P1 서연 리노컷 아트 원본과 자산 파이프라인

- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
Yun Chan 2026-10-01 09:58:15 +09:00
parent 85bd079d18
commit 00169533d8
83 changed files with 7843 additions and 0 deletions

View file

@ -0,0 +1,452 @@
"""P1 서연 입술 결 스프라이트 생성 — 2단계-B-1d-A5(작업 패킷 A5, §8.4 2026-10-01 개정 반영).
원칙(결정문 §8.4 개정): **중립에서 렌더된 입은 원화와 픽셀 단위로 같고, 움직이면
원화 픽셀이 변형된다.** 입술 모양은 스프라이트 알파가 정하고, 렌더러는 스프라이트를
세로 띠로 변형만 한다. A2'(half 분할 + 6px 최근접 복제 확장)는 렌더러가 벡터 입술
윤곽으로 결을 잘라내자 윗입술이 뾰족해지고 입꼬리가 바늘처럼 튀어나오고 아랫입술
그늘이 성긴 빗금이 되는 문제를 낳았다 — 원인은 확장 영역이 좁고(6px) 입꼬리 홈·
입 선 전체·아랫입술 그늘이 벡터 윤곽 안에 다 들어가지 못한 것.
A3 재설계:
- 스프라이트 영역 R = 입술 색 마스크를 10px 넓힌 영역. 마스크 계산 자체(Lab a채널
Otsu AND front가 faceless보다 어두움, 캡 bbox 안에서 계산, 구멍 메움·작은 조각
제거)는 그대로.
- R 안은 **원화 픽셀 그대로**다(복제·반사·메우기·블러 금지). 알파만 R 경계 안쪽
4px에서 0→255 선형 램프, 그 안은 255(거리 변환으로 계산).
- 중립 입 선 곡선 Cu0(mouthCornerLeft·mouthCenter·mouthCornerRight 2차 곡선)로
R을 위/아래로 나눈다(윗 스프라이트 y<=Cu0(x), 아랫 스프라이트 y>Cu0(x)). 이
분할은 R의 외곽 알파 램프와 무관하므로(램프는 R 전체 기준 거리 변환 한 번으로만
계산하고 분할선은 그 값을 그대로 나눠 쓸 뿐) 나누는 경계에는 알파 램프가 없다 —
두 장을 원래 자리에 도로 놓으면 램프 없이 딱 맞붙어 원화가 된다.
Cu0의 x 범위(mouthCornerLeft.x~mouthCornerRight.x) 밖은 양끝 접선으로 연장한다.
A4 수정(2026-10-01, 렌더 비교 반려): 화면 배율로 재샘플링되면서 두 스프라이트가
Cu0에서 딱 맞닿는 이음매가 가는 밝은 실금으로 보였다(경계 알파가 재샘플링으로
반투명해져 밑 피부가 비침). 윗 스프라이트를 y<=Cu0(x)+2(분할선 아래 2px)까지
겹쳐 담아 이음매를 없앤다 — 겹침 2px는 같은 원화 픽셀·같은 알파이고, 렌더러가
아랫 스프라이트를 먼저 그리고 윗 스프라이트를 그 위에 덮어 그리므로(§8.4) 중립
합성 결과는 바뀌지 않는다. 아랫 스프라이트·알파 램프 규칙은 그대로.
A5 재설계(작업 패킷 A5, 2026-10-01): 아랫입술 스프라이트에 붙어 있던 아래 18px
그늘 띠를 뺀다 — out5 판정(§8.4): 턱 변위 띠와 함께 옮기지 않으면 ㅗ에서 입이
좁아질 때 그늘이 W/W0로 같이 압축돼 짙은 직사각형이 됐다. 턱 피부 그늘은 입이
좁아져도 줄지 않아야 한다. 그늘은 별도 조각 `lip-shadow`로 뗀다:
- 아랫입술 마스크(combined_work & d>0) 아래 경계에서 열마다 18px 띠, 원화 픽셀
그대로(복제·메우기 없음, 없는 열은 생략).
- 알파: 아래·옆(열이 끊기는 곳)은 6px 선형 램프. 위쪽은 마스크 경계보다 6px 위까지
겹쳐 담고(아랫 스프라이트와 같은 원화 픽셀 원본이므로 겹침 알파 값과 무관하게
두 장을 겹쳐 그려도 원화와 같다 — A4의 2px 분할선 겹침과 같은 원리), 그 6px
안에서 4px 램프를 쓴다(겹침 나머지 2px는 이미 마스크 깊숙한 안쪽이라 풀 알파).
- 중립에서 (F + 아랫 스프라이트 + 그늘 조각 + 윗 스프라이트)를 §8.4 그리는 순서
그대로(아랫 → 그늘 → 윗) 합성하면 원화와 같아야 한다.
실행: <venv>/python.exe build_lip_texture.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image
from scipy import ndimage
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from export_rig import detect_face_landmarks, compute_mouth_center # noqa: E402
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
LAYERS_V2_DIR = ROOT / "layers" / "v2"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
DIFF_THRESH = 20.0
MIN_COMPONENT_AREA = 30.0
DILATE_PX = 10.0 # 입술 색 마스크 확장 폭(유클리드 거리) — 윗/아랫 스프라이트 R
RAMP_PX = 4.0 # R 경계 안쪽 알파 선형 램프 폭(윗/아랫 스프라이트)
SPLIT_OVERLAP_PX = 2.0 # 윗 스프라이트가 분할선(Cu0) 아래로 겹쳐 담는 폭(이음매 방지, A4)
BAND_PX = 18.0 # 그늘 조각: 아랫입술 마스크 아래 그늘 띠 폭
SHADOW_TOP_OVERLAP_PX = 6.0 # 그늘 조각: 아랫입술 마스크 경계보다 위로 겹쳐 담는 폭
SHADOW_SIDE_RAMP_PX = 6.0 # 그늘 조각: 아래·옆(열 끊김) 알파 선형 램프 폭
SHADOW_TOP_RAMP_PX = 4.0 # 그늘 조각: 위쪽 겹침 구간 안의 알파 선형 램프 폭
WORKING_MARGIN = 40.0 # 확장(10px)+그늘 띠(18px)+여유를 위한 작업 캔버스 여백
SIZE_CAP_W = 162.0
SIZE_CAP_H = 70.0
EVIDENCE_MAX_SIDE = 1400
EVIDENCE_ZOOM = 3
EVIDENCE_PAD = 8
LM: dict = {}
def curve_fit(lc, mc, rc) -> np.poly1d:
coef = np.polyfit([lc[0], mc[0], rc[0]], [lc[1], mc[1], rc[1]], 2)
return np.poly1d(coef)
def extended_curve(cu: np.poly1d, x_lo: float, x_hi: float, xs: np.ndarray) -> np.ndarray:
"""Cu0을 [x_lo, x_hi] 안에서는 그대로, 밖에서는 양끝 접선으로 연장한 y값."""
deriv = cu.deriv()
y = cu(xs)
y_lo = cu(x_lo) + deriv(x_lo) * (xs - x_lo)
y_hi = cu(x_hi) + deriv(x_hi) * (xs - x_hi)
y = np.where(xs < x_lo, y_lo, y)
y = np.where(xs > x_hi, y_hi, y)
return y
def crop_to_alpha_bbox(alpha: np.ndarray, rgb: np.ndarray) -> tuple[np.ndarray, tuple[int, int, int, int]]:
ys, xs = np.where(alpha > 0)
x0, x1 = int(xs.min()), int(xs.max()) + 1
y0, y1 = int(ys.min()), int(ys.max()) + 1
out = np.zeros((y1 - y0, x1 - x0, 4), dtype=np.uint8)
out[..., :3] = rgb[y0:y1, x0:x1]
out[..., 3] = alpha[y0:y1, x0:x1]
return out, (x0, y0, x1 - x0, y1 - y0)
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
global LM
LM["Lc"] = tuple(lm["mouthCornerLeft"])
LM["Rc"] = tuple(lm["mouthCornerRight"])
LM["T"] = tuple(lm["upperLipTopCenter"])
LM["B"] = tuple(lm["lowerLipBottomCenter"])
points = detect_face_landmarks(BASE_DIR / "base-front.png")
mouth_center = compute_mouth_center(points)
LM["Mc"] = mouth_center
print(f"mouthCenter(재계산) = {mouth_center}")
lc, rc, t, b, mc = LM["Lc"], LM["Rc"], LM["T"], LM["B"], LM["Mc"]
cu = curve_fit(lc, mc, rc)
x_lo, x_hi = (lc[0], rc[0]) if lc[0] <= rc[0] else (rc[0], lc[0])
margin_x = (SIZE_CAP_W - (rc[0] - lc[0])) / 2.0
margin_y = (SIZE_CAP_H - (b[1] - t[1])) / 2.0
cap_x0, cap_x1 = lc[0] - margin_x, rc[0] + margin_x
cap_y0, cap_y1 = t[1] - margin_y, b[1] + margin_y
print(f"입술 마스크 검색 bbox(캡) = [{cap_x0:.1f},{cap_y0:.1f},{cap_x1:.1f},{cap_y1:.1f}] "
f"w={cap_x1-cap_x0:.1f}(<=162) h={cap_y1-cap_y0:.1f}(<=70)")
icx0, icy0, icx1, icy1 = (int(round(cap_x0)), int(round(cap_y0)), int(round(cap_x1)), int(round(cap_y1)))
wx0 = icx0 - int(WORKING_MARGIN)
wy0 = icy0 - int(WORKING_MARGIN)
wx1 = icx1 + int(WORKING_MARGIN)
wy1 = icy1 + int(WORKING_MARGIN)
front_full = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
faceless_full = np.array(Image.open(BASE_DIR / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
front_work = front_full[wy0:wy1, wx0:wx1]
faceless_work = faceless_full[wy0:wy1, wx0:wx1]
front_lum_full = front_full.astype(np.float64).mean(axis=2)
faceless_lum_full = faceless_full.mean(axis=2)
diff_full = np.clip(faceless_lum_full - front_lum_full, 0.0, None)
# --- 입술 색+diff 마스크: 캡 bbox 안에서만 계산, 작업 캔버스에 배치(마스크 계산은 A2'와 동일) ---
cap_rgb = front_full[icy0:icy1, icx0:icx1]
lab = cv2.cvtColor(cap_rgb, cv2.COLOR_RGB2LAB)
a_chan = lab[..., 1].astype(np.float64)
u8 = np.clip(np.round(a_chan), 0, 255).astype(np.uint8)
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
lip_color_mask_cap = a_chan >= otsu_thresh
diff_cap = diff_full[icy0:icy1, icx0:icx1]
diff_mask_cap = diff_cap >= DIFF_THRESH
combined_cap = lip_color_mask_cap & diff_mask_cap
filled_cap = ndimage.binary_fill_holes(combined_cap)
lbl, n = ndimage.label(filled_cap)
sizes = ndimage.sum(filled_cap, lbl, range(1, n + 1)) if n else np.array([])
keep = np.isin(lbl, [i + 1 for i, s in enumerate(sizes) if s >= MIN_COMPONENT_AREA]) if n else np.zeros_like(lbl, dtype=bool)
combined_cap = filled_cap & keep
print(f"otsu(Lab a채널, 캡 bbox) = {otsu_thresh}, 입술 색 마스크 픽셀 = {int(combined_cap.sum())}")
combined_work = np.zeros((wy1 - wy0, wx1 - wx0), dtype=bool)
combined_work[icy0 - wy0:icy1 - wy0, icx0 - wx0:icx1 - wx0] = combined_cap
h, w = combined_work.shape
# --- 중립 입 선 곡선 Cu0(연장) → 위/아래 부호 d ---
xs_abs = np.arange(wx0, wx1, dtype=np.float64)
cu0_y = extended_curve(cu, x_lo, x_hi, xs_abs) # (w,)
rows_local = np.arange(h)[:, None]
yy_abs = rows_local + wy0
d = yy_abs - cu0_y[None, :] # d<=0: 윗입술 쪽, d>0: 아랫입술 쪽
# --- R = 입술 색 마스크를 10px 넓힌 영역(윗/아랫 스프라이트 전용, A5부터 그늘 띠 제외) ---
dist_out = ndimage.distance_transform_edt(~combined_work)
region = combined_work | (dist_out <= DILATE_PX)
print(f"R(확장, 윗/아랫 스프라이트) 픽셀 = {int(region.sum())}")
if region[0, :].any() or region[-1, :].any() or region[:, 0].any() or region[:, -1].any():
raise SystemExit("[중단] R이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
# --- 알파: R 기준 거리 변환 1회 → 분할선과 무관한 램프 ---
dist_in = ndimage.distance_transform_edt(region)
alpha_frac = np.clip(dist_in / RAMP_PX, 0.0, 1.0)
alpha_full = np.rint(alpha_frac * 255.0).astype(np.uint8)
alpha_full[~region] = 0
# 윗 스프라이트는 분할선(Cu0) 아래로 SPLIT_OVERLAP_PX만큼 겹쳐 담는다(이음매 방지, A4).
# 아랫 스프라이트는 그대로 d>0. 겹침 구간(0<d<=SPLIT_OVERLAP_PX)은 두 스프라이트 모두
# 같은 alpha_full·같은 front_work 픽셀을 담으므로 렌더러가 위(윗 스프라이트)를 덮어
# 그리면 중립 합성은 바뀌지 않는다.
mask_upper = region & (d <= SPLIT_OVERLAP_PX)
mask_lower = region & (d > 0)
alpha_upper = np.where(mask_upper, alpha_full, 0).astype(np.uint8)
alpha_lower = np.where(mask_lower, alpha_full, 0).astype(np.uint8)
overlap = mask_upper & mask_lower
overlap_expected = region & (d > 0) & (d <= SPLIT_OVERLAP_PX)
overlap_band_ok = bool(np.array_equal(overlap, overlap_expected))
overlap_alpha_diff = int(np.abs(alpha_upper[overlap].astype(np.int32) - alpha_lower[overlap].astype(np.int32)).max()) if overlap.any() else 0
overlap_consistent_ok = overlap_alpha_diff == 0
coverage_mismatch = region ^ (mask_upper | mask_lower)
split_ok = bool(overlap_band_ok and overlap_consistent_ok and coverage_mismatch.sum() == 0)
print(f"검사(2) 겹침 픽셀수={int(overlap.sum())}(전부 Cu0 아래 {SPLIT_OVERLAP_PX}px 띠 안={overlap_band_ok}) "
f"겹침 알파 불일치 최대차={overlap_alpha_diff} 틈(불일치) 픽셀수={int(coverage_mismatch.sum())} "
f"{'OK' if split_ok else '[실패]'}")
# --- 그늘 조각(lip-shadow, A5): 아랫입술 마스크(combined_work & d>0) 아래 경계에서
# 열마다 18px 띠. 위쪽은 마스크 경계보다 6px 위까지 겹쳐 담고, 그 6px 안에서만 4px
# 램프를 쓴다. 아래·옆(열이 끊기는 곳)은 6px 램프다. ---
lower_lip_mask = combined_work & (d > 0)
masked_rows = np.where(lower_lip_mask, rows_local, -1)
y_bottom_local = masked_rows.max(axis=0) # (w,), 마스크 없는 열은 -1
has_col = y_bottom_local >= 0
shadow_top_local = y_bottom_local.astype(np.float64) - SHADOW_TOP_OVERLAP_PX # (w,) 그늘 조각 윗 경계(제외)
shadow_bottom_local = y_bottom_local.astype(np.float64) + BAND_PX # (w,) 그늘 조각 아래 경계(포함)
shadow_region = (
has_col[None, :]
& (rows_local > shadow_top_local[None, :])
& (rows_local <= shadow_bottom_local[None, :])
)
print(f"그늘 조각 영역 픽셀 = {int(shadow_region.sum())} (열 수 {int(has_col.sum())})")
if shadow_region[0, :].any() or shadow_region[-1, :].any() or shadow_region[:, 0].any() or shadow_region[:, -1].any():
raise SystemExit("[중단] 그늘 조각이 작업 캔버스 가장자리에 닿았다 — WORKING_MARGIN을 늘려야 한다.")
# 아래·옆 램프: 위쪽 경계를 두지 않은(열이 있으면 아래 경계까지는 전부 포함) 영역을
# 따로 거리 변환해, 옆(열이 끊기는 곳)·아래 가장자리에서만 램프가 생기게 한다.
outer_calc_region = has_col[None, :] & (rows_local <= shadow_bottom_local[None, :])
dist_in_outer = ndimage.distance_transform_edt(outer_calc_region)
alpha_outer_frac = np.clip(dist_in_outer / SHADOW_SIDE_RAMP_PX, 0.0, 1.0)
# 위쪽 겹침 램프: 그늘 조각 윗 경계(마스크 경계 6px 위)에서 4px에 걸쳐 0→1.
dist_from_top = rows_local - shadow_top_local[None, :]
alpha_top_frac = np.clip(dist_from_top / SHADOW_TOP_RAMP_PX, 0.0, 1.0)
alpha_shadow_frac = np.minimum(alpha_outer_frac, alpha_top_frac)
alpha_shadow = np.where(shadow_region, np.rint(alpha_shadow_frac * 255.0), 0).astype(np.uint8)
sprites = {
"upper": {"alpha": alpha_upper, "rgb": front_work, "mask": mask_upper},
"lower": {"alpha": alpha_lower, "rgb": front_work, "mask": mask_lower},
"shadow": {"alpha": alpha_shadow, "rgb": front_work, "mask": shadow_region},
}
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
checks: dict = {}
file_sizes_png = {}
canvas_origin: dict[str, list[float]] = {}
for half, key in (("upper", "lip-upper"), ("lower", "lip-lower"), ("shadow", "lip-shadow")):
s = sprites[half]
png_arr, bbox = crop_to_alpha_bbox(s["alpha"], s["rgb"])
out_path = LAYERS_V2_DIR / f"{key}.png"
Image.fromarray(png_arr, "RGBA").save(out_path)
file_sizes_png[key] = out_path.stat().st_size
canvas_origin[half] = [round(wx0 + bbox[0], 1), round(wy0 + bbox[1], 1)]
print(f"저장: {out_path} bbox(작업캔버스 로컬)={bbox} 캔버스원점={canvas_origin[half]} size={png_arr.shape[1]}x{png_arr.shape[0]}")
# --- 검사(1) 알파 255 영역의 PNG 픽셀 = base-front(원화 픽셀을 지우거나 채우지 않았으므로 항상 0) ---
full_op = s["alpha"] == 255
ramp = (s["alpha"] > 0) & (s["alpha"] < 255)
diff_full_op = np.abs(s["rgb"][full_op].astype(np.int32) - front_work[full_op].astype(np.int32))
max_diff_full_op = int(diff_full_op.max()) if full_op.any() else 0
check1 = max_diff_full_op == 0
print(f" 검사(1) [{key}] 알파255 영역 PNG 최대차={max_diff_full_op} "
f"(알파255 {int(full_op.sum())}px, 램프 {int(ramp.sum())}px) {'OK' if check1 else '[실패]'}")
checks[key] = {
"regionPixelCount": int(s["mask"].sum()),
"fullOpacityPixelCount": int(full_op.sum()),
"rampPixelCount": int(ramp.sum()),
"preserveMaxDiffFullOpacity": max_diff_full_op,
"check1Preserve": bool(check1),
"spriteBBoxWorkingLocal": list(bbox),
}
# --- 검사(3) 중립 합성: base-faceless-padded 위에 아랫 → 그늘 → 윗 스프라이트 순으로
# 알파 합성한다(§8.4 그리는 순서와 같게) 대 base-front. ---
front_f64 = front_work.astype(np.float64)
alpha_lower_frac = (alpha_lower.astype(np.float64) / 255.0)[..., None]
step_lower = faceless_work * (1.0 - alpha_lower_frac) + front_f64 * alpha_lower_frac
alpha_shadow_frac3 = (alpha_shadow.astype(np.float64) / 255.0)[..., None]
step_shadow = step_lower * (1.0 - alpha_shadow_frac3) + front_f64 * alpha_shadow_frac3
alpha_upper_frac = (alpha_upper.astype(np.float64) / 255.0)[..., None]
composite = step_shadow * (1.0 - alpha_upper_frac) + front_f64 * alpha_upper_frac
# 실효 알파(세 장을 겹쳐 그린 뒤 실제로 얼마나 덮였는지, over-over-over 합성식) — 보고용 분류 기준.
eff_alpha = 255.0 - (
(255.0 - alpha_upper.astype(np.float64))
* (255.0 - alpha_shadow.astype(np.float64))
* (255.0 - alpha_lower.astype(np.float64))
/ (255.0 ** 2)
)
cap_sl = (slice(icy0 - wy0, icy1 - wy0), slice(icx0 - wx0, icx1 - wx0))
eff_alpha_cap = eff_alpha[cap_sl]
diff_cap = np.abs(composite[cap_sl] - front_f64[cap_sl])
full_op_cap = eff_alpha_cap >= 254.999
ramp_cap = (eff_alpha_cap > 0) & (eff_alpha_cap < 254.999)
max_diff_full_op_cap = float(diff_cap[full_op_cap].max()) if full_op_cap.any() else 0.0
mean_diff_ramp_cap = float(diff_cap[ramp_cap].mean()) if ramp_cap.any() else 0.0
mean_diff_all_cap = float(diff_cap.mean())
print(f"검사(3) 중립 합성(아랫->그늘->윗) 대 base-front 대비 알파255 최대차={max_diff_full_op_cap:.3f} "
f"램프 평균차={mean_diff_ramp_cap:.3f} 입bbox 전체 평균차={mean_diff_all_cap:.3f}")
# --- 검사(패킷 A5 목표 5-3) 그늘 조각과 아랫 스프라이트의 겹침 영역: (F+아랫+그늘) 합성이 원화와 같다 ---
overlap_shadow_lower = (alpha_shadow > 0) & (alpha_lower > 0)
diff_overlap_arr = np.abs(step_shadow - front_f64).max(axis=2)
diff_overlap = diff_overlap_arr[overlap_shadow_lower]
max_diff_overlap = float(diff_overlap.max()) if overlap_shadow_lower.any() else 0.0
diff_overlap_gt1_count = int((diff_overlap > 1.0).sum())
shadow_lower_overlap_ok = max_diff_overlap == 0.0
print(f"검사(5-3) 그늘·아랫 겹침 픽셀수={int(overlap_shadow_lower.sum())} "
f"(F+아랫+그늘) 대 원화 최대차={max_diff_overlap:.3f} diff>1 픽셀수={diff_overlap_gt1_count} "
f"{'OK' if shadow_lower_overlap_ok else '[실패, 원인은 manifest 참고]'}")
total_png = sum(file_sizes_png.values())
print(f"PNG 파일 크기 합계(참고, WebP 아님) = {total_png} bytes")
_save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow)
manifest["lipTexture"] = {
"designVersion": "A5 (2단계-B-1d-A5, 작업 패킷 A5 — 아랫입술 그늘 띠를 lip-shadow 조각으로 분리)",
"landmarks": {"mouthCornerLeft": list(lc), "mouthCornerRight": list(rc),
"upperLipTop": list(t), "lowerLipBottom": list(b), "mouthCenter": list(mc)},
"capBBox": [round(cap_x0, 2), round(cap_y0, 2), round(cap_x1, 2), round(cap_y1, 2)],
"otsuThreshLabA": otsu_thresh,
"diffThresh": DIFF_THRESH,
"dilatePx": DILATE_PX,
"alphaRampPx": RAMP_PX,
"splitOverlapPx": SPLIT_OVERLAP_PX,
"shadowBandPx": BAND_PX,
"shadowTopOverlapPx": SHADOW_TOP_OVERLAP_PX,
"shadowSideRampPx": SHADOW_SIDE_RAMP_PX,
"shadowTopRampPx": SHADOW_TOP_RAMP_PX,
"checks": {
**checks,
"splitOverlapPixelCount": int(overlap.sum()),
"splitOverlapBandOk": overlap_band_ok,
"splitOverlapAlphaMaxDiff": overlap_alpha_diff,
"splitOverlapConsistentOk": overlap_consistent_ok,
"splitCoverageMismatchPixelCount": int(coverage_mismatch.sum()),
"splitCheckOk": split_ok,
"shadowLowerOverlapPixelCount": int(overlap_shadow_lower.sum()),
"shadowLowerOverlapMaxDiff": max_diff_overlap,
"shadowLowerOverlapDiffGt1PixelCount": diff_overlap_gt1_count,
"shadowLowerOverlapOk": shadow_lower_overlap_ok,
"shadowLowerOverlapNote": (
"국소 실패(입꼬리 첨점 부근, diff>1인 픽셀 수 참고). 원인: 두 입꼬리 근처에서 "
"원화 입술 색 마스크가 하이라이트로 끊겨 윗/아랫 두 블롭으로 갈라지고, "
"기존 Cu0 부호 분할(A3/A4부터의 로직, 이번 작업에서 변경하지 않음)이 그 "
"다리 부분을 '윗'으로 분류해 alpha_lower=0이 되는 지점이 생긴다. 그 자리는 "
"그늘 조각의 옆(열 끊김) 램프 구간과도 겹쳐 그늘 쪽 알파도 완전 불투명이 "
"아니라서 등식이 깨진다. preview/v2/lip-texture.jpg의 원화|중립합성 패널을 "
"3배 확대로 육안 확인한 결과 이음매는 보이지 않는다(오케스트레이터 육안 "
"무해 판단, 2026-10-01)."
),
},
"check3NeutralComposite": {
"fullOpacityMaxDiff": round(max_diff_full_op_cap, 3),
"rampMeanAbsDiff": round(mean_diff_ramp_cap, 3),
"mouthBBoxMeanAbsDiff": round(mean_diff_all_cap, 3),
},
"pngFileSizes": file_sizes_png,
"canvasOrigin": canvas_origin,
"evidenceImage": "preview/v2/lip-texture.jpg",
}
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"manifest.json 갱신: {MANIFEST_PATH}")
return 0
def _save_evidence(front_full, sprites, icx0, icy0, icx1, icy1, wx0, wy0, composite, alpha_upper, alpha_lower, alpha_shadow) -> None:
"""원화 입 | 중립 합성 | 윗 스프라이트(체커) | 아랫 스프라이트(체커) | 그늘 조각(체커), 3배 확대.
bbox는 세 스프라이트 알파>0 영역의 합집합(+pad)에서 구한다 — 10px 확장·그늘
조각(6px 겹침+18px 띠)으로 R이 캡 bbox보다 커졌으므로 고정 pad만으로는 잘릴 수 있다.
"""
union_alpha = (alpha_upper > 0) | (alpha_lower > 0) | (alpha_shadow > 0)
ys_u, xs_u = np.where(union_alpha)
ubx0, uby0 = int(xs_u.min()), int(ys_u.min())
ubx1, uby1 = int(xs_u.max()) + 1, int(ys_u.max()) + 1
pad = EVIDENCE_PAD
bx0, by0 = max(0, wx0 + ubx0 - pad), max(0, wy0 + uby0 - pad)
bx1, by1 = wx0 + ubx1 + pad, wy0 + uby1 + pad
def zoom(arr_rgb, nearest=False):
crop = arr_rgb[by0:by1, bx0:bx1]
im = Image.fromarray(np.clip(crop, 0, 255).astype(np.uint8))
resample = Image.NEAREST if nearest else Image.LANCZOS
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), resample)
panel_orig = zoom(front_full)
# composite는 작업 캔버스 크기의 로컬 배열(원점 wx0,wy0)이다 — 절대좌표를 로컬로 옮겨 잘라야 한다.
comp_bx0, comp_by0 = bx0 - wx0, by0 - wy0
comp_bx1, comp_by1 = bx1 - wx0, by1 - wy0
panel_composite = Image.fromarray(
np.clip(composite[comp_by0:comp_by1, comp_bx0:comp_bx1], 0, 255).astype(np.uint8)
)
panel_composite = panel_composite.resize(
(panel_composite.width * EVIDENCE_ZOOM, panel_composite.height * EVIDENCE_ZOOM), Image.LANCZOS
)
def sprite_on_checker(half):
s = sprites[half]
alpha = s["alpha"]; rgb = s["rgb"]
ys_, xs_ = np.where(alpha > 0)
x0, x1 = xs_.min(), xs_.max() + 1
y0, y1 = ys_.min(), ys_.max() + 1
crop_rgb = rgb[y0:y1, x0:x1].astype(np.float64)
crop_a = (alpha[y0:y1, x0:x1].astype(np.float64) / 255.0)[..., None]
check = np.indices(crop_a.shape[:2])
checker = ((check[0] // 6 + check[1] // 6) % 2) * 60 + 180
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
out = crop_rgb * crop_a + checker3 * (1.0 - crop_a)
im = Image.fromarray(np.clip(out, 0, 255).astype(np.uint8))
return im.resize((im.width * EVIDENCE_ZOOM, im.height * EVIDENCE_ZOOM), Image.NEAREST)
panel_upper = sprite_on_checker("upper")
panel_lower = sprite_on_checker("lower")
panel_shadow = sprite_on_checker("shadow")
panels = [panel_orig, panel_composite, panel_upper, panel_lower, panel_shadow]
gap = 12
max_h = max(p.height for p in panels)
total_w = sum(p.width for p in panels) + gap * (len(panels) - 1)
combined = Image.new("RGB", (total_w, max_h), (255, 255, 255))
x = 0
for p in panels:
combined.paste(p.convert("RGB"), (x, 0))
x += p.width + gap
scale = min(1.0, EVIDENCE_MAX_SIDE / max(combined.size))
if scale < 1.0:
combined = combined.resize((round(combined.width * scale), round(combined.height * scale)), Image.LANCZOS)
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
out_path = PREVIEW_V2_DIR / "lip-texture.jpg"
combined.save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
if __name__ == "__main__":
sys.exit(main())