ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

人脸识别项目实战:从数据对齐、CNN骨干到ArcFace损失调参全流程

人脸识别项目实战:从数据对齐、CNN骨干到ArcFace损失调参全流程

简介:面向机器学习初学者与图像识别开发者,这套资源提供CNN卷积神经网络应用于人脸识别的完整工程代码,源自DeepLearning tutorial系列,配套详细实现流程讲解。代码覆盖人脸数据读取与预处理、卷积层、池化层、全连接层及Softmax分类器的设计,并给出训练与测试两个独立脚本,可帮助读者从零搭建并跑通一个真实的人脸识别模型。压缩包共4个文件,包含两个Python脚本(train脚本用于训练模型并保存参数,use脚本负责加载模型完成预测)、一个pkl格式的预训练模型参数文件,以及一张gif格式的olivettifaces人脸样本动图,整体大小约14.64MB,结构清晰,便于对照学习。目前已有17751人学习下载,是理解CNN在图像分类任务中应用的经典入门资源。通过该资源,读者不仅能掌握CNN基本组件的实现细节,还能直接复用训练好的参数进行快速推理,并在此基础上调整网络结构做进一步实验,适合用于课程设计、期末项目或算法复现练习。

1. 人脸识别项目先拉通流程,再谈网络结构

人脸识别这个题目,听着像是论文里的高深内容,落到毕业设计或者课程设计里,其实是一套“把人脸照片变成向量,再比较向量相似度”的工程闭环。真正的难点不在卷积神经网络本身,而在于数据怎么处理、网络怎么设计、训练怎么调参、最后怎么验收,这几步能否连贯地走通。这套方案要给你的是一个可以直接照搬落地的人脸识别项目:从人脸检测与对齐,到 CNN 骨干网络与损失函数,再到训练避坑和答辩验收,代码可以在工程环境里直接跑通。适合正需要交毕设或课设的同学,也适合想把识别模块临时放进考勤、门禁 Demo 的从业者。

2. 数据准备与预处理:识别精度七成取决于这里

很多同学演示识别效果时发现准确率忽高忽低,第一反应是去换更深的网络结构。实际上在公开人脸数据集上做实验,预处理做得是否到位,对最终准确率的影响常常比换 backbone 还要明显。人脸识别和普通图像分类最大的区别在于:它必须保证输入到网络里的人脸姿态、位置、尺度尽量一致,否则特征空间会被这些无关因素带偏。下面把数据侧的几个关键步骤拆开讲。

2.1 数据集选型与目录组织

训练一个人脸识别模型,首选不是自己拍照收集,而是用公开数据集。公开的人脸数据集里有大规模训练数据,也有像 LFW 这类主要用于验证的标准集。自己拍摄的样本量通常只有几百到几千张,用来做课程设计的 Demo 可以,但要复现出像样的识别率,还是建议先拿大规模公开数据集完成预训练,再拿自己的数据微调。

目录组织我一般按身份区分,每个类一个子目录,这样后面做数据加载和标签生成都简单。数据集的路径结构大致如下:

data/ ├── train/ │ ├── identity_001/ │ │ ├── img_0001.jpg │ │ └── img_0002.jpg │ └── identity_002/ │ └── ... └── test/ ├── query/ # 待识别的人脸图片 └── gallery/ # 注册库图片

这一步看起来基础,但有个容易犯错的地方:同一身份的图片不要散落在多个目录里,否则标签索引会错位。通常的做法是写一个脚本遍历目录并生成一份“路径 + 标签”的 CSV,而不是手写数据列表,这样后期增减样本只改目录不改代码。

2.2 人脸检测与样本对齐:MTCNN 的落地写法

人脸识别里常说的预处理不只是缩放和归一化,最关键是两步:第一步把人脸从原图中框出来,第二步根据关键点把整个人脸摆正。没有做对齐的样本,即使同一个人,因为转头角度不同,网络提取到的特征也有可能差异很大。

我一般会先用 MTCNN 做人脸检测和关键点提取,把五个人脸关键点(双眼、鼻尖、嘴角)检测出来,再通过仿射变换将关键点映射到标准位置。这样输入的每张脸都处于相近的几何姿态。

import cv2 import numpy as np from mtcnn import MTCNN detector = MTCNN() def align_face(img, target_size=(112, 112)): # 检测图片中的人脸,取置信度最高的一张 faces = detector.detect_faces(img) if not faces: return None face = max(faces, key=lambda f: f["confidence"]) keypoints = face["keypoints"] src = np.array([ keypoints["left_eye"], keypoints["right_eye"], keypoints["nose"], keypoints["mouth_left"], keypoints["mouth_right"] ], dtype=np.float32) # 标准对齐目标坐标,尺寸112x112是轻量网络的常用输入 dst = 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) tform = cv2.estimateAffinePartial2D(src, dst)[0] aligned = cv2.warpAffine(img, tform, target_size) return aligned

这里面有两个参数值得注意。一是target_size取 112×112,这是轻量人脸识别模型常用的输入尺寸,信息量够且显存友好。二是标准对齐坐标不是随便写的,它对应固定尺寸下人脸的相对位置,训练和推理时必须使用同一套坐标,否则模型看到的分布就不一致。对齐之后,建议顺手把图片转成 RGB 并做像素归一化到 [-1, 1],这一步可以放到数据加载器里,也能够将随机裁剪和水平翻转等增强操作穿插进来。

2.3 数据增强与样本均衡

人脸数据的增强和普通分类任务有区别。水平翻转是安全的,因为人脸的左右对称性,翻转不会改变身份;但旋转角度不能太大,超过 15 度就会破坏对齐效果。比较常踩的坑是直接照搬 ImageNet 的增强策略,比如随机裁剪加 resize,这在人脸识别里会破坏关键点布局,导致训练出来的模型识别率反而下降。

我一般会采用这样的增强组合:水平翻转、亮度抖动、轻微模糊、随机擦除。在样本量有限的情况下,这些增强能让网络对光照和遮挡更鲁棒。随机擦除的做法是把图片随机一块区域用噪声覆盖,强迫网络不要只依赖某一局部特征去判断身份。增强做的效果往往是黑匣子,单看图像看不出差别,只能靠实验对比判断哪组策略有效。

样本均衡也要处理。公开数据集往往存在长尾分布,有的身份有几千张,有的只有几十张。如果不做均衡,小样本类别的特征会被淹没。通常的做法是每个 epoch 按类别采样,让每个身份在一轮训练中出现的次数大致相同。实现上可以用带权重的采样器,也可以直接在遍历目录时控制每个类最多取多少张。

2.4 数据划分:训练与验证

数据划分有两件事必须做对。第一,训练集和验证集必须按身份划分,同一个人的图片不能既出现在训练集又出现在验证集。很多人会把所有图片随机混在一起按比例切分,结果验证集里包含训练集见过的身份,识别率虚高,答辩时一换新图片就露馅。第二,验证集里应该区分 query 和 gallery,模拟真实的识别场景:gallery 是已注册的人脸库,query 是待验证的新抓拍图,两者的拍摄时间、角度最好有差异。

划分比例上,我一般会让训练集占 8 成,验证集占 2 成,并且保证验证集中的身份完全不出现在训练集。如果数据集有一个人对应多张图片的情况,还要确保同一个人的所有图片被完整划分到同一侧。这一步后续直接决定评估指标的可靠性,建议在代码里用身份 ID 做最少遍历,而不是按文件名随机抽取。数据链路理顺之后,后面排查问题才能放心地把锅甩给模型而不是数据。

3. 卷积网络设计:从骨干网络到特征输出层的落地实现

3.1 网络选型思路:为什么用残差结构

人脸识别的 CNN 骨干网络演进其实有清晰脉络。早期的卷积网络层深有限,识别率上不去;后来引入残差结构解决深层网络梯度退化,成为人脸识别的主流骨干。做课程设计或毕设时直接徒手写一个几十层的网络风险很大,既要在有限的数据上调试,又要面对训练不收敛的问题。通常的做法是选一个成熟残差结构做骨干,再针对人脸的轻量化需求做收窄,把它改造到能在单张消费级显卡上训练。

另一个让残差结构适合人脸任务的原因是它和 BN 配合得很稳定。人脸识别训练时类别数可能几百上千,特征分布又要求全局归一化,残差块中的恒等映射可以避免层数加深后梯度消失,也让 BN 统计量更容易稳定。对项目交付来说,结构稳定比结构新奇更重要。

3.2 backbone 实现:一个可跑的轻量卷积网络

