基于PyQt5将ROS节点上接收到的实时数据动态绘制在GUI界面上
今年4月初开始接触PyQt5,5月开始重新从头学ROS(去年自学过一个月,早就已经啥都不记得了...),吭哧吭哧倒腾了许久,总算是完成了以下三项任务:
1. 基于PyQt5编写GUI界面,界面上包含按钮、编辑框、曲线图等
2. 基于rospy进行简单的自定义消息的发布和订阅
3. 将ROS订阅节点上接收到的实时数据以动态曲线的形式绘制在用PyQt5编写的GUI界面上
过程是艰辛的,没有太多基础,从零开始探索真的很痛苦,在这期间,心态不知道爆炸了多少次。但是当一个一个问题得到了解决,内心也是有成就感和自信心的。
闲言少叙,按上述列出的三项任务的顺序,我们开始说正事儿:
一、基于PyQt5编写GUI界面,界面上包含按钮、编辑框、曲线图等
一般用PyQt5写GUI界面有两种方法:1. 直接写python代码,实现各控件的布局安排和触发机制;2. 借助Qt Designer,以一种可视化的方式完成拖控件调整布局和各种槽函数的设置,并自动生成.py文件。这篇文章的重点其实不在界面的设计上,因此下面就举个简单的例子,直接随便写点python代码就好了。当然咯,在编写布局和功能都比较复杂的GUI界面的时候,按我的习惯,其实是用Qt Designer设计界面,生成.py文件后,再通过添加代码完善功能。
以下就是一段包含一个按钮、一个文本编辑框、一个绘图控件的python代码实例:
# 导入需要的各个模块
import sys
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import pyqtgraph as pg # pyqtgraph是一个很好用的绘图模块class Signal(QWidget):def __init__(self): super(Signal, self).__init__()self.initUI()def initUI(self):self.setGeometry(0, 0, 1200, 800) # 设置GUI界面的大小self.setWindowTitle('signal_analysis') # 界面窗口名称layout_chart = QtWidgets.QGridLayout() # 表格布局self.setLayout(layout_chart)pg.setConfigOption('background', 'w') # 把绘图控件的背景设置为白色,默认是黑色的self.pw = pg.PlotWidget()self.pw.showGrid(x=True, y=True) # 绘图控件显示网格self.curve = self.pw.plot(pen='k') # 画笔颜色设置layout_chart.addWidget(self.pw, 0, 0, 9, 10) # 绘图控件在整个GUI界面上所占的区域设置,这里表示绘图控件在表格布局的第一行第一列,并且占据9行10列的区域bt1 = QPushButton('Button', self) # 按钮控件layout_chart.addWidget(bt1, 10, 0, 1, 1) # 按钮控件在表格布局中的第11行第1列,因为前面设置了绘图控件占9行,所以前面9行都是绘图控件,第10行空出来,不然图像和别的控件挨太近不好看text1_edit = QLineEdit("", self) # # 文本编辑框layout_chart.addWidget(text1_edit, 10, 1, 1, 2) # 文本编辑框放在按钮旁边,占2列宽def main():app = QtWidgets.QApplication(sys.argv)gui = Signal() gui.show() sys.exit(app.exec_())if __name__ =='__main__':main()
以上代码大部分语句都打了注释,还是很好理解的。代码运行的结果就是得到了如下界面:

