简介这是一份面向深度学习与目标检测初学者及进阶开发者的MTCNN人脸检测完整实现资源围绕多任务级联卷积网络展开完整覆盖P-Net、R-Net、O-Net三阶段P-Net通过浅层卷积快速生成大量候选框R-Net进一步回归筛选并精炼边界框O-Net在输出最终人脸框的同时预测眼睛、鼻子、嘴角等关键点坐标。资源共238个文件压缩包约216.98MB包含Python训练与推理脚本、PyTorch模型权重文件pt、TensorFlow训练事件日志、图片与XML标注文件另附readme说明、演示视频及按训练阶段划分的数据目录可支持模型复现、微调训练、损失曲线分析与结果可视化。包内多个events文件记录了不同训练时间点的状态便于对照实验排查问题。目前已有599人学习下载适合想动手跑通MTCNN训练与推理、深入理解级联网络设计并完成人脸检测部署的开发者使用。 作为一个常年跟人脸检测打交道的人我陆陆续续把 MTCNN 的代码从训练到推理完整跑通过好几遍每次重写都有新收获。这个算法虽然已经是 2016 年的老将但它的级联思想和工程技巧至今依然很值钱。今天我就用一篇文章把 MTCNN 的完整代码实现从头到尾拆开聊透适合那些不想只调库、想真正理解人脸检测原理再去落地的朋友参考。我默认你有一定的 Python 和 PyTorch 基础如果你只是刚入门文章里涉及的关键概念我也会尽量讲清楚保证你能跟着把推理代码跑起来。1. 为什么现在还要手写 MTCNN人脸检测这个领域现在已经有非常多现成的工具比如 mtcnn 库、facenet-pytorch、RetinaFace、insightface 里面的检测器。那为什么我依然强烈建议你去手写一遍 MTCNN 的代码实现因为它的价值早就跳出了“人脸检测”本身。以级联方式做粗到精的预测是 MTCNN 最核心的思想。先用一个轻量网络快速产出大量候选框再用稍重的网络逐级过滤和精修这种思路在目标检测、关键点定位、甚至某些 NLP 任务里都能看见。你写一遍级联结构之后再接触任何 coarse-to-fine 的算法都会很快上手。另一个原因是工程价值。MTCNN 的模型非常轻量P-Net 甚至可以跑在 CPU 上做视频流的人脸检测前处理、人脸裁剪、活体检测对齐、人脸跟踪初始化都非常顺手。我自己在嵌入式设备上部署过人脸检测MTCNN 是少数能在性能和召回率之间取得良好平衡的经典方案。最后一个原因是面试和学习。很多测试题会让你手写 NMS、IoU、框回归这些恰好是 MTCNN 代码实现里的核心模块。与其临时背题不如自己完整写一遍面试时你甚至能从三角网络参数讲到金字塔 scale 的计算细节这种深度是背八股文给不了的。那我们就开始吧先别急着写代码先搞定三个网络模型的设计因为不看懂 P-Net 和 R-Net、O-Net 的分工后面代码容易绕晕。2. 动工之前先把三个网络的分工彻底想清楚MTCNN 由三个级联的卷积网络组成P-NetProposal Network负责生成候选框R-NetRefine Network负责精修O-NetOutput Network负责出最终结果。这三者设计得很有节奏像漏斗一样层层收敛。2.1 P-Net快速产出候选框P-Net 是一个全卷积网络输入尺寸是 12x12x3。因为输入小网络很浅卷积核数量也很少所以计算量非常低可以在整张图像的每一个位置快速滑动并判断“这里有没有人脸”。它的输出有三条分支分类分支输出 2 个值表示是人脸face还是非人脸框回归分支输出 4 个值表示候选框相对锚点位置的偏移关键点分支输出 10 个值即 5 个关键点的归一化坐标虽然这一层的关键点精度不高但会参与训练P-Net 之后我们会得到一堆候选框其中大多数是错检。这里不追求精度只追求召回率所以 P-Net 的置信度阈值通常给低一些比如 0.6 左右。2.2 R-Net过滤大量假阳性R-Net 的输入尺寸是 24x24网络比 P-Net 更深一些。它做的事情是拿 P-Net 产出的候选框把对应区域截取出来后缩放到 24x24再判断“这到底是不是一张人脸”。经过 R-Net绝大多数背景和误检框会被过滤掉阈值可以稍高比如 0.7。这一阶段还要再做一次 NMS 和框回归确保候选框位置更准数量也大幅下降。这里框回归的分支和三阶段一致输出也是 4 个偏移值。2.3 O-Net产出最终结果和关键点O-Net 的输入尺寸是 48x48是三个网络中最大的。它对候选框做最终判定同时输出 5 个关键点的精确坐标左眼、右眼、鼻尖、左嘴角、右嘴角。阈值通常会设到 0.7 以上保证最终结果的精度。O-Net 之后还需要做最后一轮框回归校准然后输出最终人脸框和关键点。三个网络职责对比如下网络输入尺寸主要作用常见阈值输出P-Net12x12快速生成候选框0.6分类、框偏移、关键点粗结果R-Net24x24过滤假阳性0.7分类、框偏移、关键点粗结果O-Net48x48最终判定与关键点精修0.7分类、框偏移、关键点结果理清了这个分工逻辑你就能明白为什么推理阶段要一级一级传P-Net 负责“广撒网”R-Net 负责“缩小包围圈”O-Net 负责“一锤定音”。下面我们就正式进入代码实现。3. 核心代码拆解PyTorch 实现一次讲透我用的环境是 Python 3.9 PyTorch 2.0 OpenCV代码里主要用到了卷积和全连接这些 API 在 TensorFlow 里也有对应实现思路是一样的。建议你把代码仓库建好文件结构按照网络定义、数据处理、推理流水线分开方便后续调试。3.1 P-Net 网络定义全卷积的力量P-Net 的特点是设计成全卷积结构这样它就能接受任意大小的输入图片并在输出层得到每个位置的预测结果。我直接用 PyTorch 的 nn.Module 实现import torch import torch.nn as nn class PNet(nn.Module): def __init__(self): super(PNet, self).__init__() self.pre_layer nn.Sequential( nn.Conv2d(3, 10, kernel_size3, stride1, padding1), nn.PReLU(), nn.MaxPool2d(kernel_size2, stride2), nn.Conv2d(10, 16, kernel_size3, stride1, padding1), nn.PReLU(), nn.Conv2d(16, 32, kernel_size3, stride1, padding1), nn.PReLU(), ) self.conv4_1 nn.Conv2d(32, 2, kernel_size1, stride1) self.conv4_2 nn.Conv2d(32, 4, kernel_size1, stride1) self.conv4_3 nn.Conv2d(32, 10, kernel_size1, stride1) def forward(self, x): x self.pre_layer(x) cls torch.softmax(self.conv4_1(x), dim1) offset self.conv4_2(x) landmark self.conv4_3(x) return cls, offset, landmark注意这里返回的 cls 已经做了 softmax分类分支取第 1 个通道索引从 0 开始第 0 个是非人脸第 1 个是人脸就是人脸置信度。如果你是在训练时调用这部分网络需要根据你自己的标签定义调整输出的顺序推理时这样写最直观。3.2 R-Net 与 O-Net加深度、加判断力R-Net 和 O-Net 的结构比较相似都是卷积层提取特征之后接全连接层输出结果。我用 R-Net 举例O-Net 的差异我已经在注释里标出来。class RNet(nn.Module): def __init__(self): super(RNet, self).__init__() self.pre_layer nn.Sequential( nn.Conv2d(3, 28, kernel_size3, stride1, padding1), nn.PReLU(), nn.MaxPool2d(kernel_size3, stride2), nn.Conv2d(28, 48, kernel_size3, stride1, padding1), nn.PReLU(), nn.MaxPool2d(kernel_size3, stride2), nn.Conv2d(48, 64, kernel_size2, stride1, padding0), nn.PReLU(), ) self.dense4 nn.Linear(64 * 3 * 3, 128) self.prelu4 nn.PReLU() self.dense5_1 nn.Linear(128, 2) self.dense5_2 nn.Linear(128, 4) self.dense5_3 nn.Linear(128, 10) def forward(self, x): x self.pre_layer(x) x x.view(x.size(0), -1) x self.prelu4(self.dense4(x)) cls torch.softmax(self.dense5_1(x), dim1) offset self.dense5_2(x) landmark self.dense5_3(x) return cls, offset, landmarkO-Net 的卷积层输出通道数依次是 32、64、64后续再接一个 128 通道的卷积层最后展开成 256 维的全连接。这里我不再贴完整代码结构完全模仿上面你替换掉 conv 参数和全连接输入维度即可。有一点提醒新手在 R-Net 和 O-Net 里MaxPool2d 的 kernel_size 和 stride 不完全一样P-Net 的池化是 2x2 步长 2R-Net 是 3x3 步长 2O-Net 中间一层是 2x2 步长 2。这些参数直接影响到展平之后全连接层的输入维度所以抄代码的时候别顺手改掉。3.3 图像金字塔检测多尺度人脸的前提MTCNN 的第二步关键操作是把输入图片做金字塔缩放。因为 P-Net 本身就是固定感受野 12x12如果想检测大尺寸人脸需要把原图缩小如果想检测小人脸需要把原图放大或保持原尺寸。缩放比例的公式是scale 12.0 / min_face_size while min(w, h) * scale 12: scales.append(scale) scale * factor这里的 min_face_size 是最小人脸尺寸一般设成 24 或 40。factor 是缩放因子论文里用的约是 0.709对应每轮面积减少一半。这个参数会影响金字塔的层数和检测速度层数越多越慢但小人脸召回率也越高。工程上通常取 0.7 左右即可。构建好 scales 列表之后对每个 scale 将图片 resize然后送进 P-Net。这里很多人容易搞混图像金字塔生成的是不同尺寸的输入图而不是直接产生不同尺度的候选框候选框的尺度差异是靠金字塔输入图映射回原图坐标来实现的。原理上有点像卷积神经网络版的“滑动窗口”只是这个窗口是卷积网络自己学出来的特征图响应。3.4 NMS 和框回归两个绕不开的细节NMS非极大值抑制的意义在于P-Net 会在一张人脸上产生大量重叠框我们需要保留得分最高的那个抑制掉和它重叠度太高的其他框。实现代码如下def nms(boxes, scores, threshold0.5, methodunion): if len(boxes) 0: return [] x1, y1, x2, y2 boxes[:, 0], boxes[:, 1], boxes[:, 2], boxes[:, 3] areas (x2 - x1 1) * (y2 - y1 1) order scores.argsort()[::-1] keep [] while order.size 0: i order[0] keep.append(i) xx1 np.maximum(x1[i], x1[order[1:]]) yy1 np.maximum(y1[i], y1[order[1:]]) xx2 np.minimum(x2[i], x2[order[1:]]) yy2 np.minimum(y2[i], y2[order[1:]]) w np.maximum(0.0, xx2 - xx1 1) h np.maximum(0.0, yy2 - yy1 1) inter w * h if method union: iou inter / (areas[i] areas[order[1:]] - inter) else: iou inter / np.minimum(areas[i], areas[order[1:]]) inds np.where(iou threshold)[0] order order[inds 1] return keep框回归的校准公式也很核心。P-Net 输出的 offset 是 4 个值dx1、dy1、dx2、dy2分别表示候选框四条边相对于原始候选框宽高的偏移比例。校准方法x1 x1 dx1 * box_width y1 y1 dy1 * box_height x2 x2 dx2 * box_width y2 y2 dy2 * box_height注意这里的 dx1 和 dx2 是以宽为基准dy1 和 dy2 是以高为基准四个值并不是绝对值而是比例所以框回归处理之后框的宽高也会同步变化这和多阶段目标检测里的回归分支是一个思路。如果你在写训练代码可能会疑惑为什么一个既有分类又有回归的任务用普通 L2 损失就行。MTCNN 的损失函数就是简单地把分类交叉熵损失、框回归的欧式距离损失和关键点回归损失按权重相加多个任务共享底层特征这也是“多任务级联”这个名字里“多任务”的含义。4. 完整推理流水线从图片到人脸框有了三个网络和配套函数接下来就是把它们串起来。下面是一份完整推理代码的核心逻辑def detect_faces(img): img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) height, width, _ img.shape base_scale 12.0 / min_face_size scales [] current_scale base_scale while min(height, width) * current_scale 12: scales.append(current_scale) current_scale * factor total_boxes [] for scale in scales: sw int(width * scale) sh int(height * scale) scaled_img cv2.resize(img, (sw, sh)) tensor_img torch.from_numpy(scaled_img).permute(2, 0, 1).float().unsqueeze(0) tensor_img (tensor_img - 127.5) / 128.0 cls, offset, _ pnet(tensor_img) cls cls[0, 1].detach().numpy() offset offset[0].detach().numpy() idxs np.where(cls threshold[0]) for idx in zip(*idxs): y, x idx score cls[y, x] box np.array([ (x * stride) / scale, (y * stride) / scale, (x * stride 12) / scale, (y * stride 12) / scale ]) reg offset[:, y, x] box_w box[2] - box[0] box_h box[3] - box[1] box[0] reg[0] * box_w box[1] reg[1] * box_h box[2] reg[2] * box_w box[3] reg[3] * box_h total_boxes.append(np.hstack([box, score])) total_boxes np.array(total_boxes) keep nms(total_boxes[:, :4], total_boxes[:, 4], 0.5) total_boxes total_boxes[keep]这里有一个特别容易错的细节P-Net 输出的特征图坐标点(y, x)并不是原图坐标它需要先乘以卷积步长 stride 再除以 scale才能映射回原图位置。因为 P-Net 的 backbone 包含一次 stride2 的最大池化所以输出的特征图分辨率是输入图的二分之一stride 为 2。而如果输入图就是原图的 scale 倍缩放那么最终的映射公式就是(x * stride) / scale。忘记除以 scale 是很多人写出 bug 的第一个来源。拿到候选框以后流程会继续往下走从原图中根据总框坐标截取对应区域缩放到 24x24送 R-NetR-Net 输出后再次阈值过滤、NMS、框回归从原图截取 48x48 区域送 O-NetO-Net 输出最终人脸框、置信度和关键点最后再做一次 NMS这一步对应的代码逻辑比较长但核心就是每一级都在做同一套动作截取框、resize、归一化、跑网络、过滤、NMS、框回归。我建议你把这套动作封装成一个 call 函数避免在三层网络里复制粘贴三遍差不多的代码。关于归一化MTCNN 常用的做法是(img - 127.5) / 128.0这个区间近似于 [-1, 1]主要是为了配合训练时使用的预处理方式。如果你后面要换用 ONNX 模型或者 TensorRT注意确认模型导出的归一化参数是否一致否则检测结果会整体漂移。5. 实测踩坑记录与性能优化建议代码跑通只是第一步真正常遇到的问题是检测效果不理想、速度慢或者在某些设备上不稳定。这部分我把自己踩过的坑整理一下希望能替你省下几个晚上。5.1 常见问题速查现象可能原因解决方法小脸检测不到min_face_size 设置过大或金字塔层数不足调小 min_face_size检查缩放因子是否过大同一张脸多个框NMS 阈值过小把 NMS 阈值设为 0.5 到 0.7 之间实测 0.5 效果均衡大量背景误检P-Net 阈值过低把 P-Net 阈值提高到 0.6R-Net 提高到 0.7人脸框偏大或偏小框回归没做或坐标映射错误检查 stride 和 scale 的换算逻辑关键点偏移O-Net 之后没做固定区域校正直接使用 O-Net 输出的关键点但需确认输入图的裁剪区域包含完整人脸推理速度慢金字塔层数太多O-Net 输入太多框适当增大 min_face_size、增大 R-Net 的阈值或用 TensorRT 优化这里多讲一个我遇到的真实例子。之前我把 P-Net 的阈值设成了 0.5结果在会议室场景下桌椅纹路被当成了一大堆人脸候选R-Net 阶段需要处理几千个框直接把吞吐量拉垮。后来我把 P-Net 阈值调到 0.6R-Net 调到 0.7检测速度几乎快了 3 倍而且准确率没有明显下降。这说明在级联结构里每一级的阈值调参对整体性能影响非常大不是随便填一个数就行。5.2 可以留作扩展的性能优化点如果你的项目对实时性要求比较高我建议你从三个方向入手导出 ONNX 并用 TensorRT 加速。MTCNN 三个网络都是典型的 CNN没有复杂的动态结构非常适合直接导出 ONNX。导出时把输入尺寸固定成最小人脸尺寸比如 P-Net 输入是 12x12R-Net 是 24x24O-Net 是 48x48模型便可以在推理引擎上做算子融合优化。实测下来TensorRT 加速后整体推理速度能达到 CPU 下 OpenCV DNN 方案的 3 到 4 倍。在 P-Net 阶段做区域切块再 NMS。如果是超高清大图P-Net 会一次性产生几千个候选框。一种常见的优化手段是把原图先切分成若干重叠区域对每个区域单独跑 P-Net 和 NMS最后把结果合并再做一次 NMS。这样能避免在全局图上产生过多重叠框也方便并行处理。根据上一帧结果做 tracking 辅助。如果是视频流应用可以在检测到人脸之后把下一帧输入根据上一帧人脸框的位置做局部裁剪而不是直接跑全图金字塔。这种方法能大幅减少计算量代价是突然出现的新人脸会晚一两帧才被检测到看具体场景取舍。另外一个容易忽视的细节是多人脸密集场景下NMS 的 IoU 阈值建议稍微调低一点比如 0.4。我用过默认的 0.7在戴口罩或者人脸密集排队场景里经常出现两个人的框被合并成一个的情况。调低 IoU 阈值可以更稳妥地保留相邻人脸的独立框代价是可能会有少量重复框但通常对后续对齐任务影响不大。6. 最后的经验总结动手写一遍比单纯调库值太多我现在再回过头看MTCNN 是我接触过的代码实现中最适合精读的检测算法之一。它的模型结构足够简单三阶段串起来也就几百行代码但如果能把每一行都吃透你对卷积特征图坐标映射、NMS、框回归、多任务学习这几个核心问题的理解会比看十篇博客都深。建议你把代码一行行敲一遍而不是直接复制粘贴。敲的过程中你会自然去思考“为什么这里 stride 是 2”“为什么这里要除以 scale”“为什么 P-Net 输出三层分支而不是一层”这些问题的答案才是实现背后的真正价值。如果你打算把它接进自己的项目还有一个实用建议优先把模型导出成固定维度、批量友好的 ONNX 格式然后在运行时保持输入图片按金字塔顺序排列。这样你后续即使换推理框架核心逻辑也不用改动太多。最后分享一个我自己常用的验证技巧找一组包含大脸、小脸、侧脸、遮挡人脸的测试图片把每一级网络的可视化结果依次打印出来。P-Net 阶段应该能看到密集但位置粗糙的框R-Net 之后框数明显下降、位置更准O-Net 最终输出的时候框的位置和关键点应该已经和你肉眼判断非常接近。按这个标准逐级验证代码如果真的有问题几乎一眼就能定位到是哪一层出的问题。祝你把 MTCNN 这份代码吃透跑出自己的完善版本。本文还有配套的精品资源点击获取