- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
353 lines
23 KiB
Markdown
353 lines
23 KiB
Markdown
# 공통 리노컷 아바타 파이프라인
|
||
|
||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/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. `brow_sprite.py` — `layers/v2/brow-{left,right}.png`(원화 눈썹 스프라이트) +
|
||
`manifest.eyeLineScale`(눈꺼풀 선 굵기 배율) + `manifest.iris`(홍채 실측, 플래그와
|
||
무관하게 항상 기록). 아래 "`browSpriteFromArt`/`eyeLineScaleFromArt`/
|
||
`irisFromArt`" 절 참고.
|
||
7. `paper_grain.py` — `layers/v2/paper-grain.png`(다른 단계와 독립, 순서 유연)
|
||
8. `lip_texture.py` — `layers/v2/lip-{upper,lower,shadow}.png`
|
||
9. `jaw_pieces.py` — `layers/v2/jaw-{head,detail}.png`
|
||
10. `export_rig.py` — WebP 게시(`apps/web/public/avatar/v3/<publicSlug>/`) + `rigs/<rigFileName>` 생성
|
||
11. `final_previews.py` — 게시된 WebP로 모션·눈/입 확대 미리보기
|
||
|
||
이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 `final_previews`는
|
||
`export_rig`가 쓴 `export-rig-report.json`을 읽으므로 반드시 `export_rig` *다음*이어야
|
||
한다. `face_detail`·`jaw_pieces`는 `manifest.landmarks`(1~2단계가 채움)와
|
||
`layers/v2/{head,hairFront,body}.png`(3~4단계)가 먼저 있어야 한다.
|
||
|
||
## 실행
|
||
|
||
```bash
|
||
# 전체 실행
|
||
<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`)에 둔다. 랜드마크·분할로 계산 가능한
|
||
값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는
|
||
값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.
|
||
|
||
```jsonc
|
||
{
|
||
"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 }],
|
||
// "features"(기본)면 눈·눈썹·입 주변 타원만 faceDetail(원화 픽셀) 범위다. "face"면
|
||
// 얼굴 오벌 전체(moles 포함)로 넓어진다 — 캐스트 확장(새 페르소나)은 "face"를 쓴다.
|
||
// 자세한 설계는 아래 "faceDetail.region" 절 참고.
|
||
"region": "face",
|
||
// region="face"일 때만 쓴다. 얼굴 오벌 경계에서 안쪽으로 줄이는 폭(px, 기본 8) —
|
||
// 원화 윤곽 잉크선이 faceDetail 범위 밖에 남도록 원화를 보고 정한다.
|
||
"faceInsetPx": 8,
|
||
// true면 제외 영역(눈·눈썹·입) 안을 채우는 faceless(F) 픽셀의 색을 주변 원화
|
||
// 피부에 맞춰 평행이동한다(기본 false). 아래 "faceDetail.matchFacelessColor" 절 참고.
|
||
"matchFacelessColor": true
|
||
},
|
||
|
||
// 렌더러 회전/스케일 중심점(결정문 §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 전용 호환 장치, 새 페르소나는 쓰지 않는다
|
||
|
||
```jsonc
|
||
"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`와 같은 값을 넣어 재실행 결과가
|
||
그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** —
|
||
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
|
||
|
||
### `browHalfWidthFromArt` — 눈썹 굵기를 원화에서 잴지 여부(기본 true, 캐스트 확장)
|
||
|
||
```jsonc
|
||
{
|
||
"browHalfWidthFromArt": false
|
||
}
|
||
```
|
||
|
||
렌더러(`v3/linocutGeometry.ts` buildBrow)의 눈썹 반두께는 기본으로 P1 설계값
|
||
(`BROW_STOPS` = [4.5, 3.2, 1.0])이 아니라 **원화에서 실측한 값**을 쓴다
|
||
(`brow_centerline.py`가 눈썹 잉크 띠의 중심선 법선 방향 반두께를 안쪽·꼭짓점·바깥
|
||
세 점에서 재 `manifest.browCenterline.halfWidth{Left,Right}`에 남기고,
|
||
`export_rig.py`가 `browHalfWidthFromArt`가 `false`가 아니면(필드 생략 시 기본 true)
|
||
그 값을 `rig.landmarks.browLeft/Right.halfWidth`로 내보낸다). 굵은 눈썹 캐릭터(성인·
|
||
남성 등)가 P1 고정 굵기로 그려져 원화와 달라지는 문제를 캐스트 확장에서 없앤다.
|
||
|
||
P1은 `false`로 둔다 — 기존 `p1Rig.ts`가 2단계-B에서 소유자 검수를 거쳐 고정됐으므로
|
||
(§8.4 눈썹 항목) 재실행해도 바뀌면 안 된다. 측정값 자체는 `browHalfWidthFromArt`
|
||
설정과 무관하게 항상 manifest에 남는다(참고용). **새 페르소나는 이 필드를 생략한다**
|
||
(기본 true로 원화 실측값을 쓴다).
|
||
|
||
### `browSpriteFromArt`/`eyeLineScaleFromArt`/`irisFromArt` — 눈썹 스프라이트·눈꺼풀 굵기·홍채(모두 기본 true)
|
||
|
||
```jsonc
|
||
{
|
||
"browSpriteFromArt": false,
|
||
"eyeLineScaleFromArt": false,
|
||
"irisFromArt": false
|
||
}
|
||
```
|
||
|
||
굵은 판화 눈썹 캐릭터는 벡터 눈썹(P1 설계 굵기에 맞춘 리본·털 획)이 "갈색빛 회색
|
||
막대"로 그려진다(결정문 §8.4.1). 입술과 같은 "중립 렌더 = 원화" 원칙을 적용한다:
|
||
|
||
- `browSpriteFromArt`(기본 true): `brow_sprite.py`가 눈썹 중심선(`manifest.landmarks.
|
||
eyebrowLeft/Right` + `browCenterline.halfWidth{Left,Right}`)을 따라 띠를 만들고,
|
||
그 안의 원화 잉크(Otsu+diff 연결성분, `brow_centerline.py`와 같은 기준)에서 머리카락
|
||
(`layers/v2/hairFront.png` 알파 — 분할 카테고리 자체가 아니다. 이 화풍에서는
|
||
mediapipe 머리카락 분할이 두꺼운 눈썹 잉크 자체를 머리카락으로 오탐해, 분할 결과를
|
||
그대로 빼면 눈썹 잉크 태반이 사라진다. 실제로 화면에서 눈썹 위에 겹쳐 그려질
|
||
hairFront 알파로 기준을 좁혀야 "앞머리 가닥이 눈썹과 같이 움직이면 안 된다"는
|
||
의도를 과하게 지우지 않고 구현할 수 있다)를 뺀 자리만 `brow-{left,right}.png`로
|
||
뗀다. `export_rig.py`가 입술 결과 같은 무손실(exact) WebP로 게시하고
|
||
`rig.landmarks.browLeft/Right.sprite`를 채운다 — 렌더러(`linocutGeometry.ts`
|
||
`computeBrowSpriteStripTransforms`)가 벡터 몸통·털 획 대신 이 스프라이트를 세로
|
||
16띠로 나눠 옮긴다(안쪽 올림 그늘은 그대로 벡터로 그린다).
|
||
- `eyeLineScaleFromArt`(기본 true): `brow_sprite.py`가 눈 폭 가운데 60% 열마다
|
||
`upperLidTop.y-12`~홍채 중심 y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값을
|
||
재, P1 기준값(2.0px, 상수로 고정 — 근거는 `brow_sprite.py` 주석 참고)으로 나눈
|
||
비를 `[1.0, 2.5]`로 clamp한 값을 `manifest.eyeLineScale`에 남긴다. `export_rig.py`가
|
||
`rig.landmarks.eyeLeft/Right.lineScale`로 내보내고, 렌더러가 눈꺼풀·쌍꺼풀·
|
||
아랫눈꺼풀 굵기에 곱한다(홍채·흰자는 그대로).
|
||
|
||
- `irisFromArt`(기본 true): mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로
|
||
학습돼, 판화 잉크로 그려진 홍채보다 반지름을 크게(또는 중심을 다르게) 잡을 수 있다
|
||
(2차 보정 T3, P7 실측: 가로 반지름 기준 리그 대비 약 11~15% 작다). `brow_sprite.py`가
|
||
(browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상) 원화에서 홍채 가로 반지름·
|
||
중심을 재 `manifest.iris`에 남기고, `export_rig.py`가 `irisFromArt`가 `false`가
|
||
아니면 `rig.landmarks.eyeLeft/Right.iris`를 이 실측값으로 채운다(세로 경계는 윗눈꺼풀
|
||
잉크와 홍채가 같은 톤으로 이어져 가를 수 없어, 가로 반지름 + 아래쪽 경계로만
|
||
중심y를 역산한다 — 자세한 방법은 `brow_sprite.py` `measure_iris` 참고).
|
||
|
||
P1은 셋 다 `false`다 — 이미 고정 벡터 눈썹·굵기·홍채 위치로 빌드·검수됐고,
|
||
`eyeLineScaleFromArt`의 경우 P1 자체가 lineScale의 기준값이라 바뀌면 안 된다.
|
||
**새 페르소나는 이 필드들을 생략한다**(기본 true로 원화 스프라이트·실측 굵기·실측
|
||
홍채를 쓴다).
|
||
|
||
### `faceDetail.region` — faceDetail이 그려질 범위(기본 "features", 캐스트 확장 "face")
|
||
|
||
`face_detail.py`가 눈·눈썹·입 "제외 영역"(이목구비 잉크선) 밖에서 원화(base-front) 픽셀을
|
||
쓰는 범위를 정한다. 레이어 픽셀은 항상 base-faceless(F)에서 가져오므로(README 상단
|
||
단계 설명 참고), 이 범위 밖은 F뿐이라 — 다크서클·볼 해칭·팔자 주름·턱 그늘 같은
|
||
원화 고유의 판화 해칭이 전혀 남지 않는다.
|
||
|
||
- `"features"`(기본, P1 그대로): 눈·입 주변 타원(눈두덩~다크서클, 입가 음영)에서만
|
||
원화 픽셀을 쓴다. 나머지 얼굴 피부는 F(매끈한 기본형)다.
|
||
- `"face"`(캐스트 확장): 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은
|
||
36점 루프·1.04 배율) 전체에서 원화 픽셀을 쓴다. moles 타원은 그대로 합쳐진다.
|
||
- 오벌을 그대로 쓰면 원화 얼굴 윤곽 잉크선(jaw_pieces.py 기준 오벌 경계 ±18px
|
||
안팎)이 영역 안에 들어와, F 기준으로 이미 그려진 head 레이어의 윤곽선과 겹쳐
|
||
두 겹 턱선(머리 이동 시차)이 생긴다 — `faceInsetPx`(기본 8)만큼 오벌을 안쪽으로
|
||
줄여(거리변환 기준, 방사형 스케일이 아니다) 이 잉크선을 영역 밖에 둔다. 페더는
|
||
다른 영역과 같은 `REGION_FEATHER_PX`(12px, sigma 6)를 쓴다. 새 페르소나마다
|
||
원화 윤곽선 위치가 다르므로, 원화를 보고 `faceInsetPx`를 정해 보고해야 한다.
|
||
- 머리카락 알파 0 규칙(머리카락 위에는 faceDetail을 그리지 않는다)은 그대로이되,
|
||
`"face"`에서는 머리카락 마스크를 **원화(front)와 faceless(F) 분할 결과의
|
||
합집합**으로 만든다. 둘의 앞머리 가닥 위치가 달라(codex 편집본 시차) 한쪽
|
||
분할만 쓰면 다른 쪽 잔머리가 마스크 밖에 남아 유령 가닥이 생기기 때문이다.
|
||
`"features"`는 기존처럼 F 분할만 쓴다(P1 회귀 보존).
|
||
|
||
manifest.layersV2.faceDetail.region/faceInsetPx에 남는다.
|
||
|
||
### `faceDetail.matchFacelessColor` — 제외 영역 F 색을 원화 피부에 맞추기(기본 false)
|
||
|
||
`true`면 제외 영역(눈·눈썹·입) 안을 채우는 F 픽셀의 색을 주변 원화 피부에 맞춘다.
|
||
영역(eyeLeft/eyeRight/browLeft/browRight/mouth)마다 그 영역 바깥 `RING_PX`(12px)
|
||
고리에서 어두운 잉크(원화 명도<110) 픽셀을 뺀 나머지로 원화·F 평균 RGB 차(ΔRGB)를
|
||
구하고, 그 영역 전체의 F 픽셀을 ΔRGB만큼 그대로 평행이동(가산)한다 — 보간·블러·
|
||
패치 합성은 하지 않는다(과거 P1에서 그 방식이 회색 얼룩으로 반려된 적이 있다).
|
||
영역별 ΔRGB는 콘솔과 `manifest.layersV2.faceDetail.matchFacelessColorDeltaRgb`에
|
||
남는다. P1은 `false`(기존 결과 그대로)다.
|
||
|
||
## 캐스트 확장 0단계 — 원화 3장을 준비하는 공통 도구
|
||
|
||
새 페르소나의 후보 원화는 크기·구도가 P1과 다르다(결정문 §8.2 생성 규칙은 그대로
|
||
따르되, 캔버스에 맞추는 과정이 필요하다). 아래 두 도구가 "사람이 고른 후보 원화 +
|
||
codex 편집본 2장"을 파이프라인이 기대하는 `base/base-front.png`·
|
||
`base/base-faceless.png`·`raw/body.png`(모두 캔버스 1005x1566)로 만든다. 순서대로
|
||
쓴다:
|
||
|
||
1. `register_edit.py` — codex가 만든 얼굴 없는 기본형·body 편집본을 ECC 아핀
|
||
정렬(`cv2.findTransformECC`, `MOTION_AFFINE`)로 후보 원화(ref) 좌표계에 맞춘다.
|
||
edited가 ref와 크기가 달라도 된다(먼저 ref 크기로 리사이즈한다).
|
||
|
||
```bash
|
||
<venv>/python.exe register_edit.py <ref.png> <faceless-edit.png> <faceless-aligned.png> \
|
||
--mode faceless --report <faceless-report.json>
|
||
<venv>/python.exe register_edit.py <ref.png> <body-edit.png> <body-aligned.png> \
|
||
--mode body --report <body-report.json>
|
||
```
|
||
|
||
콘솔·리포트 JSON에 정렬 품질 지표(변환 배율·회전·이동, 마스크 안 Canny 에지
|
||
챔퍼 거리 중앙값·p90, faceless는 얼굴 윤곽 띠 챔퍼와 눈·눈썹·입 상자 잉크 비율)를
|
||
낸다 — **판정은 하지 않는다**(수용 기준은 출력만, 사람/오케스트레이터가 본다).
|
||
|
||
2. `normalize_base.py` — 위 세 장(원래 ref, 정렬된 faceless, 정렬된 body)에 **같은**
|
||
아핀 변환 하나(등방 배율 + 평행이동, 회전 없음)를 적용해 캔버스로 옮긴다. 배율·
|
||
이동은 P1 `base-front.png`의 눈 중심·눈 사이 거리·눈-턱 거리 비율에 맞춘다
|
||
(P1과 머리 크기 비율이 같아지도록).
|
||
|
||
```bash
|
||
<venv>/python.exe normalize_base.py <persona-dir> \
|
||
--front <ref.png> --faceless <faceless-aligned.png> --body <body-aligned.png>
|
||
```
|
||
|
||
출력: `<persona-dir>/base/base-front.png`·`base/base-faceless.png`(1005x1566
|
||
RGB, 위아래 빈 곳은 원본 가장자리 띠 반사 패딩)·`raw/body.png`(1005x1566,
|
||
빈 곳은 `#00ff00`)·`base/normalize.json`(배율·오프셋·덮인 범위·입력 파일
|
||
sha256).
|
||
|
||
이 둘은 `persona.json`이 아직 없어도 돈다(두 스크립트 다 페르소나 폴더 경로만
|
||
받고 `load_persona_config`에 의존하지 않는다) — persona.json은 이 산출물을 보고
|
||
그다음에 쓴다(아래 "새 페르소나를 추가할 때" 3번).
|
||
|
||
## 모델 파일(저장소에 없음)
|
||
|
||
`scripts/_models/`에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, `.gitignore`의
|
||
`docs/avatar-art/*/scripts/_models/` 패턴에 그대로 맞는다).
|
||
|
||
- `face_landmarker.task`: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
|
||
- `selfie_multiclass_256x256.tflite`: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
|
||
|
||
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
||
|
||
## 새 페르소나를 추가할 때 사람이 해야 하는 일
|
||
|
||
이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야
|
||
하는 것:
|
||
|
||
1. 원화 2장: `<persona-dir>/base/base-front.png`(정면 기본형)과
|
||
`base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
|
||
후보 원화 크기·구도가 P1과 다르면(캐스트 확장이 보통 그렇다) 바로 두지 말고
|
||
위 "캐스트 확장 0단계" 도구(`register_edit.py` → `normalize_base.py`)로 캔버스에
|
||
맞춰 만든다.
|
||
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에도 남긴다)를 기준치와 비교해 판정한다.
|
||
|
||
## QA 캡처(`qa/capture_cast_sheet.mjs`)
|
||
|
||
리그가 게시된(`run_pipeline.py`가 `export_rig.py`까지 끝낸) 페르소나를 Lab
|
||
(`/dev/avatar-lab`)에서 조작해 캐스트 시트 5장을 만든다.
|
||
|
||
```bash
|
||
node qa/capture_cast_sheet.mjs <CODE> <outDir> [--base http://localhost:5173]
|
||
```
|
||
|
||
- `<CODE>`: `P1`처럼 대문자 페르소나 코드. `apps/web/src/components/avatar/v3/rigs/<code>Rig.ts`의
|
||
`<CODE>_LINOCUT_RIG` export와 `docs/avatar-art/<code>-linocut/base/base-front.png`를 읽는다.
|
||
- Lab에 `?persona=<CODE>` 쿼리(`v3/rigs/index.ts` `labLinocutRigs()` + `AvatarLab.tsx`
|
||
`?persona=` 지원, 세션 화면과 무관)로 들어가 그 페르소나를 바로 고른다.
|
||
- `--base`가 가리키는 dev server가 이미 떠 있어야 한다(이 스크립트는 서버를
|
||
띄우거나 끄지 않는다). 기본값 `http://localhost:5173`.
|
||
|
||
산출(`<outDir>`에 JPG, 모두 긴 변 ≤1000px — 개별 캡처 PNG는 `<outDir>/frames/`):
|
||
|
||
- `sheet-expr-bust.jpg` / `sheet-expr-face.jpg`: 16표정(강도 1) × bust/face 크롭,
|
||
4×4 격자.
|
||
- `sheet-viseme.jpg`: 비짐 9종(X·M·A·EO·O·U·EU·I·E), 입 주변 확대, 3×3 격자. Lab에
|
||
채널을 직접 주입하는 고정 기능이 없어(엔진 값을 바꾸지 않는 이 패킷 범위에서는
|
||
추가하지 않는다) 비짐 9종이 모두 나오는 문장을 재생하며 50ms 간격으로
|
||
(시각·비짐·스크린샷)을 한 패스에 모아 두고, 각 비짐이 가장 오래 유지된 구간의
|
||
중앙 표본을 그 비짐의 대표 프레임으로 쓴다(스캔과 재생을 두 패스로 나눠 같은
|
||
시각을 다시 재생하면 클릭~엔진 시계 리셋 사이 수십ms급 비결정성 때문에 스캔 때와
|
||
다른 비짐이 잡힐 수 있음을 실측으로 확인했다 — 항상 한 패스로 찍는다).
|
||
`<outDir>/viseme-timeline.json`에 전체 표본을 남긴다.
|
||
- `neutral-vs-art.jpg`: neutral 렌더(portrait 크롭)와 `base/base-front.png`를
|
||
나란히 둔 것 — 리그 피벗·배율이 원화와 맞는지 한눈에 비교한다.
|
||
- `face-art-vs-render.jpg`: 고해상도 얼굴 대조(2차 보정 T4b). `rig.crops.face`
|
||
영역을 원화는 500px, 렌더(neutral·grief·joy·angry)는 760px로 키워 2줄 3칸으로
|
||
나란히 두고 최종 긴 변 ≤1000px로 다시 줄인다 — 눈·눈썹처럼 작은 차이가 bust
|
||
전체 비교에서는 묻히는 부위를 본다.
|
||
|
||
**깜빡임/유휴 시선 흔들림 회피**(`screenshotEyeStable`, 2차 보정 T4a): `neutral-vs-art.jpg`·
|
||
`face-art-vs-render.jpg`의 정적 비교 스크린샷은 Reduced motion을 다시 켠 뒤(viseme
|
||
구간에서 꺼 둔 채로 남는다 — 유휴층 breath·blink·시선 흔들림이 채널에 섞여 든다)
|
||
700ms 간격 3연사 중 eye-left outline(흰자 바탕) bbox 높이가 가장 큰 장을 쓴다 —
|
||
neutral-vs-art가 깜빡임 중에 찍힌 사례(P2)와, 원화 대비 렌더 홍채 아래 흰 띠가
|
||
유휴 시선 흔들림(gazeY) 중에만 나타나는 것을 실측으로 확인했다(2026-10-01).
|