Files
dtourolle f891e579c5 chore(traces): put TRACES tags on their own line; regenerate the report
The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 —
prose` swallowed the prose into the tag and the row went unmatched. Splitting
the comment leaves the tag greppable by the same pattern as the code tags and
the commit trailers, which is the point of the house format.

Mechanical throughout; no logic touched. The regenerated report reflects this
session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which
is the SuperHero accuracy assertion that is documented but not yet a test.
2026-08-04 14:04:21 +02:00

217 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()