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
+6
View File
@@ -19,8 +19,14 @@ class QaProviderComparisonRequest(BaseModel):
class QaProviderComparisonResult(BaseModel):
status: str
warnings: list[str] = Field(default_factory=list)
# Counts of the population that was actually matched, so that
# ``matches + false_positives == candidate_feature_count`` holds even when
# an area filter or an unparseable geometry removed features. The ``_raw``
# fields keep the untouched dataset totals visible next to them.
candidate_feature_count: int
reference_feature_count: int
candidate_feature_count_raw: int | None = None
reference_feature_count_raw: int | None = None
matches: int
false_positives: int
false_negatives: int
@@ -0,0 +1,175 @@
"""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,
}
+38 -1
View File
@@ -168,24 +168,61 @@ class DetectionQaService:
clipped_boundary_count=clipped_boundary_count,
)
# A polygon whose area is within this fraction of its own bounding box is
# an axis-aligned rectangle for practical purposes.
RECTANGULAR_AREA_RATIO = 0.99
@staticmethod
def candidate_geometry_mode(geometries: list[tuple[dict[str, Any], BaseGeometry]]) -> str:
"""Say whether the candidates are detector boxes or true footprints.
It matters for reading the score. An axis-aligned box can never reach
IoU 1 against a rotated or L-shaped building footprint, so a strict
footprint IoU understates a box detector by a fixed amount that has
nothing to do with whether it found the building.
"""
polygonal = [
geometry
for _, geometry in geometries
if geometry.geom_type in {"Polygon", "MultiPolygon"} and geometry.area > 0
]
if not polygonal:
return "unknown"
rectangular = sum(
1
for geometry in polygonal
if geometry.area / geometry.envelope.area >= DetectionQaService.RECTANGULAR_AREA_RATIO
)
return "axis_aligned_boxes" if rectangular == len(polygonal) else "footprint_polygons"
@staticmethod
def box_to_footprint_diagnostics(
strict_evidence: QaMatchEvidence,
envelope_evidence: QaMatchEvidence,
*,
iou_threshold: float,
candidate_geometry_mode: str = "unknown",
) -> dict[str, Any]:
envelope_metrics = DetectionQaService._metrics(envelope_evidence)
return {
diagnostics = {
"diagnostic_only": True,
"canonical_method": "candidate_polygon_vs_reference_footprint_iou",
"diagnostic_method": "candidate_polygon_vs_reference_envelope_iou",
"iou_threshold": iou_threshold,
"candidate_geometry_mode": candidate_geometry_mode,
"strict_matches": strict_evidence.matches,
"envelope_matches": envelope_evidence.matches,
"possible_box_to_footprint_mismatch_count": max(0, envelope_evidence.matches - strict_evidence.matches),
**envelope_metrics,
}
if candidate_geometry_mode == "axis_aligned_boxes":
diagnostics["interpretation"] = (
"Candidates are axis-aligned detector boxes. The strict footprint IoU therefore has a "
"ceiling below 1 for rotated or non-rectangular buildings; the envelope figures isolate "
"detection quality from that shape mismatch."
)
return diagnostics
@staticmethod
def _metrics(evidence: QaMatchEvidence) -> dict[str, Any]:
+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,
@@ -0,0 +1,116 @@
"""A single F1 at one arbitrary confidence cut cannot compare two models.
Reporting F1 at whichever threshold the operator happened to type makes two
models look better or worse depending on their calibration rather than their
detection quality. The sweep produces the standard curve instead: precision
and recall at every operating point, average precision, and the threshold
where F1 actually peaks.
"""
from __future__ import annotations
import pytest
from shapely.geometry import box
from app.services.detection_metrics_service import DetectionMetricsService
def _candidate(name: str, geometry, confidence: float):
return ({"id": name, "confidence": confidence}, geometry)
def _reference(name: str, geometry):
return ({"id": name}, geometry)
def test_perfect_detector_reaches_average_precision_one() -> None:
references = [_reference("r1", box(0, 0, 1, 1)), _reference("r2", box(5, 5, 6, 6))]
candidates = [
_candidate("c1", box(0, 0, 1, 1), 0.9),
_candidate("c2", box(5, 5, 6, 6), 0.8),
]
curve = DetectionMetricsService.precision_recall_curve(candidates, references, iou_threshold=0.5)
assert curve["average_precision"] == pytest.approx(1.0)
assert curve["best_f1"] == pytest.approx(1.0)
assert curve["reference_count"] == 2
def test_low_confidence_false_positive_is_only_penalised_below_its_threshold() -> None:
references = [_reference("r1", box(0, 0, 1, 1))]
candidates = [
_candidate("hit", box(0, 0, 1, 1), 0.9),
_candidate("junk", box(20, 20, 21, 21), 0.2),
]
curve = DetectionMetricsService.precision_recall_curve(candidates, references, iou_threshold=0.5)
# Cutting at 0.2 admits the junk box, so precision there is 0.5.
low = next(point for point in curve["points"] if point["confidence_threshold"] == pytest.approx(0.2))
assert low["precision"] == pytest.approx(0.5)
assert low["recall"] == pytest.approx(1.0)
# The optimum simply drops it.
assert curve["best_f1"] == pytest.approx(1.0)
assert curve["best_f1_threshold"] == pytest.approx(0.9)
# AP is computed over the ranking, so one trailing false positive after
# full recall does not reduce it.
assert curve["average_precision"] == pytest.approx(1.0)
def test_ranking_quality_is_visible_in_average_precision() -> None:
"""A detector that ranks its mistake above its hit scores worse."""
references = [_reference("r1", box(0, 0, 1, 1))]
good = DetectionMetricsService.precision_recall_curve(
[_candidate("hit", box(0, 0, 1, 1), 0.9), _candidate("junk", box(20, 20, 21, 21), 0.1)],
references,
iou_threshold=0.5,
)
bad = DetectionMetricsService.precision_recall_curve(
[_candidate("hit", box(0, 0, 1, 1), 0.1), _candidate("junk", box(20, 20, 21, 21), 0.9)],
references,
iou_threshold=0.5,
)
assert good["average_precision"] > bad["average_precision"]
assert bad["average_precision"] == pytest.approx(0.5)
def test_missed_reference_caps_recall_and_average_precision() -> None:
references = [_reference("r1", box(0, 0, 1, 1)), _reference("r2", box(9, 9, 10, 10))]
candidates = [_candidate("hit", box(0, 0, 1, 1), 0.9)]
curve = DetectionMetricsService.precision_recall_curve(candidates, references, iou_threshold=0.5)
assert curve["points"][0]["recall"] == pytest.approx(0.5)
assert curve["average_precision"] == pytest.approx(0.5)
assert curve["best_f1"] == pytest.approx(2 / 3)
def test_curve_is_independent_of_input_order() -> None:
references = [_reference("r1", box(0, 0, 1, 1)), _reference("r2", box(5, 5, 6, 6))]
candidates = [
_candidate("c1", box(0, 0, 1, 1), 0.9),
_candidate("c2", box(5, 5, 6, 6), 0.4),
_candidate("c3", box(30, 30, 31, 31), 0.6),
]
forward = DetectionMetricsService.precision_recall_curve(candidates, references, iou_threshold=0.5)
reverse = DetectionMetricsService.precision_recall_curve(
list(reversed(candidates)), list(reversed(references)), iou_threshold=0.5
)
assert forward == reverse
def test_empty_candidate_population_is_reported_not_crashed() -> None:
curve = DetectionMetricsService.precision_recall_curve(
[], [_reference("r1", box(0, 0, 1, 1))], iou_threshold=0.5
)
assert curve["average_precision"] == 0.0
assert curve["best_f1"] == 0.0
assert curve["best_f1_threshold"] is None
assert curve["points"] == []
@@ -0,0 +1,88 @@
"""QA matching must be reproducible and must credit the best candidate.
The greedy IoU matcher decides which candidate is reported as a match and
which becomes false-positive evidence for an operator. If that decision
depends on database row order, the same run produces different scores and
points reviewers at the wrong geometry.
"""
from __future__ import annotations
from shapely.geometry import box
from app.services.qa_service import QaService
REFERENCE = [({"id": "R1"}, box(0.0, 0.0, 10.0, 10.0))]
# Two detections of the same building. ``sloppy`` is far too tall (IoU 0.51),
# ``accurate`` is nearly exact (IoU 0.96).
SLOPPY = box(0.0, 0.0, 10.0, 19.5)
ACCURATE = box(0.0, 0.0, 10.0, 10.4)
def _candidates(order: list[tuple[str, float]]) -> list[tuple[dict, object]]:
geometries = {"sloppy": SLOPPY, "accurate": ACCURATE}
return [
({"id": name, "confidence": confidence}, geometries[name])
for name, confidence in order
]
def test_matching_is_independent_of_candidate_row_order() -> None:
forward = QaService._match_io_u_evidence(
_candidates([("sloppy", 0.42), ("accurate", 0.91)]), REFERENCE, 0.5
)
reverse = QaService._match_io_u_evidence(
_candidates([("accurate", 0.91), ("sloppy", 0.42)]), REFERENCE, 0.5
)
assert forward.matches == reverse.matches
assert forward.false_positives == reverse.false_positives
assert forward.false_negatives == reverse.false_negatives
assert forward.match_iou_values == reverse.match_iou_values
assert forward.match_evidence == reverse.match_evidence
assert forward.false_positive_evidence == reverse.false_positive_evidence
def test_highest_confidence_candidate_claims_the_reference() -> None:
evidence = QaService._match_io_u_evidence(
_candidates([("sloppy", 0.42), ("accurate", 0.91)]), REFERENCE, 0.5
)
assert evidence.matches == 1
assert evidence.match_evidence[0]["candidate_feature_id"] == "accurate"
assert evidence.false_positive_evidence == [{"candidate_feature_id": "sloppy"}]
assert evidence.match_iou_values[0] > 0.9
def test_matching_without_confidence_is_still_deterministic() -> None:
"""Vector-vs-vector QA has no confidence; identity keeps it reproducible."""
left = [
({"id": "b-second"}, SLOPPY),
({"id": "a-first"}, ACCURATE),
]
right = list(reversed(left))
assert QaService._match_io_u_evidence(left, REFERENCE, 0.5).match_evidence == (
QaService._match_io_u_evidence(right, REFERENCE, 0.5).match_evidence
)
def test_evidence_is_ordered_by_confidence_for_review() -> None:
evidence = QaService._match_io_u_evidence(
[
({"id": "low", "confidence": 0.30}, box(30.0, 30.0, 31.0, 31.0)),
({"id": "high", "confidence": 0.95}, box(40.0, 40.0, 41.0, 41.0)),
({"id": "mid", "confidence": 0.60}, box(50.0, 50.0, 51.0, 51.0)),
],
REFERENCE,
0.5,
)
assert [item["candidate_feature_id"] for item in evidence.false_positive_evidence] == [
"high",
"mid",
"low",
]