from __future__ import annotations import argparse from pathlib import Path import numpy as np from PIL import Image, ImageDraw, ImageFont ROOT = Path(__file__).resolve().parents[1] SOURCE = ROOT / "docs" / "handpaint-batch2-shu-infantry-quality-restart-source-v3.png" WORK_DIR = ROOT / "tmp" / "handpaint-shu-infantry-quality-sample" UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" DOCS_DIR = ROOT / "docs" BATTLE_REFERENCE = DOCS_DIR / "handpaint-batch1-liu-guan-size-match-battle.png" STEM = "unit-shu-infantry" OUTPUT_PREFIX = "handpaint-batch2-shu-infantry-quality-restart-v3" CONTACT_TITLE = "Shu Infantry Quality Restart Sample v3" BEFORE_TITLE = "Shu Infantry Previous / Quality Restart Sample v3" BEFORE_SCALE_LABEL = "Previous / v3 at battle scale" REPORT_TITLE = "Shu Infantry Quality Restart Sample v3" SAMPLE_LABEL = "shu sample" COMPARE_STEMS = ["unit-liu-bei", "unit-guan-yu", "unit-zhang-fei", "unit-rebel", STEM] COMPARE_LABELS = ["liu-bei", "guan-yu", "zhang-fei", "rebel", "shu-infantry"] FIT_WIDTH_SCALE = 1.0 MIN_FRAME_HEIGHT = 0 STRETCH_X = 1.0 ACTION_SHRINK_X = 1.0 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()) def remove_magenta(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 > 200) & (b > 200) & (g < 115) near_key = (r > 145) & (b > 135) & (g < 140) & ((r + b - g * 2) > 145) alpha = np.where(key | near_key, 0, 255).astype(np.uint8) fringe = (alpha > 0) & (r > 150) & (b > 150) & (g < 140) arr[:, :, 0] = np.where(fringe, np.minimum(arr[:, :, 0], 90), arr[:, :, 0]) arr[:, :, 2] = np.where(fringe, np.minimum(arr[:, :, 2], 90), arr[:, :, 2]) arr[:, :, 3] = alpha return Image.fromarray(arr, "RGBA") def keep_useful_components(image: Image.Image, allow_detached_effects = False) -> Image.Image: arr = np.array(image.convert("RGBA")) alpha = arr[:, :, 3] > 0 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 < 24: 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)) if not components: return image anchor = max(components, key=lambda item: item[0]) anchor_area, left, top, right, bottom, anchor_cx, anchor_cy = anchor expanded = (left - 95, top - 90, right + 95, bottom + 82) 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] big_effect = area >= max(550, anchor_area * 0.07) useful = component == anchor or (near_anchor and area >= 55) or (allow_detached_effects and big_effect) if useful: 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 keep_largest_component(image: Image.Image) -> Image.Image: arr = np.array(image.convert("RGBA")) alpha = arr[:, :, 3] > 0 height, width = alpha.shape visited = np.zeros_like(alpha, dtype=bool) best_mask: np.ndarray | None = None best_area = 0 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 mask = np.zeros_like(alpha, dtype=bool) area = 0 while stack: x, y = stack.pop() mask[y, x] = True area += 1 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)) if area > best_area: best_area = area best_mask = mask if best_mask is None: return image arr[:, :, 3] = np.where(best_mask, arr[:, :, 3], 0).astype(np.uint8) return Image.fromarray(arr, "RGBA") def crop_cell(source: Image.Image, row: int, col: int) -> Image.Image: width, height = source.size left = round(col * width / 6) right = round((col + 1) * width / 6) top = round(row * height / 2) bottom = round((row + 1) * height / 2) pad_x = 0 pad_y = 6 return source.crop((max(0, left - pad_x), max(0, top - pad_y), min(width, right + pad_x), min(height, bottom + pad_y))) def crop_subject(image: Image.Image, allow_detached_effects = False) -> Image.Image: rgba = keep_useful_components(remove_magenta(image), allow_detached_effects) return trim_to_alpha(rgba, pad=7) def trim_to_alpha(image: Image.Image, pad: int = 0) -> Image.Image: rgba = image.convert("RGBA") alpha = np.array(rgba.getchannel("A")) ys, xs = np.where(alpha > 0) if len(xs) == 0: return Image.new("RGBA", (1, 1), (0, 0, 0, 0)) 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 solidify_alpha(rgba.crop((left, top, right, bottom))) def solidify_alpha(image: Image.Image) -> Image.Image: rgba = image.convert("RGBA") arr = np.array(rgba) arr[:, :, 3] = np.where(arr[:, :, 3] > 20, 255, 0).astype(np.uint8) return Image.fromarray(arr, "RGBA") def grow_to_minimum_height(subject: Image.Image, max_width: int, max_height: int) -> Image.Image: if MIN_FRAME_HEIGHT <= 0 or subject.height >= MIN_FRAME_HEIGHT: return subject grow = min(max_width / subject.width, max_height / subject.height, MIN_FRAME_HEIGHT / subject.height) if grow <= 1: return subject size = (max(1, round(subject.width * grow)), max(1, round(subject.height * grow))) return trim_to_alpha(solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS))) def stretch_subject_x(subject: Image.Image, max_width: int) -> Image.Image: if STRETCH_X <= 1: return subject stretched_width = min(max_width, max(1, round(subject.width * STRETCH_X))) if stretched_width <= subject.width: return subject return trim_to_alpha(solidify_alpha(subject.resize((stretched_width, subject.height), Image.Resampling.LANCZOS))) def shrink_action_frame_x(frame: Image.Image) -> Image.Image: if ACTION_SHRINK_X >= 1: return frame rgba = solidify_alpha(frame) alpha = np.array(rgba.getchannel("A")) ys, xs = np.where(alpha > 0) if len(xs) == 0: return rgba left, right = int(xs.min()), int(xs.max()) + 1 top, bottom = int(ys.min()), int(ys.max()) + 1 subject = rgba.crop((left, top, right, bottom)) target_width = max(1, round(subject.width * ACTION_SHRINK_X)) if target_width >= subject.width: return rgba subject = solidify_alpha(subject.resize((target_width, subject.height), Image.Resampling.LANCZOS)) out = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) out.alpha_composite(subject, ((FRAME - target_width) // 2, bottom - subject.height)) return solidify_alpha(out) def fit_frame(source: Image.Image, max_width: int, max_height: int, bottom: int = 304, x_offset: int = 0, allow_detached_effects = False) -> Image.Image: max_width = int(round(max_width * FIT_WIDTH_SCALE)) subject = crop_subject(source, allow_detached_effects) if subject.width <= 1 or subject.height <= 1: return Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) scale = min(max_width / subject.width, max_height / subject.height) size = (max(1, round(subject.width * scale)), max(1, round(subject.height * scale))) subject = solidify_alpha(subject.resize(size, Image.Resampling.LANCZOS)) subject = trim_to_alpha(subject) subject = grow_to_minimum_height(subject, max_width, max_height) if not allow_detached_effects: subject = keep_largest_component(subject) subject = trim_to_alpha(subject) subject = grow_to_minimum_height(subject, max_width, max_height) subject = stretch_subject_x(subject, max_width) frame = Image.new("RGBA", (FRAME, FRAME), (0, 0, 0, 0)) x = (FRAME - subject.width) // 2 + x_offset y = bottom - subject.height frame.alpha_composite(subject, (x, y)) return solidify_alpha(frame) def mirror(frame: Image.Image) -> Image.Image: return frame.transpose(Image.Transpose.FLIP_LEFT_RIGHT) def load_pose_frames() -> dict[str, Image.Image]: source = Image.open(SOURCE).convert("RGB") cells = {(row, col): crop_cell(source, row, col) for row in range(2) for col in range(6)} return { "idle_front": fit_frame(cells[(0, 0)], 252, 300), "walk_front": fit_frame(cells[(0, 1)], 252, 300), "walk_side": fit_frame(cells[(0, 2)], 266, 300), "walk_back": fit_frame(cells[(0, 3)], 254, 300), "ready_side": fit_frame(cells[(0, 4)], 266, 300), "attack_ready": fit_frame(cells[(0, 5)], 284, 294), "attack_slash": fit_frame(cells[(1, 0)], 300, 294, allow_detached_effects=True), "attack_thrust": fit_frame(cells[(1, 1)], 292, 294), "command": keep_largest_component(fit_frame(cells[(1, 2)], 264, 300)), "hurt": fit_frame(cells[(1, 3)], 252, 294), "victory": keep_largest_component(fit_frame(cells[(1, 4)], 264, 300)), "walk_side_alt": fit_frame(cells[(1, 5)], 266, 300), } def build_base_sheet(poses: dict[str, Image.Image]) -> Image.Image: rows = { "south": { "idle": [poses["idle_front"]] * 8, "walk": [poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"], poses["walk_front"], poses["idle_front"]], }, "east": { "idle": [poses["ready_side"]] * 8, "walk": [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]], }, "north": { "idle": [poses["walk_back"]] * 8, "walk": [poses["walk_back"]] * 8, }, "west": { "idle": [mirror(poses["ready_side"])] * 8, "walk": [mirror(frame) for frame in [poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["walk_side_alt"], poses["ready_side"], poses["walk_side"], poses["ready_side"]]], }, } sheet = Image.new("RGBA", (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), (0, 0, 0, 0)) for row, direction in enumerate(DIRECTIONS): frames = rows[direction]["idle"] + rows[direction]["walk"] for col, frame in enumerate(frames): sheet.alpha_composite(solidify_alpha(frame), (col * FRAME, row * FRAME)) return solidify_alpha(sheet) def directed(frame: Image.Image, direction: str) -> Image.Image: if direction == "west": return mirror(frame) return frame def build_action_sheet(poses: dict[str, Image.Image]) -> Image.Image: attack = [ poses["attack_ready"], poses["attack_ready"], poses["attack_thrust"], poses["attack_slash"], poses["attack_slash"], poses["attack_thrust"], poses["ready_side"], poses["walk_side_alt"], poses["ready_side"], poses["ready_side"], ] strategy = [poses["command"], poses["command"], poses["victory"], poses["command"], poses["command"], poses["victory"], poses["command"], poses["idle_front"]] item = [poses["idle_front"], poses["command"], poses["victory"], poses["command"], poses["idle_front"], poses["command"], poses["victory"], poses["idle_front"]] hurt = [poses["hurt"]] * 4 celebrate = [poses["victory"], poses["command"], poses["victory"], poses["command"], poses["victory"], poses["idle_front"]] groups = { "attack": attack, "strategy": strategy, "item": item, "hurt": hurt, "celebrate": celebrate, } 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 groups.items(): offset = ACTION_OFFSETS[action] for index, frame in enumerate(frames): action_frame = directed(frame, direction) if direction == "south" and action == "attack": action_frame = frame if direction == "north" and action in {"attack", "strategy", "item", "celebrate"}: action_frame = poses["walk_back"] if action == "attack" else poses["command"] action_frame = shrink_action_frame_x(action_frame) sheet.alpha_composite(solidify_alpha(action_frame), ((offset + index) * FRAME, row * FRAME)) return solidify_alpha(sheet) 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 font(size: int = 12): for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"): try: return ImageFont.truetype(candidate, size) except OSError: continue return ImageFont.load_default() def draw_frame_on_bg(frame: Image.Image, size: int) -> Image.Image: bg = checker_background((size, size), 8) scaled = solidify_alpha(frame).resize((size, size), Image.Resampling.LANCZOS) bg.alpha_composite(scaled, (0, 0)) return bg def crop_frame(sheet: Image.Image, row: int, col: int) -> Image.Image: return sheet.crop((col * FRAME, row * FRAME, (col + 1) * FRAME, (row + 1) * FRAME)) def save_contact_sheet(base_sheet: Image.Image, action_sheet: Image.Image) -> Path: picks = [ ("idle", crop_frame(base_sheet, 0, 0)), ("walk S", crop_frame(base_sheet, 0, 8)), ("walk E", crop_frame(base_sheet, 1, 8)), ("walk N", crop_frame(base_sheet, 2, 8)), ("walk W", crop_frame(base_sheet, 3, 8)), ("atk 1", crop_frame(action_sheet, 1, 0)), ("atk 4", crop_frame(action_sheet, 1, 3)), ("atk 7", crop_frame(action_sheet, 1, 6)), ("cmd", crop_frame(action_sheet, 0, ACTION_OFFSETS["strategy"] + 2)), ("item", crop_frame(action_sheet, 0, ACTION_OFFSETS["item"] + 1)), ("hurt", crop_frame(action_sheet, 0, ACTION_OFFSETS["hurt"])), ("win", crop_frame(action_sheet, 0, ACTION_OFFSETS["celebrate"] + 1)), ] thumb = 132 pad = 18 label_h = 24 cols = 6 rows = 2 out = Image.new("RGBA", (pad + cols * (thumb + pad), pad + rows * (thumb + label_h + pad) + 18), (20, 24, 22, 255)) draw = ImageDraw.Draw(out) draw.text((pad, 5), CONTACT_TITLE, fill=(240, 226, 178, 255), font=font(14)) for index, (label, frame) in enumerate(picks): x = pad + (index % cols) * (thumb + pad) y = 24 + pad + (index // cols) * (thumb + label_h + pad) out.alpha_composite(draw_frame_on_bg(frame, thumb), (x, y)) draw.text((x, y + thumb + 5), label, fill=(238, 224, 178, 255), font=font(11)) path = DOCS_DIR / f"{OUTPUT_PREFIX}-contact.png" out.convert("RGB").save(path, optimize=True) return path def save_before_after(base_sheet: Image.Image) -> Path: before = Image.open(WORK_DIR / f"before-{STEM}.png").convert("RGBA").crop((0, 0, FRAME, FRAME)) after = crop_frame(base_sheet, 0, 0) out = Image.new("RGBA", (820, 360), (18, 21, 20, 255)) draw = ImageDraw.Draw(out) draw.text((30, 18), BEFORE_TITLE, fill=(238, 224, 178, 255), font=font(14)) 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_SCALE_LABEL, fill=(238, 224, 178, 255), font=font(12)) out.alpha_composite(draw_frame_on_bg(before, 68), (30, 252)) out.alpha_composite(draw_frame_on_bg(after, 68), (128, 252)) path = DOCS_DIR / f"{OUTPUT_PREFIX}-before-after.png" out.convert("RGB").save(path, optimize=True) return path def save_animation_gif(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 = crop_frame(base_sheet, 0, index % 8) walk = crop_frame(base_sheet, 1, 8 + index % 8) attack = crop_frame(action_sheet, 1, index) for x, frame in ((22, idle), (190, walk), (358, attack)): canvas.alpha_composite(frame.resize((150, 150), Image.Resampling.LANCZOS), (x, 25)) frames.append(canvas.convert("P", palette=Image.Palette.ADAPTIVE, colors=160)) path = DOCS_DIR / f"{OUTPUT_PREFIX}-animation.gif" frames[0].save(path, save_all=True, append_images=frames[1:], optimize=True, duration=120, loop=0) return path def save_scale_compare() -> Path: stems = COMPARE_STEMS labels = COMPARE_LABELS tile = 50 zoom = 72 gap = 22 width = 28 + len(stems) * (zoom + gap) height = 210 out = Image.new("RGBA", (width, height), (19, 25, 21, 255)) draw = ImageDraw.Draw(out) draw.text((18, 10), "Battle-scale readability compare", fill=(240, 226, 178, 255), font=font(14)) for index, stem in enumerate(stems): sheet = Image.open(UNIT_DIR / f"{stem}.png").convert("RGBA") frame = crop_frame(sheet, 0, 0) x = 18 + index * (zoom + gap) out.alpha_composite(draw_frame_on_bg(frame, zoom), (x, 38)) out.alpha_composite(draw_frame_on_bg(frame, tile), (x + 11, 122)) draw.text((x, 180), labels[index], fill=(226, 218, 175, 255), font=font(10)) path = DOCS_DIR / f"{OUTPUT_PREFIX}-scale-compare.png" out.convert("RGB").save(path, optimize=True) return path def save_battle_composite(base_sheet: Image.Image) -> Path: battle = Image.open(BATTLE_REFERENCE).convert("RGBA") frame = crop_frame(base_sheet, 0, 0) draw = ImageDraw.Draw(battle) display = 72 small = 50 # Empty-ish field cells near the approved ally sprites. This is a review # composite only; the gameplay roster is left untouched during sample review. battle.alpha_composite(solidify_alpha(frame).resize((display, display), Image.Resampling.LANCZOS), (207, 535)) draw.text((207, 612), SAMPLE_LABEL, fill=(255, 248, 218, 255), font=font(12), stroke_width=2, stroke_fill=(20, 18, 16, 230)) panel_x, panel_y = 936, 542 draw.rounded_rectangle((panel_x, panel_y, panel_x + 205, panel_y + 116), radius=4, fill=(17, 23, 31, 235), outline=(214, 181, 99, 220), width=2) draw.text((panel_x + 14, panel_y + 12), "Composite scale check", fill=(240, 226, 178, 255), font=font(13)) draw.text((panel_x + 14, panel_y + 32), "not gameplay", fill=(198, 204, 200, 255), font=font(11)) battle.alpha_composite(draw_frame_on_bg(frame, display), (panel_x + 18, panel_y + 56)) battle.alpha_composite(draw_frame_on_bg(frame, small), (panel_x + 114, panel_y + 67)) draw.text((panel_x + 20, panel_y + 132), "72px", fill=(238, 224, 178, 255), font=font(10)) draw.text((panel_x + 117, panel_y + 132), "50px", fill=(238, 224, 178, 255), font=font(10)) path = DOCS_DIR / f"{OUTPUT_PREFIX}-battle-composite.png" battle.convert("RGB").save(path, optimize=True) return path def validate(path: Path, expected: tuple[int, int], rows: int, cols: int) -> dict[str, int | str]: image = Image.open(path).convert("RGBA") if image.size != expected: raise ValueError(f"{path}: expected {expected}, 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}: partial alpha {partial}") widths: list[int] = [] heights: list[int] = [] bottoms: list[int] = [] border_hits = 0 empty_frames = 0 for row in range(rows): for col in range(cols): frame = alpha[row * FRAME : (row + 1) * FRAME, col * FRAME : (col + 1) * FRAME] ys, xs = np.where(frame > 0) if len(xs) == 0: empty_frames += 1 continue widths.append(int(xs.max() - xs.min() + 1)) heights.append(int(ys.max() - ys.min() + 1)) bottoms.append(int(ys.max())) if xs.min() <= 1 or ys.min() <= 1 or xs.max() >= FRAME - 2 or ys.max() >= FRAME - 2: border_hits += 1 return { "partial_alpha": partial, "opaque_pixels": opaque, "border_hits": border_hits, "empty_frames": empty_frames, "min_width": min(widths) if widths else 0, "max_width": max(widths) if widths else 0, "min_height": min(heights) if heights else 0, "max_height": max(heights) if heights else 0, "min_bottom": min(bottoms) if bottoms else 0, "max_bottom": max(bottoms) if bottoms else 0, "status": "ok" if border_hits == 0 and empty_frames == 0 else "needs-review", } def copy_before_once() -> None: WORK_DIR.mkdir(parents=True, exist_ok=True) for suffix in ("", "-actions"): source = UNIT_DIR / f"{STEM}{suffix}.png" target = WORK_DIR / f"before-{STEM}{suffix}.png" if not target.exists(): target.write_bytes(source.read_bytes()) def project_path(value: str) -> Path: path = Path(value) return path if path.is_absolute() else ROOT / path def split_csv(value: str) -> list[str]: return [item.strip() for item in value.split(",") if item.strip()] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Assemble a 6x2 hand-painted source into 313px unit sprite sheets.") parser.add_argument("--source", default=str(SOURCE.relative_to(ROOT))) parser.add_argument("--stem", default=STEM) parser.add_argument("--work-dir", default=str(WORK_DIR.relative_to(ROOT))) parser.add_argument("--output-prefix", default=OUTPUT_PREFIX) parser.add_argument("--battle-reference", default=str(BATTLE_REFERENCE.relative_to(ROOT))) parser.add_argument("--contact-title", default=CONTACT_TITLE) parser.add_argument("--before-title", default=BEFORE_TITLE) parser.add_argument("--before-scale-label", default=BEFORE_SCALE_LABEL) parser.add_argument("--report-title", default=REPORT_TITLE) parser.add_argument("--sample-label", default=SAMPLE_LABEL) parser.add_argument("--compare-stems", default=",".join(COMPARE_STEMS)) parser.add_argument("--compare-labels", default=",".join(COMPARE_LABELS)) parser.add_argument("--fit-width-scale", type=float, default=FIT_WIDTH_SCALE) parser.add_argument("--min-frame-height", type=int, default=MIN_FRAME_HEIGHT) parser.add_argument("--stretch-x", type=float, default=STRETCH_X) parser.add_argument("--action-shrink-x", type=float, default=ACTION_SHRINK_X) return parser.parse_args() def configure(args: argparse.Namespace) -> None: global SOURCE, WORK_DIR, BATTLE_REFERENCE, STEM, OUTPUT_PREFIX global CONTACT_TITLE, BEFORE_TITLE, BEFORE_SCALE_LABEL, REPORT_TITLE, SAMPLE_LABEL global COMPARE_STEMS, COMPARE_LABELS global FIT_WIDTH_SCALE, MIN_FRAME_HEIGHT, STRETCH_X, ACTION_SHRINK_X SOURCE = project_path(args.source) WORK_DIR = project_path(args.work_dir) BATTLE_REFERENCE = project_path(args.battle_reference) STEM = args.stem OUTPUT_PREFIX = args.output_prefix CONTACT_TITLE = args.contact_title BEFORE_TITLE = args.before_title BEFORE_SCALE_LABEL = args.before_scale_label REPORT_TITLE = args.report_title SAMPLE_LABEL = args.sample_label COMPARE_STEMS = split_csv(args.compare_stems) COMPARE_LABELS = split_csv(args.compare_labels) FIT_WIDTH_SCALE = args.fit_width_scale MIN_FRAME_HEIGHT = args.min_frame_height STRETCH_X = args.stretch_x ACTION_SHRINK_X = args.action_shrink_x if len(COMPARE_STEMS) != len(COMPARE_LABELS): raise ValueError("--compare-stems and --compare-labels must have the same number of entries") def main() -> None: configure(parse_args()) copy_before_once() poses = load_pose_frames() base_sheet = build_base_sheet(poses) action_sheet = build_action_sheet(poses) base_path = UNIT_DIR / f"{STEM}.png" action_path = UNIT_DIR / f"{STEM}-actions.png" base_sheet.save(base_path, optimize=True) action_sheet.save(action_path, optimize=True) contact = save_contact_sheet(base_sheet, action_sheet) before_after = save_before_after(base_sheet) animation = save_animation_gif(base_sheet, action_sheet) scale_compare = save_scale_compare() battle_composite = save_battle_composite(base_sheet) base_result = validate(base_path, (FRAME * BASE_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), BASE_FRAMES_PER_DIRECTION) action_result = validate(action_path, (FRAME * ACTION_FRAMES_PER_DIRECTION, FRAME * len(DIRECTIONS)), len(DIRECTIONS), ACTION_FRAMES_PER_DIRECTION) report = DOCS_DIR / f"{OUTPUT_PREFIX}-report.md" report.write_text( "\n".join( [ f"# {REPORT_TITLE}", "", f"- source: `{SOURCE.name}`", f"- base status: {base_result['status']}", f"- action status: {action_result['status']}", f"- base partial alpha: {base_result['partial_alpha']}, opaque pixels: {base_result['opaque_pixels']}", f"- action partial alpha: {action_result['partial_alpha']}, opaque pixels: {action_result['opaque_pixels']}", f"- base frame border hits: {base_result['border_hits']}", f"- action frame border hits: {action_result['border_hits']}", f"- base empty frames: {base_result['empty_frames']}", f"- action empty frames: {action_result['empty_frames']}", f"- base bbox width range: {base_result['min_width']}..{base_result['max_width']}", f"- base bbox height range: {base_result['min_height']}..{base_result['max_height']}", f"- base bottom range: {base_result['min_bottom']}..{base_result['max_bottom']}", f"- action bbox width range: {action_result['min_width']}..{action_result['max_width']}", f"- action bbox height range: {action_result['min_height']}..{action_result['max_height']}", f"- action bottom range: {action_result['min_bottom']}..{action_result['max_bottom']}", f"- contact: `{contact.name}`", f"- animation: `{animation.name}`", f"- before/after: `{before_after.name}`", f"- scale compare: `{scale_compare.name}`", f"- battle composite: `{battle_composite.name}`", "- manual checks: run build and local browser verification after generation", "- note: battle composite is a scale review image; gameplay roster is unchanged during sample approval", "", ] ), encoding="utf-8", ) print(f"Wrote {base_path}") print(f"Wrote {action_path}") print(f"Wrote {contact}") print(f"Wrote {animation}") print(f"Wrote {before_after}") print(f"Wrote {scale_compare}") print(f"Wrote {battle_composite}") print(f"Wrote {report}") if __name__ == "__main__": main()