
简介面向计算机专业毕业生与初学者的完整毕业设计项目以Python和深度学习为核心实现人脸识别考勤系统的注册、识别、签到状态管理等功能适合用于本科毕设、课程设计或期末大作业。包内共25个文件压缩包约239.41MB主要包括3个Python源程序、15张PNG界面与背景素材、配置文件和API接口等其中PNG素材覆盖了用户列表、识别区域、签到状态等可视化模块便于理解业务逻辑与界面交互docx手册提供使用与部署说明msyh字体和ico图标保障界面正常显示。项目涉及人脸检测、特征提取、考勤记录等关键模块经导师指导项目评审分达98分源码结构清晰、注释完整可帮助读者快速掌握深度学习模型在考勤场景中的落地流程。目前已有166人学习使用适合需要项目实战练习或准备毕业设计答辩的学生参考学习。1. 一到期末就有人问人脸识别考勤系统这个东西真不是玄学每年毕业设计选题季人脸识别考勤系统都是python方向里问得最多的题目之一。上课点名浪费时间公司门禁又需要无感考勤一套能对着摄像头自动打卡的代码把本科生阶段能接触到的技术点全串起来了python基础、opencv人脸识别、深度学习特征提取、sqlite数据库再加一个简单的桌面界面。它不是纯调库也不是造火箭处在“有点难度但能落地”的中间位置最适合作为python入门后的第一个完整项目也适合给做过爬虫或管理系统、想往深度学习方向靠的同学练手。真正难的不是识别那一瞬间而是把识别结果变成一条可靠的考勤记录。这篇文章就按这个思路从选型讲到参数再讲到排错和验收。2. 技术选型人脸识别考勤系统的三层架构与框架选择2.1 核心算法栈检测、特征提取、比对三件事人脸识别考勤不是“一个模型干到底”拆开看是检测、特征提取、比对三件事。检测负责在摄像头画面里找到人脸框输出坐标特征提取负责把人脸区域转成一组向量比对负责计算这组向量和库中已有向量的距离距离小于阈值就判定为同一个人。三件事用到的技术完全不同但很多本科方案把它们混在一起调库出问题时不知道看哪一段。检测层常见做法有Haar级联、HOG、MTCNN和RetinaFace四种各有各的适用位置。考勤这种室内固定摄像头的场景计算成本和实时性优先我用过之后给出的选型建议如下表检测方法计算成本小脸表现在考勤场景的定位Haar级联极低差注册阶段采集人脸时够用HOGdlib低一般face_recognition默认实时识别够用MTCNN中好追求精度时可替换默认检测器RetinaFace高很好需要GPU本科毕设没必要上检测和特征提取之间还有一步人脸对齐通常被face_recognition封装在内部。对齐不是简单的resize而是根据人眼坐标做仿射变换把眼睛对齐到固定位置后再送入网络。这一步对识别率的提升非常明显因为人脸旋转、倾斜都会让特征向量发生偏移。这也是为什么有人直接用opencv的resize替换检测框再自己接一个分类器效果总是差一截——他跳过了对齐。写论文时把检测、对齐、特征提取、比对四条链路画清楚整套系统至少在逻辑上就完整了。2.2 快速原型选 face_recognition高分路线补上深度学习原理快速原型我一般推荐face_recognition库。它是dlib的上层封装特征提取用的是在数百万张人脸上训练过的ResNet变体输出128维特征向量。单张推理在CPU上几十毫秒对考勤场景来说已经足够实时。它的价值在于把深度学习那套复杂的训练流程封装成两个API调试成本低本科答辩的演示效果能很快出来。想拿高分不建议自己从头训练人脸识别模型而是要把face_recognition背后那套讲清楚。人脸检测框交给网络后经过卷积层、残差块最后全局池化输出128维嵌入训练时用三元组损失同一人的向量距离被拉近不同人的向量距离被拉远。论文里补上这个原理说明再用真实数据做采样验证就比光调库高一个档次。如果非要用“自己训练的模型”来拔高现实一点的做法是拿预训练模型做迁移学习只重训练最后几层分类器而不是从随机权重开始跑整个训练流程那是研究生的实验规模不是本科毕设能承受的时间成本。这里还有一个常见误用有人为了加快识别速度把一个人的多张照片特征向量求平均后只存一条。这种做法在光照变化大的场景下会让平均向量偏离真实分布导致本人到场时距离反而偏大。保留所有原始向量做全量比对用argmin取最近距离虽然多占一点内存但换来的稳定性值得。2.3 考勤系统的周边选型数据库与界面框架怎么配识别链路定了业务层就好选了。数据库用sqlite单文件零配置答辩不需要演示安装MySQL。界面用tkinterpython自带能显示识别结果和打卡状态就够了把太多精力花在酷炫界面上反而偏离主线。我问过几个选了django做web版的同学最后几乎都卡在浏览器摄像头权限、并发连接和前端异步上工作量完全失控。硬件上常见做法是先用PC加USB摄像头跑通再迁移到树莓派上。树莓派上装同样的环境和代码接一个USB摄像头就变成一台微型人脸识别门禁机很多人答辩现场展示的门禁机就是这么做的。代码层没有硬件绑定可能只是摄像头索引从0变成1。商用的人脸识别门禁机在原理上完全相同只是摄像头模组、算力和外壳是定制的成本高一个数量级。建议先把PC端跑通再谈硬件迁移不要一上来就并行搞两套环境。多人注册时还有一个目录管理的坑。dataset目录下一个人一个子目录子目录名就是识别结果里的姓名或学号如果直接往dataset根目录丢一堆jpg后面os.listdir(dataset)会把文件当目录处理直接跳过。批量给班级注册时我一般先用Excel整理名单再写脚本批量创建目录采集时把person_name变量替换成循环里的学号这也是从“演示”到“可用”迈出的一步。提示环境安装顺序有讲究。先按python安装教程把Python 3.8以上装好再在VSCode里把python环境配置解释器指到同一个环境最后pip install opencv-python face_recognition。这样后面调代码报错时你能分清是环境问题还是代码问题。3. 本地跑通最小可用版注册人脸、生成特征、实时识别3.1 摄像头注册人脸20张照片是底线第一步是采集人脸照片。这里不建议用网图或翻拍照考勤系统必须用真人、当天光照条件下的数据。采集策略有三点每张照片之间稍微转动头部方向让左右侧脸都有覆盖如果教室或办公室是固定光源就在同一个位置多拍几张如果场景是走廊门禁光差变化大最少拍30张。下面脚本用Haar检测人脸并保存20张是底线。import cv2 import os face_cascade cv2.CascadeClassifier( cv2.data.haarcascades haarcascade_frontalface_default.xml ) camera cv2.VideoCapture(0) person_name zhangsan save_dir fdataset/{person_name} os.makedirs(save_dir, exist_okTrue) count 0 while count 20: ok, frame camera.read() if not ok: print(读取摄像头失败检查摄像头权限) break gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) faces face_cascade.detectMultiScale( gray, scaleFactor1.1, minNeighbors5, minSize(80, 80) ) for (x, y, w, h) in faces: count 1 face frame[y:y h, x:x w] cv2.imwrite(f{save_dir}/{count}.jpg, face) cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) print(f已保存 {count} 张) cv2.imshow(register, frame) if cv2.waitKey(1) 0xFF ord(q): break camera.release() cv2.destroyAllWindows()采集阶段用Haar而不是深度学习检测器是因为这一步只做裁剪保存的图片后面还要走深度特征提取。Haar偶尔漏检不影响最终结果但速度在CPU上能跑满帧。detectMultiScale的三个参数在考勤场景下保持默认即可scaleFactor1.1表示搜索窗口每轮缩小10%越小越精细也越慢minNeighbors5表示候选框至少被5个邻近窗口确认越大漏检越多minSize(80, 80)过滤太小区域避免把噪点当人脸。count是按检测框累加的一帧里出现两张脸就保存两张这是正常的。程序结束前必须主动release摄像头和销毁窗口。很多人在采集脚本里直接关终端下次运行报Device or resource busy问题就出在这里。ctrlc结束程序不会执行release所以要养成在脚本末尾释放资源的习惯或者把采集封装成函数在finally块里release。3.2 把照片转成128维特征特征文件生成脚本照片存好后下一步是把dataset目录下所有人的照片批量编码输出一个pickle特征文件。这个文件就是人脸库后面启动识别时直接加载。import face_recognition import os import pickle known_encodings [] known_names [] for person_name in os.listdir(dataset): person_dir os.path.join(dataset, person_name) if not os.path.isdir(person_dir): continue for img_name in os.listdir(person_dir): img_path os.path.join(person_dir, img_name) image face_recognition.load_image_file(img_path) encodings face_recognition.face_encodings(image) if encodings: known_encodings.append(encodings[0]) known_names.append(person_name) data {encodings: known_encodings, names: known_names} with open(encodings.pkl, wb) as f: pickle.dump(data, f) print(f已处理 {len(known_names)} 张人脸保存到 encodings.pkl)face_recognition.face_encodings内部完成检测、对齐、特征提取三步返回128维向量。同一张脸在不同光照下向量不会完全一样所以一个人的多张照片向量都要存进列表识别时取最小距离作为判断依据。这段代码有个隐藏细节如果某张照片没有检测到人脸encodings是空列表必须跳过否则后面取encodings[0]会直接报下标越界。另一种常见写法是对同一人多张照片的特征取平均再存我之前说过不推荐原因是平均向量会把各种姿态下的偏差互相抵消导致此人实际到场时距离反而偏大。pickle文件保存的是列表套字典的结构加载时直接pickle.load就能恢复。注意pickle的兼容性用Python 3.10生成的pkl在Python 3.7上不一定能读出来如果换电脑运行最好在同一版本环境下重新生成不要搬运pkl文件。dataset目录里也不要放奇怪的隐藏文件macOS的.DS_Store、Windows的Thumbs.db会被os.listdir扫到后面按目录跳过时没事但如果你把文件直接平铺在dataset根目录这些文件就会混进特征生成流程。3.3 实时识别循环从摄像头逐帧比对到打卡特征文件有了识别部分就是打开摄像头、逐帧找脸、逐帧比对。import face_recognition import pickle import cv2 with open(encodings.pkl, rb) as f: data pickle.load(f) known_encodings data[encodings] known_names data[names] video_capture cv2.VideoCapture(0) while True: ok, frame video_capture.read() if not ok: break rgb_frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) face_locations face_recognition.face_locations(rgb_frame) face_encodings face_recognition.face_encodings(rgb_frame, face_locations) for face_encoding in face_encodings: distances face_recognition.face_distance(known_encodings, face_encoding) best_match_index distances.argmin() best_distance distances[best_match_index] if best_distance 0.45: name known_names[best_match_index] else: name unknown print(f识别结果: {name}, 距离: {best_distance:.3f}) if cv2.waitKey(1) 0xFF ord(q): break video_capture.release() cv2.destroyAllWindows()阈值0.45是经验值第6章会讲怎么用测试集校准。这里先看几个边界情况一帧里出现多张脸循环会按顺序对每张脸做全量比对当摄像头对着五六个人时CPU占用会显著上升此时可以把处理降到每3帧一次加个帧计数器frame_count 0 while True: ok, frame video_capture.read() if not ok: break frame_count 1 if frame_count % 3 ! 0: continue # 后续识别代码保持不变这样牺牲一点点实时性换来CPU负载大幅下降。face_distance计算的是欧氏距离没有固定上限一般同一个人不同光照下的距离在0.3到0.5之间不同人大约在0.5到1.0以上。如果想在画面上显示识别框用opencv把face_locations的坐标画上去但face_recognition返回的坐标是(top, right, bottom, left)和opencv常用的(x, y, w, h)不同画框时记得转换这是一个非常容易写错的细节。如果想给考勤留证据常见做法是在识别成功那一帧把画了框的截图写到logs目录文件名带上时间戳。截图保存现场画面数据库只存时间和人员两者配合才是完整的考勤证据链。用cv2.imwrite写入时注意中文路径问题OpenCV对中文路径支持不稳定路径里尽量用英文时间戳里的冒号要替换成下划线否则Windows会直接报错。4. 拿数据说话考勤记录的数据模型、打卡逻辑与展示4.1 考勤表怎么建一条记录对应一次成功识别识别结果如果不落库程序一关数据就没了。很多方案把考勤记录存在内存列表里答辩演示时一重启就露馅。正确的做法是落库sqlite的建表语句如下CREATE TABLE IF NOT EXISTS attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, student_id TEXT NOT NULL, student_name TEXT NOT NULL, check_time TEXT NOT NULL, status TEXT DEFAULT normal );id自增student_id学号student_name姓名check_time存ISO格式时间字符串status存考勤状态。student_id这个字段看似冗余但必须留dataset目录里如果直接用中文名同名情况会直接导致考勤记到错误的人头上学号才是唯一标识。check_time用TEXT在sqlite里完全够用date()和datetime()函数都能直接处理ISO字符串。建议再给student_id和check_time建一个联合索引CREATE INDEX idx_attendance_student_date ON attendance(student_id, check_time);判重查询走这个索引后数据量上千条也感觉不到延迟。查看当天考勤记录的SQL是SELECT student_id, student_name, check_time, status FROM attendance WHERE date(check_time) 2025-05-20 ORDER BY check_time;这条查询也是导出报表的基础。考勤记录导出成CSV是答辩时展示系统完整度的加分项用python内置csv模块就能做import csv import sqlite3 conn sqlite3.connect(attendance.db) rows conn.execute( SELECT student_id, student_name, check_time, status FROM attendance ORDER BY check_time ).fetchall() with open(attendance.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([学号, 姓名, 打卡时间, 状态]) writer.writerows(rows)这里的encodingutf-8-sig是必须的直接用utf-8编码生成的CSV用Excel打开会乱码这个细节几乎每次都会被问到。attendance表的设计原则是“事件流水”谁在哪天几点什么状态被识别到。表结构越简单后面统计越灵活。4.2 避免重复打卡用状态机把“已打卡”拦下来考勤系统和纯人脸识别的最大区别是状态管理。一节课45分钟摄像头一直在抓脸不加判断的话同一个人的名字会反复插入数据库。处理办法是按天判重同一天内一个学号只允许插入第一条记录。import sqlite3 import datetime def check_in(student_id, student_name, conn): today datetime.date.today().isoformat() cur conn.cursor() cur.execute( SELECT id FROM attendance WHERE student_id? AND date(check_time)?, (student_id, today), ) if cur.fetchone(): return False, 今日已打卡 now datetime.datetime.now() status normal if now.hour 9: status late cur.execute( INSERT INTO attendance (student_id, student_name, check_time, status) VALUES (?,?,?,?), (student_id, student_name, now.isoformat(), status), ) conn.commit() return True, statusdate(check_time)?这个条件依赖check_time格式统一全程序必须统一用datetime.now().isoformat()插入。有人会在某处用time.strftime(%Y-%m-%d %H:%M:%S)另一处用isoformat两种格式相差一个字母Tdate函数解析结果完全不同判重直接失效。这是排查时最容易忽略的点。迟到判断放在check_in内部而不是调用处是为了让考勤规则集中在一处改上课时间时只改这一个函数。注意now.hour是整数如果上课时间是9点半写成now.hour 9.5永远不成立正确做法是拿now.time()和datetime.time(9, 30)比较。sqlite在考勤系统里只有一个人口写入不存在并发问题。如果以后改成web版多个客户端同时打卡sqlite会报database is locked届时再考虑换PostgreSQL或加锁队列毕设阶段完全不必。考勤规则里请假和补卡是两类常见状态我的做法是在status字段里扩展枚举normal、late、leave、earlycheck_in只处理normal和lateleave和early由管理员界面手动录入。如果硬把所有状态都塞进check_in函数会越来越复杂不值得。4.3 从识别到落库把前面两块拼起来识别循环和考勤逻辑的整合方式如下import sqlite3 import face_recognition import pickle import cv2 conn sqlite3.connect(attendance.db) with open(encodings.pkl, rb) as f: data pickle.load(f) video_capture cv2.VideoCapture(0) while True: ok, frame video_capture.read() if not ok: break rgb_frame cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) face_locations face_recognition.face_locations(rgb_frame) face_encodings face_recognition.face_encodings(rgb_frame, face_locations) for face_encoding in face_encodings: distances face_recognition.face_distance(data[encodings], face_encoding) best_idx distances.argmin() if distances[best_idx] 0.45: student_name data[names][best_idx] ok_flag, msg check_in(student_name, student_name, conn) print(f{student_name}: {msg}) if cv2.waitKey(1) 0xFF ord(q): break video_capture.release() conn.close() cv2.destroyAllWindows()这里student_id直接用目录名代替实际项目中可以换成从学生信息表映射。判重逻辑在check_in内部所以同一人连续识别时只有第一条会插入后续全部返回“今日已打卡”。结构上识别层和考勤层是解耦的换检测模型只改识别循环改考勤规则只动check_in这是这套代码后期最好维护的地方。界面层我一般用tkinter的Label做实时状态显示用一个Text控件滚动显示打卡记录。注意tkinter的mainloop会阻塞while True识别循环常见的处理方式是开一个后台线程跑识别线程里通过queue把识别结果发给主线程。线程里不要直接操作控件否则界面会假死。这部分代码本身不难但新手很容易踩写进论文“系统设计”一节反而算亮点。5. 人脸识别考勤系统的排查笔记五个高频翻车现场5.1 采集的照片全是黑的摄像头被占用与权限问题现象注册脚本运行时cv2.imshow窗口是黑的保存下来的jpg也全是黑色画面。原因最常见是摄像头被占用上一个python进程没有释放摄像头其次是macOS或Windows的隐私设置没给终端摄像头权限还有可能是摄像头索引不对外接USB摄像头不在0号索引。解决先看任务管理器或活动监视器里有没有残留的python进程杀掉再重试。再尝试把VideoCapture(0)改成VideoCapture(1)验证索引。macOS在“系统设置-隐私与安全性-摄像头”里勾选对应程序Windows在“设置-隐私-相机”里允许桌面应用访问。按这个顺序排查80%的情况是进程残留。我实际排查中还遇到过一种情况VSCode里同时开了调试器和采集脚本调试器占用了摄像头画面一卡一卡关掉调试器就恢复正常。摄像头设备不像文件操作系统默认不允许两个进程同时读流。5.2 识别率低到没法用光照与眼镜问题现象背光环境或晚上开灯时频繁识别成unknown即便匹配上距离也在0.5以上。原因特征向量对光照敏感。注册时采集的是白天正面光线使用时换了光源方向同一张脸的向量发生漂移。另一个容易被忽略的因素是眼镜注册时没戴眼镜使用时戴了距离会被拉大0.1到0.2正好卡在阈值边界上。解决注册数据要覆盖不同光照、左右角度、戴与不戴眼镜的情况不要只坐在一个位置不动。放宽阈值是应急手段但会牺牲精度。最稳妥的方案是补数据再重新生成encodings.pkl不要指望靠阈值弥补数据质量这是血泪经验。数据质量决定识别率上限阈值只是在下限附近做取舍。5.3 同一节课被记了三条考勤漏掉判重状态现象一个人站在摄像头前几秒attendance表里出现了三条同一时间的记录。原因识别循环每一帧都在跑每帧识别成功都调用了一次插入没有判重逻辑。解决按第4.2节的check_in做日级去重。需要注意date(check_time)?依赖check_time格式统一不能一个入口用datetime.now().isoformat()另一个入口用strftime那样date函数解析错乱判重会失效。排查时先执行SELECT * FROM attendance看看时间字段的实际格式如果混进了手工插入的测试数据格式不统一也会让判重形同虚设。5.4 陌生人也能打卡成功阈值过松与数据污染现象用手机里的一张照片对着摄像头系统识别成了库里的某个同学距离只有0.35。原因阈值设得太松0.5以上时不同人的向量距离也可能落进匹配区间。另一个隐藏原因是dataset里混入了翻拍屏幕的照片等于把陌生人的脸标成了同学特征库已经被污染。解决先把dataset里明显不是本人或分辨率过低的照片删掉人工过一遍再重新生成encodings.pkl。阈值回调到0.4附近用第6章的测试集方法量化精度。这里要说明一个物理限制单摄像头考勤系统无法区分真人和照片用手机照片对着摄像头就能打卡这不是代码bug。答辩时主动讲清楚这点并提出用活体检测或双摄像头做改进方向反而是加分项。5.5 第一次启动卡了将近半分钟模型加载成本现象运行实时识别脚本后终端没有任何输出等了约20到30秒才开始出画面。原因face_recognition第一次调用face_locations或face_encodings时会加载dlib的预训练模型文件这个加载是一次性开销跟电脑配置关系不大。解决这是正常现象不用改代码。如果想让启动过程看起来更舒服可以在启动时打印“正在加载模型…”加载完成后再打印“模型加载完成”。另一个相关的坑是用PyInstaller打包exe时dlib的模型文件不会自动打包进去需要在spec文件里手动加数据文件否则换一台电脑启动直接报错找不到模型。这点在答辩现场演示前一定要验证我见过不止一个组在教室电脑上双击exe后黑屏最后只能回到命令行救场。6. 给考勤系统打包票用离线测试集校准阈值阈值是整个系统里最值得较真的参数。我的做法是做一个离线验证集新建test/positive目录放本人照片test/negative目录放其他人的照片然后写一个脚本扫描不同阈值下的精确率和召回率。import face_recognition import os import pickle import numpy as np with open(encodings.pkl, rb) as f: data pickle.load(f) def evaluate(threshold): tp fp fn 0 for img_name in os.listdir(test/positive): img face_recognition.load_image_file(ftest/positive/{img_name}) enc face_recognition.face_encodings(img) if not enc: continue dist face_recognition.face_distance(data[encodings], enc[0]).min() if dist threshold: tp 1 else: fn 1 for img_name in os.listdir(test/negative): img face_recognition.load_image_file(ftest/negative/{img_name}) enc face_recognition.face_encodings(img) if not enc: continue dist face_recognition.face_distance(data[encodings], enc[0]).min() if dist threshold: fp 1 precision tp / (tp fp 1e-6) recall tp / (tp fn 1e-6) return precision, recall for t in np.arange(0.30, 0.60, 0.01): p, r evaluate(t) print(fT{t:.2f} precision{p:.3f} recall{r:.3f})跑完这组数据你会直观看到阈值从0.55到0.35的变化精确率往上走召回率往下走两条曲线的交叉点附近就是当前数据下的最优配置。这个脚本的价值不只在调参答辩时还能作为系统验证材料比空口说“效果挺好”有说服力得多。测试照片一定不能用进过训练集的照片否则指标全部虚高。我后来做这类考勤系统都习惯性先建测试集再调参数先把测试照片单独放到一个目录里锁起来防止训练时误用。希望帮到你。本文还有配套的精品资源点击获取