리노컷 자산 파이프라인 공통화와 캐스트 외형 설계
- P1 전용 스크립트를 docs/avatar-art/linocut-pipeline 으로 옮겨 persona.json 설정으로 일반화(P1 재실행 리그 바이트 동일) - 7명 외형·상징 설계(linocut-cast.md)와 P2~P7 정면 원화 생성 프롬프트, 얼굴 없는 화풍 참조 - P1 원화 생성 프롬프트 보존
This commit is contained in:
parent
f11e76ff18
commit
ecb36d123f
38 changed files with 2393 additions and 990 deletions
|
|
@ -1,452 +0,0 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue