squad-proto/tools/sprite_preview.py

84 lines
3.6 KiB
Python
Raw Normal View History

"""Make a human-readable grid from a direction-major technical sprite sheet.
This command is for the human art review loop. Agents should use the textual
validators instead of opening the generated preview.
"""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("sheet", type=Path)
parser.add_argument("--overlay", action="append", type=Path, default=[],
help="aligned paper-doll sheet to composite over the base; repeatable")
parser.add_argument("--cell", required=True, metavar="WIDTHxHEIGHT")
parser.add_argument("--frames", required=True, type=int,
help="frames per direction")
parser.add_argument("--dirs", type=int, default=8)
parser.add_argument("--scale", type=int, default=3)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
cell_w, cell_h = (int(value) for value in args.cell.lower().split("x", 1))
except (ValueError, TypeError) as error:
raise SystemExit("--cell must look like 70x90") from error
if min(cell_w, cell_h, args.frames, args.dirs, args.scale) < 1:
raise SystemExit("cell, frames, dirs and scale must be positive")
source = Image.open(args.sheet).convert("RGBA")
expected = (cell_w * args.frames * args.dirs, cell_h)
if source.size != expected:
raise SystemExit(f"sheet is {source.size[0]}x{source.size[1]}, expected "
f"{expected[0]}x{expected[1]}")
layers = [source]
for path in args.overlay:
layer = Image.open(path).convert("RGBA")
if layer.size != expected:
raise SystemExit(f"overlay {path} is {layer.size}, expected {expected}")
layers.append(layer)
label_h = 18
sw, sh = cell_w * args.scale, cell_h * args.scale
out = Image.new("RGBA", (args.frames * sw, args.dirs * (sh + label_h)),
(22, 24, 29, 255))
draw = ImageDraw.Draw(out)
for direction in range(args.dirs):
for frame in range(args.frames):
index = direction * args.frames + frame
box = (index * cell_w, 0, (index + 1) * cell_w, cell_h)
cell = layers[0].crop(box)
for layer in layers[1:]:
cell.alpha_composite(layer.crop(box))
cell = cell.resize((sw, sh), Image.Resampling.NEAREST)
x, y = frame * sw, direction * (sh + label_h) + label_h
# Neutral checker makes transparent padding and the exact cell edge visible.
checker = Image.new("RGBA", (sw, sh), (36, 39, 45, 255))
cd = ImageDraw.Draw(checker)
block = max(4, args.scale * 4)
for cy in range(0, sh, block):
for cx in range(0, sw, block):
if ((cx // block) + (cy // block)) & 1:
cd.rectangle((cx, cy, cx + block - 1, cy + block - 1),
fill=(48, 52, 59, 255))
checker.alpha_composite(cell)
out.alpha_composite(checker, (x, y))
draw.rectangle((x, y, x + sw - 1, y + sh - 1), outline=(105, 112, 126, 255))
draw.text((x + 4, y - label_h + 3), f"dir {direction} frame {frame}",
fill=(220, 224, 232, 255))
target = args.output or args.sheet.with_name(args.sheet.stem + ".preview.png")
target.parent.mkdir(parents=True, exist_ok=True)
out.convert("RGB").save(target)
print(target.resolve())
return 0
if __name__ == "__main__":
raise SystemExit(main())