使用 PyQt 为网络摄像头创建 GUI

转载自:pythonforthelab.com

在本教程中,我们将构建一个图形用户界面(GUI)以从您的网络摄像头获取图像。我们将使用 OpenCV 快速从您的相机获取图像,并使用 PyQt5 构建用户界面。您可能会在网上找到很多关于如何使用 Python 完成不同任务的教程,但是很难找到关于如何使用 Python 构建桌面应用程序的完整指南。

构建 GUI 并不复杂,要使它们变得复杂,是在允许用户随机与程序交互时必须考虑的因素。例如,假设您的程序允许用户选择他们想要使用的相机,然后获取图像。您必须考虑如果用户首先尝试获取图像会发生什么情况。这只是冰山一角。

完成本教程后,您将对如何构建项目有一个很好的概述,将重要部分分为模块。您还将逐步学习如何在复杂性的基础上从头开始开发 PyQt 应用程序。最后,您将获得一个有效的示例,该示例演示如何使用用户界面与现实世界的设备连接。

安装 OpenCV 和 PyQt5

该项目将为您的网络摄像头构建一个用户界面。为了做到这一点,我们将需要两个主要的库:OpenCV 将负责获取,而 PyQt5 是我们用于接口的框架。

OpenCV 是一个非常大的软件包,可以与不同的编程语言一起使用。它可以处理各种图像操作,包括面部检测,对象跟踪等。在本教程中,我们将不会利用所有这些可能性,但是您应该意识到该库具有的潜力。要安装 OpenCV,最简单的方法是运行以下命令:

pip install opencv-python
pip install numpy

请记住,最佳实践是在虚拟环境中工作,以避免与其他库等发生冲突。安装过程还应安装 numpy。如果在安装 OpenCV 时遇到问题,可以查看官方文档

要检查 OpenCV 是否已正确安装和配置,可以启动 Python 解释器并运行以下命令以查看可用的版本:

>>> import cv2
>>> cv2.__version__
'3.4.8'

下一步是安装 PyQt5,这还需要一个命令:

pip install PyQt5

通常情况下此方法都可以成功安装,但是 PyQt5 可能在某些特定平台上会出现一些问题。如果找不到解决方案,则可以选择安装 Anaconda,它将在所有平台上提供所有软件包。

要测试 PyQt5 是否正常运行,您可以使用以下内容创建一个简短的脚本:

from PyQt5.QtWidgets import QApplication, QMainWindow

app = QApplication([])
win = QMainWindow()
win.show()
app.exit(app.exec_())

如果运行文件,则将弹出一个小的空白窗口。这意味着一切正常。

最后,我们需要一个能够显示通过网络摄像头获取的图像的库。有几种选择。您可以使用 Matplotlib,这是用于制作包括2D图像的绘图的常用工具。您还可以使用 Pillow,这是在 Python 中处理图片的好工具。第三种选择是使用 PyQtGraph,该库在普通 Python 开发人员中不是主流,但在研究实验室中广泛使用。

由于该网站的背景,我们将选择第三个选项:使用 PyQtGraph。一方面,这将使以 Luke Campagnola 为首的惊人项目变得可见。要安装它,只需执行以下操作:

pip install pyqtgraph

现在我们准备开发该应用程序。


欢迎使用 OpenCV

在开发此类应用程序时,第一步是在着手设计和开发用户界面之前,先了解我们要做什么。使用 OpenCV 可以很容易地从连接到计算机的网络摄像头中读取内容,只需执行以下操作:

import cv2
import numpy as np

cap = cv2.VideoCapture(0)
ret, frame = cap.read()
cap.release()

print(np.min(frame))
print(np.max(frame))

在第一行中,我们初始化与摄像机的通信。当然,如果未连接任何摄像机,则在运行以下命令时 cap.read(),将不会获取任何内容,但是该程序不会崩溃。最后,我们释放相机。最后两行只是打印相机记录的最大值和最小值。请记住,这 frame 是一个 numpy 2D 数组。

要向前迈出一步,我们还可以从相机获取视频。与上面的代码唯一的区别是我们需要运行一个无限循环,并且在每次循环中,都会获取并显示一个新的帧。要退出该应用程序,您需要按Q键盘上的。请注意,我们还将图像转换为灰度。您可以删除该行并检查图像的外观。

