AR-018 was marked Done while the promotion path still ran on the
constants it was meant to replace. track_gallery.hpp rejected a track
when buffer_spread (1 minus the minimum pairwise cosine) exceeded
expand_track_spread_max, and skipped a view when its raw gal_sim cleared
expand_novelty_sim. Both were bare cosines with no recorded EXCEPTION,
so both were defects under the AR-024 invariant rather than tagging gaps.
The calibrated band was real but unreachable. expand_band_lo/hi were
declared in Config and read nowhere, and set_band() had no callers, so
the gate always ran at the hardcoded 0.90/0.95 while --expand-novelty-sim
and --expand-spread-max stayed live flags.
The spread gate becomes store_coherence: the band's lower bound asked of
every pair in the store, in probability space, rather than a second
constant. admit() compares a newcomer only against its nearest existing
member, so a gradually drifting track chains A to B to C with every step
inside the band while A and C are strangers — the shape a track-ID
collision takes over a slow pan. The bound is re-asked pairwise before
anything reaches an actor's annex.
The novelty gate is deleted rather than converted. SPEC section AR-018
contrasts the band with expand_novelty_sim as the thing it replaces, and
AR-019 requires only that the band is satisfied. Novelty-seeking now
lives entirely in the eviction ordering, which ranks by similarity to the
actor's references instead of cutting at a constant, so there is nothing
left to tune but the two bounds.
BufEntry stored a raw cosine and the eviction loop compared two of them.
The map is monotonic so the ranking was never wrong, but it left a bare
cosine as a decision variable; it now stores the calibrated probability.
The [AR-018] Catch2 tag previously sat on the spread gate, reporting the
replaced mechanism as verification of its replacement. It now sits on the
band: both bounds asserted exactly, since they are inclusive and an
off-by-one there is invisible anywhere else; refusal counted on each
side; and the config bounds driven away from the shipped defaults so a
hardcoded fallback fails. The case that carries the invariant is "band
thresholds probability, not cosine" — under a calibration shifted by
0.10, cosine 0.84 is admitted and cosine 0.92 refused, the opposite of
their raw verdicts. A raw-cosine gate passes an identity-calibrated test
by accident and cannot pass that one. 15 cases, 38 assertions, passing.
scene_preview.cpp takes the flag rename because it would otherwise
reference deleted Config fields. It still does not compile, for reasons
predating this change: it also reads track_max_embed_dist and
track_max_frames_missing, retired by the earlier AR-024 tracker work, and
constructs FaceTrackerFunc with one argument where the registry and
calibration are now required.
Two notes for anyone reading the chain. The main.cpp flag rename and the
AR-018/AR-024 register rows landed in 35e7033, whose trailer names AR-004
only, so git log --grep=AR-018 will not surface them. And
docs/traceability.md is left uncommitted on purpose: regenerating it now
would bake in VR-013 rows for two experiment scripts that are not yet
committed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TRACES: AR-018, AR-024 | SR-005
179 lines
12 KiB
C++
179 lines
12 KiB
C++
#pragma once
|
||
#include "inference/backend_config.hpp"
|
||
#include <string>
|
||
|
||
inline const std::string kDefaultDetectorModel = std::string(SAE_MODELS_DIR) + "/scrfd_500m_bnkps.onnx";
|
||
inline const std::string kDefaultArcfaceModel = std::string(SAE_MODELS_DIR) + "/LVFace-B_Glint360K.onnx";
|
||
inline const std::string kDefaultSceneModel = std::string(SAE_MODELS_DIR) + "/transnetv2.onnx";
|
||
|
||
enum class Verbosity {
|
||
minimal, // actor names + merged time windows only
|
||
standard, // per-frame detail: bbox, similarity, unknowns logged
|
||
xray, // Jellyfin-Xray format: {"second": ["Actor", ...], ...}
|
||
};
|
||
// debug verbosity = compile with -DSAE_DEBUG → scene_analyze_debug binary
|
||
|
||
struct Config {
|
||
// ── Input ─────────────────────────────────────────────────────────────────
|
||
std::string movie_path;
|
||
std::string gallery_path;
|
||
// TRACES: IR-002 | SR-003
|
||
// "global" (matched against the whole library) or "limited" (this title's
|
||
// credited cast only). The strongest single quality signal when two
|
||
// manifests compete for the same cut: identical gallery_size can mean very
|
||
// different recall depending on which was used.
|
||
std::string gallery_scope{"global"}; // gallery.json produced by build_gallery
|
||
|
||
// ── Output ───────────────────────────────────────────────────────────────
|
||
std::string output_path; // annotations.json
|
||
Verbosity verbosity{Verbosity::minimal};
|
||
|
||
// When set, tee the embedder output to an HDF5 dump (schema:
|
||
// scripts/optimizer/SCHEMA.md) for offline threshold-sweep replay via sae_kpn.
|
||
std::string dump_embeddings_path;
|
||
|
||
// ── Sampling ─────────────────────────────────────────────────────────────
|
||
float sample_fps{1.0f}; // frames to analyse per second of movie
|
||
float max_decode_fps{0.f}; // wall-clock cap on source decode rate (0 = uncapped)
|
||
double start_sec{0.0}; // seek to this timestamp before sampling
|
||
double end_sec{-1.0}; // stop at this timestamp (-1 = end of file)
|
||
|
||
// ── Detection (SCRFD-500MF via cv::dnn::Net) ──────────────────────────────
|
||
std::string detector_model;
|
||
std::string detector_engine; // optional path to pre-built TRT engine; bypasses ORT
|
||
// TRACES: AR-003 | SR-002
|
||
// 0 = no cap, the default. A fixed cap discards the SMALLEST faces first,
|
||
// which are exactly the background cast X-Ray still credits with scene
|
||
// membership. Per-frame cost is contained by backpressure (AR-004) rather
|
||
// than by throwing work away. Set >0 only to bound a pathological source.
|
||
int max_faces{0};
|
||
float min_face_px{40.f}; // discard detections narrower or shorter than this
|
||
float detector_conf{0.5f};
|
||
float detector_nms{0.4f};
|
||
|
||
/// TRACES: GR-004 | SR-001
|
||
// Gallery ↔ embedder binding. A gallery built with a different model than the
|
||
// one loaded here is a hard error, always. This flag additionally promotes
|
||
// "cannot prove they match" (unstamped legacy gallery, or a name-only match
|
||
// because the ONNX could not be hashed) from a loud warning to a hard error.
|
||
// Also settable via SAE_REQUIRE_GALLERY_STAMP=1. Measurement runs want it on.
|
||
bool require_gallery_stamp{false}; // --require-gallery-stamp
|
||
|
||
// ── Recognition (ArcFace ONNX) ────────────────────────────────────────────
|
||
std::string arcface_model;
|
||
std::string arcface_engine; // optional path to a pre-built TRT engine; bypasses ORT
|
||
int embed_batch_size{4}; // max faces per ORT Run() call — bounds per-call latency
|
||
float match_prior{0.5f}; // base-rate prior; 0.5 = use calibrated sigmoid directly
|
||
// prob_threshold tuned by Differential Evolution against Amazon X-Ray per-scene
|
||
// presence over 4 films, per-second metric (see docs/rep4-optimizer-results.md).
|
||
// Best model+mode: LVFace-B_Glint360K, full gallery, expansion on. Supersedes the
|
||
// earlier 9-film scene-union-metric tuning (0.76) — that metric is now known to
|
||
// have hidden out-of-cast false positives (see docs/optimizer-experiments.md).
|
||
float prob_threshold{0.754f}; // posterior P(match | sim, prior) threshold
|
||
float match_threshold{0.45f}; // cosine distance hard ceiling fallback (no calibration)
|
||
float match_ratio{0.80f}; // ratio test fallback: accept if best/second < ratio
|
||
float match_ratio_ceil{0.65f}; // ratio test only fires below this absolute distance
|
||
|
||
// ── Cut detection ────────────────────────────────────────────────────────
|
||
float cut_threshold{0.70f}; // grayscale histogram correlation below this → hard cut
|
||
|
||
// ── Scene detection (TransNetV2, opt-in) ─────────────────────────────────
|
||
// When enabled, the source decodes densely (native FPS) and a decimator
|
||
// splits the stream: full-res 1-FPS frames to the face pipeline, and a
|
||
// downscaled dense stream to the TransNetV2 scene detector. Shot boundaries
|
||
// it finds are surfaced as Frame::is_scene_boundary. This is separate from
|
||
// the always-on histogram cut, which flags intra-scene camera-angle changes.
|
||
bool scene_detect{false}; // master switch (--scene-detect)
|
||
std::string scene_model; // TransNetV2 .onnx (default set in main)
|
||
std::string scene_engine; // optional pre-built TRT .engine; bypasses ORT
|
||
float scene_threshold{0.60f}; // sigmoid boundary prob above this → boundary
|
||
// (this export's non-boundary baseline sits
|
||
// at ~0.50; real boundaries spike to ~0.7+)
|
||
int scene_stride{50}; // frames advanced between windows (≤ kWindow)
|
||
|
||
// Dense-decode throughput knobs (only active with scene_detect). Dense decode
|
||
// of every native-rate frame is the pipeline's cost driver; these trade a
|
||
// little boundary precision for a large speedup.
|
||
// scene_decode_fps: rate the source decodes at in dense mode. Lower =
|
||
// fewer frames decoded. TransNetV2 tolerates ~12fps; boundary timestamps
|
||
// stay correct (keyed off each frame's real timestamp). 0 = native fps.
|
||
// dense_scale: downscale factor applied to decoded frames in dense mode
|
||
// (0<f≤1; e.g. 0.5 = half size). Cheaper sws_scale + smaller frames
|
||
// through the fanout. NOTE: also shrinks what the face detector sees —
|
||
// keep ≥0.5 on 1080p sources so SCRFD still resolves small faces. 1 = off.
|
||
float scene_decode_fps{12.0f}; // dense decode rate (0 = native)
|
||
float dense_scale{1.0f}; // dense-mode frame downscale (1 = off)
|
||
|
||
// ── Face tracking (frame-to-frame) ───────────────────────────────────────
|
||
/// TRACES: AR-007, AR-008, AR-024 | SR-002
|
||
// track_alpha is the *base* weight, used on ordinary frames. It is
|
||
// frame-dependent (AR-007): on is_cut / is_scene_boundary, and for any track
|
||
// that is no longer on screen, it drops to 0 (embedding only), because
|
||
// position carries no information across a viewpoint change or a gap.
|
||
float track_alpha{0.4f}; // base cost weight: 0=embedding only, 1=spatial only
|
||
float track_min_iou{0.1f}; // IoU below which spatial link alone is rejected
|
||
// Minimum P(same person) for an association to be admissible on appearance
|
||
// alone. This replaces track_max_embed_dist (a raw cosine distance, AR-024).
|
||
// 0.5 is not a tuned constant: it is the decision boundary. Below it the pair
|
||
// is more likely two people than one, and no amount of IoU makes that a link
|
||
// worth asserting on identity grounds.
|
||
float track_assoc_min_prob{0.5f};
|
||
// How long a track that has gone off screen stays available for association
|
||
// before the registry reaps it and emits its presence claim (AR-013).
|
||
// Replaces track_max_frames_missing: a frame count silently changed meaning
|
||
// with sample_fps, and the same number had to be guessed twice (once for an
|
||
// ordinary miss, once for a cut). Seconds mean one thing at any sample rate.
|
||
double track_extinction_sec{5.0};
|
||
|
||
// ── Scene tracking ────────────────────────────────────────────────────────
|
||
// extinction_sec re-tuned by DE against X-Ray per-second presence, 4-film rep4
|
||
// matrix (docs/rep4-optimizer-results.md). Reverses the earlier "short is better"
|
||
// finding: with a stricter prob_threshold, a long extinction window bridges real
|
||
// presence gaps (occlusion, turned face) instead of just smearing FPs — every
|
||
// model's best config pushed to ~90%+ of the search ceiling (tried up to 60s).
|
||
// The ceiling kept getting hit, so treat 60 as "good enough", not a proven optimum.
|
||
double extinction_sec{57.4}; // keep actor active this many seconds after last detection
|
||
// anneal_sec: previously found INSENSITIVE at a 1–30s range; the wider rep4 sweep
|
||
// (1–60s) also pushed this to the ceiling alongside extinction_sec (see above).
|
||
double anneal_sec{35.5}; // merge actor windows separated by less than this into one epoch
|
||
|
||
// ── Per-film gallery expansion ────────────────────────────────────────────
|
||
// Within one uncut track every face is the same physical person — a free
|
||
// same-identity label the baked gallery lacks. When a track is confidently
|
||
// owned by an actor, its gallery-far (pose-varied) embeddings are validated
|
||
// new reference views; they are promoted into a per-film, in-memory annex so
|
||
// later frames/tracks of that actor at similar poses recognise. See
|
||
// gallery/track_gallery.hpp.
|
||
// Default ON: rep4 matrix (docs/rep4-optimizer-results.md) found expansion helps
|
||
// recall on the full (unrestricted) gallery for the winning model/mode — the
|
||
// opposite of the earlier assumption that it only helps restricted galleries.
|
||
bool expand_gallery{true}; // master switch
|
||
int expand_buffer_size{20}; // per-track diversity buffer capacity
|
||
// TRACES: AR-018, AR-024 | SR-005
|
||
// Banded admission for the per-subject store, in PROBABILITY space. An
|
||
// embedding joins only if P(same person) against something already stored
|
||
// lands inside [lo, hi]: above hi it is redundant, below lo it is evidence
|
||
// the track is not one person. The same lo is re-applied to the whole store
|
||
// at promotion time — see track_gallery.hpp. This is the only threshold the
|
||
// expansion path has: it replaces the raw-cosine expand_novelty_sim (0.55)
|
||
// and expand_track_spread_max (0.60), which are retired (AR-024).
|
||
// Working values pending VR-007; sweep both bounds, they fail in opposite
|
||
// directions.
|
||
float expand_band_lo{0.90f};
|
||
float expand_band_hi{0.95f};
|
||
int expand_min_anchor_frames{3}; // require ≥N accepted frames naming the actor before
|
||
// the track is confirmed and its buffer promoted
|
||
std::string expand_debug_dir; // if set, dump promoted mugshots + embeddings here
|
||
|
||
// ── Inference backend tuning ────────────────────────────────────────────
|
||
// Consumed by the compiled-in inference backend (ORT or TRT).
|
||
// INT8 is unsafe for ArcFace without a calibration table.
|
||
BackendConfig trt{}; // fp16=true, int8=false, cache_dir="./trt_cache"
|
||
|
||
// ── Debug output (only used when SAE_DEBUG is defined) ───────────────────
|
||
#ifdef SAE_DEBUG
|
||
std::string debug_dir{"debug_frames"};
|
||
float crop_context{1.5f}; // bbox expansion factor for context crop
|
||
#endif
|
||
};
|