Qt5 和 OpenCV4 计算机视觉项目:1~5

原文:Qt 5 and OpenCV 4 Computer Vision Projects

协议:CC BY-NC-SA 4.0

译者:飞龙

本文来自【ApacheCN 计算机视觉 译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。

当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。

一、构建图像查看器

计算机视觉是使计算机能够对数字图像和视频有较高了解的技术,而不仅仅是将它们视为字节或像素。 它广泛用于场景重建,事件检测,视频跟踪,对象识别,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 上良好运行。 您可以从这里免费下载。

以下是显示我们的应用原型的线框:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PaTCrNVP-1681871114312)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/251333cc-d29d-4708-aaba-717b82b2af4d.png)]

如上图所示,我们在主窗口中有四个区域:菜单栏工具栏主区域状态栏

菜单栏上有两个菜单选项-文件视图菜单。 每个菜单将具有其自己的一组操作。 文件菜单包含以下三个操作,如下所示:

  • 打开:此选项从硬盘打开图像。
  • 另存为:此选项以任何受支持的格式将当前图像的副本另存为另一个文件(具有不同的路径或文件名)。
  • 退出:此选项退出应用。

视图菜单包含四个操作,如下所示:

  • 放大:此选项放大图像。
  • 缩小:此选项缩小图像。
  • 上一个:此选项可打开当前文件夹中的上一个图像。
  • 下一个:此选项可打开当前文件夹中的下一张图像。

工具栏由几个按钮组成,也可以在菜单选项中找到。 我们将它们放在工具栏上,为用户提供触发这些操作的快捷方式。 因此,有必要包括所有经常使用的操作,包括以下内容:

  • 打开
  • 放大
  • 缩小
  • 上一张图片
  • 下一张图片

主区域用于显示由应用打开的图像。

状态栏用于显示与我们正在查看的图像有关的一些信息,例如其路径,尺寸及其大小(以字节为单位)。

您可以在 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 
    #include 

    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作为生成项目时要使用的模板。 此处允许使用许多其他值,例如libsubdirs。 我们正在构建一个可以直接运行的应用,因此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 
 ^~~~~~~~~~~~~~
 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 -makefilemake命令来再次构建应用,如下所示:

 $ 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。 现在,让我们执行它,看看窗口是什么样的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fBaoJGL0-1681871114317)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/bc8f3961-2404-413c-a509-21248663fe49.png)]

如我们所见,这只是一个空白窗口。 我们将在下一部分中根据我们设计的线框实现完整的用户界面。

尽管我们没有提到任何 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方法初始化在私有部分中声明的所有窗口小部件。 imageSceneimageView小部件将放置在窗口的主要区域中以显示图像。 其他小部件的类型和名称是不言自明的,因此为了使本章简洁,我将不对它们进行过多说明。

为了使本章简洁明了,在介绍该文件时,我没有将每个源文件完整地包含在文本中。 例如,在大多数情况下,文件开头的#include ...方向被忽略。 您可以在 GitHub 上的代码存储库中引用源文件以检查详细信息(如果需要)。

另一个关键方面是mainwindow.cppinitUI方法的实现,如下所示:

    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项目文件进行许多更改,如下所示:

  1. 我们只是编写了一个新的源文件,它应该被qmake所知道:
     # in ImageViewer.pro
     SOURCES += main.cpp mainwindow.cpp
  1. 头文件mainwindow.h具有一个特殊的宏Q_OBJECT,它指示它具有标准 C++ 预处理器无法处理的内容。 该头文件应由 Qt 提供的名为moc元对象编译器的预处理器正确处理,以生成包含某些与 Qt 元对象系统相关的代码的 C++ 源文件。 因此,我们应该通过将以下行添加到ImageViewer.pro来告诉qmake检查该头文件:
     HEADERS += mainwindow.h

好。 现在,所有步骤都已完成,让我们再次运行qmake -makefilemake,然后运行新的可执行文件。 您应该看到以下窗口:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PFoAlpEW-1681871114318)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/d1a1fe3b-aa9d-4e28-a6f0-0eb459e56e50.png)]

好吧,到目前为止一切都很好。 现在,让我们继续添加应该在菜单中显示的项目。 在 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中创建。 让我们编译源代码,看看窗口现在是什么样子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fp3GGMJ7-1681871114318)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/51dc64d7-43b2-4ddf-9bd1-5118bd62bdff.png)]

大! 该窗口看起来就像我们设计的线框一样,现在我们可以通过单击菜单栏上的项目来展开菜单!

实现动作函数

在上一节中,我们向菜单和工具栏添加了一些操作。 但是,如果单击这些操作,则什么也不会发生。 那是因为我们还没有为他们编写任何处理器。 Qt 使用信号和插槽连接机制来建立事件及其处理器之间的关系。 当用户对窗口小部件执行操作时,将发出该窗口小部件的信号。 然后,Qt 将确定​​是否有与该信号相连的插槽。 如果找到该插槽,则将调用该插槽。 在本节中,我们将为在上一节中创建的动作创建插槽,并将动作信号分别连接到这些插槽。 另外,我们将为常用操作设置一些热键。

退出动作

以退出动作为例。 如果用户从“文件”菜单中单击它,则将发出名为triggered的信号。 因此,让我们将该信号连接到MainWindow类的成员函数createActions中的应用实例的插槽中:

     connect(exitAction, SIGNAL(triggered(bool)), QApplication::instance(), SLOT(quit()));

connect方法采用四个参数:信号发送器,信号,接收器和插槽。 一旦建立连接,发送方的信号一发出,接收方的插槽就会被调用。 在这里,我们将退出操作的triggered信号与应用实例的quit插槽连接,以使我们能够在单击退出操作时退出。

现在,要编译并运行,请从“文件”菜单中单击“退出”项。 如果一切顺利,该应用将按我们期望的那样退出。

打开图像

Qt 提供了QApplicationquit插槽,但是如果要在单击打开操作时打开图像,我们应该使用哪个插槽? 在这种情况下,这种自定义任务没有内置的插槽。 我们应该自己写一个插槽。

要编写插槽,首先我们应该在类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)的文件。 完成这些设置后,我们调用dialogexec方法将其打开。 如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gULAbpzl-1681871114318)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/55ee82be-4f51-44c3-82bd-66547dfff600.png)]

如果用户选择一个文件并单击“打开”按钮,则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的文本,该文本已添加到状态栏中。

让我们看看该图像在打开时如何显示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-0v4Hj3vj-1681871114319)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/be5379ea-a17b-459e-bb6e-2a68f5b1b65f.png)]

不错! 该应用现在看起来像一个真正的图像查看器,因此让我们继续实现其所有预期功能。

放大和缩小

好。 我们已经成功显示了图像。 现在,让我们扩展一下。 在这里,我们以放大为例。 根据上述操作的经验,我们应该对如何执行操作有一个清晰的认识。 首先,我们声明一个专用插槽zoomIn,并提供其实现,如以下代码所示:

     void MainWindow::zoomIn()
     {
         imageView->scale(1.2, 1.2);
     }

容易吧? 只需使用宽度的缩放比例和高度的缩放比例调用imageViewscale方法。 然后,在MainWindow类的createActions方法中,将zoomInActiontriggered信号连接到此插槽:

     connect(zoomInAction, SIGNAL(triggered(bool)), this, SLOT(zoomIn()));

编译并运行该应用,使用它打开一个图像,然后单击工具栏上的“放大”按钮。 您会发现,每次单击时图像会放大到其当前大小的 120%。

缩小仅需要以小于1.0的速率缩放imageView。 请尝试自己实现。 如果发现困难,可以参考我们在 GitHub 上的代码存储库。

通过我们的应用,我们现在可以打开图像并将其缩放以进行查看。 接下来,我们将实现saveAsAction操作的功能。

保存副本

让我们回顾一下MainWindowshowImage方法。 在该方法中,我们从图像创建了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 图像,以检查图像是否已正确保存。

浏览文件夹

现在,我们已经完成了与单个图像有关的所有操作,让我们进一步浏览一下当前图像所在目录(即prevActionnextAction)中的所有图像。

要知道上一个或下一个图像是什么构成的,我们应该注意以下两点:

  • 当前是哪个
  • 我们计算它们的顺序

因此,首先我们向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 上的代码存储库中阅读其代码。

响应热键

至此,几乎所有功能都按照我们的预期实现了。 现在,让我们为常用操作添加一些热键,以使我们的应用更易于使用。