下面这个实现是精简过的残差卷积网络,输入对齐后的 112×112 人脸图,输出 256 维特征向量。它把标准残差块里的参数按通道比例压缩,便于项目快速跑通。

import torch import torch.nn as nn import torch.nn.functional as F class BasicBlock(nn.Module): def __init__(self, in_ch, out_ch, stride=1): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, stride, 1, bias=False) self.bn1 = nn.BatchNorm2d(out_ch) self.conv2 = nn.Conv2d(out_ch, out_ch, 3, 1, 1, bias=False) self.bn2 = nn.BatchNorm2d(out_ch) self.shortcut = nn.Sequential() if stride != 1 or in_ch != out_ch: self.shortcut = nn.Sequential( nn.Conv2d(in_ch, out_ch, 1, stride, bias=False), nn.BatchNorm2d(out_ch) ) def forward(self, x): out = F.relu(self.bn1(self.conv1(x))) out = self.bn2(self.conv2(out)) out += self.shortcut(x) return F.relu(out) class FaceBackbone(nn.Module): def __init__(self, embed_dim=256): super().__init__() self.stem = nn.Sequential( nn.Conv2d(3, 64, 3, 1, 1, bias=False), nn.BatchNorm2d(64), nn.ReLU(inplace=True) ) self.layer1 = nn.Sequential(BasicBlock(64, 64, 2), BasicBlock(64, 64)) self.layer2 = nn.Sequential(BasicBlock(64, 128, 2), BasicBlock(128, 128)) self.layer3 = nn.Sequential(BasicBlock(128, 256, 2), BasicBlock(256, 256)) self.gap = nn.AdaptiveAvgPool2d(1) self.fc = nn.Linear(256, embed_dim) def forward(self, x): x = self.stem(x) x = self.layer1(x) x = self.layer2(x) x = self.layer3(x) x = self.gap(x).flatten(1) return self.fc(x)

这段代码要说明几点。stem是网络入口,负责把原始像素升维;layer1到layer3通过stride=2逐步降低空间分辨率、扩大通道数,得到多尺度特征;全局平均池化把特征图压成一维向量,再经过全连接层映射到 256 维嵌入空间。这里所有卷积都配了 BN,训练时 BN 能让深层网络梯度更稳定。这个结构刻意压掉了分组卷积和注意力模块,好让人脸识别的核心流程不被花哨组件干扰,在没有预训练权重的情况下也能从零训起来。

3.3 损失函数怎么选:Softmax、Triplet 与 ArcFace

识别任务的损失函数决定特征空间怎么分布。普通分类任务用 Softmax 交叉熵也能训,但它只要求类别可分,不要求同一身份不同角度的两张脸在特征空间足够接近,所以人脸识别里很少直接用它。

主流的方案有三个,对比关系如下:

损失函数核心思想优点主要缺点
Softmax 交叉熵类别概率可区分实现简单、收敛快特征判别性一般
Triplet Loss同类拉近、异类推远特征分布直观难样本挖掘麻烦
ArcFaceSoftmax 加角度边距类间紧凑、类内分离需要调 margin 参数

实操中请优先选 ArcFace 或它的简化实现。首先它的收敛稳定性比 Triplet 好,不需要像 Triplet 那样精心维护样本对;其次它只需要类别标签,数据准备成本低。代码层面只是多了一个全连接分类层和角度计算,复杂度增加不大,但对特征判别性的提升非常明显。ArcFace 的核心是在角度空间给正确类别加一个边距,让决策面更紧,同一个人的特征向量会尽量聚集到一个小角度范围内。

3.4 特征向量维度与归一化

特征维度常见取 128、256、512。维度越高表达能力越强,但存储和计算开销也变大,而且维度超过一定程度收益递减。做毕设或课程设计一般取 256,模型规模可控,识别精度与开销平衡得比较好。

还有一个容易被忽略的细节是特征归一化。训练和推理时,最好将最终输出的特征向量做 L2 归一化,再送入损失函数或比较相似度。这样两个特征之间的相似度就等价于向量夹角的余弦值,取值范围稳定在 [-1, 1],设置阈值和跨库比较都方便。如果不做归一化,人脸特征向量的分布会和具体网络权重大小耦合,同一个人的两个特征距离可能远远大于不同人的距离,识别就完全不可靠。这也是很多自实现网络效果不如预训练模型的原因之一。

4. 训练流程与参数调优:让损失曲线按预期下降

