在 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 导入画布。该后端可配合 PyQt6、PySide6、PyQt5 或 PySide2;先导入 PyQt5.QtCore 就会选择 PyQt5。旧的 backend_qt5agg 兼容模块仍然存在,但 Matplotlib 文档不建议新代码使用它。

现在应通过 figure.add_subplot(projection="3d") 创建 3D 坐标轴,不需要单独导入 Axes3D。交互式后端中的 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、操作系统、架构、软件包来源及所选版本组合。不要从这条未固定版本的安装命令推断生产兼容性;应用正常工作后,应捕获并持续测试锁定文件或受约束的依赖集合。

Matplotlib 当前依赖页把 PyQt5 5.12 或更新版本列为 Qt 后端选项,但这个下限不保证每个旧 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 后端,也不启动事件循环。它更新已有的 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 导入发生在 backend_qtagg 之前,所以即使还安装了其他受支持 Qt 绑定,Matplotlib 也会选择 PyQt5。画布、坐标轴、线条、工具栏、计时器与回调标识符均为属性,生命周期因而清晰可见。

请在图形桌面会话中运行 app.py。按 Matplotlib 当前文档中的 3D 默认绑定,按住鼠标左键拖动可旋转,中键可平移,右键上下拖动可缩放。2D 工具栏的平移/缩放模式不是 3D 相机控制。

让宿主拥有 Qt 事件循环

独立应用只创建一个 QApplication,显示窗口,然后调用 application.exec()。Qt 在这个主循环里分发窗口系统与输入事件。不要在这种嵌入式控件设计中再加入 pyplot.show()pyplot.pause()、另一个 QApplication 或嵌套的 exec()

若更大的 PyQt5 应用、IDE 或测试框架已经拥有事件循环,应由它自行创建并保留 PlotWindow,且不得调用本模块的 main()。因此,main() 在发现已有 QApplication 时会明确失败,而不是暗中启动第二个循环。

按钮回调中的耗时计算会同时阻塞画布与界面其他部分。合适时应在 GUI 线程之外计算,再通过 Qt 信号把结果送回,并在 GUI 线程中更新 Matplotlib Artist。

更新 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() 来代替事件驱动设计。

使用计时器而不拖死界面

示例中的 QTimer 以每秒 20 帧改变滑块;滑块信号完成一次小型 Artist 更新并安排重绘。计时器以窗口为父对象,同时由窗口保留,并在清理时停止。计时器回调通过 Qt 事件循环运行,所以必须快速返回。

数据采集和绘制不必使用同一频率。工作线程可以快速产生数据,而 GUI 计时器只以有界频率显示最新快照。应有意保护交接过程、避免无界队列,并且绝不能从工作线程直接更新 Qt 控件。

理解 3D 鼠标交互

使用交互式画布且事件循环正在运行时,Axes3D 会安装其正常鼠标处理。当前 mouse_init(rotate_btn=1, pan_btn=2, zoom_btn=3) 用于配置控制相机的按钮。只有应用确实需要不同绑定,或要撤销先前的 disable_mouse_rotation() 时才调用它。

若没有交互,应先诊断基础条件:确认可见控件确实是持有坐标轴的 FigureCanvas,Qt 事件循环正在运行,GUI 线程没有阻塞,画布对象仍然存活,且没有覆盖控件吞掉鼠标事件。反复调用 mouse_init() 无法修复这些条件。

清理计时器、回调与控件

保存 mpl_connect 返回的每个自定义 Matplotlib 连接标识符;窗口关闭或相关控制器被替换时,把它传给 mpl_disconnect。停止活动计时器,并取消应用自己拥有的工作任务。对于即使某些平台上的 exec() 后代码不会运行也必须释放的全局资源,Qt 的 aboutToQuit 信号很合适。

本示例使用 Matplotlib 面向对象 API,从未向 pyplot 注册 Figure,因此没有 pyplot 管理的窗口需要关闭。Qt 的父对象/控件层次拥有嵌入画布;应用代码自己拥有的计时器、回调、文件、套接字或工作任务仍需显式清理。

有意选择并诊断后端

新代码应从 backend_qtagg 导入 FigureCanvas。Matplotlib 会依次依据已导入的 Qt 绑定、QT_API、再到可用绑定顺序进行选择。像示例这样先导入 PyQt5,是影响范围最小的明确选择。若一套代码支持多个绑定,可在进程启动时设置 QT_API=PyQt5

不要全局设置 MPLBACKEND。Matplotlib 警告,全局覆盖可能产生反直觉行为,而且必须在创建 Figure 前完成后端选择。对嵌入式应用而言,显式导入画布比依赖 matplotlib.get_backend() 与 pyplot 自动发现更直接。

窗口没有出现时,分层排查:

  1. 运行最小 PyQt5 窗口,验证 Qt 及其平台插件。
  2. 用同一解释器输出 QtCore.PYQT_VERSION_STRQtCore.QT_VERSION_STRmatplotlib.__version__
  3. 确认导入的画布模块是 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 版本;
  • 在每个受支持操作系统、架构、显示协议与缩放模式上测试;
  • 核验 PyQt5 许可证及再分发 Qt 组件的许可证;
  • 同时运行 Agg 单元测试与真实 Qt 窗口/输入集成测试;
  • 测试关闭并重新打开视图,检查计时器、回调与工作任务泄漏;
  • 让耗时计算远离 GUI 线程;以及
  • 环境中加入其他 Qt 绑定时,重新测试后端选择。

若要支持 Qt 6,请把它作为单独的受测试迁移。统一的 backend_qtagg 导入有所帮助,但 PyQt5 与 PyQt6 的枚举名称、Qt API、打包及应用代码仍可能不同。

一手官方文档

---

2019 年原始导出(逐字保留)

以下是完整的源导出,包含元数据、链接、正文与代码。它发表于 2019-05-01,并在九秒后完成最后修改。两条 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