基于 Qt+OpenCV 人脸识别考勤系统

4980 字
25 分钟
基于 Qt+OpenCV 人脸识别考勤系统

OpenCV-Qt-FaceAttendance 工程阅读与代码详解#

1. 项目定位#

这是一个“摄像头客户端 + 人脸识别服务端”的 Qt Widgets 桌面考勤系统。客户端负责采集视频、检测人脸、压缩图片和展示结果;服务端负责接收图片、调用 SeetaFace 识别、查询 SQLite 员工资料、写入考勤记录并返回 JSON。

本文不讨论简历包装,而是沿着真实代码说明工程如何启动、一帧图像如何流转、各模块为何这样划分,以及当前实现有哪些边界。项目不是前后端 HTTP 系统,而是基于 TCP 的本地客户端/服务端程序。

核心概念:OpenCV 负责摄像头、图像处理和 JPEG 编解码,不负责网络通信;Qt Network 负责 TCP 收发;QDataStream 负责把长度字段和字节数组序列化到 TCP 字节流。JPEG 是标准格式,“长度 + JPEG 数据”是本工程约定的应用层封装。

2. 技术栈#

  • C++11
  • Qt Widgets:.ui 界面、QMainWindow、信号槽、QTimer/QDataStream
  • OpenCV 4.x:VideoCapture、灰度转换、Haar Cascade、JPEG 编解码
  • SeetaFace 6:人脸检测、关键点定位、人脸注册和特征库查询
  • Qt Network:QTcpSocket、QTcpServer
  • Qt SQL + SQLite:员工表、考勤表和人脸 ID 映射
  • qmake:两个工程分别由 .pro 文件构建

3. 目录与职责#

OpenCV-Qt-FaceAttendance/
├─ FaceAttendanceClient/ # 客户端:摄像头和结果展示
│ ├─ faceattendance.cpp/.h
│ ├─ faceattendance.ui
│ └─ FaceAttendence.pro
├─ FaceAttendanceServer/ # 服务端:识别、数据库和管理窗口
│ ├─ attendancewin.cpp/.h # TCP 服务和考勤主流程
│ ├─ qfaceobject.cpp/.h # SeetaFace 封装
│ ├─ registerwin.cpp/.h # 人员注册
│ ├─ selectwin.cpp/.h # 员工/考勤查询
│ └─ FaceAttendaceServer.pro
├─ SeetaFace/ # 仓库内附带的头文件、库和模型目录
└─ opencv452/ # 仓库内附带的 OpenCV 开发文件

建议阅读顺序:FaceAttendanceClient/faceattendance.cpp -> FaceAttendanceServer/attendancewin.cpp -> qfaceobject.cpp -> registerwin.cpp -> main.cpp。

4. 总体架构#

摄像头
↓
客户端 FaceAttendance::timerEvent()
├─ OpenCV 采集帧
├─ Haar Cascade 检测第一张人脸
├─ Mat 人脸区域编码为 JPEG
└─ QDataStream 写入 [长度][JPEG],TCP 发给 9999 端口
↓
服务端 AttendanceWin::read_data()
├─ 按长度处理 TCP 半包
├─ JPEG 解码为 cv::Mat
└─ queued signal 投递到 QFaceObject 工作线程
↓
QFaceObject::face_query()
└─ SeetaFace Query -> faceId/相似度
↓
AttendanceWin::recv_faceid()
├─ faceId 查询 employee 表
├─ 插入 attendance 表
└─ JSON 返回客户端
↓
客户端 recv_data() 更新姓名、工号、部门、时间

5. 一次完整打卡的数据流#

把工程串起来理解,比逐个函数孤立阅读更有效。一次打卡会经历以下过程:

摄像头
│ BGR 视频帧
▼
FaceAttendance::timerEvent()
│ 灰度化、Haar 检测、截取第一张人脸 ROI
│ OpenCV imencode() 生成 JPEG 字节
▼
QDataStream + QTcpSocket
│ [quint64 长度][QByteArray/JPEG]
▼
AttendanceWin::read_data()
│ 等待完整数据、QPixmap 显示、OpenCV imdecode()
▼
query(cv::Mat&) 信号
│ 跨线程排队调用
▼
QFaceObject::face_query()
│ SeetaFace Query -> faceId + similarity
▼
send_faceid(int64_t) 信号
│ 回到 AttendanceWin 所在线程
▼
AttendanceWin::recv_faceid()
│ employee 查询 -> attendance 插入 -> JSON 回包
▼
FaceAttendance::recv_data()
└─ 更新姓名、工号、部门、时间和认证状态

这个流程中存在三种不同的数据身份:

数据含义存储位置
JPEG 字节某一时刻采集的人脸图片只在网络传输过程中短暂存在
faceIdSeetaFace 特征库中某个人脸的编号face.db 与 employee.faceId
employeeId业务系统中的员工编号SQLite server.db

faceId 不是员工号。识别算法只返回 faceId,服务端必须再查询 employee 表,才能得到业务身份。

6. 客户端代码深读#

6.1 对象和事件来源#

客户端的核心成员如下:

cv::VideoCapture cap; // 摄像头
cv::CascadeClassifier cascade; // Haar 人脸检测器
QTcpSocket msocket; // 与识别服务端通信
QTimer mtimer; // 连接和断线重连
int send_flag = -1; // 控制同一张脸的发送频率

main.cpp 创建 QApplication 和 FaceAttendance 后进入 a.exec()。此后程序不再靠手写循环运行,而由 Qt 事件循环分发定时器、Socket 和界面事件。

构造函数中的 startTimer(100) 使用的是 QObject 的基础定时器,所以超时后进入重写的 timerEvent();mtimer 则是一个独立的 QTimer,通过 timeout 信号发起连接。两者用途不同。

6.2 摄像头采集与 Haar 检测#

每次 timerEvent() 先执行:

cv::Mat srcImage;
if (cap.grab()) {
cap.read(srcImage);
}
if (srcImage.data == nullptr) return;

grab() 推进视频流,read() 取出并解码当前帧。随后将 BGR 图像转为灰度图,再使用 detectMultiScale() 得到人脸矩形列表。灰度化可以减少输入通道数,符合 Haar 分类器的工作方式。

当前实现只处理 faceRects.at(0),所以画面中有多张脸时只识别检测列表中的第一张。列表顺序不保证等同于“离摄像头最近”或“面积最大”;若需要明确策略,应按矩形面积或中心距离排序。

6.3 ROI 的内存关系#

cv::Mat faceImage = srcImage(rect);

这行通常创建的是 ROI 视图,faceImage 与 srcImage 共享底层像素内存,而不是立即复制整块数据。当前函数紧接着调用 imencode(),在 srcImage 仍有效时完成编码,所以可以工作。如果要把 ROI 保存到异步队列,应使用 faceImage.clone() 获得独立数据。

6.4 JPEG 编码与 TCP 封装#

std::vector<uchar> buf;
cv::imencode(".jpg", faceImage, buf);
QByteArray byte(reinterpret_cast<const char *>(buf.data()), buf.size());
QByteArray sendData;
QDataStream stream(&sendData, QIODevice::WriteOnly);
stream.setVersion(QDataStream::Qt_5_14);
quint64 bytesize = byte.size();
stream << bytesize << byte;
msocket.write(sendData);

这里分三层理解:

  1. imencode() 把像素矩阵编码为标准 JPEG。
  2. QDataStream 把 quint64 和 QByteArray 按 Qt 的序列化规则写入 sendData。
  3. QTcpSocket::write() 把 sendData 交给 Socket 发送缓冲区。

write() 成功只代表数据被接受进 Qt/操作系统缓冲区,不代表服务端已经完成接收或识别。若要确认发送进度,可以监听 bytesWritten;若要确认业务完成,则必须等待服务端回包。

6.5 send_flag 状态变化#

send_flag 是一个简易的抗抖机制:

没有检测到人脸 -> send_flag = 0
连续检测到人脸 -> 0, 1, 2, 3...
send_flag > 2 -> 发送一次,然后设置为 -2
人脸继续存在 -> 保持 -2,不再发送
人脸离开 -> 重置为 0

发送后设置为 -2,后续帧不会再进入 send_flag >= 0 分支,因此同一张脸会保持“不再发送”状态,直到检测不到人脸时重置为 0。它是一种简单的抗抖和防重复打卡策略,不是带时间窗口的通用限流器。

6.6 BGR/RGB 与图像生命周期#

OpenCV 彩色图默认是 BGR,Qt 的 QImage::Format_RGB888 需要 RGB,因此显示前必须交换通道。QImage 构造函数此处引用 cv::Mat 的现有内存,没有深拷贝;当前代码马上用 QPixmap::fromImage() 生成用于控件显示的对象,没有把临时 QImage 保存到函数外。

如果以后将 QImage 缓存为成员变量,应调用 copy() 或确保对应 cv::Mat 一直有效,否则可能访问已经释放或被下一帧覆盖的内存。

6.7 连接和断线重连#

客户端启动后每 5 秒调用一次:

msocket.connectToHost(QHostAddress::LocalHost, 9999);

连接成功后停止重连定时器;断开时以 3 秒间隔重新启动。QHostAddress::LocalHost 对应本机回环地址,因此当前配置只能连接本机服务端。若服务端在另一台设备上,需要把地址改为配置项。

6.8 JSON 结果接收#

recv_data() 使用 readAll() 读取当前已经到达的字节,并调用 QJsonDocument::fromJson()。识别成功时更新控件,姓名为空时隐藏成功提示。

这里没有为 JSON 设置消息边界。TCP 可能把一条 JSON 分成多次到达,也可能把多条 JSON 合并到一次 readyRead 中。因此正式实现应该保留接收缓冲区,并给 JSON 增加长度字段或换行分隔符。

7. 服务端网络代码深读#

7.1 监听和连接对象#

AttendanceWin 构造时调用:

mserver.listen(QHostAddress::Any, 9999);

Any 表示监听本机所有网卡地址。收到 newConnection 后,nextPendingConnection() 返回代表该客户端的 QTcpSocket,其 readyRead 信号连接到 read_data()。

当前窗口只有一个 QTcpSocket *msocket 和一个 quint64 bsize。后连接的客户端会覆盖 msocket,不同连接也会共享 bsize,因此代码实际适用于一个考勤客户端,并不具备完整的多客户端并发管理能力。

7.2 为什么要有长度字段#

TCP 是有序字节流,不保留应用层的消息边界。客户端一次 write() 的内容,服务端可能分多次收到;客户端连续多次 write(),服务端也可能一次收到多条消息。

长度字段的作用是告诉接收端:“下一段有效载荷应有多少字节”。服务端先读取 bsize,如果剩余字节不足就返回,等待下一次 readyRead。这处理了单个图片分段到达的情况。

7.3 QByteArray 序列化细节#

代码使用 stream << bytesize << byte,接收端使用 stream >> bsize 和 stream >> data。QDataStream 在序列化 QByteArray 时本身也会写入数组长度,因此流中并非简单的“8 字节长度后紧跟裸 JPEG”,而是 Qt 序列化格式:外层手写的 quint64 后还有 QByteArray 自己的长度信息。

由于两端都设置 QDataStream::Qt_5_14 并使用对称的 <</>>,它们能够正确解析。但若以后使用非 Qt 客户端,就必须明确字节序和 QByteArray 序列化规则,或者改成手工写入固定头部与裸 payload。

7.4 当前半包处理的边界#