4.1 训练循环的代码骨架

数据和模型都准备好以后,训练部分的代码反而最机械。关键是训练循环里要正确维护两个状态:模型权重和 BN 层的统计量。训练时模型要停在 train 模式,因为 BN 层在 train 和 eval 两种模式下行为不同——train 模式用当前 batch 统计量,eval 模式使用累计移动均值,很多人漏了model.eval()导致评估结果不稳定。

import torch import torch.nn.functional as F def train_one_epoch(model, arcface_head, loader, optimizer, device): model.train() arcface_head.train() total_loss = 0.0 for images, labels in loader: images, labels = images.to(device), labels.to(device) optimizer.zero_grad() embeddings = model(images) logits = arcface_head(embeddings, labels) # ArcFace计算角度边距后的logits loss = F.cross_entropy(logits, labels) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) return total_loss / len(loader.dataset) def evaluate_acc(model, gallery_embeds, gallery_labels, query_embeds, query_labels, threshold=0.5): model.eval() # gallery_embeds和query_embeds来自推理时提取的特征,已做L2归一化 sims = query_embeds @ gallery_embeds.T # 余弦相似度矩阵 preds = sims.argmax(dim=1) return (preds == query_labels).float().mean().item()

评估阶段的设计要点是:不依赖训练用的分类头,直接比较人脸嵌入的余弦相似度。evaluate_acc里把 query 特征和 gallery 特征做矩阵乘法,得到相似度矩阵,再取每个 query 的最大相似度身份作为预测结果。这更接近真实识别流程,也是答辩时能讲清楚的评判逻辑。

4.2 超参数怎么定

人脸识别训练的超参设置我一般给这样一组起始值:

参数名推荐起点说明
学习率0.01 到 0.001backbone 较深时取小值,浅层网络可以取大
Batch Size32 或 64取决于显存,建议不低于 16
训练轮数60 到 100数据量大时可减少,小数据要更多轮
特征维度256平衡容量与计算开销
优化器SGD + 动量Adam 收敛快但后期泛化能力略差

SGD 加动量的组合在人脸识别任务里往往比 Adam 更稳。Adam 在训练初期收敛快,但后期容易在最优解附近震荡;SGD 虽然起步慢,调好学习率后测试集上的表现通常更好。这是训练过多个分类任务后比较明显的经验,人脸特征这种高维空间里,SGD 的平滑路径反而更容易落到泛化好的区域。

学习率调度建议用阶梯式或余弦退火。我一般用余弦退火,因为它不需要频繁调衰减节点。实现时用torch.optim.lr_scheduler.CosineAnnealingLR,把T_max设成总训练轮数,让训练后期学习率平缓降到很小,有助特征空间进一步收敛。

4.3 损失下降不理想怎么排查

训练过程中最常遇到的是损失在某个值附近徘徊,或者干脆不下降。这时候先排查学习率:过大会导致损失震荡不收敛,过小则会下降极慢。用打印的 loss 曲线可以观察到:前一两个 epoch 如果完全不动,很可能是学习率太小或者数值精度问题。先用当前学习率的五分之一做一轮实验,看曲线是否有变化,再逐步回调。

第二个要排查的是数据标签。人脸数据处理环节较多,标签错位的概率比想象中高。写个简单的脚本,把每个 batch 里首张图片和它的标签打印出来人工看一眼,很快就能确认数据流是否对。另一个小技巧是先用一个小数据集过拟合,训练几十步,如果训练损失能降到很低,说明网络和数据链路正常,再加大数据量。训练损失下不去而验证损失正常,通常说明数据划分有身份泄漏或增强过强。

5. 常见问题与避坑指南:四个最容易翻车的环节

在完整带过几次人脸识别项目之后,我把整理出的高频问题放在这一章。这些问题都很细,但任何一个都能让最终结果看起来像没调好。每条按现象、原因、解决三个角度来拆。

5.1 检测与对齐不一致

现象:同一批图片单独看没问题,测试时发现识别率明显低于实验预期。

原因:训练时用 MTCNN 对齐得到 112×112 人脸,推理时却直接从原图框出来 resize,没有做相似变换,输入分布不一致。这种情况最容易发生在“训练代码和部署代码分开写”的项目里,两套逻辑各改各的,最后跑出来的结果完全不是一回事。

