简介:这份资源是《DeepLearning tutorial(5)CNN卷积神经网络应用于人脸识别》一文的配套代码包,面向具备一定深度学习基础、希望动手实践卷积神经网络图像分类的开发者与学习者,解决从理论到代码落地过程中缺少可运行示例的问题。压缩包共4个文件,约14.64MB,包含2个Python脚本分别负责训练与推理、1个pkl模型参数文件以及1个gif数据集图像,覆盖数据读取、网络搭建、训练保存与测试预测的完整链路。资源以Olivetti人脸数据集为实验对象,读者可据此复现CNN在人脸识别任务上的训练流程,理解卷积、池化与全连接层的组织方式,并借助已保存的参数文件直接进行推理验证,省去重复训练成本。目前已有17745人学习下载,适合作为入门CNN实战与课程作业的参考素材。
1. 从一张 96×96 的灰度图说起:CNN 人脸识别到底在识别什么
很多人第一次跑 CNN 人脸识别,都是拿一张 96×96 的灰度人脸图喂进网络,然后盯着终端里跳出来的Accuracy: 0.98发愣——这 0.98 到底意味着什么?是网络认出了“这是张三”,还是只学会了“这是人脸”?这个区别,决定了你后面所有代码是白写还是能用。
CNN 卷积神经网络做人脸识别,本质上解决的是把一张人脸图像映射到一个高维特征向量,再让同一个人的人脸向量彼此靠近、不同人的向量彼此远离。它跟传统 Haar + LBPH 那套“手工特征 + 分类器”最大的不同在于:卷积核自己从数据里学该看什么——第一层学边缘和纹理,中间层学眼睛鼻子嘴巴的局部组合,深层学整张脸的全局结构。你不需要告诉它“眼角到鼻翼的距离是多少”,它自己会找到对区分身份最有用的那组特征。
这套方案适合谁?适合已经会写 Python、装过 PyTorch 或 TensorFlow、想从“调包跑 demo”进阶到“自己搭一套能落地的人脸识别流程”的工程师。也适合手里有几百到几万张人脸图、想搞清楚数据怎么组织、模型怎么选、阈值怎么定、线上怎么排查的人。下面按“数据 → 模型 → 训练 → 部署 → 避坑”的顺序,把每个环节的参数和代码都摊开讲。
2. 数据管线:从原始人脸图到 CNN 能吃的张量
2.1 人脸检测与对齐:别让背景和角度毁掉你的 CNN
CNN 对人脸位置和角度非常敏感。同一张脸,偏 15 度、背景多一块,特征向量可能就飘到另一个簇里去了。所以第一步不是直接 resize,而是检测 + 对齐。
常见做法是用 MTCNN 或 RetinaFace 做人脸检测和 5 点关键点(左右眼、鼻尖、左右嘴角)定位,然后做相似变换,把眼睛和嘴巴对齐到一个标准位置。下面这段代码用facenet-pytorch里的 MTCNN 做检测和对齐:
import cv2 import torch from facenet_pytorch import MTCNN from PIL import Image # 初始化 MTCNN,image_size 设 160 是 FaceNet 的标准输入 mtcnn = MTCNN( image_size=160, # 输出人脸图尺寸 margin=14, # 在检测框外扩的像素,防止裁掉下巴和额头 keep_all=False, # 只保留置信度最高的一张脸 post_process=False, # 不归一化,后面自己处理 device='cuda' if torch.cuda.is_available() else 'cpu' ) def detect_and_align(img_path): img = Image.open(img_path).convert('RGB') face = mtcnn(img) # 返回对齐后的 tensor,形状 [3,160,160] if face is None: return None # 转成 numpy,HWC 格式,方便后面存图或送进自己的网络 face_np = face.permute(1, 2, 0).numpy().astype('uint8') return face_np aligned = detect_and_align('raw_faces/zhangsan_001.jpg') if aligned is not None: cv2.imwrite('aligned/zhangsan_001.jpg', cv2.cvtColor(aligned, cv2.COLOR_RGB2BGR))逻辑说明:MTCNN 先做三级级联检测(P-Net、R-Net、O-Net),拿到人脸框和 5 个关键点,再用相似变换把脸“摆正”。margin=14这个参数很关键——太小会裁掉下巴,太大会把背景带进来。image_size=160是 FaceNet 的标准输入,如果你用自己设计的 CNN,可以改成 112 或 128,但一定要和后面网络的输入层对齐。
参数说明:keep_all=False表示一张图只取置信度最高的人脸,适合单人脸数据集;如果一张图有多个人,设成True再按置信度过滤。post_process=False保留 0-255 的像素值,后面自己做归一化,避免重复归一化导致像素值全挤在 0 附近。
2.2 数据集划分与增强:同一个人不能同时出现在训练和测试集
人脸识别数据集划分有个铁律:按身份划分,不是按图片划分。同一个人如果有 10 张图,不能 7 张训练 3 张测试——那样测试集里出现的脸,网络在训练时已经见过这个身份了,准确率会虚高到 99%,上线就翻车。
正确做法是:先把身份列表打乱,按 8:1:1 切成 train/val/test 三个身份集合,每个集合内部再取该身份的所有图片。下面是一个划分脚本:
import os import random from collections import defaultdict def split_by_identity(root_dir, train_ratio=0.8, val_ratio=0.1): # root_dir 下每个子文件夹是一个人的名字,里面是这个人的人脸图 identities = sorted(os.listdir(root_dir)) random.seed(42) random.shuffle(identities) n = len(identities) n_train = int(n * train_ratio) n_val = int(n * val_ratio) train_ids = identities[:n_train] val_ids = identities[n_train:n_train + n_val] test_ids = identities[n_train + n_val:] splits = {'train': train_ids, 'val': val_ids, 'test': test_ids} for split_name, ids in splits.items(): with open(f'{split_name}_identities.txt', 'w') as f: for i in ids: f.write(i + '\n') print(f'train: {len(train_ids)} ids, val: {len(val_ids)} ids, test: {len(test_ids)} ids') return splits split_by_identity('aligned_faces')逻辑说明:random.seed(42)保证每次划分结果一致,方便复现。划分结果写成 txt 文件,后面 DataLoader 直接读这个文件,避免每次重新划分导致训练/测试集不一致。
数据增强方面,人脸识别常用的有:随机水平翻转(概率 0.5)、随机裁剪(裁剪比例 0.9-1.0)、轻微颜色抖动(亮度/对比度 ±0.2)。不要用垂直翻转和大幅旋转——人脸上下颠倒或旋转 90 度,在真实场景里几乎不会出现,加了反而让网络学偏。
2.3 用 Dataset 和 DataLoader 把图喂进 CNN
PyTorch 的Dataset负责“一张一张读图 + 增强”,DataLoader负责“攒成 batch + 打乱 + 多进程加载”。下面是一个可直接用的实现:
import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms from PIL import Image import os class FaceDataset(Dataset): def __init__(self, root_dir, identity_file, transform=None): self.root_dir = root_dir self.transform = transform self.samples = [] # (img_path, label) self.label2idx = {} with open(identity_file) as f: ids = [line.strip() for line in f if line.strip()] for idx, identity in enumerate(ids): self.label2idx[identity] = idx person_dir = os.path.join(root_dir, identity) if not os.path.isdir(person_dir): continue for img_name in os.listdir(person_dir): if img_name.lower().endswith(('.jpg', '.png', '.jpeg')): self.samples.append((os.path.join(person_dir, img_name), idx)) def __len__(self): return len(self.samples) def __getitem__(self, index): img_path, label = self.samples[index] img = Image.open(img_path).convert('RGB') if self.transform: img = self.transform(img) return img, label # 训练集增强:翻转 + 轻微裁剪 + 颜色抖动 train_tf = transforms.Compose([ transforms.RandomHorizontalFlip(p=0.5), transforms.RandomResizedCrop(160, scale=(0.9, 1.0)), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) # 验证/测试集:只做 resize 和归一化,不做随机增强 val_tf = transforms.Compose([ transforms.Resize((160, 160)), transforms.ToTensor(), transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]) ]) train_ds = FaceDataset('aligned_faces', 'train_identities.txt', transform=train_tf) val_ds = FaceDataset('aligned_faces', 'val_identities.txt', transform=val_tf) train_loader = DataLoader(train_ds, batch_size=64, shuffle=True, num_workers=4, pin_memory=True) val_loader = DataLoader(val_ds, batch_size=64, shuffle=False, num_workers=4, pin_memory=True)逻辑说明:Normalize用 0.5 均值和 0.5 标准差,把像素从 [0,1] 映射到 [-1,1],这是人脸识别里最常用的归一化方式。pin_memory=True在 GPU 训练时能加速 CPU 到 GPU 的数据传输。num_workers=4根据你机器的 CPU 核数调整,设太大反而会因为进程切换拖慢速度。
参数说明:batch_size=64是常见起点,显存不够就降到 32 或 16。RandomResizedCrop(160, scale=(0.9, 1.0))表示随机裁剪原图 90%-100% 的区域再缩放到 160×160,模拟人脸框轻微偏移的情况。
3. 模型选型与训练:从零搭 CNN 还是用预训练骨干
3.1 自己搭一个轻量 CNN:适合小数据集和教学
如果你只有几千张图,或者想彻底搞懂每一层在干什么,可以从零搭一个 4-5 层的 CNN。下面这个结构在 160×160 输入下,参数量约 1.2M,单卡 1080Ti 就能跑:
import torch.nn as nn import torch.nn.functional as F class SmallFaceCNN(nn.Module): def __init__(self, embedding_dim=128, num_classes=100): super().__init__() # 输入 [3,160,160] self.conv1 = nn.Conv2d(3, 32, kernel_size=3, padding=1) self.bn1 = nn.BatchNorm2d(32) self.conv2 = nn.Conv2d(32, 64, kernel_size=3, padding=1) self.bn2 = nn.BatchNorm2d(64) self.conv3 = nn.Conv2d(64, 128, kernel_size=3, padding=1) self.bn3 = nn.BatchNorm2d(128) self.conv4 = nn.Conv2d(128, 256, kernel_size=3, padding=1) self.bn4 = nn.BatchNorm2d(256) self.pool = nn.MaxPool2d(2, 2) self.gap = nn.AdaptiveAvgPool2d(1) # 全局平均池化,输出 [B,256,1,1] self.fc = nn.Linear(256, embedding_dim) self.classifier = nn.Linear(embedding_dim, num_classes) def forward(self, x): x = self.pool(F.relu(self.bn1(self.conv1(x)))) # [B,32,80,80] x = self.pool(F.relu(self.bn2(self.conv2(x)))) # [B,64,40,40] x = self.pool(F.relu(self.bn3(self.conv3(x)))) # [B,128,20,20] x = self.pool(F.relu(self.bn4(self.conv4(x)))) # [B,256,10,10] x = self.gap(x).flatten(1) # [B,256] embedding = self.fc(x) # [B,128] logits = self.classifier(embedding) # [B,num_classes] return embedding, logits逻辑说明:每个卷积层后面接 BatchNorm 和 ReLU,再池化。BatchNorm 让训练更稳,学习率可以设大一点。最后用全局平均池化把 [B,256,10,10] 压成 [B,256,1,1],再展平接全连接,这样参数量比直接 flatten 少很多,也不容易过拟合。
参数说明:embedding_dim=128是输出特征向量的维度,后面做人脸比对时就用这个向量算余弦相似度。num_classes等于训练集里的身份数。如果你有 1000 个人,就设 1000。
3.2 用预训练骨干 + ArcFace 损失:工业级精度
自己搭的 CNN 在几千张图上能跑到 90% 左右,但想上 99%,常见做法是用预训练骨干(如 ResNet50、MobileFaceNet)+ ArcFace 损失。ArcFace 的核心思想是在角度空间里加一个 margin,让同类更紧、异类更远。
下面用insightface的 ArcFace 实现(也可以自己写):
import math import torch import torch.nn as nn import torch.nn.functional as F class ArcFace(nn.Module): def __init__(self, embedding_dim, num_classes, s=64.0, m=0.5): super().__init__() self.s = s # 缩放因子 self.m = m # 角度 margin self.weight = nn.Parameter(torch.FloatTensor(num_classes, embedding_dim)) nn.init.xavier_uniform_(self.weight) def forward(self, embeddings, labels): # 归一化权重和特征 cosine = F.linear(F.normalize(embeddings), F.normalize(self.weight)) # 计算角度 theta = torch.acos(torch.clamp(cosine, -1.0 + 1e-7, 1.0 - 1e-7)) # 只在目标类上加 margin target_logits = torch.cos(theta + self.m) one_hot = torch.zeros_like(cosine) one_hot.scatter_(1, labels.view(-1, 1).long(), 1) logits = one_hot * target_logits + (1.0 - one_hot) * cosine logits *= self.s loss = F.cross_entropy(logits, labels) return loss逻辑说明:s=64把余弦值放大,让 softmax 的梯度更明显;m=0.5(约 28.6 度)是加在目标类角度上的 margin,让同类样本必须靠得更近才能被正确分类。one_hot用来区分目标类和非目标类,只对目标类加 margin。
参数说明:m一般设 0.5,太大训练难收敛,太小区分度不够。s一般设 64,也可以试 32 或 128。embedding_dim常用 512,比 128 表达能力强,但计算量也大。
训练循环里,每个 epoch 结束后在验证集上算准确率,保存最好的模型:
def train_one_epoch(model, arcface, loader, optimizer, device): model.train() total_loss = 0 for imgs, labels in loader: imgs, labels = imgs.to(device), labels.to(device) embeddings, _ = model(imgs) loss = arcface(embeddings, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) # 使用示例 device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = SmallFaceCNN(embedding_dim=128, num_classes=len(train_ds.label2idx)).to(device) arcface = ArcFace(embedding_dim=128, num_classes=len(train_ds.label2idx)).to(device) optimizer = torch.optim.Adam(list(model.parameters()) + list(arcface.parameters()), lr=1e-3) for epoch in range(30): loss = train_one_epoch(model, arcface, train_loader, optimizer, device) print(f'epoch {epoch}, loss {loss:.4f}')逻辑说明:ArcFace 的权重也要优化,所以optimizer里同时传了model.parameters()和arcface.parameters()。学习率 1e-3 是 Adam 的常用起点,如果 loss 震荡就降到 1e-4。
3.3 验证与阈值:准确率 99% 不等于能用
训练完模型,下一步是在验证集上找最佳阈值。人脸识别不是简单的“分类正确”,而是“判断两张脸是不是同一个人”。你需要算同一人的余弦相似度分布和不同人的相似度分布,找一个阈值让误拒率和误识率平衡。
import numpy as np from sklearn.metrics import roc_curve def compute_similarities(model, loader, device): model.eval() embeddings_list = [] labels_list = [] with torch.no_grad(): for imgs, labels in loader: imgs = imgs.to(device) emb, _ = model(imgs) embeddings_list.append(F.normalize(emb).cpu()) labels_list.append(labels) embeddings = torch.cat(embeddings_list) labels = torch.cat(labels_list) # 算余弦相似度矩阵 sim_matrix = embeddings @ embeddings.T return sim_matrix.numpy(), labels.numpy() sim, labels = compute_similarities(model, val_loader, device) # 构造正负样本对 pos_scores, neg_scores = [], [] for i in range(len(labels)): for j in range(i+1, len(labels)): if labels[i] == labels[j]: pos_scores.append(sim[i, j]) else: neg_scores.append(sim[i, j]) # 用 ROC 找最佳阈值 y_true = [1]*len(pos_scores) + [0]*len(neg_scores) y_scores = pos_scores + neg_scores fpr, tpr, thresholds = roc_curve(y_true, y_scores) best_idx = np.argmax(tpr - fpr) best_threshold = thresholds[best_idx] print(f'最佳阈值: {best_threshold:.4f}, TPR: {tpr[best_idx]:.4f}, FPR: {fpr[best_idx]:.4f}')逻辑说明:sim_matrix是 N×N 的余弦相似度矩阵,对角线是自身相似度(恒为 1),我们只取上三角。正样本对是同一人的两张图,负样本对是不同人的两张图。ROC 曲线找tpr - fpr最大的点作为阈值。
参数说明:best_threshold一般在 0.5-0.7 之间。如果业务要求“宁可错放不可错认”,就选 FPR 更低的阈值;如果要求“尽量不拒真”,就选 TPR 更高的阈值。
4. 避坑与排查:人脸识别 CNN 落地时最容易翻车的 5 个点
4.1 现象:训练准确率 99%,测试准确率 60%
原因:按图片划分数据集,同一个人同时出现在训练和测试集。网络记住了这个人的脸,不是学会了区分不同人。
解决:按身份划分,用split_by_identity脚本重新切分。切完后训练准确率会掉到 85% 左右,但测试准确率能到 80% 以上,这才是真实水平。
4.2 现象:loss 不下降,一直卡在 8.0 左右
原因:ArcFace 的 marginm设太大(比如 1.0),或者学习率太高导致梯度爆炸。
解决:先把m降到 0.3,学习率降到 1e-4,跑 5 个 epoch 看 loss 是否下降。如果还不降,检查输入归一化是不是用了 ImageNet 的 mean/std 而不是 0.5/0.5——人脸识别和 ImageNet 的像素分布不一样,用错归一化会让网络第一层就学不动。
4.3 现象:验证集相似度分布重叠严重,找不到清晰阈值
原因:embedding 维度太低(比如 64),或者训练数据里每个人只有 2-3 张图,网络没学到足够判别力。
解决:把 embedding_dim 提到 256 或 512;每个人至少保证 5 张以上不同角度/光照的图。如果数据实在少,用预训练骨干 + 冻结底层,只训最后几层。
4.4 现象:GPU 显存爆了,batch_size 降到 8 还是 OOM
原因:输入分辨率太高(比如 224×224),或者模型参数量太大(ResNet50 在 224 输入下 batch 32 就要 10G+ 显存)。
解决:把输入降到 112×112,或者换 MobileFaceNet 这种轻量骨干。也可以用梯度累积:batch_size 设 8,累积 4 次再更新一次参数,等效 batch 32。
4.5 现象:线上推理时,同一个人今天能识别,明天就识别不了
原因:线上人脸图没有做对齐,或者对齐用的关键点检测器跟训练时不一致。训练时用 MTCNN 对齐,线上用 Haar 检测,关键点位置差几个像素,特征向量就飘了。
解决:训练和线上用同一套检测+对齐代码,把对齐后的图存下来做训练数据。线上推理前先检查人脸框是否完整、关键点是否在合理位置,不合理的直接拒识,不要硬送进网络。
5. 从模型文件到可用服务:量化、加速与一个验证技巧
模型训完只是半成品。真正上线要考虑推理速度、模型大小和跨平台部署。我一般会做三件事:导出 ONNX、做动态量化、写一个批量验证脚本。
导出 ONNX 的代码:
import torch model.eval() dummy_input = torch.randn(1, 3, 160, 160).to(device) torch.onnx.export( model, dummy_input, 'face_cnn.onnx', input_names=['input'], output_names=['embedding', 'logits'], dynamic_axes={'input': {0: 'batch'}, 'embedding': {0: 'batch'}}, opset_version=11 ) print('ONNX 导出完成')逻辑说明:dynamic_axes让 batch 维度可变,线上可以一次送多张图。opset_version=11兼容性最好,大部分推理引擎都支持。
量化方面,PyTorch 的动态量化对 CNN 很友好,模型大小能压到原来的 1/4,推理速度提升 1.5-2 倍:
import torch.quantization model_cpu = model.to('cpu').eval() quantized_model = torch.quantization.quantize_dynamic( model_cpu, {torch.nn.Linear, torch.nn.Conv2d}, dtype=torch.qint8 ) torch.save(quantized_model.state_dict(), 'face_cnn_quantized.pth') print('量化完成,模型大小约减少 75%')逻辑说明:quantize_dynamic只量化 Linear 和 Conv2d 的权重,激活值保持浮点,精度损失很小(通常掉 0.5% 以内)。量化后的模型只能在 CPU 上跑,适合边缘设备。
最后是一个我常用的验证技巧:用同一个人的两张不同图算相似度,再用这个人的图和另一个人的图算相似度,看差值是否大于 0.15。如果差值小于 0.1,说明模型对这个人的区分力不够,需要补数据或调阈值。
def verify(model, img1_path, img2_path, threshold=0.6): model.eval() tf = transforms.Compose([ transforms.Resize((160, 160)), transforms.ToTensor(), transforms.Normalize([0.5]*3, [0.5]*3) ]) def get_emb(path): img = Image.open(path).convert('RGB') img = tf(img).unsqueeze(0).to(device) with torch.no_grad(): emb, _ = model(img) return F.normalize(emb).cpu() emb1 = get_emb(img1_path) emb2 = get_emb(img2_path) sim = (emb1 @ emb2.T).item() return sim > threshold, sim same, score = verify(model, 'test/zhangsan_a.jpg', 'test/zhangsan_b.jpg') print(f'同一人: {same}, 相似度: {score:.4f}')逻辑说明:verify函数返回是否同一人以及相似度分数。阈值 0.6 是经验值,实际用验证集 ROC 找最佳阈值。如果同一人相似度低于 0.5,先检查两张图是否都对齐了,再考虑模型是否欠拟合。
这套流程我前后调了三个月,最大的教训是:别在没对齐的数据上死磕模型结构。早期我花了两周换骨干、调损失函数,准确率卡在 88% 上不去,后来发现是训练图里有一半没做关键点对齐,对齐后同样的模型直接跳到 96%。数据管线上的每一分钟,都比调参的一小时值钱。希望帮到你。
本文还有配套的精品资源,点击获取