리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측

- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
Yun Chan 2026-10-01 22:38:27 +09:00
parent 9a22d1647b
commit 98577cf4ae
18 changed files with 2524 additions and 85 deletions

View file

@ -40,6 +40,7 @@ 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 # noqa: E402 — 새 페르소나 첫 실행에서도 segmentation 단계 전에 패딩본을 만든다
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
BBOX_X_PAD = 25.0
@ -52,6 +53,8 @@ MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
HALFWIDTH_WINDOW_PX = 6.0 # 반두께를 잴 때 각 점 주변 이 폭(열, px) 안의 열을 모아 중앙값을 낸다
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
PEAK_FALLBACK_FRAC = 0.35
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
@ -126,6 +129,23 @@ def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int,
}
def measure_half_width(cw: dict, fit: np.poly1d, x: float, window_px: float = HALFWIDTH_WINDOW_PX) -> float:
"""x 주변 window_px 안 열들의 band 세로 폭(col_yhi-col_ylo) 중앙값을, 그 지점의
중심선 접선 기울기로 중심선 법선 방향 반두께로 환산한다(세로로 자른 폭은 띠가
기울어진 만큼 실제보다 넓게 잡힌다 — cos(기울기각)을 곱해 보정)."""
xs_abs = cw["xs_local"] + cw["x0"]
in_window = (xs_abs >= x - window_px) & (xs_abs <= x + window_px)
ylo = cw["col_ylo"][in_window]
yhi = cw["col_yhi"][in_window]
valid = ~np.isnan(ylo) & ~np.isnan(yhi)
if not valid.any():
return float("nan")
vertical_extent = float(np.median(yhi[valid] - ylo[valid]))
slope = float(fit.deriv()(x))
theta = np.arctan(slope)
return 0.5 * vertical_extent * float(np.cos(theta))
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
coef = np.polyfit(xs_abs, ys, 2)
fit = np.poly1d(coef)
@ -220,6 +240,10 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
n_ok += 1
frac_ok = n_ok / n_total if n_total else 0.0
half_width_inner = measure_half_width(cw, fit, float(inner_x))
half_width_peak = measure_half_width(cw, fit, float(peak_pt_raw[0]))
half_width_outer = measure_half_width(cw, fit, float(outer_x))
return {
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
"colRange": [col_min, col_max], "width": width,
@ -230,6 +254,9 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
"peakFail": bool(peak_fail),
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
"halfWidthInner": round(half_width_inner, 3),
"halfWidthPeak": round(half_width_peak, 3),
"halfWidthOuter": round(half_width_outer, 3),
"band": band, "bandX0": x0, "bandY0": y0,
"fit": fit, "colMin": col_min, "colMax": col_max,
}
@ -284,7 +311,7 @@ def main(persona_dir: Path) -> int:
lm = manifest["landmarks"]
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
faceless = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
faceless = np.array(build_padded_faceless(base_dir).convert("RGB")).astype(np.float64)
front_lum = front.mean(axis=2)
faceless_lum = faceless.mean(axis=2)
diff = np.clip(faceless_lum - front_lum, 0.0, None)
@ -311,6 +338,7 @@ def main(persona_dir: Path) -> int:
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
print(f" 반두께(inner/peak/outer) = {result['halfWidthInner']}/{result['halfWidthPeak']}/{result['halfWidthOuter']}")
dropL = resultL["inner"][1] - resultL["peak"][1]
dropR = resultR["inner"][1] - resultR["peak"][1]
@ -361,6 +389,10 @@ def main(persona_dir: Path) -> int:
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
"halfWidthLeft": [resultL["halfWidthInner"], resultL["halfWidthPeak"], resultL["halfWidthOuter"]],
"halfWidthRight": [resultR["halfWidthInner"], resultR["halfWidthPeak"], resultR["halfWidthOuter"]],
"halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 "
"persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.",
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
"evidenceImage": "preview/v2/brow-centerline.jpg",

View file

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

View file

@ -59,6 +59,15 @@ LIP_SPECS = [
]
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 그늘 조각 분리로 3장
# --- 눈썹 스프라이트(결정문 §8.4.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기") --
BROW_SPECS = [
# (rig key, 파일 stem, public href stem)
("left", "brow-left", "brow-left"),
("right", "brow-right", "brow-right"),
]
BROW_SIZE_BUDGET_BYTES = 60 * 1024
# --- 턱 조각(결정문 §8.4 하관 띠 변형) ---------------------------------------
JAW_SPECS = [
@ -307,6 +316,123 @@ def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path)
return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report
def publish_brow_sprite(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
"""layers/v2/brow-{left,right}.png(brow_sprite.py 산출)를 알파 bbox로 잘라
WebP로 게시한다(결정문 §8.4.1). 소스가 없으면 (None, None) —
browSpriteFromArt=false인 리그(P1)는 RigBrow.sprite 없이 벡터 눈썹을 그대로
쓴다(렌더러 대체 경로).
PNG는 이미 알파 bbox로 꽉 차게 잘려 있어(brow_sprite.py crop_to_alpha_bbox)
crop_to_bbox는 사실상 no-op이고, 캔버스 위치는 manifest["browSprite"]
["canvasOrigin"]에서 읽는다(publish_lip_texture와 같은 패턴). 인코딩은 입술
스프라이트와 같은 무손실(exact) 우선 — 알파=0 영역도 원화 RGB를 그대로
담고 있으므로(brow_sprite.py의 피부색 역산·잉크색 대체) exact=True로
버려지지 않게 한다."""
canvas_origin = manifest.get("browSprite", {}).get("canvasOrigin")
if canvas_origin is None:
return None, None
sources: dict[str, tuple[Path, str]] = {}
for key, stem, href_stem in BROW_SPECS:
p = layers_v2_dir / f"{stem}.png"
if not p.exists():
return None, None
sources[key] = (p, href_stem)
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
for key, (path, href_stem) in sources.items():
im = Image.open(path)
cropped_im, local_bbox = crop_to_bbox(im)
lx0, ly0 = canvas_origin[key]
abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3])
cropped[key] = (cropped_im, abs_bbox, href_stem)
BROW_DIFF_TARGET = 2.0
def encode_all(quality: int) -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, (im, _bbox, href_stem) in cropped.items():
out[href_stem] = encode_webp(im.convert("RGBA"), quality)
return out
def encode_all_lossless() -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, (im, _bbox, href_stem) in cropped.items():
buf = io.BytesIO()
im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True)
out[href_stem] = buf.getvalue()
return out
def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]:
diffs: dict[str, float] = {}
for key, (im, _bbox, href_stem) in cropped.items():
src_arr = np.array(im.convert("RGBA")).astype(np.float64)
dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64)
diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3)
return diffs
lossless_encoded = encode_all_lossless()
lossless_total = sum(len(b) for b in lossless_encoded.values())
if lossless_total <= BROW_SIZE_BUDGET_BYTES:
chosen, chosen_quality = lossless_encoded, "lossless"
else:
candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = []
chosen, chosen_quality = None, None
for q in (100, 99, 98, 97, 96, 95):
encoded = encode_all(q)
total = sum(len(b) for b in encoded.values())
diffs = webp_vs_png_diff(encoded)
candidates.append((q, encoded, total, diffs))
if total <= BROW_SIZE_BUDGET_BYTES and max(diffs.values()) <= BROW_DIFF_TARGET:
chosen, chosen_quality = encoded, q
break
if chosen is None:
within_budget = [c for c in candidates if c[2] <= BROW_SIZE_BUDGET_BYTES]
pool = within_budget if within_budget else candidates
best = min(pool, key=lambda c: max(c[3].values()))
chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3]
print(
f" [경고] quality 95~100 중 예산({BROW_SIZE_BUDGET_BYTES}B) AND 평균차<={BROW_DIFF_TARGET} "
f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택"
)
assert chosen is not None and chosen_quality is not None
public_dir = cfg.public_dir
public_dir.mkdir(parents=True, exist_ok=True)
file_sizes: dict[str, int] = {}
for href_stem, data in chosen.items():
out_path = public_dir / f"{href_stem}.webp"
out_path.write_bytes(data)
file_sizes[href_stem] = len(data)
total_bytes = sum(file_sizes.values())
print(f"눈썹 스프라이트 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)")
for href_stem, size in file_sizes.items():
print(f" {href_stem}.webp = {size} bytes")
print(
f"눈썹 스프라이트 합계 = {total_bytes} bytes (예산 {BROW_SIZE_BUDGET_BYTES} bytes) "
f"{'OK' if total_bytes <= BROW_SIZE_BUDGET_BYTES else '[초과]'}"
)
webp_vs_png = webp_vs_png_diff(chosen)
for href_stem in file_sizes:
key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem)
print(f" 검사 [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}")
brow_field = {}
for key, (_im, bbox, href_stem) in cropped.items():
x0, y0, w, h = bbox
brow_field[key] = {
"href": f"{cfg.public_href_prefix}/{href_stem}.webp",
"x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1),
}
publish_report = {
"quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes,
"budgetBytes": BROW_SIZE_BUDGET_BYTES,
"webpVsPngMeanAbsDiff": webp_vs_png,
}
return brow_field, publish_report
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형) ----------------------------------
@ -764,29 +890,49 @@ def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]:
# --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) -----------------
def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict:
def build_landmarks(
cfg: PersonaConfig, manifest: dict, mouth_center: tuple[float, float], brow_sprite_field: dict | None
) -> dict:
lm = manifest["landmarks"]
half_widths = manifest.get("browCenterline", {}) if cfg.brow_half_width_from_art else {}
eye_line_scale = manifest.get("eyeLineScale", {}) if cfg.eye_line_scale_from_art else {}
iris_measured = manifest.get("iris", {}) if cfg.iris_from_art else {}
def pt(p) -> list[float]:
return [round(p[0], 1), round(p[1], 1)]
def eye(e) -> dict:
return {
def eye(e, line_scale_key: str) -> dict:
iris_m = iris_measured.get(line_scale_key)
if iris_m is not None:
iris_field = {"center": pt(iris_m["measuredCenter"]), "radius": round(float(iris_m["measuredRadius"]), 1)}
else:
iris_field = {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)}
d = {
"inner": pt(e["innerCorner"]),
"outer": pt(e["outerCorner"]),
"upperLidTop": pt(e["upperLidTop"]),
"lowerLidBottom": pt(e["lowerLidBottom"]),
"iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)},
"iris": iris_field,
}
line_scale = eye_line_scale.get(line_scale_key, {}).get("lineScale")
if line_scale is not None:
d["lineScale"] = round(float(line_scale), 3)
return d
def brow(b) -> dict:
return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
def brow(b, half_width_key: str, sprite_key: str) -> dict:
d = {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
hw = half_widths.get(half_width_key)
if hw is not None:
d["halfWidth"] = [round(float(v), 2) for v in hw]
if brow_sprite_field is not None and brow_sprite_field.get(sprite_key) is not None:
d["sprite"] = brow_sprite_field[sprite_key]
return d
return {
"eyeLeft": eye(lm["eyeLeft"]),
"eyeRight": eye(lm["eyeRight"]),
"browLeft": brow(lm["eyebrowLeft"]),
"browRight": brow(lm["eyebrowRight"]),
"eyeLeft": eye(lm["eyeLeft"], "left"),
"eyeRight": eye(lm["eyeRight"], "right"),
"browLeft": brow(lm["eyebrowLeft"], "halfWidthLeft", "left"),
"browRight": brow(lm["eyebrowRight"], "halfWidthRight", "right"),
"noseTip": pt(lm["noseTip"]),
"mouthCornerLeft": pt(lm["mouthCornerLeft"]),
"mouthCornerRight": pt(lm["mouthCornerRight"]),
@ -937,11 +1083,22 @@ def render_rig_ts(cfg: PersonaConfig, rig: dict) -> str:
lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},')
lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},')
lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},')
if e.get("lineScale") is not None:
lines.append(f' lineScale: {ts_num(e["lineScale"])},')
lines.append(" },")
def emit_brow(name, b):
hw = b.get("halfWidth")
hw_str = f", halfWidth: [{ts_num(hw[0])}, {ts_num(hw[1])}, {ts_num(hw[2])}]" if hw else ""
sprite = b.get("sprite")
sprite_str = ""
if sprite:
sprite_str = (
f', sprite: {{ href: "{sprite["href"]}", x: {ts_num(sprite["x"])}, y: {ts_num(sprite["y"])}, '
f'w: {ts_num(sprite["w"])}, h: {ts_num(sprite["h"])} }}'
)
lines.append(
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},'
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])}{hw_str}{sprite_str} }},'
)
emit_eye("eyeLeft", lmk["eyeLeft"])
@ -1001,7 +1158,12 @@ def main(persona_dir: Path) -> int:
for k, v in palette.items():
print(f" {k} = {v}")
landmarks = build_landmarks(manifest, mouth_center)
print("=== 눈썹 스프라이트 게시 ===")
brow_sprite_field, brow_sprite_publish_report = publish_brow_sprite(cfg, manifest, layers_v2_dir)
if brow_sprite_field is None:
print(" brow-left.png/brow-right.png 없음(생략) — 벡터 눈썹을 그대로 쓰는 리그")
landmarks = build_landmarks(cfg, manifest, mouth_center, brow_sprite_field)
face_crop = compute_face_crop(face_oval)
print(f"crops.face = {[round(v,1) for v in face_crop]}")
@ -1083,6 +1245,7 @@ def main(persona_dir: Path) -> int:
"lipTexturePublish": lip_publish_report,
"jawPublish": jaw_publish_report,
"motifPublish": motif_publish_report,
"browSpritePublish": brow_sprite_publish_report,
"rig": rig,
}
report_path = cfg.preview_v2_dir / "export-rig-report.json"
@ -1097,8 +1260,14 @@ def main(persona_dir: Path) -> int:
if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report:
manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"]
manifest["motifPublish"] = motif_publish_report
manifest["browSpritePublish"] = brow_sprite_publish_report
if brow_sprite_publish_report is not None and "webpVsPngMeanAbsDiff" in brow_sprite_publish_report:
manifest.setdefault("browSprite", {})["checkWebpVsPngMeanAbsDiff"] = brow_sprite_publish_report["webpVsPngMeanAbsDiff"]
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {manifest_path.relative_to(cfg.repo_root)}")
print(
f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish, browSpritePublish): "
f"{manifest_path.relative_to(cfg.repo_root)}"
)
return 0

View file

