#!/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()