前言
通过GUI进行信号的动态展示在很多方面都有着现实的需求,而UDP作为一种高速网络传输协议,在一些实时性要求很高的情况下也应用比较广泛。例如:在有源控制算法中,我们期望能够实时查看死循环中误差信号的收敛情况,这时候UDP和GUI绘图就派上用场了。
1. Qt 的用法
? pyqt5是qt的python版本,其主要是以对象的形式存在的,在编程的过程中无法可视化,带来诸多的不便。为了简化pyqt5的界面设计环节,我们可以使用qt中的设计器Qt Designer (C:\Qt\5.12.11\mingw73_32\bin\designer.exe)来设计图形界面,生成的图形界面通常保存在*.ui后缀的文件中。
?pyqt5可以直接调用.ui文件,也可以通过pyqt5自带的pyuic.exe将设计好的.ui文件转换为.py格式的pyqt5类,供其他模块调用。
?Qt 的安装教程在CSDN论坛上有很多,在此不再赘述了,建议使用 Qt5, 高版本的Qt6目前还没有被 matplotlib 纳入后端支持。
2. Pycharm 设置
?首先安利一波,Pycharm在代码颜色主题、功能界面、python环境切换、打开终端、jupyter notebook支持、变量查看、Markdown支持、console多开等方面具有较高的便利性,因此本人主要使用pychrm 进行相关代码的开发,推荐使用。
2.1 安装 Pyqt5 和 pyinstaller 包
?在pycharm底部打开终端,并输入如下代码安装pyqt5包和pyinstaller包。Pyinstaller包是用来将pyqt5GUI设计打包成exe可执行文件的工具,有了这个工具,就可以将程序拷贝到其他windows电脑上使用。
pip install pyqt5,pyinstaller
?后续还需要安装matplotlib包,按照类似的方式进行安装,不再赘述。在安装了pyqt5后,matplotlib会自动以pyqt5为后端,绘制出来的图像效果更好,所带的工具栏也更加实用,推荐日常使用。
2.2 Pycharm pyqt工具配置
?在使用Qt进行界面设计时,可以在pycharm中将Qt软件自带的几个工具都配置为外部工具(这就是pycharm众多优点之一),方便随时调用。pycharm中以及点击文件-设置-工具-外部工具(英文版自行对照)即可进入外部工具添加界面。
程序路径:
C:\Qt\5.12.11\mingw73_32\bin\designer.exe
工作目录:
$ProjectFileDir$
?程序路径在对应环境的Script目录下:
C:\Anaconda3\envs\tensor37\Scripts\pyuic5.exe
?参数设置如下:
$FileName$ -o $FileNameWithoutExtension$.py
?工作目录:
$ProjectFileDir$
- PyUI 工具(Qt UI界面转为python代码)
?程序路径:
C:\Qt\Tools\QtCreator\bin\qtcreator.exe
?工作目录:
$ProjectFileDir$
?完成上述设置后在右键菜单中可以打开designer.exe和creator.exe这两个GUI设计应用,选中.ui文件右键运行pyuic.exe则会生成同名的.py文件,文件中包含有能够产生相同GUI的pyqt5类。
3 UDP图形界面设计
3.1 GUI设计
?在pycharm的空白处右键选择外部工具,打开designer,新建Main Window窗体。
?根据需要,我设计了一个UDP网络编程的界面,主要功能是接收UDP客户端发来的正弦数据,保存数据到txt文件中并将其绘制在底部的widget (窗体部件)中。
?目标运行界面如下:
3.2 将GUI文件转换为py文件
?设计好界面后,保存得到widget_recev.ui图形文件,在左侧的项目资源管理器中可以选中ui文件右键实用外部pyuic工具将其转换为widget_recev.py文件,供程序调用。这个操作在后续的调试中经常会用到,随时改动GUI随时生成新的py文件。新建的py文件会覆盖原来的内容,所以建议另建其他python模块调用该模块,避免信息丢失。
3.3 widget窗体提升,整合matplotlib的功能
?这里需要注意的是,matplotlib中的FigureCanvas和GUI中的widget都是Qwidget的子类,matplotlib是无法直接在widget中绘图的,需要在Designer中将widget提升为Qwidget类。选中GUI中的widget右键选择提升窗口部件,选择Qwidget,给提升的类取一个好记的名字,在这里我使用的是mplwidget。
?生成的widget_recev.py最后面会生成一句:
from mplwidget import mplwidget
?将其放在类文件的开头,否则会报错。
?mplwidget.py 模块需要自己构建,在对应的路径下自己建一个mplwidget.py文件,主要功能是创建一个同时继承了FigureCanvas与QWidget的类,按照上面预定义的,将其命名为mplwidget类。该操作使原来的widget窗体具有了matplotlib画布功能,可以在上面绘图了。mplwidget.py文件的内容如下:
from PyQt5 import QtGui,QtWidgets
from matplotlib.backends.backend_qt5agg \
import FigureCanvasQTAgg as FigureCanvas
from matplotlib.figure import Figure
from PyQt5.QtCore import QThread
class MplCanvas(FigureCanvas,QThread):
def __init__(self):
self.fig = Figure()
FigureCanvas.__init__(self, self.fig)
FigureCanvas.setSizePolicy(self,
QtWidgets.QSizePolicy.Expanding,
QtWidgets.QSizePolicy.Expanding)
FigureCanvas.updateGeometry(self)
class mplwidget(QtWidgets.QWidget):
def __init__(self, parent=None):
QtWidgets.QWidget.__init__(self, parent)
self.canvas = MplCanvas()
self.vbl = QtWidgets.QVBoxLayout()
self.vbl.addWidget(self.canvas)
self.setLayout(self.vbl)
3.4 GUI 设计结果
?生成的pyqt5 UI(widget_recev.py)内容如下。该文件是根据Qt ui文件自动生成的,因此一般只需要知道里面有哪些部件即可,对于一些大小、位置设置的细节可以不用关注,因为已经在GUI设计的时候做好了。
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt_Learning.UDP.GUI.mplwidget import mplwidget
class Ui_Widget(object):
def setupUi(self, Widget):
Widget.setObjectName("Widget")
Widget.resize(280, 165)
self.label_2 = QtWidgets.QLabel(Widget)
self.label_2.setGeometry(QtCore.QRect(110, 10, 55, 16))
self.label_2.setObjectName("label_2")
self.lineEdit_2 = QtWidgets.QLineEdit(Widget)
self.lineEdit_2.setGeometry(QtCore.QRect(110, 30, 61, 21))
self.lineEdit_2.setObjectName("lineEdit_2")
self.pushButton = QtWidgets.QPushButton(Widget)
self.pushButton.setGeometry(QtCore.QRect(10, 120, 71, 24))
self.pushButton.setObjectName("pushButton")
self.label = QtWidgets.QLabel(Widget)
self.label.setGeometry(QtCore.QRect(12, 10, 55, 16))
self.label.setObjectName("label")
self.lineEdit = QtWidgets.QLineEdit(Widget)
self.lineEdit.setGeometry(QtCore.QRect(12, 30, 81, 21))
self.lineEdit.setObjectName("lineEdit")
self.pushButton_2 = QtWidgets.QPushButton(Widget)
self.pushButton_2.setGeometry(QtCore.QRect(180, 120, 75, 24))
self.pushButton_2.setObjectName("pushButton_2")
self.lineEdit_5 = QtWidgets.QLineEdit(Widget)
self.lineEdit_5.setGeometry(QtCore.QRect(190, 80, 61, 21))
self.lineEdit_5.setObjectName("lineEdit_5")
self.label_3 = QtWidgets.QLabel(Widget)
self.label_3.setGeometry(QtCore.QRect(190, 60, 71, 16))
self.label_3.setObjectName("label_3")
self.label_4 = QtWidgets.QLabel(Widget)
self.label_4.setGeometry(QtCore.QRect(10, 60, 71, 16))
self.label_4.setObjectName("label_4")
self.lineEdit_3 = QtWidgets.QLineEdit(Widget)
self.lineEdit_3.setGeometry(QtCore.QRect(10, 80, 51, 21))
self.lineEdit_3.setObjectName("lineEdit_3")
self.label_5 = QtWidgets.QLabel(Widget)
self.label_5.setGeometry(QtCore.QRect(110, 60, 71, 16))
self.label_5.setObjectName("label_5")
self.lineEdit_4 = QtWidgets.QLineEdit(Widget)
self.lineEdit_4.setGeometry(QtCore.QRect(110, 80, 51, 21))
self.lineEdit_4.setObjectName("lineEdit_4")
self.label_6 = QtWidgets.QLabel(Widget)
self.label_6.setGeometry(QtCore.QRect(70, 80, 21, 16))
self.label_6.setObjectName("label_6")
self.retranslateUi(Widget)
QtCore.QMetaObject.connectSlotsByName(Widget)
def retranslateUi(self, Widget):
_translate = QtCore.QCoreApplication.translate
Widget.setWindowTitle(_translate("Widget", "数据发送端"))
self.label_2.setText(_translate("Widget", "端口"))
self.lineEdit_2.setText(_translate("Widget", "9999"))
self.pushButton.setText(_translate("Widget", "发送正弦"))
self.label.setText(_translate("Widget", "IP地址"))
self.lineEdit.setText(_translate("Widget", "127.0.0.1"))
self.pushButton_2.setText(_translate("Widget", "停止发送"))
self.lineEdit_5.setText(_translate("Widget", "8"))
self.label_3.setText(_translate("Widget", "正弦通道数"))
self.label_4.setText(_translate("Widget", "正弦频率"))
self.lineEdit_3.setText(_translate("Widget", "50"))
self.label_5.setText(_translate("Widget", "正弦幅度"))
self.lineEdit_4.setText(_translate("Widget", "1"))
self.label_6.setText(_translate("Widget", "Hz"))
4 多线程编程UDP通讯
?使用Qt进行界面设计非常方便。pyqt编程的难点在于底层的信号-槽函数机制以及多线程编程。先抛开多线程UDP编程,简单举例讲一下信号-槽函数的原理。
4.1 信号和槽函数
?信号相当于是GUI主循环中的事件,一旦触发某个事件,对应的槽函数(对象方法)将会运行。
?信号可以是内建信号,也可以是自定义信号。内建信号一般直接跟部件相关联,可以根据一定的规则构建对应的槽函数,例如:
def on_pushButtom_clicked(self):
...
def on_pushButtom_2_clicked(self):
...
def on_pushButtom_3_clicked(self):
...
分别对应着pushButtom,pushButtom_2,pushButtom_3 三个按钮被触发 clicked()事件时的自动关联槽函数,事件触发后立即运行对应的槽函数。类似的,checkBox_5被触发则默认自动关联如下槽函数,传递checked 布尔信号:
def on_checkBox_5_toggled(self,checked):
...
?自定义信号则更加灵活,其在事件触发时通过emit()函数发送数据。在pyqt5中,信号发送的数据类型可以是python支持的任何类型, 目前的测试表明,numpy.array、list、str、int、float等数据类型可以通过信号传递给槽函数,作为槽函数的输入。
在主线程(或GUI主循环)内,自定义简单的信号和槽函数对如下。
from PyQt5.QtCore import QObject
from PyQt5 import QtCore
class Test(QObject):
test_signal = QtCore.pyqtSignal(list)
def __init__(self, parent=None):
super().__init__(parent)
self.test_signal.connect(self.print_data)
def toggle(self):
a = list([1, 2, 3, 4, 5])
self.test_signal.emit(a)
@QtCore.pyqtSlot(list)
def print_data(self, list_var):
print(list_var)
test = Test()
test.toggle()
>>> [1, 2, 3, 4, 5]
?信号一般在初始化方法之前定义,作为Qt类的成员,定义信号时给出所发送信号的数据类型,以下例子中使用的是list类型。在初始化时将信号与对应的槽函数相关联。然后根据需要在不同的方法中发送信号给槽函数,槽函数接收到数据后立即执行函数中的内容。以上例子较为简单,主要在主线线程内进行信号和槽函数的触发。后文中将给出多线程进行信号和槽函数传递数据的案例。
4.2 多线程
?pyqt的主界面使用的是主线程,可以看做是一个死循环。一旦主线程中产生了较为耗时的操作,将导致主线程出现假死的现象,体现在GUI界面就是无响应和无法进行任何操作。
?在进行GUI程序设计时一般遵循GUI界面和代码界面分开设计的原则,主线程只负责管理基本GUI的动作,而耗时的操作则通过子线程进行计算。
?回到“多线程UDP通讯”的主题,在创建好GUI的基础上,UDP通讯接收端的主函数如下。代码实现了主线程向子线程、子线程向子线程以及子线程向主线程传递数据的三种情况。
当然,所有信号与槽函数的连接都必须在主线程中完成。 具体方法是:在主线程中创建子线程实例,将子线程作为主线程的成员,如此可以实现子线程与子线程,子线程与主线程之间的信号传递。
import socket
import sys
from PyQt5.QtWidgets import QApplication, QMainWindow
from PyQt_Learning.UDP.GUI.widget_recev import Ui_MainWindow
from PyQt5 import QtCore,uic
from PyQt5.QtCore import QThread,pyqtSlot
class QmyDialog(QMainWindow):
UDP_para = QtCore.pyqtSignal(list)
sender_para = QtCore.pyqtSignal(list)
def __init__(self, parent=None):
super().__init__(parent)
self.pause=False
self.statusBar().showMessage('Load UI...')
if 0:
self.ui = uic.loadUi('E:/Pywork/PyQt_Learning/UDP/GUI/widget_recev.ui',self)
else:
self.ui = Ui_MainWindow()
self.ui.setupUi(self)
self.statusBar().showMessage('Init Canvas...')
self.canvas = self.ui.widget.canvas
self.canvas.ax1 = self.canvas.fig.add_subplot(111)
self.canvas.ax1.get_yaxis().grid(True)
self.statusBar().showMessage('Init UDP...')
self.UDP = UDPThread(self.para(1))
self.UDP_para.connect(self.UDP.UDP_para_update)
self.statusBar().showMessage('Init plot sender...')
self.Plot_fig = Plot_Thread(self.para(2))
self.UDP.send_data.connect(self.Plot_fig.send)
self.sender_para.connect(self.Plot_fig.Sender_para_update)
self.Plot_fig.plot_data.connect(self.plot_fig)
self.statusBar().showMessage('Ready!')
def on_pushButton_clicked(self):
self.update_udp_para()
self.update_sender_para()
self.statusBar().showMessage('Para changed...')
def on_pushButton_2_clicked(self):
self.update_udp_para()
self.update_sender_para()
self.UDP.pause = False
self.Plot_fig.pause=False
self.UDP.start()
self.ui.lineEdit.setReadOnly(True)
self.ui.lineEdit_2.setReadOnly(True)
self.Plot_fig.start()
self.statusBar().showMessage('Receiving data...')
def on_pushButton_3_clicked(self):
self.pause=True
self.update_udp_para()
self.update_sender_para()
self.statusBar().showMessage('Receiving paused!')
def plot_fig(self,temp):
self.canvas.ax1.clear()
self.canvas.ax1.plot(temp)
self.canvas.fig.tight_layout()
self.canvas.draw()
def update_udp_para(self):
self.UDP_para.emit(self.para(1))
def update_sender_para(self):
self.sender_para.emit(self.para(2))
def para(self,flag):
if flag==1:
return list([self.ui.lineEdit.text(),int(self.ui.lineEdit_2.text()),self.pause])
else:
return list([int(self.ui.lineEdit_3.text()),self.pause])
class UDPThread(QThread):
send_data = QtCore.pyqtSignal(str)
def __init__(self,udp_para_list):
super().__init__()
self.IP,self.Port,self.pause = udp_para_list
self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
def run(self) -> None:
try:
self.s.bind((self.IP, self.Port))
except:pass
i = 1
with open('out.txt', 'w') as f:
while True:
temp = self.s.recv(1024).decode('utf-8')
if i%11==1:
self.send_data.emit(temp)
f.writelines(temp + '\n')
i=(i+1)%2000
if self.pause:
break
def UDP_para_update(self,udp_para_list):
self.Ip,self.Port,self.pause=udp_para_list
class Plot_Thread(QThread):
plot_data = QtCore.pyqtSignal(list)
def __init__(self,para_list):
super().__init__()
self.pause = False
self.data = []
self.max_len = para_list[0]
self.i=1
def change_Len(self,len):
if len<1000:
self.max_len=1000
else:
self.max_len = len
@pyqtSlot(str)
def send(self,data):
self.data.append(float(data))
if len(self.data)>self.max_len:
self.data=self.data[(len(self.data)-self.max_len):]
self.i=(self.i+1)%(1000)
if self.i==0:
self.plot_data.emit(self.data)
def Sender_para_update(self,para_list):
self.max_len,self.pause=para_list
app = QApplication(sys.argv)
form = QmyDialog()
form.show()
sys.exit(app.exec())
?效果显示如下: 点击记录并绘图,程序在保存UDP接收的数据的同时,将部分数据发送到GUI界面进行绘图。
5 Pyinstaller 打包成exe
?pyinstaller将代码打包成exe时会面临生成的exe过大的情况,一个很小功能的exe体积高达200M。归根到底是pyinstaller将一些相互关联的安装包都打包到exe中了,而大多数安装包在当前项目中并没有真正使用到。
?经测试,可以使用pipenv 创建一个干净的虚拟环境,降低exe的大小。 环境中只安装需要的pyinstaller, pyqt5, numpy等即可。在虚拟环境下生成的pyqt5 exe可执行文件只有几十兆。
?想进一步压缩可以下载 upx.exe,将放入pipenv虚拟环境下的Script文件夹中,pyinstaller打包的时候会自动调用。压缩量小,但算是有点效果的,毕竟没有其他补救措施了。
?在pipenv虚拟环境下运行如下代码:
import os
error = os.system('pyinstaller --clean -Fw E:\\Pywork\PyQt_Learning\\UDP\\GUI\\Receiver.py E:\\Pywork\\PyQt_Learning\\UDP\\GUI\\widget_recev.py E:\\Pywork\\PyQt_Learning\\UDP\\GUI\\mplwidget.py')
if not error: print('成功生成exe文件!')
?最终的大小约为44M,个人感觉还行。
?写在最后:兴趣使然,水平有限,欢迎相互交流。
|