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

  • derek

    感謝你提供學習紀錄和github原始code讓我可以學習Qt5這項技術,已經購買你推薦的書籍(Python GUI程式設計:PyQt5實戰)來學習,希望未來能看到更多你實作GUI的作品範例和說明。

  • 陳同學

    請問您的程式的攝影機畫面顯示出來會變偏藍色的色調有辦法改成正常彩色的嗎

  • Twicsy

    An impressive share! I’ve just forwarded this onto a
    coworker who had been doing a little research
    on this. And he actually bought me breakfast due to the fact that I discovered it for him…
    lol. So let me reword this…. Thank YOU for
    the meal!! But yeah, thanx for spending time to discuss
    this subject here on your internet site.

  • instagram likes paypal payment

    Just desire to say your article is as surprising.
    The clarity in your submit is simply excellent and i could suppose you are a professional in this subject.
    Fine along with your permission allow me to clutch your feed to keep up to
    date with imminent post. Thanks one million and
    please continue the gratifying work.

留下一個回覆

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