ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

基于深度学习的人脸识别考勤系统:全流程实现与避坑指南

基于深度学习的人脸识别考勤系统:全流程实现与避坑指南

简介:这套基于深度学习的人脸识别考勤系统源码,是面向计算机相关专业毕业设计的高分项目,经导师指导并通过评审,得分98分。资源适合正在完成毕业设计、课程设计或期末大作业的本科生,也适合需要项目实战练习的Python学习者,可用于快速搭建人脸识别考勤应用并理解完整实现流程。压缩包共25个文件,约239MB,内含Python核心程序与界面脚本、前端界面图片素材、字体图标文件、配置文件及配套说明手册,覆盖源码、资源与文档三类内容,便于对照学习。目前已有166人学习浏览。借助该资源,读者可获得项目完整目录结构、人脸识别核心模块设计、UI界面与静态资源组织方式,以及可直接运行或二次开发的基础代码,能够节省从零搭建的时间,帮助理解考勤业务流程与深度学习模型的工程化应用。

1. 本科毕设答辩现场,评委最想看的不是模型,而是这一层闭环

站在答辩讲台上被评委追问「这个深度学习模型是你自己训的吗」,几乎是每个做系统类课题的人都会经历的一幕。基于深度学习的人脸识别考勤系统这个 python 毕业设计能拿到 98 分评审分,靠的恰恰不是「重新发明人脸识别」,而是把成熟的人脸特征提取能力完整地接进考勤业务:注册人脸、摄像头实时识别、自动写签到记录、在界面里展示考勤结果,这一整条业务链路是学生自己设计并实现的。这套项目适合正在做毕业设计、课程设计或期末大作业的计算机相关专业学生,也适合想快速弄懂「深度学习模型怎么落地成业务系统」的一线开发拿来拆解。

2. 整体架构拆解:从摄像头到考勤数据库的完整闭环

2.1 技术选型:为什么是 dlib + face_recognition + Flask

这套项目在 2018 年前后的本科毕设里是非常典型的一套组合拳。它没有从零训练卷积神经网络,而是直接使用 dlib 自带的预训练 ResNet 模型提取人脸特征,模型输出一个 128 维的特征向量。这个方案的好处很明显:深度学习模型是现成的,答辩时能讲清楚特征提取原理;同时模型可以在 CPU 上推理,不依赖 GPU,演示环境不用讲究。

OpenCV 负责读取摄像头画面、解码上传的图片、做人脸框的绘制;face_recognition 库把人脸检测和人脸特征编码封装成两行调用,大幅降低了业务开发的复杂度;Flask 提供 HTTP 接口,供桌面端和 Web 端共用;SQLite 数据库保存用户白名单与每日考勤记录,整个系统不依赖外部数据库服务,拷贝就能迁移。桌面端用 tkinter 编写,Web 端使用 static 目录下的静态资源,两套界面共用同一个后端接口。

这套选型在毕设场景里是经过权衡的。如果用 MTCNN + FaceNet 自己搭推理链路,模型转换和依赖安装就够折腾两周;如果完全不用深度学习,又过不了「基于深度学习」这个题目要求。dlib 的预训练模型让项目既有深度学习含量,又能把主要精力放在考勤业务逻辑上,这也是它能拿高分的原因之一。对想二次开发的读者来说,这套结构还留了替换模型的余地。

2.2 项目目录结构:face-master 里每个模块负责什么

拿到源码后,先看目录结构比直接跑命令更重要。face-master 下的组织方式属于「后端接口 + 前端静态资源 + 打包产物」的经典分层:

face-master/ ├── api/ │ └── face.py # 人脸检测、特征提取、注册与签到接口 ├── static/ # Web 端静态资源:背景、logo、用户列表 UI 素材 ├── Ui_test_01.py # 桌面端入口,tkinter 编写的考勤演示界面 ├── config # 运行配置:阈值、摄像头编号、存储路径 ├── msyh.ttf # 微软雅黑字体,解决界面中文乱码 ├── 人脸识别-软件.zip # 打包好的演示版本,答辩应急用 ├── 手册.docx # 使用手册,对应论文与操作说明 └── .gitignore

