vignette/docs/avatar-art/linocut-pipeline/scripts/jaw_pieces.py
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

170 lines
7.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""공통 리노컷 리그 — 턱 조각(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를
만들어 두어야 한다.
실행: <venv>/python.exe jaw_pieces.py <persona-dir>
"""
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])))