AR-024's register row gives its verification tier as "Static check -- no bare cosine outside a tagged EXCEPTION". No such check existed, so the invariant was enforced by reading, and reading had missed a live violation. scripts/ci/check_raw_cosine.py is that check, wired into the traceability workflow as a blocking step. It is honest about its reach: it catches direct cosine_similarity() uses not routed through a calibration, and it cannot follow a cosine through a variable across statements. That limit is documented in the script rather than left for someone to discover after trusting a pass. What it caught, and what this commit removes with it: The identity matcher's no-calibration fallback thresholded raw cosine distance (match_threshold) plus a ratio test (match_ratio, match_ratio_ceil). Worse than the invariant breach: it fed max(0, cosine) into TrackRegistry::observe, whose contract reads "posterior is a calibrated probability, never a raw cosine (AR-024) ... so the accumulation cannot be fed an uncalibrated number by a careless caller". It could, and did. And it disagreed with the rest of the pipeline about what "the fit failed" means -- same_person_probability answers that with the untuned default sigmoid and a loud warning, so association stayed in probability space while matching alone left it. One run, two policies, no announcement. Now one rule: cal_.probability() always, with a warning when the fit is not real. A worse answer than a fitted calibration, a better one than a number whose units nothing else shares. TrackGallery::set_calibration is mandatory for the same reason. Its default was max(0, cosine), which made expand_band_lo = 0.90 mean "cosine > 0.9" in a test and "P(same person) > 0.9" in production. FaceTrackerFunc already threw without one; the expansion store now matches. One exception is recorded, in the calibration's own dedup. It is not a close call: at 1 - 1e-7 it asks whether two vectors are the same vector, and it runs on the fit's input, so a calibrated comparison there would have to be calibrated by the fit it is feeding. Also drops seven dead keys from the optimizer's CFG_KEYS. Config keys are read with a contains() check, so each one had been silently inert since the field behind it was deleted -- a sweep varying one of them measured nothing and reported an ordinary-looking F1. TRACES: AR-024, AR-023 | SR-002
305 lines
14 KiB
Python
305 lines
14 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"][:]
|
||
# TRACES: AR-028 | SR-002
|
||
# The quality vector, present from schema v2. A v1 dump predates AR-028
|
||
# and simply has no such dataset — read as absent, never as a default,
|
||
# so a face from an old dump stays at the C++ -1 "unscored" sentinel
|
||
# rather than acquiring a fabricated sharpness of 0 (which is a real
|
||
# value on this axis, meaning a featureless crop).
|
||
qual = {k: f[f"faces/{k}"][:] for k in ("sharpness", "alignment_residual")
|
||
if f"faces/{k}" in f}
|
||
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),
|
||
**{k: np.ascontiguousarray(v[keep][sel], dtype=np.float32)
|
||
for k, v in qual.items()},
|
||
})
|
||
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),
|
||
**{k: np.ascontiguousarray(v[keep], dtype=np.float32)
|
||
for k, v in qual.items()},
|
||
})
|
||
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}
|
||
|
||
|
||
# TRACES: AR-024 | SR-002
|
||
# Keys the C++ Config actually still has. Seven names were removed here, all of
|
||
# them accepted silently for months after the fields behind them were deleted:
|
||
#
|
||
# match_threshold, match_ratio, match_ratio_ceil — the raw-cosine accept
|
||
# fallback, retired with AR-024's enforcement.
|
||
# track_max_embed_dist, cut_revive_sim — raw cosines, retired
|
||
# earlier by AR-024 when association moved into probability space.
|
||
# track_max_frames_missing, cut_inactive_max_frames — frame counts whose
|
||
# meaning changed with sample_fps, retired by AR-008/AR-013 in favour of
|
||
# track_extinction_sec.
|
||
#
|
||
# A sweep that varied one of these was measuring nothing, and reported a
|
||
# perfectly ordinary-looking F1 for its trouble. kpn_bindings.cpp reads config
|
||
# keys with a contains() check, so an unknown key is not an error — which makes
|
||
# a stale entry here silently inert rather than loudly wrong.
|
||
CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior",
|
||
"track_alpha", "track_min_iou", "track_assoc_min_prob",
|
||
"track_extinction_sec",
|
||
"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()
|