Files
geointel/backend/app/services/detection_metrics_service.py
T
JensandClaude Opus 5 2b968b74cf 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>
2026-08-22 14:31:21 +02:00

176 lines
7.3 KiB
Python

"""Threshold-independent detection metrics.
A single precision/recall/F1 triple describes one operating point. Which point
that is depends on the confidence threshold the operator typed, so two models
cannot be compared from it: a conservatively calibrated model looks worse at a
low cut and better at a high one without detecting anything differently.
This service produces the standard alternative — the full precision/recall
curve over every confidence value that occurs in the run, the average
precision derived from it, and the threshold where F1 actually peaks — using
the same greedy IoU matching rule as the rest of QA so the numbers stay
comparable with the persisted quality checks.
"""
from __future__ import annotations
from typing import Any
from shapely.geometry.base import BaseGeometry
from shapely.strtree import STRtree
from app.services.qa_service import QaService
class DetectionMetricsService:
SUPPORTED_GEOMETRY_TYPES = QaService.SUPPORTED_GEOMETRY_TYPES
@staticmethod
def _rank_candidates(
candidates: list[tuple[dict[str, Any], BaseGeometry]],
) -> list[tuple[float, str, dict[str, Any], BaseGeometry]]:
"""Order candidates by confidence, highest first, deterministically."""
ranked: list[tuple[float, str, dict[str, Any], BaseGeometry]] = []
for index, (feature, geometry) in enumerate(candidates):
if geometry.is_empty or geometry.geom_type not in DetectionMetricsService.SUPPORTED_GEOMETRY_TYPES:
continue
if geometry.area <= 0:
continue
confidence = QaService._feature_confidence(feature)
identifier = QaService._feature_identifier(feature, "candidate", index)
ranked.append((float(confidence if confidence is not None else 0.0), identifier, feature, geometry))
ranked.sort(key=lambda item: (-item[0], item[1]))
return ranked
@staticmethod
def _greedy_hits(
ranked: list[tuple[float, str, dict[str, Any], BaseGeometry]],
references: list[tuple[dict[str, Any], BaseGeometry]],
iou_threshold: float,
) -> tuple[list[bool], int]:
"""Mark each ranked candidate as a hit or a miss, best score first.
Walking the ranking once and consuming references as they are claimed
is exactly the COCO/PASCAL rule, and it is what makes the result
independent of the order rows came out of the database.
"""
supported = [
(index, geometry)
for index, (_, geometry) in enumerate(references)
if geometry.geom_type in DetectionMetricsService.SUPPORTED_GEOMETRY_TYPES
and not geometry.is_empty
and geometry.area > 0
]
reference_count = len(supported)
if not supported:
return [False] * len(ranked), 0
tree = STRtree([geometry for _, geometry in supported])
claimed: set[int] = set()
hits: list[bool] = []
for _, _, _, geometry in ranked:
best_iou = 0.0
best_index: int | None = None
for position in sorted(int(value) for value in tree.query(geometry)):
if position in claimed:
continue
_, reference_geometry = supported[position]
intersection_area = geometry.intersection(reference_geometry).area
if intersection_area <= 0:
continue
union_area = geometry.area + reference_geometry.area - intersection_area
if union_area <= 0:
continue
iou = intersection_area / union_area
if iou > best_iou:
best_iou = iou
best_index = position
if best_index is not None and best_iou >= iou_threshold:
claimed.add(best_index)
hits.append(True)
else:
hits.append(False)
return hits, reference_count
@staticmethod
def _average_precision(points: list[dict[str, Any]]) -> float:
"""Area under the precision/recall curve, with precision made monotone.
Interpolating precision to its running maximum from the right is the
VOC/COCO convention; without it the sawtooth from individual false
positives shows up as noise in the score.
"""
if not points:
return 0.0
recalls = [0.0] + [point["recall"] for point in points]
precisions = [1.0] + [point["precision"] for point in points]
for index in range(len(precisions) - 2, -1, -1):
precisions[index] = max(precisions[index], precisions[index + 1])
area = 0.0
for index in range(1, len(recalls)):
area += (recalls[index] - recalls[index - 1]) * precisions[index]
return area
@staticmethod
def precision_recall_curve(
candidates: list[tuple[dict[str, Any], BaseGeometry]],
references: list[tuple[dict[str, Any], BaseGeometry]],
*,
iou_threshold: float,
) -> dict[str, Any]:
"""Sweep every confidence value present and describe the whole curve."""
ranked = DetectionMetricsService._rank_candidates(candidates)
hits, reference_count = DetectionMetricsService._greedy_hits(ranked, references, iou_threshold)
points: list[dict[str, Any]] = []
true_positives = 0
for position, hit in enumerate(hits):
if hit:
true_positives += 1
false_positives = position + 1 - true_positives
precision = true_positives / (position + 1)
recall = true_positives / reference_count if reference_count else 0.0
f1 = (2 * precision * recall / (precision + recall)) if precision + recall > 0 else 0.0
points.append(
{
"confidence_threshold": ranked[position][0],
"candidate_count": position + 1,
"true_positives": true_positives,
"false_positives": false_positives,
"false_negatives": max(0, reference_count - true_positives),
"precision": precision,
"recall": recall,
"f1_score": f1,
}
)
# Keep one point per distinct threshold: the last one, which is the
# complete tally for everything at or above that confidence.
deduplicated: list[dict[str, Any]] = []
for point in points:
if deduplicated and deduplicated[-1]["confidence_threshold"] == point["confidence_threshold"]:
deduplicated[-1] = point
else:
deduplicated.append(point)
best = max(deduplicated, key=lambda point: (point["f1_score"], point["confidence_threshold"]), default=None)
return {
"iou_threshold": iou_threshold,
"reference_count": reference_count,
"candidate_count": len(ranked),
"average_precision": DetectionMetricsService._average_precision(points),
"best_f1": best["f1_score"] if best else 0.0,
"best_f1_threshold": best["confidence_threshold"] if best else None,
"best_f1_precision": best["precision"] if best else None,
"best_f1_recall": best["recall"] if best else None,
"points": deduplicated,
}