100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Render a simple contact sheet from a directory of normalized sprite frames."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import math
|
|
import re
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from PIL import Image, ImageDraw
|
|
except ImportError as exc: # pragma: no cover
|
|
raise SystemExit(
|
|
"Pillow is required. Install it with `python3 -m pip install pillow`."
|
|
) from exc
|
|
|
|
|
|
NUMBER_RE = re.compile(r"(\d+)")
|
|
|
|
|
|
def natural_key(path: Path) -> list[int | str]:
|
|
parts: list[int | str] = []
|
|
for chunk in NUMBER_RE.split(path.stem):
|
|
if not chunk:
|
|
continue
|
|
if chunk.isdigit():
|
|
parts.append(int(chunk))
|
|
else:
|
|
parts.append(chunk)
|
|
parts.append(path.suffix)
|
|
return parts
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Render a preview contact sheet from a directory of sprite frames."
|
|
)
|
|
parser.add_argument("--frames-dir", required=True, help="Directory containing PNG frames.")
|
|
parser.add_argument("--out", required=True, help="Output PNG path.")
|
|
parser.add_argument(
|
|
"--columns",
|
|
type=int,
|
|
default=4,
|
|
help="Number of columns in the preview sheet. Default: 4.",
|
|
)
|
|
parser.add_argument(
|
|
"--gap",
|
|
type=int,
|
|
default=8,
|
|
help="Gap between frames in pixels. Default: 8.",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def paint_checkerboard(image: Image.Image, tile: int = 16) -> None:
|
|
draw = ImageDraw.Draw(image)
|
|
colors = ((240, 243, 246, 255), (225, 230, 235, 255))
|
|
for top in range(0, image.height, tile):
|
|
for left in range(0, image.width, tile):
|
|
color = colors[((left // tile) + (top // tile)) % 2]
|
|
draw.rectangle((left, top, left + tile, top + tile), fill=color)
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
if args.columns < 1:
|
|
raise SystemExit("--columns must be at least 1.")
|
|
if args.gap < 0:
|
|
raise SystemExit("--gap cannot be negative.")
|
|
|
|
frame_dir = Path(args.frames_dir)
|
|
frames = sorted(frame_dir.glob("*.png"), key=natural_key)
|
|
if not frames:
|
|
raise SystemExit("No PNG frames were found in --frames-dir.")
|
|
|
|
images = [Image.open(path).convert("RGBA") for path in frames]
|
|
frame_width = max(image.width for image in images)
|
|
frame_height = max(image.height for image in images)
|
|
rows = math.ceil(len(images) / args.columns)
|
|
sheet_width = args.columns * frame_width + max(0, args.columns - 1) * args.gap
|
|
sheet_height = rows * frame_height + max(0, rows - 1) * args.gap
|
|
sheet = Image.new("RGBA", (sheet_width, sheet_height), (255, 255, 255, 255))
|
|
paint_checkerboard(sheet)
|
|
|
|
for index, image in enumerate(images):
|
|
row = index // args.columns
|
|
column = index % args.columns
|
|
left = column * (frame_width + args.gap) + (frame_width - image.width) // 2
|
|
top = row * (frame_height + args.gap) + (frame_height - image.height) // 2
|
|
sheet.alpha_composite(image, (left, top))
|
|
|
|
out_path = Path(args.out)
|
|
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
sheet.save(out_path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|