@ -39,6 +39,7 @@ SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from segmentation import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over, CREAM_BG # noqa: E402
from persona_config import load_persona_config # noqa: E402
from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402 — region="face"용 얼굴 오벌(캐스트 확장)
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
REGION_FEATHER_PX = 12.0
@ -99,6 +100,9 @@ CHECKE_THRESH = 1.5
GENERIC_PATCH_SIZE = 48
GENERIC_PATCH_MARGIN = 40
# --- matchFacelessColor(faceDetail.matchFacelessColor) 상수 -----------------
MATCH_RING_LUM_THRESH = 110.0 # 고리 표본에서 이보다 어두운(잉크) front 픽셀은 뺀다
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
@ -108,6 +112,26 @@ def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, fe
return np.clip(alpha, 0, 255)
def face_oval_region_alpha(
base_front_path: Path, inset_px: float, w: int, h: int, feather: float = REGION_FEATHER_PX
) -> tuple[np.ndarray, np.ndarray, int, int]:
"""base-front.png 랜드마크로 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은
36점 루프·1.04 배율)을 구해 inset_px만큼 안쪽으로 줄인 뒤 feather px로 페더링한다.
원화 윤곽 잉크선은 오벌 경계 근방(jaw_pieces.py OVAL_OUTSET_PX=18px 안팎)에 있으므로,
inset_px를 충분히 크게 잡아야 이 잉크선이 반환 영역(알파>0) 밖에 남는다 — 호출자가
persona.json faceDetail.faceInsetPx로 값을 정하고 근거를 보고해야 한다.
반환: (알파, 원본 오벌 불리언 마스크, 오벌 픽셀 수, inset 후 픽셀 수)."""
points = detect_face_landmarks(base_front_path)
oval_pts = compute_face_oval(points)
oval_img = Image.new("L", (w, h), 0)
ImageDraw.Draw(oval_img).polygon(oval_pts, fill=255)
oval_mask = np.array(oval_img) > 127
dist_in = distance_transform_edt(oval_mask)
inset_mask = oval_mask & (dist_in >= inset_px)
alpha = np.clip(gaussian_filter(inset_mask.astype(np.float64) * 255.0, sigma=feather / 2.0), 0, 255)
return alpha, oval_mask, int(oval_mask.sum()), int(inset_mask.sum())
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(img)
@ -453,7 +477,18 @@ def main(persona_dir: Path) -> int:
mcx, mcy = mole["center"]
r = float(mole["radius"])
a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX))
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
if cfg.face_detail_region == "face":
a_face, oval_mask, oval_px, inset_px_count = face_oval_region_alpha(
base_dir / "base-front.png", cfg.face_inset_px, w, h
)
region_alpha = np.maximum(a_face, a_mole)
print(
f"faceDetail.region=face: faceInsetPx={cfg.face_inset_px} "
f"오벌픽셀={oval_px} inset후픽셀={inset_px_count}"
)
else:
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
# ------------------------------------------------------------------
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
@ -521,7 +556,20 @@ def main(persona_dir: Path) -> int:
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
# ------------------------------------------------------------------
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
hair_mask_raw = category_mask == 1
if cfg.face_detail_region == "face":
# region="face"는 얼굴 피부 전체에 원화(front) 픽셀을 노출하므로, front와
# faceless의 앞머리 가닥 위치가 달라(codex 편집본 시차) faceless 분할 하나만
# 쓰면 front 쪽 잔머리가 머리카락 마스크 밖으로 나와 유령 가닥이 생긴다 —
# 두 그림의 분할 합집합으로 덮는다.
front_category_mask = run_segmentation(base_dir / "base-front.png")
hair_mask_front_raw = front_category_mask == 1
hair_mask_raw = hair_mask_raw | hair_mask_front_raw
print(
f"머리카락 분할(합집합): faceless={int((category_mask == 1).sum())}px "
f"front={int(hair_mask_front_raw.sum())}px union={int(hair_mask_raw.sum())}px"
)
hair_mask = binary_dilation(hair_mask_raw, iterations=HAIR_DILATE_PX)
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
# ------------------------------------------------------------------
@ -535,6 +583,31 @@ def main(persona_dir: Path) -> int:
for r in stamp_report:
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
# ------------------------------------------------------------------
# matchFacelessColor: 제외 영역(눈·눈썹·입) 안의 F 색을 주변 원화 피부에 맞춘다.
# 영역마다 RING_PX 바깥 고리(어두운 잉크 제외)에서 front·F 평균 RGB 차를 구해
# 그 영역 전체의 F를 그 차이만큼 평행이동한다 — 보간·블러·패치 합성은 하지 않는다.
# ------------------------------------------------------------------
match_color_report: dict[str, list[float]] = {}
if cfg.match_faceless_color:
print("=== matchFacelessColor: 영역별 ΔRGB(front - F, 안쪽 고리 표본) ===")
deltas: dict[str, np.ndarray] = {}
for name, region_mask in named_excl.items():
ring = binary_dilation(region_mask, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
sample_mask = ring & (lum_front >= MATCH_RING_LUM_THRESH)
if not sample_mask.any():
deltas[name] = np.zeros(3)
print(f" {name}: 고리 표본 없음 — 보정 생략")
continue
front_mean = front[sample_mask].mean(axis=0)
f_mean = f_clean[sample_mask].mean(axis=0)
deltas[name] = front_mean - f_mean
d = deltas[name]
print(f" {name}: ΔRGB=({d[0]:+.2f},{d[1]:+.2f},{d[2]:+.2f}) 표본={int(sample_mask.sum())}px")
for name, region_mask in named_excl.items():
f_clean[region_mask] = np.clip(f_clean[region_mask] + deltas[name], 0, 255)
match_color_report = {name: [round(float(c), 2) for c in d] for name, d in deltas.items()}
# ------------------------------------------------------------------
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
@ -714,6 +787,10 @@ def main(persona_dir: Path) -> int:
manifest.setdefault("layersV2", {})
manifest["layersV2"]["faceDetail"] = {
"browLandmarksSource": brow_source,
"region": cfg.face_detail_region,
"faceInsetPx": cfg.face_inset_px if cfg.face_detail_region == "face" else None,
"matchFacelessColor": cfg.match_faceless_color,
"matchFacelessColorDeltaRgb": match_color_report,
"regions": {
"eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)],
"eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)],

View file

@ -0,0 +1,249 @@
"""공통 리노컷 리그 — 캐스트 확장 0단계: 원화 3장을 캔버스로 정규화.
입력 front/faceless/body는 모두 같은 좌표계(후보 원화) 위에 있다고 전제한다
(T1 register_edit.py가 faceless·body를 원화 좌표계로 이미 정렬해 둔 출력을
그대로 받는다). 셋 모두에 **같은 아핀 변환 하나**(등방 배율 + 평행이동,
회전 없음)를 적용해 캔버스 1005x1566(결정문 §8.4)으로 옮긴다.
기준은 P1 base-front.png의 랜드마크다(눈 중심 두 점의 중점, 두 눈 사이 거리,
턱끝) — 머리 크기·위치가 P1과 같은 비율이 되게 새 원화의 배율·이동을 定한다.
mediapipe FaceLandmarker 인덱스: 눈A=(33,133) 중점, 눈B=(263,362) 중점, 턱=152
(landmarks.py의 EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
배율: s_head = (P1 눈-턱 거리/원화 눈-턱 거리 + P1 눈 사이 거리/원화 눈 사이
거리) / 2. s = max(s_head, 캔버스폭/원화폭) — 원화가 머리 비율상 더 작게
배율돼도 캔버스 폭은 항상 덮어야 한다.
이동: 두 눈 중점을 P1 두 눈 중점에 맞춘다. 가로 오프셋은 배율된 원화가 캔버스
폭을 꼭 덮도록 [캔버스폭 − 원화폭·s, 0] 구간으로 자른다(좌우 빈틈 방지).
세로 오프셋은 자르지 않는다 — 위/아래로 남는 빈 곳은 각 이미지 종류에 맞게
채운다(front·faceless: 원본 가장자리 띠 반사 패딩, body: #00ff00).
사용: normalize_base.py <persona-dir> --front <원화> --faceless <정렬된 faceless> --body <정렬된 body>
"""
from __future__ import annotations
import argparse
import hashlib
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
SCRIPTS_DIR = Path(__file__).resolve().parent
CANVAS_W = 1005
CANVAS_H = 1566
TOP_PAPER_BAND_PX = 8 # 위쪽 채움 색을 뽑을 원화 맨 위 행 수(머리카락은 14행부터 나온다)
P1_REFERENCE_RELATIVE = Path("docs/avatar-art/p1-linocut/base/base-front.png")
# mediapipe FaceLandmarker 인덱스(landmarks.py EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
EYE_A_IDX = (33, 133)
EYE_B_IDX = (263, 362)
CHIN_IDX = 152
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
"""FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유)."""
with tempfile.TemporaryDirectory() as td:
out_json = Path(td) / "landmarks.json"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_json.exists():
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
data = json.loads(out_json.read_text(encoding="utf-8"))
if not data.get("ok"):
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
return [(p[0], p[1]) for p in data["points"]]
def midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
def dist(a: tuple[float, float], b: tuple[float, float]) -> float:
return float(np.hypot(a[0] - b[0], a[1] - b[1]))
def measure_head(points: list[tuple[float, float]]) -> dict:
eye_a = midpoint(points[EYE_A_IDX[0]], points[EYE_A_IDX[1]])
eye_b = midpoint(points[EYE_B_IDX[0]], points[EYE_B_IDX[1]])
eyes_center = midpoint(eye_a, eye_b)
chin = points[CHIN_IDX]
return {
"eyeA": eye_a, "eyeB": eye_b, "eyesCenter": eyes_center, "chin": chin,
"eyeDistance": dist(eye_a, eye_b),
"eyeChinDistance": dist(eyes_center, chin),
}
def measure_report(head: dict) -> dict:
return {
"eyesCenter": [round(head["eyesCenter"][0], 2), round(head["eyesCenter"][1], 2)],
"chin": [round(head["chin"][0], 2), round(head["chin"][1], 2)],
"eyeDistance": round(head["eyeDistance"], 3),
"eyeChinDistance": round(head["eyeChinDistance"], 3),
}
def clamp(v: float, lo: float, hi: float) -> float:
return max(lo, min(hi, v))
def sha256_of(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="원화 3장(front/faceless/body)을 캔버스 1005x1566으로 정규화한다.")
parser.add_argument("persona_dir", help="페르소나 폴더(예: docs/avatar-art/p2-linocut)")
parser.add_argument("--front", required=True, help="후보 원화(정면 기본형)")
parser.add_argument("--faceless", required=True, help="원화 좌표계로 정렬된 얼굴 없는 기본형(T1 출력)")
parser.add_argument("--body", required=True, help="원화 좌표계로 정렬된 body 편집본(T1 출력)")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
persona_dir = Path(args.persona_dir).resolve()
front_path = Path(args.front).resolve()
faceless_path = Path(args.faceless).resolve()
body_path = Path(args.body).resolve()
# persona_dir 위치와 무관하게 이 스크립트 파일 기준으로 repo root를 고정한다
# (persona.json이 아직 없을 수 있는 단계라 persona_config.py의 persona_dir 기준
# repo_root 계산에 의존하지 않는다): scripts/ -> linocut-pipeline -> avatar-art -> docs -> repo root.
repo_root = SCRIPTS_DIR.parents[3]
ref_path = repo_root / P1_REFERENCE_RELATIVE
if not ref_path.exists():
raise SystemExit(f"[중단] P1 기준 이미지가 없다: {ref_path}")
print(f"=== 기준(P1) 랜드마크: {ref_path} ===")
ref_head = measure_head(detect_face_landmarks(ref_path))
print(
f" eyesCenter={ref_head['eyesCenter']} eyeDistance={ref_head['eyeDistance']:.2f} "
f"eyeChinDistance={ref_head['eyeChinDistance']:.2f}"
)
print(f"=== 입력 원화 랜드마크: {front_path} ===")
front_head = measure_head(detect_face_landmarks(front_path))
print(
f" eyesCenter={front_head['eyesCenter']} eyeDistance={front_head['eyeDistance']:.2f} "
f"eyeChinDistance={front_head['eyeChinDistance']:.2f}"
)
front_img = Image.open(front_path).convert("RGB")
front_w, front_h = front_img.size
s_head = (
ref_head["eyeChinDistance"] / front_head["eyeChinDistance"]
+ ref_head["eyeDistance"] / front_head["eyeDistance"]
) / 2.0
s_width = CANVAS_W / front_w
s = max(s_head, s_width)
print(f"s_head={s_head:.5f} s_width(캔버스폭/원화폭)={s_width:.5f} -> s={s:.5f}")
offset_x_natural = ref_head["eyesCenter"][0] - front_head["eyesCenter"][0] * s
offset_x_lo, offset_x_hi = CANVAS_W - front_w * s, 0.0
offset_x = clamp(offset_x_natural, offset_x_lo, offset_x_hi)
offset_y = ref_head["eyesCenter"][1] - front_head["eyesCenter"][1] * s
print(f"offset_x natural={offset_x_natural:.3f} clamp=[{offset_x_lo:.3f}, {offset_x_hi:.3f}] -> {offset_x:.3f}")
print(f"offset_y={offset_y:.3f} (클램프 없음)")
covered_x0 = clamp(offset_x, 0.0, CANVAS_W)
covered_x1 = clamp(offset_x + front_w * s, 0.0, CANVAS_W)
covered_y0 = clamp(offset_y, 0.0, CANVAS_H)
covered_y1 = clamp(offset_y + front_h * s, 0.0, CANVAS_H)
print(
f"덮인 범위(반사/채움 아닌 원화 픽셀): x=[{covered_x0:.1f}, {covered_x1:.1f}] "
f"y=[{covered_y0:.1f}, {covered_y1:.1f}] (캔버스 {CANVAS_W}x{CANVAS_H})"
)
matrix = np.array([[s, 0.0, offset_x], [0.0, s, offset_y]], dtype=np.float64)
def warp(src_path: Path, border_mode: int, border_value: tuple[int, int, int] | None) -> np.ndarray:
arr = np.array(Image.open(src_path).convert("RGB"))
kwargs: dict = {"flags": cv2.INTER_LANCZOS4, "borderMode": border_mode}
if border_value is not None:
kwargs["borderValue"] = border_value
return cv2.warpAffine(arr, matrix, (CANVAS_W, CANVAS_H), **kwargs)
# front·faceless: 빈 곳은 원본 가장자리 띠 반사(BORDER_REFLECT_101 — 가장자리 픽셀을
# 복제하지 않고 그 안쪽부터 비추어, 복제판(T3 build_padded_faceless류)보다 이음매가
# 덜 드러난다; 위쪽은 아래에서 종이색으로 덮는다). body: 빈 곳은 크로마키 배경(#00ff00)으로 채운다.
front_out = warp(front_path, cv2.BORDER_REFLECT_101, None)
faceless_out = warp(faceless_path, cv2.BORDER_REFLECT_101, None)
body_out = warp(body_path, cv2.BORDER_CONSTANT, (0, 255, 0))
# 위쪽 빈 곳만은 반사하지 않고 원화 위 가장자리의 종이색으로 채운다. 머리카락이 원화 위
# 끝 가까이(14~42px)까지 오는 후보는 반사하면 머리 위에 뒤집힌 머리카락이 생긴다
# (2026-10-01 P3·P5 판정). 배경은 분할에서 레이어 밖으로 빠지므로 평평한 색이어도 화면에
# 드러나지 않는다. 아래쪽은 옷이 이어지는 편이 나아 반사를 유지한다.
top_rows = int(np.ceil(covered_y0))
if top_rows > 0:
for out, src_path in ((front_out, front_path), (faceless_out, faceless_path)):
band = np.array(Image.open(src_path).convert("RGB"))[:TOP_PAPER_BAND_PX].reshape(-1, 3)
paper = band[band.mean(axis=1) > 150]
out[:top_rows] = np.median(paper if len(paper) else band, axis=0).astype(np.uint8)
base_dir = persona_dir / "base"
raw_dir = persona_dir / "raw"
base_dir.mkdir(parents=True, exist_ok=True)
raw_dir.mkdir(parents=True, exist_ok=True)
front_out_path = base_dir / "base-front.png"
faceless_out_path = base_dir / "base-faceless.png"
body_out_path = raw_dir / "body.png"
Image.fromarray(front_out, "RGB").save(front_out_path)
Image.fromarray(faceless_out, "RGB").save(faceless_out_path)
Image.fromarray(body_out, "RGB").save(body_out_path)
print(f"저장: {front_out_path}")
print(f"저장: {faceless_out_path}")
print(f"저장: {body_out_path}")
report = {
"canvas": {"w": CANVAS_W, "h": CANVAS_H},
"reference": {"image": str(ref_path.relative_to(repo_root)), **measure_report(ref_head)},
"input": {
"front": str(front_path),
"faceless": str(faceless_path),
"body": str(body_path),
"frontSize": [front_w, front_h],
**measure_report(front_head),
},
"transform": {
"sHead": round(s_head, 6),
"sWidth": round(s_width, 6),
"s": round(s, 6),
"rotationDeg": 0.0,
"offsetXNatural": round(offset_x_natural, 3),
"offsetXClampRange": [round(offset_x_lo, 3), round(offset_x_hi, 3)],
"offsetX": round(offset_x, 3),
"offsetY": round(offset_y, 3),
},
"coveredRange": {
"x": [round(covered_x0, 1), round(covered_x1, 1)],
"y": [round(covered_y0, 1), round(covered_y1, 1)],
},
"inputSha256": {
"front": sha256_of(front_path),
"faceless": sha256_of(faceless_path),
"body": sha256_of(body_path),
},
}
report_path = base_dir / "normalize.json"
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {report_path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -37,6 +37,7 @@ DEFAULT_BACKDROP = {
"energy": "#E2E0D0",
}
DEFAULT_BUST_Y_OFFSET = 40.0
DEFAULT_FACE_INSET_PX = 8.0
@dataclass
@ -70,6 +71,32 @@ class PersonaConfig:
# 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각
# inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마.
brow_landmarks_override: dict | None
# false면 export_rig.py가 RigBrow.halfWidth를 쓰지 않는다(렌더러가 linocutGeometry.ts
# BROW_STOPS 고정값을 쓴다). 기본 true — 원화에서 잰 눈썹 반두께(manifest.browCenterline.
# halfWidth{Left,Right})를 쓴다. P1은 이미 고정 굵기로 빌드·검수됐으므로 false로 둔다.
brow_half_width_from_art: bool
# false면 brow_sprite.py가 눈썹 스프라이트(layers/v2/brow-{left,right}.png)를
# 만들지 않는다(결정문 §8.4.1). 기본 true. P1은 이미 고정 벡터 눈썹으로
# 빌드·검수됐으므로 false로 둔다.
brow_sprite_from_art: bool
# false면 brow_sprite.py가 눈꺼풀 선 굵기 배율(RigEye.lineScale)을 재지 않는다
# (결정문 §8.4.1). 기본 true. P1은 lineScale의 기준값 자체(P1_UPPER_LID_THICKNESS_PX)라
# false로 둔다.
eye_line_scale_from_art: bool
# false면 export_rig.py가 RigEye.iris에 manifest.landmarks(mediapipe)의 값을 그대로
# 쓴다. 기본 true — brow_sprite.py가 원화에서 실측한 홍채(manifest.iris)를 쓴다
# (2차 보정 T3: mediapipe iris 랜드마크는 실제 눈 비례로 학습돼 판화 잉크 홍채보다
# 크게 잡는 경향이 있다). P1은 이미 고정 벡터 렌더로 빌드·검수됐으므로 false로 둔다.
iris_from_art: bool
# faceDetail.region: "features"(기본, 눈·입 주변 타원만) | "face"(얼굴 오벌 전체 —
# 캐스트 확장, 얼굴 피부 해칭 보존). face_detail.py만 쓴다.
face_detail_region: str
# region="face"일 때 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px) — 원화 윤곽 잉크선이
# 이 영역에 들어오지 않게 하는 여유. region="features"면 쓰이지 않는다.
face_inset_px: float
# true면 face_detail.py가 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의
# 색을 주변 원화 피부에 맞춰 평행이동한다(보간/블러 없음). 기본 false(P1 그대로).
match_faceless_color: bool
def require_pivots(self) -> dict:
if self.pivots is None:
@ -183,6 +210,18 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig:
face_detail = data.get("faceDetail", {})
moles = face_detail.get("moles", [])
brow_landmarks_override = face_detail.get("browLandmarksOverride")
brow_half_width_from_art = data.get("browHalfWidthFromArt", True)
brow_sprite_from_art = data.get("browSpriteFromArt", True)
eye_line_scale_from_art = data.get("eyeLineScaleFromArt", True)
iris_from_art = data.get("irisFromArt", True)
face_detail_region = face_detail.get("region", "features")
if face_detail_region not in ("features", "face"):
raise SystemExit(
f"[중단] {persona_dir.name}/persona.json의 faceDetail.region={face_detail_region!r}는 "
"지원하지 않는다 — \"features\" 또는 \"face\"만 쓴다."
)
face_inset_px = float(face_detail.get("faceInsetPx", DEFAULT_FACE_INSET_PX))
match_faceless_color = bool(face_detail.get("matchFacelessColor", False))
pivots = data.get("pivots")
crops = data.get("crops", {})
@ -211,4 +250,11 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig:
palette_fixed=palette_fixed,
backdrop=backdrop,
brow_landmarks_override=brow_landmarks_override,
brow_half_width_from_art=bool(brow_half_width_from_art),
brow_sprite_from_art=bool(brow_sprite_from_art),
eye_line_scale_from_art=bool(eye_line_scale_from_art),
iris_from_art=bool(iris_from_art),
face_detail_region=face_detail_region,
face_inset_px=face_inset_px,
match_faceless_color=match_faceless_color,
)

View file

@ -0,0 +1,327 @@
"""공통 리노컷 리그 — 캐스트 확장 0단계: codex 편집본을 원화 좌표계로 정렬.
ref는 편집의 참조가 된 원본(후보 원화, normalize_base.py 이전 — 아직 캔버스로
옮기지 않은 원래 크기)이고, edited는 그 ref를 참조해 codex가 편집한 결과다
(얼굴 없는 기본형 또는 body 재생성). 크기가 다를 수 있다.
정렬: edited를 ref 크기로 리사이즈한 뒤, cv2.findTransformECC(MOTION_AFFINE)로
회색조·가우시안 흐림(gaussFiltSize) 위에서 ref 좌표계에 정렬한다. ECC가 보는
영역은 모드별 마스크로 제한한다(전체 이미지를 다 보면 얼굴 생김새 차이·크로마키
배경이 정렬을 왜곡한다):
- faceless: 머리카락·옷·얼굴 윤곽처럼 두 이미지에서 모양이 같아야 하는 곳만
본다 — 랜드마크(ref 기준)로 잡은 눈·눈썹·입 상자를 25px 넓혀 뺀 뒤, 평평한
배경(지역 표준편차가 작은 곳)도 뺀다.
- body: 턱끝 +20px 아래 행 중 edited(리사이즈본)의 비초록(크로마키 전경) 픽셀만
본다 — 그 위는 얼굴이라 body 재생성과 무관하고, 초록 배경은 정렬 정보가 없다.
출력은 ref 크기로 맞춘 정렬된 편집본 1장이다. ECC로 못 채우는 테두리는
faceless면 테두리 복제(BORDER_REPLICATE), body면 크로마키 배경(#00ff00)이다.
사용: register_edit.py <ref.png> <edited.png> <out.png> --mode faceless|body [--report <json>]
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
import tempfile
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from scipy.ndimage import distance_transform_edt
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from segmentation import chroma_key # noqa: E402 — 크로마키 전경/배경 판정 재사용(같은 임계치로 일관성 유지)
# --- mediapipe FaceLandmarker 인덱스(landmarks.py/segmentation.py와 같다) ---
RIGHT_EYEBROW_IDX = [46, 53, 52, 65, 55, 70, 63, 105, 66, 107]
LEFT_EYEBROW_IDX = [276, 283, 282, 295, 285, 300, 293, 334, 296, 336]
EYE_A_IDX = {"outer": 33, "inner": 133, "top": 159, "bottom": 145}
EYE_B_IDX = {"inner": 362, "outer": 263, "top": 386, "bottom": 374}
MOUTH_CORNER_A_IDX = 61
MOUTH_CORNER_B_IDX = 291
UPPER_LIP_TOP_IDX = 0
LOWER_LIP_BOTTOM_IDX = 17
CHIN_IDX = 152
FACE_OVAL_LOOP = [
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127,
162, 21, 54, 103, 67, 109,
]
FACE_OVAL_SCALE = 1.04
# --- ECC ---
ECC_EXCLUDE_PAD_PX = 25.0 # 눈·눈썹·입 상자를 이만큼 넓혀 ECC 마스크에서 뺀다
ECC_CHIN_MARGIN_PX = 20.0 # body: 턱끝 + 이만큼 아래부터 본다
FLAT_BG_WINDOW = 9 # 평평한 배경 판정용 지역 표준편차 창(px)
FLAT_BG_STD_THRESH = 6.0 # 이 미만이면 "평평"(정렬 정보 없음)으로 보고 뺀다
GREEN_ALPHA_THRESH = 127 # chroma_key 알파 임계치(이상이면 비초록 전경)
ECC_MIN_MASK_PX = 400 # 이보다 적으면 ECC가 의미 있게 수렴하지 않는다
ECC_GAUSS_FILT_SIZE = 7 # findTransformECC 내부 가우시안 흐림 창(홀수)
ECC_MAX_ITER = 5000
ECC_EPS = 1e-7
# --- 지표 ---
CANNY_BLUR_SIGMA = 1.0
CANNY_LOW, CANNY_HIGH = 40, 120
FACE_OVAL_BAND_PX = 12.0 # 얼굴 윤곽 띠 폭(±px)
INK_LUM_THRESH = 70.0 # "어두운 잉크" 휘도 임계치
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
"""FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유)."""
with tempfile.TemporaryDirectory() as td:
out_json = Path(td) / "landmarks.json"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_json.exists():
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
data = json.loads(out_json.read_text(encoding="utf-8"))
if not data.get("ok"):
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
return [(p[0], p[1]) for p in data["points"]]
def bbox_of(points: list[tuple[float, float]], idxs: list[int]) -> tuple[float, float, float, float]:
xs = [points[i][0] for i in idxs]
ys = [points[i][1] for i in idxs]
return (min(xs), min(ys), max(xs), max(ys))
def expand_box(box: tuple[float, float, float, float], pad: float, w: int, h: int) -> tuple[int, int, int, int]:
x0, y0, x1, y1 = box
return (
max(0, int(np.floor(x0 - pad))), max(0, int(np.floor(y0 - pad))),
min(w, int(np.ceil(x1 + pad))), min(h, int(np.ceil(y1 + pad))),
)
def build_face_boxes(points: list[tuple[float, float]]) -> dict[str, tuple[float, float, float, float]]:
"""눈(좌/우)·눈썹(좌/우)·입 상자(원화 좌표, 확장 전)."""
mouth_idx = [MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX, UPPER_LIP_TOP_IDX, LOWER_LIP_BOTTOM_IDX]
return {
"eyeA": bbox_of(points, list(EYE_A_IDX.values())),
"eyeB": bbox_of(points, list(EYE_B_IDX.values())),
"browA": bbox_of(points, RIGHT_EYEBROW_IDX),
"browB": bbox_of(points, LEFT_EYEBROW_IDX),
"mouth": bbox_of(points, mouth_idx),
}
def flat_background_mask(gray_u8: np.ndarray) -> np.ndarray:
"""지역(FLAT_BG_WINDOW) 표준편차가 FLAT_BG_STD_THRESH 미만인 곳 — 평평한 배경(정렬
정보가 없는 영역)으로 보고 ECC 포함 마스크에서 뺀다."""
g = gray_u8.astype(np.float32)
mean = cv2.boxFilter(g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW))
sqmean = cv2.boxFilter(g * g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW))
std = np.sqrt(np.clip(sqmean - mean * mean, 0.0, None))
return std < FLAT_BG_STD_THRESH
def face_oval_band_mask(points: list[tuple[float, float]], w: int, h: int, band_px: float) -> np.ndarray:
"""랜드마크 face oval(결정문 §8.2, segmentation.py/export_rig.py와 같은 루프·배율)
경계선에서 band_px 이내인 픽셀."""
from PIL import ImageDraw
loop_pts = [points[i] for i in FACE_OVAL_LOOP]
cx = float(np.mean([p[0] for p in loop_pts]))
cy = float(np.mean([p[1] for p in loop_pts]))
scaled = [((x - cx) * FACE_OVAL_SCALE + cx, (y - cy) * FACE_OVAL_SCALE + cy) for x, y in loop_pts]
outline_img = Image.new("L", (w, h), 0)
ImageDraw.Draw(outline_img).polygon(scaled, outline=255, width=1)
boundary = np.array(outline_img) > 0
dist_to_boundary = distance_transform_edt(~boundary)
return dist_to_boundary <= band_px
def chamfer_stats(ref_edges: np.ndarray, aligned_edges: np.ndarray, region_mask: np.ndarray) -> dict:
"""aligned_edges 중 region_mask 안에 있는 에지 픽셀들이 ref_edges의 가장 가까운
에지까지 떨어진 거리(챔퍼)의 중앙값·p90. ref_edges 쪽 거리장은 호출자가 한 번
계산해 재사용하도록 이미 distance_transform_edt를 적용한 배열을 받는다는 점에
주의 — 이 함수는 edge 불boolean 배열 두 개를 받아 내부에서 거리장을 계산한다."""
dist_to_ref_edge = distance_transform_edt(~ref_edges)
sample_mask = aligned_edges & region_mask
n = int(sample_mask.sum())
if n == 0:
return {"median": None, "p90": None, "nSamples": 0}
d = dist_to_ref_edge[sample_mask]
return {"median": round(float(np.median(d)), 3), "p90": round(float(np.percentile(d, 90)), 3), "nSamples": n}
def ink_ratio(gray_u8: np.ndarray, box: tuple[int, int, int, int]) -> float:
x0, y0, x1, y1 = box
region = gray_u8[y0:y1, x0:x1].astype(np.float64)
if region.size == 0:
return float("nan")
return float((region < INK_LUM_THRESH).mean())
def decompose_affine(warp_matrix: np.ndarray) -> dict:
a, b, tx = warp_matrix[0]
c, d, ty = warp_matrix[1]
det = a * d - b * c
scale = float(np.sqrt(abs(det)))
rotation_deg = float(np.degrees(np.arctan2(c, a)))
return {
"scaleSqrtDet": round(scale, 5),
"rotationDeg": round(rotation_deg, 3),
"translateX": round(float(tx), 3),
"translateY": round(float(ty), 3),
"matrix": [[round(float(v), 6) for v in row] for row in warp_matrix.tolist()],
"direction": "ref(template) 좌표 -> edited(resized, input) 좌표. 정렬된 출력은 이 행렬의 역변환(WARP_INVERSE_MAP)으로 만든다.",
}
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="codex 편집본을 ECC 아핀 정렬로 원화(ref) 좌표계에 맞춘다.")
parser.add_argument("ref", help="편집의 참조가 된 원본(원화)")
parser.add_argument("edited", help="codex 편집본(크기가 ref와 다를 수 있다)")
parser.add_argument("out", help="정렬된 출력(ref 크기)")
parser.add_argument("--mode", required=True, choices=["faceless", "body"])
parser.add_argument("--report", help="지표 JSON 저장 경로(생략 가능, 콘솔에는 항상 출력)")
return parser.parse_args(argv)
def main(argv: list[str]) -> int:
args = parse_args(argv)
ref_path = Path(args.ref).resolve()
edited_path = Path(args.edited).resolve()
out_path = Path(args.out).resolve()
mode = args.mode
ref_img = Image.open(ref_path).convert("RGB")
w, h = ref_img.size
ref_arr = np.array(ref_img)
ref_gray = cv2.cvtColor(ref_arr, cv2.COLOR_RGB2GRAY)
edited_img = Image.open(edited_path).convert("RGB")
ew, eh = edited_img.size
print(f"ref 크기={w}x{h} edited 원본 크기={ew}x{eh} (리사이즈 후 ref 크기로 정렬)")
edited_resized = np.array(edited_img.resize((w, h), Image.LANCZOS))
edited_gray = cv2.cvtColor(edited_resized, cv2.COLOR_RGB2GRAY)
print("=== ref 랜드마크 검출 ===")
ref_points = detect_face_landmarks(ref_path)
chin_xy = ref_points[CHIN_IDX]
print(f"턱끝(152)={chin_xy}")
boxes_report: dict[str, list[int]] = {}
if mode == "faceless":
boxes = build_face_boxes(ref_points)
exclude = np.zeros((h, w), dtype=bool)
for name, box in boxes.items():
x0, y0, x1, y1 = expand_box(box, ECC_EXCLUDE_PAD_PX, w, h)
boxes_report[name] = [x0, y0, x1, y1]
exclude[y0:y1, x0:x1] = True
flat_bg = flat_background_mask(ref_gray)
include = ~exclude & ~flat_bg
print(f"제외 상자(25px 확장): {boxes_report}")
print(f"평평한 배경 제외 픽셀: {int(flat_bg.sum())} ({flat_bg.mean()*100:.1f}%)")
else:
_, edited_alpha = chroma_key(edited_resized)
nongreen = edited_alpha > GREEN_ALPHA_THRESH
chin_line = chin_xy[1] + ECC_CHIN_MARGIN_PX
below_chin = np.zeros((h, w), dtype=bool)
below_chin[int(np.ceil(chin_line)):, :] = True
include = below_chin & nongreen
print(f"턱끝+{ECC_CHIN_MARGIN_PX:.0f}px={chin_line:.1f} 아래 비초록 픽셀: {int(include.sum())}")
n_include = int(include.sum())
print(f"ECC 포함 마스크 픽셀 수: {n_include} / {w * h} ({n_include / (w * h) * 100:.2f}%)")
if n_include < ECC_MIN_MASK_PX:
raise SystemExit(f"[중단] ECC 포함 마스크 픽셀이 너무 적다({n_include} < {ECC_MIN_MASK_PX}).")
include_u8 = (include.astype(np.uint8)) * 255
warp0 = np.eye(2, 3, dtype=np.float32)
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, ECC_MAX_ITER, ECC_EPS)
try:
cc, warp_matrix = cv2.findTransformECC(
ref_gray.astype(np.float32), edited_gray.astype(np.float32),
warp0, cv2.MOTION_AFFINE, criteria, include_u8, ECC_GAUSS_FILT_SIZE,
)
except cv2.error as exc:
raise SystemExit(f"[중단] ECC 정렬이 수렴하지 않았다: {exc}")
print(f"ECC 상관계수={cc:.5f}")
print(f"warp_matrix=\n{warp_matrix}")
transform_report = decompose_affine(warp_matrix)
print(
f"변환: 배율(sqrt det)={transform_report['scaleSqrtDet']} 회전={transform_report['rotationDeg']}deg "
f"이동=({transform_report['translateX']}, {transform_report['translateY']})"
)
border_mode = cv2.BORDER_REPLICATE if mode == "faceless" else cv2.BORDER_CONSTANT
border_value = None if mode == "faceless" else (0, 255, 0)
warp_kwargs: dict = {"flags": cv2.INTER_LANCZOS4 | cv2.WARP_INVERSE_MAP, "borderMode": border_mode}
if border_value is not None:
warp_kwargs["borderValue"] = border_value
aligned = cv2.warpAffine(edited_resized, warp_matrix, (w, h), **warp_kwargs)
out_path.parent.mkdir(parents=True, exist_ok=True)
Image.fromarray(aligned, "RGB").save(out_path)
print(f"저장: {out_path}")
aligned_gray = cv2.cvtColor(aligned, cv2.COLOR_RGB2GRAY)
ref_edges = cv2.Canny(cv2.GaussianBlur(ref_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0
aligned_edges = cv2.Canny(cv2.GaussianBlur(aligned_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0
mask_chamfer = chamfer_stats(ref_edges, aligned_edges, include)
print(
f"마스크 안 Canny 챔퍼: 중앙값={mask_chamfer['median']} p90={mask_chamfer['p90']} "
f"(n={mask_chamfer['nSamples']})"
+ (f" 기준(중앙값<=1.0,p90<=2.5) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.0 and mask_chamfer['p90'] <= 2.5 else '[미달]'}"
if mode == "faceless" else
f" 기준(중앙값<=1.5,p90<=3.0) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.5 and mask_chamfer['p90'] <= 3.0 else '[미달]'}")
)
report: dict = {
"mode": mode,
"ref": str(ref_path),
"edited": str(edited_path),
"out": str(out_path),
"refSize": [w, h],
"editedOriginalSize": [ew, eh],
"eccCorrelation": round(float(cc), 5),
"transform": transform_report,
"eccIncludeMaskPixels": n_include,
"chamferInMask": mask_chamfer,
}
if mode == "faceless":
oval_band = face_oval_band_mask(ref_points, w, h, FACE_OVAL_BAND_PX)
oval_chamfer = chamfer_stats(ref_edges, aligned_edges, oval_band)
print(
f"얼굴 윤곽 띠(±{FACE_OVAL_BAND_PX:.0f}px) Canny 챔퍼: 중앙값={oval_chamfer['median']} "
f"p90={oval_chamfer['p90']} (n={oval_chamfer['nSamples']}) 기준(p90<=2.0) "
f"{'OK' if oval_chamfer['p90'] is not None and oval_chamfer['p90'] <= 2.0 else '[미달]'}"
)
report["faceOvalBandChamfer"] = oval_chamfer
ink_report: dict[str, dict] = {}
for name, box in boxes_report.items():
ref_ratio = ink_ratio(ref_gray, tuple(box))
edited_ratio = ink_ratio(aligned_gray, tuple(box))
ink_report[name] = {"box": box, "refInkRatio": round(ref_ratio, 4), "editedInkRatio": round(edited_ratio, 4)}
print(f" 잉크 비율(lum<{INK_LUM_THRESH:.0f}) [{name}] ref={ref_ratio:.4f} edited={edited_ratio:.4f}")
report["inkRatioByBox"] = ink_report
report["eccExcludeBoxes"] = boxes_report
if args.report:
report_path = Path(args.report).resolve()
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {report_path}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -1,7 +1,7 @@
"""공통 리노컷 리그 파이프라인 — 단일 진입점.
페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에
대해 1~8단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
대해 1~11단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
(mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가
나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가
다른 단계·전체 파이프라인을 오염시키지 않는다).
@ -15,11 +15,12 @@ export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다):
3. segmentation — layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션
4. layers_v2 — layers/v2/{body,head,hairFront}.png(결함 보정)
5. face_detail — layers/v2/face-detail.png
6. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)
7. lip_texture — layers/v2/lip-{upper,lower,shadow}.png
8. jaw_pieces — layers/v2/jaw-{head,detail}.png
9. export_rig — WebP 게시 + rigs/<code>Rig.ts 생성
10. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기
6. brow_sprite — layers/v2/brow-{left,right}.png + manifest.eyeLineScale(결정문 §8.4.1)
7. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)
8. lip_texture — layers/v2/lip-{upper,lower,shadow}.png
9. jaw_pieces — layers/v2/jaw-{head,detail}.png
10. export_rig — WebP 게시 + rigs/<code>Rig.ts 생성
11. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기
주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로
새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만
@ -36,6 +37,7 @@ from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
import time
@ -52,6 +54,7 @@ STAGES: list[tuple[str, str]] = [
("segmentation", "segmentation.py"),
("layers_v2", "layers_v2.py"),
("face_detail", "face_detail.py"),
("brow_sprite", "brow_sprite.py"),
("paper_grain", "paper_grain.py"),
("lip_texture", "lip_texture.py"),
("jaw_pieces", "jaw_pieces.py"),
@ -64,9 +67,13 @@ STAGE_NAMES = [name for name, _ in STAGES]
def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, float]:
script_path = SCRIPTS_DIR / script
t0 = time.monotonic()
# 단계 스크립트 출력은 UTF-8로 주고받는다. Windows 콘솔 기본(cp949)은 em dash 같은 문자를
# 못 그려, 모티프 없는 리그 분기(export_rig) 같은 데서 단계가 UnicodeEncodeError로 죽었다
# (2026-10-01 P7). 부모가 콘솔에 옮겨 쓸 때 못 그리는 문자는 main()에서 대체 문자로 바꾼다.
proc = subprocess.run(
[sys.executable, "-u", str(script_path), str(persona_dir)],
capture_output=True, text=True,
capture_output=True, text=True, encoding="utf-8", errors="replace",
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
)
elapsed = time.monotonic() - t0
output = proc.stdout + proc.stderr
@ -75,6 +82,8 @@ def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, flo
def main() -> int:
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행")
parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)")
parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다")

