docs: 接口1四庭七眼 技术方案v2.0 + 开发任务书 + 测试夹具

- 技术方案修订v2.0: 引入方案B(BiSeNet分割取真实发际线/头顶)解决方案A循环论证;
  修复分辨率写反/PingFang字体/numpy2.x冲突/渐变线性能;solvePnP替换正面判定;
  分辨率门槛放宽至短边600长边800且可配置
- 新增开发任务书(给AI agent执行): 10阶段串行步骤+交付物+验证方法+DoD;
  §14测试素材清单 §15三层精度验证策略(合成真值/缩放不变性/可视化)
- tests/fixtures: frontal/hard_longhair/lowres/landscape/corrupt 5个夹具
  (1006超大图改测试时动态生成不入库)
This commit is contained in:
xsl
2026-06-13 23:56:34 +08:00
parent 2c669fa7e5
commit 9d2ff0b4b2
10 changed files with 682 additions and 59 deletions
@@ -1,6 +1,6 @@
# 接口 1:四庭七眼测量 — 技术实现方案
> 基于 MediaPipe Face Mesh468 关键点)+ 人脸比例先验知识
> 基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
---
@@ -18,7 +18,16 @@
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe |
**结论:目前没有开源模型能直接检测「头顶」和「真实发际线」关键点。** 所有模型在向上(额头以上方向均存在覆盖盲区。因此采用 **MediaPipe Face Mesh (468 点) + 方案 A(人脸比例推算)**
**结论:没有任何「关键点检测模型能直接给出「头顶」和「真实发际线」坐标**——所有关键点模型在额头以上方向都有盲区
但「**人脸解析 / 头发分割模型**」可以直接把头发区域分割出来,从而得到**真实**的发际线与头顶位置(详见 §1.4 与 §4 方案 B)。因此本方案采用**双策略**:
- **眉心以下(中庭、下庭、七眼)**MediaPipe Face Mesh 468 点直接实测,精度高。
- **眉心以上(上庭、顶庭,即发际线与头顶)**:
- **方案 B(主)**:人脸解析分割(BiSeNet)提取真实发际线/头顶 —— 这两庭是**真实测量值**。
- **方案 A(兜底)**:当分割失败、光头、或被帽子/刘海遮挡时,退化为「人脸比例推算」。
> ⚠️ **重要**:旧版本仅用方案 A,存在「循环论证」缺陷 —— 用三庭标准比例反推发际线、再据此算占比,输出的顶庭/上庭占比几乎等于输入常数,不反映真实脸型。引入方案 B 后,顶上两庭才成为真正的测量结果。方案 A 仅作降级使用。
### 1.2 为什么用 468 点而非 478 点
@@ -33,6 +42,26 @@ pip install mediapipe opencv-python pillow numpy -i https://pypi.tuna.tsinghua.e
经典 Solutions API 模型文件已打包在 wheel 包内(路径:`mediapipe/modules/face_landmark/`),安装后直接可用,无需额外下载。
> ⚠️ **版本兼容性坑**MediaPipe 0.10.x 对 numpy 2.x 支持不稳定,常出现 import 崩溃。**必须锁定 `numpy<2`(推荐 1.26.x**,详见 §10。
### 1.4 发际线 / 头顶分割模型(方案 B 依赖)
关键点模型够不到的额头以上区域,用**人脸解析(face parsing)**模型补齐。这类模型对整张脸做像素级语义分割,类别中包含 `hair`(头发):
| 模型 | 训练集 | 类别数 | 体积 | Python 支持 | 备注 |
|------|--------|--------|------|-------------|------|
| **BiSeNet (face-parsing.PyTorch)** | CelebAMask-HQ | 19(含 hair/skin/眉眼鼻嘴等) | ~50 MB | ✅ PyTorch | 最常用,CPU 可跑(~0.31s/张) |
| MODNet | 人像 matting | 前景/背景 | ~25 MB | ✅ | 只分前景,不区分头发 |
| SegFormer-b0 face-parsing | CelebAMask-HQ | 19 | ~15 MB | ✅ HuggingFace | 更轻,需 transformers |
**选型:BiSeNetface-parsing.PyTorch**,社区成熟、权重易得、19 类直接含 `hair`
拿到分割 mask 后:
- **真实发际线** = 沿面部中轴线(用 §4 的 `brow_center_x` 作为 x),从上往下扫描,**头发区域 → 皮肤区域**的第一个交界 y 坐标。
- **头顶** = 头发 mask 的**最高点**(最小 y)。
> 权重需单独下载(`79999_iter.pth`~50 MB),放入 `face_analysis/weights/`,不入 git(写进 `.gitignore`),由部署脚本拉取。
---
## 2. 关键点索引映射
@@ -170,6 +199,8 @@ def estimate_scale_factor(landmarks, image_width, image_height):
> **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
### 3.4 备用校准:人脸比例法
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:
@@ -185,9 +216,49 @@ def estimate_scale_factor(landmarks, image_width, image_height):
---
## 4. 方案 A:人脸比例推算头顶 & 发际线
## 4. 头顶 & 发际线定位(方案 B 主 / 方案 A 兜底)
### 4.1 核心思路
> **决策流程**:先跑方案 B(分割)。若分割成功且发际线/头顶落在合理范围(发际线在眉心上方、头顶在发际线上方、各庭长度为正),用方案 B 结果;否则记录 `hairline_source = "estimated"` 并回退方案 A。方案 B 成功时 `hairline_source = "segmentation"`,需在返回 `data` 里透出该字段,方便前端/业务区分真实测量与估算。
### 4.0 方案 B(主):分割提取真实发际线 & 头顶
```python
def locate_hairline_by_segmentation(hair_mask, brow_center_x, image_height):
"""
输入: hair_mask (H×W bool/uint8, True=头发像素), 面部中轴线 x, 图高
输出: (hairline_y, hair_top_y) 像素坐标; 失败返回 None
"""
import numpy as np
if hair_mask is None or hair_mask.sum() == 0:
return None # 光头 / 分割失败 → 交给方案 A
cx = int(round(brow_center_x))
# 在中轴线附近取一个窄列带(±3px)求稳,避免单列噪声
band = hair_mask[:, max(0, cx - 3): cx + 4]
col = band.any(axis=1) # 每一行在该列带是否有头发
hair_rows = np.where(col)[0]
if hair_rows.size == 0:
return None
# 发际线 = 中轴线上「头发→皮肤」交界:即该列带头发像素中最靠下的连续头发块的下沿
# 简化:取中轴线列上头发区域的最大 y(向下为正)作为发际线
hairline_y = int(hair_rows.max())
# 头顶 = 整张头发 mask 的最高点(最小 y),更鲁棒地用全图而非单列
top_rows = np.where(hair_mask.any(axis=1))[0]
hair_top_y = int(top_rows.min())
# 合理性校验:头顶必须在发际线上方
if hair_top_y >= hairline_y:
return None
return hairline_y, hair_top_y
```
> 实际实现可对 mask 先做轻量形态学开运算去噪;发际线判定可改为"沿中轴线从上往下首次出现的 hair→non-hair 跳变",比单纯取 max 更贴合带刘海/碎发场景。具体阈值在拿到测试集后调。
### 4.1 方案 A(兜底)核心思路
> 仅当方案 B 不可用时启用。**注意其循环论证局限:顶上两庭为估算值,不反映真实脸型。**
MediaPipe 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。
@@ -359,11 +430,11 @@ def create_annotated_image(input_image_path, vertical_result, eye_result, px_per
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# 字体(尝试 PingFangSC,降级为系统默认中文字体)
try:
font = ImageFont.truetype("PingFangSC-Regular", 10)
except:
font = ImageFont.load_default() # 降级方案
# ⚠️ 字体:PingFangSC 是 macOS 字体,Linux 服务器没有;且 ImageFont.load_default()
# 不渲染中文(会出现方块/空白)。必须随仓库打包一个中文 TTF 并用绝对路径加载。
# 建议放 face_analysis/fonts/SourceHanSansSC-Regular.otf(思源黑体)或 Noto Sans CJK。
FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "SourceHanSansSC-Regular.otf")
font = ImageFont.truetype(FONT_PATH, 10) # 字体缺失时直接抛错,避免静默降级成乱码
line_color = (255, 255, 255, 255) # #FFFFFF 100%
line_width = 1 # 1pt
@@ -412,21 +483,34 @@ def create_annotated_image(input_image_path, vertical_result, eye_result, px_per
### 渐变线实现
```python
def draw_gradient_horizontal_line(draw, cx, cy, img_width, color, line_width):
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线"""
max_alpha = color[3] # 255
half_length = img_width // 3 # 渐变线长度
> ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
for side in [-1, 1]: # 左 (-1) / 右 (+1)
for i in range(half_length):
# 线性衰减 alpha
alpha = int(max_alpha * (1 - i / half_length))
x = cx + side * i
if 0 <= x < img_width:
draw.point((x, cy), fill=(color[0], color[1], color[2], alpha))
```python
import numpy as np
def draw_gradient_horizontal_line(canvas: Image.Image, cx, cy, color, half_length=None):
"""以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线(numpy 向量化)"""
arr = np.asarray(canvas) # RGBA, H×W×4
h, w = arr.shape[:2]
cy = int(round(cy)); cx = int(round(cx))
if not (0 <= cy < h):
return
half = half_length or (w // 3)
xs = np.arange(w)
dist = np.abs(xs - cx)
alpha = np.clip(1.0 - dist / half, 0.0, 1.0) * color[3] # 线性衰减,超出 half 为 0
mask = alpha > 0
row = arr[cy]
row[mask, 0], row[mask, 1], row[mask, 2] = color[0], color[1], color[2]
# 与已有 alpha 取较大值,避免覆盖其它线条
row[mask, 3] = np.maximum(row[mask, 3], alpha[mask].astype(np.uint8))
# 注意:需用可写数组(np.array(canvas) 复制),处理完用 Image.fromarray 写回画布
```
> 实现时建议全程在一个 `np.zeros((h, w, 4), uint8)` 缓冲区上画线,最后 `Image.fromarray` 一次性转回,再用 `ImageDraw` 画文字/箭头。
### 虚线带箭头
```python
@@ -468,7 +552,7 @@ def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_le
┌─────────────────────────────────────┐
│ 1. 预处理 │
│ - 校验格式 (JPG/PNG) │
│ - 校验分辨率 (1080×1920 ~ 4000×5000)
│ - 校验分辨率 (短边≥600 长边≥800, 可配置)
│ - 校验文件大小 (≤ 1MB) │
│ - 校验人脸数量 (仅单人) │
└──────────────┬──────────────────────┘
@@ -479,34 +563,45 @@ def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_le
│ max_num_faces=1,
│ refine_landmarks=True) │
│ - 输出: 468+10 关键点 │
│ - 无人脸 → 1001 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 3. 关键点提取
│ - 纵向: 头顶*/发际线*/眉心/鼻翼/下巴
│ - 横向: 眼宽/脸宽/两眼间距
│ * 推算值 │
│ 3. 姿态校验 (solvePnP)
│ - 解算 yaw/pitch/roll
│ - 超阈值 → 1003 (非正面照)
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 4. 尺度校准
│ 4. 关键点提取 + 发际线/头顶定位
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
│ 记录 hairline_source │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 5. 尺度校准 │
│ - 虹膜直径法: px_per_cm 估算 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
5. 计算与生成 │
6. 计算与生成 │
│ - 像素 → 厘米 │
│ - 计算占比 │
│ - 生成标注图层 PNG │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
6. 输出 │
7. 输出 │
│ - annotated_image_url (标注PNG) │
│ - face_total_height_cm │
│ - four_courts (含cm & ratios) │
│ - seven_eyes (含cm & ratios) │
│ - landmarks (5个点原图像素坐标) │
│ - hairline_source ("segmentation"│
│ / "estimated") │
│ - head_pose (yaw/pitch/roll) │
└─────────────────────────────────────┘
```
@@ -521,13 +616,20 @@ hair/
├── app.py # 现有 FastAPI 应用
├── face_analysis/
│ ├── __init__.py
│ ├── detector.py # MediaPipe 封装
│ ├── measure.py # 四庭七眼测量逻辑
│ ├── calibration.py # px→cm 尺度校准
│ ├── annotation.py # 标注图片生成
── face_mesh_landmarks.py # 关键点索引常量
│ ├── detector.py # MediaPipe Face Mesh 封装
│ ├── hair_segmenter.py # 方案 BBiSeNet 头发分割封装
│ ├── pose.py # solvePnP 头部姿态估计 + 正面校验
│ ├── measure.py # 四庭七眼测量逻辑(整合方案 B/A)
── calibration.py # px→cm 尺度校准(虹膜法)
│ ├── annotation.py # 标注图片生成(numpy 渐变线 + 中文字体)
│ ├── face_mesh_landmarks.py # 关键点索引常量
│ ├── fonts/
│ │ └── SourceHanSansSC-Regular.otf # 打包的中文字体(入 git)
│ └── weights/ # 模型权重(不入 git,部署脚本拉取)
│ └── 79999_iter.pth # BiSeNet face-parsing 权重 ~50MB
├── static/
│ └── annotations/ # 生成的标注 PNG 存放目录
├── .gitignore # 忽略 face_analysis/weights/*.pth
└── requirements.txt
```
@@ -598,7 +700,13 @@ async def face_measure(image_file: UploadFile = File(...)):
return err(1008, "图片格式不支持")
h, w = image.shape[:2]
if h < 1080 or w < 1920:
# ⚠️ 竖拍人像通常 w=1080, h=1920;不要把 w/h 写反导致竖图被全部拒绝。
# 用「短边/长边」判断,方向无关,竖拍横拍都兼容。
# 门槛可配置(环境变量),默认放宽到 600/800 以适配真实用户上传图。
min_short = int(os.getenv("MIN_SHORT_SIDE", "600"))
min_long = int(os.getenv("MIN_LONG_SIDE", "800"))
short_side, long_side = min(w, h), max(w, h)
if short_side < min_short or long_side < min_long:
return err(1002, "人像分辨率过低")
# 3. 人脸检测
@@ -632,26 +740,59 @@ async def face_measure(image_file: UploadFile = File(...)):
**前置姿态校验**(检测是否为正面照):
> 旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。**改用 `cv2.solvePnP` 解算真实头部欧拉角(yaw/pitch/roll,单位:度)**,阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。
```python
def check_frontal_face(landmarks):
"""简单正面照判定:基于左右眼关键点的 y 坐标对称性 + 鼻尖偏移"""
left_eye = landmarks[33]
right_eye = landmarks[263]
nose_tip = landmarks[4]
# 双眼高度差(偏航判定)
y_diff = abs(left_eye.y - right_eye.y)
# 鼻尖偏离双眼中点(偏航/滚转判定)
eye_center_x = (left_eye.x + right_eye.x) / 2
nose_offset = abs(nose_tip.x - eye_center_x)
# 阈值根据实际测试调整
if y_diff > 0.03 or nose_offset > 0.08:
return False # 非正面照
return True
import cv2
import numpy as np
# 通用 3D 头部模型(单位 mm,近似),与下方 MediaPipe 索引一一对应
_MODEL_POINTS = np.array([
(0.0, 0.0, 0.0), # 鼻尖 -> 1(或 4
(0.0, -63.6, -12.5), # 下巴 -> 152
(-43.3, 32.7, -26.0), # 左眼外角 -> 33
(43.3, 32.7, -26.0), # 右眼外角 -> 263
(-28.9, -28.9, -24.1), # 左嘴角 -> 61
(28.9, -28.9, -24.1), # 右嘴角 -> 291
], dtype=np.float64)
_PNP_IDX = [1, 152, 33, 263, 61, 291]
def estimate_head_pose(landmarks, image_width, image_height):
"""返回 (yaw, pitch, roll) 角度。solvePnP 失败返回 None。"""
image_points = np.array([
(landmarks[i].x * image_width, landmarks[i].y * image_height)
for i in _PNP_IDX
], dtype=np.float64)
focal = image_width # 近似焦距
cam_matrix = np.array([[focal, 0, image_width / 2],
[0, focal, image_height / 2],
[0, 0, 1]], dtype=np.float64)
dist = np.zeros((4, 1)) # 假设无畸变
ok, rvec, tvec = cv2.solvePnP(_MODEL_POINTS, image_points, cam_matrix, dist,
flags=cv2.SOLVEPNP_ITERATIVE)
if not ok:
return None
rot, _ = cv2.Rodrigues(rvec)
sy = (rot[0, 0] ** 2 + rot[1, 0] ** 2) ** 0.5
pitch = np.degrees(np.arctan2(-rot[2, 0], sy))
yaw = np.degrees(np.arctan2(rot[1, 0], rot[0, 0]))
roll = np.degrees(np.arctan2(rot[2, 1], rot[2, 2]))
return yaw, pitch, roll
def check_frontal_face(landmarks, image_width, image_height,
yaw_thr=15, pitch_thr=15, roll_thr=15):
"""正面照判定:yaw/pitch/roll 均在阈值内才算正面。阈值待测试集标定。"""
pose = estimate_head_pose(landmarks, image_width, image_height)
if pose is None:
return True # 解算失败时不拦截,交由后续逻辑
yaw, pitch, roll = pose
return abs(yaw) <= yaw_thr and abs(pitch) <= pitch_thr and abs(roll) <= roll_thr
```
> 上面 `_MODEL_POINTS` 是常用近似头模,索引/坐标可在测试阶段微调。阈值 15° 为初始值,按 §11 收集的测试数据标定。
---
## 10. 依赖与版本
@@ -659,11 +800,19 @@ def check_frontal_face(landmarks):
```
# requirements.txt 新增
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
opencv-python==4.10.0 # 图片读取处理
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
numpy==2.1.0
opencv-python==4.10.0 # 图片读取处理、solvePnP 姿态估计
Pillow==11.0.0 # 标注图生成(PNG 透明图层)
numpy==1.26.4 # ⚠️ 必须 <2,否则 mediapipe 0.10.x import 崩溃
# 方案 B:头发分割(BiSeNet face-parsing
torch==2.2.2 # CPU 版即可:pip install torch --index-url https://download.pytorch.org/whl/cpu
torchvision==0.17.2
```
> ⚠️ **numpy 锁版本**mediapipe 0.10.x 对 numpy 2.x 支持不稳定,务必锁 `numpy<2`(已验证 1.26.4 可用)。先用此组合跑通,再考虑升级。
>
> ⚠️ **torch 体积**CPU 版 torch ~200MB,是本接口最大的依赖。若服务器资源紧张或不想引入 torch,可改用 SegFormer-b0onnxruntime 推理,体积更小),或先只上线方案 A、把方案 B 作为第二期。
> MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
---
@@ -677,7 +826,7 @@ numpy==2.1.0
---
> **文档版本**: v1.0
> **创建日期**: 2026-06-13
> **依赖模型**: MediaPipe Face Mesh (468 landmarks)
> **测量策略**: 实测关键点 + 方案A(人脸比例推算未覆盖区域
> **文档版本**: v2.0
> **创建日期**: 2026-06-13(v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底