基于 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 字节 | 某一时刻采集的人脸图片 | 只在网络传输过程中短暂存在 |
faceId | SeetaFace 特征库中某个人脸的编号 | 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);这里分三层理解:
imencode()把像素矩阵编码为标准 JPEG。QDataStream把quint64和QByteArray按 Qt 的序列化规则写入sendData。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() 依次完成:
- 检查输入图像非空。
- 将
cv::Mat的数据指针、宽、高、通道数填入SeetaImageData。 - 调用
DetectFaces(),没有人脸就返回-1。 - 先调用
Query(),相似度大于0.7时认为已经注册。 - 调用
Register()获取新faceId。 - 调用
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。
建议运行顺序:
- 检查 Qt SQLite 驱动、OpenCV DLL、SeetaFace DLL 和模型文件。
- 启动服务端,确认
server.db打开且 9999 端口监听成功。 - 在服务端注册窗口录入至少一个人脸与员工资料。
- 启动客户端,确认摄像头画面和 TCP 连接日志。
- 面向摄像头,依次观察检测、发送、服务端识别、数据库写入和客户端回显。
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 *socketQByteArray receiveBufferquint64 expectedPayloadSizeParseState stateSocket 断开时销毁对应会话,不让不同客户端共享 bsize。
12.3 统一消息格式
图片请求和 JSON 响应应使用同一种包头,例如:
| magic | version | messageType | payloadLength | payload |messageType 区分图像、识别结果和错误;payloadLength 必须设置合理上限;解析器将新字节追加到缓冲区,并循环取出所有完整消息。
12.4 业务一致性
人脸特征库与 SQLite 是两个存储系统,无法直接使用同一个数据库事务。注册时需要补偿逻辑:如果 SQLite 插入失败,应删除刚注册的特征或记录待清理状态。删除员工时也应同步删除特征和头像。
12.5 错误处理
当前多个操作只打印日志,生产代码应向上返回明确错误,包括摄像头打不开、Haar/SeetaFace 模型加载失败、JPEG 编解码失败、Socket 写入失败、数据库提交失败和特征库保存失败。
13. 建议的调试路径
遇到“没有识别结果”时,不要直接怀疑模型,应顺着数据链逐段确认:
- 采集层:
cap.isOpened()是否为真,帧尺寸和empty()是否正常。 - 检测层: Haar 文件是否加载成功,
faceRects.size()是否大于 0。 - 编码层:
imencode()是否成功,JPEG 字节数是否合理。 - 连接层: Socket 是否进入
ConnectedState,服务端是否收到newConnection。 - 拆包层:
bsize与bytesAvailable()是否符合预期。 - 解码层:
imdecode()后的cv::Mat是否为空。 - 识别层: 模型和
face.db是否加载成功,相似度是多少。 - 映射层: 返回的
faceId是否存在于employee表。 - 数据层: SQL 是否执行成功,
lastError()输出什么。 - 回包层: JSON 是否完整到达客户端并成功解析。
14. 工程理解总结
这个工程的核心不是单独某个人脸识别 API,而是把多个子系统串成一条完整链路:
Qt 事件循环 + OpenCV 摄像头与图像处理 + Qt TCP 字节流通信 + SeetaFace 特征库检索 + Qt 跨线程信号槽 + SQLite 业务数据持久化阅读时应始终区分四个边界:OpenCV 与 Qt 图像对象的内存边界、JPEG 编码与 TCP 封包的协议边界、GUI 线程与识别线程的执行边界,以及 SeetaFace faceId 与业务 employeeId 的数据边界。把这四处理解清楚,工程的主要结构就清楚了。




























