from __future__ import annotations from dataclasses import dataclass, replace from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] WORK_DIR = ROOT / "tmp" / "batch1-handpaint-sprites" UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" DOCS_DIR = ROOT / "docs" FRAME = 313 DIRECTIONS = ("south", "east", "north", "west") BASE_FRAMES_PER_DIRECTION = 16 ACTION_COUNTS = { "attack": 10, "strategy": 8, "item": 8, "hurt": 4, "celebrate": 6, } ACTION_OFFSETS = { "attack": 0, "strategy": 10, "item": 18, "hurt": 26, "celebrate": 30, } ACTION_FRAMES_PER_DIRECTION = sum(ACTION_COUNTS.values()) @dataclass(frozen=True) class UnitSpec: key: str label: str max_width: int = 292 max_height: int = 300 bottom: int = 306 stretch_x: float = 1.12 UNIT_SPECS = [ UnitSpec("unit-guan-yu", "Guan Yu", max_width=306, max_height=304, stretch_x=1.18), UnitSpec("unit-zhang-fei", "Zhang Fei", max_width=306, max_height=302, stretch_x=1.15), UnitSpec("unit-rebel", "Yellow Turban Infantry", max_width=286, max_height=298, stretch_x=1.10), UnitSpec("unit-rebel-archer", "Yellow Turban Archer", max_width=306, max_height=298, stretch_x=1.08), UnitSpec("unit-rebel-cavalry", "Yellow Turban Cavalry", max_width=306, max_height=292, stretch_x=1.02), ] MANUAL_ACTION_CROPS: dict[str, dict[str, list[tuple[int, int, int, int]]]] = { "unit-guan-yu": { "attack": [ (78, 154, 210, 286), (240, 154, 248, 286), (406, 154, 260, 286), (566, 154, 252, 286), (724, 154, 238, 286), (886, 154, 252, 286), (1046, 154, 252, 286), (1220, 154, 310, 286), (1408, 154, 246, 286), (1584, 154, 218, 286), ], "middle": [ (250, 500, 330, 340), (555, 500, 310, 340), (825, 500, 310, 340), (1110, 500, 330, 340), (1420, 500, 330, 340), ], "lower": [ (530, 762, 360, 330), (835, 762, 340, 330), (1160, 762, 380, 330), ], }, "unit-zhang-fei": { "attack": [ (82, 154, 222, 286), (250, 154, 238, 286), (420, 154, 262, 286), (590, 154, 262, 286), (756, 154, 266, 286), (925, 154, 270, 286), (1096, 154, 260, 286), (1268, 154, 270, 286), (1432, 154, 240, 286), (1586, 154, 218, 286), ], "middle": [ (260, 502, 360, 340), (555, 502, 330, 340), (825, 502, 330, 340), (1090, 502, 350, 340), (1402, 502, 360, 340), ], "lower": [ (520, 764, 380, 330), (835, 764, 360, 330), (1160, 764, 390, 330), ], }, "unit-rebel": { "attack": [ (82, 154, 214, 286), (260, 154, 218, 286), (454, 154, 236, 286), (635, 154, 238, 286), (810, 154, 236, 286), (990, 154, 250, 286), (1195, 154, 236, 286), (1375, 154, 230, 286), (1518, 154, 224, 286), (1618, 154, 210, 286), ], "middle": [ (315, 510, 350, 340), (580, 510, 340, 340), (825, 510, 320, 340), (1082, 510, 330, 340), (1320, 510, 330, 340), ], "lower": [ (650, 765, 360, 330), (852, 765, 330, 330), (1088, 765, 350, 330), ], }, "unit-rebel-archer": { "attack": [ (70, 154, 210, 286), (235, 154, 214, 286), (405, 154, 230, 286), (578, 154, 242, 286), (748, 154, 250, 286), (912, 154, 250, 286), (1080, 154, 260, 286), (1250, 154, 260, 286), (1420, 154, 232, 286), (1586, 154, 216, 286), ], "middle": [ (275, 510, 330, 340), (575, 510, 330, 340), (835, 510, 330, 340), (1095, 510, 330, 340), (1370, 510, 350, 340), ], "lower": [ (565, 765, 370, 330), (835, 765, 340, 330), (1085, 765, 370, 330), ], }, "unit-rebel-cavalry": { "attack": [ (82, 154, 280, 286), (255, 154, 286, 286), (430, 154, 300, 286), (605, 154, 300, 286), (780, 154, 300, 286), (960, 154, 320, 286), (1155, 154, 330, 286), (1355, 154, 310, 286), (1510, 154, 282, 286), (1610, 154, 230, 286), ], "middle": [ (350, 510, 410, 340), (610, 510, 390, 340), (845, 510, 390, 340), (1095, 510, 390, 340), (1335, 510, 420, 340), ], "lower": [ (560, 765, 440, 330), (855, 765, 390, 330), (1115, 765, 420, 330), ], }, } ACTION_CROPS_V2: dict[str, dict[str, list[tuple[int, int, int, int]]]] = { "unit-guan-yu": { "attack": [ (92, 150, 220, 280), (280, 150, 245, 280), (500, 150, 255, 280), (700, 150, 255, 280), (875, 150, 245, 280), (1045, 150, 255, 280), (1250, 150, 315, 280), (1430, 150, 245, 280), (1590, 150, 220, 280), (1698, 150, 180, 280), ], "middle": [ (255, 475, 330, 330), (555, 475, 310, 330), (825, 475, 310, 330), (1130, 475, 350, 330), (1465, 475, 330, 330), ], "lower": [ (540, 735, 380, 320), (835, 735, 360, 320), (1165, 735, 400, 320), ], }, "unit-zhang-fei": { "attack": [ (88, 150, 230, 280), (272, 150, 250, 280), (486, 150, 265, 280), (690, 150, 275, 280), (880, 150, 260, 280), (1062, 150, 255, 280), (1230, 150, 245, 280), (1398, 150, 245, 280), (1570, 150, 245, 280), (1695, 150, 185, 280), ], "middle": [ (250, 475, 350, 330), (565, 475, 330, 330), (850, 475, 330, 330), (1135, 475, 360, 330), (1460, 475, 360, 330), ], "lower": [ (525, 735, 400, 320), (850, 735, 370, 320), (1190, 735, 410, 320), ], }, "unit-rebel": { "attack": [ (80, 155, 215, 285), (245, 155, 220, 285), (410, 155, 225, 285), (590, 155, 225, 285), (755, 155, 230, 285), (940, 155, 265, 285), (1120, 155, 230, 285), (1285, 155, 225, 285), (1450, 155, 225, 285), (1595, 155, 205, 285), ], "middle": [ (275, 510, 350, 340), (550, 510, 340, 340), (835, 510, 330, 340), (1085, 510, 335, 340), (1345, 510, 350, 340), ], "lower": [ (590, 765, 370, 330), (835, 765, 340, 330), (1085, 765, 370, 330), ], }, "unit-rebel-archer": { "attack": [ (88, 150, 220, 280), (260, 150, 220, 280), (455, 150, 245, 280), (650, 150, 250, 280), (850, 150, 260, 280), (1040, 150, 260, 280), (1255, 150, 260, 280), (1435, 150, 245, 280), (1600, 150, 230, 280), (1600, 150, 230, 280), ], "middle": [ (270, 475, 340, 330), (570, 475, 330, 330), (850, 475, 330, 330), (1130, 475, 340, 330), (1445, 475, 360, 330), ], "lower": [ (555, 735, 380, 320), (835, 735, 350, 320), (1120, 735, 390, 320), ], }, "unit-rebel-cavalry": { "attack": [ (80, 154, 300, 286), (260, 154, 300, 286), (445, 154, 315, 286), (620, 154, 315, 286), (800, 154, 330, 286), (980, 154, 330, 286), (1145, 154, 320, 286), (1300, 154, 300, 286), (1460, 154, 285, 286), (1600, 154, 260, 286), ], "middle": [ (340, 510, 420, 340), (600, 510, 400, 340), (850, 510, 400, 340), (1095, 510, 400, 340), (1340, 510, 420, 340), ], "lower": [ (590, 765, 460, 330), (850, 765, 400, 330), (1120, 765, 430, 330), ], }, } def keyed_rgba(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") arr = np.array(rgba) rgb = arr[:, :, :3].astype(np.int16) r, g, b = rgb[:, :, 0], rgb[:, :, 1], rgb[:, :, 2] key = (r > 185) & (b > 185) & (g < 125) near_key = (r > 145) & (b > 135) & (g < 150) & ((r + b - g * 2) > 180) alpha = np.where(key | near_key, 0, 255).astype(np.uint8) body = alpha > 0 magenta_fringe = body & (r > 155) & (b > 135) & (g < 130) arr[:, :, 0] = np.where(magenta_fringe, np.minimum(arr[:, :, 0], 72), arr[:, :, 0]) arr[:, :, 2] = np.where(magenta_fringe, np.minimum(arr[:, :, 2], 72), arr[:, :, 2]) arr[:, :, 3] = alpha return Image.fromarray(arr, "RGBA") def connected_components(alpha: np.ndarray) -> list[tuple[int, int, int, int, int, float, float]]: height, width = alpha.shape visited = np.zeros_like(alpha, dtype=bool) components: list[tuple[int, int, int, int, int, float, float]] = [] for start_y in range(height): for start_x in range(width): if visited[start_y, start_x] or not alpha[start_y, start_x]: continue stack = [(start_x, start_y)] visited[start_y, start_x] = True xs: list[int] = [] ys: list[int] = [] while stack: x, y = stack.pop() xs.append(x) ys.append(y) for ny in (y - 1, y, y + 1): if ny < 0 or ny >= height: continue for nx in (x - 1, x, x + 1): if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]: continue visited[ny, nx] = True stack.append((nx, ny)) area = len(xs) if area < 18: continue left, right = min(xs), max(xs) top, bottom = min(ys), max(ys) components.append((area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2)) return components def connected_component_masks( alpha: np.ndarray, ) -> list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]]: height, width = alpha.shape visited = np.zeros_like(alpha, dtype=bool) components: list[tuple[tuple[int, int, int, int, int, float, float], np.ndarray]] = [] for start_y in range(height): for start_x in range(width): if visited[start_y, start_x] or not alpha[start_y, start_x]: continue stack = [(start_x, start_y)] visited[start_y, start_x] = True xs: list[int] = [] ys: list[int] = [] mask = np.zeros_like(alpha, dtype=bool) while stack: x, y = stack.pop() xs.append(x) ys.append(y) mask[y, x] = True for ny in (y - 1, y, y + 1): if ny < 0 or ny >= height: continue for nx in (x - 1, x, x + 1): if nx < 0 or nx >= width or visited[ny, nx] or not alpha[ny, nx]: continue visited[ny, nx] = True stack.append((nx, ny)) area = len(xs) if area < 18: continue left, right = min(xs), max(xs) top, bottom = min(ys), max(ys) component = (area, left, top, right, bottom, (left + right) / 2, (top + bottom) / 2) components.append((component, mask)) return components def keep_subject_components(image: Image.Image) -> Image.Image: arr = np.array(image.convert("RGBA")) alpha = arr[:, :, 3] > 0 height, width = alpha.shape components = connected_components(alpha) if not components: return image image_center_x = width / 2 image_center_y = height * 0.60 anchor = max( components, key=lambda component: component[0] - abs(component[5] - image_center_x) * 2.6 - abs(component[6] - image_center_y) * 1.2, ) anchor_area, left, top, right, bottom, anchor_cx, _ = anchor expanded = (left - 92, top - 92, right + 92, bottom + 76) keep = np.zeros_like(alpha, dtype=bool) for component in components: area, c_left, c_top, c_right, c_bottom, cx, cy = component near_anchor = ( c_right >= expanded[0] and c_left <= expanded[2] and c_bottom >= expanded[1] and c_top <= expanded[3] ) central = 0.015 * width <= cx <= 0.985 * width substantial = area >= max(22, anchor_area * 0.006) edge_fragment = ( component != anchor and (c_left <= 1 or c_right >= width - 2 or c_top <= 1 or c_bottom >= height - 2) and area < anchor_area * 0.45 ) far_fragment = component != anchor and abs(cx - anchor_cx) > width * 0.30 and area < anchor_area * 0.75 if component == anchor or (near_anchor and central and substantial and not edge_fragment and not far_fragment): keep[c_top : c_bottom + 1, c_left : c_right + 1] |= alpha[c_top : c_bottom + 1, c_left : c_right + 1] arr[:, :, 3] = np.where(keep, arr[:, :, 3], 0).astype(np.uint8) return Image.fromarray(arr, "RGBA") def crop_subject(image: Image.Image) -> Image.Image: rgba = keep_subject_components(keyed_rgba(image)) alpha = np.array(rgba.getchannel("A")) ys, xs = np.where(alpha > 0) if len(xs) == 0: return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) pad = 9 left = max(int(xs.min()) - pad, 0) top = max(int(ys.min()) - pad, 0) right = min(int(xs.max()) + pad + 1, rgba.width) bottom = min(int(ys.max()) + pad + 1, rgba.height) return rgba.crop((left, top, right, bottom)) def quantize_alpha(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") arr = np.array(rgba) arr[:, :, 3] = np.where(arr[:, :, 3] > 24, 255, 0).astype(np.uint8) return Image.fromarray(arr, "RGBA") def fit_subject(source: Image.Image, spec: UnitSpec, scale_bias: float = 1.0) -> Image.Image: subject = crop_subject(source) if subject.width <= 1 or subject.height <= 1: return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) max_width = min(FRAME - 4, round(spec.max_width * scale_bias)) max_height = min(FRAME - 4, round(spec.max_height * scale_bias)) scale = min(max_width / subject.width, max_height / subject.height) size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale))) subject = subject.resize(size, Image.Resampling.LANCZOS) stretched_width = min(max_width, max(1, round(subject.width * spec.stretch_x))) if stretched_width != subject.width: subject = subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS) subject = quantize_alpha(subject) frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) x = (FRAME - subject.width) // 2 y = spec.bottom - subject.height frame.alpha_composite(subject, (x, y)) return quantize_alpha(keep_subject_components(frame)) def compact_frame_width(frame: Image.Image, max_content_width: int) -> Image.Image: rgba = frame.convert("RGBA") alpha = np.array(rgba.getchannel("A")) ys, xs = np.where(alpha > 0) if len(xs) == 0: return rgba left = int(xs.min()) right = int(xs.max()) + 1 top = int(ys.min()) bottom = int(ys.max()) + 1 content = rgba.crop((left, top, right, bottom)) if content.width <= max_content_width: return rgba content = content.resize((max_content_width, content.height), Image.Resampling.LANCZOS) content = quantize_alpha(content) out = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) out.alpha_composite(content, ((FRAME - max_content_width) // 2, bottom - content.height)) return quantize_alpha(keep_subject_components(out)) def compact_walk_frame(frame: Image.Image, spec: UnitSpec) -> Image.Image: if spec.key == "unit-guan-yu": return compact_frame_width(frame, 230) if spec.key == "unit-zhang-fei": return compact_frame_width(frame, 220) return frame def crop_grid(image: Image.Image, row: int, col: int, rows: int, cols: int, x_pad: int = 0, y_pad: int = 0) -> Image.Image: cell_w = image.width / cols cell_h = image.height / rows left = max(0, int(round(col * cell_w)) - x_pad) top = max(0, int(round(row * cell_h)) - y_pad) right = min(image.width, int(round((col + 1) * cell_w)) + x_pad) bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad) return image.crop((left, top, right, bottom)) def crop_row(image: Image.Image, row: int, rows: int, y_pad: int = 0) -> Image.Image: cell_h = image.height / rows top = max(0, int(round(row * cell_h)) - y_pad) bottom = min(image.height, int(round((row + 1) * cell_h)) + y_pad) return image.crop((0, top, image.width, bottom)) def crop_center_xy(image: Image.Image, center_x: int, center_y: int, width: int, height: int) -> Image.Image: left = max(0, center_x - width // 2) top = max(0, center_y - height // 2) right = min(image.width, center_x + width // 2) bottom = min(image.height, center_y + height // 2) return image.crop((left, top, right, bottom)) def tightened_crop(crop: tuple[int, int, int, int], spec: UnitSpec, group: str) -> tuple[int, int, int, int]: center_x, center_y, width, height = crop if group == "attack": return center_x, center_y, width, height return center_x, center_y, width, height def action_fit_spec(spec: UnitSpec, group: str) -> UnitSpec: if group != "attack": return spec attack_widths = { "unit-guan-yu": 274, "unit-zhang-fei": 276, "unit-rebel-cavalry": 286, } max_width = attack_widths.get(spec.key) if max_width is None: return spec return replace(spec, max_width=max_width) def extract_row_subjects(image: Image.Image, row: int, rows: int, expected: int, y_pad: int = 0) -> list[Image.Image]: row_image = keyed_rgba(crop_row(image, row, rows, y_pad)) arr = np.array(row_image.convert("RGBA")) alpha = arr[:, :, 3] > 0 component_items = connected_component_masks(alpha) if not component_items: return [crop_grid(image, row, col, rows, expected) for col in range(expected)] width = row_image.width slot_width = width / expected slot_centers = [(slot + 0.5) * slot_width for slot in range(expected)] def body_score(component: tuple[int, int, int, int, int, float, float], slot_center: float) -> float: area, left, top, right, bottom, cx, _ = component component_width = right - left + 1 component_height = bottom - top + 1 aspect = component_width / max(1, component_height) broadness = min(1.25, max(0.18, aspect)) slender_penalty = 0.25 if component_width < 18 or aspect < 0.16 else 1.0 edge_penalty = 0.55 if left <= 1 or right >= width - 2 else 1.0 return area * broadness * slender_penalty * edge_penalty - abs(cx - slot_center) * 42 anchors: list[int] = [] used: set[int] = set() indexed_components = list(enumerate(component_items)) for slot_center in slot_centers: candidates = [ (index, item) for index, item in indexed_components if index not in used and item[0][0] >= 90 ] if not candidates: anchors.append(max(range(len(component_items)), key=lambda index: body_score(component_items[index][0], slot_center))) continue index, _ = max(candidates, key=lambda item: body_score(item[1][0], slot_center)) used.add(index) anchors.append(index) masks = [np.zeros_like(alpha, dtype=bool) for _ in range(expected)] for component, component_mask in component_items: area, left, top, right, bottom, cx, _ = component if area < 28: continue slot = min(range(expected), key=lambda index: abs(cx - component_items[anchors[index]][0][5])) anchor_area, anchor_left, anchor_top, anchor_right, anchor_bottom, anchor_cx, _ = component_items[anchors[slot]][0] expanded = ( anchor_left - slot_width * 0.78, anchor_top - 96, anchor_right + slot_width * 0.78, anchor_bottom + 96, ) near_anchor = right >= expanded[0] and left <= expanded[2] and bottom >= expanded[1] and top <= expanded[3] likely_neighbor_fragment = ( component != component_items[anchors[slot]][0] and (left <= 2 or right >= width - 3) and area < max(700, anchor_area * 0.18) ) if not near_anchor or likely_neighbor_fragment: continue masks[slot] |= component_mask subjects: list[Image.Image] = [] for slot, mask in enumerate(masks): ys, xs = np.where(mask) if len(xs) == 0: subjects.append(crop_grid(image, row, slot, rows, expected)) continue pad = 10 left = max(int(xs.min()) - pad, 0) top = max(int(ys.min()) - pad, 0) right = min(int(xs.max()) + pad + 1, row_image.width) bottom = min(int(ys.max()) + pad + 1, row_image.height) subject_arr = np.array(row_image.crop((left, top, right, bottom)).convert("RGBA")) subject_mask = mask[top:bottom, left:right] subject_arr[:, :, 3] = np.where(subject_mask, subject_arr[:, :, 3], 0).astype(np.uint8) subjects.append(Image.fromarray(subject_arr, "RGBA")) return subjects def build_base_sheet(base_source: Image.Image, spec: UnitSpec) -> Image.Image: rows: list[list[Image.Image]] = [] for row in range(4): frames = [fit_subject(crop_grid(base_source, row, col, 4, 8, 0, 2), spec) for col in range(8)] idle = [frames[0].copy() for _ in range(8)] walk = [compact_walk_frame(frame, spec) for frame in frames] rows.append(idle + walk) sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0)) for row, frames in enumerate(rows): for col, frame in enumerate(frames): sheet.alpha_composite(frame, (col * FRAME, row * FRAME)) return sheet def build_action_source_frames(action_source: Image.Image, spec: UnitSpec) -> dict[str, list[Image.Image]]: layout = ACTION_CROPS_V2.get(spec.key) if layout: attack_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "attack")) for crop in layout["attack"]] middle_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "middle")) for crop in layout["middle"]] lower_sources = [crop_center_xy(action_source, *tightened_crop(crop, spec, "lower")) for crop in layout["lower"]] else: attack_sources = [crop_grid(action_source, 0, col, 3, 10, 0, 18) for col in range(10)] middle_sources = [crop_grid(action_source, 1, col, 3, 5, 0, 18) for col in range(5)] lower_sources = [crop_grid(action_source, 2, col, 3, 3, 0, 12) for col in range(3)] attack_frames = [fit_subject(source, action_fit_spec(spec, "attack")) for source in attack_sources] middle = [fit_subject(source, spec) for source in middle_sources] lower = [fit_subject(source, spec) for source in lower_sources] return { "attack": attack_frames, "strategy": [middle[index % len(middle)] for index in (0, 1, 2, 1, 0, 1, 2, 1)], "item": [middle[index % len(middle)] for index in (3, 4, 3, 4, 3, 4, 3, 4)], "hurt": [lower[0] for _ in range(4)], "celebrate": [lower[1], lower[2], lower[1], lower[2], lower[1], lower[2]], } def direction_frame(frame: Image.Image, direction: str) -> Image.Image: if direction == "west": return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) return frame def build_action_sheet(action_source: Image.Image, spec: UnitSpec) -> Image.Image: source_frames = build_action_source_frames(action_source, spec) sheet = Image.new("RGBA", (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0)) for row, direction in enumerate(DIRECTIONS): for action, frames in source_frames.items(): offset = ACTION_OFFSETS[action] for index, frame in enumerate(frames): sheet.alpha_composite(direction_frame(frame, direction), ((offset + index) * FRAME, row * FRAME)) return sheet def representative_base_frames(base_sheet: Image.Image) -> list[Image.Image]: picks = [(0, 0), (0, 10), (1, 10), (2, 10), (3, 10)] return [base_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks] def representative_action_frames(action_sheet: Image.Image) -> list[Image.Image]: picks = [ (1, 0), (1, 3), (1, 6), (0, ACTION_OFFSETS["strategy"] + 2), (0, ACTION_OFFSETS["item"] + 1), (0, ACTION_OFFSETS["hurt"]), (0, ACTION_OFFSETS["celebrate"] + 1), ] return [action_sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) for row, col in picks] def checker_background(size: tuple[int, int], tile: int = 16) -> Image.Image: image = Image.new("RGBA", size, (64, 84, 53, 255)) draw = ImageDraw.Draw(image) for y in range(0, size[1], tile): for x in range(0, size[0], tile): color = (75, 97, 60, 255) if ((x // tile) + (y // tile)) % 2 == 0 else (55, 72, 47, 255) draw.rectangle((x, y, x + tile - 1, y + tile - 1), fill=color) return image def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image: bg = checker_background((size, size), 8) scaled = frame.resize((size, size), Image.Resampling.LANCZOS) bg.alpha_composite(scaled, (0, 0)) return bg def save_contact_sheet(spec: UnitSpec, base_sheet: Image.Image, action_sheet: Image.Image) -> Path: thumbs = representative_base_frames(base_sheet) + representative_action_frames(action_sheet) labels = ["idle", "walk S", "walk E", "walk N", "walk W", "atk 1", "atk 4", "atk 7", "cmd", "item", "hurt", "win"] thumb_size = 132 pad = 18 label_h = 24 cols = 6 rows = 2 out = Image.new("RGBA", (pad + cols * (thumb_size + pad), pad + rows * (thumb_size + label_h + pad)), (20, 24, 22, 255)) draw = ImageDraw.Draw(out) font = ImageFont.load_default() draw.text((pad, 4), spec.label, fill=(240, 226, 178, 255), font=font) for index, frame in enumerate(thumbs): x = pad + (index % cols) * (thumb_size + pad) y = 22 + pad + (index // cols) * (thumb_size + label_h + pad) tile = draw_frame_on_bg(frame, thumb_size) out.alpha_composite(tile, (x, y)) draw.text((x, y + thumb_size + 5), labels[index], fill=(238, 224, 178, 255), font=font) path = DOCS_DIR / f"handpaint-batch1-{spec.key}-contact.png" out.convert("RGB").save(path, optimize=True) return path def save_before_after(spec: UnitSpec, base_sheet: Image.Image) -> Path: before_path = WORK_DIR / f"before-{spec.key}.png" before = Image.open(before_path).convert("RGBA").crop((0, 0, FRAME, FRAME)) after = base_sheet.crop((0, 0, FRAME, FRAME)) out = Image.new("RGBA", (820, 360), (18, 21, 20, 255)) draw = ImageDraw.Draw(out) font = ImageFont.load_default() draw.text((30, 18), f"{spec.label} Before / After at 313px", fill=(238, 224, 178, 255), font=font) out.alpha_composite(draw_frame_on_bg(before, 160), (30, 48)) out.alpha_composite(draw_frame_on_bg(after, 160), (220, 48)) draw.text((30, 218), "Before / After at battle scale", fill=(238, 224, 178, 255), font=font) out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252)) out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252)) draw.text((30, 330), "left: previous asset, right: new hand-painted batch 1 asset", fill=(172, 185, 166, 255), font=font) path = DOCS_DIR / f"handpaint-batch1-{spec.key}-before-after.png" out.convert("RGB").save(path, optimize=True) return path def save_animation_gif(spec: UnitSpec, base_sheet: Image.Image, action_sheet: Image.Image) -> Path: frames: list[Image.Image] = [] for index in range(10): canvas = checker_background((540, 190), 10) idle = base_sheet.crop(((index % 8) * FRAME, 0, ((index % 8) + 1) * FRAME, FRAME)) walk = base_sheet.crop(((8 + index % 8) * FRAME, FRAME, (9 + index % 8) * FRAME, 2 * FRAME)) attack = action_sheet.crop((index * FRAME, FRAME, (index + 1) * FRAME, 2 * FRAME)) for x, frame in ((22, idle), (190, walk), (358, attack)): scaled = frame.resize((150, 150), Image.Resampling.LANCZOS) canvas.alpha_composite(scaled, (x, 25)) frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=128)) path = DOCS_DIR / f"handpaint-batch1-{spec.key}-animation.gif" frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=110, loop=0) return path def copy_before_once(spec: UnitSpec) -> None: for suffix in ("", "-actions"): source = UNIT_DIR / f"{spec.key}{suffix}.png" target = WORK_DIR / f"before-{spec.key}{suffix}.png" if not target.exists(): target.write_bytes(source.read_bytes()) def validate_sheet(path: Path, expected_size: tuple[int, int]) -> tuple[tuple[int, int], int, int]: image = Image.open(path).convert("RGBA") if image.size != expected_size: raise ValueError(f"{path.name}: expected {expected_size}, got {image.size}") alpha = np.array(image.getchannel("A")) partial = int(np.count_nonzero((alpha > 0) & (alpha < 255))) opaque = int(np.count_nonzero(alpha == 255)) if partial: raise ValueError(f"{path.name}: found {partial} partially transparent pixels") if opaque <= 0: raise ValueError(f"{path.name}: no opaque subject pixels found") return image.size, partial, opaque def save_overview(processed: list[tuple[UnitSpec, Image.Image, Image.Image]]) -> Path: thumb = 96 pad = 14 label_h = 20 cols = 5 rows = 3 out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad)), (19, 23, 21, 255)) draw = ImageDraw.Draw(out) font = ImageFont.load_default() for col, (spec, base_sheet, action_sheet) in enumerate(processed): frames = [ base_sheet.crop((0, 0, FRAME, FRAME)), base_sheet.crop((10 * FRAME, FRAME, 11 * FRAME, 2 * FRAME)), action_sheet.crop((3 * FRAME, FRAME, 4 * FRAME, 2 * FRAME)), ] for row, frame in enumerate(frames): x = pad + col * (thumb + pad) y = pad + row * (thumb + label_h + pad) out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y)) if row == 0: draw.text((x, y + thumb + 4), spec.key.replace("unit-", ""), fill=(238, 224, 178, 255), font=font) path = DOCS_DIR / "handpaint-batch1-overview-contact.png" out.convert("RGB").save(path, optimize=True) return path def main() -> None: WORK_DIR.mkdir(parents=True, exist_ok=True) processed: list[tuple[UnitSpec, Image.Image, Image.Image]] = [] alpha_lines = ["# Handpaint Batch 1 Alpha Validation", ""] for spec in UNIT_SPECS: copy_before_once(spec) base_source = Image.open(WORK_DIR / f"source-{spec.key}-base-motion.png").convert("RGBA") action_source = Image.open(WORK_DIR / f"source-{spec.key}-action.png").convert("RGBA") base_sheet = build_base_sheet(base_source, spec) action_sheet = build_action_sheet(action_source, spec) base_path = UNIT_DIR / f"{spec.key}.png" action_path = UNIT_DIR / f"{spec.key}-actions.png" base_sheet.save(base_path, optimize=True) action_sheet.save(action_path, optimize=True) contact_path = save_contact_sheet(spec, base_sheet, action_sheet) before_after_path = save_before_after(spec, base_sheet) animation_path = save_animation_gif(spec, base_sheet, action_sheet) base_result = validate_sheet(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS))) action_result = validate_sheet(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS))) alpha_lines.extend( [ f"## {spec.key}", f"- base: size {base_result[0]}, partial alpha {base_result[1]}, opaque pixels {base_result[2]}", f"- action: size {action_result[0]}, partial alpha {action_result[1]}, opaque pixels {action_result[2]}", f"- contact: `{contact_path.name}`", f"- animation: `{animation_path.name}`", f"- before/after: `{before_after_path.name}`", "", ] ) processed.append((spec, base_sheet, action_sheet)) print(f"Wrote {base_path}") print(f"Wrote {action_path}") print(f"Wrote {contact_path}") print(f"Wrote {before_after_path}") print(f"Wrote {animation_path}") overview_path = save_overview(processed) alpha_report = DOCS_DIR / "handpaint-batch1-alpha-report.md" alpha_report.write_text("\n".join(alpha_lines), encoding="utf-8") print(f"Wrote {overview_path}") print(f"Wrote {alpha_report}") if __name__ == "__main__": main()