OpenCV3 和 Qt5 计算机视觉:6~10

原文:Computer Vision with OpenCV 3 and Qt5

协议:CC BY-NC-SA 4.0

译者:飞龙

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

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

六、OpenCV 中的图像处理

它始终以未经处理的原始图像开始,这些图像是使用智能手机,网络摄像头,DSLR 相机,或者简而言之,是能够拍摄和记录图像数据的任何设备拍摄的。 但是,通常以清晰或模糊结束。 明亮,黑暗或平衡; 黑白或彩色; 以及同一图像数据的许多其他不同表示形式。 这可能是计算机视觉算法中的第一步(也是最重要的步骤之一),通常被称为图像处理(目前,让我们忘记一个事实,有时计算机视觉和图像处理可互换使用;这是历史专家的讨论。 当然,您可以在任何计算机视觉过程的中间或最后阶段进行图像处理,但是通常,用大多数现有设备记录的任何照片或视频首先都要经过某种图像处理算法。 这些算法中的某些仅用于转换图像格式,某些用于调整颜色,消除噪点,还有很多我们无法开始命名。 OpenCV 框架提供了大量功能来处理各种图像处理任务,例如图像过滤,几何变换,绘图,处理不同的色彩空间,图像直方图等,这将是本章的重点。

在本章中,您将学习许多不同的函数和类,尤其是从 OpenCV 框架的imgproc模块中。 我们将从图像过滤开始,在此过程中,您将学习如何创建允许正确使用现有算法的 GUI。 之后,我们将继续学习 OpenCV 提供的几何变换功能。 然后,我们将简要介绍一下什么是色彩空间,如何将它们彼此转换,等等。 之后,我们将继续学习 OpenCV 中的绘图函数。 正如我们在前几章中所看到的,Qt 框架还提供了相当灵活的绘图函数,它甚至还可以通过使用场景视图项目架构更轻松地处理屏幕上的不同图形项。 但是,在某些情况下,我们也会使用 OpenCV 绘图函数,这些函数通常非常快,并且可以为日常图形任务提供足够的功能。 本章将以 OpenCV 中功能最强大但最易于使用的匹配和检测方法之一结尾,即模板匹配方法。

本章将包含许多有趣的示例和动手学习材料,并且一定要确保您尝试所有这些示例,以便在工作中看到它们,并根据第一手经验而不是仅仅通过第一手经验来学习它们。 紧随本章,某些部分结尾处提供的屏幕快照和示例源代码之后。

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

  • 如何为Computer_Vision项目和每个学习过的 OpenCV 技能创建新的插件
  • 如何过滤图像
  • 如何执行图像转换
  • 颜色空间,如何将它们彼此转换以及如何应用颜色映射
  • 图像阈值
  • OpenCV 中可用的绘图函数
  • 模板匹配以及如何将其用于对象检测和计数

图像过滤

在本入门部分,您将了解 OpenCV 中可用的不同线性和非线性图像滤波方法。 重要的是要注意,本节中讨论的所有函数都将Mat图像作为输入,并产生相同大小和相同通道数的Mat图像。 实际上,过滤器是独立应用于每个通道的。 通常,滤波方法从输入图像中获取一个像素及其相邻像素,并基于来自这些像素的函数响应来计算所得图像中相应像素的值。

这通常需要在计算滤波后的像素结果时对不存在的像素进行假设。 OpenCV 提供了许多方法来解决此问题,可以使用cv::BorderTypes枚举在几乎所有需要处理此现象的 OpenCV 函数中指定它们。 稍后我们将在本章的第一个示例中看到如何使用它,但是在此之前,让我们确保使用下图完全理解它:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3yG6Oak9-1681870063144)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/ebef60c8-cfe5-4de0-978a-0fa3f2e39174.png)]

如上图所示,计算(或在这种情况下为滤波函数)将区域 A 中的像素作为像素,并在处理后的所得图像(在这种情况下为过滤后的图像)中给我们像素 A。 在这种情况下没有问题,因为输入图像像素 A 附近的所有像素都在图像内部,即区域 A。 但是,图像边缘附近的像素或 OpenCV 中称为“边界像素”的像素又如何呢? 如您所见,并非像素 B 的所有相邻像素都落入输入图像,即区域 B。 这就是我们需要做的假设,即将图像外部像素的值视为零,与边界像素相同,依此类推。 这正是cv::BorderTypes枚举的含义,我们需要在示例中使用合适的值进行指定。

现在,在开始图像过滤函数之前,让我们用第一个示例演示cv::BorderTypes的用法。 我们将借此机会还学习如何为上一章中开始的Computer_Vision项目创建新插件(或克隆现有插件)。 因此,让我们开始:

  1. 如果您已经完全按照本书中的示例进行了操作,那么如果您已经在第 5 章,“图形视图框架”。 为此,请从Computer_Vision项目文件夹中的template_plugin文件夹复制(或复制并粘贴到同一文件夹中,这仅取决于您使用的 OS)。 然后,将新文件夹重命名为copymakeborder_plugin。 我们将为Computer_Vision项目创建第一个真实的插件,并通过一个真实的示例了解cv::BorderTypes的工作方式。
  2. 转到copymakeborder_plugin文件夹,然后在此处重命名所有文件以匹配插件文件夹名称。 只需将文件名中的所有template词替换为copymakeborder即可。
  3. 您可以猜测,现在我们还需要更新copymakeborder_plugin的项目文件。 为此,您可以简单地在标准文本编辑器中打开copymakeborder_plugin.pro文件,或将其拖放到“Qt Creator 代码编辑器”区域(而不是“项目”窗格)中。 然后,将TARGET设置为CopyMakeBorder_Plugin,如此处所示。 显然,您需要更新已经存在的类似行:
        TARGET = CopyMakeBorder_Plugin 
  1. 与上一步类似,我们还需要相应地更新DEFINES
        DEFINES += COPYMAKEBORDER_PLUGIN_LIBRARY 
  1. 最后,确保pro文件中的HEADERSSOURCES条目也已更新,如此处所示,然后保存并关闭pro文件:
        SOURCES += \ 
          copymakeborder_plugin.cpp 
        HEADERS += \ 
          copymakeborder_plugin.h \ 
          copymakeborder_plugin_global.h
  1. 现在,使用 Qt Creator 打开computer_vision.pro文件。 这将打开整个Computer_Vision项目,即Qt Multi-Project。 Qt 允许在单个容器项目中处理多个项目,或者由 Qt 本身称为subdirs项目类型。 与常规 Qt Widgets 应用项目不同,subdirs项目通常(不一定)具有非常简单且简短的*.pro文件。 一行将TEMPLATE类型提到为subdirs,并列出了SUBDIRS条目,该条目列出了subdirs项目文件夹中的所有项目文件夹。 让我们在 Qt Creator 代码编辑器中打开computer_vision.pro文件,亲自了解一下:
        TEMPLATE = subdirs 
        SUBDIRS += \ 
        mainapp \ 
        template_plugin 
  1. 现在,只需将copymakeborder_plugin添加到条目列表。 您更新的computer_vision.pro文件应如下所示:
         TEMPLATE = subdirs 
         SUBDIRS += \ 
         mainapp \ 
         template_plugin \ 
         copymakeborder_plugin 

请注意,在所有qmake(基本上是所有 Qt 项目文件)定义中,如果将条目划分为多行,则需要在除最后一行之外的所有行中都添加\,如前面的代码块所示。 我们可以通过删除\并在条目之间添加空格字符来编写相同的内容。 推荐的方法不是后者,但仍然正确。

  1. 最后,对于这部分,我们需要更新copymakeborder_plugin源文件和头文件的内容,因为显然,类名,包含的头文件甚至某些编译器指令都需要更新。 处理这些编程开销确实令人沮丧,因此让我们利用这一机会来了解 Qt Creator 中最有用的技巧之一,即 Qt Creator 中的“在此目录中查找…”功能。 您可以使用它从字面上查找(并替换)Qt 项目文件夹或子文件夹中的任何内容。 当我们希望避免手动浏览文件并一一替换代码段时,您将学习并使用此技术。 要使用它,您只需要从“项目”窗格中选择合适的文件夹,右键单击它,然后选择“在此目录中查找…”选项。 让我们用copymakeborder_plugin项目来完成它,如屏幕截图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OwMctrXQ-1681870063145)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/9df20080-4ca4-4f0b-a0e9-b6aae594a379.png)]

  1. 如下面的屏幕快照所示,这将打开 Qt Creator 窗口底部的“搜索结果”窗格。 在这里,您必须在“搜索:”字段中输入TEMPLATE_PLUGIN。 另外,请确保选中区分大小写选项。 其余所有选项均保持不变,然后单击“搜索&替换”按钮:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-puukKDCS-1681870063146)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/6f2ec581-4892-486e-819d-dadbaae4dc52.png)]

  1. 这会将“搜索结果”窗格切换到“替换”模式。 用COPYMAKEBORDER_PLUGIN填充替换为:字段,然后单击替换按钮。 显示如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-zyiNtwdc-1681870063146)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/41ff2952-77a1-40c4-91e6-18c08171ea1b.png)]

  1. 在前面的步骤中,我们使用查找和替换 Qt Creator 的功能将所有TEMPLATE_PLUGIN条目替换为COPYMAKEBORDER_PLUGIN。 使用相同的技能,并用copymakeborder_plugin替换所有template_plugin条目,并用CopyMakeBorder_Plugin替换所有Template_Plugin条目。 这样,我们的新插件项目就可以进行编程了,并最终可以在Computer_Vision项目中使用。

本章第一个示例项目中的所有上述所有步骤仅用于准备插件项目,从现在开始,无论何时需要,我们将这些步骤与克隆或(复制)模板插件来创建X插件,而在此示例中,X就是copymakeborder_plugin。 这将帮助我们避免大量重复的说明,同时,将使我们能够更加专注于学习新的 OpenCV 和 Qt 技能。 通过前面的步骤,尽可能地冗长而冗长,我们将避免处理图像读取,显示图像,选择正确的语言,选择正确的主题和样式以及许多其他任务,因为它们全都位于 Computer_Vision项目的一个子项目,称为mainapp,仅是 Qt Widgets 应用,负责处理所有与插件无关的任务,这些插件不涉及执行特定计算机视觉任务的插件。 在以下步骤中,我们将简单地填写插件的现有功能并创建其所需的 GUI。 然后,我们可以将构建的插件库文件复制到Computer_Vision可执行文件旁边的cvplugins文件夹中,并且当我们在Computer_Vision项目中运行mainapp时,每个插件都将在来自主菜单的Plugins中显示为条目,包括新添加的菜单。 本书其余部分的所有示例都将遵循相同的模式,至少在很大程度上,这意味着,除非我们需要专门更改插件或主应用的一部分,否则有关克隆和创建新版本的所有说明均应遵循。 插件(之前的步骤)将被省略。

如前几章所述,更改*.pro文件(或多个文件)后手动运行qmake始终是一个好主意。 只需在 Qt Creator 的“项目”窗格中右键单击该项目,然后单击“运行qmake”。

  1. 现在该为我们的插件编写代码并相应地创建其 GUI 了。 打开plugin.ui文件,并确保其用户界面包含以下小部件。 另外,请注意小部件的objectName值。 请注意,整个PluginGui文件的布局都设置为网格布局,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yRoFPAkD-1681870063146)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/af5f9e0a-8e51-47a0-afd0-69c7c7c2c69a.png)]

  1. borderTypeLabelsize Policy / Horizontal Policy属性设置为Fixed。 这将确保标签根据其宽度占据固定的水平空间。
  2. 通过右键单击borderTypeComboBox小部件的currentIndexChanged(int)信号添加方法,选择“转到插槽…”,选择提到的信号,然后单击“确定”按钮。 然后,在此函数的新创建函数(准确地说是插槽)中编写以下代码行:
        emit updateNeeded(); 

该信号的目的是告诉mainapp,在组合框的所选项目发生更改之后,插件可能会产生不同的结果,并且mainapp可能希望基于此信号更新其 GUI。 您可以检查mainapp项目的源代码,您会注意到所有插件的信号都连接到mainapp中的相关插槽,该插槽仅调用插件的processImage函数。

  1. 现在,在copymakeborder_plugin.cpp文件中,将以下代码添加到其setupUi函数中。 setupUi函数的内容应如下所示:
        ui = new Ui::PluginGui; 
        ui->setupUi(parent); 
        QStringList items; 
        items.append("BORDER_CONSTANT"); 
        items.append("BORDER_REPLICATE"); 
        items.append("BORDER_REFLECT"); 
        items.append("BORDER_WRAP"); 
        items.append("BORDER_REFLECT_101"); 
        ui->borderTypeComboBox->addItems(items); 
        connect(ui->borderTypeComboBox, 
        SIGNAL(currentIndexChanged(int)), 
        this, 
        SLOT(on_borderTypeComboBox_currentIndexChanged(int))); 

我们已经熟悉了与 UI 相关的启动调用,这些调用与每个 Qt Widgets 应用中的调用几乎相同,正如我们在上一章中了解到的那样。 之后,我们用相关项填充组合框,这些项只是cv::BorderTypes枚举中的条目。 如果按此顺序插入,则每个项目索引值将与其对应的枚举值相同。 最后,我们将所有信号手动连接到插件中的相应插槽。 请注意,这与常规 Qt 窗口小部件应用稍有不同,在常规应用中,您无需连接名称兼容的信号和插槽,因为它们是通过调用代码文件中的QMetaObject:: connectSlotsByName自动连接的,代码文件由 UIC 自动生成(请参阅第 3 章,“创建综合 Qt + OpenCV 项目”)。

  1. 最后,更新插件中的processImage函数,如下所示:
        int top, bot, left, right; 
        top = bot = inputImage.rows/2; 
        left = right = inputImage.cols/2; 
        cv::copyMakeBorder(inputImage, 
            outputImage, 
            top, 
            bot, 
            left, 
            right, 
        ui->borderTypeComboBox->currentIndex()); 

在这里,我们将调用copyMakeBorder函数,该函数也称为内部函数,该函数需要处理有关图像外部不存在的像素的假设。 我们仅假设图像顶部和底部添加的边框是图像高度的一半,而图像左侧和右侧添加的边框是图像宽度的一半。 至于borderType参数,我们只需从插件 GUI 上的选定项中获取即可。

一切都完成了,我们可以测试我们的插件了。 通过在“项目”窗格中右键单击整个Computer_Vision多项目并从菜单中选择“重建”(以确保清除并重建了所有内容),确保构建了整个Computer_Vision多项目。 然后,转到插件Build文件夹,从那里复制库文件,然后将其粘贴到mainapp可执行文件旁边的cvplugins文件夹中(在主应用Build文件夹中),最后运行mainapp 来自 Qt Creator。

mainapp启动后,您将面临一条错误消息(如果未复制插件或格式错误),或者最终将出现在Computer_Vision应用主窗口中。 然后,如果尚未选择mainapp的插件菜单,则可以选择我们刚刚构建的插件。 您可以在mainapp主窗口的组框中看到我们为插件设计的 GUI。 然后,您可以使用主菜单打开或保存图形场景的内容。 尝试打开一个文件,然后在插件组合框中的不同选项之间切换。 您也可以通过选中查看原始图像复选框来查看原始图像。 这是屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eSZoz7xf-1681870063146)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/90b80d20-8700-4b37-8008-425e72bbd313.png)]

从组合框中选择任何其他“边框类型”,您将立即注意到结果图像的变化。 重要的是要注意BORDER_REFLECT_101也是默认的边框类型(如果您未在 OpenCV 过滤和类似函数中指定一个),则与BORDER_REFLECT十分相似,但不会重复边界之前的最后一个像素。 有关此的更多信息,请参见cv::BorderTypes的 OpenCV 文档页面。 如前所述,这是需要处理外部(不存在)像素的相似插值的每个 OpenCV 函数相同的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-17Lnbk0H-1681870063147)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/d1973b8e-23c6-4259-a74a-441a1d018d17.png)]

而已。 现在,我们准备开始使用 OpenCV 中可用的过滤函数。

OpenCV 中的过滤函数

OpenCV 中的所有过滤函数均会拍摄图像,并产生尺寸和通道完全相同的图像。 如前所述,它们也都带有borderType参数,我们刚刚完成了实验和学习。 除此之外,每个过滤函数都有自己的必需参数来配置其行为。 这是可用的 OpenCV 过滤函数的列表及其说明和使用方法。 在列表的最后,您可以找到一个示例插件(称为filter_plugin)及其源代码的链接,其中包括以下列表中提到的大多数过滤器,并带有 GUI 控件以试验不同的参数和设置。 为每一个:

  • bilateralFilter:可用于获取图像的Bilateral Filtered副本。 根据σ值和直径,您可以获得的图像看上去可能与原始图像没有太大差异,或者获得的图像看起来像卡通图像(如果σ值足够高)。 这是bilateralFilter函数作为我们的应用的插件工作的示例代码:
        bilateralFilter(inpMat,outMat,15,200,200); 

这是bilateralFilter函数的屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hzJz15K8-1681870063147)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/9b8f8fea-fe40-4729-84a2-69b544298b9d.png)]

  • blurboxFiltersqrBoxFilterGaussianBlurmedianBlur:这些均用于获取输入图像的平滑版本。 所有这些函数都使用核大小参数,该参数与直径参数基本相同,并且用于确定从中计算出滤波后像素的相邻像素的直径。 (尽管我们没有了解它们的详细信息,但是这些过滤器函数与我们在本书前面各章中使用的过滤器函数相同。)GaussianBlur函数需要提供高斯核标准差(σ)参数,在XY方向上。 (有关这些参数的数学来源的足够信息,请参阅 OpenCV 文档。)实际上,值得注意的是,高斯过滤器中的核大小必须为奇数和正数。 同样,如果核大小也足够高,较高的σ值只会对结果产生重大影响。 以下是提到的平滑过滤器的几个示例(左侧为GaussianBlur,右侧为medianBlur),以及示例函数调用:
        Size kernelSize(5,5); 
        blur(inpMat,outMat,kernelSize); 
        int depth = -1; // output depth same as source 
        Size kernelSizeB(10,10); 
        Point anchorPoint(-1,-1); 
        bool normalized = true; 
        boxFilter(inutMat,outMat,depth, 
           kernelSizeB,anchorPoint, normalized); 
        double sigma = 10; 
        GaussianBlur(inpMat,outMat,kernelSize,sigma,sigma); 
        int apertureSize = 10; 
        medianBlur(inpMat,outMat,apertureSize); 

以下屏幕截图描绘了高斯和中值模糊的结果以及用于设置其参数的 GUI:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JrnZInqp-1681870063147)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/5db26a4a-b815-43a4-bc7c-785c2d28a738.png)]

  • filter2D:此函数可用于将自定义过滤器应用于图像。 您需要为此函数提供的一个重要参数是核矩阵。 此函数非常强大,它可以产生许多不同的结果,包括与我们先前看到的模糊函数相同的结果,以及许多其他过滤器,具体取决于提供的核。 这里有几个示例核,以及如何使用它们以及生成的图像。 确保尝试使用不同的核(您可以在互联网上搜索大量有用的核矩阵),并亲自尝试使用此函数:
        // Sharpening image 
        Matx33f f2dkernel(0, -1, 0, 
                         -1, 5, -1, 
                          0, -1, 0); 
        int depth = -1; // output depth same as source 
        filter2D(inpMat,outMat,depth,f2dkernel); 

        ***** 

        // Edge detection 
        Matx33f f2dkernel(0, +1.5, 0, 
                          +1.5, -6, +1.5, 
                          0, +1.5, 0); 
        int depth = -1; // output depth same as source 
          filter2D(inpMat,outMat,depth,f2dkernel); 

前面代码中第一个核的结果图像显示在左侧(这是图像的锐化版本),而第二个产生图像边缘检测的核在右侧可见:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rfxoJ6ho-1681870063148)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/b803466a-f39b-45a5-8f6e-e15e5457fe2d.png)]

  • LaplacianScharrSobelspatialGradient:这些函数处理图像导数。 图像导数在计算机视觉中非常重要,因为它们可用于检测图像中具有变化或更好的是显着变化的区域(因为这是导数的用例之一)。 无需过多地讨论其理论和数学细节,可以提及的是,在实践中,它们用于处理边缘或角点检测,并且在 OpenCV 框架中被关键点提取方法广泛使用。 在前面的示例和图像中,我们还使用了导数计算核。 以下是一些有关如何使用它们以及产生的图像的示例。 屏幕截图来自Computer_Vision项目和filter_plugin,此列表后不久有一个链接。 您始终可以使用 Qt 控件(例如旋转框,刻度盘和滑块)来获取 OpenCV 函数的不同参数值,以更好地控制该函数的行为:
        int depth = -1; 
        int dx = 1; int dy = 1; 
        int kernelSize = 3; 
        double scale = 5; double delta = 220; 
        Sobel(inpMat, outMat, depth,dx,dy,kernelSize,scale,delta); 

以下是上述代码的输出屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-8mltom4Z-1681870063148)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/9ccaccf3-0a7f-495c-8703-de600d9ade01.png)]

如果我们使用以下代码:

        int depth = -1; 
        int dx = 1; int dy = 0; 
        double scale = 1.0; double delta = 100.0; 
        Scharr(inpMat,outMat,depth,dx,dy,scale,delta); 

我们最终会得到类似于以下内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fS7Xm3IQ-1681870063148)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/30ae45da-d38b-4379-ae6c-3da8b9de7464.png)]

对于以下代码:

        int depth = -1; int kernelSize = 3; 
        double scale = 1.0; double delta = 0.0; 
        Laplacian(inpMat,outMat,depth, kernelSize,scale,delta); 

将产生类似于以下内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5NbKkt6o-1681870063148)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/a494560b-65d2-4ffd-9da9-739eae3a1351.png)]

  • erodedilate:从它们的名称可以猜出这些函数,它们对于获得腐蚀和膨胀效果很有用。 这两个函数都采用一个结构元素矩阵,可以通过简单地调用getStructuringElement函数来构建它。 (可选)您可以选择多次运行该函数(或对其进行迭代),以获得越来越腐蚀或膨胀的图像。 以下是如何同时使用这两个函数及其生成的图像的示例:
        erode(inputImage, 
        outputImage, 
        getStructuringElement(shapeComboBox->currentIndex(), 
        Size(5,5)), // Kernel size 
        Point(-1,-1), // Anchor point (-1,-1) for default 
        iterationsSpinBox->value()); 

以下是生成的图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ugQsR3lJ-1681870063148)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/dfde1698-e75c-4cea-b4a2-6a3d5edbc363.png)]

您可以将完全相同的参数传递给dilate函数。 在前面的代码中,假设使用组合框小部件获取结构元素的形状,该小部件可以是MORPH_RECTMORPH_CROSSMORPH_ELLIPSE。 同样,通过使用旋转框小部件设置迭代计数,该小部件可以是大于零的数字。

让我们继续下一个函数:

  • morphologyEx:此函数可用于执行各种形态学操作。 它需要一个操作类型参数以及我们在dilateerode函数中使用的相同参数。 以下是可以传递给morphologyEx函数的参数及其含义:
    • MORPH_ERODE:产生与erode函数相同的结果。
    • MORPH_DILATE:产生与dilate函数相同的结果。
    • MORPH_OPEN:可用于执行打开操作。 这与对侵蚀的图像进行放大相同,对于消除图像中的细微伪影很有用。
    • MORPH_CLOSE:可用于执行关闭操作。 它与侵蚀膨胀的图像相同,可用于消除线条中的细小断开等。
    • MORPH_GRADIENT:此函数提供图像的轮廓,并且与同一图像的侵蚀和膨胀版本的区别相同。
    • MORPH_TOPHAT:可用于获取图像与其打开的变形之间的差异。
    • MORPH_BLACKHAT:这可以用来获取图像关闭和图像本身之间的差异。

这是一个示例代码,并且如您所见,该函数调用与扩散和侵蚀非常相似。 再次,我们假设使用组合框小部件选择了形态类型和形状,并使用SpinBox选择了迭代计数:

        morphologyEx(inputImage, 
            outputImage, 
            morphTypeComboBox->currentIndex(), 
            getStructuringElement(shapeComboBox->currentIndex(), 
            Size(5,5)), // kernel size 
            Point(-1,-1), // default anchor point 
        iterationsSpinBox->value()); 

以下是不同形态学操作的结果图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rQ5dMQZG-1681870063149)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/e3558967-c7cc-498c-9347-eca091444a86.png)]

您可以使用以下链接获取filter_plugin源代码的副本,该代码与Computer_Vision项目兼容,并且包括您在本节中学到的大多数图像过滤函数。 您可以使用同一插件来测试并生成本节中看到的大多数图像。 尝试扩展插件以控制更多参数,或者尝试向插件添加更多功能。 这是filter_plugin源代码的链接:您可以使用以下链接

图像转换函数

在本节中,您将了解 OpenCV 中可用的图像转换函数。 通常,如果您查看 OpenCV 文档,则 OpenCV 中有两种图像转换类别,称为几何转换和其他(仅表示其他一切)转换。 在此解释其原因。

几何变换可以从其名称中猜出,主要处理图像的几何属性,例如图像的大小,方向,形状等。 注意,几何变换不会改变图像的内容,而只是根据几何变换类型通过在图像的像素周围移动来改变其形式和形状。 与我们在上一节开始时对图像进行过滤一样,几何变换函数还需要处理图像外部像素的外推,或者简单地说,在计算像素时对不存在的像素进行假设。 转换后的图像。 为此,当我们处理第一个示例copymakeborder_plugin时,可以使用本章前面学习的相同cv::BorderTypes枚举。

除此之外,除了所需的外推法之外,几何变换函数还需要处理像素的内插,因为变换后的图像中像素的计算位置将为float(或double)类型,而不是 integer,并且由于每个像素只能具有单一颜色,并且必须使用整数指定其位置,因此需要确定像素的值。 为了更好地理解这一点,让我们考虑一种最简单的几何变换,即调整图像大小,这是使用 OpenCV 中的resize函数完成的。 例如,您可以将图像调整为其大小的一半,完成后,计算出的图像中至少一半像素的新位置将包含非整数值。 位置(2,2)中的像素将位于调整大小后的图像中的位置(1,1),但是位置(3,2)中的像素将需要位于位置(1.5,1)中,依此类推。 OpenCV 提供了许多插值方法,这些方法在cv::InterpolationFlags枚举中定义,其中包括:

  • INTER_NEAREST:这是用于最近邻插值
  • INTER_LINEAR:用于双线性插值
  • INTER_CUBIC:这用于双三次插值
  • INTER_AREA:这是用于像素区域关系重采样
  • INTER_LANCZOS4:这是用于8x8附近的 Lanczos 插值

几乎所有的几何变换函数都需要提供cv::BorderTypecv::InterpolationFlags参数,以处理所需的外推和内插参数。

几何转换

现在,我们将从一些最重要的几何转换开始,然后学习色彩空间以及它们如何与一些广泛使用的非几何(或其他)转换相互转换。 因此,它们是:

  • resize:此函数可用于调整图像尺寸。 这是一个用法示例:
        // Resize to half the size of input image 
        resize(inMat, outMat, 
        Size(), // an empty Size 
        0.5, // width scale factor 
        0.5, // height scale factor 
        INTER_LANCZOS4); // set the interpolation mode to Lanczos 

        // Resize to 320x240, with default interpolation mode 
        resize(inMat, outMat, Size(320,240)); 
  • warpAffine:此函数可用于执行仿射变换。 您需要为此函数提供适当的变换矩阵,可以使用getAffineTransform函数获得该矩阵。 getAffineTransform函数必须提供两个三角形(源三角形和变换三角形),或者换句话说,提供两组三个点。 这是一个例子:
        Point2f triangleA[3]; 
        Point2f triangleB[3]; 

        triangleA[0] = Point2f(0 , 0); 
        triangleA[1] = Point2f(1 , 0); 
        triangleA[2] = Point2f(0 , 1); 

        triangleB[0] = Point2f(0, 0.5); 
        triangleB[1] = Point2f(1, 0.5); 
        triangleB[2] = Point2f(0.5, 1); 

        Mat affineMat = getAffineTransform(triangleA, triangleB); 

        warpAffine(inputImage, 
        outputImage, 
        affineMat, 
        inputImage.size(), // output image size, same as input 
        INTER_CUBIC, // Interpolation method 
        BORDER_WRAP); // Extrapolation method 

这是结果图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-52GQZ9Bt-1681870063149)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/8ae66fb1-24d4-4057-9fec-161d91d27680.png)]

您也可以使用warpAffine函数来旋转源图像。 只需使用getRotationMatrix2D函数来获取我们在前面的代码中使用的变换矩阵,然后将其与warpAffine函数一起使用。 请注意,此方法可用于执行任意角度的旋转,而不仅仅是 90 度旋转及其乘数。 这是一个示例代码,它围绕图像的中心旋转源图像-45.0度。 您也可以选择缩放输出图像。 在此示例中,我们在旋转输出图像时将其缩放为源图像大小的一半:

        Point2f center = Point(inputImage.cols/2, 
          inputImage.rows/2); 
       double angle = -45.0; 
       double scale = 0.5; 
       Mat rotMat = getRotationMatrix2D(center, angle, scale); 

       warpAffine(inputImage, 
                  outputImage, 
                  rotMat, 
                  inputImage.size(), 
                  INTER_LINEAR, 
                  BORDER_CONSTANT); 

以下是生成的输出屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D1vc5A5R-1681870063149)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/cb8f614d-97fd-43dd-b598-583d3a00faef.png)]

  • warpPerspective:此函数对于执行透视变换很有用。 与warpAffine函数相似,此函数还需要可以使用findHomography函数获得的变换矩阵。 findHomography函数可用于计算两组点之间的单应性变化。 这是一个示例代码,其中我们使用两组角点来计算单应性更改矩阵(或warpPerspective的变换矩阵),然后使用它执行透视更改。 在此示例中,我们还将外推颜色值(可选)设置为深灰色阴影:
        std::vector<Point2f> cornersA(4); 
        std::vector<Point2f> cornersB(4); 

        cornersA[0] = Point2f(0, 0); 
        cornersA[1] = Point2f(inputImage.cols, 0); 
        cornersA[2] = Point2f(inputImage.cols, inputImage.rows); 
        cornersA[3] = Point2f(0, inputImage.rows); 

        cornersB[0] = Point2f(inputImage.cols*0.25, 0); 
        cornersB[1] = Point2f(inputImage.cols * 0.90, 0); 
        cornersB[2] = Point2f(inputImage.cols, inputImage.rows); 
        cornersB[3] = Point2f(0, inputImage.rows * 0.80); 

        Mat homo = findHomography(cornersA, cornersB); 
        warpPerspective(inputImage, 
                      outputImage, 
                      homo, 
                      inputImage.size(), 
                      INTER_LANCZOS4, 
                      BORDER_CONSTANT, 
                      Scalar(50,50,50)); 

以下是生成的输出屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yAMonH6f-1681870063149)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/b1a23222-9bfc-422b-b8c0-81d979d6959f.png)]

  • remap:此函数是非常强大的几何变换函数,可用于执行从源到输出图像的像素重映射。 这意味着您可以将像素从源图像重定位到目标图像中的其他位置。 您可以模拟以前的转换和许多其他转换的相同行为,只要您创建正确的映射并将其传递给此函数即可。 这是几个示例,它们演示remap函数的功能以及使用起来的难易程度:
        Mat mapX, mapY; 
        mapX.create(inputImage.size(), CV_32FC(1)); 
        mapY.create(inputImage.size(), CV_32FC(1)); 
        for(int i=0; i<inputImage.rows; i++) 
        for(int j=0; j<inputImage.cols; j++) 
        { 
           mapX.at<float>(i,j) = j * 5; 
           mapY.at<float>(i,j) = i * 5; 
        } 

        remap(inputImage, 
         outputImage, 
         mapX, 
         mapY, 
         INTER_LANCZOS4, 
         BORDER_REPLICATE); 

从前面的代码中可以看出,除了输入和输出图像以及内插和外推参数之外,我们还需要提供映射矩阵,一个用于X方向,另一个用于Y方向。 这是从前面的代码重新映射的结果。 它只是使图像缩小了五倍(请注意,图像尺寸在remap函数中保持不变,但内容基本上被压缩为原始尺寸的五倍)。 在下面的屏幕快照中显示了该内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YuJPajP5-1681870063150)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/a08b244a-9d74-4076-8aa1-9d1f54f18deb.png)]

您可以尝试通过简单地替换两个for循环中的代码,并用不同的值填充mapXmapY矩阵来尝试多种不同的图像重映射。 以下是一些重新映射的示例:

考虑第一个示例:

    // For a vertical flip of the image 
    mapX.at<float>(i,j) = j; 
    mapY.at<float>(i,j) = inputImage.rows-i; 

考虑以下示例:

    // For a horizontal flip of the image 
    mapX.at<float>(i,j) = inputImage.cols - j; 
    mapY.at<float>(i,j) = i;

通常最好将 OpenCV 图像坐标转换为标准坐标系(笛卡尔坐标系),并以标准坐标处理XY,然后再将其转换回 OpenCV 坐标系。 原因很简单,就是我们在学校或任何几何书籍或课程中学习的坐标系都使用笛卡尔坐标系。 另一个原因是它还提供负坐标,这在处理转换时具有更大的灵活性。 这是一个例子:

    Mat mapX, mapY; 
    mapX.create(inputImage.size(), CV_32FC(1)); 
    mapY.create(inputImage.size(), CV_32FC(1)); 

    // Calculate the center point 
    Point2f center(inputImage.cols/2, 
                   inputImage.rows/2); 

    for(int i=0; i<inputImage.rows; i++) 
      for(int j=0; j<inputImage.cols; j++) 
      { 
        // get i,j in standard coordinates, thus x,y 
        double x = j - center.x; 
        double y = i - center.y; 

        // Perform a mapping for X and Y 
        x = x*x/500; 
        y = y; 

        // convert back to image coordinates 
        mapX.at<float>(i,j) = x + center.x; 
        mapY.at<float>(i,j) = y + center.y; 
      } 

      remap(inputImage, 
           outputImage, 
           mapX, 
           mapY, 
           INTER_LANCZOS4, 
           BORDER_CONSTANT); 

这是前面的代码示例中的映射操作的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gc9ZzQ20-1681870063150)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/3714125d-4f22-4365-b919-459eedc9bcd0.png)]

remap函数的另一个(也是非常重要的)用途是校正图像中的镜头失真。 您可以使用initUndistortRectifyMapinitWideAngleProjMap函数在XY方向上获取所需的映射以进行失真校正,然后将它们传递给remap函数。

您可以使用以下链接获取transform_plugin的源代码副本,该代码与Computer_Vision项目兼容,并包括您在本节中学到的转换函数。 您可以使用同一插件来测试并生成本节中看到的大多数图像。 尝试扩展插件以控制更多参数,或者尝试不同的映射操作并自己尝试不同的图像。 这是transform_plugin源代码的链接

杂项转换

杂项转换处理其他不能视为几何转换的其他任务,例如颜色空间(和格式)转换,应用颜色图,傅里叶转换等。 让我们看看它们。

颜色和色彩空间

简而言之,色彩空间是用于表示图像中像素颜色值的模型。 严格来讲,计算机视觉中的颜色由一个或多个数值组成,每个数值对应于一个通道,以 OpenCV Mat类而言。 因此,色彩空间是定义这些数值(或多个数值)如何转换为色彩的模型。 让我们以一个示例案例来更好地理解这一点。 最受欢迎的颜色空间之一(有时也称为图像格式,尤其是在 Qt 框架中)是 RGB 颜色空间,其中颜色是由红色,绿色和蓝色的组合制成的。 RGB 色彩空间已被电视,监视器,LCD 和类似的显示屏广泛使用。 另一个示例是 CMYK(或 CMYB青色,栗色,黄色,黑色))颜色空间,可以猜到它是四通道颜色空间,并且它主要用于彩色打印机。 还有许多其他色彩空间,每个色彩空间都有各自的优势和用例,但是我们将使用给定的示例,因为我们将主要关注于将不常见的色彩空间转换为更常见的色彩空间,尤其是灰度和 BGR(请注意 B 和 R 在 BGR 中交换,否则类似于 RGB)颜色空间,这是大多数处理彩色图像的 OpenCV 函数中的默认颜色空间。

正如我们刚刚提到的,在计算机视觉科学中,因此在 OpenCV 框架中,通常需要将色彩空间相互转换,因为在某些色彩空间中,通常更容易区分图像的某些属性。 同样,正如我们在前几章中已经了解的那样,我们可以使用 Qt Widget 轻松显示 BGR 图像,但是对于其他颜色空间则无法如此。

OpenCV 框架允许使用cvtColor函数在不同的色彩空间之间进行转换。 此函数仅将输入和输出图像与转换代码(在cv::ColorConversionCodes枚举中的条目)一起使用。 以下是几个示例:

    // Convert BGR to HSV color space 
    cvtColor(inputImage, outputImage, CV_BGR2HSV); 

    // Convert Grayscale to RGBA color space 
    cvtColor(inputImage, outputImage, CV_GRAY2RGBA); 

OpenCV 框架提供了一个称为applyColorMap的函数(类似于remap函数,但本质上有很大不同),该函数可用于将输入图像的颜色映射到输出图像中的其他颜色。 您只需要为它提供cv::ColormapTypes枚举的输入图像,输出图像和颜色映射类型。 这是一个简单的例子:

    applyColorMap(inputImage, outputImage, COLORMAP_JET); 

以下是上述代码的输出屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-JFFBHUFH-1681870063150)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/e0fadc02-d2c1-40df-b22c-c7fb6204a3ea.png)]

您可以使用以下链接获取color_plugin的源代码副本,该代码与Computer_Vision项目兼容,并包括在本节中学习的由适当的用户界面控制的颜色映射函数。 使用此处提供的源代码,尝试不同的颜色映射操作并自己尝试使用不同的图像。 这是color_plugin源代码的链接

图像阈值

在计算机视觉科学中,阈值化是图像分割的一种方法,其本身就是在强度,颜色或任何其他图像属性方面区分相关像素组的过程。 OpenCV 框架通常提供许多功能来处理图像分割。 但是,在本节中,您将了解 OpenCV 框架(以及计算机视觉)中两种最基本的(尽管已广泛使用)图像分割方法:thresholdadaptiveThreshold。 因此,在不浪费更多单词的情况下,它们是:

  • threshold:此函数可用于向图像应用固定级别的阈值。 尽管可以对多通道图像使用此函数,但通常在单通道(或灰度)图像上使用它来创建二进制图像,该图像具有可接受的像素和超过阈值的像素。 让我们用一个示例场景来说明这一点,您可能会遇到很多情况。 假设我们需要检测图像的最暗部分,换句话说,检测图像中的黑色。 这是我们可以使用阈值函数来仅滤除图像中像素值几乎为黑色的像素的方法:
        cvtColor(inputImage, grayScale, CV_BGR2GRAY); 
        threshold(grayScaleIn, 
                 grayScaleOut, 
                 45, 
                 255, 
                 THRESH_BINARY_INV); 
        cvtColor(grayScale, outputImage, CV_GRAY2BGR); 

在前面的代码中,首先,我们将输入图像转换为灰度颜色空间,然后应用阈值函数,然后将结果转换回 BGR 颜色空间。 这是生成的输出图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NXtCl92f-1681870063150)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/e7d445a3-2312-4e05-8849-0648a1faba9c.png)]

在前面的示例代码中,我们使用THRESH_BINARY_INV作为阈值类型参数; 但是,如果我们使用THRESH_BINARY,我们将得到结果的倒排版本。 threshold函数只是为我们提供了所有大于阈值参数的像素,在前面的示例中为40

下一个是adaptiveThreshold

  • adaptiveThreshold:可用于将自适应阈值应用于灰度图像。 根据传递给它的自适应方法(cv::AdaptiveThresholdTypes),此函数可用于分别自动计算每个像素的阈值。 但是,您仍然需要传递最大阈值,块大小(可以为 3、5、7 等),以及将从计算出的块平均值中减去的常数,可以是零。 这是一个例子:
        cvtColor(inputImage, grayScale, CV_BGR2GRAY); 
        adaptiveThreshold(grayScale, 
                          grayScale, 
                          255, 
                          ADAPTIVE_THRESH_GAUSSIAN_C, 
                          THRESH_BINARY_INV, 
                          7, 
                          0); 
        cvtColor(grayScale, outputImage, CV_GRAY2BGR); 

与之前一样,以及我们在阈值函数中所做的操作,我们将首先将图像色彩空间从 BGR 转换为灰度,然后应用自适应阈值,最后将其转换回。 这是前面的示例代码的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ggH8MbCp-1681870063150)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/5ae3bca1-cbd1-49c2-954c-49e02235a980.png)]

使用以下链接获取segmentation_plugin的源代码副本,该代码与Computer_Vision项目兼容,并包括在本节中学习的阈值函数,并由适当的用户界面控制

离散傅立叶变换

傅立叶变换可用于从时间函数中获取基本频率。 另一方面,离散傅里叶变换DFT 是一种计算采样时间函数(因此是离散的)的基础频率的方法。 那是一个纯粹的数学定义,从这个意义上来说是一个很短的定义,因此,就计算机视觉和图像处理而言,您首先需要尝试将图像(灰度图像)视为点上离散点的分布。 三维空间,其中每个离散元素的XY是图像中的像素位置,Z是像素的强度值。 如果您能够做到这一点,那么您还可以想象存在一个可以在空间中产生这些点的函数。 考虑到这种情况,傅立叶变换是将函数转换为其基础频率的方法。 如果您仍然感到迷路,请不要担心。 如果您不熟悉该概念,则绝对应该考虑在线阅读有关傅立叶变换的数学知识,或者甚至可以咨询您的数学教授。

在数学中,傅立叶分析是一种基于输入数据的傅立叶变换来获取信息的方法。 同样,为了使这种含义更具有计算机视觉意义,可以使用图像的 DFT 来导出最初在原始图像本身中不可见的信息。 视计算机视觉应用的目标领域而定,差异很大,但是我们将看到一个示例案例,以更好地理解 DFT 的使用方式。 因此,首先,您可以在 OpenCV 中使用dft函数来获取图像的 DFT。 请注意,由于图像(灰度)是 2D 矩阵,因此dft实际上将执行 2D 离散傅立叶变换,从而产生具有复数值的频率函数。 这是在 OpenCV 中对灰度(单通道)图像执行 DFT 的方法:

  1. 我们需要首先获得最佳大小来计算图像的 DFT。 在大小为 2 的幂(2、4、8、16 等)的数组上执行 DFT 变换是一个更快,更有效的过程。 对大小为2乘积的数组执行的 DFT 转换也非常有效。 因此,使用我们刚刚提到的原理的getOptimalDFTSize用于获得大于我们图像尺寸的最小尺寸,这对于执行 DFT 是最佳的。 这是完成的过程:
        int optH = getOptimalDFTSize( grayImg.rows ); 
        int optW = getOptimalDFTSize( grayImg.cols ); 
  1. 接下来,我们需要创建具有此最佳尺寸的图像,并使用零填充添加的宽度和高度中的像素。 因此,我们可以使用本章前面了解的copyMakeBorder函数:
        Mat padded; 
        copyMakeBorder(grayImg, 
                        padded, 
                        0, 
                        optH - grayImg.rows, 
                        0, 
                        optW - grayImg.cols, 
                        BORDER_CONSTANT, 
                        Scalar::all(0)); 
  1. 现在,我们在padded中拥有了最佳尺寸的图像。 我们现在需要做的是形成一个适合于馈入dft函数的两通道Mat类。 这可以使用合并函数来完成。 请注意,由于dft需要浮点Mat类,因此我们还需要将最佳尺寸的图像转换为带有浮点元素的Mat类,如下所示:
         Mat channels[] = {Mat_<float>(padded), 
                      Mat::zeros(padded.size(), 
                      CV_32F)}; 
        Mat complex; 
        merge(channels, 2, complex); 
  1. 一切准备就绪即可执行离散傅立叶变换,因此我们将其简称为此处所示。 结果也存储在complex中,这将是一个复杂值Mat类:
        dft(complex, complex); 
  1. 现在,我们需要将复杂的结果分为真实和复杂的部分。 为此,我们可以再次使用channels数组,如下所示:
        split(complex, channels); 
  1. 现在,我们需要使用magnitude函数将复杂结果转换为其大小; 经过更多的转换之后,这将是适合于显示目的的结果。 由于channels现在包含复杂结果的两个通道,因此我们可以在magnitude函数中使用它,如下所示:
        Mat mag; 
        magnitude(channels[0], channels[1], mag); 
  1. magnitude函数的结果(如果尝试查看元素)将非常大,以至于无法使用灰度图像的可能比例进行可视化。 因此,我们将使用以下代码行将其转换为更小的对数刻度:
        mag += Scalar::all(1); 
        log(mag, mag); 
  1. 由于我们使用最佳大小计算了 DFT,因此如果行或列的数量为奇数,我们现在需要裁剪结果。 使用以下代码片段可以轻松完成此操作。 请注意,使用-2的按位and操作用于删除正整数中的最后一位,并使其成为偶数,或者基本上是创建带有额外像素的padded图像时所做的操作的反面:
        mag = mag(Rect( 
                       0, 
                       0, 
                       mag.cols & -2, 
                       mag.rows & -2));
  1. 由于结果是一个频谱,显示了由 DFT 获得的频率函数所产生的波,因此我们应将结果的原点移至其中心,该中心当前位于左上角。 我们可以使用以下代码为结果的四分之四创建四个 ROI,然后将结果左上角的四分之一与右下角的四分之一交换,也将结果右上角的四分之一与左下角的四分之一交换:
        int cx = mag.cols/2; 
        int cy = mag.rows/2; 

        Mat q0(mag, Rect(0, 0, cx, cy));   // Top-Left 
        Mat q1(mag, Rect(cx, 0, cx, cy));  // Top-Right 
        Mat q2(mag, Rect(0, cy, cx, cy));  // Bottom-Left 
        Mat q3(mag, Rect(cx, cy, cx, cy)); // Bottom-Right 

        Mat tmp; 
        q0.copyTo(tmp); 
        q3.copyTo(q0); 
        tmp.copyTo(q3); 

        q1.copyTo(tmp); 
        q2.copyTo(q1); 
        tmp.copyTo(q2); 
  1. 除非我们使用normalize函数将结果缩放到正确的灰度范围(0255),否则尝试将结果可视化仍然是不可能的,如下所示:
        normalize(mag, mag, 0, 255, CV_MINMAX); 
  1. 使用 OpenCV 中的imshow函数,我们已经可以查看结果了,但是为了能够在 Qt 小部件中查看结果,我们需要将其转换为正确的深度(8 位)和多个通道,因此我们需要以下内容作为最后一步:
        Mat_<uchar> mag8bit(mag); 
        cvtColor(mag8bit, outputImage, CV_GRAY2BGR); 

现在,您可以尝试在我们的测试图像上运行它。 结果将如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vsit4O03-1681870063151)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/4d852168-a6aa-4b45-8920-7dd35ca5b7d9.png)]

您在结果中看到的结果应解释为从上方直接观看的波,其中每个像素的亮度实际上是其高度的表示。 尝试在不同种类的每个图像上运行相同的过程,以查看结果如何变化。 除了通过外观检查 DFT 结果(取决于用例)之外,DFT 的一个非常特殊的用例(我们将留给您自己尝试)是在掩盖 DFT 结果的一部分后执行反向 DFT, 以获取原始图像。 此过程可以通过多种方式更改原始图像,具体取决于已过滤 DFT 结果的一部分。 这个主题在很大程度上取决于原始图像的内容,并且与 DFT 的数学特性有着深厚的联系,但是绝对值得研究和试验。 总之,您可以通过调用相同的dft函数并将附加的DCT_INVERSE参数传递给它来执行逆 DFT。 显然,这次,输入应该是图像的计算出的 DFT,输出将是图像本身。

参考:OpenCV 文档,离散傅里叶变换。

OpenCV 中的绘图

通常,当主题是 OpenCV 和计算机视觉时,就不能忽略在图像上绘制文本和形状。 由于无数原因,您将需要在输出图像上绘制(输出)一些文本或形状。 例如,您可能想编写一个在其上打印图像日期的程序。 或者,您可能需要在执行面部检测后在图像中的面部周围绘制一个正方形。 即使 Qt 框架也提供了处理这些任务的强大功能,也可以使用 OpenCV 本身来绘制图像。 在本节中,您将学习到使用 OpenCV 绘图函数的方法,它们令人惊讶的非常容易使用,以及示例代码和输出结果。

可以理解,OpenCV 中的绘图函数接受输入和输出图像,以及一些大多数参数共有的参数。 以下是 OpenCV 中提到的图形函数的常用参数,以及它们的含义和可能的值:

  • color:此参数只是在图像上绘制的对象的颜色。 它可以使用标量创建,并且必须采用 BGR 格式(用于彩色图像),因为它是大多数 OpenCV 函数的默认颜色格式。
  • thickness:此参数默认设置为1,是在图像上绘制的对象轮廓的粗细。 此参数以像素为单位指定。
  • lineType:这可以是cv::LineTypes枚举中的条目之一,它决定在图像上绘制的对象轮廓的细节。 如下图所示,LINE_AA(抗锯齿)较平滑,但绘制速度也比LINE_4LINE_8(默认为lineType)慢。 下图描述了cv::LineTypes之间的区别:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QuVgOCsG-1681870063151)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/8c169885-aabf-43ea-8f98-8a5d9c5dee37.png)]

  • shift:仅在提供给绘图函数的点和位置包括小数位的情况下使用此参数。 在这种情况下,首先使用以下转换函数根据移位参数对每个点的值进行移位。 对于标准整数点值,移位值将为零,这也使以下转换对结果没有影响:
        Point(X , Y) = Point( X * pow(2,-shift), Y * pow(2,-shift) ) 

现在,让我们从实际的绘图函数开始:

  • line:可以通过获取线条的起点和终点来在图像上画一条线条。 以下示例代码在图像上绘制了一个X标记(两条线连接该图像的角),其厚度为3像素,并带有红色:
        cv::line(img, 
                 Point(0,0), 
                 Point(img.cols-1,img.rows-1), 
                 Scalar(0,0,255), 
                 3, 
                 LINE_AA); 

        cv::line(img, 
                Point(img.cols-1,0), 
                Point(0, img.rows-1), 
                Scalar(0,0,255), 
                3, 
                LINE_AA); 
  • arrowedLine:用于绘制箭头线。 箭头的方向由终点(或第二个点)决定,否则,此函数的用法与line相同。 这是一个示例代码,用于从顶部到图像中心绘制一条箭头线:
        cv::arrowedLine(img, 
                        Point(img.cols/2, 0), 
                        Point(img.cols/2, img.rows/3), 
                        Scalar(255,255,255), 
                        5, 
                        LINE_AA);
  • rectangle:可用于在图像上绘制矩形。 您可以向其传递一个矩形(Rect类)或两个点(Point类),第一个点对应于矩形的左上角,第二个点对应于矩形的右下角。 以下是在图像中心绘制的矩形示例:
        cv::rectangle(img, 
                      Point(img.cols/4, img.rows/4), 
                      Point(img.cols/4*3, img.rows/4*3), 
                      Scalar(255,0,0), 
                      10, 
                      LINE_AA); 
  • putText:此函数可用于在图像上绘制(或书写或放置)文本。 除了 OpenCV 绘图函数中的常规绘图参数外,您还需要为该函数提供需要在图像上绘制的文本以及字体和比例尺参数。 字体可以是cv::HersheyFonts枚举中的条目之一,而尺度是与字体有关的字体缩放。 以下代码块的示例可用于在图像中写入Computer Vision
        cv::putText(img, 
                    "Computer Vision", 
                    Point(0, img.rows/2), 
                    FONT_HERSHEY_PLAIN, 
                    2, 
                    Scalar(255,255,255), 
                    2, 
                    LINE_AA); 

以下屏幕截图是在测试图像上按顺序执行时在本节中看到的所有绘图示例的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-M9EDyM6m-1681870063151)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/a60f4373-4120-4653-9c2c-4eee47bca602.png)]

除了我们在本节中看到的绘图函数外,OpenCV 还提供了绘制圆,折线,椭圆等的函数。 所有这些都以与本章所述完全相似的方式使用。 尝试使用这些函数来熟悉 OpenCV 中的所有绘图函数。 您始终可以通过参考 OpenCV 的文档获取最新的绘图函数列表,可以从 OpenCV 网站的首页轻松访问。

模板匹配

OpenCV 框架提供了许多不同的方法来进行对象检测,跟踪和计数。 模板匹配是 OpenCV 中对象检测的最基本方法之一,但是,如果正确使用它并与良好的阈值结合使用,它可以用于有效检测和计数图像中的对象。 通过在 OpenCV 中使用一个称为matchTemplate函数的函数来完成此操作。

matchTemplate函数将图像作为输入参数。 考虑将要搜索的图像作为我们感兴趣的对象(或者更好的是可能包含模板的场景)。它也将模板作为第二个参数。 该模板也是一幅图像,但是它是将在第一个图像参数中搜索的模板。 此函数所需的另一个参数(也是最重要的参数和决定模板匹配方法的一个参数)是method参数,它可以是cv::TemplateMatchModes枚举中的条目之一:

  • TM_SQDIFF
  • TM_SQDIFF_NORMED
  • TM_CCORR
  • TM_CCORR_NORMED
  • TM_CCOEFF
  • TM_CCOEFF_NORMED

如果您有兴趣,可以访问matchTemplate文档页面,以了解上述每种方法的数学计算,但是,实际上,您可以通过了解matchTemplate的一般工作原理,来了解每种方法的执行方式。

matchTemplate函数使用method参数中指定的方法,将大小为WxH的模板滑动到大小为QxS的图像上,并将模板与图像的所有重叠部分进行比较,然后存储 result Mat中的比较。 显然,图像大小(QxS)必须大于模板大小(WxH)。 重要的是要注意,所得的Mat大小实际上是Q-WxS-H,即图像高度和宽度减去模板高度和宽度。 这是由于以下事实:模板的滑动仅发生在源图像上,甚至不发生在其外部的单个像素上。

如果使用名称中带有_NORMED的方法之一进行模板匹配,则在模板匹配函数之后无需进行标准化,因为结果将在01之间; 否则,我们将需要使用normalize函数对结果进行归一化。 将结果归一化后,可以使用minMaxLoc函数在结果图像中定位全局最小值(图像中的最暗点)和全局最大值(图像中的最亮点)。 请记住,result Mat类包含模板和图像重叠部分之间的比较结果。 这意味着,根据所使用的模板匹配方法,result Mat类中的全局最小值或全局最大值位置实际上是最佳模板匹配。 因此,是我们检测结果的最佳候选者。 假设我们想将左侧屏幕上的图像与以下屏幕截图右侧屏幕上的图像匹配:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Zpy1ZZ2m-1681870063151)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/77706a37-a184-406e-8d6d-fc1fe83fe1f9.png)]

因此,我们可以使用matchTemplate函数。 这是一个示例案例:

    matchTemplate(img, templ, result, TM_CCORR_NORMED); 

在前面的函数调用中,img装载有图像本身(右侧的图像),templ装载有模板图像(左侧),TM_CCORR_NORMED被用作模板匹配方法。 如果我们在前面的代码中可视化result Mat(为简单起见,使用imshow函数),我们将得到以下输出。 注意结果图像中的最亮点:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-F5irjNql-1681870063152)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/95ae1e7e-ef87-449a-a58c-a20ac714fe60.png)]

这是模板匹配的最佳位置。 我们可以使用minMaxLoc函数找到该位置,并通过使用您在本章先前了解的绘图函数在其周围绘制一个矩形(与模板大小相同)。 这是一个例子:

    double minVal, maxVal; 
    Point minLoc, maxLoc; 
    minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc); 
    rectangle(img, 
              Rect(maxLoc.x, maxLoc.y, templ.cols, templ.rows), 
              Scalar(255,255,255), 
              2); 

通过可视化img,我们将获得以下屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-D0NU7OsS-1681870063152)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/8e4de7dc-e357-410d-83b3-81c1a4c83ffb.png)]

值得注意的是matchTemplate函数不是比例不变的。 这意味着它将无法匹配图像内各种尺寸的模板,而只能匹配给该函数的模板相同的尺寸。 matchTemplate函数的另一个用例是计算图像中的对象数量。 为此,您需要确保在循环内运行matchTemplate函数,并在每次成功匹配后从源图像中删除匹配的部分,以便在下一个matchTemplate调用中找不到该部分。 尝试自己编写代码,作为一个很好的示例案例,以了解有关模板匹配及其如何用于模板计数的更多信息。

模板计数是一种广泛使用的方法,用于对生产线或平坦表面中的对象(或产品)进行计数,或对显微图像中形状和大小相似的单元进行计数,以及无数其他类似的用例和应用。

总结

现在,我们熟悉 OpenCV 框架中一些使用最广泛的函数,枚举和类。 您在本章中学到的大多数技能几乎都以一种或另一种方式用于每种计算机视觉应用中。 从图像过滤(这是计算机视觉过程中最初始的步骤之一)开始,直到图像转换方法和色彩空间转换,每个计算机视觉应用都必须有权使用这些方法,才能执行特定任务,或以某种方式优化其性能。 在本章中,您学习了有关图像过滤和几何变换的所有知识。 您学习了如何使用remap之类的函数来执行无数的图像转换。 您还了解了色彩空间以及如何将它们相互转换。 后来,我们甚至使用颜色映射函数将图像中的颜色映射到另一组颜色。 然后,您学习了图像阈值处理以及如何提取具有特定像素值的图像部分。 正如您将在整个职业生涯或计算机视觉研究中看到的那样,由于无数种原因,阈值化是无时无刻都需要和使用的。 设定阈值后,您了解了 OpenCV 框架中的绘图函数。 如前所述,Qt 框架还提供了大量接口来处理绘图任务,但是仍然不可避免地,有时我们可能需要将 OpenCV 本身用于绘图任务。 最后,我们通过学习模板匹配及其用法来完成本章。

在第 7 章,“特征和描述符”中,我们将通过学习关键点和特征描述符以及它们如何用于对象来更深入地研究计算机视觉和 OpenCV 框架。 检测和匹配。 您还将了解许多关键概念,例如直方图。 您将了解什么是直方图以及通常如何提取和使用它们。 在第 7 章,“特征和描述符”中,我们还将作为刚刚完成的那一章的补充章,在该章中,我们将使用本章中学习到的大多数技能,以及与图像特征和关键点相关的新技能,以执行图像中更复杂的匹配,比较和检测任务。

七、特征和描述符

在第 6 章,“OpenCV 中的图像处理”中,我们主要从图像内容和像素方面了解了图像处理。 我们学习了如何对它们进行过滤,转换或以一种或另一种方式处理像素值。 甚至为了匹配模板,我们仅使用原始像素内容来获取结果,并找出图像中是否存在对象。 但是,我们仍未了解使我们能够区分不同种类的对象的算法,这些算法不仅基于原始像素,而且还基于图像的特定特征来区分图像的总体含义。 识别和识别不同类型的人脸,汽车,文字以及几乎任何可见和视觉的对象,这对他们来说几乎是一件微不足道的任务,因为它们并不十分相似。 对于我们人类来说,这种情况在大多数情况下都是在我们根本没有考虑的情况下发生的。 我们甚至可以根据大脑几乎自动拾取的微小且独特的碎片来区分非常相似的人脸,并在再次看到这些人脸时再次使用它们来识别人脸。 或者,以不同汽车品牌为例。 大多数主要汽车制造商的徽标几乎都被我们的大脑所窃取。 我们的大脑很容易地使用该徽标来区分汽车模型(或制造商)。 简而言之,一直以来,我们在观察周围环境及其周围的一切时,我们的大脑在我们的眼睛的帮助下,在任何视觉对象中搜索可区分的部分(显然,在这种情况下,对象可以是任何东西) ,然后使用这些片段来识别相同或相似的视觉对象。 当然,即使是人的大脑和眼睛,也总是有出错的机会,而且事实是,我们可能会简单地忘记特定物体(或面部)的外观。

我们在引言段落中刚刚描述的内容也是创建许多用于相同目的的计算机视觉算法的基础。 在本章中,您将学习 OpenCV 框架中一些最重要的类和方法,这使我们能够在图像(或图像中的对象)中找到称为特征(或关键点)的可区分片段。 然后,我们将继续学习描述符,顾名思义,这些描述符是对找到的特征的描述。 因此,我们将学习如何检测图像中的特征,然后从特征中提取描述符。 这些描述符然后可以在计算机视觉应用中用于许多目的,包括两个图像的比较,单应性变化检测,在图像内部定位已知对象等等。 重要的是要注意,保存,处理图像的特征和描述符并基本上执行任何操作通常比尝试使用图像本身更快,更容易,因为特征和描述符只是一堆数值,尝试以一种或另一种方式描述图像,具体取决于用于检测特征和提取描述符的算法。

您可以从到目前为止所看到的内容中轻松猜出,尤其是在前几章的过程中,OpenCV 和 Qt 框架都是大量的工具,类,函数等等的集合,它们使您能够创建强大的计算机视觉应用或任何其他类型的应用。 因此,可以肯定地说的是,在一本书中涵盖所有这些框架都是不可能的,也是徒劳的。 相反,由于这两个框架都是以高度结构化的方式创建的,因此,只要我们对底层类的层次结构有清晰的了解,我们仍然可以了解我们第一次看到和使用的这些框架中的类或函数。 对于用于检测特征和提取描述符的类和方法,这几乎是完全正确的。 这就是为什么在本章中,我们将首先研究 OpenCV 中用于特征检测和描述符提取的类的层次结构,然后再深入探讨如何在实践中使用它们。

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

  • OpenCV 中的算法是什么?
  • 如何使用现有的 OpenCV 算法
  • 使用FeatureDetector类检测特征(或关键点)
  • 使用DescriptorExtractor类提取描述符
  • 如何匹配描述符并将其用于检测
  • 如何得出描述符匹配的结果
  • 如何为我们的用例选择算法

所有算法的基础 – Algorithm

OpenCV 中的所有算法或更好的算法,至少不是不太简短的算法,都被创建为cv::Algorithm类的子类。 与通常所期望的相反,该类不是抽象类,这意味着您可以创建它的实例,而该实例只是不执行任何操作。 即使将来可能会更改它,也不会真正影响我们访问和使用它的方式。 在 OpenCV 中使用cv::Algorithm类的方式,以及如果要创建自己的算法的推荐方法,是首先创建cv::Algorithm的子类,其中包含用于特定目的或目标的所有必需成员函数。 。 然后,可以再次对该新创建的子类进行子类化,以创建同一算法的不同实现。 为了更好地理解这一点,让我们首先详细了解cv::Algorithm类。 大致了解一下 OpenCV 源代码的外观:

    class Algorithm 
    { 
      public: 
      Algorithm(); 
      virtual ~Algorithm(); 
      virtual void clear(); 
      virtual void write(FileStorage& fs) const; 
      virtual void read(const FileNode& fn); 
      virtual bool empty() const; 
      template<typename _Tp> 
        static Ptr<_Tp> read(const FileNode& fn); 
      template<typename _Tp> 
        static Ptr<_Tp> load(const String& filename, 
            const String& objname=String()); 
      template<typename _Tp> 
        static Ptr<_Tp> loadFromString(const String& strModel, 
            const String& objname=String()); 
      virtual void save(const String& filename) const; 
      virtual String getDefaultName() const; 
      protected: 
      void writeFormat(FileStorage& fs) const; 
    }; 

首先,让我们看看cv::Algorithm类中使用的FileStorageFileNode类是什么(以及许多其他 OpenCV 类),然后介绍cv::Algorithm类中的方法:

  • FileStorage类可用于轻松地读写 XML,YAML 和 JSON 文件。 此类在 OpenCV 中广泛使用,以存储许多算法产生或需要的各种类型的信息。 此类几乎与任何其他文件读取器/写入器类一样工作,不同之处在于它可以与所提到的文件类型一起工作。
  • FileNode类本身是Node类的子类,用于表示FileStorage类中的单个元素。 FileNode类可以是FileNode元素集合中的单个叶子,也可以是其他FileNode元素的容器。

除了上一个列表中提到的两个类之外,OpenCV 还具有另一个名为FileNodeIterator的类,顾名思义,该类可用于遍历 STL 中的节点,例如循环。 让我们看一个小的示例,该示例描述在实践中如何使用上述类:

    using namespace cv; 
    String str = "a random note"; 
    double d = 999.001; 
    Matx33d mat = {1,2,3,4,5,6,7,8,9}; 
    FileStorage fs; 
    fs.open("c:/dev/test.json", 
        FileStorage::WRITE | FileStorage::FORMAT_JSON); 
    fs.write("matvalue", mat); 
    fs.write("doublevalue", d); 
    fs.write("strvalue", str); 
    fs.release(); 

OpenCV 中的此类代码将导致创建 JSON 文件,如下所示:

    { 
      "matvalue": { 
        "type_id": "opencv-matrix", 
        "rows": 3, 
        "cols": 3, 
        "dt": "d", 
        "data": [ 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0 ] 
      }, 
      "doublevalue": 9.9900099999999998e+02, 
      "strvalue": "a random note" 
    } 

如您所见,FileStorage类几乎可以确保 JSON 文件的结构正确无误,并且以一种以后可以轻松检索的方式存储一切。 通常最好使用isOpened函数检查打开文件是否成功,为简单起见,我们跳过了该函数。 整个过程称为将类或数据结构序列化。 现在,要读回它,我们可以执行以下操作:

    using namespace cv; 
    FileStorage fs; 
    fs.open("c:/dev/test.json", 
        FileStorage::READ | FileStorage::FORMAT_JSON); 
    FileNode sn = fs["strvalue"]; 
    FileNode dn = fs["doublevalue"]; 
    FileNode mn = fs["matvalue"]; 
    String str = sn; 
    Matx33d mat = mn; 
    double d = dn; 
    fs.release(); 

为了便于阅读,并演示FileStorage类实际上是在读取并创建FileNode类的实例,我们将每个值都分配给FileNode,然后再分配给变量本身,但是,很显然,您已经可以将读取节点的结果直接分配给适当类型的变量。 这两个类提供的功能远远超出此范围,它们绝对值得您亲自检查一下,但这对我们来说足够了,目的是解释cv::Algorithm类如何使用它们。 因此,现在我们了解到可以使用这些类轻松地存储和检索不同类型的类,甚至包括 OpenCV 特定的类型,我们可以更深入地研究cv::Algorithm本身。

如您先前所见,cv::Algorithm类在其声明以及其实现中都使用上述类来存储和检索算法的状态,即算法的基础参数,输入或输出值,等等。 为此,它提供了我们将简要介绍的方法。

现在,不必担心它们的详细用法,因为实际上它们是在子类中重新实现的,并且它们大多数的工作方式实际上取决于实现它的特定算法; 因此,我们仅关注 OpenCV 的结构及其组织方式。

这是cv::Algorithm类提供的方法:

  • read:这里有一些重载版本,可用于读取算法状态。
  • write:它类似于read,用于保存算法状态。
  • clear:可用于清除算法状态。
  • empty:可用于确定算法的状态是否为空。 例如,这意味着它是否正确加载(读取)。
  • load:与read几乎相同。
  • loadFromString:它与loadread非常相似,只是它从字符串读取并加载算法的状态。

看一下 OpenCV 网站上的cv::Algorithm文档页面(尤其是它的继承图),您会立即注意到 OpenCV 中大量实现它的类。 您可以猜测它们都具有前面提到的功能。 除此之外,它们中的每一个都提供特定于它们中每一个的方法和功能。 在重新实现cv::Algorithm的许多类中,有一个名为Feature2D的类,该类基本上是本章将要学习的类,它负责 OpenCV 中存在的所有特征检测和描述符提取算法。 此类及其子类在 OpenCV 中被称为 2D 特征框架(将其视为 OpenCV 框架的子框架),这是本章下一节的主题。

2D 特征框架

正如本章前面提到的,OpenCV 提供了类来执行由世界各地的计算机视觉研究人员创建的各种特征检测和描述符提取算法。 与 OpenCV 中实现的任何其他复杂算法一样,特征检测器和描述符提取器也通过将cv::Algorithm类子类化而创建。 该子类称为Feature2D,它包含所有特征检测和描述符提取类共有的各种函数。 基本上,任何可用于检测特征和提取描述符的类都应该是Featured2D的子类。 为此,OpenCV 使用以下两种类类型:

  • FeatureDetector
  • DescriptorExtractor

重要的是要注意,实际上这两个类都是Feature2D的不同名称,因为它们是使用以下typedef语句在 OpenCV 中创建的(我们将在本节后面讨论其原因) ):

    typedef Feature2D FeatureDetector; 
    typedef Feature2D DescriptorExtractor; 

看到Feature2D类的声明也是一个好主意:

    class Feature2D : public virtual Algorithm 
    { 
      public: 
      virtual ~Feature2D(); 
      virtual void detect(InputArray image, 
        std::vector<KeyPoint>& keypoints, 
        InputArray mask=noArray() ); 
      virtual void detect(InputArrayOfArrays images, 
        std::vector<std::vector<KeyPoint> >& keypoints, 
        InputArrayOfArrays masks=noArray() ); 
        virtual void compute(InputArray image, 
          std::vector<KeyPoint>& keypoints, 
        OutputArray descriptors ); 
        virtual void compute( InputArrayOfArrays images, 
          std::vector<std::vector<KeyPoint> >& keypoints, 
          OutputArrayOfArrays descriptors ); 
        virtual void detectAndCompute(InputArray image, 
          InputArray mask, 
          std::vector<KeyPoint>& keypoints, 
          OutputArray descriptors, 
          bool useProvidedKeypoints=false ); 
          virtual int descriptorSize() const; 
          virtual int descriptorType() const; 
          virtual int defaultNorm() const;     
          void write( const String& fileName ) const; 
          void read( const String& fileName ); 
          virtual void write( FileStorage&) const; 
          virtual void read( const FileNode&); 
          virtual bool empty() const; 
    }; 

让我们快速回顾一下Feature2D类的声明中的内容。 首先,它是cv::Algorithm的子类,正如我们之前所学。 读,写和空函数只是cv::Algorithm中存在的简单重新实现的函数。 但是,以下函数是cv::Algorithm中的新增函数,不存在,它们基本上是特征检测器和描述符提取器所需的其他函数:

  • detect函数可用于从一个图像或一组图像中检测特征(或关键点)。
  • compute函数可用于从关键点提取(或计算)描述符。
  • detectAndCompute函数可用于执行单个特征的检测和计算。
  • descriptorSizedescriptorTypedefaultNorm是算法相关的值,它们在每个能够提取描述符的Feature2D子类中重新实现。

看起来似乎很奇怪,但是有充分的理由以这种方式对特征检测器和描述符进行分类,并且只有一个类,这是因为某些算法(并非全部)都提供了特征检测和描述符提取函数。 随着我们继续为此目的创建许多算法,这将变得更加清晰。 因此,让我们从 OpenCV 2D 特征框架中现有的Feature2D类和算法开始。

检测特征

OpenCV 提供了许多类来处理图像中的特征(关键点)检测。 每个类都有自己的实现,具体取决于它实现的特定算法,并且可能需要一组不同的参数才能正确执行或具有最佳表现。 但是,它们所有的共同点就是前面提到的detect函数(因为它们都是Feature2D的子类),可用于检测图像中的一组关键点。 OpenCV 中的关键点或特征是KeyPoint类实例,其中包含为正确的关键点需要存储的大多数信息(这些术语,即关键点和特征,可以互换使用,并且很多,因此,请尝试习惯它)。 以下是KeyPoint类的成员及其定义:

  • pt或简单地指向:这包含关键点(XY)在图像中的位置。
  • angle:这是指关键点的顺时针旋转(0 到 360 度),即,检测到关键点的算法是否能够找到它; 否则,它将被设置为-1
  • response:这是关键点的强度,可用于排序或过滤弱关键点,依此类推。
  • size:这是指指定可用于进一步处理的关键点邻域的直径。
  • octave:这是图像的八度(或金字塔音阶),从中可以检测到该特定关键点。 这是一个非常强大且实用的概念,在检测关键点或使用它们进一步检测图像上可能具有不同大小的对象时,已广泛用于实现与比例尺(比例尺不变)的独立性。 为此,可以使用相同的算法处理同一图像的不同缩放版本(仅较小版本)。 每个刻度基本上称为octave或金字塔中的一个等级。

为了方便起见,KeyPoint类提供了其他成员和方法,以便您可以自己检查一下,但是为了进一步使用它们,我们肯定经历了我们需要熟悉的所有重要属性。 现在,让我们看一下现有的 OpenCV 特征检测器类的列表,以及它们的简要说明以及如何使用它们的示例:

  • 可以使用AgastFeatureDetector(包括 AGAST自适应和通用加速分段测试)算法的实现)来检测图像中的角。 它需要三个参数(可以省略所有参数以使用默认值)来配置其行为。 这是一个例子:
        Ptr<AgastFeatureDetector> agast = AgastFeatureDetector::create(); 
        vector<KeyPoint> keypoints; 
        agast->detect(inputImage, keypoints); 

如此简单,我们仅将AgastFeatureDetector与默认参数集一起使用。 在深入研究上述操作的结果之前,让我们首先看一下代码本身,因为其中使用了 OpenCV 中最重要和最实用的类之一(称为Ptr)。 如前面的代码所示,我们使用了Ptr类,它是 OpenCV 共享指针(也称为智能指针)的实现。 使用智能指针的优点之一是,您不必担心在使用完该类后释放为该类分配的内存。 另一个优点以及被称为共享指针的原因是,多个Ptr类可以使用(共享)单个指针,并且该指针(分配的内存)仅保留到Ptr指向的最后一个实例被摧毁为止。 在复杂的代码中,这可能意味着极大的简化。

接下来,请务必注意,您需要使用静态create函数来创建AgastFeatureDetector类的共享指针实例。 您将无法创建此类的实例,因为它是抽象类。 其余代码并不是新内容。 我们只需创建KeyPointstd::vector,然后使用 AGAST 的基础算法检测输入Mat图像中的关键点。

编写相同代码的另一种方法(也许是更灵活的方法)是使用多态和Feature2D类。 因为AgastFeatureDetector实际上是Feature2D的子类,所以我们可以编写相同的代码,如下所示:

    Ptr<Feature2D> fd = AgastFeatureDetector::create(); 
    vector<KeyPoint> keypoints; 
    fd->detect(inputImage, keypoints); 

当然,只有在我们希望在不同的特征检测算法之间切换而不创建和传递许多类的许多实例的情况下,这才证明是有用的。 这是一个示例,其中根据alg的值(可以是我们定义的枚举的条目,并且包括可能的算法的名称),可以使用 AGAST 或 AKAZE 算法来检测关键点 (我们将在本章后面看到):

    Ptr<Feature2D> fd; 
    switch(alg) 
    { 
      case AGAST_ALG: 
      fd = AgastFeatureDetector::create(); 
      break; 

      case AKAZE_ALG: 
       fd = AKAZE::create(); 
       break; 
    } 
    vector<KeyPoint> keypoints; 
    fd->detect(inputImage, keypoints); 

在讨论 AGAST 算法的参数之前,还有一个提示,即可以通过迭代检测到的关键点和绘制点(实际上是圆圈,但是它们与点一样小)来绘制检测到的关键点,如此处所示 :

    inputImage.copyTo(outputImage); 
    foreach(KeyPoint kp, keypoints) 
    circle(outputImage, kp.pt, 1, Scalar(0,0,255), 2); 

或者,甚至更好的是,使用 OpenCV 2D 特征框架中专用于此目的的drawKeypoints函数。 它的优点是您无需将图像复制到输出图像,并且还可以确保对关键点进行着色以使其更加可区分。 这是一个例子; 实际上,这是使用 OpenCV 中的 AGAST 算法检测和绘制关键点的完整代码:

    Ptr<AgastFeatureDetector> agast = AgastFeatureDetector::create(); 
    vector<KeyPoint> keypoints; 
    agast->detect(inputImage, keypoints); 
    drawKeypoints(inputImage, keypoints, outputImage); 

我们将在示例中使用简单且非多态的方法,但是,如本章前面所述,使用多态并在适用于不同情况的不同算法之间进行切换始终更为实用。

假设左侧的图像是我们的原始测试图像,执行前面的代码将在右侧产生结果图像,如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sCnqxyN4-1681870063152)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/8749441e-4ab0-46e9-a14b-759441788c69.png)]

这是结果图像的局部放大图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-stzbbq9a-1681870063152)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/f6734d80-de0c-4300-b511-10e29fe41f09.png)]

如您所见,所有检测到的关键点都绘制在结果图像上。 同样,在运行任何特征检测功能之前运行某种模糊过滤器(如果图像太清晰)总是更好的选择。 这有助于减少不必要的(和不正确的)关键点。 其原因是,在清晰的图像中,即使是图像的最细微的点点也可以检测为边缘或拐​​角点。

在前面的示例中,我们仅使用了默认参数(省略了默认参数),但是为了更好地控制 AGAST 算法的行为,我们需要注意以下参数:

  • 默认情况下设置为10threshold值用于基于像素与围绕它的圆上的像素之间的强度差传递特征。 阈值越高意味着检测到的特征数量越少,反之亦然。
  • NonmaxSuppression可用于对检测到的关键点应用非最大抑制。 默认情况下,此参数设置为true,可用于进一步过滤掉不需要的关键点。
  • 可以将type参数设置为以下值之一,并确定 AGAST 算法的类型:
    • AGAST_5_8
    • AGAST_7_12d
    • AGAST_7_12s
    • OAST_9_16(默认值)

您可以使用适当的 Qt 小部件从用户界面获取参数值。 这是 AGAST 算法的示例用户界面以及其底层代码。 另外,您可以下载本节末尾提供的完整keypoint_plugin源代码,其中包含该源代码以及以下特征检测示例,它们全部集成在一个插件中,与我们全面的computer_vision项目兼容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Qh5ygQeI-1681870063152)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/d85b48e5-2782-44f2-ac7d-209179b04cdb.png)]

请注意,当我们更改阈值并选择其他类型的 AGAST 算法时,检测到的关键点数量会发生变化。 在以下示例代码中,agastThreshSpin是旋转框小部件的objectNameagastNonmaxCheck是复选框的objectName,并且agastTypeCombo是用于选择类型的组合框的objectName

    Ptr<AgastFeatureDetector> agast =  
       AgastFeatureDetector::create(); 
    vector<KeyPoint> keypoints; 
    agast->setThreshold(ui->agastThreshSpin->value()); 
    agast->setNonmaxSuppression(ui->agastNonmaxCheck->isChecked()); 
    agast->setType(ui->agastTypeCombo->currentIndex()); 
    agast->detect(inputImage, 
                  keypoints); 
    drawKeypoints(inputImage, 
                  keypoints, 
                  outputImage); 

OpenCV 提供了一种便捷函数,可用于在不使用AgastFeatureDetector类的情况下直接在灰度图像上调用 AGAST 算法。 此函数称为AGAST(如果考虑名称空间,则称为cv::AGAST),并且通过使用它,我们可以编写相同的代码,如下所示:

    vector<KeyPoint> keypoints; 
    AGAST(inputImage, 
          keypoints, 
          ui->agastThreshSpin->value(), 
          ui->agastNonmaxCheck->isChecked(), 
          ui->agastTypeCombo->currentIndex()); 
    drawKeypoints(inputImage, 
                  keypoints, 
                  outputImage); 

在本节中看到的算法以及在 OpenCV 中实现的几乎所有其他算法,通常都是基于研究研究和来自世界各地的已发表论文。 值得一看的是每种算法的相关论文,以清楚地了解其基本实现方式以及参数的确切效果以及如何有效使用它们。 因此,在每个示例的末尾,并且在研究了每种算法之后,如果您有兴趣,还将与您共享其参考文献(如果有)以供进一步研究。 第一个用于 AGAST 算法,出现在此信息框之后。

参考:Elmar Mair, Gregory D. Hager, Darius Burschka, Michael Suppa, and Gerhard Hirzinger. Adaptive and generic corner detection based on the accelerated segment test. In European Conference on Computer Vision (ECCV'10), September 2010.

让我们继续我们的特征检测算法列表。

KAZE 和 AKAZE

KAZEAKAZE加速 KAZE)类可用于使用 KAZE 算法(其加速版本)检测特征。 有关 KAZE 和 AKAZE 算法的详细信息,请参考以下参考文献列表中提到的文件。 类似于我们在 AGAST 中看到的那样,我们可以使用默认参数集并简单地调用detect函数,或者我们可以使用适当的 Qt 小部件获取所需的参数并进一步控制算法的行为。 这是一个例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VTbsSesl-1681870063153)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/abf57665-8afc-459f-8087-cd00c0bb20af.png)]

AKAZE 和 KAZE 中的主要参数如下:

  • nOctaves或八度的数量(默认为 4)可用于定义图像的最大八度。
  • nOctaveLayers或八度级别的数量(默认为 4)是每个八度(或每个比例级别)的子级别数。
  • 扩散率可以采用下列项之一,它是 KAZE 和 AKAZE 算法使用的非线性扩散方法(如稍后在此算法的参考文献中所述):
    • DIFF_PM_G1
    • DIFF_PM_G2
    • DIFF_WEICKERT
    • DIFF_CHARBONNIER
  • 阈值是接受关键点的响应值(默认为 0.001000)。 阈值越低,检测到的(和接受的)关键点数量越多,反之亦然。
  • 描述符类型参数可以是以下值之一。 请注意,此参数仅存在于 AKAZE 类中:
    • DESCRIPTOR_KAZE_UPRIGHT
    • DESCRIPTOR_KAZE
    • DESCRIPTOR_MLDB_UPRIGHT
  • descriptor_size用于定义描述符的大小。 零值(也是默认值)表示完整尺寸的描述符。
  • descriptor_channels可用于设置描述符中的通道数。 默认情况下,此值设置为 3。

现在,不要理会与描述符相关的参数,例如描述符类型和大小以及通道数,我们将在后面看到。 这些相同的类也用于从特征中提取描述符,并且这些参数将在其中起作用,而不必检测关键点,尤其是detect函数。

这是前面示例用户界面的源代码,其中,根据我们前面示例用户界面中Accelerated复选框的状态,选择了 KAZE(未选中)或 AKAZE(加速):

    vector<KeyPoint> keypoints; 
    if(ui->kazeAcceleratedCheck->isChecked()) 
    { 
      Ptr<AKAZE> akaze = AKAZE::create(); 
      akaze->setDescriptorChannels(3); 
      akaze->setDescriptorSize(0); 
      akaze->setDescriptorType( 
        ui->akazeDescriptCombo->currentIndex() + 2); 
      akaze->setDiffusivity(ui->kazeDiffCombo->currentIndex()); 
      akaze->setNOctaves(ui->kazeOctaveSpin->value()); 
      akaze->setNOctaveLayers(ui->kazeLayerSpin->value()); 
      akaze->setThreshold(ui->kazeThreshSpin->value()); 
      akaze->detect(inputImage, keypoints); 
    } 
    else 
    { 
      Ptr<KAZE> kaze = KAZE::create(); 
      kaze->setUpright(ui->kazeUprightCheck->isChecked()); 
      kaze->setExtended(ui->kazeExtendCheck->isChecked()); 
      kaze->setDiffusivity(ui->kazeDiffCombo->currentIndex()); 
      kaze->setNOctaves(ui->kazeOctaveSpin->value()); 
      kaze->setNOctaveLayers(ui->kazeLayerSpin->value()); 
      kaze->setThreshold(ui->kazeThreshSpin->value()); 
      kaze->detect(inputImage, keypoints); 
    } 
    drawKeypoints(inputImage, keypoints, outputImage); 

参考文献:

KAZE Features. Pablo F. Alcantarilla, Adrien Bartoli and Andrew J. Davison. In European Conference on Computer Vision (ECCV), Fiorenze, Italy, October 2012.

Fast Explicit Diffusion for Accelerated Features in Nonlinear Scale Spaces. Pablo F. Alcantarilla, Jesús Nuevo and Adrien Bartoli. In British Machine Vision Conference (BMVC), Bristol, UK, September 2013.

BRISK

BRISK类可用于使用 BRISK二进制鲁棒不变可缩放关键点)算法检测图像中的特征。 请确保参考以下文章,以获取有关其工作方式以及 OpenCV 中基础实现的详细信息。 不过,用法与我们在 AGAST 和 KAZE 中看到的用法非常相似,其中使用create函数创建了类,然后设置了参数(如果我们不使用默认值),最后是detect函数被调用。 这是一个简单的例子:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-G8iVqzi6-1681870063153)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/8c096657-f322-40f2-8206-13a2695755bb.png)]

以下是此类用户界面的源代码。 小部件名称很容易猜到,每个小部件名称对应 BRISK 算法所需的三个参数之一,它们是thresh(类似于AGAST类中的阈值,因为 BRISK 在内部使用了类似的方法),octaves(类似于 KAZE 和 AKAZE 类中的八度数)和patternScale(这是 BRISK 算法使用的可选模式缩放参数),默认情况下设置为 1:

    vector<KeyPoint> keypoints; 
    Ptr<BRISK> brisk = 
        BRISK::create(ui->briskThreshSpin->value(), 
                      ui->briskOctaveSpin->value(), 
                      ui->briskScaleSpin->value()); 
    drawKeypoints(inputImage, keypoints, outputImage); 

参考文献:Stefan Leutenegger, Margarita Chli, and Roland Yves Siegwart. Brisk: Binary robust invariant scalable keypoints. In Computer Vision (ICCV), 2011 IEEE International Conference on, pages 2548-2555. IEEE, 2011.

FAST

FastFeatureDetector类可用于使用FAST方法检测图像中的特征(“加速段测试中的特征”)。 FAST 和 AGAST 算法共享很多,因为它们都是使用加速段测试的方法,即使在 OpenCV 实现以及此类的使用方式中,这也是显而易见的。 确保参考该算法的文章以了解更多有关它的详细信息。 但是,我们将在另一个示例中重点介绍如何使用它:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Z8fbPvR6-1681870063153)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/c5056c39-5e19-4e00-ae63-6663ed3bff56.png)]

并且,这是使用 FAST 算法从图像中检测关键点的此类用户界面的源代码。 所有三个参数的含义与 AGAST 算法的含义相同,不同之处在于类型可以是以下类型之一:

  • TYPE_5_8
  • TYPE_7_12
  • TYPE_9_16

参考文献:Edward Rosten and Tom Drummond. Machine learning for high-speed corner detection. In Computer Vision-ECCV 2006, pages 430-443. Springer, 2006.

GFTT

GFTT(需要跟踪的良好特征)仅是特征检测器。 GFTTDetector可用于使用 Harris(以创建者命名)和 GFTT 角检测算法检测特征。 因此,是的,该类别实际上是将两种特征检测方法组合在一起的一个类别,原因是 GFTT 实际上是哈里斯算法的一种修改版本,使用的哪一种将由输入参数决定。 因此,让我们看看如何在示例案例中使用它,然后简要介绍一下参数:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ccLe9qzF-1681870063153)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/5431337c-c412-4612-b9d8-1c0c4e265409.png)]

这是此用户界面的相关源代码:

    vector<KeyPoint> keypoints; 
    Ptr<GFTTDetector> gftt = GFTTDetector::create(); 
    gftt->setHarrisDetector(ui->harrisCheck->isChecked()); 
    gftt->setK(ui->harrisKSpin->value()); 
    gftt->setBlockSize(ui->gfttBlockSpin->value()); 
    gftt->setMaxFeatures(ui->gfttMaxSpin->value()); 
    gftt->setMinDistance(ui->gfttDistSpin->value()); 
    gftt->setQualityLevel(ui->gfttQualitySpin->value()); 
    gftt->detect(inputImage, keypoints); 
    drawKeypoints(inputImage, keypoints, outputImage); 

以下是GFTTDetector类的参数及其定义:

  • 如果将useHarrisDetector设置为true,则将使用 Harris 算法,否则将使用 GFTT。 默认情况下,此参数设置为false
  • blockSize可用于设置块大小,该块大小将用于计算像素附近的导数协方差矩阵。 默认为 3。
  • K是 Harris 算法使用的常数参数值。
  • 可以设置maxFeaturesmaxCorners来限制检测到的关键点数量。 默认情况下,它设置为 1000,但是如果关键点的数量超过该数量,则仅返回最强的响应。
  • minDistance是关键点之间的最小可接受值。 默认情况下,此值设置为 1,它不是像素距离,而是欧几里得距离。
  • qualityLevel是阈值级别的值,用于过滤质量指标低于特定级别的关键点。 请注意,实际阈值是通过将该值与图像中检测到的最佳关键点质量相乘得出的。

参考文献:

Jianbo Shi and Carlo Tomasi. Good features to track. In Computer Vision and Pattern Recognition, 1994. Proceedings CVPR'94., 1994 IEEE Computer Society Conference on, pages 593-600. IEEE, 1994.

C. Harris and M. Stephens (1988). A combined corner and edge detector. Proceedings of the 4th Alvey Vision Conference. pp. 147-151.

ORB

最后,ORB 算法,这是我们将在本节中介绍的最后一个特征检测算法。

ORB类可用于使用 ORB(二进制鲁棒独立基本特征)算法检测图像中的关键点。 此类封装了我们已经看到的一些方法(例如 FAST 或 Harris)来检测关键点。 因此,在类构造器中设置或使用设置器函数设置的某些参数与描述符提取有关,我们将在后面学习; 但是,ORB 类可用于检测关键点,如以下示例所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-My3XXtHC-1681870063153)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/2cfa7427-2163-4a62-868c-47e9ef19e96a.png)]

这是此类用户界面所需的源代码。 同样,小部件的objectName属性几乎是不言自明的,如上图所示,但让我们先看一下代码,然后详细查看参数:

    vector<KeyPoint> keypoints; 
    Ptr<ORB> orb = ORB::create(); 
    orb->setMaxFeatures(ui->orbFeaturesSpin->value()); 
    orb->setScaleFactor(ui->orbScaleSpin->value()); 
    orb->setNLevels(ui->orbLevelsSpin->value()); 
    orb->setPatchSize(ui->orbPatchSpin->value()); 
    orb->setEdgeThreshold(ui->orbPatchSpin->value()); // = patch size 
    orb->setWTA_K(ui->orbWtaSpin->value()); 
    orb->setScoreType(ui->orbFastCheck->isChecked() ? 
                      ORB::HARRIS_SCORE 
                    : 
                      ORB::FAST_SCORE); 
    orb->setPatchSize(ui->orbPatchSpin->value()); 
    orb->setFastThreshold(ui->orbFastSpin->value()); 
    orb->detect(inputImage, keypoints); 
    drawKeypoints(inputImage, keypoints, outputImage); 

该序列与到目前为止我们看到的其他算法完全相同。 让我们看看设置了哪些参数:

  • MaxFeatures参数只是应该检索的最大关键点数。 请注意,检测到的关键点数量可能比此数量少很多,但永远不会更高。
  • ScaleFactor或金字塔抽取比率,与我们先前算法中看到的八度参数有些相似,用于确定金字塔每个级别的尺度值,这些尺度将用于检测关键点并从不同尺度提取同一张图片的描述符。 这就是在 ORB 中实现尺度不变性的方式。
  • NLevels是金字塔的等级数。
  • PatchSize是 ORB 算法使用的补丁的大小。 有关此内容的详细信息,请确保参考以下参考文献,但是对于简短说明,补丁大小决定了要提取描述的关键点周围的区域。 请注意,PatchSizeEdgeThreshold参数需要大约相同的值,在前面的示例中也将其设置为相同的值。
  • EdgeThreshold是在关键点检测期间将忽略的以像素为单位的边框。
  • WTA_K或 ORB 算法内部使用的 WTA 哈希的 K 值是一个参数,用于确定将用于在 ORB 描述符中创建每个元素的点数。 我们将在本章稍后看到更多有关此的内容。
  • 可以设置为以下值之一的ScoreType决定 ORB 算法使用的关键点检测方法:
    • ORB::HARRIS_SCORE用于哈里斯角点检测算法
    • ORB::FAST_SCORE用于 FAST 关键点检测算法
  • FastThreshold只是 ORB 在关键点检测算法中使用的阈值。

参考文献:

Ethan Rublee, Vincent Rabaud, Kurt Konolige, and Gary Bradski. Orb: an efficient alternative to sift or surf. In Computer Vision (ICCV), 2011 IEEE International Conference on, pages 2564-2571. IEEE, 2011.

