交互式图形和异步编程#

Matplotlib 通过将图形嵌入到 GUI 窗口中来支持丰富的交互式图形。在 Axes 中平移和缩放以检查数据的基本交互功能是开箱即用的。它由完整的鼠标和键盘事件处理系统支持,您可以使用该系统构建复杂的交互式图表。

本指南旨在介绍 Matplotlib 如何与 GUI 事件循环集成的低级细节。有关 Matplotlib 事件 API 的更实际的介绍,请参阅事件处理系统交互式教程使用 Matplotlib 的交互式应用程序

GUI 事件#

所有 GUI 框架(Qt、Wx、Gtk、Tk、macOS 或 Web)都有某种捕获用户交互并将其传递回应用程序的方法,但具体细节取决于工具包(例如 Tk 中的回调或 Qt 中的Signal / Slot 框架)。Matplotlib 后端封装了 GUI 框架的细节,并通过 Matplotlib 的事件处理系统提供了一个独立于框架的 GUI 事件接口。通过将函数连接到事件处理系统(参见FigureCanvasBase.mpl_connect),您可以以与 GUI 工具包无关的方式交互式响应用户操作。

事件循环#

从根本上说,所有用户交互(和网络)都实现为一个无限循环,等待用户(通过操作系统)的事件,然后对其进行处理。例如,一个最小的读取-评估-打印循环(REPL)是:

exec_count = 0
while True:
    inp = input(f"[{exec_count}] > ")        # Read
    ret = eval(inp)                          # Evaluate
    print(ret)                               # Print
    exec_count += 1                          # Loop

这缺少许多细节(例如,它在第一个异常时退出!),但它代表了所有终端、GUI 和服务器背后的事件循环[1]。通常,“读取”步骤正在等待某种形式的 I/O——无论是用户输入还是网络——而“评估”和“打印”则负责解释输入然后对其进行处理

在实践中,我们与一个框架交互,该框架提供了一种机制来注册回调,以响应特定事件而运行,而不是直接实现 I/O 循环[2]。例如,“当用户点击此按钮时,请运行此函数”或“当用户按下‘z’键时,请运行此其他函数”。这允许用户编写反应式、事件驱动的程序,而无需深入研究 I/O 的繁琐细节[3]。核心事件循环有时被称为“主循环”,通常根据库的不同,通过名称如execrunstart的方法启动。

命令行集成#

到目前为止,一切顺利。我们有 REPL(如 IPython 终端),它允许我们交互式地向解释器发送代码并获取结果。我们还有一个 GUI 工具包,它运行一个事件循环等待用户输入,并允许我们注册函数以在发生这种情况时运行。然而,如果我们要同时进行这两者,就会遇到问题:提示符和 GUI 事件循环都是无限循环,无法并行运行。为了使提示符和 GUI 窗口都保持响应,我们需要一种方法允许循环进行“分时”。

  1. 阻塞提示符:当您需要交互式窗口时,让 GUI 主循环阻塞 Python 进程

  2. 输入钩子集成:让 CLI 主循环阻塞 Python 进程并间歇性运行 GUI 循环

  3. 完全嵌入:将 Python 完全嵌入到 GUI 中(但这基本上是编写一个完整的应用程序)

阻塞提示符#

pyplot.show

显示所有打开的图形。

pyplot.pause

运行 GUI 事件循环 interval 秒。

backend_bases.FigureCanvasBase.start_event_loop

启动一个阻塞事件循环。

backend_bases.FigureCanvasBase.stop_event_loop

停止当前阻塞事件循环。

最简单的解决方案是启动 GUI 事件循环并让它独占运行,这样可以得到响应式的图形窗口。但是,CLI 事件循环将不会运行,因此您无法输入新命令。我们称之为“阻塞”模式。(您的终端可能会回显键入的字符,但它们尚未被 CLI 事件循环处理,因为 Python 解释器正在忙于运行 GUI 事件循环)。

可以停止 GUI 事件循环并将控制权返回给 CLI 事件循环。然后您可以再次使用提示符,但任何仍然打开的图形窗口都将无响应。重新启动 GUI 事件循环将使这些图形再次响应(并将处理任何排队的用户交互)。

显示所有图形并独占运行 GUI 事件循环直到所有图形关闭的典型命令是:

plt.show()

或者,您可以使用pyplot.pause来启动 GUI 事件循环一段时间。

如果您不使用pyplot,您可以通过FigureCanvasBase.start_event_loopFigureCanvasBase.stop_event_loop来启动和停止事件循环。然而,在大多数不使用pyplot的情况下,您是将 Matplotlib 嵌入到一个大型 GUI 应用程序中,并且 GUI 事件循环应该已经为该应用程序运行。

离开提示符,如果您想编写一个暂停等待用户交互或在轮询附加数据之间显示图形的脚本,此技术将非常有用。有关更多详细信息,请参阅脚本和函数

输入钩子集成#

虽然以阻塞模式运行 GUI 事件循环或显式处理 UI 事件很有用,但我们可以做得更好!我们真正想要的是能够拥有一个可用的提示符交互式图形窗口。

我们可以使用交互式提示符的“输入钩子”功能来做到这一点。当提示符等待用户键入时(即使是打字飞快的人,提示符也大部分时间在等待人思考和移动手指),就会调用此钩子。尽管细节因提示符而异,但逻辑大致是:

  1. 开始等待键盘输入

  2. 启动 GUI 事件循环

  3. 一旦用户按下某个键,退出 GUI 事件循环并处理该键

  4. 重复

这给了我们同时拥有交互式 GUI 窗口和交互式提示符的错觉。大多数时候 GUI 事件循环正在运行,但一旦用户开始键入,提示符就会再次接管。

这种分时技术只允许事件循环在 Python 闲置并等待用户输入时运行。如果您希望 GUI 在长时间运行的代码期间保持响应,则需要定期刷新 GUI 事件队列,如显式旋转事件循环中所述。在这种情况下,是您的代码而不是 REPL 阻塞了进程,因此您需要手动处理“分时”。反之,非常慢的图形绘制将阻塞提示符直到绘制完成。

完全嵌入#

也可以反向操作,将图形(和Python 解释器)完全嵌入到功能丰富的本地应用程序中。Matplotlib 为每个工具包提供了可以直接嵌入到 GUI 应用程序中的类(内置窗口就是这样实现的!)。有关更多详细信息,请参阅在图形用户界面中嵌入 Matplotlib

脚本和函数#

backend_bases.FigureCanvasBase.flush_events

刷新图形的 GUI 事件。

backend_bases.FigureCanvasBase.draw_idle

请求在控制权返回 GUI 事件循环时重新绘制小部件。

figure.Figure.ginput

与图形交互的阻塞调用。

pyplot.ginput

与图形交互的阻塞调用。

pyplot.show

显示所有打开的图形。

pyplot.pause

运行 GUI 事件循环 interval 秒。

在脚本中使用交互式图形有几种用例:

  • 捕获用户输入以控制脚本

  • 长时间运行的脚本进行中的进度更新

  • 数据源的流式更新

阻塞函数#

如果您只需要在 Axes 中收集点,可以使用Figure.ginput。但是,如果您编写了一些自定义事件处理或正在使用widgets,则需要使用上述方法手动运行 GUI 事件循环。

您还可以使用阻塞提示符中描述的方法来暂停运行 GUI 事件循环。一旦循环退出,您的代码将恢复。通常,任何您会使用time.sleep的地方,您都可以改用pyplot.pause,并额外获得交互式图形的好处。

例如,如果您想轮询数据,可以使用类似以下的代码:

fig, ax = plt.subplots()
ln, = ax.plot([], [])

while True:
    x, y = get_new_data()
    ln.set_data(x, y)
    plt.pause(1)

这将以 1Hz 的频率轮询新数据并更新图形。

显式旋转事件循环#

backend_bases.FigureCanvasBase.flush_events

刷新图形的 GUI 事件。

backend_bases.FigureCanvasBase.draw_idle

请求在控制权返回 GUI 事件循环时重新绘制小部件。

如果您的开放窗口有待处理的 UI 事件(鼠标点击、按钮按下或绘制),您可以通过调用FigureCanvasBase.flush_events来显式处理这些事件。这将运行 GUI 事件循环,直到所有当前等待的 UI 事件都被处理。具体行为取决于后端,但通常会处理所有图形上的事件,并且只处理那些正在等待处理的事件(而不是在处理过程中添加的事件)。

例如:

import time
import matplotlib.pyplot as plt
import numpy as np
plt.ion()

fig, ax = plt.subplots()
th = np.linspace(0, 2*np.pi, 512)
ax.set_ylim(-1.5, 1.5)

ln, = ax.plot(th, np.sin(th))

def slow_loop(N, ln):
    for j in range(N):
        time.sleep(.1)  # to simulate some work
        ln.figure.canvas.flush_events()

slow_loop(100, ln)

虽然这会感觉有点滞后(因为我们每 100 毫秒才处理一次用户输入,而 20-30 毫秒才感觉“响应”),但它会做出响应。

如果您更改了绘图并希望重新渲染,您需要调用draw_idle来请求重新绘制画布。这个方法可以类比于asyncio.loop.call_soon,可被视为draw_soon

