PyQt与matplotlib-画图软件(三):子界面绘图

PyQt

This article was last updated on <span id="expire-date"></span> days ago, the information described in the article may be outdated.

前言

这是本系列第三篇文章,主要涉及如何将 matplotlibPyQt 结合,PyQt 的信号以及 PyQt 中多线程、多进程的使用

回顾与完善

上节中我演示了如何创建一个主界面和两个子界面并通过按钮和槽函数将三者联系到一起,以及如何为软件绑定快捷键,监听关闭事件 (closeEvent)。现在我们想让用户打开子界面时主界面会隐藏起来,在关闭子界面后主界面再次弹出,防止多余的窗口造成不必要的麻烦。

首先需要设置显示子界面的槽函数,使其可以隐藏主界面

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 文件: mainwindow.py
class MainWindow(QMainWindow):
# ...
# 省略已有代码
# ...
def show_ppi(self):
if self.ppi_window is not None:
self.ppi_window.deleteLater()
self.ppi_window = PPIWindow()
self.ppi_window.show()
self.hide() # 调用 hide 函数隐藏主界面

def show_rhi(self):
if self.rhi_window is not None:
self.rhi_window.deleteLater()
self.rhi_window = RHIWindow()
self.rhi_window.show()
self.hide()

为了让主界面可以获知子界面已经关闭,我们使用 PyQt信号 进行通讯。PyQt 的信号非常好用,尤其在多线程和多进程通讯方面。

`PyQt` 中线程与进程间的通讯

尽管 Python 有队列 (Queue)用于线程和进程间通讯,在 PyQt 中请尽量信号进行通讯,这可以保证 Qt 程序在运行中不会出现意外的问题。

首先在两个子界面中设置关闭信号,并设置在关闭界面时触发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 文件: ppi.py
from PyQt5.QtCore import pyqtSignal # 导入对应的包

class PPIWindow(QMainWindow):
# 信号必须在这个位置设置,在 __init__ 函数中设置无效
close_signal = pyqtSignal()
def __init__(self):
# ...

def closeEvent(self, QCloseEvent):
self.close_signal.emit()
QCloseEvent.accept()

# 文件: rhi.py
from PyQt5.QtCore import pyqtSignal

class RHIWindow(QMainWindow):
close_signal = pyqtSignal()
def __init__(self):
# ...

def closeEvent(self, QCloseEvent):
self.close_signal.emit()
QCloseEvent.accept()

然后定义好信号的槽函数,并在实例化时进行绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# 文件: mainwindow.py
class MainWindow(QMainWindow):
# ...
# 省略已有代码
# ...
def show_ppi(self):
if self.ppi_window is not None:
self.ppi_window.deleteLater()
self.ppi_window = PPIWindow()
self.ppi_window.close_signal.connect(self.close_signal_slot)
self.ppi_window.show()
self.hide()

def show_rhi(self):
if self.rhi_window is not None:
self.rhi_window.deleteLater()
self.rhi_window = RHIWindow()
self.rhi_window.close_signal.connect(self.close_signal_slot)
self.rhi_window.show()
self.hide()

def close_signal_slot(self):
self.show()

现在主界面将在打开子界面时隐藏,并在退出子界面时重新显示

在 PyQt 中使用 matplotlib

以下仅以 ppi.py 及对应 .ui 文件为例,rhi.py 情况相同。

首先先在子界面中添加一个 800*800 固定大小 (将部件的最小尺寸和最大尺寸设置为同样的值即可) 的 QWidget 部件,命名为 canvas_widget,这个部件将会用来放置画布。然后在其上添加一个按钮命名为 plot,这个按钮将用来触发画图事件。最后对整个窗口使用栅格布局使其部件自动对齐。

碍于篇幅这里就不截全图了

然后我们要用 matplotlib 提供的 FigureCanvasQTAgg 类来创建一个可以在 PyQt 中使用的 Figure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 文件: ppi.py
import matplotlib
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg
from matplotlib.figure import Figure
import matplotlib.transforms as mtransforms

matplotlib.use("Qt5Agg") # 使用 Qt5 作为后端进行画图

class CreateCanvas(FigureCanvasQTAgg):
"""
新定义一个 FigureCanvasQTAgg 的子类,在子类中创建 figure、ax以及cax
"""
def __init__(self, figsize=(4, 3), dpi=100, colorbar_pad=0.02, colorbar_width=0.01):
self.cax = None
self.axes = None
self.fig = Figure(figsize, dpi)
super(CreateCanvas, self).__init__(self.fig)
self.create_axes(colorbar_pad, colorbar_width)
self.colorbar_pad = colorbar_pad
self.colorbar_width = colorbar_width

def create_axes(self, colorbar_pad, colorbar_width):
# 创建子图
self.axes = self.fig.add_subplot(111)
axes_position = self.axes.get_position()
# 创建 cax,cax 用来绘制 colorbar
cax_position = mtransforms.Bbox.from_extents(
axes_position.x1 + colorbar_pad,
axes_position.y0,
axes_position.x1 + colorbar_pad + colorbar_width,
axes_position.y1
)
self.cax = self.axes.figure.add_axes(cax_position)

def clear_figure(self):
# 清除所有子图并重新添加,起到清除画布的效果
self.fig.clf()
self.create_axes(self.colorbar_pad, self.colorbar_width)

# ...
# 省略已有代码
# ...

可以看到,CreateCanvas 最后初始化了三个对象,figaxes 以及 cax,画图时使用 axes 画图即可,其拥有的方法与平时在 matplotlib 中使用的子图 ax 一致。

然后我们需要在 PPIWindow 中将绘图部件添加到 canvas_widget 上,顺便定义一下画图函数,就画个简单的正弦函数吧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 文件: ppi.py
# 导入 Qt 的网格管理器
from PyQt5.QtWidgets import QGridLayout
import numpy as np

class PPIWindow(QMainWindow):
def __init__(self):
# ...
# 省略已有代码
# ...
# 在 canvas_widget 上创建栅格布局
self.grid_layout = QGridLayout(self.ui.canvas_widget)
# 初始化画布
self.figure = CreateCanvas()
# 将画布添加到 canvas_widget 上,这里仅填一个参数,其他默认
# figure 部件会自动填满整个 widget
self.grid_layout.addWidget(self.figure)
self.ui.plot.clicked.connect(self.plot_sin)

def plot_sin(self):
self.figure.clear_figure()
t = np.arange(0, 5, 0.01)
s = np.sin(2 * np.pi * t)
self.figure.axes.plot(t, s)
# 切记绘制完图像后一定要调用 draw 函数以使显示的图像及时更新
self.figure.draw()

运行程序,点击 plot,画布即可显示出正弦函数图像

多线程和多进程的使用

有时我们需要绘制的并不是简单的正弦函数图像,在这种情况下为了保证 UI 界面的更新不会被阻塞 (主线程不会被阻塞),将执行时间长的任务交给其他线程或者进程是最好的解决方案。

UI的更新

应尽量避免跨线程更新 UI,这会导致程序不稳定,容易发生意外的错误。理想的程序设计应该是主线程只负责更新 UI,所有其他任务交由其他线程或进程处理。

PyQt 有一个 QThread 类可以执行多线程的操作,新定义一个类继承自 QThread 并重写其 run 方法即可。

这里我们使用知名的 中国业务天气雷达开源库 pycwr 来在 UI 上绘制组合反射率,数据使用 pycwr 提供的 2015年数据NUIST.20150627.002438.AR2.bz2

UI 的修改

首先我们再添加一个按钮和槽函数,用来打开雷达数据文件,并添加一个进度条显示进度

当然这里进度条显示的并不是实际的进度,而是每隔一段固定时间均匀增长的进度条,它的作用主要是用来告诉用户 “程序正在运行,请耐心等待”

接下来添加 open 按钮的槽函数,这里将打开文件的行为交由子线程进行处理,并通过信号返回读取好的数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
# 文件: ppi.py
# 导入 QThread 类和 pycwr
from PyQt5.QtCore import QThread
from pycwr.io import read_auto

