Files
hair/face_analysis/special_points.py
xsl 0b02ad1693 feat(接口99): 人脸特殊点检测 + 网关转发路由;文档同步至当前代码
- worker: POST /api/v1/face/special-points,60 个固定编号关键点(6组)
  + 5 个发际线特殊点(沿指定关键点方向射线步进,与 BiSeNet 头发 mask 首次相交;
  双射线取中点,left_left=mid(333→332, 298→284) 与右侧镜像)
- 网关: 同路径盲转发路由(调用频率低,维持转发不占网关依赖;
  响应无 *_base64 字段,改写逻辑天然跳过)
- 测试页 static/test_interface99.html:分组着色小点 + 点ID标注(可开关)
  + 发际线点大圆标签 + JSON 展示
- 文档: CLAUDE.md 重写至当前架构;实现说明/接口文档补接口 9~12/99 契约;
  网关待改动补 §7(9/10已加) §8(11/12待加) §9(接口99部署步骤)
2026-09-06 23:32:24 +08:00

167 lines
6.3 KiB
Python

"""接口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))