Files
aetherbound-guild/tools/trim_transparent_assets.py
T

106 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""Crop PNG assets to their visible alpha bounds with deterministic padding."""
from __future__ import annotations
import argparse
from collections import deque
from pathlib import Path
from PIL import Image
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("input_dir", type=Path)
parser.add_argument("output_dir", type=Path)
parser.add_argument("--padding", type=int, default=8)
parser.add_argument(
"--alpha-threshold",
type=int,
default=1,
help="ignore alpha values below this threshold when finding visible bounds",
)
parser.add_argument(
"--despill-green",
action="store_true",
help="remove residual green-dominant chroma pixels before trimming",
)
return parser.parse_args()
def despill_green(image: Image.Image) -> Image.Image:
rgba = image.convert("RGBA")
cleaned: list[tuple[int, int, int, int]] = []
for red, green, blue, alpha in rgba.getdata():
if alpha == 0 or (green - red > 10 and green - blue > 10):
cleaned.append((0, 0, 0, 0))
else:
cleaned.append((red, green, blue, alpha))
rgba.putdata(cleaned)
return rgba
def remove_edge_white_lines(image: Image.Image) -> Image.Image:
"""Remove near-white grid seams connected to the crop boundary."""
rgba = image.convert("RGBA")
pixels = rgba.load()
width, height = rgba.size
pending: deque[tuple[int, int]] = deque()
visited: set[tuple[int, int]] = set()
for x in range(width):
pending.append((x, 0))
pending.append((x, height - 1))
for y in range(height):
pending.append((0, y))
pending.append((width - 1, y))
while pending:
x, y = pending.popleft()
if (x, y) in visited or x < 0 or y < 0 or x >= width or y >= height:
continue
visited.add((x, y))
red, green, blue, alpha = pixels[x, y]
if alpha == 0 or not (red >= 238 and green >= 238 and blue >= 238):
continue
pixels[x, y] = (0, 0, 0, 0)
pending.extend(((x - 1, y), (x + 1, y), (x, y - 1), (x, y + 1)))
return rgba
def main() -> None:
args = parse_args()
if args.padding < 0:
raise SystemExit("padding must be non-negative")
if args.alpha_threshold < 1 or args.alpha_threshold > 255:
raise SystemExit("alpha-threshold must be between 1 and 255")
args.output_dir.mkdir(parents=True, exist_ok=True)
count = 0
for source in sorted(args.input_dir.glob("*.png")):
image = Image.open(source).convert("RGBA")
if args.despill_green:
image = despill_green(image)
image = remove_edge_white_lines(image)
alpha = image.getchannel("A")
if args.alpha_threshold > 1:
alpha = alpha.point(lambda value: 255 if value >= args.alpha_threshold else 0)
bounds = alpha.getbbox()
if bounds is None:
raise SystemExit(f"empty transparent asset: {source}")
left, top, right, bottom = bounds
left = max(0, left - args.padding)
top = max(0, top - args.padding)
right = min(image.width, right + args.padding)
bottom = min(image.height, bottom + args.padding)
image.crop((left, top, right, bottom)).save(args.output_dir / source.name)
count += 1
if count == 0:
raise SystemExit(f"no PNG assets found under {args.input_dir}")
print(
"TRIM_TRANSPARENT_ASSETS_OK "
f"assets={count} padding={args.padding} despill_green={str(args.despill_green).lower()}"
)
if __name__ == "__main__":
main()