:TCP 粘包处理、SeetaFace6、SQLite 全记录)
项目源码MIT 协议欢迎 Starhttps://github.com/LL-yanyan/facego技术栈C17 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而不是每台机器各自识别算力集中人脸识别依赖模型和特征库集中在服务端客户端不需要高配数据集中员工信息、考勤记录只存在服务端一台机器上好维护、好备份终端可扩展加一台考勤机 开一个客户端服务端零改动。二、技术选型分类技术干什么用语言/标准C17主力语言GUI 框架Qt 6Widgets / Network / Sql界面、网络、数据库一把梭图像处理OpenCV 4.5摄像头采集、灰度化、级联分类器、JPEG 编解码人脸识别SeetaFace6人脸检测、关键点定位、特征提取与 1:N 比对网络通信TCPQTcpServer/QTcpSocket客户端与服务端通信数据库SQLiteQt 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 图像数据 │ │ quint64QDataStream 大端 │ 长度 帧头给出的字节数 │ └──────────────────────────┴─────────────────────────────┘发送端客户端打包// 把画面编码成 JPEGstd::vectorucharbuf;cv::imencode(.jpg,frame_gary,buf);QByteArraybyte_data((constchar*)buf.data(),buf.size());quint64 data_sizebyte_data.size();// 负载长度QByteArray send_data;QDataStreamdata_stream(send_data,QIODevice::WriteOnly);data_stream.setVersion(QDataStream::Qt_6_0);// 固定序列化版本跨 Qt 版本一致data_streamdata_sizebyte_data;// 先发长度再发负载socket-write(send_data);接收端服务端用一个小状态机遵循「先读 8 字节长度再按长度把负载收满」QDataStreamdata_stream(socket);data_stream.setVersion(QDataStream::Qt_6_0);staticquint64 data_size0;// 记录当前帧负载长度0 表示还没读到帧头if(data_size0){// 连 8 字节帧头都没到齐等下一次 readyReadif(socket-bytesAvailable()(qint64)sizeof(data_size))return;data_streamdata_size;// 读出这一帧负载有多长}// 负载还没收满半包继续等if(socket-bytesAvailable()data_size)return;// 收满一整帧读出负载复位状态准备下一帧QByteArray frame_data;data_streamframe_data;data_size0;收不满就return剩下的字节会留在 socket 的接收缓冲区里等下一次readyRead触发时继续拼——粘包和半包就都解决了。3.3 模式切换服务端用字符串指令控制客户端服务端有三种工作模式需要让客户端配合发送不同的画面指令服务端 → 客户端含义客户端发送内容RECOGNITION人脸识别模式灰度 JPEG识别不需要颜色数据量更小REGISTER人脸注册模式彩色 JPEGMONITOR实时监控模式彩色 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_modecurr_mode;}3.4 识别结果用 JSON 返回服务端识别完成后把员工信息拼成 JSON 回给客户端{id:2612340139,name:张三,department:技术部,position:软件工程师}识别失败时id为-1。客户端用QJsonDocument解析展示「认证成功/失败」并填充工号、姓名等字段。四、核心难点二人脸注册与 1:N 识别4.1 SeetaFace6 的三个模型SeetaFace6 的FaceEngine需要三个模型文件分别对应检测、关键点定位、识别fd_2_00.datFaceDetector人脸检测pd_2_00_pts5.datFaceLandmarker5 个关键点定位fr_2_10.datFaceRecognizer特征提取。初始化引擎seeta::ModelSettingFD_model(fd_2_00.dat);seeta::ModelSettingPD_model(pd_2_00_pts5.dat);seeta::ModelSettingFR_model(fr_2_10.dat);face_enginenewseeta::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.dataface_img.data;// 像素数据seeta_img.widthface_img.cols;// 宽seeta_img.heightface_img.rows;// 高seeta_img.channelsface_img.channels();// 通道数4.3 注册提取特征并存入特征库int64_tface_idface_engine-Register(seeta_img);if(face_id0){// 注册成功立刻把特征库持久化到磁盘face_engine-Save(face.db);}returnface_id;注册流程整体是客户端在注册页持续上报彩色画面 → 管理员点「拍照」→ 服务端调Register拿到face_id→ 照片存成user_imgs/工号.jpg→ 把员工业务信息连同face_id一起写进员工表。4.4 识别1:N 比对 相似度阈值识别时引擎拿当前人脸的特征和特征库里所有已注册特征逐一比对返回最像的那个face_id和相似度floatsimilarity0;int64_tface_idface_engine-Query(seeta_img,similarity);// 相似度低于 0.8认为库里没有这个人if(similarity0.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::vectorcv::Rectfaces;cascade.detectMultiScale(frame_gary,faces);// Haar 级联检测人脸位置for(constautorect:faces){// 把界面上的人脸框移动/缩放到人脸位置ui-label_face_area-move(rect.x-20,rect.yrect.height/2);ui-label_face_area-resize(rect.width40,rect.height40);}两个我觉得比较实用的细节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 单例模式数据库连接全局只需要一个我用单例封装删除拷贝构造和赋值DataBaseDataBase::getInstance(){staticDataBase db;returndb;}6.2 五张表t_user系统管理员账号、密码、姓名、身份证、性别、年龄、手机、头像t_department部门编号自增、部门名唯一t_position岗位通过外键department_id关联部门t_employees员工工号、部门、岗位、姓名、身份证、性别、生日、学历、毕业院校、手机、入职日期、face_idt_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(constQStringfileName){returnQDir(QCoreApplication::applicationDirPath()).filePath(fileName);}inlineQStringmodelFile(constQStringfileName){#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-GMinGW Makefiles-DCMAKE_PREFIX_PATHD:/Qt2/6.8.3/mingw_64-DOpenCV_DIRD:/opencv/opencv452/x64/mingw/lib-DSeetaFace_DIRD:/opencv/SeetaFace/lib/cmake-DCMAKE_BUILD_TYPERelease cmake--build build-j7.3 GitHub Actions 持续集成我配了 CIUbuntu每次提交自动通过 apt 安装 Qt6 和 OpenCV编译客户端至少保证「客户端在干净环境下能编过」。服务端没放进 CI原因很现实SeetaFace6 官方开放版只通过网盘分发预编译二进制没有公开的包管理器源CI 无法自动、可靠地获取。服务端我在本地 Windows MinGW 实际构建验证过。八、踩坑实录重点别学我这一章是项目里真实存在、我已经记录在架构文档里的问题。写出来一是提醒自己二是给想抄作业的朋友避坑。坑 1static data_size在多客户端时会串状态前面接收状态机里的data_size我图省事写成了函数内的static局部变量。但多个客户端的readyRead都会进同一个槽这个变量被所有连接共享A 连接刚读了一半长度B 连接进来把它改了状态直接错乱。正确做法是把「每连接的接收状态」绑定到对应的 socket比如定义一个RecvState结构体用QMapQTcpSocket*, RecvState按连接保存谁也不干扰谁。坑 2moveToThread了但人脸计算其实还在主线程我本意是把耗时的人脸计算丢到子线程代码也写了QThread*threadnewQThread(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 发出。坑 3SQL 全是字符串拼接有注入风险现在的 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。这是业务闭环的最后一步接下来会补上。坑 5Windows 下 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 架构、考勤系统