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

168 lines
7.1 KiB
Python

#!/usr/bin/env python3
"""
fetch_missing_actors.py — close the gallery coverage gap.
X-Ray credits ~67% of each film's cast that our gallery never had a reference
embedding for, making those actors unrecoverable FNs no threshold can fix. This
fetches images for those missing actors (by IMDb nm id → TMDB profile photos),
embeds them with the SAME SCRFD+ArcFace models (sae_embed), and writes gallery
entries. Merge the result into the baseline to make those actors recognisable.
nm → TMDB person → /person/{id}/images profile photos → download → embed.
Usage:
python scripts/optimizer/fetch_missing_actors.py \
--missing missing_actors.json \
--out gallery_missing.json \
[--images-per-actor 3] [--build-dir build]
# TMDB_API_KEY from env/.env
Then merge:
python scripts/optimizer/fetch_missing_actors.py --merge \
gallery_arcface_w600k_r50.json gallery_missing.json \
--out gallery_augmented.json
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import tempfile
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
import sae_env # noqa: E402 loads .env
from sae_tmdb import tmdb_get, tmdb_person_for_imdb, TMDB_IMG # noqa: E402
from sae_embed_loader import resolve_arcface # noqa: E402
from sae_gallery import (download_images, embedder_stamp, # noqa: E402
enforce_embedder_stamp, wikidata_image_urls)
from sae_embed_loader import load_embedder # noqa: E402
def profile_urls_for_imdb(imdb_id: str, token: str, n: int) -> tuple[str | None, list[str]]:
"""(tmdb_person_id, [image_url,...]) via /find then /person/{id}/images."""
data = tmdb_get(f"/find/{imdb_id}", token, external_source="imdb_id")
people = data.get("person_results", [])
if not people:
return None, []
pid = str(people[0]["id"])
imgs = tmdb_get(f"/person/{pid}/images", token)
profiles = imgs.get("profiles", [])[:n]
return pid, [TMDB_IMG + p["file_path"] for p in profiles if p.get("file_path")]
def fetch(missing_path, out_path, token, build_dir, models_dir, arcface,
images_per_actor, use_wikidata=False):
missing = json.loads(Path(missing_path).read_text())
src = "TMDB + Wikidata fallback" if use_wikidata else "TMDB"
print(f"[fetch] {len(missing)} missing actors to resolve via {src}", file=sys.stderr)
embedder = load_embedder(build_dir, models_dir, arcface)
stamp = embedder_stamp(resolve_arcface(models_dir, arcface)) # TRACES: GR-004 | SR-001
img_root = Path(tempfile.mkdtemp(prefix="missing_gallery_"))
actors = []
n_resolved = n_no_tmdb = n_no_img = n_no_face = 0
n_via_wikidata = 0
for i, m in enumerate(missing, 1):
nm, name = m["imdb_id"], m.get("name", "")
tmdb_id, urls = None, []
try:
tmdb_id, urls = profile_urls_for_imdb(nm, token, images_per_actor)
except Exception as e:
print(f" [{i}] {name}: TMDB error {e}", file=sys.stderr)
# Wikidata fallback: keyed cleanly by IMDb nm (P345→P18 Commons photo),
# recovers on-camera character actors TMDB's film-centric DB misses.
if (not urls) and use_wikidata:
wiki_urls = wikidata_image_urls(nm)[:images_per_actor]
if wiki_urls:
urls = wiki_urls
n_via_wikidata += 1
if not urls:
if tmdb_id is None:
n_no_tmdb += 1
else:
n_no_img += 1
continue
dest = img_root / nm
dest.mkdir(parents=True, exist_ok=True)
paths = download_images(urls, dest, images_per_actor)
embeddings = []
for p in paths:
res = embedder.embed(str(p))
if res.ok:
embeddings.append(list(res.embedding))
if not embeddings:
n_no_face += 1
continue
actors.append({"imdb_id": nm, "tmdb_id": str(tmdb_id) if tmdb_id else "",
"jellyfin_id": "", "name": name,
"embeddings": embeddings, "source_images": []})
n_resolved += 1
if i % 20 == 0 or i == len(missing):
print(f" [{i}/{len(missing)}] resolved={n_resolved} "
f"(wiki={n_via_wikidata}) no_tmdb={n_no_tmdb} no_img={n_no_img} "
f"no_face={n_no_face}", file=sys.stderr)
# TRACES: GR-004 | SR-001
# the legacy JSON gallery carries the same stamp as
# the HDF5 one; src/gallery/gallery_store.cpp reads it from either.
Path(out_path).write_text(json.dumps({"embedder": stamp, "actors": actors}, indent=2))
n_emb = sum(len(a["embeddings"]) for a in actors)
print(f"\n[fetch] recovered {n_resolved}/{len(missing)} actors "
f"({n_via_wikidata} via Wikidata), {n_emb} embeddings → {out_path}",
file=sys.stderr)
print(f"[fetch] unrecoverable: no_tmdb={n_no_tmdb} no_img={n_no_img} "
f"no_face={n_no_face}", file=sys.stderr)
def merge(base_path, add_path, out_path):
base = json.loads(Path(base_path).read_text())
add = json.loads(Path(add_path).read_text())
# TRACES: GR-004 | SR-001
# merging two galleries from different models makes
# ONE file containing two incompatible embedding spaces. Nothing downstream can
# ever untangle that, so this is the one place the check must run before, not
# after, the write.
enforce_embedder_stamp(base.get("embedder"), add.get("embedder"),
str(base_path), str(add_path))
have = {a.get("imdb_id") for a in base["actors"] if a.get("imdb_id")}
added = [a for a in add["actors"] if a.get("imdb_id") not in have]
base["actors"].extend(added)
Path(out_path).write_text(json.dumps(base, indent=2))
print(f"[merge] {len(base['actors'])-len(added)} + {len(added)} = "
f"{len(base['actors'])} actors → {out_path}", file=sys.stderr)
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--merge", nargs=2, metavar=("BASE", "ADD"),
help="merge ADD gallery into BASE → --out")
p.add_argument("--missing")
p.add_argument("--out", required=True)
p.add_argument("--tmdb-key", default=os.environ.get("TMDB_API_KEY"))
p.add_argument("--build-dir", default=str(REPO / "build"))
p.add_argument("--models-dir", default=str(REPO / "models"))
p.add_argument("--arcface", default=None)
p.add_argument("--images-per-actor", type=int, default=3)
p.add_argument("--wikidata", action="store_true",
help="fall back to Wikidata (P345→P18 Commons photo) when TMDB has no image")
args = p.parse_args()
if args.merge:
merge(args.merge[0], args.merge[1], args.out)
return
if not args.missing:
sys.exit("--missing required (or use --merge)")
if not args.tmdb_key:
sys.exit("no TMDB key — set TMDB_API_KEY")
fetch(args.missing, args.out, args.tmdb_key, args.build_dir, args.models_dir,
args.arcface, args.images_per_actor, use_wikidata=args.wikidata)
if __name__ == "__main__":
main()