➣ Reading Time: 31 minutes

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

前言

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

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

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

此篇文章的範例程式碼 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

⭐Python PyQt5 相關文章整理⭐:
⭐基礎知識與架構篇⭐:
1.【PyQt5】Day 1 – 安裝 PyQt,建立自己的第一支 PyQt5 程式
2.【PyQt5】Day 2 – 利用 Qt designer 建立第一支有自己介面的 PyQt5 程式
3.【PyQt5】Day 4 – 重要的 Qt 程式邏輯觀念,務必先有此觀念後面才會懂自己在幹嘛
4.【PyQt5】Day 5 – 開始來設計我們的 controller.py,改以「程式角度」來說明如何建立 PyQt 的系統
⭐基本元件應用篇⭐:
1.【PyQt5】Day 6 – 我們的第一個 output 手段 – Qlabel
2.【PyQt5】Day 7 – 我們的第一個 input 手段 – QPushButton
3.【PyQt5】Day 8 – 我們的第二個 input 手段 – QLineEdit
4.【PyQt5】Day 9 – 以 QLineEdit, QTextEdit, QPlainTextEdit 作為文字的輸入
6.【PyQt5】Day 14 - 使用 QSlider 製作可拖曳的滑條
8.【PyQt5】Day 19 - 使用 QProgressBar,製作進度條的功能
⭐介面系統控制篇⭐:
5.【PyQt5】Day 10 – 以 QFileDialog 讀取系統的檔案、資料夾
7.【PyQt5】Day 18 / Project 使用 QTimer,自製碼表(計時器) PyQt5 stopwatch DIY
1.【PyQt5】Day 20 - PyQt 最重要的 QThread 概念 / 為什麼 windows, mac, ubuntu (linux) 程式會「沒有回應」?
2.【PyQt5】Day 21 – 透過 PyQt 實現滑鼠監聽總整理,完全掌握滑鼠控制 (listen mouse)
3.【PyQt5】Day 22 – PyQt 視窗的個性化/屬性控制 setWindowFlags,禁止放大縮小、永遠顯示於最上層/最下層
4.【PyQt5】Day 23 – 使用系統內建的調色盤 QColorDialog,來替我們選擇顏色 QColor (Color Picker)
5.【PyQt5】Day 24 / Project 偵測滑鼠目前指示顏色的小工具 (滴管工具), 利用 QCursor 偵測滑鼠, QApplication 取得截圖
⭐影像處理篇⭐:
1.【PyQt5】Day 11 – 以 Qlabel 在 PyQt 中顯示圖片 (基於 QImage 使用 OpenCV)
2.【PyQt5】Day 12 – 建立一個可以縮放圖片大小的顯示器 (基於 QImage 使用 OpenCV)
3.【PyQt5】Day 13 – 使用 QVBoxLayout, QscrollArea 製作出捲軸,以高解析度檢視圖片 (基於 QImage 使用 OpenCV)
4.【PyQt5】Day 15 / Project 與檔案功能整合,製作出可讀取圖片並可縮放的 UI 介面 (使用 PyQt + OpenCV)
5.【PyQt5】Day 16 - 在 PyQt5 中取得圖片座標 (滑鼠位置) mousePressEvent,觀察圖片在 Qt 中產生的方式,對原圖進行座標換算處理
6.【PyQt5】Day 17 / Project 製作標註 roi 工具, 開始導入 OpenCV 作為繪圖引擎, 在圖上畫點並顯示座標
⭐project 篇⭐:
1.【PyQt5】Day 25 / Project 自己做一個影片播放器 DIY video player (結合 PyQt + OpenCV)
2.【PyQt5】Day 26 / Project 替我們影片播放器增加一個顯示進度的滑條 video player add slider (與昨日 bottleneck 處理細節)
3.【PyQt5】Day 27 / Project 製作影片 ROI 標註工具 (PyQt 結合 OpenCV 在圖上畫點畫線)
4.【PyQt5】Day 28 / final project – 1 來搞一個自己的 photoshop 吧!UI 篇 + 純程式架構篇 (結合 PyQt + OpenCV)
5.【PyQt5】Day 29 / final project – 2 來搞一個自己的 photoshop 吧!後段程式細節篇 (結合 PyQt + OpenCV)
6.【PyQt5】Day 30 / final project – 3 來搞一個自己的 photoshop 吧!把每個方法封裝起來製作出還原功能吧! (結合 PyQt + OpenCV)
⭐打包程式篇⭐:
1.【PyQt5】Day 3 – 用 pyinstaller 將 python 程式打包,把每天的成果分享給你的親朋好友
⭐【喜歡我的文章嗎? 歡迎幫我按讚~ 讓基金會請創作者喝一杯咖啡!
如果喜歡我的文章,請幫我在下方【按五下Like】 (Google, Facebook 免註冊),會由 「LikeCoin」 贊助作者鼓勵繼續創作,讀者們「只需幫忙按讚,完全不用出錢」哦!

likecoin-steps