← Commit history

hero: flood-fill knockout (preserves surfaces the naive colour key destroyed)

adonis ·8bc0a07f7e ·1mo ago ·parent 9d6a921
4 files changed +58
board-icon.pngadded
⋯ 1 unchanged line ⋯
docs/board-3d.pngadded
⋯ 1 unchanged line ⋯
screenshots/hero.png
⋯ 1 unchanged line ⋯
tools_knockout.pyadded+58
@@ -0,0 +1,58 @@+#!/usr/bin/env python3+"""Border-seeded flood-fill knockout (per transparent-hero-renders.md).+Numpy-vectorised BFS: same algorithm, fast. Never a global colour key."""+import sys, numpy as np+from PIL import Image++SRC, DOC, ICON = sys.argv[1], sys.argv[2], sys.argv[3]+FILL_TOL, T1 = 12, 30++im = Image.open(SRC).convert("RGB")+a = np.asarray(im).astype(np.int16)+h, w, _ = a.shape+corners = [tuple(a[2,2]), tuple(a[2,w-3]), tuple(a[h-3,2]), tuple(a[h-3,w-3])]+KEY = np.array(corners[0], dtype=np.int16)+print("corners:", corners, "| flat:", len(set(corners)) == 1)++near = (np.abs(a - KEY).max(axis=2) <= FILL_TOL)      # candidate background++# border-seeded flood fill, vectorised BFS over `near`+bg = np.zeros((h, w), bool)+seed = np.zeros((h, w), bool)+seed[0,:] = seed[-1,:] = True; seed[:,0] = seed[:,-1] = True+frontier = near & seed+bg |= frontier+while frontier.any():+    nxt = np.zeros((h, w), bool)+    nxt[1:,:]  |= frontier[:-1,:]+    nxt[:-1,:] |= frontier[1:,:]+    nxt[:,1:]  |= frontier[:,:-1]+    nxt[:,:-1] |= frontier[:,1:]+    frontier = nxt & near & ~bg+    bg |= frontier++# fringe = non-bg pixels 8-adjacent to bg+nb = np.zeros((h, w), bool)+for dy in (-1,0,1):+    for dx in (-1,0,1):+        if dy == dx == 0: continue+        nb[max(0,dy):h+min(0,dy), max(0,dx):w+min(0,dx)] |= bg[max(0,-dy):h+min(0,-dy), max(0,-dx):w+min(0,-dx)]+fringe = nb & ~bg++out = np.zeros((h, w, 4), np.uint8)+solid = ~bg & ~fringe+out[solid, :3] = a[solid]; out[solid, 3] = 255++d = np.abs(a - KEY).max(axis=2).astype(np.float32)+al = np.clip(d / T1, 0, 1)+af = np.maximum(al, 1e-3)[..., None]+un = KEY + (a - KEY) / af                              # un-premultiply the blend+un = np.clip(un, 0, 255)+out[fringe, :3] = un[fringe].astype(np.uint8)+out[fringe, 3]  = (al[fringe] * 255).round().astype(np.uint8)++img = Image.fromarray(out, "RGBA")+img.save(DOC)+img.crop(img.getbbox()).save(ICON)+ex = img.getchannel("A").getextrema()+print(f"bg px {int(bg.sum())} | fringe {int(fringe.sum())} | alpha {ex} | doc {img.size} | icon {img.crop(img.getbbox()).size}")