Adds TRACES tags to code that already satisfies a Done requirement, so coverage reflects what exists rather than starting from zero: AR-001 face detection, AR-005 ArcFace alignment, AR-023 calibration fit, DP-001/DP-002 the single analysis core behind the CLI, IR-001 truth-file emission, IR-006 the Jellyfin round trip, GR-001/GR-002 gallery build and incremental merge, VR-001 the embedding dump, VR-002 replay through the real nodes, VR-003 per-second scoring. Only Done requirements are tagged. A tag on Planned work would inflate coverage with fiction that looks plausible — the same failure family as a gate that cannot fail, and harder to spot. GR-005 (gallery never leaves the instance) stays untagged deliberately: it is a prohibition satisfied by the absence of an egress path, so there is no unit that decides it. Same shape as PR-005 in the system spec, which has no software row for the same reason. A goal held only by prohibitions cannot be verified by pointing at code. Coverage 5/63 to 14/63. The three VR tags are reported as tagged-but-unexecuted and excluded from the numerator, since their tier cannot run on the CI host — tagging deliberately cannot raise the number on its own. Suite still 64 cases, 3199 assertions. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> TRACES: AR-001, AR-005, AR-023, DP-001, DP-002, IR-001, IR-006, GR-001, GR-002, VR-001, VR-002, VR-003
275 lines
12 KiB
Python
275 lines
12 KiB
Python
#!/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.5–1%, 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()
|