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