"""공통 리노컷 리그 — 분할 기반 덩어리 레이어(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 기준 섹션)를 보정 입력으로 쓰므로 파이프라인에서 가장 먼저 실행해야 한다. 실행: /python.exe segmentation.py """ 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])))