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

216 lines
8.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""make_gallery.py — fetch actor images for a movie and build gallery.h5.
Fetches the cast from TMDB, downloads actor profile images, embeds them via
the sae_embed module (SCRFD + ArcFace, same models as scene_analyze, loaded
once), then writes gallery.h5.
Requirements:
pip install requests Pillow
Usage:
# By IMDB movie ID (most natural — resolves to TMDB automatically):
python scripts/make_gallery.py \\
--tmdb-key YOUR_KEY \\
--imdb-id tt0137523 \\
--output gallery.h5
# Or directly with a TMDB movie ID:
python scripts/make_gallery.py \\
--tmdb-key YOUR_KEY \\
--movie-id 550 \\
--output gallery.h5
# Additional options:
# --build-dir build/ build dir containing sae_embed module
# --models-dir models/ directory with ONNX models
# --images-per-actor 3 profile images to download per actor
# --image-dir /tmp/gallery_imgs where to cache downloaded images
Get a free TMDB API key at: https://www.themoviedb.org/settings/api
"""
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder, resolve_arcface
from sae_gallery import (download_images, embedder_stamp, save_gallery,
wikidata_image_urls)
from sae_tmdb import TMDB_IMG, tmdb_get, tmdb_id_from_imdb
def fetch_cast(movie_id: int, key: str) -> list[dict]:
"""Return list of {id, name, imdb_id, profile_images: [...url...]}."""
credits = tmdb_get(f"/movie/{movie_id}/credits", key)
cast = credits.get("cast", [])
actors = []
for member in cast:
person_id = member["id"]
# Get IMDB ID for this person
ext = tmdb_get(f"/person/{person_id}/external_ids", key)
imdb_id = ext.get("imdb_id") or ""
# Get profile images (sorted by vote_average desc by TMDB)
images_data = tmdb_get(f"/person/{person_id}/images", key)
profiles = images_data.get("profiles", [])
image_urls = [TMDB_IMG + p["file_path"] for p in profiles if p.get("file_path")]
if not image_urls:
image_urls = wikidata_image_urls(imdb_id)
if image_urls:
print(f" [info] no TMDB images for {member['name']}, "
f"found {len(image_urls)} via Wikidata", file=sys.stderr)
if not image_urls:
print(f" [warn] no images for {member['name']}", file=sys.stderr)
actors.append({
"id": person_id,
"name": member["name"],
"imdb_id": imdb_id,
"tmdb_id": str(person_id),
"profile_images": image_urls,
})
time.sleep(0.05) # be polite to TMDB
return actors
# ── Gallery assembly ─────────────────────────────────────────────────────────
def build_gallery(movie_id: int, key: str, embedder,
images_per_actor: int,
image_root: Path) -> tuple[dict, list[dict]]:
"""Fetch cast, download images, embed, return (gallery dict, actors needing more images)."""
print(f"Fetching cast for TMDB movie {movie_id}…", file=sys.stderr)
actors = fetch_cast(movie_id, key)
print(f"Found {len(actors)} cast member(s)", file=sys.stderr)
gallery_actors = []
missing = []
for actor in actors:
safe_name = actor["name"].replace(" ", "_")
dir_id = actor["imdb_id"] or f"tmdb_{actor['tmdb_id']}"
actor_dir = image_root / f"{dir_id}_{safe_name}"
print(f"\n{actor['name']} ({dir_id})", file=sys.stderr)
if not actor["profile_images"]:
print(" no images found, skipping", file=sys.stderr)
missing.append({"name": actor["name"], "imdb_id": actor["imdb_id"],
"tmdb_id": actor["tmdb_id"], "reason": "no images found"})
continue
image_paths = download_images(actor["profile_images"], actor_dir, images_per_actor)
if not image_paths:
print(" no images downloaded, skipping", file=sys.stderr)
missing.append({"name": actor["name"], "imdb_id": actor["imdb_id"],
"tmdb_id": actor["tmdb_id"], "reason": "download failed"})
continue
print(f" embedding {len(image_paths)} image(s)…", file=sys.stderr)
embeddings = []
source_images = []
for path in image_paths:
res = embedder.embed(str(path))
if not res.ok:
print(f" [skip] {path.name}: {res.error}", file=sys.stderr)
continue
embeddings.append(res.embedding)
source_images.append(path.name)
print(f" [ok] {path.name} conf={res.confidence:.2f}",
file=sys.stderr)
if not embeddings:
print(" no valid embeddings, skipping actor", file=sys.stderr)
missing.append({"name": actor["name"], "imdb_id": actor["imdb_id"],
"tmdb_id": actor["tmdb_id"], "reason": "no valid embeddings"})
continue
gallery_actors.append({
"imdb_id": actor["imdb_id"],
"tmdb_id": actor["tmdb_id"],
"jellyfin_id": "",
"name": actor["name"],
"source_images": source_images,
"embeddings": embeddings,
})
print(f" → {len(embeddings)} embedding(s) stored", file=sys.stderr)
return {"actors": gallery_actors}, missing
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Fetch TMDB cast images and build gallery.h5 via sae_embed")
parser.add_argument("--tmdb-key", required=True,
help="TMDB Bearer token (API Read Access Token from themoviedb.org/settings/api)")
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--imdb-id",
help="IMDB movie ID, e.g. tt0137523 — looked up via TMDB automatically")
group.add_argument("--movie-id", type=int,
help="TMDB movie ID (alternative to --imdb-id)")
parser.add_argument("--output", required=True, help="Output gallery.h5 path")
parser.add_argument("--build-dir", default="build",
help="Build directory containing the sae_embed module (default: build)")
parser.add_argument("--models-dir", default="models",
help="Directory containing ONNX models (default: models/)")
parser.add_argument("--arcface", default=None,
help="Path to ArcFace ONNX model (overrides --models-dir selection)")
parser.add_argument("--images-per-actor",type=int, default=3,
help="Profile images to download per actor (default: 3)")
parser.add_argument("--image-dir", default=None,
help="Where to store downloaded images (default: <output_dir>/images)")
parser.add_argument("--keep-images", action="store_true",
help="Do not delete downloaded images after embedding")
args = parser.parse_args()
# Resolve paths
output = Path(args.output)
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
# TRACES: GR-004 | SR-001 — stamp with the model actually loaded, resolved
# through the same helper load_embedder uses so the two cannot diverge.
arcface_path = resolve_arcface(args.models_dir, args.arcface)
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
stamp = embedder_stamp(arcface_path)
# Resolve movie ID
movie_id = args.movie_id
if movie_id is None:
print(f"Resolving IMDB ID {args.imdb_id} → TMDB…", file=sys.stderr)
movie_id = tmdb_id_from_imdb(args.imdb_id, args.tmdb_key)
print(f"TMDB movie ID: {movie_id}", file=sys.stderr)
# Build gallery
gallery, missing = build_gallery(
movie_id = movie_id,
key = args.tmdb_key,
embedder = embedder,
images_per_actor = args.images_per_actor,
image_root = image_root,
)
n_actors = len(gallery["actors"])
n_embeddings = sum(len(a["embeddings"]) for a in gallery["actors"])
print(f"\nGallery: {n_actors} actors, {n_embeddings} total embeddings",
file=sys.stderr)
if n_actors == 0:
sys.exit("No actors could be processed — check models and images.")
save_gallery(gallery, missing, output, embedder=stamp)
if __name__ == "__main__":
main()