本篇目的: 理解 Matplotlib 如何取得滑鼠位置與對應 Wafer 晶粒座標,並透過 QTimer 與 PySide6 的 Signal & Slot,在 GUI 中即時顯示晶粒量測數值。
在 day 28 中我們已經成功將 Matplotlib 的 wafer mapping 嵌入 QT UI 中,並且能透過 GUI 切換 wafer、測項與 color scale。
不過實務上,當我們看到 mapping 某個區域的顏色特別異常時,除了想知道異常發生在哪裡,還會想即時確認這個區塊的異常值來確認可能的失效模式。
因此今天 day 29 要說明如何利用 Matplotlib 滑鼠事件達成滑鼠即時查值功能,讓 user 將滑鼠移到 mapping 圖上的 die,就能在旁邊的表格看到該顆晶粒的量測數據。

在 day 20,我們介紹過利用 Qt 的 eventFilter 偵測鍵盤行為。而 Matplotlib 的 canvas 提供 mpl_connect() 讓我們可以將圖面上的各種事件連接到 Python function。使用方式如下:
# WaferMapView.__init__() 內
# Figure、Canvas 與 Layout 功能同 day 28 設定
self.canvas.mpl_connect(
"motion_notify_event", self._on_motion
)
motion_notify_event 代表滑鼠移動事件。這樣每當滑鼠在 Canvas 上移動,Matplotlib 就會呼叫 _on_motion(),並傳入一個 MouseEvent 物件。
MouseEvent 物件物件包含滑鼠位置等資訊,本次會使用以下三個屬性:
| 屬性 | 用途 |
|---|---|
event.inaxes |
判斷滑鼠是否位於 Mapping 的 Axes |
event.xdata |
取得滑鼠所在位置的 X 資料座標 |
event.ydata |
取得滑鼠所在位置的 Y 資料座標 |
另外就算滑鼠位於 canvas 中,也不代表一定在 mapping 的繪圖區域內,有可能是空白區、key 等等沒有值的地方。因此若滑鼠停在繪圖區外時就不應繼續後面的查值行為。
因此我們可以利用 event.inaxes is self.axes,確認滑鼠目前位於 Mapping 使用的 Axes。另外當滑鼠完全離開 canvas 時,mpl_connect() 內有對應的 figure_leave_event 事件可以設定,我們會設定 _on_leave() 清空表格內顯示的晶粒數值。
self.canvas.mpl_connect(
"figure_leave_event", self._on_leave
)
_on_motion()與_on_leave()的實際內容會在下方說明
雖然 event.xdata、event.ydata 代表滑鼠在圖表上的資料座標,但這些數值不一定是整數。當我們滑鼠在大顆晶粒內滑動,不太可能完美停在晶粒中間,取得的 X 座標可能是 12.2、11.8 等數值。因此我們需要先將它轉換成 Die 的整數座標,才能查到對應的量測資料。
pcolormesh() 在繪製時,需要為每顆晶粒設定邊界,本次以晶粒中心的整數座標為基準,將邊界設定在中心的 ±0.5。例如 X=12 的 Die,其 X 方向範圍為 11.5~12.5。
因此若我們抓到滑鼠座標後,要先將座標轉換為(X, Y) 的整數 Tuple,方式如下:
key = tuple(
int(np.floor(value + 0.5))
for value in self._pending
)
這裡使用 np.floor(value + 0.5),是為了讓座標轉換規則與 Mapping 的晶粒邊界一致。
不過轉換成整數座標,也不代表該位置一定存在有效晶粒。例如 wafer 外圍、缺角或對位 key 都可能對應不到實際 Die。因此接下來我們會另外建立查詢資料結構,判斷該座標是否有對應值。
我們在 day 6~7 繪製 Mapping 時,主要將資料整理成二維陣列,交給 pcolormesh() 使用。
但是滑鼠查值的需求不同:我們已經知道 Die 的 X/Y,希望直接取得該顆晶粒的量測資料。如果每次滑鼠移動都重新篩選 Pandas DataFrame,會浪費許多時間。因此本專案中會事先將各檢測資料另外用 dict 保存,並以晶粒座標 (X, Y) 作為 Key、量測資料作為 Value。實作方式如下:
lookup = {
(12, 18): {
"X": 12,
"Y": 18,
"Vf2": 2.91,
"Po": 0.52,
"Wd2": 0.47,
}
}
這樣只需要提供 (12, 18),就能直接找到對應的晶粒資料。
在 mapping_cal.py 的 prepare_wafer_map() 中,我們使用以下方式建立 Lookup Dictionary:
lookup = {}
item_index = VALUE_COLUMNS.index(item)
for row in numeric.itertuples(index=False, name=None):
if np.isfinite(row[item_index]):
lookup[(int(row[0]), int(row[1]))] = dict(
zip(VALUE_COLUMNS, row)
)
其中 VALUE_COLUMNS 包含 X、Y、Vf2、Po、Wd2,而 item 是目前選擇的 Mapping 測項。這裡只會對目前測項具有有效數值的 Die 建立查詢資料,因此沒有有效繪圖數值的位置,也不會被當成可查詢晶粒。
最後,只要透過:
die_data = lookup.get((12, 18))
就能取得該顆 Die 的所有量測資料,不需要再次搜尋整張 DataFrame。
換句話說,同一份 Wafer 資料可以依照用途整理成不同結構:二維陣列負責繪圖,Lookup Dictionary 負責即時查值。
首先我們要修改昨天建立的圖面管理器。先在 WaferMapView.__init__() 中建立 QTimer 及相關狀態變數:
class WaferMapView(QObject):
die_hovered = Signal(object)
def __init__(self, container: QWidget) -> None:
super().__init__(container)
# Figure、Canvas 與 Layout 功能同 day 28 設定
# 其他繪圖狀態初始化同 day 28 設定
self._pending = None
self._dirty = False
self._last_key = None
self.timer = QTimer(self)
self.timer.setInterval(33)
self.timer.timeout.connect(self._flush_hover)
self.canvas.mpl_connect(
"motion_notify_event", self._on_motion
)
self.canvas.mpl_connect(
"figure_leave_event", self._on_leave
)
本次新增的各變數意義與用途如下:
| 變數 | 意義 | 用途 |
|---|---|---|
_pending |
最新的滑鼠資料座標 (xdata, ydata);None 表示沒有有效繪圖座標 |
暫存滑鼠位置,供 _flush_hover() 查詢 |
_dirty |
True:有新事件尚待處理;False:沒有待處理的新事件 |
避免 QTimer 在沒有新事件時重複查值 |
_last_key |
上次通知 GUI 的 Die 座標 (X, Y);None 表示目前沒有有效晶粒 |
判斷晶粒是否改變,避免重複更新,並處理無晶粒位置的表格清空 |
timer |
間隔設為 33 ms 的 QTimer 物件 | 定期觸發 _flush_hover() |
特別注意,_dirty 不是用來判斷晶粒是否合格,而是標記有沒有新的滑鼠事件需要處理。另外 QTimer 是由 Qt 事件迴圈管理的計時器,用途是作為 UI 更新的排程。
我們已經知道如何取得滑鼠座標,也建立了查值用的 Dictionary。但我們如果每移動一次滑鼠就更新一次表格,大家的電腦可能要當掉(晶粒是越做越小)。
因此本次採用另一種方式:**滑鼠事件只保存最新座標,並設立一計時器(設定 33 ms),時間到了才觸發UI更新事件。**相關流程如下示意:

我們在 __init__中有設定 self.timer.timeout.connect(self._flush_hover),這個的用意是當 timer 時間到之後,觸發 _flush_hover 的 UI 更新事件。
我們在”Matplotlib 的滑鼠事件機制”這一段中有提到"滑鼠移動”以及"滑鼠離開圖面”有對應的 function。以下會對這2個 function 做相關說明。
首先我們建立滑鼠移動”對應的 _on_motion(),這個 function 只做兩件事:
保存最新滑鼠座標後將 _dirty 設成 True,這樣後續 UI 才會更新
如果滑鼠不在 Mapping 的 Axes 中,就把 _pending 設為 None
def _on_motion(self, event) -> None:
"""保存最新鼠標位置供計時器處理."""
# 忽略 Colorbar 與圖外座標
self._pending = (
(event.xdata, event.ydata)
if event.inaxes is self.axes
and self.axes is not None
and event.xdata is not None
and event.ydata is not None
else None
)
self._dirty = True
另外當滑鼠完全離開 Canvas 時,則由 _on_leave() 將相關的 flag 清空讓後續 UI 中顯示表格變成空白。
def _on_leave(self, event) -> None:
"""在鼠標離開畫布時排入清空事件."""
self._pending = None
self._dirty = True
如此一來,無論滑鼠移動到有效 Die、沒有晶粒的區域,或直接離開 Canvas,都有”是否更新”以及”對應座標” 2 個資訊可以交給同一套查值流程處理。
接下來就是由 QTimer 觸發的 _flush_hover(),這個 function 會抓取 _on_motion 抓到的座標以及用此座標查值、最後更新 UI 內的查詢表。
這段程式可以分成四個部分:
確認是否需要處理:若 _dirty 為 False,或沒有 Mapping 資料,就直接結束。
轉換並查詢座標:將滑鼠位置轉成 Die X/Y,確認是否存在於 Lookup Dictionary。
比較前一次晶粒:若 key 與 _last_key 相同,就不需要更新 GUI。
發送查詢結果:若晶粒改變,就透過 die_hovered.emit() 發送數據。
def _flush_hover(self) -> None:
"""查詢最新鼠標位置並通知晶粒變更."""
# 將連續滑鼠事件合併為一次座標查詢
if not self._dirty or self.map_data is None:
return
self._dirty = False
key = None
if self._pending is not None and np.isfinite(self._pending).all():
key = tuple(
int(np.floor(value + 0.5))
for value in self._pending
)
if key not in self.map_data.lookup:
key = None
# 同一晶粒不重複更新表格
if key != self._last_key:
self._last_key = key
self.die_hovered.emit(
self.map_data.lookup[key] if key is not None else None
)
我們剛剛有提到**”當滑鼠停在沒有晶粒的位置時,要清空 GUI 的數值表格”。**在這個 function 的實作流程如下:
假設滑鼠移動到 wafer 外圍。因為新的座標在 lookup 內沒有對應檢測資料組,因此 key 會變成 None。
此時 key != self._last_key 成立,因此程式會發出 self.die_hovered.emit(None)
通知 GUI 清空原本的晶粒數值,而不是繼續顯示上一顆 Die
另外如果滑鼠一直停在無晶粒區域,因為 _last_key 已經是 None,也不會反覆發送清空訊號。因此 _last_key 不只用來減少重複更新,也能協助處理有效 Die 與無晶粒區域之間的切換。
最後,Timer 並不是建立後就立即啟動,而是在成功繪製 Mapping 時才開始運作:
def draw_map(self, wafer_id, item, map_data, scale, show_grid):
# Mapping 繪製與 Canvas 更新功能同 day 28 設定
# 成功建立 Mapping 後啟動查值 Timer
self.timer.start()
而當 Mapping 被清除時,clear() 也會執行 self.timer.stop(),並重設查值狀態。
這樣就完成了滑鼠事件的接收、合併與查詢管理。
前面我們已經能透過滑鼠座標取得晶粒資料,但接下來還要將結果顯示在 GUI 中。
我們在 day 28 有提到 WaferMapView 繼承 QObject 是為了能發射 signal,這個 signal 就是UI 表格要更新的內容。
首先我們在 WaferMapView 中定義:
class WaferMapView(QObject):
die_hovered = Signal(object)
這裡使用 Signal(object),因為本次需要傳遞兩種資料:
| 傳送內容 | 代表意義 | GUI 行為 |
|---|---|---|
| Dictionary | 找到有效 Die,包含 X、Y、Vf2、Po、Wd2 | 顯示量測數值 |
None |
沒有有效 Die 或 Mapping 已清除 | 清空數值表格 |
如此一來,繪圖管理器只需要發送資料,不必知道 GUI 使用哪個表格來顯示結果。
在 Controller 初始化時,我們先針對監看用 table 做設定,加入行列標題與設定不可編輯:
# MainController.__init__() 內
# WaferMapView 建立功能同 day 28 設定
table = ui_window.table_show_map_value
table.setRowCount(5)
table.setColumnCount(1)
table.setVerticalHeaderLabels(VALUE_COLUMNS)
table.setHorizontalHeaderLabels(["數值"])
for row in range(5):
cell = QTableWidgetItem("")
cell.setFlags(cell.flags() & ~Qt.ItemFlag.ItemIsEditable)
table.setItem(row, 0, cell)
這裡 VALUE_COLUMNS 的內容如下,因此會依序顯示五項資料。
VALUE_COLUMNS = ["X", "Y", "Vf2", "Po", "Wd2"]
另外,我們在初始化時就先建立所有 QTableWidgetItem,後續滑鼠移動時只需要修改文字,不必反覆建立新的表格物件。
接著,在 Controller 的 make_connection() 中,將 Signal 連接到負責更新數值的 Method:
self.map_view.die_hovered.connect(self.update_map_values)
最後,完整的 update_map_values() 如下:
# controller 中
def update_map_values(self, values: dict | None) -> None:
"""更新鼠標對應晶粒的五列數值.
Args:
values: 含 X, Y, Vf2, Po, Wd2 的晶粒字典,
None 表示清空數值.
"""
# 重用表格儲存格,避免每次滑鼠移動建立新物件
for row, column in enumerate(VALUE_COLUMNS):
text = ""
if values is not None:
text = (str(int(values[column])) if column in ("X", "Y")
else f"{values[column]:.3f}")
self.ui_window.table_show_map_value.item(row, 0).setText(text)
這段程式的重點在於 values 的內容。
如果收到有效的 Die Dictionary,就依照 VALUE_COLUMNS 取得資料。其中 X/Y 顯示為整數,Vf2、Po、Wd2 則保留三位小數。
但如果收到的是 None,因為 text 預設就是空字串 "",所以五個儲存格都會被清空。這也完成前一節提到的功能:當滑鼠移到沒有晶粒的位置,GUI 就不再保留上一顆 Die 的數值,避免使用者誤判。
透過 Signal & Slot,WaferMapView 負責判斷滑鼠位置並提供晶粒資料,Controller 則負責更新 QTableWidget。這樣即使未來想改用其他 UI 元件顯示晶粒數據,也不需要重新修改 Matplotlib 的滑鼠查值邏輯。
這次我們透過 Matplotlib 的 MouseEvent 取得滑鼠位置,將座標轉換成 Wafer Die X/Y,再利用 Lookup Dictionary 查詢對應的量測數值。
另外,也使用 QTimer 控制滑鼠事件的處理頻率,避免 GUI 不斷重複更新相同晶粒的數據。最後透過 PySide6 的 Signal & Slot,將查詢結果顯示在表格中。
至此,從 day 27 的專案架構規劃、day 28 的 Matplotlib 嵌入,到今天的滑鼠即時查值,我們已經完成一套具備良率分析與互動式 Mapping 功能的 Wafer 分析 GUI。
終於來到了最後一天,明天的文章會回顧這 30 天的內容,順便分享一些寫作動機與作者碎碎念。