PyQt5:完成一個 WebCam GUI 程式

| May 15, 2021 min read

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() # 關閉視窗


### 用 Qt Designer 來設計界面,並將所有物件進行有意義的命名

先用 Qt Designer 來設計界面,然後用 pyuic 來進行轉換。下面的圖是我事先設計好的 GUI 介面。 (建好的介面副檔名為 \*.ui,要讓 Python 讀取,必須要轉成 \*.py 檔) ![](http://localhost/wordpress/wp-content/uploads/2021/05/Untitled-4.png) 為了方便了解設計步驟,我準備了大約 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](https://github.com/jacky10001/WebCam-application-using-pyqt5-and-opencv.git "Github")

結語
--

文章看到這裡,相信對整個 PyQt5 控制在控制攝影機的功能上,有一定程度的認識及了解,雖然這邊只是完成開啟、關閉攝影機的影像而已,並沒有加入任何影像處理演算法,但是相信通過這個教學,自己也可以建立出一套完整 Python 的視窗程式,也歡迎通過修改這個程式製作出屬於自己的 GUI 程式。
comments powered by Disqus