리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
This commit is contained in:
parent
9a22d1647b
commit
98577cf4ae
18 changed files with 2524 additions and 85 deletions
|
|
@ -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)],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue