Featured image of post 【PyQt5】Day 16 - 在 PyQt5 中取得圖片座標 (滑鼠位置) mousePressEvent,觀察圖片在 Qt 中產生的方式,對原圖進行座標換算處理

【PyQt5】Day 16 - 在 PyQt5 中取得圖片座標 (滑鼠位置) mousePressEvent,觀察圖片在 Qt 中產生的方式,對原圖進行座標換算處理

看完這篇文章你會得到的成果圖

前言

這一篇我們會繼續拿現有的 day 15 成品來改,
接下來我們要面對關於「處理圖片」與「顯示圖片」不一致的問題。

這是一個會影響非常深遠的問題,因此我們需要早點針對這個問題進行規劃。

我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。

https://wongwongnotes.com/posts/python/gui/pyqt/pyqt5-5/

此篇文章的範例程式碼 github

https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day16_mouse_get_pos

我們先來分析「處理圖片」與「顯示圖片」不一致的問題

為什麼會有「處理圖片」與「顯示圖片」不一致的問題?
最主要的原因是因為我們拿進來的圖片可能會解析度較高,

而我們處理的視窗就那麼大,我們沒辦法每次都讓他已「原解析度」來顯示。
所以在「處理圖片」與「顯示圖片」之間溝通的橋樑我們必須早點做處理。

而在我們程式中,「處理圖片」與「顯示圖片」分別對應到的是以下兩個變數。

  • 顯示的圖片 self.qpixmap
  • 處理中的圖片 self.img

分析兩者之間的「程式」關係

依照 day15 的邏輯,我們處理圖片顯示的過程中如下,
我們來看看這其中有沒有什麼可以簡化的地方。

1. self.img 是由 OpenCV 的 imread 取得的圖片 (讀入的原圖)

self.img = cv2.imread(self.img_path)

2. 我們會先經由以下處理,將他轉為 Qimg

self.qimg = QImage(self.img, self.origin_width, self.origin_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()

3. 再來會由 Qimg 轉為 QPixmap

self.origin_qpixmap = QPixmap.fromImage(self.qimg)

4. Qpixmap 可能會經由一些縮放的處理,最後藉由 Qlabel 顯示在畫面上

self.label_img.setPixmap(self.qpixmap)

分析這個流程,發現實際上圖片經過了很多次的轉換,才到最後顯示的部分。

OpenCV image -> Qimg -> QPixmap -> Qlabel顯示

我們目前最多是在 QPixmap 這裡才處理縮放的問題。
但接下來也許我們會需要針對原圖進行改動,這時候我們會需要處理原解析度的圖片。

也就是說,雖然我們是在 QPixmap 作業,但實際上處理的層級是在 OpenCV image

我們簡化這個流程後,我們可以知道我們可以記錄以下訊息會更方便我們處理:

  • QPixmap 現在的長寬 (會因為顯示而改變)
  • QPixmap 與 OpenCV image 的比例差距 (會因為顯示而改變)
  • OpenCV image 原圖的長寬 (永遠不變)

並且可以得到換算公式:

「QPixmap 現在的長寬」=「OpenCV image 的長寬」*「QPixmap 與 OpenCV image 的比例差距」

有沒有更不容易混淆的做法? - 不如我們都「正規化」一下

雖然上面我們已經把公式都寫出來也整理好了,但我覺得換算上還是很容易混淆…
例如:一不小心可能就會不小心把公式寫錯邊,到底誰乘誰?、到底誰除誰?

所以我們就統一用「正規化」來溝通吧,這樣標準就一定一致了。

  • 如下圖:我們原來的作法

這個做法的優點就是直覺,但使用公式上需注意有沒有不小心乘除搞錯。
等等我們要進行座標 (x ,y) 換算時更需要小心。

  • 如下圖:我們優化的作法 (正規化)

我們一律先把 (x,y) 座標正規化至一個長寬介於為 0~1 的比例上,
再來進行後續的換算,這樣我們只要知道「顯示圖片」、「實際圖片」的長寬,
在處理上都一慮用正規化的概念下去想 (x, y),
我們會相對比較難犯下不小心搞錯公式的問題。

簡單來說,可以比較不容易出現公式錯誤的問題。(對我個人來說)

UI 設計部份 (UI.py)

我們今天要來取得圖片上的座標,會由 day 15 的結果繼續進行更改,
上述的討論中,我們已經有討論到我們怎麼樣處理「顯示圖片」與「原先圖片」的差異,

我們就直接在 UI 上寫下以下內容,並給予對應參數:

*「點擊座標(顯示圖片的座標)」:label_click_pos
*「換算後,正規化的座標」:label_norm_pos
*「實際座標(對應到原圖片的座標)」:label_real_pos

我設計的介面如同上圖

轉換成 UI.py

一樣的編譯指令,我們加上 -x (也可不加),
我們就可以先檢視看看轉換後的視窗是不是跟我們想像的一樣。

轉換 day16.ui -> UI.py

pyuic5 -x day16.ui -o UI.py

執行看看 UI.py 畫面是否如同我們想像

一樣,這程式只有介面 (視覺上的呈現),沒有任何互動功能

  • 看看我們製作出來的介面
python UI.py

這樣我們的介面就大致出來囉!

controller 設計部份 (controller.py)

從 UI.py 中找出物件名稱

這次我們新增了 3 個 label

*「點擊座標(顯示圖片的座標)」:label_click_pos
*「換算後,正規化的座標」:label_norm_pos
*「實際座標(對應到原圖片的座標)」:label_real_pos

同 day13 的 scrollArea 說明,我們一樣需要刪除 scrollAreaWidgetContents 的部份

  • 新增與調整的 scrollArea 片段
self.scrollArea = QtWidgets.QScrollArea(self.verticalLayoutWidget)
self.scrollArea.setWidgetResizable(True)
self.scrollArea.setObjectName("scrollArea")
# self.scrollAreaWidgetContents = QtWidgets.QWidget()
# self.scrollAreaWidgetContents.setGeometry(QtCore.QRect(0, 0, 937, 527))
# self.scrollAreaWidgetContents.setObjectName("scrollAreaWidgetContents")
# self.label_img = QtWidgets.QLabel(self.scrollAreaWidgetContents)
self.label_img = QtWidgets.QLabel() # 調整為只單純宣告
self.label_img.setGeometry(QtCore.QRect(0, 0, 941, 521))
self.label_img.setObjectName("label_img")
# self.scrollArea.setWidget(self.scrollAreaWidgetContents)
self.scrollArea.setWidget(self.label_img)

取得名稱後,去修改控制部分

截至到 day15,總共有 controller.py, img_controller.py 兩支程式來控制我們的系統,

  • controller.py:主要控制程式的部分
  • img_controller.py:另外封裝專門處理圖片的部分

修改控制主程式的 controller.py

我們接續 day 15 的內容,
新增我們剛剛在 UI 增加的 label,因為也是跟圖片有關的內容,
我們只做參數的傳遞,其他交由 img_controller.py 處理。

self.img_controller = img_controller(img_path=self.file_path,
                                     label_img=self.ui.label_img,
                                     label_file_path=self.ui.label_file_name,
                                     label_ratio=self.ui.label_ratio,
                                     label_img_shape=self.ui.label_img_shape,
                                     label_click_pos=self.ui.label_click_pos,
                                     label_norm_pos=self.ui.label_norm_pos,
                                     label_real_pos=self.ui.label_real_pos)

另外封裝專門處理圖片的 img_controller.py

我們替 day 15 的 function 「擴充」新的偵測座標功能

宣告的地方,新增傳入的參數

class img_controller(object):
    def __init__(self, img_path, label_img, label_file_path, label_ratio, label_img_shape, label_click_pos, label_norm_pos, label_real_pos):
        self.label_click_pos = label_click_pos
        self.label_norm_pos = label_norm_pos
        self.label_real_pos = label_real_pos

更新圖片時,同步增加監聽偵測滑鼠位置的 mousePressEvent

def __update_img(self):       
        self.label_img.setPixmap(self.qpixmap)
        self.label_img.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
        self.label_img.mousePressEvent = self.get_clicked_position

self.label_img.mousePressEvent = self.get_clicked_position

我們替 Qlabel 增加一個 mousePressEvent,而宣告的 function 就是我們等等會撰寫的 get_clicked_position()

幫助我們取得回傳座標的 get_clicked_position

def get_clicked_position(self, event):
    x = event.pos().x()
    y = event.pos().y() 
    self.norm_x = x/self.qpixmap.width()
    self.norm_y = y/self.qpixmap.height()
    print(f"(x, y) = ({x}, {y}), normalized (x, y) = ({self.norm_x}, {self.norm_y})")
    self.__update_text_clicked_position(x, y)

我們每觸發一次上述的點擊 mousePressEvent,就會執行一次 get_clicked_position 的內容,
我們可以從 event 這個變數取得點擊的 (x, y)

  • x = event.pos().x()
  • y = event.pos().y()

在我們最上方的討論中,我們決定要把所有的座標進行正規化,
以避免直接運算,容易產生的公式乘除錯誤的問題,
因此我們直接透過以下公式將座標正規化。

  • self.norm_x = x/self.qpixmap.width()
  • self.norm_y = y/self.qpixmap.height()

最後我們可以顯示一下,我們所點擊的 (x, y),與正規化後介於 0~1 之間呈現比例展示的 x, y 座標。
並將這些資訊傳入我們修改文字的 function 中。

  • print(f"(x, y) = ({x}, {y}), normalized (x, y) = ({self.norm_x}, {self.norm_y})")
  • self.__update_text_clicked_position(x, y)

更新畫面座標資訊的 __update_text_clicked_position()

因為只是純更新資訊,我們將此 function 設為 private,不讓我們能夠輕易存取內容,
我們更新三種座標的顯示:

*「點擊座標(顯示圖片的座標)」:label_click_pos
*「換算後,正規化的座標」:label_norm_pos
*「實際座標(對應到原圖片的座標)」:label_real_pos

def __update_text_clicked_position(self, x, y):
    self.label_click_pos.setText(f"Clicked postion = ({x}, {y})")
    self.label_norm_pos.setText(f"Normalized postion = ({self.norm_x:.3f}, {self.norm_y:.3f})")
    self.label_real_pos.setText(f"Real postion = ({int(self.norm_x*self.origin_width)}, {int(self.norm_y*self.origin_height)})")

這樣就更新完了。

執行結果

照我們 day5 的程式架構,我們執行

python start.py

我們點擊任意的點,就會顯示「該座標」、「正規化座標」、「對應原圖實際座標」。

而在我們的 terminal 當中也會顯示一些我們剛剛印出來的資訊,方便我們 debug。

觀察並檢查座標 (x, y) - 我們在 UI 介面上點擊的原點在哪?

這邊有個衍伸的問題,我們在 UI 介面上點擊的原點在哪?
也就是說 (0, 0) 是從哪裡開始算的呢?

我們可以順著我們剛剛做出來的成品,一路找到 (0, 0) 的位置,

我們發現 (0, 0) 座標剛好就位於「圖片」的左上角,
而不是 「UI介面」的左上角,看起來完全這符合我們預期
(這邊只是再確認座標與我們想像無誤,免得後續才回來處理很麻煩)

至於圖片的最右下角,座標又是什麼呢?

我們可以發現就是圖片目前「顯示」的解析度的上限值,
因此我們可以完全確認,我們正在操作的座標就是 QPixmap 的座標,
我們的換算都可以由 QPixmap 出發,依照比例進行換算。

Reference

使用 Hugo 建立
主題 StackJimmy 設計