# -*- coding: utf-8 -*- """유닛 아트 크기 표 생성(PD 2026-11-09 「영웅 목록 캐릭터 크기 동일 · 말 탄 유닛도 보병·창병과 비슷한 비율」). 기준(2026-11-10 개정 · PD 「새 유닛(강유·서황 등)이 이전 유닛보다 크다 · 이전에 유닛 크기를 조정한 기준에 맞게」) eye_px = 대기 첫 프레임(front_idle/f000.png)의 두 눈 중심 사이 가로 px(얼굴 피부 덩어리 안 어두운 구멍 2개) face_px = 같은 눈 높이 행에서 얼굴 피부 덩어리(구멍 메움) 폭 head_px = 머리 실루엣 폭(투구·머리카락 포함 · 가는 무기는 열림 연산으로 뗌 · 눈~이마 행 중앙값) crown_px = 정수리(가는 깃 제외)~눈 높이 lock 1 = PD 가 크기를 확인한 행(2026-11-09 기존 20종 · 눈 간격 기준) → size_px 고정 = 기준점 나머지 size_px = 가운데 값(eye_px × a, face_px × b, head_px × c, crown_px × d) × adjust a~d = 기준점(걷는 유닛) size_px ÷ 각 지표 중앙값 · adjust = 영웅 목록 카드 시뮬레이션(기준 6종과 나란히) 눈 확인 보정(1.1 = 10 % 작게 · 사람이 적음) (11-09 판 = eye_px 하나 → 새 그림은 투구·관이 커서 머리 덩어리가 커짐 → 네 지표 가운데 값 + 눈 확인 보정) 게임(FrameAnimator)·영웅 목록은 bodyHeightPx 대신 size_px 로 배율을 정한다 → 머리 덩어리 크기가 고르게 맞고, 말 탄 유닛은 탄 사람이 보병과 같은 크기가 되도록 커진다(게임 상한 = BalanceConfig.mountedArtMaxTop). 표 = Assets/Resources/Units/art_size.csv(utf-8-sig · SOT) · mounted = 게임 표(영웅·무장·병사)의 병종이 cavalry 면 1(art_types · 표에 없는 아트만 기존 값 · 그것도 없으면 이름에 cavalry 면 1). 눈을 못 찾으면 size_px 를 비워 둔다(게임은 bodyHeightPx 로 대체) · 실행 결과를 --sheet 시트로 눈 위치를 확인할 것. note 가 「머리 수동 <머리÷눈>·<정수리÷눈>」 으로 시작하면 head_px·crown_px = 그 비율 × 자동 eye_px(소품이 머리 옆에 붙어 자동 머리 폭이 소품까지 재는 그림 · 예 제갈량 깃털부채 2026-11-10 · 묶음 배율이 바뀌어도 맞도록 비율로 적음). 사용: python -I -B -X utf8 Tools/unit_art_size.py <프로젝트 루트> [--sheet out.png] """ import csv import glob import json import os import re 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', 'eye_up_px', 'face_px', 'head_px', 'crown_px', 'body_px', 'size_px', 'lock', 'adjust', 'note'] def head_width(img, eyes): """(머리 실루엣 폭, 정수리~눈 높이) px — 창대 같은 가는 무기는 열림 연산(반지름 0.22 × 눈 간격)으로 떼어 내고, 폭 = 눈 높이 ~ 그 위 0.6 × 눈 간격 행들에서 눈 가운데를 품은 불투명 구간 폭의 중앙값 · 못 재면 (0, 0).""" if not eyes: return 0, 0 (_, lx, ly), (_, rx, ry) = eyes E = rx - lx y0, cx = int((ly + ry) / 2), int((lx + rx) / 2) al = np.asarray(img.convert('RGBA'))[..., 3] > 128 r = max(2, int(E * 0.22)) yy, xx = np.ogrid[-r:r + 1, -r:r + 1] al = ndimage.binary_opening(al, structure=(xx * xx + yy * yy) <= r * r) def run(y): row = al[y] if not row[cx]: return 0 l = q = cx while l > 0 and row[l - 1]: l -= 1 while q < len(row) - 1 and row[q + 1]: q += 1 return q - l + 1 ws = [w for w in (run(y) for y in range(max(0, int(y0 - 0.6 * E)), y0 + 1, 2)) if w] top = y0 for y in range(y0, -1, -1): # 정수리 = 눈에서 위로, 눈 가운데를 품은 구간 폭 ≥ 0.5 × 눈 간격 인 마지막 행(가는 깃·창끝 제외) if run(y) < 0.5 * E: break top = y return (round(float(np.median(ws)), 1) if ws else 0), float(y0 - top) def face_width(img, eyes): """눈 높이 행에서 두 눈을 품은 얼굴 피부 덩어리(구멍 메움) 폭 px · 못 재면 0.""" if not eyes: return 0 a = np.asarray(img.convert('RGBA')).astype(np.float32) / 255.0 (_, lx, ly), (_, rx, ry) = eyes y, cx = int(round((ly + ry) / 2)), int(round((lx + rx) / 2)) sk = skin_mask(a, False)[0] | skin_mask(a, True)[0] lab, n = ndimage.label(sk) k = lab[y, cx] for dx in range(1, int(rx - lx) + 1): if k: break for xx in (cx - dx, cx + dx): if 0 <= xx < lab.shape[1] and lab[y, xx]: k = lab[y, xx] break if not k: return 0 xs = np.where(ndimage.binary_fill_holes(lab == k)[y])[0] return int(xs.max() - xs.min() + 1) if len(xs) else 0 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 oy = np.where((a[..., 3] > 0.5).any(1))[0] mid_y = oy.min() + 0.5 * (oy.max() - oy.min()) if len(oy) else a.shape[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 if ys.mean() > mid_y: continue # 얼굴 = 몸 위쪽 절반 · 아래쪽 살색 덩어리 = 말 주둥이(콧구멍을 눈으로 잡음 · 2026-11-10 장비 v5 실측) 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 art_types(root): """게임 표(SOT)에서 아트 id → 병종 — 영웅(heroes.csv art · unit_type) · 무장(stages.csv generals id:병종 → generals.csv art 또는 id) · 병사(stages.csv 병종 열 > 0 → factions.csv art + _병종). 말 탄 유닛 = 병종 cavalry(2026-11-09 새 영웅·무장 아트 91종 — 이름에 cavalry 가 없는 기병 영웅). """ def rows(p): with open(os.path.join(root, p), encoding='utf-8-sig', newline='') as f: return list(csv.DictReader(f)) types = {} for h in rows('Assets/Data/OutGame/heroes.csv'): types.setdefault(h['art'], set()).add(h['unit_type']) gen = {g['id']: (g['art'] or g['id']) for g in rows('Assets/Data/Stages/generals.csv')} fac = {f['code']: f['art'] for f in rows('Assets/Data/Stages/factions.csv')} kinds = ('infantry', 'spear', 'cavalry', 'archer', 'strategist') for s in rows('Assets/Data/Stages/stages.csv'): for k in kinds: if (s.get(k) or '0').strip() not in ('', '0') and s['faction'] in fac: types.setdefault(fac[s['faction']] + '_' + k, set()).add(k) for part in (s.get('generals') or '').split(';'): f = part.strip().split(':') if len(f) == 3 and f[0] in gen: types.setdefault(gen[f[0]], set()).add(f[1]) return types def main(): root = sys.argv[1] types = art_types(root) 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, {}) t = types.get(art) mounted = ('1' if 'cavalry' in t else '0') if t else prev.get('mounted', '1' if 'cavalry' in art else '0') eye = round(eyes[1][1] - eyes[0][1], 1) if eyes else 0 if not eyes and prev.get('note', '').startswith('눈 수동') and float(prev.get('eye_px') or 0) > 0: eye = float(prev['eye_px']) # 자동으로 못 찾는 그림(안대·눈썹과 붙은 눈·짙은 피부) = 표에 적은 수동 실측 값 유지 lock = prev.get('lock', '') hw, cr = head_width(img, eyes) mh = re.match(r'머리 수동 ([\d.]+)·([\d.]+)', prev.get('note', '')) if mh and eye > 0: hw, cr = round(float(mh.group(1)) * eye, 1), round(float(mh.group(2)) * eye, 1) # 눈 높이(발 위 px) = 영웅 목록 카드가 눈 줄을 맞추는 기준(PD 2026-11-10 「전위·태사자처럼 너무 위쪽에 보이는 영웅은 내려」) foot_y = img.height - meta['pivot'][1] * img.height eye_up = round(foot_y - (eyes[0][2] + eyes[1][2]) / 2, 1) if eyes else 0 if not eyes and prev.get('note', '').startswith('눈 수동') and float(prev.get('eye_up_px') or 0) > 0: eye_up = float(prev['eye_up_px']) rows.append({'art': art, 'mounted': mounted, 'eye_px': eye, 'eye_up_px': eye_up, 'face_px': face_width(img, eyes), 'head_px': hw, 'crown_px': cr, 'body_px': body, 'size_px': float(prev['size_px']) if lock == '1' and prev.get('size_px') else '', 'lock': lock, 'adjust': prev.get('adjust', ''), 'note': prev.get('note', '')}) shots.append((art, meta, img, eyes)) def median(v): v = sorted(v) m = len(v) // 2 return v[m] if len(v) % 2 else (v[m - 1] + v[m]) / 2 # 기준 = lock 1 행(PD 가 확인한 크기 · 고정) 중 걷는 유닛: size_px ÷ 눈 간격·얼굴 폭·머리 폭 중앙값 → 나머지 = 세 추정 평균 # (PD 2026-11-10 「새 유닛(강유·서황)이 이전 유닛보다 크다 · 이전에 유닛 크기를 조정한 기준에 맞게」 — 새 그림은 투구가 커서 눈 간격 하나로는 머리 덩어리가 커짐) anchor = [r for r in rows if r['lock'] == '1' and r['mounted'] == '0' and r['size_px']] if not anchor: sys.exit('기준 행(lock = 1 · 걷는 유닛) 없음') ratio = {} for key in ('eye_px', 'face_px', 'head_px', 'crown_px'): v = [r['size_px'] / r[key] for r in anchor if r[key]] ratio[key] = median(v) if v else 0 for r in rows: if r['lock'] == '1' and r['size_px']: continue # 네 추정(눈 간격 · 얼굴 폭 · 머리 폭 · 정수리 높이)의 가운데 값 — 하나가 투구·수염·무기에 속아도 버팀 · × adjust(영웅 목록 카드 눈 확인 보정 · 1.1 = 10 % 작게) est = sorted(r[k] * ratio[k] for k in ratio if r[k] and ratio[k]) if not est: r['size_px'] = '' continue m = len(est) // 2 base = est[m] if len(est) % 2 else (est[m - 1] + est[m]) / 2 r['size_px'] = round(base * (float(r['adjust']) if r['adjust'] else 1.0), 1) 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('기준 %d종(lock · 걷는 유닛) · 비율 size/눈 %.3f · size/얼굴 %.3f · size/머리 %.3f · size/정수리 %.3f' % ( len(anchor), ratio['eye_px'], ratio['face_px'], ratio['head_px'], ratio['crown_px'])) for r in rows: s = (r['size_px'] / r['body_px']) if r['size_px'] else 0 print('%-22s mounted %s lock %1s eye %6s face %4s head %6s body %5s size %7s 배율 x%.2f%s' % ( r['art'], r['mounted'], r['lock'], r['eye_px'], r['face_px'], r['head_px'], r['body_px'], r['size_px'], 1 / s if s else 0, '' if r['eye_px'] else ' ← 눈 못 찾음')) 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()