"""P1 서연 리노컷 v2 레이어 보정 — 2단계-B-1a (1)(2). 기존 layers/{body,head,hairFront}.png(2단계-A 산출물)을 다시 만들지 않고, 같은 분할·랜드마크 계산을 재실행해 다음 두 결함만 보정한 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 제거)를 적용해 배경색이 섞여 나오지 않게 한다. 실행: /python.exe build_layers_v2.py """ 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 build_layers_segmented 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, ) ROOT = SCRIPTS_DIR.parent BASE_DIR = ROOT / "base" RAW_DIR = ROOT / "raw" LAYERS_DIR = ROOT / "layers" LAYERS_V2_DIR = ROOT / "layers" / "v2" PREVIEW_V2_DIR = ROOT / "preview" / "v2" MANIFEST_PATH = ROOT / "manifest.json" CREAM_BG = np.array([0xEE, 0xE5, 0xD3], dtype=np.float64) 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 턱선 오분류 조각 제거 ----------------------------------- # 입을 크게 벌리면 head의 턱선과 별개로 hairFront에도 같은 턱선 모양 잉크가 겹쳐 # 보이던 결함의 원인: 머리카락 분할이 입 중심 아래 짙은 턱선 그림자를 머리카락으로 # 잘못 분류했고, 그 조각이 face_oval 안쪽이라 hairfront_mask에 들어갔다. mediapipe # FaceLandmarker의 입술 안쪽 중심(13/14) y 아래, faceOval 경계 14px 이내에서 페더 # 간극(<=3px)으로 이어지는 성분을 하나로 묶어 폭/높이 비율을 본다. 턱선을 따라 # 가로로 긴(비율>1) 성분만 지우고, 세로로 늘어진 실제 머리카락 가닥(비율<=1)은 # 남긴다. 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 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) # 아주 어두운(순수 잉크에 가까운) 픽셀은 원본 유지, 나머지 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[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) 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) out_rgb = rgb.copy() out_rgb[edge] = unpre[edge] dist = np.sqrt(((unpre - CREAM_BG[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 = CREAM_BG) -> float: 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()) def main() -> int: LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True) PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True) print("=== 기준 데이터 재계산(2단계-A와 동일 알고리즘) ===") f_img = build_padded_faceless() 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_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 ) 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[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 = np.array([30.0, 31.0, 31.0]) # #1E1F1F, export_rig.py 팔레트 ink와 동일 방식으로 산출된 값 # ------------------------------------------------------------------ # (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, pair_layers(False, False, False)) comp_after = composite(CREAM_BG, 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 = (500.0, 990.0) 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, body_before, head_before, hf_before, 0.0, -14.0) chin_after_up = motion_composite(CREAM_BG, body_after, head_after, hf_after, 0.0, -14.0) chin_before_rot = motion_composite(CREAM_BG, body_before, head_before, hf_before, -4.0, 0.0) chin_after_rot = motion_composite(CREAM_BG, body_after, head_after, hf_after, -4.0, 0.0) box = (100, 750, 950, 1150) 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((680, 150, 980, 500)) hair_crop_after_r = Image.fromarray(comp_after_dark).crop((680, 150, 980, 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())