总的来说,PyQt5的功能还是很强大的,需要多加练习以达到熟能生巧的状态,我还在生巧的过程中。。。
(备注:PyQt5的学习,主要参考了知乎专栏:https://zhuanlan.zhihu.com/p/48373518?utm_source=wechat_session&utm_medium=social&utm_oi=61921436893184 和《PyQt5快速开发与实战》这本书)
二、基于rospy进行简单的自定义消息的发布和订阅
这部分要从一开始的创建ros工作空间写起,不然时间久了我自己都要忘记了...
1. 创建ros工作空间和ros功能包
自己找个合适的文件夹位置,然后创建一个空的文件夹,这里我给自己的文件夹命名为ros_topic,进入这个topic文件夹,在里面创建一个空文件夹src,然后再进入这个src文件夹,在这个src文件夹下打开终端,然后是以下一系列指令:
$ catkin_init_workspace //你会发现src文件夹下出现了一个CMakelists.txt的文件,并且这个文件的图标右下角还带了一把锁
$ cd .. //返回到src的上一级文件夹,也就是ros_topic路径下
$ catkin_make //编译工作空间,这只能在工作空间路径下进行,完成这步之后,就会发现ros_topic路径下,除了之前自己创建的src文件夹,还多出来了devel和build文件夹,里头有很多编译过程中自动生成的文件
到这里,工作空间已经创建完了,接下来创建ros功能包
$ cd src //还是先进入到ros_topic下的src文件夹
$ catkin_create_pkg topic std_msgs rospy roscpp //topic是功能包名字,std_msgs、rospy、roscpp是依赖项,此时src路径下多了一个topic文件夹
$ cd .. //返回到src的上一级文件夹,也就是ros_topic路径下,因为要再次编译工作空间啦
$ catkin_make //编译工作空间,这时候会发现src路径下的topic文件夹里有include和src两个文件夹以及CMakelists.txt和package.xml两个文件
2.创建节点
在上一步创建的功能包topic路径下创建一个文件夹scripts,并在scripts文件夹下创建两个.py文件,这里我就给它们一个命名为pytalker.py,另一个命名为pylistener.py,分别用于发送和接收消息。(其实有一点我没想明白:一般来讲我们都会把源代码放在src文件夹下,但我看过的各种教程或者贴子什么的,都教我新建一个scripts文件夹然后把py代码放进去,而前面第二次通过catkin_make在topic文件夹下编译出来的src文件夹是空的,并且就放着它空着不用)
pytalker.py和pylistener.py文件的内容如下:
import sys
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import pyqtgraph as pg
import numpy as npdata = np.random.normal(size=100) # 这里生成一些随机数来画到界面上class Signal(QWidget):ptr1 = 0 # 这是个累加器def __init__(self): super(Signal, self).__init__()self.initUI()def initUI(self):self.setGeometry(0, 0, 1200, 800)self.setWindowTitle('signal_analysis')layout_chart = QtWidgets.QGridLayout()self.setLayout(layout_chart)pg.setConfigOption('background', 'w')self.pw = pg.PlotWidget()self.pw.showGrid(x=True, y=True)self.curve = self.pw.plot(pen='k')layout_chart.addWidget(self.pw, 0, 0, 9, 10)bt1 = QPushButton('Button', self)layout_chart.addWidget(bt1, 10, 0, 1, 1)text1_edit = QLineEdit("", self)layout_chart.addWidget(text1_edit, 10, 1, 1, 2)def draw_signal(self): # 这个函数用来完成画图的工作 data[:-1] = data[1:] # 把data已有的数据放在data数组的最前面data[-1] = np.random.normal() # 在data数组的末尾再添加一个随机数self.ptr1 += 1 # 每添加一个随机数到data中,累加器就+1self.curve.setData(data) # 调用setData(这个模块自带的函数)把要画线的数值给画笔self.curve.setPos(self.ptr1, 0) # 加上这句话,横坐标轴就可以动起来了def main():app = QtWidgets.QApplication(sys.argv)gui = Signal()gui.show()gui.draw_signal() # 调用draw——signal()在绘图空间上画图,这次调用只画最开始生成的100个随机数timer = pg.QtCore.QTimer() # 设一个定时器timer.timeout.connect(gui.draw_signal) # 每次定时器到时间了就调用一次draw_signal(),每次调用都会在data后面再加一个随机数,然后画图timer.start(1000) #refresh time # 定时器刷新时间为1ssys.exit(app.exec_())if __name__ =='__main__':main()
这段代码跑起来的效果是这样的:
可以看到,横坐标轴起点不是0,这是因为我截图的时候横坐标轴在往左移动,0的那一部分已经不见了,动图不会弄,只能这样意思一下了......
其实对于横坐标的设置有很多不同风格的,让0固定不动,随着数据的增多,x轴上的数据范围越来越大,或者坐标轴固定不动,只有曲线到处乱跳等等,各种设置都是可以做到的,具体的操作也记不住了,不过pyqtgraph有一系列可通过运行来访问的示例,终端上打开python,运行这两句就可以了:
import pyqtgraph.examples
pyqtgraph.examples.run()
或者去网上找找别人写的方法都可以。
2. 将从ROS节点上接收到的数据绘制在GUI界面上
上面画动态曲线图,数据来源是用生成随机数的方法自己造的。那么,我们自然会想到,把数据来源改成从ros节点上读到的数据不就好了嘛。具体怎么操作呢?
再翻一下前面写的ros节点收发数据的两段代码:pytalker.py负责把数据发送出来,pylistener,py负责接收数据。我们现在的任务是把接收到的数据画成曲线,所以,对于发送数据的pytalker.py,就还让它发数据好了,不需要动它。而对于接收数据的pylistener.py,原来的代码就是只接收了数据就完事了,而我们要做的是:不仅要让它接收数据,还要添加上有GUI界面设置的代码段,并且把GUI界面上画笔数据的来源设置为ros接收到的数据。而ros节点接收数据时,是通过调用callback回调函数的形式来完成的。因此回调函数部分,除了接收数据之外,还有画图的操作。所以,对于pylistener.py,要把它改造成下面这样:
import rospy
import math
from topic.msg import*import sys
from PyQt5 import QtWidgets
from PyQt5.QtWidgets import *
from PyQt5.QtGui import *
from PyQt5.QtCore import *
import pyqtgraph as pg
import numpy as np
import stringdata = [] # 要画的数据存在这里class Signal(QWidget): # 先写GUI界面ptr1 = 0def __init__(self): super(Signal, self).__init__()self.initUI()def initUI(self):self.setGeometry(0, 0, 1200, 800)self.setWindowTitle('signal_analysis')layout_chart = QtWidgets.QGridLayout()self.setLayout(layout_chart)pg.setConfigOption('background', 'w')self.pw = pg.PlotWidget()self.pw.showGrid(x=True, y=True)self.curve = self.pw.plot(pen='k')layout_chart.addWidget(self.pw, 0, 0, 9, 10)bt1 = QPushButton('Button', self)layout_chart.addWidget(bt1, 10, 0, 1, 1)text1_edit = QLineEdit("", self)layout_chart.addWidget(text1_edit, 10, 1, 1, 2)def draw_signal(self, x, y): data.append(y) # ROS上传来的y值存在data里self.ptr1 = x # ROS上传来的x值赋给ptr1self.curve.setData(data) # 纵坐标self.curve.setPos(self.ptr1, 0) # 横坐标def callback(gps, gui):distance = math.sqrt(math.pow(gps.x, 2)+math.pow(gps.y, 2)) rospy.loginfo('Listener: GPS: x=%f, y=%f, distance=%f', gps.x, gps.y, distance)gui.draw_signal(gps.x, gps.y) # 回调函数里调用draw_signal画图def main():app = QtWidgets.QApplication(sys.argv)gui = Signal() gui.show() rospy.init_node('pylistener', anonymous = True) # ros节点初始化rospy.Subscriber('gps_info', gps, callback, gui) # ros节点接收数据sys.exit(app.exec_())rospy.spin()if __name__ =='__main__':main()
其实这就是个GUI界面和ros接收数据的代码合体。现在来看这段代码,一副思路清晰条理清楚的样子 ,当初研究怎么把GUI和pylistener.py结合在一起,那可是费了一番周折的。一开始我把重点放在了GUI界面上,总觉得应该是对draw_signal()函数做一番改造就可以了,总想在draw_signal()里头调用rospy的东西,可以怎么尝试都不对,焦头烂额了好久。后来经人各种点拨提示明示暗示,才发现一开始的心思就动错了,这段代码,从本质上来讲还是做ros节点的消息接收,所以应该在回调函数里调GUI的东西...上面这段代码运行完了之后是这样的:

从横坐标上的数值能够看出来,这里的横坐标还是会向左移动的。
至此,整个任务完成啦!
结语:
这篇文章其实5月底就开始写了,不过才刚写了个开头,就因为各种其他的事情忙来忙去而耽搁了。而那只有两段开头的草稿就在草稿箱里躺了快3个月。到8月底的时候终于想起来还有篇文章没完成,并且再不写就真的要忘了这些东西是怎么做出来的了!于是仔细回顾当时的各种细节,重新再做了一遍,确保各个环节没啥问题了,开始接着写。然后又没写几段,又有事情的优先级排在它前面了,然后又拖了大半个月,终于在这周花了2个晚上和半个上午给它写完了......
写文章这种事情,我自己的理解是,要么就一直写一直写,变成习惯了,任你再忙都能找到时间写出来的。然而我这目前为止算上这篇才写了两篇的,习惯自然是没有养成的,又有别的事情要花大精力去操心,这一拖就现在了。其实说起来,这将近4个月的时间里,真的找不到一点时间来写它吗?那当然不是的,毕竟也还没有忙到这种地步,说白了还是拖延症以及没有利用好每天的时间。把那些发呆刷微博追剧看综艺的时间,随便挪一些出来,其实早就能写完的。最后还是在每天拖延的过程中,实在罪恶感太强烈了,加上真的不想让自己当初用心思考的东西就这么白白忘记了,逼着自己终于写出来了。。。以后还是要合理规划时间,该记录就及时写下来,不然哪天真的忘得差不多了,那也对不起当初焦头烂额探索了好久的自己啊。
本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!