您可能已经注意到,在创建动作时,有时会在其文本中添加一个奇怪的&,例如&FileE&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 插件机制以更灵活的方式添加这些编辑操作。

问题

尝试以下问题,以测试您对本章的了解:

  1. 我们使用一个消息框来告诉用户,当他们试图查看第一个图像之前的上一个图像或最后一个图像之后的下一个图像时,他们已经在查看第一个或最后一个图像。 但是还有另一种处理这种情况的方法-当用户查看第一张图像时禁用prevAction,而当用户查看最后一张图像时禁用nextAction。 如何实现?
  2. 我们的菜单项或工具按钮仅包含文本。 我们如何向他们添加图标图像?
  3. 我们使用QGraphicsView.scale放大或缩小图像视图,但是如何旋转图像视图?
  4. moc有什么作用? SIGNALSLOT宏执行什么动作?

二、像专业人士一样编辑图像

在第 1 章,“构建图像查看器”中,我们构建了一个简单的应用,用于从头开始使用 Qt 进行图像查看。 使用该应用,我们可以从本地磁盘查看图像,放大或缩小视图,以及在打开目录中导航。 在本章中,我们将继续该应用并添加一些功能,以允许用户编辑打开的图像。 为了实现这个目标,我们将使用本书开头提到的 OpenCV 库。 为了使应用可扩展,我们将使用 Qt 的插件机制将这些编辑功能中的大多数开发为插件。

本章将涵盖以下主题:

  • 在 Qt 和 OpenCV 之间转换图像
  • 通过 Qt 的插件机制扩展应用
  • 使用 OpenCV 提供的图像处理算法修改图像

技术要求

要求用户正确运行我们在上一章中构建的ImageViewer应用。 本章中的开发将基于该应用。

此外,还必须具备一些 OpenCV 的基本知识。 我们将使用最新版本的 OpenCV,即 4.0 版,该版本于 2018 年 12 月编写本书时发布。 由于新版本尚未包含在许多操作系统(例如 Debian,Ubuntu 或 Fedora)的软件存储库中,因此我们将从源头构建它。 请不要担心,我们将在本章稍后简要介绍安装说明。

本章的所有代码都可以在本书的 GitHub 存储库中找到。

观看以下视频,查看运行中的代码

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 = ImageEditorTARGET重命名为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 模糊图像

在上一节中,我们设置了编辑器应用。 在本节中,我们将添加一个简单的图像编辑功能-一个操作(在菜单和工具栏上)以使图像模糊。

我们将分两步执行此操作:

  1. 首先,我们将设置 UI 并添加操作,然后将操作连接到虚拟插槽。
  2. 然后,我们将覆盖虚拟插槽以使图像模糊,这将涉及到 OpenCV 库。

添加模糊动作

我们将在本章中添加的大多数操作将用于编辑图像,因此我们应将其归类到新的菜单和工具栏中。 首先,我们将在mainwindow.h头文件的私有部分中声明三个成员,即编辑菜单,编辑工具栏和模糊动作:

         QMenu *editMenu;
         QToolBar *editToolBar;
         QAction *blurAction;

然后,我们将分别在MainWindow::initUIMainWindow::createActions方法中创建它们,如下所示:

MainWindow::initUI中,执行如下:

         editMenu = menuBar()->addMenu("&Edit");
         editToolBar = addToolBar("Edit");

MainWindow::createActions中,执行如下:

         blurAction = new QAction("Blur", this);
         editMenu->addAction(blurAction);
         editToolBar->addAction(blurAction);

到现在为止,我们都有一个编辑菜单和一个编辑工具栏,它们两个都带有模糊操作。 但是,如果用户单击工具栏上的模糊按钮或编辑菜单下的模糊项目,则不会发生任何事情。 这是因为我们尚未将插槽连接到该操作。 让我们现在为该动作添加一个插槽。 首先,我们将在mainwindow.hprivate 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!正在打印。

窗口和打印的消息如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-juBo2zNh-1681871114319)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/558c9347-79be-4627-b4ec-cc8a69882b5e.png)]

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 发行页面如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IG0Iuutr-1681871114319)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/251cce41-fd3d-44b3-9131-46f6e34054b0.png)]

我们可以单击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,现在我们可以运行makemake 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.solibopencv_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分别返回到QImageQPixmap

在转换方面,我们必须做很多工作。 让我们通过以下几行代码来讨论:

         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 -makefilemake命令来重建项目,然后运行新的可执行文件。

如果像我一样,在非/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

使用我们的应用打开图像时,将获得以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Xd2oh1AO-1681871114319)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/38507eec-6352-46a7-bc72-c1b42ccbb5b2.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5YtGroNy-1681871114320)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/47fa78e2-fba7-4de6-86c9-d41c2727f478.png)]

我们可以看到我们的图像已成功模糊。

QPixmapQImageMat

在上一节中,我们添加了一项新功能来模糊在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 进行图像处理。

然后,问题是,如何在QImageQPixmapMat这三种数据类型之间转换? 在上一节中,我们讨论了如何将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_C

  • 可以用8163264代替,它们表示用于在像素中存储每个元素的位数
  • 对于无符号整数,有符号整数和浮点值,需要分别用USF替换
  • 应该是通道数

因此,在我们的代码中,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,这意味着BR值互换了。 因此,我们在代码中将恒定颜色表示为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分别转换回QImageQPixmap,以显示编辑后的图像。

现在,如果我们想向我们的应用添加另一个编辑功能,我们应该怎么做? 当然,只重复前面添加模糊动作的过程就可以了,但是效率不高。 如果我们想象我们只是以添加模糊动作的相同方式向应用添加了另一个编辑动作,我们会发现大多数工作或代码都是相同的。 我们正在重复自己。 这不仅是一种不良的发展模式,而且是无聊的工作。

要解决此问题,我们应该仔细地进行重复的过程,将其分为多个步骤,然后找出哪些步骤完全相同,哪些步骤有所不同。

这样,我们可以找出添加其他编辑功能的关键点:

  • 对于不同的编辑功能,操作的名称是不同的。
  • Mat实例上的操作因不同的编辑功能而有所不同。

除前两个之外,其他所有步骤或逻辑在添加不同的编辑动作的过程中都是相同的。 也就是说,当我们要添加新的编辑功能时,我们只需要做两件事。 首先,我们将其命名,然后找出一种使用 OpenCV 对Mat实例进行编辑操作的方法。 一旦清除了这两件事,就可以确定新的编辑功能。 接下来,我们需要将新功能集成到应用中。

那么,我们如何将其集成到应用中呢? 我们将使用 Qt 的插件机制来执行此操作,并且每个编辑功能都将是一个插件。

插件接口

Qt 插件机制是使 Qt 应用更可扩展的强大方法。 如前所述,我们将使用这种机制来抽象一种可以轻松添加新编辑功能的方式。 完成后,在添加新的编辑功能时,只需要注意编辑功能的名称和Mat实例上的操作即可。

第一步是找出一个接口,以便在应用和插件之间提供通用协议,以便我们可以加载和调用插件,而不管插件是如何实现的。 在 C++ 中,接口是具有纯虚拟成员函数的类。 对于我们的插件,我们需要处理Mat的动作名称和操作,因此我们在editor_plugin_interface.h中声明我们的接口,如下所示:

     #ifndef EDITOR_PLUGIN_INTERFACE_H
     #define EDITOR_PLUGIN_INTERFACE_H

     #include 
     #include 
     #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的类,这是我们的接口类。 在该类中,除了虚拟的空析构器之外,我们还可以看到两个纯虚拟成员函数:nameedit函数。 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.herode_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 
     #include 

     #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的类,该类继承自QObjectEditorPluginInterface。 后者是我们在上一节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的实例,其IIDEDIT_PLUGIN_INTERFACE_IID,因此 Qt 应用可以告诉它并加载此插件。

现在,我们可以专注于如何实现接口。 首先,我们在接口中声明两个纯粹的重要函数:

     public:
         QString name();
         void edit(const cv::Mat &input, cv::Mat &output);

然后,我们在erode_plugin.cpp文件中实现它们。 对于name函数,这很简单-我们只需返回QStringErode作为插件的名称(以及编辑操作的名称)即可:

     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 

     #include "editor_plugin_interface.h"

