# WL-814x 측정기 — 렌더 PNG + 마스크 PNG 를 읽어 합격선 6항목을 잰다. # python AgentScripts/WL814x_metrics.py [파일접두어필터] # # 정의(보고서와 동일) # · 서로 다른 색 비율 = 고유색 수 / 캐릭터 영역 픽셀 수 × 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) h_, s_, v_ = hsv(px) r = dict(px=n, colors=uc, ratio=100.0 * uc / n, steps=st, lmin=float(L.min()), lmax=float(L.max()), hue=mean_hue(px), sat=float(s_.mean()), val=float(v_.mean()), lmean=float(L.mean())) 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)] # 🔴 색상/채도 기준은 언제나 「지금」 상태(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)] 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() print("%-26s | %-22s | %-22s | %s" % ("render", "dHue 몸통/피부/머리칼", "채도S 몸통/피부/머리칼", "밝기L 몸통/피부/머리칼")) b0 = rows[0] for r in rows: def g(p): x = r.get(p) 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"))) 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()