Files
dtourolle 6f0ad83a55 feat(tooling): X-Ray threshold optimizer, gallery utilities, artifact registry, docs build
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.
2026-07-19 19:06:48 +02:00

138 lines
5.5 KiB
Python

#!/usr/bin/env python3
"""
convert_transnetv2.py — verify and optimise the TransNetV2 scene-detector model.
The scene detector (src/nodes/scene_detector_node.hpp → ISceneDetector) consumes
the elya5/transnetv2 ONNX export with a fixed input contract:
input "input" : float32 [1, 100, 27, 48, 3] RGB, channels-last, 0-255
output "534" : float32 [1, 100, 1] per-frame boundary logits (used)
output "535" : float32 [1, 100, 1] many-hot head (ignored)
This script provides the ORT/TRT conversion infra for that model:
--verify (default) assert the .onnx matches the contract above and run one
dummy inference through onnxruntime, reporting output ranges.
--ort-opt write a graph-optimised .ort next to the model (ORT loads this
faster; the runtime also caches its own under ./ort_cache).
--trt build a TensorRT engine via trtexec with the pinned 1x100x27x48x3
profile (delegates to scripts/build_trt_engines.sh SCENE_MODEL).
Usage:
python scripts/convert_transnetv2.py # verify default model
python scripts/convert_transnetv2.py --ort-opt
python scripts/convert_transnetv2.py --trt
python scripts/convert_transnetv2.py --model path/to/transnetv2.onnx --verify
"""
import argparse
import os
import subprocess
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DEFAULT_MODEL = os.path.join(ROOT, "models", "transnetv2.onnx")
# The fixed contract the C++ scene detector depends on.
EXPECTED_INPUT_SHAPE = [1, 100, 27, 48, 3]
EXPECTED_OUTPUT_SHAPE = [1, 100, 1]
def verify(model_path: str) -> int:
import numpy as np
import onnx
import onnxruntime as ort
print(f"[verify] loading {model_path}")
m = onnx.load(model_path, load_external_data=False)
g = m.graph
def shape(t):
return [d.dim_value if d.HasField("dim_value") else d.dim_param
for d in t.type.tensor_type.shape.dim]
in_shape = shape(g.input[0])
print(f"[verify] input '{g.input[0].name}': {in_shape}")
if in_shape != EXPECTED_INPUT_SHAPE:
print(f"[verify] ERROR: input shape {in_shape} != {EXPECTED_INPUT_SHAPE}")
return 1
out_names = [o.name for o in g.output]
out0_shape = shape(g.output[0])
print(f"[verify] outputs: {out_names} primary '{out_names[0]}': {out0_shape}")
if out0_shape != EXPECTED_OUTPUT_SHAPE:
print(f"[verify] ERROR: primary output {out0_shape} != {EXPECTED_OUTPUT_SHAPE}")
return 1
# One dummy inference: a mid-grey clip should produce low boundary scores.
sess = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
dummy = np.full(EXPECTED_INPUT_SHAPE, 128.0, dtype=np.float32)
logits = sess.run([out_names[0]], {g.input[0].name: dummy})[0]
probs = 1.0 / (1.0 + np.exp(-logits))
print(f"[verify] dummy inference OK — boundary prob "
f"min={probs.min():.4f} max={probs.max():.4f} mean={probs.mean():.4f}")
print("[verify] contract matches the C++ ISceneDetector. ✓")
return 0
def ort_opt(model_path: str) -> int:
import onnxruntime as ort
out_path = os.path.splitext(model_path)[0] + ".ort"
print(f"[ort-opt] writing graph-optimised model → {out_path}")
so = ort.SessionOptions()
so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
so.optimized_model_filepath = out_path
# Constructing the session triggers optimisation + serialisation.
ort.InferenceSession(model_path, so, providers=["CPUExecutionProvider"])
print(f"[ort-opt] done: {out_path}")
return 0
def trt(model_path: str) -> int:
script = os.path.join(ROOT, "scripts", "build_trt_engines.sh")
print(f"[trt] delegating to {script} (SCENE_MODEL={model_path})")
env = dict(os.environ, SCENE_MODEL=model_path)
# build_trt_engines.sh also builds ArcFace/SCRFD; that's harmless (and a
# useful sanity check), but if you only want the scene engine, run trtexec
# directly with the profile printed below.
print("[trt] equivalent standalone command:")
print(f" trtexec --onnx={model_path} --fp16 "
f"--minShapes=input:1x100x27x48x3 "
f"--optShapes=input:1x100x27x48x3 "
f"--maxShapes=input:1x100x27x48x3 "
f"--saveEngine=trt_cache/transnetv2.100x27x48.fp16.engine --useCudaGraph")
return subprocess.call(["bash", script], env=env)
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--model", default=DEFAULT_MODEL,
help=f"path to transnetv2.onnx (default: {DEFAULT_MODEL})")
ap.add_argument("--verify", action="store_true", help="verify I/O contract (default)")
ap.add_argument("--ort-opt", action="store_true", help="write optimised .ort")
ap.add_argument("--trt", action="store_true", help="build a TensorRT engine")
args = ap.parse_args()
if not os.path.isfile(args.model):
print(f"ERROR: model not found: {args.model}\n"
f"Fetch it with: bash scripts/download_models.sh", file=sys.stderr)
return 2
# Default action is verify when nothing else is requested.
if not (args.ort_opt or args.trt):
args.verify = True
rc = 0
if args.verify:
rc |= verify(args.model)
if rc == 0 and args.ort_opt:
rc |= ort_opt(args.model)
if rc == 0 and args.trt:
rc |= trt(args.model)
return rc
if __name__ == "__main__":
sys.exit(main())