vignette/docs/avatar-art/linocut-pipeline/scripts/segmentation.py
Yun Chan 98577cf4ae 리노컷 공통 파이프라인 캐스트 확장 — 정렬·정규화·얼굴 해칭·원화 눈썹·눈 실측
- 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은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
2026-10-01 22:38:27 +09:00

486 lines
21 KiB
Python

"""공통 리노컷 리그 — 분할 기반 덩어리 레이어(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 기준 섹션)를 보정 입력으로
쓰므로 파이프라인에서 가장 먼저 실행해야 한다.
실행: <venv>/python.exe segmentation.py <persona-dir>
"""
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")
front_h = Image.open(base_dir / "base-front.png").size[1]
arr = np.array(bf)
deficit = front_h - arr.shape[0]
if deficit < 0:
raise SystemExit(
f"[중단] base-faceless.png 높이({arr.shape[0]})가 base-front.png 높이({front_h})보다 크다."
)
if deficit > 0:
pad_rows = np.repeat(arr[-1:, :, :], deficit, axis=0)
padded = np.concatenate([arr, pad_rows], axis=0)
else:
padded = arr
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)
orig_faceless_h = Image.open(base_dir / "base-faceless.png").size[1]
f_img = build_padded_faceless(base_dir)
f_arr = np.array(f_img)
h, w, _ = f_arr.shape
pad_rows = h - orig_faceless_h
print(f"F(패딩된 base-faceless) 크기: {w}x{h} (원본 {w}x{orig_faceless_h}, 패딩 {pad_rows}행)")
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, orig_faceless_h],
"padNote": f"base-faceless.png 마지막 행을 {pad_rows}번 복제해 {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])))