PyQt5:完成一個 WebCam GUI 程式
2024.01更新: 有大神提示本篇文章運用Signal/Slot的時候很可能會遇上難解的問題!另外這篇code年久失修,請各位斟酌參考呦~~
本篇要講得是如何使用 OpenCV 和 PyQt5 來控制網路攝影機,這個東西網路其實範例也挺多的,但既然這是自己做過的東西,乾脆就紀錄一下自己實現的過程吧!而且最近整理了不少以前寫過的程式碼,發現有些東西還挺不熟的,像這篇就是,怕以後忘記,先寫起來放,以後要做類似的,至少還有自己的東西可以參考XD。
OpenCV
OpenCV 是一個開放式跨平台的機器視覺函式庫,常見的影像處理演算法,皆可藉由這個 API 來實現,也可以用於商業或任何領域中免費使用。
PyQt5
PyQt5 是一種基於 Qt 的 GUI 套件, 來替代原本的 tkinter。 Qt 本身是跨平台的 C++ 函式庫,用於 UI 的開發,而 Qt 的開發公司 Nokia 還另外發布 PySide,這是以 LGPL 授權,也提高了在商業上的價值。(搞懂LGPL及GNU授權差異)
花了些時間介紹完 OpenCV 和 PyQt5 之後,就要來開始做出控制網路攝影機的 GUI 程式啦。雖說是控制也只是控制開始以及暫停的功能,但這兩個功能就可以運用到許多知識了吧!?應該吧!?我實在太廢了,不敢肯定阿ㄏㄏ
主要功能 及 學習內容
本篇主要功能為:
- 開啟、暫停按鈕
- 顯示網路攝影機的影像
- 調整影像顯示的區域及大小
- 關閉程式時,自動釋放網路攝影機
本篇學習內容為:
- OpenCV
- 開啟網路攝影機
- 讀取攝影機影像
- 傳送至 PyQt5 顯示
- 釋放網路攝影機
- PyQt5
- 主視窗程式以及版面設計功能
- 按鈕事件程式
- 顯示影像的事件撰寫
- 多執行緒的事件撰寫
- 信號發送與信號槽 (slot) 功能撰寫
- 下拉選單事件控制程式撰寫
- 滑鼠控制的事件撰寫
- 鍵盤控制的事件撰寫
- 狀態欄事件程式撰寫
- 結束視窗程式的事件撰寫
關於如何使用 PyQt5 來開發程式可以參考我寫得這兩篇:PyQt5:使用 VS Code 來開發 PyQt 的 GUI 程式、PyQt5:使用 Eric6 進行 PyQt5 的開發。
個人現在偏好 VS Code 來寫,用起來挺舒服的。
程式實現
先了解 OpenCV 如何使用 Python 擷取攝影機影像的功能
首先,我先將 OpenCV 使用 Web Camera 的程式寫出來。短短幾行就搞定了呢!?
import cv2 # 引入 OpenCV
cap = cv2.VideoCapture(0, cv2.CAP_DSHOW) # 設定攝影機的物件
while True:
_, frame = cap.read() # 讀取圖片
cv2.imshow('frame', frame) # 顯示圖片
if cv2.waitKey(1) & 0xFF == ord('q'): # 跳出迴圈
break
cap.release() # 釋放攝影機
cv2.destroyAllWindows() # 關閉視窗
如果今天任務不只是要顯示功能,而是要對影像做一些簡單操控,可用 OpenCV 的 GUI API,就能實現一些基本互動功能,例如:滑鼠控制及鍵盤控制,但是當程式需要更多更複雜的功能時,OpenCV 的介面就太陽春了,這時就該輪到 PyQt 上場,為程式注入新生命吧。
用 Qt Designer 來設計界面,並將所有物件進行有意義的命名
先用 Qt Designer 來設計界面,然後用 pyuic 來進行轉換。下面的圖是我事先設計好的 GUI 介面。
(建好的介面副檔名為 *.ui,要讓 Python 讀取,必須要轉成 *.py 檔)
為了方便了解設計步驟,我準備了大約 8 分鐘的影片來示範。
做好的介面再用 pyuic 來轉成 *.py 檔就好。像我的檔名是 main.ui,將會轉成 Ui_main.py。
開啟 PyQt5 的界面
弄完 PyQt 的介面之後,接下來先寫一個程式,來秀出剛剛做好的視窗程式吧!
從下面程式可以看到,我先將 GUI 的程式引入到我的程式中,再用一個 Class 去將整個視窗程式 (Ui_MainWindow) 包裝起來,這樣的好處是可以將介面和邏輯功能分開製作,例如:
- 完成介面之後,我們可以專注於程式背後的演算法撰寫
- 相關檔案可以分別管理,建立模組化機制,增加專案的可用性
- 未來專案需要更新,只需要針對部分的模組進行修改即可等等
程式相關說明就直接看註解吧!!!
# 引入相關模組
import sys
# 引入介面的模組
from PyQt5 import QtCore, QtGui, QtWidgets
from Ui_main import Ui_MainWindow
# 建立類別來繼承 Ui_MainWindow 介面程式
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
# 初始化方法
def __init__(self, parent=None):
# 繼承視窗程式所有的功能
super(MainWindow, self).__init__(parent)
# 配置 pyqt5 的物件
self.setupUi(self)
if __name__==__main__:
# 這個蠻複雜的,簡單講建立一個應用程式都需要它
# 然後將 sys.argv 這個參數引入進去之後
# 就能執行最後一行的 sys.exit(app.exec_())
app = QtWidgets.QApplication(sys.argv)
#建立視窗程式的物件
win = MainWindow()
# 顯示視窗
win.show()
# 離開程式
sys.exit(app.exec_())
完整程式說明
先將需要的功能引入到程式中。
import cv2 # 引入 OpenCV 的模組,製作擷取攝影機影像之功能
import sys, time # 引入 sys 跟 time 模組
import numpy as np # 引入 numpy 來處理讀取到得影像矩陣
# 引入 PyQt5 模組
# Ui_main 為自行設計的介面程式
from PyQt5 import QtCore, QtGui, QtWidgets
from Ui_main import Ui_MainWindow
接著將前面的 “擷取攝影機影像的功能” 包裝成 QtCore.QThread 的類別。
- 先建立一個 pyqt 的信號
QtCore.pyqtSignal
,來傳遞執行緒讀到的影像 (numpy 矩陣) - 相機類別方法
__init__
:初始化cv2.VideoCapture(0, cv2.CAP_DSHOW)
建立攝影機物件self.cam is None or not self.cam.isOpened()
判別攝影機是否可用- 設定
self.connect
、self.running
屬性來確認攝影機狀態
run
:在執行緒中執行的程式- 讀取影像
- 傳遞影像
- 例外處理
open
:打開攝影機stop
:暫停影像讀取close
:關閉攝影機
class Camera(QtCore.QThread): # 繼承 QtCore.QThread 來建立 Camera 類別
rawdata = QtCore.pyqtSignal(np.ndarray) # 建立傳遞信號,需設定傳遞型態為 np.ndarray
def __init__(self, parent=None):
""" 初始化
- 執行 QtCore.QThread 的初始化
- 建立 cv2 的 VideoCapture 物件
- 設定屬性來確認狀態
- self.connect:連接狀態
- self.running:讀取狀態
"""
# 將父類初始化
super().__init__(parent)
# 建立 cv2 的攝影機物件
self.cam = cv2.VideoCapture(0, cv2.CAP_DSHOW)
# 判斷攝影機是否正常連接
if self.cam is None or not self.cam.isOpened():
self.connect = False
self.running = False
else:
self.connect = True
self.running = False
def run(self):
""" 執行多執行緒
- 讀取影像
- 發送影像
- 簡易異常處理
"""
# 當正常連接攝影機才能進入迴圈
while self.running and self.connect:
ret, img = self.cam.read() # 讀取影像
if ret:
self.rawdata.emit(img) # 發送影像
else: # 例外處理
print("Warning!!!")
self.connect = False
def open(self):
""" 開啟攝影機影像讀取功能 """
if self.connect:
self.running = True # 啟動讀取狀態
def stop(self):
""" 暫停攝影機影像讀取功能 """
if self.connect:
self.running = False # 關閉讀取狀態
def close(self):
""" 關閉攝影機功能 """
if self.connect:
self.running = False # 關閉讀取狀態
time.sleep(1)
self.cam.release() # 釋放攝影機
為何需要使用 QtCore.QThread?
由於 “影像讀取” 功能是用一個 while 迴圈去執行,因此將 “影像讀取” 功能寫在主視窗的方法中,會造成介面程式卡住,無法進行其他動作,最簡單的辦法就是將 “影像讀取” 功能交付至另一個執行緒去處理,主執行緒就執行視窗介面程式。
將攝影機功能完成之後,就是將此功能放到介面中,通過 UI 實現使用者可操作的應用程式。接下來的程式和前面一樣,先建立類別來繼承 Ui_MainWindow 程式,並將相關操作功能實現出來。由於這裡功能蠻多的,詳細實現解說,可以直接看下面程式的註解,這邊先來快速預覽一下有哪先功能會被實現:
- 初始化:各種初始化,包含程式置於最上層、物件配置、相關屬性配置……等等。
- 取得影像:接收攝影機的影像
- 顯示影像:顯示影像在畫面上
- 開啟攝影機:啟動攝影機的影像讀取
- 暫停讀取:凍結攝影機的影像
- 關閉程式:同時會釋放攝影機
- 滑鼠操作:控制顯示的視窗區域
- 鍵盤操作:增加快捷鍵
- 底部狀態欄實現:用來顯示攝影機狀態
# 繼承 QtWidgets.QMainWindow, Ui_MainWindow 來建立 MainWindow 類別
class MainWindow(QtWidgets.QMainWindow, Ui_MainWindow):
def __init__(self, parent=None):
""" 初始化
- 物件配置
- 相關屬性配置
"""
super(MainWindow, self).__init__(parent)
self.setupUi(self)
self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint)
self.viewData.setScaledContents(True)
# view 屬性設置,為了能讓滑鼠進行操控,
self.view_x = self.view.horizontalScrollBar()
self.view_y = self.view.verticalScrollBar()
self.view.installEventFilter(self)
self.last_move_x = 0
self.last_move_y = 0
# 設定 Frame Rate 的參數
self.frame_num = 0
# 設定相機功能
self.ProcessCam = Camera() # 建立相機物件
if self.ProcessCam.connect:
self.debugBar('Connection!!!')
# 連接影像訊號 (rawdata) 至 getRaw()
self.ProcessCam.rawdata.connect(self.getRaw) # 槽功能:取得並顯示影像
# 攝影機啟動按鈕的狀態:ON
self.camBtn_open.setEnabled(True)
else:
self.debugBar('Disconnection!!!')
# 攝影機啟動按鈕的狀態:OFF
self.camBtn_open.setEnabled(False)
# 攝影機的其他功能狀態:OFF
self.camBtn_stop.setEnabled(False)
self.viewCbo_roi.setEnabled(False)
# 連接按鍵
self.camBtn_open.clicked.connect(self.openCam) # 槽功能:開啟攝影機
self.camBtn_stop.clicked.connect(self.stopCam) # 槽功能:暫停讀取影像
def getRaw(self, data): # data 為接收到的影像
""" 取得影像 """
self.showData(data) # 將影像傳入至 showData()
def openCam(self):
""" 啟動攝影機的影像讀取 """
if self.ProcessCam.connect: # 判斷攝影機是否可用
self.ProcessCam.open() # 影像讀取功能開啟
self.ProcessCam.start() # 在子緒啟動影像讀取
# 按鈕的狀態:啟動 OFF、暫停 ON、視窗大小 ON
self.camBtn_open.setEnabled(False)
self.camBtn_stop.setEnabled(True)
self.viewCbo_roi.setEnabled(True)
def stopCam(self):
""" 凍結攝影機的影像 """
if self.ProcessCam.connect: # 判斷攝影機是否可用
self.ProcessCam.stop() # 影像讀取功能關閉
# 按鈕的狀態:啟動 ON、暫停 OFF、視窗大小 OFF
self.camBtn_open.setEnabled(True)
self.camBtn_stop.setEnabled(False)
self.viewCbo_roi.setEnabled(False)
def showData(self, img):
""" 顯示攝影機的影像 """
self.Ny, self.Nx, _ = img.shape # 取得影像尺寸
# 建立 Qimage 物件 (灰階格式)
# qimg = QtGui.QImage(img[:,:,0].copy().data, self.Nx, self.Ny, QtGui.QImage.Format_Indexed8)
# 建立 Qimage 物件 (RGB格式)
qimg = QtGui.QImage(img.data, self.Nx, self.Ny, QtGui.QImage.Format_RGB888)
# viewData 的顯示設定
self.viewData.setScaledContents(True) # 尺度可變
### 將 Qimage 物件設置到 viewData 上
self.viewData.setPixmap(QtGui.QPixmap.fromImage(qimg))
### 顯示大小設定
if self.viewCbo_roi.currentIndex() == 0: roi_rate = 0.5
elif self.viewCbo_roi.currentIndex() == 1: roi_rate = 0.75
elif self.viewCbo_roi.currentIndex() == 2: roi_rate = 1
elif self.viewCbo_roi.currentIndex() == 3: roi_rate = 1.25
elif self.viewCbo_roi.currentIndex() == 4: roi_rate = 1.5
else: pass
self.viewForm.setMinimumSize(self.Nx*roi_rate, self.Ny*roi_rate)
self.viewForm.setMaximumSize(self.Nx*roi_rate, self.Ny*roi_rate)
self.viewData.setMinimumSize(self.Nx*roi_rate, self.Ny*roi_rate)
self.viewData.setMaximumSize(self.Nx*roi_rate, self.Ny*roi_rate)
# Frame Rate 計算並顯示到狀態欄上
if self.frame_num == 0:
self.time_start = time.time()
if self.frame_num >= 0:
self.frame_num += 1
self.t_total = time.time() - self.time_start
if self.frame_num % 100 == 0:
self.frame_rate = float(self.frame_num) / self.t_total
self.debugBar('FPS: %0.3f frames/sec' % self.frame_rate) # 顯示到狀態欄
def eventFilter(self, source, event):
""" 事件過濾 (找到對應物件並定義滑鼠動作) """
if source == self.view: # 找到 view 來源
if event.type() == QtCore.QEvent.MouseMove: # 定義滑鼠點擊移動動作
# 找到滑鼠移動位置
if self.last_move_x == 0 or self.last_move_y == 0:
self.last_move_x = event.pos().x()
self.last_move_y = event.pos().y()
# 計算滑鼠移動量
distance_x = self.last_move_x - event.pos().x()
distance_y = self.last_move_y - event.pos().y()
# 設置 view 的視窗移動
self.view_x.setValue(self.view_x.value() + distance_x)
self.view_y.setValue(self.view_y.value() + distance_y)
# 儲存滑鼠最後移動的位置
self.last_move_x = event.pos().x()
self.last_move_y = event.pos().y()
elif event.type() == QtCore.QEvent.MouseButtonRelease: # 定義滑鼠放開動作
# 滑鼠放開過後,最後位置重置
self.last_move_x = 0
self.last_move_y = 0
return QtWidgets.QWidget.eventFilter(self, source, event)
def closeEvent(self, event):
""" 視窗應用程式關閉事件 """
if self.ProcessCam.running:
self.ProcessCam.close() # 關閉攝影機
time.sleep(1)
self.ProcessCam.terminate() # 關閉子緒
QtWidgets.QApplication.closeAllWindows() # 關閉所有視窗
def keyPressEvent(self, event):
""" 鍵盤事件 """
if event.key() == QtCore.Qt.Key_Q: # 偵測是否按下鍵盤 Q
if self.ProcessCam.running:
self.ProcessCam.close() # 關閉攝影機
time.sleep(1)
self.ProcessCam.terminate() # 關閉子緒
QtWidgets.QApplication.closeAllWindows() # 關閉所有視窗
def debugBar(self, msg):
""" 狀態欄功能顯示 """
self.statusBar.showMessage(str(msg), 5000) # 在狀態列顯示字串資訊
PyQt 的信號傳遞機制?
PyQt 的特色就是使用 Signals 和 Slot (訊號與槽) 的概念,來建立物件與動作之間連結,例如按鈕物件:當按下開起按鈕時,會觸發開啟攝影機的動作,注意到了嗎?按鈕 >> 按下 >> 觸法 >> 動作 (開啟攝影機)
camBtn_open
>>clicked
>>connect
>>openCam()
物件 >> 信號 >> 傳遞 >> 槽通過上面的方法,就可以建立出完整的功能事件出來,也加強了程式結構。
最後是顯示視窗物件的程式。
if __name__==__main__:
app = QtWidgets.QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec_())
完整程式連結:Github
結語
文章看到這裡,相信對整個 PyQt5 控制在控制攝影機的功能上,有一定程度的認識及了解,雖然這邊只是完成開啟、關閉攝影機的影像而已,並沒有加入任何影像處理演算法,但是相信通過這個教學,自己也可以建立出一套完整 Python 的視窗程式,也歡迎通過修改這個程式製作出屬於自己的 GUI 程式。
16 則留言
路人
如果你是透過Google搜尋進來的PyQt新手,請不要像這篇文章創一個新的class去繼承QThread!
因為在使用Signal/Slot的時候很可能會遇上難解的問題!
yang10001
有大神路過!!確實當時的作法還很不成熟,感恩>.<
路人
我不是什麼大神,只是因應公司部門需求而開始學PyQt/PySide的上班族而已。
其實我只查到官方不建議繼承QThread去創一個新的subclass原因之一是改寫run這個method以後,原本QThread的eventloop會消失。
Singal/Slot的問題我自己還沒完全釐清原因。
只是我不知道這邊還有沒有人維護,怕提出也沒人討論,就用聽起來很像在抱怨的口氣留言了。
dubai racing channel
You have noted very interesting details! ps decent web site. live stream dubai
Live TV
You’re so awesome! I don’t believe I have read a single thing like that before.Live TV