#!/usr/bin/env python3 from __future__ import annotations import argparse from collections import deque from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont ROWS = ["down", "up", "right", "left"] def resize_rgba_alpha_aware(image: Image.Image, size: tuple[int, int]) -> Image.Image: rgba = image.convert("RGBA") arr = np.asarray(rgba).astype(np.float32) alpha = arr[:, :, 3:4] / 255.0 premultiplied = arr[:, :, :3] * alpha premul_image = Image.fromarray(np.clip(premultiplied, 0, 255).astype(np.uint8), "RGB") alpha_image = Image.fromarray(arr[:, :, 3].astype(np.uint8), "L") resized_premul = np.asarray(premul_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32) resized_alpha = np.asarray(alpha_image.resize(size, Image.Resampling.LANCZOS)).astype(np.float32) alpha_fraction = resized_alpha[:, :, None] / 255.0 rgb = np.zeros_like(resized_premul) np.divide(resized_premul, alpha_fraction, out=rgb, where=alpha_fraction > 0.001) output = np.dstack([np.clip(rgb, 0, 255), resized_alpha]) return Image.fromarray(np.clip(output, 0, 255).astype(np.uint8), "RGBA") def largest_component_mask(alpha: np.ndarray, threshold: int = 8) -> np.ndarray: foreground = alpha > threshold visited = np.zeros(foreground.shape, dtype=bool) best: list[tuple[int, int]] = [] height, width = foreground.shape for y in range(height): for x in range(width): if not foreground[y, x] or visited[y, x]: continue component: list[tuple[int, int]] = [] queue: deque[tuple[int, int]] = deque([(y, x)]) visited[y, x] = True while queue: cy, cx = queue.popleft() component.append((cy, cx)) for ny, nx in ((cy - 1, cx), (cy + 1, cx), (cy, cx - 1), (cy, cx + 1)): if 0 <= ny < height and 0 <= nx < width and foreground[ny, nx] and not visited[ny, nx]: visited[ny, nx] = True queue.append((ny, nx)) if len(component) > len(best): best = component mask = np.zeros(foreground.shape, dtype=bool) for y, x in best: mask[y, x] = True return mask def keep_largest_component(cell: Image.Image, dilate: int) -> Image.Image: arr = np.asarray(cell.convert("RGBA")).copy() keep = largest_component_mask(arr[:, :, 3], threshold=10) for _ in range(dilate): expanded = keep.copy() expanded[:-1, :] |= keep[1:, :] expanded[1:, :] |= keep[:-1, :] expanded[:, :-1] |= keep[:, 1:] expanded[:, 1:] |= keep[:, :-1] keep = expanded arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8) return Image.fromarray(arr, "RGBA") def remove_magenta_contamination(cell: Image.Image) -> Image.Image: arr = np.asarray(cell.convert("RGBA")).copy() rgb = arr[:, :, :3].astype(np.int16) alpha = arr[:, :, 3] magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 80) & (rgb[:, :, 2] > 120) if magenta.any(): arr[:, :, 3] = np.where(magenta, 0, alpha).astype(np.uint8) return Image.fromarray(arr, "RGBA") def fit_cell( cell: Image.Image, *, frame_size: int, target_body_height: int, target_foot_y: int, component_dilate: int, ) -> Image.Image: cell = remove_magenta_contamination(keep_largest_component(cell, component_dilate)) bbox = cell.getchannel("A").getbbox() if bbox is None: return Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0)) trimmed = cell.crop(bbox) scale = min(target_body_height / trimmed.height, (frame_size - 8) / trimmed.width, 1.0) next_size = (max(1, round(trimmed.width * scale)), max(1, round(trimmed.height * scale))) resized = resize_rgba_alpha_aware(trimmed, next_size) output = Image.new("RGBA", (frame_size, frame_size), (0, 0, 0, 0)) x = (frame_size - resized.width) // 2 y = target_foot_y - resized.height y = max(0, min(frame_size - resized.height, y)) output.alpha_composite(resized, (x, y)) return output def extract_row(path: Path, args: argparse.Namespace) -> list[Image.Image]: image = Image.open(path).convert("RGBA") width, height = image.size x_edges = [round(i * width / args.columns) for i in range(args.columns + 1)] frames: list[Image.Image] = [] for column in range(args.columns): cell = image.crop((x_edges[column], 0, x_edges[column + 1], height)) frames.append( fit_cell( cell, frame_size=args.frame_size, target_body_height=args.target_body_height, target_foot_y=args.target_foot_y, component_dilate=args.component_dilate, ) ) return align_frames_to_neutral_upper_body(frames) def shift_frame_horizontally(frame: Image.Image, offset: int) -> Image.Image: if offset == 0: return frame shifted = Image.new("RGBA", frame.size, (0, 0, 0, 0)) shifted.alpha_composite(frame, (offset, 0)) return shifted def align_frames_to_neutral_upper_body(frames: list[Image.Image], max_shift: int = 8) -> list[Image.Image]: """Undo per-frame recentering caused by wider walking-leg silhouettes.""" if not frames: return frames upper_end = round(frames[0].height * 0.60) neutral = np.asarray(frames[0].convert("RGBA"))[:upper_end, :, 3] > 20 aligned = [frames[0]] for frame in frames[1:]: candidate = np.asarray(frame.convert("RGBA"))[:upper_end, :, 3] > 20 best_score = float("inf") best_shift = 0 for offset in range(-max_shift, max_shift + 1): shifted = np.zeros_like(candidate) if offset < 0: shifted[:, :offset] = candidate[:, -offset:] elif offset > 0: shifted[:, offset:] = candidate[:, :-offset] else: shifted = candidate union = np.logical_or(neutral, shifted) score = float(np.logical_xor(neutral, shifted).sum() / union.sum()) if union.any() else 0.0 if score < best_score: best_score = score best_shift = offset aligned.append(shift_frame_horizontally(frame, best_shift if abs(best_shift) >= 2 else 0)) return aligned def checkerboard(width: int, height: int, tile: int = 8) -> Image.Image: image = Image.new("RGBA", (width, height), (226, 226, 226, 255)) draw = ImageDraw.Draw(image) for y in range(0, height, tile): for x in range(0, width, tile): if (x // tile + y // tile) % 2 == 0: draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=(248, 248, 248, 255)) return image def save_review(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None: scale = 2 label_width = 68 strip_height = frame_size * scale canvas = Image.new("RGB", (label_width + sheet.width * scale, strip_height * 4), (246, 247, 250)) draw = ImageDraw.Draw(canvas) font = ImageFont.load_default() for row, name in enumerate(ROWS): y = row * strip_height draw.text((8, y + strip_height // 2 - 5), name, fill=(28, 32, 36), font=font) row_sheet = sheet.crop((0, row * frame_size, sheet.width, (row + 1) * frame_size)) row_preview = row_sheet.resize((sheet.width * scale, strip_height), Image.Resampling.NEAREST) bg = checkerboard(row_preview.width, row_preview.height, 16) bg.alpha_composite(row_preview) canvas.paste(bg.convert("RGB"), (label_width, y)) for index in range(columns + 1): x = label_width + index * frame_size * scale draw.line((x, y, x, y + strip_height), fill=(205, 60, 60), width=1) path.parent.mkdir(parents=True, exist_ok=True) canvas.save(path) def save_feet_zoom(sheet: Image.Image, path: Path, *, frame_size: int, columns: int) -> None: scale = 4 crop_y0 = round(frame_size * 0.49) crop_h = frame_size - crop_y0 label_w = 64 label_h = 22 cell_w = frame_size * scale cell_h = crop_h * scale canvas = Image.new("RGB", (label_w + cell_w * columns, label_h + cell_h * 4), (246, 247, 250)) draw = ImageDraw.Draw(canvas) font = ImageFont.load_default() for column in range(columns): draw.text((label_w + column * cell_w + 4, 5), f"F{column + 1}", fill=(72, 76, 82), font=font) for row, row_name in enumerate(ROWS): y = label_h + row * cell_h draw.text((6, y + cell_h // 2 - 5), row_name, fill=(28, 32, 36), font=font) for column in range(columns): cell = sheet.crop( ( column * frame_size, row * frame_size + crop_y0, (column + 1) * frame_size, (row + 1) * frame_size, ) ).resize((cell_w, cell_h), Image.Resampling.NEAREST) bg = checkerboard(cell_w, cell_h, 20) bg.alpha_composite(cell) x = label_w + column * cell_w canvas.paste(bg.convert("RGB"), (x, y)) draw.rectangle((x, y, x + cell_w - 1, y + cell_h - 1), outline=(188, 194, 202), width=1) path.parent.mkdir(parents=True, exist_ok=True) canvas.save(path) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Assemble four WhaleTown direction strips into an 8x4 spritesheet.") parser.add_argument("--down", type=Path, required=True) parser.add_argument("--up", type=Path, required=True) parser.add_argument("--right", type=Path, required=True) parser.add_argument("--left", type=Path, required=True) parser.add_argument("--name", required=True) parser.add_argument("--output-dir", type=Path, required=True) parser.add_argument("--frame-size", type=int, default=160) parser.add_argument("--columns", type=int, default=8) parser.add_argument("--target-body-height", type=int, default=116) parser.add_argument("--target-foot-y", type=int, default=137) parser.add_argument("--component-dilate", type=int, default=2) return parser.parse_args() def main() -> None: args = parse_args() inputs = {"down": args.down, "up": args.up, "right": args.right, "left": args.left} for name, path in inputs.items(): if not path.exists(): raise FileNotFoundError(f"{name} strip not found: {path}") sheet = Image.new("RGBA", (args.frame_size * args.columns, args.frame_size * 4), (0, 0, 0, 0)) for row, direction in enumerate(ROWS): frames = extract_row(inputs[direction], args) for column, frame in enumerate(frames): sheet.alpha_composite(frame, (column * args.frame_size, row * args.frame_size)) processed_dir = args.output_dir / "processed" review_dir = args.output_dir / "review" processed_dir.mkdir(parents=True, exist_ok=True) review_dir.mkdir(parents=True, exist_ok=True) sheet_path = processed_dir / f"{args.name}_spritesheet.png" review_path = review_dir / f"{args.name}_review.png" feet_path = review_dir / f"{args.name}_feet_zoom.png" sheet.save(sheet_path) save_review(sheet, review_path, frame_size=args.frame_size, columns=args.columns) save_feet_zoom(sheet, feet_path, frame_size=args.frame_size, columns=args.columns) print(sheet_path) print(review_path) print(feet_path) if __name__ == "__main__": main()