Four test files and one node header carried no TRACES tag, so the requirements they verify read as implemented-but-unverified. Tagging a test is what distinguishes the two. test_calibration.cpp is AR-023; its three [report] cases verify GR-003 and are tagged separately, since the report is fitted from the same distributions but is its own requirement. test_similarity.cpp is the CI half of AR-026 — equivalence against hand-computed dot products, where throughput at scale is AR-027 and cannot run on this host. test_face_tracker.cpp is AR-007 and AR-008. Two headers described code that no longer exists. face_aligner_node.hpp still documented the RANSAC fit AR-005 replaced with an Umeyama least-squares fit over all five points — not merely out of date but the opposite of what the file does, and it reads as a rationale for discarding the landmarks AR-030 measures. test_face_tracker.cpp still described the park/revive branch AR-008 deleted, and the raw-cosine cut_revive_sim that guarded it, which AR-024 retired. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-005, AR-007, AR-008, AR-023, AR-026, AR-030 | GR-003 | SR-001, SR-002
208 lines
7.6 KiB
C++
208 lines
7.6 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.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);
|
|
}
|