然后,在MainWindow类的声明主体中,声明两个成员函数:

  • void loadPlugins():用于加载出现在某个目录中的所有插件。
  • void pluginPerform():这是一个公共插槽,它将连接到已加载插件创建的所有操作。 在此插槽中,我们应区分触发了哪个动作,导致该插槽被调用,然后我们找到与该动作相关的插件并执行其编辑操作。

添加这两个成员函数后,我们添加QMap类型的成员字段以注册所有已加载的插件:

     QMap<QString, EditorPluginInterface*> editPlugins;

该映射的键将是插件的名称,而值将是指向已加载插件实例的指针。

头文件中的所有工作都已完成,因此让我们实现loadPlugins函数来加载我们的插件。 首先,我们应该在mainwindow.cpp中包含必要的头文件:

    #include 

然后,我们将提供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的形式获取开始图像,然后依次将其转换为QImageMat。 一旦它成为Mat的实例,我们就可以对其应用插件的edit函数,即plugin_ptr->edit(mat, mat);。 完成编辑操作后,我们将编辑后的Mat分别转换回QImageQPixmap,然后在图形场景中显示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 -makefilemake命令。 等待这些命令完成。 然后,通过运行./ImageEditor命令启动我们的应用; 您将看到以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BAYkiAtT-1681871114320)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/0c494eac-f671-45a1-9719-42a2b103f11f.png)]

在运行应用之前,请不要忘记在 Linux 或 MacOS 上将LD_LIBRARY_PATHDYLD_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。 您应该创建该目录并在其中复制插件文件。

让我们再次运行我们的应用:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W0rzvvYc-1681871114320)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/94dcfa12-52aa-46ea-800d-500d0c8b9ccb.png)]

现在,我们可以在“编辑”菜单和“编辑”工具栏上看到“腐蚀”动作。 让我们打开一个图像以查看erode的功能。

这是在执行任何操作之前由应用打开的图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vFNLLNlN-1681871114321)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/efdd7765-a1d5-4a33-98a5-1a3c07fc5427.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dmUfUzqf-1681871114321)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/677cb9fa-dea0-4421-b74d-3db4c111e4f0.png)]

如您所见,单击“腐蚀”操作后,图像的暗部被放大,白色对象缩小。 这是因为 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插件项目的项目文件相同,除了TARGETHEADERSSOURCES的设置。 这三个设置的更改就其键和值而言很容易且不言自明。

现在,让我们看一下源文件。 第一个是头文件sharpen_plugin.h

     #include 
     #include 

     #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。 我们使该类成为QObjectEditorPluginInterface的后代。 在该类的主体中,我们使用多个 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变量是一个整数,它将指示我们将锐化图像的强度,而smoothedcv::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

如果一切顺利,您将在“编辑”菜单和“编辑”工具栏下看到“锐化”操作。 让我们看看打开图像后的样子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DLOB1J1s-1681871114321)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/eca78b19-30d3-4dbc-a238-de11bebece4a.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fape6ODr-1681871114321)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/cc404f2f-65da-4482-ac1d-9a60f0966cfe.png)]

我们可以看到它们之间明显的区别。 请随意使用intensity变量和GaussianBlur函数的参数来获得自己喜欢的结果。

卡通效果

在上一节中,我们添加了新的编辑功能,以便可以在应用中锐化图像。 在本节中,我们将添加一个新的编辑功能,以便为图像创建有趣的卡通效果。 为了获得这种卡通效果,我们需要做两件事:首先,我们需要使图像具有卡通外观,因此我们需要找到一种减少其调色板的方法。 然后,我们将检测图像中的边缘并与它们一起产生粗体轮廓。 之后,我们将合并这些步骤的结果图像,然后获得实现了卡通效果的新图像。

幸运的是,所有这些都可以通过使用 OpenCV 库来完成。 因此,让我们开始新的插件项目,我们将其称为CartoonPlugin。 创建插件项目的步骤和项目的目录结构与我们之前所做的非常相似,因此,为了使本章简洁明了,在此我们不会向您展示如何逐步创建项目。

要创建项目,我们将创建一个名为CartoonPlugin的新目录,然后在该目录中创建项目文件和源文件。 该目录应如下所示:

 $ ls
 cartoon_plugin.cpp cartoon_plugin.h CartoonPlugin.pro
 $

您可以从我们以前的插件项目之一复制项目文件,然后将TARGETHEADERSSOURCES设置的值更改为此项目的正确值。 由于源文件的内容也与先前项目中的内容非常相似,因此您可以将任何已完成的插件项目中的源文件用作模板来简化开发过程-只需复制文件,更改文件名, 其中的插件类名称,以及nameeidt方法的实现。

在此项目中,我们使用CartoonPlugin作为插件类名称,并在CartoonPlugin::name方法中使用return "Cartoon";作为插件类名称。 现在,我们要做的就是实现CartoonPlugin::edit方法。 现在让我们继续进行这一关键部分。

第一项任务是减少调色板。 为此,我们可以使用 OpenCV 库提供的双边过滤器。 尽管双边过滤器效果很好,并通过平滑平坦区域并保持锐利边缘为普通的 RGB 图像提供了卡通外观,但是它比其他平滑算法(例如,我们之前使用的高斯模糊算法)慢得多。 但是,在我们的应用中,速度很重要-为了使代码易于理解,我们不会创建单独的辅助线程来进行编辑工作。 如果编辑过程太慢,它将冻结我们应用的用户界面-也就是说,在编辑时,我们的应用将不是交互式的,用户界面也不会被更新。

幸运的是,我们有两种方法可以加快这一过程,从而缩短冻结时间:

  1. 缩小原始图像,然后将过滤器应用于该缩小的版本。
  2. 代替一次对图像应用大的双边过滤器,我们可以多次应用小双边的过滤器。

让我们看看如何做到这一点:

         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类对象copy1copy2,然后将input的副本分配给copy1

然后,我们使用cv::pyrDown重复缩小copy1的大小(两次通过int num_down = 2;)。 在此循环中,我们对两个定义的矩阵copy1copy2进行操作。 由于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

启动我们的应用并使用它打开图像后,我们得到以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TCY64v79-1681871114321)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/d7f7b010-cb0d-42bc-aac6-03d8b2dd9289.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vlH2nvPu-1681871114322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/2442be76-9f57-4de0-8c0f-2274129131b5.png)]

这还不错,您可以随意使用所有过滤器函数的参数来自己调整卡通效果。

在本节中,我们使用了 OpenCV 提供的许多过滤器函数。 在调用这些函数时,我指出了medianBlur函数支持原地操作,而bilateralFilter函数则不支持。 这是什么意思,我们如何知道某个函数是否支持原地操作?

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7OJWKfgQ-1681871114322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/84881f18-d518-4f26-a740-7b33e9cf7d10.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dcJGMnzp-1681871114322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/1e420196-7a2b-405a-9aa7-4f2c62f5b649.png)]

值得注意的是,如果文档中说某个函数支持原地操作,那么它将支持。 如果文档没有说明某个函数是否支持原地操作,则最好假定它不支持原地操作。

旋转图像

在前面的部分中,我们已将许多编辑功能作为插件添加,所有这些功能都利用了 OpenCV 提供的图像过滤器。 从本节开始,我们将添加一些利用 OpenCV 库的转换函数的功能。

根据 OpenCV 库的文档,OpenCV 中有两个图像转换类别:

  • 几何变换
  • 杂项变换(除几何变换外的所有变换)

在本节和下一部分中,我们将研究几何变换。 我们可以从它们的名称猜测得出,几何变换主要处理图像的几何属性,例如图像的大小,方向和形状。 它们不更改图像的内容,而是根据几何变换的性质,通过在周围移动图像的像素来更改图像的形式和形状。

让我们首先从简单的几何变换开始-旋转图像。 使用 OpenCV 旋转图像有多种方法。 例如,我们可以在矩阵上应用转置和翻转的复合操作,也可以使用适当的变换矩阵进行仿射变换。 在本节中,我们将使用后一种方法。

现在是时候开始一个新的动手项目来开发旋转插件了。 我们可以通过使用以前的插件项目作为模板来做到这一点。 以下是此过程的重点列表:

  1. 使用RotatePlugin作为项目名称。
  2. 创建项目文件和源文件(.h文件和.cpp文件)。
  3. 更改项目文件中的相关设置。
  4. 使用RotatePlugin作为插件类名称。
  5. name方法中返回Rotate作为插件名称。
  6. 更改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方法的实现中,参数inputoutput实际上是相同的矩阵。 这意味着我们不能将它们传递给不支持原地操作的函数。

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

打开图像后,我们应该获得以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-nCuAvlgn-1681871114322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/c3ae5b63-e213-4d57-9606-2b371e33ac32.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uGz2wFTN-1681871114322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/1cc08f62-5aaa-496a-9455-c14459ecae92.png)]

如我们所见,图像正如我们预期的那样逆时针旋转 45 度。 随意更改中心点,角度和比例的值以查看会发生什么。

仿射变换

在上一节中,我们使用warpAffine成功旋转了图像。 在本节中,我们将尝试使用相同的函数执行仿射变换。

首先,我们将创建一个新的编辑插件项目,并使用AffinePlugin作为项目名称和插件类名称,然后使用Affine作为操作名称(即,我们将在name方法中返回此字符串) )。

这次,在edit方法中,我们将使用另一种方法来获取warpAffine函数的转换矩阵。 首先,我们准备两个三角形-一个用于输入图像,另一个用于输出图像。 在我们的代码中,我们使用下图中显示的三角形:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6q1OsIjm-1681871114323)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/54d228bf-bd4f-43e2-8524-6d6b51bc862b.png)]

左边的一个用于输入,而右边的一个用于输出。 我们可以很容易地看到,在此变换中,图像的顶部将保持不变,而图像的底部将向右移动与图像宽度相同的距离。

在代码中,我们将使用三个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

这是打开图像后我们的应用的外观:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-PIra39om-1681871114323)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/83beb7c8-f18a-439b-8b61-f0502770bea3.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tbQGy33n-1681871114323)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/e3f70d45-5b26-484b-b2ec-130dff60cce6.png)]

万岁! 图像以与上图中所示相同的方式进行转换。

您可能会注意到,在此代码中,我们也使用BORDER_CONSTANT作为边框类型,因此,在图像倾斜移动后,其左下角将被恒定的颜色填充,默认情况下为黑色。 除了用恒定的颜色填充边界外,还有许多其他方法可以对边界进行插值。 以下列表显示了 OpenCV 文档中的所有方法:

  • BORDER_CONSTANT:以指定的i插值为iiiiii|abcdefgh|iiiiiii
  • BORDER_REPLICATE:内插为aaaaaa|abcdefgh|hhhhhhh
  • BORDER_REFLECT:内插为fedcba|abcdefgh|hgfedcb
  • BORDER_WRAP:内插为cdefgh|abcdefgh|abcdefg
  • BORDER_REFLECT_101:内插为gfedcb|abcdefgh|gfedcba
  • BORDER_TRANSPARENT:内插为uvwxyz|abcdefgh|ijklmno
  • BORDER_REFLECT101:与BORDER_REFLECT_101相同
  • BORDER_DEFAULT:与BORDER_REFLECT_101相同
  • BORDER_ISOLATED:不要在 ROI 之外看

在此列表的解释性条款中,|abcdefgh|表示原始图像,并且其周围的字母表示将如何进行插值。 例如,如果我们使用BORDER_WRAP值,则插值将为cdefgh|abcdefgh|abcdefg; 也就是说,它将使用图像的右侧填充左侧边框,并使用图像的左侧填充右侧边框。 作为一种特殊情况,BORDER_TRANSPARENT保持目标矩阵中的相应像素不变,并且不使用输入图像中的颜色。

如果我们在AffinePlugin插件中使用BORDER_WRAP,则转换后的图像将如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-P7ofvtAe-1681871114323)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/48fabf51-00bf-46f4-8990-d46db70f4034.png)]

这里没有展示所有边界插值类型的效果,因此,如果您有兴趣,请自己尝试。

在本节和上一节中,我们了解了如何使用仿射变换来变换图像。 除了这种变换图像的方法之外,还有许多方法可以进行几何变换。 所有这些方法都是 OpenCV 提供的,包括调整大小,透视图转换,重新映射以及许多其他转换,例如色彩空间转换。 这些几何变换是可查看的,您可以在这个页面中找到其文档。 在其他转换方面,我们在CartoonPlugin插件中使用了其中之一cv::adaptiveThreshold。 可以在这个页面上找到有关此类转换的完整文档。 您可以在我们的插件项目或您自己的插件中摆弄所有这些转换,以了解有关它们的更多信息。

总结

在本章中,我们重新制作了在第 1 章,“构建图像查看器”中构建的用于图像查看的桌面应用,以制作图像编辑器应用。 然后,我们添加了一个简单的编辑功能来模糊图像。 同时,我们了解了如何为 Qt 应用安装和设置 OpenCV,与 Qt 和 OpenCV 中的图像处理相关的数据结构,以及如何使用 OpenCV 处理图像。

之后,我们了解了 Qt 库的插件机制,并抽象出了一种以更灵活,更便捷的方式向我们的应用添加编辑功能的方法,即作为插件。 例如,我们编写了第一个插件来腐蚀图像。

然后,我们将注意力转移到 OpenCV 库上,讨论如何像专家一样编辑图像-我们制作了许多插件来编辑图像,锐化图像,制作卡通效果,旋转,执行仿射变换等。

在下一章中,我们将学习如何使用 OpenCV 和 Qt 处理视频,并且将在运动分析技术的帮助下在家中构建一个简单的安全应用。

问题

尝试这些问题,以测试您对本章的了解:

  1. 我们如何知道 OpenCV 函数是否支持原地操作?
  2. 我们如何为作为插件添加的每个动作添加一个热键?
  3. 我们如何添加一个新动作来丢弃应用中当前图像的所有更改?
  4. 我们如何使用 OpenCV 调整图像大小?

三、家庭安全应用

在第 2 章,“像专家一样编辑图像”,我们通过构建自己的图像编辑器应用,了解了 Qt 库的插件机制以及来自 OpenCV 库的许多图像过滤器和转换。 在本章中,我们将从处理图像转到处理视频。 我们将构建一个新的应用,通过该应用,我们可以使用 PC 的网络摄像头执行许多操作,例如播放从其实时捕获的视频,记录其视频提要中的部分视频,计算其每秒帧FPS),通过对其视频馈送进行实时运动分析来检测运动,等等。

本章将涵盖以下主题:

  • 设计和创建用户界面UI
  • 处理相机和视频
  • 录制影片
  • 实时计算 FPS
  • 运动分析和运动检测
  • 在桌面应用中向手机发送通知

技术要求

正如我们在前几章中所看到的,您必须安装 Qt 版本 5(至少),并且具有 C++ 和 Qt 编程的基本知识。 另外,应正确安装最新版本的 OpenCV(4.0)。 除了核心和imgproc模块外,本章还将使用 OpenCV 的视频和videoio模块。 在前面的章节之后,必须已经满足这些要求。

在本章中,我们将向您展示如何处理摄像头,因此您需要一个网络摄像头,它既可以是内置的也可以是外部的,可以从计算机上访问。

本章还要求具备多线程的基本知识。

本章的所有代码都可以在本书的 GitHub 存储库中找到。

观看以下视频,查看运行中的代码

Gazer 应用

为了深入研究相机处理,视频处理和运动分析,我们将开发一个全新的应用。 除了学习这些主题之外,我们还将获得一个具有许多实用功能的应用:能够通过网络摄像头录制视频,监控我们的家庭安全,并在检测到可疑动作时通过移动设备通知我们。 让我们阐明其功能,如下所示:

  • 打开网络摄像头并实时播放从中捕获的视频
  • 通过单击开始/停止按钮从网络摄像头录制视频
  • 显示已保存视频的列表
  • 检测到动作,保存视频并在检测到可疑动作时向我们的手机发送通知
  • 显示有关摄像机和应用状态的一些信息

在澄清了这些功能之后,我们可以设计 UI。 再次,我们将使用在第 1 章,“构建图像查看器”,Pencil 中使用的开源 GUI 原型工具绘制应用原型的线框,如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-niTyooMf-1681871114323)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/5eb51685-40ea-4ef8-bffa-d31e5dcf1aca.png)]

如上图所示,我们将整个窗口分为五个部分:菜单栏,将要播放视频的主要区域,操作按钮所在的操作区域,其中包含已保存视频缩略图的水平列表。 将被放置,以及状态栏。

您可以从 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 库的videovideoio模块附加到LIBS键的值的末尾,因为我们将使用这些模块来处理项目中的视频。 您应该注意的另一点是,您应该将这段代码中的路径更改为实际的 OpenCV 安装路径。

最后,让我们设置标题和源:

     HEADERS += mainwindow.h
     SOURCES += main.cpp mainwindow.cpp

在此处设置三个源文件(包括头文件)时,现在只有一个空的main.cpp文件。 不用担心,在之前的项目中我们已经做过很多此类工作,所以让我们进行一些复制,粘贴和更改:

  1. main.cpp文件从我们之前的任何项目复制到我们的Gazer项目中,例如,第 1 章,“构建图像查看器”中的ImageViewer项目。 内容不变。
  2. 从先前的项目之一将mainwindown.h文件复制到我们的Gazer项目,打开文件,然后删除类正文中除Q_OBJECT宏的行,构造器和析构器之外的所有行。 更改后,类主体应如下所示:
       class MainWindow : public QMainWindow
       {
           Q_OBJECT

       public:
           explicit MainWindow(QWidget *parent=nullptr);
           ~MainWindow();
       }
  1. 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 库逐帧捕获视频,并且每一帧都是图像本身。 要播放视频,我们可以在捕获到特定区域后立即显示该框架。 因此,我们仍然使用QGraphicsSenceQGraphicsView逐帧显示帧,以达到播放视频的效果:

         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 1QGridLayout类来安排我们设计和声明的小部件。

借助介绍的布局系统知识,让我们设置我们设计的完整 UI。 首先,我们在MainWindow类的主体中声明两个私有方法initUIcreateActions

     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,这是我们之前计划的。 然后,我们创建QGraphicsSenceQGraphicsView的实例,这些实例将用于显示图像并播放视频。 最后一行对我们来说很新,它显示了如何向布局添加小部件。 我们用五个参数调用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 设置,因此让我们编译并运行该应用以查看其外观:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d4D4w02H-1681871114324)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/1c965cc4-f9e9-4d4e-baef-2a611ceb092d.png)]

如您所见,借助 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方法将此插槽连接到cameraInfoActiontriggered信号:

         connect(cameraInfoAction, SIGNAL(triggered(bool)), this, SLOT(showCameraInfo()));

好的,让我们编译并运行Gazer应用。 现在,单击“文件”菜单下的“相机信息”项,然后查看提供的信息:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vJduKBZB-1681871114324)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/66b18e7d-7242-4e72-8c1d-551fb542b10e.png)]

我的笔记本电脑有一个内置的网络摄像头和一个连接到 USB 端口的外部网络摄像头,因此该应用在计算机上运行时会显示两个摄像头:/dev/video0/dev/video2。 我在笔记本电脑上使用 GNU/Linux,在此平台上,设备名称采用/dev/video的模式,其中是摄像机的索引。 如果您使用的是其他操作系统,则看到的信息可能与我的不同。

在这种情况下,“可用摄像头”短语中的“可用”一词表示已在计算机上正确连接并驱动了摄像头,并且该摄像头必须不忙,即未被任何应用使用。 如果任何应用正在使用相机,则该相机将不会包含在QCameraInfo::availableCameras方法的返回列表中。

捕捉和播放

我们已经在上一节中获得了网络摄像头的信息,因此让我们使用 OpenCV 捕获并播放来自选定网络摄像头的视频提要。

使用 OpenCV 捕获视频非常容易。 以下是一个示例:

     #include 
     #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 
     #include 
     #include 

     #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关键字指示此方法是一个虚拟方法,并且它正在覆盖与其基类的方法之一同名的方法。 QThreadrun方法是线程的起点。 当我们调用线程的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成员字段 。

然后,我们将capturerframeCaptured信号连接到主窗口的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.

应用启动后,单击“文件”菜单中的“打开相机”操作以从相机的角度查看视图。 以下是我的网络摄像头在办公室外的视图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-e4Y7Anca-1681871114324)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/6105425e-f6e2-443c-aa39-9c5668d69792.png)]

线程和实时视频处理的性能

在本节中,我们将在我们的应用中涉及多线程技术。 这是出于以下两个目的:

  • 为了避免主线程(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对象而不是QGraphicsSenceQGraphicsView对象的QCamera类实例。 让我们在mainwindow.h头文件中查看它们的声明:

     #ifdef GAZER_USE_QT_CAMERA
         QCamera *camera;
         QCameraViewfinder *viewfinder;
     #endif

如您所见,变量声明在我们的代码中被ifdef/endif块包围。 这样可以确保仅在编译应用时定义GAZER_USE_QT_CAMERA宏时,才会使用有关使用 Qt 捕获视频的代码。 否则,我们的应用仍会使用 OpenCV 捕获视频。

然后,在mainwindow.cpp文件中实现initUI方法的过程中,我们创建并配置我们刚刚声明的QCameraQCameraViewfinder对象:

     #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对象。

然后,我们创建QCameraViewfinderQCameraViewfinderSettings。 该对象用于配置取景器对象。 在我们的代码中,我们使用它来设置取景器的分辨率。 此处的分辨率值必须与相机兼容。 我的相机是 Logitech C270,从其规格页面中,我们可以看到它支持320 x 240640 x 480800 x 600的分辨率。我在代码中使用800 x 600。 设置和取景器准备就绪后,我们通过调用setViewfindersetViewfinderSettings方法将它们设置为相机对象。 然后,将取景器添加到主窗口的主网格布局中,并使其占据前 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 模块。 项目文件更新后,我们可以编译并运行我们的应用。 对其进行编译,启动,然后单击“打开摄像机”操作-您将在我们应用的主要区域中看到视频。 以下是该应用在计算机上运行时的屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-R2lP8PuA-1681871114324)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/9d020029-883d-4f8f-a0f7-db69d2d05d76.png)]

如您在前面的屏幕快照中所见,它几乎与我们使用 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源文件的构造器中将它们初始化为false0.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 的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EXrYvmRM-1681871114325)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/817c9ee7-fd9e-47cd-beea-0e7bdbcad21d.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8bvgl165-1681871114325)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/6540fd7c-d6ea-461b-afeb-43a34e5c2935.png)]

供应商在该网页上说其 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//Videos/。 如果使用其他操作系统,则路径在 MacOS 上为/Users//Movies,在 Windows 上为C:\Users\\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_widthframe_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文件中为两种方法提供实现。 当视频保存工作即将开始或停止时,将调用startSavingVideostopSavingVideo方法。 让我们看看它们在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方法中创建了按钮,我们就可以将此插槽连接到recordButtonclicked信号:

        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_imgcodecs OpenCV 模块添加到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

最后,是时候编译并运行我们的应用了! 以下屏幕截图显示了录制多个视频文件后应用的外观:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DFcLuZ3l-1681871114325)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/05d44380-78e5-4e91-9a9f-0fb6b4516f0b.png)]

为了使本章,项目简洁明了,我们未提供在应用中播放保存的视频的功能。 如果要播放它们,只需使用您喜欢的视频播放器。

OpenCV 运动分析

在前面的部分中,我们构建了一个完整的应用,用于使用相机播放和保存视频。 但是对于家庭安全应用来说,这还不够。 我们必须了解家里发生的事情时的情况。 这将通过使用 OpenCV 提供的运动检测功能来完成。

OpenCV 运动检测

通常,运动检测是通过分割图像中的背景和前景内容来完成的。 因此,在检测运动时,我们通常假定出现在摄像机中的给定场景的背景部分是静态的,并且不会在视频的连续帧中变化。 通过分析这些连续的帧,我们可以以某种方式提取该场景的背景,因此也可以提取前景。 如果在前景中发现了一些物体,我们可以假定检测到运动。

但是,这种假设在现实世界中并不总是正确的-太阳升起,落下,灯光开和关,阴影出现,移动和消失。 这些变化可能会改变背景,因此我们的算法取决于该假设。 因此,使用固定安装的摄像机和受控的照明条件始终是构建准确的背景/前景分割系统的先决条件。

为了简化应用的实现,我们还假设网络摄像头是固定的或安装在负责稳定房屋安全的照明条件稳定的地方。

在计算机视觉领域,术语背景/前景提取,背景减法和背景/前景分割指的是我们正在讨论的相同技术。 在本书中,我将互换使用它们。

在 OpenCV 中,提供了许多算法来进行背景分割。 它们中的大多数实现为视频模块中BackgroundSubtractor类的子类。 我们可以在这个页面中找到类层次结构。 该网页的以下屏幕截图显示了相关的类:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QprBgPGB-1681871114325)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/f9620241-49a6-4eff-ad31-9a826a74f721.png)]

