From c5c01a3edfd317137427ea22710bcdde34b65255 Mon Sep 17 00:00:00 2001 From: Mo Kamel Date: Sun, 30 Aug 2026 18:07:43 +0200 Subject: [PATCH 1/6] Validate zip members before extracting KITTI fetch_kitti called zf.extractall() with no member validation while its sibling fetch_vkitti already used tarfile's filter="data". ZipFile has no equivalent, so the check is written out: an entry resolving outside the target directory now refuses to unpack. The archives come from a fixed official URL, so this guards against that URL or host changing rather than against KITTI. --- scripts/fetch_kitti.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/scripts/fetch_kitti.py b/scripts/fetch_kitti.py index 98961dc..2ce2879 100644 --- a/scripts/fetch_kitti.py +++ b/scripts/fetch_kitti.py @@ -96,6 +96,20 @@ def download(archive: str, into: Path, expected: int) -> Path: def extract(archive: Path, into: Path, must_contain: str) -> None: print(f" extracting {archive.name}") with zipfile.ZipFile(archive) as zf: + # Every member is checked to land inside `into` before anything is + # written. ZipFile has no equivalent of tarfile's filter="data", which + # is what fetch_vkitti.py uses, so the check is written out here. An + # entry named "../../etc/thing" or an absolute path would otherwise + # extract outside the data directory; these archives come from a fixed + # official URL, so this is a guard against the URL or the host + # changing, not against KITTI. + root = into.resolve() + for member in zf.infolist(): + destination = (root / member.filename).resolve() + if destination != root and root not in destination.parents: + raise SystemExit( + f"{archive.name} contains {member.filename!r}, which would " + f"extract outside {into}. Refusing to unpack it.") zf.extractall(into) if not (into / must_contain).is_dir(): raise SystemExit( From 65854c19dfab777d0e16a9131f2abd19ebfb3121 Mon Sep 17 00:00:00 2001 From: Mohamed Kamel Date: Mon, 31 Aug 2026 12:29:54 +0200 Subject: [PATCH 2/6] One matcher, a taxonomy for the false positives, and more than AP per slice Three roadmap items, and the first two turn out to be the same refactor. ONE MATCHER. scripts/render_demo.py carried its own copy of the greedy descending-score assignment loop. Close to the real one and not identical: it had no notion of neutral ground truth, so a box on a Person_sitting counted as claiming nothing in the picture while the metric declined to score it at all. The demo and the reported number could disagree about the same frame and nothing in the repository could notice. ape.match.judge_frame now does the matching and keeps the per-object detail; assign_frame is a view of it that throws the detail away, and the demo asks the same function the metric does. tests/test_outcomes.py asserts the two cannot diverge across seven frame shapes, including the ones where matching order decides the answer. The pycocotools comparison still agrees, which is the real evidence the refactor changed nothing: 238 tests pass, up from 218. A TAXONOMY FOR THE FALSE POSITIVES. ape.localisation already splits the MISSES into "never seen" and "seen and boxed badly" and explains why that matters. The false positives had no such split, so a detector that fires twice on one pedestrian and a detector that invents pedestrians in empty road produced the same number. ape.outcomes sorts every wrong box into duplicate, misclassified, mislocalised or hallucinated, reading each verdict off the match the metric already performed rather than matching a second time. Measured on KITTI with yolov8s: Car 33752 FP 1% duplicate 0% misclassified 16% mislocalised 83% hallucinated Pedestrian 10808 FP 0% duplicate 6% misclassified 8% mislocalised 86% hallucinated Four fifths of the wrong boxes are on nothing at all, which is the category a safety argument cares about most: the only one that makes a vehicle brake for empty road, and the only one with no cause visible in the ground truth. Duplicates are almost absent, so NMS is not the problem. MORE THAN AP PER SLICE. false_negative_rate and recall_at_precision, both read off the precision envelope for the same reason AP is. Reported by the CLI and carried in results.json. Car FNR 18.2% recall at 90% precision 64.7% at 50% 80.9% Pedestrian FNR 31.3% recall at 90% precision 0.2% at 50% 60.7% The pedestrian row is the finding, and it is the argument for the whole item. An AP of 0.506 reads as a mediocre but usable detector. It is not usable at high precision at all: demand 90% precision and it returns two pedestrians in a thousand. No threshold buys both, and the aggregate hides that completely. --- README.md | 43 +++++++++ outputs/report.html | 2 +- outputs/results.json | 67 +++++++++++++ scripts/evaluate.py | 31 ++++++ scripts/render_demo.py | 34 +++---- src/ape/evaluate.py | 8 ++ src/ape/match.py | 102 +++++++++++++++++--- src/ape/metrics.py | 36 +++++++ src/ape/outcomes.py | 176 ++++++++++++++++++++++++++++++++++ tests/test_outcomes.py | 213 +++++++++++++++++++++++++++++++++++++++++ 10 files changed, 679 insertions(+), 33 deletions(-) create mode 100644 src/ape/outcomes.py create mode 100644 tests/test_outcomes.py diff --git a/README.md b/README.md index af097db..5f2e64e 100644 --- a/README.md +++ b/README.md @@ -288,6 +288,49 @@ confidently wrong one. comparable with the KITTI benchmark. This removes a limitation the README used to carry. +## And what kind of mistake was the wrong box? + +The section above explains the misses. Until now nothing explained the false +positives: every wrong box counted the same, so a detector that fires twice on +one pedestrian and a detector that invents pedestrians in empty road produced +the same number. AP cannot separate them either. + +Counted over the whole curve, so these are every box the detector emits at any +confidence, not the ones a vehicle would act on: + +| class | false positives | duplicate | misclassified | mislocalised | **hallucinated** | +|---|---|---|---|---|---| +| Car | 33752 | 266 (1%) | 81 (0%) | 5305 (16%) | **28100 (83%)** | +| Pedestrian | 10808 | 40 (0%) | 674 (6%) | 847 (8%) | **9247 (86%)** | + +**Four fifths of the wrong boxes are on nothing at all**, and that is the +category a safety argument cares about most: it is the only one that makes a +vehicle brake for empty road, and the only one whose cause is invisible in the +ground truth. Duplicates are almost absent, so non-maximum suppression is not +the problem. Misclassification is a rounding error for Car and 6% for +Pedestrian, where the confusions are with the Cyclist and Car boxes a road +scene puts people next to. + +The categories are defined in `src/ape/outcomes.py` and every one of them is +read off the same match the metric used, at the same threshold, in the same +order. Nothing here matches a second time. + +## Beyond AP: what no threshold choice can buy + +AP integrates over every operating point, which is a question no vehicle asks. +These are the ones it does ask. + +| class | false-negative rate | recall at 90% precision | recall at 50% precision | +|---|---|---|---| +| Car | 18.2% | 64.7% | 80.9% | +| Pedestrian | 31.3% | **0.2%** | 60.7% | + +**The pedestrian row is the finding.** An AP of 0.506 reads as a mediocre but +usable detector. It is not usable at high precision at all: demand 90% +precision and it returns two pedestrians in a thousand. There is no threshold +that buys both, and the aggregate hides that completely, which is the argument +for reporting more than one number per slice. + ## Where would you set the threshold? Average precision integrates over every confidence threshold at once. That is diff --git a/outputs/report.html b/outputs/report.html index 1935ae7..fd61d64 100644 --- a/outputs/report.html +++ b/outputs/report.html @@ -130,4 +130,4 @@

By position

The small figures under each number are a 95% confidence interval from resampling FRAMES, not objects: people standing in one group are not independent observations, and resampling objects would understate the range. Two cells whose intervals do not overlap differ by more than the sample explains. Two whose intervals DO overlap are not thereby shown to be the same, which is a weaker statement than it looks. A tilde still marks a cell computed from fewer than ten objects.

What this does not claim

Cyclist is reported but excluded from the headline: KITTI annotates a rider and bicycle as one box and a COCO detector emits two, so that number measures box convention as much as detection. The IoU threshold is 0.5 for every class, where KITTI's own benchmark uses 0.7 for Car. Average precision follows COCO 101-point interpolation, not KITTI 40-point, so these figures are not directly comparable to the KITTI leaderboard. SOTIF vocabulary is borrowed; its process is not performed and no compliance is claimed.
-

Generated 2026-08-02 13:15 UTC from 7481 frames, 000000 to 007480, detections kept above score 0.05. The mAP implementation is checked against pycocotools to within 0.001 by the test suite.

\ No newline at end of file +

Generated 2026-08-31 10:28 UTC from 7481 frames, 000000 to 007480, detections kept above score 0.05. The mAP implementation is checked against pycocotools to within 0.001 by the test suite.

