#!/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()