标题
前后端分离的图像检测上位机软件知识点拆解
最近开发了一个前后端分离的图像检测上位机软件。下图是架构简易图。
CalculateServer使用python,其他模块是使用c++进行编码的
使用到的技术点有
- 多线程,实现任务的并发执行
- qt的信号与槽机制,实现事件响应机制
- QWebSocketServer库和QWebSocket库,实现前后端的通讯
- 共享内存,实现进程间通讯
- opencv库,实现摄像头画面捕获
- 图片相关的操作
多线程
qt框架下实现多线程有两种方式
继承QThread线程类成为线程类
第一种是常见的形式,通过继承QThread线程类成为线程类
在主线程中创建该类,然后调用start函数
moveToThread函数
第二种是不常见的形式,使用moveToThread函数,将普通的类移到创建的线程中去执行自己的成员方法、槽函数。
m_captureThread = new QThread();
m_video->moveToThread(m_captureThread);
m_captureThread->start();
这个m_video是一个捕获摄像头画面的类,它并不用继承QThread类。
qt的信号与槽机制
qt信号与槽机制中很有趣的地方是:信号与槽连接的方式,方式有2种
信号与槽的连接方式
- 直接连接 Qt::DirectConnection。
发送信号后,立刻执行槽函数。即便发送信号的emit代码所在代码块后续还有代码。
- 队列连接 Qt::QueuedConnection
发送信号后,槽函数放入到队列中,先执行完代码块的后续所有代码后,再从队列中取出槽函数执行
如果不显式添加连接类型,默认为Qt::AutoConnection。该类型是什么意思呢?
它表示,当信号发送所在线程与槽函数执行所在线程是同一个线程时,则等于Qt::DirectConnection。
当不在同一个线程时,则等于Qt::QueuedConnection。
注意:
- 即便当信号发送所在线程与槽函数执行所在线程不是同一线程,我们依旧可以使用Qt::DirectConnection。
- 即便当信号发送所在线程与槽函数执行所在线程是同一线程,我们依旧可以使用Qt::QueuedConnection。
- 信号发送者处在A线程,信号接收者处在B线程。
当使用的是Qt::QueuedConnection连接时,槽函数的执行是在B线程中执行的。 - 当使用的是Qt::DirectConnection连接时,槽函数的执行是在A线程中执行的。
往往副线程不会添加事件循环,这会导致副线程的QTimer无法发挥作用,除非QTimer在如下情况被使用。1.QTimer所在的函数是一个槽函数。 2. 所在槽函数连接的信号在另一个有事件循环的线程中。3. 槽函数与信号的连接是直接连接。
满足上述三点条件后,槽函数会在信号发送方执行,QTimer就会被转移到信号发送方的事件循环之中。即可正常执行。
上述操作不推荐。
QTimer的timeout信号相关注意事项
- QTimer 的工作机制:
- QTimer 是基于 Qt 的事件循环机制实现的。它通过向线程的事件队列中插入定时器事件来触发定时操作。
- 当你调用 QTimer::start() 时,Qt 会注册一个定时器事件到该线程的事件循环中。当定时器到期时,事件循环会将定时器事件分发给 QTimer,从而触发其超时信号(timeout())。
- 线程与事件循环的关系:
- 在 Qt 中,每个线程可以有一个事件循环,但需要显式启动(通过调用 QThread::exec())。
- 主线程(GUI 线程)通常会自动运行事件循环(例如,在调用 QApplication::exec() 时启动)。
- 如果你在非主线程中使用 QTimer,必须确保该线程已经启动了事件循环;否则,定时器事件不会被处理。
- 如果没有事件循环会发生什么?
- 如果 QTimer 所在线程没有运行事件循环,即使你调用了 QTimer::start(),定时器事件也不会被分发或处理。
- 这不会导致程序崩溃或抛出错误,但 QTimer 的超时信号永远不会被触发,因此看起来像是“无效”的。
QWebSocketServer库和QWebSocket库
这两个库在传统的TcpSocket库和TcpSocketServer库的基础上封装了新的特性,比如,通讯协议被提前定义好,无需自己定义。
QWebSocket进行通讯的两种协议:
- 基于text类型(其实就是string类型)的消息,传递消息的函数为QWebSocket::sendTextMessage
- 基于二进制类型的消息,传递消息的函数为QWebSocket::sendBinaryMessage
客户端如何与服务端构建通讯
服务端:
- 初始化QWebSocketServer对象
m_server = new QWebSocketServer("Camera Service", QWebSocketServer::NonSecureMode, nullptr);
- 进行监听,这里需要指定在哪个端口监听,监听哪些ip地址传来的连接请求
quint16 port = 8888;
if(!m_server->listen(QHostAddress::Any, port)) {
qDebug() << "Server could not start!";
} else {
qDebug() << "Server started";
}
- 记录构建连接的众多客户端套接字对象
connect(m_server, &QWebSocketServer::newConnection, this, &CameraService::handleNewConnection);
void CameraService::handleNewConnection()
{
QWebSocket *clientSocket = m_server->nextPendingConnection(); // 获取即将与服务端连接的客户端网络套接字
connect(clientSocket, &QWebSocket::disconnected, clientSocket, &QWebSocket::deleteLater);
// 构建消息发送和消息响应的信号槽机制
connect(clientSocket, &QWebSocket::textMessageReceived, this, &CameraService::onTextMessageReceived);
m_clients.append(clientSocket);
qDebug() << "new client connected.";
}
客户端:
- 初始化QWebSocket对象
m_videoSocket = new QWebSocket();
m_calcuSocket = new QWebSocket();
- 进行连接,设置ip地址,端口号
QString serverIP = this->m_LE_serverIP->text();
QString videoPort = this->m_LE_videoPort->text();
QString calcuPort = this->m_LE_calcuPort->text();
QString s1 = "ws://" + serverIP + ":" + videoPort;
QString s2 = "ws://" + serverIP + ":" + calcuPort;
m_videoSocket->open(QUrl(s1));
m_calcuSocket->open(QUrl(s2));
共享内存,实现进程间通讯
进程间通讯,有7种
- 共享内存
- 匿名管道通讯
- 命名管道通讯
- 消息队列
- 套接字通讯
- 信号量通讯
- 信号通讯
本程序的录像模块与计算模块的通讯是通过共享内存实现的,它的特性是:进程通讯中效率最高的
以下是创建共享内存,访问共享内存的方式。
创建和使用的具体逻辑如下:
先由录像模块创建一个共享内存,将一张图片数据保存到共享内存中,再由计算模块去访问该共享内存,取出图片。
录像模块如何创建共享内存
#include <windows.h>
#define ShareMemoryRoom 140000
// 创建共享内存
m_hMapFile = CreateFileMapping(
INVALID_HANDLE_VALUE, // 使用系统分页文件
NULL, // 默认安全性
PAGE_READWRITE, // 可读写权限
0, // 最大对象大小(高位)
ShareMemoryRoom, // 最大对象大小(低位)
L"Local\\MySharedMemory"); // 名称
if (m_hMapFile == NULL) {
qDebug() << "Could not create file mapping object: " << GetLastError();
return 1;
}
// 映射到当前进程的内存空间
// 映射缓冲区视图
char* m_pBuf = (char*)MapViewOfFile(m_hMapFile, // 映射对象句柄
FILE_MAP_ALL_ACCESS, // 可读写许可
0,
0,
ShareMemoryRoom); // 映射大小
if (m_pBuf == NULL) {
qDebug() << "Could not map view of file: " << GetLastError();
CloseHandle(m_hMapFile);
return 1;
}
计算模块如何使用这个已经创建好的共享内存呢?
from multiprocessing import shared_memory
try:
# 尝试打开已存在的共享内存
self.existing_shm = shared_memory.SharedMemory(name=self.shm_name[6:], create=False)
except FileNotFoundError:
print("Shared memory not found.")
exit(1)
录像模块如何将图片存入该共享内存呢?
- 对空间进行定义
// 对共享内存进行定义,一部分空间用来记录图片大小,剩下的空间用来记录图片数据
m_length = (int*)m_pBuf;
*m_length = 0;
m_data = m_pBuf + sizeof(int);
- 对空间进行操作
*m_length = imgBase64.size();
memcpy(m_data, imgBase64.data(), imgBase64.size());
- 为了保证录像模块在对共享内存进行操作时,不被其他线程操作打断,需要添加锁
#include <mutex>
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
*m_length = imgBase64.size();
memcpy(m_data, imgBase64.data(), imgBase64.size());
lock.unlock(); // 手动解锁
- 为了保证录像模块与计算模块的操作同步,需要使用window事件对象以实现同步机制
static int count = 0;
DWORD result;
count++;
// if (count == 100) count = 2;
if (count != 1) {
result = WaitForSingleObject(m_REvent, 30); // 最多等待读取共享内存数据事件完成信号30ms
if (result == WAIT_TIMEOUT) {
qDebug() << "write delay timeout";
}
}
// 将数据保存到共享内存中
std::mutex mtx;
std::unique_lock<std::mutex> lock(mtx);
*m_length = imgBase64.size();
memcpy(m_data, imgBase64.data(), imgBase64.size());
lock.unlock(); // 手动解锁
// 通知处理图片数据的进程,可以进行读取
SetEvent(m_WEvent); // 发送写操作完成信号
具体操作如下,录像模块创建同步事件对象,计算模块使用同步事件对象。
如下是录像模块创建同步事件对象的代码。
// 创建windows写事件对象用于同步
m_WEvent = CreateEvent(NULL, FALSE, FALSE, L"Local\\MySharedWEvent");
if (m_WEvent == NULL) {
qDebug() << "Could not create event write object: " << GetLastError();
UnmapViewOfFile(m_pBuf);
CloseHandle(m_hMapFile);
return 1;
}
// 创建windows读事件对象用于同步
m_REvent = CreateEvent(NULL, FALSE, FALSE, L"Local\\MySharedREvent");
if (m_REvent == NULL) {
qDebug() << "Could not create event read object: " << GetLastError();
UnmapViewOfFile(m_pBuf);
CloseHandle(m_hMapFile);
return 1;
}
计算模块那边如何使用这些事件对象呢?
import win32event
self.r_event = win32event.OpenEvent(0x1F0003, False, self.r_event_name) # 0x1F0003意味着请求对该事件对象具有完全的控制权。
self.w_event = win32event.OpenEvent(0x1F0003, False, self.w_event_name)
接下来介绍计算模块如何对共享内存中的图片数据进行读取?
# 等待写操作事件完成信号
result = win32event.WaitForSingleObject(self.w_event, 100)
if result == win32con.WAIT_TIMEOUT:
logger.info("等待超时,已经超过100毫秒")
break
# 读取数据长度
length_b = self.existing_shm.buf[:4].tobytes() # 返回的是bytes类型
# 将bytes类型数据解包为元组,其元素为int类型
length = struct.unpack("i", length_b)[0] # 由于length_b只有四个字节,存储一个int类型的数据,故元组元素个数为1
data = self.existing_shm.buf[4:4+length].tobytes() # 获取共享内存中的原始数据
# 设置读取完成信号,允许C++程序再次写入
win32event.SetEvent(self.r_event)
这里我忘记对共享内存的读取设置锁操作了
opencv库,实现摄像头画面捕获
摄像头初始化
Video::Video(int index, QObject *parent): QObject(parent), m_isRecording(true)
{
m_camera.open(index); // 打开第一个摄像头
m_isRecordingTimer = new QTimer();
connect(m_isRecordingTimer, &QTimer::timeout, this, &Video::onisRecordingTimerSignal);
if (!m_camera.isOpened()) {
this->setCreatVideoCaptureSucc(false);
}
else {
this->setCreatVideoCaptureSucc(true);
m_camera.set(cv::CAP_PROP_FRAME_WIDTH, 640); // 设置帧宽为640像素
m_camera.set(cv::CAP_PROP_FRAME_HEIGHT, 480); // 设置帧高为480像素
m_camera.set(cv::CAP_PROP_FPS, 30); // 设置帧率为30fps
// 检查是否成功设置了参数
double width = m_camera.get(cv::CAP_PROP_FRAME_WIDTH);
double height = m_camera.get(cv::CAP_PROP_FRAME_HEIGHT);
double brightness = m_camera.get(cv::CAP_PROP_BRIGHTNESS);
double fps = m_camera.get(cv::CAP_PROP_FPS);
double focus = m_camera.get(cv::CAP_PROP_FOCUS);
double exposure = m_camera.get(cv::CAP_PROP_EXPOSURE);
double saturation = m_camera.get(cv::CAP_PROP_SATURATION);
std::cout << "Width: " << width << ", Height: " << height
<< ", Brightness: " << brightness << ", FPS: " << fps
<< ", Focus: " << focus << ", Exposure: " << exposure
<< ", Saturation: " << saturation << std::endl;
}
}
使用cv::CAP_DSHOW作为后端可以更快打开摄像头
m_camera.open(index, cv::CAP_DSHOW);
读取画面,将画面展示在窗口上
void Video::playVideo()
{
// 创建窗口
cv::namedWindow("video");
m_isRecordingTimer->start(60000);
while(m_isRecording){
m_camera.read(m_frame);
cv::imshow("video", m_frame);
m_videoWriter.write(m_frame);
int key = cv::waitKey(30); // 等待30毫秒,获取键值
std::cout << "KeyPressed: " << static_cast<int>(key) << std::endl;
if (key == 27) break; // 按下Esc键退出
}
cv::destroyWindow("video");
this->onCloseCameraSignal();
}
将图片写入视频文件
# 定义视频文件的输出参数
path = 'video/output_video.mp4'
fps = 30 # 视频帧率(每秒帧数)
frame_size = (myConfig["save_video_width"], myConfig["save_video_height"]) # 帧尺寸 (宽度, 高度)
# 创建 VideoWriter 对象
fourcc = cv2.VideoWriter_fourcc(*'mp4v') # 编码格式,这里使用 MP4
self.video_writer = cv2.VideoWriter(path, fourcc, fps, frame_size)
self.video_writer.write(self.image) # self.image 是cv2.Mat格式
图片相关的操作
将图片数据转为base64编码的2进制数据
base64编码的好处:
- 跨系统兼容性,不同系统对二进制数据的处理可能不一致(如字节序、编码)。Base64作为中间格式,确保数据在不同系统间正确解析。
- 避免数据损坏,二进制数据可能包含特殊字符(如\0、换行符),在传输或存储时可能被截断或篡改(如邮件系统处理换行符)。Base64编码后的数据仅包含安全字符,避免此类问题。
如下为代码
QByteArray imgEncoded;
if (!m_camera.read(m_frame))
{
qDebug() << "lose a frame";
}
if (m_frame.empty()) return;
// 将图片转为jpeg格式图片,并且按照base64编码
std::vector<uchar> buffer;
cv::imencode(".jpg", m_frame, buffer); // 将frame转为jgep格式的数据,存储到buffer中
imgEncoded = QByteArray((const char*)buffer.data(), buffer.size());
QByteArray imgBase64 = imgEncoded.toBase64(); // 按照base64进行编码
emit sentPicSignal(imgBase64);
将base64编码的2进制数据进行解码
m_imageData = QByteArray::fromBase64(imgBase64);
将base64编码的2进制数据变为Mat格式的数据
image_data = base64.b64decode(data)
self.image = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR)