Files
scene-actor-extraction/tests/test_face_detector_node.cpp
dtourolleandClaude Opus 5 f33403fff8 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
2026-08-04 21:17:57 +02:00

115 lines
5.1 KiB
C++

// AR-002 — minimum face size, in original video resolution.
//
// TRACES: AR-002 | SR-002 | UT-002
//
// Tier T1 here, T2 in test_replay_fixtures.cpp. The requirement is arithmetic on
// bounding boxes, so the two edge cases that matter — a face sitting exactly on
// the threshold, and the same face seen through a downscaled decode — are
// reachable without a detector, a model or a GPU. What the fixture check adds is
// that the rule was actually applied on the way to a dump; what this adds is that
// it is applied *correctly*, which no real dump can demonstrate because real
// footage does not contain a 39.999 px face on demand.
//
// FaceDetectorFunc is never constructed: its constructor loads SCRFD. Only the
// static rule is called, so make_face_detector() is never odr-used and nothing
// here needs a backend.
#include <catch2/catch_test_macros.hpp>
#include "nodes/face_detector_node.hpp"
#include "types.hpp"
#include <vector>
namespace {
DetectedFace box(float w, float h) {
DetectedFace f;
f.bbox = cv::Rect2f(10.f, 10.f, w, h);
f.confidence = 0.9f;
return f;
}
// Sizes the rule kept, in the order given.
std::vector<float> surviving_widths(std::vector<DetectedFace> faces,
float min_face_px, float bbox_upscale) {
FaceDetectorFunc::drop_undersized(faces, min_face_px, bbox_upscale);
std::vector<float> out;
for (const auto& f : faces) out.push_back(f.bbox.width);
return out;
}
constexpr float kMin = 40.f; // Config::min_face_px default, and AR-002's number
} // namespace
// ── Exactly at the threshold ─────────────────────────────────────────────────
// The boundary case is the whole content of a minimum: "40x40" has to mean 40 is
// admissible, or the requirement says 41.
TEST_CASE("a face exactly at the minimum is kept", "[detector][AR-002]") {
CHECK(surviving_widths({box(kMin, kMin)}, kMin, 1.f).size() == 1);
}
TEST_CASE("a face one tenth of a pixel under the minimum is dropped",
"[detector][AR-002]") {
CHECK(surviving_widths({box(39.9f, 100.f)}, kMin, 1.f).empty());
CHECK(surviving_widths({box(100.f, 39.9f)}, kMin, 1.f).empty());
}
TEST_CASE("both sides must clear the minimum, not the larger one",
"[detector][AR-002]") {
// A wide, short box has enough pixels and is still unusable: ArcFace
// alignment needs both dimensions. Area would admit this; the rule must not.
CHECK(surviving_widths({box(400.f, 20.f)}, kMin, 1.f).empty());
}
TEST_CASE("the filter is a filter, not a reordering", "[detector][AR-002]") {
auto kept = surviving_widths(
{box(80.f, 80.f), box(10.f, 10.f), box(60.f, 60.f), box(39.f, 39.f)},
kMin, 1.f);
REQUIRE(kept.size() == 2);
// Order is load-bearing downstream (AR-003's largest-first sort tie-breaks on
// it, and the Hungarian solver tie-breaks on index) — erase-remove must not
// shuffle the survivors.
CHECK(kept[0] == 80.f);
CHECK(kept[1] == 60.f);
}
// ── The dense_scale interaction the requirement exists for ───────────────────
// dense_scale 0.5 halves the decoded frame, so the detector reports a 40 px face
// as 20 px. If the threshold were applied to those numbers, turning on a
// throughput knob would silently double the minimum face size the pipeline
// accepts — a recall change with no line in the config to explain it. AR-002
// pins the minimum to the ORIGINAL resolution instead.
TEST_CASE("at dense_scale 0.5 the cutoff stays 40 px of original footage",
"[detector][AR-002]") {
constexpr float kUpscale = 2.f; // frame_source_node: 1 / dense_scale
// 20 px in downscaled space is exactly 40 px of original footage: kept.
CHECK(surviving_widths({box(20.f, 20.f)}, kMin, kUpscale).size() == 1);
// 19.9 px downscaled is 39.8 px original: dropped.
CHECK(surviving_widths({box(19.9f, 19.9f)}, kMin, kUpscale).empty());
// And the interaction stated as one claim: a face of a given original size is
// admitted or refused identically whether or not the decode was downscaled.
for (float original : {30.f, 39.f, 40.f, 41.f, 80.f}) {
INFO("original size " << original);
const bool full = !surviving_widths({box(original, original)},
kMin, 1.f).empty();
const bool dense = !surviving_widths({box(original / kUpscale,
original / kUpscale)},
kMin, kUpscale).empty();
CHECK(full == dense);
CHECK(full == (original >= kMin));
}
}
TEST_CASE("an absent or degenerate upscale falls back to the raw threshold",
"[detector][AR-002]") {
// bbox_upscale is 1 on every non-dense frame; 0 would mean the frame source
// never set it. Dividing by that would reject every face in the film, which
// is a failure worth not having.
CHECK(surviving_widths({box(kMin, kMin)}, kMin, 0.f).size() == 1);
CHECK(surviving_widths({box(39.f, 39.f)}, kMin, 0.f).empty());
}