P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷) - P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지 - 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치 - MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
This commit is contained in:
parent
85bd079d18
commit
00169533d8
83 changed files with 7843 additions and 0 deletions
49
docs/avatar-art/p1-linocut/scripts/_run_face_landmarks.py
Normal file
49
docs/avatar-art/p1-linocut/scripts/_run_face_landmarks.py
Normal 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())
|
||||
45
docs/avatar-art/p1-linocut/scripts/_run_segmentation.py
Normal file
45
docs/avatar-art/p1-linocut/scripts/_run_segmentation.py
Normal 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())
|
||||
369
docs/avatar-art/p1-linocut/scripts/build_brow_centerline.py
Normal file
369
docs/avatar-art/p1-linocut/scripts/build_brow_centerline.py
Normal 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())
|
||||
740
docs/avatar-art/p1-linocut/scripts/build_face_detail.py
Normal file
740
docs/avatar-art/p1-linocut/scripts/build_face_detail.py
Normal 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())
|
||||
338
docs/avatar-art/p1-linocut/scripts/build_final_previews.py
Normal file
338
docs/avatar-art/p1-linocut/scripts/build_final_previews.py
Normal 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())
|
||||
176
docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Normal file
176
docs/avatar-art/p1-linocut/scripts/build_jaw_pieces.py
Normal 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())
|
||||
361
docs/avatar-art/p1-linocut/scripts/build_layers.py
Normal file
361
docs/avatar-art/p1-linocut/scripts/build_layers.py
Normal 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())
|
||||
411
docs/avatar-art/p1-linocut/scripts/build_layers_segmented.py
Normal file
411
docs/avatar-art/p1-linocut/scripts/build_layers_segmented.py
Normal 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())
|
||||
650
docs/avatar-art/p1-linocut/scripts/build_layers_v2.py
Normal file
650
docs/avatar-art/p1-linocut/scripts/build_layers_v2.py
Normal 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())
|
||||
452
docs/avatar-art/p1-linocut/scripts/build_lip_texture.py
Normal file
452
docs/avatar-art/p1-linocut/scripts/build_lip_texture.py
Normal 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())
|
||||
177
docs/avatar-art/p1-linocut/scripts/build_paper_grain.py
Normal file
177
docs/avatar-art/p1-linocut/scripts/build_paper_grain.py
Normal 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())
|
||||
221
docs/avatar-art/p1-linocut/scripts/detect_landmarks.py
Normal file
221
docs/avatar-art/p1-linocut/scripts/detect_landmarks.py
Normal 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())
|
||||
1090
docs/avatar-art/p1-linocut/scripts/export_rig.py
Normal file
1090
docs/avatar-art/p1-linocut/scripts/export_rig.py
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue