人脸检测这个方向,我从传统 Haar 级联一路跟到深度学习方案,踩过的坑不算少。MTCNN 算是我早期真正动手复现并跑通全流程的模型之一,它把“人脸检测”和“关键点定位”两件事用一个级联网络串起来,思路干净、工程落地性强,直到现在很多边缘设备上的方案还在用它做前置检测。这篇内容我打算把 MTCNN 从论文思路到 Python 代码实现完整拆一遍,包括三级网络各自在干什么、训练数据怎么造、OHEM 和在线难例挖掘为什么关键、推理阶段图像金字塔和 NMS 怎么配合,以及我自己复现时遇到的那些“论文里没写但代码里必须处理”的细节。适合已经了解卷积网络基础、想真正把 MTCNN 跑起来并搞懂每一步为什么这么做的同学,也适合需要把它集成到实际项目里的人参考。
1. 整体设计思路与三级级联架构拆解
1.1 为什么是“级联”而不是“一个大网络”
人脸检测这个任务有个很别扭的地方:一张图里人脸尺寸差异极大,从几十像素到几百像素都有,而且背景区域远多于人脸区域。如果用一个统一的检测网络去扫全图,要么计算量爆炸,要么小脸漏检。MTCNN 的核心思路是把检测拆成由粗到细的三步,每一步只做一件相对简单的事,前一步的输出作为后一步的输入,逐级提高精度、降低候选数量。
这个设计背后的逻辑其实很朴素:先用极低的计算成本把绝大多数背景区域干掉,只留下少量“可能是人脸”的候选框;然后对这些候选框做更精细的筛选和边框回归;最后再对剩下的少量框做关键点定位。三级网络分别是 P-Net(Proposal Network)、R-Net(Refine Network)、O-Net(Output Network)。名字里的 Proposal、Refine、Output 已经把各自职责说清楚了。
我个人的理解是,级联的本质是“计算资源按难度分配”。P-Net 面对的是全图所有位置,数量巨大,所以它必须极轻量,用全卷积结构一次前向就能输出整张特征图上的候选;R-Net 面对的是 P-Net 筛完的几百个候选,数量少了一个量级,可以稍微重一点;O-Net 面对的是几十个候选,可以做得更重,同时多输出一个关键点分支。这种“漏斗式”结构让整体计算量远小于单一大网络。
1.2 三级网络各自的输入输出与职责
把三级网络的关键参数列出来对比会更清楚。下面这张表是我根据论文和实际代码整理的核心配置,注意 P-Net 的输入尺寸其实不固定,因为它是全卷积的,但训练时会裁成 12x12 的 patch。
| 网络 | 输入尺寸 | 核心职责 | 输出内容 | 卷积层特点 |
|---|---|---|---|---|
| P-Net | 12x12(训练patch) | 生成候选框 | 人脸概率 + 边框回归 | 全卷积,无全连接 |
| R-Net | 24x24 | 过滤误检、精修边框 | 人脸概率 + 边框回归 | 含全连接层 |
| O-Net | 48x48 | 最终判定 + 关键点 | 人脸概率 + 边框回归 + 5关键点 | 含全连接层 |
P-Net 输出的是特征图上每个位置对应原图一个区域的“是否人脸”概率和边框偏移。因为是全卷积,输入可以是任意尺寸,输出就是一张概率热力图。这一点非常关键,也是它能高效处理全图的原因。R-Net 和 O-Net 因为含全连接层,输入尺寸必须固定,所以要把候选框统一缩放到 24x24 和 48x48。
1.3 边框回归的编码方式:为什么用偏移量而不是绝对坐标
MTCNN 的边框回归不是直接预测框的四个坐标,而是预测相对于“锚框”或“候选框”的偏移量。具体来说,对于每个候选框,网络输出四个值,分别对应框中心点的 x、y 偏移和宽高的缩放比例。这种编码方式的好处是网络学习的是“修正量”而不是“绝对位置”,数值范围更稳定,收敛更快。
论文里用的编码公式是:中心点偏移除以宽高,宽高用对数比。我实测下来这个编码方式确实比直接回归坐标稳定很多,尤其是当候选框本身已经比较接近真实框时,网络只需要学一个很小的修正量。这个细节在代码实现里必须严格对齐,否则训练会莫名其妙不收敛——我第一版就是编码方式写错了,loss 一直卡在 0.6 下不去。
2. 训练数据构造与难例挖掘实操要点
2.1 正负样本、部分样本的划分标准
MTCNN 训练数据分三类:正样本(人脸)、负样本(背景)、部分样本(Part face,只有部分人脸区域)。划分依据是候选框与真实框的 IoU(交并比)。具体标准是:IoU 大于 0.65 算正样本,小于 0.3 算负样本,介于 0.4 到 0.65 之间算部分样本。部分样本的作用是让网络学会处理那些“像人脸但不完整”的区域,提升对遮挡和边缘情况的鲁棒性。
这里有个容易忽略的点:负样本的数量要远多于正样本,因为真实场景里背景占绝大多数。论文里正负比大概是 1:3 左右,但实际训练时如果负样本太少,网络会倾向于把所有东西都判成人脸,误检率飙升。我在复现时一开始正负比设成 1:1,结果模型在简单背景上表现还行,一到复杂场景就满屏误检。
2.2 在线难例挖掘(OHEM)到底怎么落地
OHEM 是 MTCNN 训练里非常关键的一环,但论文里只是一笔带过。它的核心思想是:在每个 batch 里,不把所有样本的 loss 都平等对待,而是挑出 loss 最大的那部分样本(也就是网络当前最“搞不定”的难例)重点训练。这样做的好处是训练效率高,模型能快速聚焦到那些容易分错的区域。
具体实现上,对于每个 batch,先前向算出所有样本的 loss,然后按 loss 从大到小排序,只保留前 70% 的样本参与反向传播,剩下的 30% 直接丢弃。注意这里丢弃的是简单样本,不是难例。我一开始理解反了,以为要保留简单样本,结果模型学得特别慢。这个比例不是固定的,可以根据任务调整,但 70% 是个比较稳的起点。
提示:OHEM 在 P-Net 和 R-Net 阶段效果最明显,因为这两个阶段候选框数量多、噪声大。O-Net 阶段候选已经比较干净,OHEM 的收益相对小一些,但加上也没坏处。
2.3 数据增强的几种必要手段
人脸检测的数据增强不能随便做,因为有些增强会破坏人脸的结构信息。我常用的几种是:随机水平翻转(这个最安全,人脸左右对称)、随机裁剪(模拟不同构图)、颜色抖动(模拟不同光照)、以及小角度的随机旋转(一般不超过 15 度,角度大了人脸结构就变形了)。缩放也是必须的,因为要模拟不同尺度的人脸。
这里有个坑:做随机裁剪时,要保证裁剪后的人脸框仍然满足正样本的 IoU 标准,否则标签就错了。我建议在增强后重新计算一次 IoU,把不满足条件的样本过滤掉。另外,颜色抖动不要做得太猛,饱和度、亮度、对比度的变化范围控制在 0.8 到 1.2 倍之间比较合适,太猛会让网络学到错误的颜色先验。
3. 三级网络结构实现与关键代码解析
3.1 P-Net 的全卷积实现细节
P-Net 的结构非常轻,三个卷积层加一个 1x1 卷积输出。第一层是 3x3 卷积,输出 10 个通道,接 PReLU 和最大池化;第二层是 3x3 卷积,输出 16 个通道,同样接 PReLU 和池化;第三层是 3x3 卷积,输出 32 个通道;最后用两个 1x1 卷积分别输出人脸分类(2 通道)和边框回归(4 通道)。
用 PyTorch 实现时,关键点是不要加全连接层,保持全卷积特性。这样推理时输入任意尺寸的图像,输出就是对应尺寸的特征图。下面是我常用的 P-Net 定义:
import torch import torch.nn as nn class PNet(nn.Module): def __init__(self): super(PNet, self).__init__() self.conv1 = nn.Conv2d(3, 10, 3, 1) self.prelu1 = nn.PReLU(10) self.pool1 = nn.MaxPool2d(2, 2, ceil_mode=True) self.conv2 = nn.Conv2d(10, 16, 3, 1) self.prelu2 = nn.PReLU(16) self.conv3 = nn.Conv2d(16, 32, 3, 1) self.prelu3 = nn.PReLU(32) self.conv4_1 = nn.Conv2d(32, 2, 1, 1) self.conv4_2 = nn.Conv2d(32, 4, 1, 1) self.softmax = nn.Softmax(dim=1) def forward(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.softmax(self.conv4_1(x)) reg = self.conv4_2(x) return cls, reg注意ceil_mode=True这个参数,它保证池化后的尺寸向上取整,避免特征图尺寸计算出现偏差。这个细节在推理阶段处理边界时很重要,我踩过一次坑,因为默认的向下取整导致最后几个像素的候选框丢失。
3.2 R-Net 与 O-Net 的全连接层设计
R-Net 比 P-Net 多了一个全连接层,输入固定 24x24。结构是:3x3 卷积(28 通道)→ 池化 → 3x3 卷积(48 通道)→ 池化 → 2x2 卷积(64 通道)→ 全连接(128 维)→ 输出分类和回归。O-Net 类似,但输入是 48x48,最后多一个关键点分支输出 10 个值(5 个点各 2 个坐标)。
全连接层的存在意味着 R-Net 和 O-Net 不能像 P-Net 那样直接吃任意尺寸输入。推理时要把每个候选框从原图裁剪出来,缩放到固定尺寸再送进网络。这个裁剪和缩放的过程在候选框很多时会有性能开销,所以实际部署时通常会做批处理,把多个候选框拼成一个 batch 一起前向。
3.3 损失函数的三分支组合
MTCNN 的损失函数由三部分组成:人脸分类损失(交叉熵)、边框回归损失(平方损失)、关键点损失(平方损失)。训练时不是每个样本都参与所有损失计算,而是根据样本类型决定。正样本和部分样本参与分类损失和回归损失,正样本额外参与关键点损失,负样本只参与分类损失。
这个设计的原因是:负样本没有边框和关键点标注,强行让它参与回归损失会引入噪声。我在实现时用一个 mask 来控制每个样本参与哪些损失,具体做法是给每个样本打一个类型标签,然后根据标签生成三个 mask 向量。这个细节论文里没细说,但不做的话训练效果会明显变差。
4. 推理流程与图像金字塔实战
4.1 图像金字塔的构建与缩放因子选择
推理阶段最核心的机制是图像金字塔。因为 P-Net 虽然能处理任意尺寸,但训练时只见过 12x12 的人脸,如果直接拿原图去检测,大脸能检测到,小脸就漏了。所以要把原图按不同比例缩放,形成一个金字塔,每一层都送进 P-Net 检测,这样不同尺度的人脸都能在某一层上以接近 12x12 的尺寸被检测到。
缩放因子(factor)一般取 0.709,这个值是论文里给的,约等于 1/√2。为什么用这个值?因为它保证相邻两层之间的尺度变化是平滑的,不会漏掉中间尺度的人脸。如果 factor 取太大(比如 0.5),层数少、速度快,但可能漏检;取太小(比如 0.9),层数多、覆盖全,但计算量大。0.709 是个经验上的平衡点。
金字塔的最小尺寸一般设为 12,也就是缩放到人脸小于 12 像素时就停止。这个值不能小于 P-Net 的输入尺寸,否则没有意义。
4.2 候选框的生成、NMS 与边框回归
每一层金字塔经过 P-Net 后,会得到一张概率热力图和对应的回归值。把概率高于阈值(一般 0.6)的位置取出来,结合回归值还原成原图坐标下的候选框,然后做 NMS(非极大值抑制)去掉重叠框。NMS 的 IoU 阈值一般设 0.5。
这里有个细节:P-Net 输出的回归值是相对于 12x12 输入框的偏移,还原到原图时要乘以当前金字塔层的缩放比例的倒数。这个坐标变换很容易写错,我建议写完后用一张只有单张人脸的图验证一下,看框的位置对不对。
R-Net 和 O-Net 阶段同样要做 NMS,但阈值可以逐步降低,因为候选框越来越少,可以容忍更多重叠。我通常 P-Net 用 0.5,R-Net 用 0.7,O-Net 用 0.7 或者直接不做(因为 O-Net 输出已经是最终结果)。
4.3 关键点输出的后处理
O-Net 输出的关键点是相对于 48x48 输入框的归一化坐标,还原到原图时要先乘以 48,再映射回原图坐标。5 个关键点分别是左眼、右眼、鼻尖、左嘴角、右嘴角。这些关键点可以直接用于人脸对齐,也可以作为后续识别的输入。
我实测下来,关键点的精度在正脸情况下很好,但侧脸和大角度旋转时会有偏差。如果项目对关键点精度要求高,建议在 O-Net 之后再加一个专门的关键点精修网络,或者用其他关键点检测方案做补充。
5. 常见问题排查与避坑经验实录
5.1 训练不收敛的几种典型原因
训练 MTCNN 最容易遇到的就是 loss 不下降或者震荡。我总结下来主要有几个原因:一是学习率太大,MTCNN 对学习率比较敏感,建议从 0.001 开始,用 Adam 优化器;二是边框回归的编码方式写错,这个前面提过,必须严格对齐论文公式;三是数据标签错误,尤其是增强后没有重新计算 IoU,导致正负样本标反;四是 OHEM 的比例设错,把难例丢了。
排查顺序建议是:先关掉 OHEM 和数据增强,用一小批干净数据跑,看 loss 能不能降到很低。如果能,再逐步加回增强和 OHEM,定位是哪一步引入的问题。这个方法我用了很多次,非常有效。
5.2 推理阶段漏检和误检的调优
漏检通常是因为概率阈值设太高,或者图像金字塔的 factor 太大导致某些尺度没覆盖到。可以先把阈值降到 0.5 试试,如果漏检明显减少,说明是阈值问题。误检多则相反,提高阈值,或者加强 NMS。
还有一个容易被忽略的点是输入图像的预处理。MTCNN 训练时用的是归一化到 [-1, 1] 的像素值,推理时如果忘了做同样的归一化,结果会差很多。我见过有人直接送 0-255 的原始像素进去,然后抱怨模型没用,其实就是预处理没对齐。
5.3 性能优化的一些实用技巧
MTCNN 在 CPU 上跑其实也能接受,但如果要实时,还是需要一些优化。我常用的几个手段:一是把 P-Net 的推理做成 batch,把金字塔各层拼成一个 batch 一起前向;二是用 ONNX 或 TensorRT 做推理加速;三是限制图像金字塔的层数,比如最大边超过 1000 像素时先缩小再检测。
另外,R-Net 和 O-Net 的候选框裁剪可以用 ROI Align 或者简单的批量裁剪来加速,避免 Python 循环逐个处理。这个在候选框多的时候提升很明显,我从逐个裁剪改成批量处理后,整体推理时间少了差不多 40%。
下面这张表是我整理的一些常见现象和对应排查方向,方便快速定位问题。
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| loss 不下降 | 学习率过大、编码错误 | 降学习率、核对回归编码 |
| 误检多 | 阈值低、负样本少 | 提高阈值、增加负样本 |
| 漏检多 | 阈值高、金字塔层数少 | 降阈值、减小 factor |
| 关键点偏移 | 后处理坐标变换错误 | 核对归一化还原公式 |
| 推理慢 | 逐个裁剪、无批处理 | 批量化、模型加速 |
6. 工程落地中的扩展与集成思路
6.1 与后续任务的衔接
MTCNN 的输出天然适合做人脸对齐的输入。拿到 5 个关键点后,可以用相似变换把人脸旋转到标准姿态,再送给识别网络。这个流程在人脸识别项目里非常常见。我建议把 MTCNN 封装成一个独立的检测模块,输入图像输出人脸框和关键点,这样后续换检测器时不影响其他部分。
如果项目需要跟踪,可以把 MTCNN 的检测结果和简单的跟踪算法结合,比如在检测间隔帧用光流或卡尔曼滤波预测位置,减少检测频率,提升整体帧率。
6.2 模型轻量化与部署考量
MTCNN 本身已经比较轻了,但如果要部署到算力受限的设备,还可以进一步压缩。常见做法是把卷积通道数减半,或者用深度可分离卷积替换标准卷积。不过要注意,压缩后精度会下降,需要重新训练和调参。
部署时建议把三级网络分别导出,因为它们的输入尺寸不同,分开导出更灵活。P-Net 导出成动态输入尺寸的模型,R-Net 和 O-Net 导出成固定尺寸。这样在推理时可以针对不同阶段做不同的批处理策略。
我在实际项目里发现,MTCNN 最大的价值不是它的精度有多高,而是它的结构清晰、易于修改和集成。你可以很方便地替换其中某一级网络,或者调整阈值来适配不同场景。这种灵活性在快速迭代的项目里比单纯的精度更重要。如果后续要扩展到多人脸、密集人脸场景,可以考虑在 O-Net 之后加一个基于热力图的精修,或者直接换用更现代的检测框架,但 MTCNN 作为入门和基线方案,依然值得完整跑一遍。