【没钱买ps,PyQt自己写】Day 17 / Project 制作标注 roi 工具, 开始导入 OpenCV 作为绘图引擎, 在图上画点并显示座标

看完这篇文章你会得到的成果图

前言

这一篇我们会继续拿现有的 day 16 成品来改,
我们在 day 16 已经学会了如何取得点座标,
接下来我们要将「点画在画面上」、并「取得该点座标」,
而点座标又可分为「正规化 roi 比例座标」、「实际图片座标」

而在其中,「画点」的功能,我们虽然能够透过 PyQt 的 Qpainter 实现,
不过因为我们後续也会大量使用 OpenCV 作为我们图片处理的引擎,
所以不如就趁先在开始导入吧!

我们接下来的讨论,会基於读者已经先读过我 day5 文章 的架构下去进行程序设计
如果还不清楚我程序设计的逻辑 (UI.py、controller.py、start.py 分别在干麻)
建议先阅读 day5 文章後再来阅读此文。

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

此篇文章的范例程序码 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


★ 本文也同步发於我的个人网站(会有内容目录与显示各个小节,阅读起来更流畅):【PyQt5】Day 17 / Project 制作标注 roi 工具, 开始导入 OpenCV 作为绘图引擎, 在图上画点并显示座标


<<:  [day 17] Swift 语法梳理後续

>>:  [Day 17]独自一人的全端攻略(後端篇)

Day 24 - fetch

fetch 改善了 XHR 又长又麻烦的写法,简化了程序码使阅读容易许多,而 fetch retur...

Day3 安装 Kubernetes & Open-Match 核心

在昨天我们简单介绍了框架是如何产生配对後,今天我们要来部署 Open-Match 所需要的环境与核心...

Day8 GraphQL 介绍、在WordPress 上安装 WPGraphQL plugin

我们的系统架构很单纯,分为托管在 Vercel 上的 Next.js 前端,以及托管在 BlueHo...

09 程序除错技巧指南

不管在哪个阶段,在写程序时总是会遇到大大小小的问题,不是程序不照着你的想法走,就是他连动都不想动。在...

Day6 资料储存 - object storage基础

Object storage - 云端最流行的储存方式 Object storage和file st...