api/face.py 是核心后端,所有识别逻辑都收口在这里;static 里那一堆 png 前景图、用户列表背景图、logo 素材,是给 Web 界面摆放人脸框和用户信息用的,不是无用文件。Ui_test_01.py 是桌面端测试界面,负责调用 api 层的能力,本质上是一个客户端壳子。

值得注意的还有 msyh.ttf 字体文件和「人脸识别-软件.zip」。前者说明项目早期一定踩过中文显示成方块的问题,后者说明做完以后用 PyInstaller 打过包。这两样对答辩演示非常实用,后面第 5 章的踩坑记录里我会专门展开讲。

2.3 数据流与表结构设计:人脸特征怎么存、怎么比

整个系统的核心链路只有两条。注册链路是:摄像头拍一张照片或者上传一张图片,后端先做人脸检测,确认画面里只有一张脸,再提取这张脸的 128 维特征向量,连同 user_id、姓名一起存入数据库。签到链路是:摄像头持续取帧,每隔几帧做一次人脸检测,检出人脸后提取特征,与库里所有已注册特征逐一计算欧氏距离,距离小于阈值就判定为同一个人,写入一条签到记录,同时把用户信息返回给前端展示。

数据库表结构也围绕这两个链路设计。源码里一般情况下会建两张核心表:user_info 保存用户白名单和人脸特征,attendance_record 保存每日签到流水:

CREATE TABLE user_info ( user_id TEXT PRIMARY KEY, name TEXT NOT NULL, face_feature BLOB, register_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE attendance_record ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, checkin_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );

face_feature 字段用 BLOB 存储,常见做法是把 128 维 numpy 数组通过 tobytes() 序列化后写入,识别时再读到内存用 np.frombuffer() 还原。这种做法的好处是特征保存在业务库里,导出、备份都随库走;缺点是换模型后旧特征维度不匹配,需要重新注册一次。第 6 章改造部分我会给出一个更稳的存储方案。

3. 核心模块拆解:注册、识别、防重复签到的完整实现

3.1 注册接口:为什么强制要求画面里只能有一张人脸

api/face.py 里的注册接口是整套系统的第一个关键节点。它的输入是 user_id、姓名和一张人脸图片,输出是注册成功或失败。核心逻辑拆出来大概是这样的:

# api/face.py - 用户注册接口(简化版) @app.route('/api/register', methods=['POST']) def register(): user_id = request.form.get('user_id') name = request.form.get('name') file = request.files.get('face_image') # 从二进制解码成 OpenCV 图像 img = cv2.imdecode(np.frombuffer(file.read(), np.uint8), cv2.IMREAD_COLOR) # 人脸检测,boxes 是所有人脸框坐标列表 boxes = face_recognition.face_locations(img) # 强制要求只能有一张人脸 if len(boxes) != 1: return jsonify({'code': 400, 'msg': '画面中必须只有一张人脸'}) # 提取第一张人脸的 128 维特征向量 encoding = face_recognition.face_encodings(img, boxes)[0] # 将特征与用户信息写入数据库 save_face_feature(user_id, name, encoding) return jsonify({'code': 0, 'msg': '注册成功'})

face_recognition.face_locations() 返回的是图片中人脸框的 (top, right, bottom, left) 坐标列表,默认使用 HOG 检测器,CPU 上单张图耗时约几十毫秒,作为注册接口完全够用。face_encodings() 会检测到的人脸裁剪后送入预训练模型,输出 128 维 float 数组。

强制「必须只有一张人脸」不是故意刁难用户,而是为了保证特征入库的正确性:如果画面里有两张脸,采集到的特征就无法确定归属于哪个 user_id,后期识别时还会出现特征混淆。我在实际测试中还发现,注册照片如果太模糊或光线不足,face_locations 会检测不到人脸,这时候接口应该返回「未检测到人脸,请重新拍摄」,而不是让前端转圈。