Michael Calonder, Vincent Lepetit, Christoph Strecha, and Pascal Fua, BRIEF: Binary Robust Independent Elementary Features, 11th European Conference on Computer Vision (ECCV), Heraklion, Crete. LNCS Springer, September 2010.

而已。 现在,我们熟悉如何使用 OpenCV 3 中可用的各种算法检测关键点。当然,除非我们从这些关键点中提取描述符,否则这些关键点(或特征)几乎没有用; 因此,在下一节中,我们将学习从关键点提取描述符的方法,这将因此使我们获得 OpenCV 中的描述符匹配功能,在这里我们可以使用在本节中了解到的类来识别,检测,跟踪对象和对图像进行分类。 请注意,对于我们了解的每种算法,最好阅读本文以了解其所有细节,尤其是如果您打算构建自己的自定义关键点检测器,而只是按原样使用时,就像之前提到的,对它们的目的有一个清晰的认识就足够了。

提取和匹配描述符

计算机视觉中的描述符是一种描述关键点的方式,该关键点完全依赖于用于提取关键点的特定算法,并且与关键点(在KeyPoint类中定义的)不同,描述符没有共同的结构 ,除了每个描述符都代表一个关键点这一事实外。 OpenCV 中的描述符存储在Mat类中,其中生成的描述符Mat类中的每一行都引用关键点的描述符。 正如我们在上一节中了解到的,我们可以使用任何FeatureDetector子类的detect函数从图像上基本上检测出一组关键点。 同样,我们可以使用任何DescriptorExtractor子类的compute函数从关键点提取描述符。

由于特征检测器和描述符提取器在 OpenCV 中的组织方式(这都是Feature2D子类,正如我们在本章前面了解的那样),令人惊讶的是,将它们结合使用非常容易。 在本节中看到,我们将使用完全相同的类(或更确切地说,也提供描述符提取方法的类)从我们在上一节中使用各种类发现的关键点中提取特征描述符,以在场景图像中找到对象。 重要的是要注意,并非所有提取的关键点都与所有描述符兼容,并且并非所有算法(在这种情况下为Feature2D子类)都提供detect函数和compute函数。 不过,这样做的人还提供了detectAndCompute函数,可以一次性完成关键点检测和特征提取,并且比分别调用这两个函数要快。 让我们从第一个示例案例开始,以便使所有这些变得更加清晰。 这也是匹配两个单独图像的特征所需的所有步骤的示例,这些图像可用于检测,比较等:

  1. 首先,我们将使用 AKAZE 算法(使用上一节中学习的AKAZE类)从以下图像中检测关键点:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ImpHR2Yl-1681870063154)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/94bace3a-2c40-47a4-821a-559b8b8ec8cf.png)]

我们可以使用以下代码从两个图像中提取关键点:

      using namespace cv; 
      using namespace std; 
      Mat image1 = imread("image1.jpg"); 
      Mat image2 = imread("image2.jpg"); 
      Ptr<AKAZE> akaze = AKAZE::create(); 
      // set AKAZE params ... 
      vector<KeyPoint> keypoints1, keypoints2; 
      akaze->detect(image1, keypoints1); 
      akaze->detect(image2, keypoints2); 
  1. 现在我们有了两个图像的特征(或关键点),我们可以使用相同的AKAZE类实例从这些关键点提取描述符。 这是完成的过程:
        Mat descriptor1, descriptor2; 
        akaze->compute(image1, keypoints1, descriptor1); 
        akaze->compute(image2, keypoints2, descriptor2); 
  1. 现在,我们需要匹配两个图像,这是两个图像中关键点的描述符。 为了能够执行描述符匹配操作,我们需要在 OpenCV 中使用一个名为DescriptorMatcher(非常方便)的类。 需要特别注意的是,此匹配器类需要设置为正确的类型,否则,将不会得到任何结果,或者甚至可能在运行时在应用中遇到错误。 如果在本示例中使用 AKAZE 算法来检测关键点并提取描述符,则可以在DescriptorMatcher中使用FLANNBASED类型。 这是完成的过程:
        descMather = DescriptorMatcher::create( 
          DescriptorMatcher::FLANNBASED); 

请注意,您可以将以下值之一传递给DescriptorMatcher的创建函数,并且这完全取决于您用来提取描述符的算法,显然,因为将对描述符执行匹配。 您始终可以参考每种算法的文档,以了解可用于任何特定描述符类型的算法,例如AKAZEKAZE之类的算法具有浮点类型描述符,因此可以将FLANNBASED与他们一起使用; 但是,具有String类型的描述符(例如ORB)将需要与描述符的汉明距离匹配的匹配方法。 以下是可用于匹配的现有方法:

  • FLANNBASED
  • BRUTEFORCE
  • BRUTEFORCE_L1
  • BRUTEFORCE_HAMMING
  • BRUTEFORCE_HAMMINGLUT
  • BRUTEFORCE_SL2

当然,最坏的情况是,当您不确定不确定时,尝试为每种特定的描述符类型找到正确的匹配算法时,只需简单地尝试每个。

  1. 现在,我们需要调用DescriptorMatchermatch函数,以尝试将第一张图像(或需要检测的对象)中找到的关键点与第二张图像(或可能包含我们的对象的场景)中的关键点进行匹配。 match函数将需要一个DMatch向量,并将所有匹配结果填充其中。 这是完成的过程:
        vector<DMatch> matches; 
        descMather->match(descriptor1, descriptor2, matches); 

DMatch类是简单类,仅用作保存匹配结果数据的结构:

  1. 在深入研究如何解释匹配操作的结果之前,我们将学习如何使用drawMatches函数。 与drawKeypoints函数相似,drawMatches可用于自动创建适合显示的输出结果。 这是如何做:
        drawMatches(image1, 
                    keypoints1, 
                    image2, 
                    keypoints2, 
                    matches, 
                    dispImg); 

在前面的代码中,dispImg显然是可以显示的Mat类。 这是结果图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wkA2wann-1681870063154)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/69c03cae-b40e-4dad-b63e-365dccbf1633.png)]

如您所见,drawMatches函数将获取第一张和第二张图像及其关键点以及匹配结果,并会处理绘制适当结果所需的一切。 在此示例中,我们仅提供了必需的参数,这会导致颜色随机化并绘制所有关键点和匹配的关键点(使用将它们连接在一起的线)。 当然,还有一些其他参数可用于进一步修改其工作方式。 (可选)您可以设置关键点和线条的颜色,还可以决定忽略不匹配的关键点。 这是另一个例子:

        drawMatches(image1, 
                    keypoints1, 
                    image2, 
                    keypoints2, 
                    matches, 
                    dispImg, 
                    Scalar(0, 255, 0), // green for matched 
                    Scalar::all(-1), // unmatched color (default) 
                    vector<char>(), // empty mask 
                    DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS); 

这将产生以下结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xKP0yKVY-1681870063154)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/5a2b54f1-de45-433b-98e7-c4261bfef4bb.png)]

现在,颜色更适合我们在这里使用的颜色。 还应注意一些很不正常的不正确匹配,可以通过修改 KAZE 算法的参数甚至使用其他算法来解决。 现在让我们看看如何解释匹配结果。

  1. 解释匹配结果完全取决于用例。 例如,如果我们要匹配两个具有相同大小和相同内容类型的图像(例如人脸,相同类型的对象,指纹等),则我们可能需要考虑距离值高于某个阈值的,匹配的关键点数量。 或者,就像在当前示例中一样,我们可能希望使用匹配来检测场景中的对象。 这样做的一种常见方法是尝试找出匹配关键点之间的单应性变化。 为此,我们需要执行以下三个操作:
    • 首先,我们需要过滤出匹配结果,以消除较弱的匹配,换句话说,仅保留良好的匹配; 同样,这完全取决于您的场景和对象,但是通常,通过几次尝试和错误,您可以找到最佳阈值
    • 接下来,我们需要使用findHomography函数来获得好关键点之间的单应性变化
    • 最后,我们需要使用perspectiveTransform将对象边界框(矩形)转换为场景

您已了解findHomographyperspectiveTransform以及如何在第 6 章,“OpenCV 中的图像处理”中使用它们。

这是我们可以过滤掉不需要的匹配结果以获得良好匹配的方法。 请注意,匹配阈值的0.1值是通过反复试验得出的。 通常在匹配集中找到最小和最大距离,然后仅接受距离小于与最小距离相关的某个值的匹配,尽管这不是我们在此处所做的:

    vector<DMatch> goodMatches; 
    double matchThresh = 0.1; 
    for(int i=0; i<descriptor1.rows; i++) 
    { 
      if(matches[i].distance < matchThresh) 
          goodMatches.push_back(matches[i]); 
    } 

在需要微调阈值的情况下,可以使用 Qt 框架和用户界面的功能。 例如,您可以使用 Qt 滑块小部件快速轻松地微调并找到所需的阈值。 只要确保将matchThresh替换为滑块小部件的值即可。

现在,我们可以使用良好的匹配找到单应性变化。 为此,我们首先需要根据匹配项过滤关键点,然后将这些过滤后的关键点(仅这些点)馈送到findHomography函数,以获得所需的变换矩阵或单应性更改。 这里是:

    vector<Point2f> goodP1, goodP2; 
    for(int i=0; i<goodMatches.size(); i++) 
    { 
      goodP1.push_back(keypoints1[goodMatches[i].queryIdx].pt); 
      goodP2.push_back(keypoints2[goodMatches[i].trainIdx].pt); 
    } 
    Mat homoChange = findHomography(goodP1, goodP2); 

最后,我们可以使用刚刚发现的单应性变化矩阵将透视变换应用于匹配点。 为此,首先,我们需要构造与第一个图像的四个角相对应的四个点,然后应用变换,最后,简单地绘制连接四个结果点的四条线。 方法如下:

    vector<Point2f> corners1(4), corners2(4); 
    corners1[0] = Point2f(0,0); 
    corners1[1] = Point2f(image1.cols-1, 0); 
    corners1[2] = Point2f(image1.cols-1, image1.rows-1); 
    corners1[3] = Point2f(0, image1.rows-1); 

    perspectiveTransform(corners1, corners2, homoChange); 

    image2.copyTo(dispImage); 

    line(dispImage, corners2[0], corners2[1], Scalar::all(255), 2); 
    line(dispImage, corners2[1], corners2[2], Scalar::all(255), 2); 
    line(dispImage, corners2[2], corners2[3], Scalar::all(255), 2); 
    line(dispImage, corners2[3], corners2[0], Scalar::all(255), 2); 

这是此操作的结果:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-C88ayIWi-1681870063154)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/2398008a-fff7-4117-bde0-63ca177c5979.png)]

这实际上不是对这种方法功能强大程度的测试,因为对象基本上是从同一图像上剪切下来的。 这是运行相同过程的结果,但是这次第二张图像发生了旋转和透视图变化,甚至出现了一些噪点(这是使用智能手机从屏幕上拍摄的图像)。 即使第一张图片的一小部分在视图之外,结果也几乎是正确的:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kr9KIpAA-1681870063154)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/d6fb7e05-9ac9-45c5-ba53-3cec8b9ba1e3.png)]

出于参考目的,使用具有DESCRIPTOR_KAZE描述符类型,0.0001阈值,4八度,4八度和DIFF_PM_G1扩散率参数的 AKAZE 算法进行匹配和检测。 自己尝试具有不同照明条件和图像的不同参数。

我们还可以将drawMatches结果与检测结果结合起来,这意味着,我们可以在匹配结果图像上方绘制检测边界框,这可能会更有帮助,尤其是在微调参数或出于其他任何信息目的时 。 为此,您需要确保首先调用drawMatches函数以创建输出图像(在我们的示例中为dispImg变量),然后添加所有具有偏移值的点,因为drawMatches也将第一个图片在左侧输出。 此偏移量仅有助于将我们生成的边界框移到右侧,或者换句话说,将第一张图像的宽度添加到每个点的X成员。 这是完成的过程:

    Point2f offset(image1.cols, 0); 

    line(dispImage, corners2[0] + offset, 
      corners2[1] + offset, Scalar::all(255), 2); 
    line(dispImage, corners2[1] + offset, 
      corners2[2] + offset, Scalar::all(255), 2); 
    line(dispImage, corners2[2] + offset, 
      corners2[3] + offset, Scalar::all(255), 2); 
    line(dispImage, corners2[3] + offset, 
      corners2[0] + offset, Scalar::all(255), 2); 

这是结果图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iC2Kl9my-1681870063155)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/11c4e88a-2d52-4921-abe6-ae7294e53d73.png)]

在前面的示例中,如您在结果中所看到的,图像以多种方式失真(例如比例,方向等),但是该算法仍然可以在一组正确的输入参数下表现良好。 从理论上讲,理想情况下,我们一直在寻找一种即用型算法,并且希望它在所有可能的情况下都能表现出色; 但是,不幸的是,在实践中,这种情况根本不会发生或很少发生。 在下一节中,我们将学习如何为用例选择最佳算法。

如何选择算法

如前所述,没有一种算法可以轻松地用于所有开箱即用的情况,其主要原因是与软件和硬件相关的因素种类繁多。 一个算法可能非常准确,但是同时,它可能需要大量资源(例如内存或 CPU 使用率)。

另一种算法可能需要较少的参数(几乎总是缓解),但同样,它可能无法达到其最高性能。 我们甚至无法开始列出影响选择最佳Feature2D(或特征检测器和描述符提取器)算法或最佳匹配算法的所有可能因素,但我们仍可以考虑一些主要且更知名的因素,这也是从结构上来讲,按原样创建 OpenCV 和大多数计算机视觉算法的原因。 以下是这些因素:

  • 准确率
  • 速度
  • 资源使用情况(内存,磁盘空间等)
  • 可用性

请注意,表现一词通常是指准确率,速度和资源使用情况的组合。 因此,我们所寻找的本质上是一种表现达到我们要求的算法,并且该算法可用于需要我们的应用进行工作的一个或多个平台。 值得一提的是,您作为工程师还可以影响这些参数,特别是通过将用例缩小到恰好需要的范围。 让我们通过刚才提到的因素来解释这一点。

准确率

首先,准确率非常容易引起误解,因为一旦看到准确率下降,我们通常会倾向于放弃算法,但是正确的方法是首先弄清楚用例的准确率要求。 查看由非常著名的公司生产的基于计算机视觉的机器的数据表,您会立即发现诸如 95% 以上的东西,依此类推。 这并不意味着机器不完美。 相反,这意味着机器的精度是明确定义的,用户可以期望达到一定的精度,同时,他们可以承受一定的低误差。 话虽这么说,它总是很好,建议目标是 100% 的准确率。

除了浏览该算法的论文和参考文献,还有更好的办法,亲自尝试一下,没有比这更好的方法来为您的用例选择一种准确的算法。 确保使用 Qt 中的适当小部件创建用户界面,以便您可以轻松地尝试现有(甚至可能是您自己的)算法。 创建基准并确保您完全了解更改阈值或其他参数时任何特定算法的行为。

另外,请确保根据比例和旋转独立性方面的需要选择算法。 例如,在 AKAZE 中,使用标准 AKAZE 描述符类型(非直立),该算法允许旋转独立,因此您的匹配甚至可以与旋转对象一起使用。 或者,使用较高的八度(或金字塔等级)数字,因为这可以帮助匹配不同大小的图像,从而实现比例独立。

速度

如果您正在开发实时应用,其中 FPS每秒帧或帧速率)值必须尽可能高,则算法执行的速度尤为重要。 因此,与准确率一样,您也需要小心以澄清此要求。 如果您匹配两幅图像并向用户显示一些匹配结果,则即使半​​秒(500 毫秒)的延迟仍然可以接受,但是当使用高 FPS 值时,每帧的半秒延迟会非常高 。

您可以在 OpenCV 中使用TickMeter类或getTickFrequencygetTickCount函数来测量计算机视觉进程(或与此相关的任何进程)的执行时间。 首先,让我们看看旧方法的工作原理:

    double freq = cv::getTickFrequency(); 
    double tick = cv::getTickCount(); 
    processImage(); // Any process 
    double dur = (cv::getTickCount() - tick) / freq; 

getTickFrequency函数可用于以秒(或频率)为单位获取 CPU 滴答计数。 同样,getTickCount可用于获取自启动以来传递的 CPU 滴答声数量。 因此,很明显,在前面的示例代码中,我们将获得执行processImage函数的持续时间(以秒为单位)。

但是TickMeter类提供了更大的灵活性,并且更易于使用。 您只需在任何过程之前启动它,然后在该过程之后停止它。 这是完成的过程:

    cv::TickMeter meter; 
    meter.start(); 
    processImage(); // Any process 
    meter.stop(); 
    meter.getTimeMicro(); 
    meter.getTimeMilli(); 
    meter.getTimeSec(); 
    meter.getTimeTicks(); 

在满足您精度要求的不同算法之间进行切换,并使用此技术测量其速度,然后选择最适合您的算法。 尝试远离经验法则,例如 ORB 更快,或者 BRISK 更准确,等等。 即使具有String类型的描述符(例如 ORB)通常在匹配方面也更快(因为它们使用汉明距离); 最新的算法(例如 AKAZE)可以使用 GPU 和 OpenCV UMat(请参阅第 4 章,“MatQImage”,以了解有关UMat类的更多信息) 。 因此,请尝试使用您的度量或任何受信任的度量参考作为经验法则的来源。

您也可以使用 Qt 的QElapsedTimer类,类似于 OpenCV 的TickMeter类,来测量任何进程的执行时间。

资源使用

尤其是对于较新的高端设备和计算机,这通常不是什么大问题,但是对于磁盘和内存空间有限的计算机(例如嵌入式计算机),这仍然可能是个问题。 为此,请尝试使用操作系统随附的资源监视器应用。 例如,在 Windows 上,您可以使用任务管理器应用查看已使用的资源,例如内存。 在 MacOS 上,您可以使用“活动监视器”应用甚至查看每个程序使用的电池电量(能量)以及内存和其他资源使用情况信息。 在 Linux 上,您可以使用多种工具(例如系统监视器)来实现完全相同的目的。

可用性

即使 OpenCV 和 Qt 都是跨平台框架,算法(甚至是类或函数)仍然可以依赖于平台特定的功能,尤其是出于性能方面的考虑。 重要且显而易见的是,您需要确保使用的算法在旨在发布您的应用的平台上可用。最好的来源通常是 OpenCV 和 Qt 框架中基础类的文档页面。 。

您可以从以下链接下载用于关键点检测,描述符提取和描述符匹配的完整源代码。 您可以使用同一插件在准确率和速度方面比较不同的算法。 不用说,此插件与我们在整本书中一直构建的computer_vision项目兼容

总结

特征检测,描述和匹配可能是计算机视觉中最重要和最热门的主题,仍在不断发展和改进中。 本章介绍的算法只是世界上现有算法的一小部分,我们之所以选择介绍它们,是因为它们或多或少都可供公众免费使用,以及它们默认包含在feature2d模块下的 OpenCV 中。 如果您有兴趣了解更多算法,还可以查看额外 2D 特征框架xfeature2d),其中包含非自由算法,例如 SURF 和 SIFT,或其他仍处于实验状态的算法。 当然,在构建它们以将其功能包括在 OpenCV 安装中之前,您需要单独下载它们并将其添加到 OpenCV 源代码中。 也建议。 但是,还要确保使用不同的图像和各种参数来尝试使用本章中学习的算法,以熟悉它们。

通过完成本章,您现在可以使用与特征和描述符相关的算法来检测关键点并提取特征并进行匹配以检测对象或相互比较图像。 使用本章介绍的类,您现在可以正确显示匹配操作的结果,还可以测量每个过程的性能来确定哪个更快。

在第 8 章,“多线程”中,我们将了解 Qt 中的多线程和并行处理(及其在 OpenCV 中的应用)以及如何从应用的主线程中,有效创建和使用分别存在的线程和进程。 利用下一章的知识,我们将准备处理继续在视频文件或摄像机帧中的连续帧上执行的视频处理和计算机视觉任务。

八、多线程

不久前,计算机程序的设计和构建是一个接一个地运行一系列指令。 实际上,这种方法非常易于理解和实现,即使在今天,我们也使用相同的方法来编写脚本和简单程序,这些脚本和简单程序以串行方式处理所需的任务。 但是,随着时间的推移,尤其是随着功能更强大的处理器的兴起,多任务成为主要问题。 期望计算机一次执行多个任务,因为它们足够快地执行多个程序所需的指令,并且仍然有一些空闲时间。 当然,随着时间的流逝,甚至会编写更复杂的程序(游戏,图形程序等),并且处理器必须公平地管理不同程序所使用的时间片,以使所有程序继续正确运行。 程序(或过程,在这种情况下使用更合适的词)被分成称为线程的较小片段。 直到现在,这种方法(或多线程)已经帮助创建了可以与相似或完全不相关的进程一起运行的快速响应进程,从而带来了流畅的多任务处理体验。

在具有单个处理器(和单个内核)的计算机上,每个线程都有一个时间片,并且处理器显然一次只能处理一个线程,但是多个线程之间的切换通常是如此之快,以至于从用户需求的角度来看,似乎是真正的并行性。 但是,如今,即使人们随身携带的大多数智能手机中的处理器也具有使用处理器中的多个内核处理多个线程的能力。

为确保我们对线程以及如何使用它们有清晰的了解,以及为什么不使用线程就无法编写功能强大的计算机视觉程序,我们来看看进程与线程之间的主要区别:

  • 进程类似于单个程序,它们直接由操作系统执行
  • 线程是进程的子集,换句话说,一个进程可以包含多个线程
  • 一个进程(通常)独立于任何其他进程,而线程彼此共享内存和资源(请注意,进程可以通过操作系统提供的方法相互交互)

根据设计的方式,每个进程可能会也可能不会创建和执行不同的线程,以实现最佳性能和响应能力。 另一方面,每个线程将执行该进程所需的特定任务。 Qt 和 GUI 编程中的典型示例是进度信息。 运行复杂且耗时的过程时,通常需要显示有关进度的阶段和状态的信息,例如剩余的工作百分比,完成的剩余时间等等。 最好通过将实际任务和 GUI 更新任务分成单独的线程来完成。 在计算机视觉中非常常见的另一个示例是视频(或摄像机)处理。 您需要确保在需要时正确阅读,处理和显示了视频。 在学习 Qt 框架中的多线程功能时,这以及此类示例将成为本章的重点。

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

  • Qt 中的多线程方法
  • 如何在 Qt 中使用QThread和多线程类
  • 如何创建响应式 GUI
  • 如何处理多张图像
  • 如何处理多个摄像机或视频

Qt 中的多线程

Qt 框架提供了许多不同的技术来处理应用中的多线程。 QThread类用于处理各种多线程功能,正如我们将在本章中看到的那样,使用它也是 Qt 框架中处理线程的最强大,最灵活的方式。 除了QThread,Qt 框架还提供了许多其他名称空间,类和函数,可满足各种多线程需求。 在我们查看如何使用它们的示例之前,以下是它们的列表:

  • QThread:此类是 Qt 框架中所有线程的基础。 可以将其子类化以创建新线程,在这种情况下,您需要覆盖run方法,或者可以创建该方法的新实例,然后通过调用 Qt 对象(QObject子类)将其移至新线程中。 moveToThread函数。
  • QThreadPool:通过允许将现有线程重新用于新用途,可用于管理线程并帮助降低线程创建成本。 每个 Qt 应用都包含一个全局QThreadPool实例,可以使用QThreadPool::globalInstance()静态函数对其进行访问。 此类与QRunnable类实例结合使用,以控制,管理和回收 Qt 应用中的可运行对象。
  • QRunnable:这提供了另一种创建线程的方法,它是 Qt 中所有可运行对象的基础。 与QThread不同,QRunnable不是QObject子类,并且用作需要运行的一段代码的接口。 您需要继承并覆盖run函数,才能使用QRunnable。 如前所述,QRunnable实例由QThreadPool类管理。
  • QMutexQMutexLockerQSemaphoreQWaitConditionQReadLockerQWriteLockerQWriteLocke:这些类用于处理线程间同步任务。 根据情况,可以使用这些类来避免出现以下问题:线程覆盖彼此的计算,试图读取或写入一次只能处理一个线程的设备的线程以及许多类似的问题。 创建多线程应用时,通常需要手动解决此类问题。
  • QtConcurrent:此命名空间可用于使用高级 API 创建多线程应用。 它使编写多线程应用变得更加容易,而无需处理互斥量,信号量和线程间同步问题。
  • QFutureQFutureWatcherQFututeIteratorQFutureSynchronizer:这些类都与QtConcurrent命名空间结合使用,以处理多线程和异步操作结果。

通常,在 Qt 中有两种不同的多线程方法。 第一种基于QThread的方法是低级方法,它提供了很多灵活性和对线程的控制,但是需要更多的编码和维护才能完美地工作。 但是,有很多方法可以使用QThread来制作多线程应用,而工作量却少得多,我们将在本章中学习它们。 第二种方法基于QtConcurrent命名空间(或 Qt 并发框架),这是在应用中创建和运行多个任务的高级方法。

使用QThread的低级多线程

在本节中,我们将学习如何使用QThread及其关联类创建多线程应用。 我们将通过创建一个示例项目来完成此过程,该项目将使用单独的线程处理并显示视频源的输入和输出帧。 这有助于使 GUI 线程(主线程)保持空闲和响应状态,而第二个线程处理更密集的进程。 正如前面提到的,我们将主要关注计算机视觉和 GUI 开发的通用用例。 但是,可以将相同(或非常相似)的方法应用于任何多线程问题。

我们将使用此示例项目来使用 Qt 中提供的两种不同方法(用于QThread类)来实现多线程。 首先,子类化并覆盖run方法,其次,使用所有 Qt 对象中可用的moveToThread函数,或者换句话说,使用QObject子类。

子类化QThread

让我们首先在 Qt Creator 中创建一个名为MultithreadedCV的示例 Qt Widgets 应用。 以我们在本书开始章节中学到的相同方式将 OpenCV 框架添加到该项目中:在MultithreadedCV.pro文件中包含以下代码(请参见第 2 章,“第一个 Qt 和 OpenCV 项目”或第 3 章,“创建一个综合的 Qt + OpenCV 项目”,以了解更多信息):

    win32: { 
      include("c:/dev/opencv/opencv.pri") 
    } 
    unix: !macx{ 
      CONFIG += link_pkgconfig 
      PKGCONFIG += opencv 
    } 
    unix: macx{ 
    INCLUDEPATH += /usr/local/include 
      LIBS += -L"/usr/local/lib" \ 
      -lopencv_world 
    } 

然后,将两个标签窗口小部件添加到mainwindow.ui文件,如下所示。 我们将使用以下标签在计算机上显示来自默认摄像头的原始视频和经过处理的视频:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5fBH4D8w-1681870063155)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/0b4f39ff-6005-4198-b087-d3140cc06205.png)]

确保将左侧标签的objectName属性设置为inVideo,将右侧标签的objectName属性设置为outVideo。 另外,将其alignment/Horizontal属性设置为AlignHCenter。 现在,通过右键单击项目 PRO 文件并从菜单中选择“新建”,创建一个名为VideoProcessorThread的新类。 然后,选择“C++ 类”,并确保新类向导中的组合框和复选框如下图所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-snEPwWP0-1681870063155)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/e1a79f82-e6b3-46e5-96fd-e864574766f4.png)]

创建类后,项目中将有两个名为videoprocessorthread.hvideoprocessor.cpp的新文件,其中将实现一个视频处理器,该处理器在与mainwindow文件和 GUI 线程不同的线程中工作。 首先,通过添加相关的包含行和类继承来确保此类继承了QThread,如下所示(只需在头文件中将QObject替换为QThread)。 另外,请确保您包含 OpenCV 标头:

    #include <QThread> 
    #include "opencv2/opencv.hpp" 

    class VideoProcessorThread : public QThread 

您需要类似地更新videoprocessor.cpp文件,以便它调用正确的构造器:

    VideoProcessorThread::VideoProcessorThread(QObject *parent) 
      : QThread(parent) 

现在,我们需要向videoprocessor.h文件中添加一些必需的声明。 将以下行添加到您的类的private成员区域:

    void run() override; 

然后,将以下内容添加到signals部分:

    void inDisplay(QPixmap pixmap); 
    void outDisplay(QPixmap pixmap); 

最后,将以下代码块添加到videoprocessorthread.cpp文件:

    void VideoProcessorThread::run() 
    { 
      using namespace cv; 
      VideoCapture camera(0); 
      Mat inFrame, outFrame; 
      while(camera.isOpened() && !isInterruptionRequested()) 
      { 
        camera >> inFrame; 
        if(inFrame.empty()) 
            continue; 

        bitwise_not(inFrame, outFrame); 

        emit inDisplay( 
             QPixmap::fromImage( 
                QImage( 
                  inFrame.data, 
                  inFrame.cols, 
                  inFrame.rows, 
                  inFrame.step, 
                  QImage::Format_RGB888) 
                      .rgbSwapped())); 

        emit outDisplay( 
             QPixmap::fromImage( 
                QImage( 
                 outFrame.data, 
                   outFrame.cols, 
                   outFrame.rows, 
                   outFrame.step, 
                   QImage::Format_RGB888) 
                     .rgbSwapped())); 
      } 
    }  

run函数被覆盖,并已执行以执行所需的视频处理任务。 如果您试图在mainwindow.cpp代码中循环执行相同的操作,则会注意到您的程序无响应,最终必须终止它。 但是,使用这种方法,现在相同的代码位于单独的线程中。 您只需要确保通过调用start函数而不是run启动此线程即可! 注意run函数是在内部调用的,因此您只需要重新实现它即可,如本示例所示; 但是,要控制线程及其执行行为,您需要使用以下函数:

  • start:如果尚未启动线程,则可用于启动该线程。 该函数通过调用我们实现的run函数开始执行。 您可以将以下值之一传递给start函数,以控制线程的优先级:

    • QThread::IdlePriority(在没有其他线程在运行时调度)
    • QThread::LowestPriority
    • QThread::LowPriority
    • QThread::NormalPriority
    • QThread::HighPriority
    • QThread::HighestPriority
    • QThread::TimeCriticalPriority(尽可能安排此时间)
    • QThread::InheritPriority(这是默认值,它仅从父级继承优先级)
  • terminate:此函数仅在极端情况下使用(意味着永远不会,希望如此),将强制线程终止。

  • setTerminationEnabled:可用于启用或禁用terminate函数。

  • wait:此函数可用于阻塞线程(强制等待),直到线程完成或达到超时值(以毫秒为单位)为止。

  • requestInterruptionisRequestInterrupted:这些函数可用于设置和获取中断请求状态。 使用这些函数是确保线程在可能永远持续的进程中间安全停止的一种有用方法。

  • isRunningisFinished:这些函数可用于请求线程的执行状态。

除了我们在此处提到的函数之外,QThread包含其他可用于处理多线程的函数,例如quitexitidealThreadCount等。 最好亲自检查一下并考虑其中每个用例。 QThread是一个功能强大的类,可以帮助您最大化应用的效率。

让我们继续我们的示例。 在run函数中,我们使用 OpenCV VideoCapture类读取视频帧(永久),并将简单的bitwise_not运算符应用于Mat帧(此时我们可以进行任何其他图像处理,因此 bitwise_not只是一个例子,是一个相当简单的解释我们的观点),然后通过QImage将其转换为QPixmap,然后使用两个信号发送原始帧和修改后的帧。 请注意,在永远持续的循环中,我们将始终检查摄像头是否仍处于打开状态,并还会检查对此线程是否有中断请求。

现在,让我们在MainWindow中使用我们的线程。 首先将其头文件包含在mainwindow.h文件中:

    #include "videoprocessorthread.h" 

然后,将以下行添加到mainwindow.h文件中MainWindowprivate成员部分:

    VideoProcessorThread processor; 

现在,在setupUi行之后,将以下代码添加到MainWindow构造器中:

    connect(&processor, 
            SIGNAL(inDisplay(QPixmap)), 
            ui->inVideo, 
            SLOT(setPixmap(QPixmap))); 

    connect(&processor, 
            SIGNAL(outDisplay(QPixmap)), 
            ui->outVideo, 
            SLOT(setPixmap(QPixmap))); 

    processor.start(); 

然后将以下行添加到delete ui;行之前的MainWindow析构器中:

    processor.requestInterruption(); 
    processor.wait(); 

我们只需将VideoProcessorThread类的两个信号连接到我们添加到MainWindow GUI 的两个标签,然后在程序启动后立即启动线程。 我们还要求线程在MainWindow关闭后立即删除,并且在删除 GUI 之前。 在继续执行删除指令之前,wait函数调用可确保等待线程清理并安全完成执行。 尝试运行此代码以自行检查。 程序启动后,您应该会看到类似于下图的内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uq8IBMf6-1681870063155)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/34b59246-3467-43c8-ab5b-47abd2495e8e.png)]

程序启动后,计算机上默认摄像头中的视频应立即开始播放,关闭程序后,该视频将停止播放。 尝试通过向其中传递摄像机索引号或视频文件路径来扩展VideoProcessorThread类。 您可以根据需要实例化许多VideoProcessorThread类。 您只需要确保将信号连接到 GUI 上的正确小部件,就可以通过这种方式在运行时动态处理和显示多个视频或摄像机。

使用moveToThread函数

如前所述,您还可以使用任何QObject子类的moveToThread函数来确保它在单独的线程中运行。 为了确切地了解它是如何工作的,让我们通过创建完全相同的 GUI 来重复相同的示例,然后创建一个新的 C++ 类(与以前相同),但是这次将其命名为VideoProcessor。 但是,这一次,在创建类之后,您无需从QThread继承它,而将其保留为QObject(原样)。 只需将以下成员添加到videoprocessor.h文件中:

    signals: 
      void inDisplay(QPixmap pixmap); 
      void outDisplay(QPixmap pixmap); 

   public slots: 
      void startVideo(); 
      void stopVideo(); 

  private: 
      bool stopped; 

signals与以前完全相同。 stopped是一个标志,我们将用来帮助我们停止视频,以使视频不会永远播放下去。 startVideostopVideo是我们用来启动和停止来自默认网络摄像头的视频处理的功能。 现在,我们可以切换到videoprocessor.cpp文件并添加以下代码块。 与以前非常相似,但明显的区别是我们不需要实现run函数,因为它不是QThread子类,并且我们按自己的喜好命名了函数:

    void VideoProcessor::startVideo() 
    { 
      using namespace cv; 
      VideoCapture camera(0); 
      Mat inFrame, outFrame; 
      stopped = false; 
      while(camera.isOpened() && !stopped) 
      { 
        camera >> inFrame; 
        if(inFrame.empty()) 
            continue; 

        bitwise_not(inFrame, outFrame); 

        emit inDisplay( 
          QPixmap::fromImage( 
             QImage( 
                  inFrame.data, 
                  inFrame.cols, 
                  inFrame.rows, 
                  inFrame.step, 
                  QImage::Format_RGB888) 
                    .rgbSwapped())); 

        emit outDisplay( 
           QPixmap::fromImage( 
             QImage( 
                 outFrame.data, 
                 outFrame.cols, 
                 outFrame.rows, 
                 outFrame.step, 
                 QImage::Format_RGB888) 
                    .rgbSwapped())); 
      }   
    } 

    void VideoProcessor::stopVideo() 
    { 
      stopped = true; 
    } 

现在我们可以在MainWindow类中使用它。 确保为VideoProcessor类添加include文件,然后将以下内容添加到MainWindow的私有成员部分:

    VideoProcessor *processor; 

现在,将以下代码段添加到mainwindow.cpp文件中的MainWindow构造器中:

    processor = new VideoProcessor(); 

    processor->moveToThread(new QThread(this)); 

    connect(processor->thread(), 
          SIGNAL(started()), 
          processor, 
          SLOT(startVideo())); 

   connect(processor->thread(), 
          SIGNAL(finished()), 
          processor, 
          SLOT(deleteLater())); 

   connect(processor, 
        SIGNAL(inDisplay(QPixmap)), 
        ui->inVideo, 
        SLOT(setPixmap(QPixmap))); 

   connect(processor, 
        SIGNAL(outDisplay(QPixmap)), 
        ui->outVideo, 
        SLOT(setPixmap(QPixmap))); 

   processor->thread()->start(); 

在前面的代码片段中,首先,我们创建了VideoProcessor的实例。 请注意,我们没有在构造器中分配任何父对象,并且还确保将其定义为指针。 当我们打算使用moveToThread函数时,这非常重要。 具有父对象的对象无法移到新线程中。 此代码段中的第二个非常重要的教训是,我们不应该直接调用VideoProcessorstartVideo函数,而只能通过将适当的信号连接到它来调用它。 在这种情况下,我们使用了自己线程的启动信号。 但是,您可以使用具有相同签名的任何其他信号。 剩下的全部都是关于连接的。

MainWindow析构器中,添加以下行:

    processor->stopVideo(); 
    processor->thread()->quit(); 
    processor->thread()->wait(); 

这是很不言自明的,但是,为了清楚起见,让我们在这里再做一个说明,那就是在这样的线程启动之后,必须通过调用quit函数来停止它,而且,不应包含任何运行循环或未决指令。 如果不满足这些条件之一,则在处理线程时将面临严重的问题。

线程同步工具

多线程编程通常需要维护线程之间的冲突和问题,这些冲突和问题是由于并行性以及底层操作系统负责照顾线程将在何时以及确切地运行多长时间的原因而简单产生的。 一个提供多线程功能的强大框架(例如 Qt 框架)还必须提供处理此类问题的方法,所幸的是,正如我们在本章中将学到的那样,它确实可以做到。

在本节中,我们将学习由多线程编程引起的可能问题以及 Qt 中可用于解决这些问题的现有类。 这些类通常称为线程同步工具。 线程同步是指以这样的方式处理和编程线程:它们使用简单易用的方式了解其他线程的状态,同时,它们可以继续完成自己的特定任务。

互斥体

如果您对本主题以及即将到来的有关线程同步工具的部分感到熟悉,那么您将很轻松地掌握所涵盖的主题,并且您将很快了解 Qt 中实现的相同工具的易用性; 否则,最好彻底,仔细地遵循这些部分。 因此,让我们从第一个线程同步工具开始。 通常,如果两个线程尝试同时访问同一对象(例如变量或类实例等),并且如果每个线程对对象的处理顺序很重要,那么有时生成的对象可能会与我们期望的有所不同。 让我们用一个例子来分解它,因为即使您完全遵循刚才提到的内容,它仍然可能会令人困惑。 假设一个线程一直在使用以下代码行(在QThread的重新实现的run函数中,或者从另一个线程在使用moveToThread函数的类中)始终读取名为imageMat类实例。):

    forever 
    { 
      image = imread("image.jpg"); 
    } 

forever宏是一个 Qt 宏(与for(;;)相同),可用于创建无限循环。 使用此类 Qt 宏有助于提高代码的可读性。

第二个不同的线程一直在修改该图像。 让我们假设像这样一个非常简单的图像处理任务(将图像转换为灰度然后调整其大小):

    forever 
    { 
       cvtColor(image, image, CV_BGR2GRAY); 
       resize(image, image, Size(), 0.5, 0.5); 
    } 

如果这两个线程同时运行,则在某个时候,可以在第二个线程的cvtColor之后和resize之前调用第一个线程的imread函数。 如果发生这种情况,我们将无法获得比输入图像大一半的灰度图像(如示例代码中所预期)。 我们无法用此代码来阻止它,因为在运行时在线程之间进行切换时,这完全取决于操作系统。 在多线程编程中,这是一种竞争条件问题,可以通过确保每个线程在访问和修改对象之前等待其轮换来解决。 该问题的解决方案称为访问序列化,在多线程编程中,通常使用互斥对象解决。

互斥锁只是一种保护和防止对象实例同时被多个线程访问的方法。 Qt 提供了一个名为QMutex的类(非常方便)来处理访问序列化,我们可以在前面的示例中非常轻松地使用它,如此处所示。 我们只需要确保Mat类存在QMutex实例即可。 由于我们的Mat类称为image,因此将其称为互斥锁imageMutex,那么我们将需要将该互斥锁锁定在访问图像的每个线程中,并在完成操作后将其解锁。 因此,对于第一个线程,我们将有以下内容:

    forever 
    { 
      imageMutex.lock(); 
      image = imread("image.jpg"); 
      imageMutex.unlock(); 
    } 

对于第二个线程,我们将具有以下代码块:

    forever 
    { 
      imageMutex.lock(); 
      cvtColor(image, image, CV_BGR2GRAY); 
      resize(image, image, Size(), 0.5, 0.5); 
      imageMutex.unlock(); 
    } 

这样,每当两个线程中的每个线程开始处理图像时,首先,它将使用lock函数锁定互斥锁。 如果简单地说,在过程的中间,操作系统决定切换到另一个线程,该线程也将尝试锁定互斥锁,但是由于互斥锁已被锁定,因此调用lock函数的新线程将被阻塞,直到第一个线程(称为锁)调用unlock为止。 从获取锁的钥匙的角度考虑它。 只有调用互斥量的lock函数的线程才能通过调用unlock函数将其解锁。 这样可以确保,只要一个线程正在访问一个对象,所有其他线程都应该简单地等待它完成!

从我们的简单示例中可能并不明显,但是在实践中,如果需要敏感对象的函数数量增加,则使用互斥可能会成为负担。 因此,在使用 Qt 时,最好使用QMutexLocker类来保护互斥锁。 如果我们回到前面的示例,则可以这样重写相同的代码:

    forever 
    { 
      QMutexLocker locker(&imageMutex); 
      image = imread("image.jpg"); 
    } 

    And for the second thread: 
    forever 
    { 
      QMutexLocker locker(&imageMutex); 
      cvtColor(image, image, CV_BGR2GRAY); 
      resize(image, image, Size(), 0.5, 0.5); 
    } 

通过将互斥量传递给它来构造QMutexLocker类时,该互斥量将被锁定,并且一旦QMutexLocker被销毁(对于超出范围的实例),该互斥量将被解锁。

读写锁

与互斥锁一样强大,它们缺乏某些功能,例如不同类型的锁。 因此,尽管它们对于访问序列化非常有用,但是它们不能有效地用于诸如读写序列化之类的情况,该情况基本上依赖于两种不同类型的锁:读写。 让我们再用一个例子来分解。 假设我们希望各种线程能够同时从一个对象(例如变量,类实例,文件等)读取,但是我们要确保只有一个线程可以修改(或写入) 该对象在任何给定时间。 对于这种情况,我们可以使用读写锁定机制,该机制基本上是增强的互斥体。 Qt 框架提供QReadWriteLock类,可以使用与QMutex类类似的方式,除了它提供用于读取的锁定函数(lockForRead)和用于写入的另一个锁定函数(lockForWrite)。 以下是每个lock函数的功能:

  • 如果在线程中调用lockForRead函数,其他线程仍可以调用lockForRead并出于读取目的访问敏感对象。 (通过敏感对象,我们指的是我们正在为其使用锁的对象。)
  • 另外,如果在线程中调用了lockForRead函数,则任何调用lockForWrite的线程都将被阻塞,直到该线程调用了解锁函数。
  • 如果在线程中调用了lockForWrite函数,则所有其他线程(无论是用于读取还是写入)都将被阻塞,直到该线程调用解锁为止。
  • 如果在一个线程中调用了lockForWrite函数,而先前的线程已经在其中设置了读锁定,则所有调用lockForRead的新线程将必须等待需要写锁定的线程。 因此,需要lockForWrite的线程将具有更高的优先级。

为了简化我们刚才提到的读写锁定机制的功能,可以说QReadWriteLock可用于确保多个读取器可以同时访问一个对象,而写入器将不得不等待读取器先完成。 另一方面,将只允许一个写者对该对象进行写操作。 并且,如果读者过多,为了保证作家不会永远等待,他们将获得更高的优先级。

现在,让我们看一下如何使用QReadWriteLock类的示例代码。 请注意,此处的lock变量具有QReadWriteLock类型,read_image函数是从对象读取的任意函数:

    forever 
    { 
       lock.lockForRead(); 
       read_image(); 
       lock.unlock(); 
    } 

类似地,在需要写入对象的线程中,我们将具有以下内容(write_image是写入对象的任意函数):

    forever 
    { 
     lock.lockForWrite(); 
     write_image(); 
     lock.unlock(); 
    } 

QMutex相似,在其中我们使用QMutexLocker来更轻松地处理lockunlock函数,我们可以使用QReadLockerQWriteLocker类相应地锁定和解锁QReadWriteLock。 因此,对于前面示例中的第一个线程,我们将具有以下代码行:

    forever 
    { 
      QReadLocker locker(&lock); 
      Read_image(); 
    }  

对于第二个,我们将需要以下代码行:

    forever 
    { 
      QWriteLocker locker(&lock); 
      write_image(); 
    } 

信号量

有时,在多线程编程中,我们需要确保多个线程可以相应地访问有限数量的相同资源。 例如,将用于运行程序的设备上的内存可能非常有限,因此我们希望需要大量内存的线程考虑到这一事实并根据可用的内存数量采取行动。 多线程编程中的此问题和类似问题通常通过使用信号量来解决。 信号量类似于增强的互斥锁,它不仅能够锁定和解锁,而且还能跟踪可用资源的数量。

Qt 框架提供了一个名为QSemaphore的类(足够方便)以在多线程编程中使用信号量。 由于信号量是根据可用资源的数量用于线程同步的,因此函数名称也比lockunlock函数更适合于此目的。 以下是QSemaphore类中的可用函数:

  • acquire:可用于获取特定数量的所需资源。 如果没有足够的资源,则线程将被阻塞,必须等待直到有足够的资源。
  • release:可用于释放特定数量的已使用且不再需要的资源。
  • available:可用于获取可用资源的数量。 如果我们希望我们的线程执行其他任务而不是等待资源,则可以使用此函数。

除了一个适当的例子,没有什么可以比这个更加清楚的了。 假设我们有100MB的可用内存空间供所有线程使用,并且每个线程需要X兆字节数来执行其任务,具体取决于线程,因此X在所有线程中都不相同,可以说它是使用将在线程中处理的图像大小或与此相关的任何其他方法来计算的。 对于当前的当前问题,我们可以使用QSemaphore类来确保我们的线程仅访问可用的内存空间,而不会访问更多。 因此,我们将在程序中创建一个信号量,如下所示:

    QSemaphore memSem(100); 

并且,在每个线程内部,在占用大量内存的过程之前和之后,我们将获取并释放所需的内存空间,如下所示:

    memSem.acquire(X); 
    process_image(); // memory intensive process 
    memSem.release(X); 

请注意,在此示例中,如果某个线程中的X大于100,则它将无法继续通过acquire,直到release函数调用(释放的资源)等于或大于该值。 acquire函数调用(获取的资源)。 这意味着可以通过调用release函数(其值大于获取的资源)来增加(创建)可用资源的数量。

等待条件

多线程编程中的另一个常见问题可能会发生,因为某个线程必须等待操作系统正在执行的线程以外的其他条件。 在这种情况下,如果很自然地线程使用了互斥锁或读写锁,则它可能会阻塞所有其他线程,因为轮到该线程运行并且正在等待某些特定条件。 人们会希望需要等待条件的线程在释放互斥锁或读写锁后进入睡眠状态,以便其他线程继续运行,并在满足条件时被另一个线程唤醒。

在 Qt 框架中,有一个名为QWaitCondition的类,专用于处理我们刚刚提到的此类问题。 此类可能需要等待某些条件的任何线程使用。 让我们通过一个简单的例子来进行研究。 假设有多个线程与Mat类一起使用(准确地说是一个图像),并且一个线程负责读取此图像(仅当它存在时)。 现在,还要假设另一个进程,程序或用户负责创建此图像文件,因此它可能暂时无法使用。 由于图像由多个线程使用,因此我们可能需要使用互斥锁以确保线程一次访问一个图像。 但是,如果图像仍然不存在,则读取器线程可能仍需要等待。 因此,对于阅读器线程,我们将具有以下内容:

    forever 
    { 
      mutex.lock(); 
      imageExistsCond.wait(&mutex); 
      read_image(); 
      mutex.unlock(); 
    } 

注意,在该示例中,mutex的类型为QMuteximageExistsCond的类型为QWaitCondition。 前面的代码段只是意味着锁定互斥锁并开始工作(读取图像),但是如果您必须等到图像存在后再释放互斥锁,以便其他线程可以继续工作。 这需要另一个负责唤醒阅读器线程的线程。 因此,我们将得到以下内容:

    forever 
    { 
      if(QFile::exists("image.jpg")) 
          imageExistsCond.wakeAll(); 
    } 

该线程只是一直在检查图像文件的存在,如果存在,它将尝试唤醒所有等待此等待条件的线程。 我们也可以使用wakeOne函数代替wakeAll函数,该函数只是试图唤醒一个正在等待等待条件的随机线程。 如果满足条件,我们只希望一个线程开始工作,这将很有用。

这样就结束了我们对线程同步工具(或原语)的讨论。 本节中介绍的类是 Qt 框架中最重要的类,它们与线程结合使用以处理线程同步。 确保检查 Qt 文档,以了解那些类中存在的其他功能,这些功能可用于进一步改善多线程应用的行为。 当编写这样的多线程应用时,或者换句话说,使用低级方法时,我们必须确保线程使用本节刚刚介绍的类以一种方式或另一种方式彼此了解。 另外,请务必注意,这些技术并不是解决线程同步的唯一可能方法,有时(随着程序的发展变得越来越复杂),您肯定需要混合使用这些技术,进行调整, 弯曲它们,甚至自己发明一些。

使用QtConcurrent的高级多线程

除了在上一节中学到的知识之外,Qt 框架还提供了用于创建多线程程序的高级 API,而无需使用线程同步工具(例如互斥锁,锁等)。 QtConcurrent名称空间或 Qt 框架中的 Qt 并发模块,提供了易于使用的功能,这些功能可用于创建多线程应用,换句话说,并发性,方法是使用最佳数量的数据处理数据列表。 适用于任何平台的线程。 在经历了QtConcurrent中的功能以及与其结合使用的类之后,这将变得非常清晰。 之后,我们还将处理实际示例,以了解 Qt Concurrent模块的功能以及如何利用它。

总体上,以下函数(及其稍有不同的变体)可用于使用高级QtConcurrent API 处理多线程:

  • filter:可以用来过滤列表。 该函数需要提供一个包含要过滤的数据的列表和一个过滤函数。 我们提供的过滤函数将应用于列表中的每个项目(使用最佳或自定义线程数),并且根据过滤器函数返回的值,该项目将被删除或保留在列表中。
  • filtered:它与filter的工作方式相同,除了它返回过滤的列表而不是原地更新输入列表。
  • filteredReduced:其工作方式类似于filtered函数,但它还将第二个函数应用于通过过滤器的每个项目。
  • map:可用于将特定函数应用于列表中的所有项目(使用最佳或自定义线程数)。 很明显,类似于filter函数,map函数也需要提供一个列表和一个函数。
  • mapped:与map的工作方式相同,除了它返回结果列表而不是原地更新输入列表。
  • mappedReduced:此函数的作用类似于mapped函数,但它还将第二个函数应用于除第一个映射函数之后的每个项目。
  • run:此函数可用于在单独的线程中轻松执行函数。

每当我们谈论 Qt Concurrent 模块中的返回值时,我们实际上的意思是异步计算的结果。 原因很简单,因为 Qt Concurrent 在单独的线程中启动所有计算,并且无论您使用QtConcurrent名称空间中的哪个函数,它们都会立即返回给调用者,并且结果只有在计算完成之后才可用。 这是通过使用所谓的 Future 变量来完成的,或者使用 Qt 框架中的QFuture及其附属类来实现。

QFuture类可用于检索由QtConcurrent命名空间中提到的功能之一启动的计算结果; 通过暂停,恢复和类似方法控制其工作; 并监视该计算的进度。 为了能够使用 Qt 信号和插槽对QFuture类进行更灵活的控制,我们可以使用一个名为QFutureWatcher的便捷类,该类包含可以通过使用小部件更轻松地监视计算的信号和插槽。 例如进度条(QProgressBarQProgressDialog)。

让我们总结并阐明在实际示例应用中提到的所有内容。 在不描述QtConcurrent命名空间功能的情况下,不可能描述QFuture及其关联类的使用方式,这只能通过一个示例来实现:

  1. 让我们开始使用 Qt Creator 创建一个 Qt Widgets 应用项目,并将其命名为ConcurrentCV。 我们将创建一个使用 Qt Concurrent 模块处理多个图像的程序。 为了更加专注于程序的多线程部分,该过程将非常简单。 我们将读取每个图像的日期和时间,并将其写在图像的左上角。

  2. 创建项目后,通过在ConcurrentCV.pro文件中添加以下行,将 OpenCV 框架添加到项目中:

        win32: { 
          include("c:/dev/opencv/opencv.pri") 
        } 

        unix: !macx{ 
         CONFIG += link_pkgconfig 
          PKGCONFIG += opencv 
        } 

        unix: macx{ 
         INCLUDEPATH += /usr/local/include 
         LIBS += -L"/usr/local/lib" \ 
         -lopencv_world 
        } 
  1. 为了能够在 Qt 项目中使用 Qt 并发模块和QtConcurrent命名空间,必须通过添加以下行来确保在.pro文件中指定了它:
         QT += concurrent 
  1. 现在,我们需要为应用中需要的几个函数编写代码。 第一个是在用户选择的文件夹中获取图像列表(*.jpg*.png文件已足够)。 为此,请将以下行添加到mainwindow.h私有成员中:
        QFileInfoList getImagesInFolder(); 
  1. 不用说,QFileInfoList必须在mainwindow.h文件的包含列表中。 实际上,QFileInfoList是包含QFileInfo元素的QList,可以使用QDir类的entryInfoList函数对其进行检索。 因此,将其实现添加到mainwindow.cpp,如此处所示。 请注意,仅出于简单起见,我们仅使用文件创建日期,而不处理图像EXIF数据以及使用相机拍摄照片的原始日期或时间:
        QFileInfoList MainWindow::getImagesInFolder() 
        { 
           QDir dir(QFileDialog::getExistingDirectory(this, 
           tr("Open Images Folder"))); 
             return dir.entryInfoList(QStringList() 
             << "*.jpg" 
             << "*.png", 
             QDir::NoDotAndDotDot | QDir::Files, 
             QDir::Name); 
        }
  1. 我们需要的下一个函数称为addDateTime。 我们可以在类之外定义和实现它,这是稍后在调用QtConcurrent.map函数时将使用的函数。 在mainwindow.h文件中定义如下:
        void addDateTime(QFileInfo &info); 
  1. 将其实现添加到mainwindow.cpp文件中,如下所示:
       void addDateTime(QFileInfo &info) 
       { 
         using namespace cv; 
         Mat image = imread(info.absoluteFilePath().toStdString()); 
         if(!image.empty()) 
         { 
          QString dateTime = info.created().toString(); 
          putText(image, 
            dateTime.toStdString(), 
            Point(30,30) , // 25 pixels offset from the corner 
            FONT_HERSHEY_PLAIN, 
            1.0, 
            Scalar(0,0,255)); // red 
          imwrite(info.absoluteFilePath().toStdString(), 
                image); 
         } 
       } 
  1. 现在打开mainwindow.ui文件,并在“设计”模式下创建类似于以下内容的 UI。 如下所示,loopBtn小部件是带有循环文本处理的QPushButton,而concurrentBtn小部件是同时带有文本处理的QPushButton。 为了能够比较使用多个线程或使用简单循环在单个线程中完成此任务的结果,我们将实现这两​​种情况,并测量每种情况下完成该任务所花费的时间。 另外,在继续执行下一步之前,请确保将progressBar小部件的value属性设置为零。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-49yk81aS-1681870063156)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/5cff07d1-6d24-46c3-8f3f-9c8a6736a521.png)]

  1. 剩下要做的唯一事情就是使用QtConcurrent(多线程)在一个循环中(单线程)执行该过程。 因此,为loopBtnpressed插槽编写以下代码:
        void MainWindow::on_loopBtn_pressed() 
        { 
          QFileInfoList list = getImagesInFolder(); 

          QElapsedTimer elapsedTimer; 
          elapsedTimer.start(); 

          ui->progressBar->setRange(0, list.count()-1); 
          for(int i=0; i<list.count(); i++) 
          { 
           addDateTime(list[i]); 
           ui->progressBar->setValue(i); 
           qApp->processEvents(); 
          } 

          qint64 e = elapsedTimer.elapsed(); 

          QMessageBox::information(this, 
          tr("Done!"), 
          QString(tr("Processed %1 images in %2 milliseconds")) 
            .arg(list.count()) 
            .arg(e)); 
        }

这很简单,而且绝对没有效率,我们稍后会学习。 此代码仅循环遍历文件列表,并将它们传递给addDateTime函数,该函数仅读取图像并添加日期时间戳并覆盖图像。

  1. 最后,为concurrentBtn小部件的pressed插槽添加以下代码:
        void MainWindow::on_concurrentBtn_pressed() 
        { 
          QFileInfoList list = getImagesInFolder(); 
          QElapsedTimer elapsedTimer; 
          elapsedTimer.start(); 
          QFuture<void> future = QtConcurrent::map(list, addDateTime); 
          QFutureWatcher<void> *watcher =  
            new QFutureWatcher<void>(this); 
          connect(watcher, 
              SIGNAL(progressRangeChanged(int,int)), 
              ui->progressBar, 
              SLOT(setRange(int,int))); 
          connect(watcher, 
              SIGNAL(progressValueChanged(int)), 
              ui->progressBar, 
              SLOT(setValue(int))); 
          connect(watcher, 
              &QFutureWatcher<void>::finished, 
              [=]() 
          { 
            qint64 e = elapsedTimer.elapsed(); 
            QMessageBox::information(this, 
               tr("Done!"), 
           QString(tr("Processed %1 images in %2 milliseconds")) 
            .arg(list.count()) 
            .arg(e)); 
          }); 
          connect(watcher, 
            SIGNAL(finished()), 
            watcher, 
            SLOT(deleteLater())); 
          watcher->setFuture(future); 
        }

在查看前面的代码并查看其工作方式之前,请尝试运行该应用并将两个按钮与测试图像文件夹一起使用。 尤其是在多核处理器上,性能差异如此之大,以至于不需要进行任何精确的测量。 在我使用的大约 50 张随机图像的测试机上(如今是中等级别的系统),并发(多线程)版本完成这项工作的速度至少快了三倍。 有多种方法可以使它更加高效,例如设置 Qt Concurrent 模块创建和使用的线程数,但是在此之前,让我们看看代码的作用。

起始行与之前相同,但是这次,我们而不是循环遍历文件列表,而是将列表传递给QtConcurrent::map函数。 然后,此函数自动启动多个线程(使用默认线程数和理想线程数,这也是可调的),并将addDateTime函数应用于列表中的每个条目。 项的处理顺序是完全不确定的,但是结果将是相同的。 然后将结果传递给QFuture<void>,该实例由QFutureWatcher<void>实例监视。 如前所述,QFutureWatcher类是监视来自QtConcurrent的计算的便捷方式,该计算已分配给QFuture类。 注意,在这种情况下,QFutureWatcher被定义为指针,并在处理完成时稍后删除。 原因是QFutureWatcher在整个过程继续进行期间必须保持活动状态,并且只有在计算完成后才能删除。 因此,首先完成QFutureWatcher的所有必需连接,然后相应地设置其将来变量。 重要的是要确保在建立所有连接后设置未来。 使用QtConcurrent进行多线程计算所需的全部内容也以正确的方式向 GUI 发送信号。

请注意,您还可以在全范围或全局范围内定义QFuture,然后使用其线程控制功能轻松控制QtConcurrent运行的计算。 QFuture包含以下(不言自明的)函数,可用于控制计算:

  • pause
  • resume
  • cancel

您还可以使用以下函数(同样,由于命名而非常不言自明)来检索计算状态:

  • isStarted
  • isPaused
  • isRunning
  • isFinished
  • isCanceled

至此,我们对前面的代码进行了回顾。 如您所见,只要您了解结构以及需要传递和连接的内容,使用QtConcurrent就非常容易,这就是应该的方式。

使用以下函数设置QtConcurrent函数的最大线程数:

QThreadPool::globalInstance()->setMaxThreadCount(n)
在我们的示例案例中尝试一下,看看改变线程数如何影响处理时间。 如果您使用不同数量的线程,则会注意到更多的线程并不一定意味着更高的性能或更快的代码,这就是为什么总有理想的线程数取决于处理器和处理器的原因。 其他与系统相关的规格。

我们可以类似的方式使用QtConcurrent过滤器和其他功能。 例如,对于过滤器函数,我们需要定义一个为每个项目返回布尔值的函数。 假设我们希望前面的示例应用跳过早于某个日期(2015 年之前)的图像,并将其从文件列表中删除,然后我们可以像这样定义过滤器函数:

    bool filterImage(QFileInfo &info) 
    { 
      if(info.created().date().year() < 2015) 
         true; 
      else 
        false; 
    } 

然后调用QtConcurrent来过滤我们的列表,如下所示:

    QtConcurrent::filter(list, filterImage); 

在这种情况下,我们需要将过滤后的结果传递给map函数,但是有一个更好的方法,那就是调用filteredReduced函数,如下所示:

    QtConcurrent::filteredReduced(list, filterImage, addDateTime); 

请注意,filteredReduced函数返回QFuture<T>结果,其中T与输入列表的类型相同。 与以前不同的是,我们仅收到适合监视计算进度的QFuture<void>,而QFuture<T>也包含结果列表。 请注意,由于我们并未真正修改列表中的单个元素(相反,我们正在更新文件),因此我们只能观察列表中元素数量的变化,但是如果我们尝试通过更新Mat类或QImage类的列表(或与此相关的任何其他变量),然后我们将观察到各个项也根据reduce函数中的代码进行了更改。

总结

不能说这就是谈论多线程和并行编程的全部内容,但是可以公平地说,我们涵盖了一些最重要的主题,可以帮助您编写多线程和高效的计算机视觉。 应用(或任何其他应用)。 您学习了如何对QThread进行子类化以创建执行特定任务的新线程类,或者如何使用moveToThread函数将负责复杂且耗时的计算的对象移动到另一个线程中。 您还了解了一些最重要的低级多线程原语,例如互斥体,信号量等。 到目前为止,您应该完全意识到由于在我们的应用中实现和使用多个线程而可能引起的问题,以及这些问题的解决方案。 如果您认为仍然需要练习以确保您熟悉所有提出的概念,那么您肯定对所有主题都给予了充分的关注。 多线程可能是一种困难且复杂的方法,但是如果您花大量时间练习不同的可能的多线程方案,那么最终还是值得的。 例如,您可以尝试将任务划分为之前编写的程序(或在网上,书中或其他地方看到的程序),然后将其转换为多线程应用。

在第 9 章,“视频分析”中,我们会将您在本章中学到的内容与之前的各章结合起来,并以此为基础,深入研究视频处理主题。 您将了解如何从摄像机或文件中跟踪视频中的运动对象,检测视频中的运动以及更多主题,所有这些都需要处理连续的帧并保留从先前帧中计算出的内容。 换句话说,计算不仅取决于图像,而且还取决于该图像(及时)。 因此,我们将使用线程,并使用您在本章中学习的任何方法来实现您将在下一章中学习的计算机视觉算法。

九、视频分析

除了本书到目前为止所看到的所有内容之外,计算机视觉的故事还有另一面,它涉及视频,摄像机以及输入帧的实时处理。 它是最受欢迎的计算机视觉主题之一,并且有充分的理由,因为它可以为有生命的机器或设备供电,这些机器或设备可以监视周围环境中是否存在感兴趣的对象,运动,图案,颜色等。 我们已经了解的所有算法和类,尤其是在第 6 章,“OpenCV 中的图像处理”和第 7 章,“特征和描述符”只能用于单个图像,因此,由于相同的原因,它们可以以完全相同的方式轻松地应用于单个视频帧。 我们只需要确保将单个帧正确地读取(例如,使用cv::VideoCapture类)到cv::Mat类实例中,然后作为单个图像传递到这些函数中即可。 但是,在处理视频以及视频时,我们指的是来自网络的视频,摄像机,视频文件等,有时我们需要通过处理特定时间段内的连续视频帧获得的结果。 这意味着结果不仅取决于当前从视频中获取的图像,还取决于之前获取的帧。

在本章中,我们将学习 OpenCV 中一些最重要的算法和类,这些算法和类可用于连续帧。 因此,视频。 我们将从学习这些算法使用的一些概念开始,例如直方图和反投影图像,然后通过使用示例并获得动手经验来更深入地研究每种算法。 我们将学习如何使用臭名昭著的 MeanShift 和 CamShift 算法进行实时对象跟踪,并且将继续进行视频中的运动分析。 我们将在本章中学到的大多数内容都与 OpenCV 框架中的视频分析模块(简称为video)有关,但我们还将确保遍历该模块所需的其他模块中的任何相关主题。 为了有效地遵循本章中的主题,尤其是直方图和反投影图像,这对于理解本章中涉及的视频分析主题至关重要。 背景/前景检测也是我们将在本章中学习的最重要主题之一。 通过结合使用这些方法,您将能够有效地处理视频以检测和分析运动,基于视频的颜色隔离视频帧中的零件或片段,或者使用现有的 OpenCV 算法以一种或另一种方式处理它们以进行图像处理。

同样,基于我们从第 8 章,“多线程”中学到的知识,我们将使用线程来实现在本章中学习的算法。 这些线程将独立于任何项目类型。 无论它是独立的应用,库,插件等,您都可以简单地包含和使用它们。

本章将涵盖以下主题:

  • 直方图以及如何提取,使用或可视化它们
  • 图像反投影
  • MeanShift 和 CamShift 算法
  • 背景/前景检测和运动分析

了解直方图

如本章介绍部分所述,计算机视觉中的一些概念在处理视频处理以及我们将在本章稍后讨论的算法时特别重要。 这些概念之一是直方图。 由于了解直方图对于理解大多数视频分析主题至关重要,因此在继续下一个主题之前,我们将在本节中详细了解它们。 直方图通常被称为表示数据分布的一种方式。 这是一个非常简单和完整的描述,但让我们也描述它在计算机视觉方面的含义。 在计算机视觉中,直方图是图像中像素值分布的图形表示。 例如,在灰度图像中,直方图将是表示包含灰度中每个可能强度(0 到 255 之间的值)的像素数的图表。 在 RGB 彩色图像中,它将是三个图形,每个图形代表包含所有可能的红色,绿色或蓝色强度的像素数。 请注意,像素值不一定表示颜色或强度值。 例如,在转换为 HSV 色彩空间的彩色图像中,其直方图将包含色相,饱和度和值数据。

OpenCV 中的直方图是使用calcHist函数计算的,并存储在Mat类中,因为它们可以存储为数字数组,可能具有多个通道。 calcHist函数需要以下参数来计算直方图:

  • images或输入图像是我们要为其计算直方图的图像。 它应该是cv::Mat类的数组。
  • nimages是第一个参数中的图像数量。 请注意,您还可以为第一个参数传递cv::Mat类的std::vector,在这种情况下,您可以省略此参数。
  • channels是一个数组,其中包含将用于计算直方图的通道的索引号。
  • mask可用于遮盖图像,以便仅使用部分输入图像来计算直方图。 如果不需要遮罩,则可以传递一个空的Mat类,否则,我们需要提供一个单通道Mat类,对于应遮罩的所有像素,该类包含零,对于计算直方图时应考虑的所有像素,包含非零值。
  • hist是输出直方图。 这应该是Mat类,并且在函数返回时将用计算出的直方图填充。
  • dims是直方图的维数。 它可以包含一个介于 1 到 32 之间的值(在当前的 OpenCV 3 实现中)。 我们需要根据用于计算直方图的通道数进行设置。
  • histSize是一个数组,其中包含每个维度中直方图的大小,即所谓的箱子大小。 直方图中的合并是指在计算直方图时将相似值视为相同值。 我们将在后面的示例中看到它的确切含义,但现在,我们只需提及直方图的大小与其箱数相同的事实就足够了。
  • ranges是一个数组数组,其中包含每个通道的值范围。 简而言之,它应该是一个数组,其中包含一对值,用于通道的最小和最大可能值。
  • uniform是一个布尔值标志,它决定直方图是否应该统一。
  • accumulate是布尔值标志,它决定在计算直方图之前是否应清除该直方图。 如果我们要更新先前计算的直方图,这可能非常有用。

现在,让我们来看几个示例如何使用此函数。 首先,为了方便使用,我们将计算灰度图像的直方图:

    int bins = 256; 
    int channels[] = {0}; // the first and the only channel 
    int histSize[] = { bins }; // number of bins 

    float rangeGray[] = {0,255}; // range of grayscale 
    const float* ranges[] = { rangeGray }; 

    Mat histogram; 

    calcHist(&grayImg, 
       1, // number of images 
       channels, 
       Mat(), // no masks, an empty Mat 
       histogram, 
       1, // dimensionality 
       histSize, 
       ranges, 
       true, // uniform 
       false // not accumulate 
    ); 

在前面的代码中,grayImgMat类中的灰度图像。 图像数量仅为一个,并且channels索引数组参数仅包含一个值(对于第一个通道为零),因为我们的输入图像是单通道和灰度。 dimensionality也是一个,其余参数与它们的默认值相同(如果省略)。

执行完前面的代码后,我们将在histogram变量内获取生成的灰度图像直方图。 它是具有256行的单通道单列Mat类,每行代表像素值与行号相同的像素数。 我们可以使用以下代码将Mat类中存储的每个值绘制为图形,并且输出将以条形图的形式显示我们的直方图:

    double maxVal = 0; 
    minMaxLoc(histogram, 
      Q_NULLPTR, // don't need min 
      &maxVal, 
      Q_NULLPTR, // don't need index min 
      Q_NULLPTR // don't need index max 
    ); 

    outputImage.create(640, // any image width 
      360, // any image height 
      CV_8UC(3)); 

    outputImage = Scalar::all(128); // empty grayish image 

    Point p1(0,0), p2(0,outputImage.rows-1); 
    for(int i=0; i<bins; i++) 
    { 
      float value = histogram.at<float>(i,0); 
      value = maxVal - value; // invert 
      value = value / maxVal * outputImage.rows; // scale 
      p1.y = value; 
      p2.x = float(i+1) * float(outputImage.cols) / float(bins); 
      rectangle(outputImage, 
        p1, 
        p2, 
        Scalar::all(0), 
        CV_FILLED); 
      p1.x = p2.x; 
    } 

这段代码起初可能看起来有些棘手,但实际上它很简单,它基于以下事实:直方图中的每个值都需要绘制为矩形。 对于每个矩形,使用value变量和图像宽度除以箱数(即histSize)来计算左上角的点。 在示例代码中,我们简单地将最大可能值分配给了箱子(即 256),这导致了直方图的高分辨率可视化,因为条形图图中的每个条形图都会代表灰度级的一个像素强度 。

请注意,从这个意义上说,分辨率不是指图像的分辨率或质量,而是指构成条形图的最小块数的分辨率。

我们还假定输出可视化高度将与直方图的峰值(最高点)相同。 如果我们在下图左侧所示的灰度图像上运行这些代码,则所得的直方图将是右侧所示的直方图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ihMjNsEx-1681870063156)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/9c8c00f6-3e67-4531-a688-d8d6239e18f4.png)]

让我们解释输出直方图的可视化,并进一步说明我们在代码中使用的参数通常具有什么作用。 首先,每个条形从左到右是指具有特定灰度强度值的像素数。 最左边的条(非常低)指的是绝对黑色(强度值为零),最右边的条指的是绝对白色(255),中间的所有条指的是不同的灰色阴影。 注意最右边的小跳。 这实际上是由于输入图像的最亮部分(左上角)而形成的。 每个条形的高度除以最大条形值,然后缩放以适合图像高度。

我们还要看看bins变量的作用。 降低bins将导致强度分组在一起,从而导致较低分辨率的直方图被计算和可视化。 如果运行bins值为20的相同代码,则将得到以下直方图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ss4wBVjI-1681870063156)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/f8da1614-c50a-4a16-9eec-7bb6a56ced29.png)]

如果我们需要一个简单的图形而不是条形图视图,则可以在上一个代码末尾的绘图循环中使用以下代码:

    Point p1(0,0), p2(0,0); 
    for(int i=0; i<bins; i++) 
    { 
      float value = histogram.at<float>(i,0); 
      value = maxVal - value; // invert 
      value = value / maxVal * outputImage.rows; // scale 
      line(outputImage, 
         p1, 
         Point(p1.x,value), 
         Scalar(0,0,0)); 
      p1.y = p2.y = value; 
      p2.x = float(i+1) * float(outputImage.cols) / float(bins); 
      line(outputImage, 
         p1, p2, 
         Scalar(0,0,0)); 
      p1.x = p2.x; 
    } 

如果再次使用256bins值,将导致以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-rKUJunQF-1681870063156)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/f935e6f5-d217-47b7-beec-da255ba24345.png)]

同样,我们可以计算和可视化彩色(RGB)图像的直方图。 我们只需要为三个单独的通道修改相同的代码即可。 为了做到这一点,首先我们需要将输入图像划分为其基础通道,然后为每个图像计算直方图,就好像它是单通道图像一样。 这是如何拆分图像以获取三个Mat类,每个类代表一个通道:

    vector<Mat> planes; 
    split(inputImage, planes); 

现在,您可以在循环中使用planes[i]或类似的东西,并将每个通道视为图像,然后使用前面的代码示例来计算和可视化其直方图。 如果我们使用其自己的颜色可视化每个直方图,结果将是类似的结果(生成此直方图的图像是我们在整本书中使用的上一个示例的彩色图像):

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NFzo0bew-1681870063156)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/42628510-9277-43d3-952a-7be9c6e010c4.png)]

同样,结果的内容几乎可以像以前一样解释。 前面的直方图图像显示了颜色如何分布在 RGB 图像的不同通道中。 但是,除了获取像素值分布的信息以外,我们如何真正使用直方图? 下一节将介绍直方图可用于修改图像的方式。

了解图像反投影

除了直方图中的视觉信息外,它还有更重要的用途。 这称为直方图的反投影,可用于使用其直方图来修改图像,或者正如我们将在本章稍后看到的那样,在图像中定位感兴趣的对象。 让我们进一步分解。 正如我们在上一节中了解到的,直方图是图像上像素数据的分布,因此如果我们以某种方式修改所得的直方图,然后将其重新应用于源图像(就好像它是像素值的查找表) ,则生成的图像将被视为反投影图像。 重要的是要注意,反投影图像始终是单通道图像,其中每个像素的值都是从直方图中的相应像素中提取的。

让我们将其视为另一个示例。 首先,这是在 OpenCV 中如何计算反投影:

    calcBackProject(&image, 
      1, 
      channels, 
      histogram, 
      backprojection, 
      ranges); 

calcBackProject函数的使用方式与calcHist函数非常相似。 您只需要确保传递一个附加的Mat类实例即可获得图像的反投影。 由于在背投图像中,像素值是从直方图中获取的,因此它们很容易超出标准灰度范围,该范围在0255(含)之间。 这就是为什么我们需要在计算反投影之前相应地标准化直方图的结果。 方法如下:

    normalize(histogram, 
      histogram, 
      0, 
      255, 
      NORM_MINMAX); 

normalize函数将缩放直方图中的所有值以适合提供的最小值和最大值,分别为0255。 只是重复一次,必须在calcBackProject之前调用此函数,否则,您将在反投影图像中产生溢出的数据,如果您尝试使用[[ imshow函数。

如果我们在查看反投影图像时未对生成它的直方图进行任何修改,那么在我们的示例情况下,我们将获得以下输出图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mnt3wW5D-1681870063157)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/16eeec23-5149-4621-8a39-974a80cce617.png)]

先前图像中每个像素的强度与包含该特定值的图像中像素的数量有关。 例如,请注意反投影图像的右上最暗部分。 与较亮的区域相比,该区域包含的像素值很少。 换句话说,明亮的区域包含的像素值在图像中以及图像的各个区域中都存在得多。 再说一遍,在处理图像和视频帧时如何使用呢?

本质上,反投影图像可用于为计算机视觉操作获取有用的遮罩图像。 到目前为止,我们还没有在 OpenCV 函数中真正使用掩码参数(并且它们存在于大多数函数中)。 让我们从使用前面的反投影图像的示例开始。 我们可以使用简单的阈值修改直方图,以获得用于过滤掉不需要的图像部分的遮罩。 假设我们想要一个可用于获取包含最暗值(例如,从039像素值)的像素的遮罩。 为此,首先我们可以通过将第一个40元素(只是最暗值的阈值,可以将其设置为任何其他值或范围)设置为灰度范围内的最大可能值来修改直方图(255),然后将其余的取到最小可能值(零),然后计算反投影图像。 这是一个例子:

    calcHist(&grayImg, 
       1, // number of images 
       channels, 
       Mat(), // no masks, an empty Mat 
       histogram, 
       1, // dimensionality 
       histSize, 
       ranges); 

    for(int i=0; i<histogram.rows; i++) 
    { 
      if(i < 40) // threshold 
        histogram.at<float>(i,0) = 255; 
      else 
        histogram.at<float>(i,0) = 0; 
    } 

    Mat backprojection; 
    calcBackProject(&grayImg, 
       1, 
       channels, 
       histogram, 
       backprojection, 
       ranges); 

通过运行前面的示例代码,我们将在backprojection变量内获得以下输出图像。 实际上,这是一种阈值技术,可为使用 OpenCV 的任何计算机视觉处理获得合适的遮罩,以隔离图像中最暗的区域。 我们使用此示例代码获得的遮罩可以传递到任何接受遮罩的 OpenCV 函数中,这些遮罩用于对与遮罩中白色位置对应的像素执行操作,而忽略与黑位置对应的像素:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jlaGOKQh-1681870063157)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/f5a7e084-48d7-4258-895c-6ca18ba0694b.png)]

类似于我们刚刚学习的阈值化方法的另一种技术可以用于遮盖图像中包含特定颜色的区域,因此可以将其仅用于处理(例如修改颜色)图像的某些部分,甚至跟踪图像的某些部分。 具有特定颜色的对象,我们将在本章稍后学习。 但是在此之前,让我们首先了解 HSV 颜色空间的直方图(使用色相通道)以及如何隔离具有特定颜色的图像部分。 让我们通过一个例子来进行研究。 假设您需要查找图像中包含特定颜色的部分,例如下图中的红玫瑰:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lZJeRQu5-1681870063157)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/2156c5d6-66d3-46db-b073-da4353586ba6.png)]

您不能根据阈值简单地滤除红色通道(在 RGB 图像中),因为它可能太亮或太暗,但仍然可以是红色的其他阴影。 另外,您可能需要考虑与红色过于相似的颜色,以确保您尽可能准确地获得玫瑰。 使用色调饱和度HSV)颜色空间,其中颜色保留在单个通道(色相或 H 通道)中,可以最好地处理这种情况以及需要处理颜色的类似情况。 这可以通过使用 OpenCV 进行示例实验来证明。 只需尝试在新应用中运行以下代码段即可。 它可以是控制台应用或小部件,没关系:

    Mat image(25, 180, CV_8UC3); 
    for(int i=0; i<image.rows; i++) 
    { 
      for(int j=0; j<image.cols; j++) 
      { 
        image.at<Vec3b>(i,j)[0] = j; 
        image.at<Vec3b>(i,j)[1] = 255; 
        image.at<Vec3b>(i,j)[2] = 255; 
      } 
    } 
    cvtColor(image,image,CV_HSV2BGR); 
    imshow("Hue", image); 

请注意,我们仅更改了三通道图像中的第一个通道,其值从0更改为179。 这将导致以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3a3TDJNh-1681870063157)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/c1840bf1-92e3-4af5-9f23-6bc3b7f8134b.png)]

如前所述,其原因是这样的事实,即色调是造成每个像素颜色的原因。 另一方面,饱和度和值通道可用于获得相同颜色的较亮(使用饱和度通道)和较暗(使用值通道)变化。 请注意,在 HSV 颜色空间中,与 RGB 不同,色相是介于 0 到 360 之间的值。这是因为色相被建模为圆形,因此,每当其值溢出时,颜色就会回到起点。 如果查看上一张图像的开始和结尾,这两个都是红色,则很明显,因此 0 或 360 附近的色相值必须是带红色的颜色。

但是,在 OpenCV 中,色相通常会除以 2 以容纳 8 位(除非我们为像素数据使用 16 位或更多位),因此色相的值可以在0180之间变化。 如果返回上一个代码示例,您会注意到在Mat类的列上,色相值从0设置为180,这将导致我们的色谱输出图像。

现在,让我们使用我们刚刚学到的东西创建一个颜色直方图,并使用它来获取背投图像以隔离我们的红玫瑰。 为了达到这个目的,我们甚至可以使用一段简单的代码将其变成蓝玫瑰,但是正如我们将在本章稍后学习的那样,该方法与 MeanShift 和 CamShift 算法结合使用来跟踪对象, 有特定的颜色。 我们的直方图将基于图像的 HSV 版本中的颜色分布或色相通道。 因此,我们需要首先使用以下代码将其转换为 HSV 颜色空间:

    Mat hsvImg; 
    cvtColor(inputImage, hsvImg, CV_BGR2HSV); 

然后,使用与上一个示例完全相同的方法来计算直方图。 这次(在可视化方面)的主要区别在于,由于直方图是颜色分布,因此直方图还需要显示每个垃圾箱的颜色,否则输出将难以解释。 为了获得正确的输出,这次我们将使用 HSV 到 BGR 的转换来创建一个包含所有箱子的颜色值的缓冲区,然后相应地填充输出条形图中的每个条形。 这是用于在计算出色相通道直方图(或换句话说就是颜色分布图)之后将其正确可视化的源代码:

    Mat colors(1, bins, CV_8UC3); 
    for(int i=0; i<bins; i++) 
    { 
      colors.at<Vec3b>(i) =  
      Vec3b(saturate_cast<uchar>( 
        (i+1)*180.0/bins), 255, 255); 
    } 
    cvtColor(colors, colors, COLOR_HSV2BGR); 

    Point p1(0,0), p2(0,outputImage.rows-1); 
    for(int i=0; i<ui->binsSpin->value(); i++) 
    { 
      float value = histogram.at<float>(i,0); 
      value = maxVal - value; // invert 
      value = value / maxVal * outputImage.rows; // scale 
      p1.y = value; 
      p2.x = float(i+1) * float(outputImage.cols) / float(bins); 
      rectangle(outputImage, 
       p1, 
       p2, 
       Scalar(colors.at<Vec3b>(i)), 
       CV_FILLED); 
      p1.x = p2.x; 
    } 

正如我们在前面的代码示例中看到的,maxVal是使用minMaxLoc函数从直方图数据中计算出来的。 bins只是箱子的数量(或直方图大小),在这种情况下不能高于180; 众所周知,色相只能在0179之间变化。 其余部分几乎相同,除了设置图形中每个条形的填充颜色值。 如果我们在示例玫瑰图像中使用最大箱子大小(即180)执行上述代码,则将获得以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-43duk1yk-1681870063157)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/ae8bd9fe-af9d-4b03-b0a3-17633dd9ef46.png)]

在此直方图中,基本上所有具有色相精度(八位)的可能颜色都在直方图中考虑,但是我们可以通过减小箱子大小来进一步简化此操作。 24的箱子大小足够小,可以简化并将相似的颜色分组在一起,同时提供足够的精度。 如果将箱子大小更改为24,则会得到以下输出:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IW4P7evg-1681870063158)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/cd5dbeca-35be-4284-90b6-9a36eaa95662.png)]

通过查看直方图,可以明显看出直方图中24条的前两个(左起)和后两个条是最带红色的颜色。 就像以前一样,我们将简单地限制其他所有内容。 这是如何做:

    for(int i=0; i<histogram.rows; i++) 
    { 
      if((i==0) || (i==22) || (i==23)) // filter 
        histogram.at<float>(i,0) = 255; 
      else 
        histogram.at<float>(i,0) = 0; 
    } 

一个好的实践案例是创建一个用户界面,该界面允许选择直方图中的箱子并将其过滤掉。 您可以根据自己到目前为止所学的知识,通过使用QGraphicsSceneQGraphicsRectItem绘制条形图和直方图来进行此操作。 然后,您可以启用项目选择,并确保在按下Delete按钮时,条被删除并因此被滤除。

在简单阈值之后,我们可以使用以下代码来计算反投影。 请注意,由于我们的直方图是一维直方图,因此仅当输入图像也是单通道时,才可以使用反向投影重新应用它。 这就是为什么我们首先需要从图像中提取色相通道的原因。 mixChannels函数可用于将通道从一个Mat类复制到另一个。 因此,我们可以使用相同的函数将色相通道从 HSV 图像复制到单通道Mat类中。 仅需要为mixChannels函数提供源和目标Mat类(仅具有相同的深度,不一定是通道),源和目标图像的数量以及一对整数(在以下代码的fromto数组中),用于确定源通道索引和目标通道索引:

    Mat hue; 
    int fromto[] = {0, 0}; 
    hue.create(hsvImg.size(), hsvImg.depth()); 
    mixChannels(&hsvImg, 1, &hue, 1, fromto, 1); 
    Mat backprojection; 
    calcBackProject(&hue, 
       1, 
       channels, 
       histogram, 
       backprojection, 
       ranges); 

在将其转换为 RGB 颜色空间后,使用imshow或 Qt Widget 在输出中直接显示背投图像,您将在玫瑰图像示例中看到我们的红色微调完美遮罩:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-VAHev0SH-1681870063158)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/63471ce6-fa9e-47c8-b8e2-1c90103e7489.png)]

现在,如果我们将色相通道中的值偏移正确的数量,则可以从红色玫瑰中得到蓝色玫瑰; 不仅是相同的静态蓝色,而且在所有相应像素中具有正确的阴影和亮度值。 如果返回本章前面创建的色谱图像输出,您会注意到红色,绿色,蓝色和红色再次与色相值0120240360完全一致。 当然,再次,如果我们考虑除以二(因为360不能适合一个字节,但是180可以适合),它们实际上是060120180。 这意味着,如果我们要在色调通道中移动红色以获得蓝色,则必须将其偏移120,并且类似地要转换以获得其他颜色。 因此,我们可以使用类似的方法正确地改变颜色,并且只能在之前的背投图像突出显示的像素中进行。 请注意,我们还需要注意溢出问题,因为最高的色相值应为179,且不能大于:

    for(int i=0; i<hsvImg.rows; i++) 
    { 
      for(int j=0; j<hsvImg.cols; j++) 
      { 
        if(backprojection.at<uchar>(i, j)) 
        { 
            if(hsvImg.at<Vec3b>(i,j)[0] < 60) 
                hsvImg.at<Vec3b>(i,j)[0] += 120; 
            else if(hsvImg.at<Vec3b>(i,j)[0] > 120) 
                hsvImg.at<Vec3b>(i,j)[0] -= 60; 
        } 
      } 
   } 

   Mat imgHueShift; 
   cvtColor(hsvImg, imgHueShift, CV_HSV2BGR); 

通过执行前面的代码,我们将获得下面的结果图像,它是从红色像素变为蓝色的图像转换回的 RGB 图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FTE37AiR-1681870063158)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/de426d5d-0407-4e1f-9e0b-b956b79c2feb.png)]

对于不同的柱状图大小,请尝试相同的操作。 另外,作为练习,您可以尝试构建适当的 GUI 以进行色移。 您甚至可以尝试编写一个程序,该程序可以将图像中具有特定颜色(精确的颜色直方图)的对象更改为其他颜色。 电影和照片编辑程序中广泛使用了一种非常相似的技术来改变图像或连续视频帧中特定区域的颜色(色相)。

直方图比较

使用calcHist函数计算出的两个直方图,或者从磁盘加载并填充到Mat类中的直方图,或者使用任何方法按字面意义创建的两个直方图,都可以相互比较以找出它们之间的距离或差异(或差异), 通过使用compareHist方法。 请注意,只要直方图的Mat结构与我们之前看到的一致(即列数,深度和通道),就可以实现。

compareHist函数采用存储在Mat类中的两个直方图和comparison方法,它们可以是以下常量之一:

  • HISTCMP_CORREL
  • HISTCMP_CHISQR
  • HISTCMP_INTERSECT
  • HISTCMP_BHATTACHARYYA
  • HISTCMP_HELLINGER
  • HISTCMP_CHISQR_ALT
  • HISTCMP_KL_DIV

请注意,compareHist函数的返回值以及应如何解释完全取决于comparison方法,它们的变化很大,因此请务必查看 OpenCV 文档页面以获取详细的列表。 每种方法中使用的基础比较方程。 这是示例代码,可使用所有现有方法来计算两个图像(或两个视频帧)之间的差异:

    Mat img1 = imread("d:/dev/Packt/testbw1.jpg", IMREAD_GRAYSCALE); 
    Mat img2 = imread("d:/dev/Packt/testbw2.jpg", IMREAD_GRAYSCALE); 

    float range[] = {0, 255}; 
    const float* ranges[] = {range}; 
    int bins[] = {100}; 

    Mat hist1, hist2; 
    calcHist(&img1, 1, 0, Mat(), hist1, 1, bins, ranges); 
    calcHist(&img2, 1, 0, Mat(), hist2, 1, bins, ranges); 

    qDebug() << compareHist(hist1, hist2, HISTCMP_CORREL); 

    qDebug() << compareHist(hist1, hist2, HISTCMP_CHISQR); 

    qDebug() << compareHist(hist1, hist2, HISTCMP_INTERSECT); 

    // Same as HISTCMP_HELLINGER 
    qDebug() << compareHist(hist1, hist2, HISTCMP_BHATTACHARYYA); 

    qDebug() << compareHist(hist1, hist2, HISTCMP_CHISQR_ALT); 

    qDebug() << compareHist(hist1, hist2, HISTCMP_KL_DIV); 

我们可以在以下两个图像上尝试前面的代码:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XzxhyifE-1681870063158)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/c3c1a2fd-22c4-44cb-a378-edc46ef6fa67.png)]

比较的结果可以在 Qt Creator 输出中查看,如下所示:

-0.296291 
1.07533e+08 
19811 
0.846377 
878302 
834340 

通常,通常使用直方图差异来比较图像。 还可以在视频帧中使用类似的技术来检测与场景或场景中存在的对象的差异。 因此,应该存在一个预先准备好的直方图,然后将其与每个传入视频帧的直方图进行比较。

直方图均衡

图像的直方图可用于调整图像的亮度和对比度。 OpenCV 提供了一个称为equalizeHist的函数,该函数在内部计算给定图像的直方图,对直方图进行归一化,计算直方图的积分(所有仓位的总和),然后使用更新后的直方图作为查找表来更新输入图像的像素,导致输入图像中的亮度和对比度标准化。 使用此函数的方法如下:

    equalizeHist(image, equalizedImg); 

如果您在亮度不适当或收缩的图像上尝试使用此函数,则将在亮度和对比度方面将它们自动调整到视觉上更好的水平。 此过程称为直方图均衡。 以下示例显示两个亮度级别太低或太高的图像及其直方图,它们显示相应的像素值分布。 左侧的图像是使用equalizeHist函数生成的,对于左侧的两个图像,它看起来或多或少都是相同的。 注意输出图像的直方图中的变化,这反过来会导致图像更具视觉吸引力:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Tw8hWe6-1681870063159)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/fb7f9139-e39d-41aa-86a4-32e5553eeef7.png)]

大多数数码相机使用类似的技术来根据像素在整个图像中的分布量来调整像素的暗度和亮度。 您也可以在任何常见的智能手机上尝试此操作。 只需将相机对准明亮的区域,智能手机上的软件就会开始降低亮度,反之亦然。

MeanShift 和 CamShift

到目前为止,我们在本章中学到的知识除了已经看到的用例之外,还旨在为我们正确使用 MeanShift 和 CamShift 算法做准备,因为它们从直方图和反投影图像中受益匪浅。 但是,MeanShift 和 CAMShift 算法是什么?

让我们从 MeanShift 开始,然后继续进行 CamShift,它基本上是同一算法的增强版本。 因此,MeanShift 的一个非常实用的定义(如当前 OpenCV 文档中所述)如下:

在反投影图像上找到对象