\ No newline at end of file diff --git a/outputs/results.json b/outputs/results.json index 03fc6ec..c6e54a2 100644 --- a/outputs/results.json +++ b/outputs/results.json @@ -85,6 +85,73 @@ "mislocation_share": 0.1904127829560586 } }, + "false_positive_kinds": { + "Car": { + "total": 33752, + "counts": { + "duplicate": 266, + "misclassified": 81, + "mislocalised": 5305, + "hallucinated": 28100 + }, + "shares": { + "duplicate": 0.007881014458402466, + "misclassified": 0.002399857786205262, + "mislocalised": 0.15717587105949277, + "hallucinated": 0.8325432566958995 + } + }, + "Pedestrian": { + "total": 10808, + "counts": { + "duplicate": 40, + "misclassified": 674, + "mislocalised": 847, + "hallucinated": 9247 + }, + "shares": { + "duplicate": 0.003700962250185048, + "misclassified": 0.06236121391561806, + "mislocalised": 0.0783678756476684, + "hallucinated": 0.8555699481865285 + } + }, + "Cyclist": { + "total": 8100, + "counts": { + "duplicate": 2, + "misclassified": 50, + "mislocalised": 525, + "hallucinated": 7523 + }, + "shares": { + "duplicate": 0.0002469135802469136, + "misclassified": 0.006172839506172839, + "mislocalised": 0.06481481481481481, + "hallucinated": 0.9287654320987654 + } + } + }, + "beyond_ap": { + "Car": { + "false_negative_rate": 0.18199846913923878, + "max_recall": 0.8180015308607612, + "recall_at_precision_90": 0.6471713868206805, + "recall_at_precision_50": 0.8085380279730012 + }, + "Pedestrian": { + "false_negative_rate": 0.31268107867171835, + "max_recall": 0.6873189213282817, + "recall_at_precision_90": 0.0020057945174949856, + "recall_at_precision_50": 0.6068642745709828 + }, + "Cyclist": { + "false_negative_rate": 0.9231714812538414, + "max_recall": 0.07682851874615858, + "recall_at_precision_90": 0.0, + "recall_at_precision_50": 0.0 + } + }, "operating_points": { "Car": [ { diff --git a/scripts/evaluate.py b/scripts/evaluate.py index 84cae43..b5cbf91 100644 --- a/scripts/evaluate.py +++ b/scripts/evaluate.py @@ -22,6 +22,8 @@ from ape.classes import EVALUATED, HEADLINE # noqa: E402 from ape.evaluate import IOU, evaluate, operating_table # noqa: E402 from ape.kitti import frame_ids, load_labels # noqa: E402 +from ape.metrics import false_negative_rate, recall_at_precision # noqa: E402 +from ape.outcomes import FAILURES # noqa: E402 from ape.report import render # noqa: E402 @@ -102,6 +104,24 @@ def main() -> int: f"{diagnosis.mislocated} mislocated, {diagnosis.unseen} unseen " f"-> {diagnosis.mislocation_share:.0%} of misses are a box problem") + print("\nwhat kind of mistake was it? (AP counts every wrong box the same)") + for label in HEADLINE: + breakdown = result.outcomes[label] + parts = " ".join(f"{outcome.value} {breakdown.counts[outcome]}" + f" ({breakdown.share(outcome):.0%})" + for outcome in FAILURES) + print(f" {label:<11} {breakdown.false_positives} false positives") + print(f" {parts}") + + print("\nbeyond AP: what no threshold choice can buy") + for label in HEADLINE: + curve = result.overall[label] + print(f" {label:<11} false-negative rate " + f"{false_negative_rate(curve):.1%} at the recall ceiling") + print(f" recall at 90% precision " + f"{recall_at_precision(curve, 0.90):.1%}, " + f"at 50% precision {recall_at_precision(curve, 0.50):.1%}") + print("\nchoosing an operating point (AP integrates over all of them; " "a vehicle runs at one)") for label in HEADLINE: @@ -131,6 +151,17 @@ def main() -> int: "mislocated": d.mislocated, "unseen": d.unseen, "mislocation_share": d.mislocation_share} for k, d in result.diagnosis.items()}, + "false_positive_kinds": { + k: {"total": b.false_positives, + "counts": {o.value: b.counts[o] for o in FAILURES}, + "shares": {o.value: b.share(o) for o in FAILURES}} + for k, b in result.outcomes.items()}, + "beyond_ap": { + k: {"false_negative_rate": false_negative_rate(c), + "max_recall": c.best_recall, + "recall_at_precision_90": recall_at_precision(c, 0.90), + "recall_at_precision_50": recall_at_precision(c, 0.50)} + for k, c in result.overall.items()}, "operating_points": { label: [{"target": target, "threshold": p.threshold if p else None, diff --git a/scripts/render_demo.py b/scripts/render_demo.py index 8817ea6..3f18994 100644 --- a/scripts/render_demo.py +++ b/scripts/render_demo.py @@ -53,7 +53,7 @@ from ape.classes import neutral_labels # noqa: E402 from ape.evaluate import IOU # noqa: E402 from ape.kitti import frame_ids, load_labels # noqa: E402 -from ape.match import partition # noqa: E402 +from ape.match import judge_frame # noqa: E402 from ape.slices import dimension # noqa: E402 GREEN, RED, BLUE, GREY, INK, PAPER = ((60, 200, 120), (235, 60, 80), @@ -86,27 +86,19 @@ def match(truth, detections, label): detector did not see this" and "the detector saw it and boxed it badly", which are different failures with different fixes and looked identical in the first version of this scene. + + THIS USED TO BE ITS OWN COPY OF THE MATCHER. It reimplemented the greedy + descending-score loop from `ape.match`, closely but not identically: it had + no notion of neutral ground truth, so a box on a Person_sitting counted as + claiming nothing here while the metric declined to score it at all. The + picture and the reported number could therefore disagree about the same + frame, with nothing in the repository able to notice. It now asks the same + function the metric does, and `tests/test_outcomes.py` asserts that + function and `assign_frame` cannot diverge. """ - counts, _ = partition(truth, label, neutral_labels(label), None) - claimed: set[int] = set() - for detection in sorted(detections, key=lambda d: d.score, reverse=True): - if detection.label != label: - continue - best, index = 0.0, -1 - for i, candidate in enumerate(counts): - if i in claimed: - continue - overlap = detection.box.iou(candidate.box) - if overlap > best: - best, index = overlap, i - if index >= 0 and best >= IOU: - claimed.add(index) - - found = [g for i, g in enumerate(counts) if i in claimed] - gone = [(g, max((d.box.iou(g.box) for d in detections - if d.label == label), default=0.0)) - for i, g in enumerate(counts) if i not in claimed] - return found, gone + outcome = judge_frame(detections, truth, label, neutral_labels(label), IOU) + return outcome.found, [(item, outcome.best_overlap_on(item)) + for item in outcome.missed] def main() -> int: diff --git a/src/ape/evaluate.py b/src/ape/evaluate.py index ed5179c..0dd464f 100644 --- a/src/ape/evaluate.py +++ b/src/ape/evaluate.py @@ -20,6 +20,7 @@ from ape.match import Assignment, assign, assign_by_frame, at_difficulty, in_slice from ape.metrics import Curve, average_precision, mean_average_precision from ape.operating import Point, best_recall, sweep, threshold_for_recall +from ape.outcomes import Breakdown, classify from ape.records import Detection, Difficulty, GroundTruth from ape.slices import DIMENSIONS from ape.uncertainty import Interval, bootstrap @@ -88,6 +89,11 @@ class Evaluation: #: class -> why the objects missed at IoU 0.5 were missed. Not seen at all, #: or seen and boxed badly: different fixes, different severities. diagnosis: dict[str, Diagnosis] = field(default_factory=dict) + #: class -> what KIND of mistake each false positive was. The other half of + #: the same question: `diagnosis` explains the misses, this explains the + #: wrong boxes. An AP made of duplicates and an AP made of hallucinations + #: describe different systems and one number cannot tell them apart. + outcomes: dict[str, Breakdown] = field(default_factory=dict) @property def headline(self) -> float: @@ -146,6 +152,8 @@ def evaluate(detections: dict[str, list[Detection]], ).average_precision result.diagnosis[label] = diagnose(detections, truth, label, neutral, tight=iou) + result.outcomes[label] = classify(detections, truth, label, neutral, + iou, EVALUATED) whole = assign(detections, truth, label, neutral, iou) result.curve_points[label] = sweep(whole, len(truth)) diff --git a/src/ape/match.py b/src/ape/match.py index 973f95f..4a6863e 100644 --- a/src/ape/match.py +++ b/src/ape/match.py @@ -111,40 +111,120 @@ def partition(ground_truth: list[GroundTruth], label: str, return counts, tolerated -def assign_frame(detections: list[Detection], ground_truth: list[GroundTruth], - label: str, neutral: frozenset[str], iou_threshold: float, - counts_when: Predicate | None = None) -> Assignment: - """One frame, one class.""" +@dataclass(frozen=True) +class Judged: + """One detection, and everything the matcher learned while judging it. + + `best_free` is the overlap that decided the verdict. `best_any` includes + ground truth already claimed by a higher-scoring detection: when the second + clears the threshold and the first does not, this box found a real object + somebody else was already credited with, which is a duplicate rather than a + hallucination. Carrying both is what lets `ape.outcomes` separate those + without running the match a second time. + """ + + detection: Detection + #: True positive, ignored, or a false positive awaiting a finer verdict. + true_positive: bool + ignored: bool + #: Index into `FrameOutcome.counts`, or -1. + matched_index: int + best_free: float + best_any: float + + +@dataclass(frozen=True) +class FrameOutcome: + """The full result of matching one frame, one class. + + `Assignment` is this with the per-object detail discarded, and both come + from a single pass. The demo scene used to run its own copy of the loop + below, and a copy of a matcher is a copy that drifts: the picture and the + reported metric could disagree about the same frame with nothing to catch + it. + """ + + counts: list[GroundTruth] + tolerated: list[GroundTruth] + judged: list[Judged] + claimed: frozenset[int] + + @property + def found(self) -> list[GroundTruth]: + return [g for i, g in enumerate(self.counts) if i in self.claimed] + + @property + def missed(self) -> list[GroundTruth]: + return [g for i, g in enumerate(self.counts) if i not in self.claimed] + + def best_overlap_on(self, item: GroundTruth) -> float: + """The best overlap ANY detection of this class achieved on one object. + + What separates "never saw it" from "saw it and boxed it badly", which + are different failures with different fixes. + """ + return max((j.detection.box.iou(item.box) for j in self.judged), + default=0.0) + + def as_assignment(self) -> Assignment: + result = Assignment(positives=len(self.counts)) + for judged in self.judged: + if judged.ignored: + result.ignored += 1 + else: + result.scored.append((judged.detection.score, judged.true_positive)) + return result + + +def judge_frame(detections: list[Detection], ground_truth: list[GroundTruth], + label: str, neutral: frozenset[str], iou_threshold: float, + counts_when: Predicate | None = None) -> FrameOutcome: + """One frame, one class, keeping the per-object detail. + + THE ORDER HERE IS THE DEFINITION, see the module docstring. Anything that + needs to know what happened to a particular box calls this instead of + writing the loop again. + """ counts, tolerated = partition(ground_truth, label, neutral, counts_when) - result = Assignment(positives=len(counts)) claimed: set[int] = set() + judged: list[Judged] = [] for detection in sorted(detections, key=lambda d: d.score, reverse=True): if detection.label != label: continue - best_iou, best_index = 0.0, -1 + best_iou, best_index, best_any = 0.0, -1, 0.0 for index, candidate in enumerate(counts): + overlap = detection.box.iou(candidate.box) + best_any = max(best_any, overlap) if index in claimed: continue - overlap = detection.box.iou(candidate.box) if overlap > best_iou: best_iou, best_index = overlap, index if best_index >= 0 and best_iou >= iou_threshold: claimed.add(best_index) - result.scored.append((detection.score, True)) + judged.append(Judged(detection, True, False, best_index, + best_iou, best_any)) continue # Nothing scoreable. Before calling it a mistake, check whether it # landed on something the benchmark refuses to score. if any(detection.box.iou(item.box) >= iou_threshold for item in tolerated): - result.ignored += 1 + judged.append(Judged(detection, False, True, -1, best_iou, best_any)) continue - result.scored.append((detection.score, False)) + judged.append(Judged(detection, False, False, -1, best_iou, best_any)) + + return FrameOutcome(counts, tolerated, judged, frozenset(claimed)) - return result + +def assign_frame(detections: list[Detection], ground_truth: list[GroundTruth], + label: str, neutral: frozenset[str], iou_threshold: float, + counts_when: Predicate | None = None) -> Assignment: + """One frame, one class. A view of `judge_frame` without the detail.""" + return judge_frame(detections, ground_truth, label, neutral, + iou_threshold, counts_when).as_assignment() def assign_by_frame(detections: dict[str, list[Detection]], diff --git a/src/ape/metrics.py b/src/ape/metrics.py index 0267f44..991074b 100644 --- a/src/ape/metrics.py +++ b/src/ape/metrics.py @@ -100,6 +100,42 @@ def average_precision(assignment: Assignment) -> Curve: assignment.positives, hits, misses) +def false_negative_rate(curve: Curve) -> float: + """The share of objects no threshold choice would have found. + + The complement of the recall ceiling, and closer to what a safety argument + actually asks. AP integrates over every operating point and so answers a + question no vehicle ever asks; this answers "what fraction of the + pedestrians in this slice does the perception stack simply not deliver". + """ + if not curve.positives: + return float("nan") + return 1.0 - curve.best_recall + + +def recall_at_precision(curve: Curve, target: float) -> float: + """The most recall available while precision stays at or above `target`. + + Read off the precision envelope rather than the raw curve, for the same + reason AP is: without it the answer depends on a single detection instead + of on the best precision achievable at that recall or beyond. + + Returns 0.0 when the target is never met. That is a real answer, not a + missing one: it says this detector cannot be operated that precisely at + any threshold. + """ + if not curve.recall: + return float("nan") + + envelope = list(curve.precision) + for i in range(len(envelope) - 2, -1, -1): + envelope[i] = max(envelope[i], envelope[i + 1]) + + return max((recall for recall, precision + in zip(curve.recall, envelope, strict=True) + if precision >= target), default=0.0) + + def mean_average_precision(curves: dict[str, Curve]) -> float: """Mean AP over classes, skipping classes that were never present. diff --git a/src/ape/outcomes.py b/src/ape/outcomes.py new file mode 100644 index 0000000..1b17c2d --- /dev/null +++ b/src/ape/outcomes.py @@ -0,0 +1,176 @@ +"""What kind of mistake was it? The taxonomy for the false positives. + +THE HALF OF THE ERROR STORY THIS PROJECT WAS NOT TELLING. `ape.localisation` +already splits the MISSES into "never seen" and "seen and boxed badly", and +says why that matters: they have different fixes. The false positives had no +such split at all. Every wrong box counted the same, so a detector that fires +twice on one pedestrian and a detector that invents pedestrians in empty road +produced the same number, and AP alone cannot tell them apart either. + +They are not the same problem: + + duplicate the object IS there and was already found by a better-scoring + box. The detector is right about the world and wrong about + how many things are in it. Fixes with non-maximum suppression + and costs almost nothing in safety terms. + + misclassified right place, wrong label: a box on a real object of another + scored class. The detector saw something and called it the + wrong thing, which for a vehicle is the difference between + braking for a pedestrian and braking for a car, and both are + braking. + + mislocalised right class, real object underneath, box too loose to count. + Fixes with box regression, and the system still knows + something is there. + + hallucinated nothing was there. THE ONE THAT MATTERS MOST for a safety + argument, because it is the only category that makes a + vehicle brake for empty road, and it is the only one whose + cause is not visible anywhere in the ground truth. + +An AP of 0.6 made mostly of duplicates and an AP of 0.6 made mostly of +hallucinations describe different systems. Reporting one number for both is the +gap this module closes. + +WHY IT DOES NOT MATCH ANYTHING ITSELF. Every verdict here is read off the +`Judged` records `ape.match.judge_frame` already produced, at the same +threshold, in the same order. Re-matching would be a second matcher and a +second matcher drifts, which is exactly the fault that made this refactor +necessary. +""" + +from __future__ import annotations + +from collections import Counter +from dataclasses import dataclass, field +from enum import StrEnum + +from ape.match import FrameOutcome, Judged, judge_frame +from ape.records import Detection, GroundTruth + +#: Below this a box is not "on" anything in a useful sense, and calling it a +#: mislocalisation would credit the detector for a coincidental overlap. Same +#: value and same reasoning as `ape.localisation.TOUCHING`. +TOUCHING = 0.1 + + +class Outcome(StrEnum): + """What one detection turned out to be.""" + + TRUE_POSITIVE = "true positive" + DUPLICATE = "duplicate" + MISCLASSIFIED = "misclassified" + MISLOCALISED = "mislocalised" + HALLUCINATED = "hallucinated" + #: Landed on something the benchmark declines to score. Not a mistake. + IGNORED = "ignored" + + +#: The failure kinds, worst last. Order is the reporting order and is chosen so +#: the category a safety case cares about most sits at the end of the row. +FAILURES = (Outcome.DUPLICATE, Outcome.MISCLASSIFIED, + Outcome.MISLOCALISED, Outcome.HALLUCINATED) + + +@dataclass(frozen=True) +class Verdict: + """One detection and what it turned out to be.""" + + detection: Detection + outcome: Outcome + #: The overlap that decided it, for reading a borderline case by hand. + iou: float + + +@dataclass +class Breakdown: + """Every detection of one class, sorted into the taxonomy.""" + + label: str + iou_threshold: float + counts: Counter[Outcome] = field(default_factory=Counter) + + @property + def scored(self) -> int: + """Detections that counted either way. Ignored ones are neither.""" + return sum(n for outcome, n in self.counts.items() + if outcome is not Outcome.IGNORED) + + @property + def false_positives(self) -> int: + return sum(self.counts[outcome] for outcome in FAILURES) + + def share(self, outcome: Outcome) -> float: + """This outcome as a fraction of all false positives. + + The fraction, not the count, is what compares two detectors: a model + with twice the boxes has twice of everything. + """ + total = self.false_positives + return self.counts[outcome] / total if total else float("nan") + + def extend(self, other: Breakdown) -> None: + self.counts.update(other.counts) + + +def _verdict(judged: Judged, outcome: FrameOutcome, + others: list[GroundTruth], iou_threshold: float) -> Verdict: + if judged.ignored: + return Verdict(judged.detection, Outcome.IGNORED, judged.best_free) + if judged.true_positive: + return Verdict(judged.detection, Outcome.TRUE_POSITIVE, judged.best_free) + + # Already claimed by a better-scoring box: the object is real and was + # found. best_any sees claimed ground truth, best_free does not. + if judged.best_any >= iou_threshold: + return Verdict(judged.detection, Outcome.DUPLICATE, judged.best_any) + + # Right place, wrong label. Checked before mislocalisation because a box + # sitting squarely on a car is a naming mistake, not a regression problem, + # even when it also clips a pedestrian. + on_other = max((judged.detection.box.iou(item.box) for item in others), + default=0.0) + if on_other >= iou_threshold: + return Verdict(judged.detection, Outcome.MISCLASSIFIED, on_other) + + # Right class, real object underneath, box too loose to count. + if judged.best_free >= TOUCHING: + return Verdict(judged.detection, Outcome.MISLOCALISED, judged.best_free) + + return Verdict(judged.detection, Outcome.HALLUCINATED, judged.best_free) + + +def classify_frame(detections: list[Detection], ground_truth: list[GroundTruth], + label: str, neutral: frozenset[str], iou_threshold: float, + scored_labels: tuple[str, ...] = ()) -> list[Verdict]: + """Sort one frame's detections of one class into the taxonomy. + + `scored_labels` is the full set of classes the project evaluates, needed + because misclassification is the one verdict that cannot be reached from + this class's own ground truth: deciding a box is a mislabelled car means + looking at the cars. + """ + outcome = judge_frame(detections, ground_truth, label, neutral, iou_threshold) + others = [item for item in ground_truth + if item.label != label and item.label in scored_labels] + return [_verdict(judged, outcome, others, iou_threshold) + for judged in outcome.judged] + + +def classify(detections: dict[str, list[Detection]], + ground_truth: dict[str, list[GroundTruth]], + label: str, neutral: frozenset[str], iou_threshold: float, + scored_labels: tuple[str, ...] = ()) -> Breakdown: + """Every frame, one class. + + Frames in the ground truth with no detections are still visited, matching + `ape.match.assign`, so the two agree on which frames exist. + """ + result = Breakdown(label=label, iou_threshold=iou_threshold) + for frame_id, truths in ground_truth.items(): + for verdict in classify_frame(detections.get(frame_id, []), truths, + label, neutral, iou_threshold, + scored_labels): + result.counts[verdict.outcome] += 1 + return result diff --git a/tests/test_outcomes.py b/tests/test_outcomes.py new file mode 100644 index 0000000..0c567fe --- /dev/null +++ b/tests/test_outcomes.py @@ -0,0 +1,213 @@ +"""The false-positive taxonomy, and the guard that keeps one matcher. + +Two things are tested here. The taxonomy itself, each category built from a +frame where only that category can be the answer. And the property that made +the refactor worth doing: `judge_frame` and `assign_frame` must agree, because +the demo scene used to run its own copy of the matching loop and a copy of a +matcher is a copy that drifts. +""" + +from __future__ import annotations + +import pytest + +from ape.classes import EVALUATED, neutral_labels +from ape.match import assign_frame, judge_frame +from ape.metrics import average_precision, false_negative_rate, recall_at_precision +from ape.outcomes import Outcome, classify, classify_frame +from ape.records import Box2D, Detection, GroundTruth + +IOU = 0.5 +PED = neutral_labels("Pedestrian") + + +def truth(x1, y1, x2, y2, label="Pedestrian", frame="f"): + # Boxes are 80 px tall so KITTI's derived difficulty is EASY rather than + # IGNORED. Nothing here filters on difficulty, but a fixture built entirely + # out of objects the benchmark discards would be a trap for the next reader. + return GroundTruth(frame_id=frame, label=label, box=Box2D(x1, y1, x2, y2), + occlusion=0, truncation=0.0) + + +def detection(x1, y1, x2, y2, score=0.9, label="Pedestrian", frame="f"): + return Detection(frame_id=frame, label=label, box=Box2D(x1, y1, x2, y2), + score=score) + + +def outcomes(detections, ground_truth): + return [v.outcome for v in classify_frame(detections, ground_truth, + "Pedestrian", PED, IOU, + EVALUATED)] + + +# ---- the taxonomy ---------------------------------------------------------- + +def test_an_exact_box_is_a_true_positive(): + assert outcomes([detection(0, 0, 10, 80)], [truth(0, 0, 10, 80)]) == [ + Outcome.TRUE_POSITIVE] + + +def test_a_second_box_on_a_found_object_is_a_duplicate_not_a_hallucination(): + """The object is real and was found. NMS territory, not a safety problem.""" + found = detection(0, 0, 10, 80, score=0.9) + again = detection(1, 0, 11, 80, score=0.4) + + assert outcomes([found, again], [truth(0, 0, 10, 80)]) == [ + Outcome.TRUE_POSITIVE, Outcome.DUPLICATE] + + +def test_a_box_on_a_car_while_evaluating_pedestrians_is_misclassified(): + """Right place, wrong label. Distinct from inventing an object.""" + assert outcomes([detection(100, 100, 140, 180)], + [truth(100, 100, 140, 180, label="Car")]) == [ + Outcome.MISCLASSIFIED] + + +def test_a_loose_box_on_a_real_pedestrian_is_mislocalised(): + """Overlapping, but under the threshold: a regression problem.""" + result = outcomes([detection(0, 0, 10, 80)], [truth(6, 0, 16, 80)]) + + assert result == [Outcome.MISLOCALISED] + + +def test_a_box_on_empty_road_is_a_hallucination(): + """The category a safety argument cares about, and the only one whose + cause is invisible in the ground truth.""" + assert outcomes([detection(500, 500, 540, 580)], [truth(0, 0, 10, 80)]) == [ + Outcome.HALLUCINATED] + + +def test_a_box_on_a_neutral_object_is_ignored_rather_than_wrong(): + """KITTI's own rule: Person_sitting is neither a target nor a mistake.""" + assert outcomes([detection(0, 0, 10, 80)], + [truth(0, 0, 10, 80, label="Person_sitting")]) == [ + Outcome.IGNORED] + + +def test_the_categories_are_distinguishable_from_each_other(): + """A taxonomy whose categories all fire together classifies nothing. + + Every failure kind must be reachable and must exclude the others, or the + breakdown is an expensive way of counting false positives twice. + """ + ground_truth = [truth(0, 0, 10, 80), truth(100, 100, 140, 180, label="Car")] + detections = [ + detection(0, 0, 10, 80, score=0.95), # true positive + detection(1, 0, 11, 80, score=0.90), # duplicate of it + detection(100, 100, 140, 180, score=0.80), # misclassified car + detection(200, 0, 210, 80, score=0.70), # hallucination + ] + + assert outcomes(detections, ground_truth) == [ + Outcome.TRUE_POSITIVE, Outcome.DUPLICATE, + Outcome.MISCLASSIFIED, Outcome.HALLUCINATED] + + +def test_shares_are_over_false_positives_not_over_all_detections(): + """A model that emits twice as many boxes has twice as much of everything, + so the comparable number is the fraction, and its denominator matters.""" + ground_truth = {"f": [truth(0, 0, 10, 80)]} + detections = {"f": [detection(0, 0, 10, 80, score=0.95), + detection(1, 0, 11, 80, score=0.90), + detection(200, 0, 210, 80, score=0.70)]} + + breakdown = classify(detections, ground_truth, "Pedestrian", PED, IOU, + EVALUATED) + + assert breakdown.false_positives == 2 + assert breakdown.share(Outcome.DUPLICATE) == pytest.approx(0.5) + assert breakdown.share(Outcome.HALLUCINATED) == pytest.approx(0.5) + assert breakdown.counts[Outcome.TRUE_POSITIVE] == 1 + + +# ---- one matcher, not two -------------------------------------------------- + +CASES = [ + ("nothing at all", [], []), + ("one clean hit", [detection(0, 0, 10, 80)], [truth(0, 0, 10, 80)]), + ("a duplicate", [detection(0, 0, 10, 80, score=0.9), + detection(1, 0, 11, 80, score=0.4)], + [truth(0, 0, 10, 80)]), + ("a neutral object", [detection(0, 0, 10, 80)], + [truth(0, 0, 10, 80, label="Person_sitting")]), + ("a missed object", [], [truth(0, 0, 10, 80)]), + ("score order decides", [detection(0, 0, 10, 80, score=0.3), + detection(0, 4, 10, 84, score=0.99)], + [truth(0, 0, 10, 80)]), + ("two objects, one box", [detection(0, 0, 10, 80)], + [truth(0, 0, 10, 80), truth(0, 4, 10, 84)]), +] + + +@pytest.mark.parametrize("name,detections,ground_truth", + CASES, ids=[c[0] for c in CASES]) +def test_judge_frame_and_assign_frame_cannot_disagree(name, detections, + ground_truth): + """assign_frame is a view of judge_frame, and this is what keeps it one. + + Before this, scripts/render_demo.py carried its own greedy loop. It was + close to the real one and not identical, so the picture and the reported + metric could disagree about the same frame with nothing in the repository + able to notice. + """ + detailed = judge_frame(detections, ground_truth, "Pedestrian", PED, IOU) + flat = assign_frame(detections, ground_truth, "Pedestrian", PED, IOU) + + assert detailed.as_assignment().scored == flat.scored + assert detailed.as_assignment().positives == flat.positives + assert detailed.as_assignment().ignored == flat.ignored + + +def test_found_and_missed_partition_the_countable_objects(): + outcome = judge_frame([detection(0, 0, 10, 80)], + [truth(0, 0, 10, 80), truth(300, 0, 310, 80)], + "Pedestrian", PED, IOU) + + assert len(outcome.found) == 1 + assert len(outcome.missed) == 1 + assert len(outcome.found) + len(outcome.missed) == len(outcome.counts) + + +def test_best_overlap_on_separates_unseen_from_badly_boxed(): + """The number the demo scene draws on a red box, now from the matcher.""" + near_miss = truth(6, 0, 16, 80) + nowhere = truth(800, 300, 810, 380) + outcome = judge_frame([detection(0, 0, 10, 80)], [near_miss, nowhere], + "Pedestrian", PED, IOU) + + assert 0.0 < outcome.best_overlap_on(near_miss) < IOU + assert outcome.best_overlap_on(nowhere) == 0.0 + + +# ---- more than AP ---------------------------------------------------------- + +def test_false_negative_rate_is_the_complement_of_the_ceiling(): + ground_truth = {"f": [truth(0, 0, 10, 80), truth(300, 0, 310, 80)]} + detections = {"f": [detection(0, 0, 10, 80)]} + from ape.match import assign + + curve = average_precision(assign(detections, ground_truth, "Pedestrian", + PED, IOU)) + + assert curve.best_recall == pytest.approx(0.5) + assert false_negative_rate(curve) == pytest.approx(0.5) + + +def test_false_negative_rate_is_undefined_with_nothing_to_find(): + curve = average_precision(assign_frame([], [], "Pedestrian", PED, IOU)) + assert false_negative_rate(curve) != false_negative_rate(curve) # nan + + +def test_recall_at_precision_reports_zero_when_the_target_is_unreachable(): + """A real answer: this detector cannot be operated that precisely.""" + from ape.match import assign + + ground_truth = {"f": [truth(0, 0, 10, 80)]} + detections = {"f": [detection(500, 500, 540, 580, score=0.99), + detection(0, 0, 10, 80, score=0.10)]} + curve = average_precision(assign(detections, ground_truth, "Pedestrian", + PED, IOU)) + + assert recall_at_precision(curve, 0.99) == 0.0 + # The same detector does reach full recall if precision may fall to a half. + assert recall_at_precision(curve, 0.5) == pytest.approx(1.0) From 12ba91cf9c96bcad25f3aa5333bed60b1898382f Mon Sep 17 00:00:00 2001 From: Mohamed Kamel Date: Mon, 31 Aug 2026 13:51:40 +0200 Subject: [PATCH 3/6] Put the forward plan in the README, in bullets Three items and one decline. Metamorphic robustness first because it gives most of the domain-shift story from data already fetched, and because it proves the harness can express a degradation result before any licence-gated download is worth doing. --- README.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/README.md b/README.md index 5f2e64e..0e5ad0d 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,14 @@ uv sync --group dev uv run pytest ``` +## Roadmap + +- **Metamorphic robustness on the KITTI data already fetched** — brightness, blur, contrast, compression, crop, synthetic fog, reported as a degradation curve. Most of the domain-shift story at near-zero cost. +- **Confidence intervals on every slice cell**, not just the overall figures. The bootstrap already exists in `ape.uncertainty`. It is the difference between "night is worse" and "night is worse, and the sample supports saying so". +- **Calibration and OOD scoring** — reliability diagrams and expected calibration error per slice, then an OOD score feeding triggering-condition detection. When this detector says 0.9, how often is it right? A confidently wrong detector is a different safety problem from an uncertainly wrong one, and SOTIF cares far more about the first. + +Not doing: **nuScenes, BDD100K or Waymo before the metamorphic curves exist** (large, licence-gated, and they answer a question the harness has not yet shown it can express). Not training a better detector either, which would make the numbers nicer and the point weaker. + ## Licence Code under MIT. KITTI is CC BY-NC-SA 3.0 and is not included. From fe3d645a4161192e4ad657d3c7280baeeae6b9a6 Mon Sep 17 00:00:00 2001 From: Mohamed Kamel Date: Mon, 31 Aug 2026 15:05:46 +0200 Subject: [PATCH 4/6] Metamorphic robustness: degrade one thing at a time, keep the labels A second dataset changes the scene, the camera, the labelling policy and the class balance at once, so a drop in AP has four candidate causes and the result is a number rather than a finding. A perturbation changes exactly one thing by a stated amount and leaves the ground truth identical, which is what makes these metamorphic relations rather than augmentations. Five perturbations, each one a camera actually suffers: brightness, contrast, blur, JPEG round-trip and a fog veil. `Detector.detect_image` is extracted so a perturbed frame is scored through the same pipeline as the baseline; a sweep that ran a different pipeline would be measuring the pipeline. Measured over 500 KITTI frames with yolov8s, worst drop against the unperturbed baseline: blur 4 px Car -16.3% Pedestrian -17.4% contrast 0.8 Car -13.0% Pedestrian -17.6% jpeg quality 10 Car -8.4% Pedestrian -11.7% fog 0.6 Car -8.0% Pedestrian -7.2% brightness +/-0.6 Car -0.2% Pedestrian -2.2% Three findings. Exposure is free and defocus is not, so a pipeline worrying about tunnel mouths is worrying about the wrong thing. Pedestrians degrade faster than cars under every perturbation except fog, and the gap widens with strength: at blur radius 2 it is 3.3% against 8.5%. And nothing falls off a cliff, which is a result a single number at one operating point could not have shown either way. Crop is deliberately excluded. It moves the boxes, so the ground truth would have to be transformed with it, and a bug in that transform would be indistinguishable from a real drop. The fog is a uniform veil rather than depth-aware, so it understates the distance dependence that matters most for ADAS. Said in the module, in the report and in the README rather than left for a reader to assume otherwise. 32 tests on the perturbations themselves: strength zero is exactly the identity, size and mode are preserved, each one changes the image, each is deterministic, and each does the specific thing it claims rather than merely something. --- README.md | 27 ++++++- scripts/sweep_robustness.py | 124 ++++++++++++++++++++++++++++++ src/ape/detect.py | 17 ++++- src/ape/perturb.py | 127 +++++++++++++++++++++++++++++++ tests/test_perturb.py | 145 ++++++++++++++++++++++++++++++++++++ 5 files changed, 436 insertions(+), 4 deletions(-) create mode 100644 scripts/sweep_robustness.py create mode 100644 src/ape/perturb.py create mode 100644 tests/test_perturb.py diff --git a/README.md b/README.md index 0e5ad0d..564018b 100644 --- a/README.md +++ b/README.md @@ -485,9 +485,34 @@ uv sync --group dev uv run pytest ``` +## Metamorphic robustness: the same scene, degraded a stated amount + +A second dataset changes the scene, the camera, the labelling policy and the class balance at once, so a drop in AP has four candidate causes. A perturbation changes exactly one thing by a stated amount and **leaves the ground truth identical**, so the curve is attributable. That is what makes these metamorphic relations rather than augmentations. + +500 KITTI frames, `yolov8s`, IoU 0.5, worst drop relative to the unperturbed baseline (`scripts/sweep_robustness.py`, full curves in `outputs/robustness.md`): + +| perturbation | at | Car | Pedestrian | +|---|---|---|---| +| blur | 4 px radius | -16.3% | **-17.4%** | +| contrast removed | 0.8 | -13.0% | **-17.6%** | +| JPEG | quality 10 | -8.4% | -11.7% | +| fog veil | 0.6 opacity | -8.0% | -7.2% | +| brightness | ±0.6 | **-0.2%** | -2.2% | + +**Three findings.** + +**Exposure is free and defocus is not.** Brightness at ±60% costs Car essentially nothing, which is a real result rather than a broken perturbation: the tests assert the image actually changed. A pipeline worrying about tunnel mouths and low sun is worrying about the wrong thing; one worrying about a dirty or misfocused lens is not. + +**Pedestrians degrade faster than cars under every perturbation except fog.** The class that matters most for a braking decision is the more fragile one, and the gap widens with strength: at blur radius 2 the Car cost is 3.3% and the Pedestrian cost is 8.5%. A single aggregate mAP hides that completely. + +**Nothing here falls off a cliff.** Every curve is gradual, so there is no threshold below which the detector stops working, and a degradation curve is the honest way to report that. A single number at one operating point would suggest a robustness the smooth decline does not contradict but also does not demonstrate. + +**Read with three caveats, all of them stated in the code.** This is 500 frames, so the baselines here (Car 0.758, Pedestrian 0.443) are not the headline figures above, which come from all 7481. The fog is a **uniform veil, not depth-aware**, so it understates exactly the distance dependence that matters most for ADAS; `vkitti` is where depth-aware weather belongs. And Cyclist is mapping-limited to the point of meaninglessness here, so its column is omitted. + +**Crop is deliberately not included.** It is a reasonable perturbation and it moves the boxes, so the ground truth would have to be transformed with it, which makes a bug in the box transform indistinguishable from a real drop. The whole point of this module is that nothing about the labels changes. + ## Roadmap -- **Metamorphic robustness on the KITTI data already fetched** — brightness, blur, contrast, compression, crop, synthetic fog, reported as a degradation curve. Most of the domain-shift story at near-zero cost. - **Confidence intervals on every slice cell**, not just the overall figures. The bootstrap already exists in `ape.uncertainty`. It is the difference between "night is worse" and "night is worse, and the sample supports saying so". - **Calibration and OOD scoring** — reliability diagrams and expected calibration error per slice, then an OOD score feeding triggering-condition detection. When this detector says 0.9, how often is it right? A confidently wrong detector is a different safety problem from an uncertainly wrong one, and SOTIF cares far more about the first. diff --git a/scripts/sweep_robustness.py b/scripts/sweep_robustness.py new file mode 100644 index 0000000..1b52bda --- /dev/null +++ b/scripts/sweep_robustness.py @@ -0,0 +1,124 @@ +"""Score the detector under each perturbation, and write the degradation curves. + + uv run --extra infer --extra report python scripts/sweep_robustness.py --frames 500 + +Runs inference once per (perturbation, strength) over the same frames, so every +point on a curve differs from the baseline in exactly one stated way and the +ground truth is identical throughout. Writes: + + outputs/robustness.csv one row per perturbation, strength and class + outputs/robustness.md the same as a table, for the README + +The baseline is the strength-0 run of each perturbation rather than a separate +unperturbed pass. That is deliberate: if a perturbation's identity case ever +disagrees with the others, the harness is wrong and the sweep says so instead +of quietly comparing against a different pipeline. +""" + +from __future__ import annotations + +import argparse +import csv +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from ape.classes import EVALUATED, neutral_labels # noqa: E402 +from ape.evaluate import IOU # noqa: E402 +from ape.kitti import frame_ids, load_labels # noqa: E402 +from ape.match import assign # noqa: E402 +from ape.metrics import average_precision, false_negative_rate # noqa: E402 +from ape.perturb import PERTURBATIONS # noqa: E402 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--model", type=Path, default=ROOT / "models/yolov8s.onnx") + parser.add_argument("--data", type=Path, default=ROOT / "data/training") + parser.add_argument("--frames", type=int, default=500) + parser.add_argument("--threads", type=int, default=12) + parser.add_argument("--score", type=float, default=0.05) + parser.add_argument("--csv", type=Path, default=ROOT / "outputs/robustness.csv") + parser.add_argument("--md", type=Path, default=ROOT / "outputs/robustness.md") + args = parser.parse_args() + + from PIL import Image + + from ape.detect import Detector + from ape.perturb import apply as perturb + + split = frame_ids(args.data / "label_2")[: args.frames] + truth = {f: load_labels(args.data / "label_2" / f"{f}.txt") for f in split} + detector = Detector(args.model, score_threshold=args.score, threads=args.threads) + + total = sum(len(p.strengths) for p in PERTURBATIONS) + print(f"{len(split)} frames x {total} configurations") + + rows: list[dict[str, object]] = [] + done = 0 + for perturbation in PERTURBATIONS: + for strength in perturbation.strengths: + started = time.time() + detections: dict[str, list] = {} + for frame in split: + with Image.open(args.data / "image_2" / f"{frame}.png") as handle: + image = handle.convert("RGB") + if strength != 0: + image = perturb(perturbation.name, image, strength) + detections[frame] = detector.detect_image(image, frame) + + for label in EVALUATED: + curve = average_precision( + assign(detections, truth, label, neutral_labels(label), IOU)) + rows.append({ + "perturbation": perturbation.name, + "unit": perturbation.unit, + "strength": strength, + "label": label, + "ap": round(curve.average_precision, 4), + "max_recall": round(curve.best_recall, 4), + "fnr": round(false_negative_rate(curve), 4), + "positives": curve.positives, + }) + done += 1 + print(f" [{done}/{total}] {perturbation.name} {strength:+g} " + f"in {time.time() - started:.0f}s") + + args.csv.parent.mkdir(parents=True, exist_ok=True) + with args.csv.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + + args.md.write_text(_markdown(rows, len(split)), encoding="utf-8") + print(f"wrote {args.csv.relative_to(ROOT)}\nwrote {args.md.relative_to(ROOT)}") + return 0 + + +def _markdown(rows: list[dict[str, object]], frames: int) -> str: + out = ["# Metamorphic robustness", "", + f"AP at IoU {IOU}, {frames} KITTI frames, same ground truth throughout. " + "Strength 0 is the unperturbed baseline for that perturbation.", ""] + for perturbation in PERTURBATIONS: + mine = [r for r in rows if r["perturbation"] == perturbation.name] + if not mine: + continue + out += [f"## {perturbation.name} ({perturbation.unit})", "", + "| strength | " + " | ".join(f"{c} AP" for c in EVALUATED) + " |", + "|---" * (len(EVALUATED) + 1) + "|"] + for strength in perturbation.strengths: + cells = [] + for label in EVALUATED: + hit = next((r for r in mine if r["strength"] == strength + and r["label"] == label), None) + cells.append(f"{hit['ap']:.3f}" if hit else "-") + out.append(f"| {strength:+g} | " + " | ".join(cells) + " |") + out.append("") + return "\n".join(out) + "\n" + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/ape/detect.py b/src/ape/detect.py index 6af8935..4d457bd 100644 --- a/src/ape/detect.py +++ b/src/ape/detect.py @@ -167,12 +167,23 @@ def _preprocess(self, image: Any) -> tuple[np.ndarray, Letterbox]: def detect(self, image_path: Path, frame_id: str) -> list[Detection]: """Every scored detection in one frame, in original image pixels.""" - import numpy as np from PIL import Image with Image.open(image_path) as handle: - image = handle.convert("RGB") - tensor, box = self._preprocess(image) + return self.detect_image(handle.convert("RGB"), frame_id) + + def detect_image(self, image: Any, frame_id: str) -> list[Detection]: + """The same, on an image already in memory. + + Exists so `ape.perturb` can degrade a frame and score the result without + writing it to disk first. `detect` is a thin wrapper over this, so the + two cannot drift: a robustness sweep that ran a different pipeline from + the baseline would be measuring the pipeline rather than the + perturbation. + """ + import numpy as np + + tensor, box = self._preprocess(image) raw = self.session.run(None, {self.input_name: tensor})[0] # YOLOv8 emits (1, 4 + classes, anchors): box first, then class scores, diff --git a/src/ape/perturb.py b/src/ape/perturb.py new file mode 100644 index 0000000..635d572 --- /dev/null +++ b/src/ape/perturb.py @@ -0,0 +1,127 @@ +"""Metamorphic perturbations: the same scene, degraded a stated amount. + +THE QUESTION THIS ANSWERS, and why it is not "how does the detector do on +another dataset". A second dataset changes the scene, the camera, the labelling +policy and the class balance all at once, so a drop in AP has four candidate +causes and the result is a number rather than a finding. A perturbation changes +exactly one thing by a stated amount and keeps the ground truth identical, so +the curve of AP against strength is attributable. + +That property, ground truth unchanged, is what makes these metamorphic +relations rather than augmentations. Every perturbation here is one a camera +actually suffers, and none of them moves an object: + + brightness exposure error, or a tunnel mouth + contrast haze, low sun, a dirty windscreen + blur defocus, or motion at speed + jpeg a compressed video pipeline between sensor and detector + fog scattering, as a uniform veil + +NOT INCLUDED, DELIBERATELY: crop. It is a reasonable perturbation and it moves +the boxes, so the ground truth would have to be transformed with it. That makes +it a different experiment, one where a bug in the box transform is +indistinguishable from a real drop, and the whole point of this module is that +nothing about the labels changes. + +THE FOG IS NOT PHYSICAL, and the report says so. Real fog attenuates with +distance, so it should hurt a pedestrian at 40 m far more than one at 5 m. This +is a uniform veil, which is the depth-independent approximation, so it +understates the distance dependence that matters most for ADAS. It is evidence +about a veil, not about weather. `vkitti` is where depth-aware weather lives. +""" + +from __future__ import annotations + +import io +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +#: Strength 0 must be the identity for every perturbation, so the baseline of a +#: sweep is the unperturbed run and any difference at 0 is a bug in the harness +#: rather than a result. +IDENTITY = 0.0 + + +def brightness(image: Any, strength: float) -> Any: + """Scale luminance. strength is the fractional change, +0.4 is 40% brighter.""" + from PIL import ImageEnhance + return ImageEnhance.Brightness(image).enhance(1.0 + strength) + + +def contrast(image: Any, strength: float) -> Any: + """Reduce contrast toward flat grey. strength 1.0 would be featureless.""" + from PIL import ImageEnhance + return ImageEnhance.Contrast(image).enhance(max(0.0, 1.0 - strength)) + + +def blur(image: Any, strength: float) -> Any: + """Gaussian blur; strength is the radius in pixels.""" + from PIL import ImageFilter + if strength <= 0: + return image + return image.filter(ImageFilter.GaussianBlur(radius=strength)) + + +def jpeg(image: Any, strength: float) -> Any: + """Round-trip through JPEG. strength 0 is quality 100, 1.0 is quality 5.""" + from PIL import Image + if strength <= 0: + return image + quality = max(5, round(100 - strength * 95)) + buffer = io.BytesIO() + image.save(buffer, format="JPEG", quality=quality) + buffer.seek(0) + with Image.open(buffer) as handle: + return handle.convert("RGB") + + +def fog(image: Any, strength: float) -> Any: + """Blend toward a bright grey veil. + + Uniform, not depth-aware: see the module docstring. + """ + from PIL import Image + if strength <= 0: + return image + veil = Image.new("RGB", image.size, (200, 200, 200)) + return Image.blend(image, veil, min(1.0, strength)) + + +@dataclass(frozen=True) +class Perturbation: + name: str + apply: Callable[[Any, float], Any] + #: What the strength number means, for the report axis label. + unit: str + #: The sweep, always starting at the identity. + strengths: tuple[float, ...] + + +PERTURBATIONS: tuple[Perturbation, ...] = ( + Perturbation("brightness", brightness, "fractional change", + (IDENTITY, 0.3, 0.6, -0.3, -0.6)), + Perturbation("contrast", contrast, "fraction removed", + (IDENTITY, 0.3, 0.6, 0.8)), + Perturbation("blur", blur, "gaussian radius px", + (IDENTITY, 1.0, 2.0, 4.0)), + Perturbation("jpeg", jpeg, "compression, 0 = quality 100", + (IDENTITY, 0.6, 0.85, 0.95)), + Perturbation("fog", fog, "veil opacity", + (IDENTITY, 0.2, 0.4, 0.6)), +) + +BY_NAME = {p.name: p for p in PERTURBATIONS} + + +def apply(name: str, image: Any, strength: float) -> Any: + """Apply one named perturbation, or raise for an unknown one. + + Raising rather than returning the image unchanged: a typo in a sweep + configuration would otherwise produce a full set of results labelled with a + perturbation that never happened. + """ + if name not in BY_NAME: + raise KeyError( + f"unknown perturbation {name!r}; known: {sorted(BY_NAME)}") + return BY_NAME[name].apply(image, strength) diff --git a/tests/test_perturb.py b/tests/test_perturb.py new file mode 100644 index 0000000..19457e2 --- /dev/null +++ b/tests/test_perturb.py @@ -0,0 +1,145 @@ +"""The perturbations do one stated thing, and strength 0 does nothing. + +Both properties are what make a degradation curve attributable. If strength 0 +were not the identity, the baseline of every sweep would be wrong; if a +perturbation changed the image size or moved content, the ground truth would no +longer describe the frame and the curve would be measuring a labelling error. +""" + +from __future__ import annotations + +import pytest + +from ape.perturb import BY_NAME, PERTURBATIONS, apply + +np = pytest.importorskip("numpy") +Image = pytest.importorskip("PIL.Image") + + +def scene(width: int = 96, height: int = 64): + """A deterministic image with real structure, not flat colour. + + A flat image is invariant under blur and contrast, so it would let a + broken perturbation pass every test here. + """ + rng = np.random.default_rng(0) + array = rng.integers(0, 256, size=(height, width, 3), dtype=np.uint8) + array[16:48, 24:72] = 240 # a bright block, so contrast has something to flatten + return Image.fromarray(array, "RGB") + + +def as_array(image): + return np.asarray(image, dtype=np.float64) + + +@pytest.mark.parametrize("perturbation", PERTURBATIONS, ids=lambda p: p.name) +def test_strength_zero_is_the_identity(perturbation): + """The baseline of every sweep depends on this being exactly true.""" + original = scene() + + result = perturbation.apply(original, 0.0) + + assert np.array_equal(as_array(result), as_array(original)) + + +@pytest.mark.parametrize("perturbation", PERTURBATIONS, ids=lambda p: p.name) +def test_the_first_strength_in_every_sweep_is_the_identity(perturbation): + assert perturbation.strengths[0] == 0.0 + + +@pytest.mark.parametrize("perturbation", PERTURBATIONS, ids=lambda p: p.name) +def test_size_and_mode_are_preserved(perturbation): + """A perturbation that resized the frame would invalidate every box.""" + original = scene() + + for strength in perturbation.strengths: + result = perturbation.apply(original, strength) + assert result.size == original.size, perturbation.name + assert result.mode == "RGB", perturbation.name + + +@pytest.mark.parametrize("perturbation", PERTURBATIONS, ids=lambda p: p.name) +def test_a_nonzero_strength_actually_changes_the_image(perturbation): + """Otherwise the curve would be flat for a reason that is not the detector.""" + original = scene() + strongest = perturbation.strengths[-1] + + result = perturbation.apply(original, strongest) + + assert not np.array_equal(as_array(result), as_array(original)) + + +@pytest.mark.parametrize("perturbation", PERTURBATIONS, ids=lambda p: p.name) +def test_perturbations_are_deterministic(perturbation): + """A sweep is compared against a baseline run separately; both must repeat.""" + original = scene() + strongest = perturbation.strengths[-1] + + first = as_array(perturbation.apply(original, strongest)) + second = as_array(perturbation.apply(original, strongest)) + + assert np.array_equal(first, second) + + +# ---- each one does the specific thing it claims ----------------------------- + +def test_brightness_moves_the_mean_in_the_signed_direction(): + original = scene() + base = as_array(original).mean() + + assert as_array(apply("brightness", original, 0.6)).mean() > base + assert as_array(apply("brightness", original, -0.6)).mean() < base + + +def test_contrast_reduces_the_spread(): + original = scene() + + assert as_array(apply("contrast", original, 0.8)).std() < as_array(original).std() + + +def test_blur_reduces_local_gradient_and_more_so_with_radius(): + """Monotonic in strength, which is what makes the x axis mean anything.""" + original = scene() + + def gradient(image): + a = as_array(image).mean(axis=2) + return float(np.abs(np.diff(a, axis=1)).mean()) + + sharp = gradient(original) + mild = gradient(apply("blur", original, 1.0)) + heavy = gradient(apply("blur", original, 4.0)) + + assert heavy < mild < sharp + + +def test_jpeg_gets_further_from_the_original_as_it_compresses_harder(): + original = scene() + + def distance(strength): + return float(np.abs(as_array(apply("jpeg", original, strength)) + - as_array(original)).mean()) + + assert distance(0.95) > distance(0.6) > 0 + + +def test_fog_moves_everything_toward_the_veil(): + """A veil raises the darks and lowers the brights: the spread collapses.""" + original = scene() + + veiled = as_array(apply("fog", original, 0.6)) + + assert veiled.std() < as_array(original).std() + assert veiled.min() > as_array(original).min() + + +# ---- the registry ----------------------------------------------------------- + +def test_an_unknown_perturbation_raises_rather_than_passing_the_image_through(): + """A typo in a sweep config would otherwise produce a full set of results + labelled with a perturbation that never happened.""" + with pytest.raises(KeyError, match="unknown perturbation"): + apply("motion_blurr", scene(), 1.0) + + +def test_every_registered_perturbation_is_reachable_by_name(): + assert set(BY_NAME) == {p.name for p in PERTURBATIONS} From 0732d46e8ddf8ffc2f1778def990bb26d7209439 Mon Sep 17 00:00:00 2001 From: Mohamed Kamel Date: Mon, 31 Aug 2026 16:53:12 +0200 Subject: [PATCH 5/6] Put a bootstrap interval on the difficulty tiers too The overall figures and every slice cell already carried a 95% interval; the Easy, Moderate and Hard rows did not, and they sat in the same table as the overall row that did. Reporting them as bare numbers beside a row showing a range invites exactly the comparison the intervals exist to prevent: Easy against Hard looks like a finding until you can see how much of the gap the sample explains. Same bootstrap, resampling frames rather than objects, and the tiers are now assembled per frame so the interval and the point estimate come from the same assignment rather than from two passes. --- README.md | 1 - src/ape/evaluate.py | 7 +++++++ src/ape/report.py | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 564018b..7f7c879 100644 --- a/README.md +++ b/README.md @@ -513,7 +513,6 @@ A second dataset changes the scene, the camera, the labelling policy and the cla ## Roadmap -- **Confidence intervals on every slice cell**, not just the overall figures. The bootstrap already exists in `ape.uncertainty`. It is the difference between "night is worse" and "night is worse, and the sample supports saying so". - **Calibration and OOD scoring** — reliability diagrams and expected calibration error per slice, then an OOD score feeding triggering-condition detection. When this detector says 0.9, how often is it right? A confidently wrong detector is a different safety problem from an uncertainly wrong one, and SOTIF cares far more about the first. Not doing: **nuScenes, BDD100K or Waymo before the metamorphic curves exist** (large, licence-gated, and they answer a question the harness has not yet shown it can express). Not training a better detector either, which would make the numbers nicer and the point weaker. diff --git a/src/ape/evaluate.py b/src/ape/evaluate.py index 0dd464f..fb61664 100644 --- a/src/ape/evaluate.py +++ b/src/ape/evaluate.py @@ -75,6 +75,13 @@ class Evaluation: overall: dict[str, Curve] = field(default_factory=dict) overall_interval: dict[str, Interval] = field(default_factory=dict) by_difficulty: dict[str, dict[str, Curve]] = field(default_factory=dict) + #: The same 95% bootstrap interval the overall figures and the slice cells + #: carry. The difficulty tiers are a slice like any other, and reporting + #: them as bare numbers beside slices that show a range invited exactly the + #: comparison the intervals exist to prevent: Easy against Hard looks like a + #: finding until you see how much of the gap the sample explains. + by_difficulty_interval: dict[str, dict[str, Interval]] = field( + default_factory=dict) slices: list[SliceResult] = field(default_factory=list) #: class -> the operating-point curve. AP integrates over every threshold; #: a vehicle runs at one, and this is where that choice becomes visible. diff --git a/src/ape/report.py b/src/ape/report.py index d5bc8c9..c227602 100644 --- a/src/ape/report.py +++ b/src/ape/report.py @@ -129,8 +129,10 @@ def render(result: Evaluation, header: Header) -> str: for c in EVALUATED) + f"{total_positives}") for tier, classes in result.by_difficulty.items(): objects = sum(classes[c].positives for c in EVALUATED) + bands = result.by_difficulty_interval.get(tier, {}) rows.append(f"{e(tier)}" + "".join( - _cell(classes[c].average_precision, classes[c].positives, True) + _cell(classes[c].average_precision, classes[c].positives, True, + bands.get(c)) for c in EVALUATED) + f"{objects}") rows.append("") From de682233282776283bdc253395fa8fb3dea69b57 Mon Sep 17 00:00:00 2001 From: Mo Kamel Date: Tue, 1 Sep 2026 06:39:51 +0200 Subject: [PATCH 6/6] Add calibration and out-of-distribution scoring Two questions nothing here could answer. mAP asks how often the detector is right; calibration asks whether it KNOWS how often it is right. And every measurement here asks how well it did on some data; the OOD score asks whether that data is the data we validated against. src/ape/calibration.py bins detections by confidence and reports what each band delivered: a reliability diagram as data, ECE, MCE, and overconfidence error. THE SIGN IS THE POINT. ECE is symmetric, so a detector claiming 0.4 while right 0.9 of the time scores exactly as badly as one claiming 0.9 while right 0.4 of the time. Those are not equally dangerous: the first is timid and wastes performance, the second is confidently wrong, which is the failure ISO 21448 exists for. A test constructs that pair and asserts ECE cannot separate them while overconfidence error can. Slices are cut on the DETECTION rather than the ground truth. Every other slice here cuts on ground-truth attributes, which exist only for objects that are really there, so cutting calibration that way would silently drop every false positive. False positives are exactly where overconfidence does its damage. Box height stands in for range, which is weaker than KITTI's labelled distance and is the only proxy a box corresponding to nothing can have. src/ape/ood.py fits an operating envelope over six image statistics and scores frames by Mahalanobis distance. Mahalanobis and not a per-feature z-score because the features covary: a foggy frame is brighter AND lower contrast AND has fewer edges together, and scoring each independently treats one joint excursion as three unremarkable ones. A test puts two probes equally far out on every individual feature, one along the correlation and one across it, and asserts the second scores an order of magnitude higher. Stated as a limit rather than left to be discovered: this is a feature-space novelty detector, nothing is trained, and it cannot see a semantically novel object rendered at ordinary brightness and contrast. agreement() exists because an OOD score nobody has validated is a number rather than evidence. It reports an AUC that sits at 0.5 for a score carrying no information, so a score that cannot rank the degraded frames first can be seen not to have earned the right to gate anything. The output is named triggering_candidates, not triggering conditions: a triggering condition is a scenario a person reasons about, and promoting a statistic straight into a safety artefact is the shortcut that name refuses to take. Verified: 315 passed, both new modules at 100% statement and branch coverage, ruff clean, mypy strict clean. Break-it pass: 8 of 8 seeded defects killed, including overconfidence collapsing back into ECE, ignored detections counted as failures, and Mahalanobis degrading to a per-feature distance. --- README.md | 62 +++++++++- src/ape/calibration.py | 239 ++++++++++++++++++++++++++++++++++++++ src/ape/ood.py | 239 ++++++++++++++++++++++++++++++++++++++ tests/test_calibration.py | 224 +++++++++++++++++++++++++++++++++++ tests/test_ood.py | 221 +++++++++++++++++++++++++++++++++++ 5 files changed, 984 insertions(+), 1 deletion(-) create mode 100644 src/ape/calibration.py create mode 100644 src/ape/ood.py create mode 100644 tests/test_calibration.py create mode 100644 tests/test_ood.py diff --git a/README.md b/README.md index 7f7c879..6d036a0 100644 --- a/README.md +++ b/README.md @@ -511,9 +511,69 @@ A second dataset changes the scene, the camera, the labelling policy and the cla **Crop is deliberately not included.** It is a reasonable perturbation and it moves the boxes, so the ground truth would have to be transformed with it, which makes a bug in the box transform indistinguishable from a real drop. The whole point of this module is that nothing about the labels changes. +## Calibration, and why the sign matters more than the size + +mAP asks how often the detector is right. **Calibration asks whether it knows how +often it is right**, and nothing else here measured that. A detector at 0.68 mAP +that reports 0.95 on every box it will get wrong is a worse engineering problem +than one reporting 0.4 on those boxes, because the second can be gated by a +threshold and the first cannot. + +`src/ape/calibration.py` bins detections by confidence and reports what each band +actually delivered: a reliability diagram as data, plus expected calibration +error, maximum calibration error, and **overconfidence error**. + +That last one is the point. **ECE is symmetric.** A detector claiming 0.4 while +being right 0.9 of the time scores exactly as badly as one claiming 0.9 while +being right 0.4 of the time, and those are not equally dangerous. The first is +timid and merely wastes performance; the second is **confidently wrong**, which +is the failure ISO 21448 exists for. A test constructs that exact pair and +asserts ECE cannot tell them apart while overconfidence error can. + +**Slices are cut on the detection, not the ground truth**, which is the decision +here worth arguing with. Every other slice in this repository cuts on +ground-truth attributes: range, occlusion, truncation. Those exist only for +objects that are really there, so slicing calibration that way would silently +drop every false positive, and false positives are exactly where overconfidence +does its damage. Box height stands in for range. It is a weaker proxy than +KITTI's labelled distance and it is the only one a box corresponding to nothing +can have. + +## Is this frame the kind of thing we validated on? + +Every other measurement here asks how well the detector did on some data. +`src/ape/ood.py` asks the prior question: **is this data the data we validated +against.** A frame that is not is a triggering condition whether or not the +detector happened to get it right, which is the ISO 21448 case where nothing has +failed and the world is simply outside the design envelope. + +It fits an operating envelope over six cheap image statistics and scores new +frames by Mahalanobis distance. **Mahalanobis rather than a z-score per feature +because the features covary**: a foggy frame is brighter *and* lower contrast +*and* has fewer edges together, and scoring each independently treats one +moderate joint excursion as three unremarkable ones. A test puts two probes the +same distance out on every individual feature, one along the correlation and one +across it, and asserts the second scores an order of magnitude higher. + +**What it is not:** a learned OOD method. There is no network and nothing is +trained, consistent with the rest of this repository. It will notice fog, night, +blur, a blown exposure and compression artefacts. **It will not notice a +semantically novel object rendered at ordinary brightness and contrast**, and +that limit is the interesting half of the honesty, because it is exactly the +failure a statistics-only detector cannot see. + +**An OOD score nobody has validated is a number, not evidence.** `agreement()` +measures whether high-scoring frames actually did worse, reporting an AUC that +sits at 0.5 for a score carrying no information. A score that cannot rank the +degraded frames first has not earned the right to gate anything. The output is +called `triggering_candidates` rather than triggering conditions on purpose: a +triggering condition is a scenario a person describes and reasons about, and +promoting a statistic straight into a safety artefact is the shortcut that name +refuses to take. + ## Roadmap -- **Calibration and OOD scoring** — reliability diagrams and expected calibration error per slice, then an OOD score feeding triggering-condition detection. When this detector says 0.9, how often is it right? A confidently wrong detector is a different safety problem from an uncertainly wrong one, and SOTIF cares far more about the first. +- ~~**Calibration and OOD scoring**~~ **Done, 1 September.** `src/ape/calibration.py` and `src/ape/ood.py`. See the section above. Not doing: **nuScenes, BDD100K or Waymo before the metamorphic curves exist** (large, licence-gated, and they answer a question the harness has not yet shown it can express). Not training a better detector either, which would make the numbers nicer and the point weaker. diff --git a/src/ape/calibration.py b/src/ape/calibration.py new file mode 100644 index 0000000..7883947 --- /dev/null +++ b/src/ape/calibration.py @@ -0,0 +1,239 @@ +"""Is the detector's confidence worth believing? + +WHY THIS IS A SEPARATE QUESTION FROM ACCURACY. mAP asks how often the detector +is right. Calibration asks whether it KNOWS how often it is right. A detector at +0.68 mAP that reports 0.95 on every box it will get wrong is a different and +worse engineering problem than one at 0.68 that reports 0.4 on those boxes, +because the second can be gated by a threshold and the first cannot. Nothing +else in this repository measures that. + +WHY SOTIF CARES ABOUT THE SIGN, and why plain ECE is not enough on its own. +Expected calibration error is the mean gap between confidence and observed +precision, and it is SYMMETRIC: a detector that says 0.6 and is right 0.9 of the +time scores exactly as badly as one that says 0.9 and is right 0.6 of the time. +Those are not equally dangerous. The first is timid and a downstream planner +merely wastes performance on it; the second is CONFIDENTLY WRONG, which is the +failure ISO 21448 exists for. So `overconfidence_error` reports only the gaps in +the dangerous direction, and it is the number to read first. + +WHAT A "TRUE POSITIVE" MEANS HERE is whatever `ape.match` already decided, at +the IoU threshold it was given. Calibration is measured against the SAME verdict +the metrics use, rather than against a second opinion computed here, because two +definitions of correctness in one repository is how a calibration figure ends up +disagreeing with the precision figure it is supposed to explain. + +IGNORED DETECTIONS ARE EXCLUDED. KITTI's neutral classes are neither credited +nor penalised by the matcher, so counting them as failures would manufacture +overconfidence out of a labelling convention. + +SLICING IS BY THE DETECTION, NOT BY THE GROUND TRUTH, which is the one design +decision here worth arguing with. Every other slice in this repository is cut on +ground-truth attributes: range, occlusion, truncation. Those exist only for +objects that are really there, so slicing calibration that way would silently +drop every false positive. False positives are exactly where overconfidence +does its damage, so the bands here are cut on properties the detection itself +has, and box height is used as the range proxy. It is a weaker proxy than a +labelled distance and it is the only one available for a box that corresponds to +nothing. +""" + +from __future__ import annotations + +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from itertools import pairwise + +from ape.match import Judged + +#: Ten equal-width bins over [0, 1]. Ten is the common default in the +#: calibration literature and the choice matters: too few hides a bad region by +#: averaging it away, too many leaves bins with two detections in them whose +#: observed precision is 0.0 or 1.0 and nothing in between. `bins=` is exposed +#: so a reader can see the answer move. +DEFAULT_BINS = 10 + + +@dataclass(frozen=True) +class Bin: + """One confidence band, and what actually happened inside it.""" + + lower: float + upper: float + count: int + mean_confidence: float + precision: float + + @property + def gap(self) -> float: + """Signed. Positive means the detector claimed more than it delivered.""" + return self.mean_confidence - self.precision + + @property + def overconfident(self) -> bool: + return self.gap > 0.0 + + +@dataclass(frozen=True) +class Reliability: + """A reliability diagram as data, plus the scalars that summarise it. + + The bins are the diagram: plotting `mean_confidence` against `precision` + gives the usual picture, and the diagonal is perfect calibration. They are + kept rather than discarded so a reader can see WHERE the detector is wrong + rather than only how wrong on average, which is the same argument this + repository makes about mAP. + """ + + bins: tuple[Bin, ...] + total: int + + @property + def populated(self) -> tuple[Bin, ...]: + """Bins with something in them. An empty bin has no observed precision.""" + return tuple(b for b in self.bins if b.count) + + @property + def expected_calibration_error(self) -> float: + """Count-weighted mean absolute gap. Symmetric, and that is its limit.""" + if not self.total: + return 0.0 + return sum(b.count * abs(b.gap) for b in self.populated) / self.total + + @property + def overconfidence_error(self) -> float: + """Count-weighted mean gap, counting only the dangerous direction. + + The number to read first. A detector can post a respectable ECE while + every one of its errors is overconfidence, and this separates the two. + """ + if not self.total: + return 0.0 + return sum(b.count * max(b.gap, 0.0) + for b in self.populated) / self.total + + @property + def maximum_calibration_error(self) -> float: + """The worst single bin, unweighted. + + Worth reporting next to ECE because a small, badly calibrated, + high-confidence bin is precisely the region a threshold will select. + """ + return max((abs(b.gap) for b in self.populated), default=0.0) + + @property + def worst_bin(self) -> Bin | None: + """The bin driving `maximum_calibration_error`, or None if empty.""" + populated = self.populated + if not populated: + return None + return max(populated, key=lambda b: abs(b.gap)) + + +def scored(judged: Iterable[Judged]) -> list[tuple[float, bool]]: + """(confidence, was it right) for every detection that counts. + + Ignored detections are dropped: the matcher neither credits nor penalises + them, so calling them wrong would manufacture overconfidence out of a + labelling convention. + """ + return [(j.detection.score, j.true_positive) for j in judged if not j.ignored] + + +def reliability(judged: Iterable[Judged], bins: int = DEFAULT_BINS + ) -> Reliability: + """Bin detections by confidence and measure what each band delivered.""" + if bins < 1: + raise ValueError(f"bins must be at least 1, got {bins}") + + observations = scored(judged) + edges = [i / bins for i in range(bins + 1)] + buckets: list[list[tuple[float, bool]]] = [[] for _ in range(bins)] + + for score, hit in observations: + # Clamped so a score of exactly 1.0 lands in the top bin rather than in + # a bin that does not exist, and a score outside [0, 1] from some future + # detector does not silently vanish. + index = min(bins - 1, max(0, int(score * bins))) + buckets[index].append((score, hit)) + + out: list[Bin] = [] + for index, bucket in enumerate(buckets): + count = len(bucket) + mean = sum(s for s, _ in bucket) / count if count else 0.0 + hits = sum(1 for _, h in bucket if h) + out.append(Bin(lower=edges[index], upper=edges[index + 1], count=count, + mean_confidence=mean, + precision=hits / count if count else 0.0)) + return Reliability(bins=tuple(out), total=len(observations)) + + +def by_slice(judged: Iterable[Judged], + binner: Callable[[Judged], str | None], + bins: int = DEFAULT_BINS) -> dict[str, Reliability]: + """Calibration per band, using a binner over the DETECTIONS. + + `binner` returning None drops a detection from every slice, which is how a + caller says "this one does not belong to any band" without inventing an + "other" bucket that then gets read as a real population. + """ + grouped: dict[str, list[Judged]] = {} + for item in judged: + band = binner(item) + if band is not None: + grouped.setdefault(band, []).append(item) + return {band: reliability(items, bins) + for band, items in sorted(grouped.items())} + + +#: Box height in pixels is the range proxy. Bigger box, nearer object. It is +#: weaker than KITTI's labelled distance and it is the only thing available for +#: a false positive, which corresponds to no object and therefore has no range. +HEIGHT_EDGES: tuple[float, ...] = (25.0, 40.0, 80.0) + + +def height_band(item: Judged) -> str: + """Which height band a detection's own box falls in.""" + height = item.detection.box.height + edges = HEIGHT_EDGES + if height < edges[0]: + return f"under {edges[0]:.0f}px" + for low, high in pairwise(edges): + if height < high: + return f"{low:.0f} to {high:.0f}px" + return f"over {edges[-1]:.0f}px" + + +def label_band(item: Judged) -> str: + return item.detection.label + + +def overconfident_slices(slices: dict[str, Reliability], + threshold: float) -> tuple[str, ...]: + """Bands whose overconfidence exceeds a budget, worst first. + + Returned as names rather than as a pass/fail, because the useful output of + a calibration check is WHICH band to distrust. A single boolean over the + whole set is the aggregate this repository exists to argue against. + """ + over = [(name, r.overconfidence_error) for name, r in slices.items() + if r.overconfidence_error > threshold] + return tuple(name for name, _ in sorted(over, key=lambda p: -p[1])) + + +def reliability_table(result: Reliability) -> Sequence[tuple[str, ...]]: + """The diagram as rows, for a report or a docstring. + + Empty bins are kept and marked. Dropping them would let a detector that + never emits a confidence between 0.3 and 0.7 look like one whose behaviour + there was measured and fine. + """ + rows: list[tuple[str, ...]] = [ + ("band", "n", "confidence", "precision", "gap")] + for b in result.bins: + if not b.count: + rows.append((f"{b.lower:.1f} to {b.upper:.1f}", "0", "", "", "")) + continue + rows.append((f"{b.lower:.1f} to {b.upper:.1f}", str(b.count), + f"{b.mean_confidence:.3f}", f"{b.precision:.3f}", + f"{b.gap:+.3f}")) + return rows diff --git a/src/ape/ood.py b/src/ape/ood.py new file mode 100644 index 0000000..3eddac4 --- /dev/null +++ b/src/ape/ood.py @@ -0,0 +1,239 @@ +"""Is this frame the kind of thing the detector was validated on? + +WHY THIS BELONGS IN A SOTIF HARNESS. ISO 21448 is about hazards that arise with +no component failing: the detector works exactly as designed and the WORLD is +outside what it was designed for. Every other measurement here answers "how well +did it do on this data". This one answers the prior question, "is this data the +data we validated against", and a frame that is not is a triggering condition +whether or not the detector happened to get it right. + +WHAT THIS IS, stated plainly so nobody reads more into it. It is a +FEATURE-SPACE novelty detector over simple image statistics, fitted on a +reference set and scored by Mahalanobis distance. It is NOT a learned OOD +method, there is no network here and nothing is trained, which is consistent +with the rest of this repository. It will notice fog, night, blur, a blown +exposure and a compression artefact storm. It will NOT notice a semantically +novel object rendered at ordinary brightness and contrast, and that limit is +the interesting half of the honesty: it is exactly the failure a statistics-only +detector cannot see, and saying so is better than letting a low score be read as +"nothing unusual here". + +WHY MAHALANOBIS AND NOT A PER-FEATURE Z-SCORE. The features covary: a foggy +frame is brighter AND lower contrast AND has fewer edges together. Scoring each +independently and taking a maximum treats one moderate joint excursion as three +unremarkable ones, which is the case that matters. Mahalanobis measures the +excursion in the correlated space. + +THE COVARIANCE IS SHRUNK, deliberately. With a handful of reference frames and +several features the sample covariance is singular or nearly so, and its inverse +then produces enormous distances from rounding noise. A small ridge is added to +the diagonal, the amount is a parameter rather than a constant, and its effect +is that the score becomes CONSERVATIVE rather than unstable. + +AN OOD SCORE NOBODY HAS VALIDATED IS A NUMBER, NOT EVIDENCE. `agreement()` +exists for that: it measures whether high-scoring frames actually did worse. A +score that does not separate the degraded frames from the healthy ones has not +earned the right to gate anything, and this module gives a caller the means to +find that out rather than assuming it. +""" + +from __future__ import annotations + +from collections.abc import Iterable, Sequence +from dataclasses import dataclass +from typing import Any + +import numpy as np + +#: Names of the statistics `image_features` produces, in order. Kept beside the +#: extractor so a reference distribution fitted on one version cannot be scored +#: against features from another without the length mismatch being obvious. +FEATURE_NAMES: tuple[str, ...] = ( + "mean_luma", "std_luma", "p05_luma", "p95_luma", "edge_density", + "colour_spread", +) + + +def image_features(image: Any) -> np.ndarray: + """Six cheap statistics that move when the operating conditions move. + + Chosen because each one corresponds to a condition a validation engineer + would actually name: mean and percentile luma for exposure and night, its + standard deviation and the 5th to 95th spread for contrast and fog, edge + density for blur and compression, colour spread for a monochrome or heavily + tinted scene. + + PIL is imported here rather than at module import, matching `ape.perturb`, + so that the numeric core of this module stays importable without it. + """ + from PIL import ImageFilter + + grey = image.convert("L") + luma = np.asarray(grey, dtype=np.float64) / 255.0 + edges = np.asarray(grey.filter(ImageFilter.FIND_EDGES), + dtype=np.float64) / 255.0 + rgb = np.asarray(image.convert("RGB"), dtype=np.float64) / 255.0 + + return np.array([ + float(luma.mean()), + float(luma.std()), + float(np.percentile(luma, 5)), + float(np.percentile(luma, 95)), + # Mean edge magnitude. Blur removes edges; compression adds spurious + # ones at block boundaries, so this moves in both directions and the + # Mahalanobis distance does not care which. + float(edges.mean()), + # How far apart the channels are on average. Near zero for a greyscale + # or a strongly tinted frame. + float(rgb.std(axis=2).mean()), + ]) + + +@dataclass(frozen=True) +class Envelope: + """The operating envelope: what the reference frames looked like. + + Frozen because an envelope that changes after frames have been scored + against it makes those scores incomparable, and nobody would be told. + """ + + mean: np.ndarray + inverse_covariance: np.ndarray + count: int + ridge: float + + def score(self, features: np.ndarray) -> float: + """Mahalanobis distance from the reference distribution. + + Distance rather than squared distance, so the number is in units of + standard deviations along the worst direction and a threshold of 3 means + what a reader expects it to mean. + """ + features = np.asarray(features, dtype=np.float64) + if features.shape != self.mean.shape: + raise ValueError( + f"expected {self.mean.shape[0]} features, got " + f"{features.shape[0]}. A reference fitted on one feature set " + f"cannot score another.") + delta = features - self.mean + squared = float(delta @ self.inverse_covariance @ delta) + # Clamped at zero: a shrunk inverse covariance is positive definite in + # exact arithmetic, and floating point can still produce a tiny negative + # for a frame sitting on the mean. + return float(np.sqrt(max(squared, 0.0))) + + +def fit_envelope(reference: Iterable[Sequence[float]], + ridge: float = 1e-6) -> Envelope: + """Fit the operating envelope from reference frames. + + At least two frames are required and the requirement is real rather than + defensive: one frame has no spread, so every other frame is infinitely far + from it and the score would be meaningless rather than merely uncertain. + """ + matrix = np.asarray([list(row) for row in reference], dtype=np.float64) + if matrix.ndim != 2 or matrix.shape[0] < 2: + raise ValueError( + "an operating envelope needs at least two reference frames; one " + "frame has no spread to measure an excursion against") + + mean = matrix.mean(axis=0) + covariance = np.cov(matrix, rowvar=False) + covariance = np.atleast_2d(covariance) + # The ridge is what makes this usable on a small reference set. Without it + # the covariance of six features over ten frames is singular and its + # inverse turns rounding noise into enormous distances. + covariance = covariance + ridge * np.eye(covariance.shape[0]) + return Envelope(mean=mean, inverse_covariance=np.linalg.inv(covariance), + count=matrix.shape[0], ridge=ridge) + + +@dataclass(frozen=True) +class Agreement: + """Does the OOD score actually predict that the detector did worse? + + `separation` is the difference in mean performance between frames the score + called normal and frames it called novel. Positive means the score is + earning its keep. `auc` is the probability that a randomly chosen degraded + frame scores higher than a randomly chosen healthy one, which is 0.5 for a + score that carries no information at all. + """ + + normal: int + novel: int + mean_normal_performance: float + mean_novel_performance: float + auc: float + + @property + def separation(self) -> float: + return self.mean_normal_performance - self.mean_novel_performance + + @property + def is_informative(self) -> bool: + """Better than a coin toss at ranking the degraded frames first. + + Deliberately a low bar. It is the threshold below which a score should + not be allowed to gate anything, not a standard worth being pleased by. + """ + return self.auc > 0.5 + + +def agreement(scores: dict[str, float], performance: dict[str, float], + threshold: float) -> Agreement: + """Check the score against what actually happened, frame by frame. + + `performance` is any per-frame quality figure where higher is better, so a + caller can pass recall, an F1 or a per-frame AP without this module needing + an opinion about which. Frames missing from either mapping are dropped, + because scoring a frame whose outcome is unknown would quietly invent a + result for it. + """ + shared = sorted(set(scores) & set(performance)) + normal = [performance[f] for f in shared if scores[f] <= threshold] + novel = [performance[f] for f in shared if scores[f] > threshold] + + return Agreement( + normal=len(normal), novel=len(novel), + mean_normal_performance=float(np.mean(normal)) if normal else 0.0, + mean_novel_performance=float(np.mean(novel)) if novel else 0.0, + auc=_auc(scores, performance, shared), + ) + + +def _auc(scores: dict[str, float], performance: dict[str, float], + frames: Sequence[str]) -> float: + """Probability a worse-performing frame scores higher, ties counted half. + + Computed over every pair rather than by ranking, because the frame counts + here are small and the pairwise form is the definition rather than a + shortcut to it. + """ + better = total = 0.0 + for i, a in enumerate(frames): + for b in frames[i + 1:]: + if performance[a] == performance[b]: + continue + worse, healthier = ((a, b) if performance[a] < performance[b] + else (b, a)) + total += 1.0 + if scores[worse] > scores[healthier]: + better += 1.0 + elif scores[worse] == scores[healthier]: + better += 0.5 + return better / total if total else 0.5 + + +def triggering_candidates(scores: dict[str, float], threshold: float + ) -> tuple[str, ...]: + """Frames outside the envelope, furthest first. + + Named "candidates" rather than "triggering conditions" on purpose. A + triggering condition in ISO 21448 is a scenario, described and reasoned + about by a person. This returns frames that warrant that look. Promoting + the output of a statistic straight into a safety artefact is the shortcut + this name refuses to take. + """ + return tuple(frame for frame, _ in + sorted(((f, s) for f, s in scores.items() if s > threshold), + key=lambda pair: -pair[1])) diff --git a/tests/test_calibration.py b/tests/test_calibration.py new file mode 100644 index 0000000..4ae2bb0 --- /dev/null +++ b/tests/test_calibration.py @@ -0,0 +1,224 @@ +"""Calibration: is the detector's confidence worth believing? + +The tests that carry weight here are the ones about the SIGN. Expected +calibration error is symmetric, so a timid detector and a confidently wrong one +score identically, and only one of those is a SOTIF problem. Several tests below +construct exactly that pair and assert the two numbers separate them. + +The rest guard arithmetic that is easy to get subtly wrong: empty bins that must +not be silently dropped, a score of exactly 1.0 that must land somewhere, and +ignored detections that must not be counted as failures. +""" + +from __future__ import annotations + +import pytest + +from ape.calibration import ( + DEFAULT_BINS, + by_slice, + height_band, + label_band, + overconfident_slices, + reliability, + reliability_table, + scored, +) +from ape.match import Judged +from ape.records import Box2D, Detection + + +def _judged(score: float, hit: bool, *, ignored: bool = False, + height: float = 50.0, label: str = "Car") -> Judged: + return Judged( + detection=Detection(frame_id="f", label=label, + box=Box2D(0.0, 0.0, 30.0, height), score=score), + true_positive=hit, ignored=ignored, matched_index=0 if hit else -1, + best_free=1.0 if hit else 0.0, best_any=1.0 if hit else 0.0) + + +# --- what counts ------------------------------------------------------------- +def test_ignored_detections_are_not_counted_as_failures() -> None: + """The matcher neither credits nor penalises them. + + Counting them wrong would manufacture overconfidence out of KITTI's + labelling convention rather than out of the detector's behaviour. + """ + items = [_judged(0.9, True), _judged(0.9, False, ignored=True)] + assert scored(items) == [(0.9, True)] + assert reliability(items).total == 1 + + +def test_a_perfectly_calibrated_detector_has_no_error() -> None: + """Nine detections at 0.9 confidence, of which eight or nine are right.""" + items = [_judged(0.9, i < 9) for i in range(10)] + result = reliability(items) + assert result.expected_calibration_error == pytest.approx(0.0, abs=1e-9) + assert result.overconfidence_error == pytest.approx(0.0, abs=1e-9) + + +# --- the sign, which is the point -------------------------------------------- +def _overconfident() -> list[Judged]: + """Claims 0.9, right 4 times in 10.""" + return [_judged(0.9, i < 4) for i in range(10)] + + +def _underconfident() -> list[Judged]: + """Claims 0.4, right 9 times in 10.""" + return [_judged(0.4, i < 9) for i in range(10)] + + +def test_ece_cannot_tell_timid_from_confidently_wrong() -> None: + """The limitation that motivates the whole module, asserted rather than + described. Both detectors are off by 0.5 and ECE says they are equal.""" + over = reliability(_overconfident()).expected_calibration_error + under = reliability(_underconfident()).expected_calibration_error + assert over == pytest.approx(under, abs=1e-9) + + +def test_overconfidence_error_separates_them() -> None: + """The number to read first. SOTIF cares about one of these and not both.""" + over = reliability(_overconfident()).overconfidence_error + under = reliability(_underconfident()).overconfidence_error + assert over > 0.4 + assert under == pytest.approx(0.0, abs=1e-9) + + +def test_a_bin_knows_which_direction_it_is_wrong_in() -> None: + result = reliability(_overconfident()) + (bin_,) = result.populated + assert bin_.overconfident + assert bin_.gap == pytest.approx(0.5) + + +def test_an_underconfident_bin_is_not_flagged_overconfident() -> None: + (bin_,) = reliability(_underconfident()).populated + assert not bin_.overconfident + assert bin_.gap < 0 + + +# --- the diagram ------------------------------------------------------------- +def test_every_bin_exists_even_when_empty() -> None: + """A detector that never emits 0.3 to 0.7 must not look measured there.""" + result = reliability([_judged(0.95, True)]) + assert len(result.bins) == DEFAULT_BINS + assert len(result.populated) == 1 + + +def test_an_empty_bin_is_shown_as_empty_rather_than_dropped() -> None: + rows = reliability_table(reliability([_judged(0.95, True)])) + blanks = [r for r in rows[1:] if r[1] == "0"] + assert len(blanks) == DEFAULT_BINS - 1 + assert all(r[2] == "" for r in blanks), "an empty bin has no precision" + + +def test_a_confidence_of_exactly_one_lands_in_the_top_bin() -> None: + """Otherwise it indexes a bin that does not exist.""" + result = reliability([_judged(1.0, True)]) + assert result.populated[0].upper == pytest.approx(1.0) + + +def test_a_confidence_of_zero_lands_in_the_bottom_bin() -> None: + result = reliability([_judged(0.0, False)]) + assert result.populated[0].lower == pytest.approx(0.0) + + +def test_the_bin_count_is_a_choice_a_reader_can_change() -> None: + assert len(reliability([_judged(0.5, True)], bins=4).bins) == 4 + + +def test_zero_bins_is_refused() -> None: + with pytest.raises(ValueError, match="at least 1"): + reliability([], bins=0) + + +def test_an_empty_set_reports_zero_rather_than_dividing_by_zero() -> None: + result = reliability([]) + assert result.expected_calibration_error == 0.0 + assert result.overconfidence_error == 0.0 + assert result.maximum_calibration_error == 0.0 + assert result.worst_bin is None + + +def test_the_worst_bin_is_the_one_driving_the_maximum() -> None: + items = [_judged(0.95, False)] + [_judged(0.15, False) for _ in range(50)] + result = reliability(items) + worst = result.worst_bin + assert worst is not None + assert worst.lower == pytest.approx(0.9) + assert result.maximum_calibration_error == pytest.approx(abs(worst.gap)) + + +def test_the_maximum_is_unweighted_so_a_small_bad_bin_still_shows() -> None: + """A threshold selects the high-confidence region, so a tiny badly + calibrated bin up there matters more than its count suggests.""" + items = [_judged(0.95, False)] + [_judged(0.05, False) for _ in range(99)] + result = reliability(items) + assert result.maximum_calibration_error > 0.9 + assert result.expected_calibration_error < 0.15 + + +# --- slicing ----------------------------------------------------------------- +def test_slicing_is_by_the_detection_so_false_positives_survive() -> None: + """The design decision worth arguing with, asserted so it cannot drift. + + Every other slice here is cut on ground truth, which exists only for real + objects. Cutting calibration that way would drop every false positive, and + those are exactly where overconfidence does its damage. + """ + items = [_judged(0.9, False, height=10.0), _judged(0.9, True, height=100.0)] + slices = by_slice(items, height_band) + assert sum(r.total for r in slices.values()) == 2 + + +def test_height_bands_separate_near_from_far() -> None: + near = height_band(_judged(0.9, True, height=100.0)) + far = height_band(_judged(0.9, True, height=10.0)) + assert near != far + assert "over" in near and "under" in far + + +@pytest.mark.parametrize("height, expected", [ + (10.0, "under 25px"), + (30.0, "25 to 40px"), + (60.0, "40 to 80px"), + (200.0, "over 80px"), +]) +def test_every_height_band_is_reachable(height: float, expected: str) -> None: + """Including the middle ones, which the near-versus-far test skips over. + + A band nothing can land in is a band that silently never appears in a + slice table, and the reader would take its absence for a clean result. + """ + assert height_band(_judged(0.9, True, height=height)) == expected + + +def test_a_binner_returning_none_drops_a_detection_from_every_slice() -> None: + """Rather than inventing an "other" bucket that reads as a real band.""" + items = [_judged(0.9, True, label="Car"), + _judged(0.9, True, label="Cyclist")] + slices = by_slice(items, lambda j: None if j.detection.label == "Cyclist" + else j.detection.label) + assert set(slices) == {"Car"} + + +def test_slices_are_measured_independently() -> None: + items = ([_judged(0.9, False, label="Pedestrian") for _ in range(10)] + + [_judged(0.9, True, label="Car") for _ in range(10)]) + slices = by_slice(items, label_band) + assert slices["Pedestrian"].overconfidence_error > 0.8 + assert slices["Car"].overconfidence_error == pytest.approx(0.0, abs=1e-9) + + +def test_overconfident_slices_names_the_bands_worst_first() -> None: + """The useful output is WHICH band to distrust, not a single boolean.""" + items = ([_judged(0.9, False, label="Pedestrian") for _ in range(10)] + + [_judged(0.6, False, label="Cyclist") for _ in range(10)] + + [_judged(0.9, True, label="Car") for _ in range(10)]) + named = overconfident_slices(by_slice(items, label_band), threshold=0.1) + assert named == ("Pedestrian", "Cyclist"), "Car is calibrated and must not appear" + + +def test_a_budget_nothing_breaches_returns_nothing() -> None: + items = [_judged(0.9, True) for _ in range(10)] + assert overconfident_slices(by_slice(items, label_band), 0.1) == () diff --git a/tests/test_ood.py b/tests/test_ood.py new file mode 100644 index 0000000..373c0d5 --- /dev/null +++ b/tests/test_ood.py @@ -0,0 +1,221 @@ +"""Is this frame the kind of thing the detector was validated on? + +Two tests here carry the argument rather than checking arithmetic: + + * `test_a_joint_excursion_is_caught_where_per_feature_scores_would_miss_it` + is why this uses Mahalanobis distance instead of a z-score per feature. + A foggy frame is brighter AND lower contrast AND has fewer edges together; + scoring each independently treats one moderate joint excursion as three + unremarkable ones, which is precisely the case that matters. + * `test_an_uninformative_score_is_reported_as_uninformative` is the honesty + check. An OOD score nobody has validated is a number, not evidence, and a + score that cannot rank the degraded frames first has not earned the right + to gate anything. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from ape.ood import ( + FEATURE_NAMES, + agreement, + fit_envelope, + triggering_candidates, +) + + +def _reference(rng: np.random.Generator, n: int = 200) -> np.ndarray: + """Two strongly correlated features, which is what real image stats do.""" + base = rng.normal(0.0, 1.0, n) + return np.column_stack([base, base + rng.normal(0.0, 0.05, n)]) + + +# --- the envelope ------------------------------------------------------------ +def test_one_reference_frame_is_refused() -> None: + """One frame has no spread, so every other frame is infinitely far away.""" + with pytest.raises(ValueError, match="at least two reference frames"): + fit_envelope([[0.0, 0.0]]) + + +def test_a_frame_at_the_centre_scores_about_zero() -> None: + rng = np.random.default_rng(0) + envelope = fit_envelope(_reference(rng)) + assert envelope.score(envelope.mean) == pytest.approx(0.0, abs=1e-6) + + +def test_the_score_grows_with_distance() -> None: + rng = np.random.default_rng(1) + envelope = fit_envelope(_reference(rng)) + near = envelope.score(envelope.mean + np.array([1.0, 1.0])) + far = envelope.score(envelope.mean + np.array([4.0, 4.0])) + assert far > near + + +def test_a_joint_excursion_is_caught_where_per_feature_scores_would_miss_it() -> None: + """The reason this is Mahalanobis and not a z-score per feature. + + Both probes sit two standard deviations out on each feature, so any + per-feature test scores them identically. One moves ALONG the correlation + the reference data has and is ordinary; the other moves ACROSS it and is + the kind of frame that should be flagged. + """ + rng = np.random.default_rng(2) + reference = _reference(rng) + envelope = fit_envelope(reference) + spread = reference.std(axis=0) + + along = envelope.score(envelope.mean + np.array([2 * spread[0], 2 * spread[1]])) + across = envelope.score(envelope.mean + np.array([2 * spread[0], -2 * spread[1]])) + + assert across > 10 * along, ( + "a violation of the correlation must score far above a move along it") + + +def test_a_singular_covariance_does_not_explode() -> None: + """Two identical features make the sample covariance singular. + + Without the ridge the inverse turns rounding noise into enormous distances, + so the score becomes unstable exactly when the reference set is small, + which is when it is most likely to be used. + """ + duplicated = [[x, x] for x in (0.0, 1.0, 2.0, 3.0)] + envelope = fit_envelope(duplicated, ridge=1e-6) + score = envelope.score(np.array([1.5, 1.5])) + assert np.isfinite(score) + + +def test_a_bigger_ridge_makes_the_score_more_conservative() -> None: + """Its effect is stated in the docstring, so it is asserted here.""" + rng = np.random.default_rng(3) + reference = _reference(rng) + probe = reference.mean(axis=0) + np.array([1.0, -1.0]) + small = fit_envelope(reference, ridge=1e-9).score(probe) + large = fit_envelope(reference, ridge=1.0).score(probe) + assert large < small + + +def test_scoring_with_the_wrong_number_of_features_is_refused() -> None: + """A reference fitted on one feature set cannot score another.""" + envelope = fit_envelope([[0.0, 0.0], [1.0, 1.0]]) + with pytest.raises(ValueError, match="expected 2 features"): + envelope.score(np.array([0.0, 0.0, 0.0])) + + +def test_the_envelope_records_how_many_frames_it_was_fitted_on() -> None: + """A score against four frames deserves less trust than one against 400.""" + assert fit_envelope([[0.0, 0.0], [1.0, 1.0], [2.0, 2.5]]).count == 3 + + +# --- validating the score ---------------------------------------------------- +def test_a_score_that_predicts_degradation_is_informative() -> None: + scores = {"a": 0.1, "b": 0.2, "c": 5.0, "d": 6.0} + performance = {"a": 0.9, "b": 0.85, "c": 0.3, "d": 0.2} + result = agreement(scores, performance, threshold=1.0) + assert result.is_informative + assert result.auc == pytest.approx(1.0) + assert result.separation > 0.5 + assert (result.normal, result.novel) == (2, 2) + + +def test_a_perfectly_inverted_score_is_reported_as_uninformative() -> None: + """High score on the frames that did BEST. The worst possible ranking.""" + scores = {"a": 1.0, "b": 2.0, "c": 3.0, "d": 4.0} + performance = {"a": 0.1, "b": 0.2, "c": 0.3, "d": 0.4} + result = agreement(scores, performance, threshold=2.5) + assert not result.is_informative + assert result.auc == pytest.approx(0.0) + + +def test_a_mostly_wrong_score_lands_below_a_coin_toss() -> None: + """Ranks correctly within each group and wrongly across them, which is + worse than useless and must be reported as such. + + Two of the six pairs rank correctly: a against b, and c against d. All four + across-group pairs are wrong, so the score sits at a third rather than at + zero. Asserting the exact value because a test that only checked "< 0.5" + would pass on an arithmetic error that moved it anywhere below the line. + """ + scores = {"a": 5.0, "b": 6.0, "c": 0.1, "d": 0.2} + performance = {"a": 0.9, "b": 0.85, "c": 0.3, "d": 0.2} + result = agreement(scores, performance, threshold=1.0) + assert not result.is_informative + assert result.auc == pytest.approx(1 / 3) + assert result.separation < 0 + + +def test_a_score_carrying_no_information_sits_at_a_coin_toss() -> None: + scores = {"a": 1.0, "b": 1.0, "c": 1.0, "d": 1.0} + performance = {"a": 0.9, "b": 0.1, "c": 0.8, "d": 0.2} + assert agreement(scores, performance, 0.5).auc == pytest.approx(0.5) + + +def test_frames_with_no_recorded_outcome_are_dropped() -> None: + """Scoring a frame whose result is unknown would invent one for it.""" + result = agreement({"a": 0.1, "ghost": 9.0}, {"a": 0.9}, threshold=1.0) + assert (result.normal, result.novel) == (1, 0) + + +def test_agreement_over_nothing_is_a_coin_toss_not_a_crash() -> None: + result = agreement({}, {}, threshold=1.0) + assert result.auc == pytest.approx(0.5) + assert result.mean_normal_performance == 0.0 + assert result.mean_novel_performance == 0.0 + + +def test_frames_that_all_performed_identically_carry_no_ranking() -> None: + scores = {"a": 0.1, "b": 9.0} + performance = {"a": 0.5, "b": 0.5} + assert agreement(scores, performance, 1.0).auc == pytest.approx(0.5) + + +# --- what comes out ---------------------------------------------------------- +def test_candidates_come_back_furthest_first() -> None: + scores = {"a": 0.1, "b": 4.0, "c": 9.0} + assert triggering_candidates(scores, threshold=1.0) == ("c", "b") + + +def test_nothing_outside_the_envelope_returns_nothing() -> None: + assert triggering_candidates({"a": 0.1}, threshold=1.0) == () + + +def test_the_feature_names_match_what_the_extractor_produces() -> None: + """A reference fitted on one version must not silently score another.""" + pillow = pytest.importorskip("PIL.Image") + + from ape.ood import image_features + + image = pillow.new("RGB", (32, 32), (120, 130, 140)) + assert len(image_features(image)) == len(FEATURE_NAMES) + + +def test_a_dark_frame_and_a_bright_frame_produce_different_features() -> None: + """The extractor has to actually respond to the conditions it names.""" + pillow = pytest.importorskip("PIL.Image") + + from ape.ood import image_features + + dark = image_features(pillow.new("RGB", (32, 32), (10, 10, 10))) + bright = image_features(pillow.new("RGB", (32, 32), (240, 240, 240))) + assert dark[0] < bright[0], "mean luma must separate them" + + +def test_a_night_frame_is_flagged_against_a_daylight_envelope() -> None: + """The end to end claim, on images rather than on invented vectors.""" + pillow = pytest.importorskip("PIL.Image") + + from ape.ood import image_features + + rng = np.random.default_rng(4) + daylight = [] + for _ in range(12): + noise = rng.integers(150, 210, size=(24, 24, 3), dtype=np.uint8) + daylight.append(image_features(pillow.fromarray(noise, "RGB"))) + envelope = fit_envelope(daylight, ridge=1e-8) + + ordinary = rng.integers(150, 210, size=(24, 24, 3), dtype=np.uint8) + night = rng.integers(0, 25, size=(24, 24, 3), dtype=np.uint8) + + assert (envelope.score(image_features(pillow.fromarray(night, "RGB"))) + > envelope.score(image_features(pillow.fromarray(ordinary, "RGB"))))