# wheel_kp_v3 — round-2 self-training (Phase 1 scaled round)

`wheel_kp_v2` fine-tuned on **9,979 pseudo-labeled real PassBox frames** — the
1,800 round-1 (v1-labeled) frames plus **8,179 new frames labeled by v2** from
3,441 new clips (night-oversampled: 1,619 night frames vs 13 in round 1).
Same architecture, IO, and sidecar contract as v1/v2 — **drop-in** for the
onnxruntime / onnxruntime-web pipeline (`src/wheel_kp_infer.py`).

**Role: hard-conditions specialist + A/B arm-A labeler.** At a matched
operating point, v2 remains the better default for rain/clear; v3 wins night,
dusk, and fog. Use v3 as arm A when labeling round 3, against v2 as arm B.

## THE table to read first: matched operating point

v3 scores run much hotter than v2 (holdout p50 max-contact score 0.66 → 0.90),
so fixed-threshold comparisons flatter v3. Pinning v3's threshold to **0.832**
(the point where its global fire rate equals v2 @0.5, i.e. 0.636) isolates the
real capability shift (frozen holdout, 43 round-1 holdout riders):

| condition | n | v2 @0.5 | v3 @matched (0.832) | delta |
|-----------|-----|---------|---------------------|-------|
| night     | 95  | 0.484 | **0.526** | +4.2 pts |
| dusk      | 48  | 0.104 | **0.229** | +12.5 pts |
| fog       | 28  | 0.357 | **0.571** | +21.4 pts |
| clear     | 2756| 0.650 | 0.656 | +0.6 pts |
| rain      | 86  | **0.744** | 0.640 | −10.4 pts |
| overcast  | 373 | **0.635** | 0.576 | −5.9 pts |

v3 redistributes firing toward the hard conditions at slight cost to
rain/overcast. **v2 stays the default at the matched point for rain/clear.**

## Fixed-threshold census (for continuity with the v2 README)

fire@t = fraction of holdout tracked frames where max contact-kp score >= t.

| condition | n | v1 @0.5 | v2 @0.5 | v3 @0.5 |
|-----------|-----|---------|---------|---------|
| all       | 3386 | 0.215 | 0.636 | 0.849 |
| night     | 95   | 0.063 | 0.484 | 0.653 |
| dusk      | 48   | 0.042 | 0.104 | 0.708 |
| fog       | 28   | 0.107 | 0.357 | 0.679 |
| rain      | 86   | 0.174 | 0.744 | 0.837 |
| clear     | 2756 | 0.224 | 0.650 | 0.861 |

These numbers are inflated by the score shift — recalibrate any downstream
threshold from `metrics.json` census tables before comparing.

Localization vs v1-CONFIDENT pseudo-labels (holdout PNGs, n=1,095 kps):
v3 median **2.07 px** (p95 8.61) vs v2 1.69 px (p95 7.14) — no catastrophic
forgetting; slight drift, direction worth watching in round 3.

## Honest caveats
- **Compounded circularity.** Round-2 labels are v2 predictions; v2 was itself
  trained on v1 predictions. Two generations of self-training with **no human
  ground truth in the loop**. The PassBox hardware cm ground truth (via the
  contact-point IPM) remains the only external arbiter.
- **Score inflation compounds per round** (p50 0.28 → 0.66 → 0.90). A fixed
  0.5 threshold means something different for every generation. Always compare
  at matched operating points.
- **Small-sample conditions.** dusk n=48, fog n=28 (fog from 2 rides). The big
  dusk/fog gains are directionally real but the confidence intervals are wide.
- **v1 agreement on round-2 labels**: v1 corroborates 48% of v2-confident kps
  (day 51% — near v1's own firing ceiling; night 11% — expected, v1 is
  night-blind). Per-annotation `v1_agrees` tags are in the dataset.
- **New-fire QA** (v3 >= 0.5 where v2 < 0.15; n=94): 9 viewed — 3 clean,
  5 marginal (heavy blur/occlusion, kps on-vehicle near wheel region),
  1 wrong (night, kps above the box at 0.55).
- Round-2 conditions are **screen-derived** (2-frame HSV; night purity spot
  check 24/24), not human-reviewed like round 1.

## Training recipe (v3run1)
- init = `models/wheel_kp_v2/wheel_kp.pt`; input 512 / HM 128; 28 epochs,
  bs 16, lr 2e-4 cosine, seed 0; aug dark 0.35 / blur 0.25 / lowres 0.25;
  weights occ 2.0 / near 2.0 / front-far 2.0 / rear-far 1.5 (= run2 recipe).
- best.pt = **epoch 5** (val_px_err 26.40 vs pseudo-labels — trend metric,
  not absolute); tail plateaued ~30–31, no late re-dip.
- Data: rider-disjoint by construction — the 43 frozen round-1 holdout riders
  were excluded from all round-2 sampling (0 leaks, asserted at merge).
  Per-clip cap of 4 accepted frames (uniform temporal spread) against
  near-duplicate 10 fps frames.
- Pipeline: `realworld/phaseBreal-code/` (+ round-2 additions: `finalize2`,
  `agree_v1`, `merge_train`, `selection.cap_keep`); run ledger in
  `realworld/phaseBreal-code/ledger.md` (row v3run1).

## Files
- `wheel_kp.onnx` — single-file export (Git LFS); `image[batch,3,512,512]` →
  `heatmaps[batch,8,128,128]`; onnx-vs-torch parity 4.8e-07.
- `wheel_kp.pt` — torch checkpoint, v3run1 epoch 5 (Git LFS).
- `wheel_kp.json` — sidecar (kp order, sizes, mean/std, decode).
- `metrics.json` — full census/localization/matched-point tables, provenance,
  QA summary, v1-agreement stats.
