人脸识别签到系统,支持图片签到,摄像头签到

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

登记这个看起来有点奇怪

人脸识别签到毕设项目:OpenCV + Tkinter 从零搭建完整考勤系统

这个项目做的是一个课堂人脸签到系统,用 OpenCV 的人脸检测加 LBPH 人脸识别技术,配合 Tkinter 图形界面,实现了从学员登记、人脸签到、记录查询到出勤统计的一整套考勤流程,全程用 Python 就能跑起来,不用搭服务器也不用深度学习框架。

完整源码链接:https://pan.quark.cn/s/1e54aa2ae950

一、这个项目到底在做什么

先把这个项目说清楚。所谓"人脸签到",就是人往摄像头前一站,系统自动认出你是谁,然后把签到时间记下来。和传统的刷卡、点名、填表格相比,它的好处很明显:不用带卡、不用喊名字、现场几分钟就能把一整个班的人签到完,数据还是自动统计的。

我给它定了四个核心功能,正好对应界面上四个页签:

  1. 学员登记:把学员的学号和姓名录入系统,同时采集他的一张人脸照片,作为后续识别的基础;
  2. 人脸签到:打开摄像头,对着人脸看几秒,系统识别出是谁,自动完成签到;
  3. 签到记录:所有签到流水按时间展示,随时可以导出成 Excel 能打开的 CSV 文件;
  4. 统计分析:自动算出每个人的出勤天数、出勤率,生成每日出勤人数图,方便期末统计。

做这个选题的理由也很实在:第一,演示效果直观,摄像头前面一站就识别出来了,答辩现场放个视频或者直接演示,说服力很强;第二,技术路线成熟稳定,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-pythoncv2.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 的 imreadimwrite、模型的 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 后端,能明显提升笔记本摄像头的打开速度,这是实测经验。

七、运行与操作步骤

拿到源码包后,按下面几步走就能跑起来:

  1. 装依赖:在项目目录运行 pip install -r requirements.txt
  2. 启动程序:运行 python main.py,弹出主界面;
  3. 登记学员:切到「学员登记」页签,填好学号和姓名,点「开启摄像头登记」,在弹出的窗口里对准人脸按 S 拍照,照片自动裁剪好,然后点「重新训练模型」;登记完的人会出现在右边的学员列表里,点选可以回填表单,点「删除选中学员」或「清空人脸库」可以管理;
  4. 人脸签到:切到「人脸签到」页签,点「开启摄像头」,人站到摄像头前,绿框框住脸后识别出姓名学号,几秒后提示"签到成功",右边"今日已签到"名单里出现记录,还没来的人会留在"今日未签到"名单里;如果不想开摄像头,也可以点「从图片签到」选一张合照,系统会把照片里的所有人一次识别签到,识别成功的画绿框、陌生人画红框,原图直接显示在画面上方便核对;
  5. 看记录:切到「签到记录」页签,所有签到流水按时间排列,带识别距离,可以导出 CSV;
  6. 出统计:切到「统计分析」页签,点「生成统计报告」,表格和图就都出来了。

窗口底部的状态栏全程可见:人脸库多少名学员多少张照片、模型训没训好、摄像头开没开、今日签到了几个人,一眼就知道系统当前处于什么状态。如果人脸库是空的,签到页会直接提示先去登记,不会让人干等。

如果现场没有摄像头,直接点「统计分析」页签的「生成演示数据」,然后用上面的 5、6 步看效果。

八、实验结果与数据分析

下面这组数据是演示数据生成器实际跑出来的结果(固定随机种子,可复现),我在界面上统计展示的就是这些数字。

8.1 学员花名册

登记了 8 名学员:

学号姓名
2026001张伟
2026002王芳
2026003李娜
2026004刘强
2026005陈静
2026006杨帆
2026007赵磊
2026008黄婷

8.2 签到流水(部分)

共 6 个考勤日、38 条签到记录,这是 6 月 1 号那天的原始数据,每条都带识别距离:

日期时间学号姓名距离
2026-06-0108:02:202026001张伟44.07
2026-06-0108:09:412026002王芳54.08
2026-06-0108:02:562026003李娜36.25
2026-06-0108:10:392026005陈静45.58
2026-06-0108:13:362026006杨帆45.34
2026-06-0108:12:492026007赵磊51.51
2026-06-0108:13:492026008黄婷46.42

可以看到这一天 7 条记录的距离都在 36~55 之间,全部小于阈值 80,判定为通过并记录签到。距离这个数本身就是"识别质量"的体现——距离越小,说明 LBPH 判断"越像本人"越有把握。

8.3 每日出勤人数

把 6 天的记录按日期汇总:

日期出勤人数
2026-06-017
2026-06-026
2026-06-036
2026-06-046
2026-06-057
2026-06-066

每天出勤人数在 6~7 人之间波动,8 名学员里每天有 1~2 人缺席,整体出勤率处于正常水平。这张表放进报告里,配合"每日出勤人数.png"柱状图,一目了然。

8.4 个人出勤统计

按学号汇总出勤情况,这是统计模块的核心输出:

学号姓名出勤天数应到天数缺勤天数出勤率
2026001张伟660100%
2026005陈静660100%
2026002王芳56183.33%
2026004刘强56183.33%
2026008黄婷56183.33%
2026003李娜46266.67%
2026007赵磊46266.67%
2026006杨帆36350%

几个可以直接拿来讲的结论:张伟、陈静两人全勤;杨帆出勤率最低(50%),6 天里缺了 3 天,这样的人在考勤管理里最该被关注;大部分学员出勤率集中在 83%~100%。表按出勤率降序排,谁出勤最好谁最差,一眼看清。

8.5 识别日志示例(含陌生人拦截)

识别模块每次识别都会输出"学号、姓名、距离、判定"这条日志,我模拟了一次签到会话的日志(8 人中每人一次 + 1 名陌生人):

日期学号姓名距离判定
2026-06-012026001张伟36.81通过
2026-06-022026002王芳52.82通过
2026-06-032026003李娜46.09通过
2026-06-042026004刘强52.34通过
2026-06-052026005陈静43.51通过
2026-06-062026006杨帆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 把识别服务发布出去,手机也能扫码签到,那就更接近真实产品了。

更多推荐