Skip to content

Four root causes behind zero moments, measured against your production data - #17

Merged
ralyodio merged 1 commit into
masterfrom
fix-root-causes
Aug 10, 2026
Merged

ralyodio merged 1 commit into
masterfrom
fix-root-causes

Conversation

@ralyodio

Copy link
Copy Markdown
Contributor

You said stop working around it. So I pulled the real project database off the prod volume and ran the actual scorer over your 9961 tracks. Every number here is from Sam's game, not a fixture.

Four root causes

1. Tracks accumulate. Detection appended instead of replacing, so each re-analysis layered another copy over the last.

9961 stored tracks  ->  2790 distinct
top of the list: trk_850a27bc / trk_8a58250b / trk_e67f99f7
                 all 33.334s, all 364 points — the same track, three times

clearTracks now replaces a video's tracks. Because focal_track_id is a bare column with no foreign key, it also clears the binding and warns you to re-identify — otherwise scoring keeps an anchor pointed at a deleted row.

2. Only the first ball track was ever read. tracks.find(t => t.className === 'ball'). Your detector emits the ball as 56 fragments totalling 10.1 seconds of 300, longest 0.4s — so the signals carrying 0.45 of the weight read one arbitrary fragment. All fragments now count; when several are live, the one nearest the athlete wins.

3. An unseen ball scored zero instead of nothing. That kept 0.45 of weight in the denominator contributing nothing to the numerator, for 97% of the game. Absence of evidence isn't evidence of a dull moment. Worth 0.251 → 0.305 on your data.

4. Sam is not one track. The tracker splits a child into fragments; the picker showed him as several people; binding one followed 23.9 seconds of a 300-second game. You spotted this — "there's 2-3 photos of him".

An athlete can now be identified across several fragments. tracks.athlete_id already existed and nothing wrote it. Scoring stitches them into one series, keeps the more confident observation where fragments overlap, and refuses to interpolate across a gap — a straight line through a hole puts a child on the court while he sat on the bench. The virtual cameraman still interpolates freely; the cap is focal-only.

Measured on your game

Sam = 1 track (as prod is today)   best 0.305   0 moments
Sam stitched from 3 tracks         best 0.419   3 moments
Sam stitched from 12 tracks        best 0.435   5 moments
                                   20s-27s 0.435 [player_ball_proximity,
                                                  player_acceleration, high_motion]

Coverage 23.9s → 93s → 99s. Caveat worth stating: I selected those extra fragments by duration, not by verifying they are Sam. The mechanism is proven; the exact scores depend on which crops are actually him.

What this does not fix

The detector cannot see a basketball. 1920×1080 → 540p proxy → the model's fixed 416×416 input makes the ball roughly six pixels, below YOLOX-Tiny's finest stride of eight. It is under the model's resolution floor, which is why 10 seconds of 300.

That caps this footage at 0.435 against a 0.35 threshold, on player acceleration and scene motion alone — moments clear by 0.07. Thin. The durable fix is detection at higher effective resolution: a 640-input model, or tiling each frame into quarters at 416 (≈4× inference, ~8 min for a 5-minute clip). I did not guess at that here because it needs validating against your footage, not asserted.

Also unused: your video has an audio stream and nothing extracts it, so audio_spike (weight 0.1) has been permanently dark.

Verification

423 tests pass (14 new), lint and typecheck clean. The prod numbers come from explainScoring and computeMoments run over the exported tracks.

One correction made along the way: my first version capped interpolation globally and broke the virtual cameraman, which legitimately bridges sparse keyframes. The cap is now opt-in and applies only to the focal athlete.

…n data

Diagnosed by pulling the real project database off the prod volume and running
the actual scorer over 9961 tracks from a five-minute game. Every number below
is from that data, not a fixture.

Detection appended tracks instead of replacing them, so each re-analysis
layered another copy over the last: 9961 stored tracks collapse to 2790
distinct ones, and three copies of the same 33.334s/364-point track sat in the
top ten. clearTracks now replaces a video's tracks, and because focal_track_id
is a bare column with no foreign key it also clears the binding and warns that
the athlete needs re-identifying.

buildContext took the first ball track and ignored the rest. A detector that
only glimpses the ball emits it as many short fragments — 56 of them, totalling
10.1 seconds of 300, longest 0.4s — so the two signals carrying 0.45 of the
weight read one arbitrary fragment. All fragments are now considered, and when
several are live the one nearest the athlete wins.

Those signals also returned 0 when no ball was visible, which kept their weight
in the denominator while contributing nothing to the numerator. An unseen ball
is unmeasured, not stationary; they now return null. Worth 0.251 to 0.305 on
the real data.

The tracker splits one child into many tracks, so the picker showed Sam as
several people and binding one followed 23.9 seconds of a 300-second game. An
athlete can now be identified across several fragments — tracks.athlete_id
already existed and nothing wrote it — and scoring stitches them into one
series, keeping the more confident observation where fragments overlap and
refusing to interpolate across a gap, since a straight line through a hole puts
a child on the court while they sat on the bench. The cameraman still
interpolates freely; the cap applies only to the focal athlete.

Measured end to end on Sam's game:

  Sam = 1 track (as prod is today)   best 0.305   0 moments
  Sam stitched from 3 tracks         best 0.419   3 moments
  Sam stitched from 12 tracks        best 0.435   5 moments

What this does not fix: the shipped detector cannot see a basketball. At
1920x1080 downscaled to a 540p proxy and then to the model's fixed 416x416
input, the ball is about six pixels across, below YOLOX-Tiny's finest stride of
eight. Ball proximity will stay mostly dark until detection runs at higher
resolution or on tiles.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ralyodio
ralyodio merged commit dcda4c2 into master Aug 10, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant