
简介基于华为MindSpore框架实现的人脸识别与关键点检测MTCNN课程大作业完整工程包覆盖数据生成、模型训练、MindSpore Lite转换与摄像头实时推理全链路面向计算机视觉、人工智能方向的学生及端侧AI部署初学者适合课程设计、毕设拓展或项目初期演示。工程包含PNet、RNet、ONet三层级联网络的训练与推理脚本支持生成训练数据、模型训练、模型转换及实时推理并附有README说明、数据集标注txt与环境配置指引。压缩包共53个文件涵盖19个Python源码、3个mindir端侧模型、3个ckpt训练权重、数据集标注txt及测试图片等整体大小7.14MB目录按训练、推理、模型等模块划分便于快速定位。资源已获248人学习浏览代码经运行验证下载后对照文档即可复现人脸检测流程也可基于现有模型和脚本进一步调优或迁移到手机端部署。1. 用 MindSpore 复刻 MTCNN一份能跑通训练与端侧推理的课程源码人脸检测是很多人脸应用的第一步而 MTCNN 是少有的「网络不大、效果能打」的经典方案。这份基于华为 MindSpore 框架实现的 MTCNN 源码包来自北航与华为合作的 AI 课程大作业完整覆盖了 PNet/RNet/ONet 三个阶段的训练代码、WIDER Face 数据生成脚本、预训练权重和 MindSpore Lite 推理示例。对正在做人脸识别课设、毕设或者想学 MindSpore 但找不到完整项目下手的人来说这套资源的价值在于它不是只给一个推理 demo而是把「数据怎么切、三个网络怎么训、模型怎么导出、端侧怎么跑」整条链路都串起来了。下面我按自己拆项目的方式从环境、数据、训练、部署到踩坑记录一层层过一遍。2. MindSpore 环境与 WIDER Face 数据三份训练集是怎么切出来的2.1 MindSpore 安装版本与 Python 环境的一次性匹配拿到源码包的第一件事不是看代码而是把环境装到和项目同频的状态。这套项目基于 MindSpore 框架编写训练脚本用的是mindspore.nn、mindspore.ops这套 API安装时最容易翻车的点是版本与 Python 版本的匹配关系。常见做法是用 Python 3.7 或 3.8 配 MindSpore 1.7 左右的 CPU 版本因为 MTCNN 的网络结构很轻PNet 只有几层卷积CPU 上跑完全没压力不需要为了训练去装 CUDA 版。conda create -n mindspore python3.7 conda activate mindspore pip install mindspore1.7.0 -i https://pypi.tuna.tsinghua.edu.cn/simple装完之后用一行命令验证安装是否可用。这一步能过滤掉绝大多数「装了半天一 import 就崩」的问题。python -c import mindspore; print(mindspore.__version__)如果这行报错先看是不是 conda 环境没激活再看 pip 安装日志里有没有版本冲突。MindSpore 的 CPU 版本依赖protobuf、numpy这些常见的包通常不会有冲突。验证通过后把源码包里的mtcnn-mindspore-master解压先用编辑器打开README.md和models目录下的网络定义文件确认这个版本的 API 写法。不同 MindSpore 版本之间 API 有差异比如 1.7 的mindspore.nn.Conv2d和 2.0 的mindspore.nn.Conv2d参数名有小改动但骨架基本一致。我习惯用 PyCharm 打开整个目录配合 VSCode 的 Python 插件做跳转两者都能正常识别 MindSpore 的自动补全。2.2 数据生成WIDER Face 标注格式与三重标签划分MTCNN 的训练不能直接用原图跑需要先根据标注框裁剪出大量小图块再按 IoU 划分成正样本、部分样本和负样本。这套项目里有三个数据生成脚本分别是generate_PNet_data.py、generate_RNet_data.py、generate_ONet_data.py它们做的事情逻辑一致只是裁剪尺寸不同PNet 用 12x12RNet 用 24x24ONet 用 48x48。WIDER Face 的标注文件wider_face_train_bbx_gt.txt格式很直观每张图片的信息分三块第一行是图片相对路径第二行是该图人脸数量紧接着每行是一个人脸的标注格式为x, y, w, h, blur, expression, illumination, occlusion, pose。项目里的data_format_converter.py就是用来解析这个格式的工具。PNet 数据生成的核心逻辑是读取标注文件对每张图随机裁剪若干小图块计算每个图块与真实人脸的 IoU然后打标签。划分规则是业界通用的标准IoU 大于 0.65 的当作正样本0.4 到 0.65 之间的当作部分样本小于 0.3 的当作负样本0.3 到 0.4 之间的直接丢弃避免边界样本干扰训练。下面是从项目中整理出来的核心逻辑import cv2 import numpy as np def crop_face(img, bbox, size12): x, y, w, h bbox # 随机偏移裁剪区域制造更多训练样本 offset_x np.random.randint(-w // 4, w // 4) offset_y np.random.randint(-h // 4, h // 4) x1 max(0, x offset_x) y1 max(0, y offset_y) x2 min(img.shape[1], x w offset_x) y2 min(img.shape[0], y h offset_y) crop img[y1:y2, x1:x2] crop cv2.resize(crop, (size, size)) return crop, (x1, y1, x2, y2)这段代码里offset_x和offset_y的作用是让裁剪框在真实标注附近随机浮动相当于做了数据增强。裁剪后统一 resize 到size参数指定的尺寸这个size就是对应阶段的输入大小。写数据生成脚本时要注意负样本的数量要远大于正样本通常控制在 3:1 到 5:1 之间因为背景图块远比人脸图块容易获得而且 PNet 阶段如果负样本太少网络会倾向于把所有输入都预测成人脸。项目的data.py和utils.py提供了数据集封装和 IoU 计算函数训练时直接通过Dataset接口读入生成好的.npy或图片文件。这块有一个容易被忽略的细节WIDER Face 只提供了人脸框标注没有公开关键点标注。ONet 要训练关键点分支需要额外的关键点数据。源码包里的trainImageList.txt和测试列表文件实际上是把 WIDER Face 的图片路径整理成了列表关键点部分用的是项目内置的标注或 MTFL 格式的数据所以你在复现时不要指望 WIDER Face 原始 txt 里有 5 个关键点的坐标那部分数据是项目作者额外准备的。3. 三段式训练从 12x12 的 PNet 到 48x48 的 ONet3.1 网络骨架PNet/RNet/ONet 的输入输出与共享结构MTCNN 的三个网络遵循从粗到精的级联设计思路。PNet 是全卷积网络输入 12x12 的彩色图块输出三样东西人脸分类得分、人脸框回归偏移量、关键点坐标。因为输入尺寸极小PNet 的计算量非常低适合在图像金字塔上做密集滑窗扫描。RNet 输入 24x24比 PNet 多了一层全连接负责过滤 PNet 输出的候选框。ONet 输入 48x48结构最深最终输出精确的人脸框和 5 个关键点坐标。项目里models/PNet.py、models/RNet.py、models/ONet.py分别定义了这三个网络。PNet 定义的核心代码如下我用注释标出了每一层的职责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) # 分类头输出 2 个通道对应 人脸/背景 self.conv4_1 nn.Conv2d(32, 2, kernel_size1) # 框回归头输出 4 个通道对应 归一化偏移量 self.conv4_2 nn.Conv2d(32, 4, kernel_size1) # 关键点头输出 10 个通道对应 5 个点坐标 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) bbox self.conv4_2(x) landmark self.conv4_3(x) return cls, bbox, landmark注意pad_mode参数的差异conv1用了same保持空间尺寸conv2和conv3用valid让特征图逐步缩小。这样设计的原因在于 PNet 最终要对 12x12 的输入输出一个 1x1x2 的分类向量如果用same填充感受野对应不上。这个细节在复现时不要改否则训练出来的 PNet 推理时边界框会偏。RNet 和 ONet 的结构类似只是卷积核数量更多、层数更深且在卷积层后接了全连接层nn.Dense。标准的 MTCNN 中 RNet 和 ONet 的输入经过了 PNet 的粗筛不再需要滑窗所以全连接层可以接受固定尺寸的输入输出同样三个分支。项目的Loss.py里定义了多任务损失函数三个分支的 loss 加权求和权重参数在不同训练阶段会调整这部分在 3.2 里展开讲。3.2 损失函数分类、框回归、关键点回归的权重设计MTCNN 的多任务训练是整套算法的灵魂。三个分支的损失函数分别为人脸分类用交叉熵框回归和关键点回归用欧氏距离。项目Loss.py的实现逻辑是把三个 loss 加权求和import mindspore.nn as nn import mindspore.ops as ops class MtcnnLoss(nn.Cell): def __init__(self, cls_weight1.0, bbox_weight0.5, landmark_weight0.5): super(MtcnnLoss, self).__init__() self.cls_loss nn.SoftmaxCrossEntropyWithLogits() self.bbox_loss nn.MSELoss() self.landmark_loss nn.MSELoss() self.cls_weight cls_weight self.bbox_weight bbox_weight self.landmark_weight landmark_weight def construct(self, cls_pred, bbox_pred, landmark_pred, cls_label, bbox_label, landmark_label): c_loss self.cls_loss(cls_pred, cls_label) b_loss self.bbox_loss(bbox_pred, bbox_label) l_loss self.landmark_loss(landmark_pred, landmark_label) total_loss self.cls_weight * c_loss self.bbox_weight * b_loss self.landmark_weight * l_loss return total_loss这段代码里有个关键点框回归和关键点回归都用了MSELoss但实际训练时负样本只参与分类 loss 的计算不参与回归 loss。原因很好理解——负样本没有真实人脸框强行回归只会给网络灌输错误信息。我在自己写训练循环时会在构造 batch 时把负样本的 bbox 标签和 landmark 标签全部置零并记录一个 mask 矩阵在 loss 计算时通过 mask 过滤掉无效的回归样本。项目源码里这段逻辑分布在train_PNet.py、train_RNet.py和train_ONet.py中复现时注意看数据集的__getitem__方法返回值里面包含了cls_label、bbox_offset、landmark_offset三组标签。权重分配上PNet 阶段通常设置cls_weight1.0、bbox_weight0.5、landmark_weight0.5。因为 PNet 的主要任务是召回分类权重最大框回归次之。到了 ONet 阶段关键点的权重会适当提高因为 ONet 是最后一个精修网络关键点精度直接决定最终输出质量。如果你发现 ONet 关键点飘得厉害优先调大landmark_weight同时检查关键点标签的归一化方式——MTCNN 的关键点回归目标不是绝对像素坐标而是相对于人脸框宽高的比例值这个归一化过程在generate_ONet_data.py里完成。3.3 训练顺序与超参为什么 RNet 要等 PNet 的候选框MTCNN 的训练顺序是有讲究的不能三个网络并行训练必须按照 PNet → RNet → ONet 的顺序依次进行因为 RNet 和 ONet 的训练数据不是从 WIDER Face 原图上随便裁的而是来自上一个网络的输出结果。这就是项目里三个训练脚本需要依次执行的原因先跑generate_PNet_data.py生成 12x12 样本训练 PNet再用训好的 PNet 在原图上跑出大量候选框把候选框裁剪成 24x24 作为 RNet 的输入ONet 同理。训练脚本的通用骨架如下以train_PNet.py为例import mindspore as ms import mindspore.nn as nn from mindspore import Tensor, Model from mindspore.train.callback import LossMonitor, ModelCheckpoint, CheckpointConfig # 网络实例化 net PNet() # 优化器Adam 在 MTCNN 这种小网络上表现稳定 optimizer nn.Adam(net.trainable_params(), learning_rate1e-3) # 损失函数 loss MtcnnLoss() # 包装成 MindSpore 模型 model Model(net, loss_fnloss, optimizeroptimizer) # 加载数据 dataset create_dataset(pnet_train_data.npy, batch_size128, repeat10) # 训练 30 个 epoch每 5 个 epoch 保存一次 checkpoint config CheckpointConfig(save_checkpoint_stepsdataset.get_dataset_size() * 5, keep_checkpoint_max5) ckpt_cb ModelCheckpoint(prefixPNet, directory./ckpt, configconfig) model.train(30, dataset, callbacks[LossMonitor(100), ckpt_cb])这里batch_size128是针对 12x12 小输入的合理取值如果显存或内存吃紧可以降到 64。学习率1e-3是起步值训练到第 15 个 epoch 左右建议降到1e-4否则 loss 会震荡。repeat10表示每个 epoch 把数据集重复 10 次相当于变相增大训练轮数。RNet 和 ONet 的训练脚本结构相同差异在网络定义和输入尺寸。RNet 的 batch size 建议降到 64ONet 因为输入 48x48、网络更深batch size 进一步降到 32 比较稳妥。训练 RNet 前需要先跑完 PNet 的推理把所有训练图片过一遍 PNet收集候选框并保存到文件再执行generate_RNet_data.py对这些候选框做裁剪和打标签。这个过程有一定的玄学成分——PNet 训练得好不好直接决定 RNet 的数据质量而 RNet 数据质量又决定 ONet 效果所以训练第一阶段的 PNet 时就要认真观察分类准确率不要草草跑完就往下走。4. 模型导出与端侧推理MINDIR 文件里到底装了什么4.1 ckpt 转 MINDIRexport 的输入维度与 batch 陷阱训练完的三个网络会各自保存一份.ckpt权重文件项目里infer_models目录下存放了PNet.ckpt、RNet.ckpt、ONet.ckpt这些是训练阶段的产物只能在 MindSpore 框架里加载。要部署到 MindSpore Lite需要把权重连同网络结构一起导出成.mindir格式。项目根目录下已经有pnet.mindir、rnet.mindir、onet.mindir三个文件说明作者已经导出了一版但你自己训练出的新权重还是要走一遍这个流程。modelToMNDIR.py这个脚本干的就是这件事核心代码逻辑如下import numpy as np import mindspore as ms from mindspore import Tensor, export from models.PNet import PNet # 实例化网络并加载权重 net PNet() ms.load_checkpoint(infer_models/PNet.ckpt, net) # 构造一个符合网络输入的示例 tensor # 注意 batch 维度必须是 1且输入尺寸要和训练时完全一致 input_arr Tensor(np.ones((1, 3, 12, 12)), ms.float32) export(net, input_arr, file_namepnet, file_formatMINDIR)export函数的关键在于输入的 shape 必须和网络 forward 时的输入完全一致尤其是 batch 维。如果你训练时用了batch_size128导出时示例 tensor 的 batch 维也要是 128否则导出会报 shape 不匹配的错误。但部署时更常见的是导出 batch1 的版本因为端侧推理都是单张图片逐个跑。还有一个容易忽视的问题CPU 版本 MindSpore 导出的 MINDIR 文件只能在 CPU 的 MindSpore Lite 环境中运行如果端侧设备有 NPU需要确保训练环境和导出环境一致否则模型能加载但算子跑不起来。我一般会把训练环境固定在同一台机器上完成导出不交叉换机器。4.2 推理链路图像金字塔、NMS 与两级精修拿到 MINDIR 文件后推理阶段要做的事比网络本身复杂得多。MTCNN 的推理链路是读入整张图 → 构建图像金字塔 → PNet 滑窗扫描 → NMS 合并候选框 → RNet 精修 → ONet 输出最终框和关键点。项目里infer_models目录下的infer_path.py实现的是单张图片路径推理infer_camera.py实现的是摄像头实时推理。图像金字塔的构建有一组经典参数直接决定小脸检测能力import math import numpy as np min_face_size 20 # 最小可检测人脸边长 factor 0.709 # 缩放因子MTCNN 论文推荐值 thresholds [0.6, 0.7, 0.7] # PNet/RNet/ONet 的分类阈值 def build_pyramid(img, min_face_size20, factor0.709): h, w img.shape[:2] # 初始缩放尺度让最小人脸缩放到 PNet 的输入尺寸 12x12 scale 12.0 / min_face_size scales [] min_side min(h, w) while scale 1.0 and min_side * scale 12: scales.append(scale) scale * factor return scales这段代码的逻辑是初始缩放尺度让最短边缩到约 12 像素然后按 0.709 倍逐步缩小直到图像比 PNet 输入还小为止。min_face_size越大金字塔层数越少速度越快但小脸检测能力越差。如果你要检测的场景是监控摄像头拍到的远距离人脸min_face_size建议设为 20 以下如果是自拍场景可以放宽到 40。PNet 在金字塔每一层上做前向推理输出的候选框按当前层的 scale 映射回原图坐标然后做一次 NMS。NMS 的 IoU 阈值在 MTCNN 中通常设为 0.5 到 0.7 之间设得太小会保留大量重复框设得太大可能把相邻的两个真脸合并成一个框。RNet 和 ONet 的推理就简单了——直接把候选框区域裁剪并 resize 到对应输入尺寸逐网络跑前向每次跑完再做一次 NMS。infer_path.py里的检测函数返回的最终结果是(bbox, landmarks)二元组result.jpg就是检测结果可视化后的输出图把框和关键点画在了原图上。摄像头推理的实现不复杂用 OpenCV 的VideoCapture(0)读取帧每帧调用一次检测函数。但这里有个性能问题MTCNN 的 PNet 在 CPU 上处理一帧 640x480 的图大概需要 50 到 100 毫秒再加上 RNet 和 ONet帧率通常在 5 到 10 fps。infer_camera.py里做了个偷懒但有效的优化——只在检测到人脸的那一帧完整执行三级网络没有检测到人脸时只用 PNet 粗扫这样能显著降低 CPU 占用。如果你跑起来还是卡可以把摄像头分辨率从默认值降到 320x240人脸检测本来就不需要太高分辨率。5. 避坑清单数据生成到摄像头实测的五处翻车现场踩坑一数据生成脚本报 FileNotFoundError现象执行generate_PNet_data.py时提示找不到输出目录或者保存图片时cv2.imwrite返回 False。原因脚本默认输出到相对路径比如./dataset/pnet/positive但当前工作目录不在项目根目录下或者这些子目录本来就不存在。解决所有数据生成脚本都有output_dir之类的参数执行前先手动建好positive、negative、part三个子目录或者用os.makedirs(..., exist_okTrue)把自动建目录的代码补上。我自己跑的时候习惯在项目根目录下新建一个data文件夹再在脚本里把输出路径写成绝对路径省得后续因为工作目录问题反复折腾。踩坑二训练 loss 不下降一直在 1.0 附近抖动现象LossMonitor打印的 loss 值在 0.9 到 1.1 之间震荡几百个 step 后毫无下降趋势。原因最常见的是标签没对齐。负样本的标签是[0, 1]正样本是[1, 0]如果数据集封装时把分类标签写反了loss 就会卡在大约 ln(2)≈0.69 到 1.0 之间。另一个常见原因是正负样本比例失衡负样本占了 90% 以上网络学到的全是背景特征。解决单独写一个脚本打印数据集里每个 batch 的标签分布检查正负样本比例。调整采样逻辑把 PNet 训练数据控制在一半正样本、一半负样本。如果项目生成的.npy数据太大不好检查直接在data.py的__getitem__方法里加个打印跑两三个 step 就停人工核对标签和图片内容是否一致。踩坑三模型导出时报 shape 不匹配现象export函数报ValueError: The shape of input tensor ... does not match ...。原因示例输入 tensor 的 shape 和网络construct方法里实际接收的输入 shape 不一致。PNet 的导出输入必须是(1, 3, 12, 12)RNet 是(1, 3, 24, 24)ONet 是(1, 3, 48, 48)不能凭感觉写。解决导出前先实例化网络打印网络的输入参数或者直接看网络定义里construct的x.shape依赖。如果训练时用了动态 shape导出前要把set_dynamic_shape相关的设置去掉导出静态 shape 版本。踩坑四摄像头推理画面很糊检测框明显偏大或偏小现象infer_camera.py跑起来能检测人脸但画出来的框比实际人脸大一圈或者框里只有半张脸。原因图像金字塔的坐标映射出问题。PNet 输出的边界框坐标是相对于缩放后图像的还原到原图时要除以对应层的 scale。很多复现版 MTCNN 的 bug 都出在这条映射链路上尤其是做了 padding 操作后偏移量容易算错。解决拿一张已知人脸位置的单脸图片做单步调试打印金字塔每一层的候选框坐标手动算一遍映射结果和代码输出比对。把infer_path.py的输出图片和 OpenCV 自带的人脸检测器结果叠在一起看框的位置应该基本吻合。踩坑五MindSpore Lite 端侧推理结果和 PC 端推理结果不一致现象同一张测试图PC 上检测出人脸部署到手机后检测不到或者框的位置偏移了几个像素。原因端侧推理时的预处理和 PC 端不一致。常见的有三个点一是图片缩放方式不同OpenCV 的resize默认用双线性插值端侧可能用了不同的插值算法二是图片通道顺序PC 端是 BGR 顺序端侧某些推理框架默认 RGB三是归一化方式训练时用(x - 128) / 128端侧可能写成了x / 255。解决把 PC 端的预处理代码和端侧的预处理代码逐行对齐尤其是resize、transpose、astype这三步。最稳妥的做法是写一个测试用例在 PC 端把一张图预处理成npy文件再在端侧用同样的输入跑一遍对比两个环境的输出 tensor。6. 进阶玩法把 MTCNN 输出接到人脸识别与关键点对齐6.1 关键点输出能干什么人脸对齐与活体判断ONet 输出的 5 个关键点坐标在真实业务场景里有直接价值。最常见的是人脸对齐用眼睛、鼻子、嘴角五个点做仿射变换把倾斜的人脸矫正到标准姿态再送入人脸识别网络提取特征。如果你要做人脸识别门禁之类的项目抬手就能用的方案是拿 ONet 的关键点做对齐再用 MobileFaceNet 之类的轻量网络做特征提取全程不依赖离线算法库。关键点还有一个容易被忽略的用途——简单活体判断。真实人脸的五个关键点在人脸框内的相对位置是符合一定分布规律的例如两只眼睛的连线与人脸框的水平偏移角度通常在 -30 度到 30 度之间两个瞳孔距离约占人脸框宽度的 30% 到 60%。如果检测结果中关键点分布明显异常大概率是照片或屏幕翻拍。这类判断不严谨但能挡住一部分低级攻击。6.2 换精度更高的检测头把 PNet 换成 MobileNet 浅层MTCNN 的 PNet 设计于 2016 年对小脸的召回率有限。如果你觉得原版 PNet 检测不到太小的脸可以考虑把 PNet 换成 MobileNet 的前几层卷积块保持输出头不变输入尺寸从 12x12 提升到 16x16 或 24x24。这样做的代价是速度下降但检测小脸的能力会明显提升。6.3 参数调优表min_face_size、阈值与 NMS 的联动关系参数默认值调优建议min_face_size20减小到 10 可检测更小脸但推理时间翻倍增大到 40 可提速适合自拍场景factor0.709增大到 0.8 金字塔层数减少速度快但容易漏检减小到 0.6 层数增多小脸更准PNet threshold0.6降低到 0.5 提高召回率代价是 RNet 输入压力增大RNet/ONet threshold0.7目标是提高精确率如果误检多就调高到 0.8NMS IoU0.7降低到 0.5 会保留更多重复框NMS 后可能误删相邻人脸这几组参数之间存在联动关系不要单独调某一个。我的习惯是先用低阈值跑一遍统计检测框数量和误检率再逐步提高阈值。每换一个阈值组合就用同样的一组测试图跑一遍对比检测结果和耗时记录到表格里。这套项目走到这一步训练和部署链路基本打通了。我最初接触这份源码时也踩过数据标签搞反、mindir 导出失败这些坑后来形成了固定习惯每次改动训练参数后先在测试集上跑一遍 PNet 的单独输出确认候选框数量在合理范围内再继续训练后续网络。这套流程能省下大量排错时间希望帮到你。本文还有配套的精品资源点击获取