HEX
Server: Apache/2.4.46 (Win64) OpenSSL/1.1.1j PHP/8.4.25
System: Windows NT DESKTOP-4TAV2RJ 10.0 build 19045 (Windows 10) AMD64
User: fred (0)
PHP: 8.4.25
Disabled: NONE
Upload Files
File: C:/Users/fred/.codex/.tmp/plugins/plugins/game-studio/scripts/normalize_sprite_strip.py
#!/usr/bin/env python3
"""Normalize a raw animation strip into fixed-size transparent frames."""

from __future__ import annotations

import argparse
from pathlib import Path
from typing import Iterable

try:
    from PIL import Image
except ImportError as exc:  # pragma: no cover
    raise SystemExit(
        "Pillow is required. Install it with `python3 -m pip install pillow`."
    ) from exc


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=(
            "Extract one horizontal strip into fixed-size frames using a shared "
            "global scale and bottom-center alignment."
        )
    )
    parser.add_argument("--input", required=True, help="Path to the raw strip image.")
    parser.add_argument("--out-dir", required=True, help="Output directory for frames.")
    parser.add_argument(
        "--frames",
        type=int,
        required=True,
        help="Number of horizontal frames in the strip.",
    )
    parser.add_argument(
        "--frame-size",
        type=int,
        default=64,
        help="Output square frame size in pixels. Default: 64.",
    )
    parser.add_argument(
        "--anchor",
        help="Optional anchor frame used to stabilize global scale and frame 01 output.",
    )
    parser.add_argument(
        "--lock-frame1",
        action="store_true",
        help="Replace frame 01 with the provided anchor frame after normalization.",
    )
    parser.add_argument(
        "--alpha-threshold",
        type=int,
        default=8,
        help="Pixels with alpha above this threshold count as sprite content. Default: 8.",
    )
    return parser.parse_args()


def threshold_bbox(image: Image.Image, alpha_threshold: int) -> tuple[int, int, int, int] | None:
    alpha = image.getchannel("A").point(lambda value: 255 if value > alpha_threshold else 0)
    return alpha.getbbox()


def crop_to_content(image: Image.Image, alpha_threshold: int) -> Image.Image | None:
    bbox = threshold_bbox(image, alpha_threshold)
    if bbox is None:
        return None
    return image.crop(bbox)


def split_strip(strip: Image.Image, frames: int) -> list[Image.Image]:
    if frames < 1:
        raise ValueError("frames must be at least 1")
    step = strip.width / frames
    slots: list[Image.Image] = []
    for index in range(frames):
        left = int(round(index * step))
        right = int(round((index + 1) * step))
        slots.append(strip.crop((left, 0, right, strip.height)))
    return slots


def max_content_size(images: Iterable[Image.Image | None]) -> tuple[int, int]:
    widths: list[int] = []
    heights: list[int] = []
    for image in images:
        if image is None:
            continue
        widths.append(image.width)
        heights.append(image.height)
    if not widths or not heights:
        raise SystemExit("No sprite content was detected in the provided strip.")
    return max(widths), max(heights)


def compose_frame(
    image: Image.Image | None,
    frame_size: int,
    scale: float,
) -> Image.Image:
    canvas = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0))
    if image is None:
        return canvas

    width = max(1, int(round(image.width * scale)))
    height = max(1, int(round(image.height * scale)))
    resized = image.resize((width, height), Image.Resampling.NEAREST)
    offset_x = (frame_size - width) // 2
    offset_y = frame_size - height
    canvas.alpha_composite(resized, (offset_x, offset_y))
    return canvas


def load_anchor(path: str | None, alpha_threshold: int) -> tuple[Image.Image | None, Image.Image | None]:
    if path is None:
        return None, None
    anchor = Image.open(path).convert("RGBA")
    cropped = crop_to_content(anchor, alpha_threshold)
    return anchor, cropped


def main() -> None:
    args = parse_args()
    if args.frames < 1:
        raise SystemExit("--frames must be at least 1.")
    if args.frame_size < 1:
        raise SystemExit("--frame-size must be positive.")
    if args.lock_frame1 and not args.anchor:
        raise SystemExit("--lock-frame1 requires --anchor.")

    strip = Image.open(args.input).convert("RGBA")
    slots = split_strip(strip, args.frames)
    contents = [crop_to_content(slot, args.alpha_threshold) for slot in slots]
    anchor_image, anchor_content = load_anchor(args.anchor, args.alpha_threshold)
    max_width, max_height = max_content_size([*contents, anchor_content])
    scale = min(args.frame_size / max_width, args.frame_size / max_height)

    out_dir = Path(args.out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    for index, content in enumerate(contents, start=1):
        if index == 1 and args.lock_frame1:
            assert anchor_image is not None
            if anchor_image.width == args.frame_size and anchor_image.height == args.frame_size:
                frame = anchor_image
            else:
                frame = compose_frame(anchor_content, args.frame_size, scale)
        else:
            frame = compose_frame(content, args.frame_size, scale)
        frame.save(out_dir / f"{index:02d}.png")


if __name__ == "__main__":
    main()