Iterations: app v0.2.2 to v0.2.6

done2026-10-11Sprint direction

Five iterations after v0.2.1, one per build. For each: what changed, the simulation evidence, and what is verified (JVM unit tests only: ./gradlew testDebugUnitTest) versus compile-checked only (builds into the APK, never run on a device; no emulator or phone was used). All numbers are App-Measured / proxy / unreviewed; every automatic event is an unconfirmed suggestion. Downloads: Sprint section; latest is 0.2.6-alpha-sprint (versionCode 52).

Summary table

Full per-iteration detail with tables: out/iterations.md (+ out/iter2_results.txt, iter3_results.json, iter4_results.txt, iter4_quality.json, baseline_v0.2.1.json). Code: motion_signal.py (frame-difference sampler), startsuggest.py (reference of Kotlin StartSuggester), calib.py (reference of Kotlin CourseCalibration), iter*_eval.py, iter_metrics.py, build_iter.sh. All numbers App-Measured / proxy / unreviewed; every auto event is an unconfirmed suggestion; nothing sent or deployed. No emulator or phone was used: "unit-tested" = JVM tests; "compile-checked" = builds into the APK, never run.

it version (code) tests change evidence (before → after) verified unmeasured
1 0.2.2 (48) 353 Golf flag poles: gold pole+flag on the AR floor plane at every split + finish, labelled, glow/pulse when reached; flag strip on capture (taps) and review (split times vs scrub) UX request, sim cannot measure flag state logic, geometry, glow all rendering (GL, labels, z-order, clutter at 50/60 m)
2 0.2.3 (49) 375 Start as an UNCONFIRMED SUGGESTION: frame-difference motion → StartSuggester; AUTO_DETECTED / first-appearance / pass-by start sources; suggested tick on scrub bar; accept by tap before: no help (manual scrub). After (6 static reps): median abs 83 ms, worst 200 ms vs my manual frames (pose research method 67 / 167 ms); rolling/handheld cases → low confidence + alternatives Kotlin == Python on 6 real signals phone decode speed; Luke's runs and the drifting-camera clip are NOT solved (1.0–1.2 s late, or wrong)
3 0.2.4 (50) 383 Cone/tape calibration (1-D projective map), crossing proposals (isotonic, sub-frame), quality warnings, calibration saved in run JSON before: metre unavailable from video (±20 % scale ≈ ±150–200 ms). After, synthetic: side-on 20–50 ms; behind-the-start 1.2 m: −239/−264/−329 ms median at 5/10/20 m (p90 357/420/714) — warning fires in all behind cases maths, warnings, parity with Python any real marked-course footage; foot proxy on real runners
4 0.2.5 (51) 390 Footage check (handheld/low-res/low-fps/short/coverage), indicative ± per split, relative-time block, JSON footage + uncertainty handheld flag right on 6/6 clips at clip level, frame recall 0.28–0.88; ±1 frame covered 3–8 % (behind) of simulated errors → new range 92–98 %; sub-frame interpolation removes the quantisation error (idealised sim) rules, uncertainty maths, JSON ± for person-set marks is an assumption; slow drift under-detected; wording usefulness
5 0.2.6 (52) 397 Open a video from the phone (review/test footage); regression of 0.2.1 fixes; end-to-end pure pipeline test; docs no algorithm change; all evaluations re-run, identical 0.2.1 fixes, whole pure pipeline picker/copy/review flow on a phone

Honest bottom line. What is solid: the start suggestion on a static, ≥720p camera (≈ ±0.1 s vs my manual frames), the calibration maths, the uncertainty wording, the 0.2.1 fixes. What is not: Luke's low-res handheld rolling runs (suggestions are late/wrong and the app now says "poor footage"), anything on a moving camera, and every piece of UI (never run). Calibration from behind the start line is geometrically poor at 10–20 m (3–4 px tap error = 1–3 m) — the app warns; a side-on view is ~10x better in the sim.

