Files
whale-town-end-v2/scripts/skin_generation/tools/assemble_direction_strips.py

276 lines
11 KiB
Python
Executable File

#!/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()