from __future__ import annotations import argparse import colorsys import hashlib import math from collections import deque from dataclasses import dataclass from pathlib import Path from PIL import Image, ImageEnhance, ImageFilter, ImageOps ROOT = Path(__file__).resolve().parents[1] UNIT_DIR = ROOT / "src" / "assets" / "images" / "units" SOURCE_DIR = ROOT / "docs" / "unit-sprite-art-sources" CONTACT_PATH = ROOT / "docs" / "unit-sprite-raster-contact-sheet-v1.png" ACTUAL_SIZE_PATH = ROOT / "docs" / "unit-sprite-raster-actual-size-v1.png" FRAME_SIZE = 313 PNG_PALETTE_COLORS = 192 DIRECTIONS = ("south", "east", "north", "west") IDLE_FRAME_COUNT = 8 MOVE_FRAME_COUNT = 8 BASE_FRAMES_PER_DIRECTION = IDLE_FRAME_COUNT + MOVE_FRAME_COUNT ACTION_ORDER = ("attack", "strategy", "item", "hurt", "celebrate") ACTION_FRAME_COUNTS = { "attack": 10, "strategy": 8, "item": 8, "hurt": 4, "celebrate": 6, } ACTION_COLUMN_OFFSETS = { action: sum(ACTION_FRAME_COUNTS[previous] for previous in ACTION_ORDER[:index]) for index, action in enumerate(ACTION_ORDER) } ACTION_FRAMES_PER_DIRECTION = sum(ACTION_FRAME_COUNTS.values()) @dataclass(frozen=True) class SourceGrid: name: str file: str x_lines: tuple[int, ...] y_lines_by_section: tuple[tuple[int, ...], tuple[int, ...]] attack_col: int @property def base_cols(self) -> int: return max(4, min(6, self.attack_col)) @dataclass(frozen=True) class UnitStyle: source: str section: int faction: str role: str rank: int target: tuple[int, int, int] secondary: tuple[int, int, int] scale: float bulk: float SOURCES: dict[str, SourceGrid] = { "infantry": SourceGrid( "infantry", "infantry-spearman-master.png", (134, 326, 518, 710, 902, 1094, 1289), ((9, 140, 254, 370, 483), (498, 627, 743, 862, 981)), 5, ), "ranged": SourceGrid( "ranged", "archer-strategist-master.png", (161, 320, 477, 631, 785, 937, 1091, 1243), ((18, 149, 268, 389, 510), (523, 655, 779, 904, 1011)), 6, ), "cavalry": SourceGrid( "cavalry", "cavalry-master.png", (34, 193, 352, 510, 670, 828, 991), ((25, 205, 380, 559, 735), (768, 954, 1129, 1308, 1484)), 5, ), "rebel": SourceGrid( "rebel", "rebel-master.png", (160, 346, 535, 723, 912, 1100, 1289), ((17, 138, 263, 390, 504), (518, 647, 769, 893, 1017)), 5, ), "nanman": SourceGrid( "nanman", "nanman-master.png", (18, 180, 343, 506, 667, 833, 997), ((13, 183, 353, 519, 687), (711, 913, 1110, 1303, 1485)), 5, ), "commander": SourceGrid( "commander", "commander-master.png", (16, 174, 330, 486, 642, 795, 948), ((9, 201, 389, 571, 757), (783, 977, 1162, 1347, 1510)), 5, ), } REPRESENTATIVE_UNITS = ( "unit-liu-bei", "unit-guan-yu", "unit-zhang-fei", "unit-shu-archer", "unit-shu-strategist", "unit-zhao-yun", "unit-rebel", "unit-rebel-archer", "unit-rebel-cavalry", "unit-wei-infantry", "unit-wu-cavalry", "unit-nanman-shaman", ) FACTION_TARGETS = { "shu": ((49, 124, 79), (219, 179, 76)), "wei": ((67, 83, 156), (213, 171, 77)), "wu": ((36, 132, 140), (219, 148, 76)), "rebel": ((143, 103, 55), (196, 143, 62)), "nanman": ((158, 84, 47), (210, 148, 66)), } SIGNATURE_TARGETS = { "unit-liu-bei": ((48, 130, 78), (229, 190, 83)), "unit-guan-yu": ((32, 118, 64), (225, 181, 75)), "unit-zhang-fei": ((137, 47, 45), (222, 163, 68)), "unit-zhao-yun": ((89, 142, 184), (237, 230, 202)), "unit-zhuge-liang": ((82, 148, 136), (234, 229, 196)), "unit-cao-cao": ((63, 75, 150), (214, 174, 78)), "unit-lu-bu": ((145, 38, 50), (224, 164, 66)), "unit-meng-huo": ((170, 82, 43), (224, 157, 62)), "unit-ma-chao": ((98, 144, 186), (231, 219, 170)), "unit-ma-dai": ((83, 129, 164), (220, 200, 146)), } def main() -> None: parser = argparse.ArgumentParser(description="Build final unit sprite sheets from generated raster source sheets.") parser.add_argument("--only", help="Comma-separated unit stems to rebuild.") parser.add_argument("--preview-only", action="store_true", help="Only write representative contact sheets.") args = parser.parse_args() stems = list(REPRESENTATIVE_UNITS) if args.preview_only and not args.only else discover_unit_stems(normalize_only(args.only)) library = RasterLibrary() rendered: dict[str, tuple[Image.Image, Image.Image]] = {} for stem in stems: style = unit_style(stem) base_sheet = build_base_sheet(library, style) action_sheet = build_action_sheet(library, style) if stem in REPRESENTATIVE_UNITS: rendered[stem] = (base_sheet.copy(), action_sheet.copy()) if not args.preview_only: save_png(base_sheet, UNIT_DIR / f"{stem}.png") save_png(action_sheet, UNIT_DIR / f"{stem}-actions.png") for stem in REPRESENTATIVE_UNITS: if stem not in rendered: style = unit_style(stem) rendered[stem] = (build_base_sheet(library, style), build_action_sheet(library, style)) write_contact_sheet(rendered, CONTACT_PATH) write_actual_size_preview(rendered, ACTUAL_SIZE_PATH) print(f"Raster-built {len(stems)} unit sprite definitions.") print(f"Representative contact sheet: {CONTACT_PATH}") print(f"Actual-size preview: {ACTUAL_SIZE_PATH}") def normalize_only(raw: str | None) -> set[str]: if not raw: return set() return { entry.strip().removesuffix(".png").removesuffix("-actions") for entry in raw.split(",") if entry.strip() } def discover_unit_stems(only: set[str]) -> list[str]: stems = [ path.stem for path in sorted(UNIT_DIR.glob("unit-*.png")) if not path.name.endswith("-actions.png") ] if only: stems = [stem for stem in stems if stem in only] if not stems: raise ValueError("No unit sprite sheets matched the requested selection.") return stems class RasterLibrary: def __init__(self) -> None: self.sources = { name: Image.open(SOURCE_DIR / grid.file).convert("RGB") for name, grid in SOURCES.items() } self.cell_cache: dict[tuple[str, int, str, int], Image.Image] = {} self.frame_cache: dict[tuple[UnitStyle, str, int], Image.Image] = {} def frame(self, style: UnitStyle, direction: str, col: int) -> Image.Image: frame_key = (style, direction, col) if frame_key in self.frame_cache: return self.frame_cache[frame_key].copy() key = (style.source, style.section, direction, col) if key not in self.cell_cache: self.cell_cache[key] = self.extract_cell(SOURCES[style.source], style.section, direction, col) cell = self.cell_cache[key].copy() cell = apply_unit_variant(cell, style) frame = place_on_canvas(cell, style, direction) self.frame_cache[frame_key] = frame return frame.copy() def extract_cell(self, grid: SourceGrid, section: int, direction: str, col: int) -> Image.Image: image = self.sources[grid.name] row = DIRECTIONS.index(direction) y_lines = grid.y_lines_by_section[section] x0 = grid.x_lines[col] + 2 x1 = grid.x_lines[col + 1] - 2 y0 = y_lines[row] + 2 y1 = y_lines[row + 1] - 2 crop = image.crop((x0, y0, x1, y1)) transparent = remove_cell_background(crop) bbox = transparent.getbbox() if bbox: transparent = transparent.crop(bbox) return transparent def build_base_sheet(library: RasterLibrary, style: UnitStyle) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * BASE_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) grid = SOURCES[style.source] idle_seq = idle_sequence(grid.base_cols) move_seq = move_sequence(grid.base_cols) for row, direction in enumerate(DIRECTIONS): for frame_index, col in enumerate(idle_seq): frame = library.frame(style, direction, col) frame = motion_adjust(frame, style, "idle", frame_index, len(idle_seq), direction) sheet.alpha_composite(frame, (frame_index * FRAME_SIZE, row * FRAME_SIZE)) for frame_index, col in enumerate(move_seq): frame = library.frame(style, direction, col) frame = motion_adjust(frame, style, "move", frame_index, len(move_seq), direction) sheet.alpha_composite(frame, ((IDLE_FRAME_COUNT + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def build_action_sheet(library: RasterLibrary, style: UnitStyle) -> Image.Image: sheet = Image.new("RGBA", (FRAME_SIZE * ACTION_FRAMES_PER_DIRECTION, FRAME_SIZE * len(DIRECTIONS)), (0, 0, 0, 0)) grid = SOURCES[style.source] for row, direction in enumerate(DIRECTIONS): for action in ACTION_ORDER: seq = action_sequence(action, grid) start_col = ACTION_COLUMN_OFFSETS[action] for frame_index, source_col in enumerate(seq): frame = library.frame(style, direction, source_col) frame = motion_adjust(frame, style, action, frame_index, len(seq), direction) sheet.alpha_composite(frame, ((start_col + frame_index) * FRAME_SIZE, row * FRAME_SIZE)) return sheet def idle_sequence(base_cols: int) -> list[int]: if base_cols >= 6: return [0, 1, 2, 3, 4, 5, 4, 2] return [0, 1, 2, 3, 4, 3, 2, 1] def move_sequence(base_cols: int) -> list[int]: if base_cols >= 6: return [1, 2, 3, 4, 5, 4, 2, 0] return [1, 2, 3, 4, 3, 2, 1, 0] def action_sequence(action: str, grid: SourceGrid) -> list[int]: attack = grid.attack_col ready = max(0, attack - 1) if action == "attack": return [0, 1, ready, attack, attack, ready, 2, 1, 0, 0] if action == "strategy": return [0, 1, ready, attack, attack, ready, 1, 0] if action == "item": return [0, 1, 2, ready, attack, ready, 1, 0] if action == "hurt": return [0, 1, 1, 0] return [0, 1, 2, ready, attack, 1] def motion_adjust(frame: Image.Image, style: UnitStyle, action: str, index: int, count: int, direction: str) -> Image.Image: result = frame.copy() phase = math.sin((index / max(1, count)) * math.tau) if action == "idle": return offset_frame(result, 0, -1 if phase > 0.55 else 0) if action == "move": stride = 3 if style.role == "cavalry" else 2 dx, dy = direction_delta(direction) return offset_frame(result, int(dx * stride * phase), int(dy * stride * phase) - abs(int(phase))) if action == "attack": dx, dy = direction_delta(direction) lunge = max(0, math.sin((index / max(1, count - 1)) * math.pi)) * (9 if style.role == "cavalry" else 6) return offset_frame(result, int(dx * lunge), int(dy * lunge * 0.45)) if action == "hurt": result = tint_rgba(result, (180, 55, 45), 0.2 + index * 0.04) dx, _ = direction_delta(direction) return offset_frame(result, int(-dx * (index + 1) * 2), -1) if action == "celebrate": lift = 4 if index in (2, 3) else 0 return offset_frame(result, 0, -lift) if action in ("strategy", "item"): return brighten_frame(result, 1.0 + (0.05 if index in (3, 4) else 0)) return result def direction_delta(direction: str) -> tuple[int, int]: return { "south": (0, 1), "east": (1, 0), "north": (0, -1), "west": (-1, 0), }[direction] def offset_frame(image: Image.Image, dx: int, dy: int) -> Image.Image: if dx == 0 and dy == 0: return image result = Image.new("RGBA", image.size, (0, 0, 0, 0)) result.alpha_composite(image, (dx, dy)) return result def brighten_frame(image: Image.Image, factor: float) -> Image.Image: rgb = ImageEnhance.Brightness(image.convert("RGB")).enhance(factor) rgb.putalpha(image.getchannel("A")) return rgb def remove_cell_background(image: Image.Image) -> Image.Image: rgb = image.convert("RGB") w, h = rgb.size border_samples: list[tuple[int, int, int]] = [] for x in range(w): border_samples.append(rgb.getpixel((x, 0))) border_samples.append(rgb.getpixel((x, h - 1))) for y in range(h): border_samples.append(rgb.getpixel((0, y))) border_samples.append(rgb.getpixel((w - 1, y))) bg = tuple(sorted(channel)[len(channel) // 2] for channel in zip(*border_samples)) visited = bytearray(w * h) alpha = bytearray([255] * (w * h)) queue: deque[tuple[int, int]] = deque() for x in range(w): queue.append((x, 0)) queue.append((x, h - 1)) for y in range(h): queue.append((0, y)) queue.append((w - 1, y)) tolerance = 48 while queue: x, y = queue.popleft() if x < 0 or y < 0 or x >= w or y >= h: continue index = y * w + x if visited[index]: continue visited[index] = 1 pixel = rgb.getpixel((x, y)) if color_distance(pixel, bg) > tolerance and not is_background_shadow(pixel, bg): continue alpha[index] = 0 queue.append((x + 1, y)) queue.append((x - 1, y)) queue.append((x, y + 1)) queue.append((x, y - 1)) rgba = rgb.convert("RGBA") rgba.putalpha(Image.frombytes("L", (w, h), bytes(alpha)).filter(ImageFilter.GaussianBlur(0.35))) return rgba def is_background_shadow(pixel: tuple[int, int, int], bg: tuple[int, int, int]) -> bool: pr, pg, pb = pixel br, bgc, bb = bg return pr < br + 18 and pg < bgc + 18 and pb < bb + 18 and max(pixel) - min(pixel) < 35 def color_distance(a: tuple[int, int, int], b: tuple[int, int, int]) -> float: return math.sqrt(sum((left - right) ** 2 for left, right in zip(a, b))) def apply_unit_variant(image: Image.Image, style: UnitStyle) -> Image.Image: seed = stable_int(f"{style.source}:{style.section}:{style.role}:{style.faction}:{style.rank}") strength = 0.26 + (style.rank * 0.04) if style.faction in {"rebel", "nanman"}: strength += 0.08 if style.source == "commander": strength *= 0.72 shifted = shift_hue(image, ((seed % 13) - 6) / 240.0) tinted = tint_rgba(shifted, style.target, strength) rgb = ImageEnhance.Contrast(tinted.convert("RGB")).enhance(1.04 + style.rank * 0.035) rgb = ImageEnhance.Brightness(rgb).enhance(1.12 + style.rank * 0.025) rgb.putalpha(tinted.getchannel("A")) return rgb def tint_rgba(image: Image.Image, color: tuple[int, int, int], strength: float) -> Image.Image: rgba = image.convert("RGBA") pixels = rgba.load() target = color for y in range(rgba.height): for x in range(rgba.width): r, g, b, a = pixels[x, y] if a == 0: continue if max(r, g, b) - min(r, g, b) < 18 and r > 160: continue lum = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 local_strength = strength * (0.55 + 0.35 * (1 - lum)) pixels[x, y] = ( clamp(r * (1 - local_strength) + target[0] * local_strength), clamp(g * (1 - local_strength) + target[1] * local_strength), clamp(b * (1 - local_strength) + target[2] * local_strength), a, ) return rgba def shift_hue(image: Image.Image, amount: float) -> Image.Image: if abs(amount) < 0.001: return image.copy() rgba = image.convert("RGBA") pixels = rgba.load() for y in range(rgba.height): for x in range(rgba.width): r, g, b, a = pixels[x, y] if a == 0: continue h, s, v = colorsys.rgb_to_hsv(r / 255, g / 255, b / 255) if s < 0.12 or v > 0.88: continue h = (h + amount) % 1.0 nr, ng, nb = colorsys.hsv_to_rgb(h, s, v) pixels[x, y] = (round(nr * 255), round(ng * 255), round(nb * 255), a) return rgba def place_on_canvas(sprite: Image.Image, style: UnitStyle, direction: str) -> Image.Image: bbox = sprite.getbbox() if bbox: sprite = sprite.crop(bbox) max_w, max_h = target_bounds(style, direction) scale = min(max_w / sprite.width, max_h / sprite.height) * style.scale scale = min(scale, max_w / sprite.width, max_h / sprite.height) new_size = (max(1, round(sprite.width * scale)), max(1, round(sprite.height * scale))) sprite = sprite.resize(new_size, Image.Resampling.LANCZOS) canvas = Image.new("RGBA", (FRAME_SIZE, FRAME_SIZE), (0, 0, 0, 0)) x = (FRAME_SIZE - sprite.width) // 2 if direction == "east": x += 4 elif direction == "west": x -= 4 bottom = 294 if style.role != "cavalry" else 296 y = bottom - sprite.height canvas.alpha_composite(sprite, (x, y)) return canvas def target_bounds(style: UnitStyle, direction: str) -> tuple[int, int]: if style.role == "cavalry": if direction in {"east", "west"}: return (304, 262) return (252, 276) if style.role == "strategist": return (190, 260) if style.role == "archer": return (198, 252) if style.role in {"lord", "officer"}: return (220, 268) if style.role == "spearman": return (226, 266) return (208, 258) def unit_style(stem: str) -> UnitStyle: lower = stem.lower() faction = infer_faction(lower) role = infer_role(lower) rank = infer_rank(lower) target, secondary = SIGNATURE_TARGETS.get(stem, FACTION_TARGETS[faction]) source, section = source_for(stem, faction, role, rank) scale = 1.0 bulk = 1.0 if role == "strategist": scale *= 0.96 bulk *= 0.9 elif role == "archer": scale *= 0.98 elif role == "cavalry": scale *= 1.0 elif role in {"lord", "officer"}: scale *= 1.04 if "ragged" in lower or "scout" in lower: scale *= 0.96 if "veteran" in lower or "elite" in lower or "warlord" in lower: scale *= 1.03 rank = max(rank, 1) if rank >= 2: scale *= 1.04 return UnitStyle(source, section, faction, role, rank, target, secondary, scale, bulk) def source_for(stem: str, faction: str, role: str, rank: int) -> tuple[str, int]: lower = stem.lower() if faction == "nanman": if role == "strategist" or "shaman" in lower: return "nanman", 1 return "nanman", 0 if faction == "rebel": if role in {"archer", "strategist"}: return "rebel", 1 return "rebel", 0 if role == "cavalry": return "cavalry", 1 if any(token in lower for token in ("zhao-yun", "ma-chao", "ma-dai", "white", "elite")) else 0 if role == "archer": return "ranged", 0 if role == "strategist": return "ranged", 1 if role in {"lord", "officer"} or rank >= 2: if any(token in lower for token in ("guan-yu", "zhang-fei", "wei-yan")): return "commander", 1 return "commander", 0 if role == "spearman": return "infantry", 1 return "infantry", 0 def infer_faction(lower: str) -> str: 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(lower: str) -> str: 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 "crossbow" in lower or "longbow" in lower or "huang-zhong" 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 "spearman" if any(token in lower for token in ("liu-bei", "cao-cao")): return "lord" if "leader" in lower or "officer" in lower or any(token in lower for token in ("meng-huo", "wei-yan", "wang-ping", "wu-yi", "li-yan", "huang-quan", "gong-zhi")): return "officer" return "infantry" def infer_rank(lower: str) -> int: if any(token in lower for token in ("liu-bei", "guan-yu", "zhang-fei", "zhao-yun", "cao-cao", "lu-bu", "zhuge", "sima", "ma-chao", "meng-huo")): return 2 if any(token in lower for token in ("officer", "leader", "veteran", "elite", "guard", "warlord")): return 1 return 0 def stable_int(value: str) -> int: return int(hashlib.sha256(value.encode("utf-8")).hexdigest()[:8], 16) def clamp(value: float) -> int: return max(0, min(255, round(value))) def save_png(image: Image.Image, path: Path) -> None: path.parent.mkdir(parents=True, exist_ok=True) output = image if image.mode == "RGBA": output = image.quantize(colors=PNG_PALETTE_COLORS, method=Image.Quantize.FASTOCTREE) temporary_path = path.with_name(f".{path.stem}.tmp.png") output.save(temporary_path, optimize=True) temporary_path.replace(path) def write_contact_sheet(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None: cell = 66 label_w = 148 gap = 14 rows_per_unit = 8 width = label_w + cell * 10 + 36 height = 34 + len(REPRESENTATIVE_UNITS) * (rows_per_unit * cell + gap) + 12 sheet = Image.new("RGB", (width, height), (30, 36, 28)) y = 18 for stem in REPRESENTATIVE_UNITS: base, action = rendered[stem] draw_label(sheet, stem, 12, y + 8) for direction_index, direction in enumerate(DIRECTIONS): row_y = y + direction_index * cell draw_label(sheet, f"idle {direction}", 12, row_y + 28, small=True) for col in range(6): frame = base.crop((col * FRAME_SIZE, direction_index * FRAME_SIZE, (col + 1) * FRAME_SIZE, (direction_index + 1) * FRAME_SIZE)) paste_thumb(sheet, frame, label_w + col * cell, row_y, cell) action_y = y + 4 * cell for direction_index, direction in enumerate(DIRECTIONS): row_y = action_y + direction_index * cell draw_label(sheet, f"atk {direction}", 12, row_y + 28, small=True) for col in range(6): frame_index = ACTION_COLUMN_OFFSETS["attack"] + col + 2 frame = action.crop((frame_index * FRAME_SIZE, direction_index * FRAME_SIZE, (frame_index + 1) * FRAME_SIZE, (direction_index + 1) * FRAME_SIZE)) paste_thumb(sheet, frame, label_w + col * cell, row_y, cell) y += rows_per_unit * cell + gap save_png(sheet, path) def write_actual_size_preview(rendered: dict[str, tuple[Image.Image, Image.Image]], path: Path) -> None: tile = 82 cols = 6 rows = math.ceil(len(REPRESENTATIVE_UNITS) / cols) width = 72 + cols * 150 height = 72 + rows * 126 image = Image.new("RGB", (width, height), (28, 36, 29)) for x in range(0, width, tile): for y in range(height): image.putpixel((x, y), (50, 61, 48)) for y in range(0, height, tile): for x in range(width): image.putpixel((x, y), (50, 61, 48)) draw_label(image, "Actual tactical scale preview", 24, 18) for index, stem in enumerate(REPRESENTATIVE_UNITS): base, _ = rendered[stem] col = index % cols row = index // cols x = 40 + col * 150 y = 58 + row * 126 frame = base.crop((0, 0, FRAME_SIZE, FRAME_SIZE)) paste_thumb(image, frame, x, y, 86) draw_label(image, stem.replace("unit-", ""), x, y + 88, small=True) save_png(image, path) def paste_thumb(sheet: Image.Image, frame: Image.Image, x: int, y: int, cell: int) -> None: frame = frame.copy() frame.thumbnail((cell - 6, cell - 6), Image.Resampling.LANCZOS) box = Image.new("RGB", (cell - 2, cell - 2), (43, 51, 39)) sheet.paste(box, (x + 1, y + 1)) px = x + (cell - frame.width) // 2 py = y + cell - frame.height - 3 sheet.paste(frame, (px, py), frame) def draw_label(image: Image.Image, text: str, x: int, y: int, small: bool = False) -> None: from PIL import ImageDraw, ImageFont draw = ImageDraw.Draw(image) font_size = 11 if small else 15 font = None for candidate in (r"C:\Windows\Fonts\malgun.ttf", r"C:\Windows\Fonts\arial.ttf"): try: font = ImageFont.truetype(candidate, font_size) break except OSError: continue draw.text((x, y), text, fill=(223, 212, 169), font=font) if __name__ == "__main__": main()