资讯动态

PFLD人脸关键点检测:CPU实时运行的轻量模型与ONNX部署实践

发布时间:2026/10/4 7:31:52 来源:尧图企业网站定制
简介面向人脸关键点检测任务这套资源提供基于PFLD算法的完整Python实现覆盖模型设计、训练、测试、推理和模型转换全流程可直接加载预训练权重完成检测。压缩包共74个文件大小约89.88MB主体包括26个Python脚本、8个PyTorch预训练模型.pth、8个ONNX模型辅以MTCNN检测参数、标注规则图片、Markdown说明和文本标注数据。模型目录提供lite、v2、v3三种PFLD版本兼顾移动端轻量部署与高精度任务同时包含模型转换工具与ncnn优化相关脚本方便将PyTorch权重导出为ONNX并部署到不同推理框架。项目还给出了训练测试入口、数据集处理模块和106点人脸标注规则配套依赖清单与项目说明可帮助复现实验、微调模型或迁移到自定义数据集。目前已有123人学习下载适合计算机视觉学习者、算法工程师以及以人脸关键点检测为课题方向的学生参考。1. PFLD人脸关键点检测一个能在CPU上实时跑的轻量方案PFLDPractical Facial Landmark Detector是一个专为轻量部署设计的人脸关键点检测算法。它的优势很直接模型体积常见只有几MB到十几MB普通笔记本CPU上单帧推理只要几毫秒到十几毫秒不需要GPU也能跑实时。配合ONNX导出同一份模型还能跨Python、C、移动端复用。这个方案适合两类开发者一类是做实时人脸特效、表情分析、活体检测前处理要在摄像头链路加一个低延迟的预处理模块另一类是刚入门想借一份能跑通的Python源码把推理、转换、部署链路拉一遍。下面就从网络原理、源码实现到落地避坑把这条路走通。2. PFLD网络为什么轻量从骨干网络到损失函数的设计思路2.1 骨干网络把MobileNet的高效块做进特征提取主干PFLD的骨干网络设计思路和MobileNet一脉相承用depthwise separable convolution替代标准卷积降低FLOPs和参数量。具体来说PFLD主干由几个efficient blocks串联每个block内用1x1卷积升维、3x3 depthwise卷积提取空间特征、再1x1卷积降维中间配合ReLU激活。这套结构在图像分类任务里已经被验证过搬到关键点检测任务里依然有效因为它做的不是分类而是坐标回归特征图的感受野和空间分辨率比通道数更关键。在加载源码时你会在模型定义文件里看到类似下面这种结构这是各开源实现里常见的写法class Block(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.conv1 nn.Conv2d(in_channels, out_channels, 1, biasFalse) self.bn1 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) self.conv2 nn.Conv2d(out_channels, out_channels, 3, stride, 1, groupsout_channels, biasFalse) self.bn2 nn.BatchNorm2d(out_channels) self.conv3 nn.Conv2d(out_channels, out_channels, 1, biasFalse) self.bn3 nn.BatchNorm2d(out_channels) def forward(self, x): out self.relu(self.bn1(self.conv1(x))) out self.relu(self.bn2(self.conv2(out))) out self.bn3(self.conv3(out)) return out这里有几个参数值得注意groupsout_channels那个卷积层是 depthwise 卷积每个输入通道独立做空间卷积计算量从k×k×in×out降到k×k×in这是模型轻量的核心。stride参数控制下采样时机PFLD 在浅层用 stride2 快速缩小特征图深层保持 stride1 保住空间精度。如果你要改输入分辨率需要同步检查 stride 的累乘结果确保最终特征图尺寸能被预测头接受。2.2 损失函数Wing Loss 为什么比 MSE 更服管关键点检测本质是回归问题早期实现大多直接用 MSE Loss。但人脸关键点有一个特点误差小的时候MSE 的梯度也小模型对亚像素级别的偏差不敏感误差大的时候MSE 又容易让训练被少数离群点带偏。PFLD 论文里提出的 Wing Loss 就是针对这个问题设计的。Wing Loss 的数学形式是分段函数在误差小于阈值 w 的区间内用对数形式放大梯度超过阈值后退化为线性函数防止离群样本把梯度拉爆。这个设计和 Huber Loss 的思路正好相反——Huber 在小误差时给二次项、大误差时给一次项而 Wing Loss 在小误差时给更陡的对数项目的是让模型在收敛后期还能持续把 1~2 像素的小偏差压下去。在训练时除了 Wing LossPFLD 还引入了一个辅助网络auxiliary network用姿态估计的结果对关键点损失做加权。这个辅助网络只在训练阶段使用推理时会被摘掉。这也是你拿到源码后在模型定义里看到两个 forward 方法的原因——一个带姿态分支用于训练一个不带用于推理。导出 ONNX 时要用推理模式的 forward否则会把辅助网络的多余输出一起带进图里。2.3 输入输出约定关键点个数与坐标归一化PFLD 的输入输出格式在不同实现里有差异。论文原版使用 68 点WFLW 数据集上常见 98 点实现也有一些工程版本迁移到 5 点或 21 点用于专用场景。拿到这份「Python源码模型onnx」的压缩包时第一件事不是急着跑通而是确认你手里的模型输出层是多少个点。常见做法是打开 ONNX 文件查看输出张量形状用 Netron 打开模型文件看输出节点是batch × 19698 点还是batch × 13668 点。这个数字除以 2 就是关键点个数。输入节点通常是batch × 3 × 112 × 112也就是 112×112 的 RGB 图像。模型输出的坐标是归一化坐标范围约在 [-1, 1] 或 [0, 1] 之间需要乘以图像宽高才能得到像素坐标。提示如果你打开模型发现输出是batch × 196 3这种形状说明导出时把辅助网络的姿态输出也带上了。推理时要么把多余维度截掉要么在导出脚本里严格指定只导出主干输出。3. Python源码跑通推理从模型加载到关键点绘制的完整实现3.1 依赖清单与环境准备用onnxruntime比PyTorch推理省一半依赖跑推理有两条路径用 PyTorch 加载权重或者用 ONNX Runtime 加载 ONNX。既然压缩包里已经带了 onnx 文件我建议推理链路直接走 ONNX Runtime。原因很直接torch 的推理链会拖进来 CUDA、torchvision 等一系列依赖环境稍微一乱就出现版本不匹配而 onnxruntime 只需要onnxruntime、numpy、opencv-python三个包就能把推理链路跑通。在开始之前先把基础环境准备好python -m venv pfld_env source pfld_env/bin/activate # Windows 下执行 pfld_env\Scripts\activate pip install numpy opencv-python onnxruntime3.2 推理脚本预处理-推理-后处理三步核心脚本拆成三步来写。代码里用路径占位你需要把它改成压缩包解压后的实际路径。import cv2 import numpy as np import onnxruntime as ort MODEL_PATH pfld.onnx # 解压后的 onnx 文件实际路径 IMAGE_PATH test.jpg INPUT_SIZE 112 # 常见 PFLD 输入尺寸是 112x112 MEAN np.array([0.485, 0.456, 0.406], dtypenp.float32).reshape(1, 3, 1, 1) * 255.0 STD np.array([0.229, 0.224, 0.225], dtypenp.float32).reshape(1, 3, 1, 1) * 255.0 def load_model(): session ort.InferenceSession(MODEL_PATH, providers[CPUExecutionProvider]) return session def preprocess(img_bgr): # 训练数据通常是 RGBOpenCV 读出来是 BGR必须转换 img_rgb cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB) img_rgb cv2.resize(img_rgb, (INPUT_SIZE, INPUT_SIZE), interpolationcv2.INTER_AREA) img_rgb img_rgb.astype(np.float32) img_rgb img_rgb / 255.0 img_rgb (img_rgb - MEAN[0].transpose(1, 2, 0)) / STD[0].transpose(1, 2, 0) x img_rgb.transpose(2, 0, 1)[None] # HWC - CHW - NCHW return np.ascontiguousarray(x, dtypenp.float32) def postprocess(output, img_w, img_h): landmarks output.reshape(-1, 2) # 假设模型输出范围在 [-1, 1]映射回原图尺寸 landmarks[:, 0] (landmarks[:, 0] 1) / 2.0 * img_w landmarks[:, 1] (landmarks[:, 1] 1) / 2.0 * img_h return landmarks def draw_landmarks(img, landmarks): for x, y in landmarks: cv2.circle(img, (int(x), int(y)), 2, (0, 255, 0), -1) return img if __name__ __main__: session load_model() img cv2.imread(IMAGE_PATH) input_tensor preprocess(img) outputs session.run(None, {session.get_inputs()[0].name: input_tensor}) landmarks postprocess(outputs[0], img.shape[1], img.shape[0]) result draw_landmarks(img, landmarks) cv2.imwrite(result.jpg, result)逻辑说明preprocess里先 BGR 转 RGB 匹配训练数据的通道顺序再缩放到 112×112最后做(x / 255.0 - mean) / std。这里的 MEAN 和 STD 是常见的 ImageNet 统计值但你具体拿到的模型可能在训练时用的是x / 255.0 - 0.5这类简单归一化两条路如果混用关键点坐标会整体偏移。判断标准很简单跑一张人脸图如果关键点位置错乱或整体漂移优先检查这一步和训练代码是否一致。postprocess里的(x 1) / 2.0 * img_w把模型输出从归一化坐标映射回原图尺寸这里假设范围是 [-1, 1]。如果模型实际输出范围是 [0, 1]映射公式要改成x * img_w具体看模型训练时的约定。session.run第二个参数用session.get_inputs()[0].name取输入名而不是硬编码字符串因为不同导出工具生成的输入节点名可能是input、data或x。3.3 后处理细节从归一化坐标到原图坐标的映射陷阱刚才脚本里的后处理存在一个隐患它假设输入的人脸图已经是对齐好的 112×112。真实场景中摄像头画面里往往是一张完整的人脸你需要先做人脸检测把脸裁剪出来再送入 PFLD。此时如果直接用原图的 shape 做映射关键点坐标会和裁剪框的坐标系对不上。正确做法是把 PFLD 的输出映射回裁剪框坐标系再换算到原图。假设人脸检测框是(x0, y0, w, h)PFLD 输入是从原图上裁剪并缩放的 112×112 图那么逆变换公式为landmarks_pixel np.zeros_like(landmarks_norm) # 先把归一化坐标映射到 112x112 输入图 landmarks_pixel[:, 0] (landmarks_norm[:, 0] 1) / 2.0 * 112 landmarks_pixel[:, 1] (landmarks_norm[:, 1] 1) / 2.0 * 112 # 再映射回原图注意缩放比例 scale_x w / 112.0 scale_y h / 112.0 landmarks_pixel[:, 0] landmarks_pixel[:, 0] * scale_x x0 landmarks_pixel[:, 1] landmarks_pixel[:, 1] * scale_y y0这里还有一个人脸框容易被忽略的点人脸检测框通常比较贴脸而下巴关键点经常超出检测框底边。如果直接按检测框裁剪再缩放下巴处的关键点会被裁掉模型只能凭上下文猜位置输出会很飘。常见做法是对人脸框做 20%~50% 的外扩特别是下巴方向让裁剪区域留出余量。这个外扩比例是一个超参数我一般先扩 30%再根据可视化结果微调。4. PyTorch转ONNX导出全流程动态输入轴与算子兼容处理4.1 导出前的模型检查把PFLD的forward输出对齐在跑导出之前先做一次纯 PyTorch 的推理确认模型能正常产出关键点。这里最容易翻车的地方是PFLD 源码里写了一个带辅助输出的 forward导出时没有切换导致 ONNX 里多出一个姿态分支的输出张量。检查方法很简单把模型加载后直接打印前向传播的输出形状。import torch from pfld_model import PFLDInference # 以实际源码模块名为准 model PFLDInference() checkpoint torch.load(checkpoint.pth.tar, map_locationcpu) model.load_state_dict(checkpoint[state_dict] if state_dict in checkpoint else checkpoint) model.eval() x torch.randn(1, 3, 112, 112) with torch.no_grad(): y model(x) if isinstance(y, (tuple, list)): print(模型输出是列表长度为, len(y)) else: print(模型输出是张量形状为, y.shape)如果你发现y是列表说明当前 forward 同时输出关键点和姿态。处理方式有两种一是直接取y[0]作为导出目标二是在模型定义里单独写一个只走主干网络的 forward 推理方法。第二种更稳妥因为 torch.onnx.export 默认只导出被调用到的 Tensor 操作如果在 forward 里返回了一个用不上的姿态张量ONNX 图里也会包含对应节点增加转换失败概率。4.2 导出脚本torch.onnx.export的动态轴配置确认输出只有一组关键点张量之后就可以执行导出。下面这段对应的是常见做法我一般会加opset_version和动态输入轴两个配置。import torch from pfld_model import PFLDInference model PFLDInference() checkpoint torch.load(checkpoint.pth.tar, map_locationcpu) model.load_state_dict(checkpoint[state_dict] if state_dict in checkpoint else checkpoint) model.eval() dummy_input torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, pfld.onnx, export_paramsTrue, opset_version11, do_constant_foldingTrue, input_names[input], output_names[landmarks], dynamic_axes{ input: {0: batch}, landmarks: {0: batch} } )参数说明dummy_input必须是(1, 3, 112, 112)的随机张量用于构建计算图batch 维用 1 即可不需要真的传入多张图。opset_version11是一个兼容性很好的版本opset 太新在低版本 onnxruntime 里可能不支持太旧则一些算子会被拆成多个小算子增加图复杂度。dynamic_axes声明 batch 维是动态的。如果不加这个配置ONNX 模型会被固定为 batch1部署时换 batch 会直接报错。如果只做单张图推理也可以不加但加上没坏处建议保留。input_names和output_names用于控制节点名后续在 C 或移动端加载时不用去查 ONNX 图里的默认名称。导出后用 onnx 库做一次检查import onnx model_onnx onnx.load(pfld.onnx) onnx.checker.check_model(model_onnx) print(检查通过输出节点:, [out.name for out in model_onnx.graph.output])4.3 导出后验证用onnxruntime对比PyTorch输出误差导出成功不等于导出正确。模型图里的数值精度可能有细微差异因为某些算子在 PyTorch 和 ONNX Runtime 里的实现方式不同或者常量折叠带来浮点重排。验证方法是对同一张输入分别用 PyTorch 和 ONNX Runtime 推理比较输出的最大绝对误差。import numpy as np import onnxruntime as ort import torch test_input np.random.randn(1, 3, 112, 112).astype(np.float32) with torch.no_grad(): torch_out model(torch.from_numpy(test_input)) torch_out torch_out[0] if isinstance(torch_out, (tuple, list)) else torch_out torch_out torch_out.numpy() session ort.InferenceSession(pfld.onnx, providers[CPUExecutionProvider]) ort_out session.run(None, {input: test_input})[0] diff np.abs(torch_out - ort_out) print(最大误差:, diff.max()) print(均值误差:, diff.mean())通常最大误差在 1e-4 量级甚至更小。如果发现误差超过 1e-2先确认输入的 batch 维是否匹配、动态轴是否正确声明以及模型是否处于 eval 模式。BatchNorm 在 train 模式和 eval 模式下的行为完全不同导出前忘记model.eval()是这一步最经典的错误。提示对比用的输入张量必须完全一致。不要把「生成 PyTorch 随机输入」和「生成 ONNX 随机输入」分两次写那样对比的其实是两个不同输入下的出力之差没有意义。5. PFLD部署避坑归一化方式、人脸框与模型输入不匹配5.1 归一化方式不一致关键点整体偏移 20 个像素现象模型输出的关键点位置大致正确但所有点整体向某个方向偏移而且偏移量随人脸在画面中的位置变化。原因训练时用的归一化方式和推理脚本里不一致。常见有几种组合x / 255、(x / 255 - 0.5) / 0.5、(x / 255 - mean) / std。如果训练代码用的是第二种而推理时只做了x / 255输入分布完全错位模型输出的坐标自然跟着偏。解决回到源码仓库里找训练脚本的 transform 定义原样复制到推理脚本。如果训练代码已经丢了就试三种最常见的归一化组合分别推理看哪一组的可视化结果对齐得最准。这个办法很土但有效关键点输出对输入分布极其敏感。5.2 人脸框是长方形关键点被压到图像边缘之外现象检测框头部区域的关键点正常下巴轮廓上的点全部挤在一起且坐标明显超出人脸框范围。原因第 3.3 节提到的裁剪问题。原始人脸检测框横纵比通常接近 0.75~1.0但很多检测器为了贴合头部高度方向会紧贴下巴。PFLD 的 112×112 输入是正方形裁剪长方形人脸框后直接 resize人脸被纵向拉长或横向压扁下巴点已经掉出裁剪区域。解决裁剪前先按长边把检测框扩展成正方形。具体做法是取检测框的长边作为边长以检测框中心为原点裁一个正方形区域。如果正方形区域超出画面边界用边缘像素填充。5.3 动态轴没配置batch不等于1就报错现象ONNX 模型单张跑通脚本里改成批量推理比如np.zeros((4, 3, 112, 112))就报 shape mismatch。原因导出时没有声明dynamic_axes模型输入被固定为(1, 3, 112, 112)。这在做多人脸对齐时很常见一帧画面里有 5 张脸想一次性跑 5 个推理结果直接报错。解决重新导出并加上 4.2 节里的dynamic_axes配置。如果模型已经部署到别的机器上不方便重导出也可以用循环逐张推理但吞吐量会下降。批量推理在 CPU 上的速度提升有限先确认你的场景真的需要批处理。5.4 OpenCV读图通道顺序BGR/RGB不一致导致检测漂移现象关键点在额头、眼睛区域正常嘴角和下巴区域偏移明显且不太像随机噪声更像系统性的上移。原因OpenCV 的cv2.imread读出来是 BGR 三通道而大多数 PFLD 训练代码用的是 RGB 输入。直接用 BGR 图喂模型等于把红色通道和蓝色通道的数据交换了。对关键点任务来说这会扰动肤色纹理的统计分布导致局部特征响应偏移。解决在预处理里无条件加一行cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。有个检测技巧分别用 BGR 和 RGB 各跑一遍对比两版输出的关键点坐标如果差异明显大于 1 个像素基本就是通道顺序问题。5.5 自定义算子导出失败把自定义模块替换成原生算子现象执行 torch.onnx.export 时抛出错误报错信息指向某个未识别的算子比如预测头里有一个训练阶段才用的自定义层。原因有些 PFLD 改进版本会在预测头里加入自定义模块比如用于特征融合的注意力层。PyTorch 的很多原生模块可以被 torch.onnx 直接翻译成 ONNX 算子但自定义模块如果没有实现 symbolic导出器就不知道该把它映射成什么。解决把自定义模块替换为等效的原生算子组合。比如一个自定义的全局平均池化加全连接结构直接用torch.nn.AdaptiveAvgPool2d和torch.nn.Linear重写。如果替换后精度下降再逐层对比替换前后的输出定位是哪一层引入的差异。6. 关键点输出更稳时序平滑与ONNX量化两个实用技巧6.1 视频流中的关键点抖动抑制单帧推理的关键点已经准了但做成视频流之后会发现眼角、嘴角的小点会像噪声一样抖动。这是因为模型每帧独立推理帧与帧之间的亚像素误差没有关联。在视频流里加一阶低通滤波是常见解法smoothed None alpha 0.6 for frame in video_stream: landmarks infer(frame) if smoothed is None: smoothed landmarks else: smoothed alpha * smoothed (1 - alpha) * landmarksalpha越大曲线越平滑但延迟越高alpha越小跟随越快但抖动越多。人脸关键点任务里 0.5~0.7 是一个比较合适的区间。如果做美颜类特效可以调到 0.8 以上因为用户对延迟不敏感而对平滑很敏感如果做表情驱动建议保持 0.5 左右避免表情变化被压制。6.2 ONNX Runtime 的 INT8 量化加速CPU 部署时如果还想再压一截推理延迟可以试 ONNX Runtime 的 INT8 动态量化。动态量化不需要校准数据集只要一个 Python 脚本处理成本很低from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputpfld.onnx, model_outputpfld_int8.onnx, weight_typeQuantType.QUInt8 )量化后模型体积大约缩小到原来的 1/4CPU 推理速度在支持 INT8 指令的处理器上通常有 30%~80% 的提升。但关键点回归任务对数值精度比分类任务敏感量化后最大误差可能从 1e-4 上升到 1e-2。如果关键点在画面上开始肉眼可见地抖就回退到 FP32 版本。这是我在几个项目里反复用到的两条经验一是导出 ONNX 前先确认 forward 方法不携带训练分支二是视频流里的平滑处理一定要做成可调参数而不是写死。这两件事在项目初期看起来都是小事但到联调阶段它们往往会决定体验好不好用。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