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.
202 lines
8.5 KiB
Python
202 lines
8.5 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
dump_error_frames.py — extract example video frames for visual inspection of a
|
|
replayed prediction vs X-Ray ground truth: best-agreement seconds, FPI (false
|
|
identification) seconds, and FN (missed cast) seconds.
|
|
|
|
Reuses second_score.py's per-second timeline/prediction loading, but keeps the
|
|
per-second classification (score_seconds only returns aggregates) and picks
|
|
representative timestamps in each bucket, then pulls single frames from the
|
|
source video via ffmpeg -ss (nearest keyframe-independent seek + decode).
|
|
|
|
If --raw (the JSONL from `replay.py --raw-out`) is given, also draws each visible
|
|
actor's bounding box + name/similarity on the extracted frame — green for
|
|
identified, orange for unknown — matching debug_renderer_node.hpp's colour
|
|
convention. Without --raw, frames are saved unannotated.
|
|
|
|
Usage:
|
|
python scripts/optimizer/dump_error_frames.py \
|
|
--pred pred.json --raw raw.jsonl \
|
|
--xray experiments/xray/.../900_The_Many_Saints_Of_Newark \
|
|
--movie "/mnt/movies/The Many Saints Of Newark (2021)/....mp4" \
|
|
--gallery experiments/galleries/gallery_LVFace-B_Glint360K.h5 \
|
|
--out-dir experiments/dump_review/many_saints --n-per-bucket 6
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import cv2
|
|
|
|
REPO = Path(__file__).resolve().parent.parent.parent
|
|
sys.path.insert(0, str(REPO / "scripts" / "optimizer"))
|
|
sys.path.insert(0, str(REPO / "scripts" / "validation"))
|
|
|
|
from second_score import load_second_timeline, load_pred_intervals, _match # noqa: E402
|
|
from sample_eval import load_gallery_keys # noqa: E402
|
|
from identity import keys_for # noqa: E402
|
|
|
|
|
|
def per_second_detail(pred_json: dict, xray_dir: str, gallery_keys: set | None):
|
|
"""Like second_score.score_seconds, but yields one record per sampled second
|
|
instead of collapsing to aggregates."""
|
|
timeline, film_cast, duration = load_second_timeline(xray_dir)
|
|
pred = load_pred_intervals(pred_json)
|
|
|
|
name_by_keys = {}
|
|
for a in pred_json.get("actors", []):
|
|
k = frozenset(keys_for(imdb_id=a.get("imdb_id"), tmdb_id=a.get("tmdb_id"),
|
|
jellyfin_id=a.get("jellyfin_id"), name=a.get("name")))
|
|
name_by_keys[k] = a.get("name", "?")
|
|
|
|
records = []
|
|
for t in sorted(timeline):
|
|
G = [set(a) for a in timeline[t]]
|
|
P_all = [(k, set(k)) for k, wins in pred if any(w0 <= t <= w1 for w0, w1 in wins)]
|
|
if gallery_keys is not None:
|
|
G = [g for g in G if g & gallery_keys]
|
|
|
|
P = [p for _, p in P_all]
|
|
tp, matched = _match(P, G)
|
|
fp_names, fn_names = [], []
|
|
for key, pa in P_all:
|
|
if not any(pa & ga for ga in G):
|
|
fp_names.append(name_by_keys.get(key, "?"))
|
|
for j, ga in enumerate(G):
|
|
if not matched[j]:
|
|
fn_names.append("|".join(sorted(x for x in ga if not x.startswith("imdb:") and not x.startswith("tmdb:"))) or "?")
|
|
|
|
union = tp + len(fp_names) + len(fn_names)
|
|
jaccard = (tp / union) if union else 1.0
|
|
records.append({"t": t, "tp": tp, "fp": fp_names, "fn": fn_names, "jaccard": jaccard})
|
|
return records
|
|
|
|
|
|
def pick_timestamps(records, n_per_bucket):
|
|
best = sorted(records, key=lambda r: (-r["jaccard"], -r["tp"]))
|
|
best = [r for r in best if r["tp"] > 0][:n_per_bucket]
|
|
|
|
fpi = [r for r in records if r["fp"]]
|
|
fpi = sorted(fpi, key=lambda r: -len(r["fp"]))[:n_per_bucket]
|
|
|
|
fn = [r for r in records if r["fn"]]
|
|
fn = sorted(fn, key=lambda r: -len(r["fn"]))[:n_per_bucket]
|
|
|
|
return {"best": best, "fpi": fpi, "fn": fn}
|
|
|
|
|
|
def pick_by_interval(records, interval_sec):
|
|
"""One best (highest jaccard) and one worst (lowest jaccard) second per
|
|
interval_sec-second window across the whole film, e.g. --interval-sec 600 for
|
|
a per-10-minute best/worst sweep. Windows with no sampled seconds are skipped
|
|
(X-Ray timelines only cover scenes, so gaps between/after scenes are common)."""
|
|
windows: dict[int, list] = {}
|
|
for r in records:
|
|
windows.setdefault(r["t"] // interval_sec, []).append(r)
|
|
|
|
buckets: dict[str, list] = {}
|
|
for w in sorted(windows):
|
|
wr = windows[w]
|
|
best = max(wr, key=lambda r: (r["jaccard"], r["tp"]))
|
|
worst = min(wr, key=lambda r: (r["jaccard"], -max(len(r["fp"]), len(r["fn"]))))
|
|
buckets[f"w{w:03d}_best"] = [best]
|
|
buckets[f"w{w:03d}_worst"] = [worst]
|
|
return buckets
|
|
|
|
|
|
def load_raw_annotations(raw_path: str):
|
|
"""second (int, floor) -> list of visible_actors dicts (last frame wins if
|
|
several fall in the same second, which is the common case at 1fps sampling)."""
|
|
by_second = {}
|
|
with open(raw_path) as f:
|
|
for line in f:
|
|
sa = json.loads(line)
|
|
if sa.get("eof"):
|
|
continue
|
|
by_second[int(sa["timestamp_sec"])] = sa.get("visible_actors", [])
|
|
return by_second
|
|
|
|
|
|
def draw_annotations(frame_path: Path, actors: list):
|
|
img = cv2.imread(str(frame_path))
|
|
if img is None:
|
|
return
|
|
for a in actors:
|
|
known = a.get("actor_idx", -1) >= 0
|
|
colour = (60, 200, 0) if known else (220, 100, 0) # BGR: green / orange
|
|
x, y, w, h = a["bbox"]
|
|
x, y, w, h = int(x), int(y), int(w), int(h)
|
|
cv2.rectangle(img, (x, y), (x + w, y + h), colour, 2)
|
|
|
|
label = f"{a['name']} {a['similarity']*100:.0f}%" if known else f"unknown {a['similarity']*100:.0f}%"
|
|
(tw, th), baseline = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
|
|
strip_y0 = max(0, y - th - 4)
|
|
cv2.rectangle(img, (x, strip_y0), (x + tw + 4, y), colour, cv2.FILLED)
|
|
cv2.putText(img, label, (x + 2, y - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5,
|
|
(255, 255, 255), 1, cv2.LINE_AA)
|
|
cv2.imwrite(str(frame_path), img)
|
|
|
|
|
|
def extract_frame(movie: str, t: float, out_path: Path):
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
subprocess.run(
|
|
["ffmpeg", "-y", "-ss", str(t), "-i", movie, "-frames:v", "1",
|
|
"-q:v", "2", str(out_path)],
|
|
check=True, capture_output=True)
|
|
|
|
|
|
def main():
|
|
p = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
p.add_argument("--pred", required=True)
|
|
p.add_argument("--raw", help="raw per-frame annotations JSONL (replay.py --raw-out); "
|
|
"draws bboxes + names on extracted frames if given")
|
|
p.add_argument("--xray", required=True)
|
|
p.add_argument("--movie", required=True)
|
|
p.add_argument("--gallery")
|
|
p.add_argument("--out-dir", required=True)
|
|
p.add_argument("--n-per-bucket", type=int, default=6)
|
|
p.add_argument("--interval-sec", type=int,
|
|
help="instead of global best/fpi/fn buckets, pick one best + one "
|
|
"worst (by jaccard) second per interval-sec window across "
|
|
"the whole film, e.g. 600 for per-10-minute best/worst")
|
|
args = p.parse_args()
|
|
|
|
pred_json = json.loads(Path(args.pred).read_text())
|
|
gk = load_gallery_keys(args.gallery) if args.gallery else None
|
|
records = per_second_detail(pred_json, args.xray, gk)
|
|
buckets = (pick_by_interval(records, args.interval_sec) if args.interval_sec
|
|
else pick_timestamps(records, args.n_per_bucket))
|
|
raw_by_second = load_raw_annotations(args.raw) if args.raw else None
|
|
|
|
out_dir = Path(args.out_dir)
|
|
manifest = []
|
|
for bucket, recs in buckets.items():
|
|
for r in recs:
|
|
fname = f"{bucket}_t{r['t']:05d}.jpg"
|
|
out_path = out_dir / bucket / fname
|
|
try:
|
|
extract_frame(args.movie, r["t"], out_path)
|
|
ok = True
|
|
if raw_by_second is not None:
|
|
draw_annotations(out_path, raw_by_second.get(r["t"], []))
|
|
except subprocess.CalledProcessError as e:
|
|
ok = False
|
|
print(f"[dump_error_frames] ffmpeg failed at t={r['t']}: {e}", file=sys.stderr)
|
|
manifest.append({"bucket": bucket, "t": r["t"], "tp": r["tp"],
|
|
"fp": r["fp"], "fn": r["fn"], "jaccard": round(r["jaccard"], 3),
|
|
"file": str(out_path.relative_to(out_dir)) if ok else None})
|
|
print(f"[{bucket}] t={r['t']}s tp={r['tp']} fp={r['fp']} fn={r['fn']}", file=sys.stderr)
|
|
|
|
(out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False))
|
|
print(f"[dump_error_frames] wrote {len(manifest)} frames + manifest.json to {out_dir}",
|
|
file=sys.stderr)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|