# -*- coding: utf-8 -*- """유닛 아트 크기 표 생성(PD 2026-11-09 「영웅 목록 캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」). 기준 = 눈 간격(머리 크기) — 투구·수염·말과 상관없이 같은 화풍이면 머리 크기에 비례한다. eye_px = 대기 첫 프레임(front_idle/f000.png)의 두 눈 중심 사이 가로 px(얼굴 피부 덩어리 안 어두운 구멍 2개) ref = 말을 타지 않은 유닛(mounted=0)의 eye_px / body_px 중앙값 size_px = eye_px / ref = 「머리 크기가 표준인 유닛의 키」로 환산한 세로 px 게임(FrameAnimator)·영웅 목록은 bodyHeightPx 대신 size_px 로 배율을 정한다 → 모든 유닛의 머리 크기가 같아지고, 말 탄 유닛은 탄 사람이 보병과 같은 크기가 되도록 커진다. 표 = Assets/Resources/Units/art_size.csv(utf-8-sig · SOT) · mounted 열만 사람이 고친다(새 아트 = 이름에 cavalry 면 1). 눈을 못 찾으면 size_px 를 비워 둔다(게임은 bodyHeightPx 로 대체) · 실행 결과를 --sheet 시트로 눈 위치를 확인할 것. 사용: python -I -B -X utf8 Tools/unit_art_size.py <프로젝트 루트> [--sheet out.png] """ import csv import glob import json import os import sys import numpy as np from PIL import Image, ImageDraw, ImageFont from scipy import ndimage TABLE = 'Assets/Resources/Units/art_size.csv' FIELDS = ['art', 'mounted', 'eye_px', 'body_px', 'size_px', 'note'] def skin_mask(a, red): r, g, b, al = a[..., 0], a[..., 1], a[..., 2], a[..., 3] mx = np.maximum(np.maximum(r, g), b) mn = np.minimum(np.minimum(r, g), b) sat = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-6), 0) if red: # 붉은 얼굴(관우) sk = (al > 0.9) & (r > 0.8) & (g > 0.3) & (g < 0.6) & (b > 0.25) & (b < 0.55) else: sk = (al > 0.9) & (r >= g) & (g >= b - 0.02) & (mx > 0.78) & (sat > 0.06) & (sat < 0.45) & ((r - b) > 0.06) return sk, mx def find_eyes(img): """두 눈 (왼쪽, 오른쪽) = (면적, x, y) · 못 찾으면 None.""" a = np.asarray(img.convert('RGBA')).astype(np.float32) / 255.0 for red in (False, True): sk, val = skin_mask(a, red) lab, n = ndimage.label(sk) if not n: continue sizes = ndimage.sum(sk, lab, range(1, n + 1)) for k in list(np.argsort(sizes)[::-1][:6] + 1): face = lab == k ys, xs = np.where(face) if len(ys) < 3000: continue fh = ys.max() - ys.min() + 1 holes = ndimage.binary_fill_holes(face) & ~face hl, hn = ndimage.label(holes) cand = [] for j in range(1, hn + 1): yy, xx = np.where(hl == j) if len(yy) < 150 or val[yy, xx].mean() > 0.4: continue if (xx.max() - xx.min() + 1) > 2.2 * (yy.max() - yy.min() + 1): continue # 가로로 긴 구멍 = 수염·입 cand.append((len(yy), xx.mean(), yy.mean())) best = None for l in cand: for r in cand: if l[1] >= r[1] or abs(l[2] - r[2]) > 0.12 * fh: continue if min(l[0], r[0]) < 0.45 * max(l[0], r[0]): continue score = min(l[0], r[0]) * (r[1] - l[1]) if best is None or score > best[0]: best = (score, l, r) if best: return best[1], best[2] return None def main(): root = sys.argv[1] sheet_path = sys.argv[sys.argv.index('--sheet') + 1] if '--sheet' in sys.argv else None table = os.path.join(root, TABLE) old = {} if os.path.exists(table): with open(table, encoding='utf-8-sig', newline='') as f: old = {r['art']: r for r in csv.DictReader(f)} rows, shots = [], [] for meta_path in sorted(glob.glob(os.path.join(root, 'Assets/Resources/Units/*/meta.json'))): folder = os.path.dirname(meta_path) art = os.path.basename(folder) meta = json.load(open(meta_path, encoding='utf-8')) body = meta.get('bodyHeightPx') or meta.get('charHeightPx') or 0 frame = os.path.join(folder, 'front_idle', 'f000.png') if not os.path.exists(frame): frame = sorted(glob.glob(os.path.join(folder, '*', 'f000.png')))[0] img = Image.open(frame).convert('RGBA') eyes = find_eyes(img) prev = old.get(art, {}) mounted = prev.get('mounted', '1' if 'cavalry' in art else '0') eye = round(eyes[1][1] - eyes[0][1], 1) if eyes else 0 rows.append({'art': art, 'mounted': mounted, 'eye_px': eye, 'body_px': body, 'size_px': '', 'note': prev.get('note', '')}) shots.append((art, meta, img, eyes)) foot = sorted(r['eye_px'] / r['body_px'] for r in rows if r['mounted'] == '0' and r['eye_px'] and r['body_px']) if not foot: sys.exit('말을 타지 않은 유닛의 눈 간격을 하나도 못 찾음') mid = len(foot) // 2 ref = foot[mid] if len(foot) % 2 else (foot[mid - 1] + foot[mid]) / 2 for r in rows: r['size_px'] = round(r['eye_px'] / ref, 1) if r['eye_px'] else '' with open(table, 'w', encoding='utf-8-sig', newline='') as f: w = csv.DictWriter(f, fieldnames=FIELDS, lineterminator='\n') w.writeheader() w.writerows(rows) print('ref(눈 간격 / 키 · 말 안 탄 %d종 중앙값) = %.4f' % (len(foot), ref)) for r in rows: s = (r['size_px'] / r['body_px']) if r['size_px'] else 0 print('%-22s mounted %s eye %6s body %5s size %7s 배율 x%.2f%s' % ( r['art'], r['mounted'], r['eye_px'], r['body_px'], r['size_px'], 1 / s if s else 0, '' if r['eye_px'] else ' ← 눈 못 찾음(bodyHeightPx 대체)')) if sheet_path: # 확인 시트: 표 배율로 그린 대기 그림 + 눈 표시(머리 크기가 같아 보여야 함) unit = 160.0 # 표준 키 = 시트 160 px cols, cw, ch = 9, 220, 330 sheet = Image.new('RGB', (cw * cols, ch * ((len(shots) + cols - 1) // cols)), (40, 44, 60)) dr = ImageDraw.Draw(sheet) font = ImageFont.truetype('C:/Windows/Fonts/malgun.ttf', 13) for i, ((art, meta, img, eyes), r) in enumerate(zip(shots, rows)): size = r['size_px'] or r['body_px'] sc = unit / size x0, y0 = (i % cols) * cw, (i // cols) * ch t = img.resize((max(1, int(img.width * sc)), max(1, int(img.height * sc))), Image.LANCZOS) base = y0 + ch - 22 ox, oy = x0 + cw // 2 - t.width // 2, int(base - (1 - meta['pivot'][1]) * img.height * sc) sheet.paste(t, (ox, oy), t) for e in eyes or (): ex, ey = ox + e[1] * sc, oy + e[2] * sc dr.ellipse([ex - 3, ey - 3, ex + 3, ey + 3], outline=(0, 255, 0), width=2) dr.text((x0 + 3, base + 3), '%s%s' % (art, ' (말)' if r['mounted'] == '1' else ''), fill=(255, 255, 255), font=font) sheet.save(sheet_path) print('sheet', sheet_path) if __name__ == '__main__': main()