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

101 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""
reembed_gallery.py — re-embed an existing gallery's actors with a different model.
For the embedding-model bake-off: take a reference gallery (with all actor ids +
source_images) and produce a new gallery where every actor's embeddings are computed
by a DIFFERENT ArcFace/LVFace model from the SAME cached source images. All identity
keys (imdb/tmdb/jellyfin/name) are preserved, so membership/matching is unchanged —
only the embedding vectors (and hence the model's similarity space) differ.
Source images live in `--images <root>/<jellyfin_id>_<Name>/NN.jpg` (the gallery build
cache). Actors are matched to their image dir by jellyfin_id first, then name.
Usage:
python scripts/optimizer/reembed_gallery.py \
--ref gallery_arcface_w600k_r50.h5 \
--images images \
--arcface models/arcface_r18.onnx \
--out experiments/galleries/gallery_arcface_r18.h5 \
[--build-dir build]
"""
from __future__ import annotations
import argparse
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from sae_embed_loader import load_embedder, resolve_arcface # noqa: E402
from sae_gallery import (embedder_stamp, load_gallery_hdf5, # noqa: E402
save_gallery_hdf5)
def find_dir(images_root: Path, jellyfin_id: str, name: str) -> Path | None:
if jellyfin_id:
d = images_root / f"{jellyfin_id}_{name.replace(' ', '_')}"
if d.is_dir():
return d
# jellyfin_id prefix match (name spelling may differ)
hits = list(images_root.glob(f"{jellyfin_id}_*"))
if hits:
return hits[0]
hits = list(images_root.glob(f"*_{name.replace(' ', '_')}"))
return hits[0] if hits else None
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--ref", required=True, help="reference gallery.h5 (ids + source imgs)")
p.add_argument("--images", required=True, help="image cache root")
p.add_argument("--arcface", required=True, help="model ONNX to re-embed with")
p.add_argument("--out", required=True)
p.add_argument("--build-dir", default=str(REPO / "build"))
p.add_argument("--models-dir", default=str(REPO / "models"))
args = p.parse_args()
ref = load_gallery_hdf5(Path(args.ref))
images_root = Path(args.images)
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
# TRACES: GR-004 | SR-001 — this script exists to produce a gallery in a
# DIFFERENT model's space from the reference. The output must therefore never
# inherit the reference's stamp; it carries the stamp of --arcface, which is
# the whole point of the bake-off being safe to run.
stamp = embedder_stamp(resolve_arcface(args.models_dir, args.arcface))
out_actors = []
n_ok = n_nodir = n_noemb = 0
total = len(ref["actors"])
for i, a in enumerate(ref["actors"], 1):
d = find_dir(images_root, a.get("jellyfin_id", ""), a["name"])
if d is None:
n_nodir += 1
continue
embeddings = []
for img in sorted(d.glob("*.jpg")):
res = embedder.embed(str(img))
if res.ok:
embeddings.append(list(res.embedding))
if not embeddings:
n_noemb += 1
continue
out_actors.append({"imdb_id": a.get("imdb_id", ""), "tmdb_id": a.get("tmdb_id", ""),
"jellyfin_id": a.get("jellyfin_id", ""), "name": a["name"],
"embeddings": embeddings,
"source_images": [p.name for p in sorted(d.glob("*.jpg"))]})
n_ok += 1
if i % 200 == 0 or i == total:
print(f" [{i}/{total}] ok={n_ok} no_dir={n_nodir} no_emb={n_noemb}",
file=sys.stderr)
save_gallery_hdf5({"actors": out_actors}, Path(args.out), stamp)
n_emb = sum(len(a["embeddings"]) for a in out_actors)
print(f"[reembed] {Path(args.arcface).stem}: {n_ok}/{total} actors, {n_emb} embeddings "
f"→ {args.out}", file=sys.stderr)
if __name__ == "__main__":
main()