- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
260 lines
12 KiB
Python
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,
|
|
)
|