Files
scene-actor-extraction/scripts/optimizer/SCHEMA.md
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

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

  • embedding rows are unit-norm (cosine == dot product against the gallery).
  • face_offset[0] == 0; face_offset[i+1] == face_offset[i] + face_count[i].
  • bbox is 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.