解决:训练和测试统一走同一套对齐流程。建议把对齐封装成同一个函数,固定目标尺寸和关键点坐标,跑数据时不要临时改参数。条件允许的话,在推理入口加一个校验逻辑,比对输入图像的尺寸和是否经过仿射变换,不匹配就直接报错而不是默默产出错误结果。

5.2 训练损失不下降

现象:loss 在前面几十轮几乎不变,之后突然下降或一直在高位震荡。

原因:通常是学习率过大引起梯度震荡,或是 BN 层设置遗漏导致层间数值不稳定。还有一个容易被忽略的因素是 DataLoader 忘记设置shuffle=True,每个 batch 的样本顺序固定,梯度方向持续偏置,损失曲线会呈现一种规律的锯齿状。

解决:先把学习率降到当前值的五分之一看是否变稳;同时确认网络每一轮 batch 输入数据有做随机增强,加载器开启 shuffle。如果两样都做了还不行,打印每一层的梯度范数,检查是浅层梯度消失还是深层梯度爆炸,针对性调整网络初始化或 BN 位置。

5.3 验证集准确率虚高

现象:验证集识别率特别漂亮,但换到新鲜数据后惨不忍睹。

原因:没有按身份划分数据集,同一个人的照片同时出现在训练和验证中。这是常见假象里最隐蔽的一种,因为总准确率看着很高,误判率也不明显,很难让人警觉。

解决:按身份 ID 分组,用sklearn.model_selection.GroupShuffleSplit保证同一身份的照片只落在一边。评估时还要确认 gallery 里有足够多的身份,不能只在 20 人小库上测完就下结论。更严格一点的做法是额外留出一批完全没参与过训练的身份做开放集测试,模拟“新人注册”的真实场景。

5.4 显存溢出与推理速度过慢

现象:训练时显存溢出,程序在中间 epoch 直接退出;推理时每秒只能处理一两张。

原因:图像分辨率或 batch size 选得过大;backbone 层数多且推理时没有用半精度或量化。很多人只看网络参数量,忽略了 112×112 输入下中间特征图占用的显存,实际训练时 batch 一大就爆。

解决:显存不够先降低 batch size,同时缩小输入到 112×112;推理部署可以在导出模型时做量化,把权重转成 FP16。实测推理速度对消费级卡影响非常明显,这也是答辩时可以讲的一个优化点。如果项目允许,可以再走一步模型蒸馏,把大模型的特征分布教给一个小模型,识别率损失不大但速度提升明显。

6. 验收与答辩准备:从模型指标到讲清细节

6.1 验收指标怎么定

项目收尾时不要只说“准确率约多少”,要拿出一套可复现的验收数据。我一般会给三组指标:识别准确率、误识别率、单张推理耗时。

指标验收方式参考起点
识别准确率固定 gallery 与 query 集常规公开数据集上 90% 以上
误识别率加入非注册身份做负样本检测低于 5%
推理耗时单张 GPU 推理取 100 次平均单卡 10ms 级

6.2 直观结果展示

答辩时最有说服力的不是一张准确率表格,而是一张特征分布图。取 20 个身份的人脸特征,用 PCA 降维到二维空间,把每个身份用不同颜色标出。如果训练得好,同一个身份的点会聚成一个紧密的簇。这个可视化能直观说明 ArcFace 加归一化之后特征是有区分度的,比只贴 loss 曲线有力得多。还可以配合一个相似度阈值曲线,展示当阈值取 0.5 时,误识别率变化最平稳,说明调参是做过系统性实验的。

6.3 答辩怎么讲

答辩被问得最多的三个问题:为什么做对齐、为什么用这个损失函数、参数为什么这么定。对齐的问题就讲清楚“训练和推理必须同分布”;损失函数的问题对比 Softmax 和 ArcFace 的特征分布差异;参数问题直接拿出第 4 章的调参记录说明验证过程。讲的时候按“现象 → 原因 → 实验验证”的顺序说,不要背论文,评委想听到的是你真把项目跑通过。

人脸识别项目最容易让人翻车的地方不在于算法有多深,而是流程里的细节点太容易脱节,比如对齐方式不一致、数据集划分泄露、BN 状态没切换。从那以后我每次做这类模型,都会强制自己把训练和评估的逻辑写进同一个脚本模板里,数据划分、对齐函数、评估函数各占一段,留好开关,所有实验从同一套代码跑出结果,避免各改各的。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进