基于OpenCV与Python的人脸识别考勤系统实战:LBPH训练到SQLite打卡
简介:人脸识别技术在企业考勤、安防验证等场景应用广泛,但云端API调用往往面临隐私泄露与网络依赖问题。基于OpenCV与Python的本地化方案,利用LBPH局部二值模式直方图算法,通过采集人脸图像、提取纹理特征并计算特征距离,实现轻量级身份匹配。该方案完全离线运行,无需GPU与外部API,具备低成本、高可控性,适合办公室考勤、实验室门禁及毕业设计等场景。文章从环境配置、数据集构建、模型训练、置信度阈值调整,到SQLite考勤逻辑与防重复打卡机制,完整拆解一套可复现的人脸识别考勤系统,并分享Haar级联与DNN检测器的实战对比及多个踩坑记录,帮助开发者快速落地。 人脸识别考勤系统,第一反应是不是觉得得用商汤、旷视那种云端API才能搞定?其实完全不是。我之前在一家小公司做内部工具,预算基本为零,又不想把员工人脸数据传到外部平台,最后用OpenCV加Python从零搭了一套本地的人脸识别打卡系统,训练、识别、考勤记录全都在内网跑,效果够用,成本就是几台普通电脑和摄像头。这套项目后来被我整理成了源码加文档的形式,很多做毕设、搞课程设计或者想给团队做个轻量打卡工具的朋友都拿去参考过。
这篇文章就把这套系统的完整实现思路拆开来讲——从选型、环境搭建、数据采集、模型训练,到考勤逻辑和数据库落地,再到我实际跑项目时踩过的几个坑。不追求炫技,重点是可复现、可扩展。内容偏实战,适合有一定Python基础、想快速搭一套可演示人脸识别考勤系统的开发者,也适合拿来做毕业设计的准备素材。
1. 为什么用OpenCV自己训练,而不是调云平台接口或者买现成门禁机
先聊选型。很多人一听到人脸识别考勤,第一反应是买一台人脸识别门禁机,几百到几千不等,配好就能用。但如果只是给一个几十人的团队做内部考勤,或者想彻底搞懂原理、做一次完整的技术实践,门禁机基本就是个黑盒,你看不到里面的数据流和算法细节,也没法按自己的需求改逻辑。云端API方案又涉及网络依赖和隐私合规,不适合所有场景。
OpenCV这条路的优势在于三件事:完全本地化、零API费用、代码可控。考勤是敏感场景,人脸特征数据如果能完全留在本地,至少少了一层顾虑。OpenCV内置的face模块提供了三种经典的人脸识别算法——EigenFaces、FisherFaces和LBPH(Local Binary Patterns Histograms),各有特点:
| 算法 | 原理 | 训练速度 | 识别速度 | 光照敏感度 | 适用场景 |
|---|---|---|---|---|---|
| EigenFaces | 主成分分析降维人脸 | 快 | 快 | 较高 | 受控光照、正脸 |
| FisherFaces | 线性判别分析,类间距离最大 | 中 | 快 | 中等 | 样本均衡、光照变化不大 |
| LBPH | 局部二值模式直方图 | 快 | 快 | 较低 | 光照变化、轻量级本地应用 |
我最终选了LBPH。原因很简单:考勤场景的光照不是完全可控的,早晨和傍晚、晴天和阴天,办公室的光线差异很大。EigenFaces和FisherFaces需要把图像拉成一维向量做全局特征提取,对光照很敏感;LBPH做的是像素局部纹理特征,对光照变化有更好的鲁棒性,而且训练时不需要GPU,一台普通笔记本几十秒就能训完。
另外,OpenCV的人脸检测可以用Haar级联分类器,也可以加载DNN模型做深度学习检测。初版为了减少外部依赖,我用的是OpenCV自带的Haar级联模型,因为随手就能加载,不需要额外下载权重文件。后面优化版本再换成DNN,识别精度和侧脸适应能力都提升了一个档次,这部分后面会单独讲。
1.1 系统整体模块划分
整套系统拆成五个独立模块,各管一摊,后面要扩展功能也不用推倒重来:
- 人脸采集模块:调用摄像头捕获人脸,预处理后存成训练集。
- 人脸检测模块:从画面中定位人脸位置,我用的Haar级联,OpenCV自带的模型文件。
- 模型训练模块:基于LBPH算法,把人脸图像转成特征模型文件。
- 识别与考勤模块:实时识别画面中的人脸,匹配到员工后写入考勤记录。
- 数据管理模块:SQLite本地库,存储员工信息和打卡记录。
这套划分思路其实就是把“识别”和“业务”解耦。模型负责“这是谁”,考勤逻辑负责“该不该记、该怎么记”,两个环节各自独立,即使以后换了深度学习模型,考勤模块一行不用改。
1.2 这个项目适合谁来参考
如果你是学生,想拿它做毕设或课设,这套实现足够完整,从采集到训练到识别再到数据库,五脏俱全,而且能讲清楚原理。如果你是公司IT或运维,想给团队搞一个内部轻量打卡工具,这套代码可以用,但要注意我给的是单机版,多台机器同时打卡需要把SQLite换成MySQL或者加一层服务端接口。如果你只是想学OpenCV的实战用法,这套项目也把图像采集、灰度转换、直方图均衡化、级联分类器检测、模型训练与预测这些核心API全覆盖了,一个项目把OpenCV的常用路径走通了一遍。
2. 环境搭建时最容易翻车的三个细节
先说基础环境。系统是Windows 10,Python用的是3.9,OpenCV装的是4.5.5版本。理论上OpenCV 4.x都行,Python 3.7到3.10兼容性都很好。
2.1 opencv-python和opencv-contrib-python的区别
这是第一个坑。很多人直接pip install opencv-python,跑起来没毛病,图像打开、边缘检测都没问题,但一调用cv2.face就报错——No module named 'cv2.face'。原因很简单,LBPH识别的face模块属于OpenCV的contrib扩展,默认的opencv-python包不带。
我装的是opencv-contrib-python:
pip install opencv-contrib-python
装完不要装重复,先卸载掉已有的opencv-python再装,不然两个包会互相覆盖文件,出各种诡异问题。
另外如果你部署在没有显示器的服务器上,可以装opencv-contrib-python-headless,省掉GUI相关依赖,但注意本机测试时摄像头没办法用,因为它不含highgui模块。
2.2 摄像头打不开的排查顺序
很多人写的第一版代码是cap = cv2.VideoCapture(0),然后一运行cap.read()返回False,摄像头灯不亮,就开始怀疑人生。排查顺序是这样:先换索引,笔记本自带摄像头通常是0,外接USB摄像头可能是1,也可能是-1。有些笔记本的摄像头被厂商驱动特殊处理,OpenCV直接调需要先初始化Media Foundation,那就在代码里加上:
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
Windows上这个参数能解决绝大多数摄像头初始化失败的问题。
2.3 中文路径和中文文件名
这个坑藏在后面。人脸采集阶段要求员工姓名作为文件夹名,训练阶段再遍历文件夹。如果你直接用中文文件夹名,在部分Windows版本上会因编码问题读取失败——OpenCV的imread不支持非ASCII路径,这是传统遗留问题。
我的处理方案是:文件夹名和数据库里的name字段都用UTF-8编码的英文标识符,比如employee_1001,显示名单独存数据库,识别时再根据ID查出中文名。这样既避免了文件系统编码问题,也让数据管理更干净。
注意:实际部署时如果非要用中文当文件夹名,可以用cv2.imdecode替代cv2.imread,但没必要绕这个弯,内部ID加外部显示名的方式更合理。
3. 人脸采集与数据集构建:识别率的第一个分水岭
我做这套系统的时候最有感触的一点——LBPH模型的识别率,很大程度上在采集阶段就决定了,而不是训练阶段。你采集的人脸数据要是模糊、角度单一、光照单一,后面调参数调出花来也没用。
3.1 采集程序怎么写才有效
采集程序的核心逻辑:循环读取摄像头帧,检测人脸,截取人脸区域,转灰度,统一尺寸,保存到员工对应的文件夹。
import cv2
import os
def collect_faces(employee_id, save_dir="dataset", max_count=100):
person_dir = os.path.join(save_dir, employee_id)
os.makedirs(person_dir, exist_ok=True)
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
face_cascade = cv2.CascadeClassifier(
cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
)
count = 0
while count < max_count:
ret, frame = cap.read()
if not ret:
print("读取摄像头失败")
break
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(
gray, scaleFactor=1.1, minNeighbors=5, minSize=(100, 100)
)
for (x, y, w, h) in faces:
face = gray[y:y + h, x:x + w]
face = cv2.resize(face, (200, 200))
cv2.imwrite(os.path.join(person_dir, f"{count:04d}.jpg"), face)
count += 1
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.putText(frame, f"collected: {count}/{max_count}", (10, 30),
cv2.FONT_HERSHEY_SIMPLEX, 0.8, (0, 255, 0), 2)
cv2.imshow("collect", frame)
if cv2.waitKey(1) & 0xFF == ord('q'):
break
cap.release()
cv2.destroyAllWindows()
print(f"采集完成,共保存 {count} 张图片到 {person_dir}")
3.2 采集时的关键经验
采集数量:我按每个员工100张采集。你可以少一点,60张也能训,但100张在光照和角度变化上覆盖更充分。注意采集时让面部稍微转动,左右转头、抬头低头都拍一点,不然模型只认正脸,侧一点就报Unknown。
采集环境:尽量在考勤实际使用的场景采集。如果考勤机放在门口,那采集也尽量在门口那个位置采,光照条件一致会让识别率明显提升。我第一版在工位采集,放到门口去识别,一到傍晚就经常认不出,后来重新在门口采了一次,问题缓解很多。
直方图均衡化:我在保存前不做,在训练和识别前做。主要理由很简单——保存原始灰度图,均衡化放到每次训练和识别时统一做,保持一致性。如果采集时做了均衡化但识别时忘记做,特征就会出现偏差。
3.3 为什么采集后要检查数据质量
我建议训练前写一段小脚本,把每个人每张图片都拼成一张大图看一眼,删除模糊的、没对准的、包含两个人的样本。这一步人工只要5分钟,但能显著提升模型训练质量。LBPH对光照鲁棒,但对图像清晰度仍然敏感,模糊样本会拉高整个类的特征方差。
4. 模型训练与识别逻辑:理解LBPH置信度的真正含义
数据准备好了,接下来就是训练。LBPH的原理不复杂,它把图像划分成多个小区域,在每个区域内计算局部二值模式直方图,再把这些局部直方图拼成一个特征向量来代表一张人脸。不认识的人脸和已注册人脸比较时,计算这个特征向量的距离,距离越小说明越像。
4.1 训练代码
import cv2
import os
import numpy as np
def load_training_data(data_dir="dataset"):
images = []
labels = []
label_names = {}
for label, person_id in enumerate(os.listdir(data_dir)):
person_dir = os.path.join(data_dir, person_id)
if not os.path.isdir(person_dir):
continue
label_names[label] = person_id
for img_name in os.listdir(person_dir):
img_path = os.path.join(person_dir, img_name)
img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
if img is None:
continue
img = cv2.equalizeHist(img)
images.append(img)
labels.append(label)
return images, np.array(labels), label_names
images, labels, label_names = load_training_data()
print(f"加载了 {len(images)} 张图片,共 {len(label_names)} 个员工")
recognizer = cv2.face.LBPHFaceRecognizer_create(
radius=1, neighbors=8, grid_x=8, grid_y=8
)
recognizer.train(images, labels)
recognizer.save("trainer.yml")
with open("label_names.txt", "w", encoding="utf-8") as f:
for label, name in label_names.items():
f.write(f"{label},{name}\n")
print("模型训练完成,已保存到 trainer.yml")
参数的设置我有话说。radius=1代表计算LBP时用的邻域半径,neighbors=8是邻域采样点个数,这两个保持默认就好。真正影响识别率的是grid_x和grid_y——图像被切分的格子数。格子数越大,特征越精细,但也越容易过拟合。我用8x8的网格,对200x200的输入图像差不多把每张脸切成64个区域,效果比较均衡。如果你的人脸数据集量很大,可以试12x12,识别更细,但训练时间也涨。
4.2 LBPH置信度到底怎么理解
这是很多初学者最懵的地方。LBPH的predict方法返回两个值:label是预测的标签,confidence是置信度,但这个置信度和你想的可能是反的——它越小,代表距离越近,越有把握。0表示完全匹配,一般数值跑到100以上基本就是另一个人的脸了。
我初版代码写了if confidence < 80才认为是合法打卡。但跑完发现有个同事怎么都打不上卡,一看日志confidence在90到110之间波动。后来调整了阈值并且换个角度想问题:阈值不能拍脑袋定,应该看真实测试数据的分布。我的做法是先让每个员工识别十次,把confidence数据打出来,取一个不会误报同部门同事、又能覆盖本人波动的中间值。最终我的项目里阈值设在85到95之间,具体数值根据你的摄像头和光线而定。
注意:同一个模型、同一张脸,在不同光照下confidence波动非常大。如果阈值定太死——比如50,那这个系统在实际环境基本用不了,方差大得吓人。阈值宁可放宽一点,再用连续多帧投票的机制来保证准确率,也不能死磕单帧识别。
4.3 二分类思维:这是本人还是不是本人
LBPH本质是分类器,但它做的是一对一的最近邻匹配。系统里只有员工A和员工B,它会把未知人脸强行归类到其中一人。所以必须用confidence阈值做一个“陌生人拒绝”逻辑:
label, confidence = recognizer.predict(processed_face)
if confidence < threshold:
person = label_names.get(label, "Unknown")
else:
person = "Unknown"
如果不加这个判断,前台小姐姐路过摄像头也会被识别成某个员工并打上卡。
5. 考勤业务逻辑与数据存储:从“认出人”到“完成打卡”
识别只是前半场,考勤系统真正的核心在业务逻辑。这个模块要解决几个问题:一天只能打一次上班卡、下班卡不能覆盖上班卡、迟到早退怎么算、没注册的人怎么处理。
5.1 数据库表设计
我用SQLite,好处是零配置文件、单文件存储、不需要装数据库服务。两张表就够:员工表和考勤记录表。
CREATE TABLE employees (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_no TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
registered_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_no TEXT NOT NULL,
work_date TEXT NOT NULL,
check_in_time TEXT,
check_out_time TEXT,
status TEXT DEFAULT 'normal',
UNIQUE(employee_no, work_date)
);
employee_no对应模型训练时用的文件夹名,这样模型识别出employee_no之后,再去查employees表拿到中文名。attendance表里加了一个UNIQUE(employee_no, work_date),确保同一天内一个员工只生成一条记录,防止重复打卡。
5.2 签到签退和迟到早退逻辑
打卡的核心业务逻辑,我用几个判断串起来:
import sqlite3
from datetime import datetime
LATE_THRESHOLD = "09:00"
def handle_check(employee_no):
now = datetime.now()
work_date = now.strftime("%Y-%m-%d")
current_time = now.strftime("%H:%M:%S")
conn = sqlite3.connect("attendance.db")
cursor = conn.cursor()
# 查询当天是否有记录
cursor.execute(
"SELECT id, check_in_time, check_out_time FROM attendance WHERE employee_no=? AND work_date=?",
(employee_no, work_date)
)
row = cursor.fetchone()
if row is None:
# 不存在记录,说明是上班打卡
status = "late" if current_time > LATE_THRESHOLD else "normal"
cursor.execute(
"INSERT INTO attendance (employee_no, work_date, check_in_time, status) VALUES (?, ?, ?, ?)",
(employee_no, work_date, current_time, status)
)
conn.commit()
conn.close()
return "上班打卡成功" if status == "normal" else "上班迟到,已记录"
# 已有上班记录但没下班记录
if row[2] is None:
cursor.execute(
"UPDATE attendance SET check_out_time=? WHERE id=?",
(current_time, row[0])
)
conn.commit()
conn.close()
return "下班打卡成功"
conn.close()
return "今天已完成两次打卡,不需要重复操作"
这段逻辑看着简单,但处理了一个关键问题:迟到判断是在第一次打卡时写死的,如果他9点半才来打卡,status直接被标记为late,后面改不了。这个是按我的需求来的,如果你需要允许补卡,可以在attendance表再加一个is_manual字段来标识人工修正。
5.3 防重复打卡和陌生人拦截
单靠数据库的UNIQUE约束还不够。实际场景里,摄像头每帧都能识别到人脸,相当于每秒好几次打卡请求,如果不加限制,数据库里会疯狂写入记录。我加了两个机制:
一是在内存里维护一个上一次打卡时间字典。同一个员工两次打卡间隔小于10秒直接忽略,这个是给识别用的平滑机制,防止连续帧重复触发。
二是在识别线程和数据库操作之间做队列缓冲。识别结果先进队列,再由单独的线程写库,避免摄像头帧率阻塞数据库I/O。
对这个项目规模来说,这两个机制已经够用了。但要注意,我这套是单机版,SQLite不支持高并发写入,如果多人同时打卡,写锁会互斥,卡顿肉眼可见。多机部署需要换成MySQL或者PostgreSQL,代码改动主要在数据库连接层,业务逻辑不用动。
6. 踩坑实录:我在这套系统上踩过的五个坑
这里列几个我在实际开发中踩过的坑,全是血泪换来的,希望你能避开。
6.1 opencv-python和opencv-contrib-python的恩怨
最典型的报错是ModuleNotFoundError: No module named 'cv2.face'。我一开始以为是自己代码写错了,反复检查import,后来搜索才发现,cv2.face是contrib扩展模块,标准opencv-python不包含。我当时是在同一环境里先装了opencv-python,又装了opencv-contrib-python,结果两个包的文件混在一起,项目用着用着突然报AttributeError,查了半天发现是dll被覆盖了。正确做法:先卸载opencv-python再装contrib版本,两个包不要共存。
6.2 OpenCV的putText不支持中文
识别成功后要在画面上显示员工中文名,用cv2.putText直接显示中文会变成????。这是OpenCV的freetype模块没编译进去的问题,最简单的方案是用PIL把中文转成图像再贴到画面上:
from PIL import Image, ImageDraw, ImageFont
import numpy as np
def draw_chinese_text(img, text, position, font_path="msyh.ttc", font_size=28, color=(0, 255, 0)):
img_pil = Image.fromarray(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
draw = ImageDraw.Draw(img_pil)
font = ImageFont.truetype(font_path, font_size)
draw.text(position, text, font=font, fill=color)
return cv2.cvtColor(np.array(img_pil), cv2.COLOR_RGB2BGR)
Windows上用msyh.ttc(微软雅黑)一般都能找到。Linux服务器上没有这个字体,路径换成/usr/share/fonts下的中文字体,或者用文泉驿的wqy-microhei.ttc。
6.3 Haar级联在侧脸和逆光下几乎失效
这是我在真实使用过程中遇到的最大性能瓶颈。Haar级联检测器本质是基于特征的级联分类器,它对正脸检测效果很好,但人脸稍微侧一点,检测框就直接消失了,更谈不上识别。逆光下整个人脸暗成一团,detectMultiScale直接返回空列表。
我的解决方案是两步走:第一步,把采集训练数据时的头部动作范围加大,多转角度。第二步,在识别端加入自动曝光补偿,效果有限但聊胜于无。真正有效的方案是换DNN检测器,后面会讲。如果非要用Haar,至少把minNeighbors从默认的3调成5,减少误检,再把minSize调大到100x100,让检测只锁定近距离大脸,不然远距离小脸会频繁误触打卡。
6.4 单帧识别不稳定,需要帧投票机制
摄像头取哪一帧完全随机,可能正好赶上眨眼、低头、光线变化,导致同一秒内识别结果在A和Unknown之间跳来跳去。我用了一个非常简单的窗口投票机制:连续10帧中如果某个标签出现超过6次,才认为识别成功并触发打卡。10帧在30fps的摄像头下只要0.3秒,体验上没有延迟感,但稳定性提升非常明显。
6.5 Windows控制台编码导致的SQLite中文写入报错
数据库写入中文字段时,在部分Windows终端下会报sqlite3.OperationalError: near "??": syntax error。这其实是Python连接SQLite时默认编码和终端编码不一致导致的。在代码开头加上:
import sys
import io
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
能让标准输出编码稳定。数据库层面,SQLite本身支持UTF-8,问题出在Python和终端的交互层,加这段就解决了。
7. 用DNN检测器替换Haar后的性能提升
Haar的局限性在前面已经说了,换DNN是性价比最高的升级。OpenCV的DNN模块可以直接加载Caffe训练好的SSD人脸检测模型,不需要额外装TensorFlow或PyTorch,一个caffemodel加一个prototxt就够了。
net = cv2.dnn.readNetFromCaffe(
"deploy.prototxt",
"res10_300x300_ssd_iter_140000_fp16.caffemodel"
)
def detect_faces_dnn(frame):
h, w = frame.shape[:2]
blob = cv2.dnn.blobFromImage(
cv2.resize(frame, (300, 300)),
1.0,
(300, 300),
(104.0, 177.0, 123.0)
)
net.setInput(blob)
detections = net.forward()
faces = []
for i in range(detections.shape[2]):
confidence = detections[0, 0, i, 2]
if confidence > 0.7:
box = detections[0, 0, i, 3:7] * np.array([w, h, w, h])
x1, y1, x2, y2 = box.astype("int")
x1, y1 = max(0, x1), max(0, y1)
x2, y2 = min(w, x2), min(h, y2)
faces.append((x1, y1, x2 - x1, y2 - y1))
return faces
对比感受:Haar在逆光下基本失灵,DNN模型对光照和角度的适应能力强得多。换了之后,之前经常识别失败的那几个同事都能正常打上卡了。代价是多了一个30MB左右的caffemodel文件,运行时会消耗一些CPU,但对现在的电脑来说完全可以接受。使用DNN时,采集数据阶段需要同步升级——采集和识别端使用同一套检测器,保持人脸裁剪位置的一致性,否则训练和识别人脸区域分布不一致,识别效果下降。
8. 关于代码组织与后续扩展
最后聊一下这套项目怎么组织目录结构,以及后续能做到什么程度。我实际整理源码时用的是这个结构:
├── collect.py # 人脸采集
├── train.py # 模型训练
├── recognize.py # 实时识别与考勤
├── attendance_db.py # 数据库操作
├── config.py # 全局配置(阈值、路径、迟到时间)
├── dataset/ # 训练数据
└── trainer.yml # 训练结果模型
config.py的建议:
# config.py
FACE_SIZE = (200, 200)
CONFIDENCE_THRESHOLD = 90
LATE_TIME = "09:00"
CAMERA_INDEX = 0
MIN_FRAMES_VOTE = 6
WINDOW_FRAMES = 10
DATASET_DIR = "dataset"
MODEL_PATH = "trainer.yml"
LABEL_FILE = "label_names.txt"
DB_PATH = "attendance.db"
把阈值、路径、摄像头索引这类变化频繁的配置独立出来,比改源码方便得多。我后续在这个项目基础上加过几个小功能,都很简单:导出一段时间内的考勤报表为Excel、在识别界面显示当前员工上次打卡时间、通过配置文件切换多种识别模型。
这套系统的定位始终是一个轻量、本地化的考勤解决方案。如果你想在生产环境真正落地,还需要考虑多人同时识别的并发写库、日志审计、前端管理界面、设备端的模型部署这些问题。但作为学习OpenCV项目、毕设演示、小团队内部工具,这套架构已经完全够用了。
如果让我再总结一句个人体会:做这种项目,数据采集阶段花的时间至少要和训练调参一样多,因为识别系统的上限,往往在你按下快门的那一刻就已经定死了。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)