Files
scene-actor-extraction/scripts/sae_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

234 lines
10 KiB
Python

"""Shared image-download and gallery-writing helpers.
Consolidates the three near-identical download loops (make_gallery.download_images,
make_jellyfin_gallery.download_urls + download_person_images) and the duplicated
"write gallery.h5 + .missing_images.json" tail from both builders.
Galleries are written directly as HDF5 — never JSON. Same layout the C++ side
reads/writes (src/gallery/gallery_store.cpp): flat [N,512] embeddings + per-actor
offset/count, parallel imdb_id/tmdb_id/jellyfin_id/name string arrays, a
per-embedding-row source_images array, and an /embedder group carrying the
GR-004 model binding. calibration is left absent (calib_hash=0); the C++
identity_matcher fits and writes it back into the file on first use.
The GR-004 embedder stamp written into that /embedder group lives in sae_stamp
and is re-exported below, so existing callers keep importing it from here.
"""
import io
import json
import sys
from pathlib import Path
import h5py
import numpy as np
import requests
from PIL import Image, UnidentifiedImageError
# Errors that mean "this one image couldn't be fetched/decoded" rather than a bug:
# network/HTTP failures, filesystem errors, and undecodable image bytes. We catch
# exactly these around a single download so a coding error isn't swallowed as a
# spurious "download failed".
DOWNLOAD_ERRORS = (requests.RequestException, OSError, UnidentifiedImageError)
# Wikidata SPARQL: map an IMDB person id (P345) to their image (P18) on
# Wikimedia Commons. Used as a free, CC-licensed headshot fallback when a
# provider (TMDB/Jellyfin) has no usable image. Wikidata requires a
# descriptive User-Agent.
WIKIDATA_SPARQL = "https://query.wikidata.org/sparql"
WIKIDATA_HEADERS = {
"Accept": "application/sparql-results+json",
"User-Agent": "scene-actor-extraction/1.0 (https://github.com/; gallery builder)",
}
def wikidata_image_urls(imdb_person_id: str) -> list[str]:
"""Return Commons image URL(s) for a person via their IMDB id (P345 -> P18)."""
if not imdb_person_id:
return []
query = (
"SELECT ?image WHERE { "
f'?person wdt:P345 "{imdb_person_id}" . '
"?person wdt:P18 ?image . "
"}"
)
try:
r = requests.get(WIKIDATA_SPARQL, params={"query": query, "format": "json"},
headers=WIKIDATA_HEADERS, timeout=15)
r.raise_for_status()
bindings = r.json().get("results", {}).get("bindings", [])
return [b["image"]["value"] for b in bindings if "image" in b]
except (requests.RequestException, ValueError) as e:
print(f" [warn] wikidata lookup failed for {imdb_person_id}: {e}", file=sys.stderr)
return []
def download_image(url: str, out_path: Path, *,
headers: dict | None = None,
params: dict | None = None) -> Path | None:
"""Download one image to out_path as JPEG, returning the path or None on failure.
Skips the download if out_path already exists and is larger than 1 KiB
(a previously cached, non-truncated image).
"""
if out_path.exists() and out_path.stat().st_size > 1024:
return out_path
try:
r = requests.get(url, headers=headers, params=params, timeout=15)
r.raise_for_status()
ctype = r.headers.get("Content-Type", "")
if ctype and not ctype.startswith("image/"):
print(f" [warn] unexpected response for {url}: "
f"status={r.status_code} content-type={ctype!r} len={len(r.content)}",
file=sys.stderr)
return None
out_path.parent.mkdir(parents=True, exist_ok=True)
Image.open(io.BytesIO(r.content)).convert("RGB").save(out_path, "JPEG")
return out_path
except DOWNLOAD_ERRORS as e:
print(f" [warn] image download failed: {url}: {e}", file=sys.stderr)
return None
def download_images(urls: list[str], dest_dir: Path, n: int,
start_index: int = 0, *,
headers: dict | None = None,
params: dict | None = None) -> list[Path]:
"""Download up to n images from urls into dest_dir, numbered from start_index."""
dest_dir.mkdir(parents=True, exist_ok=True)
paths = []
for i, url in enumerate(urls[:n]):
out = dest_dir / f"{start_index + i:02d}.jpg"
path = download_image(url, out, headers=headers, params=params)
if path is not None:
paths.append(path)
return paths
# ── GR-004: gallery ↔ embedder binding ───────────────────────────────────────
# Implemented in sae_stamp (kept dependency-light so the optimizer's replay
# subprocesses can import it without pulling requests/Pillow); re-exported here
# because the gallery writers and every existing caller reach for it via this
# module. See src/gallery/embedder_stamp.hpp for the C++ twin and the rationale.
from sae_stamp import ( # noqa: F401
EmbedderMismatch,
check_embedder_stamp,
describe_stamp,
embedder_stamp,
enforce_embedder_stamp,
read_gallery_stamp,
require_gallery_stamp_from_env,
sha256_file,
verify_gallery_stamp,
_as_str,
_stamp_empty,
)
def save_gallery_hdf5(gallery: dict, output: Path, embedder: dict | None = None) -> None:
"""Write a gallery dict ({"actors": [...]}) directly as HDF5 — same schema
src/gallery/gallery_store.cpp reads/writes. No calibration group; the
C++ identity_matcher computes and writes it back into this file on first
use against an unseen set of embeddings.
`embedder` is the GR-004 stamp (see embedder_stamp()); it may also be carried
on the gallery dict under "embedder", which is how a filtered/derived gallery
keeps its binding without the caller having to re-hash anything."""
embedder = embedder if embedder is not None else gallery.get("embedder")
actors = gallery["actors"]
embs, offsets, counts = [], [], []
imdb, tmdb, jf, name, src_images = [], [], [], [], []
row = 0
for a in actors:
e = a.get("embeddings", [])
offsets.append(row)
counts.append(len(e))
row += len(e)
embs.extend(e)
si = a.get("source_images", [])
for i in range(len(e)):
src_images.append(si[i] if i < len(si) else "")
imdb.append(a.get("imdb_id", "") or "")
tmdb.append(str(a.get("tmdb_id", "") or ""))
jf.append(a.get("jellyfin_id", a.get("jellyfin_person_id", "")) or "")
name.append(a.get("name", "") or "")
emb_arr = np.asarray(embs, dtype=np.float32) if embs else np.zeros((0, 512), np.float32)
if emb_arr.ndim == 1:
emb_arr = emb_arr.reshape(0, 512)
output.parent.mkdir(parents=True, exist_ok=True)
str_t = h5py.string_dtype("utf-8")
with h5py.File(output, "w") as f:
f.create_dataset("embeddings", data=emb_arr)
f.create_dataset("offset", data=np.asarray(offsets, np.int64))
f.create_dataset("count", data=np.asarray(counts, np.int32))
f.create_dataset("imdb_id", data=np.asarray(imdb, dtype=object), dtype=str_t)
f.create_dataset("tmdb_id", data=np.asarray(tmdb, dtype=object), dtype=str_t)
f.create_dataset("jellyfin_id", data=np.asarray(jf, dtype=object), dtype=str_t)
f.create_dataset("name", data=np.asarray(name, dtype=object), dtype=str_t)
f.create_dataset("source_images", data=np.asarray(src_images, dtype=object), dtype=str_t)
# TRACES: GR-004 | SR-001 — omitted entirely when unknown, so "unstamped"
# round-trips as unstamped rather than as a stamp naming no model.
if not _stamp_empty(embedder):
g = f.create_group("embedder")
g.attrs["model_name"] = embedder.get("model_name", "")
g.attrs["model_sha256"] = embedder.get("model_sha256", "")
g.attrs["embed_dim"] = np.int32(embedder.get("embed_dim", 512))
stamp_note = (f", embedder {embedder['model_name']}" if not _stamp_empty(embedder)
else ", NO EMBEDDER STAMP (GR-004)")
print(f"Saved: {output} ({len(actors)} actors, {emb_arr.shape[0]} embeddings"
f"{stamp_note})", file=sys.stderr)
def load_gallery_hdf5(path: Path) -> dict:
"""Read an HDF5 gallery back into the same {"actors": [...]} dict shape the
builders work with in memory (for --merge). Mirrors save_gallery_hdf5."""
with h5py.File(path, "r") as f:
emb = f["embeddings"][:]
offset = f["offset"][:]
count = f["count"][:]
imdb = [s.decode() if isinstance(s, bytes) else s for s in f["imdb_id"][:]]
tmdb = [s.decode() if isinstance(s, bytes) else s for s in f["tmdb_id"][:]]
jf = [s.decode() if isinstance(s, bytes) else s for s in f["jellyfin_id"][:]]
name = [s.decode() if isinstance(s, bytes) else s for s in f["name"][:]]
src_images = None
if "source_images" in f:
src_images = [s.decode() if isinstance(s, bytes) else s
for s in f["source_images"][:]]
# TRACES: GR-004 | SR-001 — carried through so a derived gallery (filter,
# merge, cast-restrict) keeps the binding of the gallery it came from.
stamp = None
if "embedder" in f:
a = f["embedder"].attrs
stamp = {"model_name": _as_str(a.get("model_name", "")),
"model_sha256": _as_str(a.get("model_sha256", "")),
"embed_dim": int(a.get("embed_dim", 512))}
actors = []
for a in range(len(offset)):
s, n = int(offset[a]), int(count[a])
actor = {"imdb_id": imdb[a], "tmdb_id": tmdb[a], "jellyfin_id": jf[a],
"name": name[a], "embeddings": [emb[s + i].tolist() for i in range(n)]}
if src_images is not None:
actor["source_images"] = [src_images[s + i] for i in range(n)]
actors.append(actor)
out = {"actors": actors}
if stamp is not None:
out["embedder"] = stamp
return out
def save_gallery(gallery: dict, missing: list[dict], output: Path,
embedder: dict | None = None) -> None:
"""Write the gallery as HDF5 (forcing a .h5 extension) and, if any actors
lack images, a .missing_images.json sidecar."""
if output.suffix not in (".h5", ".hdf5"):
output = output.with_suffix(".h5")
save_gallery_hdf5(gallery, output, embedder)
if missing:
missing_path = output.with_name(output.stem + ".missing_images.json")
missing_path.write_text(json.dumps(missing, indent=2) + "\n")
print(f"{len(missing)} actor(s) need images — see {missing_path}", file=sys.stderr)