Found while reviewing #99, whose Phase 1 window wraps at the seam and the matcher's does not. Chasing that divergence turned up a second, live defect underneath it. Two separate things, same geometry:
1. Eleven ramps are double-marked across the seam in manual_gold — live, measurable
manual_gold's GT comes from the independent 1,000-pano YOLO labeling pass (manual_labels/*.txt → rampnet/detection_eval.py::yolo_ground_truth), which is a different annotation path from the nine verdict-reviewed splits. #43's reviewer rule ("model hit either side → one TP, do not add a missed mark on the other side") was written for the GT viewer and the verdict schema; it never applied here, and yolo_ground_truth() does no dedup of its own — every class cx cy w h line becomes a point.
Result: one physical ramp straddling x≈0 / x≈1 is labeled twice, once per half. The model emits one detection, claims one mark, and the other mark is an unclaimable false negative.
Scanned across every committed analysis_out/op_cache split — pairs that are distinct without the wrap and the same ramp with it (wrapped separation < the 22.53 px match radius):
| split |
GT |
wrapped-duplicate GT pairs |
| richmond, bend, clovis, morgantown, annapolis, paterson, gainesville, budapest_district5, sao_paulo |
2,641 |
0 |
manual_gold |
3,919 |
11 |
Zero in the nine reviewed splits, which is the expected result and a nice confirmation that the #43 rule worked where it was applied. All eleven are in manual_gold:
manual_labels/<pano>.txt |
x of mark A |
x of mark B |
wrapped separation |
2NgKmkIoU9nUwvjk6K5wtw.txt |
0.0021 |
0.9897 |
12.8 px |
JrR9wwG_ynJrSP9ov3Rxrw.txt |
0.0067 |
0.9971 |
9.9 px |
MJbCTDJV5advy8eYbL0V5Q.txt |
0.0023 |
0.9854 |
17.3 px |
XCbhS0jxrSrOLyndJXtQvA.txt |
0.9942 |
0.0004 |
6.3 px |
XFxls0YjTNlFLpZ6eNUuwg.txt |
0.0081 |
0.9992 |
9.2 px |
YRTsmStw9SqMzZGZpNTcsg.txt |
0.0093 |
0.9969 |
12.7 px |
oE6q6mzI7U-P8l35PsTDFA.txt |
0.0014 |
0.9913 |
10.3 px |
oyyca_diFZoYJGETmlwc3g.txt |
0.0037 |
0.9969 |
7.0 px |
shnQL5DurRLY94LPJmCTYA.txt |
0.9919 |
0.0019 |
10.2 px |
tRwq7cx78-g4Mi59uuN5-Q.txt |
0.0019 |
0.9991 |
2.9 px |
wVEb1Vhhz4yPct7DrQ8-fQ.txt |
0.0006 |
0.99 |
10.9 px |
2.9 px apart at the tightest — not a physical spacing for two ramps.
What it costs. RampNet on manual_gold at the deployed 0.30. The GT policy and the matcher interact, so here is the full grid rather than a single before/after:
| GT policy |
matcher |
P |
R |
F1 |
tp / fp / fn |
| keep both (today) |
plain |
0.9118 |
0.8921 |
0.9018 |
3496 / 338 / 423 |
| keep both (today) |
wrapping |
0.9118 |
0.8921 |
0.9018 |
3496 / 338 / 423 |
| keep first of pair |
plain |
0.9113 |
0.8941 |
0.9026 |
3494 / 340 / 414 |
| keep second of pair |
plain |
0.9108 |
0.8936 |
0.9021 |
3492 / 342 / 416 |
| keep either |
wrapping |
0.9118 |
0.8946 |
0.9031 |
3496 / 338 / 412 |
Eleven phantom FNs, +0.25 recall points and +0.13 F1 — and note the shape of the grid, which is the part that matters more than the size:
- Row 1 vs 2: while the duplicates are there, wrapping the matcher changes nothing. That is why part 2 measures as free.
- Rows 3 vs 4: dedup alone gives a different answer depending on which of the two marks you happen to keep. The score would depend on an accident of annotation order, and a model-agnostic dedup rule cannot prefer the mark that suits the detector — nor should it.
- Row 5: with the matcher wrapping, both keep-orders give the identical result. The wrap is what makes the dedup well-defined, which is the real argument for doing them together.
Small in magnitude, but worth fixing for three reasons: manual_gold is the in-distribution reference the YOLO baseline is compared against in #51 (0.851 vs 0.908), where a systematic GT defect is exactly the kind of thing that should not be sitting in the denominator; every model scored on this split pays the same eleven phantom FNs, so absolute recall is uniformly ~0.25 pts low across the whole comparison table; and the labels are committed, so it is checkable and fixable today with no imagery.
2. The shared matcher does not wrap at the seam — latent, costs nothing today
rampnet/metrics.py::greedy_match takes a plain x difference:
dist_sq = (pred_x - gt_x) ** 2 + (pred_y - gt_y) ** 2
so a prediction at x=0.9883 and a GT at x=0.0021 are ~1010 px apart to the matcher and ~14 px apart on the actual panorama. This is the single matching core every evaluator shares — RampNet's metrics, the VLM comparison, the YOLO baseline.
Measured cost right now: zero. Substituting a wrapping greedy_match changes no TP, FP or FN on any split. That is not luck: the only places the wrap would bite are the same panoramas where the duplicate mark from part 1 is already sitting in range on the near side, so the detection has something to claim either way. The duplicate GT is masking the matcher gap.
Which is why the two have to move together — see the grid above. Dedup alone leaves the score dependent on which duplicate was kept; with the wrap, both choices agree. Worth noting also that #43's reviewer rule already assumes a wrapping matcher ("model hit either side → one TP"), which the code has never provided.
Proposed
Distinct from #43, which covers the reviewer rule and the GT viewer for the verdict-based splits and explicitly scopes the code and model side out ("Model limitation — separate thread"). This is that thread, plus the annotation path #43 never covered.
Refs #43, #58 (the manual_gold split), #51 (the baseline comparison that reads this split), #46 (where it surfaced).
Reproduce the scan (committed caches only — no GPU, no network, no imagery)
import os, sys
sys.path.insert(0, "."); sys.path.insert(0, "scripts/analysis")
from operating_point_curve import CACHE_DIR, read_cache
from rampnet.detection_eval import PANO_SCALE_X, PANO_SCALE_Y, radius_sq_for
from miss_decomposition import ALL_SPLITS
R = radius_sq_for() ** 0.5
def dupes(pts):
"""GT pairs that are distinct without the wrap and the same ramp with it."""
out = []
for i, a in enumerate(pts):
for j in range(i + 1, len(pts)):
b = pts[j]
dx = abs(a[0] - b[0]) * PANO_SCALE_X
dy2 = ((a[1] - b[1]) * PANO_SCALE_Y) ** 2
wrapped = (min(dx, PANO_SCALE_X - dx) ** 2 + dy2) ** 0.5
plain = (dx ** 2 + dy2) ** 0.5
if wrapped < R <= plain:
out.append((i, j))
return out
for city in ALL_SPLITS:
path = os.path.join(CACHE_DIR, f"{city}.json")
if not os.path.exists(path):
continue
panos, _ = read_cache(path)
n = sum(len(dupes(pd["gt"].gt_points)) for pd in panos
if pd["gt"].fn_confirmed and pd["gt"].gt_points)
print(f"{city:>18} {n}")
The P/R/F1 grid is the same loop feeding score_pano with the duplicate dropped
(and, for the wrapping rows, rampnet.detection_eval.greedy_match swapped for a
copy whose only change is dx = min(dx, scale_x - dx)).
🤖 Generated with Claude Code (claude-opus-5[1m])
Found while reviewing #99, whose Phase 1 window wraps at the seam and the matcher's does not. Chasing that divergence turned up a second, live defect underneath it. Two separate things, same geometry:
1. Eleven ramps are double-marked across the seam in
manual_gold— live, measurablemanual_gold's GT comes from the independent 1,000-pano YOLO labeling pass (manual_labels/*.txt→rampnet/detection_eval.py::yolo_ground_truth), which is a different annotation path from the nine verdict-reviewed splits. #43's reviewer rule ("model hit either side → one TP, do not add a missed mark on the other side") was written for the GT viewer and the verdict schema; it never applied here, andyolo_ground_truth()does no dedup of its own — everyclass cx cy w hline becomes a point.Result: one physical ramp straddling x≈0 / x≈1 is labeled twice, once per half. The model emits one detection, claims one mark, and the other mark is an unclaimable false negative.
Scanned across every committed
analysis_out/op_cachesplit — pairs that are distinct without the wrap and the same ramp with it (wrapped separation < the 22.53 px match radius):manual_goldZero in the nine reviewed splits, which is the expected result and a nice confirmation that the #43 rule worked where it was applied. All eleven are in
manual_gold:manual_labels/<pano>.txt2NgKmkIoU9nUwvjk6K5wtw.txtJrR9wwG_ynJrSP9ov3Rxrw.txtMJbCTDJV5advy8eYbL0V5Q.txtXCbhS0jxrSrOLyndJXtQvA.txtXFxls0YjTNlFLpZ6eNUuwg.txtYRTsmStw9SqMzZGZpNTcsg.txtoE6q6mzI7U-P8l35PsTDFA.txtoyyca_diFZoYJGETmlwc3g.txtshnQL5DurRLY94LPJmCTYA.txttRwq7cx78-g4Mi59uuN5-Q.txtwVEb1Vhhz4yPct7DrQ8-fQ.txt2.9 px apart at the tightest — not a physical spacing for two ramps.
What it costs. RampNet on
manual_goldat the deployed 0.30. The GT policy and the matcher interact, so here is the full grid rather than a single before/after:Eleven phantom FNs, +0.25 recall points and +0.13 F1 — and note the shape of the grid, which is the part that matters more than the size:
Small in magnitude, but worth fixing for three reasons:
manual_goldis the in-distribution reference the YOLO baseline is compared against in #51 (0.851 vs 0.908), where a systematic GT defect is exactly the kind of thing that should not be sitting in the denominator; every model scored on this split pays the same eleven phantom FNs, so absolute recall is uniformly ~0.25 pts low across the whole comparison table; and the labels are committed, so it is checkable and fixable today with no imagery.2. The shared matcher does not wrap at the seam — latent, costs nothing today
rampnet/metrics.py::greedy_matchtakes a plain x difference:so a prediction at x=0.9883 and a GT at x=0.0021 are ~1010 px apart to the matcher and ~14 px apart on the actual panorama. This is the single matching core every evaluator shares — RampNet's metrics, the VLM comparison, the YOLO baseline.
Measured cost right now: zero. Substituting a wrapping
greedy_matchchanges no TP, FP or FN on any split. That is not luck: the only places the wrap would bite are the same panoramas where the duplicate mark from part 1 is already sitting in range on the near side, so the detection has something to claim either way. The duplicate GT is masking the matcher gap.Which is why the two have to move together — see the grid above. Dedup alone leaves the score dependent on which duplicate was kept; with the wrap, both choices agree. Worth noting also that #43's reviewer rule already assumes a wrapping matcher ("model hit either side → one TP"), which the code has never provided.
Proposed
greedy_match(dx = min(dx, scale_x - dx)), with a test pinning a pair across the seam. It moves no metric on any committed split before the GT fix, which makes it a safe first step; check the same for the ignore-point fallback inscore_pano, which does its own inline distance and would otherwise stay unwrapped.manual_labelspairs — keep one mark per ramp, after the wrap lands, so the keep-order does not matter. These are committed label files, so the change is reviewable as a diff.yolo_ground_truth()(and the verdict path) reject or merge two GT points within the match radius on the wrapped axis. 360 pano seam: curb ramps split across the left/right edge get double-counted in GT scoring #43 proposed a reviewer-facing warning; this is the code-level version, and it would have caught all eleven.manual_gold's row wherever it is quoted once both land —docs/model_comparison.mdand the Train a supervised YOLO baseline (YOLO11 / YOLO26) on the RampNet dataset — isolate architecture vs. data #51 comparison.Distinct from #43, which covers the reviewer rule and the GT viewer for the verdict-based splits and explicitly scopes the code and model side out ("Model limitation — separate thread"). This is that thread, plus the annotation path #43 never covered.
Refs #43, #58 (the manual_gold split), #51 (the baseline comparison that reads this split), #46 (where it surfaced).
Reproduce the scan (committed caches only — no GPU, no network, no imagery)
The P/R/F1 grid is the same loop feeding
score_panowith the duplicate dropped(and, for the wrapping rows,
rampnet.detection_eval.greedy_matchswapped for acopy whose only change is
dx = min(dx, scale_x - dx)).🤖 Generated with Claude Code (claude-opus-5[1m])