리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
parent
9a22d1647b
commit
98577cf4ae
18 changed files with 2524 additions and 85 deletions
|
|
@ -16,11 +16,15 @@
|
|||
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로 모션·눈/입 확대 미리보기
|
||||
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` *다음*이어야
|
||||
|
|
@ -79,7 +83,17 @@
|
|||
|
||||
"faceDetail": {
|
||||
// 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
|
||||
"moles": [{ "center": [661.3, 627.9], "radius": 20.0 }]
|
||||
"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). 원화를 보고 목·몸통·얼굴 중심을 정한다.
|
||||
|
|
@ -120,6 +134,150 @@ P1의 기존 `face-detail.png`·`jaw-detail.png`·해당 webp·`p1Rig.ts`는 눈
|
|||
그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** —
|
||||
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
|
||||
|
||||
### `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`의
|
||||
|
|
@ -137,8 +295,12 @@ 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와 같은 정렬).
|
||||
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬). 역시 캐스트
|
||||
확장이면 위 도구가 만든다.
|
||||
3. `<persona-dir>/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`·
|
||||
`paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
|
||||
4. (선택) `<persona-dir>/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가
|
||||
|
|
@ -147,3 +309,45 @@ python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
|||
|
||||
그 다음 `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).
|
||||
|
|
|
|||
394
docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs
Normal file
394
docs/avatar-art/linocut-pipeline/qa/capture_cast_sheet.mjs
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
// 공통 리노컷 리그 — 캐스트 QA 캡처 도구(T7). scratch/linocut-review/capture_out4.mjs의
|
||||
// 방식(apps/web의 playwright, auth/me 모킹, Lab 조작, 일시정지 뒤 스크린샷)을 그대로
|
||||
// 따른다. 페르소나 코드 하나를 받아 AvatarLab(/dev/avatar-lab?persona=<CODE>)을 조작해
|
||||
// 5장을 만든다: sheet-expr-bust.jpg, sheet-expr-face.jpg, sheet-viseme.jpg,
|
||||
// neutral-vs-art.jpg, face-art-vs-render.jpg.
|
||||
//
|
||||
// 비짐 9종은 Lab에 고정 기능이 없어(엔진 채널을 직접 주입할 UI가 없다) 발화 재생 중
|
||||
// 프레임을 쓴다: 비짐 글자를 담는 텍스트를 재생하면서 매 프레임을 처음부터 다시
|
||||
// 재생해 정확한 오프셋만큼 기다리는 방식(capture_speech_strip*.mjs 패턴)으로 (t,
|
||||
// 비짐) 타임라인을 훑고, 각 목표 비짐이 가장 오래 유지된 구간의 중앙 지점을 그
|
||||
// 비짐의 대표 프레임으로 쓴다.
|
||||
//
|
||||
// 사용: node capture_cast_sheet.mjs <CODE> <outDir> [--base http://localhost:5173]
|
||||
import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO = path.join(__dirname, "../../../../");
|
||||
const require = createRequire(path.join(REPO, "apps/web/package.json"));
|
||||
const { chromium } = require("playwright");
|
||||
const esbuild = require("esbuild");
|
||||
const Module = require("node:module");
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const CODE = argv[0];
|
||||
const outDirArg = argv[1];
|
||||
if (!CODE || !outDirArg) {
|
||||
console.error("사용: node capture_cast_sheet.mjs <CODE> <outDir> [--base http://localhost:5173]");
|
||||
process.exit(1);
|
||||
}
|
||||
const baseIdx = argv.indexOf("--base");
|
||||
const BASE_URL = baseIdx !== -1 ? argv[baseIdx + 1] : "http://localhost:5173";
|
||||
const OUT = path.resolve(outDirArg);
|
||||
const FRAMES = path.join(OUT, "frames");
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
mkdirSync(FRAMES, { recursive: true });
|
||||
|
||||
const SLUG = CODE.toLowerCase();
|
||||
const CODE_UPPER = CODE.toUpperCase();
|
||||
|
||||
/* ── 16표정(사용자 지정) ── */
|
||||
const EXPR16 = [
|
||||
"neutral", "calm", "warm", "joy", "sad", "grief", "lonely", "anxious",
|
||||
"panic", "startled", "ashamed", "angry", "skeptical", "confused", "tired", "bored",
|
||||
];
|
||||
|
||||
/* ── 비짐 9종(결정문 §5.4) — 목표 open/wide/round로 가장 또렷이 드러나는 한글 음절 1개씩.
|
||||
밥/맘은 초성·종성이 모두 양순음(M)이라 M 비짐 구간이 더 길게 유지된다. ── */
|
||||
const VISEME_WORDS = ["밥", "너", "소", "두", "그", "미", "네"];
|
||||
const VISEME_TEXT = VISEME_WORDS.join(" ");
|
||||
const VISEME_TARGETS = ["M", "A", "EO", "O", "U", "EU", "I", "E"]; // X는 재생 전 초기 상태에서 따로 캡처
|
||||
const VISEME_SCAN_END_MS = 2800;
|
||||
const VISEME_SCAN_STEP_MS = 50;
|
||||
const VISEME_CELL_TARGET_W = 300; // 최종 비짐 크롭 1장의 캔버스 단위 목표 너비(px)
|
||||
|
||||
/* ── 깜빡임/유휴 시선 흔들림 회피(T4a) — neutral-vs-art가 깜빡임 중에 찍힌 사례가
|
||||
있었다(P2). reducedMotion=true면 유휴층(breath·blink·시선 흔들림)이 꺼져 채널이
|
||||
표정 목표값에 그대로 스냅되지만(엔진 §idle layer), 안전망으로 700ms 간격 3연사 중
|
||||
eye-left outline bbox 높이가 가장 큰 장(=눈이 가장 크게 뜬 상태)을 고른다. ── */
|
||||
const EYE_STABILITY_SHOTS = 3;
|
||||
const EYE_STABILITY_INTERVAL_MS = 700;
|
||||
const FACE_COMPARE_RENDER_PX = 760; // T4b: 렌더 얼굴 크롭 캡처 해상도
|
||||
const FACE_COMPARE_ART_PX = 500; // T4b: 원화 얼굴 크롭 캡처 해상도
|
||||
const FACE_COMPARE_MAX_LONG_SIDE = 1000;
|
||||
|
||||
function toDataUri(filePath) {
|
||||
return `data:image/png;base64,${readFileSync(filePath).toString("base64")}`;
|
||||
}
|
||||
function loadTs(file) {
|
||||
const code = esbuild.transformSync(readFileSync(file, "utf8"), { loader: "ts", format: "cjs" }).code;
|
||||
const m = new Module(file);
|
||||
m.filename = file;
|
||||
m.paths = Module._nodeModulePaths(path.dirname(file));
|
||||
m._compile(code, file);
|
||||
return m.exports;
|
||||
}
|
||||
async function mockAuth(page) {
|
||||
await page.route("**/api/auth/me", (route) =>
|
||||
route.fulfill({
|
||||
status: 200, contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
user_id: "cast-sheet", email: "cast-sheet@hs.ac.kr", display_name: "CastSheet",
|
||||
role: "learner", cohort_ids: [], onboarding_completed_at: Math.floor(Date.now() / 1000),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
async function waitReady(page) {
|
||||
await page.waitForFunction(() => {
|
||||
const svg = document.querySelector('.al__linocut-main svg[data-render-mode="linocut-v3"]');
|
||||
return svg && svg.getAttribute("data-load-state") === "ready";
|
||||
}, { timeout: 10000 });
|
||||
}
|
||||
async function setReducedMotion(page, on) {
|
||||
const el = page.locator('[data-testid="reduced-motion-toggle"]');
|
||||
if ((await el.isChecked()) !== on) await el.click();
|
||||
}
|
||||
async function setExpression(page, id) {
|
||||
await page.locator(`[data-testid="expression-${id}"]`).click();
|
||||
}
|
||||
async function setIntensity(page, value) {
|
||||
await page.locator(".al__intensity input[type=range]").evaluate((el, v) => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set;
|
||||
setter.call(el, String(v));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, value);
|
||||
}
|
||||
async function resizeAndScreenshot(locator, wPx, hPx, filePath) {
|
||||
const prev = await locator.evaluate((el) => ({ w: el.style.width, h: el.style.height }));
|
||||
await locator.evaluate((el, size) => { el.style.width = `${size.w}px`; el.style.height = `${size.h}px`; }, { w: wPx, h: hPx });
|
||||
await locator.waitFor();
|
||||
await locator.screenshot({ path: filePath });
|
||||
await locator.evaluate((el, p) => { el.style.width = p.w; el.style.height = p.h; }, prev);
|
||||
}
|
||||
function labelRow(cells, cellPx, cols, labelH = 22) {
|
||||
return `<div style="display:grid;grid-template-columns:repeat(${cols},${cellPx}px);gap:6px;">${cells
|
||||
.map((c) => `<div style="width:${cellPx}px;"><img src="${toDataUri(c.src)}" width="${cellPx}" style="display:block;border:1px solid #ccc;" /><div style="font:11px sans-serif;text-align:center;height:${labelH}px;overflow:hidden;">${c.label}</div></div>`)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
async function buildMontage(browser, filePath, htmlBody) {
|
||||
// fullPage 스크린샷은 실제 콘텐츠 크기로 찍힌다(뷰포트보다 작아도 줄지 않고, 커도
|
||||
// 안 잘린다 — 실측 확인됨). 뷰포트를 넉넉하게 어림잡으면 그 어림값만큼 빈 여백이
|
||||
// 그대로 찍혀 긴 변 1000px 기준을 넘길 수 있어, 작게 고정해 항상 실제 콘텐츠
|
||||
// 크기로 나오게 한다.
|
||||
const page = await browser.newPage({ viewport: { width: 60, height: 60 } });
|
||||
const html = `<!doctype html><html><body style="margin:0;padding:12px;background:#fff;font-family:sans-serif;">${htmlBody}</body></html>`;
|
||||
await page.setContent(html);
|
||||
await page.waitForTimeout(300);
|
||||
await page.screenshot({ path: filePath, type: "jpeg", quality: 88, fullPage: true });
|
||||
await page.close();
|
||||
}
|
||||
async function cropScale(page, srcPath, rect, scale, destPath) {
|
||||
const uri = toDataUri(srcPath);
|
||||
const outUri = await page.evaluate(async ({ src, rect, scale }) => {
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const im = new Image();
|
||||
im.onload = () => resolve(im);
|
||||
im.onerror = reject;
|
||||
im.src = src;
|
||||
});
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, Math.round(rect.w * scale));
|
||||
c.height = Math.max(1, Math.round(rect.h * scale));
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(img, rect.x, rect.y, rect.w, rect.h, 0, 0, c.width, c.height);
|
||||
return c.toDataURL("image/png");
|
||||
}, { src: uri, rect, scale });
|
||||
writeFileSync(destPath, Buffer.from(outUri.split(",")[1], "base64"));
|
||||
}
|
||||
async function scaleDownToMaxLongSide(browser, filePath, maxSide) {
|
||||
const page = await browser.newPage({ viewport: { width: 60, height: 60 } });
|
||||
const uri = toDataUri(filePath);
|
||||
const outUri = await page.evaluate(async ({ src, maxSide }) => {
|
||||
const img = await new Promise((resolve, reject) => {
|
||||
const im = new Image();
|
||||
im.onload = () => resolve(im);
|
||||
im.onerror = reject;
|
||||
im.src = src;
|
||||
});
|
||||
const scale = Math.min(1, maxSide / Math.max(img.width, img.height));
|
||||
const c = document.createElement("canvas");
|
||||
c.width = Math.max(1, Math.round(img.width * scale));
|
||||
c.height = Math.max(1, Math.round(img.height * scale));
|
||||
const ctx = c.getContext("2d");
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(img, 0, 0, c.width, c.height);
|
||||
return c.toDataURL("image/jpeg", 0.9);
|
||||
}, { src: uri, maxSide });
|
||||
writeFileSync(filePath, Buffer.from(outUri.split(",")[1], "base64"));
|
||||
await page.close();
|
||||
}
|
||||
/** 깜빡임(또는 유휴 시선 흔들림) 중 캡처되는 것을 피한다(T4a). 같은 locator를
|
||||
EYE_STABILITY_INTERVAL_MS 간격으로 EYE_STABILITY_SHOTS번 찍어, eye-left outline
|
||||
(흰자 바탕) bbox 높이가 가장 큰 장을 destPath에 남긴다. */
|
||||
async function screenshotEyeStable(page, locator, wPx, hPx, destPath) {
|
||||
const candidates = [];
|
||||
for (let i = 0; i < EYE_STABILITY_SHOTS; i++) {
|
||||
if (i > 0) await page.waitForTimeout(EYE_STABILITY_INTERVAL_MS);
|
||||
const tmpPath = path.join(FRAMES, `${path.basename(destPath, path.extname(destPath))}-stab${i}.png`);
|
||||
await resizeAndScreenshot(locator, wPx, hPx, tmpPath);
|
||||
const h = await page.evaluate(() => {
|
||||
const outline = document.querySelector('[data-part="eye-left"]')?.children?.[0];
|
||||
return outline ? outline.getBBox().height : 0;
|
||||
});
|
||||
candidates.push({ h, tmpPath });
|
||||
}
|
||||
const best = candidates.reduce((a, b) => (b.h > a.h ? b : a));
|
||||
copyFileSync(best.tmpPath, destPath);
|
||||
return destPath;
|
||||
}
|
||||
|
||||
/** 같은 비짐이 유지된 가장 긴 구간에서 중앙 표본을 고른다(전환 중 프레임을 피한다).
|
||||
표본 자체(스크린샷 포함)를 반환한다 — 나중에 같은 t로 재생을 다시 틀어 캡처하면
|
||||
클릭~엔진 시계 리셋 사이의 수십ms급 비결정성 때문에 스캔 때와 다른 비짐이 잡힐
|
||||
수 있다(실측 확인됨). 그래서 스캔 패스에서 이미 찍어 둔 스크린샷을 그대로 쓴다. */
|
||||
function pickRepresentativeSample(samples, viseme) {
|
||||
let bestLen = 0;
|
||||
let bestMid = null;
|
||||
let i = 0;
|
||||
while (i < samples.length) {
|
||||
if (samples[i].viseme !== viseme) { i++; continue; }
|
||||
let j = i;
|
||||
while (j < samples.length && samples[j].viseme === viseme) j++;
|
||||
const runLen = j - i;
|
||||
if (runLen > bestLen) {
|
||||
bestLen = runLen;
|
||||
bestMid = samples[Math.floor((i + j - 1) / 2)];
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
return bestMid;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const rigPath = path.join(REPO, `apps/web/src/components/avatar/v3/rigs/${SLUG}Rig.ts`);
|
||||
const rigExportName = `${CODE_UPPER}_LINOCUT_RIG`;
|
||||
const rigModule = loadTs(rigPath);
|
||||
const rig = rigModule[rigExportName];
|
||||
if (!rig) throw new Error(`${rigPath}에 ${rigExportName} export가 없다.`);
|
||||
const baseFrontPath = path.join(REPO, `docs/avatar-art/${SLUG}-linocut/base/base-front.png`);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage({ viewport: { width: 1400, height: 1100 } });
|
||||
await mockAuth(page);
|
||||
await page.goto(`${BASE_URL}/dev/avatar-lab?seed=1&persona=${CODE_UPPER}`);
|
||||
await page.waitForSelector('[data-avatar-lab="true"]');
|
||||
await waitReady(page);
|
||||
|
||||
const bustCircle = page.locator(".al__preview-cell--bust-circle");
|
||||
const face96 = page.locator(".al__preview-cell--face-96");
|
||||
const portrait = page.locator(".al__preview-cell--portrait");
|
||||
const visemeCell = page.locator('[data-testid="speech-shape-viseme"]');
|
||||
|
||||
/* ── 1,2) sheet-expr-bust.jpg / sheet-expr-face.jpg: 16표정, 강도1, 4x4 격자 ── */
|
||||
await setReducedMotion(page, true);
|
||||
const CELL_PX = 215;
|
||||
const exprShots = [];
|
||||
for (const id of EXPR16) {
|
||||
await setExpression(page, id);
|
||||
await setIntensity(page, 1);
|
||||
await page.waitForTimeout(500);
|
||||
const bustPath = path.join(FRAMES, `expr-bust-${id}.png`);
|
||||
const facePath = path.join(FRAMES, `expr-face-${id}.png`);
|
||||
await resizeAndScreenshot(bustCircle, CELL_PX, CELL_PX, bustPath);
|
||||
await resizeAndScreenshot(face96, CELL_PX, CELL_PX, facePath);
|
||||
exprShots.push({ id, bustPath, facePath });
|
||||
console.log(`[expr] ${id}@1 캡처 완료`);
|
||||
}
|
||||
{
|
||||
const cells = exprShots.map((s) => ({ src: s.bustPath, label: s.id }));
|
||||
const rows = [];
|
||||
for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4));
|
||||
await buildMontage(browser, path.join(OUT, "sheet-expr-bust.jpg"), rows.join(""));
|
||||
console.log("sheet-expr-bust.jpg 완료");
|
||||
}
|
||||
{
|
||||
const cells = exprShots.map((s) => ({ src: s.facePath, label: s.id }));
|
||||
const rows = [];
|
||||
for (let i = 0; i < cells.length; i += 4) rows.push(labelRow(cells.slice(i, i + 4), CELL_PX, 4));
|
||||
await buildMontage(browser, path.join(OUT, "sheet-expr-face.jpg"), rows.join(""));
|
||||
console.log("sheet-expr-face.jpg 완료");
|
||||
}
|
||||
|
||||
/* ── 3) sheet-viseme.jpg: 비짐 9종, 입 주변 확대, Lab에 고정 기능이 없어 발화 재생 중
|
||||
프레임을 쓴다(패킷 비목표 — 엔진 값 변경 금지라 임시 harness도 만들지 않는다). ── */
|
||||
const [fcx, fcy, fcw] = rig.crops.face;
|
||||
const RENDER_PX = 1203;
|
||||
const faceScale = RENDER_PX / fcw;
|
||||
const L = rig.landmarks;
|
||||
const mxs = [L.mouthCornerLeft[0], L.mouthCornerRight[0]];
|
||||
const mys = [L.upperLipTop[1], L.lowerLipBottom[1]];
|
||||
const mw = mxs[1] - mxs[0];
|
||||
const mh = mys[1] - mys[0];
|
||||
const mouthRect = { x: mxs[0] - mw * 0.25, y: mys[0] - mh * 0.6, w: mw * 1.5, h: mh * 2.2 };
|
||||
const mouthRenderRect = { x: (mouthRect.x - fcx) * faceScale, y: (mouthRect.y - fcy) * faceScale, w: mouthRect.w * faceScale, h: mouthRect.h * faceScale };
|
||||
const cropStageScale = (VISEME_CELL_TARGET_W / mouthRect.w) / faceScale;
|
||||
|
||||
async function captureMouthZoom(destPath) {
|
||||
const bigPath = path.join(FRAMES, "viseme-big-tmp.png");
|
||||
await resizeAndScreenshot(face96, RENDER_PX, RENDER_PX, bigPath);
|
||||
await cropScale(page, bigPath, mouthRenderRect, cropStageScale, destPath);
|
||||
}
|
||||
|
||||
await setReducedMotion(page, false);
|
||||
await setExpression(page, "neutral");
|
||||
await setIntensity(page, 0.3);
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
const visemeShots = [];
|
||||
// X(쉼) — 아직 재생을 누르기 전 초기 휴지 상태.
|
||||
{
|
||||
const shotPath = path.join(FRAMES, "viseme-X.png");
|
||||
await captureMouthZoom(shotPath);
|
||||
visemeShots.push({ id: "X", src: shotPath });
|
||||
console.log("[viseme] X(재생 전 초기 상태) 캡처 완료");
|
||||
}
|
||||
|
||||
await page.locator('[data-testid="speech-text"]').fill(VISEME_TEXT);
|
||||
// 한 패스로 (t, 비짐, 스크린샷)을 같이 모은다 — 읻고 나중에 같은 t로 재생을 다시
|
||||
// 틀어 캡처하면(2패스) 클릭~엔진 시계 리셋 사이 비결정성 때문에 스캔 때와 다른
|
||||
// 비짐이 잡히는 것을 실측으로 확인했다(capture_speech_strip*.mjs와 같은 1패스 방식).
|
||||
const samples = [];
|
||||
for (let t = 0; t <= VISEME_SCAN_END_MS; t += VISEME_SCAN_STEP_MS) {
|
||||
await page.locator('[data-testid="play-speech"]').click();
|
||||
await page.waitForTimeout(t);
|
||||
const viseme = await visemeCell.innerText().catch(() => "?");
|
||||
const shotPath = path.join(FRAMES, `viseme-scan-t${String(t).padStart(5, "0")}.png`);
|
||||
await captureMouthZoom(shotPath);
|
||||
samples.push({ t, viseme, shotPath });
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(OUT, "viseme-timeline.json"),
|
||||
JSON.stringify(samples.map(({ t, viseme, shotPath }) => ({ t, viseme, shotPath })), null, 1),
|
||||
);
|
||||
console.log(`[viseme] 타임라인 ${samples.length}개 표본 수집(스크린샷 포함) 완료`);
|
||||
|
||||
for (const target of VISEME_TARGETS) {
|
||||
const best = pickRepresentativeSample(samples, target);
|
||||
if (best === null) {
|
||||
console.log(`[viseme] ${target} — 타임라인에서 찾지 못함(건너뜀)`);
|
||||
continue;
|
||||
}
|
||||
visemeShots.push({ id: target, src: best.shotPath, t: best.t });
|
||||
console.log(`[viseme] ${target} t=${best.t}ms 표본 채택`);
|
||||
}
|
||||
|
||||
{
|
||||
const cells = visemeShots.map((s) => ({ src: s.src, label: s.id }));
|
||||
const rows = [];
|
||||
const cellPx = VISEME_CELL_TARGET_W;
|
||||
for (let i = 0; i < cells.length; i += 3) rows.push(labelRow(cells.slice(i, i + 3), cellPx, 3));
|
||||
await buildMontage(browser, path.join(OUT, "sheet-viseme.jpg"), rows.join(""));
|
||||
console.log("sheet-viseme.jpg 완료");
|
||||
}
|
||||
|
||||
/* ── 4) neutral-vs-art.jpg: neutral 렌더(portrait)와 base-front.png를 나란히(T4a:
|
||||
reducedMotion을 다시 켜 유휴층을 끄고, 700ms 3연사로 깜빡임/시선 흔들림을 피한다 —
|
||||
viseme 구간에서 reducedMotion이 꺼진 채로 남아 neutral-vs-art가 유휴 애니메이션
|
||||
중에 찍힌 사례가 있었다, P2). ── */
|
||||
await setReducedMotion(page, true);
|
||||
await setExpression(page, "neutral");
|
||||
await setIntensity(page, 1);
|
||||
await page.waitForTimeout(400);
|
||||
const PANEL_W = 420;
|
||||
const PANEL_H = Math.round((PANEL_W * rig.canvas.h) / rig.canvas.w);
|
||||
const neutralPortraitPath = path.join(FRAMES, "neutral-portrait.png");
|
||||
await screenshotEyeStable(page, portrait, PANEL_W, PANEL_H, neutralPortraitPath);
|
||||
{
|
||||
const html = `<div style="display:flex;gap:20px;align-items:flex-start;">
|
||||
<div style="width:${PANEL_W}px;"><img src="${toDataUri(neutralPortraitPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">neutral 렌더(portrait)</div></div>
|
||||
<div style="width:${PANEL_W}px;"><img src="${toDataUri(baseFrontPath)}" width="${PANEL_W}" style="display:block;border:1px solid #ccc;" /><div style="font:12px sans-serif;text-align:center;margin-top:4px;">base-front.png(원화)</div></div>
|
||||
</div>`;
|
||||
await buildMontage(browser, path.join(OUT, "neutral-vs-art.jpg"), html);
|
||||
console.log("neutral-vs-art.jpg 완료");
|
||||
}
|
||||
|
||||
/* ── 5) face-art-vs-render.jpg: 고해상도 얼굴 대조(T4b) — bust 얼굴 크롭(rig.crops.face)을
|
||||
렌더 760px·원화 500px로 키워 neutral·grief·joy·angry와 나란히 둔다(2줄 3칸,
|
||||
최종 긴 변 <=1000px로 다시 줄인다). ── */
|
||||
{
|
||||
const fch = rig.crops.face[3];
|
||||
const artFaceCropPath = path.join(FRAMES, "face-art-crop.png");
|
||||
await cropScale(page, baseFrontPath, { x: fcx, y: fcy, w: fcw, h: fch }, FACE_COMPARE_ART_PX / fcw, artFaceCropPath);
|
||||
|
||||
const FACE_COMPARE_EXPR = ["neutral", "grief", "joy", "angry"];
|
||||
const faceCompareCells = [{ src: artFaceCropPath, label: "원화(base-front)" }];
|
||||
for (const id of FACE_COMPARE_EXPR) {
|
||||
await setExpression(page, id);
|
||||
await setIntensity(page, 1);
|
||||
await page.waitForTimeout(400);
|
||||
const shotPath = path.join(FRAMES, `face-art-vs-render-${id}.png`);
|
||||
await screenshotEyeStable(page, face96, FACE_COMPARE_RENDER_PX, FACE_COMPARE_RENDER_PX, shotPath);
|
||||
faceCompareCells.push({ src: shotPath, label: `렌더: ${id}` });
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (let i = 0; i < faceCompareCells.length; i += 3) {
|
||||
rows.push(labelRow(faceCompareCells.slice(i, i + 3), FACE_COMPARE_RENDER_PX, 3));
|
||||
}
|
||||
const outPath = path.join(OUT, "face-art-vs-render.jpg");
|
||||
await buildMontage(browser, outPath, rows.join(""));
|
||||
await scaleDownToMaxLongSide(browser, outPath, FACE_COMPARE_MAX_LONG_SIDE);
|
||||
console.log("face-art-vs-render.jpg 완료");
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
console.log(`\n모두 완료. 산출물: ${OUT}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
|
|
@ -40,6 +40,7 @@ from scipy import ndimage
|
|||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
from segmentation import build_padded_faceless # noqa: E402 — 새 페르소나 첫 실행에서도 segmentation 단계 전에 패딩본을 만든다
|
||||
|
||||
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
|
||||
BBOX_X_PAD = 25.0
|
||||
|
|
@ -52,6 +53,8 @@ MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보
|
|||
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
|
||||
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
|
||||
|
||||
HALFWIDTH_WINDOW_PX = 6.0 # 반두께를 잴 때 각 점 주변 이 폭(열, px) 안의 열을 모아 중앙값을 낸다
|
||||
|
||||
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
|
||||
PEAK_FALLBACK_FRAC = 0.35
|
||||
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
|
||||
|
|
@ -126,6 +129,23 @@ def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int,
|
|||
}
|
||||
|
||||
|
||||
def measure_half_width(cw: dict, fit: np.poly1d, x: float, window_px: float = HALFWIDTH_WINDOW_PX) -> float:
|
||||
"""x 주변 window_px 안 열들의 band 세로 폭(col_yhi-col_ylo) 중앙값을, 그 지점의
|
||||
중심선 접선 기울기로 중심선 법선 방향 반두께로 환산한다(세로로 자른 폭은 띠가
|
||||
기울어진 만큼 실제보다 넓게 잡힌다 — cos(기울기각)을 곱해 보정)."""
|
||||
xs_abs = cw["xs_local"] + cw["x0"]
|
||||
in_window = (xs_abs >= x - window_px) & (xs_abs <= x + window_px)
|
||||
ylo = cw["col_ylo"][in_window]
|
||||
yhi = cw["col_yhi"][in_window]
|
||||
valid = ~np.isnan(ylo) & ~np.isnan(yhi)
|
||||
if not valid.any():
|
||||
return float("nan")
|
||||
vertical_extent = float(np.median(yhi[valid] - ylo[valid]))
|
||||
slope = float(fit.deriv()(x))
|
||||
theta = np.arctan(slope)
|
||||
return 0.5 * vertical_extent * float(np.cos(theta))
|
||||
|
||||
|
||||
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
|
||||
coef = np.polyfit(xs_abs, ys, 2)
|
||||
fit = np.poly1d(coef)
|
||||
|
|
@ -220,6 +240,10 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
|
|||
n_ok += 1
|
||||
frac_ok = n_ok / n_total if n_total else 0.0
|
||||
|
||||
half_width_inner = measure_half_width(cw, fit, float(inner_x))
|
||||
half_width_peak = measure_half_width(cw, fit, float(peak_pt_raw[0]))
|
||||
half_width_outer = measure_half_width(cw, fit, float(outer_x))
|
||||
|
||||
return {
|
||||
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
|
||||
"colRange": [col_min, col_max], "width": width,
|
||||
|
|
@ -230,6 +254,9 @@ def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: di
|
|||
"peakFail": bool(peak_fail),
|
||||
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
|
||||
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
|
||||
"halfWidthInner": round(half_width_inner, 3),
|
||||
"halfWidthPeak": round(half_width_peak, 3),
|
||||
"halfWidthOuter": round(half_width_outer, 3),
|
||||
"band": band, "bandX0": x0, "bandY0": y0,
|
||||
"fit": fit, "colMin": col_min, "colMax": col_max,
|
||||
}
|
||||
|
|
@ -284,7 +311,7 @@ def main(persona_dir: Path) -> int:
|
|||
lm = manifest["landmarks"]
|
||||
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
faceless = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
faceless = np.array(build_padded_faceless(base_dir).convert("RGB")).astype(np.float64)
|
||||
front_lum = front.mean(axis=2)
|
||||
faceless_lum = faceless.mean(axis=2)
|
||||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||||
|
|
@ -311,6 +338,7 @@ def main(persona_dir: Path) -> int:
|
|||
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
|
||||
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
|
||||
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
|
||||
print(f" 반두께(inner/peak/outer) = {result['halfWidthInner']}/{result['halfWidthPeak']}/{result['halfWidthOuter']}")
|
||||
|
||||
dropL = resultL["inner"][1] - resultL["peak"][1]
|
||||
dropR = resultR["inner"][1] - resultR["peak"][1]
|
||||
|
|
@ -361,6 +389,10 @@ def main(persona_dir: Path) -> int:
|
|||
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
|
||||
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
|
||||
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
|
||||
"halfWidthLeft": [resultL["halfWidthInner"], resultL["halfWidthPeak"], resultL["halfWidthOuter"]],
|
||||
"halfWidthRight": [resultR["halfWidthInner"], resultR["halfWidthPeak"], resultR["halfWidthOuter"]],
|
||||
"halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 "
|
||||
"persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.",
|
||||
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
|
||||
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
|
||||
"evidenceImage": "preview/v2/brow-centerline.jpg",
|
||||
|
|
|
|||
635
docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py
Normal file
635
docs/avatar-art/linocut-pipeline/scripts/brow_sprite.py
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
"""공통 리노컷 리그 — 원화 눈썹 스프라이트 + 눈꺼풀 선 굵기 배율 생성(결정문 §8.4.1,
|
||||
작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기").
|
||||
|
||||
배경(오케스트레이터 판정): P7 시범 렌더에서 벡터 눈썹(P1의 가늘고 옅은 눈썹에 맞춘
|
||||
굵기)이 P7의 굵은 판화 눈썹을 "갈색빛 회색 막대"로 그렸다. 입술에서 받아들여진
|
||||
"중립 렌더 = 원화" 원칙을 눈썹에도 적용한다 — 원화 눈썹 잉크를 그대로 스프라이트로
|
||||
떼어 렌더러가 세로 띠로 나눠 휘게 한다. 눈은 감고 뜨기 때문에 벡터를 유지하되,
|
||||
원화에서 잰 윗눈꺼풀 잉크 두께 배율(lineScale)을 선 굵기에 곱한다.
|
||||
|
||||
persona.json browSpriteFromArt/eyeLineScaleFromArt(둘 다 기본 true)가 false인
|
||||
페르소나는 해당 산출을 건너뛴다(P1: 이미 고정 벡터 눈썹·굵기로 빌드·검수됐고,
|
||||
lineScale의 기준값 자체이기도 하다 — 바뀌면 안 된다).
|
||||
|
||||
눈썹 영역:
|
||||
- manifest의 눈썹 중심선(brow_centerline.py 결과: landmarks.eyebrowLeft/Right
|
||||
inner·peak·outer, browCenterline.halfWidthLeft/Right)을 따라 띠를 만든다.
|
||||
반높이는 max(halfWidth)+8, inner·outer 바깥으로 6px 연장한다.
|
||||
- 띠 안에서 원화(base-front) 잉크와 연결된 성분만 남긴다(Otsu(front 휘도) AND
|
||||
diff(F-front)>=25, 형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취한다 —
|
||||
brow_centerline.build_ink_band와 같은 기준).
|
||||
- 머리카락은 뺀다. 기준은 layers/v2/hairFront.png(이미 게시되는 그 레이어)의
|
||||
알파>128(1px 팽창, 오케스트레이터 판정값)다 — 분할 카테고리(category=1) 자체를
|
||||
쓰면 이 화풍(두꺼운 판화 눈썹이 앞머리와 맞닿아 있다)에서 세그멘터가 눈썹
|
||||
잉크 자체를 "머리카락"으로 광범위 오탐한다(P7 1차 실측: 분할 합집합을 그대로
|
||||
빼면 눈썹 잉크의 54~82%가 사라짐). hairFront는 렌더 z-순서에서 눈썹 스프라이트
|
||||
"위"에 그려지므로, 그 알파 아래 픽셀을 스프라이트에서 빼도 중립 렌더는 원화와
|
||||
같고 이중 가닥(겹쳐 보임)도 생기지 않는다 — "앞머리 가닥이 눈썹과 같이
|
||||
움직이면 안 된다"는 의도를 분할 카테고리보다 정확히 구현한다(오케스트레이터
|
||||
실측: 눈썹 띠 안 잉크 중 hairFront 알파>128 아래에 있는 비율은 좌 47%
|
||||
(1392/2953), 우 19%(442/2329) — 분할 카테고리 기준 손실 54~82%보다 훨씬 적다).
|
||||
|
||||
알파: a = clamp((L_skin-L)/(L_skin-L_ink), 0, 1) × (region 경계 1.5px 페더, RAMP_PX).
|
||||
L_skin은 띠 안 faceless 휘도 중앙값, L_ink는 띠 안에서 "잉크로 분류된 픽셀"(머리카락
|
||||
제외 후 region, band_ink_mask 결과) 휘도의 중앙값이다 — 그보다 어두운 픽셀은 a=1이
|
||||
된다(2차 보정, 작업 패킷 "눈썹 스프라이트·눈꺼풀 굵기 2차 보정": 띠 전체 휘도의
|
||||
5백분위를 쓰면 극단적으로 어두운 꼬리값이 L_ink가 돼, 잉크 픽셀 대부분이 a<1로
|
||||
옅게 그려졌다). 페더도 4px→1.5px로 줄였다 — 눈썹 해칭 획 자체가 가늘어(경계거리
|
||||
평균 2.28px) 4px 페더로는 L_ink를 낮춰도 대부분 페더 중간값에 걸려 옅었다(RAMP_PX
|
||||
주석 참고, 블러·형태학 닫기는 쓰지 않아 결은 그대로다).
|
||||
|
||||
색: RGB = clip((front - (1-a)*skin)/a), skin은 띠 안 faceless 평균색. a<0.05면
|
||||
잉크색(L_ink population의 front RGB 중앙값)으로 대체한다(0 나눗셈 폭주 방지).
|
||||
|
||||
눈꺼풀 굵기: 눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채 중심 y 사이의 첫
|
||||
연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다. P1 기준값(아래 상수)으로 나눈
|
||||
비를 [1.0, 2.5]로 clamp한 값이 lineScale이다.
|
||||
|
||||
전제: landmarks.py·brow_centerline.py·layers_v2.py·face_detail.py가 먼저 돌아서
|
||||
manifest.landmarks(+browCenterline)와 layers/v2/{body,head,hairFront,face-detail}.png를
|
||||
만들어 두어야 한다. export_rig.py보다 앞, face_detail.py보다 뒤에 둔다.
|
||||
|
||||
실행: <venv>/python.exe brow_sprite.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy import ndimage
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
from segmentation import build_padded_faceless, to_u8, composite_over, CREAM_BG # noqa: E402
|
||||
from lip_texture import crop_to_alpha_bbox # noqa: E402 — 알파 bbox로 잘라 RGBA PNG를 내는 공용 유틸(입술 결과 동일 패턴)
|
||||
|
||||
# --- 눈썹 영역(띠) 상수 -------------------------------------------------------
|
||||
BAND_EXTRA_HALF_HEIGHT_PX = 8.0 # 반높이 = max(halfWidth) + 이 값
|
||||
BAND_END_EXTEND_PX = 6.0 # inner·outer 바깥 연장
|
||||
BAND_BBOX_PAD_PX = 10 # 닫기 연산이 띠 경계에서 잘리지 않게 주는 여유
|
||||
|
||||
# brow_centerline.py의 잉크 판정 기준과 같다(같은 원화 눈썹 잉크를 다시 찾는
|
||||
# 것이므로 같은 임계값을 쓴다) — DIFF_THRESH·CLOSING_KERNEL·MIN_COMPONENT_AREA.
|
||||
DIFF_THRESH = 25.0
|
||||
CLOSING_KERNEL = (7, 11)
|
||||
MIN_COMPONENT_AREA = 30.0
|
||||
|
||||
HAIR_DILATE_ITER = 1 # 머리카락(hairFront 알파) 마스크 팽창(오케스트레이터 판정값)
|
||||
HAIR_ALPHA_THRESH = 128.0 # layers/v2/hairFront.png 알파가 이보다 크면 "머리카락이 덮는다"로 본다(오케스트레이터 판정값)
|
||||
|
||||
RAMP_PX = 1.5 # region(잉크, 머리카락 제외) 경계 알파 페더(px)
|
||||
# 2차 보정: 눈썹 해칭은 획 자체가 가늘다(P7 실측: region 안 경계거리변환(distance_transform_edt)
|
||||
# 평균 2.28px, dist>=4px인 픽셀 14~17%뿐) — 기존 4px 페더로는 L_ink를 아무리 낮춰도(=어둡게
|
||||
# 잡아도) 거의 모든 획이 페더 중간값에 걸려 옅게 그려졌다(T1 목표 미달의 실제 원인). 1.5px로
|
||||
# 줄이면 블러·형태학 닫기 없이(결 보존) 경계 anti-alias만 남기고 목표(알파>0 영역 평균절대차
|
||||
# <=10)를 만족한다(P7 실측: 좌 7.83 우 4.99, RAMP_PX=4일 때는 좌 42.75 우 41.77이었다).
|
||||
MIN_ALPHA_FOR_UNMIX = 0.05 # a<0.05면 피부색 역산(unmix) 대신 잉크색을 쓴다(0 나눗셈 폭주 방지)
|
||||
|
||||
# --- 눈꺼풀 선 굵기(lineScale) 상수 ------------------------------------------
|
||||
UPPER_LID_WINDOW_ABOVE_PX = 12.0 # 측정 창 위쪽 = upperLidTop.y - 이 값
|
||||
UPPER_LID_DARK_LUM_THRESH = 80.0
|
||||
UPPER_LID_COLUMN_MIDDLE_FRAC = 0.6 # 눈 폭 가운데 60%(양옆 20%씩 제외)
|
||||
LINE_SCALE_MIN = 1.0
|
||||
LINE_SCALE_MAX = 2.5
|
||||
|
||||
# P1 기준값(윗눈꺼풀 잉크 두께, px) — measure_upper_lid_thickness를 P1
|
||||
# manifest.landmarks.eyeLeft/eyeRight + base/base-front.png에 적용한 값이다
|
||||
# (둘 다 median=2.000px, 표본 열 57/58개 — 실측 근거, 2026-10-01). P1은
|
||||
# "다시 빌드하지 않는다"는 결정문 지시로 고정돼 있어 매번 다시 재는 대신
|
||||
# 상수로 고정한다(패킷 지시: "P1 값은... 상수로 고정하되 근거를 주석으로 단다").
|
||||
P1_UPPER_LID_THICKNESS_PX = 2.0
|
||||
|
||||
# --- 홍채 실측(2차 보정 T3) 상수 ---------------------------------------------
|
||||
# mediapipe iris 랜드마크(468~477)는 실제 사람 눈 비례로 학습돼, 판화 잉크로
|
||||
# 그려진 홍채(원화 실측)보다 반지름을 크게 잡을 수 있다(P7 실측: 가로 반지름
|
||||
# 기준 리그 대비 약 11~15% 작다, 2026-10-01). lineScale·browSprite와 무관하게
|
||||
# 항상 측정해 manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 항상 기록"
|
||||
# 원칙) — export_rig.py가 persona.json irisFromArt(기본 true)일 때만 이 값을 쓴다.
|
||||
IRIS_DARK_LUM_THRESH = 100.0 # 홍채 잉크 실측 휘도(28~40대) vs 흰자(150 이상) 중간, 여유를 둔 임계값
|
||||
IRIS_SCAN_BAND_PX = 4 # 중심 둘레 ±이 값(px)의 행/열을 스캔한다
|
||||
|
||||
|
||||
# --- 눈썹 띠(band) 기하 -------------------------------------------------------
|
||||
|
||||
|
||||
def _quad_point(p0: tuple[float, float], c: tuple[float, float], p2: tuple[float, float], t: float) -> tuple[float, float]:
|
||||
mt = 1.0 - t
|
||||
return (mt * mt * p0[0] + 2 * mt * t * c[0] + t * t * p2[0], mt * mt * p0[1] + 2 * mt * t * c[1] + t * t * p2[1])
|
||||
|
||||
|
||||
def _quad_control(p0: tuple[float, float], mid: tuple[float, float], p2: tuple[float, float]) -> tuple[float, float]:
|
||||
return (2 * mid[0] - 0.5 * (p0[0] + p2[0]), 2 * mid[1] - 0.5 * (p0[1] + p2[1]))
|
||||
|
||||
|
||||
def _normalize(dx: float, dy: float) -> tuple[float, float]:
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
return dx / length, dy / length
|
||||
|
||||
|
||||
def _seg_normal(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
|
||||
dx, dy = b[0] - a[0], b[1] - a[1]
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
return -dy / length, dx / length
|
||||
|
||||
|
||||
def brow_band_polygon(
|
||||
inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float],
|
||||
half_height: float, extend_px: float, samples: int = 40,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""눈썹 중심선(inner→peak→outer 2차 베지어, linocutGeometry.ts buildBrow의
|
||||
곡선과 같은 방식)을 따라 반높이 half_height인 리본 폴리곤을 만든다(곡선 표본 +
|
||||
법선 오프셋, linocutGeometry.ts polylineRibbon과 같은 기법). 양끝은 접선
|
||||
방향으로 extend_px만큼 늘린다(inner·outer 바깥 연장)."""
|
||||
control = _quad_control(inner, peak, outer)
|
||||
pts = [_quad_point(inner, control, outer, i / (samples - 1)) for i in range(samples)]
|
||||
t0 = _normalize(pts[0][0] - pts[1][0], pts[0][1] - pts[1][1])
|
||||
t1 = _normalize(pts[-1][0] - pts[-2][0], pts[-1][1] - pts[-2][1])
|
||||
ext_inner = (pts[0][0] + t0[0] * extend_px, pts[0][1] + t0[1] * extend_px)
|
||||
ext_outer = (pts[-1][0] + t1[0] * extend_px, pts[-1][1] + t1[1] * extend_px)
|
||||
pts = [ext_inner] + pts + [ext_outer]
|
||||
|
||||
n = len(pts)
|
||||
normals = []
|
||||
for i in range(n):
|
||||
a = _seg_normal(pts[i - 1], pts[i]) if i > 0 else _seg_normal(pts[i], pts[i + 1])
|
||||
b = _seg_normal(pts[i], pts[i + 1]) if i < n - 1 else _seg_normal(pts[i - 1], pts[i])
|
||||
normals.append(((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0))
|
||||
|
||||
top = [(p[0] + nx * half_height, p[1] + ny * half_height) for p, (nx, ny) in zip(pts, normals)]
|
||||
bot = [(p[0] - nx * half_height, p[1] - ny * half_height) for p, (nx, ny) in zip(pts, normals)]
|
||||
return top + list(reversed(bot))
|
||||
|
||||
|
||||
def polygon_bool_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray:
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).polygon(points, fill=255)
|
||||
return np.array(img) > 127
|
||||
|
||||
|
||||
def mask_bbox(mask: np.ndarray, pad: int, w: int, h: int) -> tuple[int, int, int, int]:
|
||||
ys, xs = np.where(mask)
|
||||
if len(xs) == 0:
|
||||
raise SystemExit("[중단] 눈썹 띠 마스크가 비어 있다.")
|
||||
x0 = max(0, int(xs.min()) - pad)
|
||||
y0 = max(0, int(ys.min()) - pad)
|
||||
x1 = min(w, int(xs.max()) + 1 + pad)
|
||||
y1 = min(h, int(ys.max()) + 1 + pad)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
# --- 띠 안 잉크 연결성분 ------------------------------------------------------
|
||||
|
||||
|
||||
def band_ink_mask(front_lum: np.ndarray, diff: np.ndarray, band_mask: np.ndarray, bbox: tuple[int, int, int, int]) -> tuple[np.ndarray, float]:
|
||||
"""brow_centerline.build_ink_band와 같은 기준(Otsu(front 휘도) AND diff>=25,
|
||||
형태학적 닫기로 해칭 조각을 묶어 최대 성분만 취함)을, 축 정렬 bbox 대신 눈썹
|
||||
띠 폴리곤(band_mask) 안으로 제한해 적용한다."""
|
||||
x0, y0, x1, y1 = bbox
|
||||
region_lum = front_lum[y0:y1, x0:x1]
|
||||
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
|
||||
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
ink_mask = region_lum <= otsu_thresh
|
||||
diff_mask = diff[y0:y1, x0:x1] >= DIFF_THRESH
|
||||
band_region = band_mask[y0:y1, x0:x1]
|
||||
combined = ink_mask & diff_mask & band_region
|
||||
|
||||
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
|
||||
labeled, n = ndimage.label(closed)
|
||||
if n == 0:
|
||||
raise SystemExit("[중단] 눈썹 띠 안에 잉크 마스크가 전혀 없다.")
|
||||
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
|
||||
top_label = int(np.argmax(sizes)) + 1
|
||||
band_ink_local = combined & (labeled == top_label)
|
||||
|
||||
lbl2, n2 = ndimage.label(band_ink_local)
|
||||
if n2 > 0:
|
||||
sizes2 = ndimage.sum(band_ink_local, lbl2, range(1, n2 + 1))
|
||||
keep = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
|
||||
band_ink_local = band_ink_local & np.isin(lbl2, keep)
|
||||
|
||||
full = np.zeros(front_lum.shape, dtype=bool)
|
||||
full[y0:y1, x0:x1] = band_ink_local
|
||||
return full, float(otsu_thresh)
|
||||
|
||||
|
||||
# --- 눈썹 스프라이트 한 쪽 ----------------------------------------------------
|
||||
|
||||
|
||||
def build_one_brow_sprite(
|
||||
inner: tuple[float, float], peak: tuple[float, float], outer: tuple[float, float],
|
||||
half_width: tuple[float, float, float],
|
||||
front_rgb: np.ndarray, faceless_rgb: np.ndarray, front_lum: np.ndarray, faceless_lum: np.ndarray,
|
||||
diff: np.ndarray, hair_mask: np.ndarray, canvas_w: int, canvas_h: int, side_label: str,
|
||||
) -> dict:
|
||||
half_height = max(half_width) + BAND_EXTRA_HALF_HEIGHT_PX
|
||||
band_points = brow_band_polygon(inner, peak, outer, half_height, BAND_END_EXTEND_PX)
|
||||
band_mask = polygon_bool_mask(band_points, canvas_w, canvas_h)
|
||||
bbox = mask_bbox(band_mask, BAND_BBOX_PAD_PX, canvas_w, canvas_h)
|
||||
|
||||
ink_mask_raw, otsu_thresh = band_ink_mask(front_lum, diff, band_mask, bbox)
|
||||
hair_overlap_px = int((ink_mask_raw & hair_mask).sum())
|
||||
region = ink_mask_raw & ~hair_mask
|
||||
if not region.any():
|
||||
raise SystemExit(f"[중단] {side_label} 눈썹 영역이 비었다(머리카락 제거 후 잉크 픽셀 0).")
|
||||
|
||||
band_lum_faceless = faceless_lum[band_mask]
|
||||
l_skin = float(np.median(band_lum_faceless))
|
||||
# 2차 보정: 띠 전체 휘도의 5백분위(극단적으로 어두운 꼬리값) 대신, 실제로 잉크로
|
||||
# 분류된 픽셀(region, 머리카락 제거 후)의 휘도 중앙값을 쓴다 — 그보다 어두운
|
||||
# 잉크 픽셀은 a=1이 된다(목표: 눈썹 알파>0 영역 평균절대차 <=10).
|
||||
l_ink = float(np.median(front_lum[region]))
|
||||
denom = (l_skin - l_ink) if abs(l_skin - l_ink) > 1e-6 else 1e-6
|
||||
|
||||
dist_in = ndimage.distance_transform_edt(region)
|
||||
feather = np.clip(dist_in / RAMP_PX, 0.0, 1.0)
|
||||
density = np.clip((l_skin - front_lum) / denom, 0.0, 1.0)
|
||||
alpha_frac = np.where(region, density * feather, 0.0)
|
||||
alpha = np.rint(alpha_frac * 255.0).astype(np.uint8)
|
||||
|
||||
skin_rgb = faceless_rgb[band_mask].mean(axis=0)
|
||||
ink_sample_mask = band_mask & (front_lum <= l_ink)
|
||||
ink_fallback_rgb = (
|
||||
np.median(front_rgb[ink_sample_mask], axis=0) if ink_sample_mask.any() else np.array([0.0, 0.0, 0.0])
|
||||
)
|
||||
|
||||
a3 = alpha_frac[..., None]
|
||||
unmixed = (front_rgb - (1.0 - a3) * skin_rgb.reshape(1, 1, 3)) / np.maximum(a3, 1e-6)
|
||||
unmixed = np.clip(unmixed, 0.0, 255.0)
|
||||
rgb = np.where(a3 >= MIN_ALPHA_FOR_UNMIX, unmixed, ink_fallback_rgb.reshape(1, 1, 3))
|
||||
|
||||
print(
|
||||
f" [{side_label}] 띠 반높이={half_height:.2f}px otsu={otsu_thresh:.1f} "
|
||||
f"L_skin={l_skin:.2f} L_ink={l_ink:.2f} 영역픽셀={int(region.sum())} "
|
||||
f"앞머리겹침픽셀={hair_overlap_px}"
|
||||
)
|
||||
|
||||
return {
|
||||
"alpha": alpha, "rgb": rgb, "region": region, "bandMask": band_mask, "bbox": bbox,
|
||||
"halfHeight": half_height, "otsuThresh": otsu_thresh, "lSkin": l_skin, "lInk": l_ink,
|
||||
"hairOverlapPx": hair_overlap_px, "regionPixelCount": int(region.sum()),
|
||||
}
|
||||
|
||||
|
||||
# --- 중립 합성 검사(기존 레이어 위에 스프라이트를 얹어 원화와 비교) ------------
|
||||
|
||||
|
||||
def _load_rgba(path: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
arr = np.array(Image.open(path).convert("RGBA")).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
|
||||
def composite_with_sprites(layers_v2_dir: Path, canvas_w: int, canvas_h: int, sprites: dict[str, dict]) -> np.ndarray:
|
||||
"""body→head→face-detail(이미 눈썹 자리를 faceless로 메워 둔 상태) 위에
|
||||
눈썹 스프라이트를 얹고, 그 위에 hairFront를 덮는다 — LinocutAvatar.tsx의
|
||||
그리는 순서(브로우는 face 그룹 안, hairFront보다 먼저)와 같다."""
|
||||
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
|
||||
canvas[..., 0] = CREAM_BG[0]
|
||||
canvas[..., 1] = CREAM_BG[1]
|
||||
canvas[..., 2] = CREAM_BG[2]
|
||||
canvas[..., 3] = 255.0
|
||||
for stem in ("body", "head", "face-detail"):
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
if p.exists():
|
||||
rgb, a = _load_rgba(p)
|
||||
canvas = composite_over(canvas, to_u8(rgb), to_u8(a))
|
||||
for data in sprites.values():
|
||||
canvas = composite_over(canvas, to_u8(data["rgb"]), data["alpha"])
|
||||
hf_path = layers_v2_dir / "hairFront.png"
|
||||
if hf_path.exists():
|
||||
hf_rgb, hf_a = _load_rgba(hf_path)
|
||||
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
|
||||
return canvas[..., :3]
|
||||
|
||||
|
||||
def _save_evidence(front_rgb: np.ndarray, sprites: dict[str, dict], composite_rgb: np.ndarray, preview_v2_dir: Path) -> None:
|
||||
"""옆 쪽마다 원화 | 중립 합성(스프라이트 포함) | 스프라이트(체커 배경) 3열,
|
||||
3배 확대(lip_texture.py _save_evidence와 같은 방식)."""
|
||||
rows = []
|
||||
for side_label, data in sprites.items():
|
||||
x0, y0, x1, y1 = data["bbox"]
|
||||
pad = 10
|
||||
bx0, by0 = max(0, x0 - pad), max(0, y0 - pad)
|
||||
bx1, by1 = x1 + pad, y1 + pad
|
||||
|
||||
def zoom(arr: np.ndarray) -> Image.Image:
|
||||
crop = arr[by0:by1, bx0:bx1]
|
||||
im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8))
|
||||
return im.resize((im.width * 3, im.height * 3), Image.LANCZOS)
|
||||
|
||||
panel_orig = zoom(front_rgb).convert("RGB")
|
||||
panel_composite = zoom(composite_rgb).convert("RGB")
|
||||
|
||||
alpha = data["alpha"]
|
||||
rgb = data["rgb"]
|
||||
crop_rgb = rgb[by0:by1, bx0:bx1]
|
||||
crop_a = (alpha[by0:by1, bx0:bx1].astype(np.float64) / 255.0)[..., None]
|
||||
yy, xx = np.indices(crop_a.shape[:2])
|
||||
checker = ((yy // 6 + xx // 6) % 2) * 60 + 180
|
||||
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
|
||||
sprite_view = crop_rgb * crop_a + checker3 * (1 - crop_a)
|
||||
panel_sprite = Image.fromarray(np.clip(sprite_view, 0, 255).astype(np.uint8)).resize(
|
||||
(panel_orig.width, panel_orig.height), Image.NEAREST
|
||||
)
|
||||
|
||||
gap = 12
|
||||
row = Image.new("RGB", (panel_orig.width * 3 + gap * 2, panel_orig.height), (255, 255, 255))
|
||||
row.paste(panel_orig, (0, 0))
|
||||
row.paste(panel_composite, (panel_orig.width + gap, 0))
|
||||
row.paste(panel_sprite, (panel_orig.width * 2 + gap * 2, 0))
|
||||
rows.append(row)
|
||||
|
||||
gap_y = 12
|
||||
max_w = max(r.width for r in rows)
|
||||
total_h = sum(r.height for r in rows) + gap_y * (len(rows) - 1)
|
||||
combined = Image.new("RGB", (max_w, total_h), (255, 255, 255))
|
||||
y = 0
|
||||
for row in rows:
|
||||
combined.paste(row, (0, y))
|
||||
y += row.height + gap_y
|
||||
scale = min(1.0, 1400 / max(combined.size))
|
||||
if scale < 1.0:
|
||||
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = preview_v2_dir / "brow-sprite.jpg"
|
||||
combined.save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
||||
# --- 눈꺼풀 선 굵기(lineScale) -------------------------------------------------
|
||||
|
||||
|
||||
def measure_upper_lid_thickness(eye: dict, front_lum: np.ndarray) -> tuple[float, int]:
|
||||
"""눈 폭(innerCorner~outerCorner) 가운데 60% 열마다, upperLidTop.y-12에서
|
||||
홍채 중심 y 사이의 첫 연속 어두운 구간(lum<80) 길이를 재 중앙값을 낸다."""
|
||||
inner_x = eye["innerCorner"][0]
|
||||
outer_x = eye["outerCorner"][0]
|
||||
x_lo, x_hi = sorted([inner_x, outer_x])
|
||||
width = x_hi - x_lo
|
||||
margin = (1.0 - UPPER_LID_COLUMN_MIDDLE_FRAC) / 2.0 * width
|
||||
col_lo = x_lo + margin
|
||||
col_hi = x_hi - margin
|
||||
y_top = eye["upperLidTop"][1] - UPPER_LID_WINDOW_ABOVE_PX
|
||||
y_bottom = eye["iris"]["center"][1]
|
||||
|
||||
lengths: list[int] = []
|
||||
for cx in range(int(round(col_lo)), int(round(col_hi)) + 1):
|
||||
col = front_lum[int(round(y_top)):int(round(y_bottom)), cx]
|
||||
dark = col < UPPER_LID_DARK_LUM_THRESH
|
||||
idx = np.where(dark)[0]
|
||||
if len(idx) == 0:
|
||||
continue
|
||||
start = int(idx[0])
|
||||
length = 1
|
||||
i = start
|
||||
while i + 1 < len(dark) and dark[i + 1]:
|
||||
length += 1
|
||||
i += 1
|
||||
lengths.append(length)
|
||||
if not lengths:
|
||||
return float("nan"), 0
|
||||
return float(np.median(lengths)), len(lengths)
|
||||
|
||||
|
||||
# --- 홍채 실측(T3) -------------------------------------------------------------
|
||||
|
||||
|
||||
def _contiguous_dark_run(values: np.ndarray, center_idx: int, thresh: float) -> tuple[int, int] | None:
|
||||
"""1차원 휘도 배열에서 center_idx를 포함하는 연속 어두운(값<thresh) 구간의
|
||||
(시작, 끝) 인덱스. center_idx 자체가 어둡지 않으면 None."""
|
||||
n = len(values)
|
||||
if center_idx < 0 or center_idx >= n or values[center_idx] >= thresh:
|
||||
return None
|
||||
s = center_idx
|
||||
while s - 1 >= 0 and values[s - 1] < thresh:
|
||||
s -= 1
|
||||
e = center_idx
|
||||
while e + 1 < n and values[e + 1] < thresh:
|
||||
e += 1
|
||||
return s, e
|
||||
|
||||
|
||||
def measure_iris(eye: dict, front_lum: np.ndarray, side_label: str) -> dict:
|
||||
"""홍채(잉크 원판)의 실측 중심·반지름. 세로축은 윗눈꺼풀 잉크와 홍채가 같은
|
||||
톤으로 이어져(판화 특성상 경계가 없다) 못 가르므로, 가로축(중심 y 부근 행마다
|
||||
중심 x를 포함하는 연속 어두운 구간)만으로 반지름·중심 x를 재고, 중심 x 부근
|
||||
열마다 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝(중앙값)에서 그 가로
|
||||
반지름을 빼 중심 y를 역산한다(원이라고 가정)."""
|
||||
cx, cy = eye["iris"]["center"]
|
||||
cxi, cyi = int(round(cx)), int(round(cy))
|
||||
|
||||
lefts: list[int] = []
|
||||
rights: list[int] = []
|
||||
for dy in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1):
|
||||
row = front_lum[cyi + dy, :]
|
||||
seg = _contiguous_dark_run(row, cxi, IRIS_DARK_LUM_THRESH)
|
||||
if seg is not None:
|
||||
lefts.append(seg[0])
|
||||
rights.append(seg[1])
|
||||
if not lefts:
|
||||
raise SystemExit(f"[중단] {side_label} 홍채 가로 실측 — 중심 부근 행에서 어두운 구간을 찾지 못했다.")
|
||||
left = float(np.median(lefts))
|
||||
right = float(np.median(rights))
|
||||
radius = (right - left) / 2.0
|
||||
measured_cx = (right + left) / 2.0
|
||||
|
||||
bottoms: list[int] = []
|
||||
for dx in range(-IRIS_SCAN_BAND_PX, IRIS_SCAN_BAND_PX + 1):
|
||||
col = front_lum[:, cxi + dx]
|
||||
seg = _contiguous_dark_run(col, cyi, IRIS_DARK_LUM_THRESH)
|
||||
if seg is not None:
|
||||
bottoms.append(seg[1])
|
||||
if not bottoms:
|
||||
raise SystemExit(f"[중단] {side_label} 홍채 세로 실측 — 중심 부근 열에서 어두운 구간을 찾지 못했다.")
|
||||
bottom = float(np.median(bottoms))
|
||||
measured_cy = bottom - radius
|
||||
|
||||
rig_radius = float(eye["iris"]["radius"])
|
||||
print(
|
||||
f" [{side_label}] 홍채 실측: center=({measured_cx:.1f},{measured_cy:.1f}) radius={radius:.2f} "
|
||||
f"(리그 center=({cx:.1f},{cy:.1f}) radius={rig_radius:.2f}, 차이 radius={radius - rig_radius:+.2f}, "
|
||||
f"표본 가로 {len(lefts)}행/세로 {len(bottoms)}열)"
|
||||
)
|
||||
return {
|
||||
"measuredCenter": [round(measured_cx, 2), round(measured_cy, 2)],
|
||||
"measuredRadius": round(radius, 2),
|
||||
"rigCenter": [round(cx, 2), round(cy, 2)],
|
||||
"rigRadius": round(rig_radius, 2),
|
||||
"nRowsHorizontal": len(lefts),
|
||||
"nColsVertical": len(bottoms),
|
||||
}
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
lm = manifest["landmarks"]
|
||||
canvas_w = manifest["canvas"]["w"]
|
||||
canvas_h = manifest["canvas"]["h"]
|
||||
|
||||
front_rgb = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
faceless_rgb = np.array(build_padded_faceless(base_dir)).astype(np.float64)
|
||||
front_lum = front_rgb.mean(axis=2)
|
||||
faceless_lum = faceless_rgb.mean(axis=2)
|
||||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||||
|
||||
browsprite_report: dict | None = None
|
||||
if cfg.brow_sprite_from_art:
|
||||
print("=== 눈썹 스프라이트 ===")
|
||||
bc = manifest.get("browCenterline")
|
||||
if bc is None:
|
||||
raise SystemExit("[중단] manifest.browCenterline이 없다 — brow_centerline.py를 먼저 실행하라.")
|
||||
half_width_by_side = {"left": bc["halfWidthLeft"], "right": bc["halfWidthRight"]}
|
||||
eyebrow_by_side = {"left": lm["eyebrowLeft"], "right": lm["eyebrowRight"]}
|
||||
|
||||
hairfront_path = layers_v2_dir / "hairFront.png"
|
||||
if not hairfront_path.exists():
|
||||
raise SystemExit(f"[중단] {hairfront_path}가 없다 — layers_v2.py를 먼저 실행하라.")
|
||||
hairfront_alpha = np.array(Image.open(hairfront_path).convert("RGBA"))[..., 3].astype(np.float64)
|
||||
if hairfront_alpha.shape != (canvas_h, canvas_w):
|
||||
raise SystemExit(
|
||||
f"[중단] layers/v2/hairFront.png 크기({hairfront_alpha.shape[1]}x{hairfront_alpha.shape[0]})가 "
|
||||
f"캔버스({canvas_w}x{canvas_h})와 다르다."
|
||||
)
|
||||
hair_mask = ndimage.binary_dilation(hairfront_alpha > HAIR_ALPHA_THRESH, iterations=HAIR_DILATE_ITER)
|
||||
print(f"머리카락 마스크(hairFront 알파>{HAIR_ALPHA_THRESH}, {HAIR_DILATE_ITER}px 팽창) 픽셀 = {int(hair_mask.sum())}")
|
||||
|
||||
sprites: dict[str, dict] = {}
|
||||
for side in ("left", "right"):
|
||||
eb = eyebrow_by_side[side]
|
||||
sprites[side] = build_one_brow_sprite(
|
||||
tuple(eb["inner"]), tuple(eb["peak"]), tuple(eb["outer"]),
|
||||
tuple(half_width_by_side[side]),
|
||||
front_rgb, faceless_rgb, front_lum, faceless_lum, diff, hair_mask,
|
||||
canvas_w, canvas_h, side,
|
||||
)
|
||||
|
||||
composite_rgb = composite_with_sprites(layers_v2_dir, canvas_w, canvas_h, sprites)
|
||||
diffs: dict[str, float] = {}
|
||||
for side, data in sprites.items():
|
||||
d = np.abs(composite_rgb - front_rgb).mean(axis=2)
|
||||
# 2차 보정: 띠 전체가 아니라 스프라이트 알파>0 영역(region)만 잰다 — 알파=0인
|
||||
# 띠 가장자리 피부 픽셀(원래도 거의 차이가 없다)이 섞여 들어가 잉크 알파가
|
||||
# 옅은 문제를 가리던 것을 바로잡는다(목표 <=10).
|
||||
diffs[side] = float(d[data["region"]].mean())
|
||||
print(f" 검사 [{side}] 중립 합성(스프라이트 포함) vs 원화, 눈썹 알파>0 영역 평균절대차 = {diffs[side]:.3f}")
|
||||
|
||||
_save_evidence(front_rgb, sprites, composite_rgb, preview_v2_dir)
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
side_reports: dict[str, dict] = {}
|
||||
canvas_origin: dict[str, list[float]] = {}
|
||||
for side, data in sprites.items():
|
||||
png_arr, bbox = crop_to_alpha_bbox(data["alpha"], to_u8(data["rgb"]))
|
||||
out_path = layers_v2_dir / f"brow-{side}.png"
|
||||
Image.fromarray(png_arr, "RGBA").save(out_path)
|
||||
file_size = out_path.stat().st_size
|
||||
canvas_origin[side] = [float(bbox[0]), float(bbox[1])]
|
||||
print(f"저장: {out_path} bbox(캔버스 절대좌표)={bbox} size={png_arr.shape[1]}x{png_arr.shape[0]} ({file_size} bytes)")
|
||||
side_reports[side] = {
|
||||
"bboxCanvas": list(bbox),
|
||||
"halfHeightPx": round(data["halfHeight"], 3),
|
||||
"otsuThresh": data["otsuThresh"],
|
||||
"lSkin": round(data["lSkin"], 3),
|
||||
"lInk": round(data["lInk"], 3),
|
||||
"regionPixelCount": data["regionPixelCount"],
|
||||
"hairOverlapPixelCount": data["hairOverlapPx"],
|
||||
"neutralCompositeMeanAbsDiffAlphaRegion": round(diffs[side], 3),
|
||||
"pngFileSizeBytes": file_size,
|
||||
}
|
||||
|
||||
browsprite_report = {
|
||||
"enabled": True,
|
||||
"bandExtraHalfHeightPx": BAND_EXTRA_HALF_HEIGHT_PX,
|
||||
"bandEndExtendPx": BAND_END_EXTEND_PX,
|
||||
"diffThresh": DIFF_THRESH,
|
||||
"closingKernel": list(CLOSING_KERNEL),
|
||||
"minComponentAreaPx": MIN_COMPONENT_AREA,
|
||||
"hairSource": "layers/v2/hairFront.png alpha(게시되는 레이어 그 자체, 분할 카테고리 아님)",
|
||||
"hairAlphaThresh": HAIR_ALPHA_THRESH,
|
||||
"hairDilateIterations": HAIR_DILATE_ITER,
|
||||
"rampPx": RAMP_PX,
|
||||
"inkLumMethod": "median(ink-classified pixels in band, region after hair removal)",
|
||||
"minAlphaForUnmix": MIN_ALPHA_FOR_UNMIX,
|
||||
"hairMaskPixelCount": int(hair_mask.sum()),
|
||||
"left": side_reports["left"],
|
||||
"right": side_reports["right"],
|
||||
"canvasOrigin": canvas_origin,
|
||||
"evidenceImage": "preview/v2/brow-sprite.jpg",
|
||||
}
|
||||
else:
|
||||
print("browSpriteFromArt=false — 눈썹 스프라이트를 건너뛴다.")
|
||||
browsprite_report = {"enabled": False}
|
||||
|
||||
eye_line_scale_report: dict | None = None
|
||||
if cfg.eye_line_scale_from_art:
|
||||
print("=== 눈꺼풀 선 굵기(lineScale) ===")
|
||||
side_reports = {}
|
||||
for side, key in (("left", "eyeLeft"), ("right", "eyeRight")):
|
||||
eye = lm[key]
|
||||
measured_px, n_cols = measure_upper_lid_thickness(eye, front_lum)
|
||||
if n_cols == 0:
|
||||
raise SystemExit(f"[중단] {key} 윗눈꺼풀 두께 측정 열이 하나도 없다(모든 열에서 lum<80 구간을 못 찾음).")
|
||||
ratio = measured_px / P1_UPPER_LID_THICKNESS_PX
|
||||
line_scale = float(np.clip(ratio, LINE_SCALE_MIN, LINE_SCALE_MAX))
|
||||
print(
|
||||
f" [{key}] 측정={measured_px:.3f}px(열 {n_cols}개) / P1기준={P1_UPPER_LID_THICKNESS_PX}px "
|
||||
f"= 비율 {ratio:.3f} -> lineScale={line_scale:.3f}"
|
||||
+ (" [clamp 적용됨]" if not (LINE_SCALE_MIN <= ratio <= LINE_SCALE_MAX) else "")
|
||||
)
|
||||
side_reports[side] = {
|
||||
"measuredPx": round(measured_px, 3), "nColumns": n_cols,
|
||||
"ratio": round(ratio, 3), "lineScale": round(line_scale, 3),
|
||||
}
|
||||
eye_line_scale_report = {
|
||||
"enabled": True,
|
||||
"method": "눈 폭 가운데 60% 열마다 upperLidTop.y-12 ~ 홍채중심y 사이 첫 연속 어두운(lum<80) 구간 길이의 중앙값",
|
||||
"windowAbovePx": UPPER_LID_WINDOW_ABOVE_PX,
|
||||
"darkLumThresh": UPPER_LID_DARK_LUM_THRESH,
|
||||
"columnMiddleFrac": UPPER_LID_COLUMN_MIDDLE_FRAC,
|
||||
"p1BaselinePx": P1_UPPER_LID_THICKNESS_PX,
|
||||
"lineScaleClampRange": [LINE_SCALE_MIN, LINE_SCALE_MAX],
|
||||
"left": side_reports["left"],
|
||||
"right": side_reports["right"],
|
||||
}
|
||||
else:
|
||||
print("eyeLineScaleFromArt=false — 눈꺼풀 lineScale을 건너뛴다.")
|
||||
eye_line_scale_report = {"enabled": False}
|
||||
|
||||
# 홍채 실측(T3)은 browSpriteFromArt/eyeLineScaleFromArt와 무관하게 항상 재
|
||||
# manifest에 남긴다(browHalfWidthFromArt와 같은 "측정값은 설정과 무관하게 항상
|
||||
# 기록" 원칙). export_rig.py가 persona.json irisFromArt(기본 true)일 때만 쓴다.
|
||||
print("=== 홍채 실측(T3) ===")
|
||||
iris_report = {
|
||||
"method": (
|
||||
"가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 "
|
||||
"중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 "
|
||||
"어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 "
|
||||
"홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다)."
|
||||
),
|
||||
"darkLumThresh": IRIS_DARK_LUM_THRESH,
|
||||
"scanBandPx": IRIS_SCAN_BAND_PX,
|
||||
"left": measure_iris(lm["eyeLeft"], front_lum, "eyeLeft"),
|
||||
"right": measure_iris(lm["eyeRight"], front_lum, "eyeRight"),
|
||||
}
|
||||
|
||||
manifest["browSprite"] = browsprite_report
|
||||
manifest["eyeLineScale"] = eye_line_scale_report
|
||||
manifest["iris"] = iris_report
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신(browSprite, eyeLineScale, iris): {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
|
|
@ -59,6 +59,15 @@ LIP_SPECS = [
|
|||
]
|
||||
LIP_SIZE_BUDGET_BYTES = 80 * 1024 # 그늘 조각 분리로 3장
|
||||
|
||||
# --- 눈썹 스프라이트(결정문 §8.4.1, 작업 패킷 "원화 눈썹 스프라이트와 눈꺼풀 선 굵기") --
|
||||
|
||||
BROW_SPECS = [
|
||||
# (rig key, 파일 stem, public href stem)
|
||||
("left", "brow-left", "brow-left"),
|
||||
("right", "brow-right", "brow-right"),
|
||||
]
|
||||
BROW_SIZE_BUDGET_BYTES = 60 * 1024
|
||||
|
||||
# --- 턱 조각(결정문 §8.4 하관 띠 변형) ---------------------------------------
|
||||
|
||||
JAW_SPECS = [
|
||||
|
|
@ -307,6 +316,123 @@ def publish_lip_texture(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path)
|
|||
return {"upper": lip_field["upper"], "lower": lip_field["lower"], "shadow": lip_field["shadow"]}, publish_report
|
||||
|
||||
|
||||
def publish_brow_sprite(cfg: PersonaConfig, manifest: dict, layers_v2_dir: Path) -> tuple[dict | None, dict | None]:
|
||||
"""layers/v2/brow-{left,right}.png(brow_sprite.py 산출)를 알파 bbox로 잘라
|
||||
WebP로 게시한다(결정문 §8.4.1). 소스가 없으면 (None, None) —
|
||||
browSpriteFromArt=false인 리그(P1)는 RigBrow.sprite 없이 벡터 눈썹을 그대로
|
||||
쓴다(렌더러 대체 경로).
|
||||
|
||||
PNG는 이미 알파 bbox로 꽉 차게 잘려 있어(brow_sprite.py crop_to_alpha_bbox)
|
||||
crop_to_bbox는 사실상 no-op이고, 캔버스 위치는 manifest["browSprite"]
|
||||
["canvasOrigin"]에서 읽는다(publish_lip_texture와 같은 패턴). 인코딩은 입술
|
||||
스프라이트와 같은 무손실(exact) 우선 — 알파=0 영역도 원화 RGB를 그대로
|
||||
담고 있으므로(brow_sprite.py의 피부색 역산·잉크색 대체) exact=True로
|
||||
버려지지 않게 한다."""
|
||||
canvas_origin = manifest.get("browSprite", {}).get("canvasOrigin")
|
||||
if canvas_origin is None:
|
||||
return None, None
|
||||
|
||||
sources: dict[str, tuple[Path, str]] = {}
|
||||
for key, stem, href_stem in BROW_SPECS:
|
||||
p = layers_v2_dir / f"{stem}.png"
|
||||
if not p.exists():
|
||||
return None, None
|
||||
sources[key] = (p, href_stem)
|
||||
|
||||
cropped: dict[str, tuple[Image.Image, tuple[int, int, int, int], str]] = {}
|
||||
for key, (path, href_stem) in sources.items():
|
||||
im = Image.open(path)
|
||||
cropped_im, local_bbox = crop_to_bbox(im)
|
||||
lx0, ly0 = canvas_origin[key]
|
||||
abs_bbox = (lx0 + local_bbox[0], ly0 + local_bbox[1], local_bbox[2], local_bbox[3])
|
||||
cropped[key] = (cropped_im, abs_bbox, href_stem)
|
||||
|
||||
BROW_DIFF_TARGET = 2.0
|
||||
|
||||
def encode_all(quality: int) -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
out[href_stem] = encode_webp(im.convert("RGBA"), quality)
|
||||
return out
|
||||
|
||||
def encode_all_lossless() -> dict[str, bytes]:
|
||||
out: dict[str, bytes] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
buf = io.BytesIO()
|
||||
im.convert("RGBA").save(buf, "WEBP", lossless=True, quality=100, method=6, exact=True)
|
||||
out[href_stem] = buf.getvalue()
|
||||
return out
|
||||
|
||||
def webp_vs_png_diff(encoded: dict[str, bytes]) -> dict[str, float]:
|
||||
diffs: dict[str, float] = {}
|
||||
for key, (im, _bbox, href_stem) in cropped.items():
|
||||
src_arr = np.array(im.convert("RGBA")).astype(np.float64)
|
||||
dec_arr = np.array(Image.open(io.BytesIO(encoded[href_stem])).convert("RGBA")).astype(np.float64)
|
||||
diffs[key] = round(float(np.abs(src_arr - dec_arr).mean()), 3)
|
||||
return diffs
|
||||
|
||||
lossless_encoded = encode_all_lossless()
|
||||
lossless_total = sum(len(b) for b in lossless_encoded.values())
|
||||
if lossless_total <= BROW_SIZE_BUDGET_BYTES:
|
||||
chosen, chosen_quality = lossless_encoded, "lossless"
|
||||
else:
|
||||
candidates: list[tuple[int, dict[str, bytes], int, dict[str, float]]] = []
|
||||
chosen, chosen_quality = None, None
|
||||
for q in (100, 99, 98, 97, 96, 95):
|
||||
encoded = encode_all(q)
|
||||
total = sum(len(b) for b in encoded.values())
|
||||
diffs = webp_vs_png_diff(encoded)
|
||||
candidates.append((q, encoded, total, diffs))
|
||||
if total <= BROW_SIZE_BUDGET_BYTES and max(diffs.values()) <= BROW_DIFF_TARGET:
|
||||
chosen, chosen_quality = encoded, q
|
||||
break
|
||||
if chosen is None:
|
||||
within_budget = [c for c in candidates if c[2] <= BROW_SIZE_BUDGET_BYTES]
|
||||
pool = within_budget if within_budget else candidates
|
||||
best = min(pool, key=lambda c: max(c[3].values()))
|
||||
chosen_quality, chosen, _total, _diffs = best[0], best[1], best[2], best[3]
|
||||
print(
|
||||
f" [경고] quality 95~100 중 예산({BROW_SIZE_BUDGET_BYTES}B) AND 평균차<={BROW_DIFF_TARGET} "
|
||||
f"동시 만족 없음 — quality={chosen_quality}(평균차 최소) 채택"
|
||||
)
|
||||
assert chosen is not None and chosen_quality is not None
|
||||
|
||||
public_dir = cfg.public_dir
|
||||
public_dir.mkdir(parents=True, exist_ok=True)
|
||||
file_sizes: dict[str, int] = {}
|
||||
for href_stem, data in chosen.items():
|
||||
out_path = public_dir / f"{href_stem}.webp"
|
||||
out_path.write_bytes(data)
|
||||
file_sizes[href_stem] = len(data)
|
||||
total_bytes = sum(file_sizes.values())
|
||||
print(f"눈썹 스프라이트 인코딩: quality={chosen_quality} (무손실 시도 시 합계 {lossless_total} bytes)")
|
||||
for href_stem, size in file_sizes.items():
|
||||
print(f" {href_stem}.webp = {size} bytes")
|
||||
print(
|
||||
f"눈썹 스프라이트 합계 = {total_bytes} bytes (예산 {BROW_SIZE_BUDGET_BYTES} bytes) "
|
||||
f"{'OK' if total_bytes <= BROW_SIZE_BUDGET_BYTES else '[초과]'}"
|
||||
)
|
||||
|
||||
webp_vs_png = webp_vs_png_diff(chosen)
|
||||
for href_stem in file_sizes:
|
||||
key = next(k for k, (_im, _bbox, hs) in cropped.items() if hs == href_stem)
|
||||
print(f" 검사 [{href_stem}] WebP 대 PNG 평균차={webp_vs_png[key]}")
|
||||
|
||||
brow_field = {}
|
||||
for key, (_im, bbox, href_stem) in cropped.items():
|
||||
x0, y0, w, h = bbox
|
||||
brow_field[key] = {
|
||||
"href": f"{cfg.public_href_prefix}/{href_stem}.webp",
|
||||
"x": round(x0, 1), "y": round(y0, 1), "w": round(w, 1), "h": round(h, 1),
|
||||
}
|
||||
publish_report = {
|
||||
"quality": chosen_quality, "fileSizes": file_sizes, "totalBytes": total_bytes,
|
||||
"budgetBytes": BROW_SIZE_BUDGET_BYTES,
|
||||
"webpVsPngMeanAbsDiff": webp_vs_png,
|
||||
}
|
||||
return brow_field, publish_report
|
||||
|
||||
|
||||
# --- 턱 조각 게시(결정문 §8.4 하관 띠 변형) ----------------------------------
|
||||
|
||||
|
||||
|
|
@ -764,29 +890,49 @@ def compute_mouth_center_from_manifest(manifest: dict) -> tuple[float, float]:
|
|||
# --- landmarks 매핑(manifest 값을 이름만 바꿔 그대로 사용) -----------------
|
||||
|
||||
|
||||
def build_landmarks(manifest: dict, mouth_center: tuple[float, float]) -> dict:
|
||||
def build_landmarks(
|
||||
cfg: PersonaConfig, manifest: dict, mouth_center: tuple[float, float], brow_sprite_field: dict | None
|
||||
) -> dict:
|
||||
lm = manifest["landmarks"]
|
||||
half_widths = manifest.get("browCenterline", {}) if cfg.brow_half_width_from_art else {}
|
||||
eye_line_scale = manifest.get("eyeLineScale", {}) if cfg.eye_line_scale_from_art else {}
|
||||
iris_measured = manifest.get("iris", {}) if cfg.iris_from_art else {}
|
||||
|
||||
def pt(p) -> list[float]:
|
||||
return [round(p[0], 1), round(p[1], 1)]
|
||||
|
||||
def eye(e) -> dict:
|
||||
return {
|
||||
def eye(e, line_scale_key: str) -> dict:
|
||||
iris_m = iris_measured.get(line_scale_key)
|
||||
if iris_m is not None:
|
||||
iris_field = {"center": pt(iris_m["measuredCenter"]), "radius": round(float(iris_m["measuredRadius"]), 1)}
|
||||
else:
|
||||
iris_field = {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)}
|
||||
d = {
|
||||
"inner": pt(e["innerCorner"]),
|
||||
"outer": pt(e["outerCorner"]),
|
||||
"upperLidTop": pt(e["upperLidTop"]),
|
||||
"lowerLidBottom": pt(e["lowerLidBottom"]),
|
||||
"iris": {"center": pt(e["iris"]["center"]), "radius": round(e["iris"]["radius"], 1)},
|
||||
"iris": iris_field,
|
||||
}
|
||||
line_scale = eye_line_scale.get(line_scale_key, {}).get("lineScale")
|
||||
if line_scale is not None:
|
||||
d["lineScale"] = round(float(line_scale), 3)
|
||||
return d
|
||||
|
||||
def brow(b) -> dict:
|
||||
return {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
|
||||
def brow(b, half_width_key: str, sprite_key: str) -> dict:
|
||||
d = {"inner": pt(b["inner"]), "peak": pt(b["peak"]), "outer": pt(b["outer"])}
|
||||
hw = half_widths.get(half_width_key)
|
||||
if hw is not None:
|
||||
d["halfWidth"] = [round(float(v), 2) for v in hw]
|
||||
if brow_sprite_field is not None and brow_sprite_field.get(sprite_key) is not None:
|
||||
d["sprite"] = brow_sprite_field[sprite_key]
|
||||
return d
|
||||
|
||||
return {
|
||||
"eyeLeft": eye(lm["eyeLeft"]),
|
||||
"eyeRight": eye(lm["eyeRight"]),
|
||||
"browLeft": brow(lm["eyebrowLeft"]),
|
||||
"browRight": brow(lm["eyebrowRight"]),
|
||||
"eyeLeft": eye(lm["eyeLeft"], "left"),
|
||||
"eyeRight": eye(lm["eyeRight"], "right"),
|
||||
"browLeft": brow(lm["eyebrowLeft"], "halfWidthLeft", "left"),
|
||||
"browRight": brow(lm["eyebrowRight"], "halfWidthRight", "right"),
|
||||
"noseTip": pt(lm["noseTip"]),
|
||||
"mouthCornerLeft": pt(lm["mouthCornerLeft"]),
|
||||
"mouthCornerRight": pt(lm["mouthCornerRight"]),
|
||||
|
|
@ -937,11 +1083,22 @@ def render_rig_ts(cfg: PersonaConfig, rig: dict) -> str:
|
|||
lines.append(f' upperLidTop: {ts_point(e["upperLidTop"])},')
|
||||
lines.append(f' lowerLidBottom: {ts_point(e["lowerLidBottom"])},')
|
||||
lines.append(f' iris: {{ center: {ts_point(e["iris"]["center"])}, radius: {ts_num(e["iris"]["radius"])} }},')
|
||||
if e.get("lineScale") is not None:
|
||||
lines.append(f' lineScale: {ts_num(e["lineScale"])},')
|
||||
lines.append(" },")
|
||||
|
||||
def emit_brow(name, b):
|
||||
hw = b.get("halfWidth")
|
||||
hw_str = f", halfWidth: [{ts_num(hw[0])}, {ts_num(hw[1])}, {ts_num(hw[2])}]" if hw else ""
|
||||
sprite = b.get("sprite")
|
||||
sprite_str = ""
|
||||
if sprite:
|
||||
sprite_str = (
|
||||
f', sprite: {{ href: "{sprite["href"]}", x: {ts_num(sprite["x"])}, y: {ts_num(sprite["y"])}, '
|
||||
f'w: {ts_num(sprite["w"])}, h: {ts_num(sprite["h"])} }}'
|
||||
)
|
||||
lines.append(
|
||||
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])} }},'
|
||||
f' {name}: {{ inner: {ts_point(b["inner"])}, peak: {ts_point(b["peak"])}, outer: {ts_point(b["outer"])}{hw_str}{sprite_str} }},'
|
||||
)
|
||||
|
||||
emit_eye("eyeLeft", lmk["eyeLeft"])
|
||||
|
|
@ -1001,7 +1158,12 @@ def main(persona_dir: Path) -> int:
|
|||
for k, v in palette.items():
|
||||
print(f" {k} = {v}")
|
||||
|
||||
landmarks = build_landmarks(manifest, mouth_center)
|
||||
print("=== 눈썹 스프라이트 게시 ===")
|
||||
brow_sprite_field, brow_sprite_publish_report = publish_brow_sprite(cfg, manifest, layers_v2_dir)
|
||||
if brow_sprite_field is None:
|
||||
print(" brow-left.png/brow-right.png 없음(생략) — 벡터 눈썹을 그대로 쓰는 리그")
|
||||
|
||||
landmarks = build_landmarks(cfg, manifest, mouth_center, brow_sprite_field)
|
||||
face_crop = compute_face_crop(face_oval)
|
||||
print(f"crops.face = {[round(v,1) for v in face_crop]}")
|
||||
|
||||
|
|
@ -1083,6 +1245,7 @@ def main(persona_dir: Path) -> int:
|
|||
"lipTexturePublish": lip_publish_report,
|
||||
"jawPublish": jaw_publish_report,
|
||||
"motifPublish": motif_publish_report,
|
||||
"browSpritePublish": brow_sprite_publish_report,
|
||||
"rig": rig,
|
||||
}
|
||||
report_path = cfg.preview_v2_dir / "export-rig-report.json"
|
||||
|
|
@ -1097,8 +1260,14 @@ def main(persona_dir: Path) -> int:
|
|||
if jaw_publish_report is not None and "vsPublishedLayerAbsDiff" in jaw_publish_report:
|
||||
manifest.setdefault("jaw", {})["check1VsPublishedLayerAbsDiff"] = jaw_publish_report["vsPublishedLayerAbsDiff"]
|
||||
manifest["motifPublish"] = motif_publish_report
|
||||
manifest["browSpritePublish"] = brow_sprite_publish_report
|
||||
if brow_sprite_publish_report is not None and "webpVsPngMeanAbsDiff" in brow_sprite_publish_report:
|
||||
manifest.setdefault("browSprite", {})["checkWebpVsPngMeanAbsDiff"] = brow_sprite_publish_report["webpVsPngMeanAbsDiff"]
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish): {manifest_path.relative_to(cfg.repo_root)}")
|
||||
print(
|
||||
f"manifest.json 갱신(lipTexturePublish, jawPublish, motifPublish, browSpritePublish): "
|
||||
f"{manifest_path.relative_to(cfg.repo_root)}"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ SCRIPTS_DIR = Path(__file__).resolve().parent
|
|||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over, CREAM_BG # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402 — region="face"용 얼굴 오벌(캐스트 확장)
|
||||
|
||||
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
|
||||
REGION_FEATHER_PX = 12.0
|
||||
|
|
@ -99,6 +100,9 @@ CHECKE_THRESH = 1.5
|
|||
GENERIC_PATCH_SIZE = 48
|
||||
GENERIC_PATCH_MARGIN = 40
|
||||
|
||||
# --- matchFacelessColor(faceDetail.matchFacelessColor) 상수 -----------------
|
||||
MATCH_RING_LUM_THRESH = 110.0 # 고리 표본에서 이보다 어두운(잉크) front 픽셀은 뺀다
|
||||
|
||||
|
||||
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
|
||||
|
|
@ -108,6 +112,26 @@ def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, fe
|
|||
return np.clip(alpha, 0, 255)
|
||||
|
||||
|
||||
def face_oval_region_alpha(
|
||||
base_front_path: Path, inset_px: float, w: int, h: int, feather: float = REGION_FEATHER_PX
|
||||
) -> tuple[np.ndarray, np.ndarray, int, int]:
|
||||
"""base-front.png 랜드마크로 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은
|
||||
36점 루프·1.04 배율)을 구해 inset_px만큼 안쪽으로 줄인 뒤 feather px로 페더링한다.
|
||||
원화 윤곽 잉크선은 오벌 경계 근방(jaw_pieces.py OVAL_OUTSET_PX=18px 안팎)에 있으므로,
|
||||
inset_px를 충분히 크게 잡아야 이 잉크선이 반환 영역(알파>0) 밖에 남는다 — 호출자가
|
||||
persona.json faceDetail.faceInsetPx로 값을 정하고 근거를 보고해야 한다.
|
||||
반환: (알파, 원본 오벌 불리언 마스크, 오벌 픽셀 수, inset 후 픽셀 수)."""
|
||||
points = detect_face_landmarks(base_front_path)
|
||||
oval_pts = compute_face_oval(points)
|
||||
oval_img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(oval_img).polygon(oval_pts, fill=255)
|
||||
oval_mask = np.array(oval_img) > 127
|
||||
dist_in = distance_transform_edt(oval_mask)
|
||||
inset_mask = oval_mask & (dist_in >= inset_px)
|
||||
alpha = np.clip(gaussian_filter(inset_mask.astype(np.float64) * 255.0, sigma=feather / 2.0), 0, 255)
|
||||
return alpha, oval_mask, int(oval_mask.sum()), int(inset_mask.sum())
|
||||
|
||||
|
||||
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
|
||||
img = Image.new("L", (w, h), 0)
|
||||
d = ImageDraw.Draw(img)
|
||||
|
|
@ -453,7 +477,18 @@ def main(persona_dir: Path) -> int:
|
|||
mcx, mcy = mole["center"]
|
||||
r = float(mole["radius"])
|
||||
a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX))
|
||||
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
|
||||
|
||||
if cfg.face_detail_region == "face":
|
||||
a_face, oval_mask, oval_px, inset_px_count = face_oval_region_alpha(
|
||||
base_dir / "base-front.png", cfg.face_inset_px, w, h
|
||||
)
|
||||
region_alpha = np.maximum(a_face, a_mole)
|
||||
print(
|
||||
f"faceDetail.region=face: faceInsetPx={cfg.face_inset_px} "
|
||||
f"오벌픽셀={oval_px} inset후픽셀={inset_px_count}"
|
||||
)
|
||||
else:
|
||||
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
|
||||
|
|
@ -521,7 +556,20 @@ def main(persona_dir: Path) -> int:
|
|||
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
|
||||
# ------------------------------------------------------------------
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
|
||||
hair_mask_raw = category_mask == 1
|
||||
if cfg.face_detail_region == "face":
|
||||
# region="face"는 얼굴 피부 전체에 원화(front) 픽셀을 노출하므로, front와
|
||||
# faceless의 앞머리 가닥 위치가 달라(codex 편집본 시차) faceless 분할 하나만
|
||||
# 쓰면 front 쪽 잔머리가 머리카락 마스크 밖으로 나와 유령 가닥이 생긴다 —
|
||||
# 두 그림의 분할 합집합으로 덮는다.
|
||||
front_category_mask = run_segmentation(base_dir / "base-front.png")
|
||||
hair_mask_front_raw = front_category_mask == 1
|
||||
hair_mask_raw = hair_mask_raw | hair_mask_front_raw
|
||||
print(
|
||||
f"머리카락 분할(합집합): faceless={int((category_mask == 1).sum())}px "
|
||||
f"front={int(hair_mask_front_raw.sum())}px union={int(hair_mask_raw.sum())}px"
|
||||
)
|
||||
hair_mask = binary_dilation(hair_mask_raw, iterations=HAIR_DILATE_PX)
|
||||
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -535,6 +583,31 @@ def main(persona_dir: Path) -> int:
|
|||
for r in stamp_report:
|
||||
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# matchFacelessColor: 제외 영역(눈·눈썹·입) 안의 F 색을 주변 원화 피부에 맞춘다.
|
||||
# 영역마다 RING_PX 바깥 고리(어두운 잉크 제외)에서 front·F 평균 RGB 차를 구해
|
||||
# 그 영역 전체의 F를 그 차이만큼 평행이동한다 — 보간·블러·패치 합성은 하지 않는다.
|
||||
# ------------------------------------------------------------------
|
||||
match_color_report: dict[str, list[float]] = {}
|
||||
if cfg.match_faceless_color:
|
||||
print("=== matchFacelessColor: 영역별 ΔRGB(front - F, 안쪽 고리 표본) ===")
|
||||
deltas: dict[str, np.ndarray] = {}
|
||||
for name, region_mask in named_excl.items():
|
||||
ring = binary_dilation(region_mask, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
|
||||
sample_mask = ring & (lum_front >= MATCH_RING_LUM_THRESH)
|
||||
if not sample_mask.any():
|
||||
deltas[name] = np.zeros(3)
|
||||
print(f" {name}: 고리 표본 없음 — 보정 생략")
|
||||
continue
|
||||
front_mean = front[sample_mask].mean(axis=0)
|
||||
f_mean = f_clean[sample_mask].mean(axis=0)
|
||||
deltas[name] = front_mean - f_mean
|
||||
d = deltas[name]
|
||||
print(f" {name}: ΔRGB=({d[0]:+.2f},{d[1]:+.2f},{d[2]:+.2f}) 표본={int(sample_mask.sum())}px")
|
||||
for name, region_mask in named_excl.items():
|
||||
f_clean[region_mask] = np.clip(f_clean[region_mask] + deltas[name], 0, 255)
|
||||
match_color_report = {name: [round(float(c), 2) for c in d] for name, d in deltas.items()}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
|
||||
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
|
||||
|
|
@ -714,6 +787,10 @@ def main(persona_dir: Path) -> int:
|
|||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["faceDetail"] = {
|
||||
"browLandmarksSource": brow_source,
|
||||
"region": cfg.face_detail_region,
|
||||
"faceInsetPx": cfg.face_inset_px if cfg.face_detail_region == "face" else None,
|
||||
"matchFacelessColor": cfg.match_faceless_color,
|
||||
"matchFacelessColorDeltaRgb": match_color_report,
|
||||
"regions": {
|
||||
"eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)],
|
||||
"eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)],
|
||||
|
|
|
|||
249
docs/avatar-art/linocut-pipeline/scripts/normalize_base.py
Normal file
249
docs/avatar-art/linocut-pipeline/scripts/normalize_base.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
"""공통 리노컷 리그 — 캐스트 확장 0단계: 원화 3장을 캔버스로 정규화.
|
||||
|
||||
입력 front/faceless/body는 모두 같은 좌표계(후보 원화) 위에 있다고 전제한다
|
||||
(T1 register_edit.py가 faceless·body를 원화 좌표계로 이미 정렬해 둔 출력을
|
||||
그대로 받는다). 셋 모두에 **같은 아핀 변환 하나**(등방 배율 + 평행이동,
|
||||
회전 없음)를 적용해 캔버스 1005x1566(결정문 §8.4)으로 옮긴다.
|
||||
|
||||
기준은 P1 base-front.png의 랜드마크다(눈 중심 두 점의 중점, 두 눈 사이 거리,
|
||||
턱끝) — 머리 크기·위치가 P1과 같은 비율이 되게 새 원화의 배율·이동을 定한다.
|
||||
mediapipe FaceLandmarker 인덱스: 눈A=(33,133) 중점, 눈B=(263,362) 중점, 턱=152
|
||||
(landmarks.py의 EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
|
||||
|
||||
배율: s_head = (P1 눈-턱 거리/원화 눈-턱 거리 + P1 눈 사이 거리/원화 눈 사이
|
||||
거리) / 2. s = max(s_head, 캔버스폭/원화폭) — 원화가 머리 비율상 더 작게
|
||||
배율돼도 캔버스 폭은 항상 덮어야 한다.
|
||||
|
||||
이동: 두 눈 중점을 P1 두 눈 중점에 맞춘다. 가로 오프셋은 배율된 원화가 캔버스
|
||||
폭을 꼭 덮도록 [캔버스폭 − 원화폭·s, 0] 구간으로 자른다(좌우 빈틈 방지).
|
||||
세로 오프셋은 자르지 않는다 — 위/아래로 남는 빈 곳은 각 이미지 종류에 맞게
|
||||
채운다(front·faceless: 원본 가장자리 띠 반사 패딩, body: #00ff00).
|
||||
|
||||
사용: normalize_base.py <persona-dir> --front <원화> --faceless <정렬된 faceless> --body <정렬된 body>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
|
||||
CANVAS_W = 1005
|
||||
CANVAS_H = 1566
|
||||
TOP_PAPER_BAND_PX = 8 # 위쪽 채움 색을 뽑을 원화 맨 위 행 수(머리카락은 14행부터 나온다)
|
||||
P1_REFERENCE_RELATIVE = Path("docs/avatar-art/p1-linocut/base/base-front.png")
|
||||
|
||||
# mediapipe FaceLandmarker 인덱스(landmarks.py EYE_A_IDX/EYE_B_IDX/CHIN_IDX와 같다).
|
||||
EYE_A_IDX = (33, 133)
|
||||
EYE_B_IDX = (263, 362)
|
||||
CHIN_IDX = 152
|
||||
|
||||
|
||||
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
|
||||
"""FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out_json = Path(td) / "landmarks.json"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
print(proc.stdout.strip())
|
||||
if proc.returncode != 0 or not out_json.exists():
|
||||
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
|
||||
data = json.loads(out_json.read_text(encoding="utf-8"))
|
||||
if not data.get("ok"):
|
||||
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
|
||||
return [(p[0], p[1]) for p in data["points"]]
|
||||
|
||||
|
||||
def midpoint(a: tuple[float, float], b: tuple[float, float]) -> tuple[float, float]:
|
||||
return ((a[0] + b[0]) / 2.0, (a[1] + b[1]) / 2.0)
|
||||
|
||||
|
||||
def dist(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
return float(np.hypot(a[0] - b[0], a[1] - b[1]))
|
||||
|
||||
|
||||
def measure_head(points: list[tuple[float, float]]) -> dict:
|
||||
eye_a = midpoint(points[EYE_A_IDX[0]], points[EYE_A_IDX[1]])
|
||||
eye_b = midpoint(points[EYE_B_IDX[0]], points[EYE_B_IDX[1]])
|
||||
eyes_center = midpoint(eye_a, eye_b)
|
||||
chin = points[CHIN_IDX]
|
||||
return {
|
||||
"eyeA": eye_a, "eyeB": eye_b, "eyesCenter": eyes_center, "chin": chin,
|
||||
"eyeDistance": dist(eye_a, eye_b),
|
||||
"eyeChinDistance": dist(eyes_center, chin),
|
||||
}
|
||||
|
||||
|
||||
def measure_report(head: dict) -> dict:
|
||||
return {
|
||||
"eyesCenter": [round(head["eyesCenter"][0], 2), round(head["eyesCenter"][1], 2)],
|
||||
"chin": [round(head["chin"][0], 2), round(head["chin"][1], 2)],
|
||||
"eyeDistance": round(head["eyeDistance"], 3),
|
||||
"eyeChinDistance": round(head["eyeChinDistance"], 3),
|
||||
}
|
||||
|
||||
|
||||
def clamp(v: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def sha256_of(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="원화 3장(front/faceless/body)을 캔버스 1005x1566으로 정규화한다.")
|
||||
parser.add_argument("persona_dir", help="페르소나 폴더(예: docs/avatar-art/p2-linocut)")
|
||||
parser.add_argument("--front", required=True, help="후보 원화(정면 기본형)")
|
||||
parser.add_argument("--faceless", required=True, help="원화 좌표계로 정렬된 얼굴 없는 기본형(T1 출력)")
|
||||
parser.add_argument("--body", required=True, help="원화 좌표계로 정렬된 body 편집본(T1 출력)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
persona_dir = Path(args.persona_dir).resolve()
|
||||
front_path = Path(args.front).resolve()
|
||||
faceless_path = Path(args.faceless).resolve()
|
||||
body_path = Path(args.body).resolve()
|
||||
|
||||
# persona_dir 위치와 무관하게 이 스크립트 파일 기준으로 repo root를 고정한다
|
||||
# (persona.json이 아직 없을 수 있는 단계라 persona_config.py의 persona_dir 기준
|
||||
# repo_root 계산에 의존하지 않는다): scripts/ -> linocut-pipeline -> avatar-art -> docs -> repo root.
|
||||
repo_root = SCRIPTS_DIR.parents[3]
|
||||
ref_path = repo_root / P1_REFERENCE_RELATIVE
|
||||
if not ref_path.exists():
|
||||
raise SystemExit(f"[중단] P1 기준 이미지가 없다: {ref_path}")
|
||||
|
||||
print(f"=== 기준(P1) 랜드마크: {ref_path} ===")
|
||||
ref_head = measure_head(detect_face_landmarks(ref_path))
|
||||
print(
|
||||
f" eyesCenter={ref_head['eyesCenter']} eyeDistance={ref_head['eyeDistance']:.2f} "
|
||||
f"eyeChinDistance={ref_head['eyeChinDistance']:.2f}"
|
||||
)
|
||||
|
||||
print(f"=== 입력 원화 랜드마크: {front_path} ===")
|
||||
front_head = measure_head(detect_face_landmarks(front_path))
|
||||
print(
|
||||
f" eyesCenter={front_head['eyesCenter']} eyeDistance={front_head['eyeDistance']:.2f} "
|
||||
f"eyeChinDistance={front_head['eyeChinDistance']:.2f}"
|
||||
)
|
||||
|
||||
front_img = Image.open(front_path).convert("RGB")
|
||||
front_w, front_h = front_img.size
|
||||
|
||||
s_head = (
|
||||
ref_head["eyeChinDistance"] / front_head["eyeChinDistance"]
|
||||
+ ref_head["eyeDistance"] / front_head["eyeDistance"]
|
||||
) / 2.0
|
||||
s_width = CANVAS_W / front_w
|
||||
s = max(s_head, s_width)
|
||||
print(f"s_head={s_head:.5f} s_width(캔버스폭/원화폭)={s_width:.5f} -> s={s:.5f}")
|
||||
|
||||
offset_x_natural = ref_head["eyesCenter"][0] - front_head["eyesCenter"][0] * s
|
||||
offset_x_lo, offset_x_hi = CANVAS_W - front_w * s, 0.0
|
||||
offset_x = clamp(offset_x_natural, offset_x_lo, offset_x_hi)
|
||||
offset_y = ref_head["eyesCenter"][1] - front_head["eyesCenter"][1] * s
|
||||
print(f"offset_x natural={offset_x_natural:.3f} clamp=[{offset_x_lo:.3f}, {offset_x_hi:.3f}] -> {offset_x:.3f}")
|
||||
print(f"offset_y={offset_y:.3f} (클램프 없음)")
|
||||
|
||||
covered_x0 = clamp(offset_x, 0.0, CANVAS_W)
|
||||
covered_x1 = clamp(offset_x + front_w * s, 0.0, CANVAS_W)
|
||||
covered_y0 = clamp(offset_y, 0.0, CANVAS_H)
|
||||
covered_y1 = clamp(offset_y + front_h * s, 0.0, CANVAS_H)
|
||||
print(
|
||||
f"덮인 범위(반사/채움 아닌 원화 픽셀): x=[{covered_x0:.1f}, {covered_x1:.1f}] "
|
||||
f"y=[{covered_y0:.1f}, {covered_y1:.1f}] (캔버스 {CANVAS_W}x{CANVAS_H})"
|
||||
)
|
||||
|
||||
matrix = np.array([[s, 0.0, offset_x], [0.0, s, offset_y]], dtype=np.float64)
|
||||
|
||||
def warp(src_path: Path, border_mode: int, border_value: tuple[int, int, int] | None) -> np.ndarray:
|
||||
arr = np.array(Image.open(src_path).convert("RGB"))
|
||||
kwargs: dict = {"flags": cv2.INTER_LANCZOS4, "borderMode": border_mode}
|
||||
if border_value is not None:
|
||||
kwargs["borderValue"] = border_value
|
||||
return cv2.warpAffine(arr, matrix, (CANVAS_W, CANVAS_H), **kwargs)
|
||||
|
||||
# front·faceless: 빈 곳은 원본 가장자리 띠 반사(BORDER_REFLECT_101 — 가장자리 픽셀을
|
||||
# 복제하지 않고 그 안쪽부터 비추어, 복제판(T3 build_padded_faceless류)보다 이음매가
|
||||
# 덜 드러난다; 위쪽은 아래에서 종이색으로 덮는다). body: 빈 곳은 크로마키 배경(#00ff00)으로 채운다.
|
||||
front_out = warp(front_path, cv2.BORDER_REFLECT_101, None)
|
||||
faceless_out = warp(faceless_path, cv2.BORDER_REFLECT_101, None)
|
||||
body_out = warp(body_path, cv2.BORDER_CONSTANT, (0, 255, 0))
|
||||
|
||||
# 위쪽 빈 곳만은 반사하지 않고 원화 위 가장자리의 종이색으로 채운다. 머리카락이 원화 위
|
||||
# 끝 가까이(14~42px)까지 오는 후보는 반사하면 머리 위에 뒤집힌 머리카락이 생긴다
|
||||
# (2026-10-01 P3·P5 판정). 배경은 분할에서 레이어 밖으로 빠지므로 평평한 색이어도 화면에
|
||||
# 드러나지 않는다. 아래쪽은 옷이 이어지는 편이 나아 반사를 유지한다.
|
||||
top_rows = int(np.ceil(covered_y0))
|
||||
if top_rows > 0:
|
||||
for out, src_path in ((front_out, front_path), (faceless_out, faceless_path)):
|
||||
band = np.array(Image.open(src_path).convert("RGB"))[:TOP_PAPER_BAND_PX].reshape(-1, 3)
|
||||
paper = band[band.mean(axis=1) > 150]
|
||||
out[:top_rows] = np.median(paper if len(paper) else band, axis=0).astype(np.uint8)
|
||||
|
||||
base_dir = persona_dir / "base"
|
||||
raw_dir = persona_dir / "raw"
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
raw_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
front_out_path = base_dir / "base-front.png"
|
||||
faceless_out_path = base_dir / "base-faceless.png"
|
||||
body_out_path = raw_dir / "body.png"
|
||||
Image.fromarray(front_out, "RGB").save(front_out_path)
|
||||
Image.fromarray(faceless_out, "RGB").save(faceless_out_path)
|
||||
Image.fromarray(body_out, "RGB").save(body_out_path)
|
||||
print(f"저장: {front_out_path}")
|
||||
print(f"저장: {faceless_out_path}")
|
||||
print(f"저장: {body_out_path}")
|
||||
|
||||
report = {
|
||||
"canvas": {"w": CANVAS_W, "h": CANVAS_H},
|
||||
"reference": {"image": str(ref_path.relative_to(repo_root)), **measure_report(ref_head)},
|
||||
"input": {
|
||||
"front": str(front_path),
|
||||
"faceless": str(faceless_path),
|
||||
"body": str(body_path),
|
||||
"frontSize": [front_w, front_h],
|
||||
**measure_report(front_head),
|
||||
},
|
||||
"transform": {
|
||||
"sHead": round(s_head, 6),
|
||||
"sWidth": round(s_width, 6),
|
||||
"s": round(s, 6),
|
||||
"rotationDeg": 0.0,
|
||||
"offsetXNatural": round(offset_x_natural, 3),
|
||||
"offsetXClampRange": [round(offset_x_lo, 3), round(offset_x_hi, 3)],
|
||||
"offsetX": round(offset_x, 3),
|
||||
"offsetY": round(offset_y, 3),
|
||||
},
|
||||
"coveredRange": {
|
||||
"x": [round(covered_x0, 1), round(covered_x1, 1)],
|
||||
"y": [round(covered_y0, 1), round(covered_y1, 1)],
|
||||
},
|
||||
"inputSha256": {
|
||||
"front": sha256_of(front_path),
|
||||
"faceless": sha256_of(faceless_path),
|
||||
"body": sha256_of(body_path),
|
||||
},
|
||||
}
|
||||
report_path = base_dir / "normalize.json"
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"저장: {report_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
|
@ -37,6 +37,7 @@ DEFAULT_BACKDROP = {
|
|||
"energy": "#E2E0D0",
|
||||
}
|
||||
DEFAULT_BUST_Y_OFFSET = 40.0
|
||||
DEFAULT_FACE_INSET_PX = 8.0
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -70,6 +71,32 @@ class PersonaConfig:
|
|||
# 단위 회귀가 성립한다. {"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:
|
||||
|
|
@ -183,6 +210,18 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig:
|
|||
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", {})
|
||||
|
|
@ -211,4 +250,11 @@ def load_persona_config(persona_dir: Path) -> PersonaConfig:
|
|||
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,
|
||||
)
|
||||
|
|
|
|||
327
docs/avatar-art/linocut-pipeline/scripts/register_edit.py
Normal file
327
docs/avatar-art/linocut-pipeline/scripts/register_edit.py
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
"""공통 리노컷 리그 — 캐스트 확장 0단계: codex 편집본을 원화 좌표계로 정렬.
|
||||
|
||||
ref는 편집의 참조가 된 원본(후보 원화, normalize_base.py 이전 — 아직 캔버스로
|
||||
옮기지 않은 원래 크기)이고, edited는 그 ref를 참조해 codex가 편집한 결과다
|
||||
(얼굴 없는 기본형 또는 body 재생성). 크기가 다를 수 있다.
|
||||
|
||||
정렬: edited를 ref 크기로 리사이즈한 뒤, cv2.findTransformECC(MOTION_AFFINE)로
|
||||
회색조·가우시안 흐림(gaussFiltSize) 위에서 ref 좌표계에 정렬한다. ECC가 보는
|
||||
영역은 모드별 마스크로 제한한다(전체 이미지를 다 보면 얼굴 생김새 차이·크로마키
|
||||
배경이 정렬을 왜곡한다):
|
||||
|
||||
- faceless: 머리카락·옷·얼굴 윤곽처럼 두 이미지에서 모양이 같아야 하는 곳만
|
||||
본다 — 랜드마크(ref 기준)로 잡은 눈·눈썹·입 상자를 25px 넓혀 뺀 뒤, 평평한
|
||||
배경(지역 표준편차가 작은 곳)도 뺀다.
|
||||
- body: 턱끝 +20px 아래 행 중 edited(리사이즈본)의 비초록(크로마키 전경) 픽셀만
|
||||
본다 — 그 위는 얼굴이라 body 재생성과 무관하고, 초록 배경은 정렬 정보가 없다.
|
||||
|
||||
출력은 ref 크기로 맞춘 정렬된 편집본 1장이다. ECC로 못 채우는 테두리는
|
||||
faceless면 테두리 복제(BORDER_REPLICATE), body면 크로마키 배경(#00ff00)이다.
|
||||
|
||||
사용: register_edit.py <ref.png> <edited.png> <out.png> --mode faceless|body [--report <json>]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import distance_transform_edt
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import chroma_key # noqa: E402 — 크로마키 전경/배경 판정 재사용(같은 임계치로 일관성 유지)
|
||||
|
||||
# --- mediapipe FaceLandmarker 인덱스(landmarks.py/segmentation.py와 같다) ---
|
||||
RIGHT_EYEBROW_IDX = [46, 53, 52, 65, 55, 70, 63, 105, 66, 107]
|
||||
LEFT_EYEBROW_IDX = [276, 283, 282, 295, 285, 300, 293, 334, 296, 336]
|
||||
EYE_A_IDX = {"outer": 33, "inner": 133, "top": 159, "bottom": 145}
|
||||
EYE_B_IDX = {"inner": 362, "outer": 263, "top": 386, "bottom": 374}
|
||||
MOUTH_CORNER_A_IDX = 61
|
||||
MOUTH_CORNER_B_IDX = 291
|
||||
UPPER_LIP_TOP_IDX = 0
|
||||
LOWER_LIP_BOTTOM_IDX = 17
|
||||
CHIN_IDX = 152
|
||||
FACE_OVAL_LOOP = [
|
||||
10, 338, 297, 332, 284, 251, 389, 356, 454, 323, 361, 288, 397, 365, 379,
|
||||
378, 400, 377, 152, 148, 176, 149, 150, 136, 172, 58, 132, 93, 234, 127,
|
||||
162, 21, 54, 103, 67, 109,
|
||||
]
|
||||
FACE_OVAL_SCALE = 1.04
|
||||
|
||||
# --- ECC ---
|
||||
ECC_EXCLUDE_PAD_PX = 25.0 # 눈·눈썹·입 상자를 이만큼 넓혀 ECC 마스크에서 뺀다
|
||||
ECC_CHIN_MARGIN_PX = 20.0 # body: 턱끝 + 이만큼 아래부터 본다
|
||||
FLAT_BG_WINDOW = 9 # 평평한 배경 판정용 지역 표준편차 창(px)
|
||||
FLAT_BG_STD_THRESH = 6.0 # 이 미만이면 "평평"(정렬 정보 없음)으로 보고 뺀다
|
||||
GREEN_ALPHA_THRESH = 127 # chroma_key 알파 임계치(이상이면 비초록 전경)
|
||||
ECC_MIN_MASK_PX = 400 # 이보다 적으면 ECC가 의미 있게 수렴하지 않는다
|
||||
ECC_GAUSS_FILT_SIZE = 7 # findTransformECC 내부 가우시안 흐림 창(홀수)
|
||||
ECC_MAX_ITER = 5000
|
||||
ECC_EPS = 1e-7
|
||||
|
||||
# --- 지표 ---
|
||||
CANNY_BLUR_SIGMA = 1.0
|
||||
CANNY_LOW, CANNY_HIGH = 40, 120
|
||||
FACE_OVAL_BAND_PX = 12.0 # 얼굴 윤곽 띠 폭(±px)
|
||||
INK_LUM_THRESH = 70.0 # "어두운 잉크" 휘도 임계치
|
||||
|
||||
|
||||
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
|
||||
"""FaceLandmarker를 별도 프로세스로 실행한다(세그폴트 회피, segmentation.py와 같은 이유)."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out_json = Path(td) / "landmarks.json"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_face_landmarks.py"), str(image_path), str(out_json)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
print(proc.stdout.strip())
|
||||
if proc.returncode != 0 or not out_json.exists():
|
||||
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker 서브프로세스 실패.\n{proc.stderr}")
|
||||
data = json.loads(out_json.read_text(encoding="utf-8"))
|
||||
if not data.get("ok"):
|
||||
raise SystemExit(f"[중단] {image_path.name}: FaceLandmarker가 얼굴을 찾지 못했다.")
|
||||
return [(p[0], p[1]) for p in data["points"]]
|
||||
|
||||
|
||||
def bbox_of(points: list[tuple[float, float]], idxs: list[int]) -> tuple[float, float, float, float]:
|
||||
xs = [points[i][0] for i in idxs]
|
||||
ys = [points[i][1] for i in idxs]
|
||||
return (min(xs), min(ys), max(xs), max(ys))
|
||||
|
||||
|
||||
def expand_box(box: tuple[float, float, float, float], pad: float, w: int, h: int) -> tuple[int, int, int, int]:
|
||||
x0, y0, x1, y1 = box
|
||||
return (
|
||||
max(0, int(np.floor(x0 - pad))), max(0, int(np.floor(y0 - pad))),
|
||||
min(w, int(np.ceil(x1 + pad))), min(h, int(np.ceil(y1 + pad))),
|
||||
)
|
||||
|
||||
|
||||
def build_face_boxes(points: list[tuple[float, float]]) -> dict[str, tuple[float, float, float, float]]:
|
||||
"""눈(좌/우)·눈썹(좌/우)·입 상자(원화 좌표, 확장 전)."""
|
||||
mouth_idx = [MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX, UPPER_LIP_TOP_IDX, LOWER_LIP_BOTTOM_IDX]
|
||||
return {
|
||||
"eyeA": bbox_of(points, list(EYE_A_IDX.values())),
|
||||
"eyeB": bbox_of(points, list(EYE_B_IDX.values())),
|
||||
"browA": bbox_of(points, RIGHT_EYEBROW_IDX),
|
||||
"browB": bbox_of(points, LEFT_EYEBROW_IDX),
|
||||
"mouth": bbox_of(points, mouth_idx),
|
||||
}
|
||||
|
||||
|
||||
def flat_background_mask(gray_u8: np.ndarray) -> np.ndarray:
|
||||
"""지역(FLAT_BG_WINDOW) 표준편차가 FLAT_BG_STD_THRESH 미만인 곳 — 평평한 배경(정렬
|
||||
정보가 없는 영역)으로 보고 ECC 포함 마스크에서 뺀다."""
|
||||
g = gray_u8.astype(np.float32)
|
||||
mean = cv2.boxFilter(g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW))
|
||||
sqmean = cv2.boxFilter(g * g, -1, (FLAT_BG_WINDOW, FLAT_BG_WINDOW))
|
||||
std = np.sqrt(np.clip(sqmean - mean * mean, 0.0, None))
|
||||
return std < FLAT_BG_STD_THRESH
|
||||
|
||||
|
||||
def face_oval_band_mask(points: list[tuple[float, float]], w: int, h: int, band_px: float) -> np.ndarray:
|
||||
"""랜드마크 face oval(결정문 §8.2, segmentation.py/export_rig.py와 같은 루프·배율)
|
||||
경계선에서 band_px 이내인 픽셀."""
|
||||
from PIL import ImageDraw
|
||||
|
||||
loop_pts = [points[i] for i in FACE_OVAL_LOOP]
|
||||
cx = float(np.mean([p[0] for p in loop_pts]))
|
||||
cy = float(np.mean([p[1] for p in loop_pts]))
|
||||
scaled = [((x - cx) * FACE_OVAL_SCALE + cx, (y - cy) * FACE_OVAL_SCALE + cy) for x, y in loop_pts]
|
||||
outline_img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(outline_img).polygon(scaled, outline=255, width=1)
|
||||
boundary = np.array(outline_img) > 0
|
||||
dist_to_boundary = distance_transform_edt(~boundary)
|
||||
return dist_to_boundary <= band_px
|
||||
|
||||
|
||||
def chamfer_stats(ref_edges: np.ndarray, aligned_edges: np.ndarray, region_mask: np.ndarray) -> dict:
|
||||
"""aligned_edges 중 region_mask 안에 있는 에지 픽셀들이 ref_edges의 가장 가까운
|
||||
에지까지 떨어진 거리(챔퍼)의 중앙값·p90. ref_edges 쪽 거리장은 호출자가 한 번
|
||||
계산해 재사용하도록 이미 distance_transform_edt를 적용한 배열을 받는다는 점에
|
||||
주의 — 이 함수는 edge 불boolean 배열 두 개를 받아 내부에서 거리장을 계산한다."""
|
||||
dist_to_ref_edge = distance_transform_edt(~ref_edges)
|
||||
sample_mask = aligned_edges & region_mask
|
||||
n = int(sample_mask.sum())
|
||||
if n == 0:
|
||||
return {"median": None, "p90": None, "nSamples": 0}
|
||||
d = dist_to_ref_edge[sample_mask]
|
||||
return {"median": round(float(np.median(d)), 3), "p90": round(float(np.percentile(d, 90)), 3), "nSamples": n}
|
||||
|
||||
|
||||
def ink_ratio(gray_u8: np.ndarray, box: tuple[int, int, int, int]) -> float:
|
||||
x0, y0, x1, y1 = box
|
||||
region = gray_u8[y0:y1, x0:x1].astype(np.float64)
|
||||
if region.size == 0:
|
||||
return float("nan")
|
||||
return float((region < INK_LUM_THRESH).mean())
|
||||
|
||||
|
||||
def decompose_affine(warp_matrix: np.ndarray) -> dict:
|
||||
a, b, tx = warp_matrix[0]
|
||||
c, d, ty = warp_matrix[1]
|
||||
det = a * d - b * c
|
||||
scale = float(np.sqrt(abs(det)))
|
||||
rotation_deg = float(np.degrees(np.arctan2(c, a)))
|
||||
return {
|
||||
"scaleSqrtDet": round(scale, 5),
|
||||
"rotationDeg": round(rotation_deg, 3),
|
||||
"translateX": round(float(tx), 3),
|
||||
"translateY": round(float(ty), 3),
|
||||
"matrix": [[round(float(v), 6) for v in row] for row in warp_matrix.tolist()],
|
||||
"direction": "ref(template) 좌표 -> edited(resized, input) 좌표. 정렬된 출력은 이 행렬의 역변환(WARP_INVERSE_MAP)으로 만든다.",
|
||||
}
|
||||
|
||||
|
||||
def parse_args(argv: list[str]) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="codex 편집본을 ECC 아핀 정렬로 원화(ref) 좌표계에 맞춘다.")
|
||||
parser.add_argument("ref", help="편집의 참조가 된 원본(원화)")
|
||||
parser.add_argument("edited", help="codex 편집본(크기가 ref와 다를 수 있다)")
|
||||
parser.add_argument("out", help="정렬된 출력(ref 크기)")
|
||||
parser.add_argument("--mode", required=True, choices=["faceless", "body"])
|
||||
parser.add_argument("--report", help="지표 JSON 저장 경로(생략 가능, 콘솔에는 항상 출력)")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str]) -> int:
|
||||
args = parse_args(argv)
|
||||
ref_path = Path(args.ref).resolve()
|
||||
edited_path = Path(args.edited).resolve()
|
||||
out_path = Path(args.out).resolve()
|
||||
mode = args.mode
|
||||
|
||||
ref_img = Image.open(ref_path).convert("RGB")
|
||||
w, h = ref_img.size
|
||||
ref_arr = np.array(ref_img)
|
||||
ref_gray = cv2.cvtColor(ref_arr, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
edited_img = Image.open(edited_path).convert("RGB")
|
||||
ew, eh = edited_img.size
|
||||
print(f"ref 크기={w}x{h} edited 원본 크기={ew}x{eh} (리사이즈 후 ref 크기로 정렬)")
|
||||
edited_resized = np.array(edited_img.resize((w, h), Image.LANCZOS))
|
||||
edited_gray = cv2.cvtColor(edited_resized, cv2.COLOR_RGB2GRAY)
|
||||
|
||||
print("=== ref 랜드마크 검출 ===")
|
||||
ref_points = detect_face_landmarks(ref_path)
|
||||
chin_xy = ref_points[CHIN_IDX]
|
||||
print(f"턱끝(152)={chin_xy}")
|
||||
|
||||
boxes_report: dict[str, list[int]] = {}
|
||||
if mode == "faceless":
|
||||
boxes = build_face_boxes(ref_points)
|
||||
exclude = np.zeros((h, w), dtype=bool)
|
||||
for name, box in boxes.items():
|
||||
x0, y0, x1, y1 = expand_box(box, ECC_EXCLUDE_PAD_PX, w, h)
|
||||
boxes_report[name] = [x0, y0, x1, y1]
|
||||
exclude[y0:y1, x0:x1] = True
|
||||
flat_bg = flat_background_mask(ref_gray)
|
||||
include = ~exclude & ~flat_bg
|
||||
print(f"제외 상자(25px 확장): {boxes_report}")
|
||||
print(f"평평한 배경 제외 픽셀: {int(flat_bg.sum())} ({flat_bg.mean()*100:.1f}%)")
|
||||
else:
|
||||
_, edited_alpha = chroma_key(edited_resized)
|
||||
nongreen = edited_alpha > GREEN_ALPHA_THRESH
|
||||
chin_line = chin_xy[1] + ECC_CHIN_MARGIN_PX
|
||||
below_chin = np.zeros((h, w), dtype=bool)
|
||||
below_chin[int(np.ceil(chin_line)):, :] = True
|
||||
include = below_chin & nongreen
|
||||
print(f"턱끝+{ECC_CHIN_MARGIN_PX:.0f}px={chin_line:.1f} 아래 비초록 픽셀: {int(include.sum())}")
|
||||
|
||||
n_include = int(include.sum())
|
||||
print(f"ECC 포함 마스크 픽셀 수: {n_include} / {w * h} ({n_include / (w * h) * 100:.2f}%)")
|
||||
if n_include < ECC_MIN_MASK_PX:
|
||||
raise SystemExit(f"[중단] ECC 포함 마스크 픽셀이 너무 적다({n_include} < {ECC_MIN_MASK_PX}).")
|
||||
include_u8 = (include.astype(np.uint8)) * 255
|
||||
|
||||
warp0 = np.eye(2, 3, dtype=np.float32)
|
||||
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, ECC_MAX_ITER, ECC_EPS)
|
||||
try:
|
||||
cc, warp_matrix = cv2.findTransformECC(
|
||||
ref_gray.astype(np.float32), edited_gray.astype(np.float32),
|
||||
warp0, cv2.MOTION_AFFINE, criteria, include_u8, ECC_GAUSS_FILT_SIZE,
|
||||
)
|
||||
except cv2.error as exc:
|
||||
raise SystemExit(f"[중단] ECC 정렬이 수렴하지 않았다: {exc}")
|
||||
print(f"ECC 상관계수={cc:.5f}")
|
||||
print(f"warp_matrix=\n{warp_matrix}")
|
||||
|
||||
transform_report = decompose_affine(warp_matrix)
|
||||
print(
|
||||
f"변환: 배율(sqrt det)={transform_report['scaleSqrtDet']} 회전={transform_report['rotationDeg']}deg "
|
||||
f"이동=({transform_report['translateX']}, {transform_report['translateY']})"
|
||||
)
|
||||
|
||||
border_mode = cv2.BORDER_REPLICATE if mode == "faceless" else cv2.BORDER_CONSTANT
|
||||
border_value = None if mode == "faceless" else (0, 255, 0)
|
||||
warp_kwargs: dict = {"flags": cv2.INTER_LANCZOS4 | cv2.WARP_INVERSE_MAP, "borderMode": border_mode}
|
||||
if border_value is not None:
|
||||
warp_kwargs["borderValue"] = border_value
|
||||
aligned = cv2.warpAffine(edited_resized, warp_matrix, (w, h), **warp_kwargs)
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.fromarray(aligned, "RGB").save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
aligned_gray = cv2.cvtColor(aligned, cv2.COLOR_RGB2GRAY)
|
||||
ref_edges = cv2.Canny(cv2.GaussianBlur(ref_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0
|
||||
aligned_edges = cv2.Canny(cv2.GaussianBlur(aligned_gray, (0, 0), CANNY_BLUR_SIGMA), CANNY_LOW, CANNY_HIGH) > 0
|
||||
|
||||
mask_chamfer = chamfer_stats(ref_edges, aligned_edges, include)
|
||||
print(
|
||||
f"마스크 안 Canny 챔퍼: 중앙값={mask_chamfer['median']} p90={mask_chamfer['p90']} "
|
||||
f"(n={mask_chamfer['nSamples']})"
|
||||
+ (f" 기준(중앙값<=1.0,p90<=2.5) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.0 and mask_chamfer['p90'] <= 2.5 else '[미달]'}"
|
||||
if mode == "faceless" else
|
||||
f" 기준(중앙값<=1.5,p90<=3.0) {'OK' if mask_chamfer['median'] is not None and mask_chamfer['median'] <= 1.5 and mask_chamfer['p90'] <= 3.0 else '[미달]'}")
|
||||
)
|
||||
|
||||
report: dict = {
|
||||
"mode": mode,
|
||||
"ref": str(ref_path),
|
||||
"edited": str(edited_path),
|
||||
"out": str(out_path),
|
||||
"refSize": [w, h],
|
||||
"editedOriginalSize": [ew, eh],
|
||||
"eccCorrelation": round(float(cc), 5),
|
||||
"transform": transform_report,
|
||||
"eccIncludeMaskPixels": n_include,
|
||||
"chamferInMask": mask_chamfer,
|
||||
}
|
||||
|
||||
if mode == "faceless":
|
||||
oval_band = face_oval_band_mask(ref_points, w, h, FACE_OVAL_BAND_PX)
|
||||
oval_chamfer = chamfer_stats(ref_edges, aligned_edges, oval_band)
|
||||
print(
|
||||
f"얼굴 윤곽 띠(±{FACE_OVAL_BAND_PX:.0f}px) Canny 챔퍼: 중앙값={oval_chamfer['median']} "
|
||||
f"p90={oval_chamfer['p90']} (n={oval_chamfer['nSamples']}) 기준(p90<=2.0) "
|
||||
f"{'OK' if oval_chamfer['p90'] is not None and oval_chamfer['p90'] <= 2.0 else '[미달]'}"
|
||||
)
|
||||
report["faceOvalBandChamfer"] = oval_chamfer
|
||||
|
||||
ink_report: dict[str, dict] = {}
|
||||
for name, box in boxes_report.items():
|
||||
ref_ratio = ink_ratio(ref_gray, tuple(box))
|
||||
edited_ratio = ink_ratio(aligned_gray, tuple(box))
|
||||
ink_report[name] = {"box": box, "refInkRatio": round(ref_ratio, 4), "editedInkRatio": round(edited_ratio, 4)}
|
||||
print(f" 잉크 비율(lum<{INK_LUM_THRESH:.0f}) [{name}] ref={ref_ratio:.4f} edited={edited_ratio:.4f}")
|
||||
report["inkRatioByBox"] = ink_report
|
||||
report["eccExcludeBoxes"] = boxes_report
|
||||
|
||||
if args.report:
|
||||
report_path = Path(args.report).resolve()
|
||||
report_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"저장: {report_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"""공통 리노컷 리그 파이프라인 — 단일 진입점.
|
||||
|
||||
페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에
|
||||
대해 1~8단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
|
||||
대해 1~11단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
|
||||
(mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가
|
||||
나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가
|
||||
다른 단계·전체 파이프라인을 오염시키지 않는다).
|
||||
|
|
@ -15,11 +15,12 @@ export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다):
|
|||
3. segmentation — layers/{body,head,hairFront}.png(v1) + manifest 기준 섹션
|
||||
4. layers_v2 — layers/v2/{body,head,hairFront}.png(결함 보정)
|
||||
5. face_detail — layers/v2/face-detail.png
|
||||
6. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)
|
||||
7. lip_texture — layers/v2/lip-{upper,lower,shadow}.png
|
||||
8. jaw_pieces — layers/v2/jaw-{head,detail}.png
|
||||
9. export_rig — WebP 게시 + rigs/<code>Rig.ts 생성
|
||||
10. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기
|
||||
6. brow_sprite — layers/v2/brow-{left,right}.png + manifest.eyeLineScale(결정문 §8.4.1)
|
||||
7. paper_grain — layers/v2/paper-grain.png(다른 단계와 독립, 순서 유연)
|
||||
8. lip_texture — layers/v2/lip-{upper,lower,shadow}.png
|
||||
9. jaw_pieces — layers/v2/jaw-{head,detail}.png
|
||||
10. export_rig — WebP 게시 + rigs/<code>Rig.ts 생성
|
||||
11. final_previews — 게시된 WebP로 모션·눈/입 확대 미리보기
|
||||
|
||||
주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로
|
||||
새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만
|
||||
|
|
@ -36,6 +37,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
|
@ -52,6 +54,7 @@ STAGES: list[tuple[str, str]] = [
|
|||
("segmentation", "segmentation.py"),
|
||||
("layers_v2", "layers_v2.py"),
|
||||
("face_detail", "face_detail.py"),
|
||||
("brow_sprite", "brow_sprite.py"),
|
||||
("paper_grain", "paper_grain.py"),
|
||||
("lip_texture", "lip_texture.py"),
|
||||
("jaw_pieces", "jaw_pieces.py"),
|
||||
|
|
@ -64,9 +67,13 @@ STAGE_NAMES = [name for name, _ in STAGES]
|
|||
def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, float]:
|
||||
script_path = SCRIPTS_DIR / script
|
||||
t0 = time.monotonic()
|
||||
# 단계 스크립트 출력은 UTF-8로 주고받는다. Windows 콘솔 기본(cp949)은 em dash 같은 문자를
|
||||
# 못 그려, 모티프 없는 리그 분기(export_rig) 같은 데서 단계가 UnicodeEncodeError로 죽었다
|
||||
# (2026-10-01 P7). 부모가 콘솔에 옮겨 쓸 때 못 그리는 문자는 main()에서 대체 문자로 바꾼다.
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(script_path), str(persona_dir)],
|
||||
capture_output=True, text=True,
|
||||
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
env={**os.environ, "PYTHONIOENCODING": "utf-8"},
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
output = proc.stdout + proc.stderr
|
||||
|
|
@ -75,6 +82,8 @@ def run_stage(name: str, script: str, persona_dir: Path) -> tuple[bool, str, flo
|
|||
|
||||
|
||||
def main() -> int:
|
||||
sys.stdout.reconfigure(errors="replace")
|
||||
sys.stderr.reconfigure(errors="replace")
|
||||
parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행")
|
||||
parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)")
|
||||
parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다")
|
||||
|
|
|
|||
|
|
@ -112,8 +112,18 @@ def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|||
|
||||
def build_padded_faceless(base_dir: Path) -> Image.Image:
|
||||
bf = Image.open(base_dir / "base-faceless.png").convert("RGB")
|
||||
front_h = Image.open(base_dir / "base-front.png").size[1]
|
||||
arr = np.array(bf)
|
||||
padded = np.concatenate([arr, arr[-1:, :, :]], axis=0)
|
||||
deficit = front_h - arr.shape[0]
|
||||
if deficit < 0:
|
||||
raise SystemExit(
|
||||
f"[중단] base-faceless.png 높이({arr.shape[0]})가 base-front.png 높이({front_h})보다 크다."
|
||||
)
|
||||
if deficit > 0:
|
||||
pad_rows = np.repeat(arr[-1:, :, :], deficit, axis=0)
|
||||
padded = np.concatenate([arr, pad_rows], axis=0)
|
||||
else:
|
||||
padded = arr
|
||||
out = Image.fromarray(padded, "RGB")
|
||||
out.save(base_dir / "base-faceless-padded.png")
|
||||
return out
|
||||
|
|
@ -233,10 +243,12 @@ def main(persona_dir: Path) -> int:
|
|||
layers_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
orig_faceless_h = Image.open(base_dir / "base-faceless.png").size[1]
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img)
|
||||
h, w, _ = f_arr.shape
|
||||
print(f"F(패딩된 base-faceless) 크기: {w}x{h}")
|
||||
pad_rows = h - orig_faceless_h
|
||||
print(f"F(패딩된 base-faceless) 크기: {w}x{h} (원본 {w}x{orig_faceless_h}, 패딩 {pad_rows}행)")
|
||||
|
||||
front_pts = detect_face_landmarks(base_dir / "base-front.png")
|
||||
chin_y = front_pts[152][1]
|
||||
|
|
@ -442,8 +454,8 @@ def main(persona_dir: Path) -> int:
|
|||
"front": "base/base-front.png",
|
||||
"faceless": "base/base-faceless.png",
|
||||
"facelessPadded": "base/base-faceless-padded.png",
|
||||
"facelessSize": [w, h - 1],
|
||||
"padNote": f"base-faceless.png 마지막 행을 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.",
|
||||
"facelessSize": [w, orig_faceless_h],
|
||||
"padNote": f"base-faceless.png 마지막 행을 {pad_rows}번 복제해 {w}x{h}(base-front.png 크기)으로 패딩한 것이 F다.",
|
||||
}
|
||||
manifest["segmenter"] = {
|
||||
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
1005,
|
||||
1565
|
||||
],
|
||||
"padNote": "base-faceless.png 마지막 행을 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다."
|
||||
"padNote": "base-faceless.png 마지막 행을 1번 복제해 1005x1566(base-front.png 크기)으로 패딩한 것이 F다."
|
||||
},
|
||||
"layers": [
|
||||
{
|
||||
|
|
@ -424,6 +424,10 @@
|
|||
],
|
||||
"faceDetail": {
|
||||
"browLandmarksSource": "persona.json faceDetail.browLandmarksOverride",
|
||||
"region": "features",
|
||||
"faceInsetPx": null,
|
||||
"matchFacelessColor": false,
|
||||
"matchFacelessColorDeltaRgb": {},
|
||||
"regions": {
|
||||
"eyeLeft": [
|
||||
309.1,
|
||||
|
|
@ -1439,6 +1443,9 @@
|
|||
"peakFail": false,
|
||||
"checkA_fracColumnsFitWithinMask": 0.9247,
|
||||
"checkA_pass": true,
|
||||
"halfWidthInner": 2.389,
|
||||
"halfWidthPeak": 8.999,
|
||||
"halfWidthOuter": 3.963,
|
||||
"bandX0": 274,
|
||||
"bandY0": 456,
|
||||
"colMin": 309,
|
||||
|
|
@ -1482,12 +1489,26 @@
|
|||
"peakFail": false,
|
||||
"checkA_fracColumnsFitWithinMask": 0.9202,
|
||||
"checkA_pass": true,
|
||||
"halfWidthInner": 5.84,
|
||||
"halfWidthPeak": 9.499,
|
||||
"halfWidthOuter": 2.984,
|
||||
"bandX0": 506,
|
||||
"bandY0": 454,
|
||||
"colMin": 513,
|
||||
"colMax": 688
|
||||
}
|
||||
},
|
||||
"halfWidthLeft": [
|
||||
2.389,
|
||||
8.999,
|
||||
3.963
|
||||
],
|
||||
"halfWidthRight": [
|
||||
5.84,
|
||||
9.499,
|
||||
2.984
|
||||
],
|
||||
"halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.",
|
||||
"checkB_innerMinusPeakY": {
|
||||
"browLeft": 14.62,
|
||||
"browRight": 13.47
|
||||
|
|
@ -1721,59 +1742,84 @@
|
|||
},
|
||||
"pipelineRun": {
|
||||
"persona": "P1",
|
||||
"timestamp": "2026-10-01T11:04:58",
|
||||
"timestamp": "2026-10-01T21:46:24",
|
||||
"completed": true,
|
||||
"stages": [
|
||||
{
|
||||
"stage": "landmarks",
|
||||
"ok": true,
|
||||
"elapsedSec": 1.24
|
||||
},
|
||||
{
|
||||
"stage": "brow_centerline",
|
||||
"ok": true,
|
||||
"elapsedSec": 0.64
|
||||
},
|
||||
{
|
||||
"stage": "segmentation",
|
||||
"ok": true,
|
||||
"elapsedSec": 7.84
|
||||
},
|
||||
{
|
||||
"stage": "layers_v2",
|
||||
"ok": true,
|
||||
"elapsedSec": 12.39
|
||||
},
|
||||
{
|
||||
"stage": "face_detail",
|
||||
"ok": true,
|
||||
"elapsedSec": 12.25
|
||||
"elapsedSec": 15.41
|
||||
},
|
||||
{
|
||||
"stage": "brow_sprite",
|
||||
"ok": true,
|
||||
"elapsedSec": 1.05
|
||||
},
|
||||
{
|
||||
"stage": "paper_grain",
|
||||
"ok": true,
|
||||
"elapsedSec": 1.91
|
||||
"elapsedSec": 2.45
|
||||
},
|
||||
{
|
||||
"stage": "lip_texture",
|
||||
"ok": true,
|
||||
"elapsedSec": 1.92
|
||||
"elapsedSec": 2.45
|
||||
},
|
||||
{
|
||||
"stage": "jaw_pieces",
|
||||
"ok": true,
|
||||
"elapsedSec": 1.89
|
||||
"elapsedSec": 2.17
|
||||
},
|
||||
{
|
||||
"stage": "export_rig",
|
||||
"ok": true,
|
||||
"elapsedSec": 21.12
|
||||
"elapsedSec": 22.95
|
||||
},
|
||||
{
|
||||
"stage": "final_previews",
|
||||
"ok": true,
|
||||
"elapsedSec": 16.83
|
||||
"elapsedSec": 19.59
|
||||
}
|
||||
]
|
||||
},
|
||||
"browSprite": {
|
||||
"enabled": false
|
||||
},
|
||||
"eyeLineScale": {
|
||||
"enabled": false
|
||||
},
|
||||
"browSpritePublish": null,
|
||||
"iris": {
|
||||
"method": "가로: 중심 y 부근 상하 각 4행에서 중심 x를 포함하는 연속 어두운(lum<100) 구간의 중앙값으로 반지름·중심x. 세로: 중심 x 부근 좌우 각 4열에서 중심 y를 포함하는 연속 어두운 구간의 아래쪽 끝 중앙값에서 가로 반지름을 빼 중심y(세로축은 윗눈꺼풀 잉크와 홍채가 같은 톤으로 이어져 위쪽 경계를 가를 수 없다).",
|
||||
"darkLumThresh": 100.0,
|
||||
"scanBandPx": 4,
|
||||
"left": {
|
||||
"measuredCenter": [
|
||||
395.0,
|
||||
567.0
|
||||
],
|
||||
"measuredRadius": 30.0,
|
||||
"rigCenter": [
|
||||
387.33,
|
||||
579.15
|
||||
],
|
||||
"rigRadius": 21.43,
|
||||
"nRowsHorizontal": 9,
|
||||
"nColsVertical": 9
|
||||
},
|
||||
"right": {
|
||||
"measuredCenter": [
|
||||
600.0,
|
||||
573.0
|
||||
],
|
||||
"measuredRadius": 22.0,
|
||||
"rigCenter": [
|
||||
601.93,
|
||||
576.21
|
||||
],
|
||||
"rigRadius": 21.28,
|
||||
"nRowsHorizontal": 9,
|
||||
"nColsVertical": 9
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,6 +35,10 @@
|
|||
"browRight": { "inner": [531.24, 535.37], "peak": [628.82, 496.05], "outer": [687.99, 522.25] }
|
||||
}
|
||||
},
|
||||
"browHalfWidthFromArt": false,
|
||||
"browSpriteFromArt": false,
|
||||
"eyeLineScaleFromArt": false,
|
||||
"irisFromArt": false,
|
||||
"pivots": {
|
||||
"neck": [500, 990],
|
||||
"body": [502, 1566],
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@
|
|||
},
|
||||
"haloThreshold": 2.0
|
||||
},
|
||||
"browSpritePublish": null,
|
||||
"rig": {
|
||||
"schemaVersion": "vignette.avatar.v3.rig.v1",
|
||||
"persona": "P1",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue