vignette/docs/avatar-art/linocut-pipeline/README.md
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

8.5 KiB

공통 리노컷 아바타 파이프라인

아바타 v3 리노컷 리그(결정문 avatar-expression-engine-v3.md §8.2 리그 원칙)의 자산 파이프라인이다. P1~P7 등 모든 페르소나가 이 공통 스크립트를 공유하고, 페르소나별 차이는 각 페르소나 폴더(예: ../p1-linocut/)의 persona.json에서만 읽는다.

결과물(레이어 픽셀, 알파, 게시된 WebP, 리그 TS)은 페르소나별 폴더 구조나 상수 위치가 바뀌었다는 이유로 달라지지 않는다 — 알고리즘은 건드리지 않았다(P1 회귀 검증: 아래 참고).

단계와 의존 순서

run_pipeline.py가 아래 순서로 각 단계를 독립 프로세스로 실행한다(괄호는 산출물):

  1. landmarks.py — base-front.png 랜드마크 검출 (manifest.landmarks)
  2. brow_centerline.py — manifest.landmarks.eyebrowLeft/Right를 잉크 띠 중심선으로 보정
  3. segmentation.py — layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션
  4. layers_v2.py — layers/v2/{body,head,hairFront}.png(턱 밑 띠·잔머리 halo 보정)
  5. face_detail.py — layers/v2/face-detail.png
  6. paper_grain.py — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)
  7. lip_texture.py — layers/v2/lip-{upper,lower,shadow}.png
  8. jaw_pieces.py — layers/v2/jaw-{head,detail}.png
  9. export_rig.py — WebP 게시(apps/web/public/avatar/v3/<publicSlug>/) + rigs/<rigFileName> 생성
  10. final_previews.py — 게시된 WebP로 모션·눈/입 확대 미리보기

이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 final_previews는 export_rig가 쓴 export-rig-report.json을 읽으므로 반드시 export_rig 다음이어야 한다. face_detail·jaw_pieces는 manifest.landmarks(12단계가 채움)와 layers/v2/{head,hairFront,body}.png(34단계)가 먼저 있어야 한다.

실행

# 전체 실행
<venv>/python.exe run_pipeline.py <persona-dir>

# 한 단계만 다시 실행
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail

# 중간부터 끝까지
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture

# 단계 이름 목록
<venv>/python.exe run_pipeline.py --list

각 단계 스크립트는 python <script>.py <persona-dir>로 단독 실행도 된다(디버깅용).

주의: landmarks.py만 혼자 다시 돌리면 manifest.landmarks를 통째로 새로 써서 brow_centerline.py가 보정한 눈썹 중심선이 사라진다. --only landmarks를 쓴 뒤에는 --only brow_centerline도 반드시 같이 돌려야 한다(자동으로 뒤따라 돌지 않는다).

persona.json 스키마

페르소나 폴더(예: ../p1-linocut/persona.json)에 둔다. 랜드마크·분할로 계산 가능한 값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는 값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.

