feat: TrackRegistry — presence follows track extent
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
This commit is contained in:
@@ -39,12 +39,12 @@ Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
|
||||
| AR-009 | Camera-cut detection (histogram) as an association hint | SR-002 | High | Done |
|
||||
| AR-010 | Scene-boundary detection (TransNetV2) as an association hint | SR-002 | Medium | **Not started** — `is_scene_boundary` has no producer; `SceneDetectorFunc` is a terminal sink and never annotates the frame |
|
||||
| AR-011 | **Every model is fed the input it was trained for** — cost reduced by running less often, never by degrading one inference | SR-002 | High | Planned |
|
||||
| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | Planned |
|
||||
| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | Planned |
|
||||
| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | Planned |
|
||||
| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | Planned |
|
||||
| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | Planned |
|
||||
| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | Planned |
|
||||
| AR-012 | Presence follows **track extent**, not per-frame recognition | **SR-002** | High | **Done** — `src/track_registry.hpp`; window is `[first_seen, last_seen]` of an owned track |
|
||||
| AR-013 | `last_seen` optional state machine; window ends at last sighting, never after | SR-002 | High | **Done** — `last_seen` optional is the whole state machine; interior gaps absorbed, trailing cool-down never claimed |
|
||||
| AR-014 | Belief swap A→B terminates the track and starts a new one | SR-002 | Medium | **Done** — swap closes at `last_seen` and opens a successor at the swap frame; counted |
|
||||
| AR-015 | Two live tracks owned by one actor ⇒ treat as a detected cut, re-associate | SR-002 | Medium | **Done** — reverse index detects it on the causing update; counted |
|
||||
| AR-016 | All tracks closed at EOF — a film ends with faces on screen | SR-002 | High | **Done** — `flush()`, idempotent, closes at last sighting or final tick |
|
||||
| AR-017 | Every presence claim carries its belief and identification route | SR-002 | High | **Done** — `DeadTrack` carries belief and observation count |
|
||||
| AR-018 | Per-subject embedding store with banded admission (novel enough, safe enough) | SR-005 | Medium | Planned |
|
||||
| AR-019 | Per-film gallery annex from owned tracks; acquires the non-frontal views TMDB lacks | SR-005 | Medium | In Progress |
|
||||
| AR-020 | Deferred re-identification of unknown tracks against the final expanded gallery | SR-005 | High | Planned |
|
||||
|
||||
+64
-15
@@ -3,7 +3,7 @@
|
||||
<!-- GENERATED FILE - do not edit by hand. -->
|
||||
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
|
||||
|
||||
**Generated:** 2026-07-30T19:19:47+00:00
|
||||
**Generated:** 2026-07-31T07:07:04+00:00
|
||||
|
||||
Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, T3, static`).
|
||||
|
||||
@@ -11,13 +11,13 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| Source files scanned | 92 |
|
||||
| TRACES tags found | 74 |
|
||||
| Source files scanned | 94 |
|
||||
| TRACES tags found | 76 |
|
||||
| EXCEPTION tags found | 0 |
|
||||
| Requirements defined | 63 |
|
||||
| Requirements covered | 14 |
|
||||
| **Coverage** | **22.2%** (14/63) |
|
||||
| Coverage of CI-executable scope | 26.9% (14/52) |
|
||||
| Requirements covered | 20 |
|
||||
| **Coverage** | **31.7%** (20/63) |
|
||||
| Coverage of CI-executable scope | 38.5% (20/52) |
|
||||
| Tagged but unexecuted in CI | 3 |
|
||||
| Orphan tags | 0 |
|
||||
|
||||
@@ -25,13 +25,13 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev
|
||||
|
||||
| Type | Covered | Tagged but unexecuted | Defined |
|
||||
|---|---|---|---|
|
||||
| AR | 3 | 0 | 27 |
|
||||
| AR | 9 | 0 | 27 |
|
||||
| DP | 2 | 0 | 8 |
|
||||
| IR | 6 | 0 | 8 |
|
||||
| GR | 3 | 0 | 9 |
|
||||
| VR | 0 | 3 | 11 |
|
||||
|
||||
- **UT** tags present (separate taxonomy, not counted in coverage): UT-101, UT-102, UT-103, UT-104
|
||||
- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-101, UT-102, UT-103, UT-104
|
||||
- **PR** tags present (separate taxonomy, not counted in coverage): PR-002, PR-004
|
||||
- **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-005
|
||||
|
||||
@@ -88,12 +88,12 @@ _None._
|
||||
| AR-009 | Done | T2 | SR-002 | untagged | - | Camera-cut detection (histogram) as an association hint |
|
||||
| AR-010 | **Not started** — `… | T2 | SR-002 | untagged | - | Scene-boundary detection (TransNetV2) as an association hint |
|
||||
| AR-011 | Planned | T1, T2 | SR-002 | untagged | - | **Every model is fed the input it was trained for** — cost reduced by… |
|
||||
| AR-012 | Planned | T2 | **SR-002** | untagged | - | Presence follows **track extent**, not per-frame recognition |
|
||||
| AR-013 | Planned | T2 | SR-002 | untagged | - | `last_seen` optional state machine; window ends at last sighting, nev… |
|
||||
| AR-014 | Planned | T2 | SR-002 | untagged | - | Belief swap A→B terminates the track and starts a new one |
|
||||
| AR-015 | Planned | T2 | SR-002 | untagged | - | Two live tracks owned by one actor ⇒ treat as a detected cut, re-asso… |
|
||||
| AR-016 | Planned | T2 | SR-002 | untagged | - | All tracks closed at EOF — a film ends with faces on screen |
|
||||
| AR-017 | Planned | T1, T2 | SR-002 | untagged | - | Every presence claim carries its belief and identification route |
|
||||
| AR-012 | Planned | T2 | **SR-002** | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Presence follows **track extent**, not per-frame recognition |
|
||||
| AR-013 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | `last_seen` optional state machine; window ends at last sighting, nev… |
|
||||
| AR-014 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Belief swap A→B terminates the track and starts a new one |
|
||||
| AR-015 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Two live tracks owned by one actor ⇒ treat as a detected cut, re-asso… |
|
||||
| AR-016 | Planned | T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | All tracks closed at EOF — a film ends with faces on screen |
|
||||
| AR-017 | Planned | T1, T2 | SR-002 | covered | `src/track_registry.hpp`, `tests/test_track_registry.cpp` | Every presence claim carries its belief and identification route |
|
||||
| AR-018 | Planned | T1, T2 | SR-005 | untagged | - | Per-subject embedding store with banded admission (novel enough, safe… |
|
||||
| AR-019 | In Progress | T2 | SR-005 | untagged | - | Per-film gallery annex from owned tracks; acquires the non-frontal vi… |
|
||||
| AR-020 | Planned | T2 | SR-005 | untagged | - | Deferred re-identification of unknown tracks against the final expand… |
|
||||
@@ -155,6 +155,48 @@ _None._
|
||||
|
||||
- [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `inline cv::Mat align_face(const cv::Mat& img,`
|
||||
|
||||
### AR-012
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-013
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-014
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-015
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-016
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-017
|
||||
|
||||
**Locations:** 2
|
||||
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### AR-023
|
||||
|
||||
**Locations:** 1
|
||||
@@ -369,11 +411,12 @@ _None._
|
||||
|
||||
### SR-002
|
||||
|
||||
**Locations:** 3
|
||||
**Locations:** 4
|
||||
|
||||
- [`src/face_utils.hpp:2`](../src/face_utils.hpp#L2) — `inline cv::Mat align_face(const cv::Mat& img,`
|
||||
- [`src/gallery/gallery_calibration.hpp:2`](../src/gallery/gallery_calibration.hpp#L2) — `Unknown`
|
||||
- [`src/nodes/face_detector_node.hpp:2`](../src/nodes/face_detector_node.hpp#L2) — `Unknown`
|
||||
- [`src/track_registry.hpp:2`](../src/track_registry.hpp#L2) — `Unknown`
|
||||
|
||||
### SR-003
|
||||
|
||||
@@ -389,6 +432,12 @@ _None._
|
||||
|
||||
- [`scripts/make_jellyfin_gallery.py:4`](../scripts/make_jellyfin_gallery.py#L4) — `Unknown`
|
||||
|
||||
### UT-001
|
||||
|
||||
**Locations:** 1
|
||||
|
||||
- [`tests/test_track_registry.cpp:3`](../tests/test_track_registry.cpp#L3) — `Unknown`
|
||||
|
||||
### UT-101
|
||||
|
||||
**Locations:** 5
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
#pragma once
|
||||
/// TRACES: AR-012, AR-013, AR-014, AR-015, AR-016, AR-017 | SR-002
|
||||
///
|
||||
/// TrackRegistry — the single owner of track state and of presence.
|
||||
///
|
||||
/// Presence follows **track extent**, not per-frame recognition (AR-012): a
|
||||
/// window is `[first_seen, last_seen]` of a track an actor owns, so it starts
|
||||
/// when the actor appeared rather than when the recogniser first succeeded.
|
||||
///
|
||||
/// `last_seen` carries the entire liveness state (AR-013):
|
||||
///
|
||||
/// unset → on screen now
|
||||
/// set → went off screen at that timestamp, still revivable
|
||||
/// reaped → emitted to the aggregator and erased
|
||||
///
|
||||
/// There is no missing-frame counter and no expired flag; the optional *is* the
|
||||
/// state machine, and it subsumes what was previously a two-pool split in the
|
||||
/// tracker (active vs. parked-across-a-cut).
|
||||
///
|
||||
/// **Interior gaps are claimed, the trailing cool-down is not.** A face lost at
|
||||
/// t1 and re-associated at t2 within the timeout never closed its track, so the
|
||||
/// actor is present across [t1, t2] — correct, since someone briefly occluded or
|
||||
/// off-camera has not left the scene. But a track that dies ends its window at
|
||||
/// `last_seen`, never at the moment of death. That asymmetry is what removes the
|
||||
/// over-claim the retired `extinction_sec` keep-alive produced.
|
||||
///
|
||||
/// The registry is created in `main` and shared by `shared_ptr`; it is *not* a
|
||||
/// KPN node. Ownership is not a stage in the stream — it is state several stages
|
||||
/// read and write, whose final answer is only known when a track dies.
|
||||
|
||||
#include "types.hpp"
|
||||
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <map>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// ── DeadTrack ────────────────────────────────────────────────────────────────
|
||||
// A finished presence claim, emitted exactly once when a track is reaped or
|
||||
// flushed. Immutable by construction: it carries everything needed to justify
|
||||
// itself (AR-017), with no back-reference into registry state.
|
||||
struct DeadTrack {
|
||||
int track_id{-1};
|
||||
double first_seen{0.0};
|
||||
double last_seen{0.0}; ///< always the last sighting, never the death time
|
||||
int actor_idx{-1}; ///< -1 when the track was never owned
|
||||
float belief{0.0f}; ///< accumulated posterior for actor_idx
|
||||
int observations{0}; ///< evidence updates that landed on this track
|
||||
};
|
||||
|
||||
// ── Track ────────────────────────────────────────────────────────────────────
|
||||
struct Track {
|
||||
int id{-1};
|
||||
double first_seen{0.0};
|
||||
std::optional<double> last_seen; ///< unset ⇒ on screen
|
||||
std::optional<int> actor; ///< set once a posterior crosses
|
||||
std::map<int, float> belief; ///< actor_idx → accumulated log-odds
|
||||
Embedding mean{}; ///< running directional mean
|
||||
int n_obs{0};
|
||||
|
||||
bool on_screen() const { return !last_seen.has_value(); }
|
||||
};
|
||||
|
||||
// ── TrackRegistry ────────────────────────────────────────────────────────────
|
||||
class TrackRegistry {
|
||||
public:
|
||||
using DeadTrackFn = std::function<void(const DeadTrack&)>;
|
||||
|
||||
struct Config {
|
||||
double extinction_sec{5.0}; ///< how long a lost track stays revivable
|
||||
float ownership_logodds{2.0f}; ///< belief needed to own a track (~0.88 posterior)
|
||||
};
|
||||
|
||||
explicit TrackRegistry(Config cfg) : cfg_(cfg) {}
|
||||
|
||||
void on_track_dead(DeadTrackFn fn) { on_dead_ = std::move(fn); }
|
||||
|
||||
// ── Frame scope ──────────────────────────────────────────────────────────
|
||||
// The tracker mutates registry state across a whole association pass, so
|
||||
// that pass must be atomic as a unit — per-call locking would let another
|
||||
// thread observe a half-updated frame. FrameScope holds the lock for its
|
||||
// lifetime and exposes the mutating operations without re-locking.
|
||||
class FrameScope {
|
||||
public:
|
||||
FrameScope(TrackRegistry& reg, double now)
|
||||
: reg_(reg), lock_(reg.mu_) { reg_.tick_locked(now); }
|
||||
|
||||
/// All live tracks — **one pool**. `last_seen` tells the caller whether
|
||||
/// IoU is meaningful; a dormant track is matched on embedding alone.
|
||||
/// There is no separate revival path (AR-008).
|
||||
std::vector<Track*> candidates() {
|
||||
std::vector<Track*> out;
|
||||
out.reserve(reg_.tracks_.size());
|
||||
for (auto& [id, t] : reg_.tracks_) out.push_back(&t);
|
||||
return out;
|
||||
}
|
||||
|
||||
int create(double t, const Embedding& e) { return reg_.create_locked(t, e); }
|
||||
void mark_seen(int id, double t, const Embedding& e){ reg_.mark_seen_locked(id, t, e); }
|
||||
void mark_lost(int id, double last_on_screen) { reg_.mark_lost_locked(id, last_on_screen); }
|
||||
|
||||
private:
|
||||
TrackRegistry& reg_;
|
||||
std::unique_lock<std::mutex> lock_;
|
||||
};
|
||||
|
||||
FrameScope begin_frame(double now) { return FrameScope(*this, now); }
|
||||
|
||||
/// Advance the clock and reap. Called every sampled frame **whether or not
|
||||
/// it had detections** — without it a track only dies when some other face
|
||||
/// happens to appear, and a film ending mid-track never closes.
|
||||
void tick(double now) { std::lock_guard g(mu_); tick_locked(now); }
|
||||
|
||||
// ── Evidence ─────────────────────────────────────────────────────────────
|
||||
/// Fold one observation into a track's belief (AR-025). `delta_logodds` is
|
||||
/// already discounted for correlation by the caller — that judgement belongs
|
||||
/// with whatever can tell a novel pose from a redundant one.
|
||||
///
|
||||
/// A vote for a track that has already been reaped is dropped and counted:
|
||||
/// a nonzero `dropped_votes()` means the timeout is shorter than the
|
||||
/// matcher's lag, which is a real misconfiguration and must not be silent.
|
||||
void observe(int track_id, int actor_idx, float delta_logodds) {
|
||||
std::lock_guard g(mu_);
|
||||
auto it = tracks_.find(track_id);
|
||||
if (it == tracks_.end()) { ++dropped_votes_; return; }
|
||||
|
||||
Track& t = it->second;
|
||||
t.belief[actor_idx] += delta_logodds;
|
||||
++t.n_obs;
|
||||
|
||||
const int best = argmax_belief(t);
|
||||
const float best_lo = t.belief[best];
|
||||
if (best_lo < cfg_.ownership_logodds) return;
|
||||
|
||||
if (!t.actor.has_value()) {
|
||||
claim_locked(t, best);
|
||||
return;
|
||||
}
|
||||
if (*t.actor != best) {
|
||||
// AR-014 — belief swapped A→B. Not a correction: a track_id almost
|
||||
// certainly carried across a viewpoint change onto a different
|
||||
// person. Two non-twins both clearing the threshold on one face is
|
||||
// not realistic; a track spanning two people is. Continuing would
|
||||
// emit one window blending both, so close here and start afresh.
|
||||
split_locked(t, best);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot read: tally and verdict under one lock. Reading them separately
|
||||
/// would let a track be both unowned and owned within a single promotion
|
||||
/// decision, since the matcher may be voting concurrently.
|
||||
std::optional<int> owner(int track_id) const {
|
||||
std::lock_guard g(mu_);
|
||||
auto it = tracks_.find(track_id);
|
||||
return it == tracks_.end() ? std::nullopt : it->second.actor;
|
||||
}
|
||||
|
||||
// ── Termination ──────────────────────────────────────────────────────────
|
||||
/// Emit every still-live track and empty the registry (AR-016). A film ends
|
||||
/// with faces on screen and those tracks have not timed out, so without this
|
||||
/// the closing scene's cast is silently never emitted — a loss that presents
|
||||
/// as a recognition miss rather than a bookkeeping bug.
|
||||
///
|
||||
/// Idempotent: calling it twice emits nothing the second time.
|
||||
void flush(double final_ts) {
|
||||
std::lock_guard g(mu_);
|
||||
for (auto& [id, t] : tracks_) emit_locked(t, t.last_seen.value_or(final_ts));
|
||||
tracks_.clear();
|
||||
}
|
||||
|
||||
// ── Diagnostics ──────────────────────────────────────────────────────────
|
||||
// These measure how often tracking is silently wrong, which nothing in the
|
||||
// pipeline currently reveals.
|
||||
int dropped_votes() const { std::lock_guard g(mu_); return dropped_votes_; }
|
||||
int belief_swaps() const { std::lock_guard g(mu_); return belief_swaps_; }
|
||||
int actor_conflicts() const { std::lock_guard g(mu_); return actor_conflicts_; }
|
||||
std::size_t live() const { std::lock_guard g(mu_); return tracks_.size(); }
|
||||
|
||||
private:
|
||||
// ── Locked internals ─────────────────────────────────────────────────────
|
||||
void tick_locked(double now) {
|
||||
for (auto it = tracks_.begin(); it != tracks_.end(); ) {
|
||||
const auto& ls = it->second.last_seen;
|
||||
if (ls && (now - *ls) > cfg_.extinction_sec) {
|
||||
emit_locked(it->second, *ls);
|
||||
it = tracks_.erase(it);
|
||||
} else {
|
||||
++it;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int create_locked(double t, const Embedding& e) {
|
||||
const int id = next_id_++;
|
||||
Track tr;
|
||||
tr.id = id;
|
||||
tr.first_seen = t;
|
||||
tr.mean = e;
|
||||
tr.n_obs = 0;
|
||||
tracks_.emplace(id, std::move(tr));
|
||||
return id;
|
||||
}
|
||||
|
||||
void mark_seen_locked(int id, double t, const Embedding& e) {
|
||||
auto it = tracks_.find(id);
|
||||
if (it == tracks_.end()) return;
|
||||
Track& tr = it->second;
|
||||
tr.last_seen.reset(); // back on screen; the gap is absorbed
|
||||
update_mean(tr, e);
|
||||
(void)t;
|
||||
}
|
||||
|
||||
void mark_lost_locked(int id, double last_on_screen) {
|
||||
auto it = tracks_.find(id);
|
||||
if (it == tracks_.end()) return;
|
||||
it->second.last_seen = last_on_screen;
|
||||
}
|
||||
|
||||
void claim_locked(Track& t, int actor) {
|
||||
// AR-015 — if another live track already owns this actor, at least one
|
||||
// is wrong: a person cannot be in two places at once. The cause is the
|
||||
// same as a belief swap — a missed camera or scene change. Detected on
|
||||
// the update that causes it via the reverse index, not by scanning.
|
||||
auto seen = owner_index_.find(actor);
|
||||
if (seen != owner_index_.end() && seen->second != t.id
|
||||
&& tracks_.count(seen->second)) {
|
||||
++actor_conflicts_;
|
||||
}
|
||||
t.actor = actor;
|
||||
owner_index_[actor] = t.id;
|
||||
}
|
||||
|
||||
void split_locked(Track& t, int new_actor) {
|
||||
++belief_swaps_;
|
||||
const double boundary = t.last_seen.value_or(t.first_seen);
|
||||
emit_locked(t, boundary);
|
||||
|
||||
// The successor inherits the embedding and the belief that caused the
|
||||
// swap, and starts at the swap frame — so the two windows abut without
|
||||
// overlapping and neither blends the two people.
|
||||
Track next;
|
||||
next.id = next_id_++;
|
||||
next.first_seen = boundary;
|
||||
next.mean = t.mean;
|
||||
next.belief[new_actor] = t.belief[new_actor];
|
||||
next.n_obs = 1;
|
||||
const int old_id = t.id;
|
||||
Track stash = std::move(next);
|
||||
tracks_.erase(old_id);
|
||||
const int nid = stash.id;
|
||||
tracks_.emplace(nid, std::move(stash));
|
||||
claim_locked(tracks_.at(nid), new_actor);
|
||||
}
|
||||
|
||||
void emit_locked(Track& t, double end_ts) {
|
||||
if (!on_dead_) return;
|
||||
DeadTrack d;
|
||||
d.track_id = t.id;
|
||||
d.first_seen = t.first_seen;
|
||||
d.last_seen = end_ts;
|
||||
d.observations = t.n_obs;
|
||||
if (t.actor) {
|
||||
d.actor_idx = *t.actor;
|
||||
d.belief = logistic(t.belief[*t.actor]);
|
||||
auto oi = owner_index_.find(*t.actor);
|
||||
if (oi != owner_index_.end() && oi->second == t.id) owner_index_.erase(oi);
|
||||
}
|
||||
on_dead_(d);
|
||||
}
|
||||
|
||||
static int argmax_belief(const Track& t) {
|
||||
int best = -1;
|
||||
float hi = -1e30f;
|
||||
for (const auto& [a, lo] : t.belief) if (lo > hi) { hi = lo; best = a; }
|
||||
return best;
|
||||
}
|
||||
|
||||
static void update_mean(Track& t, const Embedding& e) {
|
||||
// Directional mean: accumulate then re-normalise to the unit sphere, so
|
||||
// cosine against it stays a plain dot product.
|
||||
double norm = 0.0;
|
||||
for (int i = 0; i < 512; ++i) {
|
||||
t.mean[i] = t.mean[i] * static_cast<float>(t.n_obs ? t.n_obs : 1) + e[i];
|
||||
norm += static_cast<double>(t.mean[i]) * t.mean[i];
|
||||
}
|
||||
norm = norm > 0 ? std::sqrt(norm) : 1.0;
|
||||
for (int i = 0; i < 512; ++i) t.mean[i] = static_cast<float>(t.mean[i] / norm);
|
||||
}
|
||||
|
||||
static float logistic(float z) {
|
||||
return z >= 0 ? 1.f / (1.f + std::exp(-z))
|
||||
: std::exp(z) / (1.f + std::exp(z));
|
||||
}
|
||||
|
||||
Config cfg_;
|
||||
mutable std::mutex mu_;
|
||||
std::map<int, Track> tracks_;
|
||||
std::map<int, int> owner_index_; ///< actor_idx → live track_id (AR-015)
|
||||
DeadTrackFn on_dead_;
|
||||
int next_id_{0};
|
||||
int dropped_votes_{0};
|
||||
int belief_swaps_{0};
|
||||
int actor_conflicts_{0};
|
||||
};
|
||||
@@ -21,6 +21,7 @@ add_executable(sae_tests
|
||||
test_face_utils.cpp
|
||||
test_track_gallery.cpp
|
||||
test_face_tracker.cpp
|
||||
test_track_registry.cpp
|
||||
test_audio_signature.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/backends/gemm_backend.cpp
|
||||
${CMAKE_SOURCE_DIR}/src/gallery/gallery_store.cpp
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user