# 定义读取数据文件的子线程
class OpenFile(QThread):
# 这里定义两个信号,一个用来发送读取好的数据,一个用来告知主线程将进度条设置为 100%
# 如果需要通过信号发送参数,则需要在 pyqtSignal 中设置参数对应的类型
# 这里发送一个字典变量,便写为 pyqtSignal(dict)
open_file_finish_signal = pyqtSignal(dict)
set_progress_bar_100_signal = pyqtSignal()
# 实例化子线程时需要传入要读取的文件名
def __init__(self, filename):
super(OpenFile, self).__init__()
self.filename = filename
# 重写 run 函数使得线程启动时读取雷达数据并返回
def run(self):
data = read_auto(self.filename)
self.open_file_finish_signal.emit({'data': data})
self.set_progress_bar_100_signal.emit()

# 定义为进度条计算进度的子线程
class ProgressBar(QThread):
set_progress_bar_signal = pyqtSignal(int)

def __init__(self, sleep_time: float):
super(ProgressBar, self).__init__()
self.stop_flag = False
self.sleep_time = sleep_time

def run(self):
value = 0
while value < 99:
if self.stop_flag:
break
value += 1
self.set_progress_bar_signal.emit(value)
sleep(self.sleep_time)

def stop_thread(self):
# 这个函数用来停止该线程
self.stop_flag = True


class PPIWindow(QMainWindow):
def __init__(self):
# ...
# 省略已有的代码
# ...
self.thread_control = {} # 定义一个字典用来统一管理启动的线程
self.ui.open.clicked.connect(self.open_file_button_slot)

# 省略已有的代码

def open_file_signal_slot(self, data: dict):
# OpenFile 信号槽函数,接收读取的数据
self.radar_data = data['data']

def set_progress_bar_signal_slot(self, value: int):
# 进度条槽函数,接收信号并设置进度条的进度
self.ui.progressBar.setValue(value)

def set_progress_bar_100_signal_slot(self):
# 另一个进度条槽函数,用来停止进度条线程并将进度条设置为 100%
if self._check_thread('ProgressBar'):
self.thread_control['ProgressBar'].stop_thread()
self.set_progress_bar_signal_slot(100)

def open_file_button_slot(self):
# 打开文件按钮槽函数,打开文件选择框获取文件路径,并启动线程进行读取
if self._check_thread('OpenFile'):
return
filename = QFileDialog.getOpenFileName(self, "选择数据", "./", "bz2压缩文件(*.bz2);;所有文件(*)")[0]
if filename == '':
return
self._start_progress_bar(sleep_time=0.3)
self.thread_control['OpenFile'] = OpenFile(filename)
self.thread_control['OpenFile'].open_file_finish_signal.connect(self.open_file_signal_slot)
self.thread_control['OpenFile'].set_progress_bar_100_signal.connect(self.set_progress_bar_100_signal_slot)
self.thread_control['OpenFile'].start()

def _start_progress_bar(self, sleep_time=0.25):
# 启动进度条的函数
if self._check_thread('ProgressBar'):
self.thread_control['ProgressBar'].stop_thread()
self.thread_control['ProgressBar'] = ProgressBar(sleep_time)
self.thread_control['ProgressBar'].set_progress_bar_signal.connect(self.set_progress_bar_signal_slot)
self.thread_control['ProgressBar'].start()

def _check_thread(self, name: str):
# 检查 thread_control 中是否有名称为 name 的线程以及线程是否正在运行
if name in self.thread_control.keys() and self.thread_control[name].isRunning():
return True
else:
return False

现在可以尝试读取雷达数据了,进度条将在数据读取完成后走到 100%

画图函数的添加

新建一个 backend/backend.py 文件,将画图功能单独分离出来。参照 官方教程 绘制笛卡尔坐标的组合反射率,但是这里做出了一些修改,为了避免子线程更新主线程属性带来意外问题,将 add_product_CR_xy 函数进行拆解,直接获取绘图需要用到的数据,并将计算数据的部分交由子进程进行处理。

