Files
geointel/backend/tests/test_sprint8c_detection_visualization_qa.py
T
JensandClaude Opus 5 08188005bd correct the tiled inference chain and move runs off the request thread
Tile handling produced results that were wrong before any model quality
question arose:

- orthophoto tiles reached the model through PIL convert("RGB"), which
  truncates the high byte of a 16-bit product and treats a 4-band RGB+NIR
  tile's infrared channel as colour. Tiles are now read with rasterio, the
  visible bands are chosen explicitly, and values are percentile-stretched
  across all three bands together so hue is preserved;
- an object wider than the tile overlap was truncated by both tiles into two
  boxes that barely intersect, so IoU suppression kept both: two false
  positives and one missed footprint per seam building. Suppression now also
  compares overlap against the smaller box, and boxes cut by an interior tile
  edge are dropped in favour of the neighbouring tile's complete view;
- georeferencing fell back to an assumed EPSG:4326 when a manifest carried no
  CRS, producing geometry that renders plausibly in the wrong place. QA
  already refused such a tile; inference now fails closed too.

Segmentation QA scored candidates against every reference feature in the
dataset, so every building outside the inferred tiles counted as a false
negative. It now applies the same persisted tile coverage that detection QA
has always used, including the indexed ST_Intersects prefilter.

Duplicate suppression uses an STRtree instead of the O(n^2) scan, tiles are
predicted in batches of YOLO_BATCH_SIZE (a setting that existed but was never
read), and detection/segmentation runs can be queued through /run-async for a
polling background worker rather than holding an HTTP worker thread for
minutes of GPU work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-22 14:32:44 +02:00

588 lines
21 KiB
Python

from __future__ import annotations
import json
from datetime import UTC, datetime
from uuid import uuid4
import pytest
from fastapi.testclient import TestClient
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon, box
from app.core.errors import AppError
from app.main import app
from app.db.session import get_db
from app.models import AnalysisRun, Dataset, Detection, Metric, QualityCheck, SourceRegistry, SourceSnapshot, VectorFeature
from app.services.detection_service import DetectionService
class FakeQuery:
def __init__(self, rows):
self.rows = list(rows)
def filter(self, *criteria):
for criterion in criteria:
left = getattr(criterion, "left", None)
right = getattr(criterion, "right", None)
operator = getattr(criterion, "operator", None)
name = getattr(left, "name", None)
value = getattr(right, "value", right)
if name and operator:
if operator.__name__ == "eq":
self.rows = [row for row in self.rows if getattr(row, name) == value]
elif operator.__name__ == "ge":
self.rows = [row for row in self.rows if getattr(row, name) >= value]
return self
def order_by(self, *_args):
return self
def all(self):
return list(self.rows)
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:
self.objects = objects or {}
self.query_rows = query_rows or {}
self.added = []
self.commits = 0
self.refreshes = []
def get(self, model, item_id):
return self.objects.get((model, item_id))
def query(self, model):
return FakeQuery(self.query_rows.get(model, []))
def add(self, item) -> None:
self.added.append(item)
if getattr(item, "id", None) is not None:
self.objects[(item.__class__, item.id)] = item
def commit(self) -> None:
self.commits += 1
def refresh(self, item) -> None:
self.refreshes.append(item)
def _detection(project_id, dataset_id, analysis_run_id, class_name="building", confidence=0.91, geom=None):
return Detection(
id=uuid4(),
project_id=project_id,
dataset_id=dataset_id,
analysis_run_id=analysis_run_id,
job_id=uuid4(),
model_name="yolo-configured",
model_version="local-test",
class_name=class_name,
confidence=confidence,
geometry=from_shape(geom or box(4.0, 51.0, 4.1, 51.1), srid=4326),
bbox_json={"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12},
source_tile_path="storage/tiles/tile_0000.tif",
)
def _source_dataset(project_id, dataset_id):
return Dataset(
id=dataset_id,
project_id=project_id,
name="source.tif",
dataset_type="raster",
source="test",
source_name="test",
)
def _authoritative_reference(dataset: Dataset) -> Dataset:
"""Model the reference as a fully governed GRB fixture, never test data."""
source_id = uuid4()
snapshot_id = uuid4()
checksum = "a" * 64
source = SourceRegistry(
id=source_id,
source_key="grb",
display_name="GRB QA fixture",
classification="authoritative",
authority_name="Digitaal Vlaanderen",
authority_scope_json={"zone": "Flanders"},
usage_policy_json={"ground_truth_allowed": True, "validation_authority": {"building_validation": "primary"}},
)
snapshot = SourceSnapshot(
id=snapshot_id,
source_registry_id=source_id,
snapshot_key=f"detection-qa-{dataset.id}",
checksum_sha256=checksum,
ingest_status="ingested",
freshness_status="current",
)
dataset.source = "grb"
dataset.source_name = "grb"
dataset.dataset_role = "reference"
dataset.status = "ready"
dataset.checksum_sha256 = checksum
dataset.source_registry_id = source_id
dataset.source_snapshot_id = snapshot_id
dataset.data_contract_key = "geointel.vector.geojson"
dataset.data_contract_version = "1.0.0"
dataset.validation_status = "passed"
dataset.provenance_status = "complete"
dataset.lineage_status = "complete"
dataset.quarantine_status = "not_quarantined"
dataset.source_registry = source
dataset.source_snapshot = snapshot
return dataset
def test_detection_geojson_feature_collection_shape() -> None:
project_id = uuid4()
dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id)
db = FakeSession(
objects={(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, analysis_type="detection", status="success", parameters_json={})},
query_rows={Detection: [detection]},
)
feature_collection = DetectionService.detections_to_geojson(db, analysis_run_id=analysis_run_id)
assert feature_collection["type"] == "FeatureCollection"
assert len(feature_collection["features"]) == 1
feature = feature_collection["features"][0]
assert feature["geometry"]["type"] == "Polygon"
assert feature["properties"]["detection_id"] == str(detection.id)
assert feature["properties"]["class_name"] == "building"
assert feature["properties"]["confidence"] == 0.91
assert feature["properties"]["model_name"] == "yolo-configured"
assert feature["properties"]["analysis_run_id"] == str(analysis_run_id)
assert feature["properties"]["dataset_id"] == str(dataset_id)
assert feature["properties"]["job_id"] == str(detection.job_id)
assert feature["properties"]["source_tile_path"] == "storage/tiles/tile_0000.tif"
assert feature["properties"]["bbox_json"] == {"x_min": 1, "y_min": 2, "x_max": 10, "y_max": 12}
def test_detection_list_filters_by_dataset_class_and_confidence() -> None:
project_id = uuid4()
dataset_id = uuid4()
other_dataset_id = uuid4()
analysis_run_id = uuid4()
rows = [
_detection(project_id, dataset_id, analysis_run_id, "building", 0.91),
_detection(project_id, dataset_id, analysis_run_id, "road", 0.95),
_detection(project_id, dataset_id, analysis_run_id, "building", 0.25),
_detection(project_id, other_dataset_id, analysis_run_id, "building", 0.99),
]
db = FakeSession(
objects={(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, analysis_type="detection", status="success", parameters_json={})},
query_rows={Detection: rows},
)
result = DetectionService.list_detections(
db,
analysis_run_id=analysis_run_id,
dataset_id=dataset_id,
class_name="building",
min_confidence=0.5,
)
assert result.total == 1
assert result.items[0].class_name == "building"
assert result.items[0].confidence == 0.91
def test_detection_detail_returns_one_detection() -> None:
project_id = uuid4()
dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id)
db = FakeSession(objects={(Detection, detection.id): detection})
result = DetectionService.get_detection(db, detection.id)
assert result.id == detection.id
assert result.class_name == "building"
def test_detection_models_api_envelope_still_canonical() -> None:
response = TestClient(app).get("/api/v1/detection/models")
assert response.status_code == 200
assert "data" in response.json()
assert "models" in response.json()["data"]
def test_detection_geojson_api_uses_canonical_envelope(monkeypatch) -> None:
analysis_run_id = uuid4()
monkeypatch.setattr(
"app.api.routes.detection.DetectionService.detections_to_geojson",
lambda *_args, **_kwargs: {"type": "FeatureCollection", "features": []},
)
app.dependency_overrides[get_db] = lambda: FakeSession()
try:
response = TestClient(app).get(f"/api/v1/detection/runs/{analysis_run_id}/geojson")
finally:
app.dependency_overrides.pop(get_db, None)
assert response.status_code == 200
assert response.json() == {"data": {"type": "FeatureCollection", "features": []}}
def test_detection_qa_persists_quality_check_and_metrics() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
))
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(0, 0, 1, 1), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
quality_checks = [item for item in db.added if isinstance(item, QualityCheck)]
metrics = [item for item in db.added if isinstance(item, Metric)]
assert result["matches"] == 1
assert result["precision"] == 1.0
assert result["recall"] == 1.0
assert result["f1_score"] == 1.0
assert result["quality_check_id"] == str(quality_checks[0].id)
assert quality_checks[0].analysis_run_id == analysis_run_id
assert quality_checks[0].reference_dataset_id == reference_dataset_id
assert quality_checks[0].parameters_json["temporal_compatibility"]["status"] == "compatible"
assert quality_checks[0].findings_json["temporal_compatibility"]["status"] == "compatible"
assert [metric.metric_key for metric in metrics] == [
"precision",
"recall",
"f1",
"mean_iou",
"false_positive_count",
"false_negative_count",
# Threshold-independent metrics, so two models can be compared without
# both having to be read at the same confidence cut.
"average_precision",
"best_f1",
"best_f1_threshold",
]
def test_detection_qa_rejects_non_overlapping_historical_reference_editions() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
source_dataset = _source_dataset(project_id, dataset_id)
source_dataset.source_name = "digitaal_vlaanderen_orthophoto"
source_dataset.source_metadata = {"product_key": "2020", "supports_detection": False}
source_dataset.valid_from = datetime(2020, 1, 1, tzinfo=UTC)
source_dataset.valid_to = datetime(2020, 12, 31, 23, 59, 59, tzinfo=UTC)
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="current-grb.geojson",
dataset_type="vector",
source="grb",
source_name="grb",
dataset_role="reference",
valid_from=datetime(2026, 7, 1, tzinfo=UTC),
valid_to=datetime(2026, 7, 31, 23, 59, 59, tzinfo=UTC),
))
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(
id=analysis_run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
parameters_json={},
),
(Dataset, dataset_id): source_dataset,
(Dataset, reference_dataset_id): reference_dataset,
},
)
with pytest.raises(AppError) as exc_info:
DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
)
assert exc_info.value.code == "DETECTION_QA_TEMPORAL_MISMATCH"
assert db.added == []
def test_detection_qa_no_match_case_persists_zero_scores() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0, 0, 1, 1))
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
))
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(10, 10, 11, 11), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(id=analysis_run_id, project_id=project_id, dataset_id=dataset_id, analysis_type="detection", status="success", parameters_json={}),
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert result["matches"] == 0
assert result["false_positives"] == 1
assert result["false_negatives"] == 1
assert result["precision"] == 0.0
assert result["recall"] == 0.0
assert result["f1_score"] == 0.0
def test_configured_yolo_qa_requires_persisted_tile_manifest_provenance() -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
detection = _detection(project_id, dataset_id, analysis_run_id)
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
))
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(4.0, 51.0, 4.1, 51.1), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(
id=analysis_run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
parameters_json={"model_id": "yolo-configured"},
),
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
with pytest.raises(AppError) as exc_info:
DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
assert exc_info.value.code == "DETECTION_QA_COVERAGE_UNAVAILABLE"
assert db.added == []
def _coverage_manifest(tmp_path, dataset_id, bounds=(-1.0, -1.0, 3.0, 3.0)):
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(
json.dumps(
{
"source_dataset_id": str(dataset_id),
"crs": "EPSG:4326",
"tiles": [
{
"index": 0,
"path": "tile_0000.tif",
"bounds": list(bounds),
"crs": "EPSG:4326",
}
],
}
),
encoding="utf-8",
)
return manifest_path
def test_detection_qa_excludes_references_outside_persisted_tile_coverage(tmp_path) -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
manifest_path = _coverage_manifest(tmp_path, dataset_id, bounds=(0.0, 0.0, 1.0, 1.0))
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0.1, 0.1, 0.9, 0.9))
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
))
inside_reference = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(0.1, 0.1, 0.9, 0.9), srid=4326),
)
outside_reference = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(box(10.0, 10.0, 11.0, 11.0), srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(
id=analysis_run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
parameters_json={
"model_id": "yolo-configured",
"tile_manifest_path": str(manifest_path),
},
),
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [inside_reference, outside_reference]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
quality_check = next(item for item in db.added if isinstance(item, QualityCheck))
assert result["matches"] == 1
assert result["false_negatives"] == 0
assert result["reference_feature_count_raw"] == 2
assert result["reference_feature_count"] == 1
assert result["coverage"]["applied"] is True
assert result["coverage"]["reference_excluded_outside_count"] == 1
assert quality_check.parameters_json["coverage_policy"] == "persisted_tile_manifest_union"
assert quality_check.findings_json["coverage"] == result["coverage"]
def test_detection_qa_reports_box_to_footprint_diagnostic_without_changing_strict_metrics(tmp_path) -> None:
project_id = uuid4()
dataset_id = uuid4()
reference_dataset_id = uuid4()
analysis_run_id = uuid4()
manifest_path = _coverage_manifest(tmp_path, dataset_id)
detection = _detection(project_id, dataset_id, analysis_run_id, geom=box(0.0, 0.0, 2.0, 2.0))
l_shaped_footprint = Polygon(
[(0.0, 0.0), (2.0, 0.0), (2.0, 0.4), (0.4, 0.4), (0.4, 2.0), (0.0, 2.0), (0.0, 0.0)]
)
reference_dataset = _authoritative_reference(Dataset(
id=reference_dataset_id,
project_id=project_id,
name="reference.geojson",
dataset_type="vector",
source="test",
dataset_role="reference",
))
reference_feature = VectorFeature(
id=uuid4(),
dataset_id=reference_dataset_id,
feature_class="building",
geometry=from_shape(l_shaped_footprint, srid=4326),
)
db = FakeSession(
objects={
(AnalysisRun, analysis_run_id): AnalysisRun(
id=analysis_run_id,
project_id=project_id,
dataset_id=dataset_id,
analysis_type="detection",
status="success",
model_name="yolo-configured",
parameters_json={
"model_id": "yolo-configured",
"tile_manifest_path": str(manifest_path),
},
),
(Dataset, dataset_id): _source_dataset(project_id, dataset_id),
(Dataset, reference_dataset_id): reference_dataset,
},
query_rows={Detection: [detection], VectorFeature: [reference_feature]},
)
result = DetectionService.compare_detections_with_reference(
db=db,
analysis_run_id=analysis_run_id,
reference_dataset_id=reference_dataset_id,
iou_threshold=0.5,
)
diagnostics = result["box_to_footprint_diagnostics"]
quality_check = next(item for item in db.added if isinstance(item, QualityCheck))
assert result["matches"] == 0
assert result["false_positives"] == 1
assert result["false_negatives"] == 1
assert diagnostics["diagnostic_only"] is True
assert diagnostics["envelope_matches"] == 1
assert diagnostics["possible_box_to_footprint_mismatch_count"] == 1
assert quality_check.findings_json["box_to_footprint_diagnostics"] == diagnostics