docs/SPEC.md specified this removal, listed its parts, and ended "grep
for both names and expect no survivors". There were about forty.
docs/requirements.md meanwhile recorded both constants as Withdrawn and
"deleted rather than retained at zero", on the grounds that a field
naming a mechanism the pipeline no longer has is actively misleading.
Neither statement was true of the code: Config still carried
extinction_sec 57.4 and anneal_sec 35.5, --extinction and --anneal still
parsed, and SceneTrackerFunc still ran its keep-alive in both shipped
pipelines, announcing its timeout at every startup.
SceneTrackerFunc is replaced by FrameAnnotationFunc, which is stateless:
same ports, same output type, no keep-alive. Presence belongs to
TrackRegistry (AR-012), where a window is the extent of a track an actor
owned and ends at the last sighting (AR-013). The keep-alive answered
that question a second time and answered it worse, by re-opening exactly
the trailing cool-down AR-013 refuses.
Visible change: --verbosity standard's frames[].identified listed every
actor inside the keep-alive, including ones absent from the frame. It
now lists what was matched in that frame. Minimal and xray output is
untouched -- both were already built from registry claims and never
consulted this node. No schema bump: the published extraction block
reports track_extinction_sec, a different knob that bounds
re-association and never extends a claim.
TrackRegistry::Config::extinction_sec is renamed track_extinction_sec to
match the Config field feeding it, so the grep SPEC.md asks for now
returns nothing rather than one confusing false positive.
Two targets turned out to have been silently dead, both since the
AR-007/AR-008 tracker redesign, and both for the same reason -- they
construct FaceTrackerFunc from a Config alone, a signature that stopped
existing when association moved into probability space:
- scene_preview is fixed here. It now mirrors main.cpp's construction
order exactly (matcher, then registry, then tracker) and wires the
registry's claims into the sink, which it was not doing. DP-001 says
modes are front-ends that must not fork pipeline logic; this one had
forked it and then rotted.
- sae_kpn is not fixed. Restructuring the seam so the tracker can reach
a calibration that only exists once the matcher is built is VR-011's
rewrite, not a patch, and presence claims do not cross the seam at all
today. It is now behind SAE_BUILD_KPN_BINDINGS=OFF with the reason
recorded, so `cmake --build` succeeds and the breakage is attributed
rather than rediscovered.
That second one is worth stating plainly: VR-002 ("replay drives the
real KPN nodes, not a reimplementation") is marked Done, and the module
that makes replay possible has not compiled for some time. The .so in a
stale build/ predates the change.
Python side: the two names are gone from optimize.py, replay.py and
run_holdout_all_models.py as Config keys. anneal_sec survives as
REPLAY_LOCAL_KEYS -- it still configures replay.py's own windowing,
which is a Python reimplementation that no longer matches the sink and
is documented as such. That divergence is VR-011's.
TRACES: AR-012, AR-013 | DP-001 | SR-002
252 lines
9.9 KiB
C++
252 lines
9.9 KiB
C++
// 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.track_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);
|
||
}
|
||
}
|
||
}
|