交互式程序设计与异步图形

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

本指南旨在介绍Matplotlib如何与GUI事件循环集成的底层细节。有关Matplotlib事件API的更实用的介绍,请参见 event handling systemInteractive 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]. 总的来说 Read step正在等待某种类型的I/O—无论是用户输入还是网络—而 评估打印 负责解释输入,然后 关于它的一些东西。

在实践中,我们与一个框架交互,该框架提供了一种机制来注册回调以响应特定事件,而不是直接实现I/O循环 [2]. 例如“当用户点击此按钮时,请运行此功能”或“当用户点击“z”键时,请运行此其他功能”。这允许用户编写反应式、事件驱动的程序,而不必深入研究其本质 [3] I/O的细节。核心事件循环有时被称为“主循环”,通常由名为 _execrunstart .

所有GUI框架(Qt、Wx、Gtk、tk、OSX或web)都有一些方法来捕获用户交互并将它们传递回应用程序(例如 Signal / Slot 但具体细节取决于工具箱。Matplotlib有一个 backend 对于我们支持的每个GUI工具箱,它使用工具箱API将工具箱UI事件桥接到Matplotlib的 event handling system . 然后你可以使用 FigureCanvasBase.mpl_connect 将函数连接到Matplotlib的事件处理系统。这允许您直接与数据交互,并编写与GUI工具箱无关的用户界面。

命令提示集成

到目前为止,还不错。我们有REPL(就像IPython终端一样),它让我们以交互方式向解释器发送代码并返回结果。我们还有一个GUI工具箱,它运行一个等待用户输入的事件循环,并允许我们注册要在发生这种情况时运行的函数。然而,如果我们想同时做这两件事,我们就有一个问题:prompt和GUI事件循环都是无限循环,每个循环都在思考 they 你负责!为了让提示符和GUI窗口都能响应,我们需要一种方法来允许循环“分时共享”:

  1. 当需要交互式窗口时,让GUI主循环阻止python进程
  2. 让CLI主循环阻塞python进程并间歇运行GUI循环
  3. 在GUI中完全嵌入python(但这基本上是在编写一个完整的应用程序)

阻止提示

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 您正在一个大型GUI应用程序中嵌入Matplotlib,并且应用程序的GUI事件循环应该已经在运行了。

除了提示之外,如果您想编写一个暂停用户交互的脚本,或者在轮询其他数据之间显示一个数字,那么这种技术非常有用。看到了吗 脚本和函数 了解更多详细信息。

输入挂钩集成

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

我们可以使用交互式提示的“input hook”特性来实现这一点。这个钩子由提示符调用,因为它等待用户键入(即使对于快速打字员,提示符也主要是等待人类思考和移动手指)。虽然提示之间的细节有所不同,但逻辑大致上是一致的

  1. 开始等待键盘输入
  2. 启动GUI事件循环
  3. 一旦用户点击一个键,就退出GUI事件循环并处理该键
  4. 重复

这给了我们一种同时拥有交互式GUI窗口和交互式提示的错觉。大多数情况下,GUI事件循环都在运行,但一旦用户开始键入提示,提示就会再次出现。

这种时间共享技术只允许在python空闲并等待用户输入时运行事件循环。如果您想让GUI在长时间运行的代码中响应,那么有必要按照下面的描述定期刷新GUI事件队列 above . 在本例中,是您的代码而不是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事件循环 间隔 秒。

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

  • 捕获用户输入以引导脚本
  • 进度随着长时间运行的脚本的进度而更新
  • 来自数据源的流式更新

阻塞函数

如果您只需要收集轴上的点,可以使用 figure.Figure.ginput 或者更普遍地说是 blocking_input 这些工具将负责为您启动和停止事件循环。但是,如果您已经编写了一些自定义事件处理或正在使用 widgets 您将需要使用所描述的方法手动运行GUI事件循环 above .

也可以使用中描述的方法 阻止提示 暂停运行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 要求重新绘制画布。这种方法可以考虑 draw_soon 类似于 asyncio.loop.call_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 实例发生更改时 axes.Axesfigure.Figure 包含它的也将被标记为“过时”。因此, fig.stale 将报告图中的任何艺术家是否已被修改,并且与屏幕上显示的内容不同步。这是用来确定 draw_idle 应调用以安排图形的重新渲染。

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

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

默认情况下,设置为将过时状态转发给艺术家的父级的函数。如果要禁止传播给定的艺术家,请将此属性设置为“无”。

figure.Figure 实例没有包含艺术家,其默认回调为 None . 如果你打电话 pyplot.ion 而且不在 IPython 我们将安装一个回调来调用 draw_idle 每当 figure.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/读线

pythoncapi提供了一个钩子, PyOS_InputHook ,注册一个要运行的函数“当Python的解释器提示即将空闲并等待来自终端的用户输入时,将调用该函数”。这个钩子可以用来集成第二个事件循环(GUI事件循环)和python输入提示循环。钩子函数通常会耗尽GUI事件队列中所有挂起的事件,运行主循环一小段固定的时间,或者运行事件循环直到在stdin上按下一个键。

Matplotlib当前不管理 PyOS_InputHook 由于Matplotlib的使用方法非常广泛。这种管理留给下游库——用户代码或shell。交互式图形,即使matplotlib处于“交互式模式”,如果适当的 PyOS_InputHook 未注册。

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

IPython/prompt工具包

当IPython>=5.0时,IPython已从使用基于cpython的readline提示符改为 prompt_toolkit 基于提示。 prompt_toolkit 具有相同的概念输入钩子 prompt_toolkit 通过 IPython.terminal.interactiveshell.TerminalInteractiveShell.inputhook() 方法。数据的来源 prompt_toolkit 输入钩子位于 IPython.terminal.pt_inputhooks

脚注

[1]

这种设计的一个限制是,您只能等待一个输入,如果需要在多个源之间进行多路传输,那么循环将类似于:

fds = [...]
while True:                    # Loop
    inp = select(fds).read()   # Read
    eval(inp)                  # Evaluate / Print
[2]或者你可以 write your own 如果你必须的话。
[3]这些例子大大降低了现实世界中必须处理的许多复杂问题,如键盘中断、超时、错误输入、资源分配和清理等。