fix: bound detection QA to inference coverage
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-15 00:35:39 +02:00
parent 2f9898bc82
commit 0cad8fdf76
12 changed files with 159 additions and 45 deletions
+7 -2
View File
@@ -129,9 +129,14 @@ class DetectionQaService:
def filter_population(
geometries: list[tuple[dict[str, Any], BaseGeometry]],
coverage: DetectionQaCoverage,
*,
raw_count: int | None = None,
) -> CoveragePopulation:
evaluated: list[tuple[dict[str, Any], BaseGeometry]] = []
excluded_outside_count = 0
resolved_raw_count = len(geometries) if raw_count is None else raw_count
if resolved_raw_count < len(geometries):
raise ValueError("raw_count cannot be smaller than the supplied geometry population")
excluded_outside_count = resolved_raw_count - len(geometries)
clipped_boundary_count = 0
for feature, geometry in geometries:
@@ -157,7 +162,7 @@ class DetectionQaService:
return CoveragePopulation(
geometries=evaluated,
raw_count=len(geometries),
raw_count=resolved_raw_count,
evaluated_count=len(evaluated),
excluded_outside_count=excluded_outside_count,
clipped_boundary_count=clipped_boundary_count,
+37 -20
View File
@@ -9,6 +9,7 @@ from typing import Type
from geoalchemy2.shape import from_shape, to_shape
from shapely.geometry import mapping, shape
from sqlalchemy import func
from app.core.config import Settings, get_settings
from app.core.errors import AppError
@@ -288,18 +289,8 @@ class DetectionService:
class_name=class_name,
min_confidence=min_confidence,
)
references = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id).all()
if not references:
raise AppError(
code="REFERENCE_FEATURES_NOT_FOUND",
message="Reference dataset has no persisted vector features for QA",
status_code=422,
)
raw_candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in detections]
raw_reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
candidate_geometries = raw_candidate_geometries
reference_geometries = raw_reference_geometries
run_parameters = run.parameters_json if isinstance(run.parameters_json, dict) else {}
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
resolved_settings = get_settings()
@@ -314,6 +305,34 @@ class DetectionService:
status_code=422,
)
coverage = None
if manifest_path:
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles)
coverage = DetectionQaService.build_tile_coverage(
manifest,
manifest_path=manifest_path,
expected_dataset_id=run.dataset_id,
)
reference_query = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id)
if coverage is not None and hasattr(reference_query, "count"):
reference_raw_count = reference_query.count()
references = reference_query.filter(
func.ST_Intersects(VectorFeature.geometry, from_shape(coverage.geometry, srid=4326))
).all()
else:
references = reference_query.all()
reference_raw_count = len(references)
if reference_raw_count == 0:
raise AppError(
code="REFERENCE_FEATURES_NOT_FOUND",
message="Reference dataset has no persisted vector features for QA",
status_code=422,
)
raw_reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
reference_geometries = raw_reference_geometries
coverage_summary: dict[str, Any] = {
"applied": False,
"mode": "unbounded_no_manifest",
@@ -324,21 +343,19 @@ class DetectionService:
"candidate_evaluated_count": len(raw_candidate_geometries),
"candidate_excluded_outside_count": 0,
"candidate_clipped_boundary_count": 0,
"reference_raw_count": len(raw_reference_geometries),
"reference_raw_count": reference_raw_count,
"reference_evaluated_count": len(raw_reference_geometries),
"reference_excluded_outside_count": 0,
"reference_clipped_boundary_count": 0,
}
coverage_warnings: list[str] = []
if manifest_path:
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles)
coverage = DetectionQaService.build_tile_coverage(
manifest,
manifest_path=manifest_path,
expected_dataset_id=run.dataset_id,
)
if coverage is not None:
candidate_population = DetectionQaService.filter_population(raw_candidate_geometries, coverage)
reference_population = DetectionQaService.filter_population(raw_reference_geometries, coverage)
reference_population = DetectionQaService.filter_population(
raw_reference_geometries,
coverage,
raw_count=reference_raw_count,
)
candidate_geometries = candidate_population.geometries
reference_geometries = reference_population.geometries
if not reference_geometries:
@@ -434,7 +451,7 @@ class DetectionService:
"candidate_feature_count": len(candidate_geometries),
"reference_feature_count": len(reference_geometries),
"candidate_feature_count_raw": len(raw_candidate_geometries),
"reference_feature_count_raw": len(raw_reference_geometries),
"reference_feature_count_raw": reference_raw_count,
"matches": evidence.matches,
"false_positives": evidence.false_positives,
"false_negatives": evidence.false_negatives,
+9 -5
View File
@@ -8,6 +8,7 @@ from uuid import UUID
from geoalchemy2.shape import to_shape
from shapely.geometry import GeometryCollection
from shapely.geometry.base import BaseGeometry
from shapely.strtree import STRtree
from shapely.ops import unary_union
from shapely.validation import make_valid
from shapely.geometry import shape
@@ -160,7 +161,10 @@ class QaService:
],
)
unmatched_reference_indices = set(range(len(reference_supported)))
reference_tree = STRtree([geometry for _, _, geometry in reference_supported])
unmatched_reference_indices = {
index for index, (_, _, geometry) in enumerate(reference_supported) if geometry.area > 0
}
evidence = QaMatchEvidence(warnings=[f"Unsupported geometry types: {unsupported}"] if unsupported else [], unsupported=bool(unsupported))
for source_index, source_feature, source_geom in source_supported:
@@ -172,11 +176,11 @@ class QaService:
best_iou = 0.0
best_index = None
for reference_index in list(unmatched_reference_indices):
_, _, reference_geom = reference_supported[reference_index]
if reference_geom.area <= 0:
unmatched_reference_indices.discard(reference_index)
candidate_reference_indices = sorted(int(index) for index in reference_tree.query(source_geom))
for reference_index in candidate_reference_indices:
if reference_index not in unmatched_reference_indices:
continue
_, _, reference_geom = reference_supported[reference_index]
try:
intersection = source_geom.intersection(reference_geom)
except Exception as exc: # pragma: no cover - robustness path