"""공통 리노컷 리그 게시 스크립트(2단계-B-1a, 결정문 §8.2). layers/{v2 있으면 v2 우선, 없으면 layers/*}의 PNG를 알파 bbox로 잘라 WebP로 apps/web/public/avatar/v3//에 게시하고, apps/web/src/components/avatar/v3/rigs/을 생성한다. 같은 입력이면 같은 결과가 나오도록 결정적으로 만든다(팔레트·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를 만들어 두어야 한다. 실행: /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 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.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기") -- BROW_SPECS = [ # (rig key, 파일 stem, public href stem) ("left", "brow-left", "brow-left"), ("right", "brow-right", "brow-right"), ] BROW_SIZE_BUDGET_BYTES = 60 * 1024 # --- 턱 조각(결정문 §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 def publish_brow_sprite(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]: """layers/v2/brow-{left,right}.png(brow_sprite.py 산출)를 알파 bbox로 잘라 WebP로 게시한다(결정문 §8.4.1). 소스가 없으면 (None, None) — browSpriteFromArt=false인 리그(P1)는 RigBrow.sprite 없이 벡터 눈썹을 그대로 쓴다(렌더러 대체 경로). PNG는 이미 알파 bbox로 꽉 차게 잘려 있어(brow_sprite.py crop_to_alpha_bbox) crop_to_bbox는 사실상 no-op이고, 캔버스 위치는 manifest["browSprite"] ["canvasOrigin"]에서 읽는다(publish_lip_texture와 같은 패턴). 인코딩은 입술 스프라이트와 같은 무손실(exact) 우선 — 알파=0 영역도 원화 RGB를 그대로 담고 있으므로(brow_sprite.py의 피부색 역산·잉크색 대체) exact=True로 버려지지 않게 한다.""" canvas_origin = manifest.get("browSprite", {}).get("canvasOrigin") if canvas_origin is None: return None, None sources: dict[str, tuple[Path, str]] = {} for key, stem, href_stem in BROW_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) BROW_DIFF_TARGET = 2.0 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() 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 lossless_encoded = encode_all_lossless() lossless_total = sum(len(b) for b in lossless_encoded.values()) if lossless_total <= BROW_SIZE_BUDGET_BYTES: chosen, chosen_quality = lossless_encoded, "lossless" else: 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 <= BROW_SIZE_BUDGET_BYTES and max(diffs.values()) <= BROW_DIFF_TARGET: chosen, chosen_quality = encoded, q break if chosen is None: within_budget = [c for c in candidates if c[2] <= BROW_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 중 예산({BROW_SIZE_BUDGET_BYTES}B) AND 평균차<={BROW_DIFF_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 (예산 {BROW_SIZE_BUDGET_BYTES} bytes) " f"{'OK' if total_bytes <= BROW_SIZE_BUDGET_BYTES else '[초과]'}" ) 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" 검사 [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}") brow_field = {} for key, (_im, bbox, href_stem) in cropped.items(): x0, y0, w, h = bbox brow_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": BROW_SIZE_BUDGET_BYTES, "webpVsPngMeanAbsDiff": webp_vs_png, } return brow_field, 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)로 다시 늘린다 — 브라우저가 태그를 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 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( cfg: PersonaConfig, manifest: dict, mouth_center: tuple[float, float], brow_sprite_field: dict | None ) -> dict: lm = manifest["landmarks"] half_widths = manifest.get("browCenterline", {}) if cfg.brow_half_width_from_art else {} eye_line_scale = manifest.get("eyeLineScale", {}) if cfg.eye_line_scale_from_art else {} iris_measured = manifest.get("iris", {}) if cfg.iris_from_art else {} def pt(p) -> list[float]: return [round(p[0], 1), round(p[1], 1)] def eye(e, line_scale_key: str) -> dict: iris_m = iris_measured.get(line_scale_key) if iris_m is not None: iris_field = {"center": pt(iris_m["measuredCenter"]), "radius": round(float(iris_m["measuredRadius"]), 1)} else: iris_field = {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)} d = { "inner": pt(e["innerCorner"]), "outer": pt(e["outerCorner"]), "upperLidTop": pt(e["upperLidTop"]), "lowerLidBottom": pt(e["lowerLidBottom"]), "iris": iris_field, } line_scale = eye_line_scale.get(line_scale_key, {}).get("lineScale") if line_scale is not None: d["lineScale"] = round(float(line_scale), 3) return d def brow(b, half_width_key: str, sprite_key: str) -> dict: d = {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])} hw = half_widths.get(half_width_key) if hw is not None: d["halfWidth"] = [round(float(v), 2) for v in hw] if brow_sprite_field is not None and brow_sprite_field.get(sprite_key) is not None: d["sprite"] = brow_sprite_field[sprite_key] return d return { "eyeLeft": eye(lm["eyeLeft"], "left"), "eyeRight": eye(lm["eyeRight"], "right"), "browLeft": brow(lm["eyebrowLeft"], "halfWidthLeft", "left"), "browRight": brow(lm["eyebrowRight"], "halfWidthRight", "right"), "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"])} }},') if e.get("lineScale") is not None: lines.append(f' lineScale: {ts_num(e["lineScale"])},') lines.append(" },") def emit_brow(name, b): hw = b.get("halfWidth") hw_str = f", halfWidth: [{ts_num(hw[0])}, {ts_num(hw[1])}, {ts_num(hw[2])}]" if hw else "" sprite = b.get("sprite") sprite_str = "" if sprite: sprite_str = ( f', sprite: {{ href: "{sprite["href"]}", x: {ts_num(sprite["x"])}, y: {ts_num(sprite["y"])}, ' f'w: {ts_num(sprite["w"])}, h: {ts_num(sprite["h"])} }}' ) lines.append( f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])}{hw_str}{sprite_str} }},' ) 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}") print("=== 눈썹 스프라이트 게시 ===") brow_sprite_field, brow_sprite_publish_report = publish_brow_sprite(cfg, manifest, layers_v2_dir) if brow_sprite_field is None: print(" brow-left.png/brow-right.png 없음(생략) — 벡터 눈썹을 그대로 쓰는 리그") landmarks = build_landmarks(cfg, manifest, mouth_center, brow_sprite_field) 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, "browSpritePublish": brow_sprite_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["browSpritePublish"] = brow_sprite_publish_report if brow_sprite_publish_report is not None and "webpVsPngMeanAbsDiff" in brow_sprite_publish_report: manifest.setdefault("browSprite", {})["checkWebpVsPngMeanAbsDiff"] = brow_sprite_publish_report["webpVsPngMeanAbsDiff"] manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") print( f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish, browSpritePublish): " f"{manifest_path.relative_to(cfg.repo_root)}" ) return 0 if __name__ == "__main__": sys.exit(main(Path(sys.argv[1])))