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.
109 lines
4.1 KiB
Python
109 lines
4.1 KiB
Python
#!/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()
|