交互式图形和异步编程#
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]。核心事件循环有时被称为“主循环”,通常根据库的不同,通过类似 _exec
、run
或 start
的方法启动。
所有 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 窗口都能够响应,我们需要一种方法来允许循环“分时”
当您想要交互式窗口时,让 GUI 主循环阻止 python 进程
让 CLI 主循环阻止 python 进程并间歇性地运行 GUI 循环
将 python 完全嵌入 GUI 中(但这基本上是在编写一个完整的应用程序)
阻塞提示符#
显示所有打开的图形。 |
|
运行 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
的上下文中,您正在将 Matplotlib 嵌入到大型 GUI 应用程序中,并且 GUI 事件循环应该已经在为应用程序运行。
在离开提示符时,如果您想编写一个脚本,该脚本会暂停等待用户交互,或在轮询其他数据之间显示图形,则此技术非常有用。有关更多详细信息,请参阅 脚本和函数。
输入钩子集成#
虽然以阻塞模式运行 GUI 事件循环或显式处理 UI 事件很有用,但我们可以做得更好!我们真的希望能够拥有一个可用的提示符和交互式图形窗口。
我们可以使用交互式提示符的“输入钩子”功能来做到这一点。当提示符等待用户键入时,提示符会调用此钩子(即使对于快速的打字员来说,提示符也主要是在等待人类思考和移动手指)。尽管提示符之间的详细信息有所不同,但逻辑大致如下
开始等待键盘输入
启动 GUI 事件循环
一旦用户按下某个键,退出 GUI 事件循环并处理该键
重复
这让我们产生了同时拥有交互式 GUI 窗口和交互式提示符的错觉。大多数时候 GUI 事件循环都在运行,但是一旦用户开始键入,提示符就会再次接管。
这种分时技术只允许在 Python 处于空闲并等待用户输入时运行事件循环。如果希望 GUI 在长时间运行的代码期间保持响应,则必须按照显式旋转事件循环中所述,定期刷新 GUI 事件队列。在这种情况下,是您的代码(而不是 REPL)阻塞了进程,因此您需要手动处理“分时”。相反,非常缓慢的图形绘制会阻塞提示符,直到绘制完成。
完全嵌入#
也可以反过来,将图形(以及Python 解释器)完全嵌入到富本地应用程序中。Matplotlib 为每个工具包提供了可以直接嵌入到 GUI 应用程序中的类(这就是内置窗口的实现方式!)。有关更多详细信息,请参阅在图形用户界面中嵌入 Matplotlib。
脚本和函数#
刷新图形的 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
。默认情况下,过时状态会向上传播到绘制树中艺术家的父级,例如,如果Line2D
实例的颜色发生更改,则包含它的Axes
和Figure
也会被标记为“过时”。因此,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 的使用方式多种多样,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
。
脚注