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>
234 lines
10 KiB
Python
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)
|