Files
hair/docs/接口1-四庭七眼测量-技术实现方案.md
T
xsl 2e789d4efa docs: 架构拆分为外网网关+高性能worker(GPU)
- 新增 系统架构-网关与高性能后端.md: 网关薄代理(健康检查/空闲派发/鉴权/
  base64落盘改URL/无后端→1007) + worker(GPU跑完整app)职责划分、配置文件、
  鉴权(共享密码可轮换)、标注图base64流程、部署、安全注意
- 关键约束: 接口文档不变; 每worker并发=1, 总并发=健康worker数; 多worker可配置
- 技术方案: 加运行位置横幅; torch改GPU(CUDA); handler返回annotated_image_base64
- 任务书v1.4: 标注差异说明; 阶段八返base64+鉴权中间件; 阶段十worker(GPU)部署;
  新增§16网关工作流; DoD拆分worker侧/网关侧
- OFFLINE_ASSETS: 区分worker(GPU torch)与网关(轻量)依赖
2026-06-14 13:55:45 +08:00

38 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. 非正面照角度阈值:具体多少度算「角度过大」?建议前期收集测试数据后定阈值

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