// 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 #include "config.hpp" #include "nodes/face_tracker_node.hpp" #include "types.hpp" #include "track_registry.hpp" #include "evidence_discount.hpp" #include #include #include 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 reg; FaceTrackerFunc ft; explicit Rig(double extinction = 30.0, float assoc_min_prob = 0.5f) : reg(std::make_shared( [extinction] { TrackRegistry::Config c; c.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)); 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); }