Film next (a marked-course test): phone on a tripod/stable object, 1.0–1.5 m high, 60 fps, 1080p, locked exposure, no hand on the phone; cones/tape at 0, 5, 10, 20 m placed ON the lane line; start with the camera SIDE-ON ~15 m away looking at the 10 m cone (best for calibration) and again from 8 m behind the start; athlete in the set position for ≥2 s before going, then runs; ≥10 runs each; keep one clip with a rolling start. Note the tape-measured cone distances and the phone model. Then open them in the app via "Open a video from this phone".

Per-iteration detail

All numbers App-Measured / proxy / unreviewed. Every auto event is an UNCONFIRMED SUGGESTION. No emulator: "unit-tested" = JVM unit tests (./gradlew testDebugUnitTest); "compile-checked" = builds into the APK but never run on a device. Re-run: python3 iter_metrics.py (baseline numbers), python3 iter_eval.py (per-iteration algorithm evaluations, added from iteration 2).

Baseline (v0.2.1-alpha-sprint, 346 tests) — footage set re-run at start of iteration 1

Source: baseline_v0.2.1.json (regenerated by iter_metrics.py from cached pose tracks; exp_final.py reproduces the 2026-10-09 numbers exactly).

metric clip set value
Start error, pose-activity (research, NOT in app) wKP0 x6 + 3hRE, vs my manual frames median +40 ms, median abs 67 ms, worst −167 ms (n=7)
Start vs pass-by, Luke run 1 / run 2 luke_run (368x496, rolling start) first pose −10 f (−333 ms) / +5 f; activity burst +26 f (+867 ms) / +3 f
Gate timing noise (shoulder-scale proxy) wKP0 x6, 3/5/7 m median 130 ms, p90 304 ms; 17/18 gates found
Metre-scale uncertainty any ±20 % scale -> ±150–200 ms on the 5 m split (needs tape/cone)
Pose coverage, full frame vs far runner wKP0 0 % below 150 px tall, 14 % at 150–250 px (needs x3 crop) — not on-device feasible as is
Camera moving (>2 px/frame global motion) wKP0 4 %, A4hq 29 %, Enugu 42 %, 3hRE 56 %, Athletissima 78 % static clip good; others fail fixed-gate logic
App today — no auto suggestion of anything; start = go cue or person scrub; no handheld/low-res warning; flags none

