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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user