交互式图形和异步编程#
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
这缺少了许多优点(例如,它在第一次异常时退出!),但它代表了所有终端、图形用户界面和服务器的基础事件循环 [1]。通常,读取 步骤在等待某种 I/O ——无论是用户输入还是网络——而 评估 和 打印 负责解释输入并对其采取 行动。
在实践中,我们与一个框架交互,该框架提供了一种机制来注册回调函数,以便在特定事件发生时运行,而不是直接实现I/O循环 [2]。例如,“当用户点击这个按钮时,请运行这个函数”或“当用户按下'z'键时,请运行另一个函数”。这允许用户编写响应式、事件驱动的程序,而无需深入了解I/O的细节 [3]。核心事件循环有时被称为“主循环”,通常根据库的不同,由名称类似于``_exec``、``run``或``start``的方法启动。
所有的GUI框架(如Qt、Wx、Gtk、tk、macOS或web)都有一些捕捉用户交互并将其传递回应用程序的方法(例如Qt中的``Signal`` / ``Slot``框架),但具体细节取决于工具包。Matplotlib为每个我们支持的GUI工具包提供了一个:ref:后端,该后端使用工具包API将工具包UI事件桥接到Matplotlib的:ref:事件处理系统。然后,您可以使用`.FigureCanvasBase.mpl_connect`将您的函数连接到Matplotlib的事件处理系统。这使您可以直接与您的数据交互,并编写与GUI工具包无关的用户界面。
命令提示符集成#
到目前为止,一切顺利。我们有一个 REPL(类似于 IPython 终端),可以让我们交互式地将代码发送到解释器并获取结果。我们还有一个 GUI 工具包,它运行一个事件循环等待用户输入,并让我们注册函数以便在发生这种情况时运行。然而,如果我们想同时做这两件事,我们就会遇到问题:提示符和 GUI 事件循环都是无限循环,每个循环都认为 它们 是主导者!为了让提示符和 GUI 窗口都能响应,我们需要一种方法让这些循环能够 '分时共享'。
当你想要交互式窗口时,让GUI主循环阻塞Python进程
让 CLI 主循环阻塞 Python 进程,并间歇性地运行 GUI 循环
在GUI中完全嵌入Python(但这基本上是在编写一个完整的应用程序)
阻止提示#
显示所有打开的图形。 |
|
运行GUI事件循环 interval 秒。 |
|
启动一个阻塞的事件循环。 |
|
停止当前的阻塞事件循环。 |
最简单的“集成”方式是在‘阻塞’模式下启动GUI事件循环并接管CLI。当GUI事件循环运行时,您无法在提示符中输入新命令(您的终端可能会回显输入到终端的字符,但它们不会被发送到Python解释器,因为它正忙于运行GUI事件循环),但图形窗口将是响应的。一旦事件循环停止(任何仍打开的图形窗口将变得无响应),您将能够再次使用提示符。重新启动事件循环将使任何打开的图形再次响应(并将处理任何排队等待的用户交互)。
要启动事件循环直到所有打开的图形关闭,请使用 pyplot.show 如下
pyplot.show(block=True)
要启动一个固定时间(以秒为单位)的事件循环,请使用 pyplot.pause。
如果你没有使用 pyplot,你可以通过 FigureCanvasBase.start_event_loop 和 FigureCanvasBase.stop_event_loop 来启动和停止事件循环。然而,在大多数不使用 pyplot 的上下文中,你是在一个大型GUI应用程序中嵌入Matplotlib,并且该应用程序的GUI事件循环应该已经在运行。
离开提示符,如果你想编写一个暂停以等待用户交互的脚本,或者在轮询更多数据时显示一个图形,这种技术会非常有用。更多详情请参见 交互式脚本。
输入钩子集成#
虽然在阻塞模式下运行GUI事件循环或显式处理UI事件是有用的,但我们可以做得更好!我们真正想要的是能够拥有一个可用的提示**和**交互式图形窗口。
我们可以使用交互式提示的'输入钩子'功能来实现这一点。这个钩子在提示等待用户输入时被调用(即使对于快速打字者来说,提示大部分时间也在等待人类思考和移动手指)。尽管不同提示之间的细节有所不同,但逻辑大致是
开始等待键盘输入
启动 GUI 事件循环
当用户按下任意键时,退出GUI事件循环并处理该键
重复
这给了我们一种同时拥有交互式GUI窗口和交互式提示的错觉。大多数时候GUI事件循环在运行,但一旦用户开始输入,提示就会再次接管。
这种分时技术仅允许事件循环在Python处于空闲状态并等待用户输入时运行。如果你想在长时间运行的代码期间保持GUI的响应性,则需要定期刷新GUI事件队列,如 显式地旋转事件循环 中所述。在这种情况下,是你的代码,而不是REPL,阻塞了进程,因此你需要手动处理“分时”。相反,一个非常慢的图形绘制将阻塞提示符,直到它完成绘制。
完全嵌入#
也可以反向操作,完全嵌入图形(以及 Python 解释器)到一个丰富的原生应用程序中。Matplotlib 为每个工具包提供了可以直接嵌入到 GUI 应用程序中的类(这就是内置窗口的实现方式!)。更多详情请参见 用户界面。
脚本和函数#
刷新图形界面的GUI事件。 |
|
请求在控制返回到GUI事件循环时重绘小部件。 |
|
阻塞调用以与图形交互。 |
|
阻塞调用以与图形交互。 |
|
显示所有打开的图形。 |
|
运行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的频率更新图形。
显式地旋转事件循环#
刷新图形界面的GUI事件。 |
|
请求在控制返回到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。默认情况下,stale 状态会传播到绘制树中的艺术家的父级,例如,如果 Line2D 实例的颜色发生变化,包含它的 Axes 和 Figure 也将被标记为“stale”。因此,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 属性来通知图形何时变得陈旧。
空闲绘制#
渲染 |
|
请求在控制返回到GUI事件循环时重绘小部件。 |
|
刷新图形界面的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 目前不管理 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_toolkit 中。prompt_toolkit 输入钩子的源代码位于 IPython.terminal.pt_inputhooks。
脚注