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,49 @@
"""단독 프로세스로 FaceLandmarker를 실행해 478 랜드마크를 JSON으로 출력한다.
(같은 프로세스에서 ImageSegmenter와 함께 쓰면 세그폴트가 재현되어 분리했다.)
사용: python _run_face_landmarks.py <이미지경로> <출력json경로>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image
MODEL_FACE = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
def main() -> int:
image_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_FACE))
options = vision.FaceLandmarkerOptions(
base_options=base_options, running_mode=vision.RunningMode.IMAGE, num_faces=1
)
im = Image.open(image_path).convert("RGB")
w, h = im.size
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.FaceLandmarker.create_from_options(options) as landmarker:
result = landmarker.detect(mp_image)
if not result.face_landmarks:
out_path.write_text(json.dumps({"ok": False}), encoding="utf-8")
print("FACE_LANDMARKS_FAILED")
return 1
lm = result.face_landmarks[0]
pts = [[p.x * w, p.y * h] for p in lm]
out_path.write_text(json.dumps({"ok": True, "width": w, "height": h, "points": pts}), encoding="utf-8")
print(f"FACE_LANDMARKS_OK n={len(pts)}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,45 @@
"""단독 프로세스로 ImageSegmenter(selfie_multiclass_256x256)를 실행해
category_mask를 .npy로 저장한다. (세그폴트 회피를 위해 FaceLandmarker와 분리.)
사용: python _run_segmentation.py <이미지경로> <출력npy경로>
"""
from __future__ import annotations
import sys
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image
MODEL_SEG = Path(__file__).resolve().parent / "_models" / "selfie_multiclass_256x256.tflite"
def main() -> int:
image_path = Path(sys.argv[1])
out_path = Path(sys.argv[2])
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_SEG))
options = vision.ImageSegmenterOptions(
base_options=base_options, output_confidence_masks=False, output_category_mask=True
)
im = Image.open(image_path).convert("RGB")
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.ImageSegmenter.create_from_options(options) as seg:
result = seg.segment(mp_image)
if result.category_mask is None:
print("SEGMENTATION_FAILED")
return 1
category_mask = result.category_mask.numpy_view()[:, :, 0].copy()
np.save(out_path, category_mask)
print(f"SEGMENTATION_OK shape={category_mask.shape}")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,369 @@
"""P1 서연 눈썹 랜드마크를 잉크 띠 중심선으로 정정 — 2단계-B-1d-A(재작업).
1차(반려)는 front-F 밝기 차이만으로 마스크를 만들어 눈두덩 그늘 해칭까지
눈썹으로 잡았고, 열별 지역 평활(Savitzky-Golay)이 평평한 눈썹에서 물결치며
peak를 안쪽 끝 열에 찍었다(오케스트레이터 판정, 2단계-B-1d).
이번 버전:
- 마스크 = (front 휘도가 눈썹 bbox 안 Otsu 임계값 이하, 짙은 잉크) AND
(front가 F보다 25 이상 어두움, 머리카락·눈꺼풀 크레이스 등 F에도 있는
구조물 제외). 두 조건의 교집합이라 중간 톤 그늘 해칭은 Otsu 임계값보다
밝아 빠진다.
- 눈썹은 조각도 해칭 때문에 잉크가 여러 조각으로 끊겨 있다(단일 최대
연결성분만 쓰면 절반 가까이 누락됨을 확인). 그래서 형태학적 closing으로
같은 눈썹에 속한 조각을 하나로 묶은 뒤, 그 묶음 영역 안의 "원본"(닫기
전) 마스크 픽셀만 최종 띠로 쓴다(닫기는 성분을 찾는 데만 쓰고 픽셀을
부풀리지 않는다). 그 안에서 면적 30px 미만 잔점은 버린다.
- 중심선은 전역 2차 다항식 하나를 열별 가중 무게중심에 적합한다(지역
평활 대신 — 평평한 눈썹에서 물결을 만들지 않는다). 잔차 2배 표준편차를
넘는 이상치 열을 한 번 제거하고 재적합한다.
- peak는 적합 곡선의 꼭짓점(2차 다항식은 극값이 하나뿐이다)이 바깥 끝
기준 25~60% 구간에 있으면 그 점, 아니면(구간 밖 = 그 구간에서 단조,
또는 구간 안 높이 차 < 3px = 거의 평평) 바깥 끝에서 35% 지점의 곡선
위 점을 쓴다.
실행: <venv>/python.exe build_brow_centerline.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import cv2
import numpy as np
from PIL import Image, ImageDraw
from scipy import ndimage
SCRIPTS_DIR = Path(__file__).resolve().parent
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
# bbox: old(mediapipe) 세 점 bbox에 이 여백을 더한 영역 안에서 잉크를 찾는다.
BBOX_X_PAD = 25.0
BBOX_Y_UP = 42.0
BBOX_Y_DOWN = 14.0 # 30이면 눈꺼풀 크레이스(다크서클 경계) 잉크가 섞인다(탐색 확인).
DIFF_THRESH = 25.0 # front가 F보다 이만큼 어두우면 "front에만 있는 것"으로 본다
CLOSING_KERNEL = (7, 11) # (세로, 가로) — 눈썹 해칭 조각을 같은 띠로 묶기 위한 형태학적 닫기
MIN_COMPONENT_AREA = 30.0 # 닫기로 묶은 최대 성분 안에서, 이보다 작은 잔점은 버린다
OUTLIER_STD_MULT = 2.0 # 잔차가 표준편차의 이 배수를 넘으면 이상치 열로 제거
EDGE_INSET_FRAC = 0.03 # inner·outer = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
PEAK_WINDOW = (0.25, 0.60) # 바깥 끝 기준 이 구간 안에 꼭짓점이 있으면 그 점을 쓴다
PEAK_FALLBACK_FRAC = 0.35
PEAK_FLAT_HEIGHT_PX = 3.0 # 이 구간 안 높이 차가 이보다 작으면 "거의 평평"으로 본다
PEAK_FAIL_EDGE_FRAC = 0.20 # 최종 peak가 양끝 이 비율 안이면 실패
CHECK_A_MIN_FRAC = 0.80
EVIDENCE_MAX_SIDE = 1000
EVIDENCE_ZOOM = 3
def region_bbox_from_points(inner, peak, outer) -> tuple[int, int, int, int]:
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0 = int(round(min(xs) - BBOX_X_PAD))
x1 = int(round(max(xs) + BBOX_X_PAD))
y0 = int(round(min(ys) - BBOX_Y_UP))
y1 = int(round(max(ys) + BBOX_Y_DOWN))
return x0, y0, x1, y1
def build_ink_band(front_lum: np.ndarray, diff: np.ndarray, x0: int, y0: int, x1: int, y1: int) -> tuple[np.ndarray, float]:
"""bbox 안에서 (Otsu 잉크) AND (front가 F보다 어두움) 마스크를 만들고,
형태학적 닫기로 같은 눈썹 조각을 묶은 최대 성분 안의 원본 픽셀만
남긴 뒤 작은 잔점을 버린다. 반환: (band bool 배열(bbox 로컬 좌표), otsu 임계값)."""
region_lum = front_lum[y0:y1, x0:x1]
u8 = np.clip(np.round(region_lum), 0, 255).astype(np.uint8)
otsu_thresh, _ = cv2.threshold(u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
ink_mask = region_lum <= otsu_thresh
diff_region = diff[y0:y1, x0:x1]
diff_mask = diff_region >= DIFF_THRESH
combined = ink_mask & diff_mask
closed = ndimage.binary_closing(combined, structure=np.ones(CLOSING_KERNEL))
labeled, n = ndimage.label(closed)
if n == 0:
raise SystemExit("[중단] 눈썹 bbox 안에 잉크 마스크가 전혀 없다.")
sizes = ndimage.sum(closed, labeled, range(1, n + 1))
top_label = int(np.argmax(sizes)) + 1
band = combined & (labeled == top_label)
lbl2, n2 = ndimage.label(band)
if n2 > 0:
sizes2 = ndimage.sum(band, lbl2, range(1, n2 + 1))
keep_labels = [i + 1 for i, s in enumerate(sizes2) if s >= MIN_COMPONENT_AREA]
band = band & np.isin(lbl2, keep_labels)
return band, float(otsu_thresh)
def column_weighted_centroid(band: np.ndarray, diff_region: np.ndarray, x0: int, y0: int) -> dict:
colmask = band.any(axis=0)
cols = np.where(colmask)[0]
col_min, col_max = int(cols.min()), int(cols.max())
xs_local = np.arange(col_min, col_max + 1)
centroid_y = np.full(len(xs_local), np.nan)
col_ylo = np.full(len(xs_local), np.nan)
col_yhi = np.full(len(xs_local), np.nan)
for i, cx in enumerate(xs_local):
colpix = band[:, cx]
if not colpix.any():
continue
yy = np.where(colpix)[0]
w = diff_region[yy, cx]
centroid_y[i] = np.average(yy, weights=w) + y0
col_ylo[i] = yy.min() + y0
col_yhi[i] = yy.max() + y0
return {
"col_min": col_min + x0, "col_max": col_max + x0,
"xs_local": xs_local, "centroid_y": centroid_y,
"col_ylo": col_ylo, "col_yhi": col_yhi, "x0": x0,
}
def fit_centerline(xs_abs: np.ndarray, ys: np.ndarray) -> tuple[np.poly1d, int]:
coef = np.polyfit(xs_abs, ys, 2)
fit = np.poly1d(coef)
resid = ys - fit(xs_abs)
std = resid.std()
outlier = np.abs(resid) > OUTLIER_STD_MULT * std if std > 0 else np.zeros_like(resid, dtype=bool)
n_outliers = int(outlier.sum())
if n_outliers > 0 and (~outlier).sum() >= 3:
coef2 = np.polyfit(xs_abs[~outlier], ys[~outlier], 2)
fit = np.poly1d(coef2)
return fit, n_outliers
def find_peak(fit: np.poly1d, col_min: int, col_max: int, outer_edge_x: float, width: float) -> tuple[tuple[float, float], str]:
a, b, _c = fit.coeffs
xs_sample = np.linspace(col_min, col_max, 400)
t_sample = np.abs(xs_sample - outer_edge_x) / width
in_window = (t_sample >= PEAK_WINDOW[0]) & (t_sample <= PEAK_WINDOW[1])
use_fallback = True
vertex_pt: tuple[float, float] | None = None
if abs(a) > 1e-6:
xv = -b / (2 * a)
if col_min <= xv <= col_max:
tv = abs(xv - outer_edge_x) / width
if PEAK_WINDOW[0] <= tv <= PEAK_WINDOW[1]:
window_ys = fit(xs_sample[in_window])
height_diff = float(window_ys.max() - window_ys.min()) if window_ys.size else 0.0
if height_diff >= PEAK_FLAT_HEIGHT_PX:
vertex_pt = (float(xv), float(fit(xv)))
use_fallback = False
if use_fallback:
# 바깥 끝에서 안쪽으로 35% 지점
if outer_edge_x <= col_min + 1e-6:
fx = col_min + PEAK_FALLBACK_FRAC * width
else:
fx = col_max - PEAK_FALLBACK_FRAC * width
peak_pt = (float(fx), float(fit(fx)))
method = "fallback35"
else:
peak_pt = vertex_pt # type: ignore[assignment]
method = "vertex"
return peak_pt, method
def process_brow(name: str, front_lum: np.ndarray, diff: np.ndarray, old_pts: dict) -> dict:
inner_old, peak_old, outer_old = old_pts["inner"], old_pts["peak"], old_pts["outer"]
x0, y0, x1, y1 = region_bbox_from_points(inner_old, peak_old, outer_old)
band, otsu_thresh = build_ink_band(front_lum, diff, x0, y0, x1, y1)
diff_region = diff[y0:y1, x0:x1]
cw = column_weighted_centroid(band, diff_region, x0, y0)
valid = ~np.isnan(cw["centroid_y"])
xs_abs = cw["xs_local"][valid] + x0
ys_valid = cw["centroid_y"][valid]
fit, n_outliers = fit_centerline(xs_abs, ys_valid)
col_min, col_max = cw["col_min"], cw["col_max"]
width = float(col_max - col_min)
inset_px = width * EDGE_INSET_FRAC
left_is_outer = outer_old[0] < inner_old[0]
if left_is_outer:
outer_edge_x = float(col_min)
inner_edge_x = float(col_max)
outer_x = col_min + inset_px
inner_x = col_max - inset_px
else:
outer_edge_x = float(col_max)
inner_edge_x = float(col_min)
outer_x = col_max - inset_px
inner_x = col_min + inset_px
inner_pt = (round(float(inner_x), 2), round(float(fit(inner_x)), 2))
outer_pt = (round(float(outer_x), 2), round(float(fit(outer_x)), 2))
peak_pt_raw, peak_method = find_peak(fit, col_min, col_max, outer_edge_x, width)
peak_pt = (round(peak_pt_raw[0], 2), round(peak_pt_raw[1], 2))
peak_frac = abs(peak_pt_raw[0] - outer_edge_x) / width
peak_fail = peak_frac < PEAK_FAIL_EDGE_FRAC or peak_frac > (1.0 - PEAK_FAIL_EDGE_FRAC)
# 검사(a): 열의 80% 이상에서 적합 곡선 y가 그 열 마스크의 위-아래 끝 사이
n_ok = 0
n_total = int(valid.sum())
for i in range(len(cw["xs_local"])):
if not valid[i]:
continue
cx_abs = cw["xs_local"][i] + x0
fy = float(fit(cx_abs))
if cw["col_ylo"][i] <= fy <= cw["col_yhi"][i]:
n_ok += 1
frac_ok = n_ok / n_total if n_total else 0.0
return {
"name": name, "bbox": [x0, y0, x1, y1], "otsuThresh": otsu_thresh,
"colRange": [col_min, col_max], "width": width,
"nColumns": n_total, "nOutliersRemoved": n_outliers,
"fitCoeffs": [round(float(c), 8) for c in fit.coeffs],
"inner": inner_pt, "peak": peak_pt, "outer": outer_pt,
"peakMethod": peak_method, "peakFracFromOuterEdge": round(float(peak_frac), 4),
"peakFail": bool(peak_fail),
"checkA_fracColumnsFitWithinMask": round(frac_ok, 4),
"checkA_pass": frac_ok >= CHECK_A_MIN_FRAC,
"band": band, "bandX0": x0, "bandY0": y0,
"fit": fit, "colMin": col_min, "colMax": col_max,
}
def draw_evidence_panel(front_img: Image.Image, old_pts: dict, result: dict) -> Image.Image:
x0, y0, x1, y1 = result["bbox"]
pad = 15
box = (max(0, x0 - pad), max(0, y0 - pad), x1 + pad, y1 + pad)
crop = front_img.crop(box).convert("RGB")
crop = crop.resize((crop.width * EVIDENCE_ZOOM, crop.height * EVIDENCE_ZOOM), Image.LANCZOS)
d = ImageDraw.Draw(crop)
ox, oy = box[0], box[1]
def to_panel(px, py):
return ((px - ox) * EVIDENCE_ZOOM, (py - oy) * EVIDENCE_ZOOM)
# 마스크 윤곽선(노랑)
band_u8 = (result["band"].astype(np.uint8)) * 255
contours, _ = cv2.findContours(band_u8, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
bx0, by0 = result["bandX0"], result["bandY0"]
for cnt in contours:
pts = [to_panel(p[0][0] + bx0, p[0][1] + by0) for p in cnt]
if len(pts) >= 2:
d.line(pts + [pts[0]], fill=(230, 200, 0), width=2)
# 적합 중심선(청록)
fit = result["fit"]
xs_line = np.linspace(result["colMin"], result["colMax"], 200)
ys_line = fit(xs_line)
line_pts = [to_panel(x, y) for x, y in zip(xs_line, ys_line)]
d.line(line_pts, fill=(0, 200, 200), width=2)
for key in ("inner", "peak", "outer"):
ox_pt, oy_pt = old_pts[key]
px, py = to_panel(ox_pt, oy_pt)
d.ellipse([px - 5, py - 5, px + 5, py + 5], outline=(230, 30, 30), width=2)
nx_pt, ny_pt = result[key]
px2, py2 = to_panel(nx_pt, ny_pt)
d.ellipse([px2 - 5, py2 - 5, px2 + 5, py2 + 5], fill=(0, 220, 220))
return crop
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
faceless = np.array(Image.open(BASE_DIR / "base-faceless-padded.png").convert("RGB")).astype(np.float64)
front_lum = front.mean(axis=2)
faceless_lum = faceless.mean(axis=2)
diff = np.clip(faceless_lum - front_lum, 0.0, None)
# 재실행 멱등성: 이 스크립트가 landmarks.eyebrowLeft/Right를 덮어쓰므로,
# 진짜 mediapipe 원본은 browCenterline.oldPoints에 한 번 고정해 두고
# 재실행 시 거기서 읽는다(자기가 쓴 결과를 다시 원본으로 오인하지 않는다).
prev_old_points = manifest.get("browCenterline", {}).get("oldPoints")
if prev_old_points is not None:
browL_old = prev_old_points["browLeft"]
browR_old = prev_old_points["browRight"]
else:
browL_old = lm["eyebrowLeft"]
browR_old = lm["eyebrowRight"]
resultL = process_brow("browLeft", front_lum, diff, browL_old)
resultR = process_brow("browRight", front_lum, diff, browR_old)
for result, old_pts in ((resultL, browL_old), (resultR, browR_old)):
print(f"=== {result['name']} ===")
print(f" bbox={result['bbox']} otsuThresh={result['otsuThresh']} colRange={result['colRange']}")
print(f" fit={result['fitCoeffs']} outliers제거={result['nOutliersRemoved']}/{result['nColumns']}")
print(f" old inner={old_pts['inner']} peak={old_pts['peak']} outer={old_pts['outer']}")
print(f" new inner={result['inner']} peak={result['peak']}({result['peakMethod']}) outer={result['outer']}")
print(f" 검사(a) 열 포함 비율 = {result['checkA_fracColumnsFitWithinMask']} (기준>=0.80) {'OK' if result['checkA_pass'] else '[실패]'}")
print(f" peak 위치비율(바깥끝기준) = {result['peakFracFromOuterEdge']} {'[실패:양끝20% 안]' if result['peakFail'] else 'OK'}")
dropL = resultL["inner"][1] - resultL["peak"][1]
dropR = resultR["inner"][1] - resultR["peak"][1]
print(f"검사(b) browLeft inner.y-peak.y = {dropL:.2f} (예상 0~20)")
print(f"검사(b) browRight inner.y-peak.y = {dropR:.2f} (예상 0~20)")
inner_y_symmetry = abs(resultL["inner"][1] - resultR["inner"][1])
peak_y_symmetry = abs(resultL["peak"][1] - resultR["peak"][1])
print(f"검사(d) 좌우 inner.y 차 = {inner_y_symmetry:.2f}")
print(f"검사(d) 좌우 peak.y 차 = {peak_y_symmetry:.2f}")
print(f"검사(c) browLeft peak 위치비율 = {resultL['peakFracFromOuterEdge']} ({resultL['peakMethod']})")
print(f"검사(c) browRight peak 위치비율 = {resultR['peakFracFromOuterEdge']} ({resultR['peakMethod']})")
front_img = Image.open(BASE_DIR / "base-front.png").convert("RGB")
panelL = draw_evidence_panel(front_img, browL_old, resultL)
panelR = draw_evidence_panel(front_img, browR_old, resultR)
gap = 20
combined = Image.new("RGB", (panelL.width + panelR.width + gap, max(panelL.height, panelR.height)), (255, 255, 255))
combined.paste(panelL, (0, 0))
combined.paste(panelR, (panelL.width + gap, 0))
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)
evidence_path = PREVIEW_V2_DIR / "brow-centerline.jpg"
combined.convert("RGB").save(evidence_path, "JPEG", quality=90)
print(f"저장: {evidence_path}")
lm["eyebrowLeft"] = {"inner": list(resultL["inner"]), "peak": list(resultL["peak"]), "outer": list(resultL["outer"])}
lm["eyebrowRight"] = {"inner": list(resultR["inner"]), "peak": list(resultR["peak"]), "outer": list(resultR["outer"])}
def strip_for_json(r: dict) -> dict:
return {k: v for k, v in r.items() if k not in ("band", "fit")}
manifest["browCenterline"] = {
"method": "Otsu(front lum in bbox) AND diff(F-front)>=25, morphological closing to merge hatching, "
"single global degree-2 polyfit on column-weighted centroid with one-pass outlier removal",
"diffThresh": DIFF_THRESH,
"bboxPad": {"x": BBOX_X_PAD, "yUp": BBOX_Y_UP, "yDown": BBOX_Y_DOWN},
"closingKernel": list(CLOSING_KERNEL),
"minComponentAreaPx": MIN_COMPONENT_AREA,
"outlierStdMult": OUTLIER_STD_MULT,
"edgeInsetFrac": EDGE_INSET_FRAC,
"peakWindow": list(PEAK_WINDOW),
"peakFallbackFrac": PEAK_FALLBACK_FRAC,
"peakFlatHeightPx": PEAK_FLAT_HEIGHT_PX,
"peakFailEdgeFrac": PEAK_FAIL_EDGE_FRAC,
"oldPoints": {"browLeft": browL_old, "browRight": browR_old},
"newPoints": {"browLeft": strip_for_json(resultL), "browRight": strip_for_json(resultR)},
"checkB_innerMinusPeakY": {"browLeft": round(dropL, 2), "browRight": round(dropR, 2)},
"checkD_symmetry": {"innerYDiff": round(inner_y_symmetry, 2), "peakYDiff": round(peak_y_symmetry, 2)},
"evidenceImage": "preview/v2/brow-centerline.jpg",
}
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())

