ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于OpenCV与MySQL的人脸识别考勤系统:从检测、识别到防重落库

基于OpenCV与MySQL的人脸识别考勤系统:从检测、识别到防重落库 简介一套基于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_threshold0.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 facesblobFromImage 的三个关键参数缩放因子 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 ffaces/{person_id} os.makedirs(save_dir, exist_okTrue) 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( radius1, # 邻域半径1 是默认值纹理细 neighbors8, # 邻域采样点数一般固定 8 grid_x8, # 横向分块数越大对位置误差越敏感 grid_y8 # 纵向分块数 ) recognizer.train(images, np.array(labels)) recognizer.save(lbph_model.yml) with open(label_map.json, w, encodingutf-8) as f: json.dump(label_map, f, ensure_asciiFalse, indent2)提示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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个关键设计决策值得解释。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_nameattendance_pool, pool_size5, # 按摄像头路数估算1 路摄像头 2~3 足够 host127.0.0.1, userattendance, passwordchange-me, databaseattendance_sys, charsetutf8mb4 ) 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 唯一键依然保证只成功一条另一条 rowcount0 直接丢弃。所以生产环境我一般是唯一键兜底、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, encodingutf-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(host127.0.0.1, port6379, decode_responsesTrue) def try_acquire(emp_id, clock_type): key fattendance:{emp_id}:{datetime.now():%Y-%m-%d}:{clock_type} # nxTrue 键不存在才写入ex 设置 24 小时自动过期 return bool(r.set(key, 1, nxTrue, ex86400))注意 Redis 只是第一道门MySQL 唯一键仍是最终裁决即使 Redis 的 key 意外丢失唯一键也会把重复插入变成 rowcount0。两边口径必须一致都以「数据库插入返回 1」作为成功标准。5.3 用留出集给阈值做动态标定固定阈值 85 不可持续。公司加人后不同人的特征在直方图空间里挤得更近「他人最小距离」会越来越小85 可能从安全阈值变成危险阈值。我一般把识别时距离落在 70 到 130 之间的样本自动存档每周人工标注一次累积成留出集脚本算出当前误识率 FAR 最低且拒识率 FRR 可控的阈值import numpy as np def calibrate(same_dists, diff_dists, target_far0.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让阈值随着员工规模增长自动逼近真实边界。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进