Moves two responsibilities inside the registry that callers should never have been trusted with. AR-025 — per-frame evidence is discounted for correlation by the registry itself, via EvidenceDiscounter. Log-odds accumulation is only valid for independent observations, and consecutive frames of one track are anything but: near-identical pose, lighting and expression. Accumulated naively, thirty frames of the same face at the same angle drive the posterior to certainty on what is effectively one measurement. Each observation is weighted by how much it adds — a view already contributed counts for ~nothing, a genuinely new pose counts in full. This reuses the novelty judgement gallery expansion already makes rather than inventing a second one. The discounter is a separate class the registry holds, so it stays testable and swappable, but it is a constructor argument rather than an option: there is no correct way to accumulate without it. AR-024 — observe() takes a calibrated probability and converts to log-odds internally. A caller can no longer hand it a raw cosine, which would have been silently wrong rather than obviously so. Retiring the remaining raw-cosine constants in the tracker is still open. DeadTrack now reports effective_obs alongside observations: the raw count and the evidence that actually counted. A large gap between them is a track the camera stared at, and worth seeing. Three tests, one of which is the point: two tracks given the same number of observations at the same posterior, one repeating a single view and one seeing eight distinct ones, must not end up equally confident. Without discounting they would be identical. Fixed a test that asserted a belief swap on tied evidence. A tie leaves ownership where it is — a challenger must out-accumulate the incumbent, since one contrary observation is noise. The original test passed only because it fed raw log-odds directly. Suite: 78 cases, 3245 assertions. Coverage 20/63 to 22/63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-024, AR-025 | SR-002
336 lines
15 KiB
C++
336 lines
15 KiB
C++
#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 "evidence_discount.hpp"
|
|
|
|
#include <cmath>
|
|
#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
|
|
float effective_obs{0.f}; ///< sum of discounted weights — the evidence that counted
|
|
};
|
|
|
|
// ── 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
|
|
std::vector<Embedding> views; ///< distinct looks, for AR-025 discounting
|
|
float discounted_weight{0.f}; ///< sum of applied weights
|
|
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)
|
|
};
|
|
|
|
/// The discounter is a constructor argument rather than an option: there is
|
|
/// no correct way to accumulate per-frame evidence without it.
|
|
TrackRegistry(Config cfg, EvidenceDiscounter discounter)
|
|
: cfg_(cfg), discounter_(std::move(discounter)) {}
|
|
|
|
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).
|
|
///
|
|
/// `posterior` is a **calibrated probability**, never a raw cosine
|
|
/// (AR-024) — the registry converts it to log-odds itself, so the
|
|
/// accumulation cannot be fed an uncalibrated number by a careless caller.
|
|
///
|
|
/// Correlation discounting is applied **here**, not by the caller.
|
|
/// Consecutive frames of one track are near-identical, and accumulating
|
|
/// them as independent evidence drives the posterior to certainty on what
|
|
/// is effectively a single measurement. Leaving that to callers would mean
|
|
/// a forgotten or doubly-applied discount produces confident wrong answers
|
|
/// silently; the registry is the one place all evidence converges, so it is
|
|
/// the one place the correction belongs.
|
|
///
|
|
/// 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 posterior, const Embedding& e) {
|
|
std::lock_guard g(mu_);
|
|
auto it = tracks_.find(track_id);
|
|
if (it == tracks_.end()) { ++dropped_votes_; return; }
|
|
|
|
Track& t = it->second;
|
|
const float w = discounter_.weight(t.views, e);
|
|
t.belief[actor_idx] += w * logit(posterior);
|
|
t.discounted_weight += w;
|
|
++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;
|
|
d.effective_obs = t.discounted_weight;
|
|
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));
|
|
}
|
|
|
|
static float logit(float p) {
|
|
const float eps = 1e-6f;
|
|
p = std::min(1.f - eps, std::max(eps, p));
|
|
return std::log(p / (1.f - p));
|
|
}
|
|
|
|
Config cfg_;
|
|
EvidenceDiscounter discounter_;
|
|
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};
|
|
};
|