Qt5-和-OpenCV4-计算机视觉项目-全-
Qt5 和 OpenCV4 计算机视觉项目(全)
零、前言
我们正在进入智力时代。 如今,越来越多的数字设备和应用提供了人工智能技术所促进的功能。 计算机视觉技术是人工智能技术的重要组成部分,而 OpenCV 库是计算机视觉技术最全面,最成熟的库之一。 OpenCV 超越了传统的计算机视觉技术。 它结合了许多其他技术,例如 DNN,CUDA,OpenGL 和 OpenCL,并且正在发展为功能更强大的库。 但是,与此同时,它的 GUI 功能(不是库的主要功能)并没有发展太多。 同时,在许多 GUI 开发库和框架中,Qt 库是最适合跨平台,具有最佳易用性和最大的控件多样性的库和框架。 本书的目标是将 OpenCV 和 Qt 结合起来,以开发出具有许多有趣功能的成熟应用。
本书是使用 Qt 开发 OpenCV 库和 GUI 应用的实用指南。 我们将在每一章中开发一个完整的应用。 在这些应用的每一个中,将涵盖许多计算机视觉算法,Qt 小部件和其他功能,并将创建具有功能特征的精心设计的用户界面。
这本书是几个月努力工作的结果,没有 Packt 团队和技术审核员的宝贵帮助,这是不可能的。
这本书是给谁的
本书适用于所有想知道如何使用 OpenCV 库处理图像和视频的开发人员,适合那些希望通过 Qt 学习 GUI 开发的人员,以及想要知道如何在计算机视觉中使用深度学习的开发人员。 领域,以及那些对开发成熟的计算机视觉应用感兴趣的人。
本书涵盖的内容
第 1 章,“构建图像查看器”涵盖了使用 Qt 构建我们的第一个应用。 我们将构建一个图像查看器,通过它可以浏览文件夹中的图像。 我们还可以在查看图像时放大或缩小图像。
第 2 章,“像高手一样编辑图像”,结合了 Qt 库和 OpenCV 库来构建一个新的应用,即图像编辑器。 在本章中,我们将从模糊图像开始,以学习如何编辑图像。 然后,我们将学习如何使用许多其他编辑效果,例如腐蚀,锐化,卡通效果和几何变换。 这些功能中的每一个都将作为 Qt 插件被合并,因此 Qt 库的插件机制也将被涵盖。
第 3 章,“家庭安全应用”涵盖了构建家庭安全应用的过程。 借助网络摄像头,该应用可以检测到运动并在检测到运动时向移动电话发送通知。 在本章中,我们将学习如何处理摄像机和视频,如何使用 OpenCV 分析运动并检测运动,以及如何通过 IFTTT 发送通知。
第 4 章,“面部表情”探索了如何使用 OpenCV 检测面部和人脸标志。 在本章中,我们将构建一个应用以实时检测视频中的面部和面部地标,并且在检测到面部地标后,将对面部应用一些有趣的遮罩。
第 5 章,“光学字符识别”向您介绍 Tesseract 库。 借助此库,我们将从图像中提取文本,例如书页照片和扫描的文档。 为了从常见场景的照片中提取文本,我们将使用名为 EAST 的深度学习模型来检测照片中的文本区域,然后将这些区域传递到 Tesseract 库。 为了方便地在屏幕上提取文本,我们还将学习如何使用 Qt 库将屏幕抓取为图像。
第 6 章,“实时对象检测”显示了如何使用级联分类器检测对象。 除了使用预先训练的分类器,我们还将学习如何自行训练分类器。 然后,我们将介绍如何使用深度学习模型来检测对象,并且将使用名为 YOLOv3 的模型来演示此方法的用法。
第 7 章,“实时汽车检测和距离测量”涵盖了创建检测汽车和测量距离的应用。 在该应用中,我们将学习如何从鸟瞰视角测量物体之间的距离,以及如何在视线高度视图上测量物体与相机之间的距离。
第 8 章,“使用 OpenGL 进行图像的高速滤波”,这本书的最后一章介绍了一种异构计算方法。 在本章中,我们首先简要介绍 OpenGL 规范,然后使用它在 GPU 上过滤图像。 这不是使用 OpenGL 的典型方法,也不是进行异构计算的方法,因此,如果我们想以成熟的方式进行异构计算,则可以参考 OpenCL 或 CUDA。
附录 A,“答案”包含所有评估问题的答案。
充分利用这本书
为了获得本书的总体结果,必须满足以下先决条件:
- 您需要具有 C++ 和 C 编程语言的一些基本知识。
- 您需要安装 Qt v5.0 或更高版本。
- 您需要将网络摄像头连接到计算机。
- 还需要许多库,例如 OpenCV 和 Tesseract。 有关安装它们的说明,请参见首次使用每个库的章节。
- 深度学习和异构计算的知识将有助于理解某些章节。
下载示例代码文件
您可以从 www.packt.com 的帐户中下载本书的示例代码文件。 如果您在其他地方购买了此书,则可以访问 www.packt.com/support 并注册以将文件直接通过电子邮件发送给您。
您可以按照以下步骤下载代码文件:
- 登录或注册 www.packt.com 。
- 选择支持选项卡。
- 单击代码下载和勘误。
- 在搜索框中输入书籍的名称,然后按照屏幕上的说明进行操作。
下载文件后,请确保使用以下最新版本解压缩或解压缩文件夹:
- Windows 的 WinRAR/7-Zip
- Mac 的 Zipeg/iZip/UnRarX
- Linux 的 7-Zip/PeaZip
本书的代码包也托管在 GitHub 上。 如果代码有更新,它将在现有 GitHub 存储库上进行更新。
我们还从这里提供了丰富的书籍和视频目录中的其他代码包。 去看一下!
下载彩色图像
我们还提供了 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。 您可以在此处下载。
行动中的代码
使用约定
本书中使用了许多文本约定。
CodeInText:以文本,类或类型名称表示代码字。 这是一个示例:“Qt 项目文件ImageViewer.pro应该重命名为ImageEditor.pro。您可以在文件管理器或终端中执行此操作。”
代码块设置如下:
QMenu *editMenu;
QToolBar *editToolBar;
QAction *blurAction;
当我们希望引起您对代码块特定部分的注意时,将在行尾添加注释:
// for editting
void blurImage();
任何命令行输入或输出的编写方式如下:
$ mkdir Chapter-02
$ cp -r Chapter-01/ImageViewer/ Chapter-02/ImageEditor
$ ls Chapter-02
ImageEditor
$ cd Chapter-02/ImageEditor
$ make clean
$ rm -f ImageViewer
$符号是 shell 提示符,其后的文本是命令。 不以$开头的行是前面命令的输出。
警告或重要提示如下所示。
提示和技巧如下所示。
保持联系
始终欢迎读者的反馈。
一、构建图像查看器
计算机视觉是使计算机能够对数字图像和视频有较高了解的技术,而不仅仅是将它们视为字节或像素。 它广泛用于场景重建,事件检测,视频跟踪,对象识别,3D 姿态估计,运动估计和图像恢复。
OpenCV(开源计算机视觉)是一个实现几乎所有计算机视觉方法和算法的库。 Qt 是一个跨平台的应用框架和窗口小部件工具箱,用于创建具有图形用户界面的应用,这些用户界面可以在所有主要的台式机平台,大多数嵌入式平台甚至移动平台上运行。
在许多受益于计算机视觉技术的行业中,这两个功能强大的库被许多开发人员一起使用,以创建具有可靠 GUI 的专业软件。 在本书中,我们将演示如何使用 Qt 5 和 OpenCV 4 构建这些类型的功能应用,它们具有友好的图形用户界面以及与计算机视觉技术相关的多种功能。
在第一章中,我们将从构建一个简单的 GUI 应用开始,以使用 Qt 5 进行图像查看。
本章将涵盖以下主题:
- 设计用户界面
- 使用 Qt 读取和显示图像
- 放大和缩小图像
- 以任何受支持的格式保存图像副本
- 响应 Qt 应用中的热键
技术要求
确保至少安装了 Qt 版本 5 并具有 C++ 和 Qt 编程的一些基本知识。 还需要兼容的 C++ 编译器,即 Linux 上的 GCC 5 或更高版本,MacOS 上的 Clang 7.0 或更高版本,以及 Microsoft Windows 的 MSVC 2015 或更高版本。
由于必须具备一些相关的基础知识,因此本书不包括 Qt 安装和编译器环境设置。 有很多书籍,在线文档或教程(例如,《使用 C++ 和 Qt5 的 GUI 编程》,作者 Lee Zhi Eng 以及官方的 Qt 库文档)可以帮助您逐步讲解这些基本配置过程; 用户可以根据需要自行参考。
具备所有这些先决条件后,让我们开始开发第一个应用-简单的图像查看器。
设计用户界面
构建应用的第一部分是定义应用将要执行的操作。 在本章中,我们将开发一个图像查看器应用。 它应具有的功能如下:
- 从硬盘打开图像
- 放大/缩小
- 查看同一文件夹中的上一张或下一张图像
- 将当前图像的副本以其他格式另存为另一个文件(具有不同的路径或文件名)
我们可以遵循许多图像查看器应用,例如 Linux 上的 gThumb 和 MacOS 上的 Preview 应用。 但是,我们的应用比进行一些预先计划的应用要简单。 这涉及使用铅笔绘制应用原型的线框。
Pencil 是功能性的原型制作工具。 有了它,您可以轻松创建模型。 它是开源且独立于平台的软件。 铅笔的最新版本现在是基于电子的应用。 它可以在 Windows,Linux 和 MacOS 上良好运行。 您可以从这里免费下载。
以下是显示我们的应用原型的线框:

如上图所示,我们在主窗口中有四个区域:菜单栏,工具栏,主区域和状态栏。
菜单栏上有两个菜单选项-文件和视图菜单。 每个菜单将具有其自己的一组操作。 文件菜单包含以下三个操作,如下所示:
- 打开:此选项从硬盘打开图像。
- 另存为:此选项以任何受支持的格式将当前图像的副本另存为另一个文件(具有不同的路径或文件名)。
- 退出:此选项退出应用。
视图菜单包含四个操作,如下所示:
- 放大:此选项放大图像。
- 缩小:此选项缩小图像。
- 上一个:此选项可打开当前文件夹中的上一个图像。
- 下一个:此选项可打开当前文件夹中的下一张图像。
工具栏由几个按钮组成,也可以在菜单选项中找到。 我们将它们放在工具栏上,为用户提供触发这些操作的快捷方式。 因此,有必要包括所有经常使用的操作,包括以下内容:
- 打开
- 放大
- 缩小
- 上一张图片
- 下一张图片
主区域用于显示由应用打开的图像。
状态栏用于显示与我们正在查看的图像有关的一些信息,例如其路径,尺寸及其大小(以字节为单位)。
您可以在 GitHub 上的代码存储库中找到此设计的源文件。 该文件仅位于存储库的根目录中,名为WireFrames.epgz。 不要忘记应该使用 Pencil 应用将其打开。
从头开始项目
在本节中,我们将从头开始构建图像查看器应用。 您所使用的集成开发环境(IDE)或编辑器均不做任何假设。 我们将只关注代码本身以及如何在终端中使用qmake来构建应用。
首先,让我们为我们的项目创建一个名为ImageViewer的新目录。 我使用 Linux 并在终端中执行此操作,如下所示:
$ pwd
/home/kdr2/Work/Books/Qt5-And-OpenCV4-Computer-Vision-Projects/Chapter-01
$ mkdir ImageViewer
$
然后,我们在该目录中创建一个名为main.cpp的 C++ 源文件,其内容如下:
#include <QApplication>
#include <QMainWindow>
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QMainWindow window;
window.setWindowTitle("ImageViewer");
window.show();
return app.exec();
}
该文件将成为我们应用的网关。 在此文件中,我们首先包括 Qt 库提供的基于 GUI 的 Qt 应用的专用头文件。 然后,我们定义main函数,就像大多数 C++ 应用一样。 在main函数中,我们定义了QApplication类的实例,该实例表示我们的图像查看器应用正在运行,并且定义了QMainWindow的实例,它将作为主 UI 窗口,并且我们在上一节中进行了设计。 创建QMainWindow实例后,我们调用它的一些方法:setWindowTitle设置窗口的标题,show允许窗口出现。 最后,我们调用应用实例的exec方法以进入 Qt 应用的主事件循环。 这将使应用等待,直到调用exit(),然后返回设置为exit()的值。
一旦main.cpp文件保存在我们的项目目录中,我们在终端中进入该目录并运行qmake -project来生成 Qt 项目文件,如下所示:
$ cd ImageViewer/
$ ls
main.cpp
$ qmake -project
$ ls
ImageViewer.pro main.cpp
$
如您所见,将生成一个名为ImageViewer.pro的文件。 该文件包含
Qt 项目的许多指令和配置,qmake稍后将使用此
ImageViewer.pro文件生成生成文件。 让我们检查该项目文件。 在我们省略以#开头的所有注释行之后,以下片段中列出了其内容,如下所示:
TEMPLATE = app
TARGET = ImageViewer
INCLUDEPATH += .
DEFINES += QT_DEPRECATED_WARNINGS
SOURCES += main.cpp
让我们逐行处理。
第一行TEMPLATE = app指定app作为生成项目时要使用的模板。 此处允许使用许多其他值,例如lib和subdirs。 我们正在构建一个可以直接运行的应用,因此app值对我们来说是合适的。 使用其他值超出了本章的范围。 您可以自己参考上的qmake手册,以进行探索。
第二行TARGET = ImageViewer指定应用可执行文件的名称。 因此,一旦构建项目,我们将获得一个名为ImageViewer的可执行文件。
其余各行为编译器定义了几个选项,例如include路径,宏定义和输入源文件。 您可以根据这些行中的变量名称轻松确定哪个行在做什么。
现在,让我们构建项目,运行qmake -makefile生成生成文件,然后运行make生成项目,即,将源代码编译为目标可执行文件:
$ qmake -makefile
$ ls
ImageViewer.pro main.cpp Makefile
$ make
g++ -c -pipe -O2 -Wall -W -D_REENTRANT -fPIC -DQT_DEPRECATED_WARNINGS -DQT_NO_DEBUG -DQT_GUI_LIB -DQT_CORE_LIB -I. -I. -isystem /usr/include/x86_64-linux-gnu/qt5 -isystem /usr/include/x86_64-linux-gnu/qt5/QtGui -isystem /usr/include/x86_64-linux-gnu/qt5/QtCore -I. -isystem /usr/include/libdrm -I/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++
-o main.o main.cpp
main.cpp:1:10: fatal error: QApplication: No such file or directory
#include <QApplication>
^~~~~~~~~~~~~~
compilation terminated.
make: *** [Makefile:395: main.o] Error 1
$
糟糕! 我们遇到了一个大错误。 这是因为从 Qt 版本 5 开始,所有本机 GUI 功能都已从核心模块移至单独的模块,即小部件模块。 通过将行greaterThan(QT_MAJOR_VERSION, 4): QT += widgets添加到项目文件中,我们应该告诉qmake我们的应用依赖于该模块。 进行此修改后,ImageViewer.pro的内容如下所示:
TEMPLATE = app
TARGET = ImageViewer
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
INCLUDEPATH += .
DEFINES += QT_DEPRECATED_WARNINGS
SOURCES += main.cpp
现在,让我们通过在终端中发出qmake -makefile和make命令来再次构建应用,如下所示:
$ qmake -makefile
$ make
g++ -c -pipe -O2 -Wall -W -D_REENTRANT -fPIC -DQT_DEPRECATED_WARNINGS -DQT_NO_DEBUG -DQT_WIDGETS_LIB -DQT_GUI_LIB -DQT_CORE_LIB -I. -I. -isystem /usr/include/x86_64-linux-gnu/qt5 -isystem /usr/include/x86_64-linux-gnu/qt5/QtWidgets -isystem /usr/include/x86_64-linux-gnu/qt5/QtGui -isystem /usr/include/x86_64-linux-gnu/qt5/QtCore -I. -isystem /usr/include/libdrm -I/usr/lib/x86_64-linux-gnu/qt5/mkspecs/linux-g++ -o main.o main.cpp
g++ -Wl,-O1 -o ImageViewer main.o -lQt5Widgets -lQt5Gui -lQt5Core -lGL -lpthread
$ ls
ImageViewer ImageViewer.pro main.cpp main.o Makefile
$
万岁! 最后,我们在项目目录中获得了可执行文件ImageViewer。 现在,让我们执行它,看看窗口是什么样的:

如我们所见,这只是一个空白窗口。 我们将在下一部分中根据我们设计的线框实现完整的用户界面。
尽管我们没有提到任何 IDE 或编辑器,而是使用qmake在终端中构建了该应用,但是您可以使用任何您熟悉的 IDE,例如 Qt Creator。 特别是在 Windows 上,终端(CMD 或 MinGW)的性能不如 Linux 和 MacOS 上的终端,因此请随时使用 IDE。
设置完整的用户界面
让我们继续开发。 在上一节中,我们建立了一个空白窗口,现在我们将菜单栏,工具栏,图像显示组件和状态栏添加到窗口中。
首先,我们将自己定义一个名为MainWindow的类,而不是使用QMainWindow类,该类扩展了QMainWindow类。 让我们在mainwindow.h中查看其声明:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent = nullptr);
~MainWindow();
private:
void initUI();
private:
QMenu *fileMenu;
QMenu *viewMenu;
QToolBar *fileToolBar;
QToolBar *viewToolBar;
QGraphicsScene *imageScene;
QGraphicsView *imageView;
QStatusBar *mainStatusBar;
QLabel *mainStatusLabel;
};
一切都很简单。 Q_OBJECT是 Qt 库提供的关键宏。 如果我们要声明一个具有自定义信号和插槽的类,或者使用 Qt 元对象系统中的任何其他功能,则必须在该类声明中或更确切地说在私有声明中并入这个关键宏。 就像我们刚才所做的那样。 initUI方法初始化在私有部分中声明的所有窗口小部件。 imageScene和imageView小部件将放置在窗口的主要区域中以显示图像。 其他小部件的类型和名称是不言自明的,因此为了使本章简洁,我将不对它们进行过多说明。
为了使本章简洁明了,在介绍该文件时,我没有将每个源文件完整地包含在文本中。 例如,在大多数情况下,文件开头的#include ...方向被忽略。 您可以在 GitHub 上的代码存储库中引用源文件以检查详细信息(如果需要)。
另一个关键方面是mainwindow.cpp中initUI方法的实现,如下所示:
void MainWindow::initUI()
{
this->resize(800, 600);
// setup menubar
fileMenu = menuBar()->addMenu("&File");
viewMenu = menuBar()->addMenu("&View");
// setup toolbar
fileToolBar = addToolBar("File");
viewToolBar = addToolBar("View");
// main area for image display
imageScene = new QGraphicsScene(this);
imageView = new QGraphicsView(imageScene);
setCentralWidget(imageView);
// setup status bar
mainStatusBar = statusBar();
mainStatusLabel = new QLabel(mainStatusBar);
mainStatusBar->addPermanentWidget(mainStatusLabel);
mainStatusLabel->setText("Image Information will be here!");
}
如您所见,在此阶段,我们并未为菜单和工具栏创建所有项目和按钮; 我们只是设置了主要骨架。 在前面的代码中,imageScene变量是QGraphicsSence实例。 这样的实例是 2D 图形项目的容器。 根据其设计,它仅管理图形项目,而没有视觉外观。 为了可视化它,我们应该使用它创建QGraphicsView类的实例,这就是imageView变量在那里的原因。 在我们的应用中,我们使用这两个类来显示图像。
在实现MainWindow类的所有方法之后,该编译源代码了。 在执行此操作之前,需要对ImageViewer.pro项目文件进行许多更改,如下所示:
- 我们只是编写了一个新的源文件,它应该被
qmake所知道:
# in ImageViewer.pro
SOURCES += main.cpp mainwindow.cpp
- 头文件
mainwindow.h具有一个特殊的宏Q_OBJECT,它指示它具有标准 C++ 预处理器无法处理的内容。 该头文件应由 Qt 提供的名为moc,元对象编译器的预处理器正确处理,以生成包含某些与 Qt 元对象系统相关的代码的 C++ 源文件。 因此,我们应该通过将以下行添加到ImageViewer.pro来告诉qmake检查该头文件:
HEADERS += mainwindow.h
好。 现在,所有步骤都已完成,让我们再次运行qmake -makefile和make,然后运行新的可执行文件。 您应该看到以下窗口:

好吧,到目前为止一切都很好。 现在,让我们继续添加应该在菜单中显示的项目。 在 Qt 中,菜单中的每个项目都由QAction的实例表示。 在这里,我们以打开一个新图像为例进行操作。 首先,我们声明一个指向QAction实例的指针作为MainWindow类的私有成员:
QAction *openAction;
然后,在initUI方法的主体中,通过调用new运算符将操作创建为主窗口的子窗口小部件,并将其添加到“文件”菜单中,如下所示:
openAction = new QAction("&Open", this);
fileMenu->addAction(openAction);
您可能会注意到,我们通过调用new运算符创建了许多 Qt 对象,但从未删除它们。 很好,因为所有这些对象都是QObject的实例或其子类。 QObject的实例被组织在 Qt 库中的一个或多个对象树中。 当将QObject创建为另一个对象的子对象时,该对象将自动添加到其父对象的children()列表中。 父对象将获得子对象的所有权。 并且,当处置父对象时,其子对象将自动在其析构器中删除。 在我们的应用中,我们将QObject的大多数实例创建为主窗口对象的子代,因此不需要删除它们。
幸运的是,工具栏上的按钮也可以用QAction表示,因此我们可以将openAction直接添加到文件工具栏:
fileToolBar->addAction(openAction);
如前所述,我们要创建七个动作:打开,另存为,退出,放大,缩小,上一张图像和下一张图像。 可以按照添加打开操作的相同方式添加所有内容。 另外,鉴于添加这些动作需要很多代码行,因此我们可以对代码进行一些重构—创建一个名为createActions的新私有方法,将该动作的所有代码插入该方法,然后在initUI中调用它。
现在,重构后,所有操作都在单独的方法createActions中创建。 让我们编译源代码,看看窗口现在是什么样子:

大! 该窗口看起来就像我们设计的线框一样,现在我们可以通过单击菜单栏上的项目来展开菜单!
实现动作函数
在上一节中,我们向菜单和工具栏添加了一些操作。 但是,如果单击这些操作,则什么也不会发生。 那是因为我们还没有为他们编写任何处理器。 Qt 使用信号和插槽连接机制来建立事件及其处理器之间的关系。 当用户对窗口小部件执行操作时,将发出该窗口小部件的信号。 然后,Qt 将确定是否有与该信号相连的插槽。 如果找到该插槽,则将调用该插槽。 在本节中,我们将为在上一节中创建的动作创建插槽,并将动作信号分别连接到这些插槽。 另外,我们将为常用操作设置一些热键。
退出动作
以退出动作为例。 如果用户从“文件”菜单中单击它,则将发出名为triggered的信号。 因此,让我们将该信号连接到MainWindow类的成员函数createActions中的应用实例的插槽中:
connect(exitAction, SIGNAL(triggered(bool)), QApplication::instance(), SLOT(quit()));
connect方法采用四个参数:信号发送器,信号,接收器和插槽。 一旦建立连接,发送方的信号一发出,接收方的插槽就会被调用。 在这里,我们将退出操作的triggered信号与应用实例的quit插槽连接,以使我们能够在单击退出操作时退出。
现在,要编译并运行,请从“文件”菜单中单击“退出”项。 如果一切顺利,该应用将按我们期望的那样退出。
打开图像
Qt 提供了QApplication的quit插槽,但是如果要在单击打开操作时打开图像,我们应该使用哪个插槽? 在这种情况下,这种自定义任务没有内置的插槽。 我们应该自己写一个插槽。
要编写插槽,首先我们应该在类MainWindow的主体中声明一个函数,并将其放在插槽部分中。 由于其他类未使用此函数,因此将其放在专用插槽部分中,如下所示:
private slots:
void openImage();
然后,为该插槽(也是成员函数)提供一个简单的测试定义:
void MainWindow::openImage()
{
qDebug() << "slot openImage is called.";
}
现在,我们将打开动作的triggered信号连接到createActions方法主体中主窗口的openImage插槽:
connect(openAction, SIGNAL(triggered(bool)), this, SLOT(openImage()));
现在,让我们再次编译并运行它。 单击“文件”菜单中的“打开”项,或单击工具栏上的“打开”按钮,slot openImage is called.消息将打印在终端中。
我们现在有一个测试位置,可以很好地与打开动作配合使用。 让我们更改其主体,如下面的代码所示,以实现从磁盘打开图像的功能:
QFileDialog dialog(this);
dialog.setWindowTitle("Open Image");
dialog.setFileMode(QFileDialog::ExistingFile);
dialog.setNameFilter(tr("Images (*.png *.bmp *.jpg)"));
QStringList filePaths;
if (dialog.exec()) {
filePaths = dialog.selectedFiles();
showImage(filePaths.at(0));
}
让我们逐行浏览此代码块。 在第一行中,我们创建QFileDialog的实例,其名称为dialog。 然后,我们设置对话框的许多属性。 此对话框用于从磁盘本地选择一个图像文件,因此我们将其标题设置为“打开图像”,并将其文件模式设置为QFileDialog::ExistingFile,以确保它只能选择一个现有文件,而不能选择许多文件或文件。 不存在的文件。 名称过滤器图像(* .png * .bmp * .jpg)确保只能选择具有提到的扩展名(即.png,.bmp和.jpg)的文件。 完成这些设置后,我们调用dialog的exec方法将其打开。 如下所示:

如果用户选择一个文件并单击“打开”按钮,则dialog.exec将返回一个非零值。 然后,我们调用dialog.selectedFiles来获取被选为QStringList实例的文件的路径。 在这里,只允许一个选择。 因此,结果列表中只有一个元素:我们要打开的图像的路径。 因此,我们用唯一的元素调用MainWindow类的showImage方法来显示图像。 如果用户单击“取消”按钮,则exec方法将返回零值,我们可以忽略该分支,因为这意味着用户已放弃打开图像。
showImage方法是我们刚刚添加到MainWindow类的另一个私有成员函数。 它的实现如下:
void MainWindow::showImage(QString path)
{
imageScene->clear();
imageView->resetMatrix();
QPixmap image(path);
imageScene->addPixmap(image);
imageScene->update();
imageView->setSceneRect(image.rect());
QString status = QString("%1, %2x%3, %4 Bytes").arg(path).arg(image.width())
.arg(image.height()).arg(QFile(path).size());
mainStatusLabel->setText(status);
}
在显示图像的过程中,我们将图像添加到imageScene,然后更新场景。 之后,场景通过imageView可视化。 鉴于在打开并显示另一幅图像时应用可能已经打开了一幅图像,我们应该删除旧图像,并在显示新图像之前重置视图的任何变换(例如,缩放或旋转)。 这项工作在前两行中完成。 此后,我们使用选定的文件路径构造QPixmap的新实例,然后将其添加到场景中并更新场景。 接下来,我们在imageView上调用setSceneRect来告诉它场景的新范围-它与图像的大小相同。
至此,我们已经在主要区域的中心以原始尺寸显示了目标图像。 最后要做的是在状态栏上显示与图像有关的信息。 我们构造一个包含其路径,尺寸和大小(以字节为单位)的字符串,然后将其设置为mainStatusLabel的文本,该文本已添加到状态栏中。
让我们看看该图像在打开时如何显示:

不错! 该应用现在看起来像一个真正的图像查看器,因此让我们继续实现其所有预期功能。
放大和缩小
好。 我们已经成功显示了图像。 现在,让我们扩展一下。 在这里,我们以放大为例。 根据上述操作的经验,我们应该对如何执行操作有一个清晰的认识。 首先,我们声明一个专用插槽zoomIn,并提供其实现,如以下代码所示:
void MainWindow::zoomIn()
{
imageView->scale(1.2, 1.2);
}
容易吧? 只需使用宽度的缩放比例和高度的缩放比例调用imageView的scale方法。 然后,在MainWindow类的createActions方法中,将zoomInAction的triggered信号连接到此插槽:
connect(zoomInAction, SIGNAL(triggered(bool)), this, SLOT(zoomIn()));
编译并运行该应用,使用它打开一个图像,然后单击工具栏上的“放大”按钮。 您会发现,每次单击时图像会放大到其当前大小的 120%。
缩小仅需要以小于1.0的速率缩放imageView。 请尝试自己实现。 如果发现困难,可以参考我们在 GitHub 上的代码存储库。
通过我们的应用,我们现在可以打开图像并将其缩放以进行查看。 接下来,我们将实现saveAsAction操作的功能。
保存副本
让我们回顾一下MainWindow的showImage方法。 在该方法中,我们从图像创建了QPixmap的实例,然后通过调用imageScene->addPixmap将其添加到imageScene中。 我们没有从该函数中保留任何图像处理器; 因此,现在我们没有方便的方法来在新插槽中获取QPixmap实例,我们将为saveAsAction实现该实例。
为了解决这个问题,我们在MainWindow中添加了一个新的私有成员字段QGraphicsPixmapItem *currentImage来保存imageScene->addPixmap的返回值,并在MainWindow的构造器中使用nullptr对其进行初始化。 然后,我们在MainWindow::showImage主体中找到代码行:
imageScene->addPixmap(image);
为了保存返回的值,我们将这一行替换为以下一行:
currentImage = imageScene->addPixmap(image);
现在,我们准备为saveAsAction创建一个新插槽。 专用插槽部分中的声明很简单,如下所示:
void saveAs();
定义也很简单:
void MainWindow::saveAs()
{
if (currentImage == nullptr) {
QMessageBox::information(this, "Information", "Nothing to save.");
return;
}
QFileDialog dialog(this);
dialog.setWindowTitle("Save Image As ...");
dialog.setFileMode(QFileDialog::AnyFile);
dialog.setAcceptMode(QFileDialog::AcceptSave);
dialog.setNameFilter(tr("Images (*.png *.bmp *.jpg)"));
QStringList fileNames;
if (dialog.exec()) {
fileNames = dialog.selectedFiles();
if(QRegExp(".+\\.(png|bmp|jpg)").exactMatch(fileNames.at(0))) {
currentImage->pixmap().save(fileNames.at(0));
} else {
QMessageBox::information(this, "Information", "Save error: bad format or filename.");
}
}
}
首先,我们检查currentImage是否为nullptr。 如果为true,则表示我们尚未打开任何图像。 因此,我们打开QMessageBox告诉用户没有任何可保存的内容。 否则,我们将创建一个QFileDialog,为其设置相关属性,然后通过调用其exec方法将其打开。 如果用户为对话框提供文件名,然后单击对话框上的打开按钮,我们将获得其中仅包含一个元素的文件路径列表,作为我们的QFileDialog的最后用法。 然后,我们使用正则表达式匹配检查文件路径是否以我们支持的扩展名结尾。 如果一切顺利,我们将从currentImage->pixmap()获取当前图像的QPixmap实例,并将其保存到指定的路径。 插槽准备就绪后,我们将其连接到createActions中的信号:
connect(saveAsAction, SIGNAL(triggered(bool)), this, SLOT(saveAs()));
要测试此功能,我们可以在“另存图像为...”文件对话框中提供一个以.jpg结尾的文件名,以打开 PNG 图像并将其另存为 JPG 图像。 然后,使用另一个图像查看应用打开刚刚保存的新 JPG 图像,以检查图像是否已正确保存。
浏览文件夹
现在,我们已经完成了与单个图像有关的所有操作,让我们进一步浏览一下当前图像所在目录(即prevAction和nextAction)中的所有图像。
要知道上一个或下一个图像是什么构成的,我们应该注意以下两点:
- 当前是哪个
- 我们计算它们的顺序
因此,首先我们向MainWindow类添加一个新的成员字段QString currentImagePath,以保存当前图像的路径。 然后,在showImage中显示图像时,通过向该方法添加以下行来保存图像的路径:
currentImagePath = path;
然后,我们决定根据图像的名称按字母顺序对图像进行计数。 有了这两条信息,我们现在可以确定哪个是上一个图像或下一个图像。 让我们看看如何为prevAction定义广告位:
void MainWindow::prevImage()
{
QFileInfo current(currentImagePath);
QDir dir = current.absoluteDir();
QStringList nameFilters;
nameFilters << "*.png" << "*.bmp" << "*.jpg";
QStringList fileNames = dir.entryList(nameFilters, QDir::Files, QDir::Name);
int idx = fileNames.indexOf(QRegExp(QRegExp::escape(current.fileName())));
if(idx > 0) {
showImage(dir.absoluteFilePath(fileNames.at(idx - 1)));
} else {
QMessageBox::information(this, "Information", "Current image is the first one.");
}
}
首先,我们获得当前图像所在的目录作为QDir的实例,然后列出带有名称过滤器的目录,以确保仅返回 PNG,BMP 和 JPG 文件。 在列出目录时,我们使用QDir::Name作为第三个参数,以确保返回的列表按文件名按字母顺序排序。 由于我们正在查看的当前图像也在此目录中,因此其文件名必须在文件名列表中。 我们通过使用由QRegExp::escape生成的正则表达式调用列表中的indexOf来找到其索引,以便它可以完全匹配其文件名。 如果索引为零,则表示当前图像是该目录中的第一张。 弹出一个消息框,向用户提供此信息。 否则,我们将显示文件名位于index - 1位置的图像以完成操作。
在测试prevAction是否有效之前,请不要忘记在createActions方法的主体中添加以下行来连接信号和插槽:
connect(prevAction, SIGNAL(triggered(bool)), this, SLOT(prevImage()));
好吧,这并不太难,所以您可以自己尝试nextAction的工作,或者只是在 GitHub 上的代码存储库中阅读其代码。
响应热键
至此,几乎所有功能都按照我们的预期实现了。 现在,让我们为常用操作添加一些热键,以使我们的应用更易于使用。
您可能已经注意到,在创建动作时,有时会在其文本中添加一个奇怪的&,例如&File和E&xit。 实际上,这是在 Qt 中设置快捷方式的一种方式。 在某些 Qt 小部件中,在字符前面使用&将自动为该字符创建助记符(快捷方式)。 因此,在我们的应用中,如果按Alt + F,将触发“文件”菜单,并且在“文件”菜单展开时,我们可以看到对其的“退出”操作。 此时,您按Alt + X,将触发退出操作,以使应用退出。
现在,让我们为最常用的操作提供一些单键快捷方式,以使其更方便快捷地使用它们,如下所示:
- 加号(
+)或等于(=)用于放大 - 减号(
-)或下划线(_)用于缩小 - 向上或向左查看上一张图像
- 向下或向右查看下一张图像
为实现此目的,我们在MainWindow类中添加了一个名为setupShortcuts的新私有方法,并按如下方式实现它:
void MainWindow::setupShortcuts()
{
QList<QKeySequence> shortcuts;
shortcuts << Qt::Key_Plus << Qt::Key_Equal;
zoomInAction->setShortcuts(shortcuts);
shortcuts.clear();
shortcuts << Qt::Key_Minus << Qt::Key_Underscore;
zoomOutAction->setShortcuts(shortcuts);
shortcuts.clear();
shortcuts << Qt::Key_Up << Qt::Key_Left;
prevAction->setShortcuts(shortcuts);
shortcuts.clear();
shortcuts << Qt::Key_Down << Qt::Key_Right;
nextAction->setShortcuts(shortcuts);
}
为了支持一个动作的多个快捷键,例如用于放大的+和=,对于每个动作,我们将QKeySequence的空白QList设为空,然后将每个快捷键序列添加到列表中。 在 Qt 中,QKeySequence封装了快捷方式使用的键序列。 因为QKeySequence具有带有int参数的非显式构造器,所以我们可以将Qt::Key值直接添加到列表中,并将它们隐式转换为QKeySequence的实例。 填充列表后,我们对每个带有填充列表的操作调用setShortcuts方法,这样设置快捷方式将更加容易。
在createActions方法主体的末尾添加setupShortcuts()方法调用,然后编译并运行; 现在您可以在应用中测试快捷方式,它们应该可以正常工作。
总结
在本章中,我们使用 Qt 从头构建了一个用于查看图像的桌面应用。 我们学习了如何设计用户界面,从头开始创建 Qt 项目,构建用户界面,打开和显示图像,响应热键以及保存图像副本。
在下一章中,我们将向应用添加更多操作,以允许用户使用 OpenCV 提供的功能来编辑图像。 另外,我们将使用 Qt 插件机制以更灵活的方式添加这些编辑操作。
问题
尝试以下问题,以测试您对本章的了解:
- 我们使用一个消息框来告诉用户,当他们试图查看第一个图像之前的上一个图像或最后一个图像之后的下一个图像时,他们已经在查看第一个或最后一个图像。 但是还有另一种处理这种情况的方法-当用户查看第一张图像时禁用
prevAction,而当用户查看最后一张图像时禁用nextAction。 如何实现? - 我们的菜单项或工具按钮仅包含文本。 我们如何向他们添加图标图像?
- 我们使用
QGraphicsView.scale放大或缩小图像视图,但是如何旋转图像视图? moc有什么作用?SIGNAL和SLOT宏执行什么动作?
二、像专业人士一样编辑图像
在第 1 章,“构建图像查看器”中,我们构建了一个简单的应用,用于从头开始使用 Qt 进行图像查看。 使用该应用,我们可以从本地磁盘查看图像,放大或缩小视图,以及在打开目录中导航。 在本章中,我们将继续该应用并添加一些功能,以允许用户编辑打开的图像。 为了实现这个目标,我们将使用本书开头提到的 OpenCV 库。 为了使应用可扩展,我们将使用 Qt 的插件机制将这些编辑功能中的大多数开发为插件。
本章将涵盖以下主题:
- 在 Qt 和 OpenCV 之间转换图像
- 通过 Qt 的插件机制扩展应用
- 使用 OpenCV 提供的图像处理算法修改图像
技术要求
要求用户正确运行我们在上一章中构建的ImageViewer应用。 本章中的开发将基于该应用。
此外,还必须具备一些 OpenCV 的基本知识。 我们将使用最新版本的 OpenCV,即 4.0 版,该版本于 2018 年 12 月编写本书时发布。 由于新版本尚未包含在许多操作系统(例如 Debian,Ubuntu 或 Fedora)的软件存储库中,因此我们将从源头构建它。 请不要担心,我们将在本章稍后简要介绍安装说明。
ImageEditor应用
在本章中,我们将构建一个可用于编辑图像的应用,因此将其命名为ImageEditor。 要使用 GUI 应用编辑图像,第一步是使用该应用打开和查看图像,这是我们在上一章中所做的。 因此,在添加图像编辑功能之前,我决定制作一个ImageViewer应用的副本并将其重命名为ImageEditor。
让我们从复制源开始:
$ mkdir Chapter-02
$ cp -r Chapter-01/ImageViewer/ Chapter-02/ImageEditor
$ ls Chapter-02
ImageEditor
$ cd Chapter-02/ImageEditor
$ make clean
$ rm -f ImageViewer
使用这些命令,我们将Chapter-01目录下的ImageViewer目录复制到Chapter-02/ImageEditor。 然后,我们可以进入该目录,运行make clean来清理在编译过程中生成的所有中间文件,并使用rm -f ImageViewer删除旧的目标可执行文件。
现在我们有一个清理的项目,让我们重命名一些项目:
- 在复制过程中,项目目录使用新的项目名称
ImageEditor命名,因此我们无需在此处做任何事情。 - Qt 项目文件
ImageViewer.pro应该重命名为ImageEditor.pro。 您可以在文件管理器或终端中执行此操作。 - 在
ImageEditor.pro文件中,我们应该通过将TARGET = ImageViewer行更改为TARGET = ImageEditor将TARGET重命名为ImageEditor。 - 在源文件
main.cpp中,我们应该通过将window.setWindowTitle("ImageViewer");行更改为window.setWindowTitle("ImageEditor");来更改窗口标题。
现在,所有内容都已重命名,让我们编译并运行新的ImageEditor应用,该应用已从ImageViewer复制:
$ qmake -makefile
$ make
g++ -c -pipe ...
# output truncated
# ...
$ ls
ImageEditor ImageEditor.pro main.cpp main.o mainwindow.cpp mainwindow.h
mainwindow.o Makefile moc_mainwindow.cpp moc_mainwindow.o moc_predefs.h
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ./ImageEditor
您将看到该窗口与ImageViewer的窗口完全相同,但是它具有不同的窗口标题ImageEditor。 无论如何,我们已经设置了编辑器应用,即使它现在没有图像编辑功能。 我们将在下一章中添加一个简单的编辑功能。
使用 OpenCV 模糊图像
在上一节中,我们设置了编辑器应用。 在本节中,我们将添加一个简单的图像编辑功能-一个操作(在菜单和工具栏上)以使图像模糊。
我们将分两步执行此操作:
- 首先,我们将设置 UI 并添加操作,然后将操作连接到虚拟插槽。
- 然后,我们将覆盖虚拟插槽以使图像模糊,这将涉及到 OpenCV 库。
添加模糊动作
我们将在本章中添加的大多数操作将用于编辑图像,因此我们应将其归类到新的菜单和工具栏中。 首先,我们将在mainwindow.h头文件的私有部分中声明三个成员,即编辑菜单,编辑工具栏和模糊动作:
QMenu *editMenu;
QToolBar *editToolBar;
QAction *blurAction;
然后,我们将分别在MainWindow::initUI和MainWindow::createActions方法中创建它们,如下所示:
在MainWindow::initUI中,执行如下:
editMenu = menuBar()->addMenu("&Edit");
editToolBar = addToolBar("Edit");
在MainWindow::createActions中,执行如下:
blurAction = new QAction("Blur", this);
editMenu->addAction(blurAction);
editToolBar->addAction(blurAction);
到现在为止,我们都有一个编辑菜单和一个编辑工具栏,它们两个都带有模糊操作。 但是,如果用户单击工具栏上的模糊按钮或编辑菜单下的模糊项目,则不会发生任何事情。 这是因为我们尚未将插槽连接到该操作。 让我们现在为该动作添加一个插槽。 首先,我们将在mainwindow.h的private slot部分中声明一个插槽,如下所示:
// for editting
void blurImage();
然后,我们将在mainwindow.cpp中为其提供一个虚拟实现:
void MainWindow::blurImage()
{
qDebug() << "Blurring the image!";
}
现在插槽已经准备好了,是时候在mainwindow::createActions方法的末尾将模糊操作的triggered信号与此插槽连接了:
connect(blurAction, SIGNAL(triggered(bool)), this, SLOT(blurImage()));
编译并运行应用时,您将看到菜单,工具栏和操作。 如果通过单击触发操作,您将看到消息Blurring the image!正在打印。
窗口和打印的消息如下所示:

UI 部分现已准备就绪,这意味着我们可以集中精力在以下部分的插槽中,通过使用 OpenCV 来模糊图像。
从源代码构建和安装 OpenCV
在上一节中,我们为模糊操作安装了一个虚拟插槽,该插槽什么都不做,只显示一条简单消息。 现在,我们将覆盖该插槽的实现以进行真正的模糊处理。
如前几节所述,我们将使用 OpenCV 库(更确切地说是它的最新版本(4.0))来编辑图像。 因此,在开始编写代码之前,我们将安装最新版本的 OpenCV 库并将其包含在我们的项目中。
OpenCV 是一组库,工具和模块,包含构建计算机视觉应用所需的类和函数。 可以在其官方网站的发布页面上找到其发布文件。 我们需要知道的另一件事是,OpenCV 使用了一种称为 CMake 的现代构建工具来构建其构建系统。 这意味着我们必须在操作系统上安装 CMake 才能从源代码构建 OpenCV,并且至少需要 CMake 3.12 版本,因此请确保正确设置了 CMake 版本。
在软件工程界,如何构建项目(尤其是大型项目)是一个复杂的话题。 在软件工程的开发过程中,发明了许多工具来应对与该主题有关的各种情况。 从make到 Autotools,从 SCons 到 CMake,从 Ninja 到 bazel,这里有太多要讨论的话题。 但是,到目前为止,在我们的书中只介绍了其中的两个:qmake是 Qt 团队开发的,专门用于构建 Qt 项目。 CMake 是当今许多项目(包括 OpenCV)广泛采用的另一种方法。
在我们的书中,我们将尽力使这些工具的使用简单明了。
OpenCV 发行页面如下所示:

我们可以单击Sources链接将其源的 ZIP 包下载到本地磁盘,然后将其解压缩。 我们将在终端中使用 CMake 来构建 OpenCV,因此,我们将打开一个终端并将其工作目录更改为未压缩源的目录。 另外,OpenCV 不允许您直接在其源代码树的根目录中进行构建,因此我们应该创建一个单独的目录来进行构建。
以下是我们在终端中用于构建 OpenCV 的说明:
$ cd ~/opencv-4.0.0 # path to the unzipped source
$ mkdir release # create the separate dir
$ cd release
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=$HOME/programs/opencv ..
# ... output of cmake ...
# rm ../CMakeCache.txt if it tells you are not in a separate dir
$ make
# ... output of make ...
$ make install
cmake ...行读取已解压缩源的根目录中的CMakeLists.txt文件,并生成一个 makefile。 使用-D传递给cmake命令的CMAKE_BUILD_TYPE变量指定我们以RELEASE模式构建 OpenCV。 同样,CMAKE_INSTALL_PREFIX变量指定将 OpenCV 库安装到的路径。 在这里,我将 OpenCV 安装到$HOME/programs/opencv,即/home/kdr2/programs/opencv -如果需要,可以更改CMAKE_INSTALL_PREFIX的值以更改目标目录。 cmake命令成功结束后,将生成一个名为Makefile的文件。 使用 makefile,现在我们可以运行make和make install来编译和安装该库。
如果上述所有说明均操作正确,则将正确安装您的 OpenCV 版本。 您可以通过浏览安装目录进行检查:
$ ls ~/programs/opencv/
bin include lib share
$ ls ~/programs/opencv/bin/
opencv_annotation opencv_interactive-calibration opencv_version
opencv_visualisation setup_vars_opencv4.sh
$ ls -l ~/programs/opencv/lib/
# ...
lrwxrwxrwx 1 kdr2 kdr2 21 Nov 20 13:28 libopencv_core.so -> libopencv_core.so.4.0
lrwxrwxrwx 1 kdr2 kdr2 23 Nov 20 13:28 libopencv_core.so.4.0 -> libopencv_core.so.4.0.0
-rw-r--r-- 1 kdr2 kdr2 4519888 Nov 20 12:34 libopencv_core.so.4.0.0
# ...
lrwxrwxrwx 1 kdr2 kdr2 24 Nov 20 13:28 libopencv_imgproc.so -> libopencv_imgproc.so.4.0
lrwxrwxrwx 1 kdr2 kdr2 26 Nov 20 13:28 libopencv_imgproc.so.4.0 -> libopencv_imgproc.so.4.0.0
-rw-r--r-- 1 kdr2 kdr2 4714608 Nov 20 12:37 libopencv_imgproc.so.4.0.0
# ... output truncated
OpenCV 是一个模块化库。 它由两种类型的模块组成-主模块和附加模块。
从源代码构建时,默认情况下,主要模块包含在 OpenCV 中,它们包含所有 OpenCV 核心功能,以及用于图像处理任务,过滤,转换和更多功能的模块。
额外的模块包括默认情况下未包含在 OpenCV 库中的所有 OpenCV 功能,并且它们大多包含其他与计算机视觉相关的功能。
如果在检查 OpenCV 是否正确安装时回顾一下 OpenCV 安装路径下lib目录的内容,则会发现许多以libopencv_*.so*模式命名的文件。 通常,每个文件都对应一个 OpenCV 模块。 例如,libopencv_imgproc.so文件是imgproc模块,该模块用于图像处理任务。
现在我们已经安装了 OpenCV 库,是时候将其包含在我们的 Qt 项目中了。 让我们打开 Qt 项目文件ImageEditor.pro,并在其中添加以下几行:
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -l opencv_imgproc
}
unix: !mac指令的意思是在除 MacOS 之外的任何类似 UNIX 的系统上使用其旁边方括号中的配置。 我使用此指令是因为我正在使用 Debian GNU/Linux。 括号内的指令是在以下行中导入 OpenCV 库的关键部分:
- 第一行通过更新
INCLUDEPATH的值来告诉编译器我们将在代码中使用的 OpenCV 头文件在哪里。 - 第二行告诉链接器我们的应用应该链接到哪个 OpenCV 模块(共享对象),以及在哪里找到它们。 更具体地说,
-lopencv_core -l opencv_imgproc表示我们应将应用与libopencv_core.so和libopencv_imgproc.so链接,而-L...则意味着链接器应在/home/kdr2/programs/opencv/lib目录下找到这些库文件(共享对象)。
在 MacOS 或 Windows 上,OpenCV 以另一种方式构建和链接,但不在模块的每个库文件中。 在这种情况下,所有模块都链接到一个名为opencv_world的库。 我们可以将-DBUILD_opencv_world=on传递给 CMake 在 Linux 上达到相同的效果:
# on mac
$ ls -l
-rwxr-xr-x 1 cheftin staff 25454204 Dec 3 13:47 libopencv_world.4.0.0.dylib
lrwxr-xr-x 1 cheftin staff 27 Dec 3 13:36 libopencv_world.4.0.dylib -> libopencv_world.4.0.0.dylib
lrwxr-xr-x 1 cheftin staff 25 Dec 3 13:36 libopencv_world.dylib -> libopencv_world.4.0.dylib
# on Linux with -D BUILD_opencv_world=on
$ ls -l
lrwxrwxrwx 1 kdr2 kdr2 22 Nov 29 22:55 libopencv_world.so -> libopencv_world.so.4.0
lrwxrwxrwx 1 kdr2 kdr2 24 Nov 29 22:55 libopencv_world.so.4.0 -> libopencv_world.so.4.0.0
-rw-r--r-- 1 kdr2 kdr2 57295464 Nov 29 22:09 libopencv_world.so.4.0.0
以这种方式构建 OpenCV 可以简化我们在编译源代码时的链接器选项-我们不需要像-lopencv_core -lopencv_imgproc那样为链接器提供模块列表。 告诉链接器链接到opencv_world就足够了。 对于 MacOS 和 Windows,我们可以将以下代码放入ImageEditor.pro:
unix: mac {
INCLUDEPATH += /path/to/opencv/include/opencv4
LIBS += -L/path/to/opencv/lib -lopencv_world
}
win32 {
INCLUDEPATH += c:/path/to/opencv/include/opencv4
LIBS += -lc:/path/to/opencv/lib/opencv_world
}
尽管这种方法比较容易,但是本书仍然使用单独的模块,使您可以深入了解我们正在学习和使用的 OpenCV 模块。
qmake为您提供了另一种配置第三方库的方法,即通过pkg-config,它是用于维护库的元信息的工具。 不幸的是,根据这个页面 4的说法,OpenCV 从 4.0 版开始不再支持pkg-config。 这意味着我们需要使用更直接,更灵活的方法在 Qt 项目中配置 OpenCV,而不是使用pkg-config方法。
图像模糊
最后,我们已经安装并配置了 OpenCV 库。 现在,让我们尝试使用它来模糊连接到我们的模糊操作的插槽中的图像。
首先,我们将以下行添加到mainwindow.cpp文件的开头,以便我们可以包含 OpenCV 头文件:
#include "opencv2/opencv.hpp"
现在准备工作已经完成,因此让我们集中讨论 slot 方法的实现。 像打算在单个打开的图像上运行的任何插槽一样,在执行任何操作之前,我们需要检查当前是否有打开的图像:
if (currentImage == nullptr) {
QMessageBox::information(this, "Information", "No image to edit.");
return;
}
如您所见,如果没有打开的图像,我们将提示一个消息框并立即从函数返回。
在确定当前在应用中有打开的图像之后,我们知道可以将打开的图像作为QPixmap的实例。 但是,如何使用 OpenCV 来使图像QPixmap形式的图像模糊? 答案是,我们不能。 在使用 OpenCV 对图像进行任何操作之前,我们必须使图像具有 OpenCV 如何保存图像的形式,这通常是Mat类的实例。 OpenCV 中的Mat类表示矩阵-实际上,任何图像都是具有给定宽度,高度,通道数和深度的矩阵。 在 Qt 中,我们有一个类似的类QImage,它用于保存图像的矩阵数据。 这意味着我们有了一个如何使用 OpenCV 模糊QPixmap的想法-我们需要将QPixmap转换为QImage,使用QImage构造Mat,模糊Mat,然后转换 Mat分别返回到QImage和QPixmap。
在转换方面,我们必须做很多工作。 让我们通过以下几行代码来讨论:
QPixmap pixmap = currentImage->pixmap();
QImage image = pixmap.toImage();
此代码段非常简单。 我们获取当前图像的数据作为QPixmap的实例,然后通过调用其toImage方法将其转换为QImage实例。
下一步是将QImage转换为Mat,但是这里有些复杂。 我们正在打开的图像可以是任何格式-它可以是单色图像,灰度图像或深度不同的彩色图像。 要模糊它,我们必须知道它的格式,因此尽管它是原始格式,我们仍将其转换为具有 8 位深度和三个通道的常规格式。 这由 Qt 中的QImage::Format_RGB888和 OpenCV 中的CV_8UC3表示。 现在让我们看看如何进行转换并构造Mat对象:
image = image.convertToFormat(QImage::Format_RGB888);
cv::Mat mat = cv::Mat(
image.height(),
image.width(),
CV_8UC3,
image.bits(),
image.bytesPerLine());
最后,这是一段可编辑的代码。 现在我们有了Mat对象,让我们对其进行模糊处理:
cv::Mat tmp;
cv::blur(mat, tmp, cv::Size(8, 8));
mat = tmp;
OpenCV 在其imgproc模块中提供blur函数。 它使用带有核的归一化框过滤器来模糊图像。 第一个参数是我们要模糊的图像,而第二个参数是我们要放置模糊的图像的位置。 我们使用临时矩阵存储模糊的图像,并在模糊结束后将其分配回原始图像。 第三个参数是核的大小。 在这里,核用于告诉 OpenCV 如何通过将其与不同数量的相邻像素组合来更改任何给定像素的值。
现在,我们已经将模糊图像作为Mat的实例,我们必须将其转换回QPixmap的实例,并在场景和视图中进行显示:
QImage image_blurred(
mat.data,
mat.cols,
mat.rows,
mat.step,
QImage::Format_RGB888);
pixmap = QPixmap::fromImage(image_blurred);
imageScene->clear();
imageView->resetMatrix();
currentImage = imageScene->addPixmap(pixmap);
imageScene->update();
imageView->setSceneRect(pixmap.rect());
对我们来说,前面代码的新部分是从mat对象构造QImage对象image_blurred,然后使用QPixmap::fromImage静态方法将QImage对象转换为QPixmap。 尽管这是新的,但很明显。 这段代码的其余部分对我们来说并不陌生,它与我们在MainWindow类的showImage方法中使用的代码相同。
现在我们已经显示了模糊的图像,我们可以更新状态栏上的消息以告诉用户他们正在查看的该图像是已编辑的图像,而不是他们打开的原始图像:
QString status = QString("(editted image), %1x%2")
.arg(pixmap.width()).arg(pixmap.height());
mainStatusLabel->setText(status);
至此,我们已经完成了MainWindow::blurImage方法。 让我们通过在终端中发出qmake -makefile和make命令来重建项目,然后运行新的可执行文件。
如果像我一样,在非/usr或/usr/local的路径中安装 OpenCV,则在运行可执行文件时可能会遇到问题:
$ ./ImageEditor
./ImageEditor: error while loading shared libraries: libopencv_core.so.4.0: cannot open shared object file: No such file or directory
这是因为我们的 OpenCV 库不在系统的库搜索路径中。 通过在 Linux 上设置LD_LIBRARY_PATH环境变量,在 MacOS 上设置DYLD_LIBRARY_PATH,我们可以将其路径添加到库搜索路径:
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ./ImageEditor
使用我们的应用打开图像时,将获得以下输出:

单击工具栏上的“模糊”按钮后,其显示如下:

我们可以看到我们的图像已成功模糊。
QPixmap,QImage和Mat
在上一节中,我们添加了一项新功能来模糊在ImageEditor应用中打开的图像。 在模糊图像的同时,我们将图像从QPixmap转换为QImage并转换为Mat,然后在使用 OpenCV 对其进行模糊处理之后将其向后转换。 在那里,我们做了工作,但对这些类没有多说。 让我们现在谈论它们。
QPixmap
QPixmap是 Qt 库提供的一个类,打算在需要在屏幕上显示图像时使用。 这正是我们在项目中使用它的方式—我们读取图像作为其实例,并将该实例添加到QGraphicsSence中以显示它。
有很多方法可以创建QPixmap的实例。 就像我们在第 1 章,“构建图像查看器”以及本章前面的部分中所做的一样,我们可以使用图像文件的路径实例化它:
QPixmap map("/path/to/image.png");
另外,我们可以实例化一个空的QPixmap,然后将数据加载到其中:
QPixmap map;
map.load("/path/to/image.png");
对于实例中包含图像的实例,我们可以通过调用其save方法将其保存到文件中,就像我们在“另存为”操作的插槽中所做的那样:
map.save("/path/to/output.png");
最后,我们可以通过调用toImage方法将QPixmap方法转换为QImage方法:
//...
QImage image = map.toImage();
QImage
尽管QPixmap主要用于以 Qt 显示图像,但QImage是针对 I/O 以及直接像素访问和操纵而设计和优化的。 通过此类,我们可以获得有关图像的信息,例如图像的大小,是否具有 alpha 通道,是否为灰度图像以及其中任何像素的颜色。
QImage设计用于直接像素访问和操纵,并且它提供进行图像处理的功能,例如像素操纵和变换。 毕竟,Qt 库不是专门用于图像处理的库,因此它在此域中提供的功能不能满足本章的要求。 因此,在将QImage对象转换为Mat对象后,我们将使用 OpenCV 进行图像处理。
然后,问题是,如何在QImage,QPixmap和Mat这三种数据类型之间转换? 在上一节中,我们讨论了如何将QPixmap转换为QImage,但现在让我们看一下如何将其转换回:
QPixmap pixmap = QPixmap::fromImage(image);
如您所见,这是一个简单的过程-您只需使用QImage对象作为唯一参数来调用QPixmap类的fromImage静态方法。
如果您对QImage其他功能的详细信息感兴趣,可以在这个页面上参考其文档。 在下一节中,我们将讨论如何将QImage转换为Mat,反之亦然。
Mat
Mat类是 OpenCV 库中最重要的类之一,其名称是矩阵的简称。 在计算机视觉领域,正如我们前面提到的,任何图像都是具有给定宽度,高度,通道数量和深度的矩阵。 因此,OpenCV 使用Mat类表示图像。 实际上,Mat类是一个 N 维数组,可用于存储具有任何给定数据类型的单个或多个数据通道,并且它包含许多以多种方式创建,修改或操纵它的成员和方法。 。
Mat类具有许多构造器。 例如,我们可以创建一个实例,该实例的宽度(列)为800,高度(行)为600,其中三个通道包含 8 位无符号int值,如下所示:
Mat mat(600, 800, CV_8UC3);
此构造器的第三个参数是该矩阵的type; OpenCV 预定义了许多可用于它的值。 这些预定义的值在名称中都有一个模式,以便我们在看到名称时可以知道矩阵的类型,或者可以在确定矩阵的性质时猜测应该使用的名称。
此模式称为CV_<depth><type>C<channels>:
<depth>可以用8,16,32或64代替,它们表示用于在像素中存储每个元素的位数- 对于无符号整数,有符号整数和浮点值,需要分别用
U,S或F替换<type> <channel>应该是通道数
因此,在我们的代码中,CV_8UC3表示声明的图像的深度为8,其像素的每个元素都存储在 8 位无符号int中,并且具有三个通道。 换句话说,每个像素中具有 3 个元素,CV_8UC3占据 24 位(depth * channels)。
我们还可以在构建图像时为其填充一些数据。 例如,我们可以用恒定的颜色填充它,如下所示:
int R = 40, G = 50, B = 60;
Mat mat(600, 800, CV_8UC3, Scalar(B, G, R));
在前面的代码中,我们创建了与上一个示例中相同的图像,但是使用第四个参数指定的恒定颜色RGB(40, 50, 60)填充了该图像。
重要的是要注意,OpenCV 中默认的颜色顺序是 BGR,而不是 RGB,这意味着B和R值互换了。 因此,我们在代码中将恒定颜色表示为Scalar(B, G, R)而不是Scalar(R, G, B)。 如果我们使用 OpenCV 读取图像,但使用另一个对颜色使用不同顺序的库来处理图像,则反之亦然,尤其是当我们的处理方法分别处理图像的每个通道时,这一点很重要。
那就是在我们的应用中发生的事情-我们使用 Qt 加载图像并将其转换为 OpenCV Mat数据结构,然后对其进行处理并将其转换回QImage。 但是,如您所见,在使图像模糊时,我们没有交换红色和蓝色通道来求助于颜色顺序。 这是因为blur函数在通道上对称运行; 通道之间没有干扰,因此在这种情况下颜色顺序并不重要。 如果执行以下操作,则可以省略通道交换:
- 我们将
QImage转换为Mat,然后处理Mat并将其转换回QImage - 我们在
Mat上执行的处理期间内的所有操作在通道上都是对称的; 也就是说,通道之间没有干扰 - 在处理期间我们不会显示图像; 我们仅在将它们转换回
QImage后向他们显示
在这种情况下,我们可以简单地忽略颜色顺序的问题。 这将应用于我们稍后将编写的大多数插件。 但是,在某些情况下,您不能只是简单地忽略它。 例如,如果您使用 OpenCV 读取图像,将其转换为QImage的实例,然后在 Qt 中显示,则以下代码将显示其红色和蓝色通道已交换的图像:
cv::Mat mat = cv::imread("/path/to/an/image.png");
QImage image(
mat.data,
mat.cols,
mat.rows,
mat.step,
QImage::Format_RGB888
);
在将其转换为QImage之前,应先交换 R 和 B 通道:
cv::Mat mat = cv::imread("/path/to/an/image.png");
cv::cvtColor(mat, mat, cv::COLOR_BGR2RGB);
QImage image(
mat.data,
mat.cols,
mat.rows,
mat.step,
QImage::Format_RGB888
);
请记住,如果我们使用的过程不能使用 OpenCV 对称地处理颜色通道,则在执行该操作之前,必须确保颜色顺序为 BGR。
现在我们已经讨论了颜色的顺序,我们将回到创建Mat对象的主题。 我们刚刚了解到,可以在创建Mat对象时用恒定的颜色填充它,但是,在我们的应用中,我们应该创建一个Mat对象,该对象与给定QImage对象的图像相同。 让我们回头看看我们是如何做到的:
// image is the give QImage object
cv::Mat mat = cv::Mat(
image.height(),
image.width(),
CV_8UC3,
image.bits(),
image.bytesPerLine()
);
除了我们已经讨论的前三个参数外,我们还传递由QImage对象持有并由其bits方法返回的数据指针作为第四个参数。 我们还传递了另一个额外的参数,即图像每行的字节数,以使 OpenCV 知道如何处理图像填充字节,以及如何以有效的方式将其存储在内存中。
如前所述,Mat类的构造器太多了,在此不多讨论。 我们甚至可以创建尺寸更大的Mat对象。 您可以参考这里上的文档以获取其构造器的完整列表。 在本章中,我们将不多讨论它们。
现在我们已经掌握了如何在 Qt 和 OpenCV 之间转换图像对象的知识,接下来的几节将继续介绍如何使用 OpenCV 编辑图像。
使用 Qt 的插件机制添加功能
在上一节中,我们向我们的应用添加了一个名为编辑的新菜单和工具栏,并向它们两个添加了操作以使打开的图像模糊。 让我们回顾一下添加此功能的过程。
首先,我们添加了菜单和工具栏,然后添加了动作。 添加动作后,我们将新的插槽连接到该动作。 在该插槽中,我们将打开的图像作为QPixmap的实例,并将其转换为QImage对象,然后转换为Mat对象。 关键的编辑工作从这里开始-我们使用 OpenCV 修改Mat实例以完成编辑工作。 然后,我们将Mat分别转换回QImage和QPixmap,以显示编辑后的图像。
现在,如果我们想向我们的应用添加另一个编辑功能,我们应该怎么做? 当然,只重复前面添加模糊动作的过程就可以了,但是效率不高。 如果我们想象我们只是以添加模糊动作的相同方式向应用添加了另一个编辑动作,我们会发现大多数工作或代码都是相同的。 我们正在重复自己。 这不仅是一种不良的发展模式,而且是无聊的工作。
要解决此问题,我们应该仔细地进行重复的过程,将其分为多个步骤,然后找出哪些步骤完全相同,哪些步骤有所不同。
这样,我们可以找出添加其他编辑功能的关键点:
- 对于不同的编辑功能,操作的名称是不同的。
Mat实例上的操作因不同的编辑功能而有所不同。
除前两个之外,其他所有步骤或逻辑在添加不同的编辑动作的过程中都是相同的。 也就是说,当我们要添加新的编辑功能时,我们只需要做两件事。 首先,我们将其命名,然后找出一种使用 OpenCV 对Mat实例进行编辑操作的方法。 一旦清除了这两件事,就可以确定新的编辑功能。 接下来,我们需要将新功能集成到应用中。
那么,我们如何将其集成到应用中呢? 我们将使用 Qt 的插件机制来执行此操作,并且每个编辑功能都将是一个插件。
插件接口
Qt 插件机制是使 Qt 应用更可扩展的强大方法。 如前所述,我们将使用这种机制来抽象一种可以轻松添加新编辑功能的方式。 完成后,在添加新的编辑功能时,只需要注意编辑功能的名称和Mat实例上的操作即可。
第一步是找出一个接口,以便在应用和插件之间提供通用协议,以便我们可以加载和调用插件,而不管插件是如何实现的。 在 C++ 中,接口是具有纯虚拟成员函数的类。 对于我们的插件,我们需要处理Mat的动作名称和操作,因此我们在editor_plugin_interface.h中声明我们的接口,如下所示:
#ifndef EDITOR_PLUGIN_INTERFACE_H
#define EDITOR_PLUGIN_INTERFACE_H
#include <QObject>
#include <QString>
#include "opencv2/opencv.hpp"
class EditorPluginInterface
{
public:
virtual ~EditorPluginInterface() {};
virtual QString name() = 0;
virtual void edit(const cv::Mat &input, cv::Mat &output) = 0;
};
#define EDIT_PLUGIN_INTERFACE_IID "com.kdr2.editorplugininterface"
Q_DECLARE_INTERFACE(EditorPluginInterface, EDIT_PLUGIN_INTERFACE_IID);
#endif
我们使用ifndef/define习惯用语(前两行和最后一行)来确保此头文件一次包含在源文件中。 在前两行之后,我们包括 Qt 和 OpenCV 提供的一些头文件,以介绍相关的数据结构。 然后,我们声明一个名为EditorPluginInterface的类,这是我们的接口类。 在该类中,除了虚拟的空析构器之外,我们还可以看到两个纯虚拟成员函数:name和edit函数。 name函数返回QString,这将是编辑操作的名称。 edit函数将Mat的两个引用用作其输入和输出,并用于编辑操作。 每个插件都是该接口的子类,这两个函数的实现将确定操作名称和编辑操作。
在类声明之后,我们定义一个名为com.kdr2.editorplugininterface的唯一标识符字符串作为接口的 ID。 该 ID 在应用范围内必须是唯一的,也就是说,如果编写其他接口,则必须为它们使用不同的 ID。 然后,我们使用Q_DECLARE_INTERFACE宏将接口的类名与定义的唯一标识符相关联,以便 Qt 的插件系统可以在加载之前识别此接口的插件。
至此,已经确定了用于编辑功能的接口。 现在,让我们编写一个插件来实现此接口。
用ErodePlugin腐蚀图像
要编写 Qt 插件,我们应该从头开始一个新的 Qt 项目。 在先前的编辑功能中,我们只是通过从 OpenCV 调用blur函数来使图像模糊。 考虑到我们的主要目的是介绍 Qt 库的插件机制,我们仍将使用 OpenCV 库中的一个简单函数进行简单的编辑以使这一部分更加清楚。 在这里,我们将从 OpenCV 库中调用erode函数,以侵蚀图像中的对象。
让我们命名插件ErodePlugin并从头开始创建项目:
$ ls
ImageEditor
$ mkdir ErodePlugin
$ ls
ErodePlugin ImageEditor
$ cd ErodePlugin
$ touch erode_plugin.h erode_plugin.cpp
$ qmake -project
$ ls
erode_plugin.h erode_plugin.cpp ErodePlugin.pro
首先,在终端中,将目录更改为ImageEditor项目的父目录,创建一个名为ErodePlugin的新目录,然后输入该目录。 然后,我们创建两个空的源文件erode_plugin.h和erode_pluigin.cpp。 稍后我们将在这两个文件中编写源代码。 现在,我们在终端中运行qmake -project,这将返回一个名为ErodePlugin.pro的 Qt 项目文件。 由于此项目是 Qt 插件项目,因此其项目文件具有许多不同的设置。 现在让我们看一下:
TEMPLATE = lib
TARGET = ErodePlugin
COPNFIG += plugin
INCLUDEPATH += . ../ImageEditor
在项目文件的开头,我们使用lib而不是app作为其TEMPLATE设置的值。 TARGET设置没有什么不同,我们只使用项目名称作为其值。 我们还添加了特殊行CONFIG += plugin来告诉qmake该项目是 Qt 插件项目。 最后,在上一个代码块的最后一行中,我们将ImageEditor项目的根目录添加为该项目包含路径的一项,以便编译器可以找到接口头文件editor_plugin_interface.h, 在编译插件时已将其放在上一节的ImageEditor项目中。
在此插件中,我们还需要 OpenCV 来实现我们的编辑功能,因此,我们需要像在 Qt 插件项目的设置中一样,添加 OpenCV 库的信息-更准确地说是库路径,并包括库的路径。 在ImageEditor项目中:
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -l opencv_imgproc
}
unix: mac {
INCLUDEPATH += /path/to/opencv/include/opencv4
LIBS += -L/path/to/opencv/lib -lopencv_world
}
win32 {
INCLUDEPATH += c:/path/to/opencv/include/opencv4
LIBS += -lc:/path/to/opencv/lib/opencv_world
}
在项目文件的末尾,我们将头文件和 C++ 源文件添加到项目中:
HEADERS += erode_plugin.h
SOURCES += erode_plugin.cpp
现在,我们插件的项目文件已经完成,让我们开始编写我们的插件。 就像我们设计的那样,为新的编辑功能编写插件只是为了提供我们在上一节中抽象的EditorPluginInterface接口的实现。 因此,我们在erode_plugin.h中声明了该接口的子类:
#include <QObject>
#include <QtPlugin>
#include "editor_plugin_interface.h"
class ErodePlugin: public QObject, public EditorPluginInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID EDIT_PLUGIN_INTERFACE_IID);
Q_INTERFACES(EditorPluginInterface);
public:
QString name();
void edit(const cv::Mat &input, cv::Mat &output);
};
如您所见,在包含必要的头文件之后,我们声明一个名为ErodePlugin的类,该类继承自QObject和EditorPluginInterface。 后者是我们在上一节editor_plugin_interface.h中定义的接口。 在这里,我们将插件实现作为QOBject的子类,因为这是 Qt 元对象系统和插件机制的要求。 在类的主体中,我们使用 Qt 库定义的一些宏添加更多信息:
Q_OBJECT
Q_PLUGIN_METADATA(IID EDIT_PLUGIN_INTERFACE_IID);
Q_INTERFACES(EditorPluginInterface);
在上一章中,我们介绍了Q_OBJECT宏; 它与 Qt 元对象系统有关。 Q_PLUGIN_METADATA(IID EDIT_PLUGIN_INTERFACE_IID)行声明了此插件的元数据,在这里我们声明了在editor_plugin_interface.h中定义为其IID元数据的插件接口的唯一标识符。 然后,我们使用Q_INTERFACES(EditorPluginInterface)行告诉 Qt 此类正在尝试实现的是EditorPluginInterface接口。 有了前面的信息,Qt 插件系统就知道了有关该项目的所有信息:
- 这是一个 Qt 插件项目,因此该项目的目标将是一个库文件。
- 该插件是
EditorPluginInterface的实例,其IID是EDIT_PLUGIN_INTERFACE_IID,因此 Qt 应用可以告诉它并加载此插件。
现在,我们可以专注于如何实现接口。 首先,我们在接口中声明两个纯粹的重要函数:
public:
QString name();
void edit(const cv::Mat &input, cv::Mat &output);
然后,我们在erode_plugin.cpp文件中实现它们。 对于name函数,这很简单-我们只需返回QString,Erode作为插件的名称(以及编辑操作的名称)即可:
QString ErodePlugin::name()
{
return "Erode";
}
对于edit函数,我们如下实现:
void ErodePlugin::edit(const cv::Mat &input, cv::Mat &output)
{
erode(input, output, cv::Mat());
}
这也很简单-我们只调用 OpenCV 库提供的erode函数。 该函数的作用称为图像腐蚀。 它是数学形态学领域中的两个基本运算符之一。 侵蚀是缩小图像前景或 1 值对象的过程。 它可以平滑对象边界并去除半岛,手指和小物体。 在下一部分中将插件加载到应用中后,我们将看到此效果。
好。 我们插件项目的大部分工作都已完成,因此让我们对其进行编译。 编译方式与普通 Qt 应用的编译方式相同:
$ qmake -makefile
$ make
g++ -c -pipe -O2 ...
# output trucated
ln -s libErodePlugin.so.1.0.0 libErodePlugin.so
ln -s libErodePlugin.so.1.0.0 libErodePlugin.so.1
ln -s libErodePlugin.so.1.0.0 libErodePlugin.so.1.0
$ ls -l *.so*
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 libErodePlugin.so -> libErodePlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 libErodePlugin.so.1 -> libErodePlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 libErodePlugin.so.1.0 -> libErodePlugin.so.1.0.0
-rwxr-xr-x 1 kdr2 kdr2 78576 Dec 12 16:24 libErodePlugin.so.1.0.0
$
首先,我们运行qmake -makefile生成Makefile,然后通过执行make命令来编译源代码。 编译过程完成后,我们将使用ls -l *.so*检查输出文件,并找到许多共享对象文件。 这些是我们将加载到应用中的插件文件。
检查输出文件时,您可能会发现许多扩展名为1.0.0的文件。 这些字符串告诉我们有关库文件的版本号。 这些文件大多数是一个真实库文件的别名(以符号链接的形式)。 在下一部分中加载插件时,将复制真实库文件的副本,但不包含其版本号。
如果使用的平台不同于 GNU/Linux,则输出文件也可能会有所不同:在 Windows 上,文件将被命名为ErodePlugin.dll,在 MacOS 上,文件将被命名为libErodePlugin.dylib。
将插件加载到我们的应用中
在前面的部分中,我们为应用的编辑功能抽象了一个接口,然后实现了一个插件,该插件通过将 OpenCV 库中的erode函数应用于打开的图像来满足该接口。 在本节中,我们会将插件加载到我们的应用中,以便我们可以使用它来侵蚀我们的图像。 之后,我们将查看一个名为Erode的新动作,该动作可以在编辑菜单下和编辑工具栏上找到。 如果我们通过单击来触发动作,我们将看到Erode在图像上的作用。
因此,让我们加载插件! 首先,我们修改ImageEditor项目的项目文件,并将包含插件接口的头文件添加到HEADERS设置的列表中:
HEADERS += mainwindow.h editor_plugin_interface.h
然后,将此文件包含在我们的mainwindow.cpp源文件中。 我们还将使用另一个名为QMap的数据结构来保存将要加载的所有插件的列表,因此我们也包含QMap的头文件:
#include <QMap>
#include "editor_plugin_interface.h"
然后,在MainWindow类的声明主体中,声明两个成员函数:
void loadPlugins():用于加载出现在某个目录中的所有插件。void pluginPerform():这是一个公共插槽,它将连接到已加载插件创建的所有操作。 在此插槽中,我们应区分触发了哪个动作,导致该插槽被调用,然后我们找到与该动作相关的插件并执行其编辑操作。
添加这两个成员函数后,我们添加QMap类型的成员字段以注册所有已加载的插件:
QMap<QString, EditorPluginInterface*> editPlugins;
该映射的键将是插件的名称,而值将是指向已加载插件实例的指针。
头文件中的所有工作都已完成,因此让我们实现loadPlugins函数来加载我们的插件。 首先,我们应该在mainwindow.cpp中包含必要的头文件:
#include <QPluginLoader>
然后,我们将提供loadPlugins成员函数的实现,如下所示:
void MainWindow::loadPlugins()
{
QDir pluginsDir(QApplication::instance()->applicationDirPath() + "/plugins");
QStringList nameFilters;
nameFilters << "*.so" << "*.dylib" << "*.dll";
QFileInfoList plugins = pluginsDir.entryInfoList(
nameFilters, QDir::NoDotAndDotDot | QDir::Files, QDir::Name);
foreach(QFileInfo plugin, plugins) {
QPluginLoader pluginLoader(plugin.absoluteFilePath(), this);
EditorPluginInterface *plugin_ptr = dynamic_cast<EditorPluginInterface*>(pluginLoader.instance());
if(plugin_ptr) {
QAction *action = new QAction(plugin_ptr->name());
editMenu->addAction(action);
editToolBar->addAction(action);
editPlugins[plugin_ptr->name()] = plugin_ptr;
connect(action, SIGNAL(triggered(bool)), this, SLOT(pluginPerform()));
// pluginLoader.unload();
} else {
qDebug() << "bad plugin: " << plugin.absoluteFilePath();
}
}
}
我们假设可执行文件所在的目录中有一个名为plugins的子目录。 只需调用QApplication::instance()->applicationDirPath()即可获取包含可执行文件的目录,然后将/plugins字符串附加到其末尾以生成插件目录。 如上一节所述,我们的插件是库文件,它们的名称以.so,.dylib或.dll结尾,具体取决于所使用的操作系统。 然后,我们在plugins目录中列出所有具有这些扩展名的文件。
在将所有可能的插件文件列出为QFileInfoList之后,我们遍历该列表以尝试使用foreach加载每个插件。 foreach是 Qt 定义的宏,并实现了for循环。 在循环内部,每个文件都是QFileInfo的一个实例。 我们通过调用abstractFilePath方法获得其绝对路径,然后在该路径上构造QPluginLoader的实例。
然后,我们有许多关键步骤需要解决。 首先,我们在QPluginLoader实例上调用instance方法。 如果已加载目标插件,则将返回指向QObject的指针,否则将返回0。 然后,我们将返回指针转换为指向我们的插件接口类型即EditorPluginInterface*的指针。 如果该指针非零,则将是插件的实例! 然后,我们创建一个QAction,其名称为已加载插件的名称,即plugin_ptr->name()的结果。 你还记得是什么吗? 这是ErodePlugin中的name函数,我们在其中返回Erode字符串:
QString ErodePlugin::name()
{
return "Erode";
}
现在已经创建了Erode操作,通过使用该操作调用它们的addAction方法,我们将其添加到编辑菜单和编辑工具栏。 然后,我们在editPlugins映射中注册已加载的插件:
editPlugins[plugin_ptr->name()] = plugin_ptr;
稍后,我们将使用此映射在插件创建的所有动作的公共位置中按其名称查找插件。
最后,我们将使用操作连接一个插槽:
connect(action, SIGNAL(triggered(bool)), this, SLOT(pluginPerform()));
您可能很好奇,这一行代码处于循环中,并且我们将所有操作的触发信号连接到同一插槽; 这个可以吗? 是的,我们有一种方法可以区分插槽中触发了哪个操作,然后我们可以根据该操作执行操作。 让我们看看这是如何完成的。 在pluginPerform插槽的实现中,我们检查是否有打开的图像:
if (currentImage == nullptr) {
QMessageBox::information(this, "Information", "No image to edit.");
return;
}
然后,我们找到它刚刚触发的动作,以便它通过调用 Qt 库提供的sender()函数来发送信号并调用插槽。 sender()函数返回一个指向QObject实例的指针。 在这里,我们知道我们仅将QAction的实例连接到此插槽,因此我们可以使用qobject_cast将返回的指针安全地强制转换为QAction的指针。 现在,我们知道触发了哪个动作。 然后,我们获得动作的文本。 在我们的应用中,操作的文本是创建该操作的插件的名称。 通过使用此文本,我们可以从我们的注册映射中找到某个插件。 这是我们的操作方式:
QAction *active_action = qobject_cast<QAction*>(sender());
EditorPluginInterface *plugin_ptr = editPlugins[active_action->text()];
if(!plugin_ptr) {
QMessageBox::information(this, "Information", "No plugin is found.");
return;
}
得到插件指针后,我们检查它是否存在。 如果没有,我们只是向用户显示一个消息框,然后从 slot 函数返回。
至此,我们有了用户已通过其操作触发的插件,因此现在我们来看一下编辑操作。 这段代码与blurImage插槽函数中的代码非常相似。 首先,我们以QPixmap的形式获取开始图像,然后依次将其转换为QImage和Mat。 一旦它成为Mat的实例,我们就可以对其应用插件的edit函数,即plugin_ptr->edit(mat, mat);。 完成编辑操作后,我们将编辑后的Mat分别转换回QImage和QPixmap,然后在图形场景中显示QPixmap并更新状态栏上的信息:
QPixmap pixmap = currentImage->pixmap();
QImage image = pixmap.toImage();
image = image.convertToFormat(QImage::Format_RGB888);
Mat mat = Mat(
image.height(),
image.width(),
CV_8UC3,
image.bits(),
image.bytesPerLine());
plugin_ptr->edit(mat, mat);
QImage image_edited(
mat.data,
mat.cols,
mat.rows,
mat.step,
QImage::Format_RGB888);
pixmap = QPixmap::fromImage(image_edited);
imageScene->clear();
imageView->resetMatrix();
currentImage = imageScene->addPixmap(pixmap);
imageScene->update();
imageView->setSceneRect(pixmap.rect());
QString status = QString("(editted image), %1x%2")
.arg(pixmap.width()).arg(pixmap.height());
mainStatusLabel->setText(status);
已经添加了两个新函数,所以我们要做的最后一件事是在MainWindow类的构造器中调用loadPlugins函数,方法是在MainWindow::MainWindow(QWidget *parent)的末尾添加以下行:
loadPlugins();
现在,我们已经从可执行文件所在目录的plugins子目录中加载并设置了插件,现在让我们编译应用并对其进行测试。
首先,在终端中,将目录更改为ImageEditor项目的根目录,然后发出qmake -makefile和make命令。 等待这些命令完成。 然后,通过运行./ImageEditor命令启动我们的应用; 您将看到以下输出:

在运行应用之前,请不要忘记在 Linux 或 MacOS 上将LD_LIBRARY_PATH或DYLD_LIBRARY_PATH环境变量设置为 OpenCV 的lib目录。
哦,什么都没改变-我们在编辑菜单或编辑工具栏上找不到Erode操作。 这是因为我们没有将Erode插件文件复制到plugins目录中。 让我们现在开始:
$ ls
ImageEditor ImageEditor.pro plugins ...
$ ls -l ../ErodePlugin/*.so*
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 ../ErodePlugin/libErodePlugin.so -> libErodePlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 ../ErodePlugin/libErodePlugin.so.1 -> libErodePlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 23 Dec 12 16:24 ../ErodePlugin/libErodePlugin.so.1.0 -> libErodePlugin.so.1.0.0
-rwxr-xr-x 1 kdr2 kdr2 78576 Dec 12 16:24 ../ErodePlugin/libErodePlugin.so.1.0.0
$ cp ../ErodePlugin/libErodePlugin.so.1.0.0 plugins/libErodePlugin.so
$ ls plugins/
libErodePlugin.so
$
如果您使用的是 macOS,则在编译项目后,将找到一个名为ImageEditor.app的目录,而不是ImageEditor可执行文件。 这是因为在 MacOS 上,每个应用都是一个以.app作为扩展名的目录。 真正的可执行文件位于ImageEditor.app/Contents/MacOS/ImageEdtior,因此,在 MacOS 上,我们的插件目录为ImageEditor.app/Contents/MacOS/plugins。 您应该创建该目录并在其中复制插件文件。
让我们再次运行我们的应用:

现在,我们可以在“编辑”菜单和“编辑”工具栏上看到“腐蚀”动作。 让我们打开一个图像以查看erode的功能。
这是在执行任何操作之前由应用打开的图像:

单击“侵蚀”操作后,将获得以下输出:

如您所见,单击“腐蚀”操作后,图像的暗部被放大,白色对象缩小。 这是因为 OpenCV 将图像的深色部分视为背景,并且侵蚀了图像中的对象(浅色部分)。
我们已经使用 Qt 库提供的插件机制成功添加了新的编辑功能。 本节的重点是介绍该插件机制,而不是图像编辑功能,因此我们仅使用erode函数来实现编辑功能,以简化图像编辑。 现在已经介绍了插件机制,我们可以继续使用 OpenCV 库和使用该库的图像编辑功能。
像专业人士一样编辑图像
在上一节中,我们研究了如何为应用添加图像编辑功能作为插件。 这样,我们就不需要照顾用户界面,打开和关闭图像以及热键。 相反,我们必须添加一个新的编辑功能,即编写EditorPluginInterface接口的子类并实现其纯虚拟函数,然后将其编译为插件文件(共享库文件)并将其复制到我们应用的插件目录。 在本节中,我们将讨论使用 OpenCV 进行图像编辑。
首先,让我们从锐化图像开始。
锐化图像
图像锐化是由许多著名的图像编辑软件(例如 GIMP 和 Photoshop)实现的常见功能。 锐化图像的原理是我们从原始版本中减去图像的平滑版本,以得到这两个版本之间的差异,然后将该差异添加到原始图像中。 我们可以通过对图像的副本应用高斯平滑过滤器来获得平滑版本。 稍后我们将看到如何使用 OpenCV 进行此操作,但是第一步是创建一个新的 Qt 插件项目。
由于我们在上一节中创建了一个名为ErodePlugin的 Qt 插件项目,因此创建类似的其他项目并不难。
首先,我们在终端中创建目录和必要的文件:
$ ls
ErodePlugin ImageEditor
$ mkdir SharpenPlugin
$ ls
ErodePlugin ImageEditor SharpenPlugin
$ cd SharpenPlugin
$ touch sharpen_plugin.h sharpen_plugin.cpp
$ qmake -project
$ ls
sharpen_plugin.h sharpen_plugin.cpp SharpenPlugin.pro
然后,我们编辑SharpenPlugin.pro项目文件并设置其配置:
TEMPLATE = lib
TARGET = SharpenPlugin
COPNFIG += plugin
INCLUDEPATH += . ../ImageEditor
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -l opencv_imgproc
}
unix: mac {
INCLUDEPATH += /path/to/opencv/include/opencv4
LIBS += -L/path/to/opencv/lib -lopencv_world
}
win32 {
INCLUDEPATH += c:/path/to/opencv/include/opencv4
LIBS += -lc:/path/to/opencv/lib/opencv_world
}
HEADERS += sharpen_plugin.h
SOURCES += sharpen_plugin.cpp
该项目文件的大部分内容与ErodePlugin插件项目的项目文件相同,除了TARGET,HEADERS和SOURCES的设置。 这三个设置的更改就其键和值而言很容易且不言自明。
现在,让我们看一下源文件。 第一个是头文件sharpen_plugin.h:
#include <QObject>
#include <QtPlugin>
#include "editor_plugin_interface.h"
class SharpenPlugin: public QObject, public EditorPluginInterface
{
Q_OBJECT
Q_PLUGIN_METADATA(IID EDIT_PLUGIN_INTERFACE_IID);
Q_INTERFACES(EditorPluginInterface);
public:
QString name();
void edit(const cv::Mat &input, cv::Mat &output);
};
该文件与我们在 ErodePlugin 项目中编写的erode_plugin.h头文件相同,只不过我们在此处使用了不同的类名SharpenPlugin。 我们使该类成为QObject和EditorPluginInterface的后代。 在该类的主体中,我们使用多个 Qt 宏向 Qt 库的元对象和插件系统提供必要的信息,然后声明必须实现的两个方法才能满足EditorPluginInterface接口。
我们完成了项目文件和头文件。 如您所见,它们的大多数内容与我们在 ErodePlugin 项目中的内容相同,除了一些名称更改,包括项目名称,目标名称和文件名。
现在,该看看sharpen_plugin.cpp中方法的实现了。 毫不奇怪,对其所做的唯一更改就是名称的更改以及方法主体的更改。 首先让我们看一下name方法:
QString SharpenPlugin::name()
{
return "Sharpen";
}
在这里,我们在第一行中将类名称更改为SharpenPlugin,然后返回Sharpen字符串作为其名称和标签。 那很简单。 现在,让我们继续进行edit方法:
void SharpenPlugin::edit(const cv::Mat &input, cv::Mat &output)
{
int intensity = 2;
cv::Mat smoothed;
GaussianBlur(input, smoothed, cv::Size(9, 9), 0);
output = input + (input - smoothed) * intensity;
}
虽然仅在第一行中更改了类名,但我们在此方法的主体中进行了很多更改以进行锐化工作。 首先,我们定义两个变量。 intensity变量是一个整数,它将指示我们将锐化图像的强度,而smoothed是cv::Mat的实例,将用于保存图像的平滑版本。 然后,我们调用GaussianBlur函数对作为cv::Mat实例传递到我们的方法的图像进行平滑处理,并将平滑后的版本存储在smoothed变量中。
在图像处理中,高斯模糊是一种被广泛采用的算法,尤其是当您要减少图像的噪点或细节时。 它以出色的数学家和科学家卡尔·弗里德里希·高斯(Carl Friedrich Gauss)的名字命名,因为它使用高斯函数来模糊图像。 有时也称为高斯平滑。
您可以在这个页面中找到有关此算法的更多信息。 在 OpenCV 中,我们使用GaussianBlur函数来实现此效果。 与大多数 OpenCV 函数一样,此函数接受许多参数。 第一个和第二个是输入和输出图像。 第三个参数是cv::Size对象,代表核的大小。 第四个是double类型的变量,它表示 X 方向上的高斯核标准差。 它还有两个带有默认值的额外参数。 我们在代码中使用其默认值以使该方法易于理解,但是您可以在这个页面上参考GaussianBlur函数的文档,了解更多信息。
在获得原始图像的平滑版本之后,可以通过从原始版本中减去平滑版本input - smoothed来找到原始版本和平滑版本之间的良好区别。 此表达式中的减法运算在 OpenCV 中称为按元素矩阵运算。 逐元素矩阵运算是计算机视觉中的数学函数和算法,可对矩阵的各个元素(即图像的像素)起作用。 重要的是要注意,可以逐个元素并行化操作,这从根本上意味着矩阵元素的处理顺序并不重要。 通过执行此减法,我们得到了区别-它也是cv::Mat实例,因此如果您要查看它,可以在应用中显示它。 由于这种区别很小,因此即使显示出来,您也会看到黑色图像,尽管它不是完全黑色的-其中有一些无块像素。 为了锐化原始图像,我们可以通过使用附加的逐元素运算将这个区分矩阵叠加到原始图像上一次或多次。 在我们的代码中,次数是我们定义的intensity变量。 首先,我们将intensity标量乘以区分矩阵(这也是标量和矩阵之间的元素操作),然后将结果添加到原始图像矩阵中:
input + (input - smoothed) * intensity
最后,我们将结果矩阵分配给输出变量cv::Mat的引用,以out参数的方式返回锐化的图像。
所有代码已准备就绪,因此让我们在终端中编译我们的插件:
$ qmake -makefile
$ make
g++ -c -pipe -O2 ...
# output truncated
$ ls -l *so*
lrwxrwxrwx 1 kdr2 kdr2 25 Dec 20 11:24 libSharpenPlugin.so -> libSharpenPlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 25 Dec 20 11:24 libSharpenPlugin.so.1 -> libSharpenPlugin.so.1.0.0
lrwxrwxrwx 1 kdr2 kdr2 25 Dec 20 11:24 libSharpenPlugin.so.1.0 -> libSharpenPlugin.so.1.0.0
-rwxr-xr-x 1 kdr2 kdr2 78880 Dec 20 11:24 libSharpenPlugin.so.1.0.0
$ cp libSharpenPlugin.so.1.0.0 ../ImageEditor/plugins/libSharpenPlugin.so
$
编译好插件并将其复制到ImageEditor应用的插件目录之后,我们可以运行该应用以测试我们的新插件:
$ cd ../ImageEditor/
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ./ImageEditor
如果一切顺利,您将在“编辑”菜单和“编辑”工具栏下看到“锐化”操作。 让我们看看打开图像后的样子:

现在,让我们通过单击新插件提供的“锐化”操作来锐化图像后,看看图像是什么样子:

我们可以看到它们之间明显的区别。 请随意使用intensity变量和GaussianBlur函数的参数来获得自己喜欢的结果。
卡通效果
在上一节中,我们添加了新的编辑功能,以便可以在应用中锐化图像。 在本节中,我们将添加一个新的编辑功能,以便为图像创建有趣的卡通效果。 为了获得这种卡通效果,我们需要做两件事:首先,我们需要使图像具有卡通外观,因此我们需要找到一种减少其调色板的方法。 然后,我们将检测图像中的边缘并与它们一起产生粗体轮廓。 之后,我们将合并这些步骤的结果图像,然后获得实现了卡通效果的新图像。
幸运的是,所有这些都可以通过使用 OpenCV 库来完成。 因此,让我们开始新的插件项目,我们将其称为CartoonPlugin。 创建插件项目的步骤和项目的目录结构与我们之前所做的非常相似,因此,为了使本章简洁明了,在此我们不会向您展示如何逐步创建项目。
要创建项目,我们将创建一个名为CartoonPlugin的新目录,然后在该目录中创建项目文件和源文件。 该目录应如下所示:
$ ls
cartoon_plugin.cpp cartoon_plugin.h CartoonPlugin.pro
$
您可以从我们以前的插件项目之一复制项目文件,然后将TARGET,HEADERS和SOURCES设置的值更改为此项目的正确值。 由于源文件的内容也与先前项目中的内容非常相似,因此您可以将任何已完成的插件项目中的源文件用作模板来简化开发过程-只需复制文件,更改文件名, 其中的插件类名称,以及name和eidt方法的实现。
在此项目中,我们使用CartoonPlugin作为插件类名称,并在CartoonPlugin::name方法中使用return "Cartoon";作为插件类名称。 现在,我们要做的就是实现CartoonPlugin::edit方法。 现在让我们继续进行这一关键部分。
第一项任务是减少调色板。 为此,我们可以使用 OpenCV 库提供的双边过滤器。 尽管双边过滤器效果很好,并通过平滑平坦区域并保持锐利边缘为普通的 RGB 图像提供了卡通外观,但是它比其他平滑算法(例如,我们之前使用的高斯模糊算法)慢得多。 但是,在我们的应用中,速度很重要-为了使代码易于理解,我们不会创建单独的辅助线程来进行编辑工作。 如果编辑过程太慢,它将冻结我们应用的用户界面-也就是说,在编辑时,我们的应用将不是交互式的,用户界面也不会被更新。
幸运的是,我们有两种方法可以加快这一过程,从而缩短冻结时间:
- 缩小原始图像,然后将过滤器应用于该缩小的版本。
- 代替一次对图像应用大的双边过滤器,我们可以多次应用小双边的过滤器。
让我们看看如何做到这一点:
int num_down = 2;
int num_bilateral = 7;
cv::Mat copy1, copy2;
copy1 = input.clone();
for(int i = 0; i < num_down; i++) {
cv::pyrDown(copy1, copy2);
copy1 = copy2.clone();
}
for(int i = 0; i < num_bilateral; i++) {
cv::bilateralFilter(copy1, copy2, 9, 9, 7);
copy1 = copy2.clone();
}
for(int i = 0; i < num_down; i++) {
cv::pyrUp(copy1, copy2);
copy1 = copy2.clone();
}
首先,我们定义两个Mat类对象copy1和copy2,然后将input的副本分配给copy1。
然后,我们使用cv::pyrDown重复缩小copy1的大小(两次通过int num_down = 2;)。 在此循环中,我们对两个定义的矩阵copy1和copy2进行操作。 由于cv::pyrDown函数不支持原地操作,因此对于输出,我们必须使用与输入矩阵不同的矩阵。 为了实现重复操作,我们应在每次操作后将所得矩阵的copy2克隆为copy1。
缩小操作后,我们在copy1中获得了原始图像的降采样版本。 现在,就像缩小过程一样,我们反复对copy1应用一个小的双边过滤器(通过int num_bilateral = 7;进行七次)。 此函数也不支持原地,因此我们将copy1用作其输入图像,并将copy2用作其输出图像。 我们传递给cv::bilateralFilter函数的最后三个参数指定像素邻域的直径,其值为9,色彩空间中的过滤器σ,其值也为9,以及坐标中的过滤器σ空间,其值分别为7。 您可以参考这里了解如何在过滤器中使用这些值。
缩小调色板后,我们应该将向下采样的图像放大到其原始大小。 这是通过在copy1上调用cv::pyrUp的次数与在其上调用cv::pyrDown相同的次数来完成的。
因为在缩小时将结果图像的大小计算为Size((input.cols + 1) / 2, (input.rows + 1) / 2),而在放大时将结果图像的大小计算为Size(input.cols * 2, (input.rows * 2),所以copy1矩阵的大小可能与原始图像不同。 它可能等于或大于原始像素几个像素。 在此,如果copy1在尺寸上与原始图片不同,则应将copy1调整为原始图片的尺寸:
if (input.cols != copy1.cols || input.rows != copy1.rows) {
cv::Rect rect(0, 0, input.cols, input.rows);
copy1(rect).copyTo(copy2);
copy1 = copy2;
}
至此,我们得到了原始图像的副本,该副本的调色板减小且尺寸不变。 现在,让我们继续前进,检测边缘并生成一些大胆的轮廓。 OpenCV 提供了许多检测边缘的函数。 在这里,我们选择cv::adaptiveThreshold函数并以cv::THRESH_BINARY作为其阈值类型进行调用以执行边缘检测。 在自适应阈值算法中,不是使用全局值作为阈值,而是使用动态阈值,该阈值由当前像素周围较小区域中的像素确定。 这样,我们可以检测每个小区域中最显着的特征,并据此计算阈值。 这些函数正是我们应该在图像中的对象周围绘制粗体和黑色轮廓的地方。 同时,自适应算法也有其弱点-容易受到噪声的影响。 因此,最好在检测边缘之前对图像应用中值过滤器,因为中值过滤器会将每个像素的值设置为周围所有像素的中值,这样可以减少噪声。 让我们看看如何做到这一点:
cv::Mat image_gray, image_edge;
cv::cvtColor(input, image_gray, cv::COLOR_RGB2GRAY);
cv::medianBlur(image_gray, image_gray, 5);
cv::adaptiveThreshold(image_gray, image_gray, 255,
cv::ADAPTIVE_THRESH_MEAN_C, cv::THRESH_BINARY, 9, 2);
cv::cvtColor(image_gray, image_edge, cv::COLOR_GRAY2RGB);
首先,我们通过调用cvtColor函数将输入图像转换为灰度图像,然后将cv::COLOR_RGB2GRAY作为颜色空间转换代码作为其第三个参数。 此函数也不能原地工作,因此我们使用另一个与输入矩阵不同的矩阵image_gray作为输出矩阵。 此后,我们在image_gray矩阵中获得原始图像的灰度版本。 然后,我们调用cv::medianBlur将中值过滤器应用于灰度图像。 如您所见,在此函数调用中,我们将image_gray矩阵用作其输入和输出矩阵。 这是因为此函数支持原地操作。 它可以原地处理输入矩阵的数据; 也就是说,它从输入读取数据,进行计算,然后将结果写入输入矩阵,而不会干扰图像。
应用中值过滤器后,我们在灰度图像上调用cv::adaptiveThreshold以检测图像中的边缘。 我们在灰度图像上进行此操作,因此,在执行此操作后,灰度图像将变为仅包含边缘的二进制图像。 然后,我们将二进制边缘转换为 RGB 图像,并通过调用cvtColor将其存储在image_edge矩阵中。
现在,调色板已缩小并且边缘图像已准备就绪,让我们通过按位and操作合并它们并将其分配给output矩阵以返回它们:
output = copy1 & image_edge;
至此,所有开发工作已经完成。 现在,该编译并测试我们的插件了:
$ make
g++ -c -pipe -O2 -Wall ...
# output truncated
$ cp libCartoonPlugin.so.1.0.0 ../ImageEditor/plugins/libCartoonPlugin.so
$ ls ../ImageEditor/plugins/
libCartoonPlugin.so libErodePlugin.so libSharpenPlugin.so
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ../ImageEditor/ImageEditor
启动我们的应用并使用它打开图像后,我们得到以下输出:

让我们单击卡通动作,看看会发生什么:

这还不错,您可以随意使用所有过滤器函数的参数来自己调整卡通效果。
在本节中,我们使用了 OpenCV 提供的许多过滤器函数。 在调用这些函数时,我指出了medianBlur函数支持原地操作,而bilateralFilter函数则不支持。 这是什么意思,我们如何知道某个函数是否支持原地操作?
如前所述,如果一个函数支持原地操作,则意味着该函数可以从输入图像读取,进行计算,然后将结果写入矩阵,该矩阵可以是我们用作输入的矩阵或与输入矩阵不同的矩阵。 当我们使用一个矩阵作为其输入和输出时,该函数仍然可以正常工作,并将结果放入输入矩阵中而不会破坏数据。 如果某个函数不支持原地运算,则必须使用与输入矩阵不同的矩阵作为其输出,否则数据可能会损坏。 实际上,在 OpenCV 的实现中,它会断言以确保在不支持原地操作的函数中,输入和输出不是同一矩阵,或者是共享同一数据缓冲区的不同矩阵。 如果某个函数支持原地操作,则可以使用它来提高程序的性能,因为这种方式可以节省内存。 由于 OpenCV 有充分的文档说明,因此可以参考文档以了解函数是否支持原地操作。 让我们看一下我们刚刚使用的medianBlur函数的文档:

在前面的屏幕快照中,我突出显示了该函数支持原地操作的行。 一些(但不是全部)不支持原地操作的函数也有一条声明明确指出。 例如bilateralFilter()函数,我们在本节中也使用了该函数:

值得注意的是,如果文档中说某个函数支持原地操作,那么它将支持。 如果文档没有说明某个函数是否支持原地操作,则最好假定它不支持原地操作。
旋转图像
在前面的部分中,我们已将许多编辑功能作为插件添加,所有这些功能都利用了 OpenCV 提供的图像过滤器。 从本节开始,我们将添加一些利用 OpenCV 库的转换函数的功能。
根据 OpenCV 库的文档,OpenCV 中有两个图像转换类别:
- 几何变换
- 杂项变换(除几何变换外的所有变换)
在本节和下一部分中,我们将研究几何变换。 我们可以从它们的名称猜测得出,几何变换主要处理图像的几何属性,例如图像的大小,方向和形状。 它们不更改图像的内容,而是根据几何变换的性质,通过在周围移动图像的像素来更改图像的形式和形状。
让我们首先从简单的几何变换开始-旋转图像。 使用 OpenCV 旋转图像有多种方法。 例如,我们可以在矩阵上应用转置和翻转的复合操作,也可以使用适当的变换矩阵进行仿射变换。 在本节中,我们将使用后一种方法。
现在是时候开始一个新的动手项目来开发旋转插件了。 我们可以通过使用以前的插件项目作为模板来做到这一点。 以下是此过程的重点列表:
- 使用
RotatePlugin作为项目名称。 - 创建项目文件和源文件(
.h文件和.cpp文件)。 - 更改项目文件中的相关设置。
- 使用
RotatePlugin作为插件类名称。 - 在
name方法中返回Rotate作为插件名称。 - 更改
edit方法的实现。
除了最后一步,每个步骤都非常简单明了。 因此,让我们跳过前五个步骤,直接进入最后一步-这是我们在此插件中实现edit方法的方式:
void RotatePlugin::edit(const cv::Mat &input, cv::Mat &output)
{
double angle = 45.0;
double scale = 1.0;
cv::Point2f center = cv::Point(input.cols/2, input.rows/2);
cv::Mat rotateMatrix = cv::getRotationMatrix2D(center, angle, scale);
cv::Mat result;
cv::warpAffine(input, result,
rotateMatrix, input.size(),
cv::INTER_LINEAR, cv::BORDER_CONSTANT);
output = result;
}
如前所述,我们使用仿射变换来进行旋转,这是通过调用 OpenCV 库提供的cv::warpAffine函数来实现的。 此函数不支持原地操作,因此我们将定义一个新的临时矩阵result来存储输出。
当我们在ImageEditor应用中调用每个插件的edit方法时,我们使用一个矩阵作为输入和输出参数,即plugin_ptr->edit(mat, mat);,因此,在插件的edit方法的实现中,参数input和output实际上是相同的矩阵。 这意味着我们不能将它们传递给不支持原地操作的函数。
warpAffine函数将称为转换矩阵的矩阵作为其第三个参数。 该变换矩阵包含描述仿射变换应如何完成的数据。 手工编写此转换矩阵有点复杂,因此 OpenCV 提供了生成该转换矩阵的函数。 为了生成旋转的变换矩阵,我们可以使用cv::getRotationMatrix2D函数,为其指定一个点作为轴点,一个角度和一个缩放比例。
在我们的代码中,我们将输入图像的中心点用作旋转的轴点,并使用正数 45 表示旋转将逆时针旋转 45 度这一事实。 由于我们只想旋转图像,因此我们使用 1.0 作为缩放比例。 准备好这些参数后,我们通过调用cv::getRotationMatrix2D函数获得rotateMatrix,然后将其传递给第三位置的cv::warpAffine。
cv::warpAffine的第四个参数是输出图像的大小。 我们在这里使用输入图像的大小来确保图像的大小在编辑过程中不会改变。 第五个参数是插值方法,因此在这里我们只使用cv::INTER_LINEAR。 第六个参数是输出图像边界的像素外推方法。 我们在这里使用cv::BORDER_CONSTANT,以便在旋转后,如果某些区域未被原始图像覆盖,则将用恒定的颜色填充它们。 我们可以将此颜色指定为第七个参数,否则默认使用黑色。
既然代码已经清晰了,让我们编译和测试插件:
$ make
g++ -c -pipe -O2 -Wall ...
# output truncated
$ cp libRotatePlugin.so.1.0.0 ../ImageEditor/plugins/libRotatePlugin.so
$ ls ../ImageEditor/plugins/
libCartoonPlugin.so libErodePlugin.so libRotatePlugin.so libSharpenPlugin.so
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ../ImageEditor/ImageEditor
打开图像后,我们应该获得以下输出:

让我们单击“旋转”操作,看看会发生什么:

如我们所见,图像正如我们预期的那样逆时针旋转 45 度。 随意更改中心点,角度和比例的值以查看会发生什么。
仿射变换
在上一节中,我们使用warpAffine成功旋转了图像。 在本节中,我们将尝试使用相同的函数执行仿射变换。
首先,我们将创建一个新的编辑插件项目,并使用AffinePlugin作为项目名称和插件类名称,然后使用Affine作为操作名称(即,我们将在name方法中返回此字符串) )。
这次,在edit方法中,我们将使用另一种方法来获取warpAffine函数的转换矩阵。 首先,我们准备两个三角形-一个用于输入图像,另一个用于输出图像。 在我们的代码中,我们使用下图中显示的三角形:

左边的一个用于输入,而右边的一个用于输出。 我们可以很容易地看到,在此变换中,图像的顶部将保持不变,而图像的底部将向右移动与图像宽度相同的距离。
在代码中,我们将使用三个Point2f类的数组表示每个三角形,然后将它们传递给getAffineTransform函数以获得转换矩阵。 一旦获得了转换矩阵,就可以调用warpAffine函数,就像在RotatePlugin项目中所做的那样。 这是我们在代码中执行此操作的方式:
void AffinePlugin::edit(const cv::Mat &input, cv::Mat &output)
{
cv::Point2f triangleA[3];
cv::Point2f triangleB[3];
triangleA[0] = cv::Point2f(0 , 0);
triangleA[1] = cv::Point2f(1 , 0);
triangleA[2] = cv::Point2f(0 , 1);
triangleB[0] = cv::Point2f(0, 0);
triangleB[1] = cv::Point2f(1, 0);
triangleB[2] = cv::Point2f(1, 1);
cv::Mat affineMatrix = cv::getAffineTransform(triangleA, triangleB);
cv::Mat result;
cv::warpAffine(
input, result,
affineMatrix, input.size(), // output image size, same as input
cv::INTER_CUBIC, // Interpolation method
cv::BORDER_CONSTANT // Extrapolation method
//BORDER_WRAP // Extrapolation method
);
output = result;
}
现在我们已经完成了开发,让我们编译项目,复制插件,然后运行ImageEditor应用:
$ make
g++ -c -pipe -O2 -Wall ...
# output truncated
$ cp libAffinePlugin.so.1.0.0 ../ImageEditor/plugins/libAffinePlugin.so
$ ls ../ImageEditor/plugins/
libAffinePlugin.so libCartoonPlugin.so libErodePlugin.so
libRotatePlugin.so libSharpenPlugin.so
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib/
$ ../ImageEditor/ImageEditor
这是打开图像后我们的应用的外观:

这是我们在触发仿射操作后获得的效果:

万岁! 图像以与上图中所示相同的方式进行转换。
您可能会注意到,在此代码中,我们也使用BORDER_CONSTANT作为边框类型,因此,在图像倾斜移动后,其左下角将被恒定的颜色填充,默认情况下为黑色。 除了用恒定的颜色填充边界外,还有许多其他方法可以对边界进行插值。 以下列表显示了 OpenCV 文档中的所有方法:
BORDER_CONSTANT:以指定的i插值为iiiiii|abcdefgh|iiiiiiiBORDER_REPLICATE:内插为aaaaaa|abcdefgh|hhhhhhhBORDER_REFLECT:内插为fedcba|abcdefgh|hgfedcbBORDER_WRAP:内插为cdefgh|abcdefgh|abcdefgBORDER_REFLECT_101:内插为gfedcb|abcdefgh|gfedcbaBORDER_TRANSPARENT:内插为uvwxyz|abcdefgh|ijklmnoBORDER_REFLECT101:与BORDER_REFLECT_101相同BORDER_DEFAULT:与BORDER_REFLECT_101相同BORDER_ISOLATED:不要在 ROI 之外看
在此列表的解释性条款中,|abcdefgh|表示原始图像,并且其周围的字母表示将如何进行插值。 例如,如果我们使用BORDER_WRAP值,则插值将为cdefgh|abcdefgh|abcdefg; 也就是说,它将使用图像的右侧填充左侧边框,并使用图像的左侧填充右侧边框。 作为一种特殊情况,BORDER_TRANSPARENT保持目标矩阵中的相应像素不变,并且不使用输入图像中的颜色。
如果我们在AffinePlugin插件中使用BORDER_WRAP,则转换后的图像将如下所示:

这里没有展示所有边界插值类型的效果,因此,如果您有兴趣,请自己尝试。
在本节和上一节中,我们了解了如何使用仿射变换来变换图像。 除了这种变换图像的方法之外,还有许多方法可以进行几何变换。 所有这些方法都是 OpenCV 提供的,包括调整大小,透视图转换,重新映射以及许多其他转换,例如色彩空间转换。 这些几何变换是可查看的,您可以在这个页面中找到其文档。 在其他转换方面,我们在CartoonPlugin插件中使用了其中之一cv::adaptiveThreshold。 可以在这个页面上找到有关此类转换的完整文档。 您可以在我们的插件项目或您自己的插件中摆弄所有这些转换,以了解有关它们的更多信息。
总结
在本章中,我们重新制作了在第 1 章,“构建图像查看器”中构建的用于图像查看的桌面应用,以制作图像编辑器应用。 然后,我们添加了一个简单的编辑功能来模糊图像。 同时,我们了解了如何为 Qt 应用安装和设置 OpenCV,与 Qt 和 OpenCV 中的图像处理相关的数据结构,以及如何使用 OpenCV 处理图像。
之后,我们了解了 Qt 库的插件机制,并抽象出了一种以更灵活,更便捷的方式向我们的应用添加编辑功能的方法,即作为插件。 例如,我们编写了第一个插件来腐蚀图像。
然后,我们将注意力转移到 OpenCV 库上,讨论如何像专家一样编辑图像-我们制作了许多插件来编辑图像,锐化图像,制作卡通效果,旋转,执行仿射变换等。
在下一章中,我们将学习如何使用 OpenCV 和 Qt 处理视频,并且将在运动分析技术的帮助下在家中构建一个简单的安全应用。
问题
尝试这些问题,以测试您对本章的了解:
- 我们如何知道 OpenCV 函数是否支持原地操作?
- 我们如何为作为插件添加的每个动作添加一个热键?
- 我们如何添加一个新动作来丢弃应用中当前图像的所有更改?
- 我们如何使用 OpenCV 调整图像大小?
三、家庭安全应用
在第 2 章,“像专家一样编辑图像”,我们通过构建自己的图像编辑器应用,了解了 Qt 库的插件机制以及来自 OpenCV 库的许多图像过滤器和转换。 在本章中,我们将从处理图像转到处理视频。 我们将构建一个新的应用,通过该应用,我们可以使用 PC 的网络摄像头执行许多操作,例如播放从其实时捕获的视频,记录其视频提要中的部分视频,计算其每秒帧(FPS),通过对其视频馈送进行实时运动分析来检测运动,等等。
本章将涵盖以下主题:
- 设计和创建用户界面(UI)
- 处理相机和视频
- 录制影片
- 实时计算 FPS
- 运动分析和运动检测
- 在桌面应用中向手机发送通知
技术要求
正如我们在前几章中所看到的,您必须安装 Qt 版本 5(至少),并且具有 C++ 和 Qt 编程的基本知识。 另外,应正确安装最新版本的 OpenCV(4.0)。 除了核心和imgproc模块外,本章还将使用 OpenCV 的视频和videoio模块。 在前面的章节之后,必须已经满足这些要求。
在本章中,我们将向您展示如何处理摄像头,因此您需要一个网络摄像头,它既可以是内置的也可以是外部的,可以从计算机上访问。
本章还要求具备多线程的基本知识。
Gazer 应用
为了深入研究相机处理,视频处理和运动分析,我们将开发一个全新的应用。 除了学习这些主题之外,我们还将获得一个具有许多实用功能的应用:能够通过网络摄像头录制视频,监控我们的家庭安全,并在检测到可疑动作时通过移动设备通知我们。 让我们阐明其功能,如下所示:
- 打开网络摄像头并实时播放从中捕获的视频
- 通过单击开始/停止按钮从网络摄像头录制视频
- 显示已保存视频的列表
- 检测到动作,保存视频并在检测到可疑动作时向我们的手机发送通知
- 显示有关摄像机和应用状态的一些信息
在澄清了这些功能之后,我们可以设计 UI。 再次,我们将使用在第 1 章,“构建图像查看器”,Pencil 中使用的开源 GUI 原型工具绘制应用原型的线框,如下图所示:

如上图所示,我们将整个窗口分为五个部分:菜单栏,将要播放视频的主要区域,操作按钮所在的操作区域,其中包含已保存视频缩略图的水平列表。 将被放置,以及状态栏。
您可以从 GitHub 上的代码库中找到此设计的源文件。 该文件位于存储库的根目录中,称为WireFrames.epgz。 不要忘记应该使用 Pencil 应用将其打开。 线框位于此文件的“第 2 页”上。
启动项目并设置 UI
好的,我们现在知道了应用的外观,所以让我们袖手旁观并使用 Qt 设置 UI!
命名项目和Gazer应用。 现在,让我们在终端中创建项目:
$ mkdir Gazer/
$ cd Gazer/
$ touch main.cpp
$ ls
main.cpp
$ qmake -project
$ ls
Gazer.pro main.cpp
$
接下来,让我们编辑Gazer.pro项目文件。 首先,我们需要从将使用的 Qt 库中设置应用信息和模块:
TEMPLATE = app
TARGET = Gazer
INCLUDEPATH += .
QT += core gui multimedia
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
现在,我们对qmake和 Qt 项目的项目文件非常熟悉,因此,我无需在此处逐行解释这段代码。 我们应该注意的唯一一点是,我们包括 Qt 库的多媒体模块,稍后我们将使用它。
然后,我们将设置 OpenCV 库的配置:
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_video -lopencv_videoio
}
unix: mac {
INCLUDEPATH += /path/to/opencv/include/opencv4
LIBS += -L/path/to/opencv/lib -lopencv_world
}
win32 {
INCLUDEPATH += c:/path/to/opencv/include/opencv4
LIBS += -lc:/path/to/opencv/lib/opencv_world
}
我们将 OpenCV 库的video和videoio模块附加到LIBS键的值的末尾,因为我们将使用这些模块来处理项目中的视频。 您应该注意的另一点是,您应该将这段代码中的路径更改为实际的 OpenCV 安装路径。
最后,让我们设置标题和源:
HEADERS += mainwindow.h
SOURCES += main.cpp mainwindow.cpp
在此处设置三个源文件(包括头文件)时,现在只有一个空的main.cpp文件。 不用担心,在之前的项目中我们已经做过很多此类工作,所以让我们进行一些复制,粘贴和更改:
- 将
main.cpp文件从我们之前的任何项目复制到我们的Gazer项目中,例如,第 1 章,“构建图像查看器”中的ImageViewer项目。 内容不变。 - 从先前的项目之一将
mainwindown.h文件复制到我们的Gazer项目,打开文件,然后删除类正文中除Q_OBJECT宏的行,构造器和析构器之外的所有行。 更改后,类主体应如下所示:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent=nullptr);
~MainWindow();
}
- 将
mainwindow.cpp源文件创建为一个空文件,并向其添加构造器和析构器的实现:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent)
{
}
MainWindow::~MainWindow()
{
}
您现在可以编译并运行我们的应用,但是运行时将看到一个空白窗口。
要按照我们的设计设置完整的 UI,我们应该在空白窗口中添加几个 Qt 小部件。 首先,我们将在MainWindow类的主体的私有部分中为该菜单声明一个QMenu方法和三个QAction方法:
private:
QMenu *fileMenu;
QAction *cameraInfoAction;
QAction *openCameraAction;
QAction *exitAction;
接下来是我们将显示视频的主要区域。 将使用 OpenCV 库逐帧捕获视频,并且每一帧都是图像本身。 要播放视频,我们可以在捕获到特定区域后立即显示该框架。 因此,我们仍然使用QGraphicsSence和QGraphicsView逐帧显示帧,以达到播放视频的效果:
QGraphicsScene *imageScene;
QGraphicsView *imageView;
然后,在操作区域上有按钮,已保存视频的水平列表和状态栏:
QCheckBox *monitorCheckBox;
QPushButton *recordButton;
QListView *saved_list;
QStatusBar *mainStatusBar;
QLabel *mainStatusLabel;
我们声明的第一行中的复选框将用于告诉我们安全监视器的状态是否已打开。 如果选中,我们的应用将执行运动检测并在发生某些情况时发送通知; 否则,该应用将只能用作摄像机播放器。 该按钮将用于开始或停止录制视频。
在头文件中,我们只是声明了这些小部件,但是如我们在原型线框中所设计的那样,为了将这些小部件布置在正确的位置,我们应该采用 Qt 布局系统。 借助 Qt 布局系统,可以自动排列父窗口小部件的子窗口小部件,以便所有可用空间将被子窗口小部件正确使用。 布局系统还将照顾所有窗口小部件的排列,并确保在托管窗口小部件的父窗口小部件的大小或位置发生更改时,或者在托管窗口小部件本身的大小或位置发生变化时对其进行管理。
对于此布局系统,Qt 提供了许多类,它们都是从QLayout类派生的。 让我们看一些例子:
QHBoxLayout类在水平行中从左到右排列窗口小部件。QVBoxLayout类在垂直列中按从上到下的顺序排列小部件。QGridLayout类安排可占据二维网格中多个单元的窗口小部件。QFormLayout类在两列网格中排列小部件,每行有两个小部件排列在描述性标签字段中。
对于我们的应用Gazer的设计,我们可以使用QGridLayout类,该类具有多行和仅一列。 根据我的粗略估计,这三个部分(主要区域,操作区域和保存的视频列表)的高度比例约为 12:1:4。 因此,我们可以创建一个17 x 1的QGridLayout类来安排我们设计和声明的小部件。
借助介绍的布局系统知识,让我们设置我们设计的完整 UI。 首先,我们在MainWindow类的主体中声明两个私有方法initUI和createActions:
private:
void initUI();
void createActions();
然后,我们转到mainwindow.cpp源文件以实现它们。 让我们先来看void MainWindow::initUI()。 在此方法中,我们将应用的主窗口设置为适当的大小,并在开头创建文件菜单:
this->resize(1000, 800);
// setup menubar
fileMenu = menuBar()->addMenu("&File");
然后,我们设置窗口的中心区域:
QGridLayout *main_layout = new QGridLayout();
imageScene = new QGraphicsScene(this);
imageView = new QGraphicsView(imageScene);
main_layout->addWidget(imageView, 0, 0, 12, 1);
在这段代码中,我们将创建一个QGridLayout类的新实例,其大小将为17 x 1,这是我们之前计划的。 然后,我们创建QGraphicsSence和QGraphicsView的实例,这些实例将用于显示图像并播放视频。 最后一行对我们来说很新,它显示了如何向布局添加小部件。 我们用五个参数调用QGridLayout实例的addWidget方法:第一个是要添加到布局中的小部件,接下来的四个数字描述一个矩形(开始行,开始列, 它跨越的行数,以及跨越的列数)所添加的小部件将占据的行数。 在我们的代码中,QGraphicsView将占据网格布局的前 12 行。
以下视频播放区域是操作区域。 在此区域中,我们有两个小部件,一个复选框和一个按钮。 因此,我们需要一种新的布局来安排它们。 我们还将在此处选择QGridLayout进行排列。 这意味着我们将在主网格布局中嵌套另一个网格布局:
QGridLayout *tools_layout = new QGridLayout();
main_layout->addLayout(tools_layout, 12, 0, 1, 1);
monitorCheckBox = new QCheckBox(this);
monitorCheckBox->setText("Monitor On/Off");
tools_layout->addWidget(monitorCheckBox, 0, 0);
recordButton = new QPushButton(this);
recordButton->setText("Record");
tools_layout->addWidget(recordButton, 0, 1, Qt::AlignHCenter);
tools_layout->addWidget(new QLabel(this), 0, 2);
在前面的代码的前两行中,我们创建一个名为tools_layout的新网格布局,然后将其添加到主网格布局中。 以12, 0, 1, 1作为其位置矩形,此子布局仅占据主网格布局中的一行,即第 13 行。 子布局放置到位后,让我们创建子窗口小部件并将其添加到其中。这些窗口小部件应水平排列在一行中,因此布局的大小将为1xN。 如前所述,我们有两个小部件将放置在操作区域中,但是按照我们的设计,我们希望将最重要的小部件(记录按钮)在水平方向上居中对齐。 为此,我们在tools_layout后面添加一个占位符,即空白的QLable方法。 现在,我们在布局中有三个小部件。 记录按钮是第二个按钮,即中间的按钮。
在前面的代码中,很明显,我们创建了小部件,设置了它们的文本,然后将它们添加到布局中。 值得注意的是,当我们调用网格布局对象的addWidget方法时,我们仅使用三个参数,而不是使用五个参数,就像在主布局对象上调用它一样。 这是因为在此布局中,任何小部件都没有行跨度或列跨度-仅提供行索引和列索引就足以为该小部件定位单个单元格。 另外,当我们添加按钮时,我们使用额外的对齐参数Qt::AlignHCenter,以确保该按钮不仅位于中间单元格中,而且位于该单元格的中央。
在操作区域下方是已保存视频的列表。 Qt 提供了一个名为QListView的小部件,我们可以在此处直接使用它,因此我们只需创建对象并将其添加到主布局即可:
// list of saved videos
saved_list = new QListView(this);
main_layout->addWidget(saved_list, 13, 0, 4, 1);
还记得 12:1:4 的比例吗? 在这里,我们使列表小部件在主网格布局中占据四行,从第 14 行开始。
到现在为止,主布局中的所有小部件都处于其位置。 现在是时候
将主布局添加到我们的主窗口了。 在这里,我们不能直接调用this->setLayout(main_layout);,因为主窗口具有自己的方式来管理其内容。 您可能还记得在前几章中完成的项目中,您会意识到我们在主窗口中调用了setCentralWidget来设置这些项目中的内容。 在这里,我们可以创建一个新的小部件,该小部件将以主网格布局作为其布局,然后将该小部件设置为主窗口的中央小部件:
QWidget *widget = new QWidget();
widget->setLayout(main_layout);
setCentralWidget(widget);
接下来需要查看的是状态栏和操作:
// setup status bar
mainStatusBar = statusBar();
mainStatusLabel = new QLabel(mainStatusBar);
mainStatusBar->addPermanentWidget(mainStatusLabel);
mainStatusLabel->setText("Gazer is Ready");
createActions();
如您在前面的代码中看到的,除了状态栏之外,在initUI方法的末尾,我们调用MainWindow::createActions在“文件”菜单中创建操作。 createActions方法的实现很简单-在其中创建QActions的实例,然后将它们添加到“文件”菜单中。 我没有在这里逐行解释代码,因为我们在以前的项目中已经做了很多次这样的事情。 对于退出操作,我们将应用的quit插槽连接到其triggered信号; 对于其他操作,我们目前没有空位,但在以下各节中将提供一个空位。
现在,我们在MainWindow类的构造器中调用initUI方法。 最后,我们具有完整的 UI 设置,因此让我们编译并运行该应用以查看其外观:

如您所见,借助 Qt 提供的布局系统,我们可以完美地实现设计好的 UI。 如果要了解有关此功能强大的布局系统的更多信息,可以在这个页面上参考其文档。
存取相机
在上一节中,我们设置了应用的 UI。 在本节中,我们将播放由个人计算机的摄像头提供的视频提要。
在访问摄像机之前,我们应该了解有关它的一些信息-如果我们使用 OpenCV,则需要我们要从中捕获视频的摄像机的索引。 如果使用 Qt,则需要它的设备名称。 对于典型的笔记本电脑,它通常具有一个默认的内置网络摄像头,其索引为0,但其名称取决于平台或环境。 如果我们有一台计算机的多个网络摄像头,通常它们的索引和名称都取决于平台或环境。 要确定此信息,我们可以从 Qt 库中转到QCameraInfo类。
使用 Qt 列出相机
通过使用 Qt 库中的QCameraInfo类,我们可以轻松地获得当前计算机上的可用摄像机。 它有一个名为availableCameras的静态方法,该方法返回QCameraInfo对象的列表。
现在,我们将为cameraInfoAction添加一个插槽以完成此工作。 首先,我们在mainwindow.h文件的MainWindow类的主体中声明一个专用插槽:
private slots:
void showCameraInfo();
然后,我们给出其实现,如下所示:
void MainWindow::showCameraInfo()
{
QList<QCameraInfo> cameras = QCameraInfo::availableCameras();
QString info = QString("Available Cameras: \n");
foreach (const QCameraInfo &cameraInfo, cameras) {
info += " - " + cameraInfo.deviceName() + ": ";
info += cameraInfo.description() + "\n";
}
QMessageBox::information(this, "Cameras", info);
}
在这段代码中,我们获得了照相机信息列表,使用列表中的所有照相机构建了一个人类可读的字符串,并在提示消息框中显示了它。
最后,我们以MainWindow::createActions方法将此插槽连接到cameraInfoAction的triggered信号:
connect(cameraInfoAction, SIGNAL(triggered(bool)), this, SLOT(showCameraInfo()));
好的,让我们编译并运行Gazer应用。 现在,单击“文件”菜单下的“相机信息”项,然后查看提供的信息:

我的笔记本电脑有一个内置的网络摄像头和一个连接到 USB 端口的外部网络摄像头,因此该应用在计算机上运行时会显示两个摄像头:/dev/video0和/dev/video2。 我在笔记本电脑上使用 GNU/Linux,在此平台上,设备名称采用/dev/video<N>的模式,其中<N>是摄像机的索引。 如果您使用的是其他操作系统,则看到的信息可能与我的不同。
在这种情况下,“可用摄像头”短语中的“可用”一词表示已在计算机上正确连接并驱动了摄像头,并且该摄像头必须不忙,即未被任何应用使用。 如果任何应用正在使用相机,则该相机将不会包含在QCameraInfo::availableCameras方法的返回列表中。
捕捉和播放
我们已经在上一节中获得了网络摄像头的信息,因此让我们使用 OpenCV 捕获并播放来自选定网络摄像头的视频提要。
使用 OpenCV 捕获视频非常容易。 以下是一个示例:
#include <iostream>
#include "opencv2/opencv.hpp"
using namespace std;
using namespace cv;
int main() {
VideoCapture cap(0);
if(!cap.isOpened()) {
return -1;
}
while(1) {
Mat frame;
cap >> frame;
if (frame.empty())
break;
imshow( "Frame", frame );
char c = (char)waitKey(25);
if(c==27) // ESC
break;
}
cap.release();
destroyAllWindows();
return 0;
}
在前面的代码中,首先,我们使用默认摄像头的索引创建VideoCapture的实例,然后测试摄像头是否成功打开。 如果打开,则进入无限循环。 在循环中,我们从VideoCapture实例读取图像到Mat实例。 随着循环的进行,将从网络摄像头读取连续的图像并将它们组成视频。 在视频处理方面,这些连续图像中的每一个通常称为帧。 这就是为什么我们在前面的代码中使用名称frame的原因。 读取一帧后,我们检查它是否为空。 如果为true,则打破无限循环; 否则,我们通过调用imshow函数来显示它。 然后,我们等待长达 25 毫秒的按键。 如果在等待期间按下Esc键,我们将中断循环。 否则,无限循环将继续下去。 循环结束后,我们释放分配的资源,例如释放相机,破坏用于显示图像的窗口等。
如您所见,使用 OpenCV 捕获视频非常简单。 但是,当我们开始将此功能集成到实际的 GUI 应用中时,事情会变得有些复杂。 您还记得我们在第 2 章,“像高手一样编辑图像”的“卡通效果”部分中构建了生成图像卡通效果的功能吗? 为了实现该功能,我们采用了一些慢得多的算法。 在 GUI 线程中运行缓慢的任务将在任务运行期间冻结 UI。 为了避免应用过于复杂,在这种情况下,我们采用优化算法的方式来缩短任务的运行时间,从而缩短了 GUI 的冻结时间。 但是在当前捕获视频的情况下,只要用户打开相机,我们就必须一直保持捕获帧,因为我们无法在时间维度上对此进行优化。 如果我们在 GUI 线程中捕获视频,则 UI 将一直冻结。 因此,为了保持应用界面的响应性,我们必须在不同于 GUI 线程的另一个线程中捕获视频。
Qt 库提供了许多不同的技术来处理应用中的多线程。 QThread类是最直接和最基本的工具。 它简单,但功能强大且灵活。 在本节中,我们将主要使用此类将捕获任务分成一个新线程。
要在另一个线程中进行视频捕获,我们需要做的第一件事是定义一个从QThread类派生的新类。 我们将此类命名为CaptureThread,并在capture_thread.h文件中对其进行声明。
让我们看一下头文件。 该文件的开头和结尾分别是一次包含宏定义和包含指令的头文件:
#ifndef CAPTURE_THREAD_H
#define CAPTURE_THREAD_H
#include <QString>
#include <QThread>
#include <QMutex>
#include "opencv2/opencv.hpp"
// ... the class declaration goes here.
#endif // CAPTURE_THREAD_H
中间是类声明:
class CaptureThread : public QThread
{
Q_OBJECT
public:
CaptureThread(int camera, QMutex *lock);
CaptureThread(QString videoPath, QMutex *lock);
~CaptureThread();
void setRunning(bool run) {running = run; };
protected:
void run() override;
signals:
void frameCaptured(cv::Mat *data);
private:
bool running;
int cameraID;
QString videoPath;
QMutex *data_lock;
cv::Mat frame;
};
如前所述,该类是从QThread类派生的,并且在其主体的第一行中,我们使用Q_OBJECT宏告诉 Qt 库的元对象系统负责该类。
然后,在公共部分声明两个构造器和一个析构器。 第一个构造器接受整数(即目标网络摄像头的索引)和QMutex指针,该指针将用于在竞争条件下保护数据。 第二个构造器接受一个字符串,该字符串将被视为视频文件和QMutex指针的路径。 使用此构造器,我们可以使用视频文件来模拟网络摄像头。 还有一个称为setRunning的公共方法,该方法用于设置捕获线程的运行状态。
接下来是受保护的部分。 在本节中,我们声明一个名为run的方法。 override关键字指示此方法是一个虚拟方法,并且它正在覆盖与其基类的方法之一同名的方法。 QThread的run方法是线程的起点。 当我们调用线程的start方法时,将在创建新线程后调用其run方法。 稍后我们将以这种方法进行捕获工作。
然后,我们声明一个名称为frameCapture的信号,该信号将指向Mat对象的指针作为其唯一参数。 每次从网络摄像头捕获帧时,都会发出此信号。 如果您对此信号感兴趣,可以将一个插槽连接到它。
最后,在私有部分中,我们声明了许多成员字段:
running用于线程状态cameraID用于摄像机索引videoPath用于模拟网络摄像头的视频的路径data_lock用于在竞争条件下保护数据frame用于存储当前捕获的帧
就类声明而言就是这样。 现在,让我们继续进行capture_thread.cpp文件中的方法实现。 首先是构造器和析构器。 它们都很简单,仅提供有关诸如字段初始化之类的信息:
CaptureThread::CaptureThread(int camera, QMutex *lock):
running(false), cameraID(camera), videoPath(""), data_lock(lock)
{
}
CaptureThread::CaptureThread(QString videoPath, QMutex *lock):
running(false), cameraID(-1), videoPath(videoPath), data_lock(lock)
{
}
CaptureThread::~CaptureThread() {
}
接下来是最重要的部分-run方法的实现:
void CaptureThread::run() {
running = true;
cv::VideoCapture cap(cameraID);
cv::Mat tmp_frame;
while(running) {
cap >> tmp_frame;
if (tmp_frame.empty()) {
break;
}
cvtColor(tmp_frame, tmp_frame, cv::COLOR_BGR2RGB);
data_lock->lock();
frame = tmp_frame;
data_lock->unlock();
emit frameCaptured(&frame);
}
cap.release();
running = false;
}
创建线程后立即调用此方法,当它返回时,线程的寿命将结束。 因此,我们在进入此方法时将运行状态设置为true,并在从该方法返回之前将运行状态设置为false。 然后,就像本节开始时给出的示例一样,我们使用相机索引创建VideoCapture类的实例,并创建Mat的实例以保存捕获的帧。 之后是无限循环。 在循环中,我们捕获一帧并检查它是否为空。 我们正在使用 OpenCV 捕获帧,因此捕获帧的颜色顺序是 BGR 而不是 RGB。 考虑到我们将使用 Qt 显示帧,我们应该将帧转换为以 RGB 为颜色顺序的新帧。 这就是对cvtColor函数的调用。
准备好捕获的帧后,将其分配给frame类成员,然后使用指向刚刚修改的frame成员字段的指针发出frameCapture信号。 如果您对此信号感兴趣,可以将一个插槽连接到它。 在连接的插槽中,将具有指向此frame成员的指针作为其参数。 换句话说,您可以在连接的插槽中自由读取或写入此frame对象。 考虑到连接的插槽将在与捕获线程完全不同的另一个线程中运行,frame成员很可能同时被两个不同的线程修改,并且此行为可能会破坏其中的数据。 为了防止这种情况的发生,我们使用QMutex来确保在任何时候都只有一个线程正在访问frame成员字段。 我们在这里使用的QMutex实例是QMutex *data_lock成员字段。 在将其分配给frame成员之前,请先调用其lock方法,并在分配后调用其unlock方法。
如果有人将running状态设置为false(通常在另一个线程中),则无限
循环将中断,然后我们进行一些清理工作,例如释放VideoCapture实例并确保运行标志设置为false。
至此,捕获线程的所有工作都已完成。 接下来,我们需要将其与主窗口集成。 因此,让我们开始吧。
首先,我们在mainwindow.h头文件的MainWindow类中添加一些私有成员字段:
cv::Mat currentFrame;
// for capture thread
QMutex *data_lock;
CaptureThread *capturer;
currentFrame成员用于存储捕获线程捕获的帧。 capturer是捕获线程的句柄,当用户打开摄像机时,我们将使用它来进行视频捕获。 QMutext对象data_lock用于在竞争条件下保护CaptureThread.frame的数据。 它将在 GUI 线程和捕获线程中使用。 然后,在MainWindow类的构造器中,我们在调用initUI方法之后初始化data_lock字段:
initUI();
data_lock = new QMutex();
接下来,让我们回到mainwindow.h头文件,并在类声明中添加另外两个私有插槽:
void openCamera();
void updateFrame(cv::Mat*);
openCamera插槽用于创建新的捕获线程,并在触发文件菜单中的“打开相机”操作时调用。 首先,我们将该插槽连接到createActions方法中“打开摄像机”动作的triggered信号:
connect(openCameraAction, SIGNAL(triggered(bool)), this, SLOT(openCamera()));
然后,我们转到openCamera插槽的实现:
int camID = 2;
capturer = new CaptureThread(camID, data_lock);
connect(capturer, &CaptureThread::frameCaptured, this, &MainWindow::updateFrame);
capturer->start();
mainStatusLabel->setText(QString("Capturing Camera %1").arg(camID));
在前面的代码中,我们使用给定的摄像机索引和在MainWindow类的构造器中创建的QMutex对象创建CaptureThread类的新实例,然后将其分配给capturer成员字段 。
然后,我们将capturer的frameCaptured信号连接到主窗口的updateFrame插槽,以便在发出CaptureThread::frameCaptured信号时,将使用相同的参数调用MainWindow::updateFrame插槽(方法) 当信号发出时使用。
现在准备工作已经完成,我们可以通过调用CaptureThread实例的start方法(称为capturer)来启动捕获线程。 顺便说一句,我们通过在状态栏中显示一些文本来告诉用户某个摄像机已打开。
正如我已经提到的,我的笔记本电脑上有两个网络摄像头,而我正在使用第二个网络摄像头,其索引为2。 您应该根据自己的选择将camID变量的值更改为正确的摄像机索引。 在通常情况下,默认摄像头应使用值 0。
现在,捕获线程已启动,它将继续从相机捕获帧并发出frameCaptured信号。 让我们填充主窗口的updateFrame插槽以响应发出的信号:
void MainWindow::updateFrame(cv::Mat *mat)
{
data_lock->lock();
currentFrame = *mat;
data_lock->unlock();
QImage frame(
currentFrame.data,
currentFrame.cols,
currentFrame.rows,
currentFrame.step,
QImage::Format_RGB888);
QPixmap image = QPixmap::fromImage(frame);
imageScene->clear();
imageView->resetMatrix();
imageScene->addPixmap(image);
imageScene->update();
imageView->setSceneRect(image.rect());
}
如前所述,在此插槽中,我们有一个指向CaptureThread捕获的帧的指针作为参数。 在插槽主体中,我们将捕获的帧分配给主窗口类的currentFrame字段。 在此分配表达式中,我们从捕获的帧中读取内容,然后进行分配。 因此,为了避免损坏数据,我们使用data_lock互斥锁来确保在捕获线程向其frame字段写入数据时不会发生读取。
在获取捕获的帧之后,我们将其与图形场景一起显示,就像在第 2 章,“像高手一样编辑图像”那样构建的图像编辑器应用中所做的一样。
现在所有的点都连接在一起了-用户单击“打开摄像机”操作; 然后发出该动作的triggered信号; openCamera插槽被调用; 创建捕获线程,并开始从相机捕获帧; 随着帧被连续捕获,frameCaptured信号被连续发射。 然后为每个捕获的帧调用主窗口的updateFrame插槽; 这样一来,我们主窗口主区域中的图形视图将迅速地一幅接一幅显示捕获的连续帧,最终用户将看到正在播放的视频。
但是我们的代码中仍然存在一个小故障:如果用户多次单击“打开摄像机”操作,则会创建多个捕获线程,并且它们将同时运行。 这不是我们想要的情况。 因此,在启动新线程之前,我们必须检查是否已经在运行一个线程,如果存在,则应该在启动新线程之前将其停止。 为此,让我们在openCamera插槽的开头添加以下代码:
if(capturer != nullptr) {
// if a thread is already running, stop it
capturer->setRunning(false);
disconnect(capturer, &CaptureThread::frameCaptured, this, &MainWindow::updateFrame);
connect(capturer, &CaptureThread::finished, capturer, &CaptureThread::deleteLater);
}
在前面的代码中,我们将CaptureThread实例的运行状态(即capturer)设置为false,以破坏其无限循环(如果发现它不为null)。 然后,我们断开连接的信号和它的插槽,并将其自身的新插槽deleteLater连接到其finished信号。 在无限循环结束并返回run方法之后,线程将进入其生命周期的尽头,并且将发出其finished信号。 由于从finished信号到deleteLater插槽的连接,线程结束后将调用deleteLater插槽。 结果,当程序的控制流返回到 Qt 库的事件循环时,Qt 库将删除该线程实例。
现在,让我们更新Gazer.pro项目文件,以便可以将新的头文件和源文件添加到我们的应用中:
HEADERS += mainwindow.h capture_thread.h
SOURCES += main.cpp mainwindow.cpp capture_thread.cpp
然后,我们将需要编译并运行该应用:
$ qmake -makefile
$ make
g++ -c -pipe -O2 -Wall -W...
# output truncated
$ echo $LD_LIBRARY_PATH
/home/kdr2/programs/opencv/lib/
$ ./Gazer
# the application is running now.
应用启动后,单击“文件”菜单中的“打开相机”操作以从相机的角度查看视图。 以下是我的网络摄像头在办公室外的视图:

线程和实时视频处理的性能
在本节中,我们将在我们的应用中涉及多线程技术。 这是出于以下两个目的:
- 为了避免主线程(GUI 线程)被冻结
- 为了避免视频处理中潜在的性能下降
首先,如前所述,在 GUI 线程中运行缓慢的任务会在任务运行期间冻结 UI。 从摄像机捕获视频并对其进行处理是一个持续的过程; 它是无止境的,它将永久冻结 GUI,直到我们关闭相机。 因此,我们必须将主线程和视频捕获线程分开。
另一方面,视频处理工作,特别是实时视频处理,是一项占用大量 CPU 且对时间敏感的任务。 捕获帧,处理它并显示它-所有工作必须尽快完成。
其中一个关键点是我们用来处理每一帧的算法。 他们必须有足够的表现。 如果它们太慢,则在相机生成新帧的同时,程序仍在忙于处理先前捕获的帧,因此它没有机会读取新帧。 这将导致新帧丢失。
另一个关键点是,如果有多个线程正在共享帧的数据,并且同时使用锁来保持数据安全,则锁不得将线程阻塞得太久。 例如,在我们的应用的捕获线程中,假设我们使用以下锁:
while(running) {
data_lock->lock(); // notice here,
cap >> tmp_frame;
if (tmp_frame.empty()) {
data_lock->unlock(); // and here,
break;
}
cvtColor(tmp_frame, tmp_frame, cv::COLOR_BGR2RGB);
frame = tmp_frame;
data_lock->unlock(); // and here.
emit frameCaptured(&frame);
}
如果我们将更多行移到该锁保护的范围内,然后重新编译并运行该应用,您会感到帧滞后或帧丢失。 这是因为,通过此更改,UI 线程应在updateFrame插槽中等待很长时间。
捕捉和玩转 Qt
在上一节中,我们向您展示了如何使用 OpenCV 从网络摄像头捕获视频。 Qt 库还在其 Qt 多媒体模块中提供了许多用于播放多媒体的功能,其中包括一些使我们能够从网络摄像头捕获视频的功能。 在本节中,我们将尝试使用这些功能从网络摄像头捕获视频,而不是使用 OpenCV。
要使用 Qt 捕获视频,我们可以简单地使用带有QCameraViewfinder对象而不是QGraphicsSence和QGraphicsView对象的QCamera类实例。 让我们在mainwindow.h头文件中查看它们的声明:
#ifdef GAZER_USE_QT_CAMERA
QCamera *camera;
QCameraViewfinder *viewfinder;
#endif
如您所见,变量声明在我们的代码中被ifdef/endif块包围。 这样可以确保仅在编译应用时定义GAZER_USE_QT_CAMERA宏时,才会使用有关使用 Qt 捕获视频的代码。 否则,我们的应用仍会使用 OpenCV 捕获视频。
然后,在mainwindow.cpp文件中实现initUI方法的过程中,我们创建并配置我们刚刚声明的QCamera和QCameraViewfinder对象:
#ifdef GAZER_USE_QT_CAMERA
QList<QCameraInfo> cameras = QCameraInfo::availableCameras();
// I have two cameras and use the second one here
camera = new QCamera(cameras[1]);
viewfinder = new QCameraViewfinder(this);
QCameraViewfinderSettings settings;
// the size must be compatible with the camera
settings.setResolution(QSize(800, 600));
camera->setViewfinder(viewfinder);
camera->setViewfinderSettings(settings);
main_layout->addWidget(viewfinder, 0, 0, 12, 1);
#else
imageScene = new QGraphicsScene(this);
imageView = new QGraphicsView(imageScene);
main_layout->addWidget(imageView, 0, 0, 12, 1);
#endif
在前面的代码中,我们首先测试在编译时是否定义了GAZER_USE_QT_CAMERA宏。 如果已定义,我们将使用 Qt 从摄像机捕获视频—首先,我们获取所有可用摄像机的信息,然后选择其中一个以创建QCamera对象。
然后,我们创建QCameraViewfinder和QCameraViewfinderSettings。 该对象用于配置取景器对象。 在我们的代码中,我们使用它来设置取景器的分辨率。 此处的分辨率值必须与相机兼容。 我的相机是 Logitech C270,从其规格页面中,我们可以看到它支持320 x 240、640 x 480和800 x 600的分辨率。我在代码中使用800 x 600。 设置和取景器准备就绪后,我们通过调用setViewfinder和setViewfinderSettings方法将它们设置为相机对象。 然后,将取景器添加到主窗口的主网格布局中,并使其占据前 12 行。
如果未定义GAZER_USE_QT_CAMERA宏,则将使用#else分支中的代码,也就是说,我们仍将使用图形场景和图形视图来播放网络摄像头捕获的视频。
现在已经完成了小部件中的更改,我们将更改openCamera插槽:
#ifdef GAZER_USE_QT_CAMERA
void MainWindow::openCamera()
{
camera->setCaptureMode(QCamera::CaptureVideo);
camera->start();
}
#else
// The original implementation which uses QThread and OpenCV
#endif
如果定义了GAZER_USE_QT_CAMERA宏,则将定义使用 Qt 的openCamera的版本。 这个版本很简单-设置相机的拍摄模式,然后调用相机的start方法。 由于QCamera类将为我们处理线程,因此无需处理任何有关线程的明确内容。
最后,我们更新Gazer.pro项目文件,并在其中添加以下几行:
# Using OpenCV or QCamera
DEFINES += GAZER_USE_QT_CAMERA=1
QT += multimediawidgets
DEFINES += GAZER_USE_QT_CAMERA=1行将在编译时将GAZER_USE_QT_CAMERA宏定义为1,而下一行QT += multimediawidgets将在我们的项目中包含multimediawidgets Qt 模块。 项目文件更新后,我们可以编译并运行我们的应用。 对其进行编译,启动,然后单击“打开摄像机”操作-您将在我们应用的主要区域中看到视频。 以下是该应用在计算机上运行时的屏幕截图:

如您在前面的屏幕快照中所见,它几乎与我们使用 OpenCV 时具有相同的效果,除了QCameraViewfinder具有黑色背景。 如您所见,使用 Qt 捕获视频比使用 OpenCV 容易得多。 但是,我们仍将在项目中使用 OpenCV 而不是 Qt,因为我们应用的功能之一,即运动检测,超出了 Qt 库的范围。 Qt 主要是一个 GUI 库或框架,而 OpenCV 专门用于计算机视觉领域,包括图像和视频处理。 我们必须使用正确的工具在开发中做正确的事情,因此我们将利用这两个库来构建我们的应用。
在本章的其余部分,我们将刚添加到项目文件中的行注释掉,我们将继续使用 OpenCV 来处理视频处理工作:
# Using OpenCV or QCamera
# DEFINES += GAZER_USE_QT_CAMERA=1
# QT += multimediawidgets
编译应用时,如果未更改源文件,则仅更新项目文件,因此不会发生任何事情。 您应该运行make clean命令清理项目,然后运行make命令进行编译。
计算 FPS
在前面的部分中,我们学习了如何使用 OpenCV 的视频和videoio模块以及 Qt 提供的多媒体功能来捕获和播放视频。 如前所述,在本章的其余部分中,我们将使用 OpenCV 库而不是 Qt 库的多媒体模块来处理视频。 Qt 库将仅用于 UI。
保存从网络摄像头捕获的视频之前,让我们讨论视频和摄像机的重要指标 FPS,尽管有时将其称为帧频或每秒帧。 对于相机而言,其 FPS 表示我们在一秒钟内可以从其中捕获多少帧。 如果此数字太小,则用户将单独感知每个帧,而不是像运动一样感知连续的帧。 另一方面,如果此数字太大,则意味着在短时间内大量帧会泛滥,如果该程序的性能不足,则可能会使我们的视频处理器爆炸。 通常,对于电影或动画,FPS 为24。 这是一个公平的数字,适合人眼将帧感知为运动,并且对于常见的视频处理器也足够友好。
借助先前的 FPS,我们可以轻松计算给定摄像机的 FPS,从中读取一定数量的帧并测量该捕获过程所用的时间。 然后,可以通过将帧数除以使用的时间来计算 FPS。 这听起来不容易吗? 现在在我们的应用中执行此操作。
为了避免 UI 冻结,我们将在视频捕获线程中进行计算,并在计算完成后向信号通知主线程。 因此,我们打开capture_thread.h头文件,并向CaptureThread类添加一些字段和方法。 首先,我们将两个字段添加到私有部分:
// FPS calculating
bool fps_calculating;
float fps;
bool类型的fps_calculating字段用于指示捕获线程是否正在执行或应该执行 FPS 计算。 另一个名为fps的字段用于保存计算的 FPS。 我们在capture_thread.cpp源文件的构造器中将它们初始化为false和0.0:
fps_calculating = false;
fps = 0.0;
然后,我们添加一些方法:
startCalcFPS方法用于触发 FPS 计算。 当用户想要计算其摄像机的 FPS 时,将在 UI 线程中直接调用此方法。 在此方法中,我们只需将fps_calculating字段设置为true。 由于此方法是一种简单的内联方法,因此我们不需要在.cpp文件中提供实现。void fpsChanged(float fps)方法位于信号部分,因此它是一个信号。 完成 FPS 计算后,该信号将与 FPS 的计算值一起发射。 由于此方法是一种信号,因此moc将负责其实现。- 称为
void calculateFPS(cv::VideoCapture &cap)的私有方法,用于计算 FPS。
第三种方法calculateFPS是唯一需要在.cpp文件中实现的方法。 让我们在capture_thread.cpp文件中查看其方法主体:
void CaptureThread::calculateFPS(cv::VideoCapture &cap)
{
const int count_to_read = 100;
cv::Mat tmp_frame;
QTime timer;
timer.start();
for(int i = 0; i < count_to_read; i++) {
cap >> tmp_frame;
}
int elapsed_ms = timer.elapsed();
fps = count_to_read / (elapsed_ms / 1000.0);
fps_calculating = false;
emit fpsChanged(fps);
}
在机身上,我们决定从相机读取 100 帧,这是唯一的参数。 在开始读取之前,我们创建QTimer的实例,并启动它以定时读取过程。 当执行for循环时,读取过程完成,我们使用timer.elapsed()表达式获取经过的时间(以毫秒为单位)。 然后,通过将帧计数除以以秒为单位的经过时间来计算 FPS。 最后,我们将fps_calculating标志设置为false,并使用计算出的 FPS 发出fpsChanged信号。
当fps_calculating字段设置为true时,捕获线程的最后一件事是在run方法的无限循环中调用calculateFPS方法。 让我们将以下代码添加到该无限循环的末尾:
if(fps_calculating) {
calculateFPS(cap);
}
好的,捕获线程的工作已经完成,因此让我们转到 UI 线程提供一个动作,该动作将用于触发 FPS 计算并在计算完成后在主窗口的状态栏上显示计算出的 FPS。
在mainwindow.h头文件中,我们将添加一个新的QAction方法和两个插槽:
private slots:
// ....
void calculateFPS();
void updateFPS(float);
//...
private:
//...
QAction *calcFPSAction;
该操作将添加到文件菜单。 单击后,将调用新添加的calculateFPS插槽。 这是通过createActions方法中的以下代码完成的:
calcFPSAction = new QAction("&Calculate FPS", this);
fileMenu->addAction(calcFPSAction);
// ...
connect(calcFPSAction, SIGNAL(triggered(bool)), this, SLOT(calculateFPS()));
现在,让我们看一下触发动作时的calculateFPS插槽:
void MainWindow::calculateFPS()
{
if(capturer != nullptr) {
capturer->startCalcFPS();
}
}
这很简单-如果捕获线程对象不为null,则调用其startCalcFPS方法,以使其在run方法的无限循环中计算 FPS。 如果计算完成,将发出捕获线程对象的fpsChanged信号。 为了接收发射的信号,我们必须将其连接到插槽。 这是通过MainWindow::openCamera方法中的代码完成的,我们在其中创建了捕获线程。 创建捕获线程后,我们立即将信号连接到插槽:
if(capturer != nullptr) {
// ...
disconnect(capturer, &CaptureThread::fpsChanged, this, &MainWindow::updateFPS);
}
// ...
connect(capturer, &CaptureThread::fpsChanged, this, &MainWindow::updateFPS);
capturer->start();
// ...
如您所见,除了连接信号和插槽外,当我们停止捕获线程时,我们还断开了它们的连接。 连接的插槽也是本节中新添加的插槽。 让我们看一下它的实现:
void MainWindow::updateFPS(float fps)
{
mainStatusLabel->setText(QString("FPS of current camera is %1").arg(fps));
}
这很简单; 在这里,我们构造QString并将其设置在状态栏上。
所有工作都已完成,因此我们现在可以编译我们的应用并运行它来计算网络摄像头的 FPS。 这是我的外部网络摄像头 Logitech C270 的结果:

结果表明,当前相机的 FPS 为 29.80。 让我们在主页上进行检查:

供应商在该网页上说其 FPS 为 30。我们的结果非常接近。
在计算其帧速率之前,我们必须打开网络摄像头。 因此,在我们的应用中,请先单击“打开相机”操作,然后再单击“计算 FPS”操作。 另一点值得注意的是,在帧率计算过程中捕获的所有帧均被丢弃,因此我们可以看到在此期间在 UI 上冻结计算之前捕获的最后一帧。
以这种方式计算的 FPS 是理论上限-它是我们的硬件,而不是我们的应用(软件)。 如果您想获得我们应用的 FPS,则可以使用run方法计算循环中的帧数和时间,然后使用该数据计算出 FPS。
保存视频
在上一节中,我们学习了如何访问连接到计算机的摄像机,以及如何获取所有摄像机的信息,实时播放从摄像机捕获的视频以及如何计算摄像机的帧频。 在本节中,我们将学习如何从摄像机录制视频。
录制视频的原理很简单:当我们从摄像机捕获帧时,我们以某种方式压缩每个帧并将其写入视频文件。 OpenCV 库的videoio模块中的VideoWriter类提供了一种方便的方法,我们将在本节中使用它来记录视频。
在开始录制视频之前,我们应该为应用做一些准备工作,例如,将视频保存在何处以及如何命名每个视频文件。 为了解决这些先决条件,我们将在名为utilities.h的新头文件中创建名为Utilities的助手类:
class Utilities
{
public:
static QString getDataPath();
static QString newSavedVideoName();
static QString getSavedVideoPath(QString name, QString postfix);
};
由于我省略了ifndef/define习惯用语和#include指令的行,因此类声明非常清晰; 我们有三种静态方法:
QString getDataPath()方法返回我们将在其中保存视频文件的目录。QString newSavedVideoName()方法为将保存的视频生成一个新名称。QString getSavedVideoPath(QString name, QString postfix)方法接受名称和后缀(扩展名),并返回具有给定名称的视频文件的绝对路径。
让我们在utilities.cpp源文件中查看它们的实现。
在getDataPath方法中,我们使用 Qt 提供的QStandardPaths类来获取标准位置,该位置用于通过使用QStandardPaths::MoviesLocation调用QStandardPaths::standardLocations静态方法并拾取其中的第一个元素来保存视频和电影。 返回清单。 在我的笔记本电脑上,一个 Linux 机器上,此路径为/home/<USERNAME>/Videos/。 如果使用其他操作系统,则路径在 MacOS 上为/Users/<USERNAME>/Movies,在 Windows 上为C:\Users\<USERNAME>\Videos。 然后,我们在该视频目录中创建一个名为Gazer的子目录,并返回新目录的绝对路径。 这是代码:
QString Utilities::getDataPath()
{
QString user_movie_path = QStandardPaths::standardLocations(QStandardPaths::MoviesLocation)[0];
QDir movie_dir(user_movie_path);
movie_dir.mkpath("Gazer");
return movie_dir.absoluteFilePath("Gazer");
}
在newSavedVideoName方法中,我们使用调用该方法的日期和时间来生成新名称。 时间以yyyy-MM-dd+HH:mm:ss模式格式化,该模式包含从日期到秒的大多数日期和时间字段:
QString Utilities::newSavedVideoName()
{
QDateTime time = QDateTime::currentDateTime();
return time.toString("yyyy-MM-dd+HH:mm:ss");
}
在QString getSavedVideoPath(QString name, QString postfix)方法中,我们只是简单地返回一个新字符串,该字符串是通过将给定名称和后缀与一个圆点连接在一起,然后将连接的字符串和一个前斜杠附加到getDataPath返回的字符串上而构成的:
return QString("%1/%2.%3").arg(Utilities::getDataPath(), name, postfix);
好了,视频保存位置的准备工作已经完成,让我们继续进行CaptureThread类并开始视频保存工作。
首先,我们在CaptureThread类的public部分中添加一个枚举类型:
enum VideoSavingStatus {
STARTING,
STARTED,
STOPPING,
STOPPED
};
我们将在捕获线程中保存视频。 此枚举类型将用于指示该线程中视频保存工作的状态。 我将在稍后介绍该枚举的值。
然后,我们在CaptureThread类的私有部分中添加一些成员字段:
// video saving
int frame_width, frame_height;
VideoSavingStatus video_saving_status;
QString saved_video_name;
cv::VideoWriter *video_writer;
frame_width和frame_height变量在名称上非常不言自明,在创建视频编写器时将使用它们。 虽然video_saving_status字段是我们提到的视频保存状态的指示符,但是saved_video_name字段将保存正在保存的视频的名称。 最后一个cv::VideoWriter *video_writer是视频写入器,我们将在其中写入捕获的帧。 这将帮助我们将帧保存到目标视频文件。 这些成员应在构造器中初始化:
frame_width = frame_height = 0;
video_saving_status = STOPPED;
saved_video_name = "";
video_writer = nullptr;
接下来是新方法,信号和插槽的声明:
public:
// ...
void setVideoSavingStatus(VideoSavingStatus status) {video_saving_status = status; };
// ...
signals:
// ...
void videoSaved(QString name);
// ...
private:
// ...
void startSavingVideo(cv::Mat &firstFrame);
void stopSavingVideo();
setVideoSavingStatus内联方法用于设置视频保存状态。 一旦停止录制并且完全保存了视频文件,将以保存的视频文件的名称发出videoSaved信号。 由于它是头文件中定义的内联方法或信号方法,由 Qt 元对象系统处理,因此我们不需要在.cpp文件中为两种方法提供实现。 当视频保存工作即将开始或停止时,将调用startSavingVideo和stopSavingVideo方法。 让我们看看它们在capture_thread.cpp源文件中的实现:
void CaptureThread::startSavingVideo(cv::Mat &firstFrame)
{
saved_video_name = Utilities::newSavedVideoName();
QString cover = Utilities::getSavedVideoPath(saved_video_name, "jpg");
cv::imwrite(cover.toStdString(), firstFrame);
video_writer = new cv::VideoWriter(
Utilities::getSavedVideoPath(saved_video_name, "avi").toStdString(),
cv::VideoWriter::fourcc('M','J','P','G'),
fps? fps: 30,
cv::Size(frame_width,frame_height));
video_saving_status = STARTED;
}
如您所见,startSavingVideo方法接受对框架的引用作为其参数。 该帧是我们将保存在视频中的第一帧。 在方法主体中,首先,我们为视频生成一个新名称,然后获取具有该名称和jpg字符串作为后缀的路径。 显然,使用jpg作为扩展名,该路径用于图像而不是视频文件。 是的,我们首先通过调用imwrite函数将视频的第一帧保存到图像中,并且该图像将用作 UI 中保存的当前视频的封面。 保存封面图像后,我们将使用Utilities类生成的正确视频文件路径来创建VideoWriter类的实例。 除了文件路径,我们还需要几个参数来创建视频编写器:
- 一个 4 字符的编解码器,用于压缩帧。 在这里,我们使用
VideoWriter::fourcc('M','J','P','G')获得 motion-jpeg 编解码器,然后将其传递给编写器的构造器。 - 视频文件的帧频。 它应该与摄像机相同。 如果有相机,我们将使用相机的 FPS 计算; 否则,我们使用默认值
30,它来自相机的规格。 - 视频帧的大小。 稍后,我们将在
CaptureThread类的run方法中初始化用于构造size参数的变量。
创建视频编写器后,我们将video_saving_status设置为STARTED。
在执行stopSavingVideo方法的实现之前,我们应该转到CaptureThread类的run方法进行一些更新。 首先,在打开相机之后,进入无限循环之前,我们先获取视频帧的宽度和高度,并将其分配给相应的类成员:
frame_width = cap.get(cv::CAP_PROP_FRAME_WIDTH);
frame_height = cap.get(cv::CAP_PROP_FRAME_HEIGHT);
然后,在无限循环中,我们在捕获帧之后以及将捕获的帧转换为 RGB 颜色顺序图像之前添加以下代码:
if(video_saving_status == STARTING) {
startSavingVideo(tmp_frame);
}
if(video_saving_status == STARTED) {
video_writer->write(tmp_frame);
}
if(video_saving_status == STOPPING) {
stopSavingVideo();
}
在这段代码中,我们检查video_saving_status字段的值:
- 如果将其设置为
STARTING,我们将调用startSavingVideo方法。 在该方法中,我们将当前帧保存为封面图像,创建视频编写器,然后将video_saving_status设置为STARTED。 - 如果将其设置为
STARTED,我们将捕获的帧写入视频文件。 - 如果将其设置为
STOPPING,我们将调用stopSavingVideo方法进行一些清洁工作。
现在,让我们回到stopSavingVideo,看一下清洁工作:
void CaptureThread::stopSavingVideo()
{
video_saving_status = STOPPED;
video_writer->release();
delete video_writer;
video_writer = nullptr;
emit videoSaved(saved_video_name);
}
清理工作非常简单:我们将video_saving_status设置为STOPPED,释放和删除视频写入器,将视频写入器设置为null,然后发出VideoSaved信号。
到目前为止,我们已经完成了捕获线程中的所有视频保存工作。 现在,我们将其与 UI 集成。 因此,我们打开mainwindow.h文件并添加一些插槽和字段:
private slots:
// ...
void recordingStartStop();
void appendSavedVideo(QString name);
//...
private:
// ...
QStandardItemModel *list_model;
list_model字段用于为QListView对象saved_list提供数据。 QListView类旨在遵循模型/视图模式。 在这种模式下,将保存数据的模型从视图中分离出来,而视图则负责表示数据。 因此,我们需要一个模型来为其提供数据。 在MainWindow::initUI()方法的主体中,创建saved_list后,我们添加了一些代码来设置列表以显示保存的视频:
// list of saved videos
saved_list = new QListView(this);
saved_list->setViewMode(QListView::IconMode);
saved_list->setResizeMode(QListView::Adjust);
saved_list->setSpacing(5);
saved_list->setWrapping(false);
list_model = new QStandardItemModel(this);
saved_list->setModel(list_model);
main_layout->addWidget(saved_list, 13, 0, 4, 1);
我们将其查看模式设置为QListView::IconMode,以确保将使用大尺寸的LeftToRight流来布局其项目。 然后,我们将其调整大小模式设置为QListView::Adjust,以确保每次调整视图大小时都会布局其项目。 间距和包装的设置是为了确保项目之间有适当的间距,并且无论有多少项目,所有项目都将放置在一行中。 设置列表视图后,我们创建模型并将其设置为视图。
列表视图已设置完毕,让我们继续到插槽。 recordingStartStop插槽用于recordButton按钮。 它的实现如下:
void MainWindow::recordingStartStop() {
QString text = recordButton->text();
if(text == "Record" && capturer != nullptr) {
capturer->setVideoSavingStatus(CaptureThread::STARTING);
recordButton->setText("Stop Recording");
} else if(text == "Stop Recording" && capturer != nullptr) {
capturer->setVideoSavingStatus(CaptureThread::STOPPING);
recordButton->setText("Record");
}
}
我们检查recordButton按钮和捕获线程对象的文本。 如果文本为“记录”,且捕获线程不为空,则将捕获线程的视频保存状态设置为CaptureThread::STARTING以告知其开始录制,并将recordButton的文本设置为Stop Recording; 如果文本为Stop Recording且捕获线程不为空,则将捕获线程的视频保存状态设置为CaptureThread::STOPPING以告知其停止录制,并将recordButton的文本设置回Record 。 在给出此实现后,一旦在MainWindow::initUI方法中创建了按钮,我们就可以将此插槽连接到recordButton的clicked信号:
connect(recordButton, SIGNAL(clicked(bool)), this, SLOT(recordingStartStop()));
现在,通过单击“录制”按钮,我们可以开始或停止录制视频。 但是在主线程中,我们如何知道录制已完成? 是的,当视频文件完全保存时,我们会发出一个信号-CaptureThread::videoSaved信号。 新的MainWindow::appendSavedVideo插槽用于此信号。 让我们看一下该插槽的实现:
void MainWindow::appendSavedVideo(QString name)
{
QString cover = Utilities::getSavedVideoPath(name, "jpg");
QStandardItem *item = new QStandardItem();
list_model->appendRow(item);
QModelIndex index = list_model->indexFromItem(item);
list_model->setData(index, QPixmap(cover).scaledToHeight(145), Qt::DecorationRole);
list_model->setData(index, name, Qt::DisplayRole);
saved_list->scrollTo(index);
}
用视频名称调用该插槽,该视频名称在发出CaptureThread::videoSaved信号时显示。 在方法主体中,我们使用Utilities类为保存的视频生成封面图像的路径。 然后,我们创建一个新的QStandardItem对象,并将其附加到列表视图list_model的模型中。 QStandarditem项目是带有标准图标图像和字符串的项目。 对于我们的 UI 设计,其图标太小,因此我们将一个空项目用作占位符,然后在其位置将大图像设置为装饰数据。 为此,在添加了空项目之后,我们在模型中找到其索引,然后调用模型的setData方法来设置QPixmap对象,该对象由封面图像构造并按比例缩放至适当大小,位置由找到的Qt::DecorationRole角色索引所指定。 同样,我们将视频名称设置为Qt::DisplayRole角色在相同位置的显示数据。 最后,我们告诉列表视图滚动到新添加项目的索引。
MainWindow::appendSavedVideo插槽已完成,因此在创建线程之后,让我们使用MainWindow::openCamera方法将其连接到捕获线程的videoSaved信号:
connect(capturer, &CaptureThread::videoSaved, this, &MainWindow::appendSavedVideo);
使用相同的方法停止现有的捕获线程时,请不要忘记断开它们的连接:
disconnect(capturer, &CaptureThread::videoSaved, this, &MainWindow::appendSavedVideo);
好吧,除了一件事情外,几乎所有事情都完成了:当我们启动我们的应用时,我们的应用上次运行时可能保存了许多视频文件。 因此,我们需要填充这些文件,并在底部列表视图中显示它们。 我创建了一个名为MainWindow::populateSavedList的新方法来执行此操作,其实现没有新知识,如您从以下列表中可以看到的:
- 列出视频目录并找到所有封面文件,这些文件是我们在第 2 章,“像专家一样编辑图像”在加载插件时所做的工作
- 将每个封面图像追加到底部列表视图,这就是我们刚刚编写的
MainWindow::appendSavedVideo方法
我不会在这里粘贴并解释此方法的代码; 尝试自己实现。 如果需要帮助,请随时参考我们随附的 GitHub 存储库中的代码。
现在,有关代码的所有工作都已完成。 在编译我们的应用之前,我们需要更新我们的项目文件:
- 添加新的源文件。
- 将
opencv_imgcodecsOpenCV 模块添加到LIBS设置中,因为该模块提供了我们用来保存封面图像的imwrite函数。
以下代码是项目文件的更改后的行,如下所示:
# ...
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_video -lopencv_videoio
# ...
# Input
HEADERS += mainwindow.h capture_thread.h utilities.h
SOURCES += main.cpp mainwindow.cpp capture_thread.cpp utilities.cpp
最后,是时候编译并运行我们的应用了! 以下屏幕截图显示了录制多个视频文件后应用的外观:

为了使本章,项目简洁明了,我们未提供在应用中播放保存的视频的功能。 如果要播放它们,只需使用您喜欢的视频播放器。
OpenCV 运动分析
在前面的部分中,我们构建了一个完整的应用,用于使用相机播放和保存视频。 但是对于家庭安全应用来说,这还不够。 我们必须了解家里发生的事情时的情况。 这将通过使用 OpenCV 提供的运动检测功能来完成。
OpenCV 运动检测
通常,运动检测是通过分割图像中的背景和前景内容来完成的。 因此,在检测运动时,我们通常假定出现在摄像机中的给定场景的背景部分是静态的,并且不会在视频的连续帧中变化。 通过分析这些连续的帧,我们可以以某种方式提取该场景的背景,因此也可以提取前景。 如果在前景中发现了一些物体,我们可以假定检测到运动。
但是,这种假设在现实世界中并不总是正确的-太阳升起,落下,灯光开和关,阴影出现,移动和消失。 这些变化可能会改变背景,因此我们的算法取决于该假设。 因此,使用固定安装的摄像机和受控的照明条件始终是构建准确的背景/前景分割系统的先决条件。
为了简化应用的实现,我们还假设网络摄像头是固定的或安装在负责稳定房屋安全的照明条件稳定的地方。
在计算机视觉领域,术语背景/前景提取,背景减法和背景/前景分割指的是我们正在讨论的相同技术。 在本书中,我将互换使用它们。
在 OpenCV 中,提供了许多算法来进行背景分割。 它们中的大多数实现为视频模块中BackgroundSubtractor类的子类。 我们可以在这个页面中找到类层次结构。 该网页的以下屏幕截图显示了相关的类:

如我们所见,有 10 个类,其中 2 个在 OpenCV 主模块(视频模块)中。 cv::bgsegm命名空间中的类位于bgsegm附加模块中,而cv::cuda命名空间中的类位于cudabgsegm附加模块中。 如果要使用其他模块中的算法,则必须在构建 OpenCV 库时确保正确配置了这些模块。 为此,您应该从这里准备附加模块的源目录,然后在构建 OpenCV 时将带有-DOPENCV_EXTRA_MODULES_PATH选项的目录传递给 CMake。 在本节中,我们将使用主要 OpenCV 模块中的BackgroundSubtractorMOG2类使内容易于学习。 但是,您可以自己尝试任何其他算法实现。
让我们开始工作。 首先,我们将打开capture_thread.h头文件并添加一些新的字段和方法:
public:
// ...
void setMotionDetectingStatus(bool status) {
motion_detecting_status = status;
motion_detected = false;
if(video_saving_status != STOPPED) video_saving_status = STOPPING;
};
// ...
private:
void motionDetect(cv::Mat &frame);
// ...
private:
// ...
// motion analysis
bool motion_detecting_status;
bool motion_detected;
cv::Ptr<cv::BackgroundSubtractorMOG2> segmentor;
首先让我们看一下三个字段:
bool motion_detecting_status用于指示我们的应用是否负责家庭的安全。 如果为true,则将启用运动功能;否则,将打开运动功能。 否则,我们的应用只是一个摄像机视频播放器。bool motion_detected用于保存是否在网络摄像头捕获的最后一帧中检测到运动的状态。cv::Ptr<cv::BackgroundSubtractorMOG2> segmentor显然是用于检测视频中运动的减法器实例。
现在,让我们看一下新方法:
setMotionDetectingStatus方法用于打开和关闭
运动检测功能。 除了设置motion_detecting_status函数开关的值之外,我们还重置motion_detected标志并在有视频标志的情况下停止视频记录工作。 请注意,这是一个内联方法,因此我们不需要在其他文件中实现。- 如果打开了运动检测功能开关,则会在每帧的视频捕获无限循环中调用
motionDetect方法以检测运动。
现在,让我们转到源文件capture_thread.cpp,看看应该在此处进行哪些更改。 首先,我们在构造器中将功能开关初始化为false:
motion_detecting_status = false;
然后,在CaptureThread::run方法中,我们在打开相机后创建减法器实例:
segmentor = cv::createBackgroundSubtractorMOG2(500, 16, true);
使用三个参数创建减法器:
- 该算法使用跨像素历史的采样技术来创建采样的背景图像。 第一个参数称为
history,我们将500作为其值传递。 这用于定义用于采样背景图像的先前帧数。 - 第二个是
dist2Threshold,它是采样的背景图像中像素的当前值与其对应的像素值之间的平方距离的阈值。 - 第三个
detectShadows用于确定在背景分割期间是否要检测阴影。
创建减法器后,我们在无限循环中调用新的motionDetect方法:
if(motion_detecting_status) {
motionDetect(tmp_frame);
}
此方法调用必须放在视频记录工作的代码之前,因为一旦我们检测到运动,就将打开视频记录。 当前帧应在录制的视频中。
对于此类,最后一件事是motionDetect方法的实现。 这是运动检测功能的关键部分,因此让我们详细了解一下:
cv::Mat fgmask;
segmentor->apply(frame, fgmask);
if (fgmask.empty()) {
return;
}
在方法的开头,在前面的代码中,我们创建一个新的Mat实例以保存前景遮罩。 然后,我们使用捕获的帧和前景遮罩调用segmentor减法器的apply方法。 由于在每个捕获的帧上调用此方法,因此减法器将了解场景并提取背景和前景。 之后,fgmask将是灰度图像,背景填充为黑色,前景部分填充为非块像素。
现在我们有了一个灰度前景遮罩,让我们对其进行一些图像处理以消除噪声并强调我们感兴趣的对象:
cv::threshold(fgmask, fgmask, 25, 255, cv::THRESH_BINARY);
int noise_size = 9;
cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(noise_size, noise_size));
cv::erode(fgmask, fgmask, kernel);
kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(noise_size, noise_size));
cv::dilate(fgmask, fgmask, kernel, cv::Point(-1,-1), 3);
在前面的代码中,我们使用threshold函数过滤出值太小的像素。 此步骤将消除前景掩膜中的暗噪声。 然后,我们执行一个通常称为图像打开的操作,该操作会侵蚀,然后以一定的核大小扩展遮罩。 此步骤将消除大小小于核大小的噪声。 我们可以调整noise_size的值以应对不同的情况; 例如,对于远距离运动检测使用较小的值,对于近距离运动检测使用较大的值。
除去噪声后,我们可以通过调用findContours方法在前景遮罩中找到对象的轮廓:
vector<vector<cv::Point> > contours;
cv::findContours(fgmask, contours, cv::RETR_TREE, cv::CHAIN_APPROX_SIMPLE);
可以找到多个轮廓,每个轮廓由一系列要点描述。 一旦在遮罩中找到一个或多个轮廓,就可以假定检测到运动:
bool has_motion = contours.size() > 0;
if(!motion_detected && has_motion) {
motion_detected = true;
setVideoSavingStatus(STARTING);
qDebug() << "new motion detected, should send a notification.";
} else if (motion_detected && !has_motion) {
motion_detected = false;
setVideoSavingStatus(STOPPING);
qDebug() << "detected motion disappeared.";
}
在前面的代码中,如果在最后一帧中未检测到运动,但在当前帧中检测到一个或多个,则可以说检测到新的运动; 然后,我们可以开始从摄像机录制视频,并告诉某人正在发生事情。 另一方面,当在当前帧中未检测到运动但在最后一帧中检测到一个或多个运动时,可以说运动已经消失,因此停止录像。
最后,我们为在遮罩中找到的每个轮廓找到一个边界矩形,然后将其绘制在捕获帧上以强调我们发现的内容:
cv::Scalar color = cv::Scalar(0, 0, 255); // red
for(size_t i = 0; i < contours.size(); i++) {
cv::Rect rect = cv::boundingRect(contours[i]);
cv::rectangle(frame, rect, color, 1);
}
好的,运动检测工作已经完成。 考虑到此过程有点抽象,我们可以保存捕获的帧,提取的前景遮罩,去除噪声的遮罩以及带有矩形的帧作为图像绘制到硬盘上。 下图显示了一个小型垃圾箱在几辆车之间通过时我保存的图像:

上图包含与我们提到的阶段相对应的四个标记图像:
- 输入是原始捕获帧。
mask1是我们的减法器提取的前景遮罩。mask2是已除去噪声的前景遮罩。- 输出是绘制矩形的帧。
希望借助这些图像,您可以轻松了解运动检测的工作原理。
捕获线程中的工作已完成,因此让我们继续 UI。 还记得我们放在主窗口操作区域中的复选框吗? 是时候为其添加一个插槽了。 在mainwindow.h头文件中,我们在专用插槽部分为其声明一个新插槽:
private slots:
// ...
void updateMonitorStatus(int status);
然后,我们在mainwindow.cpp源文件中实现它:
void MainWindow::updateMonitorStatus(int status)
{
if(capturer == nullptr) {
return;
}
if(status) {
capturer->setMotionDetectingStatus(true);
recordButton->setEnabled(false);
} else {
capturer->setMotionDetectingStatus(false);
recordButton->setEnabled(true);
}
}
在此插槽中,如果捕获线程为null,我们将立即从方法返回;否则,返回false。 否则,我们将根据复选框的新状态将捕获线程的运动检测状态设置为打开或关闭。 另外,如果打开了动作检测功能,我们将禁用录制按钮,以避免在检测到动作时使手动启动的录制过程干扰自动启动的录制过程。 准备好该插槽后,我们可以在initUI方法中创建该复选框后,将其连接到该复选框:
connect(monitorCheckBox, SIGNAL(stateChanged(int)), this, SLOT(updateMonitorStatus(int)));
除了此插槽外,还有其他一些琐碎的事情要做,这些内容与监视器状态复选框和录制按钮的状态有关:
- 在
MainWindow::recordingStartStop方法中,在录制按钮的插槽中,我们应该在开始录制视频时禁用该复选框,并在录制过程停止时启用它。 这也是为了避免在检测到运动时手动开始的录制过程干扰自动开始的录制过程。 - 在
MainWindow::openCamera方法中,创建新的捕获线程后,我们应确保未选中该复选框,并且已启用“文本”为“Record”的“记录”按钮。
我没有在此处粘贴这些更改的代码,因为它们非常简单-您应该可以自己进行更改,或者在需要帮助时直接引用我们代码存储区中的代码。
现在,运动检测功能已经完成,因此我们可以编译应用并进行尝试。 以下屏幕截图显示了它在玩具车场景中如何工作:

玩具车是近景。 对于我来说不在办公室之外的长距离场景,我将noise_size更改为4。 我得到以下输出:

您会看到在公园散步的人周围以及在公园道路上行驶的汽车周围的许多矩形。
发送通知到我们的手机
在上一节中,我们完成了运动检测功能。 但是,当它检测到运动时,除了保存该运动的视频外,它仅打印一条消息。 作为家庭安全应用,这还不够。 无论我们身在何处或在做什么,我们都需要知道何时检测到运动。 在本节中,我们将通过 IFTTT 服务将通知发送到我们的手机来实现。
IFTTT 是连接许多有用服务的平台。 您可以通过创建一个 IFTTT 小程序来连接两个选定的服务,一个名为this,另一个名为that。 如果this发生事件,则将触发该服务。 这就是 IFTTT 的意思:if this then that。
要使用 IFTTT 发送通知,我们需要一个 IFTTT 帐户,该帐户可以在这个页面上创建。 通过一个帐户,我们可以创建一个带有 Webhook 的小程序作为this服务,并将手机通知服务作为that服务。 让我们逐步创建小程序。 创建一个小程序总共需要八个步骤。 以下屏幕截图显示了前四个:

这是最后四个步骤:

我们需要在每个步骤中采取一些措施:
-
登录,单击右上角的用户名,然后在下拉菜单中单击“新建小程序”。
-
点击蓝色的
+链接。 -
在“选择服务”页面上,在文本框中键入
webhooks,然后单击 Webhooks 方框。 然后,在下一页上选择“接收 Web 请求”。 -
在“完成触发器字段”页面上,键入
Motion-Detected-by-Gazer作为事件名称,然后单击“创建触发器”按钮。 -
点击新页面上的
+ that链接。 -
在“选择操作服务”页面上,在文本框中键入
notifi,然后选择“通知”方框,然后在下一页上选择“从 IFTTT 应用发送通知”。 -
在“完成操作字段”页面上,您将找到一个文本区域。 在文本区域中键入
Motions are just detected by the Gazer application from the camera {{Value1}} on {{Value2}}, please check it up!,然后单击创建动作按钮。 -
在“审阅并完成”页面上,为小程序命名,然后单击“完成”按钮。 我将其命名为
Gazer Notification,但您可以选择任何名称。
现在已经创建了 Applet,让我们在 IFTTT 上找到 webhook 的端点:

转到 IFTTT 上的 Webhook 服务的设置页面。 您可以通过在浏览器中访问这个页面来找到该页面。 在此页面上,您会找到信息“步骤 9”,如先前的屏幕截图所示。 复制该页面上的 URL 并访问它-您将被导航到类似“步骤 10”的页面。 该页面向我们展示了如何触发网络挂钩。 您可能会注意到此页面上 URL 中有一个文本框。 在其中创建小程序Motion-Detected-by-Gazer时,输入我们使用的事件名称。 这样,您将获得完整的 URL(即 Webhook 的端点)。 看起来像https://maker.ifttt.com/trigger/Motion-Detected-by-Gazer/with/key/-YOUR_KEY。 请记住该端点,因为我们将很快对其发出 Web 请求。
现在,我们已经在 IFTTT 上创建了帐户,我们需要在手机上安装 IFTTT 应用。 我们可以使用IFTTT关键字在 Apple App Store 或 Google Play 上进行搜索以找到该应用。 安装该应用后,我们应该使用刚刚创建的帐户登录并在手机上启用其通知,以便我们可以接收它们。
现在,让我们回到我们的应用,以便我们可以学习如何向该端点发出请求。 我们将在我们的Utilities类中执行此操作。 在utilities.h头文件中,我们添加了一个新的静态方法:
static void notifyMobile(int cameraID);
然后,我们将在utilities.cpp源文件中实现它,如下所示:
void Utilities::notifyMobile(int cameraID)
{
// CHANGE endpoint TO YOURS HERE:
QString endpoint = "https://maker.ifttt.com/trigger/...";
QNetworkRequest request = QNetworkRequest(QUrl(endpoint));
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
QJsonObject json;
json.insert("value1", QString("%1").arg(cameraID));
json.insert("value2", QHostInfo::localHostName());
QNetworkAccessManager nam;
QNetworkReply *rep = nam.post(request, QJsonDocument(json).toJson());
while(!rep->isFinished()) {
QApplication::processEvents();
}
rep->deleteLater();
}
在此方法中,我们创建了QNetworkRequest对象,并根据 IFTTT 的要求将其内容类型标头设置为"application/json"。 然后,我们构造将发布到 Webhook 的 JSON。 还记得我们在创建小程序时在“步骤 7”中键入的消息吗? 在该消息中,{{Value1}}和{{Value2}}字符串是占位符,它们将被我们发布的 JSON 中的value1和value2字段替换。 在这里,我们将摄像机索引用作value1的值,并将主机名用作value2的值。 然后,我们创建一个网络访问管理器,并通过使用请求对象和 JSON 对象调用其post方法来触发 POST 请求。 我们需要做的最后一件事是等待请求完成。 完成后,我们告诉 Qt 通过调用其deleteLater方法在事件循环的下一轮中删除回复对象。
当检测到运动时,我们将其称为此方法。 触发 Web 请求并等待其完成是一个非常缓慢的过程,因此我们无法在捕获线程中完成它。 如果这样做,它将阻止视频帧被处理。 幸运的是,Qt 提供了一种运行函数的方法是另一个线程:
if(!motion_detected && has_motion) {
motion_detected = true;
setVideoSavingStatus(STARTING);
qDebug() << "new motion detected, should send a notification.";
QtConcurrent::run(Utilities::notifyMobile, cameraID);
} else if (motion_detected && !has_motion) {
// ...
如您所见,通过使用QtConcurrent::run函数,我们可以轻松地在从 Qt 库提供的线程池中拾取的线程中运行函数。
为此,我们将两个新的 Qt 模块导入到我们的项目中:网络模块和并发模块。 在编译项目之前,我们必须在项目文件中告知生成系统:
QT += core gui multimedia network concurrent
现在,我们将编译我们的项目并运行该应用,然后在手机上安装 IFTTT 应用。 当检测到运动时,将会在我们的手机上收到通知。 我的看起来像这样:

不要忘记在手机上安装 IFTTT 应用并为其启用通知,然后使用您的 IFTTT 帐户登录。 否则,将不会收到通知。
总结
在本章中,我们创建了一个新的桌面应用 Gazer,用于捕获,播放和保存摄像机中的视频。 为了家庭安全,我们还添加了运动检测功能。 我们使用 Qt 构建了 UI,并使用 OpenCV 开发了视频处理功能。 这两个部分有机地集成到了我们的应用中。 在此应用的开发中,我们了解了如何使用 Qt 布局系统在 UI 上排列小部件,如何使用多线程技术在与主 UI 线程不同的线程中进行慢速工作,如何使用来检测运动。 OpenCV,以及如何通过触发 HTTP 请求通过 IFTTT 向我们的手机发送通知。
在下一章中,我们将学习如何实时识别图像或视频中的面部,并且我们将构建一个有趣的应用,以便可以在检测到的面部上放置有趣的遮罩。
问题
尝试以下问题,以测试您对本章的了解:
- 我们可以从视频文件而不是摄像机中检测运动吗? 我们该怎么做?
- 我们可以在不同于视频捕获线程的线程中进行运动检测吗? 为什么或者为什么不?
- IFTTT 允许您在发送的通知中包括图像-当通过 IFTTT 的此功能向您的手机发送通知时,我们如何发送检测到的运动图像?
四、人脸上的乐趣
在第 3 章,“家庭安全应用”中,我们创建了一个名为 Gazer 的新应用,利用该应用,我们可以捕获视频到我们的计算机并从连接的网络摄像头中检测运动。 在本章中,我们将继续使用网络摄像头-代替检测运动,我们将创建一个新应用,该应用能够使用相机检测面部。 首先,我们将检测网络摄像头中的面部。 然后,我们将在检测到的面部上检测人脸标志。 通过这些人脸标志,我们可以知道每个检测到的脸上的眼睛,鼻子,嘴巴和脸颊在哪里,因此我们可以在脸上应用一些有趣的面具。
本章将涵盖以下主题:
- 从网络摄像头拍照
- 使用 OpenCV 检测人脸
- 使用 OpenCV 检测人脸标志
- Qt 库的资源系统
- 在脸上覆盖遮罩
技术要求
正如我们在前几章中所看到的,要求用户至少安装 Qt 版本 5 并具有 C++ 和 Qt 编程的基本知识。 另外,应正确安装最新版本的 OpenCV 4.0。 另外,除了core和imgproc模块外,本章还将使用 OpenCV 的video和videoio模块。 如果您已经阅读了前面的章节,那么这些要求将已经得到满足。
我们将使用 OpenCV 提供的一些经过预训练的机器学习模型来检测面部和人脸标志,因此,如果您具有一些机器学习技术的基础知识,那就更好了。 其中一些机器学习模型来自 OpenCV 库的其他模块,因此 OpenCV 的其他模块也必须与核心模块一起安装。 如果不确定这一点,请放心,我们将逐步安装额外的 OpenCV 模块,然后在本章中使用它们。
Facetious 应用
由于我们将在本章中创建的应用会通过将有趣的遮罩实时应用于检测到的面部而为我们带来很多乐趣,因此我将应用命名为 Facetious。 Facetious 应用可以做的第一件事是打开一个网络摄像头,然后播放其中的视频。 这就是我们在上一章中由我们构建的 Gazer 应用中所做的工作。 因此,在这里,我将借用 Gazer 应用的框架作为新应用的基础。 该计划是,首先,我们复制 Gazer,将其重命名为Facetious,删除有关运动检测的功能,并将视频记录功能更改为新的照相功能。 这样,我们将获得一个简单干净的应用,可以在其中添加面部和人脸标志检测的新功能。
从 Gazer 到 Facetious
让我们从复制 Gazer 应用的源代码开始:
$ mkdir Chapter-04
$ cp -r Chapter-03/Gazer Chapter-04/Facetious
$ ls Chapter-04
Facetious
$ cd Chapter-04/Facetious
$ make clean
$ rm -f Gazer
$ rm -f Makefile
使用这些命令,我们将Chapter-03目录下的Gazer目录复制到Chapter-04/Facetious。 然后,我们进入该目录,运行make clean清除编译过程中生成的所有中间文件,并使用rm -f Gazer删除旧的目标可执行文件。
现在,让我们按文件重命名并清除项目文件。
首先是Gazer.pro项目文件。 我们将其重命名为Facetious.pro,然后使用编辑器将其打开以编辑其内容。 在编辑器中,我们将TARGET键的值从Gazer更改为Facetious,并从QT的值中删除了我们将在此新应用中不使用的 Qt 模块,网络和并发。 ]键,然后删除文件末尾的相关GAZER_USE_QT_CAMERA行。 Facetious.pro中更改的行列出如下:
TARGET = Facetious
# ...
QT += core gui multimedia
# ...
# the below lines are deleted in this update:
# Using OpenCV or QCamera
# DEFINES += GAZER_USE_QT_CAMERA=1
# QT += multimediawidgets
接下来是main.cpp文件。 这个文件很简单,因为我们只是将窗口标题从Gazer更改为Facetious:
window.setWindowTitle("Facetious");
接下来是capture_thread.h文件。 在此文件中,我们从CaptureThread类中删除了许多字段和方法。 将要删除的字段包括:
// FPS calculating
bool fps_calculating;
int fps;
// video saving
// int frame_width, frame_height; // notice: we keep this line
VideoSavingStatus video_saving_status;
QString saved_video_name;
cv::VideoWriter *video_writer;
// motion analysis
bool motion_detecting_status;
bool motion_detected;
cv::Ptr<cv::BackgroundSubtractorMOG2> segmentor;
此类中将要删除的方法如下:
void startCalcFPS() {...};
void setVideoSavingStatus(VideoSavingStatus status) {...};
void setMotionDetectingStatus(bool status) {...};
// ...
signals:
// ...
void fpsChanged(int fps);
void videoSaved(QString name);
private:
void calculateFPS(cv::VideoCapture &cap);
void startSavingVideo(cv::Mat &firstFrame);
void stopSavingVideo();
void motionDetect(cv::Mat &frame);
不再需要enum VideoSavingStatus类型,因此我们也将其删除。
OK,capture_thread.h文件已清除,让我们继续进行capture_thread.cpp文件。 根据头文件中的更改,我们应该首先执行以下操作:
- 在构造器中,删除头文件中已删除字段的初始化。
- 删除我们在头文件中删除的方法(包括插槽)的实现。
- 在
run方法的实现中,删除所有有关视频保存,运动检测和每秒帧(FPS)计算的代码。
好的,有关视频保存,运动检测和 FPS 计算的所有代码将从捕获线程中删除。 现在让我们看下一个文件mainwindow.h。 在本章中,我们仍将使用 OpenCV 进行视频捕获,因此,首先,我们删除#ifdef GAZER_USE_QT_CAMERA和#endif行之间的代码。 这种代码有两个块,我们将它们都删除了。 然后,我们删除了许多方法和字段,其中大多数也与视频保存,运动检测和 FPS 计算有关。 这些字段和方法如下:
void calculateFPS();
void updateFPS(int);
void recordingStartStop();
void appendSavedVideo(QString name);
void updateMonitorStatus(int status);
private:
// ...
QAction *calcFPSAction;
// ...
QCheckBox *monitorCheckBox;
QPushButton *recordButton;
请注意,appendSavedVideo方法和QPushButton *recordButton字段并未真正删除。 我们将它们分别重命名为appendSavedPhoto和QPushButton *shutterButton:
void appendSavedPhoto(QString name);
// ...
QPushButton *shutterButton;
这是在新应用中拍照的准备-正如我们所说的,在 Facetious 中,我们不录制视频,而仅拍照。
然后,在mainwindow.cpp文件中,类似于对其头文件进行的操作,首先,我们删除#ifdef GAZER_USE_QT_CAMERA和#else行之间的代码。 也有两个此类块需要移除; 不要忘记为每个这些块删除#endif行。 之后,我们删除了五个已删除方法的实现:
void calculateFPS();void updateFPS(int);void recordingStartStop();void appendSavedVideo(QString name);void updateMonitorStatus(int status);
大部分删除操作都已完成,但是MainWindow类仍有很多工作要做。 让我们从用户界面开始。 在MainWindow::initUI方法中,我们删除有关监视器状态复选框,记录按钮和记录按钮旁边的占位符的代码,然后创建新的快门按钮:
shutterButton = new QPushButton(this);
shutterButton->setText("Take a Photo");
tools_layout->addWidget(shutterButton, 0, 0, Qt::AlignHCenter);
使用前面的代码,我们将快门按钮设为tools_layout的唯一子窗口小部件,并确保按钮居中对齐。
然后,在创建状态栏之后,我们将状态栏上的启动消息更改为Facetious is Ready:
mainStatusLabel->setText("Facetious is Ready");
接下来是MainWindow::createActions方法。 在此方法中,我们应该执行的更改是删除有关calcFPSAction操作的代码,包括创建和信号插槽连接。
然后,在MainWindow::openCamera方法中,我们删除有关 FPS 计算和视频保存的所有代码,其中大多数是信号槽连接和断开连接。 此方法末尾有关复选框和按钮的代码也应删除。
关于此文件,我们要做的最后一件事是为新添加的appendSavedPhoto方法提供空实现,并清空populateSavedList方法的主体。 我们将在以下小节中为他们提供新的实现:
void MainWindow::populateSavedList()
{
// TODO
}
void MainWindow::appendSavedPhoto(QString name)
{
// TODO
}
现在轮到utilities.h和utilities.cpp文件了。 在头文件中,我们删除notifyMobile方法,并将newSavedVideoName和getSavedVideoPath方法分别重命名为newPhotoName和getPhotoPath:
public:
static QString getDataPath();
static QString newPhotoName();
static QString getPhotoPath(QString name, QString postfix);
在utilities.cpp文件中,除了根据头文件中的更改重命名和删除之外,我们还更改了getDataPath方法的实现:
QString Utilities::getDataPath()
{
QString user_pictures_path = QStandardPaths::standardLocations(QStandardPaths::PicturesLocation)[0];
QDir pictures_dir(user_pictures_path);
pictures_dir.mkpath("Facetious");
return pictures_dir.absoluteFilePath("Facetious");
}
最重要的变化是,现在我们使用QStandardPaths::PicturesLocation而不是QStandardPaths::MoviesLocation来获取照片而不是视频的标准目录。
现在,通过简化 Gazer 应用,我们已经成功地获得了我们新 Facetious 应用的基础。 让我们尝试编译并运行它:
$ qmake -makefile
$ make
g++ -c #...
# output truncated
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib
$ ./Facetious
如果一切顺利,您将看到一个与 Gazer 主窗口非常相似的空白窗口。 该小节的所有代码更改都可以在单个 Git 提交中找到。 如果您在完成本小节时遇到困难,请随时参考该提交。
拍照
在前面的小节中,我们通过从 Gazer 应用中删除了视频保存和运动检测功能,为新应用 Facetious 建立了基础。 我们还在新应用中放置了一些存根照片。 在本小节中,我们将完成拍照功能。
与视频录制功能相比,拍照要简单得多。 首先,在capture_thread.h头文件中,向CaptureThread类添加一个字段和许多方法:
public:
// ...
void takePhoto() {taking_photo = true; }
// ...
signals:
// ...
void photoTaken(QString name);
private:
void takePhoto(cv::Mat &frame);
private:
// ...
// take photos
bool taking_photo;
bool taking_photo字段是指示捕获线程是否应将当前帧作为照片保存在硬盘上的标志,void takePhoto()公共内联方法用于将该标志设置为true。 当用户单击主窗口上的快门按钮时,我们将在主线程中调用此方法。 每次拍摄照片后都会发出void photoTaken(QString name)信号,并且 Qt 元对象系统将负责其实现。 void takePhoto(cv::Mat &frame)私有方法是负责将帧作为照片保存在磁盘上的方法,它是我们唯一需要在capture_thread.cpp源文件中实现的方法。 让我们看一下它的实现:
void CaptureThread::takePhoto(cv::Mat &frame)
{
QString photo_name = Utilities::newPhotoName();
QString photo_path = Utilities::getPhotoPath(photo_name, "jpg");
cv::imwrite(photo_path.toStdString(), frame);
emit photoTaken(photo_name);
taking_photo = false;
}
在方法的主体中,我们使用在Utilities类中编写的函数来生成新名称,并使用生成的名称和jpg作为扩展名来获取要保存的照片的路径。 然后,我们使用 OpenCV 的imgcodecs模块中的imwrite函数将帧作为 JPEG 图像文件写入具有指定路径的磁盘上。 保存照片后,我们发出带有照片名称的photoTaken信号。 如果有人对此信号感兴趣,则必须将一个插槽连接到该插槽,并在发出信号时立即调用该插槽。 在方法主体的末尾,我们将taking_photo标志设置回false。
在实现CaptureThread::takePhoto(cv::Mat &frame)方法之后,让我们在CaptureThread::run()方法的捕获无限循环中将其称为:
if(taking_photo) {
takePhoto(tmp_frame);
}
在这段代码中,我们检查taking_photo标志以查看是否应该拍照。 如果是真的,我们调用takePhoto(cv::Mat &frame)方法将当前帧保存为照片。 必须在tmp_frame通过非空检查之后以及该帧的颜色顺序从 BGR 转换为 RGB 之前放置这段代码,以确保它是具有正确颜色顺序的正确帧,然后可以将其传递给 imwrite函数。
关于CaptureThread类的最后一件事是在其构造器中将taking_photo标志初始化为false。
现在,让我们进入用户界面。 首先,我们向mainwindow.h中的MainWindow类添加一个新的专用插槽:
private slots:
// ...
void takePhoto();
该插槽将连接到快门按钮的信号。 让我们在mainwindow.cpp源文件中查看其实现:
void MainWindow::takePhoto()
{
if(capturer != nullptr) {
capturer->takePhoto();
}
}
这很简单。 在此方法中,我们调用CaptureThread实例capturer的void takePhoto()方法,以告知它拍照(如果不为空)。 然后,在MainWindow::initUI()方法中,在创建快门按钮之后,将此插槽连接到shutterButton的clicked信号:
connect(shutterButton, SIGNAL(clicked(bool)), this, SLOT(takePhoto()));
通过我们之前完成的工作,现在我们可以告诉捕获线程拍照。 但是,在拍摄照片时,主窗口如何知道呢? 这是通过CaptureThread::photoTaken信号与MainWindow::appendSavedPhoto插槽之间的连接完成的。 我们在MainWindow::openCamera()方法中创建捕获线程实例后建立此连接:
connect(capturer, &CaptureThread::photoTaken, this, &MainWindow::appendSavedPhoto);
另外,不要忘记在以相同方法关闭的捕获线程实例之前断开它们的连接:
disconnect(capturer, &CaptureThread::photoTaken, this, &MainWindow::appendSavedPhoto);
现在,让我们看看MainWindow::appendSavedPhoto(QString name)插槽的作用。 在前面的小节中,我们只是给了它一个空的主体。 现在它必须承担责任:
void MainWindow::appendSavedPhoto(QString name)
{
QString photo_path = Utilities::getPhotoPath(name, "jpg");
QStandardItem *item = new QStandardItem();
list_model->appendRow(item);
QModelIndex index = list_model->indexFromItem(item);
list_model->setData(index, QPixmap(photo_path).scaledToHeight(145), Qt::DecorationRole);
list_model->setData(index, name, Qt::DisplayRole);
saved_list->scrollTo(index);
}
它所做的工作与在第 3 章,“家庭安全应用”中将新录制的视频的封面图像附加到 Gazer 应用中保存的视频列表中时的操作非常相似。 因此,我将不在这里逐行解释这段代码。
还有另一种方法MainWindow::populateSavedList(),该方法用于在应用启动时填充保存在照片列表中的所有照片。 这种方法也非常类似于我们用于在 Gazer 应用中填充已保存的视频的方法,因此我将由您自己实现。 如果有任何问题,可以在 GitHub 上参考本书随附的代码存储库。 本小节中的所有更改都可以在以下提交中找到。
现在,让我们再次编译并运行我们的应用。 应用显示其主窗口后,我们可以单击“文件”菜单下的“打开相机”操作以打开相机,然后单击“快门”按钮拍照。 完成这些操作后,我的主窗口如下图所示:

在本节中,我们设置新应用的基本功能。 在下一节中,我们将使用 OpenCV 实时检测捕获的帧中的人脸。
使用级联分类器检测人脸
在上一节中,我们创建了一个新的应用 Facetious,可以使用它来播放相机中的视频供稿并拍照。 在本节中,我们将为其添加一个新功能-使用 OpenCV 库实时检测视频中的人脸。
我们将使用 OpenCV 提供的称为级联分类器的某些功能来检测人脸。 级联分类器不仅用于检测人脸,还用于检测对象。 作为分类器,它告诉我们图像中特定的关注区域是否是特定类型的对象。 分类器包含几个较简单的分类器或阶段,然后将这些较简单的分类器应用于兴趣区域。 如果有任何简单的分类器给出否定结果,则可以说兴趣区域不包含任何感兴趣的对象。 否则,如果所有阶段都通过了,我们说我们在那个区域找到了物体。 这就是层叠这个词的意思。
在准备使用级联分类器之前,必须先对其进行训练。 在训练过程中,我们为分类器提供了某种对象的许多示例视图(称为正例和负例),其中许多图像不包含此类对象。 例如,如果我们希望级联分类器帮助我们检测人脸,则必须准备许多包含人脸的图像和许多不包含人脸的图像以对其进行训练。 通过这些给定的肯定示例和否定示例,级联分类器将学习如何判断图像的给定区域是否包含某种对象。
训练过程很复杂,但是幸运的是,OpenCV 随其发布提供了许多预训练的层叠分类器。 以 Haar 分类器为例,我们稍后将使用它。 如果我们检查已安装的 OpenCV 库的数据目录,则会在其中找到许多经过预训练的分类器数据:
# if you use a system provided OpenCV, the path is /usr/share/opencv/haarcascades
$ ls /home/kdr2/programs/opencv/share/opencv4/haarcascades/
haarcascade_eye_tree_eyeglasses.xml haarcascade_lefteye_2splits.xml
haarcascade_eye.xml haarcascade_licence_plate_rus_16stages.xml
haarcascade_frontalcatface_extended.xml haarcascade_lowerbody.xml
haarcascade_frontalcatface.xml haarcascade_profileface.xml
haarcascade_frontalface_alt2.xml haarcascade_righteye_2splits.xml
haarcascade_frontalface_alt_tree.xml haarcascade_russian_plate_number.xml
haarcascade_frontalface_alt.xml haarcascade_smile.xml
haarcascade_frontalface_default.xml haarcascade_upperbody.xml
haarcascade_fullbody.xml
我们可以通过名称轻松区分预训练的数据文件。 面部检测需要包含frontalface的文件名。
有了有关对象检测(尤其是面部检测)的知识,现在让我们回到应用以从视频提要中检测面部。
首先,我们应该更新我们的Facetious.pro项目文件:
# ...
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_video -lopencv_videoio -lopencv_objdetect
}
# ...
DEFINES += OPENCV_DATA_DIR=\\\"/home/kdr2/programs/opencv/share/opencv4/\\\"
#...
在LIBS键的配置中,我们将opencv_objdetect OpenCV 模块附加到其值,因为此 OpenCV 核心模块提供了将要使用的对象检测功能(包括面部检测)。 更改的第二部分是一个宏定义,该定义定义了我们的 OpenCV 安装的数据目录。 我们将使用此宏将预训练的分类器数据加载到我们的代码中。
然后,转到capture_thread.h头文件。 我们在此文件的CaptureThread类中添加一个私有方法和一个私有成员字段:
#include "opencv2/objdetect.hpp"
//...
private:
// ...
void detectFaces(cv::Mat &frame);
private:
// ...
// face detection
cv::CascadeClassifier *classifier;
显然,成员字段是用于检测人脸的级联分类器,并且人脸检测的工作将通过新添加的detectFaces方法完成。
现在,让我们转到capture_thread.cpp源文件,看看我们将如何在其中使用级联分类器。 首先,我们更新CaptureThread::run方法的主体:
classifier = new cv::CascadeClassifier(OPENCV_DATA_DIR "haarcascades/haarcascade_frontalface_default.xml");
while(running) {
cap >> tmp_frame;
if (tmp_frame.empty()) {
break;
}
detectFaces(tmp_frame);
// ...
}
cap.release();
delete classifier;
classifier = nullptr;
输入此方法并打开网络摄像头后,我们创建一个cv::CascadeClassifier实例并将其分配给classifier成员字段。 创建实例时,我们将预训练的分类器数据路径传递给构造器。 路径由OPENCV_DATA_DIR宏构建,该宏由我们在项目文件中定义。 此外,我们使用 OpenCV 数据目录下的haarcascades/haarcascade_frontalface_default.xml文件创建用于面部检测的分类器。
在run方法的无限循环中,我们使用刚从打开的摄像头捕获的帧调用新添加的detectFaces方法。
在无限循环结束之后,在捕获线程退出之前,我们要进行清理工作,释放打开的相机,删除分类器,并将其设置为null。
最后,让我们看一下detectFaces方法的实现:
void CaptureThread::detectFaces(cv::Mat &frame)
{
vector<cv::Rect> faces;
cv::Mat gray_frame;
cv::cvtColor(frame, gray_frame, cv::COLOR_BGR2GRAY);
classifier->detectMultiScale(gray_frame, faces, 1.3, 5);
cv::Scalar color = cv::Scalar(0, 0, 255); // red
for(size_t i = 0; i < faces.size(); i++) {
cv::rectangle(frame, faces[i], color, 1);
}
}
在此方法的主体中,我们首先声明一个cv::Rect向量,用于保存将要检测的人脸的外接矩形。 接下来,我们将输入帧转换为灰度图像,因为人脸检测过程与 RGB 颜色的特征无关。 然后,我们称为分类器的detectMultiScale方法。 该方法的第一个参数是我们要在其中检测面部的灰度输入框。 第二个参数是对矩形向量的引用,该矩形向量用于保存我们刚刚定义的检测到的脸部的外接矩形。 第三个参数用于指定在每个图像比例下将图像尺寸缩小多少,这是为了补偿对一个面部由于仅仅靠近相机而显得比另一个更大时出现的尺寸错误认识。 。 这种检测算法使用移动窗口来检测对象; 第四个参数用于定义在可以声明要查找的人脸之前在当前对象附近找到多少个对象。
detectMultiScale方法返回后,我们将在faces向量中获取检测到的面部的所有区域,然后在捕获的帧上绘制这些矩形,并用一像素的红色边框。
好的,人脸检测功能现已完成,因此让我们编译并运行我们的项目。 当有人进入打开的网络摄像头的视野时,您会在他们的脸部周围看到一个红色矩形:

在我们的代码中,当我们创建级联分类器时,我们使用了haarcascade_frontalface_default.xml文件。 但是您可能会注意到,当我们列出 OpenCV 安装的数据目录时,有多个文件,其名称指示该文件用于正面检测,例如haarcascade_frontalface_alt.xml,haarcascade_frontalface_alt2.xml或haarcascade_frontalface_alt_tree.xml。 为什么我们选择haarcascade_frontalface.xml,而不选择其他? 在不同的数据集上或在不同的配置下训练该预训练的模型数据。 这些文件的详细信息在每个文件的开头都以注释形式记录在文件中,因此您可以根据需要参考该文件。 选择模型文件的另一种直接方法是尝试所有模型文件,在数据集上对其进行测试,计算精度和召回率,然后为您的案例选择最佳模型。
除 Haar 级联分类器外,还有一个称为本地二进制模式(LBP)级联分类器的级联分类器,默认随 OpenCV 版本一起提供。 您可以在 OpenCV 安装的数据路径的lbpcascades目录下找到其训练有素的模型数据。 LBP 级联分类器比 Haar 更快,但精度也较低。 我们可以将它们与下表进行比较:

请随意尝试这些算法和预先训练的模型数据,以找到适合您情况的最佳算法。
检测人脸标志
在上一节中,通过使用 OpenCV 提供的级联分类器检测面部,我们知道哪些区域是图像中的面部。 但是只有矩形区域,我们不知道有关脸部的许多细节:脸部的眼睛,眉毛和鼻子在哪里? 在面部识别技术中,我们将这些细节称为人脸标志。 在本节中,我们将尝试找到一种检测这些人脸标志的方法。
不幸的是,OpenCV 核心模块没有提供检测人脸标志的算法,因此我们应该诉诸于人脸模块,这是一个额外的 OpenCV 模块。
在使用额外的面部模块之前,我们必须确保已在计算机上安装了该模块。 在第 2 章,“像专家一样编辑图像”,在“从源代码构建和安装 OpenCV”部分中,我们从源代码构建并安装了 OpenCV v4.0.0。 没有额外的模块。 现在,让我们使用包含的其他模块重建并重新安装它。
我们下载并解压缩了 OpenCV 的源代码,并在上次构建它时将其放置到了某个目录中。 现在,让我们从其发布页面下载 OpenCV 额外模块的源代码。 由于我们下载并使用的核心模块的版本为 v4.0.0,因此我们从这里下载了相同版本的额外模块。 下载源代码后,我们将其解压缩,将其放在解压缩的核心模块源所在的目录中,并在我们的终端中进行构建:
$ ls ~
opencv-4.0.0 opencv_contrib-4.0.0 # ... other files
$ cd ~/opencv-4.0.0 # path to the unzipped source of core modules
$ mkdir release # create the separate dir
$ cd release
$ cmake -D OPENCV_EXTRA_MODULES_PATH=../../opencv_contrib-4.0.0/modules \
-D CMAKE_BUILD_TYPE=RELEASE \
-D CMAKE_INSTALL_PREFIX=$HOME/programs/opencv ..
# ... output of cmake ...
# rm ../CMakeCache.txt if it tells you are not in a separate dir
$ make
# ... output of make ...
$ make install
如您所见,与上次安装相反,我们在cmake命令中添加了-D OPENCV_EXTRA_MODULES_PATH=../../opencv_contrib-4.0.0/modules选项,以告诉它额外模块的源位于何处。 这些命令完成后,我们可以检查面部模块是否正确安装:
$ ls ~/programs/opencv/include/opencv4/opencv2/face
bif.hpp facemark.hpp facerec.hpp
face_alignment.hpp facemarkLBF.hpp mace.hpp
facemarkAAM.hpp facemark_train.hpp predict_collector.hpp
$ ls ~/programs/opencv/lib/libopencv_face*
/home/kdr2/programs/opencv/lib/libopencv_face.so
/home/kdr2/programs/opencv/lib/libopencv_face.so.4.0
/home/kdr2/programs/opencv/lib/libopencv_face.so.4.0.0
$
如果头文件和共享对象位于 OpenCV 安装的路径中,如先前的 shell 命令所示,则您已成功安装了 OpenCV 附加模块。
安装面部模块后,让我们通过在网络浏览器中打开这个页面来打开其文档,以查看其提供的功能 。
FacemarkKazemi,FacemarkAAM和FacemarkLBF类是用于检测人脸标志的算法。 这些算法都是基于机器学习的方法,因此还有许多用于数据集处理和模型训练的工具。 训练机器学习模型超出了本章的范围,因此在本节中,我们仍将使用预训练的模型。
在我们的应用中,我们将使用FacemarkLBF类实现的算法。 可以从这里下载预训练的模型数据文件。 让我们下载它并将其放在项目根目录中名为data的子目录中:
$ mkdir -p data
$ cd data/
$ pwd
/home/kdr2/Work/Books/Qt-5-and-OpenCV-4-Computer-Vision-Projects/Chapter-04/Facetious/data
$ curl -O https://raw.githubusercontent.com/kurnianggoro/GSOC2017/master/data/lbfmodel.yaml
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 53.7M 0 53.7k 0 0 15893 0 0:59:07 0:59:07 0:00:00 0
$ ls
lbfmodel.yaml
现在所有准备工作都已完成,因此让我们回到项目的代码源,以完成人脸标志检测功能的开发。 在capture_thread.h文件中,我们为CaptureThread类添加了一个新的include伪指令和一个新的私有成员字段:
// ...
#include "opencv2/face/facemark.hpp"
// ...
class CaptureThread : public QThread
{
// ...
private:
// ...
cv::Ptr<cv::face::Facemark> mark_detector;
};
mark_detector类型的mark_detector成员字段是我们将用来检测人脸标志的精确检测器。 让我们在capture_thread.cpp源文件的CaptureThread::run方法中实例化它:
classifier = new cv::CascadeClassifier(OPENCV_DATA_DIR "haarcascades/haarcascade_frontalface_default.xml");
mark_detector = cv::face::createFacemarkLBF();
QString model_data = QApplication::instance()->applicationDirPath() + "/data/lbfmodel.yaml";
mark_detector->loadModel(model_data.toStdString());
如下面的代码所示,在run方法中,创建用于面部检测的分类器后,我们通过调用cv::face::createFacemarkLBF()创建FacemarkLBF的实例并将其分配给mark_detector成员字段 。 然后,我们构造一个字符串来保存到我们下载的预训练模型数据文件的路径。 最后,我们使用模型数据文件的路径调用loadModel方法,以将数据加载到mark_detector中。 此时,检测器可以使用了。 让我们看看如何在CaptureThread::detectFaces方法中使用它:
vector< vector<cv::Point2f> > shapes;
if (mark_detector->fit(frame, faces, shapes)) {
// draw facial land marks
for (unsigned long i=0; i<faces.size(); i++) {
for(unsigned long k=0; k<shapes[i].size(); k++) {
cv::circle(frame, shapes[i][k], 2, color, cv::FILLED);
}
}
}
在CaptureThread::detectFaces方法的结尾,我们声明一个向量类型的变量,其元素类型为cv::Point2f的向量。 一个人脸的人脸标志是由vector<cv::Point2f>类型表示的一系列点,并且在单个帧中可能检测到多个人脸,因此我们应该使用这种复杂的数据类型来表示它们。 然后,关键部分出现了-我们调用mark_detector成员字段的fit方法来检测人脸标志。 在此调用中,我们传递输入帧,使用级联分类器检测到的脸部矩形以及用于将输出标志保存到该方法的变量。 如果fit方法返回非零值,则说明成功获得了标志。 获得面部地标后,我们遍历检测到的脸部,然后遍历每张脸部的地标点,以便为每个点绘制一个 2 像素的圆圈以显示地标。
如前所述,如果您使用的是 macOS,则编译后的应用实际上是名为Facetious.app的目录,而QApplication::instance()->applicationDirPath()表达式的值将为Facetious.app/Contents/MacOS。 因此,在 MacOS 上,应将lbfmodel.yaml模型数据放置在Facetious.app/Contents/MacOS/data目录下。
除了项目文件以外,几乎所有事情都已完成。 让我们在该文件中添加用于LIBS配置的额外面部模块:
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_video -lopencv_videoio -lopencv_objdetect -lopencv_face
}
好的,现在该编译并运行我们的应用了。 在应用运行时,这些标志如下所示:

如您所见,我们在眼睛,眉毛,鼻子,嘴巴和下巴上有很多标志。 但是我们仍然无法分辨出哪些点代表哪些面部特征。 考虑到每个面部的地标中的点的顺序是固定的,我们可以使用这些点的索引来确定某个点是否适合某个面部特征。 为了清楚起见,我们绘制每个点的索引号而不是 2 像素的圆,以查看其分布:
// cv::circle(frame, shapes[i][k], 2, color, cv::FILLED);
QString index = QString("%1").arg(k);
cv::putText(frame, index.toStdString(), shapes[i][k], cv::FONT_HERSHEY_SIMPLEX, 0.4, color, 2);
然后,当我们检测并绘制人脸上的人脸标志时,它看起来像这样:

如我们所见,由其索引号表示的点在脸部上具有固定位置,因此我们可以通过以下点的索引来访问这些面部特征:
- 可以通过点
[48, 68]访问嘴。 - 右眉通过点
[17, 22]。 - 左眉通过点
[22, 27]。 - 右眼通过
[36, 42]。 - 左眼通过
[42, 48]。 - 鼻子通过
[27, 35]。 - 下巴通过
[0, 17]。
在下一部分中,我们将使用此位置信息在检测到的面部上应用遮罩。
在脸上覆盖遮罩
在本章的前面各节中,我们成功地在视频提要中检测到了面部和人脸标志。 在本节中,我们将做一些更有趣的事情-我在这里有三个面具或装饰物。 让我们尝试将它们实时应用于检测到的面部:

这些装饰物是磁盘上的图像。 与来自用户的数据不同,例如我们在第 1 章,“构建图像查看器”中查看的图像,在第 2 章“像高手一样编辑图像”中编辑的图像,以及我们在第 3 章,“家庭安全应用”中录制的视频,这些装饰物并非来自用户的数据; 它们像代码源文件一样,是我们应用的一部分。 我们必须以某种方式将它们绑定到我们的应用,尤其是当我们将应用交付给用户时。 您可以简单地将这些资源打包到已编译的二进制文件中,让用户对其进行解压缩,然后将这些资源放在特定的路径上。 但这可能给用户带来困难,尤其是当用户在不同平台上运行应用时。 幸运的是,Qt 库提供了一种资源系统来应对这种情况。 该资源系统是与平台无关的机制。 它可以存储我们在应用的可执行文件中使用的资源文件。 如果您的应用使用了某些静态文件集(例如,图标,图像,翻译文件,级联样式表等),并且您不希望在运输应用时遇到麻烦,也不会有丢失文件的风险,那么 Qt 资源系统适合您。
让我们看看如何使用此资源系统来管理和加载装饰物的图像。
使用 Qt 资源系统加载图像
Qt 资源系统要求我们在应用中使用的资源文件必须是应用源代码树的一部分。 因此,首先,我们将提到的每个装饰物另存为 JPEG 图像,并将其放置在项目根目录的名为images的子目录中:
$ pwd
/home/kdr2/Work/Books/Qt-5-and-OpenCV-4-Computer-Vision-Projects/Chapter-04/Facetious
$ ls images
glasses.jpg mouse-nose.jpg mustache.jpg
我们将这些装饰物叠加到视频供稿的框架上。 为了简化此叠加,将这些装饰图像保存为 3 通道 JPEG 图像,其前景色为黑色,背景色为白色,形状为正方形。 稍后我们将解释为什么这样做可以使 Ornaments 应用变得简单。
准备好图像后,我们将创建一个 Qt 资源收集文件来描述它们。 Qt 资源文件是基于 XML 的文件格式,其扩展名是.qrc(Qt 资源收集的缩写)。 我们将资源文件命名为images.qrc,并将其放置在项目的根目录中。 让我们看看它的内容:
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource>
<file>images/glasses.jpg</file>
<file>images/mustache.jpg</file>
<file>images/mouse-nose.jpg</file>
</qresource>
</RCC>
非常简单。 我们将资源图像的所有路径都列为 Qt 资源收集文件中的file节点。 指定的路径是相对于包含.qrc文件的目录的,该文件是此处项目的根目录。 请注意,列出的资源文件必须与.qrc文件或其子目录之一位于同一目录中,此处我们使用子目录。
然后,我们将此资源收集文件添加到Facetious.pro项目文件中,以告诉qmake处理该文件:
RESOURCES = images.qrc
这样,当我们用qmake -makefile和make编译项目时,将调用rcc的命令放在Makefile中,然后执行。 结果,将生成一个名为qrc_images.cpp的 C++ 源文件。 该文件由 Qt 资源编译器rcc生成。 同样,.qrc文件中列出的所有图像都作为字节数组嵌入到此生成的 C++ 源文件中,并且在编译项目时将被编译到应用的可执行文件中。
好的,我们成功将装饰图像嵌入到应用的可执行文件中。 但是,我们如何访问它们? 这很容易; 这些资源可以在我们的代码中以与源树中带有:/前缀的相同文件名进行访问,也可以通过具有qrc方案的 URL 进行访问。 例如,img/glasses.jpg文件路径或qrc:img/glasses.jpg URL 将允许访问glasses.jpg文件,该文件在应用源代码树中的位置为images/glasses.jpg。
有了有关 Qt 资源系统的知识,让我们将装饰物作为cv::Mat的实例加载到我们的应用中。 首先是capture_thread.h头文件的更改:
// ...
private:
// ...
void loadOrnaments();
// ...
private:
// ...
// mask ornaments
cv::Mat glasses;
cv::Mat mustache;
cv::Mat mouse_nose;
// ...
如您所见,我们添加了三个cv::Mat类型的私有成员字段来保存已加载的装饰品,并添加了一个私有方法来加载它们。 新添加的方法的实现在capture_thread.cpp源文件中:
void CaptureThread::loadOrnaments()
{
QImage image;
image.load(img/glasses.jpg");
image = image.convertToFormat(QImage::Format_RGB888);
glasses = cv::Mat(
image.height(), image.width(), CV_8UC3,
image.bits(), image.bytesPerLine()).clone();
image.load(img/mustache.jpg");
image = image.convertToFormat(QImage::Format_RGB888);
mustache = cv::Mat(
image.height(), image.width(), CV_8UC3,
image.bits(), image.bytesPerLine()).clone();
image.load(img/mouse-nose.jpg");
image = image.convertToFormat(QImage::Format_RGB888);
mouse_nose = cv::Mat(
image.height(), image.width(), CV_8UC3,
image.bits(), image.bytesPerLine()).clone();
}
在此方法中,我们首先定义一个以QImage作为其类型的对象,然后调用其load方法以加载图像。 在调用load方法时,我们使用img/glasses.jpg字符串作为其参数。 这是一个以:/开头的字符串,并且如上所述,这是从 Qt 资源系统加载资源的方式。 在这里,使用qrc:img/glasses.jpg字符串也可以。
加载图像后,我们将其转换为 3 通道和 8 深度格式,以便我们可以将CV_8UC3作为其数据类型来构建cv::Mat实例。 转换后,我们像先前项目中所做的那样,从QImage构造了一个cv::Mat对象。 值得注意的是,我们调用了clone方法以对刚刚构造的Mat实例进行深层复制,然后将其分配给类成员字段。 因此,刚构建的Mat对象与QImage对象共享基础数据缓冲区。 当我们重新加载QImage或方法返回且QImage销毁时,该数据缓冲区将被删除。
然后,以相同的方式加载胡子和鼠标鼻子的装饰物。 然后,我们将这种新添加的loadOrnaments方法称为CaptureThread类的构造器。
此时,由于有了 Qt 资源系统,我们可以将所有三个装饰图像编译到应用可执行文件中,并方便地将它们加载到我们的代码中。 实际上,Qt 资源系统可以做的比我们在本章中使用的要多。 例如,它可以根据应用运行的环境选择要使用的不同资源文件。 它还可以将所有资源文件编译为单个rcc二进制文件,而不是将它们嵌入到应用可执行文件中,然后使用QResource API 进行注册和加载。 有关这些详细信息,您可以在这个页面上参考 Qt 资源系统的正式文档。
在脸上绘制遮罩
在前面的小节中,我们将准备好的装饰品作为cv::Mat的实例加载到我们的应用中。 现在,让我们将它们绘制到在本小节中从相机检测到的面部上。
现在,让我们看看眼镜的装饰。 在将眼镜戴在脸上之前,有很多事情要做。 首先,我们在装饰图像中的眼镜具有固定的宽度,但是在视频中检测到的脸部可以是任意宽度,因此我们应调整眼镜的大小以根据宽度匹配这些脸部。 然后,将我们的眼镜水平放置,但是视频中的面部可能会倾斜,甚至旋转 90 度,因此我们必须旋转眼镜以适应面部的倾斜。 最后,我们应该找到合适的位置来画眼镜。
让我们看看如何在代码中执行这些操作。 在capture_thread.h头文件中,我们在类中添加新的方法声明:
private:
// ...
void drawGlasses(cv::Mat &frame, vector<cv::Point2f> &marks);
此方法的第一个参数是我们要在其上绘制眼镜的框架,第二个参数是我们在此框架中检测到的特定面部的人脸标志。 然后,在capture_thread.cpp源文件中给出其实现:
void CaptureThread::drawGlasses(cv::Mat &frame, vector<cv::Point2f> &marks)
{
// resize
cv::Mat ornament;
double distance = cv::norm(marks[45] - marks[36]) * 1.5;
cv::resize(glasses, ornament, cv::Size(0, 0), distance / glasses.cols, distance / glasses.cols, cv::INTER_NEAREST);
// rotate
double angle = -atan((marks[45].y - marks[36].y) / (marks[45].x - marks[36].x));
cv::Point2f center = cv::Point(ornament.cols/2, ornament.rows/2);
cv::Mat rotateMatrix = cv::getRotationMatrix2D(center, angle * 180 / 3.14, 1.0);
cv::Mat rotated;
cv::warpAffine(
ornament, rotated, rotateMatrix, ornament.size(),
cv::INTER_LINEAR, cv::BORDER_CONSTANT, cv::Scalar(255, 255, 255));
// paint
center = cv::Point((marks[45].x + marks[36].x) / 2, (marks[45].y + marks[36].y) / 2);
cv::Rect rec(center.x - rotated.cols / 2, center.y - rotated.rows / 2, rotated.cols, rotated.rows);
frame(rec) &= rotated;
}
如您在前面的代码中所见,此方法的第一部分是调整眼镜的大小。 我们通过调用cv::norm函数来计算点marks[45]和marks[36]之间的距离。 这两点是什么,为什么要选择它们? 还记得本章的“检测人脸标志”部分中,我们使用图片演示了脸上 69 个人脸标志的位置吗? 在该图片中,我们发现marks[45]是左眼的最外点,而marks[36]是右眼的最外点。 通常,眼镜的宽度略大于这两个点之间的距离,因此我们将距离乘以 1.5 作为合适的眼镜宽度,然后将眼镜装饰图像调整为合适的尺寸。 请注意,调整图像大小时,宽度和高度以相同的比例缩放。
第二部分是旋转装饰品。 我们将垂直距离除以两个选定点的水平距离,然后将结果传递到atan函数以计算面部倾斜的角度。 请注意,生成的角度以弧度表示,当使用 OpenCV 旋转角度时,应将其转换为度。 然后,我们使用第 2 章,“像高手一样编辑图像”中使用的cv::warpAffine函数。 旋转装饰品。 旋转图像时,除非我们为旋转的图像计算适当的大小而不是使用输入图像的大小,否则可能会对其进行裁剪。 但是,当输入图像为正方形并且其中对象的最大宽度和高度均小于正方形的边长时,我们可以使用输入图像的大小作为输出大小来安全旋转它,而无需裁剪。 这就是为什么我们在前面的小节中将装饰图像准备为正方形的原因; 这确实使我们的旋转变得简单和容易。
最后一部分是将调整大小和旋转的眼镜绘制到脸上。 我们使用选定的两个点的中心点作为眼镜图像的中心点,以计算应放置眼镜图像的矩形。 然后,使用frame(rec) &= rotated;语句绘制装饰。 该声明可能需要一些解释。 frame变量为cv::Mat类型,frame(rec)调用其Mat cv::Mat::operator()(const Rect &roi) const运算符方法。 此方法返回一个新的cv::Mat类型,该类型由Rect参数确定,并与原始cv::Mat类型共享数据缓冲区。 因此,对该矩阵的任何运算实际上都将应用于原始矩阵的相应区域。 由于我们的装饰图像以白色为背景色,以黑色为前景色,因此我们可以简单地使用按位 AND 操作进行绘制。
现在,该方法的实现已完成,我们将其命名为detectFaces,以便在从它们获得人脸标志后将其绘制在我们检测到的每个人脸上:
// ...
for (unsigned long i=0; i<faces.size(); i++) {
for(unsigned long k=0; k<shapes[i].size(); k++) {
// cv::circle(frame, shapes[i][k], 2, color, cv::FILLED);
}
drawGlasses(frame, shapes[i]);
}
// ...
为了使视频清晰,我们还注释掉了用于绘制人脸标志的语句。 现在,让我们编译并运行该应用以查看其运行情况:

哈,还不错! 让我们继续。 我们仍然需要绘制两个装饰物,但是可以通过与绘制眼镜几乎相同的方式来完成:
- 对于胡须,我们使用
marks[54](它是嘴的左上角)和marks[48](它是嘴的右上角)来计算宽度和倾斜度。 用marks[33]和marks[51]确定中心点。 - 对于鼠标鼻子,我们使用
marks[13]和marks[3]确定宽度,使用marks[16]和marks[0]计算倾斜度,并使用marks[30]和鼻尖作为中心点。
利用前面的信息,您可以自己实现drawMustache和drawMouseNose方法,或在我们的代码库中引用代码。 下图显示了我们的应用如何完成所有这些装饰:

我的男孩好可爱,不是吗?
在 UI 上选择遮罩
在本章的前面各节中,我们做了很多工作来实时检测来自网络摄像头的视频提要中的面部和人脸标志。 在此过程中,我们在脸部周围绘制外接矩形,在脸部上将脸部标志绘制为 2 像素圆,然后在脸部上应用 3 种不同的遮罩。 这样可以教会我们很多有关使用 OpenCV 进行人脸识别的知识,但是从用户的角度来看,要画在人脸上实在太多了。 因此,在本节中,我们将为用户提供机会,通过用户界面上的复选框选择在检测到的面部上绘制哪些标记或遮罩-我们将在主窗口的快门按钮下方添加五个复选框。
在更改用户界面之前,我们必须使CaptureThread类具有选择首先绘制哪些标记或遮罩的功能。 因此,我们打开capture_thread.h文件向类中添加一些内容:
public:
// ...
enum MASK_TYPE{
RECTANGLE = 0,
LANDMARKS,
GLASSES,
MUSTACHE,
MOUSE_NOSE,
MASK_COUNT,
};
void updateMasksFlag(MASK_TYPE type, bool on_or_off) {
uint8_t bit = 1 << type;
if(on_or_off) {
masks_flag |= bit;
} else {
masks_flag &= ~bit;
}
};
// ...
private:
// ...
bool isMaskOn(MASK_TYPE type) {return (masks_flag & (1 << type)) != 0; };
private:
// ...
uint8_t masks_flag;
// ...
首先,我们添加一个枚举MASK_TYPE类型以指示标记或掩码的类型:
RECTANGLE的值0用于人脸周围的外接矩形。LANDMARKS的值1用于人脸上的人脸标志。GLASSES的值2用于眼镜装饰(或遮罩)。MUSTACHE用于胡须装饰品,值3。MOUSE_NOSE用于鼠标鼻子装饰,其值为4。MASK_COUNT不适用于任何标记或掩膜,它具有5值(前一个值加 1),为方便起见,它用于标记和掩膜的总数。
然后,我们添加一个新的uint8_t masks_flag专用字段,以保存这些标记和掩码是打开还是关闭的状态。 该字段用作位图-如果打开一种标记或掩码,则相应的位将设置为1,否则,该位将设置为0。 这是通过新添加的updateMasksFlag公共内联方法完成的。 我们还提供isMaskOn专用内联方法来测试某个标记或遮罩是否已打开。 现在,让我们在capture_thread.cpp源文件中使用这些工具。
首先,在CaptureThread::run方法中,在我们称为detectFaces方法的位置,添加条件检查:
// detectFaces(tmp_frame);
if(masks_flag > 0)
detectFaces(tmp_frame);
如果masks_flag位图为零,则说明没有打开任何标记或掩码,因此无需调用detectFaces方法。 但是,如果该位图大于零,则必须调用并输入该方法以检测人脸。 让我们在这里看看如何进行条件检查:
// ...
if (isMaskOn(RECTANGLE)) {
for(size_t i = 0; i < faces.size(); i++) {
cv::rectangle(frame, faces[i], color, 1);
}
}
// ...
if (isMaskOn(LANDMARKS)) {
for(unsigned long k=0; k<shapes[i].size(); k++) {
cv::circle(frame, shapes[i][k], 2, color, cv::FILLED);
}
}
// ...
if (isMaskOn(GLASSES))
drawGlasses(frame, shapes[i]);
if (isMaskOn(MUSTACHE))
drawMustache(frame, shapes[i]);
if (isMaskOn(MOUSE_NOSE))
drawMouseNose(frame, shapes[i]);
// ...
它们简单明了。 我们使用isMaskOn方法检查每种遮罩类型的标志,然后确定是否要绘制该类型的标记或遮罩。
CaptureThread类的最后一件事是不要忘记在构造器中将masks_flag初始化为零。 完成CaptureThread类的所有这些操作之后,让我们继续进行主窗口以更改用户界面。
在mainwindow.h头文件中,我们添加了一个新的专用插槽和一系列复选框:
private slots:
// ...
void updateMasks(int status);
// ...
private:
// ...
QCheckBox *mask_checkboxes[CaptureThread::MASK_COUNT];
如前所述,前面的数组具有CaptureThread::MASK_COUNT元素,即5。 updateMasks插槽用于这些复选框。 让我们在mainwindow.cpp源文件的initUI方法中创建和排列这些复选框:
// masks
QGridLayout *masks_layout = new QGridLayout();
main_layout->addLayout(masks_layout, 13, 0, 1, 1);
masks_layout->addWidget(new QLabel("Select Masks:", this));
for (int i = 0; i < CaptureThread::MASK_COUNT; i++){
mask_checkboxes[i] = new QCheckBox(this);
masks_layout->addWidget(mask_checkboxes[i], 0, i + 1);
connect(mask_checkboxes[i], SIGNAL(stateChanged(int)), this, SLOT(updateMasks(int)));
}
mask_checkboxes[0]->setText("Rectangle");
mask_checkboxes[1]->setText("Landmarks");
mask_checkboxes[2]->setText("Glasses");
mask_checkboxes[3]->setText("Mustache");
mask_checkboxes[4]->setText("Mouse Nose");
在initUI方法中,我们将前面的代码添加到创建快门按钮的以下行中,以便为复选框创建新的网格布局。 该网格布局占据主布局的第一行,即第 14 行。 然后,我们在新创建的网格布局中添加一个新标签,并将其文本设置为Select Masks:,以介绍该区域的功能。 之后,在for循环中,我们为每种类型的遮罩创建一个复选框,将其添加到网格布局中,然后将其stateChanged(int)信号连接到新添加的updateMasks(int)插槽。 在for循环之后,我们为刚创建的每个复选框设置了适当的文本。
考虑到新添加的区域占据了主布局的第 14 行,我们必须使用相同的方法将已保存照片的列表区域向下移动一行:
// main_layout->addWidget(saved_list, 13, 0, 4, 1);
main_layout->addWidget(saved_list, 14, 0, 4, 1);
以下是updateMasks插槽的实现,现在让我们看一下:
void MainWindow::updateMasks(int status)
{
if(capturer == nullptr) {
return;
}
QCheckBox *box = qobject_cast<QCheckBox*>(sender());
for (int i = 0; i < CaptureThread::MASK_COUNT; i++){
if (mask_checkboxes[i] == box) {
capturer->updateMasksFlag(static_cast<CaptureThread::MASK_TYPE>(i), status != 0);
}
}
}
在插槽中,我们首先检查捕获线程是否为空。 如果不为null,则找到信号发送者,即单击哪个复选框,以便通过从 Qt 库调用sender函数来调用此插槽。 然后,我们在mask_checkboxes复选框数组中找到发送者的索引。 我们刚刚发现的索引恰好是MASK_TYPE枚举中相应掩码的类型的值,所以接下来,我们根据sender复选框的状态,调用捕获线程实例的updateMasksFlag方法来打开或关闭掩码。
关于用户界面更改的最后一件事是,在MainWindow::openCamera方法中创建并启动捕获线程的新实例之后,我们将所有checkboxes都设置为未选中:
for (int i = 0; i < CaptureThread::MASK_COUNT; i++){
mask_checkboxes[i]->setCheckState(Qt::Unchecked);
}
好的,最后,我们新应用 Facetious 的所有工作都完成了! 让我们编译并运行它以查看其外观:

现在,用户可以利用快门按钮下方的复选框选择要显示的标记或遮罩。
总结
在本章中,我们通过缩短上一章中构建的 Gazer 应用创建了一个名为 Facetious 的新应用。 在缩短过程中,删除了视频保存和运动检测的功能,并添加了新的照相功能。 结果,我们得到了一个干净的应用,它使我们可以专注于面部识别。 然后,在该应用中,我们开发了使用预训练的叶栅分类器进行人脸检测的功能,以及使用其他预训练的机器学习模型进行人脸标志检测的功能。 最后,这是有趣的部分,我们借助检测到的地标在检测到的面部上应用了许多装饰。
在下一章中,我们将讨论光学字符识别(OCR)技术,并使用该技术从图像或扫描的文档中提取文本。
问题
尝试这些问题,以测试您对本章的了解:
- 尝试使用 LBP 级联分类器自己检测人脸。
- 还有一些其他算法可用于检测 OpenCV 库中的人脸标志,并且大多数算法可在这里。 请自己尝试。
- 如何将彩色装饰物应用到脸上?
五、光学字符识别
在前面的章节中,我们对视频和摄像机做了很多工作。 我们创建了应用(Gazer 和 Facetious),通过它们可以播放连接到计算机的网络摄像头中的视频。 我们还可以使用这些应用实时记录视频,拍照,检测动作和面部,以及将遮罩应用于在视频供稿中检测到的面部。
现在,我们将重点转移到图像中的文本上。 在许多情况下,我们要从图像中提取文本或字符。 在计算机视觉领域,有一种称为光学字符识别(OCR)的技术可以自动执行这种工作,而不是手动转录文本。 在本章中,我们将构建一个新的应用,以使用 Qt 和许多 OCR 库从图像和扫描的文档中提取文本。
我们将在本章介绍以下主题:
- 从图像中提取文本
- 检测图像中的文本区域
- 访问屏幕内容
- 在窗口小部件上绘制并裁剪屏幕的某些部分
技术要求
从前面的章节中可以看到,要求用户至少安装 Qt 版本 5 并具有 C++ 和 Qt 编程的一些基本知识。 另外,应该正确安装最新版本的 Tesseract 4.0 版,因为在本章中我们将使用此库作为 OCR 工具。 对于 Windows,可以在这个页面中找到预构建的 Tesseract 二进制包。 对于类似 UNIX 的系统,我们将在使用它之前逐步从源代码构建 Tesseract。
深度学习的一些知识也将对理解本章的内容有很大帮助。
创建 Literacy
如前所述,我们将创建一个新的应用以从图像或扫描的文档中提取文本,因此其名称为 Literacy。 首先是要弄清楚应用打算做什么。 主要功能是从图像中提取文本,但是,为了方便用户,我们应该提供多种指定图像的方法:
- 该图像可能来自本地硬盘。
- 可以从屏幕上捕获图像。
在明确了此要求之后,现在让我们设计 UI。
设计 UI
绘制以下线框,作为我们应用的 UI 设计:

如您所见,我们将主区域垂直分为两部分-左侧部分用于显示打开的或捕获的图像,而右侧部分用于提取的文本。 窗口的其他部分,例如菜单栏,工具栏和状态栏,都是我们非常熟悉的方面。
您可以从 GitHub 上的代码存储库中找到此设计的源文件。 该文件位于存储库的根目录中,名为WireFrames.epgz,而本章的线框位于第三页上。 不要忘记应该使用 Pencil 应用将其打开。
设置 UI
在上一节中,我们设计了新应用 Literacy 的 UI。 现在,让我们为其创建 Qt 项目,并在 Qt 主窗口中设置其完整的 UI。
首先,让我们在终端中创建项目:
$ mkdir Literacy/
$ cd Literacy/
$ touch main.cpp
$ ls
main.cpp
$ qmake -project
$ ls
Literacy.pro main.cpp
$
然后,我们打开项目文件Literacy.pro,并用以下内容填充它:
TEMPLATE = app
TARGET = Literacy
QT += core gui
greaterThan(QT_MAJOR_VERSION, 4): QT += widgets
INCLUDEPATH += .
# Input
HEADERS += mainwindow.h
SOURCES += main.cpp mainwindow.cpp
这很简单,因为我们已经做了很多次了。 但是,仍然值得注意的是,我们在此项目文件中指定了一个头文件和两个源文件,而此时,我们只有一个空的源文件main.cpp。 在编译项目之前不用担心,我们将完成提到的所有这些源文件。
main.cpp文件也非常简单:
#include <QApplication>
#include "mainwindow.h"
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
MainWindow window;
window.setWindowTitle("Literacy");
window.show();
return app.exec();
}
与我们在其他项目中所做的相似,我们创建QApplication的实例和MainWindow的实例,然后调用窗口的show方法和应用的exec方法来启动应用。 但是,MainWindow类尚不存在,所以让我们现在创建它。
在项目的根目录中,我们创建一个名为mainwindow.h的新文件来容纳MainWindow类。 忽略ifndef/define惯用语和include伪指令,该类如下所示:
class MainWindow : public QMainWindow
{
Q_OBJECT
public:
explicit MainWindow(QWidget *parent=nullptr);
~MainWindow();
private:
void initUI();
void createActions();
void setupShortcuts();
private:
QMenu *fileMenu;
QToolBar *fileToolBar;
QGraphicsScene *imageScene;
QGraphicsView *imageView;
QTextEdit *editor;
QStatusBar *mainStatusBar;
QLabel *mainStatusLabel;
QAction *openAction;
QAction *saveImageAsAction;
QAction *saveTextAsAction;
QAction *exitAction;
};
显然,它是QMainWindow的子类,因此,它的主体开头具有Q_OBJECT宏。 最重要的方面是我们在专用部分声明的小部件,包括文件菜单fileMenu; 工具栏fileToolBar; QGraphicsScene和QGraphicsView显示目标图像; QTextEditor在其上放置识别的文本,状态栏和标签; 最后是四个QAction指针。
除了这些小部件声明之外,我们还提供了三种私有方法来实例化这些小部件并将它们安排在我们设计的主窗口中:
initUI:实例化除动作以外的所有小部件。createActions:创建所有动作; 这由initUI方法调用。setupShortcuts:设置一些热键,使我们的应用更易于使用。 这由createActions方法调用。
现在是时候实现这些方法了。 我们在项目的根目录中创建一个名为mainwindow.cpp的新源文件,以适应这些实现。 首先,让我们看一下initUI方法:
void MainWindow::initUI()
{
this->resize(800, 600);
// setup menubar
fileMenu = menuBar()->addMenu("&File");
// setup toolbar
fileToolBar = addToolBar("File");
// main area
QSplitter *splitter = new QSplitter(Qt::Horizontal, this);
imageScene = new QGraphicsScene(this);
imageView = new QGraphicsView(imageScene);
splitter->addWidget(imageView);
editor = new QTextEdit(this);
splitter->addWidget(editor);
QList<int> sizes = {400, 400};
splitter->setSizes(sizes);
setCentralWidget(splitter);
// setup status bar
mainStatusBar = statusBar();
mainStatusLabel = new QLabel(mainStatusBar);
mainStatusBar->addPermanentWidget(mainStatusLabel);
mainStatusLabel->setText("Application Information will be here!");
createActions();
}
在这种方法中,我们首先设置窗口大小,创建文件菜单,然后将其添加到菜单栏,创建文件工具栏,最后,我们创建状态栏,然后在其上放置标签。 所有这些工作与我们在先前项目中所做的相同。 与先前项目不同的重要部分是主区域的创建,该主区域是方法主体的中间部分。 在本部分中,我们将创建一个水平方向的QSplitter对象,而不是一个QGridLayout实例来容纳图形视图和编辑器。
使用QSplitter使我们能够通过拖动其分隔条自由地更改其子窗口小部件的宽度,这是QGridLayout无法实现的。 此后,我们创建图形场景以及图形视图,然后编辑器将它们有序地添加到拆分器中。 通过使用列表int调用setSizes方法来设置拆分器的子级的宽度; 我们让每个子项占据 400 像素的相等宽度。 最后,我们将分割器设置为主窗口的中央小部件。
以下代码与createActions方法有关:
void MainWindow::createActions()
{
// create actions, add them to menus
openAction = new QAction("&Open", this);
fileMenu->addAction(openAction);
saveImageAsAction = new QAction("Save &Image as", this);
fileMenu->addAction(saveImageAsAction);
saveTextAsAction = new QAction("Save &Text as", this);
fileMenu->addAction(saveTextAsAction);
exitAction = new QAction("E&xit", this);
fileMenu->addAction(exitAction);
// add actions to toolbars
fileToolBar->addAction(openAction);
setupShortcuts();
}
在这里,我们创建所有声明的动作,并将它们添加到文件菜单和工具栏。 在此方法的结尾,我们调用setupShortcuts。 现在,让我们看看我们在其中设置了哪些快捷方式:
void MainWindow::setupShortcuts()
{
QList<QKeySequence> shortcuts;
shortcuts << (Qt::CTRL + Qt::Key_O);
openAction->setShortcuts(shortcuts);
shortcuts.clear();
shortcuts << (Qt::CTRL + Qt::Key_Q);
exitAction->setShortcuts(shortcuts);
}
如您所见,我们使用Ctrl-O触发openAction,并使用Ctrl-Q触发exitAction。
最后,有构造器和析构器:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent)
{
initUI();
}
MainWindow::~MainWindow()
{
}
这些都非常简单,因此我们在这里不再赘述。 现在,我们可以编译并运行 Literacy 应用:
$ qmake -makefile
$ make
g++ -c -pipe -O2 -Wall -W # ...
# output trucated
$ ./Literacy
运行应用后,桌面上将出现一个如下所示的窗口:

因此,我们设置了没有任何交互功能的完整 UI。 接下来,我们将向我们的应用添加许多交互式功能,包括以下内容:
- 从本地磁盘打开图像
- 将当前图像作为文件保存到本地磁盘上
- 将编辑器小部件中的文本另存为文本文件到本地磁盘上
为了实现这些目标,我们应该在mainwindow.h头文件中的MainWindow类中添加一些方法,插槽和成员字段:
private:
// ...
void showImage(QString);
// ...
private slots:
void openImage();
void saveImageAs();
void saveTextAs();
private:
// ...
QString currentImagePath;
QGraphicsPixmapItem *currentImage;
showImage方法和openImage插槽的实现与我们在ImageViewer应用中编写的MainWindow::showImage和MainWindow::openImage方法的实现相同(请参阅第 1 章,“图像查看器”)。 同样,saveImageAs插槽与该ImageViewer应用中的MainWindow::saveAs方法具有完全相同的实现。 由于我们应该在新应用中保存图像和文本,因此我们在此处仅使用一个不同的名称,并且此方法仅用于保存图像。 因此,我们只需将这些实现复制到我们的新项目中。 为了使本章保持简短,我们在这里不再赘述。
我们尚未介绍的唯一方法是saveTextAs插槽。 现在,让我们看一下它的实现:
void MainWindow::saveTextAs()
{
QFileDialog dialog(this);
dialog.setWindowTitle("Save Text As ...");
dialog.setFileMode(QFileDialog::AnyFile);
dialog.setAcceptMode(QFileDialog::AcceptSave);
dialog.setNameFilter(tr("Text files (*.txt)"));
QStringList fileNames;
if (dialog.exec()) {
fileNames = dialog.selectedFiles();
if(QRegExp(".+\\.(txt)").exactMatch(fileNames.at(0))) {
QFile file(fileNames.at(0));
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::information(this, "Error", "Can't save text.");
return;
}
QTextStream out(&file);
out << editor->toPlainText() << "\n";
} else {
QMessageBox::information(this, "Error", "Save error: bad format or filename.");
}
}
}
它与saveImageAs方法非常相似。 区别如下:
- 在文件对话框中,我们使用扩展名
txt设置名称过滤器,以确保只能选择文本文件。 - 保存文本时,我们使用所选文件名创建一个
QFile实例,然后使用可以写入的QFile实例创建一个QTextStream实例。 最后,我们通过调用文本编辑器的toPlainText()方法获取文本编辑器的内容,并将其写入刚刚创建的流中。
现在,所有方法和插槽都已完成,因此让我们在createActions方法中连接信号和这些插槽:
// connect the signals and slots
connect(exitAction, SIGNAL(triggered(bool)), QApplication::instance(), SLOT(quit()));
connect(openAction, SIGNAL(triggered(bool)), this, SLOT(openImage()));
connect(saveImageAsAction, SIGNAL(triggered(bool)), this, SLOT(saveImageAs()));
connect(saveTextAsAction, SIGNAL(triggered(bool)), this, SLOT(saveTextAs()));
最后,我们在构造器中将currentImage成员字段初始化为nullptr:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent), currentImage(nullptr)
{
initUI();
}
现在,我们再次编译并运行我们的应用,以测试这些新添加的交互式功能。 单击操作,打开图像,在编辑器中键入一些单词,拖动分隔条以调整列的宽度,然后将图像或文本另存为文件。 我们正在与之交互的主窗口如下所示:

正如您在图中所看到的,我们打开一个包含许多字符的图像,并在右侧的编辑器中键入一些文本。 在下一部分中,我们将从图像中提取文本,然后通过单击工具栏上的按钮自动将提取的文本填充到编辑器中。
OCR 与 Tesseract
在本节中,我们将使用 Tesseract 从图像中提取文本。 如前所述,要在 Windows 上安装 Tesseract,我们可以使用预构建的二进制包。 在类似 UNIX 的系统上,我们可以使用系统包管理器进行安装,例如,在 Debian 上安装apt-get,在 MacOS 上安装brew。 以 Debian 为例-我们可以安装libtesseract-dev和tesseract-ocr-all包来安装所需的所有库和数据文件。 无论如何安装,请确保已安装正确的版本 4.0.0。
尽管有预构建的包,但出于教学目的,我们将从 Linux 系统上的源代码构建它,以查看其中包含哪些组件以及如何使用其命令行工具。
从源构建 Tesseract
我们将从源代码构建版本 4.0.0,因此,首先,在发行页面上,选择 4.0.0 Release 下的 zip 文件,进行下载。 下载.zip文件后,我们将其解压缩并输入构建目录:
$ curl -L https://github.com/tesseract-ocr/tesseract/archive/4.0.0.zip -o tesseract-4.0.0.zip
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 127 0 127 0 0 159 0 --:--:-- --:--:-- --:--:-- 159
100 2487k 0 2487k 0 0 407k 0 --:--:-- 0:00:06 --:--:-- 571k
$ unzip tesseract-4.0.0.zip
# output omitted
$ cd tesseract-4.0.0/
$ ./configure --prefix=/home/kdr2/programs/tesseract
# output omitted
$ make && make install
# output omitted
Tesseract 使用autotools来构建其构建系统,因此其构建过程非常容易。 我们首先运行configure脚本,然后运行make && make install。 我们提供一个参数--prefix=/home/kdr2/programs/tesseract,以在运行configure脚本时指定安装前缀,因此,如果一切按计划进行,则 Tesseract 库,包括头文件,静态库,动态库以及许多其他文件,将安装在该指定目录下。
Tesseract 4 中引入了一种基于长短期记忆(LSTM)神经网络的新型 OCR 引擎,该引擎专注于行识别。 还保留了通过识别字符样式起作用的 Tesseract 3。 因此,我们可以选择在 Tesseract 4 中自由使用哪个引擎。要使用新的 OCR 引擎,我们必须在该引擎中下载 LSTM AI 模型的预训练数据。 可以在 GitHub 存储库中中找到这些经过预训练的数据。 我们下载该存储库的内容并将其放置在我们的 Tesseract 安装目录下:
$ curl -O -L https://github.com/tesseract-ocr/tessdata/archive/master.zip
# output omitted
$ unzip master.zip
Archive: master.zip
590567f20dc044f6948a8e2c61afc714c360ad0e
creating: tessdata-master/
inflating: tessdata-master/COPYING
inflating: tessdata-master/README.md
inflating: tessdata-master/afr.traineddata
...
$ mv tessdata-master/* /home/kdr2/programs/tesseract/share/tessdata/
$ ls /home/kdr2/programs/tesseract/share/tessdata/ -l |head
total 1041388
-rw-r--r-- 1 kdr2 kdr2 7851157 May 10 2018 afr.traineddata
-rw-r--r-- 1 kdr2 kdr2 8423467 May 10 2018 amh.traineddata
-rw-r--r-- 1 kdr2 kdr2 2494806 May 10 2018 ara.traineddata
-rw-r--r-- 1 kdr2 kdr2 2045457 May 10 2018 asm.traineddata
-rw-r--r-- 1 kdr2 kdr2 4726411 May 10 2018 aze_cyrl.traineddata
-rw-r--r-- 1 kdr2 kdr2 10139884 May 10 2018 aze.traineddata
-rw-r--r-- 1 kdr2 kdr2 11185811 May 10 2018 bel.traineddata
-rw-r--r-- 1 kdr2 kdr2 1789439 May 10 2018 ben.traineddata
-rw-r--r-- 1 kdr2 kdr2 1966470 May 10 2018 bod.traineddata
如您所见,在此步骤中,我们得到许多扩展名为traineddata的文件。 这些文件是针对不同语言的预训练数据文件; 语言名称用作基本文件名。 例如,eng.traineddata用于识别英文字符。
实际上,您不必将此受过训练的数据放在 Tesseract 安装数据目录下。 您可以将这些文件放在任意位置,然后将环境变量TESSDATA_PREFIX设置到该目录。 Tesseract 将通过遵循此环境变量来找到它们。
Tesseract 提供了一个命令行工具来从图像中提取文本。 让我们使用此工具来检查 Tesseract 库是否正确安装:
$ ~/programs/tesseract/bin/tesseract -v
tesseract 4.0.0
leptonica-1.76.0
libgif 5.1.4 : libjpeg 6b (libjpeg-turbo 1.5.2) : libpng 1.6.36 : libtiff 4.0.9 : zlib 1.2.11 : libwebp 0.6.1 : libopenjp2 2.3.0
Found AVX
Found SSE
-v选项告诉tesseract工具除了打印版本信息外什么也不做。 我们可以看到已经安装了 Tesseract 4.0.0。 此消息中的单词leptonica是另一个图像处理库,被 Tesseract 用作默认图像处理库。 在我们的项目中,我们已经有了 Qt 和 OpenCV 来处理图像,因此我们可以忽略这些信息。
现在,让我们尝试使用此命令行工具从图像中提取文本。 下图准备作为输入:

这是图表上命令行工具性能的结果:

如您所见,在这种情况下,我们为命令行工具提供了许多参数:
- 第一个是输入图像。
- 第二个是输出。 我们使用
stdout告诉命令行工具将结果写入终端的标准输出。 我们可以在此处指定基本文件名; 例如,使用text-out将告诉工具将结果写入文本文件text-out.txt。 - 其余参数是选项。 我们使用
-l eng告诉我们要提取的文本是英语。
您可以看到 Tesseract 在我们的图中很好地识别了文本。
除了-l选项之外,Tesseract 命令行工具还有两个更重要的选项:--oem和--psm。
我们可以从其名称中猜测--oem选项,用于选择 OCR 引擎模式。 通过运行以下命令,我们可以列出 Tesseract 支持的所有 OCR 引擎模式:
$ ~/programs/tesseract/bin/tesseract --help-oem
OCR Engine modes:
0 Legacy engine only.
1 Neural nets LSTM engine only.
2 Legacy + LSTM engines.
3 Default, based on what is available.
在 Tesseract 4.0 中默认使用 LSTM 模式,并且在大多数情况下,它的性能都很好。 您可以通过在我们运行的命令末尾附加--oem 0来尝试使用旧版引擎以提取文本,您会发现旧版引擎在我们的图表上表现不佳。
--psm选项用于指定页面分割模式。 如果运行tesseract --help-psm,我们会发现 Tesseract 中有许多页面分割模式:
$ ~/programs/tesseract/bin/tesseract --help-psm
Page segmentation modes:
0 Orientation and script detection (OSD) only.
1 Automatic page segmentation with OSD.
2 Automatic page segmentation, but no OSD, or OCR.
3 Fully automatic page segmentation, but no OSD. (Default)
4 Assume a single column of text of variable sizes.
5 Assume a single uniform block of vertically aligned text.
6 Assume a single uniform block of text.
7 Treat the image as a single text line.
8 Treat the image as a single word.
9 Treat the image as a single word in a circle.
10 Treat the image as a single character.
11 Sparse text. Find as much text as possible in no particular order.
12 Sparse text with OSD.
13 Raw line. Treat the image as a single text line,
bypassing hacks that are Tesseract-specific.
如果要处理具有复杂排版的扫描文档,也许应该为其选择页面分割模式。 但是由于我们现在正在处理一个简单的图像,因此我们将忽略此选项。
到目前为止,我们已经成功安装了 Tesseract 库,并学习了如何使用其命令行工具从图像中提取文本。 在下一个小节中,我们将将此库集成到我们的应用 Literacy 中,以促进文本识别功能。
识别字符
我们的 Tesseract 库已经准备就绪,因此让我们使用它来识别 Literacy 应用中的字符。
我们应该做的第一件事是更新项目文件以合并与 Tesseract 库有关的信息:
# use your own path in the following config
unix: {
INCLUDEPATH += /home/kdr2/programs/tesseract/include
LIBS += -L/home/kdr2/programs/tesseract/lib -ltesseract
}
win32 {
INCLUDEPATH += c:/path/to/tesseract/include
LIBS += -lc:/path/to/opencv/lib/tesseract
}
DEFINES += TESSDATA_PREFIX=\\\"/home/kdr2/programs/tesseract/share/tessdata/\\\"
在前面的变更集中,我们为不同平台添加了 Tesseract 库的include路径和库路径,然后定义了一个宏TESSDATA_PREFIX,其值是 Tesseract 库的数据路径的路径。 稍后,我们将使用此宏将预训练的数据加载到我们的代码中。
然后,我们打开mainwindow.h头文件以添加一些新行:
#include "tesseract/baseapi.h"
class MainWindow : public QMainWindow
{
// ...
private slots:
// ...
void extractText();
private:
// ...
QAction *ocrAction;
// ...
tesseract::TessBaseAPI *tesseractAPI;
};
在此变更集中,我们首先添加include指令以包含 Tesseract 库的基本 API 头文件,然后向MainWindow类添加一个插槽和两个成员。
QAction *ocrAction成员将出现在主窗口的工具栏上。 触发此操作后,将调用新添加的插槽extractText,该插槽将使用tesseract::TessBaseAPI *tesseractAPI成员来识别已打开的图像中的字符。
现在,让我们看看这些事情在源文件mainwindow.cpp中如何发生。
在MainWindow类的构造器中,将成员字段tesseractAPI初始化为nullptr:
MainWindow::MainWindow(QWidget *parent) :
QMainWindow(parent)
, currentImage(nullptr)
, tesseractAPI(nullptr)
{
initUI();
}
在createActions方法中,我们创建ocrAction操作,将其添加到工具栏,然后将其triggered信号连接到新添加的extractText插槽:
void MainWindow::createActions()
{
// ...
ocrAction = new QAction("OCR", this);
fileToolBar->addAction(ocrAction);
// ...
connect(ocrAction, SIGNAL(triggered(bool)), this, SLOT(extractText()));
// ...
}
现在,剩下的就是最复杂,最重要的部分。 extractText插槽的实现:
void MainWindow::extractText()
{
if (currentImage == nullptr) {
QMessageBox::information(this, "Information", "No opened image.");
return;
}
char *old_ctype = strdup(setlocale(LC_ALL, NULL));
setlocale(LC_ALL, "C");
tesseractAPI = new tesseract::TessBaseAPI();
// Initialize tesseract-ocr with English, with specifying tessdata path
if (tesseractAPI->Init(TESSDATA_PREFIX, "eng")) {
QMessageBox::information(this, "Error", "Could not initialize tesseract.");
return;
}
QPixmap pixmap = currentImage->pixmap();
QImage image = pixmap.toImage();
image = image.convertToFormat(QImage::Format_RGB888);
tesseractAPI->SetImage(image.bits(), image.width(), image.height(),
3, image.bytesPerLine());
char *outText = tesseractAPI->GetUTF8Text();
editor->setPlainText(outText);
// Destroy used object and release memory
tesseractAPI->End();
delete tesseractAPI;
tesseractAPI = nullptr;
delete [] outText;
setlocale(LC_ALL, old_ctype);
free(old_ctype);
}
在方法主体的开头,我们检查currentImage成员字段是否为空。 如果为null,则在我们的应用中没有打开任何图像,因此我们在显示消息框后立即返回。
如果它不为null,那么我们将创建 Tesseract API 实例。 Tesseract 要求我们必须将语言环境设置为C,因此,首先,我们使用LC_ALL类别和空值调用setlocale函数,以获取并保存当前的语言环境设置,然后再次使用相同的类别和值C来设置 Tesseract 所需的语言环境。 OCR 工作完成后,我们将使用保存的LC_ALL值恢复语言环境设置。
在将区域设置设置为C之后,现在,我们可以创建 Tesseract API 实例。 我们使用表达式new tesseract::TessBaseAPI()创建它。 新创建的 API 实例必须在使用前进行初始化。 通过调用Init方法执行初始化。 Init方法有很多版本(重载):
// 1
int Init(const char* datapath, const char* language, OcrEngineMode mode,
char **configs, int configs_size,
const GenericVector<STRING> *vars_vec,
const GenericVector<STRING> *vars_values,
bool set_only_non_debug_params);
// 2
int Init(const char* datapath, const char* language, OcrEngineMode oem) {
return Init(datapath, language, oem, nullptr, 0, nullptr, nullptr, false);
}
// 3
int Init(const char* datapath, const char* language) {
return Init(datapath, language, OEM_DEFAULT, nullptr, 0, nullptr, nullptr, false);
}
// 4
int Init(const char* data, int data_size, const char* language,
OcrEngineMode mode, char** configs, int configs_size,
const GenericVector<STRING>* vars_vec,
const GenericVector<STRING>* vars_values,
bool set_only_non_debug_params, FileReader reader);
在其中一些版本中,我们可以指定预训练的数据路径,语言名称,OCR 引擎模式,页面分段模式以及许多其他配置。 为了简化代码,我们使用此方法的最简单版本(第三个版本)来初始化 API 实例。 在此调用中,我们仅传递数据路径和语言名称。 值得注意的是,数据路径由我们在项目文件中定义的宏表示。 初始化过程可能会失败,因此如果初始化失败,我们会在显示简短消息后检查其结果并立即返回。
准备好 Tesseract API 实例后,我们将获得当前打开的图像,并将其转换为QImage::Format_RGB888格式的图像,就像在先前项目中所做的那样。
获得具有RGB888格式的图像后,可以通过调用其SetImage方法将其提供给 Tesseract API 实例。 SetImage方法还具有许多不同的重载版本:
// 1
void SetImage(const unsigned char* imagedata, int width, int height,
int bytes_per_pixel, int bytes_per_line);
// 2
void SetImage(Pix* pix);
可以使用给定图像的数据格式信息调用第一个。 它不限于任何库定义的类,例如 Qt 中的QImage或 OpenCV 中的Mat。 第二个版本接受Pix指针作为输入图像。 Pix类由图像处理库 Leptonica 定义。 显然,这是最适合这种情况的第一个版本。 它需要的所有信息都可以从QImage实例中检索到,该实例具有 3 个通道,深度为 8 位。 因此,它为每个像素使用3字节:
tesseractAPI->SetImage(image.bits(), image.width(), image.height(),
3, image.bytesPerLine());
Tesseract API 实例获取图像后,我们可以调用其GetUTF8Text()方法来获取其从图像中识别的文本。 在这里值得注意的是,调用者有责任释放此方法的结果数据缓冲区。
剩下的任务是将提取的文本设置到编辑器小部件,销毁 Tesseract API 实例,删除GetUTF8Text调用的结果数据缓冲区,并恢复语言环境设置。
好。 让我们编译并重新启动我们的应用。 应用启动后,我们打开其中包含文本的图像,然后单击工具栏上的 OCR 操作。 然后,应用将使用从左侧图像中提取的文本填充编辑器:

如果对结果满意,可以通过单击文件菜单下的“将文本另存为”项将文本另存为文件。
到目前为止,我们的应用已经能够从作为书本或扫描文档的照片的图像中识别和提取文本。 对于这些图像,它们中仅包含具有良好排版的文本。 如果我们给应用提供包含许多不同元素的照片,而文字仅占据其中的一小部分,例如店面的照片或道路上的交通标志,则很可能无法识别字符。 我们可以用以下照片进行测试:

就像我们猜到的那样,我们的应用无法提取其中的文本。 为了处理这类图像,我们不应该只是将整个图像传递给 Tesseract。 我们还必须告诉 Tesseract,图像的哪个区域包含文本。 因此,在从此类图像中提取文本之前,我们必须首先检测该图像中的文本区域。 我们将在下一部分中使用 OpenCV 进行此操作。
使用 OpenCV 检测文本区域
在上一节中,我们成功地从带有排版好的文本的图像中提取了文本; 例如,扫描的文档。 但是,对于常见场景照片中的文字,我们的应用无法正常运行。 在本节中,我们将解决此应用问题。
在本节中,我们将使用带有 OpenCV 的 EAST 文本检测器来检测图像中是否存在文本。 EAST 是有效且准确的场景文本检测器的缩写,其描述可以在这个页面上找到。 它是基于神经网络的算法,但是其神经网络模型的架构和训练过程不在本章范围之内。 在本节中,我们将重点介绍如何使用 OpenCV 的 EAST 文本检测器的预训练模型。
在开始编写代码之前,让我们先准备好预训练的模型。 可以从这里下载 EAST 模型的预训练模型文件。。 让我们下载它并将其放置在我们项目的根目录中:
$ curl -O http://depot.kdr2.com/books/Qt-5-and-OpenCV-4-Computer-Vision-Projects/trained-model/frozen_east_text_detection.pb
# output omitted
$ ls -l
total 95176
-rw-r--r-- 1 kdr2 kdr2 96662756 Mar 22 17:03 frozen_east_text_detection.pb
-rwxr-xr-x 1 kdr2 kdr2 131776 Mar 22 17:30 Literacy
-rw-r--r-- 1 kdr2 kdr2 988 Mar 23 21:13 Literacy.pro
-rw-r--r-- 1 kdr2 kdr2 224 Mar 7 15:32 main.cpp
-rw-r--r-- 1 kdr2 kdr2 11062 Mar 23 21:13 mainwindow.cpp
-rw-r--r-- 1 kdr2 kdr2 1538 Mar 23 21:13 mainwindow.h
# output truncated
这很简单。 神经网络模式的准备工作已经完成。 现在,让我们继续进行代码。
需要更新的第一个文件是项目文件Literacy.pro。 就像前面的章节中一样,我们需要合并 OpenCV 库的设置:
# opencv config
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_dnn
}
unix: mac {
INCLUDEPATH += /path/to/opencv/include/opencv4
LIBS += -L/path/to/opencv/lib -lopencv_world
}
win32 {
INCLUDEPATH += c:/path/to/opencv/include/opencv4
LIBS += -lc:/path/to/opencv/lib/opencv_world
}
值得注意的是,我们将opencv_dnn模块添加到LIBS设置中。 因此,该模块中实际上是一个深度神经网络的 EAST 算法的实现。
我们要更新的下一个文件是头文件mainwindow.h。 我们在其中包含两个 OpenCV 头文件,然后在此文件的MainWindow类中添加一些字段和方法:
// ...
#include <QCheckBox>
// ...
#include "opencv2/opencv.hpp"
#include "opencv2/dnn.hpp"
class MainWindow : public QMainWindow
{
// ...
private:
// ...
void showImage(cv::Mat);
// ...
void decode(const cv::Mat& scores, const cv::Mat& geometry, float scoreThresh,
std::vector<cv::RotatedRect>& detections, std::vector<float>& confidences);
cv::Mat detectTextAreas(QImage &image, std::vector<cv::Rect>&);
// ...
private:
// ...
QCheckBox *detectAreaCheckBox;
// ...
cv::dnn::Net net;
};
让我们首先检查新添加的私有字段。 成员字段cv::dnn::Net net是一个深层神经网络实例,将用于检测文本区域。 detectAreaCheckBox字段是一个出现在工具栏上的复选框,允许用户向我们提供一个指示器,以确定在执行 Tesseract 的 OCR 工作之前是否应该检测文本区域。
detectTextAreas方法用于通过 OpenCV 检测区域,而decode方法构成辅助方法。 在检测到图像上的文本区域后,我们将为该图像上的每个文本区域绘制一个矩形。 所有这些步骤都是使用 OpenCV 完成的。 因此,该图像将表示为cv::Mat的实例,因此我们重载了showImage方法的另一个版本,该方法将cv::Mat的实例作为其唯一参数,以在 UI 上显示更新的图像。
现在,让我们转到mainwindow.cpp源文件以查看更改。
首先,让我们看看最重要的方法MainWindow::detectTextAreas。 按照预期,此方法将QImage对象作为输入图像作为其第一个参数。 它的第二个参数是对cv::Rect向量的引用,该向量用于保存检测到的文本区域。 该方法的返回值为cv::Mat,它表示在其上绘制了检测到的矩形的输入图像。 让我们在以下代码片段中查看其实现:
cv::Mat MainWindow::detectTextAreas(QImage &image, std::vector<cv::Rect> &areas)
{
float confThreshold = 0.5;
float nmsThreshold = 0.4;
int inputWidth = 320;
int inputHeight = 320;
std::string model = "./frozen_east_text_detection.pb";
// Load DNN network.
if (net.empty()) {
net = cv::dnn::readNet(model);
}
// more ...
}
代码是方法主体的第一部分。 在这一部分中,我们定义许多变量并创建一个深度神经网络。 前两个阈值用于置信度和非最大抑制。 我们将使用它们来过滤 AI 模型的检测结果。 EAST 模型要求图像的宽度和高度必须是 32 的倍数,因此我们定义了两个int变量,其值均为320。 在将输入图像发送到 DNN 模型之前,我们将其调整为这两个变量描述的尺寸,在这种情况下为320 x 320。
然后,使用已下载的预训练模型数据文件的路径定义一个字符串,并在类成员net为空的情况下调用cv::dnn::readNet函数来加载它。 OpenCV 的 DNN 支持多种预训练的模型数据文件:
*.caffemodel(Caffe)*.pb(TensorFlow)*.t7或*.net(Torch)*.weights(Darknet)*.bin(DLDT)
从前面的列表中,您可以确定我们使用的预训练模型是使用 TensorFlow 框架构建和训练的。
因此,将加载 DNN 模型。 现在,让我们将输入图像发送到模型以执行文本检测:
std::vector<cv::Mat> outs;
std::vector<std::string> layerNames(2);
layerNames[0] = "feature_fusion/Conv_7/Sigmoid";
layerNames[1] = "feature_fusion/concat_3";
cv::Mat frame = cv::Mat(
image.height(),
image.width(),
CV_8UC3,
image.bits(),
image.bytesPerLine()).clone();
cv::Mat blob;
cv::dnn::blobFromImage(
frame, blob,
1.0, cv::Size(inputWidth, inputHeight),
cv::Scalar(123.68, 116.78, 103.94), true, false
);
net.setInput(blob);
net.forward(outs, layerNames);
在这段代码中,我们定义了一个cv::Mat向量,以保存模型的输出层。 然后,将需要从 DNN 模型中提取的两层的名称放入字符串向量,即layerNames变量。 这两个层包含我们想要的信息:
- 第一层
feature_fusion/Conv_7/Sigmoid是 Sigmoid 激活的输出层。 该层中的数据包含给定区域是否包含文本的概率。 - 第二层
feature_fusion/concat_3是特征映射的输出层。 该层中的数据包含图像的几何形状。 通过稍后在此层中解码数据,我们将获得许多边界框。
之后,我们将输入图像从QImage转换为cv::Mat,然后将矩阵转换为另一个矩阵,该矩阵是一个 4 维 BLOB,可以用作 DNN 模型的输入,换句话说, 输入层。 后一种转换是通过在 OpenCV 库的cv::dnn名称空间中调用blobFromImage函数来实现的。 在此转换中执行许多操作,例如从中心调整大小和裁剪图像,减去平均值,通过比例因子缩放值以及交换 R 和 B 通道。 在对blobFromImage函数的调用中,我们有很多参数。 现在让我们一一解释:
- 第一个参数是输入图像。
- 第二个参数是输出图像。
- 第三个参数是每个像素值的比例因子。 我们使用 1.0,因为我们不需要在此处缩放像素。
- 第四个参数是输出图像的空间大小。 我们说过,此尺寸的宽度和高度必须是 32 的倍数,此处我们将
320 x 320与我们定义的变量一起使用。 - 第五个参数是应该从每个图像中减去的平均值,因为在训练模型时已使用了该平均值。 在此,使用的平均值为
(123.68, 116.78, 103.94)。 - 下一个参数是我们是否要交换 R 和 B 通道。 这是必需的,因为 OpenCV 使用 BGR 格式,而 TensorFlow 使用 RGB 格式。
- 最后一个参数是我们是否要裁剪图像并进行中心裁剪。 在这种情况下,我们指定
false。
在该调用返回之后,我们得到了可用作 DNN 模型输入的 Blob。 然后,将其传递给神经网络,并通过调用模型的setInput方法和forward方法执行一轮转发以获取输出层。 转发完成后,我们想要的两个输出层将存储在我们定义的outs向量中。 下一步是处理这些输出层以获取文本区域:
cv::Mat scores = outs[0];
cv::Mat geometry = outs[1];
std::vector<cv::RotatedRect> boxes;
std::vector<float> confidences;
decode(scores, geometry, confThreshold, boxes, confidences);
std::vector<int> indices;
cv::dnn::NMSBoxes(boxes, confidences, confThreshold, nmsThreshold, indices);
outs向量的第一个元素是得分,而第二个元素是几何形状。 然后,我们调用MainWindow类的另一种方法decode,以解码文本框的位置及其方向。 通过此解码过程,我们将候选文本区域作为cv::RotatedRect并将其存储在boxes变量中。 这些框的相应置信度存储在confidences变量中。
由于我们可能会为文本框找到许多候选对象,因此我们需要过滤掉外观最好的文本框。 这是使用非最大抑制来完成的,即对NMSBoxes方法的调用。 在此调用中,我们给出解码后的框,置信度以及置信度和非最大值抑制的阈值,未消除的框的索引将存储在最后一个参数indices中。
decode方法用于从输出层提取置信度和框信息。 可以在这个页面中找到其实现。 要理解它,您应该了解 DNN 模型中的数据结构,尤其是输出层中的数据结构。 但是,这超出了本书的范围。 如果您对此感兴趣,可以在这个页面上参阅与 EAST 有关的论文,并在这个页面上使用 Tensorflow 来实现它的一种实现。
现在,我们将所有文本区域作为cv::RotatedRect的实例,并且这些区域用于调整大小的图像,因此我们应该将它们映射到原始输入图像上:
cv::Point2f ratio((float)frame.cols / inputWidth, (float)frame.rows / inputHeight);
cv::Scalar green = cv::Scalar(0, 255, 0);
for (size_t i = 0; i < indices.size(); ++i) {
cv::RotatedRect& box = boxes[indices[i]];
cv::Rect area = box.boundingRect();
area.x *= ratio.x;
area.width *= ratio.x;
area.y *= ratio.y;
area.height *= ratio.y;
areas.push_back(area);
cv::rectangle(frame, area, green, 1);
QString index = QString("%1").arg(i);
cv::putText(
frame, index.toStdString(), cv::Point2f(area.x, area.y - 2),
cv::FONT_HERSHEY_SIMPLEX, 0.5, green, 1
);
}
return frame;
为了将文本区域映射到原始图像,我们应该知道在将图像发送到 DNN 模型之前如何调整图像大小,然后逆转文本区域的大小调整过程。 因此,我们根据宽度和高度方面计算尺寸调整率,然后将它们保存到cv::Point2f ratio中。 然后,我们迭代保留的索引,并获得每个索引指示的每个cv::RotatedRect对象。 为了降低代码的复杂性,我们无需将cv::RotatedRect及其内容旋转为规则矩形,而是简单地获取其边界矩形。 然后,我们对矩形进行反向调整大小,然后将其推入areas向量。 为了演示这些区域的显示方式,我们还将它们绘制在原始图像上,并在每个矩形的右上角插入一个数字,以指示它们将被处理的顺序。
在方法的最后,我们返回更新的原始图像。
现在我们已经完成了文本区域检测方法,让我们将其集成到我们的应用中。
首先,在initUI方法中,我们创建用于确定在执行 OCR 之前是否应该检测文本区域的复选框,并将其添加到文件工具栏中:
detectAreaCheckBox = new QCheckBox("Detect Text Areas", this);
fileToolBar->addWidget(detectAreaCheckBox);
然后,在MainWindow::extractText方法中,将整个图像设置为 Tesseract API 之后,我们检查该复选框的状态:
tesseractAPI->SetImage(image.bits(), image.width(), image.height(),
3, image.bytesPerLine());
if (detectAreaCheckBox->checkState() == Qt::Checked) {
std::vector<cv::Rect> areas;
cv::Mat newImage = detectTextAreas(image, areas);
showImage(newImage);
editor->setPlainText("");
for(cv::Rect &rect : areas) {
tesseractAPI->SetRectangle(rect.x, rect.y, rect.width, rect.height);
char *outText = tesseractAPI->GetUTF8Text();
editor->setPlainText(editor->toPlainText() + outText);
delete [] outText;
}
} else {
char *outText = tesseractAPI->GetUTF8Text();
editor->setPlainText(outText);
delete [] outText;
}
如您所见,如果选中此复选框,我们将调用detextTextAreas方法来检测文本区域。 调用返回该图像作为cv::Mat的实例,并在其上绘制了文本区域和索引,然后调用带有该图像的showImage方法以将其显示在窗口上。 然后,我们遍历文本区域,并通过调用其SetRectangle方法将其发送到 Tesseract API,以告知它仅尝试识别此矩形内的字符。 然后,我们获得识别的文本,将其添加到编辑器中,并释放文本的存储空间。
如果未选中该复选框,则我们将应用长期存在的逻辑。 让 Tesseract 识别整个图像中的文本。
我们还在这里对我们的代码进行了小的优化。 由于 Tesseract API 实例可以重复使用,因此我们只需创建和初始化一次即可:
if (tesseractAPI == nullptr) {
tesseractAPI = new tesseract::TessBaseAPI();
// Initialize tesseract-ocr with English, with specifying tessdata path
if (tesseractAPI->Init(TESSDATA_PREFIX, "eng")) {
QMessageBox::information(this, "Error", "Could not initialize tesseract.");
return;
}
}
然后在MainWindow类的析构器中销毁它:
MainWindow::~MainWindow()
{
// Destroy used object and release memory
if(tesseractAPI != nullptr) {
tesseractAPI->End();
delete tesseractAPI;
}
}
因此,剩下要做的最后一件事就是实现重载的showImage方法。 由于我们已经在图像格式转换和使用 Qt 显示图像方面做了很多工作,这对我们来说确实是小菜一碟:
void MainWindow::showImage(cv::Mat mat)
{
QImage image(
mat.data,
mat.cols,
mat.rows,
mat.step,
QImage::Format_RGB888);
QPixmap pixmap = QPixmap::fromImage(image);
imageScene->clear();
imageView->resetMatrix();
currentImage = imageScene->addPixmap(pixmap);
imageScene->update();
imageView->setSceneRect(pixmap.rect());
}
好。 最后,我们可以编译并运行我们的应用以对其进行测试:
$ make
# output omitted
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib:/home/kdr2/programs/tesseract/lib
$ ./Literacy
让我们使用应用打开包含文本的照片,取消选中“检测文本区域”复选框,然后单击“OCR”按钮。 将看到以下不良结果:

然后,我们选中该复选框并再次单击OCR按钮,看看会发生什么:

正确检测到四个文本区域,并且正确识别了其中三个文本。 不错!
识别屏幕上的字符
在前面的部分中,我们结束了对 Literacy 应用几乎所有功能的讨论。 在本节中,为了改善应用的用户体验,我们将添加一项功能,以允许用户抓住屏幕的一部分作为应用的输入图像。 使用此功能,用户可以单击鼠标按钮,然后将其拖动以选择屏幕的矩形区域作为图像。 然后,他们可以将图像另存为文件或对其执行 OCR。
我们将创建一个新类来实现此功能。 新类称为ScreenCapturer,并且在头文件screencapturer.h中定义:
class ScreenCapturer : public QWidget {
Q_OBJECT
public:
explicit ScreenCapturer(MainWindow *w);
~ScreenCapturer();
protected:
void paintEvent(QPaintEvent *event) override;
void mouseMoveEvent(QMouseEvent *event) override;
void mousePressEvent(QMouseEvent *event) override;
void mouseReleaseEvent(QMouseEvent *event) override;
private slots:
void closeMe();
void confirmCapture();
private:
void initShortcuts();
QPixmap captureDesktop();
private:
MainWindow *window;
QPixmap screen;
QPoint p1, p2;
bool mouseDown;
};
省略include指令后,类定义非常清晰。 它是QWidget的子类,并且在类主体的开头具有Q_OBJECT宏。 我们在此类中定义了许多成员字段:
MainWindow *window是一个指向我们应用主窗口对象的指针。 抓取图像时,我们将调用其showImage方法以显示抓取的图像。QPixmap screen用于存储整个屏幕或多个屏幕的图像。QPoint p1, p2是所选矩形的右上角和左下角点。bool mouseDown是用于指示是否按下鼠标按钮的标志; 也就是说,如果用户正在拖动或移动鼠标。
该类中还有许多方法。 让我们先来看一下它的构造器和析构器。 其构造器将指向主窗口对象的指针作为其参数:
ScreenCapturer::ScreenCapturer(MainWindow *w):
QWidget(nullptr), window(w)
{
setWindowFlags(
Qt::BypassWindowManagerHint
| Qt::WindowStaysOnTopHint
| Qt::FramelessWindowHint
| Qt::Tool
);
setAttribute(Qt::WA_DeleteOnClose);
screen = captureDesktop();
resize(screen.size());
initShortcuts();
}
在构造器的实现中,我们使用空指针调用其父类构造器,并使用唯一参数初始化window成员。 在方法主体中,我们为小部件设置了许多标志:
Qt::BypassWindowManagerHint告诉它忽略窗口管理器的布置。Qt::WindowStaysOnTopHint告诉它保持在桌面的最顶层。Qt::FramelessWindowHint使窗口小部件没有标题栏或窗口边框。Qt::Tool指示窗口小部件是工具窗口。
有了这些标志,我们的小部件将成为一个无边界的工具窗口,始终位于桌面的顶层。
Qt::WA_DeleteOnClose属性可确保在关闭小部件实例后将其删除。
设置完所有标志和属性后,我们调用captureDesktop()方法将整个桌面作为一个大图像捕获,并将其分配给screen成员字段。 然后,我们将小部件的大小调整为大图像的大小,并调用initShortcuts设置一些热键。
我们在这里省略了析构器,因为它无关紧要。 因此,它只有一个空的方法主体。 现在,让我们转到captureDesktop方法,看看如何将整个桌面作为一个大图像来抓取:
QPixmap ScreenCapturer::captureDesktop() {
QRect geometry;
for (QScreen *const screen : QGuiApplication::screens()) {
geometry = geometry.united(screen->geometry());
}
QPixmap pixmap(QApplication::primaryScreen()->grabWindow(
QApplication::desktop()->winId(),
geometry.x(),
geometry.y(),
geometry.width(),
geometry.height()
));
pixmap.setDevicePixelRatio(QApplication::desktop()->devicePixelRatio());
return pixmap;
}
一个桌面可能有多个屏幕,因此我们可以通过QGuiApplication::screens()获得所有这些屏幕,并将它们的几何形状组合成一个大矩形。 然后,我们通过QApplication::desktop()->winId()获得桌面小部件的 ID(也称为根窗口),并将桌面根窗口作为QPixmap的实例。 由于我们将组合矩形的位置和大小传递给grabWIndow函数,因此将抓取包括所有屏幕在内的整个桌面。 最后,我们将图像的设备像素比率设置为适合本地设备的像素比率,然后将其返回。
现在我们知道了小部件的构造方式以及在构造过程中如何抓取桌面,接下来的事情是在小部件上显示抓取的图像。 这是通过覆盖其paintEvent方法来完成的:
void ScreenCapturer::paintEvent(QPaintEvent*) {
QPainter painter(this);
painter.drawPixmap(0, 0, screen);
QRegion grey(rect());
painter.setClipRegion(grey);
QColor overlayColor(20, 20, 20, 50);
painter.fillRect(rect(), overlayColor);
painter.setClipRect(rect());
}
每当小部件需要更新自身时,都会调用此方法paintEvent; 例如,当它打开,调整大小或移动时。 在此方法中,我们定义了QPainter方法,然后使用它在小部件上绘制抓取的图像。 由于抓取的图像看起来与桌面完全相同,因此用户可能没有意识到我们正在显示抓取的图像。 为了告诉用户他们面对的只是一个抓取的图像而不是桌面,我们在抓取的图像的顶部绘制了一个半透明的灰色覆盖层。
然后,我们应该找出一种方法,允许用户选择所抓取图像的区域。 这是通过覆盖三个鼠标事件处理器来完成的:
mousePressEvent,当按下鼠标按钮时调用mouseMoveEvent,当鼠标移动时调用mouseReleaseEvent,当释放按下的鼠标按钮时调用
当按下鼠标按钮时,我们会将其按下的位置保存到成员p1和p2中,将mouseDown标志标记为true,然后调用update告诉小部件重新绘制自身:
void ScreenCapturer::mousePressEvent(QMouseEvent *event)
{
mouseDown = true;
p1 = event->pos();
p2 = event->pos();
update();
}
当鼠标移动时,我们检查mouseDown标志。 如果为true,则用户正在拖动鼠标,因此我们将鼠标的当前位置更新为成员字段p2,然后调用update重新绘制小部件:
void ScreenCapturer::mouseMoveEvent(QMouseEvent *event)
{
if(!mouseDown) return;
p2 = event->pos();
update();
}
释放按下的鼠标按钮时,事情很简单:
void ScreenCapturer::mouseReleaseEvent(QMouseEvent *event)
{
mouseDown = false;
p2 = event->pos();
update();
}
我们将mouseDown标志标记为false,将事件位置保存到p2,然后更新小部件。
使用这三个事件处理器,当用户拖动鼠标时,我们可以得到一个由连续更新的点p1和p2确定的矩形。 该矩形是选择区域。 现在,让我们在paintEvent方法中将此矩形绘制到小部件:
void ScreenCapturer::paintEvent(QPaintEvent*) {
QPainter painter(this);
painter.drawPixmap(0, 0, screen);
QRegion grey(rect());
if(p1.x() != p2.x() && p1.y() != p2.y()) {
painter.setPen(QColor(200, 100, 50, 255));
painter.drawRect(QRect(p1, p2));
grey = grey.subtracted(QRect(p1, p2));
}
painter.setClipRegion(grey);
QColor overlayColor(20, 20, 20, 50);
painter.fillRect(rect(), overlayColor);
painter.setClipRect(rect());
}
如您所见,在方法主体中,我们添加了四行代码。 我们检查p1和p2是否相同。 如果不是,则绘制由p1和p2确定的矩形的边界,并从绘制半透明灰色叠加层的区域中减去该矩形。 现在,如果用户拖动鼠标,他们将看到选定的矩形。
现在,用户可以打开全屏窗口小部件并选择从整个桌面获取的图像区域。 之后,用户将要使用选择或放弃操作。 这些由插槽confirmCapture和closeMe实现:
void ScreenCapturer::confirmCapture()
{
QPixmap image = screen.copy(QRect(p1, p2));
window->showImage(image);
closeMe();
}
void ScreenCapturer::closeMe()
{
this->close();
window->showNormal();
window->activateWindow();
}
在confirmCapture插槽中,我们将选定的矩形复制到整个桌面的抓取图像中作为新图像,然后使用它调用主窗口的showImage方法来显示并使用它。 最后,我们调用closeMe关闭窗口小部件窗口。
在closeMe插槽中,除了关闭当前窗口小部件窗口并恢复主窗口的状态外,我们什么也不做。
然后,我们将这些插槽连接到initShortcuts方法中的一些热键:
void ScreenCapturer::initShortcuts() {
new QShortcut(Qt::Key_Escape, this, SLOT(closeMe()));
new QShortcut(Qt::Key_Return, this, SLOT(confirmCapture()));
}
如您所见,如果用户按下键盘上的Esc键,我们将关闭小部件,如果用户按下Enter键,我们将使用用户的选择作为输入应用的图像并关闭捕获窗口小部件。
现在,屏幕捕获小部件已完成,因此让我们将其集成到主窗口中。 让我们看一下头文件mainwindow.h中的更改:
class MainWindow : public QMainWindow
{
// ...
public:
// ...
void showImage(QPixmap);
// ...
private slots:
// ...
void captureScreen();
void startCapture();
private:
// ..
QAction *captureAction;
// ...
};
首先,我们添加采用QPixmap对象的showImage方法的另一个版本。 屏幕捕获窗口小部件使用它。 由于我们已经有很多此方法的版本,因此留给读者来实现。 如有必要,您可以参考随附的代码存储库。
然后,我们添加两个插槽和一个操作。 我们创建动作并将其添加到createActions方法中的工具栏中:
captureAction = new QAction("Capture Screen", this);
fileToolBar->addAction(captureAction);
// ...
connect(captureAction, SIGNAL(triggered(bool)), this, SLOT(captureScreen()));
在前面的代码中,我们将新添加的动作连接到captureScreen插槽,因此让我们看一下该插槽的实现:
void MainWindow::captureScreen()
{
this->setWindowState(this->windowState() | Qt::WindowMinimized);
QTimer::singleShot(500, this, SLOT(startCapture()));
}
在此时隙中,我们最小化主窗口,然后在 0.5 秒后安排对startCapture时隙的调用。 在startCapture插槽中,我们将创建一个屏幕截图小部件实例,将其打开,然后将其激活:
void MainWindow::startCapture()
{
ScreenCapturer *cap = new ScreenCapturer(this);
cap->show();
cap->activateWindow();
}
在这里,我们使用另一个插槽并将其安排在较短的时间后,因为如果立即执行此操作,则在捕获屏幕时不会完成主窗口的最小化。
现在,只有一件事要做,我们可以编译和运行我们的项目。 更新项目文件并合并新的源文件:
HEADERS += mainwindow.h screencapturer.h
SOURCES += main.cpp mainwindow.cpp screencapturer.cpp
现在,让我们编译并启动我们的应用。 单击工具栏上的“捕获屏幕”按钮,然后拖动鼠标以选择如下区域:

此时,您可以按Esc键取消选择,或按Enter键确认选择。 如果按Enter键,然后单击工具栏上的OCR按钮,我们的应用将显示如下:

好! 它完全按照我们的期望工作,我们的应用终于完成了。
总结
在本章中,我们创建了一个名为 Literacy 的新应用。 在此应用中,我们使用 Tesseract 库识别图像上的字符。 对于具有良好排版字符的图像,Tesseract 效果很好; 但是对于日常生活中照片中的人物,它无法识别它们。 为了解决此问题,我们使用带有 OpenCV 的 EAST 模型。 使用预先训练的 EAST 模型,我们首先检测照片中的文本区域,然后指示 Tesseract 库仅识别检测到的区域中的字符。 此时,Tesseract 再次表现良好。 在上一节中,我们学习了如何将桌面作为图像获取,以及如何通过拖动鼠标在桌面上选择区域。
在本章,前几章中,我们使用了几个预训练的神经网络模型。 在下一章中,我们将进一步了解它们。 例如,如何使用预训练的分类器或模型来检测对象以及如何训练模型。
问题
尝试这些问题以测试您对本章的了解:
- Tesseract 如何识别非英语语言的字符?
- 当我们使用 EAST 模型检测文本区域时,检测到的区域实际上是旋转的矩形,而我们只是使用它们的边界矩形。 这总是对的吗? 如果没有,如何解决?
- 是否可以找到一种方法,允许用户在从屏幕上捕获图像时拖动鼠标后调整所选区域?
六、实时对象检测
在上一章中,我们了解了光学字符识别(OCR)技术。 我们借助 Tesseract 库和预训练的深度学习模型(EAST 模型)来识别扫描文档和照片中的文本,该模型已随 OpenCV 一起加载。 在本章中,我们将继续进行对象检测这一主题。 我们将讨论 OpenCV 以及其他库和框架提供的几种对象检测方法。
本章将涵盖以下主题:
- 训练和使用级联分类器检测对象
- 使用深度学习模型进行对象检测
技术要求
与前面的章节一样,要求读者至少安装版本 5 和 OpenCV 4.0.0 的 Qt。 具备一些有关 C++ 和 Qt 编程的基本知识也是一个基本要求。
尽管我们专注于 OpenCV 4.0.0,但在本章中还需要 OpenCV3.4.x。 您应该已经安装了多个版本的 OpenCV(4.0.0 和 3.4.5),才能与本章一起学习。 稍后我将解释原因。
由于我们将使用深度学习模型来检测对象,因此拥有深度学习知识也将有助于理解本章的内容。
使用 OpenCV 检测对象
OpenCV 中有许多方法可以进行对象检测。 这些方法可以分类如下:
- 基于颜色的算法,例如均值移位和连续自适应均值移位(CAMshift)
- 模板匹配
- 特征提取与匹配
- 人工神经网络(人工神经网络)
- 级联分类器
- 预先训练的深度学习模型
前三个是传统的对象检测方法,后三个是机器学习方法。
基于颜色的算法(例如均值偏移和 CAMshift)使用直方图和反投影图像以惊人的速度在图像中定位对象。 模板匹配方法将感兴趣的对象用作模板,并尝试通过扫描给定场景的图像来找到对象。 特征提取和匹配方法首先从感兴趣的对象和场景图像中提取所有特征,通常是边缘特征和角点特征,然后使用这些特征进行匹配以找到对象。 所有这些方法在简单和静态的场景中都能很好地工作,并且非常易于使用。 但是它们通常无法在复杂而动态的情况下正常工作。
ANN,级联分类器和深度学习方法被归类为机器学习方法。 他们都需要在使用之前训练模型。 借助 OpenCV 提供的功能,我们可以训练 ANN 模型或级联分类器模型,但目前尚无法使用 OpenCV 训练深度学习模型。 下表显示了这些方法是否可以与 OpenCV 库一起训练或使用,以及它们的表现(在查全率和准确率上)水平:
| 方法 | 可以由 OpenCV 训练 | 可以由 OpenCV 加载 | 效果 |
|---|---|---|---|
| 人工神经网络 | 是 | 是 | 中 |
| 级联分类器 | 是 | 是 | 中 |
| 深度学习模型 | 没有 | 是(多种格式) | 高 |
实际上,人工神经网络和深度学习都是神经网络。 它们之间的区别在于,ANN 模型具有简单的架构,并且只有很少的隐藏层,而深度学习模型可能具有复杂的架构(例如 LSTM,RNN,CNN 等)以及大量的隐藏层 。 在上个世纪,人们使用人工神经网络是因为它们没有足够的计算能力,因此不可能训练复杂的神经网络。 现在,由于过去十年中异构计算的发展,训练复杂的神经网络成为可能。 如今,我们使用深度学习模型是因为它们比简单的 ANN 模型具有更高的表现(在召回率和准确率方面)。
在本章中,我们将重点介绍级联分类器和深度学习方法。 尽管无法使用当前版本的 OpenCV 库训练深度学习模型,但将来可能会实现。
使用级联分类器检测对象
首先,让我们看看如何使用级联分类器检测对象。 实际上,本书已经使用了级联分类器。 在第 4 章,“面部表情”中,我们使用了预训练的级联分类器来实时检测面部。 我们使用的预训练级联分类器是 OpenCV 内置级联分类器之一,可以在 OpenCV 安装的数据目录中找到:
$ ls ~/programs/opencv/share/opencv4/haarcascades/
haarcascade_eye_tree_eyeglasses.xml haarcascade_lefteye_2splits.xml
haarcascade_eye.xml haarcascade_licence_plate_rus_16stages.xml
haarcascade_frontalcatface_extended.xml haarcascade_lowerbody.xml
haarcascade_frontalcatface.xml haarcascade_profileface.xml
haarcascade_frontalface_alt2.xml haarcascade_righteye_2splits.xml
haarcascade_frontalface_alt_tree.xml haarcascade_russian_plate_number.xml
haarcascade_frontalface_alt.xml haarcascade_smile.xml
haarcascade_frontalface_default.xml haarcascade_upperbody.xml
haarcascade_fullbody.xml
如您所见,haarcascade_frontalface_default.xml文件是我们在第 4 章,面对人脸中使用的文件。
在本章中,我们将尝试自己训练级联分类器。 在此之前,我们将首先构建一个应用来测试级联分类器。 我将这个应用称为 Detective。
该应用与我们在第 3 章,“家庭安全应用”(Gazer 应用)和第 4 章中内置的 FacesFaces(Facetious)应用非常相似 ,因此我们将通过应对其中一种应用来快速构建它。
您还记得我们在第 4 章,“人脸上的乐趣”开头所做的事情吗? 我们从第 3 章,“家庭安全应用”复制了 Gazer 应用,然后将其简化为一个基本应用,可以使用该应用播放网络摄像头中的视频供稿并拍照。 我们可以在这个页面上找到该提交的基本应用。 让我们将其复制到终端中:
$ pwd
/home/kdr2/Work/Books/Qt-5-and-OpenCV-4-Computer-Vision-Projects
$ git checkout 744d445
Note: checking out '744d445'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by performing another checkout.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -b with the checkout command again. Example:
git checkout -b <new-branch-name>
HEAD is now at 744d445 Facetious: take photos
$ mkdir Chapter-06
# !!! you should copy it to a different dir
$ cp -r Chapter-04/Facetious Chapter-06/Detective
$ ls Chapter-06
Detective
$git checkout master
$ cd Chapter-06/Detective/
在我们的代码存储库中,我们签出到744d445提交,为此章创建一个新目录,然后将该版本的 Facetious 项目的源树复制到Chapter-06目录下一个名为Detective的新目录中。然后,我们切换回master分支。
在编写本书时,我已将基本应用复制到Chapter-06/Detective目录,因此在您阅读本书时该目录已经存在。 如果您按照说明进行编码,则可以将基本应用复制到另一个新目录并在该目录下工作。
获得基本应用后,我们对其进行一些小的更改:
- 将
Facetious.pro项目文件重命名为Detective.pro。 - 在以下文件中将单词 Facetious 更改为 Detective:
Detective.promain.cppmainwindow.cpputilities.cpp
好的,现在我们有一个基本的侦探应用。 此阶段中的所有更改都可以在这个页面的提交中找到。
接下来的事情是使用预训练的级联分类器检测某种对象。 这次,我们将使用 OpenCV 库中包含的haarcascade_frontalcatface_extended.xml文件来检测猫的脸。
首先,我们打开capture_thread.h文件以添加一些行:
class CaptureThread : public QThread
{
// ...
private:
// ...
void detectObjects(cv::Mat &frame);
private:
// ...
// object detection
cv::CascadeClassifier *classifier;
};
然后,在capture_thread.cpp文件中,我们实现detectObjects方法,如下所示:
void CaptureThread::detectObjects(cv::Mat &frame)
{
vector<cv::Rect> objects;
classifier->detectMultiScale(frame, objects, 1.3, 5);
cv::Scalar color = cv::Scalar(0, 0, 255); // red
// draw the circumscribe rectangles
for(size_t i = 0; i < objects.size(); i++) {
cv::rectangle(frame, objects[i], color, 2);
}
}
在这种方法中,我们通过调用级联分类器的detectMultiScale方法检测对象,然后在图像上绘制检测到的矩形,就像在第 4 章,“人脸上的乐趣”中所做的一样。
接下来,我们在run方法中实例化级联分类器,并在视频捕获无限循环中调用detectObjects方法:
void CaptureThread::run() {
// ...
classifier = new cv::CascadeClassifier(OPENCV_DATA_DIR \
"haarcascades/haarcascade_frontalcatface_extended.xml");
// ...
while(running) {
// ...
detectObjects(tmp_frame);
// ...
}
// ...
delete classifier;
classifier = nullptr;
running = false;
}
如您所见,在无限循环结束后,我们还将销毁级联分类器。
我们更新Detective.pro项目文件,将opencv_objdetect模块添加到链接选项,然后定义OPENCV_DATA_DIR宏:
# ...
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_video -lopencv_videoio -lopencv_objdetect
}
# ...
DEFINES += OPENCV_DATA_DIR=\\\"/home/kdr2/programs/opencv/share/opencv4/\\\"
# ...
现在我们编译并运行该应用,打开相机,然后将一只猫放到相机的视线中:

也许你没有猫。 不用担心 cv::VideoCapture类提供了许多其他测试应用的方法。
您可以找到猫的视频并将其放置在本地磁盘上,然后将其路径传递到cv::VideoCapture的结构,而不传递摄像机 ID(例如cv::VideoCapture cap("/home/kdr2/Videos/cats.mp4"))。 构造器还接受视频流的 URI,例如http://some.site.com/some-video.mp4或rtsp://user:password@192.168.1.100:554/camera/0?channel=1。
如果您有很多猫的图片,那也可以。 cv::VideoCapture类的构造器还接受由字符串表示的图像序列。 例如,如果将"image_%02d.jpg"字符串传递给构造器,则cv::VideoCapture实例将读取名称类似于image_00.jpg,image_01.jpg和image_02.jpg等的图像,然后逐一显示为视频帧。
好的,我们已经设置了 Detective 应用,以使用预训练的层叠分类器检测对象。 在下一节中,我们将尝试自己训练级联分类器。 当获得自训练的级联分类器文件时,我们可以将其路径传递给cv::CascadeClassifier类的构造器以加载它,并更改cv::VideoCapture的输入以对其进行测试。
训练级联分类器
OpenCV 提供了一些工具来训练级联分类器,但它们已从 4.0.0 版本中删除。 如我们所提到的,这种删除主要是由于深度学习方法的兴起。 深度学习方法成为现代方法,而其他方法(包括级联分类器)则成为了传统。 但是,世界上仍在使用许多级联分类器,在许多情况下,它们仍然是一个不错的选择。 这些工具可能有一天会重新添加。 您可以在这个页面上找到并参与有关此主题的讨论。
幸运的是,我们可以使用 OpenCV v3.4.x,它提供了这些工具来训练级联分类器。 由 v3.4 训练的结果级联分类器文件与 v4.0.x 兼容。 换句话说,我们可以使用 OpenCV v3.4.x 训练级联分类器,并将其与 OpenCV v4.0.x 一起使用。
首先,我们应该安装 OpenCVv3.4.x。 我们可以使用系统包管理器来安装它,例如yum或apt-get。 我们也可以从这里下载它并从源代码构建它。 如果决定从源代码构建它,请记住将-D BUILD_opencv_apps=yes选项传递给cmake命令。 否则,将无法构建训练工具。 安装后,我们可以在其二进制目录下找到许多可执行文件:
$ ls ~/programs/opencv-3.4.5/bin/
opencv_annotation opencv_createsamples
opencv_interactive-calibration opencv_traincascade
opencv_version opencv_visualisation
setup_vars_opencv3.sh
我们将用来训练级联分类器的工具是opencv_createsamples,opencv_traincascade,有时是opencv_annotation。
opencv_createsamples和opencv_annotation工具用于创建样本,opencv_traincascade工具用于使用创建的样本训练级联分类器。
在训练级联分类器之前,我们必须准备两种样本:正样本和负样本。 正样本应包含我们要检测的对象,而负样本应包含除我们要检测的对象以外的所有内容。 可以通过 OpenCV 提供的工具opencv_createsamples生成正样本。 没有任何工具可以生成负样本,因为负样本可以是任何不包含我们要检测的对象的任意图像。
我们如何准备或产生阳性样本? 让我们看一些例子。
禁止进入的交通标志
在此示例中,我们将训练级联分类器,该分类器将用于检测交通标志,即禁止进入标志:

首先要做的是准备负样本-背景图像。 如前所述,负样本可以是不包含感兴趣对象的任意图像,因此我们可以轻松地为此目的收集一些图像。 收集这些图像后,我们将其路径放在文本文件中。 在该文件中,路径以先列后行格式一个路径,并且该路径可以是绝对路径或相对路径。
在本章中,我们将交替使用短语负面样本图像和背景图像,因为它们在上下文中是相同的。
您可以从这里下载许多交通照片,并选择其中一些不包含任何禁止进入的照片用作背景图像的标志:

我们将它们放在名为background的文件夹中,并将它们的相对路径保存到名为bg.txt的文件中:
$ ls background/
traffic-sign-bg-0.png traffic-sign-bg-1.png traffic-sign-bg-2.png traffic-sign-bg-3.png
$ ls background/* > bg.txt
$ cat bg.txt
background/traffic-sign-bg-0.png
background/traffic-sign-bg-1.png
background/traffic-sign-bg-2.png
background/traffic-sign-bg-3.png
这些图像可以具有不同的大小。 但是它们都不应该小于训练窗口的大小。 通常,训练窗口的大小是我们感兴趣的对象的平均大小,即禁止进入标志的图像。 这是因为将从这些背景图像中获取具有训练窗口大小作为其维度的负样本。 如果背景图像小于示例图像,则无法执行此操作。
好,负片图像已准备就绪。 让我们继续进行阳性样本的制备。
如前所述,我们将使用opencv_createsamples工具生成正样本。 正样本将用于训练过程中,以告诉级联分类器感兴趣的对象实际是什么样。
为了创建阳性样本,我们将感兴趣的对象(非进入符号)保存为名为no-entry.png的文件,位于background文件夹和bg.txt文件所在的目录中。 然后我们按以下方式调用opencv_createsamples工具:
opencv_createsamples -vec samples.vec -img no-entry.png -bg bg.txt \
-num 200 -bgcolor 0 -bgthresh 20 -maxidev 30 \
-maxxangle 0.3 -maxyangle 0.3 -maxzangle 0.3 \
-w 32 -h 32
如您所见,在运行该工具时,我们提供了许多参数,这似乎很可怕。 但是不用担心。 我们将一一解释:
-vec参数用于指定我们要创建的正样本文件。 在本例中,我们使用samples.vec作为文件名。-img参数用于指定我们要检测的对象的图像。 该工具将使用它来生成阳性样本。 正如我们所提到的,在我们的例子中是no-entry.png。-bg参数用于指定背景图像的描述文件。 我们的是bg.txt,其中包含四个选定背景的相对路径。-num参数是要生成的正样本数。-bgcolor自变量用于指定感兴趣对象的图像的背景色。 背景色表示透明色,在生成样本时将被视为透明色。 我们在这里使用的感兴趣图像的背景是黑色,因此在这里使用零。 在某些情况下,例如,当图像上出现压缩伪像时,给定图像的背景色将具有多种颜色,而不是单一的颜色值。 为了应对这种情况,还有一个名为-bgthresh的参数指定背景的颜色容忍度。 如果指定此参数,则颜色在bgcolor - bgthresh和bgcolor + bgthresh之间的像素将被解释为透明的。- 正如我们提到的,
-bgthresh参数指定bgcolor的阈值。 -maxidev自变量用于设置生成样本时前景像素值的最大强度偏差。 值 30 表示前景像素的强度可以在其原始值 +30 和其原始值 -30 之间变化。-maxxangle,-maxyangle和-maxzangle自变量对应于创建新样本时,x,y和z方向所允许的最大可能旋转。 这些值以弧度为单位。 在这里,我们使用 0.3、0.3 和 0.3,因为交通标志通常不会在照片中剧烈旋转。-w和-h自变量定义了样本的宽度和高度。 我们都使用了 32,因为我们要寻找的对象是训练一个适合正方形的分类器。 这些相同的值将在以后训练分类器时使用。 另外,请注意,这将是稍后您训练有素的分类器中可检测到的最小大小。
命令返回后,将生成示例文件。 我们可以使用相同的工具来查看样本:
opencv_createsamples -vec samples.vec -show
如果运行此命令,将出现一个32 x 32大小的窗口,其中带有单个样本图像。 您可以按N查看下一个,或按Esc键退出。 这些阳性样本在我的计算机上如下所示:

好了,阳性样本已经准备好了。 让我们训练级联分类器:
mkdir -p classifier
opencv_traincascade -data classifier -numStages 10 -featureType HAAR \
-vec samples.vec -bg bg.txt \
-numPos 200 -numNeg 200 -h 32 -w 32
我们首先为输出文件创建一个新目录,然后使用许多参数调用opencv_traincascade工具:
-data参数指定输出目录。 训练有素的分类器和许多中间文件将放置在此目录中。 该工具不会为我们创建目录,因此我们应该像运行mkdir命令一样,在运行命令之前自行创建目录。-vec参数指定由opencv_createsamples工具创建的正样本文件。-bg参数用于指定背景描述文件,在本例中为bg.txt文件。-numPos参数指定在每个分类器阶段的训练过程中将使用多少阳性样本。-numNeg参数指定在每个分类器阶段的训练中将使用多少个负样本。-numStages参数指定将训练多少个级联阶段。-featureType参数指定特征的类型。 它的值可以是 HAAR 或 LBP。-w和-h自变量指定训练过程中使用的样本的宽度和高度(以像素为单位)。 这些值必须与我们使用opencv_createsamples工具生成的阳性样本的宽度和高度完全相同。
该命令的运行将花费几分钟到几小时。 一旦返回,我们将在用作-data参数值的目录(即classifier目录)中找到许多输出文件:
$ ls classifier/
cascade.xml stage10.xml stage5.xml stage9.xml
params.xml stage2.xml stage6.xml
stage0.xml stage3.xml stage7.xml
stage1.xml stage4.xml stage8.xml
让我们看看这些文件的用途:
params.xml文件包含用于训练分类器的参数。stage<NN>.xml文件是在每个训练阶段完成之后创建的检查点。 如果训练过程意外终止,则可以使用它们稍后重新开始训练。cascade.xml文件是经过训练的分类器,也是由训练工具创建的最后一个文件。
让我们现在测试我们新训练的级联分类器。 打开capture_thread.cpp文件,在run方法中找到创建分类器的行,然后将我们新训练的分类器文件的路径传递给它:
classifier = new cv::CascadeClassifier("../no-entry/classifier/cascade.xml");
在detectObjects方法中,当调用分类器的detectMultiScale方法时,我们将第四个参数minNeighbors更改为3。
好,一切都完成了。 让我们编译并运行该应用。 打开照相机; 您将看到一个这样的窗口:

如果您不方便使用计算机上的网络摄像头捕获包含禁止进入标志的视频,则可以从互联网上搜索并下载此类视频或某些图片,然后将其传递给cv::VideoCapture实例以执行测试。
我将训练该级联分类器所需的所有命令包装到一个外壳脚本中,并将其放入本书随附的代码存储库中的Chapter-06/no-entry目录中。 在我的计算机上的该目录中,还有一个名为cascade.xml的级联分类器文件。 请注意,您的训练结果可能与我的完全不同。 如果在同一环境中重新运行训练,我们甚至会得到不同的结果。 您可以摆弄对象图像,背景图像和训练参数,以自己找到可接受的输出。
在本小节中,我们训练交通标志的分类器,并使用交通标志的特定图像来生成正样本。 这种生成样本的方法非常适用于稳定的对象,例如固定的徽标或固定的交通标志。 但是,一旦给它一些刚度不高的物体(例如人或动物的脸),我们就会发现它是失败的。 在这种情况下,我们应该使用另一种方法来生成阳性样本。 在这种替代方法中,我们应该收集许多真实的对象图像,并使用opencv_annotation工具对其进行标注。 然后,我们可以使用opencv_createsamples工具从带标注的图像中创建正样本。 我们将在下一部分中尝试这种方法。
波士顿公牛队的人脸
在本小节中,我们将训练一个级联分类器,用于一个不太刚性的对象:狗脸。
我们将使用这个页面中的数据集。 该数据集包含 20,580 张狗的图像,分为 120 个类别,每个类别都是一个犬种。 让我们下载并解压缩图像的压缩包:
$ curl -O http://vision.stanford.edu/aditya86/ImageNetDogs/images.tar
$ tar xvf images.tar
# output omitted
$ ls Images/
n02085620-Chihuahua n02091635-otterhound
n02097298-Scotch_terrier n02104365-schipperke
n02109525-Saint_Bernard n02085782-Japanese_spaniel
# output truncated
我们将把波士顿公牛犬种的人脸作为目标。 Images/n02096585-Boston_bull目录中有 182 张波士顿公牛的图像。 与固定物体(例如交通标志)不同,我们找不到波士顿公牛队脸部的标准图片。 我们应该在刚刚选择的 182 张图像上标注狗的脸。 标注是使用 OpenCV v3.4.x 提供的opencv_annotation工具完成的:
rm positive -fr
cp -r Images/n02096585-Boston_bull positive
opencv_annotation --annotations=info.txt --images=positive
我们将包含波士顿牛市图像的目录复制到新的positive目录中,以明显方便地将它们用作正图像。 然后,我们使用两个参数调用opencv_annotation工具:
--annotations参数指定标注的输出文件。--images参数指定一个文件夹,其中包含我们要标注的图像。
调用opencv_annotation工具将打开一个窗口,显示需要标注的图像。 我们可以使用鼠标和键盘在图像上做标注:
- 左键单击鼠标以标记标注的起点。
- 移动鼠标。 您将看到一个矩形; 通过移动鼠标来调整此矩形以适合狗的脸。
- 当您得到适当的矩形时,停止移动鼠标,然后再次单击鼠标左键。 您将获得一个固定的红色矩形。
- 现在,您可以按键盘上的
D键删除矩形,或按C键确认矩形。 如果确认为矩形,它将变为绿色。 - 您可以重复这些步骤以在图像上标记多个矩形。
- 完成当前图像的标注后,请按键盘上的
N键以转到下一张图像。 - 您可以按
Esc退出该工具。
这是我标注狗脸时该工具的屏幕截图:

我们应该在 182 张图像中仔细标记所有的狗脸。 这将是一个繁琐的过程,因此我在代码存储库的Chapter-06/boston-bull目录中提供了标注过程的结果文件info.txt文件。 该文件的数据格式非常简单:
positive/n02096585_10380.jpg 1 7 4 342 326
positive/n02096585_11731.jpg 1 158 218 93 83
positive/n02096585_11776.jpg 2 47 196 104 120 377 76 93 98
positive/n02096585_1179.jpg 1 259 26 170 165
positive/n02096585_12825.jpg 0
positive/n02096585_11808.jpg 1 301 93 142 174
上面的列表是从info.txt文件中选取的一些行。 我们可以看到此文件的每一行都是单个图像的信息,并且该信息以PATH NUMBER_OF_RECT RECT0.x RECT0.y RECT0.width RECT0.height RECT1.x RECT1.y RECT1.width RECT1.height ...格式组织。
借助此标注信息文件,我们可以创建正样本:
opencv_createsamples -info info.txt -vec samples.vec -w 32 -h 32
如您所见,它比上次使用opencv_createsamples工具要简单。 我们不需要为其提供背景图像,感兴趣对象的图像以及使对象变形的最大角度。 只给它注解数据作为-info参数就足够了。
调用返回后,我们在samples.vec文件中获得了正样本。 同样,我们可以使用opencv_createsamples工具进行查看:
opencv_createsamples -vec samples.vec -show
您可以通过按键盘上的N在提示窗口中一一查看所有样本。 这些样本如下所示:

现在可以准备好阳性样本了,该准备背景图像了。 Briard 品种的狗与 Boston Bulls 有很大的不同,因此我决定将这些图像用作背景图像:
rm negative -fr
cp -r Images/n02105251-briard negative
ls negative/* >bg.txt
我们将目录Images/n02105251-briard复制到negative目录,并将该目录下所有图像的相对路径保存到bg.txt文件。 bg.txt文件只是我们的背景描述文件:
negative/n02105251_1201.jpg
negative/n02105251_1240.jpg
negative/n02105251_12.jpg
negative/n02105251_1382.jpg
negative/n02105251_1588.jpg
...
正样本和背景图像都已准备就绪,因此让我们训练分类器:
mkdir -p classifier
opencv_traincascade -data classifier -vec samples.vec -bg bg.txt \
-numPos 180 -numNeg 180 -h 32 -w 32
此步骤与我们训练分类器的禁止进入交通标志的步骤非常相似。 值得注意的是,我们在这里使用-numPos 180,因为在samples.vec文件中只有 183 个阳性样本。
训练过程完成后,我们将在classifier目录下获得训练后的分类器,作为cascade.xml文件。 让我们现在尝试这个新训练的分类器。
首先,我们以CaptureThread::run()方法加载它:
classifier = new cv::CascadeClassifier("../boston-bull/classifier/cascade.xml");
然后,在CaptureThread::detectObjects方法中将detectMultiScale调用的minNeighbors参数更改为5:
int minNeighbors = 5; // 3 for no-entry-sign; 5-for others.
classifier->detectMultiScale(frame, objects, 1.3, minNeighbors);
让我们编译并运行 Detective 应用,并在一些材料上测试我们的新分类器:

好,还不错,我们训练了两个级联分类器。 您可能对在训练过程中如何选择 HAAR 或 LBP 特征感到好奇,所以让我们更深入一些。
OpenCV 提供了一个名为opencv_visualisation的工具,以帮助我们可视化训练有素的级联。 有了它,我们可以看到在每个阶段选择的级联分类器具有哪些特征:
$ mkdir -p visualisation
$ opencv_visualisation --image=./test-visualisation.png \
--model=./classifier/cascade.xml \
--data=./visualisation/
我们创建一个新目录,并使用许多参数调用opencv_visualisation工具:
--image参数用于指定图像的路径。 该图像应该是感兴趣的图像,具有我们在创建样本和训练分类器时使用的尺寸,即,波士顿牛头犬脸的32 x 32图像。--model参数是新训练模型的路径。--data是输出目录。 它必须以斜杠(/)结尾,并且必须预先手动创建目录。
当此命令返回时,我们将在输出目录中获得许多图像和一个视频文件:
$ ls visualisation/
model_visualization.avi stage_14.png stage_1.png stage_7.png
stage_0.png stage_15.png stage_2.png stage_8.png
stage_10.png stage_16.png stage_3.png stage_9.png
stage_11.png stage_17.png stage_4.png
stage_12.png stage_18.png stage_5.png
stage_13.png stage_19.png stage_6.png
制作了一个名为model_visualization.avi的视频,用于每个阶段的特征可视化; 您可以播放它以查看级联分类器如何选择特征。 此外,输出目录中的每个阶段都有一个图像。 我们可以检查这些图像以查看特征选择。
我们用于训练该分类器的所有材料,以及经过处理的cascade.xml,都位于我们代码存储库中的Chapter-06/boston-bull目录中。 请随便摆弄它们。
使用深度学习模型检测对象
在上一节中,我们学习了如何训练和使用级联分类器来检测对象。 但是,与不断扩展的深度学习方法相比,在召回率和准确率方面都提供了较差的表现。 OpenCV 库已经开始转向深度学习方法。 在 3.x 版中,它引入了深度神经网络(DNN)模块,现在在最新版本 v4.x 中,我们可以加载多种格式的神经网络架构, 以及他们的预训练权重。 另外,正如我们提到的,在最新版本中不推荐使用用于训练级联分类器的工具。
在本节中,我们将继续进行深度学习方法,以了解如何使用 OpenCV 来检测对象是深度学习方法。 我们已经使用过这种方法。 在第 5 章,“光学字符识别”中,我们使用了预训练的 EAST 模型来检测照片上的文本区域。 这是使用 TensorFlow 框架开发和训练的深度学习模型。 除了由 TensorFlow 框架训练的 DNN 模型外,OpenCV 还支持来自许多其他框架的多种格式的模型:
- 来自 Caffe 而非 Caffe2 的
*.caffemodel格式, - TensorFlow 的
*.pb格式 - Torch 而非 PyTorch 的
*.t7或*.net格式 - Darknet 的
*.weights格式 - DLDT 中的
*.bin格式
如您所见,尽管 OpenCV 支持多种 DNN 模型,但是仍然有一些流行的深度学习框架不在前面的列表中;例如, PyTorch 框架,Caffe2 框架,MXNet 和 Microsoft 认知工具包(CNTK)。 幸运的是,存在一种称为开放式神经网络交换(ONNX)的格式,该格式由其社区开发和支持,并且 OpenCV 库现在可以加载此格式的模型 。
大多数流行的深度学习框架(包括我刚才提到的框架)也支持 ONNX 格式。 因此,如果您开发的 DNN 模型的框架不在 OpenCV 支持列表中,则可以将模型架构和经过训练的权重保存为 ONNX 格式。 然后它将与 OpenCV 库一起使用。
OpenCV 库本身不具备构建和训练 DNN 模型的能力,但是由于它可以加载和转发 DNN 模型,并且比其他深度学习框架具有更少的依赖关系,因此,部署 DNN 模型确实是一个很好的解决方案 。 OpenCV 团队和英特尔还创建并维护了其他一些专注于机器学习模型部署的项目,例如 DLDT 和 OpenVINO 项目。
如果我们无法使用 OpenCV 训练模型,我们如何获得可与 OpenCV 一起使用以检测对象的模型? 最简单的方法是找到一个预先训练的模型。 最受欢迎的深度学习框架都有一个model zoo,它收集了许多使用该框架构建和预训练的模型。 您可以在互联网上搜索框架名称以及关键字model zoo来找到它们。 这些是我发现的一些:
另外,您可以在这里找到许多开源的预训练模型。
现在,让我们回到对象检测的主题。 通常,有三种基于深度学习的对象检测器:
- 基于 R-CNN 的检测器,包括 R-CNN,Fast R-CNN 和 Faster R-CNN
- 单发检测器(SSD)
- 只看一次(YOLO)
在基于区域特征的 CNN(R-CNN)中,我们首先需要使用一种算法,提出可能包含对象的候选边界框,然后将这些候选框发送到卷积神经网络(CNN)模型进行分类。 因此,这种检测器也称为两级检测器。 这种方法的问题是它非常慢并且不是端到端的深度学习对象检测器(因为我们需要在进入 CNN 模型之前搜索候选框并做一些其他工作)。 尽管 R-CNN 进行了两次改进(使用 Fast R-CNN 和 Faster R-CNN),即使在 GPU 上,这些方法仍然不够快。
R-CNN 方法使用两阶段策略,而 SSD 和 YOLO 方法使用一个阶段策略。 一阶段策略将对象检测视为回归问题,获取给定的输入图像,同时学习边界框坐标和相应的类标签概率。 通常,一级检测器的精度往往不如二级检测器,但要快得多。 例如,由这里引入的单阶段策略 YOLO 的著名实现在 GPU 上具有可飙升至 45 FPS 的性能,其中两级检测器可能仅具有 5-10 FPS 的性能。
在本节中,我们将使用预训练的 YOLOv3 检测器来检测对象。 在 COCO 数据集上训练了该模型; 它可以检测数百种对象。 要在 OpenCV 中使用此模型,我们首先应为其下载一些文件:
现在,让我们将它们下载到我们的侦探应用的data子目录中:
$ pwd
/home/kdr2/Work/Books/Qt-5-and-OpenCV-4-Computer-Vision-Projects/Chapter-06/Detective
$ mkdir data
$ cd data/
$ curl -L -O https://raw.githubusercontent.com/pjreddie/darknet/master/data/coco.names
# output omitted
$ curl -L -O https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg
# output omitted
$ curl -L -O https://pjreddie.com/media/files/yolov3.weights
# output omitted
$ ls -l
total 242216
-rw-r--r-- 1 kdr2 kdr2 625 Apr 9 15:23 coco.names
-rw-r--r-- 1 kdr2 kdr2 8342 Apr 9 15:24 yolov3.cfg
-rw-r--r-- 1 kdr2 kdr2 248007048 Apr 9 15:49 yolov3.weights
准备好这三个文件后,我们可以在应用中加载模型。 首先,让我们打开capture_thread.h头文件以添加一些方法和字段:
// ...
#include "opencv2/dnn.hpp"
// ...
class CaptureThread : public QThread
{
// ...
private:
// ...
void detectObjectsDNN(cv::Mat &frame);
private:
// ...
cv::dnn::Net net;
vector<string> objectClasses;
};
首先,我们将添加include指令以包含opencv2/dnn.hpp头文件,因为我们将使用 DNN 模块。 这些是方法和字段:
- 方法
detectObjectsDNN用于使用 DNN 模型检测帧中的对象。 - 成员字段
cv::dnn::Net net是 DNN 模型实例。 - 成员字段
vector<string> objectClasses将保存 COCO 数据集中的对象的类名称。
让我们打开源代码capture_thread.cpp,以查看detectObjectsDNN方法的实现:
void CaptureThread::detectObjectsDNN(cv::Mat &frame)
{
int inputWidth = 416;
int inputHeight = 416;
if (net.empty()) {
// give the configuration and weight files for the model
string modelConfig = "data/yolov3.cfg";
string modelWeights = "data/yolov3.weights";
net = cv::dnn::readNetFromDarknet(modelConfig, modelWeights);
objectClasses.clear();
string name;
string namesFile = "data/coco.names";
ifstream ifs(namesFile.c_str());
while(getline(ifs, name)) objectClasses.push_back(name);
}
// more code here ...
}
在此方法的开头,我们为 YOLO 模型定义了输入图像的宽度和高度。 共有三个选项:320 x 320、416 x 416和608 x 608。在这里,我们选择416 x 416,所有输入图像都将调整为该尺寸。
我们检查net字段是否为空。 如果为空,则意味着我们尚未加载模型,因此我们使用模型配置文件和权重文件的路径调用cv::dnn::readNetFromDarknet函数以加载模型。
之后,我们通过创建ifstream的实例来打开data/coco.names文件。 如前所述,该文件包含 COCO 数据集中对象的所有类名称:
$ wc -l data/coco.names
80 data/coco.names
$ head data/coco.names
person
bicycle
car
motorbike
aeroplane
bus
train
truck
boat
traffic light
在 shell 命令和先前的输出中,我们可以看到总共有 80 个类名。 通过使用head命令查看前十个名称,我们也可以大致了解这些名称。 让我们继续我们的 C++ 代码。 我们逐行读取打开的文件,然后将读取的名称(即每一行)推送到成员字段objectClasses。 完成此操作后,objectClasses字段将保存所有 80 个名称。
好的,模型和类名都已加载。 接下来,我们应该转换输入图像并将其传递给 DNN 模型以进行正向传播以获得输出:
cv::Mat blob;
cv::dnn::blobFromImage(
frame, blob, 1 / 255.0,
cv::Size(inputWidth, inputHeight),
cv::Scalar(0, 0, 0), true, false);
net.setInput(blob);
// forward
vector<cv::Mat> outs;
net.forward(outs, getOutputsNames(net));
转换通过调用cv::dnn::blobFromImage方法完成。 这个调用有点复杂,所以让我们逐个分析参数:
- 第一个参数是输入图像。
- 第二个参数是输出图像。
- 第三个是每个像素值的比例因子。 我们在这里使用
1 / 255.0,因为模型要求像素值是0到1范围内的浮点数。 - 第四个参数是输出图像的空间大小; 我们在这里使用
416 x 416,以及我们定义的变量。 - 第五个参数是平均值,应该从每个图像中减去平均值,因为在训练模型时会使用该平均值。 YOLO 不执行均值减法,因此在此我们使用零。
- 下一个参数是我们是否要交换 R 和 B 通道。 这是我们必需的,因为 OpenCV 使用 BGR 格式,而 YOLO 使用 RGB 格式。
- 最后一个参数是我们是否要裁剪图像并进行中心裁剪。 在这种情况下,我们指定
false。
关键参数是比例因子(第三个)和均值(第五个)。 在转换中,首先从输入图像的每个像素中减去平均值,然后将像素乘以比例因子,即,将输出 BLOB 的像素计算为output_pixel = (input_pixel - mean) * scale_factor。
但是,我们如何知道应该为模型使用这两个参数的哪些值? 一些模型同时使用均值减法和像素缩放,一些模型仅使用均值减法而不使用像素缩放,而某些模型仅使用像素法缩放而不使用平均减法。 对于特定的模型,了解这些值的详细信息的唯一方法是阅读文档。
获取输入 BLOB 后,通过调用模型的setInput方法将其传递给 DNN 模型,然后对模型执行转发。 但是我们必须知道在执行前向传递时要通过转发获得哪些层。 这是通过名为getOutputsNames的辅助函数完成的,我们也在capture_thread.cpp源文件中实现了该函数:
vector<string> getOutputsNames(const cv::dnn::Net& net)
{
static vector<string> names;
vector<int> outLayers = net.getUnconnectedOutLayers();
vector<string> layersNames = net.getLayerNames();
names.resize(outLayers.size());
for (size_t i = 0; i < outLayers.size(); ++i)
names[i] = layersNames[outLayers[i] - 1];
return names;
}
DNN 模型的输出层的索引可以通过getUnconnectedOutLayers方法获得,而所有层的名称都可以通过getLayerNames方法获得。 如果我们监视getLayerNames方法的结果向量,则将在此 YOLO 模型中发现 254 层。 在我们的函数中,我们获得所有这 254 个名称,然后选择未连接输出层的索引所指示的名称。 实际上,此函数只是cv::dnn::Net.getUnconnectedOutLayersNames()方法的另一个版本。 在这里,我们使用自制版本来了解有关cv::dnn::Net类的更多信息。
让我们回到我们的detectObjectsDNN方法。 转发完成后,我们将在vector<cv::Mat> outs变量中获取输出层的数据。 所有信息(包括我们检测到的盒子中的对象,以及它们的置信度和类索引)都在此矩阵向量中。 我们在capture_thread.cpp源文件中编写了另一个辅助函数,以对向量进行解码以获得所需的所有信息:
void decodeOutLayers(
cv::Mat &frame, const vector<cv::Mat> &outs,
vector<int> &outClassIds,
vector<float> &outConfidences,
vector<cv::Rect> &outBoxes
)
{
float confThreshold = 0.5; // confidence threshold
float nmsThreshold = 0.4; // non-maximum suppression threshold
vector<int> classIds;
vector<float> confidences;
vector<cv::Rect> boxes;
// not finished, more code here ...
}
此函数将原点框架和输出层的数据作为其内部参数,并通过其外部参数返回检测到的对象框及其类索引和置信度。 在函数主体的开头,我们定义了几个变量,例如置信度阈值和非最大抑制阈值,以及在过滤之前检测到的所有对象的框信息。
然后,我们遍历输出层中的矩阵:
for (size_t i = 0; i < outs.size(); ++i) {
float* data = (float*)outs[i].data;
for (int j = 0; j < outs[i].rows; ++j, data += outs[i].cols)
{
cv::Mat scores = outs[i].row(j).colRange(5, outs[i].cols);
cv::Point classIdPoint;
double confidence;
// get the value and location of the maximum score
cv::minMaxLoc(scores, 0, &confidence, 0, &classIdPoint);
if (confidence > confThreshold)
{
int centerX = (int)(data[0] * frame.cols);
int centerY = (int)(data[1] * frame.rows);
int width = (int)(data[2] * frame.cols);
int height = (int)(data[3] * frame.rows);
int left = centerX - width / 2;
int top = centerY - height / 2;
classIds.push_back(classIdPoint.x);
confidences.push_back((float)confidence);
boxes.push_back(cv::Rect(left, top, width, height));
}
}
}
让我们以输出向量中的单个矩阵(即代码中的outs[i])为例。 矩阵中的每一行代表一个检测到的框。 每行包含(5 + x)元素,其中x是coco.names文件中类名称的数量,即 80,如上所述。
前四个元素表示框的center_x,center_y,width和height。 第五个元素表示边界框包围对象的置信度。 其余元素是与每个类别相关的置信度。 将该框分配给与该框的最高分数相对应的类别。
换句话说,row[i + 5]的值是该框是否包含objectClasses[i]类的对象的置信度。 因此,我们使用cv::minMaxLoc函数来获得最大的置信度及其位置(索引)。 然后,我们检查置信度是否大于定义的置信度阈值。 如果为true,则将框解码为cv::Rect,然后将框及其类索引和置信度推入boxes,classIds和confidences定义的变量。
下一步是将解码后的框和置信度传递给非最大抑制,以减少重叠框的数量。 未消除的框的索引将存储在cv::dnn::NMSBoxes函数的最后一个参数中,即indices变量:
// non maximum suppression
vector<int> indices;
cv::dnn::NMSBoxes(boxes, confidences, confThreshold, nmsThreshold, indices);
for (size_t i = 0; i < indices.size(); ++i) {
int idx = indices[i];
outClassIds.push_back(classIds[idx]);
outBoxes.push_back(boxes[idx]);
outConfidences.push_back(confidences[idx]);
}
最后,我们迭代保留的索引,并将相应的框及其类索引和置信度推入外部参数。
decodeOutLayers函数完成后,让我们再次回到detectObjectsDNN方法。 通过调用新实现的decodeOutLayers函数,我们可以获得检测到的对象的所有信息。 现在让我们在原点框架上绘制它们:
for(size_t i = 0; i < outClassIds.size(); i ++) {
cv::rectangle(frame, outBoxes[i], cv::Scalar(0, 0, 255));
// get the label for the class name and its confidence
string label = objectClasses[outClassIds[i]];
label += cv::format(":%.2f", outConfidences[i]);
// display the label at the top of the bounding box
int baseLine;
cv::Size labelSize = cv::getTextSize(label,
cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);
int left = outBoxes[i].x, top = outBoxes[i].y;
top = max(top, labelSize.height);
cv::putText(frame, label, cv::Point(left, top),
cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(255,255,255));
}
使用前面的代码,我们绘制了检测到的对象的边界框。 然后在每个框的左上角绘制一个字符串,其中包含类名和相应检测到的对象的置信度。
至此,用 YOLO 检测物体的工作完成了。 但是,在编译和运行应用之前,还有几件事要做。
首先,在CaptureThread::run()方法中,将调用更改为detectObjects方法,该方法使用级联分类器来检测对我们新增方法detectObjectsDNN的调用的对象:
// detectObjects(tmp_frame);
detectObjectsDNN(tmp_frame);
其次,我们将opencv_dnn模块添加到Detective.pro项目文件中LIBS配置的末尾:
unix: !mac {
INCLUDEPATH += /home/kdr2/programs/opencv/include/opencv4
LIBS += -L/home/kdr2/programs/opencv/lib -lopencv_core -lopencv_imgproc -lopencv_imgcodecs -lopencv_video -lopencv_videoio -lopencv_objdetect -lopencv_dnn
}
现在,让我们编译并运行我们的应用以对其进行测试。 这是我通过相机观察桌面时的侦探应用的屏幕截图:

这是运动场景图像的屏幕截图:

如您所见,YOLO 在对象检测方面确实做得很好。 但是仍然有一些错误的预测。 在第一个屏幕截图中,YOLO 将我的 iPad 识别为 0.67 的笔记本电脑。 在第二张屏幕截图中,它以 0.62 的置信度将足球场识别为电视监视器。 为了消除这些错误的预测,我们可以将置信度阈值设置得更高一些,例如 0.70。 我将留给您摆弄参数。
值得注意的是,YOLO 模型有其缺点。 例如,它不能始终很好地处理小对象,尤其是不能处理紧密组合在一起的对象。 因此,如果您要处理小对象或组合在一起的对象的视频或图像,则 YOLO 不是最佳选择。
到目前为止,我们已经讨论了如何找到使用许多深度学习框架构建的预训练 DNN 模型,以及如何在 OpenCV 中使用它们。 但是,如果没有针对您的案例的预训练模型,例如,检测不在COCO数据集中的某种对象,该怎么办? 在这种情况下,您应该自己构建和训练 DNN 模型。 对于每个深度学习框架,都有一个有关如何在 MNIST 或 CIFAR-10/100 数据集上构建和训练模型的教程。 请遵循这些教程。 您将学习如何为您的用例训练 DNN 模型。 还有一个名为 Keras 的框架,该框架提供了用于构建,训练和运行 DNN 模型的高级 API。 它使用 TensorFlow,CNTK 和 Theano 作为其基础框架。 对于初学者来说,使用 Keras 提供的友好 API 也是一个不错的选择。 本书侧重于 OpenCV,而这些知识已超出其范围,因此我将把学习训练 DNN 模型的任务交给您。
关于实时
当我们处理视频时,无论是视频文件还是来自摄像机的实时视频源,我们都知道视频的帧频通常约为 24-30 FPS。 这意味着我们有 33-40 毫秒来处理每个帧。 如果我们花费的时间更多,则会从实时视频源中丢失一些帧,或者从视频文件中获得较慢的播放速度。
现在,让我们向应用中添加一些代码,以测量检测对象时每帧花费的时间。 首先,在Detective.pro项目文件中,添加一个新的宏定义:
DEFINES += TIME_MEASURE=1
我们将使用此宏来打开或关闭时间测量代码。 如果要关闭时间测量,只需将这一行注释掉,然后通过运行make clean && make命令重建应用。
然后,在CaptureThread::run方法的capture_thread.cpp文件中,我们在detectObjects或detectObjectsDNN方法的调用前后添加一些行:
#ifdef TIME_MEASURE
int64 t0 = cv::getTickCount();
#endif
detectObjects(tmp_frame);
// detectObjectsDNN(tmp_frame);
#ifdef TIME_MEASURE
int64 t1 = cv::getTickCount();
double t = (t1 - t0) * 1000 /cv::getTickFrequency();
qDebug() << "Detecting time on a single frame: " << t <<"ms";
#endif
在前面的代码中,cv::getTickCount函数返回从头开始的时钟周期数(系统启动的时间)。 我们在检测之前和之后两次调用它。 之后,使用t1 - t0表达式获取检测对象时的时钟周期。 由于cv::getTickFrequency()函数返回一秒内有多少个时钟周期,因此我们可以通过(t1 - t0) * 1000 /cv::getTickFrequency()将经过的时钟周期数转换为毫秒。 最后,我们通过qDebug()输出与时间使用相关的消息。
如果使用级联分类器方法编译并运行“侦探”应用,则会看到类似以下的消息:
Detecting time on a single frame: 72.5715 ms
Detecting time on a single frame: 71.7724 ms
Detecting time on a single frame: 73.8066 ms
Detecting time on a single frame: 71.7509 ms
Detecting time on a single frame: 70.5172 ms
Detecting time on a single frame: 70.5597 ms
如您所见,我在每个帧上花费了 70 毫秒以上。 该值应大于(33-40)。 就我而言,这主要是因为我的计算机中装有旧的 CPU,大约是十年前购买的,并且没有降低输入帧的分辨率。 为了优化这一点,我们可以使用功能更强大的 CPU,并将输入帧的大小调整为更小,更合适的大小。
在使用前面的代码来衡量 YOLO 方法使用的时间之前,让我们向CaptureThread::detectObjectsDNN方法中添加一些代码行:
// ...
net.forward(outs, getOutputsNames(net));
#ifdef TIME_MEASURE
vector<double> layersTimes;
double freq = cv::getTickFrequency() / 1000;
double t = net.getPerfProfile(layersTimes) / freq;
qDebug() << "YOLO: Inference time on a single frame: " << t <<"ms";
#endif
在 DNN 模型上执行转发后,我们调用其getPerfPerofile方法来获取在前向传递中花费的时间。 然后我们将其转换为毫秒并打印。 通过这段代码,我们将获得两次:一次是调用detectObjectsDNN方法所花费的总时间;另一次是调用detectObjectsDNN方法所花费的总时间。 另一个是推理时间,即在前向传递上花费的时间。 如果从第一个中减去第二个,我们将得到花在 BLOB 准备和结果解码上的时间。
让我们在run方法中切换到 YOLO 方法并运行该应用:
YOLO: Inference time on a single frame: 2197.44 ms
Detecting time on a single frame: 2209.63 ms
YOLO: Inference time on a single frame: 2203.69 ms
Detecting time on a single frame: 2217.69 ms
YOLO: Inference time on a single frame: 2303.73 ms
Detecting time on a single frame: 2316.1 ms
YOLO: Inference time on a single frame: 2203.01 ms
Detecting time on a single frame: 2215.23 ms
哦,不,这太慢了。 但是我们说 YOLO 性能飙升至 45 FPS,这意味着它在每个帧上仅花费 22 毫秒。 为什么我们的结果慢 100 倍? 性能可能会飙升至 45 FPS,但这是在 GPU 而非 CPU 上测得的。 深度神经网络需要大规模的计算,这不适合在 CPU 上运行,但很适合在 GPU 上运行。 当前,将计算放到 GPU 上最成熟的解决方案是 CUDA 和 OpenCL,而 OpenCV 库的 DNN 模块目前仅支持 OpenCL 方法。
cv::dnn::Net类有两种方法来设置其后端和目标设备:
setPreferableBackend()setPreferableTarget()
如果您有 GPU,并且已正确安装 OpenCL 和 GPU 驱动程序,则可以使用-DWITH_OPENCL=ON标志构建 OpenCV 以启用 OpenCL 支持。 之后,您可以使用net.setPreferableTarget(cv::dnn::DNN_TARGET_OPENCL)使用 GPU 进行计算。 这将带来性能上的巨大改进。
总结
在本章中,我们创建了一个名为 Detective 的新应用,以使用不同的方法来检测对象。 首先,我们使用 OpenCV 内置的层叠分类器来检测猫的脸。 然后,我们学习了如何自己训练级联分类器。 我们训练了用于刚性物体(禁止进入的交通标志)的级联分类器和用于不太刚性物体(波士顿公牛队的脸)的级联分类器,然后在我们的应用中对此进行了测试。
我们转向了深度学习方法。 我们讨论了深度学习技术的不断扩展,介绍了许多框架,并了解了 DNN 模型可以使用两阶段检测器和一阶段检测器检测对象的不同方式。 我们结合了 OpenCV 库的 DNN 模块和预训练的 YOLOv3 模型来检测应用中的对象。
最后,我们简要讨论了实时性和检测器的性能。 我们了解了如何将计算移至 GPU,以实现性能的大幅提高。
在本章中,我们检测了多种对象。 在下一章中,我们将讨论如何借助计算机视觉技术来测量它们之间的距离。
问题
尝试这些问题以测试您对本章的了解:
- 当我们为波士顿公牛队的脸训练级联分类器时,我们自己在每个图像上标注了狗脸。 标注过程花费了我们很多时间。 在以下网站上有该数据集的注解数据包。 我们可以通过一段代码从此标注数据生成
info.txt文件吗? 我们该怎么做? - 尝试找到预训练的(快速/快速)R-CNN 模型和预训练的 SSD 模型。 运行它们,并将其性能与 YOLOv3 进行比较。
- 我们可以使用 YOLOv3 来检测某种对象,但不能检测所有 80 类对象吗?
七、实时汽车检测和距离测量
在上一章中,我们通过级联分类器方法和深度学习方法学习了如何使用 OpenCV 库检测对象。 在本章中,我们将讨论如何测量检测到的物体之间或感兴趣的物体与相机之间的距离。 我们将在新的应用中检测汽车,并测量汽车之间的距离以及汽车与摄像机之间的距离。
本章将涵盖以下主题:
- 使用带有 OpenCV 的 YOLOv3 模型检测汽车
- 测量不同视角距离的方法
- 在鸟瞰图中测量汽车之间的距离
- 在眼睛水平视图中测量汽车与摄像头之间的距离
技术要求
像前面的章节一样,您至少需要安装 Qt 版本 5 并安装 OpenCV 4.0.0。 也必须具有 C++ 和 Qt 编程的基本知识。
我们将使用深度学习模型 YOLOv3 来检测汽车,因此拥有深度学习知识也将有很大帮助。 由于我们在第 6 章,“实时对象检测”中介绍了深度学习模型,因此建议您先阅读本章之前的内容。
实时汽车检测
在测量物体之间的距离之前,我们必须检测出感兴趣的物体以找出它们的位置。 在本章中,我们决定测量汽车之间的距离,因此我们应该从检测汽车开始。 在上一章,第 6 章,“实时对象检测”中,我们学习了如何以多种方式检测对象,我们看到 YOLOv3 模型在准确率方面具有良好的表现, 幸运的是,car对象类在可可数据集(即coco.names文件)的类别列表中。 因此,我们将遵循该方法,并使用 YOLOv3 模型来检测汽车。
与前面几章一样,我们将通过复制我们已经完成的项目之一来创建本章的新项目。 这次,让我们复制上一章完成的 Detective 应用,作为本章的新项目。 我们将新项目命名为DiGauge,以表明该项目用于衡量检测到的对象之间的距离。 让我们直接进行复制:
$ pwd
/home/kdr2/Work/Books/Qt-5-and-OpenCV-4-Computer-Vision-Projects
$ mkdir Chapter-07
# !!! you should copy it to a different dir
$ cp -r Chapter-06/Detective Chapter-07/DiGauge
$ ls Chapter-07
DiGauge
$ cd Chapter-07/DiGauge/
如果您一直在进行,则应该将项目复制到Chapter-07以外的其他目录,因为DiGauge目录已经存在于我们代码存储库中的该文件夹中。
现在我们已经完成了复制,让我们进行一些重命名:
- 将
Detective.pro项目文件重命名为DiGauge.pro。 - 在该项目文件中将目标值从
Detective重命名为DiGauge。 - 在对
main.cpp源文件中的window.setWindowTitle的调用中,将Detective窗口标题更改为DiGauge。 - 将
mainwindow.cpp源文件中位于MainWindow::initUI方法中对mainStatusLabel->setText的调用中的状态栏上的文本从Detective is Ready更改为DiGauge is Ready。 - 在
utilities.cpp源文件中的Utilities::getDataPath方法中,对pictures_dir.mkpath和pictures_dir.absoluteFilePath的调用中,将Detective字符串更改为DiGauge。
至此,我们有了一个与侦探应用相同的新应用,除了名称和相对路径中的单词Detective。 UI 上的文本也已更改为DiGauge。 要看到这一点,我们可以编译并运行它。
可在以下提交中找到重命名中的更改集。 如果您对此完全感到困惑,请参考提交。
由于我们决定使用 YOLOv3 深度学习模型来检测汽车,因此,我们最好删除所有与级联分类器方法有关的代码,以使我们的项目代码简洁明了。 此步骤也非常简单:
-
在
DiGauge.pro项目文件中,我们在LIBS配置中删除了opencv_objdetect模块,因为在删除了使用级联分类器的代码之后,将不再使用该模块。 也可以删除DEFINES配置中定义的宏,因为我们也不会使用它们。 -
在
capture_thread.h文件中,我们从CaptureThread类中删除了void detectObjects(cv::Mat &frame)私有方法和cv::CascadeClassifier *classifier;字段。 -
最后,我们对
capture_thread.cpp源文件进行一些更改:
至此,我们有了一个仅使用 YOLOv3 模型检测对象的干净项目。 可以在这里找到该变更集。
现在,通过使用 YOLOv3 模型,我们的应用可以检测视频或图像中的所有 80 类对象。 但是,对于此应用,我们对所有这些类都不感兴趣-我们仅对汽车感兴趣。 让我们在coco.names文件中找到car类:
$ grep -Hn car data/coco.names
data/coco.names:3:car
data/coco.names:52:carrot
$
如我们所见,car类是coco.names文件中的第三行,因此它的类 ID 是2(具有从 0 开始的索引)。 让我们覆盖capture_thread.cpp源文件中的decodeOutLayers函数,以过滤掉除 ID 为2的类之外的所有类:
void decodeOutLayers(
cv::Mat &frame, const vector<cv::Mat> &outs,
vector<cv::Rect> &outBoxes
)
{
float confThreshold = 0.65; // confidence threshold
float nmsThreshold = 0.4; // non-maximum suppression threshold
// vector<int> classIds; // this line is removed!
// ...
}
让我们看一下我们在前面的代码中所做的更改:
-
对函数签名的更改:
outClassIds参数将不再有用,因为我们将仅检测一类对象,因此我们将其删除。outConfidences参数也将被删除,因为我们不在乎每个检测到的汽车的置信度。
-
对函数主体的更改:
confThreshold变量从0.5更改为0.65,以提高准确率。- 出于与删除
outClassIds参数相同的原因,还删除了用于存储检测到的对象的类 ID 的classIds局部变量。
然后,在通过调用cv::minMaxLoc函数获得类 ID 之后,在第二级for循环中处理检测到的对象的边界框时,我们检查类 ID 是否为2。 如果不是2,我们将忽略当前的边界框并转到下一个边界框:
cv::minMaxLoc(scores, 0, &confidence, 0, &classIdPoint);
if (classIdPoint.x != 2) // not a car!
continue;
最后,我们删除所有试图更新已删除的classIds,outClassIds和outConfidences变量的行。 现在,对decodeOutLayers函数的更改已完成,因此让我们继续调用decodeOutLayers的函数,即CaptureThread::detectObjectsDNN方法。
对于CaptureThread::detectObjectsDNN方法,我们只需要更新其主体的末端部分:
// remove the bounding boxes with low confidence
// vector<int> outClassIds; // removed!
// vector<float> outConfidences; // removed!
vector<cv::Rect> outBoxes;
// decodeOutLayers(frame, outs, outClassIds, outConfidences, outBoxes); // changed!
decodeOutLayers(frame, outs, outBoxes);
for(size_t i = 0; i < outBoxes.size(); i ++) {
cv::rectangle(frame, outBoxes[i], cv::Scalar(0, 0, 255));
}
如您所见,我们删除了类 ID 和与置信度相关的变量,并使用outBoxes变量作为其唯一的out参数调用了decodeOutLayers函数。 然后,我们遍历检测到的边界框并将其绘制为红色。
最后,我们已经完成了对 Detective 应用的重建,以便它是一个名为 DiGauge 的新应用,它可以使用 YOLOv3 深度学习模型来检测汽车。 让我们编译并运行它:
$ qmake
$ make
g++ -c -pipe -O2 -Wall #...
# output trucated
$ export LD_LIBRARY_PATH=/home/kdr2/programs/opencv/lib
$ ./DiGauge
不要忘记将与 YOLOV3 模型相关的文件(coco.names,yolov3.cfg和yolov3.weights)复制到我们项目的data子目录中,否则将无法成功加载模型。 如果您对如何获取这些文件有疑问,则应阅读第 6 章,“实时对象检测”。
应用启动后,如果您在其中有汽车的某些场景上对其进行测试,则会发现每个检测到的汽车都有一个红色边框:

现在我们可以检测到汽车了,让我们在下一部分中讨论如何测量它们之间的距离。
距离测量
在不同情况下,有许多方法可以测量或估计对象之间或对象与相机之间的距离。 例如,如果我们感兴趣的物体或我们的相机以已知且固定的速度运动,则通过运动检测和对象检测技术,我们可以轻松地在相机视图中估计物体之间的距离。 另外,如果我们使用立体声相机,则可以遵循这里来测量距离。
但是,对于我们的情况,我们只有一个固定位置的普通网络摄像头,那么如何测量与之的距离呢? 好吧,可以满足一些先决条件。
让我们先谈谈测量物体之间的距离。 这种情况下的先决条件是,我们应该将摄像机安装到固定位置,以便可以鸟瞰鸟瞰对象,并且必须有一个已知大小固定的对象,它将在相机的视线中用作参考。 让我们来看看用我的相机拍摄的照片:

在上一张照片中,我的桌子上有两个硬币。 硬币的直径为 25 毫米,该长度在照片中占据 128 个像素。 利用这些信息,我们可以测量两个硬币的距离(以像素为单位),即照片中的距离为 282 像素。 好吧,照片中 128 像素的长度在我的桌子上代表 25 毫米,那么 282 像素的长度代表多长时间? 非常简单:25 / 128 * 282 = 55.07毫米。 因此,在这种情况下,一旦检测到参考对象和要测量的距离的顶点,便可以通过简单的计算获得距离。 在下一部分的应用中,我们将使用这种简洁的方法来测量汽车之间的距离。
现在,让我们继续讨论测量目标物体和相机之间的距离的主题。 在这种情况下,先决条件是我们应该将摄像机安装到固定位置,以便可以在眼平视图中拍摄对象的视频,并且也必须有参考。 但是,这里的参考与鸟瞰情况有很大不同。 让我们看看为什么:

上图演示了我们在拍照时对象与相机之间的位置关系。 此处,F是相机的焦距,D0是相机与物体之间的距离。Hr是物体的高度,H0是相机镜头上物体图像的高度(以米为单位,而不是以像素为单位)。
由于图片中有两个明显相似的三角形,因此我们可以得到一些方程式:

上图中有很多方程式,所以让我们一一看一下:
- 第一个方程式来自三角形相似度。
- 从等式
(1),我们知道焦点F可以计算为等式(2)。 - 然后,如果将对象移动到另一个位置,并将距离标记为
D1,并将镜头上图像的高度标记为H1,则考虑到相机的焦距是固定值,我们将得到方程(3)。 - 如果我们将方程
(2)和方程(3)结合起来,我们将得到方程(4)。 - 从等式
(4),经过一些变换,我们可以得出距离D1,可以将其计算为等式(5)。 - 由于我们已经将
Hr的高度与实际物体进行了比较,因此H0和H1的值非常小,因此我们可以推测出这些值Hr-H0和Hr-H1的比值几乎相同。 这就是方程(6)所说的。 - 利用等式
(6),我们可以将等式(4)简化为等式(7)。
因此,无论以镜头上的米为单位还是照片上的像素为单位,H0/H1的值始终相同,我们可以更改H0和H1可以计算它们所占据的像素数,以便我们可以在数码照片中对其进行测量。
在这里,我们将以D0(以米为单位)和H0(以像素为单位)作为参考,这意味着在测量相机与物体之间的距离之前,必须先将其放在相机之前的某个位置,然后将其测量为D0并拍照。 然后,我们可以将照片中物体的高度记为H0,并将这些值用作参考值。 让我们看一个例子:

在上一张照片的左侧,我在离相机 230 厘米的桌子上放了一个文件夹,并拍摄了照片。 在此,它在垂直方向上占据 90 个像素。 然后,我将其移至距相机几厘米的位置,然后再次拍照。 这次,其高度为 174 像素。 我们可以将左侧的值用作参考值,即:
D0是 230 厘米H0是 90 像素H1为 174 像素
根据方程(7),我们可以将D1计算为H0 / H1 * D0 = 90 / 174 * 230 = 118.96 cm。 结果非常接近我从桌子上的直尺获得的值,即 120 厘米。
现在我们知道了如何测量物体之间或物体与相机之间距离的原理,让我们将其应用于 DiGauge 应用中。
测量汽车之间或汽车与相机之间的距离
有了我们在上一节中讨论的原理,让我们利用它们来测量应用中的距离。
正如我们之前提到的,我们将从两种不同的角度进行衡量。 首先,让我们看一下鸟瞰图。
鸟瞰汽车之间的距离
为了能够鸟瞰汽车,我将相机固定在办公室八层的窗户上,使其面向地面。 这是我从相机中获得的图片之一:

您会看到道路上的汽车从图片的左侧向右侧行驶。 这不是绝对的鸟瞰图,但是我们可以使用上一节中讨论的方法来估计汽车之间的距离。 让我们在我们的代码中做到这一点。
在capture_thread.cpp源文件中,我们将添加一个名为distanceBirdEye的新函数:
void distanceBirdEye(cv::Mat &frame, vector<cv::Rect> &cars)
{
// ...
}
它有两个参数:
- 视频中
cv::Mat类型的帧 - 给定帧中检测到的汽车的边界框的向量
我们将首先计算水平方向上边界框的距离(以像素为单位)。 但是,这些盒子在水平方向上可能部分重叠。 例如,在上一张照片中,左侧的两辆白色汽车在水平方向上几乎处于同一位置,显然,我们感兴趣的水平方向上它们之间的距离为零, 并且我们没有必要对其进行衡量。 因此,在计算任何两个给定框的每个距离之前,我们应该将在水平方向上重叠的框合并为一个框。
这是我们合并框的方法:
vector<int> length_of_cars;
vector<pair<int, int>> endpoints;
vector<pair<int, int>> cars_merged;
首先,在前面的代码中,我们声明变量:
length_of_cars变量是整数向量,将保留汽车的长度(以像素为单位),即边界框的宽度。endpoints变量将保留汽车两端的位置。 此变量是整数对的向量。 其中的每一对都是汽车的一端(前端或后端)。 如果是后端,则对为(X, 1),否则为(X, -1),其中X是端点的x坐标。cars_merged变量用于合并汽车后的汽车位置信息。 我们只关心它们在水平方向上的位置,因此我们使用对代替矩形来表示位置。 一对中的第一个元素是汽车的后端(在左侧),第二个元素是汽车的前端(在右侧)。
然后,我们遍历检测到的汽车的边界框以填充这三个向量:
for (auto car: cars) {
length_of_cars.push_back(car.width);
endpoints.push_back(make_pair(car.x, 1));
endpoints.push_back(make_pair(car.x + car.width, -1));
}
填充向量后,我们对长度的向量进行排序,并找到中位数作为int length变量。 稍后我们将使用该中值作为参考值之一:
sort(length_of_cars.begin(), length_of_cars.end());
int length = length_of_cars[cars.size() / 2];
现在,我们执行最后一步:
sort(
endpoints.begin(), endpoints.end(),
[](pair<int, int> a, pair<int, int> b) {
return a.first < b.first;
}
);
int flag = 0, start = 0;
for (auto ep: endpoints) {
flag += ep.second;
if (flag == 1 && start == 0) { // a start
start = ep.first;
} else if (flag == 0) { // an end
cars_merged.push_back(make_pair(start, ep.first));
start = 0;
}
}
在前面的代码中,我们按其中每个对的第一个元素对endpoints向量进行排序。 排序后,我们遍历已排序的endpoints以进行合并。 在迭代中,我们将对中的第二个整数添加到初始值为零的标志中,然后检查标志的值。 如果它是 1,并且我们还没有开始合并范围,则这是一个起点。 当标志减少到零时,我们得到范围的终点。 换句话说,我们从左到右遍历了汽车的所有端点。 当我们遇到汽车的后端点时,将其添加到标志中,当我们遇到汽车的前端点时,将其从标志中移开。 当标志从零变为 1 时,它是合并范围的起点;当标志从非零变为零时,它是合并范围的端点。
下图更详细地描述了该算法:

通过将起点和终点成对地推到cars_merged向量,我们将得到所有合并的框或合并的范围,因为我们只关心水平方向。
当我们谈到在鸟瞰图中测量距离时,我们说必须有一个固定且已知大小的参考物体,例如硬币。 但是在这种情况下,我们没有满足此条件的对象。 要解决此问题,我们将选择检测到的汽车长度的中位数,并假设其在现实世界中的长度为 5 米,并将其用作参考对象。 让我们看看如何使用此参考车计算合并范围之间的距离:
for (size_t i = 1; i < cars_merged.size(); i++) {
// head of car, start of spacing
int x1 = cars_merged[i - 1].second;
// end of another car, end of spacing
int x2 = cars_merged[i].first;
cv::line(frame, cv::Point(x1, 0), cv::Point(x1, frame.rows),
cv::Scalar(0, 255, 0), 2);
cv::line(frame, cv::Point(x2, 0), cv::Point(x2, frame.rows),
cv::Scalar(0, 0, 255), 2);
float distance = (x2 - x1) * (5.0 / length);
// TODO: show the distance ...
}
在前面的代码中,我们遍历合并的范围,找到范围的头部(车)和下一个范围的后端(车),然后在找到的两个点绘制绿色垂直线和红色垂直线 , 分别。
然后,我们使用(x2 - x1) * (5.0 / length)表达式计算两条垂直线之间的距离,其中5.0是常识上汽车的近似平均长度,length是我们在视频中检测到的汽车长度的中位数。
现在,让我们在框架上显示计算出的距离:
// display the label at the top of the bounding box
string label = cv::format("%.2f m", distance);
int baseLine;
cv::Size labelSize = cv::getTextSize(
label, cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);
int label_x = (x1 + x2) / 2 - (labelSize.width / 2);
cv::putText(
frame, label, cv::Point(label_x, 20),
cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(255, 255, 255));
前面的代码也位于for循环中。 在这里,我们格式化distance变量(它是字符串的浮点数)的格式,并用cv::getTextSize函数测量的文本大小将其绘制在框架的顶部和两行的中间。
至此,可以鸟瞰鸟瞰汽车之间的距离。 让我们在CaptureThread::detectObjectsDNN方法中调用它:
for(size_t i = 0; i < outBoxes.size(); i ++) {
cv::rectangle(frame, outBoxes[i], cv::Scalar(0, 0, 255));
}
distanceBirdEye(frame, outBoxes);
如您所见,在CaptureThread::detectObjectsDNN方法中绘制检测到的汽车的边界框后,我们直接使用边界框的框架和向量调用新添加的函数。 现在,让我们编译并启动我们的应用,然后打开相机以查看外观:

不出所料,我们在视频中发现了许多绿线和红线对,它们表示距离,并且距离的大约长度标记在视频的两线之间。
这种方法的重点是在鸟瞰图中查看感兴趣的对象并找到固定大小的参考对象。 在这里,我们使用经验值作为参考值,因为我们在现实世界中并不总是获得合适的参考对象。 我们使用汽车长度的中位数,因为可能有一半的汽车正在驶入或驶出摄像机的视线,这使得使用平均值不太合适。
我们已经成功地测量了鸟瞰视野中的汽车距离,因此让我们继续看一下如何应对眼高视野。
在眼睛水平视图中测量汽车与摄像头之间的距离
在前面的小节中,我们在鸟瞰图中测量了汽车之间的距离。 在本小节中,我们将测量汽车与摄像头之间的距离。
在这种情况下谈论距离测量时,我们了解到,在测量距离之前,必须将摄像机安装在固定位置,然后从中拍摄照片以获得两个参考值:
- 照片中对象的高度或宽度,以像素为单位。 我们将此值称为
H0或W0。 - 拍摄照片时相机与物体之间的距离。 我们将此值称为
D0。
下面的照片是从我的相机上拍摄的-这是我的车的照片:

这张照片的两个参考值如下:
W0 = 150 pixelsD0 = 10 meters
现在已经有了参考值,让我们开始在代码中进行距离测量。 首先,我们将添加一个名为distanceEyeLevel的新函数:
void distanceEyeLevel(cv::Mat &frame, vector<cv::Rect> &cars)
{
const float d0 = 1000.0f; // cm
const float w0 = 150.0f; // px
// ...
}
像distanceBirdEye函数一样,此函数也将视频帧和检测到的汽车的边界框作为其自变量。 在其主体的开头,我们定义了两个参考值。 然后,我们尝试找到感兴趣的汽车:
// find the target car: the most middle and biggest one
vector<cv::Rect> cars_in_middle;
vector<int> cars_area;
size_t target_idx = 0;
for (auto car: cars) {
if(car.x < frame.cols / 2 && (car.x + car.width) > frame.cols / 2) {
cars_in_middle.push_back(car);
int area = car.width * car.height;
cars_area.push_back(area);
if (area > cars_area[target_idx]) {
target_idx = cars_area.size() - 1;
}
}
}
if(cars_in_middle.size() <= target_idx) return;
考虑到视频中可能检测到不止一辆汽车,我们必须找出一种选择一辆汽车作为目标的方法。 在这里,我们选择了视图中间最大的视图。 为此,我们必须声明三个变量:
cars_in_middle是矩形的向量,该向量将容纳位于视图中间的汽车的边界框。cars_area是一个整数向量,用于将矩形的区域保存在cars_in_middle向量中。target_idx将是我们找到的目标汽车的索引。
我们遍历边界框并检查每个边界框。 如果它的左上角在视频的左侧,而它的右上角在视频的右侧,则说它在视频的中间。 然后,将其及其区域分别推入cars_in_middle向量和cars_area向量。 完成此操作后,我们检查我们刚刚按下的区域是否大于当前目标的区域。 如果为真,则将当前索引设置为目标索引。 迭代完成后,我们将在target_idx变量中获得目标汽车的索引。 然后,我们得到目标汽车的矩形以测量距离:
cv::Rect car = cars_in_middle[target_idx];
float distance = (w0 / car.width) * d0; // (w0 / w1) * d0
// display the label at the top-left corner of the bounding box
string label = cv::format("%.2f m", distance / 100);
int baseLine;
cv::Size labelSize = cv::getTextSize(
label, cv::FONT_HERSHEY_SIMPLEX, 0.5, 1, &baseLine);
cv::putText(frame, label, cv::Point(car.x, car.y + labelSize.height),
cv::FONT_HERSHEY_SIMPLEX, 0.5, cv::Scalar(0, 255, 255));
在前面的代码中,我们根据公式(7)找到矩形并使用(w0 / car.width) * d0表达式计算距离。 然后,将distance变量格式化为字符串,然后将其绘制在目标汽车左上角的边界框中。
最后,我们将对distanceBirdEye函数的调用更改为对CaptureThread::detectObjectsDNN方法中新添加的distanceEyeLevel函数的调用,然后再次编译并运行我们的应用。 看起来是这样的:

如您所见,我们在视频中检测到了多于一辆汽车,但是仅测量了中间一辆与摄像机之间的距离。 距离的长度以黄色文本标记在目标汽车边界框的左上角。
在查看模式之间切换
在前面的两个小节中,我们以两种模式测量距离:鸟瞰图和视平线图。 但是,在我们的 DiGauge 应用中,在这些模式之间切换的唯一方法是更改代码并重新编译应用。 显然,最终用户无法执行此操作。 为了向最终用户介绍此功能,我们将在应用中添加一个新菜单,使用户有机会在两种模式之间进行切换。 让我们开始编码。
首先,让我们在capture_thread.h文件中添加一些行:
class CaptureThread : public QThread
{
// ...
public:
// ...
enum ViewMode { BIRDEYE, EYELEVEL, };
void setViewMode(ViewMode m) {viewMode = m; };
// ...
private:
// ...
ViewMode viewMode;
};
在前面的代码中,我们定义了一个名为ViewMode的公共枚举,它具有两个值来表示两种视图模式,而该类型的私有成员字段则用于指示当前模式。 还有一个公共内联设置器来更新当前模式。
然后,在CaptureThread类的构造器中,在capture_thread.cpp文件中,我们初始化新添加的字段:
CaptureThread::CaptureThread(int camera, QMutex *lock):
running(false), cameraID(camera), videoPath(""), data_lock(lock)
{
frame_width = frame_height = 0;
taking_photo = false;
viewMode = BIRDEYE; // here
}
CaptureThread::CaptureThread(QString videoPath, QMutex *lock):
running(false), cameraID(-1), videoPath(videoPath), data_lock(lock)
{
frame_width = frame_height = 0;
taking_photo = false;
viewMode = BIRDEYE; // and here
}
在CaptureThread::detectObjectsDNN方法中,我们根据viewMode成员字段的值调用distanceBirdEye或distanceEyeLevel:
if (viewMode == BIRDEYE) {
distanceBirdEye(frame, outBoxes);
} else {
distanceEyeLevel(frame, outBoxes);
}
现在,让我们转到mainwindow.h头文件,向MainWindow类添加一些方法和字段:
class MainWindow : public QMainWindow
{
// ...
private slots:
// ...
void changeViewMode();
private:
// ...
QMenu *viewMenu;
QAction *birdEyeAction;
QAction *eyeLevelAction;
// ...
};
在此变更集中,我们向MainWindow类添加了QMenu和两个QAction,以及用于新添加动作的名为changeViewMode的插槽。 现在,让我们实例化mainwindow.cpp源文件中的菜单和操作。
在MainWindow::initUI()方法中,我们创建菜单:
// setup menubar
fileMenu = menuBar()->addMenu("&File");
viewMenu = menuBar()->addMenu("&View");
然后,在MainWindow::createActions方法中,我们实例化动作并将其添加到视图菜单中:
birdEyeAction = new QAction("Bird Eye View");
birdEyeAction->setCheckable(true);
viewMenu->addAction(birdEyeAction);
eyeLevelAction = new QAction("Eye Level View");
eyeLevelAction->setCheckable(true);
viewMenu->addAction(eyeLevelAction);
birdEyeAction->setChecked(true);
如您所见,这次与我们之前创建动作的时候有些不同。 创建动作实例后,我们将它们称为true的setCheckable方法。 这样可以检查动作,并且动作文本左侧的复选框将出现。 最后一行将动作状态birdEyeAction设置为选中。 然后,将动作的triggered信号连接到我们在同一方法中刚刚声明的广告位:
connect(birdEyeAction, SIGNAL(triggered(bool)), this, SLOT(changeViewMode()));
connect(eyeLevelAction, SIGNAL(triggered(bool)), this, SLOT(changeViewMode()));
现在,让我们看看该插槽是如何实现的:
void MainWindow::changeViewMode()
{
CaptureThread::ViewMode mode = CaptureThread::BIRDEYE;
QAction *active_action = qobject_cast<QAction*>(sender());
if(active_action == birdEyeAction) {
birdEyeAction->setChecked(true);
eyeLevelAction->setChecked(false);
mode = CaptureThread::BIRDEYE;
} else if (active_action == eyeLevelAction) {
eyeLevelAction->setChecked(true);
birdEyeAction->setChecked(false);
mode = CaptureThread::EYELEVEL;
}
if(capturer != nullptr) {
capturer->setViewMode(mode);
}
}
在此插槽中,我们获得了信号发送器,该信号发送器必须是两个新添加的动作之一,将发送器设置为选中状态,将另一个设置为未选中,然后根据选中的动作保存查看模式。 之后,我们检查捕获线程是否为空; 如果不是,我们通过调用setViewMode方法设置其查看模式。
我们需要做的最后一件事是在创建并启动新的捕获线程时重置这些操作的状态。 在MainWindow::openCamera方法主体的末尾,我们需要添加几行:
birdEyeAction->setChecked(true);
eyeLevelAction->setChecked(false);
现在,一切都已完成。 让我们编译并运行应用以测试新功能:

从前面的屏幕快照中可以看到,我们可以通过“视图”菜单切换视图模式,我们的 DiGauge 应用终于完成了。
总结
在本章中,我们计划使用 OpenCV 测量汽车之间或汽车与摄像机之间的距离。 首先,我们创建了一个名为 DiGauge 的新应用,通过取消在上一章中开发的 Detective 应用来从摄像机检测汽车。 然后,我们以两种视图模式(鸟瞰图和水平视图)讨论了计算机视觉域中距离测量的原理。 之后,我们在应用中的这两种视图模式中实现了距离测量功能,并在 UI 上添加了一个菜单,以在两种视图模式之间切换。
在下一章中,我们将介绍一种称为 OpenGL 的新技术,并了解如何在 Qt 中使用它以及如何在计算机视觉领域为我们提供帮助。
问题
尝试回答以下问题,以测试您对本章的了解:
- 在测量汽车之间的距离时,是否可以使用更好的参考对象?
八、OpenGL 图像高速过滤
在前面的章节中,我们学到了很多有关如何使用 OpenCV 处理图像和视频的知识。 这些过程大多数由 CPU 完成。 在本章中,我们将探索另一种处理图像的方法,即使用 OpenGL 将图像过滤从 CPU 移至图形处理单元(GPU)。
在许多类型的软件(例如 Google Chrome 浏览器)中,您可能会在“设置”页面上看到用于硬件加速或类似功能的选项。 通常,这些设置意味着将图形卡(或 GPU)用于渲染或计算。 这种使用另一个处理器而不是 CPU 进行计算或渲染的方法称为异构计算。 进行异构计算的方法有很多,包括 OpenCL,我们在第 6 章,“实时对象检测”中提到了这一点,而我们是在使用 OpenCV 及其 OpenCL 后端运行深度学习模型时 。 我们将在本章中介绍的 OpenGL 也是一种异构计算的方法,尽管它主要用于 3D 图形渲染。 在这里,我们将使用它来过滤 GPU 上的图像,而不是渲染 3D 图形。
本章将涵盖以下主题:
- OpenGL 简介
- 在 Qt 中使用 OpenGL
- 使用 OpenGL 在 GPU 上过滤图像
- 在 OpenCV 中使用 OpenGL
技术要求
必须具备 C 和 C++ 编程语言的基本知识才能遵循本章。 由于 OpenGL 将是本章的主要部分,因此对 OpenGL 的深入了解也将是一个很大的优势。
考虑到我们将 Qt 和 OpenCV 与 OpenGL 一起使用,至少要求读者以与前面各章相同的方式安装 Qt 5 和 OpenCV 4.0.0。
你好 OpenGL
OpenGL 不是像 OpenCV 或 Qt 这样的典型编程库。 它的维护者 Khronos 组仅设计和定义 OpenGL 的 API 作为规范。 但是,它不负责执行。 相反,图形卡制造商应负责提供实现。 大多数制造商,例如英特尔,AMD 和 Nvidia,都在其显卡驱动程序中提供了实现。 在 Linux 上,有一个称为 Mesa 的 OpenGL 实现,如果图形卡驱动正确,它可以进行软件渲染,同时也支持硬件渲染。
如今,OpenGL 的学习曲线非常陡峭。 这是因为您需要了解异构架构和另一种编程语言,称为 OpenGL Shading Language,以及 C 和 C++。 在本章中,我们将使用新样式的 API 来渲染和过滤图像,该 API 是 OpenGL V4.0 中引入的,并已反向移植到 V3.3。 我们将从一个简单的示例开始,向 OpenGL 说“Hello”。
在开始示例之前,我们应该确保在我们的计算机上安装了 OpenGL 和一些帮助程序库。 在 Windows 上,如果您安装了用于图形卡的最新驱动程序,则还将安装 OpenGL 库。 在现代 MacOS 上,预先安装了 Apple 实现的 OpenGL 库。 在 Linux 上,我们可以使用 Mesa 实现或已安装图形卡的专有硬件驱动程序。 使用 Mesa 更容易,因为一旦安装了 Mesa 的运行时和开发包,我们将获得有效的 OpenGL 安装。
在使用 OpenGL 进行任何操作之前,我们必须创建一个 OpenGL 上下文进行操作,并创建一个与该上下文关联的窗口以显示渲染的图形。 这项工作通常取决于平台。 幸运的是,有许多库可以隐藏这些与平台有关的细节,并包装用于该用途的通用 API。 在这里,我们将使用 GLFW 和 GLEW 库。 GLFW 库将帮助我们创建 OpenGL 上下文和一个窗口来显示渲染的图形,而 GLEW 库将处理 OpenGL 标头和扩展名。 在类似 UNIX 的系统上,我们可以从源代码构建它们,也可以使用系统包管理器轻松地安装它们。 在 Windows 上,我们可以下载两个帮助程序库的官方网站上提供的二进制包以进行安装。
最后,在安装所有必备组件之后,我们可以启动Hello OpenGL示例。 编写 OpenGL 程序通常涉及以下步骤,如下所示:
- 创建上下文和窗口。
- 准备要绘制的对象的数据(以 3D 形式)。
- 通过调用一些 OpenGL API 将数据传递给 GPU。
- 调用绘图指令以告诉 GPU 绘制对象。 在绘制过程中,GPU 将对数据进行许多操作,并且可以通过使用 OpenGL 着色语言编写着色器来自定义这些操作。
- 编写将在 GPU 上运行的着色器,以操纵 GPU 上的数据。
让我们看一下如何在代码中执行这些步骤。 首先,我们创建一个名为main.c的源文件,然后添加基本的include指令和main函数,如下所示:
#include <stdio.h>
#include <GL/glew.h>
#include <GLFW/glfw3.h>
int main() {
return 0;
}
然后,正如我们提到的,第一步是创建 OpenGL 上下文和用于显示图形的窗口:
// init glfw and GL context
if (!glfwInit()) {
fprintf(stderr, "ERROR: could not start GLFW3\n");
return 1;
}
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3); // 3.3 or 4.x
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
GLFWwindow *window = NULL;
window = glfwCreateWindow(640, 480, "Hello OpenGL", NULL, NULL);
if (!window) {
fprintf(stderr, "ERROR: could not open window with GLFW3\n");
glfwTerminate();
return 1;
}
glfwMakeContextCurrent(window);
在这段代码中,我们首先通过调用其glfwInit函数来初始化 GLFW 库。 然后,我们使用glfwWindowHint函数设置一些提示,如下所示:
GLFW_CONTEXT_VERSION_MAJOR和GLFW_CONTEXT_VERSION_MINOR用于指定 OpenGL 版本; 正如我们提到的,我们使用的是新样式的 API,该 API 是在 V4.0 中引入的,并已反向移植到 V3.3,因此,这里至少应使用 V3.3。GLFW_OPENGL_FORWARD_COMPAT将 OpenGL 前向兼容性设置为true。GLFW_OPENGL_PROFILE用于设置用于创建 OpenGL 上下文的配置文件。 通常,我们可以选择两个配置文件:核心配置文件和兼容性配置文件。 使用核心配置文件,只能使用新样式的 API,而使用兼容性配置文件,制造商可以提供对旧 API 和新 API 的支持。 但是,使用兼容性配置文件时,在某些实现上运行新版本的着色器时可能会出现一些故障。 因此,在这里,我们使用核心配置文件。
设置提示后,我们声明并创建窗口。 从glfwCreateWindow函数的参数中可以看到,新创建的窗口的宽度为 640 像素,高度为 480 像素,并以Hello OpenGL字符串作为标题。
与该窗口关联的 OpenGL 上下文也随该窗口一起创建。 我们调用glfwMakeContextCurrent函数将上下文设置为当前上下文。
之后,GLEW库也需要初始化:
// start GLEW extension handler
GLenum ret = glewInit();
if ( ret != GLEW_OK) {
fprintf(stderr, "Error: %s\n", glewGetErrorString(ret));
}
接下来,让我们转到第二步,准备要绘制的对象的数据。 我们在这里画一个三角形。 三角形是 OpenGL 中最原始的形状,因为我们在 OpenGL 中绘制的几乎所有东西都是由三角形组成的。 以下是三角形的数据:
GLfloat points[] = {+0.0f, +0.5f, +0.0f,
+0.5f, -0.5f, +0.0f,
-0.5f, -0.5f, +0.0f };
如您所见,数据是一个由 9 个元素组成的浮点数组。 也就是说,我们使用三个浮点数来描述 3D 空间中三角形的每个顶点,并且每个三角形有三个顶点。 在本书中,我们不会过多关注 3D 渲染,因此我们将每个顶点的z坐标设置为 0.0,以将三角形绘制为 2D 形状。
OpenGL 使用称为规范化设备坐标(NDC)的坐标系。 在该坐标系中,所有坐标都限制在 -1.0 和 1.0 的范围内。 如果对象的坐标超出此范围,则它们将不会显示在 OpenGL 视口中。 通过省略z轴,可以通过下图演示 OpenGL 的视口和给出的点(形成三角形):

顶点数据已准备就绪,现在我们应该将其传递到 GPU。 这是通过顶点缓冲对象(VBO)和顶点数组对象(VAO)完成的; 让我们看下面的代码:
// vbo
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, 9 * sizeof(GLfloat), points, GL_STATIC_DRAW);
在前面的代码中,对glGenBuffers函数的调用将生成一个顶点缓冲区对象(第一个参数),并将对象的名称存储到vbo变量(第二个参数)中。 然后,我们调用glBindBuffer函数将顶点缓冲区对象绑定为GL_ARRAY_BUFFER类型的当前 OpenGL 上下文,这意味着该对象用于顶点属性的数据。 最后,我们调用glBufferData函数来创建数据存储,并使用当前绑定缓冲区的顶点数据对其进行初始化。 函数调用的最后一个参数告诉 OpenGL 我们的数据不会更改,这是优化的提示。
现在,我们已经将数据填充到顶点缓冲区对象中,但是该缓冲区在 GPU 上不可见。 为了使它在 GPU 上可见,我们应该引入一个顶点数组对象并放置一个指向其中缓冲区的指针:
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, NULL);
与顶点缓冲区对象一样,顶点数组对象应在使用前生成并绑定。 之后,我们调用glEnableVertexAttribArray函数启用索引为0的通用顶点属性数组指针; 在顶点数组对象中。 您可以认为这是为我们在顶点数组对象中创建的顶点缓冲对象保留席位,席位号为0。 然后,我们调用glVertexAttribPointer函数,以使当前绑定的顶点缓冲对象位于相反的位置。 该函数接受许多参数,其签名如下:
void glVertexAttribPointer(
GLuint index,
GLint size,
GLenum type,
GLboolean normalized,
GLsizei stride,
const GLvoid * pointer
);
让我们一一探讨它们,如下所示:
index指定索引(或座位号)。size指定每个顶点属性的组件数; 每个顶点(或点)有三个浮点数,因此我们在此处使用3。type是缓冲区的元素类型或顶点属性的组成部分的数据类型。normalized指定在 GPU 上访问数据之前是否应通过 OpenGL 对我们的数据进行规范化。 在我们的例子中,我们使用规范化的数据(介于-1.0 和 1.0 之间),因此不需要再次进行规范化。stride是连续的通用顶点属性之间的偏移量。 我们在这里使用0来告诉 OpenGL 我们的数据紧密包装并且没有偏移量。pointer是缓冲区中第一个通用顶点属性的第一部分的偏移量。 我们使用NULL表示零偏移。
至此,我们已经通过使用顶点缓冲对象和顶点数组对象将顶点数据成功传递到了 GPU 上。 然后,数据将被发送到 OpenGL 的图形管道。 OpenGL 图形管线有几个阶段:接受我们的 3D 顶点,数据和一些其他数据,将它们转换为 2D 图形中的彩色像素,并将其显示在屏幕上。 GPU 具有大量处理器,可以在这些处理器上并行完成顶点的转换。 因此,通过使用 GPU,我们可以在处理图像或进行可并行化的数值计算时提高性能。
在继续之前,让我们先看一下 OpenGL 图形管线的各个阶段。 我们可以将其大致分为六个阶段,如下所示:
- 顶点着色器:此阶段将顶点属性数据(在我们的情况下,我们已经传递给 GPU)作为其输入,并给出每个顶点的位置作为其输出。 OpenGL 在此阶段没有提供默认的着色器程序,因此我们应该自己编写一个。
- 形状组装:此阶段用于组装形状;此阶段用于组装形状。 例如,生成顶点并将其定位。 这是一个可选阶段,对于我们来说,我们将忽略它。
- 几何着色器:此阶段用于生成或删除几何,它也是一个可选阶段,我们无需编写着色器程序。
- 栅格化:此阶段将 3D 形状(在 OpenGL 中主要是三角形)转换为 2D 像素。 此阶段不需要任何着色器程序。
- 片段着色器:此阶段用于着色光栅化阶段中的片段。 像顶点着色器阶段一样,OpenGL 在此阶段不提供默认的着色器程序,因此我们应该自己编写一个。
- 混合:此阶段在屏幕或帧缓冲区上渲染 2D 图形。
这六个阶段中的每个阶段都将其前一级的输出作为输入,并将输出提供给下一级。 此外,在某些阶段,我们可以或需要编写着色器程序来参与这项工作。 着色器程序是一段用 OpenGL 着色语言编写并在 GPU 上运行的代码。 它由 OpenGL 实现在我们的应用运行时中编译。 在前面的阶段列表中可以看到,至少有两个阶段,即顶点着色器和片段着色器,即使在最小的 OpenGL 应用中,也需要我们提供着色器程序。 这是 OpenGL 学习曲线中最陡峭的部分。 让我们检查一下这些着色器程序的外观:
// shader and shader program
GLuint vert_shader, frag_shader;
GLuint shader_prog;
const char *vertex_shader_code = "#version 330\n"
"layout (location = 0) in vec3 vp;"
"void main () {"
" gl_Position = vec4(vp, 1.0);"
"}";
const char *fragment_shader_code = "#version 330\n"
"out vec4 frag_colour;"
"void main () {"
" frag_colour = vec4(0.5, 1.0, 0.5, 1.0);"
"}";
vert_shader = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vert_shader, 1, &vertex_shader_code, NULL);
glCompileShader(vert_shader);
frag_shader = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(frag_shader, 1, &fragment_shader_code, NULL);
glCompileShader(frag_shader);
shader_prog = glCreateProgram();
glAttachShader(shader_prog, frag_shader);
glAttachShader(shader_prog, vert_shader);
glLinkProgram(shader_prog);
在这段代码中,我们首先定义三个变量:vert_shader和frag_shader用于相应阶段所需的着色器程序,shader_prog用于整个着色器程序,它将包含所有着色器程序的所有着色器程序。 阶段。 然后,将着色器程序作为字符串编写在代码中,我们将在后面解释。 接下来,我们通过调用glCreateShader函数并向其附加源字符串来创建每个着色器程序,然后对其进行编译。
准备好阶段的着色器程序之后,我们将创建整个着色器程序对象,将阶段着色器程序附加到该对象,然后链接该程序。 至此,整个着色器程序就可以使用了,我们可以调用glUseProgram来使用它。 我们将在解释着色器程序的代码之后再做。
现在,让我们看一下顶点着色器:
#version 330
layout (location = 0) in vec3 vp;
void main() {
gl_Position = vec4(vp, 1.0);
}
前面代码的第一行是版本提示,即,它指定 OpenGL 着色语言的版本。 在这里,我们使用版本 330,它对应于我们使用的 OpenGL 版本。
然后,在第二行中,声明输入数据的变量。 layout (location = 0)限定符指示此输入数据与当前绑定的顶点数组对象的索引0(或编号为0的座位)相关联。 另外,in关键字表示它是输入变量。 代表3浮点数向量的单词vec3是数据类型,vp是变量名。 在我们的例子中,vp将是我们存储在points变量中的一个顶点的坐标,并且这三个顶点将被分派到 GPU 上的三个不同处理器,因此这段代码的每个顶点将在这些处理器上并行运行。 如果只有一个输入数组,则可以在此着色器中省略layout限定符。
正确描述输入数据之后,我们定义main函数,该函数是程序的入口点,就像使用 C 编程语言一样。 在main函数中,我们从输入构造一个包含四个浮点数的向量,然后将其分配给gl_Position变量。 gl_Position变量是预定义的变量,它是下一阶段的输出,并表示顶点的位置。
该变量的类型为vec4,但不是vec3; 第四个组件名为w,而前三个组件为x,y和z,我们可以猜测。 w成分是一个因子,用于分解其他向量成分以使其均一; 在本例中,我们使用 1.0,因为我们的值已经是标准化值。
总而言之,我们的顶点着色器从顶点数组对象获取输入,并保持不变。
现在,让我们看一下片段着色器:
#version 330
out vec4 frag_colour;
void main () {
frag_colour = vec4(0.5, 1.0, 0.5, 1.0);
}
在此着色器中,我们使用vec4类型的out关键字定义一个输出变量,它以 RGBA 格式表示颜色。 然后,在main函数中,我们为输出变量分配恒定的颜色,即浅绿色。
现在,着色器程序已经准备就绪,让我们开始图形管道:
while (!glfwWindowShouldClose(window)) {
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
glUseProgram(shader_prog);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
// update other events like input handling
glfwPollEvents();
// put the stuff we've been drawing onto the display
glfwSwapBuffers(window);
}
glfwTerminate();
在前面的代码中,除非关闭应用窗口,否则我们将连续运行代码块。 在代码块中,我们清除窗口上的位平面区域,然后使用我们创建的着色器程序并绑定顶点数组对象。 此操作将着色器程序和数组或缓冲区与当前 OpenGL 上下文连接。 接下来,我们调用glDrawArrays启动图形管道以绘制对象。 glDrawArrays函数的第一个参数是原始类型。 在这里,我们要绘制一个三角形,因此使用GL_TRIANGLES。 第二个参数是我们为顶点缓冲区对象启用的缓冲区索引,最后一个参数是我们要使用的顶点数。
至此,绘制三角形的工作已经完成,但是我们还有更多工作要做:我们调用glfwPollEvents函数以捕获发生在窗口上的事件,并通过窗口对象调用glfwSwapBuffers函数来显示我们绘制的图形。 我们需要后一个函数调用,因为 GLFW 库使用双缓冲区优化。
当用户关闭窗口时,我们将跳出代码块并启动对glfwTerminate函数的调用,以释放 GLFW 库分配的所有资源。 然后应用退出。
好的,让我们编译并运行该应用:
gcc -Wall -std=c99 -o main.exe main.c -lGLEW -lglfw -lGL -lm
./main.exe
您将看到以下绿色三角形:

如果使用 Windows,则可以使用gcc -Wall -std=c99 -o main.exe main.c libglew32.dll.a glfw3dll.a -lOpenGL32 -lglew32 -lglfw3 -lm命令在 MinGW 上编译应用。 不要忘记使用-I和-L选项指定 GLFW 和 GLEW 库的包含路径和库路径。
好的,我们的第一个 OpenGL 应用完成了。 但是,正如您所看到的,GLFW 并不是完整的 GUI 库,尤其是当我们将其与 Qt 库进行比较时,在本书中我们经常使用它。 GLFW 库可以创建窗口并捕获和响应 UI 事件,但是它没有很多小部件。 那么,如果我们在应用中同时需要 OpenGL 和一些小部件,会发生什么情况? 我们可以在 Qt 中使用 OpenGL 吗? 答案是肯定的,我们将在下一节中演示如何做到这一点。
Qt 中的 OpenGL
在早期,Qt 有一个名为OpenGL的模块,但是在 Qt 5.x 中,该模块已被弃用。 gui模块中加入了新版本的 OpenGL 支持函数。 如果您在 Qt 文档中搜索名称以QOpenGL开头的类,则会找到它们。 除了gui模块中的函数外,widgets模块中还有一个重要的类,名为QOpenGLWidget。 在本节中,我们将使用其中一些函数在 Qt 中使用 OpenGL 绘制一个三角形。
首先,让我们创建所需的 Qt 项目:
$ pwd
/home/kdr2/Work/Books/Qt5-And-OpenCV4-Computer-Vision-Projects/Chapter-08
$ mkdir QtGL
$ cd QtGL/
$ touch main.cpp
$ qmake -project
$ ls
QtGL.pro main.cpp
$
然后,我们将QtGL.pro项目文件的内容更改为以下内容:
TEMPLATE = app
TARGET = QtGL
QT += core gui widgets
INCLUDEPATH += .
DEFINES += QT_DEPRECATED_WARNINGS
# Input
HEADERS += glpanel.h
SOURCES += main.cpp glpanel.cpp
RESOURCES = shaders.qrc
这是指许多目前尚不存在的文件,但请不要担心-我们将在编译项目之前创建所有文件。
首先,我们将创建一个名为GLPanel的小部件类,以显示将在 OpenGL 上下文中绘制的图形。 我们用于准备数据以及绘制图形的代码也将在此类中。 让我们检查一下glpanel.h头文件中的GLPanel类的声明:
class GLPanel : public QOpenGLWidget, protected QOpenGLFunctions_4_2_Core
{
Q_OBJECT
public:
GLPanel(QWidget *parent = nullptr);
~GLPanel();
protected:
void initializeGL() override;
void paintGL() override;
void resizeGL(int w, int h) override;
private:
GLuint vbo;
GLuint vao;
GLuint shaderProg;
};
该类派生自两个类:QOpenGLWidget类和QOpenGLFunctions_4_2_Core类。
QOpenGLWidget类提供了三个必须在我们的类中实现的受保护的方法,如下所示:
initializeGL方法用于初始化; 例如,准备顶点数据,顶点缓冲区对象,数组缓冲区对象和着色器程序。paintGL方法用于绘图工作; 例如,在其中我们将调用glDrawArrays函数。resizeGL方法是在调整窗口小部件大小时将调用的函数。
QOpenGLFunctions_4_2_Core类包含许多函数,它们的名称与 Khronos 的 OpenGL V4.2 API 相似。 类名称中的4_2字符串指示我们正在使用的 OpenGL 版本,Core字符串告诉我们已使用 OpenGL 的核心配置文件。 我们从该类派生我们的类,以便我们可以使用具有相同名称的所有 OpenGL 函数,而在我们的类中没有任何前缀,尽管这些函数实际上是 Qt 提供的包装器。
让我们转到glpanel.cpp源文件以查看实现。 构造器和析构器非常简单,因此在此不再赘述。 首先,让我们看一下初始化方法void GLPanel::initializeGL():
void GLPanel::initializeGL()
{
initializeOpenGLFunctions();
// ... omit many lines
std::string vertex_shader_str = textContent(":/shaders/vertex.shader");
const char *vertex_shader_code = vertex_shader_str.data();
std::string fragment_shader_str = textContent(":/shaders/fragment.shader");
const char *fragment_shader_code = fragment_shader_str.data();
// ... omit many lines
}
此方法中的大多数代码是从上一部分的main函数复制而来的; 因此,我在这里省略了很多行,仅说明了相同点和不同点。 在这种方法中,我们准备了顶点数据,顶点缓冲对象和顶点数组对象。 将数据传递给 GPU; 并编写,编译和链接着色器程序。 除了vao,vbo和shaderProg是类成员而不是局部变量之外,该过程与前面的应用相同。
除此之外,我们在一开始就调用initializeOpenGLFunctions方法来初始化 OpenGL 函数包装器。 另一个区别是我们将着色器代码移到单独的文件中,以提高着色器程序的可维护性。 我们将文件放在名为shaders的子目录下,并在shaders.qrc Qt 资源文件中引用它们:
<!DOCTYPE RCC>
<RCC version="1.0">
<qresource>
<file>shaders/vertex.shader</file>
<file>shaders/fragment.shader</file>
</qresource>
</RCC>
然后,我们使用textContent函数加载这些文件的内容。 此函数也在glpanel.cpp文件中定义:
std::string textContent(QString path) {
QFile file(path);
file.open(QFile::ReadOnly | QFile::Text);
QTextStream in(&file);
return in.readAll().toStdString();
}
现在初始化已完成,让我们继续进行paintGL方法:
void GLPanel::paintGL()
{
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glUseProgram(shaderProg);
glBindVertexArray(vao);
glDrawArrays(GL_TRIANGLES, 0, 3);
glFlush();
}
如您所见,在此方法中绘制三角形的所有操作与先前应用中的最后一个代码块完全相同。
最后,当调整窗口小部件的大小时,我们调整 OpenGL 视口的大小:
void GLPanel::resizeGL(int w, int h)
{
glViewport(0, 0, (GLsizei)w, (GLsizei)h);
}
至此,我们已经完成了GLPanel OpenGL 小部件,因此让我们在main.cpp文件中使用它:
int main(int argc, char *argv[])
{
QApplication app(argc, argv);
QSurfaceFormat format = QSurfaceFormat::defaultFormat();
format.setProfile(QSurfaceFormat::CoreProfile);
format.setVersion(4, 2);
QSurfaceFormat::setDefaultFormat(format);
QMainWindow window;
window.setWindowTitle("QtGL");
window.resize(800, 600);
GLPanel *panel = new GLPanel(&window);
window.setCentralWidget(panel);
window.show();
return app.exec();
}
在main函数中,我们获取默认的QSurefaceFormat类型并更新与 OpenGL 相关的一些关键设置,如下所示:
- 将配置文件设置为核心配置文件。
- 由于我们使用
QOpenGLFunctions_4_2_Core类,因此将版本设置为 V4.2。
然后,我们创建主窗口和GLPanel类的实例,将GLPanel实例设置为主窗口的中央小部件,显示该窗口并执行该应用。 我们在 OpenGL 中的第一个 Qt 应用已经完成,因此您现在可以编译并运行它。 但是,您可能会发现与前面的示例中的窗口没有什么不同。 是的,我们向您展示了如何在 Qt 项目中使用 OpenGL,但没有向您展示如何制作具有许多小部件的复杂应用。 由于在前几章中我们已经学到了很多有关如何使用 Qt 构建复杂的 GUI 应用的知识,并且现在有了 OpenGL 小部件,因此您可以尝试自己开发具有 OpenGL 功能的复杂 Qt 应用。 在下一节中,我们将更深入地研究 OpenGL,以探索如何使用 OpenGL 过滤图像。
除了QOpenGLFunctions_*类中的 OpenGL API 函数外,Qt 还为 OpenGL 中的概念包装了许多其他类。 例如,QOpenGLBuffer类用于顶点缓冲区对象,QOpenGLShaderProgram类型用于着色器程序,等等。 这些类的使用也非常方便,但与最新版本的 OpenGL 相比可能会(或将会)落后一些。
使用 OpenGL 过滤图像
到目前为止,我们已经学习了如何在 OpenGL 中绘制一个简单的三角形。 在本节中,我们将学习如何绘制图像并使用 OpenGL 对其进行过滤。
我们将在 QtGL 项目的副本(即名为GLFilter的新项目)中进行此工作。 就像我们在前几章中所做的那样,该项目的创建仅涉及直接复制和一点重命名。 我在这里不再重复,所以请自己复制。
使用 OpenGL 绘制图像
为了在 OpenGL 视口上绘制图像,我们应该引入 OpenGL 的另一个概念-纹理。 OpenGL 中的纹理通常是 2D 图像,通常用于向对象(主要是三角形)添加视觉细节。
由于任何类型的数字图像通常都是矩形,因此我们应绘制两个三角形以组成图像的矩形,然后将图像加载为纹理并将其映射到矩形。
纹理使用的坐标系与绘制三角形时使用的 NDC 不同。 纹理坐标系的x和y(轴)都在0和1之间,即,左下角是(0, 0),右上角是(1, 1)。 因此,我们的顶点和坐标映射如下所示:

上图显示了我们将绘制的两个三角形之一,即右下角的三角形。 括号中的坐标为三角形顶点的坐标,方括号中的坐标为纹理的坐标。
如图所示,我们定义顶点属性数据如下:
GLfloat points[] = {
// first triangle
+1.0f, +1.0f, +0.0f, +1.0f, +1.0f, // top-right
+1.0f, -1.0f, +0.0f, +1.0f, +0.0f, // bottom-right
-1.0f, -1.0f, +0.0f, +0.0f, +0.0f, // bottom-left
// second triangle
-1.0f, -1.0f, +0.0f, +0.0f, +0.0f, // bottom-left
-1.0f, +1.0f, +0.0f, +0.0f, +1.0f, // top-left
+1.0f, +1.0f, +0.0f, +1.0f, +1.0f // top-right
};
如您所见,我们为两个三角形定义了六个顶点。 此外,每个顶点有五个浮点数-前三个是三角形顶点的坐标,而后两个是与顶点对应的纹理的坐标。
现在,让我们将数据传递到 GPU:
// VBA & VAO
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(points), points, GL_STATIC_DRAW);
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), NULL);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
在前面的代码中,我们创建顶点缓冲区对象,将浮点数填充到其中,然后创建顶点数组对象。 在这里,与上一次在Hello OpenGL示例中绘制单个三角形时,将顶点缓冲区对象绑定到顶点数组对象相比,我们在顶点数组对象中启用了两个指针,但没有一个; 也就是说,我们在那里预留了两个席位。 第一个索引为0,用于三角形顶点的坐标。 第二个索引是1,用于将纹理坐标映射到顶点。
下图显示了缓冲区中三个顶点的数据布局以及我们如何在顶点数组对象中使用它:

我们将顶点的坐标用作索引为 0 的指针,如图所示,每个顶点的元素计数为 3,步幅为 20(5 * sizeof(float)),其偏移量为 0。这些是我们第一次调用glVertexAttribPointer函数时传递的参数。 对于纹理的坐标,元素数为 2,步幅为 20,偏移量为 12。使用这些数字,我们再次调用glVertexAttribPointer函数来设置数组指针。
好的,因此将顶点属性的数据传递到 GPU; 现在让我们演示如何将图像(或纹理)加载到 GPU 上:
// texture
glEnable(GL_TEXTURE_2D);
// 1\. read the image data
QImage img(img/lizard.jpg");
img = img.convertToFormat(QImage::Format_RGB888).mirrored(false, true);
// 2\. generate texture
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB,
img.width(), img.height(), 0, GL_RGB, GL_UNSIGNED_BYTE, img.bits());
glGenerateMipmap(GL_TEXTURE_2D);
在这段代码中,我们启用 OpenGL 的 2D 纹理功能,从 Qt 资源系统加载图像,然后将图像转换为 RGB 格式。 您可能会注意到,我们通过调用mirrored方法在垂直方向翻转图像。 这是因为 Qt 中的图像和 OpenGL 中的纹理使用不同的坐标系:(0, 0)是 Qt 图像中的左上角,而它是 OpenGL 纹理中的左下角。 换句话说,它们的y轴方向相反。
加载图像后,我们生成一个纹理对象,并将其名称保存到texture类成员,并将其绑定到当前 OpenGL 上下文。 然后,我们调用glTexImage2D函数将图像数据复制到 GPU 的纹理内存中。 这是此函数的签名:
void glTexImage2D(
GLenum target,
GLint level,
GLint internalformat,
GLsizei width,
GLsizei height,
GLint border,
GLenum format,
GLenum type,
const GLvoid * data
);
让我们检查一下此函数的参数,如下所示:
- 以
GL_TEXTURE_2D作为其值的target指定纹理目标。 OpenGL 还支持 1D 和 3D 纹理,因此我们使用此值来确保它是我们正在操作的当前绑定的 2D 纹理。 level是 mipmap 级别。 在 OpenGL 中,mipmap 是通过调整原始图像大小而生成的一系列不同大小的图像。 在该系列中,每个后续图像都是前一个图像的两倍。 自动选择了 mipmap 中的图像,以用于不同的对象大小,或特定对象位于不同位置时使用。 例如,如果物体在我们看来不远,则将使用较小的图像。 与动态调整纹理大小到适当大小相比,使用 mipmap 中预先计算的纹理可以减少计算并提高图像质量。 如果要手动操作 mipmap,则应使用非零值。 在这里,我们将使用 OpenGL 提供的函数来生成 mipmap,因此我们只需使用0即可。internalformat告诉 OpenGL 我们想以哪种格式存储纹理。我们的图像只有 RGB 值,因此我们也将存储带有 RGB 值的纹理。width和height是目标纹理的宽度和高度。 我们在这里使用图像的尺寸。border是没有意义的传统参数,应始终为0。format和type是源图像的格式和数据类型。data是指向实际图像数据的指针。
调用返回后,纹理数据已在 GPU 上准备就绪,然后调用glGenerateMipmap(GL_TEXTURE_2D)为当前绑定的 2D 纹理生成 mipmap。
至此,纹理已经准备就绪,让我们看一下着色器程序。 首先是顶点着色器,如下所示:
#version 420
layout (location = 0) in vec3 vertex;
layout (location = 1) in vec2 inTexCoord;
out vec2 texCoord;
void main()
{
gl_Position = vec4(vertex, 1.0);
texCoord = inTexCoord;
}
在此着色器中,两个输入变量(其位置为0和1)对应于我们在顶点数组对象中启用的两个指针,它们表示三角形顶点的坐标和纹理映射坐标。 在主函数中,我们通过将顶点分配给预定义变量gl_Position来设置顶点的位置。 然后,我们将纹理坐标传递给使用out关键字声明的输出变量。 这个输出变量将被传递给下一个着色器,即片段着色器。 以下是我们的片段着色器:
#version 420
in vec2 texCoord;
out vec4 frag_color;
uniform sampler2D theTexture;
void main()
{
frag_color = texture(theTexture, texCoord);
}
此片段着色器中有三个变量,如下所示:
in vec2 texCoord是来自顶点着色器的纹理坐标。out vec4 frag_color是输出变量,我们将对其进行更新以传递片段的颜色。uniform sampler2D theTexture是纹理。 它是uniform变量; 与in和out变量不同,可以在 OpenGL 图形管线的任何阶段的任何着色器中看到uniform变量。
在主函数中,我们使用内置的texture函数获取与给定纹理坐标texCoord对应的颜色,并将其分配给输出变量。 从纹理中选择颜色的过程在 OpenGL 术语中称为纹理采样。
现在,着色器已准备就绪。 我们在初始化函数中要做的最后一件事是调整主窗口的大小以适合图像大小:
((QMainWindow*)this->parent())->resize(img.width(), img.height());
好的,现在让我们在GLPanel::paintGL方法中绘制对象:
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT | GL_STENCIL_BUFFER_BIT);
glUseProgram(shaderProg);
glBindVertexArray(vao);
glBindTexture(GL_TEXTURE_2D, texture);
glDrawArrays(GL_TRIANGLES, 0, 6);
glFlush();
与上次绘制三角形相比,这里我们向glBindTexture添加了一个新函数调用,以将新创建的纹理绑定到当前 OpenGL 上下文,并使用6作为glDrawArrays函数的第三个参数, 因为我们要绘制两个三角形。
现在,该编译并运行我们的应用了。 如果一切顺利,您将看到 OpenGL 渲染的图像:

尽管在我们的代码中,我们从 Qt 资源系统加载了图像,但是我们可以在本地磁盘上加载任何图像-只需更改路径即可。
在片段着色器中过滤图像
在前面的小节中,我们使用 OpenGL 绘制了图像。 绘制图像时,我们从片段着色器的纹理(与原始图像具有相同的数据)中选择了颜色。 因此,如果我们在片段着色器中根据特定规则更改颜色,然后再将其散发出去,我们会得到修改后的图像吗?
按照这种想法,让我们在片段着色器程序中尝试一个简单的线性模糊过滤器。 下图显示了线性模糊过滤器的原理:

对于给定的像素,我们根据其周围像素的颜色确定其颜色。 在上图中,对于给定的像素,我们在其周围绘制5 x 5的正方形,并确保它是正方形的中心像素。 然后,我们对正方形中除中心像素本身以外的所有像素的颜色求和,求出平均值(通过将总和除以5 x 5 - 1),然后将平均值用作给定像素的颜色。 在这里,我们将平方称为过滤器核及其边长,即核大小5。
但是,我们这里有一个问题。 texCoord变量中存储的坐标是 0 到 1 之间的浮点数,而不是像素数。 在这样的范围内,我们无法直接确定核大小,因此我们需要知道纹理坐标系中一个像素代表多长时间。 这可以通过将 1.0 除以图像的宽度和高度来解决。 这样,我们将获得两个浮点数,它们在纹理坐标系中分别代表一个像素的宽度和一个像素的高度。 稍后,我们将两个数字存储在统一的两个元素向量中。 让我们更新片段着色器,如下所示:
#version 420
in vec2 texCoord;
out vec4 frag_color;
uniform sampler2D theTexture;
uniform vec2 pixelScale;
void main()
{
int kernel_size = 5;
vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
for(int i = -(kernel_size / 2); i <= kernel_size / 2; i++) {
for(int j = -(kernel_size / 2); j <= kernel_size / 2; j++) {
if(i == 0 && j == 0) continue;
vec2 coord = vec2(texCoord.x + i * pixelScale.x, texCoord.y + i * pixelScale.y);
color = color + texture(theTexture, coord);
}
}
frag_color = color / (kernel_size * kernel_size - 1);
}
前面代码中的uniform vec2 pixelScale变量是我们刚刚讨论的比率数。 在主函数中,我们使用5作为核大小,计算出核正方形中像素的纹理坐标,拾取颜色,然后将它们汇总为两级嵌套for循环。 循环后,我们计算平均值并将其分配给输出变量。
下一步是将值设置为uniform vec2 pixelScale变量。 链接着色器程序后,可以通过GLPanel::initializeGL方法完成此操作:
// ...
glLinkProgram(shaderProg);
// scale ration
glUseProgram(shaderProg);
int pixel_scale_loc = glGetUniformLocation(shaderProg, "pixelScale");
glUniform2f(pixel_scale_loc, 1.0f / img.width(), 1.0f / img.height());
链接着色器程序后,我们在当前 OpenGL 上下文中激活(即使用)着色器程序,然后使用着色器程序和统一变量名称作为其参数调用glGetUniformLocation。 该调用将返回统一变量的位置。 在此位置,我们可以调用glUniform2f设置其值。 函数名称中的2f后缀表示两个浮点数,因此,我们将两个缩放比例传递给它。
至此,除一种情况外,我们的过滤器已基本完成。 考虑如果我们正在计算其颜色的给定像素位于图像边缘,将会发生什么情况。 换句话说,我们如何处理图像的边缘? 解决方法如下:
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_MIRRORED_REPEAT);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT);
在GLPanel::initializeGL方法中将这两行添加到生成和绑定纹理的行旁边。 这些行设置纹理环绕的行为:GL_TEXTURE_WRAP_S用于水平方向,GL_TEXTURE_WRAP_T用于垂直方向。 我们将它们都设置为GL_MIRRORED_REPEAT,因此,如果我们使用小于 0 或大于 1 的坐标,则纹理图像的镜像重复将在那里进行采样。 换句话说,它具有与 OpenCV 库中的BORDER_REFLECT相同的效果,当我们调用cv::warpAffine函数时,该库将内插为fedcba|abcdefgh|hgfedcb。 例如,当我们访问坐标为(1, 1 + y)的点时,它返回点(x, 1 - y)的颜色。
现在,我们的线性模糊过滤器已经完成,因此让我们重新编译并运行我们的应用以查看效果:

好吧,它按预期工作。 为了更清楚地看到其效果,我们甚至可以仅模糊图像的一部分。 这是更新的片段着色器的main函数:
void main()
{
int kernel_size = 7;
vec4 color = vec4(0.0, 0.0, 0.0, 0.0);
if(texCoord.x > 0.5) {
for(int i = -(kernel_size / 2); i <= kernel_size / 2; i++) {
for(int j = -(kernel_size / 2); j <= kernel_size / 2; j++) {
if(i == 0 && j == 0) continue;
vec2 coord = vec2(texCoord.x + i * pixelScale.x, texCoord.y + i * pixelScale.y);
color = color + texture(theTexture, coord);
}
}
frag_color = color / (kernel_size * kernel_size - 1);
} else {
frag_color = texture(theTexture, texCoord);
}
}
在此版本中,我们仅模糊图像的右侧(即texCoord.x > 0.5的部分); 效果如下:

由于我们将所有资源都编译为可执行文件,因此我们需要在更新资源文件(包括着色器)后重新编译应用。
在本节中,我们在 GPU 上运行的片段着色器中实现一个简单的线性模糊过滤器。 如果您拥有不错的 GPU 并将其应用于大图像,则与在 CPU 上运行类似的过滤器相比,您将获得较大的性能提升。
由于我们可以访问纹理(或图像)的所有像素并确定片段着色器中渲染图像的所有像素的颜色,因此我们可以在着色器程序中实现任何过滤器。 您可以自己尝试使用高斯模糊过滤器。
保存过滤的图像
在前面的小节中,我们实现了模糊过滤器并成功地对其进行了模糊处理-模糊的图像在 OpenGL 视口上呈现。 那么,我们可以将生成的图像另存为本地磁盘上的文件吗? 当然; 让我们这样做如下:
void GLPanel::saveOutputImage(QString path)
{
QImage output(img.width(), img.height(), QImage::Format_RGB888);
glReadPixels(
0, 0, img.width(), img.height(), GL_RGB, GL_UNSIGNED_BYTE, output.bits());
output = output.mirrored(false, true);
output.save(path);
}
我们添加了一个新的GLPanel::saveOutputImage方法,该方法接受文件路径作为其参数来保存图像。 另一点值得注意的是,我们将原始图像QImage img从initializeGL方法中的局部变量更改为类成员,因为我们将在类范围内使用它。
在此新添加的方法中,我们定义了一个与原始图像具有相同尺寸的新QImage对象,然后调用glReadPixels函数以读取图像对象的前四个参数所描述的矩形中的数据。 然后,由于前面提到的坐标系不同,我们在垂直方向上翻转了图像。 最后,我们将图像保存到磁盘。
如果在paintGL方法的末尾调用此方法,则在屏幕上看到图像后将找到已保存的图像。
在 OpenCV 中使用 OpenGL
在上一节中,当我们加载源图像并将其翻转时,我们使用 Qt 进行工作。 也可以使用 OpenCV 库完成此工作:
img = cv::imread(img/lizard.jpg");
cv::Mat tmp;
cv::flip(img, tmp, 0);
cvtColor(tmp, img, cv::COLOR_BGR2RGB);
// ...
glTexImage2D(
GL_TEXTURE_2D, 0, GL_RGB,
img.cols, img.rows, 0, GL_RGB, GL_UNSIGNED_BYTE, img.data);
// ...
同样,当保存结果图像时,我们可以这样做:
cv::Mat output(img.rows, img.cols, CV_8UC3);
glReadPixels(
0, 0, img.cols, img.rows, GL_RGB, GL_UNSIGNED_BYTE, output.data);
cv::Mat tmp;
cv::flip(output, tmp, 0);
cvtColor(tmp, output, cv::COLOR_RGB2BGR);
cv::imwrite(path.toStdString(), output);
QImage和cv::Mat都代表图像,因此很容易来回交换它们。
除了简单地使用cv::Mat类与纹理交换数据外,OpenCV 还具有创建 OpenGL 上下文的能力。 从源代码构建库时,需要使用-D WITH_OPENGL=on选项配置库。 启用 OpenGL 支持后,我们可以创建一个窗口(使用highgui模块),该窗口具有与之关联的 OpenGL 上下文:
cv::namedWindow("OpenGL", cv::WINDOW_OPENGL);
cv::resizeWindow("OpenGL", 640, 480);
这里的关键是cv::WINDOW_OPENGL标志; 设置此标志后,将创建一个 OpenGL 上下文并将其设置为当前上下文。 但是 OpenCV 没有提供选择我们要使用的 OpenGL 版本的方法,并且它并不总是使用计算机上可用的最新版本。
我在代码库的Chapter-08/CVGLContext目录中提供了一个绘制带有 OpenGL 上下文和 OpenCV highgui模块的三角形的示例,您可以参考它以了解更多信息。 OpenCV 库的核心模块提供了一个名称空间cv::ogl,其中包括许多用于与 OpenGL 进行互操作的功能。
但是,这些功能与 Qt 提供的 OpenGL 相关类具有相同的问题,也就是说,它们可能远远落后于最新的 OpenGL。 因此,在这里,我建议,如果要使用 OpenGL,请仅使用原始 OpenGL API,而不要使用任何包装。 大多数 OpenGL 实现都足够灵活,可以轻松地与通用库集成,并且您始终可以以这种方式使用最新的 API。
总结
OpenGL 是用于开发 2D 和 3D 图形应用的规范,并且具有许多实现。 在本章中,我们学到了很多东西,包括绘制诸如三角形的图元,将其与 Qt 库集成,使用纹理渲染图像以及在片段着色器中过滤图像。 此外,我们以非典型方式使用 OpenGL,也就是说,我们并未将其用于图形渲染,而是用于并行计算和处理 GPU 上的图像。
最后,我们学习了如何集成 OpenCV 和 OpenGL,并且在我看来,通过将这种方法与使用原始 OpenGL API 进行比较,这不是生产应用的推荐方法,但是可以在尝试时随意使用它。
在本章的最后,我们完成了本书。 我希望我们使用 Qt,OpenCV,Tesseract,许多 DNN 模型和 OpenGL 开发的所有项目都能使您更接近计算机视觉世界。
进一步阅读
OpenGL 除了本章介绍的内容以外,还有很多其他内容。 由于我们在本书中主要关注图像处理,因此仅展示了如何使用它来过滤图像。 如果您对 OpenGL 感兴趣,可以在其官方网站上找到更多资源。 互联网上也有许多很棒的教程,例如这里和这里。
如果您对主要用于 2D 和 3D 图形开发的 OpenGL 不太感兴趣,但是对异构计算感兴趣,则可以参考 OpenCL 或 CUDA。 OpenCL 与 OpenGL 非常相似; 它是 Khronos 组维护的规范。 此外,下一代 OpenGL 和 OpenCL 现在已合并为一个名为 Vulkan 的规范,因此 Vulkan 也是一个不错的选择。 CUDA 是 Nvidia 的异构计算专有解决方案,并且是该领域中最成熟的解决方案,因此,如果您拥有 Nvidia 显卡,则使用 CUDA 进行异构计算是最佳选择。
九、答案
第 1 章,构建图像查看器
- 我们使用消息框来告诉用户他们在尝试查看第一张图像之前的图像或最后一张图像之后的图像时已经在查看第一张或最后一张图像。 但是,还有另一种处理方法:当用户查看第一张图像时禁用
prevAction,而当用户查看最后一张图像时禁用nextAction。 我们该如何处理?
QAction类具有bool enabled属性,因此具有setEnabled(bool)方法,我们称其为启用或禁用prevImage和nextImage方法中的相应动作。
- 我们的菜单项或工具按钮上只有文字。 我们如何向他们添加图标图像?
QAction类具有QIcon icon属性,因此具有setIcon方法,您可以创建和设置操作图标。 要创建QIcon对象,请参考这里上的相应文档。
- 我们使用
QGraphicsView.scale放大或缩小图像视图。 图像视图如何旋转?
使用QGraphicsView.rotate方法。
moc有什么作用?SIGNAL和SLOT宏有什么作用?
moc命令是 Qt 元对象系统编译器。 它主要从包含QOBJECT宏的用户定义类中提取所有与元对象系统相关的信息,包括信号和时隙。 然后,它创建一个名称以moc_开头的 C++ 源文件来管理此元信息(主要是信号和插槽)。 它还提供了该文件中信号的实现。 SIGNAL和SLOT宏将其参数转换为字符串,该字符串可用于在由moc命令管理的元信息中找到相应的信号或时隙。
第 2 章,像专家一样编辑图像
- 我们如何知道 OpenCV 函数是否支持原地操作?
如本章所述,我们可以参考与该函数有关的正式文件。 如果文档规定它支持原地操作,则支持,否则,不支持。
- 如何将热键添加到我们作为插件添加的每个操作中?
我们可以向插件接口类添加一个新方法,该方法返回QList<QKeySequence>实例并在具体的插件类中实现。 加载插件时,我们调用该方法以获取快捷键序列,并将其设置为该插件操作的热键。
- 如何添加新操作以丢弃应用中当前图像中的所有更改?
首先,将QPixmap类型的类字段添加到MainWindow类中。 在编辑当前图像之前,我们将图像的副本保存到该字段。 然后,我们添加一个新动作和一个连接到该动作的新插槽。 在插槽中,我们将保存的图像设置为图形场景。
- 如何使用 OpenCV 调整图像大小?
第 3 章,家庭安全应用
- 我们可以从视频文件而不是从摄像机检测运动吗? 如何实现的?
我们可以。 只需使用视频文件路径来构造VideoCapture实例。 可以在这个页面上找到更多详细信息。
- 我们可以在不同于视频捕获线程的线程中执行运动检测工作吗? 如果是这样,这怎么可能?
是。 但是我们应该使用多种同步机制来确保数据安全。 另外,如果我们将帧分派到不同的线程,则必须确保将结果帧发送回并即将显示时,它们的顺序也正确。
- IFTTT 允许您在发送的通知中包括图像-当通过 IFTTT 的此功能向您的手机发送通知时,我们如何发送检测到的运动图像?
首先,在 IFTTT 上创建小程序时,选择从 IFTTT 应用发送丰富通知作为that服务。 然后,当检测到运动时,我们将帧作为图像上传到诸如 imgur 之类的位置,并获取其 URL。 然后,将图像 URL 作为参数发布到 IFTTT Webhook,并使用该 URL 作为富格式通知中的图像 URL,该格式可以在其主体中包含图像 URL。
第 4 章,人脸上的乐趣
- LBP 级联分类器可以用来自己检测人脸吗?
是。 只需使用 OpenCV 内置的lbpcascades/lbpcascade_frontalface_improved.xml分类器数据文件。
- 还有许多其他算法可用于检测 OpenCV 库中的人脸标志。 其中大多数可以在这个页面中找到。 自己尝试一下。
可以通过以下链接使用不同的函数,创建不同的算法实例。 所有这些算法都与本章中使用的 API 具有相同的 API,因此您只需更改它们的创建语句即可轻松尝试这些算法。
- 如何将彩色装饰物应用到脸上?
在我们的项目中,视频帧和装饰物均为BGR格式,没有 alpha 通道。 考虑到装饰物有白色背景,我们可以使用cv::threshold函数先生成一个遮罩。 遮罩是二进制图像,背景为白色,前景(装饰的一部分)为黑色。 然后,我们可以使用以下代码来应用装饰:
frame(rec) &= mask;
ornament &= ^mask;
frame(rec) |= ornament;
第 5 章,光学字符识别
- Tesseract 如何识别非英语语言的字符?
初始化TessBaseAPI实例时,请指定相应的语言名称。
- 当我们使用 EAST 模型检测文本区域时,检测到的区域实际上是旋转的矩形,而我们只是使用其边界矩形。 这总是对的吗? 如果没有,该如何纠正?
是正确的,但这不是最佳方法。 我们可以将旋转矩形的边界框中的区域复制到新图像,然后旋转并裁剪它们以将旋转矩形转换为规则矩形。 之后,通常通过将生成的规则矩形发送到 Tesseract 来提取文本,通常将获得更好的输出。
- 尝试找出一种方法,允许用户在从屏幕捕获图像时拖动鼠标后调整所选区域。
通常的方法是在选定区域的边界矩形的顶点和侧面上插入八个手柄,然后用户可以拖动这些手柄以调整选定区域。 这可以通过扩展我们的ScreenCapturer类的paintEvent和mouse*Event方法来完成。 在paintEvent方法中,我们绘制选择矩形及其句柄。 在mouse*Event方法中,我们检查是否在手柄上按下了鼠标,然后通过拖动鼠标重新绘制选择矩形。
第 6 章,实时对象检测
- 当我们针对波士顿公牛的脸部训练级联分类器时,我们会在每个图像上自行标注狗的脸部。 标注过程非常耗时。 网站上有该数据集的标注数据压缩包。 是否可以使用一段代码从此标注数据生成
info.txt文件? 如何才能做到这一点?
该压缩文件中的标注数据与狗的身体有关,而不与狗的脸有关。 因此,我们不能使用它来训练狗脸的分类器。 但是,如果您想为狗的全身训练分类器,这会有所帮助。 该压缩文件中的数据以 XML 格式存储,标注矩形是具有//annotation/object/bndbox路径的节点,我们可以轻松提取该路径。
- 尝试找到预训练的(快速/快速)R-CNN 模型和预训练的 SSD 模型,运行它们,然后将其性能与 YOLOv3 进行比较。
以下列表提供了一些 Faster R-CNN 和 SSD 模型。 如果您对它们之一感兴趣,请自己进行测试:
- 我们可以使用 YOLOv3 来检测某种对象,但不是全部 80 类对象吗?
是的,您可以根据特定的类 ID 过滤结果。 在第 7 章,“实时汽车检测和距离测量”中,我们采用了这种方法来检测汽车,请仅参考该章。
第 7 章,实时汽车检测和距离测量
- 测量汽车之间的距离时是否有更好的参考对象?
可可数据集中有许多类,其中对象通常具有固定位置; 例如,交通信号灯,消防栓和停车标志。 我们可以在相机视图中找到其中一些,选择其中任意两个,测量它们之间的距离,然后将所选对象及其距离用作参考。


浙公网安备 33010602011771号