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(', ')}]`, + ); + } +}