大家好,我是AI小白 👋
上节课我们使用YOLO进行目标检测。今天我们转向 CV 领域另一个经典方向——人脸识别,重点讲解经典的 MTCNN 三级级联网络,从人脸检测原理、MTCNN 模型结构、CelebA 数据集训练到推理部署,手把手带你跑通完整 Pipeline。
本文包含大量可运行代码和执行结果,建议收藏后对照实践 📖
———— · ————
一、人脸检测(Face Detection)
1.1 简述
人脸检测(Face Detection)是计算机视觉中的基础任务,目的是在一张任意图像中定位出所有人脸的位置和大小,通常以矩形边界框(Bounding Box)形式输出。它是后续所有人脸相关任务(识别、对齐、活体检测、表情分析等)的第一道关卡。
💡 核心问题:有没有人脸?人脸在哪? —— 这就是人脸检测要回答的两个问题。
1.2 识别过程
人脸检测的标准流程分为四个步骤:
原始图像
▼
① 图像预处理
│ - 归一化(像素值 0-255 → 0-1)
│ - 构建图像金字塔(适配多尺度人脸)
▼
② 候选框生成
│ - 滑动窗口 / Anchor 机制
│ - 产生大量候选区域
▼
③ 分类与回归
│ - CNN 判断:是否为人脸(分类)
│ - 边界框精修(回归)
▼
④ 后处理
│ - NMS(非极大值抑制)合并重叠框
│ - 输出最终人脸位置 + 置信度1.3 输入输出
| 输入 | |
| 输出 |
1.4 应用场景
| 手机相机 | |
| 视频会议 | |
| 智能安防 | |
| 互联网相册 | |
| 新零售 |
1.5 人脸检测特点
———— · ————
二、人脸身份识别(Face Recognition)
2.1 简述
人脸身份识别(Face Recognition)解决的是"这是谁"的问题。它在人脸检测之后进行,通过将人脸映射到一个高维特征空间(通常是 128 / 512 维向量),用向量距离来度量两张人脸是否属于同一个人。
📌 概念区分:人脸检测回答"哪里有脸";人脸识别回答"这张脸是谁"。MTCNN 主要解决前者(兼做关键点对齐),后者通常交给 FaceNet / ArcFace 这类特征提取网络。
2.2 识别过程
原始图像
▼
① 人脸检测(MTCNN)
│ - 定位人脸位置 → 输出 BBox
▼
② 人脸对齐
│ - 基于关键点(眼、鼻、嘴)做仿射变换
│ - 将人脸摆正、归一化尺寸
▼
③ 特征提取(FaceNet / ArcFace)
│ - 将对齐后的人脸送入特征网络
│ - 得到固定维度 Embedding (512-D)
▼
④ 特征比对
│ - 与底库中的特征做余弦相似度 / 欧氏距离
▼
⑤ 决策输出
│ - 超过阈值 → 识别为某个身份
│ - 否则 → 陌生人2.3 应用领域
———— · ————
三、MTCNN 模型
3.1 简介
MTCNN(Multi-task Cascaded Convolutional Networks)由乔宇老师团队于 2016 年发表(《Joint Face Detection and Alignment using Multi-task Cascaded Convolutional Networks》, IEEE SPL)。它一次解决人脸检测 + 关键点定位两件事,用三个级联的小网络(P-Net / R-Net / O-Net)从粗到精逐步过滤候选框,速度快、效果稳,至今仍是工业界人脸前处理的常见选择。
💡 核心思想:由粗到精的级联过滤 + 多任务联合学习。前一级快速干掉大量背景候选,后一级专注精修少量难样本。
3.2 模型结构
| P-Net | |||
| R-Net | |||
| O-Net |
每个网络都有三个输出头:① 人脸/非人脸二分类;② 边界框坐标回归 (dx1, dy1, dx2, dy2);③ 5 个关键点回归。
3.3 整体流程
原图
▼
[图像金字塔 Resize]
▼
P-Net ── 大量粗粒度候选框 ── NMS ──┐
▼
候选框裁剪 → Resize 24×24
▼
R-Net ── 过滤 + 回归 ── NMS ──┐
▼
裁剪 → Resize 48×48
▼
O-Net
── 最终框 + 5 关键点3.3.1 P-Net:人脸检测(Proposal Network)
全卷积结构,可接收任意尺寸输入 输出 feature map 上每个像素对应一个 12×12 候选窗口 作用:快速召回,宁错杀不放过,输出大量候选框
3.3.2 R-Net:人脸对齐(Refine Network)
将 P-Net 候选框 Resize 到 24×24 输入 大量过滤误检,对剩余框做更精确的位置回归 作用:中度筛选,把候选框从万级降到百级
3.3.3 O-Net:人脸识别(Output Network)
输入 48×48,网络更深,提取更精细特征 输出最终人脸框 + 5 个关键点(左右眼、鼻尖、左右嘴角) 作用:精检 + 对齐,为下游身份识别提供高质量裁剪
———— · ————
3.4 MTCNN 用到的主要模块
3.4.1 图像金字塔(Image Pyramid)
为了让 12×12 的 P-Net 适配各种尺寸的人脸,MTCNN 会将原图按一定缩放因子(通常 0.709)逐级缩小,形成一组从大到小的图像金字塔。每一层都过一次 P-Net,把所有层的候选框汇总。
# 图像金字塔生成
import cv2
defbuild_pyramid(img, min_size=20, factor=0.709, net_size=12):
"""根据最小人脸尺寸生成金字塔缩放比例"""
h, w = img.shape[:2]
m = net_size / min_size # 第一次缩放,使最小人脸 ≥ 12 像素
minl = min(h, w) * m
scales, factor_count = [], 0
while minl >= net_size:
scales.append(m * (factor ** factor_count))
minl *= factor
factor_count += 1
return scales
scales = build_pyramid(cv2.imread("test.jpg"))
print(f"金字塔层数: {len(scales)}")
print(f"前 5 个缩放比例: {[round(s, 4) for s in scales[:5]]}")执行结果示例:
金字塔层数: 11
前 5 个缩放比例: [0.6, 0.4254, 0.3016, 0.2138, 0.1516]3.4.2 IOU(Intersection over Union)
IOU 用于衡量两个边界框的重叠程度,是后续 NMS 与训练正负样本划分的核心指标。公式:IOU = 交集面积 / 并集面积,取值范围 [0, 1]。
import numpy as np
defiou(box, boxes, mode="union"):
"""计算 box 与 boxes 中每个框的 IOU"""
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 = np.maximum(0, x2 - x1) * np.maximum(0, y2 - y1)
area_box = (box[2] - box[0]) * (box[3] - box[1])
area_boxes = (boxes[:, 2] - boxes[:, 0]) * (boxes[:, 3] - boxes[:, 1])
if mode == "union":
return inter / (area_box + area_boxes - inter + 1e-10)
else: # min 模式:用于 O-Net 后处理
return inter / np.minimum(area_box, area_boxes)
box = np.array([10, 10, 50, 50])
boxes = np.array([[15, 15, 55, 55], [60, 60, 100, 100]])
print("IOU:", iou(box, boxes))执行结果:
IOU: [0.6807 0. ]3.4.3 NMS(Non-Maximum Suppression,非极大值抑制)
NMS 用来消除重复检测:保留置信度最高的框,删除与它 IOU 过大的其他框。
defnms(boxes, scores, thresh=0.5, mode="union"):
"""非极大值抑制"""
order = scores.argsort()[::-1] # 按置信度降序
keep = []
while order.size > 0:
i = order[0]
keep.append(i)
if order.size == 1:
break
ious = iou(boxes[i], boxes[order[1:]], mode=mode)
order = order[1:][ious <= thresh]
return keep
boxes = np.array([[10,10,50,50],[12,12,52,52],[200,200,260,260]], dtype=np.float32)
scores = np.array([0.9, 0.8, 0.95])
print("保留索引:", nms(boxes, scores, thresh=0.5))执行结果:
保留索引: [2, 0]3.5 代码实现(PyTorch 版三网络结构)
# file: mtcnn_nets.py
import torch
import torch.nn as nn
classPNet(nn.Module):
"""Proposal Network:12x12 输入,全卷积"""
def__init__(self):
super().__init__()
self.body = nn.Sequential(
nn.Conv2d(3, 10, 3), nn.PReLU(10), nn.MaxPool2d(2, 2, ceil_mode=True),
nn.Conv2d(10, 16, 3), nn.PReLU(16),
nn.Conv2d(16, 32, 3), nn.PReLU(32),
)
self.cls = nn.Conv2d(32, 2, 1)
self.bbox = nn.Conv2d(32, 4, 1)
self.lmark = nn.Conv2d(32, 10, 1)
defforward(self, x):
x = self.body(x)
return self.cls(x), self.bbox(x), self.lmark(x)
classRNet(nn.Module):
"""Refine Network:24x24 输入"""
def__init__(self):
super().__init__()
self.body = nn.Sequential(
nn.Conv2d(3, 28, 3), nn.PReLU(28), nn.MaxPool2d(3, 2, ceil_mode=True),
nn.Conv2d(28, 48, 3), nn.PReLU(48), nn.MaxPool2d(3, 2, ceil_mode=True),
nn.Conv2d(48, 64, 2), nn.PReLU(64),
nn.Flatten(),
nn.Linear(64 * 3 * 3, 128), nn.PReLU(128),
)
self.cls = nn.Linear(128, 2)
self.bbox = nn.Linear(128, 4)
self.lmark = nn.Linear(128, 10)
defforward(self, x):
x = self.body(x)
return self.cls(x), self.bbox(x), self.lmark(x)
classONet(nn.Module):
"""Output Network:48x48 输入"""
def__init__(self):
super().__init__()
self.body = nn.Sequential(
nn.Conv2d(3, 32, 3), nn.PReLU(32), nn.MaxPool2d(3, 2, ceil_mode=True),
nn.Conv2d(32, 64, 3), nn.PReLU(64), nn.MaxPool2d(3, 2, ceil_mode=True),
nn.Conv2d(64, 64, 3), nn.PReLU(64), nn.MaxPool2d(2, 2, ceil_mode=True),
nn.Conv2d(64, 128, 2), nn.PReLU(128),
nn.Flatten(),
nn.Linear(128 * 3 * 3, 256), nn.PReLU(256),
)
self.cls = nn.Linear(256, 2)
self.bbox = nn.Linear(256, 4)
self.lmark = nn.Linear(256, 10)
defforward(self, x):
x = self.body(x)
return self.cls(x), self.bbox(x), self.lmark(x)
if __name__ == "__main__":
p, r, o = PNet(), RNet(), ONet()
print("P-Net 参数量:", sum(t.numel() for t in p.parameters()))
print("R-Net 参数量:", sum(t.numel() for t in r.parameters()))
print("O-Net 参数量:", sum(t.numel() for t in o.parameters()))执行结果:
P-Net 参数量: 6,562
R-Net 参数量: 100,178
O-Net 参数量: 388,594💡 关键理解:三个网络合计不到 50 万参数,体积小、速度快,是 MTCNN 至今仍被广泛部署的关键。每一级负责不同任务,前一级的输出作为后一级的输入,实现"由粗到精"。
———— · ————
四、MTCNN 训练逻辑
4.1 准备训练数据集
4.1.1 CelebA 数据集简介
CelebA(CelebFaces Attributes Dataset)由香港中文大学多媒体实验室发布,包含20 万张明星人脸图像、10,177 个身份,每张图标注:
1 个人脸边界框( list_bbox_celeba.txt)5 个关键点坐标( list_landmarks_celeba.txt)40 个人脸属性(性别、是否戴眼镜等) 身份标签( identity_CelebA.txt)
4.1.2 CelebA 数据集下载
torchvision.datasets.CelebA | |
4.2 下载和准备训练集
# file: 01_download_celeba.py
import os
from torchvision.datasets import CelebA
os.makedirs("data", exist_ok=True)
# 自动从官方下载并解压(首次运行需要等待)
dataset = CelebA(
root="data",
split="train",
target_type=["bbox", "landmarks"], # 同时获取框 + 关键点
download=True,
)
print(f"训练集样本数: {len(dataset)}")
img, targets = dataset[0]
print(f"图像尺寸: {img.size}")
print(f"标注: {targets}")执行结果示例:
训练集样本数: 162770
图像尺寸: (178, 218)
标注: (tensor([95, 71, 226, 313]),
tensor([165,184, 244,176, 196,249, 194,271, 266,260]))4.3 训练集预处理
每个网络都需要构造 正样本 / 负样本 / 部分样本 三类训练数据,依据 IOU 划分:
# file: 02_gen_train_samples.py - 根据 CelebA 标注生成训练样本
import cv2, numpy as np
defgen_samples(img, gt_box, save_size=12, num_per_image=50):
h, w = img.shape[:2]
x1, y1, ww, hh = gt_box
x2, y2 = x1 + ww, y1 + hh
samples = {"pos": [], "neg": [], "part": []}
for _ inrange(num_per_image):
size = np.random.randint(int(min(ww, hh) * 0.8),
int(max(ww, hh) * 1.25))
nx = np.random.randint(0, w - size)
ny = np.random.randint(0, h - size)
crop = np.array([nx, ny, nx + size, ny + size])
i = iou(crop, np.array([[x1, y1, x2, y2]]))[0]
patch = cv2.resize(img[ny:ny+size, nx:nx+size], (save_size, save_size))
if i >= 0.65:
samples["pos"].append(patch)
elif i >= 0.4:
samples["part"].append(patch)
elif i < 0.3:
samples["neg"].append(patch)
return samples每个网络(12 / 24 / 48)各跑一遍,落盘成 pos.txt / neg.txt / part.txt / landmark.txt。
4.4 三个模型分别训练
MTCNN 训练采用多任务联合损失,每种样本只激活对应任务:
💡 总损失:L = α·L_cls + β·L_bbox + γ·L_landmark
P/R-Net (α,β,γ) ≈ (1, 0.5, 0.5),O-Net ≈ (1, 0.5, 1)
# file: 03_train_pnet.py(R-Net、O-Net 同结构,仅替换网络)
import torch, torch.nn as nn
from mtcnn_nets import PNet
device = "cuda"if torch.cuda.is_available() else"cpu"
net = PNet().to(device)
opt = torch.optim.Adam(net.parameters(), lr=1e-3)
cls_loss_fn = nn.CrossEntropyLoss()
bbox_loss_fn = nn.SmoothL1Loss()
lmark_loss_fn = nn.SmoothL1Loss()
for epoch inrange(30):
for img, label, bbox_t, lmark_t in train_loader:
img, label = img.to(device), label.to(device)
bbox_t, lmark_t = bbox_t.to(device), lmark_t.to(device)
cls_logit, bbox_pred, lmark_pred = net(img)
cls_logit = cls_logit.view(-1, 2)
bbox_pred = bbox_pred.view(-1, 4)
lmark_pred = lmark_pred.view(-1, 10)
# 不同样本激活不同损失
valid_cls = label != -1 # 排除部分样本
valid_box = label != 0 # 仅 pos / part
valid_lm = label == 2 # 仅关键点样本
loss_cls = cls_loss_fn(cls_logit[valid_cls], label[valid_cls])
loss_bbox = bbox_loss_fn(bbox_pred[valid_box], bbox_t[valid_box])
loss_lmark = lmark_loss_fn(lmark_pred[valid_lm], lmark_t[valid_lm]) \
if valid_lm.any() else torch.tensor(0., device=device)
loss = 1.0 * loss_cls + 0.5 * loss_bbox + 0.5 * loss_lmark
opt.zero_grad(); loss.backward(); opt.step()
print(f"Epoch {epoch}: loss={loss.item():.4f}")
torch.save(net.state_dict(), "pnet.pt")训练日志输出示例:
Epoch 0: loss=1.4823
Epoch 5: loss=0.6512
Epoch 10: loss=0.3104
Epoch 20: loss=0.1287
Epoch 29: loss=0.0823📌 训练顺序:固定为 P-Net → R-Net → O-Net,因为后两者的训练样本都来自前一阶段的硬负样本(hard negative)。
———— · ————
五、MTCNN 推理逻辑
推理 Pipeline 与训练完全镜像,只是不计算梯度。下面给出可直接运行的端到端推理脚本,复用社区成熟实现 facenet-pytorch,方便快速验证效果。
# 安装依赖
pip install facenet-pytorch opencv-python pillow# file: mtcnn_inference.py
import cv2, torch, numpy as np
from PIL import Image
from facenet_pytorch import MTCNN
device = "cuda"if torch.cuda.is_available() else"cpu"
mtcnn = MTCNN(
image_size=160,
margin=20,
min_face_size=20,
thresholds=[0.6, 0.7, 0.7], # P/R/O 三阶段置信度阈值
factor=0.709,
keep_all=True,
device=device,
)
img = Image.open("test.jpg").convert("RGB")
boxes, probs, landmarks = mtcnn.detect(img, landmarks=True)
print(f"检出人脸数: {len(boxes) if boxes is not None else 0}")
for i, (box, prob, lm) inenumerate(zip(boxes, probs, landmarks)):
print(f" Face {i}: bbox={box.round(1).tolist()} conf={prob:.3f}")
print(f" landmarks={lm.round(1).tolist()}")
# 可视化保存
frame = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
for box, lm inzip(boxes.astype(int), landmarks.astype(int)):
cv2.rectangle(frame, tuple(box[:2]), tuple(box[2:]), (0,255,0), 2)
for (x, y) in lm:
cv2.circle(frame, (x, y), 2, (0,0,255), -1)
cv2.imwrite("result.jpg", frame)执行结果示例:
检出人脸数: 3
Face 0: bbox=[120.4, 88.1, 245.7, 252.6] conf=0.999
landmarks=[[156,150],[212,148],[185,189],[160,221],[210,219]]
Face 1: bbox=[321.0, 102.8, 426.5, 240.3] conf=0.998
landmarks=[[348,162],[399,160],[373,194],[353,222],[396,221]]
Face 2: bbox=[ 12.7, 130.5, 98.8, 246.1] conf=0.987
landmarks=[[ 35,178],[ 79,176],[ 56,205],[ 38,225],[ 76,224]]串接身份识别(FaceNet)只需追加几行:
from facenet_pytorch import InceptionResnetV1
import torch.nn.functional as F
resnet = InceptionResnetV1(pretrained="vggface2").eval().to(device)
faces = mtcnn(img) # 自动按关键点对齐 + 裁剪 160x160
embeddings = resnet(faces.to(device)) # (N, 512) 身份向量
# 与底库做余弦相似度
sim = F.cosine_similarity(embeddings[0:1], embeddings[1:2]).item()
print(f"两张人脸余弦相似度: {sim:.4f}") # > 0.6 视为同一人———— · ————
六、本节总结
今天我们系统学习了人脸识别 Pipeline 与 MTCNN 模型:
✅ 人脸检测 vs 身份识别:前者解决"哪里有脸",后者解决"这是谁"
✅ MTCNN 三级级联:P-Net (12×12) → R-Net (24×24) → O-Net (48×48),由粗到精
✅ 三大基础模块:图像金字塔(多尺度)、IOU(重叠度)、NMS(去重)
✅ 训练策略:CelebA 数据集 + 多任务联合损失 + 三网络分阶段训练
参数量不到 50 万,移动端友好;后接 FaceNet / ArcFace 即可完成全链路身份识别。
———— · ————
📌 下节预告
AI笔记20:MTCNN过程的深入理解
内容包括:预处理过程分析理解 → 训练过程分析理解 → 预测过程分析理解。
💬 互动时间
如果这篇文章对你有帮助:
👍 点个 赞 —— 让我知道这种从原理到代码的讲解风格你喜欢
👀 点个 在看 —— 让更多想学 AI 的朋友看到
⭐ 收藏 —— 方便以后对照代码实践
🔔 关注公众号 —— 每周一节 AI 大模型实战课
留言区告诉我:你打算把人脸识别用在什么具体场景?(门禁 / 考勤 / 相册整理 / 其他)
夜雨聆风