Files
scene-actor-extraction/src/gallery/track_gallery.hpp
T
dtourolleandClaude Opus 5 c843c4abe3 feat: banded admission for the per-subject embedding store
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
2026-07-31 15:10:31 +02:00

295 lines
13 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#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_;
};