Project_WL/AgentScripts/WL814x2_calib.py

111 lines
4.4 KiB
Python
Raw Normal View History

# WL-814x2 팔레트 「최종 화면 색」 역보정 (닫힌 루프)
# 측정 지점을 알베도가 아니라 **Play 화면 픽셀**로 옮긴다.
# 칸마다 원본색 O(부위 보정 역산) · 화면색 S(측정) 를 비교해
# 목표 T = O × (밝기 S / 밝기 O) 가 되도록 팔레트 칸 색에 채널별 이득을 곱한다.
# python AgentScripts/WL814x2_calib.py <측정에쓸 렌더이름> [apply]
import sys, os, json
import numpy as np
from PIL import Image
RAW = 'Screenshots_WL/WL814x/2nd/raw'
PAL = 'Assets/WL/Look/Character/Textures/WLPalette_FINAL.png'
TGT = 'AgentScripts/WL814x2_target.json'
GAIN = {'M05': (0.80, 1.00), 'Skin': (0.86, 0.93), 'Hair': (0.85, 0.93)}
PARTMAP = {'Body_m05': 'M05', 'Arm_m05': 'M05', 'Leg_M05': 'M05', 'Head': 'Skin', 'Hair05': 'Hair'}
REN = sys.argv[1] if len(sys.argv) > 1 else 'a_now'
APPLY = len(sys.argv) > 2 and sys.argv[2] == 'apply'
def L(n):
return np.asarray(Image.open(RAW + '/' + n + '.png').convert('RGB'), dtype=np.uint8)
def lum(c):
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
def hue(c):
r, g, b = [float(x) for x in c]
mx, mn = max(r, g, b), min(r, g, b); d = mx - mn
if d < 1e-6: return 0.0
if mx == r: h = ((g - b) / d) % 6
elif mx == g: h = (b - r) / d + 2
else: h = (r - g) / d + 4
return h * 60.0
def dh(a, b):
return (b - a + 540) % 360 - 180
idm = L('idmap'); ma = L('mask_all')[:, :, 0] > 127
palimg = np.array(Image.open(PAL).convert('RGB'), dtype=np.uint8)
S = palimg.shape[0]; G = S // 8
idref = np.array([[9 + j * 15, 240 - j * 11, 40 + j * 7] for j in range(G * G)], np.float32)
flat = idm.reshape(-1, 3).astype(np.float32)
d2 = ((flat[:, None, :] - idref[None, :, :]) ** 2).sum(2)
near = d2.argmin(1).reshape(idm.shape[:2])
kid = np.where((d2.min(1).reshape(idm.shape[:2]) < 400) & (idm.sum(2) > 12), near, -1)
masks = {}
for p in PARTMAP:
f = RAW + '/mask_' + p + '.png'
if os.path.exists(f):
masks[p] = L('mask_' + p)[:, :, 0] > 127
# 목표색(원본) — 최초 1회만 저장하고 이후 회차에서는 고정해 쓴다
if os.path.exists(TGT):
target = {int(k): v for k, v in json.load(open(TGT)).items()}
else:
target = {}
for j in range(G * G):
gx, gy = j % G, j // G
p = palimg[gy * 8 + 4, gx * 8 + 4].astype(np.float32)
sel = ma & (kid == j)
if sel.sum() < 200:
target[j] = [float(x) for x in p]; continue
part = max(((k, int((sel & v).sum())) for k, v in masks.items()), key=lambda t: t[1])[0]
gs, gv = GAIN[PARTMAP[part]]
mx = p.max() / 255.0; mn = p.min() / 255.0
s = (mx - mn) / mx if mx > 1e-6 else 0
s2 = min(s / gs, 1.0); v2 = min(mx / gv, 1.0)
# H 유지 · S,V 만 역보정
import colorsys
h = hue(p) / 360.0
r, g, b = colorsys.hsv_to_rgb(h, s2, v2)
target[j] = [float(r * 255), float(g * 255), float(b * 255)]
json.dump({str(k): v for k, v in target.items()}, open(TGT, 'w'))
img = L(REN)
newpal = palimg.copy()
print('%3s %-10s %7s | %-16s %-16s %-16s | %-16s %6s -> %6s' %
('', '부위', 'px', '원본 O', '팔레트 P', '화면 S', '새 팔레트 P\'', 'dHue', 'dHue예상'))
rows = []
for j in range(G * G):
gx, gy = j % G, j // G
p = palimg[gy * 8 + 4, gx * 8 + 4].astype(np.float32)
sel = ma & (kid == j)
n = int(sel.sum())
if n < 200:
continue
part = max(((k, int((sel & v).sum())) for k, v in masks.items()), key=lambda t: t[1])[0]
o = np.array(target[j], np.float32)
s = img[sel].mean(0).astype(np.float32)
t = o * (max(lum(s), 1.0) / max(lum(o), 1.0)) # 같은 밝기에서의 「원본 색조」
gain = np.clip(t / np.maximum(s, 4.0), 0.55, 1.8)
np_ = np.clip(p * gain, 0, 255)
rows.append((j, part, n, o, p, s, t, np_))
print('%3d %-10s %7d | %-16s %-16s %-16s | %-16s %+6.1f -> %+6.1f' % (
j, part, n, tuple(int(x) for x in o), tuple(int(x) for x in p), tuple(int(x) for x in s),
tuple(int(x) for x in np_), dh(hue(o), hue(s)), dh(hue(o), hue(t))))
if APPLY:
newpal[gy * 8:gy * 8 + 8, gx * 8:gx * 8 + 8] = np.round(np_).astype(np.uint8)
err = [abs(dh(hue(r[3]), hue(r[5]))) for r in rows]
w = np.array([r[2] for r in rows], np.float64)
print('화면 Hue 오차: 면적가중 평균 %.1f° · 단순평균 %.1f° · 최대 %.1f°' %
((np.array(err) * w).sum() / w.sum(), np.mean(err), np.max(err)))
if APPLY:
Image.fromarray(newpal).save(PAL)
print('팔레트 갱신 →', PAL)