import cv2

cap = cv2.VideoCapture(0)

while(True):
    # 获取每帧
    ret, frame = cap.read()

    # 我们在这个 frame 下操作
    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

    # 显示帧
    cv2.imshow('frame',gray)

    # 如果按下 Q 就关闭程序,按键监控间隔 1 ms。
    if cv2.waitKey(1) & 0xFF == ord('q'):
        break

# 释放
cap.release()
cv2.destroyAllWindows()

现在,我们可以清楚地了解采集过程的工作原理。我们必须开始与相机进行通信,然后才能从中读取信息。我们可以更改图像本身的一些参数,例如转换为黑白或相机。例如,我们可以通过在下面添加以下内容来增加亮度 VideoCapture

cap.set(cv2.CAP_PROP_BRIGHTNESS, 1)

由于您是将属性设置为相机本身,因此即使您重新启动程序,该属性也不会消失,直到您未将其设置回。您可以查看有关属性的文档, 以查看可能的情况。请记住,并非所有相机都支持所有选项,因此可能会出现一些错误,或者根本没有可见的变化。

要制作视频,您需要无限循环不断地从摄像机获取视频。我们现在不再赘述,但是如果您的相框需要很长时间才能获取,例如设置更长的曝光时间,则可能会出现问题。


欢迎来到 PyQt

与 OpenCV 类似,Qt 是一个通用库,用 C ++ 编写,可用于许多平台。PyQt 是与 Qt 的 Python 绑定,即原始代码到可以在 Python 内部使用的对象的翻译。使用 Qt 的主要困难来自以下事实:许多文档不适用于 Python 绑定,但适用于原始代码。这意味着用户必须将一种语言翻译成另一种语言。一旦适应了它,它就可以很好地工作,但是需要花费一些时间来学习。

注意

有一组适用于 Python 的绑定,称为 PySide2。
它们是 Qt 正式发布的绑定,实际上,它们的工作原理完全相同。
主要区别在于发布它们所依据的许可证。
如果您担心要发布代码,则应检查选项。

用户界面由一个无限循环组成,在该循环中绘制窗口,捕获用户交互,显示来自网络摄像头的图像等。如果该循环断开,则应用程序完成,关闭所有窗口。因此,让我们从一个简单的窗口开始:

from PyQt5.QtWidgets import QApplication, QMainWindow

app = QApplication([])
win = QMainWindow()
win.show()
app.exit(app.exec_())

在这种情况下,无限循环由给出 app.exec_()。如果删除该行,您将看到程序正在运行,但是实际上什么也没有发生。将循环放置在中 app.exit() 是确保循环停止运行时正确关闭应用程序的一种方式。重要的是要注意,在定义任何窗口之前,应始终定义要在其中运行窗口的应用程序。如果您更改顺序,则会得到描述性很强的错误:

QWidget: Must construct a QApplication before a QWidget
Aborted (core dumped)

在 PyQt(或通常的Qt)中,窗口的构建块称为 Widget。窗口是窗口小部件,按钮,对话框,图像,图标等。您甚至可以定义自己的自定义窗口小部件。在上面的代码中,您看到只有一个空窗口出现,不太令人兴奋。让我们在窗口中添加一个按钮:

from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton

app = QApplication([])
win = QMainWindow()
button = QPushButton('Test')
win.setCentralWidget(button)
win.show()
app.exit(app.exec_())

按钮称为 QPushButton。代码的各个部分总是相同的,例如应用程序的创建或循环的执行。当创建按钮时,我们还定义按钮将具有的文本。要将按钮添加到窗口,有不同的选项。在这种情况下,由于我们将窗口定义为 QMainWindow,因此可以将按钮设置为其中央窗口小部件。只有在主窗口中定义了中央窗口小部件时,主窗口才起作用。该窗口如下所示:

看起来很傻,但这是一个很好的开始。剩下的最后一件事情就是按下按钮时要做的事情。为了通过按下按钮来触发某些操作,您必须了解 Qt 上下文中的信号和插槽。

Qt 中的信号和插槽

在开发复杂的应用程序(例如带有用户界面的应用程序)时,您可能希望在特定条件下触发不同的操作。例如,您可能想向用户发送电子邮件,说明网络摄像头已完成电影的拍摄。但是,您稍后可能要添加将视频保存到硬盘或将其发布到 Youtube 的可能性。在另一时间,您决定当用户按下按钮时也要保存视频,或者在计算机收到电子邮件时要发布到 Youtube。

开发程序时可以在特定事件上触发动作的一种非常方便的方法是,如果您可以将函数订阅到特定时刻生成的信号。一旦获取了视频,该程序就可以发出一条消息,该消息将被其所有订户捕获。这样,您可以编写一次获取视频的代码,但是可以轻松更改视频结束时发生的情况。

另一方面,您可以编写一次保存视频的功能,并在视频结束或用户按下按钮等时触发它。开发用户界面时要意识到的主要事情是您不知道当事情要发生的时候。可能是用户先获取图像然后制作视频。可能是用户未获取视频并试图保存数据等。因此,能够触发特定事件的动作非常方便。

在 Qt 中,用 Signals 定义触发具有特定事件的动作的整个想法,该信号在特定时刻和 Slots 触发, Slots 是将要执行的动作。使用我们定义的按钮,可以按一个动作或信号。所述 槽是任何函数,我们希望它是,例如,我们将打印一个消息到终端:

from PyQt5.QtWidgets import QApplication, QMainWindow, QPushButton

def button_pressed():
    print('Button Pressed')

app = QApplication([])
win = QMainWindow()
button = QPushButton('Test')
button.clicked.connect(button_pressed)
win.setCentralWidget(button)
win.show()
app.exit(app.exec_())

注意,在这种情况下,我们首先定义了函数 button_pressed。真正的魔术发生在突出显示的行中。我们要使用的信号是 clicked,我们将该信号连接到button_pressed (请注意,我们不在()此行中添加)。如果再次运行该程序并按按钮,则终端上将显示一条消息。

为了继续上面讨论的内容,您可以定义一个新功能,只要按下按钮,该功能就会触发。您将得到类似以下的内容(为了使示例简短,我删除了一些通用的部分):

def button_pressed():
    print('Button Pressed')

def new_button_pressed():
    print('Another function')

button.clicked.connect(button_pressed)
button.clicked.connect(new_button_pressed)

如果再次运行该程序,您将看到,每按一次该按钮,终端上就会出现两条消息。当然,您可以使用从不同软件包中导入的功能。为了提供完整的示例,最后一点是添加第二个按钮并将其 clicked 信号连接到 button_pressed

向主窗口添加新的小部件需要一些额外的步骤。如前所述,每个主窗口都需要一个(也只有一个)中央小部件。主窗口的框架如下所示:

您可以添加窗口具有的所有常规内容,例如菜单,工具栏等,但是只有一个中央小部件。由于我们要添加两个按钮,因此最好是定义一个空的小部件来容纳这两个按钮。反过来,该小部件将成为窗口的中央小部件。

from PyQt5.QtWidgets import QApplication, QMainWindow, 
    QPushButton, QVBoxLayout, QWidget

app = QApplication([])
win = QMainWindow()
central_widget = QWidget()
button = QPushButton('Test', central_widget)
button2 = QPushButton('Second Test', central_widget)
win.setCentralWidget(central_widget)
win.show()
app.exit(app.exec_())

当我们定义按钮时,第二个参数表示哪个元素是小部件的父级。这是一种向小部件添加元素并在彼此之间建立清晰关系的快速方法,我们将在后面看到。如果运行上面的代码,则只会显示 Second Test 按钮。如果您要更改 button 和的定义顺序 button2,则实际上您会看到一个按钮位于另一个按钮的顶部。由于 Second Test 占用更多空间,因此无法让您看到其 Test 下方的内容。

要设置按钮(或任何其他小部件)的位置,可以使用方法 setGeometry。它有四个参数,前两个是相对于父窗口小部件的x,y坐标中的位置。由于小部件可以嵌套,因此记住这一点很重要。其他两个参数是宽度和高度。我们可以执行以下操作:

button.setGeometry(0,50,120,40)

这会将按钮 Test 向下移动 50像素,使其宽度为 120像素,高度为 40像素。如果再次运行代码,您将看到如下所示的窗口:

这不是一件艺术品,但是您可以看到两个按钮一个接一个。如果您喜欢冒险,可以使用 setGeometry 主窗口的方法。如果使它小于按钮占用的空间或更大,等等会发生什么。通过此类示例,您将了解 Qt 的强大功能,以及使事物看起来完全符合您的需求可能变得多么复杂。

在所有这些题外话之后,添加了两个按钮,是时候将它们挂接到功能上了。步骤与使用一个按钮的 clicked 信号相同,仅使用一个按钮的 信号:

button.clicked.connect(button_pressed)
button2.clicked.connect(button_pressed)

如果再次运行该程序,您将看到无论您按下什么按钮,都将执行相同的功能。您还可以将两个按钮都连接到不同的功能,也可以连接到多个功能,等等。这种编程模式使您的代码更易于维护,但对于初学者来说也更复杂。由于可以在程序的任何位置定义要触发的动作,因此可能需要一段时间才能了解何时会发生什么。

添加样式布局

通过设置两个按钮的几何形状来添加两个按钮是可行的,但这并不是最方便的事情。如果更改按钮中的字符数,则文本可能无法容纳在该空格中,您需要跟踪每个按钮的位置,以便在下方添加另一个按钮,依此类推。对于更复杂的布局,当您具有输入字段或不同类型的小部件,单独设置几何形状将非常麻烦。幸运的是,我们可以使用 Layouts 来加快和简化设计。

布局是一种告诉Qt如何组织元素相对于彼此的方式。例如,如果我们希望两个按钮一个接一个,我们可以使用垂直布局。布局已分配给小部件,因此也分配给 central_widget。在我们的示例中,它将变为:

from PyQt5.QtWidgets import QApplication, QMainWindow, 
    QPushButton, QVBoxLayout, QWidget


app = QApplication([])
win = QMainWindow()
central_widget = QWidget()
button2 = QPushButton('Second Test', central_widget)
button = QPushButton('Test', central_widget)
layout = QVBoxLayout(central_widget)
layout.addWidget(button2)
layout.addWidget(button)
win.setCentralWidget(central_widget)
win.show()
app.exit(app.exec_())

现在窗口看起来好多了:

您可以继续尝试调整窗口大小,并查看按钮的适应方式。将其与不使用布局的情况进行比较。当然,您可能希望将一个按钮放在另一个按钮旁边,在这种情况下,您将使用 QHBoxLayout,但是其余代码相同。将信号连接到函数的工作方式完全相同,因为按钮相同,无论它是否在布局内。


从图形用户界面获取图像

现在,您已经完成了如何开始使用 Qt 开发用户界面的第一步。但是,现在该是我们做些什么了。由于我们已经设置了控制网络摄像头的任务,因此我们将这样做。您已经看到将按钮连接到功能非常容易。我们可以完全使用我们先前看到的内容从相机读取帧。首先,让我们导入 OpenCV 并定义我们将要使用的功能:

import cv2
import numpy as np
from PyQt5.QtWidgets import QApplication, QMainWindow, 
    QPushButton, QVBoxLayout, QWidget

cap = cv2.VideoCapture(0)
def button_min_pressed():
    ret, frame = cap.read()
    print(np.min(frame))

def button_max_pressed():
    ret, frame = cap.read()
    print(np.max(frame))

您可以看到我们定义了两个函数,一个函数输出记录帧的最小值,另一个函数输出最大值。现在,我们需要构建其余的用户界面,并将两个按钮连接到这些功能。请注意按钮采用的新名称:

app = QApplication([])
win = QMainWindow()
central_widget = QWidget()
button_min = QPushButton('Get Minimum', central_widget)
button_max = QPushButton('Get Maximum', central_widget)
button_min.clicked.connect(button_min_pressed)
button_max.clicked.connect(button_max_pressed)
layout = QVBoxLayout(central_widget)
layout.addWidget(button_min)
layout.addWidget(button_max)
win.setCentralWidget(central_widget)
win.show()
app.exit(app.exec_())
cap.release()

