리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
This commit is contained in:
parent
f11e76ff18
commit
ecb36d123f
38 changed files with 2393 additions and 990 deletions
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""공통 리노컷 파이프라인의 페르소나별 설정 로더.
|
||||
|
||||
페르소나 폴더(예: 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
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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")
|
||||
|
||||
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,
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue