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>
155 lines
7.2 KiB
C++
155 lines
7.2 KiB
C++
#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};
|
||
};
|