{
  "code": "P1",                        // 필수. manifest.persona, rig.persona
  "publicSlug": "p1",                  // 생략 시 code.lower(). apps/web/public/avatar/v3/<publicSlug>/
  "rigFileName": "p1Rig.ts",           // 생략 시 "<publicSlug>Rig.ts"
  "rigExportName": "P1_LINOCUT_RIG",   // 생략 시 "<CODE>_LINOCUT_RIG"

  // 모티프 팔레트(motifPetal/motifLeaf) 표본을 뽑을 스타일 참조 이미지(persona.json 기준 상대경로).
  // export_rig.py의 팔레트 계산에서만 쓴다 — 모티프 스프라이트 자체는 이번 파이프라인
  // 범위 밖이다(오케스트레이터가 따로 설계).
  "styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",

  "paletteSamples": {
    // 머리카락 어두운 덩어리 표본(ink 팔레트색). base-front.png 픽셀 기준 상자.
    "ink": { "box": [280, 100, 720, 350], "lumThreshold": 55 },
    // 눈 흰자/홍채/홍채테 고정 설계값(결정문 §8.2 "고정값" 원칙) — 생략하면 공통 기본값 사용.
    "eyeOverride": { "sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F" },
    // styleFrame에서 꽃잎(ochre)·잎/구름(blue) 색을 뽑을 상자들. kind는 "ochre" 또는 "blue".
    "motifPetalBoxes": [{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] }],
    "motifLeafBoxes": [{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] }]
  },

  "faceDetail": {
    // 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
    "moles": [{ "center": [661.3, 627.9], "radius": 20.0 }]
  },

  // 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다.
  "pivots": { "neck": [500, 990], "body": [502, 1566], "face": [490, 660] },

  // bust 크롭은 정사각형(변 = 캔버스 폭)이고 위쪽 오프셋만 여기서 정한다.
  "crops": { "bustYOffset": 40 }
}

eyeOverride·paletteFixed(mouthInner/teeth/blush/tear/pallor/paper)·backdrop(겉표정 그룹별 배경색)은 모든 페르소나가 공유하는 기본값이 있다(persona_config.py의 DEFAULT_*) — 캐릭터마다 다르게 할 필요가 있을 때만 persona.json에 적어 덮어쓴다.

styleFrame·paletteSamples.ink·paletteSamples.motifPetalBoxes/motifLeafBoxes· pivots는 필수다(export_rig.py가 해당 값을 쓰는 시점에 없으면 어떤 필드를 채워야 하는지 알려주며 멈춘다).

faceDetail.browLandmarksOverride — P1 전용 호환 장치, 새 페르소나는 쓰지 않는다

"faceDetail": {
  "browLandmarksOverride": {
    "browLeft": { "inner": [..], "peak": [..], "outer": [..] },
    "browRight": { "inner": [..], "peak": [..], "outer": [..] }
  }
}

있으면 face_detail.py가 눈썹 제외 영역·눈 영역 y0 계산에 manifest.landmarks의 현재(중심선 보정) 눈썹 좌표 대신 이 값을 쓴다. 다른 랜드마크(눈·입·코·턱)는 그대로 현재 값을 쓴다 — 눈썹만 바꾼다.

P1의 기존 face-detail.png·jaw-detail.png·해당 webp·p1Rig.ts는 눈썹 중심선 보정 (manifest.browCenterline) 이전 좌표(browCenterline.oldPoints)로 빌드된 뒤 "다시 빌드하지 않는다"는 오케스트레이터 지시로 고정됐다(소유자도 그 결과를 검수했다). 그래서 P1 persona.json에는 browCenterline.oldPoints와 같은 값을 넣어 재실행 결과가 그 고정본과 바이트 단위로 같아지게 한다. 새 페르소나는 이 필드를 넣지 않는다 — 처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.

모델 파일(저장소에 없음)

scripts/_models/에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, .gitignore의 docs/avatar-art/*/scripts/_models/ 패턴에 그대로 맞는다).

python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.

새 페르소나를 추가할 때 사람이 해야 하는 일

이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야 하는 것:

  1. 원화 2장: <persona-dir>/base/base-front.png(정면 기본형)과 base-faceless.png(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
  2. <persona-dir>/raw/body.png: body 레이어가 head_mask로 가려지는 목 상단 영역을 메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬).
  3. <persona-dir>/persona.json: 위 스키마대로 작성한다. 특히 styleFrame· paletteSamples·pivots는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
  4. (선택) <persona-dir>/motif/: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가 따로 설계한다. 없으면 export_rig.py가 모티프 없는 리그를 만든다(rig.motif 생략).
  5. scripts/_models/에 모델 파일이 없으면 받아 둔다(위 링크, 한 번만).

그 다음 run_pipeline.py <persona-dir>를 실행하고, 출력된 검사 수치(halo%, 평균절대차 등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다.