from __future__ import annotations import argparse import colorsys import hashlib import math from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageChops, ImageDraw, ImageEnhance, ImageFilter FRAME_SIZE = 313 GRID_SIZE = 4 PALETTE_COLORS = 224 ROOT = Path(__file__).resolve().parents[1] UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" SOURCE_ATLAS = ROOT / "src" / "assets" / "images" / "unit-sources" / "three-kingdoms-unit-atlas.png" DIRECTIONS = ("south", "east", "north", "west") ACTION_ORDER = ("attack", "strategy", "item", "hurt", "celebrate") @dataclass(frozen=True) class Template: atlas_index: int role: str target_height: int target_width: int @dataclass(frozen=True) class UnitPlan: template: str tint: tuple[int, int, int] tint_strength: float saturation: float = 1.0 brightness: float = 1.0 scale: float = 1.0 @dataclass(frozen=True) class RoleSpec: height: int max_width: int bottom_padding: int idle_scale_x: tuple[float, float, float, float] idle_scale_y: tuple[float, float, float, float] idle_dx: tuple[int, int, int, int] TEMPLATES: dict[str, Template] = { "shu-lord": Template(0, "infantry", 286, 274), "green-polearm": Template(1, "polearm", 292, 282), "red-spear": Template(2, "polearm", 292, 282), "white-cavalry": Template(3, "cavalry", 292, 304), "wei-lord": Template(4, "infantry", 286, 274), "red-cavalry": Template(5, "cavalry", 292, 304), "strategist": Template(6, "strategist", 276, 250), "rebel-infantry": Template(7, "infantry", 286, 274), "rebel-archer": Template(8, "archer", 282, 276), "rebel-cavalry": Template(9, "cavalry", 292, 304), "wu-officer": Template(10, "infantry", 286, 274), "nanman-officer": Template(11, "polearm", 292, 282), } ROLE_SPECS: dict[str, RoleSpec] = { "infantry": RoleSpec(286, 274, 4, (1.0, 0.985, 1.015, 1.0), (1.0, 1.018, 0.992, 1.01), (0, 0, 0, 0)), "polearm": RoleSpec(292, 282, 4, (1.0, 0.986, 1.014, 1.0), (1.0, 1.018, 0.992, 1.01), (0, 0, 0, 0)), "cavalry": RoleSpec(292, 304, 3, (1.0, 1.012, 0.988, 1.008), (1.0, 1.014, 0.99, 1.008), (0, 1, 0, -1)), "strategist": RoleSpec(276, 250, 5, (1.0, 0.988, 1.012, 1.0), (1.0, 1.018, 0.994, 1.01), (0, 0, 0, 0)), "archer": RoleSpec(282, 276, 4, (1.0, 0.986, 1.012, 1.0), (1.0, 1.016, 0.992, 1.01), (0, 0, 0, 0)), } SIGNATURE_PLANS: dict[str, UnitPlan] = { "unit-liu-bei": UnitPlan("shu-lord", (42, 142, 72), 0.04, 1.04, 1.02, 1.0), "unit-guan-yu": UnitPlan("green-polearm", (34, 132, 70), 0.04, 1.03, 1.02, 1.0), "unit-zhang-fei": UnitPlan("red-spear", (152, 42, 42), 0.05, 1.04, 1.02, 1.0), "unit-zhao-yun": UnitPlan("white-cavalry", (42, 116, 184), 0.06, 1.04, 1.03, 1.0), "unit-cao-cao": UnitPlan("wei-lord", (52, 58, 122), 0.06, 1.02, 1.01, 1.0), "unit-lu-bu": UnitPlan("red-cavalry", (140, 36, 54), 0.05, 1.05, 1.02, 1.0), "unit-zhuge-liang": UnitPlan("strategist", (42, 126, 118), 0.05, 1.02, 1.03, 1.0), "unit-pang-tong": UnitPlan("strategist", (80, 126, 68), 0.09, 1.02, 1.0, 1.0), "unit-sima-yi": UnitPlan("strategist", (58, 62, 132), 0.16, 1.04, 0.96, 1.0), "unit-huang-zhong": UnitPlan("rebel-archer", (54, 124, 64), 0.28, 1.04, 1.02, 1.0), "unit-ma-chao": UnitPlan("white-cavalry", (54, 118, 194), 0.1, 1.04, 1.04, 1.0), "unit-ma-dai": UnitPlan("white-cavalry", (98, 122, 156), 0.22, 1.02, 0.98, 1.0), "unit-meng-huo": UnitPlan("nanman-officer", (192, 88, 42), 0.1, 1.04, 1.02, 1.0), "unit-jiang-wei": UnitPlan("green-polearm", (44, 140, 124), 0.18, 1.04, 1.02, 1.0), "unit-wei-yan": UnitPlan("green-polearm", (132, 42, 58), 0.28, 1.05, 0.98, 1.0), } FACTION_TINTS = { "shu": (42, 142, 76), "wei": (64, 72, 150), "wu": (28, 132, 142), "nanman": (188, 88, 42), "rebel": (202, 150, 36), } def main() -> None: parser = argparse.ArgumentParser(description="Redraw unit sprite sheets from a high-quality generated source atlas.") parser.add_argument("--only", help="Comma-separated unit stems or filenames to redraw.") parser.add_argument("--out-dir", default=str(UNIT_DIR), help="Output directory. Defaults to the project unit asset directory.") args = parser.parse_args() if not SOURCE_ATLAS.exists(): raise FileNotFoundError(f"Missing source atlas: {SOURCE_ATLAS}") output_dir = Path(args.out_dir).resolve() output_dir.mkdir(parents=True, exist_ok=True) only = {entry.strip().removesuffix(".png") for entry in args.only.split(",") if entry.strip()} if args.only else set() atlas_sprites = extract_source_sprites(Image.open(SOURCE_ATLAS).convert("RGBA")) stems = [ path.stem for path in sorted(UNIT_DIR.glob("unit-*.png")) if not path.name.endswith("-actions.png") ] written = 0 for stem in stems: if only and stem not in only: continue plan = unit_plan(stem) template = TEMPLATES[plan.template] source = atlas_sprites[template.atlas_index] source = tint_sprite(source, plan) sheet = draw_base_sheet(source, template, stem, plan) save_unit_png(sheet, output_dir / f"{stem}.png") save_unit_png(draw_action_sheet(sheet, template, stem), output_dir / f"{stem}-actions.png") written += 2 print(f"Redrew {written} unit sprite sheets from {SOURCE_ATLAS}") def extract_source_sprites(atlas: Image.Image) -> list[Image.Image]: cell_width = atlas.width // 4 cell_height = atlas.height // 3 sprites: list[Image.Image] = [] for index in range(12): col = index % 4 row = index // 4 cell = atlas.crop((col * cell_width, row * cell_height, (col + 1) * cell_width, (row + 1) * cell_height)) sprite = remove_chroma_key(cell) bbox = sprite.getbbox() if not bbox: raise ValueError(f"Could not extract atlas sprite {index}") sprite = sprite.crop(expand_box(bbox, sprite.size, 10)) sprites.append(sprite) return sprites def remove_chroma_key(image: Image.Image) -> Image.Image: image = image.convert("RGBA") pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = pixels[x, y] green_dominance = green - max(red, blue) is_key_green = green > 158 and red < 118 and blue < 118 and green_dominance > 58 if is_key_green: fade = min(255, max(0, round((green_dominance - 58) * 4.8))) alpha = max(0, alpha - fade) if alpha <= 18: pixels[x, y] = (red, green, blue, 0) continue if alpha > 0 and green > 150 and red < 132 and blue < 132 and green_dominance > 48: green = round(max(red, blue) + green_dominance * 0.38) pixels[x, y] = (red, green, blue, alpha) alpha = image.getchannel("A") alpha = alpha.filter(ImageFilter.MinFilter(3)).filter(ImageFilter.MaxFilter(3)) red, green, blue, _ = image.split() cleaned = Image.merge("RGBA", (red, green, blue, alpha.point(lambda value: 0 if value < 9 else value))) cleaned = despill_key_edges(cleaned) return remove_detached_artifacts(cleaned) def despill_key_edges(image: Image.Image) -> Image.Image: image = image.convert("RGBA") source = image.copy() src = source.load() pixels = image.load() width, height = image.size for y in range(height): for x in range(width): red, green, blue, alpha = src[x, y] if alpha <= 0: continue green_dominance = green - max(red, blue) if green <= 120 or green_dominance <= 34: continue touches_transparency = False for ny in range(max(0, y - 1), min(height, y + 2)): for nx in range(max(0, x - 1), min(width, x + 2)): if src[nx, ny][3] <= 8: touches_transparency = True break if touches_transparency: break if not touches_transparency: continue green = min(green, max(red, blue) + 18) pixels[x, y] = (red, green, blue, alpha) return image def draw_base_sheet(source: Image.Image, template: Template, stem: str, plan: UnitPlan) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * GRID_SIZE, FRAME_SIZE * GRID_SIZE), (0, 0, 0, 0)) fitted = fit_sprite(source, template, plan) for row, direction in enumerate(DIRECTIONS): directed = orient_sprite(fitted, direction) for col in range(GRID_SIZE): frame = frame_variant(directed, template, stem, direction, col) sheet.alpha_composite(frame, (col * FRAME_SIZE, row * FRAME_SIZE)) return sheet def fit_sprite(source: Image.Image, template: Template, plan: UnitPlan) -> Image.Image: bbox = source.getbbox() if not bbox: return source subject = source.crop(bbox) width, height = subject.size spec = ROLE_SPECS[template.role] target_height = round(spec.height * plan.scale) proportional_width = round(width * (target_height / height)) target_width = min(round(spec.max_width * plan.scale), max(1, proportional_width)) new_size = (max(1, target_width), max(1, target_height)) subject = subject.resize(new_size, Image.Resampling.LANCZOS) subject = subject.filter(ImageFilter.UnsharpMask(radius=0.65, percent=95, threshold=2)) return subject def orient_sprite(sprite: Image.Image, direction: str) -> Image.Image: if direction == "east": return side_variant(sprite) if direction == "west": return ImageOps_mirror(side_variant(sprite)) if direction == "north": return back_variant(sprite) return sprite def frame_variant(sprite: Image.Image, template: Template, stem: str, direction: str, frame_index: int) -> Image.Image: canvas = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) seed = stable_int(f"{stem}:{direction}") spec = ROLE_SPECS[template.role] frame = idle_pose(sprite, spec, frame_index) dx = spec.idle_dx[frame_index] if direction == "west": dx = -dx if direction == "north": dx = round(dx * 0.5) dx += seed % 3 - 1 bottom = FRAME_SIZE - spec.bottom_padding left = round((FRAME_SIZE - frame.width) / 2 + dx) top = round(bottom - frame.height) canvas.alpha_composite(frame, (left, top)) return finish_frame(canvas) def idle_pose(sprite: Image.Image, spec: RoleSpec, frame_index: int) -> Image.Image: sx = spec.idle_scale_x[frame_index] sy = spec.idle_scale_y[frame_index] width = max(1, round(sprite.width * sx)) height = max(1, round(sprite.height * sy)) return sprite.resize((width, height), Image.Resampling.LANCZOS) def draw_action_sheet(base_sheet: Image.Image, template: Template, stem: str) -> Image.Image: output = Image.new("RGBA", (FRAME_SIZE * GRID_SIZE * len(ACTION_ORDER), FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) for row, direction in enumerate(DIRECTIONS): for action_index, action in enumerate(ACTION_ORDER): for frame_index in range(GRID_SIZE): frame = base_sheet.crop((frame_index * FRAME_SIZE, row * FRAME_SIZE, (frame_index + 1) * FRAME_SIZE, (row + 1) * FRAME_SIZE)).convert("RGBA") output.alpha_composite( action_frame(frame, template, stem, direction, action, frame_index), ((action_index * GRID_SIZE + frame_index) * FRAME_SIZE, row * FRAME_SIZE) ) return output def action_frame(frame: Image.Image, template: Template, stem: str, direction: str, action: str, frame_index: int) -> Image.Image: if action == "hurt": return hurt_frame(frame, direction, frame_index) if action == "celebrate": return celebrate_frame(frame, direction, frame_index) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) if action == "attack": if frame_index >= 2: draw_attack_effect(draw, template, direction, frame_index) lunge = [0, 5, 13, 7][frame_index] if template.role != "cavalry" else [0, 8, 18, 10][frame_index] lift = [0, -2, -4, -1][frame_index] dx, dy = direction_offset(direction, lunge) body = offset_subject(frame, dx, dy + lift) result = Image.alpha_composite(effect, body) elif action == "strategy": draw_strategy_effect(draw, frame_index) result = Image.alpha_composite(effect, offset_subject(frame, 0, [0, -3, -5, -2][frame_index])) else: draw_item_effect(draw, direction, frame_index) result = Image.alpha_composite(offset_subject(frame, 0, [0, -2, -3, -1][frame_index]), effect) return finish_frame(result) def draw_attack_effect(draw: ImageDraw.ImageDraw, template: Template, direction: str, frame_index: int = 2) -> None: warm = (239, 177, 68, 160) bright = (255, 237, 184, 205) red = (178, 52, 42, 132) if template.role == "archer": shafts = { "east": ((122, 132), (286, 98)), "west": ((191, 132), (27, 98)), "north": ((170, 179), (135, 35)), "south": ((143, 112), (180, 283)), } start, end = shafts[direction] line(draw, [start, end], bright, 6 if frame_index == 2 else 4) line(draw, [start, end], red, 3 if frame_index == 2 else 2) draw.polygon(arrow_tip(end, start, 19), fill=bright, outline=(25, 17, 14, 210)) return if template.role == "strategist": draw_strategy_effect(draw, frame_index) return arcs = { "east": ((132, 58, 303, 224), -55, 55), "west": ((10, 58, 181, 224), 125, 235), "north": ((50, 14, 267, 182), 204, 336), "south": ((45, 113, 269, 295), 25, 155), } box, start, end = arcs[direction] width = 17 if frame_index == 2 else 11 draw.arc(box, start=start, end=end, fill=warm, width=width) inset = (box[0] + 15, box[1] + 15, box[2] - 15, box[3] - 15) draw.arc(inset, start=start + 4, end=end - 4, fill=bright, width=max(4, width - 10)) def draw_strategy_effect(draw: ImageDraw.ImageDraw, frame_index: int = 0) -> None: center = (156, 151) pulse = (0, 8, 16, 6)[frame_index] for radius, color, width in ((88 + pulse, (39, 94, 156, 66), 4), (58 + pulse // 2, (46, 142, 174, 106), 4), (28 + pulse // 3, (236, 184, 74, 134), 5)): draw.ellipse((center[0] - radius, center[1] - radius, center[0] + radius, center[1] + radius), outline=color, width=width) for angle in range(0, 360, 60): x = center[0] + math.cos(math.radians(angle)) * 72 y = center[1] + math.sin(math.radians(angle)) * 72 draw.polygon([(x, y - 7), (x + 5, y), (x, y + 7), (x - 5, y)], fill=(236, 184, 74, 126)) def draw_item_effect(draw: ImageDraw.ImageDraw, direction: str, frame_index: int = 0) -> None: positions = { "south": (184, 145), "east": (220, 146), "north": (126, 137), "west": (92, 146), } x, y = positions[direction] y += [0, -8, -12, -6][frame_index] draw.rounded_rectangle((x - 20, y - 16, x + 20, y + 20), radius=7, fill=(126, 79, 39, 235), outline=(25, 18, 14, 235), width=3) line(draw, [(x - 27, y + 1), (x + 27, y + 1)], (226, 178, 72, 196), 4) draw.ellipse((x - 11, y - 31, x + 11, y - 9), fill=(76, 178, 112, 200), outline=(24, 18, 14, 220), width=2) def hurt_frame(frame: Image.Image, direction: str, frame_index: int = 0) -> Image.Image: knockback = [0, -10, -15, -5][frame_index] dx, dy = direction_offset(direction, knockback) body = offset_subject(frame, dx, dy + [0, -2, 2, 0][frame_index]) body = ImageEnhance.Color(body).enhance(0.74) alpha = body.getchannel("A") red = Image.new("RGBA", body.size, (188, 42, 36, 0)) red.putalpha(alpha.point(lambda value: min(86, value // 3))) body = Image.alpha_composite(body, red) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) if frame_index == 0: return finish_frame(body) cx, cy = (194, 122) if direction in ("west", "north") else (120, 122) cy += -4 if frame_index == 1 else 4 spikes = [(cx, cy - 31), (cx + 9, cy - 9), (cx + 34, cy - 6), (cx + 13, cy + 8), (cx + 23, cy + 31), (cx, cy + 15), (cx - 24, cy + 30), (cx - 14, cy + 7), (cx - 34, cy - 6), (cx - 9, cy - 9)] draw.polygon(spikes, fill=(232, 176, 72, 190), outline=(138, 31, 31, 220)) line(draw, [(cx - 38, cy - 34), (cx + 36, cy + 34)], (255, 231, 181, 166), 5) return finish_frame(Image.alpha_composite(body, effect)) def celebrate_frame(frame: Image.Image, direction: str, frame_index: int = 0) -> Image.Image: lift = [0, -8, -3, -11][frame_index] sway = [0, -2, 2, 0][frame_index] if direction == "west": sway = -sway body = offset_subject(frame, sway, lift) effect = Image.new("RGBA", frame.size, (0, 0, 0, 0)) draw = ImageDraw.Draw(effect) cx = 156 + sway cy = 92 + lift if frame_index in (1, 3): draw.ellipse((cx - 42, cy - 26, cx + 42, cy + 58), outline=(255, 220, 114, 118), width=5) for angle in range(20, 360, 72): x = cx + math.cos(math.radians(angle)) * 54 y = cy + math.sin(math.radians(angle)) * 40 draw.polygon([(x, y - 8), (x + 5, y), (x, y + 8), (x - 5, y)], fill=(255, 226, 132, 148)) return finish_frame(Image.alpha_composite(body, effect)) def unit_plan(stem: str) -> UnitPlan: if stem in SIGNATURE_PLANS: return SIGNATURE_PLANS[stem] faction = infer_faction(stem) role = infer_role(stem) tint = FACTION_TINTS[faction] strength = 0.16 if faction == "rebel": strength = 0.12 if faction == "nanman": strength = 0.16 if faction == "shu": strength = 0.08 if faction == "wei": strength = 0.2 if faction == "wu": strength = 0.18 template_key = template_for(faction, role, stem) saturation = 1.03 if "veteran" in stem or "elite" in stem or "warlord" in stem else 1.0 brightness = 1.03 if "white" in stem else 1.0 return UnitPlan(template_key, tint, strength, saturation, brightness, 1.0) def template_for(faction: str, role: str, stem: str) -> str: if faction == "nanman": if role == "strategist" or "shaman" in stem: return "nanman-officer" return "nanman-officer" if role in {"officer", "cavalry", "polearm"} else "rebel-infantry" if faction == "wu": if role == "strategist": return "strategist" if role == "cavalry": return "rebel-cavalry" if role == "archer": return "rebel-archer" return "wu-officer" if faction == "wei": if role == "strategist": return "strategist" if role == "cavalry": return "red-cavalry" if role == "archer": return "rebel-archer" return "wei-lord" if role == "officer" else "rebel-infantry" if faction == "rebel": if role == "cavalry": return "rebel-cavalry" if role == "archer": return "rebel-archer" if role == "officer" or role == "polearm": return "rebel-infantry" return "rebel-infantry" if role == "cavalry": return "white-cavalry" if role == "archer": return "rebel-archer" if role == "strategist": return "strategist" if role == "polearm": return "green-polearm" return "shu-lord" if role == "officer" else "green-polearm" def infer_faction(stem: str) -> str: lower = stem.lower() if "nanman" in lower or "meng-huo" in lower: return "nanman" if lower.startswith("unit-wu-") or "lu-meng" in lower: return "wu" if lower.startswith("unit-wei-") or any(token in lower for token in ("cao-cao", "sima-yi")): return "wei" if "rebel" in lower or "yellow" in lower or "bandit" in lower: return "rebel" return "shu" def infer_role(stem: str) -> str: lower = stem.lower() if "cavalry" in lower or any(token in lower for token in ("zhao-yun", "ma-chao", "ma-dai", "lu-bu")): return "cavalry" if "archer" in lower or "huang-zhong" in lower or "crossbow" in lower or "longbow" in lower: return "archer" if any(token in lower for token in ("strategist", "shaman", "zhuge", "sima", "fa-zheng", "ma-liang", "yi-ji", "pang-tong")): return "strategist" if "spearman" in lower or any(token in lower for token in ("guan-yu", "zhang-fei", "jiang-wei", "yan-yan")): return "polearm" if "leader" in lower or "officer" in lower or any(token in lower for token in ("liu-bei", "cao-cao", "meng-huo", "wei-yan", "wang-ping", "wu-yi", "li-yan", "huang-quan", "gong-zhi")): return "officer" return "infantry" def tint_sprite(image: Image.Image, plan: UnitPlan) -> Image.Image: image = image.convert("RGBA") if plan.tint_strength <= 0: return image pixels = image.load() tint_h, tint_s, tint_v = colorsys.rgb_to_hsv(plan.tint[0] / 255, plan.tint[1] / 255, plan.tint[2] / 255) for y in range(image.height): for x in range(image.width): red, green, blue, alpha = pixels[x, y] if alpha <= 8 or is_skin(red, green, blue) or is_neutral_metal(red, green, blue): continue hue, sat, val = colorsys.rgb_to_hsv(red / 255, green / 255, blue / 255) strength = plan.tint_strength * (0.45 + min(0.55, sat)) hue = hue * (1 - strength) + tint_h * strength sat = min(1.0, sat * plan.saturation + tint_s * strength * 0.45) val = min(1.0, val * plan.brightness + tint_v * strength * 0.08) nr, ng, nb = colorsys.hsv_to_rgb(hue, sat, val) pixels[x, y] = (round(nr * 255), round(ng * 255), round(nb * 255), alpha) return image def back_variant(sprite: Image.Image) -> Image.Image: result = sprite.copy() result = result.resize((max(1, round(result.width * 0.96)), result.height), Image.Resampling.LANCZOS) overlay = Image.new("RGBA", result.size, (18, 20, 26, 0)) overlay.putalpha(result.getchannel("A").point(lambda value: min(48, value // 6))) result = Image.alpha_composite(result, overlay) return result def side_variant(sprite: Image.Image) -> Image.Image: result = sprite.resize((max(1, round(sprite.width * 0.9)), sprite.height), Image.Resampling.LANCZOS) overlay = Image.new("RGBA", result.size, (16, 16, 22, 0)) overlay.putalpha(result.getchannel("A").point(lambda value: min(18, value // 18))) return Image.alpha_composite(result, overlay) def finish_frame(frame: Image.Image) -> Image.Image: frame = clean_alpha(frame) alpha = frame.getchannel("A") if not alpha.getbbox(): return frame ink_alpha = ImageChops.subtract(alpha.filter(ImageFilter.MaxFilter(3)), alpha) rim = Image.new("RGBA", frame.size, (10, 8, 10, 0)) rim.putalpha(ink_alpha.point(lambda value: min(180, round(value * 0.65)))) result = Image.new("RGBA", frame.size, (0, 0, 0, 0)) result.alpha_composite(rim) result.alpha_composite(frame) return clean_alpha(result) def clean_alpha(image: Image.Image) -> Image.Image: image = image.convert("RGBA") red, green, blue, alpha = image.split() alpha = alpha.point(lambda value: 0 if value < 5 else (255 if value > 250 else value)) return Image.merge("RGBA", (red, green, blue, alpha)) def remove_detached_artifacts(image: Image.Image) -> Image.Image: alpha = image.getchannel("A") pixels = alpha.load() width, height = alpha.size visited = bytearray(width * height) components: list[tuple[int, tuple[int, int, int, int], list[tuple[int, int]]]] = [] for y in range(height): for x in range(width): index = y * width + x if visited[index] or pixels[x, y] <= 8: continue stack = [(x, y)] visited[index] = 1 points: list[tuple[int, int]] = [] left = right = x top = bottom = y while stack: px, py = stack.pop() points.append((px, py)) left = min(left, px) right = max(right, px) top = min(top, py) bottom = max(bottom, py) for nx, ny in ((px + 1, py), (px - 1, py), (px, py + 1), (px, py - 1)): if nx < 0 or nx >= width or ny < 0 or ny >= height: continue ni = ny * width + nx if visited[ni] or pixels[nx, ny] <= 8: continue visited[ni] = 1 stack.append((nx, ny)) components.append((len(points), (left, top, right + 1, bottom + 1), points)) if not components: return image components.sort(key=lambda component: component[0], reverse=True) keep_points = set(components[0][2]) largest_area, largest_bbox, _ = components[0] for area, bbox, points in components[1:]: if area >= largest_area * 0.08 or bbox_distance(largest_bbox, bbox) < 12: keep_points.update(points) cleaned_alpha = Image.new("L", image.size, 0) cleaned_pixels = cleaned_alpha.load() source_pixels = alpha.load() for x, y in keep_points: cleaned_pixels[x, y] = source_pixels[x, y] red, green, blue, _ = image.split() return Image.merge("RGBA", (red, green, blue, cleaned_alpha)) def expand_box(box: tuple[int, int, int, int], size: tuple[int, int], padding: int) -> tuple[int, int, int, int]: left, top, right, bottom = box width, height = size return (max(0, left - padding), max(0, top - padding), min(width, right + padding), min(height, bottom + padding)) def bbox_distance(left_box: tuple[int, int, int, int], right_box: tuple[int, int, int, int]) -> int: left_a, top_a, right_a, bottom_a = left_box left_b, top_b, right_b, bottom_b = right_box dx = max(left_a - right_b, left_b - right_a, 0) dy = max(top_a - bottom_b, top_b - bottom_a, 0) return max(dx, dy) def offset_subject(frame: Image.Image, dx: int, dy: int) -> Image.Image: canvas = Image.new("RGBA", frame.size, (0, 0, 0, 0)) bbox = frame.getbbox() if not bbox: return frame subject = frame.crop(bbox) canvas.alpha_composite(subject, (bbox[0] + dx, bbox[1] + dy)) return canvas def direction_offset(direction: str, amount: int) -> tuple[int, int]: if direction == "east": return amount, 0 if direction == "west": return -amount, 0 if direction == "north": return 0, -amount return 0, amount def arrow_tip(end: tuple[float, float], start: tuple[float, float], size: float) -> list[tuple[float, float]]: ex, ey = end sx, sy = start angle = math.atan2(ey - sy, ex - sx) left = angle + math.radians(150) right = angle - math.radians(150) return [ (ex, ey), (ex + math.cos(left) * size, ey + math.sin(left) * size), (ex - math.cos(angle) * size * 0.45, ey - math.sin(angle) * size * 0.45), (ex + math.cos(right) * size, ey + math.sin(right) * size), ] def line(draw: ImageDraw.ImageDraw, points: list[tuple[float, float]], fill: tuple[int, int, int, int], width: int) -> None: draw.line([(round(x), round(y)) for x, y in points], fill=fill, width=width, joint="curve") def is_skin(red: int, green: int, blue: int) -> bool: return red > 110 and green > 55 and blue > 32 and red > green * 1.1 and green > blue * 1.05 def is_neutral_metal(red: int, green: int, blue: int) -> bool: return abs(red - green) < 18 and abs(green - blue) < 22 and red > 118 def stable_int(value: str) -> int: return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:8], 16) def ImageOps_mirror(image: Image.Image) -> Image.Image: from PIL import ImageOps return ImageOps.mirror(image) def save_unit_png(image: Image.Image, path: Path) -> None: image = clean_alpha(image) indexed = image.quantize(colors=PALETTE_COLORS, method=Image.Quantize.FASTOCTREE, dither=Image.Dither.NONE) indexed.save(path, optimize=True) if __name__ == "__main__": main()