3.2 签到接口:特征比对和阈值设置的取舍

签到接口是识别链路的核心,逻辑上比注册多两步:先比对库里所有人脸特征,找到最近的那一个;再判断这个人今天是否已经打过卡。核心代码框架如下:

# api/face.py - 考勤识别接口(简化版) @app.route('/api/checkin', methods=['POST']) def checkin(): # 从摄像头取一帧 frame = get_frame_from_camera() # 检测当前帧中所有人脸 boxes = face_recognition.face_locations(frame) for box in boxes: encoding = face_recognition.face_encodings(frame, [box])[0] # 与库中所有特征比较,返回距离最小的用户 user = find_nearest_user(encoding, threshold=0.55) # 匹配成功且当天未签到则写入记录 if user and not has_checked_in_today(user['user_id']): insert_attendance(user['user_id']) return jsonify({'code': 0, 'user': user['name']}) return jsonify({'code': 404, 'msg': '未识别到有效人员'})

find_nearest_user 内部做的事情是遍历 user_info 表中所有人脸特征,用 np.linalg.norm 计算欧氏距离,取最小值对应的用户,同时判断这个最小值是否小于阈值。face_recognition 库自带的 compare_faces 也能完成这件事,但返回的是布尔列表,不方便拿到具体距离值,所以我更推荐自己写距离计算,方便调参。

阈值这东西很玄学,定得太大容易误识别,定得太小又导致真人签不上。常见范围是 0.5 到 0.6,项目里默认 0.55 是个折中值。如果现场灯光不好或者摄像头离得远,我一般会把阈值放宽到 0.58,再配合多点采样取平均来降低误判概率。

3.3 两套界面共用一套 API:tkinter 桌面端与 Web 前端的协作方式

这套项目很有意思的一点是同时存在桌面端和 Web 端两套展示界面。Ui_test_01.py 是用 tkinter 写的桌面窗口,static 目录则是给 Web 页面准备的资源。两套界面最终都通过 requests 或 ajax 调用同一个 Flask 接口,这种设计在答辩时特别加分,因为能同时展示桌面开发和前端知识,而且后端代码一套搞定。

桌面端按钮回调的核心逻辑大致是这样的:

# Ui_test_01.py 中签到按钮回调(节选) def on_checkin_click(): resp = requests.post( 'http://127.0.0.1:5000/api/checkin', files={'frame': capture_photo()} ).json() if resp['code'] == 0: label_result.config(text=f"签到成功:{resp['user']}") else: label_result.config(text=resp['msg'])

capture_photo() 负责从本地摄像头采集一帧图片,然后通过 multipart/form-data 上传给后端。Web 端则用 ajax 做同样的事情,只是把摄像头采集换成了 video 标签截图。这里有一个容易犯的错误:如果直接打开 static 下的 HTML 文件而不是通过 Flask 路由访问,浏览器跨域会直接拦截请求。正确做法是先启动后端,从后端渲染页面,再在页面里调用同源 API。

4. 运行部署:从依赖安装到联调签到的完整流程

4.1 环境准备:版本锁定是第一件正经事

这套项目的时间点在 2018 年,依赖库版本和现在的 Python 环境有不小差异。直接 pip install 最新版容易出现 dlib 编译失败、face_recognition 与新版 numpy 不兼容这类问题。我建议先在一个干净的 Python 3.6 或 3.7 环境里安装,依赖尽量锁住版本:

pip install -r requirements.txt

一般 requirements.txt 里会包含这些核心库:

numpy==1.18.5 opencv-python==4.4.0.46 dlib==19.22.0 face_recognition==1.3.0 Flask==1.1.2 pandas==1.0.5 pillow==7.2.0

这里最挑剔的是 dlib。在 Windows 上,默认源码编译需要 Visual Studio 环境,很多人卡在这一步。常见做法是直接下载预编译的 dlib wheel 文件安装,省去编译过程。face_recognition 依赖 dlib,必须等 dlib 装好以后再装。如果用的是 Python 3.8 以上版本,dlib 的预编译包不太好找,这也是我建议锁 Python 3.6/3.7 的原因。

4.2 config 文件:摄像头编号、阈值这些参数去哪改

项目里有一个 config 文件,负责集中管理运行参数。它类似于 ini 配置,里面一般会写摄像头编号、模型路径、数据库路径、识别阈值这些关键配置:

[face] model_path = models/dlib_face_recognition_resnet_model_v1.dat threshold = 0.55 camera_index = 0 frame_interval = 3 [db] path = attendance.db

几个参数的含义和调法我列一下。camera_index 是摄像头编号,通常笔记本自带摄像头是 0,外接 USB 摄像头可能是 1,黑屏时一般先改这里。threshold 是前面说过的欧氏距离阈值,0.5 到 0.6 之间调。frame_interval 是抽帧间隔,每 3 帧处理一次,降低 CPU 压力。model_path 指向 dlib 的预训练模型文件,这个文件解压后约 99MB,路径不能写错。

config 文件的意义在于,答辩演示时不改代码就能切换摄像头或调整识别灵敏度。有些拿到源码的同学不知道有这个文件,把没找到模型的问题当成代码 bug 去排查,其实只是 model_path 没对上。

4.3 启动顺序:先起后端,再开界面,最后联调

正确启动步骤应该是固定的,不然会出现界面开了但接口连不上的尴尬情况:

# 第一步:启动 Flask 后端服务 python api/face.py # 第二步:另开一个终端,启动桌面端 python Ui_test_01.py

后端正常启动后,Flask 默认在 5000 端口监听。Ui_test_01.py 里写的请求地址是 http://127.0.0.1:5000,两者必须保持一致。如果 5000 端口被占用,启动时 Flask 会报错,常见做法是在 face.py 末尾修改 app.run(port=5001),同时把 Ui_test_01.py 里的请求地址同步改掉。

联调的时候建议先用注册功能录一个正面照,再用同一张脸走签到流程。第一次运行如果摄像头一直黑屏,先确认 config 里的 camera_index;如果提示「未识别到有效人员」,先确认注册时的照片是不是正脸、光线是否足够。整体流程跑通后,再尝试打包或调整界面样式。

注意:摄像头是独占设备,桌面端打开摄像头以后,Web 页面再调摄像头会取不到画面。演示时最好只保留一个界面占用摄像头。

5. 避坑排查:跑这个系统最常见的五个翻车点

5.1 摄像头黑屏,提示无法获取画面

现象:启动 Ui_test_01.py 后窗口能打开,但视频区域是全黑的,控制台报错 vidio capture error 或者 IndexError。

原因:绝大多数情况是摄像头编号不对。笔记本自带摄像头在 0 号,外接 USB 摄像头可能占用了 1 号或更高编号;也有可能是另一个程序已经占用了摄像头,导致 OpenCV 拿不到画面。

解决:先打开任务管理器看有没有其他应用占用摄像头,关掉以后把 config 里的 camera_index 改成 1 再试。如果还是黑,写一行临时代码打印摄像头可用编号,逐个测试。

5.2 同一个人签到失败,或者把另一个人识别成自己

现象:注册时用的是正面照,签到时换了个角度或者灯光暗一点,就提示「未识别到有效人员」;更头疼的是,两个长得像的人互相误识别。

原因:阈值设置不合理是最直接的原因。阈值太严,同一个人稍有差异就匹配不上;阈值太松,不同人特征距离也可能小于阈值。另一个常见原因是注册时的照片质量太差,特征本身不具代表性。

解决:先把 config 里的 threshold 从 0.55 放宽到 0.58,观察误识别率;如果误识别变多,再降回 0.53。同时把注册照片换成光线均匀的正面照。距离阈值这种事,换了环境就得重新标定一次,别指望一个参数吃遍所有场景。

5.3 界面上的中文变成方块或乱码

现象:姓名、签到提示在 tkinter 界面里显示成一个个方框,英文正常,中文全是菱形问号。

