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.
This commit is contained in:
2026-07-04 20:42:11 +02:00
parent 65fee74585
commit aca6147d69
+108
View File
@@ -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()