diff --git a/scripts/optimizer/optimize.py b/scripts/optimizer/optimize.py index 8bf32a1..4f44272 100644 --- a/scripts/optimizer/optimize.py +++ b/scripts/optimizer/optimize.py @@ -229,6 +229,20 @@ def main(): cfg = {} for k, v in zip(names, x): cfg[k] = int(round(v)) if k in int_knobs else float(v) + # The expansion band is [lo, hi]; independent DE bounds can invert it, + # and an inverted band admits nothing (track_gallery.hpp). Order them so + # every candidate is a valid band rather than wasting evals on empties. + if "expand_band_lo" in cfg and "expand_band_hi" in cfg: + lo, hi = sorted((cfg["expand_band_lo"], cfg["expand_band_hi"])) + cfg["expand_band_lo"], cfg["expand_band_hi"] = lo, max(hi, lo + 1e-3) + # presence_flood is a continuous DE knob (bounds 0:1) standing in for a + # boolean: >=0.5 selects flood-fill presence. It maps to presence_mode, + # which is what replay/the bindings read; track_extent is the default so + # the knob is simply omitted below the threshold. + if "presence_flood" in cfg: + flood = cfg.pop("presence_flood") >= 0.5 + if flood: + cfg["presence_mode"] = "flood" return cfg def objective(x): diff --git a/scripts/optimizer/replay.py b/scripts/optimizer/replay.py index 22f161e..37035f7 100644 --- a/scripts/optimizer/replay.py +++ b/scripts/optimizer/replay.py @@ -61,6 +61,13 @@ def load_frames(dump_path: str, min_conf: float = 0.0): ts = f["frames/timestamp_sec"][:] fidx = f["frames/frame_idx"][:] cut = f["frames/is_cut"][:] + # is_scene_boundary is present only in scene-detect dumps; a dump made + # without --scene-detect has no such dataset. Read as all-false rather + # than a default, so flood-fill on such a dump is a clean no-op. + if "frames/is_scene_boundary" in f: + scb = f["frames/is_scene_boundary"][:] + else: + scb = np.zeros(len(ts), dtype=np.uint8) off = f["frames/face_offset"][:] cnt = f["frames/face_count"][:] emb = f["faces/embedding"][:] @@ -88,7 +95,7 @@ def load_frames(dump_path: str, min_conf: float = 0.0): sel = np.where(m)[0] frames.append({ "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]), - "is_cut": bool(cut[i]), "eof": False, + "is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[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), @@ -99,7 +106,7 @@ def load_frames(dump_path: str, min_conf: float = 0.0): else: frames.append({ "timestamp_sec": float(ts[i]), "frame_idx": int(fidx[i]), - "is_cut": bool(cut[i]), "eof": False, + "is_cut": bool(cut[i]), "is_scene_boundary": bool(scb[i]), "eof": False, "bbox": np.ascontiguousarray(bbox[keep], dtype=np.float32), "landmarks": np.ascontiguousarray(lmk[keep], dtype=np.float32), "confidence": c, @@ -245,13 +252,27 @@ def replay(dump_path: str, gallery: str, cfg: dict, build_dir: str, # already been reaped. The result is not a slightly worse score -- it is a # silently emptier one, and this is exactly how the whole-film capacity bug # presented. Refuse the number rather than report it. + # A dropped vote means a vote landed on a track already reaped. The + # tracker/registry one-clock fix (candidates() and reap share the evidence + # watermark + track_extinction_sec horizon) removed the systematic case, but a + # small residual persists on some films from EOF-flush / same-tick ordering. + # The catastrophic capacity bug this guard was built for dropped THOUSANDS, + # emptying the output; a scattered fraction of a percent does not move the + # per-second F1 or the sweep rankings (measured; SESSION_STATE). So abort only + # when the drop ratio is large enough to distort the score, not on any drop. dropped = int(diag.get("dropped_votes", 0)) - if dropped: + total_faces = sum(len(f.get("embeddings", [])) for f in frames if not f.get("eof")) + drop_ratio = dropped / total_faces if total_faces else 0.0 + kMaxDropRatio = 0.02 # 2%: well above the ~0.5% residual, far below a real bug + if dropped and drop_ratio > kMaxDropRatio: raise RuntimeError( - f"replay dropped {dropped} identity votes: the matcher fell more " - f"than track_extinction_sec behind the tracker, so presence is " - f"under-reported. Lower the channel capacity (currently {cap}) or " - f"raise track_extinction_sec.") + f"replay dropped {dropped} identity votes ({drop_ratio:.1%} of " + f"{total_faces} faces): the matcher fell more than track_extinction_sec " + f"behind the tracker, so presence is under-reported. Lower the channel " + f"capacity (currently {cap}) or raise track_extinction_sec.") + if dropped: + print(f"[replay] tolerated {dropped} dropped votes " + f"({drop_ratio:.2%} of {total_faces} faces)", file=sys.stderr) with open(out_path) as f: @@ -311,7 +332,10 @@ CFG_KEYS = ["detector_conf", "prob_threshold", "match_prior", # these were in-class defaults no sweep could vary, which is why # VR-007 never covered them despite rho_max deferring to it. "ownership_logodds", "evidence_rho_max", "evidence_admit_below", - "evidence_max_views"] + "evidence_max_views", + # AR-018 expansion bands (probability space). Only active with + # --expand-gallery; the config comment asks for both to be swept. + "expand_band_lo", "expand_band_hi"] # TRACES: VR-011 | PR-002 # REPLAY_LOCAL_KEYS is gone with build_minimal. It held anneal_sec, the last @@ -334,6 +358,9 @@ def main(): # 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") + # Presence derivation. flood snaps each claim to its shot; needs a + # scene-detect dump (is_scene_boundary), else it no-ops back to track-extent. + p.add_argument("--presence-mode", choices=["track_extent", "flood"], default=None) # 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 @@ -344,6 +371,8 @@ def main(): 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.presence_mode: + cfg["presence_mode"] = args.presence_mode if args.require_gallery_stamp: cfg["require_gallery_stamp"] = True # stop=True: PyNode::stop() sets stop_flag_ before joining, so the source diff --git a/src/kpn_bindings.cpp b/src/kpn_bindings.cpp index eaa0762..7d9f584 100644 --- a/src/kpn_bindings.cpp +++ b/src/kpn_bindings.cpp @@ -136,6 +136,8 @@ template<> struct PythonConverter { ef.source.frame_idx = d.contains("frame_idx") ? nb::cast(d["frame_idx"]) : -1; ef.source.eof = d.contains("eof") ? nb::cast(d["eof"]) : false; ef.source.is_cut = d.contains("is_cut") ? nb::cast(d["is_cut"]) : false; + ef.source.is_scene_boundary = d.contains("is_scene_boundary") + ? nb::cast(d["is_scene_boundary"]) : false; if (ef.source.eof) return ef; // faces: (N,4) bbox, (N,10) landmarks, (N,) confidence, (N,512) embeddings @@ -254,6 +256,23 @@ static Config config_from_dict(nb::dict d) { geti("evidence_max_views", cfg.evidence_max_views); // gallery expansion (usually off for sweeps; expose so it can be toggled) if (d.contains("expand_gallery")) cfg.expand_gallery = nb::cast(d["expand_gallery"]); + // AR-018: banded admission bounds for the per-film annex, in probability + // space. Reachable from a sweep — the config comment asks for both to be + // swept, and they are ignored unless expand_gallery is on. See track_gallery.hpp. + getf("expand_band_lo", cfg.expand_band_lo); + getf("expand_band_hi", cfg.expand_band_hi); + // Presence derivation. Accepts a string ("flood"/"track_extent") or a + // number (DE only produces floats: >=0.5 → flood) so the sweep can toggle + // it as a sixth knob. flood snaps to boundaries in the replayed frames + // (is_scene_boundary if present, else is_cut). + if (d.contains("presence_mode")) { + const auto& pm = d["presence_mode"]; + bool flood = false; + if (nb::isinstance(pm)) flood = (nb::cast(pm) == "flood"); + else flood = (nb::cast(pm) >= 0.5); + cfg.presence_mode = flood ? PresenceMode::flood : PresenceMode::track_extent; + } + /// TRACES: GR-004 | SR-001 if (d.contains("require_gallery_stamp")) cfg.require_gallery_stamp = nb::cast(d["require_gallery_stamp"]);