基于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将ROS节点上接收到的实时数据动态绘制在GUI界面上_第1张图片

总的来说,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文件的内容如下:

# pytalker.py内容

import rospy
from topic.msg import gps  # 这个topic.msg是后面自定义消息出来的,先放这里,后面再详细说

def talker():
    pub = rospy.Publisher('gps_info', gps, queue_size = 10)
    rospy.init_node('pytalker', anonymous = True)
    
    rate = rospy.Rate(1)  # 数据发送的频率,这里设定为1秒发一次
    x = 1.0
    y = 2.0
    state = 'working'
    while not rospy.is_shutdown():
        rospy.loginfo('Talker : GPS: x = %f, y = %f', x, y)
        pub.publish(gps(state, x, y))
        x = 1.83 * x
        y = 1.83 * y
        rate.sleep()

if __name__ == '__main__':
    talker()
# pylistener.py内容

import rospy
import math
from topic.msg import gps

def callback(gps):  # callback函数,对接收到的数据做一些处理
    distance = math.sqrt(math.pow(gps.x, 2)+math.pow(gps.y, 2))   # 做个简单的数学计算
    rospy.loginfo('Listener: GPS: x=%f, y=%f, distance=%f, state=%s', gps.x, gps.y, distance, gps.state)

def listener():
    rospy.init_node('pylistener', anonymous=True)
    rospy.Subscriber('gps_info', gps, callback)  # 接收消息时要调用callback回调函数
    rospy.spin()

if __name__ == '__main__':
    listener()

(为啥这两个py文件的注释没有第一part的那么多了?因为大部分东西我也是照葫芦画瓢的,深究起来也讲不太明白...)

3. 自定义消息

在上述的节点收发程序中,用到了自定义类型gps的数据,因此需要自定义gps消息。

在topic文件夹下创建msg文件夹,并在msg文件夹下创建文件gps.msg,其内容为:

string state  # 工作状态
float32 x  # x坐标
float32 y  # y坐标

以上就定义了一个gps类型的消息,可以把它理解成一个C语言中的结构体。

当创建完了gps.msg文件之后,需要修改topic文件夹下的CMakelists.txt和package.xml这两个文件中的内容,具体如下:

# CMakelists.txt中需要改动的部分

find_package(catkin REQUIRED COMPONENTS
  roscpp
  rospy
  std_msgs
  message_generation
)

add_message_files(
  FILES
  gps.msg
)

generate_messages(
  DEPENDENCIES
  std_msgs
)

catkin_package(
  CATKIN_DEPENDS message_runtime
)
# package.xml中需要改动的部分,其实就是把下面这两句的注释给它去掉

message_generation
message_runtime

完成上述这些操作之后,再回到工作空间去编译,也就是到ros_topic文件夹下再跑一遍catkin_make。这次编译之后,会在./devel/include/topic/文件夹下生成一个gps.h文件。

一切准备工作完成,可以进入到topic文件夹下的scripts文件夹中,分别打开两个终端各自运行pytalker.py和pylistener.py了。然而按我的写作风格,以及我这种白菜水平,怎么可能这么顺利,这中间当然有插曲!!!这个时候运行这两个.py文件它不是给你报缺了什么python模块就是报ModuleNotFoundError: No module named 'topic'!!!

如果只是报缺了什么模块,那我们用pip安装一下也就好了(用pip安装的过程中,也学到了:如果用pip install xxx 装不上xxx模块的话,就在它后面加上 --user,把这条指令变成pip install xxx --user,一般来讲就可以装上了。此外,还有同事告诫过我,千万不要用sudo install xxx,至于是为什么嘛,其实我还是没有太理解...暂且把它当成一种习惯来培养好了......)

但是,对于ModuleNotFoundError: No module named 'topic'这种报错,它的处理方法,知道的人就觉得这不是理所当然要做的嘛,而不知道的人就是感觉死也不知道啊!!!(从这个阶段过来的,深有感触,真的好气人......)其实,就是在运行两个.py文件之前,先分别在两个终端打上$ source /home/xxx/ros_topic/devel/setup.sh,也就是需要source一下devel文件夹中的setup.sh文件。

现在,可以跑pytalker.py和pylistener.py两个文件了,运行结果是这样的:

 

可以看到,分别在两个终端中运行pytalker.py和pylistener.py,就是一个发数据,一个收数据。

(备注:rospy的学习和上述例子的演示参考了https://sychaichangkun.gitbooks.io/ros-tutorial-icourse163/content/教程,其实还有其他一些技术贴,不过当时是在网上到处翻的,参考了哪些贴子的东西也不太记得了,要是有哪位作者看到我的内容是借鉴了你写的东西,还请留言啊,我给你把参考链接加上去...)

4. 梳理一下这些个文件夹和文件的关系

对于一个ros工作空间中的各种文件夹,这真的是,每次找文件都是到处戳来戳去,心塞...这些文件夹互相之间的关系和作用,可以看这个知乎专栏的一篇文章:https://zhuanlan.zhihu.com/p/51430594

这里呢,我只想把本文的ros工作空间中的东西理出来展示一下,直观一点嘛...

基于PyQt5将ROS节点上接收到的实时数据动态绘制在GUI界面上_第2张图片

上图中有些框里打了六个点是因为里头有好多文件夹或者文件,然后又不想展开写了,就偷懒打省略号了(字写得一般般,纸是桌子上随手扯来的一张医院开的药品单,背面空白的就当草稿纸了...) 。这个ros工作空间中的文件构成大概就是这样啦。

三、将ROS订阅节点上接收到的实时数据以动态曲线的形式绘制在用pyqt编写的GUI界面上

这件事情,可以分解为两个task来完成,第一是在GUI界面上画动态曲线图,第二是画图的数据来自于从ros节点上接收到的数据。

1. 在GUI界面上画动态曲线图

在前面第一部分介绍GUI界面设计的时候,我们用了pyqtgraph这个模块来做绘图控件,并且对绘图控件的背景和画笔做了一些设置。那么,要在绘图控件上画曲线,自然就是要告诉画笔你要在哪个坐标位置的地方画线。于是,对于前面已完成了GUI界面设置的代码段,再添加对画笔的进一步设置,即可完成绘制动态曲线图的任务。代码如下:

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

data = 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中,累加器就+1
        self.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  # 定时器刷新时间为1s
    sys.exit(app.exec_())

if __name__ =='__main__':
    main()

这段代码跑起来的效果是这样的:基于PyQt5将ROS节点上接收到的实时数据动态绘制在GUI界面上_第3张图片

 可以看到,横坐标轴起点不是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 string

data = []  # 要画的数据存在这里

class Signal(QWidget):  # 先写GUI界面
    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, x, y):        
        data.append(y)  # ROS上传来的y值存在data里
        self.ptr1 = x   # ROS上传来的x值赋给ptr1
        self.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的东西...上面这段代码运行完了之后是这样的:

基于PyQt5将ROS节点上接收到的实时数据动态绘制在GUI界面上_第4张图片

从横坐标上的数值能够看出来,这里的横坐标还是会向左移动的。

至此,整个任务完成啦!

 

结语:

这篇文章其实5月底就开始写了,不过才刚写了个开头,就因为各种其他的事情忙来忙去而耽搁了。而那只有两段开头的草稿就在草稿箱里躺了快3个月。到8月底的时候终于想起来还有篇文章没完成,并且再不写就真的要忘了这些东西是怎么做出来的了!于是仔细回顾当时的各种细节,重新再做了一遍,确保各个环节没啥问题了,开始接着写。然后又没写几段,又有事情的优先级排在它前面了,然后又拖了大半个月,终于在这周花了2个晚上和半个上午给它写完了......

写文章这种事情,我自己的理解是,要么就一直写一直写,变成习惯了,任你再忙都能找到时间写出来的。然而我这目前为止算上这篇才写了两篇的,习惯自然是没有养成的,又有别的事情要花大精力去操心,这一拖就现在了。其实说起来,这将近4个月的时间里,真的找不到一点时间来写它吗?那当然不是的,毕竟也还没有忙到这种地步,说白了还是拖延症以及没有利用好每天的时间。把那些发呆刷微博追剧看综艺的时间,随便挪一些出来,其实早就能写完的。最后还是在每天拖延的过程中,实在罪恶感太强烈了,加上真的不想让自己当初用心思考的东西就这么白白忘记了,逼着自己终于写出来了。。。以后还是要合理规划时间,该记录就及时写下来,不然哪天真的忘得差不多了,那也对不起当初焦头烂额探索了好久的自己啊。

你可能感兴趣的:(基于PyQt5将ROS节点上接收到的实时数据动态绘制在GUI界面上)