This commit is contained in:
Nicholas Butzke
2026-08-02 02:20:02 -04:00
parent 61669627db
commit 922983429e
226 changed files with 34032 additions and 18521 deletions
+183
View File
@@ -0,0 +1,183 @@
#!/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 <dir-or-png>... [--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 2835% 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())