vignette/docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Yun Chan 00169533d8 P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
2026-10-01 09:58:15 +09:00

176 lines
8.1 KiB
Python
Raw 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.

"""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을 더/
빼는" 결과와 사실상 같다 — 두 방식을 대조해 확인했다.)
실행: <venv>/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())