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>
3.3 KiB
Embedding-dump HDF5 schema (v1)
One file per analysed title. Captures the pipeline state at the EmbeddedSceneFrame
channel — i.e. after decode → detect → align → embed, but before tracking and
identity matching. Everything downstream (face tracker, identity matcher, scene
tracker/anneal) is cheap CPU math, so replaying from this file lets a parameter
sweep re-run the whole downstream tail thousands of times with no GPU and no video.
Written by the C++ dump sink (--dump-embeddings out.h5); read by
scripts/optimizer/replay.py.
Layout
The dump is flat/ragged: all faces across all frames are concatenated into per-face arrays, with a per-frame index table pointing into them. This avoids variable-length HDF5 types and reads straight into numpy.
/ (root)
attrs:
schema_version : int = 1
movie : str (source video path)
sample_fps : float
embed_dim : int = 512
embedder_model : str basename of the embedding model (GR-004)
embedder_sha256: str SHA-256 of that model file (GR-004)
frames/ group — one row per sampled frame
timestamp_sec : float64 [F]
frame_idx : int64 [F]
is_cut : uint8 [F] (histogram intra-scene cut)
is_scene_boundary : uint8 [F] (TransNetV2 boundary; 0 if scene_detect off)
face_offset : int64 [F] start index into faces/* for this frame
face_count : int32 [F] number of faces in this frame
faces/ group — one row per detected face, concatenated
embedding : float32 [N, 512] L2-normalised ArcFace embedding
bbox : float32 [N, 4] x, y, w, h in original video pixels
landmarks : float32 [N, 10] 5 (x,y) pairs, SCRFD/ArcFace order
confidence : float32 [N] detector confidence
F = number of sampled frames, N = total faces (= sum of face_count).
Frame i's faces are faces/*[ face_offset[i] : face_offset[i]+face_count[i] ].
Model binding (GR-004)
embedder_model / embedder_sha256 record which embedder produced every vector
in faces/embedding. A replay has no live embedder, so the dump is the embedder
as far as the gallery is concerned: replay.py checks these two attributes
against the gallery's own /embedder stamp and refuses to run on a mismatch,
naming both sides. Cross-model cosines are meaningless but look plausible.
The attributes are additive, not a format break — schema_version stays 1. Dumps
written before GR-004 simply lack them, which reports as unverifiable (a loud
warning, or a hard error under SAE_REQUIRE_GALLERY_STAMP=1) rather than as a
pass. Re-dump to bind an old dump; there is no in-place migration, because unlike
a gallery nobody can assert after the fact which model produced a vector.
Invariants
embeddingrows are unit-norm (cosine == dot product against the gallery).face_offset[0] == 0;face_offset[i+1] == face_offset[i] + face_count[i].bboxis already mapped to original resolution (bbox_upscale applied at dump time), matching what the identity matcher would emit.- A frame with no faces has
face_count == 0(still gets a row, so timestamps stay dense). - EOF sentinel frames are NOT written.