使用QThread分离耗时操作

问题

使用PyQt写交互式分割的小工具,加载完图像之后,需要显示图像,同时需要生成注视点图,并显示注视点图。

于是写出了下面的【第一版代码】:

1
2
3
4
5
6
7
8
9
10
def open_image(self):
options = QFileDialog.Options()
file_name, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.png *.jpg *.bmp)", options=options)
if file_name:
self.image = cv2.imread(file_name)
self.show_image(self.image, self.image_label)

self.fixation_map = self.generate_fixation_map(self.image)
self.fixation_map = predict_transal.predict(file_name)
self.show_image(self.fixation_map, self.fixation_map_label)

运行后发现,image和fixation map显示的都很慢,image总是等待fixation map一起显示。

理想情况下,show_image在前,image应该先显示,然后再生成fixation map。image应该先显示出来,而不是等待fixation map缓慢的预测。

查阅资料发现:

PyQt中,在如果一个函数中包含多个连续操作(比如加载图像和生成注视点图),并且没有返回控制权给事件循环,界面刷新就会被阻塞,直到函数执行完毕。这是因为 PyQt 的 GUI 是单线程的,主事件循环会一直等待当前的操作完成才能更新界面。因此,长时间运行的任务会让界面冻结或延迟刷新。

解决

QTimer

对于需要短暂延迟的操作,可以使用 QTimer.singleShot() 创建非阻塞的延迟。

【第二版代码】

1
2
3
4
5
6
7
from PyQt5.QtCore import QTimer

self.image = cv2.imread(file_name)
self.show_image(self.image, self.image_label)
# 在显示图像后延迟一段时间生成注视点图
QTimer.singleShot(1, lambda: self.generate_fixation_map(file_name)) # 延迟1秒

QTimer.singleShot() 将任务调度为一个“单独的事件”,延迟执行,从而让事件循环有机会刷新界面。

  • 事件循环的原理:PyQt 应用程序运行在一个事件循环中,这个循环会不断检查是否有用户事件(如鼠标点击、键盘输入等)或任务需要处理。当你直接在函数中执行一系列操作时,这些操作是顺序执行的,直到函数完全结束,事件循环才会继续处理其他任务或更新界面。
  • QTimer.singleShot() 的工作原理QTimer.singleShot() 会在指定的时间(例如 1000 毫秒,即 1 秒)后触发一个“单次事件”。这个事件会在主事件循环中排队等待执行。由于 singleShot 把任务放在事件队列里并设置了延迟,当前函数会立即结束,控制权回到事件循环。这时,界面有机会刷新、显示打开的图像。
  • 延迟后执行任务:在延迟时间过后(如 1 秒),事件循环会检查到 singleShot 添加的任务,并执行 generate_fixation_map() 方法来生成注视点图。这样做的好处是,图像会在生成注视点图之前显示,而不会等待整个过程完成。

这种方法虽然让image显示出来了,但是在fixation_map生成过程中,界面不允许任何操作,无法响应。

QThread

将耗时的操作(生成注视点图)放到单独的线程中,这样可以避免阻塞主线程,从而让界面保持响应。使用多线程实现这一目标有几种常见方法,比如 QThreadconcurrent.futures,这里主要尝试了QThead

使用 QThread步骤:

  1. 定义一个任务线程:创建一个专门执行耗时任务的线程类。这个类只处理图像处理任务,生成结果后再传回主线程显示。
  2. 将耗时任务放入线程:启动线程后,让它执行注视点生成任务,而不影响主线程的界面更新。
  3. 任务完成后更新界面:将生成的注视点图像传回主线程,显示在界面上。

【第三版代码】

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class FixationMapThread(QThread):
# 定义信号,用于传递生成的注视点图
fixation_map_generated = pyqtSignal(np.ndarray)

def __init__(self, file_name):
super().__init__()
self.file_name = file_name

def run(self):
# 在新线程中执行注视点图生成
fixation_map = predict_transal.predict(self.file_name)
self.fixation_map_generated.emit(fixation_map) # 发送信号,传递生成的注视点图

class SegmentTool(QMainWindow):
def __init__(self):
super().__init__()
# ...

def open_image(self):
# 选择并加载图片
file_name, _ = QFileDialog.getOpenFileName(self, "选择图片", "", "Images (*.png *.jpg *.bmp)")
if file_name:
self.image = cv2.imread(file_name)
self.show_image(self.image, self.image_label)

# 创建并启动注视点图生成线程
self.fixation_thread = FixationMapThread(file_name)
self.fixation_thread.fixation_map_generated.connect(self.show_fixation_map) # 连接信号
self.fixation_thread.start()

def show_image(self, img, label):
# ...

def show_fixation_map(self, fixation_map):
self.fixation_map = fixation_map
self.show_image(fixation_map, self.fixation_map_label)

if __name__ == "__main__":
app = QApplication([])
window = SegmentTool()
window.show()
app.exec_()

  1. FixationMapThread:这是一个自定义线程类,继承自 QThread。它负责在后台生成注视点图,并通过信号 fixation_map_generated 将生成的注视点图发送到主线程。
  2. open_image 方法:当用户打开图像时,主线程负责显示图像,然后创建并启动 FixationMapThread,将文件名传递给线程。
  3. show_fixation_map 方法:该方法接收从 FixationMapThread 发出的信号,获取生成的注视点图并显示在主界面上。

这种实现方法,首先界面不会卡顿:生成注视点图的任务在后台执行,不会阻塞主线程,因此图像可以立即显示。同时,更新流畅:注视点图生成完成后,通过信号更新界面,避免了界面刷新延迟。

Author: Krab
Link: https://isxrh.github.io/2024/11/11/202411_QThread/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.