PySide2 / PyQt5,  Python

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.connectself.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 則留言

留下一個回覆

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *