基于OpenCV与MySQL的人脸识别考勤系统:从检测、识别到防重落库
2026/9/13 19:45:18 网站建设 项目流程

简介:一套基于Python、OpenCV与MySQL构建的完整人脸识别打卡考勤系统,来源于个人大四毕业设计并经导师认可的高分项目,适合正在准备毕业设计、课程设计或期末大作业的计算机相关专业学生,也适合想通过完整项目进行实战练习的初级开发者。资源共18个文件,以13个Python源码文件为主,覆盖人脸采集、特征提取、识别比对、界面交互、员工管理、上下班打卡与考勤查询等前后端功能模块;另含2个预训练模型数据文件、1个建库SQL脚本和1个说明文档,压缩包整体大小约88.99MB,结构清晰、便于按模块理解与二次开发。由于代码完整且可直接运行,一定程度上解决了初学者在环境配置、数据库对接和算法调用上的入门难点,可作为从零搭建考勤系统的参考模板,也可在此基础上扩展课表管理、异常提醒等功能。目前已有182人学习下载,适合需要一套可演示、可答辩的工程化项目来支撑毕设落地的人群。

1. 人脸识别打卡考勤系统,从摄像头到数据库要过几道关

把人脸识别打卡考勤系统拆开看,最容易翻车的往往不是识别模型本身,而是整条链路的可靠性:摄像头能稳定抓到人脸、识别程序能输出确定身份、MySQL 能扛住早晚两次集中写入、同一张脸不会因为多停留两秒被记成两条打卡。很多拉下来的项目跑不起来,问题集中在这三处:opencv-contrib-python 缺失导致 cv2.face 不可用、LBPH 阈值没按现场环境标定、考勤表缺少唯一键防重。

这套系统的本质是「检测 → 识别 → 写库 → 防重 → 审计」的闭环,核心工作量在工程整合而不是算法。常见做法是 OpenCV 负责检测识别,MySQL 负责记录去重,中间用带事务和唯一约束的写入逻辑接起来。下面按这条链路展开:先讲检测选型和 LBPH 训练的参数,再讲 MySQL 建模与防重约束,给出能直接跑的主流程代码,最后补活体检测和阈值标定。新手能照着复现,熟手直接翻 2.3 参数表和 4.2 调参清单。

2. Python + OpenCV 的人脸检测与识别:选型、样本采集与 LBPH 训练

人脸识别的完整链路分两步:先用检测器找到画面里的人脸框,再把人脸图像交给识别器判断身份。这两步在 OpenCV 里的选型完全不同,很多项目识别率低,问题就出在第一步——检测器漏检或框位不准,导致第二步拿到的根本不是一张正脸。

2.1 人脸检测走 DNN 还是 Haar:漏检率差在侧脸和光照

Haar 级联是 OpenCV 里最老的方案,cv2.CascadeClassifier 加载一个 xml 文件就能用,读取快、内存小,适合树莓派这类低性能设备。但它对侧脸、低头、逆光非常敏感,在办公室顶光环境下漏检率明显偏高。而 OpenCV 自带的 DNN 人脸检测器(基于 ResNet-10 SSD 的 Caffe 模型)对姿态和光照的容忍度高得多,缺点是额外加载一个约 10MB 的模型文件。我一般直接用 DNN 检测器,除非目标设备确实跑不动。

