iT邦幫忙

2026 iThome 鐵人賽

DAY 29
0
Software Development

用 Python 打造最順手的良率統計工具:半導體工程師的模組化開發之道系列 第 29 篇

Day 29:鴨子划水 - 使用 Matplotlib 滑鼠事件達成圖面即時查值

  • 分享至 

  • xImage
  •  

本篇目的: 理解 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,就能在旁邊的表格看到該顆晶粒的量測數據。

https://i.meee.com.tw/jlIRGM0.gif

Matplotlib 的滑鼠事件機制

在 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() 的實際內容會在下方說明

Wafer 晶粒座標與量測資料查詢

雖然 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。因此接下來我們會另外建立查詢資料結構,判斷該座標是否有對應值。

使用 Lookup Dictionary 建立晶粒查詢資料結構

我們在 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 負責即時查值。

在圖面管理器中加入滑鼠事件偵測與對應功能

Class 的初始化設定

首先我們要修改昨天建立的圖面管理器。先在 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 更新的排程。

使用 QTimer 控制滑鼠查值頻率

我們已經知道如何取得滑鼠座標,也建立了查值用的 Dictionary。但我們如果每移動一次滑鼠就更新一次表格,大家的電腦可能要當掉(晶粒是越做越小)。

因此本次採用另一種方式:**滑鼠事件只保存最新座標,並設立一計時器(設定 33 ms),時間到了才觸發UI更新事件。**相關流程如下示意:

https://ithelp.ithome.com.tw/upload/images/20261012/2018231937BsnknxS2.png

我們在 __init__中有設定 self.timer.timeout.connect(self._flush_hover),這個的用意是當 timer 時間到之後,觸發 _flush_hover 的 UI 更新事件。

不同滑鼠事件的相關 function 設定

我們在”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 個資訊可以交給同一套查值流程處理。

座標查晶粒值與 UI 更新的 function 設定

接下來就是由 QTimer 觸發的 _flush_hover(),這個 function 會抓取 _on_motion 抓到的座標以及用此座標查值、最後更新 UI 內的查詢表。

這段程式可以分成四個部分:

  1. 確認是否需要處理:若 _dirty 為 False,或沒有 Mapping 資料,就直接結束。

  2. 轉換並查詢座標:將滑鼠位置轉成 Die X/Y,確認是否存在於 Lookup Dictionary。

  3. 比較前一次晶粒:若 key 與 _last_key 相同,就不需要更新 GUI。

  4. 發送查詢結果:若晶粒改變,就透過 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 的實作流程如下:

  1. 假設滑鼠移動到 wafer 外圍。因為新的座標在 lookup 內沒有對應檢測資料組,因此 key 會變成 None。

  2. 此時 key != self._last_key 成立,因此程式會發出 self.die_hovered.emit(None)

  3. 通知 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(),並重設查值狀態。

這樣就完成了滑鼠事件的接收、合併與查詢管理。

Matplotlib 與 PySide6 的 Signal & Slot 串接

前面我們已經能透過滑鼠座標取得晶粒資料,但接下來還要將結果顯示在 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 的滑鼠查值邏輯。

To be continued →

這次我們透過 Matplotlib 的 MouseEvent 取得滑鼠位置,將座標轉換成 Wafer Die X/Y,再利用 Lookup Dictionary 查詢對應的量測數值。

另外,也使用 QTimer 控制滑鼠事件的處理頻率,避免 GUI 不斷重複更新相同晶粒的數據。最後透過 PySide6 的 Signal & Slot,將查詢結果顯示在表格中。

至此,從 day 27 的專案架構規劃、day 28 的 Matplotlib 嵌入,到今天的滑鼠即時查值,我們已經完成一套具備良率分析與互動式 Mapping 功能的 Wafer 分析 GUI。

終於來到了最後一天,明天的文章會回顧這 30 天的內容,順便分享一些寫作動機與作者碎碎念。


上一篇
Day 28:竹篙鬥菜刀 - 如何將 Matplotlib 圖像放入 QT UI 中
下一篇
Day 30:行百里半九十 - 結語與未來展望
系列文
用 Python 打造最順手的良率統計工具:半導體工程師的模組化開發之道 共 31 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言