Files
hair/docs/接口1-四庭七眼测量-开发任务书.md
T
xsl bf68a32d9f docs: 拆分网关与worker文档,按机器分别开发
- 新增 网关-开发任务书.md: 独立自包含的网关侧任务书(骨架/健康检查派发/
  鉴权转发/base64改URL/静态托管/部署/DoD),在外网机开发
- worker任务书 v1.5: 移除§16网关章节,顶部标注本机职责,DoD只留worker侧,
  指向独立网关任务书
- 新增 docs/README.md: 文档索引,标清worker机/网关机/共享分别读哪些
2026-06-14 14:29:48 +08:00

499 lines
29 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:四庭七眼测量 — 开发任务书(worker 侧 · AI Agent 执行版)
> **在高性能 worker(GPU 机)上开发。** 本任务书只负责 worker 侧算法逻辑。
> 配套技术方案:[`接口1-四庭七眼测量-技术实现方案.md`](接口1-四庭七眼测量-技术实现方案.md)
> 系统架构(两端共享契约,先读):[`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)
> **网关侧在另一台机器开发,有独立任务书**:[`网关-开发任务书.md`](网关-开发任务书.md),本机不涉及。
> 执行者:AI coding agent。请**严格按阶段顺序**执行,每个阶段完成后运行该阶段的「验证方法」,**通过后再进入下一阶段**。
> 📌 **本机职责**worker 跑完整 `app.py` + `face_analysis`,用 GPU。与单机版的两处关键差异:
> - ① torch 用 **GPU(CUDA)** 版;
> - ② 阶段八 handler **返回 `annotated_image_base64`,不保存到 static、不拼 URL**(落盘改 URL 由网关做)。
> - 接口文档(对外契约)**完全不变**。
---
## 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. **中文字体**`face_analysis/fonts/NotoSansCJKsc-Regular.otf`= 思源黑体,已预下载,见 `OFFLINE_ASSETS.md`)。
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. **权重/字体已预下载到位**(内网无需联网,见根目录 `OFFLINE_ASSETS.md` 的 sha256 清单):
- `face_analysis/weights/79999_iter.pth`BiSeNet 主权重 ~53MB
- `face_analysis/weights/resnet18-5c106cde.pth`(骨干 ~45MB
- `face_analysis/fonts/NotoSansCJKsc-Regular.otf`(中文字体 ~16MB
仍需写 `scripts/download_weights.sh`(供联网环境/生产机重建),但内网执行时跳过此步、直接用已有文件。
⚠️ **resnet18 骨干**:BiSeNet 初始化会尝试联网下载骨干,内网会失败——需把 `resnet18-5c106cde.pth` 拷到 `~/.cache/torch/hub/checkpoints/` 或改 BiSeNet 代码从 `weights/` 本地加载。
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`resnet18-5c106cde.pth` 存在。
- `ls face_analysis/fonts/NotoSansCJKsc-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`**worker 侧**)替换 `/api/v1/face/measure` 的 Mock 实现:
- 解析三选一图片输入(沿用现有 URL/base64/file 处理;URL 需下载,base64 需去前缀解码)。
- 校验:大小 ≤1MB(1006)、可解码(1008)、分辨率用**短边/长边**判断(1002,技术方案 §8.3 修订版)。**门槛做成可配置**:读环境变量 `MIN_SHORT_SIDE`(默认 600)、`MIN_LONG_SIDE`(默认 800),不要硬编码(见 §14)。
- `detector.detect` → None 则 1001。
- `check_frontal_face` → False 则 1003。
- `hair_segmenter` 取 mask(失败传 None,由 measure 内部兜底)。
- `measure_face``create_annotated_image`
- **⚠️ 拆分架构:返回 base64,不落盘不拼 URL**。`data["annotated_image_base64"] = base64(png)``return ok(data)`。落盘成 `annotated_image_url` 由网关完成(见架构文档 §9)。**worker 不写 static、不拼 hair.xiangsilian.com URL**。
2. 模型单例在模块加载时初始化(detector、segmenter),避免每请求重建;BiSeNet `.to('cuda' if available)`
3. **鉴权中间件**worker 增加校验 `X-Internal-Token` 的依赖/中间件,密码来自 worker 配置文件 `accept_passwords` 列表,不匹配返回 HTTP 401(架构文档 §7)。`/health` 不校验(供网关探测)。
4. 异常兜底:未预期异常返回 `err(1007, ...)`
**交付物**:更新后的 `app.py`
**验证方法**(本地起 worker
> 默认门槛已是 600/800`frontal.jpg` 直接放行。worker 已加鉴权,需带 `X-Internal-Token` 头(值取 worker 配置的密码;本地测试可设一个测试密码)。
```bash
./venv/bin/uvicorn app:app --host 127.0.0.1 --port 8000 &
F=http://127.0.0.1:8000/api/v1/face/measure
H="X-Internal-Token: testpass" # 与本地 worker 配置一致
# 0) 正常图 → code==0data 含 four_courts/seven_eyes/annotated_image_base64/hairline_source/head_pose
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 1002/1001/1008/1006 同样带 -H "$H"
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/lowres.png # 1002
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/landscape.jpg # 1001
curl -s -H "$H" -X POST $F -F image_file=@tests/fixtures/corrupt.bin # 1008
head -c 1100000 /dev/urandom > /tmp/oversize.bin
curl -s -H "$H" -X POST $F -F image_file=@/tmp/oversize.bin # 1006
# 鉴权) 不带 token → HTTP 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST $F -F image_file=@tests/fixtures/frontal.jpg # 401
# 1003) 侧脸:仓库无素材,用 mock 大 yaw 在单测中覆盖
```
- 正常用例 data 含 `annotated_image_base64`**不是** URL——落盘改 URL 由网关做,见网关任务书);base64 解码后是合法 PNG。
- 不带 token 返回 401`/health` 不需要 token。
- `/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. 阶段十:worker 部署与冒烟(GPU 机 :28187
**开发步骤**
1. 在 GPU 机部署完整 `app.py` + `face_analysis` + 模型权重(见 `OFFLINE_ASSETS.md`);torch 用 CUDA 版。
2. resnet18 骨干放入 torch 缓存(`~/.cache/torch/hub/checkpoints/`),避免联网下载。
3. worker 配置文件写好 `accept_passwords`uvicorn 监听 `0.0.0.0:28187`;防火墙只放行网关 IP。
4. systemd 管理 worker 进程;确认 `/health` 模型就绪后才返回 200。
**交付物**worker 部署说明 + systemd unit + worker 配置文件示例
**验证方法**(在网关机或被放行的机器上)
```bash
TOK="X-Internal-Token: <worker配置的密码>"
# 0) 正常请求(直连 worker)→ code==0data 含 annotated_image_base64
curl -s -H "$TOK" -X POST http://<worker>:28187/api/v1/face/measure \
-F image_file=@tests/fixtures/frontal.jpg | python -m json.tool
# 鉴权) 不带 token → 401
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://<worker>:28187/api/v1/face/measure -F image_file=@tests/fixtures/frontal.jpg
# 就绪) /health → 200
curl -s http://<worker>:28187/health
nvidia-smi # 确认推理时 GPU 被占用
```
**完成标准**:worker 直连返回真实数据(base64 图)、鉴权生效、`/health` 就绪、GPU 在用。
> 端到端(经网关的 HTTPS 冒烟)见 [`网关-开发任务书.md`](网关-开发任务书.md) 的验证。
---
## 12. 总交付清单(worker 侧 Definition of Done
- [ ] `requirements.txt`torch CUDA 版)/ `.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),**返回 `annotated_image_base64`**
- [ ] worker 鉴权中间件(`X-Internal-Token`+ 配置文件 + `/health` 就绪态
- [ ] `tests/`fixtures + 单元 + 集成 + 数值回归 + §15 三层精度,`pytest` 全绿
- [ ] workerGPU 机 :28187)部署冒烟通过,GPU 在用
- [ ] worker 返回 data 含 `annotated_image_base64``hairline_source``head_pose`,业务字段与 `docs/接口文档.md` 对齐
> 网关侧的交付清单在 [`网关-开发任务书.md`](网关-开发任务书.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.5 **创建日期**: 2026-06-13(v1.5:拆出网关任务书到独立文档,本书聚焦 worker 侧)| 配套技术方案 v2.0 / 系统架构 v1.0 / 网关任务书 v1.0