Wire the XGBoost scene-boundary detector into scene_analyze as a post-EOF step in
the result sink (like flood-fill itself — the per-film knee threshold needs the
whole film, so it cannot stream). With --scene-xgb-model set, the camera-position
node stamps a per-frame RGB histogram onto the Frame, it rides through to the
sink, and at EOF the sink runs XGBSceneBoundary over the collected histograms +
the movie's per-second audio log-PSD to produce the flood-fill boundaries. Falls
back to is_scene_boundary / is_cut when no model is configured or inference fails.
Inference is real XGBoost via CMake FetchContent (v2.1.1, static), C API in
src/inference/xgb_scene_boundary.hpp; audio log-PSD in src/inference/
audio_logpsd.hpp (FFTW + ffmpeg full-file 16kHz decode). Feature extraction
matches training exactly — video features verified row-identical to numpy, and to
avoid chasing numpy's every rounding the shipped model is TRAINED on the
C++-extracted features (scene_features_dump exe → train_xgb_cpp.py). The
C++/Python peak-finders differ slightly so boundary counts differ, but what
matters is downstream: flood + C++ detector = 75.8% macro presence F1 vs 64.0%
for the histogram-cut flood and 62.5% for track_extent, and it fixes the Scarface
flood collapse (41 -> 70). All nine films improve.
Guarded by the SAE_SCENE_XGB CMake option (on by default; heavy first build).
xgb_boundary_parity is a diff harness; scene_features_dump writes the C++ feature
matrix so training and inference share one feature implementation.
Verified end to end: scene_analyze --scene-xgb-model on a real movie stamps the
histogram, runs the detector at EOF ("XGBoost scene detector: N boundaries"), and
flood-snaps presence to the learned boundaries.
291 lines
19 KiB
C++
291 lines
19 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", ...], ...}
|
||
};
|
||
|
||
// How a track's accepted frames become a reported presence window.
|
||
enum class PresenceMode {
|
||
// A claim IS its track's [first_seen, last_seen] (AR-012/AR-013). The
|
||
// default and the only mode whose semantics the register validated.
|
||
track_extent,
|
||
// Flood-fill: snap each claim to the shot it sits in, so an actor seen once
|
||
// anywhere in a scene is reported for the whole scene [prev_boundary,
|
||
// next_boundary]. Trades precision for recall against X-Ray's per-scene cast
|
||
// granularity. Snaps to TransNetV2 shot boundaries (is_scene_boundary) when a
|
||
// scene detector populated them, else to the always-on histogram cuts
|
||
// (is_cut). With no boundaries at all it degrades to track_extent per claim.
|
||
flood,
|
||
};
|
||
// 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;
|
||
|
||
/// TRACES: VR-015 | PR-004
|
||
// When set, write a per-node timing and bottleneck report here (src/
|
||
// benchmark.hpp) and print it at shutdown. Costs one background thread
|
||
// reading relaxed atomics on a timer, so it is safe to leave on, but a
|
||
// measurement run should still be isolated (nothing else on the GPU).
|
||
std::string benchmark_path;
|
||
int benchmark_interval_ms{100}; // channel-occupancy sampling period
|
||
|
||
// ── 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.433f}; // base-rate prior; 10-knob DE optimum (was 0.5)
|
||
// Tuned by Differential Evolution against Amazon X-Ray per-second presence
|
||
// over ALL 9 films (opencv5 build, LVFace-B_Glint360K, full gallery,
|
||
// expansion on), a 10-parameter sweep — see docs/model-bakeoff.md. The
|
||
// per-second misID-weighted macro-F1 optimum is 64.0% (P 79.0%, R 61.1%).
|
||
//
|
||
// This is a permissive operating point: the sweep discovered that with
|
||
// flood-fill presence recovering recall, a LOW threshold pays off. It
|
||
// supersedes the earlier 0.754, which came from a 4-film subset under the
|
||
// now-withdrawn anneal/extinction windows and was never re-derived after a
|
||
// scoring-bug fix. The full-9-film sweep at 0.485 beats it.
|
||
//
|
||
// Caveat, still true: the optimum generalises unevenly. It is strong on 7 of
|
||
// 9 films (F1 62–80%) and weak on two — The Many Saints of Newark (an
|
||
// ensemble of look-alikes; nearly all the run's misIDs land here) and
|
||
// Scarface (sparse cuts, so flood-fill over-extends: R 95% / P 26%). Both
|
||
// were the low outliers in every prior run too. Shipped because it wins on
|
||
// average and on the misID-weighted objective; not a settled, film-agnostic
|
||
// constant.
|
||
float prob_threshold{0.485f}; // posterior P(match | sim, prior) threshold
|
||
// TRACES: AR-024 | SR-002
|
||
// match_threshold (0.45), match_ratio (0.80) and match_ratio_ceil (0.65) are
|
||
// RETIRED, joining track_max_embed_dist, cut_revive_sim, expand_novelty_sim
|
||
// and expand_track_spread_max. All were raw cosine distances, and they were
|
||
// the accept rule whenever the calibration fit failed — so the one situation
|
||
// in which the pipeline knew its probabilities were untrustworthy was the
|
||
// one in which it stopped using them. An unfitted sigmoid is now the
|
||
// fallback everywhere, which is at least the same wrong number in every
|
||
// stage. See identity_matcher_node.hpp.
|
||
|
||
// ── Presence derivation ──────────────────────────────────────────────────
|
||
// How accepted frames become a reported window. flood requires scene_detect.
|
||
// Default flood: the 10-knob DE optimum uses it — snapping presence to the
|
||
// shot recovers enough recall against X-Ray's scene-level cast to win the
|
||
// misID-weighted F1, at a precision cost that is a net gain on 7 of 9 films.
|
||
// Falls back to track_extent per claim when no boundaries exist. See
|
||
// docs/model-bakeoff.md and PresenceMode above.
|
||
PresenceMode presence_mode{PresenceMode::flood};
|
||
|
||
// Path to the learned XGBoost scene-boundary model. When set (build has
|
||
// SAE_SCENE_XGB), the camera-position node stamps a per-frame RGB histogram
|
||
// and the sink runs the detector post-EOF to supply flood-fill boundaries —
|
||
// the measured best flood boundary source (presence F1 ~76% vs ~64% for the
|
||
// always-on histogram cut). Empty → flood falls back to is_cut.
|
||
std::string scene_xgb_model;
|
||
|
||
// ── 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 knobs (only active with scene_detect). Dense decode of every
|
||
// native-rate frame is the pipeline's cost driver, which is what made the
|
||
// temporal shortcut below tempting.
|
||
/// TRACES: AR-011 | SR-002
|
||
// scene_decode_fps: rate the source decodes at in dense mode.
|
||
// **0 = native, and native is the only correct setting.** kWindow is 100
|
||
// frames: at native 25 fps that window spans ~4 s, which is what
|
||
// TransNetV2 was trained on; at the 12 fps this used to default to it
|
||
// spans ~8.3 s, so the model saw half-speed motion over twice its
|
||
// temporal context. Boundary *timestamps* stay right either way — which
|
||
// is exactly why the degradation was invisible, and why the compressed
|
||
// separation it produced (~0.50 baseline against ~0.7+ peaks) was read
|
||
// as a property of the export rather than of the input. Lowering this
|
||
// buys decode time by running the model off-distribution; reach for
|
||
// dense_scale or scene_stride instead, which do not.
|
||
// 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. A spatial reduction, and TransNetV2 downsamples to
|
||
// 48×27 regardless, so unlike the above it is a documented, understood
|
||
// degradation. 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{0.f}; // 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.435f}; // base cost weight: 0=embedding only, 1=spatial only (10-knob DE optimum)
|
||
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{31.0}; // 10-knob DE optimum (was 5.0)
|
||
|
||
// ── Ownership and evidence accumulation (AR-025) ──────────────────────────
|
||
// TRACES: AR-025, AR-017 | SR-002
|
||
// These four decided how presence is claimed and were unreachable: they
|
||
// lived as in-class initialisers on TrackRegistry::Config and
|
||
// EvidenceDiscounter::Config, and main constructed the discounter with the
|
||
// one-argument constructor, so nothing short of a recompile could move
|
||
// them. rho_max's own comment defers to "the sweep (VR-007)" for where it
|
||
// belongs — a sweep that could not reach it.
|
||
//
|
||
// ownership_logodds is arguably the most consequential constant in the
|
||
// pipeline after prob_threshold: below it a track produces no presence
|
||
// claim at all, so it decides whether an actor is reported rather than how
|
||
// confidently. 1.72 is a posterior of ~0.85 — the 10-knob DE optimum (was
|
||
// an unswept 2.0 ≈ 0.88); slightly more permissive, consistent with the
|
||
// low-threshold operating point the sweep converged on.
|
||
float ownership_logodds{1.72f};
|
||
|
||
// How much a single observation may move a track's belief. n_eff =
|
||
// n / (1 + (n-1)·rho), so rho_max caps what a repeated view can ever be
|
||
// worth: 0.5 caps it at two independent observations however long the shot
|
||
// runs. It is deliberately below 1 — a held pose still yields a fresh
|
||
// detection, alignment and noise realisation, so a little independent
|
||
// evidence survives. Setting it to 1 freezes belief after the first frame,
|
||
// which is the bug this replaced.
|
||
float evidence_rho_max{0.204f}; // 10-knob DE optimum (was 0.5): weights a
|
||
// held pose closer to a single observation
|
||
// P(same view) below this and the observation counts as a genuinely new
|
||
// look, so it joins the per-track view set.
|
||
float evidence_admit_below{0.784f}; // 10-knob DE optimum (was 0.6)
|
||
// Distinct views remembered per track, which bounds the novelty comparison.
|
||
int evidence_max_views{8};
|
||
|
||
// ── Scene tracking ────────────────────────────────────────────────────────
|
||
// TRACES: AR-012, AR-013 | SR-002
|
||
// extinction_sec (57.4) and anneal_sec (35.5) are GONE, along with
|
||
// SceneTrackerFunc, which is what read the first of them. docs/SPEC.md
|
||
// specified this removal and ended it "grep for both names and expect no
|
||
// survivors"; there were about forty, and the register meanwhile recorded
|
||
// both as Withdrawn and "deleted rather than retained at zero" on the
|
||
// grounds that a field naming a mechanism the pipeline no longer has is
|
||
// actively misleading.
|
||
//
|
||
// Both existed to bridge gaps between isolated accepted frames. A track
|
||
// that survives its own gaps leaves them nothing to do: AR-012 makes a
|
||
// window the extent of a track an actor owns, and AR-013 ends it at the
|
||
// last sighting. The keep-alive answered the same question again and
|
||
// answered it worse, by re-opening exactly the trailing cool-down AR-013
|
||
// refuses.
|
||
//
|
||
// track_extinction_sec above is NOT the same knob under a new name. It
|
||
// bounds how long a lost track stays available for re-association, which is
|
||
// a tracking question; it never extends a presence claim.
|
||
|
||
// ── 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: the rep4 matrix (docs/model-bakeoff.md, "Two effects in
|
||
// isolation") 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. The same section is
|
||
// explicit that on the full gallery it buys +2.1pp F1 and +3.9pp recall
|
||
// "at a real cost" in misIDs, where in restricted mode it is a clean win.
|
||
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).
|
||
// 10-knob DE optimum (was 0.90/0.95). The sweep widened the band — a lower lo
|
||
// admits more pose-varied views into the annex — which the optimum preferred.
|
||
float expand_band_lo{0.804f};
|
||
float expand_band_hi{0.952f};
|
||
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
|
||
};
|