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 我们已经透过 Qt desinger 设定完成,
而 start 没什麽好说。
我们开始着重讨论 controller 的细节。
我们选择独立「图片本身」与「图片处理方法」,
我们想避免把所有图片的功能全部都做在我们的图像中心 (image center) 里面,
这样会变成一个超级巨大的 class (又名为 god class),
功能太多之後要维护一个特定功能太难了,所以我们才独立「图像处理方法」进行操作。
这部分是套用 design pattern 的设计原则 (使用 Interface Segregation Principle(ISP) 介面隔离原则)
我们可以把介面分离出来,更方便之後功能的维护。
套用 design pattern 的 Interface Segregation Principle(ISP) 介面隔离原则後,
我们把「修改图片的方法」这个介面独立出来,更方便我们维护「图片修改」的部分。
而继承的部分,从变更图片的「所有共通方法 -> 滑条类方法/笔类方法 -> 各项细节方法」。
我们所有关於图像的处理都在这边,注意因为我们把「变化方法」丢出去做成介面了,
所以这里只有「显示相关」不包含「修改」。
因此这部分被简化过,我们有:
而 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)
我们需要一个帮助我们感应「滑鼠在图片上动作」的功能,例如之後的画笔可能会使用到,
我们将这些功能封装成一个 class label_mouse_controller,
当要制作画笔类的功能时,他会协助我们完成「图像上侦测滑鼠」的相关动作。
我们定义的功能有:
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()=}"
正如同我们上面所说,我们将所有的方法都包装好,并照上图的方式一层层的继承下来。
分类他是画面方法或是画笔类方法,再个别「继承後,进行更细部的定义」。
所有的「图形处理」介面,基本上都会依照此介面定义,
我们先在这个做好基本的功能,更客制化的细节功能就交给孙子们去处理。
这里只有定义:
__init__
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),
我们在里面多定义了会使用到「滑条来修改图片」的相关功能,会使用到的介面。
而「会滑条来修改图片」的众多功能,就交给孩子们去做更细节的定义吧!
这里定义了:
__init__
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:
你可能看完会很好奇,怎麽都没有「滑条相关」的细节实作?
这就是继承的好处,因为我们已经在「父母辈」定义好了实作方法,
而在 __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 的图像处理引擎,并把它全部包成可以直接取用的方法「@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 呢?
★ 本文也同步发於我的个人网站(会有内容目录与显示各个小节,阅读起来更流畅):【PyQt5】Day 29 final project - 2 / 来搞一个自己的 photoshop 吧!後段程序细节篇 (结合 PyQt + OpenCV)
>>: DAY29 - 为你的side project 写个 readme
今天来介绍一些实用的Plugins,能够加速工作的效率。废话不多说就开始吧! 1.Android R...
cssProperty() ? 对於写 E2E 检查颜色是否正确应该是再平凡不过的事了,当然 Nig...
一、前言 昨天发文後,马上收到系统罐头通知,终於熬到这一天了(我好兴奋啊啊啊!)终於要完成人生首...
今天是最後一天了,每天看这本书《听说做完380个实例,就能成为.NET Core大内高手》,真的里面...
今天上班搞一整天,只解出一个BUG,结果下班以後脑袋比较灵光? 总之今天是顺利解出来了 题号:129...