线上赛技术报告
侦察信息显示
1.技术任务要求
- 上位机开发工具不做限制
- 显示实时画面和侦察情况
2.运行环境
2.1客户端
- 使用ubuntu作为系统环境
- 使用python3.7作为软件开发环境
2.2服务端
- 选择windows作为服务端,因为有多个小车
- 使用qt5作为软件的集成开发环境
- 兼有python3.7写的python程序作为外部调用
3.流程分析
3.1功能简介
客户端
出于多车的考虑以及实际环境的考量,每个小车安排一个客户端,需要订阅到小车摄像头的话题,由于三个车,所以有三个大致相同的客户端,同时也需要能够与小车进行通信,给小车指令
同时为了显示侦察情况,将小车识别到的地图也需要进行传输,所以单独为了侦察情况显示,写了一个客户端用于传输地图信息
服务端
能够显示本地ip地址,用于客户端的连接
出于简洁性的考量,为了能够传输视频,采用外部调用python程序的方法,外部调用方法为在单独线程中,调用python程序
在主程序的主进程中用于信息的接受和传输,采用自定义通信协议的方法,如果连接到相应小车客户端,小车会发送信息,服务端识别小车编号
3.2通信建立
在服务端首先开始监听本地端口8888,等待客户端连接,用于传输确认信息,以及简单的通信。
同时在子线程中调用外部python程序。相当于新进程,同样采用tcp通信,开始监听本地端口7777,6666,9999,5555.用于接受视频的传输、
客户端选择连接服务端的端口号,而建立连接
3.3通信信息的收发
采用自定义通信协议
发送(服务端
选择以一种类似广播的方式从服务端向客户端进行信息的传输,让每一个客户端都能接收到服务端发来的信息,
接受(服务端
message作为头部,表示是信息传递,用于确认是哪个小车的通信连接建立,可以开始通信。
| message1 | 小车1通信连接建立 |
|---|---|
| message2 | 小车2通信连接建立 |
| message3 | 小车3通信连接建立 |
发送(客户端
接收到消息之后,会通过一个if语句来判断接收到的信息是否是来自服务端的信息,并且是否是发送给自己的信息,如果是则执行发送程序回传一个以message为开头的确认信息
接受(客户端
以自定义的通信协议进行通信
| hello1 | 小车1通信连接建立 |
|---|---|
| hello2 | 小车2连接建立 |
| hello3 | 小车3连接建立 |
3.4视频信息的收发
客户端这块,订阅到摄像头发布的话题,首先将ros网络中的格式转换成opencv中的格式,然后转换成二进制的格式,通过tcpip进行通信,
服务端这块,接受视频的二进制格式,将其解码为图像,然后直接显示图像
3.5程序的终止
在qt中给控制,通过停止线程,在线程的停止函数中直接杀死python程序来达到结束python监听的目的。
4.原理以及代码分析
4.1服务端
4.1.1本地ip获取

1 | QRegularExpression ipRegex("^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$"); |
正则表达式(Regular Expression,简称Regex或RegExp)是一种描述字符串匹配规则的工具,它可以用一种非常简洁的方式来表示一类字符串的匹配规则。正则表达式通常由普通字符和特殊字符组成,这些特殊字符表示一些匹配规则,例如匹配一个或多个字符、匹配某个范围内的字符等等。
这段代码创建了一个正则表达式对象ipRegex,它可以匹配IPv4地址的格式。
具体来说,这个正则表达式的模式为^...$。这个正则表达式可以分成四个部分:^\d{1,3}、\.\d{1,3}、\.\d{1,3}、\.\d{1,3}$。其中:
^表示字符串的开始;
表示匹配1到3个数字;
.表示匹配一个点号;
$表示字符串的结束。
因此,这个正则表达式可以匹配由四段1到3位数字和点号组成的字符串,例如192.168.1.1,但不匹配其他格式的字符串,例如192.168.01.001或192.168.1.256。
1 | foreach (const QNetworkInterface &iface, QNetworkInterface::allInterfaces()){ |
这段代码的作用是获取当前计算机的IP地址,并将其显示在UI界面上。具体实现过程如下:
通过
QNetworkInterface::allInterfaces()函数遍历所有可用的网络接口。对于每个网络接口,判断其是否处于开启状态且不是回环接口,如果是,则继续下一步操作。
获取该网络接口的名称,如果名称包含“wireless”或“WiFi”,则标记为无线接口,否则标记为有线接口。
(由于电脑上有虚拟机,虚拟机网络接口显示有线连接,所以为了排除虚拟机,只能使用无线连接)
- 遍历该网络接口的所有IP地址,如果IP地址符合正则表达式的格式,并且该网络接口是无线接口,则将IP地址输出到控制台,并将其显示在UI上。
总体来说,这段代码的目的是为了获取计算机的无线IP地址,并将其显示在UI上,以方便用户查看和操作。
4.1.2信息传输
4.1.2tcp/ip通信
4.1.2通信
4.1.2.1通信连接建立

得益于Qt优秀的槽与信号机制,我们在客户端建立起信号与槽函数的连接,槽,信号与按键槽函数的链接关系如下图

经由QTCPSocket这个Qt5预定义的通讯类,我们可以轻松的与其他支持TCP协议的机器进行连接,且QTcpSocket中的信号会触发与之链接的槽函数,进而在连接完成后做好发送、接收与断连、退出等工作的准备。
4.1.3信息的获取(小车编号验证
这里服务端收到由客户端编写过的信息,有包装,需要进行解包
此处的连接建立表示通信连接建立
这个套接字用于信息的传输
接受8位信息,前7位表示信息类型,
通信信息连接建立
message表示确定信息,最后一位标识该客户端是谁,如果为1则信息来自小车1,表示小车1连接建立,其他车同理,如果为4则表示地图连接建立
视频信息连接建立
shipinn作为头部,最后以为用于表标识是客户端是谁,如果是1则信息来自小车1,表示小车1的摄像头话题被成功订阅可以实时显示小车摄像头。
1 | if (strcmp(client, "message") == 0){ |
4.1.4信息的发送
1 | QTcpSocket* conn=server->nextPendingConnection(); |
每当有新连接建立时,会将套接字添加到list后面,这个list是全局变量,用以在广播函数中使用
1 | void Server::sendToAllClients(const QByteArray &data) |
要向每个客户端都发送信息,类似广播的方式。
4.1.3python程序的调用
类似于直接在命令行中输入命令
4.1.5.1多线程
调用同样使用到了多线程来调用外部程序。通过对Qt中的<QThread>头文件继承QThread类可以定义自己的线程类:
1 | class MyThread : public QThread |
4.1.5.2QProcess
QProcess 是 Qt 框架中用于启动和控制外部进程的类,它提供了丰富的功能,可以用来执行各种外部命令、脚本和程序。通过 QProcess,可以轻松地与外部进程进行交互,比如向进程发送输入、读取进程输出、监控进程状态等。
QProcess 提供了两种启动进程的方法:
- 通过 start() 方法启动进程,该方法接受要启动的程序名和参数,返回值表示是否成功启动进程。
- 通过 startDetached() 方法启动进程,该方法也接受要启动的程序名和参数,但是它会在独立的进程中启动进程,并立即返回,不会等待进程结束。startDetached() 方法还可以指定是否在后台运行进程、设置工作目录、设置环境变量等。
QProcess 还提供了一些其他有用的功能,比如:
- 通过 write() 方法向进程发送输入,可以发送字符串或二进制数据。
- 通过 readAll() 或 readLine() 方法读取进程的输出,可以读取进程输出的字符串或二进制数据。
- 通过 waitForStarted()、waitForFinished()、waitForReadyRead() 等方法等待进程状态变化或等待进程输出可用。
- 通过 terminate()、kill() 方法终止进程,可以发送不同的信号给进程,比如 SIGTERM、SIGKILL 等。
总之,QProcess 是 Qt 框架中一个非常有用的类,它可以方便地启动和控制外部进程,并与之进行交互。使用 QProcess,可以实现很多有用的功能,比如执行外部命令、调用脚本、启动程序、调用库函数等。
最开始是使用的winexec函数,
WinExec是一个WIN32 API,它的第一个参数必须包含一个可执行文件名,在包括了头文件#<font style="color:#000080;">include</font><font style="color:#c0c0c0;"> </font><<font style="color:#008000;">windows</font>.<font style="color:#008000;">h</font>>后即可对其传入char *类型的可执行文件名和需要传入的参数,此函数即可执行此文件并传参。其第二个参数为打开模式。
使用 QProcess 比 WinExec 函数好的地方有以下几点:
- 跨平台支持:QProcess 可以在不同的平台(如 Windows、Linux、macOS)上运行,而 WinExec 函数只能在 Windows 平台上使用。
- 更好的进程控制:QProcess 提供了更多的进程控制功能,比如可以向进程发送信号、监控进程状态、获取进程输出等,而 WinExec 函数只能启动进程,无法进行更多的控制。
- 更好的安全性:QProcess 的启动过程更加安全可靠,可以避免一些安全隐患,而 WinExec 函数则存在一些安全风险。
- 更好的可维护性:使用 QProcess 可以更方便地维护代码,因为它是 Qt 的一部分,有完善的文档和示例,而 WinExec 函数则不太容易维护。
总之,使用 QProcess 能够提供更多的功能和更好的跨平台支持,同时也能提高代码的可维护性和安全性,因此建议在 Qt 应用中使用 QProcess 来启动和控制进程。
在此次任务中,因为要实现有效的控制外部进程,为了能够简单的杀死进程,所以选择QProcess
1 | void MyThread::run() |
这是一个名为 MyThread 的自定义线程类中的 run() 方法。该方法会启动一个 Python 程序,并等待其启动完成,具体的实现步骤如下:
- 创建一个 QProcess 对象,用于启动 Python 程序。
- 设置要启动的程序名和参数。这里程序名为 “python”,参数为相对路径为 “./debug/camera.py” 的 Python 脚本文件。
- 调用 process->start(program, arguments) 方法启动 Python 程序。
- 调用 process->waitForStarted() 方法等待程序启动,该方法会一直阻塞直到程序启动完成。
- 根据 waitForStarted() 方法的返回值判断程序是否已经启动,如果已经启动,则输出调试信息 “Python程序已启动”,否则输出 “Python程序启动失败”。
这段代码的主要作用是在一个独立的线程中启动一个 Python 程序,并等待其启动完成。启动程序后,线程会一直阻塞在 waitForStarted() 方法处,直到程序启动完成才会继续执行。
4.1.5.3进程的结束
1 | void MyThread::stop() |
这是一个名为 MyThread 的自定义线程类中的 stop() 方法。该方法会停止一个正在运行的进程(process)并等待其结束,具体的实现步骤如下:
- 调用 process->kill() 方法杀死进程。
- 调用 process->waitForFinished(5000) 方法等待进程结束,最多等待 5000 毫秒。
- 根据 waitForFinished() 方法的返回值判断进程是否已经结束,如果已经结束,则输出调试信息 “Python程序已结束”,否则输出 “Python程序无法结束”。
- 将 stopped 标志设置为 true。(其中的stopped本是线程终止的标志位
这段代码的主要作用是安全地停止一个正在运行的进程并确保其正常结束,同时设置一个标志来表明线程已经被停止。
在ui中有两个控件可以控制python程序的结束
一个是”断开连接“,一个是“关闭窗口”,如果直接点击ui右上角的x,则无法关闭python程序,而导致python程序一直在运行,相应的端口一直在被监听,而下一次python程序则无法正常运行
结束进程的原理是直接启用thread的stop函数,同时关闭套接字,
1 | void Server::on_DisconnectButton_clicked() |
首先,这段代码使用了一个条件语句 if,其目的是检查一个指向 server 对象的指针是否有效且 server 对象是否正在监听。这里的 server 对象可能是网络服务器的实例,因为它通常需要监听传入的连接请求。
如果这个条件被满足,那么代码会执行 server->close() 方法,关闭服务器监听的所有连接请求。然后,server->deleteLater() 方法会被调用,该方法会将 server 对象放入 Qt 的事件队列中,以便在稍后的时间被删除。这是为了确保删除 server 对象时不会干扰当前正在运行的代码。
最后,指向 server 对象的指针被设置为 nullptr,以避免出现悬空指针的情况,这样可以确保后续代码不会访问已经删除的对象。
总的来说,这段代码的作用是关闭并删除一个正在监听传入连接请求的服务器对象,并且在删除对象之后将指向该对象的指针设置为 nullptr,以避免悬空指针的问题。
4.1.4图像的获取以及展示
4.1.4.1端口的监听
出于多车的考量,有多个客户端,每个小车有一个客户端,地图再来一个客户端,一共四个客户端,地图的传输和小车摄像头图像传输类似,都是以二进制的格式进行传输,然后在windows端进行解码,所以使用一个python程序监听多个端口来实现这一需求
如果是单端口,服务器只能处理一个客户端的连接,而其他连接则必须等待,等到该连接完成,这会导致服务器的响应速度变慢,可能会导致客户端出现连接超时或者无法连接的情况
采用多端口的原因,是小车视频传输需要同时传输,特别是在处理大量数据(类似视频传输的时候),需要服务器的并发性能强一些,每个端口对应一个独立的线程,可以独立的处理客户端的视频传输,互不干扰。这样,在大量客户端连接时,可以更快的响应客户端的连接,同时也减少连接超时或者无法连接的情况。
1 | if __name__ == "__main__": |
在主程序中,定义了一个端口列表 ports,然后使用循环来遍历这个列表,并对于每个端口都创建一个新线程来调用 listen_thread 函数来监听该端口。这样就可以同时监听多个端口了。
1 | def listen_thread(port): |
这个线程主要用于监听端口号
通过创建一个 socket 对象,绑定一个指定的 IP 地址和端口号,监听客户端连接请求。当有客户端连接到服务器时,就会创建一个新的线程来处理该客户端的连接。
具体实现如下:
- 创建一个 socket 对象,指定网络类型和传输协议。这里使用的是 IPv4 和 TCP 协议,因此采用 socket.AF_INET 和 socket.SOCK_STREAM。
- 设置 SO_REUSEADDR 选项,表示可以重用该地址。这个选项设置为 1,可以在服务器关闭后再次启动时立即重用端口。
- 将 socket 对象绑定到指定的 IP 地址和端口号上。
- 开始监听连接请求。参数 1 表示最多只能有一个等待连接的连接请求排队,其他的连接请求会被拒绝。
- 进入一个无限循环,等待客户端的连接请求。
- 当有客户端连接时,就调用 accept 方法接收客户端连接,并返回一个新的 socket 对象和客户端的地址。新的 socket 对象用于与客户端进行通信。
- 创建一个新的线程,将新的 socket 对象和客户端地址作为参数传入 handle_client 函数,启动线程,开始处理客户端连接。
- 回到第 5 步,继续等待下一个客户端连接请求。
这样实现可以让每个客户端连接都在单独的线程中处理,从而实现多客户端并发连接。同时,通过设置 SO_REUSEADDR 选项可以让服务器在关闭后再次启动时立即重用端口,提高服务器的可靠性和稳定性。
单线程无法并发处理多个客户端的连接,
由于客户端连接处理需要不断地接受和解码数据,如果在主线程中处理,将会导致该线程一直被阻塞,同时无法处理其他客户端的连接,
同时客户端的处理如果出现异常,会导致主线程崩溃,如果所有客户端的连接都是在主线程中处理,那么一旦一个客户端出现异常,就会导致主线程崩溃,其他客户端也无法连接
所以在这里选择单独开一个线程用于接受视频传输的信息
4.1.4.2图像处理
4.1.4.2.1python中的struct.pack()和struct.unpack()
严格来说,python中没有结构体这种变量类型,其功能几乎均被class所取代,python 中的struct模块主要是用来处理与C交互时出现的结构化数据的,当然python间通讯也可以使用这样的打包与解包来传输结构化数据。
读入时先转换为Python的字符串类型,然后再转换为Python的结构化类型,比如元组(tuple)。一般输入的渠道来源于文件或者网络的二进制流。
在打包过程中,主要用到了一个格式化字符串(format strings),用来规定转化的方法和格式。python依据格式字符串的要求将多个参数的值按顺序进行一层包装,包装的方法由fmt指定,被包装的参数必须严格符合fmt。最后返回一个包装后的字符串。
解包返回一个由解包数据(string)得到的一个元组(tuple), 即使仅有一个数据也会被解包成元组。其中len(string) 必须等于 calcsize(fmt),这里面涉及到了一个calcsize函数。struct.calcsize(fmt):这个就是用来计算fmt格式所描述的结构的大小。
格式字符串(format string)由一个或多个格式字符(format characters)组成,对于这些格式字符的描述如下:
(实测在ROS节点中使用的数据类型与Python Type不同,需符合C Type)
4.1.4.2.2服务端使用
1 | def handle_client(conn, addr,port): |
再无限循环中,循环接受图片数据,首先接受4个字节的数据(是之前定义好的图片数据的总长度),然后使用struck.unpack将这四个字节的数解包成一个无符号整数(>I 表示使用大端字节序)。
使用一个 while 循环,不断接收图片数据,直到接收到的数据长度等于图片数据的总长度为止。conn.recv() 函数用于从 socket 连接中接收数据,每次接收的数据长度为 data_len - len(data),即接收图片数据时,每次接收剩余的数据长度。如果接收到的数据为空,则退出循环。
使用 OpenCV 的 cv2.imdecode() 函数,将二进制数据解码成一张图像。np.frombuffer() 函数将二进制数据转换成 numpy 数组,dtype=np.uint8 表示每个元素占一个字节(即 8 位),cv2.IMREAD_COLOR 表示解码为彩色图像。
4.2客户端
4.2.1小车端
4.2.1.1图像获取
1 | def depth_image_callback(msg): |
通过话题订阅订阅到小车的摄像头话题(/qingzhou/camera_link/image_raw),它的数据类型是Image,然后进入回调函数depth_image_callback,
line3:回调函数会在接受ros话题提供的深度图像时被执行,在这里我声明了两个全局变量,depth_image用于存储最新的深度图像,subscribed用于标记是否订阅到该话题。如果进入回调函数,subsrcibe会标为True
line6:使用 OpenCV 的 bridge 将 ROS 消息中的深度图像转换为 OpenCV 格式,并将图像的像素值存储在 depth_image 变量中。
line7:使用 cv2.imencode() 方法将深度图像编码为 JPEG 格式的字节数组,存储在 img_data 变量中。
line10:使用 socket 发送深度图像的大小和数据给另一端,s.sendall() 方法用于发送数据。
具体来说,发送数据的过程分为两个步骤:
首先,使用 len() 函数获取 img_data 字节数组的长度,并使用 to_bytes() 方法将其转换为一个 4 字节的大端字节序列,以便在接收端正确解析字节数组的长度。
然后,使用 s.sendall() 方法发送字节数组的长度和数据给另一端。在接收端,可以首先读取前 4 个字节来获取字节数组的长度,然后再读取相应长度的字节数组数据。
4.2.1.2小车与上位机的通信
目标是实现互相的通信,能够确认是否打开摄像头,以及与小车的基本通信,比如发送hello,目前是只是做了一个简单的学舌,当接收到hello1时,回传message1,表示小车1通信连接建立。出于实际的考量,小车需要在实际场景中与上位机进行通信,可能会需要回传小车位姿,坐标,以及方向等信息,同时上位机也需要给小车发送指令,命令小车,让小车能够被控制,达到遥控的目的。当前是实现了互相的通信,而并没有针对具体的应用场景进行设计
1 |
|
由于套接字接受信息的过程中会阻塞进程,所以出于同时传输信息,以及发送信息,实现一种类双工的通信方式,所以选择了多线程的方式,在独立的线程中,接受以及发送都不会受到干扰
line21:这代码使用了全局变量subscribe,如果订阅成功,subsribe会被设置为1,让后将shipinn编码成字节串,并使用 “!” 表示网络字节序。这是为了保证在发送和接收数据时,数据的字节顺序是一致的。
line23:使用 struct.pack() 将数据打包成一个字节串。其中,“!7sI” 表示使用网络字节序将一个长度为 7 的字节串和一个 4 字节的无符号整数打包成一个字节串。
line25:使用m.sendall()将数据发送到另一端