iT邦幫忙

2021 iThome 鐵人賽

DAY 29
0
Software Development

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

【沒錢買ps,PyQt自己寫】Day 29 - final project - 2 / 來搞一個自己的 photoshop 吧!後段程式細節篇 (結合 PyQt + OpenCV)

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

此篇文章的範例程式碼 github

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

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

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

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

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

完整版請參考:【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


★ 本文也同步發於我的個人網站(會有內容目錄與顯示各個小節,閱讀起來更流暢):【PyQt5】Day 29 final project - 2 / 來搞一個自己的 photoshop 吧!後段程式細節篇 (結合 PyQt + OpenCV)


上一篇
【沒錢買ps,PyQt自己寫】Day 28 - final project - 1 / 來搞一個自己的 photoshop 吧!UI 篇 + 純程式架構篇 (結合 PyQt + OpenCV)
下一篇
【沒錢買ps,PyQt自己寫】Day 30 - final project - 3 / 來搞一個自己的 photoshop 吧!把每個方法封裝起來製作出還原功能吧!(結合 PyQt + OpenCV)
系列文
【今年還是不夠錢買psQQ,不如我們用PyQt自己寫一個】30

尚未有邦友留言

立即登入留言