Files
hair/docs/接口1-四庭七眼测量-技术实现方案.md
T
xsl 9d2ff0b4b2 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超大图改测试时动态生成不入库)
2026-06-13 23:56:34 +08:00

833 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口 1:四庭七眼测量 — 技术实现方案
> 基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
---
## 1. 模型选型
### 1.1 调研结论
调研了以下人脸关键点检测模型:
| 模型 | 关键点数 | 覆盖范围 | Python 支持 | 备注 |
|------|----------|----------|-------------|------|
| **MediaPipe Face Mesh** | 468 / 478 | 额头中部 → 下巴(不含发际线以上) | ✅ `mediapipe` 包 | Google 官方,实时性能好 |
| dlib 68-point (300-W) | 68 | 眉毛 → 下巴 | ✅ `dlib` | 经典方法,无额头覆盖 |
| WFLW 98-point | 98 | 眉毛 → 下巴(额头仅 2 点) | ⚠️ 需额外模型 | 仍无头顶/发际线 |
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe |
**结论:没有任何「关键点检测模型」能直接给出「头顶」和「真实发际线」坐标**——所有关键点模型在额头以上方向都有盲区。
但「**人脸解析 / 头发分割模型**」可以直接把头发区域分割出来,从而得到**真实**的发际线与头顶位置(详见 §1.4 与 §4 方案 B)。因此本方案采用**双策略**:
- **眉心以下(中庭、下庭、七眼)**MediaPipe Face Mesh 468 点直接实测,精度高。
- **眉心以上(上庭、顶庭,即发际线与头顶)**:
- **方案 B(主)**:人脸解析分割(BiSeNet)提取真实发际线/头顶 —— 这两庭是**真实测量值**。
- **方案 A(兜底)**:当分割失败、光头、或被帽子/刘海遮挡时,退化为「人脸比例推算」。
> ⚠️ **重要**:旧版本仅用方案 A,存在「循环论证」缺陷 —— 用三庭标准比例反推发际线、再据此算占比,输出的顶庭/上庭占比几乎等于输入常数,不反映真实脸型。引入方案 B 后,顶上两庭才成为真正的测量结果。方案 A 仅作降级使用。
### 1.2 为什么用 468 点而非 478 点
478 点比 468 点多出 10 个虹膜(iris)关键点(索引 468–477),仅用于眼球追踪。四庭七眼测量不需要虹膜数据,468 点完全满足需求。使用经典 `mp.solutions.face_mesh` API,模型内置于 pip 包中,无需单独下载 `.task` 文件。
### 1.3 国内安装方式
```bash
# 使用清华镜像安装 mediapipe 及依赖
pip install mediapipe opencv-python pillow numpy -i https://pypi.tuna.tsinghua.edu.cn/simple/
```
经典 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. 关键点索引映射
MediaPipe Face Mesh 对 468 个点按固定拓扑编号,以下是四庭七眼测量所需的关键索引:
### 2.1 四庭纵向关键点
```
★ 头顶 (hair_top) ← 方案A推算,非MediaPipe直接检测
│ 顶庭 (~22%)
★ 发际线 (hairline) ← 方案A推算,非MediaPipe直接检测
│ 上庭 (~25%)
★ 眉心 (brow_center) ← 索引 9 或 151glabella,双眉间)
│ 中庭 (~28%)
★ 鼻翼下缘 (nose_bottom) ← 索引 94subnasale / 人中顶部)
│ 下庭 (~25%)
★ 下巴尖 (chin_tip) ← 索引 152menton
```
| 测量点 | MediaPipe 索引 | 说明 |
|--------|----------------|------|
| 头顶 | **无直接索引** | 由发际线 + 顶庭比例向上推算 |
| 发际线 | **无直接索引** | 由眉心 + 上庭比例向上推算 |
| 眉心 | **9****151** | glabella,双眉间中心点;两个点取中点 |
| 鼻翼下缘 | **94** | subnasale,鼻小柱底部与人中交界处 |
| 下巴尖 | **152** | menton,下颌最低点 |
### 2.2 七眼横向关键点
```
左脸 左眼外角 左眼内角 右眼内角 右眼外角 右脸
│ │ │ │ │ │
234 ←────── 33 ───── 133 ──── 两眼间距 ──── 362 ───── 263 ──────→ 454
│ │← 眼宽 →│ ← 两眼间距 → │← 眼宽 →│ │
│←──────────────── 脸宽 ──────────────────────────────→│
```
| 测量项目 | 左端索引 | 右端索引 | 说明 |
|----------|----------|----------|------|
| 左眼宽度 | 33(外眼角) | 133(内眼角) | 水平距离 |
| 右眼宽度 | 263(外眼角) | 362(内眼角) | 水平距离 |
| 两眼间距 | 133(左内眼角) | 362(右内眼角) | 内眦间距 |
| 脸宽 | 234(左颧弓) | 454(右颧弓) | 面部最宽处水平距离 |
> 注:脸宽使用 face oval 轮廓上颧弓高度对应的点。索引 234(左)和 454(右)位于 cheekbone 高度,是 face oval 路径 `...→234→127→162→21→...` 和 `...→454→356→389→251→...` 上的点。
### 2.3 参考索引速查表
| 索引 | 解剖位置 | 所属区域 |
|------|----------|----------|
| 4 | 鼻尖 (nose tip) | 鼻子 |
| 9, 151 | 眉间 / glabella | 眉心 |
| 10 | 额头顶端 (forehead top) — 不是发际线 | 额头 |
| 33 | 左眼外眼角 | 左眼 |
| 94 | 鼻翼下缘 / subnasale | 鼻子底部 |
| 133 | 左眼内眼角 | 左眼 |
| 152 | 下巴尖 / menton | 下巴 |
| 234 | 左脸颧弓处 | 面部轮廓 |
| 263 | 右眼外眼角 | 右眼 |
| 362 | 右眼内眼角 | 右眼 |
| 454 | 右脸颧弓处 | 面部轮廓 |
**Face Oval 连通路径**(面部轮廓线,用于验证脸宽点选择):
```
10→338→297→332→284→251→389→356→454→323→361→288→397→365→379→378→400→377→152→148→176→149→150→136→172→58→132→93→234→127→162→21→54→103→67→109→(回到10)
```
---
## 3. 厘米换算方案
### 3.1 转换原理
MediaPipe 输出的关键点坐标是 **归一化像素坐标**
- `x ∈ [0, 1]`,归一化于图像宽度
- `y ∈ [0, 1]`,归一化于图像高度
- `z` 为相对深度(以头部中心为零点,向镜头方向为负)
需要将归一化坐标转为像素坐标,再通过**尺度参照物**转为厘米。
### 3.2 像素坐标恢复
```python
def normalized_to_pixel(landmark, image_width, image_height):
"""归一化坐标 → 像素坐标"""
x_px = landmark.x * image_width
y_px = landmark.y * image_height
return x_px, y_px
def pixel_distance(p1, p2):
"""两点像素距离"""
return ((p1[0] - p2[0])**2 + (p1[1] - p2[1])**2) ** 0.5
```
### 3.3 尺度校准:虹膜直径法
**原理**:人类虹膜直径高度稳定,成人平均 **11.7 mm**(标准差 ≈ 0.5 mm,约 4%),可作为天然标尺。
```python
AVG_IRIS_DIAMETER_CM = 1.17 # 11.7 mm
# MediaPipe 虹膜关键点(开启 refine_landmarks=True 后可用)
IRIS_LEFT_CENTER = 468 # 左眼虹膜中心
IRIS_RIGHT_CENTER = 473 # 右眼虹膜中心
# 虹膜边界点(取上/下或左/右两个边缘点计算直径)
IRIS_LEFT_LEFT = 469 # 左虹膜左边缘
IRIS_LEFT_RIGHT = 471 # 左虹膜右边缘
IRIS_RIGHT_LEFT = 474 # 右虹膜左边缘
IRIS_RIGHT_RIGHT = 476 # 右虹膜右边缘
def estimate_scale_factor(landmarks, image_width, image_height):
"""通过虹膜直径估算 px → cm 缩放因子
Returns:
px_per_cm: 每厘米对应多少像素
"""
# 左眼虹膜像素直径
iris_left_l = normalized_to_pixel(landmarks[IRIS_LEFT_LEFT], image_width, image_height)
iris_left_r = normalized_to_pixel(landmarks[IRIS_LEFT_RIGHT], image_width, image_height)
iris_left_diameter_px = pixel_distance(iris_left_l, iris_left_r)
# 右眼虹膜像素直径
iris_right_l = normalized_to_pixel(landmarks[IRIS_RIGHT_LEFT], image_width, image_height)
iris_right_r = normalized_to_pixel(landmarks[IRIS_RIGHT_RIGHT], image_width, image_height)
iris_right_diameter_px = pixel_distance(iris_right_l, iris_right_r)
# 取平均,减少误差
avg_iris_diameter_px = (iris_left_diameter_px + iris_right_diameter_px) / 2
px_per_cm = avg_iris_diameter_px / AVG_IRIS_DIAMETER_CM
return px_per_cm
```
> **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
### 3.4 备用校准:人脸比例法
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:
```python
# 基于三庭五眼理想比例
# 脸宽 (234→454 px) ≈ 5 眼宽 ≈ 5 × (脸宽的 1/5)
# 已知脸宽距离的像素值,参考人脸统计平均脸宽 ~14 cm (女性) ~15 cm (男性)
# 得 px_per_cm = face_width_px / 14.5 (粗略)
```
此方法误差较大(±15%),建议优先使用虹膜法。对测量误差要求不严格的场景可接受。
---
## 4. 头顶 & 发际线定位(方案 B 主 / 方案 A 兜底)
> **决策流程**:先跑方案 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 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。
### 4.2 比例参数
根据需求文档中的 Mock 数据反推(顶庭:上庭:中庭:下庭 = 22%:25%:28%:25%),以及经典三庭五眼理论(三庭等分),定义两套可选参数:
```
方案比例(基于Mock数据):
顶庭 : 上庭 : 中庭 : 下庭 = 0.22 : 0.25 : 0.28 : 0.25
经典三庭比例(上庭=中庭=下庭):
上庭 : 中庭 : 下庭 = 1 : 1 : 1
顶庭 ≈ 0.2 × 全脸高度(通过统计)
```
实际采用混合策略:**以实测中庭和下庭为基准,按标准比例推算上庭和顶庭**。
### 4.3 推算公式
```python
def estimate_vertical_landmarks(landmarks, image_width, image_height):
"""
输入: MediaPipe 468 landmarks + 图像尺寸
输出: 5 个关键点像素坐标 + 各段像素距离
"""
# --- 1. 提取可直接检测的关键点 ---
# 眉心 (glabella):索引 9 和 151 的中点
glabella_9 = normalized_to_pixel(landmarks[9], image_width, image_height)
glabella_151 = normalized_to_pixel(landmarks[151], image_width, image_height)
brow_center_y = (glabella_9[1] + glabella_151[1]) / 2
brow_center_x = (glabella_9[0] + glabella_151[0]) / 2
# 鼻翼下缘 (subnasale):索引 94
nose_bottom = normalized_to_pixel(landmarks[94], image_width, image_height)
# 下巴尖 (menton):索引 152
chin_tip = normalized_to_pixel(landmarks[152], image_width, image_height)
# --- 2. 计算实测段长度 (像素) ---
middle_court_px = abs(brow_center_y - nose_bottom[1]) # 眉心 → 鼻翼下缘
lower_court_px = abs(nose_bottom[1] - chin_tip[1]) # 鼻翼下缘 → 下巴尖
# --- 3. 推算上庭和顶庭 ---
# 以中庭和下庭的平均值作为基准"一等份"(减小个体差异)
one_unit_px = (middle_court_px + lower_court_px) / 2 # 一等份 ≈ 中庭/下庭的平均
# 上庭 ≈ 一等份(经典三庭等分)或根据实际中庭比例微调
upper_court_px = one_unit_px * (0.25 / 0.265) # 上庭 25% vs 中庭/下庭平均 26.5%
# 顶庭 ≈ 中庭 × (22%/28%) 或 ≈ 0.79 × one_unit_px
top_court_px = one_unit_px * (0.22 / 0.28) # 约 0.786 × one_unit_px
# --- 4. 推算头顶和发际线 Y 坐标 ---
hairline_y = brow_center_y - upper_court_px
hair_top_y = hairline_y - top_court_px
# --- 5. 计算全脸总高度 ---
face_total_height_px = hair_top_y - chin_tip[1] # 注意 Y 轴方向(向下为正)
return {
"hair_top": (brow_center_x, hair_top_y),
"hairline": (brow_center_x, hairline_y),
"brow_center": (brow_center_x, brow_center_y),
"nose_bottom": (nose_bottom[0], nose_bottom[1]),
"chin_tip": (chin_tip[0], chin_tip[1]),
# 各段像素高度
"top_court_px": top_court_px,
"upper_court_px": upper_court_px,
"middle_court_px": middle_court_px,
"lower_court_px": lower_court_px,
"face_total_height_px": face_total_height_px,
}
```
### 4.4 像素 → 厘米转换
```python
def pixels_to_cm(vertical_result, px_per_cm):
"""将像素距离转为厘米"""
return {
"top_court_cm": vertical_result["top_court_px"] / px_per_cm,
"upper_court_cm": vertical_result["upper_court_px"] / px_per_cm,
"middle_court_cm": vertical_result["middle_court_px"] / px_per_cm,
"lower_court_cm": vertical_result["lower_court_px"] / px_per_cm,
"face_total_height_cm": vertical_result["face_total_height_px"] / px_per_cm,
}
```
---
## 5. 七眼测量实现
七眼测量全部基于可直接检测的关键点(无需推算),精度较好。
```python
def measure_seven_eyes(landmarks, image_width, image_height):
"""
测量眼宽、脸宽、两眼间距(像素)
返回像素值,后续通过 px_per_cm 转为厘米
"""
# 左眼外/内角
left_outer = normalized_to_pixel(landmarks[33], image_width, image_height)
left_inner = normalized_to_pixel(landmarks[133], image_width, image_height)
# 右眼内/外角
right_inner = normalized_to_pixel(landmarks[362], image_width, image_height)
right_outer = normalized_to_pixel(landmarks[263], image_width, image_height)
# 脸宽
left_cheek = normalized_to_pixel(landmarks[234], image_width, image_height)
right_cheek = normalized_to_pixel(landmarks[454], image_width, image_height)
eye_width_px = pixel_distance(left_outer, left_inner) # 左眼宽(也可用右眼或平均)
right_eye_width_px = pixel_distance(right_inner, right_outer)
avg_eye_width_px = (eye_width_px + right_eye_width_px) / 2
inter_eye_px = pixel_distance(left_inner, right_inner) # 两眼间距
face_width_px = pixel_distance(left_cheek, right_cheek) # 脸宽
return {
"eye_width_px": avg_eye_width_px,
"face_width_px": face_width_px,
"inter_eye_distance_px": inter_eye_px,
}
```
### 占比计算
```python
# 七眼比例(眼宽/脸宽,间距/脸宽)
eye_width_ratio = eye_width_px / face_width_px
inter_eye_ratio = inter_eye_px / face_width_px
# 四庭比例(各段 / 全脸总高)
for court in ["top", "upper", "middle", "lower"]:
ratios[f"{court}_court"] = result[f"{court}_court_px"] / face_total_height_px
```
---
## 6. 标注图片生成
需求要求输出**仅包含标注图层、不含人物**的 PNG 图片,规格如下:
| 项目 | 要求 |
|------|------|
| 字体色 / 线色 | `#FFFFFF` 100% |
| 字体 | PingFangSC-Regular 10pt |
| 线宽 | 1pt |
| 四庭数值位置 | 图片**左侧** |
| 七眼间距数值 | **上下穿插**展示 |
| 横线/竖线 | 渐变消失 |
| 虚线 | 两侧带箭头 |
### 实现方案
使用 **Pillow (PIL)**`ImageDraw` 生成透明底 PNG,画布尺寸与输入原图一致。
```python
from PIL import Image, ImageDraw, ImageFont
import math
def create_annotated_image(input_image_path, vertical_result, eye_result, px_per_cm):
"""生成标注图层 PNG(透明底,仅标注)"""
# 读取原图获取尺寸
original = Image.open(input_image_path)
width, height = original.size
# 创建透明画布 (RGBA, A=0)
canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# ⚠️ 字体: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
# --- 1. 绘制四庭水平分界线(渐变消失效果) ---
courts = [
("hair_top", vertical_result["hair_top"]),
("hairline", vertical_result["hairline"]),
("brow_center", vertical_result["brow_center"]),
("nose_bottom", vertical_result["nose_bottom"]),
("chin_tip", vertical_result["chin_tip"]),
]
for name, (cx, cy) in courts:
# 绘制从中心向两侧渐变的水平线
draw_gradient_horizontal_line(draw, cx, cy, width, line_color, line_width)
# --- 2. 绘制四庭数值(左侧标注) ---
court_values = [
("顶庭", vertical_result["top_court_px"] / px_per_cm),
("上庭", vertical_result["upper_court_px"] / px_per_cm),
("中庭", vertical_result["middle_court_px"] / px_per_cm),
("下庭", vertical_result["lower_court_px"] / px_per_cm),
]
left_margin = 20
for i, (label, cm_val) in enumerate(court_values):
# 标注在对应段落中间高度
y_start = courts[i][1][1]
y_end = courts[i+1][1][1]
y_mid = (y_start + y_end) / 2
text = f"{label} {cm_val:.2f}cm"
draw.text((left_margin, y_mid), text, fill=line_color, font=font)
# --- 3. 绘制七眼标注(上下穿插) ---
# 眼宽标注在上方,间距标注在下方
# (具体位置根据实际坐标布局)
# ... (详细绘制逻辑见完整实现)
# --- 4. 绘制虚线箭头 ---
# 在分界点位置绘制水平虚线,两端带箭头
return canvas
```
### 渐变线实现
> ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
```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
def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_len=4):
"""两点间画虚线,两端带箭头"""
total_len = ((x2 - x1)**2 + (y2 - y1)**2) ** 0.5
if total_len == 0:
return
dx = (x2 - x1) / total_len
dy = (y2 - y1) / total_len
# 画虚线
pos = 0
while pos < total_len:
seg_end = min(pos + dash_len, total_len)
draw.line([
(x1 + dx * pos, y1 + dy * pos),
(x1 + dx * seg_end, y1 + dy * seg_end)
], fill=color, width=1)
pos += dash_len + gap_len
# 两端箭头 (等腰三角形)
arrow_size = 6
# 左端箭头...
# 右端箭头...
```
> 标注图片的具体视觉样式建议在实现后根据实际效果微调,特别是虚线箭头的方向和位置。
---
## 7. 整体处理流程
```
输入图片
┌─────────────────────────────────────┐
│ 1. 预处理 │
│ - 校验格式 (JPG/PNG) │
│ - 校验分辨率 (短边≥600 长边≥800, 可配置)
│ - 校验文件大小 (≤ 1MB) │
│ - 校验人脸数量 (仅单人) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 2. MediaPipe 推理 │
│ - FaceMesh(static_image_mode=True,
│ max_num_faces=1,
│ refine_landmarks=True) │
│ - 输出: 468+10 关键点 │
│ - 无人脸 → 1001 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 3. 姿态校验 (solvePnP) │
│ - 解算 yaw/pitch/roll │
│ - 超阈值 → 1003 (非正面照) │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 4. 关键点提取 + 发际线/头顶定位 │
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
│ 记录 hairline_source │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 5. 尺度校准 │
│ - 虹膜直径法: px_per_cm 估算 │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 6. 计算与生成 │
│ - 像素 → 厘米 │
│ - 计算占比 │
│ - 生成标注图层 PNG │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 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) │
└─────────────────────────────────────┘
```
---
## 8. 关键代码骨架
### 8.1 目录结构建议
```
hair/
├── app.py # 现有 FastAPI 应用
├── face_analysis/
│ ├── __init__.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
```
### 8.2 MediaPipe 封装 (`detector.py`)
```python
import mediapipe as mp
import cv2
import numpy as np
mp_face_mesh = mp.solutions.face_mesh
class FaceMeshDetector:
"""MediaPipe Face Mesh 封装,单例模式"""
def __init__(self):
self.face_mesh = mp_face_mesh.FaceMesh(
static_image_mode=True,
max_num_faces=1, # 仅检测单人
refine_landmarks=True, # 启用虹膜 + 唇部精细关键点
min_detection_confidence=0.5,
)
def detect(self, image: np.ndarray) -> list | None:
"""
检测人脸关键点
Args:
image: BGR numpy array (OpenCV 格式)
Returns:
landmarks: NormalizedLandmarkList,或 None
"""
rgb = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
results = self.face_mesh.process(rgb)
if results.multi_face_landmarks:
return results.multi_face_landmarks[0] # 第一个人脸
return None
def close(self):
self.face_mesh.close()
# 全局单例
detector = FaceMeshDetector()
```
### 8.3 FastAPI 集成
```python
# 在 app.py 中集成
from face_analysis.measure import measure_face
from face_analysis.annotation import create_annotated_image
import cv2
import numpy as np
from io import BytesIO
@app.post("/api/v1/face/measure")
async def face_measure(image_file: UploadFile = File(...)):
# 1. 读取图片
contents = await image_file.read()
# 2. 校验
if len(contents) > 1_000_000:
return err(1006, "文件超出 1 MB 限制")
nparr = np.frombuffer(contents, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
if image is None:
return err(1008, "图片格式不支持")
h, w = image.shape[:2]
# ⚠️ 竖拍人像通常 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. 人脸检测
landmarks = detector.detect(image)
if landmarks is None:
return err(1001, "无法识别人像")
# 4. 测量计算
result = measure_face(landmarks, w, h)
# 5. 生成标注图
annotated = create_annotated_image(image, result)
buf = BytesIO()
annotated.save(buf, format="PNG")
# ... 保存并返回 URL
return ok(result.to_response())
```
---
## 9. 误差分析与局限
| 误差来源 | 影响范围 | 估算误差 | 缓解措施 |
|----------|----------|----------|----------|
| 头顶/发际线推算 | 顶庭、上庭 cm 值 | ±15% | 基于实测中庭下庭比例自适应 |
| 虹膜直径个体差异 | 所有 cm 值 | ±5% | 左右眼平均;未来可接性别/年龄修正 |
| 非正面照 | 所有横向测量 | ±20% | 前置校验偏航角(yaw),过大则返回 1003 |
| 相机畸变 | 边缘区域坐标 | ±3% | 假设普通手机拍照,畸变可控 |
| 人脸比例个体差异 | 推算的发际线/头顶 | ±10% | 无完美解决方案,方案 A 的自然局限 |
**前置姿态校验**(检测是否为正面照):
> 旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。**改用 `cv2.solvePnP` 解算真实头部欧拉角(yaw/pitch/roll,单位:度)**,阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。
```python
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. 依赖与版本
```
# requirements.txt 新增
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
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,后续可平滑升级。
---
## 11. 待确认事项
1. **标注图片设计稿**:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
2. **男女比例差异**:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
3. **顶庭占比**:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
4. **非正面照角度阈值**:具体多少度算「角度过大」?建议前期收集测试数据后定阈值
---
> **文档版本**: v2.0
> **创建日期**: 2026-06-13v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
> **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)