Files
scene-actor-extraction/tests/test_replay_fixtures.cpp
T
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

252 lines
9.9 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Replay tests — the real tracker and registry driven from committed fixtures.
//
// TRACES: AR-002, AR-004, AR-012, AR-013 | VR-001, VR-002 | IT-001
//
// Tier T2: composition, not units. The registry tests construct awkward states
// directly; these check that the pieces behave when wired together and fed real
// footage — 480x360 public-domain clips at 5 fps, with the cuts, gaps and
// crowded frames that actual film produces and synthetic input does not.
//
// No GPU and no model: the fixtures are HDF5 dumps taken after embedding, so
// everything here is CPU maths. That is what lets this run on the CI host at
// all (see docs/requirements.md, "CI never calls a model").
//
// Driving the node functors directly rather than through a KPN network is
// deliberate: functors are plain objects, so there are no threads, no channels
// and no scheduling — the same input gives the same output every time, which is
// exactly what a fixture-based test needs.
#include <catch2/catch_test_macros.hpp>
#include "config.hpp"
#include "evidence_discount.hpp"
#include "nodes/face_tracker_node.hpp"
#include "track_registry.hpp"
#include "types.hpp"
#include <H5Cpp.h>
#include <algorithm>
#include <cmath>
#include <limits>
#include <memory>
#include <string>
#include <vector>
namespace {
// ── Fixture reader ───────────────────────────────────────────────────────────
// The flat/ragged layout of scripts/optimizer/SCHEMA.md: per-face arrays
// concatenated, with a per-frame index table pointing into them.
struct Dump {
std::vector<double> ts;
std::vector<uint8_t> is_cut;
std::vector<int64_t> face_offset;
std::vector<int32_t> face_count;
std::vector<Embedding> emb;
std::vector<float> bbox; // 4 per face
std::string embedder;
float min_face_px{0.f}; // AR-002, as the run was configured
float bbox_upscale{1.f}; // bbox × this = original resolution
std::size_t frames() const { return ts.size(); }
std::size_t faces() const { return emb.size(); }
};
template <typename T>
std::vector<T> read1d(H5::Group& g, const char* name, const H5::DataType& dt) {
H5::DataSet ds = g.openDataSet(name);
hsize_t n = 0;
ds.getSpace().getSimpleExtentDims(&n, nullptr);
std::vector<T> out(n);
if (n) ds.read(out.data(), dt);
return out;
}
Dump load(const std::string& path) {
H5::H5File f(path, H5F_ACC_RDONLY);
H5::Group frames = f.openGroup("frames");
H5::Group faces = f.openGroup("faces");
Dump d;
d.ts = read1d<double>(frames, "timestamp_sec", H5::PredType::NATIVE_DOUBLE);
d.is_cut = read1d<uint8_t>(frames, "is_cut", H5::PredType::NATIVE_UINT8);
d.face_offset = read1d<int64_t>(frames, "face_offset", H5::PredType::NATIVE_INT64);
d.face_count = read1d<int32_t>(frames, "face_count", H5::PredType::NATIVE_INT32);
H5::DataSet e = faces.openDataSet("embedding");
hsize_t dims[2]{0, 0};
e.getSpace().getSimpleExtentDims(dims, nullptr);
std::vector<float> flat(dims[0] * dims[1]);
if (!flat.empty()) e.read(flat.data(), H5::PredType::NATIVE_FLOAT);
d.emb.resize(dims[0]);
for (hsize_t i = 0; i < dims[0]; ++i)
std::copy_n(flat.begin() + i * dims[1], 512, d.emb[i].begin());
// bbox is 2-D [N,4]; reading it with the 1-D helper would size the buffer
// from the first extent only and then read four times that many floats.
{
H5::DataSet bs = faces.openDataSet("bbox");
hsize_t bd[2]{0, 0};
bs.getSpace().getSimpleExtentDims(bd, nullptr);
d.bbox.resize(bd[0] * bd[1]);
if (!d.bbox.empty()) bs.read(d.bbox.data(), H5::PredType::NATIVE_FLOAT);
}
// GR-004: the dump records which embedder produced it, so a replay cannot
// be silently scored against a gallery from a different model.
if (f.attrExists("embedder_model")) {
// Written as a variable-length string (embedding_dump_node.hpp:99), so
// the read must name the same type explicitly.
H5::StrType vlen(H5::PredType::C_S1, H5T_VARIABLE);
f.openAttribute("embedder_model").read(vlen, d.embedder);
}
// AR-002: the threshold the run was configured with, and the scale its boxes
// are in. Read from the dump rather than assumed, so the check is against
// what this fixture was actually generated with — the hero clips predate the
// move to 40 px and were dumped at 32.
if (f.attrExists("min_face_px"))
f.openAttribute("min_face_px").read(H5::PredType::NATIVE_FLOAT,
&d.min_face_px);
if (f.attrExists("bbox_upscale"))
f.openAttribute("bbox_upscale").read(H5::PredType::NATIVE_FLOAT,
&d.bbox_upscale);
return d;
}
std::string fixture(const char* name) {
return std::string(SAE_TEST_FIXTURES_DIR) + "/dumps/" + name;
}
// ── Harness ──────────────────────────────────────────────────────────────────
struct Replay {
std::vector<DeadTrack> claims;
std::vector<int> track_ids; // per face, in fixture order
std::size_t faces_seen{0};
};
Replay run(const Dump& d, double extinction = 10.0) {
Replay r;
TrackRegistry::Config rc;
rc.extinction_sec = extinction;
auto cal = [](float cos) { return std::max(0.f, cos); };
auto reg = std::make_shared<TrackRegistry>(rc, EvidenceDiscounter(cal));
reg->on_track_dead([&r](const DeadTrack& t) { r.claims.push_back(t); });
Config cfg;
cfg.track_assoc_min_prob = 0.5f;
FaceTrackerFunc ft(cfg, reg, cal);
for (std::size_t i = 0; i < d.frames(); ++i) {
EmbeddedSceneFrame ef;
ef.source.timestamp_sec = d.ts[i];
ef.source.is_cut = d.is_cut[i] != 0;
const int64_t off = d.face_offset[i];
const int32_t n = d.face_count[i];
for (int32_t k = 0; k < n; ++k) {
DetectedFace face;
const float* b = &d.bbox[(off + k) * 4];
face.bbox = cv::Rect2f(b[0], b[1], b[2], b[3]);
face.confidence = 1.0f;
ef.faces.push_back(face);
ef.crops.push_back(cv::Mat());
ef.embeddings.push_back(d.emb[off + k]);
}
r.faces_seen += static_cast<std::size_t>(n);
auto out = ft(std::move(ef));
for (int id : out.track_ids) r.track_ids.push_back(id);
}
reg->flush(d.ts.empty() ? 0.0 : d.ts.back());
return r;
}
} // namespace
// ── AR-004 / VR-001 — the fixtures are intact and self-describing ────────────
TEST_CASE("superhero fixture is complete", "[replay][VR-001]") {
Dump d = load(fixture("superhero.h5"));
CHECK(d.frames() == 5128);
CHECK(d.faces() == 4307);
CHECK(d.embedder == "LVFace-B_Glint360K.onnx");
int64_t running = 0;
for (std::size_t i = 0; i < d.frames(); ++i) {
REQUIRE(d.face_offset[i] == running);
running += d.face_count[i];
}
CHECK(static_cast<std::size_t>(running) == d.faces());
}
// ── AR-002 — the size filter held, all the way to the dump ───────────────────
// The T1 arithmetic is in test_face_detector_node.cpp. This is the other half:
// that the rule was applied on real footage and nothing downstream of it let an
// undersized face back in.
TEST_CASE("no dumped face is below the configured minimum size",
"[replay][AR-002]") {
Dump d = load(fixture("superhero.h5"));
// A dump that did not record its threshold cannot be checked against one.
REQUIRE(d.min_face_px > 0.f);
REQUIRE(d.bbox_upscale > 0.f);
float smallest_side = std::numeric_limits<float>::max();
for (std::size_t i = 0; i < d.faces(); ++i) {
const float w = d.bbox[i * 4 + 2] * d.bbox_upscale; // original resolution
const float h = d.bbox[i * 4 + 3] * d.bbox_upscale;
REQUIRE(w >= d.min_face_px);
REQUIRE(h >= d.min_face_px);
smallest_side = std::min({smallest_side, w, h});
}
// And the filter was binding, not vacuously satisfied. 480x360 footage puts
// faces right on the cutoff, which is what makes this fixture worth checking:
// if the threshold stopped being applied the assertions above would still
// pass on a corpus of close-ups.
CHECK(smallest_side < d.min_face_px * 1.05f);
}
TEST_CASE("replaying the superhero fixture twice gives identical tracks",
"[replay][VR-002]") {
Dump d = load(fixture("superhero.h5"));
Replay a = run(d);
Replay b = run(d);
REQUIRE(a.track_ids.size() == b.track_ids.size());
CHECK(a.track_ids == b.track_ids);
REQUIRE(a.claims.size() == b.claims.size());
}
TEST_CASE("every face is assigned a track and every track closes",
"[replay][AR-012]") {
Dump d = load(fixture("superhero.h5"));
Replay r = run(d);
CHECK(r.track_ids.size() == r.faces_seen);
for (int id : r.track_ids) CHECK(id >= 0); // nothing silently unassigned
// flush() must leave nothing behind: a track still open at EOF would be a
// window that never reaches the output.
CHECK(r.claims.size() > 0);
}
TEST_CASE("windows are well-formed and inside the film", "[replay][AR-013]") {
for (const char* f : {"superhero.h5", "superhero.h5", "superhero.h5",
"superhero.h5", "superhero.h5"}) {
INFO(f);
Dump d = load(fixture(f));
Replay r = run(d);
const double t0 = d.ts.front(), t1 = d.ts.back();
for (const auto& c : r.claims) {
// A window ends at the last sighting, never after it — so it can
// never extend past the footage that produced it.
CHECK(c.first_seen <= c.last_seen);
CHECK(c.first_seen >= t0);
CHECK(c.last_seen <= t1);
}
}
}