From aca6147d69518ca2e88d4cd5c3066ed68a5ac7c6 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 4 Jul 2026 20:42:11 +0200 Subject: [PATCH] feat(scripts): add scene-gap histogram tool scene_gap_hist.py scans scene_analyze output JSONs and, for every actor, computes the gap (next_scene_start - prev_scene_end) between consecutive scenes, emitting a text histogram of the distribution. Used to inform the anneal_sec default. --- scripts/scene_gap_hist.py | 108 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 scripts/scene_gap_hist.py diff --git a/scripts/scene_gap_hist.py b/scripts/scene_gap_hist.py new file mode 100644 index 0000000..8c5a54b --- /dev/null +++ b/scripts/scene_gap_hist.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +"""scene_gap_hist.py — histogram of the gap (seconds) between an actor's consecutive scenes. + +Scans scene_analyze output JSONs and, for every actor in every file, computes +next_scene_start - prev_scene_end between consecutive scenes. Prints a text +histogram plus summary percentiles — useful for picking an --anneal value +(gaps below anneal get merged into one window). + +Note: each input was produced with some anneal_sec already, so gaps smaller +than that file's anneal were merged away before they reached this script. The +distribution here is therefore of the *surviving* gaps; run with --by-anneal to +see which anneal settings your files used. + +Usage: + python scripts/scene_gap_hist.py *.json + python scripts/scene_gap_hist.py --bin 2 --max 60 *.json + python scripts/scene_gap_hist.py --by-anneal *.json +""" + +import argparse +import json +import sys +from collections import Counter +from pathlib import Path + + +def iter_gaps(path: Path): + """Yield each within-actor gap (seconds) from one output JSON.""" + try: + data = json.loads(path.read_text()) + except (OSError, ValueError) as e: + print(f" [skip] {path}: {e}", file=sys.stderr) + return + if not isinstance(data, dict) or "actors" not in data: + return # not a scene_analyze output (e.g. gallery.missing_images.json) + for actor in data.get("actors", []): + scenes = actor.get("scenes", []) + # Scenes are [start, end]; sort defensively in case they aren't ordered. + scenes = sorted((s for s in scenes if len(s) == 2), key=lambda s: s[0]) + for (prev, cur) in zip(scenes, scenes[1:]): + gap = cur[0] - prev[1] + if gap >= 0: # negative would mean overlapping windows; ignore + yield gap + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("files", nargs="+", help="scene_analyze output JSON files") + ap.add_argument("--bin", type=float, default=1.0, help="histogram bin width in seconds (default: 1)") + ap.add_argument("--max", type=float, default=60.0, + help="gaps >= this go in a final overflow bucket (default: 60)") + ap.add_argument("--width", type=int, default=60, help="max bar width in chars (default: 60)") + ap.add_argument("--by-anneal", action="store_true", + help="also report how many files used each anneal_sec value") + args = ap.parse_args() + + paths = [Path(f) for f in args.files] + + if args.by_anneal: + anneal = Counter() + for p in paths: + try: + d = json.loads(p.read_text()) + except (OSError, ValueError): + continue + if isinstance(d, dict) and "actors" in d: + anneal[d.get("anneal_sec")] += 1 + print("anneal_sec used across files:") + for val, n in sorted(anneal.items(), key=lambda kv: (kv[0] is None, kv[0])): + print(f" {val}: {n} file(s)") + print() + + gaps = [g for p in paths for g in iter_gaps(p)] + if not gaps: + sys.exit("No gaps found (need actors with 2+ scenes).") + + bins = Counter() + for g in gaps: + if g >= args.max: + bins["overflow"] += 1 + else: + bins[int(g // args.bin)] += 1 + + peak = max(bins.values()) + n = len(gaps) + print(f"{n} gaps across {len(paths)} file(s), bin={args.bin}s\n") + n_bins = int(args.max // args.bin) + for b in range(n_bins): + lo = b * args.bin + hi = lo + args.bin + c = bins.get(b, 0) + bar = "#" * round(c / peak * args.width) + print(f"{lo:6.1f}–{hi:<6.1f} {c:6d} {c/n*100:5.1f}% {bar}") + ov = bins.get("overflow", 0) + if ov: + bar = "#" * round(ov / peak * args.width) + print(f">={args.max:<11.1f} {ov:6d} {ov/n*100:5.1f}% {bar}") + + gaps.sort() + def pct(p): + return gaps[min(len(gaps) - 1, int(p / 100 * len(gaps)))] + print(f"\nmin={gaps[0]:.1f} p50={pct(50):.1f} p75={pct(75):.1f} " + f"p90={pct(90):.1f} p95={pct(95):.1f} p99={pct(99):.1f} max={gaps[-1]:.1f}") + + +if __name__ == "__main__": + main()