From 0ce56f4df507d4db09c6f458098596d7e784ed76 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 10 Aug 2026 10:14:18 +0000 Subject: [PATCH] fix: four reasons a five-minute game suggested nothing, measured against the run that did it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production: 1,415 tracks, a rim seen for 28s, a ball for 23s, and every line the user was shown reading plausibly — "tracks found", "athlete identified: yes", "highest reachable 1.000", "none scored above 0.35". Together those say the footage was dull. The footage was fine. The athlete every focal signal depends on was bound to a ten-frame fragment lasting 0.3s of 300s, and nothing on screen said so. - Absent is not idle. `player_acceleration` and `toward_goal` returned 0 rather than null whenever the athlete had no position, as did `activity_near_goal` with no rim in frame. That is the mistake ball proximity was already fixed for, and it costs more: it kept 0.35 of weight in every denominator, and reported those signals as measurable, which is what pushed the stated ceiling to 1.000 — telling the user a threshold was reachable that arithmetically was not. - Say how much of the game the athlete is actually on screen for. `focalSeconds` and `focalTrackCount` join the diagnosis, and a binding covering under 5% of the footage now names itself as the likely reason instead of leaving "athlete identified: yes" to imply otherwise. - Re-identification could only shrink an athlete. Matching took the single best new track per old track, so N fragments in gave at most N out, however the new run cut the same child up. The 0.3s binding therefore survived two re-detections intact, over runs producing 3,948 and 1,415 tracks. Every fresh fragment clearing the threshold is now attached. - Do not detect from a proxy smaller than the size the preset detects at. The 540p editing proxy is shorter than every inference size above `fast`, and the worker decodes to its own input size regardless, so the proxy was *upscaled* — identical inference cost for strictly less picture. Measured on this 1080p game, the same preset found 145,975 detections across 3,948 tracks from the source against 67,985 across 1,415 from the proxy. The ball goes first. Verified by re-scoring the production database with the new code (see scripts/rescore-probe.mjs, read-only): the thin binding now reports 0.3s of 300s with a 0.435 ceiling and names itself, and the run goes from 0 moments to 1. Co-Authored-By: Claude Opus 5 (1M context) --- packages/core/src/analyze.ts | 82 ++++++++++++- packages/core/src/media.ts | 12 +- packages/core/src/rebindgrow.test.ts | 155 ++++++++++++++++++++++++ packages/core/src/scoring.ts | 66 +++++++++- packages/core/src/thinfocal.test.ts | 173 +++++++++++++++++++++++++++ packages/core/src/tracks.ts | 24 +++- scripts/rescore-probe.mjs | 68 +++++++++++ 7 files changed, 562 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/rebindgrow.test.ts create mode 100644 packages/core/src/thinfocal.test.ts create mode 100644 scripts/rescore-probe.mjs diff --git a/packages/core/src/analyze.ts b/packages/core/src/analyze.ts index 2c59d8d..f62cd25 100644 --- a/packages/core/src/analyze.ts +++ b/packages/core/src/analyze.ts @@ -9,7 +9,7 @@ import { run } from './ffmpeg.js'; import { createJob, logJob, updateJob } from './jobs.js'; import { getFocalAthlete } from './athletes.js'; import { generateMoments } from './moments.js'; -import { generateProxy, generateThumbnails } from './media.js'; +import { generateProxy, generateThumbnails, PROXY_HEIGHT } from './media.js'; import { readManifest } from './projects.js'; import { clearTracks, createTrack, rebindAthletes, snapshotAthleteBindings } from './tracks.js'; import type { Job, Preset } from './types.js'; @@ -51,6 +51,34 @@ export const PRESET_SETTINGS: Record, PresetSettings> thorough: { frameStride: 2, inferenceSize: 1280, minConfidence: 0.25, useProxy: false, tileGrid: 2 }, }; +/** + * Which file the detector should actually read. + * + * The proxy is only worth detecting from when it is at least as tall as the + * frame the worker will hand the model. `useProxy` was obeyed unconditionally, + * and the 540p editing proxy is shorter than every inference size above `fast`. + * The worker decodes to its own input size regardless, so a 540p proxy was + * *upscaled* — the same inference cost for strictly less picture. Measured on a + * 1080p game, the identical preset found 145,975 detections across 3,948 tracks + * from the source against 67,985 across 1,415 from the proxy; the ball, a + * handful of pixels to begin with, is what goes first. The saving was only ever + * decode time. + */ +export const detectionInputFor = ( + settings: PresetSettings, + video: { path: string; proxyPath: string | null }, + proxyExists: boolean, +): { input: string; usedProxy: boolean; proxyTooSmall: boolean } => { + const proxyTooSmall = settings.useProxy && settings.inferenceSize > PROXY_HEIGHT; + const usedProxy = + settings.useProxy && !proxyTooSmall && video.proxyPath !== null && proxyExists; + return { + input: usedProxy && video.proxyPath !== null ? video.proxyPath : video.path, + usedProxy, + proxyTooSmall, + }; +}; + export const settingsForPreset = (preset: Preset): PresetSettings => { /** * The web form and the API both cast whatever string arrives into `Preset` @@ -289,10 +317,20 @@ export const analyzeProject = async ( const share = (index + 1) / refreshed.length; await stage('detection', 0.2 + 0.5 * share, path.basename(video.path)); - const input = - settings.useProxy && video.proxyPath !== null && existsSync(video.proxyPath) - ? video.proxyPath - : video.path; + const choice = detectionInputFor( + settings, + video, + video.proxyPath !== null && existsSync(video.proxyPath), + ); + const input = choice.input; + if (choice.proxyTooSmall) { + await logJob( + root, + job.id, + `detecting from the original: the ${PROXY_HEIGHT}p proxy is smaller than the ` + + `${settings.inferenceSize}px this preset detects at, so it would only lose detail.`, + ); + } /** * Detection is the long pole — minutes of CPU inference on a full game @@ -558,7 +596,13 @@ export const analyzeProject = async ( root, job.id, `what was seen: ${classes}; longest track ${diagnosis.longestTrackSeconds.toFixed(1)}s; ` + - `athlete identified: ${diagnosis.focalBound ? 'yes' : 'no'}`, + `athlete identified: ${ + diagnosis.focalBound + ? `yes, on screen ${diagnosis.focalSeconds.toFixed(1)}s of ` + + `${diagnosis.durationSeconds.toFixed(0)}s across ` + + `${diagnosis.focalTrackCount} track(s)` + : 'no' + }`, 'warn', ); await logJob( @@ -571,6 +615,32 @@ export const analyzeProject = async ( : ` (no data for: ${diagnosis.unmeasurable.join(', ')})`), 'warn', ); + /** + * The binding is thin: said whether or not the threshold was reachable + * in principle. + * + * Reachability is computed over the whole footage, so a rim visible for + * half a minute can hold the ceiling above the threshold while the + * athlete every focal signal depends on is present for a fraction of a + * second. Production hit exactly that — a binding to a ten-frame + * fragment of a five-minute game — and every line here read plausibly: + * tracks found, athlete identified, threshold reachable, footage too + * dull. The one number that showed the problem was not among them. + */ + const coverage = + diagnosis.durationSeconds > 0 ? diagnosis.focalSeconds / diagnosis.durationSeconds : 0; + if (diagnosis.focalBound && coverage < 0.05) { + await logJob( + root, + job.id, + `your athlete is only on screen for ${diagnosis.focalSeconds.toFixed(1)}s of ` + + `${diagnosis.durationSeconds.toFixed(0)}s (${(coverage * 100).toFixed(1)}%), so every ` + + 'signal that follows them is dark for the rest of the game. That is almost certainly ' + + 'the reason, not the footage. Open "Identify your athlete" and pick them again — ' + + 'choose every fragment of them you can see, not just one.', + 'warn', + ); + } if (!diagnosis.reachable) { // The important case, and the one the old message got wrong. const because = !diagnosis.focalBound diff --git a/packages/core/src/media.ts b/packages/core/src/media.ts index e5c0744..c1daeb7 100644 --- a/packages/core/src/media.ts +++ b/packages/core/src/media.ts @@ -77,8 +77,16 @@ export const generateThumbnails = async ( return { dir, files: readdirSync(dir).sort() }; }; +/** + * Proxy height in pixels. 540 keeps scrubbing smooth on a laptop. + * + * Exported because analysis has to be able to ask whether the proxy is big + * enough to detect from, rather than assuming it always is. + */ +export const PROXY_HEIGHT = 540; + export interface ProxyOptions { - /** Proxy height in pixels. 540 keeps scrubbing smooth on a laptop. */ + /** Proxy height in pixels. Defaults to {@link PROXY_HEIGHT}. */ height?: number; crf?: number; signal?: AbortSignal; @@ -100,7 +108,7 @@ export const generateProxy = async ( } const ffmpeg = requireBinary('ffmpeg'); - const height = options.height ?? 540; + const height = options.height ?? PROXY_HEIGHT; const dir = projectDir(root, 'proxies'); mkdirSync(dir, { recursive: true }); const output = path.join(dir, `${video.id}_${height}p.mp4`); diff --git a/packages/core/src/rebindgrow.test.ts b/packages/core/src/rebindgrow.test.ts new file mode 100644 index 0000000..8a46f6b --- /dev/null +++ b/packages/core/src/rebindgrow.test.ts @@ -0,0 +1,155 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +/** + * A re-bind has to be able to *grow* an athlete's coverage, not merely survive. + * + * Matching took the single best new track per old track, so N fragments in gave + * at most N fragments out — however the new run happened to cut the same child + * up. In production a binding to one ten-frame fragment came back from + * re-detection as one ten-frame fragment, twice in a row, over runs that + * produced 3,948 and then 1,415 tracks. The athlete every focal signal depends + * on was therefore present for 0.3s of a 300s game, and the run suggested + * nothing while reporting "athlete identified: yes". + */ + +let home: string; + +beforeAll(() => { + home = mkdtempSync(path.join(tmpdir(), 'reeleel-rebindgrow-')); + process.env['REELEEL_HOME'] = home; +}); + +afterAll(async () => { + const { resetDbCache } = await import('./db.js'); + resetDbCache(); + rmSync(home, { recursive: true, force: true }); + delete process.env['REELEEL_HOME']; +}); + +const project = async (name: string): Promise => { + const { createProject } = await import('./projects.js'); + const created = await createProject({ + name, + path: path.join(home, 'projects', `${name}-${process.hrtime.bigint()}`), + }); + return created.path ?? created.root; +}; + +const video = async (root: string, id: string): Promise => { + const { execute, projectDb } = await import('./db.js'); + const db = await projectDb(root); + const now = new Date().toISOString(); + await execute( + db, + 'INSERT INTO source_videos (id, project_id, path, created_at, updated_at) VALUES (?, ?, ?, ?, ?)', + [id, 'prj_test', `/tmp/${id}.mp4`, now, now], + ); +}; + +/** Dense samples along a straight walk, the way the tracker emits them. */ +const walk = (from: number, to: number, offset = 0) => { + const out = []; + for (let i = 0; i <= Math.round((to - from) * 4); i += 1) { + const ts = from + i / 4; + out.push({ ts, frame: Math.round(ts * 30), x: 100 + ts * 10 + offset, y: 300, w: 40, h: 100, confidence: 0.9 }); + } + return out; +}; + +describe('re-identifying an athlete across a re-detection', () => { + it('picks up every new fragment of the athlete, not one per old fragment', async () => { + const root = await project('grow'); + await video(root, 'vid_a'); + const { createTrack, clearTracks, snapshotAthleteBindings, rebindAthletes, tracksForAthlete } = + await import('./tracks.js'); + const { addAthlete, updateAthlete } = await import('./athletes.js'); + + // The old run saw the child as one long track. + const old = await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(0, 30), + }); + const athlete = await addAthlete(root, { name: 'Kid' }); + await updateAthlete(root, athlete.id, { focalTrackId: old.id, focal: true }); + + const remembered = await snapshotAthleteBindings(root, 'vid_a'); + await clearTracks(root, 'vid_a'); + + // The new run cuts the same child into three consecutive pieces, and also + // sees a different child on the far side of the court throughout. + for (const [from, to] of [ + [0, 9], + [10, 19], + [20, 30], + ] as const) { + await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(from, to, 2), + }); + } + await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(0, 30, 900), + }); + + const restored = await rebindAthletes(root, 'vid_a', remembered); + expect(restored).toHaveLength(1); + + /** + * All three pieces, which is the whole point. One-best-per-old-track would + * return exactly one here and silently drop two thirds of the athlete. + */ + const assigned = await tracksForAthlete(root, athlete.id); + expect(assigned).toHaveLength(3); + }); + + it('still refuses a child who merely walked through the same space later', async () => { + const root = await project('stranger'); + await video(root, 'vid_a'); + const { createTrack, clearTracks, snapshotAthleteBindings, rebindAthletes, tracksForAthlete } = + await import('./tracks.js'); + const { addAthlete, updateAthlete } = await import('./athletes.js'); + + const old = await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(0, 30), + }); + const athlete = await addAthlete(root, { name: 'Kid' }); + await updateAthlete(root, athlete.id, { focalTrackId: old.id, focal: true }); + + const remembered = await snapshotAthleteBindings(root, 'vid_a'); + await clearTracks(root, 'vid_a'); + + // Same path, a different half of the game: never on screen together, so not + // the same person as far as anything here can tell. + await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(200, 230), + }); + // And the real athlete. + await createTrack(root, { + videoId: 'vid_a', + className: 'player', + confidence: 0.9, + samples: walk(0, 30, 2), + }); + + await rebindAthletes(root, 'vid_a', remembered); + const assigned = await tracksForAthlete(root, athlete.id); + expect(assigned).toHaveLength(1); + }); +}); diff --git a/packages/core/src/scoring.ts b/packages/core/src/scoring.ts index caf6916..79086c1 100644 --- a/packages/core/src/scoring.ts +++ b/packages/core/src/scoring.ts @@ -341,7 +341,18 @@ export const SIGNALS: Record = { if (context.focal === null) return null; const before = focalVelocityAt(context, ts - 0.5); const after = focalVelocityAt(context, ts + 0.5); - if (before === null || after === null) return 0; + /** + * Off screen is unmeasured, not motionless. + * + * This returned 0, which is the same mistake ball proximity used to make and + * costs more, because an athlete is absent from far more of a game than the + * ball is. Bound to a ten-frame fragment of a five-minute game, this signal + * and `toward_goal` between them kept 0.35 of the weight in the denominator + * and contributed nothing to the numerator for 99.9% of the footage — and + * reported themselves as "measurable" to the diagnosis, which then told the + * user the threshold was reachable when it was arithmetically not. + */ + if (before === null || after === null) return null; const delta = Math.abs(magnitude(after) - magnitude(before)); // Treat a 10%-of-diagonal-per-second change as a full-strength burst. return clamp01(delta / (context.diagonal * 0.1)); @@ -351,12 +362,15 @@ export const SIGNALS: Record = { if (context.focal === null || context.goals.length === 0) return null; const player = focalAt(context, ts); const velocity = focalVelocityAt(context, ts); - if (player === null || velocity === null || magnitude(velocity) < 1) return 0; + // Absent athlete: unmeasurable. Present but stationary: a real zero. + if (player === null || velocity === null) return null; + if (magnitude(velocity) < 1) return 0; const goalPoints = context.goals .map((goal) => sampleAt(goal, ts)) .filter((p): p is Point => p !== null); - if (goalPoints.length === 0) return 0; + // No rim in frame at this instant is no evidence either way. + if (goalPoints.length === 0) return null; const best = goalPoints.reduce((closest, point) => distance(player, point) < distance(player, closest) ? point : closest, @@ -370,7 +384,9 @@ export const SIGNALS: Record = { const goalPoints = context.goals .map((goal) => sampleAt(goal, ts)) .filter((p): p is Point => p !== null); - if (goalPoints.length === 0) return 0; + // The rim exists somewhere in the footage but is not in this frame; that is + // not a quiet key, it is no measurement. + if (goalPoints.length === 0) return null; const near = context.others.filter((track) => { const point = sampleAt(track, ts); @@ -387,7 +403,8 @@ export const SIGNALS: Record = { .map((track) => velocityAt(track, ts)) .filter((v): v is Point => v !== null) .map(magnitude); - if (speeds.length === 0) return 0; + // Nobody on screen at all: unmeasured, rather than a still court. + if (speeds.length === 0) return null; const mean = speeds.reduce((sum, s) => sum + s, 0) / speeds.length; return clamp01((mean / context.baselineSpeed - 1) / 1.5); }, @@ -470,6 +487,20 @@ export interface ScoringDiagnosis { /** False when the threshold is unreachable no matter what happens on screen. */ reachable: boolean; focalBound: boolean; + /** + * Seconds the focal athlete is actually on screen, and across how many + * fragments. + * + * "Athlete identified: yes" was the whole of what a user was told, and it is + * true of a binding to a ten-frame fragment of a five-minute game just as it + * is of a binding that follows the child all afternoon. The first cannot + * produce a moment and the second can, so the flag on its own sent people to + * re-shoot footage that was never the problem. + */ + focalSeconds: number; + focalTrackCount: number; + /** Length of the footage, so focal coverage can be read as a fraction. */ + durationSeconds: number; /** How many tracks of each class the detector produced. */ tracksByClass: Record; /** Longest single track, in seconds — short ones mean fragmented tracking. */ @@ -480,6 +511,27 @@ export interface ScoringDiagnosis { unmeasurable: string[]; } +/** + * Seconds the stitched athlete is genuinely followable — the spans scoring can + * read, not first-to-last. + * + * Gaps longer than `MAX_FOCAL_GAP` are exactly the ones `focalAt` refuses to + * interpolate across, so counting them would promise coverage the signals + * cannot use. + */ +const focalCoverageSeconds = (focal: TrackSeries | null): number => { + if (focal === null) return 0; + let covered = 0; + for (let i = 1; i < focal.samples.length; i += 1) { + const previous = focal.samples[i - 1]; + const current = focal.samples[i]; + if (previous === undefined || current === undefined) continue; + const span = current.ts - previous.ts; + if (span > 0 && span <= MAX_FOCAL_GAP) covered += span; + } + return covered; +}; + export const explainScoring = (input: ScoringInput, plugin: SportPlugin): ScoringDiagnosis => { const step = input.windowSeconds ?? 1; const context = buildContext(input, plugin.targetClass); @@ -531,6 +583,10 @@ export const explainScoring = (input: ScoringInput, plugin: SportPlugin): Scorin ceiling, reachable: ceiling >= plugin.moments.minScore, focalBound: context.focal !== null, + focalSeconds: focalCoverageSeconds(context.focal), + focalTrackCount: + input.focalTrackIds?.length ?? (input.focalTrackId === null ? 0 : 1), + durationSeconds: input.durationSeconds, tracksByClass, longestTrackSeconds, measurable: [...measurable], diff --git a/packages/core/src/thinfocal.test.ts b/packages/core/src/thinfocal.test.ts new file mode 100644 index 0000000..85420ca --- /dev/null +++ b/packages/core/src/thinfocal.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest'; + +import { getSport } from '@reeleel/sports'; + +import { detectionInputFor, PRESET_SETTINGS } from './analyze.js'; +import { computeMoments, explainScoring, SIGNALS, buildContext } from './scoring.js'; +import type { ScoringInput, TrackSeries } from './scoring.js'; + +/** + * The shape of a real run that suggested nothing, and reported every reason but + * the true one. + * + * Production: a five-minute basketball game, 1,415 tracks, a rim seen for 28s, a + * ball seen for 23s — and a focal athlete bound to a ten-frame fragment lasting + * 0.3s. Every line the user was shown read plausibly ("tracks found", "athlete + * identified: yes", "highest reachable 1.000", "none scored above 0.35"), which + * together say "your footage was dull". The footage was fine. These tests pin + * the arithmetic that made those lines wrong. + */ + +const DURATION = 300; +const plugin = getSport('basketball')!; +const FPS = 30; + +/** Dense samples over a span, the way the tracker actually emits them. */ +const spanSamples = ( + from: number, + to: number, + fn: (ts: number) => { x: number; y: number; w: number; h: number }, +) => { + const out = []; + for (let ts = from; ts <= to; ts += 1 / FPS) { + out.push({ ts: Number(ts.toFixed(3)), ...fn(ts), confidence: 0.9 }); + } + return out; +}; + +const wanderingPlayer = (id: string, from: number, to: number, offset = 0): TrackSeries => ({ + id, + className: 'player', + samples: spanSamples(from, to, (ts) => ({ + x: 200 + offset + Math.sin(ts) * 300, + y: 500 + Math.cos(ts * 0.7) * 120, + w: 60, + h: 150, + })), +}); + +const hoop: TrackSeries = { + id: 'trk_hoop', + className: 'hoop', + samples: spanSamples(0, 28, () => ({ x: 1500, y: 200, w: 80, h: 60 })), +}; + +const input = (focalTrackIds: string[], tracks: TrackSeries[]): ScoringInput => ({ + durationSeconds: DURATION, + frameWidth: 1920, + frameHeight: 1080, + focalTrackId: focalTrackIds[0] ?? null, + focalTrackIds, + tracks, +}); + +describe('an athlete bound to a sliver of the game', () => { + /** The production binding: 10 frames, ts 0 → 0.3, of a 300s video. */ + const sliver: TrackSeries = { + id: 'trk_sliver', + className: 'player', + samples: spanSamples(0, 0.3, () => ({ x: 400, y: 500, w: 60, h: 150 })), + }; + const crowd = [ + wanderingPlayer('trk_a', 0, 300), + wanderingPlayer('trk_b', 0, 300, 400), + wanderingPlayer('trk_c', 219, 251, 800), + ]; + + it('reports how little of the game the athlete is actually on screen for', () => { + const diagnosis = explainScoring(input(['trk_sliver'], [sliver, ...crowd, hoop]), plugin); + + expect(diagnosis.focalBound).toBe(true); + // The number that was missing. "Identified: yes" was true and useless. + expect(diagnosis.focalSeconds).toBeLessThan(1); + expect(diagnosis.durationSeconds).toBe(300); + expect(diagnosis.focalTrackCount).toBe(1); + }); + + it('does not count athlete signals as measurable when the athlete is absent', () => { + const diagnosis = explainScoring(input(['trk_sliver'], [sliver, ...crowd, hoop]), plugin); + + /** + * `player_acceleration` and `toward_goal` used to return 0 rather than null + * whenever the athlete had no position, which is 99.9% of this footage. + * That made them "measurable", kept 0.35 of weight in every denominator, + * and pushed the reported ceiling to 1.000 — telling the user a threshold + * was reachable that arithmetically was not. + */ + expect(diagnosis.unmeasurable).toContain('player_acceleration'); + expect(diagnosis.unmeasurable).toContain('toward_goal'); + expect(diagnosis.unmeasurable).toContain('player_ball_proximity'); + expect(diagnosis.ceiling).toBeLessThan(1); + }); + + it('suggests nothing, because nothing can be measured about the athlete', () => { + expect(computeMoments(input(['trk_sliver'], [sliver, ...crowd, hoop]), plugin)).toEqual([]); + }); +}); + +describe('signals that cannot see the athlete', () => { + const present: TrackSeries = { + id: 'trk_focal', + className: 'player', + samples: spanSamples(100, 130, (ts) => ({ + x: 400 + (ts - 100) * 30, + y: 500, + w: 60, + h: 150, + })), + }; + const context = buildContext( + input(['trk_focal'], [present, wanderingPlayer('trk_x', 0, 300), hoop]), + plugin.targetClass, + ); + + it('returns null, not zero, for acceleration outside the athlete’s span', () => { + // Off screen is unmeasured; zero would mean "measured, standing still". + expect(SIGNALS['player_acceleration']?.(context, 200)).toBeNull(); + expect(SIGNALS['player_acceleration']?.(context, 115)).not.toBeNull(); + }); + + it('returns null for toward-goal when the athlete or the rim is not in frame', () => { + // Athlete present (100–130) but the rim is only tracked over 0–28. + expect(SIGNALS['toward_goal']?.(context, 115)).toBeNull(); + // Athlete absent entirely. + expect(SIGNALS['toward_goal']?.(context, 250)).toBeNull(); + }); + + it('returns null for activity near the goal when no rim is in frame', () => { + expect(SIGNALS['activity_near_goal']?.(context, 200)).toBeNull(); + expect(SIGNALS['activity_near_goal']?.(context, 10)).not.toBeNull(); + }); +}); + +describe('choosing what the detector reads', () => { + const video = { path: '/p/source/game.mp4', proxyPath: '/p/proxies/vid_540p.mp4' }; + + it('skips a proxy smaller than the size the preset detects at', () => { + // balanced asks for 768 against a 540p proxy: upscaling, for no saving but + // decode time. This is what cost the production run 78,000 detections. + const choice = detectionInputFor(PRESET_SETTINGS.balanced, video, true); + expect(choice.input).toBe(video.path); + expect(choice.usedProxy).toBe(false); + expect(choice.proxyTooSmall).toBe(true); + }); + + it('still uses the proxy when it is big enough for the preset', () => { + const choice = detectionInputFor(PRESET_SETTINGS.fast, video, true); + expect(choice.input).toBe(video.proxyPath); + expect(choice.usedProxy).toBe(true); + expect(choice.proxyTooSmall).toBe(false); + }); + + it('falls back to the source when the proxy does not exist yet', () => { + expect(detectionInputFor(PRESET_SETTINGS.fast, video, false).input).toBe(video.path); + }); + + it('leaves presets that already read the original alone', () => { + for (const preset of ['accurate', 'thorough'] as const) { + const choice = detectionInputFor(PRESET_SETTINGS[preset], video, true); + expect(choice.input).toBe(video.path); + expect(choice.proxyTooSmall).toBe(false); + } + }); +}); diff --git a/packages/core/src/tracks.ts b/packages/core/src/tracks.ts index 4ad43c8..bcedaef 100644 --- a/packages/core/src/tracks.ts +++ b/packages/core/src/tracks.ts @@ -348,19 +348,33 @@ export const rebindAthletes = async ( const restored: { athleteId: string; trackIds: string[] }[] = []; for (const binding of bindings) { + /** + * Every new track that occupies the athlete's old space and time, not the + * single best one per old fragment. + * + * Taking one winner per old fragment made a re-bind incapable of ever + * *growing* coverage: N fragments in, at most N fragments out, however the + * new run happened to cut the same child up. A binding to one ten-frame + * fragment therefore survived re-detection as one ten-frame fragment, + * twice, while the run underneath it produced 1,415 tracks. Two tracks + * cannot be the same person at the same instant standing in the same box, + * so anything clearing the threshold is them. + */ const matched = new Set(); for (const old of binding.series) { - let best: { id: string; score: number } | null = null; for (const candidate of fresh) { if (candidate.className !== old.className) continue; - const score = trackSimilarity(old, candidate); - if (score > (best?.score ?? 0)) best = { id: candidate.id, score }; + if (trackSimilarity(old, candidate) >= REBIND_THRESHOLD) matched.add(candidate.id); } - if (best !== null && best.score >= REBIND_THRESHOLD) matched.add(best.id); } if (matched.size === 0) continue; - const trackIds = [...matched]; + // Longest first, so the single `focal_track_id` fallback is the most useful + // fragment rather than whichever one hashed first. + const byId = new Map(fresh.map((track) => [track.id, track])); + const trackIds = [...matched].sort( + (a, b) => (byId.get(b)?.samples.length ?? 0) - (byId.get(a)?.samples.length ?? 0), + ); const primary = trackIds[0]; if (primary === undefined) continue; await assignTracksToAthlete(root, binding.athleteId, trackIds); diff --git a/scripts/rescore-probe.mjs b/scripts/rescore-probe.mjs new file mode 100644 index 0000000..0a66791 --- /dev/null +++ b/scripts/rescore-probe.mjs @@ -0,0 +1,68 @@ +/** + * Re-scores an existing project database with the current in-repo scoring code, + * without touching it. Read-only: it prints what the scorer would say. + * + * Bundled and run against production to check a scoring change against the data + * that motivated it, rather than against synthetic tracks that agree with it. + * + * node scripts/rescore-probe.mjs [athleteId] + */ +import { DatabaseSync } from 'node:sqlite'; + +/* + * Straight at the built modules, not the package indexes: @reeleel/core's entry + * pulls in the libsql driver's native binding, which is neither needed here nor + * portable into a bundle. Run `pnpm -r build` first. + */ +import { getSport } from '../packages/sports/dist/index.js'; +import { computeMoments, explainScoring } from '../packages/core/dist/scoring.js'; + +const [dbPath, athleteOverride] = process.argv.slice(2); +if (dbPath === undefined) throw new Error('usage: rescore-probe.mjs [athleteId]'); + +const db = new DatabaseSync(dbPath, { readOnly: true }); +const rows = (sql, ...params) => db.prepare(sql).all(...params); + +const video = rows('SELECT id, probe_json FROM source_videos')[0]; +const probe = JSON.parse(video.probe_json ?? '{}'); + +const tracks = rows('SELECT id, class FROM tracks WHERE video_id = ?', video.id).map((track) => ({ + id: track.id, + className: track.class, + samples: rows( + 'SELECT ts, x, y, w, h, confidence FROM track_points WHERE track_id = ? ORDER BY frame', + track.id, + ), +})); + +const athletes = rows('SELECT id, name, focal_track_id, is_focal FROM athletes'); +const plugin = getSport(rows("SELECT value FROM meta WHERE key = 'sport'")[0]?.value ?? 'basketball'); + +for (const athlete of athletes) { + if (athleteOverride !== undefined && athlete.id !== athleteOverride) continue; + const assigned = rows('SELECT id FROM tracks WHERE athlete_id = ?', athlete.id).map((r) => r.id); + const input = { + durationSeconds: probe.durationSeconds ?? 0, + frameWidth: probe.video?.width ?? 1920, + frameHeight: probe.video?.height ?? 1080, + focalTrackId: athlete.focal_track_id, + focalTrackIds: assigned.length > 0 ? assigned : undefined, + tracks, + }; + + const diagnosis = explainScoring(input, plugin); + const moments = computeMoments(input, plugin); + console.log(`\n=== ${athlete.name} (${athlete.id}) is_focal=${athlete.is_focal} ===`); + console.log(` bound tracks : ${assigned.length}`); + console.log(` on screen : ${diagnosis.focalSeconds.toFixed(1)}s of ${diagnosis.durationSeconds}s`); + console.log(` best window : ${diagnosis.bestScore.toFixed(3)} at ${diagnosis.bestTs.toFixed(0)}s (threshold ${diagnosis.threshold})`); + console.log(` ceiling : ${diagnosis.ceiling.toFixed(3)} reachable=${diagnosis.reachable}`); + console.log(` measurable : ${diagnosis.measurable.join(', ') || 'none'}`); + console.log(` unmeasurable : ${diagnosis.unmeasurable.join(', ') || 'none'}`); + console.log(` moments : ${moments.length}`); + for (const moment of moments.slice(0, 12)) { + console.log( + ` ${moment.start.toFixed(1)}s–${moment.end.toFixed(1)}s score ${moment.score.toFixed(3)} [${moment.reasons.join(', ')}]`, + ); + } +}