- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
835 lines
44 KiB
Python
835 lines
44 KiB
Python
"""공통 리노컷 리그 — 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])))
|