iT邦幫忙

2021 iThome 鐵人賽

DAY 17
0
Software Development

【今年還是不夠錢買psQQ,不如我們用PyQt自己寫一個】系列 第 17

【沒錢買ps,PyQt自己寫】Day 17 / Project 製作標註 roi 工具, 開始導入 OpenCV 作為繪圖引擎, 在圖上畫點並顯示座標

  • 分享至 

  • xImage
  •  

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

前言

這一篇我們會繼續拿現有的 day 16 成品來改,
我們在 day 16 已經學會了如何取得點座標,
接下來我們要將「點畫在畫面上」、並「取得該點座標」,
而點座標又可分為「正規化 roi 比例座標」、「實際圖片座標」

而在其中,「畫點」的功能,我們雖然能夠透過 PyQt 的 Qpainter 實現,
不過因為我們後續也會大量使用 OpenCV 作為我們圖片處理的引擎,
所以不如就趁先在開始導入吧!

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

https://www.wongwonggoods.com/python/pyqt5-5/

此篇文章的範例程式碼 github

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

導入 OpenCV 作為繪圖引擎

為了往後的開發順利,這次的開發我們必須謹慎先規劃一下了,
接下來我們想導入的 OpenCV 作為我們圖像處理的引擎,
我預期會是一個像 library 一樣的模組,
我們把所有 OpenCV 圖像處理的功能的「細節」做在裡面,
而 img_controller.py 只需要呼叫「這個 OpenCV engine 的 API 即可順利使用」。

最好的情況甚至是 img_controller.py 都不用「import cv2」,
而只有 OpenCV engine 這支程式統一「import cv2」,
在此處理 OpenCV 相關的事情,所以我們等等也會稍微修改一下 day16 的部分。

  • 如下圖:(點圖可放大)

UI 設計部份 (UI.py)

我們接續 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

我設計的介面如同上圖

轉換成 UI.py

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

轉換 day17.ui -> UI.py

pyuic5 -x day17.ui -o UI.py

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

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

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

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

controller 設計部份 (controller.py)

從 UI.py 中找出物件名稱

這次我們新增了 3 個 label

*「顯示資訊用,不會改動 - Ratio ROI」:label_info_ratio_roi
*「顯示資訊用,不會改動 - Real ROI」:label_info_real_roi
*「依比例表示的 ROI 顯示欄位」:text_ratio_roi
*「依實際圖片座標表示的 ROI 顯示欄位」:text_real_roi

老樣子,同 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)

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

我們今天會新增一個 opencv_engine.py,作為圖像處理的引擎使用,
對我來說最理想的情況,就是只有 opencv_engine.py 這支程式 import cv2,
然後我們將所有會用到 OpenCV 的功能封裝成 API (function),
再給 img_controller.py 去 call API(function)。

  • controller.py:主要控制程式的部分
  • img_controller.py:另外封裝專門處理圖片的部分
  • opencv_engine.py:專門處理 OpenCV library 的引擎,基本上大部分圖像處理都靠他

專門處理圖像的引擎 opencv_engine.py

為了使用方便,我們把這個程式會呼叫的部分全部都用 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,

read_image(file_path)

對,所以我們等等也會把前幾天的透過 cv2.imread 的功能搬到這裡來,
再改用 staticmethod 的呼叫方式 opencv_engine.read_image(file_path),
取得回傳的圖片。

point_float_to_int(point)

因為 OpenCV 的點座標處理會常常要求一定要整數輸入,
而我們又常常以 (x, y) 這樣的座標為單位進行處理,
而不是個別傳入 x 與 y,因此乾脆獨立依格 function 專門把傳進來的 (x, y)
強轉成 (int x, int y) 並回傳。

draw_point(img, point=(0, 0), color = (0, 0, 255))

這個就是我們的 OpenCV 老朋友了,
為了使程式更加彈性,我特別把 point, color 拉出來,
因此之後傳入時,我們除了座標之外、也可以指定傳入的顏色。

不知道怎麼使用 OpenCV 畫點?
可以參考我的另外一篇文,內有詳細說明:【OpenCV】11 – OpenCV 建立新空白圖、畫點、畫圓 create new pictures, draw points and draw circle

專門處理圖片的 img_controller.py

依照上面的內容我們需要「修改圖片傳入的部分」,
另外也需要新增「顯示 roi 文字的部分」、「呼叫顯示點的功能」

導入 opencv_engine.py

我們要從 opencv_engine.py 導入 class opencv_engine,
因此要 from opencv_engine import opencv_engine
(前者為 .py 檔名,後者為 class 名稱)

from opencv_engine import opencv_engine

讀檔部分修改 read_file_and_init(self)

這邊只截錄重點部分,
因為我們這隻 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

畫點 draw_point(self, point)

我們剛剛已經封裝好程式的功能了,所以這邊可以直接呼叫 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()

更新 roi 內的文字 __update_text_point_roi(self, point)

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 的原因。

修改傳回點座標的 get_clicked_position

新增最後兩行,畫點 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() 設定新圖片路徑中,
我們有同樣的邏輯,都是先呼叫讀檔後去更新圖片。

這些都是要整理出流程圖才知道能優化的,沒整理前我也沒想到這裡可以優化。

所以我們就把流程圖優化成下圖

我們把重複的部分都塞進讀檔內,讓讀檔後可以直接更新,
流程圖的邏輯就可以看起來更乾淨。

統一命名格式 set, update

這個也是為了以後的自己好,因為程式會越寫越大,
趁現在我們把命名格式統一一下,

  • set 開頭的 function:可以外部呼叫,為修改事件的開頭
  • __update 開頭的 function:皆為 private function,不可外部呼叫,只作為更新畫面使用

好啦(累癱,至少現在趁程式還小,趕快把該整理的格式都整理後,之後相信會舒服很多的。

於是,這是最終修改後的 img_controller.py

這會是我最後一次貼完整程式碼了,因為越寫越大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 標註工具。

Reference


★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 17 / Project 製作標註 roi 工具, 開始導入 OpenCV 作為繪圖引擎, 在圖上畫點並顯示座標


上一篇
【沒錢買ps,PyQt自己寫】Day 16 - 在 PyQt5 中取得圖片座標 (滑鼠位置) mousePressEvent,觀察圖片在 Qt 中產生的方式,對原圖進行座標換算處理
下一篇
【沒錢買ps,PyQt自己寫】Day 18 / Project 使用 QTimer,自製碼表(計時器) PyQt5 stopwatch DIY
系列文
【今年還是不夠錢買psQQ,不如我們用PyQt自己寫一個】30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言