View file

@ -0,0 +1,740 @@
"""P1 서연 faceDetail 레이어 생성 — 2단계-B-1a (3), 결정문 §8.2.
base-front.png(눈·눈썹·입·점이 있는 원본)에서 이목구비 잉크선 전체를 덮는
"제외 영역"을 랜드마크 기준으로 넉넉히 잡고, 그 안은 base-faceless-padded(F)
픽셀을 그대로 쓴다(비율 보간·블러 없음). 제외 영역 밖은 base-front를 그대로
쓴다(다크서클 해칭·입가 음영 등 살려야 할 그늘은 그대로 남는다). 제외 영역
경계는 10px 페더로 base-front↔F를 섞는다.
F 자체에 남아 있는 잔여 잉크 중 "선 모양"(형태학적 opening으로 얇은 성분만
추출)만 복제 도장으로 지운다 — 넓고 부드러운 그늘(브로우뼈 음영 등)은 F의
진짜 결이므로 건드리지 않는다.
머리카락(분할 category=1, 2px 팽창)이 덮는 자리는 faceDetail 알파를 0으로
둔다 — head 레이어의 같은 가닥과 겹쳐 보이는 것을 막는다.
실행: <venv>/python.exe build_face_detail.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from scipy.ndimage import (
binary_closing, binary_dilation, binary_erosion, binary_fill_holes, binary_opening,
distance_transform_edt, gaussian_filter, label,
)
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers_segmented import build_padded_faceless, to_u8, alpha_bbox, run_segmentation, composite_over # noqa: E402
from build_layers_v2 import CREAM_BG # 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"
# --- 영역(region) 상수: faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영 포함) --
REGION_FEATHER_PX = 12.0
MOLE_CENTER = (661.3, 627.9)
MOLE_RADIUS = 20.0
# --- 제외 영역(구멍) 상수: 이목구비 잉크선을 덮는 좁은 범위 ------------------
EYE_INK_MARGIN = 32.0
EYE_INK_THRESH = 150.0
EYE_CREASE_UP_PX = 18 # 쌍꺼풀 주름 커버(위로)
EYE_OUTER_EXT_PX = 12 # 바깥 꼬리 커버
EYE_FINAL_DILATE_PX = 6 # 위/바깥/눈 틈에 적용(아래쪽은 별도)
# 아랫눈꺼풀은 선 자체만 좁게 덮는다 — 다크서클 해칭(그 아래 살려야 할 그늘)을
# 먹지 않기 위해 위/바깥과는 다른(더 좁은) 팽창·페더를 쓴다.
EYE_LOWER_LINE_ABOVE_PX = 2.0
EYE_LOWER_LINE_BELOW_PX = 5.0
EYE_LOWER_DILATE_PX = 2
EYE_LOWER_FEATHER_PX = 4.0
EYE_INK_LOWER_CAP_PX = 7.0 # eye_dark_hole 연결요소가 다크서클 해칭까지 붙어 나오는 것을 자른다
DARK_CIRCLE_BAND_Y0 = 10.0 # 아랫눈꺼풀 아래 10~35px
DARK_CIRCLE_BAND_Y1 = 35.0
DARK_CIRCLE_BAND_HALF_W = 35.0 # 홍채 중심 x ±35px
DARK_CIRCLE_MATCH_TOLERANCE = 6.0
BROW_INK_MARGIN = 20.0
BROW_INK_THRESH = 150.0
BROW_STROKE_HALF_WIDTH = 14 # 랜드마크 곡선 ±14px 띠
BROW_FINAL_DILATE_PX = 10
MOUTH_INK_MARGIN = 15.0
MOUTH_INK_THRESH = 150.0
MOUTH_CORNER_EXT_PX = 12
MOUTH_SHADOW_EXT_PX = 24 # 아랫입술 아래 그늘선 커버
MOUTH_FINAL_DILATE_PX = 8
EXCLUSION_BOUNDARY_FEATHER_PX = 10.0
HAIR_DILATE_PX = 2
# --- 잔여 잉크(선 모양만) 복제 도장 상수 -------------------------------------
CLONE_TARGET_THRESH = 65.0
AVOID_THRESH = 110.0
LINE_OPENING_ITER = 3
CLONE_STAMP_FEATHER_PX = 4.0
CLONE_STAMP_CANDIDATES = [
(0, -40), (0, -60), (-40, 0), (40, 0), (0, 40), (0, 60),
(-60, 0), (60, 0), (0, -80), (0, 80), (-40, -40), (40, -40), (-40, 40), (40, 40),
(0, -100), (0, -120), (0, -150), (0, -180), (0, 100), (0, 120),
(-80, 0), (80, 0), (-100, 0), (100, 0), (-120, -60), (120, -60), (-60, -100), (60, -100),
]
# --- 검사 상수 ---------------------------------------------------------------
RING_PX = 12
HF_SIGMA = 2.0
LINE_SIGMA = 3.0
CHECKD_THRESH = 0.7
CHECKE_THRESH = 1.5
GENERIC_PATCH_SIZE = 48
GENERIC_PATCH_MARGIN = 40
def ellipse_alpha(cx: float, cy: float, rx: float, ry: float, w: int, h: int, feather: float) -> np.ndarray:
yy, xx = np.mgrid[0:h, 0:w].astype(np.float64)
d = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2
alpha = (d <= 1.0).astype(np.float64) * 255.0
alpha = gaussian_filter(alpha, sigma=feather / 2.0)
return np.clip(alpha, 0, 255)
def eyebrow_mask(inner, peak, outer, w: int, h: int, width: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
d = ImageDraw.Draw(img)
d.line([tuple(outer), tuple(peak), tuple(inner)], fill=255, width=width, joint="curve")
d.ellipse([outer[0] - width / 2, outer[1] - width / 2, outer[0] + width / 2, outer[1] + width / 2], fill=255)
d.ellipse([inner[0] - width / 2, inner[1] - width / 2, inner[0] + width / 2, inner[1] + width / 2], fill=255)
return np.array(img) > 127
def lens_polygon(inner, outer, upper, lower, pad: float, n: int = 24) -> list[tuple[float, float]]:
def quad_bezier(p0, p1, p2, n):
ts = np.linspace(0, 1, n)
pts = []
for t in ts:
x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
pts.append((x, y))
return pts
upper_ctrl = (upper[0], upper[1] - pad)
lower_ctrl = (inner[0], lower[1] + pad)
upper_curve = quad_bezier(outer, upper_ctrl, inner, n)
lower_curve = quad_bezier(inner, lower_ctrl, outer, n)
return upper_curve + lower_curve
def nearest_component_label(labeled: np.ndarray, py: float, px: float) -> int:
"""(py,px) 위치의 라벨을 쓰되, 그 지점이 어떤 연결요소에도 안 속하면(랜드마크가
잉크 픽셀에서 살짝 벗어난 경우) 가장 가까운 연결요소의 라벨을 대신 쓴다."""
h, w = labeled.shape
iy, ix = int(round(py)), int(round(px))
iy = min(max(iy, 0), h - 1)
ix = min(max(ix, 0), w - 1)
seed_label = int(labeled[iy, ix])
if seed_label != 0:
return seed_label
if not (labeled != 0).any():
return 0
_, (near_y, near_x) = distance_transform_edt(labeled == 0, return_indices=True)
return int(labeled[near_y[iy, ix], near_x[iy, ix]])
def eye_dark_hole(
eye: dict, lum: np.ndarray, w: int, h: int, margin: float = EYE_INK_MARGIN,
thresh: float = EYE_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈 틈(공막·홍채·동공·아이라이너 잉크) 전체를 명도 임계값 + 연결요소로 찾는다."""
lens = lens_polygon(eye["innerCorner"], eye["outerCorner"], eye["upperLidTop"], eye["lowerLidBottom"], pad=6.0)
xs = [p[0] for p in lens]
ys = [p[1] for p in lens]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin, max(ys) + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
icx, icy = eye["iris"]["center"]
seed_label = nearest_component_label(labeled, icy, icx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def lower_lid_line_mask(
inner, outer, lower, w: int, h: int, above: float = EYE_LOWER_LINE_ABOVE_PX, below: float = EYE_LOWER_LINE_BELOW_PX
) -> np.ndarray:
"""아랫눈꺼풀 선(랜드마크 곡선) 자체만 위로 above px, 아래로 below px 두께로
감싸는 좁은 띠를 만든다 — 다크서클 해칭을 먹지 않기 위해 이 아래는 손대지 않는다."""
n = 40
ts = np.linspace(0, 1, n)
curve = [
((1 - t) ** 2 * outer[0] + 2 * (1 - t) * t * lower[0] + t ** 2 * inner[0],
(1 - t) ** 2 * outer[1] + 2 * (1 - t) * t * lower[1] + t ** 2 * inner[1])
for t in ts
]
upper_edge = [(x, y - above) for x, y in curve]
lower_edge = [(x, y + below) for x, y in curve]
poly = upper_edge + lower_edge[::-1]
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(poly, fill=255)
return np.array(img) > 127
def dark_circle_band_mask(eye: dict, w: int, h: int) -> np.ndarray:
"""아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35px 띠(다크서클 해칭 검사용)."""
icx = eye["iris"]["center"][0]
lower_y = eye["lowerLidBottom"][1]
y0, y1 = lower_y + DARK_CIRCLE_BAND_Y0, lower_y + DARK_CIRCLE_BAND_Y1
x0, x1 = icx - DARK_CIRCLE_BAND_HALF_W, icx + DARK_CIRCLE_BAND_HALF_W
m = np.zeros((h, w), dtype=bool)
yy0, yy1 = max(0, int(round(y0))), min(h, int(round(y1)))
xx0, xx1 = max(0, int(round(x0))), min(w, int(round(x1)))
m[yy0:yy1, xx0:xx1] = True
return m
def brow_dark_hole(
brow: dict, lum: np.ndarray, w: int, h: int, margin: float = BROW_INK_MARGIN,
thresh: float = BROW_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""눈썹 잉크(굵은 털 뭉치) 전체를 명도 임계값 + 연결요소로 찾는다(eye_dark_hole과 같은 방식)."""
inner, peak, outer = brow["inner"], brow["peak"], brow["outer"]
xs = [inner[0], peak[0], outer[0]]
ys = [inner[1], peak[1], outer[1]]
x0, x1 = min(xs) - margin, max(xs) + margin
y0, y1 = min(ys) - margin - 10, max(ys) + margin + 15
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
pcx, pcy = peak
seed_label = nearest_component_label(labeled, pcy, pcx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def mouth_dark_hole(
mcL, mcR, upLip, loLip, lum: np.ndarray, w: int, h: int, margin: float = MOUTH_INK_MARGIN,
thresh: float = MOUTH_INK_THRESH, close_iter: int = 3
) -> np.ndarray:
"""입술 잉크(윗/아랫입술 선·안쪽 그늘) 전체를 명도 임계값 + 연결요소로 찾는다."""
x0, x1 = min(mcL[0], mcR[0]) - margin, max(mcL[0], mcR[0]) + margin
y0, y1 = upLip[1] - margin, loLip[1] + margin
bcx, bcy = (x0 + x1) / 2, (y0 + y1) / 2
brx, bry = (x1 - x0) / 2, (y1 - y0) / 2
yy, xx = np.mgrid[0:h, 0:w]
search_ellipse = (((xx - bcx) / brx) ** 2 + ((yy - bcy) / bry) ** 2) <= 1.0
dark = search_ellipse & (lum < thresh)
dark = binary_closing(dark, iterations=close_iter)
labeled, _ = label(dark)
ccx, ccy = (mcL[0] + mcR[0]) / 2, (upLip[1] + loLip[1]) / 2
seed_label = nearest_component_label(labeled, ccy, ccx)
if seed_label == 0:
return np.zeros((h, w), dtype=bool)
comp = labeled == seed_label
return binary_fill_holes(comp)
def shift_mask(mask: np.ndarray, dy: int, dx: int) -> np.ndarray:
"""mask를 (dy,dx)만큼 평행이동한다(래핑 없음, 밖으로 밀려난 부분은 버림)."""
h, w = mask.shape
out = np.zeros_like(mask)
src_y0, src_y1 = max(0, -dy), h - max(0, dy)
dst_y0, dst_y1 = max(0, dy), h - max(0, -dy)
src_x0, src_x1 = max(0, -dx), w - max(0, dx)
dst_x0, dst_x1 = max(0, dx), w - max(0, -dx)
if src_y1 <= src_y0 or src_x1 <= src_x0:
return out
out[dst_y0:dst_y1, dst_x0:dst_x1] = mask[src_y0:src_y1, src_x0:src_x1]
return out
def grow_directional(mask: np.ndarray, dy: int = 0, dx: int = 0) -> np.ndarray:
"""mask를 (dy,dx) 방향으로 1px씩 단계적으로 밀어 그 방향으로 최대
|dy| 또는 |dx| px까지 덮는다(원래 mask가 있던 자리에서 그 방향으로 "그림자를
드리운" 모양) — 눈 주름·아랫눈꺼풀·입가 그늘처럼 특정 방향으로만 구멍을
넓힐 때 쓴다."""
steps = max(abs(dy), abs(dx), 1)
out = mask.copy()
for i in range(1, steps + 1):
fy = round(dy * i / steps)
fx = round(dx * i / steps)
out |= shift_mask(mask, fy, fx)
return out
def line_only_mask(dark_mask: np.ndarray, iterations: int = LINE_OPENING_ITER) -> np.ndarray:
"""넓고 부드러운 그늘(오프닝으로 살아남는 덩어리)을 빼고, 얇은 선 성분만 남긴다."""
opened = binary_opening(dark_mask, iterations=iterations)
return dark_mask & ~opened
def clone_stamp_fill(f_arr: np.ndarray, bad_mask: np.ndarray, avoid_mask: np.ndarray) -> tuple[np.ndarray, list[dict]]:
"""bad_mask(F에 남은 선 모양 잔여 잉크)의 연결요소마다 avoid_mask(제외 영역 ∪
머리카락 ∪ F 잔여 잉크)를 피하는 이웃 패치를 후보 오프셋에서 찾아 그대로
옮겨 붙인다 — 복제 도장. 블러 없이 원본 해칭 텍스처를 재배치만 한다."""
h, w = bad_mask.shape
labeled, n = label(bad_mask)
out = f_arr.copy()
good = ~avoid_mask
report: list[dict] = []
for comp_id in range(1, n + 1):
comp = labeled == comp_id
ys, xs = np.where(comp)
y0, y1, x0, x1 = int(ys.min()), int(ys.max()) + 1, int(xs.min()), int(xs.max()) + 1
comp_sub = comp[y0:y1, x0:x1]
# shift_mask(f_arr, dy, dx)는 출력 위치 y의 값을 입력 위치 y-dy에서
# 가져온다(아래로 dy만큼 미는 것) — 그래서 comp가 실제로 퍼오는 소스
# 좌표는 y0-dy..y1-dy, x0-dx..x1-dx이다. 여기를 검증해야 한다(이전에는
# y0+dy로 반대 방향을 검증하는 부호 버그가 있었다).
chosen = None
best_std = -1.0
for dx, dy in CLONE_STAMP_CANDIDATES:
sy0, sy1, sx0, sx1 = y0 - dy, y1 - dy, x0 - dx, x1 - dx
if sy0 < 0 or sx0 < 0 or sy1 > h or sx1 > w:
continue
if not np.all(good[sy0:sy1, sx0:sx1][comp_sub]):
continue
src_patch = f_arr[sy0:sy1, sx0:sx1][comp_sub]
s = float(src_patch.std())
if s > best_std:
best_std = s
chosen = (dx, dy)
# comp 안쪽은 무조건 완전 교체(blend=1)한다. 컴포넌트별로 가우시안이나
# 거리변환 페더를 주면, 서로 몇 px 안 떨어진 작은 컴포넌트(대다수가
# 1~수십 px)들의 페더 자락이 이웃 컴포넌트의 comp 영역까지 침범해 이미
# 교체된 픽셀을 나중 컴포넌트의 도장 값으로 다시 섞어버려 어둡게 되돌리는
# 문제가 있었다 — comp 경계는 하드컷으로 두고, 바깥쪽 전환은 뒤에서
# bad_texture 전체 기준으로 한 번에 처리한다.
blend = comp.astype(np.float64)
if chosen is None:
_, (iy, ix) = distance_transform_edt(~good, return_indices=True)
shifted = f_arr[iy, ix, :]
method = "nearest-good-pixel"
offset = None
else:
dx, dy = chosen
shifted = np.stack([shift_mask(f_arr[..., c], dy, dx) for c in range(f_arr.shape[2])], axis=2)
method = "clone-stamp"
offset = [dx, dy]
out = out * (1 - blend[..., None]) + shifted * blend[..., None]
report.append({"componentId": comp_id, "pixels": int(comp.sum()), "offset": offset, "method": method})
return out, report
def high_freq_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = HF_SIGMA) -> float:
if not mask.any():
return 0.0
hf = lum - gaussian_filter(lum, sigma=sigma)
return float(hf[mask].std())
def line_energy(lum: np.ndarray, mask: np.ndarray, sigma: float = LINE_SIGMA) -> float:
"""원본이 자신의 블러보다 어두운 정도(가는 잉크선 성분)의 평균 크기."""
if not mask.any():
return 0.0
blurred = gaussian_filter(lum, sigma=sigma)
neg = np.clip(blurred - lum, 0, None)
return float(neg[mask].mean())
def find_clean_patch(
avoid_dilated: np.ndarray, roi: tuple[int, int, int, int], size: int = GENERIC_PATCH_SIZE
) -> tuple[int, int]:
"""roi=(x0,y0,x1,y1) 범위 안에서 avoid_dilated가 전부 False인 size x size
창의 좌상단 좌표를 찾는다. 완전히 깨끗한 창이 없으면 가장 깨끗한 것을 쓴다."""
x0, y0, x1, y1 = roi
h, w = avoid_dilated.shape
x0, y0 = max(0, x0), max(0, y0)
x1, y1 = min(w, x1), min(h, y1)
eligible = ~avoid_dilated
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
sy0, sy1 = max(0, y0), min(sums.shape[0] - 1, y1 - size)
sx0, sx1 = max(0, x0), min(sums.shape[1] - 1, x1 - size)
if sy1 < sy0 or sx1 < sx0:
sy0, sy1, sx0, sx1 = 0, sums.shape[0] - 1, 0, sums.shape[1] - 1
sub = sums[sy0:sy1 + 1, sx0:sx1 + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
return int(idx[1] + sx0), int(idx[0] + sy0)
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
f_img = build_padded_faceless()
f_arr = np.array(f_img).astype(np.float64)
h, w, _ = f_arr.shape
lum_front = front.mean(axis=2)
lum_f_raw = f_arr.mean(axis=2)
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
noseTip, chinTip = lm["noseTip"], lm["chinTip"]
# ------------------------------------------------------------------
# 영역(region): faceDetail이 그려질 전체 범위(눈두덩·다크서클·입가 음영·점)
# ------------------------------------------------------------------
def eye_region_bbox(eye, brow, outward_sign: float):
outer, inner = eye["outerCorner"], eye["innerCorner"]
x_outer = outer[0] + outward_sign * 30.0
x_inner = inner[0] - outward_sign * 25.0
x_min, x_max = sorted([x_outer, x_inner])
y_min = brow["peak"][1] - 10.0
y_max = eye["lowerLidBottom"][1] + 70.0
return x_min, y_min, x_max, y_max
bx0, by0, bx1, by1 = eye_region_bbox(eyeL, browL, outward_sign=-1.0)
rx0, ry0, rx1, ry1 = eye_region_bbox(eyeR, browR, outward_sign=+1.0)
eyeL_cx, eyeL_cy = (bx0 + bx1) / 2, (by0 + by1) / 2
eyeL_rx, eyeL_ry = (bx1 - bx0) / 2, (by1 - by0) / 2
eyeR_cx, eyeR_cy = (rx0 + rx1) / 2, (ry0 + ry1) / 2
eyeR_rx, eyeR_ry = (rx1 - rx0) / 2, (ry1 - ry0) / 2
mouth_x0 = min(mcL[0], mcR[0]) - 45.0
mouth_x1 = max(mcL[0], mcR[0]) + 45.0
mouth_y0 = noseTip[1] + 25.0
mouth_y1 = chinTip[1] - 15.0
mouth_cx, mouth_cy = (mouth_x0 + mouth_x1) / 2, (mouth_y0 + mouth_y1) / 2
mouth_rx, mouth_ry = (mouth_x1 - mouth_x0) / 2, (mouth_y1 - mouth_y0) / 2
print(f"eyeLeft 영역 bbox=({bx0:.1f},{by0:.1f},{bx1:.1f},{by1:.1f})")
print(f"eyeRight 영역 bbox=({rx0:.1f},{ry0:.1f},{rx1:.1f},{ry1:.1f})")
print(f"mouth 영역 bbox=({mouth_x0:.1f},{mouth_y0:.1f},{mouth_x1:.1f},{mouth_y1:.1f})")
a_eyeL = ellipse_alpha(eyeL_cx, eyeL_cy, eyeL_rx, eyeL_ry, w, h, REGION_FEATHER_PX)
a_eyeR = ellipse_alpha(eyeR_cx, eyeR_cy, eyeR_rx, eyeR_ry, w, h, REGION_FEATHER_PX)
a_mouth = ellipse_alpha(mouth_cx, mouth_cy, mouth_rx, mouth_ry, w, h, REGION_FEATHER_PX)
a_mole = ellipse_alpha(MOLE_CENTER[0], MOLE_CENTER[1], MOLE_RADIUS, MOLE_RADIUS, w, h, REGION_FEATHER_PX)
region_alpha = np.maximum(np.maximum(a_eyeL, a_eyeR), np.maximum(a_mouth, a_mole))
# ------------------------------------------------------------------
# 제외 영역(구멍): 이목구비 잉크선 전체 + 지정 방향 여유 + 최종 팽창
# ------------------------------------------------------------------
print("=== 제외 영역 계산 ===")
eyeL_ink_raw = eye_dark_hole(eyeL, lum_front, w, h)
eyeR_ink_raw = eye_dark_hole(eyeR, lum_front, w, h)
# eye_dark_hole은 명도<150 연결요소를 찾는데, 다크서클 해칭이 눈 잉크와
# 이어져 있어(binary_closing으로 다리까지 놓여) 같은 컴포넌트로 잡혀
# 아랫눈꺼풀선 아래 30px까지 "눈 잉크"로 나온다 — 랜드마크선+7px 아래는
# 잘라내 다크서클 해칭이 눈 제외 영역에 섞이지 않게 한다.
yy_full, _ = np.mgrid[0:h, 0:w]
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + EYE_INK_LOWER_CAP_PX)
eyeL_ink = eyeL_ink_raw & eyeL_cap
eyeR_ink = eyeR_ink_raw & eyeR_cap
print(f"눈 잉크 다크서클 절단: L {int(eyeL_ink_raw.sum())}->{int(eyeL_ink.sum())}px, R {int(eyeR_ink_raw.sum())}->{int(eyeR_ink.sum())}px")
# 위(쌍꺼풀 주름)·바깥(꼬리)은 기존처럼 잉크에서 방향성 확장 + 6px 팽창.
# 아래쪽은 잉크에서 확장하지 않고, 랜드마크 곡선 자체의 좁은 띠(-2~+5px)만
# 별도로 2px 팽창한다 — 다크서클 해칭을 먹지 않기 위함(오케스트레이터 지시).
eyeL_grown = eyeL_ink | grow_directional(eyeL_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeL_ink, dx=-EYE_OUTER_EXT_PX)
eyeR_grown = eyeR_ink | grow_directional(eyeR_ink, dy=-EYE_CREASE_UP_PX) | grow_directional(eyeR_ink, dx=EYE_OUTER_EXT_PX)
# 6px 팽창은 사방으로 동시에 퍼지므로, 이미 절단한 아래쪽 경계를 다시
# 밀어 내린다 — 팽창 뒤에도 같은 절단선으로 다시 한 번 잘라 위/바깥
# 팽창(원하는 효과)만 남기고 아래쪽 재침범은 막는다.
eyeL_core_excl = binary_dilation(eyeL_grown, iterations=EYE_FINAL_DILATE_PX) & eyeL_cap
eyeR_core_excl = binary_dilation(eyeR_grown, iterations=EYE_FINAL_DILATE_PX) & eyeR_cap
eyeL_lower_line = lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], w, h)
eyeR_lower_line = lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], w, h)
eyeL_lower_excl = binary_dilation(eyeL_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeR_lower_excl = binary_dilation(eyeR_lower_line, iterations=EYE_LOWER_DILATE_PX)
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
# 페더 프로파일을 나눌 때는 "아래쪽 좁은 띠 전용 마스크"가 아니라 "제외
# 영역 중 아랫눈꺼풀선 근방(위로 3px)에 걸리는 부분 전체"를 기준으로
# 삼는다 — core_excl의 6px 팽창분도 같은 y대에 있으면 좁은 4px 페더를
# 받아야 다크서클 쪽으로 넓은 10px 페더가 새는 것을 막는다.
eyeL_lower_zone = eyeL_excl & (yy_full > eyeL["lowerLidBottom"][1] - 8.0)
eyeR_lower_zone = eyeR_excl & (yy_full > eyeR["lowerLidBottom"][1] - 8.0)
eye_lower_bands = eyeL_lower_zone | eyeR_lower_zone
browL_ink = brow_dark_hole(browL, lum_front, w, h)
browR_ink = brow_dark_hole(browR, lum_front, w, h)
browL_band = eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browR_band = eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], w, h, 2 * BROW_STROKE_HALF_WIDTH)
browL_excl = binary_dilation(browL_ink | browL_band, iterations=BROW_FINAL_DILATE_PX)
browR_excl = binary_dilation(browR_ink | browR_band, iterations=BROW_FINAL_DILATE_PX)
mouth_ink = mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, w, h)
mouth_grown = (
mouth_ink
| grow_directional(mouth_ink, dx=-MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dx=MOUTH_CORNER_EXT_PX)
| grow_directional(mouth_ink, dy=MOUTH_SHADOW_EXT_PX)
)
mouth_excl = binary_dilation(mouth_grown, iterations=MOUTH_FINAL_DILATE_PX)
exclusion_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
named_excl = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
print(f"제외 영역 픽셀: eye={int((eyeL_excl|eyeR_excl).sum())} brow={int((browL_excl|browR_excl).sum())} mouth={int(mouth_excl.sum())} union={int(exclusion_mask.sum())}")
# ------------------------------------------------------------------
# 머리카락 마스크(분할, 2px 팽창) — faceDetail 알파를 0으로 만든다
# ------------------------------------------------------------------
category_mask = run_segmentation(BASE_DIR / "base-faceless-padded.png")
hair_mask = binary_dilation(category_mask == 1, iterations=HAIR_DILATE_PX)
print(f"머리카락 마스크(2px 팽창) 픽셀: {int(hair_mask.sum())}")
# ------------------------------------------------------------------
# 텍스처원(질감, 블러 금지): F가 선 모양 잔여 잉크인 자리만 복제 도장
# ------------------------------------------------------------------
dark_in_excl = exclusion_mask & ~hair_mask & (lum_f_raw < CLONE_TARGET_THRESH)
bad_texture = line_only_mask(dark_in_excl)
avoid_mask = exclusion_mask | hair_mask | (lum_f_raw < AVOID_THRESH)
print(f"텍스처 복제 대상(제외 영역 안 F 선 모양 잔여 잉크): {int(bad_texture.sum())}px (넓은 그늘 {int(dark_in_excl.sum()) - int(bad_texture.sum())}px는 보존)")
f_clean, stamp_report = clone_stamp_fill(f_arr, bad_texture, avoid_mask)
for r in stamp_report:
print(f" 복제 도장: comp={r['componentId']} px={r['pixels']} offset={r['offset']} method={r['method']}")
# ------------------------------------------------------------------
# 합성: 제외 영역 밖은 front 그대로, 안은 F(f_clean) 그대로. 비율 보간
# 없음. 경계는 10px 페더로 섞는다 — 단 눈 아래쪽 좁은 띠(eye_lower_bands)는
# 다크서클 해칭을 먹지 않도록 4px 페더만 쓴다(오케스트레이터 지시). 두
# 페더 영역을 나눠 계산한 뒤 max로 합친다. 복제 도장으로 갈아 끼운 자리
# (bad_texture)는 경계 페더가 원본 front(잉크 그 자체)를 다시 섞어 넣지
# 못하게 blend=1로 고정한다 — 안 그러면 제외 영역 가장자리 근처의 잔여
# 잉크는 도장으로 지워도 다시 어두워진다.
# ------------------------------------------------------------------
blend_main = np.clip(
gaussian_filter((exclusion_mask & ~eye_lower_bands).astype(np.float64), sigma=EXCLUSION_BOUNDARY_FEATHER_PX / 2.0), 0.0, 1.0
)
blend_lower = np.clip(gaussian_filter(eye_lower_bands.astype(np.float64), sigma=EYE_LOWER_FEATHER_PX / 2.0), 0.0, 1.0)
blend = np.maximum(blend_main, blend_lower)
blend = np.maximum(blend, bad_texture.astype(np.float64))
face_detail_rgb = front * (1 - blend[..., None]) + f_clean * blend[..., None]
# region_alpha(눈·입 타원)는 기존 "눈두덩~다크서클" 범위용으로 만든 것이라,
# 새로 넓힌 제외 영역(특히 눈썹은 자체 타원이 없다)을 다 못 덮을 수 있다 —
# 못 덮으면 그 자리는 faceDetail이 아니라 head 레이어의 원본(미처리) 잉크가
# 그대로 비쳐 보인다. 제외 영역은 RGB와 같은 10px 페더 프로필로 알파도
# 최소 보장한다.
face_detail_alpha = np.maximum(region_alpha, blend * 255.0)
face_detail_alpha[hair_mask] = 0.0
# ------------------------------------------------------------------
# 저장
# ------------------------------------------------------------------
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
out_path = LAYERS_V2_DIR / "face-detail.png"
Image.fromarray(np.dstack([to_u8(face_detail_rgb), to_u8(face_detail_alpha)]), "RGBA").save(out_path)
bbox = alpha_bbox(to_u8(face_detail_alpha))
print(f"저장: {out_path} bbox={bbox}")
# ------------------------------------------------------------------
# 정지 합성(body+head+faceDetail+hairFront, 종이 위)
# ------------------------------------------------------------------
def load_rgba(p: Path) -> tuple[np.ndarray, np.ndarray]:
arr = np.array(Image.open(p).convert("RGBA")).astype(np.float64)
return arr[..., :3], arr[..., 3]
body_rgb, body_a = load_rgba(LAYERS_V2_DIR / "body.png")
head_rgb, head_a = load_rgba(LAYERS_V2_DIR / "head.png")
hf_rgb, hf_a = load_rgba(LAYERS_V2_DIR / "hairFront.png")
canvas = np.zeros((h, w, 4), dtype=np.float64)
canvas[..., 0] = CREAM_BG[0]; canvas[..., 1] = CREAM_BG[1]; canvas[..., 2] = CREAM_BG[2]; canvas[..., 3] = 255.0
canvas = composite_over(canvas, to_u8(body_rgb), to_u8(body_a))
canvas = composite_over(canvas, to_u8(head_rgb), to_u8(head_a))
canvas = composite_over(canvas, to_u8(face_detail_rgb), to_u8(face_detail_alpha))
canvas = composite_over(canvas, to_u8(hf_rgb), to_u8(hf_a))
static_composite = to_u8(canvas)[..., :3].astype(np.float64)
lum_composite = static_composite.mean(axis=2)
# ------------------------------------------------------------------
# 검사 (a): 제외 영역 밖, faceDetail alpha>0.5에서 base-front 대비 <=3
# ------------------------------------------------------------------
check_mask = (face_detail_alpha > 127) & ~exclusion_mask
diff = np.abs(static_composite - front).mean(axis=2)
mean_abs_a = float(diff[check_mask].mean()) if check_mask.any() else None
print(f"검사(a) 제외영역 밖 & faceDetail>0.5 평균절대차 = {mean_abs_a:.3f} (기준 <=3)")
# ------------------------------------------------------------------
# 검사 (b): 제외 영역 경계 안팎 3px 띠 평균 명도 차 <=6
# ------------------------------------------------------------------
excl_inner_band = exclusion_mask & ~binary_erosion(exclusion_mask, iterations=3)
excl_outer_band = binary_dilation(exclusion_mask, iterations=3) & ~exclusion_mask
inner_mean = float(lum_composite[excl_inner_band].mean()) if excl_inner_band.any() else None
outer_mean = float(lum_composite[excl_outer_band].mean()) if excl_outer_band.any() else None
seam_diff = abs(inner_mean - outer_mean) if inner_mean is not None and outer_mean is not None else None
print(f"검사(b) 제외영역 경계 안({inner_mean:.2f})/밖({outer_mean:.2f}) 명도차 = {seam_diff:.3f} (기준 <=6)")
# ------------------------------------------------------------------
# 검사 (c): 제외 영역 안, 머리카락이 아닌 픽셀 중 명도<70 비율 <=0.3%
# ------------------------------------------------------------------
check_region_c = exclusion_mask & ~hair_mask
violations = check_region_c & (lum_composite < 70)
n_violations = int(violations.sum())
hole_dark_pct = float(violations.sum()) / float(check_region_c.sum()) * 100.0 if check_region_c.any() else 0.0
print(f"검사(c) 제외영역 안(머리카락 제외) 명도<70 비율 = {hole_dark_pct:.4f}% (기준 <=0.3%, 위반 {n_violations}px)")
if hole_dark_pct > 0.3 and n_violations > 0:
overlay = front.copy()
overlay[violations] = np.array([230.0, 30.0, 30.0])
ys, xs = np.where(violations)
pad = 30
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
cx1, cy1 = min(w, int(xs.max()) + pad), min(h, int(ys.max()) + pad)
diag_path = PREVIEW_V2_DIR / "checkC-violations.png"
Image.fromarray(to_u8(overlay)).crop((cx0, cy0, cx1, cy1)).save(diag_path)
print(f" [checkC 미달] 위반 위치 진단 이미지 저장: {diag_path}")
# ------------------------------------------------------------------
# 일반 피부 표본(뺨 2곳 + 이마 1곳, F 기준, 이목구비·머리카락에서 40px+ 이격)
# ------------------------------------------------------------------
avoid_for_patch = binary_dilation(exclusion_mask | hair_mask, iterations=GENERIC_PATCH_MARGIN)
browL_peak, browR_peak = browL["peak"], browR["peak"]
eyeL_lower, eyeR_lower = eyeL["lowerLidBottom"], eyeR["lowerLidBottom"]
forehead_roi = (
int(min(browL_peak[0], browR_peak[0]) - 20), int(min(browL_peak[1], browR_peak[1]) - 100),
int(max(browL_peak[0], browR_peak[0]) + 20), int(min(browL_peak[1], browR_peak[1]) - 20),
)
cheekL_roi = (
int(eyeL["outerCorner"][0] - 100), int(eyeL_lower[1] + 10),
int(noseTip[0] - 20), int(mcL[1] - 5),
)
cheekR_roi = (
int(noseTip[0] + 20), int(eyeR_lower[1] + 10),
int(eyeR["outerCorner"][0] + 100), int(mcR[1] - 5),
)
patch_boxes: dict[str, tuple[int, int, int, int]] = {}
for name, roi in (("forehead", forehead_roi), ("cheekLeft", cheekL_roi), ("cheekRight", cheekR_roi)):
x0, y0 = find_clean_patch(avoid_for_patch, roi, GENERIC_PATCH_SIZE)
patch_boxes[name] = (x0, y0, x0 + GENERIC_PATCH_SIZE, y0 + GENERIC_PATCH_SIZE)
clean_frac = float((~avoid_for_patch[y0:y0 + GENERIC_PATCH_SIZE, x0:x0 + GENERIC_PATCH_SIZE]).mean()) * 100.0
print(f"일반 피부 표본 {name}: box={patch_boxes[name]} 깨끗한 비율={clean_frac:.1f}%")
lum_f_clean = f_clean.mean(axis=2)
patch_masks = {}
for name, (x0, y0, x1, y1) in patch_boxes.items():
m = np.zeros((h, w), dtype=bool)
m[y0:y1, x0:x1] = True
patch_masks[name] = m
baseline_hf = float(np.mean([high_freq_energy(lum_f_clean, m) for m in patch_masks.values()]))
baseline_line = float(np.mean([line_energy(lum_front, m) for m in patch_masks.values()]))
print(f"일반 피부 표본 기준: 고주파 에너지={baseline_hf:.3f}, 선 에너지={baseline_line:.3f}")
# ------------------------------------------------------------------
# 검사 (d): 질감 보존 — 구멍 안 고주파 에너지(F 기준) / 일반 피부 표본 평균 >= 0.7
# ------------------------------------------------------------------
checkD: dict[str, dict] = {}
all_pass_d = True
for name, side_excl in named_excl.items():
side_visible = side_excl & ~hair_mask
e_inside = high_freq_energy(lum_f_clean, side_visible)
ratio_d = e_inside / baseline_hf if baseline_hf > 1e-6 else 0.0
passed = ratio_d >= CHECKD_THRESH
all_pass_d = all_pass_d and passed
checkD[name] = {"insideEnergy": e_inside, "baselineEnergy": baseline_hf, "ratio": ratio_d, "pass": passed}
print(f"검사(d) {name}: 안={e_inside:.3f} 표본기준={baseline_hf:.3f} 비율={ratio_d:.3f} (기준 >={CHECKD_THRESH}) {'OK' if passed else '[미달]'}")
# ------------------------------------------------------------------
# 검사 (e): 유령 윤곽 — 제외 영역 밖 0~12px 띠(머리카락 제외)의 선
# 에너지(front 기준) / 일반 피부 표본 평균 <= 1.5. 머리카락 가닥은 이목구비
# 잉크선이 아니므로 checkC와 같은 기준으로 뺀다.
# ------------------------------------------------------------------
checkE: dict[str, dict] = {}
all_pass_e = True
for name, side_excl in named_excl.items():
band = binary_dilation(side_excl, iterations=RING_PX) & ~exclusion_mask & ~hair_mask
e_band = line_energy(lum_front, band)
ratio_e = e_band / baseline_line if baseline_line > 1e-6 else 0.0
passed = ratio_e <= CHECKE_THRESH
all_pass_e = all_pass_e and passed
checkE[name] = {"bandLineEnergy": e_band, "baselineLineEnergy": baseline_line, "ratio": ratio_e, "pass": passed}
print(f"검사(e) {name}: 경계띠선에너지={e_band:.3f} 표본기준={baseline_line:.3f} 비율={ratio_e:.3f} (기준 <={CHECKE_THRESH}) {'OK' if passed else '[미달-유령윤곽]'}")
# ------------------------------------------------------------------
# 검사 (다크서클 복원): 아랫눈꺼풀 아래 10~35px, 홍채 중심 x ±35 띠의 합성
# 평균 명도가 base-front와 ±6 이내여야 한다(오케스트레이터 지시 — 다크서클
# 해칭이 지워지면 안 된다).
# ------------------------------------------------------------------
checkDarkCircle: dict[str, dict] = {}
all_pass_dc = True
for name, eye in (("eyeLeft", eyeL), ("eyeRight", eyeR)):
band = dark_circle_band_mask(eye, w, h)
composite_mean = float(lum_composite[band].mean())
front_mean = float(lum_front[band].mean())
diff = abs(composite_mean - front_mean)
passed = diff <= DARK_CIRCLE_MATCH_TOLERANCE
all_pass_dc = all_pass_dc and passed
checkDarkCircle[name] = {"compositeMean": composite_mean, "frontMean": front_mean, "diff": diff, "pass": passed}
print(f"검사(다크서클) {name}: 합성={composite_mean:.1f} base-front={front_mean:.1f} 차={diff:.2f} (기준 <={DARK_CIRCLE_MATCH_TOLERANCE}) {'OK' if passed else '[미달]'}")
manifest.setdefault("layersV2", {})
manifest["layersV2"]["faceDetail"] = {
"regions": {
"eyeLeft": [round(bx0, 1), round(by0, 1), round(bx1, 1), round(by1, 1)],
"eyeRight": [round(rx0, 1), round(ry0, 1), round(rx1, 1), round(ry1, 1)],
"mouth": [round(mouth_x0, 1), round(mouth_y0, 1), round(mouth_x1, 1), round(mouth_y1, 1)],
"cheekMole": {"center": [round(MOLE_CENTER[0], 1), round(MOLE_CENTER[1], 1)], "radius": MOLE_RADIUS},
},
"exclusionPixels": {
"eyeLeft": int(eyeL_excl.sum()), "eyeRight": int(eyeR_excl.sum()),
"browLeft": int(browL_excl.sum()), "browRight": int(browR_excl.sum()),
"mouth": int(mouth_excl.sum()), "union": int(exclusion_mask.sum()),
},
"hairMaskPixels": int(hair_mask.sum()),
"bbox": bbox,
"textureCloneStamp": {
"cloneTargetThreshold": CLONE_TARGET_THRESH,
"avoidThreshold": AVOID_THRESH,
"lineOpeningIterations": LINE_OPENING_ITER,
"featherPx": CLONE_STAMP_FEATHER_PX,
"badTexturePixels": int(bad_texture.sum()),
"preservedShadowPixels": int(dark_in_excl.sum()) - int(bad_texture.sum()),
"components": stamp_report,
},
"genericPatches": {name: list(box) for name, box in patch_boxes.items()},
"genericPatchBaseline": {"highFreqEnergy": baseline_hf, "lineEnergy": baseline_line},
"checkA_meanAbsDiff": mean_abs_a,
"checkB_seamLumDiff": seam_diff,
"checkC_holeDarkResiduePct": hole_dark_pct,
"checkC_violationPixels": n_violations,
"checkD_texturePreservation": checkD,
"checkD_allPass": all_pass_d,
"checkD_mouthException": "mouth 0.550은 가공 없는 원본 F 픽셀 그대로라 원본 특성으로 수용(오케스트레이터 판정).",
"checkE_ghostOutline": checkE,
"checkE_allPass": all_pass_e,
"checkDarkCircle": checkDarkCircle,
"checkDarkCircle_allPass": all_pass_dc,
}
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())

View file

@ -0,0 +1,338 @@
"""P1 서연 리노컷 최종 게시 미리보기 — 2단계-B-1a (5).
게시된 WebP(apps/web/public/avatar/v3/p1/*.webp)를 다시 읽어 합성한다(원본 PNG가
아니라 실제로 배포되는 파일을 검증하기 위함). export_rig.py가 남긴
preview/v2/export-rig-report.json의 rig 정보(레이어 x,y,w,h, pivots, palette)를
좌표 소스로 쓴다. 벡터 부위(눈·눈썹·입 등)는 없다 — faceDetail과 grain까지만
포함한 정적 합성이다.
생성물: motion-{cream,cool,dark}.png, face-detail.png, closed-eyes.png, holes-texture.jpg, ghost-check.jpg
실행: <venv>/python.exe build_final_previews.py
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
SCRIPTS_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers_segmented import composite_over, rotate_rgba, to_u8, translate_rgba # noqa: E402
import build_face_detail as bfd # noqa: E402
ROOT = SCRIPTS_DIR.parent
BASE_DIR = ROOT / "base"
PREVIEW_V2_DIR = ROOT / "preview" / "v2"
MANIFEST_PATH = ROOT / "manifest.json"
REPORT_PATH = PREVIEW_V2_DIR / "export-rig-report.json"
REPO_ROOT = ROOT.parents[2]
PUBLIC_DIR = REPO_ROOT / "apps" / "web" / "public" / "avatar" / "v3" / "p1"
GRAIN_OPACITY = 0.35
BG_CREAM = (0xEE, 0xE5, 0xD3)
BG_COOL = (0xDC, 0xE0, 0xE2)
BG_DARK = (0x3A, 0x3A, 0x3A)
FRAMES = [
("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}),
("down10px", {"rotate": 0.0, "tx": 0.0, "ty": 10.0}),
("right12px", {"rotate": 0.0, "tx": 12.0, "ty": 0.0}),
]
def load_layer(href_stem: str, x: float, y: float, w: float, h: float, canvas_w: int, canvas_h: int) -> tuple[np.ndarray, np.ndarray]:
"""게시된 webp를 rig 사각형(x,y,w,h)에 맞춰 리사이즈하고 캔버스 크기로 패딩한다."""
im = Image.open(PUBLIC_DIR / f"{href_stem}.webp").convert("RGBA")
tw, th = round(w), round(h)
if im.size != (tw, th):
im = im.resize((tw, th), Image.LANCZOS)
canvas = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
canvas.paste(im, (round(x), round(y)))
arr = np.array(canvas).astype(np.float64)
return arr[..., :3], arr[..., 3]
def polygon_alpha_mask(points: list[tuple[float, float]], w: int, h: int) -> np.ndarray:
img = Image.new("L", (w, h), 0)
ImageDraw.Draw(img).polygon(points, fill=255)
from scipy.ndimage import gaussian_filter
return np.clip(gaussian_filter(np.array(img, dtype=np.float64), sigma=2.0), 0, 255)
def tile_grain(canvas_w: int, canvas_h: int, size: int) -> np.ndarray:
grain_im = Image.open(PUBLIC_DIR / "paper-grain.webp").convert("RGB")
if grain_im.size != (size, size):
grain_im = grain_im.resize((size, size), Image.LANCZOS)
grain = np.array(grain_im).astype(np.float64)
ny = -(-canvas_h // size)
nx = -(-canvas_w // size)
tiled = np.tile(grain, (ny, nx, 1))[:canvas_h, :canvas_w, :]
return tiled
def apply_grain_multiply(rgb: np.ndarray, grain: np.ndarray, opacity: float) -> np.ndarray:
factor = grain / 255.0
multiplied = rgb * factor
return np.clip(rgb * (1 - opacity) + multiplied * opacity, 0, 255)
def composite_static(body, head, face_detail, hair_front, bg: tuple[int, int, int], canvas_w: int, canvas_h: int) -> np.ndarray:
canvas = np.zeros((canvas_h, canvas_w, 4), dtype=np.float64)
canvas[..., 0], canvas[..., 1], canvas[..., 2] = bg
canvas[..., 3] = 255.0
canvas = composite_over(canvas, to_u8(body[0]), to_u8(body[1]))
canvas = composite_over(canvas, to_u8(head[0]), to_u8(head[1]))
canvas = composite_over(canvas, to_u8(face_detail[0]), to_u8(face_detail[1]))
canvas = composite_over(canvas, to_u8(hair_front[0]), to_u8(hair_front[1]))
return to_u8(canvas)[..., :3].astype(np.float64)
def main() -> int:
report = json.loads(REPORT_PATH.read_text(encoding="utf-8"))
rig = report["rig"]
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
cw, ch = rig["canvas"]["w"], rig["canvas"]["h"]
neck_pivot = tuple(rig["pivots"]["neck"])
def layer_of(key: str) -> tuple[np.ndarray, np.ndarray]:
l = rig["layers"][key]
stem = Path(l["href"]).stem
return load_layer(stem, l["x"], l["y"], l["w"], l["h"], cw, ch)
body = layer_of("body")
head = layer_of("head")
hair_front = layer_of("hairFront")
face_detail_raw = layer_of("faceDetail")
face_oval = [tuple(p) for p in rig["faceOval"]]
clip = polygon_alpha_mask(face_oval, cw, ch) / 255.0
face_detail = (face_detail_raw[0], face_detail_raw[1] * clip)
grain_tile = tile_grain(cw, ch, rig["grain"]["size"])
# ------------------------------------------------------------------
# motion-{cream,cool,dark}.png
# ------------------------------------------------------------------
for bg_name, bg in (("cream", BG_CREAM), ("cool", BG_COOL), ("dark", BG_DARK)):
frame_ims = []
for name, t in FRAMES:
h_rgb, h_a = to_u8(head[0]), to_u8(head[1])
fd_rgb, fd_a = to_u8(face_detail[0]), to_u8(face_detail[1])
hf_rgb, hf_a = to_u8(hair_front[0]), to_u8(hair_front[1])
if t["rotate"] != 0.0:
h_rgb, h_a = rotate_rgba(h_rgb, h_a, t["rotate"], neck_pivot)
fd_rgb, fd_a = rotate_rgba(fd_rgb, fd_a, t["rotate"], neck_pivot)
hf_rgb, hf_a = rotate_rgba(hf_rgb, hf_a, t["rotate"], neck_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"])
fd_rgb, fd_a = translate_rgba(fd_rgb, fd_a, t["tx"], t["ty"])
hf_rgb, hf_a = translate_rgba(hf_rgb, hf_a, t["tx"] * 1.4, t["ty"] * 1.4)
frame_rgb = composite_static(
(to_u8(body[0]), to_u8(body[1])), (h_rgb, h_a), (fd_rgb, fd_a), (hf_rgb, hf_a), bg, cw, ch
)
frame_rgb = apply_grain_multiply(frame_rgb, grain_tile, GRAIN_OPACITY)
im = Image.fromarray(to_u8(frame_rgb), "RGB")
d = ImageDraw.Draw(im)
label_color = (255, 60, 60) if bg_name != "dark" else (255, 200, 140)
d.text((20, 20), name, fill=label_color)
frame_ims.append(im)
# 세로로 긴 캔버스라 옆으로 5장 나열하면 매우 넓어지므로 절반 크기로 축소해 나열
scale = 0.45
sw, sh = round(cw * scale), round(ch * scale)
gap = 10
strip = Image.new("RGB", (sw * len(frame_ims) + gap * (len(frame_ims) - 1), sh), bg)
x = 0
for im in frame_ims:
strip.paste(im.resize((sw, sh), Image.LANCZOS), (x, 0))
x += sw + gap
out_path = PREVIEW_V2_DIR / f"motion-{bg_name}.png"
strip.save(out_path)
print(f"저장: {out_path}")
# ------------------------------------------------------------------
# 제외 영역 재구성(build_face_detail.py와 동일한 함수·상수) — 미리보기가
# 실제 게시물이 쓴 것과 같은 제외 영역 윤곽을 보여주게 한다.
# ------------------------------------------------------------------
lm = manifest["landmarks"]
front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB")).astype(np.float64)
eyeL, eyeR = lm["eyeLeft"], lm["eyeRight"]
browL, browR = lm["eyebrowLeft"], lm["eyebrowRight"]
mcL, mcR = lm["mouthCornerLeft"], lm["mouthCornerRight"]
upLip, loLip = lm["upperLipTopCenter"], lm["lowerLipBottomCenter"]
lum_front = front.mean(axis=2)
import scipy.ndimage as ndi
from build_layers_segmented import build_padded_faceless
yy_full, _ = np.mgrid[0:ch, 0:cw]
eyeL_cap = yy_full <= (eyeL["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
eyeR_cap = yy_full <= (eyeR["lowerLidBottom"][1] + bfd.EYE_INK_LOWER_CAP_PX)
eyeL_ink = bfd.eye_dark_hole(eyeL, lum_front, cw, ch) & eyeL_cap
eyeR_ink = bfd.eye_dark_hole(eyeR, lum_front, cw, ch) & eyeR_cap
eyeL_grown = eyeL_ink | bfd.grow_directional(eyeL_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeL_ink, dx=-bfd.EYE_OUTER_EXT_PX)
eyeR_grown = eyeR_ink | bfd.grow_directional(eyeR_ink, dy=-bfd.EYE_CREASE_UP_PX) | bfd.grow_directional(eyeR_ink, dx=bfd.EYE_OUTER_EXT_PX)
eyeL_core_excl = ndi.binary_dilation(eyeL_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeL_cap
eyeR_core_excl = ndi.binary_dilation(eyeR_grown, iterations=bfd.EYE_FINAL_DILATE_PX) & eyeR_cap
eyeL_lower_excl = ndi.binary_dilation(
bfd.lower_lid_line_mask(eyeL["innerCorner"], eyeL["outerCorner"], eyeL["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
)
eyeR_lower_excl = ndi.binary_dilation(
bfd.lower_lid_line_mask(eyeR["innerCorner"], eyeR["outerCorner"], eyeR["lowerLidBottom"], cw, ch), iterations=bfd.EYE_LOWER_DILATE_PX
)
eyeL_excl = eyeL_core_excl | eyeL_lower_excl
eyeR_excl = eyeR_core_excl | eyeR_lower_excl
browL_ink = bfd.brow_dark_hole(browL, lum_front, cw, ch)
browR_ink = bfd.brow_dark_hole(browR, lum_front, cw, ch)
browL_band = bfd.eyebrow_mask(browL["inner"], browL["peak"], browL["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
browR_band = bfd.eyebrow_mask(browR["inner"], browR["peak"], browR["outer"], cw, ch, 2 * bfd.BROW_STROKE_HALF_WIDTH)
browL_excl = ndi.binary_dilation(browL_ink | browL_band, iterations=bfd.BROW_FINAL_DILATE_PX)
browR_excl = ndi.binary_dilation(browR_ink | browR_band, iterations=bfd.BROW_FINAL_DILATE_PX)
mouth_ink = bfd.mouth_dark_hole(mcL, mcR, upLip, loLip, lum_front, cw, ch)
mouth_grown = (
mouth_ink
| bfd.grow_directional(mouth_ink, dx=-bfd.MOUTH_CORNER_EXT_PX)
| bfd.grow_directional(mouth_ink, dx=bfd.MOUTH_CORNER_EXT_PX)
| bfd.grow_directional(mouth_ink, dy=bfd.MOUTH_SHADOW_EXT_PX)
)
mouth_excl = ndi.binary_dilation(mouth_grown, iterations=bfd.MOUTH_FINAL_DILATE_PX)
named_holes = {"eyeLeft": eyeL_excl, "eyeRight": eyeR_excl, "browLeft": browL_excl, "browRight": browR_excl, "mouth": mouth_excl}
hole_mask = eyeL_excl | eyeR_excl | browL_excl | browR_excl | mouth_excl
def mask_outline(mask: np.ndarray) -> np.ndarray:
return mask & ~ndi.binary_erosion(mask, iterations=2)
# ------------------------------------------------------------------
# holes-texture.jpg: 구멍별로 [F 원본, 메운 결과(face_detail_rgb),
# base-front, 제외 영역 윤곽 겹침]을 2배 확대해 나란히 놓는다.
# ------------------------------------------------------------------
f_arr = np.array(build_padded_faceless()).astype(np.float64)
# 게시된 webp는 알파 bbox로 잘려 있어(bbox 밖은 빈 캔버스) 구멍이 bbox 경계에
# 걸치면 미리보기가 검게 잘린 것처럼 보인다 — 원본 PNG(전체 캔버스, RGB가
# 어디서나 정의됨)를 직접 읽어 이 문제를 피한다.
fd_rgb_full = np.array(Image.open(ROOT / "layers" / "v2" / "face-detail.png").convert("RGBA")).astype(np.float64)[..., :3]
outline_overlay = front.copy()
outline_overlay[mask_outline(hole_mask)] = np.array([40.0, 200.0, 60.0])
rows = []
zoom = 2
hole_pad = 16
for name, m in named_holes.items():
ys, xs = np.where(m)
bx0, by0, bx1, by1 = int(xs.min()) - hole_pad, int(ys.min()) - hole_pad, int(xs.max()) + 1 + hole_pad, int(ys.max()) + 1 + hole_pad
box = (max(0, bx0), max(0, by0), min(cw, bx1), min(ch, by1))
f_crop = Image.fromarray(to_u8(f_arr), "RGB").crop(box)
fill_crop = Image.fromarray(to_u8(fd_rgb_full), "RGB").crop(box)
front_crop = Image.fromarray(to_u8(front), "RGB").crop(box)
outline_crop = Image.fromarray(to_u8(outline_overlay), "RGB").crop(box)
pw2, ph2 = f_crop.size
f_crop = f_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
fill_crop = fill_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
front_crop = front_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
outline_crop = outline_crop.resize((pw2 * zoom, ph2 * zoom), Image.NEAREST)
row = Image.new("RGB", (pw2 * zoom * 4 + 30, ph2 * zoom + 20), (255, 255, 255))
d = ImageDraw.Draw(row)
for i, (label, im) in enumerate([("F 원본", f_crop), ("메운 결과", fill_crop), ("base-front", front_crop), ("제외영역 윤곽", outline_crop)]):
row.paste(im, (i * (pw2 * zoom + 10), 20))
d.text((i * (pw2 * zoom + 10), 2), f"{name}: {label}", fill=(0, 0, 0))
rows.append(row)
max_w = max(r.width for r in rows)
total_h = sum(r.height for r in rows) + 10 * (len(rows) - 1)
holes_tex = Image.new("RGB", (max_w, total_h), (255, 255, 255))
y = 0
for r in rows:
holes_tex.paste(r, (0, y))
y += r.height + 10
holes_tex_path = PREVIEW_V2_DIR / "holes-texture.jpg"
holes_tex.convert("RGB").save(holes_tex_path, "JPEG", quality=90)
print(f"저장: {holes_tex_path}")
fd_bbox = manifest["layersV2"]["faceDetail"]["bbox"]
fx0, fy0, fx1, fy1 = fd_bbox
pad = 20
fx0, fy0 = max(0, fx0 - pad), max(0, fy0 - pad)
fx1, fy1 = min(cw, fx1 + pad), min(ch, fy1 + pad)
# (1) faceDetail 단독(크림 배경 위)
fd_on_cream = np.zeros((ch, cw, 3), dtype=np.float64)
fd_on_cream[...] = BG_CREAM
fd_on_cream = composite_over(
np.dstack([fd_on_cream, np.full((ch, cw), 255.0)]), to_u8(face_detail[0]), to_u8(face_detail[1])
)[..., :3]
# (2) 구멍 표시(빨강 오버레이)
hole_overlay = front.copy()
hole_overlay[hole_mask] = hole_overlay[hole_mask] * 0.4 + np.array([230.0, 40.0, 40.0]) * 0.6
# (3) 정지 합성
static_full = composite_static(body, head, face_detail, hair_front, BG_CREAM, cw, ch)
static_full = apply_grain_multiply(static_full, grain_tile, GRAIN_OPACITY)
# (4) base-front 비교는 front 그대로
panels = [
("faceDetail 단독", Image.fromarray(to_u8(fd_on_cream), "RGB")),
("구멍 표시", Image.fromarray(to_u8(hole_overlay), "RGB")),
("정지 합성", Image.fromarray(to_u8(static_full), "RGB")),
("base-front", Image.fromarray(to_u8(front), "RGB")),
]
crop_box = (int(fx0), int(fy0), int(fx1), int(fy1))
cropped = [im.crop(crop_box) for _, im in panels]
pw, ph = cropped[0].size
strip = Image.new("RGB", (pw * 4 + 30, ph + 24), (255, 255, 255))
x = 0
for (label, _), im in zip(panels, cropped):
strip.paste(im, (x, 24))
d = ImageDraw.Draw(strip)
d.text((x, 4), label, fill=(0, 0, 0))
x += pw + 10
out_path = PREVIEW_V2_DIR / "face-detail.png"
strip.save(out_path)
print(f"저장: {out_path}")
# ------------------------------------------------------------------
# closed-eyes.png: 벡터 없이 구멍만 보이는 정지 합성의 눈·입 확대
# ------------------------------------------------------------------
eye_box = (280, 470, 700, 680)
mouth_box = (360, 730, 630, 910)
eye_crop = Image.fromarray(to_u8(static_full), "RGB").crop(eye_box)
mouth_crop = Image.fromarray(to_u8(static_full), "RGB").crop(mouth_box)
zoom = 2
eye_crop = eye_crop.resize((eye_crop.width * zoom, eye_crop.height * zoom), Image.LANCZOS)
mouth_w = eye_crop.width
mouth_h = round(mouth_crop.height * (mouth_w / mouth_crop.width))
mouth_crop = mouth_crop.resize((mouth_w, mouth_h), Image.LANCZOS)
out_im = Image.new("RGB", (mouth_w, eye_crop.height + mouth_h + 10), (255, 255, 255))
out_im.paste(eye_crop, (0, 0))
out_im.paste(mouth_crop, (0, eye_crop.height + 10))
out_path = PREVIEW_V2_DIR / "closed-eyes.png"
out_im.save(out_path)
print(f"저장: {out_path}")
# ------------------------------------------------------------------
# ghost-check.jpg: faceDetail만 올린 얼굴(벡터 없음)의 눈·눈썹·입을 한
# 프레임으로 2배 확대 — 옛 잉크선(유령 윤곽)이 남았는지 보는 용도.
# ------------------------------------------------------------------
ghost_box = (260, 440, 760, 940)
ghost_crop = Image.fromarray(to_u8(static_full), "RGB").crop(ghost_box)
ghost_crop = ghost_crop.resize((ghost_crop.width * 2, ghost_crop.height * 2), Image.LANCZOS)
out_path = PREVIEW_V2_DIR / "ghost-check.jpg"
ghost_crop.convert("RGB").save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -0,0 +1,176 @@
"""P1 서연 턱 조각(jaw-head·jaw-detail) 생성 — 2단계-B, 결정문 §8.4 하관 띠 변형
(작업 패킷 A5).
하관 띠 변형(렌더러 v3)은 ㅏ·ㅗ 등에서 턱·아랫입술·콧방울을 세로 띠로 나눠 옮기는
변형이다(결정문 §8.4 "하관 띠 변형 — 턱·코 들썩"). 전체 1005×1566 head·faceDetail
이미지를 매 프레임 다시 그리면 비용이 크므로(out5 실측: long task 14/7.3s), 변형이
실제로 필요한 사각형만 미리 잘라 둔다.
영역(결정문 §8.4, 작업 패킷 A5):
- y: noseTip.y − 50 − 10 부터 chinTip.y + 40 까지.
- x: 얼굴 윤곽(faceOval, p1Rig.ts에 이미 게시된 값과 같은 36점 루프)을 바깥으로
18px 넓힌 다각형의, 이 y 범위 안에서의 가로 범위(렌더러가 각 띠를 그 다각형으로
clip하므로, 이 y 범위를 벗어난 곳의 폭은 필요 없다 — 재는 방식은 아래 참고).
- 변형·메우기는 하지 않는다. head·face-detail의 **원본 픽셀·알파를 그대로** 잘라
layers/v2/jaw-{head,detail}.png로 저장한다. 게시본은 무손실 WebP(exact=True)다
(export_rig.py의 publish_jaw_pieces).
가로 범위 측정: 얼굴 윤곽 다각형을 캔버스 크기 마스크로 채우고(cv2.fillPoly) 유클리드
거리 변환으로 18px 확장한 뒤(build_lip_texture.py의 DILATE_PX와 같은 기법), y 범위로
제한한 행들에서 열이 하나라도 켜진 범위를 x0~x1로 쓴다. (참고: 이 y 범위 안에서는
다각형 경계가 거의 수직에 가까워, 단순히 "y 범위 안 원본 다각형의 x0/x1에 18을 더/
빼는" 결과와 사실상 같다 — 두 방식을 대조해 확인했다.)
실행: <venv>/python.exe build_jaw_pieces.py
"""
from __future__ import annotations
import io
import json
import math
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_face_oval # 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"
NOSE_TO_TOP_PX = 50.0 + 10.0 # y0 = noseTip.y - (이 값)
CHIN_TO_BOTTOM_PX = 40.0 # y1 = chinTip.y + (이 값)
OVAL_OUTSET_PX = 18.0 # 얼굴 윤곽을 바깥으로 넓히는 폭
def main() -> int:
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
lm = manifest["landmarks"]
nose_tip = tuple(lm["noseTip"])
chin_tip = tuple(lm["chinTip"])
print(f"noseTip={nose_tip} chinTip={chin_tip}")
points = detect_face_landmarks(BASE_DIR / "base-front.png")
face_oval = compute_face_oval(points)
canvas_w, canvas_h = manifest["canvas"]["w"], manifest["canvas"]["h"]
y0f = nose_tip[1] - NOSE_TO_TOP_PX
y1f = chin_tip[1] + CHIN_TO_BOTTOM_PX
y0i, y1i = int(math.floor(y0f)), int(math.ceil(y1f))
print(f"y 범위 = [{y0f:.2f}, {y1f:.2f}] -> 정수 [{y0i}, {y1i}) (h={y1i-y0i})")
oval_mask = np.zeros((canvas_h, canvas_w), dtype=np.uint8)
oval_pts = np.array([[round(p[0]), round(p[1])] for p in face_oval], dtype=np.int32)
cv2.fillPoly(oval_mask, [oval_pts], 1)
oval_mask_b = oval_mask.astype(bool)
dist_out = ndimage.distance_transform_edt(~oval_mask_b)
dilated = oval_mask_b | (dist_out <= OVAL_OUTSET_PX)
if y0i <= 0 or y1i >= canvas_h:
raise SystemExit(f"[중단] y 범위가 캔버스 경계에 닿았다: [{y0i},{y1i}) canvas_h={canvas_h}")
band = dilated[y0i:y1i, :]
cols = np.where(band.any(axis=0))[0]
if len(cols) == 0:
raise SystemExit("[중단] 얼굴 윤곽 확장 다각형이 해당 y 범위에서 비어 있다.")
x0i, x1i = int(cols.min()), int(cols.max()) + 1
if x0i <= 0 or x1i >= canvas_w:
raise SystemExit(f"[중단] x 범위가 캔버스 경계에 닿았다: [{x0i},{x1i}) canvas_w={canvas_w}")
print(f"x 범위(얼굴 윤곽 {OVAL_OUTSET_PX}px 확장, y 범위 안) = [{x0i}, {x1i}) (w={x1i-x0i})")
# 대조: 다각형 경계가 이 y 범위에서 거의 수직인지 확인 — 확장 없이 구한 x 범위에 단순히
# OVAL_OUTSET_PX를 더/뺀 값과 비교해 기록만 한다(설계 검증용, 결과에는 dilated만 쓴다).
band_raw = oval_mask_b[y0i:y1i, :]
cols_raw = np.where(band_raw.any(axis=0))[0]
x0_raw, x1_raw = int(cols_raw.min()), int(cols_raw.max()) + 1
naive_x0, naive_x1 = x0_raw - int(OVAL_OUTSET_PX), x1_raw + int(OVAL_OUTSET_PX)
print(f"대조(단순 폭 확장) = [{naive_x0}, {naive_x1}) vs 거리변환 [{x0i}, {x1i}) "
f"차이=({x0i-naive_x0},{x1i-naive_x1})")
head_full = np.array(Image.open(LAYERS_V2_DIR / "head.png").convert("RGBA"))
detail_full = np.array(Image.open(LAYERS_V2_DIR / "face-detail.png").convert("RGBA"))
if head_full.shape[:2] != (canvas_h, canvas_w) or detail_full.shape[:2] != (canvas_h, canvas_w):
raise SystemExit(
f"[중단] head/face-detail 크기가 캔버스와 다르다: head={head_full.shape[:2]} "
f"detail={detail_full.shape[:2]} canvas=({canvas_h},{canvas_w})"
)
pieces = {"head": (head_full, LAYERS_V2_DIR / "jaw-head.png"),
"detail": (detail_full, LAYERS_V2_DIR / "jaw-detail.png")}
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
checks: dict = {}
for key, (src, out_path) in pieces.items():
crop = src[y0i:y1i, x0i:x1i].copy()
Image.fromarray(crop, "RGBA").save(out_path)
reloaded = np.array(Image.open(out_path).convert("RGBA"))
max_diff = int(np.abs(reloaded.astype(np.int32) - crop.astype(np.int32)).max())
ok = max_diff == 0
print(f"저장: {out_path} size={crop.shape[1]}x{crop.shape[0]} "
f"검사(1) 원본 대비 PNG 왕복 최대차(알파 포함)={max_diff} {'OK' if ok else '[실패]'}")
checks[key] = {
"pngRoundTripMaxDiff": max_diff,
"pngRoundTripOk": ok,
"pngFileSize": out_path.stat().st_size,
}
_save_evidence(pieces, x0i, y0i, x1i, y1i)
manifest["jaw"] = {
"designVersion": "A5 (작업 패킷 A5, 결정문 §8.4 하관 띠 변형 — 턱 조각 원본)",
"noseTip": list(nose_tip),
"chinTip": list(chin_tip),
"yFormula": "noseTip.y - 50 - 10 .. chinTip.y + 40",
"noseToTopPx": NOSE_TO_TOP_PX,
"chinToBottomPx": CHIN_TO_BOTTOM_PX,
"ovalOutsetPx": OVAL_OUTSET_PX,
"faceOvalSource": "export_rig.detect_face_landmarks + compute_face_oval(base-front.png, 결정적)",
"bboxCanvas": [x0i, y0i, x1i, y1i],
"naiveXRangeForComparison": [naive_x0, naive_x1],
"checks": checks,
"evidenceImage": "preview/v2/jaw-pieces.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(pieces: dict, x0i: int, y0i: int, x1i: int, y1i: int) -> None:
"""jaw-head | jaw-detail(체커 배경), 원본 배율."""
panels = []
for key in ("head", "detail"):
src, _out_path = pieces[key]
crop = src[y0i:y1i, x0i:x1i]
rgb = crop[..., :3].astype(np.float64)
a = (crop[..., 3].astype(np.float64) / 255.0)[..., None]
check = np.indices(a.shape[:2])
checker = ((check[0] // 10 + check[1] // 10) % 2) * 60 + 180
checker3 = np.stack([checker] * 3, axis=-1).astype(np.float64)
out = rgb * a + checker3 * (1.0 - a)
panels.append(Image.fromarray(np.clip(out, 0, 255).astype(np.uint8)))
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, (x, 0))
x += p.width + gap
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
out_path = PREVIEW_V2_DIR / "jaw-pieces.jpg"
combined.save(out_path, "JPEG", quality=92)
print(f"저장: {out_path}")
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,361 @@
"""P1 서연 리노컷 리그 덩어리 레이어(body/head/hairFront) 빌드 스크립트.
raw/*.png (초록 배경 위 codex exec 생성본) -> 크로마키 -> 기준 이미지 위상상관 정렬
-> layers/*.png(투명 PNG) + preview/*.png + manifest.json(layers 섹션).
실행: <venv>/python.exe build_layers.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 gaussian_filter
ROOT = Path(__file__).resolve().parents[1]
BASE_DIR = ROOT / "base"
RAW_DIR = ROOT / "raw"
LAYERS_DIR = ROOT / "layers"
PREVIEW_DIR = ROOT / "preview"
MANIFEST_PATH = ROOT / "manifest.json"
CREAM_BG = (0xEE, 0xE5, 0xD3)
# AGENTS.md §4.2 알파 정제 임계치
ALPHA_LO, ALPHA_HI = 35, 205
FEATHER_SIGMA = 0.6 # ~1px 페더
# 크로마키(HSV 기반) 튜닝값. #00ff00 배경 기준.
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
GREEN_RESIDUE_MARGIN = 30 # G > R+margin && G > B+margin
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=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 load_and_normalize(path: Path, canvas_size: tuple[int, int]) -> tuple[np.ndarray, dict]:
im = Image.open(path).convert("RGB")
src_w, src_h = im.size
tgt_w, tgt_h = canvas_size
report = {"srcSize": [src_w, src_h], "targetSize": [tgt_w, tgt_h], "resized": False}
if (src_w, src_h) != (tgt_w, tgt_h):
src_ratio = src_w / src_h
tgt_ratio = tgt_w / tgt_h
ratio_diff_pct = abs(src_ratio - tgt_ratio) / tgt_ratio * 100.0
report["srcRatio"] = src_ratio
report["targetRatio"] = tgt_ratio
report["ratioDiffPct"] = ratio_diff_pct
if ratio_diff_pct > 1.0:
raise SystemExit(
f"[중단] {path.name}: 종횡비 차이 {ratio_diff_pct:.3f}% > 1% "
f"(src={src_w}x{src_h}, target={tgt_w}x{tgt_h}) — 보고 후 정지."
)
im = im.resize((tgt_w, tgt_h), Image.LANCZOS)
report["resized"] = True
return np.array(im), report
def phase_correlate(mask_a: np.ndarray, mask_b: np.ndarray) -> tuple[int, int]:
"""mask_a를 mask_b에 맞추기 위한 정수 (dx, dy) 오프셋을 반환한다.
mask_a를 (dy行, dx열)만큼 이동시키면 mask_b와 정렬된다."""
a = mask_a.astype(np.float64)
b = mask_b.astype(np.float64)
fa = np.fft.fft2(a)
fb = np.fft.fft2(b)
cross = fa * np.conj(fb)
denom = np.abs(cross)
denom[denom == 0] = 1e-12
r = np.fft.ifft2(cross / denom)
r = np.abs(r)
peak = np.unravel_index(np.argmax(r), r.shape)
dy, dx = peak
h, w = a.shape
if dy > h // 2:
dy -= h
if dx > w // 2:
dx -= w
# 교차 위상 스펙트럼 peak는 -d(이동량)에서 나타난다(이산 이동 정리) — 부호 반전해 반환.
return int(-dx), int(-dy)
def shift_rgba(rgb: np.ndarray, alpha: np.ndarray, dx: int, dy: int) -> tuple[np.ndarray, np.ndarray]:
h, w = alpha.shape
out_rgb = np.zeros_like(rgb)
out_alpha = np.zeros_like(alpha)
src_x0, src_x1 = max(0, -dx), min(w, w - dx)
src_y0, src_y1 = max(0, -dy), min(h, h - dy)
dst_x0, dst_x1 = max(0, dx), min(w, w + dx)
dst_y0, dst_y1 = max(0, dy), min(h, h + dy)
out_rgb[dst_y0:dst_y1, dst_x0:dst_x1] = rgb[src_y0:src_y1, src_x0:src_x1]
out_alpha[dst_y0:dst_y1, dst_x0:dst_x1] = alpha[src_y0:src_y1, src_x0:src_x1]
return out_rgb, out_alpha
def pad_to_canvas(mask: np.ndarray, canvas_w: int, canvas_h: int) -> np.ndarray:
src_h, src_w = mask.shape
if (src_w, src_h) == (canvas_w, canvas_h):
return mask
out = np.zeros((canvas_h, canvas_w), dtype=mask.dtype)
h = min(src_h, canvas_h)
w = min(src_w, canvas_w)
out[:h, :w] = mask[:h, :w]
return out
def build_body_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray:
h, w, _ = base_front_rgb.shape
lum = base_front_rgb.astype(np.float64).mean(axis=2)
y0 = int(0.62 * h)
mask = np.zeros((h, w), dtype=bool)
mask[y0:, :] = lum[y0:, :] < 90
return mask
def build_head_ref_mask(base_faceless_rgb: np.ndarray) -> np.ndarray:
h, w, _ = base_faceless_rgb.shape
bg = np.array([233.0, 226.0, 207.0])
diff = np.sqrt(((base_faceless_rgb.astype(np.float64) - bg) ** 2).sum(axis=2))
y1 = int(0.735 * h)
mask = np.zeros((h, w), dtype=bool)
mask[:y1, :] = diff[:y1, :] > 25
return mask
def build_hair_front_ref_mask(base_front_rgb: np.ndarray) -> np.ndarray:
h, w, _ = base_front_rgb.shape
lum = base_front_rgb.astype(np.float64).mean(axis=2)
yy, xx = np.mgrid[0:h, 0:w]
cx, cy = w * 0.5, h * 0.365
rx, ry = w * 0.30, h * 0.34
oval = ((xx - cx) / rx) ** 2 + ((yy - cy) / ry) ** 2 <= 1.0
dark = lum < 90
return oval & dark
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 main() -> int:
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
base_front = np.array(Image.open(BASE_DIR / "base-front.png").convert("RGB"))
base_faceless = np.array(Image.open(BASE_DIR / "base-faceless.png").convert("RGB"))
canvas_h, canvas_w = base_front.shape[0], base_front.shape[1]
canvas_size = (canvas_w, canvas_h)
print(f"기준 캔버스: {canvas_w}x{canvas_h}")
ref_masks = {
"body": build_body_ref_mask(base_front),
"head": pad_to_canvas(build_head_ref_mask(base_faceless), canvas_w, canvas_h),
"hairFront": build_hair_front_ref_mask(base_front),
}
for k, m in ref_masks.items():
Image.fromarray((m * 255).astype(np.uint8)).save(PREVIEW_DIR / f"refmask-{k}.png")
layer_specs = [
("body", "body.png"),
("head", "head.png"),
("hairFront", "hair-front.png"),
]
layers_report = []
results = {}
for layer_id, filename in layer_specs:
raw_path = RAW_DIR / filename
rgb, norm_report = load_and_normalize(raw_path, canvas_size)
despilled_rgb, alpha = chroma_key(rgb)
layer_mask = alpha > 127
ref_mask = ref_masks[layer_id]
dx0, dy0 = phase_correlate(layer_mask, ref_mask)
shifted_rgb, shifted_alpha = shift_rgba(despilled_rgb, alpha, dx0, dy0)
shifted_mask = shifted_alpha > 127
dx1, dy1 = phase_correlate(shifted_mask, ref_mask)
residual_exceeds = abs(dx1) > 1 or abs(dy1) > 1
out = np.dstack([shifted_rgb, shifted_alpha]).astype(np.uint8)
out_path = LAYERS_DIR / f"{layer_id}.png"
Image.fromarray(out, "RGBA").save(out_path)
bbox = alpha_bbox(shifted_alpha)
opaque_pixels = int((shifted_alpha == 255).sum())
green_residue = green_residue_count(shifted_rgb, shifted_alpha)
entry = {
"id": layer_id,
"file": f"layers/{layer_id}.png",
"sourceRaw": f"raw/{filename}",
"rawNormalize": norm_report,
"alphaBBox": bbox,
"opaquePixels": opaque_pixels,
"greenResidue": green_residue,
"alignOffsetBefore": [dx0, dy0],
"alignOffsetAfter": [dx1, dy1],
"residualExceeds1px": residual_exceeds,
}
layers_report.append(entry)
results[layer_id] = (shifted_rgb, shifted_alpha)
print(
f"[{layer_id}] normalize={norm_report} offsetBefore=({dx0},{dy0}) "
f"offsetAfter=({dx1},{dy1}) bbox={bbox} opaque={opaque_pixels} "
f"greenResidue={green_residue}"
)
if residual_exceeds:
print(f" [경고] {layer_id} 잔여 오프셋이 ±1px를 초과했다: ({dx1},{dy1})")
canvas_rgba = np.zeros((canvas_h, canvas_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 = results[layer_id]
canvas_rgba = composite_over(canvas_rgba, rgb, alpha)
composite = canvas_rgba.astype(np.uint8)
composite_img = Image.fromarray(composite, "RGBA")
composite_img.save(PREVIEW_DIR / "composite-faceless.png")
base_faceless_img = Image.open(BASE_DIR / "base-faceless.png").convert("RGBA")
bf_w, bf_h = base_faceless_img.size
cmp_w, cmp_h = composite_img.size
diff_w, diff_h = min(bf_w, cmp_w), min(bf_h, cmp_h)
size_note = None
if (bf_w, bf_h) != (cmp_w, cmp_h):
size_note = (
f"base-faceless.png({bf_w}x{bf_h})와 composite({cmp_w}x{cmp_h}) 크기가 달라 "
f"좌상단 기준 {diff_w}x{diff_h} 교차 영역만 비교했다."
)
print(f"[안내] {size_note}")
composite_arr = np.array(composite_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64)
base_arr = np.array(base_faceless_img.convert("RGB"))[0:diff_h, 0:diff_w].astype(np.float64)
full_diff = np.abs(composite_arr - base_arr).mean(axis=2)
mean_abs_diff_full = float(full_diff.mean())
head_ref = ref_masks["head"][0:diff_h, 0:diff_w]
mean_abs_diff_face = float(full_diff[head_ref].mean()) if head_ref.any() else None
hair_edge = ref_masks["hairFront"][0:diff_h, 0:diff_w]
from scipy.ndimage import binary_dilation, binary_erosion
dilated = binary_dilation(hair_edge, iterations=6)
eroded = binary_erosion(hair_edge, iterations=6)
hair_outline_band = dilated & ~eroded
mean_abs_diff_hair_outline = (
float(full_diff[hair_outline_band].mean()) if hair_outline_band.any() else None
)
side_by_side = Image.new("RGB", (diff_w * 2 + 20, diff_h), CREAM_BG)
side_by_side.paste(base_faceless_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (0, 0))
side_by_side.paste(composite_img.convert("RGB").crop((0, 0, diff_w, diff_h)), (diff_w + 20, 0))
side_by_side.save(PREVIEW_DIR / "compare.png")
print(
f"합성 차이: 전체={mean_abs_diff_full:.3f} 얼굴영역={mean_abs_diff_face} "
f"머리윤곽영역={mean_abs_diff_hair_outline}"
)
manifest = {}
if MANIFEST_PATH.exists():
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["schemaVersion"] = "vignette.avatar.v3.layers.v1"
manifest["persona"] = "P1"
manifest["canvas"] = {"w": canvas_w, "h": canvas_h}
manifest["base"] = {
"front": "base/base-front.png",
"faceless": "base/base-faceless.png",
"facelessSize": list(base_faceless_img.size),
}
manifest["layers"] = layers_report
manifest["composite"] = {
"meanAbsDiff": {
"full": mean_abs_diff_full,
"face": mean_abs_diff_face,
"hairOutline": mean_abs_diff_hair_outline,
},
"sizeNote": size_note,
}
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())

View file

@ -0,0 +1,411 @@
"""P1 서연 리노컷 리그 — 분할 기반 재작업 (2단계-A 반려 후 설계).
레이어 픽셀은 항상 base-faceless.png(패딩판 F)에서 가져온다. mediapipe
ImageSegmenter(selfie_multiclass_256x256)로 F를 분할해 head/hairFront/body
마스크를 만들고, body는 head 마스크가 덮는 영역 중 기존에 수용된 재생성
raw/body.png(크로마키 결과, offset 0,0 확인됨)의 알파>0 부분만 가려진 영역
채움으로 사용한다. imagegen 추가 호출 없음.
실행: <venv>/python.exe build_layers_segmented.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, distance_transform_edt, gaussian_filter
ROOT = Path(__file__).resolve().parents[1]
BASE_DIR = ROOT / "base"
RAW_DIR = ROOT / "raw"
LAYERS_DIR = ROOT / "layers"
PREVIEW_DIR = ROOT / "preview"
MANIFEST_PATH = ROOT / "manifest.json"
SCRIPTS_DIR = Path(__file__).resolve().parent
MODEL_SEG = SCRIPTS_DIR / "_models" / "selfie_multiclass_256x256.tflite"
MODEL_FACE = SCRIPTS_DIR / "_models" / "face_landmarker.task"
MEDIAPIPE_VERSION = "1.0.1" # pip show mediapipe로 확인(런타임 import 생략 — 동일 프로세스 세그폴트 회피)
CREAM_BG = (0xEE, 0xE5, 0xD3)
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
# 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,
]
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers import chroma_key # noqa: E402 (기존 크로마키 함수 재사용)
def build_padded_faceless() -> 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)가
재현확인됐다(scripts/_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() -> int:
LAYERS_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_DIR.mkdir(parents=True, exist_ok=True)
f_img = build_padded_faceless()
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
from scipy.ndimage import binary_erosion
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"] = "P1"
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": [1005, 1565],
"padNote": "base-faceless.png 마지막 행을 복제해 1005x1566(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"] = {
"head": {"file": "raw/head.png", "status": "rejected", "reason": "얼굴 폭·턱선·귀·머리숱이 base-faceless와 달라짐(2단계-A 1차 반려 사유)"},
"hairFront": {"file": "raw/hair-front.png", "status": "rejected", "reason": "노란 하이라이트 획 등 기준에 없던 색상 아티팩트, 형태 변형(2단계-A 1차 반려 사유)"},
"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())

View file

@ -0,0 +1,650 @@
"""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 제거)를
적용해 배경색이 섞여 나오지 않게 한다.
실행: <venv>/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())

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())

View file

@ -0,0 +1,177 @@
"""P1 서연 종이결 타일 생성 — 2단계-B-1a (4).
base-front.png의 배경 영역(분할 category 0, 인물 경계에서 20px 이상 떨어진 곳)에서
256x256을 뽑아 무채색에 가까운(평균 명도 245~250) 곱하기용 타일로 만든다.
np.roll로 원본의 이음매를 캔버스 중앙 십자선으로 옮긴 뒤 그 십자선만 국소적으로
블러 블렌드해 감춘다 — 그 결과 타일의 실제 바깥 가장자리(좌우/상하)는 원본에서
서로 인접했던 픽셀이라 이어 붙여도 이음매가 거의 보이지 않는다.
실행: <venv>/python.exe build_paper_grain.py
"""
from __future__ import annotations
import json
from pathlib import Path
import numpy as np
from PIL import Image
from scipy.ndimage import distance_transform_edt, gaussian_filter, label
SCRIPTS_DIR = Path(__file__).resolve().parent
import sys # noqa: E402
sys.path.insert(0, str(SCRIPTS_DIR))
from build_layers_segmented import run_segmentation # 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"
TILE_SIZE = 256
PERSON_MARGIN_PX = 20
CANVAS_EDGE_MARGIN_PX = 10 # 캔버스 실제 가장자리(테두리 비네팅 위험) 회피
MIN_CLEAN_FRAC = 0.90 # 완전히 깨끗한 256x256 창이 캔버스 어디에도 없어 최선의 창을 쓰고 나머지는 메운다
TARGET_LUM_MIN = 245.0
TARGET_LUM_MAX = 250.0
TARGET_LUM_MID = (TARGET_LUM_MIN + TARGET_LUM_MAX) / 2.0
CHROMA_KEEP = 0.30 # 원래 색조를 30%만 남기고(거의 무채색), 70%는 명도값으로 대체
SEAM_FALLOFF_PX = 24.0
SEAM_BLUR_SIGMA = 6.0
def find_tile_origin(eligible: np.ndarray, size: int) -> tuple[int, int, float]:
"""eligible 비율이 가장 높은 size x size 창의 좌상단 좌표와 그 비율을 고른다.
캔버스 인물이 배경 대부분 면적에 걸쳐 있어(세로로 긴 흉상 구도) 잔머리 등
가장자리 침범 때문에 완전히 100% 깨끗한 창은 어디에도 없다 — 최선의 창을 고르고
남은 오염 픽셀은 호출부에서 인접 픽셀로 메운다."""
h, w = eligible.shape
mask_f = eligible.astype(np.float64)
csum = np.pad(np.cumsum(np.cumsum(mask_f, axis=0), axis=1), ((1, 0), (1, 0)))
sums = csum[size:, size:] - csum[:-size, size:] - csum[size:, :-size] + csum[:-size, :-size]
y_lo, y_hi = CANVAS_EDGE_MARGIN_PX, h - size - CANVAS_EDGE_MARGIN_PX
x_lo, x_hi = CANVAS_EDGE_MARGIN_PX, w - size - CANVAS_EDGE_MARGIN_PX
if y_hi < y_lo or x_hi < x_lo:
y_lo, y_hi, x_lo, x_hi = 0, h - size, 0, w - size
sub = sums[y_lo:y_hi + 1, x_lo:x_hi + 1]
idx = np.unravel_index(np.argmax(sub), sub.shape)
y0, x0 = int(idx[0] + y_lo), int(idx[1] + x_lo)
frac = float(sub[idx]) / float(size * size)
if frac < MIN_CLEAN_FRAC:
raise SystemExit(f"[중단] 가장 깨끗한 256x256 창도 {frac*100:.1f}%로 기준({MIN_CLEAN_FRAC*100:.0f}%) 미달이다.")
return y0, x0, frac
def main() -> int:
LAYERS_V2_DIR.mkdir(parents=True, exist_ok=True)
PREVIEW_V2_DIR.mkdir(parents=True, exist_ok=True)
front_path = BASE_DIR / "base-front.png"
front = np.array(Image.open(front_path).convert("RGB")).astype(np.float64)
h, w, _ = front.shape
category_mask = run_segmentation(front_path)
bg_mask = category_mask == 0
# 배경(category0) 자체가 종이결 텍스처 노이즈 때문에 분류기가 곳곳에 좁쌀만한
# 오분류 반점을 흩뿌려 놓는다(연결요소가 15개로 쪼개짐). 그 반점 하나하나에서
# 20px씩 침식하면 실제 인물과 무관한 곳까지 배경 후보가 사라진다. 대신
# "인물(가장 큰 전경 연결요소) 경계에서 20px 이상"만 걸러 낸다 — 반점은 실제
# 얼굴 이 아니라 채도 낮은 종이 위의 분류 잡음이므로 그 자리 원본 픽셀도 그냥
# 종이 질감이다.
fg_mask = category_mask != 0
labeled_fg, _ = label(fg_mask)
sizes = np.bincount(labeled_fg.ravel())
sizes[0] = 0
person_mask = labeled_fg == int(np.argmax(sizes))
dist_to_person = distance_transform_edt(~person_mask)
eligible = bg_mask & (dist_to_person >= PERSON_MARGIN_PX)
print(f"배경(category0) 픽셀: {int(bg_mask.sum())}, 인물 경계 {PERSON_MARGIN_PX}px 이상 떨어진 후보: {int(eligible.sum())}")
y0, x0, clean_frac = find_tile_origin(eligible, TILE_SIZE)
patch = front[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE, :].copy()
print(f"타일 원본 위치: (x={x0}, y={y0}), size={TILE_SIZE}, 깨끗한 비율={clean_frac*100:.2f}%")
# 인물이 거의 전체 캔버스 높이를 차지하는 흉상 구도라 완전히 깨끗한 창이 없다
# (가장 좋은 창도 잔머리가 모서리에 살짝 걸침). 그 창 안의 오염 픽셀만 같은
# 패치의 가장 가까운 깨끗한 픽셀 색으로 메운다 — 머리카락 색이 종이결에
# 섞이는 것을 막는다.
contaminated_frac = 1.0 - clean_frac
win_eligible = eligible[y0:y0 + TILE_SIZE, x0:x0 + TILE_SIZE]
bad = ~win_eligible
n_bad = int(bad.sum())
if n_bad > 0:
_, (iy, ix) = distance_transform_edt(bad, return_indices=True)
patch[bad] = patch[iy[bad], ix[bad]]
print(f"오염 픽셀 메움: {n_bad}px ({contaminated_frac*100:.2f}%)")
# --- 무채색화 + 명도 목표대로 이동(결 대비=표준편차는 유지) ---
lum = patch.mean(axis=2)
shift = TARGET_LUM_MID - lum.mean()
lum_adj = np.clip(lum + shift, 0, 255)
# 각 픽셀의 원래 색조(자기 명도 대비 편차)를 유지한 채 명도만 이동
tinted = patch - lum[..., None] + lum_adj[..., None]
achromatic = np.clip(tinted * CHROMA_KEEP + lum_adj[..., None] * (1.0 - CHROMA_KEEP), 0, 255)
print(f"명도 이동: 원본평균={lum.mean():.2f} -> 목표={TARGET_LUM_MID:.2f} (shift={shift:.2f})")
print(f"조정 후 평균 명도={achromatic.mean(axis=2).mean():.2f}, 표준편차={lum.std():.2f}(원본)/{achromatic.mean(axis=2).std():.2f}(조정 후)")
# --- 이음매를 캔버스 중앙으로 옮기고 그 십자선만 블러로 감춘다 ---
rolled = np.roll(achromatic, shift=(TILE_SIZE // 2, TILE_SIZE // 2), axis=(0, 1))
blurred = np.stack([gaussian_filter(rolled[..., c], sigma=SEAM_BLUR_SIGMA) for c in range(3)], axis=2)
yy, xx = np.mgrid[0:TILE_SIZE, 0:TILE_SIZE]
dist_seam = np.minimum(np.abs(yy - TILE_SIZE // 2), np.abs(xx - TILE_SIZE // 2)).astype(np.float64)
seam_w = np.clip(1.0 - dist_seam / SEAM_FALLOFF_PX, 0.0, 1.0) ** 2
tile = rolled * (1 - seam_w[..., None]) + blurred * seam_w[..., None]
tile = np.clip(tile, 0, 255)
out_path = LAYERS_V2_DIR / "paper-grain.png"
Image.fromarray(tile.round().astype(np.uint8), "RGB").save(out_path)
print(f"저장: {out_path}")
# --- 이음매 연속성 검사: 좌우/상하 가장자리 1px 평균 차 ---
left_edge = tile[:, 0, :].mean(axis=0)
right_edge = tile[:, -1, :].mean(axis=0)
top_edge = tile[0, :, :].mean(axis=0)
bottom_edge = tile[-1, :, :].mean(axis=0)
lr_diff = float(np.abs(left_edge - right_edge).mean())
tb_diff = float(np.abs(top_edge - bottom_edge).mean())
print(f"이음매 연속성: 좌우 가장자리 평균차={lr_diff:.3f}, 상하 가장자리 평균차={tb_diff:.3f} (참고: 작을수록 매끄러움)")
final_lum = tile.mean(axis=2)
print(f"최종 타일 평균 명도={final_lum.mean():.2f} (목표 {TARGET_LUM_MIN}~{TARGET_LUM_MAX}), 표준편차={final_lum.std():.2f}")
# --- 미리보기: 3x3 타일링 ---
tiled = Image.new("RGB", (TILE_SIZE * 3, TILE_SIZE * 3))
tile_im = Image.fromarray(tile.round().astype(np.uint8), "RGB")
for j in range(3):
for i in range(3):
tiled.paste(tile_im, (i * TILE_SIZE, j * TILE_SIZE))
tiled.save(PREVIEW_V2_DIR / "paper-grain-tiled.png")
print(f"저장: {PREVIEW_V2_DIR / 'paper-grain-tiled.png'}")
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
manifest["layersV2"]["paperGrain"] = {
"sourceImage": "base/base-front.png",
"sourceOrigin": [x0, y0],
"tileSize": TILE_SIZE,
"personMarginPx": PERSON_MARGIN_PX,
"cleanFraction": clean_frac,
"contaminatedPixelsFilled": n_bad,
"chromaKeep": CHROMA_KEEP,
"targetLumRange": [TARGET_LUM_MIN, TARGET_LUM_MAX],
"lumShift": shift,
"finalMeanLum": float(final_lum.mean()),
"finalLumStd": float(final_lum.std()),
"seamContinuity": {"leftRightDiff": lr_diff, "topBottomDiff": tb_diff},
}
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__":
raise SystemExit(main())

View file

@ -0,0 +1,221 @@
"""base-front.png에서 mediapipe FaceLandmarker로 얼굴 랜드마크를 검출해
manifest.json의 landmarks 섹션을 채우고 preview/landmarks.png를 만든다.
좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는
이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다.
실행: <venv>/python.exe detect_landmarks.py
"""
from __future__ import annotations
import json
from pathlib import Path
import mediapipe as mp
import numpy as np
from mediapipe.tasks import python as mp_python
from mediapipe.tasks.python import vision
from PIL import Image, ImageDraw, ImageFont
ROOT = Path(__file__).resolve().parents[1]
BASE_FRONT = ROOT / "base" / "base-front.png"
MODEL_PATH = Path(__file__).resolve().parent / "_models" / "face_landmarker.task"
MANIFEST_PATH = ROOT / "manifest.json"
PREVIEW_PATH = ROOT / "preview" / "landmarks.png"
RIGHT_EYEBROW_IDX = [46, 53, 52, 65, 55, 70, 63, 105, 66, 107]
LEFT_EYEBROW_IDX = [276, 283, 282, 295, 285, 300, 293, 334, 296, 336]
EYE_A_IDX = {"outer": 33, "inner": 133, "top": 159, "bottom": 145}
EYE_B_IDX = {"inner": 362, "outer": 263, "top": 386, "bottom": 374}
NOSE_TIP_IDX = 1
CHIN_IDX = 152
MOUTH_CORNER_A_IDX = 61
MOUTH_CORNER_B_IDX = 291
UPPER_LIP_TOP_IDX = 0
LOWER_LIP_BOTTOM_IDX = 17
FACE_EDGE_A_IDX = 234
FACE_EDGE_B_IDX = 454
IRIS_A = {"center": 468, "ring": [469, 470, 471, 472]}
IRIS_B = {"center": 473, "ring": [474, 475, 476, 477]}
def detect() -> dict | None:
if not MODEL_PATH.exists():
return None
base_options = mp_python.BaseOptions(model_asset_path=str(MODEL_PATH))
options = vision.FaceLandmarkerOptions(
base_options=base_options,
running_mode=vision.RunningMode.IMAGE,
num_faces=1,
)
im = Image.open(BASE_FRONT).convert("RGB")
w, h = im.size
arr = np.array(im)
mp_image = mp.Image(image_format=mp.ImageFormat.SRGB, data=arr)
with vision.FaceLandmarker.create_from_options(options) as landmarker:
result = landmarker.detect(mp_image)
if not result.face_landmarks:
return None
lm = result.face_landmarks[0]
pts = [(p.x * w, p.y * h) for p in lm]
print(f"검출된 랜드마크 개수: {len(pts)}")
def pt(idx: int) -> list[float]:
x, y = pts[idx]
return [round(x, 2), round(y, 2)]
def screen_label(idx_a: int, idx_b: int) -> tuple[int, int]:
"""두 인덱스를 화면 기준 left(작은 x)/right(큰 x)로 정렬해 반환."""
xa = pts[idx_a][0]
xb = pts[idx_b][0]
return (idx_a, idx_b) if xa < xb else (idx_b, idx_a)
left_eye_idx, right_eye_idx = screen_label(EYE_A_IDX["outer"], EYE_B_IDX["outer"])
eyeA_is_left = left_eye_idx == EYE_A_IDX["outer"]
eyeL = EYE_A_IDX if eyeA_is_left else EYE_B_IDX
eyeR = EYE_B_IDX if eyeA_is_left else EYE_A_IDX
irisL, irisR = (IRIS_A, IRIS_B) if eyeA_is_left else (IRIS_B, IRIS_A)
def iris_stats(iris: dict) -> dict:
cx, cy = pts[iris["center"]]
radii = [
float(np.hypot(pts[i][0] - cx, pts[i][1] - cy)) for i in iris["ring"]
]
return {"center": [round(cx, 2), round(cy, 2)], "radius": round(float(np.mean(radii)), 2)}
browL_first_x = pts[RIGHT_EYEBROW_IDX[0]][0]
browB_first_x = pts[LEFT_EYEBROW_IDX[0]][0]
browSetL, browSetR = (
(RIGHT_EYEBROW_IDX, LEFT_EYEBROW_IDX)
if browL_first_x < browB_first_x
else (LEFT_EYEBROW_IDX, RIGHT_EYEBROW_IDX)
)
def brow_stats(idx_set: list[int], face_cx: float) -> dict:
xs = [pts[i][0] for i in idx_set]
ys = [pts[i][1] for i in idx_set]
peak_i = idx_set[int(np.argmin(ys))]
inner_i = min(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
outer_i = max(idx_set, key=lambda i: abs(pts[i][0] - face_cx))
return {
"inner": pt(inner_i),
"peak": pt(peak_i),
"outer": pt(outer_i),
}
face_cx = pts[NOSE_TIP_IDX][0]
mouthL_idx, mouthR_idx = screen_label(MOUTH_CORNER_A_IDX, MOUTH_CORNER_B_IDX)
faceEdgeL_idx, faceEdgeR_idx = screen_label(FACE_EDGE_A_IDX, FACE_EDGE_B_IDX)
landmarks = {
"coordSystem": "screen (image pixel: x=0 좌측, y=0 상단; left=작은 x, right=큰 x; 인물 해부학적 좌우 아님)",
"eyeLeft": {
"innerCorner": pt(eyeL["inner"]),
"outerCorner": pt(eyeL["outer"]),
"upperLidTop": pt(eyeL["top"]),
"lowerLidBottom": pt(eyeL["bottom"]),
"iris": iris_stats(irisL),
},
"eyeRight": {
"innerCorner": pt(eyeR["inner"]),
"outerCorner": pt(eyeR["outer"]),
"upperLidTop": pt(eyeR["top"]),
"lowerLidBottom": pt(eyeR["bottom"]),
"iris": iris_stats(irisR),
},
"eyebrowLeft": brow_stats(browSetL, face_cx),
"eyebrowRight": brow_stats(browSetR, face_cx),
"noseTip": pt(NOSE_TIP_IDX),
"mouthCornerLeft": pt(mouthL_idx),
"mouthCornerRight": pt(mouthR_idx),
"upperLipTopCenter": pt(UPPER_LIP_TOP_IDX),
"lowerLipBottomCenter": pt(LOWER_LIP_BOTTOM_IDX),
"chinTip": pt(CHIN_IDX),
"faceWidthAtEyeLevelLeft": pt(faceEdgeL_idx),
"faceWidthAtEyeLevelRight": pt(faceEdgeR_idx),
}
draw_preview(im, landmarks)
return landmarks
def draw_preview(im: Image.Image, landmarks: dict) -> None:
canvas = im.convert("RGB").copy()
draw = ImageDraw.Draw(canvas)
try:
font = ImageFont.truetype("arial.ttf", 13)
except Exception:
font = ImageFont.load_default()
GROUP_COLORS = {
"eyeLeft": (220, 0, 0),
"eyeRight": (0, 120, 220),
"eyebrowLeft": (180, 0, 180),
"eyebrowRight": (0, 150, 80),
"noseTip": (255, 140, 0),
"mouthCornerLeft": (200, 0, 100),
"mouthCornerRight": (0, 100, 200),
"upperLipTopCenter": (150, 100, 0),
"lowerLipBottomCenter": (0, 150, 150),
"chinTip": (100, 60, 0),
"faceWidthAtEyeLevelLeft": (120, 120, 120),
"faceWidthAtEyeLevelRight": (120, 120, 120),
}
def dot(xy: list[float], label: str, color=(255, 0, 0)) -> None:
x, y = xy
r = 5
draw.ellipse([x - r, y - r, x + r, y + r], outline=color, width=2)
draw.text((x + 7, y - 7), label, fill=color, font=font)
flat = []
for group, val in landmarks.items():
if group == "coordSystem":
continue
if isinstance(val, list):
flat.append((val, group))
elif isinstance(val, dict):
for k, v in val.items():
if isinstance(v, list):
flat.append((v, f"{group}.{k}"))
elif isinstance(v, dict) and "center" in v:
flat.append((v["center"], f"{group}.iris.center"))
for xy, label in flat:
group = label.split(".")[0]
dot(xy, label, GROUP_COLORS.get(group, (255, 0, 0)))
canvas.save(PREVIEW_PATH)
print(f"landmarks 미리보기 저장: {PREVIEW_PATH}")
def main() -> int:
manifest = {}
if MANIFEST_PATH.exists():
manifest = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
result = detect()
if result is None:
manifest["landmarks"] = None
manifest["landmarkDetector"] = None
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("[검출 실패] mediapipe FaceLandmarker가 얼굴을 찾지 못했다. manifest.landmarks=null로 기록.")
return 1
manifest["landmarks"] = result
manifest["landmarkDetector"] = f"mediapipe FaceLandmarker (tasks) {mp.__version__}, model=face_landmarker(float16, v1)"
MANIFEST_PATH.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print("manifest.json landmarks 섹션 기록 완료")
return 0
if __name__ == "__main__":
raise SystemExit(main())

File diff suppressed because it is too large Load diff