➣ Reading Time: 34 minutes

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

此篇文章的範例程式碼 github

https://github.com/howarder3/ironman2021_PyQt5_photoshop/tree/main/day28-30_final_project

之前內容的重點複習 (前情提要)

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

複習昨日的內容 (前情提要)

完整版請參考:【PyQt5】Day 28 final project – 1 / 來搞一個自己的 photoshop 吧!UI 篇 + 純程式架構篇 (結合 PyQt + OpenCV)

昨天我們討論到了我們是如何設計程式的程式架構,
以大概念來說,我們主軸還是圍繞在

  • UI
  • controller
  • start

三大面向,而 UI 我們已經透過 Qt desinger 設定完成,
而 start 沒什麼好說。
我們開始著重討論 controller 的細節。

獨立「圖像本身」與「圖像處理方法」,額外設計圖像處理介面。

我們選擇獨立「圖片本身」與「圖片處理方法」,
我們想避免把所有圖片的功能全部都做在我們的圖像中心 (image center) 裡面,
這樣會變成一個超級巨大的 class (又名為 god class),
功能太多之後要維護一個特定功能太難了,所以我們才獨立「圖像處理方法」進行操作。

這部分是套用 design pattern 的設計原則 (使用 Interface Segregation Principle(ISP) 介面隔離原則)
我們可以把介面分離出來,更方便之後功能的維護。

介面設計與繼承方法

套用 design pattern 後 (使用 Interface Segregation Principle(ISP) 介面隔離原則)

套用 design pattern 的 Interface Segregation Principle(ISP) 介面隔離原則後,
我們把「修改圖片的方法」這個介面獨立出來,更方便我們維護「圖片修改」的部分。

而繼承的部分,從變更圖片的「所有共通方法 -> 滑條類方法/筆類方法 -> 各項細節方法」。

今天我們從各個功能的細節開始談

圖像中心 image_center

我們所有關於圖像的處理都在這邊,注意因為我們把「變化方法」丟出去做成介面了,
所以這裡只有「顯示相關」不包含「修改」。

因此這部分被簡化過,我們有:

  • 讀檔 read_file_and_init
  • 更新圖片 update_img, __update_label_img
  • 處理圖片顯示的縮放 set_zoom_value, __update_img_zoom

而 update_img, set_zoom_value 是給外部呼叫的,作為 trigger 我們的 image_center 進行更新。

class image_center(object):
    def __init__(self, img_path, ui):
        self.img_path = img_path
        self.ui = ui
        self.label_mouse_controller = label_mouse_controller(self)
        self.zoom_value = 1
        self.read_file_and_init()

    def read_file_and_init(self):
        try:
            self.origin_img = opencv_engine.read_image(self.img_path) # if cancel, no error !!!!
            self.origin_img_height, self.origin_img_width, self.origin_img_channel = self.origin_img.shape # need this to make error !!!
        except:
            self.origin_img = opencv_engine.read_image('./demo_materials/sad.png')
            self.origin_img_height, self.origin_img_width, self.origin_img_channel = self.origin_img.shape

        self.display_img = np.copy(self.origin_img) # make a clone
        self.__update_label_img()

    def update_img(self, img):
        self.display_img = img # default = not change, like zoom
        self.__update_label_img()

    def set_zoom_value(self, value):
        self.zoom_value = value

    def __update_img_zoom(self):        
        qpixmap_height = self.origin_img_height * self.zoom_value
        self.qpixmap = self.qpixmap.scaledToHeight(qpixmap_height)

    def __update_label_img(self):       
        bytesPerline = 3 * self.origin_img_width
        qimg = QImage(self.display_img, self.origin_img_width, self.origin_img_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()
        self.qpixmap = QPixmap.fromImage(qimg)
        self.__update_img_zoom()
        self.ui.label_img.setPixmap(self.qpixmap)
        self.ui.label_img.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)

「滑鼠控制」相關 label_mouse_controller

我們需要一個幫助我們感應「滑鼠在圖片上動作」的功能,例如之後的畫筆可能會使用到,
我們將這些功能封裝成一個 class label_mouse_controller,
當要製作畫筆類的功能時,他會協助我們完成「圖像上偵測滑鼠」的相關動作。

我們定義的功能有:

  • 偵測滑鼠按壓時:mouse_press_event
  • 偵測滑鼠放開時:mouse_release_event
  • 偵測滑鼠按下並拖曳時:mouse_moving_event
class label_mouse_controller(object):
    def __init__(self, image_center):
        self.image_center = image_center
        self.ui = self.image_center.ui # new pointer point to self.image_center.ui
        self.ui.label_img.mousePressEvent = self.mouse_press_event
        self.ui.label_img.mouseReleaseEvent = self.mouse_release_event
        self.ui.label_img.mouseMoveEvent = self.mouse_moving_event

    def mouse_press_event(self, event):
        msg = f"{event.x()=}, {event.y()=}, {event.button()=}"
        x = event.x()
        y = event.y()
        norm_x = x/self.image_center.qpixmap.width()
        norm_y = y/self.image_center.qpixmap.height()
        real_x = int(norm_x*self.image_center.origin_img_width)
        real_y = int(norm_y*self.image_center.origin_img_height)
        self.ui.label_click_pos.setText(f"Clicked postion = ({x}, {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 = ({real_x}, {real_y})")

    def mouse_release_event(self, event):
        msg = f"{event.x()=}, {event.y()=}, {event.button()=}"

    def mouse_moving_event(self, event):
        msg = f"{event.x()=}, {event.y()=}, {event.button()=}"

「圖形處理」介面相關

正如同我們上面所說,我們將所有的方法都包裝好,並照上圖的方式一層層的繼承下來。
分類他是畫面方法或是畫筆類方法,再個別「繼承後,進行更細部的定義」。

「圖形處理」介面大祖宗 (method_interface)

所有的「圖形處理」介面,基本上都會依照此介面定義,
我們先在這個做好基本的功能,更客製化的細節功能就交給孫子們去處理。

這裡只有定義:

  • 初始化參數:__init__
  • 更新圖片:update_img
import abc

class method_interface(abc.ABC):
    @abc.abstractmethod
    def __init__(self):
        return NotImplemented

    @abc.abstractmethod
    def update_img(self):
        return NotImplemented

「圖形處理」介面父母輩 (slider_method_interface, pen_method_interface)

因為時間的關係,只來得及做一半 (slider_method_interface),
我們在裡面多定義了會使用到「滑條來修改圖片」的相關功能,會使用到的介面。

而「會滑條來修改圖片」的眾多功能,就交給孩子們去做更細節的定義吧!

這裡定義了:

  • 更多詳細的「與滑條有關的」初始化參數:__init__
  • 滑條按下與釋放:slider_press_event, slider_release_event
  • 取得滑條值: getslidervalue
  • 設定滑條值 (當滑條的值被變更時,觸發此功能): setsliderlabel
  • 更新圖片相關:setimage, update_img
class slider_method_interface(method_interface):
    def __init__(self, slider, label, image_center):
        self.label = label
        self.slider = slider
        self.image_center = image_center
        self.tmp_origin_img = self.image_center.display_img
        self.slider.setRange(-100, 100)
        self.slider.setProperty("value", 0)
        self.slider.valueChanged.connect(self.setsliderlabel)
        self.slider.sliderPressed.connect(self.slider_press_event)
        self.slider.sliderReleased.connect(self.slider_release_event)
        self.prefix = ""

    # get first picture snapshot, 
    def slider_press_event(self):
        self.tmp_origin_img = self.image_center.display_img

    # final update back to image center (not necessary, for double check)
    def slider_release_event(self):
        img = self.setimage(self.tmp_origin_img)
        self.image_center.update_img(img)

    # image do the method
    def setimage(self, img):        
        return img

    @property
    def getslidervalue(self):
        return self.slider.value()

    # trigger function, get your signal from here
    def setsliderlabel(self):
        self.label.setText(f"{self.prefix}{self.slider.value():+}")
        self.update_img()  

    def update_img(self):
        self.image_center.update_img(self.tmp_origin_img) # default = origin_image no change, like zoom in/out

「圖形處理」孩子輩 (method_lightness, method_saturation, method_contrast…)

這裡我們就來開始撰寫「與滑條相關」的各項細部功能,像是「光線、飽和度、對比度…」,
都會是在這邊實作,而因為我們已經有在上面定義好了滑條相關的方法,
這邊如果沒有必要多做修改,可以完全不用新增「滑條的處理方法」(傳入正確的變數就會自動搞定了),
只需要專注在實現「修改圖片的方法」即可。

這邊隨便舉個範例,調整光線 method_lightness:

  • setimage:處理圖片光線變化的方法
  • update_img:將變化後的圖片傳回去圖像中心更新 (image_center)
  • setsliderlabel: trigger 用,感應滑條變化的時間

你可能看完會很好奇,怎麼都沒有「滑條相關」的細節實作?
這就是繼承的好處,因為我們已經在「父母輩」定義好了實作方法,
而在 __init__ 中直接傳入對應的參數,瞬間就實作完「滑條相關」的細節 (因為都是共通的概念)。

這邊就是這樣處理,相當的方便,又不用重寫多次滑條處理方法。

class method_lightness(slider_method_interface):
    def __init__(self, slider, label, image_center):
        super().__init__(slider, label, image_center)
        self.prefix = "lightness: "
        self.update_img()

    def setimage(self, img):        
        return opencv_engine.modify_lightness(img, lightness=self.slider.value())

    def update_img(self):
        img = self.setimage(self.tmp_origin_img)
        self.image_center.update_img(img)

    # trigger function, get your signal from here
    def setsliderlabel(self):
        self.label.setText(f"{self.prefix}{self.slider.value():+}")
        self.update_img()  

OpenCV 圖像處理引擎 (opencv_engine)

製作一個 OpenCV 的圖像處理引擎,並把它全部包成可以直接取用的方法「@staticmethod」,
我們只在這支程式中使用「import cv2」,方便我們集中管理。

import cv2
import numpy as np
import math

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)

    @staticmethod
    def draw_line(img, start_point = (0, 0), end_point = (0, 0), color = (0, 255, 0)): # green
        start_point = opencv_engine.point_float_to_int(start_point)
        end_point = opencv_engine.point_float_to_int(end_point)
        thickness = 3 # width
        return cv2.line(img, start_point, end_point, color, thickness)

    @staticmethod
    def draw_rectangle_by_points(img, left_up=(0, 0), right_down=(0, 0), color = (0, 0, 255)): # red
        left_up = opencv_engine.point_float_to_int(left_up)
        right_down = opencv_engine.point_float_to_int(right_down)
        thickness = 2 # 寬度 (-1 表示填滿)
        return cv2.rectangle(img, left_up, right_down, color, thickness)

    @staticmethod
    def draw_rectangle_by_xywh(img, xywh=(0, 0, 0, 0), color = (0, 0, 255)): # red
        left_up = opencv_engine.point_float_to_int((xywh[0], xywh[1]))
        right_down = opencv_engine.point_float_to_int((xywh[0]+xywh[2], xywh[1]+xywh[3]))
        thickness = 2 # 寬度 (-1 表示填滿)
        return cv2.rectangle(img, left_up, right_down, color, thickness)

    @staticmethod    
    def modify_lightness(img, lightness = 0): # range: -100 ~ 100
        if lightness == 0: # no change
            return img
        # lightness 調整為  "1 +/- 幾 %"

        # 圖像歸一化,且轉換為浮點型
        fImg = img.astype(np.float32)
        fImg = fImg / 255.0

        # 顏色空間轉換 BGR -> HLS
        hlsImg = cv2.cvtColor(fImg, cv2.COLOR_BGR2HLS)
        hlsCopy = np.copy(hlsImg)

        # 亮度調整
        hlsCopy[:, :, 1] = (1 + lightness / 100.0) * hlsCopy[:, :, 1]
        hlsCopy[:, :, 1][hlsCopy[:, :, 1] > 1] = 1  # 應該要介於 0~1,計算出來超過1 = 1

        # 顏色空間反轉換 HLS -> BGR 
        result_img = cv2.cvtColor(hlsCopy, cv2.COLOR_HLS2BGR)
        result_img = ((result_img * 255).astype(np.uint8))


        return result_img

    @staticmethod    
    def modify_saturation(img, saturation = 0): # range: -100 ~ 100
        if saturation == 0: # no change
            return img
        # saturation 調整為 "1 +/- 幾 %"

        # 圖像歸一化,且轉換為浮點型
        fImg = img.astype(np.float32)
        fImg = fImg / 255.0

        # 顏色空間轉換 BGR -> HLS
        hlsImg = cv2.cvtColor(fImg, cv2.COLOR_BGR2HLS)
        hlsCopy = np.copy(hlsImg)

        # 飽和度調整
        hlsCopy[:, :, 2] = (1 + saturation / 100.0) * hlsCopy[:, :, 2]
        hlsCopy[:, :, 2][hlsCopy[:, :, 2] > 1] = 1  # 應該要介於 0~1,計算出來超過1 = 1

        # 顏色空間反轉換 HLS -> BGR 
        result_img = cv2.cvtColor(hlsCopy, cv2.COLOR_HLS2BGR)
        result_img = ((result_img * 255).astype(np.uint8))

        return result_img


    @staticmethod
    def modify_contrast_brightness(img, brightness=0 , contrast=0): # range: -100 ~ 100
        if brightness == 0 and contrast == 0:
            return img
        B = brightness / 255.0
        c = contrast / 255.0 
        k = math.tan((45 + 44 * c) / 180 * math.pi)

        img = (img - 127.5 * (1 - B)) * k + 127.5 * (1 + B)

        # 所有值必須介於 0~255 之間,超過255 = 255,小於 0 = 0
        img = np.clip(img, 0, 255).astype(np.uint8)

        return img

最終結果

把上面落落長的東西都實作完,並 debug 完,
終於暫時有了現在的作品!

但現在還有一些效能問題要處理,例如說載入太大解析度的圖片時,
我們使用「滑條功能」,因為會產生「連續的變化計算」,
太大解析度的電腦計算速度可能會跟不上。

目前這部分可能還需要想想怎麼樣優化會更好XD

(或者直接縮放後以低解析度作處理XD,紀錄「方法步驟」後,最後存檔才重新實現這些步驟。)
這個是我下一篇想要談的XD,有沒有機會把「方法」當作一個個的「物件」,保存進一個 queue 呢?

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