리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측

- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움)
- faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor
- brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측
- 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기
- 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조)
- P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
Yun Chan 2026-10-01 22:38:27 +09:00
parent 9a22d1647b
commit 98577cf4ae
18 changed files with 2524 additions and 85 deletions

View file

@ -39,6 +39,7 @@ SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from segmentation import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over, CREAM_BG # noqa: E402
from persona_config import load_persona_config # noqa: E402
from export_rig import detect_face_landmarks, compute_face_oval # noqa: E402 — region="face"용 얼굴 오벌(캐스트 확장)
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
REGION_FEATHER_PX = 12.0
@ -99,6 +100,9 @@ CHECKE_THRESH = 1.5
GENERIC_PATCH_SIZE = 48
GENERIC_PATCH_MARGIN = 40
# --- matchFacelessColor(faceDetail.matchFacelessColor) 상수 -----------------
MATCH_RING_LUM_THRESH = 110.0 # 고리 표본에서 이보다 어두운(잉크) front 픽셀은 뺀다
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
@ -108,6 +112,26 @@ def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, fe
return np.clip(alpha, 0, 255)
def face_oval_region_alpha(
base_front_path: Path, inset_px: float, w: int, h: int, feather: float = REGION_FEATHER_PX
) -> tuple[np.ndarray, np.ndarray, int, int]:
"""base-front.png 랜드마크로 얼굴 오벌(export_rig.compute_face_oval, 다른 단계와 같은
36점 루프·1.04 배율)을 구해 inset_px만큼 안쪽으로 줄인 뒤 feather px로 페더링한다.
원화 윤곽 잉크선은 오벌 경계 근방(jaw_pieces.py OVAL_OUTSET_PX=18px 안팎)에 있으므로,
inset_px를 충분히 크게 잡아야 이 잉크선이 반환 영역(알파>0) 밖에 남는다 — 호출자가
persona.json faceDetail.faceInsetPx로 값을 정하고 근거를 보고해야 한다.
반환: (알파, 원본 오벌 불리언 마스크, 오벌 픽셀 수, inset 후 픽셀 수)."""
points = detect_face_landmarks(base_front_path)
oval_pts = compute_face_oval(points)
oval_img = Image.new("L", (w, h), 0)
ImageDraw.Draw(oval_img).polygon(oval_pts, fill=255)
oval_mask = np.array(oval_img) > 127
dist_in = distance_transform_edt(oval_mask)
inset_mask = oval_mask & (dist_in >= inset_px)
alpha = np.clip(gaussian_filter(inset_mask.astype(np.float64) * 255.0, sigma=feather / 2.0), 0, 255)
return alpha, oval_mask, int(oval_mask.sum()), int(inset_mask.sum())
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(img)
@ -453,7 +477,18 @@ def main(persona_dir: Path) -> int:
mcx, mcy = mole["center"]
r = float(mole["radius"])
a_mole = np.maximum(a_mole, ellipse_alpha(mcx, mcy, r, r, w, h, REGION_FEATHER_PX))
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
if cfg.face_detail_region == "face":
a_face, oval_mask, oval_px, inset_px_count = face_oval_region_alpha(
base_dir / "base-front.png", cfg.face_inset_px, w, h
)
region_alpha = np.maximum(a_face, a_mole)
print(
f"faceDetail.region=face: faceInsetPx={cfg.face_inset_px} "
f"오벌픽셀={oval_px} inset후픽셀={inset_px_count}"
)
else:
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
# ------------------------------------------------------------------
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
@ -521,7 +556,20 @@ def main(persona_dir: Path) -> int:
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
# ------------------------------------------------------------------
category_mask = run_segmentation(base_dir / "base-faceless-padded.png")
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
hair_mask_raw = category_mask == 1
if cfg.face_detail_region == "face":
# region="face"는 얼굴 피부 전체에 원화(front) 픽셀을 노출하므로, front와
# faceless의 앞머리 가닥 위치가 달라(codex 편집본 시차) faceless 분할 하나만
# 쓰면 front 쪽 잔머리가 머리카락 마스크 밖으로 나와 유령 가닥이 생긴다 —
# 두 그림의 분할 합집합으로 덮는다.
front_category_mask = run_segmentation(base_dir / "base-front.png")
hair_mask_front_raw = front_category_mask == 1
hair_mask_raw = hair_mask_raw | hair_mask_front_raw
print(
f"머리카락 분할(합집합): faceless={int((category_mask == 1).sum())}px "
f"front={int(hair_mask_front_raw.sum())}px union={int(hair_mask_raw.sum())}px"
)
hair_mask = binary_dilation(hair_mask_raw, iterations=HAIR_DILATE_PX)
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
# ------------------------------------------------------------------
@ -535,6 +583,31 @@ def main(persona_dir: Path) -> int:
for r in stamp_report:
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
# ------------------------------------------------------------------
# matchFacelessColor: 제외 영역(눈·눈썹·입) 안의 F 색을 주변 원화 피부에 맞춘다.
# 영역마다 RING_PX 바깥 고리(어두운 잉크 제외)에서 front·F 평균 RGB 차를 구해
# 그 영역 전체의 F를 그 차이만큼 평행이동한다 — 보간·블러·패치 합성은 하지 않는다.
# ------------------------------------------------------------------
match_color_report: dict[str, list[float]] = {}
if cfg.match_faceless_color:
print("=== matchFacelessColor: 영역별 ΔRGB(front - F, 안쪽 고리 표본) ===")
deltas: dict[str, np.ndarray] = {}
for name, region_mask in named_excl.items():
ring = binary_dilation(region_mask, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
sample_mask = ring & (lum_front >= MATCH_RING_LUM_THRESH)
if not sample_mask.any():
deltas[name] = np.zeros(3)
print(f" {name}: 고리 표본 없음 — 보정 생략")
continue
front_mean = front[sample_mask].mean(axis=0)
f_mean = f_clean[sample_mask].mean(axis=0)
deltas[name] = front_mean - f_mean
d = deltas[name]
print(f" {name}: ΔRGB=({d[0]:+.2f},{d[1]:+.2f},{d[2]:+.2f}) 표본={int(sample_mask.sum())}px")
for name, region_mask in named_excl.items():
f_clean[region_mask] = np.clip(f_clean[region_mask] + deltas[name], 0, 255)
match_color_report = {name: [round(float(c), 2) for c in d] for name, d in deltas.items()}
# ------------------------------------------------------------------
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
@ -714,6 +787,10 @@ def main(persona_dir: Path) -> int:
manifest.setdefault("layersV2", {})
manifest["layersV2"]["faceDetail"] = {
"browLandmarksSource": brow_source,
"region": cfg.face_detail_region,
"faceInsetPx": cfg.face_inset_px if cfg.face_detail_region == "face" else None,
"matchFacelessColor": cfg.match_faceless_color,
"matchFacelessColorDeltaRgb": match_color_report,
"regions": {
"eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)],
"eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)],