ThreeKingdomsSlingshot/Tools/unit_art_size.py

276 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- 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()