"""Turn flat-background character art into transparent cutouts. Reads the freshly generated char_*.png files, flood-fills the near-uniform studio background from the edges into transparency, auto-crops to the subject, softens the alpha edge, downsizes, and writes optimized RGBA PNGs into assets/. This is what stops the diorama characters from looking like rectangular photo "plates". """ from __future__ import annotations from pathlib import Path from PIL import Image, ImageDraw, ImageFilter SRC_DIR = Path( "/Users/kavyabhand/.cursor/projects/Users-kavyabhand-Desktop-ag/assets" ) OUT_DIR = Path(__file__).resolve().parent.parent / "assets" NAMES = [ "char_crystal_tree", "char_joke_dragon", "char_blind_cartographer", "char_fog_merchant", "char_lantern_fox", "char_thirteen_sighs", "char_silent_gear", "char_disagreeing_reflection", "char_echo_counter", "char_unfed_bonfire", ] MAX_DIM = 640 SENTINEL = (255, 0, 255) # A few subjects were painted with a bright radial aura that blends into the # grey studio backdrop; they need a more aggressive flood-fill threshold. THRESH_OVERRIDES = { "char_lantern_fox": 178, "char_silent_gear": 178, "char_crystal_tree": 150, } def remove_background(img: Image.Image, thresh: int = 118) -> Image.Image: img = img.convert("RGB") w, h = img.size work = img.copy() draw_target = work.load() # noqa: F841 (ensure loaded) seeds = [ (0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), (w // 2, 0), (w // 2, h - 1), (0, h // 2), (w - 1, h // 2), ] for s in seeds: ImageDraw.floodfill(work, s, SENTINEL, thresh=thresh) rgba = img.convert("RGBA") px_work = work.load() px_out = rgba.load() for y in range(h): for x in range(w): if px_work[x, y] == SENTINEL: r, g, b, _ = px_out[x, y] px_out[x, y] = (r, g, b, 0) return rgba def autocrop(img: Image.Image, pad: int = 12) -> Image.Image: alpha = img.split()[-1] bbox = alpha.getbbox() if not bbox: return img left, top, right, bottom = bbox left = max(0, left - pad) top = max(0, top - pad) right = min(img.width, right + pad) bottom = min(img.height, bottom + pad) return img.crop((left, top, right, bottom)) def soften_edges(img: Image.Image) -> Image.Image: r, g, b, a = img.split() # Erode 1px to shave the grey fringe left by the studio vignette, then # feather slightly so edges read as soft cutouts rather than hard plates. a = a.filter(ImageFilter.MinFilter(3)) a = a.filter(ImageFilter.GaussianBlur(0.9)) return Image.merge("RGBA", (r, g, b, a)) def downsize(img: Image.Image) -> Image.Image: w, h = img.size scale = min(1.0, MAX_DIM / max(w, h)) if scale < 1.0: img = img.resize((round(w * scale), round(h * scale)), Image.LANCZOS) return img def main() -> None: for name in NAMES: src = SRC_DIR / f"{name}.png" if not src.exists(): print(f" MISSING {src}") continue img = Image.open(src) img = remove_background(img, THRESH_OVERRIDES.get(name, 118)) img = autocrop(img) img = soften_edges(img) img = downsize(img) out = OUT_DIR / f"{name}.png" img.save(out, "PNG", optimize=True) kb = out.stat().st_size / 1024 print(f" {name}.png {img.size[0]}x{img.size[1]} {kb:.0f}KB") if __name__ == "__main__": main()