The registry closed a track when the *tracker's* timestamp passed `track_extinction_sec`. But votes arrive from the matcher, which is a separate KPN node behind a channel, and much the slower of the pair. Backpressure — working exactly as AR-004 intends — turns that channel's depth into lag, so the tracker's clock can be far ahead of the last frame anybody has voted on. Tracks were therefore closed before their evidence arrived: the votes landed on ids that no longer existed, were counted as dropped, and the track was emitted unowned or not at all. The symptom is the part worth remembering: **a deeper channel produced fewer identifications, from identical input.** On the SuperHero fixture, 5 actors / 16 windows at depth 32 against 3 actors / 5 windows at depth 10322; through the replay harness, capacity 32 gave 5 actors and 10322 gave 0. A throughput knob was silently changing the answer, which makes every sweep tuned against it suspect. The fix is not to bound the channel against `track_extinction_sec` — that makes an algorithm constant police a throughput knob and leaves the result a function of scheduling. It is to reap on an evidence watermark: the matcher advances it as it folds each frame in, and a track is only finished once everything up to its extinction point has actually been voted on. Same device `SceneBoundaries::scored_through()` uses for the AR-010 join — a consumer past that point is asking about frames nobody has looked at yet, and the honest answer is to wait rather than guess. Association keeps the tracker's clock, and separating the two is the other half. They answer different questions: "may this detection link to that track?" is asked now, about a box seen `track_extinction_sec` ago; "is that track finished?" cannot be answered until every vote is in. Deferring association to the evidence clock — which deferring the erase alone did — left retired tracks associable for as long as the matcher lagged, so a new face re-associated onto a long-dead track and two people merged into one window. The watermark is monotonic and only ever *delays* a reap, so no window is extended by it: AR-013's "a window ends at the last sighting, never after" is a property of `emit_locked`, which takes `last_seen` and never `now`. `dropped_votes` is exposed and reported — by main at shutdown and through the replay bindings — because this failed silently for as long as it did precisely because nothing counted it. It warns rather than aborts: a dropped frame means the output describes footage nobody analysed and is always wrong, while a dropped vote degrades a claim without falsifying it, and there is no measurement yet of how often it happens on real content. replay.py's channel capacity stops being the whole film. It was sized that way to dodge a PyNode overflow drop that AR-004 has since replaced with parking, and removing backpressure that way is what made the defect above so extreme. Tag separators in kpn_bindings.cpp corrected to pipes between requirement types, which the traceability gate was reporting as diagnostics; the matrix is regenerated and reports 0 orphan tags. 149/149. TRACES: AR-004, AR-012, AR-013, AR-025 | VR-011 | SR-002 | PR-002
391 lines
20 KiB
C++
391 lines
20 KiB
C++
#pragma once
|
||
#include "types.hpp"
|
||
#include "config.hpp"
|
||
#include "inference/similarity.hpp"
|
||
#include "gallery/gallery_store.hpp"
|
||
#include "gallery/gallery_calibration.hpp"
|
||
#include "gallery/track_gallery.hpp"
|
||
#include "track_registry.hpp"
|
||
|
||
#include <cstdint>
|
||
#include <cstring>
|
||
#include <iostream>
|
||
#include <limits>
|
||
#include <memory>
|
||
#include <stdexcept>
|
||
#include <vector>
|
||
|
||
// ── IdentityMatcherFunc ───────────────────────────────────────────────────────
|
||
// KPN node: compares each embedding against every reference embedding in the
|
||
// actor gallery using cosine similarity.
|
||
//
|
||
// Matching strategy — one mode, always.
|
||
//
|
||
// Gallery calibration fits a sigmoid P(match) = σ(a·similarity + b) from
|
||
// intra/inter-class pairs. A face is accepted if P(match | best_actor) >
|
||
// prob_threshold. Per-actor best similarity is the closest reference
|
||
// embedding (best-of-N).
|
||
//
|
||
/// TRACES: AR-024 | SR-002
|
||
// **There is no raw-cosine fallback.** There used to be: when the fit was
|
||
// invalid this node switched to a cosine-distance ceiling plus a ratio test
|
||
// (`match_threshold`, `match_ratio`, `match_ratio_ceil`). Three things were
|
||
// wrong with it, and the third is the one that mattered.
|
||
//
|
||
// 1. It violated AR-024 outright, untagged — a bare cosine threshold means
|
||
// something different for every model, gallery and face size.
|
||
// 2. It disagreed with the rest of the pipeline about what "calibration
|
||
// failed" means. `same_person_probability` answers that question by
|
||
// falling back to the untuned default sigmoid and saying so loudly, so
|
||
// tracking and evidence weighting stayed in probability space while
|
||
// matching alone left it. One run, two policies.
|
||
// 3. Its accepted faces were still fed to `TrackRegistry::observe`, whose
|
||
// contract reads "posterior is a calibrated probability, never a raw
|
||
// cosine (AR-024) ... so the accumulation cannot be fed an uncalibrated
|
||
// number by a careless caller". It could. `max(0, cosine)` went straight
|
||
// into the log-odds accumulation as though it were a probability.
|
||
//
|
||
// An invalid fit now behaves exactly as everywhere else: the default sigmoid,
|
||
// with a warning that says the probabilities are not meaningful. That is a
|
||
// worse answer than a fitted calibration and a better one than a number whose
|
||
// units nothing else in the pipeline shares.
|
||
//
|
||
// Gallery scan: the full reference set (tens of thousands of 512-dim
|
||
// embeddings) is uploaded to the GPU once at construction time and stays
|
||
// resident there. Per frame, only the small query matrix (n_faces x 512) is
|
||
// uploaded and a single SGEMM computes the full similarity matrix in well under
|
||
// a millisecond. The GPU math backend (cuBLAS or rocBLAS) lives behind
|
||
// ISimilarityEngine (backends/gemm_backend.cpp) and is selected at compile time.
|
||
//
|
||
// TRACES: AR-026 | SR-001
|
||
// That resident matrix grows during a film: per-film expansion (AR-019) promotes
|
||
// pose-varied views, and they are APPENDED to it rather than scored separately,
|
||
// so one multiply covers baked and promoted references alike and best-of-N is a
|
||
// single pass over one similarity column. There is no second similarity path in
|
||
// this node to fall out of step with the first.
|
||
|
||
struct IdentityMatcherFunc {
|
||
static constexpr std::string_view label() { return "identity_matcher"; }
|
||
|
||
// Max faces handled per frame without reallocating GPU buffers.
|
||
static constexpr int kMaxFaces = 32;
|
||
|
||
IdentityMatcherFunc(const ActorGallery& gallery, const Config& cfg)
|
||
: gallery_(gallery)
|
||
, prob_threshold_(cfg.prob_threshold)
|
||
, log_prior_odds_(std::log(cfg.match_prior / (1.f - cfg.match_prior)))
|
||
, track_gallery_(cfg)
|
||
{
|
||
std::cerr << "[identity_matcher] flattening gallery embeddings...\n";
|
||
for (int ai = 0; ai < static_cast<int>(gallery_.actors.size()); ++ai) {
|
||
for (const auto& emb : gallery_.actors[ai].embeddings) {
|
||
flat_emb_.push_back(emb);
|
||
flat_actor_.push_back(ai);
|
||
}
|
||
}
|
||
n_gallery_ = static_cast<int>(flat_emb_.size());
|
||
|
||
std::cerr << "[identity_matcher] starting calibration ("
|
||
<< flat_emb_.size() << " embeddings)...\n";
|
||
bool recomputed = false;
|
||
cal_ = calibrate_gallery_cached(flat_emb_, flat_actor_,
|
||
gallery_.calib_a, gallery_.calib_b,
|
||
gallery_.calib_valid, gallery_.calib_hash,
|
||
cfg.gallery_path + ".calib_cache", recomputed);
|
||
if (recomputed) {
|
||
// Persist the freshly-fitted calibration into the gallery file (always
|
||
// HDF5 — save_gallery rewrites any other extension, see gallery_store.cpp)
|
||
// so the next run against this same, unchanged gallery skips the O(n^2) fit.
|
||
ActorGallery to_save = gallery_;
|
||
to_save.calib_a = cal_.a;
|
||
to_save.calib_b = cal_.b;
|
||
to_save.calib_valid = cal_.valid;
|
||
to_save.calib_hash = hash_gallery_embeddings(flat_emb_, flat_actor_);
|
||
save_gallery(cfg.gallery_path, to_save);
|
||
std::cerr << "[identity_matcher] wrote refreshed calibration back to "
|
||
<< cfg.gallery_path << "\n";
|
||
}
|
||
|
||
/// TRACES: AR-024 | SR-002
|
||
// Same sentence either way, because it is the same decision rule; only
|
||
// the provenance of (a, b) differs. An unfitted sigmoid still returns
|
||
// plausible-looking probabilities, so the warning has to be the thing
|
||
// that distinguishes them — nothing downstream can.
|
||
std::cerr << "[identity_matcher] calibrated Bayesian matching"
|
||
<< " prior=" << cfg.match_prior
|
||
<< " P_threshold=" << prob_threshold_
|
||
<< " effective_sim_boundary="
|
||
<< cal_.boundary_at(prob_threshold_, log_prior_odds_) << "\n";
|
||
if (!cal_.valid) {
|
||
std::cerr << "[identity_matcher] WARNING: the calibration is NOT fitted "
|
||
"(a=" << cal_.a << ", b=" << cal_.b << ") — matching runs "
|
||
"on the untuned default sigmoid, so prob_threshold is not "
|
||
"comparable to a tuned run's.\n";
|
||
}
|
||
std::cerr << "[identity_matcher] gallery: "
|
||
<< gallery_.actors.size() << " actors, "
|
||
<< flat_emb_.size() << " reference embeddings\n";
|
||
|
||
std::vector<float> host_gallery(static_cast<size_t>(n_gallery_) * 512);
|
||
for (int i = 0; i < n_gallery_; ++i)
|
||
std::memcpy(host_gallery.data() + static_cast<size_t>(i) * 512,
|
||
flat_emb_[i].data(), 512 * sizeof(float));
|
||
|
||
sim_engine_ = make_similarity_engine(host_gallery.data(), n_gallery_, kMaxFaces);
|
||
|
||
/// TRACES: AR-018, AR-024 | SR-005
|
||
// The expansion store thresholds in the same probability space as
|
||
// association and evidence weighting, so a "0.9" means one thing
|
||
// pipeline-wide rather than three.
|
||
track_gallery_.set_calibration(same_person_probability(cal_));
|
||
}
|
||
|
||
/// TRACES: AR-023, AR-024 | SR-002
|
||
/// The fitted sigmoid. Exposed because the matcher is where it gets fitted
|
||
/// (and cached back to the gallery), but it is not the matcher's private
|
||
/// property: track association and evidence weighting must threshold in the
|
||
/// *same* probability space, or a "0.5" in one stage and a "0.5" in another
|
||
/// mean different things. See `same_person_probability`.
|
||
const GalleryCalibration& calibration() const { return cal_; }
|
||
|
||
/// TRACES: AR-012, AR-025 | SR-002
|
||
/// Where per-frame identity evidence reaches the registry. Optional: with no
|
||
/// registry attached the matcher behaves exactly as before, which keeps the
|
||
/// replay harness and the unit tests working unchanged.
|
||
void set_registry(std::shared_ptr<TrackRegistry> r) {
|
||
registry_ = std::move(r);
|
||
// This node is the evidence source, so the registry must not close a
|
||
// track until this node's watermark has passed it (AR-013).
|
||
if (registry_) registry_->expect_evidence();
|
||
}
|
||
|
||
// Runtime setter — lets a persistent pipeline be reused across a threshold sweep
|
||
// without rebuilding the (expensive, gallery-resident) matcher. The gallery,
|
||
// calibration and GPU sim-engine stay put; only the accept threshold changes.
|
||
void set_prob_threshold(float t) { prob_threshold_ = t; }
|
||
|
||
MatchedSceneFrame operator()(TrackedSceneFrame tf) {
|
||
if (tf.source.eof) {
|
||
track_gallery_.clear_tracks();
|
||
return {std::move(tf.source), {}};
|
||
}
|
||
|
||
/// TRACES: AR-012, AR-013 | SR-002
|
||
// Publish the evidence watermark BEFORE voting on this frame: every
|
||
// observation strictly before it has now been folded in, so the registry
|
||
// may reap against it. Unconditional -- a frame with no faces still
|
||
// advances the watermark, or a long faceless stretch would stall reaping
|
||
// and hold every dormant track open to the end of the film.
|
||
//
|
||
// This is what makes presence independent of node speed. The registry
|
||
// used to reap on the TRACKER's clock, and backpressure (working as
|
||
// AR-004 intends) means the tracker can be a whole channel's depth ahead
|
||
// of this node -- so tracks were closed before their votes arrived, the
|
||
// votes were dropped, and the run silently under-reported. Measured on
|
||
// the SuperHero fixture before this change: channel depth 32 gave 5
|
||
// actors, depth 10322 gave 0, from identical input.
|
||
if (registry_) registry_->advance_evidence(tf.source.timestamp_sec);
|
||
|
||
// A hard cut changes the camera viewpoint. The face_tracker may revive a
|
||
// track_id across the cut (identity continuity), but promotion must never
|
||
// mix embeddings from two viewpoints under one buffer, so we still drop
|
||
// every diversity buffer here — a revived track simply re-accumulates its
|
||
// buffer from post-cut frames. Stale cross-cut embeddings are never promoted.
|
||
/// TRACES: AR-019 | SR-005
|
||
// Promotion may only borrow same-identity evidence from a span where
|
||
// identity is certain, so ALL THREE discontinuity signals clear the
|
||
// buffers, not just the histogram cut:
|
||
// is_cut — camera-angle change
|
||
// is_scene_boundary — different scene (AR-010; previously never set,
|
||
// so this half of the gate was dead)
|
||
// The third, an identity contradiction (AR-015), is enforced by the
|
||
// registry: a track whose belief swapped is closed outright, so it can
|
||
// no longer promote anything.
|
||
if (tf.source.is_cut || tf.source.is_scene_boundary)
|
||
track_gallery_.clear_tracks();
|
||
|
||
const int n_faces = static_cast<int>(tf.embeddings.size());
|
||
std::vector<IdentifiedActor> actors;
|
||
actors.reserve(n_faces);
|
||
|
||
if (n_faces == 0) return {std::move(tf.source), {}};
|
||
|
||
/// TRACES: AR-003, AR-004 | SR-002
|
||
// kMaxFaces sizes the similarity engine's preallocated buffer, so it
|
||
// bounds MEMORY, not how many faces a frame may contain. It used to
|
||
// throw above the bound, which made it a hard cap on crowd scenes by
|
||
// accident; now the frame is scored in batches of that size.
|
||
//
|
||
// Faces per frame are unbounded (AR-003) because X-Ray credits scene
|
||
// membership to background cast too, and a fixed cap discards exactly
|
||
// those — the smallest faces are dropped first. Cost is contained by
|
||
// backpressure (AR-004), which slows the producer, rather than by
|
||
// silently throwing work away.
|
||
std::vector<float> host_query(static_cast<size_t>(kMaxFaces) * 512);
|
||
|
||
for (int base = 0; base < n_faces; base += kMaxFaces) {
|
||
const int chunk = std::min(kMaxFaces, n_faces - base);
|
||
for (int k = 0; k < chunk; ++k) {
|
||
std::memcpy(host_query.data() + static_cast<size_t>(k) * 512,
|
||
tf.embeddings[base + k].data(), 512 * sizeof(float));
|
||
}
|
||
|
||
/// TRACES: AR-026 | SR-001
|
||
// One GEMM now covers baked references AND the per-film annex: promoted
|
||
// rows were appended to the engine's resident matrix, so they are just
|
||
// more gallery rows with an entry in flat_actor_. The annex used to be
|
||
// folded in afterwards by a host-side cosine loop, justified by "tens of
|
||
// embeddings" — an assumption AR-018/AR-019 retired, since every owned
|
||
// track promotes and the annex grows with cast size and film length.
|
||
//
|
||
// n_gallery() is read per frame, not cached: it grows as promotions land.
|
||
const int n_gal = sim_engine_->n_gallery();
|
||
const float* host_sims = sim_engine_->compute(host_query.data(), chunk);
|
||
|
||
for (int ci = 0; ci < chunk; ++ci) {
|
||
const int fi = base + ci;
|
||
const float* sims = host_sims + static_cast<size_t>(ci) * n_gal;
|
||
|
||
std::vector<float> best_sim(gallery_.actors.size(),
|
||
-std::numeric_limits<float>::max());
|
||
for (int ei = 0; ei < n_gal; ++ei) {
|
||
float sim = sims[ei];
|
||
int ai = flat_actor_[ei];
|
||
if (sim > best_sim[ai]) best_sim[ai] = sim;
|
||
}
|
||
|
||
// Only the best matters now. The runner-up was tracked solely for
|
||
// the retired ratio test, which asked whether the best cosine stood
|
||
// out from the second — a question the calibrated posterior does
|
||
// not need, since it already says how likely the best match is to
|
||
// be right rather than how much it beat its neighbour by.
|
||
int best_actor = -1;
|
||
float best_s = -std::numeric_limits<float>::max();
|
||
for (int ai = 0; ai < static_cast<int>(best_sim.size()); ++ai) {
|
||
if (best_sim[ai] > best_s) {
|
||
best_s = best_sim[ai];
|
||
best_actor = ai;
|
||
}
|
||
}
|
||
|
||
/// TRACES: AR-024 | SR-002
|
||
// One rule, whatever the fit's provenance. The cosine reaches a
|
||
// comparison only through cal_.probability().
|
||
const float best_p = best_actor >= 0
|
||
? cal_.probability(best_s, log_prior_odds_)
|
||
: 0.f;
|
||
const bool accept = best_actor >= 0 && best_p > prob_threshold_;
|
||
|
||
IdentifiedActor ia;
|
||
// Map bbox back to original video resolution when dense_scale
|
||
// downscaled the decoded frame (detection/tracking ran downscaled;
|
||
// output bboxes must be in original pixel space).
|
||
ia.bbox = tf.faces[fi].bbox;
|
||
if (tf.source.bbox_upscale != 1.f) {
|
||
const float s = tf.source.bbox_upscale;
|
||
ia.bbox.x *= s; ia.bbox.y *= s;
|
||
ia.bbox.width *= s; ia.bbox.height *= s;
|
||
}
|
||
ia.crop = tf.crops[fi];
|
||
ia.track_id = tf.track_ids[fi];
|
||
|
||
if (accept) {
|
||
ia.actor_idx = best_actor;
|
||
ia.name = gallery_.actors[best_actor].name;
|
||
ia.imdb_id = gallery_.actors[best_actor].imdb_id;
|
||
ia.tmdb_id = gallery_.actors[best_actor].tmdb_id;
|
||
ia.jellyfin_id = gallery_.actors[best_actor].jellyfin_id;
|
||
ia.similarity = best_p;
|
||
}
|
||
|
||
// Feed this face into per-film gallery expansion. best_actor/best_s
|
||
// reflect the actor with the strongest gallery similarity for this
|
||
// face (annex already folded in above); the track's diversity buffer
|
||
// keeps the gallery-far views and promotes them once the track is
|
||
// confirmed. No-op unless --expand-gallery is set.
|
||
// TRACES: AR-012, AR-025 | SR-002
|
||
// Every scored face is evidence, not only the accepted ones: a run of
|
||
// near-misses for one actor is itself informative, and discarding it
|
||
// would make ownership depend on a per-frame threshold the redesign
|
||
// exists to stop relying on. The registry discounts for correlation
|
||
// and decides ownership from the accumulated posterior (AR-025).
|
||
if (registry_ && best_actor >= 0 && tf.track_ids[fi] >= 0)
|
||
registry_->observe(tf.track_ids[fi], best_actor, best_p,
|
||
tf.embeddings[fi]);
|
||
|
||
// TRACES: AR-019 | SR-005
|
||
// Ownership is the registry's, computed once. TrackGallery used to
|
||
// tally its own plurality vote over accepted frames, which meant two
|
||
// different answers to "who is this track" could coexist — and the
|
||
// expansion one ignored the Bayesian accumulation entirely.
|
||
if (registry_ && tf.track_ids[fi] >= 0) {
|
||
if (auto owner = registry_->owner(tf.track_ids[fi]))
|
||
track_gallery_.set_owner(tf.track_ids[fi], *owner);
|
||
}
|
||
|
||
track_gallery_.observe(tf.track_ids[fi], tf.embeddings[fi],
|
||
best_actor, best_s, accept, tf.crops[fi]);
|
||
|
||
actors.push_back(std::move(ia));
|
||
}
|
||
} // chunk loop
|
||
|
||
absorb_promotions();
|
||
|
||
/// TRACES: AR-019 | SR-005
|
||
// Drop buffers for tracks the registry has reaped. Without this a track
|
||
// that simply went off screen kept its diversity buffer until the next
|
||
// cut, so the store grew with the film rather than with what is on
|
||
// screen — and a buffer that outlives its track is evidence about a
|
||
// person nobody is looking at any more.
|
||
if (registry_)
|
||
track_gallery_.prune_dead(
|
||
[this](int id) { return registry_->is_live(id); });
|
||
|
||
return {std::move(tf.source), std::move(actors)};
|
||
}
|
||
|
||
private:
|
||
/// TRACES: AR-026 | SR-001
|
||
/// Move rows promoted during this frame into the resident gallery matrix,
|
||
/// extending the actor mapping in lockstep so row i keeps naming the actor
|
||
/// at flat_actor_[i]. Runs once per frame, after every face has been scored:
|
||
/// appending mid-frame would invalidate the similarity pointer the chunk
|
||
/// loop is still reading, and it is also the semantics the expansion store
|
||
/// documents — a promotion helps SUBSEQUENT frames, never the one that
|
||
/// produced it, so identification cannot depend on face order within a frame.
|
||
void absorb_promotions() {
|
||
if (!track_gallery_.enabled()) return;
|
||
|
||
pending_emb_.clear();
|
||
pending_actor_.clear();
|
||
const int n = track_gallery_.drain_promotions(pending_emb_, pending_actor_);
|
||
if (n == 0) return;
|
||
|
||
sim_engine_->append_rows(pending_emb_.data(), n);
|
||
flat_actor_.insert(flat_actor_.end(),
|
||
pending_actor_.begin(), pending_actor_.end());
|
||
n_gallery_ = sim_engine_->n_gallery();
|
||
}
|
||
|
||
ActorGallery gallery_;
|
||
GalleryCalibration cal_;
|
||
float prob_threshold_;
|
||
float log_prior_odds_;
|
||
/// flat_emb_ is the BAKED reference set only — it is the calibration fit's
|
||
/// input (AR-023) and is not touched again after construction. flat_actor_,
|
||
/// by contrast, is the actor mapping parallel to the *engine's* rows, so it
|
||
/// grows with every promotion absorbed (AR-026) and is the longer of the two.
|
||
std::vector<Embedding> flat_emb_;
|
||
std::vector<int> flat_actor_;
|
||
int n_gallery_{0};
|
||
|
||
// Reused across frames so absorbing a promotion allocates nothing.
|
||
std::vector<float> pending_emb_;
|
||
std::vector<int> pending_actor_;
|
||
|
||
std::unique_ptr<ISimilarityEngine> sim_engine_;
|
||
TrackGallery track_gallery_;
|
||
std::shared_ptr<TrackRegistry> registry_;
|
||
};
|