交互式图形和异步编程#

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

本指南旨在介绍 Matplotlib 与 GUI 事件循环集成如何工作的低级细节.有关 Matplotlib 事件 API 的更实际介绍,请参见 event handling system , Interactive TutorialInteractive Applications using 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] . 核心事件循环有时被称为"主循环",并且通常根据库的不同,由诸如 _exec , runstart 之类的方法启动.

所有 GUI 框架(Qt,Wx,Gtk,tk,macOS 或 web)都有一些捕获用户交互并将其传递回应用程序的方法(例如 Qt 中的 Signal / Slot 框架),但具体细节取决于工具包.Matplotlib 为我们支持的每个 GUI 工具包都有一个 backend ,它使用工具包 API 将工具包 UI 事件桥接到 Matplotlib 的 event handling system .然后,您可以使用 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 interpreter )完全嵌入到丰富的原生应用程序中.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 ,则需要使用 above 中描述的方法手动运行 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 显式地处理这些事件. This will run the GUI event loop until all UI events currently waiting have been processed. The exact behavior is backend-dependent but typically events on all figure are processed and only events waiting to be processed (not those added during processing) will be handled.

例如

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 的频率越高,你的图形就会感觉越灵敏,但代价是在可视化上花费更多的资源,而在计算上花费更少的资源.

陈旧的艺术家 (Stale artists)#

艺术家(从 Matplotlib 1.5 开始)具有一个 stale 属性,如果艺术家的内部状态自上次渲染以来已更改,则该属性为 True .默认情况下,陈旧状态会传播到绘图树中艺术家的父级,例如,如果 Line2D 实例的颜色发生更改,则包含它的 AxesFigure 也将被标记为 "stale".因此, fig.stale 将报告图形中的任何艺术家是否已被修改,并且与屏幕上显示的内容不同步. This is intended to be used to determine if draw_idle should be called to schedule a re-rendering of the figure.

每个艺术家都有一个 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.draw . draw 强制渲染图形,而 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 处于"交互模式",交互式图形也可能无法在 vanilla 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 .

脚注