為了讓影像辨識程式更容易操作,我開始用 PyQt5 製作圖形介面。這篇從安裝與 Designer 開始,整理 .ui 轉換、信號傳遞、背景工作與設定保存,作為開發桌面應用程式時的查閱筆記。

本篇重點
  • 用 Qt Designer 建立介面並轉換 .ui 檔案
  • 透過信號連接背景工作與介面更新
  • 保存設定,讓下次啟動能延續操作狀態

原始實作記錄於 2022 年;以下操作畫面與程式範例保留當時版本。

安裝 PyQt5 與開啟 Designer

  • 以下先記錄 conda 的安裝方式。透過 conda 安裝的 Designer,其語言與位置會受版本影響,請以自己的安裝結果為準:
    這份實作中使用的 Designer 路徑為:anaconda3\envs\yolov5-py3.9\Library\bin\designer.exe

    1
    conda install -c anaconda pyqt

    介面畫面:安裝引導:原文圖片 01

  • 若使用 pip,請在執行程式的同一個 Python 環境安裝:

    1
    pip install PyQt5

開始實作前的參考資料

剛開始學習時,我參考下方的 PyQt5 教學,先認識視窗與常見元件,再回到自己的需求做小範例。教學提供的是起點,實際使用時仍需對照元件文件與執行結果。


本文整理我在介面開發時常用的 QT5 程式碼,重點放在介面檔案、信號、背景工作與設定保存的連接方式。


實作背景

我開始接觸 PyQt5,是為了替視覺缺陷辨識程式設計操作介面。把常用功能放進視窗,讓使用者更容易操作,也讓我有機會整理介面與背景工作之間的關係。


轉換 .ui 檔案為 .py 程式碼檔案

Designer 負責排列元件,Python 程式則負責元件的行為。先確認介面能開啟,再加入按鈕事件與背景工作,比一次放入所有功能更容易查找問題。

在終端機切換到 .ui 檔案所在的目錄,依下方指令把介面定義轉成 Python 檔案。請把範例中的輸入與輸出檔名改成自己的檔名。

1
pyuic5 -o qt_test.py -x qt_test.ui

QtCore.pyqtSignal 信號

使用 QT5 時,介面透過事件迴圈處理操作。QT5 的介面元件應由主執行緒更新,不要直接在 QThread多執行緒 中操作。背景工作完成後,可送出信號,由主執行緒接收並處理 你要執行的元件指令。信號也能傳遞數值等資料,供接收端決定如何更新畫面。

自訂 pyqtSignal 通常宣告在 QObject 子類別中,並依需求指定要傳遞的資料型態。
信號表示某件事已發生;透過 connect,可以把信號連接到接收資料的函式。例如,背景工作完成後傳回結果,由介面函式更新標籤或按鈕狀態。

類別形式的信號範例(點擊展開)
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QObject, pyqtSignal # 定義信號的模組

class Ui_MainWindow(QObject):
# ----------------------------------------------------
# 新增代碼
label_show_ON = pyqtSignal(int) # 宣布定義信號名稱
def label_show(self,show):
if show == 1:
self.label.setText("A")
if show == 2:
self.label.setText("B")

def A(self):
self.label_show_ON.emit(1)

def B(self):
self.label_show_ON.emit(2)
# ----------------------------------------------------

def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(221, 100)
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.pushButtonA = QtWidgets.QPushButton(self.centralwidget)
self.pushButtonA.setGeometry(QtCore.QRect(20, 20, 75, 23))
self.pushButtonA.setObjectName("pushButtonA")
self.pushButtonB = QtWidgets.QPushButton(self.centralwidget)
self.pushButtonB.setGeometry(QtCore.QRect(130, 20, 75, 23))
self.pushButtonB.setObjectName("pushButtonB")
self.label = QtWidgets.QLabel(self.centralwidget)
self.label.setGeometry(QtCore.QRect(20, 50, 181, 21))
self.label.setAlignment(QtCore.Qt.AlignCenter)
self.label.setObjectName("label")
MainWindow.setCentralWidget(self.centralwidget)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)

self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)

def retranslateUi(self, MainWindow):
translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(translate("MainWindow", "TEST"))
self.pushButtonA.setText(translate("MainWindow", "Button A"))
self.pushButtonB.setText(translate("MainWindow", "Button B"))
self.label.setText(translate("MainWindow", "TextLabel"))
# ----------------------------------------------------
# 新增代碼
self.label_show_ON.connect(self.label_show)
self.pushButtonA.clicked.connect(self.A)
self.pushButtonB.clicked.connect(self.B)
# ----------------------------------------------------


if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec_())

QThread 多執行緒

需要持續讀取相機或執行較長的運算時,可以把工作與介面事件處理分開。下方保留 QThread 與 Python threading 的範例,閱讀時請注意工作如何啟動、如何回傳資料,以及視窗關閉時如何結束工作。

一般的寫法(點擊展開)
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
from PyQt5 import QtWidgets
from PyQt5.QtCore import QThread
import sys, time

app = QtWidgets.QApplication(sys.argv)

Form = QtWidgets.QWidget()
Form.setWindowTitle('oxxo.studio')
Form.resize(300, 200)

label_a = QtWidgets.QLabel(Form)
label_a.setGeometry(10, 10, 100, 30)

label_b = QtWidgets.QLabel(Form)
label_b.setGeometry(10, 50, 100, 30)

def a():
for i in range(0,5):
label_a.setText(str(i))
print('A:',i)
time.sleep(0.5)

def b():
for i in range(0,50,10):
label_b.setText(str(i))
print('B:',i)
time.sleep(0.5)

thread_a = QThread() # 建立 Thread()
thread_a.run = a # 設定該執行緒執行 a()
thread_a.start() # 啟動執行緒

thread_b = QThread() # 建立 Thread()
thread_b.run = b # 設定該執行緒執行 b()
thread_b.start() # 啟動執行緒

Form.show()
sys.exit(app.exec_())
使用類別管理背景工作(點擊展開)
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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import QThread
import time

class Ui_MainWindow(object):
def a(self):
for i in range(0,5):
self.label_1.setText(str(i))
print('A:',i)
time.sleep(1)

def b(self):
for i in range(0,50,10):
self.label_2.setText(str(i))
print('B:',i)
time.sleep(1)

def run(self):
self.thread_a.start() # 執行多執行緒 thread_a
self.thread_b.start() # 執行多執行緒 thread_b

def setupUi(self, MainWindow):
MainWindow.setObjectName("MainWindow")
MainWindow.resize(221, 100)
self.centralwidget = QtWidgets.QWidget(MainWindow)
self.centralwidget.setObjectName("centralwidget")
self.pushButton = QtWidgets.QPushButton(self.centralwidget)
self.pushButton.setGeometry(QtCore.QRect(20, 20, 75, 23))
self.pushButton.setObjectName("pushButton")
self.label_1 = QtWidgets.QLabel(self.centralwidget)
self.label_1.setGeometry(QtCore.QRect(20, 50, 71, 21))
self.label_1.setAlignment(QtCore.Qt.AlignCenter)
self.label_1.setObjectName("label_1")
self.label_2 = QtWidgets.QLabel(self.centralwidget)
self.label_2.setGeometry(QtCore.QRect(130, 50, 71, 21))
self.label_2.setAlignment(QtCore.Qt.AlignCenter)
self.label_2.setObjectName("label_2")
MainWindow.setCentralWidget(self.centralwidget)
self.statusbar = QtWidgets.QStatusBar(MainWindow)
self.statusbar.setObjectName("statusbar")
MainWindow.setStatusBar(self.statusbar)
self.retranslateUi(MainWindow)
QtCore.QMetaObject.connectSlotsByName(MainWindow)

def retranslateUi(self, MainWindow):
_translate = QtCore.QCoreApplication.translate
MainWindow.setWindowTitle(_translate("MainWindow", "TEST"))
self.pushButton.setText(_translate("MainWindow", "Button A"))
self.thread_a = QThread() # 建立 Thread()
self.thread_a.run = self.a # 設定該執行緒執行 a()
self.thread_b = QThread() # 建立 Thread()
self.thread_b.run = self.b # 設定該執行緒執行 b()
self.pushButton.clicked.connect(self.run)

if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)
MainWindow = QtWidgets.QMainWindow()
ui = Ui_MainWindow()
ui.setupUi(MainWindow)
MainWindow.show()
sys.exit(app.exec_())

QThread 常用方法

方法 參數 說明
start() 無 啟動執行緒
wait() 無 等待該執行緒結束
sleep() Sec 等待該執行緒幾秒

關閉介面前保存目前設定

先列出需要延續的狀態,例如路徑、選項或數值,再檢查保存內容與重新啟動後讀回的結果是否一致。設定保存與背景工作結束是兩件事,關閉視窗時都需要確認。

在關閉介面前保存設定,並在下一次啟動時讀取,讓使用者能接續上次的操作狀態。

1
2
3
4
5
6
7
def setupUi(self, WindowForms):
# 設定QT的QSettings
self.settings = QSettings('MyCompany', 'yolov5-AINTEC')
self.settings_save = QThread()

self.settings_save.run = self.settings_save_data # 選擇執行的副程式
self.settings_save.start() # 開始背景執行

把設定值寫入保存檔案

1
2
3
4
5
6
7
def settings_save_data(self):
try:
while True:
self.settings.setValue('Setting_bool1', self.SettingOther_radioButton_1.isChecked())
# self.settings.setValue("保存值的索引名稱", "欲保存的變數")
except:
pass # 避免突然關閉QT介面時,產生的錯誤訊息

啟動時讀取保存的設定

1
2
3
a = self.settings.value('CCDtest1', defaultValue=0, type=int) # 引用int範例
b = self.settings.value('Setting_bool1', defaultValue=False, type=bool) # 引用bool範例
c = self.settings.value('threshold_mode_1', defaultValue='無') # 引用string範例

實作後的檢查順序

先確認按鈕與基本畫面能正常操作,再加入耗時工作。工作執行期間試著拖曳視窗、操作按鈕並關閉程式,觀察是否仍能回應,以及背景工作是否確實結束。

執行緒與 GUI 的分工可對照 Qt 5 官方執行緒說明。下方原始範例仍屬當時的實作紀錄,沒有因文字修訂而重新驗證執行結果。