Main weaknesses the baseline points to: (1) the app offers no help finding START in a clip (a person scrubs); (2) rolling start / enters-from-off-frame (Luke's clip) has no sensible "start"; (3) the metre can only come from tape/cone, but the app has no way to use a cone tap; (4) no uncertainty/handheld/low-res honesty on the results screen; (5) clips cannot be imported for testing.

Iteration 1 — v0.2.2-alpha-sprint (vc 48): golf flag poles

Request from Luke (not a numbers-driven pick). Algorithmic change: none → not measurable by the simulation. - What: FlagPoles (pure state/geometry) + GL flag (pole line, flag triangle, halo point) on the AR floor plane at each split mark + finish along A→B (smoothed heading while walking); screen-space labels (“⚑ 5 m”) from projected pole tops; 2-D FlagTrackStrip on capture (driven by taps) and review (driven by split times vs scrub playhead; scrubbing back un-lights). - Unit-tested (7 tests, 353 total): which flags lit for progress/taps/splits/playhead, finish flag, next target, glow pulse + steady, colour brighten, floor-plane geometry (pole vertical, flag perpendicular to course). - Compile-checked only: GL drawing, label overlay, AR→flag progress feed, strip Canvas. Unknown on device: z-order against camera background, pole visibility at 1.3 m height, label clutter at 10 poles (50/60 m course).

Iteration 2 — v0.2.3-alpha-sprint (vc 49): start detection as an UNCONFIRMED SUGGESTION

Pick (from the numbers): the baseline's one measured, reasonably reliable automatic piece is the start (pose: median |err| 67 ms), but pose needs a ×3 crop + a model that does not exist on the phone; and Luke's clip showed there often is no standing start at all. So: port an on-device-feasible, model-free start heuristic and make "rolling start / enters from off-frame" first-class. What changed (app): StartSuggester (Kotlin port of startsuggest.py: frame-difference motion energy + centroid + 8x8 spread; calm-period/settle logic; sustained-rise onset with hysteresis; kind = STANDING_START / ENTERS_FROM_OFF_FRAME / ALREADY_MOVING / NONE; alternatives FIRST_APPEARANCE, PASS_BY, ACTIVITY_STEP_UP; confidence capped 0.9, halved for handheld/rolling). MotionFeatures + FrameMotionSampler (MediaMetadataRetriever → 160 px luma). StartSource += AUTO_DETECTED (suggested, person-accepted) / FIRST_APPEARANCE / PASS_BY with honest caption wording. Review screen: "Suggest start from video" card, amber hollow suggested tick + grey alternative ticks on the scrub bar (ScrubMarkerBar), explicit "Accept as START" / "Use as reference" buttons — nothing applied automatically. Sim (python reference startsuggest.py, signals from motion_signal.py; iter2_eval.py → iter2_results.txt): | clip | reference (manual) | suggestion | error | kind / confidence | |---|---|---|---|---| | wKP0 rep0..5 (30 fps, static) | first-movement frames 354/1383/2845/4230/4986/5758 | 360/1381/2842/4228/4989/5760 | +200 / −67 / −100 / −67 / +100 / +67 ms | STANDING_START, 0.9 (cap) | | 3hRE side block start (25 fps, camera drifts) | 192 | 0 (ALREADY_MOVING; step-up alt @67) | wrong (−7.7 s) | ALREADY_MOVING, 0.23 + “camera moves in 15 % of frames” | | Luke run 1 (rolling, handheld settle) | pass-by 188 | first appearance 225 | +1.2 s late | ALREADY_MOVING, 0.23, handheld note | | Luke run 2 | pass-by 603 | first appearance 634 | +1.0 s late | ALREADY_MOVING, 0.45 | | A4hq handheld failure case | – (no start) | “standing start” @2.7 s | n/a | STANDING_START but 0.45 + handheld warning | Standing starts, n=6: median |err| 83 ms, worst 200 ms (research pose method on 7 clips: 67 ms / 167 ms — same ballpark, no model). All references are my own manual frames (±1–2 f), params tuned on the same 6 reps (in-sample; one decision: sensitivity floor) — treat as optimistic. Where it fails / still unmeasured: drifting-camera block start (3hRE) and both of Luke's rolling runs are NOT resolved: the settle logic skips the in-frame pass-by that happens while the hand-held camera is still moving (run 1: pass-by at f188, calm found only at f225). The suggestion for those is correctly low-confidence and the app says so, but it is not useful there. On a phone: MediaMetadataRetriever decode speed, real-camera noise, auto-exposure flicker are unmeasured (compile-checked only). Tests: +22 → 375 total (Kotlin↔Python parity on 6 real-footage signals incl. ALREADY_MOVING/handheld cases; synthetic standing/entry/rolling/quiet/settle; feature extractor; scrub ticks; StartSource JSON + caption wording).

Iteration 3 — v0.2.4-alpha-sprint (vc 50): cone/tape calibration → distance-along-course → proposed split crossings

Pick (from the numbers): baseline said the metre cannot come from the video (±20 % scale → ±150–200 ms on the 5 m split) and the app had no way to use a cone. Tape/laser owns the metre, so let the person tell the app where known distances are. What changed (app): CourseCalibration (1-D projective map along the tapped line, 2 taps = straight-line scale, 3+ = perspective-corrected; quality warnings: <3 points, taps out of order/too close, perspective ratio >6x, unstable pole inside range, poor fit >0.30 m RMS, taps cover <60 % of the course); CrossingProposer (median-5, isotonic monotone envelope, sub-sample interpolation → AUTO_DETECTED, humanReviewed=false); indicativeMs (rough ± from tap error ÷ px-per-metre ÷ speed); foot-proxy (lowest changed pixels) from the same decode pass; review "Calibrate with cones/tape" panel (tap a still, pick the distance, warnings, "Suggest split marks"), amber suggested ticks and a per-mark "Use suggested …s (unconfirmed)" button; calibration stored in run JSON (calibration, with the warnings). Sim (synthetic pinhole, 1280x720, f=931 px, sprinter vmax 8.5 m/s, 4 taps at 0/5/10/20 m with σ=4 px, foot track σ=3 px, N=300; calib.py, iter3_eval.py, iter3_results.json) — error of proposed crossing vs truth: | geometry | cam height | perspective ratio | 5 m: median / p90 abs (ms) | 10 m | 20 m | warnings fired | |---|---|---|---|---|---|---| | behind (cam 8 m behind start on the lane) | 0.3 m | 12.2x | +3294 / 3453 | -1865 / 2159 | -2286 / 2896 | perspective too extreme | | behind (cam 8 m behind start on the lane) | 0.8 m | 12.4x | -500 / 699 | -538 / 722 | -704 / 1139 | perspective too extreme | | behind (cam 8 m behind start on the lane) | 1.2 m | 12.0x | -239 / 357 | -264 / 420 | -329 / 714 | perspective too extreme | | behind (cam 8 m behind start on the lane) | 1.6 m | 12.5x | -130 / 232 | -153 / 253 | -199 / 505 | perspective too extreme | | side (cam 15 m to the side, at 10 m) | 0.3 m | 1.0x | -24 / 34 | -2 / 16 | +34 / 46 | none | | side (cam 15 m to the side, at 10 m) | 0.8 m | 1.0x | -23 / 34 | +1 / 16 | +34 / 48 | none | | side (cam 15 m to the side, at 10 m) | 1.2 m | 1.0x | -22 / 35 | +1 / 17 | +35 / 48 | none | | side (cam 15 m to the side, at 10 m) | 1.6 m | 1.0x | -24 / 36 | +0 / 16 | +34 / 47 | none | Also: 2 taps only (behind, 1.2 m): 5 m split −591 ms median (flagged "only 2 points … cover only 10 m"); 5 side taps: 5 m −23, 10 m +1, 20 m +34 ms (bias = cones 0.4 m beside the lane line vs runner on it → tap points ON the lane line). Reading: from behind the start the far end is geometrically hopeless with a finger tap (1.5 px/m at 20 m, 4 px = 2.7 m); the app now says so (perspective warning fires in every behind case and in none of the side cases) and shows indicative ± per mark. A side/oblique view gives ~20–50 ms. This matches the earlier recommendation (side-on 15–20 m away is worth testing). Not measured: no footage with real markers exists, so real-footage error is UNKNOWN; the foot proxy (lowest changed pixels) is untested on real runners (shadows, arm swing, other people, ground-level cameras where feet collapse onto the horizon like wKP0 — the perspective warning is the guard). Tap UI, still-frame grab, foot sampler = compile-checked only. Tests: +8 → 383 total (Kotlin↔Python parity of distances + crossings on 2 geometries, accuracy bounds, exact noise-free recovery, every warning, unconfirmed provenance, PAV envelope, foot proxy, run-JSON round-trip).

Iteration 4 — v0.2.5-alpha-sprint (vc 51): honest review — footage checks, uncertainty ranges, relative time

Pick (from the numbers): the sims showed (a) 5 of 6 clips are moving-camera/low-res and the suggestions there are poor, so the app must say so before anyone trusts them; (b) a naive "±1 frame" does not describe the real error of calibration-suggested marks (covers only 3–8 % of simulated errors from behind the start); (c) Luke's runs had no tape, so the useful read-out is scale-free. What changed (app): FootageQuality (handheld / low-res / low-fps / too-short / low tracking coverage → GOOD/FAIR/POOR with plain-English notes, shown in a "Footage check" card in review); FootageProbe (size, fps, duration); TimingUncertainty (person-set pick = ±1 frame; suggested marks = frame quantisation ⊕ 40 ms floor ⊕ 3x tap error through the calibration); results screen shows "(±… indicative)" per split + a note that ranges are not bounds; RelativeTiming (time to 25/50/75/90 % of the course from confirmed marks, labelled "interpolated, constant speed assumed", shown when the course length is not tape-confirmed); run JSON carries `footage` and `splitUncertaintyMs`; crossing proposals already interpolate sub-frame. Sim numbers (`iter4_eval.py`, `iter4_results.txt`, `iter4_quality.json`; synthetic pinhole + real clips): | check | before (0.2.4) | after (0.2.5) | |---|---|---| | Clip-level handheld/moving-camera flag vs optical-flow truth (6 clips: 1 static, 5 moving) | none shown | 6/6 correct (static wKP0 4 % → no flag; 3hRE 15 %, A4hq 11 %, Luke 11 %, Enugu 27 %, Athletissima 76 % → flagged) | | Frame-level shake detection vs optical-flow (threshold 0.3) | – | precision 0.83–1.00, recall only 0.28–0.88 (slow 1–3 px/frame drift is missed: 3hRE 0.28, A4hq 0.38, Luke 0.67; static wKP0 0.88). Luke (0.11) and A4hq (0.11) sit just over the 0.10 warn line. | | Low-res flag | none | Luke 368x496 and Athletissima 640x360 flagged; 720p clips not | | Displayed ± covers actual simulated error (calibration-suggested marks) | naive ±1 frame: 3 % / 8 % (behind, cam 1.2 / 1.6 m), 78 % (side) | model: 92 % / 97 % (behind), 98 % (side); typical ± ≈ 480 ms / 370 ms behind, 51 ms side | | Sub-frame interpolation of crossing time (idealised, 1 px noise) | – | mean abs error 2 ms at every frame rate vs 10 / 14 / 37 ms without it at 60 / 30 / 15 fps (earlier real-footage subsample test: 15 fps 24 ms with vs 39 ms without) | Start suggestion on Luke's runs is still not useful (see iteration 2) — now it is accompanied by "Footage check: poor (368x496; camera moves)" instead of looking authoritative. The handheld settle problem is NOT fixed: recall on slow drift needs a real global-motion estimate (optical flow / phase correlation), not a frame-difference grid. Unmeasured: whether people understand/need the ± wording; the ± constants for person-set marks (±1 frame) are an assumption, not measured; relative timing has only unit tests (no independent truth); Footage probe on phones (fps metadata may be missing → assumes 30 fps, says so). Tests: +7 → 390 total (clip-set grade table, each rule, coverage, uncertainty maths incl. far/behind worse than near/side, relative-time interpolation + non-monotone refusal, run JSON round-trip).

Iteration 5 — v0.2.6-alpha-sprint (vc 52): import a video + regression + docs

Pick: Luke's footage cannot be reviewed in the app at all (clips only come from the in-app camera), so none of the tools above can be tried on his test clips; and the earlier phone-test fixes needed re-verifying under the new code. What changed (app): "Open a video from this phone" on the Sprint home (system file picker → copied to the app's sprint folder → straight into Review with the suggestion / calibration / footage-check tools; no go cue, so START is set by scrubbing or by accepting a suggestion; run id sprint_import_…; names sanitised, 600 MB cap, non-video refused). Regression021Test re-verifies the 0.2.1 fixes (zero start at A with phone at head height, readout/flags keep counting past 10 m, floor-plane guard at any height, run clock excludes the countdown and run time = finish − START, Measure popup saves default + resets marks) and runs the whole pure pipeline end-to-end (signal → suggestion → accept → calibration → proposals → run JSON/caption) checking everything stays UNCONFIRMED until a person reviews and that no Official/Certified/Verified wording appears. docs/SPRINT.md updated. Sim: re-ran every evaluation at the end: baseline numbers unchanged (diff of iter_metrics.py output = identical), iteration-2 start table unchanged, iteration 3/4 results as above. No algorithm changed in this iteration → nothing new to measure. Tests: +7 → 397 total, 0 failures. APK versionCode 52 / 0.2.6-alpha-sprint confirmed with aapt2. Compile-checked only (never run): all GL/Compose UI (flags, strips, panels, calibration tap canvas), FrameMotionSampler, FootageProbe, ImportedClip.copyIn, file picker flow, camera capture. How to import a video (documented): Sprint home → "Open a video from this phone" → choose the clip (Files/Photos picker) → Review opens → "Suggest start from video" (decodes ~30 fps, shows progress) → accept/nudge START → optional "Calibrate with cones/tape" → "Suggest split marks" → set/confirm each mark → "I've reviewed the marks — save run".

Luke's video simulation · Full report · ← Experiments overview