Files
scene-actor-extraction/src/types.hpp
T
Claude 7db40f430d GR-004: bind galleries to the embedder that built them
A gallery is only valid for the embedder that produced its vectors. Cosine
similarities across models are meaningless but *look* plausible, so the mistake
is silent and every measurement taken afterwards is suspect. Stamp the embedder
identity into the gallery at build; verify it at every load.

The stamp is the model file's basename plus the SHA-256 of its bytes (plus
embed_dim). The hash decides, the name explains. A name alone is a promise
rather than a fact — models get re-exported and overwritten in place under an
unchanged filename, which is exactly the case where the weights differ and
nothing else does. A hash alone is correct but unactionable in an error message.
SHA-256 is derived from the artefact, needs no registry kept current, and costs
~0.1s for a 250MB ONNX, memoised per process.

Mismatch is a hard error in every mode, with no bypass, naming both sides.

Unstamped legacy galleries warn loudly and proceed: unknown is not known-bad,
and hard-failing every pre-existing gallery would turn the check into something
people disable rather than trust. --require-gallery-stamp (or
SAE_REQUIRE_GALLERY_STAMP=1, which propagates to subprocesses) promotes that to
a hard error — the mode measurement work should run in. scripts/stamp_gallery.py
re-binds an existing gallery with no re-embedding, so "warn" is a cheap state to
leave rather than a permanent one.

Embedding dumps carry the same stamp: a replay has no live embedder, so the dump
is the embedder as far as the gallery is concerned. Derived galleries inherit
their source's stamp; --merge and the JSON gallery merge check before writing,
since one file holding two embedding spaces cannot be untangled afterwards.

Verified in: scene_analyze, scene_preview, the sae_kpn matcher binding,
replay.py, optimize.py (once per film at startup, before the first evaluation),
movienet_eval.py and both merge paths.

Stamp logic lives in src/gallery/embedder_stamp.{hpp,cpp} and its Python twin
scripts/sae_stamp.py, kept dependency-light so replay subprocesses do not pay
sae_gallery's requests/Pillow import to ask whether two models match.

Tests: 12 new cases in test_gallery_store.cpp covering the comparison logic,
both round trips, and the SHA-256 vectors that guarantee the C++ and hashlib
stamps agree. No ONNX or GPU required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:35:46 +02:00