我们可以将其添加到上面的例子中:

def slow_loop(N, ln):
    for j in range(N):
        time.sleep(.1)  # to simulate some work
        if j % 10:
            ln.set_ydata(np.sin(((j // 10) % 5 * th)))
            ln.figure.canvas.draw_idle()

        ln.figure.canvas.flush_events()

slow_loop(100, ln)

您调用FigureCanvasBase.flush_events的频率越高,您的图形响应速度就越快,但代价是在可视化上花费更多资源,而在计算上花费更少。

陈旧的艺术家#

艺术家(从 Matplotlib 1.5 开始)有一个stale属性,如果艺术家的内部状态自上次渲染以来已更改,则该属性为True。默认情况下,陈旧状态会传播到绘制树中艺术家的父级,例如,如果Line2D实例的颜色发生更改,则包含它的AxesFigure也将被标记为“陈旧”。因此,fig.stale将报告图中是否有任何艺术家已被修改且与屏幕上显示的内容不同步。这旨在用于确定是否应调用draw_idle来安排图形的重新渲染。

每个艺术家都有一个Artist.stale_callback属性,该属性包含一个签名为以下的回调函数:

def callback(self: Artist, val: bool) -> None:
   ...

默认情况下,它被设置为一个将陈旧状态转发给艺术家父级的函数。如果您希望禁止给定艺术家传播,请将此属性设置为 None。

Figure实例没有包含艺术家,它们的默认回调是None。如果您调用pyplot.ion且不在IPython中,我们将安装一个回调,以便在Figure变得陈旧时调用draw_idle。在IPython中,我们使用'post_execute'钩子在执行用户输入之后但在将提示符返回给用户之前,对任何陈旧的图形调用draw_idle。如果您不使用pyplot,您可以使用回调Figure.stale_callback属性来在图形变得陈旧时接收通知。

空闲绘制#

backend_bases.FigureCanvasBase.draw

渲染Figure

backend_bases.FigureCanvasBase.draw_idle

请求在控制权返回 GUI 事件循环时重新绘制小部件。

backend_bases.FigureCanvasBase.flush_events

刷新图形的 GUI 事件。

在几乎所有情况下,我们都建议使用backend_bases.FigureCanvasBase.draw_idle而不是backend_bases.FigureCanvasBase.drawdraw会强制渲染图形,而draw_idle会安排在 GUI 窗口下次重绘屏幕时进行渲染。这通过仅渲染将显示在屏幕上的像素来提高性能。如果您希望屏幕尽快更新,请执行:

fig.canvas.draw_idle()
fig.canvas.flush_events()

多线程#

大多数 GUI 框架要求所有屏幕更新及其主事件循环都在主线程上运行。这使得将绘图的定期更新推送到后台线程变得不可能。尽管看起来是反直觉的,但通常更容易将计算推送到后台线程,并定期在主线程上更新图形。

一般来说,Matplotlib 不是线程安全的。如果您要在一个线程中更新Artist对象,并从另一个线程绘制,您应该确保在关键部分进行锁定。

事件循环集成机制#

CPython / readline#

Python C API 提供了一个钩子PyOS_InputHook,用于注册一个函数以便在“Python 解释器提示符即将空闲并等待终端用户输入时”运行。这个钩子可以用来将第二个事件循环(GUI 事件循环)与 Python 输入提示符循环集成。钩子函数通常会耗尽 GUI 事件队列中所有待处理的事件,短时间固定地运行主循环,或者运行事件循环直到 stdin 上按下某个键。

Matplotlib 目前没有对PyOS_InputHook进行任何管理,这是由于 Matplotlib 的使用方式广泛。此管理留给下游库——无论是用户代码还是 shell。交互式图形,即使 Matplotlib 处于“交互模式”下,如果未注册适当的PyOS_InputHook,也可能无法在普通的 Python REPL 中工作。

输入钩子以及安装它们的辅助函数通常包含在 GUI 工具包的 Python 绑定中,并可能在导入时注册。IPython 也为 Matplotlib 支持的所有 GUI 框架提供了输入钩子函数,可以通过%matplotlib安装。这是集成 Matplotlib 和提示符的推荐方法。

IPython / prompt_toolkit#

IPython >= 5.0 以后,IPython 从使用 CPython 的基于 readline 的提示符更改为基于prompt_toolkit的提示符。prompt_toolkit具有相同的概念性输入钩子,该钩子通过IPython.terminal.interactiveshell.TerminalInteractiveShell.inputhook()方法馈送到prompt_toolkitprompt_toolkit输入钩子的源代码位于IPython.terminal.pt_inputhooks

脚注