vignette/docs/avatar-art/p1-linocut/scripts/build_face_detail.py
Yun Chan 00169533d8 P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
2026-10-01 09:58:15 +09:00

740 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

"""P1 서연 faceDetail 레이어 생성 — 2단계-B-1a (3), 결정문 §8.2.
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
경계는 10px 페더로 base-front↔F를 섞는다.
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
진짜 결이므로 건드리지 않는다.
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
실행: <venv>/python.exe build_face_detail.py
"""
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 build_layers_segmented import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over # noqa: E402
from build_layers_v2 import CREAM_BG # noqa: E402
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
LAYERS_V2_DIR = ROOT / "layers" / "v2"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
REGION_FEATHER_PX = 12.0
MOLE_CENTER = (661.3, 627.9)
MOLE_RADIUS = 20.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
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 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() -> int:
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()
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"]
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
# ------------------------------------------------------------------
# 영역(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 = ellipse_alpha(MOLE_CENTER[0], MOLE_CENTER[1], MOLE_RADIUS, MOLE_RADIUS, w, h, REGION_FEATHER_PX)
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 = binary_dilation(category_mask == 1, 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']}")
# ------------------------------------------------------------------
# 합성: 제외 영역 밖은 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())
diff = abs(composite_mean - front_mean)
passed = diff <= DARK_CIRCLE_MATCH_TOLERANCE
all_pass_dc = all_pass_dc and passed
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff, "pass": passed}
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
manifest.setdefault("layersV2", {})
manifest["layersV2"]["faceDetail"] = {
"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)],
"cheekMole": {"center": [round(MOLE_CENTER[0], 1), round(MOLE_CENTER[1], 1)], "radius": MOLE_RADIUS},
},
"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,
"checkD_mouthException": "mouth 0.550은 가공 없는 원본 F 픽셀 그대로라 원본 특성으로 수용(오케스트레이터 판정).",
"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())