交互式图形和异步编程#

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

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

事件循环#

从根本上说,所有用户交互(和网络)都作为无限循环实现,等待来自用户(通过操作系统)的事件,然后对其进行处理。例如,一个极简的读入-求值-打印循环 (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 等名称的方法启动。

所有 GUI 框架(Qt、Wx、Gtk、tk、macOS 或 Web)都有一些捕获用户交互并将它们传递回应用程序的方法(例如 Qt 中的 Signal / Slot 框架),但具体细节取决于工具包。Matplotlib 为我们支持的每个 GUI 工具包提供了一个后端,该后端使用工具包 API 将工具包 UI 事件桥接到 Matplotlib 的事件处理系统中。然后,您可以使用 FigureCanvasBase.mpl_connect 将您的函数连接到 Matplotlib 的事件处理系统。这使您能够直接与您的数据进行交互,并编写与 GUI 工具包无关的用户界面。

命令提示符集成#

到目前为止,一切都很好。我们有 REPL(如 IPython 终端),它允许我们交互式地将代码发送到解释器并获取结果。我们还有 GUI 工具包,它运行一个事件循环,等待用户输入,并允许我们注册在发生这种情况时运行的函数。但是,如果我们想同时执行这两个操作,我们就会遇到一个问题:提示符和 GUI 事件循环都是无限循环,每个循环都认为它们负责!为了使提示符和 GUI 窗口都响应,我们需要一种方法来允许循环“分时”

  1. 当您想要交互式窗口时,让 GUI 主循环阻塞 Python 进程

  2. 让 CLI 主循环阻塞 Python 进程,并间歇性地运行 GUI 循环

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

阻塞提示符#

pyplot.show

显示所有打开的图形。

pyplot.pause

间隔秒运行 GUI 事件循环。

backend_bases.FigureCanvasBase.start_event_loop

启动阻塞事件循环。

backend_bases.FigureCanvasBase.stop_event_loop

停止当前阻塞事件循环。

最简单的“集成”是在“阻塞”模式下启动 GUI 事件循环并接管 CLI。在 GUI 事件循环运行时,您无法在提示符中输入新命令(您的终端可能会回显输入到终端中的字符,但它们不会发送到 Python 解释器,因为它正忙于运行 GUI 事件循环),但图形窗口将响应。一旦事件循环停止(使任何仍然打开的图形窗口无响应),您将能够再次使用提示符。重新启动事件循环将使任何打开的图形再次响应(并将处理任何排队的用户交互)。

要启动事件循环,直到所有打开的图形关闭,请使用 pyplot.show 作为

pyplot.show(block=True)

要启动事件循环固定时间(以秒为单位),请使用 pyplot.pause

如果您未使用 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 事件循环。

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

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

  • 随着长时间运行脚本的进行而进行进度更新

  • 从数据源流式更新

阻塞函数#

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

每个艺术家都有一个 Artist.stale_callback 属性,其中包含一个具有签名的回调

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

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

Figure 实例没有包含的艺术家,它们的默认回调是 None。如果您调用 pyplot.ion 并且不在 IPython 中,我们将安装一个回调来调用 draw_idle,只要 Figure 变为陈旧。在 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 的使用方式多种多样,因此 Matplotlib 目前不会对PyOS_InputHook 进行任何管理。此管理留给下游库——用户代码或 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

脚注