Files
scene-actor-extraction/scripts/optimizer/replay.py
T
dtourolle f891e579c5 chore(traces): put TRACES tags on their own line; regenerate the report
The parser reads a tag up to end of line, so `# TRACES: GR-004 | SR-001 —
prose` swallowed the prose into the tag and the row went unmatched. Splitting
the comment leaves the tag greppable by the same pattern as the code tags and
the commit trailers, which is the point of the house format.

Mechanical throughout; no logic touched. The regenerated report reflects this
session's new tags: 137 -> 148 found, and one more tagged-but-unexecuted, which
is the SuperHero accuracy assertion that is documented but not yet a test.
2026-08-04 14:04:21 +02:00

277 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
replay.py — replay a dumped embedding HDF5 through the real KPN downstream nodes.
TRACES: VR-002 | PR-002
Reads an embedding dump (scripts/optimizer/SCHEMA.md), feeds each frame as an
EmbeddedSceneFrame into a Python-assembled KPN network wiring the *real* C++
face_tracker → identity_matcher → scene_tracker, and returns the same presence-window
JSON that scene_analyze's result_sink produces (minimal schema). No decode, no GPU
embedding — only the cheap downstream tail runs, so a sweep can vary Config knobs
freely. See [[kpn-python-replay-optimizer]].
CLI:
python scripts/optimizer/replay.py --dump film.h5 --gallery gallery.json \
--out replayed.json [--prob-threshold 0.99] [--anneal 10] ...
"""
from __future__ import annotations
import argparse
import json
import sys
import time
from pathlib import Path
import h5py
import numpy as np
REPO = Path(__file__).resolve().parent.parent.parent
sys.path.insert(0, str(REPO / "scripts"))
from sae_stamp import verify_gallery_stamp # noqa: E402
def dump_embedder_stamp(dump_path: str) -> dict:
"""The GR-004 embedder stamp recorded in an embedding dump.
A replay has no live embedder — the dump IS the embedder as far as the gallery
is concerned, so the dump's stamp is what the gallery must be checked against.
Dumps written before GR-004 have no attributes and yield an empty stamp, which
the check reports as unverifiable rather than silently accepting."""
with h5py.File(dump_path, "r") as f:
name = f.attrs.get("embedder_model", "")
sha = f.attrs.get("embedder_sha256", "")
dec = lambda v: v.decode() if isinstance(v, bytes) else ("" if v is None else str(v))
return {"model_name": dec(name), "model_sha256": dec(sha), "embed_dim": 512}
def load_frames(dump_path: str, min_conf: float = 0.0):
"""Yield EmbeddedSceneFrame dicts from the HDF5 dump, then a trailing EOF.
`min_conf` drops detections below that detector confidence before they reach the
matcher — an UPWARD-only detector_conf sweep on already-dumped faces (the dump was
made at detector_conf=0.5, so 0.5 is the floor). Lets us test whether near-threshold
detections are real faces (raising min_conf hurts recall) or phantoms (it helps
precision at no recall cost)."""
with h5py.File(dump_path, "r") as f:
ts = f["frames/timestamp_sec"][:]
fidx = f["frames/frame_idx"][:]
cut = f["frames/is_cut"][:]
off = f["frames/face_offset"][:]
cnt = f["frames/face_count"][:]
emb = f["faces/embedding"][:]
bbox = f["faces/bbox"][:]
lmk = f["faces/landmarks"][:]
conf = f["faces/confidence"][:]
movie = f.attrs.get("movie", "")
fps = float(f.attrs.get("sample_fps", 1.0))
frames = []
for i in range(len(ts)):
s, n = int(off[i]), int(cnt[i])
keep = slice(s, s + n)
c = np.ascontiguousarray(conf[keep], dtype=np.float32)
if min_conf > 0.0 and n:
m = c >= min_conf
sel = np.where(m)[0]
frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep][sel], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep][sel], dtype=np.float32),
"confidence": np.ascontiguousarray(c[sel], dtype=np.float32),
"embeddings": np.ascontiguousarray(emb[keep][sel], dtype=np.float32),
})
else:
frames.append({
"timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]),
"is_cut": bool(cut[i]), "eof": False,
"bbox": np.ascontiguousarray(bbox[keep], dtype=np.float32),
"landmarks": np.ascontiguousarray(lmk[keep], dtype=np.float32),
"confidence": c,
"embeddings": np.ascontiguousarray(emb[keep], dtype=np.float32),
})
last_ts = float(ts[-1]) if len(ts) else 0.0
frames.append({"timestamp_sec": last_ts, "eof": True})
return frames, str(movie), fps
def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, stop: bool = True,
raw_out: str | None = None) -> dict:
"""Run the dump through the real KPN chain; return minimal-schema presence JSON.
cfg may include "detector_conf" to prune dumped detections below that confidence
(upward-only from the 0.5 dump floor) before matching.
raw_out: if set, also write the raw per-frame annotations (timestamp, actor_idx,
name, bbox, similarity — one entry per input frame, before merging into windows)
as JSON lines to this path. Needed to draw bounding boxes on extracted frames;
the merged window schema returned by this function has no per-frame bbox."""
sys.path.insert(0, build_dir)
import sae_kpn
# TRACES: GR-004 | SR-001
# checked here, before any network is built, so a
# cross-model replay dies with one readable error instead of producing a
# plausible-looking score. add_identity_matcher re-checks it C++-side below;
# that is the backstop for any other caller of the binding.
stamp = dump_embedder_stamp(dump_path)
verify_gallery_stamp(gallery, stamp=stamp,
embedder_desc=f"embedding dump {Path(dump_path).name}",
require_stamp=bool(cfg.get("require_gallery_stamp", False)))
frames, movie, fps = load_frames(dump_path, min_conf=float(cfg.get("detector_conf", 0.0)))
net = sae_kpn.Network()
sae_kpn._register_types(net)
idx = [0]
eof = {"timestamp_sec": frames[-1]["timestamp_sec"], "eof": True}
def source():
# A no-input source node's run_loop calls this in a tight loop. Once frames
# are exhausted we must NOT hot-spin returning EOF — that pegs a core and
# floods the downstream channel with EOFs (livelock that wedged DE). Sleep
# briefly after the single real EOF so net.stop() can tear the thread down.
i = idx[0]
idx[0] += 1
if i < len(frames):
return frames[i]
time.sleep(0.05)
return eof
# Channel capacity must exceed the frame count so the fast source can't overflow
# a downstream FIFO before the serial reader drains it — PyNode DROPS on overflow,
# which would silently truncate the replay. Size to the whole film + slack.
# Every channel gets capacity ≥ the whole film so NOTHING can ever overflow-drop:
# the source can push all frames before any downstream node has drained, and a
# dropped frame silently corrupts the score. Memory is cheap (a few k pointers);
# correctness is not. Generous slack on top.
cap = len(frames) * 2 + 64
sae_kpn.add_node_python(net, "replay", source, [], ["EmbeddedSceneFrame"], cap)
sae_kpn.add_face_tracker(net, "tracker", cfg, cap)
sae_kpn.add_identity_matcher(net, "matcher", gallery, cfg, cap,
stamp["model_name"], stamp["model_sha256"])
sae_kpn.add_scene_tracker(net, "scene", cfg, cap)
net.connect("replay", 0, "tracker", 0)
net.connect("tracker", 0, "matcher", 0)
net.connect("matcher", 0, "scene", 0)
net.build()
net.start()
# Read exactly one annotation per input frame. The source emits EOF as an ordinary
# value AFTER the last frame, but the concurrent pipeline lets that EOF OVERTAKE
# the last few real frames still flowing tracker→matcher→scene. Breaking on the
# first eof therefore dropped a random tail (~0.51%, race-dependent). Instead we
# keep reading past eof until we've collected all n_frames annotations (or hit a
# run of consecutive eofs meaning the pipeline is genuinely drained).
n_expected = len(frames) - 1 # excludes the trailing eof frame
annotations = []
eof_streak = 0
max_reads = n_expected * 2 + 32
for _ in range(max_reads):
sa = net.read("scene", 0)
if sa.get("eof"):
eof_streak += 1
# stragglers can still arrive after an eof; only stop once we've either
# got everything or seen several eofs in a row (truly drained).
if len(annotations) >= n_expected or eof_streak >= 8:
break
continue
eof_streak = 0
annotations.append(sa)
if len(annotations) >= n_expected:
break
if raw_out:
with open(raw_out, "w") as f:
for sa in annotations:
f.write(json.dumps(sa) + "\n")
result = build_minimal(annotations, movie, fps, cfg)
if stop:
net.stop()
return result
def build_minimal(annotations, movie, fps, cfg) -> dict:
"""Reproduce result_sink's minimal schema: per-actor annealed [start,end] windows.
Mirrors ResultSinkFunc::build_actor_windows — merge each actor's detection
timestamps into windows, bridging gaps shorter than anneal_sec.
"""
anneal = float(cfg.get("anneal_sec", 10.0))
info = {} # actor_idx -> identity fields
times = {} # actor_idx -> [timestamps]
for sa in annotations:
for a in sa["visible_actors"]:
if a["actor_idx"] < 0:
continue
info[a["actor_idx"]] = a
times.setdefault(a["actor_idx"], []).append(sa["timestamp_sec"])
actors = []
for idx, ts in times.items():
ts.sort()
scenes = []
ws = we = ts[0]
for t in ts[1:]:
if t - we > anneal:
scenes.append([ws, we])
ws = t
we = t
scenes.append([ws, we])
a = info[idx]
actors.append({
"name": a["name"], "imdb_id": a["imdb_id"], "tmdb_id": a["tmdb_id"],
"jellyfin_id": a["jellyfin_id"], "scenes": scenes,
})
return {"schema_version": 1, "movie": movie, "sample_fps": fps,
"anneal_sec": anneal, "actors": actors}
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", "match_threshold", "match_ratio",
"match_ratio_ceil", "track_alpha", "track_min_iou", "track_max_embed_dist",
"track_max_frames_missing", "cut_revive_sim", "cut_inactive_max_frames",
"extinction_sec", "anneal_sec"]
def main():
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--dump", required=True, help="embedding HDF5 dump")
p.add_argument("--gallery", required=True)
p.add_argument("--out", required=True, help="output presence JSON")
p.add_argument("--raw-out", help="also write raw per-frame annotations (JSONL, with bboxes) here")
p.add_argument("--build-dir", default=str(REPO / "build"))
for k in CFG_KEYS:
p.add_argument(f"--{k.replace('_','-')}", type=float, default=None)
# per-film gallery expansion: promotes pose-varied views of confidently-identified
# actors into an in-memory annex, recovering ~+4 recall at no precision cost.
p.add_argument("--expand-gallery", action="store_true")
# TRACES: GR-004 | SR-001
# promote an unprovable gallery/dump binding from a
# loud warning to a hard error. Measurement sweeps should set this (or
# SAE_REQUIRE_GALLERY_STAMP=1) so no number comes from an unbound pair.
p.add_argument("--require-gallery-stamp", action="store_true")
args = p.parse_args()
cfg = {k: getattr(args, k) for k in CFG_KEYS if getattr(args, k) is not None}
if args.expand_gallery:
cfg["expand_gallery"] = True
if args.require_gallery_stamp:
cfg["require_gallery_stamp"] = True
# stop=True: PyNode::stop() sets stop_flag_ before joining, so the source
# thread's run_loop actually exits. stop=False skips that, leaving stop_flag_
# false forever — the PyNode destructor's jthread.join() then blocks forever
# (verified via gdb: stuck in the source node's run_loop, not the GEMM path).
result = replay(args.dump, args.gallery, cfg, args.build_dir, stop=True,
raw_out=args.raw_out)
Path(args.out).write_text(json.dumps(result, indent=2))
print(f"[replay] {len(result['actors'])} actors → {args.out}", file=sys.stderr)
if __name__ == "__main__":
main()