今年4月初开始接触PyQt5,5月开始重新从头学ROS(去年自学过一个月,早就已经啥都不记得了...),吭哧吭哧倒腾了许久,总算是完成了以下三项任务:
1. 基于PyQt5编写GUI界面,界面上包含按钮、编辑框、曲线图等
2. 基于rospy进行简单的自定义消息的发布和订阅
3. 将ROS订阅节点上接收到的实时数据以动态曲线的形式绘制在用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快速开发与实战》这本书)
这部分要从一开始的创建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两个文件
在上一步创建的功能包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的那么多了?因为大部分东西我也是照葫芦画瓢的,深究起来也讲不太明白...)
在上述的节点收发程序中,用到了自定义类型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/教程,其实还有其他一些技术贴,不过当时是在网上到处翻的,参考了哪些贴子的东西也不太记得了,要是有哪位作者看到我的内容是借鉴了你写的东西,还请留言啊,我给你把参考链接加上去...)
对于一个ros工作空间中的各种文件夹,这真的是,每次找文件都是到处戳来戳去,心塞...这些文件夹互相之间的关系和作用,可以看这个知乎专栏的一篇文章:https://zhuanlan.zhihu.com/p/51430594
这里呢,我只想把本文的ros工作空间中的东西理出来展示一下,直观一点嘛...
上图中有些框里打了六个点是因为里头有好多文件夹或者文件,然后又不想展开写了,就偷懒打省略号了(字写得一般般,纸是桌子上随手扯来的一张医院开的药品单,背面空白的就当草稿纸了...) 。这个ros工作空间中的文件构成大概就是这样啦。
这件事情,可以分解为两个task来完成,第一是在GUI界面上画动态曲线图,第二是画图的数据来自于从ros节点上接收到的数据。
在前面第一部分介绍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()
可以看到,横坐标轴起点不是0,这是因为我截图的时候横坐标轴在往左移动,0的那一部分已经不见了,动图不会弄,只能这样意思一下了......
其实对于横坐标的设置有很多不同风格的,让0固定不动,随着数据的增多,x轴上的数据范围越来越大,或者坐标轴固定不动,只有曲线到处乱跳等等,各种设置都是可以做到的,具体的操作也记不住了,不过pyqtgraph有一系列可通过运行来访问的示例,终端上打开python,运行这两句就可以了:
import pyqtgraph.examples
pyqtgraph.examples.run()
或者去网上找找别人写的方法都可以。
上面画动态曲线图,数据来源是用生成随机数的方法自己造的。那么,我们自然会想到,把数据来源改成从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的东西...上面这段代码运行完了之后是这样的:
从横坐标上的数值能够看出来,这里的横坐标还是会向左移动的。
至此,整个任务完成啦!
这篇文章其实5月底就开始写了,不过才刚写了个开头,就因为各种其他的事情忙来忙去而耽搁了。而那只有两段开头的草稿就在草稿箱里躺了快3个月。到8月底的时候终于想起来还有篇文章没完成,并且再不写就真的要忘了这些东西是怎么做出来的了!于是仔细回顾当时的各种细节,重新再做了一遍,确保各个环节没啥问题了,开始接着写。然后又没写几段,又有事情的优先级排在它前面了,然后又拖了大半个月,终于在这周花了2个晚上和半个上午给它写完了......
写文章这种事情,我自己的理解是,要么就一直写一直写,变成习惯了,任你再忙都能找到时间写出来的。然而我这目前为止算上这篇才写了两篇的,习惯自然是没有养成的,又有别的事情要花大精力去操心,这一拖就现在了。其实说起来,这将近4个月的时间里,真的找不到一点时间来写它吗?那当然不是的,毕竟也还没有忙到这种地步,说白了还是拖延症以及没有利用好每天的时间。把那些发呆刷微博追剧看综艺的时间,随便挪一些出来,其实早就能写完的。最后还是在每天拖延的过程中,实在罪恶感太强烈了,加上真的不想让自己当初用心思考的东西就这么白白忘记了,逼着自己终于写出来了。。。以后还是要合理规划时间,该记录就及时写下来,不然哪天真的忘得差不多了,那也对不起当初焦头烂额探索了好久的自己啊。