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