"""base-front.png에서 mediapipe FaceLandmarker로 얼굴 랜드마크를 검출해 manifest.json의 landmarks 섹션을 채우고 preview/landmarks.png를 만든다. 좌표계: 화면(이미지) 기준 left/right. "left"는 이미지의 왼쪽(작은 x), "right"는 이미지의 오른쪽(큰 x)이다. 인물 해부학적 좌/우가 아니다. 실행: /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())