MTCNN人脸检测系统源码拆解:多任务级联卷积网络的工程实践
十几年前我第一次在嵌入式板子上做人脸检测的时候试过一堆主流检测模型结果不是精度不够就是速度拉胯。后来回头用MTCNN这套多任务级联卷积网络的方案反倒是稳稳跑起来了。这两年总有人问我都什么年代了还有人用MTCNN做人脸检测系统我的回答是如果你的目标是在CPU上跑得动、误检率可接受、能输出关键点、源码可控那MTCNN依然是性价比极高的选择。这篇博客我会完整拆解一套基于MTCNN的人脸检测系统源码从三个子网络的原理、损失函数设计到训练数据准备、推理流程、部署优化把我在实际项目中踩过的坑和调参经验一并写出来。MTCNN的全称是Multi-task Cascaded Convolutional Networks翻译过来就是多任务级联卷积网络。它最核心的亮点是把人脸检测拆成了三个阶段每个阶段用一个独立的轻量卷积网络处理先用一个极小网络快速扫出大量候选框再用中等网络过滤最后用大网络精修并输出人脸关键点。这样做的直接好处是计算量被大幅分散绝大多数候选框在第一个阶段就被丢掉了后面两个阶段只需要处理很少的框整体速度自然快。适合的读者包括想把检测算法跑在CPU或边缘设备上的开发者、正在毕设或课程项目中做人脸识别前置模块的学生、以及想做低延迟视频流检测但不想上重型模型的技术人员。1. 为什么今天的工程里还会用MTCNN1.1 边缘设备上精度够用、速度可控的定位最近几年的检测模型确实猛动不动就是几十上百层的骨干网络加上FPN、Transformer这些结构精度一个比一个高。但问题在于推理开销。我自己在某ARM开发板上试过跑一个轻量级的通用检测模型输入分辨率还刻意压低到320x320单帧推理耗时仍然要两百多毫秒而且还没算图像预处理。做产品的时候你总不能让用户对着摄像头等半秒才看到框吧。MTCNN三个网络加起来参数量非常小P-Net只有几万参数R-Net和O-Net稍大但也远小于通用检测模型。更重要的是它的级联设计天然适合边缘场景第一层P-Net在整幅图像金字塔上扫一遍虽然执行次数多但每次只处理12x12的小输入计算量极低大量非人脸区域在这一层就被排除了后面的R-Net和O-Net只在候选人脸上做精细判断。我在实际测试中1080P图像纯CPU推理一轮完整检测在几百毫秒量级通过设置合适的minsize和图像金字塔层数可以进一步压到一两百毫秒。1.2 级联结构的核心思想三次筛选代替一次暴力搜索用一个生活化的类比来理解级联你去停车场找一辆红色轿车不会挨个趴在每辆车窗户上看半天而是先用眼睛快速扫一遍全场圈出所有红色的车然后走近看好牌照开头几个字母最后再蹲下来确认完整车牌和车型。MTCNN的三级网络就是这样一个先粗筛、再审慎、最后精雕的过程。P-NetProposal Network负责快速生成候选区域它用全卷积的方式在图像金字塔的每一层上滑动输出每个位置是否是人脸的概率以及粗略的边框坐标R-NetRefine Network把P-Net给出的候选框缩放到24x24再进网络做进一步的二分类和边框调整过滤掉大量假正例O-NetOutput Network输入48x48除了给出最终的人脸概率和边框还会回归5个关键点位置左眼、右眼、鼻尖、左嘴角、右嘴角。每过一级候选人脸数量减少一个数量级网络复杂度上升一个台阶信息量也逐渐丰富。这套思路放在今天的检测框架里依然不过时很多两阶段检测器也是类似的逻辑。1.3 源码选型时我最在意的三个特性如果你打算在项目里集成MTCNN对源码的第一要求不是好看而是好改。我见过不少开源实现代码写得花团锦簇真要换一个损失函数或者改一下NMS阈值就得从底层动刀非常痛苦。我整理自己的这套源码时最看重三个特性。第一三个网络的结构定义必须独立清晰P-Net、R-Net、O-Net分文件或分模块管理方便单独导出ONNX、单独做量化。第二训练和推理路径要分开训练时用PyTorch的动态图方便调试推理时可以把三个网络分别导出成静态图或ONNX格式避免在部署环境里再装一套训练框架。第三数据准备代码要跟模型解耦检测框标注、关键点标注、正负样本生成这些逻辑独立出来这样换数据集时不需要动网络代码。这三个特性决定了你后面改模型、换部署平台时的效率。我在下面几个部分展开的源码组织方式就是围绕这三个原则来的。2. 三个子网络的分工与损失函数设计2.1 P-Net12x12的小感受野怎么完成初筛P-Net的输入设计得很极端只有12x12像素。你可能会觉得这么小的输入能看出什么其实它看的不是细节而是局部纹理的统计特征——人脸的五官区域通常有稳定的明暗变化模式比如眼睛周围比额头暗、眉毛和眼睛形成明显的梯度反差。12x12虽然小但对于这块区域有没有人脸感这种粗粒度判断已经足够用了。网络结构非常精简三层卷积加一层最后的多任务输出。第一层卷积3x3输出10个通道接一个2x2的max pooling第二层3x3卷积输出16个通道第三层3x3卷积输出32个通道最后分成三个分支分别输出1x1x2的人脸分类得分、1x1x8的边框坐标偏移、1x1x10的关键点坐标虽然O-Net之前的关键点分支其实不参与最终输出但源码里经常保留。因为P-Net是全卷积结构所以它不限制输入尺寸可以接受任意大小的图像金字塔层这也是它能在整幅图上高效滑动的关键。2.2 R-Net和O-Net从粗调到精调的信息递增R-Net的输入是24x24网络结构比P-Net多了全连接层。它做的事情本质上是复核P-Net给出的候选框大部分是粗略的可能包含背景、身体、树干等明显非人脸区域R-Net需要更细地分辨这到底是不是人脸。24x24的输入让网络能看到眼睛、鼻子、嘴巴的相对位置关系能过滤掉很多P-Net阶段误判的纹理块。O-Net则更进一步输入48x48网络更深、卷积通道更多除了做人脸/非人脸分类和边框精修还要输出5个关键点的精确坐标。这个阶段对计算量的消耗是三个网络里最大的但因为前面两级过滤之后剩下的候选框可能只有几个到几十个所以整体开销依然可控。O-Net的关键点回归对人脸对齐、活体检测这类下游任务非常重要我做的系统里就用MTCNN的5点输出去做人脸对齐预处理效果比单纯用边框裁剪稳定很多。2.3 分类、框回归、关键点回归的多任务联合损失三个子网络的训练都涉及多任务损失这也是MTCNN名字里Multi-task的来源。以O-Net为例损失函数由三部分组成人脸分类是二分类任务直接用交叉熵损失边框回归是四个数值x1、y1、x2、y2或者转换为中心点加宽高用欧氏距离损失关键点回归是10个数值5个点各两个坐标同样用欧氏距离损失。三个损失加权求和得到总的训练目标。实际训练时权重一般取分类损失权重1.0边框回归权重0.5关键点回归权重0.5。但这个比例不是死的我在训练时发现如果关键点标注质量比较差可以适当调低关键点损失的权重避免网络被脏标注带偏。还有个细节是关键点回归只在那些带关键点标注的样本上计算纯人脸框样本不参与关键点损失。2.4 在线困难样本挖掘被多数实现忽略的训练细节MTCNN的训练还有一个关键设计在线困难样本挖掘Online Hard Example Mining。简单说就是对每个batch内的负样本按分类损失排序只取损失最大的前一定比例论文里是70%做反向传播其余丢弃。这样做的好处是训练时网络始终关注那些容易被误判成脸的难例而不是在已经能分得很清的简单背景上浪费梯度。我早期训练时偷懒没做这一步结果模型在纯背景图像上经常误检纹理丰富的地板、树叶、草丛全是假正例。后来把OHEM加上误检率立刻下降了一大截。实现方式也不复杂在PyTorch里就是计算完分类损失后用topk筛选出难样本的索引然后把三个任务的损失都按这个索引做mask再反向传播。3. 从零搭一个可运行的检测源码目录与数据准备3.1 依赖环境与版本组合我推荐直接用PyTorch作为训练框架推理阶段可以导出ONNX配合运行时加速。依赖清单不复杂pytorch、torchvision主要用里面的NMS函数、opencv-python或Pillow做图像读取和缩放、numpy做数组操作。如果要做ONNX导出再加onnx和onnxruntime。版本组合上踩过一个坑PyTorch 1.x和2.x导出的ONNX在算子兼容性上有细微差别如果你是沿用旧项目升级环境建议先把三个网络的单独推理跑通再导出测试。我给一个比较稳的版本搭配Python 3.8或3.10PyTorch 1.132.1OpenCV 4.5以上。具体不必严格到某一个小版本但要确保CUDA如果训练用GPU版本跟PyTorch对应。3.2 工程目录结构设计一套清晰的目录结构能帮你省掉大量改代码的时间。我建议这样组织mtcnn/ ├── data/ │ ├── prepare_data.py │ └── dataloader.py ├── models/ │ ├── pnet.py │ ├── rnet.py │ ├── onet.py │ └── common.py ├── train/ │ ├── train_pnet.py │ ├── train_rnet.py │ └── train_onet.py ├── inference/ │ ├── detector.py │ ├── pyramid.py │ ├── nms.py │ └── align.py ├── config.py └── utils.pymodels目录下三个网络各自独立每个文件里只有网络结构定义和初始化方法不掺其他逻辑train目录下三个训练脚本独立因为三个网络的数据分布和训练策略差别很大分开写更清晰inference目录下的detector.py是主推理入口它调用pyramid.py生成图像金字塔再依次跑P-Net、R-Net、O-Net最后输出检测结果。config.py统一放超参数比如图像金字塔缩放因子、NMS阈值、最小人脸尺寸等。这样你调参时只需要改一个文件不需要去推理代码里翻常量。3.3 训练数据标注格式与样本组织人脸检测数据集一般有两类标注一类是纯框标注给定人脸矩形的左上角和右下角坐标一类是带关键点标注额外给5个关键点坐标。MTCNN训练需要对标注做进一步组织生成四种样本正样本是人脸框与人脸标定框IoU大于0.65的裁剪图负样本是IoU小于0.3的任意裁剪图完全不含人脸或只含极小部分人脸部分样本是IoU在0.3到0.65之间的裁剪图边框回归任务需要这类样本关键点样本是带关键点标注的裁剪图用于训练O-Net的关键点输出。样本比例也需要控制一般推荐负样本数量最多。我在项目里按负:正:部分样本 3:1:1来采关键点样本单独按正样本量的2倍准备。比例失衡会导致网络对背景不敏感误检率上升。数据生成脚本里最关键的是随机裁剪的算法要从原图上按不同位置、尺度切出子图再计算与标注框的IoU根据IoU区间打标签。这个过程最耗时建议用opencv的多线程做预处理把裁剪和缩放并行化。4. 推理流程拆解图像金字塔、框映射和NMS4.1 图像金字塔为什么检测小脸必须多尺度缩放MTCNN的P-Net输入固定是12x12但图像里的人脸大小是不固定的。如果直接把整幅图缩到12x12小脸会糊成一团大脸又会被强行压缩导致边框定位不准。解决方案就是构建图像金字塔把原图按一系列缩放因子逐级缩小每层都去做一次P-Net检测这样相当于用固定尺寸的检测窗口去适配不同大小的人脸。金字塔的层数不是随便定的它由两个参数控制minsize最小人脸尺寸和factor缩放因子。我在代码里这样实现def build_pyramid(image, minsize40, factor0.709): h, w image.shape[:2] min_side min(h, w) scales [] scale 12.0 / minsize while min_side * scale 12: scales.append(scale) scale * factor return scales这里的12是P-Net输入的最小边长。scale逻辑是如果有人脸最小要检测40像素那么图像需要缩小到12/40 0.3倍时40像素的人脸正好变成12像素进入P-Net然后scale不断乘0.709约等于根号2分之一让相邻两层金字塔的人脸尺度覆盖有足够的重叠避免漏检。factor选0.709是个经验值太大会漏掉某些尺度的人脸太小会生成过多金字塔层数拖慢速度。4.2 P-Net输出的坐标变换最容易出bug的映射关系P-Net的检测结果是在缩放后的金字塔层上给出的要映射回原图坐标就要除以这一层的scale。这一步是我见过最容易写错的地方很多人把坐标映射写反或者忘了乘回去结果检测框全部错位。还有一个P-Net特有的细节它的输出是12x12滑窗结果但经过stride和max pooling之后特征图上每个位置对应的原图感受野中心是有固定步长的。换句话说P-Net给出的每个候选框在原图上的跨度不是连续滑动得到的而是每隔几个像素采样一次。因此候选框坐标要做一次扩展变换通常是把框的宽高放大一定倍数比如乘以0.1或0.2的偏移量后再加上原坐标让框能包裹住完整的人脸区域。这部分在后处理代码里通常写成def box_regression(box, reg): # reg是网络输出的边框偏移需要先乘以标准偏差 reg reg * [0.1, 0.1, 0.2, 0.2] x1 box[0] reg[0] * box[2] # box[2]是框宽 y1 box[1] reg[1] * box[3] x2 box[0] reg[2] * box[2] y2 box[1] reg[3] * box[3] return [x1, y1, x2, y2]每个候选框都要做一遍框回归修正然后用NMS过滤掉重叠度过高的框。这个流程P-Net、R-Net、O-Net三个阶段都要做区别只是NMS阈值和是否输出关键点。4.3 两种NMS的阈值差异与后处理顺序NMS非极大值抑制在很多目标检测算法里都有MTCNN的特别之处在于三个阶段用的阈值不一样。P-Net阶段的候选框数量巨大且质量粗糙NMS阈值设得比较低比如0.5即两个框的IoU超过0.5就合并目的是快速消除重复框R-Net和O-Net阶段候选框数量已经很少NMS阈值可以放宽到0.7保留更多细节上的差异让更精准的框留到最后。实现上我推荐直接用torchvision.ops.nms它实现了标准NMS且速度很快。如果你要部署到没有PyTorch的环境那就在onmxruntime里用自定义op或者直接用numpy实现一个逻辑也不复杂按得分排序逐个剔除与当前保留框IoU超过阈值的框。这里有个工程细节先做框回归再做NMS还是先NMS再做框回归我试过两种顺序结论是先做框回归再做NMS效果更稳因为回归会把框的位置修正得更接近真实边界这时再做NMS能更准确地判断重叠关系。有些实现为了省事先NMS再回归框的重叠度判断可能失真。5. 训练与部署中我实测踩过的坑5.1 从公开人脸数据集裁剪样本时的大小脸问题训练MTCNN的样本裁剪不是简单地拿标注框直接裁。一个常见问题数据集中大脸和小脸的比例严重失衡如果直接按原图裁剪网络会偏向检测中等以上尺寸的人脸小脸漏检很多。我的做法是在裁剪阶段做数据均衡按人脸尺寸分桶每个桶里采样相同数量的正样本和负样本小脸桶适当过采样。另外裁剪时的尺度可以加一点随机性把人脸框随机放大或缩小10%20%这样网络对边框的起始位置更鲁棒检测框不会特别紧贴或者松垮。我在实际测试中发现随机扰动之后训练出来的模型在视频流中检测框的抖动明显减小对下游的人脸跟踪非常友好。5.2 推理端精度下降ONNX导出和FP16的影响训练好的PyTorch模型要部署一般会导出成ONNX格式。MTCNN的三个网络结构都不复杂导出本身一般不会报错但要注意两个坑。第一个坑是batch维度的设置。P-Net在推理时一次会输入很多候选框导出时建议把batch维度设成动态R-Net和O-Net则通常固定batch为1就行因为候选框是逐个推理的。如果你把动态batch搞错了部署时输入形状对不上就得重新导出。第二个坑是FP16半精度推理。在支持FP16的GPU上跑速度确实有提升但关键点回归的坐标精度会下降尤其是O-Net的输出5个关键点坐标可能偏移12个像素。如果你下游要做人脸对齐然后做人脸识别这个偏移会直接影响识别精度。我的建议如果算力不紧张O-Net保持FP32P-Net和R-Net可以放心用FP16。5.3 误检、漏检和关键点偏移的调试方法遇到误检多的时候先别急着调网络按照优先级排查线索误检假正例多先看P-Net阶段的NMS阈值和分类得分阈值。P-Net输出的分类得分是经过softmax的常用阈值在0.60.9之间阈值越低误检越多。R-Net和O-Net的阈值一般可以设得比P-Net高比如0.7。我通常把P-Net阈值设低一些宁可多给候选后面两阶段兜底R-Net和O-Net阈值设高一些保证最终精度。漏检假负例多优先检查图像金字塔的参数。minsize设置过大小脸全被忽略factor设置过大金字塔层数太少中间尺度的人脸直接漏掉。一个实用经验在测试集上统计人脸尺寸分布把minsize设成分布中最小人脸尺寸的一半左右factor用0.709固定不动。关键点偏移检查数据标注质量。关键点标注如果本身有系统偏差训练出来必然偏移。另外确认输入图像在进O-Net之前有没有做合适的人脸对齐很多关键点偏移其实是人脸框不准确导致的把框放宽一点再裁关键点样本会有帮助。6. 进一步优化量化、裁剪与多线程调度6.1 8bit量化后精度损失的边界测试端侧部署最常用的加速手段是INT8量化。MTCNN三个网络结构相对简单量化后精度损失通常可控。我在某平台实测的效果大概是这样P-Net和R-Net量化后分类准确率下降小于1%基本无感O-Net的关键点回归误差会增大一些平均偏差从0.5像素涨到1像素左右如果下游任务对关键点精度要求不高完全可接受。量化建议用ONNX Runtime的量化工具先收集一组校准数据几百张典型图像上P-Net输出的人脸区域再做静态量化。注意校准数据的分布要尽量接近真实场景如果训练数据全是室内人脸部署到户外场景校准量化结果会明显变差。校准数据里一定要包含难例比如侧脸、模糊脸、小脸否则量化后的模型在真实场景里容易退化。6.2 输入尺度、线程数与推理耗时的实测关系推理耗时跟四个因素强相关输入图像分辨率、minsize金字塔层数、运行线程数、是否量化。输入分辨率越大P-Net需要处理的像素总量越大耗时近似线性增长minsize越小金字塔层数越多同样增加P-Net的扫描次数。线程方面P-Net阶段因为要一把扫完整个金字塔适合用多线程并行处理每一层R-Net和O-Net阶段每个候选框独立推理天然适合多线程并发但并发数受候选框数量限制。实际工程里我给R-Net和O-Net设置的线程池大小是48再大反而因为线程切换开销导致收益递减。直线结果的经验是1280x720输入、minsize40、INT8量化、4线程总耗时能控制在100毫秒以内同样的条件在1080P下大概要翻倍。6.3 和跟踪、活体检测模块组合时的工程建议MTCNN在完整的人脸系统中通常不是单独存在的后面还会接人脸跟踪、活体检测、人脸识别等模块。我组合这些模块时有两个原则。第一不要让MTCNN每帧都做全量检测。视频流里人脸位置在连续帧之间变化不大检测模块可以每510帧跑一次全量MTCNN中间用光流或简单的IoU匹配做跟踪检测到的人脸作为跟踪的初始化。这样整条链路的CPU占用率会大幅下降。第二O-Net的关键点输出可以做轻量级质量评估。用左右眼连线的倾斜角度判断人脸是否端正鼻尖到两嘴角的相对位置关系判断是否为正面脸这些都可以在O-Net输出上直接算出来不需要额外跑一个姿态估计模型。我做过一个门禁场景的Demo就是利用MTCNN关键点快速筛选出正脸帧再送去做识别误识别率降了将近一半。另外要特别注意内存管理。MTCNN的推理管线会产生大量中间结果金字塔层、候选框数组在长时间运行的嵌入式设备上容易出现内存碎片。我的习惯是提前分配好固定大小的数组复用而不是反复创建临时对象尤其是opencv的Mat及时释放对大尺寸图像尤其重要。这套MTCNN检测系统我先后迭代了大半年从最初在PC上调参跑通到后来移植到嵌入式平台中间换过多种部署方式。回头看它并不是一个炫技的方案而是一个扎实的方案原理足够清晰代码足够直观每一层的计算开销都可预测。如果你正在评估人脸检测模块的选型我建议你把MTCNN当作一个重要基准——因为它让你能用最小的成本把整条链路跑通之后再决定要不要换成更重的模型。个人体会是越是追求极致速度的边缘场景这套级联检测思路越是值得反复揣摩。