【没钱买ps,PyQt自己写】Day 16 - 在 PyQt5 中取得图片座标 (滑鼠位置) mousePressEvent,观察图片在 Qt 中产生的方式,对原图进行座标换算处理

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

前言

这一篇我们会继续拿现有的 day 15 成品来改,
接下来我们要面对关於「处理图片」与「显示图片」不一致的问题。

这是一个会影响非常深远的问题,因此我们需要早点针对这个问题进行规划。

我们接下来的讨论,会基於读者已经先读过我 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/day16_mouse_get_pos

我们先来分析「处理图片」与「显示图片」不一致的问题

为什麽会有「处理图片」与「显示图片」不一致的问题?
最主要的原因是因为我们拿进来的图片可能会解析度较高,

而我们处理的视窗就那麽大,我们没办法每次都让他已「原解析度」来显示。
所以在「处理图片」与「显示图片」之间沟通的桥梁我们必须早点做处理。

而在我们程序中,「处理图片」与「显示图片」分别对应到的是以下两个变数。

  • 显示的图片 self.qpixmap
  • 处理中的图片 self.img

分析两者之间的「程序」关系

依照 day15 的逻辑,我们处理图片显示的过程中如下,
我们来看看这其中有没有什麽可以简化的地方。

1. self.img 是由 OpenCV 的 imread 取得的图片 (读入的原图)

self.img = cv2.imread(self.img_path)

2. 我们会先经由以下处理,将他转为 Qimg

self.qimg = QImage(self.img, self.origin_width, self.origin_height, bytesPerline, QImage.Format_RGB888).rgbSwapped()

3. 再来会由 Qimg 转为 QPixmap

self.origin_qpixmap = QPixmap.fromImage(self.qimg)

4. Qpixmap 可能会经由一些缩放的处理,最後藉由 Qlabel 显示在画面上

self.label_img.setPixmap(self.qpixmap)

分析这个流程,发现实际上图片经过了很多次的转换,才到最後显示的部分。

OpenCV image -> Qimg -> QPixmap -> Qlabel显示

我们目前最多是在 QPixmap 这里才处理缩放的问题。
但接下来也许我们会需要针对原图进行改动,这时候我们会需要处理原解析度的图片。

也就是说,虽然我们是在 QPixmap 作业,但实际上处理的层级是在 OpenCV image

我们简化这个流程後,我们可以知道我们可以记录以下讯息会更方便我们处理:

  • QPixmap 现在的长宽 (会因为显示而改变)
  • QPixmap 与 OpenCV image 的比例差距 (会因为显示而改变)
  • OpenCV image 原图的长宽 (永远不变)

并且可以得到换算公式:

「QPixmap 现在的长宽」=「OpenCV image 的长宽」*「QPixmap 与 OpenCV image 的比例差距」

有没有更不容易混淆的做法? - 不如我们都「正规化」一下

虽然上面我们已经把公式都写出来也整理好了,但我觉得换算上还是很容易混淆...
例如:一不小心可能就会不小心把公式写错边,到底谁乘谁?、到底谁除谁?

所以我们就统一用「正规化」来沟通吧,这样标准就一定一致了。

  • 如下图:我们原来的作法

这个做法的优点就是直觉,但使用公式上需注意有没有不小心乘除搞错。
等等我们要进行座标 (x ,y) 换算时更需要小心。

  • 如下图:我们优化的作法 (正规化)

我们一律先把 (x,y) 座标正规化至一个长宽介於为 0~1 的比例上,
再来进行後续的换算,这样我们只要知道「显示图片」、「实际图片」的长宽,
在处理上都一虑用正规化的概念下去想 (x, y),
我们会相对比较难犯下不小心搞错公式的问题。

简单来说,可以比较不容易出现公式错误的问题。(对我个人来说)

UI 设计部份 (UI.py)

我们今天要来取得图片上的座标,会由 day 15 的结果继续进行更改,
上述的讨论中,我们已经有讨论到我们怎麽样处理「显示图片」与「原先图片」的差异,

我们就直接在 UI 上写下以下内容,并给予对应参数:

*「点击座标(显示图片的座标)」:label_click_pos
*「换算後,正规化的座标」:label_norm_pos
*「实际座标(对应到原图片的座标)」:label_real_pos

我设计的介面如同上图

转换成 UI.py

一样的编译指令,我们加上 -x (也可不加),
我们就可以先检视看看转换後的视窗是不是跟我们想像的一样。

转换 day16.ui -> UI.py

pyuic5 -x day16.ui -o UI.py

执行看看 UI.py 画面是否如同我们想像

一样,这程序只有介面 (视觉上的呈现),没有任何互动功能

  • 看看我们制作出来的介面
python UI.py

这样我们的介面就大致出来罗!

controller 设计部份 (controller.py)

从 UI.py 中找出物件名称

这次我们新增了 3 个 label

*「点击座标(显示图片的座标)」:label_click_pos
*「换算後,正规化的座标」:label_norm_pos
*「实际座标(对应到原图片的座标)」:label_real_pos

同 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)

取得名称後,去修改控制部分

截至到 day15,总共有 controller.py, img_controller.py 两支程序来控制我们的系统,

  • controller.py:主要控制程序的部分
  • img_controller.py:另外封装专门处理图片的部分

修改控制主程序的 controller.py

我们接续 day 15 的内容,
新增我们刚刚在 UI 增加的 label,因为也是跟图片有关的内容,
我们只做参数的传递,其他交由 img_controller.py 处理。

