OpenCV3 和 Qt5 计算机视觉(二)

部署运行你感兴趣的模型镜像

原文:Computer Vision with OpenCV 3 and Qt5

协议:CC BY-NC-SA 4.0

五、图形视图框架

既然我们已经熟悉了 Qt 和 OpenCV 框架中计算机视觉应用的基本构建模块,那么我们可以继续学习有关计算机视觉应用中可视化部分的开发的更多信息。 谈论计算机视觉,每个用户都会立即寻找一些预览图像或视频。 以您想要的任何图像编辑器为例,它们都在用户界面上包含一个区域,该区域可立即引起注意,并且可以通过 GUI 上的其他组件轻松地通过一些边框甚至简单的线条来识别。 关于视频编辑软件以及实际上需要与视觉概念和媒体输入源配合使用的所有内容,也可以这样说。 同样,对于我们将创建的计算机视觉应用,完全相同的推理也适用。 当然,在某些情况下,过程的结果只是简单地显示为数值或通过网络发送给与过程有关的其他各方。 但是,对我们来说幸运的是,我们将看到这两种情况,因此我们需要在应用中具有类似的功能,以便用户可以预览自己打开的文件或查看生成的转换(或过滤后的)图像。 屏幕。 甚至更好,请在实时视频输出预览面板中查看某些对象检测算法的结果。 该面板基本上是一个场景,或者甚至更好,它是一个图形场景,这是本书本章要讨论的主题。

在 Qt 框架内的许多模块,类和子框架下,有一块专门用于简化图形处理的工具,称为图形视图框架。 它包含许多类,几乎所有的类都以QGraphics开头,并且所有这些类都可用于处理构建计算机视觉应用时可能遇到的大多数图形任务。 图形视图框架将所有可能的对象简单地分为三个主要类别,随之而来的架构允许轻松地添加,删除,修改以及显示图形对象。

  • 场景(QGraphicsScene类)
  • 视图(QGraphicsView小部件)
  • 图形项目(QGraphicsItem及其子类)

在之前的章节中,我们使用了最简单的方式同时使用 OpenCV(imshow函数)和 Qt 标签窗口小部件来可视化图像,这在处理显示的图像(例如选择它们,修改它们, 缩放它们,依此类推。 即使是最简单的任务,例如选择图形项目并将其拖动到其他位置,我们也必须编写大量代码并经历令人困惑的鼠标事件处理。 放大和缩小图像也是如此。 但是,通过使用图形视图框架中的类,可以更轻松地处理所有这些事情,并具有更高的性能,因为图形视图框架类旨在以高效的方式处理许多图形对象。

在本章中,我们将开始学习 Qt 的图形视图框架中最重要的类,并且重要的是,我们显然是指与构建全面的计算机视觉应用所需的类最相关的类。 本章学习的主题将完成Computer_Vision项目的基础,该项目是在第 3 章,“创建全面的 Qt + OpenCV 项目”的结尾创建的。 到本章末,您将能够创建一个与图像编辑软件中看到的场景相似的场景,在该场景中,您可以向场景中添加新图像,选择它们,删除它们,放大和缩小它们等等。 您还将在本章末找到Computer_Vision项目基础和基础版本的链接,我们将继续使用该链接,直到本书的最后几章。

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

  • 如何使用QGraphicsScene在场景上绘制图形
  • 如何使用QGraphicsItem及其子类来管理图形项目
  • 如何使用QGraphicsView查看QGraphicsScene
  • 如何开发放大,缩小以及其他图像编辑和查看功能

场景-视图-项目架构

正如引言中提到的那样,Qt 中的图形视图框架(或从现在开始简称 Qt)将可能需要处理的与图形相关的对象分为三个主要类别,即场景,视图和项目。 Qt 包含名称非常醒目的类,以处理此架构的每个部分。 尽管从理论上讲,将它们彼此分开很容易,但在实践中,它们却是交织在一起的。 这意味着我们不能不提及其他人而真正深入研究其中之一。 清除架构的一部分,您将完全没有图形。 另外,再看一下架构,我们可以看到模型视图设计模式,其中模型(在本例中为场景)完全不知道如何显示或显示哪个部分。 正如在 Qt 中所说的,这是一种基于项目的模型-视图编程方法,我们将牢记这一点,同时还要简要介绍一下它们中的每一个在实践中的含义:

  • 场景或QGraphicsScene管理项目或QGraphicsItem的实例(其子类),包含它们,并将事件(例如,鼠标单击等)传播到项目中。
  • 视图或QGraphicsView小部件用于可视化和显示QGraphicsScene的内容。 它还负责将事件传播到QGraphicsScene。 这里要注意的重要一点是QGraphicsSceneQGraphicsView都具有不同的坐标系。 可以猜到,如果放大,缩小或进行不同的相似变换,则场景上的位置将不同。 QGraphicsSceneQGraphicsView都提供了转换彼此适合的位置值的功能。
  • 这些项目或QGraphicsItem子类的实例是QGraphicsScene中包含的项目。 它们可以是线,矩形,图像,文本等。

让我们从一个简单的入门示例开始,然后继续详细讨论上述每个类:

  1. 创建一个名为Graphics_Viewer的 Qt Widgets 应用,类似于在第 4 章,“MatQImage”中创建的项目,以了解有关在 Qt 中显示图像的信息。 但是,这一次只需向其中添加“图形视图”窗口小部件,而无需任何标签,菜单,状态栏等。 将其objectName属性保留为graphicsView

  2. 另外,添加与以前相同的拖放功能。 如前所述,您需要在MainWindow类中添加dragEnterEventdropEvent。 并且不要忘记将setAcceptDrops添加到MainWindow类的构造器中。 显然,这一次,您需要删除用于在QLabel上设置QPixmap的代码,因为该项目中没有任何标签。

  3. 现在,将所需变量添加到mainwindow.hMainWindow类的私有成员部分,如下所示:

        QGraphicsScene scene; 

scene基本上是我们将使用并显示在添加到MainWindow类的QGraphicsView小部件中的场景。 最有可能的是,您需要为所使用的每个类添加一个#include语句,这是代码编辑器无法识别的。 您还将获得与此相关的编译器错误,通常可以很好地提醒我们忘记将其包含在源代码中的类。 因此,从现在开始,请确保为您使用的每个 Qt 类添加一个类似于以下内容的#include指令。 但是,如果要使某个类可用就需要采取任何特殊的措施,则将在书中明确说明:

        #include <QGraphicsScene> 
  1. 接下来,我们需要确保我们的graphicsView对象可以访问场景。 您可以通过在MainWindow构造器中添加以下行来实现。 (步骤 5 之后的行。)
  2. 另外,您需要为graphicsView禁用acceptDrops,因为我们希望能够保留放置在窗口各处的图像。 因此,请确保您的MainWindow构造器仅包含以下函数调用:
        ui->setupUi(this); 
        this->setAcceptDrops(true); 
        ui->graphicsView->setAcceptDrops(false); 
        ui->graphicsView->setScene(&scene); 
  1. 接下来,在上一个示例项目的dropEvent函数中,我们设置标签的pixmaps属性,这一次,我们需要确保创建了QGraphicsItem并将其添加到场景中,或者准确地说是QGraphicsPixmapItem。 这可以通过两种方式完成,让我们来看第一种:
        QFileInfo file(event 
               ->mimeData() 
               ->urls() 
               .at(0) 
               .toLocalFile()); 
        QPixmap pixmap; 
        if(pixmap.load(file 
               .absoluteFilePath())) 
        { 
          scene.addPixmap(pixmap); 
        } 
        else 
        { 
         // Display an error message 
        } 

在这种情况下,我们仅使用了QGraphicsSceneaddPixmap函数。 另外,我们可以创建QGraphicsPixmapItem并使用addItem方法将其添加到场景中,如下所示:

         QGraphicsPixmapItem *item =  
            new QGraphicsPixmapItem(pixmap); 
         scene.addItem(item); 

在这两种情况下,都不必担心项目指针,因为在调用addItem时场景将拥有它的所有权,并且场景会自动从内存中清除。 当然,如果我们要手动从场景和内存中完全删除该项目,我们可以编写一个简单的delete语句来删除该项目,如下所示:

        delete item; 

我们的简单代码有一个大问题,乍看之下看不到,但是如果我们继续将图像拖放到窗口中,则每次将最新图像添加到先前图像的顶部并且不清除先前图像。 实际上,如果您亲自尝试一下,这是一个好主意。 但是,首先在写入addItem的行之后添加以下行:

        qDebug() << scene.items().count(); 

您需要将以下头文件添加到mainwindow.h文件中,此文件才能起作用:

        #include <QDebug>

现在,如果您运行该应用并尝试通过将其拖放到窗口中来添加图像,您会注意到,在 Qt Creator 代码编辑器屏幕底部的“应用输出”窗格中,每次放置图像时,所显示的数字增加,即sceneitemscount

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/1281f635-ff2b-4d8e-b4be-cb19736c9575.png

如上例所示,使用qDebug()是许多 Qt 开发人员用来在开发过程中快速查看某些变量的值的技巧。 Qt 中的qDebug()是与std::cout类似的玩具,用于输出到控制台(或终端)。 我们将在第 10 章,“调试和测试”中了解更多有关测试和调试的信息,但现在,让我们记下qDebug()并使用它来快速解决以下问题。 我们使用 Qt 和 C++ 进行开发时的代码。

  1. 因此,要解决前面示例中提到的问题,我们显然需要先对clearscene进行添加。 因此,只需在调用任何addItem(或addPixmap等)之前添加以下内容:
        scene.clear(); 

尝试再次运行您的应用,然后查看结果。 现在,将其放入我们的应用窗口后,应该只存在一个图像。 另外,记下应用的输出,您将看到显示的值始终为1,这是因为在任何时候scene中始终只保留一个图像。 在我们刚才看到的示例项目中,我们使用了 Qt 的图形视图框架中的所有现有主要部分,即场景,项目和视图。 现在,我们将详细了解这些类,同时,为我们全面的计算机视觉应用Computer_Vision项目创建强大的图形查看器和编辑器。

场景,QGraphicsScene

此类提供了处理多个图形项(QGraphicsItem)所需的几乎所有方法,即使在前面的示例中我们仅将其与单个QGraphicxPixmapItem一起使用。 在本节中,我们将回顾该类中一些最重要的函数。 如前所述,我们将主要关注用例所需的属性和方法,因为涵盖所有方法(尽管它们都很重要)对于本书而言都是徒劳的。 我们将跳过QGraphicsScene的构造器,因为它们仅用于获取场景的尺寸并相应地创建场景。 至于其余的方法和属性,就在这里,对于其中一些可能不太明显的示例,您可以找到一个简单的示例代码,可以使用本章前面创建的Graphics_Viewer项目进行尝试 :

  • addEllipseaddLineaddRectaddPolygon函数可以从它们的名称中猜测出来,可以用来向场景添加通用的几何形状。 它们中的一些提供了重载函数,以便于输入参数。 创建并添加到场景时,上述每个函数都会返回其对应的QGraphicsItem子类实例(如下所示)。 返回的指针可以保留,以后可用于修改,删除或以其他方式使用该项目:
    • QGraphicsEllipseItem
    • QGraphicsLineItem
    • QGraphicsRectItem
    • QGraphicsPolygonItem

这是一个例子:

        scene.addEllipse(-100.0, 100.0, 200.0, 100.0, 
                QPen(QBrush(Qt::SolidPattern), 2.0), 
                QBrush(Qt::Dense2Pattern)); 

        scene.addLine(-200.0, 200, +200, 200, 
              QPen(QBrush(Qt::SolidPattern), 5.0)); 

        scene.addRect(-150, 150, 300, 140); 

        QVector<QPoint> points; 
        points.append(QPoint(150, 250)); 
        points.append(QPoint(250, 250)); 
        points.append(QPoint(165, 280)); 
        points.append(QPoint(150, 250)); 
        scene.addPolygon(QPolygon(points)); 

这是前面代码的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/6011fe12-5460-4f62-bb9f-87e21929ade9.png

  • addPath函数可用于将QPainterPath与给定的QPenQBrush添加到场景中。 QPainterPath类可用于记录绘画操作,类似于我们在QPainter中看到的操作,并在以后使用它们。 另一方面,QPenQBrush类具有不言自明的标题,但在本章后面的示例中我们也将使用它们。 addPath函数返回一个指向新创建的QGraphicsPathItem实例的指针。

  • addSimpleTextaddText函数可用于将纯文本和带格式的文本添加到场景中。 它们分别返回指向QGraphicsSimpleTextItemQGraphicsTextItem的指针。

  • 在上一示例中已经使用过的addPixmap函数可用于将图像添加到场景,并且它返回指向QGraphicsPixmapItem类的指针。

  • addItem函数仅接受任何QGraphicsItem子类并将其添加到场景中。 我们在前面的示例中也使用了此函数。

  • addWidget函数可用于将 Qt 小部件添加到场景。 除了某些特殊的小部件(即设置了Qt::WA_PaintOnScreen标志的小部件或使用外部库(例如OpenGLActive-X绘制的小部件))之外,您还可以将其他任何小部件添加到场景中,就像将其添加到场景中一样。 一个窗口。 这为使用交互式图形项创建场景提供了巨大的力量。 您绝对可以使用它来创建简单的游戏,添加对图像执行某些操作的按钮以及许多其他功能。 我们将在Computer_Vision项目中大量使用此示例,并提供足够的示例来帮助您入门,但是现在这是一个简短的示例:

        QPushButton *button = new QPushButton(Q_NULLPTR); 
        connect(button, SIGNAL(pressed()), this, SLOT(onAction())); 
        button->setText(tr("Do it!")); 
        QGraphicsProxyWidget* proxy = scene.addWidget(button); 
        proxy->setGeometry(QRectF(-200.0, -200, 400, 100.0)); 

前面的代码只是添加了一个标题为Do it!的按钮,并将其连接到名为onAction的插槽。 每当按下场景中的此按钮时,就会调用onAction函数。 与向窗口添加按钮时完全相同:

  • setBackgroundBrushbackgroundBrushsetForegroundBrushforegroundBrush函数允许访问负责刷刷场景的backgroundforegroundQBrush类。

  • fontsetFont函数可用于获取或设置QFont类,以确定场景中使用的字体。

  • 当我们想要定义最小尺寸来决定某项是否适合绘制(渲染)时,minimumRenderSizesetMinimumRenderSize函数非常有用。

  • sceneRectsetSceneRect函数可用于指定场景的边界矩形。 这基本上意味着场景的宽度和高度,以及其在坐标系上的位置。 重要的是要注意,如果未调用setSceneRect或在QGraphicsScene的构造器中未设置矩形,则调用sceneRect将始终返回可以覆盖添加到场景的所有项目的最大矩形。 始终最好设置一个场景矩形,并根据需要在场景中进行任何更改等操作,基本上根据需要手动(使用setSceneRect)再次对其进行设置。

  • stickyFocussetStickyFocus函数可用于启用或禁用场景的粘滞聚焦模式。 如果启用了粘滞聚焦,则单击场景中的空白区域不会对聚焦的项目产生任何影响; 否则,将仅清除焦点,并且不再选择选定的项目。

  • collidingItems是一个非常有趣的功能,可用于简单地确定某项是否与其他任何项共享其区域的某个部分(或发生碰撞)。 您需要将QGraphicsItem指针与Qt::ItemSelectionMode一起传递,您将获得与项目发生冲突的QGraphicsItem实例的QList

  • createItemGroupdestroyItemGroup函数可用于创建和删除QGraphicsItemGroup类实例。 QGraphicsItemGroup基本上是另一个QGraphicsItem子类(如QGraphicsLineItem等),可用于将一组图形项分组并因此表示为单个项。

  • hasFocussetFocusfocusItemsetFocusItem函数均用于处理图形场景中当前聚焦的项目。

  • 返回与sceneRect.width()sceneRect.height()相同值的widthheight可用于获取场景的宽度和高度。 请务必注意,这些函数返回的值的类型为qreal(默认情况下与double相同),而不是integer,因为场景坐标在像素方面不起作用。 除非使用视图绘制场景,否则将其上的所有内容都视为逻辑和非视觉对象,而不是视觉对象,这是QGraphicsView类的领域。

  • 在某些情况下,与update()相同的invalidate可用于请求全部或部分重绘场景。 类似于刷新函数。

  • itemAt函数可用于在场景中的某个位置找到指向QGraphicItem的指针。

  • item返回添加到场景的项目列表。 基本上是QGraphicsItemQList

  • itemsBoundingRect可用于获取QRectF类,或仅获取可包含场景中所有项目的最小矩形。 如果我们需要查看所有项目或执行类似操作,此函数特别有用。

  • mouseGrabberItem可用于获取当前单击的项目,而无需释放鼠标按钮。 此函数返回一个QGraphicsItem指针,使用它我们可以轻松地向场景添加“拖动和移动”或类似功能。

  • removeItem函数可用于从场景中删除项目。 此函数不会删除该项目,并且调用方负责任何必需的清理。

  • render可用于渲染QPaintDevice上的场景。 这只是意味着您可以使用QPainter类(如您在第 4 章,“MatQImage”中学习的)在QImageQPrinter等类似对象上绘制场景,通过将QPainter类的指针传递给此函数。 (可选)您可以在QPaintDevice渲染目标类的一部分上渲染场景的一部分,并且还要注意宽高比的处理。

  • selectedItemsselectionAreasetSelectionArea函数结合使用时,可以帮助处理一个或多个项目选择。 通过提供Qt::ItemSelectionMode枚举,我们可以基于完全选择一个框中的项目或仅对其一部分进行选择,等等。 我们还可以为该函数提供Qt::ItemSelectionOperation枚举条目,以增加选择或替换所有先前选择的项目。

  • sendEvent函数可用于将QEvent类(或子类)发送到场景中的项目。

  • stylesetStyle函数用于设置和获取场景样式。

  • update函数可用于重绘部分或全部场景。 当场景的视觉部分发生变化时,最好将此函数与QGraphicsScene类发出的变化信号结合使用。

  • views函数可用于获取QList类,其中包含用于显示(或查看)此场景的QGraphicsView小部件。

除了先前的现有方法外,QGraphicsScene提供了许多虚拟函数,可用于进一步自定义和增强QGraphicsScene类的行为以及外观。 因此,与其他任何类似的 C++ 类一样,您需要创建QGraphicsScene的子类,并只需添加这些虚拟函数的实现即可。 实际上,这是使用QGraphicsScene类的最佳方法,它为新创建的子类提供了极大的灵活性:

  • 可以覆盖dragEnterEventdragLeaveEventdragMoveEventdropEvent函数,以向场景添加拖放功能。 请注意,这与前面示例中将图像拖放到窗口中所做的非常相似。 这些事件中的每一个都提供足够的信息和参数来处理整个拖放过程。
  • 如果我们需要在整个场景中添加自定义背景或前景,则应覆盖drawBackgrounddrawForeground函数。 当然,对于简单的背景或前景绘画或着色任务,我们可以简单地调用setBackgroundBrushsetForegroundBrush函数,而跳过这些函数。
  • mouseDoubleClickEventmouseMoveEventmousePressEventmouseReleaseEventwheelEvent函数可用于处理场景中的不同鼠标事件。 例如,当我们在Computer_Vision项目中为场景添加放大和缩小功能时,将在本章稍后使用wheelEvent
  • 可以覆盖event以处理场景接收到的所有事件。 此函数基本上负责将事件调度到其相应的处理器,但是它也可以用于处理自定义事件或不具有便捷功能的事件,例如前面提到的所有事件。

就像到目前为止您学过的所有类一样,无论是在 Qt 还是 OpenCV 中,本书中提供的方法,属性和函数的列表都不应被视为该类各个方面的完整列表。 最好总是使用框架的文档来学习新函数和属性。 但是,本书中的描述旨在更简单,尤其是从计算机视觉开发人员的角度出发。

项目,QGraphicsItem

这是场景中绘制的所有项目的基类。 它包含各种方法和属性来处理每个项目的绘制,碰撞检测(与其他项目),处理鼠标单击和其他事件,等等。 即使您可以将其子类化并创建自己的图形项,Qt 也会提供一组子类,这些子类可用于大多数(如果不是全部)日常图形任务。 以下是这些子类,在前面的示例中已经直接或间接使用了这些子类:

  • QGraphicsEllipseItem
  • QGraphicsLineItem
  • QGraphicsPathItem
  • QGraphicsPixmapItem
  • QGraphicsPolygonItem
  • QGraphicsRectItem
  • QGraphicsSimpleTextItem
  • QGraphicsTextItem

如前所述,QGraphicsItem提供了许多函数和属性来处理图形应用中的问题和任务。 在本节中,我们将介绍QGraphicsItem中一些最重要的成员,这些成员因此可以通过熟悉前面提到的子类来帮助我们:

  • acceptDropssetAcceptDrops函数可用于使项目接受拖放事件。 请注意,这与我们在前面的示例中已经看到的拖放事件非常相似,但是这里的主要区别是项目本身可以识别拖放事件。
  • acceptHoverEventssetAcceptHoverEventsacceptTouchEventssetAcceptTouchEventsacceptedMouseButtonssetAcceptedMouseButtons函数均处理项目交互及其对鼠标单击的响应等。 这里要注意的重要一点是,一个项目可以根据Qt::MouseButtons枚举设置来响应或忽略不同的鼠标按钮。 这是一个简单的例子:
        QGraphicsRectItem *item = 
           new QGraphicsRectItem(0, 
                                 0, 
                                 100, 
                                 100, 
                                 this); 
        item->setAcceptDrops(true); 
        item->setAcceptHoverEvents(true); 
        item->setAcceptedMouseButtons( 
                Qt::LeftButton | 
                Qt::RightButton | 
                Qt::MidButton); 
  • boundingRegion函数可用于获取描述图形项区域的QRegion类。 这是一项非常重要的函数,因为它可用于获取需要绘制(或重绘)项目的确切区域,并且与项目的边界矩形不同,因为简单地说,该项目可能仅覆盖其边界矩形的一部分,如直线等。 有关更多信息,请参见以下示例。
  • 在计算项目的boundingRegion函数时,boundingRegionGranularitysetBoundingRegionGranularity函数可用于设置和获取粒度级别。 从这个意义上讲,粒度是01之间的实数,它对应于计算时的预期详细程度:
        QGraphicsEllipseItem *item = 
            new QGraphicsEllipseItem(0, 
                                     0, 
                                     100, 
                                     100); 
        scene.addItem(item); 
        item->setBoundingRegionGranularity(g); // 0 , 0.1 , 0.75 and 1.0 
        QTransform transform; 
        QRegion region = item->boundingRegion(transform); 
        QPainterPath painterPath; 
        painterPath.addRegion(region); 
        QGraphicsPathItem *path = new QGraphicsPathItem(painterPath); 
        scene.addItem(path); 

在前面的代码中,如果将g替换为0.00.10.751.0,则会得到以下结果。 显然,0的值(默认粒度)导致单个矩形(边界矩形),这不是准确的估计。 随着级别的增加,我们得到了覆盖图形形状和项目的更准确的区域(基本上是矩形集):

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/18f48d84-95e0-4fd4-9869-ec1c63d52f8c.png

  • childItems函数可用于获取填充有QGraphicsItem类的QList,这些类是此项的子级。 将它们视为更复杂项目的子项目。

  • childrenBoundingRectboundingRectsceneBoundingRect函数可用于检索QRectF类,其中包含该项目的子项bounding rect,该项目本身和场景。

  • clearFocussetFocushasFocus函数可用于删除,设置和获取该项目的聚焦状态。 具有焦点的项目接收键盘事件。

  • collidesWithItemcollidesWithPathcollidingItems函数可用于检查此项目是否与任何给定项目发生冲突,以及该项目与之碰撞的项目列表。

  • contains函数获取一个点的位置(准确地说是QPointF类),然后检查此项是否包含该点。

  • cursorsetCursorunsetCursorhasCursor函数对于设置,获取和取消设置此项的特定鼠标光标类型很有用。 您还可以在取消设置之前检查项目是否有任何设置的光标。 设置后,如果鼠标光标悬停在该项目上,则光标形状变为一组。

  • hideshowsetVisibleisVisibleopacitysetOpacityeffectiveOpacity函数均与商品的可见性(和不透明度)有关。 所有这些函数都具有不言自明的名称,唯一值得注意的是effectiveOpacity,它可能与此项的不透明度相同,因为它是基于该项及其父项的不透明度级别计算的。 最终,effectiveOpacity是用于在屏幕上绘制该项目的不透明度级别。

  • flagssetFlagssetFlag函数可用于获取或设置此项的标志。 通过标志,我们基本上是指QGraphicsItem::GraphicsItemFlag枚举中各项的组合。 这是一个示例代码:

        item->setFlag(QGraphicsItem::ItemIsFocusable, true); 
        item->setFlag(QGraphicsItem::ItemIsMovable, false);

重要的是要注意,当我们使用setFlag函数时,所有以前的标志状态都会保留,并且此函数中只有一个标志会受到影响。 但是,当我们使用setFlags时,基本上所有标志都会根据给定的标志组合进行重置。

  • 当我们想要更改从场景中获取鼠标和键盘事件的项目时,grabMousegrabKeyboardungrabMouseungrabKeyboard方法很有用。 显然,使用默认实现时,一次只能抓取一个项目,除非另一个抓取项目或者项目本身不变形或被删除或隐藏,否则抓取器将保持不变。 正如本章前面所看到的,我们总是可以使用QGraphicsScene类中的mouseGrabberItem函数来获取抓取器项目。
  • setGraphicsEffectgraphicsEffect函数可用于设置和获取QGraphicsEffect类。 这是一个非常有趣且易于使用的函数,但功能强大,可用于向场景中的项目添加过滤器或效果。 QGraphicsEffect是 Qt 中所有图形效果的基类。 您可以将其子类化并创建自己的图形效果或过滤器,也可以仅使用提供的 Qt 图形效果之一。 目前,Qt 中有一些图形效果类,您可以自己尝试一下:
    • QGraphicsBlurEffect
    • QGraphicsColorizeEffect
    • QGraphicsDropShadowEffect
    • QGraphicsOpacityEffect

让我们看一个示例自定义图形效果,并使用 Qt 自己的图形效果使自己更加熟悉这个概念:

  1. 您可以使用我们在本章前面创建的Graphics_Viewer项目。 只需在 Qt Creator 中打开它,然后使用主菜单中的New FileProject,选择 C++ 和 C++ 类,然后单击Choose按钮。

  2. 接下来,确保输入QCustomGraphicsEffect作为类名。 选择QObject作为基类,最后选中Include QObject复选框(如果默认情况下未选中)。 单击下一步,然后单击完成按钮。

  3. 然后,将以下include语句添加到新创建的qcustomgraphicseffect.h文件中:

        #include <QGraphicsEffect> 
        #include <QPainter>
  1. 之后,您需要确保我们的QCustomGraphicsEffect类继承了QGraphicsEffect而不是QObject。 确保首先更改qcustomgraphicseffect.h文件中的类定义行,如下所示:
        class QCustomGraphicsEffect : public QGraphicsEffect
  1. 我们还需要更新该类的构造器,并确保在我们的类构造器中调用了QGraphicsEffect构造器,否则将出现编译器错误。 因此,更改qcustomgraphics.cpp文件中的类构造器,如下所示:
      QCustomGraphicsEffect::QCustomGraphicsEffect(QObject *parent) 
         : QGraphicsEffect(parent) 
  1. 接下来,我们需要实现draw函数。 基本上,这是通过实现draw函数制作所有QGraphicsEffect类的方式。 因此,将以下代码行添加到qcustomgraphicseffect.h文件中的QCustomGraphicsEffect类定义中:
        protected: 
          void draw(QPainter *painter); 
  1. 然后,我们需要编写实际的效果代码。 在此示例中,我们将编写一个简单的阈值过滤器,根据像素的灰度值,将其设置为完全黑色或完全白色。 尽管起初代码看起来有些棘手,但它仅使用了我们在前几章中已经学到的经验。 而且,这也是使用QGraphicsEffect类编写新效果和过滤器的简单程度的简单示例。 如您所见,传递给draw函数的QPainter类的指针可用于在效果所需的更改之后简单地对其进行修改和绘制:
        void QCustomGraphicsEffect::draw(QPainter *painter) 
        { 
          QImage image; 
          image = sourcePixmap().toImage(); 
          image = image.convertToFormat( 
                QImage::Format_Grayscale8); 
          for(int i=0; i<image.byteCount(); i++) 
          image.bits()[i] = 
                image.bits()[i] < 100 ? 
                    0 
                  : 
                    255; 
          painter->drawPixmap(0,0,QPixmap::fromImage(image)); 
        }
  1. 最后,我们可以使用新的效果类。 只要确保它包含在mainwindow.h文件中:
        #include "qcustomgraphicseffect.h" 
  1. 然后,通过调用项目的setGraphicsEffect函数来使用它。 在我们的Graphics_Viewer项目中,我们实现了dropEvent。 您可以简单地将以下代码段添加到dropEvent函数中,因此将具有以下内容:
        QGraphicsPixmapItem *item = new QGraphicsPixmapItem(pixmap); 
        item->setGraphicsEffect(new QCustomGraphicsEffect(this)); 
        scene.addItem(item); 

如果在运行应用并将其放置在其上的图像时所有操作均正确完成,您将注意到我们的阈值效果的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/cb0aa492-12bc-40b2-95bf-0bca6581bb29.png

在我们使用自定义图形效果的最后一步中,尝试用任何 Qt 提供的效果的类名替换QCustomGraphicsEffect,然后亲自检查结果。 如您所见,它们在图形效果和类似概念方面提供了极大的灵活性。

现在,让我们继续进行QGraphicsItem类中的其余函数和属性:

  • 当我们想将一个项目添加到组中或获取包含该项目的组类时,groupsetGroup函数非常有用,只要该项目属于任何组。 QGraphicsItemGroup是负责处理组的类,就像您在本章前面所学的那样。
  • isAncestorOf函数可用于检查该项目是否为任何给定其他项目的父项(或父项的父项,依此类推)。
  • 可以设置setParentItemparentItem并检索当前项目的父项目。 一个项目可能根本没有任何父项,在这种情况下,parentItem函数将返回零。
  • isSelectedsetSelected函数可用于更改项目的所选模式。 这些函数与setSelectionArea和您在QGraphicsScene类中了解的类似函数密切相关。
  • mapFromItemmapToItemmapFromParentmapToParentmapFromScenemapToScenemapRectFromItemmapRectToScenemapRectFromParentmapRectToParentmapRectFromScenemapRectToScene函数 ,所有这些函数甚至都具有更多方便的重载函数,构成了一长串函数,这些函数用于从或向其进行基本映射,或者换句话说,可用于从场景,另一项或父对象到场景的坐标转换。 。 实际上,如果您考虑到每个单独的项目和场景与其他项目无关的事实,那么这很容易掌握。 首先,请看下面的图,然后让我们对其进行更详细的讨论:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/c52b657c-957b-46c3-af3b-b4a0f551c1d9.png

因为场景包含所有项目,所以我们假设主坐标系(或世界坐标系)是场景的坐标系。 实际上,这是一个正确的假设。 因此,项目在场景中的位置值为(A, B)。 同样,父项在场景中的位置为(D, E)。 现在,这有点棘手,子项 1父项中的位置值为(F, G)。 类似地,子项 2父项中的位置值为(H, I)。 显然,如果父项和子项的数量增加,我们将拥有不同坐标系的迷宫,在这里,提到的映射函数会很有用。 这是一些示例情况。 您可以使用以下代码段自己测试它,以创建一个场景,其中包含与前面提到的场景类似的项目:

    QGraphicsRectItem *item = 
    new QGraphicsRectItem(0, 
                          0, 
                          100, 
                          100); 
    item->setPos(50,400); 
    scene.addItem(item); 
    QGraphicsRectItem *parentItem = 
        new QGraphicsRectItem(0, 
                              0, 
                              320, 
                              240); 
    parentItem->setPos(300, 50); 
    scene.addItem(parentItem); 

    QGraphicsRectItem *childItem1 = 
        new QGraphicsRectItem(0, 
                              0, 
                              50, 
                              50, 
                              parentItem); 
    childItem1->setPos(50,50); 
    QGraphicsRectItem *childItem2 = 
        new QGraphicsRectItem(0, 
                              0, 
                              75, 
                              75, 
                              parentItem); 
    childItem2->setPos(150,75); 

    qDebug() << item->mapFromItem(childItem1, 0,0); 
    qDebug() << item->mapToItem(childItem1, 0,0); 
    qDebug() << childItem1->mapFromScene(0,0); 
    qDebug() << childItem1->mapToScene(0,0); 
    qDebug() << childItem2->mapFromParent(0,0); 
    qDebug() << childItem2->mapToParent(0,0); 
    qDebug() << item->mapRectFromItem(childItem1, 
                                  childItem1->rect()); 
    qDebug() << item->mapRectToItem(childItem1, 
                                childItem1->rect()); 
    qDebug() << childItem1->mapRectFromScene(0,0, 25, 25); 
    qDebug() << childItem1->mapRectToScene(0,0, 25, 25); 
    qDebug() << childItem2->mapRectFromParent(0,0, 30, 30); 
    qDebug() << childItem2->mapRectToParent(0,0, 25, 25); 

尝试在 Qt Creator 和 Qt Widgets 项目中运行前面的代码,您将在 Qt Creator 的应用输出窗格中看到以下内容,这基本上是qDebug()语句的结果:

    QPointF(300,-300) 
    QPointF(-300,300) 
    QPointF(-350,-100) 
    QPointF(350,100) 
    QPointF(-150,-75) 
    QPointF(150,75) 
    QRectF(300,-300 50x50) 
    QRectF(-300,300 50x50) 
    QRectF(-350,-100 25x25) 
    QRectF(350,100 25x25) 
    QRectF(-150,-75 30x30) 
    QRectF(150,75 25x25) 

让我们尝试看看产生第一个结果的指令:

    item->mapFromItem(childItem1, 0,0); 

item在场景中的位置为(50, 400)childItem1在场景中的(50, 50)位置。 该语句在childItem1坐标系中的位置(0, 0)并将其转换为项目的坐标系。 自己一个个地检查其他说明。 当我们要在场景中的项目周围移动或对场景中的项目进行类似的转换时,这非常简单但非常方便:

  • moveBypossetPosxsetXysetYrotationsetRotationscalesetScale函数可用于获取或设置项目的不同几何属性。 有趣的是,posmapToParent(0,0)返回相同的值。 检查前面的示例,然后通过将其添加到示例代码中来进行尝试。

  • transformsetTransformsetTransformOriginPointresetTransform函数可用于对项目应用或检索任何几何变换。 重要的是要注意,所有变换都假设一个原点(通常为(0,0)),可以使用setTransformOriginPoint对其进行更改。

  • scenePos函数可用于获取项目在场景中的位置。 与调用mapToScene(0,0)相同。 您可以自己在前面的示例中进行尝试并比较结果。

  • datasetData函数可用于设置和检索项目中的任何自定义数据。 例如,我们可以使用它来存储设置为QGraphicsPixmapItem的图像的路径,或者存储与特定项目相关的任何其他类型的信息。

  • zValuesetZValue函数可用于修改和检索项目的Z值。 Z值决定应在其他项目之前绘制哪些项目,依此类推。 具有较高Z值的项目将始终绘制在具有较低Z值的项目上。

与我们在QGraphicsScene类中看到的类似,QGraphicsItem类还包含许多受保护的虚函数,这些函数可以重新实现,主要用于处理传递到场景项上的各种事件。 以下是一些重要且非常有用的示例:

  • contextMenuEvent
  • dragEnterEventdragLeaveEventdragMoveEventdropEvent
  • focusInEventfocusOutEvent
  • hoverEnterEventhoverLeaveEventhoverMoveEvent
  • keyPressEventkeyReleaseEvent
  • mouseDoubleClickEventmouseMoveEventmousePressEventmouseReleaseEventwheelEvent

视图,QGraphicsView

我们到了 Qt 中的图形视图框架的最后一部分。 QGraphicsView类是 Qt 窗口小部件类,可以将其放置在窗口上以显示QGraphicsScene,该窗口本身包含许多QGraphicsItem子类和/或窗口小部件。 与QGraphicsScene类相似,该类还提供大量函数,方法和属性来处理图形的可视化部分。 我们将审核以下列表中的一些最重要的函数,然后我们将学习如何对QGraphicsView进行子类化并将其扩展为在我们全面的计算机视觉应用中具有若干重要功能,例如放大,缩小, 项目选择等。 因此,这是我们在计算机视觉项目中需要的QGraphicsView类的方法和成员:

  • alignmentsetAlignment函数可用于设置场景在视图中的对齐方式。 重要的是要注意,只有当视图可以完全显示场景并且仍然有足够的空间并且视图不需要滚动条时,这才具有可见效果。

  • dragModesetDragMode函数可用于获取和设置视图的拖动模式。 这是视图的最重要函数之一,它可以决定在视图上单击并拖动鼠标左键时会发生什么。 在下面的示例中,我们将使用它并对其进行全面了解。 我们将使用QGraphicsView::DragMode枚举设置不同的拖动模式。

  • isInteractivesetInteractive函数允许检索和修改视图的交互行为。 交互式视图会响应鼠标和键盘(如果已实现),否则,所有鼠标和键盘事件都将被忽略,并且该视图只能用于查看并且不能与场景中的项目进行交互。

  • optimizationFlagssetOptimizationFlagsrenderHintssetRenderHintsviewportUpdateModesetViewportUpdateMode函数分别用于获取和设置与视图的性能和渲染质量有关的参数。 在下面的示例项目中,我们将在实践中看到这些函数的用例。

  • dragMode设置为RubberBandDrag模式的情况下,可以使用rubberBandSelectionModesetRubberBandSelectionMode函数设置视图的项目选择模式。 可以设置以下内容,它们是Qt::ItemSelectionMode枚举中的条目:

    • Qt::ContainsItemShape
    • Qt::IntersectsItemShape
    • Qt::ContainsItemBoundingRect
    • Qt::IntersectsItemBoundingRect
  • sceneRectsetSceneRect函数可用于获取和设置视图中场景的可视化区域。 显然,该值不必与QGraphicsScene类的sceneRect相同。

  • centerOn函数可用于确保特定点或项目位于视图中心。

  • ensureVisible函数可用于将视图滚动到特定区域(具有给定的边距)以确保它在视图中。 此函数适用于点,矩形和图形项目。

  • fitInView函数与centerOnensureVisible非常相似,但主要区别在于,该函数还使用给定的宽高比处理参数缩放视图的内容以适合视图。 以下:

    • Qt::IgnoreAspectRatio
    • Qt::KeepAspectRatio
    • Qt::KeepAspectRatioByExpanding
  • itemAt函数可用于在视图中的特定位置检索项目。

我们已经了解到场景中的每个项目和场景中的每个项目都有各自的坐标系,我们需要使用映射函数将位置从一个位置转换到另一个位置,反之亦然。 视图也是如此。 视图还具有自己的坐标系,主要区别在于视图中的位置和矩形等实际上是根据像素进行测量的,因此它们是整数,但是场景和项目的位置使用实数,等等。 这是由于以下事实:场景和项目在视图上被查看之前都是逻辑实体,因此所有实数都将转换为整数,而整个场景(或部分场景)准备在屏幕上显示。 。 下图可以帮助您更好地理解这一点:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/fc1db2f4-4a0c-4eaf-b394-7c389c6fdf71.png

在上图中,视图的中心点实际上是场景右上角的某个位置。 视图提供了类似的映射函数(与我们在项目中看到的函数相同),可以将场景坐标系中的位置转换为视图坐标系,反之亦然。 这里是它们,再加上其他一些函数和方法,在继续之前,我们需要学习以下视图

  • mapFromScenemapToScene函数可用于在场景坐标系之间转换位置。 与前面提到的一致,mapFromScene函数接受实数并返回整数值,而mapToScene函数接受整数并返回实数。 稍后我们将开发视图的缩放功能时,将使用这些函数。
  • items函数可用于获取场景中的项目列表。
  • render函数对于执行整个视图或其一部分的渲染很有用。 该函数的用法与QGraphicsScene中的render完全相同,只是此函数在视图上执行相同的功能。
  • rubberBandRect函数可用于获取橡皮筋选择的矩形。 如前所述,这仅在拖动模式设置为rubberBandSelectionMode时才有意义。
  • setScenescene函数可用于设置和获取视图场景。
  • setMatrixsetTransformtransformrotatescalesheartranslate函数都可以用于修改或检索视图的几何特性。

QGraphicsSceneQGraphicsItem类相同,QGraphicsView还提供了许多相同的受保护虚拟成员,可用于进一步扩展视图的功能。 现在,我们将扩展Graphics_Viewer示例项目,以支持更多项目,项目选择,项目删除以及放大和缩小功能,并且在此过程中,我们将概述以下项目的一些最重要用例: 我们在本章中学到的视图,场景和项目。 因此,让我们完成它:

  1. 首先在 Qt Creator 中打开Graphics_Viewer项目; 然后,从主菜单中选择“新建文件”或“项目”,然后在“新建文件或项目”窗口中选择“C++ 和 C++ 类”,然后单击“选择”按钮。
  2. 确保输入QEnhancedGraphicsView作为类名,然后选择QWidget作为基类。 另外,如果Include QWidget旁边的复选框尚未选中,请选中它。 然后,单击“下一步”,然后单击“完成”。
  3. 添加以下内容以包含qenhancedgraphicsview.h头文件:
        #include <QGraphicsView> 
  1. 确保QEnhancedGraphicsView类继承了qenhancedgraphicsview.h文件中的QGraphicsView而不是QWidget,如下所示:
        class QEnhancedGraphicsView : public QGraphicsView 
  1. 您必须更正QEnhancedGraphicsView类的构造器实现,如此处所示。 显然,这是在qenhancedgraphicsview.cpp文件中完成的,如下所示:
        QEnhancedGraphicsView::QEnhancedGraphicsView(QWidget
           *parent) 
         : QGraphicsView(parent) 
        { 
        } 
  1. 现在,将以下受保护的成员添加到qenhancedgraphicsview.h文件中的增强型视图类定义中:
        protected: 
          void wheelEvent(QWheelEvent *event);
  1. 并将其实现添加到qenhancedgraphicsview.cpp文件,如以下代码块所述:
        void QEnhancedGraphicsView::wheelEvent(QWheelEvent *event) 
        { 
          if (event->orientation() == Qt::Vertical) 
          { 
            double angleDeltaY = event->angleDelta().y(); 
            double zoomFactor = qPow(1.0015, angleDeltaY); 
            scale(zoomFactor, zoomFactor); 
            this->viewport()->update(); 
            event->accept(); 
          } 
          else 
          { 
            event->ignore(); 
          } 
        } 

您需要确保QWheelEventQtMath包含在我们的类源文件中,否则,您将获得qPow函数和QWheelEvent类的编译器错误。 前面的代码大部分是不言自明的-它首先检查鼠标滚轮事件的方向,然后根据滚轮中的移动量在 X 和 Y 轴上都应用一个比例。 然后,它更新视口,以确保根据需要重新绘制所有内容。

  1. 现在,我们需要进入 Qt Creator 中的“设计”模式,以在窗口上提升graphicsView对象(如我们先前所见)。 我们需要右键单击并从上下文菜单中选择“升级为”。 然后,输入QEnhancedGraphicsView作为升级的类名称,然后单击“添加”按钮,最后单击“升级”按钮。 (您已经在前面的示例中学习了关于提升的知识,这也不例外。)由于QGraphicsViewQEnhancedGraphicsView类是兼容的(第一个是后者的父类),因此我们可以将父代提升为子代,和/ 或将其降级(如果我们不需要)。 升级就像将小部件转换为其子小部件以支持和添加更多功能一样。

  2. 您需要在mainwindow.cppdropEvent函数顶部添加一小段代码,以确保在加载新图像时重置缩放级别(准确地说是比例转换):

        ui->graphicsView->resetTransform(); 

现在,您可以启动应用,并尝试使用鼠标滚轮滚动。 向上或向下旋转轮子时,您可以看到比例级别的变化。 这是放大和缩小图像时结果应用的屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/1f9a24f3-3ad9-408a-aa5a-1a6815e21b48.png

如果再尝试一点,很快就会发现一件事,缩放功能总是朝着图像的中心起作用,这很奇怪而且不舒服。 为了能够解决此问题,我们需要利用在本章中学到的更多提示,技巧和功能:

  1. 首先向我们的增强型视图类添加另一个私有受保护的函数。 除了先前使用的wheelEvent外,我们还将使用mouseMoveEvent。 因此,将以下代码行添加到qenhancedgraphicsview.h文件中的受保护成员部分:
        void mouseMoveEvent(QMouseEvent *event); 
  1. 另外,添加一个私有成员,如下所示:
        private: 
          QPointF sceneMousePos; 
  1. 现在,转到它的实现部分,并将以下代码行添加到qenhancedgraphicsview.cpp文件:
        void QEnhancedGraphicsView::mouseMoveEvent(QMouseEvent
           *event) 
       { 
         sceneMousePos = this->mapToScene(event->pos()); 
       }
  1. 您还需要稍微调整wheelEvent函数。 确保其外观如下:
        if (event->orientation() == Qt::Vertical) 
        { 
          double angleDeltaY = event->angleDelta().y(); 
          double zoomFactor = qPow(1.0015, angleDeltaY); 
          scale(zoomFactor, zoomFactor); 
          if(angleDeltaY > 0) 
          { 
            this->centerOn(sceneMousePos); 
            sceneMousePos = this->mapToScene(event->pos()); 
          } 
          this->viewport()->update(); 
          event->accept(); 
        } 
        else 
        { 
          event->ignore(); 
        } 

您只需关注函数名称,就可以很容易地看到这里发生的事情。 我们实现了mouseMoveEvent来拾取鼠标的位置(在场景坐标中,这非常重要); 然后我们确保在放大(而不是缩小)之后,该视图确保所采集的点位于屏幕的中心。 最后,它会更新位置,以获得更舒适的变焦体验。 重要的是要注意,有时诸如此类的小缺陷或功能可能意味着用户可以舒适地使用您的应用,最终这是应用增长(或最坏的情况是下降)的重要参数。

现在,我们将向Graphics_Viewer应用添加更多功能。 让我们首先确保我们的Graphics_Viewer应用能够处理无限数量的图像:

  1. 首先,我们需要确保在将每个图像拖放到视图中(因此是场景)之后,不会清除场景,因此首先从mainwindow.cppdropEvent中删除以下行:
        scene.clear(); 
  1. 另外,从dropEvent中删除以下代码行,我们先前添加了以下代码行以重置缩放比例:
        ui->graphicsView->resetTransform();
  1. 现在,将以下两行代码添加到mainwindow.cpp文件中dropEvent的起点:
        QPoint viewPos = ui->graphicsView->mapFromParent
          (event->pos()); 
        QPointF sceneDropPos = ui->graphicsView->mapToScene
          (viewPos); 
  1. 然后,确保将项目的位置设置为sceneDropPos,如下所示:
        item->setPos(sceneDropPos); 

就是这样,现在不需要其他任何东西。 启动Graphics_Viewer应用,然后尝试将图像放入其中。 在第一张图像之后,尝试缩小并添加更多图像。 (请不要通过夸大此测试来填充内存,因为如果您尝试添加大量图像,则您的应用将开始消耗过多的内存,从而导致操作系统出现问题。不用说,您的应用可能会崩溃 。)以下是在场景中各个位置拖放的一些图像的屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/376494ed-9341-4a3e-9e44-e3296a513383.png

显然,该应用仍然遗漏了很多东西,但是在剩下的部分让您自己找出并发现之前,我们将在本章中介绍一些非常关键的功能。 一些非常重要的缺失功能是我们无法选择,删除项目或对其施加某些效果。 让我们一次完成一个简单但功能强大的Graphics_Viewer应用。 如您所知,稍后,我们将使用在综合计算机视觉应用(名为Computer_Vision项目)中学到的所有技术。 因此,让我们开始为Graphics_Viewer项目添加以下最终内容:

  1. 首先向增强的图形视图类添加另一个受保护的成员,如下所示:
        void mousePressEvent(QMouseEvent *event); 
  1. 然后,将以下专用插槽添加到相同的类定义中:
        private slots: 
          void clearAll(bool); 
          void clearSelected(bool); 
          void noEffect(bool); 
          void blurEffect(bool); 
          void dropShadowEffect(bool); 
          void colorizeEffect(bool); 
          void customEffect(bool); 
  1. 现在,将所有必需的实现添加到视图类源文件,即qenhancedgraphicsview.cpp文件。 首先添加mousePressEvent的实现,如下所示:
        void QEnhancedGraphicsView::mousePressEvent(QMouseEvent 
          *event) 
        { 
         if(event->button() == Qt::RightButton) 
         { 
          QMenu menu; 
          QAction *clearAllAction = menu.addAction("Clear All"); 
          connect(clearAllAction, 
                SIGNAL(triggered(bool)), 
                this, 
                SLOT(clearAll(bool))); 
          QAction *clearSelectedAction = menu.addAction("Clear Selected"); 
          connect(clearSelectedAction, 
                SIGNAL(triggered(bool)), 
                this, 
                SLOT(clearSelected(bool))); 
          QAction *noEffectAction = menu.addAction("No Effect"); 
          connect(noEffectAction, 
                SIGNAL(triggered(bool)), 
                this, 
                SLOT(noEffect(bool))); 
          QAction *blurEffectAction = menu.addAction("Blur Effect"); 
          connect(blurEffectAction, 
                SIGNAL(triggered(bool)), 
                this, 
                SLOT(blurEffect(bool))); 
          // *** 
          menu.exec(event->globalPos()); 
          event->accept(); 
         } 
         else 
         {  
           QGraphicsView::mousePressEvent(event); 
         } 
        } 

在前面的代码中,//***对于dropShadowEffectcolorizeEffectcustomEffect函数插槽基本上以相同的模式重复。 在前面的代码中,我们所做的只是简单地创建并打开一个上下文(右键单击)菜单,然后将每个动作连接到将在下一步中添加的插槽。

  1. 现在,添加插槽的实现,如下所示:
        void QEnhancedGraphicsView::clearAll(bool) 
        { 
          scene()->clear(); 
        } 
        void QEnhancedGraphicsView::clearSelected(bool) 
        { 
          while(scene()->selectedItems().count() > 0) 
          { 
           delete scene()->selectedItems().at(0); 
           scene()->selectedItems().removeAt(0); 
          } 
        } 
        void QEnhancedGraphicsView::noEffect(bool) 
        { 
          foreach(QGraphicsItem *item, scene()->selectedItems()) 
          { 
           item->setGraphicsEffect(Q_NULLPTR); 
          } 
        } 

        void QEnhancedGraphicsView::blurEffect(bool) 
        { 
          foreach(QGraphicsItem *item, scene()->selectedItems()) 
          { 
            item->setGraphicsEffect(new QGraphicsBlurEffect(this)); 
          } 
        } 

       //*** 

与前面的代码相同,其余插槽遵循相同的模式。

  1. 在我们的应用准备好进行测试运行之前,我们需要处理一些最后的事情。 首先,我们需要确保增强的图形视图类是交互式的,并允许通过单击和拖动来选择项目。 您可以通过将以下代码段添加到mainwindow.cpp文件中来实现。 设置场景后立即在初始化函数(构造器)中执行以下操作:
        ui->graphicsView->setInteractive(true); 
        ui->graphicsView->setDragMode(QGraphicsView::RubberBandDrag); 
        ui->graphicsView->setRubberBandSelectionMode( 
           Qt::ContainsItemShape); 
  1. 最后但并非最不重要的一点是,在mainwindow.cppdropEvent函数中添加以下代码行,以确保可以选择项目。 将它们添加到项目创建代码之后以及添加到场景的行之前:
        item->setFlag(QGraphicsItem::ItemIsSelectable); 
        item->setAcceptedMouseButtons(Qt::LeftButton);

而已。 我们准备开始并测试我们的Graphics_Viewer应用,该应用现在还可以添加效果并具有更多功能。 这是显示所谓的橡皮筋选择模式行为的屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/221f1f37-dd44-44cb-99ae-1d397a2895f4.png

最后,下面是正在运行的Graphics_Viewer应用的屏幕快照,同时为场景中的图像添加了不同的效果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/8b4e6dc5-a618-4d1b-8c15-1a500a9c672c.png

而已。 现在,我们可以创建功能强大的图形查看器,并将其添加到Computer_Vision项目中,在学习新的以及更多的 OpenCV 和 Qt 技能和技术的同时,还将在接下来的章节中使用。 按照承诺,您可以从以下链接下载Computer_Vision项目的完整版本

正如我们在前几章中反复提到的那样,该项目的目标是通过照顾每种所需的 GUI 功能,语言,主题等,帮助我们仅专注于计算机视觉主题。 。 该项目是到目前为止您学到的一切的完整示例。 该应用可以使用样式进行自定义,可以支持新语言,并且可以使用插件进行扩展。 它还将您在本章中学到的所有内容打包到一个漂亮而强大的图形查看器中,我们将在本书的其余部分中使用该图形查看器。 在继续以下各章之前,请确保下载了它。

Computer_Vision项目包含一个 Qt 多项目中的两个项目,或者更确切地说是subdirs项目类型。 第一个是mainapp,第二个是template_plugin项目。 您可以复制(克隆)并替换该项目中的代码和 GUI 文件,以创建与Computer_Vision项目兼容的新插件。 这正是我们在第 6 章,“OpenCV 中的图像处理”)中所做的工作,对于您学习的大多数 OpenCV 技能,我们将为Computer_Vision创建一个插件。 该项目还包含示例附加语言和示例附加主题,可以再次对其进行简单地复制和修改,以为应用创建新的语言和主题。 确保您查看了整个下载的源代码,并确保其中没有奥秘,并且您完全了解Computer_Vision项目源代码中的所有内容。 同样,这是为了总结您所学的所有知识并将其打包到一个单一的,全面的,可重用的示例项目中。

总结

自本书开始以来,我们已经走了很长一段路,到现在,我们已经完全掌握了许多有用的技术来承担计算机视觉应用开发的任务。 在前面的所有章节(包括我们刚刚完成的章节)中,您了解了更多有关创建强大而全面的应用所需的技能(通常,大部分情况),而不仅仅是专注于计算机视觉(准确来说是 OpenCV 技能)方面。 您学习了如何创建支持多种语言,主题和样式,插件的应用; 在本章中,您学习了如何在场景和视图中可视化图像和图形项目。 现在,我们已经拥有了深入研究计算机视觉应用开发世界所需的几乎所有东西。

在第 6 章,“OpenCV 中的图像处理”中,您将了解有关 OpenCV 以及其中可能的图像处理技术的更多信息。 对于每个学习的主题,我们仅假设我们正在创建与Computer_Vision项目兼容的插件。 这意味着我们将在Computer_Vision项目中使用模板插件,将其复制,然后简单地制作一个能够执行特定计算机视觉任务,转换过滤器或计算的新插件。 当然,这并不意味着您不能创建具有相同功能的独立应用,正如您将在接下来的章节中看到的那样,我们的插件具有 GUI,与创建应用或创建应用本质上没有什么不同。 准确地说,您在上一章中学到了所有的 Qt Widgets 应用。 但是,从现在开始,我们将继续学习更高级的主题,并且我们的重点将主要放在应用的计算机视觉方面。 您将学习如何在 OpenCV 中使用众多的过滤和其他图像处理功能,它支持的色彩空间,许多转换技术等等。

