make QA scoring reproducible and threshold-independent

The greedy IoU matcher gave a reference to whichever candidate was offered
first. Row order decided that, and every detection in a run shares one
transaction timestamp, so ordering by created_at left the assignment
undefined: the same QA run over the same data produced different mean IoU,
and the geometry shown to a reviewer as a false positive could be the better
of two detections. Candidates are now ranked by confidence with feature
identity as tiebreaker, which is also the COCO/PASCAL rule.

A single precision/recall/F1 triple describes one operating point, so two
models cannot be compared from it: a conservatively calibrated model looks
worse at a low confidence cut and better at a high one without detecting
anything differently. DetectionMetricsService adds the full curve, average
precision and the threshold where F1 actually peaks.

Also:
- report the population the metrics were computed over, so
  matches + false_positives equals candidate_feature_count even under an
  area filter; raw dataset totals move to the _raw fields;
- state whether candidates are axis-aligned boxes or footprint polygons.
  A box can never reach IoU 1 against a rotated building, so the strict
  score has a ceiling that has nothing to do with detection quality.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jens
2026-08-22 14:31:21 +02:00
co-authored by Claude Opus 5
parent 918ee240d5
commit 2b968b74cf
6 changed files with 480 additions and 5 deletions
+57 -4
View File
@@ -123,6 +123,51 @@ class QaService:
return str(value)
return f"{fallback_prefix}-{index + 1}"
@staticmethod
def _feature_confidence(feature: dict[str, Any]) -> float | None:
"""Read a detector confidence from a QA feature, if the source has one.
Vector-vs-vector comparisons have no confidence at all; those fall back
to identity ordering so the result stays reproducible either way.
"""
candidates: list[Any] = [feature.get("confidence")]
properties = feature.get("properties")
if isinstance(properties, dict):
candidates.append(properties.get("confidence"))
for value in candidates:
if value is None or isinstance(value, bool):
continue
try:
confidence = float(value)
except (TypeError, ValueError):
continue
if confidence == confidence: # reject NaN
return confidence
return None
@staticmethod
def _candidate_match_order(
source_supported: list[tuple[int, dict[str, Any], BaseGeometry]],
) -> list[tuple[int, dict[str, Any], BaseGeometry]]:
"""Order candidates the way detection benchmarks do: best score first.
Greedy IoU matching gives the reference to whichever candidate is
offered first, so the input order decides both the score and which
geometry an operator sees as false-positive evidence. Database row
order is not a defensible answer to that question — every detection in
a run shares one transaction timestamp — so candidates are ranked by
confidence, with feature identity as a stable tiebreaker.
"""
def order_key(entry: tuple[int, dict[str, Any], BaseGeometry]) -> tuple[float, str, int]:
index, feature, _ = entry
confidence = QaService._feature_confidence(feature)
identifier = QaService._feature_identifier(feature, "candidate", index)
return (-(confidence if confidence is not None else 0.0), identifier, index)
return sorted(source_supported, key=order_key)
@staticmethod
def _match_io_u_evidence(
source_geometries: list[tuple[dict[str, Any], BaseGeometry]],
@@ -153,7 +198,7 @@ class QaService:
unsupported=True,
false_positive_evidence=[
{"candidate_feature_id": QaService._feature_identifier(feature, "candidate", source_index)}
for source_index, feature, _ in source_supported
for source_index, feature, _ in QaService._candidate_match_order(source_supported)
],
false_negative_evidence=[
{"reference_feature_id": QaService._feature_identifier(feature, "reference", reference_index)}
@@ -167,7 +212,7 @@ class QaService:
}
evidence = QaMatchEvidence(warnings=[f"Unsupported geometry types: {unsupported}"] if unsupported else [], unsupported=bool(unsupported))
for source_index, source_feature, source_geom in source_supported:
for source_index, source_feature, source_geom in QaService._candidate_match_order(source_supported):
source_feature_id = QaService._feature_identifier(source_feature, "candidate", source_index)
if source_geom.area <= 0:
evidence.false_positives += 1
@@ -278,6 +323,9 @@ class QaService:
dataset_ids=(candidate_dataset_id, reference_dataset_id),
)
candidate_feature_count_raw = len(candidate_payload.get("features", [])) if isinstance(candidate_payload, dict) else 0
reference_feature_count_raw = len(reference_payload.get("features", [])) if isinstance(reference_payload, dict) else 0
if area_geometry is not None:
candidate_geometries = QaService._apply_area_filter(candidate_geometries, area_geometry, dataset_id=candidate_dataset.id)
reference_geometries = QaService._apply_area_filter(reference_geometries, area_geometry, dataset_id=reference_dataset.id)
@@ -288,8 +336,11 @@ class QaService:
iou_threshold,
)
candidate_feature_count = len(candidate_payload.get("features", [])) if isinstance(candidate_payload, dict) else 0
reference_feature_count = len(reference_payload.get("features", [])) if isinstance(reference_payload, dict) else 0
# Report the population the metrics were computed over, not the raw
# dataset totals: with an area filter the two differ, and a count that
# disagrees with matches + false positives is unreadable as evidence.
candidate_feature_count = len(candidate_geometries)
reference_feature_count = len(reference_geometries)
mean_iou = None if not evidence.match_iou_values else sum(evidence.match_iou_values) / len(evidence.match_iou_values)
precision = None
@@ -310,6 +361,8 @@ class QaService:
warnings=_extract_crs_warnings(candidate_dataset, reference_dataset) + evidence.warnings,
candidate_feature_count=candidate_feature_count,
reference_feature_count=reference_feature_count,
candidate_feature_count_raw=candidate_feature_count_raw,
reference_feature_count_raw=reference_feature_count_raw,
matches=evidence.matches,
false_positives=evidence.false_positives,
false_negatives=evidence.false_negatives,