Files
open-design/skills/hatch-pet/scripts/compose_atlas.py
T
Zakaria 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
first-commit
2026-05-04 14:58:14 -04:00

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()