aether-garden / scripts /cutout_sprites.py
kavyabhand's picture
Deploy Aether Garden — full feature build
b3ca9de verified
Raw
History Blame Contribute Delete
3.52 kB
"""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()