View file

@ -112,8 +112,18 @@ def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
def build_padded_faceless(base_dir: Path) -> Image.Image:
bf = Image.open(base_dir / "base-faceless.png").convert("RGB")
front_h = Image.open(base_dir / "base-front.png").size[1]
arr = np.array(bf)
padded = np.concatenate([arr, arr[-1:, :, :]], axis=0)
deficit = front_h - arr.shape[0]
if deficit < 0:
raise SystemExit(
f"[중단] base-faceless.png 높이({arr.shape[0]})가 base-front.png 높이({front_h})보다 크다."
)
if deficit > 0:
pad_rows = np.repeat(arr[-1:, :, :], deficit, axis=0)
padded = np.concatenate([arr, pad_rows], axis=0)
else:
padded = arr
out = Image.fromarray(padded, "RGB")
out.save(base_dir / "base-faceless-padded.png")
return out
@ -233,10 +243,12 @@ def main(persona_dir: Path) -> int:
layers_dir.mkdir(parents=True, exist_ok=True)
preview_dir.mkdir(parents=True, exist_ok=True)
orig_faceless_h = Image.open(base_dir / "base-faceless.png").size[1]
f_img = build_padded_faceless(base_dir)
f_arr = np.array(f_img)
h, w, _ = f_arr.shape
print(f"F(패딩된 base-faceless) 크기: {w}x{h}")
pad_rows = h - orig_faceless_h
print(f"F(패딩된 base-faceless) 크기: {w}x{h} (원본 {w}x{orig_faceless_h}, 패딩 {pad_rows}행)")
front_pts = detect_face_landmarks(base_dir / "base-front.png")
chin_y = front_pts[152][1]
@ -442,8 +454,8 @@ def main(persona_dir: Path) -> int:
"front": "base/base-front.png",
"faceless": "base/base-faceless.png",
"facelessPadded": "base/base-faceless-padded.png",
"facelessSize": [w, h - 1],
"padNote": f"base-faceless.png 마지막 행을 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.",
"facelessSize": [w, orig_faceless_h],
"padNote": f"base-faceless.png 마지막 행을 {pad_rows}번 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.",
}
manifest["segmenter"] = {
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",