"""接口99:人脸特殊点检测。 返回 MediaPipe Face Mesh 关键点中一组固定编号的像素坐标(英文组名_点ID 为字段名), 外加 5 个发际线特殊点:从指定关键点沿指定方向射线步进,与 BiSeNet 头发分割 mask 首次相交处即发际线点(参考接口1 方案B 的发际线定位方式,见 hair_segmenter.py)。 组与编号(需求方给定): - eyebrow 107 66 105 63 70 336 296 334 293 300 - eye 189 222 224 113 226 413 442 444 342 446 - mouth 0 37 40 67 91 84 17 405 375 409 269 - nose_midline 9 168 197 5 4 194 278 48 437 217 - cheek 101 117 123 187 205 50 330 346 352 280 425 - face_edge 162 234 58 136 149 152 378 365 288 401 366 389 251 发际线点(字段名需求方给定;A/B 两射线时取中点,仅一条命中则返回该点,均未命中为 null): - hair_line_center 151 → 10 方向与发际线交点 - hair_line_right mid(108→109 交点A, 69→67 交点B) - hair_line_right_right mid(104→103 交点A, 68→54 交点B) - hair_line_left mid(337→338 交点A, 299→297 交点B) - hair_line_left_left mid(333→332 交点A, 298→284 交点B) # 与 right_right 镜像对称 """ import math import numpy as np # 固定关键点组:组英文名 → MediaPipe 点 ID 列表(顺序即需求方给定顺序) GROUPS = { "eyebrow": [107, 66, 105, 63, 70, 336, 296, 334, 293, 300], "eye": [189, 222, 224, 113, 226, 413, 442, 444, 342, 446], "mouth": [0, 37, 40, 67, 91, 84, 17, 405, 375, 409, 269], "nose_midline": [9, 168, 197, 5, 4, 194, 278, 48, 437, 217], "cheek": [101, 117, 123, 187, 205, 50, 330, 346, 352, 280, 425], "face_edge": [162, 234, 58, 136, 149, 152, 378, 365, 288, 401, 366, 389, 251], } # 发际线点定义:字段名 → 参与计算的 (起点ID, 方向点ID) 射线组;单射线直接取交点, # 双射线取两交点中点。 HAIRLINE_RAYS = { "hair_line_center": [(151, 10)], "hair_line_right": [(108, 109), (69, 67)], "hair_line_right_right": [(104, 103), (68, 54)], "hair_line_left": [(337, 338), (299, 297)], "hair_line_left_left": [(333, 332), (298, 284)], } class NoFaceError(Exception): """未检出人脸。""" class NonFrontalError(Exception): """非正面照(复用接口1 姿态阈值)。""" def ray_hairline_intersection(p1, p2, hair_mask): """从 p1 沿 p1→p2 方向逐像素步进,返回首次进入头发 mask 的整数像素坐标。 p1/p2: (x, y) 像素坐标(float)。起点本身已在头发内时直接返回起点(已到发际线)。 步进直到出图或步数上限(3×图对角线,防极端死循环);未命中返回 None。 """ mask = np.asarray(hair_mask).astype(bool) if mask.size == 0: return None h, w = mask.shape x0, y0 = float(p1[0]), float(p1[1]) dx, dy = float(p2[0]) - x0, float(p2[1]) - y0 length = math.hypot(dx, dy) if length < 1e-6: return None ux, uy = dx / length, dy / length max_steps = int(3 * math.hypot(h, w)) def _hit(x, y): xi, yi = int(round(x)), int(round(y)) if 0 <= xi < w and 0 <= yi < h: return mask[yi, xi] return False x, y = x0, y0 for _ in range(max_steps): if _hit(x, y): return int(round(x)), int(round(y)) x += ux y += uy if not (0 <= int(round(x)) < w and 0 <= int(round(y)) < h): return None # 已步出图像,方向上无头发 return None def detect_special_points(image_bgr): """主入口:图片 → {字段名: {"x","y"} 或 None} + 元信息。 Returns: {"points": {...}, "image_size": {"width","height"}, "hairline_source": "segmentation"|"unavailable"} 无人脸抛 NoFaceError;非正面抛 NonFrontalError。 """ from face_analysis.detector import detector from face_analysis.calibration import normalized_to_pixel from face_analysis.pose import check_frontal_face h, w = image_bgr.shape[:2] landmarks = detector.detect(image_bgr) if landmarks is None: raise NoFaceError("未检出人脸") if not check_frontal_face(landmarks, w, h): raise NonFrontalError("非正面照") lm = landmarks.landmark px = {i: normalized_to_pixel(lm[i], w, h) for i in range(len(lm))} points = {} for group, ids in GROUPS.items(): for idx in ids: x, y = px[idx] points[f"{group}_{idx}"] = {"x": int(round(x)), "y": int(round(y))} # 头发分割(与接口1 相同:按人脸框裁剪后再分割,脸外背景不干扰) hair_mask = None try: from face_analysis.hair_segmenter import get_segmenter face_box = (min(p[0] for p in px.values()), min(p[1] for p in px.values()), max(p[0] for p in px.values()), max(p[1] for p in px.values())) hair_mask, _ = get_segmenter().segment_hair_and_ears( image_bgr, face_box=face_box) except Exception: # noqa: BLE001 分割不可用 → 发际线点置 null,不阻塞关键点 hair_mask = None for name, rays in HAIRLINE_RAYS.items(): hits = [] if hair_mask is not None: for a, b in rays: hits.append(ray_hairline_intersection(px[a], px[b], hair_mask)) found = [p for p in hits if p is not None] if not found: points[name] = None elif len(found) == 1: points[name] = {"x": found[0][0], "y": found[0][1]} else: points[name] = { "x": int(round(sum(p[0] for p in found) / len(found))), "y": int(round(sum(p[1] for p in found) / len(found))), } return { "points": points, "image_size": {"width": w, "height": h}, "hairline_source": "segmentation" if hair_mask is not None else "unavailable", } if __name__ == "__main__": import sys import cv2 import json path = sys.argv[1] if len(sys.argv) > 1 else "tests/fixtures/frontal.jpg" img = cv2.imread(path) if img is None: print(f"无法读取图片: {path}") sys.exit(1) try: out = detect_special_points(img) except (NoFaceError, NonFrontalError) as e: print(f"检测失败: {e}") sys.exit(1) print(json.dumps(out, ensure_ascii=False, indent=2))