# 接口 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}` 与错误码 1001–1008 不变。 **核心算法策略**(见技术方案 §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、虹膜 468–477、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 通常在 20–120 之间,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 bool,True=头发)。预处理 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.15–0.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==0,data 含 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: " # 0) 正常请求(直连 worker)→ code==0,data 含 annotated_image_base64 curl -s -H "$TOK" -X POST http://: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://:28187/api/v1/face/measure -F image_file=@tests/fixtures/frontal.jpg # 就绪) /health → 200 curl -s http://: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` 全绿 - [ ] worker(GPU 机 :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-6;Tier 2 通过;Tier 3 叠加图人工确认 OK。 --- > **任务书版本**: v1.5 | **创建日期**: 2026-06-13(v1.5:拆出网关任务书到独立文档,本书聚焦 worker 侧)| 配套技术方案 v2.0 / 系统架构 v1.0 / 网关任务书 v1.0