리노컷 자산 파이프라인 공통화와 캐스트 외형 설계

- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
This commit is contained in:
Yun Chan 2026-10-01 16:11:28 +09:00
parent f11e76ff18
commit ecb36d123f
38 changed files with 2393 additions and 990 deletions

View file

@ -1,176 +0,0 @@
"""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())