MindSpore版MTCNN人脸检测:从训练数据到端侧部署的完整实践
简介基于华为MindSpore框架实现人脸识别与关键点检测网络MTCNN的课程大作业源自北航与华为合作的《AI开源计算系统前沿技术》课程。面向计算机视觉入门及进阶学习者也适合作为课程设计、毕业设计或企业端侧推理项目的参考围绕轻量级骨干网络实现手机端人脸检测并借助MindSpore Lite完成端侧推理部署配有文档说明与模型文件。资源共53个文件压缩包约7.14MB主要有19个Python源码、12个pyc编译文件、3个mindir推理模型、3个ckpt训练权重以及txt标注文件、XML配置和README说明。代码按训练模块清晰组织PNet、RNet、ONet均有独立的数据生成与训练脚本可覆盖级联网络从数据准备到模型导出的全流程同时提供模型转换脚本与摄像头、图片两种推理入口方便复现或二次开发。目前已有248人学习下载对需要系统练习MTCNN训练流程的开发者而言是一份结构完整、可直接运行的参考资料。1. 引言MTCNN 这个网络用今天的眼光看真不算复杂三个小卷积网络级联总参数量还没现在一个大模型 embedding 层多。但正因为轻量它一直是课程大作业和手机端人脸检测里最值得完整手撸一遍的骨架——PNet 粗筛候选框、RNet 精调、ONet 出五个人脸关键点三段结构天然适配 MindSpore 训练 Lite 端侧推理。这份来自北航与华为合作课程的 MindSpore 版 MTCNN 源码包把训练数据生成、三阶段训练、模型导出到摄像头推理的整条链路串完整了适合想搞清楚人脸检测从数据到端侧每一步的在校学生以及刚入行做视觉算法部署的从业者照着复现。2. MTCNN 网络结构与 MindSpore 实现PNet、RNet、ONet 三阶段各管什么人脸检测和关键点定位看起来是两个任务MTCNN 的做法是塞进一条级联流水线里。这个设计动机很直白一张图里可能有一千个候选框但其中九成以上是背景。与其让一个复杂网络把所有候选框都精修一遍不如先用廉价网络把明显不是脸的区域扔掉再把剩下少量的框交给更贵的网络处理。2.1 三阶段级联为什么拆成三段而不是一个大网络拆成三段的核心理由是计算量分配。假设输入图被图像金字塔放大到几个尺度每个尺度上的候选框总数可能上万。如果直接丢给一个 48×48 输入的深网络去精修算力根本扛不住。PNet 只有十几层卷积、输入只有 12×12整图过一遍的代价非常小粗筛后剩下几百个框RNet 再过滤到几十个最后 ONet 精修。这个越往后越贵、但输入越少的漏斗结构是整条链路能跑在手机端的根本原因。三个网络的输入输出对比如下表。网络输入尺寸输出通道主要职责PNet12×12×32 分类 4 bbox 10 关键点全卷积滑窗粗筛产出候选框RNet24×24×32 分类 4 bbox 10 关键点过滤误检精调 bboxONet48×48×32 分类 4 bbox 10 关键点最终决策输出五个人脸关键点注意 PNet 在全卷积结构下输出的是整张特征图每个特征点对应原图一个 12×12 窗口RNet 和 ONet 输入的是从原图裁剪出来的 patch输出是单个向量的分类和回归量所以实现时数据流完全是两种套路。2.2 PNet全卷积加滑窗等于第一道闸门PNet 的骨干是三层卷积加池化最后用三个 1×1 卷积分出分类、bbox、关键点三个分支。它不接全连接层所以可以接受任意尺寸输入整图过一遍直接得到所有滑窗位置的预测结果。感受野对应的就是 12×12 的输入 patchstride 由卷积和池化叠加决定整体约 2 像素步长。这里的实现细节是 bbox 回归输出 4 个值对应候选框相对当前感受野窗口的偏移量dx、dy、dw、dh 的某种编码关键点分支输出 10 个值即 5 个关键点的 x、y 坐标。训练 PNet 时裁出来的样本统一 resize 到 12×12推理时就直接用全卷积的天然优势整图计算。2.3 RNet 与 ONet从候选框精调到关键点定位RNet 输入 24×24结构比 PNet 多一层卷积和一个全连接层。全连接的引入让 RNet 能看到整个 patch 的全局信息而不是像 PNet 那样只感知局部纹理因此能更好地判断这个窗口到底像不像一张脸。RNet 的典型工作方式是把 PNet 输出的候选框在原图上裁剪出来resize 到 24×24 后逐框过网络。ONet 输入 48×48网络更深最后的全连接层也更宽输出仍然保持 2410 的结构。ONet 是最后一道关卡既要给出最终的人脸置信度和 bbox还要输出准确的 5 个关键点坐标。这三个网络的 Loss 结构完全一样只是输入尺寸和网络容量不同所以训练代码可以共用一套损失函数。2.4 核心网络在 MindSpore 里的写法项目里的 models/PNet.py 给出了 PNet 的 MindSpore 实现核心结构大致如下。import mindspore.nn as nn class PNet(nn.Cell): def __init__(self): super(PNet, self).__init__() # 前三层是共享特征提取 self.conv1 nn.Conv2d(3, 10, kernel_size3, pad_modesame) self.prelu1 nn.PReLU(10) self.pool1 nn.MaxPool2d(kernel_size2, stride2) self.conv2 nn.Conv2d(10, 16, kernel_size3, pad_modevalid) self.prelu2 nn.PReLU(16) self.conv3 nn.Conv2d(16, 32, kernel_size3, pad_modevalid) self.prelu3 nn.PReLU(32) # 三个分支人脸分类、bbox 回归、关键点回归 self.conv4_1 nn.Conv2d(32, 2, kernel_size1) self.conv4_2 nn.Conv2d(32, 4, kernel_size1) self.conv4_3 nn.Conv2d(32, 10, kernel_size1) def construct(self, x): x self.prelu1(self.conv1(x)) x self.pool1(x) x self.prelu2(self.conv2(x)) x self.prelu3(self.conv3(x)) cls self.conv4_1(x) # [N, 2, H, W] bbox self.conv4_2(x) # [N, 4, H, W] landmark self.conv4_3(x) # [N, 10, H, W] return cls, bbox, landmark这里最关键的是 pad_mode 参数的选用第一层用 same 保持 12×12 的空间分辨率不缩水后面两层用 valid 让特征图逐步缩小到 1×1最终每个位置输出 2410 共 16 个值。如果 pad_mode 选错特征图尺寸对不上后续映射回原图的坐标就会整体偏移。这属于典型的一行代码错、整个检测全乱的问题。2.5 图像金字塔与 NMS 的配合人脸在画面里可大可小所以推理时先把原图按 0.7 左右的缩放因子做图像金字塔每层缩放都过一遍 PNet再把所有层级的候选框合并做一次 NMS。NMS 的 IoU 阈值在这个项目里通常取 0.5 左右作用是把重叠的框干掉只留下置信度最高的那一个。金字塔层数越多能检测到的人脸尺度范围越宽但耗时也线性上涨移动端上一般限制在 3 到 5 层。3. 训练数据怎么来从 WIDER Face 到三个网络的训练样本MTCNN 的训练不是把整张图丢进去就行而是在 WIDER Face 标注的基础上裁 patch、算 IoU、标正负样本。这一步做得对不对直接决定模型能不能收敛。很多人复现 MTCNN 失败不是网络写错而是样本生成逻辑对不上。3.1 WIDER Face 标注读法项目里 data 目录下的 wider_face_train_bbx_gt.txt 是 WIDER Face 的训练标注每张图一条记录格式是图片路径、人脸数量、然后每个人脸的 bboxx1, y1, w, h和五个关键点坐标。0--Parade/0_Parade_marchingband_1_849.jpg 1 44 459 309 402 0 0 0 0 0 0 0 0 0 0第一行是图片路径第二行是图中人脸数第三行开始是每个人脸的标注。前四个数是左上角 x、y 和宽高后面十个数是五个关键点的 x、y。有些样本关键点是 0表示该样本不参与关键点回归训练时要用 mask 把它们挡掉。3.2 IoU 分段正负样本的划分逻辑训练样本的核心是 IoU 分段。对每个随机裁剪出来的 patch计算它和所有人脸框的 IoU然后按阈值归类。IoU 0.65 归为正样本参与分类和 bbox 回归有有效关键点的还参与关键点回归IoU 0.3 归为负样本只参与分类强制约束它输出不是人脸IoU 在 0.3 到 0.65 之间的样本丢弃负样本占比必须压住。MTCNN 的实践是正负比控制在 1:3 左右负样本太多模型会偏向把所有 patch 都判成背景。PNet 的 generate_PNet_data.py 里会做这个采样平衡我建议你拿到代码后先确认一下采样逻辑很多魔改版本把这一步省了训练出来的 PNet 会漏检严重。3.3 关键点反算与归一化裁出 patch 后原图坐标下的人脸框和关键点都要换算到 patch 坐标系里。换算公式不复杂patch 左上角在整图里的坐标是 (offset_x, offset_y)那么某个关键点 (lx, ly) 在 patch 里的位置就是 (lx - offset_x, ly - offset_y)再除以 patch 边长归一化到 0~1 之间。# 以 PNet 为例裁出 12x12 的 patch 后做关键点反算 def landmark_to_patch(landmark, offset_x, offset_y, patch_size): # landmark: 原图坐标系下的 5 个关键点shape [10] # offset_x, offset_y: patch 左上角在原图中的坐标 # patch_size: 裁剪后统一 resize 的边长PNet 为 12 result [] for i in range(0, 10, 2): lx (landmark[i] - offset_x) / patch_size ly (landmark[i 1] - offset_y) / patch_size result.append(lx) result.append(ly) return result这段代码的核心是把绝对坐标转成相对坐标再压到 0~1 区间。这样做的好处是 Loss 的回归目标不随裁剪位置变化模型学出来的是一个稳定的归一化位置。坑在于如果你忘记除以 patch_sizelandmark loss 会忽大忽小看起来像是学习率太大实际上是回归目标尺度和网络输出的尺度不一致。3.4 三个 generate 脚本的分工与产物项目里 generate_PNet_data.py、generate_RNet_data.py、generate_ONet_data.py 三个脚本分别负责三个网络的训练样本生成。它们的逻辑完全一样区别只在裁 patch 后 resize 的目标尺寸不同以及 PNet 阶段用滑窗加随机偏移RNet/ONet 阶段直接用上一级网络的输出框来裁。脚本目标尺寸样本来源产物generate_PNet_data.py12×12原图随机裁剪 滑窗正负样本图片列表generate_RNet_data.py24×24PNet 输出候选框裁剪正负样本图片列表generate_ONet_data.py48×48RNet 输出候选框裁剪正负样本图片列表这里有个隐藏的细节RNet 和 ONet 的样本不是从原图随机裁的而是先用已经训练好的前一级网络跑一遍 WIDER Face把输出的候选框当作训练样本来源。这就是所谓的在线难样本挖掘思想——让后一级网络专门学习前一级容易出错的样本。首次训练时你可以用随机裁剪替代效果会差一些但链路能跑通。4. 训练命令与损失函数train_PNet.py 怎么跑通数据准备好了接下来就是三个训练脚本。train_PNet.py、train_RNet.py、train_ONet.py 结构一致这里以 PNet 为例拆开讲另外两个照葫芦画瓢就行。4.1 数据加载与预处理utils.py 里封装了数据集读取逻辑核心是按列表文件加载图片路径、裁剪信息、标注信息然后在getitem里做随机翻转增强。import cv2 import numpy as np import mindspore.dataset as ds class MTCDataset: def __init__(self, txt_file, img_size12): self.img_size img_size # txt 每行: 图片路径 置信度 x1 y1 x2 y2 关键点... self.samples self._parse(txt_file) def __getitem__(self, idx): line self.samples[idx] img cv2.imread(line[0]) # 裁出人脸 patchresize 到统一尺寸 x1, y1, x2, y2 line[1], line[2], line[3], line[4] patch img[y1:y2, x1:x2] patch cv2.resize(patch, (self.img_size, self.img_size)) # BGR 转 RGBHWC 转 CHW patch cv2.cvtColor(patch, cv2.COLOR_BGR2RGB) patch np.transpose(patch, (2, 0, 1)) patch (patch.astype(np.float32) - 127.5) / 128.0 return patch.astype(np.float32), np.array(line[5:], dtypenp.float32)这段代码有三个容易错的地方。第一是 BGR 和 RGB 的顺序OpenCV 读图默认是 BGR训练时如果忘了转成 RGB推理阶段摄像头读进来也要做同样的转换否则模型看到的是通道错乱的颜色。第二是归一化用 (x - 127.5) / 128这是 MTCNN 官方习惯和很多图像分类任务用 /255 不一样务必保持一致。第三是 numpy 的 float32 类型MindSpore 默认训练精度是 float32类型不对会直接报错。4.2 FaceLoss三任务联合损失Loss 是 MTCNN 的另一个灵魂。分类用 softmax 交叉熵bbox 和关键点用欧氏距离三个 loss 按权重相加。import mindspore import mindspore.nn as nn import mindspore.ops as ops from mindspore.ops import functional as F class FaceLoss(nn.LossBase): def __init__(self): super(FaceLoss, self).__init__() self.cls_loss nn.SoftmaxCrossEntropyWithLogits(sparseTrue) self.reg_loss nn.MSELoss() def construct(self, pred_cls, pred_box, pred_landmark, gt_cls, gt_box, gt_landmark, mask_cls, mask_box, mask_landmark): # 分类只有正负样本都参与 cls_loss self.cls_loss(pred_cls, gt_cls) * mask_cls # bbox 回归只有正样本参与 box_loss self.reg_loss(pred_box, gt_box) * mask_box # 关键点只有正样本且有关键点标注的参与 landmark_loss self.reg_loss(pred_landmark, gt_landmark) * mask_landmark return cls_loss box_loss landmark_loss逻辑关键在于 mask。分类 mask 区分正负样本和 ignore 样本bbox mask 只对正样本生效landmark mask 只对有有效关键点标注的正样本生效。三个 loss 直接求和没有额外权重因为每个分支已经通过 mask 做了选择性参与。实际训练中如果发现分类和回归收敛速度差异大可以手动加权重常见做法是 1:0.5:0.5。4.3 训练配置与启动train_PNet.py 里的配置比较常规重点是学习率和优化器选择。数据量不大的时候Adam 比 SGD 更容易收敛初始学习率 0.001每若干轮降低一次。import mindspore as ms from mindspore import Model, context from mindspore.train.callback import LossMonitor, ModelCheckpoint, CheckpointConfig from models.PNet import PNet from Loss import FaceLoss context.set_context(modecontext.GRAPH_MODE, device_targetGPU) net PNet() loss_fn FaceLoss() optimizer nn.Adam(net.trainable_params(), learning_rate0.001) # 把多输出的网络包一层适配 MindSpore Model.train 的接口 def forward_fn(x, cls, box, landmark, m_cls, m_box, m_lmk): p_cls, p_box, p_lmk net(x) return loss_fn(p_cls, p_box, p_lmk, cls, box, landmark, m_cls, m_box, m_lmk) model Model(networkforward_fn, optimizeroptimizer) config_ck CheckpointConfig(save_checkpoint_steps5000, keep_checkpoint_max5) ckpt_cb ModelCheckpoint(prefixPNet, directory./infer_models, configconfig_ck) model.train(epochs30, train_datasetdataset, callbacks[LossMonitor(100), ckpt_cb])这里我的习惯是直接写一个 forward_fn 包装而不是用 Model 的默认 loss 参数因为 MTCNN 的 Loss 接收 6 个输入默认接口不认这种多目标的返回结构。Graph 模式在训练时会把整个正向反向编译成计算图第一次迭代会比较慢这是正常现象不是卡死。4.4 三阶段训练顺序与 checkpoint 管理三个网络必须按 PNet → RNet → ONet 的顺序训练因为后一个网络的训练数据依赖前一个网络的输出。训练产物会写到 infer_models 目录下默认命名是 PNet.ckpt、RNet.ckpt、ONet.ckpt同时每过 5000 步保存一次中间 checkpoint。这里建议把 keep_checkpoint_max 设成 2 就够避免磁盘被中间产物塞满——一个 PNet 的 ckpt 可能就有几十 MB三轮训练下来磁盘压力不小。5. 避坑MindSpore 版 MTCNN 训练与推理的五个常见问题MTCNN 链路不长但每个环节都有特定的坑。下面五条是我对照这份源码结构梳理的高频问题前三条是算法层面的后两条是 MindSpore 框架层面的。5.1 正负样本失衡loss 掉不下去预测输出全零现象训练十几个 epoch 后分类 loss 几乎不动PNet 对所有输入窗口都输出背景置信度接近 1。原因样本生成时负样本数量远大于正样本模型学到最优策略是全都判负。这在二分类任务里太常见了负样本占 90% 以上时就算全部判负分类准确率也有 90%loss 下降的动力不足。解决严格控制正负比。训练 PNet 时把负样本随机下采样到正样本的 3 倍以内实际项目里我一般压到 1 比 2 到 1 比 3 之间。另外可以用难样本挖掘每个 epoch 结束后把置信度最高的部分负样本重新采样进下一轮。5.2 关键点 loss 不收敛或震荡剧烈现象验证时人脸框很准但 5 个关键点位置乱跳眼睛画到下巴上。原因关键点回归目标没归一化或者归一化时除错了尺度。网络最后输出的 landmark 是 0~1 之间的相对坐标如果训练标签给的是 0~12 的绝对坐标两者尺度差一个数量级回归 loss 在震荡中根本学不动。解决检查 generate 脚本里 landmark 反算代码确认每个坐标都除以了 patch 的边长PNet 除以 12RNet 除以 24ONet 除以 48而不是除以原图宽高。这行代码错了关键点永远学不出来。5.3 bbox 坐标反算偏移半张脸现象推理时框住的位置整体向右下偏移IoU 不低但看起来别扭大脸和小脸的偏移程度还不一样。原因PNet 输出的 bbox 回归值是相对当前感受野窗口的偏移量反算回原图坐标时需要用特征图坐标乘以 stride 还原到原图位置再加回归偏移。如果直接把特征图坐标当原图坐标用或者 stride 算错框的位置就会系统性偏移。解决一步一步手算。PNet 的 stride 由网络结构决定通常每层计算一遍下采样倍数然后在反算时乘上这个倍数。建议在推理脚本里加一个调试模式把中间层的候选框画到原图上比对肉眼核对位置是否合理。5.4 加载 checkpoint 时参数名对不上现象执行 load_param_into_net 时报参数名不匹配的错误或者提示某个参数找不到。原因MindSpore 的 checkpoint 里存的是参数名到权重的映射参数名由网络结构里层的命名决定。训练时如果用 DataParallel 包装过参数名会多一层前缀或者训练脚本里给网络起了不同名字加载时就会错位。这也是这套代码里模型命名比较讲究的原因。解决加载前先打印 checkpoint 里的参数名列表再打印当前网络的 trainable_params 名称逐项比对。确认一致后再 load。如果训练和推理用的是同一份 models 目录下的网络定义一般来说不会出问题拿到别人的模型文件时这个坑才容易被踩到。5.5 训练收敛了推理结果全乱现象训练集上 loss 很低但拿 test.jpg 一跑框和关键点完全不对有时候甚至整图无输出。原因训练和推理的预处理不一致。训练时做了 BGR 转 RGB、归一化到 -1~1推理脚本里如果直接读图没做同样处理模型看到的输入分布完全不同输出就废了。另一个常见原因是推理时用了训练时没有的模糊或缩放操作。解决把预处理写成一个函数训练和推理共用同一个函数入口。代码里 infer_path.py 和训练脚本的预处理必须逐行对齐尤其是通道顺序和归一化公式这两处我见过太多人在这一步翻车。6. 进阶模型导出、端侧部署与验证的落地习惯训练产出的 ckpt 是 MindSpore 的训练格式要跑到手机端还得先导出成中间表示再转成 MindSpore Lite 的模型格式。这个转换链路是课程大作业后半段的核心也是和纯 PyTorch 实现最大的区别。6.1 ckpt 导出 mindirmodelToMNDIR.py 的用法代码根目录下的 modelToMNDIR.py 干的就是这件事。它把训练好的 PNet、RNet、ONet 分别导出成 pnet.mindir、rnet.mindir、onet.mindir。核心逻辑很简单先加载 ckpt再把网络切到 eval 模式最后调用 export 接口。import numpy as np import mindspore as ms from mindspore import Tensor, export, load_param_into_net from models.PNet import PNet # 固定输入尺寸PNet 输入是 [N, 3, H, W]导出时要用具体 shape input_shape (1, 3, 128, 128) net PNet() param_dict ms.load_checkpoint(./infer_models/PNet.ckpt) load_param_into_net(net, param_dict) net.set_train(False) fake_input Tensor(np.random.randn(*input_shape), ms.float32) export(net, fake_input, file_namepnet, file_formatMINDIR)导出时固定输入尺寸这点很关键。MindSpore 的 export 要求输入是一个确定 shape 的 Tensor虽然 PNet 理论上支持任意尺寸但手机端部署时固定尺寸能省掉很多动态形状的分支逻辑。我一般会把 PNet 的导出尺寸固定为训练时的最小尺度比如 128×128实际推理时第一层金字塔就从 128 开始缩放。RNet 和 ONet 的输入就是 24×24 和 48×48不存在尺寸选择问题直接固定即可。6.2 端侧部署的体积控制思路MTCNN 三个模型加起来只有几 MB这是它能跑手机端的前提。如果你后续想进一步压缩两个方向最有效一是把 PNet 的卷积核数量减半重新训练检测率会掉一点但体积直接缩到四分之一二是转为 MindSpore Lite 时开启后训练量化把 float32 权重压到 int8体积再缩 4 倍精度损失通常在 1 到 2 个点以内。量化前建议先用 test.jpg 验证一遍 fp32 模型的输出量化后做同样的验证两条 result.jpg 对比看关键点偏移有没有超出可接受范围。6.3 验证闭环test.jpg 到 result.jpg 的完整习惯包里自带 test.jpg 和 result.jpg这是一组现成的验证输入输出。我的建议是拿到代码后先跑 infer_path.py 复现 result.jpg确认链路是好的再开始改自己的数据。python infer_path.py --image ./test.jpg跑通之后换三张自己的图一张正脸近照、一张多人合影、一张侧脸。正脸验证核心功能合影验证 NMS 会不会把相邻人脸误合并侧脸验证关键点在大姿态下的表现。这三张图能覆盖 MTCNN 八成以上的常见问题比盯着训练 loss 曲线有效得多。这套流程我后来接手任何检测类项目都强制走一遍先跑通自带样例再换自己的数据做三场景验证最后才碰训练和调参。从那以后因为预处理不一致导致的黑匣子问题少了一大半。这份源码包值得下载的也正是这条完整的链路希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →