vignette/docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py
Yun Chan 98577cf4ae 리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
2026-10-01 22:38:27 +09:00

635 lines
32 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""공통 리노컷 리그 — 원화 눈썹 스프라이트 + 눈꺼풀 선 굵기 배율 생성(결정문 §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])))