PyQt5 に対話型 Matplotlib 3D プロットを埋め込む:QtAgg・更新・テスト(2026)

メンテナンス層(2026-09-01 確認)。 この版は現在の Matplotlib Qt 埋め込み API に従い、PyQt5 を明示的な対象とします。末尾には 2019 年のエクスポート全文を原文のまま保存しました。当時のキャンバス作成順序と mouse_init() に関する提案は古い構成での有用な観察ですが、現在の Matplotlib に対する万能な修正方法とは位置付けません。

対話型 3D プロットには、画像のレンダリング以上のものが必要です。Qt キャンバスが入力イベントを受け取り、一つの所有者が Qt イベントループを動かし、再描画後にはそのループへ制御を戻し、関係する Python オブジェクトが生存していなければなりません。以下ではプロット構築を PyQt5 ウィジェットから分離し、非対話バックエンドでマウス操作まで検証したふりをせずに、数値描画をヘッドレスでテストできるようにします。

2019 年のメモから変わった点

現在の Matplotlib は統一された QtAgg バックエンドを使い、キャンバスを matplotlib.backends.backend_qtagg から import します。このバックエンドは PyQt6、PySide6、PyQt5、PySide2 に対応し、先に PyQt5.QtCore を import すると PyQt5 が選ばれます。古い backend_qt5agg 互換モジュールも残っていますが、Matplotlib の文書は新規コードでの使用を推奨していません。

現在の 3D Axes は figure.add_subplot(projection="3d") で作成し、Axes3D を別途 import する必要はありません。対話型バックエンド上の Axes3D には既にマウス操作があります。mouse_init() は回転・パン・ズームに使うボタンを設定するもので、Qt イベントループの欠如、非対話バックエンド、破棄済みキャンバス、停止した GUI スレッドを直す一般的手段ではありません。

2019 年の継承例にある super(FigureCanvas, self) は、本来呼ぶべき FigureCanvas 初期化処理を飛び越えます。新しいサブクラスでは super().__init__(Figure(...)) を使いますが、通常は以下のようなコンポジションのほうが簡単です。

アーキテクチャとプロジェクト構成

再利用する描画ロジックを PyQt5 から独立させ、境界で同じ種類の Figure を別々のキャンバスへ接続します。

qt-3d-demo/
├── app.py
├── plot_model.py
└── test_plot_model.py

GUI の経路は FigureFigureCanvas → Qt レイアウト、ヘッドレスの経路は FigureFigureCanvasAgg → ピクセル描画です。両方が同じ構築・更新関数を実行しますが、対話型 Qt ウィジェットを持つのは前者だけです。

動作確認済み環境をインストールして記録する

仮想環境を作成して有効化し、アプリとテストの依存関係をインストールします。

python -m venv .venv
python -m pip install --upgrade pip
python -m pip install PyQt5 matplotlib numpy pytest
python -c "from PyQt5 import QtCore; import matplotlib, numpy; print('PyQt', QtCore.PYQT_VERSION_STR); print('Qt', QtCore.QT_VERSION_STR); print('Matplotlib', matplotlib.__version__); print('NumPy', numpy.__version__)"

venv より後のコマンドは、その環境が有効化済みであることを前提とします。PyQt5 と Matplotlib の対応状況は Python、OS、アーキテクチャ、パッケージ取得元、選んだリリースの組み合わせに依存します。このバージョン未固定のコマンドから本番互換性を推測せず、動作後にロックファイルまたは制約付き依存関係を記録して継続テストしてください。

Matplotlib の現在の依存関係ページでは、Qt バックエンドの選択肢として PyQt5 5.12 以降を挙げています。ただし、この下限は古い PyQt5 のすべてが新しい Python や各プラットフォームに対応するという保証ではありません。アプリが公称する実際の組み合わせをテストしてください。

キャンバスに依存しない 3D プロットを作る

plot_model.py を作成します。

from __future__ import annotations

import numpy as np
from matplotlib.figure import Figure


def helix_coordinates(phase: float):
    t = np.linspace(0.0, 4.0 * np.pi, 400)
    x = np.cos(t + phase)
    y = np.sin(t + phase)
    z = t / (4.0 * np.pi)
    return x, y, z


def populate_figure(figure: Figure):
    axes = figure.add_subplot(projection="3d")
    x, y, z = helix_coordinates(0.0)
    (line,) = axes.plot(x, y, z, linewidth=2.0)
    axes.set(
        title="Interactive helix",
        xlabel="x",
        ylabel="y",
        zlabel="z",
        xlim=(-1.1, 1.1),
        ylim=(-1.1, 1.1),
        zlim=(0.0, 1.0),
    )
    return axes, line


