Spaces:
Running
Running
| """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() | |