OpenCV2-计算机视觉应用编程秘籍-全-

OpenCV2 计算机视觉应用编程秘籍(全)

原文:OpenCV2 Computer Vision Application Programming Cookbook

协议:CC BY-NC-SA 4.0

零、前言

在当今的数字世界中,图像和视频无处不在,并且随着功能强大且价格适中的计算设备的出现,创建复杂的成像应用从未如此简单。 提供了大量用于处理图像和视频的软件工具和库,但是对于希望开发自己的应用的任何人,OpenCV 库都是可以使用的工具。

OpenCV(开放源计算机视觉)是一个开放源代码库,其中包含 500 多种用于图像和视频分析的优化算法。 自 1999 年推出以来,它已被计算机视觉研究人员和开发人员广泛用作主要开发工具。 OpenCV 最初由英特尔由盖瑞·布拉德斯基(Gary Bradski)领导的团队开发,旨在推动视觉研究并促进丰富的基于视觉的 CPU 密集型应用的开发。 在一系列 beta 版本发布之后,2006 年发布了 1.0 版。2009 年发布了第二个主要版本,OpenCV 2 的发布提出了重要的更改,尤其是本书中使用的新 C++ 接口。 在撰写本文时,最新版本是 2.2(2010 年 12 月)。

本书涵盖了库的许多功能,并展示了如何使用它们来完成特定任务。 我们的目标不是全面,详细地介绍 OpenCV 函数和类提供的每个选项,而是为您提供从头开始构建应用所需的元素。 在本书中,我们还将探讨图像分析的基本概念,并描述计算机视觉中的一些重要算法。

本书为您提供了机会,介绍了图像和视频分析领域。 但这仅仅是开始。 好消息是 OpenCV 继续发展和扩展。 只需查阅 OpenCV 在线文档以了解有关该库可以为您提供服务的最新信息

这本书涵盖的内容

第 1 章,“玩转图像”介绍了 OpenCV 库,并向您展示了如何使用 MS Visual C++ 和 Qt 开发环境运行简单的应用。

第 2 章,“操纵像素”解释了如何读取图像。 它描述了用于扫描图像以便对其每个像素执行操作的不同方法。 您还将学习如何在图像内定义关注区域。

第 3 章,“使用类处理图像”,包含一些秘籍,这些秘籍提供了各种面向对象的设计模式,可以帮助您构建更好的计算机视觉应用。

第 4 章,“用直方图计算像素”展示了如何计算图像直方图以及如何使用它们来修改图像。 提出了基于直方图的不同应用,它们可以实现图像分割,目标检测和图像检索。

第 5 章,“通过形态学操作变换图像”探索了数学形态学的概念。 它介绍了不同的运算符,以及它们如何用于检测图像中的边缘,角和线段。

第 6 章,“过滤图像”教您频率分析和图像过滤的原理。 它显示了如何将低通和高通过滤器应用于图像。 它提供了两个图像导数运算符:梯度和拉普拉斯算子。

第 7 章,“提取线,轮廓和分量”着重于几何图像特征的检测。 它说明了如何提取图像中的轮廓,线和连接的分量。

第 8 章,“检测和匹配兴趣点”描述了图像中的各种特征点检测器。 它还说明了兴趣点的描述符如何计算并用于匹配图像之间的点。

第 9 章,“估计图像中的投影关系”分析了图像形成中涉及的不同关系。 它还探讨了同一场景的两个图像之间存在的投影关系。

第 10 章,“处理视频序列”提供了读取和写入视频序列以及处理其帧的框架。 它还向您展示了如何逐帧跟踪特征点,以及如何提取在摄像机前移动的前景对象。

这本书适合谁

如果您是新手 C++ 程序员,并且想学习如何使用 OpenCV 库来构建计算机视觉应用,那么本菜谱非常适合您。 它也适用于希望被引入计算机视觉编程概念的专业软件开发人员。 它可以用作大学级计算机视觉课程的配套书。 它为图像处理和计算机视觉的研究生和研究人员提供了极好的参考。 本书提供了基本秘籍和高级秘籍的良好组合。 必须具备 C++ 的基本知识。

约定

在本书中,您会发现许多可以区分不同类型信息的文本样式。 以下是这些样式的一些示例,并解释了其含义。

文本中的代码字如下所示:“我们可以通过使用include指令包含其他上下文。”

代码块设置如下:

     // get the iterators
     cv::Mat_<cv::Vec3b>::const_iterator it=
                         image.begin<cv::Vec3b>();

当我们希望引起您对代码块特定部分的注意时,相关行或项目以粗体显示:

     // Converting to Lab color space 
 cv::cvtColor(image, converted, CV_BGR2Lab);
     // get the iterators of the converted image 
     cv::Mat_<cv::Vec3b>::iterator it= 

新术语重要词以粗体显示。 您在屏幕上看到的字词,例如在菜单或对话框中的字样如下:“单击处理按钮时将读取此值,这也会触发处理并显示结果 ”。

提示

提示和技巧如下所示。

一、玩转图像

在本章中,我们将介绍:

  • 安装 OpenCV 库
  • 使用 MS Visual C++ 创建 OpenCV 项目
  • 用 Qt 创建一个 OpenCV 项目
  • 加载,显示和保存图像
  • 使用 Qt 创建 GUI 应用

简介

本章将教您 OpenCV 的基本元素,并向您展示如何完成最基本的任务:读取,显示和保存图像。 在开始使用 OpenCV 之前,需要安装该库。 这是一个简单的过程,将在本章的第一部分中进行说明。

您还需要一个良好的开发环境(IDE)来运行您的 OpenCV 应用。 我们在这里提出两种选择。 首先是使用众所周知的 Microsoft Visual Studio 平台。 第二种选择是使用称为 Qt 的开源工具进行 C++ 项目开发。 两个秘诀将向您展示如何使用这两个工具来设置项目,但是您也可以使用其他 C++ IDE。 实际上,在本手册中,将以与任何特定环境和操作系统无关的方式来呈现任务,因此您可以自由使用所选择的一种。 但是,请注意,您需要使用适合您所使用的编译器和操作系统的 OpenCV 库的已编译版本。 如果您获得奇怪的行为,或者您的应用崩溃而没有明显原因,则可能是不兼容的症状。

安装 OpenCV 库

OpenCV 是用于开发计算机视觉应用的开源库。 根据 BSD 许可,它可以在学术和商业应用中使用,允许您自由使用,分发和改编它。 此秘籍将向您展示如何在计算机上安装该库。

准备

当您访问 OpenCV 官方网站时,您将找到该库的最新版本,在线文档以及有关以下内容的许多其他有用资源 OpenCV。

操作步骤

在 OpenCV 网站上,转到与您选择的平台(Linux/Unix/Mac 或 Windows)相对应的“下载”页面。 从那里您将能够下载 OpenCV 包。 然后,通常在名称与库版本相对应的目录下(例如OpenCV2.2)将其解压缩。 完成此操作后,您将找到目录的集合,尤其是包含 OpenCV 文档的doc目录,包含所有包含文件的include目录,包含所有源文件的modules目录(是的,它是开源的),以及samples目录包含许多小示例,可帮助您入门。

如果您在 Windows 下使用 Visual Studio,则还可以选择下载与您的 IDE 和 Windows 平台相对应的可执行安装包。 执行此安装程序不仅会安装源库,还将安装构建应用所需的所有预编译二进制文件。 在这种情况下,您准备开始使用 OpenCV。 如果没有,您需要采取一些其他步骤。

为了在您选择的环境下使用 OpenCV,您需要使用适当的 C++ 编译器生成库二进制文件。 要构建 OpenCV,您需要使用这个页面上提供的 CMake 工具。 CMake 是另一个开源软件工具,旨在使用与平台无关的配置文件来控制软件系统的编译过程。 因此,您需要下载并安装 CMake。 然后,您可以使用命令行来运行它,但是使用带有其图形用户界面(GUI)的 CMake 更容易。 在后一种情况下,您需要指定包含 OpenCV 库的文件夹以及将包含二进制文件的文件夹。 然后,单击配置,以选择所需的编译器(此处选择了 Visual Studio 2010),然后再次单击配置,如以下屏幕快照所示:

How to do it...

现在,您可以通过单击生成按钮来生成makefiles和工作区文件。 这些文件将允许您编译库。 这是安装过程的最后一步。

编译该库将使其可用于您的开发环境。 如果您选择了像 Visual Studio 这样的 IDE,那么您要做的就是打开 CMake 为您创建的顶级解决方案文件。 然后,您发出构建解决方案命令。 在 Unix 环境中,您将通过运行make utility命令使用生成的makefiles

如果一切顺利,现在应该在指定目录中拥有已编译且随时可用的 OpenCV 库。 除了我们已经提到的目录之外,该目录还将包含一个bin目录,其中包含已编译的库。 您可以将所有内容移动到首选位置(例如c:\OpenCV2.2),然后将bin目录添加到系统路径(在 Windows 下,这是通过打开控制面板完成的。 HTG5]系统工具,在高级选项卡下,您会找到环境变量按钮)。

工作原理

从 2.2 版开始,OpenCV 库分为几个模块。 这些模块内置在lib目录中的库文件中。 他们是:

  • opencv_core模块包含库的核心功能,尤其是基本数据结构和算术功能。
  • 包含主要图像处理功能的opencv_imgproc模块。
  • opencv_highgui模块包含图像和视频读写功能,以及其他用户界面功能。
  • opencv_features2d模块包含特征点检测器和描述符以及特征点匹配框架。
  • opencv_calib3d模块包含相机校准,两视图几何估计和立体功能。
  • opencv_video模块包含运动估计,特征跟踪以及前景提取功能和类。
  • opencv_objdetect模块包含诸如面部和人物检测器之类的对象检测功能。

该库还包括其他工具模块,其中包含机器学习功能(opencv_ml ),计算几何算法(opencv_flann ),贡献代码(opencv_contrib ),过时代码(opencv_legacy )和 gpu 加速代码(opencv_gpu )。

所有这些模块都有与之关联的头文件(位于include目录中)。 因此,典型的 OpenCV C++ 代码将从包含所需的模块开始。 例如(这是建议的声明样式):

#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <opencv2/highgui/highgui.hpp>

如果您看到以以下内容开头的 OpenCV 代码:

 #include "cv.h"

这是因为在将库重构为模块之前,它使用的是旧样式。

更多

您还可以访问位于以下位置的 OpenCV SVN 服务器正在开发的最新代码

您会发现有很多示例可以帮助您学习如何使用该库并提供许多开发技巧。

使用 MS Visual C++ 创建 OpenCV 项目

使用 MS Visual C++,您可以轻松地为 Windows 创建 OpenCV 应用。 您可以构建简单的控制台应用,也可以使用漂亮的图形用户界面(GUI)创建更复杂的应用。 由于这是最简单的选项,因此我们将在此处创建一个简单的控制台应用。 我们将使用 Visual Studio 2010,但是相同的原理也适用于 Microsoft IDE 的任何其他版本,因为不同版本中的菜单和选项非常相似。

首次运行 Visual Studio 时,可以采用某种方式进行设置,以使 C++ 成为您的默认开发环境。 这样,当您启动 IDE 时,它将处于 Visual C++ 模式。

我们假定您已按照先前秘籍中的说明在C:\OpenCV2.2目录下安装了 OpenCV。

准备

使用 Visual Studio 时,了解解决方案和项目之间的区别很重要。 基本上,解决方案由多个项目组成(每个项目是一个不同的软件模块,例如程序和库)。 这样,您的解决方案的项目可以共享文件和库。 通常,您为解决方案创建一个主目录,其中包含所有项目目录。 但是,您也可以将解决方案和项目分组到一个目录中。 这是一个项目解决方案中最常做的事情。 随着您对 VC++ 的熟悉和构建更复杂的应用,您应该利用多项目解决方案结构。

另外,在编译和执行 Visual C++ 项目时,可以在两种不同的配置下进行:Debug 和 Release。 调试模式可以帮助您创建和调试应用。 这是一个受保护程度更高的环境,例如,它将告诉您应用是否包含内存泄漏,或者它将在运行时检查是否正确使用了某些功能。 但是,它生成速度较慢的可执行文件。 这就是为什么一旦您的应用经过测试并准备好使用后,便可以在“发布”模式下构建它。 这将产生可执行文件,您将分发给应用的用户。 请注意,您可能会在调试模式下完美运行代码,但在发布模式下却遇到问题。 然后,您需要进行更多测试,以找出潜在的错误来源。 调试和发布模式并非 Visual C++ 独有,大多数 IDE 也支持这两种编译模式。

操作步骤

现在,我们准备创建我们的第一个项目。 这是通过使用文件 | 项目... | 新建项目菜单选项来完成的。 您可以在此处创建不同的项目类型。 让我们从最简单的选项开始,即选择一个 Win32 控制台应用,如以下屏幕快照所示:

How to do it...

您需要指定要在何处创建项目,以及要为项目指定的名称。 还有一个选项可以为解决方案创建或不创建目录(右下角的复选框)。 如果选中此选项,将创建一个其他目录(使用您指定的名称),该目录将包含您的解决方案目录。 如果您只是简单地取消选中此选项,则仍将创建一个解决方案文件(扩展名.sln),但此文件将包含在同一(单个)项目目录中。 单击下一步,然后单击,转到 Win32 应用向导的应用设置窗口。 如以下屏幕截图所示,那里提供了许多选项。 我们将简单地创建一个空项目。

How to do it...

请注意,我们还没有选中预编译头选项,该选项是 MS Visual Studio 特定的功能,可以使编译过程更快。 由于我们希望保留在 ANSI C++ 标准之内,因此我们将不使用此选项。 如果单击完成,则将创建您的项目。 它暂时是空的,但是我们很快会添加一个主文件。

但是首先,为了能够编译和运行您将来的 OpenCV 应用,您需要告诉 Visual C++ 在哪里可以找到 OpenCV 库并包含文件。 由于将来可能会创建多个 OpenCV 项目,因此最好的选择是创建一个属性表,您可以在项目之间重复使用。 这是通过属性管理器完成的。 如果在当前的 IDE 中尚不可见,则可以从视图菜单中访问它。

How to do it...

在 Visual C++ 2010 中,属性页是描述您的项目设置的 XML 文件。 现在,我们将通过右键单击调试 | 创建一个新的 Win32 项目的节点,并选择添加新项目属性表选项(如以下屏幕截图所示):

How to do it...

一旦我们单击添加,就会添加新的属性表。 现在,我们需要对其进行编辑。 只需双击属性表的名称,然后选择 VC++ 目录,如下所示:

How to do it...

编辑包含目录文本字段,并将路径添加到 OpenCV 库的包含文件中:

How to do it...

库目录执行相同的操作。 这次您将路径添加到 OpenCV 库文件中:

How to do it...

重要的是要注意,我们在属性表中使用了 OpenCV 库的显式路径。 通常,使用环境变量来指定库位置是一种更好的做法。 这样,如果切换到库的另一个版本,则只需更改此变量的定义,使其指向库的新位置。 同样,对于团队项目,不同的用户可能已将库安装在不同的位置。 使用环境变量将避免需要为每个用户编辑属性表。 因此,如果将环境变量OPENCV2_DIR定义为c:\OpenCV2.2,则两个 OpenCV 目录将在属性表​​中指定为$(OPENCV_DIR)\include$(OPENCV_DIR)\lib

下一步是指定 OpenCV 库文件,这些文件需要与您的代码链接才能生成可执行应用。 根据应用,您可能需要不同的 OpenCV 模块。 由于我们要在所有项目中重用此属性表,因此我们将仅添加运行本书的应用所需的库模块。 转到链接器节点的输入项目,如以下屏幕截图所示:

How to do it...

编辑其他依赖项文本字段,并添加以下库模块列表:

How to do it...

请注意,我们指定的库名称以字母d结尾。 这些是“调试”模式的二进制文件。 您将需要为发布模式创建另一个(几乎相同)的属性表。 您遵循相同的步骤,但是将其添加到发布 | Win32 节点。 这次,指定了库名,但没有在末尾添加d

现在,我们准备创建,编译和运行我们的第一个应用。 我们使用解决方案资源管理器添加新的源文件,然后右键单击源文件节点。 您选择添加新项目...,这使您有机会指定main.cpp作为此 C++ 文件的名称:

How to do it...

您也可以使用文件 | 新建 | 文件...菜单选项来执行此操作。 现在,让我们构建一个简单的应用,它将在默认目录下显示名为img.jpg的图像。

How to do it...

一旦复制了上图中的代码(将在后面进行说明),就可以编译它并使用屏幕顶部工具栏中的启动绿色箭头来运行它。 您将看到图像显示五秒钟。 这里有一个例子:

How to do it...

如果是这样,那么您已经完成了第一个成功的 OpenCV 应用! 如果程序在执行时失败,则可能是因为找不到图像文件。 请参阅以下部分以了解如何将其放置在正确的目录中。

工作原理

当您单击启动调试按钮(或按F5)时,将编译您的项目,然后执行。 您还可以通过选择构建菜单下的构建解决方案F7)来编译项目。 第一次编译项目时,将创建一个Debug目录。 这将包含可执行文件(扩展名.exe)。 同样,您也可以通过使用绿色箭头按钮右侧的下拉菜单(或使用构建菜单下的选项配置管理器,简单地选择发布管理选项来​​创建发行版本)。 然后将创建一个Release目录。

当使用 Visual Studio 的启动按钮执行项目时,默认目录将始终是包含解决方案文件的目录。 但是,如果您通过双击.exe文件(通常为Release目录)选择在 IDE 外部(即从 Windows 资源管理器)执行应用,则默认目录将变为一个包含可执行文件的目录。 因此,在执行此应用之前,请确保图像文件位于相应目录中的 。

另见

本章后面的“加载,显示和保存图像”秘籍,解释了我们在此任务中使用的 OpenCV 源代码。

使用 Qt 创建 OpenCV 项目

Qt 是用于 C++ 应用的完整集成开发环境(IDE),最初由挪威软件公司 Trolltech 开发,该公司于 2008 年被诺基亚收购。 它是根据 LPGL 开源许可以及商业(和付费)许可开发专有项目而提供的。 它由两个独立的元素组成:一个称为 Qt Creator 的跨平台 IDE,以及一组 Qt 类库和开发工具。 使用 Qt 软件开发工具包(SDK)开发 C++ 应用有很多好处:

  • 这是 Qt 社区开发的一个开放源代码计划,使您可以访问不同 Qt 组件的源代码。
  • 它是跨平台的,这意味着您可以开发可在不同操作系统(例如 Windows,Linux,Mac OS X 等)上运行的应用。
  • 它包括一个完整的跨平台 GUI 库,该库遵循有效的面向对象和事件驱动的模型。
  • Qt 还包括多个跨平台库,用于开发多媒体,图形,数据库,多线程,Web 应用以及许多其他对设计高级应用有用的有趣构建基块。

准备

可以从这个页面下载 Qt。 如果选择 LPGL 许可证,则它是免费的。 您应该下载完整的 SDK。 但是,请确保选择适合您平台的 Qt 库包。 显然,由于我们正在处理开源软件,因此始终可以在您选择的平台下重新编译该库。

在这里,我们使用 Qt Creator 1.2.1 版和 Qt 4.6.3 版。 请注意,在 Qt Creator 的项目选项卡下,可以管理可能已安装的不同 Qt 版本。 这样可以确保您始终可以使用适当的 Qt 版本来编译项目。

操作步骤

启动 Qt 时,它将询问您是否要创建一个新项目或是否要打开一个新项目。 您也可以通过在文件菜单下并选择新建...选项来创建新项目。 要复制我们在上一个秘籍中所做的操作,我们将选择Qt4 Console Application ,如以下屏幕截图所示:

How to do it...

然后,您可以指定一个名称和一个项目位置,如下所示:

How to do it...

以下屏幕将要求您选择要包含在项目中的模块。 只需保持默认状态为选中状态,然后单击下一步然后完成即可。 然后创建一个空的控制台应用,如下所示:

How to do it...

Qt 生成的代码创建QCoreApplication对象并调用其exec()方法。 仅当您的应用需要事件处理器来处理与 GUI 的用户交互时,才需要这样做。 在我们简单的打开和显示图像示例中,这不是必需的。 我们可以简单地将生成的代码替换为上一个任务中使用的代码。 然后,简单的打开并显示图像程序将如下所示:

How to do it...

为了能够编译该程序,需要指定 OpenCV 库文件和头文件的位置。 使用 Qt,此信息在项目文件(扩展名为.pro)中给出,该文件是描述项目参数的简单文本文件。 您可以通过选择相应的项目文件在 Qt Creator 中编辑此项目文件,如以下屏幕截图所示:

How to do it...

通过在项目文件的末尾添加以下行来提供构建 OpenCV 应用所需的信息:

INCLUDEPATH += C:\OpenCV2.2\include\

LIBS += -LC:\OpenCV2.2\lib \
-lopencv_core220 \
-lopencv_highgui220 \
-lopencv_imgproc220 \
-lopencv_features2d220 \
-lopencv_calib3d220

提示

下载示例代码

您可以从这个页面下载从帐户购买的所有 Packt 图书的示例代码文件。 如果您在其他地方购买了此书,则可以访问这个页面并注册以将文件直接通过电子邮件发送给您。

现在可以准备编译和执行程序了。 通过单击左下绿色箭头(或通过按Ctrl + R)来完成此操作。 您还可以使用项目选项卡的构建选项来设置调试发布模式。

How to do it...

工作原理

Qt 项目由项目文件描述。 这是一个文本文件,其中声明了一个变量列表,其中包含构建项目所需的相关信息。 实际上,此文件由名为qmake的软件工具处理,Qt 在请求编译时会调用该工具。 项目文件中定义的每个变量都与值列表关联。 Qt 中 Qmake 可以识别的主要变量如下:

  • TEMPLATE: 定义项目的类型(应用,库等)。
  • CONFIG: 指定编译器在构建项目时应使用的不同选项。
  • HEADERS: 列出项目的头文件。
  • SOURCES: 列出项目的源文件(.cpp)。
  • QT: 声明所需的 Qt 扩展模块和库。 默认情况下,包括核心和 GUI 模块。 如果要排除其中之一,请使用-=表示法。
  • INCLUDEPATH: 指定应搜索的头文件目录。
  • LIBS: 包含应与项目链接的库文件列表。 您将标志–L用于目录路径,并将标志–l用于库名称。

还定义了其他几个变量,但是最常用的是此处列出的变量。

更多

Qmake 项目文件中可以使用许多其他功能。 例如,可以定义范围以添加适用于特定平台的声明:

win32 {
   # declarations for Windows 32 platforms only
}
unix {
   # declarations for Unix 32 platforms only
}

您也可以使用pkg-config工具包。 它是一个开源工具,可帮助使用正确的编译器选项和库文件。 当使用 CMake 安装 OpenCV 时,unix-install包含一个opencv.pc文件,该文件由pkg-config读取以确定编译参数。 一个多平台的 Qmake 项目文件可以如下所示:

unix { 

    CONFIG += link_pkgconfig
    PKGCONFIG += opencv
}

Win32 {

INCLUDEPATH += C:\OpenCV2.2\include\

LIBS += -LC:\OpenCV2.2\lib \
-lopencv_core220 \
   -lopencv_highgui220 \
   -lopencv_imgproc220 \
   -lopencv_features2d220 \
   -lopencv_calib3d220
}

另见

下一个秘籍“加载,显示,和保存图像”解释了我们在此任务中使用的 OpenCV 源代码。

有关 Qt,Qt Creator 和所有 Qt 扩展模块的完整文档,请访问网站

加载,显示和保存图像

前面的两个秘籍教您如何创建一个简单的 OpenCV 项目,但是我们没有解释所使用的 OpenCV 代码。 该任务将向您展示如何执行 OpenCV 应用开发中所需的最基本的操作。 这些步骤包括从文件加载输入图像,在窗口上显示图像以及将输出图像存储在磁盘上。

准备

使用 MS Visual Studio 或 Qt,创建一个具有准备就绪的main函数的新控制台应用。 有关如何进行的操作,请参见前两个秘籍。

操作步骤

首先要做的是声明一个将保留图像的变量。 在 OpenCV 2 下,您定义了cv::Mat类的对象。

cv::Mat image;

此定义创建大小为 0 乘 0 的图像。这可以通过调用cv::Mat方法size()进行确认,该方法允许您读取此图像的当前大小。 它返回一个包含图像高度和宽度的结构:

std::cout << "size: " << image.size().height << " , " 
          << image.size().width << std::endl;

接下来,对读取函数的简单调用将从文件读取图像,对其进行解码,然后分配内存:

image=  cv::imread("img.jpg");

现在您可以使用该图像了。 但是,您应该首先检查是否已正确读取图像(如果找不到文件,文件损坏或不是可识别的格式,则会发生错误)。 图像的有效性通过以下方式测试:

if (!image.data) { 
   // no image has been created... 
}

成员变量data实际上是指向将包含图像数据的已分配存储块的指针。 当未读取图像时,将其简单地设置为 0。 您可能要对此图像进行的第一件事是显示它。 您可以使用 OpenCV 提供的highgui模块来执行此操作。 首先,声明要在其上显示图像的窗口,然后指定要在此特殊窗口上显示的图像:

cv::namedWindow("Original Image"); // define the window
cv::imshow("Original Image", image); // show the image

现在,您通常将对图像进行一些处理。 OpenCV 提供了多种处理功能,本书中将探讨其中的一些功能。 让我们从一个非常简单的例子开始,它将简单地水平翻转图像。 OpenCV 中的几种图像转换可以在原地执行,这意味着该转换直接应用于输入图像(不创建新图像)。 翻转方法就是这种情况。 但是,我们总是可以创建另一个矩阵来保存输出结果,这就是我们要做的:

cv::Mat result;
cv::flip(image,result,1); // positive for horizontal
                          // 0 for vertical,                     
                          // negative for both

结果显示在另一个窗口上:

cv::namedWindow("Output Image");
cv::imshow("Output Image", result);

由于它是一个控制台窗口,将在main函数的结尾处终止,因此我们添加了一个额外的highgui方法以等待用户键,然后再结束程序:

cv::waitKey(0);

然后,您可以在两个不同的窗口中看到输入和输出图像。 最后,您可能希望将处理后的图像保存在磁盘上。 使用以下highgui函数可以完成此操作:

cv::imwrite("output.bmp", result);

文件扩展名确定将使用哪个编解码器保存图像。

工作原理

OpenCV 的 C++ API 中定义的所有类和函数都在名称空间cv中定义。 您有两种选择来访问它们。 首先,在主函数定义之前添加以下声明:

using namespace cv;

或者,像在本秘籍中一样,在所有的 OpenCV 类名和函数名前加上cv::命名空间规范。

cv::Mat是用于保存图像(显然还有其他矩阵数据)的数据结构。 默认情况下,它们的大小为零,但您也可以指定初始大小:

cv::Mat ima(240,320,CV_8U,cv::Scalar(100));

在这种情况下,您还需要指定每个矩阵元素的类型,此处CV_8U对应于 1 字节像素图像。 字母U表示未签名。 您也可以使用字母S声明带符号的数字。 对于彩色图像,您将指定三个通道(CV_8UC3)。 您还可以声明大小为 16 和 32 的整数(有符号或无符号)(例如CV_16SC3)。 您还可以访问 32 位和 64 位浮点数(例如CV_32F)。

cv::Mat对象超出范围时,分配的内存将自动释放。 这非常方便,因为可以避免出现内存泄漏问题。 此外,cv::Mat类实现引用计数和浅表复制,以便在将图像分配给另一图像时,不复制图像数据(即像素),并且两个图像都指向同一存储块。 这也适用于按值传递或按值返回的图像。 保留引用计数,以便仅在销毁对图像的所有引用时才释放内存。 如果您希望创建一个包含原始图像新副本的图像,则可以使用copyTo()方法。 您可以通过在此项目的示例中声明一些额外的图像来测试此行为,如下所示:

cv::Mat image2, image3;
image2= result; // the two images refer to the same data
result.copyTo(image3); // a new copy is created

现在,如果再次翻转输出图像并显示另外两个图像,您将看到image2也受到转换的影响(因为它指向的图像数据与结果图像相同),而图像的副本image3保持不变。 cv::Mat对象的此分配模型还意味着您可以安全地编写返回图像的函数(或类方法):

cv::Mat function() {

   // create image
   cv::Mat ima(240,320,CV_8U,cv::Scalar(100));
   // return it
   return ima;
}

如果我们从main函数中调用此函数:

   // get a gray-level image
   cv::Mat gray= function();

gray变量现在将保留该函数创建的图像,而无需分配额外的内存。 确实,只有图像的浅表副本将从返回的cv::Mat实例传输到灰度图像。 当ima局部变量超出范围时,将取消分配此变量,但是由于关联的参考计数器指示其内部图像数据正在被另一个实例(即gray变量)引用,因此其内存块没有被释放。

但是,对于类,应该小心,不要返回图像类属性。 这是一个容易出错的实现示例:

class Test {

   // image attribute
   cv::Mat ima;

  public:

     // constructor creating a gray-level image
     Test() : ima(240,320,CV_8U,cv::Scalar(100)) {}

     // method return a class attribute, not a good idea...
     cv::Mat method() { return ima; }
};

在这里,如果函数调用此类的方法,则它将获得图像属性的浅表副本。 如果以后再修改此副本,则class属性也将被修改,这可能会影响该类的后续行为(反之亦然)。 为避免此类错误,您应该返回属性的副本。

更多

在 OpenCV 的版本 2 中,引入了新的 C++ 接口。 以前,使用(并且仍然可以使用)类似 C 的函数和结构。 特别地,使用IplImage结构来操纵图像。 该结构继承自 IPL 库(即英特尔图像处理库),该库现已与 IPP 库(英特尔集成性能原始库)集成在一起。 如果使用通过旧 C 接口创建的代码和库,则可能需要操纵这些IplImage结构。 幸运的是,有一种方便的方法可以将IplImage转换为cv::Mat对象。

IplImage* iplImage = cvLoadImage("c:\\img.jpg");
cv::Mat image4(iplImage,false);

函数cvLoadImage是用于加载图像的 C 接口函数。 cv::Mat对象的构造器中的第二个参数指示该数据将不会被复制(如果想要新的副本,请将其设置为true,而false是默认值,因此可以将其省略), IplImageimage4将共享相同的图像数据。 您需要在此处小心,不要创建悬空的指针。 因此,将IplImage指针封装到 OpenCV 2 提供的引用计数指针类中更为安全:

cv::Ptr<IplImage> iplImage = cvLoadImage("c:\\img.jpg");

否则,如果您需要释放由IplImage结构指向的内存,则需要明确地执行以下操作:

 cvReleaseImage(&iplImage);

请记住,您应该避免使用此过时的数据结构。 相反,请始终使用cv::Mat

使用 Qt 创建 GUI 应用

Qt 提供了丰富的库来构建具有专业外观的复杂 GUI。 使用 Qt Creator,GUI 创建过程变得很容易。 此秘籍将向您展示如何使用 Qt 构建 OpenCV 应用,用户可以使用 GUI 进行控制。

准备

启动 Qt Creator,我们将使用它来创建 GUI 应用。 也可以不使用此工具来创建 GUI,但是使用可视化 IDE(可在其中轻松拖放小部件)是构建美观 GUI 的最简单方法。

操作步骤

选择创建新项目...,然后选择 Qt GUI 应用,如以下屏幕快照所示:

How to do it...

给您的项目命名和位置。 如果然后单击下一步,您将看到已选中 QtGUI 模块。 由于我们不需要其他模块,因此您现在可以单击完成。 这将创建您的新项目。 除了常用的项目文件(.pro)和main.cpp文件之外,您还会看到两个mainwindow文件,它们定义了包含 GUI 窗口的类。 您还将找到一个扩展名为.ui的文件,该文件描述了 UI 布局。 实际上,如果双击它,将会看到当前的用户界面,如下所示:

How to do it...

您可以在上面拖放不同的小部件。 像前面的示例一样,放下两个按钮。 您可以调整它们的大小并调整窗口的大小以使其美观。 您还应该重命名按钮标签。 只需单击文本,然后插入您选择的名称。

现在,我们添加一个信号方法来处理单击按钮事件。 右键单击第一个按钮,然后在上下文菜单中选择转到插槽...。 然后显示可能的信号列表,如以下屏幕快照所示:

How to do it...

只需选择clicked()信号即可。 这是处理按钮按下事件的事件。 这样,您将被带到mainwindow.cpp文件。 您将看到已添加了新方法。 这是在收到click()信号时调用的时隙方法:

#include "mainwindow.h"
#include "ui_mainwindow.h"
MainWindow::MainWindow(QWidget *parent)
: QMainWindow(parent), ui(new Ui::MainWindow)
{
   ui->setupUi(this);
}
MainWindow::~MainWindow()
{
   delete ui;
}
void MainWindow::on_pushButton_clicked()
{
}

为了能够显示然后处理图像,我们需要定义一个cv::Mat类成员变量。 这是在MainWindow类类的头文件中完成的。 现在,此标头的内容如下:

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QtGui/QMainWindow>
#include <QFileDialog>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>

namespace Ui
{
   class MainWindow;
}
class MainWindow : public QMainWindow
{
   Q_OBJECT
public:
   MainWindow(QWidget *parent = 0);
   ~MainWindow();
private:
   Ui::MainWindow *ui;
 cv::Mat image; // the image variable
private slots:
   void on_pushButton_clicked();
};

#endif // MAINWINDOW_H

请注意,我们还包括了core.hpphighgui.hpp头文件。 正如我们在前面的秘籍中所了解的那样,我们一定不要忘记编辑项目文件以附加 OpenCV 库信息。

然后可以添加 OpenCV 代码。 第一个按钮打开源图像。 这是通过将以下代码添加到相应的插槽方法来完成的:

void MainWindow::on_pushButton_clicked()
{
   QString fileName = QFileDialog::getOpenFileName(this,
        tr("Open Image"), ".", 
      tr("Image Files (*.png *.jpg *.jpeg *.bmp)"));

   image= cv::imread(fileName.toAscii().data());
   cv::namedWindow("Original Image");
   cv::imshow("Original Image", image);
}

然后,通过右键单击第二个按钮来创建新的插槽。 第二个插槽将对所选输入图像执行一些处理。 以下代码将简单地翻转图像:

void MainWindow::on_pushButton_2_clicked()
{
   cv::flip(image,image,1);
   cv::namedWindow("Output Image");
   cv::imshow("Output Image", image);
}

现在,您可以编译并运行该程序,您的 2 键 GUI 将允许您选择图像并进行处理。

How to do it...

输入和输出图像显示在我们定义的两个highgui窗口上。

工作原理

在 Qt 的 GUI 编程框架下,对象使用信号和插槽进行通信。 每当窗口小部件更改状态或发生事件时,都会发出信号。 该信号具有预定义的签名,如果另一个对象想要接收该信号,则它必须定义一个具有相同签名的插槽。 因此,插槽是一种特殊的类方法,当它所连接的信号发出时会自动调用。

信号和插槽被定义为类方法,但必须在指定插槽和信号的 Qt 访问下声明。 当您在按钮上添加插槽时,这就是 Qt Creator 所做的,即:

private slots:
  void on_pushButton_clicked();

信号和插槽是松散耦合的,也就是说,信号不知道与连接插槽的对象有关的任何信息,而插槽也不知道是否连接了信号。 同样,许多插槽可以连接到一个信号,并且一个插槽可以接收来自许多物体的信号。 唯一的要求是信号的签名和时隙方法必须匹配。

QObject类继承的所有类都可以包含信号和插槽。 这些通常是小部件类的子类(QWidget的子类),但是任何其他类都可以定义插槽和信号。 实际上,信号和时隙概念是一种非常强大的类通信机制。 但是,它特定于 Qt 框架。

在 Qt 中,主窗口是类MainWindow的实例。 您可以通过在MainWindow类定义中声明的成员变量ui来访问它。 另外,GUI 的每个小部件也是一个对象。 创建 GUI 时,指向您已添加到主窗口的每个小部件实例的指针与ui变量相关联。 因此,您可以访问程序中每个窗口小部件的属性和方法。 例如,如果要在选择输入图像之前禁用处理按钮,则您需要做的就是在 GUI 初始化时(在MainWindow构造器中)调用以下方法。 :

ui->pushButton_2->setEnabled(false);

指针变量pushbutton_2在此对应于处理按钮。 然后,当成功加载图像时,您可以启用按钮(在打开图像按钮中):

if (image.data) {
   ui->pushButton_2->setEnabled(true);
}

还值得注意的是,在 Qt 下,GUI 的布局在 XML 文件中已完全描述。 这是带有.ui扩展名的文件。 如果进入项目目录并使用文本编辑器打开.ui文件,则将能够读取该文件的 XML 内容。 定义了几个 XML 标签。 在此秘籍中介绍的示例应用的情况下,您将找到两个定义为QPushButton的窗口小部件类标记。 名称与这些窗口小部件类的标记关联,该名称与附加到ui对象的指针变量的名称相对应。 其中的每一个都定义了描述其位置和大小的几何属性。 还定义了许多其他属性标签。 Qt Creator 有一个属性选项卡,显示每个小部件的属性值。 因此,即使 Qt Creator 是创建 GUI 的最佳工具,您也可以编辑.ui XML 文件来创建和修改 GUI。

更多

使用 Qt,在 GUI 上直接显示图像相对容易。 您需要做的就是在窗口中添加一个标签对象。 然后,将图像分配给该标签以显示该图像。 请记住,您可以通过ui指针(在我们的示例中为ui->label)的相应指针属性访问标签实例。 但是此图像必须是QImage类型,即处理图像的 Qt 数据结构。 转换相对简单,只是需要反转三个颜色通道的顺序(从cv::Mat中的 BGR 到QImage中的 RGB)。 我们可以使用cv::cvtColor函数。 然后,我们简单的 GUI 应用的处理按钮可以更改为:

void MainWindow::on_pushButton_2_clicked()
{
   cv::flip(image,image,1); // process the image

   // change color channel ordering
   cv::cvtColor(image,image,CV_BGR2RGB); 

   // Qt image
   QImage img= QImage((const unsigned char*)(image.data), 
      image.cols,image.rows,QImage::Format_RGB888);

   // display on label
   ui->label->setPixmap(QPixmap::fromImage(img)); 
   // resize the label to fit the image
   ui->label->resize(ui->label->pixmap()->size());
}

结果,现在将输出图像直接显示在 GUI 上,如下所示:

There's more...

另见

有关 Qt GUI 模块以及信号和插槽机制的更多信息,请查阅位于这个页面的在线 Qt 文档。

二、操纵像素

在本章中,我们将介绍:

  • 访问像素值
  • 用指针扫描图像
  • 使用迭代器扫描图像
  • 编写有效的图像扫描循环
  • 使用邻居访问扫描图像
  • 执行简单的图像算术
  • 定义兴趣区域

简介

为了构建计算机视觉应用,您必须能够访问图像内容,并最终修改或创建图像。 本章将教您如何操作图像元素(又称像素)。 您将学习如何扫描图像并处理其每个像素。 您还将学习如何有效地执行此操作,因为即使尺寸适中的图像也可能包含数万个像素。

从根本上讲,图像是数值矩阵。 这就是为什么 OpenCV 2 使用cv::Mat数据结构来操作它们的原因。 矩阵的每个元素代表一个像素。 对于灰度图像(“黑白”图像),像素为无符号的 8 位值,其中 0 对应于黑色,而 255 对应于白色。 对于彩色图像,每个像素需要三个这样的值才能代表通常的三个原色通道(红绿蓝)。 因此,在这种情况下,矩阵元素由值的三元组组成。

如上一章所述,OpenCV 还允许您创建具有不同类型(例如,整数(CV_8U)和浮点数[CV_32F)的像素值的矩阵(或图像)。 这些对于在某些图像处理任务中存储例如中间值非常有用。 大多数操作可以应用于任何类型的矩阵,其他操作则需要特定类型的矩阵,或者仅适用于给定数量的通道。 因此,对函数或方法的先决条件有充分的了解对于避免常见的编程错误至关重要。

在本章中,我们使用以下彩色图像作为输入(请参见本书的网站以彩色方式查看该图像):

Introduction

访问像素值

为了访问矩阵的每个单独元素,您只需要指定其行号和列号即可。 将返回对应的元素,在多通道图像的情况下,该元素可以是单个数值或值的向量。

准备

为了说明对像素值的直接访问,我们将创建一个简单的函数,在图像中添加椒盐噪声。 顾名思义,椒盐噪声是一种特殊类型的噪声,其中某些像素被白色或黑色像素代替。 当某些像素的值在传输过程中丢失时,这种类型的噪声可能会出现在错误的通信中。 在我们的例子中,我们将简单地随机选择一些像素并将其分配为白色。

操作步骤

我们创建一个接收输入图像的函数。 这是将由我们的函数修改的图像。 为此,我们使用了传递引用机制。 第二个参数是我们要覆盖白色值的像素数:

void salt(cv::Mat &image, int n) {

   for (int k=0; k<n; k++) {

      // rand() is the MFC random number generator
      // try qrand() with Qt
      int i= rand()%image.cols;
      int j= rand()%image.rows;

      if (image.channels() == 1) { // gray-level image

         image.at<uchar>(j,i)= 255; 

      } else if (image.channels() == 3) { // color image

         image.at<cv::Vec3b>(j,i)[0]= 255; 
         image.at<cv::Vec3b>(j,i)[1]= 255; 
         image.at<cv::Vec3b>(j,i)[2]= 255; 
      }
   }
}

该函数由单个循环组成,该循环将n乘以255值乘以随机选择的像素。 在此,使用随机数生成器选择像素列i和行j。 请注意,我们通过检查与每个像素关联的通道数来区分灰度图像和彩色图像的两种情况。 在灰度图像的情况下,将的数字255分配给单个 8 位值。 对于彩色图像,需要为三个原色通道分配255,以获得白色像素。

您可以通过向其传递先前打开的图像来调用此函数:

   // open the image
   cv::Mat image= cv::imread("boldt.jpg");

   // call function to add noise
   salt(image,3000);

   // display image
   cv::namedWindow("Image");
   cv::imshow("Image",image);

生成的图像如下所示:

How to do it...

工作原理

cv::Mat包括几种访问图像不同属性的方法。 公共成员变量colsrows为您提供图像中的列数和行数。 对于元素访问,cv::Mat具有方法at(int y, int x)。 但是,必须在编译时知道方法返回的类型,并且由于cv::Mat可以保存任何类型的元素,因此程序员需要指定期望的返回类型。 这就是at方法已被实现为模板方法的原因。 因此,在调用它时,必须指定图像元素类型,如下所示:

         image.at<uchar>(j,i)= 255; 

重要的是要注意,确保指定的类型与矩阵中包含的类型匹配是程序员的责任。 at方法不执行任何类型转换。

在彩色图像中,每个像素与三个分量相关联:红色,绿色和蓝色通道。 因此,包含彩色图像的cv::Mat将返回三个 8 位值的向量。 OpenCV 具有针对此类短向量的定义类型,称为cv::Vec3b。 它是 3 个unsigned char的向量。 这就解释了为什么元素访问彩色像素的像素写为:

         image.at<cv::Vec3b>(j,i)[channel]= value; 

索引channel指定三个颜色通道之一。

2 元素和 4 元素向量(cv::Vec2bcv::Vec4b)以及其他元素类型也存在类似的向量类型。 在此后一种情况下,最后一个字母由shortsintifloatfdoubled替换。 所有这些类型都是使用模板类cv::Vec<T,N>定义的,其中T是类型,N是向量元素的数量。

更多

使用cv::Mat类的at方法有时会很麻烦,因为必须为每个调用将返回的类型指定为模板参数。 在已知矩阵类型的情况下,可以使用cv::Mat_类,它是cv::Mat的模板子类。 此类定义了一些其他方法,但没有新的数据属性,因此可以将指向一个类的指针或引用直接转换为另一个类。 在其他方法中,operator()允许直接访问矩阵元素。 因此,如果image是对uchar矩阵的引用,则可以编写:

   cv::Mat_<uchar> im2= image; // im2 refers to image
   im2(50,100)= 0; // access to row 50 and column 100

由于cv::Mat_元素的类型是在创建变量时声明的,因此operator()方法在编译时就知道要返回哪种类型。 除了编写时间短之外,使用operator()方法可提供与at方法完全相同的结果。

另见

“编写高效的图像扫描循环”秘籍可讨论此方法的效率。

用指针扫描图像

在大多数图像处理任务中,需要扫描图像的所有像素才能执行计算。 考虑到将需要访问的大量像素,以有效的方式执行此任务至关重要。 本秘籍以及下一篇秘籍,将向您展示实现图像扫描循环的不同方法。 该秘籍使用指针算法。

准备

我们将通过完成一个简单的任务来说明图像扫描过程:减少图像中的颜色数量。

彩色图像由 3 通道像素组成。 这些通道中的每一个对应于三种原色(红色,绿色,蓝色)之一的强度值。 由于这些值均为 8 位unsigned char,因此颜色总数为256x256x256,超过 1600 万种颜色。 因此,为减少分析的复杂性,有时减少图像中的颜色数量很有用。 一种简单的方法可以将 RGB 空间细分为相等大小的多维数据集。 例如,如果将每个尺寸的颜色数量减少 8,那么您将获得总共32x32x32的颜色。 然后,原始图像中的每种颜色在色彩缩减图像中被分配一个新的颜色值,该值对应于其所属的多维数据集中心的值。

因此,基本的色彩缩减算法很简单。 如果N是缩小因子,则对于图像中的每个像素以及该像素的每个通道,将值除以N(整数除法,因此会丢失提示)。 然后将结果乘以N,这将为您提供N在输入像素值以下的倍数。 只需加N / 2,即可获得N的两个相邻倍数之间的间隔的中心位置。如果对每个 8 位通道值重复此过程,则总共将获得256 / N x 256 / N x 256 / N可能的颜色值。

操作步骤

我们的色彩缩减函数的签名如下:

void colorReduce(cv::Mat &image, int div=64);

用户提供图像和每个通道的缩小系数。 此处,在原位中完成**处理,即通过该函数修改了输入图像的像素值。 请参见“本秘籍的更多内容”部分提供了具有输入和输出参数的更通用的函数签名。

通过创建遍历所有像素值的双循环即可简单地完成处理:

void colorReduce(cv::Mat &image, int div=64) {

     int nl= image.rows; // number of lines
     // total number of elements per line
     int nc= image.cols * image.channels(); 

     for (int j=0; j<nl; j++) {

        // get the address of row j
        uchar* data= image.ptr<uchar>(j);

        for (int i=0; i<nc; i++) {

            // process each pixel ---------------------

                  data[i]=    data[i]/div*div + div/2;

            // end of pixel processing ----------------

        } // end of line                   
     }
}

可以使用以下代码片段测试此函数:

   // read the image
   image= cv::imread("boldt.jpg");
   // process the image
   colorReduce(image);
   // display the image
   cv::namedWindow("Image");
   cv::imshow("Image",image);

例如,这将为您提供以下图像(请参见本书的网站以彩色查看此图像):

How to do it...

工作原理

在彩色图像中,图像数据缓冲区的前 3 个字节给出左上像素的 3 个颜色通道值,接下来的 3 个字节是第一行第二个像素的值,依此类推(请注意,OpenCV 使用 ,默认情况下,BGR 通道顺序,因此蓝色通常是第一个通道)。 宽度为W且高度为H的图像将需要WxHx3uchar的存储块。 但是,出于效率原因,可以用很少的额外像素来填充行的长度。 这是因为某些多媒体处理器芯片(例如 Intel MMX 架构)在行数为 4 或 8 的倍数时可以更有效地处理图像。 值将被忽略。 OpenCV 将填充行的长度指定为关键字。 显然,如果未用多余像素填充图像,则有效宽度将等于实际图像宽度。 数据属性cols为您提供图像宽度(即列数),属性rows为您提供图像高度,而step数据属性为您提供有效宽度。 字节数。 即使您的图像不是uchar的类型,step仍会为您提供连续的字节数。 像素元素的大小由方法elemSize给出(例如,对于 3 通道短整数矩阵(CV_16SC3),elemSize将返回 6)。 图像中的通道数由nchannels方法给出(对于灰度图像为 1,对于彩色图像为 3)。 最后,方法total返回矩阵中像素的总数(即矩阵项)。

然后,每行的像素值数量由下式给出:

     int nc= image.cols * image.channels(); 

为了简化指针算术的计算,cv::Mat类提供了一种直接为您提供图像行地址的方法。 这是ptr方法。 这是一个模板方法,返回行号j的地址:

        uchar* data= image.ptr<uchar>(j);

注意,在处理语句中,我们可以等效地使用指针算法在列之间移动。 所以我们可以这样写:

        *data++= *data/div*div + div2;

更多

本秘籍中介绍的色彩缩减函数仅提供完成此任务的一种方法。 人们还可以使用其他色彩缩减公式。 该函数的更通用版本也将允许指定不同的输入和输出图像。 通过考虑图像数据的连续性,还可以使图像扫描更有效。 最后,也可以使用常规的低级指针算法来扫描图像缓冲区。 以下各小节将讨论所有这些元素。

其他颜色缩减秘籍

在我们的示例中,通过利用整数除法来实现色彩缩减,该整数除法将除法结果取整为最接近的较低整数:

     data[i]= data[i]/div*div + div/2;

还可以使用模运算符计算出减少的颜色,该运算符将我们带到div的最接近倍数(1D 减少因子):

     data[i]=    data[i] – data[i]%div + div/2;

但是此计算要慢一些,因为它需要两次读取每个像素值。

另一种选择是使用按位运算符。 确实,如果我们将缩减因子限制为 2 的幂,即div=pow(2,n),则屏蔽像素值的前n位将为我们提供div的最接近的较低倍数。 该掩码可以通过简单的移位来计算:

     // mask used to round the pixel value
     uchar mask= 0xFF<<n; // e.g. for div=16, mask= 0xF0

颜色减少将通过以下方式给出:

     data[i]=    (data[i]&mask) + div/2;

通常,按位运算会导致非常高效的代码,因此当需要效率时,它们可以构成强大的替代方案。

具有输入和输出参数

在我们的色彩缩减示例中,该变换直接应用于输入图像,这称为原地变换。 这样,不需要额外的图像来保存输出结果,这在需要时可以节省内存使用。 但是,在某些应用中,用户希望保持原始图像不变。 然后,在调用该函数之前,将迫使用户创建图像的副本。 请注意,创建图像的相同深层副本的最简单方法是调用clone方法,例如:

   // read the image
   image= cv::imread("boldt.jpg");
   // clone the image
   cv::Mat imageClone= image.clone();
   // process the clone
   // orginal image remains untouched
   colorReduce(imageClone);
   // display the image result
   cv::namedWindow("Image Result");
   cv::imshow("Image Result",imageClone);

通过定义一个向用户提供使用或不使用原地处理选项的函数,可以避免这种额外的过载。 该方法的签名将是:

void colorReduce(const cv::Mat &image, // input image 
                 cv::Mat &result,      // output image
                 int div=64);

请注意,现在将输入图像作为const引用传递,这意味着该图像不会被该函数修改。 如果首选原地处理,则将同一图像指定为输入和输出:

colorReduce(image,image);

如果没有,则可以提供另一个cv::Mat实例,例如:

cv::Mat result;   
colorReduce(image,result);

此处的关键是首先验证输出图像是否具有分配的数据缓冲区,该缓冲区的大小和像素类型与输入图像的大小和像素类型匹配。 非常方便的是,此检查封装在[H​​TG1]的create方法中。 这是必须使用新的大小和类型重新分配矩阵时使用的方法。 如果偶然地矩阵已经具有指定的大小和类型,则不执行任何操作,并且该方法仅返回而无需接触实例。 因此,我们的函数应该仅从对create的调用开始,该调用将构建与输入图像大小和类型相同的矩阵(如有必要):

     result.create(image.rows,image.cols,image.type());

请注意,create始终创建连续图像,即没有填充的图像。 分配的内存块的大小为total()*elemSize() 。然后使用两个指针完成循环:

     for (int j=0; j<nl; j++) {

        // get the addresses of input and output row j
        const uchar* data_in= image.ptr<uchar>(j);
        uchar* data_out= result.ptr<uchar>(j);

        for (int i=0; i<nc; i++) {

            // process each pixel ---------------------

                  data_out[i]= data_in[i]/div*div + div/2;

            // end of pixel processing ----------------

        } // end of line                   

在提供相同图像作为输入和输出的情况下,此函数变得完全等同于本秘籍中介绍的第一个版本。 如果提供另一个图像作为输出,则该函数将正常运行,而不管函数调用之前是否分配了该图像。

高效扫描连续图像

前面我们曾解释过,出于效率的考虑,可以在每行的末尾用额外的像素填充图像。 但是,有趣的是,当未填充图像时,可以将图像视为WxH像素的长一维数组。 方便的cv::Mat方法可以告诉我们是否已填充图像。 如果图像不包含填充像素,则isContinuous方法返回true

在某些特定的处理算法中,可以通过在一个(较长)循环中处理图像来利用图像的连续性。 然后,我们的处理函数将编写如下:

void colorReduce(cv::Mat &image, int div=64) {

     int nl= image.rows; // number of lines
     int nc= image.cols * image.channels(); 

     if (image.isContinuous()) 
     {
        // then no padded pixels
        nc= nc*nl; 
        nl= 1;  // it is now a 1D array
     }

     // this loop is executed only once
     // in case of continuous images
     for (int j=0; j<nl; j++) { 

     uchar* data= image.ptr<uchar>(j);

          for (int i=0; i<nc; i++) {

            // process each pixel ---------------------

                  data[i]= data[i]/div*div + div/2;

            // end of pixel processing ----------------

          } // end of line                   
     }
}

现在,当连续性测试告诉我们图像不包含填充像素时,我们通过将宽度设置为 1 并将高度设置为WxH来消除外部循环。 注意,这里还可以使用reshape方法。 在这种情况下,您将编写以下内容:

     if (image.isContinuous()) 
     {
        // no padded pixels
        image.reshape(1,            // new number of channels
           image.cols*image.rows) ; // new number of rows
     }

     int nl= image.rows; // number of lines
     int nc= image.cols * image.channels(); 

方法reshape无需任何内存复制或重新分配即可更改矩阵尺寸。 第一个参数是新的通道数,第二个参数是新的行数。 列数会相应调整。

在这些实现中,内部循环按顺序处理所有图像像素。 当将几个小图像同时扫描到同一循环中时,此方法特别有利。

低级指针算法

cv::Mat类中,图像数据包含在unsigned char的存储块中。 该存储块第一个元素的地址由data属性给定,该属性返回一个无符号的char指针。 因此,要在图像的开头开始循环,您可以编写:

uchar *data= image.data;

通过使用有效宽度移动行指针,可以完成从一行到另一行的移动:

data+= image.step;  // next line

step方法为您提供一行中的字节总数(包括填充的像素)。 通常,您可以按以下方式获取行j和列i的像素地址:

// address of pixel at (j,i) that is &image.at(j,i)     
data= image.data+j*image.step+i*image.elemSize();    

但是,即使这在我们的示例中可行,也不建议以这种方式进行。 除了容易出错外,这种方法也不适用于兴趣区域。 本章末尾讨论了兴趣区域。

另见

“编写高效的图像扫描循环”秘籍用于讨论此处介绍的扫描方法的效率。

使用迭代器扫描图像

在面向对象的编程中,通常使用迭代器完成对数据集合的循环。 迭代器是专门构建的类,用于遍历集合的每个元素,隐藏了如何针对给定的集合专门对每个元素进行迭代。 信息隐藏原理的这种应用使扫描集合变得更加容易。 此外,无论使用哪种类型的集合,它的形式都相似。 标准模板库(STL)具有与其每个集合类关联的迭代器类。 然后,OpenCV 提供一个cv::Mat迭代器类,该类与 C++ STL 中的标准迭代器兼容。

准备

在此秘籍中,我们再次使用先前秘籍中描述的色彩缩减示例。

操作步骤

可以通过首先创建cv::MatIterator_对象来获得cv::Mat实例的迭代器对象。 与cv::Mat_子类的情况一样,下划线表示这是模板方法。 实际上,由于使用了图像迭代器来访问图像元素,因此必须在编译时就知道返回类型。 然后,将迭代器声明如下:

     cv::MatIterator_<cv::Vec3b> it;

另外,您还可以使用Mat_模板类中定义的iterator类型:

     cv::Mat_<cv::Vec3b>::iterator it;

然后,您可以使用常规的beginend迭代器方法遍历像素,但这些方法又是模板方法。 因此,我们的色彩缩减函数现在编写如下:

void colorReduce(cv::Mat &image, int div=64) {

     // obtain iterator at initial position
     cv::Mat_<cv::Vec3b>::iterator it= 
               image.begin<cv::Vec3b>();
     // obtain end position
     cv::Mat_<cv::Vec3b>::iterator itend= 
               image.end<cv::Vec3b>();

     // loop over all pixels
     for ( ; it!= itend; ++it) {

        // process each pixel ---------------------

       (*it)[0]= (*it)[0]/div*div + div/2;
       (*it)[1]= (*it)[1]/div*div + div/2;
       (*it)[2]= (*it)[2]/div*div + div/2;

        // end of pixel processing ----------------
     }
}

请记住,这里的迭代器返回cv::Vec3b,因为我们正在处理彩色图像。 使用解引用operator[]访问每个颜色通道元素。

工作原理

使用迭代器,无论扫描哪种集合,都始终遵循相同的模式。

首先,使用适当的专用类(在我们的示例中为cv::Mat_<cv::Vec3b>::iterator(或cv::MatIterator_<cv::Vec3b>))创建迭代器对象。

然后,您将获得一个在起始位置(在我们的示例中为图像的左上角)初始化的迭代器。 这是使用begin方法完成的。 对于cv::Mat实例,您将其获取为image.begin<cv::Vec3b>()。 您还可以在迭代器上使用算术。 例如,如果您希望从图像的第二行开始,则可以在image.begin<cv::Vec3b>()+image.rows处初始化cv::Mat迭代器。 可以使用end方法类似地获得收藏的结束位置。 但是,如此获得的迭代器就在您的集合之外。 这就是为什么您的迭代过程到达最终位置时必须停止的原因。 您还可以在此迭代器上使用算术,例如,如果希望在最后一行之前停止,则最终迭代将在迭代器达到image.end<cv::Vec3b>()-image.rows时停止。

初始化迭代器后,您将创建一个遍历所有元素的循环,直到到达末尾为止。 典型的while循环如下所示:

     while (it!= itend) {

        // process each pixel ---------------------
         ...

        // end of pixel processing ----------------

        ++it;
     }

operator++是用于移至下一个元素的那个。 您还可以指定更大的步长。 例如,it+=10将每 10 个像素处理一次。

最后,在处理循环内部,使用解引用operator*来访问当前元素,您可以使用该元素读取(例如element= *it;)或写入(例如*it= element;)。 请注意,如果收到对const cv::Mat的引用,或者希望表示当前循环不修改cv::Mat实例,则也可以创建使用的常量迭代器。 这些声明如下:

     cv::MatConstIterator_<cv::Vec3b> it;

或者:

     cv::Mat_<cv::Vec3b>::const_iterator it;

更多

在此秘籍中,使用模板方法beginend获得迭代器的开始和结束位置。 就像我们在本章第一章中所做的那样,我们也可以使用对cv::Mat_实例的引用来获得它们。 这样可以避免在beginend方法中指定迭代器类型的需要,因为在创建cv::Mat_引用时就指定了该迭代器类型。

     cv::Mat_<cv::Vec3b> cimage= image;
     cv::Mat_<cv::Vec3b>::iterator it= cimage.begin();
     cv::Mat_<cv::Vec3b>::iterator itend= cimage.end();

另见

“编写高效的图像扫描循环”秘籍讨论了扫描图像时迭代器的效率。

另外,如果您不熟悉面向对象编程中迭代器的概念以及如何在 ANSI C++ 中实现迭代器,则应阅读有关 STL 迭代器的教程。 您只需用关键字“STL 迭代器”在网络上搜索,就可以找到许多关于该主题的参考。

编写有效的图像扫描循环

在本章的先前秘籍中,我们介绍了扫描图像以处理其像素的不同方法。 在本秘籍中,我们将比较这些不同方法的效率。

当您编写图像处理函数时,效率通常是一个问题。 在设计函数时,经常需要检查代码的计算效率,以发现可能会减慢程序速度的任何瓶颈。

但是,必须注意的是,除非有必要,否则不应以降低程序清晰度为代价进行优化。 简单的代码的确总是更容易调试和维护。 只有对程序效率至关重要的代码部分才应进行严重优化。

操作步骤

为了测量一个函数或部分代码的执行时间,存在一个非常方便的称为cv::getTickCount()的 OpenCV 函数。 此函数为您提供自上次启动计算机以来发生的时钟周期数。 由于我们希望以毫秒为单位给出代码部分的执行时间,因此我们使用了另一种方法cv::getTickFrequency() 。 这给了我们每秒的循环数。 为了获得给定函数(或部分代码)的计算时间而使用的常用模式将是:

double duration;
duration = static_cast<double>(cv::getTickCount());

colorReduce(image); // the function to be tested

duration = static_cast<double>(cv::getTickCount())-duration;
duration /= cv::getTickFrequency(); // the elapsed time in ms

持续时间结果应在函数的多次调用中取平均值。

colorReduce函数的测试中,我们还实现了使用at方法进行像素访问的函数版本。 然后,此实现的主循环将读为:

      for (int j=0; j<nl; j++) {
          for (int i=0; i<nc; i++) {

           // process each pixel ---------------------

           image.at<cv::Vec3b>(j,i)[0]=
               image.at<cv::Vec3b>(j,i)[0]/div*div + div/2;
           image.at<cv::Vec3b>(j,i)[1]=    
              image.at<cv::Vec3b>(j,i)[1]/div*div + div/2;
           image.at<cv::Vec3b>(j,i)[2]=    
              image.at<cv::Vec3b>(j,i)[2]/div*div + div/2;

           // end of pixel processing ----------------

           } // end of line                   
      }

工作原理

在此报告本章中colorReduce函数的不同实现的执行时间。 一台机器的绝对运行时数会有所不同(这里我们使用的是奔腾双核 2.2GHz)。 看看它们的相对差异是很有趣的。 我们的测试报告减少分辨率为4288x2848像素的图像的颜色所需的平均时间。 下表中汇总了结果,并在下面进行了讨论:

方法 平均时间
data[i]= data[i]/div*div + div/2 ; 37ms
*data++= *data/div*div + div/2; 37ms
*data++= v - v%div + div/2; 52ms
*data++= *data&mask + div/2; 35ms
colorReduce(input, output); 44ms
i<image.cols*image.channels(); 65ms
MatIterator 67ms
.at(j,i) 80ms
3-channel loop 29ms

首先,我们比较通过指针扫描图像的“更多内容”部分中介绍的三种计算色彩缩减的方法(第 1-4 行)。不出所料,使用按位运算符的版本最快,执行时间为35ms。 使用整数除法的版本取37ms,而取模的版本取52ms。 最快与最慢之间相差近 50%! 因此,重要的是要花一些时间来确定在图像循环中计算结果的最有效方法,因为净影响可能非常显着。 注意,当指定需要重新分配的输出图像而不是原地处理(第 5 行)时,执行时间变为44ms。 额外的持续时间代表内存分配的开销。

在循环中,应避免重复计算可能会预先计算的值。 这显然会浪费时间。 例如,如果您替换颜色减少函数的以下内部循环:

 int nc= image.cols * image.channels(); 
 ...
      for (int i=0; i<nc; i++) {

与此:

      for (int i=0; i<image.cols * image.channels(); i++) {

那是一个循环,您需要一次又一次地计算一行中的元素总数。 您将获得65ms的运行时,比35ms的原始版本(第 6 行)慢 80%。

使用迭代器(第 7 行)的色彩缩减函数版本(如秘籍“使用迭代器扫描图像”所示),在67ms处的结果较慢。 迭代器的主要目的是简化图像扫描过程,并减少出错的可能性。 不一定要优化此过程。

使用上一节末尾介绍的at方法的实现要慢得多(第 8 行)。 获得80ms的运行时。 然后,应将这种方法用于图像像素的随机访问,但在扫描图像时绝对不要使用。

即使处理的元素总数相同,使用较少语句的较短循环通常比使用单个语句的较长循环更有效地执行。 同样,如果您要对一个像素应用N个不同的计算,请全部执行一个循环,而不要编写N个连续的循环,每次计算一次。 然后,您应该偏爱循环,在较长的循环中进行更多的工作,而较长的循环会减少计算量。 举例来说,我们可以处理内部循环中的所有三个通道,并在列数上进行迭代,而不是使用原始版本,其中循环遍历元素总数(即像素数的 3 倍) 。 然后将颜色减少函数编写如下(这是最快的版本):

void colorReduce(cv::Mat &image, int div=64) {

     int nl= image.rows; // number of lines
     int nc= image.cols ; // number of columns

     // is it a continous image?
     if (image.isContinuous())  {
        // then no padded pixels
        nc= nc*nl; 
        nl= 1;  // it is now a 1D array
      }

     int n= static_cast<int>(
              log(static_cast<double>(div))/log(2.0));
     // mask used to round the pixel value
     uchar mask= 0xFF<<n; // e.g. for div=16, mask= 0xF0

     // for all pixels         
     for (int j=0; j<nl; j++) {

          // pointer to first column of line j
          uchar* data= image.ptr<uchar>(j);

          for (int i=0; i<nc; i++) {

            // process each pixel ---------------------

            *data++= *data&mask + div/2;
            *data++= *data&mask + div/2;
            *data++= *data&mask + div/2;

            // end of pixel processing ----------------

          } // end of line                   
     }
}

通过此修改,执行时间现在为29ms(第 9 行)。 我们还添加了连续性测试,该连续性测试在连续图像的情况下会产生一个循环,而不是对行和列进行常规的双循环。 对于非常大的图像(如我们在测试中使用的图像),这种优化并不重要,但总的来说,使用此策略始终是一种很好的做法,因为它可以大大提高速度。

更多

多线程是提高算法效率的另一种方法,尤其是自多核处理器问世以来。 OpenMP 和英特尔线程构建模块(TBB)是在并发编程中用于创建和管理线程的两种流行的 API。

另见

看看“执行简单图像算术”秘籍,了解使用 OpenCV 2 算术图像运算符的色彩缩减方法的实现。

使用邻居访问扫描图像

在图像处理中,通常具有基于相邻像素的值来计算每个像素位置处的值的处理函数。 当该邻域包含上一行和下一行的像素时,则需要同时扫描图像的几行。 此秘籍向您展示如何做。

准备

为了说明这一秘籍,我们将应用处理函数以使图像清晰。 它基于拉普拉斯算子(将在第 6 章中进行讨论)。 在图像处理中确实是众所周知的结果,如果从图像中减去其拉普拉斯算子,则会放大图像边缘,从而获得更清晰的图像。 该锐化运算符的计算如下:

sharpened_pixel= 5*current-left-right-up-down;

其中left是当前像素左侧的像素,up是前一行对应的像素,依此类推。

操作步骤

这次,处理无法原地完成。 用户需要提供输出图像。 图像扫描是通过使用三个指针完成的,一个指针用于当前行,一个指针用于上一行,另一个指针用于下一行。 另外,由于每个像素计算都需要访问相邻像素,因此无法为图像的第一行和最后一行的像素以及第一列和最后一列的像素计算值。 然后可以将循环编写如下:

void sharpen(const cv::Mat &image, cv::Mat &result) {

    // allocate if necessary
    result.create(image.size(), image.type()); 

    for (int j= 1; j<image.rows-1; j++) { // for all rows 
                                // (except first and last)

      const uchar* previous= 
         image.ptr<const uchar>(j-1); // previous row
      const uchar* current= 
         image.ptr<const uchar>(j);     // current row
      const uchar* next= 
         image.ptr<const uchar>(j+1); // next row

      uchar* output= result.ptr<uchar>(j); // output row

      for (int i=1; i<image.cols-1; i++) {

         *output++= cv::saturate_cast<uchar>(
                       5*current[i]-current[i-1]
                       -current[i+1]-previous[i]-next[i]); 
      }
   }

   // Set the unprocess pixels to 0
   result.row(0).setTo(cv::Scalar(0));
   result.row(result.rows-1).setTo(cv::Scalar(0));
   result.col(0).setTo(cv::Scalar(0));
   result.col(result.cols-1).setTo(cv::Scalar(0));
}

如果我们将此函数应用于测试图像的灰度版本,则会获得以下示例:

How to do it...

工作原理

为了访问上一行和下一行的相邻像素,必须简单定义共同增加的其他指针。 然后,您可以在扫描循环中访问这些行的像素。

在输出像素值的计算中,对运算结果调用模板函数cv::saturate_cast。 这是因为经常发生这样的情况:对像素应用数学表达式会导致结果超出允许的像素值范围(小于 0 或大于 255)。 然后的解决方案是恢复该 8 位范围内的值。 这是通过将负值更改为 0 并将值更改为 255 至 255 来完成的。这正是cv::saturate_cast<uchar>函数所做的。 此外,如果输入参数是浮点数,则结果将四舍五入到最接近的整数。 您显然可以将此函数与其他类型一起使用,以确保结果将保持在此类型定义的范围内。

由于邻域未完全定义而无法处理的边界像素需要单独处理。 在这里,我们将它们简单地设置为 0。在其他情况下,可以对这些像素执行一些特殊的计算,但是在大多数情况下,花费时间来处理这些很少的像素是没有意义的。 在我们的函数中,使用两种特殊方法将这些边界像素设置为 0。 第一个是row及其对偶的col。 它们返回一个特殊的cv::Mat实例,该实例由参数中指定的单行(或单列)组成。 这里没有进行复制,因为如果修改此一维矩阵的元素,它们也将在原始图像中被修改。 这就是调用方法setTo时所做的事情。 此方法为矩阵的所有元素分配一个值。 因此声明:

   result.row(0).setTo(cv::Scalar(0));

将值 0 分配给结果图像第一行的所有像素。 对于 3 通道彩色图像,可以使用cv::Scalar(a,b,c)指定三个值以分配给像素的每个通道。

更多

当在像素邻域上完成计算时,通常用核矩阵表示它。 该核描述了如何将计算中涉及的像素进行组合以获得所需的结果。 对于此秘籍中使用的锐化过滤器,核为:

0 -1 0
-1 5 -1
0 -1 0

除非另有说明,否则当前像素对应于核的中心。 核每个单元中的值代表一个乘以相应像素的因子。 然后,将所有这些乘法的总和给出核应用于像素的结果。 核的大小对应于邻域的大小(此处为3x3)。 使用这种表示法,可以看出,按照锐化过滤器的要求,当前像素的四个水平和垂直邻居都乘以 -1,而当前像素的水平和垂直邻居都乘以 5。 除了方便的表示之外,它是信号处理中卷积概念的基础。 核定义了应用于图像的过滤器。

由于过滤是图像处理中的常见操作,因此 OpenCV 定义了执行此任务的特殊函数: cv::filter2D函数。 要使用它,只需定义一个核(以矩阵的形式)。 然后使用图像和核调用该函数,并返回过滤后的图像。 因此,使用此函数可以很容易地重新定义锐化函数,如下所示:

void sharpen2D(const cv::Mat &image, cv::Mat &result) {

   // Construct kernel (all entries initialized to 0)
   cv::Mat kernel(3,3,CV_32F,cv::Scalar(0));
   // assigns kernel values
   kernel.at<float>(1,1)= 5.0;
   kernel.at<float>(0,1)= -1.0;
   kernel.at<float>(2,1)= -1.0;
   kernel.at<float>(1,0)= -1.0;
   kernel.at<float>(1,2)= -1.0;

   //filter the image
   cv::filter2D(image,result,image.depth(),kernel);
}

此实现产生与上一个完全相同的结果(并且具有相同的效率)。 但是,对于较大的核,使用filter2D方法是有利的,因为在这种情况下,它使用更有效的算法。

另见

第 6 章,“过滤图像”对图像过滤的概念进行了更多说明。

执行简单的图像运算

图像可以以不同的方式组合。 由于它们是规则矩阵,因此可以相加,相减,相乘或相除。 OpenCV 提供了各种图像算术运算符,本秘籍中将讨论它们的用法。

准备

让我们处理第二个图像,使用算术运算符将其合并到输入图像中。 以下是第二张图片:

Getting ready

操作步骤

在这里,我们添加两个图像。 当需要创建一些特殊效果或将信息覆盖在图像上时,此函数很有用。 我们通过调用cv::add函数,或更精确地说是cv::addWeighted函数来实现此目的,因为我们需要加权和,即:

   cv::addWeighted(image1,0.7,image2,0.9,0.,result);

该操作将产生一个新图像,如以下屏幕截图所示:

How to do it...

工作原理

所有二进制算术函数的工作方式均相同。 提供了两个输入,第三个参数指定了输出。 在某些情况下,可以指定在操作中用作标量乘数的权重。 这些函数中的每一个都有几种风格。 cv::add是多种形式的可用函数的典范:

   // c[i]= a[i]+b[i];
   cv::add(imageA,imageB,resultC); 
   // c[i]= a[i]+k;
   cv::add(imageA,cv::Scalar(k),resultC); 
   // c[i]= k1*a[1]+k2*b[i]+k3; 
   cv::addWeighted(imageA,k1,imageB,k2,k3,resultC);
   // c[i]= k*a[1]+b[i]; 
   cv::scaleAdd(imageA,k,imageB,resultC);

对于某些函数,您还可以指定一个掩码:

   // if (mask[i]) c[i]= a[i]+b[i];    
   cv::add(imageA,imageB,resultC,mask); 

如果应用遮罩,则仅对遮罩值不为null的像素(遮罩必须为 1 通道)执行该操作。 看看cv::subtractcv::absdiffcv::multiplycv::divide函数的不同形式。 还可以使用按位运算符:cv::bitwise_andcv::bitwise_orcv::bitwise_xorcv::bitwise_not。 查找每个元素的最大或最小像素值的运算符cv::mincv::max也非常有用。

在所有情况下,始终使用函数cv::saturate_cast(请参见前面的秘籍)以确保结果保持在定义的像素值域内(即避免上溢或下溢)。

图像必须具有相同的尺寸和类型(如果输出图像与输入尺寸匹配,则将重新分配输出图像)。 而且,由于操作是按元素执行的,因此输入图像之一可以用作输出。

也可以使用将单个图像作为输入的几种运算符:cv::sqrtcv::powcv::abscv::cuberootcv::expcv::log。 实际上,几乎所有需要对图像执行的操作都具有 OpenCV 函数。

更多

也可以在cv::Mat实例或cv::Mat实例的各个通道上使用常规的 C++ 算术运算符。 以下两个小节说明了如何执行此操作。

重载的图像运算符

非常方便的是,大多数算术函数在 OpenCV 2 中都有相应的运算符重载。因此,对cv::addWeighted的调用可以写为:

result= 0.7*image1+0.9*image2;

这是一种更紧凑的形式,也更易于阅读。 这两种写加权总和的方法是等效的。 特别是在两种情况下,函数cv::saturate_cast仍将被调用。

大多数 C++ 运算符已被重载。 其中按位运算符&|, ^~minmaxabs函数,比较运算符<<===!=>>=; 这些后来返回一个 8 位二进制图像。 您还会发现矩阵乘法m1*m2(其中m1m2都是cv::Mat实例),矩阵求逆m1.inv(),转置m1.t(),行列式m1.determinant(),向量范数v1.norm(), 叉积v1.cross(v2),点积v1.dot(v2)等。 在这种情况下,您还可以定义op=运算符(例如+=)。

在“编写高效的图像扫描循环”秘籍中,我们提出了一种色彩缩减函数,该函数是通过使用循环扫描图像像素以对其执行一些算术运算而编写的。 根据我们在这里学到的知识,可以使用输入图像上的算术运算符简单地重写此函数,即:

     image=(image&cv::Scalar(mask,mask,mask))
                  +cv::Scalar(div/2,div/2,div/2);

cv::Scalar的使用是由于我们正在处理彩色图像。 执行与在“编写高效的图像扫描循环”秘籍中所做的相同测试,我们获得89ms的执行时间。 这主要是因为如所写,该表达式需要调用两个函数,按位与和标量和(而不是在一个图像循环内执行完整的操作)。 即使生成的代码并非始终是最佳的,使用图像运算符也使代码如此简单,并且程序员如此高效,以至于在大多数情况下都应考虑使用它们。

分割图像通道

有时您可能需要独立处理图像的不同通道。 例如,您可能只想在图像的一个通道上执行操作。 当然,您可以在图像扫描循环中实现此目的。 但是,您也可以使用cv::split函数,它将彩色图像的三个通道复制到三个不同的cv::Mat实例中。 假设我们只想将雨图像添加到蓝色通道。 以下是我们将如何进行:

   // create vector of 3 images
   std::vector<cv::Mat> planes;
   // split 1 3-channel image into 3 1-channel images
   cv::split(image1,planes);
   // add to blue channel
   planes[0]+= image2;
   // merge the 3 1-channel images into 1 3-channel image
   cv::merge(planes,result);

cv::merge函数执行双重操作,即从三个 1 通道图像创建彩色图像。

定义兴趣区域

有时,仅需要在图像的一部分上应用处理函数。 该秘籍将教您如何在图像内定义兴趣区域。

准备

假设我们要组合两个大小不同的图像。 例如,假设我们要在测试图像中添加以下小徽标:

Getting ready

但是函数cv::add需要两张相同大小的图像。 在这种情况下,可以定义兴趣区域(ROI),可以在其上应用cv::add。 只要 ROI 与我们徽标图像的大小相同,这将起作用。 ROI 的位置将确定徽标将在图像中插入的位置。

操作步骤

第一步包括定义 ROI。 定义后,可以将 ROI 作为常规cv::Mat实例进行操作。 关键是 ROI 指向与其父映像相同的数据缓冲区。 然后,将徽标插入如下:

   // define image ROI
   cv::Mat imageROI;
   imageROI= image(cv::Rect(385,270,logo.cols,logo.rows));

   // add logo to image 
   cv::addWeighted(imageROI,1.0,logo,0.3,0.,imageROI);

然后获得以下图像:

How to do it...

由于徽标的颜色已添加到图像的颜色中(还可能应用了饱和度),因此视觉效果将不总是令人满意的。 因此,最好将图像的像素值简单地设置为该图像出现的徽标值。 为此,您可以使用遮罩将徽标复制到 ROI:

   // define ROI
   imageROI= image(cv::Rect(385,270,logo.cols,logo.rows));

   // load the mask (must be gray-level)
   cv::Mat mask= cv::imread("logo.bmp",0);

   // copy to ROI with mask
   logo.copyTo(imageROI,mask);

然后,结果图像为:

How to do it...

工作原理

定义 ROI 的一种方法是使用cv::Rect实例。 顾名思义,它通过指定左上角的位置(构造器的前两个参数)和矩形的大小(后两个参数给出的宽度和高度)来描述矩形区域。

还可以使用行和列范围来描述 ROI。 范围是从开始索引到结束索引的连续序列(不包括在内)。 cv::Range结构用于表示此概念。 因此,可以从两个范围定义 ROI,例如,在我们的示例中,ROI 可以等效地定义如下:

cv::Mat imageROI= image(cv::Range(270,270+logo.rows), 
                        cv::Range(385,385+logo.cols))

cv::Matoperator()返回另一个cv::Mat实例,该实例随后可用于子序列调用中。 ROI 的任何变换都会影响相应区域中的原始图像,因为图像和 ROI 共享相同的图像数据。 由于 ROI 的定义不会复制数据,因此无论 ROI 的大小如何,它都将在固定时间内执行。

如果要定义由图像的某些行组成的 ROI,可以使用以下调用:

cv::Mat imageROI= image.rowRange(start,end) ;

同样,对于由某些图像列组成的 ROI:

cv::Mat imageROI= image.colRange(start,end) ;

秘籍“使用访问邻居扫描图像”中使用的方法rowcol是这些后来方法的特殊情况,其中开始索引和结束索引相等,以便定义一个在线或单列 ROI。

三、使用类处理图像

在本章中,我们将介绍:

  • 在算法设计中使用策略模式
  • 使用控制器与处理模块通信
  • 使用单例设计模式
  • 使用模型-视图-控制器架构设计应用
  • 转换色彩空间

简介

好的计算机视觉程序始于好的编程习惯。 构建无错误的应用仅仅是开始。 您真正想要的是一个应用,您和与您一起工作的程序员将能够轻松适应新需求的发展。本章将向您展示如何充分利用一些面向对象的编程原理,以便建立高质量的软件程序。 特别是,我们将介绍一些重要的设计模式,这些模式将帮助您构建由易于测试,维护和重用的组件组成的应用。

设计模式是软件工程中众所周知的概念。 基本上,设计模式是对软件设计中经常出现的一般性问题的一种可重复使用的合理解决方案。 已经引入了许多软件模式,并有据可查。 好的程序员应该对这些现有模式有一定的了解。

本章还有第二个目标。 它将教您如何使用图像颜色。 本章中使用的示例将向您展示如何检测给定颜色的像素,最后的秘籍将说明如何使用不同的颜色空间。

在策略设计中使用策略模式

策略设计模式的目标是将算法封装到一个类中。 这样,将给定算法替换为另一个算法,或将多个算法链接在一起以构建更复杂的过程变得更加容易。 另外,该模式通过将尽可能多的复杂性隐藏在直观的编程接口后面,从而促进了算法的部署。

准备

假设我们要构建一种简单的算法,该算法将识别图像中具有给定颜色的所有像素。 然后,算法必须接受图像和颜色作为输入,并返回显示具有指定颜色的像素的二进制图像。 我们希望接受颜色的容差将是运行算法之前要指定的另一个参数。

操作步骤

该算法的核心过程很容易构建。 这是一个遍历每个像素的简单扫描循环,将其颜色与目标颜色进行比较。 使用我们在上一章中学到的知识,该循环可以写为:

     // get the iterators
     cv::Mat_<cv::Vec3b>::const_iterator it=
                         image.begin<cv::Vec3b>();
     cv::Mat_<cv::Vec3b>::const_iterator itend=
                      image.end<cv::Vec3b>();
     cv::Mat_<uchar>::iterator itout= 
                        result.begin<uchar>();

     // for each pixel
     for ( ; it!= itend; ++it, ++itout) {

        // process each pixel ---------------------

        // compute distance from target color
        if (getDistance(*it)<minDist) {

           *itout= 255;

        } else {

           *itout= 0;
        }

        // end of pixel processing ----------------
     }

cv::Mat变量image表示输入图像,而result表示二进制输出图像。 因此,第一步包括设置所需的迭代器。 这样,即可轻松实现扫描for循环。 每次迭代都会检查当前像素颜色和目标颜色之间的距离是否在minDist定义的公差范围内。 如果是这种情况,则将值255(白色)分配给输出图像,如果不是,则分配0(黑色)。 要计算两个颜色值之间的距离,请使用getDistance方法。 有多种计算此距离的方法。 例如,可以计算包含 RGB 颜色值的 3 个向量之间的欧式距离。 在我们的案例中,为使计算简单有效,我们简单地将 RGB 值的绝对差求和(也称为城市街区距离)。 getDistance方法的定义如下:

     // Computes the distance from target color.
     int getDistance(const cv::Vec3b& color) const {

        return abs(color[0]-target[0])+
               abs(color[1]-target[1])+
               abs(color[2]-target[2]);
     }

请注意,我们如何使用cv::Vec3d来保存代表颜色的 RGB 值的三个unsigned chars。 变量target显然是指指定的目标颜色,正如我们将要看到的,它在我们定义的类算法中被定义为类变量。 现在,让我们完成处理方法的定义。 用户将提供输入图像,图像扫描完成后将返回结果:

cv::Mat ColorDetector::process(const cv::Mat &image) {

     // re-allocate binary map if necessary
     // same size as input image, but 1-channel
     result.create(image.rows,image.cols,CV_8U);

*processing loop above goes here*
     ...

     return result;
}

每次调用此方法时,检查是否需要重新分配包含结果二进制映射的输出图像以适合输入图像的大小,这一点很重要。 这就是为什么我们使用cv::Matcreate方法。 请记住,只有在指定的大小和深度与当前图像结构不符时,该图像才会继续进行重新分配。

现在我们已经定义了核心处理方法,让我们看看应该添加哪些其他方法来部署此算法。 先前我们确定了算法需要哪些输入和输出数据。 因此,我们首先定义将保存此数据的类属性:

class ColorDetector {

  private:

     // minimum acceptable distance
     int minDist; 

     // target color
     cv::Vec3b target; 

     // image containing resulting binary map
     cv::Mat result;

为了创建封装我们的算法的类的实例(并命名为ColorDetector),我们需要定义一个构造器。 请记住,策略设计模式的目标之一是使算法部署尽可能容易。 可以定义的最简单的构造器是一个空的构造器。 它将在有效状态下创建类算法的实例。 然后,我们希望构造器将所有输入参数初始化为其默认值(或通常能带来良好结果的已知值)。 在我们的案例中,我们认为距离 100 通常是可以接受的公差。 我们还设置了默认的目标颜色。 我们没有特殊原因选择黑色。 目的是确保我们始终从可预测和有效的输入值开始:

     // empty constructor
     ColorDetector() : minDist(100) { 

        // default parameter initialization here
        target[0]= target[1]= target[2]= 0;
     }

此时,创建我们的类算法实例的用户可以立即使用有效图像调用process方法并获得有效输出。 这是“策略”模式的另一个目标,即确保算法始终以有效参数运行。 显然,此类的用户将想要使用自己的设置。 这是通过为用户提供适当的获取器和获取器来完成的。 让我们从颜色容差参数开始:

     // Sets the color distance threshold.
     // Threshold must be positive, 
     // otherwise distance threshold is set to 0.
     void setColorDistanceThreshold(int distance) {

        if (distance<0)
           distance=0;
        minDist= distance;
     }

     // Gets the color distance threshold
     int getColorDistanceThreshold() const {

        return minDist;
     }

注意我们如何首先检查输入的有效性。 同样,这是为了确保我们的算法永远不会在无效状态下运行。 可以类似地设置目标颜色:

     // Sets the color to be detected
     void setTargetColor(unsigned char red, 
                          unsigned char green, 
                          unsigned char blue) {

        // BGR order
        target[2]= red;
        target[1]= green;
        target[0]= blue;
     }

     // Sets the color to be detected
     void setTargetColor(cv::Vec3b color) {

        target= color;
     }

     // Gets the color to be detected
     cv::Vec3b getTargetColor() const {

        return target;
     }

这次有趣的是,我们为用户提供了setTagertColor方法的两个定义。 在第一个版本中,将三个颜色分量指定为三个参数,而在第二个版本中,cv::Vec3b用于保存颜色值。 同样,目标是促进使用我们的类算法。 用户只需选择最适合需求的安装员即可。

工作原理

一旦使用策略设计模式将算法封装到一个类中,就可以通过创建此类的实例来进行部署。 通常,实例将在程序初始化时创建。 可以读取和显示算法参数的默认值。 对于具有 GUI 的应用,可以使用不同的小部件(文本字段,滑块等)读取和设置参数值,以便用户可以轻松地使用它们。 但是在介绍 GUI 之前(这将在本章后面完成),让我们首先编写一个简单的main函数,该函数将运行我们的颜色检测算法:

int main()
{
   // 1\. Create image processor object
   ColorDetector cdetect;

   // 2\. Read input image
   cv::Mat image= cv::imread("boldt.jpg");
   if (!image.data)
      return 0; 

   // 3\. Set input parameters
   cdetect.setTargetColor(130,190,230); // here blue sky

   cv::namedWindow("result");

   // 4\. Process the image and display the result
   cv::imshow("result",cdetect.process(image));

   cv::waitKey();

   return 0;
}

在上一章介绍的彩色图像上运行该程序会产生以下输出:

How it works...

显然,我们在此类中封装的算法相对简单(只有一个扫描循环和一个公差参数)。 当要实现的算法更加复杂,具有多个步骤并包含多个参数时,策略设计模式将变得非常强大。

更多

要计算两个颜色向量之间的距离,我们使用以下简单公式:

return abs(color[0]-target[0])+
       abs(color[1]-target[1])+
       abs(color[2]-target[2]);

但是,OpenCV 包含用于计算向量的欧几里得范数的函数。 因此,我们可以计算出如下距离:

return static_cast<int>(
   cv::norm<int,3>(cv::Vec3i(color[0]-target[0],
                             color[1]-target[1],
                             color[2]-target[2])));

然后,使用getDistance方法的此定义将获得非常相似的结果。 在此,我们使用cv::Vec3i(整数的 3 个向量),因为相减的结果是整数值。

从第 2 章回忆起,OpenCV 矩阵和向量数据结构包括基本算术运算符的定义,这也很有趣。 例如,如果要添加两个cv::Vec3i向量ab并将结果分配给c,则可以简单地编写:

c= a+b;

或者,可以为距离计算提出以下定义:

return static_cast<int>(
   cv::norm<uchar,3>(color-target);

乍一看,这个定义可能是正确的,但是,这是错误的。 这是因为,所有这些运算符总是包含对saturate_cast的调用(请参阅上一章中的秘籍“使用访问邻居扫描图像”),以确保结果保持在输入类型的域内(此处为uchar)。 因此,在目标值大于相应颜色值的情况下,将分配值 0 而不是预期的负值。

另见

A. Alexandrescu 引入的基于策略的类设计是策略设计模式的一个有趣变体,其中在编译时选择算法。

Erich Gamma 等人(Addison-Wesley)于 1994 年出版的《设计模式:可重用的面向对象软件的元素》是关于该主题的经典书籍之一。

另请参阅“使用模型-视图-控制器模式”秘籍构建基于 GUI 的应用,以了解如何在具有 GUI 的应用中使用策略模式。

使用控制器与处理模块通信

在构建更复杂的应用时,您将需要创建可以组合在一起的多种算法,以完成一些高级任务。 因此,正确设置应用并让所有类一起通信将变得越来越复杂。 这样,将应用的控制集中在一个类中就变得很有利。 这是控制器背后的想法。 它是应用中的一个特定对象,起着重要的作用,我们将在本秘籍中对其进行探讨。

准备

使用两个按钮创建一个基于对话框的简单应用,一个按钮用于选择图像,另一个按钮用于开始处理,如下所示:

Getting ready

在这里,我们使用先前秘籍的ColorDetector类。

操作步骤

控制器的角色是首先创建执行应用所需的类。 在这里,它只是一堂课。 另外,我们需要两个成员变量,以保留对输入和输出结果的引用:

class ColorDetectController {

  private:

   // the algorithm class
   ColorDetector *cdetect;

   cv::Mat image;   // The image to be processed
   cv::Mat result;  // The image result

  public:

   ColorDetectController() { 

        //setting up the application
        cdetect= new ColorDetector();
   }

然后,您需要定义用户控制应用所需的所有设置器和获取器:

     // Sets the color distance threshold
     void setColorDistanceThreshold(int distance) {

        cdetect->setColorDistanceThreshold(distance);
     }

     // Gets the color distance threshold
     int getColorDistanceThreshold() const {

        return cdetect->getColorDistanceThreshold();
     }

     // Sets the color to be detected
     void setTargetColor(unsigned char red, 
        unsigned char green, unsigned char blue) {

        cdetect->setTargetColor(red,green,blue);
     }

     // Gets the color to be detected
     void getTargetColor(unsigned char &red, 
        unsigned char &green, unsigned char &blue) const {

        cv::Vec3b color= cdetect->getTargetColor();

        red= color[2];
        green= color[1];
        blue= color[0];
     }

     // Sets the input image. Reads it from file.
     bool setInputImage(std::string filename) {

        image= cv::imread(filename);

        if (!image.data)
           return false;
        else
           return true;
     }

     // Returns the current input image.
     const cv::Mat getInputImage() const {

        return image;
     }

您还需要一种将被调用以启动该过程的方法:

     // Performs image processing.
     void process() {

        result= cdetect->process(image);
     }

以及获得处理结果的方法:

     // Returns the image result from the latest processing.
     const cv::Mat getLastResult() const {

        return result;
     }

最后,在应用终止(并释放控制器)时清理所有内容非常重要:

     // Deletes processor objects created by the controller.
     ~ColorDetectController() {

        delete cdetect;
     }

工作原理

使用上面的控制器类,程序员可以轻松地为将执行算法的应用构建接口。 程序员无需了解所有类如何连接在一起,也不必找出必须调用哪个类的方法才能使所有程序正常运行。 这全部由控制器类完成。 唯一的要求是创建该控制器类的实例。

控制器中定义的设置器和获取器是您认为部署算法所需的那些。 这些方法只是在适当的类中调用相应的方法。 同样,这里的简单示例仅包含一种类算法,但是在大多数情况下,将涉及多个类实例。 因此,控制器的作用是将请求重定向到适当的类,并简化与这些类的接口。 作为这种简化的示例,请考虑方法setTargetColorgetTargetColor 。 他们都使用uchar设置并获取感兴趣的颜色。 这消除了应用程序员了解cv::Vec3b类的任何知识。

在某些情况下,控制器还准备应用程序员提供的数据。 这是我们在setInputImage方法的情况下所做的,其中将与给定文件名相对应的图像加载到内存中。 该方法返回truefalse取决于加载操作是否成功(也可能引发异常来处理这种情况)。

最后,方法process是运行该算法的方法。 该方法不返回结果,必须调用另一个方法才能获得最新处理结果。

现在,要使用此控制器创建一个非常基本的基于对话框的应用,只需将ColorDetectController成员变量添加到对话框类(此处称为colordetect)。 如果是 MFC 对话框,则“打开”按钮将如下所示:

// Callback method of "Open" button.
void OnOpen()
{
    // MFC widget to select a file of type bmp or jpg
    CFileDialog dlg(TRUE, _T("*.bmp"), NULL,
     OFN_FILEMUSTEXIST|OFN_PATHMUSTEXIST|OFN_HIDEREADONLY,
     _T("image files (*.bmp; *.jpg) 
         |*.bmp;*.jpg|All Files (*.*)|*.*||"),NULL);

    dlg.m_ofn.lpstrTitle= _T("Open Image");

    // if a filename has been selected
    if (dlg.DoModal() == IDOK) {

      // get the path of the selected filename
      std::string filename= dlg.GetPathName();  

      // set and display the input image
 colordetect.setInputImage(filename);
      cv::imshow("Input Image",colordetect.getInputImage());
    }
}

第二个按钮执行该过程并显示结果:

// Callback method of "Process" button.
void OnProcess()
{
   // target color is hard-coded here
   colordetect.setTargetColor(130,190,230);

   // process the input image and display result
 colordetect.process();
   cv::imshow("Output Result",colordetect.getLastResult());
}

显然,一个更完整的应用将包括其他小部件,以允许用户设置算法参数。

另见

另请参见“使用模型视图控制器模式构建基于 GUI 的应用”的秘籍,该模式提供了由 GUI 控制的应用的更多扩展示例。

使用单例设计模式

单例是另一种流行的设计模式,用于促进对类实例的访问,并确保在程序执行期间仅存在该类的一个实例。 在此秘籍中,我们使用单例访问控制器对象。

准备

我们使用先前秘籍的ColorDetectController类。 为了获得单例类,将对其进行修改。

操作步骤

首先要做的是添加一个私有静态成员变量,该变量将保留对单个类实例的引用。 另外,为了禁止构造其他类实例,将构造器设为私有:

   class ColorDetectController {

     private:

      // pointer to the singleton
       static ColorDetectController *singleton; 

      ColorDetector *cdetect;

      // private constructor
      ColorDetectController() { 

        //setting up the application
        cdetect= new ColorDetector();
      }

此外,您还可以将副本构造器和operator=设为私有,以确保没有人可以创建单例唯一实例的副本。 当类的用户要求此类的实例时,将按需创建单例对象。 这可以使用静态方法完成,该方法会创建实例(如果尚不存在),然后返回指向该实例的指针:

     // Gets access to Singleton instance
     static ColorDetectController *getInstance() {

        // Creates the instance at first call
        if (singleton == 0)
         singleton= new ColorDetectController;

        return singleton;
     }

请注意,但是单例的此实现不是线程安全的。 因此,当并发​​线程需要访问单例实例时,不应使用它。

最后,由于单例实例是动态创建的,因此用户在不再需要它时必须将其删除。 同样,这是通过静态方法完成的:

     // Releases the singleton instance of this controller.
     static void destroy() {

        if (singleton != 0) {
           delete singleton;
           singleton= 0;
        }
     }

由于singleton是静态成员变量,因此必须在.cpp文件中定义。 这样做如下:

#include "colorDetectController.h"

ColorDetectController *ColorDetectController::singleton=0; 

工作原理

由于可以通过公共静态方法获取单例,因此所有包含单例类声明的类都可以访问单例对象。 这对于某些复杂 GUI 的几个小部件类可以访问的控制器对象特别有用。 无需前面的秘籍中的任何一个 GUI 类中的成员变量。 对话框类的两个回调方法将如下编写:

// Callback method of "Open" button.
void OnOpen()
{
    ...

    // if a filename has beed selected
    if (dlg.DoModal() == IDOK) {

      // get the path of the selected filename
      std::string filename= dlg.GetPathName(); 

      // set and display the input image
 ColorDetectController::
 getInstance()->setInputImage(filename);
       cv::imshow("Input Image",
 ColorDetectController::
 getInstance()->getInputImage());
    }
}

// Callback method of "Process" button.
OnProcess()
{
    // target color is hard-coded here
 ColorDetectController::
 getInstance()->setTargetColor(130,190,230);

    // process the input image and display result
 ColorDetectController::getInstance()->process();
   cv::imshow("Output Result",
 ColorDetectController::getInstance()->getLastResult());
}

当应用关闭时,必须释放单例实例:

// Callback method of "Close" button.
void OnClose()
{
   // Releases the Singleton.
 ColorDetectController::getInstance()->destroy();
   OnOK();
}

如此处所示,将控制器封装在单例内时,从任何类获取对此实例的访问变得更加容易。 但是,此应用的更严格实现将需要更精细的 GUI。 这在下一个秘籍中完成,该秘籍通过介绍模型-视图-控制器架构,总结了在应用设计中使用模式的讨论。

使用模型-视图-控制器架构设计应用

前面的秘籍使您可以发现三种重要的设计模式:策略,控制器和单例模式。 本秘籍介绍了一种架构模式,其中将这三种模式与其他类结合使用。 正是模型视图控制器MVC 的目的是产生一个将应用逻辑与用户界面清楚地分开的应用。 在本秘籍中,我们将使用 MVC 模式使用 Qt 构建基于 GUI 的应用。 但是,在实际操作之前,我们先简要介绍一下该模式。

准备

顾名思义,MVC 模式包含三个主要组件。 现在,我们将看看它们各自的作用。

模型包含有关应用的信息。 它保存了应用处理的所有数据。 产生新数据时,它将通知控制器,控制器随后将要求视图显示新结果。 通常,模型会将几种算法组合在一起,可能按照策略模式实现。 所有这些算法都是模型的一部分。

视图对应于用户界面。 它由不同的小部件组成,这些小部件将数据呈现给用户并允许用户与应用进行交互。 它的作用之一是将用户发出的命令发送到控制器。 当有新数据可用时,它会刷新以显示新信息。

控制器是将视图和模型桥接在一起的模块。 它从视图接收请求,并将请求中继到模型中的适当方法。 当模型更改其状态时,也会通知它,因此要求刷新视图以显示此新信息。

操作步骤

与前面的秘籍一样,我们将使用ColorDetector类。 这将是我们的模型,其中包含应用逻辑和基础数据。 我们还实现了一个控制器,它是ColorDetectController类。 然后,通过选择最合适的窗口小部件,可以轻松构建更复杂的 GUI。 例如,使用 Qt,可以构建以下接口:

How to do it...

打开图像按钮用于选择和打开图像。 可以通过按选择颜色按钮选择要检测的颜色。 这将打开一个颜色选择器小部件(下面以黑白打印),可轻松选择所需的颜色:

How to do it...

然后使用滑块选择要使用的正确阈值。 然后,通过按处理按钮,处理图像并显示结果。

How to do it...

工作原理

在 MVC 架构下,用户界面仅调用控制器方法。 它不包含任何应用数据,也不实现任何应用逻辑。 因此,很容易用另一个接口替换一个接口。 在这里,添加了颜色选择器小部件QColorDialog,一旦选择了颜色,就会从选择颜色插槽中调用适当的控制器方法:

QColor color = QColorDialog::getColor(Qt::green, this);
if (color.isValid()) {
ColorDetectController::getInstance()
 ->setTargetColor(color.red(),color.green(),color.blue());
}

通过QSlider小部件设置阈值。 当单击处理按钮时,将读取此值,这还将触发处理并显示结果:

ColorDetectController::getInstance()
 ->setColorDistanceThreshold(
 ui->verticalSlider_Threshold->value());
ColorDetectController::getInstance()->process();
cv::Mat resulting = 
 ColorDetectController::getInstance()->getLastResult();
if (!resulting.empty())
   displayMat(resulting);

实际上,Qt 的 GUI 库大量使用了 MVC 模式。 它使用信号概念的概念,以使 GUI 的所有小部件与数据模型保持同步。

另见

Qt 在线文档可以帮助您了解有关 MVC 模式的 Qt 实现的更多信息

第 1 章的“使用 Qt 创建 GUI 应用”秘籍,以简要介绍 Qt GUI 框架及其信号和插槽模型。

转换色彩空间

本章教您如何将算法封装到类中。 这样,通过简化的接口,该算法变得更易于使用。 封装还允许您修改算法的实现,而不会影响使用该算法的类。 在此秘籍中说明了此原理,在此秘籍中,我们将修改ColorDetector类算法以使用其他颜色空间。 因此,此秘籍将是引入 OpenCV 颜色转换的机会。

准备

RGB 颜色空间(或 BGR,取决于存储颜色的顺序)基于红色,绿色和蓝色加法原色的使用。 之所以选择这些是因为将它们组合在一起可以产生各种颜色的色域。 实际上,人类视觉系统还基于三色感知的颜色,视锥细胞敏感度位于红色,绿色和蓝色光谱附近。 它通常是数字图像中的默认色彩空间,因为这是获取色彩的方式。 捕获的光通过红色,绿色和蓝色过滤器。 另外,在数字图像中,调节红色,绿色和蓝色通道,使得当以等量组合时,获得灰度级强度,即从黑色(0,0,0)到白色(255,255,255)

不幸的是,使用 RGB 颜色空间计算颜色之间的距离并不是衡量两种给定颜色相似度的最佳方法。 确实,RGB 不是在感知上均匀的色彩空间。 这意味着给定距离处的两种颜色可能看起来非常相似,而相隔相同距离的其他两种颜色看起来会非常不同。

为了解决该问题,已经引入了具有感知上均匀的特性的其他色彩空间。 特别地,CIE Lab 是一种这样的色彩空间。 通过将我们的图像转换到该空间,图像像素和目标颜色之间的欧几里得距离将有意义地成为两种颜色之间视觉相似性的度量。 我们将在此秘籍中展示如何修改先前的应用以与 CIE Lab 一起使用。

操作步骤

通过使用 OpenCV 函数cv::cvtColor可以轻松完成不同颜色空间之间的转换。 让我们在处理方法开始时将输入图像转换为 CIE Lab 颜色空间:

cv::Mat ColorDetector::process(const cv::Mat &image) {

     // re-allocate binary map if necessary
     // same size as input image, but 1-channel
     result.create(image.rows,image.cols,CV_8U);

     // re-allocate intermediate image if necessary
     converted.create(image.rows,image.cols,image.type());

     // Converting to Lab color space 
 cv::cvtColor(image, converted, CV_BGR2Lab);

     // get the iterators of the converted image 
     cv::Mat_<cv::Vec3b>::iterator it= 
                 converted.begin<cv::Vec3b>();
     cv::Mat_<cv::Vec3b>::iterator itend= 
                 converted.end<cv::Vec3b>();
     // get the iterator of the output image 
     cv::Mat_<uchar>::iterator itout= result.begin<uchar>();

     // for each pixel
     for ( ; it!= itend; ++it, ++itout) {
     ...

变量converted包含颜色转换后的图像。 在ColorDetector类中,将其定义为类属性:

class ColorDetector {

  private:
     // image containing color converted image
     cv::Mat converted;

我们还需要转换输入的目标颜色。 为此,我们创建了一个仅包含 1 个像素的临时图像。 请注意,您需要保持与先前秘籍相同的签名,即用户继续以 RGB 提供目标颜色:

     // Sets the color to be detected
     void setTargetColor(unsigned char red, 
           unsigned char green, unsigned char blue) {

         // Temporary 1-pixel image
         cv::Mat tmp(1,1,CV_8UC3);
         tmp.at<cv::Vec3b>(0,0)[0]= blue;
         tmp.at<cv::Vec3b>(0,0)[1]= green;
         tmp.at<cv::Vec3b>(0,0)[2]= red;

           // Converting the target to Lab color space 
         cv::cvtColor(tmp, tmp, CV_BGR2Lab);

         target= tmp.at<cv::Vec3b>(0,0);
     }

如果使用此修改后的类编译了先前秘籍的应用,则现在它将使用 CIE Lab 颜色空间检测目标颜色的像素。

工作原理

当图像从一种颜色空间转换为另一种颜色空间时,线性或非线性变换将应用于每个输入像素以产生输出像素。 输出图像的像素类型将与输入图像之一匹配。 即使大多数时候使用 8 位像素,也可以对浮点图像使用色彩转换(在这种情况下,通常假定像素值在01.0之间变化)或整数图像( 像素通常在065535之间变化)。 但是像素值的确切范围取决于特定的色彩空间。 例如,对于 CIE Lab 颜色空间,L通道在0100之间变化,而ab色度分量在-127127之间变化 。

可以使用最常用的色彩空间。 这只是为 OpenCV 函数提供正确的掩码的问题。 其中包括 YCrCb,它是 JPEG 压缩中使用的色彩空间。 为了从 BGR​​转换为 YCrCb,掩码应为CV_BGR2YCrCb。 请注意,具有三种常规原色(红色,绿色和蓝色)的表示形式按 RGB 顺序或 BRG 顺序可用。

HSV 和 HLS 颜色空间也很有趣,因为它们将颜色分解为其色相和饱和度分量以及值或亮度分量,这是人类描述颜色的一种更自然的方式。

您也可以将彩色图像转换为灰度图像。 输出将是一个 1 通道图像:

         cv::cvtColor(color, gray, CV_BGR2Gray);

也可以在另一个方向上进行转换,但是最终得到的彩色图像的 3 个通道将用灰度图像中的相应值完全填充。

另见

第 4 章中“使用平均移位算法找到对象”的秘籍,使用 HSV 颜色空间在图像中找到对象。

关于色彩空间理论,有许多很好的参考资料。 其中,以下是完整且最新的参考文献:E. Dubois,Morgan 和 Claypool 于 2009 年 10 月发表的《色彩空间的结构和特性以及彩色图像的表示》。

四、使用直方图计算像素

在本章中,我们将介绍:

  • 计算图像直方图
  • 应用查询表修改图像外观
  • 均衡图像直方图
  • 反投影直方图来检测特定图像内容
  • 使用均值平移算法查找对象
  • 使用直方图比较检索相似图像

简介

图像由具有不同值(颜色)的像素组成。 像素值在整个图像上的分布构成了此图像的重要特征。 本章介绍图像直方图的概念。 您将学习如何计算直方图以及如何使用它来修改图像的外观。 直方图还可以用于表征图像的内容并检测图像中的特定对象或纹理。 其中一些技巧将在本章中介绍。

计算图像直方图

图像由像素组成,每个像素具有不同的值。 例如,在 1 通道灰度图像中,每个像素的值介于 0(黑色)和 255(白色)之间。 根据图片内容,您会发现图像内部布置的每种灰色阴影的数量不同。

直方图是一个简单的表格,它给出了图像(或有时是一组图像)中具有给定值的像素数。 因此,灰度图像的直方图将具有 256 个条目(或箱子)。 箱子 0 给出值为 0 的像素数,箱子 1 给出值为 1 的像素数,依此类推。 显然,如果将直方图的所有条目相加,则应该获得像素总数。 直方图也可以归一化,以使箱子的总和等于 1。在这种情况下,每个箱子给出图像中具有该特定值的像素的百分比。

入门

定义一个简单的控制台项目,并准备使用如下图像:

Getting started

操作步骤

使用cv::calcHist函数可以很容易地用 OpenCV 计算直方图。 这是一个通用函数,可以计算任何像素值类型的多通道图像的直方图。 通过专门针对 1 通道灰度图像的类,让它更易于使用:

class Histogram1D {

  private:

    int histSize[1];  // number of bins
    float hranges[2]; // min and max pixel value
    const float* ranges[1];
    int channels[1];  // only 1 channel used here

  public:

   Histogram1D() {

      // Prepare arguments for 1D histogram
      histSize[0]= 256;
      hranges[0]= 0.0;
      hranges[1]= 255.0;
      ranges[0]= hranges; 
      channels[0]= 0; // by default, we look at channel 0
   }

使用定义的成员变量,可以使用以下方法来完成灰度直方图的计算:

   // Computes the 1D histogram.
   cv::MatND getHistogram(const cv::Mat &image) {

      cv::MatND hist;

      // Compute histogram
 cv::calcHist(&image, 
         1,           // histogram from 1 image only
         channels,  // the channel used
         cv::Mat(), // no mask is used
         hist,        // the resulting histogram
         1,           // it is a 1D histogram
         histSize,  // number of bins
         ranges     // pixel value range
      );

      return hist;
   }

现在,您的程序只需要打开一个图像,创建一个Histogram1D实例,然后调用getHistogram方法:

   // Read input image
   cv::Mat image= cv::imread("../group.jpg",
                             0); // open in b&w

   // The histogram object
   Histogram1D h;

   // Compute the histogram
 cv::MatND histo= h.getHistogram(image);

这里的histo对象是具有 256 个条目的简单一维数组。 因此,您可以通过简单地遍历此数组来读取每个箱子:

   // Loop over each bin
   for (int i=0; i<256; i++) 
      cout << "Value " << i << " = " << 
 histo.at<float>(i) << endl; 

在本章开头显示的图像中,某些显示的值将显示为:

...
Value 7 = 159
Value 8 = 208
Value 9 = 271
Value 10 = 288
Value 11 = 340
Value 12 = 418
Value 13 = 432
Value 14 = 472
Value 15 = 525
...

从这一系列值中提取任何直观的含义显然很困难。 因此,通常可以方便地将直方图显示为函数,例如使用条形图。 下面的方法创建这样的图:

   // Computes the 1D histogram and returns an image of it.
   cv::Mat getHistogramImage(const cv::Mat &image){

      // Compute histogram first
      cv::MatND hist= getHistogram(image);

      // Get min and max bin values
      double maxVal=0;
      double minVal=0;
      cv::minMaxLoc(hist, &minVal, &maxVal, 0, 0);

      // Image on which to display histogram
      cv::Mat histImg(histSize[0], histSize[0], 
                      CV_8U,cv::Scalar(255));

      // set highest point at 90% of nbins
      int hpt = static_cast<int>(0.9*histSize[0]);

      // Draw a vertical line for each bin 
      for( int h = 0; h < histSize[0]; h++ ) {

         float binVal = hist.at<float>(h);
         int intensity = static_cast<int>(binVal*hpt/maxVal);

         // This function draws a line between 2 points 
         cv::line(histImg,cv::Point(h,histSize[0]),
                          cv::Point(h,histSize[0]-intensity),
                          cv::Scalar::all(0));
      }

      return histImg;
   }

使用此方法,您可以获得以线条绘制的条形图形式的直方图特征图像:

   // Display a histogram as an image
   cv::namedWindow("Histogram");
   cv::imshow("Histogram",
 h.getHistogramImage(image));

结果如下图:

How to do it...

从该直方图可以看出,图像显示出中等灰度级值的大峰值和大量的较暗像素。 这两组主要分别对应于图像的背景和前景。 这可以通过在这两个组之间的过渡处对图像进行阈值化来验证。 为此可以使用方便的 OpenCV 函数,即cv::threshold函数 。 这是必须在图像上应用阈值以创建二进制图像时使用的函数。 在这里,我们将图像的阈值限制在直方图的高峰值(灰度值 60)增加之前的最小值:

   cv::Mat thresholded;
   cv::threshold(image,thresholded,60,255,cv::THRESH_BINARY);

生成的二进制图像清楚地显示了背景/前景分割:

How to do it...

工作原理

函数cv::calcHist具有许多参数,可以在多种情况下使用。 大多数情况下,直方图将是单个 1 通道或 3 通道图像之一。 但是,该函数允许您指定分布在多个图像上的多通道图像。 这就是为什么要将图像数组输入此函数的原因。 第 6 个参数指定直方图的维数,例如,对于 1D 直方图为 1。 在具有指定维数的数组中列出要在直方图计算中考虑的通道。 在我们的类实现中,该单个通道默认为通道 0(第三个参数)。 直方图本身由每个维度中的仓数(第七个参数,整数数组)和每个维度中的最小值和最大值(第八个参数,2 元素数组组成的数组)描述。 也可以定义不均匀的直方图,在这种情况下,您需要指定每个箱子的限制。

对于许多 OpenCV 函数,可以指定一个掩码,指示要在计数中包括哪些像素(然后忽略掩码值为 0 的所有像素)。 可以指定两个附加的可选参数,它们都是布尔值。 第一个指示直方图是否均匀(默认为均匀)。 第二个选项使您可以累积多个直方图计算的结果。 如果最后一个参数为true,则图像的像素数将添加到在输入直方图中找到的当前值。 当一个人想要计算一组图像的直方图时,这很有用。

生成的直方图存储在cv::MatND实例中。 这是用于处理 N 维矩阵的通用类。 方便地,此类为尺寸为 1、2 和 3 的矩阵定义了at方法。这就是为什么我们能够这样写:

         float binVal = hist.at<float>(h);

getHistogramImage方法中访问 1D 直方图的每个箱子时。 注意,直方图中的值存储为float

更多

本秘籍中介绍的类别Histogram1D通过将cv::calcHist函数限制为一维直方图来简化了函数。 这对于灰度图像很有用。 类似地,我们可以定义一个可用于计算彩色 BGR 图像直方图的类:

class ColorHistogram {

  private:

    int histSize[3];
    float hranges[2];
    const float* ranges[3];
    int channels[3];

  public:

   ColorHistogram() {

      // Prepare arguments for a color histogram
      histSize[0]= histSize[1]= histSize[2]= 256;
      hranges[0]= 0.0;    // BRG range
      hranges[1]= 255.0;
      ranges[0]= hranges; // all channels have the same range 
      ranges[1]= hranges; 
      ranges[2]= hranges; 
      channels[0]= 0;      // the three channels 
      channels[1]= 1; 
      channels[2]= 2; 
   }

在这种情况下,直方图将是三维的。 因此,我们需要为三个维度中的每个维度指定一个范围。 对于 BGR 图像,这三个通道具有相同的[0,255]范围。 在准备好参数之后,通过以下方法计算出颜色直方图 :

   cv::MatND getHistogram(const cv::Mat &image) {

      cv::MatND hist;

      // Compute histogram
      cv::calcHist(&image, 
         1,            // histogram of 1 image only
         channels,   // the channel used
         cv::Mat(),   // no mask is used
         hist,         // the resulting histogram
         3,            // it is a 3D histogram
         histSize,   // number of bins
         ranges      // pixel value range
      );

      return hist;
   }

返回一个三维cv::Mat实例。 该矩阵具有256 * 3个元素,表示超过 1600 万个条目。 在许多应用中,最好在计算出如此大的直方图之前减少颜色的数量(请参阅第 2 章)。 或者,您也可以使用cv::SparseMat数据结构,该数据结构用于表示大型稀疏矩阵(即,非零元素很少的矩阵),而不会占用太多内存。 cv::calcHist函数具有返回一个这样的矩阵的版本。 因此,很容易修改先前的方法以使用cv::SparseMatrix

   cv::SparseMat getSparseHistogram(const cv::Mat &image) {

      cv::SparseMat hist(3,histSize,CV_32F);

      // Compute histogram
      cv::calcHist(&image, 
         1,            // histogram of 1 image only
         channels,   // the channel used
         cv::Mat(),   // no mask is used
         hist,         // the resulting histogram
         3,            // it is a 3D histogram
         histSize,   // number of bins
         ranges      // pixel value range
      );

      return hist;
   }

另见

本章稍后的秘籍“反投影直方图以检测特定的图像内容”,该方法将使用颜色直方图来检测特定的图像内容。

应用查询表修改图像外观

图像直方图使用可用的像素强度值捕获渲染场景的方式。 通过分析图像上像素值的分布,可以使用此信息来修改并可能改善图像。 本秘籍说明了如何使用由查找表表示的简单映射函数来修改图像的像素值。

操作步骤

查找表是简单的一对一(或多对一)函数,用于定义如何将像素值转换为新值。 对于常规灰度图像,它是一维数组,具有 256 个条目。 该表的条目i给出了相应灰度的新强度值,即:

         newIntensity= lookup[oldIntensity];

OpenCV 中的函数cv::LUT将查找表应用于图像,以生成新图像。 我们可以将此函数添加到我们的Histogram1D类中:

   cv::Mat applyLookUp(const cv::Mat& image, // input image
      const cv::Mat& lookup) { // 1x256 uchar matrix

      // the output image
      cv::Mat result;

      // apply lookup table
      cv::LUT(image,lookup,result);

      return result;
   }

工作原理

当将查找表应用于图像时,会生成新图像,其中像素强度值已按照查找表的规定进行了修改。 这样的简单转换如下:

   // Create an image inversion table
   int dim(256);
   cv::Mat lut(1,  // 1 dimension
      &dim,       // 256 entries
      CV_8U);     // uchar

   for (int i=0; i<256; i++) {

      lut.at<uchar>(i)= 255-i;
   }

此转换仅使像素强度反转,即强度 0 变为 255,强度 1 变为 254,依此类推。 在图像上应用这样的查找表将产生原始图像的底片。 在上一个秘籍的图像上,在这里可以看到结果:

How it works...

更多

您还可以定义一个查找表,以尝试改善图像的对比度。 例如,如果您观察到第一个秘籍中显示的上一张图像的原始直方图,则很容易注意到未使用整个范围的可能的强度值(特别是对于此图像,在图片中未使用较亮的强度值)。 因此,可以拉伸直方图,以产生具有扩大对比度的图像。 该程序旨在检测图像直方图中计数为非零的最低(imin)和最高(imax)强度值。 然后可以重新映射强度值,以使imin值重新定位为强度 0,并且为imax赋值 255。强度i之间的线性映射简单如下:

255.0*(i-imin)/(imax-imin)+0.5);

因此,完整图像拉伸方法将如下所示:

   cv::Mat stretch(const cv::Mat &image, int minValue=0) {

      // Compute histogram first
      cv::MatND hist= getHistogram(image);

      // find left extremity of the histogram
      int imin= 0;
      for( ; imin < histSize[0]; imin++ ) {
         std::cout<<hist.at<float>(imin)<<std::endl;
         if (hist.at<float>(imin) > minValue)
            break;
      }

      // find right extremity of the histogram
      int imax= histSize[0]-1;
      for( ; imax >= 0; imax-- ) {

         if (hist.at<float>(imax) > minValue)
            break;
      }

      // Create lookup table
      int dim(256);
      cv::Mat lookup(1,  // 1 dimension
            &dim,       // 256 entries
            CV_8U);     // uchar

      // Build lookup table
      for (int i=0; i<256; i++) {

         // stretch between imin and imax
         if (i < imin) lookup.at<uchar>(i)= 0;
         else if (i > imax) lookup.at<uchar>(i)= 255;
         // linear mapping
         else lookup.at<uchar>(i)= static_cast<uchar>(
                        255.0*(i-imin)/(imax-imin)+0.5);
      }

      // Apply lookup table
      cv::Mat result;
 result= applyLookUp(image,lookup);

      return result;
   }

一旦计算出该方法,请注意对我们的applyLookUp方法的调用。 同样,在实践中,不仅忽略具有 0 值的箱子,而且忽略计数。 例如,小于给定值(在此定义为minValue)的条目也可能是有利的。 该方法称为:

// ignore starting and ending bins with less than 100 pixels   
cv::Mat streteched= h.stretch(image,100);

然后在这里看到生成的图像:

There's more...

如以下屏幕快照所示,具有以下扩展的直方图:

There's more...

另见

“均衡图像直方图”秘籍为您提供了另一种改善图像对比度的方法。

均衡图像直方图

在先前的秘籍中,我们展示了如何通过拉伸直方图来改善图像的对比度,使其直达所有可用强度值范围。 这种策略确实构成了可以有效改善图像的简单解决方案。 但是,在许多情况下,图像的视觉缺陷并不是其使用的强度范围太窄。 而是某些强度值比其他强度值使用得更频繁。 本章第一章中显示的直方图就是这种现象的一个很好的例子。 确实可以很好地表现出中灰色强度,而较暗和较亮的像素值却很少。 实际上,人们可以认为,高质量的图像应该平等地利用所有可用的像素强度。 这是直方图均衡概念的思想,即使图像直方图尽可能平坦。

操作步骤

OpenCV 提供了易于使用的函数,可以执行直方图均衡化。 可以这样调用:

   cv::Mat equalize(const cv::Mat &image) {

      cv::Mat result;
 cv::equalizeHist(image,result);

      return result;
   }

将以下屏幕截图应用于我们的图像:

How to do it...

该图像具有以下直方图:

How to do it...

当然,直方图不能完全平坦,因为查询表是全局的多对一转换。 但是,可以看出,直方图的总体分布现在比原始分布更均匀。

工作原理

在完全一致的直方图中,所有面元都有相同数量的像素。 这意味着 50% 的像素强度低于 128,25% 的像素强度低于 64,依此类推。 可以使用以下规则来表达该观察结果:在均匀的直方图中,像素的p%的强度值必须小于或等于255 * p%。 这是用于均衡直方图的规则:强度i的映射应处于与强度值低于i的像素百分比相对应的强度。 因此,可以根据以下公式构建所需的查询表:

lookup.at<uchar>(i)= static_cast<uchar>(255.0*p[i]);

其中p[i]是强度低于或等于i的像素数。 函数p[i]通常称为累积直方图,即它是一个直方图,其中包含小于或等于给定强度的像素数,而不包含具有特定强度值的像素。

通常,直方图均衡化可以大大改善图像的外观。 但是,视视觉内容而定,结果的质量可能因图像而异。

反投影直方图来检测特定图像内容

直方图是图像内容的重要特征。 如果查看显示特定纹理或特定对象的图像区域,则该区域的直方图可以看作是一个函数,给出给定像素属于该特定纹理或对象的概率。 在本秘籍中,您将学习如何将图像直方图有利地用于检测特定图像内容。

操作步骤

假设您有一张图片,并且希望检测其中的特定内容(例如,在下面的屏幕快照中,是天空中的云彩)。 首先要做的是选择一个兴趣区域,其中包含您要寻找的样本。 此区域是在以下测试屏幕截图上绘制的矩形内的区域:

How to do it...

在我们的程序中,兴趣区域的获取如下:

   cv::Mat imageROI;
   imageROI= image(cv::Rect(360,55,40,50)); // Cloud region

然后,您提取此 ROI 的直方图。 使用本章第一部分中定义的Histogram1D类可以轻松完成此操作:

   Histogram1D h;
   cv::MatND hist= h.getHistogram(imageROI);

通过对该直方图进行归一化,我们获得一个函数,该函数给出给定强度值的像素属于定义区域的概率:

   cv::normalize(histogram,histogram,1.0);

对直方图进行反投影包括将输入图像中的每个像素值替换为在归一化的直方图中读取的相应像素值。

cv::calcBackProject(&image,
      1,            // one image
      channels,     // the channels used
      histogram,    // the histogram we are backprojecting
      result,       // the resulting back projection image
      ranges,       // the range of values, for each dimension
      255.0         // a scaling factor
);

结果是以下概率图,具有从亮(低概率)到暗(高概率)的参考区域概率:

How to do it...

如果在此图像上应用阈值,我们将获得最可能的“云”像素:

cv::threshold(result, result, 255*threshold, 
                      255, cv::THRESH_BINARY);

How to do it...

工作原理

前面的结果可能令人失望,因为除了云之外,还错误地检测了其他区域。 重要的是要了解,概率函数是从简单的灰度直方图中提取的。 图像中的许多其他像素与云像素共享相同的强度,并且在反投影直方图时,相同强度的像素将被相同的概率值替换。 改善检测结果的一种解决方案是使用颜色信息。 但是为此,我们需要修改对cv::calBackProject的调用。

函数cv::calBackProjectcv::calcHist函数相似。 第一个参数指定输入图像。 然后,您需要列出要使用的通道号。 这次传递给函数的直方图是一个输入参数。 应该对其进行规范化,并且其尺寸应与通道列表数组之一以及ranges参数之一匹配。 如cv::calcHist中所述,这是一个float数组,每个float数组指定每个通道的范围(最小和最大值)。 结果输出是图像,即计算的概率图。 由于每个像素都被在对应的箱子位置处的直方图中找到的值替换,因此所得图像的值介于 0.0 和 1.0 之间(假定已提供标准化的直方图作为输入)。 最后一个参数允许您选择将这些值乘以给定因子来重新缩放这些值。

更多

现在让我们看看如何在直方图反投影算法中使用颜色信息。 我们首先定义一个封装反向投影过程的类。 首先,我们定义所需的属性并初始化数据:

class ContentFinder {

  private:

   float hranges[2];
   const float* ranges[3];
   int channels[3];

   float threshold;
   cv::MatND histogram;

  public:

   ContentFinder() : threshold(-1.0f) {

      ranges[0]= hranges; // all channels have same range 
      ranges[1]= hranges; 
      ranges[2]= hranges; 
   }

接下来,我们定义一个阈值参数,该参数将用于创建显示检测结果的二进制图。 如果此参数设置为负值,则将返回原始概率图:

   // Sets the threshold on histogram values [0,1]
   void setThreshold(float t) {

      threshold= t;
   }

   // Gets the threshold
   float getThreshold() {

      return threshold;
   }

输入直方图必须归一化:

   // Sets the reference histogram
   void setHistogram(const cv::MatND& h) {

      histogram= h;
      cv::normalize(histogram,histogram,1.0);
   }

要对直方图进行背投,您只需指定图像,范围(此处假设所有通道都具有相同的范围)以及使用的通道列表:

   cv::Mat find(const cv::Mat& image, 
                float minValue, float maxValue, 
                int *channels, int dim) {

      cv::Mat result;

      hranges[0]= minValue;
      hranges[1]= maxValue;

      for (int i=0; i<dim; i++)
         this->channels[i]= channels[i];

       cv::calcBackProject(&image, 1, // input image
             channels,       // list of channels used
             histogram,    // the histogram we are using
             result,       // the resulting backprojection
             ranges,       // the range of values
             255.0         // the scaling factor
       );
      }

      // Threshold back projection to obtain a binary image
      if (threshold>0.0)
           cv::threshold(result, result, 
                   255*threshold, 255, cv::THRESH_BINARY);

      return result;
   }

现在让我们在上面使用的图像的彩色版本上使用 BGR 直方图。 这次,我们将尝试检测蓝天区域。 我们将首先加载彩色图像,使用第 2 章的色彩缩减函数减少颜色数量,然后定义关注区域:

   ColorHistogram hc;
   // load color image
   cv::Mat color= cv::imread("../waves.jpg");
   // reduce colors
   color= hc.colorReduce(color,32);
   // blue sky area
   cv::Mat imageROI= color(cv::Rect(0,0,165,75)); 

接下来,您计算直方图并使用find方法检测图像的天空部分:

   cv::MatND hist= hc.getHistogram(imageROI);

   ContentFinder finder;
   finder.setHistogram(hist);
   finder.setThreshold(0.05f);

   // Get back-projection of color histogram
   Cv::Mat result= finder.find(color);

上一部分的图像彩色版本的检测结果在此处显示:

There's more...

另见

下一个秘籍将使用 HSV 颜色空间来检测图像中的对象。 这是可用于检测某些图像内容的许多替代解决方案中的另一个。

使用均值移动算法查找对象

直方图反投影的结果是一个概率图,该概率图表示在特定图像位置找到给定图像内容的概率。 假设我们现在知道图像中某个对象的大概位置,则可以使用概率图找到该对象的确切位置。 最有可能的是在给定窗口内最大化此概率的那个。 因此,如果我们从一个初始位置开始并反复移动,那么应该可以找到确切的对象位置。 这是通过均值平移算法完成的。

操作步骤

假设我们已经确定了一个感兴趣的对象,这里是狒狒的脸,如下面的彩色屏幕截图所示(请参见本书的网站以查看此彩色图片):

How to do it...

这次,我们将通过使用 HSV 颜色空间的色相通道来描述此对象。 这意味着我们需要将图像转换为 HSV 图像,然后提取色调通道并计算已定义 ROI 的 1D 色调直方图:

   // Read reference image
   cv::Mat image= cv::imread("../baboon1.jpg");
   // Baboon's face ROI
   cv::Mat imageROI= image(cv::Rect(110,260,35,40));
   // Get the Hue histogram
   int minSat=65;
   ColorHistogram hc;
   cv::MatND colorhist= 
            hc.getHueHistogram(imageROI,minSat);

可以看出,色调直方图是使用我们添加到ColorHistogram类中的便捷方法获得的:

   // Computes the 1D Hue histogram with a mask.
   // BGR source image is converted to HSV
   // Pixels with low saturation are ignored
   cv::MatND getHueHistogram(const cv::Mat &image, 
                             int minSaturation=0) {

      cv::MatND hist;

      // Convert to HSV color space
      cv::Mat hsv;
      cv::cvtColor(image, hsv, CV_BGR2HSV);

      // Mask to be used (or not)
      cv::Mat mask;

      if (minSaturation>0) {

         // Spliting the 3 channels into 3 images
         std::vector<cv::Mat> v;
         cv::split(hsv,v);

         // Mask out the low saturated pixels
         cv::threshold(v[1],mask,minSaturation,255,
                                 cv::THRESH_BINARY);
      }

      // Prepare arguments for a 1D hue histogram
      hranges[0]= 0.0;
      hranges[1]= 180.0;
      channels[0]= 0; // the hue channel 

      // Compute histogram
      cv::calcHist(&hsv, 
         1,          // histogram of 1 image only
         channels,   // the channel used
         mask,       // binary mask
         hist,       // the resulting histogram
         1,          // it is a 1D histogram
         histSize,   // number of bins
         ranges      // pixel value range
      );

      return hist;
   }

然后将生成的直方图输入到我们的ContentFinder类实例中:

   ContentFinder finder;
   finder.setHistogram(colorhist);

现在让我们打开第二个图像,我们要在其中定位新狒狒的脸部位置。 该图像需要转换为 HSV 空间:

   image= cv::imread("../baboon3.jpg");

   // Display image
   cv::namedWindow("Image 2");
   cv::imshow("Image 2",image);

   // Convert to HSV space
   cv::cvtColor(image, hsv, CV_BGR2HSV);

   // Split the image
   cv::split(hsv,v);

   // Identify pixels with low saturation
   cv::threshold(v[1],v[1],minSat,255,cv::THRESH_BINARY);

接下来,让我们使用先前获得的直方图获得该图像的色相通道的反投影:

   // Get back-projection of hue histogram
   result= finder.find(hsv,0.0f,180.0f,ch,1);
   // Eliminate low stauration pixels
   cv::bitwise_and(result,v[1],result);

现在,从初始矩形区域(即原始图像中狒狒脸的位置)开始,OpenCV 的cv::meanShift算法将在新的狒狒脸部位置更新rect对象:

   cv::Rect rect(110,260,35,40);
   cv::rectangle(image, rect, cv::Scalar(0,0,255));

   cv::TermCriteria criteria(cv::TermCriteria::MAX_ITER,
                             10,0.01);
   cv::meanShift(result,rect,criteria);

初始和新面部位置显示在以下屏幕截图中:

How to do it...

工作原理

在此示例中,我们使用了 HSV 颜色空间的色相成分来表征我们要寻找的对象。 因此,必须先转换图像。 当使用CV_BGR2HSV标志时,色相分量是所得图像的第一通道。 这是一个 8 位分量,其中色相从 0 到 180 变化(使用cv::cvtColor时,转换后的图像与源图像的类型相同)。 为了提取色调图像,使用cv::split函数将 3 通道 HSV 图像分为三个 1 通道图像。 将这三个图像放入std::vector实例,并且色调图像是向量的第一项(即索引 0)。

使用颜色的色相分量时,考虑其饱和度(这是向量的第二项)总是很重要的。 实际上,当颜色的饱和度低时,色相信息变得不稳定且不可靠。 这是由于以下事实:对于低饱和色,B,G 和 R 分量几乎相等。 这使得很难确定所代表的确切颜色。 因此,我们决定忽略具有低饱和度的颜色的色相成分。 也就是说,它们不计入直方图中(使用方法getHueHistogram使用参数minSat掩盖饱和度低于此阈值的像素),并且将它们从反投影结果中消除(使用cv::bitwise_and运算符,可在调用cv::meanShift之前消除所有具有低饱和度颜色的正检测像素。

均值平移算法是定位概率函数的局部最大值的迭代过程。 它通过找到预定义窗口内数据点的质心或加权均值来实现。 然后,算法将窗口中心移动到质心位置,并重复此过程,直到窗口中心收敛到稳定点为止。 OpenCV 实现定义了两个停止条件:最大迭代次数和窗口中心位移值,在该值以下,位置被认为已收敛到稳定点。 这两个条件存储在cv::TermCriteria实例中。 cv::meanShift函数返回执行的迭代次数。 显然,结果的质量取决于所提供的概率图的质量以及给定的初始位置。

另见

均值漂移算法已广泛用于视觉跟踪。 第 10 章将更详细地探讨对象跟踪问题。

OpenCV 还提供了 CamShift 算法的实现,该算法是均值偏移的改进版本,其中窗口的大小和方向可以更改。

使用直方图比较检索相似的图像

基于内容的图像检索是计算机视觉中的重要问题。 它包括查找一组呈现类似于给定查询图像的内容的图像。 由于我们已经知道直方图是表征图像内容的有效方法,因此有理由认为直方图可用于解决基于内容的检索问题。

这里的关键是能够通过简单地比较两个图像的直方图来测量两个图像之间的相似度。 需要定义一个测量函数,该函数将估计两个直方图之间的差异或相似程度。 过去已经提出了各种这样的措施,并且 OpenCV 在cv::compareHist函数的实现中提出了很少的措施。

操作步骤

为了将参考图像与图像集合进行比较并找到与该查询图像最相似的图像,我们创建了ImageComparator类。 这包含对查询图像和输入图像的引用,以及它们的直方图(cv::MatND实例)。 另外,由于我们将使用颜色直方图进行比较,因此使用了ColorHistogram类:

class ImageComparator {

  private:

   cv::Mat reference;
   cv::Mat input;
   cv::MatND refH;
   cv::MatND inputH;

   ColorHistogram hist;
   int div;

  public:

   ImageComparator() : div(32) {

   }

为了获得可靠的相似性度量,必须减少颜色数量。 因此,该类包括一个颜色减少因子,该因子将应用于查询和输入图像:

   // Color reduction factor
   // The comparison will be made on images with
   // color space reduced by this factor in each dimension
   void setColorReduction( int factor) {

      div= factor;
   }

   int getColorReduction() {

      return div;
   }

使用适当的设置器指定查询图像,该设置器还对图像进行颜色还原:

   void setReferenceImage(const cv::Mat& image) {

      reference= hist.colorReduce(image,div);
      refH= hist.getHistogram(reference);
   }

最后,compare方法将参考图像与给定的输入图像进行比较。 该方法返回一个分数,指示两个图像的相似程度。

   double compare(const cv::Mat& image) {

      input= hist.colorReduce(image,div);
      inputH= hist.getHistogram(input);

      return cv::compareHist(
                     refH,inputH,CV_COMP_INTERSECT);
   }
};

此类可用于检索类似于给定查询图像的图像。 后者最初提供给类实例:

   ImageComparator c;
   c.setReferenceImage(image);

在这里,我们使用的查询图像是本章前面的秘籍“将直方图反投影来检测特定图像内容”中显示的海滩图像的彩色版本。 将该图像与以下所示的一系列图像进行了比较。 图像从最相似到最小显示:

How to do it...

工作原理

大多数直方图比较措施都基于逐个箱比较,即在比较直方图的箱时不使用相邻箱。 因此,重要的是在测量两个颜色直方图的相似度之前减小颜色空间。 也可以使用其他色彩空间。

cv::compareHist的调用非常简单。 您只需输入两个直方图,函数就会返回测得的距离。 使用标志指定要使用的特定测量方法。 在ImageComparator类中,使用相交方法(带有标志CV_COMP_INTERSECT)。 此方法仅针对每个箱子比较每个直方图中的两个值,并保持最小值。 那么,相似性度量只是这些最小值的总和。 因此,具有没有共同颜色的直方图的两个图像的相交值将为 0,而两个相同直方图的值将等于像素总数。

其他可用的方法是:对方块之间的归一化平方差求和的卡方(标志CV_COMP_CHISQR),基于信号中使用的归一化互相关运算符的相关方法(标志CV_COMP_CORREL) 处理以测量两个信号之间的相似性,以及统计中使用的 Bhattacharyya 度量(标志CV_COMP_BHATTACHARYYA)来估计两个概率分布之间的相似性。

另见

OpenCV 文档描述了不同直方图比较度量中使用的确切公式。

地球移动距离,这也是另一种流行的直方图比较方法。 此方法的主要优点是,它考虑了在相邻箱中找到的值来评估两个直方图的相似性。 在 Y.i Rubner,C. Tomasi,L. 发表的文章《地球移动者的距离作为图像检索的度量》中进行了描述。

五、通过形态学运算转换图像

在本章中,我们将介绍:

  • 使用形态学过滤器腐蚀和膨胀图像
  • 使用形态过滤器开放和闭合图像
  • 使用形态过滤器检测边缘和角点
  • 使用分水岭分割图像
  • 用 GrabCut 算法提取前景对象

简介

形态滤波是 1960 年代开发的一种用于分析和处理离散图像的理论。 它定义了一系列运算符,这些运算符通过使用预定义的形状元素探测图像来变换图像。 该形状元素与像素邻域相交的方式决定了运算结果。 本章介绍最重要的形态运算符。 它还探讨了使用处理图像形态的算法进行图像分割的问题。

使用形态过滤器腐蚀和膨胀图像

侵蚀和膨胀是最基本的形态学操纵子。 因此,我们将在第一个秘籍中介绍它们。

数学形态学的基本工具是结构元素。 简单地将结构元素定义为在其上定义了原点的像素(形状)的配置(也称为定位点)。 应用形态学过滤器包括使用此结构元素探测图像的每个像素。 当结构元素的原点与给定像素对齐时,其与图像的交点定义了一组像素,在这些像素上应用了特定的形态学运算。 原则上,结构元素可以是任何形状,但是最常见的是,使用简单的形状,例如以原点为中心的正方形,圆形或菱形(主要是出于效率方面的考虑)。

准备

由于形态过滤器通常适用于二进制图像,因此我们将使用在上一章的第一个秘籍中通过阈值处理生成的二进制图像。 但是,由于在形态学上,惯例是使前景对象由高(白色)像素值表示,而背景由低(黑色)像素值表示,因此我们对图像进行了否定。 用形态学术语来说,以下图像是上一章中产生的图像的补充

Getting ready

操作步骤

侵蚀和膨胀在 OpenCV 中作为cv::erodecv::dilate的简单函数实现。 它们的用法很简单:

   // Read input image
   cv::Mat image= cv::imread("binary.bmp");

   // Erode the image
   cv::Mat eroded;  // the destination image
   cv::erode(image,eroded,cv::Mat());

   // Display the eroded image
   cv::namedWindow("Eroded Image");");
   cv::imshow("Eroded Image",eroded);

   // Dilate the image
   cv::Mat dilated;  // the destination image
   cv::dilate(image,dilated,cv::Mat());

   // Display the dilated image
   cv::namedWindow("Dilated Image");
   cv::imshow("Dilated Image",dilated);

在下面的屏幕快照中可以看到这些函数调用产生的两个图像。 首先显示侵蚀:

How to do it...

其次是膨胀结果:

How to do it...

工作原理

与所有其他形态过滤器一样,此秘籍的两个过滤器在每个像素周围的一组像素(或邻域)上运行,这由结构元素定义。 回想一下,当应用于给定像素时,结构化元素的锚点与此像素位置对齐,并且与结构化元素相交的所有像素都包含在当前集中。 侵蚀用定义的像素集中找到的最小像素值替换当前像素。 膨胀是互补运算符,它用定义的像素集中找到的最大像素值替换当前像素。 由于输入的二进制图像仅包含黑色(0)和白色(255)像素,因此每个像素都由白色或黑色像素替换。

描绘这两个运算符效果的一个好方法是根据背景(黑色)和前景(白色)对象进行思考。 对于腐蚀,如果结构化元素放置在给定像素位置时接触背景(即,相交集中的像素之一是黑色),则该像素将被发送到背景。 在散布的情况下,如果背景像素上的结构元素触摸前景对象,则将为该像素分配白色值。 这解释了为什么在侵蚀的图像中物体的尺寸减小了。 观察一些非常小的物体(可以视为“嘈杂的”背景像素)是如何被完全消除的。 类似地,膨胀的对象现在更大,并且其中的一些“孔”已被填充。

默认情况下,OpenCV 使用3x3正方形结构元素。 当在函数调用中将空矩阵( cv::Mat())指定为第三个参数时,将获得该默认结构元素,就像在上一个示例中所做的那样。 您还可以通过提供一个矩阵(其中非零元素定义结构元素)来指定所需大小(和形状)的结构元素。 在以下示例中,将应用7x7结构元素:

    cv::Mat element(7,7,CV_8U,cv::Scalar(1));
   cv::erode(image,eroded,element);

在这种情况下,效果显然更具破坏性,如下所示:

How it works...

获得相同结果的另一种方法是在图像上重复应用相同的结构元素。 这两个函数有一个可选参数来指定重复次数:

   // Erode the image 3 times.
   cv::erode(image,eroded,cv::Mat(),cv::Point(-1,-1),3);

原点参数cv::Point(-1,-1)表示原点位于矩阵的中心(默认值),可以在结构元素上的任何位置进行定义。 获得的图像将与我们使用7x7结构元素获得的图像相同。 确实,对图像进行两次腐蚀就好比对具有自身膨胀结构元素的图像进行腐蚀。 这也适用于扩张。

最后,由于背景/前景的概念是任意的,因此我们可以进行以下观察(这是侵蚀/膨胀运算符的基本属性)。 用结构元素腐蚀前景对象可以看作是图像背景部分的扩张。 或更正式地:

  • 图像的侵蚀等同于补充图像的膨胀的补充。
  • 图像的膨胀等效于补充图像的侵蚀的补充。

更多

重要的是要注意,即使我们在这里对二进制图像应用了形态过滤器,也可以将它们应用于具有相同定义的灰度图像。

另请注意,OpenCV 形态函数支持原地处理。 这意味着您可以将输入图像用作目标图像。 所以你可以这样写:

   cv::erode(image,image,cv::Mat());

OpenCV 为您创建所需的临时映像,以使其正常工作。

另见

下一个秘籍将级联应用腐蚀和膨胀过滤器以产生新的运算符。

使用形态学过滤器检测边缘和角落,以将形态学过滤器应用到灰度图像上。

使用形态过滤器开放和闭合图像

先前的秘籍介绍了两个基本的形态运算符:膨胀和侵蚀。 由此,可以定义其他运算符。 接下来的两个秘籍将介绍其中的一些。 此秘籍中介绍了开放和闭合运算符。

操作步骤

为了应用高级形态过滤器,需要将cv::morphologyEx函数与相应的函数代码一起使用。 例如,以下调用将应用结束运算符:

   cv::Mat element5(5,5,CV_8U,cv::Scalar(1));
   cv::Mat closed;
   cv::morphologyEx(image,closed,cv::MORPH_CLOSE,element5);

请注意,这里我们使用5x5的结构元素使过滤器的效果更加明显。 如果输入前面秘籍的二进制图像,则可获得:

How to do it...

同样,应用形态学打开运算符将得到以下图像:

How to do it...

这是从以下代码获得的:

   cv::Mat opened;
   cv::morphologyEx(image,opened,cv::MORPH_OPEN,element5);

工作原理

开放和闭合过滤器仅根据基本腐蚀和膨胀操作进行定义:

  • 闭合被定义为图像膨胀的腐蚀。
  • 开放被定义为图像腐蚀的膨胀。

因此,可以使用以下调用来计算图像的关闭:

   // dilate original image
   cv::dilate(image,result,cv::Mat()); 
   // in-place erosion of the dilated image
   cv::erode(result,result,cv::Mat()); 

通过反转这两个函数调用可以获得打开。

在检查关闭过滤器的结果时,可以看到白色前景对象的小孔已被填充。 过滤器还将几个相邻的对象连接在一起。 基本上,任何太小而不能完全容纳结构元素的孔或间隙都将被过滤器消除。

相反,打开过滤器消除了场景中的一些小物体。 所有太小而无法包含结构元素的元素均已删除。

这些过滤器通常用于对象检测。 关闭过滤器将错误地分成较小碎片的对象连接在一起,而打开过滤器则消除了由图像噪声引入的小斑点。 因此,顺序使用它们是有利的。 如果我们的测试二进制图像是连续关闭和打开的,则将获得仅显示场景中主要对象的图像,如下所示。 如果希望优先进行噪声过滤,也可以在关闭之前应用打开过滤器,但这会以消除一些碎片对象为代价。

How it works...

应该注意的是,对图像多次应用相同的打开(和类似的关闭)操作符没有任何效果。 实际上,在孔被第一开口填充的情况下,对该相同过滤器的附加应用将不会对图像产生任何其他变化。 用数学术语来说,这些运算符被认为是幂等的。

使用形态过滤器检测边缘和角点

形态过滤器也可以用于检测图像中的特定特征。 在本秘籍中,我们将学习如何检测灰度图像中的线和角。

入门

在此秘籍中,将使用以下图像:

Getting started

操作步骤

让我们定义一个名为MorphoFeatures的类,它将使我们能够检测图像特征:

class MorphoFeatures {

  private:

     // threshold to produce binary image
     int threshold;
     // structuring elements used in corner detection
     cv::Mat cross;
     cv::Mat diamond;
     cv::Mat square;
     cv::Mat x;

使用cv::morphologyEx函数的适当过滤器,检测线路非常容易:

cv::Mat getEdges(const cv::Mat &image) {

   // Get the gradient image
   cv::Mat result;
   cv::morphologyEx(image,result,
                         cv::MORPH_GRADIENT,cv::Mat());

   // Apply threshold to obtain a binary image
   applyThreshold(result);

   return result;
}

二进制边缘图像是通过该类的简单私有方法获得的:

void applyThreshold(cv::Mat& result) {

   // Apply threshold on result
   if (threshold>0)
      cv::threshold(result, result, 
                    threshold, 255, cv::THRESH_BINARY);
}

然后在主要函数中使用此类,然后按以下方式获取边缘图像:

// Create the morphological features instance
MorphoFeatures morpho;
morpho.setThreshold(40);

// Get the edges
cv::Mat edges;
edges= morpho.getEdges(image); 

结果如下图:

How to do it...

使用形态学角点检测角点有点复杂,因为它不是直接在 OpenCV 中实现的。 这是使用非正方形结构元素的一个很好的例子。 实际上,它需要定义四个不同的结构元素,形状分别为正方形,菱形,十字形和 X 形。 这是在构造器中完成的(为简单起见,所有这些结构化元素都具有固定的5x5尺寸):

MorphoFeatures() : threshold(-1), 
        cross(5,5,CV_8U,cv::Scalar(0)),
            diamond(5,5,CV_8U,cv::Scalar(1)), 
        square(5,5,CV_8U,cv::Scalar(1)),
        x(5,5,CV_8U,cv::Scalar(0)){

   // Creating the cross-shaped structuring element
   for (int i=0; i<5; i++) {

      cross.at<uchar>(2,i)= 1;
      cross.at<uchar>(i,2)= 1;                           
   }

   // Creating the diamond-shaped structuring element
   diamond.at<uchar>(0,0)= 0;
   diamond.at<uchar>(0,1)= 0;
   diamond.at<uchar>(1,0)= 0;
   diamond.at<uchar>(4,4)= 0;
   diamond.at<uchar>(3,4)= 0;
   diamond.at<uchar>(4,3)= 0;
   diamond.at<uchar>(4,0)= 0;
   diamond.at<uchar>(4,1)= 0;
   diamond.at<uchar>(3,0)= 0;
   diamond.at<uchar>(0,4)= 0;
   diamond.at<uchar>(0,3)= 0;
   diamond.at<uchar>(1,4)= 0;

   // Creating the x-shaped structuring element
   for (int i=0; i<5; i++) {

     x.at<uchar>(i,i)= 1;
     x.at<uchar>(4-i,i)= 1;                           
   }     
}

在检测角点特征时,所有这些结构元素都会级联应用以获得最终的角点贴图:

cv::Mat getCorners(const cv::Mat &image) {

   cv::Mat result;

   // Dilate with a cross   
   cv::dilate(image,result,cross);

   // Erode with a diamond
   cv::erode(result,result,diamond);

   cv::Mat result2;
   // Dilate with a X   
   cv::dilate(image,result2,x);

   // Erode with a square
   cv::erode(result2,result2,square);

   // Corners are obtained by differencing
   // the two closed images
   cv::absdiff(result2,result,result);

   // Apply threshold to obtain a binary image
   applyThreshold(result);

   return result;
}

为了更好地可视化检测结果,以下方法在二进制图上每个检测到的点上在图像上绘制一个圆:

void drawOnImage(const cv::Mat& binary, 
                   cv::Mat& image) {

   cv::Mat_<uchar>::const_iterator it= 
                        binary.begin<uchar>();
   cv::Mat_<uchar>::const_iterator itend= 
                        binary.end<uchar>();

   // for each pixel   
   for (int i=0; it!= itend; ++it,++i) {
      if (!*it)          
         cv::circle(image,
           cv::Point(i%image.step,i/image.step),
           5,cv::Scalar(255,0,0));
   }
}

然后,使用以下代码在图像上检测角点:

// Get the corners
cv::Mat corners;
corners= morpho.getCorners(image);

// Display the corner on the image
morpho.drawOnImage(corners,image);
cv::namedWindow("Corners on Image");
cv::imshow("Corners on Image",image);

然后,检测到的角的图像如下。

How to do it...

工作原理

帮助理解形态运算符对灰度图像的影响的一种好方法是将图像视为拓扑浮雕,其中灰度对应于海拔(或海拔)。 在这种情况下,明亮的区域对应于山脉,而较暗的区域则构成地形的山谷。 同样,由于边缘对应于较暗像素和较亮像素之间的快速过渡,因此可以将其描绘为陡峭的悬崖。 如果在这样的地形上应用腐蚀运算符,最终结果将是用某个邻域中的最小值替换每个像素,从而减小其高度。 结果,随着山谷的扩大,悬崖将被“侵蚀”。 扩张具有完全相反的效果,即悬崖将在山谷上空获得地形。 但是,在两种情况下,平稳度(即恒定强度的区域)将保持相对不变。

上述观察结果导致了一种检测图像边缘(或悬崖)的简单方法。 这可以通过计算膨胀图像和侵蚀图像之间的差异来完成。 由于这两个变换后的图像大部分在边缘位置不同,因此差异会突出图像的边缘。 输入cv::MORPH_GRADIENT自变量时,这正是cv::morphologyEx函数所做的事情。 显然,结构元素越大,检测到的边缘将越厚。 该边缘检测运算符也称为 Beucher 梯度(下一章将更详细地讨论图像梯度的概念)。 注意,也可以通过简单地从扩张后的图像中减去原始图像或从原始图像中减去侵蚀图像来获得类似的结果。 产生的边缘将更薄。

角点检测要复杂一些,因为它使用了四个不同的结构元素。 该运算符未在 OpenCV 中实现,但我们在这里展示它是为了演示如何定义和组合各种形状的结构化元素。 这个想法是通过使用两个不同的结构元素对图像进行扩张和腐蚀来封闭图像。 选择这些元素以使它们的直边保持不变,但是由于它们各自的作用,将影响角点的边缘。 让我们使用由单个白色正方形组成的以下简单图像更好地了解此非对称关闭操作的效果:

How it works...

第一个正方形是原始图像。 当用十字形结构元素进行扩张时,方形边缘会扩大,除了在十字形不会碰到方形的角点处。 这是中间的方块说明的结果。 然后,这个扩张的图像被结构元素侵蚀,这次,该元素具有菱形形状。 这种侵蚀使大多数边缘恢复到其原始位置,但由于它们没有膨胀,因此将角进一步推向了另一端。 然后获得左方格,可以看到它已经失去了尖角。 使用 X 形和方形结构元素重复相同的过程。 这两个元素是先前元素的旋转版本,因此将以 45 度方向捕获角。 最后,对两个结果求差将提取角点特征。

另见

The article, Morphological gradients by J.-F. Rivest, P. Soille, S. Beucher, ISET's symposium on electronic imaging science and technology, SPIE, Feb. 1992, for more on morphological gradient.

The article A modified regulated morphological corner detector by F.Y. Shih, C.-F. Chuang, V. Gaddipati, Pattern Recognition Letters , volume 26, issue 7, May 2005, for more information on morphological corner detection.

使用分水岭分割图像

分水岭变换是一种流行的图像处理算法,用于将图像快速分割为同质区域。 它依赖于这样的想法:当图像被视为拓扑浮雕时,均匀区域对应于由陡峭边缘界定的相对平坦的盆地。 由于其简单性,该算法的原始版本往往会过分分割图像,从而产生多个小区域。 这就是 OpenCV 提出该算法的变体的原因,该变体使用了一组预定义的标记来指导图像段的定义。

操作步骤

分水岭分割是通过使用cv::watershed函数获得的。 此函数的输入是一个 32 位带符号整数标记图像,其中每个非零像素代表一个标签。 想法是标记图像的某些像素,这些像素当然属于给定区域。 根据该初始标记,分水岭算法将确定其他像素所属的区域。 在本秘籍中,我们将首先将标记图像创建为灰度图像,然后将其转换为整数图像。 我们方便地将此步骤封装到WatershedSegmenter类中:

class WatershedSegmenter {

  private:

     cv::Mat markers;

  public:

     void setMarkers(const cv::Mat& markerImage) {

      // Convert to image of ints
      markerImage.convertTo(markers,CV_32S);
     }

     cv::Mat process(const cv::Mat &image) {

      // Apply watershed
      cv::watershed(image,markers);

      return markers;
     }

获得这些标记的方式取决于应用。 例如,某些预处理步骤可能导致识别出属于感兴趣对象的某些像素。 然后,分水岭将用于从该初始检测中划定整个对象。 在本秘籍中,我们将仅使用本章中使用的二进制图像来识别相应原始图像的动物(这是在第 4 章开头显示的图像)。

因此,从二进制图像中,我们需要确定肯定属于前景的像素(动物)和肯定属于背景的像素(主要是草)。 在这里,我们将用标签 255 标记前景像素,并用标签 128 标记背景像素(此选择完全是任意的,除 255 以外的任何标签编号都可以使用)。 其他像素(即标记未知的像素)的赋值为 0。就目前而言,二进制图像包含太多属于图像各个部分的白色像素。 然后,我们将严重腐蚀该图像,以便仅保留属于重要对象的像素:

   // Eliminate noise and smaller objects
   cv::Mat fg;
   cv::erode(binary,fg,cv::Mat(),cv::Point(-1,-1),6);

结果如下图:

How to do it...

请注意,仍然存在属于背景林的一些像素。 让我们简单地保留它们。 因此,它们将被认为对应于感兴趣的对象。 类似地,我们还通过对原始二进制图像进行大的扩张来选择背景的一些像素:

   // Identify image pixels without objects
   cv::Mat bg;
   cv::dilate(binary,bg,cv::Mat(),cv::Point(-1,-1),6);
   cv::threshold(bg,bg,1,128,cv::THRESH_BINARY_INV);

产生的黑色像素对应于背景像素。 这就是为什么在膨胀后立即将阈值运算分配给这些像素的值 128 的原因。然后获得以下图像:

How to do it...

这些图像被组合以形成标记图像:

   // Create markers image
   cv::Mat markers(binary.size(),CV_8U,cv::Scalar(0));
   markers= fg+bg;

请注意,我们在此处如何使用重载的operator+来组合图像。 这是将用作分水岭算法输入的图像:

How to do it...

然后按以下方式获得分段:

   // Create watershed segmentation object
   WatershedSegmenter segmenter;

   // Set markers and process
   segmenter.setMarkers(markers);
   segmenter.process(image);

然后更新标记图像,以便为每个零像素分配一个输入标签之一,而属于找到的边界的像素的值为 -1。 标签的结果图像如下:

How to do it...

边界图像为:

How to do it...

工作原理

正如我们在前面的秘籍中所做的那样,我们将在分水岭算法的描述中使用拓扑图类比。 为了创建分水岭分割,其想法是从级别 0 开始逐渐淹没图像。随着“水”级别的逐渐增加(达到级别 1、2、3 等),形成了集水盆地。 这些流域的大小也逐渐增加,因此,两个不同流域的水最终将合并。 发生这种情况时,将创建分水岭,以使两个盆地保持分离。 一旦水位达到最大水位,这些创建的盆地和集水区就构成了集水区分割。

如人们所料,洪水过程最初会形成许多小的单个盆地。 当所有这些合并时,会创建许多分水岭线,从而导致图像过度分割。 为了克服该问题,已经提出了对该算法的修改,其中,泛洪处理从预定的标记像素组开始。 由这些标记创建的盆地根据分配给初始标记的值进行标记。 当两个具有相同标签的盆地合并时,不会创建分水岭,从而防止了过度分割。

这就是调用cv::watershed函数时发生的情况。 输入的标记图像将更新以产生最终的分水岭分割。 用户可以输入带有任意数量标签的标记图像,其中未知标签的像素保留为 0。标记图像被选择为 32 位带符号整数的图像,以便能够定义 255 个以上的标签。 它还允许将特殊值 -1 分配给与分水岭相关的像素。 这是cv::watershed函数返回的内容。 为方便显示结果,我们引入了两种特殊方法。 第一个返回标签的图像(分水岭的值为 0)。 这可以通过阈值轻松完成:

     // Return result in the form of an image
     cv::Mat getSegmentation() {

      cv::Mat tmp;
      // all segment with label higher than 255
      // will be assigned value 255
      markers.convertTo(tmp,CV_8U);

      return tmp;
     }

类似地,第二种方法返回一个图像,其中分水岭线的值设置为 0,其余图像为 255。这一次,cv::convertTo方法用于实现以下结果:

     // Return watershed in the form of an image
     cv::Mat getWatersheds() {

      cv::Mat tmp;
      // Each pixel p is transformed into
      // 255p+255 before conversion
      markers.convertTo(tmp,CV_8U,255,255);

      return tmp;
     }

转换之前应用的线性变换允许将 -1 像素转换为 0(因为-1 * 255 + 255 = 0)。

值大于 255 的像素被分配值为 255。这是由于将有符号整数转换为无符号字符时应用了饱和操作。

另见

The article The viscous watershed transform by C. Vachier, F. Meyer, Journal of Mathematical Imaging and Vision, volume 22, issue 2-3, May 2005, for more information on the watershed transform.

下一个秘籍介绍了另一个图像分割算法,该算法也可以将图像分割为背景和前景对象。

使用 GrabCut 算法提取前景对象

OpenCV 提出了另一种流行的图像分割算法:GrabCut 算法的实现。 该算法不是基于数学形态学的,但是我们在这里介绍它,因为它显示了与前面秘籍中提出的分水岭分割算法的一些相似之处。 GrabCut 在计算上比分水岭贵,但通常可以产生更准确的结果。 当一个人想要在静止图像中提取前景对象(例如,将一个对象从一张图片剪切并粘贴到另一张图片)时,这是最好的算法。

操作步骤

cv::grabCut函数易于使用。 您只需要输入图像并将其某些像素标记为属于背景或前景。 基于此部分标记,该算法将确定完整图像的前景/背景分割。

指定输入图像的部分前景/背景标签的一种方法是定义一个矩形,在其中包含前景对象:

   // Open image
   image= cv::imread("../group.jpg");

   // define bounding rectangle
   // the pixels outside this rectangle
   // will be labeled as background 
   cv::Rect rectangle(10,100,380,180);

然后,此矩形之外的所有像素将被标记为背景。 除了输入图像及其分割图像之外,调用cv::grabCut函数还需要定义两个矩阵,其中包含该算法构建的模型:

   cv::Mat result; // segmentation (4 possible values)
   cv::Mat bgModel,fgModel; // the models (internally used)
   // GrabCut segmentation
   cv::grabCut(image,    // input image
            result,      // segmentation result
            rectangle,   // rectangle containing foreground 
            bgModel,fgModel, // models
            5,           // number of iterations
            cv::GC_INIT_WITH_RECT); // use rectangle

注意我们如何使用cv::GC_INIT_WITH_RECT标志作为函数的最后一个参数来指定使用边界矩形模式(下一节将讨论其他可用模式)。 输入/输出分割图像可以具有四个值之一:

  • cv::GC_BGD,用于肯定属于背景的像素(例如,在我们的示例中,矩形外部的像素)
  • cv::GC_FGD,用于肯定属于前景的像素(在我们的示例中没有)
  • cv::GC_PR_BGD,用于可能属于背景的像素
  • cv::GC_PR_FGD用于可能属于前景的像素(在我们的示例中为矩形内像素的初始值)。

通过提取值等于cv::GC_PR_FGD的像素,我们得到了分割的二进制图像:

   // Get the pixels marked as likely foreground
   cv::compare(result,cv::GC_PR_FGD,result,cv::CMP_EQ);
   // Generate output image
   cv::Mat foreground(image.size(),CV_8UC3,
                      cv::Scalar(255,255,255));
   image.copyTo(foreground,// bg pixels are not copied
                result);   

要提取所有前景像素(即,其值等于cv::GC_PR_FGDcv::GC_FGD),可以简单地检查第一位的值:

   // checking first bit with bitwise-and
   result= result&1; // will be 1 if FG 

这是可能的,因为这些常数定义为值 1 和 3,而其他两个常数定义为 0 和 2。在我们的示例中,由于分割图像不包含cv::GC_FGD像素(仅cv::GC_BGD像素已输入)。

最后,通过以下带有遮罩的复制操作,我们获得了前景对象的图像(在白色背景上):

   // Generate output image
   cv::Mat foreground(image.size(),CV_8UC3,
             cv::Scalar(255,255,255)); // all white image
   image.copyTo(foreground,result); // bg pixels not copied

结果图像如下:

How to do it...

工作原理

在前面的示例中,GrabCut 算法能够通过简单地指定在其中包含这些对象(四个动物)的矩形来提取前景对象。 备选地,还可以将值cv::GC_BGDcv::GC_FGD分配给作为cv::grabCut函数的第二自变量提供的分割图像的某些特定像素。 然后,您可以将GC_INIT_WITH_MASK指定为输入模式标志。 这些输入标签可以例如通过要求用户交互式地标记图像的一些元素来获得。 也可以组合这两种输入模式。

使用此输入信息,GrabCut 通过以下步骤创建背景/前景分割。 最初,将前景标签(cv::GC_PR_FGD)临时分配给所有未标记的像素。 基于当前分类,该算法将像素分为相似颜色的群集(即,背景为K群集,前景为K群集)。 下一步是通过在前景像素和背景像素之间引入边界来确定背景/前景分割。 这是通过优化过程来完成的,该过程尝试将像素与相似的标签连接起来,并对在强度相对均匀的区域中放置边界施加了惩罚。 通过使用图切割算法可以有效地解决此优化问题,该方法可以通过将它表示为连通图,在上面应用切割来组成最佳配置,从而找到问题的最佳解决方案。 所获得的分割为像素产生新的标签。 然后可以重复聚类过程,并再次找到新的最佳分割,依此类推。 因此,GrabCut 是一个迭代过程,可逐步改善分割结果。 根据场景的复杂性,可以在或多或少的迭代中找到一个好的解决方案(在简单情况下,一个迭代就足够了!)。

这解释了该函数的上一个最后一个参数,用户可以在其中指定要应用的迭代次数。 算法维护的两个内部模型作为函数的参数传递(并返回),这样,如果希望通过执行其他迭代来改善细分结果,则可以再次使用上次运行的模型调用该函数。

另见

The article by C. Rother, V. Kolmogorov and A. Blake, GrabCut: Interactive Foreground Extraction using Iterated Graph Cuts in ACM Transactions on Graphics (SIGGRAPH) volume 23, issue 3, August 2004, that describes in detail the GrabCut algorithm.

六、过滤图像

在本章中,我们将介绍:

  • 使用低通过滤器过滤图像
  • 使用中值过滤器过滤图像
  • 应用方向过滤器检测边缘
  • 计算图像的拉普拉斯算子

简介

滤波是信号和图像处理的基本任务之一。 它是一个过程,旨在有选择地提取图像的某些方面,这些方面被认为在给定应用的上下文中传达了重要信息。 过滤可以消除图像中的噪点,提取有趣的视觉特征,允许图像重采样等。 它起源于一般的信号和系统理论。 在此我们将不详细介绍该理论。 但是,本章将介绍一些与过滤有关的重要概念,并说明如何在图像处理应用中使用过滤器。 但首先,让我们先简要介绍一下频域分析的概念。

当我们查看图像时,我们观察到不同的灰度(或颜色)如何分布在图像上。 图像彼此不同,因为它们具有不同的灰度分布。 但是存在另一种可以分析图像的观点。 我们可以查看图像中存在的灰度变化。 一些图像包含几乎恒定强度的大区域(例如,蓝天),而在其他图像中,灰度强度在整个图像上变化很快(例如,繁忙的场景中挤满了许多小物体)。 因此,观察图像中这些变化的频率构成了表征图像的另一种方式。 这种观点被称为频域,而通过观察其灰度分布来表征图像的特性被称为空间域

频域分析将图像分解为从最低频率到最高频率的频率内容。 低频对应于图像强度缓慢变化的区域,而高频则是由强度的快速变化产生的。 存在几种众所周知的变换,例如傅立叶变换或余弦变换,可用于显式显示图像的频率内容。 注意,由于图像是二维实体,因此它由垂直频率(即垂直方向上的变化)和水平频率(水平方向上的变化)组成。

在频域分析框架下,过滤器是一种操作,可放大图像的某些频段,同时阻止(或减少)其他图像频段。 因此,低通过滤器是消除了图像的高频成分的过滤器,反之,高通过滤器是消除了低通成分的过滤器。 本章将介绍一些在图像处理中经常使用的过滤器,并说明它们在应用于图像时的效果。

使用低通过滤器过滤图像

在第一个秘籍中,我们将介绍一些非常基本的低通过滤器。 在本章的介绍部分中,我们了解到此类过滤器的目的是减小图像变化的幅度。 实现此目标的一种简单方法是用周围像素的平均值替换每个像素。 这样,快速的强度变化将被消除,从而被更渐进的过渡所替代。

操作步骤

cv::blur函数的目的是通过将每个像素替换为在矩形邻域上计算的平均像素值来使图像平滑。 该低通过滤器的应用如下:

   cv::blur(image,result,cv::Size(5,5));

这种过滤器也称为盒式过滤器。 在这里,我们通过使用5x5过滤器应用了该过滤器,以使过滤器的效果更加明显。 将其应用于下图时:

How to do it...

结果是:

How to do it...

在某些情况下,可能希望对像素附近的较近像素给予更多重视。 因此,可以计算加权平均值,在该加权平均值中,附近像素的权重大于远处像素的权重。 这可以通过使用遵循高斯函数(“钟形”函数)的加权方案来实现。 cv::GaussianBlur函数应用了这样的过滤器,其调用方式如下:

   cv::GaussianBlur(image,result,cv::Size(5,5),1.5);

结果如下图所示:

How to do it...

工作原理

如果过滤器的应用对应于用相邻像素的加权和替换像素,则称该过滤器为线性的。 在盒式过滤器中就是这种情况,其中一个像素被一个矩形邻域中的所有像素之和替换,然后除以该邻域的大小(以获得平均值)。 这就像在像素总数上将每个相邻像素乘以 1,然后将所有这些值相加。 可以使用矩阵表示过滤器的不同权重,该矩阵示出与所考虑的邻域中的每个像素位置相关联的乘法因子。 矩阵的中心元素,与当前应用了过滤器的像素相对应。 这样的矩阵有时称为掩码。 对于3x3盒式过滤器,相应的核为:

1/9 1/9 1/9
1/9 1/9 1/9
1/9 1/9 1/9

然后,应用线性过滤器对应于在图像的每个像素上移动核,并将每个相应像素乘以其关联的权重。 在数学上,此操作称为卷积

查看此秘籍中产生的输出图像,可以观察到低通过滤器的最终效果是使图像模糊或平滑。 这并不奇怪,因为此过滤器会衰减与在对象边缘可见的快速变化相对应的高频分量。

在高斯过滤器的情况下,与像素关联的权重与其与中心像素的距离成正比。 回想一下,一维高斯函数具有以下形式:

How it works...

选择归一化系数A,以使不同的权重之和为 1。 σ值控制所得高斯函数的宽度。 该值越大,函数越平坦。 例如,如果我们计算间隔为[-4,...,0,...4]σ=0.5的一维高斯过滤器的系数,则可以获得:

[0.0 0.0 0.00026 0.10645 0.78657 0.10645 0.00026 0.0 0.0]

对于σ=1.5,这些系数为:

[0.00761 0.036075 0.10959 0.21345 0.26666 
 0.21345 0.10959 0.03608 0.00761 ]

请注意,这些值是通过使用适当的σ值调用cv::getGaussianKernel函数获得的:

cv::Mat gauss= cv::getGaussianKernel(9,sigma,CV_32F);

要在图像上应用 2D 高斯过滤器,只需先在图像行上应用 1D 高斯过滤器(它将过滤水平频率),然后在图像列上应用相同的 1D 高斯过滤器(以过滤垂直频率)。 这是可能的,因为高斯过滤器是可分离过滤器(即 2D 核可以分解为两个 1D 过滤器)。 函数cv::sepFilter2D可用于应用通用的可分离过滤器。 也可以使用cv::filter2D函数直接应用 2D 核。

使用 OpenCV,通过向cv::GaussianBlur提供系数的数量(第三参数,奇数)和σ的值(第四参数)来指定要应用于图像的高斯过滤器。 您也可以简单地设置σ的值,然后让 OpenCV 确定适当的系数数(然后为过滤器大小输入值 0)。 当您为σ输入大小和值 0 时,也可能相反。 将确定最适合给定大小的σ值。 但是,建议您输入两个值以更好地控制过滤器效果。

更多

调整图像大小时,也会使用低通过滤器。 假设您希望将图像的大小减少 2 倍。您可能会认为,只需消除图像的偶数行和列即可完成此操作。 不幸的是,生成的图像看起来不会很好。 例如,原始图像中的倾斜边缘将在缩小图像上显示为阶梯。 其他锯齿形失真也将在图像的曲线和纹理部分上可见。

这些不良伪像是由一种称为空间混叠的现象引起的,当您试图在图像中包含太小而无法包含高频分量时,就会出现这种现象。 实际上,较小的图像(即像素较少的图像)不能像高分辨率的图像(想像高清电视与传统电视之间的差异)一样好表现出精细的纹理和清晰的边缘。 由于图像中的精细细节对应于高频,因此我们需要在减小图像尺寸之前去除图像中那些较高频率的成分。 我们从此秘籍中学到,可以通过低通过滤器来完成此操作。 因此,要在不增加烦人的伪影的情况下将图像尺寸减小一半,必须首先对原始图像应用低通过滤器,然后将一列和两列扔掉。 这正是cv::pyrDown函数的作用:

cv::Mat reducedImage;  // to contain reduced image
cv::pyrDown(image,reducedImage); // reduce image size by half

该相机使用5x5高斯过滤器对图像进行低通。 还存在使图像尺寸加倍的倒数cv::pyrUp函数。 当然,如果先缩小图像再放大,您将无法恢复确切的原始图像。 在缩编过程中丢失的内容无法恢复。 这两个函数用于创建图像金字塔。 这是一种由不同大小的图像的堆叠版本构成的数据结构(通常每个级别是前一级别的大小的一半),通常是为了进行有效的图像分析而构建的。 例如,如果希望检测图像中的物体,则可以首先在金字塔顶部的小图像上完成检测,并且在找到感兴趣的物体时,可以通过移到更低的金字塔等级来细化搜索,它包含图像高分辨率版本。

请注意,还有一个更通用的cv:resize函数,可让您指定所需的结果图像尺寸。 您只需指定一个可以小于或大于原始图像的新尺寸来调用它:

cv::Mat resizedImage;  // to contain resized image
cv::resize(image,resizedImage,
    cv::Size(image.cols/3,image.rows/3)); // 1/3 resizing

其他选项可用于根据比例因子指定调整大小,或选择要在重新采样过程中使用的特定插值方法。

另见

函数cv::boxFilter过滤具有仅由 1s 构成的正方形核的图像。 它类似于均值过滤器,但不将结果除以系数数量。

在第 2 章“使用访问邻居扫描图像”的“更多”部分中介绍了cv::filter2D函数。 此函数可让您通过输入所选的核将线性过滤器应用于图像。

使用中值过滤器过滤图像

本章的第一个秘籍介绍了线性过滤器的概念。 还存在可以有利地用于图像处理中的非线性过滤器。 这样的过滤器之一就是我们在本秘籍中介绍的中值过滤器。

由于中值过滤器对于抵御椒盐噪声特别有用,因此我们将使用在第 2 章的第一个秘籍中创建的图像,该图像在此处复制:

Filtering images using a median filter

操作步骤

对中值过滤函数的调用与其他过滤器类似:

   cv::medianBlur(image,result,5);

生成的图像如下:

How to do it...

工作原理

由于中值过滤器不是线性过滤器,因此不能用核矩阵表示。 但是,它也可以在像素附近进行操作,以确定输出像素值。 像素及其邻域形成一组值,顾名思义,中值过滤器将仅计算该组的中值,然后将当前像素替换为该中值。

这就解释了为什么过滤器在消除盐和胡椒噪声方面如此有效。 实际上,当给定像素邻域中存在离群的黑色或白色像素时,永远不会选择该像素作为中位值(相当大或最小值),因此总是将其替换为邻近值。 相比之下,简单的均值过滤器将受到此类噪声的极大影响,因为在下图可以观察到的噪声代表了我们的椒盐图像的均值滤波版本:

How it works...

显然,噪点像素会移动相邻像素的平均值。 结果,即使噪声已被均值过滤器模糊,仍然可见。

中值过滤器还具有保留边缘清晰度的优点。 但是,它会洗刷均匀区域中的纹理(例如,背景中的树木)。

应用方向过滤器检测边缘

本章的第一篇文章介绍了使用核矩阵进行线性过滤的思想。 所使用的过滤器具有消除或衰减高频成分而使图像模糊的效果。 在本秘籍中,我们将执行相反的变换,即放大图像的高频内容。 结果,此处介绍的高通过滤器将执行边缘检测

操作步骤

我们将在这里使用的过滤器称为 Sobel 过滤器。 之所以称为定向过滤器,是因为它仅影响垂直或水平图像频率,具体取决于所使用的过滤器核。 OpenCV 具有将 Sobel 运算符应用于图像的函数。 水平过滤器的名称如下:

   cv::Sobel(image,sobelX,CV_8U,1,0,3,0.4,128);

通过以下(和非常类似的)调用来实现垂直过滤:

   cv::Sobel(image,sobelY,CV_8U,0,1,3,0.4,128);

为该函数提供了几个整数参数,这些将在下一部分中进行说明。 只需注意,已选择这些来生成输出的 8 位图像(CV_8U)表示。

水平 Sobel 运算符的结果如下:

How to do it...

在该表示中,零值对应于灰度级 128。负值由较暗的像素表示,而正值由较亮的像素表示。 垂直的 Sobel 图像为:

How to do it...

如果您熟悉照片编辑软件,则前面的图像可能会让您想起图像浮雕效果,,实际上,这种图像转换通常基于定向过滤器的使用。

由于其核包含正值和负值,因此通常在 16 位带符号整数图像(CV_16S)中计算 Sobel 过滤器的结果。 然后将两个结果(垂直和水平)进行组合以获得 Sobel 过滤器的范数:

   // Compute norm of Sobel
   cv::Sobel(image,sobelX,CV_16S,1,0);
   cv::Sobel(image,sobelY,CV_16S,0,1);
   cv::Mat sobel;
   //compute the L1 norm
   sobel= abs(sobelX)+abs(sobelY);

使用convertTo方法的可选重新缩放参数,可以方便地在图像中显示 Sobel 范数,以获得零值对应于白色,较高的值分配为较深的灰色阴影的图像:

   // Find Sobel max value
   double sobmin, sobmax;
   cv::minMaxLoc(sobel,&sobmin,&sobmax);
   // Conversion to 8-bit image
   // sobelImage = -alpha*sobel + 255
   cv::Mat sobelImage;
   sobel.convertTo(sobelImage,CV_8U,-255./sobmax,255);

结果可以在下图中看到:

How to do it...

查看此图像,现在很清楚为什么将这种运算符称为边缘检测器。 然后可以对该图像进行阈值处理以获得显示图像轮廓的二进制图。 以下代码段创建了以下图像:

   cv::threshold(sobelImage, sobelThresholded, 
                      threshold, 255, cv::THRESH_BINARY);

How to do it...

工作原理

Sobel 运算符是经典的边缘检测线性过滤器,它基于简单的3x3核,其结构如下:

-1 0 1
-2 0 2
-1 0 1
-1 -2 -1
0 0 0
1 2 1

如果我们将图像视为二维函数,则可以将 Sobel 运算符视为图像在垂直和水平方向上变化的度量。 用数学术语来说,此度量称为梯度,它定义为由函数在两个正交方向上的一阶导数构成的 2D 向量:

How it works...

因此,Sobel 运算符通过在水平方向和垂直方向上不同像素来给出图像梯度的近似值。 它在感兴趣像素周围的小窗口上运行,以减少噪声的影响。 cv::Sobel函数计算图像与 Sobel 核的卷积结果。 其完整规格如下:

   cv::Sobel(image,  // input
             sobel,  // output
             image_depth,   // image type
             xorder,yorder, // kernel specification
             kernel_size,   // size of the square kernel 
             alpha, beta);  // scale and offset

因此,您可以决定是否将结果写入无符号字符,有符号整数或浮点图像中。 当然,如果结果落在图像像素域之外,则将应用饱和度。 这是最后两个参数可能有用的地方。 在将结果存储到图像中之前,可以将结果缩放(乘以alpha),并可以添加偏移量beta。 这就是我们在上一节中生成的图像的情况,该图像的 Sobel 值 0 由中间灰度级 128 表示。每个 Sobel 遮罩都对应一个方向的导数。 因此,使用两个参数来指定要应用的核,即xy方向上的导数顺序。

例如,水平 Sobel 核是通过将x阶和y阶指定 1 和 0 来获得的,而垂直核将用 0 和 1 生成。其他组合也可以,但是这两个是最常使用的(在下一秘籍中讨论二阶导数的情况)。 最后,也可以使用大小大于3x3的核。 值 1、3、5 和 7 是核大小的可能选择。 大小为 1 的核对应于 1D Sobel 过滤器(1x33x1)。

由于梯度是 2D 向量,因此它具有范数和方向。 梯度向量的范数告诉您变化的幅度是多少,通常将其计算为欧几里得范数(也称为 L2 范数):

How it works...

但是,在图像处理中,我们通常将此规范计算为绝对值的总和。 这称为 L1 范数,它给出的值接近 L2 范数,但计算成本却低得多。 这就是我们在本秘籍中所做的,即:

   //compute the L1 norm
   sobel= abs(sobelX)+abs(sobelY);

梯度向量始终指向最陡峭变化的方向。 对于图像,这意味着梯度方向将与边缘正交,指向从暗到亮的方向。 梯度角方向由下式给出:

How it works...

通常,对于边缘检测,仅计算范数。 但是,如果您既需要规范又需要方向,那么可以使用以下 OpenCV 函数:

   // Sobel must be computed in floating points
   cv::Sobel(image,sobelX,CV_32F,1,0);
   cv::Sobel(image,sobelY,CV_32F,0,1);
   // Compute the L2 norm and direction of the gradient
   cv::Mat norm, dir;   
   cv::cartToPolar(sobelX,sobelY,norm,dir);

默认情况下,方向以弧度计算。 只需添加true作为附加参数,以度为单位进行计算即可。

通过在梯度幅度上应用阈值获得了二进制边缘图。 选择正确的阈值并不是一项显而易见的任务。 如果阈值太低,将保留太多(较厚)边缘,而如果我们选择更严格(较高)的阈值,则将获得折断边缘。 为了说明这种折衷情况,请将前面的二进制边缘图与使用较高阈值获得的以下内容进行比较:

How it works...

一种可能的替代方法是使用滞后阈值的概念。 我们将在下一章介绍 Canny 运算符的地方对此进行解释。

更多

还存在其他梯度运算符。 例如, Prewitt 运算符定义以下核:

-1 0 1
-1 0 1
-1 0 1
-1 -1 -1
0 0 0
1 1 1

Roberts 运算符基于以下简单的2x2核:

1 0
0 -1
0 1
-1 0

当需要更精确的梯度方向估计时,首选 Scharr 运算符:

-3 0 3
-10 0 10
-3 0 3
-3 -10 -3
0 0 0
3 10 3

请注意,可以通过使用CV_SCHARR参数来将 Scharr 核与cv::Sobel函数结合使用:

   cv::Sobel(image,sobelX,CV_16S,1,0, CV_SCHARR);

或者等效地,通过调用函数cv::Scharr

   cv::Scharr(image,scharrX,CV_16S,1,0,3);

所有这些定向过滤器都试图估计图像函数的一阶导数。 因此,在存在沿过滤器方向的较大强度变化的区域获得高值,而平坦区域产生较低的值。 这就是为什么计算图像导数的过滤器是高通过滤器的原因。

另见

第 7 章中的“使用 Canny 运算符检测边缘”,通过使用两个不同的阈值获得二进制边缘图。

计算图像的拉普拉斯算子

拉普拉斯算子是另一种基于图像导数计算的高通线性过滤器。 如将要解释的,它计算二阶导数以测量图像函数的曲率。

操作步骤

OpenCV 函数cv::Laplacian计算图像的拉普拉斯算子。 它与cv::Sobel函数非常相似。 实际上,它使用相同的基本函数cv::getDerivKernels以获得其核矩阵。 唯一的区别是没有导数阶参数,因为根据定义,这些参数是二阶导数。

对于此运算符,我们将创建一个简单的类,该类将封装一些与 Laplacian 相关的有用操作。 基本方法是:

class LaplacianZC {

  private:

     // original image
     cv::Mat img;

     // 32-bit float image containing the Laplacian
     cv::Mat laplace;
     // Aperture size of the laplacian kernel
     int aperture;

  public:

     LaplacianZC() : aperture(3) {}

     // Set the aperture size of the kernel
     void setAperture(int a) {

        aperture= a;
     }

     // Compute the floating point Laplacian
     cv::Mat computeLaplacian(const cv::Mat& image) {

        // Compute Laplacian
        cv::Laplacian(image,laplace,CV_32F,aperture);

        // Keep local copy of the image
        // (used for zero-crossings)
        img= image.clone();

        return laplace;
     }

拉普拉斯算子的计算是在浮点图像上完成的。 为了获得结果的图像,我们像前面的秘籍一样执行重新缩放。 这种重新缩放基于拉普拉斯算子的最大绝对值,其中将 0 分配给灰度级 128。我们类的方法允许获取该图像表示形式:

     // Get the Laplacian result in 8-bit image 
     // zero corresponds to gray level 128
     // if no scale is provided, then the max value will be
     // scaled to intensity 255
     // You must call computeLaplacian before calling this
     cv::Mat getLaplacianImage(double scale=-1.0) {

        if (scale<0) {

           double lapmin, lapmax;
           cv::minMaxLoc(laplace,&lapmin,&lapmax);

           scale= 127/ std::max(-lapmin,lapmax);
        }

        cv::Mat laplaceImage;
        laplace.convertTo(laplaceImage,CV_8U,scale,128);

        return laplaceImage;
     }

使用此类,从7x7核计算出的拉普拉斯图像如下:

   // Compute Laplacian using LaplacianZC class
   LaplacianZC laplacian;
   laplacian.setAperture(7);
   cv::Mat flap= laplacian.computeLaplacian(image);
   laplace= laplacian.getLaplacianImage();

生成的图像如下:

How to do it...

工作原理

正式地,将 2D 函数的拉普拉斯算子定义为其第二个导数的和:

How it works...

以其最简单的形式,它可以由以下3x3核近似:

0 1 0
1 -4 1
0 1 0

至于 Sobel 运算符,也可以使用更大的核来计算拉普拉斯算子,并且由于该运算符对图像噪声更加敏感,因此希望这样做(除非考虑到计算效率)。 请注意,拉普拉斯算子的核值总和为 0。这保证了在恒定强度的区域中拉普拉斯算子将为零。 确实,由于拉普拉斯算子测量图像函数的曲率,因此在平坦区域上它应等于 0。

乍一看,拉普拉斯算子的作用可能难以解释。 根据核的定义,很明显,运算符会放大任何孤立的像素值(该值与相邻像素值非常不同)。 这是操作人员对噪声的高度敏感性的结果。 但是,查看图像边缘周围的拉普拉斯值更有趣。 图像中存在边缘是不同灰度强度的区域之间快速过渡的结果。 随着图像函数沿边缘的演变(例如,由从暗到亮的过渡引起),人们可以观察到灰度级提升必然意味着从正曲率逐渐过渡(当强度值开始上升时) )变为负曲率(强度即将达到其高平稳期时)。 因此,正和负拉普拉斯值之间(或相反)之间的过渡构成边缘存在的良好指示。 表达这一事实的另一种方式是说边缘将位于拉普拉斯函数的零交叉点 。 我们将通过在测试图像的一个小窗口中查看拉普拉斯算子的值来说明这一想法。 我们选择一个对应于由城堡之一的塔的屋顶的底部创建的边缘的边缘。 下图绘制了一个白框,以显示该兴趣区域的确切位置:

How it works...

现在查看此窗口中的拉普拉斯值(7x7核),我们有:

How it works...

如图所示,如果您仔细遵循拉普拉斯算子的零交叉点(位于不同符号的像素之间),则会获得一条与图像窗口中可见边缘相对应的曲线。 在上方,我们沿着零交叉点绘制了虚线,这些零点对应于在所选图像窗口中可见的塔架边缘。 这意味着原则上甚至可以亚像素精度检测图像边缘。

在拉普拉斯图像中遵循零交叉曲线是一项艰巨的任务。 但是,可以使用简化的算法来检测近似的零交叉位置。 这一过程如下。 扫描拉普拉斯图像,并将当前像素与其左侧的像素进行比较。 如果两个像素的符号不同,则在当前像素处声明零交叉,如果不是,则对紧接上方的像素重复相同的测试。 该算法通过以下方法实现,该方法生成零交叉的二进制图像:

     // Get a binary image of the zero-crossings
     // if the product of the two adjascent pixels is
     // less than threshold then this zero-crossing 
     // will be ignored
     cv::Mat getZeroCrossings(float threshold=1.0) {

        // Create the iterators
        cv::Mat_<float>::const_iterator it= 
           laplace.begin<float>()+laplace.step1();
        cv::Mat_<float>::const_iterator itend= 
           laplace.end<float>();
        cv::Mat_<float>::const_iterator itup= 
           laplace.begin<float>();

        // Binary image initialize to white
        cv::Mat binary(laplace.size(),CV_8U,cv::Scalar(255));
        cv::Mat_<uchar>::iterator itout= 
           binary.begin<uchar>()+binary.step1();

        // negate the input threshold value
        threshold *= -1.0;

        for ( ; it!= itend; ++it, ++itup, ++itout) {

           // if the product of two adjascent pixel is
           // negative then there is a sign change
           if (*it * *(it-1) < threshold)
              *itout= 0; // horizontal zero-crossing
           else if (*it * *itup < threshold)
              *itout= 0; // vertical zero-crossing
        }

        return binary;
     }

还引入了一个附加阈值,以确保当前的拉普拉斯值足够显着,可以视为边缘。 结果是以下二进制映射:

How it works...

如您所见,拉普拉斯算子的零交叉点检测所有边缘。 在强边缘和弱边缘之间没有区别。 我们还提到拉普拉斯算子对噪声非常敏感。 这两个事实解释了为什么运算符会检测到如此多的边缘。

更多

可以通过从图像中减去拉普拉斯算子来增强图像的对比度。 这是我们在第 2 章“通过访问邻居扫描图像”秘籍中的方法,我们在其中介绍了核:

0 -1 0
-1 5 -1
0 -1 0

等于 1 减去 Laplacian 核(即原始图像减去其 Laplacian)。

另见

第 8 章中的方法“检测尺度不变特征 SURF”,使用拉普拉斯算子进行尺度不变特征的检测。

七、提取直线,轮廓和零件

在本章中,我们将介绍:

  • 使用 Canny 运算符检测图像轮廓
  • 使用霍夫变换检测图像中的直线
  • 将线拟合到一组点
  • 提取组件的轮廓
  • 计算组件的形状描述符

简介

为了对图像执行基于内容的分析,必须从构成图像的像素集合中提取有意义的特征。 轮廓,线条,斑点等是定义图像内容的基本图像元素。 本章将教您如何提取其中一些重要的图像特征。

使用 Canny 运算符检测图像轮廓

在上一章中,我们了解了如何检测图像的边缘。 特别是,我们表明,通过对梯度幅度应用阈值,可以获得图像主要边缘的二值映射。 边缘具有重要的视觉信息,因为它们描绘了图像元素。 因此,它们可以用于例如对象识别。 然而,简单的二进制边缘图具有两个主要缺点。 首先,检测到的边缘过厚。 这意味着无法精确定位对象限制。 第二,也是更重要的是,很难找到一个阈值,该阈值足够低以检测图像的所有重要图像边缘,而同时又足够高而不会包含太多无关紧要的边缘。 这是 Canny 算法试图解决的折衷问题。

操作步骤

Canny 算法在 OpenCV 中通过函数cv::Canny实现。 如将要解释的,该算法需要指定两个阈值。 因此,对该函数的调用如下:

   // Apply Canny algorithm
   cv::Mat contours;
   cv::Canny(image,    // gray-level image
             contours, // output contours
             125,      // low threshold
             350);     // high threshold

当应用于下图时:

How to do it...

结果如下:

How to do it...

请注意,要获得如前面的屏幕快照中所示的图像,我们必须反转黑白值,因为正常结果以非零像素表示轮廓。 倒置表示形式更易于在页面上打印,其生成方式如下:

   cv::Mat contoursInv; // inverted image
   cv::threshold(contours,contoursInv,
                 128,   // values below this
                 255,   // becomes this
                 cv::THRESH_BINARY_INV);

工作原理

尽管可以使用其他梯度运算符,但 Canny 运算符通常基于 Sobel 运算符。 此处的关键思想是使用两个不同的阈值以确定哪个点应属于轮廓:一个低阈值和一个高阈值。

选择低阈值的方式应使其包括被认为属于重要图像轮廓的所有边缘像素。 例如,使用上一部分示例中指定的低阈值,并将其应用于 Sobel 运算符的结果,可获得以下边缘图:

How it works...

可以看出,描绘道路的边缘非常清晰。 但是,由于使用了允许阈值,因此还检测到了比理想情况更多的边缘。 然后,第二个阈值的作用是定义属于所有重要轮廓的边缘。 它应排除所有被视为离群值的边缘。 例如,与我们的示例中使用的高阈值相对应的 Sobel 边缘图为:

How it works...

现在,我们有一个包含断边的图像,但是可见的断点当然属于场景的重要轮廓。 Canny 算法将这两个边缘图组合在一起,以生成轮廓的“最佳”图。 它仅通过保留低阈值边缘图的边缘点(存在连续的边缘路径)并将该边缘点链接到属于高阈值边缘图的边缘来进行操作。 因此,保留了高阈值图的所有边缘点,同时移除了低阈值图中的所有孤立的边缘点链。 所获得的解决方案构成了良好的折衷,只要指定了适当的阈值,就可以获取高质量的轮廓。 基于使用两个阈值以获得二进制图的该策略称为滞后阈值,可用于需要从阈值操作获得二进制图的任何上下文中。 但是,这是以较高的计算复杂度为代价的。

另外,Canny 算法使用额外的策略来改善边缘图的质量。 在应用滞后阈值之前,应去除所有梯度幅度在梯度方向上不是最大的边缘点。 回想一下,梯度方向始终垂直于边缘。 因此,在该方向上的梯度的局部最大值对应于轮廓的最大强度点。 这解释了为什么在 Canny 等高线图中获得细边的原因。

另见

The classic article by J. Canny, A computational approach to edge detection, IEEE Transactions on Pattern Analysis and Image Understanding, vol. 18, issue 6, 1986.

使用霍夫变换检测图像中的直线

在我们的人造世界中,平面和线性结构比比皆是。 结果,在图像中经常可见直线。 这些有意义的特征在对象识别和图像理解中起着重要作用。 因此,检测图像中的这些特定特征很有用。 霍夫变换是实现此目标的经典算法。 它最初是为检测图像中的线条而开发的,并且正如我们将看到的,它也可以扩展为检测其他简单图像结构。

准备

使用霍夫变换时,使用以下公式表示线:

Getting ready

参数ρ是线与图像原点之间的距离(左上角),θ是垂直于线的角度。 在此表示下,图像中可见的线在0π弧度之间具有θ角,而半径ρ可以具有等于图像对角线长度的最大值。 例如,考虑以下几行:

Getting ready

像线 1 一样的垂直线的θ角度值等于零,而水平线(例如,线 5)的θ值等于π / 2。 因此,线 3 具有等于π / 4的角度θ,并且线 4 大约为0.7π。 为了能够在间隔[0, π]中用θ表示所有可能的线,可以将半径值设为负数。 第 2 行的情况是θ值等于0.8π,而ρ的值为负。

操作步骤

OpenCV 为行检测提供了霍夫变换的两种实现。 基本版本是cv::HoughLines。 它的输入是一个二进制映射,其中包含一组点(用非零像素表示),其中一些点对齐形成线。 通常,它是例如从 Canny 运算符获得的边缘图。 cv::HoughLines函数的输出是cv::Vec2f元素的向量,每个元素都是一对浮点值,它们代表检测到的线的参数(ρ, θ)。 这是使用此函数的示例,其中我们首先应用 Canny 运算符来获取图像轮廓,然后使用霍夫变换检测线:

   // Apply Canny algorithm
   cv::Mat contours;
   cv::Canny(image,contours,125,350);
   // Hough tranform for line detection
   std::vector<cv::Vec2f> lines;
   cv::HoughLines(test,lines,
        1,PI/180,  // step size
        80);       // minimum number of votes

参数 3 和 4 对应于行搜索的步长。 在我们的示例中,该函数将按1搜索所有可能半径的线,并按π/180搜索所有可能角度的线。 下一部分将说明最后一个参数的作用。 通过这种特殊的参数值选择,可以在先前秘籍的道路图像上检测到 15 条线。 为了可视化检测结果,有趣的是在原始图像上绘制这些线。 但是,重要的是要注意该算法检测图像中的线,而不是线段,因为未给出每条线的终点。 因此,我们将绘制横贯整个图像的线。 为此,对于一条几乎垂直的线,我们计算其与图像的水平界限的交点(即第一行和最后一行),并在这两点之间绘制一条线。 我们几乎以水平线进行类似处理,但使用第一列和最后一列。 使用cv::line函数绘制线。 请注意,即使点坐标超出图像限制,此函数也可以正常使用。 因此,不需要检查计算出的交点是否落在图像内。 然后通过如下迭代线向量来绘制线:

   std::vector<cv::Vec2f>::const_iterator it= lines.begin();
   while (it!=lines.end()) {

      float rho= (*it)[0];   // first element is distance rho
      float theta= (*it)[1]; // second element is angle theta

      if (theta < PI/4\. 
           || theta > 3.*PI/4.) { // ~vertical line

         // point of intersection of the line with first row
         cv::Point pt1(rho/cos(theta),0);        
         // point of intersection of the line with last row
         cv::Point pt2((rho-result.rows*sin(theta))/
                                  cos(theta),result.rows);
         // draw a white line
         cv::line( image, pt1, pt2, cv::Scalar(255), 1); 

      } else { // ~horizontal line

         // point of intersection of the 
         // line with first column
         cv::Point pt1(0,rho/sin(theta));        
         // point of intersection of the line with last column
         cv::Point pt2(result.cols,
                 (rho-result.cols*cos(theta))/sin(theta));
         // draw a white line
         cv::line(image, pt1, pt2, cv::Scalar(255), 1); 
      }

      ++it;
   }

然后获得以下结果:

How to do it...

可以看出,霍夫变换只是在寻找图像中边缘像素的对齐方式。 由于偶发的像素对齐,这可能会产生一些错误的检测,或者当几条线穿过像素的相同对齐时,可能会导致多次检测。

为了克服这些问题中的某些问题,并允许检测线段(即带有端点),提出了一种变换形式。 这是概率霍夫变换,在 OpenCV 中作为函数cv::HoughLinesP实现。 我们在这里使用它来创建封装函数参数的LineFinder类:

class LineFinder {

  private:

     // original image
     cv::Mat img;

     // vector containing the end points 
     // of the detected lines
     std::vector<cv::Vec4i> lines;

     // accumulator resolution parameters
     double deltaRho;
     double deltaTheta;

     // minimum number of votes that a line 
     // must receive before being considered
     int minVote;

     // min length for a line
     double minLength;

     // max allowed gap along the line
     double maxGap;

  public:

     // Default accumulator resolution is 1 pixel by 1 degree
     // no gap, no mimimum length
     LineFinder() : deltaRho(1), deltaTheta(PI/180), 
                    minVote(10), minLength(0.), maxGap(0.) {}

使用相应的设置器方法:

     // Set the resolution of the accumulator
     void setAccResolution(double dRho, double dTheta) {

        deltaRho= dRho;
        deltaTheta= dTheta;
     }

     // Set the minimum number of votes
     void setMinVote(int minv) {

        minVote= minv;
     }

     // Set line length and gap
     void setLineLengthAndGap(double length, double gap) {

        minLength= length;
        maxGap= gap;
     }

然后,执行霍夫线段检测的方法很简单:

     // Apply probabilistic Hough Transform
     std::vector<cv::Vec4i> findLines(cv::Mat& binary) {

        lines.clear();
        cv::HoughLinesP(binary,lines,
                        deltaRho, deltaTheta, minVote, 
                        minLength, maxGap);

        return lines;
     }

此方法返回cv::Vec4i的向量,每个向量都包含每个检测到的片段的起点和终点坐标。 然后可以通过以下方法将检测到的线条绘制在图像上:

     // Draw the detected lines on an image
     void drawDetectedLines(cv::Mat &image, 
                cv::Scalar color=cv::Scalar(255,255,255)) {

        // Draw the lines
        std::vector<cv::Vec4i>::const_iterator it2= 
                                           lines.begin();

        while (it2!=lines.end()) {

           cv::Point pt1((*it2)[0],(*it2)[1]);        
           cv::Point pt2((*it2)[2],(*it2)[3]);

           cv::line( image, pt1, pt2, color);

           ++it2;   
        }
     }

现在,使用相同的输入图像,可以按以下顺序检测线条:

   // Create LineFinder instance
   LineFinder finder;

   // Set probabilistic Hough parameters
   finder.setLineLengthAndGap(100,20);
   finder.setMinVote(80);

   // Detect lines and draw them
   std::vector<cv::Vec4i> lines= finder.findLines(contours);
   finder.drawDetectedLines(image);
   cv::namedWindow("Detected Lines with HoughP");
   cv::imshow("Detected Lines with HoughP",image);

得到以下结果:

How to do it...

工作原理

霍夫变换的目的是找到二进制图像中经过足够数量点的所有线。 它通过考虑输入二进制图中的每个像素点并标识通过它的所有可能的行来进行。 当同一条线穿过许多点时,这意味着该条线的重要性足以考虑。

霍夫变换使用二维累加器,以便计算识别给定行的次数。 该累加器的大小由所采用的线表示形式的[ρ, θ]参数的指定步长(如前一节所述)定义。 为了说明转换的功能,让我们通过200矩阵创建180θ对应π / 180步长,ρ对应1):

   // Create a Hough accumulator
   // here a uchar image; in practice should be ints
   cv::Mat acc(200,180,CV_8U,cv::Scalar(0));

该累加器是不同(ρ, θ)值的映射。 因此,该矩阵的每个条目对应于一条特定的行。 现在,如果我们考虑一个点,例如在坐标(50, 30)处,则可以通过遍历所有可能的θ角度(步长为π / 180)来识别通过此点的所有线,然后计算相应的(四舍五入的)ρ值:

   // Choose a point
   int x=50, y=30;

   // loop over all angles
   for (int i=0; i<180; i++) {

      double theta= i*PI/180.;

      // find corresponding rho value 
      double rho= x*cos(theta)+y*sin(theta);
      // j corresponds to rho from -100 to 100
      int j= static_cast<int>(rho+100.5);

      std::cout << i << "," << j << std::endl;

      // increment accumulator
      acc.at<uchar>(j,i)++;
   }

然后,累加器对应于计算出的(ρ, θ对)的条目增加,表示所有这些行都通过图像的一个点(或者换句话说,每个点都通过投票) 对于一组可能的候选行)。 如果将累加器显示为图像(乘以100以使计数 1 可见),我们将获得:

How it works...

该曲线表示通过考虑点的所有线的集合。 现在,如果我们重复相同的练习,比方说(30, 10),那么我们现在有了以下累加器:

How it works...

可以看出,两条结果曲线在一个点处相交。 对应于经过这两个点的线的点。 累加器的相应条目将获得两票,表明有两分通过这条线。 如果对二进制图的所有点重复相同的过程,则沿给定线对齐的点将多次增加累加器的公共项。 最后,只需要在此累加器中标识出获得大量票数的局部最大值即可检测图像中的线(即点对齐)。 在cv::HoughLines函数中指定的最后一个参数对应于必须将行视为被检测到的最小投票数。 例如,如果我们将此值降低为 60,即:

   cv::HoughLines(test,lines,1,PI/180,60);

然后,前面部分的示例将接受更多行,如下所示:

How it works...

概率霍夫变换对基本算法没有多大修改。 首先,不是系统地逐行扫描图像,而是在二进制映射图中以随机顺序选择点。 每当累加器的输入达到指定的最小值时,就会沿着相应的线扫描图像,并删除通过该图像的所有点(即使尚未投票)。 该扫描还确定了将被接受的片段的长度。 为此,算法定义了两个附加参数。 一个是要接受的段的最小长度,另一个是允许形成连续段的最大像素间隙。 这个额外的步骤增加了算法的复杂性,但是由于以下事实这一事实可以部分弥补这一点:投票过程中涉及的点更少,因为其中一些点被线扫描过程消除了。

更多

霍夫变换还可以用于检测其他几何实体。 实际上,可以由参数方程式表示的任何实体都是霍夫变换的良好候选者。

检测圆形

对于圆形,相应的参数方程为:

Detecting circles

该方程式包含三个参数(圆半径和中心坐标),这意味着将需要一个 3 维累加器。 但是,通常发现,随着其累加器维数的增加,霍夫变换的可靠性降低。 实际上,在这种情况下,对于每个点,累加器的大量条目将增加,因此,局部峰的精确定位变得更加困难。 因此,已经提出了不同的策略以克服该问题。 在霍夫圆检测的 OpenCV 实现中使用的一个使用两次通过。 在第一遍过程中,使用二维累加器查找候选圆的位置。 由于圆圆周上的点的坡度应指向半径方向,因此,对于每个点,仅累加器中沿坡度方向的条目会增加(基于预定义的最小和最大半径值)。 一旦检测到可能的圆心(即已收到预定义数量的选票),便会在第二遍过程中建立可能半径的一维直方图。 该直方图中的峰值对应于检测到的圆的半径。

实现上述策略的函数cv::HoughCircles集成了 Canny 检测和霍夫变换。 它被称为如下:

   cv::GaussianBlur(image,image,cv::Size(5,5),1.5);
   std::vector<cv::Vec3f> circles;
   cv::HoughCircles(image, circles, CV_HOUGH_GRADIENT, 
      2,   // accumulator resolution (size of the image / 2) 
      50,  // minimum distance between two circles
      200, // Canny high threshold 
      100, // minimum number of votes 
      25, 100); // min and max radius

请注意,始终建议在调用cv::HoughCircles函数之前先对图像进行平滑处理,以减少可能导致多次假圆检测的图像噪声。 检测结果在cv::Vec3f实例的向量中给出。 前两个值是圆心,第三个值是半径。 在撰写本书时, CV_HOUGH_GRADIENT是唯一可用的选项。 它对应于两遍圆检测方法。 第四个参数定义累加器分辨率。 它是一个除法因子,例如,将值指定为 2 将使累加器的大小为图像大小的一半。 下一个参数是两个检测到的圆之间的最小距离(以像素为单位)。 另一个参数对应于 Canny 边缘检测器的高阈值。 下阈值设置为该值的一半。 第七个参数是中心位置在第一遍过程中必须获得的最小投票数,才能被视为第二遍的候选圈子。 最后,最后两个参数是要检测的圆的最小和最大半径值。 可以看出,该函数包含许多参数,这些参数使其难以调整。

获得检测到的圆的向量后,可以通过在向量上进行迭代并使用找到的参数调用cv::circle绘制函数,将其绘制在图像上:

   std::vector<cv::Vec3f>::
          const_iterator itc= circles.begin();

   while (itc!=circles.end()) {

     cv::circle(image, 
        cv::Point((*itc)[0], (*itc)[1]), // circle centre
        (*itc)[2],       // circle radius
        cv::Scalar(255), // color 
        2);              // thickness

     ++itc;   
   }

这是在带有所选参数的测试图像上获得的结果:

Detecting circles

广义霍夫变换

对于某些形状,很难找到紧凑的参数表示形式,例如三角形,八边形,多边形,对象轮廓等。 但是,仍然可以使用霍夫变换在图像中定位这些形状。 原理保持不变。 创建一个二维累加器,它代表目标形状的所有可能位置。 因此,必须在形状上定义一个参考点,并且图像上的每个特征点都会为可能的参考点位置投票。 由于点可以在形状轮廓上的任何位置,因此所有可能的参考位置的轨迹都将跟踪累加器中的形状,该形状是目标形状的镜像。 同样,图像中属于相同形状的点将在累加器中与该形状位置相对应的交点处产生一个峰值。

下图中对此进行了说明,其中感兴趣的形状是一个三角形(如右图所示),在该三角形的左下角定义了参考。 在累加器上显示了一个特征点,该特征点将增加绘制位置处的所有条目,因为它们对应于穿过该特征点的三角形参考点的可能位置:

Generalized Hough transform

这种方法通常称为广义霍夫变换。 显然,它没有考虑可能的比例变化或形状旋转。 这将需要在更高维度上进行搜索。

另见

The article Gradient-based Progressive Probabilistic Hough Transform by C. Galambos, J. Kittler, and J. Matas, IEE Vision Image and Signal Processing, vol. 148 no 3, pp. 158-165, 2002. It is one of the numerous reference on the Hough transform and describes the probabilistic algorithm implemented in OpenCV.

The article by H.K. Yuen, J. Princen, J. Illingworth, and J Kittler, Comparative Study of Hough Transform Methods for Circle Finding, Image and Vision Computing, vol. 8 no 1, pp. 71-77, 1990 that describes different strategies for circle detection using the Hough transform.

将直线拟合到一组点

在某些应用中,重要的是不仅要检测图像中的线条,而且要获得线条位置和方向的准确估计。 此秘籍将向您展示如何找到最适合给定点集的线。

操作步骤

首先要做的是识别图像中似乎沿直线对齐的点。 然后,让我们使用在前面的秘籍中检测到的行之一。 假设使用cv::HoughLinesP检测到的行包含在称为linesstd::vector中。 例如,要提取看似属于的那组点,我们可以按以下步骤进行。 我们在黑色图像上绘制一条白线,并将其与用于检测线条的轮廓的 Canny 图像相交。 只需通过以下语句即可实现:

   int n=0; // we select line 0 
   // black image
   cv::Mat oneline(contours.size(),CV_8U,cv::Scalar(0));
   // white line
   cv::line(oneline, 
            cv::Point(lines[n][0],lines[n][1]),
            cv::Point(lines[n][2],lines[n][3]),
            cv::Scalar(255),
            5);
   // contours AND white line
   cv::bitwise_and(contours,oneline,oneline);

结果是仅包含可能与指定线关联的点的图像。 为了引入一些公差,我们绘制了一定厚度的线(此处为 5)。 因此,将接受所定义邻域内的所有点。 这是获得的图像(为了更好地观看,将其反转):

How to do it...

然后可以通过以下双重循环将此集合中点的坐标插入cv::Pointstd::vector中(也可以使用浮点坐标,即cv::Point2f):

   std::vector<cv::Point> points;

   // Iterate over the pixels to obtain all point positions
   for( int y = 0; y < oneline.rows; y++ ) {    
      // row y

      uchar* rowPtr = oneline.ptr<uchar>(y);

      for( int x = 0; x < oneline.cols; x++ ) {
         // column x 

         // if on a contour
         if (rowPtr[x]) {

            points.push_back(cv::Point(x,y));
         }
      }
    }

通过调用 OpenCV 函数cv::fitLine可以轻松找到最合适的线:

   cv::Vec4f line;
   cv::fitLine(cv::Mat(points),line,
               CV_DIST_L2, // distance type
               0,          // not used with L2 distance 
               0.01,0.01); // accuracy

这为我们提供了线方程的参数,其形式为单位方向向量(cv::Vec4f的前两个值)和线上一个点的坐标(cv::Vec4f的后两个值)。 对于我们的示例,这些值对于方向向量是(0.83, 0.55),对于点坐标是(366.1, 289.1)。 最后两个参数指定线参数的要求精度。 注意,std::vector中包含的输入点根据函数需要在cv::Mat中传输。

通常,线方程将用于某些属性的计算中(在需要精确参数表示的情况下,校准是一个很好的例子)。 作为说明,并确保我们计算出正确的线,让我们在图像上绘制估计的线。 在这里,我们简单地绘制一个任意的黑色段,长度为 200 像素,厚度为 3 像素:

   int x0= line[2];        // a point on the line
   int y0= line[3];
   int x1= x0-200*line[0]; // add a vector of length 200
   int y1= y0-200*line[1]; // using the unit vector
   image= cv::imread("../road.jpg",0);
   cv::line(image,cv::Point(x0,y0),cv::Point(x1,y1),
            cv::Scalar(0),3);

结果如下图所示:

How to do it...

工作原理

将线拟合到一组点是数学中的经典问题。 OpenCV 的实现通过最小化每个点到线的距离之和来进行。 提出了几种距离函数,最快的选择是使用由CV_DIST_L2指定的欧几里德距离。 此选择对应于标准最小二乘法线拟合。 当离群点(即不属于该线的点)可能包括在点集中时,可以选择对远点影响较小的其他距离函数。 最小化基于 M 估计器技术,该技术迭代解决权重与距线的距离成反比的加权最小二乘问题。

使用此函数,还可以将线拟合到 3D 点集。 在这种情况下,输入为cv::Point3icv::Point3f的集合,而输出为std::Vec6f

更多

函数cv::fitEllipse使椭圆适合一组 2D 点。 它返回一个旋转的矩形(一个cv::RotatedRect实例),在该矩形内刻有椭圆。 在这种情况下,您将编写:

   cv::RotatedRect rrect= cv::fitEllipse(cv::Mat(points));
   cv::ellipse(image,rrect,cv::Scalar(0));

函数cv::ellipse是用于绘制计算的椭圆的函数。

提取组件的轮廓

图像通常包含对象的表示。 图像分析的目标之一是识别并提取这些对象。 在对象检测/识别应用中,第一步是生成一个二进制图像,该图像显示某些感兴趣的物体可能位于何处。 无论如何获得此二进制映射(例如,可以像我们在第 4 章中所做的那样从直方图反向投影中获得,或者从运动分析中获得(如我们将在第 10 章中学习的一样) ),下一步是提取此 1 和 0 集合中包含的对象。 例如,考虑一下我们在第 5 章中处理过的二进制形式的水牛图像,如下所示:

Extracting the components' contours

我们从一个简单的阈值操作中获得了这张图像,接着是打开和关闭形态过滤器的应用。 此秘籍将向您展示如何提取此类图像的对象。 更具体地说,我们将提取连通分量,即由二进制图像中一组连接的像素组成的形状。

操作步骤

OpenCV 提供了一个简单的函数,可以提取图像的已连接组件的轮廓。 它是cv::findContours函数:

   std::vector<std::vector<cv::Point>> contours;
   cv::findContours(image, 
      contours, // a vector of contours 
      CV_RETR_EXTERNAL, // retrieve the external contours
      CV_CHAIN_APPROX_NONE); // all pixels of each contours

输入显然是二进制图像。 输出是轮廓的向量,每个轮廓由cv::Points的向量表示。 这解释了为什么将输出参数定义为std::vectorsstd::vector的原因。 另外,指定了两个标志。 第一个表示仅需要外部轮廓,也就是说,将忽略对象中的孔; (“更多”部分将讨论其他选项)。 那里的第二个标志指定轮廓的格式。 使用当前选项,向量将列出轮廓中的所有点。 使用标记CV_CHAIN_APPROX_SIMPLE,时,水平,垂直或对角线轮廓仅包含端点。 其他标记将给出轮廓的更复杂的链近似,以获得更紧凑的表示。 对于前面的图像,获得了contours.size()给出的九个轮廓。 幸运的是,有一个非常方便的函数可以在图像(这里是白色图像)上绘制这些轮廓:

   // Draw black contours on a white image
   cv::Mat result(image.size(),CV_8U,cv::Scalar(255));
   cv::drawContours(result,contours,
      -1, // draw all contours
      cv::Scalar(0), // in black
      2); // with a thickness of 2

如果此函数的第三个参数为负值,则绘制所有轮廓。 否则,可以指定要绘制轮廓的索引。 结果显示在以下屏幕截图中:

How to do it...

工作原理

轮廓是通过简单的算法提取的,该算法包括系统扫描图像直到命中组件。 从组件的此起点开始,遵循其轮廓,在其边界上标记像素。 轮廓完成后,扫描将在最后一个位置继续进行,直到找到新的零件。

然后可以分别分析识别出的连接组件。 例如,如果可以获得有关感兴趣对象的预期大小的一些先验知识,则可以消除某些组件。 然后让我们为组件的周长使用最小值和最大值。 这是通过迭代轮廓向量并消除无效分量来完成的:

   // Eliminate too short or too long contours
   int cmin= 100;  // minimum contour length
   int cmax= 1000; // maximum contour length
   std::vector<std::vector<cv::Point>>::
              const_iterator itc= contours.begin();
   while (itc!=contours.end()) {

      if (itc->size() < cmin || itc->size() > cmax)
         itc= contours.erase(itc);
      else 
         ++itc;
   }

注意,由于std::vector中的每个擦除操作均为O(N),因此可以更有效地进行此循环。 但是考虑到此向量的大小,此操作并不太昂贵。 这次我们在原始图像上绘制其余轮廓,并获得以下结果:

How it works...

我们很幸运地找到了一个简单的标准,该标准使我们能够识别出该图像中所有感兴趣的物体。 在更复杂的情况下,需要对组件的属性进行更精细的分析。 这是下一个秘籍的对象。

更多

使用cv::findContours函数,还可以将所有闭合轮廓包括在二进制图中,包括由零件中的孔形成的轮廓。 这可以通过在函数调用中指定另一个标志来完成:

   cv::findContours(image, 
      contours, // a vector of contours 
      CV_RETR_LIST, // retrieve all contours
      CV_CHAIN_APPROX_NONE); // all pixels of each contours

通过此调用,可获得以下轮廓:

There's more...

注意在背景林中添加的额外轮廓。 也可以将这些轮廓组织成一个层次。 主要组件是父组件,其中的孔是其子组件,如果这些孔中有组件,它们将成为先前子组件的子组件,依此类推。 通过使用标志CV_RETR_TREE和如下获得此层次结构:

   std::vector<cv::Vec4i> hierarchy;
cv::findContours(image, 
      contours, // a vector of contours
      hierarchy, // hierarchical representation 
      CV_RETR_TREE, // retrieve all contours in tree format
      CV_CHAIN_APPROX_NONE); // all pixels of each contours

在这种情况下,每个轮廓在由四个整数组成的相同索引处具有一个相应的层次结构元素。 前两个整数给出相同级别的下一个和上一个轮廓的索引,后两个整数给出该轮廓的第一个子级和父级的索引。 负索引指示轮廓列表的结尾。 标志CV_RETR_CCOMP类似,但将层次结构限制在两个级别。

计算组件的形状描述符

连通组件通常对应于所描绘场景中某些对象的图像。 为了识别该对象或将其与其他图像元素进行比较,对组件执行一些测量以提取其某些特性可能很有用。 在本秘籍中,我们将看一下 OpenCV 中可用的一些形状描述符,这些描述符可用于描述连接组件的形状。

操作步骤

关于形状描述,许多 OpenCV 功能都可用。 我们将其中一些应用在前面的秘籍中提取的组件上。 特别地,我们将使用四个轮廓的向量,它们对应于我们先前确定的四个水牛。 在以下代码段中,我们在轮廓(contours[0]contours[3])上计算形状描述符,然后在轮廓(厚度为 1)的图像上绘制结果(厚度为 2)。 此图像显示在本节的末尾。

第一个是边界框,应用于右下角的组件:

   // testing the bounding box 
   cv::Rect r0= cv::boundingRect(cv::Mat(contours[0]));
   cv::rectangle(result,r0,cv::Scalar(0),2);

最小包围圈类似。 它应用于右上角的组件:

   // testing the enclosing circle 
   float radius;
   cv::Point2f center;
   cv::minEnclosingCircle(cv::Mat(contours[1]),center,radius);
   cv::circle(result,cv::Point(center),
              static_cast<int>(radius),cv::Scalar(0),2);

组件轮廓的多边形近似计算如下(在左侧组件上):

   // testing the approximate polygon
   std::vector<cv::Point> poly;
   cv::approxPolyDP(cv::Mat(contours[2]),poly,
                    5,     // accuracy of the approximation
                    true); // yes it is a closed shape

在图像上绘制结果需要做更多的工作:

   // Iterate over each segment and draw it
   std::vector<cv::Point>::const_iterator itp= poly.begin();
   while (itp!=(poly.end()-1)) {
      cv::line(result,*itp,*(itp+1),cv::Scalar(0),2);
      ++itp;
   }
   // last point linked to first point
   cv::line(result,
            *(poly.begin()),
            *(poly.end()-1),cv::Scalar(20),2);

凸包是多边形近似的另一种形式:

   // testing the convex hull
   std::vector<cv::Point> hull;
   cv::convexHull(cv::Mat(contours[3]),hull);

最后,矩的计算是另一个强大的描述符:

   // testing the moments

   // iterate over all contours
   itc= contours.begin();
   while (itc!=contours.end()) {

      // compute all moments
      cv::Moments mom= cv::moments(cv::Mat(*itc++));

      // draw mass center
      cv::circle(result,
         // position of mass center converted to integer
         cv::Point(mom.m10/mom.m00,mom.m01/mom.m00),
         2,cv::Scalar(0),2); // draw black dot
   }

生成的图像如下:

How to do it...

工作原理

组件的边界框可能是在图像中表示和定位组件的最紧凑的方式。 定义为完全包含形状的最小尺寸的直立矩形。 比较盒子的高度和宽度会给出有关对象垂直或水平方向的指示(例如,将汽车的图像与行人的图像区分开)。 当仅需要组件尺寸和位置时,通常使用最小包围圈。

当人们想要操纵类似于组件形状的更紧凑的表示形式时,组件的多边形近似很有用。 通过指定精度参数创建该参数,该精度参数给出了形状与其简化的多边形之间的最大可接受距离。 它是cv::approxPolyDP函数中的第四个参数。 结果是对应于多边形顶点的cv::Point向量。 要绘制此多边形,我们需要遍历向量,并通过在它们之间画一条线将每个点与下一个点链接。

形状的凸包或凸包络是包含形状的最小凸多边形。 可以将其可视化为松紧带在组件周围放置时的形状。

矩是形状结构分析中常用的数学实体。 OpenCV 定义了一个数据结构,该数据结构封装了形状的所有计算矩。 它是cv::moments函数返回的对象。 我们简单地使用这种结构来获得每个分量的质心,这里是从前三个空间矩计算得出的。

更多

其他结构特性可以使用可用的 OpenCV 函数来计算。 函数cv::minAreaRect计算最小的封闭旋转矩形。 函数cv::contourArea估计轮廓的面积(内部像素数)。 函数cv::pointPolygonTest确定一个点在轮廓内部还是外部,cv::matchShapes测量两个轮廓之间的相似度。

八、检测和匹配兴趣点

在本章中,我们将介绍:

  • 检测哈里斯角点
  • 检测 FAST 特征
  • 检测尺度不变的 SURF 特征
  • 描述 SURF 特征

简介

在计算机视觉中,兴趣点的概念也被称为关键点特征点, 在对象识别,图像配准,视觉跟踪,3D 重建等方面存在许多问题。 它依赖于这样的想法:与其在整体上查看图像,不如在图像中选择一些特殊点并对这些点执行局部分析可能会比较有利。 只要在感兴趣的图像中检测到足够数量的此类点并且这些点是可以准确定位的独特且稳定的特征,这些方法就可以很好地工作。 本章将介绍一些兴趣点检测器,并向您展示如何在图像匹配中使用它们。

检测哈里斯角点

在图像中搜索有趣的特征点时,出现角点是一个有趣的解决方案。 它们确实是可以轻松定位在图像中的局部特征,此外,它们应在人造对象的场景中比比皆是(它们是由墙,门,窗户,桌子等产生的)。 角点也很有趣,因为它们是二维特征,因为它们位于两个边缘的交界处,所以可以准确地定位(即使以亚像素精度)。 这与位于物体的均匀区域或轮廓上的点相反,并且很难将其精确地重复定位在同一物体的其他图像上。

哈里斯特征检测器是检测图像角点的经典方法。 我们将在本秘籍中探讨该运算符。

操作步骤

用于检测哈里斯角点的基本 OpenCV 函数称为cv::cornerHarris ,易于使用。 您在输入图像上调用它,结果是浮点图像,该图像给出了每个像素位置的角点强度。 然后按顺序将阈值应用于此输出图像,以获得一组检测到的角点。 这是通过以下代码完成的:

   // Detect Harris Corners
   cv::Mat cornerStrength;
   cv::cornerHarris(image,cornerStrength,
                3,     // neighborhood size
                3,     // aperture size
                0.01); // Harris parameter

   // threshold the corner strengths
   cv::Mat harrisCorners;
   double threshold= 0.0001; 
   cv::threshold(cornerStrength,harrisCorners,
                 threshold,255,cv::THRESH_BINARY);

这是原始图片:

How to do it...

结果是下面的屏幕快照中显示的二进制映射图像,该图像被反转以更好地查看(也就是说,我们使用cv::THRESH_BINARY_INV而不是cv::THRESH_BINARY将检测到的角变成黑色):

How to do it...

从前面的函数调用中,我们观察到该兴趣点检测器需要几个参数(这些参数将在下一节中进行说明),这可能会使调整变得困难。 另外,所获得的角图包含许多角像素群集,这与我们希望检测定位良好的点的事实相矛盾。 因此,我们将尝试通过定义自己的类来检测哈里斯角点来改进角点检测方法。

该类使用其默认值以及相应的获取器和设置器方法(此处未显示)封装哈里斯参数。

class HarrisDetector {

  private:

     // 32-bit float image of corner strength
     cv::Mat cornerStrength;
     // 32-bit float image of thresholded corners
     cv::Mat cornerTh;
     // image of local maxima (internal)
     cv::Mat localMax;
     // size of neighborhood for derivatives smoothing
     int neighbourhood; 
     // aperture for gradient computation
     int aperture; 
     // Harris parameter
     double k;
     // maximum strength for threshold computation
     double maxStrength;
     // calculated threshold (internal)
     double threshold;
     // size of neighborhood for non-max suppression
     int nonMaxSize; 
     // kernel for non-max suppression
     cv::Mat kernel;

  public:

     HarrisDetector() : neighbourhood(3), aperture(3), 
                        k(0.01), maxStrength(0.0), 
                        threshold(0.01), nonMaxSize(3) {

        // create kernel used in non-maxima suppression
        setLocalMaxWindowSize(nonMaxSize);
     }

为了检测图像上的哈里斯角,我们执行两个步骤。 首先,计算每个像素的哈里斯值:

     // Compute Harris corners
     void detect(const cv::Mat& image) {

        // Harris computation
        cv::cornerHarris(image,cornerStrength,
                neighbourhood,// neighborhood size
                aperture,     // aperture size
                k);           // Harris parameter

        // internal threshold computation
        double minStrength; // not used
        cv::minMaxLoc(cornerStrength,
             &minStrength,&maxStrength);

        // local maxima detection
        cv::Mat dilated;  // temporary image
        cv::dilate(cornerStrength,dilated,cv::Mat());
        cv::compare(cornerStrength,dilated,
                    localMax,cv::CMP_EQ);
     }

接下来,基于指定的阈值获得特征点。 由于哈里斯的可能值范围取决于其参数的特定选择,因此将阈值指定为质量级别,该质量级别定义为图像中计算出的最大哈里斯值的一部分:

     // Get the corner map from the computed Harris values
     cv::Mat getCornerMap(double qualityLevel) {

        cv::Mat cornerMap;

        // thresholding the corner strength
        threshold= qualityLevel*maxStrength;
        cv::threshold(cornerStrength,cornerTh,
                      threshold,255,cv::THRESH_BINARY);

        // convert to 8-bit image
        cornerTh.convertTo(cornerMap,CV_8U);

        // non-maxima suppression
        cv::bitwise_and(cornerMap,localMax,cornerMap);

        return cornerMap;
     }

此方法返回检测到的特征的二进制角图。 哈里斯特征的检测已被分成两种方法,这一事实使我们能够以不同的阈值(直到获得适当数量的特征点)测试检测,而无需重复进行昂贵的计算。 也可能以cv::Pointstd::vector形式获得哈里斯特征:

     // Get the feature points from the computed Harris values
     void getCorners(std::vector<cv::Point> &points, 
                     double qualityLevel) {

        // Get the corner map
        cv::Mat cornerMap= getCornerMap(qualityLevel);
        // Get the corners
        getCorners(points, cornerMap);
     }

     // Get the feature points from the computed corner map
     void getCorners(std::vector<cv::Point> &points, 
                     const cv::Mat& cornerMap) {

        // Iterate over the pixels to obtain all features
        for( int y = 0; y < cornerMap.rows; y++ ) {

           const uchar* rowPtr = cornerMap.ptr<uchar>(y);

           for( int x = 0; x < cornerMap.cols; x++ ) {

              // if it is a feature point
              if (rowPtr[x]) {

                 points.push_back(cv::Point(x,y));
              }
           } 
        }
     }

此类通过添加非最大值抑制步骤来改进哈里斯角的检测,这将在下一部分中进行说明。 现在可以使用cv::circle函数将检测到的点绘制在图像上,如以下方法所示:

     // Draw circles at feature point locations on an image
     void drawOnImage(cv::Mat &image, 
        const std::vector<cv::Point> &points, 
        cv::Scalar color= cv::Scalar(255,255,255), 
        int radius=3, int thickness=2) {

        std::vector<cv::Point>::const_iterator it= 
                                       points.begin();

        // for all corners
        while (it!=points.end()) {

           // draw a circle at each corner location
           cv::circle(image,*it,radius,color,thickness);
           ++it;
        }
     }

使用此类,哈里斯点的检测如下完成:

   // Create Harris detector instance
   HarrisDetector harris;
    // Compute Harris values
   harris.detect(image);
    // Detect Harris corners
   std::vector<cv::Point> pts;
   harris.getCorners(pts,0.01);
   // Draw Harris corners
   harris.drawOnImage(image,pts);

结果如下图:

How to do it...

工作原理

为了定义图像中角点的概念,哈里斯在假定的兴趣点周围的小窗口中查看了平均方向强度变化。 如果我们考虑位移向量(u, v),则平均强度变化由下式给出:

How it works...

该求和是在所考虑像素周围的定义邻域上进行的(该邻域的大小对应于cv::cornerHarris函数中的第三个参数)。 然后可以在所有可能的方向上计算该平均强度变化,这导致将角定义为在多个方向上平均变化高的点。 根据这个定义,哈里斯检验如下进行。 我们首先获得最大平均强度变化的方向。 接下来,检查正交方向上的平均强度变化是否也很高。 如果是这样,那么我们有一个角落。

从数学上讲,可以使用泰勒展开式通过使用前面公式的近似值来测试此条件:

How it works...

然后以矩阵形式重写:

How it works...

该矩阵是协方差矩阵,它描述了各个方向上强度变化的速率。 此定义涉及通常使用 Sobel 运算符计算的图像的一阶导数。 这是 OpenCV 实现的情况,函数的第四个参数与用于计算 Sobel 过滤器的孔径相对应。 可以看出,协方差矩阵的两个特征值给出了最大的平均强度变化和正交方向的平均强度变化。 然后得出结论,如果这两个特征值较低,则我们处于相对同质的区域。 如果一个特征值高而另一个特征值低,则我们必须处于边缘。 最后,如果两个特征值都很高,那么我们将处于角点位置。 因此,要被接受为角点的条件是协方差矩阵的最小特征值高于给定阈值。

哈里斯角点算法的原始定义使用了特征分解理论的某些属性,以避免显式计算特征值的成本。 这些属性是:

  • 矩阵特征值的乘积等于其行列式
  • 矩阵的特征值之和等于矩阵对角线的总和(也称为矩阵的迹线

然后,我们可以通过计算以下分数来验证两个特征值是否较高:

How it works...

仅当两个特征值也都很高时,才能轻松验证该分数确实很高。 这是由cv::cornerHarris函数在每个像素位置计算的分数。 k的值被指定为函数的第五个参数。 可能很难确定此参数的最佳值。 然而,实际上,已经表明,在 0.05 至 0.5 范围内的值通常给出良好的结果。

为了提高检测结果,上一节中描述的类添加了一个附加的非最大值抑制步骤。 这里的目标是排除与他人相邻的哈里斯角。 因此,要被接受,哈里斯角不仅必须具有高于指定阈值的分数,而且还必须是局部最大值。 通过使用一个简单的技巧来测试这种情况,该技巧包括通过我们的detect方法扩展哈里斯评分图像:

        cv::dilate(cornerStrength,dilated,cv::Mat());

由于膨胀将每个像素值替换为所定义邻域中的最大值,因此,唯一不会被修改的点就是局部最大值,这是通过以下相等性测试验证的:

        cv::compare(cornerStrength,dilated, 
                    localMax,cv::CMP_EQ);

因此,localMax矩阵仅在局部最大值位置为真(即非零)。 然后,在getCornerMap方法中使用它来抑制所有非最大特征(使用cv::bitwise_and函数的 )。

更多

可以对原始的哈里斯角点算法进行其他改进。 本节描述了 OpenCV 中的另一个角检测器,该角检测器扩展了哈里斯检测器,以使其角更均匀地分布在整个图像上。 正如我们将看到的,该运算符在新的 OpenCV 2 通用接口中用于特征检测器。

值得跟踪的良好特征

随着浮点处理器的出现,为避免特征值分解而引入的数学简化变得可以忽略不计,因此,可以基于显式计算的特征值进行哈里斯的检测。 原则上,此修改不应显着影响检测结果,但应避免使用任意k参数。

第二修改解决了特征点聚类的问题。 实际上,尽管引入了局部极大值条件,兴趣点仍倾向于在整个图像上分布不均,从而在高度纹理化的位置显示出浓度。 该问题的解决方案是在两个兴趣点之间施加最小距离。 这可以通过以下算法来实现。 从具有最强哈里斯分数的点(即具有最大最小特征值)开始,仅当兴趣点至少位于距已接受点的给定距离处时,才接受它们。 此解决方案在 OpenCV 中通过函数cv::goodFeaturesToTrack来实现,因为它检测到的特征可以用作视觉跟踪应用中的良好起点。 它被称为如下:

   // Compute good features to track
   std::vector<cv::Point2f> corners;
   cv::goodFeaturesToTrack(image,corners,
      500,   // maximum number of corners to be returned
      0.01,   // quality level
      10);   // minimum allowed distance between points

除了质量级别阈值和兴趣点之间的最小容许距离外,该函数还使用要返回的最大点数(这是可能的,因为按强度顺序接受点)。 前面的函数调用产生以下结果:

Good features to track

这种方法增加了检测的复杂性,因为它要求按照兴趣点的哈里斯分数对兴趣点进行排序,但同时也明显改善了兴趣点在图像上的分布。 请注意,此函数还包括一个可选标志,以请求使用经典角点分数定义(使用协方差矩阵的行列式和轨迹)检测哈里斯角。

特征检测器通用接口

OpenCV 2 已为其不同的兴趣点检测器引入了新的通用接口。 该接口可轻松测试同一应用中的不同兴趣点检测器。

该接口定义了一个Keypoint类,该类封装了每个检测到的特征点的属性。 对于哈里斯角点,仅关键点的位置相关。 秘籍“检测尺度不变的 SURF 点”将讨论可能与关键点相关的其他属性。

cv::FeatureDetector抽象类基本上强加了具有以下签名的detect操作的存在:

   void detect( const Mat& image, vector<KeyPoint>& keypoints,
                const Mat& mask=Mat() ) const;

   void detect( const vector<Mat>& images,
                vector<vector<KeyPoint> >& keypoints,
                const vector<Mat>& masks=
                                   vector<Mat>() ) const;

第二种方法允许在图像向量中检测兴趣点。 该类还包括其他方法来在文件中读取和写入检测到的点。

cv::goodFeaturesToTrack函数具有一个名为cv::GoodFeatureToTrackDetector的包装类,该包装类继承自cv::FeatureDetector类。 它的使用方式类似于我们对哈里斯Corners类所做的方式,即:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construction of the Good Feature to Track detector 
   cv::GoodFeaturesToTrackDetector gftt(
      500,   // maximum number of corners to be returned
      0.01,   // quality level
      10);   // minimum allowed distance between points
   // point detection using FeatureDetector method
   gftt.detect(image,keypoints);

结果与之前获得的结果相同,因为包装器最终会调用相同的函数。

另见

The classical article describing the Harris operator: C. Harris and M.J. Stephens, A combined corner and edge detector, by Alvey Vision Conference, pp. 147–152, 1988.

The article by J. Shi and C. Tomasi, Good features to track, Int. Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994 which introduced these features.

The article by K. Mikolajczyk and C. Schmid, Scale and Affine invariant interest point detectors, International Journal of Computer Vision, vol 60, no 1, pp. 63-86, 2004, which proposes a multi-scale and affine-invariant Harris operator.

检测 FAST 特征

哈里斯运算符基于两个垂直方向上的强度变化率,为角(或更一般地为兴趣点)提出了一个正式的数学定义。 尽管这构成了一个良好的定义,但它需要计算图像导数,这是一项昂贵的操作,尤其是考虑到兴趣点检测通常只是更复杂算法中的第一步这一事实。

在本秘籍中,我们介绍了另一个特征点运算符。 经过专门设计的该工具可以快速检测图像中的兴趣点。 接受或不接受关键点的决定仅基于几个像素比较。

操作步骤

使用 OpenCV 2 通用接口进行特征点检测可轻松部署任何特征点检测器。 本秘籍中介绍的一种是 FAST 检测器。 顾名思义,它旨在快速计算:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construction of the Fast feature detector object 
   cv::FastFeatureDetector fast(
           40); // threshold for detection  
   // feature point detection 
   fast.detect(image,keypoints);

请注意,OpenCV 还建议使用通用函数在图像上绘制关键点:

   cv::drawKeypoints(image,    // original image
      keypoints,                // vector of keypoints
      image,                   // the output image
      cv::Scalar(255,255,255), // keypoint color
      cv::DrawMatchesFlags::DRAW_OVER_OUTIMG); //drawing flag

通过指定所选的绘制标记,可以在输出图像上绘制关键点,从而产生以下结果:

How to do it...

一个有趣的选项是为关键点颜色指定一个负值。 在这种情况下,将为每个绘制的圆选择不同的随机颜色。

工作原理

与哈里斯点的情况一样,FAST(加速分段测试的特征)特征算法从构成“角点”的定义中得出。 这次,此定义基于假定特征点周围的图像强度。 接受关键点的决定是通过检查以候选点为中心的像素圆来完成的。 如果发现长度大于圆周长 3/4 的连续点弧,其中所有像素均与中心点的强度明显不同,则声明关键点。

这是一个可以快速计算的简单测试。 此外,该算法使用了其他技巧来进一步加快处理速度。 确实,如果我们首先测试圆上相隔 90o 的四个点(例如,顶部,底部,右侧和左侧点),则可以很容易地证明,要满足上述条件,这些点中的至少三个必须都比中央像素更亮或更暗。 如果不是这种情况,则可以立即拒绝该点,而无需检查圆周上的其他点。 这是一种非常有效的测试,因为在实践中,大多数图像点将被此简单的 4 比较测试所拒绝。

原则上,检查像素圆的半径应该是该方法的参数。 但是,已经发现,实际上,半径为 3 既可以得到良好的结果,又可以得到很高的效率。 然后,在圆的圆周上要考虑 16 个像素,如下所示:

16 1 2
15 3
14 4
13 0 5
12 6
11 7
10 9 8

用于预测试的四个点是像素 1、5、9 和 13。

至于哈里斯特征,通常最好在发现的角点处执行非最大值抑制。 因此,需要定义角点强度度量。 可以考虑几种替代方法,以下是保留的一种方法。 角点的强度由中心像素与所标识的连续弧上的像素之间的绝对差之和得出。

该算法可实现非常快的兴趣点检测,因此在考虑速度时应使用该算法。 例如,在视觉跟踪应用中经常是这种情况,在视觉跟踪应用中,必须在具有高帧速率的视频序列中跟踪几个点。

另见

The article by E. Rosten and T. Drummond, Machine learning for high-speed corner detection, in In European Conference on Computer Vision, pp. 430-443, 2006 that describes the FAST feature algorithm in detail.

检测尺度不变的 SURF 特征

当尝试在不同图像上匹配特征时,我们经常会遇到缩放比例变化的问题。 即,可以在距感兴趣对象不同距离处拍摄要分析的不同图像,因此,将以不同大小对这些对象进行拍照。 如果我们尝试使用固定大小的邻域匹配来自两个图像的相同特征,则由于缩放比例的变化,它们的强度模式将不匹配。

为了解决这个问题,计算机视觉中引入了尺度不变特征的概念。 这里的主要思想是使比例因子与每个检测到的特征点相关。 近年来,已经提出了几种尺度不变的特征,该秘籍提出了其中之一,即 SURF 特征。 SURF 代表加速鲁棒特征,正如我们将看到的那样,它们不仅是尺度不变的特征,而且还具有非常高效地进行计算的优势。

操作步骤

SURF 特征的 OpenCV 实现也使用cv::FeatureDetector接口。 因此,这些特征的检测与我们在本章前面的秘籍中展示的类似:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construct the SURF feature detector object
   cv::SurfFeatureDetector surf(
       2500.); // threshold 
   // Detect the SURF features
   surf.detect(image,keypoints);

要绘制这些特征,我们再次使用cv::drawKeypoints OpenCV 函数,但这一次使用另一个遮罩,因为我们还想显示与每个特征相关的比例因子:

   // Draw the keypoints with scale and orientation information
   cv::drawKeypoints(image,      // original image
      keypoints,               // vector of keypoints
      featureImage,            // the resulting image
      cv::Scalar(255,255,255),   // color of the points
      cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS); //flag

通过绘图函数生成的具有检测到的特征的最终图像为:

How to do it...

从前面的屏幕截图中可以看出,由于使用DRAW_RICH_KEYPOINTS标志而产生的关键点圆的大小与每个特征的计算比例成比例。 SURF 算法还将方向与每个特征相关联,以使它们旋转不变。 该方向由每个绘制的圆内的径向线表示。

如果我们以相同比例但以不同比例拍摄另一张照片,则特征检测将导致:

How to do it...

通过仔细观察检测到的关键点,可以看出相应圆的大小变化与比例变化成比例。 例如,考虑右上方窗口的底部。 在两幅图像中,在该位置均检测到 SURF 特征,并且两个相应的圆圈(大小不同)包含相同的视觉元素。 当然,并非所有特征都如此,但是正如我们将在下一章中发现的那样,重复率足够高,可以在两个图像之间实现良好的匹配。

工作原理

在第 6 章中,我们了解了可以使用高斯过滤器来估计图像的图像导数。 这些过滤器使用σ参数来定义核的孔径(大小)。 如我们所见,此σ对应于用于构造过滤器的高斯函数的方差,然后隐式定义了评估导数的标度。 实际上,具有较大σ值的过滤器可以平滑图像的精细细节。 这就是为什么我们可以说它的运行规模更大的原因。

现在,如果我们使用不同比例的高斯过滤器计算给定图像点的拉普拉斯,那么将获得不同的值。 查看不同比例因子的过滤器响应的演变,我们获得了一条曲线,该曲线最终在σ值处达到最大值。 如果我们针对以两个不同比例拍摄的同一物体的两个图像提取该最大值,则这两个σ最大值的比率将对应于拍摄图像的比例。 这一重要观察是尺度不变特征提取过程的核心。 也就是说,应将尺度不变特征检测为空间空间(图像中)和尺度空间(从不同尺度应用的导数过滤器获得)的局部最大值。

SURF 通过以下步骤来实现此想法。 首先,为了检测特征,在每个像素处计算 Hessian 矩阵。 该矩阵测量函数的局部曲率,并定义为:

How it works...

该矩阵的行列式给出了该曲率的强度。 因此,该想法是将角定义为具有高局部曲率(即,在一个以上方向上的高变化)的图像点。 由于它是由二阶导数组成的,因此可以使用不同比例σ的拉普拉斯高斯核来计算该矩阵。 然后,该 Hessian 成为三个变量的函数:H(x, y, σ)。 因此,当该 Hessian 的行列式在空间和尺度空间均达到局部最大值时(即需要执行 3x3x3 非极大值抑制),便声明了尺度不变特征。 但是,该行列式必须具有cv::SurfFeatureDetector类类的构造器中第一个参数所指定的最小值。

所有这些导数在不同尺度下的计算在计算上是昂贵的。 SURF 算法的目标是使此过程尽可能高效。 这是通过使用仅包含少量整数加法的近似高斯核来实现的。 它们具有以下结构:

How it works...

左侧的核用于估计混合的二阶导数,而右侧的核用于估计垂直方向的二阶导数。 该第二核的旋转形式估计水平方向的二阶导数。 最小的核的大小为9x9像素,对应于σ≈1.2。 逐渐增加了大小的核。 可以通过 SURF 类的其他参数指定所应用的确切过滤器数量。 默认情况下,使用 12 种不同大小的核(最大大小为99x99)。 请注意,使用积分图像的事实保证了可以通过仅使用 3 个加法来计算每个瓣内的和,而与过滤器的大小无关。

一旦确定了局部最大值,就可以通过比例尺和图像空间中的插值获得每个检测到的兴趣点的精确位置。 然后,结果是一组以亚像素精度定位的特征点,并且与之关联了比例值。

更多

SURF 算法已被开发为另一种著名的尺度不变特征检测器 SIFT(用于尺度不变特征变换)的有效变体。 SIFT 还可以将特征检测为图像和比例尺空间中的局部最大值,但使用 Laplacian 过滤器响应而不是 Hessian 行列式。 使用不同的高斯过滤器来计算不同比例的拉普拉斯算子。 OpenCV 有一个包装器类,用于检测这些特征,并且其调用方式与 SURF 特征类似:

   // vector of keypoints
   std::vector<cv::KeyPoint> keypoints;
   // Construct the SURF feature detector object
   cv::SiftFeatureDetector sift(
      0.03,  // feature threshold
      10.);  // threshold to reduce
              // sensitivity to lines
   // Detect the SURF features
   sift.detect(image,keypoints);

结果也非常相似:

There's more...

但是,由于特征点的计算基于浮点核,因此通常认为在空间和比例尺上的特征定位方面更准确。 尽管出于同样的原因,它在计算上也更加昂贵。

另见

The article SURF: Speeded Up Robust Features by H. Bay, A. Ess, T. Tuytelaars and L. Van Gool in Computer Vision and Image Understanding, vol. 110, No. 3, pp. 346--359, 2008 that describes the SURF features.

The pioneer work by D. Lowe, Distinctive Image Features from Scale Invariant Features in International Journal of Computer Vision, Vol. 60, No. 2, 2004, pp. 91-110, describing the SIFT algorithm.

描述 SURF 特征

在前面的秘籍中讨论的 SURF 算法为每个检测到的特征定义位置和比例。 该比例因子可用于定义特征点周围的窗口大小,以使定义的邻域将包含相同的视觉信息,而不管特征所属的对象已被描绘成什么比例。 另外,包含在该邻域中的视觉信息可用于表征特征点,以使其与其他特征区分开。

本秘籍将向您展示如何使用紧凑的描述符来描述特征点的邻域。 在特征匹配中,特征描述符通常是描述特征点的 N 维向量,理想情况下以不变的方式改变光照和较小的透视变形。 另外,可以使用简单的距离度量(例如,欧几里得距离)来比较好的描述符。 因此,它们构成了用于特征匹配算法的强大工具。

操作步骤

以下代码是一种类似于用于特征检测的模式。 OpenCV 2 提出了一个通用类,该通用类定义了一个公共接口,用于提取可用的各种特征点描述符。 为了遵循前面的方法,这里我们使用 SURF 算法中提出的方法。 根据从特征检测获得的cv::Keypoint实例的std::vector,获得以下描述符:

   // Construction of the SURF descriptor extractor 
   cv::SurfDescriptorExtractor surfDesc;
   // Extraction of the SURF descriptors
   cv::Mat descriptors1;
   surfDesc.compute(image1,keypoints1,descriptors1);

结果是一个矩阵(即cv::Mat实例),它将包含与关键点向量中的元素数量一样多的行。 这些行中的每行都是一个 N 维描述符向量。 对于 SURF 描述符,默认情况下,其大小为 64。此向量表示特征点周围的强度模式。 两个特征点越相似,它们的描述符向量应该越接近。

这些描述符在图像匹配中特别有用。 例如,假设要对同一场景的两个图像进行匹配。 这可以通过首先检测每个图像上的特征,然后提取这些特征的描述符来完成。 然后将第一图像中的每个特征描述符向量与第二图像中的所有特征描述符进行比较。 然后将获得最佳分数(即,两个向量之间的最小距离)的对作为该特征的最佳匹配。 对第一张图片中的所有特征重复此过程。 这是已在 OpenCV 中实现为cv::BruteForceMatcher的最基本方案。 它的用法如下:

   // Construction of the matcher 
   cv::BruteForceMatcher<cv::L2<float>> matcher;
   // Match the two image descriptors
   std::vector<cv::DMatch> matches;
   matcher.match(descriptors1,descriptors2, matches);

此类是cv::DescriptorMatcher类的子类,为不同的匹配策略定义了公共接口。 结果是cv::DMatch实例的向量,该向量是用来表示匹配对的结构。 本质上,cv::DMatch数据结构包含一个第一索引,该索引指向描述符的第一向量中的元素,以及一个第二索引,其指向描述符第二向量中的匹配特征。 它还包含一个表示两个匹配描述符之间距离的实数值。 此距离值用于比较两个cv::DMatch实例的operator<定义。

为了可视化匹配操作的结果,OpenCV 提供了一种绘制函数,该函数可以生成由两个输入图像连接而成的图像,并且在其上的匹配点由一条线链接。 在前面的秘籍中,我们为第一个图像获得了 340 个 SURF 点。 然后,暴力破解方法将产生相同数量的比赛。 在图像上绘制所有这些线会使结果不可读。 因此,我们将仅显示距离最小的 25 个匹配项。 通过使用std::nth_element可以轻松实现此目的,该工具将按排序顺序将第n个元素放置在第n个位置,而所有较小的元素都放置在该元素之前。 完成此操作后,将清除载体的剩余元素:

   std::nth_element(matches.begin(),    // initial position
       matches.begin()+24, // position of the sorted element
       matches.end());     // end position
   // remove all elements after the 25th
   matches.erase(matches.begin()+25, matches.end()); 

回想一下前面的代码是有效的,因为在cv::DMatch类中定义了operator<。 然后,可以通过以下调用将这 25 个匹配项可视化:

   cv::Mat imageMatches;
   cv::drawMatches(
     image1,keypoints1, // 1st image and its keypoints
     image2,keypoints2, // 2nd image and its keypoints
     matches,            // the matches
     imageMatches,      // the image produced
     cv::Scalar(255,255,255)); // color of the lines

产生以下图像:

How to do it...

可以看出,大多数匹配正确地将左侧的点与右侧的相应图像点链接在一起。 由于观察到的建筑物具有对称的立面,这使得某些局部匹配不明确(最上面的匹配是特征不正确的示例之一),因此人们可能会注意到一些错误。

工作原理

好的特征描述符必须对光照的微小变化,视点以及图像噪声的存在保持不变。 因此,它们通常基于局部强度差异。 SURF 描述符就是这种情况,它在关键点周围的较大邻域内应用以下简单核:

How it works...

第一个简单地测量水平方向上的局部强度差异(称为dx),第二个简单地测量垂直方向上的局部强度差异(称为dy)。 用于提取描述符向量的邻域的大小定义为特征比例因子的 20 倍(即20σ)。 然后将这个正方形区域分成4x4个较小的正方形子区域。 对于每个子区域,在5x5规则间隔的位置(核大小为)计算核响应dxdy。 将所有这些响应汇总如下,以便为每个子区域提取四个描述符值:

How it works...

由于存在4x4 = 16子区域,因此我们总共有 64 个描述符值。 注意,为了更加重视靠近关键点的相邻像素值,核响应由以关键点位置为中心的高斯加权(σ = 3.3)。

dxdy响应也用于估计特征的方向。 这些值是在半径为的圆形邻域中,以规则的σ间隔定期计算的(核尺寸为)。 对于给定的方向,将某个角度间隔(π / 3)内的响应求和,并将给出最长向量的方向定义为主导方向。

利用 SURF 特征和描述符,可以实现尺度不变的匹配。 以下示例显示了包含两张不同比例图像的匹配对中的 25 个最佳匹配:

How it works...

更多

SIFT 算法还定义了自己的描述符。 它基于在考虑的关键点的尺度上计算出的梯度大小和方向。 对于 SURF 描述符,将关键点的缩放邻域划分为4x4子区域。 对于这些区域中的每个区域,都将构建一个 8 方向梯度直方图(由其大小和以关键点为中心的全局高斯窗口加权)。 因此,描述符向量由这些直方图的条目组成。 每个直方图有4x4区域和 8 个箱子,这导致长度为 128 的描述符。

对于特征检测,SURF 和 SIFT 描述符之间的差异主要是速度和准确率。 由于 SURF 描述符主要基于强度差异,因此它们的计算速度更快。 但是,通常认为 SIFT 描述符在找到正确的匹配特征时更为准确。

另见

有关 SURF 和 SIFT 特征的更多信息,请参考前面的秘籍。

九、估计图像中的投影关系

在本章中,我们将介绍:

  • 校准相机
  • 计算图像对的基本矩阵
  • 使用随机样本共识来匹配图像
  • 计算两个图像之间的单应性

简介

通常使用数码相机生成图像,该数码相机通过将光线投射到穿过其镜头的图像传感器上来捕获场景。 通过将 3D 场景的投影投影到 2D 平面上而形成图像的事实强加了一个场景与其图像之间以及同一场景的不同图像之间的重要关系。 投影几何是用于以数学术语描述和表征图像形成过程的工具。 在本章中,您将学习多视图图像中存在的一些基本投影关系,以及如何在计算机视觉编程中使用它们。 我们还将继续在上一章的最终秘籍中发起的关于两视图特征匹配的讨论。 您将学习改善匹配结果的新策略。 但是在开始秘籍之前,让我们探索与场景投影和图像形成有关的基本概念。

图像形成

从根本上说,自摄影开始以来,用于生成图像的过程就没有改变。 摄像机通过正面光圈捕获来自观察场景的光,并且捕获的光线撞击图像平面(或图像传感器)位于相机背面。 另外,镜头用于聚集来自不同场景元素的光线。 下图说明了此过程:

Image formation

这里,do是从镜头到被观察物体的距离,di是从镜头到像平面的距离,f镜头的焦距。 这些数量与所谓的薄透镜公式相关:

Image formation

在计算机视觉中,可以通过多种方式简化此相机模型。 首先,我们可以通过考虑具有无限小光圈的相机来忽略镜头的影响,因为从理论上讲,这不会改变图像。 因此,仅考虑中央射线。 其次,由于大多数时候我们都有do >> di,所以我们可以假设图像平面位于焦距处。 最后,我们可以从系统的几何形状中注意到,平面上的图像是反转的。 通过简单地将像平面放置在镜头前,我们可以获得相同但直立的图像。 显然,这在物理上是不可行的,但是从数学角度来看,这是完全等效的。 这种简化的模型通常称为针孔照相机模型,其表示如下:

Image formation

从该模型,并使用相似三角形的定律,我们可以轻松得出基本的投影方程:

Image formation

因此,物体(高度为ho)的图像大小(hi)与其与相机的距离(do)成反比,而距离自然是真的。 该关系允许将 3D 场景点的图像的位置预测到相机的图像平面上。

校准相机

从本章的介绍中,我们了解到,在针孔模型下,相机的基本参数是其焦距和像平面的大小(定义相机的视场)。 同样,由于我们正在处理数字图像,因此图像平面上的像素数量是相机的另一个重要特征。 最后,为了能够在像素坐标中计算图像的场景点的位置,我们需要另外一条信息。 考虑到来自与图像平面正交的焦点的线,我们需要知道该线在哪个像素位置刺穿图像平面。 该点称为主要点。 逻辑上假设该主点位于图像平面的中心可能是合乎逻辑的,但实际上,这一点可能相差几个像素,具体取决于相机的制造精度。

相机校准是获取不同相机参数的过程。 显然可以使用相机制造商提供的规格,但是对于某些任务(例如 3D 重建),这些规格不够准确。 相机校准将通过向相机显示已知图案并分析获得的图像来进行。 然后,优化过程将确定解释观测结果的最佳参数值。 这是一个复杂的过程,但是由于 OpenCV 校准功能的可用性而变得容易。

操作步骤

为了校准摄像机,这个想法是向该摄像机显示一组场景点,这些场景点的 3D 位置已知。 然后,您必须确定这些点在图像上的投影位置。 显然,为了获得准确的结果,我们需要观察以下几点。 实现此目的的一种方法是拍摄具有许多已知 3D 点的场景图片。 一种更方便的方法是从一组某些 3D 点的不同视点拍摄几张图像。 这种方法比较简单,但除了计算内部摄像机参数外,还需要计算每个摄像机视图的位置,这是可行的。

OpenCV 建议使用棋盘图案来生成校准所需的 3D 场景点集。 该图案在每个正方形的角上创建点,并且由于该图案是平坦的,因此我们可以自由地假定板位于Z = 0,并且 X 和 Y 轴与网格对齐。 在这种情况下,校准过程仅包括从不同角度向摄像机显示棋盘图案。 这是校准图案图像的一个示例:

How to do it...

令人高兴的是,OpenCV 具有自动检测此棋盘图案角的函数。 您只需提供图像和所用棋盘的大小(垂直和水平内角点的数量)即可。 该函数将返回这些棋盘角在图像上的位置。 如果函数无法找到模式,则仅返回false

    // output vectors of image points
    std::vector<cv::Point2f> imageCorners;
    // number of corners on the chessboard
    cv::Size boardSize(6,4);
    // Get the chessboard corners
    bool found = cv::findChessboardCorners(image, 
                                 boardSize, imageCorners);

请注意,如果需要调整算法,则此函数接受其他参数,此处不再讨论。 还有一个函数可以用棋盘上的线依次画出检测到的角:

        //Draw the corners
        cv::drawChessboardCorners(image, 
                    boardSize, imageCorners, 
                    found); // corners have been found

在此处看到获得的图像:

How to do it...

连接这些点的线显示了在检测到的点的向量中列出这些点的顺序。 现在要校准相机,我们需要输入一组此类图像点以及相应 3D 点的坐标。 让我们将校准过程封装在CameraCalibrator类中:

class CameraCalibrator {

    // input points:
    // the points in world coordinates
    std::vector<std::vector<cv::Point3f>> objectPoints;
    // the point positions in pixels
    std::vector<std::vector<cv::Point2f>> imagePoints;
    // output Matrices
    cv::Mat cameraMatrix;
    cv::Mat distCoeffs;
   // flag to specify how calibration is done
   int flag;
   // used in image undistortion 
    cv::Mat map1,map2; 
   bool mustInitUndistort;

  public:
   CameraCalibrator() : flag(0), mustInitUndistort(true) {};

如前所述,如果我们方便地将参考框架放置在棋盘上,则可以轻松确定棋盘图案上点的 3D 坐标。 完成此操作的方法将棋盘图像文件名的向量作为输入:

// Open chessboard images and extract corner points
int CameraCalibrator::addChessboardPoints(
         const std::vector<std::string>& filelist, 
         cv::Size & boardSize) {

   // the points on the chessboard
    std::vector<cv::Point2f> imageCorners;
    std::vector<cv::Point3f> objectCorners;

   // 3D Scene Points:
   // Initialize the chessboard corners 
   // in the chessboard reference frame
   // The corners are at 3D location (X,Y,Z)= (i,j,0)
   for (int i=0; i<boardSize.height; i++) {
      for (int j=0; j<boardSize.width; j++) {

         objectCorners.push_back(cv::Point3f(i, j, 0.0f));
      }
    }

    // 2D Image points:
    cv::Mat image; // to contain chessboard image
    int successes = 0;
    // for all viewpoints
    for (int i=0; i<filelist.size(); i++) {

        // Open the image
        image = cv::imread(filelist[i],0);

        // Get the chessboard corners
        bool found = cv::findChessboardCorners(
                        image, boardSize, imageCorners);

        // Get subpixel accuracy on the corners
        cv::cornerSubPix(image, imageCorners, 
                  cv::Size(5,5), 
                  cv::Size(-1,-1), 
         cv::TermCriteria(cv::TermCriteria::MAX_ITER +
                          cv::TermCriteria::EPS, 
             30,      // max number of iterations 
             0.1));  // min accuracy

        //If we have a good board, add it to our data
        if (imageCorners.size() == boardSize.area()) {

            // Add image and scene points from one view
            addPoints(imageCorners, objectCorners);
            successes++;
        }

    }

   return successes;
}

第一个循环输入棋盘的 3D 坐标,此处以任意正方形尺寸单位指定。 相应的图像点是cv::findChessboardCorners函数提供的图像点。 这适用于所有可用的视点。 此外,为了获得更准确的图像点位置,可以使用函数cv::cornerSubPix,顾名思义,这些图像点将以亚像素精度定位。 由cv::TermCriteria对象指定的终止标准定义了最大迭代次数和子像素坐标中的最小精度。 达到这两个条件中的第一个条件将停止角点优化过程。

成功检测到一组棋盘角后,会将这些点添加到图像和场景点的向量中:

// Add scene points and corresponding image points
void CameraCalibrator::addPoints(const std::vector<cv::Point2f>& imageCorners, const std::vector<cv::Point3f>& objectCorners) {

   // 2D image points from one view
   imagePoints.push_back(imageCorners);          
   // corresponding 3D scene points
   objectPoints.push_back(objectCorners);
}

向量包含std::vector实例。 实际上,每个向量元素都是一个视图中点的向量。

一旦处理了足够数量的棋盘图像(因此有大量 3D 场景点/ 2D 图像点对应关系可用),我们就可以开始计算校准参数:

// Calibrate the camera
// returns the re-projection error
double CameraCalibrator::calibrate(cv::Size &imageSize)
{
   // undistorter must be reinitialized
   mustInitUndistort= true;

   //Output rotations and translations
    std::vector<cv::Mat> rvecs, tvecs;

   // start calibration
   return 
     calibrateCamera(objectPoints, // the 3D points
               imagePoints,  // the image points
               imageSize,    // image size
               cameraMatrix, // output camera matrix
               distCoeffs,   // output distortion matrix
               rvecs, tvecs, // Rs, Ts 
               flag);        // set options
}

实际上,十至二十个棋盘图像就足够了,但是这些图像必须是从不同角度,不同深度拍摄的。 此函数的两个重要输出是相机矩阵和失真参数。 相机矩阵将在下一节中介绍。 现在,让我们考虑一下失真参数。 到目前为止,我们已经提到了使用针孔相机模型可以忽略镜头的影响。 但这只有在用于捕获图像的镜头不会引入太严重的光学畸变的情况下才有可能。 不幸的是,低质量的镜头或焦距非常短的镜头经常出现这种情况。 您可能已经注意到,在我们用于示例的图像中,所示的棋盘图案明显失真。 矩形板的边缘在图像中弯曲。 还应注意,随着我们远离图像中心,这种失真变得更加重要。 这是使用鱼眼镜头观察到的典型失真,称为径向失真。 普通数码相机中使用的镜头不会表现出如此高的畸变程度,但是在此处使用的镜头中,这些畸变肯定不能忽略。

通过引入适当的模型可以补偿这些变形。 这个想法是用一组数学方程来表示由透镜引起的畸变。 一旦建立,这些方程式然后可以被还原以便消除图像上可见的失真。 幸运的是,可以在校准阶段与其他相机参数一起获得将校正失真的变换的确切参数。 完成此操作后,来自新校准相机的任何图像都可以保持不失真:

// remove distortion in an image (after calibration)
cv::Mat CameraCalibrator::remap(const cv::Mat &image) {

   cv::Mat undistorted;

   if (mustInitUndistort) { // called once per calibration

    cv::initUndistortRectifyMap(
      cameraMatrix,  // computed camera matrix
      distCoeffs,    // computed distortion matrix
      cv::Mat(),     // optional rectification (none) 
      cv::Mat(),     // camera matrix to generate undistorted
            image.size(),  // size of undistorted
            CV_32FC1,      // type of output map
            map1, map2);   // the x and y mapping functions

    mustInitUndistort= false;
   }

   // Apply mapping functions
   cv::remap(image, undistorted, map1, map2, 
      cv::INTER_LINEAR); // interpolation type

   return undistorted;
}

结果如下图:

How to do it...

如您所见,一旦图像变形,我们将获得一个常规的透视图。

工作原理

为了解释校准结果,我们需要回到介绍针孔相机模型的简介中的图。 更具体地说,我们想证明位置(X, Y, Z)的 3D 中的点与其在像素坐标中指定的摄像机上的图像(x, y)之间的关系。 让我们通过添加一个参考帧重绘此图,该参考帧位于投影的中心,如下所示:

How it works...

请注意,Y 轴指向下方,以获取与将图像原点放在左上角的常规约定兼容的坐标系。 先前我们了解到,点(X, Y, Z)将以(fX / Z, fY / Z)投影到图像平面上。 现在,如果要将此坐标转换为像素,则需要将 2D 图像位置分别除以像素宽度(px)和高度(py)。 我们注意到,通过将以世界单位给出的焦距f(通常是米或毫米)除以px,可以得到以(水平)像素表示的焦距。 然后让我们将此术语定义为fx。 类似地,将fy = f / py定义为以垂直像素为单位表示的焦距。 因此,完整的投影方程为:

How it works...

How it works...

回想一下(u0, v0)是添加到结果中的主要点,以便将原点移动到图像的左上角。 通过引入齐次坐标,这些方程可以矩阵形式重写,其中 2D 点由 3 个向量表示,而 3D 点由 4 个向量表示(额外坐标只是任意比例因子,从齐次 3 向量中提取 2D 坐标时需要删除)。 这是重写的射影方程式:

How it works...

第二矩阵是简单投影矩阵。 第一矩阵包括所有摄像机参数,这些参数称为摄像机的固有参数。 此3x3矩阵是cv::calibrateCamera函数返回的输出矩阵之一。 还有一个称为cv::calibrationMatrixValues的函数,可在给定校准矩阵的情况下返回固有参数的值。

更一般而言,当参考系不在相机的投影中心时,我们将需要添加旋转(3x3矩阵)和平移向量(3x1矩阵)。 这两个矩阵描述了必须应用于 3D 点的刚性变换,以便将其带回到相机参考系。 因此,我们可以用最一般的形式重写投影方程:

How it works...

请记住,在我们的校准示例中,参考框架位于棋盘上。 因此,必须为每个视图计算一个刚性变换(旋转和平移)。 这些在cv::calibrateCamera函数的输出参数列表中。 旋转和平移分量通常称为校准的外在参数,每个视图的旋转和平移分量都不同。 对于给定的相机/镜头系统,固有参数保持恒定。 通过基于 20 个棋盘图像的校准获得的测试相机的固有参数为fx = 167fy = 178u0 = 156v0 = 119。 这些结果是通过cv::calibrateCamera通过优化过程而获得的,该优化过程旨在找到使 3D 场景点的投影所计算出的预测图像点位置与实际图像点位置之间的差异最小的内在和外在参数, 如图所示。 校准期间指定的所有点的该差的总和称为重投影误差

为了纠正失真,OpenCV 使用多项式函数,将其应用于图像点,以将其移动到其未失真的位置。 默认情况下,使用 5 个系数。 也可以提供由 8 个系数组成的模型。 一旦获得了这些系数,就可以计算 2 个映射函数(一个x坐标,一个y坐标),这些映射函数将给出图像点在上的新的未失真位置。 图像失真。 这是通过函数cv::initUndistortRectifyMap计算的,函数cv::remap将输入图像的所有点重新映射到新图像。 注意,由于非线性变换,输入图像的某些像素现在落在输出图像的边界之外。 您可以扩大输出图像的大小以补偿这种像素损失,但是现在您将获得在输入​​图像中没有值的输出像素(然后它们将显示为黑色像素)。

更多

当知道相机固有参数的良好估计时,将其输入到cv::calibrateCamera函数可能会比较有利。 然后将它们用作优化过程中的初始值。 为此,您只需要添加标志CV_CALIB_USE_INTRINSIC_GUESS并将这些值输入到校准矩阵参数中即可。 还可以为主要点(CV_CALIB_FIX_PRINCIPAL_POINT)施加固定值,该值通常被假定为中心像素。 您还可以为焦距fxfyCV_CALIB_FIX_RATIO)设置固定比例,在这种情况下,您假设像素为正方形。

计算图像对的基本矩阵

先前的秘籍向您展示了如何恢复单个摄像机的投影方程。 在本秘籍中,我们将探讨在观看同一场景的两幅图像之间存在的投影关系。 这两个图像可以通过以下方式获得:在两个不同的位置移动相机以从两个视点拍摄照片,或者使用两个相机,每个相机拍摄不同的场景图像。 当这两个摄像机由刚性基准线分开时,我们使用术语立体视觉

准备

现在让我们考虑两台摄像机观察给定的场景点,如下所示:

Getting ready

我们了解到,通过跟踪将 3D 点X与相机中心连接的线,可以找到 3D 点X的图像x。 相反,我们在位置x处观察到的图像点可以位于 3D 空间中此线上的任何位置。 这意味着如果我们要在另一幅图像中找到给定图像点的对应点,则需要沿着这条线在第二个图像平面上的投影进行搜索。 该假想线称为点x对极线。 它定义了一个基本约束,必须满足两个相应的点,即,在另一个视图中,给定点的匹配必须位于该点的对极线上。 该极线的确切方向取决于两个摄像机的位置。 实际上,对极线的位置是两视图系统的几何特征。

可以从此两视图系统的几何结构得出的另一个观察结果是,所有对极线都通过同一点。 该点对应于一个摄像机中心在另一摄像机上的投影。 这个特殊点称为极点

从数学上可以看出,可以使用3x3矩阵来表示像点与其对应的对极线之间的关系,如下所示:

Getting ready

在射影几何中,一条 2D 线也由一个 3 向量表示。 它对应于满足等式l1' x' + l2' y' + l3' = 0的 2D 点集合(x', y')(上标表示这条线属于第二个图像)。 因此,称为基本矩阵的矩阵F将一个视图中的 2D 图像点映射到另一视图中的对极线。

操作步骤

可以通过求解一组方程来估计图像对的基本矩阵,该方程组涉及两个图像之间的一定数量的已知匹配点。 此类匹配的最小数量为 7。 使用上一章中的图像对,我们可以手动选择七个良好的匹配项(如以下屏幕截图所示)。 这些将通过cv::findFundementalMat OpenCV 函数用于计算基本矩阵。

How to do it...

如果我们在每个图像中都有图像点作为cv::keypoint实例,则首先需要将它们转换为cv::Point2f才能与cv::findFundementalMat一起使用。 为此,可以使用 OpenCV 函数:

   // Convert keypoints into Point2f
   std::vector<cv::Point2f> selPoints1, selPoints2;
   cv::KeyPoint::convert(keypoints1,selPoints1,pointIndexes1);
   cv::KeyPoint::convert(keypoints2,selPoints2,pointIndexes2);

两个向量selPoints1selPoints2包含两个图像中的对应点。 keypoints1keypoints2是在上一章中检测到的选定Keypoint实例。 然后,对cv::findFundementalMat函数的调用如下:

   // Compute F matrix from 7 matches
   cv::Mat fundemental= cv::findFundamentalMat(
      cv::Mat(selPoints1), // points in first image
      cv::Mat(selPoints2), // points in second image
      CV_FM_7POINT);       // 7-point method

视觉上验证基本矩阵有效性的一种方法是绘制某些选定点的对极线。 另一个 OpenCV 函数允许计算给定点集的对极线。 一旦计算出它们,就可以使用cv::line函数绘制它们。 以下代码行完成了这两个步骤(即,从左侧的点计算并绘制右侧图像中的对极线):

   // draw the left points corresponding epipolar 
   // lines in right image 
   std::vector<cv::Vec3f> lines1; 
   cv::computeCorrespondEpilines(
      cv::Mat(selPoints1), // image points 
      1,                   // in image 1 (can also be 2)
      fundemental, // F matrix
      lines1);     // vector of epipolar lines

   // for all epipolar lines
   for (vector<cv::Vec3f>::const_iterator it= lines1.begin();
       it!=lines1.end(); ++it) {

          // draw the line between first and last column
          cv::line(image2,
            cv::Point(0,-(*it)[2]/(*it)[1]),
            cv::Point(image2.cols,-((*it)[2]+
                      (*it)[0]*image2.cols)/(*it)[1]),
                      cv::Scalar(255,255,255));
   }

然后在以下屏幕截图中看到结果:

How to do it...

请记住,子极位于所有子极线的交点,并且是另一个相机中心的投影。 在前面的图像上可以看到该极点。 通常,极线在图像边界之外相交。 如果同时拍摄了两个图像,则该位置将是第一个摄像机可见的位置。 观察图像对,并花点时间说服自己这确实是有道理的。

工作原理

前面我们已经解释了,对于一个图像中的一个点,基本矩阵给出了在另一视图中应找到其对应点的直线方程。 如果点p的对应点(用均匀坐标表示)是p',并且如果F是两个视图之间的基本矩阵,那么由于p'位于对极线Fp上,我们具有:

How it works...

该方程式表示两个对应点之间的关系,被称为对极约束。 使用该方程式,可以使用已知匹配来估计矩阵的项。 由于F矩阵的条目被赋予比例因子,因此仅需要估计八个条目(第九个可以任意设置为 1)。 每次比赛都会贡献一个方程式。 因此,通过八个已知匹配,可以通过求解线性方程组的结果来完全估计矩阵。 将CV_FM_8POINT标志与cv::findFundamentalMat函数结合使用时,便会执行此操作。 注意,在这种情况下,可以(最好)输入八个以上的匹配项。 然后可以在均方意义上求解获得的线性方程组的超定系统。

为了估计基本矩阵,还可以利用附加约束。 在数学上,F矩阵将 2D 点映射到 1D 直线铅笔(即,在公共点处相交的线)。 所有这些对极线都经过此唯一点(极点)的事实对矩阵施加了约束。 该约束将估计基本矩阵所需的匹配数减少到七个。 不幸的是,在这种情况下,方程组变为非线性,最多包含三个可能的解。 可以使用CV_FM_7POINT标志在 OpenCV 中调用F矩阵估计的七匹配解决方案。 这就是我们在上一节的示例中所做的。

最后,我们应该提到,在图像中选择合适的匹配集对于获得基本矩阵的准确估计很重要。 通常,匹配项应在整个图像上很好地分布,并包括场景中不同深度的点。 否则,解决方案将变得不稳定或导致简并的配置 。

另见

The book by R. Hartley and A. Zisserman, Multiple View Geometry in Computer Vision, Cambridge University Press, 2004 is the most complete reference on projective geometry in computer vision.

下一个秘籍将显示可与 OpenCV 基本矩阵估计一起使用的附加标志。

使用随机样本共识匹配图像

当两台摄像机观察同一场景时,它们会看到相同的元素,但视点不同。 我们已经在上一章中研究了特征点匹配问题。 在本秘籍中,我们将回到这个问题,并将学习如何利用两个视图之间的对极约束来更可靠地匹配图像特征。

我们遵循的原理很简单:当我们在两个图像之间匹配特征点时,我们仅接受落在相应对极线上的那些匹配。 但是,为了能够检查这种情况,必须知道基本矩阵,并且我们需要良好的匹配才能估计此矩阵。 这似乎是鸡与蛋的问题。 我们在本秘籍中提出一种解决方案,其中将共同计算基本矩阵和一组良好的匹配项。

操作步骤

目的是能够获得两个视图之间的一组良好匹配。 因此,将使用先前秘籍中引入的对极约束来验证所有找到的特征点对应关系。 我们首先定义一个类,该类将封装将提出的解决方案的不同元素:

class RobustMatcher {

  private:

     // pointer to the feature point detector object
     cv::Ptr<cv::FeatureDetector> detector;
     // pointer to the feature descriptor extractor object
     cv::Ptr<cv::DescriptorExtractor> extractor;
     float ratio; // max ratio between 1st and 2nd NN
     bool refineF; // if true will refine the F matrix
     double distance; // min distance to epipolar
     double confidence; // confidence level (probability)

  public:

     RobustMatcher() : ratio(0.65f), refineF(true),
                       confidence(0.99), distance(3.0) {     

        // SURF is the default feature
        detector= new cv::SurfFeatureDetector();
        extractor= new cv::SurfDescriptorExtractor();
     }

注意我们如何使用通用的cv::FeatureDetectorcv::DescriptorExtractor接口,以便用户可以提供任何特定的实现。 默认情况下,此处使用SURF函数和描述符,但可以使用适当的设置器方法指定其他函数:

     // Set the feature detector
     void setFeatureDetector(
            cv::Ptr<cv::FeatureDetector>& detect) {

        detector= detect;
     }

     // Set the descriptor extractor
     void setDescriptorExtractor(
            cv::Ptr<cv::DescriptorExtractor>& desc) {

        extractor= desc;
     }

主要方法是我们的​​match方法,该方法返回匹配项,检测到的关键点和估计的基本矩阵。 现在,我们将探索该方法分五个不同的步骤(在以下代码的注释中明确指出):

     // Match feature points using symmetry test and RANSAC
     // returns fundemental matrix
     cv::Mat match(cv::Mat& image1, 
                   cv::Mat& image2, // input images
        // output matches and keypoints 
        std::vector<cv::DMatch>& matches, 
        std::vector<cv::KeyPoint>& keypoints1,   
        std::vector<cv::KeyPoint>& keypoints2) {

      // 1a. Detection of the SURF features
      detector->detect(image1,keypoints1);
      detector->detect(image2,keypoints2);

      // 1b. Extraction of the SURF descriptors
      cv::Mat descriptors1, descriptors2;
      extractor->compute(image1,keypoints1,descriptors1);
      extractor->compute(image2,keypoints2,descriptors2);

      // 2\. Match the two image descriptors

      // Construction of the matcher 
      cv::BruteForceMatcher<cv::L2<float>> matcher;

      // from image 1 to image 2
      // based on k nearest neighbours (with k=2)
      std::vector<std::vector<cv::DMatch>> matches1;
      matcher.knnMatch(descriptors1,descriptors2, 
         matches1, // vector of matches (up to 2 per entry) 
         2);        // return 2 nearest neighbours

      // from image 2 to image 1
      // based on k nearest neighbours (with k=2)
      std::vector<std::vector<cv::DMatch>> matches2;
      matcher.knnMatch(descriptors2,descriptors1, 
         matches2, // vector of matches (up to 2 per entry) 
         2);        // return 2 nearest neighbours

      // 3\. Remove matches for which NN ratio is 
      // > than threshold

      // clean image 1 -> image 2 matches
      int removed= ratioTest(matches1);
      // clean image 2 -> image 1 matches
      removed= ratioTest(matches2);

      // 4\. Remove non-symmetrical matches
       std::vector<cv::DMatch> symMatches;
      symmetryTest(matches1,matches2,symMatches);

      // 5\. Validate matches using RANSAC
      cv::Mat fundemental= ransacTest(symMatches, 
                  keypoints1, keypoints2, matches);

      // return the found fundemental matrix
      return fundemental;
   }

第一步只是检测特征点并计算其描述符。 接下来,我们像上一章一样使用cv::BruteForceMatcher类进行特征匹配。 但是,这一次,我们为每个特征找到了两个最佳匹配点(而不仅仅是我们在前面的秘籍中所做的最佳匹配点)。 这是通过cv::BruteForceMatcher::knnMatch方法(k = 2)完成的。 此外,我们在两个方向上执行此匹配,即,对于第一张图像中的每个点,我们在第二张图像中找到两个最佳匹配,然后对第二张图像的特征点执行相同的操作,在第一张图片中找到它们的两个最佳匹配。

因此,对于每个特征点,我们在另一个视图中都有两个候选匹配。 根据描述符之间的距离,这是最好的两个。 如果此测量距离对于最佳匹配而言非常低,而对于第二最佳匹配而言则更大,则我们可以放心地将第一匹配视为好匹配,因为它无疑是最佳选择。 相反,如果两个最佳匹配的距离相对较近,则选择一个或另一个可能会出错。 在这种情况下,我们应该拒绝两个匹配项。 在这里,我们通过验证最佳匹配的距离与第二最佳匹配的距离之比不大于给定阈值来执行此测试:

     // Clear matches for which NN ratio is > than threshold
     // return the number of removed points 
     // (corresponding entries being cleared, 
     // i.e. size will be 0)
     int ratioTest(std::vector<std::vector<cv::DMatch>>
                                                &matches) {

      int removed=0;

        // for all matches
      for (std::vector<std::vector<cv::DMatch>>::iterator 
              matchIterator= matches.begin();
           matchIterator!= matches.end(); ++matchIterator) {

             // if 2 NN has been identified
             if (matchIterator->size() > 1) {

                // check distance ratio
                if ((*matchIterator)[0].distance/
                    (*matchIterator)[1].distance > ratio) {

                   matchIterator->clear(); // remove match
                   removed++;
                }

             } else { // does not have 2 neighbours

                matchIterator->clear(); // remove match
                removed++;
             }
      }

      return removed;
     }

从以下示例可以看出,此过程将消除大量不明确的匹配项。 在这里,以SURF阈值(=10)为低阈值,我们最初检测到 1,600 个特征点(黑色圆圈),其中只有 55 个在比例测试(白色圆圈)中幸存下来:

How to do it...

链接匹配点的白线表明,即使我们有大量好的匹配项,也仍然存在大量错误的匹配项。 因此,将执行第二次测试以过滤我们更多的错误匹配。 请注意,比率测试也适用于第二个匹配集。

现在,我们有两个相对较好的匹配集,一个从第一张图片到第二张图片,另一个从第二张图片到第一张图片。 现在,从这些集合中提取与这两个集合一致的匹配项。 这是对称匹秘籍案提出,要接受一个匹配对,两个点必须是另一个的最佳匹配特征:

     // Insert symmetrical matches in symMatches vector
     void symmetryTest(
        const std::vector<std::vector<cv::DMatch>>& matches1,
        const std::vector<std::vector<cv::DMatch>>& matches2,
        std::vector<cv::DMatch>& symMatches) {

      // for all matches image 1 -> image 2
      for (std::vector<std::vector<cv::DMatch>>::
              const_iterator matchIterator1= matches1.begin();
          matchIterator1!= matches1.end(); ++matchIterator1) {

         // ignore deleted matches
         if (matchIterator1->size() < 2) 
            continue;

         // for all matches image 2 -> image 1
         for (std::vector<std::vector<cv::DMatch>>::
           const_iterator matchIterator2= matches2.begin();
            matchIterator2!= matches2.end(); 
            ++matchIterator2) {

            // ignore deleted matches
            if (matchIterator2->size() < 2) 
               continue;

            // Match symmetry test
            if ((*matchIterator1)[0].queryIdx == 
                (*matchIterator2)[0].trainIdx  && 
                (*matchIterator2)[0].queryIdx == 
                (*matchIterator1)[0].trainIdx) {

                // add symmetrical match
                  symMatches.push_back(
                    cv::DMatch((*matchIterator1)[0].queryIdx,        
                              (*matchIterator1)[0].trainIdx,
                              (*matchIterator1)[0].distance));
                  break; // next match in image 1 -> image 2
            }
         }
      }
     }

在我们的测试对中,有 31 个匹配项在此对称测试中幸存下来。 现在,最后一个测试包括一个附加的过滤测试,这次将使用基本矩阵以拒绝不遵循对极约束的匹配项。 此测试基于RANSAC方法,即使在匹配集中仍然存在异常值,该方法也可以计算基本矩阵(此方法将在以下部分中进行说明):

     // Identify good matches using RANSAC
     // Return fundemental matrix
     cv::Mat ransacTest(
         const std::vector<cv::DMatch>& matches,
         const std::vector<cv::KeyPoint>& keypoints1, 
         const std::vector<cv::KeyPoint>& keypoints2,
         std::vector<cv::DMatch>& outMatches) {

      // Convert keypoints into Point2f   
      std::vector<cv::Point2f> points1, points2;   
      for (std::vector<cv::DMatch>::
            const_iterator it= matches.begin();
          it!= matches.end(); ++it) {

          // Get the position of left keypoints
          float x= keypoints1[it->queryIdx].pt.x;
          float y= keypoints1[it->queryIdx].pt.y;
          points1.push_back(cv::Point2f(x,y));
          // Get the position of right keypoints
          x= keypoints2[it->trainIdx].pt.x;
          y= keypoints2[it->trainIdx].pt.y;
          points2.push_back(cv::Point2f(x,y));
       }

      // Compute F matrix using RANSAC
      std::vector<uchar> inliers(points1.size(),0);
      cv::Mat fundemental= cv::findFundamentalMat(
         cv::Mat(points1),cv::Mat(points2), // matching points
          inliers,      // match status (inlier or outlier)  
          CV_FM_RANSAC, // RANSAC method
          distance,     // distance to epipolar line
          confidence);  // confidence probability

      // extract the surviving (inliers) matches
      std::vector<uchar>::const_iterator 
                        itIn= inliers.begin();
      std::vector<cv::DMatch>::const_iterator 
                        itM= matches.begin();
      // for all matches
      for ( ;itIn!= inliers.end(); ++itIn, ++itM) {

         if (*itIn) { // it is a valid match

            outMatches.push_back(*itM);
         }
      }

      if (refineF) {
      // The F matrix will be recomputed with 
      // all accepted matches

         // Convert keypoints into Point2f 
         // for final F computation   
         points1.clear();
         points2.clear();

         for (std::vector<cv::DMatch>::
                const_iterator it= outMatches.begin();
             it!= outMatches.end(); ++it) {

             // Get the position of left keypoints

             float x= keypoints1[it->queryIdx].pt.x;
             float y= keypoints1[it->queryIdx].pt.y;
             points1.push_back(cv::Point2f(x,y));
             // Get the position of right keypoints
             x= keypoints2[it->trainIdx].pt.x;
             y= keypoints2[it->trainIdx].pt.y;
             points2.push_back(cv::Point2f(x,y));
         }

         // Compute 8-point F from all accepted matches
         fundemental= cv::findFundamentalMat(
            cv::Mat(points1),cv::Mat(points2), // matches
            CV_FM_8POINT); // 8-point method
      }

      return fundemental;
     }

该代码有点长,因为在F矩阵计算之前需要将关键点转换为cv::Point2f

通过以下调用来启动使用我们的RobustMatcher类别的完整匹配过程:

   // Prepare the matcher
   RobustMatcher rmatcher;
   rmatcher.setConfidenceLevel(0.98);
   rmatcher.setMinDistanceToEpipolar(1.0);
   rmatcher.setRatio(0.65f);
   cv::Ptr<cv::FeatureDetector> pfd= 
          new cv::SurfFeatureDetector(10); 
   rmatcher.setFeatureDetector(pfd);

   // Match the two images
   std::vector<cv::DMatch> matches;
   std::vector<cv::KeyPoint> keypoints1, keypoints2;
   cv::Mat fundemental= rmatcher.match(image1,image2,
                         matches, keypoints1, keypoints2);

结果是 23 个匹配项,其对应的对极线显示在以下屏幕截图中:

How to do it...

How to do it...

工作原理

在前面的秘籍中,我们了解到可以从多个特征点匹配中估计与图像对关联的基本矩阵。 显然,确切地说,此匹配集必须仅由良好匹配组成。 然而,在实际环境中,不可能保证通过比较检测到的特征点的描述符而获得的匹配集将是完全准确的。 这就是为什么介绍了一种基于 RANSAC随机采样共识)策略的基本矩阵估计方法的原因。

RANSAC 算法旨在从可能包含多个异常值的数据集中估计给定的数学实体。 想法是从集合中随机选择一些数据点,然后仅使用这些数据点进行估计。 选择的点数应该是估计数学实体所需的最小点数。 在基本矩阵的情况下,该最小数目为 8 个匹配对(实际上,可以是 7 个匹配,但是 8 点线性算法的计算速度更快)。 一旦从这些随机的 8 个匹配项中估计出了基本矩阵,就将对照该集合中得出的对极约束,测试匹配集中所有其他匹配项。 识别出满足此约束的所有匹配项(即,对应特征与其对极线相距不远的匹配项)。 这些匹配形成计算出的基本矩阵的支持集

RANSAC 算法背后的中心思想是,支持集越大,计算出的矩阵就是正确矩阵的可能性就越高。 显然,如果一个(或多个)随机选择的匹配项是错误的匹配项,则计算出的基本矩阵也将是错误的,并且其支持集会很小。 重复此过程多次,最后,将保留最大支持的矩阵为最可能的矩阵。

因此,我们的目标是几次选择八场随机比赛,以便最终选择八场好的比赛,这应该为我们提供大量支持。 根据整个数据集中错误匹配的次数,选择一组八个正确匹配的概率将有所不同。 但是,我们知道,选择的次数越多,在这些选择中至少有一个好的匹配项的置信度就越高。 更准确地说,如果我们假设匹配集由 n% 个正常值(良好匹配)组成,那么我们选择 8 个良好匹配的概率为8n。 因此,选择包含至少一个错误匹配的概率为1-n8。 如果我们进行k个选择,则拥有一个仅包含良好匹配项的随机集的概率为1 - (1 - 8n) k。 这就是置信概率c,我们希望该概率尽可能高,因为我们需要至少一组良好的匹配项才能获得正确的基本矩阵。 因此,在运行 RANSAC 算法时,需要确定为了获得给定置信度而需要进行的选择数k

在 Ransacs 中使用cv::findFundamentalMat函数时,会提供两个额外的参数。 第一个是置信度,它确定要进行的迭代次数。 第二个是到一个极点到极线的最大距离。 点与它的极线之间的距离大于指定极对的距离的所有匹配对将被报告为异常值。 因此,该函数还返回char值的std::vector值,指示相应的匹配已被识别为异常值(0)或异常值(1)。

初始匹配集中的匹配越好,RANSAC 为您提供正确的基本矩阵的可能性就越高。 这就是为什么我们在调用cv::findFundamentalMat函数之前对匹配集应用了多个过滤器的原因。 显然,您可以决定跳过本秘籍中建议的一个或另一个步骤。 这只是在计算复杂性,最终匹配数以及所需的置信度之间取得平衡的问题,即所获得的匹配集将仅包含精确匹配。

计算两个图像之间的单应性

本章的第二个秘籍向您展示了如何从一组匹配项中计算图像对的基本矩阵。 存在可以根据匹配对计算的另一个数学实体:单应性。 像基本矩阵一样,单应性是具有特殊属性的3x3矩阵,正如我们在本秘籍中将看到的,它适用于特定情况下的两视图图像。

准备

让我们再次考虑在本章第一个方法中介绍的 3D 点及其在相机上的图像之间的投影关系。 基本上,我们了解到此关系由3x4矩阵表示。 现在,如果我们考虑一个场景的两个视图被纯旋转分开的特殊情况,那么可以观察到,外部矩阵的第四列将全部为 0(即平移为空)。 结果,在这种特殊情况下的投影关系变成3x3矩阵。 此矩阵称为单应性,它表示在特殊情况下(此处为纯旋转),一个视图中某个点的图像与另一视图中同一点的图像有关。 通过线性关系:

Getting ready

在齐次坐标中,该关系保持在此处由标量值s表示的比例因子上。 一旦估计了此矩阵,就可以使用该关系将一个视图中的所有点转移到第二个视图中。 注意,由于纯旋转的单应性关系的副作用,在这种情况下基本矩阵变得不确定。

操作步骤

假设我们有两个图像,它们被纯旋转分开。 这两个图像可以使用我们的RobustMatcher类进行匹配,除了我们跳过 RANSAC 验证步骤(在match方法中标识为步骤 5)之外,因为该步骤涉及基本矩阵估计。 相反,我们将应用 RANSAC 步骤,该步骤将包括基于匹配集(显然包含大量异常值)的单应性估计。 这是通过使用cv::findHomography函数与cv::findFundementalMat函数非常相似来完成的:

   // Find the homography between image 1 and image 2
   std::vector<uchar> inliers(points1.size(),0);
   cv::Mat homography= cv::findHomography(
      cv::Mat(points1), // corresponding 
      cv::Mat(points2), // points
      inliers,      // outputted inliers matches 
      CV_RANSAC,   // RANSAC method
      1.);         // max distance to reprojection point

回想一下,仅当两个图像被纯旋转分开时,单应性才会存在,以下两个图像就是这种情况:

How to do it...

How to do it...

通过以下循环在这些图像上绘制了符合找到的单应性的所得内线:

   // Draw the inlier points
   std::vector<cv::Point2f>::const_iterator itPts=  
                                            points1.begin();
   std::vector<uchar>::const_iterator itIn= inliers.begin();
   while (itPts!=points1.end()) {

      // draw a circle at each inlier location
      if (*itIn) 
          cv::circle(image1,*itPts,3,
                    cv::Scalar(255,255,255),2);
      ++itPts;
      ++itIn;
   }

   itPts= points2.begin();
   itIn= inliers.begin();
   while (itPts!=points2.end()) {

      // draw a circle at each inlier location
      if (*itIn) 
         cv::circle(image2,*itPts,3,
                    cv::Scalar(255,255,255),2);
      ++itPts;
      ++itIn;
   }

如上一节所述,一旦计算了单应性,就可以将图像点从一个图像转移到另一个图像。 实际上,您可以对图像的所有像素执行此操作,结果将是将该图像转换为另一个视图。 有一个 OpenCV 函数可以完全做到这一点:

   // Warp image 1 to image 2
   cv::Mat result;
   cv::warpPerspective(image1, // input image
      result,         // output image
      homography,      // homography
      cv::Size(2*image1.cols,
                 image1.rows)); // size of output image

一旦获得了这个新图像,就可以将其附加到另一个图像上以扩展视图(因为两个图像现在是从同一角度来看):

   // Copy image 1 on the first half of full image
   cv::Mat half(result,cv::Rect(0,0,image2.cols,image2.rows));
   image2.copyTo(half); // copy image2 to image1 roi

结果如下图:

How to do it...

工作原理

当通过单应性图将两个视图关联时,可以确定在一个图像上找到给定场景点的另一图像上的位置。 对于超出图像边界的点,此属性特别有趣。 实际上,由于第二个视图显示的场景的一部分在第一幅图像中不可见,因此可以使用单应性以通过在另一幅图像中读取其他像素的色值来扩展图像。 这就是我们能够创建新图像的方式,该图像是我们第二幅图像的扩展,其中在右侧添加了额外的列。

cv::findHomography计算的单应性是将第一图像中的点映射到第二图像中的点的单应性。 实际上,为了将图像 1 的点转移到图像 2 所需要的是逆单应性。 这正是函数cv::warpPerspective在默认情况下所做的,也就是说,它使用提供的单应性的倒数作为输入来获取输出图像每个点的颜色值。 当输出像素转移到输入图像外部的点时,黑色值(0)会简单地分配给该像素。 请注意,如果要在像素传输过程中使用直接单应性而不是反向单应性,则可以将可选标志cv::WARP_INVERSE_MAP指定为cv::warpPerspective中的可选第五个参数。

更多

平面的两个视图之间也存在单应性。 可以像在纯旋转情况下一样,通过再次查看相机投影方程式来证明这一点。 观察平面时,我们可以不失一般性地设置平面的参考框架,以使其所有点的Z坐标等于 0。这还将取消其中一列3x4投影矩阵的结果,得出3x3矩阵:单应性。 这意味着,例如,如果您从建筑物的平面立面的不同角度来看有几张图片,则可以计算这些图像之间的单应性,并通过将图像包裹起来并将它们组装在一起来构建立面的大型马赛克,我们在这个秘籍中实现了它。

计算单应性至少需要两个视图之间的四个匹配点。 函数cv::getPerspectiveTransform允许从四个对应点进行这种转换。

十、处理视频序列

在本章中,我们将介绍:

  • 读取视频序列
  • 处理视频帧
  • 编写视频序列
  • 跟踪视频中的特征点
  • 提取视频中的前景对象

简介

视频信号构成了丰富的视觉信息源。 它们由一系列图像组成,这些图像称为,以固定的时间间隔(指定为帧频)拍摄,并显示运动场景。 随着功能强大的计算机的出现,现在可以对视频序列进行高级视觉分析,并且有时以接近或什至比实际视频帧速率更快的速率进行。 本章将向您展示如何读取,处理和存储视频序列。

我们将看到,一旦提取了视频序列的各个帧,就可以将本书中介绍的不同图像处理功能应用于它们中的每个。 此外,我们还将研究一些算法,这些算法对视频序列进行时间分析,比较相邻帧以跟踪对象,或者随着时间的推移累积图像统计信息以提取前景对象。

读取视频序列

要处理视频序列,我们需要能够读取其每个帧。 OpenCV 建立了一个易于使用的框架,可以从视频文件甚至从 USB 摄像机执行帧提取。 此秘籍向您展示如何使用它。

操作步骤

基本上,要读取视频序列的帧,您要做的就是创建cv::VideoCapture类的实例。 然后,您创建一个循环,该循环将提取并读取每个视频帧。 这是一个基本的main函数,可以简单地显示视频序列的帧:

int main()
{
   // Open the video file
   cv::VideoCapture capture("../bike.avi");
   // check if video successfully opened
   if (!capture.isOpened())
      return 1;

   // Get the frame rate
   double rate= capture.get(CV_CAP_PROP_FPS);

   bool stop(false);
   cv::Mat frame; // current video frame
   cv::namedWindow("Extracted Frame");

   // Delay between each frame in ms
   // corresponds to video frame rate
   int delay= 1000/rate;

   // for all frames in video
   while (!stop) {

      // read next frame if any
      if (!capture.read(frame))
         break;

      cv::imshow("Extracted Frame",frame);

      // introduce a delay
      // or press key to stop
      if (cv::waitKey(delay)>=0)
            stop= true;
   }

   // Close the video file.
   // Not required since called by destructor
   capture.release();
 }

将显示一个窗口,视频将在该窗口上播放,如下所示:

How to do it...

工作原理

要打开视频,您只需要指定视频文件名。 这可以通过在cv::VideoCapture对象的构造器中提供文件名来完成。 如果已经创建了cv::VideoCapture,也可以使用open方法。 视频成功打开后(可以通过isOpened方法验证),就可以开始提取帧了。 通过使用带有适当标志的get方法,也可以在cv::VideoCapture对象中查询与视频文件关联的信息。 在前面的示例中,我们使用CV_CAP_PROP_FPS标志获得了帧速率。 由于它是泛型函数,因此即使在某些情况下会期望使用其他类型,它也总是返回double。 例如,将获得视频文件中的帧总数(作为整数),如下所示:

long t= static_cast<long>(
              capture.get(CV_CAP_PROP_FRAME_COUNT));

查看 OpenCV 文档中可用的不同标志,以了解可以从视频中获取哪些信息。

还有set方法,允许您向cv::VideoCapture实例实例输入一些参数。 例如,可以使用CV_CAP_PROP_POS_FRAMES请求移至特定帧:

// goto frame 100
double position= 100.0; 
capture.set(CV_CAP_PROP_POS_FRAMES, position);

您还可以使用CV_CAP_PROP_POS_MSEC指定位置(以毫秒为单位),或使用CV_CAP_PROP_POS_AVI_RATIO指定视频内部的相对位置(其中 0.0 对应于视频的开头,而 1.0 对应于视频的结尾)。 如果请求的参数设置成功,则该方法返回true。 请注意,获取或设置特定视频参数的可能性在很大程度上取决于用于压缩和存储视频序列的编解码器。 如果您无法使用某些参数,则可能仅仅是由于您使用的是特定的编解码器。

一旦成功打开捕获的视频(通过isOpened方法验证),就可以像上一节中的示例一样,通过重复调用read方法依次获取帧。 可以等效地调用重载的读取运算符:

capture >> frame;

也可以调用两个基本方法:

capture.grab();
capture.retrieve(frame);

还请注意,在我们的示例中,如何在显示每一帧时引入延迟。 使用cv::waitKey函数完成此操作。 在这里,我们将延迟设置为与输入视频帧速率相对应的值(如果fps是每秒的帧数,则 1000/fps 是两帧之间的延迟,以 ms 为单位)。 您显然可以更改此值以较慢或较快的速度显示视频。 但是,如果要显示视频帧,则要确保窗口有足够的刷新时间,插入这样的延迟很重要(由于这是低优先级的过程,因此,如果 CPU 太忙)。 cv::waitKey函数还允许我们通过按任意键来中断读取过程。 在这种情况下,函数将返回所按下键的 ASCII 码。 请注意,如果指定给cv::waitKey函数的延迟为 0,则它​​将无限期地等待用户按下某个键。 当某人想通过逐帧检查结果来跟踪过程时,此函数非常有用。

最后一条语句调用release方法,该方法将关闭视频文件。 但是,由于release也由cv::VideoCapture析构器调用,因此不需要此调用。

重要的是要注意,为了打开指定的视频文件,您的计算机必须安装相应的编解码器,否则cv::VideoCapture将无法理解输入文件。 通常,如果您能够使用计算机上的视频播放器(例如 Windows Media Player)打开视频文件,则 OpenCV 也应该能够读取此文件。

更多

您还可以读取连接到计算机的相机(例如 USB 相机)的视频流捕获。 在这种情况下,您只需为打开函数指定一个 ID 号(一个整数)而不是一个文件名即可。 为 ID 指定 0 将打开已安装的默认摄像机。 在这种情况下, cv::waitKey函数停止处理的作用变得至关重要,因为将无限次读取来自摄像机的视频流。

另见

本章中的秘籍“编写视频序列”具有有关视频编解码器的更多信息。

ffmpeg.org网站提供了一个完整的开源和跨平台解决方案,用于音频/视频读取,记录,转换和流传输。 用于操纵视频文件的 OpenCV 类是在此库之上构建的。

Xvid.org网站提供了一个基于 MPEG-4 标准的开源视频编解码器库,用于视频压缩。 Xvid 还有一个竞争对手 DivX ,它提供专有但免费的编解码器和软件工具。

处理视频帧

在本秘籍中,我们的目标是对视频序列的每个帧应用某种处理函数。 为此,我们将 OpenCV 视频捕获框架封装到我们自己的类中。 除其他事项外,此类可让我们指定每次提取新帧时都会调用的函数。

操作步骤

我们想要的是能够指定在视频序列的每个帧处调用的处理函数(回调函数)。 可以将此函数定义为接收cv::Mat实例并输出已处理的帧。 因此,我们将其设计为具有以下签名:

void processFrame(cv::Mat& img, cv::Mat& out);

作为此类处理函数的示例,请考虑以下用于计算输入图像的 Canny 边缘的简单函数:

void canny(cv::Mat& img, cv::Mat& out) {

   // Convert to gray
   if (img.channels()==3)
      cv::cvtColor(img,out,CV_BGR2GRAY);
   // Compute Canny edges
   cv::Canny(out,out,100,200);
   // Invert the image
   cv::threshold(out,out,128,255,cv::THRESH_BINARY_INV);
}

然后,让我们定义可以与回调函数相关联的视频处理类。 使用此类,过程将是创建一个类实例,指定一个输入视频文件,将回调函数附加到它,然后启动该过程。 以编程方式,这些步骤将使用我们建议的类来完成,如下所示:

   // Create instance
   VideoProcessor processor;
   // Open video file
   processor.setInput("../bike.avi");
   // Declare a window to display the video
   processor.displayInput("Current Frame");
   processor.displayOutput("Output Frame");
   // Play the video at the original frame rate
   processor.setDelay(1000./processor.getFrameRate());
   // Set the frame processor callback function
   processor.setFrameProcessor(canny);
   // Start the process
   processor.run();

既然我们已经定义了如何使用此类,那么让我们描述一下它的实现。 正如人们可能期望的那样,该类包含几个成员变量,这些成员变量控制视频帧处理的不同方面:

class VideoProcessor {

  private:

     // the OpenCV video capture object
     cv::VideoCapture capture;
     // the callback function to be called 
     // for the processing of each frame
     void (*process)(cv::Mat&, cv::Mat&);
     // a bool to determine if the 
     // process callback will be called
     bool callIt;
     // Input display window name
     std::string windowNameInput;
     // Output display window name
     std::string windowNameOutput;
     // delay between each frame processing
     int delay;
     // number of processed frames 
     long fnumber;
     // stop at this frame number
     long frameToStop;
     // to stop the processing
     bool stop;

  public:

     VideoProcessor() : callIt(true), delay(0), 
             fnumber(0), stop(false), frameToStop(-1) {}

第一个成员变量是cv::VideoCapture对象,第二个成员变量是process函数指针,它将指向回调函数。 可以使用相应的设置器方法指定:

     // set the callback function that 
     // will be called for each frame
     void setFrameProcessor(
         void (*frameProcessingCallback)
                        (cv::Mat&, cv::Mat&)) {

        process= frameProcessingCallback;
     }

并且以下方法是打开视频文件:

     // set the name of the video file
     bool setInput(std::string filename) {

      fnumber= 0;
      // In case a resource was already 
      // associated with the VideoCapture instance
      capture.release();
      images.clear();

      // Open the video file
      return capture.open(filename);
     }

在处理帧时显示它们通常很有趣。 因此,使用两种方法来创建显示窗口:

     // to display the processed frames
     void displayInput(std::string wn) {

        windowNameInput= wn;
        cv::namedWindow(windowNameInput);
     }

     // to display the processed frames
     void displayOutput(std::string wn) {

        windowNameOutput= wn;
        cv::namedWindow(windowNameOutput);
     }

     // do not display the processed frames
     void dontDisplay() {

        cv::destroyWindow(windowNameInput);
        cv::destroyWindow(windowNameOutput);
        windowNameInput.clear();
        windowNameOutput.clear();
     }

如果未调用这两种方法中的任何一种,则将不会显示相应的帧。 主要方法称为run,它是一种包含帧提取循环的方法:

     // to grab (and process) the frames of the sequence
     void run() {

        // current frame
        cv::Mat frame;
        // output frame
        cv::Mat output;

        // if no capture device has been set
        if (!isOpened())
           return;

        stop= false;

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

            // calling the process function
           if (callIt) {

            // process the frame
            process(frame, output);        
            // increment frame number
             fnumber++;

           } else {

            output= frame;
           }

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 && 
                 getFrameNumber()==frameToStop)
              stopIt();
        }
     }

     // Stop the processing
     void stopIt() {

        stop= true;
     }

     // Is the process stopped?
     bool isStopped() {

        return stop;
     }

     // Is a capture device opened?
     bool isOpened() {

        capture.isOpened();
     }

     // set a delay between each frame
     // 0 means wait at each frame
     // negative means no delay
     void setDelay(int d) {

        delay= d;
     }

此方法使用读取帧的私有方法:

     // to get the next frame 
     // could be: video file or camera
     bool readNextFrame(cv::Mat& frame) {

           return capture.read(frame);
     }

人们可能还希望简单地打开并播放视频文件(而无需调用回调函数)。 因此,我们有两种方法可以指定是否要调用回调函数:

     // process callback to be called
     void callProcess() {

        callIt= true;
     }

     // do not call process callback
     void dontCallProcess() {

        callIt= false;
     }

最后,该类还提供了在特定帧号处停止的可能性:

     void stopAtFrameNo(long frame) {

        frameToStop= frame;
     }

     // return the frame number of the next frame
     long getFrameNumber() {

         // get info of from the capture device
           long fnumber= static_cast<long>(
                   capture.get(CV_CAP_PROP_POS_FRAMES));
          return fnumber; 
     }

如果使用此类运行本节开头介绍的代码段,则两个窗口将以原始帧速率(由setDelay方法引入的延迟的后果)播放输入视频和输出结果。 ),如以下两个示例所示。 这是输入视频的一帧:

How to do it...

相应的输出帧如下:

How to do it...

工作原理

正如我们在其他秘籍中所做的那样,我们的目标是创建一个封装视频处理算法的通用功能的类。 在此类中,视频捕获循环是通过run方法实现的。 它包含帧提取循环,该循环首先调用cv::VideoCapture OpenCV 类的read方法。 执行了一系列操作,但是在调用每个操作之前,将进行检查以确定是否已请求该操作。 仅当指定了输入窗口名称(使用displayInput方法)时,才会显示输入窗口。 仅当已指定回调函数(使用setFrameProcessor)时,才调用该回调函数。 仅当定义了输出窗口名称(使用displayOutput )时,才会显示输出窗口。 仅当指定了延迟时才引入延迟(使用setDelay方法)。 最后,检查当前帧号是否已定义停止帧(使用stopAtFrameNo )。

该类还包含许多获取器和设置器方法,它们基本上只是cv::VideoCapture框架的常规setget方法的包装。

更多

我们的VideoProcessor类用于促进视频处理模块的部署。 几乎没有其他改进。

处理图像序列

有时,输入序列由一系列分别存储在不同文件中的图像组成。 我们的班级可以很容易地修改以适应这种输入。 您只需要添加一个成员变量,该变量将包含图像文件名的向量及其相应的迭代器:

     // vector of image filename to be used as input
     std::vector<std::string> images; 
     // image vector iterator
     std::vector<std::string>::const_iterator itImg;

新的setInput方法用于指定要读取的文件名:

     // set the vector of input images
     bool setInput(const std::vector<std::string>& imgs) {

      fnumber= 0;
      // In case a resource was already 
      // associated with the VideoCapture instance
      capture.release();

      // the input will be this vector of images
      images= imgs;
      itImg= images.begin();

      return true;
     }

isOpened方法变为:

     // Is a capture device opened?
     bool isOpened() {

        return capture.isOpened() || !images.empty();
     }

最后一个需要修改的方法是专用readNextFrame方法,该方法将从视频或文件名的向量中读取,具体取决于已指定的输入。 测试是如果图像文件名的向量不为空,那是因为输入是图像序列。 调用带有视频文件名的setInput会清除此引导程序:

     // to get the next frame 
     // could be: video file; camera; vector of images
     bool readNextFrame(cv::Mat& frame) {

        if (images.size()==0)
           return capture.read(frame);

        else {

           if (itImg != images.end()) {

              frame= cv::imread(*itImg);
              itImg++;
              return frame.data != 0;

           } else {

              return false;
           }
        }
     }

使用帧处理器类

在面向对象的上下文中,使用框架处理类代替框架处理函数可能更有意义。 实际上,一个类将使程序员在定义视频处理算法时具有更大的灵活性。 因此,我们可以定义一个接口,希望在VideoProcessor内部使用的任何类都需要实现:

// The frame processor interface
class FrameProcessor {

  public:
   // processing method
   virtual void process(cv:: Mat &input, cv:: Mat &output)= 0;
};

设置器方法允许您输入指向VideoProcessor框架的FrameProcessor实例的指针:

     // set the instance of the class that 
     // implements the FrameProcessor interface
     void setFrameProcessor(FrameProcessor* frameProcessorPtr)
     {

        // invalidate callback function
        process= 0;
        // this is the frame processor instance 
        // that will be called
        frameProcessor= frameProcessorPtr;
        callProcess();
     }

指定帧处理器类实例后,它将使之前可能已经设置的任何帧处理函数失效。 现在,如果指定了帧处理函数,则同样适用:

     // set the callback function that will 
     // be called for each frame
     void setFrameProcessor(
        void (*frameProcessingCallback)(cv::Mat&, cv::Mat&)) {

        // invalidate frame processor class instance
        frameProcessor= 0;
        // this is the frame processor function that 
        // will be called
        process= frameProcessingCallback;
        callProcess();
     }

并修改了run方法的while循环以考虑到此修改:

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

 // ** calling the process function or method **
 if (callIt) {

 // process the frame
 if (process) // if call back function
 process(frame, output);
 else if (frameProcessor) 
 // if class interface instance
 frameProcessor->process(frame,output);
 // increment frame number
 fnumber++;

 } else {

 output= frame;
 }

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 && 
                getFrameNumber()==frameToStop)
              stopIt();
        }

另见

本章视频中的秘籍“跟踪特征点”说明了如何使用FrameProcessor类接口。

编写视频序列

在先前的秘籍中,我们学习了如何读取视频文件并提取其帧。 此秘籍将向您展示如何编写帧并因此创建视频文件。 这将使我们能够完成典型的视频处理链:读取输入的视频流,处理其帧,然后将结果存储在视频文件中。

操作步骤

让我们扩展VideoProcessor类,以使其具有写入视频文件的能力。 这是使用 OpenCV cv::VideoWriter类完成的。 因此,它的一个实例被添加为我们的类的成员(加上其他一些成员变量):

class VideoProcessor {

  private:

...
     // the OpenCV video writer object
     cv::VideoWriter writer;
     // output filename
     std::string outputFile;
     // current index for output images
     int currentIndex;
     // number of digits in output image filename
     int digits;
     // extension of output images
     std::string extension;

一种额外的方法用于指定(并打开)输出视频文件:

     // set the output video file
     // by default the same parameters than 
     // input video will be used
     bool setOutput(const std::string &filename, 
                    int codec=0, double framerate=0.0, 
                    bool isColor=true) {

        outputFile= filename;
        extension.clear();

        if (framerate==0.0) 
           framerate= getFrameRate(); // same as input

        char c[4];
        // use same codec as input
        if (codec==0) { 
           codec= getCodec(c);
        }

        // Open output video
        return writer.open(outputFile, // filename
           codec,          // codec to be used 
           framerate,      // frame rate of the video
           getFrameSize(), // frame size
           isColor);       // color video?
     }

打开视频文件后,可以通过重复调用cv::VideoWriter类的write方法来向其中添加帧。 与前面的秘籍一样,我们还希望为用户提供将帧写为单独图像的可能性。 因此,专用writeNextFrame方法处理以下两种可能的情况:

     // to write the output frame 
     // could be: video file or images
     void writeNextFrame(cv::Mat& frame) {

        if (extension.length()) { // then we write images

           std::stringstream ss;
           // compose the output filename
           ss << outputFile << std::setfill('0') 
               << std::setw(digits) 
               << currentIndex++ << extension;
           cv::imwrite(ss.str(),frame);

        } else { // then write to video file

           writer.write(frame);
        }
     }

对于输出由单个图像文件组成的情况,我们需要一个附加的设置器方法:

     // set the output as a series of image files
     // extension must be ".jpg", ".bmp" ...
     bool setOutput(const std::string &filename, // prefix
        const std::string &ext, // image file extension 
        int numberOfDigits=3,   // number of digits
        int startIndex=0) {     // start index

        // number of digits must be positive
        if (numberOfDigits<0)
           return false;

        // filenames and their common extension
        outputFile= filename;
        extension= ext;

        // number of digits in the file numbering scheme
        digits= numberOfDigits;
        // start numbering at this index
        currentIndex= startIndex;

        return true;
     }

然后,将新步骤添加到run方法的视频捕获循环中:

        while (!isStopped()) {

           // read next frame if any
           if (!readNextFrame(frame))
              break;

           // display input frame
           if (windowNameInput.length()!=0) 
              cv::imshow(windowNameInput,frame);

            // calling the process function or method
           if (callIt) {

            // process the frame
            if (process)
                process(frame, output);
            else if (frameProcessor) 
               frameProcessor->process(frame,output);
            // increment frame number
            fnumber++;

           } else {

            output= frame;
           }

 // ** write output sequence **
 if (outputFile.length()!=0)
 writeNextFrame(output);

           // display output frame
           if (windowNameOutput.length()!=0) 
              cv::imshow(windowNameOutput,output);

           // introduce a delay
           if (delay>=0 && cv::waitKey(delay)>=0)
            stopIt();

           // check if we should stop
           if (frameToStop>=0 &&
                  getFrameNumber()==frameToStop)
              stopIt();
        }
     }

然后,将编写一个简单的程序来读取视频,对其进行处理并将结果写入视频文件:

   // Create instance
   VideoProcessor processor;

   // Open video file
   processor.setInput("../bike.avi");
   processor.setFrameProcessor(canny);
   processor.setOutput("../bikeOut.avi");
   // Start the process
   processor.run();

如果要将结果另存为一系列图像,则可以通过以下命令更改前面的语句:

   processor.setOutput("../bikeOut",".jpg");

使用默认数字位数(3)和起始索引(0),这将创建文件bikeOut000.jpgbikeOut001.jpgbikeOut002.jpg,依此类推。

工作原理

将视频写入文件后,将使用编解码器将其保存。 编解码器是一种能够对视频流进行编码和解码的软件模块。 编解码器同时定义了文件格式和用于存储信息的压缩方案。 显然,已使用给定编解码器编码的视频必须使用相同的编解码器进行解码。 因此,已将四字符代码引入唯一标识的编解码器。 这样,当软件工具需要编写视频文件时,它将通过读取指定的四字符代码来确定要使用的编解码器。

顾名思义,四字符代码由四个 ASCII 字符组成,也可以通过将它们附加在一起将其转换为整数。 使用打开的cv::VideoCapture实例的get方法的CV_CAP_PROP_FOURCC标志,可以获得打开的视频文件的此代码。 我们可以在VideoProcessor类中定义一个方法,以返回输入视频的四字符代码:

     // get the codec of input video
     int getCodec(char codec[4]) {

        // undefined for vector of images
        if (images.size()!=0) return -1;

        union { // data structure for the 4-char code
           int value;
           char code[4]; } returned;

        // get the code
        returned.value= static_cast<int>(
                           capture.get(CV_CAP_PROP_FOURCC));

        // get the 4 characters
        codec[0]= returned.code[0];
        codec[1]= returned.code[1];
        codec[2]= returned.code[2];
        codec[3]= returned.code[3];

        // return the int value corresponding to the code
        return returned.value;
     }

get方法始终返回double,然后将其转换为整数。 该整数表示可使用union数据结构从中提取四个字符的代码。 如果我们打开测试视频序列,则从以下语句开始:

   char codec[4];
   processor.getCodec(codec);
   std::cout << "Codec: " << codec[0] << codec[1] 
             << codec[2] << codec[3] << std::endl;

我们获得:

Codec : XVID

写入视频文件时,必须使用其四个字符的代码指定编解码器。 这是cv::VideoWriter类的open方法中的第二个参数。 例如,您可以使用与输入视频相同的视频(这是setOutput方法中的默认选项)。 您还可以传递值 -1,该方法将弹出一个窗口,要求您从可用编解码器列表中选择一个,如下所示:

How it works...

您将在此窗口中看到的列表与计算机上已安装的编解码器的列表相对应。 然后,所选编解码器的代码将自动发送到open方法。

跟踪视频中的特征点

本章是关于读取,写入和处理视频序列的。 目的是能够分析完整的视频序列。 例如,在本秘籍中,您将学习如何对序列进行时间分析,以便跟踪特征点在帧之间移动的情况。

操作步骤

要开始跟踪过程,首先要做的是检测初始帧中的特征点。 然后,您尝试在下一帧中跟踪这些点。 您必须找到这些点现在在此新框架中的位置。 显然,由于我们正在处理视频序列,因此很有可能在其上找到了特征点的对象已经移动(该运动也可能是由于摄像机的运动引起的)。 因此,必须在一个点的先前位置附近搜索,以便在下一帧中找到它的新位置。 这就是完成cv::calcOpticalFlowPyrLK函数的功能。 您在第一张图像中输入两个连续的帧和一个特征点向量,该函数将返回新点位置的向量。 要跟踪完整序列上的点,请逐帧重复此过程。 请注意,当您沿着序列中的点进行跟踪时,不可避免地会失去对其中某些点的跟踪,从而跟踪的特征点的数量将逐渐减少。 因此,不时检测新特征可能是一个好主意。

现在,我们将利用先前秘籍中定义的框架,并定义一个类,该类实现在本章“处理视频帧”秘籍中引入的FrameProcessor接口。 此类的数据属性包括执行特征点检测及其跟踪所需的变量:

class FeatureTracker : public FrameProcessor {

   cv::Mat gray;         // current gray-level image
   cv::Mat gray_prev;      // previous gray-level image
   // tracked features from 0->1
   std::vector<cv::Point2f> points[2]; 
   // initial position of tracked points
   std::vector<cv::Point2f> initial;   
   std::vector<cv::Point2f> features;  // detected features
   int max_count;     // maximum number of features to detect
   double qlevel;    // quality level for feature detection
   double minDist;   // min distance between two points
   std::vector<uchar> status; // status of tracked features
   std::vector<float> err;    // error in tracking

  public:

   FeatureTracker() : max_count(500), 
                      qlevel(0.01), minDist(10.) {}

接下来,我们定义process方法,该方法将为序列的每个帧调用。 基本上,我们需要进行如下操作。 首先,必要时检测特征点。 接下来,跟踪这些点。 您拒绝无法追踪或不再想要追踪的点。 现在您可以处理成功跟踪的点了。 最后,当前帧及其点成为前一帧,并为下一次迭代提供点。 这是操作方法:

   void process(cv:: Mat &frame, cv:: Mat &output) {

      // convert to gray-level image
      cv::cvtColor(frame, gray, CV_BGR2GRAY); 
      frame.copyTo(output);

      // 1\. if new feature points must be added
      if(addNewPoints())
      {
         // detect feature points
         detectFeaturePoints();
         // add the detected features to 
         // the currently tracked features
         points[0].insert(points[0].end(),
                          features.begin(),features.end());
         initial.insert(initial.end(),
                        features.begin(),features.end());
      }

      // for first image of the sequence
      if(gray_prev.empty())
           gray.copyTo(gray_prev);

      // 2\. track features
      cv::calcOpticalFlowPyrLK(
         gray_prev, gray, // 2 consecutive images
         points[0], // input point positions in first image
         points[1], // output point positions in the 2nd image
         status,    // tracking success
         err);      // tracking error

      // 2\. loop over the tracked points to reject some
      int k=0;
      for( int i= 0; i < points[1].size(); i++ ) {

         // do we keep this point?
         if (acceptTrackedPoint(i)) {

            // keep this point in vector
            initial[k]= initial[i];
            points[1][k++] = points[1][i];
         }
      }

      // eliminate unsuccesful points
        points[1].resize(k);
      initial.resize(k);

      // 3\. handle the accepted tracked points
      handleTrackedPoints(frame, output);

      // 4\. current points and image become previous ones
      std::swap(points[1], points[0]);
      cv::swap(gray_prev, gray);
   }

该方法利用了其他四个工具方法。 您应该很容易地更改任何这些方法,以便为自己的跟踪器定义新的行为。 这些方法中的第一个检测特征点。 请注意,我们已经在第 8 章的第一个秘籍中讨论了cv::goodFeatureToTrack函数:

   // feature point detection
   void detectFeaturePoints() {

      // detect the features
      cv::goodFeaturesToTrack(gray, // the image 
         features,   // the output detected features
         max_count,  // the maximum number of features 
         qlevel,     // quality level
         minDist);   // min distance between two features
   }

第二个确定是否应检测到新的特征点:

   // determine if new points should be added
   bool addNewPoints() {

      // if too few points
      return points[0].size()<=10;
   }

第三个基于应用定义的标准拒绝某些跟踪点。 在这里,我们决定拒绝不移动的点(除了cv::calcOpticalFlowPyrLK函数无法跟踪的点):

   // determine which tracked point should be accepted
   bool acceptTrackedPoint(int i) {

      return status[i] &&
         // if point has moved
         (abs(points[0][i].x-points[1][i].x)+
         (abs(points[0][i].y-points[1][i].y))>2);
   }

最后,第四种方法通过在当前帧上绘制所有被跟踪的点,并用一条线将它们连接到其初始位置(即第一次检测到它们的位置)来处理被跟踪的特征点:

   // handle the currently tracked points
   void handleTrackedPoints(cv:: Mat &frame, 
                            cv:: Mat &output) {

      // for all tracked points
      for(int i= 0; i < points[1].size(); i++ ) {

         // draw line and circle
         cv::line(output, 
                  initial[i],  // initial position 
                  points[1][i],// new position 
                  cv::Scalar(255,255,255));
         cv::circle(output, points[1][i], 3, 
                    cv::Scalar(255,255,255),-1);
      }
   }

然后将编写一个简单的main函数来跟踪视频序列中的特征点,如下所示:

int main()
{
   // Create video procesor instance
   VideoProcessor processor;

   // Create feature tracker instance
   FeatureTracker tracker;

   // Open video file
   processor.setInput("../bike.avi");

   // set frame processor
   processor.setFrameProcessor(&tracker);

   // Declare a window to display the video
   processor.displayOutput("Tracked Features");

   // Play the video at the original frame rate
   processor.etDelayetDelay(1000./processor.getFrameRate());

   // Start the process
   processor.run();
}

生成的程序将显示跟踪的特征随时间的变化。 例如,这是两个不同时刻的两个这样的帧。 在此视频中,摄像机是固定的。 因此,年轻的自行车手是唯一的运动对象。 这是视频开头的一帧:

How to do it...

几秒钟后,我们得到以下帧:

How to do it...

工作原理

要从一帧到另一帧跟踪特征点,我们必须在下一帧中找到特征点的新位置。 如果我们假设特征点的强度从一帧到下一帧都没有变化,那么我们正在寻找位移(u, v)使得:

How it works...

其中ItIt + 1分别是当前帧和下一瞬间的帧。 这种恒定强度的假设通常适用于两个接近瞬间拍摄的图像中的小位移。 然后,我们可以使用泰勒展开来通过包含图像导数的方程式近似该方程式:

How it works...

后面的方程式将我们引到另一个方程式(由于恒定强度假设的结果):

How it works...

这个众所周知的约束是基本的光流约束方程。 所谓的 Lukas-Kanade 特征跟踪算法通过进行额外的假设来利用它。 特征点附近的所有点的位移都相同。 因此,我们可以对所有这些点施加唯一的(u, v)未知位移的光流约束。 与未知数(2)相比,这给了我们更多的方程式,因此我们可以在均方意义上求解该方程式。 在实践中,它是迭代解决的,并且 OpenCV 实现还提供了以不同分辨率执行此估计的可能性,以使搜索更有效且更能容忍更大的位移。 默认情况下,图像级别数为 3,窗口大小为 15。显然,可以更改这些参数。 您还可以指定终止条件,该条件定义了停止迭代搜索的条件。 cv::calcOpticalFlowPyrLK的第六个参数包含可用于评估跟踪质量的残留均方误差。 第五个参数包含二进制标志,这些标志告诉我们跟踪相应点是否被视为成功。

上面的描述代表了 Lukas-Kanade 跟踪器背后的基本原理。 当前的实现包含其他优化和改进,以使该算法在计算大量特征点的位移时更加高效。

另见

本书的第 8 章讨论了特征点检测。

The classic article by B. Lucas and T. Kanade, An iterative image registration technique with an application to stereo vision in Int. Joint Conference in Artificial Intelligence, pp. 674-679, 1981, that describes the original feature point tracking algorithm.

The article by J. Shi and C. Tomasi, Good Features to Track in IEEE Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994, that describes an improved version of the original feature point tracking algorithm.

提取视频中的前景对象

当固定摄像机观察到场景时,背景大部分保持不变。 在这种情况下,有趣的元素是在此场景内演化的运动对象。 为了提取这些前景对象,我们需要构建背景模型,然后将该模型与当前帧进行比较以检测任何前景对象。 这就是我们在本秘籍中要做的。 前景提取是智能监控应用中的基本步骤。

操作步骤

如果我们可以使用场景背景的图像(即一个不包含前景对象的帧),那么通过一个简单的图像差异就可以很容易地提取当前帧的前景:

   // compute difference between current image and background
   cv::absdiff(backgroundImage,currentImage,foreground);

然后,将其差异足够大的每个像素声明为前景像素。 但是,在大多数情况下,此背景图片并不容易获得。 确实,要保证给定图像和繁忙场景中不存在前景物体可能很困难,因此这种情况很少发生。 此外,背景场景通常随时间变化,例如因为照明条件可能发生变化(例如,从日出到日落),或者因为可以在背景中添加或移除新对象。

因此,有必要动态建立背景场景的模型。 这可以通过观察场景一段时间来完成。 如果我们假设大多数情况下背景在每个像素位置都是可见的,那么简单地计算所有观测值的平均值可能是一个很好的策略。 但这由于许多原因是不可行的。 首先,这将需要在计算背景之前存储大量图像。 其次,当我们累积图像以计算平均图像时,将不会进行前景提取。 该解决方案还提出了应累积何时和多少图像以计算可接受的背景模型的问题。 另外,给定像素正在观察前景对象的图像将对平均背景的计算产生影响。

更好的策略是通过定期更新来动态构建背景模型。 这可以通过计算所谓的移动平均值(也称为滑动平均值)来实现。 这是一种计算时间信号平均值的方法,该方法考虑了收到的最新值。 如果pt是给定时间t的像素值,而μt-1是当前平均值,则使用以下公式更新该平均值:

How to do it...

参数α称为学习率,它定义了当前值对当前估计平均值的影响。 该值越大,移动平均值将越快地适应观测值的变化。 要建立背景模型,只需计算输入帧中每个像素的移动平均值。 然后,仅基于当前图像和背景模型之间的差异来决定是否声明前景像素。

然后让我们构建一个实现此想法的类:

class BGFGSegmentor : public FrameProcessor {

   cv::Mat gray;         // current gray-level image
   cv::Mat background;      // accumulated background
   cv::Mat backImage;      // background image
   cv::Mat foreground;      // foreground image
   // learning rate in background accumulation
   double learningRate;    
   int threshold;         // threshold for foreground extraction

  public:

   BGFGSegmentor() : threshold(10), learningRate(0.01) {}

然后,主要过程包括将当前帧与背景模型进行比较,然后更新此模型:

   // processing method
   void process(cv:: Mat &frame, cv:: Mat &output) {

      // convert to gray-level image
      cv::cvtColor(frame, gray, CV_BGR2GRAY); 

      // initialize background to 1st frame
      if (background.empty())
         gray.convertTo(background, CV_32F);

      // convert background to 8U
      background.convertTo(backImage,CV_8U);

      // compute difference between image and background
      cv::absdiff(backImage,gray,foreground);

      // apply threshold to foreground image        
      cv::threshold(foreground,output,
                    threshold,255,cv::THRESH_BINARY_INV);

      // accumulate background
      cv::accumulateWeighted(gray, background, 
                             learningRate, output);
   }

使用我们的视频处理框架,前景提取程序将按以下方式构建:

int main()
{
   // Create video procesor instance
   VideoProcessor processor;

   // Create background/foreground segmentor 
   BGFGSegmentor segmentor;
   segmentor.setThreshold(25);

   // Open video file
   processor.setInput("../bike.avi");

   // set frame processor
   processor.setFrameProcessor(&segmentor);

   // Declare a window to display the video
   processor.displayOutput("Extracted Foreground");

   // Play the video at the original frame rate
   processor.setDelay(1000./processor.getFrameRate());

   // Start the process
   processor.run();
}

将显示的结果二进制前景图像之一是:

How to do it...

工作原理

通过cv::accumulateWeighted函数可以轻松地计算图像的运行平均值,该函数将运行平均值公式应用于图像的每个像素。 请注意,生成的图像必须是浮点图像。 这就是为什么我们必须先将背景模型转换为背景图像,然后再将其与当前帧进行比较。 一个简单的阈值绝对差(由cv::absdiff ,然后由cv::threshold计算)提取前景图像。 请注意,然后我们将前景图像用作cv::accumulateWeighted的遮罩,以避免更新声明为前景的像素。 之所以可行,是因为我们的前景图像在前景像素处被定义为假(即 0)(这也解释了为什么前景对象在结果图像中显示为黑色像素)。

最后,应注意,为简单起见,由我们的程序构建的背景模型是基于提取帧的灰度版本的。 维持彩色背景将需要计算每个像素每个通道的移动平均值。 通常,这种额外的计算不会显着改善结果。 而是,主要困难是确定阈值的适当值,该阈值将为给定视频提供良好的结果。

更多

前面提取场景中前景对象的简单方法非常适合显示相对稳定背景的简单场景。 但是,在许多情况下,背景场景可能在某些区域中的不同值之间波动,从而导致频繁的虚假前景检测。 例如,这可能是由于移动的背景对象(例如,树叶)或炫耀效果(例如,在水面上)引起的。 为了解决这个问题,已经引入了更复杂的背景建模方法。

高斯方法的混合

这些算法之一是高斯方法的混合。 它以与本秘籍中介绍的方法类似的方式进行,但有以下补充:

首先,该方法每个像素维护一个以上的模型(即,多个运行平均值)。 这样,如果背景像素在两个值之间波动,那么将存储两个移动平均值。 仅当新像素值不属于任何维护的模型时,才将其声明为前景。

其次,不仅要维护每个模型的运行平均值,还要维护运行方差。 这一计算如下:

The Mixture of Gaussian method

计算的平均值和方差形成高斯模型,从中可以估计给定像素值属于该高斯模型的概率。 由于现在将阈值表示为概率而不是绝对差,因此这使确定合适的阈值更加容易。 另外,在背景值具有较大波动的区域中,将需要更大的差异来声明前景对象。

实际上,当给定的高斯模型没有足够频繁地命中时,它被排除为背景模型的一部分。 相反,当发现像素值不在当前维护的背景模型之外(即,它是前景像素)时,将创建一个新的高斯模型。 如果将来,如果此新模型频繁使用,那么它将与背景相关联。

这种更复杂的算法显然比我们简单的背景/前景分割器更复杂。 幸运的是,存在一个称为cv::BackgroundSubtractorMOG的 OpenCV 实现,它被定义为更通用的cv::BackgroundSubtractor类的子类。 与默认参数一起使用时,此类非常易于使用:

int main()
{
   // Open the video file
    cv::VideoCapture capture("../bike.avi");
   // check if video successfully opened
   if (!capture.isOpened())
      return 0;

   // current video frame
   cv::Mat frame; 
   // foreground binary image
   cv::Mat foreground;

   cv::namedWindow("Extracted Foreground");

   // The Mixture of Gaussian object
   // used with all default parameters
   cv::BackgroundSubtractorMOG mog;

   bool stop(false);
   // for all frames in video
   while (!stop) {

      // read next frame if any
      if (!capture.read(frame))
         break;

      // update the background
      // and return the foreground
      mog(frame,foreground,0.01);

      // Complement the image        
      cv::threshold(foreground,foreground,
                    128,255,cv::THRESH_BINARY_INV);

      // show foreground
      cv::imshow("Extracted Foreground",foreground);

      // introduce a delay
      // or press key to stop
      if (cv::waitKey(10)>=0)
            stop= true;
   }
}

可以看出,只需创建类实例并调用同时更新背景并返回前景图像的方法即可(额外的参数是学习率)。 显示的细分之一将是:

The Mixture of Gaussian method

每个像素可能的高斯模型的数量构成此类的参数之一。

另见

The article by C. Stauffer and W.E.L. Grimson, Adaptive background mixture models for real-time tracking, in Conf. on Computer Vision and Pattern Recognition, 1999, for a more complete description of the Mixture of Gaussian algorithm.
posted @ 2025-09-21 12:11  绝不原创的飞龙  阅读(58)  评论(0)    收藏  举报