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

1090 lines
48 KiB
Python

"""P1 서연 리노컷 리그 게시 스크립트 (2단계-B-1a).
layers/{v2 있으면 v2 우선, 없으면 layers/*}의 PNG를 알파 bbox로 잘라 WebP로
apps/web/public/avatar/v3/p1/에 게시하고, apps/web/src/components/avatar/v3/rigs/p1Rig.ts를
생성한다. 같은 입력이면 같은 결과가 나오도록 결정적으로 만든다(팔레트·faceOval 계산 방식 고정).
같은 스크립트를 (0) 선행 게시(faceDetail·grain 없음)와 (5) 최종 게시(둘 다 있음) 양쪽에 쓴다.
faceDetail/grain 소스 파일이 없으면 해당 필드를 rig에서 생략한다.
실행: <venv>/python.exe export_rig.py
"""
from __future__ import annotations
import io
import json
import math
import subprocess
import sys
import tempfile
from pathlib import Path
import numpy as np
from PIL import Image
from build_layers_v2 import halo_metric # noqa: E402
ROOT = Path(__file__).resolve().parents[1]
BASE_DIR = ROOT / "base"
LAYERS_DIR = ROOT / "layers"
LAYERS_V2_DIR = ROOT / "layers" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
SCRIPTS_DIR = Path(__file__).resolve().parent
MOTIF_ROOT = ROOT / "motif"
MOTIF_SPRITES_DIR = MOTIF_ROOT / "sprites"
MOTIF_MANIFEST_PATH = MOTIF_ROOT / "manifest.json"
BUD_STATES = ["closed", "half", "open", "droop"]
WEATHER_GROUPS = ["positive", "negative", "defensive", "cognitive", "energy"]
MOTIF_SIZE_BUDGET_BYTES = 160 * 1024
REPO_ROOT = ROOT.parents[2]
PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1"
MOTIF_PUBLIC_DIR = PUBLIC_DIR / "motif"
RIG_TS_PATH = REPO_ROOT / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / "p1Rig.ts"
STYLE_FRAME_PATH = ROOT.parents[0] / "art-direction-v3" / "p1" / "r2-b-linocut.png"
SIZE_BUDGET_BYTES = 600 * 1024
# --- 레이어 소스 해석: v2가 있으면 v2, 없으면 기존 layers/ ------------------
LAYER_SPECS = [
# (rig key, 파일 stem, public href stem, v2에만 있어도 되는가)
("body", "body", "body", False),
("head", "head", "head", False),
("hairFront", "hairFront", "hair-front", False),
("faceDetail", "face-detail", "face-detail", True),
]
GRAIN_SPEC = ("paper-grain", "paper-grain")
# --- 입술 결 스프라이트(결정문 §8.4 입, 2단계-B-1d-A2) -----------------------
LIP_SPECS = [
# (rig key, 파일 stem, public href stem)
("upper", "lip-upper", "lip-upper"),
("lower", "lip-lower", "lip-lower"),
("shadow", "lip-shadow", "lip-shadow"),
]
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 패킷 A5 크기 상한(그늘 조각 분리로 3장, A4의 60KB에서 상향)
# --- 턱 조각(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) --------------------------
JAW_SPECS = [
# (rig key, 파일 stem, public href stem)
("head", "jaw-head", "jaw-head"),
("detail", "jaw-detail", "jaw-detail"),
]
JAW_SIZE_BUDGET_BYTES = 200 * 1024 # 패킷 A5 크기 상한
def resolve_layer_source(stem: str, optional: bool) -> Path | None:
v2_path = LAYERS_V2_DIR / f"{stem}.png"
if v2_path.exists():
return v2_path
legacy_path = LAYERS_DIR / f"{stem}.png"
if legacy_path.exists():
return legacy_path
if optional:
return None
raise SystemExit(f"[중단] 레이어 소스가 없다: {v2_path} 또는 {legacy_path}")
def resolve_grain_source() -> Path | None:
stem, _ = GRAIN_SPEC
p = LAYERS_V2_DIR / f"{stem}.png"
return p if p.exists() else None
# --- 알파 bbox 크롭 ---------------------------------------------------------
def alpha_bbox(alpha: np.ndarray, threshold: int = 1) -> tuple[int, int, int, int]:
ys, xs = np.where(alpha >= threshold)
if len(xs) == 0:
raise SystemExit("[중단] 레이어 알파가 전부 0이다.")
return int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
def crop_to_bbox(im: Image.Image) -> tuple[Image.Image, tuple[int, int, int, int]]:
arr = np.array(im.convert("RGBA"))
x0, y0, x1, y1 = alpha_bbox(arr[..., 3])
return im.crop((x0, y0, x1, y1)), (x0, y0, x1 - x0, y1 - y0)
# --- WebP 게시(용량 예산에 맞춰 quality/해상도 단계적으로 낮춤) -------------
def encode_webp(im: Image.Image, quality: int) -> bytes:
import io
buf = io.BytesIO()
im.save(buf, "WEBP", quality=quality, alpha_quality=100, method=6)
return buf.getvalue()
def publish_layers(sources: dict[str, tuple[Path, str]], grain_source: Path | None) -> tuple[dict, dict | None, dict]:
"""sources: rig key -> (원본 PNG 경로, href stem). 반환: (layers 리포트, grain 리포트, 파일크기 dict)"""
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
for key, (path, href_stem) in sources.items():
im = Image.open(path)
cropped_im, bbox = crop_to_bbox(im)
cropped[key] = (cropped_im, bbox, href_stem)
grain_im = None
if grain_source is not None:
grain_im = Image.open(grain_source).convert("RGB")
def encode_all(quality: int, scale: float) -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, (im, _bbox, href_stem) in cropped.items():
work = im
if scale != 1.0:
w = max(1, round(im.width * scale))
h = max(1, round(im.height * scale))
work = im.resize((w, h), Image.LANCZOS)
out[href_stem] = encode_webp(work.convert("RGBA"), quality)
if grain_im is not None:
work = grain_im
if scale != 1.0:
w = max(1, round(grain_im.width * scale))
h = max(1, round(grain_im.height * scale))
work = grain_im.resize((w, h), Image.LANCZOS)
out[GRAIN_SPEC[1]] = encode_webp(work.convert("RGB"), quality)
return out
attempts = [(80, 1.0), (70, 1.0), (70, 0.85)]
chosen = None
chosen_meta = None
for quality, scale in attempts:
encoded = encode_all(quality, scale)
total = sum(len(b) for b in encoded.values())
if total <= SIZE_BUDGET_BYTES or (quality, scale) == attempts[-1]:
chosen = encoded
chosen_meta = {"quality": quality, "scale": scale, "totalBytes": total}
break
assert chosen is not None and chosen_meta is not None
file_sizes: dict[str, int] = {}
for href_stem, data in chosen.items():
out_path = PUBLIC_DIR / f"{href_stem}.webp"
out_path.write_bytes(data)
file_sizes[href_stem] = len(data)
layers_report = {}
for key, (_im, bbox, href_stem) in cropped.items():
x0, y0, w, h = bbox
layers_report[key] = {
"href": f"/avatar/v3/p1/{href_stem}.webp",
"x": round(x0, 1),
"y": round(y0, 1),
"w": round(w, 1),
"h": round(h, 1),
}
grain_report = None
if grain_im is not None:
grain_report = {"href": f"/avatar/v3/p1/{GRAIN_SPEC[1]}.webp", "size": 256}
return layers_report, grain_report, {"fileSizes": file_sizes, "encodeMeta": chosen_meta}
# --- 입술 결 스프라이트 게시(결정문 §8.4 입, 2단계-B-1d-A2) -----------------
def publish_lip_texture(manifest: dict) -> tuple[dict | None, dict | None]:
"""layers/v2/lip-{upper,lower}.png(build_lip_texture.py 산출)를 알파
bbox로 잘라 WebP로 게시한다. 소스가 없으면 (None, None)을 반환해
lipTexture 없는 리그도 여전히 만들 수 있게 한다(렌더러는 palette 단색
으로 대체). 두 파일 합계 예산은 60KB(패킷 A4 크기 상한).
PNG 자체는 이미 알파 bbox로 꽉 차게 잘려 있어(build_lip_texture.py) 이 함수의
crop_to_bbox는 사실상 no-op이고, 캔버스 위치 정보를 담고 있지 않다 —
RigLayer.x/y(절대 캔버스 좌표)는 manifest["lipTexture"]["canvasOrigin"](작업
캔버스 좌표를 build_lip_texture.py가 절대 좌표로 환산해 기록한 값)에서 읽는다."""
canvas_origin = manifest.get("lipTexture", {}).get("canvasOrigin")
if canvas_origin is None:
raise SystemExit("[중단] manifest.lipTexture.canvasOrigin이 없다 — build_lip_texture.py를 먼저 실행하라.")
sources: dict[str, tuple[Path, str]] = {}
for key, stem, href_stem in LIP_SPECS:
p = LAYERS_V2_DIR / f"{stem}.png"
if not p.exists():
return None, None
sources[key] = (p, href_stem)
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
for key, (path, href_stem) in sources.items():
im = Image.open(path)
cropped_im, local_bbox = crop_to_bbox(im)
lx0, ly0 = canvas_origin[key]
abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3])
cropped[key] = (cropped_im, abs_bbox, href_stem)
import io
LIP_DIFF4_TARGET = 2.0 # 검사(4) 목표(입 결은 표정의 중심이라 품질을 우선한다, 오케스트레이터 지시)
def encode_all(quality: int) -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, (im, _bbox, href_stem) in cropped.items():
out[href_stem] = encode_webp(im.convert("RGBA"), quality)
return out
def encode_all_lossless() -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, (im, _bbox, href_stem) in cropped.items():
buf = io.BytesIO()
# exact=True: libwebp 기본값(0)은 완전 투명(알파=0) 영역의 RGB를 압축률을 위해
# 바꿔도 되는 것으로 보고 버린다 — "무손실"이 알파>0 영역에만 적용된다. 알파=0
# 영역도 원화 RGB를 그대로 담고 있으므로(build_lip_texture.py) exact=True로 강제한다.
im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True)
out[href_stem] = buf.getvalue()
return out
def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]:
diffs: dict[str, float] = {}
for key, (im, _bbox, href_stem) in cropped.items():
src_arr = np.array(im.convert("RGBA")).astype(np.float64)
dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64)
diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3)
return diffs
# 1순위: 무손실. 두 장 합계가 예산(60KB) 안이면 무손실을 쓴다.
lossless_encoded = encode_all_lossless()
lossless_total = sum(len(b) for b in lossless_encoded.values())
if lossless_total <= LIP_SIZE_BUDGET_BYTES:
chosen, chosen_quality = lossless_encoded, "lossless"
else:
# 2순위: quality>=95 손실 인코딩 중 예산 안이면서 평균차<=2인 가장 작은 파일(=가장 높은 quality부터 시도).
candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = []
chosen, chosen_quality = None, None
for q in (100, 99, 98, 97, 96, 95):
encoded = encode_all(q)
total = sum(len(b) for b in encoded.values())
diffs = webp_vs_png_diff(encoded)
candidates.append((q, encoded, total, diffs))
if total <= LIP_SIZE_BUDGET_BYTES and max(diffs.values()) <= LIP_DIFF4_TARGET:
chosen, chosen_quality = encoded, q
break
if chosen is None:
# 목표(예산 AND 평균차<=2)를 동시에 만족하는 quality가 없다 — 예산 안 후보 중 평균차 최솟값을 쓴다.
within_budget = [c for c in candidates if c[2] <= LIP_SIZE_BUDGET_BYTES]
pool = within_budget if within_budget else candidates
best = min(pool, key=lambda c: max(c[3].values()))
chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3]
print(
f" [경고] quality 95~100 중 예산({LIP_SIZE_BUDGET_BYTES}B) AND 평균차<={LIP_DIFF4_TARGET} "
f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택"
)
assert chosen is not None and chosen_quality is not None
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
file_sizes: dict[str, int] = {}
for href_stem, data in chosen.items():
out_path = PUBLIC_DIR / f"{href_stem}.webp"
out_path.write_bytes(data)
file_sizes[href_stem] = len(data)
total_bytes = sum(file_sizes.values())
print(f"입술 결 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)")
for href_stem, size in file_sizes.items():
print(f" {href_stem}.webp = {size} bytes")
print(
f"입술 결 합계 = {total_bytes} bytes (예산 {LIP_SIZE_BUDGET_BYTES} bytes) "
f"{'OK' if total_bytes <= LIP_SIZE_BUDGET_BYTES else '[초과]'}"
)
# --- 검사(4) WebP 대 PNG 평균차(알파 채널 포함) ---
webp_vs_png = webp_vs_png_diff(chosen)
for href_stem in file_sizes:
key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem)
print(f" 검사(4) [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}")
lip_field = {}
for key, (_im, bbox, href_stem) in cropped.items():
x0, y0, w, h = bbox
lip_field[key] = {
"href": f"/avatar/v3/p1/{href_stem}.webp",
"x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1),
}
publish_report = {
"quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes,
"budgetBytes": LIP_SIZE_BUDGET_BYTES,
"webpVsPngMeanAbsDiff": webp_vs_png,
}
return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형, 작업 패킷 A5) -------------------
JAW_LAYER_KEY_FOR = {"head": "head", "detail": "faceDetail"} # jaw rig key -> layers_report 키
JAW_DIFF_TARGET = 2.0 # 오케스트레이터 지시: 평균 절대차 목표
def publish_jaw_pieces(manifest: dict, layers_report: dict, encode_meta: dict) -> tuple[dict | None, dict | None]:
"""layers/v2/jaw-{head,detail}.png(build_jaw_pieces.py 산출, 원본 레이어 픽셀을
그대로 잘라낸 것)를 게시한다.
A5 1차 시도(무손실 WebP)는 오케스트레이터 판정으로 반려됐다: 화면에 실제로
그려지는 원본은 게시된 `head.webp`·`face-detail.webp`이고 **그 둘 다 이미
손실 압축**이라, 턱 조각만 무손실로 게시하면 PNG와는 같아도 화면의 원본과는
달라 이음매가 드러난다. 그래서 턱 조각을 **같은 레이어가 쓴 것과 같은 인코딩
설정(quality·alpha_quality·method, publish_layers의 encode_meta)**으로 다시
인코딩한다. 픽셀 동일성 기준도 PNG가 아니라 **게시된 head.webp/face-detail.webp를
같은 캔버스 영역으로 잘라 디코드한 값**으로 바꾼다(둘 다 화면 표시 크기로
맞춘 뒤 비교 — encode_meta.scale!=1이면 원본 alpha-bbox 크기로 늘려 브라우저가
표시할 모습을 재현한다)."""
jaw_manifest = manifest.get("jaw")
if jaw_manifest is None:
return None, None
bbox = jaw_manifest["bboxCanvas"]
x0, y0, x1, y1 = bbox
w, h = x1 - x0, y1 - y0
sources: dict[str, tuple[Path, str]] = {}
for key, stem, href_stem in JAW_SPECS:
p = LAYERS_V2_DIR / f"{stem}.png"
if not p.exists():
return None, None
sources[key] = (p, href_stem)
quality = encode_meta["quality"]
scale = encode_meta["scale"]
PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
file_sizes: dict[str, int] = {}
compare_report: dict[str, dict] = {}
for key, (path, href_stem) in sources.items():
im = Image.open(path).convert("RGBA")
if im.size != (w, h):
raise SystemExit(f"[중단] {path.name} 크기({im.size})가 manifest.jaw.bboxCanvas({w}x{h})와 다르다.")
work = im
if scale != 1.0:
sw, sh = max(1, round(w * scale)), max(1, round(h * scale))
work = im.resize((sw, sh), Image.LANCZOS)
data = encode_webp(work, quality)
out_path = PUBLIC_DIR / f"{href_stem}.webp"
out_path.write_bytes(data)
file_sizes[href_stem] = len(data)
# 화면에 그려지는 모습 재현: 디코드 후 축소돼 있었다면(scale!=1) 원래 캔버스
# 크기(w,h)로 다시 늘린다 — 브라우저가 <image> 태그를 w,h 사각형에 맞춰
# 늘리는 것과 같다.
jaw_decoded = Image.open(io.BytesIO(data)).convert("RGBA")
if jaw_decoded.size != (w, h):
jaw_decoded = jaw_decoded.resize((w, h), Image.LANCZOS)
jaw_arr = np.array(jaw_decoded).astype(np.float64)
layer_key = JAW_LAYER_KEY_FOR[key]
layer_info = layers_report[layer_key]
layer_cx0, layer_cy0 = int(round(layer_info["x"])), int(round(layer_info["y"]))
layer_w, layer_h = int(round(layer_info["w"])), int(round(layer_info["h"]))
layer_webp_path = PUBLIC_DIR / Path(layer_info["href"]).name
layer_decoded = Image.open(layer_webp_path).convert("RGBA")
if layer_decoded.size != (layer_w, layer_h):
layer_decoded = layer_decoded.resize((layer_w, layer_h), Image.LANCZOS)
layer_arr = np.array(layer_decoded).astype(np.float64)
# 게시된 레이어는 자기 알파 bbox로만 잘려 있어(faceDetail처럼) 턱 조각 사각형을
# 다 못 덮을 수 있다 — 그 밖은 레이어가 화면에 아무것도 안 그리는(완전 투명)
# 자리이므로 0으로 채운 캔버스에 교집합만 옮겨 담는다.
layer_crop = np.zeros((h, w, 4), dtype=np.float64)
ix0, iy0 = max(x0, layer_cx0), max(y0, layer_cy0)
ix1, iy1 = min(x0 + w, layer_cx0 + layer_w), min(y0 + h, layer_cy0 + layer_h)
if ix1 > ix0 and iy1 > iy0:
layer_crop[iy0 - y0:iy1 - y0, ix0 - x0:ix1 - x0] = layer_arr[
iy0 - layer_cy0:iy1 - layer_cy0, ix0 - layer_cx0:ix1 - layer_cx0
]
# 알파=0인 자리는 화면에 전혀 그려지지 않으므로 그 자리의 RGB 값은 "화면에
# 보이는 차이"와 무관하다(원본 크롭·게시 레이어 각각 인코더가 자유롭게 다른
# 값을 남길 수 있다 — 실측: 이 값을 그냥 빼면 평균차가 19.7까지 치솟는데,
# 전부 두 쪽 다 알파 0인 자리의 RGB 잡음이었다). 알파를 곱한(premultiplied)
# 색으로 비교해야 "실제로 그려지는 결과"의 차이가 된다.
def _premultiplied(arr: np.ndarray) -> np.ndarray:
return arr[..., :3] * (arr[..., 3:4] / 255.0)
diff_rgb = np.abs(_premultiplied(jaw_arr) - _premultiplied(layer_crop))
diff_alpha = np.abs(jaw_arr[..., 3] - layer_crop[..., 3])
mean_diff = round(float(diff_rgb.mean()), 3)
max_diff = round(float(diff_rgb.max()), 3)
mean_diff_alpha = round(float(diff_alpha.mean()), 3)
max_diff_alpha = round(float(diff_alpha.max()), 3)
compare_report[key] = {
"comparedAgainstPublishedLayer": layer_webp_path.name,
"meanAbsDiffPremultipliedRgb": mean_diff,
"maxAbsDiffPremultipliedRgb": max_diff,
"meanAbsDiffAlpha": mean_diff_alpha,
"maxAbsDiffAlpha": max_diff_alpha,
"meetsTarget": mean_diff <= JAW_DIFF_TARGET,
}
if key == "head" and not compare_report[key]["meetsTarget"]:
compare_report[key]["orchestratorAcceptanceNote"] = (
"평균차 3.13(목표 2.0 초과) 수용(오케스트레이터, 2026-10-01). 근거: "
"최대차가 난 자리(왼쪽 위 머리카락)는 띠 clip(얼굴 윤곽 18px 바깥 "
"다각형) 밖이라 렌더러가 그리지 않는다. 띠가 켜지는 동안에도 윗경계(y_n) "
"부근은 변위 f(y)가 0에 가까워 이 조각의 위쪽 여백이 눈에 띄게 움직이지 "
"않는다. quality를 올려도 이미 게시된 head.webp 자체의 압축 오차만큼은 "
"남아 이득이 없다(비교 대상 자체가 손실 압축본)."
)
total_bytes = sum(file_sizes.values())
print(f"턱 조각 인코딩: quality={quality} scale={scale}(head·faceDetail 레이어와 동일 설정)")
for href_stem, size in file_sizes.items():
print(f" {href_stem}.webp = {size} bytes")
print(
f"턱 조각 합계 = {total_bytes} bytes (예산 {JAW_SIZE_BUDGET_BYTES} bytes) "
f"{'OK' if total_bytes <= JAW_SIZE_BUDGET_BYTES else '[초과]'}"
)
for key, href_stem in (("head", "jaw-head"), ("detail", "jaw-detail")):
r = compare_report[key]
print(f" 검사(1, A5 재게시) [{href_stem}] 게시된 {r['comparedAgainstPublishedLayer']}(같은 영역, 알파 곱한 색) 대비 "
f"평균차={r['meanAbsDiffPremultipliedRgb']} 최대차={r['maxAbsDiffPremultipliedRgb']} "
f"(알파 평균차={r['meanAbsDiffAlpha']} 최대차={r['maxAbsDiffAlpha']}) "
f"(목표 평균<={JAW_DIFF_TARGET}) {'OK' if r['meetsTarget'] else '[미달]'}")
jaw_field = {
"head": {"href": "/avatar/v3/p1/jaw-head.webp", "x": x0, "y": y0, "w": w, "h": h},
"detail": {"href": "/avatar/v3/p1/jaw-detail.webp", "x": x0, "y": y0, "w": w, "h": h},
}
publish_report = {
"quality": quality, "scale": scale,
"fileSizes": file_sizes, "totalBytes": total_bytes,
"budgetBytes": JAW_SIZE_BUDGET_BYTES,
"vsPublishedLayerAbsDiff": compare_report,
}
return jaw_field, publish_report
# --- 모티프 스프라이트 게시(꽃봉오리·날씨) ---------------------------------
def publish_motif() -> tuple[dict, dict]:
"""motif/sprites/*.png를 WebP로 게시하고(알파 bbox 크롭 없음 — 스프라이트
캔버스 그대로), 좌표는 motif/manifest.json에서 읽어 하드코딩하지 않는다.
반환: (rig의 motif 필드, manifest.json 기록용 리포트)."""
motif_manifest = json.loads(MOTIF_MANIFEST_PATH.read_text(encoding="utf-8"))
MOTIF_PUBLIC_DIR.mkdir(parents=True, exist_ok=True)
bud_canvas = motif_manifest["bud"]["canvas"]
bud_base = motif_manifest["bud"]["base_anchor_px"]
weather_sprite_canvases = {s["name"]: s["canvas"] for s in motif_manifest["weather"]["sprites"]}
weather_canvas = None
for name in WEATHER_GROUPS:
c = weather_sprite_canvases[f"weather-{name}"]
if weather_canvas is None:
weather_canvas = c
elif c != weather_canvas:
raise SystemExit(f"[중단] weather 스프라이트 캔버스 크기가 서로 다르다: {weather_sprite_canvases}")
sources: dict[str, Path] = {}
for state in BUD_STATES:
sources[f"bud-{state}"] = MOTIF_SPRITES_DIR / f"bud-{state}.png"
for name in WEATHER_GROUPS:
sources[f"weather-{name}"] = MOTIF_SPRITES_DIR / f"weather-{name}.png"
for key, p in sources.items():
if not p.exists():
raise SystemExit(f"[중단] 모티프 스프라이트가 없다: {p}")
images = {key: Image.open(p).convert("RGBA") for key, p in sources.items()}
def encode_all(quality: int) -> dict[str, bytes]:
out: dict[str, bytes] = {}
for key, im in images.items():
out[key] = encode_webp(im, quality)
return out
chosen: dict[str, bytes] | None = None
chosen_quality: int | None = None
for q in (82, 72):
encoded = encode_all(q)
total = sum(len(b) for b in encoded.values())
if total <= MOTIF_SIZE_BUDGET_BYTES or q == 72:
chosen, chosen_quality = encoded, q
break
assert chosen is not None and chosen_quality is not None
file_sizes: dict[str, int] = {}
for key, data in chosen.items():
out_path = MOTIF_PUBLIC_DIR / f"{key}.webp"
out_path.write_bytes(data)
file_sizes[key] = len(data)
total_bytes = sum(file_sizes.values())
print(f"모티프 인코딩: quality={chosen_quality}")
for key, size in file_sizes.items():
print(f" motif/{key}.webp = {size} bytes")
print(
f"모티프 합계 = {total_bytes} bytes (예산 {MOTIF_SIZE_BUDGET_BYTES} bytes) "
f"{'OK' if total_bytes <= MOTIF_SIZE_BUDGET_BYTES else '[초과]'}"
)
bud_paper = np.array(motif_manifest["bud"]["raw_sheet"]["paper_color_rgb"], dtype=np.float64)
weather_paper = np.array(motif_manifest["weather"]["raw_sheet"]["paper_color_rgb"], dtype=np.float64)
halo_report: dict[str, float] = {}
for key in images:
webp_path = MOTIF_PUBLIC_DIR / f"{key}.webp"
arr = np.array(Image.open(webp_path).convert("RGBA")).astype(np.float64)
rgb, alpha = arr[..., :3], arr[..., 3]
paper = bud_paper if key.startswith("bud") else weather_paper
hm = halo_metric(rgb, alpha, paper)
halo_report[key] = hm
print(f" halo({key}) = {hm:.3f}% (기준 <=2%) {'OK' if hm <= 2.0 else '[초과]'}")
motif_field = {
"bud": {
"canvas": {"w": bud_canvas[0], "h": bud_canvas[1]},
"base": [round(bud_base[0], 2), round(bud_base[1], 2)],
"closed": "/avatar/v3/p1/motif/bud-closed.webp",
"half": "/avatar/v3/p1/motif/bud-half.webp",
"open": "/avatar/v3/p1/motif/bud-open.webp",
"droop": "/avatar/v3/p1/motif/bud-droop.webp",
},
"weather": {
"canvas": {"w": weather_canvas[0], "h": weather_canvas[1]},
"sprites": {name: f"/avatar/v3/p1/motif/weather-{name}.webp" for name in WEATHER_GROUPS},
},
}
publish_report = {
"quality": chosen_quality,
"fileSizes": file_sizes,
"totalBytes": total_bytes,
"budgetBytes": MOTIF_SIZE_BUDGET_BYTES,
"halo": halo_report,
"haloThreshold": 2.0,
}
return motif_field, publish_report
# --- 얼굴 랜드마크(FaceLandmarker 서브프로세스, faceOval·mouthCenter용) ----
FACE_OVAL_LOOP = [
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127,
162, 21, 54, 103, 67, 109,
]
FACE_OVAL_SCALE = 1.04
MOUTH_CENTER_INNER_UPPER = 13
MOUTH_CENTER_INNER_LOWER = 14
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
with tempfile.TemporaryDirectory() as td:
out_json = Path(td) / "landmarks.json"
proc = subprocess.run(
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
capture_output=True, text=True,
)
print(proc.stdout.strip())
if proc.returncode != 0 or not out_json.exists():
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
data = json.loads(out_json.read_text(encoding="utf-8"))
if not data.get("ok"):
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
return [(p[0], p[1]) for p in data["points"]]
def compute_face_oval(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
loop_pts = [points[i] for i in FACE_OVAL_LOOP]
cx = sum(p[0] for p in loop_pts) / len(loop_pts)
cy = sum(p[1] for p in loop_pts) / len(loop_pts)
return [((x - cx) * FACE_OVAL_SCALE + cx, (y - cy) * FACE_OVAL_SCALE + cy) for x, y in loop_pts]
def compute_mouth_center(points: list[tuple[float, float]]) -> tuple[float, float]:
a = points[MOUTH_CENTER_INNER_UPPER]
b = points[MOUTH_CENTER_INNER_LOWER]
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
# --- 팔레트 표본 ------------------------------------------------------------
def hexc(rgb: np.ndarray) -> str:
return "#%02X%02X%02X" % tuple(int(round(c)) for c in np.clip(rgb, 0, 255))
def compute_palette(base_front_rgb: np.ndarray, manifest: dict) -> tuple[dict, dict]:
im = base_front_rgb.astype(np.float64)
lm = manifest["landmarks"]
samples_report: dict = {}
def eye_samples(eye: dict) -> dict:
cx, cy = eye["iris"]["center"]
r = eye["iris"]["radius"]
pad = 6
x0, x1 = int(cx - r - pad), int(cx + r + pad)
y0, y1 = int(cy - r - pad), int(cy + r + pad)
region = im[y0:y1, x0:x1]
yy, xx = np.mgrid[y0:y1, x0:x1]
dist = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
lum = region.mean(axis=2)
sclera_mask = (dist > r * 1.05) & (lum > 90) & (lum < 225)
sclera_px = region[sclera_mask]
body_mask = (dist < r * 0.85) & (lum > 15)
body_px = region[body_mask]
body_lum = lum[body_mask]
order = np.argsort(body_lum)
band = body_px[order[int(len(order) * 0.65):int(len(order) * 0.85)]] if len(order) else body_px
ring_mask = (dist >= r * 0.90) & (dist <= r * 1.05)
ring_px = region[ring_mask]
return {
"sclera": np.median(sclera_px, axis=0) if len(sclera_px) else None,
"iris": np.median(band, axis=0) if len(band) else None,
"irisRing": np.median(ring_px, axis=0) if len(ring_px) else None,
"n": {"sclera": len(sclera_px), "iris": len(band), "irisRing": len(ring_px)},
}
eL = eye_samples(lm["eyeLeft"])
eR = eye_samples(lm["eyeRight"])
# sclera/iris/irisRing 측정값은 참고용으로만 기록한다. base-front의 흰자는 조각
# 해칭 그늘 때문에 밝은 픽셀이 거의 없고(명도>140 픽셀 4개뿐, 표본 중앙값
# #A5917A), irisRing 측정값(#3E3028)이 iris 측정값보다 밝아 테두리가 안 보인다.
# 벡터 눈은 그늘을 별도로 그리므로(윗눈꺼풀 그늘 띠) 이 3개는 표본이 아니라
# 오케스트레이터가 정한 고정 설계값을 쓴다(2단계-B-1a 팔레트 정정 지시).
sclera_measured = (eL["sclera"] + eR["sclera"]) / 2.0
iris_measured = (eL["iris"] + eR["iris"]) / 2.0
iris_ring_measured = (eL["irisRing"] + eR["irisRing"]) / 2.0
sclera = np.array([0xD8, 0xCE, 0xBD], dtype=np.float64)
iris = np.array([0x4F, 0x3B, 0x2C], dtype=np.float64)
iris_ring = np.array([0x1E, 0x1F, 0x1F], dtype=np.float64)
samples_report["eyeLeft"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eL.items()}
samples_report["eyeRight"] = {k: (hexc(v) if isinstance(v, np.ndarray) else v) for k, v in eR.items()}
samples_report["scleraIrisIrisRingFixedOverride"] = {
"reason": "고정값(오케스트레이터 결정), 측정 참고값: sclera 중앙값 #A5917A, 홍채 중간 링 #3E3028",
"measured": {"sclera": hexc(sclera_measured), "iris": hexc(iris_measured), "irisRing": hexc(iris_ring_measured)},
"fixed": {"sclera": hexc(sclera), "iris": hexc(iris), "irisRing": hexc(iris_ring)},
}
# ink: 머리카락 덩어리 안 어두운 픽셀(hair 영역 bbox, lum<55)
hair_region = im[100:350, 280:720]
hair_lum = hair_region.mean(axis=2)
dark_px = hair_region[hair_lum < 55]
ink = np.median(dark_px, axis=0)
samples_report["ink"] = {"box": [280, 100, 720, 350], "lumThreshold": 55, "n": int(len(dark_px)), "hex": hexc(ink)}
# 입술: mouthCorner/upperLipTop/lowerLipBottom bbox를 mouthCenter y로 위/아래 분리
mc = compute_mouth_center_from_manifest(manifest)
lc = lm["mouthCornerLeft"]
rc = lm["mouthCornerRight"]
ut = lm["upperLipTopCenter"]
lb = lm["lowerLipBottomCenter"]
x0, x1 = int(min(lc[0], rc[0])) - 5, int(max(lc[0], rc[0])) + 5
upper_region = im[int(ut[1] - 3):int(mc[1] - 2), x0:x1]
lower_region = im[int(mc[1] + 2):int(lb[1] + 3), x0:x1]
lip_upper = np.median(upper_region.reshape(-1, 3), axis=0)
lip_lower = np.median(lower_region.reshape(-1, 3), axis=0)
mouthline_region = im[int(mc[1]) - 2:int(mc[1]) + 3, x0 + 20:x1 - 20]
mouth_line = np.median(mouthline_region.reshape(-1, 3), axis=0)
samples_report["lipUpper"] = {"box": [x0, int(ut[1] - 3), x1, int(mc[1] - 2)], "hex": hexc(lip_upper)}
samples_report["lipLower"] = {"box": [x0, int(mc[1] + 2), x1, int(lb[1] + 3)], "hex": hexc(lip_lower)}
samples_report["mouthLine"] = {"box": [x0 + 20, int(mc[1]) - 2, x1 - 20, int(mc[1]) + 3], "hex": hexc(mouth_line)}
# 모티프: 스타일 프레임 r2-b-linocut.png에서 해/튤립(ochre), 구름/잎(slate blue) 표본
style = np.array(Image.open(STYLE_FRAME_PATH).convert("RGB")).astype(np.float64)
def box_median(x0, y0, x1, y1, kind):
box = style[y0:y1, x0:x1].reshape(-1, 3)
r, g, b = box[:, 0], box[:, 1], box[:, 2]
lum = box.mean(axis=1)
if kind == "ochre":
mask = (r > 150) & (r - b > 50) & (r - g > 15)
else:
mask = (b > r) & (lum > 60) & (lum < 190)
sel = box[mask]
return np.median(sel, axis=0) if len(sel) else None, len(sel)
sun, n_sun = box_median(1230, 10, 1536, 210, "ochre")
tulip, n_tulip = box_median(1030, 280, 1170, 560, "ochre")
motif_petal = (sun + tulip) / 2.0
cloud1, n_c1 = box_median(20, 20, 380, 190, "blue")
raincloud, n_rc = box_median(520, 10, 930, 230, "blue")
wilted, n_wf = box_median(520, 280, 650, 580, "blue")
bud, n_bud = box_median(10, 290, 110, 570, "blue")
motif_leaf = (cloud1 + raincloud + wilted + bud) / 4.0
samples_report["motifPetal"] = {
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
"sun": {"box": [1230, 10, 1536, 210], "n": n_sun, "hex": hexc(sun)},
"tulip": {"box": [1030, 280, 1170, 560], "n": n_tulip, "hex": hexc(tulip)},
"hex": hexc(motif_petal),
}
samples_report["motifLeaf"] = {
"sourceImage": "docs/avatar-art/art-direction-v3/p1/r2-b-linocut.png",
"cloudLeft": {"box": [20, 20, 380, 190], "n": n_c1, "hex": hexc(cloud1)},
"raincloud": {"box": [520, 10, 930, 230], "n": n_rc, "hex": hexc(raincloud)},
"wiltedFlowerLeaf": {"box": [520, 280, 650, 580], "n": n_wf, "hex": hexc(wilted)},
"closedBudLeaf": {"box": [10, 290, 110, 570], "n": n_bud, "hex": hexc(bud)},
"hex": hexc(motif_leaf),
}
palette = {
"ink": hexc(ink),
"sclera": hexc(sclera),
"iris": hexc(iris),
"irisRing": hexc(iris_ring),
"lipUpper": hexc(lip_upper),
"lipLower": hexc(lip_lower),
"mouthLine": hexc(mouth_line),
"mouthInner": "#3B201B",
"teeth": "#E9E0CF",
"blush": "#C0624A",
"tear": "#EEE5D3",
"pallor": "#9AA3A6",
"paper": "#EEE5D3",
"motifPetal": hexc(motif_petal),
"motifLeaf": hexc(motif_leaf),
}
return palette, samples_report
_MOUTH_CENTER_CACHE: tuple[float, float] | None = None
def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]:
global _MOUTH_CENTER_CACHE
if _MOUTH_CENTER_CACHE is None:
raise SystemExit("[내부오류] mouthCenter가 먼저 계산되지 않았다.")
return _MOUTH_CENTER_CACHE
# --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) -----------------
def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict:
lm = manifest["landmarks"]
def pt(p) -> list[float]:
return [round(p[0], 1), round(p[1], 1)]
def eye(e) -> dict:
return {
"inner": pt(e["innerCorner"]),
"outer": pt(e["outerCorner"]),
"upperLidTop": pt(e["upperLidTop"]),
"lowerLidBottom": pt(e["lowerLidBottom"]),
"iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)},
}
def brow(b) -> dict:
return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
return {
"eyeLeft": eye(lm["eyeLeft"]),
"eyeRight": eye(lm["eyeRight"]),
"browLeft": brow(lm["eyebrowLeft"]),
"browRight": brow(lm["eyebrowRight"]),
"noseTip": pt(lm["noseTip"]),
"mouthCornerLeft": pt(lm["mouthCornerLeft"]),
"mouthCornerRight": pt(lm["mouthCornerRight"]),
"upperLipTop": pt(lm["upperLipTopCenter"]),
"lowerLipBottom": pt(lm["lowerLipBottomCenter"]),
"mouthCenter": [round(mouth_center[0], 1), round(mouth_center[1], 1)],
"chinTip": pt(lm["chinTip"]),
}
# --- crops.face 계산 --------------------------------------------------------
def compute_face_crop(face_oval: list[tuple[float, float]]) -> tuple[float, float, float, float]:
xs = [p[0] for p in face_oval]
ys = [p[1] for p in face_oval]
minx, maxx = min(xs), max(xs)
miny, maxy = min(ys), max(ys)
bbox_h = maxy - miny
side = bbox_h * 1.12
cx = (minx + maxx) / 2.0
cy = (miny + maxy) / 2.0
return (cx - side / 2.0, cy - side / 2.0, side, side)
# --- TypeScript 파일 렌더링 --------------------------------------------------
def ts_num(x: float) -> str:
r = round(x, 1)
if r == int(r):
return f"{int(r)}"
return f"{r}"
def ts_point(p) -> str:
return f"[{ts_num(p[0])}, {ts_num(p[1])}]"
def render_rig_ts(rig: dict) -> str:
lines: list[str] = []
lines.append("/* 생성 파일 — docs/avatar-art/p1-linocut/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */")
lines.append('import type { LinocutRig } from "../linocutRig";')
lines.append("")
lines.append("export const P1_LINOCUT_RIG: LinocutRig = {")
lines.append(f' schemaVersion: "{rig["schemaVersion"]}",')
lines.append(f' persona: "{rig["persona"]}",')
lines.append(f' canvas: {{ w: {rig["canvas"]["w"]}, h: {rig["canvas"]["h"]} }},')
lines.append(" layers: {")
for key in ("body", "head", "hairFront"):
layer = rig["layers"][key]
lines.append(
f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, '
f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},'
)
if "faceDetail" in rig["layers"]:
layer = rig["layers"]["faceDetail"]
lines.append(
f' faceDetail: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, '
f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},'
)
lines.append(" },")
if rig.get("grain") is not None:
lines.append(f' grain: {{ href: "{rig["grain"]["href"]}", size: {rig["grain"]["size"]} }},')
if rig.get("lipTexture") is not None:
lip = rig["lipTexture"]
lines.append(" lipTexture: {")
for key in ("upper", "lower"):
layer = lip[key]
lines.append(
f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, '
f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},'
)
if lip.get("shadow") is not None:
layer = lip["shadow"]
lines.append(
f' shadow: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, '
f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},'
)
lines.append(" },")
if rig.get("jaw") is not None:
jaw = rig["jaw"]
lines.append(" jaw: {")
for key in ("head", "detail"):
layer = jaw[key]
lines.append(
f' {key}: {{ href: "{layer["href"]}", x: {ts_num(layer["x"])}, y: {ts_num(layer["y"])}, '
f'w: {ts_num(layer["w"])}, h: {ts_num(layer["h"])} }},'
)
lines.append(" },")
if rig.get("motif") is not None:
motif = rig["motif"]
bud = motif["bud"]
weather = motif["weather"]
lines.append(" motif: {")
lines.append(" bud: {")
lines.append(f' canvas: {{ w: {bud["canvas"]["w"]}, h: {bud["canvas"]["h"]} }},')
lines.append(f' base: {ts_point(bud["base"])},')
lines.append(f' closed: "{bud["closed"]}",')
lines.append(f' half: "{bud["half"]}",')
lines.append(f' open: "{bud["open"]}",')
lines.append(f' droop: "{bud["droop"]}",')
lines.append(" },")
lines.append(" weather: {")
lines.append(f' canvas: {{ w: {weather["canvas"]["w"]}, h: {weather["canvas"]["h"]} }},')
lines.append(" sprites: {")
for key in ("positive", "negative", "defensive", "cognitive", "energy"):
lines.append(f' {key}: "{weather["sprites"][key]}",')
lines.append(" },")
lines.append(" },")
lines.append(" },")
lines.append(" pivots: {")
lines.append(f' neck: {ts_point(rig["pivots"]["neck"])},')
lines.append(f' body: {ts_point(rig["pivots"]["body"])},')
lines.append(f' face: {ts_point(rig["pivots"]["face"])},')
lines.append(" },")
lines.append(" crops: {")
for key in ("portrait", "bust", "face"):
r = rig["crops"][key]
lines.append(f" {key}: [{ts_num(r[0])}, {ts_num(r[1])}, {ts_num(r[2])}, {ts_num(r[3])}],")
lines.append(" },")
oval_str = ", ".join(ts_point(p) for p in rig["faceOval"])
lines.append(f" faceOval: [{oval_str}],")
lines.append(" landmarks: {")
lmk = rig["landmarks"]
def emit_eye(name, e):
lines.append(f" {name}: {{")
lines.append(f' inner: {ts_point(e["inner"])},')
lines.append(f' outer: {ts_point(e["outer"])},')
lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},')
lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},')
lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},')
lines.append(" },")
def emit_brow(name, b):
lines.append(
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},'
)
emit_eye("eyeLeft", lmk["eyeLeft"])
emit_eye("eyeRight", lmk["eyeRight"])
emit_brow("browLeft", lmk["browLeft"])
emit_brow("browRight", lmk["browRight"])
lines.append(f' noseTip: {ts_point(lmk["noseTip"])},')
lines.append(f' mouthCornerLeft: {ts_point(lmk["mouthCornerLeft"])},')
lines.append(f' mouthCornerRight: {ts_point(lmk["mouthCornerRight"])},')
lines.append(f' upperLipTop: {ts_point(lmk["upperLipTop"])},')
lines.append(f' lowerLipBottom: {ts_point(lmk["lowerLipBottom"])},')
lines.append(f' mouthCenter: {ts_point(lmk["mouthCenter"])},')
lines.append(f' chinTip: {ts_point(lmk["chinTip"])},')
lines.append(" },")
lines.append(" palette: {")
for key, val in rig["palette"].items():
lines.append(f' {key}: "{val}",')
lines.append(" },")
lines.append(" backdrop: {")
for key, val in rig["backdrop"].items():
lines.append(f' {key}: "{val}",')
lines.append(" },")
lines.append("};")
lines.append("")
return "\n".join(lines)
# --- main -------------------------------------------------------------------
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
base_front_rgb = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
print("=== 얼굴 랜드마크 검출(base-front.png) ===")
points = detect_face_landmarks(BASE_DIR / "base-front.png")
face_oval = compute_face_oval(points)
mouth_center = compute_mouth_center(points)
global _MOUTH_CENTER_CACHE
_MOUTH_CENTER_CACHE = mouth_center
print(f"mouthCenter(13,14 중점) = {mouth_center}")
fx0 = min(p[0] for p in face_oval); fx1 = max(p[0] for p in face_oval)
fy0 = min(p[1] for p in face_oval); fy1 = max(p[1] for p in face_oval)
print(f"faceOval bbox = [{fx0:.1f}, {fy0:.1f}, {fx1:.1f}, {fy1:.1f}] (w={fx1-fx0:.1f} h={fy1-fy0:.1f})")
print("=== 팔레트 표본 ===")
palette, palette_report = compute_palette(base_front_rgb, manifest)
for k, v in palette.items():
print(f" {k} = {v}")
landmarks = build_landmarks(manifest, mouth_center)
face_crop = compute_face_crop(face_oval)
print(f"crops.face = {[round(v,1) for v in face_crop]}")
print("=== 레이어 게시 ===")
layer_sources: dict[str, tuple[Path, str]] = {}
for key, stem, href_stem, optional in LAYER_SPECS:
src = resolve_layer_source(stem, optional)
if src is not None:
layer_sources[key] = (src, href_stem)
print(f" {key} <- {src.relative_to(REPO_ROOT)}")
elif not optional:
raise SystemExit(f"[중단] 필수 레이어 {key} 소스가 없다.")
else:
print(f" {key} 없음(생략)")
grain_source = resolve_grain_source()
if grain_source is not None:
print(f" grain <- {grain_source.relative_to(REPO_ROOT)}")
else:
print(" grain 없음(생략)")
layers_report, grain_report, publish_meta = publish_layers(layer_sources, grain_source)
print(f"인코딩: quality={publish_meta['encodeMeta']['quality']} scale={publish_meta['encodeMeta']['scale']}")
total = 0
for name, size in publish_meta["fileSizes"].items():
print(f" {name}.webp = {size} bytes")
total += size
print(f"합계 = {total} bytes (예산 {SIZE_BUDGET_BYTES} bytes) {'OK' if total <= SIZE_BUDGET_BYTES else '[초과]'}")
print("=== 입술 결 게시 ===")
lip_texture_field, lip_publish_report = publish_lip_texture(manifest)
if lip_texture_field is None:
print(" lip-upper.png/lip-lower.png/lip-shadow.png 없음(생략) — lipTexture 없는 리그")
print("=== 턱 조각 게시 ===")
jaw_field, jaw_publish_report = publish_jaw_pieces(manifest, layers_report, publish_meta["encodeMeta"])
if jaw_field is None:
print(" jaw-head.png/jaw-detail.png 없음(생략) — jaw 없는 리그")
print("=== 모티프 게시 ===")
motif_field, motif_publish_report = publish_motif()
rig = {
"schemaVersion": "vignette.avatar.v3.rig.v1",
"persona": "P1",
"canvas": {"w": manifest["canvas"]["w"], "h": manifest["canvas"]["h"]},
"layers": layers_report,
"grain": grain_report,
"lipTexture": lip_texture_field,
"jaw": jaw_field,
"motif": motif_field,
"pivots": {"neck": [500, 990], "body": [502, 1566], "face": [490, 660]},
"crops": {
"portrait": [0, 0, manifest["canvas"]["w"], manifest["canvas"]["h"]],
"bust": [0, 40, 1005, 1005],
"face": face_crop,
},
"faceOval": [(round(x, 1), round(y, 1)) for x, y in face_oval],
"landmarks": landmarks,
"palette": palette,
"backdrop": {
"cognitive": "#ECE3D1",
"positive": "#F1DEC2",
"negative": "#DCE0E2",
"defensive": "#E6DAD3",
"energy": "#E2E0D0",
},
}
RIG_TS_PATH.parent.mkdir(parents=True, exist_ok=True)
RIG_TS_PATH.write_text(render_rig_ts(rig), encoding="utf-8", newline="\n")
print(f"저장: {RIG_TS_PATH.relative_to(REPO_ROOT)}")
report = {
"faceOvalBBox": [round(fx0, 1), round(fy0, 1), round(fx1, 1), round(fy1, 1)],
"cropsFace": [round(v, 1) for v in face_crop],
"mouthCenter": [round(mouth_center[0], 1), round(mouth_center[1], 1)],
"palette": palette,
"paletteSamples": palette_report,
"publish": {"fileSizes": publish_meta["fileSizes"], "encodeMeta": publish_meta["encodeMeta"], "totalBytes": total},
"grain": grain_report,
"lipTexturePublish": lip_publish_report,
"jawPublish": jaw_publish_report,
"motifPublish": motif_publish_report,
"rig": rig,
}
report_path = ROOT / "preview" / "v2" / "export-rig-report.json"
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"저장: {report_path.relative_to(REPO_ROOT)}")
manifest["lipTexturePublish"] = lip_publish_report
if lip_publish_report is not None and "webpVsPngMeanAbsDiff" in lip_publish_report:
manifest.setdefault("lipTexture", {})["check4WebpVsPngMeanAbsDiff"] = lip_publish_report["webpVsPngMeanAbsDiff"]
manifest["jawPublish"] = jaw_publish_report
if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report:
manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"]
manifest["motifPublish"] = motif_publish_report
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {MANIFEST_PATH.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())