"""병과 단색 심볼 만들기(PD 2026-11-09 「병과 표시 = 기병 말 · 보병 검 · 궁병 활 · 창병 창 · 책사 깃털부채 심볼 · 예시 = 어두운 원 위 베이지 말머리」). 새로 그리지 않는다 — 확정 납품 메달 아이콘(Assets/UI/Samguk/class/cls_<병과>.png)을 만들 때 쓴 투명 바탕 사물 그림 (아트 R&D `C:/ArtRnD/samguk_ui/_work/class_symbol/rgba/cls_<병과>_s21A_rgba.png` · picked_auto.json = seed 21)의 투명도 = 형태. 기본 = 검은 바깥 테두리 + 그 안쪽 흰 채움 · 안쪽 선 없음(PD 2026-11-09 「배경 프레임 없이 · 검은 선 안쪽은 흰색 · 입체감이 느껴지지 않도록」 · --inner-lum 40 = 원본 안쪽 선 남김) · --mono = 단색(채움 한 색 · 안쪽 선 = 뚫린 선). 실행: -I -B Tools/class_symbols.py [--src ] [--fill #RRGGBB] [--line #RRGGBB] [--out Assets/UI/Symbols] [--preview ] 출력 = /cls_sym_<병과>.png(256 정사각 · 투명) · --preview = 어두운 원 위 5종 확인 그림. """ import argparse import os import numpy as np from PIL import Image, ImageDraw, ImageFilter TYPES = ["infantry", "spear", "cavalry", "archer", "strategist"] ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SRC = "C:/ArtRnD/samguk_ui/_work/class_symbol/rgba" def hexrgb(s): s = s.lstrip("#") return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4)) def symbol(path, fill, line, size=256, pad=14, stroke=8, inner_lum=0, erode_passes=6, mono=True): src = Image.open(path).convert("RGBA") bb = src.getchannel("A").point(lambda v: 255 if v > 40 else 0).getbbox() src = src.crop(bb) # 안쪽 선 = 원본 해상도에서 형태 안쪽(바깥 외곽선 두께 ≈ 30 px 보다 깊이)의 진한 픽셀 — 눈 · 갈기 · 칼날 경계 등 core = src.getchannel("A").point(lambda v: 255 if v > 128 else 0) for _ in range(erode_passes): core = core.filter(ImageFilter.MinFilter(11)) rgb = np.asarray(src.convert("RGB")).astype(np.float32) lum = rgb[:, :, 0] * 0.299 + rgb[:, :, 1] * 0.587 + rgb[:, :, 2] * 0.114 inner = Image.fromarray((((np.asarray(core) > 127) & (lum < inner_lum)) * 255).astype(np.uint8)) s = (size - 2 * pad - 2 * stroke) / max(src.size) dim = (max(1, round(src.width * s)), max(1, round(src.height * s))) alpha = src.getchannel("A").resize(dim, Image.LANCZOS) inner = inner.resize(dim, Image.LANCZOS) canvas = Image.new("L", (size, size), 0) off = ((size - dim[0]) // 2, (size - dim[1]) // 2) canvas.paste(alpha, off) lines = Image.new("L", (size, size), 0) lines.paste(inner, off) if mono: # 단색(PD 2026-11-09 「배경 프레임 없이 단색」) = 채움 한 색 · 테두리 없음 · 안쪽 선 = 뚫린 선(형태 구분) out = Image.new("RGBA", (size, size), fill + (255,)) a = np.asarray(canvas).astype(np.float32) * (1.0 - np.asarray(lines).astype(np.float32) / 255.0) out.putalpha(Image.fromarray(a.clip(0, 255).astype(np.uint8))) return out out = Image.new("RGBA", (size, size), line + (0,)) out.putalpha(canvas.filter(ImageFilter.MaxFilter(stroke * 2 + 1))) # 바깥 테두리 body = Image.new("RGBA", (size, size), fill + (255,)) body.putalpha(canvas) out.alpha_composite(body) ink = Image.new("RGBA", (size, size), line + (255,)) ink.putalpha(lines) out.alpha_composite(ink) return out def main(): ap = argparse.ArgumentParser() ap.add_argument("--src", default=SRC) ap.add_argument("--seed", default="s21") ap.add_argument("--fill", default="#FFFFFF") ap.add_argument("--line", default="#1A1A22") ap.add_argument("--out", default="Assets/UI/Symbols") ap.add_argument("--preview", default="") ap.add_argument("--inner-lum", type=float, default=0.0, help="안쪽 선(원본의 진한 선) 밝기 기준 · 0 = 안쪽 선 없음(기본 · 입체감 없이 평면)") ap.add_argument("--mono", action="store_true", help="단색(채움 한 색 · 안쪽 선 = 뚫린 선 · 테두리 없음) · 기본 = 검은 선 + 흰 채움") a = ap.parse_args() out = os.path.join(ROOT, a.out) os.makedirs(out, exist_ok=True) tiles = [] for t in TYPES: g = symbol(os.path.join(a.src, "cls_%s_%sA_rgba.png" % (t, a.seed)), hexrgb(a.fill), hexrgb(a.line), inner_lum=a.inner_lum, mono=a.mono) g.save(os.path.join(out, "cls_sym_%s.png" % t)) tile = Image.new("RGBA", (300, 300), (0, 0, 0, 0)) tile.alpha_composite(g.resize((200, 200), Image.LANCZOS), (50, 50)) tiles.append(tile) if a.preview: prev = Image.new("RGBA", (300 * len(tiles), 300), (40, 42, 60, 255)) for i, t in enumerate(tiles): prev.alpha_composite(t, (i * 300, 0)) prev.save(a.preview) print("ok ->", out) if __name__ == "__main__": main()