// 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 #include "nodes/scene_detector_node.hpp" #include #include #include namespace { // The intervals the node accumulates from a steady stream at `fps`. std::vector cadence(double fps, int n = 200) { return std::vector(static_cast(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); } // ── AR-004 — the window stores the model's input, not the decoded frame ─────── // // TRACES: AR-004, AR-010 | SR-002 | UT-003 // // The rolling window held frames as decoded, at full resolution, and left the // downscale to the backend — ~590 MB at 1080p to feed a model whose input is // 48x27, about 380 KB. Not a channel capacity, so no amount of tuning channel // depths would have found it. // // The risk in fixing it is the project invariant: every model gets the input it // was trained for. A model run off-distribution returns confident, plausible, // wrong output, and here that means fabricated shot boundaries — which would be // indistinguishable from a real cut in the output. // // So these cases do not check that the frames got smaller. They check that the // pixels are *identical* to what the backend would have produced from the full // frame, by performing the backend's own two operations independently and // comparing byte for byte. Both ort_backend.cpp and trt_backend.cpp guard // mis-sized input with convertTo(CV_8UC3) then // cv::resize(..., {kFrameW, kFrameH}, 0, 0, cv::INTER_AREA), in that order. namespace { cv::Mat gradient(int w, int h) { // Structured content, not a flat fill: INTER_AREA averages, so a constant // image would compare equal under almost any resize and prove nothing. cv::Mat m(h, w, CV_8UC3); for (int y = 0; y < h; ++y) for (int x = 0; x < w; ++x) m.at(y, x) = cv::Vec3b( static_cast((x * 7 + y * 3) % 256), static_cast((x * 13 + y * 5) % 256), static_cast((x * 3 + y * 11) % 256)); return m; } bool identical(const cv::Mat& a, const cv::Mat& b) { if (a.size() != b.size() || a.type() != b.type()) return false; cv::Mat diff; cv::absdiff(a, b, diff); return cv::countNonZero(diff.reshape(1)) == 0; } } // namespace TEST_CASE("the window frame is what the backend would have produced", "[scene][AR-004]") { for (auto [w, h] : {std::pair{1920, 1080}, std::pair{640, 360}, std::pair{720, 480}}) { INFO("source " << w << "x" << h); const cv::Mat full = gradient(w, h); // The backend's own guard, performed here independently. cv::Mat expected; cv::resize(full, expected, {ISceneDetector::kFrameW, ISceneDetector::kFrameH}, 0, 0, cv::INTER_AREA); const cv::Mat got = SceneDetectorFunc::to_model_input(full); REQUIRE(got.cols == ISceneDetector::kFrameW); REQUIRE(got.rows == ISceneDetector::kFrameH); REQUIRE(got.type() == CV_8UC3); CHECK(identical(got, expected)); } } TEST_CASE("a frame already at model size is passed through untouched", "[scene][AR-004]") { // The backend skips its guard for a correctly-sized frame, so this path must // not resize either — resampling an already-48x27 image would change it. const cv::Mat exact = gradient(ISceneDetector::kFrameW, ISceneDetector::kFrameH); CHECK(identical(SceneDetectorFunc::to_model_input(exact), exact)); } TEST_CASE("conversion happens before the resize, as the backend does it", "[scene][AR-004]") { // Order matters: converting a 4-channel frame after downscaling averages // alpha into the colour channels and gives different pixels. The backends // convert first, so this must too. cv::Mat four(360, 640, CV_8UC4, cv::Scalar(10, 20, 30, 255)); cv::Mat typed; four.convertTo(typed, CV_8UC3); cv::Mat expected; cv::resize(typed, expected, {ISceneDetector::kFrameW, ISceneDetector::kFrameH}, 0, 0, cv::INTER_AREA); CHECK(identical(SceneDetectorFunc::to_model_input(four), expected)); } TEST_CASE("the window's memory is bounded by the model input, not the source", "[scene][AR-004]") { // The point of the change, stated as a number: a full window of 1080p // frames is ~590 MB as decoded and ~380 KB as model input. const cv::Mat full = gradient(1920, 1080); const cv::Mat small = SceneDetectorFunc::to_model_input(full); const std::size_t decoded = full.total() * full.elemSize(); const std::size_t stored = small.total() * small.elemSize(); INFO("decoded " << decoded << " B, stored " << stored << " B"); CHECK(stored * 1000 < decoded); // three orders of magnitude CHECK(stored == ISceneDetector::kFrameW * ISceneDetector::kFrameH * 3u); }