每次单击其中一个按钮,都会在终端上显示一条消息,说明图像中的最大值或最小值是多少。下一步将是在 GUI 中显示图像。但是,如您所见,随着我们添加更多功能,代码开始变得不清楚。从效率的角度来看,希望一次获取图像,然后计算最大值和最小值。但是,当具有简单的脚本文件时,共享信息变得非常复杂。现在是时候改进解决方案的布局了。


程序布局:MVC设计模式

在继续改进用户界面之前,我们要做的是通过开发可以从主文件轻松导入的不同模块和类来改进代码本身。当我们引用文件名时,我们将使用粗体字符,以避免混淆。所有文件都应位于同一文件夹中,只要您具有写访问权限,就没有关系。

开发出色且可持续的程序是一项艰巨的任务,涉及的不仅仅是编码。没有任何方法可以使所有人都满意。但是,有些常规做法可以使您的程序对于新手来说更加清晰。编程中有一个常见的模式,称为模型视图控制器(MVC)。您可以阅读很多有关它的内容,并且很可能会在开发网站时找到很多有关如何使用它的示例。

在开发与实际设备连接的桌面应用程序时,MVC 结构中每个元素的含义都会改变。例如,控制器将是能够与设备(在我们的情况下是摄像机)进行通信的驱动程序。该驱动程序由 OpenCV 开发,但是很有可能在某个时候我们将开发自己的驱动程序

在模型中,我们将放置有关如何使用设备的所有逻辑,这不一定是设计设备工作方式的逻辑。例如,使用摄像机,movie 即使我们正在使用的特定摄像机仅支持获取单个帧,我们也可以实现一种称为的方法。我们可以根据我们期望如何使用设备来进行检查等。

清晰的视图与用户界面相关,因此与 Qt 相关。重要的是要注意,开发应用程序的安全方法是从视图中剥离所有逻辑。如果由于网络摄像头未就绪等原因不应该运行某些东西,则应由模型负责,而不是由视图负责,以防止发生这种情况。

MVC 模式在不同的应用程序中很常见,但是,您必须足够灵活才能理解每个组件的含义,尤其是在从头开始开发应用程序时(如本教程中的情况)。当您使用诸如 DjangoFlask 之类的框架进行 Web 开发时,框架本身会促使您遵循一些特定的模式。对于台式机和科学应用程序,此类框架尚未成熟,您必须从头开始。

如果您想查看其最终版本的代码,可以查看本文的 Github存储库


摄像机模块

由于 OpenCV 负责摄像机的控制器,因此我们可以开始为其开发模块。您可以在存储库中看到最终模块的 外观。最好的主意是生成我们想用相机做的骨架。布置我们知道将要使用的方法,输入等。然后我们调查它们。创建一个名为 models.py 的文件, 并包含以下内容:

class Camera:
    def __init__(self, cam_num):
        pass

    def get_frame(self):
        pass

    def acquire_movie(self, num_frames):
        pass

    def set_brightness(self, value):
        pass

    def __str__(self):
        return 'Camera'

我们正在为我们的设备开发一个非常简单的模块。如果您想查看模块如何使用科学相机,则可以查看我为 Hamamatsu Orca 相机开发的模块。在此阶段开发模块的优势在于,如果以后我决定更换相机或驱动程序,我唯一需要做的就是更新模块的工作方式,并且程序的其余部分将继续运行。

关于模型,没有什么要注意的事情。您可以看到我们期望该 __init__ 方法采用一个参数,即摄像机编号。这是 VideoCaptureOpenCV 要求的参数。get_frameacquire_movie 负责从相机读取数据,而 set_brightness 是在相机上设置参数的示例。str 如果我们需要识别摄像机,该方法将为我们提供帮助,并且在我们的GUI上非常方便。

我们有了模块的框架,现在该为方法添加一些含义了。使用类的优点是我们可以将重要的参数存储在类本身中。初始化时,我们应该存储 cap 变量,以便其他方法可以访问。

def __init__(self, cam_num):
    self.cap = cv2.VideoCapture(cam_num)
    self.cam_num = cam_num

