GPU-free, model-free tests for the pure logic: gallery HDF5 save/load round-trips (actors, embeddings, embedded calibration) and legacy JSON read back-compat; the calibration sigmoid fit, boundary inversion, and the in-memory hash-keyed cache reuse/staleness; TrackGallery's diversity-buffer eviction, novelty/spread safety gates, and promotion; FaceTracker's IoU/ embedding association and cross-cut track revival; and the GEMM similarity backend (forced to CPU so the suite runs without a GPU). Verified: all 39 test cases / 1640 assertions pass (cmake -DSAE_BUILD_TESTS=ON).
140 lines
5.0 KiB
C++
140 lines
5.0 KiB
C++
// 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: on a camera-angle change (Frame::is_cut) the tracker
|
|
// parks its tracks instead of destroying them, and revives a parked track_id
|
|
// when a post-cut detection's raw last-frame-embedding cosine similarity clears
|
|
// cut_revive_sim. 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 <cmath>
|
|
|
|
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;
|
|
}
|
|
|
|
Config tracker_cfg() {
|
|
Config cfg;
|
|
cfg.cut_revive_sim = 0.50f;
|
|
cfg.cut_inactive_max_frames = 5;
|
|
return cfg;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
TEST_CASE("track id is stable across ordinary frames", "[face_tracker]") {
|
|
FaceTrackerFunc ft(tracker_cfg());
|
|
Embedding e = axis(0);
|
|
int id0 = ft(frame(0.0, 10, 10, e)).track_ids[0];
|
|
int id1 = ft(frame(1.0, 11, 10, e)).track_ids[0]; // overlaps → same track
|
|
CHECK(id0 >= 0);
|
|
CHECK(id1 == id0);
|
|
}
|
|
|
|
TEST_CASE("cut revives the same track id for a matching identity", "[face_tracker]") {
|
|
FaceTrackerFunc ft(tracker_cfg());
|
|
|
|
// Pre-cut: establish a track for a person whose embedding is near-identical
|
|
// across the cut (sim well above cut_revive_sim), but whose box jumps so IoU
|
|
// is 0 — the ordinary spatial path cannot re-link it.
|
|
Embedding pre = at_sim(0, 1, 0.99f);
|
|
int id_pre = ft(frame(0.0, 10, 10, pre)).track_ids[0];
|
|
REQUIRE(id_pre >= 0);
|
|
|
|
Embedding post = at_sim(0, 1, 0.98f); // cos(diff) ≈ 0.9997 > 0.50
|
|
auto out = ft(frame(1.0, 300, 300, post, /*is_cut=*/true));
|
|
CHECK(out.track_ids[0] == id_pre); // revived, not a fresh id
|
|
}
|
|
|
|
TEST_CASE("cut starts a fresh track when identity does not match", "[face_tracker]") {
|
|
FaceTrackerFunc ft(tracker_cfg());
|
|
|
|
int id_pre = ft(frame(0.0, 10, 10, axis(0))).track_ids[0];
|
|
REQUIRE(id_pre >= 0);
|
|
|
|
// Post-cut face is orthogonal (sim 0 < cut_revive_sim) and spatially disjoint
|
|
// → no revival, brand-new id.
|
|
auto out = ft(frame(1.0, 300, 300, axis(5), /*is_cut=*/true));
|
|
CHECK(out.track_ids[0] != id_pre);
|
|
CHECK(out.track_ids[0] >= 0);
|
|
}
|
|
|
|
TEST_CASE("parked track expires after cut_inactive_max_frames", "[face_tracker]") {
|
|
Config cfg = tracker_cfg();
|
|
cfg.cut_inactive_max_frames = 2;
|
|
FaceTrackerFunc ft(cfg);
|
|
|
|
Embedding person = at_sim(0, 1, 0.99f);
|
|
int id_pre = ft(frame(0.0, 10, 10, person)).track_ids[0];
|
|
REQUIRE(id_pre >= 0);
|
|
|
|
// Cut with an unrelated face parks id_pre; then let the pool age past its
|
|
// limit with more unrelated, spatially-disjoint faces (each ages the pool by
|
|
// one). By the time the person returns, id_pre must be gone.
|
|
ft(frame(1.0, 300, 300, axis(7), /*is_cut=*/true)); // park (age 1)
|
|
ft(frame(2.0, 300, 300, axis(7))); // age 2
|
|
ft(frame(3.0, 300, 300, axis(7))); // age 3 → id_pre dropped
|
|
|
|
auto out = ft(frame(4.0, 10, 10, person)); // same identity returns
|
|
CHECK(out.track_ids[0] != id_pre); // too late — fresh id
|
|
}
|
|
|
|
TEST_CASE("eof clears active and parked tracks", "[face_tracker]") {
|
|
FaceTrackerFunc ft(tracker_cfg());
|
|
Embedding person = at_sim(0, 1, 0.99f);
|
|
int id_pre = ft(frame(0.0, 10, 10, person)).track_ids[0];
|
|
ft(frame(1.0, 300, 300, axis(7), /*is_cut=*/true)); // park id_pre
|
|
|
|
EmbeddedSceneFrame eof;
|
|
eof.source.eof = true;
|
|
auto out = ft(std::move(eof));
|
|
CHECK(out.source.eof);
|
|
|
|
// After eof the pools are empty: the returning identity must get a fresh id,
|
|
// not the parked one.
|
|
auto out2 = ft(frame(2.0, 10, 10, person));
|
|
CHECK(out2.track_ids[0] != id_pre);
|
|
}
|