The spine of the redesign. Presence is now the extent of a track an actor owns, [first_seen, last_seen], rather than the subset of frames in which recognition happened to succeed. An actor recognised only at the end of a long track is present for all of it, which is what the scene-scoped ground truth actually records. AR-013 — `last_seen` as an optional carries the entire liveness state: unset means on screen, set means went off at that timestamp and still revivable, reaped means emitted and erased. No missing-frame counter, no expired flag. It subsumes the tracker's existing two-pool split, so there is no separate revival path — matching a dormant track is ordinary inter-frame association. The asymmetry is the point: interior gaps are claimed, the trailing cool-down is not. A face lost and re-associated within the timeout never closed its track, so the gap is presence — someone briefly occluded has not left the scene. But a track that dies ends at its last sighting, never at the death time. That is precisely the over-claim the retired extinction_sec keep-alive produced, where presence ran on into the closing credits. AR-014 — a belief swap A→B closes the track and opens a successor at the swap frame. Not a correction: two non-twins both clearing the threshold on one face is not realistic, whereas a track_id carried across a viewpoint change onto a different person is. Treating it as a swap-and-continue would emit one window blending two people; treating it as a boundary yields two that are each right. AR-015 — two live tracks owned by one actor means at least one is wrong, since a person cannot be in two places at once. A reverse index catches it on the update that causes it rather than by scanning. This makes identity a third cut detector, independent of the histogram and TransNetV2 and firing where those failed. AR-016 — flush() closes tracks still live at EOF. Without it a film ending mid-shot silently drops its closing cast, which presents as a recognition miss rather than a bookkeeping bug. Reaping hands the dead track to the aggregator and erases it, so the registry holds only live tracks and its size is bounded by concurrent on-screen faces rather than growing with the film. Locking: a frame's association pass is atomic as a unit via FrameScope, since per-call locking would let another thread observe a half-updated frame. owner() reads tally and verdict under one lock — separately, a track could be both unowned and owned within a single promotion decision. A vote for an already-reaped track is dropped and counted, because a nonzero count means the timeout is shorter than the matcher's lag. 11 unit tests, driven directly against the registry with no network and no fixture — the awkward cases are constructed rather than hunted for. Suite: 75 cases, 3236 assertions. Coverage 14/63 to 20/63. Not yet wired into FaceTrackerFunc; that is AR-007/AR-008. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002
256 lines
9.3 KiB
C++
256 lines
9.3 KiB
C++
// Unit tests for TrackRegistry (track_registry.hpp): presence as track extent.
|
|
//
|
|
// TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | UT-001
|
|
//
|
|
// Pure, GPU-free, model-free — drives the registry directly with synthetic
|
|
// timestamps and evidence. Node functors and this registry are plain objects
|
|
// constructed outside the KPN network, so the awkward cases can be built
|
|
// exactly rather than hunted for in a clip: a gap one frame under the timeout,
|
|
// a belief swap, two live tracks converging on one actor, a film ending
|
|
// mid-track.
|
|
#include <catch2/catch_test_macros.hpp>
|
|
|
|
#include "track_registry.hpp"
|
|
|
|
#include <vector>
|
|
|
|
namespace {
|
|
|
|
Embedding axis(int slot) {
|
|
Embedding e{};
|
|
e[slot] = 1.0f;
|
|
return e;
|
|
}
|
|
|
|
// Collects the claims a registry emits, which is the whole observable output.
|
|
struct Sink {
|
|
std::vector<DeadTrack> claims;
|
|
void attach(TrackRegistry& r) {
|
|
r.on_track_dead([this](const DeadTrack& d) { claims.push_back(d); });
|
|
}
|
|
const DeadTrack* forActor(int a) const {
|
|
for (const auto& c : claims) if (c.actor_idx == a) return &c;
|
|
return nullptr;
|
|
}
|
|
};
|
|
|
|
TrackRegistry::Config cfg(double extinction = 5.0, float own = 2.0f) {
|
|
TrackRegistry::Config c;
|
|
c.extinction_sec = extinction;
|
|
c.ownership_logodds = own;
|
|
return c;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
// ── AR-012 — the change this whole redesign exists for ───────────────────────
|
|
TEST_CASE("window starts at first sighting, not at first recognition", "[registry][AR-012]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(10.0); id = f.create(10.0, axis(0)); }
|
|
|
|
// Seen for 20s but only recognised at the very end — the pose was wrong
|
|
// until then. This is the case the old per-frame design got wrong: it would
|
|
// have reported presence starting at 30, not 10.
|
|
for (double t = 11.0; t <= 30.0; t += 1.0) {
|
|
auto f = reg.begin_frame(t);
|
|
f.mark_seen(id, t, axis(0));
|
|
}
|
|
reg.observe(id, 7, 5.0f);
|
|
|
|
{ auto f = reg.begin_frame(31.0); f.mark_lost(id, 30.0); }
|
|
reg.tick(40.0);
|
|
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].actor_idx == 7);
|
|
CHECK(sink.claims[0].first_seen == 10.0); // ← not 30.0
|
|
CHECK(sink.claims[0].last_seen == 30.0);
|
|
}
|
|
|
|
// ── AR-013 — the asymmetry that removes the old over-claim ───────────────────
|
|
TEST_CASE("interior gaps are absorbed; the trailing cool-down is not",
|
|
"[registry][AR-013]") {
|
|
TrackRegistry reg(cfg(/*extinction=*/5.0));
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
reg.observe(id, 3, 5.0f);
|
|
|
|
// Off screen at 10, back at 13 — inside the timeout, so the same track
|
|
// continues and the actor is claimed present *through* the gap.
|
|
{ auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); }
|
|
{ auto f = reg.begin_frame(13.0); f.mark_seen(id, 13.0, axis(0)); }
|
|
CHECK(sink.claims.empty()); // nothing closed
|
|
CHECK(reg.live() == 1);
|
|
|
|
// Lost for good at 20. The window must end there, not at the death time.
|
|
{ auto f = reg.begin_frame(20.0); f.mark_lost(id, 20.0); }
|
|
reg.tick(20.0 + 5.0 + 0.001);
|
|
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].first_seen == 0.0);
|
|
CHECK(sink.claims[0].last_seen == 20.0); // ← not 25.001
|
|
}
|
|
|
|
TEST_CASE("a gap past the timeout yields two tracks, not one", "[registry][AR-013]") {
|
|
TrackRegistry reg(cfg(/*extinction=*/5.0));
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int a;
|
|
{ auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); }
|
|
reg.observe(a, 1, 5.0f);
|
|
{ auto f = reg.begin_frame(10.0); f.mark_lost(a, 10.0); }
|
|
|
|
reg.tick(30.0); // well past extinction
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].last_seen == 10.0);
|
|
|
|
// A face reappearing after the timeout is genuinely a new track: past the
|
|
// re-acquisition window there are no grounds to assert continuity.
|
|
int b;
|
|
{ auto f = reg.begin_frame(31.0); b = f.create(31.0, axis(0)); }
|
|
CHECK(b != a);
|
|
}
|
|
|
|
// ── AR-016 — the silent-loss guard ───────────────────────────────────────────
|
|
TEST_CASE("EOF flush closes tracks still on screen", "[registry][AR-016]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(100.0); id = f.create(100.0, axis(0)); }
|
|
reg.observe(id, 5, 5.0f);
|
|
|
|
// A film almost always ends with faces on screen; these have not timed out.
|
|
reg.flush(/*final_ts=*/120.0);
|
|
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].actor_idx == 5);
|
|
CHECK(sink.claims[0].last_seen == 120.0);
|
|
|
|
sink.claims.clear();
|
|
reg.flush(130.0);
|
|
CHECK(sink.claims.empty()); // idempotent
|
|
CHECK(reg.live() == 0);
|
|
}
|
|
|
|
TEST_CASE("flush closes a lost-but-unreaped track at its last sighting",
|
|
"[registry][AR-016]") {
|
|
TrackRegistry reg(cfg(/*extinction=*/60.0));
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
reg.observe(id, 2, 5.0f);
|
|
{ auto f = reg.begin_frame(10.0); f.mark_lost(id, 10.0); }
|
|
|
|
reg.flush(/*final_ts=*/50.0);
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].last_seen == 10.0); // last sighting, not EOF
|
|
}
|
|
|
|
// ── AR-014 — belief swap is a track boundary, not a correction ───────────────
|
|
TEST_CASE("belief swap closes one window and opens another", "[registry][AR-014]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
reg.observe(id, 1, 4.0f); // owned by actor 1
|
|
{ auto f = reg.begin_frame(5.0); f.mark_lost(id, 5.0); }
|
|
|
|
reg.observe(id, 2, 12.0f); // belief swings decisively to 2
|
|
|
|
CHECK(reg.belief_swaps() == 1);
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].actor_idx == 1);
|
|
CHECK(sink.claims[0].last_seen == 5.0); // closed at its last sighting
|
|
|
|
// The successor is a distinct track, so nothing blends the two people.
|
|
reg.flush(9.0);
|
|
const DeadTrack* second = sink.forActor(2);
|
|
REQUIRE(second != nullptr);
|
|
CHECK(second->track_id != id);
|
|
CHECK(second->first_seen == 5.0); // abuts, does not overlap
|
|
}
|
|
|
|
// ── AR-015 — identity contradiction as a cut detector ────────────────────────
|
|
TEST_CASE("two live tracks owned by one actor is counted", "[registry][AR-015]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int a, b;
|
|
{ auto f = reg.begin_frame(0.0); a = f.create(0.0, axis(0)); b = f.create(0.0, axis(1)); }
|
|
|
|
reg.observe(a, 9, 5.0f);
|
|
CHECK(reg.actor_conflicts() == 0);
|
|
|
|
// One person cannot be in two places at once, so this is a missed camera or
|
|
// scene change that split them — detected on the update that causes it.
|
|
reg.observe(b, 9, 5.0f);
|
|
CHECK(reg.actor_conflicts() == 1);
|
|
}
|
|
|
|
// ── AR-017 / diagnostics ─────────────────────────────────────────────────────
|
|
TEST_CASE("an unowned track emits no claim", "[registry][AR-012]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
reg.observe(id, 4, 0.5f); // never clears the ownership threshold
|
|
{ auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); }
|
|
reg.tick(100.0);
|
|
|
|
// Someone was there, but nothing can be claimed about who.
|
|
CHECK(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].actor_idx == -1);
|
|
}
|
|
|
|
TEST_CASE("claims carry the belief that justified them", "[registry][AR-017]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
reg.observe(id, 6, 4.0f);
|
|
reg.flush(1.0);
|
|
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].belief > 0.9f); // logistic(4.0) ≈ 0.982
|
|
CHECK(sink.claims[0].observations == 1);
|
|
}
|
|
|
|
TEST_CASE("a vote for a reaped track is dropped and counted", "[registry][AR-013]") {
|
|
TrackRegistry reg(cfg(/*extinction=*/1.0));
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(0.0); id = f.create(0.0, axis(0)); }
|
|
{ auto f = reg.begin_frame(1.0); f.mark_lost(id, 1.0); }
|
|
reg.tick(10.0); // reaped
|
|
|
|
// The matcher runs downstream of the tracker, so a late vote is expected.
|
|
// Silently ignoring it would hide a timeout shorter than the matcher's lag.
|
|
reg.observe(id, 3, 5.0f);
|
|
CHECK(reg.dropped_votes() == 1);
|
|
}
|
|
|
|
TEST_CASE("a single-frame track yields a zero-length window", "[registry][AR-012]") {
|
|
TrackRegistry reg(cfg());
|
|
Sink sink; sink.attach(reg);
|
|
|
|
int id;
|
|
{ auto f = reg.begin_frame(42.0); id = f.create(42.0, axis(0)); }
|
|
reg.observe(id, 8, 5.0f);
|
|
{ auto f = reg.begin_frame(43.0); f.mark_lost(id, 42.0); }
|
|
reg.tick(100.0);
|
|
|
|
REQUIRE(sink.claims.size() == 1);
|
|
CHECK(sink.claims[0].first_seen == 42.0);
|
|
CHECK(sink.claims[0].last_seen == 42.0);
|
|
}
|