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部署步骤)
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
"""接口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))
|
||||
Reference in New Issue
Block a user