Files
2026-08-11 09:53:42 -04:00

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()