big
This commit is contained in:
+183
@@ -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 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())
|
||||
Reference in New Issue
Block a user