关于子进程的部分

使用子进程来计算绘图数据是因为由于 GIL 锁的原因,在数据量较大时,计算数据的部分对 CPU 的占用会导致 UI 界面更新受到影响,因此通过使用子进程的方式将数据的计算交由其他核进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# 文件:backend.py
from pycwr.draw.RadarPlot import plot_xy
import numpy as np
from pycwr.core.NRadar import PRD
from pycwr.core.RadarGridC import get_CR_xy
import xarray as xr
from multiprocessing import Process, Queue
from PyQt5.QtCore import QThread, pyqtSignal


def _get_data(queue: Queue, *args, **kwargs):
GridV = get_CR_xy(*args, **kwargs)
queue.put(GridV)


class PlotCR(QThread):
plot_finish_signal = pyqtSignal()

def __init__(self, canvas_figure, radar: PRD):
super(PlotCR, self).__init__()
self.fig = canvas_figure.fig
self.ax = canvas_figure.axes
self.cax = canvas_figure.cax
self.radar = radar
self.canvas_figure = canvas_figure

def run(self):
x1d = np.arange(-150000, 150001, 1000)
y1d = x1d.copy() # 这里直接进行复制了
# 为了避免对主线程的属性进行修改,这里对 PRD.add_product_CR_xy 函数进行替换,
# 直接获取下面代码中用于绘图的 PRD.product.CR, 获取方式参考 add_product_CR_xy 函数定义
GridX, GridY = np.meshgrid(x1d, y1d, indexing="ij")
vol_azimuth, vol_range, fix_elevation, vol_value, radar_height, radar_lon_0, radar_lat_0 = self.radar.vol
fillvalue = -999.
# 这里使用子进程获取数据,并通过 Queue 队列返回数据
q = Queue()
p = Process(target=_get_data, args=(q, vol_azimuth, vol_range, fix_elevation, vol_value,
radar_height, GridX.astype(np.float64), GridY.astype(np.float64), fillvalue))
p.start()
GridV = q.get()
p.join()
product = xr.Dataset()
product.coords['x_cr'] = x1d
product.coords['y_cr'] = y1d
product['CR'] = (('x_cr', 'y_cr'), np.where(GridV == fillvalue, np.nan, GridV))
# 开始绘图
im = plot_xy(self.ax, GridX, GridY, product.CR, cbar=False) # colorbar 需要稍后进行特殊的绘制
self.fig.colorbar(mappable=im, cax=self.cax)
self.ax.set_xlabel("Distance From Radar In East (km)", fontsize=14)
self.ax.set_ylabel("Distance From Radar In North (km)", fontsize=14)
# 在绘制完图像后一定要调用 draw 保证界面上的图像进行更新
self.canvas_figure.draw()
self.plot_finish_signal.emit()

然后在 ppi.py 中进行设置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 文件:ppi.py
# 新增 import
from src.backend.backend import PlotCR

class PPIWindow(QMainWindow):
def __init__(self):
# 省略已有代码
self.ui.plot.clicked.connect(self.plot_CR) # 修改绑定的槽函数
# 省略已有代码
def plot_CR(self):
# 如果已经正在画图了,不响应画图请求
if self._check_thread('PlotCR'):
return
# 文件没有读取,不响应请求
if self.radar_data is None:
return
if self._check_thread('ProgressBar'):
self.thread_control['ProgressBar'].stop_thread()
self._start_progress_bar(sleep_time=0.35)
self.thread_control['PlotCR'] = PlotCR(self.figure, self.radar_data)
self.thread_control['PlotCR'].plot_finish_signal.connect(self.set_progress_bar_100_signal_slot)
self.thread_control['PlotCR'].start()

现在可以尝试进行绘制组合反射率图像了。

由于代码量的逐渐增多,最后就不放出全部的代码了,将在本系列最后给出 全部代码 的地址,看官自行 clone。

Author: Syize

Permalink: https://blog.syize.cn/2022/07/28/Lily-radar-painter-3/

本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Syizeのblog

Comments