161 lines
6.0 KiB
Python
161 lines
6.0 KiB
Python
#!/usr/bin/env python
|
|
"""Render card artwork at several widths to find where detail actually breaks.
|
|
|
|
The masters are scalable vectors, so there is no single "real" size. This
|
|
renders each input at a range of widths and writes a comparison sheet, plus a
|
|
grit metric per size to back up the eyeball test.
|
|
|
|
Accepts PNG or SVG (SVG needs cairosvg; otherwise pass a rendered PNG).
|
|
|
|
Usage:
|
|
./.venv/bin/python size_test.py ../concepts/dt-court-qh-v3.png
|
|
./.venv/bin/python size_test.py master.svg --widths 110 220 440 1050
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import sys
|
|
|
|
import numpy as np
|
|
from PIL import Image
|
|
|
|
# 110 = temporary web demo. 220/440 = 2x/4x. 1050 = 2.5in at 300dpi
|
|
# (poker card width in print). None of these is a committed target.
|
|
DEFAULT_WIDTHS = [110, 220, 440, 1050]
|
|
ASPECT = 7 / 5.0
|
|
|
|
|
|
CHROME = ("/Applications/Google Chrome.app/Contents/MacOS/Google Chrome")
|
|
|
|
|
|
def render_svg(path, width):
|
|
"""Rasterise an SVG natively at `width` using headless Chrome.
|
|
|
|
Vector art must be rendered at each target size, not downsampled from one
|
|
big raster — downsampling hides exactly the thin-stroke dropout this test
|
|
exists to find. cairosvg would also work but needs native cairo, which is
|
|
not present here.
|
|
"""
|
|
import subprocess
|
|
import tempfile
|
|
|
|
if not os.path.exists(CHROME):
|
|
sys.exit("need headless Chrome (or a rendered PNG) to rasterise SVG")
|
|
# Chrome clamps very small --window-size values, so asking for a 110px
|
|
# window silently renders something else. Instead render at the SVG's
|
|
# intrinsic box and scale with a fractional device-scale-factor, which
|
|
# rasterises the vector natively at the target pixel size.
|
|
base_w, base_h = intrinsic_size(path)
|
|
scale = width / float(base_w)
|
|
# Chrome also clamps device-scale-factor to a 0.5 floor. Below that,
|
|
# rasterise at the floor and LANCZOS the rest of the way down. Noted here
|
|
# because those sizes are then not purely native rendering.
|
|
downsample_to = None
|
|
if scale < 0.5:
|
|
downsample_to = (width, int(round(width * ASPECT)))
|
|
scale = 0.5
|
|
with tempfile.TemporaryDirectory() as td:
|
|
out = os.path.join(td, "out.png")
|
|
subprocess.run([
|
|
CHROME, "--headless=new", "--disable-gpu", "--hide-scrollbars",
|
|
"--force-device-scale-factor=%.6f" % scale,
|
|
"--default-background-color=FFFFFFFF",
|
|
"--window-size=%d,%d" % (base_w, base_h),
|
|
"--screenshot=" + out, "file://" + os.path.abspath(path),
|
|
], capture_output=True, timeout=120)
|
|
if not os.path.exists(out):
|
|
sys.exit("Chrome failed to rasterise %s" % path)
|
|
im = Image.open(out).convert("RGB")
|
|
if downsample_to:
|
|
im = im.resize(downsample_to, Image.LANCZOS)
|
|
return im
|
|
|
|
|
|
def intrinsic_size(path):
|
|
"""Read width/height (or viewBox) from the SVG root element."""
|
|
import re
|
|
head = open(path, "r", encoding="utf-8").read(4000)
|
|
w = re.search(r'\bwidth="([\d.]+)"', head)
|
|
h = re.search(r'\bheight="([\d.]+)"', head)
|
|
if w and h:
|
|
return int(float(w.group(1))), int(float(h.group(1)))
|
|
vb = re.search(r'viewBox="[\d.\-]+ +[\d.\-]+ +([\d.]+) +([\d.]+)"', head)
|
|
if vb:
|
|
return int(float(vb.group(1))), int(float(vb.group(2)))
|
|
return 500, 700
|
|
|
|
|
|
def load(path):
|
|
"""Return a callable (width) -> RGB image at that width."""
|
|
if path.lower().endswith(".svg"):
|
|
return lambda w: render_svg(path, w)
|
|
im = Image.open(path).convert("RGB")
|
|
return lambda w: im.resize((w, int(round(w * ASPECT))), Image.LANCZOS)
|
|
|
|
|
|
def grit(im):
|
|
"""Fraction of pixels that are high-frequency noise rather than flat field.
|
|
|
|
Ornament that has collapsed shows up as many isolated mid-tone pixels: high
|
|
local variance with no coherent edge. Lower is cleaner.
|
|
"""
|
|
a = np.asarray(im.convert("L")).astype(float)
|
|
if min(a.shape) < 5:
|
|
return float("nan")
|
|
# local mean / variance over 3x3
|
|
k = np.ones((3, 3)) / 9.0
|
|
pad = np.pad(a, 1, mode="edge")
|
|
mean = sum(pad[i:i + a.shape[0], j:j + a.shape[1]] * k[i, j]
|
|
for i in range(3) for j in range(3))
|
|
sq = sum((pad[i:i + a.shape[0], j:j + a.shape[1]] ** 2) * k[i, j]
|
|
for i in range(3) for j in range(3))
|
|
var = np.maximum(sq - mean ** 2, 0)
|
|
mid = (a > 40) & (a < 215) # neither ink nor paper
|
|
return float((mid & (var > 900)).mean())
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("images", nargs="+")
|
|
ap.add_argument("--widths", type=int, nargs="+", default=DEFAULT_WIDTHS)
|
|
ap.add_argument("--out", default="size-test.png")
|
|
ap.add_argument("--bg", default="#0b6b3a")
|
|
args = ap.parse_args()
|
|
|
|
widths = sorted(args.widths)
|
|
srcs = [(os.path.basename(p), load(p)) for p in args.images]
|
|
|
|
print("%-28s %s" % ("image", " ".join("%6dpx" % w for w in widths)))
|
|
scaled = []
|
|
for name, at_width in srcs:
|
|
row, metrics = [], []
|
|
for w in widths:
|
|
t = at_width(w)
|
|
if t.size != (w, int(round(w * ASPECT))):
|
|
t = t.resize((w, int(round(w * ASPECT))), Image.LANCZOS)
|
|
row.append(t)
|
|
metrics.append(grit(t))
|
|
scaled.append(row)
|
|
print("%-28s %s" % (name, " ".join("%7.3f" % m for m in metrics)))
|
|
print("\ngrit = fraction of high-variance mid-tone pixels. It is a rough "
|
|
"comparison aid,\nnot a quality score — always inspect the sheet by "
|
|
"eye as well.")
|
|
|
|
# Comparison sheet: every size shown at its true pixel size, top-aligned.
|
|
pad = 14
|
|
disp_h = int(round(widths[-1] * ASPECT))
|
|
row_h = disp_h + pad
|
|
sheet_w = sum(w + pad for w in widths) + pad
|
|
sheet = Image.new("RGB", (sheet_w, row_h * len(srcs) + pad), args.bg)
|
|
for r, row in enumerate(scaled):
|
|
x = pad
|
|
for t in row:
|
|
sheet.paste(t, (x, pad + r * row_h))
|
|
x += t.width + pad
|
|
sheet.save(args.out)
|
|
print("wrote %s (%dx%d)" % (args.out, sheet.width, sheet.height))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|