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:
@@ -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()
|
||||
Reference in New Issue
Block a user