AR-018 — an embedding joins a track's store only if its similarity to something already there falls inside a band, rather than merely being far from the gallery. Above the upper bound it is redundant: another look at a pose the store already covers, teaching the annex nothing while costing a slot a novel view could have used. Below the lower bound it is suspect: within one track every face is the same person by construction, so an embedding unlike everything else on the track is evidence that construction failed — a track-ID collision or a bad detection. Admitting it is exactly how an actor's annex gets poisoned with someone else's face. The old gate had only the upper half of that idea, expressed as a raw cosine against the gallery. Both bounds are now calibrated probabilities (AR-024), so the same number means the same thing here as in association and evidence weighting rather than three different things. This catches track-ID collisions EARLIER than the spread gate did — at the door rather than at promotion — so the buffer never becomes two-person in the first place. The spread gate stays as a second line for a track that drifts gradually instead of jumping. The existing test was asserting the mechanism rather than the outcome, so it was rewritten to assert what actually matters: whichever gate fires, the outsider must not reach the annex. Rejections are counted. A store that admits nothing is as broken as one that admits everything, and neither is visible otherwise. Band defaults 0.90-0.95 are working values pending VR-007; the two bounds fail in opposite directions and must be swept separately. Suite: 92 cases, 6133 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-018, AR-024 | SR-005
295 lines
13 KiB
C++
295 lines
13 KiB
C++
#pragma once
|
||
#include "types.hpp"
|
||
#include "config.hpp"
|
||
|
||
#include <functional>
|
||
|
||
#include <cmath>
|
||
#include <cstdio>
|
||
#include <iostream>
|
||
#include <limits>
|
||
#include <map>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
#ifdef SAE_DEBUG
|
||
#include <opencv2/imgcodecs.hpp>
|
||
#endif
|
||
#include <opencv2/core.hpp>
|
||
#include <filesystem>
|
||
|
||
// ── TrackGallery ──────────────────────────────────────────────────────────────
|
||
// Per-film gallery expansion driven by track continuity.
|
||
//
|
||
// A single uncut face track is, by construction, one physical person: the
|
||
// face_tracker links detections frame-to-frame and clears all tracks on a scene
|
||
// cut, so a track ID never spans a cut. That continuity is a same-identity label
|
||
// the baked gallery does not have. TrackGallery exploits it in two stages:
|
||
//
|
||
// 1. Diversity buffer (per track). Every frame's embedding is offered to a
|
||
// fixed-capacity buffer for that track. When full, the member whose best
|
||
// similarity to the *owning actor's* gallery references is HIGHEST is
|
||
// dropped — i.e. the pose the gallery already recognises well is the least
|
||
// informative, so the buffer is continuously biased toward the gallery-far
|
||
// (novel-pose) embeddings on which recognition currently fails.
|
||
//
|
||
// 2. Promotion (on confirmation). A track is "owned" by actor A once ≥N frames
|
||
// have been accepted (by the matcher's calibrated posterior) as A. On
|
||
// confirmation the retained buffer — the hard, gallery-far poses — is
|
||
// promoted into A's per-film annex, after two safety gates:
|
||
// • novelty: only embeddings whose best sim to A's refs is below
|
||
// expand_novelty_sim are added (skip poses already covered);
|
||
// • spread: if the retained buffer's internal spread (1 − min pairwise
|
||
// cosine sim) exceeds expand_track_spread_max the whole track is
|
||
// rejected — such spread signals a track-ID collision merging two
|
||
// people, whose embeddings must never enter A's annex.
|
||
//
|
||
// The annex is CPU-side and in-memory: it is small (tens of embeddings) so the
|
||
// matcher scans it with a scalar loop, and it is discarded when the process
|
||
// exits. Promoted embeddings only help SUBSEQUENT frames and later tracks of A —
|
||
// the pipeline stays streaming, no emitted output is buffered or relabelled.
|
||
|
||
struct TrackGallery {
|
||
// One promoted reference view held in the per-actor annex.
|
||
struct AnnexEntry {
|
||
Embedding emb;
|
||
int actor_idx{-1};
|
||
};
|
||
|
||
explicit TrackGallery(const Config& cfg)
|
||
: enabled_(cfg.expand_gallery)
|
||
, buffer_size_(std::max(1, cfg.expand_buffer_size))
|
||
, novelty_sim_(cfg.expand_novelty_sim)
|
||
, spread_max_(cfg.expand_track_spread_max)
|
||
, min_anchor_frames_(std::max(1, cfg.expand_min_anchor_frames))
|
||
, debug_dir_(cfg.expand_debug_dir)
|
||
{
|
||
if (!enabled_) return;
|
||
std::cerr << "[track_gallery] per-film expansion ON"
|
||
<< " buffer=" << buffer_size_
|
||
<< " novelty_sim<" << novelty_sim_
|
||
<< " spread_max=" << spread_max_
|
||
<< " min_anchor_frames=" << min_anchor_frames_;
|
||
if (!debug_dir_.empty()) {
|
||
std::filesystem::create_directories(debug_dir_);
|
||
std::cerr << " debug_dir=" << debug_dir_;
|
||
}
|
||
std::cerr << "\n";
|
||
}
|
||
|
||
bool enabled() const { return enabled_; }
|
||
|
||
// Current annex contents (empty when disabled). The matcher scans these
|
||
// alongside the baked gallery so a promoted view can win best-of-N for its
|
||
// actor. Returned by const-ref; only grows, never reordered.
|
||
const std::vector<AnnexEntry>& annex() const { return annex_; }
|
||
|
||
// Offer one observed face to its track's diversity buffer.
|
||
// track_id : face_tracker track (−1 = untracked, ignored)
|
||
// emb : this frame's raw embedding
|
||
// best_actor : actor with the highest gallery similarity for this face
|
||
// best_gal_sim : that similarity (best sim to best_actor's baked+annex refs)
|
||
// accepted : true if the matcher accepted this face as best_actor
|
||
// crop : aligned crop, retained only when debug dumping is on
|
||
void observe(int track_id, const Embedding& emb,
|
||
int best_actor, float best_gal_sim, bool accepted,
|
||
const cv::Mat& crop)
|
||
{
|
||
if (!enabled_ || track_id < 0) return;
|
||
|
||
TrackState& ts = tracks_[track_id];
|
||
|
||
// Vote toward ownership: only accepted frames name an actor, and a track
|
||
// that flip-flops between actors is ambiguous, so we tally per actor and
|
||
// pick the plurality winner at confirmation time.
|
||
if (accepted && best_actor >= 0) {
|
||
ts.actor_votes[best_actor]++;
|
||
ts.accepted_frames++;
|
||
}
|
||
|
||
insert_into_buffer(ts, emb, best_gal_sim, crop);
|
||
|
||
// Confirm and promote as soon as the anchor threshold is met, once.
|
||
if (!ts.promoted && ts.accepted_frames >= min_anchor_frames_)
|
||
promote(track_id, ts);
|
||
}
|
||
|
||
// Drop a track's buffer when the face_tracker expires it or on a scene cut,
|
||
// so stale/cross-cut embeddings can never be promoted later. Called by the
|
||
// matcher when it observes a cut or track disappearance.
|
||
void forget(int track_id) { tracks_.erase(track_id); }
|
||
|
||
/// TRACES: AR-024 | SR-005
|
||
/// Supply the calibration belonging to the active embedder. Without it the
|
||
/// band falls back to treating cosine as probability, which is wrong but
|
||
/// bounded — and the default is loud in the header rather than silent.
|
||
void set_calibration(std::function<float(float)> c) { calibrate_ = std::move(c); }
|
||
void set_band(float lo, float hi) { band_lo_ = lo; band_hi_ = hi; }
|
||
|
||
/// Embeddings the band refused. A store that admits nothing is as wrong as
|
||
/// one that admits everything, and neither is visible without this.
|
||
std::size_t band_rejected() const { return rejected_; }
|
||
|
||
// Drop every track buffer (scene cut / EOF). Mirrors face_tracker's clear.
|
||
void clear_tracks() { tracks_.clear(); }
|
||
|
||
private:
|
||
struct BufEntry {
|
||
Embedding emb;
|
||
float gal_sim{0.f}; // best sim to owning actor's refs when observed
|
||
cv::Mat crop; // populated only when debug_dir_ set
|
||
};
|
||
|
||
struct TrackState {
|
||
std::vector<BufEntry> buf;
|
||
std::map<int, int> actor_votes; // actor_idx → accepted-frame count
|
||
int accepted_frames{0};
|
||
bool promoted{false};
|
||
};
|
||
|
||
/// TRACES: AR-018, AR-024 | SR-005
|
||
/// Banded admission: an embedding joins the store only if its similarity to
|
||
/// something already there falls **inside a band**.
|
||
///
|
||
/// above the upper bound → redundant. It is another look at a pose the
|
||
/// store already covers, and adding it teaches the annex nothing while
|
||
/// costing a slot that a novel view could have used.
|
||
/// below the lower bound → suspect. Within one track every face is the
|
||
/// same person by construction, so an embedding unlike everything else
|
||
/// on the track is evidence the construction failed — a track-ID
|
||
/// collision or a bad detection. Admitting it is how an actor's annex
|
||
/// gets poisoned with someone else's face.
|
||
///
|
||
/// Both bounds are calibrated probabilities, never raw cosines (AR-024): a
|
||
/// bare similarity threshold means something different for every model and
|
||
/// every face size, and this gate has to hold across both.
|
||
///
|
||
/// The first embedding is always admitted — there is nothing for it to be
|
||
/// redundant with, and nothing to contradict it.
|
||
bool admit(const TrackState& ts, const Embedding& emb) const {
|
||
if (ts.buf.empty()) return true;
|
||
|
||
float p_max = 0.f;
|
||
for (const auto& b : ts.buf)
|
||
p_max = std::max(p_max, calibrate_(cosine_similarity(b.emb, emb)));
|
||
|
||
return p_max >= band_lo_ && p_max <= band_hi_;
|
||
}
|
||
|
||
void insert_into_buffer(TrackState& ts, const Embedding& emb,
|
||
float gal_sim, const cv::Mat& crop)
|
||
{
|
||
if (!admit(ts, emb)) { ++rejected_; return; }
|
||
|
||
BufEntry e;
|
||
e.emb = emb;
|
||
e.gal_sim = gal_sim;
|
||
if (!debug_dir_.empty() && !crop.empty()) e.crop = crop.clone();
|
||
|
||
if (static_cast<int>(ts.buf.size()) < buffer_size_) {
|
||
ts.buf.push_back(std::move(e));
|
||
return;
|
||
}
|
||
|
||
// Buffer full: evict the member the gallery recognises best (highest
|
||
// gal_sim) — least informative — but only if the newcomer is at least as
|
||
// novel. Keeping the most gallery-far views is the whole point.
|
||
int worst_i = -1;
|
||
float worst_sim = e.gal_sim; // newcomer's sim is the bar to beat
|
||
for (int i = 0; i < static_cast<int>(ts.buf.size()); ++i) {
|
||
if (ts.buf[i].gal_sim > worst_sim) {
|
||
worst_sim = ts.buf[i].gal_sim;
|
||
worst_i = i;
|
||
}
|
||
}
|
||
// worst_i == −1 → every buffered view is already more novel than the
|
||
// newcomer; drop the newcomer instead of a better sample.
|
||
if (worst_i >= 0) ts.buf[worst_i] = std::move(e);
|
||
}
|
||
|
||
void promote(int track_id, TrackState& ts) {
|
||
ts.promoted = true; // idempotent: never promote a track twice
|
||
|
||
int actor = plurality_actor(ts);
|
||
if (actor < 0) return;
|
||
|
||
// ── Safety gate: internal spread ─────────────────────────────────────
|
||
// A legitimate single-person track varies in pose but stays reasonably
|
||
// self-similar. Large spread signals two people merged under one track
|
||
// ID — reject the whole track rather than poison the actor's annex.
|
||
float spread = buffer_spread(ts.buf);
|
||
if (spread > spread_max_) {
|
||
std::cerr << "[track_gallery] track " << track_id
|
||
<< " → actor " << actor
|
||
<< " REJECTED (spread " << spread
|
||
<< " > " << spread_max_ << ", likely ID collision)\n";
|
||
return;
|
||
}
|
||
|
||
int added = 0;
|
||
for (const auto& be : ts.buf) {
|
||
// ── Safety gate: novelty ─────────────────────────────────────────
|
||
// Skip poses the gallery already covers; only gallery-far views are
|
||
// worth the annex slot (and the extra per-frame scan cost).
|
||
if (be.gal_sim >= novelty_sim_) continue;
|
||
annex_.push_back({be.emb, actor});
|
||
if (!debug_dir_.empty() && !be.crop.empty())
|
||
dump_mugshot(track_id, actor, added, be);
|
||
++added;
|
||
}
|
||
|
||
std::cerr << "[track_gallery] track " << track_id
|
||
<< " confirmed actor " << actor
|
||
<< " (" << ts.accepted_frames << " accepted frames, spread "
|
||
<< spread << ") — promoted " << added << "/"
|
||
<< ts.buf.size() << " views; annex now "
|
||
<< annex_.size() << "\n";
|
||
}
|
||
|
||
static int plurality_actor(const TrackState& ts) {
|
||
int best = -1, best_votes = 0;
|
||
for (const auto& [ai, v] : ts.actor_votes) {
|
||
if (v > best_votes) { best_votes = v; best = ai; }
|
||
}
|
||
return best;
|
||
}
|
||
|
||
// Spread = 1 − min pairwise cosine similarity over the buffer (0 when <2).
|
||
static float buffer_spread(const std::vector<BufEntry>& buf) {
|
||
float min_sim = std::numeric_limits<float>::max();
|
||
for (size_t i = 0; i < buf.size(); ++i)
|
||
for (size_t j = i + 1; j < buf.size(); ++j)
|
||
min_sim = std::min(min_sim, cosine_similarity(buf[i].emb, buf[j].emb));
|
||
if (min_sim == std::numeric_limits<float>::max()) return 0.f;
|
||
return 1.f - min_sim;
|
||
}
|
||
|
||
void dump_mugshot(int track_id, int actor, int idx, const BufEntry& be) {
|
||
#ifdef SAE_DEBUG
|
||
char name[64];
|
||
std::snprintf(name, sizeof(name), "trk%d_actor%d_%d_sim%.3f.jpg",
|
||
track_id, actor, idx, be.gal_sim);
|
||
cv::imwrite((std::filesystem::path(debug_dir_) / name).string(), be.crop);
|
||
#else
|
||
(void)track_id; (void)actor; (void)idx; (void)be;
|
||
#endif
|
||
}
|
||
|
||
/// cosine → P(same person). The one probability space the pipeline reasons
|
||
/// in; see gallery_calibration.hpp's same_person_probability.
|
||
std::function<float(float)> calibrate_{[](float c) { return std::max(0.f, c); }};
|
||
float band_lo_{0.90f};
|
||
float band_hi_{0.95f};
|
||
std::size_t rejected_{0}; ///< admissions refused by the band
|
||
|
||
bool enabled_;
|
||
int buffer_size_;
|
||
float novelty_sim_;
|
||
float spread_max_;
|
||
int min_anchor_frames_;
|
||
std::string debug_dir_;
|
||
|
||
std::map<int, TrackState> tracks_;
|
||
std::vector<AnnexEntry> annex_;
|
||
};
|