"""P1 서연 입술 결 스프라이트 생성 — 2단계-B-1d-A5(작업 패킷 A5, §8.4 2026-10-01 개정 반영). 원칙(결정문 §8.4 개정): **중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면 원화 픽셀이 변형된다.** 입술 모양은 스프라이트 알파가 정하고, 렌더러는 스프라이트를 세로 띠로 변형만 한다. A2'(half 분할 + 6px 최근접 복제 확장)는 렌더러가 벡터 입술 윤곽으로 결을 잘라내자 윗입술이 뾰족해지고 입꼬리가 바늘처럼 튀어나오고 아랫입술 그늘이 성긴 빗금이 되는 문제를 낳았다 — 원인은 확장 영역이 좁고(6px) 입꼬리 홈· 입 선 전체·아랫입술 그늘이 벡터 윤곽 안에 다 들어가지 못한 것. A3 재설계: - 스프라이트 영역 R = 입술 색 마스크를 10px 넓힌 영역. 마스크 계산 자체(Lab a채널 Otsu AND front가 faceless보다 어두움, 캡 bbox 안에서 계산, 구멍 메움·작은 조각 제거)는 그대로. - R 안은 **원화 픽셀 그대로**다(복제·반사·메우기·블러 금지). 알파만 R 경계 안쪽 4px에서 0→255 선형 램프, 그 안은 255(거리 변환으로 계산). - 중립 입 선 곡선 Cu0(mouthCornerLeft·mouthCenter·mouthCornerRight 2차 곡선)로 R을 위/아래로 나눈다(윗 스프라이트 y<=Cu0(x), 아랫 스프라이트 y>Cu0(x)). 이 분할은 R의 외곽 알파 램프와 무관하므로(램프는 R 전체 기준 거리 변환 한 번으로만 계산하고 분할선은 그 값을 그대로 나눠 쓸 뿐) 나누는 경계에는 알파 램프가 없다 — 두 장을 원래 자리에 도로 놓으면 램프 없이 딱 맞붙어 원화가 된다. Cu0의 x 범위(mouthCornerLeft.x~mouthCornerRight.x) 밖은 양끝 접선으로 연장한다. A4 수정(2026-10-01, 렌더 비교 반려): 화면 배율로 재샘플링되면서 두 스프라이트가 Cu0에서 딱 맞닿는 이음매가 가는 밝은 실금으로 보였다(경계 알파가 재샘플링으로 반투명해져 밑 피부가 비침). 윗 스프라이트를 y<=Cu0(x)+2(분할선 아래 2px)까지 겹쳐 담아 이음매를 없앤다 — 겹침 2px는 같은 원화 픽셀·같은 알파이고, 렌더러가 아랫 스프라이트를 먼저 그리고 윗 스프라이트를 그 위에 덮어 그리므로(§8.4) 중립 합성 결과는 바뀌지 않는다. 아랫 스프라이트·알파 램프 규칙은 그대로. A5 재설계(작업 패킷 A5, 2026-10-01): 아랫입술 스프라이트에 붙어 있던 아래 18px 그늘 띠를 뺀다 — out5 판정(§8.4): 턱 변위 띠와 함께 옮기지 않으면 ㅗ에서 입이 좁아질 때 그늘이 W/W0로 같이 압축돼 짙은 직사각형이 됐다. 턱 피부 그늘은 입이 좁아져도 줄지 않아야 한다. 그늘은 별도 조각 `lip-shadow`로 뗀다: - 아랫입술 마스크(combined_work & d>0) 아래 경계에서 열마다 18px 띠, 원화 픽셀 그대로(복제·메우기 없음, 없는 열은 생략). - 알파: 아래·옆(열이 끊기는 곳)은 6px 선형 램프. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고(아랫 스프라이트와 같은 원화 픽셀 원본이므로 겹침 알파 값과 무관하게 두 장을 겹쳐 그려도 원화와 같다 — A4의 2px 분할선 겹침과 같은 원리), 그 6px 안에서 4px 램프를 쓴다(겹침 나머지 2px는 이미 마스크 깊숙한 안쪽이라 풀 알파). - 중립에서 (F + 아랫 스프라이트 + 그늘 조각 + 윗 스프라이트)를 §8.4 그리는 순서 그대로(아랫 → 그늘 → 윗) 합성하면 원화와 같아야 한다. 실행: /python.exe build_lip_texture.py """ from __future__ import annotations import json import sys from pathlib import Path import cv2 import numpy as np from PIL import Image from scipy import ndimage SCRIPTS_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(SCRIPTS_DIR)) from export_rig import detect_face_landmarks, compute_mouth_center # 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" DIFF_THRESH = 20.0 MIN_COMPONENT_AREA = 30.0 DILATE_PX = 10.0 # 입술 색 마스크 확장 폭(유클리드 거리) — 윗/아랫 스프라이트 R RAMP_PX = 4.0 # R 경계 안쪽 알파 선형 램프 폭(윗/아랫 스프라이트) SPLIT_OVERLAP_PX = 2.0 # 윗 스프라이트가 분할선(Cu0) 아래로 겹쳐 담는 폭(이음매 방지, A4) BAND_PX = 18.0 # 그늘 조각: 아랫입술 마스크 아래 그늘 띠 폭 SHADOW_TOP_OVERLAP_PX = 6.0 # 그늘 조각: 아랫입술 마스크 경계보다 위로 겹쳐 담는 폭 SHADOW_SIDE_RAMP_PX = 6.0 # 그늘 조각: 아래·옆(열 끊김) 알파 선형 램프 폭 SHADOW_TOP_RAMP_PX = 4.0 # 그늘 조각: 위쪽 겹침 구간 안의 알파 선형 램프 폭 WORKING_MARGIN = 40.0 # 확장(10px)+그늘 띠(18px)+여유를 위한 작업 캔버스 여백 SIZE_CAP_W = 162.0 SIZE_CAP_H = 70.0 EVIDENCE_MAX_SIDE = 1400 EVIDENCE_ZOOM = 3 EVIDENCE_PAD = 8 LM: dict = {} def curve_fit(lc, mc, rc) -> np.poly1d: coef = np.polyfit([lc[0], mc[0], rc[0]], [lc[1], mc[1], rc[1]], 2) return np.poly1d(coef) def extended_curve(cu: np.poly1d, x_lo: float, x_hi: float, xs: np.ndarray) -> np.ndarray: """Cu0을 [x_lo, x_hi] 안에서는 그대로, 밖에서는 양끝 접선으로 연장한 y값.""" deriv = cu.deriv() y = cu(xs) y_lo = cu(x_lo) + deriv(x_lo) * (xs - x_lo) y_hi = cu(x_hi) + deriv(x_hi) * (xs - x_hi) y = np.where(xs < x_lo, y_lo, y) y = np.where(xs > x_hi, y_hi, y) return y def crop_to_alpha_bbox(alpha: np.ndarray, rgb: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]: ys, xs = np.where(alpha > 0) x0, x1 = int(xs.min()), int(xs.max()) + 1 y0, y1 = int(ys.min()), int(ys.max()) + 1 out = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.uint8) out[..., :3] = rgb[y0:y1, x0:x1] out[..., 3] = alpha[y0:y1, x0:x1] return out, (x0, y0, x1 - x0, y1 - y0) def main() -> int: manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) lm = manifest["landmarks"] global LM LM["Lc"] = tuple(lm["mouthCornerLeft"]) LM["Rc"] = tuple(lm["mouthCornerRight"]) LM["T"] = tuple(lm["upperLipTopCenter"]) LM["B"] = tuple(lm["lowerLipBottomCenter"]) points = detect_face_landmarks(BASE_DIR / "base-front.png") mouth_center = compute_mouth_center(points) LM["Mc"] = mouth_center print(f"mouthCenter(재계산) = {mouth_center}") lc, rc, t, b, mc = LM["Lc"], LM["Rc"], LM["T"], LM["B"], LM["Mc"] cu = curve_fit(lc, mc, rc) x_lo, x_hi = (lc[0], rc[0]) if lc[0] <= rc[0] else (rc[0], lc[0]) margin_x = (SIZE_CAP_W - (rc[0] - lc[0])) / 2.0 margin_y = (SIZE_CAP_H - (b[1] - t[1])) / 2.0 cap_x0, cap_x1 = lc[0] - margin_x, rc[0] + margin_x cap_y0, cap_y1 = t[1] - margin_y, b[1] + margin_y print(f"입술 마스크 검색 bbox(캡) = [{cap_x0:.1f},{cap_y0:.1f},{cap_x1:.1f},{cap_y1:.1f}] " f"w={cap_x1-cap_x0:.1f}(<=162) h={cap_y1-cap_y0:.1f}(<=70)") icx0, icy0, icx1, icy1 = (int(round(cap_x0)), int(round(cap_y0)), int(round(cap_x1)), int(round(cap_y1))) wx0 = icx0 - int(WORKING_MARGIN) wy0 = icy0 - int(WORKING_MARGIN) wx1 = icx1 + int(WORKING_MARGIN) wy1 = icy1 + int(WORKING_MARGIN) front_full = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")) faceless_full = np.array(Image.open(BASE_DIR / "base-faceless-padded.png").convert("RGB")).astype(np.float64) front_work = front_full[wy0:wy1, wx0:wx1] faceless_work = faceless_full[wy0:wy1, wx0:wx1] front_lum_full = front_full.astype(np.float64).mean(axis=2) faceless_lum_full = faceless_full.mean(axis=2) diff_full = np.clip(faceless_lum_full - front_lum_full, 0.0, None) # --- 입술 색+diff 마스크: 캡 bbox 안에서만 계산, 작업 캔버스에 배치(마스크 계산은 A2'와 동일) --- cap_rgb = front_full[icy0:icy1, icx0:icx1] lab = cv2.cvtColor(cap_rgb, cv2.COLOR_RGB2LAB) a_chan = lab[..., 1].astype(np.float64) u8 = np.clip(np.round(a_chan), 0, 255).astype(np.uint8) otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) lip_color_mask_cap = a_chan >= otsu_thresh diff_cap = diff_full[icy0:icy1, icx0:icx1] diff_mask_cap = diff_cap >= DIFF_THRESH combined_cap = lip_color_mask_cap & diff_mask_cap filled_cap = ndimage.binary_fill_holes(combined_cap) lbl, n = ndimage.label(filled_cap) sizes = ndimage.sum(filled_cap, lbl, range(1, n + 1)) if n else np.array([]) keep = np.isin(lbl, [i + 1 for i, s in enumerate(sizes) if s >= MIN_COMPONENT_AREA]) if n else np.zeros_like(lbl, dtype=bool) combined_cap = filled_cap & keep print(f"otsu(Lab a채널, 캡 bbox) = {otsu_thresh}, 입술 색 마스크 픽셀 = {int(combined_cap.sum())}") combined_work = np.zeros((wy1 - wy0, wx1 - wx0), dtype=bool) combined_work[icy0 - wy0:icy1 - wy0, icx0 - wx0:icx1 - wx0] = combined_cap h, w = combined_work.shape # --- 중립 입 선 곡선 Cu0(연장) → 위/아래 부호 d --- xs_abs = np.arange(wx0, wx1, dtype=np.float64) cu0_y = extended_curve(cu, x_lo, x_hi, xs_abs) # (w,) rows_local = np.arange(h)[:, None] yy_abs = rows_local + wy0 d = yy_abs - cu0_y[None, :] # d<=0: 윗입술 쪽, d>0: 아랫입술 쪽 # --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, A5부터 그늘 띠 제외) --- dist_out = ndimage.distance_transform_edt(~combined_work) region = combined_work | (dist_out <= DILATE_PX) print(f"R(확장, 윗/아랫 스프라이트) 픽셀 = {int(region.sum())}") if region[0, :].any() or region[-1, :].any() or region[:, 0].any() or region[:, -1].any(): raise SystemExit("[중단] R이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.") # --- 알파: R 기준 거리 변환 1회 → 분할선과 무관한 램프 --- dist_in = ndimage.distance_transform_edt(region) alpha_frac = np.clip(dist_in / RAMP_PX, 0.0, 1.0) alpha_full = np.rint(alpha_frac * 255.0).astype(np.uint8) alpha_full[~region] = 0 # 윗 스프라이트는 분할선(Cu0) 아래로 SPLIT_OVERLAP_PX만큼 겹쳐 담는다(이음매 방지, A4). # 아랫 스프라이트는 그대로 d>0. 겹침 구간(0 0) alpha_upper = np.where(mask_upper, alpha_full, 0).astype(np.uint8) alpha_lower = np.where(mask_lower, alpha_full, 0).astype(np.uint8) overlap = mask_upper & mask_lower overlap_expected = region & (d > 0) & (d <= SPLIT_OVERLAP_PX) overlap_band_ok = bool(np.array_equal(overlap, overlap_expected)) overlap_alpha_diff = int(np.abs(alpha_upper[overlap].astype(np.int32) - alpha_lower[overlap].astype(np.int32)).max()) if overlap.any() else 0 overlap_consistent_ok = overlap_alpha_diff == 0 coverage_mismatch = region ^ (mask_upper | mask_lower) split_ok = bool(overlap_band_ok and overlap_consistent_ok and coverage_mismatch.sum() == 0) print(f"검사(2) 겹침 픽셀수={int(overlap.sum())}(전부 Cu0 아래 {SPLIT_OVERLAP_PX}px 띠 안={overlap_band_ok}) " f"겹침 알파 불일치 최대차={overlap_alpha_diff} 틈(불일치) 픽셀수={int(coverage_mismatch.sum())} " f"{'OK' if split_ok else '[실패]'}") # --- 그늘 조각(lip-shadow, A5): 아랫입술 마스크(combined_work & d>0) 아래 경계에서 # 열마다 18px 띠. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고, 그 6px 안에서만 4px # 램프를 쓴다. 아래·옆(열이 끊기는 곳)은 6px 램프다. --- lower_lip_mask = combined_work & (d > 0) masked_rows = np.where(lower_lip_mask, rows_local, -1) y_bottom_local = masked_rows.max(axis=0) # (w,), 마스크 없는 열은 -1 has_col = y_bottom_local >= 0 shadow_top_local = y_bottom_local.astype(np.float64) - SHADOW_TOP_OVERLAP_PX # (w,) 그늘 조각 윗 경계(제외) shadow_bottom_local = y_bottom_local.astype(np.float64) + BAND_PX # (w,) 그늘 조각 아래 경계(포함) shadow_region = ( has_col[None, :] & (rows_local > shadow_top_local[None, :]) & (rows_local <= shadow_bottom_local[None, :]) ) print(f"그늘 조각 영역 픽셀 = {int(shadow_region.sum())} (열 수 {int(has_col.sum())})") if shadow_region[0, :].any() or shadow_region[-1, :].any() or shadow_region[:, 0].any() or shadow_region[:, -1].any(): raise SystemExit("[중단] 그늘 조각이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.") # 아래·옆 램프: 위쪽 경계를 두지 않은(열이 있으면 아래 경계까지는 전부 포함) 영역을 # 따로 거리 변환해, 옆(열이 끊기는 곳)·아래 가장자리에서만 램프가 생기게 한다. outer_calc_region = has_col[None, :] & (rows_local <= shadow_bottom_local[None, :]) dist_in_outer = ndimage.distance_transform_edt(outer_calc_region) alpha_outer_frac = np.clip(dist_in_outer / SHADOW_SIDE_RAMP_PX, 0.0, 1.0) # 위쪽 겹침 램프: 그늘 조각 윗 경계(마스크 경계 6px 위)에서 4px에 걸쳐 0→1. dist_from_top = rows_local - shadow_top_local[None, :] alpha_top_frac = np.clip(dist_from_top / SHADOW_TOP_RAMP_PX, 0.0, 1.0) alpha_shadow_frac = np.minimum(alpha_outer_frac, alpha_top_frac) alpha_shadow = np.where(shadow_region, np.rint(alpha_shadow_frac * 255.0), 0).astype(np.uint8) sprites = { "upper": {"alpha": alpha_upper, "rgb": front_work, "mask": mask_upper}, "lower": {"alpha": alpha_lower, "rgb": front_work, "mask": mask_lower}, "shadow": {"alpha": alpha_shadow, "rgb": front_work, "mask": shadow_region}, } LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True) checks: dict = {} file_sizes_png = {} canvas_origin: dict[str, list[float]] = {} for half, key in (("upper", "lip-upper"), ("lower", "lip-lower"), ("shadow", "lip-shadow")): s = sprites[half] png_arr, bbox = crop_to_alpha_bbox(s["alpha"], s["rgb"]) out_path = LAYERS_V2_DIR / f"{key}.png" Image.fromarray(png_arr, "RGBA").save(out_path) file_sizes_png[key] = out_path.stat().st_size canvas_origin[half] = [round(wx0 + bbox[0], 1), round(wy0 + bbox[1], 1)] print(f"저장: {out_path} bbox(작업캔버스 로컬)={bbox} 캔버스원점={canvas_origin[half]} size={png_arr.shape[1]}x{png_arr.shape[0]}") # --- 검사(1) 알파 255 영역의 PNG 픽셀 = base-front(원화 픽셀을 지우거나 채우지 않았으므로 항상 0) --- full_op = s["alpha"] == 255 ramp = (s["alpha"] > 0) & (s["alpha"] < 255) diff_full_op = np.abs(s["rgb"][full_op].astype(np.int32) - front_work[full_op].astype(np.int32)) max_diff_full_op = int(diff_full_op.max()) if full_op.any() else 0 check1 = max_diff_full_op == 0 print(f" 검사(1) [{key}] 알파255 영역 PNG 최대차={max_diff_full_op} " f"(알파255 {int(full_op.sum())}px, 램프 {int(ramp.sum())}px) {'OK' if check1 else '[실패]'}") checks[key] = { "regionPixelCount": int(s["mask"].sum()), "fullOpacityPixelCount": int(full_op.sum()), "rampPixelCount": int(ramp.sum()), "preserveMaxDiffFullOpacity": max_diff_full_op, "check1Preserve": bool(check1), "spriteBBoxWorkingLocal": list(bbox), } # --- 검사(3) 중립 합성: base-faceless-padded 위에 아랫 → 그늘 → 윗 스프라이트 순으로 # 알파 합성한다(§8.4 그리는 순서와 같게) 대 base-front. --- front_f64 = front_work.astype(np.float64) alpha_lower_frac = (alpha_lower.astype(np.float64) / 255.0)[..., None] step_lower = faceless_work * (1.0 - alpha_lower_frac) + front_f64 * alpha_lower_frac alpha_shadow_frac3 = (alpha_shadow.astype(np.float64) / 255.0)[..., None] step_shadow = step_lower * (1.0 - alpha_shadow_frac3) + front_f64 * alpha_shadow_frac3 alpha_upper_frac = (alpha_upper.astype(np.float64) / 255.0)[..., None] composite = step_shadow * (1.0 - alpha_upper_frac) + front_f64 * alpha_upper_frac # 실효 알파(세 장을 겹쳐 그린 뒤 실제로 얼마나 덮였는지, over-over-over 합성식) — 보고용 분류 기준. eff_alpha = 255.0 - ( (255.0 - alpha_upper.astype(np.float64)) * (255.0 - alpha_shadow.astype(np.float64)) * (255.0 - alpha_lower.astype(np.float64)) / (255.0 ** 2) ) cap_sl = (slice(icy0 - wy0, icy1 - wy0), slice(icx0 - wx0, icx1 - wx0)) eff_alpha_cap = eff_alpha[cap_sl] diff_cap = np.abs(composite[cap_sl] - front_f64[cap_sl]) full_op_cap = eff_alpha_cap >= 254.999 ramp_cap = (eff_alpha_cap > 0) & (eff_alpha_cap < 254.999) max_diff_full_op_cap = float(diff_cap[full_op_cap].max()) if full_op_cap.any() else 0.0 mean_diff_ramp_cap = float(diff_cap[ramp_cap].mean()) if ramp_cap.any() else 0.0 mean_diff_all_cap = float(diff_cap.mean()) print(f"검사(3) 중립 합성(아랫->그늘->윗) 대 base-front 대비 알파255 최대차={max_diff_full_op_cap:.3f} " f"램프 평균차={mean_diff_ramp_cap:.3f} 입bbox 전체 평균차={mean_diff_all_cap:.3f}") # --- 검사(패킷 A5 목표 5-3) 그늘 조각과 아랫 스프라이트의 겹침 영역: (F+아랫+그늘) 합성이 원화와 같다 --- overlap_shadow_lower = (alpha_shadow > 0) & (alpha_lower > 0) diff_overlap_arr = np.abs(step_shadow - front_f64).max(axis=2) diff_overlap = diff_overlap_arr[overlap_shadow_lower] max_diff_overlap = float(diff_overlap.max()) if overlap_shadow_lower.any() else 0.0 diff_overlap_gt1_count = int((diff_overlap > 1.0).sum()) shadow_lower_overlap_ok = max_diff_overlap == 0.0 print(f"검사(5-3) 그늘·아랫 겹침 픽셀수={int(overlap_shadow_lower.sum())} " f"(F+아랫+그늘) 대 원화 최대차={max_diff_overlap:.3f} diff>1 픽셀수={diff_overlap_gt1_count} " f"{'OK' if shadow_lower_overlap_ok else '[실패, 원인은 manifest 참고]'}") total_png = sum(file_sizes_png.values()) print(f"PNG 파일 크기 합계(참고, WebP 아님) = {total_png} bytes") _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow) manifest["lipTexture"] = { "designVersion": "A5 (2단계-B-1d-A5, 작업 패킷 A5 — 아랫입술 그늘 띠를 lip-shadow 조각으로 분리)", "landmarks": {"mouthCornerLeft": list(lc), "mouthCornerRight": list(rc), "upperLipTop": list(t), "lowerLipBottom": list(b), "mouthCenter": list(mc)}, "capBBox": [round(cap_x0, 2), round(cap_y0, 2), round(cap_x1, 2), round(cap_y1, 2)], "otsuThreshLabA": otsu_thresh, "diffThresh": DIFF_THRESH, "dilatePx": DILATE_PX, "alphaRampPx": RAMP_PX, "splitOverlapPx": SPLIT_OVERLAP_PX, "shadowBandPx": BAND_PX, "shadowTopOverlapPx": SHADOW_TOP_OVERLAP_PX, "shadowSideRampPx": SHADOW_SIDE_RAMP_PX, "shadowTopRampPx": SHADOW_TOP_RAMP_PX, "checks": { **checks, "splitOverlapPixelCount": int(overlap.sum()), "splitOverlapBandOk": overlap_band_ok, "splitOverlapAlphaMaxDiff": overlap_alpha_diff, "splitOverlapConsistentOk": overlap_consistent_ok, "splitCoverageMismatchPixelCount": int(coverage_mismatch.sum()), "splitCheckOk": split_ok, "shadowLowerOverlapPixelCount": int(overlap_shadow_lower.sum()), "shadowLowerOverlapMaxDiff": max_diff_overlap, "shadowLowerOverlapDiffGt1PixelCount": diff_overlap_gt1_count, "shadowLowerOverlapOk": shadow_lower_overlap_ok, "shadowLowerOverlapNote": ( "국소 실패(입꼬리 첨점 부근, diff>1인 픽셀 수 참고). 원인: 두 입꼬리 근처에서 " "원화 입술 색 마스크가 하이라이트로 끊겨 윗/아랫 두 블롭으로 갈라지고, " "기존 Cu0 부호 분할(A3/A4부터의 로직, 이번 작업에서 변경하지 않음)이 그 " "다리 부분을 '윗'으로 분류해 alpha_lower=0이 되는 지점이 생긴다. 그 자리는 " "그늘 조각의 옆(열 끊김) 램프 구간과도 겹쳐 그늘 쪽 알파도 완전 불투명이 " "아니라서 등식이 깨진다. preview/v2/lip-texture.jpg의 원화|중립합성 패널을 " "3배 확대로 육안 확인한 결과 이음매는 보이지 않는다(오케스트레이터 육안 " "무해 판단, 2026-10-01)." ), }, "check3NeutralComposite": { "fullOpacityMaxDiff": round(max_diff_full_op_cap, 3), "rampMeanAbsDiff": round(mean_diff_ramp_cap, 3), "mouthBBoxMeanAbsDiff": round(mean_diff_all_cap, 3), }, "pngFileSizes": file_sizes_png, "canvasOrigin": canvas_origin, "evidenceImage": "preview/v2/lip-texture.jpg", } MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print(f"manifest.json 갱신: {MANIFEST_PATH}") return 0 def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow) -> None: """원화 입 | 중립 합성 | 윗 스프라이트(체커) | 아랫 스프라이트(체커) | 그늘 조각(체커), 3배 확대. bbox는 세 스프라이트 알파>0 영역의 합집합(+pad)에서 구한다 — 10px 확장·그늘 조각(6px 겹침+18px 띠)으로 R이 캡 bbox보다 커졌으므로 고정 pad만으로는 잘릴 수 있다. """ union_alpha = (alpha_upper > 0) | (alpha_lower > 0) | (alpha_shadow > 0) ys_u, xs_u = np.where(union_alpha) ubx0, uby0 = int(xs_u.min()), int(ys_u.min()) ubx1, uby1 = int(xs_u.max()) + 1, int(ys_u.max()) + 1 pad = EVIDENCE_PAD bx0, by0 = max(0, wx0 + ubx0 - pad), max(0, wy0 + uby0 - pad) bx1, by1 = wx0 + ubx1 + pad, wy0 + uby1 + pad def zoom(arr_rgb, nearest=False): crop = arr_rgb[by0:by1, bx0:bx1] im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8)) resample = Image.NEAREST if nearest else Image.LANCZOS return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), resample) panel_orig = zoom(front_full) # composite는 작업 캔버스 크기의 로컬 배열(원점 wx0,wy0)이다 — 절대좌표를 로컬로 옮겨 잘라야 한다. comp_bx0, comp_by0 = bx0 - wx0, by0 - wy0 comp_bx1, comp_by1 = bx1 - wx0, by1 - wy0 panel_composite = Image.fromarray( np.clip(composite[comp_by0:comp_by1, comp_bx0:comp_bx1], 0, 255).astype(np.uint8) ) panel_composite = panel_composite.resize( (panel_composite.width * EVIDENCE_ZOOM, panel_composite.height * EVIDENCE_ZOOM), Image.LANCZOS ) def sprite_on_checker(half): s = sprites[half] alpha = s["alpha"]; rgb = s["rgb"] ys_, xs_ = np.where(alpha > 0) x0, x1 = xs_.min(), xs_.max() + 1 y0, y1 = ys_.min(), ys_.max() + 1 crop_rgb = rgb[y0:y1, x0:x1].astype(np.float64) crop_a = (alpha[y0:y1, x0:x1].astype(np.float64) / 255.0)[..., None] check = np.indices(crop_a.shape[:2]) checker = ((check[0] // 6 + check[1] // 6) % 2) * 60 + 180 checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64) out = crop_rgb * crop_a + checker3 * (1.0 - crop_a) im = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)) return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), Image.NEAREST) panel_upper = sprite_on_checker("upper") panel_lower = sprite_on_checker("lower") panel_shadow = sprite_on_checker("shadow") panels = [panel_orig, panel_composite, panel_upper, panel_lower, panel_shadow] gap = 12 max_h = max(p.height for p in panels) total_w = sum(p.width for p in panels) + gap * (len(panels) - 1) combined = Image.new("RGB", (total_w, max_h), (255, 255, 255)) x = 0 for p in panels: combined.paste(p.convert("RGB"), (x, 0)) x += p.width + gap scale = min(1.0, EVIDENCE_MAX_SIDE / 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 / "lip-texture.jpg" combined.save(out_path, "JPEG", quality=92) print(f"저장: {out_path}") if __name__ == "__main__": sys.exit(main())