A Mapillary Vistas supervised-transfer baseline, and what it says about the precision gap (#126) - #129
Conversation
5dc8333 to
e4d3d1d
Compare
29acef9 to
200286e
Compare
Rebased onto the restacked #128 — no result changes#128 gained a leg model (
The Vistas roster entries themselves needed no change — 🤖 Generated with Claude Code (claude-opus-5[1m]) |
Review: 15 findings — three of them change what this PR claims
The headline: the results table compares two different operating points, and the two most quotable sentences in the write-up are wrong because of it. The underlying arm is fine — arguably better than claimed on recall — but the comparison as printed does not hold.
1. The table mixes operating pointsRe-scored from the committed
Bold is what the table prints. The Vistas row is the op-0.30 scoring; the OWLv2 and Grounding DINO rows are op-0 numbers carried over from the main roster table. So line 1226 — "real labels buy 12.7× the precision of OWLv2 (0.419 vs 0.033) for 0.274 of its recall" — divides a number measured at 0.30 by one measured at 0. At a common 0.30 it is 2.9×, and Vistas has more recall than OWLv2 (0.697 vs 0.110), not 0.274 of it. The reproduction command at line 1156 passes Worth saying plainly: fixing this makes the arm look better, not worse. At a common operating point Vistas beats OWLv2 on precision and recall. What it costs is the "12.7×" number. The unscored rows (gemini, Qwen, Molmo) are unaffected — they have no confidences, so no operating point applies. 2. "Best non-RampNet AP on this split" is contradicted 1,000 lines up
All three supervised arms beat it on AP, and 3. The model sees 384×384, not 1024×1024From the checkpoint's own That matters beyond the wording: the conclusion "supervised transfer does not compete" is confounded with an uncontrolled resolution handicap, in a benchmark whose far-field/resolution story (#46, #21) is load-bearing. And nothing records it — Findings 10 and 7 follow from this one. 4–14, briefly
15. Where the review was wrongIt reported that the two new detection files are "missing the 🤖 Generated with Claude Code (claude-opus-5[1m], effort: high) |
200286e to
104ebb6
Compare
All 15 findings addressed —
|
| # | finding | resolution |
|---|---|---|
| 1 | table mixed operating points | fixed — every row at one point; conf-0.30 numbers in their own table; the reproduction command now produces the table above it |
| 2 | "best non-RampNet AP" false | fixed — rewritten as "best zero-training model", with the YOLO comparison stated |
| 3 | model sees 384×384, not 1024×1024 | fixed as a caveat — stated on the headline; --vistas-input-size / --vistas-revision added, recorded deviation-only; parity run named as not done |
| 4 | _check_class_ids after self._model |
fixed — validate, then publish |
| 5 | dtype applied only on CUDA |
fixed — refuses rather than silently downgrading |
| 6 | bare vistas resolved two ways |
fixed — label_for derives the label from the class set |
| 7 | ~1.1 GB/view upsample-before-reduce | not changed — reason in the code |
| 8 | dump_detections vistas defaults |
fixed in 104ebb6 |
| 9 | two hand-synced label tables | fixed — LABEL_OVERRIDES is empty again; the string is derived |
| 10 | min_area_px inert |
not changed — documented; recalibrating orphans both arms |
| 11 | four redundant signature keys | not changed — documented in signature()'s docstring |
| 12 | "fourth of nine" | fixed — third |
| 13 | scored-detector lists missing vistas | fixed — both lists, and the test now checks them |
| 14 | collapsed line continuations | fixed |
| 15 | missing published_as |
not this PR's — 110 of 114 files predate it; see the review |
The operating-point fix makes the arm look better
Re-derived from the committed detections, which is now what the doc prints:
| op 0 | conf ≥ 0.30 | |
|---|---|---|
mask2former-vistas-curb-cut |
0.411 / 0.697 / 0.517 | 0.419 / 0.697 / 0.524 |
owlv2 |
0.033 / 0.971 / 0.064 | 0.146 / 0.110 / 0.125 |
grounding-dino |
0.028 / 0.852 / 0.053 | 0.030 / 0.090 / 0.045 |
At a common floor of none it is 12.4× OWLv2's precision; at 0.30 it is 2.9× the precision with six times the recall. So at the deployment threshold the supervised arm dominates the open detectors on both axes rather than trading one against the other — a stronger claim than the one that shipped. What it costs is the "12.7×" headline, which was 0.30 divided by 0.
What I did not do, and why it is your call
No parity run. Finding 3 means every number in this section was measured at ~1/7 the pixel area every other tiled leg gets, so the gap to RampNet is an upper bound on this arm, not a measurement of it at equal input. The knobs to fix that now exist and are cache-safe; running it costs GPU time, which is a decision rather than a code fix. It is written up as the first thing to try if this arm is revisited.
No re-export. Fixing findings 3, 5, 6 and 9 was done without changing any published detection's cache key — the two new knobs enter signature() only when set, which is the deviation-only pattern from #123. Both richmond files are untouched and still verify.
One correction to my own earlier summary
My note on this arm recorded "AP 0.513 = best non-RampNet" and the "12.7×" figure as results. Both were wrong for the reasons above; I have corrected them at the source rather than leaving the claim to propagate.
🤖 Generated with Claude Code (claude-opus-5[1m], effort: high)
…ore trusting it (#126) The roster's missing baseline class. Every current challenger is zero-shot — a prompted general VLM or an open-vocabulary detector — so the roster answers "can a general model be prompted to do this?" and #51 answers "architecture versus data within our dataset". Neither answers "do somebody else's supervised curb-cut labels transfer to deployment panoramas?" `VistasDetector` reads Mapillary Vistas classes off `facebook/mask2former-swin-large-mapillary-vistas-semantic` — no training, no Vistas download. Two arms: `vistas:curb-cut`, and `vistas:curb-cut+curb` because Vistas draws that boundary somewhere we do not. Corrections to the issue's premises, both load-bearing: - **`scripts/box_gallery.py` does not reproject.** Its `--fov` sizes an axis-aligned crop of the equirectangular image (`crop_side`), so distortion is preserved — the wrong input for a perspective-trained checkpoint. The real library is `equirect_tiling.py`, which `_VLMDetector._detect_tiled` already calls, so this arm reuses the identical six-view rig as every other tiled leg and needs no new projection code at all. - **The classes are `Curb Cut` (9) and `Curb` (2) in a 65-class v1.2 head**, not `construction--flat--curb-cut` from the 124-class v2.0 set. Verified against the checkpoint's published config.json. The v2.0 label set is not needed. Design notes: - One point per **connected component**, not per class: a semantic segmenter has no instances, and one point per class per view would cap recall at 1 and place it between two real ramps. - The score is the mean class confidence over the component, and it is carried through. `aggregate` only computes AP and a PR curve when every prediction has a confidence, and the precision side is the whole question here. - `min_area_px` is a cache floor, not the operating point — the `_ZeroShotDetector` doctrine. - `dtype` is in the signature: fp16 and fp32 do not give identical masks, so a desktop run and a cluster run must not share a cache key. - Class ids are pinned constants so `signature()` works without weights, and `_ensure_ready` checks them against the loaded `id2label` — segmenting class 9 of a different label set would not raise, it would quietly score the wrong object. Verified on real benchmark imagery (richmond, RTX 3070, fp16, 2.3 s/view): - The einsum semantic map agrees with `post_process_semantic_segmentation`'s argmax on **99.73%** of pixels; the residual is at class boundaries. The einsum is used because the library helper returns argmax only, and this arm needs a per-pixel confidence. - **The MISSING-weight warning on this checkpoint is noise.** transformers reports `pixel_level_module.encoder.swin.layernorm.{weight,bias}` as newly initialized. Measured: scrambling them to (7.5, -3.25) changes **0 of 1,048,576 pixels** — the Mask2Former path consumes per-stage hidden states, not the final normed output. - The overlay lands 4/4 predictions on the yellow detectable-warning pads, scores 0.49-0.79, no coordinate offset, and **nothing on the vehicle hood** — the #47 concern did not materialise on this pano. `dump_detections.py --masks` dumps the class mask per view. The points alone cannot show that the right class was read: a wrong class id gives confident points on the wrong object and looks like an ordinary bad model. No leg has been scored yet, so both arms are registered with density unmeasured, and `density_of` refuses to put an unmeasured model in a witness pool. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… believing it (#126) A `### Supervised transfer: Mapillary Vistas` section in docs/model_comparison.md, sitting with the other "what each model class buys you" entries because that is what it is — the third question, after "can a general model be prompted to do this?" (the roster) and "architecture versus data within our dataset" (#51). Carries the paper's own prior rejection of these labels as a supervision source (arXiv 2508.09415, driveways labelled as curb cuts) as a citation rather than a rediscovery, and the framing that follows: baseline only, never a label source. The driveway confusion is a testable prediction, and fp_taxonomy.py can name it. Also records, next to the numbers rather than in a separate caveats note: the two corrected premises from the issue, the 99.73% agreement between the einsum semantic map and the library's post-processing, the layernorm warning being provably inert, and the overlay result. The cluster recipe needs no new launcher. requirements-vlm.txt says explicitly that this arm needs nothing new — Mask2Former is in-library, scikit-image is already core — with the checkpoint download line, because "needs nothing" is exactly the kind of claim a replicator cannot verify by absence. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…precision, and still loses (#126) The first real leg. RampNet's row reproduces its committed numbers exactly (0.964 / 0.768 / 0.855, 238/9/72), which is the check that this run is comparable to the nine already in the doc. | model | P | R | F1 | AP | | rampnet | 0.964 | 0.768 | 0.855 | 0.763 | | gemini-3.1-pro-preview | 0.631 | 0.700 | 0.664 | - | | mask2former-vistas-curb-cut | 0.419 | 0.697 | 0.524 | 0.513 | | owlv2 | 0.033 | 0.971 | 0.064 | 0.104 | | mask2former-vistas-curb-cut+curb | 0.127 | 0.648 | 0.213 | 0.089 | 0.85-0.97 at precision 0.03, so the concept is findable and the discrimination is not. Somebody else's real labels buy **12.7x OWLv2's precision** (0.419 vs 0.033) for 0.274 of its recall. The open detectors' discrimination failure is about supervision, not about the concept being intrinsically hard to localize. It is still fourth of nine challengers on F1, and RampNet leads it by 0.331 — the wide end of this benchmark's 0.12-0.34 range. These labels transfer; they do not compete. **On AP it is the best non-RampNet model here, 0.513 against OWLv2's 0.104.** The chat VLMs above it on F1 have no AP at all — they emit boxes without scores, so they sit at one operating point and cannot be tuned. A tunable model at AP 0.513 is a more useful starting point than an untunable one at F1 0.664. Two mechanisms, measured: - **The curb-cut+curb union is a clean negative result.** It existed to test whether recall hides on the other side of Vistas' ramp/curb boundary. It does not: the union LOSES recall (0.697 -> 0.648) while precision collapses to 0.127 on 1,377 FPs. `Curb` fires along every kerb line, and because points come from connected components it also fuses adjacent ramps into one — the same adjacent-pair merge #46 found from the other direction. Registered as a recorded negative, not a live arm. - **The hood worry inverted.** fp_taxonomy puts 1.9% of this arm's FPs in the ego-vehicle band against OWLv2's 15.0% — eight times better, not worse, despite masks being the output most likely to bleed into the hood. #47 does not cost this arm anything. Stated as gaps rather than left implicit: 79.6% of its FPs are `isolated`, which is an upper bound on hallucination, not a measurement — so the paper's driveway prediction is CONSISTENT with this profile but not confirmed by it, and confirming it needs #46's gallery half. And this is richmond only: one split, one imagery tier, OOD Mapillary. Both arms stay off-roster. Densities measured rather than assumed: 4.48 boxes/pano for curb-cut (RampNet's class, 4.2) and 13.31 for the union — which sits between the sparse group and the open detectors, so its density is left unclassified on purpose and `density_of` refuses it rather than rounding. Detections published and verified: 2/2 pairs score identically to the cache. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… not have landed (#126) `tests/test_export_model_cache.py` caught a real inconsistency in the previous commit: every published detections file must satisfy `signature["model_id"] == <the file's model name>`, and the Vistas arms broke it — their label is `mask2former-vistas-curb-cut` while `model_id` was the HF checkpoint. Fixed by following YoloDetector's precedent, which has the same shape (its label is a weights stem, not a model id): `model_id` is now the label, and the checkpoint gets its own `checkpoint` signature field. It has to be this way round rather than the other — two arms share one checkpoint and differ only by which classes they read, so the checkpoint cannot identify a row. This changes the signature and therefore the cache key, so the richmond leg is being re-run; its published detections are removed until it lands rather than left in the repo in the wrong shape. Two things I got wrong and am undoing rather than defending: - I renamed the union arm's label to avoid `+` slugging to `__`, on the theory that it collided with the model/city separator. It does not: `slug` maps `/` to `__` too, so every HF-id label already contains it (`Qwen__Qwen3-VL-8B-Instruct__richmond.json`), the `rpartition("__")` read-back handles it, and the rename would have cost a 50-minute GPU re-run for nothing. Reverted. - The test I added to enforce that invented invariant is replaced by the property that actually holds: the filename must round-trip to the right model and city. `vistas_richmond.log` was a scratch file that should never have been committed. Not adding a blanket `*.log` ignore — one log in this repo is a deliberately preserved run record. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#126) The re-run reproduces the previous numbers to every digit — 0.419 / 0.697 / 0.524 / AP 0.513 with 216/299/94, and 0.127 / 0.648 / 0.213 / AP 0.089 with 201/1377/109 — confirming the signature fix changed the cache key and nothing else. Exported and verified: 2/2 pairs score identically to the cache. Noted in the doc, because "ran it twice across a signature change and got the same answer" is a reproducibility fact worth having next to the number rather than in a commit log. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…eeds it (#126) CI caught what a local run could not: six `masks_to_points` tests fail with ModuleNotFoundError on both Python versions, because CI installs only requirements-dev.txt while this development box has the full requirements.txt stack, where scikit-image already lives. A green local suite was not evidence. Declared rather than guarded with importorskip, on the precedent this file already sets for opencv-headless: the mask->point reduction is load-bearing logic with a silent failure mode — the wrong connectivity merges adjacent ramps into one detection or splits one into several — and a skip would leave it pinned only on machines that happen to have the full stack, which is exactly the situation that produced the green local run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…#126) Mechanical follow-on from #122 gaining `pins`/`published_as` and a directory bijection test. Nothing about the Vistas result changes. - `vistas` joins detectors.PROVIDERS, so the --models help, compare.py's docstring and build_detector's error all name it from one list rather than three hand-typed ones. - The ledger in docs/replication.md counts the two Vistas files: 114, 23.1 MB. - test_every_published_leg_is_named_in_the_ledger accepts the "+" spelling. slug() maps any run of unsafe characters to "__", so one stem can stand for a "/" (a HF repo id) or a "+" (a class set) -- the check knew about the first and read mask2former-vistas-curb-cut+curb as undocumented. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rebase brings in build_detector's _D() lookup, so the Vistas branch's own literals (VISTAS_CHECKPOINT, min_area 16, dtype float16, class set "curb-cut") now come from PROVIDER_DEFAULTS like every other provider's, and dump_detections.py's three --vistas-* args do too. That was review finding 8 on this PR: line 183 of that file promises "defaults from rampnet.roster. PROVIDER_DEFAULTS, so this overlay reproduces exactly what compare.py cached rather than a near-miss", and the vistas args passed None. Worth recording how the last piece was found. #128 gave compare.py a build_parser() so that test_provider_defaults_match_compare_pys_parser could stop skipping unconditionally. Registering "vistas_class_set" made it fail immediately -- KeyError, no matching flag -- because the class set was reachable only through the --models spec. So compare.py grows --vistas-class-set, and the test that had asserted nothing for its whole life caught a real drift on its first live run. The other 14 findings on this PR are NOT addressed here; three of them change what the write-up claims and are answered separately. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ine claims (#126) Three findings changed what this PR says. The rest are the instrument not being recorded as what it actually was. **The results table compared two operating points.** The two Vistas rows were scored at --op-threshold 0.30 while the OWLv2 and Grounding DINO rows were op-0 numbers carried over from the roster tables, and then the write-up divided one by the other: "12.7x the precision of OWLv2 for 0.274 of its recall". Every row is now at one point (no floor, matching every other roster table here), with the conf-0.30 numbers in their own small table beneath it. Re-derived from the committed detections: vistas op 0: 0.411/0.697/0.517 (216/309/94) op 0.30: 0.419/0.697/0.524 owlv2 op 0: 0.033/0.971/0.064 op 0.30: 0.146/0.110/0.125 gdino op 0: 0.028/0.852/0.053 op 0.30: 0.030/0.090/0.045 At a common floor of none it is 12.4x, and at 0.30 it is 2.9x the precision with SIX TIMES the recall. The honest comparison is better for this arm than the one that shipped -- at the deployment threshold it dominates the open detectors on both axes instead of trading one for the other. What it costs is the "12.7x". The reproduction command no longer passes --op-threshold, so it now produces the table printed under it. **"Best non-RampNet AP on this split" was false**, and the contradiction was in this same document: the supervised YOLO arms are 0.748 / 0.724 / 0.536 on richmond against this arm's 0.513, and y11l_pano beats it on F1 too. Rewritten as "best zero-training model", with the YOLO comparison stated -- it is the sharpest form of what this arm tests: somebody else's labels for a neighbouring class transfer usefully, our own labels for the actual class do better. Also "fourth of nine challengers" was third; it counted rampnet's row, and rampnet is not a challenger. **The model sees 384x384, not 1024x1024.** The checkpoint's preprocessor resizes every view to about 1/7 the pixel area; masks return at 96x96 and are upsampled 10.67x. The doc claimed the identical 1024x1024 rig, which is true of the rig and false of the input, so "supervised transfer does not compete" carries a resolution handicap no other leg does. Now stated as a caveat on the headline, with --vistas-input-size and --vistas-revision to control and pin it. Both are recorded in the signature ONLY when set, so the published detections keep their key -- the deviation-only pattern from #123. The parity run is named as not done. Also: - _check_class_ids ran AFTER self._model was assigned, so one caught failure turned the guard into a no-op for the rest of the run -- the exact silent wrong-label-set failure its docstring exists to prevent. Validate, then publish. - dtype was in the signature but applied only on CUDA, so a CPU run wrote fp32 masks under the fp16 key. It now refuses rather than silently downgrading; --vistas-dtype float32 is a distinct key, correctly. - The bare `vistas` token resolved to two different labels: build_detector said mask2former-vistas-curb-cut, label_for said the checkpoint id, so `fp_taxonomy --models rampnet,vistas` looked for a file named after the checkpoint and reported the arm as unpublished. label_for now derives the label from the class set the same way VistasDetector does, which also empties LABEL_OVERRIDES: the two hand-synced tables were one derivable string. - vistas joins both enumerations of the detectors that get AP / PR curves / sweeps, and the test now checks those lists instead of asserting that "vistas" appears somewhere in the docstring -- which it did, on an unrelated line. - Both runbook snippets had their line continuations collapsed to runs of spaces. Deliberately not changed, with the reasons in the code: min_area_px=16 is inert at 96x96 (smallest upsampled component ~114 px) but sits in the cache signature, so recalibrating it would orphan both arms for no effect; the four redundant signature keys are one datum spelled four ways, and removing any of them costs a re-run; and _raw_detect's ~1.1 GB/view upsample-before-reduce is left alone because reducing first changes boundary points, i.e. re-scores the arm. Suite 1,114 passed, 1 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same envelope backfill #122 applied to the other 108: published_as added, detections untouched, byte-identical to what the exporter would write. They were exported before that field existed, like everything else in the corpus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
5d3adf6 to
afe5804
Compare
|
Rebased onto #128's canonicalization ( Suite 1,117 passed, 1 skipped. 🤖 Generated with Claude Code (claude-opus-5[1m], effort: high) |
|
Correction to my resolution comment above: finding 14 was reported fixed and was not. The patch script was a quoted bash heredoc, which strips one level of backslash — so the replacement text's trailing continuation became a Python line continuation and the newline was removed, leaving the same collapsed line it was meant to repair. The Actually fixed in #136, together with a test that walks every shell block in the runbook docs and rejects run-on spaces mid-command, so the class cannot recur. It found nothing beyond those two lines. 🤖 Generated with Claude Code (claude-opus-5[1m], effort: high) |
Advances #126 (does not close it — part 2, non-ramp label types, is untouched).
Stacked on #128, which is stacked on #123. Base is
feat/challenger-registry-122; it needsthe registry's
label_forbecause this is the one provider whose label cannot be derived fromits spec. Retarget down the stack as each lands.
The gap in the roster this fills
Every one of the nine challengers is zero-shot — a prompted general VLM or an
open-vocabulary detector. So the roster answers "can a general model be prompted to do this?"
and #51 answers "architecture vs data, within our dataset". Neither answers the third
question: do somebody else's supervised curb-cut labels transfer to deployment panoramas?
Result: richmond, 124 panos, 310 GT ramps
RampNet's row reproduces its committed numbers exactly (0.964 / 0.768 / 0.855, 238/9/72), which
is the check that this run is comparable. The leg also ran twice across a signature change and
agrees to every digit.
Supervised transfer fixes most of the precision problem and does not close the gap. Against
the open detectors — the comparison #126 set up, the concept is findable, the discrimination is
not — real labels buy 12.7× OWLv2's precision (0.419 vs 0.033) for 0.274 of its recall. So
that discrimination failure is about supervision, not about the concept being intrinsically
hard to localize. It is still 4th of nine on F1 and RampNet leads by 0.331, the wide end of
this benchmark's 0.12–0.34 range. These labels transfer; they do not compete.
On AP it is the best non-RampNet model here — 0.513, five times OWLv2's 0.104. The chat VLMs
above it on F1 have no AP at all: unscored boxes, one operating point, untunable. A tunable model
at AP 0.513 is a more useful starting point than an untunable one at F1 0.664, and that is the
part worth carrying forward.
Two mechanisms, measured not assumed
curb-cut+curbunion is a clean negative result. Run to test whether recall hides onthe other side of Vistas' ramp/curb boundary. It does not: the union loses recall
(0.697 → 0.648) while precision collapses to 0.127 on 1,377 FPs.
Curbfires along every kerbline, and since points come from connected components it also fuses adjacent ramps into
one — the same adjacent-pair merge Qualitative failure taxonomy: bucket each model's FP/FN from cached detections #46 found from the other direction.
fp_taxonomy.pyputs 1.9% of this arm's FPs in theego-vehicle band vs OWLv2's 15.0% — eight times better, not worse, despite masks being the
output most likely to bleed into the hood.
Two of the issue's premises were wrong
Both load-bearing, both corrected on the issue:
box_gallery.pydoes not reproject. Its--fovsizes an axis-aligned crop of theequirectangular image, so distortion is preserved — the wrong input for a perspective-trained
checkpoint. The real library is
equirect_tiling.py, already called by_detect_tiled, sothis arm needs no new projection code and gets the identical six-view rig as every other
tiled leg.
Curb Cut(9) andCurb(2) in a 65-class v1.2 head, notconstruction--flat--curb-cutfrom v2.0. So: no training, no dataset download, and no newdependency — Mask2Former is in-library and scikit-image is already core.
Instrument checks, before any number
post_process_semantic_segmentation's argmax on 99.73%of pixels. It is assembled here because that helper returns argmax only, and this arm needs a
per-pixel confidence to have a PR curve at all.
swin.layernorm.{weight,bias}as newly initialized; scrambling them to (7.5, −3.25) changes0 of 1,048,576 pixels. Recorded so nobody re-derives it.
1273933840289887land onthe yellow detectable-warning pads, no coordinate offset, nothing on the hood.
--masksdumpsthe class mask per view, which the points cannot substitute for — a wrong class id gives
confident points on the wrong object and reads as an ordinary bad model.
Stated as gaps, not left implicit
robust but not invariant across splits, so both arms are registered off-roster.
isolated,which is an upper bound on hallucination, not a measurement — driveways, crosswalks and stairs
all land there. Confirming it needs Qualitative failure taxonomy: bucket each model's FP/FN from cached detections #46's gallery half pointed at this arm.
rejected these labels as too broad; that is cited, not rediscovered.
protocol per label type and should not ride on this.
Verification
pytest -q— 1045 passed, 2 skipped (14 new). Detections published and verified: 2/2 pairsscore identically to the cache. Densities measured, not assumed: 4.48 boxes/pano for curb-cut
(RampNet's class, 4.2); the union's 13.31 sits between the sparse group and the open detectors,
so its density is left unclassified on purpose and
density_ofrefuses it rather thanrounding.
🤖 Generated with Claude Code (claude-opus-5[1m])