def __str__(self):
    return 'OpenCV Camera {}'.format(self.cam_num)

我们还修改了该 str 方法,以显示它是一台 OpenCV 摄像机及其编号。如果要快速测试代码,最好的方法是在 models.py 文件的末尾添加一个带有以下内容的块:

if __name__ == '__main__':
    cam = Camera(0)
    print(cam)

如果您只是运行 models.py,则会在屏幕上看到一条消息。您可能还注意到,在上面的示例中,我们没有关闭相机,我们已经忘记了该方法!当然,您始终可以访问该 cam.cap 属性,但是不访问控制器本身会更加优雅,因为稍后其他摄像机可能会使用另一种方法来完成通信。现在,我们可以定义新方法:

def close_camera(self):
    self.cap.release()

实际上,初始化与相机的通信不是在实例化类时而是在决定时初始化,这可能会很好。这样,即使执行了该 close_camera 方法,也可以重新打开相机 。

def __init__(self, cam_num):
    self.cam_num = cam_num
    self.cap = None

def initialize(self):
    self.cap = cv2.VideoCapture(self.cam_num)

在该 __init__ 方法中,我们将其定义 self.capNone,因为这是在初始化时定义类的所有属性的样式规则。这样,您可以快速查看将拥有哪些属性。它还将允许您 cap 在尝试使用之前检查是否已定义。进行了这些更改之后,您还需要更新文件底部的示例:

if __name__ == '__main__':
    cam = Camera(0)
    cam.initialize()
    print(cam)
    cam.close_camera()

现在有趣的部分来了。我们必须定义读取相机的方法。我们还必须决定是否要返回另一个模块可以使用的值,或者是否要将值存储在类本身中。我们还可以结合两个选项:

def get_frame(self):
    ret, self.last_frame = self.cap.read()
    return self.last_frame

如果您从一开始就遵循,那么您应该清楚发生了什么。您还可以看到我们将框架存储 self.last_frame 在类本身中。如果要显示如何使用它,可以在文件末尾更新代码。到目前为止,我们有这样的事件:

if __name__ == '__main__':
    cam = Camera(0)
    cam.initialize()
    print(cam)
    frame = cam.get_frame()
    print(frame)
    cam.close_camera()

它将输出一个很长的数组,所有值都由相机读取。现在我们可以处理 movie 方法。在一开始,我们已经看到电影只是无限循环地接连获取图像。由于无限循环有点危险(很难很好地阻止它们),我们将添加一个称为帧数的参数。

def acquire_movie(self, num_frames):
    movie = []
    for _ in range(num_frames):
        movie.append(self.get_frame())
    return movie

我们首先生成一个空列表,在其中存储图像,然后针对给定数量的帧开始 for循环。在每次循环中,我们附加由方法生成的数据 get_frame。这样做的优点之一是,我们将自动使 last_frame 属性可用。

当使用更复杂的相机时,通常以两个单独的步骤完成电影的开头和从相机中读取。即使程序运行速度较慢,这也可以确保帧之间的正确计时。

您可能已经看到该方法根本无效。追加到列表可能会非常慢,如果帧数过多,则会导致内存错误等。暂时,我们可以使用此方法。

最后要开发的方法是 set_brighntess。这很容易,您可以执行以下操作:

def set_brightness(self, value):
    self.cap.set(cv2.CAP_PROP_BRIGHTNESS, value)

您也可以问自己,是否有可能获得亮度值,实际上是您是否替换 cap.setcap.get。这对于相机的所有属性(例如像素数等)都是有效的。我们可以开发一种新方法,该方法在启动时就不考虑 get_brightness

def get_brightness(self):
    return self.cap.get(cv2.CAP_PROP_BRIGHTNESS)

并使用这两种方法,您可以改进 __main__ 代码:

cam.set_brightness(1)
print(cam.get_brightness())
cam.set_brightness(0.5)
print(cam.get_brightness())

请记住,由于您正在为相机设置参数,因此即使您使用其他程序打开相机,它们也会保留下来。如果将亮度设置得太低或太高,您将在下一次启动时发现它(真实情况)。

现在模块已经准备就绪,我们可以开始开发用户界面了。