리노컷 자산 파이프라인 공통화와 캐스트 외형 설계

- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
This commit is contained in:
Yun Chan 2026-10-01 16:11:28 +09:00
parent f11e76ff18
commit ecb36d123f
38 changed files with 2393 additions and 990 deletions

View 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에도 남긴다)를 기준치와 비교해 판정한다.

View file

@ -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())

View file

@ -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())

View 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])))

File diff suppressed because it is too large Load diff

View 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])))

View 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])))

View 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])))

View 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])))

View 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])))

View 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])))

View 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])))

View 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,
)

View 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())

View 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])))