原因:这是 2018 年前后做 tkinter 中文应用踩得最频繁的坑。tkinter 在非中文系统下默认字体不含中文字形,需要指定中文字体文件。项目里特意放了一个 msyh.ttf,就是为了兜底这个场景。

解决:在 Ui_test_01.py 里创建 Label 或 按钮时,显式指定字体,常见做法是 font=("微软雅黑", 14),或者用 msyh.ttf 文件路径加载字体。遇到用 Pillow 绘制中文时,也需要先加载 msyh.ttf 再 ImageFont.truetype 调用。

5.4 SQLite 报 database is locked

现象:注册和签到操作偶尔抛出 OperationalError: database is locked,尤其在连续快速操作时出现频率更高。

原因:Flask 默认是多线程处理请求,两个请求同时往 SQLite 写数据时,一个操作没释放写锁,另一个就报 locked。SQLite 适合低并发场景,但架不住多个请求同时写。

解决:常见做法是把数据库连接设成 check_same_thread=False,并给写操作加一个线程锁,让签到写入串行化。我一般还会把 sqlite3.connect 的超时参数设成 10 秒,减少偶发锁冲突。如果并发量真的上去了,再考虑换成 MySQL,那就是另一个课题了。

5.5 PyInstaller 打包后双击闪退

现象:本机运行一切正常,用 PyInstaller 打包成 exe 之后,双击秒退,命令行方式启动才能看到报错信息。

原因:两个经典问题。第一,dlib 的动态链接库没有被打包进去;第二,模型文件路径用的是相对路径,解压后 exe 和 models 目录不在一个位置。PyInstaller 打包的依赖分析经常漏掉 dlib 的 DLL。

解决:打包命令里用 --add-data 把模型文件和 msyh.ttf 一并打进去,然后在代码里判断是否运行在打包环境:有 sys._MEIPASS 就用临时解压目录,否则用当前目录。项目里那份「人脸识别-软件.zip」演示版其实就是按照这个思路打包的,答辩时拿到别人的电脑上也能跑。

6. 二次开发:换模型、加报表,把项目改成你自己的课题

6.1 先抽离特征提取器,给换模型留好接口

如果想把「基于深度学习」这个点讲得更有分量,最值得改的是把 dlib 特征替换成 MTCNN 或 FaceNet 这类模型。改造时不用动签到流程,只需要把特征存储结构改成「模型名 + 维度 + 向量」三件套:

# 特征存储建议统一成如下结构 { "user_id": "2024001", "name": "A同学", "model": "facenet_v1", "dim": 512, "feature": [0.012, -0.034, ...] }

这样替换模型时,只需改特征提取和距离计算逻辑,比对函数仍然是「向量 + 阈值」的无状态函数。注意不同模型的输出维度不一样,dlib 是 128 维,FaceNet 是 512 维,切换模型后旧注册数据全部作废,需要重新入库。这个坑一定要提前跟答辩评委讲清楚,反而能体现你理解模型层面的事。

6.2 加一个考勤统计报表,顺便复习 pandas

考勤系统光有签到记录还不够,导师很可能会问「怎么按月统计出勤率」。给项目加一个报表导出功能其实很快,用 pandas 读 SQLite 再导出 CSV 就行:

def export_daily_report(date): df = pd.read_sql( "SELECT user_id, name, checkin_time FROM attendance_record WHERE date(checkin_time)=?", conn, params=(date,) ) df.to_csv(f"daily_{date}.csv", index=False, encoding="utf-8-sig")

encoding 用 utf-8-sig 是因为 Excel 直接打开 UTF-8 的 CSV 会乱码,这个细节在答辩演示时很加分。改造完成后建议用两个用例验证:自己注册一张正面照并签到,确认能正确写考勤;找一个未注册的人测试误识别拦截,确认不会漏签到。从那以后我每次接这类毕设项目,都会先确认特征维度、数据库字段、前端接口三者保持一致再继续改业务代码,希望帮到你。

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

RELATED READING

延伸阅读

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