如果把“基于深度学习的人脸识别”放进毕设选题清单,很多人第一反应是“会不会太常见了”。我一开始也这么想,但真正把这个项目完整做完并开源出来之后,我的结论变了:人脸识别确实常见,但它恰恰是能把一个学生从理论到工程完整训练一遍的好方向。它不要求你有超算,也不要求你发顶会,但它逼着你把数据、模型、训练、部署、演示这一整条链路都走通。今天这篇文章,我就把这套毕设项目的完整实现思路、关键参数、踩坑记录和答辩经验全部写出来,希望能给正在做类似课题或者准备开源自己代码的同学一个参考。
1. 为什么我的毕设最终选择了人脸识别:选型与背景
1.1 从“任务型系统”到“作品型项目”的转变
做毕设之前,我一直在管理系统、推荐系统、目标检测这些方向里犹豫。管理系统开发成本低,但答辩时评委容易追着业务逻辑问;推荐系统没有真实业务数据很难自圆其说;目标检测呢,还得自己标数据。后来我意识到,毕业设计与其说是一个任务,不如说是一个作品——它需要有展示性、有技术纵深、还要有可复现性。人脸识别恰恰符合这三点:第一,它天然是深度学习中的图像领域经典任务,无论是卷积网络还是度量学习都能找到切入点;第二,演示效果直观,一台带摄像头的笔记本就能跑完整闭环;第三,开源资料多,踩坑之后能自己解决,不至于卡死。于是我把课题定为“基于深度学习的人脸识别”,并准备把它做成一个真正能开源出去的毕设项目。
人脸识别没有想象中那么玄,整个流程拆开看就是人脸检测、人脸对齐、特征提取、特征比对四步。我当时的目标很明确:不做论文级创新,不做大规模训练,而是把这套流程完整跑通,保证从数据集准备到模型训练再到最终部署每一步都有记录,把常见坑全部填平。这个目标听起来不难,但真的做下来你会发现,每一步都有让你抓狂的小问题。后面几节我按模块来讲,把我实际用到的方案、参数和踩过的坑都写出来。
1.2 技术选型对比:模型、框架和前后端方案
先放一张当时的选型对比,这些结论都是实测后留下来的,不是凭空对比。
| 模块 | 备选方案 | 选型结果 | 理由 |
|---|---|---|---|
| 人脸检测 | MTCNN / RetinaFace / 通用目标检测 | RetinaFace | MTCNN速度尚可但小脸和侧脸召回率弱;RetinaFace带关键点回归且对角度鲁棒,精度更高 |
| 特征提取骨干网络 | MobileFaceNet / ResNet50 / MobileNetV2 + ArcFace | MobileFaceNet | 毕设机器没有强力显卡,MobileFaceNet参数量小、精度接近ResNet50,CPU推理更友好 |
| 损失函数 | Softmax / Triplet Loss / ArcFace | ArcFace | Softmax分类边界不够紧凑,Triplet样本挖掘麻烦,ArcFace是度量学习里最好训练也最稳的选择 |
| 深度学习框架 | PyTorch / TensorFlow | PyTorch | 生态好、调试方便,转ONNX的过程也更省心 |
| 后端API | Flask / FastAPI | FastAPI | 异步性能好、自带接口文档,方便演示时快速查看返回 |
| 前端演示界面 | Streamlit / PyQt / Web前端 | Streamlit | 用纯Python写界面,开发速度快,适合毕设展示,不用额外学前端 |
最终方案敲定为:RetinaFace 做检测 -> 根据关键点做仿射变换对齐到112x112 -> MobileFaceNet 提取512维特征 -> ArcFace 损失函数训练 -> FastAPI 提供识别接口 -> Streamlit 做可视化面板。
提示:技术选型不要只盯准确率。毕设项目最核心的资源是时间和机器算力。MobileFaceNet在标准人脸评测集上也能到99%左右,和ResNet50差距很小,但推理速度能快好几倍,这才是毕设最理想的选择。
1.3 这个项目最终实现了哪些功能
开源版本的功能清单,我按优先级列一下:
- 单人脸注册与识别:上传或拍摄人脸图片,提取特征写入本地数据库,随后通过图片或摄像头识别出是谁。
- 1:N 人脸检索:给定一张人像,在库中查找最相似的若干条记录,返回相似度和Top-K结果。
- 人脸框与关键点可视化:结果页直接绘制检测框、五官关键点和身份信息。
- 实时识别模式:打开电脑摄像头,逐帧检测识别,画面中叠加姓名和置信度。
- 日志记录:每一次识别尝试都记录时间、结果、置信度,方便论文里统计真实场景准确率。
这些功能听起来常规,但把它们组合起来并保证长时间稳定运行,才是毕设真正花时间的地方。下面从流程角度拆解每一环的实现细节。
2. 从摄像头到特征向量:整套识别流程的模块拆解
2.1 人脸检测:为什么不能直接套通用目标检测
一般人想到人脸检测第一反应是“用目标检测模型标人就行了”。但你真的跑起来会发现两个问题:一是通用目标检测框是矩形的,不带关键点;二是人脸一旦发生旋转、遮挡、暗光,通用检测器返回的框不稳定,直接送进分类网络很容易扔进背景。人脸识别需要的并不是一个简单的矩形框,而是一个标准化的、能校正姿势的人脸区域。
所以我选择了带5点关键点回归的检测器。RetinaFace输出原图上的人脸框以及左眼、右眼、鼻尖、左嘴角、右嘴角5个关键点坐标。拿到关键点之后,通过仿射变换把人脸区域对齐到预设的112x112尺寸。这一步非常关键,我在后期测试中对比过:不做过对齐直接resize,标准评测集准确率掉了将近2个百分点;做了关键点对齐之后,同一模型的识别准确率可以从96%提到98%以上。
对齐实现用的是常用的仿射变换函数,目标关键点坐标是标准112x112下的固定位置。核心代码大致如下:
import cv2 import numpy as np def align_face(img, landmarks): # landmarks: 5个关键点坐标, 顺序为左眼、右眼、鼻尖、左嘴角、右嘴角 src = np.array(landmarks, dtype=np.float32).reshape(5, 2) 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) trans = cv2.estimateAffinePartial2D(src, dst)[0] aligned = cv2.warpAffine(img, trans, (112, 112), flags=cv2.INTER_LINEAR) return aligned这里目标点位置是参考人脸识别社区常用的标准对齐模板。注意图像通道要用BGR顺序,否则颜色会错乱,这种低级错误最容易在答辩前夜出现。
2.2 特征提取与相似度比对:阈值不是拍脑袋定的
对齐后的人脸会送进MobileFaceNet特征网络,输出一个512维的特征向量。这个向量经过了L2归一化,所以计算相似度时直接做向量点积就是cosine相似度,范围在[-1, 1]之间。在库里存特征,查询时做矩阵乘法,这是我的实现方式:
import numpy as np def get_feature(model, aligned_face): # aligned_face: 预处理后的112x112人脸,已经做像素归一化 feat = model(aligned_face).numpy() feat = feat / np.linalg.norm(feat) # L2归一化 return feat def search_topk(query_feat, db_feats, k=5): # db_feats: (N, 512) 的库特征矩阵 scores = np.dot(db_feats, query_feat) topk_idx = np.argsort(scores)[::-1][:k] return [(idx, scores[idx]) for idx in topk_idx]阈值的选择千万不要靠感觉。我当时从训练集里选了2000张正样本对(同一个人不同照片)和2000张负样本对,分别计算cosine相似度,画出分布图。正样本对通常在0.6到0.95之间,负样本对在-0.2到0.5之间,中间有一个重叠区。最终我选了0.62作为识别阈值,这样在库中能保持较高召回。如果你的应用场景要求防止陌生人误识别,阈值可以提高到0.7以上,代价是漏识别率上升。
关于MobileFaceNet特征维度,512维是业界常用值,我没有继续压到128维。128维检索速度更快,但在小训练集上特征表达能力弱一些,毕设场景里512维足够,而且对演示说明“维度到底怎么影响精度”也是一个可以讲的点。
2.3 从注册到识别的完整后端闭环
后端我拆成了三个服务:特征库管理、识别服务、演示面板。特征库管理负责把人脸图片转成特征向量并存进本地数据库,表中字段包括id、name、feature_blob、create_time。feature_blob用numpy的二进制直接存储,简单粗暴,对毕设项目足够。
识别服务提供两个接口:
- POST /register:接收姓名和图片文件,返回注册是否成功。
- POST /match:接收图片文件,返回最相似的Top-5结果。
Streamlit面板直接调用这两个接口,用户不需要接触命令行。这种设计从工程角度可能不够“高大上”,但非常稳:哪怕前端挂了,还能用命令行做接口层测试;哪怕数据库坏了,重新注册一批人脸也就几分钟。
还有一个小细节:摄像头采集到的图片是彩色视频帧,如果不做白平衡校正,在光线偏黄的环境下识别率会明显下降。我在预处理中加了直方图均衡化作为可选开关,实测在宿舍灯光下能挽回大概1%到2%的准确率。后续如果有同学想真正部署到安防场景,建议做一个自适应的图像增强模块。
3. 训练环节最容易被忽视的细节:数据、损失函数与评估
3.1 数据集来源与清洗:不能把所有图片一股脑丢进去
训练数据我选了公开的大规模人脸识别数据集。这里有一个很多新手踩过的坑:以为下载完数据集直接开始训练就行。实际上公开数据集中包含大量错误标注的图片,比如一张图里有多张人脸、人脸只占几十像素、照片已经损坏。如果不清理,模型很容易学到“看到模糊块就认为是某个身份”,特征区分度会明显下降。
我的清洗流程是这样:先跑一遍人脸检测,把所有图像中人脸框面积占整图面积比例小于15%的样本剔除,同时保留关键点置信度高的样本。之后再用一个预训练好的分类模型对每个身份做聚类,去掉离群图片。这一步虽然耗时,但训练出来的模型精度提升非常明显。数据量上,我最终保留约80万个样本,覆盖1万个左右的身份类别。
训练前还需要统一格式:图像对齐到112x112,像素值缩放到[-1, 1],每个身份按一定比例分出验证样本。注意验证样本不能和训练样本来自同一张图,否则评估结果会虚高,这就是典型的数据泄漏。
3.2 损失函数对比:为什么最终选了ArcFace
训练人脸识别模型,本质上是让人脸特征在超球面空间上类内紧凑、类间远离。Softmax是最基础的分类损失,但它只约束分类边界,不显式要求特征方向紧凑,所以识别效果一般。Triplet Loss用锚点-正样本-负样本三元组来拉近正对、推远负对,效果不错,但样本挖掘策略很麻烦,训练不稳定。ArcFace则是在Softmax上加入了角度间隔约束,让每个类别的特征在球面上被“推开”,训练难度低,效果也好。
ArcFace的核心改动是在全连接层之前对特征向量做归一化,并在目标角度上加上一个间隔m。论文里常用m=0.5,我在训练中也这样设置。代码实现用的是常见开源实现里的ArcFace头,训练时感受一下它的收敛曲线就能知道为什么它更适合做毕设:它不像Triplet那样动不动loss震荡,调参成本低很多。
下面是我记录的一种对比结果(同一批数据训练到相同步数):
| 损失函数 | 标准评测集准确率 | 训练稳定性 | 调参难度 |
|---|---|---|---|
| Softmax | 96.8% | 稳定 | 低 |
| Triplet Loss | 93.2% | 偶尔崩 | 高 |
| ArcFace | 98.4% | 很稳定 | 中 |
注意Triplet裸跑表现差,不代表这套损失不行,只能说工程调优成本高。毕设阶段时间宝贵,ArcFace是最务实的选择。
3.3 训练配置与评估:怎么看模型是不是真的收敛
训练配置给大家一个可以直接抄的参数表:
| 配置项 | 取值 |
|---|---|
| 输入尺寸 | 112 x 112 x 3 |
| Batch Size | 256 |
| 优化器 | SGD, momentum=0.9, weight_decay=5e-4 |
| 初始学习率 | 0.1 |
| 学习率衰减 | 第10万步、第18万步、第24万步时乘以0.1 |
| 总训练步数 | 30万 |
| 随机增强 | 随机水平翻转、颜色扰动、随机裁剪 |
| 预热 | 前5000步线性预热,避免初始震荡 |
训练过程中我每2000步在验证集上跑一次特征提取并计算准确率。判断模型真正收敛,不能只看loss是不是降下来了,还要看验证准确率是否进入平台期。我的经验是:如果两次验证准确率差距小于0.1%,就可以考虑停止了,后面再训练只会在测试集上过拟合。
评估时除了整体准确率,我还会按“库内Top-1识别率”来测。做法是把库内每个人的第一张照片作为底库,剩余照片作为查询集,统计查询结果中第一名正确的比例。这个指标更贴近毕设演示中的实际识别体验。我的最终模型在标准评测集上达到98.4%,在实际自建的小库(20人,每人5张注册照,100张查询)上Top-1达到96.8%,对毕设来说已经足够稳定。
4. 推理部署阶段的血泪清单:从PyTorch到端侧运行
4.1 模型转换的坑:动态输入与算子兼容
训练好模型只是第一步,真正部署时才是我最头疼的环节。我最初尝试直接加载训练框架的权重做推理,结果CPU推理一帧要400多毫秒,完全达不到实时。于是我想把模型转成ONNX格式,再用ONNX推理引擎加速。
转ONNX的过程总体顺利,但有一个我当时卡了两天的坑:模型导出的动态batch维度。如果导出时没有设置动态维度,ONNX模型默认输入batch固定为1,这会导致批量检索库特征时速度很慢。我改成把batch维度设成动态后,又遇到检测模型里的某些算子(比如NMS)在推理引擎里不支持。最后的方案是:检测模型直接保留原版,只把特征网络的导出做ONNX。因为检测模型只在摄像头流上每N帧跑一次,而特征网络要反复调用于注册和比对,速度瓶颈主要在特征网络。
转换的核心代码是:
import torch def export_onnx(model, save_path): model.eval() dummy_input = torch.randn(1, 3, 112, 112) torch.onnx.export( model, dummy_input, save_path, input_names=["input"], output_names=["feature"], dynamic_axes={"input": {0: "batch"}, "feature": {0: "batch"}}, opset_version=11 )opset_version选11是比较稳的选择,它可以兼容绝大多数当前环境的推理引擎,而且支持常见算子。如果你遇到导出的模型在推理时报“Unsupported Operator”,先检查opset_version和算子版本,再考虑是否需要对算子做等价替换。
4.2 CPU推理性能优化:一次实测数据分享
我手里的开发机是一台没有独立显卡的笔记本,CPU推理的性能优化直接决定演示效果。我在同一台机器上测了三组耗时,输入都是112x112的图片,结果如下:
| 方案 | 单张特征提取耗时 | 内存波动 |
|---|---|---|
| 原始框架CPU推理 | 420ms | 波动大 |
| ONNX Runtime CPU默认线程 | 180ms | 稳定 |
| ONNX Runtime CPU(4线程 + 图形优化) | 95ms | 稳定 |
核心优化手段有二:一是设置推理引擎的线程数,并不是线程数越多越好,在4核机器上默认会开很多线程导致上下文切换频繁,反而变慢;二是让特征网络和检测网络使用同一个推理会话,避免重复初始化。实测下来,4线程加优化选项能让耗时降到100ms以内,基本满足“单张图片识别秒出结果”的演示要求。
对于摄像头实时流,单帧识别100ms意味着每秒10帧,看起来是够用的。但实际跑起来你会发现,同时进行检测、对齐、编码和界面刷新,整个线程会互相抢资源导致卡顿。所以我的方案是把采集、识别、显示彻底分成三个线程,用队列传递视频帧。
4.3 摄像头实时识别的并发处理方案
摄像头线程负责以30FPS抓帧,但识别线程只处理其中带有“新帧”的事件。为了避免每一帧都跑检测,我在识别线程中加入了一个简单跳帧策略:如果当前正在处理上一帧,就把新帧丢弃;如果距离上次处理已经超过200ms,则强制处理当前帧。这种方式能保证识别线程不会追着摄像头跑,CPU占用也维持在可接受范围。
伪代码逻辑大概如下:
from queue import Queue from threading import Thread frame_queue = Queue(maxsize=2) def camera_capture(): while True: ret, frame = cap.read() if ret and not frame_queue.full(): frame_queue.put(frame) def recognizer(): while True: frame = frame_queue.get() boxes, landmarks = detector.detect(frame) for box, lm in zip(boxes, landmarks): aligned = align_face(frame, lm) feat = extract_feature(aligned) result = search_topk(feat) draw_result(frame, result) show_frame(frame)这里队列最大长度设为2,关键作用是保证生产者不会阻塞摄像头,消费者也不会一直积压旧帧,内存占用很低。显示部分用OpenCV的窗口显示,在Streamlit面板里则用图像流替代,但核心多线程逻辑不变。
注意:如果你用的是笔记本自带摄像头,尽量固定帧率,并且在打开摄像头后预热几秒再开始识别,否则刚启动时图像亮度未稳定,容易误判相似度不足。
5. 把项目开源出去要注意什么:代码结构、文档与License
5.1 代码仓库结构:目录设计与模块解耦
开源不是把训练代码和Web代码堆在一起就算完事,好的结构能帮用户省掉大量时间。我最终的目录设计如下:
configs/ train.yaml deploy.yaml data/ datasets/ register_images/ models/ detection/ recognition/weights/ services/ register.py match.py web/ app.py utils/ align.py logger.py train/ train_recognizer.py eval_benchmark.py README.md requirements.txt每个目录职责单一:configs放训练和部署参数,models放模型定义,services放后端逻辑,utils放对齐、日志等公共工具,train放训练和评估脚本。刚开源时我犯过一个错误,把模型定义、训练循环都耦合在一个train.py里,用户想改一个骨干网络就得重写整个文件。后来我把模型构建拆成独立函数,并提供了配置文件中更换网络名称的选项,被不少同学说“终于能跑通了”。
5.2 README 怎么写才能让同学直接跑起来
README是开源项目的门面。刚开始我看别人的README觉得很简单,真到自己写才发现要交代的事情非常多。我按“一句话项目介绍 -> 效果图 -> 快速开始 -> 文件结构 -> 原理简述 -> 常见问题”这个顺序写。快速开始部分给了两种方式:一种是纯推理,直接下载权重和配置文件,运行demo脚本;另一种是完整训练,从数据集准备到训练再到导出ONNX。
我踩过的坑是忘写环境版本。有人用Python 3.8,有人用3.10,依赖版本一旦不一致,跑不起来就会发Issue。我把依赖文件里面所有包都固定了大版本,并在README开头用醒目标注:Python版本必须大于等于3.8.0,推荐3.9。这样做之后,环境问题少了一大半。
5.3 关于模型权重与数据集的合规提醒
开源项目一定要考虑合规问题。训练好的模型权重文件里包含训练数据的分布信息,不能随便拿一个许可不明确的数据集就往外传。我的做法是:代码完全开源,权重文件作为发布附件提供,并在模型说明中明确注明训练数据来源和许可要求。对于用户上传的个人人脸数据库,我会在代码中提示“私有特征数据不会上传”,同时提供一个只存储本地数据库的选项。
我还在README里写了免责声明:本项目仅供学习研究,不用于实际安防、身份验证等场景。这样既保护自己也保护使用者。
6. 答辩和展示时如何把这套系统讲清楚
6.1 演示最容易翻车的点:现场演示不如录屏 + 本地备份
答辩现场演示是个高风险环节,我建议准备一条双保险策略:现场跑通是理想状态,但一定要提前录好一段演示视频。真实原因在于演示现场的光线、摄像头驱动、桌面弹窗都可能让系统状态和平时不一样。我答辩前晚上处理了一个问题:教室投影仪的分辨率把前端界面布局撑变形,按钮找不到,差点翻车。后来我改用固定窗口并录屏,现场就算系统崩了也可以直接用视频演示。
如果一定要现场演示,请提前到教室测试以下内容:摄像头能不能被系统识别,外接键鼠是否正常,网络是否断网(如果前端依赖在线资源会打不开),然后关闭所有无关弹窗提醒。自己独立测试一遍注册、识别、1:N检索三个核心流程,确保没有依赖任何临时脚本。
6.2 把“创新点”翻译成导师听得懂的故事
不少同学觉得人脸识别太成熟,没什么创新点。实际上毕设的创新不一定是算法层,也可以体现在工程实现和完整的评估分析上。我总结了自己答辩时被追问的几个问题,并整理出应对思路:
- 你为什么用MobileFaceNet而不是ResNet?答:因为毕设需要CPU实时推理,MobileFaceNet在几乎不损失精度的情况下,把单帧耗时从420ms降到100ms以内,这种“精度-速度”的取舍正是工程题的核心。
- 阈值0.62是怎么定的?答:我用正负样本对的相似度分布来定,而不是经验值,这体现你对系统整体错误率的思考。
- 你的系统在遮挡、逆光情况下的表现怎么样?答:坦白说出局限,然后说明检测对齐模块如何缓解,以及哪些场景下未来可以接入更鲁棒的模型。
把每一个技术决策都说成“基于数据和实验的选择”,比空谈创新有用得多。
最后分享一个小心得:在答辩演示时,如果系统识别成功,不要得意忘形的笑;如果识别失败,也不要慌,先看日志,大概率是对齐阶段出的问题。你只需要说“检测到了人脸,但相似度低于阈值,说明特征区分度还有提升空间”,然后把阈值在配置文件里微调一下,重新跑就能挽回局面。这套说辞既展示了排查能力,也解释了系统边界,反而比“全是对的”更有说服力。