155 lines
7.2 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 <array>
#include <cstdint>
#include <string>
#include <vector>
#include <opencv2/core.hpp>
#include "gallery/embedder_stamp.hpp"
// ── Embedding ─────────────────────────────────────────────────────────────────
// 512-dim L2-normalised ArcFace embedding
using Embedding = std::array<float, 512>;
inline float cosine_similarity(const Embedding& a, const Embedding& b) {
float dot = 0.f;
for (int i = 0; i < 512; ++i) dot += a[i] * b[i];
return dot;
}
// ── Frame ─────────────────────────────────────────────────────────────────────
// Raw sampled frame from the movie. eof=true is the pipeline shutdown sentinel:
// every node must forward it immediately without processing.
struct Frame {
cv::Mat image;
double timestamp_sec{0.0};
int64_t frame_idx{-1};
bool eof{false};
bool is_cut{false}; // histogram: intra-scene camera-angle change (tracker reset)
bool is_scene_boundary{false}; // TransNetV2: true shot/scene boundary (opt-in)
float cut_score{0.f}; // histogram cut score = 1 - hist_corr (0=identical, ~1=cut); HUD/debug
float bbox_upscale{1.f}; // multiply detector bboxes/landmarks by this to map back to
// original video resolution (>1 when dense_scale downscaled the frame)
};
// ── CutEvent ──────────────────────────────────────────────────────────────────
// Emitted by SceneDetectorFunc when TransNetV2 localises a shot boundary, keyed
// by the boundary frame's timestamp. eof=true is the shutdown sentinel.
struct CutEvent {
double timestamp_sec{0.0};
float probability{0.f}; // sigmoid boundary score at the peak
bool eof{false};
};
// ── ArcFace alignment ─────────────────────────────────────────────────────────
// Canonical 5-point target positions for a 112×112 ArcFace crop.
// Landmark order: right-eye, left-eye, nose, right-mouth, left-mouth
// (matches SCRFD output order — no reordering needed).
inline constexpr float kArcFaceRef[5][2] = {
{38.2946f, 51.6963f},
{73.5318f, 51.5014f},
{56.0252f, 71.7366f},
{41.5493f, 92.3655f},
{70.7299f, 92.2041f},
};
// ── DetectedFace ──────────────────────────────────────────────────────────────
// One face found by SCRFD in a Frame.
// Landmark order matches ArcFace convention (same as SCRFD output order):
// [0] right-eye-centre [1] left-eye-centre [2] nose
// [3] right-mouth [4] left-mouth
struct DetectedFace {
cv::Rect2f bbox;
std::array<cv::Point2f, 5> landmarks;
float confidence{0.f};
};
// ── Pipeline messages ─────────────────────────────────────────────────────────
struct SceneFrame {
Frame source;
std::vector<DetectedFace> faces; // empty when no faces detected (or eof)
};
struct AlignedSceneFrame {
Frame source;
std::vector<DetectedFace> faces;
std::vector<cv::Mat> crops; // 112×112 BGR, ArcFace-ready; parallel to faces
};
struct EmbeddedSceneFrame {
Frame source;
std::vector<DetectedFace> faces;
std::vector<cv::Mat> crops; // forwarded for debug rendering downstream
std::vector<Embedding> embeddings;
};
// ── Face tracking ─────────────────────────────────────────────────────────────
// Output of FaceTrackerFunc — EmbeddedSceneFrame augmented with per-detection
// track context.
struct TrackedSceneFrame {
Frame source;
std::vector<DetectedFace> faces;
std::vector<cv::Mat> crops;
std::vector<int> track_ids; // -1 = brand-new track this frame
std::vector<Embedding> embeddings; // per-frame raw (from embedder)
};
// ── Identity matching ─────────────────────────────────────────────────────────
struct IdentifiedActor {
int actor_idx{-1}; // index into ActorGallery::actors; -1 = unknown
int track_id{-1}; // face track ID from FaceTrackerFunc
std::string name;
std::string imdb_id;
std::string tmdb_id;
std::string jellyfin_id; // Jellyfin Person item GUID, if gallery was built from Jellyfin
float similarity{0.f}; // calibrated P(match) or cosine similarity; 0 for unknowns
cv::Rect2f bbox;
cv::Mat crop; // 112×112 aligned crop (stored as shared_ptr by KPN)
};
struct MatchedSceneFrame {
Frame source;
std::vector<IdentifiedActor> actors; // includes unknowns (actor_idx == -1)
};
// ── Scene annotation ──────────────────────────────────────────────────────────
// Output of the scene tracker: one per sampled frame.
// visible_actors contains all actors still within their extinction window.
struct SceneAnnotation {
double timestamp_sec{0.0};
std::vector<IdentifiedActor> visible_actors;
bool eof{false};
};
// ── Actor gallery ─────────────────────────────────────────────────────────────
// Loaded once at startup; baked into the identity matcher.
struct ActorGallery {
struct Actor {
std::string imdb_id;
std::string tmdb_id;
std::string jellyfin_id; // Jellyfin Person item GUID, if known
std::string name;
std::vector<Embedding> embeddings; // one per reference image
std::vector<std::string> source_images;
};
std::vector<Actor> actors;
/// TRACES: GR-004 | SR-001
// Which embedder produced every embedding above. Empty == the file predates
// model binding; see gallery/embedder_stamp.hpp for what is checked and why.
EmbedderStamp embedder;
// Cached Platt-sigmoid calibration (see gallery/gallery_calibration.hpp),
// stored alongside the gallery in HDF5 so it never needs recomputing
// unless the reference embeddings actually change. calib_valid=false and
// calib_hash=0 means "not present in this file, compute it."
float calib_a{10.f};
float calib_b{-5.f};
bool calib_valid{false};
uint64_t calib_hash{0};
};