【没钱买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)


<<:  固执投资者的特质:金钱、想法、耐心、运气

>>:  DAY29 - 为你的side project 写个 readme

010-Plugins

今天来介绍一些实用的Plugins,能够加速工作的效率。废话不多说就开始吧! 1.Android R...

自动化 End-End 测试 Nightwatch.js 之踩雷笔记:检查颜色

cssProperty() ? 对於写 E2E 检查颜色是否正确应该是再平凡不过的事了,当然 Nig...

Day30:【技术篇】架设网站的基本知识

一、前言   昨天发文後,马上收到系统罐头通知,终於熬到这一天了(我好兴奋啊啊啊!)终於要完成人生首...

第30天:《听说做完380个实例,就能成为.NET Core大内高手》里面真的没怎麽讲.NET Core

今天是最後一天了,每天看这本书《听说做完380个实例,就能成为.NET Core大内高手》,真的里面...

找LeetCode上简单的题目来撑过30天啦(DAY27)

今天上班搞一整天,只解出一个BUG,结果下班以後脑袋比较灵光? 总之今天是顺利解出来了 题号:129...