- register_edit(편집본 ECC 정렬·챔퍼 지표), normalize_base(P1 머리 크기 정규화, 위쪽 종이색 채움) - faceDetail region=face(얼굴 해칭을 원화 픽셀로 보존)·matchFacelessColor - brow_sprite 단계: 원화 눈썹 잉크 스프라이트(hairFront 아래 제외), 윗눈꺼풀 두께·홍채 실측 - 리그 계약 RigBrow.halfWidth/sprite·RigEye.lineScale, 렌더러 눈썹 16띠·바깥쪽 눈꺼풀 굵기 - 실행기 UTF-8 하위 실행, brow_centerline 패딩본 자체 생성, QA 시트 촬영 도구(깜빡임 회피·고해상도 대조) - P1은 새 필드를 모두 끄고 게시 자산·리그 바이트 동일
406 lines
19 KiB
Python
406 lines
19 KiB
Python
"""공통 리노컷 리그 — 눈썹 랜드마크를 잉크 띠 중심선으로 정정.
|
|
|
|
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% 지점의 곡선
|
|
위 점을 쓴다.
|
|
|
|
전제: landmarks.py가 이미 manifest.landmarks를 채워 두어야 한다.
|
|
|
|
실행: <venv>/python.exe brow_centerline.py <persona-dir>
|
|
"""
|
|
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
|
|
sys.path.insert(0, str(SCRIPTS_DIR))
|
|
from persona_config import load_persona_config # noqa: E402
|
|
from segmentation import build_padded_faceless # noqa: E402 — 새 페르소나 첫 실행에서도 segmentation 단계 전에 패딩본을 만든다
|
|
|
|
# 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 = 마스크 열 범위 양끝에서 이 비율만큼 안쪽
|
|
|
|
HALFWIDTH_WINDOW_PX = 6.0 # 반두께를 잴 때 각 점 주변 이 폭(열, px) 안의 열을 모아 중앙값을 낸다
|
|
|
|
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 measure_half_width(cw: dict, fit: np.poly1d, x: float, window_px: float = HALFWIDTH_WINDOW_PX) -> float:
|
|
"""x 주변 window_px 안 열들의 band 세로 폭(col_yhi-col_ylo) 중앙값을, 그 지점의
|
|
중심선 접선 기울기로 중심선 법선 방향 반두께로 환산한다(세로로 자른 폭은 띠가
|
|
기울어진 만큼 실제보다 넓게 잡힌다 — cos(기울기각)을 곱해 보정)."""
|
|
xs_abs = cw["xs_local"] + cw["x0"]
|
|
in_window = (xs_abs >= x - window_px) & (xs_abs <= x + window_px)
|
|
ylo = cw["col_ylo"][in_window]
|
|
yhi = cw["col_yhi"][in_window]
|
|
valid = ~np.isnan(ylo) & ~np.isnan(yhi)
|
|
if not valid.any():
|
|
return float("nan")
|
|
vertical_extent = float(np.median(yhi[valid] - ylo[valid]))
|
|
slope = float(fit.deriv()(x))
|
|
theta = np.arctan(slope)
|
|
return 0.5 * vertical_extent * float(np.cos(theta))
|
|
|
|
|
|
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
|
|
|
|
half_width_inner = measure_half_width(cw, fit, float(inner_x))
|
|
half_width_peak = measure_half_width(cw, fit, float(peak_pt_raw[0]))
|
|
half_width_outer = measure_half_width(cw, fit, float(outer_x))
|
|
|
|
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,
|
|
"halfWidthInner": round(half_width_inner, 3),
|
|
"halfWidthPeak": round(half_width_peak, 3),
|
|
"halfWidthOuter": round(half_width_outer, 3),
|
|
"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(persona_dir: Path) -> int:
|
|
cfg = load_persona_config(persona_dir)
|
|
base_dir = cfg.base_dir
|
|
preview_v2_dir = cfg.preview_v2_dir
|
|
manifest_path = cfg.manifest_path
|
|
|
|
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(build_padded_faceless(base_dir).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'}")
|
|
print(f" 반두께(inner/peak/outer) = {result['halfWidthInner']}/{result['halfWidthPeak']}/{result['halfWidthOuter']}")
|
|
|
|
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)},
|
|
"halfWidthLeft": [resultL["halfWidthInner"], resultL["halfWidthPeak"], resultL["halfWidthOuter"]],
|
|
"halfWidthRight": [resultR["halfWidthInner"], resultR["halfWidthPeak"], resultR["halfWidthOuter"]],
|
|
"halfWidthNote": "원화 눈썹 잉크 띠 반두께[inner,peak,outer](px, 중심선 법선 방향). export_rig.py가 "
|
|
"persona.json browHalfWidthFromArt!=false(기본)면 RigBrow.halfWidth로 내보낸다.",
|
|
"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(Path(sys.argv[1])))
|