Initial WhaleTown V2 backend

This commit is contained in:
2026-07-20 02:00:52 +08:00
commit c995891c1f
265 changed files with 75689 additions and 0 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

View File

@@ -0,0 +1,275 @@
#!/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()

View File

@@ -0,0 +1,293 @@
#!/usr/bin/env python3
"""Create clean transparent game-asset cutouts with BiRefNet.
This tool is intended for Novamailio-generated WhaleTown assets that come back
on a plain matte background. It uses BiRefNet for the alpha mask, then fills
transparent/semitransparent edge RGB from confident foreground pixels so Godot
texture filtering cannot reveal the original matte color.
"""
from __future__ import annotations
import argparse
from collections import deque
import os
from pathlib import Path
import sys
from typing import Iterable, Tuple
import numpy as np
from PIL import Image, ImageDraw
DEFAULT_MODEL = "ZhengPeng7/BiRefNet"
DEFAULT_HF_ENDPOINT = "https://hf-mirror.com"
DEFAULT_SIZE = 1024
def _die(message: str, code: int = 1) -> None:
print(f"Error: {message}", file=sys.stderr)
raise SystemExit(code)
def _warn(message: str) -> None:
print(f"Warning: {message}", file=sys.stderr)
def _import_ml_deps() -> Tuple[object, object, object]:
try:
import torch
from torchvision import transforms
from transformers import AutoModelForImageSegmentation
except ImportError as exc:
_die(
"Missing BiRefNet dependencies. Install them with:\n"
" python3 -m pip install torch torchvision transformers timm einops kornia scipy\n"
f"Original import error: {exc}"
)
return torch, transforms, AutoModelForImageSegmentation
def _select_device(torch: object, requested: str) -> str:
if requested != "auto":
return requested
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
if torch.cuda.is_available():
return "cuda"
return "cpu"
def _load_birefnet(model_name: str, device: str, torch: object, auto_model: object) -> object:
model = auto_model.from_pretrained(model_name, trust_remote_code=True)
model.to(device)
model.eval()
return model
def _run_birefnet(
image: Image.Image,
*,
model_name: str,
device: str,
input_size: int,
) -> Image.Image:
torch, transforms, auto_model = _import_ml_deps()
selected_device = _select_device(torch, device)
print(f"BiRefNet device: {selected_device}", file=sys.stderr)
print(f"BiRefNet model: {model_name}", file=sys.stderr)
model = _load_birefnet(model_name, selected_device, torch, auto_model)
transform = transforms.Compose(
[
transforms.Resize(
(input_size, input_size),
interpolation=transforms.InterpolationMode.BILINEAR,
),
transforms.ToTensor(),
transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
]
)
input_tensor = transform(image).unsqueeze(0).to(selected_device)
with torch.no_grad():
output = model(input_tensor)
if isinstance(output, (list, tuple)):
prediction = output[-1]
elif hasattr(output, "logits"):
prediction = output.logits
else:
prediction = output
prediction = prediction.sigmoid().detach().float().cpu()[0]
if prediction.ndim == 3:
prediction = prediction.squeeze(0)
mask = transforms.ToPILImage()(prediction)
return mask.resize(image.size, Image.Resampling.LANCZOS).convert("L")
def _stabilize_mask(
mask: Image.Image,
*,
low_cut: float,
high_span: float,
hard_low: float,
hard_high: float,
) -> Image.Image:
mask_arr = np.asarray(mask).astype(np.float32) / 255.0
mask_arr = np.clip((mask_arr - low_cut) / high_span, 0.0, 1.0)
mask_arr = np.where(mask_arr > hard_high, 1.0, mask_arr)
mask_arr = np.where(mask_arr < hard_low, 0.0, mask_arr)
return Image.fromarray((mask_arr * 255).astype(np.uint8))
def _connected_matte_mask(image: Image.Image, threshold: float) -> np.ndarray:
"""Fallback mask for comparing or rescuing BiRefNet failures."""
rgb = np.asarray(image.convert("RGB"))
arr = rgb.astype(np.int32)
h, w = arr.shape[:2]
strips = np.concatenate(
[
arr[:80, :, :].reshape(-1, 3),
arr[max(0, h - 80) : h, :, :].reshape(-1, 3),
arr[:, :80, :].reshape(-1, 3),
arr[:, max(0, w - 80) : w, :].reshape(-1, 3),
],
axis=0,
)
background = np.median(strips, axis=0).astype(np.int32)
color_dist = np.sqrt(((arr - background) ** 2).sum(axis=2))
candidate = color_dist <= threshold
visited = np.zeros((h, w), dtype=bool)
queue: deque[Tuple[int, int]] = deque()
for x in range(w):
for y in (0, h - 1):
if candidate[y, x] and not visited[y, x]:
visited[y, x] = True
queue.append((y, x))
for y in range(h):
for x in (0, w - 1):
if candidate[y, x] and not visited[y, x]:
visited[y, x] = True
queue.append((y, x))
while queue:
y, x = queue.popleft()
for ny, nx in ((y - 1, x), (y + 1, x), (y, x - 1), (y, x + 1)):
if 0 <= ny < h and 0 <= nx < w and (not visited[ny, nx]) and candidate[ny, nx]:
visited[ny, nx] = True
queue.append((ny, nx))
return ~visited
def _decontaminate_edge_rgb(rgb: np.ndarray, alpha: np.ndarray, confidence: int) -> np.ndarray:
foreground = alpha > confidence
if not foreground.any():
_warn("Mask has no confident foreground pixels; edge decontamination skipped.")
return rgb
try:
from scipy import ndimage
except ImportError:
_warn("scipy is missing; install scipy for edge RGB decontamination.")
return rgb
_, indices = ndimage.distance_transform_edt(~foreground, return_indices=True)
nearest_rgb = rgb[indices[0], indices[1]]
alpha_f = alpha.astype(np.float32) / 255.0
edge_mix = np.clip((0.98 - alpha_f) / 0.98, 0.0, 1.0)[..., None]
replace_strength = np.where(alpha_f[..., None] < 0.98, edge_mix, 0.0)
cleaned = rgb * (1.0 - replace_strength) + nearest_rgb * replace_strength
return np.clip(cleaned, 0, 255).astype(np.uint8)
def _compose_cutout(image: Image.Image, mask: Image.Image, confidence: int) -> Image.Image:
rgb = np.asarray(image.convert("RGB")).astype(np.float32)
alpha = np.asarray(mask).astype(np.uint8)
cleaned_rgb = _decontaminate_edge_rgb(rgb, alpha, confidence)
return Image.fromarray(np.dstack([cleaned_rgb, alpha]))
def _make_preview(cutout: Image.Image, output: Path, scale: float) -> None:
w, h = cutout.size
small = cutout.resize((int(w * scale), int(h * scale)), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (small.width * 2 + 48, small.height + 48), (238, 238, 238))
magenta_plate = Image.new("RGB", small.size, (198, 76, 190))
magenta_plate.paste(small, (0, 0), small)
checker = Image.new("RGB", small.size, (230, 230, 230))
draw = ImageDraw.Draw(checker)
step = 32
for y in range(0, small.height, step):
for x in range(0, small.width, step):
if ((x // step) + (y // step)) % 2 == 0:
draw.rectangle([x, y, x + step - 1, y + step - 1], fill=(190, 206, 224))
checker.paste(small, (0, 0), small)
canvas.paste(magenta_plate, (16, 32))
canvas.paste(checker, (small.width + 32, 32))
output.parent.mkdir(parents=True, exist_ok=True)
canvas.save(output)
def _magenta_stats(image: Image.Image) -> Tuple[int, int, int]:
arr = np.asarray(image.convert("RGBA")).astype(np.int16)
rgb = arr[:, :, :3]
alpha = arr[:, :, 3]
semi = (alpha > 0) & (alpha < 255)
magenta = (alpha > 0) & (rgb[:, :, 0] > 120) & (rgb[:, :, 1] < 60) & (rgb[:, :, 2] > 90)
semi_magenta = semi & magenta
return int(semi.sum()), int(magenta.sum()), int(semi_magenta.sum())
def parse_args(argv: Iterable[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", required=True, help="Source RGB/RGBA image with matte background.")
parser.add_argument("--output", required=True, help="Transparent PNG output path.")
parser.add_argument("--mask-out", help="Optional alpha mask output path.")
parser.add_argument("--preview-out", help="Optional preview sheet output path.")
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"HF model id. Default: {DEFAULT_MODEL}")
parser.add_argument("--hf-endpoint", default=DEFAULT_HF_ENDPOINT, help="Hugging Face endpoint/mirror.")
parser.add_argument("--device", choices=["auto", "cpu", "mps", "cuda"], default="auto")
parser.add_argument("--input-size", type=int, default=DEFAULT_SIZE, help="Square BiRefNet input size.")
parser.add_argument("--low-cut", type=float, default=0.025, help="Low alpha normalization cut.")
parser.add_argument("--high-span", type=float, default=0.94, help="Alpha normalization span.")
parser.add_argument("--hard-low", type=float, default=0.015, help="Values below this become transparent.")
parser.add_argument("--hard-high", type=float, default=0.985, help="Values above this become opaque.")
parser.add_argument("--edge-confidence", type=int, default=245, help="Confident foreground alpha for RGB fill.")
parser.add_argument("--preview-scale", type=float, default=0.52)
parser.add_argument("--fallback-connected-matte", action="store_true", help="Use simple connected matte mask instead of BiRefNet.")
parser.add_argument("--fallback-threshold", type=float, default=28.0)
return parser.parse_args(list(argv))
def main(argv: Iterable[str]) -> int:
args = parse_args(argv)
input_path = Path(args.input)
output_path = Path(args.output)
if not input_path.exists():
_die(f"Input image not found: {input_path}")
os.environ.setdefault("HF_ENDPOINT", args.hf_endpoint)
image = Image.open(input_path).convert("RGB")
if args.fallback_connected_matte:
foreground = _connected_matte_mask(image, args.fallback_threshold)
mask = Image.fromarray(foreground.astype(np.uint8) * 255)
else:
mask = _run_birefnet(
image,
model_name=args.model,
device=args.device,
input_size=args.input_size,
)
mask = _stabilize_mask(
mask,
low_cut=args.low_cut,
high_span=args.high_span,
hard_low=args.hard_low,
hard_high=args.hard_high,
)
cutout = _compose_cutout(image, mask, args.edge_confidence)
output_path.parent.mkdir(parents=True, exist_ok=True)
cutout.save(output_path)
if args.mask_out:
mask_path = Path(args.mask_out)
mask_path.parent.mkdir(parents=True, exist_ok=True)
mask.save(mask_path)
if args.preview_out:
_make_preview(cutout, Path(args.preview_out), args.preview_scale)
semi, magenta, semi_magenta = _magenta_stats(cutout)
print(f"wrote {output_path}")
if args.mask_out:
print(f"wrote {args.mask_out}")
if args.preview_out:
print(f"wrote {args.preview_out}")
print(f"alpha_bbox={cutout.getchannel('A').getbbox()}")
print(f"semi_transparent_pixels={semi}")
print(f"visible_magenta_like_pixels={magenta}")
print(f"semi_magenta_like_pixels={semi_magenta}")
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))

View File

@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Expand canonical A/B/C poses into the A/B/A/C/A/B/A/C walk cycle."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image
POSE_SEQUENCE = (0, 1, 0, 2, 0, 1, 0, 2)
def split_equal_columns(image: Image.Image, columns: int) -> list[Image.Image]:
width, height = image.size
edges = [round(index * width / columns) for index in range(columns + 1)]
return [image.crop((edges[index], 0, edges[index + 1], height)) for index in range(columns)]
def expand_pose_triplet(image: Image.Image) -> Image.Image:
poses = split_equal_columns(image.convert("RGBA"), 3)
cell_width = max(pose.width for pose in poses)
cell_height = image.height
output = Image.new("RGBA", (cell_width * len(POSE_SEQUENCE), cell_height), (0, 0, 0, 0))
for output_index, pose_index in enumerate(POSE_SEQUENCE):
pose = poses[pose_index]
x = output_index * cell_width + (cell_width - pose.width) // 2
output.alpha_composite(pose, (x, 0))
return output
def combine_pose_images(paths: list[Path]) -> Image.Image:
poses = [Image.open(path).convert("RGBA") for path in paths]
cell_width = max(pose.width for pose in poses)
cell_height = max(pose.height for pose in poses)
output = Image.new("RGBA", (cell_width * len(poses), cell_height), (0, 0, 0, 0))
for index, pose in enumerate(poses):
x = index * cell_width + (cell_width - pose.width) // 2
y = (cell_height - pose.height) // 2
output.alpha_composite(pose, (x, y))
return output
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", type=Path)
parser.add_argument("--pose", type=Path, action="append", default=[])
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
if bool(args.input) == bool(args.pose):
raise ValueError("Use either --input triplet or exactly three --pose images")
if args.pose:
if len(args.pose) != 3:
raise ValueError(f"Expected exactly three --pose images, got {len(args.pose)}")
for path in args.pose:
if not path.exists():
raise FileNotFoundError(f"Canonical pose not found: {path}")
image = combine_pose_images(args.pose)
else:
if args.input is None or not args.input.exists():
raise FileNotFoundError(f"Pose triplet not found: {args.input}")
image = Image.open(args.input).convert("RGBA")
if image.width < 3 or image.height < 1:
raise ValueError(f"Invalid pose triplet size: {image.size}")
output = expand_pose_triplet(image)
args.output.parent.mkdir(parents=True, exist_ok=True)
output.save(args.output)
print(args.output)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env python3
"""Save single-row review images from an assembled WhaleTown spritesheet."""
from __future__ import annotations
import argparse
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
def checkerboard(width: int, height: int, tile: int) -> 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 main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sheet", type=Path, required=True)
parser.add_argument("--direction", default="down")
parser.add_argument("--row", type=int, default=0)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--feet-output", type=Path, required=True)
args = parser.parse_args()
frame_size = 160
columns = 8
row = Image.open(args.sheet).convert("RGBA").crop(
(0, args.row * frame_size, columns * frame_size, (args.row + 1) * frame_size)
)
font = ImageFont.load_default()
scale = 2
label_width = 68
preview = row.resize((row.width * scale, row.height * scale), Image.Resampling.NEAREST)
canvas = Image.new("RGB", (label_width + preview.width, preview.height), (246, 247, 250))
plate = checkerboard(preview.width, preview.height, 16)
plate.alpha_composite(preview)
canvas.paste(plate.convert("RGB"), (label_width, 0))
draw = ImageDraw.Draw(canvas)
draw.text((8, preview.height // 2 - 5), args.direction, fill=(28, 32, 36), font=font)
for index in range(columns + 1):
x = label_width + index * frame_size * scale
draw.line((x, 0, x, preview.height), fill=(205, 60, 60), width=1)
args.output.parent.mkdir(parents=True, exist_ok=True)
canvas.save(args.output)
feet_y = round(frame_size * 0.49)
feet_scale = 4
feet_height = frame_size - feet_y
cell_width = frame_size * feet_scale
cell_height = feet_height * feet_scale
label_height = 22
feet_canvas = Image.new(
"RGB", (label_width + columns * cell_width, label_height + cell_height), (246, 247, 250)
)
feet_draw = ImageDraw.Draw(feet_canvas)
feet_draw.text((7, label_height + cell_height // 2 - 5), args.direction, fill=(28, 32, 36), font=font)
for index in range(columns):
feet_draw.text((label_width + index * cell_width + 4, 5), f"F{index + 1}", fill=(72, 76, 82), font=font)
cell = row.crop((index * frame_size, feet_y, (index + 1) * frame_size, frame_size)).resize(
(cell_width, cell_height), Image.Resampling.NEAREST
)
plate = checkerboard(cell_width, cell_height, 20)
plate.alpha_composite(cell)
x = label_width + index * cell_width
feet_canvas.paste(plate.convert("RGB"), (x, label_height))
feet_draw.rectangle(
(x, label_height, x + cell_width - 1, label_height + cell_height - 1),
outline=(188, 194, 202),
width=1,
)
feet_canvas.save(args.feet_output)
if __name__ == "__main__":
main()