import cv2 net = cv2.dnn.readNetFromCaffe( "deploy.prototxt", # 网络结构描述文件 "res10_300x300_ssd_iter_140000.caffemodel" # 预训练权重 ) def detect_faces(frame, conf_threshold=0.7): h, w = frame.shape[:2] # 统一缩放到 300x300,减去训练时的 BGR 均值再做检测 blob = cv2.dnn.blobFromImage( frame, 1.0, (300, 300), (104.0, 177.0, 123.0)) net.setInput(blob) detections = net.forward() faces = [] for i in range(detections.shape[2]): conf = detections[0, 0, i, 2] if conf < conf_threshold: continue # 输出是 [0,1] 归一化坐标,乘回原始宽高 box = detections[0, 0, i, 3:7] * [w, h, w, h] x1, y1, x2, y2 = box.astype(int) faces.append((x1, y1, x2, y2)) return faces

blobFromImage 的三个关键参数:缩放因子 1.0 表示不缩放像素值,300x300 是模型固定输入尺寸,均值 (104, 177, 123) 是 Caffe 模型训练时统计的 BGR 均值,必须按这个顺序填,填成 RGB 会整体偏色。conf_threshold 默认 0.7 在普通办公室环境够用,如果前台逆光严重可以降到 0.5,代价是误检会多一些,比如把海报上的人像也框出来。

检测器只负责输出人脸框,不负责判断是谁。接下来要把框内图像统一转灰度并缩放到固定尺寸再送进识别器。LBPH 对输入不要求正方形,但固定尺寸能让训练和预测时提取到的直方图维度一致,这步不能省。

2.2 样本采集脚本:每人拍多少张、怎么拍才能训出能用的模型

LBPH 是传统机器学习方法,对样本质量的要求比深度学习高。采集时至少一人 30 到 50 张,分布在正常坐姿、微微侧头、戴眼镜和不戴眼镜、上午和下午不同光线这几个维度。样本太少时,模型会把光线变化当成身份差异,光照一变就拒识别。采集脚本的关键是把检测到的人脸框直接裁出来存盘,避免手工剪裁。

import cv2, os person_id = "E1001" # 工号,目录名即标签 save_dir = f"faces/{person_id}" os.makedirs(save_dir, exist_ok=True) cap = cv2.VideoCapture(0) # 0 是默认摄像头,外接摄像头试 1、2 count = 0 while count < 100: # 目标 100 张,中间手动变换角度和光线 ok, frame = cap.read() if not ok: continue for (x1, y1, x2, y2) in detect_faces(frame): face_img = frame[y1:y2, x1:x2] gray = cv2.cvtColor(face_img, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (200, 200)) # 统一尺寸,200x200 精度速度均衡 cv2.imwrite(f"{save_dir}/{count:03d}.jpg", gray) count += 1 cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("capture", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()

count 在 for 循环内部递增,意味着同一帧里检测到多张脸时会连续存多张,所以别把多人同时塞进画面。实际操作时建议存到 120 张再人工删一遍,把闭眼、低头、画面模糊的删掉,质量比数量重要。目录结构 faces/E1001/xxx.jpg 是训练脚本读取标签的约定,工号和目录名一一对应,之后新增员工就是新建目录重新采集一轮。

2.3 训练 LBPH 识别器:radius、grid 参数与置信度阈值的关系

LBPH 的原理是对每个像素和它周围邻域做二进制编码,再把整张图分块统计直方图拼成特征向量。它有两个优点:对光照变化不敏感,因为编码比较的是邻域间的相对亮度;新增员工时把新样本加进数据集重跑一遍 train 即可,百人规模下训练只要几秒到几十秒。

import json from pathlib import Path import numpy as np import cv2 images, labels, label_map = [], [], {} # iter 按磁盘顺序遍历目录,label 用递增整数,工号单独存映射 for label_id, person_dir in enumerate(Path("faces").iterdir()): label_map[label_id] = person_dir.name for img_path in person_dir.glob("*.jpg"): images.append(cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE)) labels.append(label_id) recognizer = cv2.face.LBPHFaceRecognizer_create( radius=1, # 邻域半径,1 是默认值,纹理细 neighbors=8, # 邻域采样点数,一般固定 8 grid_x=8, # 横向分块数,越大对位置误差越敏感 grid_y=8 # 纵向分块数 ) recognizer.train(images, np.array(labels)) recognizer.save("lbph_model.yml") with open("label_map.json", "w", encoding="utf-8") as f: json.dump(label_map, f, ensure_ascii=False, indent=2)

提示:cv2.face 模块打包在 opencv-contrib-python 中,和 opencv-python 二选一安装,同时装会出现包覆盖导致的 ImportError。

LBPH 各参数的作用:

参数默认值调整方向对结果的影响
radius1调到 2~3覆盖更大纹理范围,但邻域跨度过大会丢失细节
neighbors8一般不推荐改采样点少于 8 对噪声敏感
grid_x / grid_y8×8降到 4×4分块粗,容忍人脸对齐误差,但不同人的区分度下降
threshold极大值按验证集标定create 时设的阈值只影响是否返回 label=-1,真正常用的是 predict 返回的距离

训练完必须自己写预测验证。recognizer.predict(face_img) 返回 (label, distance),distance 越小说明越像。常见经验区间:小于 80 可靠,80 到 110 处于是否拒绝的边缘,大于 110 基本是陌生人或另一个人。但这个区间严重依赖样本尺寸和采集环境,回到项目里必须用真实摄像头画面实测,取「本人最大距离」和「他人最小距离」的中间值作为阈值。

3. MySQL 考勤建模:表结构设计、唯一键防重与事务写库

识别出来之后,核心就变成把身份信息变成一条不可重复、可审计的考勤记录。这里最常见的错误是只靠应用层「先查再插」去重,查询和插入之间一旦有并发,同一张脸连续两帧触发两次打卡就可能插入两条记录。正确做法是把防重约束下沉到数据库层。

3.1 三张核心表:员工、人脸模型版本、考勤记录

表结构设计上我一般拆成三张:employee 存员工静态信息,face_model 记录每个员工当前使用的人脸模型文件路径和版本,attendance 存打卡流水。把 face_model 单独拆出来而不是在 employee 里加字段,是为了支持换模版重训:员工换发型或摘眼镜后重采集重训,只更新 face_model,考勤流水不受影响。

CREATE TABLE employee ( emp_id CHAR(8) PRIMARY KEY COMMENT '工号,如 E1001', emp_name VARCHAR(32) NOT NULL, dept VARCHAR(64) NULL, photo_path VARCHAR(255) NULL COMMENT '人脸样本目录,事后核对用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE face_model ( emp_id CHAR(8) PRIMARY KEY, model_path VARCHAR(255) NOT NULL COMMENT '该员工最新模版路径', feature_version INT NOT NULL DEFAULT 1 COMMENT '重训一次加 1', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_face_model_emp FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE attendance ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, emp_id CHAR(8) NOT NULL, clock_date DATE NOT NULL COMMENT '打卡日期,独立列而非函数计算', clock_time DATETIME NOT NULL COMMENT '精确到秒的打卡时间', clock_type TINYINT NOT NULL DEFAULT 1 COMMENT '1 上班 2 下班', confidence DOUBLE NOT NULL COMMENT 'LBPH 距离,越小越可靠,审计用', device_id VARCHAR(32) NOT NULL DEFAULT 'cam-01', PRIMARY KEY (id), UNIQUE KEY uk_emp_date_type (emp_id, clock_date, clock_type), KEY idx_clock_time (clock_time), CONSTRAINT fk_att_emp FOREIGN KEY (emp_id) REFERENCES employee(emp_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个关键设计决策值得解释。clock_date 单独成列而不是用 DATE(clock_time) 在查询时推导,是因为对索引列套函数会让 MySQL 放弃走 idx_clock_time,数据到百万行后按天查会明显变慢。唯一键 uk_emp_date_type 直接限制「同一员工同一天同一种类型只能有一条记录」,这是防重的最强保证。confidence 落库是给审计用的,员工申诉「今天不是我打的卡」时,回查距离值就能知道当时识别得勉强不勉强。MySQL 8.0 默认的 caching_sha2_password 认证插件需要连接库版本匹配,用 mysql-connector-python 8.x 或 pymysql 1.x 都能直接兼容。

3.2 用 INSERT IGNORE 做防重写入,用 rowcount 判断结果

有了唯一键,写入逻辑就简单了:INSERT IGNORE 遇到唯一键冲突时不报错,而是静默跳过,通过 cursor.rowcount 判断实际插入行数。0 表示今天已经打过卡,1 表示本次写入成功。这比「先 SELECT 再 INSERT」少一次往返,也彻底避开并发窗口。

from mysql.connector import pooling pool = pooling.MySQLConnectionPool( pool_name="attendance_pool", pool_size=5, # 按摄像头路数估算,1 路摄像头 2~3 足够 host="127.0.0.1", user="attendance", password="change-me", database="attendance_sys", charset="utf8mb4" ) def insert_clock(emp_id, clock_type, confidence): conn = pool.get_connection() cur = conn.cursor() try: cur.execute(""" INSERT IGNORE INTO attendance (emp_id, clock_date, clock_time, clock_type, confidence) VALUES (%s, CURDATE(), NOW(), %s, %s) """, (emp_id, clock_type, confidence)) conn.commit() return cur.rowcount == 1 # True 表示新增,False 表示当天已存在 except Exception: conn.rollback() raise finally: cur.close() conn.close() # 归还连接池,不是真正断开

识别服务是长驻进程,如果每次打卡都新建 MySQL 连接,早晚高峰期几百次打卡就会把连接数打满,所以用连接池而不是单连接。这里有个容易踩的坑:INSERT IGNORE 会把重复键和一些数据转换错误降级为警告,但外键约束失败这类错误仍然会抛异常,except 分支的 rollback 不能省。rowcount 要在 commit 前读,部分驱动在 commit 之后会把它重置。

3.3 三种去重方案的对比与选型

方案实现优点缺点适用场景
应用层先查再插SELECT + INSERT简单直观并发下有重复写入窗口单机演示、原型
唯一键 + INSERT IGNORE数据库约束从根源防重,无并发问题需要改表,灵活性略低正式环境首选
Redis SETNX + 异步落库内存原子操作挡极高并发重复请求多一个组件,两存储可能不一致多摄像头、多识别进程

多摄像头场景下,两台设备可能同时认出同一个人,MySQL 唯一键依然保证只成功一条,另一条 rowcount=0 直接丢弃。所以生产环境我一般是唯一键兜底、Redis 在前面挡请求,组合口径在 5.2 展开。

4. 实时识别打卡主流程:OpenCV 摄像头循环与 MySQL 写库联动

第 2 章的模型和第 3 章的数据库在真实系统里要靠一个常驻循环串起来。这一章给出完整主流程代码,并解释三个直接影响误识别率和体验的参数。

4.1 主循环代码:检测、识别、防重、写库一次闭环

主循环的逻辑顺序是固定:读帧 → 检测人脸 → 裁剪预处理 → recognize.predict → 用距离阈值判断是否接受 → 通过才写库。一个容易忽略的细节是距离判断必须放在写库之前,而且不能把 predict 返回的 label 直接当结果用,因为 LBPH 对任何输入都会返回一个最近的 label,包括陌生人脸。

import cv2, json, time from datetime import datetime from db import pool # 第 3 章的连接池 recognizer = cv2.face.LBPHFaceRecognizer_create() recognizer.read("lbph_model.yml") with open("label_map.json", "r", encoding="utf-8") as f: label_map = {int(k): v for k, v in json.load(f).items()} CONF_ACCEPT = 85.0 # 距离小于该值才认定为本人 last_clock = {} # (emp_id, clock_type) -> 上次成功打卡的 epoch 秒 def insert_clock(emp_id, clock_type, confidence): conn = pool.get_connection() cur = conn.cursor() try: cur.execute(""" INSERT IGNORE INTO attendance (emp_id, clock_date, clock_time, clock_type, confidence) VALUES (%s, CURDATE(), NOW(), %s, %s) """, (emp_id, clock_type, confidence)) conn.commit() return cur.rowcount == 1 except Exception: conn.rollback() raise finally: cur.close() conn.close() cap = cv2.VideoCapture(0) while True: ok, frame = cap.read() if not ok: continue for (x1, y1, x2, y2) in detect_faces(frame): # 即 2.1 节定义的函数 gray = cv2.cvtColor(frame[y1:y2, x1:x2], cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (200, 200)) label, conf = recognizer.predict(gray) if conf > CONF_ACCEPT: # 距离大于阈值,视为陌生人 status = "UNKNOWN" else: emp_id = label_map[label] now = time.time() key = (emp_id, 1) # 1 表示上班打卡 if now - last_clock.get(key, 0) < 30: # 冷却 30 秒 status = "COOLING" else: inserted = insert_clock(emp_id, 1, conf) status = f"{emp_id} OK" if inserted else f"{emp_id} DUP" if inserted: last_clock[key] = now cv2.putText(frame, status, (x1, y1 - 10), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) cv2.imshow("attendance", frame) if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows()

COOLING 分支很多人会漏掉。摄像头连续出帧,一个人站在镜头前两秒能产生几十次识别成功,没有冷却时间就会在同一秒内触发几十次写库尝试,唯一键只能挡住同类型重复,但会拖慢主循环。last_clock 的 key 是 (emp_id, clock_type),这样上班和下班互不干扰,同一人上午打过卡,下午仍能再打一次下班卡。

4.2 三个必调参数:识别距离阈值、检测置信度、打卡冷却

三个参数直接影响系统行为,调参顺序固定:先把检测置信度调到不漏检,再标定识别距离阈值,最后设冷却时间。

参数代码位置推荐初始值调大 / 调小的效果经验范围
识别距离阈值CONF_ACCEPT85调大更容易误认他人;调小更容易拒绝本人本人最大距离的 1.5 倍左右
检测置信度detect_faces 的 conf_threshold0.7调小漏检变少但误检增加0.5 ~ 0.75
打卡冷却last_clock 判断30 秒调大影响连续进出体验;调小重复写库变多10 ~ 60 秒

识别距离阈值最难拍脑袋定。正确做法是上线第一天把每次 predict 的距离打到日志,统计「本人最大距离」和「他人最小距离」,阈值取两者中间。如果两者没有空隙,说明模型训练有问题,回去补样本,而不是硬调阈值把错认压下去。

4.3 排错清单:三个高频故障的处理方法

ModuleNotFoundError: No module named 'cv2.face'。这是装错包导致的。opencv-python 和 opencv-contrib-python 不能共存,后装的那个会覆盖前一个。先 pip uninstall opencv-python opencv-contrib-python,再单独装 opencv-contrib-python,并确认训练和加载 yml 用的是同一个 OpenCV 大版本,跨大版本读模型经常报格式不支持。

模型加载报 "Unsupported format or combination of formats"。yml 里存的是直方图和标签映射,不是图片路径,换机器可迁移,但 OpenCV 3 和 OpenCV 4 的序列化格式不兼容,最省事的办法是重训一次。

识别结果不稳定,同一人时而距离 60 时而 120。优先查预处理链路:人脸框是否裁进太多背景、resize 是否统一、predict 前是否转灰度。其次查现场光照,固定机位下早晨和下午的光线差异是 LBPH 最大的敌人,重训时把不同时段样本都加进去。

注意:调阈值前先确认样本质量,样本不足时调阈值只是把错误从「误认」挪到「拒认」,系统并没有变好。

5. 考勤系统上线后的三个增强:活体检测、Redis 防重与阈值标定

LBPH 模型分不清照片里的人脸和真脸,系统会直接暴露在「打印一张工牌照片对准摄像头」的攻击下,上线前至少要做一版低成本活体检测。

5.1 用眨眼检测挡住照片攻击

低成本方案是测眨眼:人在连续 30 帧左右必须出现至少一次明显的眼睛闭合动作,才判定为活体。用 dlib 的 68 点人脸关键点模型计算眼纵横比 EAR,睁眼时 EAR 在 0.3 左右,闭眼时降到 0.2 以下:

import dlib, math detector = dlib.get_frontal_face_detector() predictor = dlib.shape_predictor("shape_predictor_68_face_landmarks.dat") def eye_aspect_ratio(landmarks, start): # start 为左眼或右眼的起始索引(36 或 42),取周边 6 个点 pts = [landmarks.part(start + i) for i in range(6)] a = math.hypot(pts[1].x - pts[5].x, pts[1].y - pts[5].y) b = math.hypot(pts[2].x - pts[4].x, pts[2].y - pts[4].y) c = math.hypot(pts[0].x - pts[3].x, pts[0].y - pts[3].y) return (a + b) / (2.0 * c)

把左右眼 EAR 平均后连续采样,序列里出现一次低于 0.22 的谷值就记为一次眨眼。和 4.1 的识别循环串起来:连续 30 帧识别通过且至少发生 1 次眨眼才执行写库。这个方案挡照片足够,挡不了录好的视频,要挡视频得做随机交互指令,比如让员工转头或眨两次眼。

5.2 多摄像头场景用 Redis SETNX 跨进程防重

单机内存冷却只在单进程内有效,两台摄像头连两个识别进程时,防重状态必须放到共享存储。Redis 的 SET NX EX 是原子操作,等价于「抢到锁才允许打卡」:

import redis from datetime import datetime r = redis.Redis(host="127.0.0.1", port=6379, decode_responses=True) def try_acquire(emp_id, clock_type): key = f"attendance:{emp_id}:{datetime.now():%Y-%m-%d}:{clock_type}" # nx=True 键不存在才写入,ex 设置 24 小时自动过期 return bool(r.set(key, "1", nx=True, ex=86400))

注意 Redis 只是第一道门,MySQL 唯一键仍是最终裁决:即使 Redis 的 key 意外丢失,唯一键也会把重复插入变成 rowcount=0。两边口径必须一致,都以「数据库插入返回 1」作为成功标准。

5.3 用留出集给阈值做动态标定

固定阈值 85 不可持续。公司加人后,不同人的特征在直方图空间里挤得更近,「他人最小距离」会越来越小,85 可能从安全阈值变成危险阈值。我一般把识别时距离落在 70 到 130 之间的样本自动存档,每周人工标注一次累积成留出集,脚本算出当前误识率 FAR 最低且拒识率 FRR 可控的阈值:

import numpy as np def calibrate(same_dists, diff_dists, target_far=0.01): lo, hi = 0.0, 200.0 for _ in range(40): # 二分 40 次,精度足够 mid = (lo + hi) / 2.0 far = np.mean(np.array(diff_dists) <= mid) # 被误识成某人的比例 if far > target_far: hi = mid # 误识太多,收紧阈值 else: lo = mid # 误识可控,可以放宽 return hi

把 calibrate 的返回值和当前 CONF_ACCEPT 对比,偏差超过 10% 就触发重新标定,同时把新增的确认样本并入训练集重训一次。阈值标定脚本挂 crontab 每周跑一次,输出当前 FAR 和 FRR,让阈值随着员工规模增长自动逼近真实边界。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询