183 lines
9.1 KiB
Python
183 lines
9.1 KiB
Python
# -*- 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(art_types ·
|
|
표에 없는 아트만 기존 값 · 그것도 없으면 이름에 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
|
|
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']) # 자동으로 못 찾는 그림(안대·눈썹과 붙은 눈·짙은 피부) = 표에 적은 수동 실측 값 유지
|
|
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()
|