交互式图形和异步编程#

Matplotlib 通过将图形嵌入到 GUI 窗口中来支持丰富的交互式图形。在坐标轴中平移和缩放以检查数据的基本交互已“内置”到 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 事件循环 interval 秒。

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 事件循环 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 的使用方式多种多样,Matplotlib 目前没有对 PyOS_InputHook 进行任何管理。这种管理留给了下游库 —— 用户代码或 shell。如果未注册适当的 PyOS_InputHook,即使 Matplotlib 处于“交互模式”,交互式图形也可能在原生的 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_toolkit 中。prompt_toolkit 输入钩子的源代码位于 IPython.terminal.pt_inputhooks

脚注