"""P1 서연 턱 조각(jaw-head·jaw-detail) 생성 — 2단계-B, 결정문 §8.4 하관 띠 변형 (작업 패킷 A5). 하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는 변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 1005×1566 head·faceDetail 이미지를 매 프레임 다시 그리면 비용이 크므로(out5 실측: long task 14/7.3s), 변형이 실제로 필요한 사각형만 미리 잘라 둔다. 영역(결정문 §8.4, 작업 패킷 A5): - y: noseTip.y − 50 − 10 부터 chinTip.y + 40 까지. - x: 얼굴 윤곽(faceOval, p1Rig.ts에 이미 게시된 값과 같은 36점 루프)을 바깥으로 18px 넓힌 다각형의, 이 y 범위 안에서의 가로 범위(렌더러가 각 띠를 그 다각형으로 clip하므로, 이 y 범위를 벗어난 곳의 폭은 필요 없다 — 재는 방식은 아래 참고). - 변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라 layers/v2/jaw-{head,detail}.png로 저장한다. 게시본은 무손실 WebP(exact=True)다 (export_rig.py의 publish_jaw_pieces). 가로 범위 측정: 얼굴 윤곽 다각형을 캔버스 크기 마스크로 채우고(cv2.fillPoly) 유클리드 거리 변환으로 18px 확장한 뒤(build_lip_texture.py의 DILATE_PX와 같은 기법), y 범위로 제한한 행들에서 열이 하나라도 켜진 범위를 x0~x1로 쓴다. (참고: 이 y 범위 안에서는 다각형 경계가 거의 수직에 가까워, 단순히 "y 범위 안 원본 다각형의 x0/x1에 18을 더/ 빼는" 결과와 사실상 같다 — 두 방식을 대조해 확인했다.) 실행: /python.exe build_jaw_pieces.py """ from __future__ import annotations import io import json import math 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_face_oval # 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" NOSE_TO_TOP_PX = 50.0 + 10.0 # y0 = noseTip.y - (이 값) CHIN_TO_BOTTOM_PX = 40.0 # y1 = chinTip.y + (이 값) OVAL_OUTSET_PX = 18.0 # 얼굴 윤곽을 바깥으로 넓히는 폭 def main() -> int: manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8")) lm = manifest["landmarks"] nose_tip = tuple(lm["noseTip"]) chin_tip = tuple(lm["chinTip"]) print(f"noseTip={nose_tip} chinTip={chin_tip}") points = detect_face_landmarks(BASE_DIR / "base-front.png") face_oval = compute_face_oval(points) canvas_w, canvas_h = manifest["canvas"]["w"], manifest["canvas"]["h"] y0f = nose_tip[1] - NOSE_TO_TOP_PX y1f = chin_tip[1] + CHIN_TO_BOTTOM_PX y0i, y1i = int(math.floor(y0f)), int(math.ceil(y1f)) print(f"y 범위 = [{y0f:.2f}, {y1f:.2f}] -> 정수 [{y0i}, {y1i}) (h={y1i-y0i})") oval_mask = np.zeros((canvas_h, canvas_w), dtype=np.uint8) oval_pts = np.array([[round(p[0]), round(p[1])] for p in face_oval], dtype=np.int32) cv2.fillPoly(oval_mask, [oval_pts], 1) oval_mask_b = oval_mask.astype(bool) dist_out = ndimage.distance_transform_edt(~oval_mask_b) dilated = oval_mask_b | (dist_out <= OVAL_OUTSET_PX) if y0i <= 0 or y1i >= canvas_h: raise SystemExit(f"[중단] y 범위가 캔버스 경계에 닿았다: [{y0i},{y1i}) canvas_h={canvas_h}") band = dilated[y0i:y1i, :] cols = np.where(band.any(axis=0))[0] if len(cols) == 0: raise SystemExit("[중단] 얼굴 윤곽 확장 다각형이 해당 y 범위에서 비어 있다.") x0i, x1i = int(cols.min()), int(cols.max()) + 1 if x0i <= 0 or x1i >= canvas_w: raise SystemExit(f"[중단] x 범위가 캔버스 경계에 닿았다: [{x0i},{x1i}) canvas_w={canvas_w}") print(f"x 범위(얼굴 윤곽 {OVAL_OUTSET_PX}px 확장, y 범위 안) = [{x0i}, {x1i}) (w={x1i-x0i})") # 대조: 다각형 경계가 이 y 범위에서 거의 수직인지 확인 — 확장 없이 구한 x 범위에 단순히 # OVAL_OUTSET_PX를 더/뺀 값과 비교해 기록만 한다(설계 검증용, 결과에는 dilated만 쓴다). band_raw = oval_mask_b[y0i:y1i, :] cols_raw = np.where(band_raw.any(axis=0))[0] x0_raw, x1_raw = int(cols_raw.min()), int(cols_raw.max()) + 1 naive_x0, naive_x1 = x0_raw - int(OVAL_OUTSET_PX), x1_raw + int(OVAL_OUTSET_PX) print(f"대조(단순 폭 확장) = [{naive_x0}, {naive_x1}) vs 거리변환 [{x0i}, {x1i}) " f"차이=({x0i-naive_x0},{x1i-naive_x1})") head_full = np.array(Image.open(LAYERS_V2_DIR / "head.png").convert("RGBA")) detail_full = np.array(Image.open(LAYERS_V2_DIR / "face-detail.png").convert("RGBA")) if head_full.shape[:2] != (canvas_h, canvas_w) or detail_full.shape[:2] != (canvas_h, canvas_w): raise SystemExit( f"[중단] head/face-detail 크기가 캔버스와 다르다: head={head_full.shape[:2]} " f"detail={detail_full.shape[:2]} canvas=({canvas_h},{canvas_w})" ) pieces = {"head": (head_full, LAYERS_V2_DIR / "jaw-head.png"), "detail": (detail_full, LAYERS_V2_DIR / "jaw-detail.png")} LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True) checks: dict = {} for key, (src, out_path) in pieces.items(): crop = src[y0i:y1i, x0i:x1i].copy() Image.fromarray(crop, "RGBA").save(out_path) reloaded = np.array(Image.open(out_path).convert("RGBA")) max_diff = int(np.abs(reloaded.astype(np.int32) - crop.astype(np.int32)).max()) ok = max_diff == 0 print(f"저장: {out_path} size={crop.shape[1]}x{crop.shape[0]} " f"검사(1) 원본 대비 PNG 왕복 최대차(알파 포함)={max_diff} {'OK' if ok else '[실패]'}") checks[key] = { "pngRoundTripMaxDiff": max_diff, "pngRoundTripOk": ok, "pngFileSize": out_path.stat().st_size, } _save_evidence(pieces, x0i, y0i, x1i, y1i) manifest["jaw"] = { "designVersion": "A5 (작업 패킷 A5, 결정문 §8.4 하관 띠 변형 — 턱 조각 원본)", "noseTip": list(nose_tip), "chinTip": list(chin_tip), "yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40", "noseToTopPx": NOSE_TO_TOP_PX, "chinToBottomPx": CHIN_TO_BOTTOM_PX, "ovalOutsetPx": OVAL_OUTSET_PX, "faceOvalSource": "export_rig.detect_face_landmarks + compute_face_oval(base-front.png, 결정적)", "bboxCanvas": [x0i, y0i, x1i, y1i], "naiveXRangeForComparison": [naive_x0, naive_x1], "checks": checks, "evidenceImage": "preview/v2/jaw-pieces.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(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int) -> None: """jaw-head | jaw-detail(체커 배경), 원본 배율.""" panels = [] for key in ("head", "detail"): src, _out_path = pieces[key] crop = src[y0i:y1i, x0i:x1i] rgb = crop[..., :3].astype(np.float64) a = (crop[..., 3].astype(np.float64) / 255.0)[..., None] check = np.indices(a.shape[:2]) checker = ((check[0] // 10 + check[1] // 10) % 2) * 60 + 180 checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64) out = rgb * a + checker3 * (1.0 - a) panels.append(Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))) 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, (x, 0)) x += p.width + gap PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True) out_path = PREVIEW_V2_DIR / "jaw-pieces.jpg" combined.save(out_path, "JPEG", quality=92) print(f"저장: {out_path}") if __name__ == "__main__": sys.exit(main())