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
+86
View File
@@ -0,0 +1,86 @@
// AR-011 — the boundary dedup window is derived from the stream's cadence, not
// assumed.
//
// TRACES: AR-011 | SR-002 | UT-003
//
// Tier T1: the derivation is arithmetic on frame timestamps, so it is checked
// against synthetic cadences at 24, 25 and 30 fps rather than against a decode.
// The number this replaced was 0.04 s — one frame at 25 fps, correct for exactly
// one of those three and quietly wrong for the other two.
//
// SceneDetectorFunc is never constructed: its constructor loads TransNetV2. Only
// the static rule is called, so make_scene_detector() is never odr-used.
#include <catch2/catch_test_macros.hpp>
#include "nodes/scene_detector_node.hpp"
#include <vector>
namespace {
// The intervals the node accumulates from a steady stream at `fps`.
std::vector<double> cadence(double fps, int n = 200) {
return std::vector<double>(static_cast<std::size_t>(n), 1.0 / fps);
}
double window(double fps) {
return SceneDetectorFunc::dedup_window_sec(cadence(fps));
}
} // namespace
// ── The property that has to hold at every rate ──────────────────────────────
// The window has exactly one job: tell "one frame scored twice by two
// overlapping windows" (a gap of zero) from "two adjacent frames, both of them
// real cuts" (a gap of one frame interval). It has to sit strictly between.
TEST_CASE("the dedup window separates a duplicate from an adjacent frame",
"[scene][AR-011]") {
for (double fps : {24.0, 25.0, 30.0, 23.976, 29.97, 50.0, 60.0}) {
INFO("source at " << fps << " fps");
const double frame = 1.0 / fps;
const double w = window(fps);
CHECK(w > 0.0); // a duplicate (gap 0) is still merged
CHECK(w < frame); // two consecutive frames both survive
}
}
// The concrete failure the hardcoded constant caused: at 30 fps a frame is
// 0.0333 s, so a 0.04 s window swallowed a cut on the very next frame. Nothing in
// the output showed it — the file just had fewer boundaries.
TEST_CASE("cuts on consecutive frames survive at 30 fps", "[scene][AR-011]") {
const double frame = 1.0 / 30.0;
CHECK(window(30.0) < frame);
CHECK(0.04 > frame); // the constant that was there, for the record
}
TEST_CASE("the window tracks the rate rather than a constant",
"[scene][AR-011]") {
// If it were still assumed, these would be equal.
CHECK(window(24.0) > window(30.0));
CHECK(window(30.0) > window(60.0));
CHECK(window(25.0) == 0.5 / 25.0);
}
// ── Robustness of the estimate ───────────────────────────────────────────────
TEST_CASE("a seek or a dropped frame does not move the derived cadence",
"[scene][AR-011]") {
auto intervals = cadence(25.0);
intervals[0] = 3.5; // a seek at the start
intervals[97] = 0.4; // a gap where the decoder lost frames
// Median, not mean: two long intervals out of 200 cannot shift it at all.
CHECK(SceneDetectorFunc::dedup_window_sec(intervals) == 0.5 / 25.0);
}
TEST_CASE("too few frames to have a cadence yields an inert window",
"[scene][AR-011]") {
// Under two frames there is no interval to measure — and also no second
// boundary to merge with, so a window of 0 changes nothing. Guessing a rate
// here would be the mistake this requirement is about.
CHECK(SceneDetectorFunc::dedup_window_sec({}) == 0.0);
}
TEST_CASE("a single observed interval is enough", "[scene][AR-011]") {
CHECK(SceneDetectorFunc::dedup_window_sec({1.0 / 24.0}) == 0.5 / 24.0);
}