如我们所见,有 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 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);
         }

好的,运动检测工作已经完成。 考虑到此过程有点抽象,我们可以保存捕获的帧,提取的前景遮罩,去除噪声的遮罩以及带有矩形的帧作为图像绘制到硬盘上。 下图显示了一个小型垃圾箱在几辆车之间通过时我保存的图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WS1HNpKC-1681871114325)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/525d8759-d54f-488a-b5c4-107607a59723.png)]

上图包含与我们提到的阶段相对应的四个标记图像:

  • 输入是原始捕获帧。
  • 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”的“记录”按钮。

我没有在此处粘贴这些更改的代码,因为它们非常简单-您应该可以自己进行更改,或者在需要帮助时直接引用我们代码存储区中的代码。

现在,运动检测功能已经完成,因此我们可以编译应用并进行尝试。 以下屏幕截图显示了它在玩具车场景中如何工作:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HIb5PkXp-1681871114326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/5fa85302-d166-4141-bfd4-82b8b0fc4732.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dXonUmkm-1681871114326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/e118d37f-f11b-4f8f-9c56-fa4a5836b99f.png)]

您会看到在公园散步的人周围以及在公园道路上行驶的汽车周围的许多矩形。

发送通知到我们的手机

在上一节中,我们完成了运动检测功能。 但是,当它检测到运动时,除了保存该运动的视频外,它仅打印一条消息。 作为家庭安全应用,这还不够。 无论我们身在何处或在做什么,我们都需要知道何时检测到运动。 在本节中,我们将通过 IFTTT 服务将通知发送到我们的手机来实现。

IFTTT 是连接许多有用服务的平台。 您可以通过创建一个 IFTTT 小程序来连接两个选定的服务,一个名为this,另一个名为that。 如果this发生事件,则将触发该服务。 这就是 IFTTT 的意思:if this then that

要使用 IFTTT 发送通知,我们需要一个 IFTTT 帐户,该帐户可以在这个页面上创建。 通过一个帐户,我们可以创建一个带有 Webhook 的小程序作为this服务,并将手机通知服务作为that服务。 让我们逐步创建小程序。 创建一个小程序总共需要八个步骤。 以下屏幕截图显示了前四个:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7DB0m0g4-1681871114326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/df9672ec-4441-4b13-bbab-9ccd27b56360.png)]

这是最后四个步骤:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VU2LDd37-1681871114326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/815a7d21-751d-4ef2-951e-3f062b89e23d.png)]

我们需要在每个步骤中采取一些措施:

  1. 登录,单击右上角的用户名,然后在下拉菜单中单击“新建小程序”。

  2. 点击蓝色的+链接。

  3. 在“选择服务”页面上,在文本框中键入webhooks,然后单击 Webhooks 方框。 然后,在下一页上选择“接收 Web 请求”。

  4. 在“完成触发器字段”页面上,键入Motion-Detected-by-Gazer作为事件名称,然后单击“创建触发器”按钮。

  5. 点击新页面上的+ that链接。

  6. 在“选择操作服务”页面上,在文本框中键入notifi,然后选择“通知”方框,然后在下一页上选择“从 IFTTT 应用发送通知”。

  7. 在“完成操作字段”页面上,您将找到一个文本区域。 在文本区域中键入Motions are just detected by the Gazer application from the camera {{Value1}} on {{Value2}}, please check it up!,然后单击创建动作按钮。

  8. 在“审阅并完成”页面上,为小程序命名,然后单击“完成”按钮。 我将其命名为Gazer Notification,但您可以选择任何名称。

现在已经创建了 Applet,让我们在 IFTTT 上找到 webhook 的端点:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MZRyr4tG-1681871114326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/f2be8abf-a161-489a-a4a8-a4f6f116d1cb.png)]

转到 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 中的value1value2字段替换。 在这里,我们将摄像机索引用作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 应用。 当检测到运动时,将会在我们的手机上收到通知。 我的看起来像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9hXAW8Pj-1681871114327)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/7c5b9542-5c32-4f0d-88dd-ffc1343882ab.png)]

不要忘记在手机上安装 IFTTT 应用并为其启用通知,然后使用您的 IFTTT 帐户登录。 否则,将不会收到通知。

总结

在本章中,我们创建了一个新的桌面应用 Gazer,用于捕获,播放和保存摄像机中的视频。 为了家庭安全,我们还添加了运动检测功能。 我们使用 Qt 构建了 UI,并使用 OpenCV 开发了视频处理功能。 这两个部分有机地集成到了我们的应用中。 在此应用的开发中,我们了解了如何使用 Qt 布局系统在 UI 上排列小部件,如何使用多线程技术在与主 UI 线程不同的线程中进行慢速工作,如何使用来检测运动。 OpenCV,以及如何通过触发 HTTP 请求通过 IFTTT 向我们的手机发送通知。

在下一章中,我们将学习如何实时识别图像或视频中的面部,并且我们将构建一个有趣的应用,以便可以在检测到的面部上放置有趣的遮罩。

问题

尝试以下问题,以测试您对本章的了解:

  1. 我们可以从视频文件而不是摄像机中检测运动吗? 我们该怎么做?
  2. 我们可以在不同于视频捕获线程的线程中进行运动检测吗? 为什么或者为什么不?
  3. IFTTT 允许您在发送的通知中包括图像-当通过 IFTTT 的此功能向您的手机发送通知时,我们如何发送检测到的运动图像?

四、人脸上的乐趣

在第 3 章,“家庭安全应用”中,我们创建了一个名为 Gazer 的新应用,利用该应用,我们可以捕获视频到我们的计算机并从连接的网络摄像头中检测运动。 在本章中,我们将继续使用网络摄像头-代替检测运动,我们将创建一个新应用,该应用能够使用相机检测面部。 首先,我们将检测网络摄像头中的面部。 然后,我们将在检测到的面部上检测人脸标志。 通过这些人脸标志,我们可以知道每个检测到的脸上的眼睛,鼻子,嘴巴和脸颊在哪里,因此我们可以在脸上应用一些有趣的面具。

本章将涵盖以下主题:

  • 从网络摄像头拍照
  • 使用 OpenCV 检测人脸
  • 使用 OpenCV 检测人脸标志
  • Qt 库的资源系统
  • 在脸上覆盖遮罩

技术要求

正如我们在前几章中所看到的,要求用户至少安装 Qt 版本 5 并具有 C++ 和 Qt 编程的基本知识。 另外,应正确安装最新版本的 OpenCV 4.0。 另外,除了coreimgproc模块外,本章还将使用 OpenCV 的videovideoio模块。 如果您已经阅读了前面的章节,那么这些要求将已经得到满足。

我们将使用 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字段并未真正删除。 我们将它们分别重命名为appendSavedPhotoQPushButton *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.hutilities.cpp文件了。 在头文件中,我们删除notifyMobile方法,并将newSavedVideoNamegetSavedVideoPath方法分别重命名为newPhotoNamegetPhotoPath

     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实例capturervoid takePhoto()方法,以告知它拍照(如果不为空)。 然后,在MainWindow::initUI()方法中,在创建快门按钮之后,将此插槽连接到shutterButtonclicked信号:

         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 上参考本书随附的代码存储库。 本小节中的所有更改都可以在以下提交中找到。

现在,让我们再次编译并运行我们的应用。 应用显示其主窗口后,我们可以单击“文件”菜单下的“打开相机”操作以打开相机,然后单击“快门”按钮拍照。 完成这些操作后,我的主窗口如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YcC4IZfg-1681871114327)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/641e3c74-b3f5-43f2-8c2e-699b73dc4957.png)]

在本节中,我们设置新应用的基本功能。 在下一节中,我们将使用 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向量中获取检测到的面部的所有区域,然后在捕获的帧上绘制这些矩形,并用一像素的红色边框。

好的,人脸检测功能现已完成,因此让我们编译并运行我们的项目。 当有人进入打开的网络摄像头的视野时,您会在他们的脸部周围看到一个红色矩形:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QTBrafcP-1681871114327)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/03184846-d6cf-47d8-807f-3f1e10720b22.png)]

