简介:面向深度学习人脸检测学习者与工程实践者的MTCNN完整实现包,围绕P-Net、R-Net、O-Net三级级联网络展开,覆盖候选区域生成、边框回归、人脸对齐、关键点定位与非极大值抑制等核心流程。压缩包共80个文件、约23.13MB,主体为48个Python脚本,配套Jupyter Notebook分步教程、Caffe模型与网络配置、示例图片和视频,另有少量C/C++、CUDA及扩展模块文件,便于从训练、测试到视频检测逐层对照。已有990人学习,适合希望理解MTCNN实现细节、复现检测基线并开展二次开发的人。读者可从中获得三阶段网络定义、训练数据生成、联合损失与级联训练脚本、检测和对齐示例,以及模型转换、视频追踪和常见调试思路,并体会级联网络在精度与速度之间的权衡;目录按网络、训练、工具、数据等模块组织,结构清晰,便于按流程阅读与查漏补缺。
1. 为什么很多人脸检测流水线最后都留着 MTCNN 这一层
做深度学习人脸检测的人,几乎都会在某个阶段被 MTCNN 拦住一次:要么是现成检测库的框在小脸、侧脸上飘,要么是自己训的模型换个摄像头就失灵。MTCNN 这套三级级联结构把检测拆成粗筛、精修、关键点回归三步,好处很实在——输入尺寸不限、小脸召回高、顺带吐出 5 个关键点直接喂给人脸对齐。它不适合追求极致帧率的场景,但作为一条稳定的基线,很多工程最后还是会把它留作补充或对照方案。这份完整代码覆盖从图像金字塔构建、三级网络前向、边界框回归到 NMS 合并的全链路,适合已经跑通过检测模型、想搞清每个阈值到底在干什么的人。
2. 三级级联拆开看:P-Net、R-Net、O-Net 各管哪一段
理解 MTCNN 的关键不是三个网络的卷积层怎么写,而是每一级交出去的框,坐标定义是什么、阈值卡在哪、下一步会怎么用它。这三件事想不清楚,代码跑通也只是个黑匣子,稍微换个场景就得返工。
2.1 图像金字塔与 P-Net:全卷积为什么能吃掉任意尺寸
P-Net 是一个纯卷积网络,输入是 12×12 的图块,输出三样东西:2 维分类(人脸/非人脸)、4 维边界框回归量、10 维关键点回归量。因为整个网络里没有全连接层,把一张大图直接喂进去,等价于让一个 12×12 的滑窗在原图上逐像素滑动——特征图上一个点,就对应原图上一个 12×12 的窗口。这就是它不需要固定输入尺寸的原因,也是它速度还不错的根本。
问题在于:12×12 的窗口只能覆盖很小的人脸。所以要先做图像金字塔,把原图按比例逐层缩小,每一层都送给 P-Net 过一遍。缩得越小,原图里的大脸在缩放图里就变成了 12×12 左右的小脸,等于用一把固定尺寸的尺子量不同大小的东西。
def build_pyramid(img_h, img_w, min_face_size=20, factor=0.709): """返回一组缩放比例,从 1.0 开始逐层乘 factor,直到最短边上的 12 像素窗口 已经覆盖不了 min_face_size 要求的人脸为止。""" m = 12.0 / min_face_size # 12 是 P-Net 的窗口边长 min_len = min(img_h, img_w) * m scales = [] cur = 1.0 while min_len * cur >= 12: # 缩放后最短边不能小于 12 scales.append(cur) cur *= factor return scales这段代码里三个参数各有脾气。min_face_size是你能接受的最小人脸边长,设成 20 就代表小于 20 像素的脸不保证检出,设成 40 则金字塔层数骤减、速度上来但小脸全丢。factor控制相邻两层之间的缩放步长,0.709 是原始实现里的经验值,它让每层面积大约减半;调大到 0.8 层数变少、速度变快,但会漏掉“刚好卡在两层之间”的那部分尺寸;调到 0.6 层数变多,召回好一点,耗时线性增长。至于那个硬编码的12,它是 P-Net 的输入边长,改网络结构时必须同步改,否则坐标反算会整体错位。
每一层前向完之后,要把特征图上的点还原回原图坐标。这里的换算公式是全局最容易写错的地方,因为 P-Net 输出的回归量是相对于 12×12 窗口的归一化偏移,必须先乘窗口边长再叠加到原始框上:
# stride = 1(全卷积滑窗步长为 1),scale 为该层缩放比例的倒数 x1 = (stride * col + 1) / scale y1 = (stride * row + 1) / scale x2 = (stride * col + 1 + 12) / scale y2 = (stride * row + 1 + 12) / scale # 回归量还原:w/h 是上面算出的框宽高 w = x2 - x1 h = y2 - y1 x1 = x1 + w * reg[0] y1 = y1 + h * reg[1] x2 = x2 + w * reg[2] - 1 y2 = y2 + h * reg[3] - 1-1这个看似多余的减法经常被漏掉。P-Net 训练时的框坐标是“左上角到右下角”的闭区间表示法,宽高按x2 - x1 + 1算,反算回去时就要减 1 保持一致。漏了它,框会整体比真实人脸大一圈,而且在 R-Net 阶段还会被进一步放大——因为 R-Net 的裁剪是基于这个框算的。
2.2 R-Net 与 O-Net:裁出来的那块图,上下文留多少
金字塔层数降下来之后,P-Net 通常会吐出几千个候选框。R-Net 的任务是把这些框一个个裁出来、缩放到 24×24、再过网络判定,输出更精细的回归量。O-Net 同理,输入 48×48。级联的收益就在这里:P-Net 只做粗筛,代价极低的卷积把所有不可能的位置排掉,剩下几百个才值得用更深的网络细看。
裁剪这一步有两个细节值得单独说。
第一个是必须先补成正方形再缩放。做法是取框的宽高较大值作为边长,以原中心点为中心向外扩,超出图像边界的部分记录下来、裁剪时再截断:
def square_crop(box, img_w, img_h, offset=0.0): """box: [x1, y1, x2, y2];offset: 正方形边长在原有基础上额外放大的比例 返回 (裁剪框, 正方形原始坐标),后者用于把网络输出映射回原图""" x1, y1, x2, y2 = box w, h = x2 - x1 + 1, y2 - y1 + 1 side = max(w, h) cx, cy = x1 + w / 2 - 1, y1 + h / 2 - 1 half = side * (1 + offset) / 2 nx1, ny1 = int(round(cx - half)), int(round(cy - half)) nx2, ny2 = int(round(cx + half)), int(round(cy + half)) # 裁剪框截断到图像范围内,但正方形坐标不截断 dx1, dy1 = max(0, nx1), max(0, ny1) dx2, dy2 = min(img_w, nx2), min(img_h, ny2) return (dx1, dy1, dx2, dy2), (nx1, ny1, nx2, ny2)为什么要区分“裁剪框”和“正方形坐标”?因为网络输出是相对于完整正方形归一化的,如果直接拿截断后的裁剪框去换算,贴边的人脸关键点会全部往内挤。这个坑我在第 5 章还会展开。
offset控制上下文的冗余量。设成 0 就是严格按正方形裁,网络只能看到框内的内容;设成 0.1~0.2 会多看一圈周围的像素,对遮挡和侧脸的判定更稳,代价是框会略微外扩。原实现里阶段一到阶段二、阶段二到阶段三的裁剪通常用 0.0,但如果你跑的是密集小脸场景,我一般会调到 0.1 试试,回收率通常有提升。
第二个是每一级都要重新做 NMS 和阈值过滤,不是只在最后做一次。P-Net 出来几千个框,先按分类分数切一刀(常见 0.6 上下),再按 IOU 0.7 做 NMS,数量降到几百;R-Net 再切一刀(常见 0.7),再做一次 NMS;O-Net 最后收口。三级阈值可以不一样,而且应该不一样——前级阈值低一点保召回,后级阈值高一点保精度。
2.3 三张表把输入输出和参数钉死
调参之前先把每个网络的张量形状记住,报 shape 错误时能省一半时间:
| 网络 | 输入尺寸 | 分类输出 | 回归输出 | 关键点输出 | 典型分类阈值 | 典型 NMS IOU |
|---|---|---|---|---|---|---|
| P-Net | 任意(12×12 滑窗) | 2 | 4 | 10 | 0.6 | 0.7 |
| R-Net | 24×24 | 2 | 4 | 10 | 0.7 | 0.7 |
| O-Net | 48×48 | 2 | 4 | 10 | 0.7 ~ 0.9 | 0.7 |
回归量的单位是“相对于框宽高的比例”,不是像素。关键点输出是 5 个点的 (x, y),同样归一化到 [-1, 1],顺序通常是左眼、右眼、鼻尖、左嘴角、右嘴角。这三组输出的顺序在 PyTorch 版和早期 TensorFlow 版实现里并不统一,有的把分类放第一个输出、有点放最后一个,权重文件换了却没改读取顺序,表现就是“框全在但分数全是乱的”——这种问题极难靠肉眼发现,只能靠对齐文档或打印几个样本比对。
3. 把代码跑起来:依赖、权重、一次完整前向
3.1 环境与依赖,版本错位比缺包更烦
依赖项本身不复杂:数值计算、图像读写、深度学习框架三样。麻烦在版本。MTCNN 的代码里大量用到np.float32与np.float64的隐式转换、cv2.resize的插值方式、以及框架的unsqueeze/squeeze顺序,跨大版本升级时这些都可能悄悄变化。
# 建议用独立虚拟环境,避免和已有项目的框架版本打架 python -m venv venv_mtcnn source venv_mtcnn/bin/activate # Windows 下用 venv_mtcnn\Scripts\activate pip install "numpy>=1.23,<2.0" # numpy 2.x 对部分旧代码有破坏性改动 pip install "opencv-python>=4.6" pip install "torch>=2.0" # 按自己的 CUDA 版本选对应 wheelnumpy那一行不是保守,是血泪经验:不少 MTCNN 实现里用了np.int、np.float这类在 1.24 之后就已被移除的别名,跑起来直接AttributeError。遇到这种报错,改代码比降版本更划算,全局搜一遍替换成int/float即可。
CUDA 版本则决定了你能不能把三级网络搬到 GPU 上。但这里有个反直觉的结论:MTCNN 在三张网络上做 GPU 推理,很多时候并不比 CPU 快,因为每个阶段的批大小很小、裁剪和 resize 全是 CPU 操作、GPU 和内存之间的来回拷贝把收益吃干净了。我一般先跑 CPU,确认整条链路正确,再考虑把 R-Net 和 O-Net 的批处理打开搬上 GPU。
3.2 权重文件与目录约定
资源里的权重通常有三种格式,必须和代码匹配:
| 格式 | 常见文件名 | 加载方式 | 注意点 |
|---|---|---|---|
| PyTorch | pnet.pth/rnet.pth/onet.pth | torch.load+load_state_dict | 注意map_location,GPU 存的权重在 CPU 上加载会报错 |
| NumPy | pnet.npy等三件套 | np.load后逐层赋值 | 早期 TF 实现常用,层顺序必须和网络定义一一对应 |
| ONNX | mtcnn.onnx或分三个 | onnxruntime推理 | 动态轴没设对的话,金字塔每层都要重建 session |
我习惯把权重放在weights/下,配置文件里只写相对路径,避免换机器就崩。加载后强制做一次形状校验,比在推理中途报错好排查得多:
state = torch.load("weights/pnet.pth", map_location="cpu") missing, unexpected = model.load_state_dict(state, strict=False) assert not missing, f"缺失的层: {missing}" # 缺层说明权重版本不对 assert not unexpected, f"多余的层: {unexpected}" # 多层说明网络定义多写了东西 model.eval() # 千万别漏,漏了 BN/Dropout 行为不一致,分数会整体偏低strict=False配两个断言,是我在小网络权重上养成的习惯。eval()那一行是重点:MTCNN 的小网络里虽然有 BatchNorm,但在推理时必须切到 eval 模式,否则单张图输入的统计量完全不可靠,表现出来就是“同一张图跑两次分数不一样”。
3.3 最小可运行脚本:一次前向到最后一张图
下面这段把前面讲的串起来,从读图到画出框和关键点,中间的裁剪、缩放、阈值过滤、NMS 都调用资源里的函数,方便对着改。
import cv2 import numpy as np import torch from mtcnn_core import PNet, RNet, ONet # 资源里的三级网络定义 from mtcnn_core.utils import build_pyramid, nms, square_crop, calibrate_box device = torch.device("cpu") # 先 CPU 验证链路,再换 cuda pnet, rnet, onet = PNet().to(device), RNet().to(device), ONet().to(device) for net, p in [(pnet, "weights/pnet.pth"), (rnet, "weights/rnet.pth"), (onet, "weights/onet.pth")]: net.load_state_dict(torch.load(p, map_location=device)) net.eval() img = cv2.cvtColor(cv2.imread("test.jpg"), cv2.COLOR_BGR2RGB) # 统一到 RGB h, w = img.shape[:2] # 阶段一:金字塔 + P-Net boxes = [] for scale in build_pyramid(h, w, min_face_size=20, factor=0.709): sh, sw = int(h * scale), int(w * scale) scaled = cv2.resize(img, (sw, sh), interpolation=cv2.INTER_AREA) x = ((scaled.astype(np.float32) - 127.5) / 128.0).transpose(2, 0, 1)[None] with torch.no_grad(): cls, reg, lm = pnet(torch.from_numpy(x).to(device)) boxes.append(decode_pnet(cls, reg, lm, scale, thresh=0.6)) # 含坐标反算 boxes = nms(np.vstack(boxes), 0.7, mode="Union") # 阶段二 / 三:裁图 -> 缩放 -> 前向 -> 二次过滤 for net, size, thresh in [(rnet, 24, 0.7), (onet, 48, 0.7)]: patches, squares = [], [] for box in boxes: crop, square = square_crop(box[:4], w, h, offset=0.0) patch = img[crop[1]:crop[3], crop[0]:crop[2]] patches.append(cv2.resize(patch, (size, size), interpolation=cv2.INTER_AREA)) squares.append(square) batch = ((np.stack(patches).astype(np.float32) - 127.5) / 128.0).transpose(0, 3, 1, 2) with torch.no_grad(): cls, reg, lm = net(torch.from_numpy(batch).to(device)) boxes = filter_and_refine(boxes, cls, reg, lm, squares, thresh) canvas = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) for b in boxes: x1, y1, x2, y2 = [int(v) for v in b[:4]] cv2.rectangle(canvas, (x1, y1), (x2, y2), (0, 255, 0), 2) for i in range(5): # 5 个关键点 cv2.circle(canvas, (int(b[5 + 2 * i]), int(b[6 + 2 * i])), 2, (0, 0, 255), -1) cv2.imwrite("out.jpg", canvas)几个必须解释的地方:
(scaled - 127.5) / 128.0是 MTCNN 训练时的归一化方式,均值 127.5、标准差 128。这一步写错(比如直接除以 255,或者忘了减均值)是最常见的“框全丢”原因,因为网络内部的特征分布整体偏移,分类头会一律判成负样本。
transpose(2, 0, 1)[None]是把 HWC 转成 CHW 再加一维 batch。批处理阶段用transpose(0, 3, 1, 2),两者的区别只在批量维度插入的位置,写混了会得到形状对但语义错的张量,结果就是框位置全乱但不报错。
interpolation=cv2.INTER_AREA在缩小图像时比默认的双线性更接近训练分布,尤其是金字塔做到后面几层,用错插值会明显掉召回。
mode="Union"是 NMS 的合并策略,表示按并集面积算 IOU。后处理里还有一个Min模式,第 4 章细说。
4. 从网络输出到能用的框:回归还原、NMS 合并、五个关键点
4.1 边界框回归量怎么还原才不掉进坐标陷阱
网络吐出的 4 个回归值不是绝对坐标,是相对于当前框的宽高做的比例偏移。还原时一定要用当前框的宽高,而不是原图宽高,也不是裁剪框宽高:
def calibrate(box, reg): """box: [x1, y1, x2, y2] 原图坐标;reg: 网络输出的 4 维回归量""" w = box[2] - box[0] + 1 h = box[3] - box[1] + 1 return [ box[0] + w * reg[0], box[1] + h * reg[1], box[2] + w * reg[2] - 1, box[3] + h * reg[3] - 1, ]三级网络都要做这一步,但基准框不同:P-Net 的基准是滑窗反算出来的 12×12 框,R-Net 的基准是 P-Net 给的那个框,O-Net 的基准是 R-Net 给的框。如果后两级算完之后再拿原图尺寸去乘一遍,框会飞出去,典型表现是“大部分框对,少数框宽度是原图的两倍”。
另外注意-1的位置:前两个坐标不加,后两个坐标减 1。这是因为 P-Net 阶段反算时已经用过一次+12(窗口边长),坐标体系是闭区间。整条链路里只要有一处用了左闭右开的表示法,后面的关键点就会整体偏半个像素到一个像素,画在 112×112 的对齐图上肉眼可见。
4.2 IOU-NMS 的两种合并模式,选错了框会变胖
NMS 本身不难,难在合并框时用哪种坐标。Union模式取两个框的并集,Min模式取交集。人脸检测里最后一步通常要用 Min,因为 Union 会把相邻但不同的人脸框、以及同一个人的多个候选框合并成一个更大的框,表现出来就是“框比脸大一圈,且两张挨着的脸被合成一个”。
def iou(box, boxes, mode="Union"): """box: (4,), boxes: (N, 4),均为 [x1, y1, x2, y2] 闭区间""" x1 = np.maximum(box[0], boxes[:, 0]) y1 = np.maximum(box[1], boxes[:, 1]) x2 = np.minimum(box[2], boxes[:, 2]) y2 = np.minimum(box[3], boxes[:, 3]) inter_w = np.maximum(0, x2 - x1 + 1) # 加 1 保持闭区间一致 inter_h = np.maximum(0, y2 - y1 + 1) inter = inter_w * inter_h area_a = (box[2] - box[0] + 1) * (box[3] - box[1] + 1) area_b = (boxes[:, 2] - boxes[:, 0] + 1) * (boxes[:, 3] - boxes[:, 1] + 1) if mode == "Min": return inter / np.minimum(area_a, area_b) # 小框被大框完全包住时 IOU=1 return inter / (area_a + area_b - inter) # 并集Min模式的行为差异在于:当一个框完全被另一个框包住时,Union算出来是个小于 1 的值(取决于面积比),可能逃过阈值;而Min直接得 1,必定被抑制。对“框套框”的场景,这就是想要的效果。我一般的搭配是:P-Net 后和 R-Net 后用Union(这时候要的是把同一张脸的多个候选聚起来),O-Net 后用Min(这时候要的是去掉那些畸形的嵌套框)。
阈值 0.7 不是固定的。两个人脸挨得很近的场景,0.7 会把两张脸当成一张压掉一张,这时候调到 0.8 甚至 0.85 更合适;反过来,如果 Detector 在虚化背景上产出一堆碎框,降到 0.6 让抑制更狠一点。这个参数值得拿一批典型图扫一遍,别照抄。
4.3 五个关键点怎么用:一句话说清仿射对齐
关键点输出的坐标是相对于正方形裁剪框归一化的,换算回原图的公式是:
def landmark_to_image(lm, square): """lm: 10 维,顺序 [x0,y0, x1,y1, ...];square: (nx1, ny1, nx2, ny2) 未截断的正方形""" nx1, ny1, nx2, ny2 = square bw = nx2 - nx1 + 1 bh = ny2 - ny1 + 1 pts = [] for i in range(5): pts.append((bw * lm[2 * i] + nx1 - 1, bh * lm[2 * i + 1] + ny1 - 1)) return pts一定要用未截断的正方形坐标,这就是 2.2 里强调要单独保留它的原因。贴边人脸如果用截断后的裁剪框换算,五个点会整体朝图像中心收缩,眼睛间距肉眼看着就偏小。
拿到五个点之后,经典做法是做一个相似变换,把五官对齐到一个固定的 112×112 模板上:
import cv2 import numpy as np TEMPLATE = np.array([ [38.2946, 51.6963], [73.5318, 51.5014], # 左眼、右眼 [56.0252, 71.7366], # 鼻尖 [41.5493, 92.3655], [70.7299, 92.2041], # 左右嘴角 ], dtype=np.float32) def align(img, pts, out_size=112): src = np.array(pts, dtype=np.float32) # estimateAffinePartial2D 只估旋转+缩放+平移,不会引入错切,更适合人脸 M, _ = cv2.estimateAffinePartial2D(src, TEMPLATE, method=cv2.LMEDS) if M is None: return None return cv2.warpAffine(img, M, (out_size, out_size), flags=cv2.INTER_LINEAR)用estimateAffinePartial2D而不是estimateAffine2D是个小技巧:后者自由度更高,会估出带错切的变换,侧脸时容易把脸“压扁”;前者限制为相似变换,出来的对齐图比例更稳。cv2.LMEDS则是在关键点有噪声时提供一个鲁棒估计,代价是慢一点点。
5. 避坑与排查:五个让我返工过的地方
5.1 框全丢,分数全是 0.0x
现象:图里明明有清晰正脸,输出为空,或者输出一堆位置完全不对的框。原因:九成是归一化没做或做错。常见三种:忘了减 127.5 只除了 128;除了 255;BGR 当 RGB 直接喂进去。解决:在喂网络前打印一批像素的 min/max/mean。归一化正确时,数值应该落在 -1 到 1 之间、均值接近 0。BGR/RGB 用一张有明显色偏的图验证最快——把人脸区域裁出来存盘看一眼,颜色不对就说明通道顺序反了。
5.2 关键点贴边就漂
现象:中间的人脸五个点很准,图像边缘的人脸点整体往内缩。原因:裁剪时用了截断后的边界去反算关键点,而网络输出是相对于完整正方形归一化的。解决:square_crop必须同时返回“截断后的裁剪框”和“未截断的正方形”,前者用来切图,后者用来换算坐标。这两个混用不会报错,只会让结果悄悄偏掉。
5.3 换成 GPU 之后结果和 CPU 不一致
现象:同一张图,CPU 出 5 个框,GPU 出 4 个或 6 个。原因:一是忘了model.eval(),BatchNorm 在训练模式下用当前 batch 的统计量,不同设备上的浮点累加顺序不同,结果就飘;二是半精度推理时阈值附近的分数刚好跨过判定线。解决:强制eval();如果用了 FP16,在分类分数和阈值比较的那一步转回 FP32 再比。批次大小也会影响,尽量保证两条路径的批大小一致。
5.4 ONNX 导出后金字塔层数不对
现象:PyTorch 版本能检出的中等尺寸人脸,转成 ONNX 后漏了。原因:导出时把输入尺寸写死成固定值,金字塔后面几层的输入尺寸和固定值不符,被补齐或截断。解决:导出时显式指定动态轴。
torch.onnx.export( pnet, dummy, "pnet.onnx", input_names=["input"], output_names=["cls", "reg", "lm"], dynamic_axes={"input": {0: "batch", 2: "h", 3: "w"}, "cls": {0: "batch"}, "reg": {0: "batch"}, "lm": {0: "batch"}}, opset_version=11, )导完之后拿同一张图,把 ONNX 输出和 PyTorch 输出逐元素比对,最大绝对误差超过 1e-3 就别往下走了。
5.5 批处理一开,框和脸对不上号
现象:单张推理正常,改成批量后框全部错位,或者关键点画到了别人脸上。原因:批处理时把多个裁剪块拼成一个 batch 前向,但回填结果时用了np.vstack之后的顺序,而中间可能插了一次按分数排序。解决:在裁剪阶段就给每个 patch 带上原始索引,回填时按索引写回,不依赖数组顺序。批处理只影响速度,不该影响任何坐标语义,一旦发现结果和单张不一致,优先怀疑顺序而不是数值。
6. 把它放进真实流水线:阈值扫档、批处理与回归验收
单张图跑通只是及格线。真要用起来,第一步是把三个阈值从“默认值”变成“我自己的值”。做法很简单:准备 200 张左右覆盖你实际场景的图(远景、逆光、侧脸、遮挡各占一部分),把 P-Net 阈值在 0.5~0.8、R-Net 在 0.6~0.85、O-Net 在 0.6~0.9 之间做网格扫描,每一档记录召回和误检。经验上 P-Net 阈值对召回影响最大,O-Net 阈值对精度影响最大,R-Net 夹在中间反而最不敏感,可以先固定它。
| 场景 | P-Net 阈值 | R-Net 阈值 | O-Net 阈值 | NMS(最后一级) | 说明 |
|---|---|---|---|---|---|
| 门禁近景单脸 | 0.7 | 0.75 | 0.85 | 0.6 | 精度优先,误检代价高 |
| 监控远景小脸 | 0.5 | 0.65 | 0.65 | 0.8 | 召回优先,接受多检 |
| 合影/密集人群 | 0.6 | 0.7 | 0.7 | 0.85 | NMS 放宽避免漏掉相邻脸 |
| 移动端实时 | 0.65 | 0.7 | 0.75 | 0.7 | 配合min_face_size调大 |
第二步是批处理。R-Net 和 O-Net 天然适合批,因为每个阶段的候选框数量是几十到几百,全部堆进一个 batch 一次前向,比逐个循环快三到五倍。要注意的是内存——几百个 48×48 的 patch 不算什么,但如果你把min_face_size调到 12,P-Net 的候选框可能上万,这时候必须分批,别一次全堆上去。
def batched_forward(net, patches, batch_size=256, device="cpu"): """patches: list of HWC uint8;返回拼接后的三组输出""" outs = [] for i in range(0, len(patches), batch_size): chunk = np.stack(patches[i:i + batch_size]).astype(np.float32) chunk = ((chunk - 127.5) / 128.0).transpose(0, 3, 1, 2) # NHWC -> NCHW with torch.no_grad(): cls, reg, lm = net(torch.from_numpy(chunk).to(device)) outs.append((cls.cpu().numpy(), reg.cpu().numpy(), lm.cpu().numpy())) # 按输出维度拼接,保持和输入 patches 的顺序一一对应 return tuple(np.concatenate([o[k] for o in outs], axis=0) for k in range(3))batch_size不是越大越好。CPU 上 256 已经能吃满缓存,再大反而因为内存带宽掉速度;GPU 上可以到 1024,但要盯着显存。更重要的是保持输入顺序稳定——这个函数不做任何排序,调用方也不能在调用前后插排序,否则回填必然错位,这就是 5.5 那个坑的根因。
第三步,也是最容易被跳过的一步:给自己留一套回归验收。我习惯固定 50 张图作为基准集,每次改阈值、换权重、调预处理,都跑一遍记录三个数:检出人脸数、平均置信度、关键点与人工标注的平均像素偏差。第三个指标最能反映问题,因为框的容错高,关键点对坐标体系的错误极其敏感——只要有一个+1/-1写反,平均偏差会立刻跳半个像素以上,而框看上去还是对的。
def regression_check(detector, cases, baseline): """cases: [(img_path, gt_boxes, gt_landmarks)];baseline: 上一次的指标""" cur = {"count": 0, "score": 0.0, "lm_err": 0.0} for path, gt_boxes, gt_lm in cases: res = detector(path) cur["count"] += len(res) if res: cur["score"] += float(np.mean([r["score"] for r in res])) cur["lm_err"] += float(np.mean(np.linalg.norm( np.array([r["keypoints"]]) - np.array(gt_lm), axis=-1))) # 任一指标偏离基线超过 5% 就中断,别让错误悄悄往下游走 for k in cur: assert abs(cur[k] - baseline[k]) / max(baseline[k], 1e-6) < 0.05, f"{k} 偏离基线" return cur这套东西的价值不在跑通,在于它会在你没注意的时候叫停你。我有一次只是把裁剪阶段的offset从 0 改成 0.1,肉眼完全看不出差别,回归检查却报关键点平均偏差涨了 0.8 像素——原因是 R-Net 阶段的框整体外扩后,O-Net 拿到的上下文变多、回归量分布和训练时不一致,关键点被轻微拉偏。要是没有这一步,这个偏差会一直带到下游的对齐图上,最后表现成“识别率莫名其妙掉了两个点”,查起来毫无头绪。
再往后就是工程侧的事了:把三级网络做成可配置的(阈值、min_face_size、factor全部从配置文件读),把权重路径和预处理常量收进一个地方,别散落在三个文件里。MTCNN 的参数耦合度比看上去高,min_face_size一变,金字塔层数、P-Net 候选数量、后续两级的最优阈值全都跟着动。从那以后我每次动这类参数,都强制走一遍那 50 张图的回归检查,宁可多花三分钟,也不想在半夜对着一个“框看起来没问题但下游全错”的现象发呆。希望帮到你。
本文还有配套的精品资源,点击获取