基于PyQt5的人脸识别考勤系统开发:模型选型、识别优化与打包实战 简介这是一份基于PyQt开发的人脸识别考勤系统Python源码源自作者98分的期末大作业适合计算机相关专业正在完成课程设计或期末项目的学生也适合想练习PyQt与人脸识别实战的开发者。系统围绕人脸识别考勤流程实现了学生/组织管理、人脸录入、摄像头识别、考勤记录与Excel导出等模块可作为完整课设方案直接参考。压缩包共33个文件约39KB其中包含21个Python源码文件、7个Qt界面ui文件另有依赖清单、数据库初始化脚本、构建脚本及说明文档结构清晰下载后配合requirements.txt即可快速还原运行环境。目前已有741人学习下载项目代码完整且经过严格调试可运行性有保障目录按界面层、业务逻辑、工具模块组织便于二次开发与功能扩展也能从中学习PyQt界面布局、摄像头调用、人脸识别API封装及Excel报表生成等关键实现思路。1. 拿到「PyQt 人脸识别考勤」这套源码先想清楚三个问题任何一个做过摄像头类桌面应用的人看到这个标题的第一反应大概都是又一套「OpenCV 画框 SQLite 存时间」的课设。但真正把它从能跑变成能交、能演示、能扛住老师提问靠的是三个在标题上看不见的决策PyQt 的界面刷新和 OpenCV 的摄像头帧率怎么对齐人脸识别到底用传统特征还是深度特征决定了它在教室后排能不能识别成功打卡记录如何去重直接决定考勤表是不是可信。网上能搜到大量「python 实战项目源码」但多数止步于画一个绿框没解决「这个人是谁、何时算打过卡、怎么防止连刷」这三件事。这篇文章按做考勤系统最常见的落地路径来讲检测、对齐、特征比对、SQLite 记录、打包发版每一步都给出能直接抄的参数和代码。2. 人脸识别考勤引擎选型PyQt 版本、检测模型与 128 维特征比对2.1 PyQt5 还是 PyQt6以课程设计与快速交付为标准课程设计环境里PyQt5 的占有率仍然明显高于 PyQt6。原因很直接网上能抄到的 PyQt 控件的用法、信号槽写法、打包踩坑记录八成以上是 PyQt5 的而 PyQt6 里exec_()换成了exec()枚举从Qt.AlignCenter变成Qt.AlignmentFlag.AlignCenter照着旧博客抄代码很容易卡在莫名其妙的位置报错。以下差异是切换版本时最容易踩的对比项PyQt5.15PyQt6对话框执行方法dlg.exec_()dlg.exec()对齐枚举写法Qt.AlignCenterQt.AlignmentFlag.AlignCenterQAction信号triggeredtriggered不变高 DPI 适配Qt.AA_EnableHighDpiScaling需要手动设置默认开启打包体积PyInstaller 打包后相对小引入 Qt6 动态库包更大我的建议是如果是从零写或用已有源码二次开发直接用 PyQt5 5.15 版本它同时支持 Python 3.7 到 3.10和 OpenCV、NumPy 的兼容性最好。PyQt6 适合你已经熟悉 Qt6 的枚举体系否则没必要在这个环节给自己增加排错成本。pip install pyqt55.15.10 opencv-python numpy2.2 人脸检测Haar 与 YuNet 的取舍检测这一步解决「脸在哪里」。OpenCV 自带的 Haar 级联分类器haarcascade_frontalface_default.xml优点是零额外依赖一个 xml 文件就能跑缺点是漏检率在侧脸、暗光、戴眼镜场景下明显偏高。考勤场景里人走到摄像头前通常不会刻意正脸对准Haar 的实用性不太够。更合适的方案是 OpenCV 4.5.4 之后内置的 YuNet 人脸检测模型。YuNet 是一个轻量神经网络以 ONNX 格式发布用cv2.FaceDetectorYN直接加载不需要安装 TensorFlow 或 PyTorch。它输出人脸框、置信度以及五个关键点右眼、左眼、鼻尖、右嘴角、左嘴角。关键点这一点非常重要因为后面特征提取之前需要做人脸对齐而 Haar 不提供关键点位置。import cv2 detector cv2.FaceDetectorYN.create( model_pathface_detection_yunet_2023mar.onnx, config, input_size(320, 240), score_threshold0.8, nms_threshold0.3, top_k5000 )input_size是送给模型的检测分辨率越大对小脸越友好但推理耗时上升。做考勤终端建议 320x240识别距离 0.5 到 2 米足够。score_threshold默认 0.9考勤场景我一般降到 0.8宁可多框出候选再交给特征比对过滤也不要在这里把脸漏掉。nms_threshold负责合并重叠框0.3 是标准值。2.3 特征提取ArcFace 系 ONNX 模型与余弦相似度检测到人脸后下一步是把人脸转换成一个固定长度的特征向量再用向量距离判断「是不是同一个人」。传统方法里的 LBPH、EigenFace 对姿态和光线变化非常敏感只适合实验室环境。现在做考勤系统最稳妥的路径是用 ArcFace 训练出的 ResNet 系列模型导出成 ONNX 后用 OpenCV 的 DNN 模块推理完全绕开深度学习框架的环境配置。ArcFace 的典型输出是 512 维特征向量。推理代码非常短import cv2 import numpy as np net cv2.dnn.readNetFromONNX(w600k_r50.onnx) def get_feature(aligned_112): blob cv2.dnn.blobFromImage( aligned_112, 1.0 / 127.5, (112, 112), (127.5, 127.5, 127.5), False ) net.setInput(blob) feat net.forward().flatten().astype(np.float32) return feat / np.linalg.norm(feat)blobFromImage的参数对应 ArcFace 训练时的预处理像素值除以 127.5 再减 1相当于归一化到[-1, 1]mean填 127.5 而不是 0.5这是最容易抄错的地方。拿到特征后做 L2 归一化后续相似度用点积就能算也就是余弦相似度。两个不同人的特征余弦相似度通常在 0.2 到 0.4 之间同一个人的不同照片在 0.6 到 0.9 之间阈值取 0.5 左右比较稳妥具体见第四章。提示选用 ONNX 人脸识别模型时注意看下载页的使用条款。课程设计在演示环境运行没有问题但如果要部署到生产考勤机需要确认模型的授权范围。3. 用 PyQt 打通上班打卡主流程摄像头拉流、脸库注册与 SQLite 落库3.1 考勤系统的最小模块划分一个能交差的 PyQt 人脸识别考勤系统至少需要四个模块它们之间的边界如果不清楚后期维护会非常痛苦模块职责关键技术点脸库管理录入工号、姓名、照片生成特征检测 对齐 ArcFace 特征入库实时识别摄像头画面中找脸、比对、判断身份YuNet 检测 特征相似度计算打卡记录写入打卡时间防止同人重复打卡SQLite 时间窗口去重界面展示实时画面、打卡提示、今日记录列表PyQt 信号槽刷新界面模块之间通过数据解耦脸库模块只负责输出「工号 → 512 维向量」的字典识别模块只输出「工号 姓名 相似度」这一条结果打卡模块只接收工号和当前时间。这样任何一层出了问题都可以单独替换模块来定位也是课程设计答辩时老师最看重的「软件工程素养」。3.2 摄像头帧与 PyQt 界面用 QTimer 驱动识别循环摄像头拉流的实现有两种路线。第一种是QTimer定时器每隔 33 毫秒从cv2.VideoCapture读一帧在主线程里做检测、比对、刷新画面。第二种是单独起QThread做视频采集和解码通过信号把帧传给主线程显示。课程设计这个体量我建议用 QTimer 方案逻辑简单不会因为跨线程更新控件报错。import cv2 import numpy as np from PyQt5.QtCore import QTimer, Qt from PyQt5.QtGui import QImage, QPixmap class CameraWidget: def __init__(self, label): self.label label self.cap cv2.VideoCapture(0) # 0 是默认摄像头 self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, 640) self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) self.timer QTimer() self.timer.timeout.connect(self.update_frame) self.timer.start(33) # 约 30 FPS实际取决于摄像头 def update_frame(self): ok, frame self.cap.read() if not ok: return # 识别逻辑放在这里检测人脸、比对特征、返回姓名 name, similar self.recognize(frame) # 把 BGR 转为 RGB 后显示到 QLabel rgb cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) h, w, ch rgb.shape qimg QImage(rgb.data, w, h, ch * w, QImage.Format_RGB888) self.label.setPixmap(QPixmap.fromImage(qimg).scaled( self.label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))QTimer.start(33)是刷新周期单位毫秒。主线程里如果识别耗时长界面会掉帧所以 640x480 分辨率下建议把检测输入尺寸控制在 320x240。CAP_PROP_FRAME_WIDTH设置的是摄像头采集分辨率和 YuNet 的输入分辨率无关摄像头高分辨率不等于检测更准。3.3 人脸对齐从五个关键点算出仿射变换YuNet 输出的五个关键点中左右眼坐标用于把人脸旋转到水平。ArcFace 模型训练时的人脸是两眼水平、人脸居中占画面约 70%所以对齐这步直接决定特征质量。对不齐的脸即使同一个人的相似度也可能低于阈值。import cv2 import numpy as np def align_face(img, landmarks, size112): right_eye landmarks[0] # 右眼 left_eye landmarks[1] # 左眼 dx left_eye[0] - right_eye[0] dy left_eye[1] - right_eye[1] angle np.degrees(np.arctan2(dy, dx)) eyes_center ((right_eye[0] left_eye[0]) / 2.0, (right_eye[1] left_eye[1]) / 2.0) dist np.sqrt(dx * dx dy * dy) scale size * 0.5 / dist # 让两眼间距占输出图的一半 rot_mat cv2.getRotationMatrix2D(eyes_center, angle, scale) aligned cv2.warpAffine(img, rot_mat, (size, size), flagscv2.INTER_LINEAR) return alignedscale的计算是关键。ArcFace 训练时两眼距离约占 112 像素的一半所以用size * 0.5 / dist把任意距离的眼睛缩放到固定距离。warpAffine只做旋转和缩放不裁剪人脸因此要求检测框已经把脸裁好。实际操作中我在调用align_face前会把检测框外扩 10% 再截取避免头发、下巴边缘被切掉。3.4 打卡记录落库SQLite 的表结构与索引识别出身份后把一条考勤记录写入 SQLite。表结构要能回答两个问题这个人今天打过几次卡、最后一次是什么时候。设计成两张表员工表和打卡记录表CREATE TABLE IF NOT EXISTS staff ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, dept TEXT DEFAULT , feature BLOB ); CREATE TABLE IF NOT EXISTS attendance ( id INTEGER PRIMARY KEY AUTOINCREMENT, staff_id INTEGER NOT NULL, check_time TEXT NOT NULL DEFAULT (datetime(now, localtime)), status TEXT DEFAULT normal, FOREIGN KEY (staff_id) REFERENCES staff(id) ); CREATE INDEX IF NOT EXISTS idx_attendance_time ON attendance(check_time);check_time直接用 SQLite 的datetime(now, localtime)生成避免 Python 和系统时区不一致。把feature以 BLOB 形式直接存在员工表里省去单独的特征文件管理启动时一次性读入内存。索引加在check_time上因为最常执行的查询是「查某人在某个时间区间内的打卡记录」import sqlite3 conn sqlite3.connect(attendance.db) def has_checked(staff_id, start, end): sql SELECT 1 FROM attendance WHERE staff_id? AND check_time BETWEEN ? AND ? LIMIT 1 return conn.execute(sql, (staff_id, start, end)).fetchone() is not NoneLIMIT 1很重要它让这条查询在命中后立刻返回不需要扫全表。在 QTimer 每帧都调用has_checked的场景下这种细节能明显降低数据库压力。4. 把「识别准、不重复打卡」做扎实阈值、间隔、多帧确认与线程模型4.1 相似度阈值的标定方法不要拍脑袋定 0.5阈值定得太高真人打卡被拒定得太低刷张照片就能通过。正确的做法是对自己采集的数据做一次小实验把每个员工的照片分成注册组和测试组算所有同人对比和异人对比的相似度分布然后取一个让两类错误率都低的中间值。import numpy as np same_sims [] # 同人对比结果 diff_sims [] # 异人对比结果 def find_best_threshold(same_sims, diff_sims, step0.01): best_t, best_err 0.5, 1.0 t 0.30 while t 0.70: # 同人低于阈值 拒识异人高于阈值 误识 err np.mean(same_sims t) np.mean(diff_sims t) if err best_err: best_err, best_t err, t t step return best_t, best_err实际数据里同人相似度一般在 0.55 到 0.85异人一般在 0.1 到 0.4中间空档 0.4 到 0.55 是安全区。如果测试结果两边叠得很厉害说明注册照片质量有问题而不是阈值问题先回去重新拍照片。4.2 防重复打卡内存去重 数据库时间窗双重校验考勤系统的核心诉求不是识别得准而是不能出现一个人站摄像头前一秒被记三次打卡。最稳妥的方案是两层去重内存里保存每个员工最近一次打卡时间小于 60 秒的直接忽略同时查数据库里当天最后一次打卡如果已经在上下班时间窗内打过卡提示「已打卡」而不是再次写入。last_check {} # staff_id - timestamp def try_check_in(staff_id, now_ts, lock_minutes1): last last_check.get(staff_id, 0) if now_ts - last lock_minutes * 60: return too_frequent start f{now_ts:%Y-%m-%d} 08:30:00 end f{now_ts:%Y-%m-%d} 20:00:00 if has_checked(staff_id, start, end): last_check[staff_id] now_ts return duplicated insert_checkin(staff_id, now_ts) last_check[staff_id] now_ts return oklock_minutes设 1 分钟防止同一个人反复出现在画面里导致连刷。数据库时间窗按考勤规则写死这里给出的是 08:30 到 20:00 的示例实际项目里应该把上下班时间放到配置文件里方便改。4.3 多帧确认连续 N 帧识别到同一人才算数单帧识别受眨眼、转头、光线突变影响很大。一个容易实现的策略连续 5 帧识别到同一个人且相似度都超过阈值才触发打卡中途任何一帧没识别到或相似度不足计数器清零。class VoteCounter: def __init__(self, need_frames5): self.need_frames need_frames self.candidate None self.count 0 def vote(self, name): if name self.candidate: self.count 1 else: self.candidate name self.count 1 if self.count self.need_frames: self.count 0 return self.candidate return None5 帧按 30 FPS 算大约是 0.17 秒人感觉不到延迟但能过滤掉大部分偶发抖动。注意计数器清零的时机打卡成功一次后也应该清零否则下一轮识别会立刻命中残留计数。4.4 线程模型识别放 QThread界面不卡顿QTimer 方案在单人识别时没问题但识别逻辑里有 YuNet 检测和 ArcFace 推理单帧耗时可能到 30 毫秒以上。如果这时候还要处理界面刷新、数据库写入主线程很容易卡到 10 FPS 以下。更稳妥的结构是把摄像头读取、检测、特征比对全部放 QThread只把结果通过信号发回主线程显示。from PyQt5.QtCore import QThread, pyqtSignal import numpy as np class RecognizerThread(QThread): frame_ready pyqtSignal(np.ndarray) # 原始帧用于画面显示 result_ready pyqtSignal(str, float) # 姓名、相似度 def __init__(self): super().__init__() self.running True def run(self): cap cv2.VideoCapture(0) while self.running: ok, frame cap.read() if not ok: continue name, sim self.recognize(frame) self.frame_ready.emit(frame) self.result_ready.emit(name, sim) cap.release()pyqtSignal(np.ndarray)在跨线程传递时属于排队连接NumPy 数组在进入队列前被完整拷贝意味着 frame 传一次会多占一份内存。640x480 的帧大约 900 KB30 FPS 下带宽压力不小但现代电脑完全可以接受。如果追求性能可以把帧缓存改成一个带锁的共享对象不过课程设计里信号拷贝是性价比最高的方案。5. 用一段 30 秒视频回归 PyQt 人脸考勤并在打包后跑通5.1 用视频文件代替摄像头做自动化验证现场调摄像头效率很低而且每次测试都会往数据库写脏数据。常见做法是准备一真一假两段视频真视频是你自己站在摄像头前录的 30 秒用于验证识别率假视频可以是手机里翻拍的别人照片验证「刷照片」会不会被拒绝。把cv2.VideoCapture(0)换成cv2.VideoCapture(test_real.avi)代码其余部分完全不用动。验证时盯两个指标打卡触发是否在预期时间点、有没有出现隔几秒就重复打卡。如果 30 秒视频里你始终站在画面中但只应该产生一次打卡记录第二次出现时界面应该提示「已打卡」。这类回归脚本建议保留在项目里答辩时现场重跑一遍比口头解释有说服力得多。5.2 PyInstaller 打包与模型文件路径打包 PyQt 程序最常见的坑不是缺库而是模型文件onnx的相对路径在运行目录变化后失效。PyInstaller 打包后资源文件被释放到sys._MEIPASS临时目录不能直接用项目里的相对路径读取。import sys, os def resource_path(relative): base getattr(sys, _MEIPASS, os.path.abspath(.)) return os.path.join(base, relative) detector cv2.FaceDetectorYN.create( resource_path(face_detection_yunet_2023mar.onnx), , (320, 240), 0.8, 0.3, 5000 )打包命令加--add-data把模型文件一并带进产物pyinstaller -w -F main.py --add-data face_detection_yunet_2023mar.onnx;. --add-data w600k_r50.onnx;.Windows 下--add-data的分隔符是分号Linux 和 macOS 是冒号。打包出来的单文件 exe 首次启动会稍慢因为需要解压临时目录这是 PyInstaller 的固有行为不用优化。5.3 高频报错速查表报错现象原因处理方式运行即崩溃提示libGL.so.1找不到OpenCV 在服务器环境缺少 GUI 依赖库pip install opencv-python-headlessFaceDetectorYN.create返回空OpenCV 版本低于 4.5.4 或模型文件路径错误升级 opencv检查resource_path路径画面卡在首帧不动摄像头被其他程序占用cap.read()阻塞换用视频文件测试确认代码逻辑再排查设备冲突打包后识别全部失败模型没有被打进包里只打包了代码检查--add-data参数和resource_path()QTimer里写数据库越来越卡SQLite 频繁打开关闭连接连接建一次全局复用不要每帧sqlite3.connect最后一个建议把员工注册也做成 PyQt 界面里的一个按钮输入工号和姓名后对准摄像头拍一张自动完成对齐、特征提取、入库。这样演示的时候可以现场注册一个陌生人再现场打卡整个流程不到一分钟比提前准备好的假数据更能证明系统是活的。本文还有配套的精品资源点击获取