Files
scene-actor-extraction/tests/test_face_tracker.cpp
T
dtourolle 3966e19a83 test: two tests still describe the registry as it was before 1477c53
Both failed the first time CI was able to run this suite, and neither is
a new break. They assert semantics that two deliberate changes replaced,
and nothing noticed because nothing had ever executed them.

1477c53 (2026-08-09) made a dormant track associable only if it had been
identified: an unowned dormant track has no actor to re-attach to, so it
only enlarges the matcher's comparison set and invites a new face landing
on an anonymous stub. Its message records the trade -- "a small recall
cost (~1-3pp on some films, e.g. Lord of War -2.5) for a cleaner, bounded
pool. Kept deliberately." Both tests build tracks that are never
identified, so since that commit they are dropped from the pool one line
before the extinction horizon they were written to exercise. The tests
date from 2026-07-31 and 2026-08-08 -- they are older than the gate.
Fixed by owning the track while it is still on screen, which is what a
real run does and what makes the horizon reachable at all.

The second test needed more than that, and the rename says why. It was
built on association and reaping answering on DIFFERENT clocks -- the
tracker's for "may this associate?", the watermark for "is this
finished?" -- which is exactly the arrangement track_registry.hpp:184-203
now rules out: one clock, one threshold, "nothing is offered past its
reap horizon, nothing is reaped while still offerable". So there is no
longer an interval in which a track is retired from association but still
alive, and a test named for that interval cannot pass. Rewritten to
assert what the unified clock actually promises: a tracker racing 50 s
past the horizon retires nothing while the matcher lags, the votes in
flight still land, and the watermark passing the horizon retires, reaps
and emits in one step.

Kept: the AR-008 tag, the late-vote check and dropped_votes() == 0, which
were the point of the test and still hold.

Full suite now 151/151 in sae-builder-cpu.

TRACES: AR-008, AR-013 | SR-002
2026-08-31 08:04:17 +02:00

215 lines
8.0 KiB
C++

