리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
This commit is contained in:
parent
f11e76ff18
commit
ecb36d123f
38 changed files with 2393 additions and 990 deletions
149
docs/avatar-art/linocut-pipeline/README.md
Normal file
149
docs/avatar-art/linocut-pipeline/README.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 공통 리노컷 아바타 파이프라인
|
||||
|
||||
아바타 v3 리노컷 리그(결정문 [`avatar-expression-engine-v3.md`](../../decisions/avatar-expression-engine-v3.md)
|
||||
§8.2 리그 원칙)의 자산 파이프라인이다. P1~P7 등 모든 페르소나가 이 공통 스크립트를 공유하고,
|
||||
페르소나별 차이는 각 페르소나 폴더(예: `../p1-linocut/`)의 `persona.json`에서만 읽는다.
|
||||
|
||||
결과물(레이어 픽셀, 알파, 게시된 WebP, 리그 TS)은 페르소나별 폴더 구조나 상수 위치가
|
||||
바뀌었다는 이유로 달라지지 않는다 — 알고리즘은 건드리지 않았다(P1 회귀 검증: 아래 참고).
|
||||
|
||||
## 단계와 의존 순서
|
||||
|
||||
`run_pipeline.py`가 아래 순서로 각 단계를 독립 프로세스로 실행한다(괄호는 산출물):
|
||||
|
||||
1. `landmarks.py` — base-front.png 랜드마크 검출 (`manifest.landmarks`)
|
||||
2. `brow_centerline.py` — `manifest.landmarks.eyebrowLeft/Right`를 잉크 띠 중심선으로 보정
|
||||
3. `segmentation.py` — `layers/{body,head,hairFront}.png`(v1) + manifest 기준 섹션
|
||||
4. `layers_v2.py` — `layers/v2/{body,head,hairFront}.png`(턱 밑 띠·잔머리 halo 보정)
|
||||
5. `face_detail.py` — `layers/v2/face-detail.png`
|
||||
6. `paper_grain.py` — `layers/v2/paper-grain.png`(다른 단계와 독립, 순서 유연)
|
||||
7. `lip_texture.py` — `layers/v2/lip-{upper,lower,shadow}.png`
|
||||
8. `jaw_pieces.py` — `layers/v2/jaw-{head,detail}.png`
|
||||
9. `export_rig.py` — WebP 게시(`apps/web/public/avatar/v3/<publicSlug>/`) + `rigs/<rigFileName>` 생성
|
||||
10. `final_previews.py` — 게시된 WebP로 모션·눈/입 확대 미리보기
|
||||
|
||||
이 순서는 전달받은 작업 설명의 번호(주제별 묶음)와 다르다 — 특히 `final_previews`는
|
||||
`export_rig`가 쓴 `export-rig-report.json`을 읽으므로 반드시 `export_rig` *다음*이어야
|
||||
한다. `face_detail`·`jaw_pieces`는 `manifest.landmarks`(1~2단계가 채움)와
|
||||
`layers/v2/{head,hairFront,body}.png`(3~4단계)가 먼저 있어야 한다.
|
||||
|
||||
## 실행
|
||||
|
||||
```bash
|
||||
# 전체 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir>
|
||||
|
||||
# 한 단계만 다시 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail
|
||||
|
||||
# 중간부터 끝까지
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture
|
||||
|
||||
# 단계 이름 목록
|
||||
<venv>/python.exe run_pipeline.py --list
|
||||
```
|
||||
|
||||
각 단계 스크립트는 `python <script>.py <persona-dir>`로 단독 실행도 된다(디버깅용).
|
||||
|
||||
**주의**: `landmarks.py`만 혼자 다시 돌리면 `manifest.landmarks`를 통째로 새로 써서
|
||||
`brow_centerline.py`가 보정한 눈썹 중심선이 사라진다. `--only landmarks`를 쓴 뒤에는
|
||||
`--only brow_centerline`도 반드시 같이 돌려야 한다(자동으로 뒤따라 돌지 않는다).
|
||||
|
||||
## persona.json 스키마
|
||||
|
||||
페르소나 폴더(예: `../p1-linocut/persona.json`)에 둔다. 랜드마크·분할로 계산 가능한
|
||||
값은 각 단계 스크립트가 직접 계산하므로 여기 없다 — 원화마다 달라지고 유도할 수 없는
|
||||
값(참조 이미지를 보고 사람이 고른 점·상자)만 이 파일에 둔다.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"code": "P1", // 필수. manifest.persona, rig.persona
|
||||
"publicSlug": "p1", // 생략 시 code.lower(). apps/web/public/avatar/v3/<publicSlug>/
|
||||
"rigFileName": "p1Rig.ts", // 생략 시 "<publicSlug>Rig.ts"
|
||||
"rigExportName": "P1_LINOCUT_RIG", // 생략 시 "<CODE>_LINOCUT_RIG"
|
||||
|
||||
// 모티프 팔레트(motifPetal/motifLeaf) 표본을 뽑을 스타일 참조 이미지(persona.json 기준 상대경로).
|
||||
// export_rig.py의 팔레트 계산에서만 쓴다 — 모티프 스프라이트 자체는 이번 파이프라인
|
||||
// 범위 밖이다(오케스트레이터가 따로 설계).
|
||||
"styleFrame": "../art-direction-v3/p1/r2-b-linocut.png",
|
||||
|
||||
"paletteSamples": {
|
||||
// 머리카락 어두운 덩어리 표본(ink 팔레트색). base-front.png 픽셀 기준 상자.
|
||||
"ink": { "box": [280, 100, 720, 350], "lumThreshold": 55 },
|
||||
// 눈 흰자/홍채/홍채테 고정 설계값(결정문 §8.2 "고정값" 원칙) — 생략하면 공통 기본값 사용.
|
||||
"eyeOverride": { "sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F" },
|
||||
// styleFrame에서 꽃잎(ochre)·잎/구름(blue) 색을 뽑을 상자들. kind는 "ochre" 또는 "blue".
|
||||
"motifPetalBoxes": [{ "label": "sun", "kind": "ochre", "box": [1230, 10, 1536, 210] }],
|
||||
"motifLeafBoxes": [{ "label": "cloudLeft", "kind": "blue", "box": [20, 20, 380, 190] }]
|
||||
},
|
||||
|
||||
"faceDetail": {
|
||||
// 점(기미) 등 랜드마크로 안 나오는 얼굴 반점. 없으면 빈 배열(점 없는 캐릭터도 된다).
|
||||
"moles": [{ "center": [661.3, 627.9], "radius": 20.0 }]
|
||||
},
|
||||
|
||||
// 렌더러 회전/스케일 중심점(결정문 §8.4). 원화를 보고 목·몸통·얼굴 중심을 정한다.
|
||||
"pivots": { "neck": [500, 990], "body": [502, 1566], "face": [490, 660] },
|
||||
|
||||
// bust 크롭은 정사각형(변 = 캔버스 폭)이고 위쪽 오프셋만 여기서 정한다.
|
||||
"crops": { "bustYOffset": 40 }
|
||||
}
|
||||
```
|
||||
|
||||
`eyeOverride`·`paletteFixed`(mouthInner/teeth/blush/tear/pallor/paper)·`backdrop`(겉표정
|
||||
그룹별 배경색)은 모든 페르소나가 공유하는 기본값이 있다(`persona_config.py`의
|
||||
`DEFAULT_*`) — 캐릭터마다 다르게 할 필요가 있을 때만 persona.json에 적어 덮어쓴다.
|
||||
|
||||
`styleFrame`·`paletteSamples.ink`·`paletteSamples.motifPetalBoxes`/`motifLeafBoxes`·
|
||||
`pivots`는 **필수**다(`export_rig.py`가 해당 값을 쓰는 시점에 없으면 어떤 필드를
|
||||
채워야 하는지 알려주며 멈춘다).
|
||||
|
||||
### `faceDetail.browLandmarksOverride` — P1 전용 호환 장치, 새 페르소나는 쓰지 않는다
|
||||
|
||||
```jsonc
|
||||
"faceDetail": {
|
||||
"browLandmarksOverride": {
|
||||
"browLeft": { "inner": [..], "peak": [..], "outer": [..] },
|
||||
"browRight": { "inner": [..], "peak": [..], "outer": [..] }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
있으면 `face_detail.py`가 눈썹 제외 영역·눈 영역 y0 계산에 `manifest.landmarks`의
|
||||
현재(중심선 보정) 눈썹 좌표 대신 이 값을 쓴다. 다른 랜드마크(눈·입·코·턱)는 그대로
|
||||
현재 값을 쓴다 — 눈썹만 바꾼다.
|
||||
|
||||
P1의 기존 `face-detail.png`·`jaw-detail.png`·해당 webp·`p1Rig.ts`는 눈썹 중심선 보정
|
||||
(`manifest.browCenterline`) **이전** 좌표(`browCenterline.oldPoints`)로 빌드된 뒤
|
||||
"다시 빌드하지 않는다"는 오케스트레이터 지시로 고정됐다(소유자도 그 결과를 검수했다).
|
||||
그래서 P1 persona.json에는 `browCenterline.oldPoints`와 같은 값을 넣어 재실행 결과가
|
||||
그 고정본과 바이트 단위로 같아지게 한다. **새 페르소나는 이 필드를 넣지 않는다** —
|
||||
처음부터 중심선 보정 좌표로 빌드되므로 과거 좌표를 따로 고정할 이유가 없다.
|
||||
|
||||
## 모델 파일(저장소에 없음)
|
||||
|
||||
`scripts/_models/`에 MediaPipe 모델을 받아 둔다(모든 페르소나가 공유, `.gitignore`의
|
||||
`docs/avatar-art/*/scripts/_models/` 패턴에 그대로 맞는다).
|
||||
|
||||
- `face_landmarker.task`: https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task
|
||||
- `selfie_multiclass_256x256.tflite`: https://storage.googleapis.com/mediapipe-models/image_segmenter/selfie_multiclass_256x256/float32/latest/selfie_multiclass_256x256.tflite
|
||||
|
||||
python 환경은 numpy·Pillow·scipy·opencv·mediapipe가 필요하다.
|
||||
|
||||
## 새 페르소나를 추가할 때 사람이 해야 하는 일
|
||||
|
||||
이 파이프라인은 자동으로 원화를 만들지 않는다. 사람(또는 다른 워커)이 먼저 준비해야
|
||||
하는 것:
|
||||
|
||||
1. 원화 2장: `<persona-dir>/base/base-front.png`(정면 기본형)과
|
||||
`base-faceless.png`(같은 그림에서 눈·눈썹·입만 지운 것). 결정문 §8.2 생성 규칙을 따른다.
|
||||
2. `<persona-dir>/raw/body.png`: body 레이어가 head_mask로 가려지는 목 상단 영역을
|
||||
메울 재생성 참조 편집본(크로마키 초록 배경, base-front와 같은 정렬).
|
||||
3. `<persona-dir>/persona.json`: 위 스키마대로 작성한다. 특히 `styleFrame`·
|
||||
`paletteSamples`·`pivots`는 원화를 눈으로 보고 정해야 한다(자동 유도 불가).
|
||||
4. (선택) `<persona-dir>/motif/`: 모티프 스프라이트는 이 라운드 범위 밖이다 — 오케스트레이터가
|
||||
따로 설계한다. 없으면 `export_rig.py`가 모티프 없는 리그를 만든다(rig.motif 생략).
|
||||
5. `scripts/_models/`에 모델 파일이 없으면 받아 둔다(위 링크, 한 번만).
|
||||
|
||||
그 다음 `run_pipeline.py <persona-dir>`를 실행하고, 출력된 검사 수치(halo%, 평균절대차
|
||||
등 — 각 단계 스크립트가 콘솔에 찍고 manifest.json에도 남긴다)를 기준치와 비교해 판정한다.
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
"""단독 프로세스로 FaceLandmarker를 실행해 478 랜드마크를 JSON으로 출력한다.
|
||||
(같은 프로세스에서 ImageSegmenter와 함께 쓰면 세그폴트가 재현되어 분리했다.)
|
||||
|
||||
사용: python _run_face_landmarks.py <이미지경로> <출력json경로>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image
|
||||
|
||||
MODEL_FACE = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_FACE))
|
||||
options = vision.FaceLandmarkerOptions(
|
||||
base_options=base_options, running_mode=vision.RunningMode.IMAGE, num_faces=1
|
||||
)
|
||||
im = Image.open(image_path).convert("RGB")
|
||||
w, h = im.size
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
with vision.FaceLandmarker.create_from_options(options) as landmarker:
|
||||
result = landmarker.detect(mp_image)
|
||||
|
||||
if not result.face_landmarks:
|
||||
out_path.write_text(json.dumps({"ok": False}), encoding="utf-8")
|
||||
print("FACE_LANDMARKS_FAILED")
|
||||
return 1
|
||||
|
||||
lm = result.face_landmarks[0]
|
||||
pts = [[p.x * w, p.y * h] for p in lm]
|
||||
out_path.write_text(json.dumps({"ok": True, "width": w, "height": h, "points": pts}), encoding="utf-8")
|
||||
print(f"FACE_LANDMARKS_OK n={len(pts)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
"""단독 프로세스로 ImageSegmenter(selfie_multiclass_256x256)를 실행해
|
||||
category_mask를 .npy로 저장한다. (세그폴트 회피를 위해 FaceLandmarker와 분리.)
|
||||
|
||||
사용: python _run_segmentation.py <이미지경로> <출력npy경로>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image
|
||||
|
||||
MODEL_SEG = Path(__file__).resolve().parent / "_models" / "selfie_multiclass_256x256.tflite"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
image_path = Path(sys.argv[1])
|
||||
out_path = Path(sys.argv[2])
|
||||
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_SEG))
|
||||
options = vision.ImageSegmenterOptions(
|
||||
base_options=base_options, output_confidence_masks=False, output_category_mask=True
|
||||
)
|
||||
im = Image.open(image_path).convert("RGB")
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
with vision.ImageSegmenter.create_from_options(options) as seg:
|
||||
result = seg.segment(mp_image)
|
||||
|
||||
if result.category_mask is None:
|
||||
print("SEGMENTATION_FAILED")
|
||||
return 1
|
||||
|
||||
category_mask = result.category_mask.numpy_view()[:, :, 0].copy()
|
||||
np.save(out_path, category_mask)
|
||||
print(f"SEGMENTATION_OK shape={category_mask.shape}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
374
docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py
Normal file
374
docs/avatar-art/linocut-pipeline/scripts/brow_centerline.py
Normal file
|
|
@ -0,0 +1,374 @@
|
|||
"""공통 리노컷 리그 — 눈썹 랜드마크를 잉크 띠 중심선으로 정정.
|
||||
|
||||
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
|
||||
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
|
||||
peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
|
||||
|
||||
이번 버전:
|
||||
- 마스크 = (front 휘도가 눈썹 bbox 안 Otsu 임계값 이하, 짙은 잉크) AND
|
||||
(front가 F보다 25 이상 어두움, 머리카락·눈꺼풀 크레이스 등 F에도 있는
|
||||
구조물 제외). 두 조건의 교집합이라 중간 톤 그늘 해칭은 Otsu 임계값보다
|
||||
밝아 빠진다.
|
||||
- 눈썹은 조각도 해칭 때문에 잉크가 여러 조각으로 끊겨 있다(단일 최대
|
||||
연결성분만 쓰면 절반 가까이 누락됨을 확인). 그래서 형태학적 closing으로
|
||||
같은 눈썹에 속한 조각을 하나로 묶은 뒤, 그 묶음 영역 안의 "원본"(닫기
|
||||
전) 마스크 픽셀만 최종 띠로 쓴다(닫기는 성분을 찾는 데만 쓰고 픽셀을
|
||||
부풀리지 않는다). 그 안에서 면적 30px 미만 잔점은 버린다.
|
||||
- 중심선은 전역 2차 다항식 하나를 열별 가중 무게중심에 적합한다(지역
|
||||
평활 대신 — 평평한 눈썹에서 물결을 만들지 않는다). 잔차 2배 표준편차를
|
||||
넘는 이상치 열을 한 번 제거하고 재적합한다.
|
||||
- peak는 적합 곡선의 꼭짓점(2차 다항식은 극값이 하나뿐이다)이 바깥 끝
|
||||
기준 25~60% 구간에 있으면 그 점, 아니면(구간 밖 = 그 구간에서 단조,
|
||||
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
|
||||
위 점을 쓴다.
|
||||
|
||||
전제: landmarks.py가 이미 manifest.landmarks를 채워 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe brow_centerline.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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
|
||||
|
||||
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
|
||||
BBOX_X_PAD = 25.0
|
||||
BBOX_Y_UP = 42.0
|
||||
BBOX_Y_DOWN = 14.0 # 30이면 눈꺼풀 크레이스(다크서클 경계) 잉크가 섞인다(탐색 확인).
|
||||
|
||||
DIFF_THRESH = 25.0 # front가 F보다 이만큼 어두우면 "front에만 있는 것"으로 본다
|
||||
CLOSING_KERNEL = (7, 11) # (세로, 가로) — 눈썹 해칭 조각을 같은 띠로 묶기 위한 형태학적 닫기
|
||||
MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보다 작은 잔점은 버린다
|
||||
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
|
||||
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
|
||||
|
||||
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
|
||||
PEAK_FALLBACK_FRAC = 0.35
|
||||
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
|
||||
PEAK_FAIL_EDGE_FRAC = 0.20 # 최종 peak가 양끝 이 비율 안이면 실패
|
||||
|
||||
CHECK_A_MIN_FRAC = 0.80
|
||||
|
||||
EVIDENCE_MAX_SIDE = 1000
|
||||
EVIDENCE_ZOOM = 3
|
||||
|
||||
|
||||
def region_bbox_from_points(inner, peak, outer) -> tuple[int, int, int, int]:
|
||||
xs = [inner[0], peak[0], outer[0]]
|
||||
ys = [inner[1], peak[1], outer[1]]
|
||||
x0 = int(round(min(xs) - BBOX_X_PAD))
|
||||
x1 = int(round(max(xs) + BBOX_X_PAD))
|
||||
y0 = int(round(min(ys) - BBOX_Y_UP))
|
||||
y1 = int(round(max(ys) + BBOX_Y_DOWN))
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def build_ink_band(front_lum: np.ndarray, diff: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> tuple[np.ndarray, float]:
|
||||
"""bbox 안에서 (Otsu 잉크) AND (front가 F보다 어두움) 마스크를 만들고,
|
||||
형태학적 닫기로 같은 눈썹 조각을 묶은 최대 성분 안의 원본 픽셀만
|
||||
남긴 뒤 작은 잔점을 버린다. 반환: (band bool 배열(bbox 로컬 좌표), otsu 임계값)."""
|
||||
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_region = diff[y0:y1, x0:x1]
|
||||
diff_mask = diff_region >= DIFF_THRESH
|
||||
combined = ink_mask & diff_mask
|
||||
|
||||
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
|
||||
labeled, n = ndimage.label(closed)
|
||||
if n == 0:
|
||||
raise SystemExit("[중단] 눈썹 bbox 안에 잉크 마스크가 전혀 없다.")
|
||||
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
|
||||
top_label = int(np.argmax(sizes)) + 1
|
||||
band = combined & (labeled == top_label)
|
||||
|
||||
lbl2, n2 = ndimage.label(band)
|
||||
if n2 > 0:
|
||||
sizes2 = ndimage.sum(band, lbl2, range(1, n2 + 1))
|
||||
keep_labels = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
|
||||
band = band & np.isin(lbl2, keep_labels)
|
||||
|
||||
return band, float(otsu_thresh)
|
||||
|
||||
|
||||
def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int, y0: int) -> dict:
|
||||
colmask = band.any(axis=0)
|
||||
cols = np.where(colmask)[0]
|
||||
col_min, col_max = int(cols.min()), int(cols.max())
|
||||
xs_local = np.arange(col_min, col_max + 1)
|
||||
centroid_y = np.full(len(xs_local), np.nan)
|
||||
col_ylo = np.full(len(xs_local), np.nan)
|
||||
col_yhi = np.full(len(xs_local), np.nan)
|
||||
for i, cx in enumerate(xs_local):
|
||||
colpix = band[:, cx]
|
||||
if not colpix.any():
|
||||
continue
|
||||
yy = np.where(colpix)[0]
|
||||
w = diff_region[yy, cx]
|
||||
centroid_y[i] = np.average(yy, weights=w) + y0
|
||||
col_ylo[i] = yy.min() + y0
|
||||
col_yhi[i] = yy.max() + y0
|
||||
return {
|
||||
"col_min": col_min + x0, "col_max": col_max + x0,
|
||||
"xs_local": xs_local, "centroid_y": centroid_y,
|
||||
"col_ylo": col_ylo, "col_yhi": col_yhi, "x0": x0,
|
||||
}
|
||||
|
||||
|
||||
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)
|
||||
resid = ys - fit(xs_abs)
|
||||
std = resid.std()
|
||||
outlier = np.abs(resid) > OUTLIER_STD_MULT * std if std > 0 else np.zeros_like(resid, dtype=bool)
|
||||
n_outliers = int(outlier.sum())
|
||||
if n_outliers > 0 and (~outlier).sum() >= 3:
|
||||
coef2 = np.polyfit(xs_abs[~outlier], ys[~outlier], 2)
|
||||
fit = np.poly1d(coef2)
|
||||
return fit, n_outliers
|
||||
|
||||
|
||||
def find_peak(fit: np.poly1d, col_min: int, col_max: int, outer_edge_x: float, width: float) -> tuple[tuple[float, float], str]:
|
||||
a, b, _c = fit.coeffs
|
||||
xs_sample = np.linspace(col_min, col_max, 400)
|
||||
t_sample = np.abs(xs_sample - outer_edge_x) / width
|
||||
in_window = (t_sample >= PEAK_WINDOW[0]) & (t_sample <= PEAK_WINDOW[1])
|
||||
|
||||
use_fallback = True
|
||||
vertex_pt: tuple[float, float] | None = None
|
||||
if abs(a) > 1e-6:
|
||||
xv = -b / (2 * a)
|
||||
if col_min <= xv <= col_max:
|
||||
tv = abs(xv - outer_edge_x) / width
|
||||
if PEAK_WINDOW[0] <= tv <= PEAK_WINDOW[1]:
|
||||
window_ys = fit(xs_sample[in_window])
|
||||
height_diff = float(window_ys.max() - window_ys.min()) if window_ys.size else 0.0
|
||||
if height_diff >= PEAK_FLAT_HEIGHT_PX:
|
||||
vertex_pt = (float(xv), float(fit(xv)))
|
||||
use_fallback = False
|
||||
|
||||
if use_fallback:
|
||||
# 바깥 끝에서 안쪽으로 35% 지점
|
||||
if outer_edge_x <= col_min + 1e-6:
|
||||
fx = col_min + PEAK_FALLBACK_FRAC * width
|
||||
else:
|
||||
fx = col_max - PEAK_FALLBACK_FRAC * width
|
||||
peak_pt = (float(fx), float(fit(fx)))
|
||||
method = "fallback35"
|
||||
else:
|
||||
peak_pt = vertex_pt # type: ignore[assignment]
|
||||
method = "vertex"
|
||||
|
||||
return peak_pt, method
|
||||
|
||||
|
||||
def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: dict) -> dict:
|
||||
inner_old, peak_old, outer_old = old_pts["inner"], old_pts["peak"], old_pts["outer"]
|
||||
x0, y0, x1, y1 = region_bbox_from_points(inner_old, peak_old, outer_old)
|
||||
band, otsu_thresh = build_ink_band(front_lum, diff, x0, y0, x1, y1)
|
||||
diff_region = diff[y0:y1, x0:x1]
|
||||
cw = column_weighted_centroid(band, diff_region, x0, y0)
|
||||
|
||||
valid = ~np.isnan(cw["centroid_y"])
|
||||
xs_abs = cw["xs_local"][valid] + x0
|
||||
ys_valid = cw["centroid_y"][valid]
|
||||
fit, n_outliers = fit_centerline(xs_abs, ys_valid)
|
||||
|
||||
col_min, col_max = cw["col_min"], cw["col_max"]
|
||||
width = float(col_max - col_min)
|
||||
inset_px = width * EDGE_INSET_FRAC
|
||||
|
||||
left_is_outer = outer_old[0] < inner_old[0]
|
||||
if left_is_outer:
|
||||
outer_edge_x = float(col_min)
|
||||
inner_edge_x = float(col_max)
|
||||
outer_x = col_min + inset_px
|
||||
inner_x = col_max - inset_px
|
||||
else:
|
||||
outer_edge_x = float(col_max)
|
||||
inner_edge_x = float(col_min)
|
||||
outer_x = col_max - inset_px
|
||||
inner_x = col_min + inset_px
|
||||
|
||||
inner_pt = (round(float(inner_x), 2), round(float(fit(inner_x)), 2))
|
||||
outer_pt = (round(float(outer_x), 2), round(float(fit(outer_x)), 2))
|
||||
peak_pt_raw, peak_method = find_peak(fit, col_min, col_max, outer_edge_x, width)
|
||||
peak_pt = (round(peak_pt_raw[0], 2), round(peak_pt_raw[1], 2))
|
||||
peak_frac = abs(peak_pt_raw[0] - outer_edge_x) / width
|
||||
peak_fail = peak_frac < PEAK_FAIL_EDGE_FRAC or peak_frac > (1.0 - PEAK_FAIL_EDGE_FRAC)
|
||||
|
||||
# 검사(a): 열의 80% 이상에서 적합 곡선 y가 그 열 마스크의 위-아래 끝 사이
|
||||
n_ok = 0
|
||||
n_total = int(valid.sum())
|
||||
for i in range(len(cw["xs_local"])):
|
||||
if not valid[i]:
|
||||
continue
|
||||
cx_abs = cw["xs_local"][i] + x0
|
||||
fy = float(fit(cx_abs))
|
||||
if cw["col_ylo"][i] <= fy <= cw["col_yhi"][i]:
|
||||
n_ok += 1
|
||||
frac_ok = n_ok / n_total if n_total else 0.0
|
||||
|
||||
return {
|
||||
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
|
||||
"colRange": [col_min, col_max], "width": width,
|
||||
"nColumns": n_total, "nOutliersRemoved": n_outliers,
|
||||
"fitCoeffs": [round(float(c), 8) for c in fit.coeffs],
|
||||
"inner": inner_pt, "peak": peak_pt, "outer": outer_pt,
|
||||
"peakMethod": peak_method, "peakFracFromOuterEdge": round(float(peak_frac), 4),
|
||||
"peakFail": bool(peak_fail),
|
||||
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
|
||||
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
|
||||
"band": band, "bandX0": x0, "bandY0": y0,
|
||||
"fit": fit, "colMin": col_min, "colMax": col_max,
|
||||
}
|
||||
|
||||
|
||||
def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) -> Image.Image:
|
||||
x0, y0, x1, y1 = result["bbox"]
|
||||
pad = 15
|
||||
box = (max(0, x0 - pad), max(0, y0 - pad), x1 + pad, y1 + pad)
|
||||
crop = front_img.crop(box).convert("RGB")
|
||||
crop = crop.resize((crop.width * EVIDENCE_ZOOM, crop.height * EVIDENCE_ZOOM), Image.LANCZOS)
|
||||
d = ImageDraw.Draw(crop)
|
||||
ox, oy = box[0], box[1]
|
||||
|
||||
def to_panel(px, py):
|
||||
return ((px - ox) * EVIDENCE_ZOOM, (py - oy) * EVIDENCE_ZOOM)
|
||||
|
||||
# 마스크 윤곽선(노랑)
|
||||
band_u8 = (result["band"].astype(np.uint8)) * 255
|
||||
contours, _ = cv2.findContours(band_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
bx0, by0 = result["bandX0"], result["bandY0"]
|
||||
for cnt in contours:
|
||||
pts = [to_panel(p[0][0] + bx0, p[0][1] + by0) for p in cnt]
|
||||
if len(pts) >= 2:
|
||||
d.line(pts + [pts[0]], fill=(230, 200, 0), width=2)
|
||||
|
||||
# 적합 중심선(청록)
|
||||
fit = result["fit"]
|
||||
xs_line = np.linspace(result["colMin"], result["colMax"], 200)
|
||||
ys_line = fit(xs_line)
|
||||
line_pts = [to_panel(x, y) for x, y in zip(xs_line, ys_line)]
|
||||
d.line(line_pts, fill=(0, 200, 200), width=2)
|
||||
|
||||
for key in ("inner", "peak", "outer"):
|
||||
ox_pt, oy_pt = old_pts[key]
|
||||
px, py = to_panel(ox_pt, oy_pt)
|
||||
d.ellipse([px - 5, py - 5, px + 5, py + 5], outline=(230, 30, 30), width=2)
|
||||
nx_pt, ny_pt = result[key]
|
||||
px2, py2 = to_panel(nx_pt, ny_pt)
|
||||
d.ellipse([px2 - 5, py2 - 5, px2 + 5, py2 + 5], fill=(0, 220, 220))
|
||||
|
||||
return crop
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_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"]
|
||||
|
||||
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)
|
||||
front_lum = front.mean(axis=2)
|
||||
faceless_lum = faceless.mean(axis=2)
|
||||
diff = np.clip(faceless_lum - front_lum, 0.0, None)
|
||||
|
||||
# 재실행 멱등성: 이 스크립트가 landmarks.eyebrowLeft/Right를 덮어쓰므로,
|
||||
# 진짜 mediapipe 원본은 browCenterline.oldPoints에 한 번 고정해 두고
|
||||
# 재실행 시 거기서 읽는다(자기가 쓴 결과를 다시 원본으로 오인하지 않는다).
|
||||
prev_old_points = manifest.get("browCenterline", {}).get("oldPoints")
|
||||
if prev_old_points is not None:
|
||||
browL_old = prev_old_points["browLeft"]
|
||||
browR_old = prev_old_points["browRight"]
|
||||
else:
|
||||
browL_old = lm["eyebrowLeft"]
|
||||
browR_old = lm["eyebrowRight"]
|
||||
|
||||
resultL = process_brow("browLeft", front_lum, diff, browL_old)
|
||||
resultR = process_brow("browRight", front_lum, diff, browR_old)
|
||||
|
||||
for result, old_pts in ((resultL, browL_old), (resultR, browR_old)):
|
||||
print(f"=== {result['name']} ===")
|
||||
print(f" bbox={result['bbox']} otsuThresh={result['otsuThresh']} colRange={result['colRange']}")
|
||||
print(f" fit={result['fitCoeffs']} outliers제거={result['nOutliersRemoved']}/{result['nColumns']}")
|
||||
print(f" old inner={old_pts['inner']} peak={old_pts['peak']} outer={old_pts['outer']}")
|
||||
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'}")
|
||||
|
||||
dropL = resultL["inner"][1] - resultL["peak"][1]
|
||||
dropR = resultR["inner"][1] - resultR["peak"][1]
|
||||
print(f"검사(b) browLeft inner.y-peak.y = {dropL:.2f} (예상 0~20)")
|
||||
print(f"검사(b) browRight inner.y-peak.y = {dropR:.2f} (예상 0~20)")
|
||||
|
||||
inner_y_symmetry = abs(resultL["inner"][1] - resultR["inner"][1])
|
||||
peak_y_symmetry = abs(resultL["peak"][1] - resultR["peak"][1])
|
||||
print(f"검사(d) 좌우 inner.y 차 = {inner_y_symmetry:.2f}")
|
||||
print(f"검사(d) 좌우 peak.y 차 = {peak_y_symmetry:.2f}")
|
||||
|
||||
print(f"검사(c) browLeft peak 위치비율 = {resultL['peakFracFromOuterEdge']} ({resultL['peakMethod']})")
|
||||
print(f"검사(c) browRight peak 위치비율 = {resultR['peakFracFromOuterEdge']} ({resultR['peakMethod']})")
|
||||
|
||||
front_img = Image.open(base_dir / "base-front.png").convert("RGB")
|
||||
panelL = draw_evidence_panel(front_img, browL_old, resultL)
|
||||
panelR = draw_evidence_panel(front_img, browR_old, resultR)
|
||||
gap = 20
|
||||
combined = Image.new("RGB", (panelL.width + panelR.width + gap, max(panelL.height, panelR.height)), (255, 255, 255))
|
||||
combined.paste(panelL, (0, 0))
|
||||
combined.paste(panelR, (panelL.width + gap, 0))
|
||||
scale = min(1.0, EVIDENCE_MAX_SIDE / 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)
|
||||
evidence_path = preview_v2_dir / "brow-centerline.jpg"
|
||||
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
|
||||
print(f"저장: {evidence_path}")
|
||||
|
||||
lm["eyebrowLeft"] = {"inner": list(resultL["inner"]), "peak": list(resultL["peak"]), "outer": list(resultL["outer"])}
|
||||
lm["eyebrowRight"] = {"inner": list(resultR["inner"]), "peak": list(resultR["peak"]), "outer": list(resultR["outer"])}
|
||||
|
||||
def strip_for_json(r: dict) -> dict:
|
||||
return {k: v for k, v in r.items() if k not in ("band", "fit")}
|
||||
|
||||
manifest["browCenterline"] = {
|
||||
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, "
|
||||
"single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
|
||||
"diffThresh": DIFF_THRESH,
|
||||
"bboxPad": {"x": BBOX_X_PAD, "yUp": BBOX_Y_UP, "yDown": BBOX_Y_DOWN},
|
||||
"closingKernel": list(CLOSING_KERNEL),
|
||||
"minComponentAreaPx": MIN_COMPONENT_AREA,
|
||||
"outlierStdMult": OUTLIER_STD_MULT,
|
||||
"edgeInsetFrac": EDGE_INSET_FRAC,
|
||||
"peakWindow": list(PEAK_WINDOW),
|
||||
"peakFallbackFrac": PEAK_FALLBACK_FRAC,
|
||||
"peakFlatHeightPx": PEAK_FLAT_HEIGHT_PX,
|
||||
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
|
||||
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
|
||||
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
|
||||
"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",
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
1106
docs/avatar-art/linocut-pipeline/scripts/export_rig.py
Normal file
1106
docs/avatar-art/linocut-pipeline/scripts/export_rig.py
Normal file
File diff suppressed because it is too large
Load diff
758
docs/avatar-art/linocut-pipeline/scripts/face_detail.py
Normal file
758
docs/avatar-art/linocut-pipeline/scripts/face_detail.py
Normal file
|
|
@ -0,0 +1,758 @@
|
|||
"""공통 리노컷 리그 — faceDetail 레이어 생성(결정문 §8.2).
|
||||
|
||||
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
|
||||
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
|
||||
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
|
||||
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
|
||||
경계는 10px 페더로 base-front↔F를 섞는다.
|
||||
|
||||
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
|
||||
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
|
||||
진짜 결이므로 건드리지 않는다.
|
||||
|
||||
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
|
||||
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
|
||||
|
||||
점(mole)처럼 원화에만 있고 랜드마크로 유도할 수 없는 반점은 persona.json의
|
||||
faceDetail.moles(원점+반경 목록)에서 읽는다. 목록이 비면 점 영역을 그리지
|
||||
않는다(모든 캐릭터가 점이 있는 것은 아니다).
|
||||
|
||||
전제: layers_v2.py가 이미 layers/v2/{body,head,hairFront}.png를 만들어 두어야
|
||||
한다(정지 합성 검사에 쓴다).
|
||||
|
||||
실행: <venv>/python.exe face_detail.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import (
|
||||
binary_closing, binary_dilation, binary_erosion, binary_fill_holes, binary_opening,
|
||||
distance_transform_edt, gaussian_filter, label,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
|
||||
REGION_FEATHER_PX = 12.0
|
||||
|
||||
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
|
||||
EYE_INK_MARGIN = 32.0
|
||||
EYE_INK_THRESH = 150.0
|
||||
EYE_CREASE_UP_PX = 18 # 쌍꺼풀 주름 커버(위로)
|
||||
EYE_OUTER_EXT_PX = 12 # 바깥 꼬리 커버
|
||||
EYE_FINAL_DILATE_PX = 6 # 위/바깥/눈 틈에 적용(아래쪽은 별도)
|
||||
|
||||
# 아랫눈꺼풀은 선 자체만 좁게 덮는다 — 다크서클 해칭(그 아래 살려야 할 그늘)을
|
||||
# 먹지 않기 위해 위/바깥과는 다른(더 좁은) 팽창·페더를 쓴다.
|
||||
EYE_LOWER_LINE_ABOVE_PX = 2.0
|
||||
EYE_LOWER_LINE_BELOW_PX = 5.0
|
||||
EYE_LOWER_DILATE_PX = 2
|
||||
EYE_LOWER_FEATHER_PX = 4.0
|
||||
|
||||
EYE_INK_LOWER_CAP_PX = 7.0 # eye_dark_hole 연결요소가 다크서클 해칭까지 붙어 나오는 것을 자른다
|
||||
|
||||
DARK_CIRCLE_BAND_Y0 = 10.0 # 아랫눈꺼풀 아래 10~35px
|
||||
DARK_CIRCLE_BAND_Y1 = 35.0
|
||||
DARK_CIRCLE_BAND_HALF_W = 35.0 # 홍채 중심 x ±35px
|
||||
DARK_CIRCLE_MATCH_TOLERANCE = 6.0
|
||||
|
||||
BROW_INK_MARGIN = 20.0
|
||||
BROW_INK_THRESH = 150.0
|
||||
BROW_STROKE_HALF_WIDTH = 14 # 랜드마크 곡선 ±14px 띠
|
||||
BROW_FINAL_DILATE_PX = 10
|
||||
|
||||
MOUTH_INK_MARGIN = 15.0
|
||||
MOUTH_INK_THRESH = 150.0
|
||||
MOUTH_CORNER_EXT_PX = 12
|
||||
MOUTH_SHADOW_EXT_PX = 24 # 아랫입술 아래 그늘선 커버
|
||||
MOUTH_FINAL_DILATE_PX = 8
|
||||
|
||||
EXCLUSION_BOUNDARY_FEATHER_PX = 10.0
|
||||
HAIR_DILATE_PX = 2
|
||||
|
||||
# --- 잔여 잉크(선 모양만) 복제 도장 상수 -------------------------------------
|
||||
CLONE_TARGET_THRESH = 65.0
|
||||
AVOID_THRESH = 110.0
|
||||
LINE_OPENING_ITER = 3
|
||||
CLONE_STAMP_FEATHER_PX = 4.0
|
||||
CLONE_STAMP_CANDIDATES = [
|
||||
(0, -40), (0, -60), (-40, 0), (40, 0), (0, 40), (0, 60),
|
||||
(-60, 0), (60, 0), (0, -80), (0, 80), (-40, -40), (40, -40), (-40, 40), (40, 40),
|
||||
(0, -100), (0, -120), (0, -150), (0, -180), (0, 100), (0, 120),
|
||||
(-80, 0), (80, 0), (-100, 0), (100, 0), (-120, -60), (120, -60), (-60, -100), (60, -100),
|
||||
]
|
||||
|
||||
# --- 검사 상수 ---------------------------------------------------------------
|
||||
RING_PX = 12
|
||||
HF_SIGMA = 2.0
|
||||
LINE_SIGMA = 3.0
|
||||
CHECKD_THRESH = 0.7
|
||||
CHECKE_THRESH = 1.5
|
||||
GENERIC_PATCH_SIZE = 48
|
||||
GENERIC_PATCH_MARGIN = 40
|
||||
|
||||
|
||||
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)
|
||||
d = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
|
||||
alpha = (d <= 1.0).astype(np.float64) * 255.0
|
||||
alpha = gaussian_filter(alpha, sigma=feather / 2.0)
|
||||
return np.clip(alpha, 0, 255)
|
||||
|
||||
|
||||
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)
|
||||
d.line([tuple(outer), tuple(peak), tuple(inner)], fill=255, width=width, joint="curve")
|
||||
d.ellipse([outer[0] - width / 2, outer[1] - width / 2, outer[0] + width / 2, outer[1] + width / 2], fill=255)
|
||||
d.ellipse([inner[0] - width / 2, inner[1] - width / 2, inner[0] + width / 2, inner[1] + width / 2], fill=255)
|
||||
return np.array(img) > 127
|
||||
|
||||
|
||||
def lens_polygon(inner, outer, upper, lower, pad: float, n: int = 24) -> list[tuple[float, float]]:
|
||||
def quad_bezier(p0, p1, p2, n):
|
||||
ts = np.linspace(0, 1, n)
|
||||
pts = []
|
||||
for t in ts:
|
||||
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
|
||||
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
|
||||
pts.append((x, y))
|
||||
return pts
|
||||
|
||||
upper_ctrl = (upper[0], upper[1] - pad)
|
||||
lower_ctrl = (inner[0], lower[1] + pad)
|
||||
upper_curve = quad_bezier(outer, upper_ctrl, inner, n)
|
||||
lower_curve = quad_bezier(inner, lower_ctrl, outer, n)
|
||||
return upper_curve + lower_curve
|
||||
|
||||
|
||||
def nearest_component_label(labeled: np.ndarray, py: float, px: float) -> int:
|
||||
"""(py,px) 위치의 라벨을 쓰되, 그 지점이 어떤 연결요소에도 안 속하면(랜드마크가
|
||||
잉크 픽셀에서 살짝 벗어난 경우) 가장 가까운 연결요소의 라벨을 대신 쓴다."""
|
||||
h, w = labeled.shape
|
||||
iy, ix = int(round(py)), int(round(px))
|
||||
iy = min(max(iy, 0), h - 1)
|
||||
ix = min(max(ix, 0), w - 1)
|
||||
seed_label = int(labeled[iy, ix])
|
||||
if seed_label != 0:
|
||||
return seed_label
|
||||
if not (labeled != 0).any():
|
||||
return 0
|
||||
_, (near_y, near_x) = distance_transform_edt(labeled == 0, return_indices=True)
|
||||
return int(labeled[near_y[iy, ix], near_x[iy, ix]])
|
||||
|
||||
|
||||
def eye_dark_hole(
|
||||
eye: dict, lum: np.ndarray, w: int, h: int, margin: float = EYE_INK_MARGIN,
|
||||
thresh: float = EYE_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""눈 틈(공막·홍채·동공·아이라이너 잉크) 전체를 명도 임계값 + 연결요소로 찾는다."""
|
||||
lens = lens_polygon(eye["innerCorner"], eye["outerCorner"], eye["upperLidTop"], eye["lowerLidBottom"], pad=6.0)
|
||||
xs = [p[0] for p in lens]
|
||||
ys = [p[1] for p in lens]
|
||||
x0, x1 = min(xs) - margin, max(xs) + margin
|
||||
y0, y1 = min(ys) - margin, max(ys) + margin
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
icx, icy = eye["iris"]["center"]
|
||||
seed_label = nearest_component_label(labeled, icy, icx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def lower_lid_line_mask(
|
||||
inner, outer, lower, w: int, h: int, above: float = EYE_LOWER_LINE_ABOVE_PX, below: float = EYE_LOWER_LINE_BELOW_PX
|
||||
) -> np.ndarray:
|
||||
"""아랫눈꺼풀 선(랜드마크 곡선) 자체만 위로 above px, 아래로 below px 두께로
|
||||
감싸는 좁은 띠를 만든다 — 다크서클 해칭을 먹지 않기 위해 이 아래는 손대지 않는다."""
|
||||
n = 40
|
||||
ts = np.linspace(0, 1, n)
|
||||
curve = [
|
||||
((1 - t) ** 2 * outer[0] + 2 * (1 - t) * t * lower[0] + t ** 2 * inner[0],
|
||||
(1 - t) ** 2 * outer[1] + 2 * (1 - t) * t * lower[1] + t ** 2 * inner[1])
|
||||
for t in ts
|
||||
]
|
||||
upper_edge = [(x, y - above) for x, y in curve]
|
||||
lower_edge = [(x, y + below) for x, y in curve]
|
||||
poly = upper_edge + lower_edge[::-1]
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).polygon(poly, fill=255)
|
||||
return np.array(img) > 127
|
||||
|
||||
|
||||
def dark_circle_band_mask(eye: dict, w: int, h: int) -> np.ndarray:
|
||||
"""아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35px 띠(다크서클 해칭 검사용)."""
|
||||
icx = eye["iris"]["center"][0]
|
||||
lower_y = eye["lowerLidBottom"][1]
|
||||
y0, y1 = lower_y + DARK_CIRCLE_BAND_Y0, lower_y + DARK_CIRCLE_BAND_Y1
|
||||
x0, x1 = icx - DARK_CIRCLE_BAND_HALF_W, icx + DARK_CIRCLE_BAND_HALF_W
|
||||
m = np.zeros((h, w), dtype=bool)
|
||||
yy0, yy1 = max(0, int(round(y0))), min(h, int(round(y1)))
|
||||
xx0, xx1 = max(0, int(round(x0))), min(w, int(round(x1)))
|
||||
m[yy0:yy1, xx0:xx1] = True
|
||||
return m
|
||||
|
||||
|
||||
def brow_dark_hole(
|
||||
brow: dict, lum: np.ndarray, w: int, h: int, margin: float = BROW_INK_MARGIN,
|
||||
thresh: float = BROW_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""눈썹 잉크(굵은 털 뭉치) 전체를 명도 임계값 + 연결요소로 찾는다(eye_dark_hole과 같은 방식)."""
|
||||
inner, peak, outer = brow["inner"], brow["peak"], brow["outer"]
|
||||
xs = [inner[0], peak[0], outer[0]]
|
||||
ys = [inner[1], peak[1], outer[1]]
|
||||
x0, x1 = min(xs) - margin, max(xs) + margin
|
||||
y0, y1 = min(ys) - margin - 10, max(ys) + margin + 15
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
pcx, pcy = peak
|
||||
seed_label = nearest_component_label(labeled, pcy, pcx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def mouth_dark_hole(
|
||||
mcL, mcR, upLip, loLip, lum: np.ndarray, w: int, h: int, margin: float = MOUTH_INK_MARGIN,
|
||||
thresh: float = MOUTH_INK_THRESH, close_iter: int = 3
|
||||
) -> np.ndarray:
|
||||
"""입술 잉크(윗/아랫입술 선·안쪽 그늘) 전체를 명도 임계값 + 연결요소로 찾는다."""
|
||||
x0, x1 = min(mcL[0], mcR[0]) - margin, max(mcL[0], mcR[0]) + margin
|
||||
y0, y1 = upLip[1] - margin, loLip[1] + margin
|
||||
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
|
||||
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
|
||||
dark = search_ellipse & (lum < thresh)
|
||||
dark = binary_closing(dark, iterations=close_iter)
|
||||
labeled, _ = label(dark)
|
||||
ccx, ccy = (mcL[0] + mcR[0]) / 2, (upLip[1] + loLip[1]) / 2
|
||||
seed_label = nearest_component_label(labeled, ccy, ccx)
|
||||
if seed_label == 0:
|
||||
return np.zeros((h, w), dtype=bool)
|
||||
comp = labeled == seed_label
|
||||
return binary_fill_holes(comp)
|
||||
|
||||
|
||||
def shift_mask(mask: np.ndarray, dy: int, dx: int) -> np.ndarray:
|
||||
"""mask를 (dy,dx)만큼 평행이동한다(래핑 없음, 밖으로 밀려난 부분은 버림)."""
|
||||
h, w = mask.shape
|
||||
out = np.zeros_like(mask)
|
||||
src_y0, src_y1 = max(0, -dy), h - max(0, dy)
|
||||
dst_y0, dst_y1 = max(0, dy), h - max(0, -dy)
|
||||
src_x0, src_x1 = max(0, -dx), w - max(0, dx)
|
||||
dst_x0, dst_x1 = max(0, dx), w - max(0, -dx)
|
||||
if src_y1 <= src_y0 or src_x1 <= src_x0:
|
||||
return out
|
||||
out[dst_y0:dst_y1, dst_x0:dst_x1] = mask[src_y0:src_y1, src_x0:src_x1]
|
||||
return out
|
||||
|
||||
|
||||
def grow_directional(mask: np.ndarray, dy: int = 0, dx: int = 0) -> np.ndarray:
|
||||
"""mask를 (dy,dx) 방향으로 1px씩 단계적으로 밀어 그 방향으로 최대
|
||||
|dy| 또는 |dx| px까지 덮는다(원래 mask가 있던 자리에서 그 방향으로 "그림자를
|
||||
드리운" 모양) — 눈 주름·아랫눈꺼풀·입가 그늘처럼 특정 방향으로만 구멍을
|
||||
넓힐 때 쓴다."""
|
||||
steps = max(abs(dy), abs(dx), 1)
|
||||
out = mask.copy()
|
||||
for i in range(1, steps + 1):
|
||||
fy = round(dy * i / steps)
|
||||
fx = round(dx * i / steps)
|
||||
out |= shift_mask(mask, fy, fx)
|
||||
return out
|
||||
|
||||
|
||||
def line_only_mask(dark_mask: np.ndarray, iterations: int = LINE_OPENING_ITER) -> np.ndarray:
|
||||
"""넓고 부드러운 그늘(오프닝으로 살아남는 덩어리)을 빼고, 얇은 선 성분만 남긴다."""
|
||||
opened = binary_opening(dark_mask, iterations=iterations)
|
||||
return dark_mask & ~opened
|
||||
|
||||
|
||||
def clone_stamp_fill(f_arr: np.ndarray, bad_mask: np.ndarray, avoid_mask: np.ndarray) -> tuple[np.ndarray, list[dict]]:
|
||||
"""bad_mask(F에 남은 선 모양 잔여 잉크)의 연결요소마다 avoid_mask(제외 영역 ∪
|
||||
머리카락 ∪ F 잔여 잉크)를 피하는 이웃 패치를 후보 오프셋에서 찾아 그대로
|
||||
옮겨 붙인다 — 복제 도장. 블러 없이 원본 해칭 텍스처를 재배치만 한다."""
|
||||
h, w = bad_mask.shape
|
||||
labeled, n = label(bad_mask)
|
||||
out = f_arr.copy()
|
||||
good = ~avoid_mask
|
||||
report: list[dict] = []
|
||||
for comp_id in range(1, n + 1):
|
||||
comp = labeled == comp_id
|
||||
ys, xs = np.where(comp)
|
||||
y0, y1, x0, x1 = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
|
||||
comp_sub = comp[y0:y1, x0:x1]
|
||||
# shift_mask(f_arr, dy, dx)는 출력 위치 y의 값을 입력 위치 y-dy에서
|
||||
# 가져온다(아래로 dy만큼 미는 것) — 그래서 comp가 실제로 퍼오는 소스
|
||||
# 좌표는 y0-dy..y1-dy, x0-dx..x1-dx이다. 여기를 검증해야 한다(이전에는
|
||||
# y0+dy로 반대 방향을 검증하는 부호 버그가 있었다).
|
||||
chosen = None
|
||||
best_std = -1.0
|
||||
for dx, dy in CLONE_STAMP_CANDIDATES:
|
||||
sy0, sy1, sx0, sx1 = y0 - dy, y1 - dy, x0 - dx, x1 - dx
|
||||
if sy0 < 0 or sx0 < 0 or sy1 > h or sx1 > w:
|
||||
continue
|
||||
if not np.all(good[sy0:sy1, sx0:sx1][comp_sub]):
|
||||
continue
|
||||
src_patch = f_arr[sy0:sy1, sx0:sx1][comp_sub]
|
||||
s = float(src_patch.std())
|
||||
if s > best_std:
|
||||
best_std = s
|
||||
chosen = (dx, dy)
|
||||
|
||||
# comp 안쪽은 무조건 완전 교체(blend=1)한다. 컴포넌트별로 가우시안이나
|
||||
# 거리변환 페더를 주면, 서로 몇 px 안 떨어진 작은 컴포넌트(대다수가
|
||||
# 1~수십 px)들의 페더 자락이 이웃 컴포넌트의 comp 영역까지 침범해 이미
|
||||
# 교체된 픽셀을 나중 컴포넌트의 도장 값으로 다시 섞어버려 어둡게 되돌리는
|
||||
# 문제가 있었다 — comp 경계는 하드컷으로 두고, 바깥쪽 전환은 뒤에서
|
||||
# bad_texture 전체 기준으로 한 번에 처리한다.
|
||||
blend = comp.astype(np.float64)
|
||||
|
||||
if chosen is None:
|
||||
_, (iy, ix) = distance_transform_edt(~good, return_indices=True)
|
||||
shifted = f_arr[iy, ix, :]
|
||||
method = "nearest-good-pixel"
|
||||
offset = None
|
||||
else:
|
||||
dx, dy = chosen
|
||||
shifted = np.stack([shift_mask(f_arr[..., c], dy, dx) for c in range(f_arr.shape[2])], axis=2)
|
||||
method = "clone-stamp"
|
||||
offset = [dx, dy]
|
||||
|
||||
out = out * (1 - blend[..., None]) + shifted * blend[..., None]
|
||||
report.append({"componentId": comp_id, "pixels": int(comp.sum()), "offset": offset, "method": method})
|
||||
return out, report
|
||||
|
||||
|
||||
def high_freq_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = HF_SIGMA) -> float:
|
||||
if not mask.any():
|
||||
return 0.0
|
||||
hf = lum - gaussian_filter(lum, sigma=sigma)
|
||||
return float(hf[mask].std())
|
||||
|
||||
|
||||
def line_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = LINE_SIGMA) -> float:
|
||||
"""원본이 자신의 블러보다 어두운 정도(가는 잉크선 성분)의 평균 크기."""
|
||||
if not mask.any():
|
||||
return 0.0
|
||||
blurred = gaussian_filter(lum, sigma=sigma)
|
||||
neg = np.clip(blurred - lum, 0, None)
|
||||
return float(neg[mask].mean())
|
||||
|
||||
|
||||
def find_clean_patch(
|
||||
avoid_dilated: np.ndarray, roi: tuple[int, int, int, int], size: int = GENERIC_PATCH_SIZE
|
||||
) -> tuple[int, int]:
|
||||
"""roi=(x0,y0,x1,y1) 범위 안에서 avoid_dilated가 전부 False인 size x size
|
||||
창의 좌상단 좌표를 찾는다. 완전히 깨끗한 창이 없으면 가장 깨끗한 것을 쓴다."""
|
||||
x0, y0, x1, y1 = roi
|
||||
h, w = avoid_dilated.shape
|
||||
x0, y0 = max(0, x0), max(0, y0)
|
||||
x1, y1 = min(w, x1), min(h, y1)
|
||||
eligible = ~avoid_dilated
|
||||
mask_f = eligible.astype(np.float64)
|
||||
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
|
||||
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
|
||||
sy0, sy1 = max(0, y0), min(sums.shape[0] - 1, y1 - size)
|
||||
sx0, sx1 = max(0, x0), min(sums.shape[1] - 1, x1 - size)
|
||||
if sy1 < sy0 or sx1 < sx0:
|
||||
sy0, sy1, sx0, sx1 = 0, sums.shape[0] - 1, 0, sums.shape[1] - 1
|
||||
sub = sums[sy0:sy1 + 1, sx0:sx1 + 1]
|
||||
idx = np.unravel_index(np.argmax(sub), sub.shape)
|
||||
return int(idx[1] + sx0), int(idx[0] + sy0)
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img).astype(np.float64)
|
||||
h, w, _ = f_arr.shape
|
||||
lum_front = front.mean(axis=2)
|
||||
lum_f_raw = f_arr.mean(axis=2)
|
||||
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
if cfg.brow_landmarks_override is not None:
|
||||
# P1 전용 호환 장치: 눈썹 제외 영역·eyeRegion y0은 눈썹 중심선 보정 이전
|
||||
# 좌표로 고정 빌드된 채 "다시 빌드하지 않는다"는 지시로 승인됐다(persona.json
|
||||
# 참고). 다른 모든 랜드마크(눈·입·코·턱)는 그대로 현재 값을 쓴다.
|
||||
browL, browR = cfg.brow_landmarks_override["browLeft"], cfg.brow_landmarks_override["browRight"]
|
||||
brow_source = "persona.json faceDetail.browLandmarksOverride"
|
||||
else:
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
brow_source = "manifest.landmarks.eyebrowLeft/Right"
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
|
||||
print(f"눈썹 좌표 출처: {brow_source}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
|
||||
# ------------------------------------------------------------------
|
||||
def eye_region_bbox(eye, brow, outward_sign: float):
|
||||
outer, inner = eye["outerCorner"], eye["innerCorner"]
|
||||
x_outer = outer[0] + outward_sign * 30.0
|
||||
x_inner = inner[0] - outward_sign * 25.0
|
||||
x_min, x_max = sorted([x_outer, x_inner])
|
||||
y_min = brow["peak"][1] - 10.0
|
||||
y_max = eye["lowerLidBottom"][1] + 70.0
|
||||
return x_min, y_min, x_max, y_max
|
||||
|
||||
bx0, by0, bx1, by1 = eye_region_bbox(eyeL, browL, outward_sign=-1.0)
|
||||
rx0, ry0, rx1, ry1 = eye_region_bbox(eyeR, browR, outward_sign=+1.0)
|
||||
eyeL_cx, eyeL_cy = (bx0 + bx1) / 2, (by0 + by1) / 2
|
||||
eyeL_rx, eyeL_ry = (bx1 - bx0) / 2, (by1 - by0) / 2
|
||||
eyeR_cx, eyeR_cy = (rx0 + rx1) / 2, (ry0 + ry1) / 2
|
||||
eyeR_rx, eyeR_ry = (rx1 - rx0) / 2, (ry1 - ry0) / 2
|
||||
|
||||
mouth_x0 = min(mcL[0], mcR[0]) - 45.0
|
||||
mouth_x1 = max(mcL[0], mcR[0]) + 45.0
|
||||
mouth_y0 = noseTip[1] + 25.0
|
||||
mouth_y1 = chinTip[1] - 15.0
|
||||
mouth_cx, mouth_cy = (mouth_x0 + mouth_x1) / 2, (mouth_y0 + mouth_y1) / 2
|
||||
mouth_rx, mouth_ry = (mouth_x1 - mouth_x0) / 2, (mouth_y1 - mouth_y0) / 2
|
||||
|
||||
print(f"eyeLeft 영역 bbox=({bx0:.1f},{by0:.1f},{bx1:.1f},{by1:.1f})")
|
||||
print(f"eyeRight 영역 bbox=({rx0:.1f},{ry0:.1f},{rx1:.1f},{ry1:.1f})")
|
||||
print(f"mouth 영역 bbox=({mouth_x0:.1f},{mouth_y0:.1f},{mouth_x1:.1f},{mouth_y1:.1f})")
|
||||
|
||||
a_eyeL = ellipse_alpha(eyeL_cx, eyeL_cy, eyeL_rx, eyeL_ry, w, h, REGION_FEATHER_PX)
|
||||
a_eyeR = ellipse_alpha(eyeR_cx, eyeR_cy, eyeR_rx, eyeR_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mouth = ellipse_alpha(mouth_cx, mouth_cy, mouth_rx, mouth_ry, w, h, REGION_FEATHER_PX)
|
||||
a_mole = np.zeros((h, w), dtype=np.float64)
|
||||
for mole in cfg.moles:
|
||||
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))
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
|
||||
# ------------------------------------------------------------------
|
||||
print("=== 제외 영역 계산 ===")
|
||||
eyeL_ink_raw = eye_dark_hole(eyeL, lum_front, w, h)
|
||||
eyeR_ink_raw = eye_dark_hole(eyeR, lum_front, w, h)
|
||||
# eye_dark_hole은 명도<150 연결요소를 찾는데, 다크서클 해칭이 눈 잉크와
|
||||
# 이어져 있어(binary_closing으로 다리까지 놓여) 같은 컴포넌트로 잡혀
|
||||
# 아랫눈꺼풀선 아래 30px까지 "눈 잉크"로 나온다 — 랜드마크선+7px 아래는
|
||||
# 잘라내 다크서클 해칭이 눈 제외 영역에 섞이지 않게 한다.
|
||||
yy_full, _ = np.mgrid[0:h, 0:w]
|
||||
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
|
||||
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
|
||||
eyeL_ink = eyeL_ink_raw & eyeL_cap
|
||||
eyeR_ink = eyeR_ink_raw & eyeR_cap
|
||||
print(f"눈 잉크 다크서클 절단: L {int(eyeL_ink_raw.sum())}->{int(eyeL_ink.sum())}px, R {int(eyeR_ink_raw.sum())}->{int(eyeR_ink.sum())}px")
|
||||
# 위(쌍꺼풀 주름)·바깥(꼬리)은 기존처럼 잉크에서 방향성 확장 + 6px 팽창.
|
||||
# 아래쪽은 잉크에서 확장하지 않고, 랜드마크 곡선 자체의 좁은 띠(-2~+5px)만
|
||||
# 별도로 2px 팽창한다 — 다크서클 해칭을 먹지 않기 위함(오케스트레이터 지시).
|
||||
eyeL_grown = eyeL_ink | grow_directional(eyeL_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeL_ink, dx=-EYE_OUTER_EXT_PX)
|
||||
eyeR_grown = eyeR_ink | grow_directional(eyeR_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeR_ink, dx=EYE_OUTER_EXT_PX)
|
||||
# 6px 팽창은 사방으로 동시에 퍼지므로, 이미 절단한 아래쪽 경계를 다시
|
||||
# 밀어 내린다 — 팽창 뒤에도 같은 절단선으로 다시 한 번 잘라 위/바깥
|
||||
# 팽창(원하는 효과)만 남기고 아래쪽 재침범은 막는다.
|
||||
eyeL_core_excl = binary_dilation(eyeL_grown, iterations=EYE_FINAL_DILATE_PX) & eyeL_cap
|
||||
eyeR_core_excl = binary_dilation(eyeR_grown, iterations=EYE_FINAL_DILATE_PX) & eyeR_cap
|
||||
|
||||
eyeL_lower_line = lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], w, h)
|
||||
eyeR_lower_line = lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], w, h)
|
||||
eyeL_lower_excl = binary_dilation(eyeL_lower_line, iterations=EYE_LOWER_DILATE_PX)
|
||||
eyeR_lower_excl = binary_dilation(eyeR_lower_line, iterations=EYE_LOWER_DILATE_PX)
|
||||
|
||||
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
|
||||
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
|
||||
# 페더 프로파일을 나눌 때는 "아래쪽 좁은 띠 전용 마스크"가 아니라 "제외
|
||||
# 영역 중 아랫눈꺼풀선 근방(위로 3px)에 걸리는 부분 전체"를 기준으로
|
||||
# 삼는다 — core_excl의 6px 팽창분도 같은 y대에 있으면 좁은 4px 페더를
|
||||
# 받아야 다크서클 쪽으로 넓은 10px 페더가 새는 것을 막는다.
|
||||
eyeL_lower_zone = eyeL_excl & (yy_full > eyeL["lowerLidBottom"][1] - 8.0)
|
||||
eyeR_lower_zone = eyeR_excl & (yy_full > eyeR["lowerLidBottom"][1] - 8.0)
|
||||
eye_lower_bands = eyeL_lower_zone | eyeR_lower_zone
|
||||
|
||||
browL_ink = brow_dark_hole(browL, lum_front, w, h)
|
||||
browR_ink = brow_dark_hole(browR, lum_front, w, h)
|
||||
browL_band = eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
|
||||
browR_band = eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
|
||||
browL_excl = binary_dilation(browL_ink | browL_band, iterations=BROW_FINAL_DILATE_PX)
|
||||
browR_excl = binary_dilation(browR_ink | browR_band, iterations=BROW_FINAL_DILATE_PX)
|
||||
|
||||
mouth_ink = mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, w, h)
|
||||
mouth_grown = (
|
||||
mouth_ink
|
||||
| grow_directional(mouth_ink, dx=-MOUTH_CORNER_EXT_PX)
|
||||
| grow_directional(mouth_ink, dx=MOUTH_CORNER_EXT_PX)
|
||||
| grow_directional(mouth_ink, dy=MOUTH_SHADOW_EXT_PX)
|
||||
)
|
||||
mouth_excl = binary_dilation(mouth_grown, iterations=MOUTH_FINAL_DILATE_PX)
|
||||
|
||||
exclusion_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
|
||||
named_excl = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
|
||||
print(f"제외 영역 픽셀: eye={int((eyeL_excl|eyeR_excl).sum())} brow={int((browL_excl|browR_excl).sum())} mouth={int(mouth_excl.sum())} union={int(exclusion_mask.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
|
||||
# ------------------------------------------------------------------
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
|
||||
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 텍스처원(질감, 블러 금지): F가 선 모양 잔여 잉크인 자리만 복제 도장
|
||||
# ------------------------------------------------------------------
|
||||
dark_in_excl = exclusion_mask & ~hair_mask & (lum_f_raw < CLONE_TARGET_THRESH)
|
||||
bad_texture = line_only_mask(dark_in_excl)
|
||||
avoid_mask = exclusion_mask | hair_mask | (lum_f_raw < AVOID_THRESH)
|
||||
print(f"텍스처 복제 대상(제외 영역 안 F 선 모양 잔여 잉크): {int(bad_texture.sum())}px (넓은 그늘 {int(dark_in_excl.sum()) - int(bad_texture.sum())}px는 보존)")
|
||||
f_clean, stamp_report = clone_stamp_fill(f_arr, bad_texture, avoid_mask)
|
||||
for r in stamp_report:
|
||||
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
|
||||
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
|
||||
# 다크서클 해칭을 먹지 않도록 4px 페더만 쓴다(오케스트레이터 지시). 두
|
||||
# 페더 영역을 나눠 계산한 뒤 max로 합친다. 복제 도장으로 갈아 끼운 자리
|
||||
# (bad_texture)는 경계 페더가 원본 front(잉크 그 자체)를 다시 섞어 넣지
|
||||
# 못하게 blend=1로 고정한다 — 안 그러면 제외 영역 가장자리 근처의 잔여
|
||||
# 잉크는 도장으로 지워도 다시 어두워진다.
|
||||
# ------------------------------------------------------------------
|
||||
blend_main = np.clip(
|
||||
gaussian_filter((exclusion_mask & ~eye_lower_bands).astype(np.float64), sigma=EXCLUSION_BOUNDARY_FEATHER_PX / 2.0), 0.0, 1.0
|
||||
)
|
||||
blend_lower = np.clip(gaussian_filter(eye_lower_bands.astype(np.float64), sigma=EYE_LOWER_FEATHER_PX / 2.0), 0.0, 1.0)
|
||||
blend = np.maximum(blend_main, blend_lower)
|
||||
blend = np.maximum(blend, bad_texture.astype(np.float64))
|
||||
face_detail_rgb = front * (1 - blend[..., None]) + f_clean * blend[..., None]
|
||||
|
||||
# region_alpha(눈·입 타원)는 기존 "눈두덩~다크서클" 범위용으로 만든 것이라,
|
||||
# 새로 넓힌 제외 영역(특히 눈썹은 자체 타원이 없다)을 다 못 덮을 수 있다 —
|
||||
# 못 덮으면 그 자리는 faceDetail이 아니라 head 레이어의 원본(미처리) 잉크가
|
||||
# 그대로 비쳐 보인다. 제외 영역은 RGB와 같은 10px 페더 프로필로 알파도
|
||||
# 최소 보장한다.
|
||||
face_detail_alpha = np.maximum(region_alpha, blend * 255.0)
|
||||
face_detail_alpha[hair_mask] = 0.0
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 저장
|
||||
# ------------------------------------------------------------------
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = layers_v2_dir / "face-detail.png"
|
||||
Image.fromarray(np.dstack([to_u8(face_detail_rgb), to_u8(face_detail_alpha)]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(to_u8(face_detail_alpha))
|
||||
print(f"저장: {out_path} bbox={bbox}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 정지 합성(body+head+faceDetail+hairFront, 종이 위)
|
||||
# ------------------------------------------------------------------
|
||||
def load_rgba(p: Path) -> tuple[np.ndarray, np.ndarray]:
|
||||
arr = np.array(Image.open(p).convert("RGBA")).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
body_rgb, body_a = load_rgba(layers_v2_dir / "body.png")
|
||||
head_rgb, head_a = load_rgba(layers_v2_dir / "head.png")
|
||||
hf_rgb, hf_a = load_rgba(layers_v2_dir / "hairFront.png")
|
||||
|
||||
canvas = np.zeros((h, w, 4), dtype=np.float64)
|
||||
canvas[..., 0] = CREAM_BG[0]; canvas[..., 1] = CREAM_BG[1]; canvas[..., 2] = CREAM_BG[2]; canvas[..., 3] = 255.0
|
||||
canvas = composite_over(canvas, to_u8(body_rgb), to_u8(body_a))
|
||||
canvas = composite_over(canvas, to_u8(head_rgb), to_u8(head_a))
|
||||
canvas = composite_over(canvas, to_u8(face_detail_rgb), to_u8(face_detail_alpha))
|
||||
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
|
||||
static_composite = to_u8(canvas)[..., :3].astype(np.float64)
|
||||
lum_composite = static_composite.mean(axis=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (a): 제외 영역 밖, faceDetail alpha>0.5에서 base-front 대비 <=3
|
||||
# ------------------------------------------------------------------
|
||||
check_mask = (face_detail_alpha > 127) & ~exclusion_mask
|
||||
diff = np.abs(static_composite - front).mean(axis=2)
|
||||
mean_abs_a = float(diff[check_mask].mean()) if check_mask.any() else None
|
||||
print(f"검사(a) 제외영역 밖 & faceDetail>0.5 평균절대차 = {mean_abs_a:.3f} (기준 <=3)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (b): 제외 영역 경계 안팎 3px 띠 평균 명도 차 <=6
|
||||
# ------------------------------------------------------------------
|
||||
excl_inner_band = exclusion_mask & ~binary_erosion(exclusion_mask, iterations=3)
|
||||
excl_outer_band = binary_dilation(exclusion_mask, iterations=3) & ~exclusion_mask
|
||||
inner_mean = float(lum_composite[excl_inner_band].mean()) if excl_inner_band.any() else None
|
||||
outer_mean = float(lum_composite[excl_outer_band].mean()) if excl_outer_band.any() else None
|
||||
seam_diff = abs(inner_mean - outer_mean) if inner_mean is not None and outer_mean is not None else None
|
||||
print(f"검사(b) 제외영역 경계 안({inner_mean:.2f})/밖({outer_mean:.2f}) 명도차 = {seam_diff:.3f} (기준 <=6)")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (c): 제외 영역 안, 머리카락이 아닌 픽셀 중 명도<70 비율 <=0.3%
|
||||
# ------------------------------------------------------------------
|
||||
check_region_c = exclusion_mask & ~hair_mask
|
||||
violations = check_region_c & (lum_composite < 70)
|
||||
n_violations = int(violations.sum())
|
||||
hole_dark_pct = float(violations.sum()) / float(check_region_c.sum()) * 100.0 if check_region_c.any() else 0.0
|
||||
print(f"검사(c) 제외영역 안(머리카락 제외) 명도<70 비율 = {hole_dark_pct:.4f}% (기준 <=0.3%, 위반 {n_violations}px)")
|
||||
|
||||
if hole_dark_pct > 0.3 and n_violations > 0:
|
||||
overlay = front.copy()
|
||||
overlay[violations] = np.array([230.0, 30.0, 30.0])
|
||||
ys, xs = np.where(violations)
|
||||
pad = 30
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + pad), min(h, int(ys.max()) + pad)
|
||||
diag_path = preview_v2_dir / "checkC-violations.png"
|
||||
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
|
||||
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 일반 피부 표본(뺨 2곳 + 이마 1곳, F 기준, 이목구비·머리카락에서 40px+ 이격)
|
||||
# ------------------------------------------------------------------
|
||||
avoid_for_patch = binary_dilation(exclusion_mask | hair_mask, iterations=GENERIC_PATCH_MARGIN)
|
||||
browL_peak, browR_peak = browL["peak"], browR["peak"]
|
||||
eyeL_lower, eyeR_lower = eyeL["lowerLidBottom"], eyeR["lowerLidBottom"]
|
||||
|
||||
forehead_roi = (
|
||||
int(min(browL_peak[0], browR_peak[0]) - 20), int(min(browL_peak[1], browR_peak[1]) - 100),
|
||||
int(max(browL_peak[0], browR_peak[0]) + 20), int(min(browL_peak[1], browR_peak[1]) - 20),
|
||||
)
|
||||
cheekL_roi = (
|
||||
int(eyeL["outerCorner"][0] - 100), int(eyeL_lower[1] + 10),
|
||||
int(noseTip[0] - 20), int(mcL[1] - 5),
|
||||
)
|
||||
cheekR_roi = (
|
||||
int(noseTip[0] + 20), int(eyeR_lower[1] + 10),
|
||||
int(eyeR["outerCorner"][0] + 100), int(mcR[1] - 5),
|
||||
)
|
||||
patch_boxes: dict[str, tuple[int, int, int, int]] = {}
|
||||
for name, roi in (("forehead", forehead_roi), ("cheekLeft", cheekL_roi), ("cheekRight", cheekR_roi)):
|
||||
x0, y0 = find_clean_patch(avoid_for_patch, roi, GENERIC_PATCH_SIZE)
|
||||
patch_boxes[name] = (x0, y0, x0 + GENERIC_PATCH_SIZE, y0 + GENERIC_PATCH_SIZE)
|
||||
clean_frac = float((~avoid_for_patch[y0:y0 + GENERIC_PATCH_SIZE, x0:x0 + GENERIC_PATCH_SIZE]).mean()) * 100.0
|
||||
print(f"일반 피부 표본 {name}: box={patch_boxes[name]} 깨끗한 비율={clean_frac:.1f}%")
|
||||
|
||||
lum_f_clean = f_clean.mean(axis=2)
|
||||
patch_masks = {}
|
||||
for name, (x0, y0, x1, y1) in patch_boxes.items():
|
||||
m = np.zeros((h, w), dtype=bool)
|
||||
m[y0:y1, x0:x1] = True
|
||||
patch_masks[name] = m
|
||||
|
||||
baseline_hf = float(np.mean([high_freq_energy(lum_f_clean, m) for m in patch_masks.values()]))
|
||||
baseline_line = float(np.mean([line_energy(lum_front, m) for m in patch_masks.values()]))
|
||||
print(f"일반 피부 표본 기준: 고주파 에너지={baseline_hf:.3f}, 선 에너지={baseline_line:.3f}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (d): 질감 보존 — 구멍 안 고주파 에너지(F 기준) / 일반 피부 표본 평균 >= 0.7
|
||||
# ------------------------------------------------------------------
|
||||
checkD: dict[str, dict] = {}
|
||||
all_pass_d = True
|
||||
for name, side_excl in named_excl.items():
|
||||
side_visible = side_excl & ~hair_mask
|
||||
e_inside = high_freq_energy(lum_f_clean, side_visible)
|
||||
ratio_d = e_inside / baseline_hf if baseline_hf > 1e-6 else 0.0
|
||||
passed = ratio_d >= CHECKD_THRESH
|
||||
all_pass_d = all_pass_d and passed
|
||||
checkD[name] = {"insideEnergy": e_inside, "baselineEnergy": baseline_hf, "ratio": ratio_d, "pass": passed}
|
||||
print(f"검사(d) {name}: 안={e_inside:.3f} 표본기준={baseline_hf:.3f} 비율={ratio_d:.3f} (기준 >={CHECKD_THRESH}) {'OK' if passed else '[미달]'}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (e): 유령 윤곽 — 제외 영역 밖 0~12px 띠(머리카락 제외)의 선
|
||||
# 에너지(front 기준) / 일반 피부 표본 평균 <= 1.5. 머리카락 가닥은 이목구비
|
||||
# 잉크선이 아니므로 checkC와 같은 기준으로 뺀다.
|
||||
# ------------------------------------------------------------------
|
||||
checkE: dict[str, dict] = {}
|
||||
all_pass_e = True
|
||||
for name, side_excl in named_excl.items():
|
||||
band = binary_dilation(side_excl, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
|
||||
e_band = line_energy(lum_front, band)
|
||||
ratio_e = e_band / baseline_line if baseline_line > 1e-6 else 0.0
|
||||
passed = ratio_e <= CHECKE_THRESH
|
||||
all_pass_e = all_pass_e and passed
|
||||
checkE[name] = {"bandLineEnergy": e_band, "baselineLineEnergy": baseline_line, "ratio": ratio_e, "pass": passed}
|
||||
print(f"검사(e) {name}: 경계띠선에너지={e_band:.3f} 표본기준={baseline_line:.3f} 비율={ratio_e:.3f} (기준 <={CHECKE_THRESH}) {'OK' if passed else '[미달-유령윤곽]'}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검사 (다크서클 복원): 아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35 띠의 합성
|
||||
# 평균 명도가 base-front와 ±6 이내여야 한다(오케스트레이터 지시 — 다크서클
|
||||
# 해칭이 지워지면 안 된다).
|
||||
# ------------------------------------------------------------------
|
||||
checkDarkCircle: dict[str, dict] = {}
|
||||
all_pass_dc = True
|
||||
for name, eye in (("eyeLeft", eyeL), ("eyeRight", eyeR)):
|
||||
band = dark_circle_band_mask(eye, w, h)
|
||||
composite_mean = float(lum_composite[band].mean())
|
||||
front_mean = float(lum_front[band].mean())
|
||||
diff2 = abs(composite_mean - front_mean)
|
||||
passed = diff2 <= DARK_CIRCLE_MATCH_TOLERANCE
|
||||
all_pass_dc = all_pass_dc and passed
|
||||
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff2, "pass": passed}
|
||||
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff2:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
|
||||
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["faceDetail"] = {
|
||||
"browLandmarksSource": brow_source,
|
||||
"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)],
|
||||
"mouth": [round(mouth_x0, 1), round(mouth_y0, 1), round(mouth_x1, 1), round(mouth_y1, 1)],
|
||||
"moles": [{"center": [round(m["center"][0], 1), round(m["center"][1], 1)], "radius": m["radius"]} for m in cfg.moles],
|
||||
},
|
||||
"exclusionPixels": {
|
||||
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
|
||||
"browLeft": int(browL_excl.sum()), "browRight": int(browR_excl.sum()),
|
||||
"mouth": int(mouth_excl.sum()), "union": int(exclusion_mask.sum()),
|
||||
},
|
||||
"hairMaskPixels": int(hair_mask.sum()),
|
||||
"bbox": bbox,
|
||||
"textureCloneStamp": {
|
||||
"cloneTargetThreshold": CLONE_TARGET_THRESH,
|
||||
"avoidThreshold": AVOID_THRESH,
|
||||
"lineOpeningIterations": LINE_OPENING_ITER,
|
||||
"featherPx": CLONE_STAMP_FEATHER_PX,
|
||||
"badTexturePixels": int(bad_texture.sum()),
|
||||
"preservedShadowPixels": int(dark_in_excl.sum()) - int(bad_texture.sum()),
|
||||
"components": stamp_report,
|
||||
},
|
||||
"genericPatches": {name: list(box) for name, box in patch_boxes.items()},
|
||||
"genericPatchBaseline": {"highFreqEnergy": baseline_hf, "lineEnergy": baseline_line},
|
||||
"checkA_meanAbsDiff": mean_abs_a,
|
||||
"checkB_seamLumDiff": seam_diff,
|
||||
"checkC_holeDarkResiduePct": hole_dark_pct,
|
||||
"checkC_violationPixels": n_violations,
|
||||
"checkD_texturePreservation": checkD,
|
||||
"checkD_allPass": all_pass_d,
|
||||
"checkE_ghostOutline": checkE,
|
||||
"checkE_allPass": all_pass_e,
|
||||
"checkDarkCircle": checkDarkCircle,
|
||||
"checkDarkCircle_allPass": all_pass_dc,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
344
docs/avatar-art/linocut-pipeline/scripts/final_previews.py
Normal file
344
docs/avatar-art/linocut-pipeline/scripts/final_previews.py
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
"""공통 리노컷 리그 — 최종 게시 미리보기.
|
||||
|
||||
게시된 WebP(apps/web/public/avatar/v3/<publicSlug>/*.webp)를 다시 읽어 합성한다
|
||||
(원본 PNG가 아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴
|
||||
preview/v2/export-rig-report.json의 rig 정보(레이어 x,y,w,h, pivots, palette)를
|
||||
좌표 소스로 쓴다. 벡터 부위(눈·눈썹·입 등)는 없다 — faceDetail과 grain까지만
|
||||
포함한 정적 합성이다.
|
||||
|
||||
생성물: motion-{cream,cool,dark}.png, face-detail.png, closed-eyes.png, holes-texture.jpg, ghost-check.jpg
|
||||
|
||||
전제: export_rig.py가 먼저 게시를 끝내야 한다(이 스크립트가 export-rig-report.json과
|
||||
게시된 webp를 읽는다) — 파이프라인에서 가장 마지막에 돌린다.
|
||||
|
||||
실행: <venv>/python.exe final_previews.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import composite_over, rotate_rgba, to_u8, translate_rgba, build_padded_faceless # noqa: E402
|
||||
import face_detail as bfd # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
GRAIN_OPACITY = 0.35
|
||||
|
||||
BG_CREAM = (0xEE, 0xE5, 0xD3)
|
||||
BG_COOL = (0xDC, 0xE0, 0xE2)
|
||||
BG_DARK = (0x3A, 0x3A, 0x3A)
|
||||
|
||||
FRAMES = [
|
||||
("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}),
|
||||
("down10px", {"rotate": 0.0, "tx": 0.0, "ty": 10.0}),
|
||||
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
|
||||
]
|
||||
|
||||
|
||||
def load_layer(public_dir: Path, href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다."""
|
||||
im = Image.open(public_dir / f"{href_stem}.webp").convert("RGBA")
|
||||
tw, th = round(w), round(h)
|
||||
if im.size != (tw, th):
|
||||
im = im.resize((tw, th), Image.LANCZOS)
|
||||
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
canvas.paste(im, (round(x), round(y)))
|
||||
arr = np.array(canvas).astype(np.float64)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
|
||||
def polygon_alpha_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)
|
||||
from scipy.ndimage import gaussian_filter
|
||||
return np.clip(gaussian_filter(np.array(img, dtype=np.float64), sigma=2.0), 0, 255)
|
||||
|
||||
|
||||
def tile_grain(public_dir: Path, canvas_w: int, canvas_h: int, size: int) -> np.ndarray:
|
||||
grain_im = Image.open(public_dir / "paper-grain.webp").convert("RGB")
|
||||
if grain_im.size != (size, size):
|
||||
grain_im = grain_im.resize((size, size), Image.LANCZOS)
|
||||
grain = np.array(grain_im).astype(np.float64)
|
||||
ny = -(-canvas_h // size)
|
||||
nx = -(-canvas_w // size)
|
||||
tiled = np.tile(grain, (ny, nx, 1))[:canvas_h, :canvas_w, :]
|
||||
return tiled
|
||||
|
||||
|
||||
def apply_grain_multiply(rgb: np.ndarray, grain: np.ndarray, opacity: float) -> np.ndarray:
|
||||
factor = grain / 255.0
|
||||
multiplied = rgb * factor
|
||||
return np.clip(rgb * (1 - opacity) + multiplied * opacity, 0, 255)
|
||||
|
||||
|
||||
def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, int], canvas_w: int, canvas_h: int) -> np.ndarray:
|
||||
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
|
||||
canvas[..., 0], canvas[..., 1], canvas[..., 2] = bg
|
||||
canvas[..., 3] = 255.0
|
||||
canvas = composite_over(canvas, to_u8(body[0]), to_u8(body[1]))
|
||||
canvas = composite_over(canvas, to_u8(head[0]), to_u8(head[1]))
|
||||
canvas = composite_over(canvas, to_u8(face_detail[0]), to_u8(face_detail[1]))
|
||||
canvas = composite_over(canvas, to_u8(hair_front[0]), to_u8(hair_front[1]))
|
||||
return to_u8(canvas)[..., :3].astype(np.float64)
|
||||
|
||||
|
||||
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
|
||||
public_dir = cfg.public_dir
|
||||
|
||||
report_path = preview_v2_dir / "export-rig-report.json"
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
rig = report["rig"]
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
cw, ch = rig["canvas"]["w"], rig["canvas"]["h"]
|
||||
neck_pivot = tuple(rig["pivots"]["neck"])
|
||||
|
||||
def layer_of(key: str) -> tuple[np.ndarray, np.ndarray]:
|
||||
l = rig["layers"][key]
|
||||
stem = Path(l["href"]).stem
|
||||
return load_layer(public_dir, stem, l["x"], l["y"], l["w"], l["h"], cw, ch)
|
||||
|
||||
body = layer_of("body")
|
||||
head = layer_of("head")
|
||||
hair_front = layer_of("hairFront")
|
||||
face_detail_raw = layer_of("faceDetail")
|
||||
|
||||
face_oval = [tuple(p) for p in rig["faceOval"]]
|
||||
clip = polygon_alpha_mask(face_oval, cw, ch) / 255.0
|
||||
face_detail = (face_detail_raw[0], face_detail_raw[1] * clip)
|
||||
|
||||
grain_tile = tile_grain(public_dir, cw, ch, rig["grain"]["size"])
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# motion-{cream,cool,dark}.png
|
||||
# ------------------------------------------------------------------
|
||||
for bg_name, bg in (("cream", BG_CREAM), ("cool", BG_COOL), ("dark", BG_DARK)):
|
||||
frame_ims = []
|
||||
for name, t in FRAMES:
|
||||
h_rgb, h_a = to_u8(head[0]), to_u8(head[1])
|
||||
fd_rgb, fd_a = to_u8(face_detail[0]), to_u8(face_detail[1])
|
||||
hf_rgb, hf_a = to_u8(hair_front[0]), to_u8(hair_front[1])
|
||||
if t["rotate"] != 0.0:
|
||||
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], neck_pivot)
|
||||
fd_rgb, fd_a = rotate_rgba(fd_rgb, fd_a, t["rotate"], neck_pivot)
|
||||
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], neck_pivot)
|
||||
if t["tx"] != 0.0 or t["ty"] != 0.0:
|
||||
h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"])
|
||||
fd_rgb, fd_a = translate_rgba(fd_rgb, fd_a, t["tx"], t["ty"])
|
||||
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
|
||||
frame_rgb = composite_static(
|
||||
(to_u8(body[0]), to_u8(body[1])), (h_rgb, h_a), (fd_rgb, fd_a), (hf_rgb, hf_a), bg, cw, ch
|
||||
)
|
||||
frame_rgb = apply_grain_multiply(frame_rgb, grain_tile, GRAIN_OPACITY)
|
||||
im = Image.fromarray(to_u8(frame_rgb), "RGB")
|
||||
d = ImageDraw.Draw(im)
|
||||
label_color = (255, 60, 60) if bg_name != "dark" else (255, 200, 140)
|
||||
d.text((20, 20), name, fill=label_color)
|
||||
frame_ims.append(im)
|
||||
|
||||
# 세로로 긴 캔버스라 옆으로 5장 나열하면 매우 넓어지므로 절반 크기로 축소해 나열
|
||||
scale = 0.45
|
||||
sw, sh = round(cw * scale), round(ch * scale)
|
||||
gap = 10
|
||||
strip = Image.new("RGB", (sw * len(frame_ims) + gap * (len(frame_ims) - 1), sh), bg)
|
||||
x = 0
|
||||
for im in frame_ims:
|
||||
strip.paste(im.resize((sw, sh), Image.LANCZOS), (x, 0))
|
||||
x += sw + gap
|
||||
out_path = preview_v2_dir / f"motion-{bg_name}.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 제외 영역 재구성(face_detail.py와 동일한 함수·상수) — 미리보기가
|
||||
# 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다.
|
||||
# ------------------------------------------------------------------
|
||||
lm = manifest["landmarks"]
|
||||
front = np.array(Image.open(base_dir / "base-front.png").convert("RGB")).astype(np.float64)
|
||||
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
|
||||
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
|
||||
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
|
||||
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
|
||||
lum_front = front.mean(axis=2)
|
||||
import scipy.ndimage as ndi
|
||||
|
||||
yy_full, _ = np.mgrid[0:ch, 0:cw]
|
||||
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
|
||||
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
|
||||
eyeL_ink = bfd.eye_dark_hole(eyeL, lum_front, cw, ch) & eyeL_cap
|
||||
eyeR_ink = bfd.eye_dark_hole(eyeR, lum_front, cw, ch) & eyeR_cap
|
||||
eyeL_grown = eyeL_ink | bfd.grow_directional(eyeL_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeL_ink, dx=-bfd.EYE_OUTER_EXT_PX)
|
||||
eyeR_grown = eyeR_ink | bfd.grow_directional(eyeR_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeR_ink, dx=bfd.EYE_OUTER_EXT_PX)
|
||||
eyeL_core_excl = ndi.binary_dilation(eyeL_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeL_cap
|
||||
eyeR_core_excl = ndi.binary_dilation(eyeR_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeR_cap
|
||||
eyeL_lower_excl = ndi.binary_dilation(
|
||||
bfd.lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
|
||||
)
|
||||
eyeR_lower_excl = ndi.binary_dilation(
|
||||
bfd.lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
|
||||
)
|
||||
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
|
||||
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
|
||||
|
||||
browL_ink = bfd.brow_dark_hole(browL, lum_front, cw, ch)
|
||||
browR_ink = bfd.brow_dark_hole(browR, lum_front, cw, ch)
|
||||
browL_band = bfd.eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
|
||||
browR_band = bfd.eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
|
||||
browL_excl = ndi.binary_dilation(browL_ink | browL_band, iterations=bfd.BROW_FINAL_DILATE_PX)
|
||||
browR_excl = ndi.binary_dilation(browR_ink | browR_band, iterations=bfd.BROW_FINAL_DILATE_PX)
|
||||
|
||||
mouth_ink = bfd.mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, cw, ch)
|
||||
mouth_grown = (
|
||||
mouth_ink
|
||||
| bfd.grow_directional(mouth_ink, dx=-bfd.MOUTH_CORNER_EXT_PX)
|
||||
| bfd.grow_directional(mouth_ink, dx=bfd.MOUTH_CORNER_EXT_PX)
|
||||
| bfd.grow_directional(mouth_ink, dy=bfd.MOUTH_SHADOW_EXT_PX)
|
||||
)
|
||||
mouth_excl = ndi.binary_dilation(mouth_grown, iterations=bfd.MOUTH_FINAL_DILATE_PX)
|
||||
|
||||
named_holes = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
|
||||
hole_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
|
||||
|
||||
def mask_outline(mask: np.ndarray) -> np.ndarray:
|
||||
return mask & ~ndi.binary_erosion(mask, iterations=2)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb),
|
||||
# base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다.
|
||||
# ------------------------------------------------------------------
|
||||
f_arr = np.array(build_padded_faceless(base_dir)).astype(np.float64)
|
||||
# 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에
|
||||
# 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가
|
||||
# 어디서나 정의됨)를 직접 읽어 이 문제를 피한다.
|
||||
fd_rgb_full = np.array(Image.open(layers_v2_dir / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3]
|
||||
|
||||
outline_overlay = front.copy()
|
||||
outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0])
|
||||
|
||||
rows = []
|
||||
zoom = 2
|
||||
hole_pad = 16
|
||||
for name, m in named_holes.items():
|
||||
ys, xs = np.where(m)
|
||||
bx0, by0, bx1, by1 = int(xs.min()) - hole_pad, int(ys.min()) - hole_pad, int(xs.max()) + 1 + hole_pad, int(ys.max()) + 1 + hole_pad
|
||||
box = (max(0, bx0), max(0, by0), min(cw, bx1), min(ch, by1))
|
||||
f_crop = Image.fromarray(to_u8(f_arr), "RGB").crop(box)
|
||||
fill_crop = Image.fromarray(to_u8(fd_rgb_full), "RGB").crop(box)
|
||||
front_crop = Image.fromarray(to_u8(front), "RGB").crop(box)
|
||||
outline_crop = Image.fromarray(to_u8(outline_overlay), "RGB").crop(box)
|
||||
pw2, ph2 = f_crop.size
|
||||
f_crop = f_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
fill_crop = fill_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
front_crop = front_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
outline_crop = outline_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
|
||||
row = Image.new("RGB", (pw2 * zoom * 4 + 30, ph2 * zoom + 20), (255, 255, 255))
|
||||
d = ImageDraw.Draw(row)
|
||||
for i, (label, im) in enumerate([("F 원본", f_crop), ("메운 결과", fill_crop), ("base-front", front_crop), ("제외영역 윤곽", outline_crop)]):
|
||||
row.paste(im, (i * (pw2 * zoom + 10), 20))
|
||||
d.text((i * (pw2 * zoom + 10), 2), f"{name}: {label}", fill=(0, 0, 0))
|
||||
rows.append(row)
|
||||
max_w = max(r.width for r in rows)
|
||||
total_h = sum(r.height for r in rows) + 10 * (len(rows) - 1)
|
||||
holes_tex = Image.new("RGB", (max_w, total_h), (255, 255, 255))
|
||||
y = 0
|
||||
for r in rows:
|
||||
holes_tex.paste(r, (0, y))
|
||||
y += r.height + 10
|
||||
holes_tex_path = preview_v2_dir / "holes-texture.jpg"
|
||||
holes_tex.convert("RGB").save(holes_tex_path, "JPEG", quality=90)
|
||||
print(f"저장: {holes_tex_path}")
|
||||
|
||||
fd_bbox = manifest["layersV2"]["faceDetail"]["bbox"]
|
||||
fx0, fy0, fx1, fy1 = fd_bbox
|
||||
pad = 20
|
||||
fx0, fy0 = max(0, fx0 - pad), max(0, fy0 - pad)
|
||||
fx1, fy1 = min(cw, fx1 + pad), min(ch, fy1 + pad)
|
||||
|
||||
# (1) faceDetail 단독(크림 배경 위)
|
||||
fd_on_cream = np.zeros((ch, cw, 3), dtype=np.float64)
|
||||
fd_on_cream[...] = BG_CREAM
|
||||
fd_on_cream = composite_over(
|
||||
np.dstack([fd_on_cream, np.full((ch, cw), 255.0)]), to_u8(face_detail[0]), to_u8(face_detail[1])
|
||||
)[..., :3]
|
||||
|
||||
# (2) 구멍 표시(빨강 오버레이)
|
||||
hole_overlay = front.copy()
|
||||
hole_overlay[hole_mask] = hole_overlay[hole_mask] * 0.4 + np.array([230.0, 40.0, 40.0]) * 0.6
|
||||
|
||||
# (3) 정지 합성
|
||||
static_full = composite_static(body, head, face_detail, hair_front, BG_CREAM, cw, ch)
|
||||
static_full = apply_grain_multiply(static_full, grain_tile, GRAIN_OPACITY)
|
||||
|
||||
# (4) base-front 비교는 front 그대로
|
||||
|
||||
panels = [
|
||||
("faceDetail 단독", Image.fromarray(to_u8(fd_on_cream), "RGB")),
|
||||
("구멍 표시", Image.fromarray(to_u8(hole_overlay), "RGB")),
|
||||
("정지 합성", Image.fromarray(to_u8(static_full), "RGB")),
|
||||
("base-front", Image.fromarray(to_u8(front), "RGB")),
|
||||
]
|
||||
crop_box = (int(fx0), int(fy0), int(fx1), int(fy1))
|
||||
cropped = [im.crop(crop_box) for _, im in panels]
|
||||
pw, ph = cropped[0].size
|
||||
strip = Image.new("RGB", (pw * 4 + 30, ph + 24), (255, 255, 255))
|
||||
x = 0
|
||||
for (label, _), im in zip(panels, cropped):
|
||||
strip.paste(im, (x, 24))
|
||||
d = ImageDraw.Draw(strip)
|
||||
d.text((x, 4), label, fill=(0, 0, 0))
|
||||
x += pw + 10
|
||||
out_path = preview_v2_dir / "face-detail.png"
|
||||
strip.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# closed-eyes.png: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대
|
||||
# (머리 영역 중심 기준 상대 좌표 — 결과물 회귀 대상이 아닌 미리보기 전용)
|
||||
# ------------------------------------------------------------------
|
||||
eyeL_out, eyeR_out = eyeL["outerCorner"], eyeR["outerCorner"]
|
||||
eye_cx = (eyeL_out[0] + eyeR_out[0]) / 2.0
|
||||
eye_box = (int(eye_cx - 210), int(browL["peak"][1] - 40), int(eye_cx + 210), int(eyeL["lowerLidBottom"][1] + 100))
|
||||
mouth_cx = (mcL[0] + mcR[0]) / 2.0
|
||||
mouth_box = (int(mouth_cx - 135), int(upLip[1] - 43), int(mouth_cx + 135), int(loLip[1] + 78))
|
||||
eye_crop = Image.fromarray(to_u8(static_full), "RGB").crop(eye_box)
|
||||
mouth_crop = Image.fromarray(to_u8(static_full), "RGB").crop(mouth_box)
|
||||
zoom = 2
|
||||
eye_crop = eye_crop.resize((eye_crop.width * zoom, eye_crop.height * zoom), Image.LANCZOS)
|
||||
mouth_w = eye_crop.width
|
||||
mouth_h = round(mouth_crop.height * (mouth_w / mouth_crop.width))
|
||||
mouth_crop = mouth_crop.resize((mouth_w, mouth_h), Image.LANCZOS)
|
||||
out_im = Image.new("RGB", (mouth_w, eye_crop.height + mouth_h + 10), (255, 255, 255))
|
||||
out_im.paste(eye_crop, (0, 0))
|
||||
out_im.paste(mouth_crop, (0, eye_crop.height + 10))
|
||||
out_path = preview_v2_dir / "closed-eyes.png"
|
||||
out_im.save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한
|
||||
# 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도.
|
||||
# ------------------------------------------------------------------
|
||||
ghost_box = (int(eye_cx - 240), int(browL["peak"][1] - 70), int(eye_cx + 260), int(loLip[1] + 110))
|
||||
ghost_crop = Image.fromarray(to_u8(static_full), "RGB").crop(ghost_box)
|
||||
ghost_crop = ghost_crop.resize((ghost_crop.width * 2, ghost_crop.height * 2), Image.LANCZOS)
|
||||
out_path = preview_v2_dir / "ghost-check.jpg"
|
||||
ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
170
docs/avatar-art/linocut-pipeline/scripts/jaw_pieces.py
Normal file
170
docs/avatar-art/linocut-pipeline/scripts/jaw_pieces.py
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
"""공통 리노컷 리그 — 턱 조각(jaw-head·jaw-detail) 생성(결정문 §8.4 하관 띠 변형).
|
||||
|
||||
하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는
|
||||
변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 head·faceDetail 이미지를
|
||||
매 프레임 다시 그리면 비용이 크므로, 변형이 실제로 필요한 사각형만 미리 잘라 둔다.
|
||||
|
||||
영역:
|
||||
- y: noseTip.y − 50 − 10 부터 chinTip.y + 40 까지.
|
||||
- x: 얼굴 윤곽(faceOval, export_rig.py가 계산하는 36점 루프)을 바깥으로 18px 넓힌
|
||||
다각형의, 이 y 범위 안에서의 가로 범위.
|
||||
|
||||
변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라
|
||||
layers/v2/jaw-{head,detail}.png로 저장한다.
|
||||
|
||||
전제: layers_v2.py, face_detail.py가 먼저 layers/v2/{head,face-detail}.png를
|
||||
만들어 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe jaw_pieces.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
|
||||
from scipy import ndimage
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
NOSE_TO_TOP_PX = 50.0 + 10.0 # y0 = noseTip.y - (이 값)
|
||||
CHIN_TO_BOTTOM_PX = 40.0 # y1 = chinTip.y + (이 값)
|
||||
OVAL_OUTSET_PX = 18.0 # 얼굴 윤곽을 바깥으로 넓히는 폭
|
||||
|
||||
|
||||
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"]
|
||||
nose_tip = tuple(lm["noseTip"])
|
||||
chin_tip = tuple(lm["chinTip"])
|
||||
print(f"noseTip={nose_tip} chinTip={chin_tip}")
|
||||
|
||||
points = detect_face_landmarks(base_dir / "base-front.png")
|
||||
face_oval = compute_face_oval(points)
|
||||
|
||||
canvas_w, canvas_h = manifest["canvas"]["w"], manifest["canvas"]["h"]
|
||||
|
||||
y0f = nose_tip[1] - NOSE_TO_TOP_PX
|
||||
y1f = chin_tip[1] + CHIN_TO_BOTTOM_PX
|
||||
y0i, y1i = int(math.floor(y0f)), int(math.ceil(y1f))
|
||||
print(f"y 범위 = [{y0f:.2f}, {y1f:.2f}] -> 정수 [{y0i}, {y1i}) (h={y1i-y0i})")
|
||||
|
||||
oval_mask = np.zeros((canvas_h, canvas_w), dtype=np.uint8)
|
||||
oval_pts = np.array([[round(p[0]), round(p[1])] for p in face_oval], dtype=np.int32)
|
||||
cv2.fillPoly(oval_mask, [oval_pts], 1)
|
||||
oval_mask_b = oval_mask.astype(bool)
|
||||
dist_out = ndimage.distance_transform_edt(~oval_mask_b)
|
||||
dilated = oval_mask_b | (dist_out <= OVAL_OUTSET_PX)
|
||||
|
||||
if y0i <= 0 or y1i >= canvas_h:
|
||||
raise SystemExit(f"[중단] y 범위가 캔버스 경계에 닿았다: [{y0i},{y1i}) canvas_h={canvas_h}")
|
||||
|
||||
band = dilated[y0i:y1i, :]
|
||||
cols = np.where(band.any(axis=0))[0]
|
||||
if len(cols) == 0:
|
||||
raise SystemExit("[중단] 얼굴 윤곽 확장 다각형이 해당 y 범위에서 비어 있다.")
|
||||
x0i, x1i = int(cols.min()), int(cols.max()) + 1
|
||||
if x0i <= 0 or x1i >= canvas_w:
|
||||
raise SystemExit(f"[중단] x 범위가 캔버스 경계에 닿았다: [{x0i},{x1i}) canvas_w={canvas_w}")
|
||||
print(f"x 범위(얼굴 윤곽 {OVAL_OUTSET_PX}px 확장, y 범위 안) = [{x0i}, {x1i}) (w={x1i-x0i})")
|
||||
|
||||
# 대조: 다각형 경계가 이 y 범위에서 거의 수직인지 확인 — 확장 없이 구한 x 범위에 단순히
|
||||
# OVAL_OUTSET_PX를 더/뺀 값과 비교해 기록만 한다(설계 검증용, 결과에는 dilated만 쓴다).
|
||||
band_raw = oval_mask_b[y0i:y1i, :]
|
||||
cols_raw = np.where(band_raw.any(axis=0))[0]
|
||||
x0_raw, x1_raw = int(cols_raw.min()), int(cols_raw.max()) + 1
|
||||
naive_x0, naive_x1 = x0_raw - int(OVAL_OUTSET_PX), x1_raw + int(OVAL_OUTSET_PX)
|
||||
print(f"대조(단순 폭 확장) = [{naive_x0}, {naive_x1}) vs 거리변환 [{x0i}, {x1i}) "
|
||||
f"차이=({x0i-naive_x0},{x1i-naive_x1})")
|
||||
|
||||
head_full = np.array(Image.open(layers_v2_dir / "head.png").convert("RGBA"))
|
||||
detail_full = np.array(Image.open(layers_v2_dir / "face-detail.png").convert("RGBA"))
|
||||
if head_full.shape[:2] != (canvas_h, canvas_w) or detail_full.shape[:2] != (canvas_h, canvas_w):
|
||||
raise SystemExit(
|
||||
f"[중단] head/face-detail 크기가 캔버스와 다르다: head={head_full.shape[:2]} "
|
||||
f"detail={detail_full.shape[:2]} canvas=({canvas_h},{canvas_w})"
|
||||
)
|
||||
|
||||
pieces = {"head": (head_full, layers_v2_dir / "jaw-head.png"),
|
||||
"detail": (detail_full, layers_v2_dir / "jaw-detail.png")}
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
checks: dict = {}
|
||||
for key, (src, out_path) in pieces.items():
|
||||
crop = src[y0i:y1i, x0i:x1i].copy()
|
||||
Image.fromarray(crop, "RGBA").save(out_path)
|
||||
reloaded = np.array(Image.open(out_path).convert("RGBA"))
|
||||
max_diff = int(np.abs(reloaded.astype(np.int32) - crop.astype(np.int32)).max())
|
||||
ok = max_diff == 0
|
||||
print(f"저장: {out_path} size={crop.shape[1]}x{crop.shape[0]} "
|
||||
f"검사(1) 원본 대비 PNG 왕복 최대차(알파 포함)={max_diff} {'OK' if ok else '[실패]'}")
|
||||
checks[key] = {
|
||||
"pngRoundTripMaxDiff": max_diff,
|
||||
"pngRoundTripOk": ok,
|
||||
"pngFileSize": out_path.stat().st_size,
|
||||
}
|
||||
|
||||
_save_evidence(pieces, x0i, y0i, x1i, y1i, preview_v2_dir)
|
||||
|
||||
manifest["jaw"] = {
|
||||
"designVersion": "A5(결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
|
||||
"noseTip": list(nose_tip),
|
||||
"chinTip": list(chin_tip),
|
||||
"yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40",
|
||||
"noseToTopPx": NOSE_TO_TOP_PX,
|
||||
"chinToBottomPx": CHIN_TO_BOTTOM_PX,
|
||||
"ovalOutsetPx": OVAL_OUTSET_PX,
|
||||
"faceOvalSource": "export_rig.detect_face_landmarks + compute_face_oval(base-front.png, 결정적)",
|
||||
"bboxCanvas": [x0i, y0i, x1i, y1i],
|
||||
"naiveXRangeForComparison": [naive_x0, naive_x1],
|
||||
"checks": checks,
|
||||
"evidenceImage": "preview/v2/jaw-pieces.jpg",
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def _save_evidence(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int, preview_v2_dir: Path) -> None:
|
||||
"""jaw-head | jaw-detail(체커 배경), 원본 배율."""
|
||||
panels = []
|
||||
for key in ("head", "detail"):
|
||||
src, _out_path = pieces[key]
|
||||
crop = src[y0i:y1i, x0i:x1i]
|
||||
rgb = crop[..., :3].astype(np.float64)
|
||||
a = (crop[..., 3].astype(np.float64) / 255.0)[..., None]
|
||||
check = np.indices(a.shape[:2])
|
||||
checker = ((check[0] // 10 + check[1] // 10) % 2) * 60 + 180
|
||||
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
|
||||
out = rgb * a + checker3 * (1.0 - a)
|
||||
panels.append(Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)))
|
||||
|
||||
gap = 12
|
||||
max_h = max(p.height for p in panels)
|
||||
total_w = sum(p.width for p in panels) + gap * (len(panels) - 1)
|
||||
combined = Image.new("RGB", (total_w, max_h), (255, 255, 255))
|
||||
x = 0
|
||||
for p in panels:
|
||||
combined.paste(p, (x, 0))
|
||||
x += p.width + gap
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = preview_v2_dir / "jaw-pieces.jpg"
|
||||
combined.save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
237
docs/avatar-art/linocut-pipeline/scripts/landmarks.py
Normal file
237
docs/avatar-art/linocut-pipeline/scripts/landmarks.py
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
"""공통 리노컷 리그 — base-front.png 얼굴 랜드마크 검출.
|
||||
|
||||
mediapipe FaceLandmarker로 manifest.json의 landmarks 섹션을 채우고
|
||||
preview/landmarks.png를 만든다.
|
||||
|
||||
좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는
|
||||
이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다.
|
||||
|
||||
주의(재실행 순서): 이 스크립트는 manifest.landmarks를 통째로 다시 쓴다.
|
||||
brow_centerline.py가 그중 eyebrowLeft/Right를 더 정확한 중심선으로 보정하므로,
|
||||
landmarks.py를 brow_centerline.py보다 "나중에" 다시 돌리면 그 보정이 사라진다.
|
||||
단계를 하나만 다시 돌릴 때는 이 순서를 지켜야 한다(run_pipeline.py가 기본
|
||||
순서로는 지켜 주지만, --only로 landmarks만 돌리면 뒤이어 brow_centerline도
|
||||
다시 돌려야 한다).
|
||||
|
||||
실행: <venv>/python.exe landmarks.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mediapipe as mp
|
||||
import numpy as np
|
||||
from mediapipe.tasks import python as mp_python
|
||||
from mediapipe.tasks.python import vision
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
MODEL_PATH = SCRIPTS_DIR / "_models" / "face_landmarker.task"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
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}
|
||||
|
||||
NOSE_TIP_IDX = 1
|
||||
CHIN_IDX = 152
|
||||
MOUTH_CORNER_A_IDX = 61
|
||||
MOUTH_CORNER_B_IDX = 291
|
||||
UPPER_LIP_TOP_IDX = 0
|
||||
LOWER_LIP_BOTTOM_IDX = 17
|
||||
FACE_EDGE_A_IDX = 234
|
||||
FACE_EDGE_B_IDX = 454
|
||||
|
||||
IRIS_A = {"center": 468, "ring": [469, 470, 471, 472]}
|
||||
IRIS_B = {"center": 473, "ring": [474, 475, 476, 477]}
|
||||
|
||||
|
||||
def detect(base_front: Path, preview_path: Path) -> dict | None:
|
||||
if not MODEL_PATH.exists():
|
||||
return None
|
||||
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH))
|
||||
options = vision.FaceLandmarkerOptions(
|
||||
base_options=base_options,
|
||||
running_mode=vision.RunningMode.IMAGE,
|
||||
num_faces=1,
|
||||
)
|
||||
im = Image.open(base_front).convert("RGB")
|
||||
w, h = im.size
|
||||
arr = np.array(im)
|
||||
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
|
||||
|
||||
with vision.FaceLandmarker.create_from_options(options) as landmarker:
|
||||
result = landmarker.detect(mp_image)
|
||||
|
||||
if not result.face_landmarks:
|
||||
return None
|
||||
|
||||
lm = result.face_landmarks[0]
|
||||
pts = [(p.x * w, p.y * h) for p in lm]
|
||||
print(f"검출된 랜드마크 개수: {len(pts)}")
|
||||
|
||||
def pt(idx: int) -> list[float]:
|
||||
x, y = pts[idx]
|
||||
return [round(x, 2), round(y, 2)]
|
||||
|
||||
def screen_label(idx_a: int, idx_b: int) -> tuple[int, int]:
|
||||
"""두 인덱스를 화면 기준 left(작은 x)/right(큰 x)로 정렬해 반환."""
|
||||
xa = pts[idx_a][0]
|
||||
xb = pts[idx_b][0]
|
||||
return (idx_a, idx_b) if xa < xb else (idx_b, idx_a)
|
||||
|
||||
left_eye_idx, right_eye_idx = screen_label(EYE_A_IDX["outer"], EYE_B_IDX["outer"])
|
||||
eyeA_is_left = left_eye_idx == EYE_A_IDX["outer"]
|
||||
eyeL = EYE_A_IDX if eyeA_is_left else EYE_B_IDX
|
||||
eyeR = EYE_B_IDX if eyeA_is_left else EYE_A_IDX
|
||||
|
||||
irisL, irisR = (IRIS_A, IRIS_B) if eyeA_is_left else (IRIS_B, IRIS_A)
|
||||
|
||||
def iris_stats(iris: dict) -> dict:
|
||||
cx, cy = pts[iris["center"]]
|
||||
radii = [
|
||||
float(np.hypot(pts[i][0] - cx, pts[i][1] - cy)) for i in iris["ring"]
|
||||
]
|
||||
return {"center": [round(cx, 2), round(cy, 2)], "radius": round(float(np.mean(radii)), 2)}
|
||||
|
||||
browL_first_x = pts[RIGHT_EYEBROW_IDX[0]][0]
|
||||
browB_first_x = pts[LEFT_EYEBROW_IDX[0]][0]
|
||||
browSetL, browSetR = (
|
||||
(RIGHT_EYEBROW_IDX, LEFT_EYEBROW_IDX)
|
||||
if browL_first_x < browB_first_x
|
||||
else (LEFT_EYEBROW_IDX, RIGHT_EYEBROW_IDX)
|
||||
)
|
||||
|
||||
def brow_stats(idx_set: list[int], face_cx: float) -> dict:
|
||||
xs = [pts[i][0] for i in idx_set]
|
||||
ys = [pts[i][1] for i in idx_set]
|
||||
peak_i = idx_set[int(np.argmin(ys))]
|
||||
inner_i = min(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
|
||||
outer_i = max(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
|
||||
return {
|
||||
"inner": pt(inner_i),
|
||||
"peak": pt(peak_i),
|
||||
"outer": pt(outer_i),
|
||||
}
|
||||
|
||||
face_cx = pts[NOSE_TIP_IDX][0]
|
||||
|
||||
mouthL_idx, mouthR_idx = screen_label(MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX)
|
||||
faceEdgeL_idx, faceEdgeR_idx = screen_label(FACE_EDGE_A_IDX, FACE_EDGE_B_IDX)
|
||||
|
||||
landmarks = {
|
||||
"coordSystem": "screen (image pixel: x=0 좌측, y=0 상단; left=작은 x, right=큰 x; 인물 해부학적 좌우 아님)",
|
||||
"eyeLeft": {
|
||||
"innerCorner": pt(eyeL["inner"]),
|
||||
"outerCorner": pt(eyeL["outer"]),
|
||||
"upperLidTop": pt(eyeL["top"]),
|
||||
"lowerLidBottom": pt(eyeL["bottom"]),
|
||||
"iris": iris_stats(irisL),
|
||||
},
|
||||
"eyeRight": {
|
||||
"innerCorner": pt(eyeR["inner"]),
|
||||
"outerCorner": pt(eyeR["outer"]),
|
||||
"upperLidTop": pt(eyeR["top"]),
|
||||
"lowerLidBottom": pt(eyeR["bottom"]),
|
||||
"iris": iris_stats(irisR),
|
||||
},
|
||||
"eyebrowLeft": brow_stats(browSetL, face_cx),
|
||||
"eyebrowRight": brow_stats(browSetR, face_cx),
|
||||
"noseTip": pt(NOSE_TIP_IDX),
|
||||
"mouthCornerLeft": pt(mouthL_idx),
|
||||
"mouthCornerRight": pt(mouthR_idx),
|
||||
"upperLipTopCenter": pt(UPPER_LIP_TOP_IDX),
|
||||
"lowerLipBottomCenter": pt(LOWER_LIP_BOTTOM_IDX),
|
||||
"chinTip": pt(CHIN_IDX),
|
||||
"faceWidthAtEyeLevelLeft": pt(faceEdgeL_idx),
|
||||
"faceWidthAtEyeLevelRight": pt(faceEdgeR_idx),
|
||||
}
|
||||
|
||||
draw_preview(im, landmarks, preview_path)
|
||||
return landmarks
|
||||
|
||||
|
||||
def draw_preview(im: Image.Image, landmarks: dict, preview_path: Path) -> None:
|
||||
canvas = im.convert("RGB").copy()
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
try:
|
||||
font = ImageFont.truetype("arial.ttf", 13)
|
||||
except Exception:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
GROUP_COLORS = {
|
||||
"eyeLeft": (220, 0, 0),
|
||||
"eyeRight": (0, 120, 220),
|
||||
"eyebrowLeft": (180, 0, 180),
|
||||
"eyebrowRight": (0, 150, 80),
|
||||
"noseTip": (255, 140, 0),
|
||||
"mouthCornerLeft": (200, 0, 100),
|
||||
"mouthCornerRight": (0, 100, 200),
|
||||
"upperLipTopCenter": (150, 100, 0),
|
||||
"lowerLipBottomCenter": (0, 150, 150),
|
||||
"chinTip": (100, 60, 0),
|
||||
"faceWidthAtEyeLevelLeft": (120, 120, 120),
|
||||
"faceWidthAtEyeLevelRight": (120, 120, 120),
|
||||
}
|
||||
|
||||
def dot(xy: list[float], label: str, color=(255, 0, 0)) -> None:
|
||||
x, y = xy
|
||||
r = 5
|
||||
draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=2)
|
||||
draw.text((x + 7, y - 7), label, fill=color, font=font)
|
||||
|
||||
flat = []
|
||||
for group, val in landmarks.items():
|
||||
if group == "coordSystem":
|
||||
continue
|
||||
if isinstance(val, list):
|
||||
flat.append((val, group))
|
||||
elif isinstance(val, dict):
|
||||
for k, v in val.items():
|
||||
if isinstance(v, list):
|
||||
flat.append((v, f"{group}.{k}"))
|
||||
elif isinstance(v, dict) and "center" in v:
|
||||
flat.append((v["center"], f"{group}.iris.center"))
|
||||
|
||||
for xy, label in flat:
|
||||
group = label.split(".")[0]
|
||||
dot(xy, label, GROUP_COLORS.get(group, (255, 0, 0)))
|
||||
|
||||
canvas.save(preview_path)
|
||||
print(f"landmarks 미리보기 저장: {preview_path}")
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_front = cfg.base_dir / "base-front.png"
|
||||
manifest_path = cfg.manifest_path
|
||||
preview_path = cfg.preview_dir / "landmarks.png"
|
||||
cfg.preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest = {}
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
result = detect(base_front, preview_path)
|
||||
if result is None:
|
||||
manifest["landmarks"] = None
|
||||
manifest["landmarkDetector"] = None
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("[검출 실패] mediapipe FaceLandmarker가 얼굴을 찾지 못했다. manifest.landmarks=null로 기록.")
|
||||
return 1
|
||||
|
||||
manifest["landmarks"] = result
|
||||
manifest["landmarkDetector"] = f"mediapipe FaceLandmarker (tasks) {mp.__version__}, model=face_landmarker(float16, v1)"
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print("manifest.json landmarks 섹션 기록 완료")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
669
docs/avatar-art/linocut-pipeline/scripts/layers_v2.py
Normal file
669
docs/avatar-art/linocut-pipeline/scripts/layers_v2.py
Normal file
|
|
@ -0,0 +1,669 @@
|
|||
"""공통 리노컷 리그 — v2 레이어 보정(2단계-B-1a (1)(2)).
|
||||
|
||||
segmentation.py가 만든 layers/{body,head,hairFront}.png(v1)을 다시 만들지 않고,
|
||||
같은 분할·랜드마크 계산을 재실행해 다음 두 결함만 보정한 layers/v2/*.png를 만든다.
|
||||
|
||||
(1) body 턱 밑 밝은 띠: head_mask가 덮는 목 상단 영역(regen raw/body.png 채움)의
|
||||
질감이 실제로 보이는 목 그늘(chin_line 바로 아래)과 이어지지 않는다.
|
||||
-> 그 영역을 raw 채움 대신, 보이는 목 그늘 띠를 반사 + 행 방향 지터로 위로
|
||||
연장한 질감으로 대체한다(목 영역에는 더 이상 raw/body.png를 쓰지 않는다).
|
||||
|
||||
(2) head/hairFront 잔머리 halo: 머리카락 바깥 경계에서 알파가 이진 분할 마스크를
|
||||
그대로 따라가며 가느다란 잔머리를 끊어 점선으로 만들고, 마스크 안쪽 경계의
|
||||
페더 픽셀은 원본이 이미 종이색과 섞여 있어 다른 배경 위에서 halo로 보인다.
|
||||
-> 경계 띠(바깥 40px 이내 배경 분류 + 안쪽 6px)의 알파를 명도 기반
|
||||
smoothstep(205->150)으로 다시 계산하고, RGB를 종이색을 뺀 잉크색으로
|
||||
unpremultiply한다. 60px 미만 미연결 조각은 제거한다.
|
||||
또한 모든 레이어의 부분 알파 경계 픽셀 전반에 일반 unpremultiply(halo 제거)를
|
||||
적용해 배경색이 섞여 나오지 않게 한다.
|
||||
|
||||
(2c) hairFront 턱선 오분류 조각 제거: 입을 크게 벌리면 head의 턱선과 별개로
|
||||
hairFront에도 같은 턱선 모양 잉크가 겹쳐 보이던 결함의 원인은 머리카락
|
||||
분할이 입 중심 아래 짙은 턱선 그림자를 머리카락으로 잘못 분류한 것이다.
|
||||
mediapipe FaceLandmarker의 입술 안쪽 중심(13/14) y 아래, faceOval 경계
|
||||
14px 이내에서 페더 간극(<=3px)으로 이어지는 성분을 하나로 묶어 폭/높이
|
||||
비율을 본다. 턱선을 따라 가로로 긴(비율>1) 성분만 지우고, 세로로 늘어진
|
||||
실제 머리카락 가닥(비율<=1)은 남긴다.
|
||||
|
||||
실행: <venv>/python.exe layers_v2.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import (
|
||||
binary_dilation, binary_erosion, distance_transform_edt, gaussian_filter, label,
|
||||
)
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import ( # noqa: E402
|
||||
build_padded_faceless, detect_face_landmarks, run_segmentation, refine_hair_edge,
|
||||
polygon_mask, feather_bool_mask, chroma_key, to_u8, alpha_bbox, green_residue_count,
|
||||
composite_over, rotate_rgba, translate_rgba,
|
||||
FACE_OVAL_LOOP, FACE_OVAL_SCALE, CHIN_MARGIN, FEATHER_PX, FILL_FEATHER_PX, CREAM_BG,
|
||||
)
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
HAIR_EDGE_BAND_OUT_PX = 40
|
||||
HAIR_EDGE_BAND_IN_PX = 6
|
||||
LUM_PAPER = 205.0
|
||||
LUM_INK = 150.0
|
||||
MIN_FRAGMENT_AREA = 60
|
||||
|
||||
RNG_SEED = 20260930
|
||||
|
||||
# --- (2c) hairFront 턱선 오분류 조각 제거 -----------------------------------
|
||||
MOUTH_CENTER_INNER_UPPER_IDX = 13
|
||||
MOUTH_CENTER_INNER_LOWER_IDX = 14
|
||||
JAW_INK_DIST_TO_OVAL_PX = 14.0
|
||||
JAW_INK_MERGE_PX = 3
|
||||
JAW_INK_ASPECT_MIN = 1.0
|
||||
|
||||
# --- (2b) 머리카락 안쪽 불투명 종이 틈 보정 상수 ----------------------------
|
||||
HAIR_PAPER_DILATE_PX = 6
|
||||
PAPER_COLOR_DIST = 40.0
|
||||
PAPER_LUM_THRESH = 195.0
|
||||
PAPER_SAT_THRESH = 40.0
|
||||
HAIR_PAPER_OPAQUE_ALPHA = 0.95
|
||||
|
||||
# 머리카락 경계·틈 보정에서 알파가 거의 0인 자리를 채우는 잉크 근접 폴백색.
|
||||
# export_rig.py 팔레트 ink 산출과 같은 "머리카락 어두운 덩어리" 개념이지만,
|
||||
# 여기서는 팔레트 표본(사람이 고른 상자)을 쓰지 않고 고정값을 쓴다 — 이 자리는
|
||||
# 알파가 3/255 미만이라 화면에 거의 안 보이고, 전부 unpremultiply가 지배한다.
|
||||
INK_FALLBACK_RGB = np.array([30.0, 31.0, 31.0]) # #1E1F1F
|
||||
|
||||
|
||||
def smoothstep(t: np.ndarray) -> np.ndarray:
|
||||
t = np.clip(t, 0.0, 1.0)
|
||||
return t * t * (3.0 - 2.0 * t)
|
||||
|
||||
|
||||
def unpremultiply(rgb: np.ndarray, alpha01: np.ndarray, bg: np.ndarray, eps: float = 0.06) -> np.ndarray:
|
||||
"""observed = fg*a + bg*(1-a) 를 풀어 fg를 복원한다. a가 아주 작을 때는 나눗셈을
|
||||
eps로 바닥을 깔아 안정화한다(그 자리는 최종 알파가 작아 안 보이므로 무해)."""
|
||||
a = np.clip(alpha01, 0.0, 1.0)[..., None]
|
||||
denom = np.maximum(a, eps)
|
||||
fg = (rgb - bg[None, None, :] * (1.0 - a)) / denom
|
||||
return np.clip(fg, 0.0, 255.0)
|
||||
|
||||
|
||||
def recompute_hair_edge_band(
|
||||
rgb: np.ndarray, alpha: np.ndarray, mask: np.ndarray, category_mask: np.ndarray, ink_rgb: np.ndarray
|
||||
) -> tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""머리카락 마스크 경계 띠(바깥 40px 배경 + 안쪽 6px)의 알파·RGB를 다시 계산한다."""
|
||||
dil = binary_dilation(mask, iterations=HAIR_EDGE_BAND_OUT_PX)
|
||||
band_outer = dil & ~mask & (category_mask == 0)
|
||||
ero = binary_erosion(mask, iterations=HAIR_EDGE_BAND_IN_PX)
|
||||
band_inner = mask & ~ero
|
||||
band = band_outer | band_inner
|
||||
|
||||
lum = rgb.mean(axis=2)
|
||||
t = (LUM_PAPER - lum) / (LUM_PAPER - LUM_INK)
|
||||
alpha_new_band = smoothstep(t) * 255.0
|
||||
|
||||
out_rgb = rgb.copy()
|
||||
out_alpha = alpha.copy()
|
||||
out_alpha[band] = alpha_new_band[band]
|
||||
|
||||
a01 = out_alpha.astype(np.float64) / 255.0
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG_ARR)
|
||||
# 아주 어두운(순수 잉크에 가까운) 픽셀은 원본 유지, 나머지 band는 unpremultiply 결과 사용.
|
||||
out_rgb[band] = unpre[band]
|
||||
# ink 근접 폴백: 알파가 거의 0인 자리는 안 보이므로 잉크색으로 채워 안정화.
|
||||
near_zero = band & (out_alpha < 3)
|
||||
out_rgb[near_zero] = ink_rgb
|
||||
|
||||
report = {
|
||||
"bandOuterPixels": int(band_outer.sum()),
|
||||
"bandInnerPixels": int(band_inner.sum()),
|
||||
}
|
||||
return out_rgb, out_alpha, report
|
||||
|
||||
|
||||
def remove_small_fragments(alpha: np.ndarray, min_area: int = MIN_FRAGMENT_AREA) -> tuple[np.ndarray, dict]:
|
||||
binary = alpha > 127 # alpha>0.5
|
||||
labeled, n = label(binary)
|
||||
if n == 0:
|
||||
return alpha, {"componentsBefore": 0, "removed": 0, "removedPixels": 0}
|
||||
sizes = np.bincount(labeled.ravel())
|
||||
sizes[0] = 0
|
||||
main_label = int(np.argmax(sizes))
|
||||
removed = 0
|
||||
removed_px = 0
|
||||
out = alpha.copy()
|
||||
for lbl in range(1, n + 1):
|
||||
if lbl == main_label:
|
||||
continue
|
||||
area = int(sizes[lbl])
|
||||
if area < min_area:
|
||||
out[labeled == lbl] = 0
|
||||
removed += 1
|
||||
removed_px += area
|
||||
return out, {"componentsBefore": int(n), "removed": removed, "removedPixels": removed_px}
|
||||
|
||||
|
||||
def is_paper_color(rgb: np.ndarray) -> np.ndarray:
|
||||
"""#EEE5D3과 RGB 거리 40 이내, 또는 명도>195이면서 채도(max-min)<40인 픽셀."""
|
||||
dist = np.sqrt(((rgb - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
lum = rgb.mean(axis=2)
|
||||
sat = rgb.max(axis=2) - rgb.min(axis=2)
|
||||
return (dist <= PAPER_COLOR_DIST) | ((lum > PAPER_LUM_THRESH) & (sat < PAPER_SAT_THRESH))
|
||||
|
||||
|
||||
def fix_hair_paper_gaps(rgb: np.ndarray, alpha: np.ndarray, region: np.ndarray) -> tuple[np.ndarray, np.ndarray, dict]:
|
||||
"""머리카락 분할 영역(6px 팽창, 피부 제외) 안에서 알파=1로 남은 종이색 픽셀(가닥
|
||||
사이 틈)에 hair-edge 경계와 같은 잉크 알파 smoothstep을 적용하고 RGB를
|
||||
unpremultiply한다. 이 함수가 받는 region은 이미 얼굴 피부·몸 피부를 뺀 것이어야
|
||||
한다(호출부에서 보장)."""
|
||||
a01 = alpha.astype(np.float64) / 255.0
|
||||
paper = is_paper_color(rgb)
|
||||
target = region & (a01 >= HAIR_PAPER_OPAQUE_ALPHA) & paper
|
||||
|
||||
lum = rgb.mean(axis=2)
|
||||
t = (LUM_PAPER - lum) / (LUM_PAPER - LUM_INK)
|
||||
alpha_new = smoothstep(t) * 255.0
|
||||
|
||||
out_alpha = alpha.copy()
|
||||
out_alpha[target] = alpha_new[target]
|
||||
|
||||
a01_new = out_alpha.astype(np.float64) / 255.0
|
||||
unpre = unpremultiply(rgb, a01_new, CREAM_BG_ARR)
|
||||
out_rgb = rgb.copy()
|
||||
out_rgb[target] = unpre[target]
|
||||
|
||||
return out_rgb, out_alpha, {"targetPixels": int(target.sum())}
|
||||
|
||||
|
||||
def remove_jaw_ink_from_hairfront(
|
||||
alpha: np.ndarray, mouth_center_y: float, boundary_dist: np.ndarray
|
||||
) -> tuple[np.ndarray, dict]:
|
||||
"""hairFront에만 있는 턱선 오분류 조각을 지운다(head는 건드리지 않는다 — head는
|
||||
같은 픽셀을 face_skin_mask로도 이미 포함하므로 정상이다).
|
||||
|
||||
입 중심(mouth_center_y) 아래, faceOval 경계로부터 JAW_INK_DIST_TO_OVAL_PX 이내인
|
||||
알파>0 픽셀을 후보로 모은다. 페더로 생긴 간극(<=JAW_INK_MERGE_PX)을 팽창으로 이어
|
||||
붙여 하나의 성분으로 묶고, 성분별 폭/높이 비율을 본다. 비율이 JAW_INK_ASPECT_MIN을
|
||||
넘는(가로로 긴, 턱선을 따라 이어지는) 성분만 알파를 0으로 지운다. 비율이 그 이하인
|
||||
(세로로 긴, 실제로 늘어진 머리카락 가닥) 성분은 그대로 남긴다."""
|
||||
h, w = alpha.shape
|
||||
yy = np.arange(h)[:, None] * np.ones((1, w))
|
||||
near_jaw = (alpha > 0) & (yy >= mouth_center_y) & (boundary_dist <= JAW_INK_DIST_TO_OVAL_PX)
|
||||
|
||||
out = alpha.copy()
|
||||
removed_groups: list[dict] = []
|
||||
kept_groups: list[dict] = []
|
||||
if near_jaw.any():
|
||||
dil = binary_dilation(near_jaw, iterations=JAW_INK_MERGE_PX, structure=np.ones((3, 3)))
|
||||
labeled, n = label(dil, structure=np.ones((3, 3)))
|
||||
for lbl in range(1, n + 1):
|
||||
grp = (labeled == lbl) & near_jaw
|
||||
cnt = int(grp.sum())
|
||||
if cnt == 0:
|
||||
continue
|
||||
ys, xs = np.where(grp)
|
||||
x0, x1 = int(xs.min()), int(xs.max())
|
||||
y0, y1 = int(ys.min()), int(ys.max())
|
||||
gw, gh = x1 - x0 + 1, y1 - y0 + 1
|
||||
aspect = gw / max(gh, 1)
|
||||
info = {
|
||||
"pixels": cnt,
|
||||
"bbox": [x0, y0, x1 + 1, y1 + 1],
|
||||
"width": gw,
|
||||
"height": gh,
|
||||
"aspect": round(aspect, 3),
|
||||
}
|
||||
if aspect > JAW_INK_ASPECT_MIN:
|
||||
out[grp] = 0
|
||||
removed_groups.append(info)
|
||||
else:
|
||||
kept_groups.append(info)
|
||||
|
||||
report = {
|
||||
"mouthCenterY": round(float(mouth_center_y), 2),
|
||||
"distThresholdPx": JAW_INK_DIST_TO_OVAL_PX,
|
||||
"mergePx": JAW_INK_MERGE_PX,
|
||||
"aspectThreshold": JAW_INK_ASPECT_MIN,
|
||||
"removedGroups": removed_groups,
|
||||
"keptGroups": kept_groups,
|
||||
"removedPixels": int(sum(g["pixels"] for g in removed_groups)),
|
||||
}
|
||||
return out, report
|
||||
|
||||
|
||||
def hair_paper_opaque_pct(rgb: np.ndarray, alpha: np.ndarray, region: np.ndarray) -> float:
|
||||
"""region(머리카락 6px 팽창, 피부 제외) 내에서 알파>=0.95이면서 종이색인 픽셀 비율(%)."""
|
||||
n_region = int(region.sum())
|
||||
if n_region == 0:
|
||||
return 0.0
|
||||
a01 = alpha.astype(np.float64) / 255.0
|
||||
paper = is_paper_color(rgb)
|
||||
hit = region & (a01 >= HAIR_PAPER_OPAQUE_ALPHA) & paper
|
||||
return float(hit.sum()) / float(n_region) * 100.0
|
||||
|
||||
|
||||
def general_halo_cleanup(
|
||||
rgb: np.ndarray, alpha: np.ndarray, exclude: np.ndarray | None = None
|
||||
) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""레이어 전체 경계(부분 알파 0.02~0.98)에 대해 unpremultiply를 적용한다.
|
||||
exclude가 주어지면 이미 처리한 영역(예: 머리카락 경계 띠)은 다시 건드리지 않는다.
|
||||
낮은 알파(<0.5)인데 unpremultiply해도 여전히 종이색에 가까운 픽셀은 실제 내용이
|
||||
없는 페더 잡음이므로 알파를 0으로 접는다(다른 배경에서 종이색 유령이 보이는 것 방지)."""
|
||||
a01 = alpha.astype(np.float64) / 255.0
|
||||
edge = (a01 > 0.02) & (a01 < 0.98)
|
||||
if exclude is not None:
|
||||
edge = edge & ~exclude
|
||||
if not edge.any():
|
||||
return rgb, alpha
|
||||
unpre = unpremultiply(rgb, a01, CREAM_BG_ARR)
|
||||
out_rgb = rgb.copy()
|
||||
out_rgb[edge] = unpre[edge]
|
||||
dist = np.sqrt(((unpre - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
spurious = edge & (a01 < 0.5) & (dist <= 30)
|
||||
out_alpha = alpha.copy()
|
||||
out_alpha[spurious] = 0
|
||||
return out_rgb, out_alpha
|
||||
|
||||
|
||||
def halo_metric(rgb: np.ndarray, alpha: np.ndarray, paper: np.ndarray = None) -> float:
|
||||
if paper is None:
|
||||
paper = CREAM_BG_ARR
|
||||
a01 = alpha.astype(np.float64) / 255.0
|
||||
edge = (a01 > 0.05) & (a01 < 0.95)
|
||||
n_edge = int(edge.sum())
|
||||
if n_edge == 0:
|
||||
return 0.0
|
||||
unpre = unpremultiply(rgb, a01, paper, eps=0.05)
|
||||
dist = np.sqrt(((unpre - paper[None, None, :]) ** 2).sum(axis=2))
|
||||
near_paper = (dist <= 30) & edge
|
||||
return float(near_paper.sum()) / float(n_edge) * 100.0
|
||||
|
||||
|
||||
def bg_leak_metric(alpha: np.ndarray, category_mask: np.ndarray, mask: np.ndarray) -> float:
|
||||
dil = binary_dilation(mask, iterations=HAIR_EDGE_BAND_OUT_PX)
|
||||
band = dil & ~mask
|
||||
# mask(=hair_mask) 내부는 refine_hair_edge가 편입시킨 category0(배경) 픽셀을 포함할 수
|
||||
# 있다(잔머리 보강용, 정상). "배경 분류 영역에서 경계 띠를 뺀 곳"은 머리카락이 아닌
|
||||
# 순수 배경만 뜻하므로 mask 내부도 제외한다.
|
||||
bg_far = (category_mask == 0) & ~band & ~mask
|
||||
if not bg_far.any():
|
||||
return 0.0
|
||||
return float((alpha[bg_far].astype(np.float64) / 255.0).mean())
|
||||
|
||||
|
||||
CREAM_BG_ARR = np.array(CREAM_BG, dtype=np.float64)
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
raw_dir = cfg.raw_dir
|
||||
layers_dir = cfg.layers_dir
|
||||
layers_v2_dir = cfg.layers_v2_dir
|
||||
preview_v2_dir = cfg.preview_v2_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print("=== 기준 데이터 재계산(segmentation.py와 동일 알고리즘) ===")
|
||||
f_img = build_padded_faceless(base_dir)
|
||||
f_arr = np.array(f_img).astype(np.float64)
|
||||
h, w, _ = f_arr.shape
|
||||
|
||||
front_pts = detect_face_landmarks(base_dir / "base-front.png")
|
||||
chin_xy = front_pts[152]
|
||||
chin_y = chin_xy[1]
|
||||
face_oval_mask, face_oval_poly = polygon_mask(
|
||||
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
|
||||
)
|
||||
mouth_center_y = (
|
||||
front_pts[MOUTH_CENTER_INNER_UPPER_IDX][1] + front_pts[MOUTH_CENTER_INNER_LOWER_IDX][1]
|
||||
) / 2.0
|
||||
face_oval_dist_in = distance_transform_edt(face_oval_mask)
|
||||
face_oval_dist_out = distance_transform_edt(~face_oval_mask)
|
||||
face_oval_boundary_dist = np.where(face_oval_mask, face_oval_dist_in, face_oval_dist_out)
|
||||
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
bg_mask = category_mask == 0
|
||||
hair_mask_raw = category_mask == 1
|
||||
body_skin_mask = category_mask == 2
|
||||
face_skin_mask = category_mask == 3
|
||||
clothes_mask = category_mask == 4
|
||||
others_mask = category_mask == 5
|
||||
hair_mask = refine_hair_edge(hair_mask_raw, f_arr.astype(np.uint8))
|
||||
|
||||
yy = np.arange(h)[:, None] * np.ones((1, w))
|
||||
chin_line = chin_y + CHIN_MARGIN
|
||||
|
||||
head_mask = hair_mask | face_skin_mask | (body_skin_mask & (yy < chin_line))
|
||||
hairfront_mask = hair_mask & face_oval_mask
|
||||
body_base_mask_raw = clothes_mask | others_mask | (body_skin_mask & (yy >= chin_line))
|
||||
|
||||
# 분할 오분류 보정: 옷/기타로 분류됐지만 실제로는 종이 배경인 덩어리가 두 군데 있다
|
||||
# (어깨 오른쪽 가장자리, 후드 왼쪽 밑단). 흰 티셔츠처럼 진짜 옷인데 밝은 영역과
|
||||
# 구분하기 위해, "배경(category0)과 실제로 이어져 있는" 밝은 덩어리만 배경으로
|
||||
# 되돌린다(흰 티셔츠는 어두운 후드에 둘러싸여 배경과 안 이어져 있어 보존된다).
|
||||
paper_dist = np.sqrt(((f_arr - CREAM_BG_ARR[None, None, :]) ** 2).sum(axis=2))
|
||||
paperlike = paper_dist < 20
|
||||
seed_bg = category_mask == 0
|
||||
candidate = body_base_mask_raw & paperlike
|
||||
union_labeled, _ = label(seed_bg | candidate)
|
||||
touch_labels = set(np.unique(union_labeled[seed_bg]).tolist()) - {0}
|
||||
reclassify_to_bg = np.isin(union_labeled, list(touch_labels)) & candidate
|
||||
body_base_mask = body_base_mask_raw & ~reclassify_to_bg
|
||||
print(f"배경 오분류 보정: clothes/others -> background {int(reclassify_to_bg.sum())}px 재분류")
|
||||
|
||||
ink_rgb = INK_FALLBACK_RGB
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# (1) body 턱 밑 밝은 띠 보정
|
||||
# ------------------------------------------------------------------
|
||||
print("=== (1) body 턱 밑 띠 보정 ===")
|
||||
body_base_alpha = feather_bool_mask(body_base_mask, FEATHER_PX)
|
||||
body_base_rgb = f_arr.copy()
|
||||
|
||||
raw_body = np.array(Image.open(raw_dir / "body.png").convert("RGB"))
|
||||
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
|
||||
|
||||
fill_target = head_mask & (regen_body_alpha > 0)
|
||||
dist_out = distance_transform_edt(~fill_target)
|
||||
fill_weight = np.clip(1.0 - dist_out / FILL_FEATHER_PX, 0.0, 1.0)
|
||||
fill_weight = gaussian_filter(fill_weight, sigma=FILL_FEATHER_PX / 2.35)
|
||||
fill_weight = np.clip(fill_weight, 0.0, 1.0)
|
||||
|
||||
base_a = body_base_alpha / 255.0
|
||||
fill_a = (regen_body_alpha.astype(np.float64) / 255.0) * fill_weight
|
||||
out_a = base_a + fill_a * (1 - base_a)
|
||||
eps = 1e-6
|
||||
body_v1_rgb = (
|
||||
body_base_rgb * base_a[..., None]
|
||||
+ regen_body_rgb.astype(np.float64) * (fill_a * (1 - base_a))[..., None]
|
||||
) / np.clip(out_a[..., None], eps, None)
|
||||
body_v1_alpha = out_a * 255.0
|
||||
|
||||
# 목 그늘 소스 밴드(턱선 바로 아래, 실제로 보이는 F 픽셀)를 hole-fill해 위로 반사한다.
|
||||
band_h = 150
|
||||
source_valid = body_base_mask & (yy >= chin_line) & (yy < chin_line + band_h)
|
||||
band_top = int(chin_line)
|
||||
band_bottom = int(chin_line) + band_h
|
||||
source_rgb = f_arr[band_top:band_bottom, :, :]
|
||||
source_valid_band = source_valid[band_top:band_bottom, :]
|
||||
# 결측(머리카락 등) 픽셀은 최근접 유효 픽셀로 채운다(가는 잔머리 틈 메움).
|
||||
_, (iy, ix) = distance_transform_edt(~source_valid_band, return_indices=True)
|
||||
clean_band = source_rgb[iy, ix, :]
|
||||
|
||||
target_region = fill_target & (yy < chin_line) & (~body_base_mask)
|
||||
rng = np.random.default_rng(RNG_SEED)
|
||||
row_jitter = rng.uniform(-2.0, 2.0, size=int(round(chin_line)) + 1)
|
||||
|
||||
reflected_rgb = body_v1_rgb.copy()
|
||||
ys_t, xs_t = np.where(target_region)
|
||||
if len(ys_t):
|
||||
d = chin_line - ys_t
|
||||
src_row_f = np.clip(d + row_jitter[ys_t], 0, band_h - 1.001)
|
||||
r0 = np.floor(src_row_f).astype(int)
|
||||
r1 = np.minimum(r0 + 1, band_h - 1)
|
||||
frac = (src_row_f - r0)[:, None]
|
||||
px = clean_band[r0, xs_t, :] * (1 - frac) + clean_band[r1, xs_t, :] * frac
|
||||
reflected_rgb[ys_t, xs_t, :] = px
|
||||
|
||||
# target_region 경계를 부드럽게(가우시안 가중 블렌드)해 이음매를 없앤다.
|
||||
blend_w = gaussian_filter(target_region.astype(np.float64), sigma=2.0)
|
||||
blend_w = np.clip(blend_w, 0.0, 1.0)
|
||||
body_v2_rgb = body_v1_rgb * (1 - blend_w[..., None]) + reflected_rgb * blend_w[..., None]
|
||||
body_v2_alpha = body_v1_alpha # 알파(형태)는 바꾸지 않는다.
|
||||
|
||||
body_v2_rgb, body_v2_alpha = general_halo_cleanup(body_v2_rgb, body_v2_alpha)
|
||||
|
||||
print(f"target_region(턱 밑 채움 대상, raw 채움 제거 대상) 픽셀 수: {int(target_region.sum())}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# (2) head / hairFront 잔머리·halo 보정
|
||||
# ------------------------------------------------------------------
|
||||
print("=== (2) head 잔머리·halo 보정 ===")
|
||||
head_rgb_v1 = f_arr.copy()
|
||||
head_alpha_v1 = feather_bool_mask(head_mask, FEATHER_PX)
|
||||
|
||||
head_rgb_v2, head_alpha_v2, head_band_report = recompute_hair_edge_band(
|
||||
head_rgb_v1, head_alpha_v1, hair_mask, category_mask, ink_rgb
|
||||
)
|
||||
head_alpha_v2, head_frag_report = remove_small_fragments(head_alpha_v2)
|
||||
hair_band_mask = binary_dilation(hair_mask, iterations=HAIR_EDGE_BAND_OUT_PX) & ~binary_erosion(hair_mask, iterations=HAIR_EDGE_BAND_IN_PX)
|
||||
head_rgb_v2, head_alpha_v2 = general_halo_cleanup(head_rgb_v2, head_alpha_v2, exclude=hair_band_mask)
|
||||
|
||||
print(f"head band: {head_band_report}, fragment 제거: {head_frag_report}")
|
||||
|
||||
print("=== (2) hairFront 잔머리·halo 보정 ===")
|
||||
hairfront_rgb_v1 = f_arr.copy()
|
||||
hairfront_alpha_v1 = feather_bool_mask(hairfront_mask, FEATHER_PX)
|
||||
|
||||
hairfront_rgb_v2, hairfront_alpha_v2, hf_band_report = recompute_hair_edge_band(
|
||||
hairfront_rgb_v1, hairfront_alpha_v1, hairfront_mask, category_mask, ink_rgb
|
||||
)
|
||||
hairfront_alpha_v2, hf_frag_report = remove_small_fragments(hairfront_alpha_v2)
|
||||
hf_band_mask = binary_dilation(hairfront_mask, iterations=HAIR_EDGE_BAND_OUT_PX) & ~binary_erosion(hairfront_mask, iterations=HAIR_EDGE_BAND_IN_PX)
|
||||
hairfront_rgb_v2, hairfront_alpha_v2 = general_halo_cleanup(hairfront_rgb_v2, hairfront_alpha_v2, exclude=hf_band_mask)
|
||||
|
||||
print(f"hairFront band: {hf_band_report}, fragment 제거: {hf_frag_report}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# (2b) 머리카락 안쪽 불투명 종이 틈 제거 — 머리카락 분할(category==1) 6px
|
||||
# 팽창 영역에서 얼굴/몸 피부를 뺀 범위. 가닥 사이에 알파=1로 남은 종이색
|
||||
# 픽셀에 hair-edge와 같은 잉크 알파를 적용한다. 얼굴 피부·몸 피부·body
|
||||
# 레이어는 이 범위에서 애초에 제외되어 절대 건드리지 않는다.
|
||||
# ------------------------------------------------------------------
|
||||
print("=== (2b) 머리카락 안쪽 종이 틈 제거 ===")
|
||||
hair_paper_region = binary_dilation(hair_mask_raw, iterations=HAIR_PAPER_DILATE_PX) & ~body_skin_mask & ~face_skin_mask
|
||||
|
||||
head_alpha_before_gap = head_alpha_v2.copy()
|
||||
head_rgb_v2, head_alpha_v2, head_paper_report = fix_hair_paper_gaps(head_rgb_v2, head_alpha_v2, hair_paper_region)
|
||||
head_alpha_v2, head_frag_report2 = remove_small_fragments(head_alpha_v2)
|
||||
head_face_alpha_diff = (
|
||||
float(np.abs(head_alpha_v2[face_skin_mask].astype(np.float64) - head_alpha_before_gap[face_skin_mask].astype(np.float64)).mean())
|
||||
if face_skin_mask.any() else 0.0
|
||||
)
|
||||
print(f"head paper-gap: {head_paper_report}, 추가 조각 제거: {head_frag_report2}, 얼굴피부 알파변화={head_face_alpha_diff:.6f}")
|
||||
|
||||
hairfront_alpha_before_gap = hairfront_alpha_v2.copy()
|
||||
hairfront_rgb_v2, hairfront_alpha_v2, hf_paper_report = fix_hair_paper_gaps(hairfront_rgb_v2, hairfront_alpha_v2, hair_paper_region)
|
||||
hairfront_alpha_v2, hf_frag_report2 = remove_small_fragments(hairfront_alpha_v2)
|
||||
hf_face_alpha_diff = (
|
||||
float(np.abs(hairfront_alpha_v2[face_skin_mask].astype(np.float64) - hairfront_alpha_before_gap[face_skin_mask].astype(np.float64)).mean())
|
||||
if face_skin_mask.any() else 0.0
|
||||
)
|
||||
print(f"hairFront paper-gap: {hf_paper_report}, 추가 조각 제거: {hf_frag_report2}, 얼굴피부 알파변화={hf_face_alpha_diff:.6f}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# (2c) hairFront 턱선 오분류 조각 제거 — head는 건드리지 않는다(위 설명 참고).
|
||||
# ------------------------------------------------------------------
|
||||
print("=== (2c) hairFront 턱선 오분류 조각 제거 ===")
|
||||
hairfront_alpha_v2, jaw_ink_report = remove_jaw_ink_from_hairfront(
|
||||
hairfront_alpha_v2, mouth_center_y, face_oval_boundary_dist
|
||||
)
|
||||
# 지운 덩어리에 안티앨리어싱으로 붙어 있던, 14px 경계띠 밖으로 살짝 벗어난 잔점(<60px)을 마저 치운다.
|
||||
hairfront_alpha_v2, jaw_ink_frag_report = remove_small_fragments(hairfront_alpha_v2)
|
||||
print(f"hairFront 턱선 조각: {jaw_ink_report}, 잔점 제거: {jaw_ink_frag_report}")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 저장
|
||||
# ------------------------------------------------------------------
|
||||
layers_out = {
|
||||
"body": (body_v2_rgb, body_v2_alpha),
|
||||
"head": (head_rgb_v2, head_alpha_v2),
|
||||
"hairFront": (hairfront_rgb_v2, hairfront_alpha_v2),
|
||||
}
|
||||
layers_report = []
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
out_path = layers_v2_dir / f"{layer_id}.png"
|
||||
Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(to_u8(alpha))
|
||||
opaque = int((to_u8(alpha) == 255).sum())
|
||||
residue = green_residue_count(to_u8(rgb), to_u8(alpha))
|
||||
print(f"[{layer_id}] 저장 {out_path.name} bbox={bbox} opaque={opaque} greenResidue={residue}")
|
||||
layers_report.append({"id": layer_id, "bbox": bbox, "opaque": opaque, "greenResidue": residue})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 검증 수치
|
||||
# ------------------------------------------------------------------
|
||||
print("=== 검증 ===")
|
||||
metrics = {}
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
hm = halo_metric(to_u8(rgb).astype(np.float64), to_u8(alpha))
|
||||
metrics[layer_id] = {"haloPct": hm}
|
||||
print(f"halo({layer_id}) = {hm:.3f}% (기준 <=2%)")
|
||||
|
||||
for layer_id, mask in (("head", hair_mask), ("hairFront", hairfront_mask)):
|
||||
_, alpha = layers_out[layer_id]
|
||||
leak = bg_leak_metric(to_u8(alpha), category_mask, mask)
|
||||
metrics[layer_id]["bgLeakAlphaMean"] = leak
|
||||
print(f"배경 알파 누설({layer_id}) 평균 = {leak:.5f} (기준 <0.01)")
|
||||
|
||||
for layer_id in ("head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
pct = hair_paper_opaque_pct(to_u8(rgb).astype(np.float64), to_u8(alpha), hair_paper_region)
|
||||
metrics[layer_id]["hairPaperOpaquePct"] = pct
|
||||
print(f"hairPaperOpaquePct({layer_id}) = {pct:.4f}% (기준 <=0.5%)")
|
||||
|
||||
labeled_head, n_head = label(to_u8(layers_out["head"][1]) > 127)
|
||||
sizes_head = np.bincount(labeled_head.ravel()); sizes_head[0] = 0
|
||||
main_head = int(np.argmax(sizes_head))
|
||||
frag_head = int(((sizes_head > 0) & (sizes_head < MIN_FRAGMENT_AREA) & (np.arange(len(sizes_head)) != main_head)).sum())
|
||||
labeled_hf, n_hf = label(to_u8(layers_out["hairFront"][1]) > 127)
|
||||
sizes_hf = np.bincount(labeled_hf.ravel()); sizes_hf[0] = 0
|
||||
main_hf = int(np.argmax(sizes_hf)) if len(sizes_hf) > 1 else 0
|
||||
frag_hf = int(((sizes_hf > 0) & (sizes_hf < MIN_FRAGMENT_AREA) & (np.arange(len(sizes_hf)) != main_hf)).sum())
|
||||
print(f"미연결 조각(<60px, 주성분 제외) head={frag_head} hairFront={frag_hf} (기준 0개)")
|
||||
metrics["head"]["disconnectedFragmentsUnder60px"] = frag_head
|
||||
metrics["hairFront"]["disconnectedFragmentsUnder60px"] = frag_hf
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 미리보기: chin-band 전/후, hair-edge 전/후
|
||||
# ------------------------------------------------------------------
|
||||
def load_v1(name):
|
||||
return np.array(Image.open(layers_dir / f"{name}.png").convert("RGBA")).astype(np.float64)
|
||||
|
||||
def composite(bg, layers):
|
||||
h2, w2, _ = layers[0][0].shape
|
||||
out = np.zeros((h2, w2, 4))
|
||||
out[..., :3] = bg
|
||||
out[..., 3] = 255.0
|
||||
for rgb, alpha in layers:
|
||||
out = composite_over(out, to_u8(rgb), to_u8(alpha))
|
||||
return to_u8(out)
|
||||
|
||||
body_v1 = load_v1("body")
|
||||
head_v1 = load_v1("head")
|
||||
hf_v1 = load_v1("hairFront")
|
||||
|
||||
def pair_layers(use_v2_body, use_v2_head, use_v2_hf):
|
||||
b = layers_out["body"] if use_v2_body else (body_v1[..., :3], body_v1[..., 3])
|
||||
hh = layers_out["head"] if use_v2_head else (head_v1[..., :3], head_v1[..., 3])
|
||||
hf = layers_out["hairFront"] if use_v2_hf else (hf_v1[..., :3], hf_v1[..., 3])
|
||||
return [b, hh, hf]
|
||||
|
||||
comp_before = composite(CREAM_BG_ARR, pair_layers(False, False, False))
|
||||
comp_after = composite(CREAM_BG_ARR, pair_layers(True, True, True))
|
||||
comp_after_dark = composite(np.array([58.0, 58.0, 58.0]), pair_layers(True, True, True))
|
||||
comp_before_dark = composite(np.array([58.0, 58.0, 58.0]), pair_layers(False, False, False))
|
||||
|
||||
# chin-band는 정지 상태가 아니라 up14px + rotate-4deg(neck pivot)에서 목이 드러날 때
|
||||
# 비교해야 결함(과 보정)이 보인다 — 원래 결함도 이 모션에서만 보였다(motion-test.png).
|
||||
# neck pivot은 턱끝(152) 좌표 바로 아래를 쓴다(이 미리보기 전용 모션 테스트는
|
||||
# 결과물 회귀 대상이 아니라 1px 단위 정밀도가 필요 없다 — 실제 렌더러 pivot은
|
||||
# persona.json의 pivots.neck이고 export_rig.py가 그 값을 그대로 rig에 싣는다).
|
||||
neck_pivot = (float(chin_xy[0]), float(chin_line) + 70.45)
|
||||
|
||||
def motion_composite(bg, body_layer, head_layer, hf_layer, rotate_deg, ty):
|
||||
b_rgb, b_a = body_layer
|
||||
h_rgb, h_a = to_u8(head_layer[0]), to_u8(head_layer[1])
|
||||
hf_rgb, hf_a = to_u8(hf_layer[0]), to_u8(hf_layer[1])
|
||||
if rotate_deg != 0.0:
|
||||
h_rgb, h_a = rotate_rgba(h_rgb, h_a, rotate_deg, neck_pivot)
|
||||
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, rotate_deg, neck_pivot)
|
||||
if ty != 0.0:
|
||||
h_rgb, h_a = translate_rgba(h_rgb, h_a, 0, ty)
|
||||
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, 0, ty * 1.4)
|
||||
return composite(bg, [(to_u8(b_rgb), to_u8(b_a)), (h_rgb, h_a), (hf_rgb, hf_a)])
|
||||
|
||||
body_before, head_before, hf_before = (body_v1[..., :3], body_v1[..., 3]), (head_v1[..., :3], head_v1[..., 3]), (hf_v1[..., :3], hf_v1[..., 3])
|
||||
body_after = layers_out["body"]; head_after = layers_out["head"]; hf_after = layers_out["hairFront"]
|
||||
|
||||
chin_before_up = motion_composite(CREAM_BG_ARR, body_before, head_before, hf_before, 0.0, -14.0)
|
||||
chin_after_up = motion_composite(CREAM_BG_ARR, body_after, head_after, hf_after, 0.0, -14.0)
|
||||
chin_before_rot = motion_composite(CREAM_BG_ARR, body_before, head_before, hf_before, -4.0, 0.0)
|
||||
chin_after_rot = motion_composite(CREAM_BG_ARR, body_after, head_after, hf_after, -4.0, 0.0)
|
||||
|
||||
# 미리보기 크롭 상자는 머리 bbox(head_mask) 중심을 기준으로 둔다(캔버스 크기가
|
||||
# 달라져도 머리 근처를 보여준다 — 이 상자는 증거 이미지 전용이라 결과물 회귀
|
||||
# 대상이 아니다).
|
||||
hys, hxs = np.where(head_mask)
|
||||
hcx, hcy = int(hxs.mean()), int(hys.mean())
|
||||
box = (max(0, hcx - 425), max(0, hcy + 30), min(w, hcx + 425), min(h, hcy + 430))
|
||||
crops = [
|
||||
Image.fromarray(chin_before_up).crop(box), Image.fromarray(chin_after_up).crop(box),
|
||||
Image.fromarray(chin_before_rot).crop(box), Image.fromarray(chin_after_rot).crop(box),
|
||||
]
|
||||
cw, ch = crops[0].size
|
||||
chin_strip = Image.new("RGB", (cw * 4 + 30, ch), (255, 255, 255))
|
||||
x = 0
|
||||
for im in crops:
|
||||
chin_strip.paste(im, (x, 0)); x += cw + 10
|
||||
chin_strip.save(preview_v2_dir / "chin-band.png")
|
||||
print("chin-band.png: [up14 전, up14 후, rotate-4 전, rotate-4 후]")
|
||||
|
||||
hair_crop_before = Image.fromarray(comp_before_dark).crop((100, 150, 400, 500))
|
||||
hair_crop_after = Image.fromarray(comp_after_dark).crop((100, 150, 400, 500))
|
||||
hair_crop_before_r = Image.fromarray(comp_before_dark).crop((max(0, w - 325), 150, w - 25, 500))
|
||||
hair_crop_after_r = Image.fromarray(comp_after_dark).crop((max(0, w - 325), 150, w - 25, 500))
|
||||
hw, hh_ = hair_crop_before.size
|
||||
hair_strip = Image.new("RGB", (hw * 4 + 30, hh_), (255, 255, 255))
|
||||
x = 0
|
||||
for im in (hair_crop_before, hair_crop_after, hair_crop_before_r, hair_crop_after_r):
|
||||
hair_strip.paste(im, (x, 0)); x += hw + 10
|
||||
hair_strip.save(preview_v2_dir / "hair-edge.png")
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"] = {
|
||||
"chinBandFix": {
|
||||
"targetRegionPixels": int(target_region.sum()),
|
||||
"sourceBandHeight": band_h,
|
||||
"rowJitterRange": [-2.0, 2.0],
|
||||
"rngSeed": RNG_SEED,
|
||||
},
|
||||
"hairEdgeFix": {
|
||||
"bandOuterPx": HAIR_EDGE_BAND_OUT_PX,
|
||||
"bandInnerPx": HAIR_EDGE_BAND_IN_PX,
|
||||
"lumSmoothstep": [LUM_PAPER, LUM_INK],
|
||||
"minFragmentAreaPx": MIN_FRAGMENT_AREA,
|
||||
"head": head_band_report | head_frag_report,
|
||||
"hairFront": hf_band_report | hf_frag_report,
|
||||
},
|
||||
"hairPaperGapFix": {
|
||||
"regionDilatePx": HAIR_PAPER_DILATE_PX,
|
||||
"paperColorDistThreshold": PAPER_COLOR_DIST,
|
||||
"paperLumThreshold": PAPER_LUM_THRESH,
|
||||
"paperSatThreshold": PAPER_SAT_THRESH,
|
||||
"opaqueAlphaThreshold": HAIR_PAPER_OPAQUE_ALPHA,
|
||||
"regionPixels": int(hair_paper_region.sum()),
|
||||
"head": head_paper_report | head_frag_report2 | {"faceSkinAlphaDiff": head_face_alpha_diff},
|
||||
"hairFront": hf_paper_report | hf_frag_report2 | {"faceSkinAlphaDiff": hf_face_alpha_diff},
|
||||
},
|
||||
"jawInkFix": jaw_ink_report | {"strayFragmentCleanup": jaw_ink_frag_report},
|
||||
"metrics": metrics,
|
||||
"layers": layers_report,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
420
docs/avatar-art/linocut-pipeline/scripts/lip_texture.py
Normal file
420
docs/avatar-art/linocut-pipeline/scripts/lip_texture.py
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
"""공통 리노컷 리그 — 입술 결 스프라이트 생성(결정문 §8.4, 작업 패킷 A5).
|
||||
|
||||
원칙(결정문 §8.4 개정): **중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면
|
||||
원화 픽셀이 변형된다.** 입술 모양은 스프라이트 알파가 정하고, 렌더러는 스프라이트를
|
||||
세로 띠로 변형만 한다.
|
||||
|
||||
설계(A5):
|
||||
- 스프라이트 영역 R = 입술 색 마스크를 10px 넓힌 영역. 마스크 계산(Lab a채널
|
||||
Otsu AND front가 faceless보다 어두움, 캡 bbox 안에서 계산, 구멍 메움·작은 조각
|
||||
제거).
|
||||
- R 안은 **원화 픽셀 그대로**다(복제·반사·메우기·블러 금지). 알파만 R 경계 안쪽
|
||||
4px에서 0→255 선형 램프, 그 안은 255(거리 변환으로 계산).
|
||||
- 중립 입 선 곡선 Cu0(mouthCornerLeft·mouthCenter·mouthCornerRight 2차 곡선)로
|
||||
R을 위/아래로 나눈다. 윗 스프라이트는 분할선 아래로 2px 겹쳐 담아(A4) 이음매를
|
||||
없앤다.
|
||||
- 아랫입술 아래 그늘은 별도 조각(`lip-shadow`)으로 뗀다(A5): 아랫입술 마스크
|
||||
아래 경계에서 18px 띠, 아래·옆 6px 램프, 위는 아랫입술 스프라이트 아래로 6px
|
||||
겹친다.
|
||||
|
||||
전제: landmarks.py가 manifest.landmarks를 채워 두어야 한다.
|
||||
|
||||
실행: <venv>/python.exe lip_texture.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy import ndimage
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from export_rig import detect_face_landmarks, compute_mouth_center # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
DIFF_THRESH = 20.0
|
||||
MIN_COMPONENT_AREA = 30.0
|
||||
DILATE_PX = 10.0 # 입술 색 마스크 확장 폭(유클리드 거리) — 윗/아랫 스프라이트 R
|
||||
RAMP_PX = 4.0 # R 경계 안쪽 알파 선형 램프 폭(윗/아랫 스프라이트)
|
||||
SPLIT_OVERLAP_PX = 2.0 # 윗 스프라이트가 분할선(Cu0) 아래로 겹쳐 담는 폭(이음매 방지, A4)
|
||||
BAND_PX = 18.0 # 그늘 조각: 아랫입술 마스크 아래 그늘 띠 폭
|
||||
SHADOW_TOP_OVERLAP_PX = 6.0 # 그늘 조각: 아랫입술 마스크 경계보다 위로 겹쳐 담는 폭
|
||||
SHADOW_SIDE_RAMP_PX = 6.0 # 그늘 조각: 아래·옆(열 끊김) 알파 선형 램프 폭
|
||||
SHADOW_TOP_RAMP_PX = 4.0 # 그늘 조각: 위쪽 겹침 구간 안의 알파 선형 램프 폭
|
||||
WORKING_MARGIN = 40.0 # 확장(10px)+그늘 띠(18px)+여유를 위한 작업 캔버스 여백
|
||||
SIZE_CAP_W = 162.0
|
||||
SIZE_CAP_H = 70.0
|
||||
|
||||
EVIDENCE_MAX_SIDE = 1400
|
||||
EVIDENCE_ZOOM = 3
|
||||
EVIDENCE_PAD = 8
|
||||
|
||||
|
||||
def curve_fit(lc, mc, rc) -> np.poly1d:
|
||||
coef = np.polyfit([lc[0], mc[0], rc[0]], [lc[1], mc[1], rc[1]], 2)
|
||||
return np.poly1d(coef)
|
||||
|
||||
|
||||
def extended_curve(cu: np.poly1d, x_lo: float, x_hi: float, xs: np.ndarray) -> np.ndarray:
|
||||
"""Cu0을 [x_lo, x_hi] 안에서는 그대로, 밖에서는 양끝 접선으로 연장한 y값."""
|
||||
deriv = cu.deriv()
|
||||
y = cu(xs)
|
||||
y_lo = cu(x_lo) + deriv(x_lo) * (xs - x_lo)
|
||||
y_hi = cu(x_hi) + deriv(x_hi) * (xs - x_hi)
|
||||
y = np.where(xs < x_lo, y_lo, y)
|
||||
y = np.where(xs > x_hi, y_hi, y)
|
||||
return y
|
||||
|
||||
|
||||
def crop_to_alpha_bbox(alpha: np.ndarray, rgb: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]:
|
||||
ys, xs = np.where(alpha > 0)
|
||||
x0, x1 = int(xs.min()), int(xs.max()) + 1
|
||||
y0, y1 = int(ys.min()), int(ys.max()) + 1
|
||||
out = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.uint8)
|
||||
out[..., :3] = rgb[y0:y1, x0:x1]
|
||||
out[..., 3] = alpha[y0:y1, x0:x1]
|
||||
return out, (x0, y0, x1 - x0, y1 - y0)
|
||||
|
||||
|
||||
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"]
|
||||
|
||||
LM: dict = {}
|
||||
LM["Lc"] = tuple(lm["mouthCornerLeft"])
|
||||
LM["Rc"] = tuple(lm["mouthCornerRight"])
|
||||
LM["T"] = tuple(lm["upperLipTopCenter"])
|
||||
LM["B"] = tuple(lm["lowerLipBottomCenter"])
|
||||
|
||||
points = detect_face_landmarks(base_dir / "base-front.png")
|
||||
mouth_center = compute_mouth_center(points)
|
||||
LM["Mc"] = mouth_center
|
||||
print(f"mouthCenter(재계산) = {mouth_center}")
|
||||
|
||||
lc, rc, t, b, mc = LM["Lc"], LM["Rc"], LM["T"], LM["B"], LM["Mc"]
|
||||
cu = curve_fit(lc, mc, rc)
|
||||
x_lo, x_hi = (lc[0], rc[0]) if lc[0] <= rc[0] else (rc[0], lc[0])
|
||||
|
||||
margin_x = (SIZE_CAP_W - (rc[0] - lc[0])) / 2.0
|
||||
margin_y = (SIZE_CAP_H - (b[1] - t[1])) / 2.0
|
||||
cap_x0, cap_x1 = lc[0] - margin_x, rc[0] + margin_x
|
||||
cap_y0, cap_y1 = t[1] - margin_y, b[1] + margin_y
|
||||
print(f"입술 마스크 검색 bbox(캡) = [{cap_x0:.1f},{cap_y0:.1f},{cap_x1:.1f},{cap_y1:.1f}] "
|
||||
f"w={cap_x1-cap_x0:.1f}(<=162) h={cap_y1-cap_y0:.1f}(<=70)")
|
||||
|
||||
icx0, icy0, icx1, icy1 = (int(round(cap_x0)), int(round(cap_y0)), int(round(cap_x1)), int(round(cap_y1)))
|
||||
|
||||
wx0 = icx0 - int(WORKING_MARGIN)
|
||||
wy0 = icy0 - int(WORKING_MARGIN)
|
||||
wx1 = icx1 + int(WORKING_MARGIN)
|
||||
wy1 = icy1 + int(WORKING_MARGIN)
|
||||
|
||||
front_full = np.array(Image.open(base_dir / "base-front.png").convert("RGB"))
|
||||
faceless_full = np.array(Image.open(base_dir / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
|
||||
front_work = front_full[wy0:wy1, wx0:wx1]
|
||||
faceless_work = faceless_full[wy0:wy1, wx0:wx1]
|
||||
front_lum_full = front_full.astype(np.float64).mean(axis=2)
|
||||
faceless_lum_full = faceless_full.mean(axis=2)
|
||||
diff_full = np.clip(faceless_lum_full - front_lum_full, 0.0, None)
|
||||
|
||||
# --- 입술 색+diff 마스크: 캡 bbox 안에서만 계산, 작업 캔버스에 배치(마스크 계산은 기존과 동일) ---
|
||||
cap_rgb = front_full[icy0:icy1, icx0:icx1]
|
||||
lab = cv2.cvtColor(cap_rgb, cv2.COLOR_RGB2LAB)
|
||||
a_chan = lab[..., 1].astype(np.float64)
|
||||
u8 = np.clip(np.round(a_chan), 0, 255).astype(np.uint8)
|
||||
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||
lip_color_mask_cap = a_chan >= otsu_thresh
|
||||
diff_cap = diff_full[icy0:icy1, icx0:icx1]
|
||||
diff_mask_cap = diff_cap >= DIFF_THRESH
|
||||
combined_cap = lip_color_mask_cap & diff_mask_cap
|
||||
filled_cap = ndimage.binary_fill_holes(combined_cap)
|
||||
lbl, n = ndimage.label(filled_cap)
|
||||
sizes = ndimage.sum(filled_cap, lbl, range(1, n + 1)) if n else np.array([])
|
||||
keep = np.isin(lbl, [i + 1 for i, s in enumerate(sizes) if s >= MIN_COMPONENT_AREA]) if n else np.zeros_like(lbl, dtype=bool)
|
||||
combined_cap = filled_cap & keep
|
||||
print(f"otsu(Lab a채널, 캡 bbox) = {otsu_thresh}, 입술 색 마스크 픽셀 = {int(combined_cap.sum())}")
|
||||
|
||||
combined_work = np.zeros((wy1 - wy0, wx1 - wx0), dtype=bool)
|
||||
combined_work[icy0 - wy0:icy1 - wy0, icx0 - wx0:icx1 - wx0] = combined_cap
|
||||
h, w = combined_work.shape
|
||||
|
||||
# --- 중립 입 선 곡선 Cu0(연장) → 위/아래 부호 d ---
|
||||
xs_abs = np.arange(wx0, wx1, dtype=np.float64)
|
||||
cu0_y = extended_curve(cu, x_lo, x_hi, xs_abs) # (w,)
|
||||
rows_local = np.arange(h)[:, None]
|
||||
yy_abs = rows_local + wy0
|
||||
d = yy_abs - cu0_y[None, :] # d<=0: 윗입술 쪽, d>0: 아랫입술 쪽
|
||||
|
||||
# --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, 그늘 띠 제외) ---
|
||||
dist_out = ndimage.distance_transform_edt(~combined_work)
|
||||
region = combined_work | (dist_out <= DILATE_PX)
|
||||
print(f"R(확장, 윗/아랫 스프라이트) 픽셀 = {int(region.sum())}")
|
||||
|
||||
if region[0, :].any() or region[-1, :].any() or region[:, 0].any() or region[:, -1].any():
|
||||
raise SystemExit("[중단] R이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
|
||||
|
||||
# --- 알파: R 기준 거리 변환 1회 → 분할선과 무관한 램프 ---
|
||||
dist_in = ndimage.distance_transform_edt(region)
|
||||
alpha_frac = np.clip(dist_in / RAMP_PX, 0.0, 1.0)
|
||||
alpha_full = np.rint(alpha_frac * 255.0).astype(np.uint8)
|
||||
alpha_full[~region] = 0
|
||||
|
||||
# 윗 스프라이트는 분할선(Cu0) 아래로 SPLIT_OVERLAP_PX만큼 겹쳐 담는다(이음매 방지, A4).
|
||||
# 아랫 스프라이트는 그대로 d>0. 겹침 구간(0<d<=SPLIT_OVERLAP_PX)은 두 스프라이트 모두
|
||||
# 같은 alpha_full·같은 front_work 픽셀을 담으므로 렌더러가 위(윗 스프라이트)를 덮어
|
||||
# 그리면 중립 합성은 바뀌지 않는다.
|
||||
mask_upper = region & (d <= SPLIT_OVERLAP_PX)
|
||||
mask_lower = region & (d > 0)
|
||||
alpha_upper = np.where(mask_upper, alpha_full, 0).astype(np.uint8)
|
||||
alpha_lower = np.where(mask_lower, alpha_full, 0).astype(np.uint8)
|
||||
|
||||
overlap = mask_upper & mask_lower
|
||||
overlap_expected = region & (d > 0) & (d <= SPLIT_OVERLAP_PX)
|
||||
overlap_band_ok = bool(np.array_equal(overlap, overlap_expected))
|
||||
overlap_alpha_diff = int(np.abs(alpha_upper[overlap].astype(np.int32) - alpha_lower[overlap].astype(np.int32)).max()) if overlap.any() else 0
|
||||
overlap_consistent_ok = overlap_alpha_diff == 0
|
||||
coverage_mismatch = region ^ (mask_upper | mask_lower)
|
||||
split_ok = bool(overlap_band_ok and overlap_consistent_ok and coverage_mismatch.sum() == 0)
|
||||
print(f"검사(2) 겹침 픽셀수={int(overlap.sum())}(전부 Cu0 아래 {SPLIT_OVERLAP_PX}px 띠 안={overlap_band_ok}) "
|
||||
f"겹침 알파 불일치 최대차={overlap_alpha_diff} 틈(불일치) 픽셀수={int(coverage_mismatch.sum())} "
|
||||
f"{'OK' if split_ok else '[실패]'}")
|
||||
|
||||
# --- 그늘 조각(lip-shadow): 아랫입술 마스크(combined_work & d>0) 아래 경계에서
|
||||
# 열마다 18px 띠. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고, 그 6px 안에서만 4px
|
||||
# 램프를 쓴다. 아래·옆(열이 끊기는 곳)은 6px 램프다. ---
|
||||
lower_lip_mask = combined_work & (d > 0)
|
||||
masked_rows = np.where(lower_lip_mask, rows_local, -1)
|
||||
y_bottom_local = masked_rows.max(axis=0) # (w,), 마스크 없는 열은 -1
|
||||
has_col = y_bottom_local >= 0
|
||||
|
||||
shadow_top_local = y_bottom_local.astype(np.float64) - SHADOW_TOP_OVERLAP_PX # (w,) 그늘 조각 윗 경계(제외)
|
||||
shadow_bottom_local = y_bottom_local.astype(np.float64) + BAND_PX # (w,) 그늘 조각 아래 경계(포함)
|
||||
shadow_region = (
|
||||
has_col[None, :]
|
||||
& (rows_local > shadow_top_local[None, :])
|
||||
& (rows_local <= shadow_bottom_local[None, :])
|
||||
)
|
||||
print(f"그늘 조각 영역 픽셀 = {int(shadow_region.sum())} (열 수 {int(has_col.sum())})")
|
||||
|
||||
if shadow_region[0, :].any() or shadow_region[-1, :].any() or shadow_region[:, 0].any() or shadow_region[:, -1].any():
|
||||
raise SystemExit("[중단] 그늘 조각이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
|
||||
|
||||
# 아래·옆 램프: 위쪽 경계를 두지 않은(열이 있으면 아래 경계까지는 전부 포함) 영역을
|
||||
# 따로 거리 변환해, 옆(열이 끊기는 곳)·아래 가장자리에서만 램프가 생기게 한다.
|
||||
outer_calc_region = has_col[None, :] & (rows_local <= shadow_bottom_local[None, :])
|
||||
dist_in_outer = ndimage.distance_transform_edt(outer_calc_region)
|
||||
alpha_outer_frac = np.clip(dist_in_outer / SHADOW_SIDE_RAMP_PX, 0.0, 1.0)
|
||||
|
||||
# 위쪽 겹침 램프: 그늘 조각 윗 경계(마스크 경계 6px 위)에서 4px에 걸쳐 0→1.
|
||||
dist_from_top = rows_local - shadow_top_local[None, :]
|
||||
alpha_top_frac = np.clip(dist_from_top / SHADOW_TOP_RAMP_PX, 0.0, 1.0)
|
||||
|
||||
alpha_shadow_frac = np.minimum(alpha_outer_frac, alpha_top_frac)
|
||||
alpha_shadow = np.where(shadow_region, np.rint(alpha_shadow_frac * 255.0), 0).astype(np.uint8)
|
||||
|
||||
sprites = {
|
||||
"upper": {"alpha": alpha_upper, "rgb": front_work, "mask": mask_upper},
|
||||
"lower": {"alpha": alpha_lower, "rgb": front_work, "mask": mask_lower},
|
||||
"shadow": {"alpha": alpha_shadow, "rgb": front_work, "mask": shadow_region},
|
||||
}
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
checks: dict = {}
|
||||
file_sizes_png = {}
|
||||
canvas_origin: dict[str, list[float]] = {}
|
||||
for half, key in (("upper", "lip-upper"), ("lower", "lip-lower"), ("shadow", "lip-shadow")):
|
||||
s = sprites[half]
|
||||
png_arr, bbox = crop_to_alpha_bbox(s["alpha"], s["rgb"])
|
||||
out_path = layers_v2_dir / f"{key}.png"
|
||||
Image.fromarray(png_arr, "RGBA").save(out_path)
|
||||
file_sizes_png[key] = out_path.stat().st_size
|
||||
canvas_origin[half] = [round(wx0 + bbox[0], 1), round(wy0 + bbox[1], 1)]
|
||||
print(f"저장: {out_path} bbox(작업캔버스 로컬)={bbox} 캔버스원점={canvas_origin[half]} size={png_arr.shape[1]}x{png_arr.shape[0]}")
|
||||
|
||||
# --- 검사(1) 알파 255 영역의 PNG 픽셀 = base-front(원화 픽셀을 지우거나 채우지 않았으므로 항상 0) ---
|
||||
full_op = s["alpha"] == 255
|
||||
ramp = (s["alpha"] > 0) & (s["alpha"] < 255)
|
||||
diff_full_op = np.abs(s["rgb"][full_op].astype(np.int32) - front_work[full_op].astype(np.int32))
|
||||
max_diff_full_op = int(diff_full_op.max()) if full_op.any() else 0
|
||||
check1 = max_diff_full_op == 0
|
||||
print(f" 검사(1) [{key}] 알파255 영역 PNG 최대차={max_diff_full_op} "
|
||||
f"(알파255 {int(full_op.sum())}px, 램프 {int(ramp.sum())}px) {'OK' if check1 else '[실패]'}")
|
||||
|
||||
checks[key] = {
|
||||
"regionPixelCount": int(s["mask"].sum()),
|
||||
"fullOpacityPixelCount": int(full_op.sum()),
|
||||
"rampPixelCount": int(ramp.sum()),
|
||||
"preserveMaxDiffFullOpacity": max_diff_full_op,
|
||||
"check1Preserve": bool(check1),
|
||||
"spriteBBoxWorkingLocal": list(bbox),
|
||||
}
|
||||
|
||||
# --- 검사(3) 중립 합성: base-faceless-padded 위에 아랫 → 그늘 → 윗 스프라이트 순으로
|
||||
# 알파 합성한다(§8.4 그리는 순서와 같게) 대 base-front. ---
|
||||
front_f64 = front_work.astype(np.float64)
|
||||
alpha_lower_frac = (alpha_lower.astype(np.float64) / 255.0)[..., None]
|
||||
step_lower = faceless_work * (1.0 - alpha_lower_frac) + front_f64 * alpha_lower_frac
|
||||
alpha_shadow_frac3 = (alpha_shadow.astype(np.float64) / 255.0)[..., None]
|
||||
step_shadow = step_lower * (1.0 - alpha_shadow_frac3) + front_f64 * alpha_shadow_frac3
|
||||
alpha_upper_frac = (alpha_upper.astype(np.float64) / 255.0)[..., None]
|
||||
composite = step_shadow * (1.0 - alpha_upper_frac) + front_f64 * alpha_upper_frac
|
||||
|
||||
# 실효 알파(세 장을 겹쳐 그린 뒤 실제로 얼마나 덮였는지, over-over-over 합성식) — 보고용 분류 기준.
|
||||
eff_alpha = 255.0 - (
|
||||
(255.0 - alpha_upper.astype(np.float64))
|
||||
* (255.0 - alpha_shadow.astype(np.float64))
|
||||
* (255.0 - alpha_lower.astype(np.float64))
|
||||
/ (255.0 ** 2)
|
||||
)
|
||||
|
||||
cap_sl = (slice(icy0 - wy0, icy1 - wy0), slice(icx0 - wx0, icx1 - wx0))
|
||||
eff_alpha_cap = eff_alpha[cap_sl]
|
||||
diff_cap_arr = np.abs(composite[cap_sl] - front_f64[cap_sl])
|
||||
full_op_cap = eff_alpha_cap >= 254.999
|
||||
ramp_cap = (eff_alpha_cap > 0) & (eff_alpha_cap < 254.999)
|
||||
max_diff_full_op_cap = float(diff_cap_arr[full_op_cap].max()) if full_op_cap.any() else 0.0
|
||||
mean_diff_ramp_cap = float(diff_cap_arr[ramp_cap].mean()) if ramp_cap.any() else 0.0
|
||||
mean_diff_all_cap = float(diff_cap_arr.mean())
|
||||
print(f"검사(3) 중립 합성(아랫->그늘->윗) 대 base-front 대비 알파255 최대차={max_diff_full_op_cap:.3f} "
|
||||
f"램프 평균차={mean_diff_ramp_cap:.3f} 입bbox 전체 평균차={mean_diff_all_cap:.3f}")
|
||||
|
||||
# --- 검사(그늘 조각과 아랫 스프라이트의 겹침 영역): (F+아랫+그늘) 합성이 원화와 같다 ---
|
||||
overlap_shadow_lower = (alpha_shadow > 0) & (alpha_lower > 0)
|
||||
diff_overlap_arr = np.abs(step_shadow - front_f64).max(axis=2)
|
||||
diff_overlap = diff_overlap_arr[overlap_shadow_lower]
|
||||
max_diff_overlap = float(diff_overlap.max()) if overlap_shadow_lower.any() else 0.0
|
||||
diff_overlap_gt1_count = int((diff_overlap > 1.0).sum())
|
||||
shadow_lower_overlap_ok = max_diff_overlap == 0.0
|
||||
print(f"검사(5-3) 그늘·아랫 겹침 픽셀수={int(overlap_shadow_lower.sum())} "
|
||||
f"(F+아랫+그늘) 대 원화 최대차={max_diff_overlap:.3f} diff>1 픽셀수={diff_overlap_gt1_count} "
|
||||
f"{'OK' if shadow_lower_overlap_ok else '[실패, 원인은 manifest 참고]'}")
|
||||
|
||||
total_png = sum(file_sizes_png.values())
|
||||
print(f"PNG 파일 크기 합계(참고, WebP 아님) = {total_png} bytes")
|
||||
|
||||
_save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow, preview_v2_dir)
|
||||
|
||||
manifest["lipTexture"] = {
|
||||
"designVersion": "A5(아랫입술 그늘 띠를 lip-shadow 조각으로 분리)",
|
||||
"landmarks": {"mouthCornerLeft": list(lc), "mouthCornerRight": list(rc),
|
||||
"upperLipTop": list(t), "lowerLipBottom": list(b), "mouthCenter": list(mc)},
|
||||
"capBBox": [round(cap_x0, 2), round(cap_y0, 2), round(cap_x1, 2), round(cap_y1, 2)],
|
||||
"otsuThreshLabA": otsu_thresh,
|
||||
"diffThresh": DIFF_THRESH,
|
||||
"dilatePx": DILATE_PX,
|
||||
"alphaRampPx": RAMP_PX,
|
||||
"splitOverlapPx": SPLIT_OVERLAP_PX,
|
||||
"shadowBandPx": BAND_PX,
|
||||
"shadowTopOverlapPx": SHADOW_TOP_OVERLAP_PX,
|
||||
"shadowSideRampPx": SHADOW_SIDE_RAMP_PX,
|
||||
"shadowTopRampPx": SHADOW_TOP_RAMP_PX,
|
||||
"checks": {
|
||||
**checks,
|
||||
"splitOverlapPixelCount": int(overlap.sum()),
|
||||
"splitOverlapBandOk": overlap_band_ok,
|
||||
"splitOverlapAlphaMaxDiff": overlap_alpha_diff,
|
||||
"splitOverlapConsistentOk": overlap_consistent_ok,
|
||||
"splitCoverageMismatchPixelCount": int(coverage_mismatch.sum()),
|
||||
"splitCheckOk": split_ok,
|
||||
"shadowLowerOverlapPixelCount": int(overlap_shadow_lower.sum()),
|
||||
"shadowLowerOverlapMaxDiff": max_diff_overlap,
|
||||
"shadowLowerOverlapDiffGt1PixelCount": diff_overlap_gt1_count,
|
||||
"shadowLowerOverlapOk": shadow_lower_overlap_ok,
|
||||
},
|
||||
"check3NeutralComposite": {
|
||||
"fullOpacityMaxDiff": round(max_diff_full_op_cap, 3),
|
||||
"rampMeanAbsDiff": round(mean_diff_ramp_cap, 3),
|
||||
"mouthBBoxMeanAbsDiff": round(mean_diff_all_cap, 3),
|
||||
},
|
||||
"pngFileSizes": file_sizes_png,
|
||||
"canvasOrigin": canvas_origin,
|
||||
"evidenceImage": "preview/v2/lip-texture.jpg",
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow, preview_v2_dir: Path) -> None:
|
||||
"""원화 입 | 중립 합성 | 윗 스프라이트(체커) | 아랫 스프라이트(체커) | 그늘 조각(체커), 3배 확대.
|
||||
|
||||
bbox는 세 스프라이트 알파>0 영역의 합집합(+pad)에서 구한다 — 10px 확장·그늘
|
||||
조각(6px 겹침+18px 띠)으로 R이 캡 bbox보다 커졌으므로 고정 pad만으로는 잘릴 수 있다.
|
||||
"""
|
||||
union_alpha = (alpha_upper > 0) | (alpha_lower > 0) | (alpha_shadow > 0)
|
||||
ys_u, xs_u = np.where(union_alpha)
|
||||
ubx0, uby0 = int(xs_u.min()), int(ys_u.min())
|
||||
ubx1, uby1 = int(xs_u.max()) + 1, int(ys_u.max()) + 1
|
||||
|
||||
pad = EVIDENCE_PAD
|
||||
bx0, by0 = max(0, wx0 + ubx0 - pad), max(0, wy0 + uby0 - pad)
|
||||
bx1, by1 = wx0 + ubx1 + pad, wy0 + uby1 + pad
|
||||
|
||||
def zoom(arr_rgb, nearest=False):
|
||||
crop = arr_rgb[by0:by1, bx0:bx1]
|
||||
im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8))
|
||||
resample = Image.NEAREST if nearest else Image.LANCZOS
|
||||
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), resample)
|
||||
|
||||
panel_orig = zoom(front_full)
|
||||
|
||||
# composite는 작업 캔버스 크기의 로컬 배열(원점 wx0,wy0)이다 — 절대좌표를 로컬로 옮겨 잘라야 한다.
|
||||
comp_bx0, comp_by0 = bx0 - wx0, by0 - wy0
|
||||
comp_bx1, comp_by1 = bx1 - wx0, by1 - wy0
|
||||
panel_composite = Image.fromarray(
|
||||
np.clip(composite[comp_by0:comp_by1, comp_bx0:comp_bx1], 0, 255).astype(np.uint8)
|
||||
)
|
||||
panel_composite = panel_composite.resize(
|
||||
(panel_composite.width * EVIDENCE_ZOOM, panel_composite.height * EVIDENCE_ZOOM), Image.LANCZOS
|
||||
)
|
||||
|
||||
def sprite_on_checker(half):
|
||||
s = sprites[half]
|
||||
alpha = s["alpha"]; rgb = s["rgb"]
|
||||
ys_, xs_ = np.where(alpha > 0)
|
||||
x0, x1 = xs_.min(), xs_.max() + 1
|
||||
y0, y1 = ys_.min(), ys_.max() + 1
|
||||
crop_rgb = rgb[y0:y1, x0:x1].astype(np.float64)
|
||||
crop_a = (alpha[y0:y1, x0:x1].astype(np.float64) / 255.0)[..., None]
|
||||
check = np.indices(crop_a.shape[:2])
|
||||
checker = ((check[0] // 6 + check[1] // 6) % 2) * 60 + 180
|
||||
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
|
||||
out = crop_rgb * crop_a + checker3 * (1.0 - crop_a)
|
||||
im = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
|
||||
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), Image.NEAREST)
|
||||
|
||||
panel_upper = sprite_on_checker("upper")
|
||||
panel_lower = sprite_on_checker("lower")
|
||||
panel_shadow = sprite_on_checker("shadow")
|
||||
|
||||
panels = [panel_orig, panel_composite, panel_upper, panel_lower, panel_shadow]
|
||||
gap = 12
|
||||
max_h = max(p.height for p in panels)
|
||||
total_w = sum(p.width for p in panels) + gap * (len(panels) - 1)
|
||||
combined = Image.new("RGB", (total_w, max_h), (255, 255, 255))
|
||||
x = 0
|
||||
for p in panels:
|
||||
combined.paste(p.convert("RGB"), (x, 0))
|
||||
x += p.width + gap
|
||||
scale = min(1.0, EVIDENCE_MAX_SIDE / 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 / "lip-texture.jpg"
|
||||
combined.save(out_path, "JPEG", quality=92)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
182
docs/avatar-art/linocut-pipeline/scripts/paper_grain.py
Normal file
182
docs/avatar-art/linocut-pipeline/scripts/paper_grain.py
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
"""공통 리노컷 리그 — 종이결 타일 생성.
|
||||
|
||||
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
|
||||
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
|
||||
np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로
|
||||
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
|
||||
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
|
||||
|
||||
base/base-front.png만 있으면 되고, 다른 단계와 순서 의존성이 없다(원하는
|
||||
때에 다시 돌려도 된다).
|
||||
|
||||
실행: <venv>/python.exe paper_grain.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from scipy.ndimage import distance_transform_edt, gaussian_filter, label
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from segmentation import run_segmentation # noqa: E402
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
TILE_SIZE = 256
|
||||
PERSON_MARGIN_PX = 20
|
||||
CANVAS_EDGE_MARGIN_PX = 10 # 캔버스 실제 가장자리(테두리 비네팅 위험) 회피
|
||||
MIN_CLEAN_FRAC = 0.90 # 완전히 깨끗한 256x256 창이 캔버스 어디에도 없어 최선의 창을 쓰고 나머지는 메운다
|
||||
TARGET_LUM_MIN = 245.0
|
||||
TARGET_LUM_MAX = 250.0
|
||||
TARGET_LUM_MID = (TARGET_LUM_MIN + TARGET_LUM_MAX) / 2.0
|
||||
CHROMA_KEEP = 0.30 # 원래 색조를 30%만 남기고(거의 무채색), 70%는 명도값으로 대체
|
||||
SEAM_FALLOFF_PX = 24.0
|
||||
SEAM_BLUR_SIGMA = 6.0
|
||||
|
||||
|
||||
def find_tile_origin(eligible: np.ndarray, size: int) -> tuple[int, int, float]:
|
||||
"""eligible 비율이 가장 높은 size x size 창의 좌상단 좌표와 그 비율을 고른다.
|
||||
캔버스 인물이 배경 대부분 면적에 걸쳐 있어(세로로 긴 흉상 구도) 잔머리 등
|
||||
가장자리 침범 때문에 완전히 100% 깨끗한 창은 어디에도 없다 — 최선의 창을 고르고
|
||||
남은 오염 픽셀은 호출부에서 인접 픽셀로 메운다."""
|
||||
h, w = eligible.shape
|
||||
mask_f = eligible.astype(np.float64)
|
||||
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
|
||||
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
|
||||
|
||||
y_lo, y_hi = CANVAS_EDGE_MARGIN_PX, h - size - CANVAS_EDGE_MARGIN_PX
|
||||
x_lo, x_hi = CANVAS_EDGE_MARGIN_PX, w - size - CANVAS_EDGE_MARGIN_PX
|
||||
if y_hi < y_lo or x_hi < x_lo:
|
||||
y_lo, y_hi, x_lo, x_hi = 0, h - size, 0, w - size
|
||||
sub = sums[y_lo:y_hi + 1, x_lo:x_hi + 1]
|
||||
|
||||
idx = np.unravel_index(np.argmax(sub), sub.shape)
|
||||
y0, x0 = int(idx[0] + y_lo), int(idx[1] + x_lo)
|
||||
frac = float(sub[idx]) / float(size * size)
|
||||
if frac < MIN_CLEAN_FRAC:
|
||||
raise SystemExit(f"[중단] 가장 깨끗한 256x256 창도 {frac*100:.1f}%로 기준({MIN_CLEAN_FRAC*100:.0f}%) 미달이다.")
|
||||
return y0, x0, frac
|
||||
|
||||
|
||||
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
|
||||
|
||||
layers_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_v2_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
front_path = base_dir / "base-front.png"
|
||||
front = np.array(Image.open(front_path).convert("RGB")).astype(np.float64)
|
||||
h, w, _ = front.shape
|
||||
|
||||
category_mask = run_segmentation(front_path)
|
||||
bg_mask = category_mask == 0
|
||||
|
||||
# 배경(category0) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
|
||||
# 오분류 반점을 흩뿌려 놓는다(연결요소가 여러 개로 쪼개짐). 그 반점 하나하나에서
|
||||
# 20px씩 침식하면 실제 인물과 무관한 곳까지 배경 후보가 사라진다. 대신
|
||||
# "인물(가장 큰 전경 연결요소) 경계에서 20px 이상"만 걸러 낸다 — 반점은 실제
|
||||
# 얼굴이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
|
||||
# 종이 질감이다.
|
||||
fg_mask = category_mask != 0
|
||||
labeled_fg, _ = label(fg_mask)
|
||||
sizes = np.bincount(labeled_fg.ravel())
|
||||
sizes[0] = 0
|
||||
person_mask = labeled_fg == int(np.argmax(sizes))
|
||||
dist_to_person = distance_transform_edt(~person_mask)
|
||||
eligible = bg_mask & (dist_to_person >= PERSON_MARGIN_PX)
|
||||
print(f"배경(category0) 픽셀: {int(bg_mask.sum())}, 인물 경계 {PERSON_MARGIN_PX}px 이상 떨어진 후보: {int(eligible.sum())}")
|
||||
|
||||
y0, x0, clean_frac = find_tile_origin(eligible, TILE_SIZE)
|
||||
patch = front[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE, :].copy()
|
||||
print(f"타일 원본 위치: (x={x0}, y={y0}), size={TILE_SIZE}, 깨끗한 비율={clean_frac*100:.2f}%")
|
||||
|
||||
# 인물이 거의 전체 캔버스 높이를 차지하는 흉상 구도라 완전히 깨끗한 창이 없다
|
||||
# (가장 좋은 창도 잔머리가 모서리에 살짝 걸침). 그 창 안의 오염 픽셀만 같은
|
||||
# 패치의 가장 가까운 깨끗한 픽셀 색으로 메운다 — 머리카락 색이 종이결에
|
||||
# 섞이는 것을 막는다.
|
||||
contaminated_frac = 1.0 - clean_frac
|
||||
win_eligible = eligible[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE]
|
||||
bad = ~win_eligible
|
||||
n_bad = int(bad.sum())
|
||||
if n_bad > 0:
|
||||
_, (iy, ix) = distance_transform_edt(bad, return_indices=True)
|
||||
patch[bad] = patch[iy[bad], ix[bad]]
|
||||
print(f"오염 픽셀 메움: {n_bad}px ({contaminated_frac*100:.2f}%)")
|
||||
|
||||
# --- 무채색화 + 명도 목표대로 이동(결 대비=표준편차는 유지) ---
|
||||
lum = patch.mean(axis=2)
|
||||
shift = TARGET_LUM_MID - lum.mean()
|
||||
lum_adj = np.clip(lum + shift, 0, 255)
|
||||
# 각 픽셀의 원래 색조(자기 명도 대비 편차)를 유지한 채 명도만 이동
|
||||
tinted = patch - lum[..., None] + lum_adj[..., None]
|
||||
achromatic = np.clip(tinted * CHROMA_KEEP + lum_adj[..., None] * (1.0 - CHROMA_KEEP), 0, 255)
|
||||
|
||||
print(f"명도 이동: 원본평균={lum.mean():.2f} -> 목표={TARGET_LUM_MID:.2f} (shift={shift:.2f})")
|
||||
print(f"조정 후 평균 명도={achromatic.mean(axis=2).mean():.2f}, 표준편차={lum.std():.2f}(원본)/{achromatic.mean(axis=2).std():.2f}(조정 후)")
|
||||
|
||||
# --- 이음매를 캔버스 중앙으로 옮기고 그 십자선만 블러로 감춘다 ---
|
||||
rolled = np.roll(achromatic, shift=(TILE_SIZE // 2, TILE_SIZE // 2), axis=(0, 1))
|
||||
blurred = np.stack([gaussian_filter(rolled[..., c], sigma=SEAM_BLUR_SIGMA) for c in range(3)], axis=2)
|
||||
|
||||
yy, xx = np.mgrid[0:TILE_SIZE, 0:TILE_SIZE]
|
||||
dist_seam = np.minimum(np.abs(yy - TILE_SIZE // 2), np.abs(xx - TILE_SIZE // 2)).astype(np.float64)
|
||||
seam_w = np.clip(1.0 - dist_seam / SEAM_FALLOFF_PX, 0.0, 1.0) ** 2
|
||||
tile = rolled * (1 - seam_w[..., None]) + blurred * seam_w[..., None]
|
||||
tile = np.clip(tile, 0, 255)
|
||||
|
||||
out_path = layers_v2_dir / "paper-grain.png"
|
||||
Image.fromarray(tile.round().astype(np.uint8), "RGB").save(out_path)
|
||||
print(f"저장: {out_path}")
|
||||
|
||||
# --- 이음매 연속성 검사: 좌우/상하 가장자리 1px 평균 차 ---
|
||||
left_edge = tile[:, 0, :].mean(axis=0)
|
||||
right_edge = tile[:, -1, :].mean(axis=0)
|
||||
top_edge = tile[0, :, :].mean(axis=0)
|
||||
bottom_edge = tile[-1, :, :].mean(axis=0)
|
||||
lr_diff = float(np.abs(left_edge - right_edge).mean())
|
||||
tb_diff = float(np.abs(top_edge - bottom_edge).mean())
|
||||
print(f"이음매 연속성: 좌우 가장자리 평균차={lr_diff:.3f}, 상하 가장자리 평균차={tb_diff:.3f} (참고: 작을수록 매끄러움)")
|
||||
|
||||
final_lum = tile.mean(axis=2)
|
||||
print(f"최종 타일 평균 명도={final_lum.mean():.2f} (목표 {TARGET_LUM_MIN}~{TARGET_LUM_MAX}), 표준편차={final_lum.std():.2f}")
|
||||
|
||||
# --- 미리보기: 3x3 타일링 ---
|
||||
tiled = Image.new("RGB", (TILE_SIZE * 3, TILE_SIZE * 3))
|
||||
tile_im = Image.fromarray(tile.round().astype(np.uint8), "RGB")
|
||||
for j in range(3):
|
||||
for i in range(3):
|
||||
tiled.paste(tile_im, (i * TILE_SIZE, j * TILE_SIZE))
|
||||
tiled.save(preview_v2_dir / "paper-grain-tiled.png")
|
||||
print(f"저장: {preview_v2_dir / 'paper-grain-tiled.png'}")
|
||||
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest.setdefault("layersV2", {})
|
||||
manifest["layersV2"]["paperGrain"] = {
|
||||
"sourceImage": "base/base-front.png",
|
||||
"sourceOrigin": [x0, y0],
|
||||
"tileSize": TILE_SIZE,
|
||||
"personMarginPx": PERSON_MARGIN_PX,
|
||||
"cleanFraction": clean_frac,
|
||||
"contaminatedPixelsFilled": n_bad,
|
||||
"chromaKeep": CHROMA_KEEP,
|
||||
"targetLumRange": [TARGET_LUM_MIN, TARGET_LUM_MAX],
|
||||
"lumShift": shift,
|
||||
"finalMeanLum": float(final_lum.mean()),
|
||||
"finalLumStd": float(final_lum.std()),
|
||||
"seamContinuity": {"leftRightDiff": lr_diff, "topBottomDiff": tb_diff},
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 갱신: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main(Path(sys.argv[1])))
|
||||
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
214
docs/avatar-art/linocut-pipeline/scripts/persona_config.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""공통 리노컷 파이프라인의 페르소나별 설정 로더.
|
||||
|
||||
페르소나 폴더(예: docs/avatar-art/p1-linocut/)의 persona.json을 읽어
|
||||
PersonaConfig로 만든다. 파이프라인 스크립트들은 이 모듈을 통해서만 페르소나별
|
||||
차이(게시 경로·rig 내보내기 이름·팔레트 표본 상자·점 위치·피벗 등)를 얻는다.
|
||||
|
||||
persona.json에 없는 선택 필드는 모두 기본값(P1에서 쓰던 값과 같은 보수적 기본값)으로
|
||||
채운다 — 랜드마크로 유도할 수 없는 "원화 고유" 상수(점 위치, 팔레트 표본 상자,
|
||||
피벗 등)만 이 설정에 둔다. 랜드마크·분할로 계산 가능한 것은 각 단계 스크립트가
|
||||
그대로 계산한다(이 모듈이 대신하지 않는다).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
# 모든 페르소나가 같은 종이·잉크 계열(리노컷 판화)이라 공유하는 기본값.
|
||||
# persona.json이 생략하면 이 값을 쓴다. 종이색(#EEE5D3)은 내부 합성·마스크 계산에
|
||||
# 쓰는 고정 상수라 각 단계 스크립트에 그대로 둔다(모든 페르소나가 같은 종이이고,
|
||||
# persona.json에서 조정할 축이 아니다 — 캐릭터별로 달라지는 "배경색"은 UI에 쓰이는
|
||||
# rig.backdrop이며 그건 persona.json의 backdrop 필드로 다룬다).
|
||||
DEFAULT_EYE_OVERRIDE = {"sclera": "#D8CEBD", "iris": "#4F3B2C", "irisRing": "#1E1F1F"}
|
||||
DEFAULT_PALETTE_FIXED = {
|
||||
"mouthInner": "#3B201B",
|
||||
"teeth": "#E9E0CF",
|
||||
"blush": "#C0624A",
|
||||
"tear": "#EEE5D3",
|
||||
"pallor": "#9AA3A6",
|
||||
"paper": "#EEE5D3",
|
||||
}
|
||||
DEFAULT_BACKDROP = {
|
||||
"cognitive": "#ECE3D1",
|
||||
"positive": "#F1DEC2",
|
||||
"negative": "#DCE0E2",
|
||||
"defensive": "#E6DAD3",
|
||||
"energy": "#E2E0D0",
|
||||
}
|
||||
DEFAULT_BUST_Y_OFFSET = 40.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersonaConfig:
|
||||
persona_dir: Path
|
||||
code: str
|
||||
public_slug: str
|
||||
rig_file_name: str
|
||||
rig_export_name: str
|
||||
# 아래 필드들은 원화마다 달라지고 랜드마크로 유도할 수 없는(참조 이미지를 보고
|
||||
# 사람이 고른) 값이라 persona.json에 없으면 None/빈 값으로 두고, 실제로 그
|
||||
# 값이 필요한 단계 스크립트가 쓰는 시점에 어떤 필드를 채워야 하는지 에러로
|
||||
# 알린다(로드 시점에 한꺼번에 요구하지 않는다 — 단계별로 독립 실행 가능해야 함).
|
||||
style_frame_path: Path | None
|
||||
palette_ink_box: list[float] | None
|
||||
palette_ink_lum_threshold: float | None
|
||||
eye_override: dict
|
||||
motif_petal_boxes: list[dict]
|
||||
motif_leaf_boxes: list[dict]
|
||||
moles: list[dict]
|
||||
pivots: dict | None
|
||||
bust_crop_y_offset: float
|
||||
palette_fixed: dict
|
||||
backdrop: dict
|
||||
# P1 전용 호환 장치(새 페르소나는 쓰지 않는다). face_detail.py가 눈썹 제외 영역·
|
||||
# eyeRegion y0을 계산할 때 쓰는 눈썹 좌표를 manifest.landmarks(중심선 보정본)
|
||||
# 대신 이 값으로 바꿔 쓴다. P1의 기존 face-detail.png·jaw-detail.png·
|
||||
# face-detail.webp·jaw-detail.webp·p1Rig.ts는 눈썹 중심선 보정(browCenterline)
|
||||
# 이전 좌표(manifest.browCenterline.oldPoints)로 빌드된 뒤 "다시 빌드하지
|
||||
# 않는다"는 오케스트레이터 지시로 고정됐다 — 그 시점 좌표를 재현해야 바이트
|
||||
# 단위 회귀가 성립한다. {"browLeft": {...}, "browRight": {...}} 형태(각
|
||||
# inner/peak/outer), manifest.landmarks.eyebrowLeft/Right와 같은 스키마.
|
||||
brow_landmarks_override: dict | None
|
||||
|
||||
def require_pivots(self) -> dict:
|
||||
if self.pivots is None:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 pivots(neck/body/face)가 없다 — "
|
||||
"원화를 보고 목·몸통·얼굴 회전 중심점을 정해 채워야 한다."
|
||||
)
|
||||
return self.pivots
|
||||
|
||||
def require_ink_sample(self) -> tuple[list[float], float]:
|
||||
if self.palette_ink_box is None or self.palette_ink_lum_threshold is None:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.ink(box/lumThreshold)가 "
|
||||
"없다 — base-front.png에서 머리카락 어두운 덩어리를 담는 상자를 정해야 한다."
|
||||
)
|
||||
return self.palette_ink_box, self.palette_ink_lum_threshold
|
||||
|
||||
def require_style_frame(self) -> Path:
|
||||
if self.style_frame_path is None or not self.style_frame_path.exists():
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json의 styleFrame이 없거나 파일이 없다 — "
|
||||
"모티프 팔레트(motifPetal/motifLeaf)를 뽑을 스타일 참조 이미지가 필요하다."
|
||||
)
|
||||
return self.style_frame_path
|
||||
|
||||
def require_motif_boxes(self) -> tuple[list[dict], list[dict]]:
|
||||
if not self.motif_petal_boxes or not self.motif_leaf_boxes:
|
||||
raise SystemExit(
|
||||
f"[중단] {self.persona_dir.name}/persona.json에 paletteSamples.motifPetalBoxes/"
|
||||
"motifLeafBoxes가 없다 — styleFrame에서 꽃잎(ochre)·잎/구름(blue) 표본 상자를 정해야 한다."
|
||||
)
|
||||
return self.motif_petal_boxes, self.motif_leaf_boxes
|
||||
|
||||
# --- 공통 경로(모든 단계 스크립트가 쓰는 폴더 레이아웃, 페르소나 폴더 안에서 고정) ---
|
||||
@property
|
||||
def root(self) -> Path:
|
||||
return self.persona_dir
|
||||
|
||||
@property
|
||||
def base_dir(self) -> Path:
|
||||
return self.persona_dir / "base"
|
||||
|
||||
@property
|
||||
def raw_dir(self) -> Path:
|
||||
return self.persona_dir / "raw"
|
||||
|
||||
@property
|
||||
def layers_dir(self) -> Path:
|
||||
return self.persona_dir / "layers"
|
||||
|
||||
@property
|
||||
def layers_v2_dir(self) -> Path:
|
||||
return self.persona_dir / "layers" / "v2"
|
||||
|
||||
@property
|
||||
def preview_dir(self) -> Path:
|
||||
return self.persona_dir / "preview"
|
||||
|
||||
@property
|
||||
def preview_v2_dir(self) -> Path:
|
||||
return self.persona_dir / "preview" / "v2"
|
||||
|
||||
@property
|
||||
def manifest_path(self) -> Path:
|
||||
return self.persona_dir / "manifest.json"
|
||||
|
||||
# --- 저장소 안 게시 경로(apps/web) ---
|
||||
@property
|
||||
def repo_root(self) -> Path:
|
||||
# docs/avatar-art/<persona>/ -> parents[0]=avatar-art, [1]=docs, [2]=저장소 루트
|
||||
return self.persona_dir.parents[2]
|
||||
|
||||
@property
|
||||
def public_dir(self) -> Path:
|
||||
return self.repo_root / "apps" / "web" / "public" / "avatar" / "v3" / self.public_slug
|
||||
|
||||
@property
|
||||
def public_href_prefix(self) -> str:
|
||||
return f"/avatar/v3/{self.public_slug}"
|
||||
|
||||
@property
|
||||
def rig_ts_path(self) -> Path:
|
||||
return (
|
||||
self.repo_root / "apps" / "web" / "src" / "components" / "avatar" / "v3" / "rigs" / self.rig_file_name
|
||||
)
|
||||
|
||||
|
||||
def load_persona_config(persona_dir: Path) -> PersonaConfig:
|
||||
persona_dir = Path(persona_dir).resolve()
|
||||
cfg_path = persona_dir / "persona.json"
|
||||
if not cfg_path.exists():
|
||||
raise SystemExit(f"[중단] 페르소나 설정이 없다: {cfg_path}")
|
||||
data = json.loads(cfg_path.read_text(encoding="utf-8"))
|
||||
|
||||
code = data["code"]
|
||||
public_slug = data.get("publicSlug", code.lower())
|
||||
rig_file_name = data.get("rigFileName", f"{public_slug}Rig.ts")
|
||||
rig_export_name = data.get("rigExportName", f"{code}_LINOCUT_RIG")
|
||||
|
||||
style_frame = data.get("styleFrame")
|
||||
style_frame_path = (persona_dir / style_frame).resolve() if style_frame else None
|
||||
|
||||
palette_samples = data.get("paletteSamples", {})
|
||||
ink = palette_samples.get("ink")
|
||||
palette_ink_box = ink.get("box") if ink else None
|
||||
palette_ink_lum_threshold = ink.get("lumThreshold") if ink else None
|
||||
eye_override = palette_samples.get("eyeOverride", DEFAULT_EYE_OVERRIDE)
|
||||
motif_petal_boxes = palette_samples.get("motifPetalBoxes", [])
|
||||
motif_leaf_boxes = palette_samples.get("motifLeafBoxes", [])
|
||||
|
||||
face_detail = data.get("faceDetail", {})
|
||||
moles = face_detail.get("moles", [])
|
||||
brow_landmarks_override = face_detail.get("browLandmarksOverride")
|
||||
|
||||
pivots = data.get("pivots")
|
||||
crops = data.get("crops", {})
|
||||
bust_crop_y_offset = crops.get("bustYOffset", DEFAULT_BUST_Y_OFFSET)
|
||||
|
||||
palette_fixed = {**DEFAULT_PALETTE_FIXED, **data.get("paletteFixed", {})}
|
||||
backdrop = {**DEFAULT_BACKDROP, **data.get("backdrop", {})}
|
||||
|
||||
return PersonaConfig(
|
||||
persona_dir=persona_dir,
|
||||
code=code,
|
||||
public_slug=public_slug,
|
||||
rig_file_name=rig_file_name,
|
||||
rig_export_name=rig_export_name,
|
||||
style_frame_path=style_frame_path,
|
||||
palette_ink_box=list(palette_ink_box) if palette_ink_box is not None else None,
|
||||
palette_ink_lum_threshold=(
|
||||
float(palette_ink_lum_threshold) if palette_ink_lum_threshold is not None else None
|
||||
),
|
||||
eye_override=eye_override,
|
||||
motif_petal_boxes=motif_petal_boxes,
|
||||
motif_leaf_boxes=motif_leaf_boxes,
|
||||
moles=moles,
|
||||
pivots=pivots,
|
||||
bust_crop_y_offset=float(bust_crop_y_offset),
|
||||
palette_fixed=palette_fixed,
|
||||
backdrop=backdrop,
|
||||
brow_landmarks_override=brow_landmarks_override,
|
||||
)
|
||||
141
docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py
Normal file
141
docs/avatar-art/linocut-pipeline/scripts/run_pipeline.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""공통 리노컷 리그 파이프라인 — 단일 진입점.
|
||||
|
||||
페르소나 폴더 하나(예: docs/avatar-art/p1-linocut/, persona.json이 있어야 한다)에
|
||||
대해 1~8단계를 정해진 의존 순서대로 실행한다. 각 단계는 독립 프로세스로 돌린다
|
||||
(mediapipe FaceLandmarker+ImageSegmenter를 같은 프로세스에서 같이 쓰면 세그폴트가
|
||||
나는 것과 같은 이유로, 모든 단계를 서브프로세스로 분리해 두면 한 단계의 문제가
|
||||
다른 단계·전체 파이프라인을 오염시키지 않는다).
|
||||
|
||||
단계 의존 순서(실제 코드 의존성 기준 — 전달받은 작업 설명의 번호는 주제별
|
||||
묶음이라 실행 순서와 다르다. 특히 final_previews는 export_rig가 쓴
|
||||
export-rig-report.json을 읽으므로 반드시 export_rig *다음*이다):
|
||||
|
||||
1. landmarks — base-front.png 랜드마크 검출, manifest.landmarks 작성
|
||||
2. brow_centerline — manifest.landmarks.eyebrowLeft/Right를 중심선으로 보정
|
||||
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로 모션·눈/입 확대 미리보기
|
||||
|
||||
주의(재실행 순서): landmarks를 혼자 다시 돌리면 manifest.landmarks를 통째로
|
||||
새로 쓰므로, brow_centerline이 보정한 eyebrowLeft/Right가 사라진다. landmarks만
|
||||
다시 돌릴 때는 brow_centerline도 같이 다시 돌려야 한다(이 스크립트가 --only로
|
||||
한 단계만 돌릴 때는 호출하는 쪽이 책임진다 — 자동으로 뒤따라 돌리지 않는다).
|
||||
|
||||
실행:
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> # 전체 실행
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --only face_detail # 한 단계만
|
||||
<venv>/python.exe run_pipeline.py <persona-dir> --from lip_texture # 중간부터
|
||||
<venv>/python.exe run_pipeline.py --list # 단계 이름 목록
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import load_persona_config # noqa: E402
|
||||
|
||||
# (단계 이름, 스크립트 파일명) — 실행 순서 그대로.
|
||||
STAGES: list[tuple[str, str]] = [
|
||||
("landmarks", "landmarks.py"),
|
||||
("brow_centerline", "brow_centerline.py"),
|
||||
("segmentation", "segmentation.py"),
|
||||
("layers_v2", "layers_v2.py"),
|
||||
("face_detail", "face_detail.py"),
|
||||
("paper_grain", "paper_grain.py"),
|
||||
("lip_texture", "lip_texture.py"),
|
||||
("jaw_pieces", "jaw_pieces.py"),
|
||||
("export_rig", "export_rig.py"),
|
||||
("final_previews", "final_previews.py"),
|
||||
]
|
||||
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()
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(script_path), str(persona_dir)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
output = proc.stdout + proc.stderr
|
||||
ok = proc.returncode == 0
|
||||
return ok, output, elapsed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="공통 리노컷 파이프라인 실행")
|
||||
parser.add_argument("persona_dir", nargs="?", help="페르소나 폴더(예: docs/avatar-art/p1-linocut)")
|
||||
parser.add_argument("--only", choices=STAGE_NAMES, help="이 단계만 실행한다")
|
||||
parser.add_argument("--from", dest="from_stage", choices=STAGE_NAMES, help="이 단계부터 끝까지 실행한다")
|
||||
parser.add_argument("--list", action="store_true", help="단계 이름 목록을 출력하고 끝낸다")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
for name in STAGE_NAMES:
|
||||
print(name)
|
||||
return 0
|
||||
|
||||
if not args.persona_dir:
|
||||
parser.error("persona_dir가 필요하다(--list가 아니면).")
|
||||
|
||||
persona_dir = Path(args.persona_dir).resolve()
|
||||
cfg = load_persona_config(persona_dir) # persona.json 검증 겸 조기 실패
|
||||
|
||||
if args.only:
|
||||
selected = [s for s in STAGES if s[0] == args.only]
|
||||
elif args.from_stage:
|
||||
start = STAGE_NAMES.index(args.from_stage)
|
||||
selected = STAGES[start:]
|
||||
else:
|
||||
selected = STAGES
|
||||
|
||||
print(f"=== 공통 리노컷 파이프라인: persona={cfg.code} dir={persona_dir} ===")
|
||||
print(f"실행할 단계: {[s[0] for s in selected]}")
|
||||
|
||||
run_log: list[dict] = []
|
||||
for name, script in selected:
|
||||
print(f"\n--- [{name}] {script} ---")
|
||||
ok, output, elapsed = run_stage(name, script, persona_dir)
|
||||
print(output.rstrip())
|
||||
print(f"--- [{name}] {'OK' if ok else '실패'} ({elapsed:.1f}s) ---")
|
||||
run_log.append({"stage": name, "ok": ok, "elapsedSec": round(elapsed, 2)})
|
||||
if not ok:
|
||||
print(f"\n[중단] 단계 {name}에서 실패했다. 이후 단계는 실행하지 않는다.")
|
||||
_record_run_log(cfg.manifest_path, cfg.code, run_log, completed=False)
|
||||
return 1
|
||||
|
||||
_record_run_log(cfg.manifest_path, cfg.code, run_log, completed=True)
|
||||
print(f"\n=== 전체 완료: {len(selected)}개 단계 ===")
|
||||
return 0
|
||||
|
||||
|
||||
def _record_run_log(manifest_path: Path, persona_code: str, run_log: list[dict], completed: bool) -> None:
|
||||
"""단계별 실행 기록(성공 여부·소요 시간)을 manifest에 남긴다. 각 단계 자체의
|
||||
검사 수치(halo%, 평균절대차 등)는 이미 각 단계 스크립트가 manifest의 해당
|
||||
섹션(layersV2, lipTexture, jaw, ...)에 기록하므로 여기서 중복하지 않는다."""
|
||||
if not manifest_path.exists():
|
||||
return
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
manifest["pipelineRun"] = {
|
||||
"persona": persona_code,
|
||||
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"completed": completed,
|
||||
"stages": run_log,
|
||||
}
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
474
docs/avatar-art/linocut-pipeline/scripts/segmentation.py
Normal file
474
docs/avatar-art/linocut-pipeline/scripts/segmentation.py
Normal file
|
|
@ -0,0 +1,474 @@
|
|||
"""공통 리노컷 리그 — 분할 기반 덩어리 레이어(body/head/hairFront) v1 빌드.
|
||||
|
||||
레이어 픽셀은 항상 base-faceless.png(패딩판 F)에서 가져온다. mediapipe
|
||||
ImageSegmenter(selfie_multiclass_256x256)로 F를 분할해 head/hairFront/body
|
||||
마스크를 만들고, body는 head 마스크가 덮는 영역 중 raw/body.png(크로마키 결과)의
|
||||
알파>0 부분만 가려진 영역 채움으로 쓴다(결정문 §8.2).
|
||||
|
||||
layers_v2.py가 이 모듈의 결과(layers/*.png, manifest 기준 섹션)를 보정 입력으로
|
||||
쓰므로 파이프라인에서 가장 먼저 실행해야 한다.
|
||||
|
||||
실행: <venv>/python.exe segmentation.py <persona-dir>
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw
|
||||
from scipy.ndimage import binary_dilation, binary_erosion, distance_transform_edt, gaussian_filter
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parent
|
||||
MODEL_SEG = SCRIPTS_DIR / "_models" / "selfie_multiclass_256x256.tflite"
|
||||
MODEL_FACE = SCRIPTS_DIR / "_models" / "face_landmarker.task"
|
||||
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
from persona_config import PersonaConfig, load_persona_config # noqa: E402
|
||||
|
||||
# 모든 페르소나가 같은 리노컷 판화 종이(결정문 §8.1)라 persona.json 축이 아니다.
|
||||
CREAM_BG = (0xEE, 0xE5, 0xD3)
|
||||
|
||||
MEDIAPIPE_VERSION = "1.0.1" # pip show mediapipe로 확인(런타임 import 생략 — 동일 프로세스 세그폴트 회피)
|
||||
|
||||
FEATHER_PX = 1.0 # 레이어 자체 경계 페더
|
||||
FILL_FEATHER_PX = 4.0 # body 채움 이음매 페더
|
||||
HAIR_EDGE_DILATE_PX = 6
|
||||
HAIR_EDGE_LUM_THRESH = 110
|
||||
CHIN_MARGIN = 8
|
||||
FACE_OVAL_SCALE = 1.04
|
||||
GREEN_RESIDUE_MARGIN = 30
|
||||
|
||||
# 크로마키(HSV 기반) 튜닝값. #00ff00 배경 기준(AGENTS.md §4.2 알파 정제 임계치 포함).
|
||||
ALPHA_LO, ALPHA_HI = 35, 205
|
||||
CHROMA_FEATHER_SIGMA = 0.6 # ~1px 페더
|
||||
HUE_TARGET_DEG = 120.0
|
||||
HUE_WINDOW_DEG = 40.0
|
||||
SAT_LO, SAT_HI = 0.15, 0.5
|
||||
VAL_LO, VAL_HI = 0.15, 0.5
|
||||
|
||||
# mediapipe FaceMesh FACE_OVAL 연결(468 캐노니컬 토폴로지)을 순서대로 이은 폐곡선.
|
||||
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,
|
||||
]
|
||||
|
||||
|
||||
# --- 크로마키(기존 build_layers.py 재사용) ----------------------------------
|
||||
|
||||
|
||||
def rgb_to_hsv_np(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
||||
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
||||
maxc = np.max(rgb, axis=-1)
|
||||
minc = np.min(rgb, axis=-1)
|
||||
v = maxc
|
||||
delta = maxc - minc
|
||||
s = np.where(maxc > 0, delta / np.where(maxc == 0, 1, maxc), 0.0)
|
||||
safe_delta = np.where(delta == 0, 1, delta)
|
||||
rc = (maxc - r) / safe_delta
|
||||
gc = (maxc - g) / safe_delta
|
||||
bc = (maxc - b) / safe_delta
|
||||
h = np.zeros_like(maxc)
|
||||
h = np.where(maxc == r, (bc - gc), h)
|
||||
h = np.where(maxc == g, 2.0 + rc - bc, h)
|
||||
h = np.where(maxc == b, 4.0 + gc - rc, h)
|
||||
h = (h / 6.0) % 1.0
|
||||
h = np.where(delta == 0, 0.0, h)
|
||||
return h, s, v
|
||||
|
||||
|
||||
def chroma_key(rgb_u8: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""RGB(0-255) 배열을 받아 (despill된 RGB uint8, 정제된 알파 uint8)를 반환."""
|
||||
rgb = rgb_u8.astype(np.float64) / 255.0
|
||||
h, s, v = rgb_to_hsv_np(rgb)
|
||||
hue_deg = h * 360.0
|
||||
hue_dist = np.abs(hue_deg - HUE_TARGET_DEG)
|
||||
hue_dist = np.minimum(hue_dist, 360.0 - hue_dist)
|
||||
hue_component = np.clip(1.0 - hue_dist / HUE_WINDOW_DEG, 0.0, 1.0)
|
||||
sat_component = np.clip((s - SAT_LO) / (SAT_HI - SAT_LO), 0.0, 1.0)
|
||||
val_component = np.clip((v - VAL_LO) / (VAL_HI - VAL_LO), 0.0, 1.0)
|
||||
green_score = hue_component * sat_component * val_component
|
||||
|
||||
alpha_raw = (1.0 - green_score) * 255.0
|
||||
alpha_refined = np.clip((alpha_raw - ALPHA_LO) * 255.0 / (ALPHA_HI - ALPHA_LO), 0, 255)
|
||||
alpha_feathered = gaussian_filter(alpha_refined, sigma=CHROMA_FEATHER_SIGMA)
|
||||
alpha_feathered = np.clip(alpha_feathered, 0, 255)
|
||||
|
||||
r = rgb_u8[..., 0].astype(np.float64)
|
||||
g = rgb_u8[..., 1].astype(np.float64)
|
||||
b = rgb_u8[..., 2].astype(np.float64)
|
||||
g_despill = np.minimum(g, np.maximum(r, b))
|
||||
despilled_rgb = np.stack([r, g_despill, b], axis=-1)
|
||||
|
||||
return despilled_rgb.astype(np.uint8), alpha_feathered.astype(np.uint8)
|
||||
|
||||
|
||||
# --- 공통 유틸 ---------------------------------------------------------------
|
||||
|
||||
|
||||
def build_padded_faceless(base_dir: Path) -> Image.Image:
|
||||
bf = Image.open(base_dir / "base-faceless.png").convert("RGB")
|
||||
arr = np.array(bf)
|
||||
padded = np.concatenate([arr, arr[-1:, :, :]], axis=0)
|
||||
out = Image.fromarray(padded, "RGB")
|
||||
out.save(base_dir / "base-faceless-padded.png")
|
||||
return out
|
||||
|
||||
|
||||
def detect_face_landmarks(image_path: Path) -> list[tuple[float, float]]:
|
||||
"""FaceLandmarker를 별도 프로세스로 실행한다.
|
||||
같은 프로세스에서 ImageSegmenter와 함께 호출하면 세그폴트(exit 139)가
|
||||
재현확인됐다(_run_face_landmarks.py, _run_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 run_segmentation(image_path: Path) -> np.ndarray:
|
||||
"""ImageSegmenter를 별도 프로세스로 실행한다(세그폴트 회피, 위 설명 참고)."""
|
||||
if not MODEL_SEG.exists():
|
||||
raise SystemExit(f"[중단] 분할 모델이 없다: {MODEL_SEG}")
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out_npy = Path(td) / "category_mask.npy"
|
||||
proc = subprocess.run(
|
||||
[sys.executable, "-u", str(SCRIPTS_DIR / "_run_segmentation.py"), str(image_path), str(out_npy)],
|
||||
capture_output=True, text=True,
|
||||
)
|
||||
print(proc.stdout.strip())
|
||||
if proc.returncode != 0 or not out_npy.exists():
|
||||
raise SystemExit(f"[중단] ImageSegmenter 서브프로세스 실패.\n{proc.stderr}")
|
||||
category_mask = np.load(out_npy)
|
||||
return category_mask
|
||||
|
||||
|
||||
def refine_hair_edge(hair_mask: np.ndarray, rgb_arr: np.ndarray) -> np.ndarray:
|
||||
dil = binary_dilation(hair_mask, iterations=HAIR_EDGE_DILATE_PX)
|
||||
band = dil & ~hair_mask
|
||||
lum = rgb_arr.astype(np.float64).mean(axis=2)
|
||||
add = band & (lum < HAIR_EDGE_LUM_THRESH)
|
||||
return hair_mask | add
|
||||
|
||||
|
||||
def polygon_mask(points: list[tuple[float, float]], w: int, h: int, scale: float = 1.0) -> tuple[np.ndarray, list[tuple[float, float]]]:
|
||||
cx = float(np.mean([p[0] for p in points]))
|
||||
cy = float(np.mean([p[1] for p in points]))
|
||||
scaled = [((x - cx) * scale + cx, (y - cy) * scale + cy) for x, y in points]
|
||||
img = Image.new("L", (w, h), 0)
|
||||
ImageDraw.Draw(img).polygon(scaled, fill=255)
|
||||
return np.array(img) > 127, scaled
|
||||
|
||||
|
||||
def feather_bool_mask(mask: np.ndarray, px: float = FEATHER_PX) -> np.ndarray:
|
||||
a = mask.astype(np.float64) * 255.0
|
||||
a = gaussian_filter(a, sigma=px / 1.6)
|
||||
return np.clip(a, 0, 255)
|
||||
|
||||
|
||||
def to_u8(x: np.ndarray) -> np.ndarray:
|
||||
"""float 배열을 반올림해 uint8로 캐스팅한다(truncation으로 255가 254 되는 것 방지)."""
|
||||
return np.clip(np.round(x), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def alpha_bbox(alpha: np.ndarray, threshold: int = 1) -> list[int] | None:
|
||||
ys, xs = np.where(alpha >= threshold)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
return [int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1]
|
||||
|
||||
|
||||
def green_residue_count(rgb: np.ndarray, alpha: np.ndarray) -> int:
|
||||
r = rgb[..., 0].astype(np.int32)
|
||||
g = rgb[..., 1].astype(np.int32)
|
||||
b = rgb[..., 2].astype(np.int32)
|
||||
mask = (alpha > 0) & (g > r + GREEN_RESIDUE_MARGIN) & (g > b + GREEN_RESIDUE_MARGIN)
|
||||
return int(mask.sum())
|
||||
|
||||
|
||||
def composite_over(base_rgba: np.ndarray, layer_rgb: np.ndarray, layer_alpha: np.ndarray) -> np.ndarray:
|
||||
out = base_rgba.astype(np.float64).copy()
|
||||
a = (layer_alpha.astype(np.float64) / 255.0)[..., None]
|
||||
out[..., :3] = layer_rgb.astype(np.float64) * a + out[..., :3] * (1 - a)
|
||||
out[..., 3] = 255.0 * a[..., 0] + out[..., 3] * (1 - a[..., 0])
|
||||
return out
|
||||
|
||||
|
||||
def rotate_rgba(rgb: np.ndarray, alpha: np.ndarray, angle_deg: float, pivot: tuple[float, float]) -> tuple[np.ndarray, np.ndarray]:
|
||||
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
|
||||
rot = im.rotate(angle_deg, resample=Image.BICUBIC, center=pivot, fillcolor=(0, 0, 0, 0))
|
||||
out = np.array(rot)
|
||||
return out[..., :3], out[..., 3]
|
||||
|
||||
|
||||
def translate_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: float, dy: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
im = Image.fromarray(np.dstack([to_u8(rgb), to_u8(alpha)]), "RGBA")
|
||||
out = Image.new("RGBA", im.size, (0, 0, 0, 0))
|
||||
out.paste(im, (round(dx), round(dy)))
|
||||
arr = np.array(out)
|
||||
return arr[..., :3], arr[..., 3]
|
||||
|
||||
|
||||
def main(persona_dir: Path) -> int:
|
||||
cfg = load_persona_config(persona_dir)
|
||||
base_dir = cfg.base_dir
|
||||
raw_dir = cfg.raw_dir
|
||||
layers_dir = cfg.layers_dir
|
||||
preview_dir = cfg.preview_dir
|
||||
manifest_path = cfg.manifest_path
|
||||
cream_bg = CREAM_BG
|
||||
|
||||
layers_dir.mkdir(parents=True, exist_ok=True)
|
||||
preview_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
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}")
|
||||
|
||||
front_pts = detect_face_landmarks(base_dir / "base-front.png")
|
||||
chin_y = front_pts[152][1]
|
||||
chin_xy = front_pts[152]
|
||||
face_oval_mask, face_oval_poly = polygon_mask(
|
||||
[front_pts[i] for i in FACE_OVAL_LOOP], w, h, scale=FACE_OVAL_SCALE
|
||||
)
|
||||
print(f"턱끝(152) 좌표: {chin_xy}, chinY+{CHIN_MARGIN}={chin_y + CHIN_MARGIN:.1f}")
|
||||
|
||||
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
|
||||
cat_counts = {int(k): int(v) for k, v in zip(*np.unique(category_mask, return_counts=True))}
|
||||
print(f"분할 카테고리 픽셀 수(F 전체 {w*h}): {cat_counts}")
|
||||
|
||||
bg_mask = category_mask == 0
|
||||
hair_mask_raw = category_mask == 1
|
||||
body_skin_mask = category_mask == 2
|
||||
face_skin_mask = category_mask == 3
|
||||
clothes_mask = category_mask == 4
|
||||
others_mask = category_mask == 5
|
||||
|
||||
hair_mask = refine_hair_edge(hair_mask_raw, f_arr)
|
||||
hair_edge_added = int((hair_mask & ~hair_mask_raw).sum())
|
||||
print(f"머리카락 가장자리 보강으로 추가된 픽셀: {hair_edge_added}")
|
||||
|
||||
yy = np.arange(h)[:, None] * np.ones((1, w))
|
||||
chin_line = chin_y + CHIN_MARGIN
|
||||
|
||||
head_mask = hair_mask | face_skin_mask | (body_skin_mask & (yy < chin_line))
|
||||
hairfront_mask = hair_mask & face_oval_mask
|
||||
body_base_mask = clothes_mask | others_mask | (body_skin_mask & (yy >= chin_line))
|
||||
|
||||
print(
|
||||
f"head_mask={int(head_mask.sum())} hairfront_mask={int(hairfront_mask.sum())} "
|
||||
f"body_base_mask={int(body_base_mask.sum())} 배경={int(bg_mask.sum())}"
|
||||
)
|
||||
|
||||
# --- head / hairFront: F 픽셀을 각 마스크로 잘라 1px 페더 ---
|
||||
head_alpha = feather_bool_mask(head_mask, FEATHER_PX)
|
||||
hairfront_alpha = feather_bool_mask(hairfront_mask, FEATHER_PX)
|
||||
head_rgb = f_arr.copy()
|
||||
hairfront_rgb = f_arr.copy()
|
||||
|
||||
# --- body: F 기반 기본 + 수용된 재생성 raw/body.png 채움(4px 페더) ---
|
||||
body_base_alpha = feather_bool_mask(body_base_mask, FEATHER_PX)
|
||||
body_base_rgb = f_arr.copy()
|
||||
|
||||
raw_body = np.array(Image.open(raw_dir / "body.png").convert("RGB"))
|
||||
if raw_body.shape[:2] != (h, w):
|
||||
raise SystemExit(f"[중단] raw/body.png 크기 {raw_body.shape[:2][::-1]}가 캔버스 {w}x{h}와 다르다.")
|
||||
regen_body_rgb, regen_body_alpha = chroma_key(raw_body)
|
||||
|
||||
fill_target = head_mask & (regen_body_alpha > 0)
|
||||
fill_target_px = int(fill_target.sum())
|
||||
print(f"body 채움 대상(head_mask ∩ 재생성 알파>0) 픽셀 수: {fill_target_px}")
|
||||
|
||||
dist_out = distance_transform_edt(~fill_target)
|
||||
fill_weight = np.clip(1.0 - dist_out / FILL_FEATHER_PX, 0.0, 1.0)
|
||||
fill_weight = gaussian_filter(fill_weight, sigma=FILL_FEATHER_PX / 2.35)
|
||||
fill_weight = np.clip(fill_weight, 0.0, 1.0)
|
||||
|
||||
base_a = body_base_alpha / 255.0
|
||||
fill_a = (regen_body_alpha.astype(np.float64) / 255.0) * fill_weight
|
||||
out_a = base_a + fill_a * (1 - base_a)
|
||||
eps = 1e-6
|
||||
body_final_rgb = (
|
||||
body_base_rgb.astype(np.float64) * base_a[..., None]
|
||||
+ regen_body_rgb.astype(np.float64) * (fill_a * (1 - base_a))[..., None]
|
||||
) / np.clip(out_a[..., None], eps, None)
|
||||
body_final_rgb = to_u8(body_final_rgb)
|
||||
body_final_alpha = to_u8(out_a * 255.0)
|
||||
|
||||
layers_out = {
|
||||
"head": (to_u8(head_rgb), to_u8(head_alpha)),
|
||||
"hairFront": (to_u8(hairfront_rgb), to_u8(hairfront_alpha)),
|
||||
"body": (body_final_rgb, body_final_alpha),
|
||||
}
|
||||
|
||||
layers_report = []
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
out_path = layers_dir / f"{layer_id}.png"
|
||||
Image.fromarray(np.dstack([rgb, alpha]), "RGBA").save(out_path)
|
||||
bbox = alpha_bbox(alpha)
|
||||
opaque = int((alpha == 255).sum())
|
||||
residue = green_residue_count(rgb, alpha)
|
||||
entry = {
|
||||
"id": layer_id,
|
||||
"file": f"layers/{layer_id}.png",
|
||||
"source": "base-faceless-masked+regenerated-fill" if layer_id == "body" else "base-faceless-masked",
|
||||
"alphaBBox": bbox,
|
||||
"opaquePixels": opaque,
|
||||
"greenResidue": residue,
|
||||
}
|
||||
if layer_id == "body":
|
||||
entry["fillTargetPixels"] = fill_target_px
|
||||
entry["fillSourceRaw"] = "raw/body.png (rejected 재생성이 아니라 수용된 body.png의 크로마키 결과; head/hairFront와 달리 body 재생성은 승인됨)"
|
||||
layers_report.append(entry)
|
||||
print(f"[{layer_id}] bbox={bbox} opaque={opaque} greenResidue={residue}")
|
||||
|
||||
# --- 정지 합성 vs F ---
|
||||
canvas_rgba = np.zeros((h, w, 4), dtype=np.float64)
|
||||
canvas_rgba[..., 0] = cream_bg[0]
|
||||
canvas_rgba[..., 1] = cream_bg[1]
|
||||
canvas_rgba[..., 2] = cream_bg[2]
|
||||
canvas_rgba[..., 3] = 255.0
|
||||
for layer_id in ("body", "head", "hairFront"):
|
||||
rgb, alpha = layers_out[layer_id]
|
||||
canvas_rgba = composite_over(canvas_rgba, rgb, alpha)
|
||||
composite = to_u8(canvas_rgba)
|
||||
Image.fromarray(composite, "RGBA").save(preview_dir / "composite-faceless.png")
|
||||
|
||||
composite_rgb = composite[..., :3].astype(np.float64)
|
||||
f_rgb = f_arr.astype(np.float64)
|
||||
full_diff = np.abs(composite_rgb - f_rgb).mean(axis=2)
|
||||
mean_abs_full = float(full_diff.mean())
|
||||
mean_abs_face = float(full_diff[head_mask].mean()) if head_mask.any() else None
|
||||
|
||||
dil = binary_dilation(hair_mask, iterations=6)
|
||||
ero = binary_erosion(hair_mask, iterations=6)
|
||||
hair_outline_band = dil & ~ero
|
||||
mean_abs_hair_outline = float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None
|
||||
|
||||
print(
|
||||
f"합성 vs F 평균절대차: 전체={mean_abs_full:.3f} 얼굴(head_mask)={mean_abs_face:.3f} "
|
||||
f"머리윤곽밴드={mean_abs_hair_outline:.3f}"
|
||||
)
|
||||
if mean_abs_full >= 3.0:
|
||||
bg_diff = float(full_diff[bg_mask].mean())
|
||||
fg_diff = float(full_diff[~bg_mask].mean())
|
||||
print(
|
||||
f"[경고] 전체 평균절대차 {mean_abs_full:.3f} >= 3.0. 원인 분해: "
|
||||
f"배경(카테고리0) 평균절대차={bg_diff:.3f}(전체의 {bg_mask.mean()*100:.1f}%), "
|
||||
f"전경 평균절대차={fg_diff:.3f}"
|
||||
)
|
||||
|
||||
side = Image.new("RGB", (w * 2 + 20, h), cream_bg)
|
||||
side.paste(Image.fromarray(f_arr), (0, 0))
|
||||
side.paste(Image.fromarray(composite[..., :3]), (w + 20, 0))
|
||||
side.save(preview_dir / "compare.png")
|
||||
|
||||
# --- masks.png ---
|
||||
colors = {0: (0, 0, 0), 1: (255, 0, 0), 2: (0, 255, 0), 3: (0, 120, 255), 4: (255, 255, 0), 5: (255, 0, 255)}
|
||||
overlay = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
for k, c in colors.items():
|
||||
overlay[category_mask == k] = c
|
||||
blend = to_u8(f_arr.astype(np.float64) * 0.55 + overlay.astype(np.float64) * 0.45)
|
||||
masks_img = Image.fromarray(blend)
|
||||
d = ImageDraw.Draw(masks_img)
|
||||
d.polygon(face_oval_poly, outline=(255, 255, 255), width=3)
|
||||
masks_img.save(preview_dir / "masks.png")
|
||||
|
||||
# --- motion-test.png ---
|
||||
pivot = chin_xy
|
||||
transforms = [
|
||||
("rotate-4deg", {"rotate": -4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("rotate+4deg", {"rotate": 4.0, "tx": 0.0, "ty": 0.0}),
|
||||
("up14px", {"rotate": 0.0, "tx": 0.0, "ty": -14.0}),
|
||||
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
|
||||
]
|
||||
frames = []
|
||||
body_rgb0, body_a0 = layers_out["body"]
|
||||
head_rgb0, head_a0 = layers_out["head"]
|
||||
hf_rgb0, hf_a0 = layers_out["hairFront"]
|
||||
for name, t in transforms:
|
||||
h_rgb, h_a = head_rgb0, head_a0
|
||||
hf_rgb, hf_a = hf_rgb0, hf_a0
|
||||
if t["rotate"] != 0.0:
|
||||
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], pivot)
|
||||
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], pivot)
|
||||
if t["tx"] != 0.0 or t["ty"] != 0.0:
|
||||
h_rgb, h_a = translate_rgba(h_rgb, h_a, t["tx"], t["ty"])
|
||||
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
|
||||
|
||||
frame = np.zeros((h, w, 4), dtype=np.float64)
|
||||
frame[..., 0] = cream_bg[0]
|
||||
frame[..., 1] = cream_bg[1]
|
||||
frame[..., 2] = cream_bg[2]
|
||||
frame[..., 3] = 255.0
|
||||
frame = composite_over(frame, body_rgb0, body_a0)
|
||||
frame = composite_over(frame, h_rgb, h_a)
|
||||
frame = composite_over(frame, hf_rgb, hf_a)
|
||||
frames.append((name, Image.fromarray(to_u8(frame), "RGBA").convert("RGB")))
|
||||
|
||||
gap = 12
|
||||
strip = Image.new("RGB", (w * 4 + gap * 3, h), cream_bg)
|
||||
x = 0
|
||||
for name, fr in frames:
|
||||
strip.paste(fr, (x, 0))
|
||||
d2 = ImageDraw.Draw(strip)
|
||||
d2.text((x + 10, 10), name, fill=(255, 0, 0))
|
||||
x += w + gap
|
||||
strip.save(preview_dir / "motion-test.png")
|
||||
|
||||
# --- manifest 갱신 ---
|
||||
manifest = {}
|
||||
if manifest_path.exists():
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
|
||||
manifest["schemaVersion"] = "vignette.avatar.v3.layers.v1"
|
||||
manifest["persona"] = cfg.code
|
||||
manifest["canvas"] = {"w": w, "h": h}
|
||||
manifest["base"] = {
|
||||
"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다.",
|
||||
}
|
||||
manifest["segmenter"] = {
|
||||
"model": "selfie_multiclass_256x256.tflite (mediapipe ImageSegmenter, storage.googleapis.com)",
|
||||
"categories": {"0": "background", "1": "hair", "2": "bodySkin", "3": "faceSkin", "4": "clothes", "5": "others"},
|
||||
"categoryPixelCounts": cat_counts,
|
||||
"hairEdgeRefine": {"dilatePx": HAIR_EDGE_DILATE_PX, "lumThreshold": HAIR_EDGE_LUM_THRESH, "addedPixels": hair_edge_added},
|
||||
}
|
||||
manifest["chinLine"] = {"landmarkIndex": 152, "xy": [round(chin_xy[0], 2), round(chin_xy[1], 2)], "marginPx": CHIN_MARGIN, "cutY": round(chin_line, 2)}
|
||||
manifest["faceOval"] = {"landmarkLoop": FACE_OVAL_LOOP, "scale": FACE_OVAL_SCALE, "sourceImage": "base/base-front.png"}
|
||||
manifest["layers"] = layers_report
|
||||
manifest["rejectedRawEdits"] = manifest.get("rejectedRawEdits", {
|
||||
"body": {"file": "raw/body.png", "status": "accepted-as-fill-source", "reason": "정렬 (0,0), 형태 변형 없음 — head_mask 채움 전용 소스로 재사용"},
|
||||
})
|
||||
manifest["composite"] = {
|
||||
"meanAbsDiff": {"full": mean_abs_full, "face": mean_abs_face, "hairOutline": mean_abs_hair_outline},
|
||||
"target": {"full": 3.0, "pass": bool(mean_abs_full < 3.0)},
|
||||
"backgroundNote": "카테고리0(배경)은 종이결 텍스처 노이즈(표준편차 ~29/채널)를 포함해 평탄한 크림(#EEE5D3)과 자체적으로 평균절대차 ~7 차이가 난다.",
|
||||
}
|
||||
manifest["landmarkDetector"] = manifest.get(
|
||||
"landmarkDetector", f"mediapipe FaceLandmarker (tasks) {MEDIAPIPE_VERSION}, model=face_landmarker(float16, v1)"
|
||||
)
|
||||
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(f"manifest.json 저장: {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(Path(sys.argv[1])))
|
||||
Loading…
Add table
Add a link
Reference in a new issue