184 lines
7.2 KiB
Python
184 lines
7.2 KiB
Python
#!/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 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())
|