互動式圖表和非同步程式設計#

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 或網頁) 都具有某種方法來擷取使用者互動並將其傳遞回應用程式 (例如 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以請求重新繪製畫布。可以將此方法視為「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實例的顏色發生了變更,則包含它的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

腳註