faster calibration curve generation
jellyfin intergration
This commit is contained in:
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user