faster calibration curve generation

jellyfin intergration
This commit is contained in:
2026-06-12 17:54:23 +02:00
parent d753062c6c
commit a1d6759abc
17 changed files with 1379 additions and 166 deletions
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""filter_gallery.py — restrict a global gallery.json to one title's known cast.
make_jellyfin_gallery.py builds a single gallery spanning the whole Jellyfin
library, so identity_matcher can recognise any actor from any film. For a
single-title run you may instead want to restrict matching to that title's
credited cast only — fewer candidates means faster matching and fewer
look-alike false positives.
This script looks up the title's cast live from Jellyfin and writes a
filtered gallery.json containing only those actors, matched via the
"jellyfin_person_id" field recorded by make_jellyfin_gallery.py.
Usage:
python scripts/filter_gallery.py \\
--gallery gallery.json \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--item-id <jellyfin item id> \\
--output gallery_movie.json
# Or search by title:
python scripts/filter_gallery.py \\
--gallery gallery.json \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--title "The Matrix" \\
--output gallery_movie.json
"""
import argparse
import json
import sys
from pathlib import Path
import requests
def jf_get(base_url: str, api_key: str, path: str, **params) -> dict:
url = base_url.rstrip("/") + path
headers = {"X-Emby-Token": api_key, "Accept": "application/json"}
r = requests.get(url, params=params, headers=headers, timeout=30)
r.raise_for_status()
return r.json()
def find_item_id(base_url: str, api_key: str, title: str, item_types: list[str]) -> str:
data = jf_get(
base_url, api_key, "/Items",
Recursive="true",
IncludeItemTypes=",".join(item_types),
SearchTerm=title,
Limit=10,
)
items = data.get("Items", [])
if not items:
raise ValueError(f"No item found matching title {title!r}")
if len(items) > 1:
print("Multiple matches found:", file=sys.stderr)
for it in items:
print(f" {it['Id']} {it.get('Type')} {it.get('Name')} ({it.get('ProductionYear')})", file=sys.stderr)
print(f"Using first match: {items[0]['Name']}", file=sys.stderr)
return items[0]["Id"]
def fetch_cast_person_ids(base_url: str, api_key: str, item_id: str) -> set[str]:
data = jf_get(base_url, api_key, f"/Items/{item_id}", Fields="People")
return {p["Id"] for p in data.get("People", []) if p.get("Type") == "Actor"}
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--gallery", required=True,
help="Global gallery.json built by make_jellyfin_gallery.py")
parser.add_argument("--jellyfin-url", required=True)
parser.add_argument("--api-key", required=True)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--item-id", help="Jellyfin item id of the title")
group.add_argument("--title", help="Title to search for (uses first match)")
parser.add_argument("--item-types", default="Movie,Series",
help="Item types to search when using --title (default: Movie,Series)")
parser.add_argument("--output", required=True, help="Output filtered gallery.json path")
args = parser.parse_args()
gallery = json.loads(Path(args.gallery).read_text())
item_id = args.item_id
if item_id is None:
item_id = find_item_id(args.jellyfin_url, args.api_key, args.title, args.item_types.split(","))
print(f"Resolved title to item id {item_id}", file=sys.stderr)
cast_ids = fetch_cast_person_ids(args.jellyfin_url, args.api_key, item_id)
print(f"Title has {len(cast_ids)} credited cast member(s)", file=sys.stderr)
# current make_jellyfin_gallery.py writes "jellyfin_id"; older galleries used
# "jellyfin_person_id" (see gallery_store.cpp's fallback for the same pair).
actors = [a for a in gallery.get("actors", [])
if (a.get("jellyfin_id") or a.get("jellyfin_person_id")) in cast_ids]
missing = len(cast_ids) - len(actors)
if missing > 0:
print(f"[warn] {missing} cast member(s) not present in gallery (not yet embedded)", file=sys.stderr)
Path(args.output).write_text(json.dumps({"actors": actors}, indent=2) + "\n")
print(f"Saved {len(actors)} actor(s) to {args.output}", file=sys.stderr)
if __name__ == "__main__":
main()
+27 -71
View File
@@ -1,9 +1,9 @@
#!/usr/bin/env python3
"""make_gallery.py — fetch actor images for a movie and build gallery.json.
Fetches the cast from TMDB, downloads actor profile images, runs the C++
embed_faces binary (SCRFD + ArcFace, same models as scene_analyze) to produce
embeddings, then writes gallery.json.
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.json.
Requirements:
pip install requests Pillow
@@ -22,7 +22,7 @@ Usage:
--output gallery.json
# Additional options:
# --embed-bin build/embed_faces path to embed_faces binary
# --build-dir build/ build dir containing sae_embed module
# --models-dir models/ directory with ONNX models
# --max-actors 20 how many cast members to include
# --images-per-actor 3 profile images to download per actor
@@ -32,19 +32,18 @@ Get a free TMDB API key at: https://www.themoviedb.org/settings/api
"""
import argparse
import io
import json
import os
import subprocess
import sys
import tempfile
import time
from pathlib import Path
import io
import requests
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder
TMDB_BASE = "https://api.themoviedb.org/3"
TMDB_IMG = "https://image.tmdb.org/t/p/original"
@@ -81,7 +80,7 @@ def fetch_cast(movie_id: int, key: str, max_actors: int) -> list[dict]:
# Get IMDB ID for this person
ext = tmdb_get(f"/person/{person_id}/external_ids", key)
imdb_id = ext.get("imdb_id") or f"tmdb_{person_id}"
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)
@@ -96,6 +95,7 @@ def fetch_cast(movie_id: int, key: str, max_actors: int) -> list[dict]:
"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
@@ -124,42 +124,9 @@ def download_images(actor: dict, dest_dir: Path, n: int) -> list[Path]:
return paths
# ── Embedding via embed_faces binary ─────────────────────────────────────────
def embed_images(image_paths: list[Path], embed_bin: str,
detector: str, arcface: str) -> list[dict | None]:
"""
Call the C++ embed_faces binary on a list of images.
Returns a list of result dicts (or None if no face / error) per image.
"""
if not image_paths:
return []
cmd = [
embed_bin,
"--detector", detector,
"--arcface", arcface,
] + [str(p) for p in image_paths]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
print(f"[error] embed_faces failed:\n{e.stderr}", file=sys.stderr)
return [None] * len(image_paths)
try:
results = json.loads(proc.stdout)
except json.JSONDecodeError as e:
print(f"[error] embed_faces output is not valid JSON: {e}", file=sys.stderr)
return [None] * len(image_paths)
return results
# ── Gallery assembly ─────────────────────────────────────────────────────────
def build_gallery(movie_id: int, key: str, embed_bin: str,
detector: str, arcface: str,
def build_gallery(movie_id: int, key: str, embedder,
max_actors: int, images_per_actor: int,
image_root: Path) -> dict:
"""Fetch cast, download images, embed, return gallery dict."""
@@ -171,27 +138,27 @@ def build_gallery(movie_id: int, key: str, embed_bin: str,
for actor in actors:
safe_name = actor["name"].replace(" ", "_")
actor_dir = image_root / f"{actor['imdb_id']}_{safe_name}"
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']} ({actor['imdb_id']})", file=sys.stderr)
print(f"\n{actor['name']} ({dir_id})", file=sys.stderr)
image_paths = download_images(actor, actor_dir, images_per_actor)
if not image_paths:
print(" no images downloaded, skipping", file=sys.stderr)
continue
print(f" embedding {len(image_paths)} image(s)…", file=sys.stderr)
results = embed_images(image_paths, embed_bin, detector, arcface)
embeddings = []
source_images = []
for path, res in zip(image_paths, results):
if res is None or res.get("embedding") is None:
reason = res.get("error", "unknown") if res else "binary error"
print(f" [skip] {path.name}: {reason}", file=sys.stderr)
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"])
embeddings.append(res.embedding)
source_images.append(path.name)
print(f" [ok] {path.name} conf={res.get('confidence', 0):.2f}",
print(f" [ok] {path.name} conf={res.confidence:.2f}",
file=sys.stderr)
if not embeddings:
@@ -200,6 +167,8 @@ def build_gallery(movie_id: int, key: str, embed_bin: str,
gallery_actors.append({
"imdb_id": actor["imdb_id"],
"tmdb_id": actor["tmdb_id"],
"jellyfin_id": "",
"name": actor["name"],
"source_images": source_images,
"embeddings": embeddings,
@@ -213,7 +182,7 @@ def build_gallery(movie_id: int, key: str, embed_bin: str,
def main():
parser = argparse.ArgumentParser(
description="Fetch TMDB cast images and build gallery.json via embed_faces")
description="Fetch TMDB cast images and build gallery.json 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)
@@ -222,8 +191,8 @@ def main():
group.add_argument("--movie-id", type=int,
help="TMDB movie ID (alternative to --imdb-id)")
parser.add_argument("--output", required=True, help="Output gallery.json path")
parser.add_argument("--embed-bin", default="build/embed_faces",
help="Path to embed_faces binary (default: build/embed_faces)")
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,
@@ -239,21 +208,10 @@ def main():
args = parser.parse_args()
# Resolve paths
embed_bin = str(Path(args.embed_bin).resolve())
models_dir = Path(args.models_dir)
detector = str(models_dir / "scrfd_500m_bnkps.onnx")
arcface = args.arcface if args.arcface else str(models_dir / "arcface_w600k_r50.onnx")
output = Path(args.output)
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
# Validate
if not Path(embed_bin).is_file():
sys.exit(f"embed_faces binary not found: {embed_bin}\n"
f"Build it first: cmake --build build --target embed_faces")
for model, name in [(detector, "SCRFD"), (arcface, "ArcFace")]:
if not Path(model).is_file():
sys.exit(f"{name} model not found: {model}\n"
f"Run: bash scripts/download_models.sh")
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
# Resolve movie ID
movie_id = args.movie_id
@@ -266,9 +224,7 @@ def main():
gallery = build_gallery(
movie_id = movie_id,
key = args.tmdb_key,
embed_bin = embed_bin,
detector = detector,
arcface = arcface,
embedder = embedder,
max_actors = args.max_actors,
images_per_actor = args.images_per_actor,
image_root = image_root,
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""make_jellyfin_gallery.py — build a gallery.json spanning an entire Jellyfin library.
Queries the Jellyfin API for every Movie/Series, collects the unique cast
across the whole library, downloads each actor's headshot directly from
Jellyfin (no TMDB key needed), embeds them with the sae_embed module (SCRFD +
ArcFace, loaded once), and writes one global gallery.json.
Because identity_matcher scores every detected face against the whole
gallery, scene_analyze can then recognise any actor in your library in any
film — not just the cast TMDB lists for that one title.
For a single-title run, use scripts/filter_gallery.py afterwards to restrict
matching to that title's credited cast (faster, fewer look-alike mismatches).
Requirements:
pip install requests Pillow
Usage:
python scripts/make_jellyfin_gallery.py \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--output gallery.json
# Re-run later to pick up newly added titles without re-embedding
# actors already in the gallery:
python scripts/make_jellyfin_gallery.py \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--output gallery.json --merge
# Fall back to TMDB profile images for actors with no usable Jellyfin image:
python scripts/make_jellyfin_gallery.py \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--tmdb-key YOUR_TMDB_KEY \\
--output gallery.json
Get a Jellyfin API key from Dashboard → Advanced → API Keys.
Get a free TMDB API key at: https://www.themoviedb.org/settings/api
"""
import argparse
import concurrent.futures
import io
import json
import sys
from pathlib import Path
import requests
from PIL import Image
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder
TMDB_BASE = "https://api.themoviedb.org/3"
TMDB_IMG = "https://image.tmdb.org/t/p/original"
# ── Jellyfin API helpers ────────────────────────────────────────────────────
def jf_get(base_url: str, api_key: str, path: str, **params) -> dict:
url = base_url.rstrip("/") + path
headers = {"X-Emby-Token": api_key, "Accept": "application/json"}
r = requests.get(url, params=params, headers=headers, timeout=30)
r.raise_for_status()
return r.json()
def fetch_library_items(base_url: str, api_key: str, item_types: list[str]):
"""Yield every Movie/Series item dict (with its People field)."""
start = 0
limit = 100
while True:
data = jf_get(
base_url, api_key, "/Items",
Recursive="true",
IncludeItemTypes=",".join(item_types),
Fields="People",
StartIndex=start,
Limit=limit,
)
items = data.get("Items", [])
for item in items:
yield item
start += limit
if start >= data.get("TotalRecordCount", 0) or not items:
break
def collect_actors(base_url: str, api_key: str, item_types: list[str]) -> dict:
"""Return {person_id: {name, primary_image_tag, appearances: [...]}}."""
actors: dict[str, dict] = {}
n_items = 0
for item in fetch_library_items(base_url, api_key, item_types):
n_items += 1
for person in item.get("People", []):
if person.get("Type") != "Actor":
continue
pid = person["Id"]
entry = actors.setdefault(pid, {
"name": person["Name"],
"primary_image_tag": person.get("PrimaryImageTag"),
"appearances": [],
})
if not entry["primary_image_tag"]:
entry["primary_image_tag"] = person.get("PrimaryImageTag")
entry["appearances"].append({
"item_id": item["Id"],
"title": item.get("Name", ""),
"type": item.get("Type", ""),
})
if n_items % 50 == 0:
print(f" scanned {n_items} items, {len(actors)} unique actors so far…", file=sys.stderr)
print(f"Scanned {n_items} items, found {len(actors)} unique actors", file=sys.stderr)
return actors
def fetch_imdb_id(base_url: str, api_key: str, person_id: str) -> str | None:
try:
data = jf_get(base_url, api_key, f"/Items/{person_id}", Fields="ProviderIds")
except requests.RequestException:
return None
return data.get("ProviderIds", {}).get("Imdb")
# ── TMDB fallback (for actors with no usable Jellyfin image) ────────────────
def tmdb_get(path: str, key: str, **params) -> dict:
url = TMDB_BASE + path
if key.startswith("eyJ"):
headers = {"Authorization": f"Bearer {key}", "Accept": "application/json"}
r = requests.get(url, params=params, headers=headers, timeout=10)
else:
params["api_key"] = key
r = requests.get(url, params=params, headers={"Accept": "application/json"}, timeout=10)
r.raise_for_status()
return r.json()
def tmdb_person_images(tmdb_person_id: str, tmdb_key: str) -> list[str]:
images_data = tmdb_get(f"/person/{tmdb_person_id}/images", tmdb_key)
return [TMDB_IMG + p["file_path"] for p in images_data.get("profiles", []) if p.get("file_path")]
def tmdb_person_for_imdb(imdb_id: str, tmdb_key: str) -> tuple[str | None, list[str]]:
"""Return (tmdb_person_id, profile_image_urls) for the TMDB person matching this IMDB person id."""
data = tmdb_get(f"/find/{imdb_id}", tmdb_key, external_source="imdb_id")
people = data.get("person_results", [])
if not people:
return None, []
tmdb_person_id = str(people[0]["id"])
return tmdb_person_id, tmdb_person_images(tmdb_person_id, tmdb_key)
def tmdb_person_by_name(name: str, tmdb_key: str) -> tuple[str | None, list[str]]:
"""Return (tmdb_person_id, profile_image_urls) for the best name match on TMDB.
Used when Jellyfin has no IMDB ProviderId for this person (the common case —
Jellyfin rarely populates ProviderIds on Person items), so /find/{imdb_id}
isn't an option. /search/person is sorted by popularity; take the top hit.
"""
data = tmdb_get("/search/person", tmdb_key, query=name)
people = data.get("results", [])
if not people:
return None, []
tmdb_person_id = str(people[0]["id"])
return tmdb_person_id, tmdb_person_images(tmdb_person_id, tmdb_key)
def download_urls(urls: list[str], dest_dir: Path, n: int, start_index: int = 0) -> 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"
if out.exists() and out.stat().st_size > 1024:
paths.append(out)
continue
try:
r = requests.get(url, timeout=15)
r.raise_for_status()
Image.open(io.BytesIO(r.content)).convert("RGB").save(out, "JPEG")
paths.append(out)
except Exception as e:
print(f" [warn] TMDB image download failed: {url}: {e}", file=sys.stderr)
return paths
# ── Image download ──────────────────────────────────────────────────────────
def download_person_images(base_url: str, api_key: str, person_id: str,
dest_dir: Path, n: int) -> list[Path]:
"""Download up to n images for a Jellyfin person item into dest_dir."""
dest_dir.mkdir(parents=True, exist_ok=True)
headers = {"X-Emby-Token": api_key}
paths = []
for i in range(n):
out = dest_dir / f"{i:02d}.jpg"
if out.exists() and out.stat().st_size > 1024:
paths.append(out)
continue
url = base_url.rstrip("/") + f"/Items/{person_id}/Images/Primary/{i}"
try:
r = requests.get(url, headers=headers, params={"api_key": api_key}, timeout=15)
if r.status_code == 404:
break
r.raise_for_status()
ctype = r.headers.get("Content-Type", "")
if not ctype.startswith("image/"):
print(f" [warn] unexpected response for {person_id} index {i}: "
f"status={r.status_code} content-type={ctype!r} "
f"len={len(r.content)} body={r.content[:200]!r}", file=sys.stderr)
break
Image.open(io.BytesIO(r.content)).convert("RGB").save(out, "JPEG")
paths.append(out)
except Exception as e:
print(f" [warn] image download failed for {person_id} index {i}: {e}", file=sys.stderr)
break
return paths
# ── Per-actor pipeline ───────────────────────────────────────────────────────
def fetch_actor_images(base_url: str, api_key: str, pid: str, info: dict,
images_per_actor: int, actor_dir: Path,
fetch_imdb_ids: bool, tmdb_key: str | None
) -> tuple[list[Path], str | None, str | None]:
"""Network-bound: download Jellyfin image(s), then fall back to TMDB if short."""
name = info["name"]
print(f"{name} ({pid}) — in {len(info['appearances'])} title(s)", file=sys.stderr)
image_paths = download_person_images(base_url, api_key, pid, actor_dir, images_per_actor)
imdb_id = None
if fetch_imdb_ids or tmdb_key:
imdb_id = fetch_imdb_id(base_url, api_key, pid)
# Resolve the TMDB person id whenever possible — independent of whether
# Jellyfin already gave us enough images, so tmdb_id is always populated
# when --tmdb-key is set. Jellyfin Person items rarely have an IMDB
# ProviderId, so fall back to a name search when imdb_id is unknown.
tmdb_id = None
tmdb_urls: list[str] = []
if tmdb_key:
try:
if imdb_id:
tmdb_id, tmdb_urls = tmdb_person_for_imdb(imdb_id, tmdb_key)
if tmdb_id is None:
tmdb_id, tmdb_urls = tmdb_person_by_name(name, tmdb_key)
except requests.RequestException as e:
print(f" [warn] {name}: TMDB lookup failed: {e}", file=sys.stderr)
if len(image_paths) < images_per_actor and tmdb_urls:
needed = images_per_actor - len(image_paths)
print(f" {name}: Jellyfin image missing/incomplete, falling back to TMDB "
f"({len(tmdb_urls)} image(s) available)…", file=sys.stderr)
image_paths += download_urls(tmdb_urls, actor_dir, needed, start_index=len(image_paths))
return image_paths, imdb_id, tmdb_id
def embed_actor(pid: str, info: dict, image_paths: list[Path],
imdb_id: str | None, tmdb_id: str | None, embedder, fetch_imdb_ids: bool,
embed_executor: concurrent.futures.ThreadPoolExecutor) -> dict | None:
"""GPU-bound: run sae_embed, always on embed_executor's single dedicated thread.
onnxruntime's CUDA EP / cudnn_frontend execution plans are not safe to run
from arbitrary threads — calling Run() from a different OS thread than the
one that last used the session corrupts the cudnn graph (CUDNN_FE failure
11 / CUDNN_BACKEND_API_FAILED). Routing every embed() call through one
persistent thread avoids that regardless of how many worker threads are
fetching images concurrently.
"""
name = info["name"]
if not image_paths:
print(f" {name}: no image available, skipping", file=sys.stderr)
return None
embeddings = []
source_images = []
print(f" {name}: embedding {len(image_paths)} image(s)…", file=sys.stderr)
for path in image_paths:
res = embed_executor.submit(embedder.embed, str(path)).result()
if not res.ok:
print(f" [skip] {name}/{path.name}: {res.error}", file=sys.stderr)
continue
embeddings.append(res.embedding)
source_images.append(path.name)
if not embeddings:
print(f" {name}: no valid embeddings, skipping actor", file=sys.stderr)
return None
print(f" {name}: → {len(embeddings)} embedding(s) stored", file=sys.stderr)
return {
"imdb_id": imdb_id if (fetch_imdb_ids and imdb_id) else "",
"tmdb_id": tmdb_id or "",
"jellyfin_id": pid,
"name": name,
"source_images": source_images,
"embeddings": embeddings,
"appearances": info["appearances"],
}
def process_actor(pid: str, info: dict, base_url: str, api_key: str,
embedder, images_per_actor: int, image_root: Path,
fetch_imdb_ids: bool, tmdb_key: str | None,
embed_executor: concurrent.futures.ThreadPoolExecutor) -> dict | None:
safe_name = info["name"].replace(" ", "_")
actor_dir = image_root / f"{pid}_{safe_name}"
image_paths, imdb_id, tmdb_id = fetch_actor_images(
base_url, api_key, pid, info, images_per_actor, actor_dir, fetch_imdb_ids, tmdb_key)
return embed_actor(pid, info, image_paths, imdb_id, tmdb_id, embedder, fetch_imdb_ids, embed_executor)
# ── Gallery assembly ─────────────────────────────────────────────────────────
def build_gallery(base_url: str, api_key: str, embedder, item_types: list[str],
images_per_actor: int, image_root: Path,
fetch_imdb_ids: bool, existing_actors: dict,
tmdb_key: str | None = None, workers: int = 8) -> dict:
actors = collect_actors(base_url, api_key, item_types)
gallery_actors = []
todo = []
for pid, info in actors.items():
if pid in existing_actors:
gallery_actors.append(existing_actors[pid])
else:
todo.append((pid, info))
if len(gallery_actors):
print(f"Skipping {len(gallery_actors)} actor(s) already present in existing gallery", file=sys.stderr)
print(f"Processing {len(todo)} new actor(s) with {workers} worker(s)…", file=sys.stderr)
n_done = 0
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor, \
concurrent.futures.ThreadPoolExecutor(max_workers=1, thread_name_prefix="embed") as embed_executor:
futures = {
executor.submit(process_actor, pid, info, base_url, api_key, embedder,
images_per_actor, image_root,
fetch_imdb_ids, tmdb_key, embed_executor): info["name"]
for pid, info in todo
}
for future in concurrent.futures.as_completed(futures):
n_done += 1
name = futures[future]
try:
actor = future.result()
except Exception as e:
print(f" [error] {name}: {e}", file=sys.stderr)
continue
if actor:
gallery_actors.append(actor)
if n_done % 25 == 0:
print(f" progress: {n_done}/{len(todo)} actors processed", file=sys.stderr)
return {"actors": gallery_actors}
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser(
description="Build a gallery.json spanning an entire Jellyfin library",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--jellyfin-url", required=True,
help="Jellyfin base server URL only, e.g. http://jellyfin.local:8096 "
"(no /Items or other API path)")
parser.add_argument("--api-key", required=True,
help="Jellyfin API key (Dashboard → Advanced → API Keys)")
parser.add_argument("--output", required=True, help="Output gallery.json path")
parser.add_argument("--item-types", default="Movie,Series",
help="Comma-separated Jellyfin item types to scan (default: Movie,Series)")
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=1,
help="Images to download per actor (default: 1 — Jellyfin usually caches one)")
parser.add_argument("--image-dir", default=None,
help="Where to store downloaded images (default: <output_dir>/images)")
parser.add_argument("--fetch-imdb-ids", action="store_true",
help="Resolve each actor's real IMDB id via Jellyfin ProviderIds "
"(one extra API call per new actor; otherwise imdb_id is left empty)")
parser.add_argument("--tmdb-key", default=None,
help="TMDB API key/bearer token. If set, actors with no usable "
"Jellyfin image fall back to TMDB profile images (looked up "
"via the actor's IMDB id, requires one extra Jellyfin call per actor)")
parser.add_argument("--merge", action="store_true",
help="If --output already exists, keep its actors and only embed "
"actors not already present (matched by jellyfin_id)")
parser.add_argument("--workers", type=int, default=8,
help="Concurrent worker threads for Jellyfin/TMDB lookups and "
"image downloads (default: 8). Embedding itself always runs "
"on a single dedicated thread regardless of this value.")
args = parser.parse_args()
# Defend against a base URL that accidentally includes an API path,
# e.g. "https://host/Items?" — strip any query string and trailing
# /Items so we don't build double-nested, query-mangled URLs.
jellyfin_url = args.jellyfin_url.split("?", 1)[0].rstrip("/")
if jellyfin_url.endswith("/Items"):
jellyfin_url = jellyfin_url[: -len("/Items")]
output = Path(args.output)
image_root = Path(args.image_dir) if args.image_dir else output.parent / "images"
item_types = [t.strip() for t in args.item_types.split(",") if t.strip()]
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
existing_actors = {}
if args.merge and output.is_file():
existing = json.loads(output.read_text())
for actor in existing.get("actors", []):
# older galleries used "jellyfin_person_id"
pid = actor.get("jellyfin_id") or actor.get("jellyfin_person_id")
if pid:
existing_actors[pid] = actor
print(f"Loaded {len(existing_actors)} actor(s) from existing gallery for merge", file=sys.stderr)
gallery = build_gallery(
base_url=jellyfin_url,
api_key=args.api_key,
embedder=embedder,
item_types=item_types,
images_per_actor=args.images_per_actor,
image_root=image_root,
fetch_imdb_ids=args.fetch_imdb_ids,
existing_actors=existing_actors,
tmdb_key=args.tmdb_key,
workers=args.workers,
)
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 Jellyfin URL/API key and models.")
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(gallery, indent=2) + "\n")
print(f"Saved: {output}", file=sys.stderr)
if __name__ == "__main__":
main()
+13 -35
View File
@@ -8,8 +8,7 @@ Usage:
--arcface models/arcface_w600k_r50.onnx \
--gt eval/gt.json \
--output eval/predictions_r50.json \
[--yunet models/face_detection_yunet_2023mar.onnx] \
[--embed-bin build/embed_faces]
[--build-dir build]
Input (--gt): list of {"crop": <path>, "imdb_id": <str>, "actor_name": <str>}
Output: list of {"crop", "gt", "pred", "similarity", "detection_failed", "all_scores"}
@@ -17,11 +16,12 @@ Output: list of {"crop", "gt", "pred", "similarity", "detection_failed", "all_sc
import argparse
import json
import math
import subprocess
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from sae_embed_loader import load_embedder
def load_gallery(path: str) -> dict[str, dict]:
"""Return {imdb_id: {"name": str, "embeddings": [[float]]}}."""
@@ -35,22 +35,6 @@ def dot(a: list[float], b: list[float]) -> float:
return sum(x * y for x, y in zip(a, b))
def embed_images(paths: list[Path], embed_bin: str, yunet: str, arcface: str) -> list[dict | None]:
if not paths:
return []
cmd = [embed_bin, "--yunet", yunet, "--arcface", arcface] + [str(p) for p in paths]
try:
proc = subprocess.run(cmd, capture_output=True, text=True, check=True)
except subprocess.CalledProcessError as e:
print(f"[error] embed_faces failed:\n{e.stderr}", file=sys.stderr)
return [None] * len(paths)
try:
return json.loads(proc.stdout)
except json.JSONDecodeError as e:
print(f"[error] embed_faces JSON parse error: {e}", file=sys.stderr)
return [None] * len(paths)
def match(embedding: list[float], gallery: dict[str, dict]) -> tuple[str, float, dict[str, float]]:
"""Return (best_imdb_id, best_similarity, {imdb_id: similarity})."""
scores: dict[str, float] = {}
@@ -67,10 +51,14 @@ def main():
p.add_argument("--arcface", required=True)
p.add_argument("--gt", required=True)
p.add_argument("--output", required=True)
p.add_argument("--yunet", default="models/face_detection_yunet_2023mar.onnx")
p.add_argument("--embed-bin", default="build/embed_faces")
p.add_argument("--build-dir", default="build",
help="Build directory containing the sae_embed module (default: build)")
p.add_argument("--models-dir", default="models",
help="Directory containing ONNX models (default: models/)")
args = p.parse_args()
embedder = load_embedder(args.build_dir, args.models_dir, args.arcface)
gallery = load_gallery(args.gallery)
print(f"[eval] gallery: {len(gallery)} actors", file=sys.stderr)
@@ -78,29 +66,19 @@ def main():
gt_entries = json.load(f)
print(f"[eval] probe crops: {len(gt_entries)}", file=sys.stderr)
# Batch all crops in one embed_faces call to amortise startup cost
crop_paths = [Path(e["crop"]) for e in gt_entries]
missing = [p for p in crop_paths if not p.exists()]
if missing:
print(f"[warn] {len(missing)} crop(s) not found on disk, skipping", file=sys.stderr)
results_raw = embed_images(
[p for p in crop_paths if p.exists()],
args.embed_bin, args.yunet, args.arcface
)
# Re-index results back to original list (missing files get None)
raw_iter = iter(results_raw)
embed_results: list[dict | None] = []
for p in crop_paths:
embed_results.append(next(raw_iter) if p.exists() else None)
embed_results = [embedder.embed(str(p)) if p.exists() else None for p in crop_paths]
predictions = []
n_det_fail = 0
n_correct = 0
for entry, result in zip(gt_entries, embed_results):
detection_failed = result is None or result.get("embedding") is None
detection_failed = result is None or not result.ok
if detection_failed:
n_det_fail += 1
predictions.append({
@@ -113,7 +91,7 @@ def main():
})
continue
pred_id, sim, all_scores = match(result["embedding"], gallery)
pred_id, sim, all_scores = match(result.embedding, gallery)
correct = pred_id == entry["imdb_id"]
if correct:
n_correct += 1
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""run_from_jellyfin.py — resolve a Jellyfin title to its media file and run scene_analyze.
Looks up a Movie/Episode in Jellyfin, reads its on-disk Path (Jellyfin and this
tool must share the same media mount), filters the gallery down to that
title's credited cast (via filter_gallery's logic, fewer look-alike
mismatches), and runs scene_analyze against the resolved file.
Usage:
python scripts/run_from_jellyfin.py \\
--jellyfin-url http://jellyfin.local:8096 \\
--api-key YOUR_API_KEY \\
--title "The Matrix" \\
--gallery whole_gallery.json \\
-- --fps 5 --verbosity 2
Anything after "--" is passed through unchanged to scene_analyze.
"""
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from filter_gallery import jf_get, find_item_id, fetch_cast_person_ids
def fetch_item_path(base_url: str, api_key: str, item_id: str) -> tuple[str, str]:
"""Return (Name, Path) for a Jellyfin item."""
data = jf_get(base_url, api_key, f"/Items/{item_id}", Fields="Path")
path = data.get("Path")
if not path:
raise ValueError(f"Item {item_id} has no Path (not a single media file?)")
return data.get("Name", item_id), path
def main():
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("--jellyfin-url", required=True)
parser.add_argument("--api-key", required=True)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument("--item-id", help="Jellyfin item id of the title")
group.add_argument("--title", help="Title to search for (uses first match)")
parser.add_argument("--item-types", default="Movie,Episode",
help="Item types to search when using --title (default: Movie,Episode)")
parser.add_argument("--gallery", required=True,
help="Global gallery.json built by make_jellyfin_gallery.py")
parser.add_argument("--no-filter", action="store_true",
help="Skip per-title cast filtering and pass --gallery through as-is")
parser.add_argument("--output", default=None,
help="scene_analyze output JSON (default: <title>.json)")
parser.add_argument("--bin", default="build/scene_analyze",
help="Path to scene_analyze binary (default: build/scene_analyze)")
parser.add_argument("--dry-run", action="store_true",
help="Resolve and print the scene_analyze command without running it")
args, extra = parser.parse_known_args()
if extra and extra[0] == "--":
extra = extra[1:]
item_id = args.item_id
if item_id is None:
item_id = find_item_id(args.jellyfin_url, args.api_key, args.title, args.item_types.split(","))
print(f"Resolved title to item id {item_id}", file=sys.stderr)
name, movie_path = fetch_item_path(args.jellyfin_url, args.api_key, item_id)
print(f"Resolved {name!r} -> {movie_path}", file=sys.stderr)
if not Path(movie_path).is_file():
sys.exit(f"Resolved path does not exist on this filesystem: {movie_path}\n"
f"(this tool must share Jellyfin's media mount)")
output = args.output or f"{name}.json"
gallery_path = args.gallery
filtered_file = None
if not args.no_filter:
cast_ids = fetch_cast_person_ids(args.jellyfin_url, args.api_key, item_id)
gallery = json.loads(Path(args.gallery).read_text())
actors = [a for a in gallery.get("actors", [])
if (a.get("jellyfin_id") or a.get("jellyfin_person_id")) in cast_ids]
print(f"Filtered gallery to {len(actors)}/{len(gallery.get('actors', []))} "
f"actor(s) credited in {name!r}", file=sys.stderr)
filtered_file = tempfile.NamedTemporaryFile(
mode="w", suffix=".json", prefix="sae_gallery_", delete=False)
json.dump({"actors": actors}, filtered_file)
filtered_file.close()
gallery_path = filtered_file.name
cmd = [args.bin, "--movie", movie_path, "--gallery", gallery_path,
"--output", output, *extra]
print("Running:", " ".join(cmd), file=sys.stderr)
if args.dry_run:
return
try:
subprocess.run(cmd, check=True)
finally:
if filtered_file is not None:
Path(filtered_file.name).unlink(missing_ok=True)
if __name__ == "__main__":
main()
+36
View File
@@ -0,0 +1,36 @@
"""Shared loader for the sae_embed nanobind module (SCRFD + ArcFace).
sae_embed.FaceEmbedder loads both ONNX sessions once and exposes an
embed(path) -> FaceResult method, avoiding the per-process model reload cost
of spawning the embed_faces CLI binary for every image.
"""
import sys
from pathlib import Path
def load_embedder(build_dir: str, models_dir: str, arcface: str | None = None,
conf: float = 0.5, nms: float = 0.4, max_side: int = 500):
"""Import sae_embed from build_dir and construct a FaceEmbedder.
Exits with a clear error if the module or models are missing there is
no subprocess fallback.
"""
build_path = Path(build_dir).resolve()
sys.path.insert(0, str(build_path))
try:
import sae_embed
except ImportError as e:
sys.exit(
f"sae_embed module not found in {build_path}: {e}\n"
f"Build it first: cmake --build {build_dir} --target sae_embed"
)
models_path = Path(models_dir)
detector_path = str(models_path / "scrfd_500m_bnkps.onnx")
arcface_path = arcface if arcface else str(models_path / "arcface_w600k_r50.onnx")
for model, name in [(detector_path, "SCRFD"), (arcface_path, "ArcFace")]:
if not Path(model).is_file():
sys.exit(f"{name} model not found: {model}\nRun: bash scripts/download_models.sh")
return sae_embed.FaceEmbedder(detector_path, arcface_path, conf, nms, max_side)