// TRACES: AR-007, AR-008 | SR-002
//
// Unit tests for FaceTrackerFunc (nodes/face_tracker_node.hpp): frame-to-frame
// track linking and, crucially, cross-cut re-association. Pure, GPU-free,
// model-free — drives the node's operator() with hand-built EmbeddedSceneFrames
// and inspects the emitted track_ids.
//
// The behaviour under test: there is one track pool keyed on `last_seen`
// (AR-008), so a face lost across a camera-angle change (Frame::is_cut) is an
// ordinary association candidate rather than a parked track needing a revival
// path — the raw-cosine `cut_revive_sim` that guarded that path is retired
// (AR-024). On a cut the association weight drops to embedding-only (AR-007),
// and IoU is deliberately driven to 0 across the cut (boxes moved) so only the
// embedding path can re-link — exactly the scenario a cut creates.
#include <catch2/catch_test_macros.hpp>
#include "config.hpp"
#include "nodes/face_tracker_node.hpp"
#include "types.hpp"
#include "track_registry.hpp"
#include "evidence_discount.hpp"
#include <algorithm>
#include <cmath>
#include <memory>
namespace {
// Unit-norm embedding in the plane of axes i,j at angle whose cosine to
// one_hot(i) is cos_t. cosine_similarity(at_sim(i,j,a), at_sim(i,j,b)) works out
// to cos(angle diff), letting a test dial the cross-cut similarity precisely.
Embedding at_sim(int i, int j, float cos_t) {
Embedding e{};
float s = std::sqrt(std::max(0.f, 1.f - cos_t * cos_t));
e[i] = cos_t;
e[j] = s;
return e;
}
Embedding axis(int slot) {
Embedding e{};
e[slot] = 1.0f;
return e;
}
DetectedFace face_at(float x, float y) {
DetectedFace f;
f.bbox = cv::Rect2f(x, y, 40.f, 40.f);
f.confidence = 0.99f;
return f;
}
// Build a single-face frame at position (x,y) with embedding emb. is_cut marks a
// camera-angle change on this frame.
EmbeddedSceneFrame frame(double t, float x, float y, const Embedding& emb,
bool is_cut = false) {
EmbeddedSceneFrame ef;
ef.source.timestamp_sec = t;
ef.source.is_cut = is_cut;
ef.faces = {face_at(x, y)};
ef.crops = {cv::Mat()};
ef.embeddings = {emb};
return ef;
}
// Build a tracker over a fresh registry. The registry IS the tracker's state
// now (AR-008), so a test constructs both together and can inspect either.
struct Rig {
std::shared_ptr<TrackRegistry> reg;
FaceTrackerFunc ft;
explicit Rig(double extinction = 30.0, float assoc_min_prob = 0.5f)
: reg(std::make_shared<TrackRegistry>(
[extinction] {
TrackRegistry::Config c;
c.track_extinction_sec = extinction;
return c;
}(),
EvidenceDiscounter([](float cos) { return std::max(0.f, cos); })))
, ft([&] {
Config c;
c.track_assoc_min_prob = assoc_min_prob;
return c;
}(),
reg,
// Trivial calibration: cosine passed through as P(same). Real runs use
// the fit belonging to the active embedder (AR-023/AR-024).
[](float cos) { return std::max(0.f, cos); })
{}
int track_of(EmbeddedSceneFrame f) { return ft(std::move(f)).track_ids[0]; }
};
} // namespace
// ── AR-008 — one pool, ordinary association ──────────────────────────────────
TEST_CASE("track id is stable across ordinary frames", "[face_tracker][AR-008]") {
Rig r;
Embedding e = axis(0);
int id0 = r.track_of(frame(0.0, 10, 10, e));
int id1 = r.track_of(frame(1.0, 11, 10, e)); // overlaps → same track
CHECK(id0 >= 0);
CHECK(id1 == id0);
}
TEST_CASE("a face lost across a cut and re-associated is the SAME track",
"[face_tracker][AR-008]") {
// Previously this was a distinct "revival" path guarded by a raw-cosine
// constant. There is no such path now: a dormant track is an ordinary
// association candidate, and continuity falls out of the embedding match.
Rig r;
Embedding pre = at_sim(0, 1, 0.99f);
int id_pre = r.track_of(frame(0.0, 10, 10, pre));
REQUIRE(id_pre >= 0);
// Box jumps so IoU is zero — only the embedding can link it.
Embedding post = at_sim(0, 1, 0.98f);
CHECK(r.track_of(frame(1.0, 300, 300, post, /*is_cut=*/true)) == id_pre);
}
TEST_CASE("a cut starts a fresh track when identity does not match",
"[face_tracker][AR-008]") {
Rig r;
int id_pre = r.track_of(frame(0.0, 10, 10, axis(0)));
REQUIRE(id_pre >= 0);
// Orthogonal embedding and disjoint box: nothing links them.
int id_post = r.track_of(frame(1.0, 300, 300, axis(5), /*is_cut=*/true));
CHECK(id_post != id_pre);
CHECK(id_post >= 0);
}
// ── AR-007 — a cut makes association ignore position ─────────────────────────
TEST_CASE("on a cut, identity follows the embedding rather than the box",
"[face_tracker][AR-007]") {
// Two people swap screen positions across a cut while keeping their faces.
// If IoU still carried weight the ids would follow the boxes and swap; with
// alpha driven to embedding-only on a cut, they must follow the faces.
Rig r;
Embedding a = at_sim(0, 1, 0.99f);
Embedding b = at_sim(2, 3, 0.99f);
EmbeddedSceneFrame f0;
f0.source.timestamp_sec = 0.0;
f0.faces = {face_at(10, 10), face_at(300, 300)};
f0.crops = {cv::Mat(), cv::Mat()};
f0.embeddings = {a, b};
auto out0 = r.ft(std::move(f0));
const int id_a = out0.track_ids[0];
const int id_b = out0.track_ids[1];
REQUIRE(id_a >= 0);
REQUIRE(id_b >= 0);
REQUIRE(id_a != id_b);
// Same two people, positions exchanged, on a cut frame.
EmbeddedSceneFrame f1;
f1.source.timestamp_sec = 1.0;
f1.source.is_cut = true;
f1.faces = {face_at(300, 300), face_at(10, 10)};
f1.crops = {cv::Mat(), cv::Mat()};
f1.embeddings = {a, b};
auto out1 = r.ft(std::move(f1));
CHECK(out1.track_ids[0] == id_a); // A kept its id despite moving to B's box
CHECK(out1.track_ids[1] == id_b);
}
// ── AR-013 — extinction replaces the parked-pool frame counter ───────────────
TEST_CASE("a track past the extinction window is gone, not revived",
"[face_tracker][AR-013]") {
// The old design aged a parked pool in frames, which silently changed
// meaning with sample_fps. Extinction is in seconds and lives in the
// registry, so the tracker no longer counts anything.
Rig r(/*extinction=*/2.0);
Embedding person = at_sim(0, 1, 0.99f);
int id_pre = r.track_of(frame(0.0, 10, 10, person));
REQUIRE(id_pre >= 0);
// Unrelated faces elsewhere while the clock runs well past extinction.
r.track_of(frame(1.0, 300, 300, axis(7), /*is_cut=*/true));
r.track_of(frame(10.0, 300, 300, axis(7)));
CHECK(r.track_of(frame(11.0, 10, 10, person)) != id_pre);
}
TEST_CASE("a track within the extinction window is still a candidate",
"[face_tracker][AR-013]") {
Rig r(/*extinction=*/30.0);
Embedding person = at_sim(0, 1, 0.99f);
int id_pre = r.track_of(frame(0.0, 10, 10, person));
// Identify it while it is still on screen. Since 1477c53 a DORMANT track is
// only associable if it was owned -- an anonymous one has no actor to
// re-attach to, so it is dropped from the pool a line before the horizon is
// ever consulted. Without this the test cannot reach what it is about.
r.reg->observe(id_pre, /*actor=*/0, /*posterior=*/0.99f, person);
r.track_of(frame(1.0, 300, 300, axis(7), /*is_cut=*/true));
// Back inside the window: the same person continues the same track, so the
// gap is absorbed into one window rather than splitting it.
CHECK(r.track_of(frame(3.0, 10, 10, person)) == id_pre);
}
TEST_CASE("eof is forwarded", "[face_tracker]") {
Rig r;
EmbeddedSceneFrame eof;
eof.source.eof = true;
CHECK(r.ft(std::move(eof)).source.eof);
}