fix: bound detection QA to inference coverage
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ from shapely.geometry import box
|
||||
|
||||
from app.core.errors import AppError
|
||||
from app.services.detection_qa_service import DetectionQaService
|
||||
from app.services.qa_service import QaService
|
||||
|
||||
|
||||
def test_tile_coverage_transforms_projected_manifest_bounds_to_epsg4326() -> None:
|
||||
@@ -78,3 +79,46 @@ def test_coverage_filter_reports_outside_and_boundary_clipped_population() -> No
|
||||
assert population.excluded_outside_count == 1
|
||||
assert population.clipped_boundary_count == 1
|
||||
assert population.geometries[1][1].bounds == pytest.approx((0.8, 0.8, 1.0, 1.0))
|
||||
|
||||
|
||||
def test_coverage_filter_preserves_prefiltered_database_population_count() -> None:
|
||||
dataset_id = uuid4()
|
||||
coverage = DetectionQaService.build_tile_coverage(
|
||||
{
|
||||
"source_dataset_id": str(dataset_id),
|
||||
"crs": "EPSG:4326",
|
||||
"tiles": [{"bounds": [0.0, 0.0, 1.0, 1.0]}],
|
||||
},
|
||||
manifest_path="/app/storage/tiles/manifest.json",
|
||||
expected_dataset_id=dataset_id,
|
||||
)
|
||||
|
||||
population = DetectionQaService.filter_population(
|
||||
[
|
||||
({"id": "inside"}, box(0.1, 0.1, 0.2, 0.2)),
|
||||
({"id": "crossing"}, box(0.8, 0.8, 1.2, 1.2)),
|
||||
],
|
||||
coverage,
|
||||
raw_count=3,
|
||||
)
|
||||
|
||||
assert population.raw_count == 3
|
||||
assert population.evaluated_count == 2
|
||||
assert population.excluded_outside_count == 1
|
||||
assert population.clipped_boundary_count == 1
|
||||
|
||||
|
||||
def test_iou_matching_keeps_exact_results_with_many_spatially_disjoint_references() -> None:
|
||||
references = [({"id": f"outside-{index}"}, box(index + 10, 10, index + 10.5, 10.5)) for index in range(100)]
|
||||
references.append(({"id": "match"}, box(0.0, 0.0, 1.0, 1.0)))
|
||||
|
||||
evidence = QaService._match_io_u_evidence(
|
||||
[({"id": "candidate"}, box(0.0, 0.0, 1.0, 1.0))],
|
||||
references,
|
||||
0.5,
|
||||
)
|
||||
|
||||
assert evidence.matches == 1
|
||||
assert evidence.false_positives == 0
|
||||
assert evidence.false_negatives == 100
|
||||
assert evidence.match_evidence[0]["reference_feature_id"] == "match"
|
||||
|
||||
@@ -42,6 +42,9 @@ class FakeQuery:
|
||||
def first(self):
|
||||
return self.rows[0] if self.rows else None
|
||||
|
||||
def count(self):
|
||||
return len(self.rows)
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, objects=None, query_rows=None) -> None:
|
||||
|
||||
Reference in New Issue
Block a user