2022-03-05

Matplotlib と QThread

Matplotlib は、Python および NumPy のためのグラフ描画ライブラリです。Qt for Python (PySide) などと組み合わせて GUI アプリを作成するのに重宝しています。調子に乗ってプロットに時間がかかるような、ちょっと重い処理にも応用を広げているのですが、当然、プロットされるまでの間、GUI が固まってしまいます。

自分だけで使うときは、判っているので、ちょっと待つという我慢で済みます。しかし、他の人も使うような場合は、このままにはできません。時間のかかる処理は QThread を使って GUI が固まらないように別スレッドで処理させるように配慮する必要があります。

なんかうまくいかない

当初の方針は、Matplotlib で作成するプロットを別スレッドで処理して、その出力である canvas をメインのスレッドに渡して GUI に表示する、というものでした。しかし、テスト用のプログラムは、もっともらしい動作をするものの、肝心のプロットの内容を表示できませんでした。

ふと、下記の警告に気が付きました。

Starting the Matplotlib GUI outside the main thread may fail.

なんと、別スレッドで Matplotlib の描画処理をするのはうまくいかないようです。

プロットのためのデータセット準備の部分をスレッドに

考えてみると、プロットを描画する時間がボトルネックになっているわけではなく、プロットするためのデータセットの準備に時間がかかっています。

上記 Matplotlib の処理を別スレッドでできるやり方を探すよりも、時間がかかるであろうデータセット準備の処理部分を別スレッドにするようにした方が確実そうです。

そうすると、今まで作っていたアプリの構成が悪く、スレッド化のために作り直しが多く発生しそうで青くなっています。スレッド化を後回しにしたツケが回ってきました。

とにもかくにも、簡単なサンプルを作って、これをもとに基本構成を作り直すことにしました。

サンプル

長い前置きでしたが、本記事では、その簡単なサンプルを紹介します。なお、下記の OS 環境で動作確認をしました。

Fedora 35 Silverblue x86_64
Python 3.10.2
PySide6 6.2.3
matplotlib 3.5.1

以下にサンプルの実行例を示します。上部ツールバーの plot をクリックすると散布図を描画するという単純なサンプルです。

qt_matplotlib_scatter_thread.py の実行例

サンプルコードを下記に示しました。時間がかかる処理の雰囲気を出すために、ダミーで 2 秒のスリープを入れました。

qt_matplotlib_scatter_thread.py
#!/usr/bin/env python
# coding: utf-8
from PySide6.QtCore import (
QObject,
QThread,
Qt,
Signal,
)
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QProgressDialog,
QToolBar,
)
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar
from matplotlib.figure import Figure
from matplotlib.widgets import EllipseSelector, RectangleSelector
import seaborn as sns
import sys
import time
class Scatter(FigureCanvas):
fig = Figure()
def __init__(self, df):
super().__init__(self.fig)
self.init_chart(df)
def init_chart(self, df):
# Seaborn Scatter
ax = sns.scatterplot(data=df, x=df.columns[0], y=df.columns[1], ax=self.fig.add_subplot(111))
ax.set(title='Scatter Sample')
# Selector
plt.RS = RectangleSelector(
ax, self.select_callback, useblit=True,
button=[1], # disable middle & right buttons
minspanx=5, minspany=5, spancoords='pixels', interactive=True,
props=dict(facecolor='pink', edgecolor='red', alpha=0.2, fill=True)
)
@staticmethod
def select_callback(eclick, erelease):
x1, y1 = eclick.xdata, eclick.ydata
x2, y2 = erelease.xdata, erelease.ydata
print(f"({x1: 3.2f}, {y1: 3.2f}) --> ({x2: 3.2f}, {y2: 3.2f})")
class Example(QMainWindow):
navtoolbar = None
# for QThread
progress = None
thread = None
worker = None
def __init__(self):
super().__init__()
self.resize(600, 600)
self.setWindowTitle('Scatter + QThread')
self.init_ui()
def init_ui(self):
toolbar = QToolBar()
menu = QAction("plot", self, triggered=self.prep_start)
toolbar.addAction(menu)
self.addToolBar(Qt.TopToolBarArea, toolbar)
# _/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_/_
# DATA PREPARATION
def prep_start(self):
# remove widget from central
self.takeCentralWidget()
if self.navtoolbar is not None:
self.removeToolBar(self.navtoolbar)
# progress bar
self.progress = QProgressDialog(labelText='Working...', parent=self)
self.progress.setWindowModality(Qt.WindowModal)
self.progress.setCancelButton(None)
self.progress.setRange(0, 0)
self.progress.setWindowTitle('progress')
self.progress.show()
# threading
self.thread = QThread()
self.worker = Worker()
self.worker.moveToThread(self.thread)
# signal handling
self.thread.started.connect(self.worker.run)
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
self.worker.prepCompleted.connect(self.prep_end)
# start threading
self.thread.start()
def prep_end(self, df):
# stop QProgressDialog
self.progress.cancel()
# plotting chart
canvas: FigureCanvas = Scatter(df)
self.setCentralWidget(canvas)
# navigation toolbar
navtoolbar = NavigationToolbar(canvas, self)
self.addToolBar(
Qt.BottomToolBarArea,
navtoolbar
)
self.navtoolbar = navtoolbar
class Worker(QObject):
"""
DATA PREPARATION WORKER
"""
prepCompleted = Signal(pd.DataFrame)
finished = Signal()
def __init__(self):
super().__init__()
def run(self):
df = pd.DataFrame(np.random.random(size=(1000, 2)), columns=['X', 'Y'])
# dummy!! dummy!! dummy!!
time.sleep(2)
self.prepCompleted.emit(df)
self.finished.emit()
def main():
app = QApplication(sys.argv)
ex = Example()
ex.show()
sys.exit(app.exec())
if __name__ == '__main__':
main()

※ コードを更新しました。[2022-04-10]

参考サイト

  1. Use PyQt's QThread to Prevent Freezing GUIs – Real Python
  2. QThread

 

 

ブログランキング・にほんブログ村へ bitWalk's - にほんブログ村 にほんブログ村 IT技術ブログ Linuxへ
にほんブログ村

0 件のコメント: