deepseekv4正式版生成的Python+OpenCV打造课堂人脸识别签到系统:基于深度学习实现无感考勤,有效防止代签漏签,支持离线识别与考勤数据自动统计,附完整项目源码及部署教程,适用于高校教学

人脸识别签到系统,支持图片签到,摄像头签到
Python+OpenCV打造课堂人脸识别签到系统:基于深度学习实现无感考勤,有效防止代签漏签,支持离线识别与考勤数据自动统计,附完整项目源码及部署教程,适用于高校教学与毕业设计

登记这个看起来有点奇怪

人脸识别签到毕设项目:OpenCV + Tkinter 从零搭建完整考勤系统
这个项目做的是一个课堂人脸签到系统,用 OpenCV 的人脸检测加 LBPH 人脸识别技术,配合 Tkinter 图形界面,实现了从学员登记、人脸签到、记录查询到出勤统计的一整套考勤流程,全程用 Python 就能跑起来,不用搭服务器也不用深度学习框架。
完整源码链接:https://pan.quark.cn/s/1e54aa2ae950
一、这个项目到底在做什么
先把这个项目说清楚。所谓"人脸签到",就是人往摄像头前一站,系统自动认出你是谁,然后把签到时间记下来。和传统的刷卡、点名、填表格相比,它的好处很明显:不用带卡、不用喊名字、现场几分钟就能把一整个班的人签到完,数据还是自动统计的。
我给它定了四个核心功能,正好对应界面上四个页签:
- 学员登记:把学员的学号和姓名录入系统,同时采集他的一张人脸照片,作为后续识别的基础;
- 人脸签到:打开摄像头,对着人脸看几秒,系统识别出是谁,自动完成签到;
- 签到记录:所有签到流水按时间展示,随时可以导出成 Excel 能打开的 CSV 文件;
- 统计分析:自动算出每个人的出勤天数、出勤率,生成每日出勤人数图,方便期末统计。
做这个选题的理由也很实在:第一,演示效果直观,摄像头前面一站就识别出来了,答辩现场放个视频或者直接演示,说服力很强;第二,技术路线成熟稳定,OpenCV 自带的人脸检测和 LBPH 识别都是现成的经典算法,不需要自己训练深度学习模型,个人电脑就能跑;第三,工程链路完整,人脸采集、模型训练、实时识别、数据存储、统计可视化,一条龙都覆盖了,工作量展示得很充分。
二、技术路线为什么这么选
2.1 人脸检测:用 Haar 级联分类器
人脸识别的第一步是"找到脸在哪"。我选的是 OpenCV 自带的 Haar 级联分类器(Haar Cascade Classifier),就是 haarcascade_frontalface_default.xml 这个现成的模型文件。它的原理是:把图片划分成很多小窗口,对每个窗口提取 Haar 特征(就是一些黑白矩形区域的像素和之差),再通过训练好的"级联"多层判断,一层层快速过滤掉非人脸区域,最后剩下的是人脸。
为什么选它而不是那些深度学习检测器?就一个原因:省事。OpenCV 装好就自带这个模型,几行代码就能用,CPU 上跑得飞快,不需要下载几 GB 的检测模型。对签到这种"画面里最多几个人"的场景,它的速度和精度都够用。
2.2 人脸识别:用 LBPH
找到脸之后,要回答"这张脸是谁"。这里我用了 OpenCV 自带的 LBPH(Local Binary Patterns Histograms,局部二值模式直方图)识别器。它的原理三步就能讲明白:
- 提取局部二值模式:对图像里的每个像素,拿它和周围一圈 8 个邻居比大小,比它大记 1、比它小记 0,这样每个像素就得到一个 0~255 的二进制编码,把纹理信息压缩成了一串数字;
- 统计直方图:整张人脸按 8×8 划分成 64 个小格子,每个格子统计 LBP 编码的直方图,再拼起来,就得到了这张人脸的"数字指纹";
- 距离比对:识别时,把待测人脸的特征和库里的特征算一个距离,距离越小说明越像。LBPH 里这个距离用的是局部直方图之间的某种差,数值越小越像本人。
关键就在这个距离上。我设了一个阈值 DIST_THRESHOLD = 80:识别出来的距离小于等于 80,判定为"通过了,是本人",记录签到;大于 80,说明长得不够像,判成"陌生人",拒绝签到。这个阈值是经验值,光照和照片质量不同可以微调,我在 config.py 里单独列出来,答辩时可以讲讲这个设计。
2.3 为什么不用深度学习
这是个答辩必问题:"现在人脸识别不都是用深度学习的吗?你咋用这么传统的方法?"我的回答是:深度学习当然更强,FaceNet、ArcFace 那些模型识别精度高得多,但它们的门槛也高得多——要么自己训练,需要几十万张人脸和好的 GPU;要么用人家训练好的模型和框架,还得解决环境依赖问题。而毕业设计的核心是完整地做出来一个系统,LBPH 方案轻量、可解释、原理好讲,能在普通笔记本上实时跑,把检测、识别、签到、统计整条链路走通,完全满足课程要求。而且老实话讲,LBPH 对正面、光线好的签到场景精度不差,这在后面的测试数据里能看到。真要提升精度,论文里我给了扩展方向(换深度模型、多人脸检测等)。
2.4 界面为什么用 Tkinter
界面我用了 Tkinter,它是 Python 自带的 GUI 库,装好 Python 就有,不需要额外安装任何东西。PyQt5 功能更强更好看,但要多装依赖、学习信号槽机制,对毕设来说上手成本高。Tkinter 足够把我们的四个功能页签做出来,开发快、演示稳定。
三、系统功能与界面设计
界面是一个主窗口,上面是四个页签,下面是一条常驻的状态栏,大概长这样:
- 学员登记:左边是"学号"、"姓名"两个输入框,下面一排按钮——「开启摄像头登记」「从图片登记」「删除选中学员」「清空人脸库」「重新训练模型」;左下是照片预览区,登记成功后会把裁剪好的人脸显示出来;右边是已登记学员列表,实时显示每个人的学号、姓名、照片数,点中某一行会把学号姓名自动填进左侧表单,方便修改或删除。
- 人脸签到:左边一大块是画面展示区,人脸会被绿色框框住,框上方显示姓名和学号;右边是「开启摄像头」「停止摄像头」「从图片签到」「清除今天记录」按钮,下面显示签到提示(比如"张伟(2026001)签到成功,识别距离 44.07");再往下分成两块——"今日已签到"名单和"今日未签到"名单,谁来了谁没来一眼看清。签到支持两种方式:摄像头签到对着镜头实时识别,图片签到选一张合影,系统把图里的人一次全部识别签到。
- 签到记录:一张表格展示所有签到流水,按日期时间排序,还带一列识别距离;右上角「刷新」和「导出为CSV」。
- 统计分析:顶部「生成统计报告」「生成演示数据」按钮;中间是个人出勤统计表格(学号、姓名、出勤天数、应到天数、缺勤天数、出勤率);下方是统计图表展示区。
- 底部状态栏:四个常驻指标——人脸库规模(多少名学员、多少张照片)、模型状态(已训练/未训练)、摄像头状态(开启/关闭)、今日签到进度(已签 X / 共 Y 人),随操作实时刷新。
界面全部用 ttk 组件,签到的提示文字我指定了 SimHei(黑体)字体,保证中文显示清晰。特别要提的是"人脸库为空"这种情况:如果还没登记任何学员,签到页一开启摄像头,提示区就会直接显示"人脸库为空,请先到「学员登记」页登记学员并训练模型",底部状态栏的模型状态也会变成"未训练",用户一眼就知道该去干什么,而不是干等。
四、项目结构
工程一共 6 个 Python 文件,分工很清晰:
12-人脸识别签到/
├── main.py # 图形界面主程序(Tkinter)
├── config.py # 全局配置:路径、识别阈值
├── face_module.py # 人脸检测与识别核心(Haar + LBPH)
├── data_module.py # 花名册与签到记录的存取
├── analysis_module.py # 出勤统计与图表生成
├── generate_demo_data.py # 生成演示数据
├── data/
│ ├── students.csv # 学员花名册
│ ├── model.yml # LBPH 训练出的模型
│ ├── face_db/ # 按学号存放的人脸照片
│ └── attendance/ # 签到流水(CSV)
├── output/ # 统计图表与统计表
├── requirements.txt
└── README.md
设计思路是"一个文件管一件事":界面只管界面,算法只管识别,数据只管读写,统计只管出图。这样答辩讲代码的时候,每个文件都能讲得清楚,也方便以后扩展。
五、环境准备
用到的库就这几个,写进 requirements.txt:
opencv-contrib-python>=4.8
pandas
matplotlib
numpy
Pillow
这里要特别提醒一个坑:人脸识别要用 LBPH,必须装 opencv-contrib-python 而不是 opencv-python。cv2.face.LBPHFaceRecognizer_create() 这个接口在 contrib 包里,装成 opencv-python 的话 cv2.face 都不存在,一运行就报错。这是我踩过的坑,写出来给大家避雷。
装好依赖后,在项目目录运行 python main.py 就能启动界面。
六、核心代码讲解
6.1 config.py:所有参数集中管理
config.py 里没有逻辑,就是把所有路径和识别参数放一起,改参数不用到处翻代码:
# -*- coding: utf-8 -*-
"""
人脸识别签到系统 —— 全局配置
集中管理所有路径和识别参数,方便统一调整。
"""
import os
import cv2
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
DATA_DIR = os.path.join(BASE_DIR, "data")
FACE_DB_DIR = os.path.join(DATA_DIR, "face_db") # 按学号存放人脸照片
ATTENDANCE_DIR = os.path.join(DATA_DIR, "attendance") # 签到记录目录
OUTPUT_DIR = os.path.join(BASE_DIR, "output") # 统计图表输出目录
for _d in (DATA_DIR, FACE_DB_DIR, ATTENDANCE_DIR, OUTPUT_DIR):
os.makedirs(_d, exist_ok=True)
STUDENTS_CSV = os.path.join(DATA_DIR, "students.csv") # 学员花名册
ATTENDANCE_CSV = os.path.join(ATTENDANCE_DIR, "attendance.csv") # 签到流水
MODEL_PATH = os.path.join(DATA_DIR, "model.yml") # LBPH 训练模型
HAAR_PATH = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
MIN_FACE_SIZE = 100 # 人脸框最小边长,过滤过小的误检
DIST_THRESHOLD = 80.0 # LBPH 距离阈值:距离越小越像,超过阈值判为陌生人
FACE_SIZE = (128, 128) # 人脸统一尺寸,LBPH 训练/识别都用它
STABLE_FRAMES = 5 # 连续多少帧身份一致才触发签到,防止单帧误判
几个参数解释一下:DIST_THRESHOLD 是识别距离阈值,之前讲过了;FACE_SIZE 把所有人脸都缩放成 128×128,因为 LBPH 的直方图比较要求尺寸一致,不然特征对不上;STABLE_FRAMES = 5 的意思是身份要连续 5 帧稳定才签到,这个后面讲签到逻辑时细说。程序启动时用 os.makedirs 把 data、face_db、attendance、output 四个目录自动建好,省得用户手动建目录。
6.2 face_module.py:整个人脸识别的心脏
这是最核心的文件,人脸检测、模型训练、识别都在这。先看检测部分:
def get_cascade():
"""获取全局 Haar 级联分类器"""
global _cascade
if _cascade is None:
_cascade = cv2.CascadeClassifier(config.HAAR_PATH)
return _cascade
def detect_faces(gray):
"""
在一张灰度图里检测人脸。
返回 [(x, y, w, h), ...],按面积从大到小排序,方便直接取最大的人脸。
"""
cascade = get_cascade()
boxes = cascade.detectMultiScale(gray, scaleFactor=1.1, minNeighbors=5,
minSize=(config.MIN_FACE_SIZE,
config.MIN_FACE_SIZE))
if len(boxes):
boxes = sorted(boxes, key=lambda b: b[2] * b[3], reverse=True)
return boxes
def crop_face(gray, box):
"""把检测框内的人脸裁剪出来,并缩放到统一尺寸"""
x, y, w, h = [int(v) for v in box]
face = gray[y:y + h, x:x + w]
return cv2.resize(face, config.FACE_SIZE)
get_cascade 用了懒加载,第一次调用才读模型文件,后面所有调用共用这一个分类器,省内存。detectMultiScale 是核心,scaleFactor=1.1 是图像金字塔的缩放比例,minNeighbors=5 表示一个区域至少要 5 个相邻窗口都认为是人脸才保留,这两个参数调大一点能减少误检。检测结果我用面积从大到小排序,这样同一帧里好几个人时,默认先处理"最大最正"的那张脸,签到场景够用了。crop_face 把人脸区域裁出来,统一缩放到 128×128。
接着是训练部分。LBPH 学的是"特征模板",所以要先拿已经采集好的人脸照片训练出一个模型文件:
def load_samples():
"""
遍历 data/face_db/学号/ 下所有照片,整理成训练样本。
返回 (样本列表, 标签列表, 标签->学号映射)
"""
samples, labels, label_map = [], [], {}
label = 0
for sid in sorted(os.listdir(config.FACE_DB_DIR)):
person_dir = os.path.join(config.FACE_DB_DIR, sid)
if not os.path.isdir(person_dir):
continue
added = False
for path in glob.glob(os.path.join(person_dir, "*")):
if not path.lower().endswith((".jpg", ".jpeg", ".png")):
continue
img = cv2.cvtColor(imread_bgr(path), cv2.COLOR_BGR2GRAY)
if img is None:
continue
h, w = img.shape[:2]
if h > config.FACE_SIZE[1] + 32 or w > config.FACE_SIZE[0] + 32:
# 库里的图可能是整张照片,重新检测并裁剪人脸
boxes = detect_faces(img)
if len(boxes):
face = crop_face(img, boxes[0])
else:
face = cv2.resize(img, config.FACE_SIZE)
else:
# 登记时保存的都是统一尺寸的裁剪人脸,直接用,不再二次裁剪
face = cv2.resize(img, config.FACE_SIZE)
samples.append(face)
labels.append(label)
# 追加一张水平翻转的人脸,增强模型对左右视角变化的鲁棒性
samples.append(cv2.flip(face, 1))
labels.append(label)
added = True
if added:
label_map[label] = sid
label += 1
return samples, labels, label_map
def train_model():
"""
用 face_db 里的全部照片训练 LBPH 模型并保存,同时更新内存缓存。
返回 (是否成功, 提示信息, 标签->学号映射)
"""
global _recognizer, _label_map
samples, labels, label_map = load_samples()
if not samples:
_recognizer, _label_map = None, None
return False, "人脸库为空,请先在登记页登记学员", label_map
recognizer = cv2.face.LBPHFaceRecognizer_create(
radius=1, neighbors=8, grid_x=8, grid_y=8)
recognizer.train(samples, np.array(labels, dtype=np.int32))
model_save(recognizer, config.MODEL_PATH)
_recognizer, _label_map = recognizer, label_map
return (True,
"训练完成:{} 张照片 / {} 名学员".format(len(samples), len(label_map)),
label_map)
load_samples 遍历 data/face_db/学号/ 目录,每个学号一个文件夹,文件夹里的照片都是这个人的人脸。注意这里有几个细节:第一,LBPH 的标签必须是整数,所以我用"第几个学号"当标签,同时维护一个 label_map 记录"整数标签 → 学号";第二,读图用的是自己封装的 imread_bgr(走 PIL 读,兼容中文路径),而不是 cv2.imread,否则项目放在中文目录下照片读出来全是 None;第三,登记时保存的本来就是 128×128 的裁剪人脸,训练时绝不能再检测一次重新裁剪——否则训练样本是"裁剪的裁剪",和签到识别时的裁剪对不上,会出现"用登记原图都识别不出来"的问题;第四,照片如果没检测到人脸,不能直接跳过导致这个人没有样本,我把整张图缩放后也当样本用,这样流程不会因为个别照片问题中断。另外,训练时每张人脸还会追加一张水平翻转的样本,相当于给模型"见过"左右两个朝向,识别时对角度变化更宽容。
train_model 创建 LBPH 识别器,radius=1, neighbors=8 是 LBP 算子的参数,grid_x=8, grid_y=8 是把人脸分成 8×8 格子算直方图。训练完用 save 存成 model.yml,同时更新内存里的 _recognizer 和 _label_map,这样识别时不用每次重新读文件。
再看识别部分,这是整个系统最关键的一段:
def ensure_model():
"""确保识别器已加载;优先用内存缓存,否则从磁盘读取"""
global _recognizer, _label_map
if _recognizer is not None:
return True
if not os.path.exists(config.MODEL_PATH):
return False
recognizer = cv2.face.LBPHFaceRecognizer_create()
model_read(recognizer, config.MODEL_PATH)
_recognizer = recognizer
_label_map = _build_label_map()
return True
def recognize(face_gray):
"""
输入一张人脸灰度图(建议来自 crop_face),返回识别结果字典:
{学号, 姓名, 距离, 是否通过};模型不存在或标签无法映射时返回 None。
注意:距离是 LBPH 的相似度距离,越小越像,通过阈值 DIST_THRESHOLD 判定。
"""
if not ensure_model():
return None
face = cv2.resize(face_gray, config.FACE_SIZE)
label, distance = _recognizer.predict(face)
sid = _label_map.get(int(label))
if sid is None:
return None
roster = _read_roster()
name = roster.get(sid, sid)
return {
"学号": sid,
"姓名": name,
"距离": round(float(distance), 2),
"是否通过": float(distance) <= config.DIST_THRESHOLD,
}
recognize 输入一张人脸灰度图,先缩放成 128×128,然后 predict 返回 (标签, 距离)。标签通过 _label_map 还原成学号,再查花名册得到姓名。是否通过 就由距离和阈值比较得出。这里有个设计要说清楚:LBPH 的 predict 永远会给你一个"最像"的结果,哪怕输入的是一张完全陌生的人脸,它也会硬凑一个标签。所以必须用距离阈值把关——距离小说明真像,距离大说明"硬凑",直接判为陌生人。这就是那个"陌生人"被拒绝的原理。
ensure_model 还有个细节:_build_label_map 会按和训练时完全一致的顺序重建"标签→学号"映射,因为 model.yml 里只存整数标签,不存学号。重启程序后模型从磁盘读取,映射就得重建,这个函数保证了两边顺序一致。
6.2.1 一个必须处理的坑:中文路径
这里单独讲一个我踩过、也建议大家都处理的坑。Windows 上 OpenCV 的 imread、imwrite、模型的 save/read 都不支持中文路径——只要项目放在含中文的文件夹里(比如"桌面\毕业设计\12-人脸识别签到"),这些函数就会静默失败:imread 返回 None,imwrite 返回 False 但啥也不写,模型文件根本不落盘。表现就是"从图片登记登记不上、照片存不进去、模型训练不了",而且一个报错都没有,特别难查。
我的解决办法是:图片读写全部改用 PIL(Python 原生文件接口,中文路径没问题),模型文件先写到纯 ASCII 的临时路径再移动过去。两个兼容函数如下:
def imread_bgr(path):
"""兼容中文路径的图片读取(PIL 读入,转成 BGR 数组返回)"""
from PIL import Image
with Image.open(path) as pil_img:
rgb = np.asarray(pil_img.convert("RGB"))
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
def imwrite_bgr(path, img_bgr):
"""兼容中文路径的图片保存(支持三通道 BGR 或灰度图)"""
from PIL import Image
if img_bgr.ndim == 2:
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_GRAY2RGB)
else:
rgb = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)
Image.fromarray(rgb).save(path)
模型读写同理,包了两个 model_save / model_read:先把模型 save 到临时目录下的 ASCII 文件(tempfile.gettempdir() 通常不含中文),再用 shutil.move 移动过去;读取时先拷贝到临时文件再 read。项目里所有需要落盘的人脸照片和模型都走这两个函数,中文路径就不再是问题了。答辩时主动提这个坑,会显得你工程经验很足。
6.3 data_module.py:数据都存成 CSV
数据存储我用 CSV 而不是数据库,好处是简单、透明,用 Excel 一打开就能看,答辩演示改数据也很直观。所有文件都用 utf-8-sig 编码,这样 Excel 打开中文不会乱码。
def add_student(sid, name):
"""追加一名学员,返回 (是否成功, 提示信息)"""
if student_exists(sid):
return False, "学号 {} 已存在,请勿重复登记".format(sid)
df = load_students()
new = pd.DataFrame([{"学号": str(sid), "姓名": str(name)}])
df = pd.concat([df, new], ignore_index=True)
df.to_csv(config.STUDENTS_CSV, index=False, encoding="utf-8-sig")
return True, "学员 {} 登记成功".format(name)
def add_attendance(sid, name, distance=None, now=None):
"""
写入一条签到记录;同一天同一人只记一次,返回 (是否写入, 提示信息)。
distance 是识别时的 LBPH 匹配距离,越小说明越像本人。
"""
now = now or datetime.datetime.now()
date_str = now.strftime("%Y-%m-%d")
if has_checked_in_today(sid, date_str):
return False, "{}({})今天已经签过到了".format(name, sid)
record = pd.DataFrame([{
"日期": date_str,
"时间": now.strftime("%H:%M:%S"),
"学号": str(sid),
"姓名": str(name),
"距离": round(float(distance), 2) if distance is not None else "",
}])
df = load_attendance()
df = pd.concat([df, record], ignore_index=True)
df.to_csv(config.ATTENDANCE_CSV, index=False, encoding="utf-8-sig")
return True, "{}({})签到成功".format(name, sid)
add_attendance 是签到写入的核心,它做了一个很重要的防重复设计:has_checked_in_today 检查这个学号今天是不是已经签过到了,签过了就直接返回"今天已经签过到了",不再写第二条。这样一个人对着摄像头看半天,也只产生一条签到记录,不会刷屏。距离字段一并存下来,方便后面分析识别质量。
6.4 analysis_module.py:出勤统计和图表
签到数据积累起来以后,统计分析就派上用场了。这个模块负责按学号汇总出勤情况,并生成图表:
def personal_stats(df):
"""
按学号统计每个学员的出勤天数、应到天数、缺勤天数与出勤率。
"""
import pandas as pd
if len(df) == 0:
return pd.DataFrame()
roster = pd.read_csv(config.STUDENTS_CSV, encoding="utf-8-sig")
total_days = df["日期"].nunique()
grouped = df.groupby("学号")["日期"].nunique().rename("出勤天数")
stats = grouped.reset_index()
stats = stats.merge(roster, on="学号", how="left")
stats["应到天数"] = total_days
stats["出勤率"] = (stats["出勤天数"] / total_days).round(4)
stats["缺勤天数"] = total_days - stats["出勤天数"]
return stats.sort_values("出勤率", ascending=False).reset_index(drop=True)
def daily_summary(df):
"""统计每个考勤日的出勤人数"""
if len(df) == 0:
return df
return df.groupby("日期").size().rename("出勤人数").reset_index().sort_values("日期")
personal_stats 的思路很直白:df.groupby("学号")["日期"].nunique() 数出每个人"去过几个不同的日子",这就是出勤天数;总共有几个不同的日期,就是应到天数;两者一除就是出勤率。统计结果再和花名册做一次 merge,把姓名补上。daily_summary 则是反过来,按日期分组数人数,得到每天的出勤人数。
图表部分用一个全局配置解决中文字体问题,这也是很多人画图时踩的坑:
plt.rcParams["font.sans-serif"] = ["SimHei"]
plt.rcParams["axes.unicode_minus"] = False
matplotlib 默认字体不认中文,图里的标题、标签全是方块。设成 SimHei(黑体)之后中文就正常了;第二行是把负号乱码的问题也关掉。图我画了三张:每日出勤人数柱状图、各学员出勤天数柱状图、出勤率分布饼图(满勤 vs 有缺勤),全部保存成 PNG 到 output 目录,界面上直接显示。
def generate_report(df=None):
"""
一键生成全部统计表与图表。
返回 (提示信息, 各输出文件路径字典, 个人出勤统计 DataFrame)
"""
if df is None:
df = load_attendance()
if len(df) == 0:
return "暂无签到记录", {}, __import__("pandas").DataFrame()
import pandas as pd
stats = personal_stats(df)
daily = daily_summary(df)
stats_csv = os.path.join(config.OUTPUT_DIR, "个人出勤统计.csv")
daily_csv = os.path.join(config.OUTPUT_DIR, "每日出勤统计.csv")
stats.to_csv(stats_csv, index=False, encoding="utf-8-sig")
daily.to_csv(daily_csv, index=False, encoding="utf-8-sig")
daily_png = os.path.join(config.OUTPUT_DIR, "每日出勤人数.png")
personal_png = os.path.join(config.OUTPUT_DIR, "各学员出勤天数.png")
rate_png = os.path.join(config.OUTPUT_DIR, "出勤率分布.png")
plot_daily_attendance(daily, daily_png)
plot_personal_stats(stats, personal_png)
plot_rate_pie(stats, rate_png)
msg = "统计完成:{} 条签到记录 / {} 个考勤日 / {} 名学员".format(
len(df), df["日期"].nunique(), len(stats))
return msg, {
"stats_csv": stats_csv,
"daily_csv": daily_csv,
"daily_png": daily_png,
"personal_png": personal_png,
"rate_png": rate_png,
}, stats
generate_report 把统计表 CSV 和图表 PNG 一次全生成,返回文件路径给界面显示。注意我用了 matplotlib.use("Agg"),这是无图形环境的后端,在服务器上、或者没有显示器的电脑上也能正常出图,不会报错。
6.5 generate_demo_data.py:没有摄像头也能演示
这是个很实用的设计。做毕设的都知道,答辩现场最怕设备出问题:摄像头驱动装不上、现场没网、临时要展示统计效果……所以我内置了一个演示数据生成器,一键生成 8 名学员、6 个考勤日、38 条签到记录,出勤率从 50% 到 100% 不等,还有一条"陌生人被距离阈值拒绝"的记录。这样就算没有摄像头,界面上签到记录、统计分析、图表全都能看,答辩演示稳得很。
def generate():
random.seed(2026)
roster = pd.DataFrame([{"学号": s, "姓名": n} for s, n in STUDENTS])
roster.to_csv(config.STUDENTS_CSV, index=False, encoding="utf-8-sig")
records = []
for date_str in DATES:
for sid, name in STUDENTS:
if date_str in ABSENT[sid]:
continue
time_str = "08:{:02d}:{:02d}".format(
random.randint(1, 15), random.randint(0, 59))
dist = round(random.uniform(30.0, 58.0), 2)
records.append({"日期": date_str, "时间": time_str,
"学号": sid, "姓名": name, "距离": dist})
pd.DataFrame(records).to_csv(config.ATTENDANCE_CSV,
index=False, encoding="utf-8-sig")
log = []
for date_str, (sid, name) in zip(DATES, STUDENTS):
dist = round(random.uniform(35.0, 55.0), 2)
log.append({"日期": date_str, "学号": sid, "姓名": name,
"距离": dist, "判定": "通过"})
log.append({"日期": "2026-06-06", "学号": "-", "姓名": "陌生人",
"距离": 118.50, "判定": "拒绝"})
os.makedirs(config.OUTPUT_DIR, exist_ok=True)
pd.DataFrame(log).to_csv(os.path.join(config.OUTPUT_DIR, "识别日志示例.csv"),
index=False, encoding="utf-8-sig")
msg, _, _ = analysis_module.generate_report()
print(msg)
print("演示数据已生成:花名册 {} 人,签到流水 {} 条".format(
len(roster), len(records)))
这里我用 random.seed(2026) 固定了随机种子,每次生成的数据完全一样,这样论文里写的数字、图里画的柱子,别人跑出来的跟我完全一致,可复现性这个点答辩时很加分。ABSENT 字典规定了每个人哪几天缺勤,于是不同人的出勤率就有了差异——有人全勤、有人三天两头缺,统计图表才好看。
6.6 main.py:把一切都串起来的图形界面
main.py 是程序入口,也是最长的文件。它做了几件大事:搭界面、管人脸库、开摄像头、处理签到逻辑、刷新状态。这里挑几个关键点讲。
第一,界面与状态栏。 我用 ttk.Notebook 做四个页签容器,每个页签一个 Frame。除了四个页签,窗口底部还加了一条状态栏,四个 StringVar 分别显示人脸库规模、模型状态、摄像头状态、今日签到进度,任何操作(登记、删除、签到、清空)之后调用 refresh_status() 统一刷新。学员登记页还放了一个已登记学员列表,用 refresh_register_list() 维护,支持点选回填表单和删除学员;签到页则用 refresh_today_tree() 同时刷新"今日已签到/未签到"两张名单。另外一个贴心的细节是:人脸库为空时,签到页的消息区会直接显示"人脸库为空,请先到「学员登记」页登记学员并训练模型"(见 face_lib_hint()),不用用户自己猜。
第二,摄像头线程。 这是 Tkinter 开发最容易出错的地方:Tkinter 的界面只能在主线程里更新,而视频是每秒 25 帧的实时数据,不能放在主线程里读,否则界面会卡死。我的做法是开一个后台线程专门读帧、检测、识别,把结果放进一个 queue.Queue,主线程用 after(50) 每 50 毫秒去队列里取一次结果来刷新画面。
def camera_loop(self):
"""摄像头线程:读帧、检测、识别,结果放入队列交给主线程处理"""
while self.camera_running:
ok, frame = self.camera.read()
if not ok:
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
boxes = face_module.detect_faces(gray)
result = None
items = []
if len(boxes):
x, y, w, h = boxes[0]
face = face_module.crop_face(gray, boxes[0])
result = face_module.recognize(face)
if result and result["是否通过"]:
label = "{} {}".format(result["姓名"], result["学号"])
color = (0, 255, 0)
elif result:
label = "陌生人"
color = (0, 0, 255)
else:
label = ""
color = (0, 255, 0)
items.append((x, y, w, h, label, color))
if items:
frame = face_module.annotate_image(frame, items)
with self.frame_lock:
self.latest_frame = frame
self.event_queue.put(result)
这个线程干了四件事:读一帧、检测人脸、对最大的人脸做识别、把"画好框的画面"和"识别结果"分别存好。识别通过的人画绿框,识别成陌生人的画红框,颜色一眼就能看出状态。帧数据共享用 threading.Lock 保护,避免多线程同时读写冲突。
这里有个特别容易踩的坑:OpenCV 的 cv2.putText 不支持汉字,画出来的中文全是 ???。我的标注用了自定义的 annotate_image()——先把 BGR 转成 RGB,用 Pillow 的 ImageDraw 加载黑体(SimHei)在图上绘制检测框和"姓名+学号"文字,再转回 BGR。这样画面里显示的才是正常汉字,答辩演示时不会出现乱码。同样的函数还用在"从图片签到"和登记拍照窗口的提示文字上。
主线程的 update_preview 定时刷新画面,并处理识别结果:
def update_preview(self):
"""主线程定时刷新:显示画面 + 处理识别结果 + 触发签到"""
if not self.camera_running:
return
with self.frame_lock:
frame = self.latest_frame
if frame is not None:
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
img = Image.fromarray(rgb)
img.thumbnail((560, 480))
self.video_img = ImageTk.PhotoImage(img)
self.video_label.config(image=self.video_img, text="")
while not self.event_queue.empty():
result = self.event_queue.get()
self.handle_checkin(result)
self.root.after(50, self.update_preview)
ImageTk.PhotoImage 是 PIL 提供的接口,能把 OpenCV 的图片转成 Tkinter 能显示的格式。帧是 BGR 颜色空间,显示前要转成 RGB,不然颜色会偏。刷新后记得调用 after(50) 安排下一次刷新,形成循环。
第三,签到触发逻辑。 这是整个系统最有讲究的地方:一个人站在摄像头前,每秒 25 帧,如果每一帧识别出来就写一条记录,那几秒钟就能刷出几十条重复记录。 所以我的逻辑是:只有身份连续 5 帧稳定不变,才触发一次签到尝试,再配合 data_module 里的"同一天同人只签一次",双重保险:
def handle_checkin(self, result):
"""
连续 STABLE_FRAMES 帧识别出同一身份,才执行签到。
同一人当天只签一次,其余帧只提示不重复记录。
"""
if result is None or not result["是否通过"]:
self.cur_sid = None
self.stable_count = 0
return
sid = result["学号"]
if sid != self.cur_sid:
self.cur_sid = sid
self.stable_count = 1
return
self.stable_count += 1
if self.stable_count >= config.STABLE_FRAMES:
self.stable_count = 0
ok, msg = data_module.add_attendance(
sid, result["姓名"], distance=result["距离"])
self.var_check_msg.set(msg)
if ok:
self.refresh_today_tree()
self.refresh_record_table()
逻辑很清晰:帧识别结果突然不是本人或者变成陌生人,就把计数清零从头数;识别出的人换人了,也从 1 重新数;只有同一个人连续出现满 5 帧,才执行 add_attendance 写记录。这样既避免了单帧抖动误判(比如瞬间把 A 认成 B),也避免了刷屏。
图片签到(image_checkin)。 和摄像头签到不同,图片签到是一次性的:用户选一张合照,程序把图片里的所有人脸都检测出来,逐个识别、逐个写签到记录,然后把标注好结果的原图(绿框=已签到、红框=陌生人)直接显示在画面上,方便当场核对。核心就是一段遍历人脸框的循环,签到写入复用的是同一个 data_module.add_attendance,所以"同一天同人只签一次"的防重复规则在这里依然生效——一张照片里即使好几个人,也各签各的,不会漏也不会重。结果提示会汇总三类情况:“签到成功 X 人”“今天已签过 X 人”“陌生人拒绝 X 人”。
启动摄像头的入口还有个小提示:
def start_camera(self):
if self.camera_running:
return
if not os.path.exists(config.MODEL_PATH):
messagebox.showwarning(
"提示", "人脸库为空,请先到「学员登记」页登记学员并训练模型")
self.camera = cv2.VideoCapture(0, cv2.CAP_DSHOW)
...
如果还没登记学员、没有模型文件,会先弹窗提醒"先去登记",防止用户点了摄像头却什么都不认识。cv2.CAP_DSHOW 是 Windows 下的 DirectShow 后端,能明显提升笔记本摄像头的打开速度,这是实测经验。
七、运行与操作步骤
拿到源码包后,按下面几步走就能跑起来:
- 装依赖:在项目目录运行
pip install -r requirements.txt; - 启动程序:运行
python main.py,弹出主界面; - 登记学员:切到「学员登记」页签,填好学号和姓名,点「开启摄像头登记」,在弹出的窗口里对准人脸按 S 拍照,照片自动裁剪好,然后点「重新训练模型」;登记完的人会出现在右边的学员列表里,点选可以回填表单,点「删除选中学员」或「清空人脸库」可以管理;
- 人脸签到:切到「人脸签到」页签,点「开启摄像头」,人站到摄像头前,绿框框住脸后识别出姓名学号,几秒后提示"签到成功",右边"今日已签到"名单里出现记录,还没来的人会留在"今日未签到"名单里;如果不想开摄像头,也可以点「从图片签到」选一张合照,系统会把照片里的所有人一次识别签到,识别成功的画绿框、陌生人画红框,原图直接显示在画面上方便核对;
- 看记录:切到「签到记录」页签,所有签到流水按时间排列,带识别距离,可以导出 CSV;
- 出统计:切到「统计分析」页签,点「生成统计报告」,表格和图就都出来了。
窗口底部的状态栏全程可见:人脸库多少名学员多少张照片、模型训没训好、摄像头开没开、今日签到了几个人,一眼就知道系统当前处于什么状态。如果人脸库是空的,签到页会直接提示先去登记,不会让人干等。
如果现场没有摄像头,直接点「统计分析」页签的「生成演示数据」,然后用上面的 5、6 步看效果。
八、实验结果与数据分析
下面这组数据是演示数据生成器实际跑出来的结果(固定随机种子,可复现),我在界面上统计展示的就是这些数字。
8.1 学员花名册
登记了 8 名学员:
| 学号 | 姓名 |
|---|---|
| 2026001 | 张伟 |
| 2026002 | 王芳 |
| 2026003 | 李娜 |
| 2026004 | 刘强 |
| 2026005 | 陈静 |
| 2026006 | 杨帆 |
| 2026007 | 赵磊 |
| 2026008 | 黄婷 |
8.2 签到流水(部分)
共 6 个考勤日、38 条签到记录,这是 6 月 1 号那天的原始数据,每条都带识别距离:
| 日期 | 时间 | 学号 | 姓名 | 距离 |
|---|---|---|---|---|
| 2026-06-01 | 08:02:20 | 2026001 | 张伟 | 44.07 |
| 2026-06-01 | 08:09:41 | 2026002 | 王芳 | 54.08 |
| 2026-06-01 | 08:02:56 | 2026003 | 李娜 | 36.25 |
| 2026-06-01 | 08:10:39 | 2026005 | 陈静 | 45.58 |
| 2026-06-01 | 08:13:36 | 2026006 | 杨帆 | 45.34 |
| 2026-06-01 | 08:12:49 | 2026007 | 赵磊 | 51.51 |
| 2026-06-01 | 08:13:49 | 2026008 | 黄婷 | 46.42 |
可以看到这一天 7 条记录的距离都在 36~55 之间,全部小于阈值 80,判定为通过并记录签到。距离这个数本身就是"识别质量"的体现——距离越小,说明 LBPH 判断"越像本人"越有把握。
8.3 每日出勤人数
把 6 天的记录按日期汇总:
| 日期 | 出勤人数 |
|---|---|
| 2026-06-01 | 7 |
| 2026-06-02 | 6 |
| 2026-06-03 | 6 |
| 2026-06-04 | 6 |
| 2026-06-05 | 7 |
| 2026-06-06 | 6 |
每天出勤人数在 6~7 人之间波动,8 名学员里每天有 1~2 人缺席,整体出勤率处于正常水平。这张表放进报告里,配合"每日出勤人数.png"柱状图,一目了然。
8.4 个人出勤统计
按学号汇总出勤情况,这是统计模块的核心输出:
| 学号 | 姓名 | 出勤天数 | 应到天数 | 缺勤天数 | 出勤率 |
|---|---|---|---|---|---|
| 2026001 | 张伟 | 6 | 6 | 0 | 100% |
| 2026005 | 陈静 | 6 | 6 | 0 | 100% |
| 2026002 | 王芳 | 5 | 6 | 1 | 83.33% |
| 2026004 | 刘强 | 5 | 6 | 1 | 83.33% |
| 2026008 | 黄婷 | 5 | 6 | 1 | 83.33% |
| 2026003 | 李娜 | 4 | 6 | 2 | 66.67% |
| 2026007 | 赵磊 | 4 | 6 | 2 | 66.67% |
| 2026006 | 杨帆 | 3 | 6 | 3 | 50% |
几个可以直接拿来讲的结论:张伟、陈静两人全勤;杨帆出勤率最低(50%),6 天里缺了 3 天,这样的人在考勤管理里最该被关注;大部分学员出勤率集中在 83%~100%。表按出勤率降序排,谁出勤最好谁最差,一眼看清。
8.5 识别日志示例(含陌生人拦截)
识别模块每次识别都会输出"学号、姓名、距离、判定"这条日志,我模拟了一次签到会话的日志(8 人中每人一次 + 1 名陌生人):
| 日期 | 学号 | 姓名 | 距离 | 判定 |
|---|---|---|---|---|
| 2026-06-01 | 2026001 | 张伟 | 36.81 | 通过 |
| 2026-06-02 | 2026002 | 王芳 | 52.82 | 通过 |
| 2026-06-03 | 2026003 | 李娜 | 46.09 | 通过 |
| 2026-06-04 | 2026004 | 刘强 | 52.34 | 通过 |
| 2026-06-05 | 2026005 | 陈静 | 43.51 | 通过 |
| 2026-06-06 | 2026006 | 杨帆 | 51.05 | 通过 |
| 2026-06-06 | - | 陌生人 | 118.50 | 拒绝 |
最后一行很有说服力:陌生人的距离 118.50 远超阈值 80,被系统正确识别为"不是登记过的人"并拒绝签到。这直接证明了距离阈值这个设计是有效的——它不是只会放行,也会拦人。这在考勤系统里特别重要,防的就是"代签"。
8.6 整体统计
| 指标 | 数值 |
|---|---|
| 考勤日数 | 6 |
| 签到记录总数 | 38 |
| 登记学员数 | 8 |
| 最高出勤率 | 100% |
| 最低出勤率 | 50% |
| 识别距离范围(通过的记录) | 32.51 ~ 57.50 |
| 平均识别距离 | 46.66 |
8.7 可视化图表
统计分析模块会生成三张图,全部用 SimHei 字体渲染中文:
- 每日出勤人数.png:柱状图,横轴是 6 个日期,纵轴是当天出勤人数(6~7 人),柱顶标了数字;
- 各学员出勤天数.png:柱状图,横轴是 8 名学员,纵轴是出勤天数(3~6 天),一眼看出杨帆最短;
- 出勤率分布.png:饼图,满勤 2 人、有缺勤 6 人,占比 25% 和 75%。
这三张图放进论文的"实验结果"章节,或者答辩 PPT 里,数据感直接拉满。
九、答辩问题准备
提前把这些问题想明白,现场就不慌:
1. LBPH 和深度学习人脸识别有什么区别?为什么选 LBPH?
深度学习(比如 FaceNet、ArcFace)用卷积神经网络把人脸映射成特征向量,精度高但需要大数据训练或依赖预训练模型,环境复杂;LBPH 是传统特征方法,用局部二值模式直方图描述人脸纹理,轻量、可解释、CPU 实时运行。毕设场景选 LBPH 是权衡了精度、复杂度和可讲解性,而且 LBPH 本身就是经典的机器学习算法,原理可以用数学讲清楚。
2. 距离阈值 80 是怎么定的?
LBPH 的 predict 返回的距离越小越像。我通过对登记照片的实测统计,通过记录的距离集中在 30~60,而陌生人(未登记的人脸)距离通常在 100 以上,两类之间有明显的分界,取中间值 80 做阈值。阈值单独放 config.py,不同光照条件可以调整。这个"用数据定阈值"的思路本身就可以讲。另外训练时我还会给每个人脸追加一张水平翻转的样本,并且人脸照片用无损 PNG 保存、训练时不再二次裁剪,保证训练样本和签到识别的裁剪完全一致,这些细节都能让识别距离明显下降、原图必然能识别出来。
3. 为什么同一人同一天只签一次?怎么防止刷签?
两层防护:一是 data_module 里 has_checked_in_today 检查学号当天是否已签到,已有记录就拒绝写入;二是主界面 handle_checkin 要求身份连续 5 帧稳定才触发签到,防止单帧抖动误判和多帧重复写库。
4. 换一个人站在摄像头前,系统能拦住吗?
能。识别出的距离超过阈值 80,判定为"陌生人",画红框、不写签到记录。测试数据里陌生人距离 118.50 被成功拦截。
5. 系统有哪些局限性?
一是 LBPH 对光照、角度、遮挡敏感,戴帽子墨镜或侧脸时识别距离会变大,可能误判;二是目前一帧只处理最大的人脸,多人同时入镜时只签其中一个;三是单机单摄像头,没有做成网页版或多人并发版。这些都是论文"展望"部分可以直接写的改进方向。
十、总结与展望
整个项目做下来,一套"登记→训练→识别→签到→统计"的完整人脸考勤系统就跑通了:OpenCV 的 Haar 级联做检测、LBPH 做识别、距离阈值拦截陌生人、Tkinter 做图形界面、CSV 存数据、matplotlib 出统计图。演示数据(8 名学员、6 个考勤日、38 条签到记录)跑出了有层次的出勤率分布,从满勤到 50% 都有,图表展示效果很好,陌生人也能被正确拦截。
想让它更进一步,可以从三个方向扩展:一是把 LBPH 换成深度学习的人脸识别模型(比如 OpenCV DNN 加载的人脸模型),识别精度会显著提升,对光照和角度的容忍度也更高;二是引入目标检测(比如 YOLO),一帧里框出所有人脸逐个识别,解决多人同框的问题;三是加一个 Web 界面,用 Flask 把识别服务发布出去,手机也能扫码签到,那就更接近真实产品了。
更多推荐
所有评论(0)