分区赛技术报告 上机开发
1.程序基本功能
上位机调度软件包含与导航功能包的socket通信实现、轻舟机器人坐标的显示、初始等待区、装货区、卸货区位置发送命令按钮、摄像头图像显示视觉识别效果,同时将视觉识别的图片进行标注,然后传回上位机。此外还可以直接从文件中加载目标点坐标,不必每次都手动输入即可实现通过上位机下发目标点,调度轻舟机器人去往指定位置,且会实时显示下位机位置,并对于目标点的到达信息以彩色字体高亮显示,使目标点切换更加迅速及时。
1.技术任务要求
- 上位机开发工具不做限制
- 视频回显视觉识别效果
- 显示实时画面和侦察情况
2.运行环境
2.1服务端
- 小车作为服务端
- 使用python编写Server用于接收
2.2客户端
- 选择windows作为服务端,因为有多个小车
- 使用qt5作为软件的集成开发环境
- 兼有python3.7写的python程序作为外部调用
3.流程分析
3.1功能分析
客户端
出于便于控制小车,以及考虑小车重启会出现问题,断联重连是否便于连接之类的问题,考虑将线上赛的服务端(windows端)改成客户端。
上位机程序可以查看视频,用于展示摄像头视觉识别效果
在主程序的主进程中用于信息的接受和传输,采用自定义通信协议的方法,如果连接到相应小车服务端,小车会发送信息
服务端
为了显示侦察情况,将小车的摄像头视频进行传输。接受到上位机传来的数据,有标志位用于阻塞
3.2通信建立
在服务端首先开始监听本地端口8888,等待客户端连接,用于传输确认信息,以及简单的通信。
同时在子线程中调用外部python程序。相当于新进程,同样采用tcp通信,开始监听本地端口9999.用于接受视频的传输
在另一个子线程中监听本地端口7777,用于传输小车坐标点
客户端选择连接服务端的端口号,而建立连接
信号的发出方来自ui,TCPSocket类以及自定义的MyThread1
经由QTCPSocket这个Qt5预定义的通讯类,我们可以轻松的与其他支持TCP协议的机器进行连接,且QTcpSocket中的信号会触发与之链接的槽函数,进而在连接完成后做好发送、接收与断连、退出等工作的准备
MyThread1中pos_update()信号来自自定义线程类重写的run()方法,在run中以0.3s一次的频率接收小车传来的位置信息与目标点到达信息
所有的槽函数都会对主界面的控件进行编辑以显示信息,故所有槽函数均属于ui这个Ui::MainWindow对象
3.3通信信息的收发
当用户在信息输入框输入普通信息并点击发送消息按钮后,发出的信号会触发on_send_clicked()函数,此函数首先将信息输入框的信息加上数据头,随后将数据包以UTF-8编码后写入TCPSocket的发送缓存区,等待自动发送,随后删除信息输入框的内容,服务器端在收到消息并解码后会检查数据头,验证为普通消息后,会将原文再次编码发出,客户端收到消息后解码并显示在消息记录窗口,同时弹窗提醒。
3.4客户端监控
3.4.1
作为附加功能,为了加快工期,我们选择了Qt调用python程序的模式完成监控功能(此程序也经pyinstaller打包为了exe文件,也可直接调用exe),同时服务端写成子线程,保证了服务端仍是一个程序来完成所有功能。点击监控按钮后,客户端会给现在连接到的服务器端口发送视频连接请求。使用“<<”操作符将两个字符串添加到“arguments”列表中。第一个字符串是相对路径“../QTmodel/tu/camera.py”,指定了要执行的Python脚本的位置。第二个字符串是(IP).toStdString(),是传递给Python脚本的IP地址参数。最后,通过调用“process->start(program, arguments)”来启动进程。
1 | QString program = "python"; |
其中(IP).toStdString()意为将服务器IP作为字符加入到命令中作为程序调用的传参,同时服务器开启子线程,子线程中会新建一个socket对象,绑定9999端口来进行视频传输,由服务器调用摄像头并使用opencv-python模块对图像进行读取并使用numpy模块进行编码后发送,客户端接收后解码并展示,客户端退出后,服务端也会终止线程,等待下次连接
3.4.2位置监控
使用Server功能包下面的getpos.py用于获取位置信息
1 | listener = tf.TransformListener() |
主要是利用了tf类下的TransformListener中的lookupTransform来监听两个坐标系之间的位置关系,base_link小车坐标系和map地图坐标系之间的关系即可以近似认为是小车的位置坐标。
值得一提的是,调用这个函数必须要写成try…except形式,否则会报错,而且根据我们调车过程中的经验来看,tf树读取出现问题的情况还是时有发生,所以这样写还是很有必要的。
获取到位置后,将其写入参数服务器由ROS Master维护,server的获取位置子线程自初始化时便使用listen阻塞进程,有连接后则0.1s发送一次从参数服务器读到的位置信息与到达目标点信息,上位机每0.3s读取一次缓冲区的全部内容防止阻塞,且可以保证全部内容中有至少一包完整的位置信息
3.5位置的上传
位置的上传使用了自定义的线程类MyThread1,继承自QThread,与视频线程相似,在整个程序初始化时使用QSocket连接到服务端的7777端口以接收位置信息,通过重写run方法来实现多线程,接收位置与到达目标点信息并展示,彩色的字体更加醒目,可以提示操作人员及时进行下一步操作。
3.6目标的传达
加载文件后,窗口会将焦点指向“前往装货区”按钮,且该按键会处于选中状态,直接敲击回车或空格键即可完成点击按钮的操作(见上文图中“前往装货区”按钮),且焦点与选中状态会在按键的槽函数中自动移向下一个目标点的按钮以实现在三个按钮间轮转,操作者不必移动鼠标即可实现目标点下发,可以有效节省下发时间,且仍可以在某个按钮被选中时选择另一个按钮,以目标点一的槽函数为例:
使用setDefault函数和setFocus
点击后本按键的选中状态即被解除,且将焦点移向下一个按键并置选中状态
由于目标点的坐标在比赛过程中不会改变,故我们在文件加载时将三个点坐标一次性发送,前往某个特定序号的目标点的命令仍由操作人手动完成三个目标点按钮下达,只不过不必每次都发送目标点坐标,同时可以做到上位机手动操控前往特定目标点
3.7目标的立废
利用move_base中使用的actionlib处理目标点相关信息
1 | typedef actionlib::SimpleActionClient<move_base_msgs::MoveBaseAction> MoveBaseClient; |
初始化部分,等待move_base的actionlib上线之后就可以进行导航的操作(如发送目标点,取消,判断是否到达等等)
1 | goal.target_pose.header.frame_id = "map"; |
消息类型是move_base_msgs::MoveBaseGoal,分别代表坐标系,时间戳,xyz三维坐标,xyzw四元数表示位姿(为了防止名字重复使用了abc代替)
ac.sendGoal();用于发布目标点
ac.cancelGoal();用于取消目标点
3.8程序的终止
一般情况下,服务器端父线程处于while(1)循环中,使用listen阻塞进程等待客户端连接,连接后同样有一个while(1)循环,等待客户端消息,若为视频连接请求则开启子线程,同时继续while循环,若收到断开连接请求则退出此循环,关闭socket并回到上层循环。回到上层循环后会再次初始化socket关键字,继续listen客户端连接;而若在连接有客户端的条件下收到结束程序请求,服务端会在关闭socket套接字后结束程序并退出。
4. 原理与代码分析
4.1 信号与槽
当某个事件发生之后,比如,按钮检测到自己被点击了一下,它就会发出一个信号(signal)。这种发出是没有目的的,类似广播。如果我们希望某个对象对这个信号感兴趣,就可以使用连接(connect)函数将想要处理的信号和自己的一个函数(称为槽(slot))绑定来处理这个信号。也就是说,当信号发出时,被连接的槽函数会自动被回调。这就类似于:当发生了感兴趣的事件,某一个操作就会被自动触发。
信号和槽是Qt特有的信息传输机制,是Qt设计程序的重要基础,它可以让互不干扰的对象建立一种联系。
槽的本质是类的成员函数,其参数可以是任意类型的。和普通C++成员函数几乎没有区别,它可以是虚函数;也可以被重载;可以是公有的、保护的、私有的、也可以被其他C++成员函数调用,本任务中的位置上传便使用了跨类传输参数的信号与槽。
4.2 通讯协议的制定
4.2.1 数据头
由于客户端发送数据频次不高,故服务器一次从socket缓存区读取1024位数据并解码,各种不同的信息都有6位字符作为数据头,单独的请求也均由6位字符组成,以方便服务器识别并作出反应,数据头对应如下表
| end!!! | 结束程序请求 |
|---|---|
| ___end | 断开连接请求 |
| camera | 连接视频请求 |
| ___cmd | 加载文件时传输点的坐标 |
| __cmd1 | 前往目标点1(装货区) |
| __cmd2 | 前往目标点2(卸货区) |
| __cmd3 | 前往目标点3(等候区) |
| change0 | 隐藏命令,可以强行更改现在正在前往目标点,防止卡死 |
| optcmd | 系统命令,用于更改目标点,初始化小车位姿等 |
| mesage | 普通消息数据头 |
4.2.2 编码格式
python中socket通信encode默认使用UTF-8编码,故服务端和客户端统一使用UTF-8编码。
客户端发送:
1 | if(!sendMessage.isEmpty()) |
服务端接收并回复:
1 | data = skt.recv(1024) |
4.2.3 结构体打包与解包
对于python中有struct模块,可以将C语言中传送的struct进行解包,同时也可以将多个数据打包为struct后发送,直接由C语言中的结构题变量承接,而无需逐个数据接收
4.2.3.1 结构体定义
客户端使用传统的结构体进行构造,服务端以类实现结构体功能,两端在定义结构体时都会调用构造函数进行初始化
4.2.3.2 python中的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)
| Format | C Type | Python type | Standard size |
|---|---|---|---|
| x | pad byte | no value | - |
| c | char | string of length 1 | 1 |
| b | signed char | integer | 1 |
| B | unsigned char | integer | 1 |
| ? | _Bool | bool | 1 |
| h | short | integer | 2 |
| H | unsigned short | integer | 2 |
| i | int | integer | 4 |
| I(大写i) | unsigned int | integer | 4 |
| l | long | integer | 4 |
| L | unsigned long | integer | 4 |
| q | long long | integer | 8 |
| Q | unsigned long long | integer | 8 |
| f | float | float | 4 |
| d | double | float | 8 |
| s | char[] | string | 1 |
| p | char[] | string | - |
| P | void * | integer | - |
4.3 图像获取与展示
4.3.1 验证身份
在客户端发送视频请求后,会与服务端的9999建立一个新通信,通信建立后客户端传输图像。
4.3.2传输图像
图像的获取是使用了opencv-python模块的函数
1 | camera = Carame_Accept_Object() # 执行这个类 |
由于需要将图像处理过的视频传输给上位机用于显示,所以这里选择订阅图像处理过的话题
将ros网络中的图片通过numpy转换成opencv中的mat格式。
使用NumPy的“frombuffer”函数,将这个二进制数据转换为NumPy数组。
“dtype=np.uint8”指定了数组的数据类型为8位无符号整数,也就是每个元素占用8个比特(即一个字节)。
然后,通过调用“reshape”函数,将数组重新塑形为三维数组。“msg.height”和“msg.width”表示图像的高度和宽度,“-1”表示在这个维度上自动推断数组的大小。这样可以根据高度、宽度和通道数自动确定数组的形状。
最终,将转换后的数组赋值给变量“img_data”,这个变量将包含原始二进制数据以图像形式呈现的表示。
cv2.imencode函数用于将图像数据编码为指定格式的图像。在这里,“.jpg”表示将图像编码为JPEG格式。函数的返回值包含两个元素,第一个元素是一个布尔值,表示编码是否成功,我们用下划线 "_" 进行占位,因为在这里不关心它。第二个元素是编码后的二进制数据,保存在变量“img_encode”中。
接下来,将“img_encode”转换为NumPy数组,即使用np.array(img_encode)。然后使用“.tobytes()”方法将这个数组转换为原始的字节序列。
最终,将转换后的字节序列赋值给变量“img_data_send”,这个变量将包含编码后的图像数据,可以用于传输或保存。
1 | img_data = np.frombuffer(msg.data, dtype=np.uint8).reshape(msg.height, msg.width, -1) |
4.3.3展示图像
1 | print(host) |
1 | img = cv2.imdecode(np.frombuffer(data, dtype=np.uint8), cv2.IMREAD_COLOR) |
使用NumPy的“frombuffer”函数,将字节序列“data”转换为NumPy数组,并指定数据类型为8位无符号整数(dtype=np.uint8)。
然后,调用OpenCV的“cv2.imdecode”函数对图像数据进行解码。该函数接受两个参数:第一个参数是从字节序列转换而来的NumPy数组,表示要解码的图像数据;第二个参数是解码选项,这里使用“cv2.IMREAD_COLOR”表示将图像以彩色模式解码。
4.4 位置的获取与传输
4.4.1 位置的获取
1 | listener = tf.TransformListener() |
由于我们程序之间需要多线程运转,实时传递参数便使用了ROS极为方便的参数服务器,不仅是python程序,c++程序也能轻松读取和写入有关信息,这就使得各个程序之间的协作关系变的更加紧密。
4.4.2 服务端读取与发送
获取到位置后,getpos.py会将x和y坐标写入参数服务器的“pos_x”和“pos_y”,而到达某个目标点后,simple_navigation_goals则会将“PR”改为对应目标点的序号,服务器的位置更新线程每0.1秒读取一次这三个参数,并将其发送到客户端
1 | def run(self): |
4.4.3 客户端接收与显示
客户端重写的run函数如下:
1 | //坐标更新线程 |
在while循环中,对从7777端口接收的数据按位读取,并判断是否有“p”开头的目标点到达信息(若没有则point为0,若有则point为p后数字),将自定义信号pos_update连同三个参数posx,posy,point发出,会进入槽函数update
1 | //坐标更新 |
4.5多线程处理
对于服务端,只有一个python程序,我们使用了三线程来处理消息/命令收发、位置上传和视频传输,其中使用了threading模块,由于任务较简单,我们仅使用了threading模块中的Thread类
对于服务端,由于并不存在太多的参数跨线程调用,故我们直接使用简单的重写QThread类run()方法的方式实现多线程
4.5.1 Thread类
Thread类内的对象与功能如下:
| 对象 | 描述 |
|---|---|
| name | 线程名(属性) |
| ident | 线程标识符(属性) |
| daemon | 线程是否是守护线程(属性) |
| init() | 实例化一个线程对象 |
| start() | 开启线程 |
| run() | 定义线程功能的方法(通常在子类中被应用开发者重写) |
| Barrier | 创建一个障碍,必须达到指定数量线程才开始运行 |
4.5.2 run()的重写
服务端代码在4.3.2 传输图像已进行分析,不再赘述
同样,位置传输线程的客户端与服务端的run方法在4.4.3中已经分析,不再赘述
客户端的视频线程,我们将在4.6 Qt调用python3中讨论
4.5.3 线程的创立与运行
1 | if message == "camera": |
在主线程的while循环中,若接到视频请求“camera”,服务端会创建一个Thread对象并开启,此进程便开始运行run()函数,客户端关闭视频窗口后,视频传输的socket连接断开,run()函数触发异常处理,退出循环并关闭套接字,等待下次线程再次被实例化
4.6qt外部调用python3
4.6.1多线程
调用同样使用到了多线程来调用外部程序。通过对Qt中的<QThread>头文件继承QThread类可以定义自己的线程类:
1 | class MyThread : public QThread |
4.6.2Qprocess
1 | process = new QProcess(this); |
首先,通过将字符串“python”赋值给变量“program”,指定要执行的程序是Python解释器。
然后,创建一个空的字符串列表“arguments”,用于存储要传递给Python程序的参数。
使用“<<”操作符将两个字符串添加到“arguments”列表中。第一个字符串是相对路径“../QTmodel/tu/camera.py”,指定要执行的Python脚本的位置。第二个字符串是“192.168.8.10”,是传递给Python脚本的IP地址参数。
接下来,通过调用“process->start(program, arguments)”来启动一个进程。这个进程将执行名为“camera.py”的Python程序,并将“192.168.8.10”作为参数传递给它。
QProcess类是Qt框架提供的一个用于启动外部进程的工具。通过指定要执行的程序和参数,可以使用它来与外部程序进行交互。在这个例子中,它被用来启动Python解释器并执行指定的脚本。
4.7.1程序整体思路
该部分主要实现了
1、目标点的发布,控制导航程序前往某一设定好的目标点
2、在小车行进过程中遇到红灯及时停车
3、切换导航控制与视觉控制权限
4、出S弯与汉字视觉识别的切换
5、汉字识别与倒车入库的衔接
6、优化原有导航机制,使其更加贴合任务需求
4.7.2与红绿灯的衔接
无论是红绿灯程序还是s弯程序,只要运用到摄像头的部分就和电脑的性能离不开关系,毕竟导航本身就已经占用了绝大部分电脑的cpu使用,再加上视觉识别势必会加重负担,所以如何能够尽量短的时间使得摄像头程序开启就成了一个问题。
我们仍然采用的是多线程的方式,通过参数服务器来进行程序之间的沟通。
红绿灯在装货区和卸货区之间,所以仅需要在小车达到装货区之后再激活红绿灯程序即可,这里我们使用的标志位是Tstart,在Tstart是false的时候红绿灯程序进入待机等待状态,直到Tstart变为true之后才打开摄像头识别红绿灯,这样就能减轻小电脑的负担。
红绿灯程序实际是辅助调度系统进行判断的程序,并不用牵扯到控制模式的转换,遇到绿灯直接忽略继续按照原定目标点走,遇到红灯红绿灯程序会发送红灯信号给中央调度程序,取消目标点,等到绿灯再发送目标点。
1 | while(!pathpointchange) |
4.7.3与s弯的衔接
S弯使用了tf树辅助定位的方法
利用tf树定位获取小车的位置信息,通过已发送的三个目标点和地图比例计算出s弯的起点和终点位置,小车到达起点或终点附近的时候发送信息给s弯程序进入或者退出,而s弯程序判断到达起点会告知调度系统取消目标点,进入视觉导航,到达终点后自动退出视觉导航回到待机状态并告知调度系统继续导航。
1 | #...接getpos的程序 |
4.7.4与倒车入库文字识别的衔接
会通过getpos.py来判断小车的位姿是否为正,当小车为子为正时,会将小车停下来用于拍照,将汉字照片保存,提供给文字识别程序。
当文字识别有结果之后,会置w_flag,通过w_begin表示识别开始,
在中央调度系统中会获取w_begin以及w_flag用于判断小车是否进行视觉识别以及视觉识别结果如何。当判断有结果后,会将dao_start置1,用于开启倒车程序。倒车通过获取标志位,判断是应该启动去哪一个库的程序。
这么做的原因是为了判断阻止死锁的发生,当判断完成识别之后再进行下一步操作。
4.7.5qt的优化
圆角以及修改控件字体
这些都可以通过直接修改样式表style list
使用qss语言(类似于css语言
可以修改颜色以及粗细
等等之类的
1 | QPushButton{ |

多级界面的切换
通过hide隐藏界面
通过show显示下一级界面
1 | void MainWindow::on_pushButton_2_clicked() |



视频嵌入
1 | WId winId = (WId)FindWindow(NULL, L"img"); // 使用FindWindow函数根据窗口标题查找窗口的句柄(HWND),并将其转换为WId类型 |

4.8一些小技巧
IP的统一
对于远程连接,IP的获取是第一位的,在服务端的socket绑定IP时,虽然可以写一个固定的IP,或者gethostname,但前者一旦IP更改就得更改文件中的IP,后者的数组顺序不确定,经常获取到127.0.0.1,这样的程序对于没有太多计算机网络知识的用户也是不够友好的,所以我们使用了使用比较广泛的域名解析服务器8.8.8.8,在程序一开始时连接此服务器,获取自身IP后作为全局变量local_addr供socket对象使用,不必再每次都手动更改程序中的IP地址,且在主函数中有语句<font style="color:#000000;background-color:#ffffff;">print(socket.gethostbyname(local_addr) + </font><font style="color:#a31515;background-color:#ffffff;">":8888 等待连接..."</font><font style="color:#000000;background-color:#ffffff;">)</font>本地运行时可以输出本机IP,有ifconfig/ipconfig的功能
1 | #########GetIP########## |
彩色文本的显示
在4.4.3 客户端接收与显示中,TEXT_COLOR_x为HTML格式的标签语言,其可被QT中的textBrowser所识别而显示为彩色文本,可以及时提醒操作者小车已到达相应位置
1 |
取消目标点的妙用
除了可以用于切换控制模式和红灯停车之外,提前取消目标点还有妙用
在调车的过程中,我们发现小车在接近目标点附近可以接收的位置和姿态之时仍然需要大量的时间进行精细化的位姿调整,这会浪费大量的时间,所以结合TF树确定位置和姿态(见3-2),就可以人为手动停止小车,从而使得小车停在可以接收的范围内,以可以接收的姿态停车,进而避免小车大量调整位姿,这一举动大大减少了停车的时间,加快了整体的速度。理论上调整导航程序的宽容度同样可以达成这一目的,但是调整宽容度对应的操作是全局相关,对于某一特定的目标点,我们采取了不同的宽容模式,如装货区要求姿态较为精准,而卸货区就没有这一要求。
5. 功能总结
- 客户端与服务器之间的Socket通讯传递字符串
- 客户端与服务器之间的Socket通讯传递打包的结构体命令并解包
- 客户端调用python程序接收服务器子线程传来的编码视频信息并解码
- 客户端加载本地坐标文件作为目标点预设传输到下位机
- 客户端与服务端通过子线程传输下位机实时位置与目标点到达信息并在上位机显示
- 实现小车上多个功能包之间交接控制权并在三个目标点之间轮转
- 客户端与服务端在一个子线程传递视频,另一个子线程传递位置信息的同时在主线程使用另一端口正常通讯