六、OpenCV 中的图像处理

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

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

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

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

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

图像过滤

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

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

https://github.com/OpenDocCN/freelearn-ml-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项目来完成它,如屏幕截图所示:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/9df20080-4ca4-4f0b-a0e9-b6aae594a379.png

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

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/6f2ec581-4892-486e-819d-dadbaae4dc52.png

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

https://github.com/OpenDocCN/freelearn-ml-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文件的布局都设置为网格布局,如下所示:

https://github.com/OpenDocCN/freelearn-ml-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。 然后,您可以使用主菜单打开或保存图形场景的内容。 尝试打开一个文件,然后在插件组合框中的不同选项之间切换。 您也可以通过选中查看原始图像复选框来查看原始图像。 这是屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/90b80d20-8700-4b37-8008-425e72bbd313.png

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

https://github.com/OpenDocCN/freelearn-ml-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函数的屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-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:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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); 

将产生类似于以下内容:

https://github.com/OpenDocCN/freelearn-ml-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()); 

以下是生成的图像:

https://github.com/OpenDocCN/freelearn-ml-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()); 

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

https://github.com/OpenDocCN/freelearn-ml-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 

这是结果图像:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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)); 

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

https://github.com/OpenDocCN/freelearn-ml-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函数中保持不变,但内容基本上被压缩为原始尺寸的五倍)。 在下面的屏幕快照中显示了该内容:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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 颜色空间。 这是生成的输出图像:

https://github.com/OpenDocCN/freelearn-ml-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 转换为灰度,然后应用自适应阈值,最后将其转换回。 这是前面的示例代码的结果:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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之间的区别:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

https://github.com/OpenDocCN/freelearn-ml-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类中的全局最小值或全局最大值位置实际上是最佳模板匹配。 因此,是我们检测结果的最佳候选者。 假设我们想将左侧屏幕上的图像与以下屏幕截图右侧屏幕上的图像匹配:

https://github.com/OpenDocCN/freelearn-ml-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函数),我们将得到以下输出。 注意结果图像中的最亮点:

https://github.com/OpenDocCN/freelearn-ml-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,我们将获得以下屏幕截图:

https://github.com/OpenDocCN/freelearn-ml-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); 

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

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

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/8749441e-4ab0-46e9-a14b-759441788c69.png

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

https://github.com/OpenDocCN/freelearn-ml-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项目兼容:

https://github.com/OpenDocCN/freelearn-ml-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 小部件获取所需的参数并进一步控制算法的行为。 这是一个例子:

https://github.com/OpenDocCN/freelearn-ml-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函数被调用。 这是一个简单的例子:

https://github.com/OpenDocCN/freelearn-ml-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 实现以及此类的使用方式中,这也是显而易见的。 确保参考该算法的文章以了解更多有关它的详细信息。 但是,我们将在另一个示例中重点介绍如何使用它:

https://github.com/OpenDocCN/freelearn-ml-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 实际上是哈里斯算法的一种修改版本,使用的哪一种将由输入参数决定。 因此,让我们看看如何在示例案例中使用它,然后简要介绍一下参数:

https://github.com/OpenDocCN/freelearn-ml-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 类可用于检测关键点,如以下示例所示:

https://github.com/OpenDocCN/freelearn-ml-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类)从以下图像中检测关键点:

https://github.com/OpenDocCN/freelearn-ml-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类。 这是结果图像:

https://github.com/OpenDocCN/freelearn-ml-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); 

这将产生以下结果:

https://github.com/OpenDocCN/freelearn-ml-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); 

这是此操作的结果:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/2398008a-fff7-4117-bde0-63ca177c5979.png

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

https://github.com/OpenDocCN/freelearn-ml-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); 

这是结果图像:

https://github.com/OpenDocCN/freelearn-ml-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文件,如下所示。 我们将使用以下标签在计算机上显示来自默认摄像头的原始视频和经过处理的视频:

https://github.com/OpenDocCN/freelearn-ml-zh/raw/master/docs/cv-opencv3-qt5/img/0b4f39ff-6005-4198-b087-d3140cc06205.png

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

https://github.com/OpenDocCN/freelearn-ml-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函数调用可确保等待线程清理并安全完成执行。 尝试运行此代码以自行检查。 程序启动后,您应该会看到类似于下图的内容:

https://github.com/OpenDocCN/freelearn-ml-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属性设置为零。

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

您可能感兴趣的与本文相关的镜像

PyTorch 2.6

PyTorch 2.6

PyTorch
Cuda

PyTorch 是一个开源的 Python 机器学习库,基于 Torch 库,底层由 C++ 实现,应用于人工智能领域,如计算机视觉和自然语言处理

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值