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
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.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));
|
|
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);
|
|
}
|