#!/usr/bin/env python """Tone statistics for captured frames. "It looks flat" is not actionable. "42% of the frame is one shade and the adjacent-pixel detail is 1.2/255" is — you can watch that number move when you add a panel-line law, and you can tell the difference between a change that helped and a change you merely believe helped. Run after any capture tool that writes PNGs: python tools/levels.py ... [--inset 12] [--gate] Columns mean p1 p50 p99 luma percentiles, 0-255. p99 is the practical white point; if it sits at 255 the frame is clipping, if it sits at 140 the frame never reaches white and reads washed out. clip% pixels >= 250. Blown highlights. Our tonemap history: a LINEAR tonemap clipped lit whites and the glow pass bloomed the clip, so every facade glowed like porcelain. This is the number that catches that happening again. black% pixels <= 7. Crushed shadows, no detail recoverable. spread p99 - p1. Total tonal range in use. dead% share of the frame held by the single most common luma. A featureless sky filling the top half shows up here. detail mean |luma difference| between horizontally adjacent pixels. THE flatness metric: untextured flat-shaded planes score near zero, surface detail (seams, wear, grain) moves it up. Cel art should be low but not dead. sat / satp99 mean and 99th-percentile HSV saturation. Guards the other direction: we shifted authored hues once by grading saturation to 1.3 and ginger hair rendered fire-truck red. The HUD is bright, saturated and pinned to the frame edges, so it drags every statistic toward itself. --inset trims that percentage off each edge, which is what you want whenever you are measuring the 3D render rather than the screen. """ import argparse import os import sys try: from PIL import Image, ImageChops except ImportError: sys.exit("levels.py needs Pillow: python -m pip install pillow") # --gate thresholds. These are not taste — they are the failure modes this # project has actually shipped and had to walk back, written down as numbers so # the next one gets caught by a script instead of by the user's eye. GATES = { "clip": (2.0, "highlights blowing out — the glow pass will bloom the clip"), "black": (35.0, "shadows crushed to pure black, no form left in them"), # Cel art deliberately carries broad flat fills. These limits are tuned to # the deterministic probe: the old clear-colour sky hit 28–35% dead and # ~0.34 detail, while the authored sky + surface laws stay under 40% dead # and above 0.37 detail. Real city shots carry substantially more detail. "dead": (40.0, "nearly half the frame is one flat shade"), "detail": (0.37, "surface detail at the old greybox baseline"), "satp99": (250.0, "saturation railed; authored hues are being shifted"), } def _percentile(hist, total, frac): """Value at `frac` of the population, from a 256-bin histogram.""" target = total * frac run = 0 for value, count in enumerate(hist): run += count if run >= target: return value return 255 def measure(path, inset_pct=0): img = Image.open(path).convert("RGB") if inset_pct: w, h = img.size dx, dy = int(w * inset_pct / 100.0), int(h * inset_pct / 100.0) img = img.crop((dx, dy, w - dx, h - dy)) luma = img.convert("L") hist = luma.histogram() total = sum(hist) # Adjacent-pixel difference: the image against itself shifted one pixel # left. Done with ImageChops so the per-pixel work stays in C — a Python # loop over a 1280x720 frame is a second per metric per shot. w, h = luma.size shifted = ImageChops.difference(luma.crop((1, 0, w, h)), luma.crop((0, 0, w - 1, h))) dhist = shifted.histogram() dtotal = sum(dhist) detail = sum(v * c for v, c in enumerate(dhist)) / max(dtotal, 1) sat = img.convert("HSV").getchannel(1) shist = sat.histogram() stotal = sum(shist) p1 = _percentile(hist, total, 0.01) p99 = _percentile(hist, total, 0.99) return { "shot": os.path.basename(path), "mean": sum(v * c for v, c in enumerate(hist)) / max(total, 1), "p1": p1, "p50": _percentile(hist, total, 0.50), "p99": p99, "clip": 100.0 * sum(hist[250:]) / max(total, 1), "black": 100.0 * sum(hist[:8]) / max(total, 1), "spread": p99 - p1, "dead": 100.0 * max(hist) / max(total, 1), "detail": detail, "sat": sum(v * c for v, c in enumerate(shist)) / max(stotal, 1), "satp99": _percentile(shist, stotal, 0.99), } HEADER = "{:<22} {:>6} {:>4} {:>4} {:>4} {:>6} {:>7} {:>7} {:>6} {:>7} {:>6} {:>7}" ROW = "{shot:<22} {mean:>6.1f} {p1:>4.0f} {p50:>4.0f} {p99:>4.0f} {clip:>6.2f} {black:>7.2f} {spread:>7.0f} {dead:>6.2f} {detail:>7.2f} {sat:>6.1f} {satp99:>7.0f}" COLS = ("shot", "mean", "p1", "p50", "p99", "clip%", "black%", "spread", "dead%", "detail", "sat", "satp99") def collect(targets): paths = [] for t in targets: if os.path.isdir(t): paths += [os.path.join(t, f) for f in sorted(os.listdir(t)) if f.lower().endswith(".png")] else: paths.append(t) return paths def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("targets", nargs="+", help="PNG files or directories of them") ap.add_argument("--inset", type=float, default=0, help="trim this %% off each edge before measuring (HUD lives there)") ap.add_argument("--gate", action="store_true", help="exit non-zero and name the frames that trip a threshold") args = ap.parse_args() paths = collect(args.targets) if not paths: sys.exit("levels.py: no PNGs found in %s" % ", ".join(args.targets)) print(HEADER.format(*COLS)) rows = [measure(p, args.inset) for p in paths] for r in rows: print(ROW.format(**r)) if len(rows) > 1: avg = {k: sum(r[k] for r in rows) / len(rows) for k in rows[0] if k != "shot"} avg["shot"] = "AVERAGE (%d)" % len(rows) print("-" * 100) print(ROW.format(**avg)) if not args.gate: return 0 failures = [] for r in rows: for key, (limit, why) in GATES.items(): # detail is a floor, everything else is a ceiling. bad = r[key] < limit if key == "detail" else r[key] > limit if bad: failures.append("%s: %s=%.2f (%s %.2f) — %s" % (r["shot"], key, r[key], "below" if key == "detail" else "over", limit, why)) print() if failures: print("GATE FAILED") for f in failures: print(" " + f) return 1 print("GATE PASSED — %d frames within thresholds" % len(rows)) return 0 if __name__ == "__main__": sys.exit(main())