- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
635 lines
32 KiB
Python
635 lines
32 KiB
Python
"""공통 리노컷 리그 — 원화 눈썹 스프라이트 + 눈꺼풀 선 굵기 배율 생성(결정문 §8.4.1,
|
||
작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기").
|
||
|
||
배경(오케스트레이터 판정): P7 시범 렌더에서 벡터 눈썹(P1의 가늘고 옅은 눈썹에 맞춘
|
||
굵기)이 P7의 굵은 판화 눈썹을 "갈색빛 회색 막대"로 그렸다. 입술에서 받아들여진
|
||
"중립 렌더 = 원화" 원칙을 눈썹에도 적용한다 — 원화 눈썹 잉크를 그대로 스프라이트로
|
||
떼어 렌더러가 세로 띠로 나눠 휘게 한다. 눈은 감고 뜨기 때문에 벡터를 유지하되,
|
||
원화에서 잰 윗눈꺼풀 잉크 두께 배율(lineScale)을 선 굵기에 곱한다.
|
||
|
||
persona.json browSpriteFromArt/eyeLineScaleFromArt(둘 다 기본 true)가 false인
|
||
페르소나는 해당 산출을 건너뛴다(P1: 이미 고정 벡터 눈썹·굵기로 빌드·검수됐고,
|
||
lineScale의 기준값 자체이기도 하다 — 바뀌면 안 된다).
|
||
|
||
눈썹 영역:
|
||
- manifest의 눈썹 중심선(brow_centerline.py 결과: landmarks.eyebrowLeft/Right
|
||
inner·peak·outer, browCenterline.halfWidthLeft/Right)을 따라 띠를 만든다.
|
||
반높이는 max(halfWidth)+8, inner·outer 바깥으로 6px 연장한다.
|
||
- 띠 안에서 원화(base-front) 잉크와 연결된 성분만 남긴다(Otsu(front 휘도) AND
|
||
diff(F-front)>=25, 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취한다 —
|
||
brow_centerline.build_ink_band와 같은 기준).
|
||
- 머리카락은 뺀다. 기준은 layers/v2/hairFront.png(이미 게시되는 그 레이어)의
|
||
알파>128(1px 팽창, 오케스트레이터 판정값)다 — 분할 카테고리(category=1) 자체를
|
||
쓰면 이 화풍(두꺼운 판화 눈썹이 앞머리와 맞닿아 있다)에서 세그멘터가 눈썹
|
||
잉크 자체를 "머리카락"으로 광범위 오탐한다(P7 1차 실측: 분할 합집합을 그대로
|
||
빼면 눈썹 잉크의 54~82%가 사라짐). hairFront는 렌더 z-순서에서 눈썹 스프라이트
|
||
"위"에 그려지므로, 그 알파 아래 픽셀을 스프라이트에서 빼도 중립 렌더는 원화와
|
||
같고 이중 가닥(겹쳐 보임)도 생기지 않는다 — "앞머리 가닥이 눈썹과 같이
|
||
움직이면 안 된다"는 의도를 분할 카테고리보다 정확히 구현한다(오케스트레이터
|
||
실측: 눈썹 띠 안 잉크 중 hairFront 알파>128 아래에 있는 비율은 좌 47%
|
||
(1392/2953), 우 19%(442/2329) — 분할 카테고리 기준 손실 54~82%보다 훨씬 적다).
|
||
|
||
알파: a = clamp((L_skin-L)/(L_skin-L_ink), 0, 1) × (region 경계 1.5px 페더, RAMP_PX).
|
||
L_skin은 띠 안 faceless 휘도 중앙값, L_ink는 띠 안에서 "잉크로 분류된 픽셀"(머리카락
|
||
제외 후 region, band_ink_mask 결과) 휘도의 중앙값이다 — 그보다 어두운 픽셀은 a=1이
|
||
된다(2차 보정, 작업 패킷 "눈썹 스프라이트·눈꺼풀 굵기 2차 보정": 띠 전체 휘도의
|
||
5백분위를 쓰면 극단적으로 어두운 꼬리값이 L_ink가 돼, 잉크 픽셀 대부분이 a<1로
|
||
옅게 그려졌다). 페더도 4px→1.5px로 줄였다 — 눈썹 해칭 획 자체가 가늘어(경계거리
|
||
평균 2.28px) 4px 페더로는 L_ink를 낮춰도 대부분 페더 중간값에 걸려 옅었다(RAMP_PX
|
||
주석 참고, 블러·형태학 닫기는 쓰지 않아 결은 그대로다).
|
||
|
||
색: RGB = clip((front - (1-a)*skin)/a), skin은 띠 안 faceless 평균색. a<0.05면
|
||
잉크색(L_ink population의 front RGB 중앙값)으로 대체한다(0 나눗셈 폭주 방지).
|
||
|
||
눈꺼풀 굵기: 눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채 중심 y 사이의 첫
|
||
연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다. P1 기준값(아래 상수)으로 나눈
|
||
비를 [1.0, 2.5]로 clamp한 값이 lineScale이다.
|
||
|
||
전제: landmarks.py·brow_centerline.py·layers_v2.py·face_detail.py가 먼저 돌아서
|
||
manifest.landmarks(+browCenterline)와 layers/v2/{body,head,hairFront,face-detail}.png를
|
||
만들어 두어야 한다. export_rig.py보다 앞, face_detail.py보다 뒤에 둔다.
|
||
|
||
실행: <venv>/python.exe brow_sprite.py <persona-dir>
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import math
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import cv2
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw
|
||
from scipy import ndimage
|
||
|
||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||
from persona_config import load_persona_config # noqa: E402
|
||
from segmentation import build_padded_faceless, to_u8, composite_over, CREAM_BG # noqa: E402
|
||
from lip_texture import crop_to_alpha_bbox # noqa: E402 — 알파 bbox로 잘라 RGBA PNG를 내는 공용 유틸(입술 결과 동일 패턴)
|
||
|
||
# --- 눈썹 영역(띠) 상수 -------------------------------------------------------
|
||
BAND_EXTRA_HALF_HEIGHT_PX = 8.0 # 반높이 = max(halfWidth) + 이 값
|
||
BAND_END_EXTEND_PX = 6.0 # inner·outer 바깥 연장
|
||
BAND_BBOX_PAD_PX = 10 # 닫기 연산이 띠 경계에서 잘리지 않게 주는 여유
|
||
|
||
# brow_centerline.py의 잉크 판정 기준과 같다(같은 원화 눈썹 잉크를 다시 찾는
|
||
# 것이므로 같은 임계값을 쓴다) — DIFF_THRESH·CLOSING_KERNEL·MIN_COMPONENT_AREA.
|
||
DIFF_THRESH = 25.0
|
||
CLOSING_KERNEL = (7, 11)
|
||
MIN_COMPONENT_AREA = 30.0
|
||
|
||
HAIR_DILATE_ITER = 1 # 머리카락(hairFront 알파) 마스크 팽창(오케스트레이터 판정값)
|
||
HAIR_ALPHA_THRESH = 128.0 # layers/v2/hairFront.png 알파가 이보다 크면 "머리카락이 덮는다"로 본다(오케스트레이터 판정값)
|
||
|
||
RAMP_PX = 1.5 # region(잉크, 머리카락 제외) 경계 알파 페더(px)
|
||
# 2차 보정: 눈썹 해칭은 획 자체가 가늘다(P7 실측: region 안 경계거리변환(distance_transform_edt)
|
||
# 평균 2.28px, dist>=4px인 픽셀 14~17%뿐) — 기존 4px 페더로는 L_ink를 아무리 낮춰도(=어둡게
|
||
# 잡아도) 거의 모든 획이 페더 중간값에 걸려 옅게 그려졌다(T1 목표 미달의 실제 원인). 1.5px로
|
||
# 줄이면 블러·형태학 닫기 없이(결 보존) 경계 anti-alias만 남기고 목표(알파>0 영역 평균절대차
|
||
# <=10)를 만족한다(P7 실측: 좌 7.83 우 4.99, RAMP_PX=4일 때는 좌 42.75 우 41.77이었다).
|
||
MIN_ALPHA_FOR_UNMIX = 0.05 # a<0.05면 피부색 역산(unmix) 대신 잉크색을 쓴다(0 나눗셈 폭주 방지)
|
||
|
||
# --- 눈꺼풀 선 굵기(lineScale) 상수 ------------------------------------------
|
||
UPPER_LID_WINDOW_ABOVE_PX = 12.0 # 측정 창 위쪽 = upperLidTop.y - 이 값
|
||
UPPER_LID_DARK_LUM_THRESH = 80.0
|
||
UPPER_LID_COLUMN_MIDDLE_FRAC = 0.6 # 눈 폭 가운데 60%(양옆 20%씩 제외)
|
||
LINE_SCALE_MIN = 1.0
|
||
LINE_SCALE_MAX = 2.5
|
||
|
||
# P1 기준값(윗눈꺼풀 잉크 두께, px) — measure_upper_lid_thickness를 P1
|
||
# manifest.landmarks.eyeLeft/eyeRight + base/base-front.png에 적용한 값이다
|
||
# (둘 다 median=2.000px, 표본 열 57/58개 — 실측 근거, 2026-10-01). P1은
|
||
# "다시 빌드하지 않는다"는 결정문 지시로 고정돼 있어 매번 다시 재는 대신
|
||
# 상수로 고정한다(패킷 지시: "P1 값은... 상수로 고정하되 근거를 주석으로 단다").
|
||
P1_UPPER_LID_THICKNESS_PX = 2.0
|
||
|
||
# --- 홍채 실측(2차 보정 T3) 상수 ---------------------------------------------
|
||
# mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로 학습돼, 판화 잉크로
|
||
# 그려진 홍채(원화 실측)보다 반지름을 크게 잡을 수 있다(P7 실측: 가로 반지름
|
||
# 기준 리그 대비 약 11~15% 작다, 2026-10-01). lineScale·browSprite와 무관하게
|
||
# 항상 측정해 manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 항상 기록"
|
||
# 원칙) — export_rig.py가 persona.json irisFromArt(기본 true)일 때만 이 값을 쓴다.
|
||
IRIS_DARK_LUM_THRESH = 100.0 # 홍채 잉크 실측 휘도(28~40대) vs 흰자(150 이상) 중간, 여유를 둔 임계값
|
||
IRIS_SCAN_BAND_PX = 4 # 중심 둘레 ±이 값(px)의 행/열을 스캔한다
|
||
|
||
|
||
# --- 눈썹 띠(band) 기하 -------------------------------------------------------
|
||
|
||
|
||
def _quad_point(p0: tuple[float, float], c: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]:
|
||
mt = 1.0 - t
|
||
return (mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p2[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p2[1])
|
||
|
||
|
||
def _quad_control(p0: tuple[float, float], mid: tuple[float, float], p2: tuple[float, float]) -> tuple[float, float]:
|
||
return (2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1]))
|
||
|
||
|
||
def _normalize(dx: float, dy: float) -> tuple[float, float]:
|
||
length = math.hypot(dx, dy) or 1.0
|
||
return dx / length, dy / length
|
||
|
||
|
||
def _seg_normal(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
|
||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||
length = math.hypot(dx, dy) or 1.0
|
||
return -dy / length, dx / length
|
||
|
||
|
||
def brow_band_polygon(
|
||
inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float],
|
||
half_height: float, extend_px: float, samples: int = 40,
|
||
) -> list[tuple[float, float]]:
|
||
"""눈썹 중심선(inner→peak→outer 2차 베지어, linocutGeometry.ts buildBrow의
|
||
곡선과 같은 방식)을 따라 반높이 half_height인 리본 폴리곤을 만든다(곡선 표본 +
|
||
법선 오프셋, linocutGeometry.ts polylineRibbon과 같은 기법). 양끝은 접선
|
||
방향으로 extend_px만큼 늘린다(inner·outer 바깥 연장)."""
|
||
control = _quad_control(inner, peak, outer)
|
||
pts = [_quad_point(inner, control, outer, i / (samples - 1)) for i in range(samples)]
|
||
t0 = _normalize(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1])
|
||
t1 = _normalize(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1])
|
||
ext_inner = (pts[0][0] + t0[0] * extend_px, pts[0][1] + t0[1] * extend_px)
|
||
ext_outer = (pts[-1][0] + t1[0] * extend_px, pts[-1][1] + t1[1] * extend_px)
|
||
pts = [ext_inner] + pts + [ext_outer]
|
||
|
||
n = len(pts)
|
||
normals = []
|
||
for i in range(n):
|
||
a = _seg_normal(pts[i - 1], pts[i]) if i > 0 else _seg_normal(pts[i], pts[i + 1])
|
||
b = _seg_normal(pts[i], pts[i + 1]) if i < n - 1 else _seg_normal(pts[i - 1], pts[i])
|
||
normals.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0))
|
||
|
||
top = [(p[0] + nx * half_height, p[1] + ny * half_height) for p, (nx, ny) in zip(pts, normals)]
|
||
bot = [(p[0] - nx * half_height, p[1] - ny * half_height) for p, (nx, ny) in zip(pts, normals)]
|
||
return top + list(reversed(bot))
|
||
|
||
|
||
def polygon_bool_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray:
|
||
img = Image.new("L", (w, h), 0)
|
||
ImageDraw.Draw(img).polygon(points, fill=255)
|
||
return np.array(img) > 127
|
||
|
||
|
||
def mask_bbox(mask: np.ndarray, pad: int, w: int, h: int) -> tuple[int, int, int, int]:
|
||
ys, xs = np.where(mask)
|
||
if len(xs) == 0:
|
||
raise SystemExit("[중단] 눈썹 띠 마스크가 비어 있다.")
|
||
x0 = max(0, int(xs.min()) - pad)
|
||
y0 = max(0, int(ys.min()) - pad)
|
||
x1 = min(w, int(xs.max()) + 1 + pad)
|
||
y1 = min(h, int(ys.max()) + 1 + pad)
|
||
return x0, y0, x1, y1
|
||
|
||
|
||
# --- 띠 안 잉크 연결성분 ------------------------------------------------------
|
||
|
||
|
||
def band_ink_mask(front_lum: np.ndarray, diff: np.ndarray, band_mask: np.ndarray, bbox: tuple[int, int, int, int]) -> tuple[np.ndarray, float]:
|
||
"""brow_centerline.build_ink_band와 같은 기준(Otsu(front 휘도) AND diff>=25,
|
||
형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취함)을, 축 정렬 bbox 대신 눈썹
|
||
띠 폴리곤(band_mask) 안으로 제한해 적용한다."""
|
||
x0, y0, x1, y1 = bbox
|
||
region_lum = front_lum[y0:y1, x0:x1]
|
||
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
|
||
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||
ink_mask = region_lum <= otsu_thresh
|
||
diff_mask = diff[y0:y1, x0:x1] >= DIFF_THRESH
|
||
band_region = band_mask[y0:y1, x0:x1]
|
||
combined = ink_mask & diff_mask & band_region
|
||
|
||
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
|
||
labeled, n = ndimage.label(closed)
|
||
if n == 0:
|
||
raise SystemExit("[중단] 눈썹 띠 안에 잉크 마스크가 전혀 없다.")
|
||
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
|
||
top_label = int(np.argmax(sizes)) + 1
|
||
band_ink_local = combined & (labeled == top_label)
|
||
|
||
lbl2, n2 = ndimage.label(band_ink_local)
|
||
if n2 > 0:
|
||
sizes2 = ndimage.sum(band_ink_local, lbl2, range(1, n2 + 1))
|
||
keep = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
|
||
band_ink_local = band_ink_local & np.isin(lbl2, keep)
|
||
|
||
full = np.zeros(front_lum.shape, dtype=bool)
|
||
full[y0:y1, x0:x1] = band_ink_local
|
||
return full, float(otsu_thresh)
|
||
|
||
|
||
# --- 눈썹 스프라이트 한 쪽 ----------------------------------------------------
|
||
|
||
|
||
def build_one_brow_sprite(
|
||
inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float],
|
||
half_width: tuple[float, float, float],
|
||
front_rgb: np.ndarray, faceless_rgb: np.ndarray, front_lum: np.ndarray, faceless_lum: np.ndarray,
|
||
diff: np.ndarray, hair_mask: np.ndarray, canvas_w: int, canvas_h: int, side_label: str,
|
||
) -> dict:
|
||
half_height = max(half_width) + BAND_EXTRA_HALF_HEIGHT_PX
|
||
band_points = brow_band_polygon(inner, peak, outer, half_height, BAND_END_EXTEND_PX)
|
||
band_mask = polygon_bool_mask(band_points, canvas_w, canvas_h)
|
||
bbox = mask_bbox(band_mask, BAND_BBOX_PAD_PX, canvas_w, canvas_h)
|
||
|
||
ink_mask_raw, otsu_thresh = band_ink_mask(front_lum, diff, band_mask, bbox)
|
||
hair_overlap_px = int((ink_mask_raw & hair_mask).sum())
|
||
region = ink_mask_raw & ~hair_mask
|
||
if not region.any():
|
||
raise SystemExit(f"[중단] {side_label} 눈썹 영역이 비었다(머리카락 제거 후 잉크 픽셀 0).")
|
||
|
||
band_lum_faceless = faceless_lum[band_mask]
|
||
l_skin = float(np.median(band_lum_faceless))
|
||
# 2차 보정: 띠 전체 휘도의 5백분위(극단적으로 어두운 꼬리값) 대신, 실제로 잉크로
|
||
# 분류된 픽셀(region, 머리카락 제거 후)의 휘도 중앙값을 쓴다 — 그보다 어두운
|
||
# 잉크 픽셀은 a=1이 된다(목표: 눈썹 알파>0 영역 평균절대차 <=10).
|
||
l_ink = float(np.median(front_lum[region]))
|
||
denom = (l_skin - l_ink) if abs(l_skin - l_ink) > 1e-6 else 1e-6
|
||
|
||
dist_in = ndimage.distance_transform_edt(region)
|
||
feather = np.clip(dist_in / RAMP_PX, 0.0, 1.0)
|
||
density = np.clip((l_skin - front_lum) / denom, 0.0, 1.0)
|
||
alpha_frac = np.where(region, density * feather, 0.0)
|
||
alpha = np.rint(alpha_frac * 255.0).astype(np.uint8)
|
||
|
||
skin_rgb = faceless_rgb[band_mask].mean(axis=0)
|
||
ink_sample_mask = band_mask & (front_lum <= l_ink)
|
||
ink_fallback_rgb = (
|
||
np.median(front_rgb[ink_sample_mask], axis=0) if ink_sample_mask.any() else np.array([0.0, 0.0, 0.0])
|
||
)
|
||
|
||
a3 = alpha_frac[..., None]
|
||
unmixed = (front_rgb - (1.0 - a3) * skin_rgb.reshape(1, 1, 3)) / np.maximum(a3, 1e-6)
|
||
unmixed = np.clip(unmixed, 0.0, 255.0)
|
||
rgb = np.where(a3 >= MIN_ALPHA_FOR_UNMIX, unmixed, ink_fallback_rgb.reshape(1, 1, 3))
|
||
|
||
print(
|
||
f" [{side_label}] 띠 반높이={half_height:.2f}px otsu={otsu_thresh:.1f} "
|
||
f"L_skin={l_skin:.2f} L_ink={l_ink:.2f} 영역픽셀={int(region.sum())} "
|
||
f"앞머리겹침픽셀={hair_overlap_px}"
|
||
)
|
||
|
||
return {
|
||
"alpha": alpha, "rgb": rgb, "region": region, "bandMask": band_mask, "bbox": bbox,
|
||
"halfHeight": half_height, "otsuThresh": otsu_thresh, "lSkin": l_skin, "lInk": l_ink,
|
||
"hairOverlapPx": hair_overlap_px, "regionPixelCount": int(region.sum()),
|
||
}
|
||
|
||
|
||
# --- 중립 합성 검사(기존 레이어 위에 스프라이트를 얹어 원화와 비교) ------------
|
||
|
||
|
||
def _load_rgba(path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||
arr = np.array(Image.open(path).convert("RGBA")).astype(np.float64)
|
||
return arr[..., :3], arr[..., 3]
|
||
|
||
|
||
def composite_with_sprites(layers_v2_dir: Path, canvas_w: int, canvas_h: int, sprites: dict[str, dict]) -> np.ndarray:
|
||
"""body→head→face-detail(이미 눈썹 자리를 faceless로 메워 둔 상태) 위에
|
||
눈썹 스프라이트를 얹고, 그 위에 hairFront를 덮는다 — LinocutAvatar.tsx의
|
||
그리는 순서(브로우는 face 그룹 안, hairFront보다 먼저)와 같다."""
|
||
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
|
||
canvas[..., 0] = CREAM_BG[0]
|
||
canvas[..., 1] = CREAM_BG[1]
|
||
canvas[..., 2] = CREAM_BG[2]
|
||
canvas[..., 3] = 255.0
|
||
for stem in ("body", "head", "face-detail"):
|
||
p = layers_v2_dir / f"{stem}.png"
|
||
if p.exists():
|
||
rgb, a = _load_rgba(p)
|
||
canvas = composite_over(canvas, to_u8(rgb), to_u8(a))
|
||
for data in sprites.values():
|
||
canvas = composite_over(canvas, to_u8(data["rgb"]), data["alpha"])
|
||
hf_path = layers_v2_dir / "hairFront.png"
|
||
if hf_path.exists():
|
||
hf_rgb, hf_a = _load_rgba(hf_path)
|
||
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
|
||
return canvas[..., :3]
|
||
|
||
|
||
def _save_evidence(front_rgb: np.ndarray, sprites: dict[str, dict], composite_rgb: np.ndarray, preview_v2_dir: Path) -> None:
|
||
"""옆 쪽마다 원화 | 중립 합성(스프라이트 포함) | 스프라이트(체커 배경) 3열,
|
||
3배 확대(lip_texture.py _save_evidence와 같은 방식)."""
|
||
rows = []
|
||
for side_label, data in sprites.items():
|
||
x0, y0, x1, y1 = data["bbox"]
|
||
pad = 10
|
||
bx0, by0 = max(0, x0 - pad), max(0, y0 - pad)
|
||
bx1, by1 = x1 + pad, y1 + pad
|
||
|
||
def zoom(arr: np.ndarray) -> Image.Image:
|
||
crop = arr[by0:by1, bx0:bx1]
|
||
im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8))
|
||
return im.resize((im.width * 3, im.height * 3), Image.LANCZOS)
|
||
|
||
panel_orig = zoom(front_rgb).convert("RGB")
|
||
panel_composite = zoom(composite_rgb).convert("RGB")
|
||
|
||
alpha = data["alpha"]
|
||
rgb = data["rgb"]
|
||
crop_rgb = rgb[by0:by1, bx0:bx1]
|
||
crop_a = (alpha[by0:by1, bx0:bx1].astype(np.float64) / 255.0)[..., None]
|
||
yy, xx = np.indices(crop_a.shape[:2])
|
||
checker = ((yy // 6 + xx // 6) % 2) * 60 + 180
|
||
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
|
||
sprite_view = crop_rgb * crop_a + checker3 * (1 - crop_a)
|
||
panel_sprite = Image.fromarray(np.clip(sprite_view, 0, 255).astype(np.uint8)).resize(
|
||
(panel_orig.width, panel_orig.height), Image.NEAREST
|
||
)
|
||
|
||
gap = 12
|
||
row = Image.new("RGB", (panel_orig.width * 3 + gap * 2, panel_orig.height), (255, 255, 255))
|
||
row.paste(panel_orig, (0, 0))
|
||
row.paste(panel_composite, (panel_orig.width + gap, 0))
|
||
row.paste(panel_sprite, (panel_orig.width * 2 + gap * 2, 0))
|
||
rows.append(row)
|
||
|
||
gap_y = 12
|
||
max_w = max(r.width for r in rows)
|
||
total_h = sum(r.height for r in rows) + gap_y * (len(rows) - 1)
|
||
combined = Image.new("RGB", (max_w, total_h), (255, 255, 255))
|
||
y = 0
|
||
for row in rows:
|
||
combined.paste(row, (0, y))
|
||
y += row.height + gap_y
|
||
scale = min(1.0, 1400 / max(combined.size))
|
||
if scale < 1.0:
|
||
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
|
||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||
out_path = preview_v2_dir / "brow-sprite.jpg"
|
||
combined.save(out_path, "JPEG", quality=92)
|
||
print(f"저장: {out_path}")
|
||
|
||
|
||
# --- 눈꺼풀 선 굵기(lineScale) -------------------------------------------------
|
||
|
||
|
||
def measure_upper_lid_thickness(eye: dict, front_lum: np.ndarray) -> tuple[float, int]:
|
||
"""눈 폭(innerCorner~outerCorner) 가운데 60% 열마다, upperLidTop.y-12에서
|
||
홍채 중심 y 사이의 첫 연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다."""
|
||
inner_x = eye["innerCorner"][0]
|
||
outer_x = eye["outerCorner"][0]
|
||
x_lo, x_hi = sorted([inner_x, outer_x])
|
||
width = x_hi - x_lo
|
||
margin = (1.0 - UPPER_LID_COLUMN_MIDDLE_FRAC) / 2.0 * width
|
||
col_lo = x_lo + margin
|
||
col_hi = x_hi - margin
|
||
y_top = eye["upperLidTop"][1] - UPPER_LID_WINDOW_ABOVE_PX
|
||
y_bottom = eye["iris"]["center"][1]
|
||
|
||
lengths: list[int] = []
|
||
for cx in range(int(round(col_lo)), int(round(col_hi)) + 1):
|
||
col = front_lum[int(round(y_top)):int(round(y_bottom)), cx]
|
||
dark = col < UPPER_LID_DARK_LUM_THRESH
|
||
idx = np.where(dark)[0]
|
||
if len(idx) == 0:
|
||
continue
|
||
start = int(idx[0])
|
||
length = 1
|
||
i = start
|
||
while i + 1 < len(dark) and dark[i + 1]:
|
||
length += 1
|
||
i += 1
|
||
lengths.append(length)
|
||
if not lengths:
|
||
return float("nan"), 0
|
||
return float(np.median(lengths)), len(lengths)
|
||
|
||
|
||
# --- 홍채 실측(T3) -------------------------------------------------------------
|
||
|
||
|
||
def _contiguous_dark_run(values: np.ndarray, center_idx: int, thresh: float) -> tuple[int, int] | None:
|
||
"""1차원 휘도 배열에서 center_idx를 포함하는 연속 어두운(값<thresh) 구간의
|
||
(시작, 끝) 인덱스. center_idx 자체가 어둡지 않으면 None."""
|
||
n = len(values)
|
||
if center_idx < 0 or center_idx >= n or values[center_idx] >= thresh:
|
||
return None
|
||
s = center_idx
|
||
while s - 1 >= 0 and values[s - 1] < thresh:
|
||
s -= 1
|
||
e = center_idx
|
||
while e + 1 < n and values[e + 1] < thresh:
|
||
e += 1
|
||
return s, e
|
||
|
||
|
||
def measure_iris(eye: dict, front_lum: np.ndarray, side_label: str) -> dict:
|
||
"""홍채(잉크 원판)의 실측 중심·반지름. 세로축은 윗눈꺼풀 잉크와 홍채가 같은
|
||
톤으로 이어져(판화 특성상 경계가 없다) 못 가르므로, 가로축(중심 y 부근 행마다
|
||
중심 x를 포함하는 연속 어두운 구간)만으로 반지름·중심 x를 재고, 중심 x 부근
|
||
열마다 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝(중앙값)에서 그 가로
|
||
반지름을 빼 중심 y를 역산한다(원이라고 가정)."""
|
||
cx, cy = eye["iris"]["center"]
|
||
cxi, cyi = int(round(cx)), int(round(cy))
|
||
|
||
lefts: list[int] = []
|
||
rights: list[int] = []
|
||
for dy in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1):
|
||
row = front_lum[cyi + dy, :]
|
||
seg = _contiguous_dark_run(row, cxi, IRIS_DARK_LUM_THRESH)
|
||
if seg is not None:
|
||
lefts.append(seg[0])
|
||
rights.append(seg[1])
|
||
if not lefts:
|
||
raise SystemExit(f"[중단] {side_label} 홍채 가로 실측 — 중심 부근 행에서 어두운 구간을 찾지 못했다.")
|
||
left = float(np.median(lefts))
|
||
right = float(np.median(rights))
|
||
radius = (right - left) / 2.0
|
||
measured_cx = (right + left) / 2.0
|
||
|
||
bottoms: list[int] = []
|
||
for dx in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1):
|
||
col = front_lum[:, cxi + dx]
|
||
seg = _contiguous_dark_run(col, cyi, IRIS_DARK_LUM_THRESH)
|
||
if seg is not None:
|
||
bottoms.append(seg[1])
|
||
if not bottoms:
|
||
raise SystemExit(f"[중단] {side_label} 홍채 세로 실측 — 중심 부근 열에서 어두운 구간을 찾지 못했다.")
|
||
bottom = float(np.median(bottoms))
|
||
measured_cy = bottom - radius
|
||
|
||
rig_radius = float(eye["iris"]["radius"])
|
||
print(
|
||
f" [{side_label}] 홍채 실측: center=({measured_cx:.1f},{measured_cy:.1f}) radius={radius:.2f} "
|
||
f"(리그 center=({cx:.1f},{cy:.1f}) radius={rig_radius:.2f}, 차이 radius={radius - rig_radius:+.2f}, "
|
||
f"표본 가로 {len(lefts)}행/세로 {len(bottoms)}열)"
|
||
)
|
||
return {
|
||
"measuredCenter": [round(measured_cx, 2), round(measured_cy, 2)],
|
||
"measuredRadius": round(radius, 2),
|
||
"rigCenter": [round(cx, 2), round(cy, 2)],
|
||
"rigRadius": round(rig_radius, 2),
|
||
"nRowsHorizontal": len(lefts),
|
||
"nColsVertical": len(bottoms),
|
||
}
|
||
|
||
|
||
def main(persona_dir: Path) -> int:
|
||
cfg = load_persona_config(persona_dir)
|
||
base_dir = cfg.base_dir
|
||
layers_v2_dir = cfg.layers_v2_dir
|
||
preview_v2_dir = cfg.preview_v2_dir
|
||
manifest_path = cfg.manifest_path
|
||
|
||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||
lm = manifest["landmarks"]
|
||
canvas_w = manifest["canvas"]["w"]
|
||
canvas_h = manifest["canvas"]["h"]
|
||
|
||
front_rgb = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||
faceless_rgb = np.array(build_padded_faceless(base_dir)).astype(np.float64)
|
||
front_lum = front_rgb.mean(axis=2)
|
||
faceless_lum = faceless_rgb.mean(axis=2)
|
||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||
|
||
browsprite_report: dict | None = None
|
||
if cfg.brow_sprite_from_art:
|
||
print("=== 눈썹 스프라이트 ===")
|
||
bc = manifest.get("browCenterline")
|
||
if bc is None:
|
||
raise SystemExit("[중단] manifest.browCenterline이 없다 — brow_centerline.py를 먼저 실행하라.")
|
||
half_width_by_side = {"left": bc["halfWidthLeft"], "right": bc["halfWidthRight"]}
|
||
eyebrow_by_side = {"left": lm["eyebrowLeft"], "right": lm["eyebrowRight"]}
|
||
|
||
hairfront_path = layers_v2_dir / "hairFront.png"
|
||
if not hairfront_path.exists():
|
||
raise SystemExit(f"[중단] {hairfront_path}가 없다 — layers_v2.py를 먼저 실행하라.")
|
||
hairfront_alpha = np.array(Image.open(hairfront_path).convert("RGBA"))[..., 3].astype(np.float64)
|
||
if hairfront_alpha.shape != (canvas_h, canvas_w):
|
||
raise SystemExit(
|
||
f"[중단] layers/v2/hairFront.png 크기({hairfront_alpha.shape[1]}x{hairfront_alpha.shape[0]})가 "
|
||
f"캔버스({canvas_w}x{canvas_h})와 다르다."
|
||
)
|
||
hair_mask = ndimage.binary_dilation(hairfront_alpha > HAIR_ALPHA_THRESH, iterations=HAIR_DILATE_ITER)
|
||
print(f"머리카락 마스크(hairFront 알파>{HAIR_ALPHA_THRESH}, {HAIR_DILATE_ITER}px 팽창) 픽셀 = {int(hair_mask.sum())}")
|
||
|
||
sprites: dict[str, dict] = {}
|
||
for side in ("left", "right"):
|
||
eb = eyebrow_by_side[side]
|
||
sprites[side] = build_one_brow_sprite(
|
||
tuple(eb["inner"]), tuple(eb["peak"]), tuple(eb["outer"]),
|
||
tuple(half_width_by_side[side]),
|
||
front_rgb, faceless_rgb, front_lum, faceless_lum, diff, hair_mask,
|
||
canvas_w, canvas_h, side,
|
||
)
|
||
|
||
composite_rgb = composite_with_sprites(layers_v2_dir, canvas_w, canvas_h, sprites)
|
||
diffs: dict[str, float] = {}
|
||
for side, data in sprites.items():
|
||
d = np.abs(composite_rgb - front_rgb).mean(axis=2)
|
||
# 2차 보정: 띠 전체가 아니라 스프라이트 알파>0 영역(region)만 잰다 — 알파=0인
|
||
# 띠 가장자리 피부 픽셀(원래도 거의 차이가 없다)이 섞여 들어가 잉크 알파가
|
||
# 옅은 문제를 가리던 것을 바로잡는다(목표 <=10).
|
||
diffs[side] = float(d[data["region"]].mean())
|
||
print(f" 검사 [{side}] 중립 합성(스프라이트 포함) vs 원화, 눈썹 알파>0 영역 평균절대차 = {diffs[side]:.3f}")
|
||
|
||
_save_evidence(front_rgb, sprites, composite_rgb, preview_v2_dir)
|
||
|
||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||
side_reports: dict[str, dict] = {}
|
||
canvas_origin: dict[str, list[float]] = {}
|
||
for side, data in sprites.items():
|
||
png_arr, bbox = crop_to_alpha_bbox(data["alpha"], to_u8(data["rgb"]))
|
||
out_path = layers_v2_dir / f"brow-{side}.png"
|
||
Image.fromarray(png_arr, "RGBA").save(out_path)
|
||
file_size = out_path.stat().st_size
|
||
canvas_origin[side] = [float(bbox[0]), float(bbox[1])]
|
||
print(f"저장: {out_path} bbox(캔버스 절대좌표)={bbox} size={png_arr.shape[1]}x{png_arr.shape[0]} ({file_size} bytes)")
|
||
side_reports[side] = {
|
||
"bboxCanvas": list(bbox),
|
||
"halfHeightPx": round(data["halfHeight"], 3),
|
||
"otsuThresh": data["otsuThresh"],
|
||
"lSkin": round(data["lSkin"], 3),
|
||
"lInk": round(data["lInk"], 3),
|
||
"regionPixelCount": data["regionPixelCount"],
|
||
"hairOverlapPixelCount": data["hairOverlapPx"],
|
||
"neutralCompositeMeanAbsDiffAlphaRegion": round(diffs[side], 3),
|
||
"pngFileSizeBytes": file_size,
|
||
}
|
||
|
||
browsprite_report = {
|
||
"enabled": True,
|
||
"bandExtraHalfHeightPx": BAND_EXTRA_HALF_HEIGHT_PX,
|
||
"bandEndExtendPx": BAND_END_EXTEND_PX,
|
||
"diffThresh": DIFF_THRESH,
|
||
"closingKernel": list(CLOSING_KERNEL),
|
||
"minComponentAreaPx": MIN_COMPONENT_AREA,
|
||
"hairSource": "layers/v2/hairFront.png alpha(게시되는 레이어 그 자체, 분할 카테고리 아님)",
|
||
"hairAlphaThresh": HAIR_ALPHA_THRESH,
|
||
"hairDilateIterations": HAIR_DILATE_ITER,
|
||
"rampPx": RAMP_PX,
|
||
"inkLumMethod": "median(ink-classified pixels in band, region after hair removal)",
|
||
"minAlphaForUnmix": MIN_ALPHA_FOR_UNMIX,
|
||
"hairMaskPixelCount": int(hair_mask.sum()),
|
||
"left": side_reports["left"],
|
||
"right": side_reports["right"],
|
||
"canvasOrigin": canvas_origin,
|
||
"evidenceImage": "preview/v2/brow-sprite.jpg",
|
||
}
|
||
else:
|
||
print("browSpriteFromArt=false — 눈썹 스프라이트를 건너뛴다.")
|
||
browsprite_report = {"enabled": False}
|
||
|
||
eye_line_scale_report: dict | None = None
|
||
if cfg.eye_line_scale_from_art:
|
||
print("=== 눈꺼풀 선 굵기(lineScale) ===")
|
||
side_reports = {}
|
||
for side, key in (("left", "eyeLeft"), ("right", "eyeRight")):
|
||
eye = lm[key]
|
||
measured_px, n_cols = measure_upper_lid_thickness(eye, front_lum)
|
||
if n_cols == 0:
|
||
raise SystemExit(f"[중단] {key} 윗눈꺼풀 두께 측정 열이 하나도 없다(모든 열에서 lum<80 구간을 못 찾음).")
|
||
ratio = measured_px / P1_UPPER_LID_THICKNESS_PX
|
||
line_scale = float(np.clip(ratio, LINE_SCALE_MIN, LINE_SCALE_MAX))
|
||
print(
|
||
f" [{key}] 측정={measured_px:.3f}px(열 {n_cols}개) / P1기준={P1_UPPER_LID_THICKNESS_PX}px "
|
||
f"= 비율 {ratio:.3f} -> lineScale={line_scale:.3f}"
|
||
+ (" [clamp 적용됨]" if not (LINE_SCALE_MIN <= ratio <= LINE_SCALE_MAX) else "")
|
||
)
|
||
side_reports[side] = {
|
||
"measuredPx": round(measured_px, 3), "nColumns": n_cols,
|
||
"ratio": round(ratio, 3), "lineScale": round(line_scale, 3),
|
||
}
|
||
eye_line_scale_report = {
|
||
"enabled": True,
|
||
"method": "눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채중심y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값",
|
||
"windowAbovePx": UPPER_LID_WINDOW_ABOVE_PX,
|
||
"darkLumThresh": UPPER_LID_DARK_LUM_THRESH,
|
||
"columnMiddleFrac": UPPER_LID_COLUMN_MIDDLE_FRAC,
|
||
"p1BaselinePx": P1_UPPER_LID_THICKNESS_PX,
|
||
"lineScaleClampRange": [LINE_SCALE_MIN, LINE_SCALE_MAX],
|
||
"left": side_reports["left"],
|
||
"right": side_reports["right"],
|
||
}
|
||
else:
|
||
print("eyeLineScaleFromArt=false — 눈꺼풀 lineScale을 건너뛴다.")
|
||
eye_line_scale_report = {"enabled": False}
|
||
|
||
# 홍채 실측(T3)은 browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상 재
|
||
# manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 설정과 무관하게 항상
|
||
# 기록" 원칙). export_rig.py가 persona.json irisFromArt(기본 true)일 때만 쓴다.
|
||
print("=== 홍채 실측(T3) ===")
|
||
iris_report = {
|
||
"method": (
|
||
"가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 "
|
||
"중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 "
|
||
"어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 "
|
||
"홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다)."
|
||
),
|
||
"darkLumThresh": IRIS_DARK_LUM_THRESH,
|
||
"scanBandPx": IRIS_SCAN_BAND_PX,
|
||
"left": measure_iris(lm["eyeLeft"], front_lum, "eyeLeft"),
|
||
"right": measure_iris(lm["eyeRight"], front_lum, "eyeRight"),
|
||
}
|
||
|
||
manifest["browSprite"] = browsprite_report
|
||
manifest["eyeLineScale"] = eye_line_scale_report
|
||
manifest["iris"] = iris_report
|
||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
print(f"manifest.json 갱신(browSprite, eyeLineScale, iris): {manifest_path}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(Path(sys.argv[1])))
|