这是对 MeanShift 算法的一个非常简单但实用的定义,并且在使用它时我们将或多或少地坚持下去。 但是,值得注意的是底层算法,因为它有助于轻松,高效地使用它。 为了开始描述 MeanShift 的工作原理,首先,我们需要将反投影图像(或通常为二进制图像)中的白色像素视为二维平面上的分散点。 那应该很容易。 以此为借口,我们可以说,MeanShift 实际上是一种迭代方法,用于查找点在分布点的平面上最密集的位置。 该算法具有一个初始窗口(指定整个图像一部分的矩形),该窗口用于搜索质心,然后将窗口中心移动到新找到的质心。 重复查找质量中心并使窗口中心偏移的过程,直到所需的偏移小于提供的阈值(ε)或达到最大迭代次数为止。 下图显示了在 MeanShift 算法中每次迭代之后窗口移动到最密集的位置(或者甚至在达到迭代计数之前,甚至之前)移动窗口的方式:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xYXnvRbE-1681870063159)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/ad701f76-3469-4d6b-9267-24b433a094ef.png)]

基于此,通过确保在每个帧的反投影中区分对象,MeanShift 算法可用于跟踪视频中的对象。 当然,为此,我们需要使用与之前相似的阈值方法。 最常见的方法是应用已经准备好的直方图,并使用它来计算反投影(在我们之前的示例中,我们只是修改了输入直方图)。 让我们通过一个示例逐步进行此操作。 因此,我们将创建一个QThread子类,可以在任何独立的 Qt 应用中创建该子类,也可以在 DLL 或插件中使用该子类,这将用于computer_vision项目。 无论如何,对于所有项目类型,此线程将保持完全相同。

如第 8 章,“多线程处理”中所讨论的,处理视频应在单独的线程中完成(如果我们不希望找到任何丑陋的解决方法),以使它不会阻塞 GUI 线程,并且可以自由地响应用户的操作。 请注意,该相同线程也可以用作创建任何其他(相似)视频处理线程的模板。 因此,让我们开始:

  1. 我们将创建一个 Qt 窗口小部件应用,该应用可以跟踪一个对象(具有任何颜色,但在这种情况下不是完全白色或黑色),该对象最初将使用鼠标,相机的实时供稿并使用 MeanShift 算法进行选择。 在初始选择之后的任何时候,我们都可以再次从摄像机的实时供稿中更改到场景中的另一个对象。 第一次选择对象时,然后每次更改选择时,将提取视频帧的色相通道,并使用直方图和反投影图像计算并提供给 MeanShift 算法,并且该对象将被跟踪。 因此,我们需要首先创建一个 Qt Widgets 应用并为其命名,例如MeanShiftTracker,然后继续实际的跟踪器实现。
  2. 正如我们在第 8 章,“多线程”中了解的那样,创建一个QThread子类。 将其命名为QCvMeanShiftThread,并确保相应地在私有和公共成员区域中包括以下内容。 我们将使用setTrackRect函数通过此函数设置初始MeanShift跟踪窗口,但还将使用此函数提供将跟踪更改为另一个对象的方法。 newFrame非常明显,它将在处理完每帧后发出,以便 GUI 可以显示它。 使用私有区域和 GUI 的成员将在后面的步骤中进行描述,但是它们包含了到目前为止我们已经了解的一些最重要的主题:
        public slots: 
          void setTrackRect(QRect rect); 

        signals: 
          void newFrame(QPixmap pix); 

        private: 
          void run() override; 
          cv::Rect trackRect; 
          QMutex rectMutex; 
          bool updateHistogram;
  1. setTrackRect函数只是用于更新我们希望 MeanShift 算法跟踪的矩形(初始窗口)的setter函数。 这是应如何实现:
        void QCvMeanShiftThread::setTrackRect(QRect rect) 
        { 
          QMutexLocker locker(&rectMutex); 
          if((rect.width()>2) && (rect.height()>2)) 
          { 
             trackRect.x = rect.left(); 
             trackRect.y = rect.top(); 
             trackRect.width = rect.width(); 
             trackRect.height = rect.height(); 
             updateHistogram = true; 
          } 
        } 

QMutexLockerrectMutex一起用于为我们的trackRect提供访问序列化。 由于我们还将以一种实时工作的方式实现跟踪方法,因此我们需要确保在处理trackRect时不会对其进行更新。 我们还确保其大小合理,否则将被忽略。

  1. 至于我们的跟踪器线程的run函数,我们需要使用VideoCapture打开计算机上的默认相机并向我们发送帧。 请注意,如果框架为空(损坏),相机关闭或从线程外部请求线程中断,则循环将退出:
        VideoCapture video; 
        video.open(0); 
        while(video.isOpened() && !this->isInterruptionRequested()) 
        { 
          Mat frame; 
          video >> frame; 
          if(frame.empty()) 
          break; 

          // rest of the process ... 
          .... 
         }

在循环内,将其标记为rest of the process ...,首先,我们将使用cv::Rect类的area函数来查看trackRect是否已设置。 如果是,那么我们将锁定访问权限并继续进行跟踪操作:

        if(trackRect.size().area() > 0) 
        { 
          QMutexLocker locker(&rectMutex); 
          // tracking code 
         }

至于 MeanShift 算法和真实跟踪,我们可以使用以下源代码:

        Mat hsv, hue, hist; 
        cvtColor(frame, hsv, CV_BGR2HSV); 
        hue.create(hsv.size(), hsv.depth()); 
        float hrange[] = {0, 179}; 
        const float* ranges[] = {hrange}; 
        int bins[] = {24}; 
        int fromto[] = {0, 0}; 
        mixChannels(&hsv, 1, &hue, 1, fromto, 1); 

        if(updateHistogram) 
        { 
          Mat roi(hue, trackRect); 
          calcHist(&roi, 1, 0, Mat(), hist, 1, bins, ranges); 

          normalize(hist, 
              hist, 
              0, 
              255, 
              NORM_MINMAX); 

          updateHistogram = false; 
        } 

        Mat backProj; 
        calcBackProject(&hue, 
          1, 
          0, 
          hist, 
          backProj, 
          ranges); 

        TermCriteria criteria; 
        criteria.maxCount = 5; 
        criteria.epsilon = 3; 
        criteria.type = TermCriteria::EPS; 
        meanShift(backProj, trackRect, criteria); 

        rectangle(frame, trackRect, Scalar(0,0,255), 2); 

上面的代码按照以下顺序执行以下操作:

  • 使用cvtColor函数将输入帧从 BGR 转换为 HSV 色彩空间。
  • 使用mixChannels函数仅提取色调通道。
  • 如果需要,可以使用calcHistnormalize函数计算并归一化直方图。
  • 使用calcBackproject函数计算反投影图像。
  • 通过提供迭代标准,在背投图像上运行 MeanShift 算法。 这是通过TermCriteria类和meanShift函数完成的。 meanShift会简单地更新提供的矩形(trackRect每帧有一个新矩形)。
  • 在原始图像上绘制检索到的矩形。

除了TermCriteria类和meanShift函数本身之外,您刚刚看到的任何代码中都没有新内容。 如前所述,MeanShift 算法是一种迭代方法,需要根据移位量(ε)和迭代次数来确定一些停止条件。 简而言之,增加迭代次数可以减慢算法的速度,但也可以使其更加准确。 另一方面,提供较小的ε值将意味着更加敏感的行为。

在处理完每个帧之后,线程仍需要使用专用信号将其发送到另一个类。 方法如下:

    emit newFrame( 
      QPixmap::fromImage( 
         QImage( 
           frame.data, 
           frame.cols, 
           frame.rows, 
           frame.step, 
           QImage::Format_RGB888) 
           .rgbSwapped()));

请注意,除了发送QPixmapQImage等,我们还可以发送不是QObject子类的类。 为了能够通过 Qt 信号发送非 Qt 类,它必须具有公共默认构造器,公共副本构造器和公共析构器。 还需要先注册。 例如,Mat类包含必需的方法,但不是已注册的类型,因此可以按如下所示进行注册:qRegisterMetaType<Mat>("Mat");。 之后,您可以在 Qt 信号和插槽中使用Mat类。

  1. 除非我们完成此线程所需的用户界面,否则仍然看不到任何结果。 让我们用QGraphicsView来做。 只需使用设计器将一个拖放到mainwindow.ui上,然后将以下内容添加到mainwindow.h中。 我们将使用QGraphicsView类的橡皮筋功能轻松实现对象选择:
        private: 
          QCvMeanShiftThread *meanshift; 
          QGraphicsPixmapItem pixmap; 

        private slots: 
         void onRubberBandChanged(QRect rect, 
         QPointF frScn, QPointF toScn); 
         void onNewFrame(QPixmap newFrm); 
  1. mainwindow.cpp文件和MainWindow类的构造器中,确保添加以下内容:
        ui->graphicsView->setScene(new QGraphicsScene(this)); 
        ui->graphicsView->setDragMode(QGraphicsView::RubberBandDrag); 
        connect(ui->graphicsView, 
        SIGNAL(rubberBandChanged(QRect,QPointF,QPointF)), 
        this, 
        SLOT(onRubberBandChanged(QRect,QPointF,QPointF))); 

        meanshift = new QCvMeanShiftThread(); 
        connect(meanshift, 
          SIGNAL(newFrame(QPixmap)), 
          this, 
          SLOT(onNewFrame(QPixmap))); 
          meanshift->start(); 

        ui->graphicsView->scene()->addItem(&pixmap);

在第 5 章,“图形视图框架”中详细讨论了如何使用 Qt 图形视图框架。

  1. 还应确保在关闭应用时注意线程,如下所示:
        meanshift->requestInterruption(); 
        meanshift->wait(); 
        delete meanshift; 
  1. 剩下的唯一事情就是在 GUI 本身上设置传入的QPixmap,并且还传递更新被跟踪对象所需的矩形:
        void MainWindow::onRubberBandChanged(QRect rect, 
          QPointF frScn, 
          QPointF toScn) 
          { 
            meanshift->setTrackRect(rect); 
          } 

       void MainWindow::onNewFrame(QPixmap newFrm) 
       { 
         pixmap.setPixmap(newFrm); 
       } 

尝试运行该应用并选择一个在相机上可见的对象。 使用鼠标在图形视图上绘制的矩形将跟随您选择的对象,无论它在屏幕上的任何位置。 这是从视图中选择 Qt 徽标后对其进行跟踪的一些屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LSTFMrgA-1681870063159)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/99fef9e5-7e9b-43ef-8557-848e6f7492fa.png)]

可视化反投影图像并查看幕后发生的魔术也是一个好主意。 请记住,如前所述,MeanShift 算法正在搜索质心,当在反投影图像中观察时,这很容易感知。 只需用以下代码替换我们用于可视化线程内图像的最后几行:

    cvtColor(backProj, backProj, CV_GRAY2BGR); 
    frame = backProj; 
    rectangle(frame, trackRect, Scalar(0,0,255), 2); 

现在再试一次。 您应该在图形视图中具有反投影图像:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BIJyrn1p-1681870063159)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/a3a43d9c-f162-425e-8e66-6ba5b6dde527.png)]

从结果可以看出,MeanShift 算法或精确的meanShift函数非常易于使用,只要为其提供灰度图像即可,该图像可以使用任何阈值方法隔离感兴趣的对象。 是的,反投影也类似于阈值设置,在该阈值设置中,您可以基于颜色,强度或其他条件让某些像素通过或某些其他像素不通过。 现在,如果我们回到 MeanShift 算法的初始描述,完全可以说它可以基于反投影图像找到并跟踪对象。

尽管meanShift函数易于使用,但它仍然缺少几个非常重要的功能。 这些是对被跟踪对象的比例和方向更改的容限。 无论对象的大小或其方向如何,camShift函数都将提供一个大小和旋转度完全相同的窗口,而该窗口只是试图以目标对象为中心。 这些问题在 MeanShift 算法的增强版本中得以解决,该增强版本称为连续自适应 MeanShift 算法,或简称为 CamShift。

CamShift函数是 OpenCV 中 CamShift 算法的实现,与 MeanShift 算法有很多共同之处,并且出于同样的原因,它的使用方式几乎相同。 为了证明这一点,只需将前面代码中对meanShift算法的调用替换为CamShift即可,如下所示:

    CamShift(backProj, trackRect, criteria); 

如果再次运行该程序,您会发现什么都没有真正改变。 但是,此函数还提供RotatedRect类型的返回值,该返回值基本上是矩形,但具有中心,大小和角度属性。 您可以保存返回的RotatedRect并将其绘制在原始图像上,如下所示:

    RotatedRect rotRec = CamShift(backProj, trackRect, criteria); 
    rectangle(frame, trackRect, Scalar(0,0,255), 2); 
    ellipse(frame, rotRec, Scalar(0,255,0), 2); 

请注意,我们实际上在这段代码中绘制了一个适合RotatedRect类属性的椭圆。 我们还绘制了先前存在的矩形,以便与旋转的矩形进行比较。 如果您尝试再次运行该程序,则结果如下:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KSFuL4Oh-1681870063159)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/3e25ea65-9315-427f-b671-b94df683a535.png)]

请注意,绿色椭圆相对于红色矩形的旋转是CamShift函数的结果。 尝试将要跟踪的彩色物体移离相机或靠近相机,然后查看CamShift如何尝试适应这些变化。 另外,尝试使用非正方形物体观察CamShift提供的旋转不变跟踪。

CamShift函数还可以用于根据物体的颜色检测物体。 当然,如果可以与周围环境区分开。 因此,您需要设置一个预先准备好的直方图,而不是像我们的示例那样在运行时设置它。 您还需要将初始窗口大小设置为很大的窗口大小,例如整个图像的大小,或图像中预期将出现对象的最大区域。 通过运行相同的代码,您会注意到,在每一帧之后,窗口将变得越来越小,直到仅覆盖我们为其提供直方图的目标对象为止。

背景/前景检测

背景/前景检测或分割(由于很好的原因通常也称为背景减法)是一种区分图像(前景)中移动区域或变化区域的方法,而不是或多或少的恒定或静态区域(背景)。 该方法在检测图像中的运动时也非常有效。 OpenCV 包括许多不同的背景扣除方法,默认情况下,当前的 OpenCV 安装中提供了两种方法,即BackgroundSubtractorKNNBackgroundSubtractorMOG2。 与我们在第 7 章,“特征和描述符”中了解到的特征检测器类相似,这些类也源自cv::Algorithm类,并且它们都非常容易且相似地使用,因为它们的用法或结果不同,而在类的实现方面不同。

BackgroundSubtractorMOG2可以通过使用高斯混合模型来检测背景/前景。 另一方面,通过使用 KNNK 最近邻方法,BackgroundSubtractorKNN也可以用于实现相同的目标。

如果您对这些算法的内部细节或如何实现感兴趣,可以参考以下文章以获取更多信息:

Zoran Zivkovic and Ferdinand van der Heijden. Efficient adaptive density estimation per image pixel for the task of background subtraction. Pattern recognition letters, 27(7):773-780, 2006.

Zoran Zivkovic. Improved adaptive gaussian mixture model for background subtraction. In Pattern Recognition, 2004. ICPR 2004. Proceedings of the 17th International Conference on, volume 2, pages 28-31. IEEE, 2004.

首先让我们看看它们是如何使用的,然后再介绍它们的一些重要功能。 与上一节中创建的QCvMeanShiftThread类相似,我们可以通过将QThread子类化来创建新线程。 将其命名为QCvBackSubThread或您认为合适的任何名称。 唯一有区别的部分是覆盖的run函数,它看起来如下所示:

    void QCvBackgroundDetect::run() 
    { 
      using namespace cv; 

      Mat foreground; 
      VideoCapture video; 
      video.open(0); 

      Ptr<BackgroundSubtractorMOG2> subtractor = 
            createBackgroundSubtractorMOG2(); 

      while(video.isOpened() && !this->isInterruptionRequested()) 
      { 
        Mat frame; 
        video >> frame; 
        if(frame.empty()) 
            break; // or continue if this should be tolerated 

        subtractor->apply(frame, foreground); 

        Mat foregroundBgr; 
        cvtColor(foreground, foregroundBgr, CV_GRAY2BGR); 

        emit newFrame( 
           QPixmap::fromImage( 
               QImage( 
                 foregroundBgr.data, 
                 foregroundBgr.cols, 
                 foregroundBgr.rows, 
                 foregroundBgr.step, 
                 QImage::Format_RGB888) 
                .rgbSwapped())); 

         } 
      } 

请注意,背景减法所需的唯一调用是BackgroundSubtractorMOG2类的构造并调用apply函数。 就使用它们而言,仅此而已,这使它们非常简单易用。 在每帧,根据图像所有区域的变化历史更新前景,即Mat类。 由于我们只是通过调用createBackgroundSubtractorMOG2函数使用了默认参数,因此我们没有更改任何参数,而是继续使用默认值,但是如果要更改算法的行为,我们需要为此提供以下参数:

  • history(默认设置为 500)是影响背景减法算法的最后一帧的数量。 在我们的示例中,我们还在 30 FPS 摄像机或视频上使用了大约 15 秒的默认值。 这意味着,如果一个区域在过去 15 秒钟内完全未变,则它将完全变黑。
  • varThreshold(默认设置为 16)是算法的差异阈值。
  • detectShadows(默认设置为true)可用于忽略或计数检测阴影变化。

尝试运行前面的示例程序,该程序使用默认参数并观察结果。 如果镜头前没有任何动作,您应该会看到一个全黑的屏幕,但是即使很小的移动也可以被视为输出上的白色区域。 您应该会看到以下内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j0YiikGB-1681870063160)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/2b60e4d0-8f11-45f2-acbc-450a33d943ce.png)]

切换到BackgroundSubtractorKNN类非常容易,您只需要用以下内容替换构造线:

    Ptr<BackgroundSubtractorKNN> subtractor =  
       createBackgroundSubtractorKNN(); 

没什么需要改变的。 但是,要修改此算法的行为,可以使用以下参数,其中一些参数也与BackgroundSubtractorMOG2类共享:

  • history与之前的算法完全相同。
  • detectShadows,也与先前的算法相同。
  • dist2Threshold默认情况下设置为400.0,并且是像素与样本之间平方距离的阈值。 为了更好地理解这一点,最好在线查看 K 最近邻算法。 当然,您可以简单地使用默认值并使用算法,而无需提供任何参数。

试用各种参数并观察结果,没有什么可以帮助您提高使用这些算法的效率。 例如,您会注意到增加历史值将有助于检测甚至更小的运动。 尝试更改其余参数,以自己观察和比较结果。

在前面的示例中,我们尝试输出通过使用背景减法类提取的前景遮罩图像。 您还可以在copyTo函数中使用相同的前景遮罩,以输出前景的实际像素。 这是如何做:

    frame.copyTo(outputImage, foreground); 

其中frame是相机输入的帧,foreground是通过背景减法算法获得的,与前面的示例相同。 如果尝试显示输出图像,则将具有以下类似内容:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LbApzCQO-1681870063160)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/b354276f-10a8-4169-8c0e-09ef33147951.png)]

请注意,此处看到的输出是移动摄像机的结果,与移动视频中的对象基本相同。 但是,如果您在视频中尝试在静态背景上四处移动其他任何彩色对象的视频中使用同一示例,则可以使用 CamShift 算法在移动对象周围获取一个边界框以提取该对象,或由于任何原因对其进行进一步处理。

使用 OpenCV 中的现有视频分析类编写应用的机会是巨大的,这仅取决于您对使用它们的熟悉程度。 例如,通过使用背景减除算法,您可以尝试编写运行警报的应用,或者在检测到运动时执行另一个过程。 可以通过测量在前面的示例中看到的提取的前景图像中像素的总和或平均值,然后检测超过某些阈值的突然增加,来轻松地完成类似的操作。 我们甚至无法开始列举所有可能性,但可以肯定的是,您是混合使用这些算法来解决特定任务的大师,并且包括该书在内的任何指南都只是您如何操作的路标集合。 使用现有算法。

总结

编写执行实时图像处理的计算机视觉应用是当今的热门话题,并且 OpenCV 包含许多类和函数来帮助简化此类应用的开发。 在本章中,我们试图介绍 OpenCV 提供的一些最重要的类和函数,这些类和函数用于实时处理视频和图像。 我们了解了 OpenCV 中的 MeanShift,CamShift 和背景减法算法,这些算法打包在快速高效的类中,同时,它们非常易于使用,前提是您熟悉大多数语言中使用的基本概念 ,例如直方图和反投影图像。 这就是为什么我们首先要学习所有有关直方图的知识,以及如何进行计算,可视化和相互比较。 我们还学习了如何计算反投影图像并将其用作查找表以更新图像。 我们在 MeanShift/CamShift 算法中也使用了相同的算法来跟踪特定颜色的对象。 到现在为止,我们应该能够高效地编写基于其中的零件和零件运动来处理视频和图像的应用。

本章是最后一章,我们将介绍 OpenCV 和 Qt 框架的详细信息。 一本书,甚至一本书,永远都不足以覆盖 OpenCV 和 Qt 框架中的所有现有材料,但是我们试图以一种可以跟进其余部分的方式来呈现整个情况的概述。 现有的类和函数可以自己开发有趣的计算机视觉应用。 确保与 OpenCV 和 Qt 框架的新开发保持同步,因为它们正在开展并吸引着正在进行中的项目,并且进展似乎不会很快停止。

本书的下一章将专门介绍如何调试,测试和部署 Qt 和 OpenCV 应用并将其部署给用户。 我们将首先了解 Qt Creator 的调试功能,然后继续使用 Qt Test 命名空间及其基础功能,这些功能可用于轻松进行 Qt 应用的单元测试。 在下一章中,我们还将介绍 Qt 安装程序框架,甚至为应用创建一个简单的安装程序。

十、调试与测试

自从使用 OpenCV 3 和 Qt5 框架进行计算机视觉之旅以来,我们已经走了很长一段路。 现在,我们可以非常轻松地安装这些强大的框架,并配置运行 Windows,MacOS 或 Linux 操作系统的计算机,以便我们可以设计和构建计算机视觉应用。 在前几章中,我们学习了如何使用 Qt 插件系统来构建模块化和基于插件的应用。 我们学习了如何使用 Qt 样式表对应用进行样式设置,以及如何使用 Qt 中的国际化技术使其支持多种语言。 我们使用 Qt 图形视图框架构建了功能强大的图形查看器应用。 该框架中的类帮助我们更加有效地显示图形项目,并具有更大的灵活性。 我们能够构建可以放大和缩小图像的图形查看器,而不必处理源图像本身(这要归功于场景-视图-项目架构)。 后来,我们开始更深入地研究 OpenCV 框架,并且了解了许多类和函数,这些类和函数使我们能够以多种方式转换图像并对其进行处理,以实现特定的计算机视觉目标。 我们学习了用于检测场景中对象的特征检测和描述符提取。 我们浏览了 OpenCV 中的许多现有算法,这些算法旨在以更加智能的方式处理图像内容,而不仅仅是原始像素值。 在最近的章节中,我们了解了 Qt 提供的多线程和线程同步工具。 我们了解了 Qt 框架提供的用于处理应用中多线程的低级(QThread)和高级(QtConcurrent)技术,而与平台无关。 最后,在上一章中,我们学习了视频的实时图像处理以及可以跟踪具有特定颜色的对象的 OpenCV 算法。 到现在为止,我们应该以这样一种方式熟悉 Qt 和 OpenCV 框架的许多方面:我们自己可以跟进更高级的主题,并且仅依赖于文档。

除了前面提到的所有内容以及在前几章中我们取得的成就的一长串清单之外,我们仍然没有谈论软件开发的一个非常重要的方面以及在与 Qt 和 OpenCV 一起工作时如何处理软件,即测试过程。 在将计算机程序部署到该应用的用户之前,无论该程序是简单的小型二进制文件,大型计算机视觉应用还是任何其他应用,都必须经过测试。 测试是开发过程中一个永无止境的阶段,它是在开发应用后立即进行的,并且时不时地解决问题或添加新功能。 在本章中,我们将学习现有技术来测试使用 Qt 和 OpenCV 构建的应用。 我们将学习开发时间测试和调试。 我们还将学习如何使用 Qt 测试框架对应用进行单元测试。 在将应用交付给最终用户之前,这是最重要的过程。

我们将在本章中介绍的主题如下:

  • Qt Creator 的调试功能
  • 如何使用 Qt 测试命名空间进行单元测试
  • 数据驱动的测试
  • GUI 测试和重放 GUI 事件
  • 创建测试用例项目

将 Qt Creator 用于调试

调试器是一种程序,在程序执行过程中突然崩溃或程序逻辑中发生意外行为时,可用于测试和调试其他程序。 在大多数情况下(如果不是总是),调试器用于开发环境中,并与 IDE 结合使用。 在我们的案例中,我们将学习如何在 Qt Creator 中使用调试器。 重要的是要注意,调试器不是 Qt 框架的一部分,并且像编译器一样,它们通常由操作系统 SDK 提供。 如果系统中存在调试器,则 Qt Creator 会自动检测并使用调试器。 可以通过依次通过主菜单“工具”和“选项”进入“Qt Creator 选项”页面进行检查。 确保从左侧列表中选择Build&Run,然后从顶部切换到Debuggers选项卡。 您应该能够在列表上看到一个或多个自动检测到的调试器。

Windows 用户:此信息框后,您应该会看到类似于屏幕截图的内容。 如果没有,则意味着您尚未安装任何调试器。 您可以按照此处提供的说明轻松下载并安装它

或者,您可以独立地在线搜索以下主题:
Windows 调试工具(WinDbg,KD,CDB,NTSD)。

但是,在安装调试器之后(假定是 Microsoft Visual C++ 编译器的 CDB 或 Microsoft 控制台调试器,以及 GCC 编译器的 GDB),您可以重新启动 Qt Creator 并返回此页面。 您应该可以具有一个或多个类似于以下内容的条目。 由于我们已经安装了 32 位版本的 Qt 和 OpenCV 框架,因此选择名称中带有 x86 的条目以查看其路径,类型和其他属性。

MacOS 和 Linux 用户
不需要执行任何操作,根据操作系统,您会看到 GDB,LLDB 或其他调试器中的条目。

这是“选项”页面上“构建和运行”选项卡的屏幕截图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wjsRBMg8-1681870063160)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/aaaf49eb-2f9d-4b22-a8bb-7ca8e25526d0.png)]

根据操作系统和已安装的调试器的不同,前面的屏幕快照可能会略有不同。 但是,您将需要一个调试器,以确保已正确设置为所用 Qt Kit 的调试器。 因此,记下调试器的路径和名称,并切换到 Kits 选项卡,然后在选择了所用的 Qt Kit 后,请确保正确设置了调试器,如以下屏幕快照所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mXdV88Kp-1681870063160)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/34dfc396-25d4-4aa6-b3fc-86b3cc1dbe5a.png)]

不必担心选择错误的调试器或任何其他选项,因为在顶部选择的 Qt Kit 图标旁边会警告您相关的图标。 当工具包一切正常时,通常会显示下图所示的图标,左侧的第二个图标表示有问题的不正确,右侧的图标表示严重错误。 将鼠标移到该图标上时,可以查看有关解决该问题所需的详细操作的更多信息:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jrQWvqkv-1681870063160)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/24e43539-b0f8-4f91-b5c3-c8aa3d37d739.png)]

Qt 套件的关键问题可能是由许多不同的因素引起的,例如缺少编译器,这将使套件在解决问题之前完全无用。 Qt 套件中的警告消息示例可能是缺少调试器,这不会使套件无用,但您将无法将其与调试器一起使用,因此,与完全配置的 Qt 套件相比,它意味着功能更少。

正确设置调试器后,您可以采用以下几种方法之一开始调试应用,这些方法基本上具有相同的结果:最终进入 Qt Creator 的调试器视图:

  • 在调试模式下启动应用
  • 附加到正在运行的应用(或进程)

请注意,可以通过多种方式来启动调试过程,例如通过将其附加到在另一台计算机上运行的过程中来远程启动。 但是,上述方法在大多数情况下就足够了,尤其是与 Qt + OpenCV 应用开发以及我们在本书中学到的内容有关的情况。

调试模式入门

要在调试模式下启动应用,请在打开 Qt 项目后使用以下方法之一:

  • 按下F5按钮
  • 使用“开始调试”按钮,在通常的“运行”按钮下,带有类似图标,但上面有一个小错误
  • 按以下顺序使用主菜单项:调试/开始调试/开始调试

要将调试器附加到正在运行的应用,可以按以下顺序使用主菜单项:调试/启动调试/附加到正在运行的应用。 这将打开“进程列表”窗口,从中可以使用其进程 ID 或可执行文件名选择应用或要调试的任何其他进程。 您还可以使用“过滤器”字段(如下图所示)来找到您的应用,因为很有可能进程列表很长。 选择正确的过程后,请确保按下“附加到过程”按钮。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cX2wZlvX-1681870063161)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/e51742a2-963d-43e8-af70-6629c893ad24.png)]

无论使用哪种方法,都将最终进入 Qt Creator 调试模式,该模式与“编辑”模式非常相似,但是它还可以执行以下操作:

  • 在代码中添加,启用,禁用和查看断点(断点只是我们希望调试器在过程中暂停的代码中的点或线,并允许我们对程序状态进行更详细的分析 )
  • 中断正在运行的程序和进程以查看和检查代码
  • 查看和检查函数调用栈(调用栈是一个包含导致断点或中断状态的函数的层次结构列表的栈)
  • 查看和检查变量
  • 反汇编源代码(从这种意义上来说,反汇编意味着提取与我们程序中的函数调用和其他 C++ 代码相对应的确切指令)

在调试模式下启动应用时,您会注意到性能下降,这显然是因为调试器正在监视和跟踪代码。 这是 Qt Creator 调试模式的屏幕截图,其中前面提到的所有功能都可以在单个窗口中以及在 Qt Creator 的调试模式下看到:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-O6uqys6Y-1681870063161)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/25d3041e-1cff-46d7-a831-da470c2279ab.png)]

您在本书中已经使用并且非常熟悉的代码编辑器中的上一个屏幕快照中用数字1指定的区域。 每行代码都有一个行号; 您可以单击其左侧以在代码中所需的任何位置切换断点。 您还可以右键单击行号以设置,删除,禁用或启用断点,方法是选择“在行 X 处设置断点”,“删除断点 X”,“禁用断点 X”或“启用断点 X”,其中所有提到的命令中的 X 这里需要用行号代替。 除了代码编辑器,您还可以使用前面的屏幕快照中编号为4的区域来添加,删除,编辑和进一步修改代码中的断点。

在代码中设置断点后,只要程序到达代码中的该行,它将被中断,并且您将被允许使用代码编辑器正下方的控件来执行以下任务:

  • 继续:这意味着继续执行程序的其余流程(或再次按F5)。
  • 步过:用于执行下一步(代码行),而无需进入函数调用或类似的代码,这些代码可能会更改调试光标的当前位置。 请注意,调试游标只是正在执行的当前代码行的指示器。 (这也可以通过按F10来完成。)
  • 单步执行:与单步执行相反,它可以用于进一步深入函数调用,以更详细地分析代码和调试。 (与按F11相同。)
  • 退出:可用于退出函数调用并在调试时返回到调用点。 (与按Shift + F11相同。)

