ThreeKingdomsSlingshot/Tools/unit_art_size.py

276 lines
14 KiB
Python
Raw Permalink Normal View History

# -*- 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'
영웅 목록 = 눈 줄 맞춤 + 너무 위에 뜨던 12종 크기 조금 키움 · 보는 방향 2차(원술·허저 반전 · 축융 되돌림) · 소교·초선 얼굴·소품 차별화(#1108 · PD 2026-11-10) PD: 「전위, 태사자 등과 같이 영웅 목록에서 위치가 너무 위쪽에 등장하는 영웅은 위치를 조금 더 내리고 크기를 약간 더 키워」 · 「여전히 방향이 반대로 되어 있는 유닛 체크」 · 「소교와 초선은 복장·헤어는 좋으나 생김새·소품(부채)이 너무 같다」 - 원인: 카드가 머리 끝(관·투구 꼭대기)을 맞춰서 관·투구가 낮은 유닛은 얼굴이 위로 뜸(카드 위 눈 높이 기존 중앙 216.5 · 허저 −62 · 전위 −48 · 태사자 −31 px) - 크기 표 eye_up_px(대기 그림 눈 높이 · 발 위 px · 자동 + 장비·하후돈·축융 수동) → UnitArt.EyeUpPx → HeroScreens 카드 = 눈 줄(걷는 216 · 기병 210 = 기존 20종 중앙값) 맞춤 · 없으면 머리 끝 맞춤 - 25 px 넘게 높던 12종 adjust × 0.95(5 % 크게) - 방향 판정 = 두 눈 가운데 ↔ 얼굴 피부 폭 가운데(머리 실루엣은 투구·끈에 속음) + 확대 확인 → 원술·허저 반전 · 축융 = 1차 반전이 오판이라 되돌림 · 출발·돌진 = 왼쪽 0 - 소교 = 동그란 얼굴·발그레 볼·밝은 웃음 + 대나무 피리 · 초선 = 가는 아몬드 눈·붉은 입술 + 춤 비단 리본(옷·머리 문장 그대로 · seed 51~54 중 · QA PASS B 0) - 테스트(고친 부분만): UnitArt_SizeTable · Lobby_Tabs_Back_ComingSoon · UILayerTests 각 1/1 · error 0 · 캡처 hero_list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:13:46 +00:00
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)
영웅 목록 = 눈 줄 맞춤 + 너무 위에 뜨던 12종 크기 조금 키움 · 보는 방향 2차(원술·허저 반전 · 축융 되돌림) · 소교·초선 얼굴·소품 차별화(#1108 · PD 2026-11-10) PD: 「전위, 태사자 등과 같이 영웅 목록에서 위치가 너무 위쪽에 등장하는 영웅은 위치를 조금 더 내리고 크기를 약간 더 키워」 · 「여전히 방향이 반대로 되어 있는 유닛 체크」 · 「소교와 초선은 복장·헤어는 좋으나 생김새·소품(부채)이 너무 같다」 - 원인: 카드가 머리 끝(관·투구 꼭대기)을 맞춰서 관·투구가 낮은 유닛은 얼굴이 위로 뜸(카드 위 눈 높이 기존 중앙 216.5 · 허저 −62 · 전위 −48 · 태사자 −31 px) - 크기 표 eye_up_px(대기 그림 눈 높이 · 발 위 px · 자동 + 장비·하후돈·축융 수동) → UnitArt.EyeUpPx → HeroScreens 카드 = 눈 줄(걷는 216 · 기병 210 = 기존 20종 중앙값) 맞춤 · 없으면 머리 끝 맞춤 - 25 px 넘게 높던 12종 adjust × 0.95(5 % 크게) - 방향 판정 = 두 눈 가운데 ↔ 얼굴 피부 폭 가운데(머리 실루엣은 투구·끈에 속음) + 확대 확인 → 원술·허저 반전 · 축융 = 1차 반전이 오판이라 되돌림 · 출발·돌진 = 왼쪽 0 - 소교 = 동그란 얼굴·발그레 볼·밝은 웃음 + 대나무 피리 · 초선 = 가는 아몬드 눈·붉은 입술 + 춤 비단 리본(옷·머리 문장 그대로 · seed 51~54 중 · QA PASS B 0) - 테스트(고친 부분만): UnitArt_SizeTable · Lobby_Tabs_Back_ComingSoon · UILayerTests 각 1/1 · error 0 · 캡처 hero_list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 04:13:46 +00:00
# 눈 높이(발 위 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()