现有逻辑表达了正确方向,但还不是通用解析器:

  • sizeof(bsize) 只检查外层长度字段,不代表后面的 QByteArray 序列化头已经完整;
  • bytesAvailable() >= bsize 没有把 QByteArray 自身的长度前缀计算在内;
  • 一次到达多个完整包时没有 while 循环继续解析;
  • 没有限制 bsize 最大值,错误长度可能造成异常资源消耗;
  • Socket 断开后没有清理残留接收状态。

更稳妥的实现是把所有新字节追加到 receiveBuffer,用显式固定长度包头解析,只有当包头和 payload 都完整时才取出一包,并循环处理缓冲区中的后续完整包。

7.5 JPEG 解码#

收到完整 QByteArray 后,代码一方面使用 QPixmap::loadFromData() 在服务端界面显示图片,另一方面转换为 std::vector<uchar>,再调用:

cv::imdecode(buf, cv::IMREAD_COLOR);

输出是 BGR cv::Mat。若 JPEG 损坏,faceImage.empty() 为真;当前代码只打印错误,仍然发出识别信号,合理的异常路径应该在此直接返回并向客户端发送错误结果。

8. SeetaFace 模块深读#

8.1 三个模型的作用#

模型文件作用
fd_2_00.dat找出图像中的人脸区域
pd_2_00_pts5.dat定位眼睛、鼻子、嘴角等 5 个关键点
fr_2_10.dat提取人脸特征并进行相似度检索

QFaceObject 用静态 FaceEngine *fengineptr 保证进程中共享同一引擎实例。首次构造时加载模型,并调用 initializeDatabase() 加载或创建 face.db。

8.2 face.db 和 server.db 的区别#

face.db
SeetaFace 自己保存的人脸特征库
保存特征和算法生成的 faceId
server.db
SQLite 业务数据库
保存员工资料、faceId 映射和考勤记录

删除 face.db 会丢失人脸特征;只保留 server.db 时,员工资料还在,但识别引擎无法找到对应特征。反过来,只保留 face.db 时算法可能返回 faceId,但服务端查不到员工姓名。两个数据库必须保持一致。

8.3 注册流程#

face_register() 依次完成:

  1. 检查输入图像非空。
  2. 将 cv::Mat 的数据指针、宽、高、通道数填入 SeetaImageData。
  3. 调用 DetectFaces(),没有人脸就返回 -1。
  4. 先调用 Query(),相似度大于 0.7 时认为已经注册。
  5. 调用 Register() 获取新 faceId。
  6. 调用 Save("./face.db") 持久化特征库。

之后 RegisterWin 才将个人资料和这个 faceId 写入 SQLite。由于这两步不在同一个事务中,存在特征注册成功但业务数据插入失败的可能,此时 face.db 中会出现没有对应员工记录的孤立特征。

8.4 查询流程和阈值#

face_query() 调用 Query() 得到候选 faceId 与 similarity。信号中使用 similarity > 0.4 ? faceid : -1,低于阈值按陌生人处理。

注册去重阈值 0.7 和识别阈值 0.4 用途不同:前者避免重复录入,后者决定一次识别是否可信。它们是工程中的经验值,不应理解为通用标准。部署时需要根据摄像头、光照、人员规模以及误识率/拒识率要求重新测试。

9. Qt 多线程模型#

9.1 当前线程关系#

服务端 GUI 线程
QTcpServer/QTcpSocket
JPEG 解码与界面更新
SQLite 查询和写入
│ query(cv::Mat&) 信号
▼
识别工作线程
QFaceObject::face_query()
SeetaFace::Query()
│ send_faceid(int64_t) 信号
▼
服务端 GUI 线程
AttendanceWin::recv_faceid()

fobject.moveToThread(thread) 改变 QFaceObject 的线程归属。发送者和接收者位于不同线程时,Qt 的自动连接会使用队列方式:信号参数进入目标线程事件队列,槽函数稍后在目标线程执行。

9.2 元类型注册#

main.cpp 调用:

qRegisterMetaType<cv::Mat>("cv::Mat&");
qRegisterMetaType<int64_t>("int64_t");

