vignette/docs/avatar-art/linocut-pipeline/scripts/layers_v2.py
Yun Chan ecb36d123f 리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일)
- 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조
- P1 원화 생성 프롬프트 보존
2026-10-01 16:11:28 +09:00

669 lines
33 KiB
Python

"""공통 리노컷 리그 — 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])))