# 接口 1:四庭七眼测量 — 技术实现方案 > 基于 MediaPipe Face Mesh(468 关键点)测量「眉心以下」+ 人脸解析分割(BiSeNet)获取「真实发际线/头顶」+ 人脸比例先验作为兜底 > 📌 **运行位置**:本文档描述的全部算法逻辑运行在 **高性能 worker(GPU 机)** 上,不在外网网关。系统已拆分为「外网网关 + worker」两层,详见 [`系统架构-网关与高性能后端.md`](系统架构-网关与高性能后端.md)。相对单机版有两处差异: > 1. **GPU 加速**:BiSeNet 改用 CUDA 推理(torch GPU 版),MediaPipe 仍 CPU。 > 2. **标注图返回 base64**:worker **不落盘、不拼 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 国内安装方式 ```bash # 使用清华镜像安装 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 | **选型:BiSeNet(face-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 或 151(glabella,双眉间) │ 中庭 (~28%) ★ 鼻翼下缘 (nose_bottom) ← 索引 94(subnasale / 人中顶部) │ 下庭 (~25%) ★ 下巴尖 (chin_tip) ← 索引 152(menton) ``` | 测量点 | MediaPipe 索引 | 说明 | |--------|----------------|------| | 头顶 | **无直接索引** | 由发际线 + 顶庭比例向上推算 | | 发际线 | **无直接索引** | 由眉心 + 上庭比例向上推算 | | 眉心 | **9** 或 **151** | 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 像素坐标恢复 ```python 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%),可作为天然标尺。 ```python 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`,可用**眼宽**(外眼角→内眼角)作为替代标尺,人类平均眼裂宽度约 **27–30 mm**,精度略低。 > ⚠️ **透视局限(务必在 API 文档/返回里注明)**:虹膜法得到的 `px_per_cm` 只在**虹膜所在的深度平面**精确。下巴、额头、头顶与虹膜不共面,2D 照片存在透视投影,因此纵向(四庭)的 cm 换算会带系统误差,离虹膜平面越远(如头顶)误差越大。返回的 cm 值应理解为**近似值**,而非全脸恒定尺度下的精确测量。比例(ratio)受透视影响小于绝对 cm 值,建议前端优先展示比例。 ### 3.4 备用校准:人脸比例法 若虹膜数据不可用,也可用 460 点基础模型的脸宽比例估算: ```python # 基于三庭五眼理想比例 # 脸宽 (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(主):分割提取真实发际线 & 头顶 ```python 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 推算公式 ```python 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 像素 → 厘米转换 ```python 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. 七眼测量实现 七眼测量全部基于可直接检测的关键点(无需推算),精度较好。 ```python 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, } ``` ### 占比计算 ```python # 七眼比例(眼宽/脸宽,间距/脸宽) 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,画布尺寸与输入原图一致。 ```python 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 向量化生成一行渐变像素后整行写入,快几个数量级: ```python 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` 画文字/箭头。 ### 虚线带箭头 ```python 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 # 方案 B:BiSeNet 头发分割封装 │ ├── 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`) ```python 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 集成 ```python # 在 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° 拒绝",并把角度返回给前端做拍照引导。 ```python 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.jpg,682×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 5090(compute capability 12.0 / Blackwell, sm_120)**。 锁定的 `torch==2.2.2+cu121` 仅编译到 **sm_90**,在 5090 上执行 CUDA 算子会报 `CUDA error: no kernel image is available`。`hair_segmenter._select_device()` 已做 一次小算子探测,失败自动回退 **CPU**(BiSeNet CPU 推理约 0.3–1s/张,方案 B 正常可用)。 - **要真正用上 5090 GPU**:需换装支持 sm_120 的构建(**torch cu128,≥2.7**,配套 torchvision),代码无需改动(`_select_device` 会自动选 CUDA)。可设 `FORCE_CPU=1` 强制 CPU。 --- > **文档版本**: v2.0 > **创建日期**: 2026-06-13(v2.0 修订:修复循环论证/分辨率/字体/numpy 等问题,引入方案 B 分割 + solvePnP 姿态) > **依赖模型**: MediaPipe Face Mesh (468 landmarks) + BiSeNet face-parsing (头发分割) > **测量策略**: 眉心以下实测关键点 + 方案 B 分割取真实发际线/头顶(方案 A 比例推算兜底)