Files
dtourolle aca6147d69 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.
2026-07-04 20:42:11 +02:00

109 lines
4.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()