# _heads.py — cut the CARD HEAD ICONS from the 4K family sheets. # # uv run --with pillow --with numpy python assets/_heads.py # build all -> _refcheck/heads/ # uv run --with pillow --with numpy python assets/_heads.py --install # -> assets/z_head.png # # WHY. The walk-on card first cropped heads out of the INSTALLED 224 px frames (config.HEAD_CROP): # a ~65 px source head drawn at 144 device px is mush, and the oldest families (sh/bb/st/ru) were # the mushiest — "the first heads in the roster are very low res". The 4K sheets in _refcheck have # the same heads at ~4x the pixels, so the icon is cut THERE and downscaled once, cleanly. # drawCard prefers z_head and degrades to the HEAD_CROP crop, so a family whose 4K sheet is # gone (they are gitignored scratch) simply keeps the old icon. Re-run after re-rolling a family. import os, sys import numpy as np from PIL import Image sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) import _fine from _zsheet import bands HERE = os.path.dirname(os.path.abspath(__file__)) SCRATCH = os.path.join(HERE, '_refcheck') OUT_H = 144 # the card draws the icon at CARD_H-14 = 48 logical px; RES caps at 3 -> 144 device FAMS = ['br', 'sh', 'bb', 'st', 'ru', 'gk', 'lj', 'dr', 'ch', 'ph', 'ma'] # head depth as a fraction of the standing figure, where the shoulder-widening rule fails: # the brute's shoulders start high, the keeper's hat brim is wider than his face, the champion # wears a headwrap, and the mascot's head IS partly the ball. Same knowledge as HEAD_CROP's note. DEPTH = {'br': 0.27, 'gk': 0.30, 'ch': 0.28, 'ma': 0.37} def head_box(a): """Head region of one keyed figure: top of the silhouette down to the shoulder widening.""" op = a[..., 3] > 0 ys = np.nonzero(op.any(1))[0] y0, y1 = ys.min(), ys.max() h = y1 - y0 + 1 width = op.sum(1) med = np.median(width[y0:y0 + max(6, h // 16)][width[y0:y0 + max(6, h // 16)] > 0]) yS = None for y in range(y0 + h // 20, y1): if width[y] > 1.9 * med: yS = y; break yS = yS or y0 + int(h * 0.2) band = op[y0:yS] xs = np.nonzero(band.any(0))[0] pad = max(3, h // 60) return (max(0, xs.min() - pad), max(0, y0 - pad), xs.max() + pad, yS + pad) def build(install=False): outdir = HERE if install else os.path.join(SCRATCH, 'heads') os.makedirs(outdir, exist_ok=True) for k in FAMS: sheet = os.path.join(SCRATCH, f'fam_{k}.png') if not os.path.exists(sheet): print(f'{k}: no 4K sheet — the card keeps its HEAD_CROP fallback'); continue a = _fine.key(Image.open(sheet)) bs = bands(a) band = a[bs[0][0]:bs[0][1]] cols = (band[..., 3] > 0).any(0) x0 = np.nonzero(cols)[0][0] # first figure of the play row gap = 0; x1 = x0 for x in range(x0, len(cols)): gap = gap + 1 if not cols[x] else 0 if gap > 30: x1 = x - gap; break else: x1 = len(cols) fig = band[:, x0:x1] ys = np.nonzero((fig[..., 3] > 0).any(1))[0] fh = ys.max() - ys.min() + 1 bx0, by0, bx1, by1 = head_box(fig) if k in DEPTH: # hand depth beats the widening rule here by1 = ys.min() + int(fh * DEPTH[k]) band2 = (fig[..., 3] > 0)[by0:by1] xs = np.nonzero(band2.any(0))[0] bx0, bx1 = max(0, xs.min() - 5), xs.max() + 5 crop = fig[by0:by1, bx0:bx1] im = Image.fromarray(crop, 'RGBA') s = OUT_H / im.height im = im.resize((max(1, round(im.width * s)), OUT_H), Image.BOX) # quantise to the crop's own palette so the icon stays flat pixel art, not a soft photo arr = np.array(im).astype(int) solid = arr[..., 3] > 110 px = crop[crop[..., 3] > 200][:, :3] pal = np.unique((px // 8) * 8, axis=0) flat = arr[..., :3].reshape(-1, 3) idx = ((flat[:, None, :] - pal[None, :, :]) ** 2).sum(2).argmin(1) arr[..., :3] = pal[idx].reshape(arr.shape[0], arr.shape[1], 3) arr[..., 3] = np.where(solid, 255, 0) out = os.path.join(outdir, f'z_{k}head.png') Image.fromarray(arr.astype('uint8'), 'RGBA').save(out) print(f'{k}: {out} {arr.shape[1]}x{arr.shape[0]}') if __name__ == '__main__': build('--install' in sys.argv)