Qt 的队列连接需要知道如何复制信号参数,所以自定义或非 Qt 内建类型需要注册。cv::Mat 本身采用引用计数,普通复制通常是浅拷贝;如果发送后源数据可能被改写,跨线程前应 clone()。

9.3 为什么识别放到工作线程#

SeetaFace 推理可能耗时。如果直接在 readyRead 槽中识别,GUI 线程会停止处理绘制、输入和后续网络事件。移入工作线程后,界面仍可响应,Socket 事件也能继续分发。

9.4 生命周期问题#

当前 QThread *thread 是构造函数中的局部指针,没有保存为成员,也没有在窗口退出时执行 quit() 和 wait();QFaceObject 又是 AttendanceWin 的成员对象。关闭程序时可能出现“线程仍在运行,对象已经析构”的问题。

更标准的写法是让窗口保存线程指针,连接 thread->finished 与清理逻辑,并在析构前按顺序停止接收新任务、quit()、wait()。共享的静态 FaceEngine 也需要明确释放者并确认注册和查询不会跨线程并发访问。

10. 数据库和管理功能#

10.1 数据表#

服务端启动时创建:

CREATE TABLE IF NOT EXISTS employee(
employeeId INTEGER PRIMARY KEY AUTOINCREMENT,
name VARCHAR(256),
sex VARCHAR(32),
birthday TEXT,
address TEXT,
phone TEXT,
faceId INTEGER UNIQUE,
headfile TEXT
);
CREATE TABLE IF NOT EXISTS attendance(
attendanceId INTEGER PRIMARY KEY AUTOINCREMENT,
employeeId INTEGER,
attendanceTime TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

employee.faceId 唯一,防止一个算法 ID 关联多名员工。attendance.employeeId 当前没有外键,数据库不会自动校验员工是否存在,也不会级联处理员工删除。

10.2 注册窗口#

注册窗口支持选择图片和摄像头拍照。提交时先完成人脸特征注册,再用 QSqlTableModel 构造员工记录,调用 insertRecord() 和 submitAll() 写入数据库。

头像保存到 ./data/,文件名使用姓名的 Base64 字符串。它能降低中文文件名带来的路径兼容问题,但同名员工可能覆盖同一文件;更可靠的方式是使用员工编号或 UUID。

10.3 识别、回包和考勤落库#

recv_faceid() 用绑定参数查询:

SELECT employeeId, name FROM employee WHERE faceId = :faceId;

找到员工后,服务端构造包含员工号、姓名、部门和时间的 JSON,写回客户端,再向 attendance 插入员工号。当前部门值是硬编码,不来自数据表;时间由服务端本机生成。

当前实现没有上下班规则、时间窗口或去重约束。因此只要客户端再次发送并识别成功,就会新增考勤记录。

10.4 查询窗口#

SelectWin 根据单选按钮选择 employee 或 attendance 表,再用 QSqlTableModel::select() 加载到 QTableView。这展示了 Qt SQL 的 Model/View 用法,但没有条件筛选、分页、排序规则或关联员工姓名。

11. 构建和运行#

11.1 两个独立工程#

客户端和服务端分别由 FaceAttendence.pro 与 FaceAttendaceServer.pro 构建。客户端需要 Qt core/gui/widgets/network;服务端还需要 sql,两者都链接 OpenCV 和 SeetaFace。

建议运行顺序:

  1. 检查 Qt SQLite 驱动、OpenCV DLL、SeetaFace DLL 和模型文件。
  2. 启动服务端,确认 server.db 打开且 9999 端口监听成功。
  3. 在服务端注册窗口录入至少一个人脸与员工资料。
  4. 启动客户端,确认摄像头画面和 TCP 连接日志。
  5. 面向摄像头,依次观察检测、发送、服务端识别、数据库写入和客户端回显。

11.2 当前路径依赖#

源码和 .pro 文件包含 D:\FaceRecognitionAttendanceSystem\... 形式的绝对路径。若工程位于其他目录,需要同步修改 OpenCV、SeetaFace 和模型路径。更好的做法是使用 qmake 变量、相对目录或配置文件,并用 QCoreApplication::applicationDirPath() 定位运行资源。

11.3 Linux 移植不是直接重新编译#

当前依赖目录含 MinGW 库和 Windows DLL,不能直接用于 Linux。移植时需要 Linux 版 OpenCV/SeetaFace 动态库,修改 .pro 的头文件与链接目录,处理 .so 搜索路径、摄像头设备权限、Qt 平台插件以及模型资源路径。

12. 代码中的关键限制与改造思路#

12.1 server.db 的创建判断#

服务端 main.cpp 用 QDir::exists("./server.db") 和 mkdir("./server.db") 处理数据库路径,但 server.db 应是文件而不是目录。SQLite 在文件不存在时可以自动创建;这里应直接 setDatabaseName() 后 open(),或用 QFile::exists() 判断。

12.2 多客户端会话#

要支持多个终端,建议为每个连接创建独立会话对象,至少保存:

QTcpSocket *socket
QByteArray receiveBuffer
quint64 expectedPayloadSize
ParseState state

Socket 断开时销毁对应会话,不让不同客户端共享 bsize。

12.3 统一消息格式#

图片请求和 JSON 响应应使用同一种包头,例如:

| magic | version | messageType | payloadLength | payload |

messageType 区分图像、识别结果和错误;payloadLength 必须设置合理上限;解析器将新字节追加到缓冲区,并循环取出所有完整消息。

12.4 业务一致性#

人脸特征库与 SQLite 是两个存储系统,无法直接使用同一个数据库事务。注册时需要补偿逻辑:如果 SQLite 插入失败,应删除刚注册的特征或记录待清理状态。删除员工时也应同步删除特征和头像。

12.5 错误处理#

当前多个操作只打印日志,生产代码应向上返回明确错误,包括摄像头打不开、Haar/SeetaFace 模型加载失败、JPEG 编解码失败、Socket 写入失败、数据库提交失败和特征库保存失败。

13. 建议的调试路径#

遇到“没有识别结果”时,不要直接怀疑模型,应顺着数据链逐段确认:

  1. 采集层: cap.isOpened() 是否为真,帧尺寸和 empty() 是否正常。
  2. 检测层: Haar 文件是否加载成功,faceRects.size() 是否大于 0。
  3. 编码层: imencode() 是否成功,JPEG 字节数是否合理。
  4. 连接层: Socket 是否进入 ConnectedState,服务端是否收到 newConnection。
  5. 拆包层: bsize 与 bytesAvailable() 是否符合预期。
  6. 解码层: imdecode() 后的 cv::Mat 是否为空。
  7. 识别层: 模型和 face.db 是否加载成功,相似度是多少。
  8. 映射层: 返回的 faceId 是否存在于 employee 表。
  9. 数据层: SQL 是否执行成功,lastError() 输出什么。
  10. 回包层: JSON 是否完整到达客户端并成功解析。

14. 工程理解总结#

这个工程的核心不是单独某个人脸识别 API,而是把多个子系统串成一条完整链路:

Qt 事件循环
+ OpenCV 摄像头与图像处理
+ Qt TCP 字节流通信
+ SeetaFace 特征库检索
+ Qt 跨线程信号槽
+ SQLite 业务数据持久化

阅读时应始终区分四个边界:OpenCV 与 Qt 图像对象的内存边界、JPEG 编码与 TCP 封包的协议边界、GUI 线程与识别线程的执行边界,以及 SeetaFace faceId 与业务 employeeId 的数据边界。把这四处理解清楚,工程的主要结构就清楚了。

基于 Qt+OpenCV 人脸识别考勤系统
https://benjian.xyz/posts/qt-opencv-face-attendance/
作者
JIAN
发布于
2026-09-04
许可协议
CC BY-NC-SA 4.0