2026-09-12 16:29:30 +00:00
|
|
|
|
# WL-814x 측정기 — 렌더 PNG + 마스크 PNG 를 읽어 합격선 6항목을 잰다.
|
|
|
|
|
|
# python AgentScripts/WL814x_metrics.py <raw폴더> [파일접두어필터]
|
|
|
|
|
|
#
|
|
|
|
|
|
# 정의(보고서와 동일)
|
|
|
|
|
|
# · 서로 다른 색 비율 = 고유색 수 / 캐릭터 영역 픽셀 수 × 100
|
|
|
|
|
|
# · 부위당 색 단계 = 그 부위의 색을 RGB 거리 16 이내로 묶었을 때, 면적 90 % 를 덮는 군집 수
|
|
|
|
|
|
# · 진한 점(눈) = 얼굴 데칼 마스크 안에서 L < 피부평균L × 0.75 인 픽셀 수 (814v 와 동일)
|
|
|
|
|
|
# · Hue 오차 = 부위별 채도가중 원형평균 Hue 의 기준 대비 차 (부호 있는 각도)
|
|
|
|
|
|
import sys, os, glob, json
|
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
RAW = sys.argv[1] if len(sys.argv) > 1 else "Screenshots_WL/WL814x/raw"
|
|
|
|
|
|
FILT = sys.argv[2] if len(sys.argv) > 2 else ""
|
|
|
|
|
|
PARTS = ["all", "body3", "Body_m05", "Arm_m05", "Leg_M05", "Head", "Hair05", "Face"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load(p):
|
|
|
|
|
|
return np.asarray(Image.open(p).convert("RGB"), dtype=np.uint8)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def masks():
|
|
|
|
|
|
m = {}
|
|
|
|
|
|
for p in PARTS:
|
|
|
|
|
|
f = os.path.join(RAW, "mask_%s.png" % p)
|
|
|
|
|
|
if os.path.exists(f):
|
|
|
|
|
|
m[p] = load(f)[:, :, 0] > 127
|
|
|
|
|
|
return m
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def lum(px):
|
|
|
|
|
|
return 0.299 * px[:, 0] + 0.587 * px[:, 1] + 0.114 * px[:, 2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def hsv(px):
|
|
|
|
|
|
a = px.astype(np.float32)
|
|
|
|
|
|
mx = a.max(1); mn = a.min(1); d = mx - mn
|
|
|
|
|
|
h = np.zeros(len(a), np.float32)
|
|
|
|
|
|
nz = d > 1e-6
|
|
|
|
|
|
r, g, b = a[:, 0], a[:, 1], a[:, 2]
|
|
|
|
|
|
i = nz & (mx == r); h[i] = ((g[i] - b[i]) / d[i]) % 6
|
|
|
|
|
|
i = nz & (mx == g) & ~(mx == r); h[i] = (b[i] - r[i]) / d[i] + 2
|
|
|
|
|
|
i = nz & (mx == b) & ~(mx == r) & ~(mx == g); h[i] = (r[i] - g[i]) / d[i] + 4
|
|
|
|
|
|
h *= 60.0
|
|
|
|
|
|
s = np.where(mx > 1e-6, d / np.maximum(mx, 1e-6), 0)
|
|
|
|
|
|
return h, s, mx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def mean_hue(px):
|
|
|
|
|
|
h, s, v = hsv(px)
|
|
|
|
|
|
w = np.where(s > 0.08, s, 0.0)
|
|
|
|
|
|
if w.sum() < 1e-6:
|
|
|
|
|
|
return float("nan")
|
|
|
|
|
|
x = (w * np.cos(np.radians(h))).sum(); y = (w * np.sin(np.radians(h))).sum()
|
|
|
|
|
|
return (np.degrees(np.arctan2(y, x)) + 360.0) % 360.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def dhue(a, b):
|
|
|
|
|
|
d = (b - a + 180.0) % 360.0 - 180.0
|
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def steps(px, thr=16.0, cover=0.90):
|
|
|
|
|
|
"""색을 RGB 거리 thr 이내로 묶어 면적 cover 를 덮는 군집 수."""
|
|
|
|
|
|
if len(px) == 0:
|
|
|
|
|
|
return 0, 0
|
|
|
|
|
|
key = (px[:, 0].astype(np.int32) << 16) | (px[:, 1].astype(np.int32) << 8) | px[:, 2].astype(np.int32)
|
|
|
|
|
|
u, cnt = np.unique(key, return_counts=True)
|
|
|
|
|
|
order = np.argsort(-cnt)
|
|
|
|
|
|
u, cnt = u[order], cnt[order]
|
|
|
|
|
|
cols = np.stack([(u >> 16) & 255, (u >> 8) & 255, u & 255], 1).astype(np.float32)
|
|
|
|
|
|
cent = []; wts = []
|
|
|
|
|
|
for i in range(len(u)):
|
|
|
|
|
|
c = cols[i]; w = cnt[i]
|
|
|
|
|
|
best = -1; bd = 1e9
|
|
|
|
|
|
for j, cc in enumerate(cent):
|
|
|
|
|
|
d = np.linalg.norm(cc - c)
|
|
|
|
|
|
if d < bd:
|
|
|
|
|
|
bd = d; best = j
|
|
|
|
|
|
if best >= 0 and bd < thr:
|
|
|
|
|
|
n = wts[best] + w
|
|
|
|
|
|
cent[best] = (cent[best] * wts[best] + c * w) / n
|
|
|
|
|
|
wts[best] = n
|
|
|
|
|
|
else:
|
|
|
|
|
|
cent.append(c.copy()); wts.append(float(w))
|
|
|
|
|
|
if len(cent) > 400:
|
|
|
|
|
|
break
|
|
|
|
|
|
wts = np.array(wts); o = np.argsort(-wts); wts = wts[o]
|
|
|
|
|
|
tot = len(px); acc = 0; k = 0
|
|
|
|
|
|
for w in wts:
|
|
|
|
|
|
acc += w; k += 1
|
|
|
|
|
|
if acc >= cover * tot:
|
|
|
|
|
|
break
|
|
|
|
|
|
return k, len(u)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def measure(img, msk, ref_hue=None, skinL=None):
|
|
|
|
|
|
px = img[msk]
|
|
|
|
|
|
n = len(px)
|
|
|
|
|
|
if n == 0:
|
|
|
|
|
|
return None
|
|
|
|
|
|
key = (px[:, 0].astype(np.int32) << 16) | (px[:, 1].astype(np.int32) << 8) | px[:, 2].astype(np.int32)
|
|
|
|
|
|
uc = len(np.unique(key))
|
|
|
|
|
|
st, _ = steps(px)
|
|
|
|
|
|
L = lum(px)
|
2026-09-12 16:53:53 +00:00
|
|
|
|
h_, s_, v_ = hsv(px)
|
2026-09-12 16:29:30 +00:00
|
|
|
|
r = dict(px=n, colors=uc, ratio=100.0 * uc / n, steps=st,
|
2026-09-12 16:53:53 +00:00
|
|
|
|
lmin=float(L.min()), lmax=float(L.max()), hue=mean_hue(px),
|
|
|
|
|
|
sat=float(s_.mean()), val=float(v_.mean()), lmean=float(L.mean()))
|
2026-09-12 16:29:30 +00:00
|
|
|
|
if ref_hue is not None and not np.isnan(r["hue"]) and not np.isnan(ref_hue):
|
|
|
|
|
|
r["dhue"] = float(dhue(ref_hue, r["hue"]))
|
|
|
|
|
|
if skinL:
|
|
|
|
|
|
r["dark"] = int((L < skinL * 0.75).sum())
|
|
|
|
|
|
return r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main():
|
|
|
|
|
|
M = masks()
|
|
|
|
|
|
files = sorted(glob.glob(os.path.join(RAW, "*.png")))
|
|
|
|
|
|
files = [f for f in files if not os.path.basename(f).startswith("mask_")]
|
|
|
|
|
|
if FILT:
|
|
|
|
|
|
files = [f for f in files if os.path.basename(f).startswith(FILT)]
|
2026-09-12 16:53:53 +00:00
|
|
|
|
# 🔴 색상/채도 기준은 언제나 「지금」 상태(i1_a_base_now) 로 고정한다.
|
|
|
|
|
|
base = os.path.join(RAW, "i1_a_base_now.png")
|
|
|
|
|
|
if os.path.exists(base):
|
|
|
|
|
|
files = [base] + [f for f in files if os.path.abspath(f) != os.path.abspath(base)]
|
2026-09-12 16:29:30 +00:00
|
|
|
|
ref = {}
|
|
|
|
|
|
rows = []
|
|
|
|
|
|
for f in files:
|
|
|
|
|
|
img = load(f)
|
|
|
|
|
|
name = os.path.splitext(os.path.basename(f))[0]
|
|
|
|
|
|
# 피부 밝기 기준 = Head 마스크에서 Face 를 뺀 영역 평균 L
|
|
|
|
|
|
skinL = None
|
|
|
|
|
|
if "Head" in M and "Face" in M:
|
|
|
|
|
|
hm = M["Head"] & ~M["Face"]
|
|
|
|
|
|
if hm.sum() > 0:
|
|
|
|
|
|
skinL = float(lum(img[hm]).mean())
|
|
|
|
|
|
rec = {"name": name, "skinL": skinL}
|
|
|
|
|
|
for p in PARTS:
|
|
|
|
|
|
if p not in M:
|
|
|
|
|
|
continue
|
|
|
|
|
|
r = measure(img, M[p], ref.get(p), skinL if p == "Face" else None)
|
|
|
|
|
|
if r is None:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if p not in ref and not np.isnan(r["hue"]):
|
|
|
|
|
|
ref[p] = r["hue"]
|
|
|
|
|
|
# 눈 크기(얼굴 왼쪽 절반의 진한 점 바운딩박스)
|
|
|
|
|
|
if p == "Face" and skinL:
|
|
|
|
|
|
ys, xs = np.where(M[p])
|
|
|
|
|
|
x0, x1 = xs.min(), xs.max()
|
|
|
|
|
|
half = (x0 + x1) // 2
|
|
|
|
|
|
sub = M[p].copy(); sub[:, half:] = False
|
|
|
|
|
|
idx = np.where(sub)
|
|
|
|
|
|
if len(idx[0]):
|
|
|
|
|
|
Ls = lum(img[sub])
|
|
|
|
|
|
d = Ls < skinL * 0.75
|
|
|
|
|
|
if d.sum() > 0:
|
|
|
|
|
|
yy = idx[0][d]; xx = idx[1][d]
|
|
|
|
|
|
r["eye"] = "%dx%d" % (xx.max() - xx.min() + 1, yy.max() - yy.min() + 1)
|
|
|
|
|
|
rec[p] = r
|
|
|
|
|
|
rows.append(rec)
|
|
|
|
|
|
|
|
|
|
|
|
hdr = "%-28s %6s %6s %5s | %s | %s | %s" % (
|
|
|
|
|
|
"render", "전체색", "비율%", "단계",
|
|
|
|
|
|
"색:몸통/피부/머리칼/얼굴", "단계:몸통/피부/머리칼", "얼굴 진한점/눈")
|
|
|
|
|
|
print(hdr); print("-" * 118)
|
|
|
|
|
|
for r in rows:
|
|
|
|
|
|
a = r.get("all"); b3 = r.get("body3"); hd = r.get("Head"); hr = r.get("Hair05"); fc = r.get("Face")
|
|
|
|
|
|
def c(x): return x["colors"] if x else 0
|
|
|
|
|
|
def s(x): return x["steps"] if x else 0
|
|
|
|
|
|
print("%-28s %6d %6.1f %5d | %5d/%4d/%4d/%4d | %6d/%3d/%3d | %4s / %s" % (
|
|
|
|
|
|
r["name"], a["colors"], a["ratio"], a["steps"],
|
|
|
|
|
|
c(b3), c(hd), c(hr), c(fc), s(b3), s(hd), s(hr),
|
|
|
|
|
|
fc.get("dark", "-") if fc else "-", fc.get("eye", "-") if fc else "-"))
|
|
|
|
|
|
print()
|
2026-09-12 16:53:53 +00:00
|
|
|
|
print("%-26s | %-22s | %-22s | %s" % ("render", "dHue 몸통/피부/머리칼", "채도S 몸통/피부/머리칼", "밝기L 몸통/피부/머리칼"))
|
|
|
|
|
|
b0 = rows[0]
|
2026-09-12 16:29:30 +00:00
|
|
|
|
for r in rows:
|
|
|
|
|
|
def g(p):
|
|
|
|
|
|
x = r.get(p)
|
2026-09-12 16:53:53 +00:00
|
|
|
|
return ("%+5.1f" % x["dhue"]) if (x and "dhue" in x) else " ref"
|
|
|
|
|
|
def s(p):
|
|
|
|
|
|
x = r.get(p); y = b0.get(p)
|
|
|
|
|
|
if not x: return " -"
|
|
|
|
|
|
return "%.2f" % x["sat"] + ("(%+.2f)" % (x["sat"] - y["sat"]) if y and r is not b0 else "")
|
|
|
|
|
|
def lv(p):
|
|
|
|
|
|
x = r.get(p); y = b0.get(p)
|
|
|
|
|
|
if not x: return " -"
|
|
|
|
|
|
return "%3.0f" % x["lmean"] + ("(%+3.0f)" % (x["lmean"] - y["lmean"]) if y and r is not b0 else "")
|
|
|
|
|
|
print("%-26s | %s %s %s | %s %s %s | %s %s %s" % (
|
|
|
|
|
|
r["name"], g("body3"), g("Head"), g("Hair05"),
|
|
|
|
|
|
s("body3"), s("Head"), s("Hair05"), lv("body3"), lv("Head"), lv("Hair05")))
|
2026-09-12 16:29:30 +00:00
|
|
|
|
with open(os.path.join(RAW, "..", "metrics.json"), "w", encoding="utf-8") as fp:
|
|
|
|
|
|
json.dump(rows, fp, ensure_ascii=False, indent=1, default=float)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
main()
|