vignette/docs/avatar-art/linocut-pipeline/scripts/export_rig.py
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

1106 lines
49 KiB
Python

"""공통 리노컷 리그 게시 스크립트(2단계-B-1a, 결정문 §8.2).
layers/{v2 있으면 v2 우선, 없으면 layers/*}의 PNG를 알파 bbox로 잘라 WebP로
apps/web/public/avatar/v3/<publicSlug>/에 게시하고,
apps/web/src/components/avatar/v3/rigs/<rigFileName>을 생성한다. 같은 입력이면
같은 결과가 나오도록 결정적으로 만든다(팔레트·faceOval 계산 방식 고정).
같은 스크립트를 선행 게시(faceDetail·grain 없음)와 최종 게시(둘 다 있음) 양쪽에
쓴다. faceDetail/grain 소스 파일이 없으면 해당 필드를 rig에서 생략한다.
전제: layers_v2.py·face_detail.py·paper_grain.py·lip_texture.py·jaw_pieces.py가
먼저 돌아서 layers/v2/*.png와 manifest.landmarks/lipTexture/jaw를 만들어 두어야
한다.
실행: <venv>/python.exe export_rig.py <persona-dir>
"""
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
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from layers_v2 import halo_metric # noqa: E402
from persona_config import PersonaConfig, load_persona_config # noqa: E402
BUD_STATES = ["closed", "half", "open", "droop"]
WEATHER_GROUPS = ["positive", "negative", "defensive", "cognitive", "energy"]
MOTIF_SIZE_BUDGET_BYTES = 160 * 1024
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 입) -------------------------------------
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 # 그늘 조각 분리로 3장
# --- 턱 조각(결정문 §8.4 하관 띠 변형) ---------------------------------------
JAW_SPECS = [
# (rig key, 파일 stem, public href stem)
("head", "jaw-head", "jaw-head"),
("detail", "jaw-detail", "jaw-detail"),
]
JAW_SIZE_BUDGET_BYTES = 200 * 1024
def resolve_layer_source(layers_dir: Path, layers_v2_dir: Path, 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(layers_v2_dir: Path) -> 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:
buf = io.BytesIO()
im.save(buf, "WEBP", quality=quality, alpha_quality=100, method=6)
return buf.getvalue()
def publish_layers(cfg: PersonaConfig, 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 = cfg.public_dir
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"{cfg.public_href_prefix}/{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"{cfg.public_href_prefix}/{GRAIN_SPEC[1]}.webp", "size": 256}
return layers_report, grain_report, {"fileSizes": file_sizes, "encodeMeta": chosen_meta}
# --- 입술 결 스프라이트 게시(결정문 §8.4 입) --------------------------------
def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
"""layers/v2/lip-{upper,lower,shadow}.png(lip_texture.py 산출)를 알파
bbox로 잘라 WebP로 게시한다. 소스가 없으면 (None, None)을 반환해
lipTexture 없는 리그도 여전히 만들 수 있게 한다(렌더러는 palette 단색
으로 대체). 세 파일 합계 예산은 80KB.
PNG 자체는 이미 알파 bbox로 꽉 차게 잘려 있어(lip_texture.py) 이 함수의
crop_to_bbox는 사실상 no-op이고, 캔버스 위치 정보를 담고 있지 않다 —
RigLayer.x/y(절대 캔버스 좌표)는 manifest["lipTexture"]["canvasOrigin"](작업
캔버스 좌표를 lip_texture.py가 절대 좌표로 환산해 기록한 값)에서 읽는다."""
canvas_origin = manifest.get("lipTexture", {}).get("canvasOrigin")
if canvas_origin is None:
raise SystemExit("[중단] manifest.lipTexture.canvasOrigin이 없다 — 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)
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를 그대로 담고 있으므로(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순위: 무손실. 세 장 합계가 예산 안이면 무손실을 쓴다.
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 = cfg.public_dir
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"{cfg.public_href_prefix}/{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 하관 띠 변형) ----------------------------------
JAW_LAYER_KEY_FOR = {"head": "head", "detail": "faceDetail"} # jaw rig key -> layers_report 키
JAW_DIFF_TARGET = 2.0
def publish_jaw_pieces(cfg: PersonaConfig, manifest: dict, layers_report: dict, encode_meta: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
"""layers/v2/jaw-{head,detail}.png(jaw_pieces.py 산출, 원본 레이어 픽셀을
그대로 잘라낸 것)를 게시한다.
턱 조각을 **같은 레이어가 쓴 것과 같은 인코딩 설정**(quality·alpha_quality·
method, publish_layers의 encode_meta)으로 다시 인코딩한다 — 화면에 실제로
그려지는 원본은 게시된 head.webp·face-detail.webp이고 그 둘 다 이미 손실
압축이라, 턱 조각만 무손실로 게시하면 PNG와는 같아도 화면의 원본과는 달라
이음매가 드러난다. 픽셀 동일성 기준도 PNG가 아니라 게시된
head.webp/face-detail.webp를 같은 캔버스 영역으로 잘라 디코드한 값으로
비교한다."""
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 = cfg.public_dir
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 값은 "화면에
# 보이는 차이"와 무관하다 — 알파를 곱한(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,
}
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) [{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": f"{cfg.public_href_prefix}/jaw-head.webp", "x": x0, "y": y0, "w": w, "h": h},
"detail": {"href": f"{cfg.public_href_prefix}/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(cfg: PersonaConfig) -> tuple[dict | None, dict | None]:
"""motif/sprites/*.png를 WebP로 게시하고(알파 bbox 크롭 없음 — 스프라이트
캔버스 그대로), 좌표는 motif/manifest.json에서 읽어 하드코딩하지 않는다.
반환: (rig의 motif 필드, manifest.json 기록용 리포트). motif/manifest.json이
없으면 (None, None) — 이 라운드는 모티프 원화 제작이 범위 밖인 페르소나가
있을 수 있다(오케스트레이터가 모티프를 따로 설계한다)."""
motif_root = cfg.root / "motif"
motif_manifest_path = motif_root / "manifest.json"
if not motif_manifest_path.exists():
return None, None
motif_sprites_dir = motif_root / "sprites"
motif_public_dir = cfg.public_dir / "motif"
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 '[초과]'}")
prefix = cfg.public_href_prefix
motif_field = {
"bud": {
"canvas": {"w": bud_canvas[0], "h": bud_canvas[1]},
"base": [round(bud_base[0], 2), round(bud_base[1], 2)],
"closed": f"{prefix}/motif/bud-closed.webp",
"half": f"{prefix}/motif/bud-half.webp",
"open": f"{prefix}/motif/bud-open.webp",
"droop": f"{prefix}/motif/bud-droop.webp",
},
"weather": {
"canvas": {"w": weather_canvas[0], "h": weather_canvas[1]},
"sprites": {name: f"{prefix}/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(cfg: PersonaConfig, 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의 흰자·홍채는
# 조각 해칭 그늘 때문에 실측이 불안정하고, 벡터 눈은 그늘을 별도로 그리므로
# persona.json의 paletteSamples.eyeOverride(고정 설계값, 결정문 §8.2 "고정값"
# 원칙)를 쓴다.
sclera_measured = (eL["sclera"] + eR["sclera"]) / 2.0 if eL["sclera"] is not None and eR["sclera"] is not None else None
iris_measured = (eL["iris"] + eR["iris"]) / 2.0 if eL["iris"] is not None and eR["iris"] is not None else None
iris_ring_measured = (
(eL["irisRing"] + eR["irisRing"]) / 2.0 if eL["irisRing"] is not None and eR["irisRing"] is not None else None
)
sclera = _hex_to_rgb(cfg.eye_override["sclera"])
iris = _hex_to_rgb(cfg.eye_override["iris"])
iris_ring = _hex_to_rgb(cfg.eye_override["irisRing"])
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": "persona.json paletteSamples.eyeOverride(고정 설계값)",
"measured": {
"sclera": hexc(sclera_measured) if sclera_measured is not None else None,
"iris": hexc(iris_measured) if iris_measured is not None else None,
"irisRing": hexc(iris_ring_measured) if iris_ring_measured is not None else None,
},
"fixed": {"sclera": hexc(sclera), "iris": hexc(iris), "irisRing": hexc(iris_ring)},
}
# ink: 머리카락 덩어리 안 어두운 픽셀(persona.json paletteSamples.ink, lum<threshold)
ink_box, ink_lum_thresh = cfg.require_ink_sample()
ix0, iy0, ix1, iy1 = [int(round(v)) for v in ink_box]
hair_region = im[iy0:iy1, ix0:ix1]
hair_lum = hair_region.mean(axis=2)
dark_px = hair_region[hair_lum < ink_lum_thresh]
ink = np.median(dark_px, axis=0)
samples_report["ink"] = {"box": [ix0, iy0, ix1, iy1], "lumThreshold": ink_lum_thresh, "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)}
# 모티프: persona.json에 정의된 스타일 참조 이미지에서 꽃잎(ochre)·잎/구름(blue) 표본
style_frame_path = cfg.require_style_frame()
petal_boxes, leaf_boxes = cfg.require_motif_boxes()
style = np.array(Image.open(style_frame_path).convert("RGB")).astype(np.float64)
def box_median(box, kind):
x0b, y0b, x1b, y1b = [int(round(v)) for v in box]
region = style[y0b:y1b, x0b:x1b].reshape(-1, 3)
r, g, b = region[:, 0], region[:, 1], region[:, 2]
lum = region.mean(axis=1)
if kind == "ochre":
mask = (r > 150) & (r - b > 50) & (r - g > 15)
else:
mask = (b > r) & (lum > 60) & (lum < 190)
sel = region[mask]
return (np.median(sel, axis=0) if len(sel) else None), len(sel)
petal_samples = [(spec["label"], *box_median(spec["box"], spec["kind"]), spec["box"]) for spec in petal_boxes]
petal_values = [v for _label, v, _n, _box in petal_samples if v is not None]
if not petal_values:
raise SystemExit("[중단] motifPetalBoxes 표본에서 ochre 픽셀을 하나도 찾지 못했다.")
motif_petal = np.mean(petal_values, axis=0)
leaf_samples = [(spec["label"], *box_median(spec["box"], spec["kind"]), spec["box"]) for spec in leaf_boxes]
leaf_values = [v for _label, v, _n, _box in leaf_samples if v is not None]
if not leaf_values:
raise SystemExit("[중단] motifLeafBoxes 표본에서 blue 픽셀을 하나도 찾지 못했다.")
motif_leaf = np.mean(leaf_values, axis=0)
try:
style_frame_display = str(style_frame_path.relative_to(cfg.repo_root))
except ValueError:
style_frame_display = str(style_frame_path)
samples_report["motifPetal"] = {
"sourceImage": style_frame_display,
"samples": {label: {"box": list(box), "n": n, "hex": hexc(v) if v is not None else None} for label, v, n, box in petal_samples},
"hex": hexc(motif_petal),
}
samples_report["motifLeaf"] = {
"sourceImage": style_frame_display,
"samples": {label: {"box": list(box), "n": n, "hex": hexc(v) if v is not None else None} for label, v, n, box in leaf_samples},
"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": cfg.palette_fixed["mouthInner"],
"teeth": cfg.palette_fixed["teeth"],
"blush": cfg.palette_fixed["blush"],
"tear": cfg.palette_fixed["tear"],
"pallor": cfg.palette_fixed["pallor"],
"paper": cfg.palette_fixed["paper"],
"motifPetal": hexc(motif_petal),
"motifLeaf": hexc(motif_leaf),
}
return palette, samples_report
def _hex_to_rgb(hex_str: str) -> np.ndarray:
hex_str = hex_str.lstrip("#")
return np.array([int(hex_str[0:2], 16), int(hex_str[2:4], 16), int(hex_str[4:6], 16)], dtype=np.float64)
_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/bust 계산 ----------------------------------------------------
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)
def compute_bust_crop(cfg: PersonaConfig, canvas_w: int) -> tuple[float, float, float, float]:
"""정사각형 크롭, 변 길이는 캔버스 폭(인물이 캔버스 폭에 거의 꽉 차는 흉상
구도라는 전제). 위쪽 오프셋만 persona.json(crops.bustYOffset)에서 읽는다
(원화마다 인물이 캔버스 안에서 수직으로 얼마나 올려/내려 잡혔는지가
달라 랜드마크로 유도하기보다 사람이 보고 정하는 편이 안전하다)."""
return (0.0, cfg.bust_crop_y_offset, float(canvas_w), float(canvas_w))
# --- 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(cfg: PersonaConfig, rig: dict) -> str:
lines: list[str] = []
lines.append("/* 생성 파일 — docs/avatar-art/linocut-pipeline/scripts/export_rig.py 가 만든다. 손으로 고치지 않는다. */")
lines.append('import type { LinocutRig } from "../linocutRig";')
lines.append("")
lines.append(f"export const {cfg.rig_export_name}: 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(persona_dir: Path) -> int:
cfg = load_persona_config(persona_dir)
base_dir = cfg.base_dir
layers_dir = cfg.layers_dir
layers_v2_dir = cfg.layers_v2_dir
manifest_path = cfg.manifest_path
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(cfg, 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(layers_dir, layers_v2_dir, stem, optional)
if src is not None:
layer_sources[key] = (src, href_stem)
print(f" {key} <- {src.relative_to(cfg.repo_root)}")
elif not optional:
raise SystemExit(f"[중단] 필수 레이어 {key} 소스가 없다.")
else:
print(f" {key} 없음(생략)")
grain_source = resolve_grain_source(layers_v2_dir)
if grain_source is not None:
print(f" grain <- {grain_source.relative_to(cfg.repo_root)}")
else:
print(" grain 없음(생략)")
layers_report, grain_report, publish_meta = publish_layers(cfg, 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(cfg, manifest, layers_v2_dir)
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(cfg, manifest, layers_report, publish_meta["encodeMeta"], layers_v2_dir)
if jaw_field is None:
print(" jaw-head.png/jaw-detail.png 없음(생략) — jaw 없는 리그")
print("=== 모티프 게시 ===")
motif_field, motif_publish_report = publish_motif(cfg)
if motif_field is None:
print(" motif/manifest.json 없음(생략) — 모티프 없는 리그")
pivots = cfg.require_pivots()
rig = {
"schemaVersion": "vignette.avatar.v3.rig.v1",
"persona": cfg.code,
"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": list(pivots["neck"]), "body": list(pivots["body"]), "face": list(pivots["face"])},
"crops": {
"portrait": [0, 0, manifest["canvas"]["w"], manifest["canvas"]["h"]],
"bust": list(compute_bust_crop(cfg, manifest["canvas"]["w"])),
"face": face_crop,
},
"faceOval": [(round(x, 1), round(y, 1)) for x, y in face_oval],
"landmarks": landmarks,
"palette": palette,
"backdrop": dict(cfg.backdrop),
}
rig_ts_path = cfg.rig_ts_path
rig_ts_path.parent.mkdir(parents=True, exist_ok=True)
rig_ts_path.write_text(render_rig_ts(cfg, rig), encoding="utf-8", newline="\n")
print(f"저장: {rig_ts_path.relative_to(cfg.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 = cfg.preview_v2_dir / "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(cfg.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(cfg.repo_root)}")
return 0
if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1])))