乐于分享
好东西不私藏

AI笔记19-人脸识别与MTCNN模型

AI笔记19-人脸识别与MTCNN模型

大家好,我是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 输入输出

项
说明
输入
任意尺寸 RGB 图像(H×W×3)
输出
每个人脸的边界框 [x1, y1, x2, y2] + 置信度 + 5个关键点(双眼、鼻尖、左右嘴角)

1.4 应用场景

场景
典型应用
手机相机
自动对焦、美颜美妆、贴纸滤镜
视频会议
虚拟背景、自动取景、视线矫正
智能安防
人脸抓拍、跨摄像机追踪
互联网相册
自动分类、人物聚类
新零售
客流统计、会员识别前置

1.5 人脸检测特点

特点
含义
尺度多变
同一图像中可能存在大小相差几十倍的多个人脸
姿态多变
正脸、侧脸、仰头、低头都需要被识别
遮挡严重
口罩、墨镜、头发、手部遮挡常见
光照复杂
逆光、夜晚、强阴影对算法鲁棒性要求高
实时性要求
移动端、摄像头场景需 ≥ 30 FPS

———— · ————

二、人脸身份识别(Face Recognition)

2.1 简述

人脸身份识别(Face Recognition)解决的是"这是谁"的问题。它在人脸检测之后进行,通过将人脸映射到一个高维特征空间(通常是 128 / 512 维向量),用向量距离来度量两张人脸是否属于同一个人。

📌 概念区分:人脸检测回答"哪里有脸";人脸识别回答"这张脸是谁"。MTCNN 主要解决前者(兼做关键点对齐),后者通常交给 FaceNet / ArcFace 这类特征提取网络。

2.2 识别过程

原始图像
   ▼
① 人脸检测(MTCNN)
   │ - 定位人脸位置 → 输出 BBox
   ▼
② 人脸对齐
   │ - 基于关键点(眼、鼻、嘴)做仿射变换
   │ - 将人脸摆正、归一化尺寸
   ▼
③ 特征提取(FaceNet / ArcFace)
   │ - 将对齐后的人脸送入特征网络
   │ - 得到固定维度 Embedding (512-D)
   ▼
④ 特征比对
   │ - 与底库中的特征做余弦相似度 / 欧氏距离
   ▼
⑤ 决策输出
   │ - 超过阈值 → 识别为某个身份
   │ - 否则 → 陌生人

2.3 应用领域

领域
典型场景
金融支付
刷脸支付、银行远程开户、保险核身
门禁考勤
企业园区、小区单元门、写字楼电梯
公共安全
车站机场身份核验、追逃布控
手机解锁
Face ID / 国产 3D 结构光人脸解锁
教育医疗
在线考试身份核验、患者识别
社交娱乐
明星脸匹配、家庭相册自动归类

———— · ————

三、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
12×12×3
候选框生成 + 框回归
全卷积 / 高速度
R-Net
24×24×3
框过滤 + 框回归
中精度筛选
O-Net
48×48×3
框最终回归 + 5关键点
高精度 / 输出关键点

每个网络都有三个输出头:① 人脸/非人脸二分类;② 边界框坐标回归 (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 数据集下载

来源
说明
官方主页
mmlab.ie.cuhk.edu.hk/projects/CelebA.html
PyTorch 内置
torchvision.datasets.CelebA
国内镜像
百度网盘 / 阿里天池 / Hugging Face Hub

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 划分:

类型
IOU 区间
用途
正样本 pos
≥ 0.65
训练分类 + 框回归
部分样本 part
[0.4, 0.65)
仅训练框回归
负样本 neg
< 0.3
仅训练分类
关键点样本
真实标注
仅训练关键点回归
# 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 大模型实战课

留言区告诉我:你打算把人脸识别用在什么具体场景?(门禁 / 考勤 / 相册整理 / 其他)

相关学习资料