chore: share game-studio hermes plugin
This commit is contained in:
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
152
.hermes/plugins/game-studio/scripts/normalize_sprite_strip.py
Normal file
@@ -0,0 +1,152 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user