在我们的代码中,当我们创建级联分类器时,我们使用了haarcascade_frontalface_default.xml文件。 但是您可能会注意到,当我们列出 OpenCV 安装的数据目录时,有多个文件,其名称指示该文件用于正面检测,例如haarcascade_frontalface_alt.xmlhaarcascade_frontalface_alt2.xmlhaarcascade_frontalface_alt_tree.xml。 为什么我们选择haarcascade_frontalface.xml,而不选择其他? 在不同的数据集上或在不同的配置下训练该预训练的模型数据。 这些文件的详细信息在每个文件的开头都以注释形式记录在文件中,因此您可以根据需要参考该文件。 选择模型文件的另一种直接方法是尝试所有模型文件,在数据集上对其进行测试,计算精度和召回率,然后为您的案例选择最佳模型。

除 Haar 级联分类器外,还有一个称为本地二进制模式LBP)级联分类器的级联分类器,默认随 OpenCV 版本一起提供。 您可以在 OpenCV 安装的数据路径的lbpcascades目录下找到其训练有素的模型数据。 LBP 级联分类器比 Haar 更快,但精度也较低。 我们可以将它们与下表进行比较:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KnES7cak-1681871114327)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/6f842a64-f44d-4238-9b7c-0693c8b9cb92.png)]

请随意尝试这些算法和预先训练的模型数据,以找到适合您情况的最佳算法。

检测人脸标志

在上一节中,通过使用 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 附加模块。

安装面部模块后,让我们通过在网络浏览器中打开这个页面来打开其文档,以查看其提供的功能 。

FacemarkKazemiFacemarkAAMFacemarkLBF类是用于检测人脸标志的算法。 这些算法都是基于机器学习的方法,因此还有许多用于数据集处理和模型训练的工具。 训练机器学习模型超出了本章的范围,因此在本节中,我们仍将使用预训练的模型。

在我们的应用中,我们将使用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类型表示的一系列点,并且在单个帧中可能检测到多个人脸,因此我们应该使用这种复杂的数据类型来表示它们。 然后,关键部分出现了-我们调用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
    }

好的,现在该编译并运行我们的应用了。 在应用运行时,这些标志如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-I004zxrP-1681871114328)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/71d8484b-bcb6-45b3-9004-4340a7f5ba9a.png)]

如您所见,我们在眼睛,眉毛,鼻子,嘴巴和下巴上有很多标志。 但是我们仍然无法分辨出哪些点代表哪些面部特征。 考虑到每个面部的地标中的点的顺序是固定的,我们可以使用这些点的索引来确定某个点是否适合某个面部特征。 为了清楚起见,我们绘制每个点的索引号而不是 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);

然后,当我们检测并绘制人脸上的人脸标志时,它看起来像这样:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GgNM12Hm-1681871114328)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/23f2644c-ad09-4510-b402-1ae72d87d07f.png)]

如我们所见,由其索引号表示的点在脸部上具有固定位置,因此我们可以通过以下点的索引来访问这些面部特征:

  • 可以通过点[48, 68]访问嘴。
  • 右眉通过点[17, 22]
  • 左眉通过点[22, 27]
  • 右眼通过[36, 42]
  • 左眼通过[42, 48]
  • 鼻子通过[27, 35]
  • 下巴通过[0, 17]

在下一部分中,我们将使用此位置信息在检测到的面部上应用遮罩。

在脸上覆盖遮罩

在本章的前面各节中,我们成功地在视频提要中检测到了面部和人脸标志。 在本节中,我们将做一些更有趣的事情-我在这里有三个面具或装饰物。 让我们尝试将它们实时应用于检测到的面部:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lnKBhH0k-1681871114328)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/f26b39b7-4ea2-4de9-a5fb-73e71842d758.png)]

这些装饰物是磁盘上的图像。 与来自用户的数据不同,例如我们在第 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 -makefilemake编译项目时,将调用rcc的命令放在Makefile中,然后执行。 结果,将生成一个名为qrc_images.cpp的 C++ 源文件。 该文件由 Qt 资源编译器rcc生成。 同样,.qrc文件中列出的所有图像都作为字节数组嵌入到此生成的 C++ 源文件中,并且在编译项目时将被编译到应用的可执行文件中。

好的,我们成功将装饰图像嵌入到应用的可执行文件中。 但是,我们如何访问它们? 这很容易; 这些资源可以在我们的代码中以与源树中带有:/前缀的相同文件名进行访问,也可以通过具有qrc方案的 URL 进行访问。 例如,https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/glasses.jpg文件路径或qrc:https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/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(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/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(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/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(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/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方法时,我们使用https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/glasses.jpg字符串作为其参数。 这是一个以:/开头的字符串,并且如上所述,这是从 Qt 资源系统加载资源的方式。 在这里,使用qrc:https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/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]);
             }
             // ...

为了使视频清晰,我们还注释掉了用于绘制人脸标志的语句。 现在,让我们编译并运行该应用以查看其运行情况:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-UOQ27ZLw-1681871114328)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/0b034f30-f1b4-4a28-92a3-8e807fa207bf.png)]

哈,还不错! 让我们继续。 我们仍然需要绘制两个装饰物,但是可以通过与绘制眼镜几乎相同的方式来完成:

  • 对于胡须,我们使用marks[54](它是嘴的左上角)和marks[48](它是嘴的右上角)来计算宽度和倾斜度。 用marks[33]marks[51]确定中心点。
  • 对于鼠标鼻子,我们使用marks[13]marks[3]确定宽度,使用marks[16]marks[0]计算倾斜度,并使用marks[30]和鼻尖作为中心点。

利用前面的信息,您可以自己实现drawMustachedrawMouseNose方法,或在我们的代码库中引用代码。 下图显示了我们的应用如何完成所有这些装饰:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SozGiLwj-1681871114328)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/f46dd526-e860-4d62-a22c-4ae6a5825904.png)]

我的男孩好可爱,不是吗?

在 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元素,即5updateMasks插槽用于这些复选框。 让我们在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 的所有工作都完成了! 让我们编译并运行它以查看其外观:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3h42XRaP-1681871114329)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/efb1c2db-8891-499e-a6f2-1060d41b8c5f.png)]

现在,用户可以利用快门按钮下方的复选框选择要显示的标记或遮罩。

总结

在本章中,我们通过缩短上一章中构建的 Gazer 应用创建了一个名为 Facetious 的新应用。 在缩短过程中,删除了视频保存和运动检测的功能,并添加了新的照相功能。 结果,我们得到了一个干净的应用,它使我们可以专注于面部识别。 然后,在该应用中,我们开发了使用预训练的叶栅分类器进行人脸检测的功能,以及使用其他预训练的机器学习模型进行人脸标志检测的功能。 最后,这是有趣的部分,我们借助检测到的地标在检测到的面部上应用了许多装饰。

在下一章中,我们将讨论光学字符识别OCR)技术,并使用该技术从图像或扫描的文档中提取文本。

问题

尝试这些问题,以测试您对本章的了解:

  1. 尝试使用 LBP 级联分类器自己检测人脸。
  2. 还有一些其他算法可用于检测 OpenCV 库中的人脸标志,并且大多数算法可在这里。 请自己尝试。
  3. 如何将彩色装饰物应用到脸上?

五、光学字符识别

在前面的章节中,我们对视频和摄像机做了很多工作。 我们创建了应用(GazerFacetious),通过它们可以播放连接到计算机的网络摄像头中的视频。 我们还可以使用这些应用实时记录视频,拍照,检测动作和面部,以及将遮罩应用于在视频供稿中检测到的面部。

现在,我们将重点转移到图像中的文本上。 在许多情况下,我们要从图像中提取文本或字符。 在计算机视觉领域,有一种称为光学字符识别OCR)的技术可以自动执行这种工作,而不是手动转录文本。 在本章中,我们将构建一个新的应用,以使用 Qt 和许多 OCR 库从图像和扫描的文档中提取文本。

我们将在本章介绍以下主题:

  • 从图像中提取文本
  • 检测图像中的文本区域
  • 访问屏幕内容
  • 在窗口小部件上绘制并裁剪屏幕的某些部分

技术要求

从前面的章节中可以看到,要求用户至少安装 Qt 版本 5 并具有 C++ 和 Qt 编程的一些基本知识。 另外,应该正确安装最新版本的 Tesseract 4.0 版,因为在本章中我们将使用此库作为 OCR 工具。 对于 Windows,可以在这个页面中找到预构建的 Tesseract 二进制包。 对于类似 UNIX 的系统,我们将在使用它之前逐步从源代码构建 Tesseract。

深度学习的一些知识也将对理解本章的内容有很大帮助。

