feat(scene): feed TransNetV2 at native rate, derive the dedup window from it

Closes both violations SPEC.md named under "Every model gets the input it
was trained for". They are one bug, not two.

The dense stream defaulted to 12 fps, so a 100-frame TransNetV2 window
spanned ~8.3 s against the ~4 s it was trained on: half-speed motion over
twice its temporal context. Boundary timestamps stayed correct throughout,
which is exactly why the degradation was invisible and why the compressed
separation it produced (~0.50 baseline against ~0.7+ peaks) was read as a
property of the ONNX export rather than of the input.

Dedup then merged boundaries closer than a literal 0.04 s — one frame at
25 fps, and wider than a frame at 30, so two cuts on consecutive frames
became one. Nothing in scenes.json showed it; the file simply had fewer
boundaries. Native rate is where that constant did the most damage, which
is why fixing the decode rate without fixing the dedup would have made
things worse.

dedup_window_sec() now takes the median interval the detector was actually
fed and halves it. Half a frame rather than a whole one: the only thing
being merged is one frame scored by two overlapping windows, and two
distinct frames are a full interval apart.

Cost is real — dense decode is the pipeline's cost driver. It is accepted;
dense_scale and scene_stride remain the reductions that do not run the
model off-distribution. scene_threshold 0.60 was fitted against the 12 fps
input and is now stale, so VR-006 goes from Low to Medium: it is no longer
a refinement, it is a constant that no longer describes the input.

AR-002 rides along because it was already implemented, just untagged and
unverified — the register said Planned while the code was correct. The size
filter becomes FaceDetectorFunc::drop_undersized(), tested at the threshold
and at dense_scale 0.5, and checked end to end against the superhero dump,
whose smallest face is exactly its recorded 32 px minimum, so the fixture
check cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: AR-002, AR-011 | SR-002 | UT-002, UT-003, IT-001
This commit is contained in:
2026-08-04 21:17:57 +02:00
co-authored by Claude Opus 5
parent 079b490ede
commit f33403fff8
10 changed files with 378 additions and 44 deletions
+20 -9
View File
@@ -91,17 +91,28 @@ struct Config {
// at ~0.50; real boundaries spike to ~0.7+)
int scene_stride{50}; // frames advanced between windows (≤ kWindow)
// Dense-decode throughput knobs (only active with scene_detect). Dense decode
// of every native-rate frame is the pipeline's cost driver; these trade a
// little boundary precision for a large speedup.
// scene_decode_fps: rate the source decodes at in dense mode. Lower =
// fewer frames decoded. TransNetV2 tolerates ~12fps; boundary timestamps
// stay correct (keyed off each frame's real timestamp). 0 = native fps.
// Dense-decode knobs (only active with scene_detect). Dense decode of every
// native-rate frame is the pipeline's cost driver, which is what made the
// temporal shortcut below tempting.
/// TRACES: AR-011 | SR-002
// scene_decode_fps: rate the source decodes at in dense mode.
// **0 = native, and native is the only correct setting.** kWindow is 100
// frames: at native 25 fps that window spans ~4 s, which is what
// TransNetV2 was trained on; at the 12 fps this used to default to it
// spans ~8.3 s, so the model saw half-speed motion over twice its
// temporal context. Boundary *timestamps* stay right either way — which
// is exactly why the degradation was invisible, and why the compressed
// separation it produced (~0.50 baseline against ~0.7+ peaks) was read
// as a property of the export rather than of the input. Lowering this
// buys decode time by running the model off-distribution; reach for
// dense_scale or scene_stride instead, which do not.
// dense_scale: downscale factor applied to decoded frames in dense mode
// (0<f≤1; e.g. 0.5 = half size). Cheaper sws_scale + smaller frames
// through the fanout. NOTE: also shrinks what the face detector sees
// keep ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
float scene_decode_fps{12.0f}; // dense decode rate (0 = native)
// through the fanout. A spatial reduction, and TransNetV2 downsamples to
// 48×27 regardless, so unlike the above it is a documented, understood
// degradation. NOTE: also shrinks what the face detector sees — keep
// ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
float scene_decode_fps{0.f}; // dense decode rate (0 = native)
float dense_scale{1.0f}; // dense-mode frame downscale (1 = off)
// ── Face tracking (frame-to-frame) ───────────────────────────────────────