"""공통 리노컷 리그 — 턱 조각(jaw-head·jaw-detail) 생성(결정문 §8.4 하관 띠 변형). 하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는 변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 head·faceDetail 이미지를 매 프레임 다시 그리면 비용이 크므로, 변형이 실제로 필요한 사각형만 미리 잘라 둔다. 영역: - y: noseTip.y − 50 − 10 부터 chinTip.y + 40 까지. - x: 얼굴 윤곽(faceOval, export_rig.py가 계산하는 36점 루프)을 바깥으로 18px 넓힌 다각형의, 이 y 범위 안에서의 가로 범위. 변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라 layers/v2/jaw-{head,detail}.png로 저장한다. 전제: layers_v2.py, face_detail.py가 먼저 layers/v2/{head,face-detail}.png를 만들어 두어야 한다. 실행: /python.exe jaw_pieces.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 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 from persona_config import load_persona_config # noqa: E402 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(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"] 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, preview_v2_dir) manifest["jaw"] = { "designVersion": "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, preview_v2_dir: Path) -> 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(Path(sys.argv[1])))