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

26 KiB
Raw Blame History

接口 1:四庭七眼测量 — 开发任务书(AI Agent 执行版)

配套技术方案:接口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. 模型权重不入 gitface_analysis/weights/*.pth 写进 .gitignore,由 §2 的下载脚本拉取。
  5. 中文字体入 gitface_analysis/fonts/SourceHanSansSC-Regular.otf
  6. 每个模块都要能单独 import 且有 if __name__ == "__main__" 自测入口,方便分阶段验证。
  7. 代码风格、注释密度与 app.py 保持一致;注释用中文。
  8. 不要 mock 兜底:算法失败时返回对应错误码,不得回退成硬编码示例数据。

2. 阶段一:环境与依赖

开发步骤

  1. 更新 requirements.txt,新增:mediapipe==0.10.14opencv-python==4.10.0Pillow==11.0.0numpy==1.26.4torch==2.2.2torchvision==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/*.pthstatic/annotations/*(保留 .gitkeep)。

交付物

  • 更新后的 requirements.txt.gitignore
  • scripts/download_weights.sh
  • 目录骨架

验证方法

./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=Truedetect(image_bgr) 返回 landmarks 或 None。

交付物face_mesh_landmarks.pydetector.py

验证方法

  • 准备一张正面人像测试图 tests/fixtures/frontal.jpg(agent 若无素材,用一张公开 CC0 正面人像;记录来源)。
  • 自测脚本:加载图 → detector.detect() → 断言返回非 None 且 landmark 数 ≥ 478(含虹膜)。
./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 验证阈值逻辑。
./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_pixelpixel_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 实测后记录实际值作为回归基线)。
./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_maskH×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.pymeasure.py(含纵向定位 + 决策)

验证方法

  • 自测分割:对 frontal.jpg 输出 hair_mask,断言 hair_mask.sum() > 0dump 一张 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"、不报错。
./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 定义逐一对齐(写一个字段对比断言)。
./venv/bin/python -m face_analysis.measure tests/fixtures/frontal.jpg
# 打印完整 data dict

完成标准:数值自洽、字段对齐文档。


8. 阶段七:标注图生成

开发步骤

  1. face_analysis/annotation.py(技术方案 §6):
    • 用打包中文字体绝对路径加载(不静默降级,缺字体直接抛错)。
    • draw_gradient_horizontal_linenumpy 向量化实现(技术方案 §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 渐变线没有退化成逐像素)。
./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_facecreate_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/800frontal.jpg 直接放行,无需绕过校验。

./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.pypytest):
    • 各模块单元测试(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 配置)、基线数值表

验证方法

./venv/bin/python -m pytest tests/ -v
  • 全绿。

完成标准pytest 全部通过。


11. 阶段十:部署与冒烟

开发步骤

  1. 确认 hair.service(systemd)无需改动即可加载新依赖;若新增 torch 导致启动变慢,记录冷启动耗时。
  2. 部署脚本补一步 scripts/download_weights.sh(生产机拉权重)。
  3. 重启服务,跑线上冒烟。

交付物:更新的部署说明(写进 CLAUDE.mddocs/

验证方法

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.pypose.pycalibration.pyhair_segmenter.pymeasure.pyannotation.pyface_mesh_landmarks.pyfonts/weights/
  • app.py/api/v1/face/measure 真实实现(移除该接口 Mock
  • tests/fixtures + 单元 + 集成 + 数值回归,pytest 全绿
  • 线上冒烟通过,标注图可公网访问
  • 文档:实测基线数值表 + 部署说明更新
  • 返回 data 含新增字段 hairline_sourcehead_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 仅看字节数、无需合法图片:

@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=600MIN_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 推算 / 占比公式。

# 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 值漂移大,说明尺度链路有问题。

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%。
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