def update_line(line, phase: float) -> None:
    x, y, z = helix_coordinates(phase)
    line.set_data_3d(x, y, z)

このモジュールは GUI バックエンドを選ばず、イベントループも開始しません。更新のたびに Axes を消去・再生成せず、既存の 3D 線を変更するため、現在のカメラ視点を保ち、不要な Artist の割り当てを避けられます。

FigureCanvas とツールバーで Figure を埋め込む

app.py を作成します。

import sys

from PyQt5 import QtCore, QtWidgets
from matplotlib.backends.backend_qtagg import FigureCanvas
from matplotlib.backends.backend_qtagg import NavigationToolbar2QT
from matplotlib.figure import Figure

from plot_model import populate_figure, update_line


class PlotWindow(QtWidgets.QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("PyQt5 interactive 3D plot")

        self.canvas = FigureCanvas(Figure(figsize=(7, 5)))
        self.axes, self.line = populate_figure(self.canvas.figure)
        self.toolbar = NavigationToolbar2QT(self.canvas, self)

        self.slider = QtWidgets.QSlider(QtCore.Qt.Horizontal)
        self.slider.setRange(0, 628)
        self.slider.valueChanged.connect(self.set_phase)

        self.animate_button = QtWidgets.QPushButton("Start animation")
        self.animate_button.clicked.connect(self.toggle_animation)

        central = QtWidgets.QWidget(self)
        layout = QtWidgets.QVBoxLayout(central)
        layout.addWidget(self.toolbar)
        layout.addWidget(self.canvas, 1)
        layout.addWidget(self.slider)
        layout.addWidget(self.animate_button)
        self.setCentralWidget(central)

        self.timer = QtCore.QTimer(self)
        self.timer.setInterval(50)
        self.timer.timeout.connect(self.advance)

        self.click_cid = self.canvas.mpl_connect(
            "button_press_event", self.report_click
        )

    def set_phase(self, value: int) -> None:
        update_line(self.line, value / 100.0)
        self.canvas.draw_idle()

    def toggle_animation(self) -> None:
        if self.timer.isActive():
            self.timer.stop()
            self.animate_button.setText("Start animation")
        else:
            self.timer.start()
            self.animate_button.setText("Stop animation")

    def advance(self) -> None:
        value = (self.slider.value() + 2) % (self.slider.maximum() + 1)
        self.slider.setValue(value)

    def report_click(self, event) -> None:
        if event.inaxes is self.axes:
            self.statusBar().showMessage(
                f"button={event.button}; x={event.xdata:.3f}; y={event.ydata:.3f}"
            )

    def closeEvent(self, event) -> None:
        self.timer.stop()
        self.canvas.mpl_disconnect(self.click_cid)
        super().closeEvent(event)


def main() -> int:
    if QtWidgets.QApplication.instance() is not None:
        raise RuntimeError("The host application already owns the Qt event loop")

    application = QtWidgets.QApplication(sys.argv)
    window = PlotWindow()
    window.resize(900, 700)
    window.show()
    return application.exec()


if __name__ == "__main__":
    raise SystemExit(main())

明示的な PyQt5 import が backend_qtagg より先に行われるので、対応する別の Qt バインディングが入っていても Matplotlib は PyQt5 を選びます。キャンバス、Axes、線、ツールバー、タイマー、コールバック ID は属性として保持され、寿命が明確です。

グラフィカルなデスクトップセッションで app.py を実行してください。Matplotlib の現在の 3D デフォルト割り当てでは、左ボタンのドラッグで回転、中ボタンでパン、右ボタンの上下ドラッグでズームします。2D ツールバーのパン/ズームモードは 3D カメラ操作ではありません。

Qt イベントループはホストに所有させる

単独アプリは一つの QApplication を作成してウィンドウを表示し、application.exec() を呼びます。Qt はそのメインループ内でウィンドウシステムと入力のイベントを配送します。この埋め込みウィジェット設計に pyplot.show()pyplot.pause()、別の QApplication、入れ子の exec() を加えないでください。

大きな PyQt5 アプリ、IDE、テストハーネスが既にイベントループを所有するなら、ホスト自身が PlotWindow を作成・保持し、このモジュールの main() は呼びません。そのため main() は既存の QApplication を見つけると、密かに二つ目のループを始めず明示的に失敗します。

ボタンのコールバックで時間のかかる計算をすると、キャンバスと UI 全体が止まります。必要に応じて GUI スレッド外で計算し、Qt シグナルで結果を戻して、Matplotlib Artist の変更は GUI スレッドで行います。

Artist を更新して再描画を要求する

データだけを変える場合は Artist を保持し、ここでの set_data_3d のような更新メソッドを呼び、その後 canvas.draw_idle() を呼びます。Matplotlib は GUI イベントループへ制御が戻るまでの複数の draw_idle() 要求をまとめます。完了済み renderer が直ちに必要な場合だけ、同期的な canvas.draw() を使ってください。

プロットの構造が変わるなら、再構築が適切な場合もあります。

self.canvas.figure.clear()
self.axes, self.line = populate_figure(self.canvas.figure)
self.canvas.draw_idle()

アニメーションの各フレームで消去すると通常は高コストで、現在の 3D カメラも失われます。イベント駆動設計の代わりに、密なループで flush_events()QApplication.processEvents() を呼ばないでください。

UI を止めずにタイマーを使う

例の QTimer は毎秒 20 フレームでスライダーを変え、スライダーのシグナルが小さな Artist 更新を一度行って再描画を予約します。タイマーはウィンドウを親に持ち、ウィンドウにも保持され、クリーンアップ時に停止します。Qt イベントループを通るタイマーコールバックは短時間で戻る必要があります。

データ取得と描画を同じ頻度にする必要はありません。ワーカーが高速にデータを生成し、GUI タイマーが最新のスナップショットだけを制限された頻度で表示できます。その受け渡しを意図的に保護し、無制限のキューを避け、ワーカースレッドから Qt ウィジェットを直接更新しないでください。

3D マウス操作を理解する

対話型キャンバスがありイベントループが動作していれば、Axes3D は通常のマウス処理を設定します。現在の mouse_init(rotate_btn=1, pan_btn=2, zoom_btn=3) はカメラ操作のボタンを設定します。アプリが異なる割り当てを意図する場合、または以前の disable_mouse_rotation() を取り消す場合だけ呼んでください。

操作できない場合は、まず基盤を診断します。表示中のウィジェットがその Axes を持つ FigureCanvas か、Qt イベントループが動いているか、GUI スレッドが止まっていないか、キャンバスが生存しているか、重なったウィジェットがマウスイベントを奪っていないかを確認します。mouse_init() の反復ではこれらを直せません。

タイマー、コールバック、ウィジェットをクリーンアップする

mpl_connect が返すカスタム接続 ID をすべて保存し、ウィンドウを閉じるときや関連コントローラーを交換するときに mpl_disconnect へ渡します。稼働中のタイマーを止め、アプリ所有のワーカーをキャンセルします。プラットフォームによって exec() 後のコードが実行されなくても解放すべきアプリ全体のリソースには、Qt の aboutToQuit シグナルが適しています。

この例は Matplotlib のオブジェクト指向 API を使い、Figure を pyplot に登録しないため、pyplot 管理のウィンドウを閉じる必要はありません。Qt の親/ウィジェット階層が埋め込みキャンバスを所有しますが、アプリ側のタイマー、コールバック、ファイル、ソケット、ワーカーは明示的に片付けます。

バックエンドを意図的に選択・診断する

新規コードでは backend_qtagg から FigureCanvas を import します。Matplotlib は、既に import 済みの Qt バインディング、QT_API、利用可能なバインディング順の順に選択します。例のように先に PyQt5 を import するのが、最も局所的で明示的な選択です。複数バインディングに対応するコードでは、プロセス起動時の QT_API=PyQt5 も利用できます。

MPLBACKEND をグローバル設定しないでください。Matplotlib はグローバル上書きが直感に反する動作を招き得ると警告しており、バックエンド選択は Figure 作成前に必要です。埋め込みアプリでは、matplotlib.get_backend() と pyplot の自動検出より、明示的なキャンバス import のほうが直接的です。

ウィンドウが出ない場合は、層ごとに切り分けます。

  1. 最小の PyQt5 ウィンドウで Qt とプラットフォームプラグインを検証する。
  2. 同じインタープリターから QtCore.PYQT_VERSION_STRQtCore.QT_VERSION_STRmatplotlib.__version__ を表示する。
  3. import したキャンバスモジュールが matplotlib.backends.backend_qtagg か確認する。
  4. IDE 外でスクリプトとして実行し、入力フックの曖昧さを除く。
  5. プロットを線一本に縮小し、タイマー、コールバック、ワーカーを一つずつ戻す。

xcbwindowscocoa プラットフォームプラグインのエラーは Qt 配置の問題です。projection="3d" の失敗でも、mouse_init() を呼ぶ理由でもありません。

ヘッドレスの境界を正直にテストする

test_plot_model.py を作成します。

import numpy as np
from matplotlib.backends.backend_agg import FigureCanvasAgg
from matplotlib.figure import Figure

from plot_model import helix_coordinates, populate_figure, update_line


def test_headless_plot_can_update_and_render():
    figure = Figure(figsize=(4, 3), dpi=100)
    canvas = FigureCanvasAgg(figure)
    axes, line = populate_figure(figure)

    update_line(line, phase=0.5)
    canvas.draw()

    expected_x, expected_y, expected_z = helix_coordinates(0.5)
    actual_x, actual_y, actual_z = line.get_data_3d()
    np.testing.assert_allclose(actual_x, expected_x)
    np.testing.assert_allclose(actual_y, expected_y)
    np.testing.assert_allclose(actual_z, expected_z)
    assert axes.figure is figure
    assert canvas.get_width_height() == (400, 300)

ディスプレイサーバーなしで実行します。

python -m pytest -q

これはプロット構築、Artist 更新、Agg 描画を検証しますが、Qt プラグイン読み込み、ウィンドウ所有権、ネイティブ入力、3D ドラッグ、高 DPI、タイマー周期、クリーンアップは検証しません。対応環境ならオフスクリーン Qt によるウィジェット構築のスモークテストを追加できますが、対話性の主張には少なくとも一つの実 GUI 統合テストを残してください。

バージョンとリリースのチェックリスト

このメンテナンス層は、当時の現行 Matplotlib 3.11.1 文書とアーカイブ版 Qt 5.15.19 文書に照らして確認しました。これらは本版の根拠であり、将来のデフォルトや任意の古い環境が同一であるという約束ではありません。

出荷前に次を確認します。

  • Python、PyQt5、Qt、Matplotlib、NumPy のバージョンを制約・記録する。
  • 対応する全 OS、アーキテクチャ、表示プロトコル、スケーリングモードでテストする。
  • PyQt5 と再配布する Qt コンポーネントのライセンスを確認する。
  • Agg 単体テストと実際の Qt ウィンドウ/入力統合テストを行う。
  • ビューの閉じ直しでタイマー、コールバック、ワーカーのリークを確認する。
  • 遅い計算を GUI スレッドから外す。
  • 別の Qt バインディングを環境に追加したらバックエンド選択を再検証する。

Qt 6 もサポートするなら、個別にテストする移行として扱ってください。統一 backend_qtagg import は役立ちますが、PyQt5 と PyQt6 では enum 名、Qt API、パッケージング、アプリコードがなお異なる場合があります。

一次公式ドキュメント

---

2019 年オリジナルエクスポート(原文のまま)

以下はメタデータ、リンク、本文、コードを含むソースエクスポートの全文です。2019-05-01 に公開され、9 秒後に最終更新されました。二つの Stack Overflow リンクと歴史的 API の用法は当時のメモの証拠として残しますが、2026 年のメンテナンス指針の根拠ではありません。このアーカイブ内は修正も現代化もしていません。

---
id: 1929
title: 'Embed an interactive 3D plot in PyQt5'
slug: 'embed-an-interactive-3d-plot-in-pyqt5'
date: '2019-05-01T13:42:15'
modified: '2019-05-01T13:42:24'
status: 'publish'
link: 'https://blog.lazying.art/en/html/computer_internet/pyqt/1929/embed-an-interactive-3d-plot-in-pyqt5.html'
author: 'Lachlan Chen'
categories:
  - 'PyQt'
---

[https://stackoverflow.com/questions/18259350/embed-an-interactive-3d-plot-in-pyside/18278457#18278457%20…](https://stackoverflow.com/questions/18259350/embed-an-interactive-3d-plot-in-pyside/18278457#18278457%20...)

1) Create the FigureCanvas *before* adding the axes. See [https://stackoverflow.com/a/9007892/3962328](https://stackoverflow.com/a/9007892/3962328)

canvas = FigureCanvas(fig)
ax = figure.add_subplot(111, projection='3d')


or

class MyFigureCanvas(FigureCanvas):
def __init__(self):
self.figure = Figure()
super(FigureCanvas, self).__init__(self.figure)
self.axes = self.figure.add_subplot(111, projection='3d')


2) Try ax.mouse_init() to restore the connection:

...
ax = fig.gca(projection="3d")
...
canvas = FigureCanvas(fig)
ax.mouse_init()

Leave a Reply