"""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% 지점의 곡선 위 점을 쓴다. 실행: /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())