這一篇我們會繼續拿現有的 day 16 成品來改,
我們在 day 16 已經學會了如何取得點座標,
接下來我們要將「點畫在畫面上」、並「取得該點座標」,
而點座標又可分為「正規化 roi 比例座標」、「實際圖片座標」
而在其中,「畫點」的功能,我們雖然能夠透過 PyQt 的 Qpainter 實現,
不過因為我們後續也會大量使用 OpenCV 作為我們圖片處理的引擎,
所以不如就趁先在開始導入吧!
我們接下來的討論,會基於讀者已經先讀過我 day5 文章 的架構下去進行程式設計
如果還不清楚我程式設計的邏輯 (UI.py、controller.py、start.py 分別在幹麻)
建議先閱讀 day5 文章後再來閱讀此文。
https://www.wongwonggoods.com/python/pyqt5-5/
https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day17_roi_drawer
為了往後的開發順利,這次的開發我們必須謹慎先規劃一下了,
接下來我們想導入的 OpenCV 作為我們圖像處理的引擎,
我預期會是一個像 library 一樣的模組,
我們把所有 OpenCV 圖像處理的功能的「細節」做在裡面,
而 img_controller.py 只需要呼叫「這個 OpenCV engine 的 API 即可順利使用
」。
最好的情況甚至是 img_controller.py 都不用「import cv2」,
而只有 OpenCV engine 這支程式統一「import cv2」,
在此處理 OpenCV 相關的事情,所以我們等等也會稍微修改一下 day16 的部分。
我們接續 day 16 的結果進行修改,
我們新增兩個 QTextEdit,作為 roi 輸出的欄位。
為什麼使用 QTextEdit? 因為這樣我們才能複製我們要用的結果XDD
Qlabel 只能顯示就不能複製了。
我們就直接在 UI 上新增以下內容,並給予對應參數:
*「顯示資訊用,不會改動 - Ratio ROI」:label_info_ratio_roi
*「顯示資訊用,不會改動 - Real ROI」:label_info_real_roi
*「依比例表示的 ROI 顯示欄位」:text_ratio_roi
*「依實際圖片座標表示的 ROI 顯示欄位」:text_real_roi
我設計的介面如同上圖
一樣的編譯指令,我們加上 -x (也可不加),
我們就可以先檢視看看轉換後的視窗是不是跟我們想像的一樣。
pyuic5 -x day17.ui -o UI.py
一樣,這程式只有介面 (視覺上的呈現),沒有任何互動功能
python UI.py
這樣我們的介面就大致出來囉!
這次我們新增了 3 個 label
*「顯示資訊用,不會改動 - Ratio ROI」:label_info_ratio_roi
*「顯示資訊用,不會改動 - Real ROI」:label_info_real_roi
*「依比例表示的 ROI 顯示欄位」:text_ratio_roi
*「依實際圖片座標表示的 ROI 顯示欄位」:text_real_roi
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)
我們今天會新增一個 opencv_engine.py,作為圖像處理的引擎使用,
對我來說最理想的情況,就是只有 opencv_engine.py 這支程式 import cv2,
然後我們將所有會用到 OpenCV 的功能封裝成 API (function),
再給 img_controller.py 去 call API(function)。
為了使用方便,我們把這個程式會呼叫的部分全部都用 class 封裝起來,
而且方便直接取用,我們讓使用者不需要先宣告一個 instance(物件),
全部都寫成 static method,方便使用者直接呼叫這些已經包好的功能。
import cv2
class opencv_engine(object):
@staticmethod
def point_float_to_int(point):
return (int(point[0]), int(point[1]))
@staticmethod
def read_image(file_path):
return cv2.imread(file_path)
@staticmethod
def draw_point(img, point=(0, 0), color = (0, 0, 255)): # red
point = opencv_engine.point_float_to_int(point)
print(f"get {point=}")
point_size = 1
thickness = 4
return cv2.circle(img, point, point_size, color, thickness)
我們一共封裝了三個功能,point_float_to_int, read_image, draw_point,
對,所以我們等等也會把前幾天的透過 cv2.imread 的功能搬到這裡來,
再改用 staticmethod 的呼叫方式 opencv_engine.read_image(file_path),
取得回傳的圖片。
因為 OpenCV 的點座標處理會常常要求一定要整數輸入,
而我們又常常以 (x, y) 這樣的座標為單位進行處理,
而不是個別傳入 x 與 y,因此乾脆獨立依格 function 專門把傳進來的 (x, y)
強轉成 (int x, int y) 並回傳。
這個就是我們的 OpenCV 老朋友了,
為了使程式更加彈性,我特別把 point, color 拉出來,
因此之後傳入時,我們除了座標之外、也可以指定傳入的顏色。
不知道怎麼使用 OpenCV 畫點?
可以參考我的另外一篇文,內有詳細說明:【OpenCV】11 – OpenCV 建立新空白圖、畫點、畫圓 create new pictures, draw points and draw circle
依照上面的內容我們需要「修改圖片傳入的部分」,
另外也需要新增「顯示 roi 文字的部分」、「呼叫顯示點的功能」
我們要從 opencv_engine.py 導入 class opencv_engine,
因此要 from opencv_engine import opencv_engine
(前者為 .py 檔名,後者為 class 名稱)
from opencv_engine import opencv_engine
這邊只截錄重點部分,
因為我們這隻 img_controller.py 程式不想再 import cv2 了,
全交由 opencv_engine 處理,
所以我們將原來的 cv2.imread() 改為 opencv_engine.read_image()
def read_file_and_init(self):
try:
self.origin_img = opencv_engine.read_image(self.img_path)
self.origin_height, self.origin_width, self.origin_channel = self.origin_img.shape
except:
self.origin_img = opencv_engine.read_image('sad.png')
self.origin_height, self.origin_width, self.origin_channel = self.origin_img.shape
我們剛剛已經封裝好程式的功能了,所以這邊可以直接呼叫 opencv_engine.draw_point(),
並把點座標傳入,記得先換算好座標 (昨天提到的,使用座標正規化統一處理)
def draw_point(self, point):
# give me normalized point, i will help you to transform to origin cv image position
cv_image_x = point[0]*self.origin_width
cv_image_y = point[1]*self.origin_height
self.display_img = opencv_engine.draw_point(self.display_img, (cv_image_x, cv_image_y))
self.__update_img()
def __update_text_point_roi(self, point):
# give me normalized point, i will help you to transform to origin cv image position
cv_image_x = point[0]*self.origin_width
cv_image_y = point[1]*self.origin_height
self.ui.text_ratio_roi.append(f"[{point[0]:.6f}, {point[1]:.6f}]")
self.ui.text_real_roi.append(f"[{int(cv_image_x)}, {int(cv_image_y)}]")
這邊就單純更新文字了,有正規化的 roi 點擊座標,
相信更新起來也相當容易吧,只需要做一些簡單的運算。
這邊為了我自己使用 roi 要使用的格式,我特別改為 list 顯示點座標,
沒有什麼特別的不使用 tuple 而使用 list 的原因。
新增最後兩行,畫點 draw_point、更新 roi 文字資訊 __update_text_point_roi
def get_clicked_position(self, event):
x = event.pos().x()
y = event.pos().y()
self.__update_text_clicked_position(x, y)
norm_x = x/self.qpixmap.width()
norm_y = y/self.qpixmap.height()
self.draw_point((norm_x, norm_y))
self.__update_text_point_roi((norm_x, norm_y))
為什麼會突然提到這個...,
這個就是我前幾天不小心欠下的技術債,
因為我沒有先規劃設定比例的邏輯,
導致現在更新圖片的思路非常混亂。
什麼混亂法呢? 就是顯示圖片的時候可能需要先想,要不要先去 call 設定比例,
欸不對設定比例後接續也會更新圖片。
可是我有時候只想更新圖片,比例也沒變,我還需要 call 設定比例嗎???
就是沒有好好規劃啦! 所以我決定重新規劃架構,並畫出來這樣思路就很清楚了。
這邊我已經先優化了 ratio 那邊的混亂邏輯才畫出來的,
所以這已經是整理過的一版XD,
啊不過做為示範,這裡其實還有可以優地的地方,
觀察下圖我們可以發現:
在初始化事件中 init() 與 set_path() 設定新圖片路徑中,
我們有同樣的邏輯,都是先呼叫讀檔後去更新圖片。
這些都是要整理出流程圖才知道能優化的,沒整理前我也沒想到這裡可以優化。
我們把重複的部分都塞進讀檔內,讓讀檔後可以直接更新,
流程圖的邏輯就可以看起來更乾淨。
這個也是為了以後的自己好,因為程式會越寫越大,
趁現在我們把命名格式統一一下,
好啦(累癱,至少現在趁程式還小,趕快把該整理的格式都整理後,之後相信會舒服很多的。
這會是我最後一次貼完整程式碼了,因為越寫越大XDD,
再貼完整程式碼於文章中會太占版面XDD,
之後會只貼更新的部分,想要完整程式碼的話可以去看我的 github。
from PyQt5 import QtCore
from PyQt5.QtGui import QImage, QPixmap
from opencv_engine import opencv_engine
class img_controller(object):
def __init__(self, img_path, ui):
self.img_path = img_path
self.ui = ui
self.ratio_value = 50
self.read_file_and_init()
def read_file_and_init(self):
try:
self.origin_img = opencv_engine.read_image(self.img_path)
self.origin_height, self.origin_width, self.origin_channel = self.origin_img.shape
except:
self.origin_img = opencv_engine.read_image('sad.png')
self.origin_height, self.origin_width, self.origin_channel = self.origin_img.shape
self.display_img = self.origin_img
self.__update_text_file_path()
self.ratio_value = 50 # re-init
self.__update_img()
def set_path(self, img_path):
self.img_path = img_path
self.read_file_and_init()
def __update_img_ratio(self):
self.ratio_rate = pow(10, (self.ratio_value - 50)/50)
qpixmap_height = self.origin_height * self.ratio_rate
self.qpixmap = self.qpixmap.scaledToHeight(qpixmap_height)
self.__update_text_ratio()
self.__update_text_img_shape()
def __update_img(self):
bytesPerline = 3 * self.origin_width
qimg = QImage(self.display_img, self.origin_width, self.origin_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
self.qpixmap = QPixmap.fromImage(qimg)
self.__update_img_ratio()
self.ui.label_img.setPixmap(self.qpixmap)
self.ui.label_img.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
self.ui.label_img.mousePressEvent = self.set_clicked_position
def __update_text_file_path(self):
self.ui.label_file_name.setText(f"File path = {self.img_path}")
def __update_text_ratio(self):
self.ui.label_ratio.setText(f"{int(100*self.ratio_rate)} %")
def __update_text_img_shape(self):
current_text = f"Current img shape = ({self.qpixmap.width()}, {self.qpixmap.height()})"
origin_text = f"Origin img shape = ({self.origin_width}, {self.origin_height})"
self.ui.label_img_shape.setText(current_text+"\t"+origin_text)
def __update_text_clicked_position(self, x, y):
# give me qpixmap point
self.ui.label_click_pos.setText(f"Clicked postion = ({x}, {y})")
norm_x = x/self.qpixmap.width()
norm_y = y/self.qpixmap.height()
print(f"(x, y) = ({x}, {y}), normalized (x, y) = ({norm_x}, {norm_y})")
self.ui.label_norm_pos.setText(f"Normalized postion = ({norm_x:.3f}, {norm_y:.3f})")
self.ui.label_real_pos.setText(f"Real postion = ({int(norm_x*self.origin_width)}, {int(norm_y*self.origin_height)})")
def set_zoom_in(self):
self.ratio_value = max(0, self.ratio_value - 1)
self.__update_img()
def set_zoom_out(self):
self.ratio_value = min(100, self.ratio_value + 1)
self.__update_img()
def set_slider_value(self, value):
self.ratio_value = value
self.__update_img()
def set_clicked_position(self, event):
x = event.pos().x()
y = event.pos().y()
self.__update_text_clicked_position(x, y)
norm_x = x/self.qpixmap.width()
norm_y = y/self.qpixmap.height()
self.draw_point((norm_x, norm_y))
self.__update_text_point_roi((norm_x, norm_y))
def draw_point(self, point):
# give me normalized point, i will help you to transform to origin cv image position
cv_image_x = point[0]*self.origin_width
cv_image_y = point[1]*self.origin_height
self.display_img = opencv_engine.draw_point(self.display_img, (cv_image_x, cv_image_y))
self.__update_img()
def __update_text_point_roi(self, point):
# give me normalized point, i will help you to transform to origin cv image position
cv_image_x = point[0]*self.origin_width
cv_image_y = point[1]*self.origin_height
self.ui.text_ratio_roi.append(f"[{point[0]:.6f}, {point[1]:.6f}]")
self.ui.text_real_roi.append(f"[{int(cv_image_x)}, {int(cv_image_y)}]")
照我們 day5 的程式架構,我們執行
python start.py
我們所有在畫面點擊的點,都會在下方以兩種不同的方式表示,
分別是比例的座標、實際的圖片座標,
就這樣完成了我要使用的 roi 標註工具。
★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 17 / Project 製作標註 roi 工具, 開始導入 OpenCV 作為繪圖引擎, 在圖上畫點並顯示座標