"""공통 리노컷 리그 — 원화 눈썹 스프라이트 + 눈꺼풀 선 굵기 배율 생성(결정문 §8.4.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기"). 배경(오케스트레이터 판정): P7 시범 렌더에서 벡터 눈썹(P1의 가늘고 옅은 눈썹에 맞춘 굵기)이 P7의 굵은 판화 눈썹을 "갈색빛 회색 막대"로 그렸다. 입술에서 받아들여진 "중립 렌더 = 원화" 원칙을 눈썹에도 적용한다 — 원화 눈썹 잉크를 그대로 스프라이트로 떼어 렌더러가 세로 띠로 나눠 휘게 한다. 눈은 감고 뜨기 때문에 벡터를 유지하되, 원화에서 잰 윗눈꺼풀 잉크 두께 배율(lineScale)을 선 굵기에 곱한다. persona.json browSpriteFromArt/eyeLineScaleFromArt(둘 다 기본 true)가 false인 페르소나는 해당 산출을 건너뛴다(P1: 이미 고정 벡터 눈썹·굵기로 빌드·검수됐고, lineScale의 기준값 자체이기도 하다 — 바뀌면 안 된다). 눈썹 영역: - manifest의 눈썹 중심선(brow_centerline.py 결과: landmarks.eyebrowLeft/Right inner·peak·outer, browCenterline.halfWidthLeft/Right)을 따라 띠를 만든다. 반높이는 max(halfWidth)+8, inner·outer 바깥으로 6px 연장한다. - 띠 안에서 원화(base-front) 잉크와 연결된 성분만 남긴다(Otsu(front 휘도) AND diff(F-front)>=25, 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취한다 — brow_centerline.build_ink_band와 같은 기준). - 머리카락은 뺀다. 기준은 layers/v2/hairFront.png(이미 게시되는 그 레이어)의 알파>128(1px 팽창, 오케스트레이터 판정값)다 — 분할 카테고리(category=1) 자체를 쓰면 이 화풍(두꺼운 판화 눈썹이 앞머리와 맞닿아 있다)에서 세그멘터가 눈썹 잉크 자체를 "머리카락"으로 광범위 오탐한다(P7 1차 실측: 분할 합집합을 그대로 빼면 눈썹 잉크의 54~82%가 사라짐). hairFront는 렌더 z-순서에서 눈썹 스프라이트 "위"에 그려지므로, 그 알파 아래 픽셀을 스프라이트에서 빼도 중립 렌더는 원화와 같고 이중 가닥(겹쳐 보임)도 생기지 않는다 — "앞머리 가닥이 눈썹과 같이 움직이면 안 된다"는 의도를 분할 카테고리보다 정확히 구현한다(오케스트레이터 실측: 눈썹 띠 안 잉크 중 hairFront 알파>128 아래에 있는 비율은 좌 47% (1392/2953), 우 19%(442/2329) — 분할 카테고리 기준 손실 54~82%보다 훨씬 적다). 알파: a = clamp((L_skin-L)/(L_skin-L_ink), 0, 1) × (region 경계 1.5px 페더, RAMP_PX). L_skin은 띠 안 faceless 휘도 중앙값, L_ink는 띠 안에서 "잉크로 분류된 픽셀"(머리카락 제외 후 region, band_ink_mask 결과) 휘도의 중앙값이다 — 그보다 어두운 픽셀은 a=1이 된다(2차 보정, 작업 패킷 "눈썹 스프라이트·눈꺼풀 굵기 2차 보정": 띠 전체 휘도의 5백분위를 쓰면 극단적으로 어두운 꼬리값이 L_ink가 돼, 잉크 픽셀 대부분이 a<1로 옅게 그려졌다). 페더도 4px→1.5px로 줄였다 — 눈썹 해칭 획 자체가 가늘어(경계거리 평균 2.28px) 4px 페더로는 L_ink를 낮춰도 대부분 페더 중간값에 걸려 옅었다(RAMP_PX 주석 참고, 블러·형태학 닫기는 쓰지 않아 결은 그대로다). 색: RGB = clip((front - (1-a)*skin)/a), skin은 띠 안 faceless 평균색. a<0.05면 잉크색(L_ink population의 front RGB 중앙값)으로 대체한다(0 나눗셈 폭주 방지). 눈꺼풀 굵기: 눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채 중심 y 사이의 첫 연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다. P1 기준값(아래 상수)으로 나눈 비를 [1.0, 2.5]로 clamp한 값이 lineScale이다. 전제: landmarks.py·brow_centerline.py·layers_v2.py·face_detail.py가 먼저 돌아서 manifest.landmarks(+browCenterline)와 layers/v2/{body,head,hairFront,face-detail}.png를 만들어 두어야 한다. export_rig.py보다 앞, face_detail.py보다 뒤에 둔다. 실행: /python.exe brow_sprite.py """ from __future__ import annotations import json import math import sys from pathlib import Path import cv2 import numpy as np from PIL import Image, ImageDraw from scipy import ndimage SCRIPTS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPTS_DIR)) from persona_config import load_persona_config # noqa: E402 from segmentation import build_padded_faceless, to_u8, composite_over, CREAM_BG # noqa: E402 from lip_texture import crop_to_alpha_bbox # noqa: E402 — 알파 bbox로 잘라 RGBA PNG를 내는 공용 유틸(입술 결과 동일 패턴) # --- 눈썹 영역(띠) 상수 ------------------------------------------------------- BAND_EXTRA_HALF_HEIGHT_PX = 8.0 # 반높이 = max(halfWidth) + 이 값 BAND_END_EXTEND_PX = 6.0 # inner·outer 바깥 연장 BAND_BBOX_PAD_PX = 10 # 닫기 연산이 띠 경계에서 잘리지 않게 주는 여유 # brow_centerline.py의 잉크 판정 기준과 같다(같은 원화 눈썹 잉크를 다시 찾는 # 것이므로 같은 임계값을 쓴다) — DIFF_THRESH·CLOSING_KERNEL·MIN_COMPONENT_AREA. DIFF_THRESH = 25.0 CLOSING_KERNEL = (7, 11) MIN_COMPONENT_AREA = 30.0 HAIR_DILATE_ITER = 1 # 머리카락(hairFront 알파) 마스크 팽창(오케스트레이터 판정값) HAIR_ALPHA_THRESH = 128.0 # layers/v2/hairFront.png 알파가 이보다 크면 "머리카락이 덮는다"로 본다(오케스트레이터 판정값) RAMP_PX = 1.5 # region(잉크, 머리카락 제외) 경계 알파 페더(px) # 2차 보정: 눈썹 해칭은 획 자체가 가늘다(P7 실측: region 안 경계거리변환(distance_transform_edt) # 평균 2.28px, dist>=4px인 픽셀 14~17%뿐) — 기존 4px 페더로는 L_ink를 아무리 낮춰도(=어둡게 # 잡아도) 거의 모든 획이 페더 중간값에 걸려 옅게 그려졌다(T1 목표 미달의 실제 원인). 1.5px로 # 줄이면 블러·형태학 닫기 없이(결 보존) 경계 anti-alias만 남기고 목표(알파>0 영역 평균절대차 # <=10)를 만족한다(P7 실측: 좌 7.83 우 4.99, RAMP_PX=4일 때는 좌 42.75 우 41.77이었다). MIN_ALPHA_FOR_UNMIX = 0.05 # a<0.05면 피부색 역산(unmix) 대신 잉크색을 쓴다(0 나눗셈 폭주 방지) # --- 눈꺼풀 선 굵기(lineScale) 상수 ------------------------------------------ UPPER_LID_WINDOW_ABOVE_PX = 12.0 # 측정 창 위쪽 = upperLidTop.y - 이 값 UPPER_LID_DARK_LUM_THRESH = 80.0 UPPER_LID_COLUMN_MIDDLE_FRAC = 0.6 # 눈 폭 가운데 60%(양옆 20%씩 제외) LINE_SCALE_MIN = 1.0 LINE_SCALE_MAX = 2.5 # P1 기준값(윗눈꺼풀 잉크 두께, px) — measure_upper_lid_thickness를 P1 # manifest.landmarks.eyeLeft/eyeRight + base/base-front.png에 적용한 값이다 # (둘 다 median=2.000px, 표본 열 57/58개 — 실측 근거, 2026-10-01). P1은 # "다시 빌드하지 않는다"는 결정문 지시로 고정돼 있어 매번 다시 재는 대신 # 상수로 고정한다(패킷 지시: "P1 값은... 상수로 고정하되 근거를 주석으로 단다"). P1_UPPER_LID_THICKNESS_PX = 2.0 # --- 홍채 실측(2차 보정 T3) 상수 --------------------------------------------- # mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로 학습돼, 판화 잉크로 # 그려진 홍채(원화 실측)보다 반지름을 크게 잡을 수 있다(P7 실측: 가로 반지름 # 기준 리그 대비 약 11~15% 작다, 2026-10-01). lineScale·browSprite와 무관하게 # 항상 측정해 manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 항상 기록" # 원칙) — export_rig.py가 persona.json irisFromArt(기본 true)일 때만 이 값을 쓴다. IRIS_DARK_LUM_THRESH = 100.0 # 홍채 잉크 실측 휘도(28~40대) vs 흰자(150 이상) 중간, 여유를 둔 임계값 IRIS_SCAN_BAND_PX = 4 # 중심 둘레 ±이 값(px)의 행/열을 스캔한다 # --- 눈썹 띠(band) 기하 ------------------------------------------------------- def _quad_point(p0: tuple[float, float], c: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]: mt = 1.0 - t return (mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p2[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p2[1]) def _quad_control(p0: tuple[float, float], mid: tuple[float, float], p2: tuple[float, float]) -> tuple[float, float]: return (2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1])) def _normalize(dx: float, dy: float) -> tuple[float, float]: length = math.hypot(dx, dy) or 1.0 return dx / length, dy / length def _seg_normal(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]: dx, dy = b[0] - a[0], b[1] - a[1] length = math.hypot(dx, dy) or 1.0 return -dy / length, dx / length def brow_band_polygon( inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float], half_height: float, extend_px: float, samples: int = 40, ) -> list[tuple[float, float]]: """눈썹 중심선(inner→peak→outer 2차 베지어, linocutGeometry.ts buildBrow의 곡선과 같은 방식)을 따라 반높이 half_height인 리본 폴리곤을 만든다(곡선 표본 + 법선 오프셋, linocutGeometry.ts polylineRibbon과 같은 기법). 양끝은 접선 방향으로 extend_px만큼 늘린다(inner·outer 바깥 연장).""" control = _quad_control(inner, peak, outer) pts = [_quad_point(inner, control, outer, i / (samples - 1)) for i in range(samples)] t0 = _normalize(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1]) t1 = _normalize(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1]) ext_inner = (pts[0][0] + t0[0] * extend_px, pts[0][1] + t0[1] * extend_px) ext_outer = (pts[-1][0] + t1[0] * extend_px, pts[-1][1] + t1[1] * extend_px) pts = [ext_inner] + pts + [ext_outer] n = len(pts) normals = [] for i in range(n): a = _seg_normal(pts[i - 1], pts[i]) if i > 0 else _seg_normal(pts[i], pts[i + 1]) b = _seg_normal(pts[i], pts[i + 1]) if i < n - 1 else _seg_normal(pts[i - 1], pts[i]) normals.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)) top = [(p[0] + nx * half_height, p[1] + ny * half_height) for p, (nx, ny) in zip(pts, normals)] bot = [(p[0] - nx * half_height, p[1] - ny * half_height) for p, (nx, ny) in zip(pts, normals)] return top + list(reversed(bot)) def polygon_bool_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray: img = Image.new("L", (w, h), 0) ImageDraw.Draw(img).polygon(points, fill=255) return np.array(img) > 127 def mask_bbox(mask: np.ndarray, pad: int, w: int, h: int) -> tuple[int, int, int, int]: ys, xs = np.where(mask) if len(xs) == 0: raise SystemExit("[중단] 눈썹 띠 마스크가 비어 있다.") x0 = max(0, int(xs.min()) - pad) y0 = max(0, int(ys.min()) - pad) x1 = min(w, int(xs.max()) + 1 + pad) y1 = min(h, int(ys.max()) + 1 + pad) return x0, y0, x1, y1 # --- 띠 안 잉크 연결성분 ------------------------------------------------------ def band_ink_mask(front_lum: np.ndarray, diff: np.ndarray, band_mask: np.ndarray, bbox: tuple[int, int, int, int]) -> tuple[np.ndarray, float]: """brow_centerline.build_ink_band와 같은 기준(Otsu(front 휘도) AND diff>=25, 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취함)을, 축 정렬 bbox 대신 눈썹 띠 폴리곤(band_mask) 안으로 제한해 적용한다.""" x0, y0, x1, y1 = bbox region_lum = front_lum[y0:y1, x0:x1] u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8) otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) ink_mask = region_lum <= otsu_thresh diff_mask = diff[y0:y1, x0:x1] >= DIFF_THRESH band_region = band_mask[y0:y1, x0:x1] combined = ink_mask & diff_mask & band_region closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL)) labeled, n = ndimage.label(closed) if n == 0: raise SystemExit("[중단] 눈썹 띠 안에 잉크 마스크가 전혀 없다.") sizes = ndimage.sum(closed, labeled, range(1, n + 1)) top_label = int(np.argmax(sizes)) + 1 band_ink_local = combined & (labeled == top_label) lbl2, n2 = ndimage.label(band_ink_local) if n2 > 0: sizes2 = ndimage.sum(band_ink_local, lbl2, range(1, n2 + 1)) keep = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA] band_ink_local = band_ink_local & np.isin(lbl2, keep) full = np.zeros(front_lum.shape, dtype=bool) full[y0:y1, x0:x1] = band_ink_local return full, float(otsu_thresh) # --- 눈썹 스프라이트 한 쪽 ---------------------------------------------------- def build_one_brow_sprite( inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float], half_width: tuple[float, float, float], front_rgb: np.ndarray, faceless_rgb: np.ndarray, front_lum: np.ndarray, faceless_lum: np.ndarray, diff: np.ndarray, hair_mask: np.ndarray, canvas_w: int, canvas_h: int, side_label: str, ) -> dict: half_height = max(half_width) + BAND_EXTRA_HALF_HEIGHT_PX band_points = brow_band_polygon(inner, peak, outer, half_height, BAND_END_EXTEND_PX) band_mask = polygon_bool_mask(band_points, canvas_w, canvas_h) bbox = mask_bbox(band_mask, BAND_BBOX_PAD_PX, canvas_w, canvas_h) ink_mask_raw, otsu_thresh = band_ink_mask(front_lum, diff, band_mask, bbox) hair_overlap_px = int((ink_mask_raw & hair_mask).sum()) region = ink_mask_raw & ~hair_mask if not region.any(): raise SystemExit(f"[중단] {side_label} 눈썹 영역이 비었다(머리카락 제거 후 잉크 픽셀 0).") band_lum_faceless = faceless_lum[band_mask] l_skin = float(np.median(band_lum_faceless)) # 2차 보정: 띠 전체 휘도의 5백분위(극단적으로 어두운 꼬리값) 대신, 실제로 잉크로 # 분류된 픽셀(region, 머리카락 제거 후)의 휘도 중앙값을 쓴다 — 그보다 어두운 # 잉크 픽셀은 a=1이 된다(목표: 눈썹 알파>0 영역 평균절대차 <=10). l_ink = float(np.median(front_lum[region])) denom = (l_skin - l_ink) if abs(l_skin - l_ink) > 1e-6 else 1e-6 dist_in = ndimage.distance_transform_edt(region) feather = np.clip(dist_in / RAMP_PX, 0.0, 1.0) density = np.clip((l_skin - front_lum) / denom, 0.0, 1.0) alpha_frac = np.where(region, density * feather, 0.0) alpha = np.rint(alpha_frac * 255.0).astype(np.uint8) skin_rgb = faceless_rgb[band_mask].mean(axis=0) ink_sample_mask = band_mask & (front_lum <= l_ink) ink_fallback_rgb = ( np.median(front_rgb[ink_sample_mask], axis=0) if ink_sample_mask.any() else np.array([0.0, 0.0, 0.0]) ) a3 = alpha_frac[..., None] unmixed = (front_rgb - (1.0 - a3) * skin_rgb.reshape(1, 1, 3)) / np.maximum(a3, 1e-6) unmixed = np.clip(unmixed, 0.0, 255.0) rgb = np.where(a3 >= MIN_ALPHA_FOR_UNMIX, unmixed, ink_fallback_rgb.reshape(1, 1, 3)) print( f" [{side_label}] 띠 반높이={half_height:.2f}px otsu={otsu_thresh:.1f} " f"L_skin={l_skin:.2f} L_ink={l_ink:.2f} 영역픽셀={int(region.sum())} " f"앞머리겹침픽셀={hair_overlap_px}" ) return { "alpha": alpha, "rgb": rgb, "region": region, "bandMask": band_mask, "bbox": bbox, "halfHeight": half_height, "otsuThresh": otsu_thresh, "lSkin": l_skin, "lInk": l_ink, "hairOverlapPx": hair_overlap_px, "regionPixelCount": int(region.sum()), } # --- 중립 합성 검사(기존 레이어 위에 스프라이트를 얹어 원화와 비교) ------------ def _load_rgba(path: Path) -> tuple[np.ndarray, np.ndarray]: arr = np.array(Image.open(path).convert("RGBA")).astype(np.float64) return arr[..., :3], arr[..., 3] def composite_with_sprites(layers_v2_dir: Path, canvas_w: int, canvas_h: int, sprites: dict[str, dict]) -> np.ndarray: """body→head→face-detail(이미 눈썹 자리를 faceless로 메워 둔 상태) 위에 눈썹 스프라이트를 얹고, 그 위에 hairFront를 덮는다 — LinocutAvatar.tsx의 그리는 순서(브로우는 face 그룹 안, hairFront보다 먼저)와 같다.""" canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64) canvas[..., 0] = CREAM_BG[0] canvas[..., 1] = CREAM_BG[1] canvas[..., 2] = CREAM_BG[2] canvas[..., 3] = 255.0 for stem in ("body", "head", "face-detail"): p = layers_v2_dir / f"{stem}.png" if p.exists(): rgb, a = _load_rgba(p) canvas = composite_over(canvas, to_u8(rgb), to_u8(a)) for data in sprites.values(): canvas = composite_over(canvas, to_u8(data["rgb"]), data["alpha"]) hf_path = layers_v2_dir / "hairFront.png" if hf_path.exists(): hf_rgb, hf_a = _load_rgba(hf_path) canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a)) return canvas[..., :3] def _save_evidence(front_rgb: np.ndarray, sprites: dict[str, dict], composite_rgb: np.ndarray, preview_v2_dir: Path) -> None: """옆 쪽마다 원화 | 중립 합성(스프라이트 포함) | 스프라이트(체커 배경) 3열, 3배 확대(lip_texture.py _save_evidence와 같은 방식).""" rows = [] for side_label, data in sprites.items(): x0, y0, x1, y1 = data["bbox"] pad = 10 bx0, by0 = max(0, x0 - pad), max(0, y0 - pad) bx1, by1 = x1 + pad, y1 + pad def zoom(arr: np.ndarray) -> Image.Image: crop = arr[by0:by1, bx0:bx1] im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8)) return im.resize((im.width * 3, im.height * 3), Image.LANCZOS) panel_orig = zoom(front_rgb).convert("RGB") panel_composite = zoom(composite_rgb).convert("RGB") alpha = data["alpha"] rgb = data["rgb"] crop_rgb = rgb[by0:by1, bx0:bx1] crop_a = (alpha[by0:by1, bx0:bx1].astype(np.float64) / 255.0)[..., None] yy, xx = np.indices(crop_a.shape[:2]) checker = ((yy // 6 + xx // 6) % 2) * 60 + 180 checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64) sprite_view = crop_rgb * crop_a + checker3 * (1 - crop_a) panel_sprite = Image.fromarray(np.clip(sprite_view, 0, 255).astype(np.uint8)).resize( (panel_orig.width, panel_orig.height), Image.NEAREST ) gap = 12 row = Image.new("RGB", (panel_orig.width * 3 + gap * 2, panel_orig.height), (255, 255, 255)) row.paste(panel_orig, (0, 0)) row.paste(panel_composite, (panel_orig.width + gap, 0)) row.paste(panel_sprite, (panel_orig.width * 2 + gap * 2, 0)) rows.append(row) gap_y = 12 max_w = max(r.width for r in rows) total_h = sum(r.height for r in rows) + gap_y * (len(rows) - 1) combined = Image.new("RGB", (max_w, total_h), (255, 255, 255)) y = 0 for row in rows: combined.paste(row, (0, y)) y += row.height + gap_y scale = min(1.0, 1400 / max(combined.size)) if scale < 1.0: combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS) preview_v2_dir.mkdir(parents=True, exist_ok=True) out_path = preview_v2_dir / "brow-sprite.jpg" combined.save(out_path, "JPEG", quality=92) print(f"저장: {out_path}") # --- 눈꺼풀 선 굵기(lineScale) ------------------------------------------------- def measure_upper_lid_thickness(eye: dict, front_lum: np.ndarray) -> tuple[float, int]: """눈 폭(innerCorner~outerCorner) 가운데 60% 열마다, upperLidTop.y-12에서 홍채 중심 y 사이의 첫 연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다.""" inner_x = eye["innerCorner"][0] outer_x = eye["outerCorner"][0] x_lo, x_hi = sorted([inner_x, outer_x]) width = x_hi - x_lo margin = (1.0 - UPPER_LID_COLUMN_MIDDLE_FRAC) / 2.0 * width col_lo = x_lo + margin col_hi = x_hi - margin y_top = eye["upperLidTop"][1] - UPPER_LID_WINDOW_ABOVE_PX y_bottom = eye["iris"]["center"][1] lengths: list[int] = [] for cx in range(int(round(col_lo)), int(round(col_hi)) + 1): col = front_lum[int(round(y_top)):int(round(y_bottom)), cx] dark = col < UPPER_LID_DARK_LUM_THRESH idx = np.where(dark)[0] if len(idx) == 0: continue start = int(idx[0]) length = 1 i = start while i + 1 < len(dark) and dark[i + 1]: length += 1 i += 1 lengths.append(length) if not lengths: return float("nan"), 0 return float(np.median(lengths)), len(lengths) # --- 홍채 실측(T3) ------------------------------------------------------------- def _contiguous_dark_run(values: np.ndarray, center_idx: int, thresh: float) -> tuple[int, int] | None: """1차원 휘도 배열에서 center_idx를 포함하는 연속 어두운(값= n or values[center_idx] >= thresh: return None s = center_idx while s - 1 >= 0 and values[s - 1] < thresh: s -= 1 e = center_idx while e + 1 < n and values[e + 1] < thresh: e += 1 return s, e def measure_iris(eye: dict, front_lum: np.ndarray, side_label: str) -> dict: """홍채(잉크 원판)의 실측 중심·반지름. 세로축은 윗눈꺼풀 잉크와 홍채가 같은 톤으로 이어져(판화 특성상 경계가 없다) 못 가르므로, 가로축(중심 y 부근 행마다 중심 x를 포함하는 연속 어두운 구간)만으로 반지름·중심 x를 재고, 중심 x 부근 열마다 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝(중앙값)에서 그 가로 반지름을 빼 중심 y를 역산한다(원이라고 가정).""" cx, cy = eye["iris"]["center"] cxi, cyi = int(round(cx)), int(round(cy)) lefts: list[int] = [] rights: list[int] = [] for dy in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1): row = front_lum[cyi + dy, :] seg = _contiguous_dark_run(row, cxi, IRIS_DARK_LUM_THRESH) if seg is not None: lefts.append(seg[0]) rights.append(seg[1]) if not lefts: raise SystemExit(f"[중단] {side_label} 홍채 가로 실측 — 중심 부근 행에서 어두운 구간을 찾지 못했다.") left = float(np.median(lefts)) right = float(np.median(rights)) radius = (right - left) / 2.0 measured_cx = (right + left) / 2.0 bottoms: list[int] = [] for dx in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1): col = front_lum[:, cxi + dx] seg = _contiguous_dark_run(col, cyi, IRIS_DARK_LUM_THRESH) if seg is not None: bottoms.append(seg[1]) if not bottoms: raise SystemExit(f"[중단] {side_label} 홍채 세로 실측 — 중심 부근 열에서 어두운 구간을 찾지 못했다.") bottom = float(np.median(bottoms)) measured_cy = bottom - radius rig_radius = float(eye["iris"]["radius"]) print( f" [{side_label}] 홍채 실측: center=({measured_cx:.1f},{measured_cy:.1f}) radius={radius:.2f} " f"(리그 center=({cx:.1f},{cy:.1f}) radius={rig_radius:.2f}, 차이 radius={radius - rig_radius:+.2f}, " f"표본 가로 {len(lefts)}행/세로 {len(bottoms)}열)" ) return { "measuredCenter": [round(measured_cx, 2), round(measured_cy, 2)], "measuredRadius": round(radius, 2), "rigCenter": [round(cx, 2), round(cy, 2)], "rigRadius": round(rig_radius, 2), "nRowsHorizontal": len(lefts), "nColsVertical": len(bottoms), } def main(persona_dir: Path) -> int: cfg = load_persona_config(persona_dir) base_dir = cfg.base_dir layers_v2_dir = cfg.layers_v2_dir preview_v2_dir = cfg.preview_v2_dir manifest_path = cfg.manifest_path manifest = json.loads(manifest_path.read_text(encoding="utf-8")) lm = manifest["landmarks"] canvas_w = manifest["canvas"]["w"] canvas_h = manifest["canvas"]["h"] front_rgb = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64) faceless_rgb = np.array(build_padded_faceless(base_dir)).astype(np.float64) front_lum = front_rgb.mean(axis=2) faceless_lum = faceless_rgb.mean(axis=2) diff = np.clip(faceless_lum - front_lum, 0.0, None) browsprite_report: dict | None = None if cfg.brow_sprite_from_art: print("=== 눈썹 스프라이트 ===") bc = manifest.get("browCenterline") if bc is None: raise SystemExit("[중단] manifest.browCenterline이 없다 — brow_centerline.py를 먼저 실행하라.") half_width_by_side = {"left": bc["halfWidthLeft"], "right": bc["halfWidthRight"]} eyebrow_by_side = {"left": lm["eyebrowLeft"], "right": lm["eyebrowRight"]} hairfront_path = layers_v2_dir / "hairFront.png" if not hairfront_path.exists(): raise SystemExit(f"[중단] {hairfront_path}가 없다 — layers_v2.py를 먼저 실행하라.") hairfront_alpha = np.array(Image.open(hairfront_path).convert("RGBA"))[..., 3].astype(np.float64) if hairfront_alpha.shape != (canvas_h, canvas_w): raise SystemExit( f"[중단] layers/v2/hairFront.png 크기({hairfront_alpha.shape[1]}x{hairfront_alpha.shape[0]})가 " f"캔버스({canvas_w}x{canvas_h})와 다르다." ) hair_mask = ndimage.binary_dilation(hairfront_alpha > HAIR_ALPHA_THRESH, iterations=HAIR_DILATE_ITER) print(f"머리카락 마스크(hairFront 알파>{HAIR_ALPHA_THRESH}, {HAIR_DILATE_ITER}px 팽창) 픽셀 = {int(hair_mask.sum())}") sprites: dict[str, dict] = {} for side in ("left", "right"): eb = eyebrow_by_side[side] sprites[side] = build_one_brow_sprite( tuple(eb["inner"]), tuple(eb["peak"]), tuple(eb["outer"]), tuple(half_width_by_side[side]), front_rgb, faceless_rgb, front_lum, faceless_lum, diff, hair_mask, canvas_w, canvas_h, side, ) composite_rgb = composite_with_sprites(layers_v2_dir, canvas_w, canvas_h, sprites) diffs: dict[str, float] = {} for side, data in sprites.items(): d = np.abs(composite_rgb - front_rgb).mean(axis=2) # 2차 보정: 띠 전체가 아니라 스프라이트 알파>0 영역(region)만 잰다 — 알파=0인 # 띠 가장자리 피부 픽셀(원래도 거의 차이가 없다)이 섞여 들어가 잉크 알파가 # 옅은 문제를 가리던 것을 바로잡는다(목표 <=10). diffs[side] = float(d[data["region"]].mean()) print(f" 검사 [{side}] 중립 합성(스프라이트 포함) vs 원화, 눈썹 알파>0 영역 평균절대차 = {diffs[side]:.3f}") _save_evidence(front_rgb, sprites, composite_rgb, preview_v2_dir) layers_v2_dir.mkdir(parents=True, exist_ok=True) side_reports: dict[str, dict] = {} canvas_origin: dict[str, list[float]] = {} for side, data in sprites.items(): png_arr, bbox = crop_to_alpha_bbox(data["alpha"], to_u8(data["rgb"])) out_path = layers_v2_dir / f"brow-{side}.png" Image.fromarray(png_arr, "RGBA").save(out_path) file_size = out_path.stat().st_size canvas_origin[side] = [float(bbox[0]), float(bbox[1])] print(f"저장: {out_path} bbox(캔버스 절대좌표)={bbox} size={png_arr.shape[1]}x{png_arr.shape[0]} ({file_size} bytes)") side_reports[side] = { "bboxCanvas": list(bbox), "halfHeightPx": round(data["halfHeight"], 3), "otsuThresh": data["otsuThresh"], "lSkin": round(data["lSkin"], 3), "lInk": round(data["lInk"], 3), "regionPixelCount": data["regionPixelCount"], "hairOverlapPixelCount": data["hairOverlapPx"], "neutralCompositeMeanAbsDiffAlphaRegion": round(diffs[side], 3), "pngFileSizeBytes": file_size, } browsprite_report = { "enabled": True, "bandExtraHalfHeightPx": BAND_EXTRA_HALF_HEIGHT_PX, "bandEndExtendPx": BAND_END_EXTEND_PX, "diffThresh": DIFF_THRESH, "closingKernel": list(CLOSING_KERNEL), "minComponentAreaPx": MIN_COMPONENT_AREA, "hairSource": "layers/v2/hairFront.png alpha(게시되는 레이어 그 자체, 분할 카테고리 아님)", "hairAlphaThresh": HAIR_ALPHA_THRESH, "hairDilateIterations": HAIR_DILATE_ITER, "rampPx": RAMP_PX, "inkLumMethod": "median(ink-classified pixels in band, region after hair removal)", "minAlphaForUnmix": MIN_ALPHA_FOR_UNMIX, "hairMaskPixelCount": int(hair_mask.sum()), "left": side_reports["left"], "right": side_reports["right"], "canvasOrigin": canvas_origin, "evidenceImage": "preview/v2/brow-sprite.jpg", } else: print("browSpriteFromArt=false — 눈썹 스프라이트를 건너뛴다.") browsprite_report = {"enabled": False} eye_line_scale_report: dict | None = None if cfg.eye_line_scale_from_art: print("=== 눈꺼풀 선 굵기(lineScale) ===") side_reports = {} for side, key in (("left", "eyeLeft"), ("right", "eyeRight")): eye = lm[key] measured_px, n_cols = measure_upper_lid_thickness(eye, front_lum) if n_cols == 0: raise SystemExit(f"[중단] {key} 윗눈꺼풀 두께 측정 열이 하나도 없다(모든 열에서 lum<80 구간을 못 찾음).") ratio = measured_px / P1_UPPER_LID_THICKNESS_PX line_scale = float(np.clip(ratio, LINE_SCALE_MIN, LINE_SCALE_MAX)) print( f" [{key}] 측정={measured_px:.3f}px(열 {n_cols}개) / P1기준={P1_UPPER_LID_THICKNESS_PX}px " f"= 비율 {ratio:.3f} -> lineScale={line_scale:.3f}" + (" [clamp 적용됨]" if not (LINE_SCALE_MIN <= ratio <= LINE_SCALE_MAX) else "") ) side_reports[side] = { "measuredPx": round(measured_px, 3), "nColumns": n_cols, "ratio": round(ratio, 3), "lineScale": round(line_scale, 3), } eye_line_scale_report = { "enabled": True, "method": "눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채중심y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값", "windowAbovePx": UPPER_LID_WINDOW_ABOVE_PX, "darkLumThresh": UPPER_LID_DARK_LUM_THRESH, "columnMiddleFrac": UPPER_LID_COLUMN_MIDDLE_FRAC, "p1BaselinePx": P1_UPPER_LID_THICKNESS_PX, "lineScaleClampRange": [LINE_SCALE_MIN, LINE_SCALE_MAX], "left": side_reports["left"], "right": side_reports["right"], } else: print("eyeLineScaleFromArt=false — 눈꺼풀 lineScale을 건너뛴다.") eye_line_scale_report = {"enabled": False} # 홍채 실측(T3)은 browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상 재 # manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 설정과 무관하게 항상 # 기록" 원칙). export_rig.py가 persona.json irisFromArt(기본 true)일 때만 쓴다. print("=== 홍채 실측(T3) ===") iris_report = { "method": ( "가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 " "중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 " "어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 " "홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다)." ), "darkLumThresh": IRIS_DARK_LUM_THRESH, "scanBandPx": IRIS_SCAN_BAND_PX, "left": measure_iris(lm["eyeLeft"], front_lum, "eyeLeft"), "right": measure_iris(lm["eyeRight"], front_lum, "eyeRight"), } manifest["browSprite"] = browsprite_report manifest["eyeLineScale"] = eye_line_scale_report manifest["iris"] = iris_report manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(f"manifest.json 갱신(browSprite, eyeLineScale, iris): {manifest_path}") return 0 if __name__ == "__main__": sys.exit(main(Path(sys.argv[1])))