self.img_controller = img_controller(img_path=self.file_path,
									 label_img=self.ui.label_img,
									 label_file_path=self.ui.label_file_name,
									 label_ratio=self.ui.label_ratio,
									 label_img_shape=self.ui.label_img_shape,
									 label_click_pos=self.ui.label_click_pos,
									 label_norm_pos=self.ui.label_norm_pos,
									 label_real_pos=self.ui.label_real_pos)

另外封装专门处理图片的 img_controller.py

我们替 day 15 的 function 「扩充」新的侦测座标功能

宣告的地方,新增传入的参数


class img_controller(object):
    def __init__(self, img_path, label_img, label_file_path, label_ratio, label_img_shape, label_click_pos, label_norm_pos, label_real_pos):
        self.label_click_pos = label_click_pos
        self.label_norm_pos = label_norm_pos
        self.label_real_pos = label_real_pos

更新图片时,同步增加监听侦测滑鼠位置的 mousePressEvent

def __update_img(self):       
        self.label_img.setPixmap(self.qpixmap)
        self.label_img.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignTop)
        self.label_img.mousePressEvent = self.get_clicked_position

self.label_img.mousePressEvent = self.get_clicked_position

我们替 Qlabel 增加一个 mousePressEvent,而宣告的 function 就是我们等等会撰写的 get_clicked_position()

帮助我们取得回传座标的 get_clicked_position

def get_clicked_position(self, event):
	x = event.pos().x()
	y = event.pos().y() 
	self.norm_x = x/self.qpixmap.width()
	self.norm_y = y/self.qpixmap.height()
	print(f"(x, y) = ({x}, {y}), normalized (x, y) = ({self.norm_x}, {self.norm_y})")
	self.__update_text_clicked_position(x, y)

我们每触发一次上述的点击 mousePressEvent,就会执行一次 get_clicked_position 的内容,
我们可以从 event 这个变数取得点击的 (x, y)

  • x = event.pos().x()
  • y = event.pos().y()

在我们最上方的讨论中,我们决定要把所有的座标进行正规化,
以避免直接运算,容易产生的公式乘除错误的问题,
因此我们直接透过以下公式将座标正规化。

  • self.norm_x = x/self.qpixmap.width()
  • self.norm_y = y/self.qpixmap.height()

最後我们可以显示一下,我们所点击的 (x, y),与正规化後介於 0~1 之间呈现比例展示的 x, y 座标。
并将这些资讯传入我们修改文字的 function 中。

  • print(f"(x, y) = ({x}, {y}), normalized (x, y) = ({self.norm_x}, {self.norm_y})")
  • self.__update_text_clicked_position(x, y)

更新画面座标资讯的 __update_text_clicked_position()

因为只是纯更新资讯,我们将此 function 设为 private,不让我们能够轻易存取内容,
我们更新三种座标的显示:

*「点击座标(显示图片的座标)」:label_click_pos
*「换算後,正规化的座标」:label_norm_pos
*「实际座标(对应到原图片的座标)」:label_real_pos

def __update_text_clicked_position(self, x, y):
	self.label_click_pos.setText(f"Clicked postion = ({x}, {y})")
	self.label_norm_pos.setText(f"Normalized postion = ({self.norm_x:.3f}, {self.norm_y:.3f})")
	self.label_real_pos.setText(f"Real postion = ({int(self.norm_x*self.origin_width)}, {int(self.norm_y*self.origin_height)})")

这样就更新完了。

执行结果

照我们 day5 的程序架构,我们执行

python start.py

我们点击任意的点,就会显示「该座标」、「正规化座标」、「对应原图实际座标」。

而在我们的 terminal 当中也会显示一些我们刚刚印出来的资讯,方便我们 debug。

观察并检查座标 (x, y) - 我们在 UI 介面上点击的原点在哪?

这边有个衍伸的问题,我们在 UI 介面上点击的原点在哪?
也就是说 (0, 0) 是从哪里开始算的呢?

我们可以顺着我们刚刚做出来的成品,一路找到 (0, 0) 的位置,

我们发现 (0, 0) 座标刚好就位於「图片」的左上角,
而不是 「UI介面」的左上角,看起来完全这符合我们预期
(这边只是再确认座标与我们想像无误,免得後续才回来处理很麻烦)

至於图片的最右下角,座标又是什麽呢?

我们可以发现就是图片目前「显示」的解析度的上限值,
因此我们可以完全确认,我们正在操作的座标就是 QPixmap 的座标,
我们的换算都可以由 QPixmap 出发,依照比例进行换算。


★ 本文也同步发於我的个人网站(会有内容目录与显示各个小节,阅读起来更流畅):【PyQt5】Day 16 - 在 PyQt5 中取得图片座标 (滑鼠位置) mousePressEvent,观察图片在 Qt 中产生的方式,对原图进行座标换算处理


<<:  Material UI in React [ Day 30 ] 总结

>>:  [Day 18] Sass - Mixins

以Postgresql为主,再聊聊资料库 PostgreSQL last N in-table cache 探讨

PostgreSQL last N in-table cache 探讨 前些天对悠游卡储值时,加值机...

Day12:有问题要主动提出来

在初学程序的时候,一定很讨厌例外(Exception)发生,因为程序就没办法跑完了,也代表我们可能有...

Day22 [实作] 一对一视讯通话(2): Signaling server

今天我们要实作 Signaling server 的部分: 建立文件 # 进入要放专案的路径 ❯ c...

[Day 26] Android Studio 七日陨石开发:嘘! 我正在监听这个元件

前言 昨天我们设计好UI介面後, 我们有一堆按钮和文字框的"元件", 要让这些元...

资讯安全

-资讯安全 安全是指保护某物免受危险或威胁的过程和达到的状态。 资讯安全是一门通过安全控制保护资讯...