feat(tooling): X-Ray threshold optimizer, gallery utilities, artifact registry, docs build

Optimizer (scripts/optimizer/): replay.py runs the real C++ tracker/matcher/
scene_tracker chain over a dumped-embeddings HDF5 via sae_kpn, so a threshold
sweep never re-decodes video or re-embeds faces. optimize.py drives scipy's
differential_evolution over the knob space, with DE-level parallelism
(multiple population candidates evaluated concurrently via a ThreadPoolExecutor)
on top of per-film replay parallelism. second_score.py is the per-second X-Ray
scoring metric (TPI/FPI/FN, out-of-cast misID weighted 10x, fair recall masked
to gallery-known cast) that superseded an earlier scene-union metric.
dump_error_frames.py / dump_scene_montage.py extract annotated video frames
(bounding boxes, TPI/FPI/FN captions, onscreen-vs-offscreen split) for visual
review of a replay against ground truth. Gallery utilities: cast_restrict.py,
gallery_membership.py, fetch_missing_actors.py, reembed_gallery.py.

scripts/validation/: X-Ray ground-truth loading and provider-agnostic identity
matching (identity.py's keys_for — an actor is the union of every id we can
derive, since pipeline output and ground truth don't share one id space).

scripts/artifacts/: push/pull scripts for the Gitea generic package registry —
galleries, montage frames, and experiment data (manifests/trajectories/results)
are pushed there instead of committed, since none are needed to run the app,
only benchmarks. Versioned by git short-SHA.

scripts/docs/: MkDocs site build (build_site.sh) and the calibration-curve
comparison chart (calibration_chart.py, matplotlib, reads each gallery's
embedded calibration).

Gallery-building scripts (make_jellyfin_gallery.py, make_gallery.py,
filter_gallery.py, run_from_jellyfin.py, movienet_eval.py, movienet_prep.py,
sae_gallery.py) updated to read/write HDF5 galleries exclusively, matching the
engine-side format switch. run_from_jellyfin.py and the optimizer no longer
carry movie source paths in shared manifests (some source filenames include
scene-release tags) — resolved locally via a gitignored file-lut.json instead.
This commit is contained in:
2026-07-19 19:06:48 +02:00
parent 26139ffe8a
commit 6f0ad83a55
31 changed files with 3411 additions and 47 deletions
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
tmdb_imdb_map.py — build & consult a cached tmdb_person_id → imdb_id crosswalk.
The gallery (and pipeline output) key actors by TMDB person id but carry no IMDb
`nm…` id, while the X-Ray / MovieNet ground truth keys on IMDb. Rather than join on
fuzzy names, we resolve tmdb→imdb once via TMDB's authoritative
`/person/{id}/external_ids` endpoint and cache the result to JSON. The eval loaders
consult this table to add an exact `imdb:` key alongside each `tmdb:` key.
Table format (JSON): { "<tmdb_person_id>": "nm0000123" | null, ... }
A null means "looked up, TMDB has no IMDb id" — cached so we don't re-query.
Build / refresh the table:
python scripts/validation/tmdb_imdb_map.py \
--gallery gallery_arcface_w600k_r50.json \
--out scripts/validation/tmdb_imdb.json
# TMDB_API_KEY read from env / .env (via sae_env)
Consult it from code:
from tmdb_imdb_map import CrosswalkTable
tbl = CrosswalkTable.load("scripts/validation/tmdb_imdb.json")
nm = tbl.imdb_for("35467") # -> "nm..." or None
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from pathlib import Path
_HERE = Path(__file__).resolve().parent
class CrosswalkTable:
"""Read-only view over the cached tmdb→imdb JSON. Missing file → empty table."""
def __init__(self, mapping: dict[str, str | None]):
self._m = mapping
@classmethod
def load(cls, path: str | Path) -> "CrosswalkTable":
p = Path(path)
if not p.exists():
return cls({})
return cls(json.loads(p.read_text()))
def imdb_for(self, tmdb_id) -> str | None:
if tmdb_id is None:
return None
return self._m.get(str(tmdb_id))
def __len__(self) -> int:
return sum(1 for v in self._m.values() if v)
# ── builder ─────────────────────────────────────────────────────────────────
def _external_ids(tmdb_get, tmdb_person_id: str, token: str) -> str | None:
data = tmdb_get(f"/person/{tmdb_person_id}/external_ids", token)
imdb = data.get("imdb_id")
return imdb or None # normalize "" → None
def build(gallery_path: str, out_path: str, token: str, sleep: float = 0.0) -> None:
# import the existing TMDB helper (scripts/ is the parent dir)
sys.path.insert(0, str(_HERE.parent))
from sae_tmdb import tmdb_get
with open(gallery_path) as f:
actors = json.load(f).get("actors", [])
tmdb_ids = sorted({str(a["tmdb_id"]) for a in actors if a.get("tmdb_id")})
print(f"[map] gallery tmdb ids: {len(tmdb_ids)}", file=sys.stderr)
out = Path(out_path)
existing: dict[str, str | None] = {}
if out.exists():
existing = json.loads(out.read_text())
print(f"[map] resuming from {len(existing)} cached entries", file=sys.stderr)
todo = [t for t in tmdb_ids if t not in existing]
print(f"[map] to resolve: {len(todo)}", file=sys.stderr)
n_ok = n_none = n_err = 0
for i, tid in enumerate(todo, 1):
try:
nm = _external_ids(tmdb_get, tid, token)
existing[tid] = nm
n_ok += (nm is not None)
n_none += (nm is None)
except Exception as e: # network/rate-limit/404 — record nothing, keep going
n_err += 1
print(f"\n[map] error on tmdb {tid}: {e}", file=sys.stderr)
if i % 25 == 0 or i == len(todo):
print(f"\r[map] {i}/{len(todo)} resolved "
f"(imdb={n_ok} none={n_none} err={n_err})", end="", file=sys.stderr)
out.write_text(json.dumps(existing, indent=2)) # checkpoint
if sleep:
time.sleep(sleep)
out.write_text(json.dumps(existing, indent=2))
print(f"\n[map] wrote {out}{sum(1 for v in existing.values() if v)} imdb ids",
file=sys.stderr)
def main():
# load .env → os.environ (same convention as the gallery builders)
sys.path.insert(0, str(_HERE.parent))
try:
import sae_env # noqa: F401 (side-effect import)
except Exception:
pass
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--gallery", required=True, help="gallery.json (source of tmdb ids)")
p.add_argument("--out", default=str(_HERE / "tmdb_imdb.json"),
help="output crosswalk JSON (default: scripts/validation/tmdb_imdb.json)")
p.add_argument("--tmdb-key", default=os.environ.get("TMDB_API_KEY"),
help="TMDB v3 API key or v4 read token. Env: TMDB_API_KEY")
p.add_argument("--sleep", type=float, default=0.0,
help="seconds between requests (TMDB has no hard limit; use if throttled)")
args = p.parse_args()
if not args.tmdb_key:
sys.exit("[map] no TMDB key — set TMDB_API_KEY or pass --tmdb-key")
build(args.gallery, args.out, args.tmdb_key, args.sleep)
if __name__ == "__main__":
main()