95 lines
3.2 KiB
Python
95 lines
3.2 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(
|
|
"--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")
|
|
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)
|
|
bounds = image.getchannel("A").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()
|