vignette/docs/avatar-art/linocut-pipeline/scripts/face_detail.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

835 lines
44 KiB
Python
Raw Permalink 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.

"""공통 리노컷 리그 — faceDetail 레이어 생성(결정문 §8.2).
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
경계는 10px 페더로 base-front↔F를 섞는다.
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
진짜 결이므로 건드리지 않는다.
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
점(mole)처럼 원화에만 있고 랜드마크로 유도할 수 없는 반점은 persona.json의
faceDetail.moles(원점+반경 목록)에서 읽는다. 목록이 비면 점 영역을 그리지
않는다(모든 캐릭터가 점이 있는 것은 아니다).
전제: layers_v2.py가 이미 layers/v2/{body,head,hairFront}.png를 만들어 두어야
한다(정지 합성 검사에 쓴다).
실행: <venv>/python.exe face_detail.py <persona-dir>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import (
binary_closing, binary_dilation, binary_erosion, binary_fill_holes, binary_opening,
distance_transform_edt, gaussian_filter, label,
)
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
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
EYE_INK_MARGIN = 32.0
EYE_INK_THRESH = 150.0
EYE_CREASE_UP_PX = 18 # 쌍꺼풀 주름 커버(위로)
EYE_OUTER_EXT_PX = 12 # 바깥 꼬리 커버
EYE_FINAL_DILATE_PX = 6 # 위/바깥/눈 틈에 적용(아래쪽은 별도)
# 아랫눈꺼풀은 선 자체만 좁게 덮는다 — 다크서클 해칭(그 아래 살려야 할 그늘)을
# 먹지 않기 위해 위/바깥과는 다른(더 좁은) 팽창·페더를 쓴다.
EYE_LOWER_LINE_ABOVE_PX = 2.0
EYE_LOWER_LINE_BELOW_PX = 5.0
EYE_LOWER_DILATE_PX = 2
EYE_LOWER_FEATHER_PX = 4.0
EYE_INK_LOWER_CAP_PX = 7.0 # eye_dark_hole 연결요소가 다크서클 해칭까지 붙어 나오는 것을 자른다
DARK_CIRCLE_BAND_Y0 = 10.0 # 아랫눈꺼풀 아래 10~35px
DARK_CIRCLE_BAND_Y1 = 35.0
DARK_CIRCLE_BAND_HALF_W = 35.0 # 홍채 중심 x ±35px
DARK_CIRCLE_MATCH_TOLERANCE = 6.0
BROW_INK_MARGIN = 20.0
BROW_INK_THRESH = 150.0
BROW_STROKE_HALF_WIDTH = 14 # 랜드마크 곡선 ±14px 띠
BROW_FINAL_DILATE_PX = 10
MOUTH_INK_MARGIN = 15.0
MOUTH_INK_THRESH = 150.0
MOUTH_CORNER_EXT_PX = 12
MOUTH_SHADOW_EXT_PX = 24 # 아랫입술 아래 그늘선 커버
MOUTH_FINAL_DILATE_PX = 8
EXCLUSION_BOUNDARY_FEATHER_PX = 10.0
HAIR_DILATE_PX = 2
# --- 잔여 잉크(선 모양만) 복제 도장 상수 -------------------------------------
CLONE_TARGET_THRESH = 65.0
AVOID_THRESH = 110.0
LINE_OPENING_ITER = 3
CLONE_STAMP_FEATHER_PX = 4.0
CLONE_STAMP_CANDIDATES = [
(0, -40), (0, -60), (-40, 0), (40, 0), (0, 40), (0, 60),
(-60, 0), (60, 0), (0, -80), (0, 80), (-40, -40), (40, -40), (-40, 40), (40, 40),
(0, -100), (0, -120), (0, -150), (0, -180), (0, 100), (0, 120),
(-80, 0), (80, 0), (-100, 0), (100, 0), (-120, -60), (120, -60), (-60, -100), (60, -100),
]
# --- 검사 상수 ---------------------------------------------------------------
RING_PX = 12
HF_SIGMA = 2.0
LINE_SIGMA = 3.0
CHECKD_THRESH = 0.7
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)
d = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
alpha = (d <= 1.0).astype(np.float64) * 255.0
alpha = gaussian_filter(alpha, sigma=feather / 2.0)
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)
d.line([tuple(outer), tuple(peak), tuple(inner)], fill=255, width=width, joint="curve")
d.ellipse([outer[0] - width / 2, outer[1] - width / 2, outer[0] + width / 2, outer[1] + width / 2], fill=255)
d.ellipse([inner[0] - width / 2, inner[1] - width / 2, inner[0] + width / 2, inner[1] + width / 2], fill=255)
return np.array(img) > 127
def lens_polygon(inner, outer, upper, lower, pad: float, n: int = 24) -> list[tuple[float, float]]:
def quad_bezier(p0, p1, p2, n):
ts = np.linspace(0, 1, n)
pts = []
for t in ts:
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
pts.append((x, y))
return pts
upper_ctrl = (upper[0], upper[1] - pad)
lower_ctrl = (inner[0], lower[1] + pad)
upper_curve = quad_bezier(outer, upper_ctrl, inner, n)
lower_curve = quad_bezier(inner, lower_ctrl, outer, n)
return upper_curve + lower_curve
def nearest_component_label(labeled: np.ndarray, py: float, px: float) -> int:
"""(py,px) 위치의 라벨을 쓰되, 그 지점이 어떤 연결요소에도 안 속하면(랜드마크가
잉크 픽셀에서 살짝 벗어난 경우) 가장 가까운 연결요소의 라벨을 대신 쓴다."""
h, w = labeled.shape
iy, ix = int(round(py)), int(round(px))
iy = min(max(iy, 0), h - 1)
ix = min(max(ix, 0), w - 1)
seed_label = int(labeled[iy, ix])
if seed_label != 0:
return seed_label
if not (labeled != 0).any():
return 0
_, (near_y, near_x) = distance_transform_edt(labeled == 0, return_indices=True)
return int(labeled[near_y[iy, ix], near_x[iy, ix]])
def eye_dark_hole(
eye: dict, lum: np.ndarray, w: int, h: int, margin: float = EYE_INK_MARGIN,
thresh: float = EYE_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈 틈(공막·홍채·동공·아이라이너 잉크) 전체를 명도 임계값 + 연결요소로 찾는다."""
lens = lens_polygon(eye["innerCorner"], eye["outerCorner"], eye["upperLidTop"], eye["lowerLidBottom"], pad=6.0)
xs = [p[0] for p in lens]
ys = [p[1] for p in lens]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin, max(ys) + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
icx, icy = eye["iris"]["center"]
seed_label = nearest_component_label(labeled, icy, icx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def lower_lid_line_mask(
inner, outer, lower, w: int, h: int, above: float = EYE_LOWER_LINE_ABOVE_PX, below: float = EYE_LOWER_LINE_BELOW_PX
) -> np.ndarray:
"""아랫눈꺼풀 선(랜드마크 곡선) 자체만 위로 above px, 아래로 below px 두께로
감싸는 좁은 띠를 만든다 — 다크서클 해칭을 먹지 않기 위해 이 아래는 손대지 않는다."""
n = 40
ts = np.linspace(0, 1, n)
curve = [
((1 - t) ** 2 * outer[0] + 2 * (1 - t) * t * lower[0] + t ** 2 * inner[0],
(1 - t) ** 2 * outer[1] + 2 * (1 - t) * t * lower[1] + t ** 2 * inner[1])
for t in ts
]
upper_edge = [(x, y - above) for x, y in curve]
lower_edge = [(x, y + below) for x, y in curve]
poly = upper_edge + lower_edge[::-1]
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(poly, fill=255)
return np.array(img) > 127
def dark_circle_band_mask(eye: dict, w: int, h: int) -> np.ndarray:
"""아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35px 띠(다크서클 해칭 검사용)."""
icx = eye["iris"]["center"][0]
lower_y = eye["lowerLidBottom"][1]
y0, y1 = lower_y + DARK_CIRCLE_BAND_Y0, lower_y + DARK_CIRCLE_BAND_Y1
x0, x1 = icx - DARK_CIRCLE_BAND_HALF_W, icx + DARK_CIRCLE_BAND_HALF_W
m = np.zeros((h, w), dtype=bool)
yy0, yy1 = max(0, int(round(y0))), min(h, int(round(y1)))
xx0, xx1 = max(0, int(round(x0))), min(w, int(round(x1)))
m[yy0:yy1, xx0:xx1] = True
return m
def brow_dark_hole(
brow: dict, lum: np.ndarray, w: int, h: int, margin: float = BROW_INK_MARGIN,
thresh: float = BROW_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈썹 잉크(굵은 털 뭉치) 전체를 명도 임계값 + 연결요소로 찾는다(eye_dark_hole과 같은 방식)."""
inner, peak, outer = brow["inner"], brow["peak"], brow["outer"]
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin - 10, max(ys) + margin + 15
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
pcx, pcy = peak
seed_label = nearest_component_label(labeled, pcy, pcx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def mouth_dark_hole(
mcL, mcR, upLip, loLip, lum: np.ndarray, w: int, h: int, margin: float = MOUTH_INK_MARGIN,
thresh: float = MOUTH_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""입술 잉크(윗/아랫입술 선·안쪽 그늘) 전체를 명도 임계값 + 연결요소로 찾는다."""
x0, x1 = min(mcL[0], mcR[0]) - margin, max(mcL[0], mcR[0]) + margin
y0, y1 = upLip[1] - margin, loLip[1] + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
ccx, ccy = (mcL[0] + mcR[0]) / 2, (upLip[1] + loLip[1]) / 2
seed_label = nearest_component_label(labeled, ccy, ccx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def shift_mask(mask: np.ndarray, dy: int, dx: int) -> np.ndarray:
"""mask를 (dy,dx)만큼 평행이동한다(래핑 없음, 밖으로 밀려난 부분은 버림)."""
h, w = mask.shape
out = np.zeros_like(mask)
src_y0, src_y1 = max(0, -dy), h - max(0, dy)
dst_y0, dst_y1 = max(0, dy), h - max(0, -dy)
src_x0, src_x1 = max(0, -dx), w - max(0, dx)
dst_x0, dst_x1 = max(0, dx), w - max(0, -dx)
if src_y1 <= src_y0 or src_x1 <= src_x0:
return out
out[dst_y0:dst_y1, dst_x0:dst_x1] = mask[src_y0:src_y1, src_x0:src_x1]
return out
def grow_directional(mask: np.ndarray, dy: int = 0, dx: int = 0) -> np.ndarray:
"""mask를 (dy,dx) 방향으로 1px씩 단계적으로 밀어 그 방향으로 최대
|dy| 또는 |dx| px까지 덮는다(원래 mask가 있던 자리에서 그 방향으로 "그림자를
드리운" 모양) — 눈 주름·아랫눈꺼풀·입가 그늘처럼 특정 방향으로만 구멍을
넓힐 때 쓴다."""
steps = max(abs(dy), abs(dx), 1)
out = mask.copy()
for i in range(1, steps + 1):
fy = round(dy * i / steps)
fx = round(dx * i / steps)
out |= shift_mask(mask, fy, fx)
return out
def line_only_mask(dark_mask: np.ndarray, iterations: int = LINE_OPENING_ITER) -> np.ndarray:
"""넓고 부드러운 그늘(오프닝으로 살아남는 덩어리)을 빼고, 얇은 선 성분만 남긴다."""
opened = binary_opening(dark_mask, iterations=iterations)
return dark_mask & ~opened
def clone_stamp_fill(f_arr: np.ndarray, bad_mask: np.ndarray, avoid_mask: np.ndarray) -> tuple[np.ndarray, list[dict]]:
"""bad_mask(F에 남은 선 모양 잔여 잉크)의 연결요소마다 avoid_mask(제외 영역 ∪
머리카락 ∪ F 잔여 잉크)를 피하는 이웃 패치를 후보 오프셋에서 찾아 그대로
옮겨 붙인다 — 복제 도장. 블러 없이 원본 해칭 텍스처를 재배치만 한다."""
h, w = bad_mask.shape
labeled, n = label(bad_mask)
out = f_arr.copy()
good = ~avoid_mask
report: list[dict] = []
for comp_id in range(1, n + 1):
comp = labeled == comp_id
ys, xs = np.where(comp)
y0, y1, x0, x1 = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
comp_sub = comp[y0:y1, x0:x1]
# shift_mask(f_arr, dy, dx)는 출력 위치 y의 값을 입력 위치 y-dy에서
# 가져온다(아래로 dy만큼 미는 것) — 그래서 comp가 실제로 퍼오는 소스
# 좌표는 y0-dy..y1-dy, x0-dx..x1-dx이다. 여기를 검증해야 한다(이전에는
# y0+dy로 반대 방향을 검증하는 부호 버그가 있었다).
chosen = None
best_std = -1.0
for dx, dy in CLONE_STAMP_CANDIDATES:
sy0, sy1, sx0, sx1 = y0 - dy, y1 - dy, x0 - dx, x1 - dx
if sy0 < 0 or sx0 < 0 or sy1 > h or sx1 > w:
continue
if not np.all(good[sy0:sy1, sx0:sx1][comp_sub]):
continue
src_patch = f_arr[sy0:sy1, sx0:sx1][comp_sub]
s = float(src_patch.std())
if s > best_std:
best_std = s
chosen = (dx, dy)
# comp 안쪽은 무조건 완전 교체(blend=1)한다. 컴포넌트별로 가우시안이나
# 거리변환 페더를 주면, 서로 몇 px 안 떨어진 작은 컴포넌트(대다수가
# 1~수십 px)들의 페더 자락이 이웃 컴포넌트의 comp 영역까지 침범해 이미
# 교체된 픽셀을 나중 컴포넌트의 도장 값으로 다시 섞어버려 어둡게 되돌리는
# 문제가 있었다 — comp 경계는 하드컷으로 두고, 바깥쪽 전환은 뒤에서
# bad_texture 전체 기준으로 한 번에 처리한다.
blend = comp.astype(np.float64)
if chosen is None:
_, (iy, ix) = distance_transform_edt(~good, return_indices=True)
shifted = f_arr[iy, ix, :]
method = "nearest-good-pixel"
offset = None
else:
dx, dy = chosen
shifted = np.stack([shift_mask(f_arr[..., c], dy, dx) for c in range(f_arr.shape[2])], axis=2)
method = "clone-stamp"
offset = [dx, dy]
out = out * (1 - blend[..., None]) + shifted * blend[..., None]
report.append({"componentId": comp_id, "pixels": int(comp.sum()), "offset": offset, "method": method})
return out, report
def high_freq_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = HF_SIGMA) -> float:
if not mask.any():
return 0.0
hf = lum - gaussian_filter(lum, sigma=sigma)
return float(hf[mask].std())
def line_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = LINE_SIGMA) -> float:
"""원본이 자신의 블러보다 어두운 정도(가는 잉크선 성분)의 평균 크기."""
if not mask.any():
return 0.0
blurred = gaussian_filter(lum, sigma=sigma)
neg = np.clip(blurred - lum, 0, None)
return float(neg[mask].mean())
def find_clean_patch(
avoid_dilated: np.ndarray, roi: tuple[int, int, int, int], size: int = GENERIC_PATCH_SIZE
) -> tuple[int, int]:
"""roi=(x0,y0,x1,y1) 범위 안에서 avoid_dilated가 전부 False인 size x size
창의 좌상단 좌표를 찾는다. 완전히 깨끗한 창이 없으면 가장 깨끗한 것을 쓴다."""
x0, y0, x1, y1 = roi
h, w = avoid_dilated.shape
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(w, x1), min(h, y1)
eligible = ~avoid_dilated
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
sy0, sy1 = max(0, y0), min(sums.shape[0] - 1, y1 - size)
sx0, sx1 = max(0, x0), min(sums.shape[1] - 1, x1 - size)
if sy1 < sy0 or sx1 < sx0:
sy0, sy1, sx0, sx1 = 0, sums.shape[0] - 1, 0, sums.shape[1] - 1
sub = sums[sy0:sy1 + 1, sx0:sx1 + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
return int(idx[1] + sx0), int(idx[0] + sy0)
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"]
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
f_img = build_padded_faceless(base_dir)
f_arr = np.array(f_img).astype(np.float64)
h, w, _ = f_arr.shape
lum_front = front.mean(axis=2)
lum_f_raw = f_arr.mean(axis=2)
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
if cfg.brow_landmarks_override is not None:
# P1 전용 호환 장치: 눈썹 제외 영역·eyeRegion y0은 눈썹 중심선 보정 이전
# 좌표로 고정 빌드된 채 "다시 빌드하지 않는다"는 지시로 승인됐다(persona.json
# 참고). 다른 모든 랜드마크(눈·입·코·턱)는 그대로 현재 값을 쓴다.
browL, browR = cfg.brow_landmarks_override["browLeft"], cfg.brow_landmarks_override["browRight"]
brow_source = "persona.json faceDetail.browLandmarksOverride"
else:
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
brow_source = "manifest.landmarks.eyebrowLeft/Right"
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
print(f"눈썹 좌표 출처: {brow_source}")
# ------------------------------------------------------------------
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
# ------------------------------------------------------------------
def eye_region_bbox(eye, brow, outward_sign: float):
outer, inner = eye["outerCorner"], eye["innerCorner"]
x_outer = outer[0] + outward_sign * 30.0
x_inner = inner[0] - outward_sign * 25.0
x_min, x_max = sorted([x_outer, x_inner])
y_min = brow["peak"][1] - 10.0
y_max = eye["lowerLidBottom"][1] + 70.0
return x_min, y_min, x_max, y_max
bx0, by0, bx1, by1 = eye_region_bbox(eyeL, browL, outward_sign=-1.0)
rx0, ry0, rx1, ry1 = eye_region_bbox(eyeR, browR, outward_sign=+1.0)
eyeL_cx, eyeL_cy = (bx0 + bx1) / 2, (by0 + by1) / 2
eyeL_rx, eyeL_ry = (bx1 - bx0) / 2, (by1 - by0) / 2
eyeR_cx, eyeR_cy = (rx0 + rx1) / 2, (ry0 + ry1) / 2
eyeR_rx, eyeR_ry = (rx1 - rx0) / 2, (ry1 - ry0) / 2
mouth_x0 = min(mcL[0], mcR[0]) - 45.0
mouth_x1 = max(mcL[0], mcR[0]) + 45.0
mouth_y0 = noseTip[1] + 25.0
mouth_y1 = chinTip[1] - 15.0
mouth_cx, mouth_cy = (mouth_x0 + mouth_x1) / 2, (mouth_y0 + mouth_y1) / 2
mouth_rx, mouth_ry = (mouth_x1 - mouth_x0) / 2, (mouth_y1 - mouth_y0) / 2
print(f"eyeLeft 영역 bbox=({bx0:.1f},{by0:.1f},{bx1:.1f},{by1:.1f})")
print(f"eyeRight 영역 bbox=({rx0:.1f},{ry0:.1f},{rx1:.1f},{ry1:.1f})")
print(f"mouth 영역 bbox=({mouth_x0:.1f},{mouth_y0:.1f},{mouth_x1:.1f},{mouth_y1:.1f})")
a_eyeL = ellipse_alpha(eyeL_cx, eyeL_cy, eyeL_rx, eyeL_ry, w, h, REGION_FEATHER_PX)
a_eyeR = ellipse_alpha(eyeR_cx, eyeR_cy, eyeR_rx, eyeR_ry, w, h, REGION_FEATHER_PX)
a_mouth = ellipse_alpha(mouth_cx, mouth_cy, mouth_rx, mouth_ry, w, h, REGION_FEATHER_PX)
a_mole = np.zeros((h, w), dtype=np.float64)
for mole in cfg.moles:
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))
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))
# ------------------------------------------------------------------
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
# ------------------------------------------------------------------
print("=== 제외 영역 계산 ===")
eyeL_ink_raw = eye_dark_hole(eyeL, lum_front, w, h)
eyeR_ink_raw = eye_dark_hole(eyeR, lum_front, w, h)
# eye_dark_hole은 명도<150 연결요소를 찾는데, 다크서클 해칭이 눈 잉크와
# 이어져 있어(binary_closing으로 다리까지 놓여) 같은 컴포넌트로 잡혀
# 아랫눈꺼풀선 아래 30px까지 "눈 잉크"로 나온다 — 랜드마크선+7px 아래는
# 잘라내 다크서클 해칭이 눈 제외 영역에 섞이지 않게 한다.
yy_full, _ = np.mgrid[0:h, 0:w]
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeL_ink = eyeL_ink_raw & eyeL_cap
eyeR_ink = eyeR_ink_raw & eyeR_cap
print(f"눈 잉크 다크서클 절단: L {int(eyeL_ink_raw.sum())}->{int(eyeL_ink.sum())}px, R {int(eyeR_ink_raw.sum())}->{int(eyeR_ink.sum())}px")
# 위(쌍꺼풀 주름)·바깥(꼬리)은 기존처럼 잉크에서 방향성 확장 + 6px 팽창.
# 아래쪽은 잉크에서 확장하지 않고, 랜드마크 곡선 자체의 좁은 띠(-2~+5px)만
# 별도로 2px 팽창한다 — 다크서클 해칭을 먹지 않기 위함(오케스트레이터 지시).
eyeL_grown = eyeL_ink | grow_directional(eyeL_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeL_ink, dx=-EYE_OUTER_EXT_PX)
eyeR_grown = eyeR_ink | grow_directional(eyeR_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeR_ink, dx=EYE_OUTER_EXT_PX)
# 6px 팽창은 사방으로 동시에 퍼지므로, 이미 절단한 아래쪽 경계를 다시
# 밀어 내린다 — 팽창 뒤에도 같은 절단선으로 다시 한 번 잘라 위/바깥
# 팽창(원하는 효과)만 남기고 아래쪽 재침범은 막는다.
eyeL_core_excl = binary_dilation(eyeL_grown, iterations=EYE_FINAL_DILATE_PX) & eyeL_cap
eyeR_core_excl = binary_dilation(eyeR_grown, iterations=EYE_FINAL_DILATE_PX) & eyeR_cap
eyeL_lower_line = lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], w, h)
eyeR_lower_line = lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], w, h)
eyeL_lower_excl = binary_dilation(eyeL_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeR_lower_excl = binary_dilation(eyeR_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
# 페더 프로파일을 나눌 때는 "아래쪽 좁은 띠 전용 마스크"가 아니라 "제외
# 영역 중 아랫눈꺼풀선 근방(위로 3px)에 걸리는 부분 전체"를 기준으로
# 삼는다 — core_excl의 6px 팽창분도 같은 y대에 있으면 좁은 4px 페더를
# 받아야 다크서클 쪽으로 넓은 10px 페더가 새는 것을 막는다.
eyeL_lower_zone = eyeL_excl & (yy_full > eyeL["lowerLidBottom"][1] - 8.0)
eyeR_lower_zone = eyeR_excl & (yy_full > eyeR["lowerLidBottom"][1] - 8.0)
eye_lower_bands = eyeL_lower_zone | eyeR_lower_zone
browL_ink = brow_dark_hole(browL, lum_front, w, h)
browR_ink = brow_dark_hole(browR, lum_front, w, h)
browL_band = eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browR_band = eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browL_excl = binary_dilation(browL_ink | browL_band, iterations=BROW_FINAL_DILATE_PX)
browR_excl = binary_dilation(browR_ink | browR_band, iterations=BROW_FINAL_DILATE_PX)
mouth_ink = mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, w, h)
mouth_grown = (
mouth_ink
| grow_directional(mouth_ink, dx=-MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dx=MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dy=MOUTH_SHADOW_EXT_PX)
)
mouth_excl = binary_dilation(mouth_grown, iterations=MOUTH_FINAL_DILATE_PX)
exclusion_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
named_excl = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
print(f"제외 영역 픽셀: eye={int((eyeL_excl|eyeR_excl).sum())} brow={int((browL_excl|browR_excl).sum())} mouth={int(mouth_excl.sum())} union={int(exclusion_mask.sum())}")
# ------------------------------------------------------------------
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
# ------------------------------------------------------------------
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
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())}")
# ------------------------------------------------------------------
# 텍스처원(질감, 블러 금지): F가 선 모양 잔여 잉크인 자리만 복제 도장
# ------------------------------------------------------------------
dark_in_excl = exclusion_mask & ~hair_mask & (lum_f_raw < CLONE_TARGET_THRESH)
bad_texture = line_only_mask(dark_in_excl)
avoid_mask = exclusion_mask | hair_mask | (lum_f_raw < AVOID_THRESH)
print(f"텍스처 복제 대상(제외 영역 안 F 선 모양 잔여 잉크): {int(bad_texture.sum())}px (넓은 그늘 {int(dark_in_excl.sum()) - int(bad_texture.sum())}px는 보존)")
f_clean, stamp_report = clone_stamp_fill(f_arr, bad_texture, avoid_mask)
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)는
# 다크서클 해칭을 먹지 않도록 4px 페더만 쓴다(오케스트레이터 지시). 두
# 페더 영역을 나눠 계산한 뒤 max로 합친다. 복제 도장으로 갈아 끼운 자리
# (bad_texture)는 경계 페더가 원본 front(잉크 그 자체)를 다시 섞어 넣지
# 못하게 blend=1로 고정한다 — 안 그러면 제외 영역 가장자리 근처의 잔여
# 잉크는 도장으로 지워도 다시 어두워진다.
# ------------------------------------------------------------------
blend_main = np.clip(
gaussian_filter((exclusion_mask & ~eye_lower_bands).astype(np.float64), sigma=EXCLUSION_BOUNDARY_FEATHER_PX / 2.0), 0.0, 1.0
)
blend_lower = np.clip(gaussian_filter(eye_lower_bands.astype(np.float64), sigma=EYE_LOWER_FEATHER_PX / 2.0), 0.0, 1.0)
blend = np.maximum(blend_main, blend_lower)
blend = np.maximum(blend, bad_texture.astype(np.float64))
face_detail_rgb = front * (1 - blend[..., None]) + f_clean * blend[..., None]
# region_alpha(눈·입 타원)는 기존 "눈두덩~다크서클" 범위용으로 만든 것이라,
# 새로 넓힌 제외 영역(특히 눈썹은 자체 타원이 없다)을 다 못 덮을 수 있다 —
# 못 덮으면 그 자리는 faceDetail이 아니라 head 레이어의 원본(미처리) 잉크가
# 그대로 비쳐 보인다. 제외 영역은 RGB와 같은 10px 페더 프로필로 알파도
# 최소 보장한다.
face_detail_alpha = np.maximum(region_alpha, blend * 255.0)
face_detail_alpha[hair_mask] = 0.0
# ------------------------------------------------------------------
# 저장
# ------------------------------------------------------------------
layers_v2_dir.mkdir(parents=True, exist_ok=True)
out_path = layers_v2_dir / "face-detail.png"
Image.fromarray(np.dstack([to_u8(face_detail_rgb), to_u8(face_detail_alpha)]), "RGBA").save(out_path)
bbox = alpha_bbox(to_u8(face_detail_alpha))
print(f"저장: {out_path} bbox={bbox}")
# ------------------------------------------------------------------
# 정지 합성(body+head+faceDetail+hairFront, 종이 위)
# ------------------------------------------------------------------
def load_rgba(p: Path) -> tuple[np.ndarray, np.ndarray]:
arr = np.array(Image.open(p).convert("RGBA")).astype(np.float64)
return arr[..., :3], arr[..., 3]
body_rgb, body_a = load_rgba(layers_v2_dir / "body.png")
head_rgb, head_a = load_rgba(layers_v2_dir / "head.png")
hf_rgb, hf_a = load_rgba(layers_v2_dir / "hairFront.png")
canvas = np.zeros((h, w, 4), dtype=np.float64)
canvas[..., 0] = CREAM_BG[0]; canvas[..., 1] = CREAM_BG[1]; canvas[..., 2] = CREAM_BG[2]; canvas[..., 3] = 255.0
canvas = composite_over(canvas, to_u8(body_rgb), to_u8(body_a))
canvas = composite_over(canvas, to_u8(head_rgb), to_u8(head_a))
canvas = composite_over(canvas, to_u8(face_detail_rgb), to_u8(face_detail_alpha))
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
static_composite = to_u8(canvas)[..., :3].astype(np.float64)
lum_composite = static_composite.mean(axis=2)
# ------------------------------------------------------------------
# 검사 (a): 제외 영역 밖, faceDetail alpha>0.5에서 base-front 대비 <=3
# ------------------------------------------------------------------
check_mask = (face_detail_alpha > 127) & ~exclusion_mask
diff = np.abs(static_composite - front).mean(axis=2)
mean_abs_a = float(diff[check_mask].mean()) if check_mask.any() else None
print(f"검사(a) 제외영역 밖 & faceDetail>0.5 평균절대차 = {mean_abs_a:.3f} (기준 <=3)")
# ------------------------------------------------------------------
# 검사 (b): 제외 영역 경계 안팎 3px 띠 평균 명도 차 <=6
# ------------------------------------------------------------------
excl_inner_band = exclusion_mask & ~binary_erosion(exclusion_mask, iterations=3)
excl_outer_band = binary_dilation(exclusion_mask, iterations=3) & ~exclusion_mask
inner_mean = float(lum_composite[excl_inner_band].mean()) if excl_inner_band.any() else None
outer_mean = float(lum_composite[excl_outer_band].mean()) if excl_outer_band.any() else None
seam_diff = abs(inner_mean - outer_mean) if inner_mean is not None and outer_mean is not None else None
print(f"검사(b) 제외영역 경계 안({inner_mean:.2f})/밖({outer_mean:.2f}) 명도차 = {seam_diff:.3f} (기준 <=6)")
# ------------------------------------------------------------------
# 검사 (c): 제외 영역 안, 머리카락이 아닌 픽셀 중 명도<70 비율 <=0.3%
# ------------------------------------------------------------------
check_region_c = exclusion_mask & ~hair_mask
violations = check_region_c & (lum_composite < 70)
n_violations = int(violations.sum())
hole_dark_pct = float(violations.sum()) / float(check_region_c.sum()) * 100.0 if check_region_c.any() else 0.0
print(f"검사(c) 제외영역 안(머리카락 제외) 명도<70 비율 = {hole_dark_pct:.4f}% (기준 <=0.3%, 위반 {n_violations}px)")
if hole_dark_pct > 0.3 and n_violations > 0:
overlay = front.copy()
overlay[violations] = np.array([230.0, 30.0, 30.0])
ys, xs = np.where(violations)
pad = 30
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
cx1, cy1 = min(w, int(xs.max()) + pad), min(h, int(ys.max()) + pad)
diag_path = preview_v2_dir / "checkC-violations.png"
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
# ------------------------------------------------------------------
# 일반 피부 표본(뺨 2곳 + 이마 1곳, F 기준, 이목구비·머리카락에서 40px+ 이격)
# ------------------------------------------------------------------
avoid_for_patch = binary_dilation(exclusion_mask | hair_mask, iterations=GENERIC_PATCH_MARGIN)
browL_peak, browR_peak = browL["peak"], browR["peak"]
eyeL_lower, eyeR_lower = eyeL["lowerLidBottom"], eyeR["lowerLidBottom"]
forehead_roi = (
int(min(browL_peak[0], browR_peak[0]) - 20), int(min(browL_peak[1], browR_peak[1]) - 100),
int(max(browL_peak[0], browR_peak[0]) + 20), int(min(browL_peak[1], browR_peak[1]) - 20),
)
cheekL_roi = (
int(eyeL["outerCorner"][0] - 100), int(eyeL_lower[1] + 10),
int(noseTip[0] - 20), int(mcL[1] - 5),
)
cheekR_roi = (
int(noseTip[0] + 20), int(eyeR_lower[1] + 10),
int(eyeR["outerCorner"][0] + 100), int(mcR[1] - 5),
)
patch_boxes: dict[str, tuple[int, int, int, int]] = {}
for name, roi in (("forehead", forehead_roi), ("cheekLeft", cheekL_roi), ("cheekRight", cheekR_roi)):
x0, y0 = find_clean_patch(avoid_for_patch, roi, GENERIC_PATCH_SIZE)
patch_boxes[name] = (x0, y0, x0 + GENERIC_PATCH_SIZE, y0 + GENERIC_PATCH_SIZE)
clean_frac = float((~avoid_for_patch[y0:y0 + GENERIC_PATCH_SIZE, x0:x0 + GENERIC_PATCH_SIZE]).mean()) * 100.0
print(f"일반 피부 표본 {name}: box={patch_boxes[name]} 깨끗한 비율={clean_frac:.1f}%")
lum_f_clean = f_clean.mean(axis=2)
patch_masks = {}
for name, (x0, y0, x1, y1) in patch_boxes.items():
m = np.zeros((h, w), dtype=bool)
m[y0:y1, x0:x1] = True
patch_masks[name] = m
baseline_hf = float(np.mean([high_freq_energy(lum_f_clean, m) for m in patch_masks.values()]))
baseline_line = float(np.mean([line_energy(lum_front, m) for m in patch_masks.values()]))
print(f"일반 피부 표본 기준: 고주파 에너지={baseline_hf:.3f}, 선 에너지={baseline_line:.3f}")
# ------------------------------------------------------------------
# 검사 (d): 질감 보존 — 구멍 안 고주파 에너지(F 기준) / 일반 피부 표본 평균 >= 0.7
# ------------------------------------------------------------------
checkD: dict[str, dict] = {}
all_pass_d = True
for name, side_excl in named_excl.items():
side_visible = side_excl & ~hair_mask
e_inside = high_freq_energy(lum_f_clean, side_visible)
ratio_d = e_inside / baseline_hf if baseline_hf > 1e-6 else 0.0
passed = ratio_d >= CHECKD_THRESH
all_pass_d = all_pass_d and passed
checkD[name] = {"insideEnergy": e_inside, "baselineEnergy": baseline_hf, "ratio": ratio_d, "pass": passed}
print(f"검사(d) {name}: 안={e_inside:.3f} 표본기준={baseline_hf:.3f} 비율={ratio_d:.3f} (기준 >={CHECKD_THRESH}) {'OK' if passed else '[미달]'}")
# ------------------------------------------------------------------
# 검사 (e): 유령 윤곽 — 제외 영역 밖 0~12px 띠(머리카락 제외)의 선
# 에너지(front 기준) / 일반 피부 표본 평균 <= 1.5. 머리카락 가닥은 이목구비
# 잉크선이 아니므로 checkC와 같은 기준으로 뺀다.
# ------------------------------------------------------------------
checkE: dict[str, dict] = {}
all_pass_e = True
for name, side_excl in named_excl.items():
band = binary_dilation(side_excl, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
e_band = line_energy(lum_front, band)
ratio_e = e_band / baseline_line if baseline_line > 1e-6 else 0.0
passed = ratio_e <= CHECKE_THRESH
all_pass_e = all_pass_e and passed
checkE[name] = {"bandLineEnergy": e_band, "baselineLineEnergy": baseline_line, "ratio": ratio_e, "pass": passed}
print(f"검사(e) {name}: 경계띠선에너지={e_band:.3f} 표본기준={baseline_line:.3f} 비율={ratio_e:.3f} (기준 <={CHECKE_THRESH}) {'OK' if passed else '[미달-유령윤곽]'}")
# ------------------------------------------------------------------
# 검사 (다크서클 복원): 아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35 띠의 합성
# 평균 명도가 base-front와 ±6 이내여야 한다(오케스트레이터 지시 — 다크서클
# 해칭이 지워지면 안 된다).
# ------------------------------------------------------------------
checkDarkCircle: dict[str, dict] = {}
all_pass_dc = True
for name, eye in (("eyeLeft", eyeL), ("eyeRight", eyeR)):
band = dark_circle_band_mask(eye, w, h)
composite_mean = float(lum_composite[band].mean())
front_mean = float(lum_front[band].mean())
diff2 = abs(composite_mean - front_mean)
passed = diff2 <= DARK_CIRCLE_MATCH_TOLERANCE
all_pass_dc = all_pass_dc and passed
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff2, "pass": passed}
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff2:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
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)],
"mouth": [round(mouth_x0, 1), round(mouth_y0, 1), round(mouth_x1, 1), round(mouth_y1, 1)],
"moles": [{"center": [round(m["center"][0], 1), round(m["center"][1], 1)], "radius": m["radius"]} for m in cfg.moles],
},
"exclusionPixels": {
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
"browLeft": int(browL_excl.sum()), "browRight": int(browR_excl.sum()),
"mouth": int(mouth_excl.sum()), "union": int(exclusion_mask.sum()),
},
"hairMaskPixels": int(hair_mask.sum()),
"bbox": bbox,
"textureCloneStamp": {
"cloneTargetThreshold": CLONE_TARGET_THRESH,
"avoidThreshold": AVOID_THRESH,
"lineOpeningIterations": LINE_OPENING_ITER,
"featherPx": CLONE_STAMP_FEATHER_PX,
"badTexturePixels": int(bad_texture.sum()),
"preservedShadowPixels": int(dark_in_excl.sum()) - int(bad_texture.sum()),
"components": stamp_report,
},
"genericPatches": {name: list(box) for name, box in patch_boxes.items()},
"genericPatchBaseline": {"highFreqEnergy": baseline_hf, "lineEnergy": baseline_line},
"checkA_meanAbsDiff": mean_abs_a,
"checkB_seamLumDiff": seam_diff,
"checkC_holeDarkResiduePct": hole_dark_pct,
"checkC_violationPixels": n_violations,
"checkD_texturePreservation": checkD,
"checkD_allPass": all_pass_d,
"checkE_ghostOutline": checkE,
"checkE_allPass": all_pass_e,
"checkDarkCircle": checkDarkCircle,
"checkDarkCircle_allPass": all_pass_dc,
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신: {manifest_path}")
return 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1])))