vignette/docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Yun Chan 98577cf4ae 리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움)
- faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor
- brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측
- 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기
- 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조)
- P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
2026-10-01 22:38:27 +09:00

260 lines
12 KiB
Python

"""공통 리노컷 파이프라인의 페르소나별 설정 로더.
페르소나 폴더(예: docs/avatar-art/p1-linocut/)의 persona.json을 읽어
PersonaConfig로 만든다. 파이프라인 스크립트들은 이 모듈을 통해서만 페르소나별
차이(게시 경로·rig 내보내기 이름·팔레트 표본 상자·점 위치·피벗 등)를 얻는다.
persona.json에 없는 선택 필드는 모두 기본값(P1에서 쓰던 값과 같은 보수적 기본값)으로
채운다 — 랜드마크로 유도할 수 없는 "원화 고유" 상수(점 위치, 팔레트 표본 상자,
피벗 등)만 이 설정에 둔다. 랜드마크·분할로 계산 가능한 것은 각 단계 스크립트가
그대로 계산한다(이 모듈이 대신하지 않는다).
"""
from __future__ import annotations
import json
from dataclasses import dataclass
from pathlib import Path
# 모든 페르소나가 같은 종이·잉크 계열(리노컷 판화)이라 공유하는 기본값.
# persona.json이 생략하면 이 값을 쓴다. 종이색(#EEE5D3)은 내부 합성·마스크 계산에
# 쓰는 고정 상수라 각 단계 스크립트에 그대로 둔다(모든 페르소나가 같은 종이이고,
# persona.json에서 조정할 축이 아니다 — 캐릭터별로 달라지는 "배경색"은 UI에 쓰이는
# rig.backdrop이며 그건 persona.json의 backdrop 필드로 다룬다).
DEFAULT_EYE_OVERRIDE = {"sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F"}
DEFAULT_PALETTE_FIXED = {
"mouthInner": "#3B201B",
"teeth": "#E9E0CF",
"blush": "#C0624A",
"tear": "#EEE5D3",
"pallor": "#9AA3A6",
"paper": "#EEE5D3",
}
DEFAULT_BACKDROP = {
"cognitive": "#ECE3D1",
"positive": "#F1DEC2",
"negative": "#DCE0E2",
"defensive": "#E6DAD3",
"energy": "#E2E0D0",
}
DEFAULT_BUST_Y_OFFSET = 40.0
DEFAULT_FACE_INSET_PX = 8.0
@dataclass
class PersonaConfig:
persona_dir: Path
code: str
public_slug: str
rig_file_name: str
rig_export_name: str
# 아래 필드들은 원화마다 달라지고 랜드마크로 유도할 수 없는(참조 이미지를 보고
# 사람이 고른) 값이라 persona.json에 없으면 None/빈 값으로 두고, 실제로 그
# 값이 필요한 단계 스크립트가 쓰는 시점에 어떤 필드를 채워야 하는지 에러로
# 알린다(로드 시점에 한꺼번에 요구하지 않는다 — 단계별로 독립 실행 가능해야 함).
style_frame_path: Path | None
palette_ink_box: list[float] | None
palette_ink_lum_threshold: float | None
eye_override: dict
motif_petal_boxes: list[dict]
motif_leaf_boxes: list[dict]
moles: list[dict]
pivots: dict | None
bust_crop_y_offset: float
palette_fixed: dict
backdrop: dict
# P1 전용 호환 장치(새 페르소나는 쓰지 않는다). face_detail.py가 눈썹 제외 영역·
# eyeRegion y0을 계산할 때 쓰는 눈썹 좌표를 manifest.landmarks(중심선 보정본)
# 대신 이 값으로 바꿔 쓴다. P1의 기존 face-detail.png·jaw-detail.png·
# face-detail.webp·jaw-detail.webp·p1Rig.ts는 눈썹 중심선 보정(browCenterline)
# 이전 좌표(manifest.browCenterline.oldPoints)로 빌드된 뒤 "다시 빌드하지
# 않는다"는 오케스트레이터 지시로 고정됐다 — 그 시점 좌표를 재현해야 바이트
# 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각
# inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마.
brow_landmarks_override: dict | None
# false면 export_rig.py가 RigBrow.halfWidth를 쓰지 않는다(렌더러가 linocutGeometry.ts
# BROW_STOPS 고정값을 쓴다). 기본 true — 원화에서 잰 눈썹 반두께(manifest.browCenterline.
# halfWidth{Left,Right})를 쓴다. P1은 이미 고정 굵기로 빌드·검수됐으므로 false로 둔다.
brow_half_width_from_art: bool
# false면 brow_sprite.py가 눈썹 스프라이트(layers/v2/brow-{left,right}.png)를
# 만들지 않는다(결정문 §8.4.1). 기본 true. P1은 이미 고정 벡터 눈썹으로
# 빌드·검수됐으므로 false로 둔다.
brow_sprite_from_art: bool
# false면 brow_sprite.py가 눈꺼풀 선 굵기 배율(RigEye.lineScale)을 재지 않는다
# (결정문 §8.4.1). 기본 true. P1은 lineScale의 기준값 자체(P1_UPPER_LID_THICKNESS_PX)라
# false로 둔다.
eye_line_scale_from_art: bool
# false면 export_rig.py가 RigEye.iris에 manifest.landmarks(mediapipe)의 값을 그대로
# 쓴다. 기본 true — brow_sprite.py가 원화에서 실측한 홍채(manifest.iris)를 쓴다
# (2차 보정 T3: mediapipe iris 랜드마크는 실제 눈 비례로 학습돼 판화 잉크 홍채보다
# 크게 잡는 경향이 있다). P1은 이미 고정 벡터 렌더로 빌드·검수됐으므로 false로 둔다.
iris_from_art: bool
# faceDetail.region: "features"(기본, 눈·입 주변 타원만) | "face"(얼굴 오벌 전체 —
# 캐스트 확장, 얼굴 피부 해칭 보존). face_detail.py만 쓴다.
face_detail_region: str
# region="face"일 때 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px) — 원화 윤곽 잉크선이
# 이 영역에 들어오지 않게 하는 여유. region="features"면 쓰이지 않는다.
face_inset_px: float
# true면 face_detail.py가 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의
# 색을 주변 원화 피부에 맞춰 평행이동한다(보간/블러 없음). 기본 false(P1 그대로).
match_faceless_color: bool
def require_pivots(self) -> dict:
if self.pivots is None:
raise SystemExit(
f"[중단] {self.persona_dir.name}/persona.json에 pivots(neck/body/face)가 없다 — "
"원화를 보고 목·몸통·얼굴 회전 중심점을 정해 채워야 한다."
)
return self.pivots
def require_ink_sample(self) -> tuple[list[float], float]:
if self.palette_ink_box is None or self.palette_ink_lum_threshold is None:
raise SystemExit(
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.ink(box/lumThreshold)가 "
"없다 — base-front.png에서 머리카락 어두운 덩어리를 담는 상자를 정해야 한다."
)
return self.palette_ink_box, self.palette_ink_lum_threshold
def require_style_frame(self) -> Path:
if self.style_frame_path is None or not self.style_frame_path.exists():
raise SystemExit(
f"[중단] {self.persona_dir.name}/persona.json의 styleFrame이 없거나 파일이 없다 — "
"모티프 팔레트(motifPetal/motifLeaf)를 뽑을 스타일 참조 이미지가 필요하다."
)
return self.style_frame_path
def require_motif_boxes(self) -> tuple[list[dict], list[dict]]:
if not self.motif_petal_boxes or not self.motif_leaf_boxes:
raise SystemExit(
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.motifPetalBoxes/"
"motifLeafBoxes가 없다 — styleFrame에서 꽃잎(ochre)·잎/구름(blue) 표본 상자를 정해야 한다."
)
return self.motif_petal_boxes, self.motif_leaf_boxes
# --- 공통 경로(모든 단계 스크립트가 쓰는 폴더 레이아웃, 페르소나 폴더 안에서 고정) ---
@property
def root(self) -> Path:
return self.persona_dir
@property
def base_dir(self) -> Path:
return self.persona_dir / "base"
@property
def raw_dir(self) -> Path:
return self.persona_dir / "raw"
@property
def layers_dir(self) -> Path:
return self.persona_dir / "layers"
@property
def layers_v2_dir(self) -> Path:
return self.persona_dir / "layers" / "v2"
@property
def preview_dir(self) -> Path:
return self.persona_dir / "preview"
@property
def preview_v2_dir(self) -> Path:
return self.persona_dir / "preview" / "v2"
@property
def manifest_path(self) -> Path:
return self.persona_dir / "manifest.json"
# --- 저장소 안 게시 경로(apps/web) ---
@property
def repo_root(self) -> Path:
# docs/avatar-art/<persona>/ -> parents[0]=avatar-art, [1]=docs, [2]=저장소 루트
return self.persona_dir.parents[2]
@property
def public_dir(self) -> Path:
return self.repo_root / "apps" / "web" / "public" / "avatar" / "v3" / self.public_slug
@property
def public_href_prefix(self) -> str:
return f"/avatar/v3/{self.public_slug}"
@property
def rig_ts_path(self) -> Path:
return (
self.repo_root / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / self.rig_file_name
)
def load_persona_config(persona_dir: Path) -> PersonaConfig:
persona_dir = Path(persona_dir).resolve()
cfg_path = persona_dir / "persona.json"
if not cfg_path.exists():
raise SystemExit(f"[중단] 페르소나 설정이 없다: {cfg_path}")
data = json.loads(cfg_path.read_text(encoding="utf-8"))
code = data["code"]
public_slug = data.get("publicSlug", code.lower())
rig_file_name = data.get("rigFileName", f"{public_slug}Rig.ts")
rig_export_name = data.get("rigExportName", f"{code}_LINOCUT_RIG")
style_frame = data.get("styleFrame")
style_frame_path = (persona_dir / style_frame).resolve() if style_frame else None
palette_samples = data.get("paletteSamples", {})
ink = palette_samples.get("ink")
palette_ink_box = ink.get("box") if ink else None
palette_ink_lum_threshold = ink.get("lumThreshold") if ink else None
eye_override = palette_samples.get("eyeOverride", DEFAULT_EYE_OVERRIDE)
motif_petal_boxes = palette_samples.get("motifPetalBoxes", [])
motif_leaf_boxes = palette_samples.get("motifLeafBoxes", [])
face_detail = data.get("faceDetail", {})
moles = face_detail.get("moles", [])
brow_landmarks_override = face_detail.get("browLandmarksOverride")
brow_half_width_from_art = data.get("browHalfWidthFromArt", True)
brow_sprite_from_art = data.get("browSpriteFromArt", True)
eye_line_scale_from_art = data.get("eyeLineScaleFromArt", True)
iris_from_art = data.get("irisFromArt", True)
face_detail_region = face_detail.get("region", "features")
if face_detail_region not in ("features", "face"):
raise SystemExit(
f"[중단] {persona_dir.name}/persona.json의 faceDetail.region={face_detail_region!r}는 "
"지원하지 않는다 — \"features\" 또는 \"face\"만 쓴다."
)
face_inset_px = float(face_detail.get("faceInsetPx", DEFAULT_FACE_INSET_PX))
match_faceless_color = bool(face_detail.get("matchFacelessColor", False))
pivots = data.get("pivots")
crops = data.get("crops", {})
bust_crop_y_offset = crops.get("bustYOffset", DEFAULT_BUST_Y_OFFSET)
palette_fixed = {**DEFAULT_PALETTE_FIXED, **data.get("paletteFixed", {})}
backdrop = {**DEFAULT_BACKDROP, **data.get("backdrop", {})}
return PersonaConfig(
persona_dir=persona_dir,
code=code,
public_slug=public_slug,
rig_file_name=rig_file_name,
rig_export_name=rig_export_name,
style_frame_path=style_frame_path,
palette_ink_box=list(palette_ink_box) if palette_ink_box is not None else None,
palette_ink_lum_threshold=(
float(palette_ink_lum_threshold) if palette_ink_lum_threshold is not None else None
),
eye_override=eye_override,
motif_petal_boxes=motif_petal_boxes,
motif_leaf_boxes=motif_leaf_boxes,
moles=moles,
pivots=pivots,
bust_crop_y_offset=float(bust_crop_y_offset),
palette_fixed=palette_fixed,
backdrop=backdrop,
brow_landmarks_override=brow_landmarks_override,
brow_half_width_from_art=bool(brow_half_width_from_art),
brow_sprite_from_art=bool(brow_sprite_from_art),
eye_line_scale_from_art=bool(eye_line_scale_from_art),
iris_from_art=bool(iris_from_art),
face_detail_region=face_detail_region,
face_inset_px=face_inset_px,
match_faceless_color=match_faceless_color,
)