项目源码(MIT 协议,欢迎 Star):https://github.com/LL-yanyan/facego
技术栈:C++17 + Qt 6 + OpenCV 4.5 + SeetaFace6 + SQLite + TCP + CMake
这篇文章不讲人脸识别原理科普,只记录一个能跑、能扩展、踩过真实坑的工程项目是怎么搭起来的,包括网络协议、多线程、数据库、工程化,以及我写代码时留下的坑和修复思路。
一、先说我做了个什么东西
传统考勤方式无非刷卡、指纹、密码,都存在「代打卡」的问题。人脸考勤是最自然的方案:人往摄像头前一站,系统识别出身份、记录时间,全程不用动手。
我用 Qt/C++ 实现了一套客户端-服务器(C/S)架构的人脸识别考勤系统FaceGo 智勤:
- 考勤客户端:可以部署在公司门口、车间的多台机器上,只负责摄像头采集画面、框出人脸、把画面通过 TCP 发给服务端,并展示识别结果;
- 考勤服务端:一台机器集中处理所有终端的请求,完成人脸注册、1:N 识别、数据库查询,再把员工信息以 JSON 返回;
- 管理功能:管理员登录、员工注册、部门/岗位管理、考勤记录查询、实时监控。
整体数据流如下:
考勤客户端 A ┐ 考勤客户端 B ├──(TCP, 端口 45678)──▶ 考勤服务端 考勤客户端 C ┘ │ │ │ 摄像头采集 / 级联框选 / JPEG 编码 ├─ 网络模块:QTcpServer,多连接管理 + 帧解析 │ ├─ 人脸模块:SeetaFace6 + OpenCV │ └─ 数据模块:SQLite(单例封装,多表关联)为什么做成 C/S,而不是每台机器各自识别?
- 算力集中:人脸识别依赖模型和特征库,集中在服务端,客户端不需要高配;
- 数据集中:员工信息、考勤记录只存在服务端一台机器上,好维护、好备份;
- 终端可扩展:加一台考勤机 = 开一个客户端,服务端零改动。
二、技术选型
| 分类 | 技术 | 干什么用 |
|---|---|---|
| 语言/标准 | C++17 | 主力语言 |
| GUI 框架 | Qt 6(Widgets / Network / Sql) | 界面、网络、数据库一把梭 |
| 图像处理 | OpenCV 4.5 | 摄像头采集、灰度化、级联分类器、JPEG 编解码 |
| 人脸识别 | SeetaFace6 | 人脸检测、关键点定位、特征提取与 1:N 比对 |
| 网络通信 | TCP(QTcpServer/QTcpSocket) | 客户端与服务端通信 |
| 数据库 | SQLite(Qt Sql 模块) | 用户、员工、考勤等数据持久化 |
| 构建 | CMake(保留 qmake.pro) | 跨平台构建 |
这里要区分OpenCV 和 SeetaFace6 的分工:OpenCV 负责「图像层面」的事——采集、灰度、用 Haar 级联分类器快速框出画面里有没有人脸、JPEG 压缩;SeetaFace6 负责「身份层面」的事——把人脸变成一串特征向量,并和已注册的特征做比对,回答「这个人是谁」。
三、核心难点一:TCP 粘包/半包,自定义应用层协议
这是整个项目里我认为最值得讲的部分。
3.1 为什么不能readAll()直接解码
客户端把摄像头画面编码成 JPEG 发给服务端,一帧画面通常有几十 KB。TCP 是面向字节流的协议,它不保留消息边界:
- 粘包:客户端连续发两帧,服务端一次
readyRead可能把两帧一起读到; - 半包:一帧画面可能被拆成几段,分好几次
readyRead才到齐。
如果拿到字节流直接imdecode,要么解码出半张图,要么把两张图混在一起,结果全是花的。
3.2 帧格式:8 字节长度头 + 负载
解决办法是自定义一个简单的应用层协议:
┌──────────────────────────┬─────────────────────────────┐ │ 帧头:8 字节数据长度 │ 负载:JPEG 图像数据 │ │ quint64,QDataStream 大端 │ 长度 = 帧头给出的字节数 │ └──────────────────────────┴─────────────────────────────┘发送端(客户端)打包:
// 把画面编码成 JPEGstd::vector<uchar>buf;cv::imencode(".jpg",frame_gary,buf);QByteArraybyte_data((constchar*)buf.data(),buf.size());quint64 data_size=byte_data.size();// 负载长度QByteArray send_data;QDataStreamdata_stream(&send_data,QIODevice::WriteOnly);data_stream.setVersion(QDataStream::Qt_6_0);// 固定序列化版本,跨 Qt 版本一致data_stream<<data_size<<byte_data;// 先发长度,再发负载socket->write(send_data);接收端(服务端)用一个小状态机,遵循「先读 8 字节长度,再按长度把负载收满」:
QDataStreamdata_stream(socket);data_stream.setVersion(QDataStream::Qt_6_0);staticquint64 data_size=0;// 记录当前帧负载长度,0 表示还没读到帧头if(data_size==0){// 连 8 字节帧头都没到齐,等下一次 readyReadif(socket->bytesAvailable()<(qint64)sizeof(data_size))return;data_stream>>data_size;// 读出这一帧负载有多长}// 负载还没收满(半包),继续等if(socket->bytesAvailable()<data_size)return;// 收满一整帧,读出负载,复位状态,准备下一帧QByteArray frame_data;data_stream>>frame_data;data_size=0;收不满就return,剩下的字节会留在 socket 的接收缓冲区里,等下一次readyRead触发时继续拼——粘包和半包就都解决了。
3.3 模式切换:服务端用字符串指令控制客户端
服务端有三种工作模式,需要让客户端配合发送不同的画面:
| 指令(服务端 → 客户端) | 含义 | 客户端发送内容 |
|---|---|---|
RECOGNITION | 人脸识别模式 | 灰度 JPEG(识别不需要颜色,数据量更小) |
REGISTER | 人脸注册模式 | 彩色 JPEG |
MONITOR | 实时监控模式 | 彩色 JPEG |
客户端连接成功后,服务端默认下发RECOGNITION;当管理员切换到注册页/监控页时,服务端检测到模式变化再下发对应指令:
// 新客户端接入client->write("RECOGNITION");// 模式不一致时下发切换指令if(curr_mode!=client_mode){switch(curr_mode){caseMODE_MONITOR:socket->write("MONITOR");break;caseMODE_REGISTER:socket->write("REGISTER");break;caseMODE_RECOGNITION:socket->write("RECOGNITION");break;}client_mode=curr_mode;}3.4 识别结果用 JSON 返回
服务端识别完成后,把员工信息拼成 JSON 回给客户端:
{"id":"2612340139","name":"张三","department":"技术部","position":"软件工程师"}识别失败时id为"-1"。客户端用QJsonDocument解析,展示「认证成功/失败」并填充工号、姓名等字段。
四、核心难点二:人脸注册与 1:N 识别
4.1 SeetaFace6 的三个模型
SeetaFace6 的FaceEngine需要三个模型文件,分别对应检测、关键点定位、识别:
fd_2_00.dat:FaceDetector,人脸检测;pd_2_00_pts5.dat:FaceLandmarker,5 个关键点定位;fr_2_10.dat:FaceRecognizer,特征提取。
初始化引擎:
seeta::ModelSettingFD_model("fd_2_00.dat");seeta::ModelSettingPD_model("pd_2_00_pts5.dat");seeta::ModelSettingFR_model("fr_2_10.dat");face_engine=newseeta::FaceEngine(FD_model,PD_model,FR_model);// 启动时把上次保存的特征库加载回来face_engine->Load("face.db");4.2 OpenCV 的 Mat 转 SeetaFace 的图像格式
SeetaFace 不认识 OpenCV 的cv::Mat,需要填一个SeetaImageData结构体,本质就是把图像的数据指针、宽、高、通道数告诉它:
SeetaImageData seeta_img;seeta_img.data=face_img.data;// 像素数据seeta_img.width=face_img.cols;// 宽seeta_img.height=face_img.rows;// 高seeta_img.channels=face_img.channels();// 通道数4.3 注册:提取特征并存入特征库
int64_tface_id=face_engine->Register(seeta_img);if(face_id>=0){// 注册成功,立刻把特征库持久化到磁盘face_engine->Save("face.db");}returnface_id;注册流程整体是:客户端在注册页持续上报彩色画面 → 管理员点「拍照」→ 服务端调Register拿到face_id→ 照片存成user_imgs/<工号>.jpg→ 把员工业务信息连同face_id一起写进员工表。
4.4 识别:1:N 比对 + 相似度阈值
识别时,引擎拿当前人脸的特征和特征库里所有已注册特征逐一比对,返回最像的那个face_id和相似度:
floatsimilarity=0;int64_tface_id=face_engine->Query(seeta_img,&similarity);// 相似度低于 0.8,认为库里没有这个人if(similarity<0.8){emitsendFaceID(-1,socket);return-1;}emitsendFaceID(face_id,socket);阈值我取的 0.8:太低会把陌生人误认成员工(误识),太高会把员工认不出来(拒识),需要根据实际场景调试。
服务端拿到face_id后,用它查员工表,再把工号、姓名、部门、岗位打包成 JSON 返回,完成一次考勤识别。
4.5 特征库为什么要持久化
注册得到的特征向量如果只放在内存里,服务端一重启,所有员工就得重新录一遍脸,这显然不可接受。所以每次注册成功都Save("face.db"),启动时Load("face.db"),特征库就和业务数据库一样能跨重启保留。
五、核心难点三:客户端的采集与通信细节
客户端主窗口用一个 100ms 的定时器驱动整个流程:
startTimer(100);// 每 100ms 触发一次 timerEventtimerEvent里完成「取帧 → 灰度 → 检测人脸 → 移动人脸框 → 编码发送 → 显示画面」:
cv::Mat frame;cap.read(frame);// 取一帧cv::cvtColor(frame,frame_gary,cv::COLOR_BGR2GRAY);std::vector<cv::Rect>faces;cascade.detectMultiScale(frame_gary,faces);// Haar 级联检测人脸位置for(constauto&rect:faces){// 把界面上的人脸框移动/缩放到人脸位置ui->label_face_area->move(rect.x-20,rect.y+rect.height/2);ui->label_face_area->resize(rect.width+40,rect.height+40);}两个我觉得比较实用的细节:
1)简单的流控,避免画面刷屏
客户端用一个recognition_finished标志:发完一帧就置为false,等服务端回了数据(说明处理完了)再置回true,才发下一帧。否则客户端帧率高、服务端处理慢,请求会越堆越多。
2)断线自动重连
服务端重启、网线松了,考勤机不能就这么死着。客户端监听disconnected信号,启动一个 3 秒定时器反复重连,连上了就停:
// 构造时connect(socket,SIGNAL(disconnected()),this,SLOT(slotDisconnected()));connect(socket,SIGNAL(connected()),this,SLOT(slotConnected()));voidAttendenceWindow::slotDisconnected(){slotConnectToServer();timer_connect_to_server->start();// 每 3s 重连一次}voidAttendenceWindow::slotConnected(){timer_connect_to_server->stop();// 连上了,停止重连}六、数据库设计:五张表 + 单例封装
6.1 单例模式
数据库连接全局只需要一个,我用单例封装,删除拷贝构造和赋值:
DataBase&DataBase::getInstance(){staticDataBase db;returndb;}6.2 五张表
- t_user:系统管理员(账号、密码、姓名、身份证、性别、年龄、手机、头像);
- t_department:部门(编号自增、部门名唯一);
- t_position:岗位,通过外键
department_id关联部门; - t_employees:员工(工号、部门、岗位、姓名、身份证、性别、生日、学历、毕业院校、手机、入职日期、face_id);
- t_attendance:考勤记录(员工外键、时间、类型、状态)。
关系很简单:
t_department(1) ──< t_position(N) t_employees (N) ──< t_attendance(N)建表前先开启外键支持,岗位表和考勤表配置级联更新/删除——删掉一个部门,它下面的岗位自动清理:
QSqlQueryquery(*m_db);query.exec("pragma foreign_keys = ON;");// 岗位表(节选)"create table if not exists t_position("" num integer primary key autoincrement,"" department_id integer not null,"" position varchar not null,"" foreign key (department_id) references t_department(num)"" on delete cascade on update cascade);"员工表(字段最多的一张):
createtableifnotexistst_employees(idchar(10)primarykey,departmentvarcharnotnull,positionvarcharnotnull,namevarchar(30)notnull,id_cardchar(18)uniquenotnull,genderchar(3)notnull,agesmallintnotnull,birthdaydatenotnull,nationvarcharnotnull,politicalvarcharnotnull,educationvarcharnotnull,collegevarcharnotnull,telchar(11)uniquenotnull,join_datedatenotnull,face_idintegernotnull);另外还有一个小功能:管理员登录支持「记住密码 / 自动登录」,用QSettings把账号密码和勾选状态写进login.ini,启动时读回来即可。
七、工程化:消除硬编码、CMake、CI
这部分是我后期重构花时间最多、也最有「工程感」的地方。
7.1 干掉所有硬编码绝对路径
早期代码里到处是我自己电脑的绝对路径,比如模型路径写死成D:/opencv/SeetaFace/model/...。这样代码发给任何人都跑不起来。
我写了一个common/apppaths.h集中管理路径,规则是:
- 可写的运行时数据(
database.db、face.db、login.ini、user_imgs/)统一放在可执行文件所在目录; - 只读的第三方资源(SeetaFace 模型、OpenCV 级联分类器)优先用 CMake 编译期注入的路径,没注入就回退到 exe 同级目录。
inlineQStringdataFile(constQString&fileName){returnQDir(QCoreApplication::applicationDirPath()).filePath(fileName);}inlineQStringmodelFile(constQString&fileName){#ifdefSEETAFACE_MODEL_DIRreturnQDir(QStringLiteral(SEETAFACE_MODEL_DIR)).filePath(fileName);#elsereturnQDir(QCoreApplication::applicationDirPath()).filePath("model/"+fileName);#endif}从此源码里再也没有一个绝对路径,换台机器、换个系统都能跑。
7.2 CMake 自动找依赖
用find_package让 CMake 自动定位 Qt / OpenCV / SeetaFace,模型和级联分类器目录自动推导,也支持-D手动覆盖:
cmake-S.-B build-G"MinGW Makefiles"`-DCMAKE_PREFIX_PATH="D:/Qt2/6.8.3/mingw_64"`-DOpenCV_DIR="D:/opencv/opencv452/x64/mingw/lib"`-DSeetaFace_DIR="D:/opencv/SeetaFace/lib/cmake"`-DCMAKE_BUILD_TYPE=Release cmake--build build-j7.3 GitHub Actions 持续集成
我配了 CI(Ubuntu),每次提交自动通过 apt 安装 Qt6 和 OpenCV,编译客户端,至少保证「客户端在干净环境下能编过」。
服务端没放进 CI,原因很现实:SeetaFace6 官方开放版只通过网盘分发预编译二进制,没有公开的包管理器源,CI 无法自动、可靠地获取。服务端我在本地 Windows + MinGW 实际构建验证过。
八、踩坑实录(重点,别学我)
这一章是项目里真实存在、我已经记录在架构文档里的问题。写出来一是提醒自己,二是给想抄作业的朋友避坑。
坑 1:static data_size在多客户端时会串状态
前面接收状态机里的data_size我图省事写成了函数内的static局部变量。但多个客户端的readyRead都会进同一个槽,这个变量被所有连接共享:A 连接刚读了一半长度,B 连接进来把它改了,状态直接错乱。
正确做法是把「每连接的接收状态」绑定到对应的 socket,比如定义一个RecvState结构体,用QMap<QTcpSocket*, RecvState>按连接保存,谁也不干扰谁。
坑 2:moveToThread了,但人脸计算其实还在主线程
我本意是把耗时的人脸计算丢到子线程,代码也写了:
QThread*thread=newQThread(this);face_object->moveToThread(thread);connect(this,SIGNAL(queryFace(cv::Mat&,QTcpSocket*)),face_object,SLOT(faceQuery(cv::Mat&,QTcpSocket*)),Qt::DirectConnection);但有两个致命遗漏,导致子线程形同虚设:
- 创建了线程却没调
thread->start(),工作线程根本没运行事件循环; - 连接方式用了
Qt::DirectConnection,强制槽在发射信号的 GUI 线程里同步执行。
结果faceQuery一直跑在主线程,帧率一高人就感觉界面卡。修复思路:
- 补上
thread->start(); - 把
DirectConnection改成默认的自动连接(跨线程时自动变队列连接); - 识别结果通过已有的
sendFaceID信号异步回传 GUI,再由 GUI 通过 socket 发出。
坑 3:SQL 全是字符串拼接,有注入风险
现在的 SQL 都是QString("... where id = '%1'").arg(id)拼出来的,这是教科书级别的 SQL 注入隐患,而且遇到特殊字符还会报错。应该改成参数化查询(占位符绑定):
QSqlQuery query;query.prepare("select name from t_user where id = ? and passwd = ?;");query.addBindValue(id);query.addBindValue(passwd);query.exec();坑 4:识别成功后的考勤记录还没写库
目前识别成功后员工信息能正常返回,但「往 t_attendance 插一条打卡记录、并判断是上班还是下班」的逻辑还是 TODO。这是业务闭环的最后一步,接下来会补上。
坑 5:Windows 下 DLL 找不到
程序在 Qt Creator 里跑得好好的,双击 exe 就报「找不到 dll」。部署时需要用windeployqt自动拷贝 Qt 的 DLL,再把 OpenCV、SeetaFace 的bin目录加进PATH(或直接把 DLL 放到 exe 旁边)。
九、后续规划
- SQL 全部改为参数化绑定,消除注入风险;
- 修复多连接接收状态串扰,实现真正的多终端并发;
- 让人脸计算真正跑在工作线程,解决高帧率卡顿;
- 识别成功自动写入考勤表,含上下班类型判定;
- 补单元测试;
- 服务端地址、模型路径支持运行时配置文件;
- Linux 完整构建脚本与 Docker 开发环境。
十、写在最后
这个项目对我来说最大的收获,不是「调通了一个人脸识别接口」,而是把一个需求完整地拆成了网络协议、视觉模块、数据模型、线程模型、构建部署几块,并且在踩坑之后能说清楚「哪里不对、为什么不对、该怎么改」。
回过头看,粘包处理、线程归属、路径可移植、SQL 注入这些问题,全都是教科书上一句话带过、只有自己动手写一遍才会真正撞上的东西。
源码已经按 MIT 协议开源,里面有更完整的架构文档(docs/architecture.md)和构建说明:
https://github.com/LL-yanyan/facego
欢迎 Star、提 Issue,也欢迎交流指正。
关键词:C++、Qt、人脸识别、SeetaFace、OpenCV、TCP 粘包、SQLite、CMake、C/S 架构、考勤系统