a46764fb1b
ci / Validate workspace (push) Has been cancelled
landing-page-ci / Validate landing page (push) Has been cancelled
landing-page-deploy / Deploy landing page (push) Has been cancelled
github-metrics / Generate repository metrics SVG (push) Has been cancelled
refresh-contributors-wall / Refresh contributors wall cache bust (push) Waiting to run
151 lines
5.1 KiB
Python
151 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Compose or normalize a Codex pet spritesheet atlas."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
COLUMNS = 8
|
|
ROWS = 9
|
|
CELL_WIDTH = 192
|
|
CELL_HEIGHT = 208
|
|
ATLAS_WIDTH = COLUMNS * CELL_WIDTH
|
|
ATLAS_HEIGHT = ROWS * CELL_HEIGHT
|
|
ATLAS_ASPECT_RATIO = ATLAS_WIDTH / ATLAS_HEIGHT
|
|
ROW_SPECS = [
|
|
("idle", 0, 6),
|
|
("running-right", 1, 8),
|
|
("running-left", 2, 8),
|
|
("waving", 3, 4),
|
|
("jumping", 4, 5),
|
|
("failed", 5, 8),
|
|
("waiting", 6, 6),
|
|
("running", 7, 6),
|
|
("review", 8, 6),
|
|
]
|
|
IMAGE_SUFFIXES = {".png", ".webp", ".jpg", ".jpeg"}
|
|
|
|
|
|
def image_files(path: Path) -> list[Path]:
|
|
return sorted(p for p in path.iterdir() if p.suffix.lower() in IMAGE_SUFFIXES)
|
|
|
|
|
|
def find_row_frames(root: Path, state: str, row_index: int) -> list[Path]:
|
|
candidates = [
|
|
root / state,
|
|
root / f"row-{row_index}",
|
|
root / f"row{row_index}",
|
|
root / f"{row_index}-{state}",
|
|
]
|
|
for candidate in candidates:
|
|
if candidate.is_dir():
|
|
files = image_files(candidate)
|
|
if files:
|
|
return files
|
|
globs = [
|
|
f"{state}_*",
|
|
f"{state}-*",
|
|
f"row{row_index}_*",
|
|
f"row-{row_index}-*",
|
|
]
|
|
files: list[Path] = []
|
|
for pattern in globs:
|
|
files.extend(p for p in root.glob(pattern) if p.suffix.lower() in IMAGE_SUFFIXES)
|
|
return sorted(set(files))
|
|
|
|
|
|
def paste_centered(atlas: Image.Image, source: Image.Image, row: int, column: int) -> None:
|
|
frame = source.convert("RGBA")
|
|
if frame.size != (CELL_WIDTH, CELL_HEIGHT):
|
|
frame.thumbnail((CELL_WIDTH, CELL_HEIGHT), Image.Resampling.LANCZOS)
|
|
left = column * CELL_WIDTH + (CELL_WIDTH - frame.width) // 2
|
|
top = row * CELL_HEIGHT + (CELL_HEIGHT - frame.height) // 2
|
|
atlas.alpha_composite(frame, (left, top))
|
|
|
|
|
|
def compose_from_source_atlas(path: Path, resize_source: bool) -> Image.Image:
|
|
with Image.open(path) as opened:
|
|
source = opened.convert("RGBA")
|
|
if source.size != (ATLAS_WIDTH, ATLAS_HEIGHT):
|
|
if not resize_source:
|
|
raise SystemExit(
|
|
f"source atlas must be {ATLAS_WIDTH}x{ATLAS_HEIGHT}; got {source.width}x{source.height}"
|
|
)
|
|
source_ratio = source.width / source.height
|
|
if abs(source_ratio - ATLAS_ASPECT_RATIO) > 0.02:
|
|
raise SystemExit(
|
|
"refusing to resize source atlas because its aspect ratio does not match "
|
|
f"the Codex atlas ratio {ATLAS_ASPECT_RATIO:.3f}; got {source_ratio:.3f}. "
|
|
"Generate exact atlas dimensions or use --frames-root."
|
|
)
|
|
source = source.resize((ATLAS_WIDTH, ATLAS_HEIGHT), Image.Resampling.LANCZOS)
|
|
|
|
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
|
for _state, row, frame_count in ROW_SPECS:
|
|
for column in range(frame_count):
|
|
left = column * CELL_WIDTH
|
|
top = row * CELL_HEIGHT
|
|
cell = source.crop((left, top, left + CELL_WIDTH, top + CELL_HEIGHT))
|
|
atlas.alpha_composite(cell, (left, top))
|
|
return atlas
|
|
|
|
|
|
def compose_from_frames(root: Path) -> Image.Image:
|
|
atlas = Image.new("RGBA", (ATLAS_WIDTH, ATLAS_HEIGHT), (0, 0, 0, 0))
|
|
for state, row, frame_count in ROW_SPECS:
|
|
files = find_row_frames(root, state, row)
|
|
if len(files) < frame_count:
|
|
raise SystemExit(
|
|
f"{state} row needs {frame_count} frames, found {len(files)} under {root}"
|
|
)
|
|
for column, frame_path in enumerate(files[:frame_count]):
|
|
with Image.open(frame_path) as frame:
|
|
paste_centered(atlas, frame, row, column)
|
|
return atlas
|
|
|
|
|
|
def save_outputs(atlas: Image.Image, output: Path, webp_output: Path | None) -> None:
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
atlas.save(output)
|
|
if webp_output is not None:
|
|
webp_output.parent.mkdir(parents=True, exist_ok=True)
|
|
atlas.save(webp_output, format="WEBP", lossless=True, quality=100, method=6)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
source = parser.add_mutually_exclusive_group(required=True)
|
|
source.add_argument("--source-atlas")
|
|
source.add_argument("--frames-root")
|
|
parser.add_argument("--output", required=True)
|
|
parser.add_argument("--webp-output")
|
|
parser.add_argument(
|
|
"--resize-source",
|
|
action="store_true",
|
|
help="Resize a lower-resolution source atlas only when it already has the Codex atlas aspect ratio.",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.source_atlas:
|
|
atlas = compose_from_source_atlas(
|
|
Path(args.source_atlas).expanduser().resolve(), args.resize_source
|
|
)
|
|
else:
|
|
atlas = compose_from_frames(Path(args.frames_root).expanduser().resolve())
|
|
|
|
save_outputs(
|
|
atlas,
|
|
Path(args.output).expanduser().resolve(),
|
|
Path(args.webp_output).expanduser().resolve() if args.webp_output else None,
|
|
)
|
|
print(f"wrote {Path(args.output).expanduser().resolve()}")
|
|
if args.webp_output:
|
|
print(f"wrote {Path(args.webp_output).expanduser().resolve()}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|