- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
170 lines
7.5 KiB
Python
170 lines
7.5 KiB
Python
"""공통 리노컷 리그 — 턱 조각(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])))
|