P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
parent
85bd079d18
commit
00169533d8
83 changed files with 7843 additions and 0 deletions
176
docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Normal file
176
docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue