vignette/docs/avatar-art/p1-linocut/scripts/detect_landmarks.py
Yun Chan 00169533d8 P1 서연 리노컷 아트 원본과 자산 파이프라인
- 아트 디렉션 v3 스타일 프레임·프롬프트(소유자 선택: 리노컷)
- P1 정면 원화·얼굴 없는 기본형, 분할 레이어, 원화 픽셀 입술·턱 조각, 모티프 스프라이트, 소유자 기준 이미지
- 파이프라인 스크립트(분할·얼굴 음영·눈썹 중심선·입술 결·턱 조각·게시)와 manifest 검사 수치
- MediaPipe 모델과 재생성 가능한 진단 PNG는 무시하고 README에 받는 곳을 적었다
2026-10-01 09:58:15 +09:00

221 lines
7.8 KiB
Python

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