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
@@ -0,0 +1,474 @@
# 接口 1:四庭七眼测量 — 开发任务书(AI Agent 执行版)
> 配套技术方案:[`接口1-四庭七眼测量-技术实现方案.md`](接口1-四庭七眼测量-技术实现方案.md)
> 执行者:AI coding agent。请**严格按阶段顺序**执行,每个阶段完成后运行该阶段的「验证方法」,**通过后再进入下一阶段**。
---
## 0. 背景与目标
把现有 `/api/v1/face/measure` 接口从 **Mock**(返回硬编码数据)替换为**真实算法实现**。
- 输入:单人正面人像图(multipart 上传 / URL / base64,三选一,≤1MB)。
- 输出:四庭(顶/上/中/下庭)、七眼(眼宽/脸宽/两眼间距)的 cm 值与占比,5 个关键点像素坐标,以及一张**仅含标注图层、透明底**的 PNG。
- 保持现有统一响应结构 `{code, message, request_id, data}` 与错误码 10011008 不变。
**核心算法策略**(见技术方案 §1.1):
- 眉心以下(中/下庭、七眼):MediaPipe Face Mesh 468 点直接实测。
- 眉心以上(上/顶庭,即发际线/头顶):**方案 B(BiSeNet 头发分割,主)** → **方案 A(比例推算,兜底)**
- 尺度换算:虹膜直径法(11.7mm)。
- 姿态校验:`cv2.solvePnP` 解算真实欧拉角。
---
## 1. 总体约束(所有阶段通用)
1. **不破坏现有接口契约**:响应外层结构、错误码、三选一图片输入规则、`ok()`/`err()` 帮助函数沿用 `app.py` 现有实现。
2. **新增逻辑全部放在 `face_analysis/` 包内**`app.py` 只做编排(读图→校验→调用→返回),保持单文件 app 的薄控制器风格。
3. **依赖锁版本**`numpy<2`(用 1.26.4)。torch 用 CPU 版。安装走 `pip.conf` 里的腾讯云镜像(torch 需用官方 CPU index)。
4. **模型权重不入 git**`face_analysis/weights/*.pth` 写进 `.gitignore`,由 §2 的下载脚本拉取。
5. **中文字体入 git**`face_analysis/fonts/SourceHanSansSC-Regular.otf`
6. **每个模块都要能单独 import 且有 `if __name__ == "__main__"` 自测入口**,方便分阶段验证。
7. 代码风格、注释密度与 `app.py` 保持一致;注释用中文。
8. **不要 mock 兜底**:算法失败时返回对应错误码,**不得**回退成硬编码示例数据。
---
## 2. 阶段一:环境与依赖
**开发步骤**
1. 更新 `requirements.txt`,新增:`mediapipe==0.10.14``opencv-python==4.10.0``Pillow==11.0.0``numpy==1.26.4``torch==2.2.2``torchvision==0.17.2`
2. 在 venv 安装依赖。torch 用 CPU index
`./venv/bin/pip install torch==2.2.2 torchvision==0.17.2 --index-url https://download.pytorch.org/whl/cpu`
其余走现有 `pip.conf` 镜像。
3. 创建目录骨架:`face_analysis/{__init__.py,fonts/,weights/}``static/annotations/`
4.`scripts/download_weights.sh`:下载 BiSeNet face-parsing 权重 `79999_iter.pth`(来源:face-parsing.PyTorch 仓库 release)到 `face_analysis/weights/`,下载思源黑体到 `face_analysis/fonts/`(若已手动放置则跳过)。
5. 更新 `.gitignore`:忽略 `face_analysis/weights/*.pth``static/annotations/*`(保留 `.gitkeep`)。
**交付物**
- 更新后的 `requirements.txt``.gitignore`
- `scripts/download_weights.sh`
- 目录骨架
**验证方法**
```bash
./venv/bin/python -c "import mediapipe, cv2, torch, numpy, PIL; \
print('numpy', numpy.__version__); print('mediapipe', mediapipe.__version__)"
```
- 必须无 import 错误;`numpy.__version__``1.26` 开头。
- `ls face_analysis/weights/79999_iter.pth` 存在且 >40MB。
- `ls face_analysis/fonts/SourceHanSansSC-Regular.otf` 存在。
**完成标准**:上述命令全部通过,无报错。
---
## 3. 阶段二:MediaPipe 关键点检测封装
**开发步骤**
1. `face_analysis/face_mesh_landmarks.py`:定义所有关键点索引常量(见技术方案 §2.3):眉心 9/151、鼻翼下缘 94、下巴 152、眼角 33/133/263/362、脸颊 234/454、鼻尖 1/4、虹膜 468477、solvePnP 用的 61/291。
2. `face_analysis/detector.py`:实现 `FaceMeshDetector` 单例(技术方案 §8.2),`static_image_mode=True, max_num_faces=1, refine_landmarks=True``detect(image_bgr)` 返回 landmarks 或 None。
**交付物**`face_mesh_landmarks.py``detector.py`
**验证方法**
- 准备一张正面人像测试图 `tests/fixtures/frontal.jpg`(agent 若无素材,用一张公开 CC0 正面人像;记录来源)。
- 自测脚本:加载图 → `detector.detect()` → 断言返回非 None 且 landmark 数 ≥ 478(含虹膜)。
```bash
./venv/bin/python -m face_analysis.detector tests/fixtures/frontal.jpg
# 期望输出:detected landmarks: 478
```
**完成标准**:能稳定检测出 478 点。
---
## 4. 阶段三:姿态校验(solvePnP
**开发步骤**
1. `face_analysis/pose.py`:实现 `estimate_head_pose(landmarks, w, h)` 返回 `(yaw, pitch, roll)``check_frontal_face(...)` 返回 bool(技术方案 §9)。
2. 阈值用初始值 15°,定义为模块常量便于后续标定。
**交付物**`pose.py`
**验证方法**
- 用正面图:`check_frontal_face` 返回 True,三个角绝对值均 < 15。
- `hard_longhair.jpg` 略带角度,打印其 yaw/pitch/roll,确认角度比 frontal 大(用于观察姿态评分是否合理)。
- 仓库未提供明显侧脸图;若要测 `frontal=False` 的拒绝路径,agent 自备一张明显侧脸图存为 `tests/fixtures/profile.jpg`(公开 CC0,记录来源),否则在测试中用 mock landmarks 构造大 yaw 验证阈值逻辑。
```bash
./venv/bin/python -m face_analysis.pose tests/fixtures/frontal.jpg # frontal=True, 三角接近 0
./venv/bin/python -m face_analysis.pose tests/fixtures/hard_longhair.jpg # 打印角度,观察是否偏大
```
**完成标准**:正面图判定为 True 且三角接近 0;阈值拒绝逻辑(大 yaw→False)有测试覆盖。
---
## 5. 阶段四:尺度校准(虹膜直径法)
**开发步骤**
1. `face_analysis/calibration.py`
- `normalized_to_pixel``pixel_distance`(技术方案 §3.2)。
- `estimate_scale_factor(landmarks, w, h)` 返回 `px_per_cm`,用虹膜左右边缘点(469/471、474/476)求直径,左右取平均,除以 `AVG_IRIS_DIAMETER_CM=1.17`(技术方案 §3.3)。
- 虹膜点缺失时降级用眼宽(外→内眼角,均值 2.85cm)。
**交付物**`calibration.py`
**验证方法**
- 自测:对正面图算 `px_per_cm`,断言为正且落在合理范围(例如 1080×1920 的人像,px_per_cm 通常在 20120 之间,agent 实测后记录实际值作为回归基线)。
```bash
./venv/bin/python -m face_analysis.calibration tests/fixtures/frontal.jpg
# 期望输出:px_per_cm: <正数>
```
**完成标准**:输出正数且量级合理;故意传一张无虹膜(refine 关闭模拟)能走眼宽降级不报错。
---
## 6. 阶段五:头发分割(方案 B)+ 兜底(方案 A)
**开发步骤**
1. `face_analysis/hair_segmenter.py`
- 封装 BiSeNet face-parsing:加载 `weights/79999_iter.pth`,输入 BGR 图,输出 `hair_mask`H×W boolTrue=头发)。预处理 resize 到 512×512,推理后 resize 回原图尺寸。CPU 推理。单例加载,避免每次请求重载权重。
- `locate_hairline_by_segmentation(hair_mask, brow_center_x, h)` 返回 `(hairline_y, hair_top_y)` 或 None(技术方案 §4.0)。
2. `face_analysis/measure.py`(先做方案 A 部分):
- `estimate_vertical_landmarks(...)`(方案 A,技术方案 §4.3)作为兜底。
3.`measure.py` 里实现**决策逻辑**:先尝试方案 B,合理性校验(头顶在发际线上方、发际线在眉心上方、各庭为正)通过则用 B 并标 `hairline_source="segmentation"`,否则回退 A 标 `"estimated"`(技术方案 §4 决策流程)。
**交付物**`hair_segmenter.py``measure.py`(含纵向定位 + 决策)
**验证方法**
- 自测分割:对 `frontal.jpg` 输出 `hair_mask`,断言 `hair_mask.sum() > 0`dump 一张 mask 预览 PNG 到 `tests/output/`,目视确认头发区域正确。
- 自测定位:方案 B 返回的 `hairline_y < brow_center_y`(发际线在眉心上方,y 向下为正)、`hair_top_y < hairline_y`
- **困难样本** `hard_longhair.jpg`:长发遮挡两侧,确认要么中分缝定位合理、要么合理性校验不过自动回退方案 A(`hairline_source=="estimated"`),**两种都算通过,关键是不报错、不输出离谱坐标**。
- **降级路径**:把 mask 置空(`None`)模拟光头/分割失败,断言决策回退方案 A、`hairline_source=="estimated"`、不报错。
```bash
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/frontal.jpg
# 期望:hair pixels: <正数>, hairline_y < brow_y, hair_top_y < hairline_y
./venv/bin/python -m face_analysis.hair_segmenter tests/fixtures/hard_longhair.jpg
# 期望:能跑通,输出分割结果或明确的回退标记
```
**完成标准**:正常头发图走分割且坐标自洽;长发/无头发图自动降级不报错。
---
## 7. 阶段六:四庭七眼测量计算
**开发步骤**
1. `measure.py` 补全:
- `measure_seven_eyes(...)`(技术方案 §5):眼宽(左右均值)、脸宽、两眼间距像素值。
- 整合主函数 `measure_face(landmarks, hair_mask, w, h)`
- 调 §4 决策得 5 个纵向点 + 各庭像素长。
- 调七眼测量。
-`estimate_scale_factor` 得 px_per_cm,全部像素 → cm。
- 算占比:四庭各段/全脸高,眼宽/脸宽、间距/脸宽。
- 返回结构化结果对象(含 cm、ratios、5 点像素坐标、hairline_source、head_pose)。
2. 结果对象提供 `to_response()` 方法,输出与现有 Mock 的 `data` 字段**完全同构**(字段名对齐 `docs/接口文档.md`)。
**交付物**:完整 `measure.py`
**验证方法**
- 对正面图跑 `measure_face`,断言:
- 四庭 ratio 之和 ≈ 1.0(±0.02)。
- 所有 cm 值为正且量级合理(全脸高度通常 18–24cm)。
- 眼宽 ratio 在 0.150.25 之间(七眼理论 ≈ 0.2)。
- 返回字段名与 `docs/接口文档.md` 定义逐一对齐(写一个字段对比断言)。
```bash
./venv/bin/python -m face_analysis.measure tests/fixtures/frontal.jpg
# 打印完整 data dict
```
**完成标准**:数值自洽、字段对齐文档。
---
## 8. 阶段七:标注图生成
**开发步骤**
1. `face_analysis/annotation.py`(技术方案 §6):
- 用打包中文字体绝对路径加载(**不静默降级**,缺字体直接抛错)。
- `draw_gradient_horizontal_line`:**numpy 向量化**实现(技术方案 §6 修订版),全程在 `np.zeros((h,w,4))` 缓冲上画,最后 `Image.fromarray`
- 四庭水平分界线(渐变消失)+ 左侧四庭 cm 数值。
- 七眼标注(上下穿插)。
- 虚线带箭头 `draw_dashed_line_with_arrows`
- 规格:线/字色 `#FFFFFF`、字体 10pt、线宽 1pt、透明底 RGBA。
2. `create_annotated_image(image_bgr, measure_result)` 返回 PIL RGBA Image。
**交付物**`annotation.py`
**验证方法**
- 对正面图生成标注 PNG,保存到 `tests/output/annotated.png`,断言:
- 模式为 `RGBA`,尺寸 == 原图尺寸。
- 存在透明像素(A==0)也存在不透明像素(A>0)。
- 中文渲染正常(人工/agent 目视 dump 图,确认"顶庭/上庭/中庭/下庭"非方块)。
- 性能:生成耗时记录,单张应 < 1s(验证 numpy 渐变线没有退化成逐像素)。
```bash
./venv/bin/python -m face_analysis.annotation tests/fixtures/frontal.jpg tests/output/annotated.png
```
**完成标准**:PNG 透明底正确、中文正常、生成快。
---
## 9. 阶段八:接入 app.py
**开发步骤**
1.`app.py` 替换 `/api/v1/face/measure` 的 Mock 实现:
- 解析三选一图片输入(沿用现有 URL/base64/file 处理;URL 需下载,base64 需去前缀解码)。
- 校验:大小 ≤1MB(1006)、可解码(1008)、分辨率用**短边/长边**判断(1002,技术方案 §8.3 修订版)。**门槛做成可配置**:读环境变量 `MIN_SHORT_SIDE`(默认 1080)、`MIN_LONG_SIDE`(默认 1920),不要硬编码(见 §14 分辨率门槛说明)。
- `detector.detect` → None 则 1001。
- `check_frontal_face` → False 则 1003。
- `hair_segmenter` 取 mask(失败传 None,由 measure 内部兜底)。
- `measure_face``create_annotated_image` → 保存到 `static/annotations/{uuid}.png` → 拼出 URL(用 `SAMPLE_IMAGE_URL` 同源的 base,即 `https://hair.xiangsilian.com/static/annotations/{uuid}.png`)。
- `return ok(result.to_response())`data 内含 `annotated_image_url`
2. 模型单例在模块加载时初始化(detector、segmenter),避免每请求重建。
3. 异常兜底:未预期异常返回 `err(1007, ...)`(按文档错误码定义对齐)。
**交付物**:更新后的 `app.py`
**验证方法**(本地起服务)
> 默认门槛已是 600/800`frontal.jpg` 直接放行,无需绕过校验。
```bash
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 &
F=http://127.0.0.1:8000/api/v1/face/measure
# 0) 正常图 → code==0data 含 four_courts/seven_eyes/annotated_image_url/hairline_source/head_pose
curl -s -X POST $F -F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 1002) 低分辨率
curl -s -X POST $F -F image_file=@tests/fixtures/lowres.png
# 1001) 非人脸风景
curl -s -X POST $F -F image_file=@tests/fixtures/landscape.jpg
# 1008) 损坏文件
curl -s -X POST $F -F image_file=@tests/fixtures/corrupt.bin
# 1006) 超大图(>1MB,临时生成不入库)
head -c 1100000 /dev/urandom > /tmp/oversize.bin
curl -s -X POST $F -F image_file=@/tmp/oversize.bin
# 1003) 侧脸:仓库无素材,用 mock 大 yaw 在单测中覆盖
```
- 访问返回的 `annotated_image_url` 对应的本地文件存在。
- `/docs` Swagger 正常加载,该接口 schema 未破坏。
**完成标准**0/1002/1001/1008/1006 五类用例返回正确 code(有现成夹具);1003 用 mock 覆盖;正常用例 data 结构与文档一致。
---
## 10. 阶段九:测试套件与回归
**开发步骤**
1. `tests/test_face_measure.py`pytest):
- 各模块单元测试(detector/pose/calibration/segmenter/measure/annotation)。
- 接口集成测试:用 FastAPI `TestClient` 跑 §9 的错误码用例。
- **精度验证:见 §15 三层策略(合成真值 / 缩放不变性 / 可视化)**——这是误差验证的核心,必做。
- 数值回归:把 `frontal.jpg` 首次跑出的四庭/七眼 cm 值记为基线,断言后续运行偏差 < 1%(防止重构回归)。
2. `tests/fixtures/` 素材已就位(见 §14),无需再准备。
3.`docs/接口1-四庭七眼测量-技术实现方案.md` §11 待确认事项旁,补一份「实测基线数值表」。
**交付物**`tests/` 目录、`pytest.ini`(或 pyproject 配置)、基线数值表
**验证方法**
```bash
./venv/bin/python -m pytest tests/ -v
```
- 全绿。
**完成标准**`pytest` 全部通过。
---
## 11. 阶段十:部署与冒烟
**开发步骤**
1. 确认 `hair.service`(systemd)无需改动即可加载新依赖;若新增 torch 导致启动变慢,记录冷启动耗时。
2. 部署脚本补一步 `scripts/download_weights.sh`(生产机拉权重)。
3. 重启服务,跑线上冒烟。
**交付物**:更新的部署说明(写进 `CLAUDE.md``docs/`
**验证方法**
```bash
sudo systemctl restart hair && sudo systemctl status hair
curl -s -X POST https://hair.xiangsilian.com/api/v1/face/measure \
-F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 期望 code==0annotated_image_url 可公网访问(curl -I 返回 200)
journalctl -u hair -n 50 # 无 ERROR/Traceback
```
**完成标准**:线上接口返回真实数据,标注图可访问,日志无异常。
---
## 12. 总交付清单(Definition of Done
- [ ] `requirements.txt` / `.gitignore` / `scripts/download_weights.sh`
- [ ] `face_analysis/``detector.py``pose.py``calibration.py``hair_segmenter.py``measure.py``annotation.py``face_mesh_landmarks.py``fonts/``weights/`
- [ ] `app.py``/api/v1/face/measure` 真实实现(移除该接口 Mock
- [ ] `tests/`fixtures + 单元 + 集成 + 数值回归,`pytest` 全绿
- [ ] 线上冒烟通过,标注图可公网访问
- [ ] 文档:实测基线数值表 + 部署说明更新
- [ ] 返回 data 含新增字段 `hairline_source``head_pose`,其余字段与 `docs/接口文档.md` 对齐
---
## 13. 风险与降级开关(提醒 agent)
1. **torch 装不上 / 太重**:若环境受限,先交付「方案 A only」版本(跳过阶段五的分割,`hairline_source` 恒为 `"estimated"`),把方案 B 标记为 TODO,但**其余阶段照常**。在交付说明里明确写出。
2. **数值不合理**(如 cm 量级离谱):优先怀疑 px_per_cm(虹膜点是否检出)和分辨率方向判断,而非盲目调比例常数。
3. **不确定字段命名**:以 `docs/接口文档.md` 为唯一权威,冲突时以文档为准并在 PR 说明里指出。
---
## 14. 测试素材清单(已就位于 `tests/fixtures/`
以下夹具**已全部创建完毕**,agent 直接使用即可,无需再拷贝/生成:
| 文件 | 尺寸(W×H) | 大小 | 来源 | 用途 |
|------|-----------|------|------|------|
| `frontal.jpg` | 682×811 | 94KB | 真实样本(原 `image/test.jpg` | **主用例**:阶段二~八全部功能验证 + 数值基线 |
| `hard_longhair.jpg` | 864×1152 | 131KB | 真实样本(原 `image/qwerqwe.jpg` | **困难样本**:分割鲁棒性、`max_num_faces=1` 只取最大脸、姿态 |
| `lowres.png` | 406×571 | 226KB | 真实样本(原 `image/image.png`,已带标注线) | **1002 拒绝用例**(短边 406 < 600);勿当干净输入 |
| `landscape.jpg` | 1000×1200 | 114KB | 程序生成(非人脸风景) | **1001 用例**:无法识别人像 |
| `corrupt.bin` | — | 2KB | 程序生成(伪 PNG 头 + 垃圾字节) | **1008 用例**:无法解码 |
| _(1006 超大图)_ | — | >1MB | **测试时动态生成,不入库** | **1006 用例**:超过 1MB |
> **1006 超大图不提交进 git**(避免仓库膨胀,内容是随机噪声无信息量)。在 `tests/conftest.py` 里用 pytest fixture 临时生成;测 1006 仅看字节数、无需合法图片:
> ```python
> @pytest.fixture
> def oversize_file(tmp_path):
> p = tmp_path / "oversize.bin"
> p.write_bytes(b"\x00" * (1_100_000)) # 1.1MB,刚过 1MB 红线
> return p
> ```
> 手动 curl 验证时临时造一个即可:`head -c 1100000 /dev/urandom > /tmp/oversize.bin`
> 仍缺:明显侧脸图(测 1003)。无合规素材,agent 用 mock landmarks 构造大 yaw 验证阈值逻辑即可(见 §4 阶段三)。
### 分辨率门槛(已放宽,可配置)
- **默认门槛下调为:短边 ≥ 600、长边 ≥ 800**(环境变量 `MIN_SHORT_SIDE=600``MIN_LONG_SIDE=800`,技术方案 §8.3 已同步)。
- 该门槛下:`frontal.jpg`(682×811)、`hard_longhair.jpg`(864×1152) 放行;`lowres.png`(406×571) 被 1002 拒绝——正好作拒绝用例,**功能测试无需再绕过校验**。
- **门槛必须做成可配置,不要硬编码**:生产可通过环境变量随时调整,无需改代码。
**待确认事项(提交给需求方,不阻塞开发)**
1. 600/800 是否合适?过低会牺牲测量精度(虹膜/关键点像素太少),过高会拒掉大量真实上传图。建议上线后按实际拒绝率/精度反馈再调。
2. `hard_longhair.jpg` 这类长发遮挡发际线的图,方案 B 大概率只能定位到中分缝;若分割结果不可靠应自动回退方案 A(`hairline_source="estimated"`)——确认这是可接受行为。
---
## 15. 精度 / 误差验证策略(三层)
> **核心认知**:管线分两层——**测量数学**(landmarks+尺度→cm)可以构造精确真值验证;**MediaPipe 检测**(图→landmarks 落点)无法合成真值,只能人工标注或间接验证。绝大多数可控 bug 在数学层,务必重点覆盖。
### Tier 1 — 合成真值,精确验证测量数学(必做,核心)
自己构造一组「已知真值」的关键点:坐标和 `px_per_cm` 都由测试设定,因此每一段的 cm/占比真值已知,算出来必须**分毫不差**(误差仅来自浮点,断言 < 1e-6)。这能精确验证 `calibration` / `measure_seven_eyes` / 方案A 推算 / 占比公式。
```python
# tests/test_geometry_truth.py
import numpy as np
class _LM: # 模拟 MediaPipe landmark.x/.y/.z
def __init__(self, x, y, z=0.0): self.x, self.y, self.z = x, y, z
def build_synthetic_landmarks(px_per_cm=50.0, W=1000, H=1000):
"""按已知 cm 几何摆放关键点,返回 (landmarks_list, ground_truth_dict)"""
cx = W / 2
def Y(cm_from_top): # cm → 归一化 y
return (cm_from_top * px_per_cm) / H
def X(px):
return px / W
# 设定真值(cm):从头顶往下
gt = {"top_court_cm": 4.0, "upper_court_cm": 5.0,
"middle_court_cm": 6.0, "lower_court_cm": 5.0,
"eye_width_cm": 3.0, "inter_eye_cm": 3.4, "face_width_cm": 14.0,
"px_per_cm": px_per_cm}
y_hairtop = 2.0
y_hairline = y_hairtop + gt["top_court_cm"]
y_brow = y_hairline + gt["upper_court_cm"]
y_nose = y_brow + gt["middle_court_cm"]
y_chin = y_nose + gt["lower_court_cm"]
lm = {i: _LM(X(cx), 0.0) for i in range(478)} # 占位
# 纵向中轴点
lm[9] = _LM(X(cx), Y(y_brow)); lm[151] = _LM(X(cx), Y(y_brow))
lm[94] = _LM(X(cx), Y(y_nose))
lm[152]= _LM(X(cx), Y(y_chin))
# 七眼横向点(按真值 px 摆位,y 任意取眉下一行)
ew = gt["eye_width_cm"] * px_per_cm
ie = gt["inter_eye_cm"] * px_per_cm
fw = gt["face_width_cm"] * px_per_cm
eye_y = Y(y_brow + 2.0)
lm[133] = _LM(X(cx - ie/2), eye_y); lm[33] = _LM(X(cx - ie/2 - ew), eye_y)
lm[362] = _LM(X(cx + ie/2), eye_y); lm[263] = _LM(X(cx + ie/2 + ew), eye_y)
lm[234] = _LM(X(cx - fw/2), eye_y); lm[454] = _LM(X(cx + fw/2), eye_y)
# 虹膜边缘点:直径 = 1.17cm * px_per_cm,使尺度可被精确反解
d = 1.17 * px_per_cm
lm[469] = _LM(X(cx - ie/2 - ew/2 - d/2), eye_y); lm[471] = _LM(X(cx - ie/2 - ew/2 + d/2), eye_y)
lm[474] = _LM(X(cx + ie/2 + ew/2 - d/2), eye_y); lm[476] = _LM(X(cx + ie/2 + ew/2 + d/2), eye_y)
return [lm[i] for i in range(478)], gt
def test_scale_factor_exact():
lm, gt = build_synthetic_landmarks(px_per_cm=50.0)
from face_analysis.calibration import estimate_scale_factor
assert abs(estimate_scale_factor(lm, 1000, 1000) - gt["px_per_cm"]) < 1e-6
def test_seven_eyes_exact():
lm, gt = build_synthetic_landmarks()
from face_analysis.measure import measure_seven_eyes
r = measure_seven_eyes(lm, 1000, 1000)
pc = gt["px_per_cm"]
assert abs(r["eye_width_px"]/pc - gt["eye_width_cm"]) < 1e-6
assert abs(r["face_width_px"]/pc - gt["face_width_cm"]) < 1e-6
assert abs(r["inter_eye_distance_px"]/pc - gt["inter_eye_cm"]) < 1e-6
# 方案A 推算、四庭占比同理,用 gt 的中/下庭做输入,断言推算的上/顶庭与 gt 关系一致
```
> 注意:方案 A 因为是「按比例推算」,它推出的上/顶庭**不会**等于任意设定的真值——Tier 1 对方案 A 只验证「推算公式按既定比例正确执行」(给定中下庭,输出符合 0.25/0.22 比例关系),而非验证它贴近真实脸。这正是方案 A 循环论证局限的体现,文档已说明。方案 B 的真值验证用合成 mask(已知头发区域上沿)走 `locate_hairline_by_segmentation`。
### Tier 2 — 缩放不变性,真实图上可运行(必做)
用真实 `frontal.jpg` 跑完整管线,再把图**等比放大 2×** 重跑。物理量应满足:
- **占比(ratio)完全不变**(±0.5%)——放大不改变比例。
- **cm 值基本不变**(±2%)——因为 px_per_cm 也随之放大,虹膜法自洽。
这用**真实 MediaPipe 输出**验证尺度处理无 bug,不需要人工真值。若放大后 cm 值漂移大,说明尺度链路有问题。
```python
def test_scale_invariance():
import cv2
img = cv2.imread("tests/fixtures/frontal.jpg")
big = cv2.resize(img, None, fx=2, fy=2, interpolation=cv2.INTER_CUBIC)
r1 = run_measure(img); r2 = run_measure(big)
for k in ["top","upper","middle","lower"]:
assert abs(r1.ratio[k] - r2.ratio[k]) < 0.005 # 占比不变
assert abs(r1.cm[k] - r2.cm[k]) / r1.cm[k] < 0.02 # cm 近似不变
```
### Tier 3 — 检测落点定性评估(人工真值,抽样)
MediaPipe 落点准不准没有合成真值,只能:
1. **可视化叠加**:把 5 个纵向点 + 眼角点画回原图存 PNG,人工/agent 目视确认落点正确(眉心在眉间、下巴在下颌最低点等)。
2. **抽样人工标注**:对 2~3 张图手工标注真值关键点像素坐标存 `tests/fixtures/*_truth.json`,断言 MediaPipe 输出与标注的像素偏差 < 全脸高度的 3%。
```python
def test_landmark_overlay():
"""生成叠加图供人工核验,并断言关键点落在图像合理区域内"""
# 画点存 tests/output/frontal_landmarks.png,断言各点坐标在 [0,W]/[0,H] 且顺序自上而下
```
### 误差预期对照(写进基线表)
| 误差来源 | 验证手段 | 预期 |
|----------|----------|------|
| 测量数学(尺度/占比/七眼/脸宽) | Tier 1 合成真值 | ≈ 0< 1e-6 |
| 尺度链路一致性 | Tier 2 缩放不变性 | 占比 < 0.5%cm < 2% |
| MediaPipe 落点 | Tier 3 人工标注抽样 | < 3% 全脸高 |
| 虹膜个体差异 + 透视 | 无法消除,文档声明 | cm ±5~15%(离虹膜平面越远越大) |
| 方案 A 推算上/顶庭 | 固有局限 | 真实脸偏差可达 ±15%,故优先方案 B |
**完成标准(补充到阶段九)**Tier 1 全部断言 < 1e-6Tier 2 通过;Tier 3 叠加图人工确认 OK。
---
> **任务书版本**: v1.3 **创建日期**: 2026-06-13v1.3:新增 §15 三层精度验证策略 + 合成真值生成器)| 配套技术方案 v2.0
@@ -1,6 +1,6 @@
# 接口 1:四庭七眼测量 — 技术实现方案 # 接口 1:四庭七眼测量 — 技术实现方案
> 基于 MediaPipe Face Mesh468 关键点)+ 人脸比例先验知识 > 基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底
--- ---
@@ -18,7 +18,16 @@
| 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 | | 3DDFA_V2 | 68+ 3D mesh | 类似 MediaPipe | ⚠️ 推理较慢 | 3D 重建更完整 |
| SPIGA | 68 | 眉毛 → 下巴 | ✅ | 实时性不如 MediaPipe | | 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 点 ### 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/`),安装后直接可用,无需额外下载。 经典 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. 关键点索引映射 ## 2. 关键点索引映射
@@ -170,6 +199,8 @@ def estimate_scale_factor(landmarks, image_width, image_height):
> **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。 > **注意**:虹膜关键点(索引 468477)需要 `FaceMesh(refine_landmarks=True)` 才会输出。如果不启用 `refine_landmarks`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。
> ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。
### 3.4 备用校准:人脸比例法 ### 3.4 备用校准:人脸比例法
若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算: 若虹膜数据不可用,也可用 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 可以精确检测 **眉心、鼻翼下缘、下巴尖** 三个关键点(均位于面部中轴线)。利用「三庭五眼」标准比例,向上推算发际线和头顶位置。 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)) canvas = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas) draw = ImageDraw.Draw(canvas)
# 字体(尝试 PingFangSC,降级为系统默认中文字体) # ⚠️ 字体:PingFangSC 是 macOS 字体,Linux 服务器没有;且 ImageFont.load_default()
try: # 不渲染中文(会出现方块/空白)。必须随仓库打包一个中文 TTF 并用绝对路径加载。
font = ImageFont.truetype("PingFangSC-Regular", 10) # 建议放 face_analysis/fonts/SourceHanSansSC-Regular.otf(思源黑体)或 Noto Sans CJK。
except: FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "SourceHanSansSC-Regular.otf")
font = ImageFont.load_default() # 降级方案 font = ImageFont.truetype(FONT_PATH, 10) # 字体缺失时直接抛错,避免静默降级成乱码
line_color = (255, 255, 255, 255) # #FFFFFF 100% line_color = (255, 255, 255, 255) # #FFFFFF 100%
line_width = 1 # 1pt line_width = 1 # 1pt
@@ -412,21 +483,34 @@ def create_annotated_image(input_image_path, vertical_result, eye_result, px_per
### 渐变线实现 ### 渐变线实现
```python > ⚠️ **性能**:逐像素 `draw.point` 在大图上极慢(每条线几百次 Python 调用,多条线 × 高分辨率图肉眼可感卡顿)。用 numpy 向量化生成一行渐变像素后整行写入,快几个数量级:
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 # 渐变线长度
for side in [-1, 1]: # 左 (-1) / 右 (+1) ```python
for i in range(half_length): import numpy as np
# 线性衰减 alpha
alpha = int(max_alpha * (1 - i / half_length)) def draw_gradient_horizontal_line(canvas: Image.Image, cx, cy, color, half_length=None):
x = cx + side * i """以 (cx, cy) 为中心,向两侧绘制渐变消失的水平线(numpy 向量化)"""
if 0 <= x < img_width: arr = np.asarray(canvas) # RGBA, H×W×4
draw.point((x, cy), fill=(color[0], color[1], color[2], alpha)) 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 ```python
@@ -468,7 +552,7 @@ def draw_dashed_line_with_arrows(draw, x1, y1, x2, y2, color, dash_len=6, gap_le
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ 1. 预处理 │ │ 1. 预处理 │
│ - 校验格式 (JPG/PNG) │ │ - 校验格式 (JPG/PNG) │
│ - 校验分辨率 (1080×1920 ~ 4000×5000) │ - 校验分辨率 (短边≥600 长边≥800, 可配置)
│ - 校验文件大小 (≤ 1MB) │ │ - 校验文件大小 (≤ 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, │ max_num_faces=1,
│ refine_landmarks=True) │ │ refine_landmarks=True) │
│ - 输出: 468+10 关键点 │ │ - 输出: 468+10 关键点 │
│ - 无人脸 → 1001 │
└──────────────┬──────────────────────┘ └──────────────┬──────────────────────┘
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ 3. 关键点提取 │ 3. 姿态校验 (solvePnP)
│ - 纵向: 头顶*/发际线*/眉心/鼻翼/下巴 │ - 解算 yaw/pitch/roll
│ - 横向: 眼宽/脸宽/两眼间距 │ - 超阈值 → 1003 (非正面照)
│ * 推算值 │
└──────────────┬──────────────────────┘ └──────────────┬──────────────────────┘
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
│ 4. 尺度校准 │ 4. 关键点提取 + 发际线/头顶定位
│ - 横向: 眼宽/脸宽/两眼间距(实测) │
│ - 中/下庭: 眉心/鼻翼/下巴 (实测) │
│ - 上/顶庭: 方案B分割(主)→A推算(兜底)│
│ 记录 hairline_source │
└──────────────┬──────────────────────┘
┌─────────────────────────────────────┐
│ 5. 尺度校准 │
│ - 虹膜直径法: px_per_cm 估算 │ │ - 虹膜直径法: px_per_cm 估算 │
└──────────────┬──────────────────────┘ └──────────────┬──────────────────────┘
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
5. 计算与生成 │ 6. 计算与生成 │
│ - 像素 → 厘米 │ │ - 像素 → 厘米 │
│ - 计算占比 │ │ - 计算占比 │
│ - 生成标注图层 PNG │ │ - 生成标注图层 PNG │
└──────────────┬──────────────────────┘ └──────────────┬──────────────────────┘
┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐
6. 输出 │ 7. 输出 │
│ - annotated_image_url (标注PNG) │ │ - annotated_image_url (标注PNG) │
│ - face_total_height_cm │ │ - face_total_height_cm │
│ - four_courts (含cm & ratios) │ │ - four_courts (含cm & ratios) │
│ - seven_eyes (含cm & ratios) │ │ - seven_eyes (含cm & ratios) │
│ - landmarks (5个点原图像素坐标) │ │ - landmarks (5个点原图像素坐标) │
│ - hairline_source ("segmentation"│
│ / "estimated") │
│ - head_pose (yaw/pitch/roll) │
└─────────────────────────────────────┘ └─────────────────────────────────────┘
``` ```
@@ -521,13 +616,20 @@ hair/
├── app.py # 现有 FastAPI 应用 ├── app.py # 现有 FastAPI 应用
├── face_analysis/ ├── face_analysis/
│ ├── __init__.py │ ├── __init__.py
│ ├── detector.py # MediaPipe 封装 │ ├── detector.py # MediaPipe Face Mesh 封装
│ ├── measure.py # 四庭七眼测量逻辑 │ ├── hair_segmenter.py # 方案 BBiSeNet 头发分割封装
│ ├── calibration.py # px→cm 尺度校准 │ ├── pose.py # solvePnP 头部姿态估计 + 正面校验
│ ├── annotation.py # 标注图片生成 │ ├── measure.py # 四庭七眼测量逻辑(整合方案 B/A)
── face_mesh_landmarks.py # 关键点索引常量 ── 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/ ├── static/
│ └── annotations/ # 生成的标注 PNG 存放目录 │ └── annotations/ # 生成的标注 PNG 存放目录
├── .gitignore # 忽略 face_analysis/weights/*.pth
└── requirements.txt └── requirements.txt
``` ```
@@ -598,7 +700,13 @@ async def face_measure(image_file: UploadFile = File(...)):
return err(1008, "图片格式不支持") return err(1008, "图片格式不支持")
h, w = image.shape[:2] 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, "人像分辨率过低") return err(1002, "人像分辨率过低")
# 3. 人脸检测 # 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 ```python
def check_frontal_face(landmarks): import cv2
"""简单正面照判定:基于左右眼关键点的 y 坐标对称性 + 鼻尖偏移""" import numpy as np
left_eye = landmarks[33]
right_eye = landmarks[263] # 通用 3D 头部模型(单位 mm,近似),与下方 MediaPipe 索引一一对应
nose_tip = landmarks[4] _MODEL_POINTS = np.array([
(0.0, 0.0, 0.0), # 鼻尖 -> 1(或 4
# 双眼高度差(偏航判定) (0.0, -63.6, -12.5), # 下巴 -> 152
y_diff = abs(left_eye.y - right_eye.y) (-43.3, 32.7, -26.0), # 左眼外角 -> 33
(43.3, 32.7, -26.0), # 右眼外角 -> 263
# 鼻尖偏离双眼中点(偏航/滚转判定) (-28.9, -28.9, -24.1), # 左嘴角 -> 61
eye_center_x = (left_eye.x + right_eye.x) / 2 (28.9, -28.9, -24.1), # 右嘴角 -> 291
nose_offset = abs(nose_tip.x - eye_center_x) ], dtype=np.float64)
_PNP_IDX = [1, 152, 33, 263, 61, 291]
# 阈值根据实际测试调整
if y_diff > 0.03 or nose_offset > 0.08: def estimate_head_pose(landmarks, image_width, image_height):
return False # 非正面照 """返回 (yaw, pitch, roll) 角度。solvePnP 失败返回 None。"""
return True 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. 依赖与版本 ## 10. 依赖与版本
@@ -659,11 +800,19 @@ def check_frontal_face(landmarks):
``` ```
# requirements.txt 新增 # requirements.txt 新增
mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载) mediapipe==0.10.14 # 经典 Solutions API(模型内置,无需额外下载)
opencv-python==4.10.0 # 图片读取处理 opencv-python==4.10.0 # 图片读取处理、solvePnP 姿态估计
Pillow==11.0.0 # 标注图生成(PNG 透明图层) Pillow==11.0.0 # 标注图生成(PNG 透明图层)
numpy==2.1.0 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,后续可平滑升级。 > MediaPipe 0.10.x 的经典 Solutions API (`mp.solutions.face_mesh`) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。
--- ---
@@ -677,7 +826,7 @@ numpy==2.1.0
--- ---
> **文档版本**: v1.0 > **文档版本**: v2.0
> **创建日期**: 2026-06-13 > **创建日期**: 2026-06-13(v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
> **依赖模型**: MediaPipe Face Mesh (468 landmarks) > **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
> **测量策略**: 实测关键点 + 方案A(人脸比例推算未覆盖区域 > **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB