Files
hair/docs/接口1-四庭七眼测量-技术实现方案.md
T
xslandClaude Opus 4.8 8d3b145111 feat(worker): 接口1 四庭七眼测量真实实现(替换 Mock)
worker 侧从 Mock 替换为真实算法:
- face_analysis 包:detector(MediaPipe 478点) / pose(solvePnP 姿态) /
  calibration(虹膜直径法) / hair_segmenter+bisenet_model(方案B 头发分割) /
  measure(方案A兜底+B/A决策+七眼+换算) / annotation(numpy渐变线+中文标注)
- app.py:/api/v1/face/measure 接真实实现,返回 annotated_image_base64
  (不落盘不拼URL,落盘由网关做);加 X-Internal-Token 鉴权、/health 就绪态、
  可配置分辨率门槛、异常兜底
- 部署:start.sh/run_worker.sh/hair-worker.service 监听 8187;worker_config 示例
- 测试 tests/:Tier1合成真值<1e-6 + Tier2缩放不变 + Tier3叠加 + 错误码集成 +
  数值回归,pytest 24 项全绿
- 文档补实测基线表 + RTX5090/torch 说明

注:worker 为 RTX 5090(sm_120),pinned torch 2.2.2(cu121) 只到 sm_90,
BiSeNet 已自动回退 CPU(方案B 正常);要用 GPU 需换 torch cu128(≥2.7)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:07:28 +08:00

40 KiB
Raw Blame History

接口 1:四庭七眼测量 — 技术实现方案

基于 MediaPipe Face Mesh468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底

📌 运行位置:本文档描述的全部算法逻辑运行在 高性能 workerGPU 机) 上,不在外网网关。系统已拆分为「外网网关 + worker」两层,详见 系统架构-网关与高性能后端.md。相对单机版有两处差异:

  1. GPU 加速BiSeNet 改用 CUDA 推理(torch GPU 版),MediaPipe 仍 CPU。
  2. 标注图返回 base64worker 不落盘、不拼 URL,把标注 PNG 以 annotated_image_base64 返回;落盘成 annotated_image_url 由网关完成(见架构文档 §9)。本文后续 §6/§8 的"保存到 static + 返回 URL"仅适用于单机版,拆分后改为返回 base64。
  3. 资源宽裕:32G + GPU,无需单机版的 2核4G 并发限制;worker 自身并发=1 由网关保证。

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 国内安装方式

# 使用清华镜像安装 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.3–1s/张)
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)。

权重需单独下载,放入 face_analysis/weights/,不入 git(写进 .gitignore):

  • 79999_iter.pth~53 MB)— BiSeNet 主权重。
  • resnet18-5c106cde.pth~45 MB)— BiSeNet 用的 resnet18 骨干。离线/内网环境必须预放BiSeNet 初始化时会尝试用 torch.utils.model_zoo 联网下载该骨干,内网会失败。需把它放进 torch hub 缓存(~/.cache/torch/hub/checkpoints/)或改代码从本地路径加载。

本仓库已预先下载好上述权重 + 字体(见根目录 OFFLINE_ASSETS.md 的 sha256 清单),内网机器无需联网,直接使用。


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 索引 说明
头顶 无直接索引 由发际线 + 顶庭比例向上推算
发际线 无直接索引 由眉心 + 上庭比例向上推算
眉心 9151 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 像素坐标恢复

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%),可作为天然标尺。

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

注意:虹膜关键点(索引 468–477)需要 FaceMesh(refine_landmarks=True) 才会输出。如果不启用 refine_landmarks,可用眼宽(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 2730 mm,精度略低。

⚠️ 透视局限(务必在 API 文档/返回里注明):虹膜法得到的 px_per_cm 只在虹膜所在的深度平面精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为近似值,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。

3.4 备用校准:人脸比例法

若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算:

# 基于三庭五眼理想比例
# 脸宽 (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(主):分割提取真实发际线 & 头顶

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 推算公式

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 像素 → 厘米转换

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. 七眼测量实现

七眼测量全部基于可直接检测的关键点(无需推算),精度较好。

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,
    }

占比计算

# 七眼比例(眼宽/脸宽,间距/脸宽)
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,画布尺寸与输入原图一致。

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 并用绝对路径加载。
    #    已打包 Noto Sans CJK SC= 思源黑体,同一套字体):face_analysis/fonts/NotoSansCJKsc-Regular.otf
    FONT_PATH = os.path.join(os.path.dirname(__file__), "fonts", "NotoSansCJKsc-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 向量化生成一行渐变像素后整行写入,快几个数量级:

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 画文字/箭头。

虚线带箭头

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/
│   │   └── NotoSansCJKsc-Regular.otf  # 打包的中文字体(= 思源黑体)
│   └── weights/              # 模型权重(不入 git,部署脚本拉取)
│       ├── 79999_iter.pth    # BiSeNet face-parsing 权重 ~53MB
│       └── resnet18-5c106cde.pth  # BiSeNet 骨干权重 ~45MB(离线必需,见下)
├── static/
│   └── annotations/          # 生成的标注 PNG 存放目录
├── .gitignore                # 忽略 face_analysis/weights/*.pth
└── requirements.txt

8.2 MediaPipe 封装 (detector.py)

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 集成

# 在 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")

    # 6. 拆分架构下:返回 base64,由网关落盘改写成 annotated_image_url(见架构文档 §9
    import base64
    data = result.to_response()
    data["annotated_image_base64"] = base64.b64encode(buf.getvalue()).decode()
    return ok(data)
    # —— 单机版(非拆分)才在此保存到 static/ 并返回 annotated_image_url ——

9. 误差分析与局限

误差来源 影响范围 估算误差 缓解措施
头顶/发际线推算 顶庭、上庭 cm 值 ±15% 基于实测中庭下庭比例自适应
虹膜直径个体差异 所有 cm 值 ±5% 左右眼平均;未来可接性别/年龄修正
非正面照 所有横向测量 ±20% 前置校验偏航角(yaw),过大则返回 1003
相机畸变 边缘区域坐标 ±3% 假设普通手机拍照,畸变可控
人脸比例个体差异 推算的发际线/头顶 ±10% 无完美解决方案,方案 A 的自然局限

前置姿态校验(检测是否为正面照):

旧版本靠「双眼 y 差 + 鼻尖偏移」的经验阈值(0.03/0.08),不可解释、难调。改用 cv2.solvePnP 解算真实头部欧拉角(yaw/pitch/roll,单位:度),阈值就能写成业务可读的"yaw>15° 拒绝",并把角度返回给前端做拍照引导。

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
# 拆分架构:worker 有 GPU → 用 CUDA 版 torch(按 worker 的 CUDA 版本选 whl
torch==2.2.2             # GPU(CUDA)版,例如 cu121--index-url https://download.pytorch.org/whl/cu121
torchvision==0.17.2

⚠️ numpy 锁版本mediapipe 0.10.x 对 numpy 2.x 支持不稳定,务必锁 numpy<2(已验证 1.26.4 可用)。先用此组合跑通,再考虑升级。

⚠️ torch GPU/CPU:拆分架构下 worker 有独立 GPU,用 CUDA 版 torch(按 worker 实际 CUDA 版本选对应 whl index,如 cu118/cu121),BiSeNet 推理走 GPU。单机/无 GPU 环境回退 CPU 版(--index-url .../whl/cpu~200MB)。BiSeNet 加载时把 .to('cuda' if torch.cuda.is_available() else 'cpu')

MediaPipe 0.10.x 的经典 Solutions API (mp.solutions.face_mesh) 仍稳定可用。如需迁移到 Tasks API,后续可平滑升级。


11. 待确认事项

  1. 标注图片设计稿:需求文档提到需要设计稿确认,当前 UI 规范(字体/颜色/线宽)按文档实现,后续可能需要根据设计师反馈微调
  2. 男女比例差异:是否需要在 cm 换算中区分性别(男女脸宽均值不同)?当前使用虹膜直径法天然与性别无关
  3. 顶庭占比:22% 为 Mock 数据值,实际部署后是否根据用户反馈调整比例参数
  4. 非正面照角度阈值:具体多少度算「角度过大」?建议前期收集测试数据后定阈值

12. 实测基线数值表(frontal.jpg682×811

worker 侧首次实现后的实测值,作为数值回归基线(tests/test_pipeline.py)。

方案 A(兜底,mask=None 方案 B(分割,主)
hairline_source estimated segmentation
顶庭 cm 5.06 6.51
上庭 cm 6.07 4.08
中庭 cm 7.23(实测) 7.23(实测)
下庭 cm 5.64(实测) 5.64(实测)
全脸高 cm 24.01 23.47
眼宽 cm 2.52 2.52
脸宽 cm 12.49 12.49
两眼间距 cm 3.24 3.24
px_per_cm(虹膜法) 15.06 15.06
head_pose (yaw/pitch/roll) 15.73 / 20.93 / 3.40 同左

说明:中/下庭、七眼、px_per_cm、姿态在两方案下一致(均为实测);顶/上庭方案 A 为 比例推算、方案 B 为分割实测,二者差异正体现"方案 B 取真实发际线/头顶"的价值。 数值回归测试固定走方案 A(确定性、与 torch 无关),容差 1%。

运行环境实测要点(worker = RTX 5090

  • GPU 架构兼容性:本 worker 为 RTX 5090compute capability 12.0 / Blackwell, sm_120。 锁定的 torch==2.2.2+cu121 仅编译到 sm_90,在 5090 上执行 CUDA 算子会报 CUDA error: no kernel image is availablehair_segmenter._select_device() 已做 一次小算子探测,失败自动回退 CPUBiSeNet CPU 推理约 0.31s/张,方案 B 正常可用)。
  • 要真正用上 5090 GPU:需换装支持 sm_120 的构建(torch cu128,≥2.7,配套 torchvision),代码无需改动(_select_device 会自动选 CUDA)。可设 FORCE_CPU=1 强制 CPU。

文档版本: v2.0
创建日期: 2026-06-13v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态)
依赖模型: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割)
测量策略: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)