简介基于PFLD算法的人脸关键点检测完整工程面向计算机视觉开发者与课程设计人群提供从模型原理到落地部署的整套参考。压缩包共74个文件涵盖26个Python源码、8个预训练pth模型、8个ONNX模型以及说明文档、图片样例、ncnn优化工具等整体约89.88MB目录按模块划分便于按需查阅。目前已有123人学习浏览可见热度与实用价值兼备。工程内实现PFLD、Ghost-PFLD、MobileV3-PFLD等多种网络结构集成MTCNN人脸检测、ONNX导出及ncnn优化脚本并提供训练测试入口、预处理工具、预训练权重及106点标注规则说明。开发者可直接加载模型进行人脸关键点检测也可依据注释学习算法细节并将ONNX模型灵活部署到不同推理框架适合算法对比与二次开发。1. 为什么 PFLD 是人脸关键点检测里最值得先跑通的那一个PFLDPractical Facial Landmark Detector是 2019 年提出的一套人脸关键点检测算法但放到今天依然很能打。它用极小的模型体积换到了接近大模型的精度尤其在大姿态抬头、侧脸、低头场景下不容易翻车这也是它名字里 Practical 的底气来源。这个项目包里的东西很直接一组能跑的 Python 源码、训练好的模型权重以及导出好的 ONNX 文件。你不需要从零复现论文也不需要自己标数据解压就能跑通。适合谁做实时人脸对齐、表情分析、头部姿态估计或者想把关键点检测塞进移动端和边缘设备的工程师。接下来我按自己落地的顺序把网络结构、推理脚本、ONNX 导出和踩过的坑完整过一遍。2. PFLD 的网络结构与损失函数先拆开黑匣子再动手2.1 主干网络从 MobileNet 借来的深度可分离卷积PFLD 的主干是一个类 MobileNet 的轻量结构由三个阶段的特征提取模块组成。第一阶段用普通 3×3 卷积快速下采样第二和第三阶段全部使用深度可分离卷积Depthwise Separable Convolution也就是先对每个通道单独做卷积再用 1×1 点卷积跨通道融合。这种做法的计算量只有标准卷积的八分之一到九分之一模型体积自然就压下来了。你如果打开源码里的网络定义文件会看到类似这样的结构定义class Block(nn.Module): def __init__(self, in_channels, out_channels, stride1): super().__init__() self.depthwise nn.Conv2d( in_channels, in_channels, kernel_size3, stridestride, padding1, groupsin_channels, biasFalse ) self.pointwise nn.Conv2d( in_channels, out_channels, kernel_size1, stride1, biasFalse ) self.bn1 nn.BatchNorm2d(in_channels) self.bn2 nn.BatchNorm2d(out_channels) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.depthwise(x))) x self.relu(self.bn2(self.pointwise(x))) return x这里的核心参数是groupsin_channels它把卷积核拆到每个输入通道上独立运算这一步是深度可分离卷积的关键。如果你以前没接触过轻量网络记住一句话depthwise 负责提取空间特征pointwise 负责通道间的线性组合两者拆开之后参数量大幅下降。PFLD 在 112×112 的输入下整网参数量大约只有 5M 上下FP32 模型也就十几 MB。这个体量对嵌入式设备非常友好也是它敢说“Practical”的原因。2.2 辅助网络让大姿态不翻车只看主干网络还不够PFLD 论文里最有价值的设计其实是一个辅助网络分支。主干网络在提取特征的同时会额外引出一个辅助分支专门预测头部的三个欧拉角偏航角Yaw、俯仰角Pitch和翻滚角Roll。这三个角度的预测结果并不直接输出给用户而是用来计算损失函数的动态权重。原理很简单正脸样本容易学侧脸和大姿态样本难学。如果不加区分地让所有样本用同样的权重回传梯度网络会偏向容易的样本大姿态下的关键点就预测不准。PFLD 的做法是让辅助网络输出一个姿态向量 φ再把这个向量映射成一组加权系数对难样本加大惩罚对易样本减小惩罚。这段逻辑在训练代码里大致长这样# 伪代码说明辅助网络如何参与损失计算 landmarks_pred, angle_pred model(input_img) # 计算关键点回归的 Wing loss loss_landmark wing_loss(landmarks_pred, landmarks_gt) # 用姿态角度对每个样本的损失加权 # angle_error 越大说明样本姿态越难权重越高 angle_error torch.abs(angle_pred - angle_gt).sum(dim1) weight 1.0 0.5 * angle_error # 实际实现会用一个可学习的变换 loss (loss_landmark * weight).mean()这段逻辑说明一个重要的点如果你只想拿现成模型做推理辅助网络对你没有影响权重已经融合进模型参数里了。但如果你打算用自己的数据微调辅助分支千万不能砍掉否则大姿态精度会明显下降。2.3 Wing Loss离群点不再支配梯度关键点回归常见的损失是 L1 或 L2 Loss但 PFLD 用的是 Wing Loss。L2 Loss 对大误差的惩罚是平方级的少数标注偏差大的点会主导整个梯度更新训练后期很难收敛到精确定位L1 Loss 虽然对大误差更平滑但在小误差区域不够敏感关键点容易在真实位置附近来回抖动。Wing Loss 的曲线就是专门针对这个问题设计的在小误差区间用对数曲线放大梯度让网络在小误差时也能持续收敛在大误差区间退化成线性减少离群点对梯度的支配。公式写出来是三段import torch def wing_loss(pred, target, w10.0, epsilon2.0): Wing Loss 实现 w: 线性区与对数区的分界点 epsilon: 对数区的平滑因子 diff pred - target abs_diff torch.abs(diff) mask abs_diff w loss torch.where( mask, w * torch.log(1.0 abs_diff / epsilon), abs_diff - w w * torch.log(1.0 w / epsilon) ) return loss.mean()参数w和epsilon是论文里写死的吗不全是。原始论文用的 w10、epsilon2但如果你在跑自己的数据集可以观察训练曲线调整。w太大时对数区过宽模型对精小误差不敏感epsilon太小则对数曲线太陡接近 L1 的行为。w10是一个经过了 WFLW 和 300W 两个数据集验证的经验值多数场景不用动。注意这个损失函数在训练阶段才需要推理阶段完全不会用到。你不用为跑 ONNX 模型去理解它但调试模型精度时一定绕不开。3. 本地跑通 PFLD环境、依赖与最小推理脚本3.1 创建虚拟环境与安装依赖我一般会在 Python 3.8 以上的环境里跑 PFLD。理论上 Python 版本只要比 3.6 新都行但 PyTorch 和 onnxruntime 对旧版的支持已经逐步收紧了别在 3.6 上折腾。如果你机器上同时有多个 Python 版本强烈建议用 venv 建一个独立环境避免把系统 Python 装乱。# 创建虚拟环境 python -m venv pfld_env # 激活环境 # Windows pfld_env\Scripts\activate # Linux / macOS source pfld_env/bin/activate # 安装依赖 pip install numpy opencv-python torch torchvision onnxruntime这段命令里每个包都不是可有可无的numpy负责张量运算和数据转换opencv-python负责读图和图像预处理torch用来加载 PyTorch 格式的模型权重onnxruntime用来跑 ONNX 推理。如果只是推理不训练不需要装torchvision但装上有备无患后面导出模型时可能会用到。有一类错误是运行时报找不到cv2或torch多半是 pip 安装到了别的 Python 环境里。用which python和python --version确认当前环境是哪一个再装依赖能省掉很多冤枉时间。3.2 加载模型并跑通单张图片的人脸关键点检测整个推理链路分四步读图、人脸检测、关键点检测、后处理。PFLD 本身只负责第三步它接收一张已经裁剪好的人脸图块尺寸固定为 112×112 的三通道 RGB 图像输出一组关键点坐标。先写一个最小推理脚本用 PyTorch 加载源文件里给到的.pth.tar权重import torch import cv2 import numpy as np from models.pfld import PFLDInference # 根据源码结构调整 import 路径 # 加载模型 model PFLDInference() checkpoint torch.load(checkpoint.pth.tar, map_locationcpu) model.load_state_dict(checkpoint[model] if model in checkpoint else checkpoint) model.eval() # 读取图片并裁剪人脸区域这里假设你已经有一个 112x112 的人脸图块 img cv2.imread(face.jpg) # 如果还没有人脸框先用 Opencv 的人脸检测器或 MTCNN 等检测 crop cv2.resize(img, (112, 112)) crop cv2.cvtColor(crop, cv2.COLOR_BGR2RGB) crop crop.astype(np.float32) / 255.0 crop (crop - 0.5) / 0.5 # 归一化到 [-1, 1] # 调整维度为 (1, 3, 112, 112) input_tensor torch.from_numpy(crop.transpose(2, 0, 1)).unsqueeze(0) with torch.no_grad(): landmarks, angle model(input_tensor) # 关键点坐标是归一化后的值还原到原图尺寸 landmarks landmarks.numpy().reshape(-1, 2) h, w img.shape[:2] landmarks[:, 0] (landmarks[:, 0] 0.5) * crop.shape[1] # 这里的 crop 尺寸要按缩放关系换算 landmarks[:, 1] (landmarks[:, 1] 0.5) * crop.shape[0]这段脚本里最容易被忽视的是归一化步骤。PFLD 在训练时把图像像素从[0, 255]先缩放到[0, 1]再减去 0.5 除 0.5也就是说模型期望的输入范围是[-1, 1]。你如果直接拿原始像素值喂进去关键点会偏得非常离谱而且这种偏移不是整体平移是每个点都往某个方向散开看起来像是模型坏了。关于关键点坐标还原模型输出的坐标同样是在[-1, 1]范围内的归一化值或者是[0, 1]取决于你拿到的权重训练时怎么定义的。源包里提供的模型一般会在 README 或者推理脚本里注明你跑通了之后拿一张人脸图验证一下即可。3.3 后处理从模型输出到可视化的人脸画线关键点还原到原图坐标之后通常要做两件事可视化验证、计算对齐矩阵。可视化验证最简单直接在图上画点即可# 继续上一段代码 import matplotlib.pyplot as plt for (x, y) in landmarks: cv2.circle(img, (int(x), int(y)), 1, (0, 255, 0), -1) plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) plt.show()如果你用的是 68 点标注PFLD 的输出就会是 68 对坐标如果是 WFLW 数据集的 98 点就是 98 对坐标。拿到点之后能不能直接画线要看你拿到的模型对应的是哪种标注拓扑。68 点和 98 点的连线规则不一样不建议你按通用的人脸轮廓去连最好找到模型训练时用的数据集标注定义照着它的索引关系连线。我在跑通第一个模型后做的第一件事不是看精度而是检查关键点是否对齐到眉毛、眼睛、嘴角这些特征位置。如果全图的关键点都往左偏十有八九是输入归一化不对如果只有眼睛区域偏可能是预处理时人脸框没有对准或者图片被缩放时没有保持宽高比。PFLD 对宽高比敏感人脸图块如果被拉伸变形关键点的位置会跟着偏移。4. 将 PyTorch 模型导出为 ONNX跨平台部署的关键一步4.1 固定输入尺寸还是动态输入尺寸源包里直接提供了 ONNX 文件但你在实际落地时经常会遇到一个问题想用自己训练好的模型导出或者想改输入尺寸重新导出。这时候就必须理解 ONNX 导出的几个核心参数。ONNX 模型是静态图导出的输入张量尺寸可以固定也可以动态。PFLD 这类关键点检测模型输入尺寸固定为(1, 3, 112, 112)就好不需要动态 batch也不需要在运行时改变尺寸。理由有两个一是模型的 backbone 里所有卷积都是全卷积结构理论上支持任意输入尺寸但最后一个全连接层的特征维度是固定的输入尺寸变了特征图大小就变了全连接层会报维度错误二是实际推理场景里人脸图块就是要缩放到固定尺寸的动态输入纯属给自己找麻烦。如果你确实想支持多尺寸输入也得确保模型的全局平均池化层把特征图压到了固定维度。PFLD 模型一般会在最后做全局平均池化再进全连接所以只要池化层是 AdaptiveAvgPool理论上可以支持动态输入但这种做法在 ONNX Runtime 里偶尔会遇到算子兼容性问题不建议新手尝试。4.2 torch.onnx.export 的实际参数与算子兼容导出 ONNX 的脚本通常写在export_onnx.py文件里核心代码如下import torch import torch.onnx # 加载训练好的模型 model PFLDInference() checkpoint torch.load(checkpoint.pth.tar, map_locationcpu) model.load_state_dict(checkpoint[model] if model in checkpoint else checkpoint) model.eval() # 构造一个固定尺寸的随机输入 dummy_input torch.randn(1, 3, 112, 112) # 导出为 ONNX torch.onnx.export( model, dummy_input, pfld.onnx, input_names[input], output_names[landmarks, angle], opset_version11, dynamic_axesNone, do_constant_foldingTrue )这里的opset_version是导出时最容易出问题的参数。onnxruntime 一般已经支持到 opset 15 以上但 PFLD 的模型结构比较简单主要算子就是 Conv、BatchNorm、ReLU、全连接opset 11 就够用了。如果你用了 PyTorch 新版本默认的 opset 可能比较高而目标设备上的 onnxruntime 版本如果比较旧会报不兼容的错误。我一般先固定用 11遇到报错再逐步升到 13 或 15不要一上来就选最新版。dynamic_axes是另一个关键参数。如果设置{input: {0: batch}}导出的模型就支持任意 batch 大小但代价是 onnxruntime 在推理时需要额外的 shape 推断开销速度略慢。单张图片推理场景直接用固定 batch 即可。do_constant_foldingTrue会把模型里某些常量计算在导出时直接算好比如 BatchNorm 的权重和偏置可以合并到卷积层里这个选项建议打开能让 ONNX 模型体积更小、推理更快。注意导出后一定要检查输出名字。PFLD 两个输出landmarks和angle在 onnxruntime 里推理时拿到的顺序可能跟 PyTorch 里 forward 的返回顺序不完全一致最好打印一下 outputs 的 shape 确认。4.3 用 ONNX Runtime 验证导出结果导出 ONNX 并不代表万事大吉PyTorch 能算的 ONNX 不一定能算。验证的可靠方式是跑同一张图对比 PyTorch 和 onnxruntime 的输出差异。import onnxruntime as ort import numpy as np import torch # 用同一份输入 input_data np.random.randn(1, 3, 112, 112).astype(np.float32) # PyTorch 推理 model.eval() with torch.no_grad(): torch_outputs model(torch.from_numpy(input_data)) torch_landmarks torch_outputs[0].numpy() # ONNX Runtime 推理 session ort.InferenceSession(pfld.onnx, providers[CPUExecutionProvider]) ort_landmarks session.run([landmarks], {input: input_data})[0] # 比较最大绝对误差 max_error np.max(np.abs(torch_landmarks - ort_landmarks)) print(fmax abs error: {max_error:.6f})这一步的关键是误差阈值。PFLD 输出的关键点坐标如果在[-1, 1]范围内PyTorch 和 onnxruntime 之间的最大绝对误差应该小于1e-4通常能到1e-6量级。误差如果到了0.01以上说明导出过程中有算子发生了数值变化比如某些融合操作改变了计算顺序。这时候先把do_constant_folding关掉再试如果还是大误差逐个算子排查重点检查 BatchNorm 的 epsilon 参数是否被 ONNX 导出改变。5. 避坑从模型加载到部署最容易翻车的五个地方5.1 pip 安装依赖版本冲突torch 和 onnxruntime 互相看不顺眼现象跑推理脚本时 onnxruntime 报DLL load failed或者No module named torch但明明刚装完依赖。原因PyTorch 和 onnxruntime 都对底层的 protobuf、numpy 版本有要求。如果你先装了 torch再装 onnxruntimepip 可能会把 numpy 升到新版和 torch 依赖的版本不兼容反过来也可能把 numpy 降回去导致 onnxruntime 挂掉。解决创建独立虚拟环境后按顺序装先装 torch再装 onnxruntime装完用pip check检查依赖关系是否完整。如果装完 numpy 版本被改动过了手动指定版本装回 torch 兼容的版本。血的教训是不要图省事用全局环境翻车概率极高。5.2 图像归一化不一致导致关键点漂移现象模型能跑关键点也能画出来但画出来的点整体偏移比如鼻子附近的点跑到脸颊上。原因训练和推理时像素值的预处理不一致。PFLD 训练时用的归一化是(x / 255 - 0.5) / 0.5如果你用x / 255.0或者干脆不归一化直接喂原图输入分布就完全不同了。解决对照源码里训练时的数据加载逻辑找出它用的 mean、std 或者归一化区间然后在推理脚本里完全照搬。最简单的方法是查看训练代码里transforms.Normalize的参数照着写。这个坑最常见的来源是换了光流算法或者换了 OpenCV 版本后BGR 和 RGB 顺序没转对。PFLD 训练用的图像是 RGB 三通道但 OpenCV 默认读进来是 BGR忘记cvtColor会让所有关键点在颜色通道上出现微小偏移虽然看起来差不多但精度就是上不去。5.3 ONNX 导出的动态轴设置错误导致推理失败现象导出的 ONNX 在 ONNX Runtime 里报错ValueError: Input input of shape (1,3,112,112) doesnt match the expected shape (None,3,112,112)或类似错误。原因导出时指定了dynamic_axes{input: {0: batch}}但 onnxruntime 的某些优化 pass 在动态 batch 下无法执行导致会话初始化失败。解决单图推理场景直接删掉dynamic_axes参数用固定维度导出。只有确有多 batch 推理需求时才启用动态维度而且要在 onnxruntime 里开启SessionOptions的图优化级别到ORT_ENABLE_ALL之前先测一下兼容性。5.4 人脸框不缩边带来的关键点偏移现象模型在标准人脸检测数据集上精度不错但在你实际业务数据上眼睛、嘴角这些点总偏外或偏内。原因训练时的人脸图块不是直接从检测框里裁出来的而是在检测框基础上外扩了一部分边距一般是 20% 到 30%然后才缩放到 112×112。如果你推理时直接用检测框的坐标裁剪人脸边缘会顶到图块边界关键点坐标自然就偏移了。解决拿到模型时先确认训练数据的人脸框定义。WFLW 数据集的人脸框本身就带边距你推理时也要按照相同逻辑先扩边再裁剪。扩边比例没有统一标准常见的做法是x1 x1 - 0.2 * wy1 y1 - 0.2 * h再重新计算宽高。如果扩太多关键点占比会变小精度下降扩太少人脸边缘会被切掉。跑你自己的数据集前先用几张图人工对比根据偏移方向把扩边比例调对。5.5 model_path 的路径问题相对路径坑你没商量现象torch.load(checkpoint.pth.tar)报文件不存在但文件明明就在当前目录。原因你是从项目根目录运行脚本的吗不一定。如果 IDE 的工作目录和项目根目录不一致相对路径就会找不到文件。尤其在 PyCharm、VSCode 里调试时默认工作目录是当前的 workspace不一定是你脚本所在的目录。解决用绝对路径或者基于脚本所在目录的路径。标准做法import os import torch base_dir os.path.dirname(os.path.abspath(__file__)) checkpoint_path os.path.join(base_dir, checkpoint.pth.tar) model.load_state_dict(torch.load(checkpoint_path, map_locationcpu)[model])这么写之后无论你从哪里运行脚本都不会出错。如果你用的是 Jupyter__file__不存在可以退而求其次用os.getcwd()打印当前目录确认。6. 进阶把 PFLD 接入视频流并做头部姿态估计6.1 用 OpenCV 读取视频并逐帧检测视频推理和单图推理差别不大只是加了一层帧循环。需要注意的是视频帧率足够高时相邻帧的人脸位置变化不大你可以把上一帧的人脸框作为下一帧的初始搜索区域减少人脸检测器的计算量。但前提是 PFLD 推理足够快在 CPU 上大概 5 到 10 毫秒一帧人脸检测反倒成了瓶颈。我的做法是每 3 帧做一次人脸检测中间几帧直接复用检测框关键点每帧都算。6.2 从欧拉角到头部姿态可视化PFLD 的辅助网络输出了三个欧拉角这其实是白送的姿态估计结果。拿到angle输出后可以直接把它当做人头部的 pitch、yaw、roll 角度来用。不过在可视化之前先确认角度单位是度还是弧度。部分权重训练时把角度归一化到了[-1, 1]需要乘回范围。具体怎么换算看源码里训练时的角度 label 处理方式。如果角度的精度够画一个三维坐标轴叠到人脸上视觉反馈比看数字直观得多。6.3 PFLD 的边界精度够用但不是无上限PFLD 在大姿态场景下表现远超同期方法但它并不是万能的。90 度全侧脸时关键点会明显退化眼睛闭合极端时眼睑点也会漂移。如果你要在这种极端场景下追求像素级精度需要自行微调或配合热图回归方法比如在 Stage 3 改成预测热图再解码坐标。想要更好的精度/速度平衡也值得试一下把模型导出为 INT8 进行量化。量化后模型体积能再缩小四分之一但精度损失需要针对你的数据实测。我习惯在量化前先记录一下当前模型在你验证集上的 NME 指标量化后对比变化损失超过 0.5% 就不建议用 INT8。我自己的体验是跑完 PFLD 整套流程之后再回头做任何关键点检测项目都会顺手很多。它把轻量网络的骨干设计、损失函数设计、ONNX 部署链路全部串在了一个足够简单的模型里你不需要在多层复杂的网络上花费大量时间就能理解整套逻辑。如果你是用这套源码做产品预研记得第一步先拿自己业务场景的 100 张图跑一遍观察关键点在遮挡、极端光照、侧脸这三种情况下的表现再决定要不要深入调参。希望帮到你。本文还有配套的精品资源点击获取