您也可以右键单击包含调试器控件的代码编辑器下方的同一工具栏,以打开以下菜单,并添加或删除更多窗格以显示其他调试和分析信息。 我们将介绍默认的调试器视图,但请确保自行检查以下每个选项,以进一步熟悉调试器:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9O9D3PoB-1681870063161)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/d3f1e4f8-b7c8-4c9a-8f54-43b435d9bc13.png)]

在前面的代码中用数字2指定的区域可用于查看调用栈。 无论您是通过按“中断”按钮还是在运行时从菜单中选择“调试/中断”来中断程序,设置断点并在特定代码行中停止程序,还是发生故障的代码都会导致程序陷入陷阱,并暂停该过程(因为调试器将捕获崩溃和异常),您始终可以查看导致中断状态的函数调用的层次结构,或者通过检查前面 Qt Creator 屏幕截图中的区域 2 来进一步分析它们。

最后,您可以使用上一个屏幕快照中的第三个区域在代码中被中断的位置查看程序的局部变量和全局变量。 您可以查看变量的内容,无论它们是标准数据类型(例如整数和浮点数还是结构和类),还可以进一步扩展和分析其内容以测试和分析代码中的任何可能问题。

有效地使用调试器可能意味着数小时的测试和解决您的代码中的问题。 就调试器的实际使用而言,实际上没有别的方法,只有尽可能多地使用它,并养成使用调试器的习惯,而且还要记下您在使用过程中发现的良好做法和技巧, 我们刚刚经历的那些。 如果您有兴趣,还可以在线阅读有关其他可能的调试方法的信息,例如远程调试,使用故障转储文件的调试(在 Windows 上)等。

Qt 测试框架

在开发应用时进行调试和测试是完全不可避免的,但是许多开发人员往往会错过的一件事就是进行单元测试,这一点尤为重要,尤其是在大型项目和难以手动进行全面测试的应用中。 在构建它们的时间或在其代码中的某个位置修复了错误。 单元测试是一种测试应用中的零件(单元)以确保它们按预期工作的方法。 还值得注意的是,测试自动化是当今软件开发的热门话题之一,它是使用第三方软件或编程来自动化单元测试的过程。

在本节中,我们将学习精确使用 Qt 测试框架(即 Qt 测试命名空间)(以及一些其他与测试相关的类)的知识,这些知识可用于为使用 Qt 构建的应用开发单元测试。 与第三方测试框架相反,Qt 测试框架是内部(基于 Qt 框架本身)和轻量级测试框架,并且在其众多功能中,它提供基准测试,数据驱动的测试和 GUI。 测试:基准测试可用于衡量函数或特定代码段的性能,而数据驱动的测试可帮助运行使用不同数据集作为输入的单元测试。 另一方面,可以通过模拟鼠标和键盘的交互来进行 GUI 测试,这又是 Qt 测试框架涵盖的另一个方面。

创建单元测试

可以通过子类化QObject类并添加 Qt 测试框架所需的插槽以及一个或多个用于执行各种测试的插槽(测试函数)来创建单元测试。 下列插槽(专用插槽)可以存在于每个测试类中,并且除了测试函数外,还可以由 Qt Test 调用:

  • initTestCase:在调用第一个测试函数之前调用它。 如果此函数失败,则整个测试将失败,并且不会调用任何测试函数。
  • cleanupTestCase:在调用最后一个测试函数后调用。
  • init:在调用每个测试函数之前调用它。 如果此函数失败,将不会执行前面的测试函数。
  • cleanup:在调用每个测试函数后调用。

让我们用一个真实的例子创建我们的第一个单元测试,看看如何将刚才提到的函数添加到测试类中,以及如何编写测试函数。 为了确保我们的示例是现实的并且易于同时进行,我们将避免过多地担心要测试的类的实现细节,而主要关注于如何测试它们。 基本上,相同的方法可用于测试具有任何级别复杂性的任何类。

因此,作为第一个示例,我们假设我们有一个返回图像像素数量(宽度乘以图像高度)的类,并且我们想使用单元测试进行测试:

  1. 可以使用 Qt Creator 创建单元测试,类似于创建 Qt 应用或库,也可以在“欢迎”模式下使用“新建项目”按钮,或者从“文件”菜单中选择“新建文件”或“项目”来创建单元测试。 确保选择以下内容作为项目模板:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-MPvLIkTr-1681870063161)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/3d6f3d58-9b54-4e41-8e3f-6301d69956d7.png)]

  1. 单击选择,然后输入HelloTest作为单元测试项目的名称,然后单击下一步。

  2. 选择与 Qt 项目完全相同的工具包,然后再次单击“下一步”。

  3. 在下一个屏幕截图中看到的“模块”页面中,您会注意到 QtCore 和 QtTest 模块是默认选择的,不能取消选择它们。 该页面只是一个帮助程序,或者是一个帮助您以交互方式选择所需模块的所谓向导。 如果忘记了添加类正常工作所需的模块,则以后也可以使用项目*.pro文件添加或删除模块。 这使得有必要再次重复一个重要的观点。 单元测试就像使用您的类和函数的应用一样。 唯一的区别是,您仅将其用于测试目的,并且仅用于确保事情按预期运行,并且没有回归:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qh5BJ9XO-1681870063161)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/dde7afdb-18ba-40ec-93aa-ef54f3857fc8.png)]

  1. 选择模块并单击下一步后,将显示“详细信息”页面或“测试类别信息”页面。 在以下屏幕截图中看到的“测试插槽”字段中输入testPixelCount,然后单击“下一步”。 其余选项(如前一个窗口)只是简单的帮助程序,可轻松地以交互方式添加所需的函数,并包括对测试单元的指令,如果缺少任何内容,也可以稍后在源文件中添加这些指令。 不过,本章稍后将了解它们的含义以及如何使用它们。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hAlsszId-1681870063162)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/b38f4a86-8f31-4d09-b9b9-cdbda5f13b82.png)]

  1. 确认所有对话框后,我们将进入 Qt Creator 编辑模式下的代码编辑器。 检查HelloTest.pro文件,您会注意到它与标准 Qt 项目(小部件或控制台应用)的*.pro文件非常相似,具有以下模块定义,可将 Qt 测试模块导入该项目。 这就是您可以在任何单元测试项目中使用 Qt Test 的方式。 但是,如果您不使用“新建文件”或“项目”向导,则该向导会自动添加:
QT += testlib

在继续下一步之前,请确保像在 Qt Widgets 应用中一样将 OpenCV 库添加到 pro 文件。 (有关这方面的更多信息,请参阅本书的初始章节。)

  1. 现在,添加您创建的类以将图像的像素计数到该项目中。 请注意,在这种情况下,添加和复制不是同一回事。 您可以在单独的文件夹中将属于另一个项目的类头文件和源文件添加到项目中,而无需将其复制到项目文件夹中。 您只需要确保它们包含在*.pro文件的HEADERSSOURCES列表中,并且可以选择将类所在的文件夹添加到INCLUDEPATH变量中。

实际上,永远不要将要测试的类的源文件复制到测试项目中,正如我们将在本节中进一步讨论的那样,即使包含subdirs模板,也应始终使用subdirs模板制作单个项目,以便至少将一个单元测试添加到项目中,并在每次构建主项目时自动执行测试。 但是,严格来讲,无论您将类文件复制到其中还是将其简单地添加到它们中,单元测试都将以相同的方式工作。

  1. 现在该编写我们的测试类了,因此在 Qt Creator 代码编辑器中打开tst_hellotesttest.cpp。 除了明显的#include指令外,这里还需要注意几件事:一个是HelloTestTest类,这是在“新建文件”或“项目”向导期间提供的类名称。 它不过是QObject子类,因此不要在此处查找任何隐藏内容。 它有一个称为testPixelCount的专用插槽,该插槽也是在向导期间设置的。 它的实现包括带有QVERIFY2宏的一行,我们将在后面的步骤中进行介绍。 但是,最后两行是新的:
QTEST_APPLESS_MAIN(HelloTestTest) 
#include "tst_hellotesttest.moc"

QTEST_APPLESS_MAIN是由 C++ 编译器和moc扩展的宏(有关moc的更多信息,请参见第 3 章,“创建一个全面的 Qt + OpenCV 项目”),以创建适当的 C++ main函数来执行我们在HelloTestTest类中编写的测试函数。 它仅创建测试类的实例并调用QTest::qExec函数以启动测试过程。 测试过程将自动调用测试类中的所有专用插槽,并输出测试结果。 最后,如果我们在单个cpp源文件中创建测试类,而不是在单独的标头和源文件中创建 Qt 框架,则最后一行是必需的。 确保使用include指令将要测试的类添加到tst_hellotesttest.cpp文件中。 (为便于参考,我们假设其名为PixelCounter。)

  1. 现在,您可以使用适当的测试宏之一来测试此类中负责计算图像像素的函数。 假设该函数采用文件名和路径(QString类型)并返回整数。 让我们使用testPixelCount插槽内已经存在的VERIFY2宏,如下所示:
void HelloTestTest::testPixelCount() 
{ 
    int width = 640, height = 427; 
    QString fname = "c:/dev/test.jpg"; 
    PixelCounter c; 
    QVERIFY2(c.countPixels(fname) == width*height, "Failure"); 
} 

在此测试中,我们仅提供了一个图像文件,该图像文件的像素数已知(宽度乘以高度),以测试我们的函数是否正常工作。 然后,我们将创建PixelCounter类的实例,并最终执行QVERIFY2宏,该宏将执行countPixels函数(假设这是我们要测试的公共函数的名称),并根据比较失败或通过来进行测试。 如果测试失败,它也会输出Failure字符串。

我们刚刚建立了第一个单元测试项目。 单击运行按钮以运行此测试,并在 Qt Creator 输出窗格中查看结果。 如果测试通过,那么您将看到类似以下内容:

********* Start testing of HelloTestTest ********* 
Config: Using QtTest library 5.9.1, Qt 5.9.1 (i386-little_endian-ilp32 shared (dynamic) debug build; by MSVC 2015) 
PASS   : HelloTestTest::initTestCase() 
PASS   : HelloTestTest::testPixelCount() 
PASS   : HelloTestTest::cleanupTestCase() 
Totals: 3 passed, 0 failed, 0 skipped, 0 blacklisted, 26ms 
********* Finished testing of HelloTestTest ********* 

如果发生故障,您将在输出中看到以下内容:

********* Start testing of HelloTestTest ********* 
Config: Using QtTest library 5.9.1, Qt 5.9.1 (i386-little_endian-ilp32 shared (dynamic) debug build; by MSVC 2015) 
PASS   : HelloTestTest::initTestCase() 
FAIL!  : HelloTestTest::testPixelCount() 'c.countPixels(fname) == width*height' returned FALSE. (Failure) 
..HelloTesttst_hellotesttest.cpp(26) : failure location 
PASS   : HelloTestTest::cleanupTestCase() 
Totals: 2 passed, 1 failed, 0 skipped, 0 blacklisted, 26ms 
********* Finished testing of HelloTestTest ********* 

结果几乎是不言而喻的,但是我们可能需要注意一件事,那就是在所有测试函数之前调用initTestCase,在所有测试函数之后调用cleanupTestCase的事实, 正如我们前面提到的。 但是,由于这些函数实际上并不存在,因此它们被标记为PASS。 如果您实现这些函数并执行实际的初始化和完成任务,则可能会有所改变。

在前面的示例中,我们看到了单元测试的最简单形式,但现实情况是,编写一个高效且可靠的单元测试来解决所有可能的问题,这是一项艰巨的任务,并且与我们面对的情况相比要复杂得多。 为了能够编写适当的单元测试,您可以在每个测试函数中使用以下宏。 这些宏在QTest中定义如下:

  • QVERIFY:可用于检查是否满足条件。 条件只是一个布尔值或任何计算结果为布尔值的表达式。 如果不满足条件,则测试将停止,失败并记录在输出中;否则,测试将失败。 否则,它将继续。
  • QTRY_VERIFY_WITH_TIMEOUT:类似于QVERIFY,但是此功能尝试检查提供的条件,直到达到给定的超时时间(以毫秒为单位)或满足条件。
  • QTRY_VERIFY:类似于QTRY_VERIFY_WITH_TIMEOUT,但是超时设置为默认值 5 秒。
  • QVERIFY2QTRY_VERIFY2_WITH_TIMEOUTQTRY_VERIFY2:这些宏与名称非常相似的以前的宏非常相似,除了在测试失败的情况下函数还会输出给定消息之外,这些宏也是如此。
  • QCOMPARE:可用于将实际值与预期的值进行比较。 它非常类似于QVERIFY,不同之处在于此宏还输出实际值和期望值以供以后参考。
  • QTRY_COMPARE_WITH_TIMEOUT:类似于QCOMPARE,但是此函数尝试比较实际值和期望值,直到达到给定的超时时间(以毫秒为单位)或相等为止。
  • QTRY_COMPARE:类似于QTRY_COMPARE_WITH_TIMEOUT,但是超时设置为默认值 5 秒。

数据驱动的测试

除了与每个测试函数内部提供的输入数据进行简单比较外,QTest还提供了使用一组更有条理和结构化的输入数据执行单元测试的方法,以执行数据驱动的测试,或者换句话说,通过不同的输入数据集。 这是通过QFETCH宏以及QTest::addColumnQTest::newRow函数来完成的。 QFETCH函数可在测试函数内使用,以获取所需的测试数据。 这需要为我们的测试函数创建一个数据函数。 数据函数还是另一个专用插槽,其名称与测试函数的名称完全相同,但名称后面附加了_data。 因此,如果我们回到前面的示例,要进行数据驱动的测试,我们需要在测试类中添加一个新的专用插槽,类似于以下内容:

void HelloTestTest::testPixelCount_data() 
{ 
    QTest::addColumn<QString>("filename"); 
    QTest::addColumn<int>("pixelcount"); 

    QTest::newRow("huge image") << 
        "c:/dev/imagehd.jpg" << 2280000; 
    QTest::newRow("small image") << 
        "c:/dev/tiny.jpg" << 51200; 
} 

请注意,数据函数名称在其名称末尾附加了_dataQTest中的测试数据被视为表格; 这就是为什么在数据函数中,addColumn函数用于创建新的列(或字段),而addRow函数用于向其中添加新的行(或记录)的原因。 前面的代码将产生类似于以下内容的测试数据表:

索引名称(或标签)文件名像素计数
0大图像c:/dev/imagehd.jpg2280000
1小图像c:/dev/tiny.jpg51200

现在,我们可以修改测试函数testPixelCount以使用此测试数据,而不是在同一函数中使用提供的单个文件名。 我们新的testPixelCount看起来与此类似(同时,为了更好的测试日志输出,我们也将QVERIFY替换为QCOMPARE):

void HelloTestTest::testPixelCount() 
{ 
    PixelCounter c; 
    QFETCH(QString, filename); 
    QFETCH(int, pixelcount); 
    QCOMPARE(c.countPixels(filename), pixelcount); 
} 

重要的是要注意,必须为QFETCH提供在数据函数内部创建的测试数据中每一列的确切数据类型和元素名称。 如果我们再次执行测试,则测试框架将调用testPixelCount,与测试数据中的行一样多,每次它将通过获取并使用新行并记录输出来运行测试函数。 使用数据驱动的测试函数有助于保持实际的测试函数完整,并且不是从测试函数内部创建测试数据,而是从简单且结构化的数据函数中获取它们。 不用说,您可以扩展它以从磁盘上的文件或其他输入方法(例如网络位置)中获取测试数据。 无论数据来自何处,当数据函数存在时,数据都应完整存在并正确构造。

基准管理

QTest提供QBENCHMARKQBENCHMARK_ONCE宏来测量函数调用或任何其他代码的性能(基准)。 这两个宏的区别仅在于它们重复一段代码以衡量其性能的次数,而后者显然只运行一次代码。 您可以通过以下方式使用这些宏:

QBENCHMARK 
{ 
    // Piece of code to be benchmarked 
} 

同样,我们可以在前面的示例中使用它来衡量PixelCounter类的性能。 您可以简单地将以下行添加到testPixelCount函数的末尾:

QBENCHMARK 
{ 
    c.countPixels(filename); 
} 

如果再次运行测试,您将在测试日志输出中看到类似于以下内容的输出。 请注意,这些数字仅是在随机测试 PC 上运行的示例,在各种系统上它们可能会有很大不同:

23 msecs per iteration (total: 95, iterations: 4) 

前面的测试输出意味着每次使用特定的测试图像对函数进行测试都花费了 23 毫秒。 另一方面,迭代次数为4,用于基准测试的总时间约为 95 毫秒。

GUI 测试

与执行特定任务的测试类相似,也可以创建用于测试 GUI 功能或小部件行为的单元测试。 在这种情况下,唯一的区别是需要为 GUI 提供鼠标单击,按键和类似的用户交互。 QTest支持通过模拟鼠标单击和其他用户交互来测试使用 Qt 创建的 GUI。 QTest命名空间中提供以下函数,以编写能够执行 GUI 测试的单元测试。 注意,几乎所有它们都依赖于以下事实:Qt 中的所有小部件和 GUI 组件都是QWidget的子类:

  • keyClick:可以用来模拟单击键盘上的按键。 为了方便起见,此函数有许多重载版本。 您可以选择提供修改键(ALTCTRL等)和/或单击该键之前的延迟时间。 keyClick不应与mouseClick混淆,稍后我们将对其进行介绍,它指的是一次按键并释放,从而导致单击。
  • keyClicks:这与keyClick十分相似,但是它可以用于模拟序列中的按键单击,同样具有可选的修饰符或两者之间的延迟。
  • keyPress:这再次类似于keyClick,但是它仅模拟按键的按下,而不释放它们。 如果我们需要模拟按下一个键,这将非常有用。
  • keyRelease:这与keyPress相反,这意味着它仅模拟键的释放而没有按下键。 如果我们想使用keyPress模拟释放先前按下的键,这将很有用。
  • keyEvent:这是键盘模拟函数的更高级版本,带有一个附加的动作参数,该参数定义是否按下,释放,单击(按下并释放)键,或者它是快捷键。
  • mouseClick:类似于keyClick,但是它可以通过单击鼠标进行操作。 这就是为此函数提供的键是鼠标按钮(例如,左,右,中等)的原因。 键的值应该是Qt::MouseButton枚举的条目。 它还支持键盘修饰符和模拟点击之前的延迟时间。 此外,此函数和所有其他鼠标模拟函数还带有一个可选点(QPoint),其中包含要单击的小部件(或窗口)内的位置。 如果提供了一个空白点,或者如果省略了此参数,则模拟的点击将发生在小部件的中间。
  • mouseDClick:这是mouseClick函数的双击版本。
  • mousePress:这与mouseClick十分相似,但是仅模拟鼠标的按下,而不释放它。 如果要模拟按住鼠标按钮,这将很有用。
  • mouseRelease:与mousePress相反,这意味着它仅模拟鼠标按钮的释放而没有按下。 这可以用来模拟一段时间后释放鼠标按钮。
  • mouseMove:可以用来模拟在小部件上移动鼠标光标。 此函数必须提供点和延迟。 与其他鼠标交互函数类似,如果未设置任何点,则将鼠标移动到小部件的中间点。 与mousePressmouseRelease结合使用时,此函数可用于模拟和测试拖放。

让我们创建一个简单的 GUI 测试以熟悉在实践中如何使用上述函数。 假设要测试已经创建的窗口或窗口小部件,则必须首先将其包含在 Qt 单元测试项目中。 因此,从创建单元测试项目开始,与在上一个示例以及我们的第一个测试项目中类似。 在项目创建期间,请确保还选择QtWidgets作为必需的模块之一。 然后,将窗口小部件类文件(可能是标头,源文件和 UI 文件)添加到测试项目。 在我们的示例中,我们假设我们有一个带有按钮和标签的简单 GUI。 每次按下该按钮,标签上的数字将乘以 2。 为了能够测试此功能或任何其他 GUI 功能,我们必须首先通过将其公开,确保表单,容器小部件或窗口上的小部件对测试类公开。 在实现此目的的许多方法中,最快,最简单的方法是在类声明中也以公共成员的身份定义相同的小部件。 然后,只需将ui变量(在使用“新建文件”或“项目”向导创建的所有 Qt 窗口小部件中找到的)变量中的类分配给整个类的成员。 假设我们窗口上的按钮和标签分别命名为nextBtninfoLabel(使用设计器设计时),然后我们必须在类声明public成员中定义以下内容:

QPushButton *nextBtn; 
QLabel *infoLabel; 

并且,我们必须在构造器中分配它们,如下所示:

ui->setupUi(this); 
this->nextBtn = ui->nextBtn; 
this->infoLabel = ui->infoLabel; 

确保在调用setupUi之后始终分配使用设计器和 UI 文件创建的窗口小部件; 否则,您的应用肯定会崩溃,因为直到调用setupUi才真正创建任何小部件。 现在,假设我们的小部件类称为TestableForm,我们可以在测试类中拥有一个专用的testGui插槽。 请记住,每次按下nextBtn时,infoLabel上的数字都将乘以 2,因此testGui函数中可以有类似以下内容:

void GuiTestTest::testGui() 
{ 
    TestableForm t; 

    QTest::mouseClick(t.nextBtn, Qt::LeftButton); 
    QCOMPARE(t.infoLabel->text(), QString::number(1)); 

    QTest::mouseClick(t.nextBtn, Qt::LeftButton); 
    QCOMPARE(t.infoLabel->text(), QString::number(2)); 

    QTest::mouseClick(t.nextBtn, Qt::LeftButton); 
    QCOMPARE(t.infoLabel->text(), QString::number(4)); 

 // repeated until necessary 
} 

替换以下行也非常重要:

QTEST_APPLESS_MAIN(GuiTestTest) 

添加以下行:

QTEST_MAIN(GuiTestTest) 

否则,不会在幕后创建QApplication,并且测试将完全失败。 使用 Qt 测试框架测试 GUI 时要记住这一点很重要。 现在,如果您尝试运行单元测试,则将单击nextBtn小部件 3 次,然后每次检查infoLabel显示的值是否正确。 如果发生故障,它将记录在输出中。 这很容易,但是问题是,如果所需交互的数量增加了怎么办? 如果必须执行大量的 GUI 交互该怎么办? 为了克服这个问题,您可以结合使用数据驱动的测试和 GUI 测试来轻松重放 GUI 交互(或事件,在 Qt 框架中称为事件)。 请记住,要在测试类中具有测试函数的数据函数,必须创建一个新函数,该函数的名称应与_data完全相同。 因此,我们可以创建一个名为testGui_data的新函数,该函数准备交互集和结果集,并使用QFETCH将其传递给测试函数,就像我们在前面的示例中使用的那样:

void GuiTestTest::testGui_data() 
{ 
    QTest::addColumn<QTestEventList>("events"); 
    QTest::addColumn<QString>("result"); 

    QTestEventList mouseEvents; // three times 
    mouseEvents.addMouseClick(Qt::LeftButton); 
    mouseEvents.addMouseClick(Qt::LeftButton); 
    mouseEvents.addMouseClick(Qt::LeftButton); 
    QTest::newRow("mouse") << mouseEvents << "4"; 

    QTestEventList keybEvents; // four times 
    keybEvents.addKeyClick(Qt::Key_Space); 
    keybEvents.addDelay(250); 
    keybEvents.addKeyClick(Qt::Key_Space); 
    keybEvents.addDelay(250); 
    keybEvents.addKeyClick(Qt::Key_Space); 
    keybEvents.addDelay(250); 
    keybEvents.addKeyClick(Qt::Key_Space); 
    QTest::newRow("keyboard") << keybEvents << "8"; 
} 

QTestEventList类是 Qt 测试框架中的便捷类,可用于轻松创建 GUI 交互列表并对其进行仿真。 它包含添加所有我们之前提到的所有可能交互的功能,这些交互是可以使用 Qt Test 执行的可能事件的一部分。

要使用此数据函数,我们需要覆盖testGui函数,如下所示:

void GuiTestTest::testGui() 
{ 
    TestableForm t; 
    QFETCH(QTestEventList, events); 
    QFETCH(QString, result); 
    events.simulate(t.nextBtn); 
    QCOMPARE(t.infoLabel->text(), result); 
} 

类似于任何数据驱动的测试,QFETCH获取由数据函数提供的数据。 但是,在这种情况下,存储的数据为QEventList,并填充了一系列必需的交互操作。 此测试方法在重放错误报告中的一系列事件以重现,修复和进一步测试特定问题方面非常有效。

测试用例项目

在前面的部分及其相应的示例中,我们看到了一些简单的测试用例,并使用 Qt Test 函数对其进行了解决。 我们了解了数据驱动和 GUI 测试,以及如何结合两者以重放 GUI 事件并执行更复杂的 GUI 测试。 我们在每种情况下学到的相同方法都可以进一步扩展,以应用于更复杂的测试用例。 在本节中我们将学习确保在构建项目时自动执行测试。 当然,根据测试所需的时间和我们的喜好,我们可能希望轻松地暂时跳过自动测试,但是最终,在构建项目时,我们将需要轻松执行测试。 为了能够自动运行您的 Qt 项目的测试单元(我们将其称为主项目),首先,我们需要确保始终使用Subdirs模板创建它们,然后将单元测试项目配置为测试案例项目。 这也可以通过已经存在但不在Subdirs模板中的项目来完成。 只需按照本节提供的步骤将现有项目添加到Subdirs模板,并为其创建一个单元测试(配置为测试用例),该单元测试在您构建主项目时将自动运行:

  1. 首先使用 Qt Creator 中“欢迎”模式下的“新建项目”按钮创建一个新项目,或者从“文件”菜单中选择“新建文件”或“项目”项。

  2. 确保选择Subdirs项目,如以下屏幕截图所示,然后单击“选择”:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-B5SJ3YtM-1681870063162)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/dd3aef3f-33d9-4763-9184-f2b5c2feded4.png)]

  1. 为您的项目选择一个名称。 该名称可以与您的主项目名称相同。 假设它称为computer_vision。 继续前进,然后在最后一个对话框中,单击“完成 & 添加子项目”按钮。 如果您是从头开始创建项目,则可以像整本书一样简单地创建项目。 否则,这意味着如果您想添加现有项目(假设在名为src的文件夹内),只需单击“取消”,然后将要为其构建测试的现有项目复制到此新创建的项目文件夹subdirs中。 然后,打开computer_vision.pro文件,并将其修改为类似于以下代码行:
TEMPLATE = subdirs 
SUBDIRS += src
  1. 现在,您可以创建一个单元测试项目,该项目也是computer_vision子目录项目的子项目,并对它进行编程以测试src文件夹中存在的类(您的主项目,它是实际的应用本身) )。 因此,再次从项目窗格中右键单击computer_vision,然后通过选择“新建子项目”,开始使用在上一节中学到的所有内容来创建单元测试。
  2. 创建测试后,无论使用哪个主项目查看测试结果,都应该能够单独运行它。 但是,要确保将其标记为测试用例项目,需要将以下代码行添加到单元测试项目的*.pro文件中:
CONFIG += testcase 
  1. 最后,您需要在 Qt Creator 中切换到项目模式,并将检查添加到Make arguments字段中,如以下屏幕截图所示。 确保首先使用“详细信息”扩展器按钮扩展“制作”部分; 否则,它将不可见:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QMh5YcjU-1681870063162)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/cv-opencv3-qt5/img/04bab01d-a86b-489f-b679-e0fcf642843d.png)]

现在,无论您是否专门运行单元测试项目都没有关系,并且每次运行主项目或尝试构建它时,测试都会自动执行。 这是一种非常有用的技术,可确保对一个库的更改不会对另一个库造成负面影响。 关于此技术要注意的重要一点是,测试结果实际上会影响构建结果。 意思是,您会在构建测试时注意到测试是否自动失败,并且测试结果将在 Qt Creator 的编译器输出窗格中可见,可以使用底部的栏或按ALT + 4键。

总结

在本章中,您学习了如何使用 Qt Creator 进行调试以及它提供的功能,以便进一步分析代码,发现问题并尝试使用断点,调用栈查看器等对其进行修复。 这只是使用调试器可以完成的工作的一点点尝试,它的目的是让您准备自己继续使用调试器,并养成自己的编码和调试习惯,从而可以帮助您克服更多编程问题。 缓解。 除了调试和开发人员级别的测试外,我们还了解了 Qt 中的单元测试,这对于使用 Qt 框架编写的越来越多的应用和项目尤其重要。 测试自动化是当今应用开发行业中的热门话题之一,对 Qt 测试框架有清晰的想法将有助于您开发更好和可靠的测试。 习惯于为项目编写单元测试非常重要,是的,即使是非常小的项目也是如此。 对于初学者或业余爱好者而言,测试应用和避免回归的成本并不容易理解,因此,为在开发生涯的后期肯定会遇到的事情做好准备是一个好主意。

在接近本书最后几章的同时,我们也越来越关注使用 Qt 和 OpenCV 进行应用开发的最后阶段。 因此,在下一章中,您将学习有关向最终用户部署应用的知识。 您还将了解应用的动态和静态链接,以及创建可以轻松安装在具有不同操作系统的计算机上的应用包。 下一章将是我们在台式机平台上使用 OpenCV 和 Qt 进行计算机视觉之旅的最后一章。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
OpenCV3和Qt5计算机视觉应用开发》是一本介绍如何结合OpenCV3和Qt5进行计算机视觉应用开发的书籍。本书共分为八章,内容丰富而全面。 第一章是对计算机视觉和相关技术的概述,引导读者了解计算机视觉的基础知识,以及OpenCV3和Qt5的基本概念和使用方法。 第二到第五章依次介绍了OpenCV3和Qt5的基础知识和使用方法。其中,在OpenCV3的章节中,读者能够学习到如何使用OpenCV3进行图像处理、特征提取、目标检测等计算机视觉任务。而在Qt5的章节中,读者将学习到如何使用Qt5进行图形界面设计,以及如何将OpenCV3与Qt5进行桥接,实现计算机视觉应用的图形化界面。 第六章介绍了如何在Qt5中导入OpenCV3库,并给出了一些在Qt中使用OpenCV进行图像处理的示例代码。读者可以通过这一章的学习,了解如何在Qt中调用OpenCV函数,实现各种图像处理功能。 第七章是一个完整的计算机视觉应用案例,案例中介绍了一个基于OpenCV3和Qt5开发的人脸识别系统。通过阅读这一章的内容,读者可以了解到如何运用OpenCV3和Qt5构建一个实际的计算机视觉应用系统,并了解到其中的原理和细节。 第八章是有关Qt5的高级使用和扩展。本章内容较为高级,主要介绍了如何使用Qt5进行多线程编程、网络编程和数据库操作等高级技术,并给出了一些示例代码。 总之,《OpenCV3和Qt5计算机视觉应用开发》是一本非常实用的书籍,适合计算机视觉爱好者和开发者阅读,通过学习本书,读者能够掌握使用OpenCV3和Qt5进行计算机视觉应用开发的技巧和方法。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值