做毕设选方向的时候,我原本以为基于机器学习深度学习的人脸识别检测系统已经被做烂了——检测有现成模型,识别有现成模型,拼在一起不就是个系统?真正动手之后才发现,标题里最扎眼的四个字其实是括号里的"相似人脸识别"。一个能区分"这个人是谁"的系统,和一个能在双胞胎兄弟、亲姐妹、甚至只是脸型轮廓接近的路人之间准确找到"那一个"的系统,难度差着量级。
这个项目最终要做的事情可以概括成一句话:输入一张图或一段视频,先检测出人脸区域,再用深度学习模型提取人脸特征向量,最后跟预先注册好的特征库比对,返回最相似的身份以及相似度分数。如果只是做个Demo,网上随便找两个开源模型一周就能拼出来;但如果目标是在候选者长相高度接近时依然给出稳定判断,那从数据到模型再到阈值策略,每一个环节都得重新打磨。这篇文章就把我在整个项目周期里的完整思路、模型选型、数据处理、代码实现、训练调参和踩坑记录逐段写清楚,给准备往人脸识别方向做毕设或者做工程预研的同学一个可以直接参考的模板。
1. 相似人脸识别为什么比普通识别难一个量级
1.1 项目目标拆解:检测与识别是两件事
很多人一开始会把"人脸识别系统"当成一个整体,但实际上它是两个独立模块的串联。
第一个模块是人脸检测,任务是在任意尺寸、任意背景的图像里把人脸框出来,去掉背景、身体、装饰物的干扰,输出人脸边界框和关键点坐标。检测做不好,后面什么都不用谈,框偏了、漏检了、把非人脸当成人脸,识别模块拿到的是脏输入。
第二个模块是人脸识别,任务是把已经框好的人脸图像映射成一个定长的特征向量,然后和特征库里所有注册过的特征做相似度计算,输出"最可能是谁"和"相似度多少"。这个模块才是相似人脸识别场景下真正拉开差距的地方。
如果把普通识别类比成"在一堆陌生人里认出自己的朋友",那相似人脸识别就是"在一对双胞胎里分出谁是哥哥谁是弟弟"。前者靠整体印象就够了,后者必须抓住那些极其细微的、稳定的身份差异。这个场景差异决定了整个系统的设计重心:不能只追求"平均准确率好看",必须重点提升对相似负样本的区分能力。
1.2 相似人脸场景特有的三个难点
我实际做下来,觉得有三个困难是该场景独有的,普通教程很少提及。
第一个是特征空间的区分度不足。通用人脸识别模型是在大众数据集上训练的,它们的优化目标是让"不同人"尽可能分开,而这个"不同人"在训练数据里往往是长相差异很大的人。模型学到的那套特征,可能根本不足以表达双胞胎之间微妙的骨骼差异。我在实验里遇到过这种情况:用通用模型提取兄弟两人的特征,它们之间的余弦相似度居然高于同一个人不同表情两张照片的相似度。这就是"类间距离小于类内距离",是相似人脸识别最核心的难题。
第二个是决策阈值会变得非常敏感。普通识别场景下,同一个人相似度分布和不同人相似度分布之间有很宽的间隔,阈值随便取一个中间值都能用。但相似人脸场景下这两个分布会大幅重叠,阈值稍微调高,正确匹配被拒绝;稍微调低,错误匹配被接受。我后面会专门讲阈值是怎么调的,这里先记住一个结论:阈值不是模型训练出来的,而是要根据验证集数据分布单独定出来的参数。
第三个是特征库规模对Top-1命中率的拖累。当特征库里只有几十个人的时候,随便做做准确率都很高;一旦注册人数多起来,每次比对都要跟所有人的特征做内积取最大值,候选集变大,最高的那个分数不一定就是正确身份。相似人脸场景更是如此,因为"最相似"的候选往往不止一个,系统需要额外策略来处理这种模糊局面。
这三个难点不是独立存在的,它们会在同一个测试样本上同时爆发。后面所有技术选型和处理策略,本质上都是在压制这三个问题。
2. 技术选型:检测与特征提取双阶段怎么搭
毕设项目技术选型最忌讳两件事:一是堆砌一堆热门模型显得"高级",二是选一个自己根本驾驭不了的大模型。我的原则是:用成熟、公开资料多、能在单卡上跑通、可解释性强的方案,同时保证它确实能扛住相似人脸场景的压力。
2.1 人脸检测器:为什么弃传统方案选MTCNN
人脸检测环节我对比过两类方案:经典方向梯度直方图(HOG)加线性分类器,以及MTCNN级联卷积网络。结果是HOG方案在正脸证件照上表现尚可,但一旦遇到侧脸、低头、暗光、遮挡,检测框就开始偏移。框的位置稍微偏一点,后续裁剪出来的人脸区域比例就乱了,特征提取的质量直线下降。
MTCNN是三个级联卷积网络的组合,处理流程分三步:第一步用浅层网络快速扫一遍全图,生成大量候选框;第二步用稍深的网络精修候选框,过滤掉大部分背景;第三步用最精细的网络输出最终边界框和五个关键点坐标。它的优势非常明显:对侧脸和小脸的召回率高,自带关键点可以做人脸对齐,模型体积小,普通CPU都能跑到接近实时。
我最终选MTCNN还有一个很实际的考虑:这个模型的开源实现和教程特别多,遇到问题能查到资料,对毕设节点来说,降低调试成本比追求极限精度更重要。
2.2 特征提取网络:FaceNet思路与ArcFace路线的取舍
特征提取是整个系统的心脏,选型我花了最多时间。当时主要对比了两条路线。
一条是FaceNet式的三元组嵌入。思路是把人脸映射到一个欧氏空间,训练时每次取三张图:锚点图、同人的正样本、不同人的负样本,约束锚点与正样本的距离远小于锚点与负样本的距离。理念很优雅,但三元组的选取质量直接决定训练成败,选不好模型要不收敛,要不容易陷入局部最优。
另一条是ArcFace式的角度边距Softmax。思路是在普通Softmax分类的基础上,给正确类别对应的角度加上一个人为的边距,让模型在分类的同时把不同人的特征在角度空间里推得更开。它的好处是训练稳定,不需要在线挖三元组,直接用人脸分类数据集训练就行,推理时照样输出适合做余弦相似度比较的特征向量。
我最后选了ArcFace路线,骨干网络用ResNet-50,输出512维特征向量。理由很直接:稳定、复现资料多、效果上限高。现在主流的人脸识别系统基本都在走这条路线,FaceNet更适合作为理解嵌入学习原理的入门模型。
2.3 传统机器学习方案为什么先被淘汰
早期原型阶段,我确实试过传统方案:用局部二值模式(LBP)提取纹理特征,再训练支持向量机分类器。在只有几十个人的小数据集上,准确率能到90%以上,但一换光照、一戴眼镜、一侧脸,准确率立刻崩。
问题出在特征本身。LBP这类手工特征描述的是局部纹理结构,它对"眼睛周围纹理有没有变化"比较敏感,但对"眼眶间距、鼻梁高度、下颌线条"这种全局几何关系几乎无能为力。而相似人脸之间的差异恰恰藏在后者里。深度卷积网络通过端到端学习,能自动从数据中归纳出层级化的语义特征,先学边缘纹理,再学五官部件,最后学整体身份表示。这条自动学习特征的路子,在相似人脸这种需要细微区分能力的任务上,优势是碾压性的。
3. 数据集与预处理:相似人脸场景下的特殊功课
3.1 训练集、测试集怎么组织与清洗
毕设没有经费采集大规模私有数据,我的方案是公开数据集加自采数据两条腿走路。训练用了一个中等规模的人脸识别公开数据集,类别数在十万级别,每个类别包含多张不同场景的照片;测试用了一个在自然条件下采集的公开测试集,里面包含明显的姿态和光照变化。两份数据完全隔离,避免出现"训练集泄露到测试集"这种自欺欺人的情况。
数据集拿到手之后,最不能省的一步是清洗。公开数据集的标签噪声比我预想的大不少。我写了一个清洗脚本:用预训练模型给每张图提特征,然后跟同类其他图片做两两相似度比对,把那些跟同类中位数相似度低得离谱的图片标记出来人工检查。这轮清洗大概剔除了2%左右的噪声样本。不要小看这个比例,在十万级的数据集上,这些错误标签会直接拉低模型上限,而且这种损失是后期靠调参补不回来的。
3.2 对齐、裁剪、归一化的操作细节
数据预处理管线里,对齐是最关键的一环。我用的对齐方式是拿MTCNN输出的五个关键点——左眼、右眼、鼻尖、左嘴角、右嘴角——做仿射变换,把关键点映射到标准位置,再把人脸裁剪成统一尺寸。
输入尺寸我选的是112乘112,原因有两个:一是ArcFace系列模型的公开权重基本都按这个尺寸训练,直接用省事;二是这个尺寸在信息量和计算量之间比较平衡。裁剪的时候要注意保留一点头部边缘,完全卡着脸裁剪会把耳朵和下颌切掉,特征提取时信息损失很大。另外,裁剪后要做像素归一化,RGB三通道各自减均值再除方差。这个步骤看起来不起眼,但跳过它之后,模型迁移到新数据集上的精度下降非常明显。
3.3 难样本挖掘:让模型学会区分"长得像的人"
相似人脸任务里,难样本挖掘是不能省的操作。所谓难样本,就是两个不同的人长得特别像、模型很容易搞混的样本对。如果不主动把这类样本喂给模型,模型就永远不会知道"长得像的人之间该怎么区分"。
我的做法是参考在线难样本挖掘的思路:每个训练批次里,除了正常的前向计算,还额外计算当前批次所有样本对的距离矩阵,找出那些模型当前误判为同一人的负样本对,把它们标记出来重点回传梯度。这个小改动对相似人脸对的区分能力提升是肉眼可见的,验证集上相似负样本的错误接受率大概降了三成。
这里补充一个理解:难样本挖掘本质上是改变训练样本的分布,让模型把"决策边界"附近的长相也纳入优化目标,而不是只优化那些本来就能分对的大路样本。
4. 核心代码实现:从原始图像到相似度分数
整个系统的代码结构可以分成五个模块:检测、对齐、特征提取、特征库管理、比对,外加一个简单的命令行接口。核心链路就三步:检测出人脸并裁剪对齐,送入网络提取特征,计算余弦相似度并判定身份。下面这段是实际操作层面的核心逻辑。
4.1 检测与对齐模块的接入
检测模块我用的是MTCNN的公开实现,初始化之后对每一帧图像调用一次检测,返回一系列人脸框和关键点坐标。代码里有两个参数要特别注意:置信度阈值我设的是0.7,低于这个值的框直接丢弃,避免把背景噪声当人脸;非极大值抑制的IoU阈值设的是0.3,用于合并重叠框。阈值不是越高越好,如果设到0.9,很多侧脸会被漏检,具体数值要结合自己的数据分布去调。
from mtcnn import MTCNN import cv2 detector = MTCNN() img = cv2.cvtColor(cv2.imread("test.jpg"), cv2.COLOR_BGR2RGB) results = detector.detect_faces(img) boxes = [] landmarks = [] for res in results: if res["confidence"] < 0.7: continue x, y, w, h = res["box"] boxes.append([x, y, x + w, y + h]) keypoints = res["keypoints"] landmarks.append([ keypoints["left_eye"], keypoints["right_eye"], keypoints["nose"], keypoints["mouth_left"], keypoints["mouth_right"] ])拿到关键点之后做仿射对齐。对齐矩阵的计算可以直接用OpenCV的相似变换求解,这一步的作用是把不同角度、不同比例的人脸统一摆到标准位置,保证后续网络输入分布稳定。
import numpy as np import cv2 def align_face(img, landmarks, size=112): src = np.array(landmarks, dtype=np.float32) 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) mat = cv2.estimateAffinePartial2D(src, dst)[0] aligned = cv2.warpAffine(img, mat, (size, size)) return aligned上面这个模板坐标是ArcFace系列公开实现常用的一个基准位置,如果你的对齐前关键点定义不同,需要相应调整。
4.2 特征提取与归一化嵌入
特征提取模块负责把对齐后的人脸图像转换成512维特征向量。加载训练好的ResNet-50骨干网络权重之后,对输入做标准化处理,前向传播得到原始特征,再做一个L2归一化。
import torch import torch.nn.functional as F def extract_embedding(model, aligned_img, device="cuda"): tensor = torch.from_numpy(aligned_img).permute(2, 0, 1).float().unsqueeze(0) tensor = (tensor - 127.5) / 127.5 tensor = tensor.to(device) model.eval() with torch.no_grad(): feat = model(tensor) feat = F.normalize(feat, p=2, dim=1) return feat.cpu().numpy().flatten()这里有两个容易踩的细节。第一是推理阶段必须把模型切成eval模式,如果模型里带BatchNorm层,eval模式会使用训练时累积的全局统计量,而不是当前批次的统计量;很多人在批量测试时没问题、单张推理时准确率暴跌,就是这个原因。第二是L2归一化一定不能省,归一化之后两个向量的点积就等于余弦相似度,计算简单、数值范围固定,这也是后续比对模块能够统一阈值的前提。
4.3 相似度比对与阈值判定逻辑
比对模块的逻辑很直白:输入人脸的特征跟特征库所有人脸的特征做内积,得到一组相似度分数,取最高分对应的身份作为候选;如果最高分超过预设阈值就判定为匹配,否则判定为未知人员。
import numpy as np def match_face(embedding, gallery_embeddings, gallery_ids, threshold=0.52): scores = np.dot(gallery_embeddings, embedding) best_idx = int(np.argmax(scores)) best_score = float(scores[best_idx]) if best_score >= threshold: return gallery_ids[best_idx], best_score else: return "unknown", best_score注册阶段的处理同样重要。每个人的注册照片不是只有一张,我的做法是采集每个人三张正脸照片,分别提取特征后取平均,再做一次L2归一化作为该人的注册特征。平均特征能显著降低单张照片的表情、光照、角度带来的干扰。读者可以把这个"多张注册取均值"当成一个标准操作,简单但非常有效。
5. 训练与调参实录:把通用模型改造成相似人脸专用模型
5.1 ArcFace损失函数的原理与参数配置
ArcFace的核心思路是在Softmax分类的基础上,把特征与类别权重的内积改写成角度形式,并给目标类别对应的角度加上一个人为设定的边距m。用大白话解释:普通Softmax只要求"正确类别的分数比其他类别高一点",ArcFace则要求"正确类别的角度要比其他类别大出一截",这一截就是m。m设得太大,同类样本会被强行压缩成极小的角度球形分布,训练容易不稳定;m太小,又起不到拉开距离的作用。
我在训练里把m设成0.5,把缩放因子s设成64。s这个参数很多人会忽略,它本质上是把余弦相似度从[-1,1]放大到一个适合Softmax计算梯度的数值范围。我一开始把它当普通温度参数随手设了16,结果loss怎么都降不下去,查资料才明白是s太小导致梯度信号微弱。这个教训值得记下来:ArcFace的s和m不是随便调的,s默认取64,m在0.4到0.6之间按任务难度选择。
5.2 训练策略、硬件配置与消融实验
训练环境比较简单:单张消费级显卡,显存16G,骨干网络是ResNet-50。Batch size设64,初始学习率0.1,用余弦退火调度,总共训练25个epoch。前10个epoch保持较高学习率让loss快速下降,后半段逐渐衰减精调,整体训练时长大约一天半。如果显卡显存只有8G,Batch size降到32,学习率也相应按比例下调,否则收敛会变得不稳定。
我专门做了三组消融实验来确认每个环节的价值,结果非常清晰:
| 方案 | Top-1准确率 | 同人相似度均值 | 异人相似度均值 |
|---|---|---|---|
| 公开预训练权重直接提取 | 95.2% | 0.71 | 0.58 |
| 预训练权重 + 微调 | 97.8% | 0.76 | 0.55 |
| 微调 + 难样本挖掘 | 98.6% | 0.78 | 0.57 |
第三行和第一行对比,Top-1准确率提升了3.4个百分点。再看相似度分布:预训练权重下,同人和异人的相似度均值只差0.13,微调加难样本挖掘之后拉开到了0.21,说明特征空间里同类和异类的分布已经明显分开了。这也印证了前面的判断:通用模型的先验知识必须经过相似人脸数据的适配,直接拿来主义是不够的。
5.3 实验结果:指标数据与相似度分布变化
除了Top-1准确率,我还额外关注了错误接受率和错误拒绝率这两个指标,因为它们更贴近"相似人脸识别"的真实使用场景。在验证集上,微调加难样本挖掘后的模型配合最优阈值,错误接受率在0.8%左右,错误拒绝率在1.5%左右。
要注意的是,这两个指标是此消彼长的关系。错误接受率调低了,错误拒绝率必然升高,反之亦然。实际项目中到底往哪边偏,取决于应用场景:门禁系统宁可拒绝不该进的人,也不能放进来陌生面孔;但相册分类工具恰好相反,宁可误归人也别漏掉。毕设论文里如果能把这个权衡关系讲清楚,是非常加分的点。
6. 踩坑记录与可复用的工程经验
最后集中说一下整个项目过程里真正踩过的坑。这些教训比模型代码本身更值钱,因为它们不是看论文能看出来的。
6.1 阈值拍脑袋定导致兄弟互认
最典型的一个坑是阈值"拍脑袋"。我当时参考网上教程把置信阈值定成0.6,结果拿一对兄弟测试时,系统把哥哥认成弟弟、把弟弟认成哥哥。一开始我以为是模型没训练好,排查了半天才发现问题出在注册照片上:两个人的注册照片拍摄角度和表情差异很大,导致同一个人两次提取的特征相似度只有0.55左右,低于0.6的阈值,而兄弟俩的相似度反而到了0.58,直接冲过了阈值。
这个坑的教训有两层。第一,阈值必须根据自己数据集的相似度分布来确定,不能照搬别人的值。我后来用验证集统计了同人分数和异人分数两个分布的均值、方差,然后用网格搜索遍历候选阈值,取错误接受率和错误拒绝率之和最小的那个点。第二,注册照片的采集条件必须约束,正脸、自然光、无遮挡是最低要求,而且要保证同一个人的多张照片覆盖一定的表情和角度变化,否则平均特征会被某一类极端照片带偏。
6.2 光照是相似度最大的干扰项
光照对相似度的影响大到我做完压力测试之后很震惊:同一人在强侧光和均匀光下的特征相似度,居然低于不同人在相同光照下的相似度。这个现象说明,模型在训练时可能把光线方向当成了身份的一部分来编码,这是数据分布偏差导致的。
应对办法有两个层面。数据层面,在训练集上做光照增强,包括随机亮度扰动、对比度扰动和色温偏移,让模型见过足够多的光照变化。部署层面,我在预处理管线里加了Gamma校正和直方图均衡化,把输入图像的光照先标准化一遍再去提取特征。这两步叠加之后,跨光照场景的识别率提升了大约4个百分点。如果读者是在室内固定光照条件下使用,这个步骤可以简化;但如果是移动端或者户外场景,光照处理必须放进主流程。
6.3 给打算复现这个项目的同学的几点建议
如果让我重做一遍这个项目,有几件事我会提前布局。
第一,先把评价指标定下来再写代码。到底是Top-1准确率优先,还是误识率上限优先,这个决定会直接影响阈值策略和模型调整方向,项目做一半再换指标等于重来。
第二,数据清洗和难样本挖掘一定要做在前面。训练集里的噪声样本和难样本分布,决定了模型能力的下限和上限,这两个是后期调参补不回来的。
第三,从第一天就记录实验日志。人脸识别项目的调试周期长,同一个模型在不同阈值、不同预处理、不同训练策略下的表现差异很大,不记日志的话,你根本说不清楚当前效果到底是哪个参数带来的,论文里的实验章节也没法写。
我自己的体会是,相似人脸识别这个毕设题目选得挺值,它表面上是"把两个现成模型串起来",实际上把数据工程、模型训练、损失函数设计、阈值决策整个链条都逼着你走了一遍。做完之后回头看,最难的部分不是让模型跑起来,而是让模型在边界条件下依然做出可靠判断——这个能力,恰恰是工程和作业之间的分水岭。