本章的所有代码都可以在我们的代码库中找到。

观看以下视频,查看运行中的代码

创建 Literacy

如前所述,我们将创建一个新的应用以从图像或扫描的文档中提取文本,因此其名称为 Literacy。 首先是要弄清楚应用打算做什么。 主要功能是从图像中提取文本,但是,为了方便用户,我们应该提供多种指定图像的方法:

  • 该图像可能来自本地硬盘。
  • 可以从屏幕上捕获图像。

在明确了此要求之后,现在让我们设计 UI。

设计 UI

绘制以下线框,作为我们应用的 UI 设计:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XH9dtcPz-1681871114329)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/e2c72cf9-c24e-4a96-a15d-c7c6802711bb.png)]

如您所见,我们将主区域垂直分为两部分-左侧部分用于显示打开的或捕获的图像,而右侧部分用于提取的文本。 窗口的其他部分,例如菜单栏,工具栏和状态栏,都是我们非常熟悉的方面。

您可以从 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 
     #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; 工具栏fileToolBarQGraphicsSceneQGraphicsView显示目标图像; 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

运行应用后,桌面上将出现一个如下所示的窗口:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rH9rWmO6-1681871114329)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/4d4d18d0-b062-4116-8300-156450cdf45b.png)]

因此,我们设置了没有任何交互功能的完整 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::showImageMainWindow::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();
     }

现在,我们再次编译并运行我们的应用,以测试这些新添加的交互式功能。 单击操作,打开图像,在编辑器中键入一些单词,拖动分隔条以调整列的宽度,然后将图像或文本另存为文件。 我们正在与之交互的主窗口如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-avPpVnCd-1681871114329)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/9a06819a-5190-49ac-80be-24616850cea8.png)]

正如您在图中所看到的,我们打开一个包含许多字符的图像,并在右侧的编辑器中键入一些文本。 在下一部分中,我们将从图像中提取文本,然后通过单击工具栏上的按钮自动将提取的文本填充到编辑器中。

在此提交中可以找到本节中所有代码的更改。

OCR 与 Tesseract

在本节中,我们将使用 Tesseract 从图像中提取文本。 如前所述,要在 Windows 上安装 Tesseract,我们可以使用预构建的二进制包。 在类似 UNIX 的系统上,我们可以使用系统包管理器进行安装,例如,在 Debian 上安装apt-get,在 MacOS 上安装brew。 以 Debian 为例-我们可以安装libtesseract-devtesseract-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 来处理图像,因此我们可以忽略这些信息。

现在,让我们尝试使用此命令行工具从图像中提取文本。 下图准备作为输入:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LnzRfQJs-1681871114330)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/7a19bbda-500c-4f33-b964-a4da288a218b.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RCiVfwk2-1681871114330)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/62a84ba5-9f8e-4135-b00c-1b3876744331.png)]

如您所见,在这种情况下,我们为命令行工具提供了许多参数:

  • 第一个是输入图像。
  • 第二个是输出。 我们使用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 操作。 然后,应用将使用从左侧图像中提取的文本填充编辑器:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-svvvZtLW-1681871114330)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/511aa3f4-8732-4a84-b802-5c8756454892.png)]

如果对结果满意,可以通过单击文件菜单下的“将文本另存为”项将文本另存为文件。

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HAeEVeLv-1681871114330)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/8c62433c-f1af-457d-944f-0ef0da9fb7d9.png)]

就像我们猜到的那样,我们的应用无法提取其中的文本。 为了处理这类图像,我们不应该只是将整个图像传递给 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 
    // ...
    #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变量。 这两个层包含我们想要的信息:

  1. 第一层feature_fusion/Conv_7/Sigmoid是 Sigmoid 激活的输出层。 该层中的数据包含给定区域是否包含文本的概率。
  2. 第二层feature_fusion/concat_3是特征映射的输出层。 该层中的数据包含图像的几何形状。 通过稍后在此层中解码数据,我们将获得许多边界框。

之后,我们将输入图像从QImage转换为cv::Mat,然后将矩阵转换为另一个矩阵,该矩阵是一个 4 维 BLOB,可以用作 DNN 模型的输入,换句话说, 输入层。 后一种转换是通过在 OpenCV 库的cv::dnn名称空间中调用blobFromImage函数来实现的。 在此转换中执行许多操作,例如从中心调整大小和裁剪图像,减去平均值,通过比例因子缩放值以及交换 R 和 B 通道。 在对blobFromImage函数的调用中,我们有很多参数。 现在让我们一一解释:

  1. 第一个参数是输入图像。
  2. 第二个参数是输出图像。
  3. 第三个参数是每个像素值的比例因子。 我们使用 1.0,因为我们不需要在此处缩放像素。
  4. 第四个参数是输出图像的空间大小。 我们说过,此尺寸的宽度和高度必须是 32 的倍数,此处我们将320 x 320与我们定义的变量一起使用。
  5. 第五个参数是应该从每个图像中减去的平均值,因为在训练模型时已使用了该平均值。 在此,使用的平均值为(123.68, 116.78, 103.94)
  6. 下一个参数是我们是否要交换 R 和 B 通道。 这是必需的,因为 OpenCV 使用 BGR 格式,而 TensorFlow 使用 RGB 格式。
  7. 最后一个参数是我们是否要裁剪图像并进行中心裁剪。 在这种情况下,我们指定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”按钮。 将看到以下不良结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BtlS5R8n-1681871114330)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/cf07eea4-d5f7-4b6a-9226-e294d824563d.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eeDh4PoV-1681871114331)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/69051594-80bb-4274-be8e-0f10ad85937f.png)]

正确检测到四个文本区域,并且正确识别了其中三个文本。 不错!

识别屏幕上的字符

在前面的部分中,我们结束了对 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,当释放按下的鼠标按钮时调用

当按下鼠标按钮时,我们会将其按下的位置保存到成员p1p2中,将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,然后更新小部件。

使用这三个事件处理器,当用户拖动鼠标时,我们可以得到一个由连续更新的点p1p2确定的矩形。 该矩形是选择区域。 现在,让我们在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());
    }

如您所见,在方法主体中,我们添加了四行代码。 我们检查p1p2是否相同。 如果不是,则绘制由p1p2确定的矩形的边界,并从绘制半透明灰色叠加层的区域中减去该矩形。 现在,如果用户拖动鼠标,他们将看到选定的矩形。

现在,用户可以打开全屏窗口小部件并选择从整个桌面获取的图像区域。 之后,用户将要使用选择或放弃操作。 这些由插槽confirmCapturecloseMe实现:

    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

现在,让我们编译并启动我们的应用。 单击工具栏上的“捕获屏幕”按钮,然后拖动鼠标以选择如下区域:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yfTqosA1-1681871114331)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/79a4312d-a213-4ae0-b91c-0a3bbd4ec27e.png)]

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

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Fd75vENG-1681871114331)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/qt5-opencv4-cv-proj/img/4ab0a052-fead-4d49-8f0b-b34eaa5fbf46.png)]

好! 它完全按照我们的期望工作,我们的应用终于完成了。

总结

在本章中,我们创建了一个名为 Literacy 的新应用。 在此应用中,我们使用 Tesseract 库识别图像上的字符。 对于具有良好排版字符的图像,Tesseract 效果很好; 但是对于日常生活中照片中的人物,它无法识别它们。 为了解决此问题,我们使用带有 OpenCV 的 EAST 模型。 使用预先训练的 EAST 模型,我们首先检测照片中的文本区域,然后指示 Tesseract 库仅识别检测到的区域中的字符。 此时,Tesseract 再次表现良好。 在上一节中,我们学习了如何将桌面作为图像获取,以及如何通过拖动鼠标在桌面上选择区域。

在本章,前几章中,我们使用了几个预训练的神经网络模型。 在下一章中,我们将进一步了解它们。 例如,如何使用预训练的分类器或模型来检测对象以及如何训练模型。

问题

尝试这些问题以测试您对本章的了解:

  1. Tesseract 如何识别非英语语言的字符?
  2. 当我们使用 EAST 模型检测文本区域时,检测到的区域实际上是旋转的矩形,而我们只是使用它们的边界矩形。 这总是对的吗? 如果没有,如何解决?
  3. 是否可以找到一种方法,允许用户在从屏幕上捕获图像时拖动鼠标后调整所选区域?

你可能感兴趣的:(opencv,人工智能,深度学习,pyqt,python,opencv)