OpenCV3-计算机视觉应用编程-全-

OpenCV3 计算机视觉应用编程(全)

原文:annas-archive.org/md5/a22a007bcf5afff9c98823d8f44f04b3

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

增强现实、驾驶辅助、视频监控;越来越多的应用现在正在使用计算机视觉和图像分析技术,但我们仍然处于能够通过视觉感知理解我们世界的全新计算机化系统发展的初期。随着强大且价格合理的计算设备和视觉传感器的出现,创建复杂的成像应用从未如此简单。众多软件工具和库可以处理图像和视频,但对于任何希望开发基于智能视觉的应用的人来说,OpenCV 库是必须使用的工具。OpenCV(开源计算机视觉)是一个包含超过 500 个针对图像和视频分析优化的算法的开源库。自 1999 年推出以来,它已被计算机视觉研究者和开发者社区广泛采用作为主要开发工具。OpenCV 最初由 Gary Bradski 领导的一个团队在英特尔开发,作为推动视觉研究并促进基于视觉、CPU 密集型应用发展的一个倡议。经过一系列的测试版发布后,2006 年推出了 1.0 版本。2009 年,随着 OpenCV 2 的推出,发生了第二次重大发布,提出了重要变化,特别是新的 C++接口,这是我们在这本书中使用的。2012 年,OpenCV 重塑了自己,作为一个非营利性基金会(opencv.org/),依靠众筹来支持其未来的发展。2013 年推出了 OpenCV3;主要更改是为了提高库的可用性。其结构已被修订以删除不必要的依赖项,大型模块已被拆分为更小的模块,API 已被精炼。这本书是《OpenCV 计算机视觉应用编程食谱》的第三版,也是第一个涵盖 OpenCV 版本 3 的版本。所有前版的编程食谱都经过了审查和更新。我们还添加了新的内容和新的章节,为读者提供对库基本功能的更好覆盖。本书涵盖了库的许多功能,并解释了如何使用它们来完成特定任务。我们的目标不是提供对 OpenCV 函数和类提供的每个选项的详细覆盖,而是为您提供构建应用程序所需的基本元素。我们还在本书中探讨了图像分析的基本概念,并描述了一些计算机视觉中的重要算法。这本书是您进入图像和视频分析世界的机会。但这只是开始。好消息是 OpenCV 仍在不断发展和扩展。只需查阅 OpenCV 在线文档(opencv.org/),即可了解库能为您做什么。您还可以访问作者的网站(www.laganiere.name/),获取关于这本食谱的最新信息。

本书涵盖内容

第一章, 玩转图像,介绍了 OpenCV 库,并展示了如何构建可以读取和显示图像的简单应用程序。它还介绍了基本的 OpenCV 数据结构。

第二章, 操作像素,解释了如何读取图像。它描述了不同的扫描图像方法,以便对每个像素执行操作。

第三章, 处理图像的颜色,包含了一系列面向对象的编程设计模式,这些模式可以帮助你构建更好的计算机视觉应用程序。它还讨论了图像中的颜色概念。

第四章, 使用直方图计算像素,展示了如何计算图像直方图以及如何使用它们来修改图像。介绍了基于直方图的不同应用,这些应用实现了图像分割、目标检测和图像检索。

第五章, 使用形态学操作变换图像,探讨了数学形态学的概念。它介绍了不同的算子以及如何使用它们在图像中检测边缘、角点和段。

第六章, 过滤图像,教授了频率分析和图像过滤的原理。它展示了如何将低通和高通滤波器应用于图像,并介绍了导数算子的概念。

第七章, 提取线条、轮廓和组件,专注于检测几何图像特征。它解释了如何在图像中提取轮廓、线条和连通组件。

第八章, 检测兴趣点,描述了图像中的各种特征点检测器。

第九章, 描述和匹配兴趣点,解释了如何计算兴趣点的描述符以及如何使用它们在图像之间匹配点。

第十章, 在图像中估计投影关系,探讨了同一场景中两个图像之间的投影关系。它还描述了如何在图像中检测特定目标。

第十一章, 重建 3D 场景,允许你从多张图像中重建场景的 3D 元素并恢复相机姿态。它还包括了相机校准过程的描述。

第十二章,处理视频序列,提供了一个读取和写入视频序列以及处理其帧的框架。它还展示了如何从摄像头前移动的前景对象中提取前景对象。

第十三章,跟踪视觉运动,讨论了视觉跟踪问题。它展示了如何在视频中计算视运动。它还解释了如何在图像序列中跟踪移动对象。

第十四章,从示例中学习,介绍了机器学习的基本概念。它展示了如何从图像样本中构建对象分类器。

为本书所需物品

本食谱基于 OpenCV 库的 C++ API。因此,假设您对 C++语言有一些经验。为了运行食谱中展示的示例并进行实验,您需要一个良好的 C++开发环境。Microsoft Visual Studio 和 Qt 是两个流行的选择。

本书面向对象

本食谱适合希望学习如何使用 OpenCV 库构建计算机视觉应用的初学者 C++程序员。它也适合希望了解计算机视觉编程概念的软件专业人士。它可以作为大学计算机视觉课程的学习伴侣书籍。它对于图像处理和计算机视觉的硕士和研究人员来说是一个极好的参考。

部分

在本书中,您会发现几个经常出现的标题(准备工作、如何做、它是如何工作的、还有更多、参见)。为了清楚地说明如何完成食谱,我们使用以下这些部分:

准备工作

本节告诉您在食谱中可以期待什么,并描述了如何设置任何软件或任何为食谱所需的初步设置。

如何做…

本节包含遵循食谱所需的步骤。

它是如何工作的…

本节通常包含对前节发生事件的详细解释。

还有更多…

本节包含有关食谱的附加信息,以便使读者对食谱有更多的了解。

参见

本节提供了对其他有用信息的链接,这些信息对食谱很有帮助。

习惯用法

在本书中,您将找到许多区分不同类型信息的文本样式。以下是一些这些样式的示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们可以通过使用include指令来包含其他上下文。”

代码块设置如下:

    // Compute Laplacian using LaplacianZC class
    LaplacianZC laplacian;
    laplacian.setAperture(7); // 7x7 laplacian
    cv::Mat flap= laplacian.computeLaplacian(image);
    laplace= laplacian.getLaplacianImage();

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

    // Compute Laplacian using LaplacianZC class
    LaplacianZC laplacian;
    laplacian.setAperture(7); // 7x7 laplacian
    cv::Mat flap= laplacian.computeLaplacian(image);
    laplace= laplacian.getLaplacianImage();

新术语重要单词以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,在文本中显示如下:“点击下一步按钮将您带到下一屏幕。”

读者反馈

我们始终欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢或不喜欢什么。读者反馈对我们很重要,因为它帮助我们开发出您真正能从中获得最大收益的标题。

要向我们发送一般反馈,请简单地发送电子邮件至 feedback@packtpub.com,并在邮件主题中提及书籍标题。

如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors

客户支持

现在,您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。

下载示例代码

您可以从 Packt Publishing 网站上的账户下载此书的示例代码文件。www.packtpub.com。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接发送给您。

您可以通过以下步骤下载代码文件:

  1. 使用您的电子邮件地址和密码登录或注册我们的网站。

  2. 将鼠标指针悬停在顶部的支持选项卡上。

  3. 点击代码下载与勘误

  4. 搜索框中输入书籍名称。

  5. 选择您想要下载代码文件的书籍。

  6. 从下拉菜单中选择您购买此书的来源。

  7. 点击代码下载

您还可以通过点击 Packt Publishing 网站上书籍网页上的代码文件按钮下载代码文件。您可以通过在搜索框中输入书籍名称来访问此页面。请注意,您需要登录您的 Packt 账户。

文件下载完成后,请确保使用最新版本的软件解压缩或提取文件夹:

  • WinRAR / 7-Zip for Windows

  • Zipeg / iZip / UnRarX for Mac

  • 7-Zip / PeaZip for Linux

书籍的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/OpenCV3-Computer-Vision-Application-Programming-Cookbook-Third-Edition。我们还有其他来自我们丰富图书和视频目录的代码包可供选择,网址为github.com/PacktPublishing/。查看它们吧!

本烹饪书中的示例源代码文件也托管在作者的 GitHub 仓库中。您可以通过访问作者的仓库github.com/laganiere来获取代码的最新版本。

下载本书的颜色图像

我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。彩色图像将帮助您更好地理解输出的变化。您可以从 www.packtpub.com/sites/default/files/downloads/OpenCV3ComputerVisionApplicationProgrammingCookbookThirdEdition_ColorImages.pdf 下载此文件。

勘误

尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问 www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。

要查看之前提交的勘误表,请访问 www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分下。

盗版

互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视保护我们的版权和许可证。如果您在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。

请通过 copyright@packtpub.com 联系我们,并提供疑似盗版材料的链接。

我们感谢您在保护我们的作者和我们为您提供有价值内容的能力方面的帮助。

询问

如果您在这本书的任何方面遇到问题,您可以通过 questions@packtpub.com 联系我们,我们将尽力解决问题。

第一章. 玩转图像

在本章中,我们将带你开始使用 OpenCV 库。你将学习如何执行以下任务:

  • 安装 OpenCV 库

  • 加载、显示和保存图像

  • 探索 cv::Mat 数据结构

  • 定义感兴趣的区域

简介

这章将教你 OpenCV 的基本元素,并展示如何完成最基本图像处理任务:读取、显示和保存图像。然而,在开始使用 OpenCV 之前,你需要安装库。这是一个简单的过程,将在本章的第一个食谱中解释。

你所有的计算机视觉应用程序都将涉及图像的处理。这就是为什么 OpenCV 提供了一个用于处理图像和矩阵的数据结构。这是一个功能强大的数据结构,具有许多有用的属性和方法。它还包含一个高级内存管理模型,这极大地简化了应用程序的开发。本章的最后两个食谱将教你如何使用这个重要的 OpenCV 数据结构。

安装 OpenCV 库

OpenCV 是一个开源库,用于开发可在多个平台上运行的计算机视觉应用程序,例如 Windows、Linux、Mac、Android 和 iOS。它可以在遵循 BSD 许可证的学术和商业应用中使用,该许可证允许你自由使用、分发和修改它。本食谱将向你展示如何在你的机器上安装这个库。

准备工作

当你访问 OpenCV 官方网站 opencv.org/ 时,你会找到库的最新版本,描述 应用程序编程接口API)的在线文档,以及许多其他关于 OpenCV 的有用资源。

如何做...

从 OpenCV 网站上找到最新可用的下载,并选择与你的选择平台(Windows、Linux/Mac 或 iOS)相对应的版本。一旦下载了 OpenCV 软件包,运行 WinZip 自解压程序并选择你喜欢的位置。将创建一个 opencv 目录;将目录重命名以显示你使用的版本是一个好主意(例如,在 Windows 上,你的最终目录可以是 C:\opencv-3.2)。这个目录将包含构成库的文件和目录集合。值得注意的是,你将找到包含所有源文件的 sources 目录(是的,它是开源的!)。

为了完成库的安装并使其准备好使用,你需要采取一个重要的步骤:为你的选择环境生成库的二进制文件。这确实是你要决定使用哪个目标平台来创建你的 OpenCV 应用程序的地方。你更喜欢使用哪个操作系统?应该选择哪个编译器?哪个版本?32 位还是 64 位?正如你所见,有许多可能的选项,这就是为什么你必须构建适合你需求的库。

在你的项目开发中使用的集成开发环境IDE)也将指导你做出这些选择。请注意,库包还附带预编译的二进制文件,如果你符合这种情况可以直接使用(检查sources目录旁边的build目录)。如果其中一个预编译的二进制文件满足你的要求,那么你就准备就绪了。

然而,有一个重要的说明。从版本 3 开始,OpenCV 已经分为两个主要组件。第一个是包含成熟算法的主要 OpenCV 源代码库。这就是你下载的那个。还有一个单独的贡献代码库,它包含最近由 OpenCV 贡献者添加的新计算机视觉算法。如果你的计划是只使用 OpenCV 的核心功能,你不需要contrib包。但如果你想尝试最新的最先进算法,那么你很可能需要这个额外的模块。实际上,这本烹饪书将向你展示如何使用这些高级算法中的几个。因此,你需要contrib模块来遵循这本书的食谱。所以你必须去github.com/opencv/opencv_contrib下载 OpenCV 的额外模块(下载 ZIP 文件)。你可以将额外模块解压到你的选择目录;这些模块应该在opencv_contrib-master/modules中找到。为了简单起见,你可以将这个目录重命名为contrib并将其直接复制到主包的sources目录中。请注意,你也可以选择你想要的额外模块并只保存它们;然而,在这个阶段,你可能发现简单地保留所有内容会更容易。

现在您已经准备好进行安装。为了构建 OpenCV 的二进制文件,强烈建议您使用位于cmake.orgCMake工具。CMake 是另一个开源软件工具,它使用平台无关的配置文件来控制软件系统的编译过程。它生成在您的环境中编译软件库所需的makefilesolution文件。因此,您必须下载并安装 CMake。另外,请参阅本食谱中的更多内容...部分,了解您可能希望在编译库之前安装的附加软件包,即可视化工具包VTK)。

您可以使用命令行界面运行cmake,但使用带有图形界面的CMakecmake-gui)会更简单。在后一种情况下,您只需指定包含 OpenCV 库源代码的文件夹和将包含二进制文件的文件夹。现在点击Configure并选择您选择的编译器:

如何操作...

一旦完成初始配置,CMake 将为您提供一系列配置选项。例如,您必须决定是否希望安装文档或是否希望安装一些额外的库。除非您知道自己在做什么,否则最好保持默认选项不变。然而,由于我们想要包含额外的模块,我们必须指定它们可以找到的目录:

如何操作...

一旦指定了额外的模块路径,再次点击Configure。现在您就可以通过点击Generate按钮来生成项目文件。这些文件将允许您编译库。这是安装过程的最后一步,它将使库准备好在您的开发环境中使用。例如,如果您选择 MS Visual Studio,那么您需要做的就是打开 CMake 为您创建的顶级解决方案文件(OpenCV.sln文件)。然后选择INSTALL项目(在CMakeTargets下)并执行Build命令(使用右键点击)。

如何操作...

要同时获得 发布版调试版 构建,你必须重复编译过程两次,一次针对每个配置。如果一切顺利,你将在 build 目录下创建一个 install 目录。这个目录将包含所有与你的应用程序链接的 OpenCV 库的 二进制 文件以及你的可执行文件在运行时必须调用的动态库文件。确保你设置系统环境变量 PATH(从 控制面板),以便操作系统在运行应用程序时能够找到 .dll 文件(例如,C:\opencv-3.2\build \install\x64\vc14\bin)。你还应该定义环境变量 OPENCV_DIR,指向 INSTALL 目录。这样,CMake 就能在配置未来的项目时找到库。

在 Linux 环境中,你可以使用 Cmake 生成所需的 Makefiles;然后通过执行 sudo make install 命令来完成安装。或者,你也可以使用打包工具 apt-get,它可以自动完成库的完整安装。对于 Mac OS,你应该使用 Homebrew 包管理器。一旦安装,你只需输入 brew install opencv3 --with-contrib 就可以安装完整的库(运行 brew info opencv3 查看所有可能的选项)。

它是如何工作的...

OpenCV 是一个不断发展的库。随着版本 3 的发布,该库继续扩展,提供了许多新的功能,并提高了性能。从版本 2 开始的全面 C++ API 的迁移现在几乎完成,并实现了更统一的接口。在这个新版本中引入的一个主要变化是重新组织库的模块,以方便其分发。特别是,创建了一个包含最新算法的独立仓库。这个 contrib 仓库还包含受特定许可证约束的非自由算法。目标是让 OpenCV 能够提供开发者和研究人员想要共享的最先进的功能,同时仍然能够提供一个非常稳定且维护良好的核心 API。因此,主要的模块是你从 opencv.org/ 下载库时获得的。额外的模块必须直接从托管在 GitHub 上的开发仓库(github.com/opencv/)下载。由于这些额外模块处于持续开发中,你应该预期它们包含的算法会有更频繁的变更。

OpenCV 库分为几个模块。例如,opencv_core 模块包含库的核心功能;opencv_imgproc 模块包括主要图像处理函数;opencv_highgui 模块提供图像和视频的读写功能以及一些用户界面函数;等等。要使用特定模块,您必须包含相应的顶级头文件。例如,大多数使用 OpenCV 的应用程序都从以下声明开始:

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

随着您学习使用 OpenCV,您将发现其众多模块中越来越多的功能。

还有更多...

OpenCV 网站 opencv.org/ 包含有关如何安装库的详细说明。它还包含完整的在线文档,其中包括关于库不同组件的几个教程。

可视化工具包和 cv::viz 模块

在某些应用中,计算机视觉用于从图像中重建场景的 3D 信息。当处理 3D 数据时,能够将结果可视化在某些 3D 虚拟世界中通常很有用。正如您将在 第十一章 中所学到的,重建 3D 场景cv::viz 模块提供了许多有用的函数,允许您在 3D 中可视化场景对象和相机。然而,此模块是建立在另一个开源库 VTK 之上的。因此,如果您想使用 cv::viz 模块,在编译 OpenCV 之前,您需要在您的机器上安装 VTK。

VTK 可在 www.vtk.org/ 获取。您只需下载库并使用 CMake 来为您的开发环境创建二进制文件。在这本书中,我们使用了版本 6.3.0。此外,您应该定义 VTK_DIR 环境变量,指向包含构建文件的目录。同样,在 CMake 在 OpenCV 安装过程中提出的配置选项中,确保已勾选 WITH_VTK 选项。

OpenCV 开发者网站

OpenCV 是一个开源项目,欢迎用户贡献。该库托管在 GitHub 上,这是一个基于 Git 的版本控制和源代码管理工具的在线服务。您可以通过 github.com/opencv/opencv/wiki 访问开发者网站。在其他方面,您可以访问目前正在开发的 OpenCV 版本。社区使用 Git 作为其版本控制系统。Git 也是一个免费的开源软件系统;它可能是您管理自己的源代码的最佳工具。

注意

下载本书的示例源代码:本书中展示的示例源代码也托管在 GitHub 上。请访问作者的仓库github.com/laganiere以获取代码的最新版本。请注意,您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

参见

加载、显示和保存图像

现在是时候运行你的第一个 OpenCV 应用程序了。由于 OpenCV 是关于图像处理的,这个任务将向你展示如何在图像应用开发中执行最基本操作。这些操作包括从文件中加载输入图像、在窗口上显示图像、应用处理函数以及保存输出图像。

准备工作

使用您喜欢的 IDE(例如,MS Visual Studio 或 Qt),创建一个新的控制台应用程序,其中包含一个准备填充的main函数。

如何操作...

首先要做的事情是包含头文件,声明你希望使用的类和函数。在这里,我们只想显示一张图片,因此我们需要包含声明图像数据结构的core头文件和包含所有图形界面函数的highgui头文件:

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

我们的主要函数首先声明一个将保存图片的变量。在 OpenCV 中,这是通过定义cv::Mat类的对象来完成的:

    cv::Mat image; // create an empty image 

这个定义创建了一个大小为0x0的图像。这可以通过访问cv::Mat的大小属性来确认:

    std::cout << "This image is " << image.rows << " x "  
              << image.cols << std::endl; 

接下来,对读取函数的简单调用将从文件中读取一张图片,对其进行解码,并分配内存:

    image=  cv::imread("puppy.bmp"); // read an input image 

您现在可以使用这张图片了。但是,您应该首先检查图片是否被正确读取(如果文件找不到、损坏或不在可识别的格式中,将发生错误)。使用以下代码测试图像的有效性:

    if (image.empty()) {  // error handling 
      // no image has been created... 
      // possibly display an error message 
      // and quit the application  
      ... 
    } 

empty方法返回true表示没有分配图像数据。

使用这张图片的第一件事可能是显示它。你可以使用highgui模块的功能来完成这个操作。首先,声明你想要显示图片的窗口,然后指定要在该特殊窗口上显示的图片:

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

如你所见,窗口是通过名称来识别的。你可以重用这个窗口来稍后显示另一张图像,或者你可以创建具有不同名称的多个窗口。当你运行这个应用程序时,你会看到一个图像窗口,如下所示:

如何操作...

现在,你通常会应用一些图像处理。OpenCV 提供了广泛的处理函数,本书中探索了其中的一些。让我们从一个非常简单的例子开始,即水平翻转图像。OpenCV 中的一些图像变换可以在原地执行,这意味着变换直接应用于输入图像(不创建新图像)。翻转方法就是这样。然而,我们始终可以创建另一个矩阵来保存输出结果,这正是我们将要做的:

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

结果将在另一个窗口中显示:

    cv::namedWindow("Output Image");    // the output window 
    cv::imshow("Output Image", result); 

由于这是一个在到达 main 函数末尾时会终止的控制台窗口,我们在结束程序之前添加了一个额外的 highgui 函数来等待用户按键:

    cv::waitKey(0); // 0 to indefinitely wait for a key pressed 
                    // specifying a positive value will wait for 
                    // the given amount of msec 

你可以看到,输出图像在以下屏幕截图所示的独立窗口中显示:

如何操作...

最后,你可能想在磁盘上保存处理后的图像。这是使用以下 highgui 函数完成的:

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

文件扩展名决定了将使用哪个编解码器来保存图像。其他流行的支持图像格式包括 JPG、TIFF 和 PNG。

工作原理...

OpenCV 的 C++ API 中所有类和函数都在 cv 命名空间内定义。你有两种方法可以访问它们。首先,在 main 函数的定义之前加上以下声明:

    using namespace cv; 

或者,你可以通过在所有 OpenCV 类和函数名称前加上命名空间指定符,即 cv::,来代替我们在这本书中所做的方式。使用这个前缀可以使你在代码中更容易识别 OpenCV 类和函数。

highgui 模块包含一系列函数,允许你轻松地可视化和与你的图像交互。当你使用 imread 函数加载图像时,你也可以选择将其作为灰度图像读取。这非常有优势,因为许多计算机视觉算法都需要灰度图像。在读取图像时即时转换输入彩色图像可以节省你的时间并最小化你的内存使用。这可以通过以下方式完成:

    // read the input image as a gray-scale image 
    image=  cv::imread("puppy.bmp", cv::IMREAD_GRAYSCALE); 

这将生成一个由无符号字节(C++中的 unsigned char)组成的图像,OpenCV 使用常量 CV_8U 来指定。或者,有时即使图像已保存为灰度图像,也必须以三通道彩色图像的形式读取图像。这可以通过使用正的第二个参数调用 imread 函数来实现:

    // read the input image as a 3-channel color image 
    image=  cv::imread("puppy.bmp", cv::IMREAD_COLOR); 

这次,将创建一个每像素 3 字节的图像,并在 OpenCV 中指定为 CV_8UC3。当然,如果你的输入图像已被保存为灰度图像,所有三个通道将包含相同的值。最后,如果你希望以保存的格式读取图像,只需将第二个参数输入为负值。可以使用 channels 方法检查图像中的通道数:

    std::cout << "This image has "  
              << image.channels() << " channel(s)"; 

当你使用 imread 打开图像而没有指定完整路径(就像我们在这里做的那样)时,请注意。在这种情况下,将使用默认目录。当你从控制台运行应用程序时,这个目录显然是当前控制台的目录。然而,如果你直接从 IDE 运行应用程序,默认目录通常是你项目文件所在的目录。因此,请确保你的输入图像文件位于正确的目录中。

当你使用 imshow 显示由整数组成的图像(指定为 CV_16U 用于 16 位无符号整数或 CV_32S 用于 32 位有符号整数)时,该图像的像素值将首先除以 256。这是为了尝试使其以 256 灰度级别可显示。同样,由浮点数组成的图像将通过假设可能的值范围在 0.0(显示为黑色)和 1.0(显示为白色)之间来显示。定义范围之外的价值将以白色(对于大于 1.0 的值)或黑色(对于小于 0.0 的值)显示。

highgui 模块对于构建快速原型应用非常有用。当你准备好制作应用程序的最终版本时,你可能会想使用 IDE 提供的 GUI 模块来构建一个外观更专业的应用程序。

在这里,我们的应用程序使用输入和输出图像。作为一个练习,你应该重写这个简单的程序,使其利用函数的就地处理功能,也就是说,不声明输出图像,而是直接写入:

    cv::flip(image,image,1); // in-place processing 

还有更多...

highgui 模块包含一套丰富的函数,可以帮助你与图像交互。使用这些函数,你的应用程序可以响应鼠标或键盘事件。你还可以在图像上绘制形状和写入文本。

点击图像

你可以编程你的鼠标,当它在创建的图像窗口之一上时执行特定操作。这是通过定义一个适当的回调函数来完成的。回调函数是一个你不会明确调用的函数,但你的应用程序会在响应特定事件时调用它(在这里,涉及鼠标与图像窗口交互的事件)。为了被应用程序识别,回调函数需要具有特定的签名,并且必须注册。在鼠标事件处理程序的情况下,回调函数必须具有以下签名:

    void onMouse( int event, int x, int y, int flags, void* param); 

第一个参数是一个整数,用于指定哪种鼠标事件触发了回调函数的调用。其他两个参数简单地是事件发生时鼠标位置的像素坐标。标志用于确定鼠标事件触发时按下了哪个按钮。最后,最后一个参数用于将一个指针作为额外参数发送到函数中。这个回调函数可以通过以下调用在应用程序中注册:

    cv::setMouseCallback("Original Image", onMouse,  
                          reinterpret_cast<void*>(&image)); 

在这个例子中,onMouse函数与称为原始图像的图像窗口相关联,显示的图像地址作为额外参数传递给函数。现在,如果我们定义如以下代码所示的onMouse回调函数,那么每次鼠标点击时,相应的像素值将在控制台上显示(这里,我们假设它是一个灰度图像):

    void onMouse( int event, int x, int y, int flags, void* param)  { 

      cv::Mat *im= reinterpret_cast<cv::Mat*>(param); 

      switch (event) {  // dispatch the event 

        case cv::EVENT_LBUTTONDOWN: // left mouse button down event 

          // display pixel value at (x,y) 
          std::cout << "at (" << x << "," << y << ") value is: "  
                    << static_cast<int>(               
                            im->at<uchar>(cv::Point(x,y))) << std::endl; 
          break; 
      } 
    } 

注意,为了获取(x,y)处的像素值,我们使用了cv::Mat对象的at方法;这将在第二章,操作像素中讨论。鼠标事件回调函数可以接收的其他可能的事件包括cv::EVENT_MOUSEMOVEcv::EVENT_LBUTTONUPcv::EVENT_RBUTTONDOWNcv::EVENT_RBUTTONUP

在图像上绘制

OpenCV 还提供了一些函数来在图像上绘制形状和写文本。基本形状绘制函数的例子有circleellipselinerectangle。以下是如何使用circle函数的例子:

    cv::circle(image,                // destination image  
               cv::Point(155,110),   // center coordinate 
               65,                   // radius   
               0,                    // color (here black) 
               3);                   // thickness 

cv::Point结构在 OpenCV 方法和函数中经常被用来指定一个像素坐标。注意,这里我们假设绘制是在灰度图像上进行的;这就是为什么颜色用单个整数指定。在下一道菜谱中,你将学习如何在使用cv::Scalar结构的情况下指定颜色值。也可以在图像上写文本。这可以按以下方式完成:

    cv::putText(image,                    // destination image 
                "This is a dog.",         // text 
                cv::Point(40,200),        // text position 
                cv::FONT_HERSHEY_PLAIN,   // font type 
                2.0,                      // font scale 
                255,                      // text color (here white) 
                2);                       // text thickness 

在我们的测试图像上调用这两个函数将导致以下截图:

在图像上绘制

注意,你必须包含顶层模块头文件opencv2/imgproc.hpp,这些示例才能正常工作。

参见

  • cv::Mat类是用来存储你的图像(以及显然,其他矩阵数据)的数据结构。这个数据结构是所有 OpenCV 类和函数的核心;下一道菜谱将详细解释这个数据结构。

探索 cv::Mat 数据结构

在前面的菜谱中,你被介绍到了 cv::Mat 数据结构。正如提到的,这是库的关键组件。它用于操作图像和矩阵(实际上,从计算和数学的角度来看,图像是一个矩阵)。由于你将在应用程序开发过程中广泛使用此数据结构,因此熟悉它是至关重要的。值得注意的是,在这个菜谱中,你将了解到这个数据结构包含了一个优雅的内存管理机制。

如何操作...

让我们编写以下测试程序,以便我们可以测试 cv::Mat 数据结构的各种属性:

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

    // test function that creates an image 
    cv::Mat function() { 
       // create image 
       cv::Mat ima(500,500,CV_8U,50); 
       // return it 
       return ima; 
    } 

    int main() { 
      // create a new image made of 240 rows and 320 columns 
      cv::Mat image1(240,320,CV_8U,100); 

      cv::imshow("Image", image1); // show the image 
      cv::waitKey(0); // wait for a key pressed 

      // re-allocate a new image 
      image1.create(200,200,CV_8U); 
      image1= 200; 

      cv::imshow("Image", image1); // show the image 
      cv::waitKey(0); // wait for a key pressed 

      // create a red color image 
      // channel order is BGR 
      cv::Mat image2(240,320,CV_8UC3,cv::Scalar(0,0,255)); 

      // or: 
      // cv::Mat image2(cv::Size(320,240),CV_8UC3); 
      // image2= cv::Scalar(0,0,255); 

      cv::imshow("Image", image2); // show the image 
      cv::waitKey(0); // wait for a key pressed 

      // read an image 
      cv::Mat image3=  cv::imread("puppy.bmp");  

      // all these images point to the same data block 
      cv::Mat image4(image3); 
      image1= image3; 

      // these images are new copies of the source image 
      image3.copyTo(image2); 
      cv::Mat image5= image3.clone(); 

      // transform the image for testing 
      cv::flip(image3,image3,1);  

      // check which images have been affected by the processing 
      cv::imshow("Image 3", image3);  
      cv::imshow("Image 1", image1);  
      cv::imshow("Image 2", image2);  
      cv::imshow("Image 4", image4);  
      cv::imshow("Image 5", image5);  
      cv::waitKey(0); // wait for a key pressed 

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

      cv::imshow("Image", gray); // show the image 
      cv::waitKey(0); // wait for a key pressed 

      // read the image in gray scale 
      image1= cv::imread("puppy.bmp", CV_LOAD_IMAGE_GRAYSCALE);  
      image1.convertTo(image2,CV_32F,1/255.0,0.0); 

      cv::imshow("Image", image2); // show the image 
      cv::waitKey(0); // wait for a key pressed 

      return 0; 
    } 

运行此程序并查看它产生的图像:

如何操作...

它是如何工作的...

cv::Mat 数据结构本质上由两部分组成:一个头和一个数据块。头包含与矩阵相关的所有信息(大小、通道数、数据类型等)。前面的菜谱展示了如何访问该结构头中的一些属性(例如,使用 colsrowschannels)。数据块包含图像的所有像素值。头包含一个指向此数据块的指针变量;它是 data 属性。cv::Mat 数据结构的一个重要特性是内存块仅在明确请求时才会被复制。确实,大多数操作只是简单地复制 cv::Mat 头部,这样多个对象将指向相同的数据块。这种内存管理模型使你的应用程序更高效,同时避免了内存泄漏,但需要理解其后果。本菜谱的示例说明了这一点。

默认情况下,cv::Mat 对象在创建时具有零大小,但你也可以指定一个初始大小,如下所示:

    // create a new image made of 240 rows and 320 columns 
    cv::Mat image1(240,320,CV_8U,100); 

在这种情况下,你还需要指定每个矩阵元素的类型-这里为 CV_8U,对应于 1 字节像素(灰度)图像。这里的 U 字母表示它是无符号的。你也可以使用 S 声明有符号数字。对于彩色图像,你会指定三个通道(CV_8UC3)。你也可以声明大小为 1632 的整数(例如,CV_16SC3)。你还可以访问 32 位和 64 位浮点数(例如,CV_32F)。

图像(或矩阵)的每个元素可以由多个值组成(例如,彩色图像的三个通道);因此,OpenCV 引入了一个简单的数据结构,用于在将像素值传递给函数时使用。这是 cv::Scalar 结构,通常用于存储一个或三个值。例如,要创建一个用红色像素初始化的彩色图像,请编写以下代码:

    // create a red color image 
    // channel order is BGR 
    cv::Mat image2(240,320,CV_8UC3,cv::Scalar(0,0,255)); 

类似地,灰度图像的初始化也可以通过编写 cv::Scalar(100) 来使用此结构完成。

图片大小通常也需要传递给函数。我们之前已经提到,可以使用colsrows属性来获取cv::Mat实例的维度。大小信息也可以通过只包含矩阵高度和宽度的cv::Size结构来提供。size()方法允许你获取当前矩阵的大小。这种格式在许多需要指定矩阵大小的函数中都被使用。

例如,一个图片可以创建如下:

    // create a non-initialized color image  
    cv::Mat image2(cv::Size(320,240),CV_8UC3); 

可以使用create方法始终为图像的数据块分配或重新分配内存。当一个图像已经被之前分配时,其旧内容首先被释放。出于效率的考虑,如果新提议的大小和类型与已存在的类型和大小匹配,则不会执行新的内存分配:

    // re-allocate a new image 
    // (only if size or type are different) 
    image1.create(200,200,CV_8U); 

当没有更多的引用指向给定的cv::Mat对象时,分配的内存会自动释放。这非常方便,因为它避免了与 C++中动态内存分配通常相关的常见内存泄漏问题。这是 OpenCV(从版本 2 开始引入)中的一个关键机制,通过cv::Mat类实现引用计数和浅拷贝来完成。因此,当一个图像被赋值给另一个图像时,图像数据(即像素)不会被复制;两个图像将指向相同的内存块。这也适用于通过值传递或返回的图像。保持引用计数,以便仅在所有对图像的引用都被销毁或赋值给另一个图像时才会释放内存:

    // all these images point to the same data block 
    cv::Mat image4(image3); 
    image1= image3; 

对前面任何图像应用任何转换也会影响其他图像。如果你希望创建图像内容的深度副本,请使用copyTo方法。在这种情况下,create方法被调用在目标图像上。另一个生成图像副本的方法是clone方法,它创建一个如下所示的新相同图像:

    // these images are new copies of the source image 
    image3.copyTo(image2); 
    cv::Mat image5= image3.clone(); 

在本菜谱的示例中,我们对image3应用了转换。其他图片也包含这个图片;其中一些共享相同的数据,而其他则持有这个图片的副本。检查显示的图片,找出哪些受到了image3转换的影响。

如果你需要将图片复制到另一个不一定具有相同数据类型的图片中,请使用convertTo方法:

    // convert the image into a floating point image [0,1] 
    image1.convertTo(image2,CV_32F,1/255.0,0.0); 

在这个例子中,源图片被复制到一个浮点图片中。该方法包括两个可选参数:缩放因子和偏移量。请注意,然而,两个图片必须有相同数量的通道。

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 属性也将被秘密修改,这可能会影响类的后续行为(反之亦然)。这是违反面向对象编程中封装重要原则的行为。为了避免这些错误,你应该返回属性的克隆。

更多...

当你操作 cv::Mat 类时,你会发现 OpenCV 还包括几个其他相关类。了解它们将对你来说非常重要。

输入和输出数组

如果你查看 OpenCV 文档,你会看到许多方法和函数接受 cv::InputArray 类型的参数作为输入。这是一个简单的代理类,用于泛化 OpenCV 中数组的概念,从而避免重复多个具有不同输入参数类型的方法或函数的多个版本。这基本上意味着你可以提供 cv::Mat 对象或其他兼容类型作为参数。由于它被声明为输入数组,你可以保证你的数据结构不会被函数修改。有趣的是,cv::InputArray 也可以从流行的 std::vector 类构建。这意味着这样的对象可以用作 OpenCV 方法或函数的输入参数(然而,永远不要在你的类和函数中使用这个类)。其他兼容类型是 cv::Scalarcv::Vec;后者结构将在下一章中介绍。还有一个 cv::OutputArray 代理类,用于指定与函数或方法返回的图像对应的参数。

操作小矩阵

在编写你的应用程序时,你可能需要操作小矩阵。然后你可以使用 cv::Matx 模板类及其子类。例如,以下代码声明了一个 3x3 的双精度浮点数矩阵和一个 3 元素向量。然后这两个相乘:

      // a 3x3 matrix of double 
      cv::Matx33d matrix(3.0, 2.0, 1.0, 
                         2.0, 1.0, 3.0, 
                         1.0, 2.0, 3.0); 
      // a 3x1 matrix (a vector) 
      cv::Matx31d vector(5.0, 1.0, 3.0); 
      // multiplication 
      cv::Matx31d result = matrix*vector; 

如你所见,通常的数学运算符可以应用于这些矩阵。

参见

  • 完整的 OpenCV 文档可以在 docs.opencv.org/ 找到

  • 第二章,操作像素,将向您展示如何高效地访问和修改由 cv::Mat 类表示的图像的像素值

  • 下一个配方 定义感兴趣区域 将解释如何在图像内部定义感兴趣区域

定义感兴趣区域

有时,一个处理函数只需要应用于图像的一部分。OpenCV 集成了定义图像子区域并像常规图像一样操作的一个优雅且简单的机制。这个配方将教会您如何在图像内部定义感兴趣区域。

准备中

假设我们想要将一个小图像复制到一个大图像上。例如,让我们假设我们想要将以下标志插入到我们的测试图像中:

准备中

要做到这一点,可以在一个感兴趣区域ROI)上定义复制操作。正如我们将看到的,ROI 的位置将决定标志将插入图像的位置。

如何做...

第一步是定义 ROI。一旦定义,ROI 就可以像常规的 cv::Mat 实例一样进行操作。关键是 ROI 确实是一个指向其父图像相同数据缓冲区的 cv::Mat 对象,并且有一个头文件指定 ROI 的坐标。插入标志的操作如下:

    // define image ROI at image bottom-right 
    cv::Mat imageROI(image,  
              cv::Rect(image.cols-logo.cols,   // ROI coordinates 
                       image.rows-logo.rows, 
                       logo.cols,logo.rows));  // ROI size 

    // insert logo 
    logo.copyTo(imageROI); 

在这里,image 是目标图像,而 logo 是标志图像(尺寸较小)。执行前面的代码后,得到以下图像:

如何做...

它是如何工作的...

定义 ROI 的一种方法是通过使用一个 cv::Rect 实例。正如其名称所示,它通过指定左上角的位置(构造函数的前两个参数)和矩形的尺寸(宽度在最后两个参数中给出)来描述一个矩形区域。在我们的例子中,我们使用了图像的大小和标志的大小来确定标志将覆盖图像右下角的位置。显然,ROI 应始终完全位于父图像内部。

ROI 也可以使用行和列的范围来描述。范围是从起始索引到结束索引(不包括两者)的连续序列。cv::Range 结构用来表示这个概念。因此,ROI 可以由两个范围定义;在我们的例子中,ROI 可以等价地定义为以下内容:

    imageROI= image(cv::Range(image.rows-logo.rows,image.rows),  
                    cv::Range(image.cols-logo.cols,image.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); 

更多内容...

OpenCV 的方法和函数包括许多在本书的食谱中未讨论的可选参数。当你第一次使用一个函数时,你应该花时间查看文档,以了解更多该函数提供的可能选项。一个非常常见的选项是定义图像掩码。

使用图像掩码

一些 OpenCV 操作允许你定义一个掩码,这将限制给定函数或方法的应用范围,这些函数或方法通常应该在所有图像像素上操作。掩码是一个 8 位图像,你应该在你想应用操作的所有位置上非零。在对应于掩码零值的像素位置,图像保持不变。例如,可以使用掩码调用copyTo方法。我们可以在这里使用它来复制之前显示的标志的白色部分,如下所示:

    // define image ROI at image bottom-right 
    imageROI= image(cv::Rect(image.cols-logo.cols, 
                             image.rows-logo.rows, 
                             logo.cols,logo.rows)); 
    // use the logo as a mask (must be gray-level) 
    cv::Mat mask(logo); 

    // insert by copying only at locations of non-zero mask 
    logo.copyTo(imageROI,mask); 

以下图像是通过执行前面的代码获得的:

使用图像掩码

我们标志的背景是黑色的(因此,它的值是0);这就是为什么它可以很容易地用作复制的图像和掩码。当然,你可以在你的应用程序中定义你选择的掩码;大多数基于像素的 OpenCV 操作都给你使用掩码的机会。

参见

  • rowcol方法将在第二章的“使用邻接访问扫描图像”食谱中用到,操作像素。这些是rowRangecolRange方法的特殊情况,其中起始和结束索引相等,以定义单行或单列 ROI。

第二章. 操作像素

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

  • 访问像素值

  • 使用指针扫描图像

  • 使用迭代器扫描图像

  • 编写高效的图像扫描循环

  • 使用邻域访问扫描图像

  • 执行简单的图像算术

  • 重映射图像

简介

为了构建计算机视觉应用,你需要能够访问图像内容,并最终修改或创建图像。本章将教你如何操作图像元素(也称为像素)。你将学习如何扫描图像并处理其每个像素。你还将学习如何高效地做到这一点,因为即使是尺寸适中的图像也可能包含数十万个像素。

从本质上讲,图像是一个数值矩阵。这就是为什么,正如我们在第一章,玩转图像中学到的,OpenCV 使用cv::Mat数据结构来操作它们。矩阵的每个元素代表一个像素。对于灰度图像(黑白图像),像素是无符号 8 位值(即类型unsigned char),其中0对应黑色,255对应白色。

对于彩色图像,需要三个主要颜色值来重现不同的可见颜色。这是由于我们的视觉系统是三原色的;视网膜上的三种锥状细胞将颜色信息传递给大脑。这意味着对于彩色图像,每个像素必须关联三个值。在摄影和数字成像中,常用的主要颜色通道是红色、绿色和蓝色。在这种情况下,矩阵元素由三个 8 位值的组合组成。请注意,尽管 8 位通道通常足够,但在某些专业应用中需要 16 位通道(例如医学成像)。

如前一章所述,OpenCV 还允许你创建具有其他类型像素值(例如,整数CV_32UCV_32S)和浮点数(CV_32F)的矩阵(或图像)。这些对于存储某些图像处理任务中的中间值非常有用。大多数操作可以应用于任何类型的矩阵;其他操作需要特定的类型或仅与特定数量的通道一起工作。因此,为了避免常见的编程错误,理解函数的前置条件至关重要。

在本章中,我们使用以下彩色图像作为输入(参考书籍的图形 PDF 或书籍的网站以查看此图像的颜色):

简介

访问像素值

为了访问矩阵中的每个单独元素,你只需要指定其行和列号。相应的元素,可以是单个数值或多通道图像中的值向量,将被返回。

准备工作

为了说明对像素值的直接访问,我们将创建一个简单的函数,该函数向图像添加盐和胡椒噪声。正如其名所示,盐和胡椒噪声是一种特定的噪声,其中一些随机选择的像素被白色或黑色像素所替代。这种类型的噪声可能在通信故障中发生,当某些像素的值在传输过程中丢失时。在我们的情况下,我们只需随机选择几个像素并将它们指定为白色。

如何做到这一点...

我们创建了一个接收输入图像的函数。这是我们的函数将要修改的图像。第二个参数是我们想要覆盖白色值的像素数:

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

      // C++11 random number generator 
      std::default_random_engine generator; 
      std::uniform_int_distribution<int>  
                   randomRow(0, image.rows - 1); 
      std::uniform_int_distribution<int>  
                   randomCol(0, image.cols - 1); 

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

        // random image coordinate 
        i= randomCol(generator); 
        j= randomRow(generator); 

        if (image.type() == CV_8UC1) { // gray-level image 

          // single-channel 8-bit image 
          image.at<uchar>(j,i)= 255;  

        } else if (image.type() == CV_8UC3) { // color image 

          // 3-channel 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;  
        } 
      } 
    } 

上述函数由一个循环组成,该循环将255值赋给随机选择的像素n次。在这里,使用随机数生成器选择像素列i和行j。请注意,使用type方法,我们区分了灰度图像和彩色图像的两种情况。在灰度图像的情况下,将255赋给单个 8 位值。对于彩色图像,您需要将255赋给三个主颜色通道,以获得白色像素。

您可以通过传递之前打开的图像来调用此函数。请参阅以下代码:

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

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

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

生成的图像将如下所示:

如何做到这一点...

它是如何工作的...

cv::Mat类包括几个方法来访问图像的不同属性。公共成员变量colsrows给出了图像中的列数和行数。对于元素访问,cv::Matat (int y, int x)方法,其中x是列号,y是行号。然而,方法的返回类型必须在编译时已知,并且由于cv::Mat可以持有任何类型的元素,程序员需要指定期望的返回类型。这就是为什么at方法被实现为一个模板方法。因此,当您调用它时,您必须指定图像元素类型,如下所示:

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

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

在彩色图像中,每个像素都与三个组件相关联:红色、绿色和蓝色通道。因此,包含彩色图像的cv::Mat类将返回一个包含三个 8 位值的向量。OpenCV 为这种短向量定义了一个类型,它被称为cv::Vec3b。这是一个包含三个无符号字符的向量。这解释了为什么对彩色像素的元素访问被写成如下形式:

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

channel索引指定了三个颜色通道中的一个。OpenCV 按蓝色、绿色和红色的顺序存储channel值(因此,蓝色是通道0)。您也可以直接使用短向量数据结构并编写:

    image.at<cv::Vec3b>(j, i) = cv::Vec3b(255, 255, 255); 

类似的向量类型也存在于 2 元素和 4 元素向量(cv::Vec2bcv::Vec4b)以及其他元素类型。例如,对于 2 元素浮点向量,类型名称的最后一个字母会被替换为f,即cv::Vec2f。对于短整数,最后一个字母被替换为s。这个字母是i用于整数,d用于双精度浮点向量。所有这些类型都是使用cv::Vec<T,N>模板类定义的,其中T是类型,N是向量元素的数量。

最后一点,你可能对这样一个事实感到惊讶:我们的图像修改函数使用的是按值传递的图像参数。这是因为当图像被复制时,它们仍然共享相同的图像数据。因此,当你想要修改图像内容时,并不一定需要通过引用传递图像。顺便提一下,按值传递的参数通常使编译器更容易进行代码优化。

更多内容...

cv::Mat类是通过使用 C++模板定义而变得通用的。

cv::Mat_模板类

使用cv::Mat类的at方法有时可能很麻烦,因为每次调用时都必须指定返回类型作为模板参数。在矩阵类型已知的情况下,可以使用cv::Mat_类,它是cv::Mat的模板子类。这个类定义了一些额外的方法但没有新的数据属性,以便一个类的指针或引用可以直接转换为另一个类。在这些额外的方法中,有operator(),它允许直接访问矩阵元素。因此,如果image是一个cv::Mat变量,对应于一个uchar矩阵,那么你可以编写以下代码:

    // use image with a Mat_ template 
    cv::Mat_<uchar> img(image); 
    img(50,100)= 0; // access to row 50 and column 100 

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

参见

  • There's more...部分解释了如何创建具有输入和输出参数的函数,这是使用指针扫描图像菜谱中的内容。

  • 编写高效的图像扫描循环菜谱提出了关于at方法效率的讨论。

使用指针扫描图像

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

准备工作

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

彩色图像由 3 个通道像素组成。这些通道中的每一个对应于三种主颜色之一——红色、绿色和蓝色的强度值。由于这些值都是 8 位无符号字符,所以总共有 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,64); 
    // display the image 
    cv::namedWindow("Image"); 
    cv::imshow("Image",image); 

这将给你以下图像:

如何做...

它是如何工作的...

在彩色图像中,图像数据缓冲区的第一个三个字节是左上角像素的 3 个通道值,接下来的三个字节是第一行第二个像素的值,以此类推(记住 OpenCV 默认使用 BGR 通道顺序)。因此,宽度为W和高度为H的图像将需要一个WxHx3uchar的内存块。然而,出于效率的考虑,行的长度可以通过添加一些额外的像素来填充。这是因为当行是 8 的倍数时,例如,图像处理有时可以更高效;这样它们可以更好地与局部内存配置对齐。显然,这些额外的像素不会被显示或保存;它们的精确值被忽略。OpenCV 将填充行的长度指定为有效宽度。显然,如果图像没有填充额外的像素,有效宽度将与实际图像宽度相等。我们已经了解到colsrows属性给出了图像的宽度和高度;同样,步长数据属性给出了以字节为单位的实际宽度。即使你的图像类型不是uchar,步长数据也会给出每行的字节数。像素元素的大小由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的下一个倍数,如下所示:

    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 &= mask;      // masking 
    *data++ += div>>1;  // add div/2;  
    // bitwise OR could also be used above instead of + 

通常情况下,位运算可能会导致非常高效的代码,因此当效率是需求时,它们可以构成一个强大的替代方案。

有输入和输出参数

在我们的颜色减少示例中,变换直接应用于输入图像,这被称为就地变换。这种方式,不需要额外的图像来保存输出结果,这可以在内存使用方面节省空间。然而,在某些应用中,用户可能希望保持原始图像不变。在这种情况下,用户将被迫在调用函数之前创建图像的副本。请注意,创建图像的相同深度副本的最简单方法是调用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); 

关键在于首先验证输出图像是否有一个与输入图像大小和像素类型匹配的已分配数据缓冲区。非常方便的是,这个检查被封装在cv::Matcreate方法中。这是当必须用新的大小和类型重新分配矩阵时要使用的方法。如果矩阵已经具有指定的大小和类型,则不执行任何操作,该方法简单地返回而不触摸实例。

因此,我们的函数应该简单地从一个调用create开始,构建一个与输入图像大小和类型相同的矩阵(如果需要):

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

分配的内存块大小为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*nchannels; i++) { 

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

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

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

      } // end of line 
    } 

在提供相同图像作为输入和输出的情况下,此函数与在此菜谱中首次展示的第一个版本完全等价。如果提供另一个图像作为输出,函数将正确工作,无论图像在函数调用之前是否已分配。

最后,请注意,这个新函数的两个参数本来可以声明为cv::InputArraycv::OutputArray。如第一章中所述,玩转图像,这将提供相同的行为,但在接受参数类型方面提供额外的灵活性。

高效扫描连续图像

我们之前解释过,出于效率原因,图像可以在每行的末尾填充额外的像素。然而,值得注意的是,当图像未填充时,它也可以被视为一个WxH像素的长一维数组。一个方便的cv::Mat方法可以告诉我们图像是否被填充。这就是返回true表示图像不包含填充像素的isContinuous方法。请注意,我们也可以通过编写以下测试来检查矩阵的连续性:

    // check if size of a line (in bytes) 
    // equals the number of columns times pixel size in bytes 
    image.step == image.cols*image.elemSize(); 

为了完整起见,这个测试还应该检查矩阵是否只有一行;在这种情况下,它按定义是连续的。尽管如此,始终使用isContinuous方法来测试连续性条件。在某些特定的处理算法中,你可以通过在一个单一(更长)的循环中处理图像来利用图像的连续性。我们的处理函数将如下编写:

    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();  

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

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

        // 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++) { 

            *data &= mask; 
            *data++ += div2; 
          } // end of line 
        } 
    } 

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

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

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

reshape()方法在不要求任何内存复制或重新分配的情况下更改矩阵维度。第一个参数是新的通道数,第二个参数是新的行数。列数相应地调整。

在这些实现中,内部循环按顺序处理所有图像像素。

低级指针算术

cv::Mat类中,图像数据包含在一个无符号字符的内存块中。该内存块第一个元素的地址由返回无符号字符指针的数据属性给出。因此,为了从图像的开始处开始循环,你可以编写以下代码:

    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();     

然而,即使这在我们的例子中会起作用,也不建议你这样操作。

参见

  • 本章中关于高效编写图像扫描循环的配方提出了对这里提出的扫描方法效率的讨论

  • 在第一章的探索 cv::Mat 数据结构菜谱中,玩转图像包含了关于 cv::Mat 类的属性和方法的信息。它还讨论了相关的类,如 cv::InputArraycv::OutputArray 类。

使用迭代器扫描图像

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

准备工作

在这个菜谱中,我们再次使用前一个菜谱中描述的颜色降低示例。

如何操作...

对于一个 cv::Mat 实例,可以通过首先创建一个 cv::MatIterator_ 对象来获取迭代器对象。与 cv::Mat_ 一样,下划线表示这是一个模板子类。确实,由于图像迭代器用于访问图像元素,返回类型必须在编译时已知。因此,颜色图像的迭代器声明如下:

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

或者,您也可以使用 Mat_ 模板类内部定义的 iterator 类型,如下所示:

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

然后,您可以使用通常的 beginend 迭代器方法遍历像素,但这些方法再次是模板方法。因此,我们的颜色降低函数现在编写如下:

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

      // div must be a power of 2 
      int n= staic_cast<int>( 
    log(static_cast<double>(div))/log(2.0) + 0.5); 
      // mask used to round the pixel value 
      uchar mask= 0xFF<<n; // e.g. for div=16, mask= 0xF0 
      uchar div2 = div >> 1; // div2 = div/2 

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

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

        (*it)[0]&= mask; 
        (*it)[0]+= div2; 
        (*it)[1]&= mask; 
        (*it)[1]+= div2; 
        (*it)[2]&= mask; 
        (*it)[2]+= div2; 
      } 
    } 

记住,这里的迭代器返回一个 cv::Vec3b 实例,因为我们正在处理彩色图像。每个颜色通道元素都是通过解引用运算符 [] 访问的。请注意,您也可以依赖 cv::Vec3b 重载运算符,并简单地写出:

      *it= *it/div*div+offset; 

这将对短向量中的每个元素应用操作。

它是如何工作的...

无论扫描哪种类型的集合,使用迭代器进行操作总是遵循相同的模式。

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

然后,你将获得一个初始化在起始位置(在我们的例子中,是图像的左上角)的迭代器。这是通过使用 begin 方法实现的。对于颜色图像的 cv::Mat 实例,你可以通过 image.begin<cv::Vec3b>() 来获取它。你还可以对迭代器进行算术运算。例如,如果你希望从图像的第二行开始,你可以将你的 cv::Mat 迭代器初始化为 image.begin<cv::Vec3b>()+image.cols。你的集合的结束位置可以通过类似的方式获得,但使用 end 方法。然而,这样获得的迭代器只是在你集合的外面。这就是为什么你的迭代过程必须在达到结束位置时停止。你还可以对这个迭代器进行算术运算;例如,如果你希望在最后一行之前停止,你的最终迭代将在迭代器达到 image.end<cv::Vec3b>()-image.cols 时停止。

一旦你的迭代器被初始化,你将创建一个循环,遍历所有元素直到达到结束。一个典型的 while 循环将如下所示:

    while (it!= itend) {  

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

      ... 

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

      ++it; 
    } 

++ 运算符是用来移动到下一个元素的。你也可以指定更大的步长。例如,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 Iterator 在网络上搜索,你将找到大量关于这个主题的参考资料。

编写高效的图像扫描循环

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

当你编写一个图像处理函数时,效率通常是一个需要考虑的因素。在设计你的函数时,你将经常需要检查你代码的计算效率,以便检测到任何可能减慢你程序的处理的瓶颈。

然而,重要的是要注意,除非必要,否则不应以牺牲代码清晰度为代价进行优化。简单的代码确实总是更容易调试和维护。只有对程序效率至关重要的代码部分才应该进行大量优化。

如何做...

为了测量一个函数或代码片段的执行时间,存在一个非常方便的 OpenCV 函数,称为 cv::getTickCount()。这个函数给你自上次启动计算机以来发生的时钟周期数。由于我们想要评估执行时间,所以想法是在执行某些代码之前和之后获取这个时钟周期数。为了将执行时间转换为秒,我们使用另一种方法,cv::getTickFrequency()。这给我们每秒的周期数,假设你的 CPU 有一个固定的频率(对于更近期的处理器来说,这并不一定是事实)。为了获得给定函数(或代码片段)的计算时间,通常使用的模式如下:

    const int64 start = cv::getTickCount(); 
    colorReduce(image); // a function call 
    // elapsed time in seconds 
    double duration = (cv::getTickCount()-start)/ 
                               cv::getTickFrequency(); 

它是如何工作的...

本章中 colorReduce 函数的不同实现的执行时间在此处报告。绝对运行时间数字会因机器而异(在这里,我们使用了一个配备 64 位 Intel Core i7 的 2.40 GHz 机器)。观察它们的相对差异是非常有趣的。这些结果还取决于用于生成可执行文件的特定编译器。我们的测试报告了将具有 320x240 像素分辨率的测试图像颜色减少的平均时间。我们在三种不同的配置上进行了这些测试:

  1. 配置 2:配备 64 位 Intel i5 的 2.5 GHz 机器和 Windows 10 下的 Visual Studio 14 2015 编译器

  2. 配置 1:3.6 GHz 机器,64 位 Intel i7,gcc 4.9.2 在 Ubuntu Linux 下

  3. 2011 款 MacBook Pro 2.3 GHz Intel i5 和 clang++ 7.0.2

首先,我们比较了在 Scanning an image with pointers 菜单的 There's more... 部分中展示的三种计算颜色减少的方法。

配置 1 配置 2 配置 3
整数除法 0.867 ms 0.586 ms 1.119 ms
取模运算符 0.774 ms 0.527 ms 1.106 ms
位运算符 0.015 ms 0.013 ms 0.066 ms

有趣的是观察到使用位运算符的公式比其他方法要快得多。其他两种方法有相似的运行时间。因此,重要的是花时间确定在图像循环中计算结果的最有效方式,因为其净影响可能非常显著。

在循环中,你应该避免重复计算那些可以被预先计算的价值。这显然会消耗时间。例如,将我们颜色减少函数的内部编写如下是不好的主意:

    for (int i=0; i<image.cols * image.channels(); i++) { 
      *data &= mask; 
      *data++ += div/2; 

事实上,在这段先前的代码中,循环需要反复计算一行中的元素总数以及div/2的结果。因此,更好的代码如下:

    int nc= image.cols * image.channels(); 
    uchar div2= div>>1;  

    for (int i=0; i<nc; i++) { 
      *(data+i) &= mask; 
      *(data+i) += div2; 

平均而言,需要重新计算的代码比更优解慢 10 倍。然而,请注意,一些编译器可能能够优化这类循环,并仍然生成高效的代码。

使用迭代器扫描图像配方中所示,使用迭代器(和位运算符)的颜色减少函数版本,在我们的三个配置中给出了 0.480 毫秒、0.320 毫秒和 0.655 毫秒的较慢结果。迭代器的主要目标是简化图像扫描过程,并使其更不容易出错。

为了完整性,我们还实现了一个使用at方法进行像素访问的功能版本。这个实现的主要循环将简单地如下所示:

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

        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 line 
    } 

这个实现的运行时间较慢,为 0.925 毫秒、0.580 毫秒和 1.128 毫秒。这种方法应该仅用于图像像素的随机访问,而永远不应该用于扫描图像。

此外,一个短循环(语句少)通常比一个长循环(只处理一个语句)执行得更有效率,即使处理的总元素数量相同。同样,如果你需要对像素应用N种不同的计算,最好在一个循环中应用所有这些计算,而不是为每种计算编写N个连续的循环。

我们还执行了连续图像情况下产生一个循环的连续性测试,而不是常规的行和列的双循环。通过平均 10%的因子,我们获得了轻微的运行时间减少。一般来说,使用这种策略是一个好习惯,因为它可以导致速度的显著提升。

更多内容...

多线程是提高算法效率的另一种方法,尤其是在多核处理器出现之后。OpenMPIntel Threading Building BlocksTBB)和Posix是流行的 API,用于并发编程中创建和管理线程。此外,C++11 现在提供了对线程的内建支持。

参见

  • 执行简单的图像算术配方展示了使用 OpenCV 算术图像运算符的颜色减少函数(在更多内容部分描述),其运行时间为 0.091 毫秒、0.047 毫秒和 0.087 毫秒,对应三个测试配置。

  • 第四章中关于应用查找表修改图像外观的配方,使用直方图计数像素描述了一个基于查找表的颜色减少函数的实现。其思想是预先计算所有强度减少值,从而将运行时间缩短到 0.129 毫秒、0.098 毫秒和 0.206 毫秒。

带邻域访问扫描图像

在图像处理中,通常有一个处理函数,它根据相邻像素的值在每个像素位置计算一个值。当这个邻域包括上一行和下一行的像素时,你则需要同时扫描图像的几行。这个配方展示了如何做到这一点。

准备工作

为了说明这个配方,我们将应用一个锐化图像的处理函数。它基于拉普拉斯算子(将在第六章,过滤图像中讨论)。实际上,在图像处理中,如果你从图像中减去拉普拉斯算子,图像的边缘就会被增强,从而得到一个更清晰的图像。

这个锐化值是这样计算的:

    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());  
      int nchannels= image.channels(); // get number of channels 

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

        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=nchannels; i<(image.cols-1)*nchannels; i++) { 

          // apply sharpening operator 
          *output++= cv::saturate_cast<uchar>( 
                  5*current[i]-current[i-nchannels]- 
                  current[i+nchannels]-previous[i]-next[i]);  
        } 
      } 

      // Set the unprocessed 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)); 
    } 

注意我们如何编写这个函数,使其既能处理灰度图像也能处理彩色图像。如果我们将这个函数应用于测试图像的灰度版本,将得到以下结果:

如何做到这一点...

它是如何工作的...

为了访问上一行和下一行的相邻像素,你只需定义一些共同递增的额外指针。然后你可以在扫描循环中访问这些行的像素。

在计算输出像素值时,会调用cv::saturate_cast模板函数来处理操作的结果。这是因为,通常情况下,应用于像素的数学表达式会导致结果超出允许的像素值范围(即低于0或超过255)。解决方案是将这些值重新调整到这个 8 位范围内。这是通过将负值变为0和将超过255的值变为255来实现的。这正是cv::saturate_cast<uchar>函数所做的事情。此外,如果输入参数是浮点数,则结果会被四舍五入到最接近的整数。显然,你可以使用这个函数与其他类型一起使用,以确保结果将保持在由该类型定义的限制内。

由于它们的邻域没有完全定义,无法处理的边界像素需要单独处理。在这里,我们只是将它们设置为0。在其他情况下,可能为这些像素执行特殊的计算,但大多数情况下,花费时间处理这么少的像素是没有意义的。在我们的函数中,这些边界像素通过两个特殊方法rowcol被设置为0。它们返回一个特殊的cv::Mat实例,该实例由一个参数指定的单行 ROI(或单列 ROI)组成(记住,我们在上一章中讨论了感兴趣区域)。这里没有进行复制;因此,如果这个一维矩阵的元素被修改,它们也会在原始图像中相应地被修改。这就是当调用setTo方法时我们所做的。这个方法将值分配给矩阵的所有元素,如下所示:

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

前面的语句将0值分配给结果图像的第一行中的所有像素。在 3 通道彩色图像的情况下,你会使用cv::Scalar(a,b,c)来指定要分配给像素每个通道的三个值。

更多内容...

当在像素邻域内进行计算时,通常用核矩阵来表示这一点。这个核描述了参与计算的像素是如何组合以获得所需结果的。对于本食谱中使用的锐化滤波器,核将如下所示:

更多内容...

除非另有说明,当前像素对应于核的中心。核中每个单元格的值代表一个乘以相应像素的因子。核应用于像素的结果就是所有这些乘积的总和。核的大小对应于邻域的大小(在这里,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函数特别有利,因为它在这种情况下使用了一个更有效的算法。

参见

  • 第六章,图像滤波,提供了更多关于图像滤波概念的解释

执行简单的图像算术

图像可以以不同的方式组合。由于它们是规则矩阵,它们可以相加、相减、相乘或相除。OpenCV 提供了各种图像算术运算符,它们的使用在本配方中进行了讨论。

准备工作

让我们使用第二个图像,我们将使用算术运算符将其与输入图像组合。以下表示这个第二个图像:

准备工作

如何做...

在这里,我们添加两个图像。当我们想要创建一些特殊效果或在一个图像上叠加信息时,这很有用。我们通过调用cv::add函数,或者更准确地说,在这里调用cv::addWeighted函数来实现,因为我们想要一个加权求和,如下所示:

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

该操作产生了一个新的图像:

如何做...

它是如何工作的...

所有二进制算术函数都以相同的方式工作。提供两个输入,第三个参数指定输出。在某些情况下,可以用作操作中的标量乘数的权重可以指定。这些函数都有几种不同的形式;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[i]+k2*b[i]+k3;  
    cv::addWeighted(imageA,k1,imageB,k2,k3,resultC); 
    // c[i]= k*a[i]+b[i];  
    cv::scaleAdd(imageA,k,imageB,resultC); 

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

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

如果应用掩码,则仅对掩码值为非零的像素执行操作(掩码必须是单通道)。查看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 中大多数算术函数都有相应的运算符重载。因此,对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)等。当这样做有意义时,你也有相应的复合赋值运算符定义(例如+=运算符)。

编写高效的图像扫描循环配方中,我们介绍了一个使用循环扫描图像像素以对它们执行某些算术运算的颜色减少函数。根据我们在这里学到的知识,这个函数可以简单地使用输入图像上的算术运算符重写如下:

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

使用cv::Scalar的原因是我们正在操作一个彩色图像。使用图像运算符使得代码非常简单,程序员的生产力也非常高,因此你应该考虑在大多数情况下使用它们。

分割图像通道

有时你可能想独立处理图像的不同通道。例如,你可能只想对图像的一个通道执行操作。当然,你可以在图像扫描循环中实现这一点。然而,你也可以使用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函数执行逆操作,即从三个单通道图像创建一个彩色图像。

重新映射图像

在本章的食谱中,你学习了如何读取和修改图像的像素值。最后一道食谱将教你如何通过移动像素来修改图像的外观。在这个过程中,像素值不会改变;而是每个像素的位置被重新映射到新的位置。这在创建图像的特殊效果或纠正由镜头等引起的图像畸变时非常有用。

如何操作...

为了使用 OpenCV 的remap函数,你首先必须定义在重映射过程中要使用的映射。其次,你必须将此映射应用于输入图像。显然,你定义映射的方式将决定产生的效果。在我们的例子中,我们定义了一个变换函数,该函数将在图像上创建波浪效果:

    // remapping an image by creating wave effects 
    void wave(const cv::Mat &image, cv::Mat &result) { 

      // the map functions 
      cv::Mat srcX(image.rows,image.cols,CV_32F); 
      cv::Mat srcY(image.rows,image.cols,CV_32F); 

      // creating the mapping 
      for (int i=0; i<image.rows; i++) { 
        for (int j=0; j<image.cols; j++) { 

          // new location of pixel at (i,j) 
          srcX.at<float>(i,j)= j; // remain on same column 
                                  // pixels originally on row i are now 
                                  // moved following a sinusoid 
          srcY.at<float>(i,j)= i+5*sin(j/10.0); 
        } 
      } 

      // applying the mapping 
      cv::remap(image,                // source image 
                result,               // destination image 
                srcX,                 // x map 
                srcY,                 // y map 
                cv::INTER_LINEAR);    // interpolation method 
    } 

结果如下:

如何操作...

它是如何工作的...

重映射的目标是生成一个新的图像版本,其中像素的位置已改变。为了构建这个新图像,我们需要知道目标图像中每个像素的原始位置。因此,所需的映射函数将给出原始像素位置作为新像素位置的函数。这被称为反向映射,因为这种变换描述了新图像的像素如何映射回原始图像。在 OpenCV 中,反向映射使用两个映射来描述:一个用于x坐标,一个用于y坐标。它们都由浮点cv::Mat实例表示:

    // the map functions 
    cv::Mat srcX(image.rows,image.cols,CV_32F); // x-map 
    cv::Mat srcY(image.rows,image.cols,CV_32F); // y-map 

这些矩阵的大小将定义目标图像的大小。然后,可以使用以下代码行在源图像中读取目标图像的(i,j)像素值:

    ( srcX.at<float>(i,j) , srcY.at<float>(i,j) ) 

例如,像我们在第一章中演示的简单图像翻转效果,玩转图像,可以通过以下映射创建:

    // creating the mapping 
    for (int i=0; i<image.rows; i++) { 
      for (int j=0; j<image.cols; j++) { 

        // horizontal flipping 
        srcX.at<float>(i,j)= image.cols-j-1; 
        srcY.at<float>(i,j)= i; 
      } 
    } 

要生成结果图像,你只需调用 OpenCV 的remap函数:

    // applying the mapping 
    cv::remap(image,             // source image
              result,            // destination image 
              srcX,              // x map 
              srcY,              // y map 
              cv::INTER_LINEAR); // interpolation method 

值得注意的是,这两张地图包含浮点值。因此,目标中的一个像素可以映射到一个非整数值(即像素之间的位置)。这非常方便,因为这允许我们定义我们选择的映射函数。例如,在我们的重映射示例中,我们使用正弦函数来定义我们的转换。然而,这也意味着我们必须在真实像素之间插值虚拟像素的值。存在不同的像素插值方法,remap函数的最后一个参数允许我们选择将使用的方法。像素插值是图像处理中的一个重要概念;这个主题将在第六章,图像滤波中讨论。

参见

  • 第六章,滤波图像使用低通滤波器配方中的更多内容...部分解释了像素插值的概念

  • 第十一章,重建 3D 场景中的校准相机配方使用重映射来校正图像中的镜头畸变

  • 第十章,估计图像中的投影关系中的计算两张图像之间的单应性配方使用透视图像扭曲来构建图像全景

第三章:处理图像的颜色

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

  • 使用策略设计模式比较颜色

  • 使用 GrabCut 算法分割图像

  • 转换颜色表示

  • 使用色调、饱和度和亮度表示颜色

简介

能够用颜色看世界是人类视觉系统的一个重要特征。人眼视网膜包括称为锥状体的特殊光感受器,它们负责感知颜色。有三种类型的锥状体,它们在吸收光波的波长范围上有所不同;通过这些不同细胞产生的刺激,人脑能够创造出颜色感知。大多数其他动物只有棒状细胞,这些是具有更好光敏感性的光感受器,但它们覆盖了整个可见光谱而没有颜色区分。在人眼中,棒状细胞主要位于视网膜的边缘,而锥状体则集中在中央部分。

在数字成像中,颜色通常是通过使用红色、绿色和蓝色加色原色来再现的。这些颜色被选中是因为当它们组合在一起时,可以产生广泛的颜色范围。实际上,这种原色选择很好地模仿了人类视觉系统的三色色觉,因为不同的锥状细胞对红色、绿色和蓝色光谱的敏感性位于周围。在本章中,你将玩转像素颜色,看看图像可以根据颜色信息进行分割。此外,你将了解到在执行颜色图像处理时,有时使用不同的颜色表示可能是有用的。

使用策略设计模式比较颜色

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

为了实现这一目标,这个食谱将使用策略设计模式。这种面向对象的设计模式构成了将算法封装在类中的绝佳方式。这样,替换给定算法为另一个算法,或者将几个算法链接在一起以构建更复杂的过程,就变得更容易了。此外,这个模式通过尽可能隐藏其复杂性,简化了算法的部署,提供了一个直观的编程接口。

如何操作...

一旦使用策略设计模式将算法封装在类中,就可以通过创建这个类的实例来部署它。通常,实例将在程序初始化时创建。在构建时,类实例将使用它们的默认值初始化算法的不同参数,以便它立即可以投入使用。也可以使用适当的方法读取和设置算法的参数值。在具有 GUI 的应用程序的情况下,可以使用不同的小部件(文本字段、滑块等)显示和修改这些参数,以便用户可以轻松地玩弄它们。

我们将在下一节向您展示Strategy类的结构;让我们从一个示例开始,看看它是如何部署和使用的。让我们编写一个简单的main函数,该函数将运行我们提出的颜色检测算法:

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

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

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

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

      cv::waitKey(); 
      return 0; 
    } 

运行此程序以检测前一章中展示的彩色版本城堡图像中的蓝色天空,产生以下输出:

如何做到这一点…

在这里,白色像素表示对所需颜色的积极检测,而黑色表示消极。

显然,我们在这个类中封装的算法相对简单(正如我们将在下一节看到的,它只由一个扫描循环和一个容差参数组成)。当要实现的算法更复杂、有多个步骤并包含多个参数时,策略设计模式变得非常强大。

它是如何工作的…

此算法的核心过程很容易构建。它是一个简单的扫描循环,遍历每个像素,比较其颜色与目标颜色。使用我们在上一章的使用迭代器扫描图像配方中学到的知识,这个循环可以写成以下形式:

    // 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) { 

      // compute distance from target color 
      if (getDistanceToTargetColor(*it)<=maxDist) { 
        *itout= 255; 
      } else { 
       *itout= 0; 
      } 
    } 

cv::Mat变量image指向输入图像,而result指向二值输出图像。因此,第一步是设置所需的迭代器。然后扫描循环就很容易实现了。请注意,输入图像迭代器被声明为const,因为它们的元素值没有被修改。对每个像素的当前像素颜色和目标颜色之间的距离进行评估,以检查它是否在由maxDist定义的容差参数内。如果是这样,则将值255(白色)分配给输出图像;如果不是,则分配0(黑色)。为了计算到目标颜色的距离,使用getDistanceToTargetColor方法。计算这个距离有不同的方法。

例如,可以计算包含 RGB 颜色值的三个向量之间的欧几里得距离。为了使这个计算简单,我们求和 RGB 值的绝对差(这也被称为曼哈顿距离)。请注意,在现代架构中,浮点欧几里得距离可能比简单的曼哈顿距离计算更快(此外,您还可以使用平方欧几里得距离以避免昂贵的平方根运算);这也是您在设计时需要考虑的事情。此外,为了增加灵活性,我们以getColorDistance方法的形式编写了getDistanceToTargetColor方法,如下所示:

    // Computes the distance from target color. 
    int getDistanceToTargetColor(const cv::Vec3b& color) const { 
      return getColorDistance(color, target); 
    } 
    // Computes the city-block distance between two colors. 
    int getColorDistance(const cv::Vec3b& color1,  
    const cv::Vec3b& color2) const { 
      return abs(color1[0]-color2[0])+
             abs(color1[1]-color2[1])+ 
             abs(color1[2]-color2[2]); 
    } 

注意我们如何使用cv::Vec3d来存储代表颜色 RGB 值的三个无符号字符。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.size(),CV_8U); 

      // processing loop above goes here 
      return result; 
    }

每次调用此方法时,都需要检查包含结果二值图的输出图像是否需要重新分配以适应输入图像的大小。这就是为什么我们使用cv::Matcreate方法。请记住,此方法只有在指定的尺寸和/或深度与当前图像结构不对应时才会进行重新分配。

现在我们已经定义了核心处理方法,让我们看看还需要添加哪些附加方法才能部署此算法。我们之前已经确定了我们的算法需要哪些输入和输出数据。因此,我们定义了将持有这些数据的类属性:

    class ColorDetector {
      private: 

      // minimum acceptable distance 
      int maxDist;  
      // target color 
      cv::Vec3b target; 

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

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

    // empty constructor 
    // default parameter initialization here 
    ColorDetector() : maxDist(100), target(0,0,0) {} 

另一个选择可能是不要创建一个空的构造函数,而是强制用户在更复杂的构造函数中输入目标颜色和颜色距离:

    // another constructor with target and distance 
    ColorDetector(uchar blue, uchar green, uchar red, int mxDist); 

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

    // 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; 
        maxDist= distance; 
      } 

      // Gets the color distance threshold 
      int getColorDistanceThreshold() const { 
        return maxDist; 
      }

注意我们首先检查输入的有效性。再次强调,这是为了确保我们的算法永远不会在无效状态下运行。目标颜色可以以类似的方式设置,如下所示:

    // Sets the color to be detected 
    void setTargetColor(uchar blue,
                        uchar green,
                        uchar red) { 
      // BGR order 
      target = cv::Vec3b(blue, green, red); 
    } 
    // 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; 
    } 

这次,有趣的是,我们为用户提供了 setTargetColor 方法的两种定义。在定义的第一版本中,三个颜色分量被指定为三个参数,而在第二版本中,使用 cv::Vec3b 来存储颜色值。再次强调,目的是为了方便使用我们的类算法。用户可以简单地选择最适合他们需求的设置器。

还有更多...

在这个食谱中使用的示例算法包括识别图像中颜色足够接近指定目标颜色的像素。这个计算可以以其他方式完成。有趣的是,OpenCV 函数执行类似任务以提取给定颜色的连通组件。此外,可以使用函数对象来补充实现策略设计模式。最后,OpenCV 定义了一个基类 cv::Algorithm,它实现了策略设计模式的概念。

计算两个颜色向量之间的距离

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

    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 向量数组)因为减法的结果是一个整数值。

还有趣的是,回顾一下第二章,操作像素,OpenCV 的矩阵和向量数据结构包括基本算术运算符的定义。因此,可以提出以下距离计算的以下定义:

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

这个定义乍一看可能看起来是正确的;然而,它是错误的。这是因为所有这些操作始终包含对saturate_cast(参见前一章的使用邻域访问扫描图像食谱)的调用,以确保结果保持在输入类型的域内(在这里是uchar)。因此,当目标值大于相应的颜色值时,将分配0值而不是预期的负值。正确表述应该是如下:

    cv::Vec3b dist; 
    cv::absdiff(color,target,dist); 
    return cv::sum(dist)[0];

然而,使用两次函数调用计算两个 3 向量数组的距离是不高效的。

使用 OpenCV 函数

在这个食谱中,我们使用带迭代器的循环来执行我们的计算。作为替代,我们也可以通过调用一系列 OpenCV 函数来达到相同的结果。颜色检测方法将如下编写:

    cv::Mat ColorDetector::process(const cv::Mat &image) { 
      cv::Mat output; 
      // compute absolute difference with target color 
      cv::absdiff(image,cv::Scalar(target),output); 

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

      // add the 3 channels (saturation might occurs here) 
      output= images[0]+images[1]+images[2]; 
      // apply threshold 
      cv::threshold(output,                  // same input/output image 
                    output,   
                    maxDist,                // threshold (must be < 256) 
                    255,                    // max value 
                    cv::THRESH_BINARY_INV); // thresholding mode 

      return output; 
    }

此方法使用absdiff函数,该函数计算图像像素与标量值之间的绝对差值。除了标量值之外,还可以提供另一个图像作为此函数的第二个参数。在后一种情况下,将应用逐像素差异;因此,两个图像必须具有相同的大小。然后使用split函数(在第二章的执行简单图像算术食谱的There's more...部分中讨论,操作像素)提取差异图像的各个通道,以便能够将它们相加。需要注意的是,这个总和有时可能大于255,但由于总是应用饱和度,结果将被限制在255。结果是,在这个版本中,maxDist参数也必须小于256;如果你认为这种行为不可接受,应该进行修正。

最后一步是通过使用cv::threshold函数创建二值图像。这个函数通常用于将所有像素与阈值值(第三个参数)进行比较,在常规阈值模式(cv::THRESH_BINARY)中,它将定义的最大值(第四个参数)分配给所有大于指定阈值的像素,并将0分配给其他像素。在这里,我们使用了逆模式(cv::THRESH_BINARY_INV),其中定义的最大值被分配给值低于或等于阈值的像素。同样值得注意的是cv::THRESH_TOZEROcv::THRESH_TOZERO_INV模式,它们将保持大于或小于阈值的像素不变。

使用 OpenCV 函数通常是一个好主意。这样,你可以快速构建复杂的应用程序,并可能减少错误数量。结果通常更高效(归功于 OpenCV 贡献者的优化努力)。然而,当执行许多中间步骤时,你可能会发现结果方法消耗了更多的内存。

floodFill 函数

我们的ColorDetector类识别图像中与给定目标颜色相似的颜色像素。是否接受或拒绝像素的决定是简单地基于每个像素进行的。cv::floodFill函数以非常相似的方式进行,但有一个重要的区别:在这种情况下,接受像素的决定也取决于其邻居的状态。这个想法是识别一定颜色的连通区域。用户指定一个起始像素位置和确定颜色相似性的容差参数。

种子像素定义了要寻找的颜色,并且从这个种子位置开始,考虑邻居以识别相似颜色的像素;然后考虑接受邻居的邻居,依此类推。这样,可以从图像中提取出一个颜色恒定的区域。例如,为了检测示例图像中的蓝色天空区域,你可以按以下步骤进行:

    cv::floodFill(image,              // input/ouput image 
           cv::Point(100, 50),        // seed point 
           cv::Scalar(255, 255, 255), // repainted color 
           (cv::Rect*)0,              // bounding rect of the repainted set 
           cv::Scalar(35, 35, 35),    // low/high difference threshold 
           cv::Scalar(35, 35, 35),    // identical most of the time  
           cv::FLOODFILL_FIXED_RANGE);// pixels compared to seed 

种子像素(100, 50)位于天空。所有连通像素都将被测试,颜色相似的像素将被重新着色为第三个参数指定的新颜色。为了确定颜色是否相似,为高于或低于参考颜色的值独立定义了不同的阈值。在这里,我们使用了固定范围模式,这意味着测试的像素都将与种子像素的颜色进行比较。默认模式是每个测试像素与其邻居的颜色进行比较。得到的结果如下:

floodFill 函数

算法将单个连通区域重新着色(在这里,我们将天空涂成了白色)。因此,即使在其他地方有一些颜色相似的像素(例如水中),除非它们与天空区域连通,否则这些像素不会被识别。

函数对象或函数对象

使用 C++运算符重载,可以创建一个其实例表现得像函数的类。这个想法是重载operator()方法,使得对类处理方法的调用看起来就像一个简单的函数调用。结果类实例被称为函数对象,或函数对象。通常,函数对象包括一个完整的构造函数,这样它就可以在创建后立即使用。例如,你可以定义你的ColorDetector类的完整构造函数如下:

    // full constructor 
    ColorDetector(uchar blue, uchar green, uchar red, int  maxDist=100): 
                  maxDist(maxDist) {  

      // target color 
      setTargetColor(blue, green, red); 
    } 

显然,你仍然可以使用之前定义的设置器和获取器。函数对象方法可以定义如下:

    cv::Mat operator()(const cv::Mat &image) { 
      // color detection code here  
    } 

要使用这个函数方法检测给定的颜色,只需编写以下代码片段:

    ColorDetector colordetector(230,190,130,  // color 
                                100);         // threshold 
    cv::Mat result= colordetector(image);     // functor call 

如您所见,对颜色检测方法的调用现在看起来像是一个函数调用。

算法的 OpenCV 基类

OpenCV 提供了许多执行各种计算机视觉任务的算法。为了便于使用,这些算法中的大多数都被制作成了名为cv::Algorithm的通用基类的子类。这个类实现了一些由策略设计模式指定的概念。首先,所有这些算法都是通过一个专门的静态方法动态创建的,该方法确保算法始终处于有效状态(即,对于未指定的参数具有有效的默认值)。以这些子类中的一个为例,cv::ORB;这是一个兴趣点算子,将在第八章中讨论的在多个尺度上检测 FAST 特征食谱中讨论,检测兴趣点。在这里,我们只是简单地将其用作算法的说明性示例。

因此,创建此算法的实例如下:

    cv::Ptr<cv::ORB> ptrORB = cv::ORB::create(); // default state 

创建后,算法就可以使用了。例如,可以使用通用的readwrite方法来加载或存储算法的状态。算法还有专门的方法(例如,对于 ORB,可以使用detectcompute方法来触发其主要计算单元)。算法还有专门的设置方法,允许指定其内部参数。请注意,我们本来可以将指针声明为cv::Ptr<cv::Algorithm>,但在这种情况下,我们就无法使用其专门的方法。

参见

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

  • 转换颜色表示食谱介绍了感知均匀颜色空间的概念,以实现更直观的颜色比较

使用 GrabCut 算法分割图像

之前的食谱展示了如何利用颜色信息将图像分割成与场景特定元素相对应的区域。物体通常具有独特的颜色,这些颜色通常可以通过识别相似颜色的区域来提取。OpenCV 提出了一种图像分割流行算法的实现:GrabCut算法。GrabCut 是一个复杂且计算量大的算法,但它通常会产生非常准确的结果。当你想要从静态图像中提取前景对象时(例如,从一个图片中剪切并粘贴到另一个图片中),这是最好的算法。

如何做到这一点...

cv::grabCut函数易于使用。你只需要输入一个图像,并标记其中一些像素属于背景或前景。基于这种部分标记,算法将确定整个图像的前景/背景分割。

为输入图像指定部分前景/背景标记的一种方法是在其中定义一个包含前景对象的矩形:

    // define bounding rectangle 
    // the pixels outside this rectangle 
    // will be labeled as background 
    cv::Rect rectangle(5,70,260,120); 

这在图像中定义了以下区域:

如何做…

所有这个矩形以外的像素都将被标记为背景。除了输入图像及其分割图像外,调用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 

这是因为这些常量被定义为值13,而其他两个(cv::GC_BGDcv::GC_PR_BGD)被定义为02。在我们例子中,由于分割图像不包含cv::GC_FGD像素(只有cv::GC_BGD像素被输入),因此得到了相同的结果。

然后得到以下图像:

如何做…

它是如何工作的…

在前面的例子中,GrabCut 算法能够通过简单地指定一个包含这个对象(城堡)的矩形来提取前景对象。或者,也可以将cv::GC_BGDcv::GC_FGD的值分配给输入图像的一些特定像素,这些像素是通过使用掩码图像作为cv::grabCut函数的第二个参数提供的。然后,你会指定GC_INIT_WITH_MASK作为输入模式标志。这些输入标签可以通过要求用户交互式标记图像的一些元素来获得。也可以将这两种输入模式结合起来。

使用此输入信息,GrabCut 算法通过以下步骤创建背景/前景分割。最初,将前景标签(cv::GC_PR_FGD)暂时分配给所有未标记的像素。根据当前的分类,算法将像素分组为相似颜色的簇(即,背景和前景各有K个簇)。下一步是通过在前景和背景像素之间引入边界来确定背景/前景分割。

这是通过一个优化过程来完成的,该过程试图连接具有相似标签的像素,并对在相对均匀强度区域放置边界施加惩罚。这个问题可以使用图割算法有效地解决,这是一种通过将其表示为应用切割以组成最优配置的连接图来找到问题最优解的方法。得到的分割为像素产生新的标签。

然后,可以重复聚类过程,再次找到新的最优分割,依此类推。因此,GrabCut 算法是一个迭代过程,逐渐改进分割结果。根据场景的复杂性,可以在更多或更少的迭代次数中找到良好的解决方案(在简单情况下,一次迭代就足够了)。

这解释了函数的参数,用户可以指定要应用的迭代次数。算法维护的两个内部模型作为函数的参数(并返回)。因此,如果希望通过执行额外的迭代来改进分割结果,可以再次使用上次运行的模型调用该函数。

参见

  • 文章GrabCut: Interactive Foreground Extraction using Iterated Graph Cuts发表在ACM Transactions on Graphics (SIGGRAPH) 第 23 卷,第 3 期,2004 年 8 月,C. Rother, V. Kolmogorov, 和 A. Blake上,详细描述了 GrabCut 算法。

  • 在第五章的使用形态学操作变换图像中,使用分水岭分割图像菜谱介绍了另一种图像分割算法。

转换颜色表示

RGB 颜色空间基于红色、绿色和蓝色加色原色的使用。我们在本章的第一个菜谱中看到,这些原色被选择是因为它们可以产生与人类视觉系统良好对齐的广泛颜色范围。这通常是数字图像中的默认颜色空间,因为这是获取彩色图像的方式,即通过使用红色、绿色和蓝色过滤器。此外,红色、绿色和蓝色通道被归一化,以便当以相等比例组合时,可以获得灰度强度,即从黑色(0,0,0)到白色(255,255,255)

很遗憾,使用 RGB 颜色空间计算颜色之间的距离并不是衡量两种给定颜色相似度的最佳方式。实际上,RGB 不是一个感知均匀的颜色空间。这意味着在给定距离的两个颜色可能看起来非常相似,而相隔相同距离的另外两种颜色可能看起来非常不同。

为了解决这个问题,已经引入了具有感知均匀特性的其他颜色表示方法。特别是,CIE Lab是一个这样的颜色模型。通过将我们的图像转换为这种表示,图像像素与目标颜色之间的欧几里得距离将是一个衡量两种颜色视觉相似度的有意义的度量。在本菜谱中,我们将向您展示如何将颜色从一种表示转换为另一种表示,以便在其他颜色空间中工作。

如何做到这一点…

通过使用cv::cvtColor OpenCV 函数,不同颜色空间之间的图像转换可以轻松完成。让我们回顾一下本章第一道菜谱中的ColorDetector类,使用策略设计模式比较颜色。我们现在在过程方法的开始将输入图像转换为 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); 

      // 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; 

你还需要转换输入的目标颜色。你可以通过创建一个只包含一个像素的临时图像来完成这个操作。请注意,你需要保持与早期菜谱中相同的签名,即用户继续以 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)= cv::Vec3b(blue, green, 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 L*a*b*颜色空间,表示每个像素亮度的L通道在0100之间变化,在 8 位图像的情况下,它被重新缩放到0255之间。

ab通道对应于色度成分。这些通道包含有关像素颜色的信息,与其亮度无关。它们的值在-127127之间变化;对于 8 位图像,将128添加到每个值,以便使其适合0255区间。然而,请注意,8 位颜色转换将引入舍入误差,这将使转换不完全可逆。

大多数常用的颜色空间都可用。这只是一个向 OpenCV 函数提供正确的颜色空间转换代码的问题(对于 CIE Lab*,此代码为CV_BGR2Lab)。其中之一是 YCrCb,这是 JPEG 压缩中使用的颜色空间。要将颜色空间从 BGR 转换为 YCrCb,代码将是CV_BGR2YCrCb。请注意,涉及三种常规原色(红色、绿色和蓝色)的所有转换都在 RGB 和 BGR 顺序中可用。

CIE Luv颜色空间是另一个感知均匀的颜色空间。您可以使用CV_BGR2Luv代码将 BGR 转换为 CIE Luv。Lab和 Luv使用相同的转换公式来表示亮度通道,但使用不同的表示来表示色度通道。此外,请注意,由于这两个颜色空间为了使其感知均匀而扭曲 RGB 颜色域,这些转换是非线性的(因此,它们在计算上成本较高)。

此外,还有 CIE XYZ 颜色空间(使用CV_BGR2XYZ代码)。这是一个标准颜色空间,用于以设备无关的方式表示任何可感知的颜色。在计算 Luv 和 Lab 颜色空间时,XYZ 颜色空间被用作中间表示。RGB 和 XYZ 之间的转换是线性的。值得注意的是,Y通道对应于图像的灰度版本。

HSV 和 HLS 是有趣的颜色空间,因为它们将颜色分解为其色调和饱和度成分以及亮度或亮度成分,这是人类描述颜色的一种更自然的方式。下一配方将介绍这个颜色空间。

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

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

也可以在相反的方向进行转换,但结果的颜色图像的三个通道将完全填充与灰度图像中相应的值。

参见

  • 第四章中“使用直方图计数像素”的使用均值漂移算法查找对象配方使用 HSV 颜色空间来在图像中查找对象。

  • 关于颜色空间理论有许多好的参考资料。其中之一是:《颜色空间的结构和性质以及颜色图像的表示》,E. Dubois,Morgan and Claypool Publishers,2009

使用色调、饱和度和亮度表示颜色

在本章中,我们玩转了图像颜色。我们使用了不同的颜色空间,并试图识别具有均匀颜色的图像区域。最初考虑了 RGB 颜色空间,尽管它对于电子成像系统中的颜色捕捉和显示是有效的表示,但这种表示并不直观。确实,这不是人类思考颜色的方式;他们通常用色调、亮度或饱和度(即是否是鲜艳或柔和的颜色)来描述颜色。因此,引入了一个基于色调、饱和度和亮度的颜色空间,以帮助用户使用对他们来说更直观的属性来指定颜色。在本菜谱中,我们将探讨色调、饱和度和亮度作为描述颜色的手段。

如何做...

将 BGR 图像转换为另一个颜色空间是通过使用在上一节中探讨的cv::cvtColor函数来完成的。在这里,我们将使用CV_BGR2HSV转换代码:

    // convert into HSV space 
    cv::Mat hsv; 
    cv::cvtColor(image, hsv, CV_BGR2HSV); 

我们可以使用CV_HSV2BGR代码回到 BGR 空间。我们可以通过将转换后的图像通道分割成三个独立的图像来可视化每个 HSV 分量,如下所示:

    // split the 3 channels into 3 images 
    std::vector<cv::Mat> channels; 
    cv::split(hsv,channels); 
    // channels[0] is the Hue 
    // channels[1] is the Saturation 
    // channels[2] is the Value 

注意,第三个通道是颜色的值,即颜色的亮度的大致度量。由于我们正在处理 8 位图像,OpenCV 会将通道值重新缩放到0255的范围(除了色调,它将在下一节中解释,将重新缩放到00180)。这非常方便,因为我们能够将这些通道作为灰度图像显示。

城堡图像的亮度通道将看起来如下:

如何做...

同一图像在饱和度通道中将看起来如下:

如何做...

最后,带有色调通道的图像如下:

如何做...

这些图像将在下一节中进行解释。

它是如何工作的…

引入色调/饱和度/值颜色空间是因为这种表示方式与人类自然组织颜色的方式相对应。确实,人类更喜欢用直观的属性来描述颜色,如色调、饱和度和亮度。这三个属性是大多数现象颜色空间的基础。色调表示主导颜色;我们给颜色起的名字(如绿色、黄色、蓝色和红色)对应于不同的色调值。饱和度告诉我们颜色的鲜艳程度;柔和的颜色饱和度低,而彩虹的颜色饱和度高。最后,亮度是一个主观属性,指的是颜色的亮度。其他现象颜色空间使用颜色或颜色亮度的概念作为表征相对颜色强度的方法。

这些颜色组件试图模仿人类对颜色的直观感知。因此,它们没有标准定义。在文献中,你会找到关于色调、饱和度和亮度的不同定义和公式。OpenCV 提出了两种现象颜色空间的实现:HSV 和 HLS 颜色空间。转换公式略有不同,但它们给出非常相似的结果。

值组件可能是最容易解释的。在 OpenCV 实现的 HSV 空间中,它被定义为三个 BGR 组件的最大值。这是亮度概念的一种非常简单的实现。为了获得更符合人类视觉系统的亮度定义,你应该使用感知均匀的 Lab和 Luv颜色空间的 L 通道。例如,L 通道考虑了绿色颜色看起来比相同强度的蓝色颜色更亮的事实。

为了计算饱和度,OpenCV 使用基于 BGR 组件最小值和最大值的公式:

工作原理…

想法是,在灰度颜色中,三个 R、G 和 B 组件都相等时,将对应于完全未饱和的颜色;因此,它将有一个饱和度值为0。饱和度是一个介于01.0之间的值。对于 8 位图像,饱和度被缩放到介于0255之间的值,并且当以灰度图像显示时,亮度区域对应于饱和度更高的颜色。

例如,从上一节的饱和度图像中可以看出,水的蓝色比天空的浅蓝色粉彩颜色饱和度更高,正如预期的那样。根据定义,不同灰度的饱和度值等于零(因为在这种情况下,所有三个 BGR 组件都相等)。这可以在城堡的不同屋顶上观察到,这些屋顶是由深灰色石头制成的。最后,在饱和度图像中,你可能已经注意到一些位于原始图像非常暗区域对应的白色斑点。这是使用饱和度定义的结果。事实上,因为饱和度只测量最大和最小 BGR 值之间的相对差异,所以像(1,0,0)这样的三元组给出完美的饱和度1.0,即使这种颜色看起来是黑色的。因此,在暗区域测量的饱和度值是不可靠的,不应予以考虑。

颜色的色调通常用一个介于 0360 度之间的角度值来表示,红色在 0 度。在 8 位图像的情况下,OpenCV 将这个角度值除以二,以便适应 1 字节的范围。因此,每个色调值对应于一种特定的颜色色调,与其亮度和饱和度无关。例如,天空和水都有相同的色调值,大约是 200 度(强度,100),这对应于蓝色调;背景中树木的绿色色调大约是 90 度。需要注意的是,当评估饱和度非常低的颜色时,色调的可靠性较低。

HSB 颜色空间通常用一个圆锥体来表示,其中圆锥体内部的每一个点都对应着一种特定的颜色。角度位置对应于颜色的色调,饱和度是距离中心轴的距离,亮度由高度决定。圆锥体的尖端对应于黑色,其色调和饱和度都是未定义的:

工作原理…

我们还可以生成一个将展示不同色调/饱和度组合的人工图像。

    cv::Mat hs(128, 360, CV_8UC3);   
    for (int h = 0; h < 360; h++) { 
      for (int s = 0; s < 128; s++) { 
        hs.at<cv::Vec3b>(s, h)[0] = h/2;    // all hue angles 
        // from high saturation to low 
        hs.at<cv::Vec3b>(s, h)[1] = 255-s*2; 
        hs.at<cv::Vec3b>(s, h)[2] = 255;    // constant value 
      }       
    }

下一个截图的列显示了不同的可能色调(从 0 到 180),而不同的线条说明了饱和度的影响;图像的上半部分显示了完全饱和的颜色,而下半部分则对应于不饱和的颜色。所有显示的颜色都被赋予了255的亮度值:

工作原理…

通过调整 HSV 值可以创造出有趣的效果。使用照片编辑软件可以创建出多种颜色效果,这些效果都来自于这个颜色空间。例如,你可能决定通过为图像的所有像素分配一个恒定的亮度来修改图像,而不改变色调和饱和度。这可以按以下方式完成:

    // convert into HSV space 
    cv::Mat hsv; 
    cv::cvtColor(image, hsv, CV_BGR2HSV); 
    // split the 3 channels into 3 images 
    std::vector<cv::Mat> channels; 
    cv::split(hsv,channels); 
    // Value channel will be 255 for all pixels 
    channels[2]= 255; 
    // merge back the channels 
    cv::merge(channels,hsv); 
    // reconvert to BGR 
    cv::Mat newImage; 
    cv::cvtColor(hsv,newImage,CV_HSV2BGR); 

这给出了以下图像,现在看起来像一幅画。

工作原理…

还有更多...

当你想寻找特定颜色的物体时,HSV 颜色空间也非常方便使用。

使用颜色进行检测 - 肤色检测

颜色信息对于特定物体的初始检测非常有用。例如,在驾驶辅助应用中检测路标可能依赖于标准路标的颜色,以便快速识别潜在的路标候选者。检测肤色是另一个例子,检测到的皮肤区域可以用作图像中存在人类的指示器;这种方法在手势识别中非常常用,其中肤色检测用于检测手的位置。

通常,要使用颜色检测一个对象,你首先需要收集一个包含从不同观察条件下捕获的对象的大数据库图像样本。这些将被用来定义你的分类器的参数。你还需要选择你将用于分类的颜色表示。对于肤色检测,许多研究表明,来自不同种族群体的肤色在色调/饱和度空间中聚类良好。因此,我们将简单地使用色调和饱和度值来识别以下图像中的肤色:

使用颜色进行检测 - 肤色检测

我们定义了一个函数,该函数仅基于一组值(最小和最大色调,以及最小和最大饱和度)将图像的像素分类为肤色或非肤色:

    void detectHScolor(const cv::Mat& image,  // input image 
               double minHue, double maxHue,  // Hue interval 
               double minSat, double maxSat,  // saturation interval 
               cv::Mat& mask) {               // output mask 

      // convert into HSV space 
      cv::Mat hsv; 
      cv::cvtColor(image, hsv, CV_BGR2HSV); 

      // split the 3 channels into 3 images 
      std::vector<cv::Mat> channels; 
      cv::split(hsv, channels); 
      // channels[0] is the Hue 
      // channels[1] is the Saturation 
      // channels[2] is the Value 

      // Hue masking 
      cv::Mat mask1; // below maxHue 
      cv::threshold(channels[0], mask1, maxHue, 255,
                    cv::THRESH_BINARY_INV); 
      cv::Mat mask2; // over minHue 
      cv::threshold(channels[0], mask2, minHue, 255, cv::THRESH_BINARY); 

      cv::Mat hueMask; // hue mask 
      if (minHue < maxHue) 
        hueMask = mask1 & mask2; 
      else // if interval crosses the zero-degree axis 
        hueMask = mask1 | mask2; 

      // Saturation masking 
      // between minSat and maxSat 
      cv::Mat satMask; // saturation mask 
      cv::inRange(channels[1], minSat, maxSat, satMask); 

      // combined mask 
      mask = hueMask & satMask; 
    }

如果我们有大量肤色(和非肤色)样本,我们可以使用一种概率方法,其中将估计在肤色类别中观察到给定颜色的可能性与在非肤色类别中观察到相同颜色的可能性。在这里,我们根据经验定义了我们测试图像的可接受色调/饱和度区间(记住,色调的 8 位版本从0180,饱和度从0255):

    // detect skin tone 
    cv::Mat mask; 
    detectHScolor(image, 160, 10,  // hue from 320 degrees to 20 degrees  
                  25, 166,         // saturation from ~0.1 to 0.65 
                  mask); 

    // show masked image 
    cv::Mat detected(image.size(), CV_8UC3, cv::Scalar(0, 0, 0)); 
    image.copyTo(detected, mask); 

以下检测图像是作为结果获得的:

使用颜色进行检测 - 肤色检测

注意,为了简化,我们没有在检测中考虑颜色亮度。实际上,排除较亮的颜色会减少错误地将明亮的红色视为肤色的可能性。显然,要可靠且准确地检测肤色,需要进行更复杂的分析。同时,由于许多因素会影响摄影中的颜色渲染,如白平衡和光照条件,因此很难保证在不同图像上都能获得良好的检测效果。尽管如此,正如这里所示,使用色调/饱和度信息作为初始检测器可以给我们带来可接受的结果。

参考内容

  • 第五章, 《使用形态学操作转换图像》展示了如何对检测得到的二值图像进行后处理

  • 文章《皮肤颜色建模与检测方法综述,模式识别,第 40 卷,2007 年,作者:Kakumanu P.,Makrogiannis S.,Bourbakis N.》回顾了不同的皮肤检测方法

第四章. 使用直方图计数像素

本章我们将介绍以下食谱:

  • 计算图像直方图

  • 应用查找表以修改图像的外观

  • 平衡图像直方图

  • 将直方图回投影以检测特定图像内容

  • 使用均值漂移算法寻找物体

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

  • 使用积分图像计数像素

简介

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

计算图像直方图

图像由具有不同值的像素组成。例如,在 1 通道灰度图像中,每个像素都有一个介于 0(黑色)和 255(白色)之间的整数值。根据图片内容,您将在图像内部找到不同数量的每种灰度色调。

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

准备工作

本章的前三个食谱将使用以下图像:

准备工作

如何操作...

使用 OpenCV 计算直方图可以通过使用cv::calcHist函数轻松完成。这是一个通用函数,可以计算任何像素值类型和范围的多个通道图像的直方图。在这里,我们将通过为 1 通道灰度图像的情况专门化一个类来简化其使用。对于其他类型的图像,您始终可以直接使用cv::calcHist函数,它提供了您所需的所有灵活性。下一节将解释其每个参数。

目前,我们专门化类的初始化如下:

    //To create histograms of gray-level images 
    class Histogram1D { 

      private: 
        int histSize[1];          // number of bins in histogram 
        float hranges[2];         // range of values 
        const float* ranges[1];   // pointer to the value ranges 
        int channels[1];          // channel number to be examined 

      public: 
      Histogram1D() { 

        // Prepare default arguments for 1D histogram 
        histSize[0]= 256;        // 256 bins 
        hranges[0]= 0.0;         // from 0 (inclusive) 
        hranges[1]= 256.0;       // to 256 (exclusive) 
        ranges[0]= hranges;  
        channels[0]= 0;          // we look at channel 0 
      }

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

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

      cv::Mat hist; 
      // Compute 1D histogram with calcHist 
      cv::calcHist(&image, 1, // histogram of 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::Mat 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, int zoom=1) { 

      // Compute histogram first 
      cv::Mat hist= getHistogram(image); 
      // Creates image 
      return getImageOfHistogram(hist, zoom); 
    } 

    // Create an image representing a histogram (static method) 
    static cv::Mat getImageOfHistogram (const cv::Mat &hist, int zoom) { 
      // Get min and max bin values 
      double maxVal = 0; 
      double minVal = 0; 
      cv::minMaxLoc(hist, &minVal, &maxVal, 0, 0); 

      // get histogram size 
      int histSize = hist.rows; 

      // Square image on which to display histogram 
      cv::Mat histImg(histSize*zoom, histSize*zoom,
                      CV_8U, cv::Scalar(255)); 

      // set highest point at 90% of nbins (i.e. image height) 
      int hpt = static_cast<int>(0.9*histSize); 

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

        float binVal = hist.at<float>(h); 
        if (binVal>0) { 
          int intensity = static_cast<int>(binVal*hpt / maxVal); 
          cv::line(histImg, cv::Point(h*zoom, histSize*zoom),
                   cv::Point(h*zoom, (histSize - intensity)*zoom),
                   cv::Scalar(0), zoom); 
        } 
      } 

      return histImg; 
    }

使用getImageOfHistogram方法,你可以获得直方图函数的图像,该图像以条形图的形式绘制,使用线条:

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

结果是以下图像:

如何操作...

从前面的直方图可以看出,图像显示出中灰度值的大峰值和大量较暗的像素。巧合的是,这两个组主要分别对应图像的背景和前景。这可以通过在这两个组之间的过渡处对图像进行阈值处理来验证。可以使用方便的 OpenCV 函数来完成此操作,即上一章中介绍的cv::threshold函数。在这里,为了创建我们的二值图像,我们在直方图向高峰值增加之前的最小值处对图像进行阈值处理(灰度值70):

    cv::Mat thresholded;                 // output binary image 
    cv::threshold(image,thresholded,70,  // threshold value 
                  255,                   // value assigned to  
                                         // pixels over threshold value 
                  cv::THRESH_BINARY);    // thresholding type 

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

如何操作...

它是如何工作的...

cv::calcHist函数有许多参数,允许它在许多上下文中使用,如下所示:

    void calcHist(const Mat*images, // source images 
          int nimages,          // number of source images (usually 1) 
          const int*channels,   // list the channels to be used 
          InputArray mask,      // input mask (pixels to consider) 
          OutputArray hist,     // output histogram 
          int dims,             // histogram dimension (number of channels) 
          const int*histSize,   // number of bins in each dimension 
          const float**ranges,  // range of each dimension 
          bool uniform=true,    // true if equally spaced bins 
          bool accumulate=false) // to cumulate over several calls 

大多数情况下,你的直方图将是一张单通道或三通道图像。然而,该函数允许你指定一个分布在多个图像(即多个cv::Mat)上的多通道图像。这就是为什么输入图像数组是该函数的第一个参数。第六个参数dims指定了直方图的维度,例如,对于一维直方图,为 1。即使你正在分析多通道图像,你也不必在直方图的计算中使用其所有channels。要考虑的通道列在具有指定维度的channel数组中。在我们的类实现中,这个单通道默认为通道0。直方图本身由每个维度的 bin 数量(这是整数的histSize数组)以及每个维度的最小(包含)和最大(不包含)值(由 2 元素数组的ranges数组给出)描述。还可以定义非均匀直方图(在这种情况下,第二个最后一个参数将设置为false),在这种情况下,你需要指定每个 bin 的界限。

与许多 OpenCV 函数一样,可以指定一个掩码,表示你想要包含在计数中的像素(掩码值为 0 的所有像素将被忽略)。还可以指定两个额外的参数,它们都是布尔值。第一个参数指示直方图是否均匀(默认值为 true)。第二个参数允许你累积多个直方图计算的结果。如果最后一个参数为 true,则图像的像素计数将添加到当前输入直方图中的值。当你想要计算一组图像的直方图时,这很有用。

结果直方图存储在一个 cv::Mat 实例中。实际上,cv::Mat 类可以用来操作通用的 N 维矩阵。回想一下 第二章,操作像素,这个类为 1、2 和 3 维矩阵定义了 at 方法。这就是为什么我们能够在 getHistogramImage 方法中访问 1D 直方图的每个分箱时编写以下代码:

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

注意,直方图中的值以 float 类型存储。

更多内容...

本菜谱中介绍的 Histogram1D 类通过将其限制为 1D 直方图简化了 cv::calcHist 函数。这对于灰度图像很有用,但对于彩色图像呢?

计算彩色图像的直方图

使用相同的 cv::calcHist 函数,我们可以计算多通道图像的直方图。例如,一个计算彩色 BGR 图像直方图的类可以定义如下:

    class ColorHistogram { 

      private: 
        int histSize[3];        // size of each dimension 
        float hranges[2];       // range of values (same for the 3 dimensions) 
        const float* ranges[3]; // ranges for each dimension 
        int channels[3];        // channel to be considered 

      public: 
      ColorHistogram() { 

        // Prepare default arguments for a color histogram 
        // each dimension has equal size and range 
        histSize[0]= histSize[1]= histSize[2]= 256; 
        hranges[0]= 0.0;    // BRG range from 0 to 256 
        hranges[1]= 256.0; 
        ranges[0]= hranges; // in this class,   
        ranges[1]= hranges; // all channels have the same range 
        ranges[2]= hranges; 
        channels[0]= 0;     // the three channels: B 
        channels[1]= 1;     // G 
        channels[2]= 2;     // R 
      }

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

    //Computes the histogram. 
    cv::Mat getHistogram(const cv::Mat &image) { 
      cv::Mat 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 个分箱的直方图时,这个矩阵有 (256)³ 个元素,这代表超过 1600 万条记录。在许多应用中,减少直方图计算中的分箱数量会更好。也可以使用 cv::SparseMat 数据结构,它被设计用来表示大型稀疏矩阵(即具有非常少非零元素的矩阵),而不会消耗太多内存。cv::calcHist 函数有一个版本返回这样一个矩阵。因此,为了使用 cv::SparseMatrix,修改先前的方 法非常简单:

    //Computes the histogram. 
    cv::SparseMat getSparseHistogram(const cv::Mat &image) { 

      cv::SparseMat hist(3,        // number of dimensions 
                    histSize,      // size of each dimension 
                    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; 
    }

在这种情况下,直方图是三维的,这使得它更难以表示。一种可能的选项是通过显示单个 R、G 和 B 直方图来展示图像中的颜色分布。

参见

  • 本章后面的 将直方图反投影以检测特定图像内容 菜单使用颜色直方图来检测特定图像内容

应用查找表以修改图像的外观

图像直方图捕捉了场景使用可用的像素强度值的方式。通过分析图像上像素值的分布,可以使用这些信息来修改和可能改进图像。本菜谱解释了我们可以如何使用一个简单的映射函数,即查找表,来修改图像的像素值。正如我们将看到的,查找表通常由直方图分布生成。

如何做...

查找表是一个简单的单对单(或一对多)函数,它定义了像素值如何转换成新的值。它是一个 1D 数组,在常规灰度图像的情况下,有256个条目。表中的条目i给出了相应灰度的新的强度值,其表达式如下:

    newIntensity= lookup[oldIntensity]; 

OpenCV 中的cv::LUT函数将查找表应用于图像以产生一个新的图像。由于查找表通常由直方图构建,我们已将此函数添加到我们的Histogram1D类中:

    static cv::Mat applyLookUp(const cv::Mat& image,   // input image 
                               const cv::Mat& lookup) {// 1x256 8U 
      // the output image 
      cv::Mat result; 

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

如何工作...

当查找表应用于图像时,它会产生一个新的图像,其中像素强度值已根据查找表进行修改。一个简单的转换可以定义为以下:

    //Create an image inversion table 
    cv::Mat lut(1,256,CV_8U); // 256x1 matrix 

    for (int i=0; i<256; i++) { 
      //0 becomes 255, 1 becomes 254, etc. 
      lut.at<uchar>(i)= 255-i; 
    } 

这种转换简单地反转了像素强度,即强度0变为2551变为254,以此类推,直到255变为0。将此类查找表应用于图像将产生原始图像的负片。

在前一个菜谱中的图像,结果如下所示:

如何工作...

更多...

查找表在所有像素强度都赋予新强度值的应用中非常有用。然而,这种转换必须是全局的;也就是说,每个强度值的所有像素都必须经历相同的转换。

通过拉伸直方图来提高图像对比度

通过定义一个修改原始图像直方图的查找表,可以改善图像的对比度。例如,如果你观察本章第一个菜谱中显示的图像的直方图,你会注意到实际上没有像素的值高于200。因此,我们可以拉伸直方图以产生具有扩展对比度的图像。为此,该程序使用一个百分位数阈值来定义在拉伸图像中可以分配最小强度值(0)和最大强度值(255)的像素百分比。

因此,我们必须找到最低的(imin)和最高的(imax)强度值,以确保我们有足够的像素数位于或高于指定的百分位数。这是通过以下循环(其中hist是计算出的 1D 直方图)实现的:

    // number of pixels in percentile 
    float number= image.total()*percentile; 

    // find left extremity of the histogram 
    int imin = 0; 
    for (float count=0.0; imin < 256; imin++) { 
      // number of pixel at imin and below must be > number 
      if ((count+=hist.at<float>(imin)) >= number) 
        break; 
    } 

    // find right extremity of the histogram 
    int imax = 255; 
    for (float count=0.0; imax >= 0; imax--) { 
      // number of pixel at imax and below must be > number 
      if ((count += hist.at<float>(imax)) >= number) 
        break; 
    }

强度值可以被重新映射,以便imin值重新定位到强度0,而imax值被分配值为255。中间的i强度值简单地线性重新映射,如下所示:

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

然后具有 1%百分位截断的拉伸图像如下所示:

拉伸直方图以改善图像对比度

扩展后的直方图看起来如下所示:

拉伸直方图以改善图像对比度

将查找表应用于彩色图像

在第二章,操作像素中,我们定义了一个颜色减少函数,该函数通过修改图像的 BGR 值来减少可能的颜色数量。我们通过遍历图像的像素并对每个像素应用颜色减少函数来实现这一点。实际上,通过预计算所有颜色减少并使用查找表修改每个像素将更加高效。这正是我们从本菜谱中学到的东西。新的颜色减少函数将如下所示:

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

      // creating the 1D lookup table 
      cv::Mat lookup(1,256,CV_8U); 

      // defining the color reduction lookup 
      for (int i=0; i<256; i++)  
        lookup.at<uchar>(i)= i/div*div + div/2; 

      // lookup table applied on all channels 
      cv::LUT(image,lookup,image); 
    }

在这里,颜色减少方案被正确应用,因为当一维查找表应用于多通道图像时,该表将单独应用于所有通道。当查找表具有多个维度时,则必须将其应用于具有相同通道数的图像。

参见

  • 下一个菜谱,均衡化图像直方图,展示了另一种提高图像对比度的方法

均衡化图像直方图

在前面的菜谱中,我们向您展示了如何通过拉伸直方图来改善图像对比度,使其占据所有可用强度值的完整范围。这种策略确实是一种简单的解决方案,可以有效地提高图像质量。然而,在许多情况下,图像的视觉缺陷并不是它使用了过于狭窄的强度范围。

相反,是因为某些强度值的使用频率远高于其他值。本章第一道菜谱中显示的直方图是这种现象的一个很好的例子。中间灰度强度确实有很高的代表性,而较暗和较亮的像素值则相对罕见。因此,提高图像质量的一种可能方法可能是使所有可用的像素强度得到均衡使用。这正是直方图均衡化概念背后的想法,即尽可能使图像直方图变得平坦。

如何操作...

OpenCV 提供了一个易于使用的函数,用于执行直方图均衡化。它的调用方式如下:

    cv::equalizeHist(image,result); 

在我们的图像上应用它之后,得到以下图像:

如何操作...

这个均衡化图像具有以下直方图:

如何操作...

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

它是如何工作的...

在一个完全均匀的直方图中,所有桶都将具有相同数量的像素。这意味着 50%的像素应该具有低于 128(中值强度值)的强度,25%应该具有低于 64 的强度,依此类推。这个观察结果可以用以下规则表示:在一个均匀的直方图中,p% 的像素必须具有低于或等于 255*p% 的强度值。均衡直方图的规则是,强度 i 的映射应该对应于具有低于 i 的强度值的像素百分比。因此,所需的查找表可以从以下方程式构建:

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

在这里,p[i] 是强度低于或等于 i 的像素数量。p[i] 函数通常被称为累积直方图,也就是说,它是一个包含低于或等于给定强度的像素计数的直方图,而不是包含具有特定强度值的像素计数。回想一下,image.total() 返回图像中的像素数量,所以 p[i]/image.total() 是像素的百分比。

通常,直方图均衡化大大改善了图像的外观。然而,根据视觉内容的不同,结果的质量可能因图像而异。

使用直方图反向投影检测特定图像内容

直方图是图像内容的一个重要特征。如果你观察一个显示特定纹理或特定对象的图像区域,那么这个区域的直方图可以看作是一个函数,它给出了给定像素属于这个特定纹理或对象的概率。在这个菜谱中,你将学习如何有利地使用直方图反向投影的概念来检测特定的图像内容。

如何做...

假设你有一个图像,并且你希望在图像中检测特定的内容(例如,在以下图像中,天空中的云)。首先要做的事情是选择一个包含你正在寻找的样本的兴趣区域。这个区域就是以下测试图像中画出的矩形内的区域:

如何做...

在我们的程序中,感兴趣的区域是通过以下方式获得的:

    cv::Mat imageROI; 
    imageROI= image(cv::Rect(216,33,24,30)); // Cloud region 

然后你提取这个感兴趣区域(ROI)的直方图。这可以通过使用本章第一道菜谱中定义的 Histogram1D 类轻松完成,如下所示:

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

通过归一化这个直方图,我们得到一个函数,它给出了给定强度值的像素属于定义区域的概率,如下所示:

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

反向投影直方图包括将输入图像中的每个像素值替换为其在归一化直方图中读取的对应概率值。OpenCV 函数按以下方式执行此任务:

    cv::calcBackProject(&image,
             1,          // one image 
             channels,   // the channels used,  
                         // based on histogram dimension 
             histogram,  // the histogram we are backprojecting 
             result,     // the resulting back projection image 
             ranges,     // the ranges of values 
             255.0       // the scaling factor is chosen  
                         // such that a probability value of 1 maps to 255 
    ); 

result 是以下概率图。为了提高可读性,我们显示 result 图像的负值,属于参考区域的概率从亮(低概率)到暗(高概率):

如何做...

如果我们对这张图像应用阈值,我们就能获得最可能的云像素:

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

结果如下截图所示:

如何做...

它是如何工作的...

前面的结果令人失望,因为除了云层外,其他区域也被错误地检测到了。重要的是要理解,概率函数是从简单的灰度直方图中提取出来的。图像中许多其他像素与云像素具有相同的强度,并且在反向投影直方图时,相同强度的像素被替换为相同的概率值。为了提高检测结果,一个解决方案是使用颜色信息。然而,为了做到这一点,我们需要修改对 cv::calBackProject 的调用。这将在 还有更多... 部分中解释。

cv::calBackProject 函数与 cv::calcHist 函数类似。与像素相关联的值指的是一个(可能的多维)直方图的某个 bin。但与增加 bin 计数不同,cv::calBackProject 函数将读取该 bin 中的值分配给输出反向投影图像中的相应像素。此函数的第一个参数指定输入图像(大多数情况下,只有一个)。然后你需要列出你希望使用的通道号。传递给函数的直方图这次是一个输入参数;其维度应与通道列表数组相匹配。与 cv::calcHist 类似,范围参数指定输入直方图的 bin 边界,形式为一个 float 数组的数组,每个数组指定每个通道的范围(最小值和最大值)。

结果输出是一个包含计算出的概率图的图像。由于每个像素都被替换为在对应 bin 位置读取的直方图中的值,因此结果图像的值在 0.01.0 之间(假设已提供归一化直方图作为输入)。最后一个参数允许你选择性地通过乘以给定因子来重新缩放这些值。

还有更多...

现在我们来看一下我们如何使用直方图反向投影算法中的颜色信息。

反向投影颜色直方图

多维直方图也可以反向投影到图像上。让我们定义一个封装反向投影过程的类。我们首先定义所需的属性并初始化数据如下:

    class ContentFinder { 
      private: 
        // histogram parameters 
        float hranges[2]; 
        const float* ranges[3]; 
        int channels[3]; 
        float threshold;         // decision threshold 
        cv::Mat histogram;       // input histogram  

      public: 
      ContentFinder() : threshold(0.1f) { 
        // in this class, all channels have the same range 
        ranges[0]= hranges;   
        ranges[1]= hranges;  
        ranges[2]= hranges;  
      }

引入了一个threshold属性,用于创建显示检测结果的二值图。如果此参数设置为负值,将返回原始概率图。输入直方图已归一化(这虽然是可选的),如下所示:

    // Sets the reference histogram 
    void setHistogram(const cv::Mat& h) { 
      histogram= h; 
      cv::normalize(histogram,histogram,1.0); 
    }

要进行直方图反向投影,您只需指定图像、范围(我们在这里假设所有通道的范围相同),以及使用的通道列表。find方法执行反向投影。此方法有两种版本;第一个使用图像的三个通道的版本调用更通用的版本:

    // Simplified version in which 
    // all channels used, with range [0,256[ by default 
    cv::Mat find(const cv::Mat& image) { 

      cv::Mat result; 
      hranges[0]= 0.0;   // default range [0,256[hranges[1]= 256.0; 
      channels[0]= 0;    // the three channels  
      channels[1]= 1;  
      channels[2]= 2;  
      return find(image, hranges[0], hranges[1], channels); 
    } 

    // Finds the pixels belonging to the histogram 
    cv::Mat find(const cv::Mat& image, float minValue, float maxValue,
                 int *channels) { 

      cv::Mat result; 
      hranges[0]= minValue; 
      hranges[1]= maxValue; 
      // histogram dim matches channel list 
      for (int i=0; i<histogram.dims; i++) 
        this->channels[i]= channels[i]; 

      cv::calcBackProject(&image, 1, // we only use one image  
                  channels,    // channels used  
                  histogram,   // the histogram we are using 
                  result,      // the back projection image 
                  ranges,      // the range of values, 
                               // for each dimension 
                  255.0        //the scaling factor is chosen such  
                               //that a histogram value of 1 maps to 255 
      ); 
    } 

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

      return result; 
    } 

现在,让我们使用之前使用的图像的颜色版本的 BGR 直方图(查看书籍网站以查看此图像的颜色)。这次,我们将尝试检测蓝色天空区域。我们首先加载彩色图像,定义感兴趣的区域,并在减少的颜色空间上计算 3D 直方图,如下所示:

    // Load color image 
    ColorHistogram hc; 
    cv::Mat color= cv::imread("waves.jpg"); 

    // extract region of interest 
    imageROI= color(cv::Rect(0,0,100,45)); // blue sky area 

    // Get 3D color histogram (8 bins per channel) 
    hc.setSize(8); // 8x8x8 
    cv::Mat shist= hc.getHistogram(imageROI); 

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

    // Create the content finder 
    ContentFinder finder; 
    // set histogram to be back-projected 
    finder.setHistogram(shist); 
    finder.setThreshold(0.05f); 

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

上一个章节中图像颜色版本的检测结果如下:

反向投影颜色直方图

BGR 颜色空间通常不是在图像中识别颜色对象的最佳选择。在这里,为了使其更加可靠,我们在计算直方图之前减少了颜色的数量(记住,原始的 BGR 空间包含超过 1600 万种颜色)。提取的直方图代表了天空区域的典型颜色分布。尝试将其投影到另一张图像上。它也应该检测到天空部分。请注意,使用由多个天空图像构建的直方图可以提高这种检测的准确性。

在这种情况下,从内存使用角度来看,计算稀疏直方图会更好。您应该能够使用cv::SparseMat重做这个练习。此外,如果您正在寻找颜色鲜艳的对象,使用 HSV 颜色空间的色调通道可能会更有效。在其他情况下,使用感知均匀空间(如 Lab*)的色度分量可能是一个更好的选择。

参见

  • 使用均值漂移算法寻找对象配方使用 HSV 颜色空间在图像中检测对象。这是您可以在某些图像内容检测中使用的许多替代解决方案之一。

  • 第三章的最后两个配方,处理图像的颜色,讨论了您可以使用直方图反向投影的不同颜色空间。

使用均值漂移算法寻找对象

直方图反向投影的结果是一个概率图,表示给定图像内容在特定图像位置被找到的概率。假设我们现在知道图像中一个物体的近似位置;概率图可以用来找到物体的确切位置。最可能的位置将是最大化给定窗口内该概率的位置。因此,如果我们从一个初始位置开始,并迭代地移动以尝试增加局部概率度量,应该可以找到物体的确切位置。这正是均值漂移算法所实现的。

如何做...

假设我们在这里识别了一个感兴趣的物体,即狒狒的脸,如下面的图像所示:

如何做...

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

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

如所示,色调直方图是通过我们添加到ColorHistogram类中的一个便捷方法获得的:

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

      cv::Mat hist; 

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

      // Mask to be used (or not) 
      cv::Mat mask; 
      // creating the mask if required 
      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;    // range is from 0 to 180 
      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"); 
    // Convert to HSV space 
    cv::cvtColor(image, hsv, CV_BGR2HSV); 
    // Get back-projection of hue histogram 
    int ch[1]={0}; 
    finder.setThreshold(-1.0f); // no thresholding 
    cv::Mat result= finder.find(hsv,0.0f,180.0f,ch); 

现在,从一个初始矩形区域(即初始图像中狒狒脸的位置),OpenCV 的cv::meanShift算法将更新rect对象到新的狒狒脸位置,如下所示:

    // initial window position 
    cv::Rect rect(110,260,35,40); 

    // search object with mean shift 
    cv::TermCriteria criteria( 
               cv::TermCriteria::MAX_ITER | cv::TermCriteria::EPS,  
               10, // iterate max 10 times 
               1); // or until the change in centroid position is less than 1px 
    cv::meanShift(result,rect,criteria); 

这里显示了初始(红色)和新的(绿色)脸部位置:

如何做...

它是如何工作的...

在这个例子中,我们使用了 HSV 颜色空间的色调分量来描述我们正在寻找的对象。我们做出这个选择是因为狒狒的脸有一种非常独特的粉红色;因此,像素的色调应该使脸部容易识别。因此,第一步是将图像转换为 HSV 颜色空间。当使用CV_BGR2HSV标志时,色调分量是结果图像的第一个通道。这是一个从0180(使用cv::cvtColor转换的图像与源图像类型相同)的 8 位分量。为了提取色调图像,使用cv::split函数将 3 通道 HSV 图像分割成三个 1 通道图像。这三个图像被插入到一个std::vector实例中,色调图像是向量的第一个条目(即索引0)。

当使用颜色的色调分量时,始终需要考虑其饱和度(这是向量的第二个元素)。确实,当颜色的饱和度低时,色调信息变得不稳定和不可靠。这是由于对于低饱和度颜色,B、G 和 R 分量几乎相等。这使得很难确定所表示的确切颜色。因此,我们决定忽略低饱和度颜色的色调分量。也就是说,它们不计入直方图(使用minSat参数,在getHueHistogram方法中屏蔽掉饱和度低于此阈值的像素)。

均值漂移算法是一种迭代过程,用于定位概率函数的局部极大值。它是通过找到预定义窗口内数据点的质心,或加权平均值来实现的。然后,算法将窗口中心移动到质心位置,并重复此过程,直到窗口中心收敛到一个稳定点。OpenCV 实现定义了两个停止标准:最大迭代次数(MAX_ITER)和窗口中心位移值,低于此值的位移认为位置已经收敛到一个稳定点(EPS)。这两个标准存储在cv::TermCriteria实例中。cv::meanShift函数返回已执行的迭代次数。显然,结果的质量取决于在给定初始位置提供的概率图的质量。注意,在这里,我们使用颜色直方图来表示图像的外观;也可以使用其他特征的直方图来表示对象(例如,边缘方向的直方图)。

参见

  • 均值漂移算法已被广泛用于视觉跟踪。第十三章,《跟踪视觉运动》,将更详细地探讨对象跟踪的问题。

  • 均值漂移算法在文章《均值漂移:一种稳健的特征空间分析方法》中被介绍,该文章由 D. Comaniciu 和 P. Meer 撰写,发表在《IEEE Transactions on Pattern Analysis and Machine Intelligence》杂志第 24 卷第 5 期,2002 年 5 月。

  • OpenCV 还提供了一个CamShift算法的实现,这是均值漂移算法的改进版本,其中窗口的大小和方向可以改变。

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

基于内容的图像检索是计算机视觉中的一个重要问题。它包括找到一组图像,这些图像呈现的内容与给定的查询图像相似。由于我们已经了解到直方图是表征图像内容的一种有效方式,因此认为它们可以用来解决基于内容的图像检索问题。

关键在于能够通过简单地比较它们的直方图来测量两张图像之间的相似度。一个测量函数将需要定义,以估计两个直方图有多不同,或者有多相似。过去已经提出了各种这样的度量,OpenCV 在其cv::compareHist函数的实现中提出了一些。

如何操作...

为了将参考图像与一系列图像进行比较,并找到与查询图像最相似的图像,我们创建了一个ImageComparator类。这个类包含一个查询图像和一个输入图像的引用,以及它们的直方图。此外,由于我们将使用颜色直方图进行比较,我们在ImageComparator类内部使用了ColorHistogram类:

    class ImageComparator { 

      private: 

      cv::Mat refH;         // reference histogram 
      cv::Mat inputH;       // histogram of input image 

      ColorHistogram hist;  // to generate the histograms 
      int nBins;           // number of bins used in each color channel 

      public: 
      ImageComparator() :nBins(8) { 

      } 

为了获得可靠的相似度度量,直方图应该在减少的 bins 数量上计算。因此,这个类允许你指定每个 BGR 通道中使用的 bins 数量。查询图像通过一个适当的 setter 指定,该 setter 还会计算参考直方图,如下所示:

    // set and compute histogram of reference image 
    void setReferenceImage(const cv::Mat& image) { 

      hist.setSize(nBins); 
      refH= hist.getHistogram(image); 
    } 

最后,一个compare方法比较参考图像与给定的输入图像。以下方法返回一个分数,表示两个图像的相似度:

    // compare the images using their BGR histograms 
    double compare(const cv::Mat& image) { 

      inputH= hist.getHistogram(image); 

      // histogram comparison using intersection 
      return cv::compareHist(refH,inputH, cv::HISTCMP_INTERSECT); 
    } 

前面的类可以用来检索与给定查询图像相似的图像。以下是如何将参考图像提供给类实例:

    ImageComparator c; 
    c.setReferenceImage(image); 

在这里,我们使用的查询图像是本章前面在将直方图反投影以检测特定图像内容食谱中显示的海滩图像的颜色版本。这幅图像与以下一系列图像进行了比较。图像按从最相似到最不相似的顺序显示:

如何操作...

工作原理...

大多数直方图比较度量都是基于 bin-by-bin 的比较。这就是为什么在测量两个颜色直方图的相似度时,与一个减少的直方图一起工作很重要,该直方图会将相邻的颜色组合到同一个 bin 中。调用cv::compareHist很简单。你只需输入两个直方图,函数就会返回测量的距离。你想要使用的特定测量方法通过一个标志来指定。在ImageComparator类中,使用的是交集方法(使用cv::HISTCMP_INTERSECT标志)。这种方法简单地比较每个 bin 中每个直方图的两个值,并保留最小的一个。相似度度量,然后,是这些最小值的总和。因此,两个没有共同颜色的直方图的图像将得到一个交集值为0,而两个相同的直方图将得到一个等于总像素数的值。

其他可用的方法包括卡方度量(cv::HISTCMP_CHISQR标志),它计算桶之间归一化平方差的和;相关方法(cv::HISTCMP_CORREL标志),它基于在信号处理中用于测量两个信号之间相似性的归一化互相关算子;以及 Bhattacharyya 度量(cv::HISTCMP_BHATTACHARYYA标志)和 Kullback-Leibler 散度(cv::HISTCMP_KL_DIV标志),两者都用于统计学中估计两个概率分布之间的相似性。

参见

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

  • 地球迁移距离是另一种流行的直方图比较方法。它在 OpenCV 中作为cv::EMD函数实现。这种方法的主要优点是它考虑了相邻桶中找到的值来评估两个直方图的相似性。它由 Y. Rubner、C. Tomasi 和 L. J. Guibas 在《国际计算机视觉杂志》第 40 卷第 2 期,2000 年,第 99-121 页的论文《The Earth Mover's Distance as a Metric for Image Retrieval》中描述。

使用积分图像计数像素

在之前的食谱中,我们了解到直方图是通过遍历图像中的所有像素并累计每个强度值在此图像中出现的频率来计算的。我们还看到,有时我们只对在图像的某个特定区域内计算我们的直方图感兴趣。实际上,在图像的子区域内累计像素总和是许多计算机视觉算法中的常见任务。现在,假设你必须在图像中多个感兴趣区域内计算多个这样的直方图。所有这些计算可能会迅速变得非常昂贵。在这种情况下,有一个工具可以极大地提高在图像子区域内计数像素的效率:积分图像。

积分图像被引入作为一种在图像感兴趣区域中求和像素的高效方法。它们在涉及例如在多个尺度上滑动窗口的计算的应用中得到了广泛的应用。

本食谱将解释积分图像背后的原理。我们的目标是展示如何仅使用三个算术运算来对矩形区域内的像素进行求和。一旦我们掌握了这个概念,本食谱的“还有更多...”部分将展示两个例子,说明积分图像如何被有利地使用。

如何做到...

本食谱将使用以下图片进行操作,其中识别出一个感兴趣的区域,显示一个女孩骑自行车:

如何做到...

当你需要对多个图像区域内的像素进行求和时,积分图像非常有用。通常情况下,如果你希望获取感兴趣区域内所有像素的总和,你会编写以下代码:

    // Open image 
    cv::Mat image= cv::imread("bike55.bmp",0); 
    // define image roi (here the girl on bike) 
    int xo=97, yo=112; 
    int width=25, height=30; 
    cv::Mat roi(image,cv::Rect(xo,yo,width,height)); 
    // compute sum 
    // returns a Scalar to work with multi-channel images 
    cv::Scalar sum= cv::sum(roi); 

cv::sum 函数简单地遍历该区域的全部像素并累加总和。使用积分图像,这可以通过仅使用三次加法操作来实现。然而,首先你需要计算积分图像,如下所示:

      // compute integral image 
      cv::Mat integralImage; 
      cv::integral(image,integralImage,CV_32S); 

如下一节所述,可以通过在计算出的积分图像上使用这个简单的算术表达式获得相同的结果,如下所示:

    // get sum over an area using three additions/subtractions 
    int sumInt= integralImage.at<int>(yo+height,xo+width)- 
                integralImage.at<int>(yo+height,xo)- 
                integralImage.at<int>(yo,xo+width)+ 
                integralImage.at<int>(yo,xo); 

这两种方法都会给出相同的结果。然而,计算积分图像是昂贵的,因为你必须遍历所有图像像素。关键是,一旦完成这个初始计算,你只需要添加四个值就可以得到对感兴趣区域的求和,无论这个区域的大小如何。因此,当需要计算多个不同大小区域的多个像素总和时,积分图像的使用就变得有利。

它是如何工作的...

在上一节中,你通过一个简要的演示介绍了积分图像的概念,展示了其背后的魔法,即它们如何被用来以低成本计算矩形区域内的像素总和。为了理解它们是如何工作的,现在让我们定义什么是积分图像。积分图像是通过将每个像素替换为位于由该像素所围成的左上象限内所有像素的总和来获得的。积分图像可以通过扫描图像一次来计算。确实,当前像素的积分值是由当前像素上方的像素的积分值加上当前行的累积和的值。因此,积分图像是一个包含像素总和的新图像。为了避免溢出,这个图像通常是一个 int 值的图像(CV_32S)或 float 值的图像(CV_32F)。

例如,在下面的图中,这个积分图像中的像素 A 将包含包含在上左角区域内的像素总和,该区域用双虚线图案标识:

它是如何工作的...

一旦计算出了积分图像,任何对矩形区域的求和都可以通过四次像素访问轻松获得,原因如下。参考前面的图示,我们可以看到,像素 ABCD 所围区域的像素总和可以通过读取像素 D 的积分值来获得,然后从这个值中减去像素 BC 左侧的像素值。然而,这样做会减去 A 的左上角像素总和的两倍;这就是为什么你必须重新加上 A 的积分和。形式上,ABCD 内部像素的总和由 A-B-C+D 给出。如果我们使用 cv::Mat 方法来访问像素值,这个公式可以转换为以下形式:

    // window at (xo,yo) of size width by height 
    return (integralImage.at<cv::Vec<T,N>>(yo+height,xo+width)- 
            integralImage.at<cv::Vec<T,N>>(yo+height,xo)- 
            integralImage.at<cv::Vec<T,N>>(yo,xo+width)+ 
            integralImage.at<cv::Vec<T,N>>(yo,xo)); 

因此,这种计算的复杂度是常数,无论感兴趣的区域大小如何。请注意,为了简单起见,我们使用了cv::Mat类的at方法,这不是访问像素值最有效的方法(参见第二章,操作像素)。这个问题将在本食谱的“更多内容...”部分进行讨论,该部分介绍了两个受益于积分图像概念效率的应用。

更多内容...

当必须执行多个像素求和时,会使用积分图像。在本节中,我们将通过介绍自适应阈值化的概念来说明积分图像的使用。积分图像对于在多个窗口上高效计算直方图也非常有用。这一点在本节中也有解释。

自适应阈值化

为了创建二值图像并对图像中的有意义元素进行提取,对图像应用阈值可能是一个好方法。假设你有一张书的以下图像:

自适应阈值化

由于你对分析图像中的文本感兴趣,你将按照以下方式对这张图像应用阈值:

    // using a fixed threshold  
    cv::Mat binaryFixed; 
    cv::threshold(image,binaryFixed,70,255,cv::THRESH_BINARY); 

你会得到以下结果:

自适应阈值化

事实上,无论你选择什么阈值值,在图像的某些部分你会得到缺失的文本,而在其他部分,文本则被阴影所掩盖。为了克服这个问题,一个可能的解决方案是使用从每个像素的邻域计算出的局部阈值。这种策略被称为自适应阈值化,它包括将每个像素与相邻像素的均值进行比较。然后,与局部均值明显不同的像素将被视为异常值,并通过阈值化过程被截断。

因此,自适应阈值化需要计算每个像素周围的局部均值。这需要通过积分图像高效地计算多个图像窗口的求和。因此,第一步是计算以下积分图像:

    // compute integral image 
    cv::Mat iimage; 
    cv::integral(image,iimage,CV_32S); 

现在我们可以遍历所有像素,并在一个正方形邻域内计算均值。我们可以使用我们的IntegralImage类来做这件事,但这个类使用效率低下的at方法来访问像素。这次,让我们通过使用指针遍历图像来提高效率,正如我们在第二章,操作像素中学习的那样。这个循环看起来如下所示:

    int blockSize= 21;  // size of the neighborhood 
    int threshold=10;   // pixel will be compared 
                        // to (mean-threshold) 

    // for each row 
    int halfSize= blockSize/2; 
    for (int j=halfSize; j<nl-halfSize-1; j++) { 

      // get the address of row j 
      uchar* data= binary.ptr<uchar>(j); 
      int* idata1= iimage.ptr<int>(j-halfSize); 
      int* idata2= iimage.ptr<int>(j+halfSize+1); 

      // for each pixel of a line 
      for (int i=halfSize; i<nc-halfSize-1; i++) { 

        // compute sum 
        int sum= (idata2[i+halfSize+1]-data2[i-halfSize]-  
                  idata1[i+halfSize+1]+idata1[i-halfSize]) 
                                      /(blockSize*blockSize); 

        // apply adaptive threshold 
        if (data[i]<(sum-threshold)) 
          data[i]= 0; 
        else 
          data[i]=255; 
      } 
    } 

在本例中,使用了一个大小为 21x21 的邻域。为了计算每个均值,我们需要访问界定正方形邻域的四个整数像素:两个位于由 idata1 指向的线上,另外两个位于由 idata2 指向的线上。当前像素与计算出的均值进行比较,然后从中减去一个阈值值(此处设置为 10);这是为了确保被拒绝的像素与它们的局部均值明显不同。随后得到以下二值图像:

自适应阈值

显然,这比我们使用固定阈值得到的结果要好得多。自适应阈值是一种常见的图像处理技术。因此,它也被 OpenCV 如下实现:

    cv::adaptiveThreshold(image,        // input image 
            binaryAdaptive,             // output binary image 
            255,                        // max value for output 
            cv::ADAPTIVE_THRESH_MEAN_C, // method 
            cv::THRESH_BINARY,          // threshold type 
            blockSize,                  // size of the block       
            threshold);                 // threshold used 

这个函数调用产生的结果与我们使用积分图像得到的结果完全相同。此外,与使用局部均值进行阈值化不同,此函数允许您在这种情况下使用高斯加权和(方法标志为 cv::ADAPTIVE_THRESH_GAUSSIAN_C)。值得注意的是,我们的实现比 cv::adaptiveThreshold 调用略快。

最后,值得一提的是,我们还可以通过使用 OpenCV 图像运算符来编写自适应阈值过程。这将是以下步骤:

    cv::Mat filtered; 
    cv::Mat binaryFiltered;     
    // box filter compute avg of pixels over a rectangular region 
    cv::boxFilter(image,filtered,CV_8U,cv::Size(blockSize,blockSize)); 
    // check if pixel greater than (mean + threshold) 
    binaryFiltered= image>= (filtered-threshold); 

图像滤波将在 第六章,滤波图像 中介绍。

使用直方图的视觉跟踪

如我们在前面的食谱中所学,直方图构成了对象外观的可靠全局表示。在本节中,我们将通过向您展示我们如何通过搜索与目标对象具有相似直方图的图像区域来定位图像中的对象来展示积分图像的有用性。我们通过使用直方图反向投影和通过均值漂移进行局部搜索的 使用均值漂移算法查找对象 食谱实现了这一点。这次,我们将通过在整幅图像上执行对具有相似直方图区域的显式搜索来找到我们的对象。

在特殊情况下,当在由 01 值组成的二值图像上使用积分图像时,积分和给出了指定区域内具有 1 值的像素数量。我们将利用这一事实在本食谱中计算灰度图像的直方图。

cv::integral 函数也适用于多通道图像。您可以利用这一事实,通过积分图像来计算图像子区域的直方图。您只需将您的图像转换为由二值平面组成的多通道图像;每个平面都与直方图的一个箱相关联,并显示哪些像素的值落在这个箱中。以下函数从一个灰度图像创建这样的多平面图像:

    // convert to a multi-channel image made of binary planes 
    // nPlanes must be a power of 2 
    void convertToBinaryPlanes(const cv::Mat& input,              
                               cv::Mat& output, int nPlanes) { 

      // number of bits to mask out 
      int n= 8-static_cast<int>( 
                     log(static_cast<double>(nPlanes))/log(2.0)); 
      // mask used to eliminate least significant bits 
      uchar mask= 0xFF<<n;  

      // create a vector of binary images 
      std::vector<cv::Mat> planes; 
      // reduce to nBins by eliminating least significant bits 
      cv::Mat reduced= input&mask; 

      // compute each binary image plane 
      for (int i=0; i<nPlanes; i++) { 
        // 1 for each pixel equals to i<<shift 
        planes.push_back((reduced==(i<<n))&0x1); 
      } 

      // create multi-channel image 
      cv::merge(planes,output); 
    } 

整数图像的计算也可以封装到一个方便的模板类中,如下所示:

    template <typename T, int N> 
    class IntegralImage { 

      cv::Mat integralImage; 

      public: 

      IntegralImage(cv::Mat image) { 

       // (costly) computation of the integral image          
       cv::integral(image,integralImage, 
                    cv::DataType<T>::type); 
      } 

      // compute sum over sub-regions of any size  
      // from 4 pixel accesses 
      cv::Vec<T,N> operator()(int xo, int yo, int width, int height) { 

      // window at (xo,yo) of size width by height 
      return (integralImage.at<cv::Vec<T,N>>(yo+height,xo+width)- 
              integralImage.at<cv::Vec<T,N>>(yo+height,xo)- 
              integralImage.at<cv::Vec<T,N>>(yo,xo+width)+ 
              integralImage.at<cv::Vec<T,N>>(yo,xo)); 
      } 

    }; 

现在我们想要找到在上一张图像中识别出的骑自行车的女孩在后续图像中的位置。让我们首先计算原始图像中女孩的直方图。我们可以使用本章的食谱“计算图像直方图”中构建的Histogram1D类来完成此操作。在这里,我们生成一个 16 箱直方图,如下所示:

    // histogram of 16 bins 
    Histogram1D h; 
    h.setNBins(16); 
    // compute histogram over image roi
    cv::Mat refHistogram=  h.getHistogram(roi); 

之前的直方图将被用作参考表示,以在后续图像中定位目标对象(骑自行车的女孩)。

假设我们唯一的信息是女孩在大约水平方向上移动。由于我们将在不同的位置计算许多直方图,我们将计算积分图像作为初步步骤。参考以下代码:

    // first create 16-plane binary image 
    cv::Mat planes; 
    convertToBinaryPlanes(secondIimage,planes,16); 
    // then compute integral image 
    IntegralImage<float,16> intHistogram(planes); 

为了进行搜索,我们在可能的范围中循环遍历,并将当前直方图与参考直方图进行比较。我们的目标是找到具有最相似直方图的位置。参考以下代码:

    double maxSimilarity=0.0; 
    int xbest, ybest; 
    // loop over a horizontal strip around girl 
    // location in initial image 
    for (int y=110; y<120; y++) { 
      for (int x=0; x<secondImage.cols-width; x++) { 

        // compute histogram of 16 bins using integral image 
        histogram= intHistogram(x,y,width,height); 
        // compute distance with reference histogram 
        double distance= cv::compareHist(refHistogram,
                                         histogram,
                                         CV_COMP_INTERSECT); 
        //find position of most similar histogram 
        if (distance>maxSimilarity) { 

          xbest= x; 
          ybest= y; 
          maxSimilarity= distance; 
        } 
      } 
    } 
    //draw rectangle at best location 
    cv::rectangle(secondImage, cv::Rect(xbest,ybest,width,height),0)); 

然后识别具有最相似直方图的位置如下:

使用直方图进行视觉跟踪

白色矩形表示搜索区域。所有适合该区域的窗口的直方图已经计算完成。我们保持了窗口大小不变,但寻找稍微小一些或大一些的窗口可能是一个好的策略,以便考虑可能的尺度变化。请注意,为了限制计算的复杂性,要计算的直方图中的箱数应保持较低。在我们的例子中,我们将这个数字减少到16个箱。因此,多平面图像的平面0包含一个二值图像,显示所有介于015之间的像素,而平面1显示介于1631之间的像素,以此类推。

对象的搜索包括计算给定大小窗口在预定像素范围内的直方图。这代表了从我们的积分图像中高效计算出的3200个不同直方图的计算。我们IntegralImage类返回的所有直方图都包含在一个cv::Vec对象中(由于使用了at方法)。然后我们使用cv::compareHist函数来识别最相似的直方图(记住,这个函数,像大多数 OpenCV 函数一样,可以通过方便的cv::InputArray泛型参数类型接受cv::Matcv::Vec对象)。

参见

  • 第八章,检测兴趣点,将介绍也依赖于积分图像使用的 SURF 算子

  • 第十四章中的使用 Haar 特征级联查找对象和面部食谱,从示例中学习,介绍了使用积分图像计算的 Haar 特征

  • 第五章中的应用形态算子于灰度图像的配方,

    使用形态学操作变换图像,介绍了一个可以产生与所提出的自适应阈值技术类似结果的算子

  • A. AdamE. RivlinI. Shimshoni在 2006 年国际计算机视觉和模式识别会议论文集中发表的基于鲁棒片段的跟踪使用积分直方图一文,描述了一种使用积分图像在图像序列中跟踪对象的有意思的方法

第五章. 使用形态学操作转换图像

在本章中,我们将涵盖以下食谱:

  • 使用形态学滤波器进行图像腐蚀和膨胀

  • 使用形态学滤波器进行图像开闭操作

  • 在灰度图像上应用形态学算子

  • 使用分水岭进行图像分割

  • 使用 MSER 提取特征区域

简介

数学形态学是 20 世纪 60 年代为分析和处理离散图像而开发的一种理论。它定义了一系列通过探针预定义的形状元素来转换图像的算子。这个形状元素与像素邻域的交集方式决定了操作的结果。本章介绍了最重要的形态学算子。它还探讨了使用基于形态学算子的算法进行图像分割和特征检测的问题。

使用形态学滤波器进行图像腐蚀和膨胀

腐蚀和膨胀是最基本的形态学算子。因此,我们将在这第一个食谱中介绍它们。数学形态学的基本组成部分是结构元素。结构元素可以简单地定义为定义了原点(也称为锚点)的像素配置(以下图中的正方形形状)。应用形态学滤波器包括使用这个结构元素探测图像中的每个像素。当结构元素的原点与给定的像素对齐时,它与图像的交集定义了一个特定形态学操作应用的像素集(以下图中的九个阴影像素)。原则上,结构元素可以是任何形状,但最常用的是具有原点在中心的简单形状,如正方形、圆形或菱形。自定义结构元素可以用来强调或消除特定形状的区域。

使用形态学滤波器进行图像腐蚀和膨胀

准备中

由于形态学滤波器通常在二值图像上工作,我们将使用上一章第一个食谱中通过阈值创建的二值图像。然而,由于在形态学中通常用高(白色)像素值表示前景对象,用低(黑色)像素值表示背景对象,因此我们对图像进行了取反。

在形态学术语中,以下图像被认为是上一章创建的图像的补码

准备中

如何操作...

腐蚀和膨胀在 OpenCV 中作为简单的函数实现,分别是cv::erodecv::dilate。它们的用法简单直接:

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

    // Erode the image 
    // with the default 3x3 structuring element (SE) 
    cv::Mat eroded;  // the destination image 
    cv::erode(image,eroded,cv::Mat()); 

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

这些函数调用产生的两个图像如下所示。第一个显示了腐蚀:

如何操作...

第二张图像显示了膨胀的结果:

如何操作...

它是如何工作的...

对于所有形态学滤波器,本配方中的两个滤波器作用于由结构元素定义的像素集。回想一下,当应用于一个特定的像素时,结构元素的锚点与该像素位置对齐,并且所有与结构元素相交的像素都包含在当前集中。腐蚀用在定义的像素集中找到的最小像素值替换当前像素。膨胀是互补操作符,它用在定义的像素集中找到的最大像素值替换当前像素。由于输入的二值图像只包含黑色(值为0)和白色(值为255)像素,每个像素将被替换为白色或黑色像素。

一种形象化这两个操作效果的好方法是考虑背景(黑色)和前景(白色)对象。在腐蚀过程中,如果结构元素放置在特定的像素位置时接触到背景(即,相交集中的某个像素是黑色的),那么这个像素将被发送到背景。在膨胀的情况下,如果结构元素在背景像素上接触到前景对象,那么这个像素将被赋予白色值。这解释了为什么在腐蚀图像中对象的大小已经减小(形状已经被腐蚀),而在膨胀图像中对象已经扩展。注意,一些小对象(可以被认为是“噪声”背景像素)在腐蚀图像中也被完全消除。同样,膨胀的对象现在更大,它们内部的一些“空洞”也被填充了。默认情况下,OpenCV 使用一个3x3的正方形结构元素。当在函数调用中将空矩阵(即cv::Mat())指定为第三个参数时,就得到了这个默认的结构元素,就像在先前的例子中那样。您也可以通过提供一个矩阵来指定您想要的(大小和形状)结构元素,其中非零元素定义了结构元素。例如,要应用一个7x7的结构元素,您将按以下步骤进行:

    // Erode the image with a larger SE 
    // create a 7x7 mat with containing all 1s 
    cv::Mat element(7,7,CV_8U,cv::Scalar(1)); 
    // erode the image with that SE 
    cv::erode(image,eroded,element); 

在这种情况下,效果要破坏性得多,如下面的截图所示:

如何工作...

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

    // 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函数。例如,以下调用将应用关闭算子:

    // Close the image 
    cv::Mat element5(5,5,CV_8U,cv::Scalar(1)); 
    cv::Mat closed; 
    cv::morphologyEx(image,closed,    // input and output images 
                     cv::MORPH_CLOSE, // operator code 
                     element5);       // structuring element 

注意,我们使用了5x5的结构元素来使滤波器效果更加明显。如果我们使用前一个配方的二值图像作为输入,我们将获得以下图像:

如何操作...

类似地,应用形态学打开算子将得到以下图像:

如何操作...

上述图像是从以下代码获得的:

    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()); 

打开滤波器可以通过交换这两个函数调用来获得。

在检查闭合过滤器的结果时,可以看到白色前景对象的微小孔洞已被填充。过滤器还连接了几个相邻的对象。基本上,任何太小而无法完全包含结构元素的孔洞或缝隙都将被过滤器消除。

相反,开放过滤器从场景中消除了几个小对象。所有太小而无法包含结构元素的对象都已移除。

这些过滤器通常用于目标检测。闭合过滤器将错误地分割成更小片段的对象连接在一起,而开放过滤器则移除了由图像噪声引入的小块。因此,按顺序使用它们是有利的。如果您希望优先考虑噪声过滤,则可以在闭合过滤器之前应用开放过滤器,但这可能会以消除部分破碎对象为代价。

以下图像是在应用闭合过滤器之前应用开放过滤器的结果:

如何工作...

注意,对图像应用相同的开放(以及类似地闭合)算子多次没有效果。实际上,由于孔洞已经被第一次开放过滤器填充,再次应用相同的过滤器不会对图像产生任何其他变化。从数学的角度来看,这些算子被称为幂等的

参见

  • 开放和闭合算子通常用于在提取图像的连通组件之前清理图像,如第七章中“提取连通组件”配方中所述,第七章,提取线条、轮廓和组件

在灰度图像上应用形态学算子

更高级的形态学算子可以通过组合本章中介绍的不同基本形态学过滤器来组合。本配方将介绍两个形态学算子,当应用于灰度图像时,可以导致检测到有趣的图像特征。

如何做...

一个有趣的形态学算子是形态学梯度,它允许提取图像的边缘。这个算子可以通过以下cv::morphologyEx函数访问:

    // Get the gradient image using a 3x3 structuring element 
    cv::Mat result; 
    cv::morphologyEx(image, result,
                     cv::MORPH_GRADIENT, cv::Mat()); 

以下结果显示了图像元素的提取轮廓(为了更好的查看,结果图像已被反转):

如何做...

另一个有用的形态学算子是顶帽变换。这个算子可以用来从图像中提取局部的小前景对象。通过将此算子应用于上一章最后菜谱的书籍图像,可以演示该算子的效果。这张图像显示了一本书页面的不均匀照明。一个黑色的顶帽变换将提取该页面的字符(在此处被视为前景对象)。此算子也可以通过使用带有适当标志的cv::morphologyEx函数来调用:

    // Apply the black top-hat transform using a 7x7 structuring element 
    cv::Mat element7(7, 7, CV_8U, cv::Scalar(1)); 
    cv::morphologyEx(image, result, cv::MORPH_BLACKHAT, element7); 

如下图中所示,此算子成功提取了原始图像中的大多数字符:

如何操作...

它是如何工作的...

要理解形态学算子对灰度图像的影响,可以将图像视为一个拓扑地形,其中灰度级别对应于海拔(或高度)。从这种角度来看,明亮区域对应于山脉,而暗区对应于地形的山谷。此外,由于边缘对应于暗亮像素之间的快速过渡,这些可以想象为陡峭的悬崖。如果在此类地形上应用侵蚀算子,最终结果将是用一定邻域中的最低值替换每个像素,从而降低其高度。因此,随着山谷的扩张,悬崖将被侵蚀。膨胀具有完全相反的效果;也就是说,悬崖将在山谷上方获得地形。然而,在这两种情况下,高原(即强度恒定的区域)将相对保持不变。

这些观察结果导致了一种简单的方法来检测图像的边缘(或悬崖)。这可以通过计算膨胀图像和侵蚀图像之间的差异来实现。由于这两个转换图像主要在边缘位置不同,因此减法将强调图像边缘。这正是当输入cv::MORPH_GRADIENT参数时cv::morphologyEx函数所做的事情。显然,结构元素越大,检测到的边缘就越粗。这个边缘检测算子被称为Beucher 梯度(下一章将更详细地讨论图像梯度的概念)。请注意,通过简单地从原始图像减去膨胀图像或从原始图像减去侵蚀图像也可以获得类似的结果。得到的边缘将只是更细。

顶帽算子也是基于图像差异的。这次,算子使用开运算和闭运算。当一个灰度图像被形态学开运算时,其局部峰值被消除;这是由于首先应用的腐蚀算子。其余的图像被保留。因此,原始图像与开运算后的图像之间的差异是局部峰值的集合。这些局部峰值是我们想要提取的前景对象。在这个配方示例书中,目标是提取页面上的字符。由于在这种情况下,前景对象是黑色背景上的黑色,我们使用了互补算子,称为黑色顶帽,它由从原始图像中减去其闭运算组成。我们使用了一个7x7的结构元素,以便闭运算足够大,可以去除字符。

参见

  • 在第六章的“应用方向滤波检测边缘”配方中,滤波图像描述了其他执行边缘检测的滤波器

  • 文章,“形态学梯度,J.-F. Rivest,P. Soille,和 S. Beucher,ISET 的电子成像科学和技术研讨会,SPIE*”,1992 年 2 月,更详细地讨论了形态学梯度的概念

  • 文章“用于角点检测的形态学算子”,R. Laganière,模式识别,第 31 卷,第 11 期,1998 年,提出了一种使用形态学滤波器检测角点的算子

使用水平集分割图像

水平集变换是一种流行的图像处理算法,用于快速将图像分割成同质区域。它依赖于这样的想法:当图像被视为拓扑起伏时,同质区域对应于相对平坦的盆地,这些盆地由陡峭的边缘所限定。使用水平集算法,通过逐渐增加该盆地中的水位来淹没这个起伏,从而实现分割。由于其简单性,该算法的原版往往会导致图像过度分割,从而产生多个小区域。这就是为什么 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; 
    } 

这些标记的获取方式取决于应用。例如,一些预处理步骤可能导致了识别出一些属于感兴趣对象的像素。然后,水线算法将被用来从初始检测中界定整个对象。在本食谱中,我们将简单地使用本章中使用的二值图像来识别对应原始图像中的动物(这是第四章中展示的图像,使用直方图计数像素)。因此,从我们的二值图像中,我们需要识别属于前景(动物)的像素和属于背景(主要是草地)的像素。在这里,我们将前景像素标记为标签255,背景像素标记为标签128(这种选择完全是任意的;除了255之外的任何标签数字都可以工作)。其他像素,即标签未知的那部分像素,被分配值为0

到目前为止,二值图像包括属于图像各个部分的白色像素。然后我们将严重腐蚀此图像,以保留仅属于前景对象的像素:

    // Eliminate noise and smaller objects 
    cv::Mat fg; 
    cv::erode(binary,fg,cv::Mat(),cv::Point(-1,-1),4); 

结果是以下图像:

如何操作...

注意,一些属于背景森林的像素仍然存在。让我们保留它们。因此,它们将被认为是属于感兴趣对象的。同样,我们可以通过原始二值图像的大膨胀来选择一些背景像素:

    // Identify image pixels without objects 
    cv::Mat bg; 
    cv::dilate(binary,bg,cv::Mat(),cv::Point(-1,-1),4); 
    cv::threshold(bg,bg,1,128,cv::THRESH_BINARY_INV); 

结果中的黑色像素对应于背景像素。这就是为什么阈值操作在膨胀之后立即将这些像素的值分配为128。得到的图像如下:

如何操作...

这些图像如下组合成标记图像:

    // Create markers image 
    cv::Mat markers(binary.size(),CV_8U,cv::Scalar(0)); 
    markers= fg+bg; 

注意我们在这里如何使用重载的operator+来组合图像。以下图像将被用作水线算法的输入:

如何操作...

在这个输入图像中,白色区域肯定属于前景对象,灰色区域是背景的一部分,黑色区域具有未知标签。因此,水线分割的作用是通过建立精确的前景对象与背景之间的边界来为黑色标记像素分配标签(背景/前景)。这种分割如下获得:

    // Create watershed segmentation object 
    WatershedSegmenter segmenter; 

    // Set markers and process 
    segmenter.setMarkers(markers); 
    segmenter.process(image); 

标记图像随后更新,使得每个零像素被分配一个输入标签,而属于找到的边界的像素具有值-1。标签的最终图像如下:

如何操作...

边界图像如下:

如何操作...

它是如何工作的...

正如我们在前面的菜谱中所做的那样,我们将在流域算法的描述中使用拓扑图类比。为了创建流域分割,想法是从级别 0 开始逐步洪水淹没图像。随着水位的逐步增加(到级别 1、2、3 等等),会形成集水盆地。这些盆地的尺寸也逐步增加,因此,两个不同盆地的水最终会合并。当这种情况发生时,会创建一个流域以保持两个盆地的分离。一旦水位达到最大级别,这些创建的盆地和流域的集合形成流域分割。

如预期的那样,洪水过程最初会创建许多小的独立盆地。当所有这些盆地合并时,会创建许多流域线,这导致图像过度分割。为了克服这个问题,已经提出了一种修改后的算法,其中洪水过程从一组预定义的标记像素开始。从这些标记创建的盆地根据分配给初始标记的值进行标记。当两个具有相同标签的盆地合并时,不会创建流域,从而防止过度分割。这就是调用cv::watershed函数时发生的情况。输入标记图像被更新以产生最终的流域分割。用户可以输入带有任何数量标签和未知标记像素保留为值0的标记图像。标记图像被选择为 32 位有符号整数的图像,以便能够定义超过255个标签。它还允许将特殊值-1分配给与流域相关的像素。

为了便于显示结果,我们引入了两种特殊方法。第一种方法返回标签图像(流域值为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。这是由于在将有符号整数转换为无符号字符时应用的饱和操作。

还有更多...

显然,标记图像可以通过多种方式获得。例如,可以交互式地要求用户通过在对象和场景背景上绘制一些区域来标记图像中的对象。或者,为了识别位于图像中心的对象,也可以简单地输入一个带有中心区域标记的图像,该区域带有某种标签,图像的边缘(假设存在背景)带有另一个标签。可以通过以下方式在标记图像上绘制粗矩形来创建这个标记图像:

    // Identify background pixels 
    cv::Mat imageMask(image.size(),CV_8U,cv::Scalar(0)); 
    cv::rectangle(imageMask, cv::Point(5,5),  
                  cv::Point(image.cols-5, image.rows-5),   
                  cv::Scalar(255), 3); 
    // Identify foreground pixels 
    // (in the middle of the image) 
    cv::rectangle(imageMask,
                  cv::Point(image.cols/2-10,image.rows/2-10),
                  cv::Point(image.cols/2+10,image.rows/2+10),
                  cv::Scalar(1), 10); 

如果我们将这个标记图像叠加到测试图像上,我们将获得以下图像:

还有更多...

下面的结果是流域图像:

还有更多...

参见

  • 文章《粘性流域变换》,作者 C. Vachier 和 F. Meyer,《数学图像与视觉杂志》,第 22 卷,第 2-3 期,2005 年 5 月,提供了关于流域变换的更多信息

使用 MSER 提取独特区域

在前面的菜谱中,你学习了如何通过逐渐淹没图像并创建流域来将图像分割成区域。最大稳定外部区域MSER)算法使用相同的沉浸类比来从图像中提取有意义的区域。这些区域将通过逐级淹没图像来创建,但这次,我们将对在沉浸过程中相对稳定的盆地感兴趣。将观察到这些区域对应于图像中场景对象的某些独特部分。

如何做...

计算图像 MSER 的基本类是cv::MSER。这个类是从cv::Feature2D类继承的抽象接口;实际上,OpenCV 中的所有特征检测器都继承自这个超类。可以通过使用create方法创建cv::MSER类的实例。在这里,我们通过指定检测区域的最小和最大尺寸来初始化它,以限制检测到的特征数量,如下所示:

    // basic MSER detector 
    cv::Ptr<cv::MSER> ptrMSER=  
     cv::MSER::create(5,     // delta value for local detection 
                      200,   // min acceptable area 
                      2000); // max acceptable area 

现在,可以通过调用detectRegions方法来获得 MSER,指定输入图像和适当的数据结构,如下所示:

    // vector of point sets 
    std::vector<std::vector<cv::Point> > points; 
    // vector of rectangles 
    std::vector<cv::Rect> rects; 
    // detect MSER features 
    ptrMSER->detectRegions(image, points, rects); 

检测结果以由像素点组成的区域向量形式提供,这些像素点构成了每个区域,以及包围区域的边界框向量。为了可视化结果,我们在一个空白图像上创建一个图像,将在不同颜色(随机选择)上显示检测到的区域。这是按照以下步骤进行的:

    // create white image 
    cv::Mat output(image.size(),CV_8UC3); 
    output= cv::Scalar(255,255,255); 

    // OpenCV random number generator 
    cv::RNG rng; 

    // Display the MSERs in color areas 
    // for each detected feature 
    // reverse order to display the larger MSER first 
    for (std::vector<std::vector<cv::Point> >::reverse_iterator  
             it= points.rbegin(); 
             it!= points.rend(); ++it) { 

        // generate a random color 
        cv::Vec3b c(rng.uniform(0,254),  
                    rng.uniform(0,254), rng.uniform(0,254)); 

        // for each point in MSER set 
        for (std::vector<cv::Point>::iterator itPts= it->begin(); 
                    itPts!= it->end(); ++itPts) { 

          // do not overwrite MSER pixels 
          if (output.at<cv::Vec3b>(*itPts)[0]==255) { 
            output.at<cv::Vec3b>(*itPts)= c; 
          } 
        } 
      } 

注意,MSER 形成了一个区域层次结构。因此,为了使所有这些区域都可见,我们选择在较大区域包含较小区域时,不覆盖较大区域。我们可以在以下图像上检测到 MSER:

如何做...

结果图像将如下所示:

如何做...

并非所有区域都可见于这张图像中。然而,我们可以观察到这个算子是如何从这张图像中提取出一些有意义的区域(例如,建筑的窗户)的。

它是如何工作的...

MSER 使用与分水岭算法相同的机制;也就是说,它是通过逐渐从级别 0 到级别 255 浇灌图像来进行的。请注意,在图像处理中,高于某个阈值的像素集通常被称为 水平集。随着水位的升高,你可以观察到那些尖锐界定的较暗区域形成了具有相对稳定形状的盆地(回想一下,在沉浸类比中,水位对应于强度水平)。这些稳定的盆地就是 MSER。这些是通过考虑每个级别的连通区域(盆地)并测量它们的稳定性来检测的。这是通过比较一个区域的当前面积与当级别下降 delta 值时的先前面积来完成的。当这种相对变化达到局部最小值时,该区域被识别为 MSER。用于测量相对稳定性的 delta 值是 cv::MSER 类构造函数的第一个参数;其默认值是 5。此外,要考虑的区域大小必须在某个预定义的范围内。可接受的区域最小和最大大小是构造函数的下一个两个参数。我们还必须确保 MSER 是稳定的(第四个参数),也就是说,其形状的相对变化足够小。稳定的区域可以包含在更大的区域中(称为父区域)。

要有效,父 MSER 必须与其子区域有足够的差异;这是多样性标准,它由 cv::MSER 构造函数的第五个参数指定。在上一节中使用的示例中,使用了这两个最后参数的默认值。(这些最后两个参数的默认值是 MSER 允许的最大变化为 0.25,父 MSER 的最小多样性为 0.2)。正如你所见,MSER 的检测需要指定多个参数,这可能会使其在不同环境中难以有效工作。

MSER 检测器的第一个输出是一个点集向量;这些点集中的每一个都构成一个区域。由于我们通常对整个区域更感兴趣,而不是其单个像素位置,因此通常用包围检测到的区域的一个简单几何形状来表示 MSER。因此,检测的第二个输出是一个边界框列表。因此,我们可以通过绘制所有这些矩形边界框来显示检测的结果。然而,这可能会表示出大量的矩形,使得结果难以可视化(记住我们还有区域内的区域,这使得表示更加杂乱)。在我们的例子中,让我们假设我们主要对检测建筑物的窗户感兴趣。因此,我们将提取所有具有直立矩形形状的区域。这可以通过比较每个边界框的面积与相应检测区域的面积来完成。如果两者具有相同的值(在这里,我们检查这两个面积的比率是否大于0.6),那么我们接受这个 MSER。以下代码实现了这个测试:

    // Extract and display the rectangular MSERs 
    std::vector<cv::Rect>::iterator itr = rects.begin(); 
    std::vector<std::vector<cv::Point> >::iterator itp = points.begin(); 
    for (; itr != rects.end(); ++itr, ++itp) { 
      // ratio test 
      if (static_cast<double>(itp->size())/itr->area() > 0.6) 
        cv::rectangle(image, *itr, cv::Scalar(255), 2); 
    } 

提取的 MSER 如下所示:

工作原理...

根据应用的不同,也可以采用其他标准和表示方法。以下代码测试检测到的区域是否不太细长(基于其旋转边界矩形的纵横比),然后使用适当方向的边界椭圆显示它们。

    // Extract and display the elliptic MSERs 
    for (std::vector<std::vector<cv::Point> >::iterator  
              it = points.begin(); 
              it != points.end(); ++it) { 
       // for each point in MSER set 
       for (std::vector<cv::Point>::iterator itPts = it->begin(); 
              itPts != it->end(); ++itPts) { 

           // Extract bouding rectangles 
          cv::RotatedRect rr = cv::minAreaRect(*it); 
          // check ellipse elongation 
          if (rr.size.height / rr.size.height > 0.6 ||  
              rr.size.height / rr.size.height < 1.6) 
              cv::ellipse(image, rr, cv::Scalar(255), 2); 
      } 
    }   

结果如下所示:

工作原理...

注意子 MSER 和父 MSER 通常由非常相似的椭圆表示。在某些情况下,然后对这些椭圆应用最小变化标准以消除这些重复表示可能是有趣的。

参见

  • 第七章的计算组件形状描述符中的“提取线条、轮廓和组件”配方将向您展示如何计算连接点集的其他属性

  • 第八章检测兴趣点将解释如何将 MSER 用作兴趣点检测器

第六章. 图像过滤

在本章中,我们将涵盖以下内容:

  • 使用低通滤波器过滤图像

  • 使用滤波器对图像进行下采样

  • 使用中值滤波器过滤图像

  • 应用方向滤波器检测边缘

  • 计算图像的拉普拉斯算子

简介

过滤是信号和图像处理中的基本任务之一。它是一个旨在选择性地提取图像中某些方面(在特定应用背景下被认为传达了重要信息)的过程。过滤可以去除图像中的噪声,提取有趣的视觉特征,允许图像重采样,等等。它源于一般的信号与系统理论。我们在这里不会详细讨论这个理论。然而,本章将介绍一些与过滤相关的重要概念,并展示如何在图像处理应用中使用过滤器。但首先,让我们从频率域分析的概念简要解释开始。

当我们观察图像时,我们观察到不同的灰度级(或颜色)模式覆盖在其上。图像之间的差异在于它们有不同的灰度级分布。然而,还有一种观点可以用来分析图像。我们可以观察图像中存在的灰度级变化。一些图像包含几乎恒定强度的大面积(例如,一片蓝天),而其他图像的灰度级强度在图像上快速变化(例如,一个拥挤的场景,充满了许多小物体)。

因此,观察图像中这些变化的频率构成了表征图像的另一种方式。这种观点被称为频域,而通过观察其灰度级分布来表征图像则被称为空间域

频域分析将图像分解为其从最低到最高频率的频率内容。图像强度变化缓慢的区域只包含低频,而高频是由强度快速变化产生的。存在几种著名的变换,如傅里叶变换余弦变换,可以用来明确地显示图像的频率内容。请注意,由于图像是一个二维实体,它既包含垂直频率(垂直方向的变化)也包含水平频率(水平方向的变化)。

在频域分析框架下,滤波器是一种操作,它放大图像(或保持不变)的某些频率带,同时阻止(或减少)其他图像频率带。例如,低通滤波器是一种消除图像高频成分的滤波器;相反,高通滤波器消除低频成分。本章将介绍一些在图像处理中经常使用的滤波器,并解释它们在图像上应用时的效果。

使用低通滤波器进行图像滤波

在这个第一个菜谱中,我们将介绍一些非常基本的低通滤波器。在本章的介绍部分,我们了解到这类滤波器的目的是减少图像变化的幅度。实现这一目标的一种简单方法是将每个像素替换为其周围像素的平均值。通过这样做,快速强度变化将被平滑,从而被更渐进的过渡所取代。

如何操作...

cv::blur函数的目标是通过用矩形邻域内计算的平均像素值替换每个像素来平滑图像。这个低通滤波器如下应用:

cv::blur(image,result, cv::Size(5,5)); // size of the filter 

这种滤波器也被称为箱式滤波器。在这里,我们通过使用5x5滤波器来应用它,以便使滤波器的影响更加明显。我们的原始图像如下所示:

如何操作...

应用在先前图像上的滤波器结果如下所示:

如何操作...

在某些情况下,可能希望给邻域中较近的像素赋予更高的权重。因此,可以计算一个加权平均值,其中附近的像素被分配比较远的像素更大的权重。这可以通过使用遵循高斯函数(一种“钟形”函数)的加权方案来实现。cv::GaussianBlur函数应用这种滤波器,其调用方式如下:

cv::GaussianBlur(image, result,  
                 cv::Size(5,5), // size of the filter 
                 1.5);          // parameter controlling 
                                // the shape of the Gaussian

结果是以下图像:

如何操作...

它是如何工作的...

如果一个滤波器的应用对应于用一个像素替换为相邻像素的加权总和,那么这个滤波器被称为线性滤波器。这是均值滤波器的情况,其中一个像素被替换为矩形邻域内所有像素的总和除以邻域的大小(以获得平均值)。这就像将每个相邻像素乘以像素总数的1,然后将所有这些值相加。滤波器的不同权重可以用一个矩阵来表示,该矩阵显示了与考虑的邻域中每个像素位置相关的乘数。

矩阵的核心元素对应于当前应用滤波器的像素。这种矩阵有时被称为掩模。对于一个3x3均值滤波器,相应的核如下所示:

如何工作...

cv::boxFilter 函数使用仅由许多 1 组成的正方形核过滤图像。它与均值滤波器相似,但不需要将结果除以系数的数量。

应用线性滤波器相当于将核在图像的每个像素上移动,并将每个对应的像素乘以其关联的权重。从数学上讲,这个操作被称为 卷积,可以正式写成以下形式:

如何工作...

前面的双重求和将当前像素 (x,y) 与核的中心对齐,假设核的中心在坐标 (0,0)

观察本食谱生成的输出图像,可以观察到低通滤波器的净效应是模糊或平滑图像。这并不令人惊讶,因为该滤波器衰减了与物体边缘上可见的快速变化相对应的高频分量。

在高斯滤波器的情况下,与像素关联的权重与其与中心像素的距离成正比。回想一下,1D 高斯函数具有以下形式:

如何工作...

正则化系数 A 被选择,使得高斯曲线下的面积等于一。σ (sigma) 值控制着结果高斯函数的宽度。这个值越大,函数就越平坦。例如,如果我们计算区间 [-4, 0, 4] 的 1D 高斯滤波器的系数,σ = 0.5,我们得到以下系数:

[0.0 0.0 0.00026 0.10645 0.78657 0.10645 0.00026 0.0 0.0]

对于 σ=1.5,这些系数如下:

[0.0076 0.03608 0.1096 0.2135 0.2667 0.2135 0.1096 0.0361 0.0076 ]

注意,这些值是通过调用 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值作为σ。将根据给定的大小确定最佳σ值。

参见

  • 使用滤波器对图像进行下采样配方解释了如何使用低通滤波器减小图像的大小。

  • 在第二章的“使用邻域访问配方扫描图像”的更多内容...部分中,介绍了cv::filter2D函数。此函数允许您通过输入您选择的核来对图像应用线性滤波器。

使用滤波器对图像进行下采样

图像通常需要调整大小(重采样)。减小图像大小的过程通常称为下采样,而增加其大小称为上采样。执行这些操作时的挑战是尽可能多地保留图像的视觉质量。为了实现这一目标,通常使用低通滤波器;本配方解释了原因。

如何操作...

您可能会认为,通过简单地消除图像的一些列和行,就可以减小图像的大小。不幸的是,生成的图像看起来不会很好。以下图通过仅保留每 4 列和行的1列和行来将原始图像大小减少4倍,以说明这一事实。

注意,为了使此图像中的缺陷更加明显,我们通过以四倍像素大小显示图像来放大图像:

如何操作...

很明显,可以看到图像质量已经下降。例如,原始图像中城堡屋顶的斜边现在在减小后的图像上表现为楼梯状。图像纹理部分(例如砖墙)的其他锯齿形扭曲也可见。

这些不希望出现的伪影是由一种称为空间混叠的现象引起的,当你试图在无法容纳这些高频分量的过小图像中包含它们时。确实,较小的图像(即像素较少的图像)无法像高分辨率图像那样很好地表示精细纹理和锐利边缘(想想高清电视与较老电视技术的区别)。由于图像中的细微细节对应于高频,我们在减小图像尺寸之前需要移除这些高频分量。

我们在先前的菜谱中了解到,这可以通过低通滤波器来完成。因此,为了在不添加令人烦恼的伪影的情况下将图像尺寸减小四倍,你必须首先对原始图像应用低通滤波器,然后再丢弃列和行。这是使用 OpenCV 进行此操作的方法:

    // first remove high frequency component 
    cv::GaussianBlur(image,image,cv::Size(11,11),2.0); 
    // keep only 1 of every 4 pixels 
    cv::Mat reduced(image.rows/4,image.cols/4,CV_8U); 
    for (int i=0; i<reduced.rows; i++) 
      for (int j=0; j<reduced.cols; j++) 
        reduced.at<uchar>(i,j)= image.at<uchar>(i*4,j*4); 

结果图像(也以四倍正常大小的像素显示)如下:

如何做...

当然,图像的一些细微细节已经丢失,但从整体上看,图像的视觉质量比之前的情况(从远处看这张图像)要好得多。

它是如何工作的...

为了避免不希望的混叠效应,在减小图像尺寸之前,必须始终对图像进行低通滤波。正如我们之前解释的,低通滤波器的作用是消除在减小尺寸的图像中无法表示的高频分量。这一事实的正式理论已经建立,通常被称为奈奎斯特-香农定理。实质上,该理论告诉我们,如果你将图像下采样为原来的一半,那么可表示的频率带宽也将减少一半。

一个特殊的 OpenCV 函数使用这个原理进行图像减小。这是cv::pyrDown函数:

    cv::Mat reducedImage;            // to contain reduced image 
    cv::pyrDown(image,reducedImage); // reduce image size by half 

前面的函数使用一个5x5高斯滤波器在减小图像尺寸之前对其进行低通滤波。存在一个相反的cv::pyrUp函数,可以将图像尺寸加倍。值得注意的是,在这种情况下,上采样是通过在每两列和每两行之间插入 0 值来完成的,然后对扩展后的图像应用相同的5x5高斯滤波器(但系数乘以四)。显然,如果你先减小图像尺寸,然后再将其放大,你将无法恢复原始图像。在减小尺寸过程中丢失的内容无法恢复。这两个函数用于创建图像金字塔。这是一种由不同尺寸的图像堆叠版本组成的数据结构,用于高效的多尺度图像分析。结果图像如下:

它是如何工作的...

在这里,每个级别比前一个级别小两倍,但减少因子可以更小,不一定是整数(例如,1.2)。例如,如果你想高效地检测图像中的对象,可以先在小金字塔顶部的图像上完成检测,当你定位到感兴趣的对象时,可以通过移动到包含更高分辨率图像版本的金字塔较低级别来细化搜索。

注意,还有一个更通用的 cv::resize 函数,允许你指定所需的结果图像大小。你只需通过指定一个新大小来调用它,这个大小可以比原始图像小或大:

    cv::Mat resizedImage;                 // to contain resized image 
    cv::resize(image, resizedImage,
               cv::Size(image.cols/4,image.rows/4)); // 1/4 resizing 

也可以用缩放因子来指定调整大小。在这种情况下,给定的参数是一个空的大小实例,后跟所需的缩放因子:

    cv::resize(image, resizedImage,  
               cv::Size(), 1.0/4.0, 1.0/4.0); // 1/4 resizing 

一个最终参数允许你在重采样过程中选择要使用的插值方法。这将在下一节中讨论。

还有更多...

当图像按分数因子调整大小时,必须执行一些像素插值,以便在现有像素之间产生新的像素值。正如在第二章“操作像素”中的“重映射图像”配方所讨论的通用图像重映射,这也是需要像素插值的情况。

插值像素值

执行插值的最基本方法是用最近邻策略。必须生成的新的像素网格放置在现有图像的上方,并且每个新像素被分配其原始图像中最接近像素的值。在图像上采样(即,使用比原始网格更密集的新网格)的情况下,这意味着新网格的多个像素将从一个相同的原始像素接收其值。例如,通过最近邻插值将上一节中减小后的图像按四倍大小调整,操作如下:

    cv::resize(reduced, newImage, cv::Size(), 3, 3, cv::INTER_NEAREST); 

在这种情况下,插值相当于简单地增加每个像素的大小四倍。一个更好的方法是通过组合几个相邻像素的值来插值新的像素值。因此,我们可以通过考虑其周围的四个像素来线性插值像素值,如下面的图所示:

插值像素值

这是通过首先垂直插值两个像素值到添加的像素的左侧和右侧来完成的。然后,使用这两个插值像素(在前面的图中以灰色绘制)来水平插值所需位置的像素值。这种双线性插值方案是 cv::resize(也可以通过 cv::INTER_LINEAR 标志显式指定)使用的默认方法:

    cv::resize(reduced, newImage, cv::Size(), 4, 4, cv::INTER_LINEAR); 

以下为结果:

插值像素值

还有其他方法可以产生更优的结果。使用双三次插值时,考虑一个4x4像素的邻域来进行插值。然而,由于这种方法使用了更多的像素(16)并且涉及到立方项的计算,它比双线性插值计算速度慢。

参见

  • 第二章中关于使用邻域访问扫描图像更多内容...部分介绍了cv::filter2D函数。此函数允许您通过输入选择的核将线性滤波器应用于图像。

  • 第八章中关于检测尺度不变特征的配方使用图像金字塔在图像中检测兴趣点。

使用中值滤波器过滤图像

本章的第一个配方介绍了线性滤波器的概念。非线性滤波器也存在,并且可以在图像处理中有效地使用。这种滤波器之一就是我们在本配方中介绍的中值滤波器。

由于中值滤波器特别适用于对抗椒盐噪声(或在我们的情况下,仅椒盐),我们将使用我们在第二章中第一个配方中创建的图像,操作像素,此处重新呈现:

使用中值滤波器过滤图像

如何操作...

调用中值滤波函数的方式与其他滤波器类似:

    cv::medianBlur(image, result, 5);  
    // last parameter is size of the filter 

结果图像如下:

如何操作...

如何工作...

由于中值滤波器不是线性滤波器,它不能通过核矩阵表示,不能通过卷积操作应用(即使用本章第一个配方中引入的双重求和方程)。然而,它也作用于像素的邻域以确定输出像素值。像素及其邻域形成一组值,正如其名称所暗示的,中值滤波器将简单地计算这组值的中值(一组的中值是当组排序时中间位置的值)。然后,当前像素被替换为中值值。

这解释了为什么该滤波器在消除椒盐噪声方面如此高效。确实,当给定像素邻域中存在异常的黑色或白色像素时,它永远不会被选为中值值(而是最大或最小值),因此它总是被相邻的值所替代。

相比之下,一个简单的均值滤波器会受到这种噪声的严重影响,如下面的图像所示,它表示了我们的椒盐噪声损坏图像的均值滤波版本:

如何工作...

显然,噪声像素改变了相邻像素的均值。因此,即使噪声已被均值滤波器模糊,噪声仍然可见。

中值滤波器也有保留边缘锐度的优势。然而,它会在均匀区域(例如,背景中的树木)洗掉纹理。由于它在图像上产生的视觉影响,中值滤波器通常用于在照片编辑软件工具中创建特殊效果。你应该在彩色图像上测试它,看看它如何产生卡通般的图像。

应用方向滤波器以检测边缘

本章的第一种配方介绍了使用核矩阵进行线性滤波的想法。所使用的滤波器具有通过移除或衰减其高频成分来模糊图像的效果。在本配方中,我们将执行相反的转换,即放大图像的高频内容。因此,本配方中引入的高通滤波器将执行边缘检测

如何操作...

我们在这里使用的滤波器被称为Sobel滤波器。据说它是一个方向滤波器,因为它只影响垂直或水平图像频率,这取决于使用的滤波器核。OpenCV 有一个函数可以对图像应用 Sobel 算子。水平滤波器的调用方式如下:

    cv::Sobel(image,     // input 
              sobelX,    // output 
              CV_8U,     // image type 
              1, 0,      // kernel specification 
              3,         // size of the square kernel 
              0.4, 128); // scale and offset 

垂直滤波是通过以下(与水平滤波非常相似)的调用实现的:

    cv::Sobel(image,     // input 
              sobelY,    // output 
              CV_8U,     // image type 
              0, 1,      // kernel specification 
              3,         // size of the square kernel 
              0.4, 128); // scale and offset 

函数提供了几个整数参数,这些将在下一节中解释。请注意,这些参数已被选择以生成输出结果的 8 位图像(CV_8U)表示。

水平Sobel算子的结果如下:

如何操作...

由于,如将在下一节中看到的,Sobel算子的核包含正负值,因此Sobel滤波器的结果通常在 16 位有符号整数图像(CV_16S)中计算。为了将结果显示为 8 位图像,如图中所示,我们使用了零值对应灰度级别128的表示。负值由较暗的像素表示,而正值由较亮的像素表示。垂直 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); 

然后生成以下图像:

如何操作...

看着这张图,现在很清楚为什么这种算子被称为边缘检测器。然后可以阈值化图像,以获得显示图像轮廓的二值图。以下代码片段创建了随后的图像:

    cv::threshold(sobelImage, sobelThresholded,  
                  threshold, 255, cv::THRESH_BINARY); 

如何做...

它是如何工作的...

Sobel 算子是一种经典的边缘检测线性滤波器,它基于两个简单的3x3核,具有以下结构:

如何工作...如何工作...

如果我们将图像视为一个二维函数,那么 Sobel 算子可以被视为图像在垂直和水平方向上的变化度量。在数学上,这个度量被称为梯度,它定义为从函数的两个正交方向的第一导数构成的二维向量:

如何工作...

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 核是通过为xorderyorder参数指定10来获得的,而垂直核将通过指定01来生成。其他组合也是可能的,但这两个是最常用的(二阶导数的情况将在下一个菜谱中讨论)。最后,也可以使用大于3x3大小的核。可能的核大小值为1357。大小为1的核对应于 1D Sobel 滤波器(1x33x1)。请参阅以下更多内容...部分,了解为什么使用更大的核可能是有用的。

由于梯度是一个二维向量,它有一个范数和一个方向。梯度向量的范数告诉你变化的幅度是多少,它通常被计算为欧几里得范数(也称为L2 范数):

如何工作...

然而,在图像处理中,这个范数通常被计算为绝对值的总和。这被称为L1 范数,它给出的值接近 L2 范数,但计算成本更低。这就是我们在本食谱中所做的:

    // compute the L1 norm 
    sobel= abs(sobelX)+abs(sobelY); 

梯度向量始终指向最陡变化的方向。对于图像,这意味着梯度方向将与边缘正交,指向暗到亮的方向。梯度角度方向由以下公式给出:

通常,对于边缘检测,只计算范数。但是,如果您需要范数和方向,则可以使用以下 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; 
    // Cartesian to polar transformation to get magnitude and angle 
    cv::cartToPolar(sobelX,sobelY,norm,dir); 

默认情况下,方向是以弧度计算的。只需将true作为附加参数添加,就可以以度为单位进行计算。

通过对梯度幅度应用阈值,已获得二值边缘图。选择正确的阈值不是一个显而易见的工作。如果阈值值太低,将保留太多(粗)边缘,而如果我们选择更严格的(更高的)阈值,则将获得断裂边缘。为了说明这种权衡情况,可以将前面的二值边缘图与以下使用更高阈值值获得的图进行比较:

如何工作...

要同时获得低阈值和高阈值的最佳效果,可以使用滞后阈值的概念。这将在下一章中解释,其中我们将介绍 Canny 算子。

更多内容...

其他梯度算子也存在。我们将在本节中介绍其中的一些。在应用导数滤波器之前,也可以先应用高斯平滑滤波器。这使其对噪声的敏感性降低,如本节所述。

梯度算子

为了估计像素位置的梯度,Prewitt 算子定义了以下核:

梯度算子梯度算子

Roberts 算子基于以下简单的2x2核:

梯度算子梯度算子

当需要更精确的梯度方向估计时,Scharr 算子更受欢迎:

梯度算子梯度算子

注意,可以通过使用cv::Sobel函数并使用CV_SCHARR参数来调用它,使用 Scharr 核:

    cv::Sobel(image,sobelX,CV_16S,1,0, CV_SCHARR); 

或者,等价地,你可以调用cv::Scharr函数:

    cv::Scharr(image,scharrX,CV_16S,1,0,3); 

所有这些方向滤波器都试图估计图像函数的一阶导数。因此,在滤波器方向上存在较大强度变化区域,会获得高值,而平坦区域产生低值。这就是为什么计算图像导数的滤波器是高通滤波器。

高斯导数

导数滤波器是高通滤波器。因此,它们倾向于放大图像中的噪声和小的、高度对比的细节。为了减少这些高频元素的影响,在应用导数滤波器之前先平滑图像是一个好习惯。你可能会认为这将是两个步骤,即先平滑图像然后计算导数。然而,仔细观察这些操作可以发现,可以通过适当选择平滑核将这两个步骤合并为一个。我们之前了解到,图像与滤波器的卷积可以表示为项的求和。有趣的是,一个著名的数学性质是,项的求和的导数等于项的导数的求和。

因此,而不是在平滑的结果上应用导数,可以推导出核,然后将其与图像进行卷积;这两个操作随后在单个像素遍历中完成。由于高斯核是连续可导的,它是一个特别合适的选择。当你用不同核大小调用cv::sobel函数时,就是这样做的。该函数将计算具有不同σ值的高斯导数核。例如,如果我们选择7x7索贝尔滤波器(即kernel_size=7)在x方向上,将得到以下结果:

高斯导数

如果你将此图像与之前显示的图像进行比较,可以看出许多细微细节已被移除,从而使得对更重要的边缘更加突出。请注意,我们现在有一个带通滤波器,一些较高频率被高斯滤波器移除,而较低频率被Sobel滤波器移除。

参见

  • 在第七章的使用 Canny 算子检测图像轮廓配方中,提取线、轮廓和组件展示了如何使用两个不同的阈值值获得二值边缘图

计算图像的拉普拉斯算子

拉普拉斯算子是另一种基于图像导数计算的高通线性滤波器。正如将解释的那样,它计算二阶导数以测量图像函数的曲率。

如何做到这一点...

OpenCV 函数cv::Laplacian计算图像的拉普拉斯算子。它与cv::Sobel函数非常相似。事实上,它使用相同的基函数cv::getDerivKernels来获取其核矩阵。唯一的区别是没有导数阶数参数,因为这些参数按定义是二阶导数。

对于此算子,我们将创建一个简单的类,它将封装一些与拉普拉斯相关的有用操作。基本属性和方法如下:

    class LaplacianZC { 

      private: 
      // 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); 
        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; 
        // get min and max laplacian values 
        cv::minMaxLoc(laplace,&lapmin,&lapmax); 
        // scale the laplacian to 127 
        scale= 127/ std::max(-lapmin,lapmax); 
      } 

      // produce gray-level image 
      cv::Mat laplaceImage; 
      laplace.convertTo(laplaceImage,CV_8U,scale,128); 
      return laplaceImage; 
    } 

使用这个类,从7x7核计算得到的拉普拉斯图像如下所示:

    // Compute Laplacian using LaplacianZC class 
    LaplacianZC laplacian; 
    laplacian.setAperture(7); // 7x7 laplacian 
    cv::Mat flap= laplacian.computeLaplacian(image); 
    laplace= laplacian.getLaplacianImage(); 

最终生成的图像如下所示:

如何做...

如何工作...

形式上,二维函数的拉普拉斯定义为其二阶导数的和:

如何工作...

在其最简单形式中,它可以近似为以下3x3核:

如何工作...

就像 Sobel 算子一样,也可以使用更大的核来计算拉普拉斯,并且由于这个算子对图像噪声更加敏感,因此这样做是可取的(除非计算效率是一个问题)。由于这些较大的核是使用高斯函数的二阶导数计算的,因此相应的算子通常被称为高斯拉普拉斯LoG)。请注意,拉普拉斯的核值总是加起来等于0。这保证了拉普拉斯将在强度恒定的区域内为零。实际上,由于拉普拉斯测量图像函数的曲率,它应该在平坦区域内等于0

初看之下,拉普拉斯的效果可能难以解释。从核的定义来看,很明显,任何孤立的像素值(即与邻居非常不同的值)都将被算子放大。这是算子对噪声高度敏感的结果。然而,更有趣的是观察图像边缘周围的拉普拉斯值。图像中边缘的存在是不同灰度级强度区域之间快速转换的结果。沿着边缘(例如,由暗到亮的转换引起的)跟踪图像函数的演变,可以观察到灰度级上升必然意味着从正曲率(当强度值开始上升时)到负曲率(当强度即将达到其高平台时)的逐渐过渡。因此,正负拉普拉斯值之间的转换(或反之)是边缘存在的好指标。另一种表达这个事实的方法是说,边缘将位于拉普拉斯函数的零交叉点。我们将通过查看测试图像中一个小窗口内的拉普拉斯值来阐述这个想法。我们选择一个对应于城堡塔楼底部部分创建的边缘的值。在以下图像中画了一个白色框,以显示这个感兴趣区域的精确位置:

如何工作...

下图显示了所选窗口内拉普拉斯图像(7x7核)的数值(除以100):

如何工作...

如果,如图所示,你仔细追踪拉普拉斯算子的某些零交叉点(位于不同符号像素之间),你将得到一条曲线,它对应于图像窗口中可见的一些边缘。在先前的图中,我们在所选图像窗口中可见的塔边缘对应的零交叉点上画了线。这意味着,原则上,你甚至可以以亚像素的精度检测图像边缘。

跟随拉普拉斯图像中的零交叉曲线是一项精细的任务。然而,可以使用一个简化的算法来检测近似零交叉位置。这个算法首先在0处对拉普拉斯算子进行阈值处理,以便获得一个分隔正负值的分区。这两个分区之间的界限对应于我们的零交叉点。因此,我们使用形态学操作来提取这些轮廓,即我们从拉普拉斯图像中减去膨胀图像(这是在第五章中介绍的 Beucher 梯度,在灰度图像上应用形态学算子食谱)。此算法通过以下方法实现,该方法生成零交叉的二值图像:

    // Get a binary image of the zero-crossings 
    // laplacian image should be CV_32F 
    cv::Mat getZeroCrossings(cv::Mat laplace) { 
      // threshold at 0 
      // negative values in black 
      // positive values in white 
      cv::Mat signImage; 
      cv::threshold(laplace,signImage,0,255,cv::THRESH_BINARY); 

      // convert the +/- image into CV_8U 
      cv::Mat binary; 
      signImage.convertTo(binary,CV_8U); 
      // dilate the binary image of +/- regions 
      cv::Mat dilated; 
      cv::dilate(binary,dilated,cv::Mat()); 

      // return the zero-crossing contours 
      return dilated-binary; 
    } 

结果是以下二值图:

如何工作...

如你所见,拉普拉斯算子的零交叉点检测到了所有的边缘。没有在强边缘和弱边缘之间做出区分。我们还提到,拉普拉斯算子对噪声非常敏感。值得注意的是,一些可见的边缘是由于压缩伪影造成的。所有这些因素解释了为什么操作者检测到这么多边缘。实际上,拉普拉斯算子通常与其他算子结合使用来检测边缘(例如,可以在强梯度幅度的零交叉位置声明边缘)。我们还将了解到第八章,检测兴趣点,拉普拉斯算子和其他二阶算子在检测多尺度兴趣点方面非常有用。

还有更多...

拉普拉斯算子是一个高通滤波器,但有趣的是,可以通过使用低通滤波器的组合来近似它。但在探讨这个方面之前,让我们谈谈图像增强,这是一个我们已经在第二章中讨论过的主题,操作像素

使用拉普拉斯算子增强图像

通过从图像中减去其拉普拉斯变换,可以增强图像的对比度。这就是我们在第二章的使用邻域访问扫描图像菜谱中做的事情,操作像素,在那里我们引入了核:

使用拉普拉斯增强图像对比度

这等于 1 减去拉普拉斯核(即原始图像减去其拉普拉斯变换)。

高斯函数差异

本章第一道菜谱中介绍的高斯滤波器提取图像的低频部分。我们了解到,高斯滤波器过滤的频率范围取决于参数σ,它控制滤波器的宽度。现在,如果我们从两个不同带宽的高斯滤波器对图像进行滤波的结果中减去这两个图像,那么得到的图像将只包含一个滤波器保留而另一个没有保留的高频部分。这种操作称为高斯函数差异DoG),其计算方法如下:

    cv::GaussianBlur(image,gauss20,cv::Size(),2.0); 
    cv::GaussianBlur(image,gauss22,cv::Size(),2.2); 

    // Compute a difference of Gaussians 
    cv::subtract(gauss22, gauss20, dog, cv::Mat(), CV_32F); 

    // Compute the zero-crossings of DoG 
    zeros= laplacian.getZeroCrossings(dog); 

代码的最后一行计算DoG算子的零交叉。它得到以下图像:

高斯函数差异

实际上,可以证明,通过适当选择σ值,DoG算子可以构成 LoG 滤波器的一个良好近似。此外,如果你从σ值递增的连续对值中计算一系列高斯函数差异,你将获得图像的尺度空间表示。这种多尺度表示很有用,例如,对于尺度不变图像特征检测,将在第八章的检测兴趣点中解释。

参见

  • 第八章中的检测尺度不变特征菜谱,检测兴趣点,使用拉普拉斯和 DoG 进行尺度不变特征的检测

第七章:提取线条、轮廓和组件

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

  • 使用 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 

让我们考虑以下图像:

如何做...

当算法应用于前述图像时,结果如下:

如何做...

注意,在这里我们反转了轮廓表示,因为正常的结果是通过非零像素来表示轮廓的。显示的图像仅仅是255-轮廓

它是如何工作的...

Canny 算子通常基于在第六章中介绍的 Sobel 算子,即过滤图像,尽管也可以使用其他梯度算子。这里的关键思想是使用两个不同的阈值来确定哪个点应属于轮廓:一个低阈值和一个高阈值。

低阈值应选择得包括所有被认为是属于重要图像轮廓的边缘像素。例如,使用前一小节中指定的低阈值值,并将其应用于 Sobel 算子的结果,可以得到以下边缘图:

它是如何工作的...

如所示,界定道路的边缘非常清晰。然而,由于使用了宽容的阈值,也检测到了比理想情况下更多的边缘。因此,第二个阈值的作用是定义属于所有重要轮廓的边缘。它应该排除所有被认为是异常值的边缘。例如,对应于我们示例中使用的较高阈值的 Sobel 边缘图如下:

工作原理...

我们现在有一个包含断裂边缘的图像,但可见的边缘肯定属于场景的重要轮廓。Canny 算法通过仅保留低阈值边缘图中存在连续边缘路径的边缘点,将这些边缘点连接到属于高阈值边缘图的边缘,将这两个边缘图结合起来以产生轮廓的最佳图。它通过仅保留高阈值图中的所有边缘点,同时移除低阈值图中的所有孤立边缘点链来实现。获得的解决方案是一个良好的折衷方案,只要指定了适当的阈值值,就可以获得高质量的轮廓。这种基于使用两个阈值来获得二值图的策略称为滞后阈值化,可以在任何需要从阈值化操作中获得二值图的环境中使用。然而,这是以更高的计算复杂度为代价的。

此外,Canny 算法采用额外策略来提高边缘图的质量。在应用阈值化之前,所有梯度方向上梯度幅度不是最大的边缘点都被移除(记住梯度方向始终垂直于边缘)。因此,这个方向上的梯度局部最大值对应于轮廓的最大强度点。这是一个轮廓细化操作,创建宽度为 1 像素的边缘。这也解释了为什么 Canny 轮廓图中会得到细边缘。

参见

  • J. Canny的经典文章,《一种边缘检测的计算方法》,IEEE Transactions on Pattern Analysis and Image Understanding,第 18 卷,第 6 期,1986 年

使用霍夫变换检测图像中的直线

在我们人造的世界中,平面和线性结构比比皆是。因此,直线在图像中经常可见。这些是有意义的特征,在物体识别和图像理解中起着重要作用。霍夫变换是一种经典算法,常用于检测图像中的这些特定特征。它最初是为了检测图像中的直线而开发的,正如我们将看到的,它还可以扩展到检测其他简单的图像结构。

准备工作

使用霍夫变换,直线用以下方程表示:

准备工作

ρ参数是线与图像原点(左上角)之间的距离,而θ是垂直于线的角度。在这种表示中,图像中可见的线的θ角度在0π弧度之间,而ρ半径可以有一个最大值,等于图像对角线的长度。例如,考虑以下线条集:

准备中

垂直线(例如,线1)的θ角度值等于零,而水平线(例如,线5)的θ值等于π/2。因此,线3θ角度等于π/4,线4大约在0.7π。为了能够用θ[0, π]区间内表示所有可能的线,可以将半径值设为负。这就是线2的情况,其θ值等于0.8π,而ρ的值为负。

如何做...

OpenCV 为线检测提供了两种 Hough 变换的实现。基本版本是cv::HoughLines。它的输入是一个包含一组点(由非零像素表示)的二值图,其中一些点排列成线。通常,这是从 Canny 算子获得的边缘图。cv::HoughLines函数的输出是一个cv::Vec2f元素的向量,每个元素都是一对浮点值,表示检测到的线的参数,(ρ,θ)。以下是一个使用此函数的示例,我们首先应用 Canny 算子以获得图像轮廓,然后使用 Hough 变换检测线:

    // Apply Canny algorithm 
    cv::Mat contours; 
    cv::Canny(image,contours,125,350); 
    // Hough transform for line detection 
    std::vector<cv::Vec2f> lines; 
    cv::HoughLines(test, lines, 1,  
                   PI/180,  // step size 
                   60);     // minimum number of votes 

参数 3 和 4 对应于线搜索的步长。在我们的例子中,函数将通过步长1搜索所有可能的半径的线,并通过步长π/180搜索所有可能的角。最后一个参数的作用将在下一节中解释。使用这种特定的参数值选择,在先前的食谱中的道路图像上检测到几条线。为了可视化检测的结果,有趣的是将这些线绘制在原始图像上。然而,重要的是要注意,此算法检测图像中的线而不是线段,因为每条线的端点没有给出。因此,我们将绘制穿过整个图像的线。为此,对于垂直方向的线,我们计算它与图像水平极限(即第一行和最后一行)的交点,并在这两个点之间绘制一条线。我们以类似的方式处理水平方向的线,但使用第一列和最后一列。使用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; 
    } 

以下结果得到:

如何做...

如所示,霍夫变换只是简单地寻找图像中边缘像素的对齐。这可能会由于偶然的像素对齐或当几条参数值略有不同的线通过相同的像素对齐时,可能会产生一些误检。

为了克服这些问题,并允许检测到线段(即带有端点),已经提出了一种变换的变体。这就是概率霍夫变换,它在 OpenCV 中作为 cv::HoughLinesP 函数实现。我们在这里使用它来创建我们的 LineFinder 类,该类封装了函数参数:

    class LineFinder { 

      private: 

      // original image 
      cv::Mat img; 

      // vector containing the endpoints 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 minimum 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(60); 

    // Detect lines and draw them on the image 
    std::vector<cv::Vec4i> lines= finder.findLines(contours); 
    finder.drawDetectedLines(image); 

前面的代码给出了以下结果:

如何做...

它是如何工作的...

霍夫变换的目标是在二值图像中找到所有通过足够多点的线。它通过考虑输入二值映射中的每个单独的像素点并识别所有通过它的可能线来进行。当相同的线通过许多点时,这意味着这条线足够重要,值得考虑。

霍夫变换使用一个二维累加器来计数给定线被识别的次数。这个累加器的大小由指定的步长(如前所述)定义的 (ρ,θ) 参数的线表示法。为了说明变换的工作原理,让我们创建一个 180200 列的矩阵(对应于 θ 的步长为 π/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*std::cos(theta)+y*std::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 的计数可见),我们得到以下结果:

它是如何工作的...

前面的曲线表示通过指定点的所有直线的集合。现在,如果我们用,比如说,点 (30,10) 重复相同的练习,我们现在有以下累加器:

它是如何工作的...

如所示,两个结果曲线在一点相交:对应于通过这两个点的直线的点。累加器的相应条目接收两个投票,表示有两个点通过这条线。

如果对二进制地图的所有点重复相同的过程,那么沿着给定直线对齐的点将多次增加累加器的公共条目。最后,你只需要识别这个累加器中接收了大量投票的局部极大值,以检测图像中的线条(即点对齐)。cv::HoughLines 函数中指定的最后一个参数对应于线条必须接收的最小投票数才能被认为是检测到的。这意味着这个最小投票数越低,检测到的线条数量就越多。

例如,如果我们将这个值降低到 50,在我们的道路示例中,那么现在检测到的以下线条:

它是如何工作的...

概率霍夫变换对基本算法进行了一些修改。首先,不是系统地按行扫描图像,而是在二进制地图中以随机顺序选择点。每当累加器的某个条目达到指定的最小值时,就会沿着相应的线条扫描图像,并移除所有通过它的点(即使它们还没有投票)。这种扫描还确定了将接受的段长度。为此,算法定义了两个额外的参数。一个是段被接受的最小长度,另一个是形成连续段所允许的最大像素间隙。这一额外步骤增加了算法的复杂性,但部分地通过减少参与投票过程中的点数来补偿,因为其中一些点被线扫描过程消除。

还有更多...

霍夫变换也可以用来检测其他几何实体。实际上,任何可以用参数方程表示的实体都是霍夫变换的良好候选者。还有一个广义霍夫变换可以检测任何形状的物体。

检测圆圈

在圆的情况下,相应的参数方程如下:

检测圆圈

该方程包含三个参数(圆的半径和中心坐标),这意味着需要一个三维累加器。然而,通常发现,随着其累加器维度的增加,霍夫变换变得更加复杂且不可靠。确实,在这种情况下,累加器的每个点都会增加大量条目,因此局部峰值的确切定位变得更加困难。已经提出了不同的策略来克服这个问题。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;    
    } 

下图是使用所选参数在测试图像上获得的结果:

检测圆

参见

  • 以下文章,《基于梯度的渐进概率 Hough 变换》,C. GalambosJ. KittlerJ. Matas撰写,发表在《IEEE 视觉图像与信号处理》,第 148 卷第 3 期,第 158-165 页,2002 年,是关于 Hough 变换的众多参考文献之一,描述了 OpenCV 中实现的概率算法。

  • 以下文章,《基于 Hough 变换的圆检测方法比较研究》,《图像与视觉计算》,第 8 卷第 1 期,第 71-77 页,1990 年,由H.K. YuenJ. PrincenJ. IllingworthJ. Kittler*撰写,描述了使用 Hough 变换进行圆检测的不同策略。

将一条线拟合到一组点

在某些应用中,可能不仅需要检测图像中的线,还需要获得线位置和方向的准确估计。这个菜谱将向您展示如何估计最适合给定点集的确切线。

如何做到这一点...

首先要做的是识别图像中似乎沿直线排列的点。让我们使用前一个菜谱中检测到的其中一条线。使用cv::HoughLinesP检测到的线包含在名为linesstd::vector<cv::Vec4i>中。为了提取似乎属于这些线的点集,例如,第一个这样的线,我们可以按以下步骤进行。我们在黑色图像上绘制一条白色线,并将其与用于检测线的contours的 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),  
             3);      // line width 
    // contours AND white line 
    cv::bitwise_and(contours,oneline,oneline); 

结果是一个包含可能关联到指定线的点的图像。为了引入一些容差,我们绘制了一条具有一定厚度(此处为3)的线。因此,定义的邻域内的所有点都被接受。

以下是获得的图像(为了更好的查看已反转):

如何做到这一点...

这个集合中点的坐标可以插入到std::vector中,其中包含cv::Point对象(浮点坐标,即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)); 
        } 
      } 
    } 

现在我们有一组点,我们想要拟合一条通过这些点的线。通过调用cv::fitLine OpenCV 函数,可以轻松找到最佳拟合线:

    cv::Vec4f line; 
    cv::fitLine(points,line, 
                cv::DIST_L2, //distance type 
                0,           //not used with L2 distance 
                0.01,0.01);  //accuracy 

上述代码以单位方向向量的形式(cv::Vec4f的前两个值)和线上的一个点的坐标(cv::Vec4f的最后两个值)的形式给出了线方程的参数。最后两个参数指定了线参数的请求精度。

通常,线方程将用于计算某些属性(校准是一个需要精确参数表示的好例子)。为了说明,并确保我们计算了正确的线,让我们在图像上绘制估计的线。在这里,我们简单地绘制了一个长度为100像素、厚度为2像素的任意黑色线段(使其可见):

    int x0= line[2];        // a point on the line 
    int y0= line[3]; 
    int x1= x0+100*line[0]; // add a vector of length 100 
    int y1= y0+100*line[1]; // using the unit vector 
    // draw the line 
    cv::line(image,cv::Point(x0,y0),cv::Point(x1,y1),  
             0,2);          // color and thickness 

以下图像显示了这条线与道路一侧的良好对齐:

如何操作...

它是如何工作的...

将直线拟合到一组点集是数学中的一个经典问题。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函数是您用来绘制计算出的椭圆的函数。

提取连通分量

图像通常包含对象的表示。图像分析的一个目标就是识别和提取这些对象。在目标检测/识别应用中,第一步通常是生成一个二值图像,显示感兴趣对象可能的位置。无论这个二值图是如何获得的(例如,从我们在第四章中执行的直方图反向投影,使用直方图计数像素,或者从我们在第十二章中将要学习的运动分析,处理视频序列),下一步就是从这组 1 和 0 中提取对象。

例如,考虑我们在第五章,使用形态学操作变换图像中操作的二值化水牛图像,如图所示:

提取连通分量

我们从简单的阈值操作和随后应用形态学滤波器获得此图像。这个配方将向您展示如何提取此类图像的对象。更具体地说,我们将提取连通分量,即在二值图像中由一组连接像素组成的形状。

如何操作...

OpenCV 提供了一个简单的函数,用于提取图像中连通分量的轮廓。这个函数是cv::findContours

    // the vector that will contain the contours 
    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::Point对象的向量表示。这解释了为什么输出参数被定义为std::vector实例的std::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 
                     0,  // in black 
                     2); // with a thickness of 2 

如果这个函数的第三个参数是一个负值,那么将绘制所有轮廓。否则,可以指定要绘制的轮廓的索引。结果如下:

如何做...

工作原理...

轮廓是通过一个简单的算法提取的,该算法系统地扫描图像,直到遇到一个组件。从这个组件的起始点开始,跟随其轮廓,标记其边界的像素。当轮廓完成时,扫描从最后的位置重新开始,直到找到新的组件。

确定的连通组件可以随后分别进行分析。例如,如果对感兴趣对象的大小有先验知识,就可以消除一些组件。然后,我们可以为组件的周长设置一个最小值和一个最大值。这是通过迭代轮廓向量并消除无效组件来完成的:

    // Eliminate too short or too long contours 
    int cmin= 50;   // minimum contour length 
    int cmax= 1000; // maximum contour length 
    std::vector<std::vector<cv::Point>>::
               iterator itc= contours.begin(); 
    // for all contours 
    while (itc!=contours.end()) { 

      // verify contour size 
      if (itc->size() < cmin || itc->size() > cmax) 
        itc= contours.erase(itc); 
      else 
        ++itc; 
    } 

注意,这个循环可以更高效,因为std::vector实例中的每次删除操作都是 O(N)。然而,考虑到这个向量的尺寸很小,总体成本并不高。

这次,我们在原始图像上绘制剩余的轮廓,得到以下结果:

工作原理...

我们非常幸运地找到了一个简单的标准,使我们能够识别出图像中所有感兴趣的对象。在更复杂的情况下,需要对组件属性进行更精细的分析。这就是下一个菜谱的主题,计算组件的形状描述符

还有更多...

使用cv::findContours函数,还可以在二值图中包括所有闭合轮廓,包括由组件形成的孔。这是通过在函数调用中指定另一个标志来完成的:

       cv::findContours(image,  
                        contours,               // a vector of contours  
                        cv::RETR_LIST,          // retrieve all contours 
                        cv::CHAIN_APPROX_NONE); // all pixels 

通过这个调用,得到以下轮廓:

还有更多...

注意到在背景森林中添加的额外轮廓。这些轮廓也可以组织成层次结构。主要组件是父元素,其中的孔是它的子元素,如果这些孔中有组件,它们成为先前子元素的子元素,依此类推。这个层次结构是通过使用 cv::RETR_TREE 标志获得的,如下所示:

    std::vector<cv::Vec4i> hierarchy; 
    cv::findContours(image, contours, // a vector of contours 
            hierarchy,                // hierarchical representation  
            cv::RETR_TREE,            // contours in tree format 
            cv::CHAIN_APPROX_NONE);   //all pixels of each contours 

在这种情况下,每个轮廓都有一个对应的同一索引的层次元素,由四个整数组成。前两个整数给出了同一级别的下一个和前一个轮廓的索引,后两个整数给出了此轮廓的第一个子元素和父元素的索引。负索引表示轮廓列表的结束。cv::RETR_CCOMP 标志类似,但限制了层次结构在两个级别。

计算组件的形状描述符

连通组件通常对应于图片场景中物体的图像。为了识别这个物体,或者将其与其他图像元素进行比较,对组件进行一些测量以提取其某些特征可能是有用的。在这个菜谱中,我们将查看 OpenCV 中可用的某些形状描述符,这些描述符可以用来描述连通组件的形状。

如何做...

当涉及到形状描述时,OpenCV 提供了许多函数。我们将应用其中的一些函数到前面菜谱中提取的组件上。特别是,我们将使用我们之前识别出的四个水牛对应的四个轮廓的向量。在下面的代码片段中,我们在轮廓上计算形状描述符(contours[0]contours[3]),并在轮廓图像上绘制结果(厚度为 2),轮廓图像的厚度为 1。此图像在本节末尾显示。

第一个是一个边界框,它应用于右下角的组件:

    // testing the bounding box  
    cv::Rect r0= cv::boundingRect(contours[0]); 
    // draw the rectangle 
    cv::rectangle(result,r0, 0, 2); 

最小包围圆类似。它应用于右上角的组件:

    // testing the enclosing circle  
    float radius; 
    cv::Point2f center; 
    cv::minEnclosingCircle(contours[1],center,radius); 
    // draw the circle 
    cv::circle(result,center,static_cast<int>(radius), 
               cv::Scalar(0),2); 

组件轮廓的多边形近似计算如下(在左侧组件):

    // testing the approximate polygon 
    std::vector<cv::Point> poly; 
    cv::approxPolyDP(contours[2],poly,5,true); 
    // draw the polygon 
    cv::polylines(result, poly, true, 0, 2); 

注意到多边形绘制函数 cv::polylines。它与其他绘图函数类似操作。第三个布尔参数用于指示轮廓是否闭合(如果是,则最后一个点与第一个点相连)。

凸包是另一种多边形近似形式(在左侧第二个组件):

    // testing the convex hull 
    std::vector<cv::Point> hull; 
    cv::convexHull(contours[3],hull); 
    // draw the polygon 
    cv::polylines(result, hull, true, 0, 2); 

最后,计算矩是另一个强大的描述符(质心被绘制在所有组件内部):

    // 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 
    } 

结果图像如下:

如何做...

它是如何工作的...

组件的边界框可能是表示和定位图像中组件的最紧凑方式。它被定义为完全包含形状的最小直立矩形。比较框的高度和宽度可以给出关于物体垂直或水平尺寸的指示(例如,可以通过高度与宽度的比例来区分汽车图像和行人图像)。当只需要近似组件大小和位置时,通常使用最小外接圆。

当需要操作一个更紧凑且类似于组件形状的表示时,组件的多边形近似很有用。它通过指定一个精度参数来创建,该参数给出了形状与其简化多边形之间的最大可接受距离。它是cv::approxPolyDP函数中的第四个参数。结果是cv::Point的向量,对应于多边形的顶点。为了绘制这个多边形,我们需要遍历这个向量,并通过在它们之间画线将每个点与下一个点连接起来。

形状的凸包,或称为凸包,是包含该形状的最小凸多边形。它可以想象成如果将一个弹性带围绕该形状放置时它所形成的形状。正如所见,凸包轮廓将在形状轮廓的凹处偏离原始轮廓。

这些位置通常被称为凸性缺陷,OpenCV 提供了一个特殊函数来识别它们:cv::convexityDefects函数。它的调用方式如下:

    std::vector<cv::Vec4i> defects; 
    cv::convexityDefects(contour, hull, defects); 

contourhull参数分别是原始轮廓和凸包轮廓(均以std::vector<cv::Point>实例表示)。输出是一个包含四个整数元素的向量。前两个整数是轮廓上点的索引,界定缺陷;第三个整数对应于凹处的最远点,最后,最后一个整数对应于这个最远点与凸包之间的距离。

瞬时矩是形状结构分析中常用的数学实体。OpenCV 定义了一个数据结构,用于封装形状的所有计算出的矩。它是cv::moments函数返回的对象。这些矩共同代表了一个物体形状的紧凑描述。它们在字符识别等应用中常用。我们简单地使用这个结构来获取由前三个空间矩计算出的每个组件的质量中心。

还有更多...

可以使用可用的 OpenCV 函数计算其他结构特性。cv::minAreaRect函数计算最小包含旋转矩形(这在第五章,使用形态学操作变换图像,在使用 MSER 提取特征区域的配方中使用过)。cv::contourArea函数估计轮廓的面积(轮廓内的像素数量)。cv::pointPolygonTest函数确定一个点是否在轮廓内部或外部,而cv::matchShapes测量两个轮廓之间的相似度。所有这些属性度量可以有效地结合起来,以执行更高级的结构分析。

四边形检测

在第五章中介绍的 MSER 特征,使用形态学操作变换图像,构成了一种有效的工具,可以用来从图像中提取形状。考虑到前一章中获得的 MSER 结果,我们现在将构建一个算法来检测图像中的四边形组件。对于当前图像,这种检测将使我们能够识别建筑物的窗户。MSER 图像的二值版本很容易获得,如下所示:

    // create a binary version 
    components= components==255; 
    // open the image (white background) 
    cv::morphologyEx(components,components,  
                     cv::MORPH_OPEN,cv::Mat(),
                     cv::Point(-1,-1),3); 

此外,我们使用形态学滤波器清理了图像。图像随后如下所示:

四边形检测

下一步是获取轮廓:

    //invert image (background must be black) 
    cv::Mat componentsInv= 255-components; 
    //Get the contours of the connected components 
    cv::findContours(componentsInv, 
                     contours,          // a vector of contours 
                     cv::RETR_EXTERNAL, // retrieve the external contours 
                     cv::CHAIN_APPROX_NONE); 

最后,我们遍历所有轮廓,并大致用多边形来近似它们:

    // white image 
    cv::Mat quadri(components.size(),CV_8U,255); 

    // for all contours 
    std::vector<std::vector<cv::Point>>::iterator it= contours.begin(); 
    while (it!= contours.end()) { 
      poly.clear(); 
      // approximate contour by polygon 
      cv::approxPolyDP(*it,poly,10,true); 

       // do we have a quadrilateral? 
      if (poly.size()==4) { 
        //draw it 
        cv::polylines(quadri, poly, true, 0, 2); 
      } 
      ++it; 
    } 

四边形是指具有四条边的多边形。检测到的如下:

四边形检测

要检测矩形,你可以简单地测量相邻边之间的角度,并拒绝那些角度与 90 度偏差太大的四边形。

第八章. 检测兴趣点

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

  • 在图像中检测角点

  • 快速检测特征

  • 检测尺度不变特征

  • 在多个尺度上检测 FAST 特征

简介

在计算机视觉中,兴趣点的概念,也称为关键点特征点,已被广泛用于解决物体识别、图像配准、视觉跟踪、3D 重建等问题。这个概念基于这样一个想法:与其将图像作为一个整体来观察(即提取全局特征),不如选择图像中的某些特殊点并对它们进行局部分析(即提取局部特征)。只要在感兴趣图像中检测到足够数量的此类点,并且这些点是区分性和稳定的特征,可以精确定位,这种方法就会很有效。这些特征可以准确定位。

因为它们用于分析图像内容,所以理想情况下应该在相同的场景或物体位置检测特征点,无论图像是从哪个视角、尺度或方向拍摄的。在图像分析中,视图不变性是一个非常理想化的特性,一直是众多研究的对象。正如我们将看到的,不同的检测器具有不同的不变性特性。本章主要关注关键点提取过程本身。接下来的章节将展示如何在不同的环境中使用兴趣点,例如图像匹配或图像几何估计。

在图像中检测角点

当在图像中搜索有趣的特征点时,角点是一个有趣的解决方案。它们确实是图像中的局部特征,可以轻松定位,而且它们在人造物体场景中应该很丰富(它们是由墙壁、门、窗户、桌子等产生的)。角点之所以有趣,还因为它们是二维特征,可以精确检测(甚至可以达到亚像素精度),因为它们位于两条边的交汇处。这与位于均匀区域或物体轮廓上的点形成对比;这些点在其他相同物体的图像上难以精确重复定位。Harris 特征检测器是检测图像角点的经典方法。我们将在这个食谱中探讨这个算子。

如何操作...

用于检测 Harris 角点的 OpenCV 基本函数称为cv::cornerHarris,使用起来非常简单。你可以在输入图像上调用它,结果是一个浮点图像,它给出了每个像素位置的角点强度。然后对这个输出图像应用一个阈值,以获得一组检测到的角点。这可以通过以下代码实现:

    // Detect Harris Corners 
    cv::Mat cornerStrength; 
    cv::cornerHarris(image,          // input image 
                     cornerStrength, // image of cornerness 
                     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); 

这是原始图像:如何操作...

结果是一个二进制映射图像,如下面的截图所示,为了更好的查看,它被反转了(即,我们使用了cv::THRESH_BINARY_INV而不是cv::THRESH_BINARY来获取检测到的角点为黑色):

如何操作...

从前面的函数调用中,我们可以观察到这个兴趣点检测器需要几个参数(这些将在下一节中解释),这些参数可能会使其难以调整。此外,获得的角点图包含许多角像素簇,这与我们希望检测到良好定位的点的事实相矛盾。因此,我们将尝试通过定义我们自己的类来检测 Harris 角点来改进角点检测方法。

该类封装了 Harris 参数及其默认值以及相应的 getter 和 setter 方法(此处未显示):

    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 neighborhood; 
      // 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(): neighborhood(3), aperture(3),  
                        k(0.01), maxStrength(0.0),  
                        threshold(0.01), nonMaxSize(3) { 

         // create kernel used in non-maxima suppression 
         setLocalMaxWindowSize(nonMaxSize); 
      } 

要在图像上检测 Harris 角点,我们分两步进行。首先,计算每个像素的 Harris 值:

    // 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 
      cv::minMaxLoc(cornerStrength,0,&maxStrength); 

      // local maxima detection 
      cv::Mat dilated;  //temporary image 
      cv::dilate(cornerStrength,dilated,cv::Mat()); 
      cv::compare(cornerStrength,dilated, localMax, cv::CMP_EQ); 
    } 

接下来,根据指定的阈值值获取特征点。由于 Harris 的可能值范围取决于其参数的特定选择,因此阈值被指定为质量级别,该级别定义为图像中计算的最大 Harris 值的分数:

    // 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; 
    } 

此方法返回检测到的特征的二进制角点图。由于 Harris 特征的检测被分为两种方法,这使得我们可以在不重复昂贵计算的情况下,通过不同的阈值(直到获得适当数量的特征点)来测试检测。此外,还可以将 Harris 特征以std::vectorcv::Point实例形式获得:

    // 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)); 
          } 
        } 
      } 
    } 

此类还通过添加非极大值抑制步骤来改进 Harris 角点的检测,这将在下一节中解释。现在可以使用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=1) { 
      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; 
      } 
    } 

使用此类,Harris 点的检测如下完成:

    // Create Harris detector instance 
    HarrisDetector harris; 
    // Compute Harris values 
    harris.detect(image); 
    // Detect Harris corners 
    std::vector<cv::Point> pts; 
    harris.getCorners(pts,0.02); 
    // Draw Harris corners 
    harris.drawOnImage(image,pts); 

这导致了以下图像:

如何操作...

它是如何工作的...

为了定义图像中角点的概念,Harris 特征检测器会观察围绕一个假设的兴趣点的小窗口内强度的平均方向变化。如果我们考虑一个位移向量(u,v),则可以通过平方差之和来测量强度变化:

它的工作原理...

求和是在考虑的像素周围的定义区域上进行的(这个区域的大小对应于cv::cornerHarris函数的第三个参数)。然后可以在所有可能的方向上计算平均强度变化,这导致了一个角点的定义,即在一个以上的方向上平均变化较高的点。从这个定义出发,哈里斯测试如下进行:我们首先获得最大平均强度变化的方向。接下来,我们检查正交方向上的平均强度变化是否也较高。如果是这样,那么我们有一个角点。

从数学上讲,这个条件可以通过使用前一个公式的泰勒展开来近似测试:

工作原理...

然后将它重写为矩阵形式:

工作原理...

这个矩阵是一个协方差矩阵,它表征了所有方向上强度变化的速度。这个定义涉及到图像的第一导数,这些导数通常使用 Sobel 算子来计算。在 OpenCV 实现中,函数的第四个参数对应于用于 Sobel 滤波器计算的孔径。可以证明,协方差矩阵的两个特征值给出了最大平均强度变化和正交方向上的平均强度变化。然后,如果这两个特征值都较低,我们处于一个相对均匀的区域。如果一个特征值较高而另一个较低,我们必须处于一个边缘上。最后,如果两个特征值都较高,那么我们处于一个角点位置。因此,一个点要被接受为角点,其协方差矩阵的最小特征值必须高于一个给定的阈值。

哈里斯角算法的原始定义使用特征分解理论的一些性质来避免显式计算特征值的成本。这些性质如下:

  • 矩阵的特征值的乘积等于其行列式

  • 矩阵的特征值之和等于矩阵对角线之和(也称为矩阵的迹)

因此,我们可以通过计算以下分数来验证矩阵的特征值是否较高:

工作原理...

可以很容易地验证,只有当两个特征值都较高时,这个分数才会确实很高。这是cv::cornerHarris函数在每个像素位置计算出的分数。k的值被指定为函数的第五个参数。确定这个参数的最佳值可能很困难。然而,在实践中,0.050.5范围内的值通常可以得到良好的结果。

为了提高检测结果,前一小节中描述的类添加了一个额外的非最大值抑制步骤。这里的目的是排除相邻的其他 Harris 角。因此,为了被接受,Harris 角不仅必须具有高于指定阈值的分数,而且它还必须是局部最大值。这个条件是通过在detect方法中使用一个简单的技巧来测试的,即膨胀 Harris 分数的图像:

    cv::dilate(cornerStrength, dilated,cv::Mat()); 

由于膨胀操作将每个像素值替换为定义的邻域中的最大值,因此唯一不会修改的点就是局部最大值。这正是以下等式测试所验证的:

    cv::compare(cornerStrength, dilated, localMax,cv::CMP_EQ); 

因此,localMax矩阵仅在局部最大值位置为真(即非零)。然后我们在getCornerMap方法中使用它来抑制所有非最大特征(使用cv::bitwise函数)。

还有更多...

可以对原始的 Harris 角算法进行额外的改进。本节描述了 OpenCV 中找到的另一个角检测器,它扩展了 Harris 检测器,使其角在图像中分布得更均匀。正如我们将看到的,这个算子实现了一个通用接口,定义了所有特征检测算子的行为。此接口允许在同一个应用程序中轻松测试不同的兴趣点检测器。

轨迹良好的特征

随着浮点处理器的出现,为了避免特征值分解而引入的数学简化变得可以忽略不计,因此,基于显式计算的特征值进行 Harris 角检测成为可能。原则上,这种修改不应显著影响检测结果,但它避免了使用任意的k参数。请注意,存在两个函数允许您显式获取 Harris 协方差矩阵的特征值(和特征向量);这些是cv::cornerEigenValsAndVecscv::cornerMinEigenVal

第二次修改解决了特征点聚类的问题。事实上,尽管引入了局部最大值条件,但兴趣点往往在图像中分布不均匀,在高度纹理的位置显示出集中。解决这个问题的方法是在两个兴趣点之间施加最小距离。这可以通过以下算法实现。从具有最强 Harris 分数的点(即具有最大的最小特征值)开始,只有当它们位于已接受点的至少给定距离处时,才接受兴趣点。这个解决方案在 OpenCV 中通过good-features-to-trackGFTT)算子实现,因此得名,因为检测到的特征可以用作视觉跟踪应用中的良好起始集。此算子如下部署:

    // Compute good features to track 
    std::vector<cv::KeyPoint> keypoints; 
    // GFTT detector 
    cv::Ptr<cv::GFTTDetector> ptrGFTT =  
        cv::GFTTDetector::create( 
                        500,   // maximum number of keypoints
                        0.01,  // quality level 
                        10);   //minimum allowed distance between points 
    // detect the GFTT 
    ptrGFTT->detect(image,keypoints); 

第一步是使用适当的静态函数(在此处,cv::GFTTDetector::create)和初始化参数来创建特征检测器。除了质量级别阈值值和兴趣点之间可容忍的最小距离之外,该函数还使用可以返回的最大点数(这是可能的,因为点按强度顺序接受)。调用此函数返回一个指向检测器实例的 OpenCV 智能指针。一旦构建了此对象,就可以调用其detect方法。请注意,通用接口还包括cv::Keypoint类的定义,该类封装了每个检测到的特征点的属性。对于 Harris 角点,仅与关键点的位置及其响应强度相关。本章的“检测尺度不变特征”食谱将讨论可以与关键点关联的其他属性。

之前的代码产生以下结果:

Good features to track

这种方法增加了检测的复杂性,因为它需要按 Harris 分数对兴趣点进行排序,但它也明显改善了点在图像中的分布。请注意,此函数还包括一个可选标志,请求使用经典角点分数定义(使用协方差矩阵的行列式和迹)来检测 Harris 角点。

OpenCV 的特征检测器通用接口定义了一个名为cv::Feature2D的抽象类,该类基本上强制执行了以下签名中的detect操作:

    void detect( cv::InputArray image,  
                 std::vector<KeyPoint>& keypoints,  
                 cv::InputArray mask ); 

    void detect( cv::InputArrayOfArrays images,                 
                 std::vector<std::vector<KeyPoint> >& keypoints,          
                 cv::InputArrayOfArrays masks ); 

第二种方法允许在图像向量中检测兴趣点。该类还包括其他方法,例如计算特征描述符的方法(将在下一章讨论)以及可以读取和写入检测到的点的文件的方法。

参见

  • 描述 Harris 算子的经典文章由 C. Harris 和 M.J. Stephens 撰写,《A combined corner and edge detector》,《Alvey Vision Conference》,第 147-152 页,1988 年

  • J. Shi 和 C. Tomasi 的文章《Good features to track》,《Int. Conference on Computer Vision and Pattern Recognition》,第 593-600 页,1994 年,介绍了这种特殊特征

  • K. Mikolajczyk 和 C. Schmid 的文章《Scale and Affine invariant interest point detectors》,《International Journal of Computer Vision》,第 60 卷,第 1 期,第 63-86 页,2004 年,提出了一种多尺度且仿射不变性的 Harris 算子

快速检测特征

Harris 算子为角点(或更一般地说,兴趣点)提出了基于两个垂直方向上强度变化率的正式数学定义。尽管这是一个合理的定义,但它需要计算图像导数,这是一个成本高昂的操作,尤其是考虑到兴趣点检测通常只是更复杂算法的第一步。

在这个菜谱中,我们介绍另一个特征点操作符,称为FAST加速段测试中的特征)。这个操作符是专门设计用来允许快速检测图像中的兴趣点,接受或拒绝关键点的决定仅基于少数像素比较。

如何做...

如前一个菜谱的最后部分所看到的,在图像中检测角点,使用 OpenCV 的通用特征点检测接口使得部署任何特征点检测器变得容易。本菜谱中介绍的检测器是 FAST 检测器。正如其名所示,它被设计用来快速检测图像中的兴趣点:

    // vector of keypoints 
    std::vector<cv::KeyPoint> keypoints; 
    // FAST detector with a threshold of 40 
    cv::Ptr<cv::FastFeatureDetector> ptrFAST =
            cv::FastFeatureDetector::create(40); 
    // detect the keypoints 
    ptrFAST->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 

通过指定选择的绘制标志,关键点被绘制在输入图像上,从而产生以下输出结果:

如何做...

一个有趣的选择是为关键点颜色指定一个负值。在这种情况下,每个绘制的圆将选择不同的随机颜色。

它是如何工作的...

就像哈里斯点检测器的情况一样,FAST 特征算法源自构成角点的定义。这次,这个定义基于假设特征点周围的图像强度。是否接受关键点的决定是通过检查以候选点为中心的像素圆来做出的。如果在圆周上找到一个连续点弧,其长度大于圆周长度的四分之三,并且所有像素与中心点的强度显著不同(要么都是较暗的,要么都是较亮的),则宣布存在一个关键点。

这是一个可以快速计算的简单测试。此外,在其原始公式中,该算法使用了一个额外的技巧来进一步加快处理速度。确实,如果我们首先测试圆周上相隔 90 度的四个点(例如,顶部、底部、右侧和左侧点),可以很容易地证明,为了满足之前表达的条件,至少有三个这些点都必须与中心像素一样亮或一样暗。

如果不是这种情况,点可以立即被拒绝,无需检查圆周上的其他点。这是一个非常有效的测试,因为在实践中,大多数图像点都会通过这个简单的 4 比较测试被拒绝。

从原则上讲,检查像素的圆的半径可以是该方法的一个参数。然而,在实践中,半径为3既能提供良好的结果,又具有高效率。因此,圆周上需要考虑的像素有16个,如下所示:

它是如何工作的...

用于预测试的四个点是像素15913,所需的连续暗或亮点的数量是9。这种特定设置通常被称为 FAST-9 角点检测器,这是 OpenCV 默认使用的。实际上,您可以在构建检测器实例时指定要使用的 FAST 检测器类型;还有一个setType方法。选项有cv::FastFeatureDetector::TYPE_5_8cv::FastFeatureDetector::TYPE_7_12cv::FastFeatureDetector::TYPE_9_16

要被视为明显更暗或更亮,点的强度必须至少与中心像素的强度差异给定量;此值对应于创建检测器实例时指定的阈值参数。此阈值越大,检测到的角点就越少。

对于 Harris 特征,通常最好对已找到的角点执行非极大值抑制。因此,需要定义一个角点强度度量。可以考虑几种替代度量,而保留的是以下度量。角点的强度由中心像素与识别出的连续弧上的像素之间的绝对差之和给出。您可以从cv::KeyPoint实例的response属性中读取角点强度。

此算法导致非常快的兴趣点检测,因此在速度是关注点时,这是首选的特征。例如,在实时视觉跟踪或对象识别应用中,必须跟踪或匹配实时视频流中的多个点时,情况就是这样。

还有更多...

可以使用不同的策略来使特征检测更适合您的应用程序。

例如,有时可能希望动态调整特征检测以获得预定义数量的兴趣点。实现此目标的一个简单策略是使用宽容的检测阈值,以便获得大量兴趣点。然后,您只需从集合中提取第 n 个最强的点。一个标准的 C++函数允许您完成此操作:

    if (numberOfPoints < keypoints.size()) 
      std::nth_element(keypoints.begin(),
                       keypoints.begin() + numberOfPoints,
                       keypoints.end(),
                       [](cv::KeyPoint& a, cv::KeyPoint& b) { 
                       return a.response > b.response; }); 

在这里,keypoints是您检测到的兴趣点的std::vector,而numberOfPoints是期望的兴趣点数量。此函数中的最后一个参数是用于提取最佳兴趣点的 lambda 比较器。请注意,如果检测到的兴趣点数量太低(即低于寻求的数量),这意味着您应该使用较低的阈值进行检测。然而,使用非常宽容的阈值通常会增加计算负载;因此,必须确定一个权衡值。

在检测特征时,经常出现的一个问题是图像中兴趣点的分布不均匀。确实,关键点往往聚集在图像的高纹理区域。例如,以下是在我们的教堂图像上检测100个兴趣点得到的结果:

还有更多...

如您所见,大多数特征点位于建筑的上下部分。为了在图像中获得更好的兴趣点分布,常用的策略是将该图像划分为子图像的网格,并对每个子图像进行独立的检测。以下代码执行了这种网格自适应检测:

    // The final vector of keypoints 
    keypoints.clear(); 
    // detect on each grid 
    for (int i = 0; i < vstep; i++) 
      for (int j = 0; j < hstep; j++) { 
        // create ROI over current grid 
        imageROI = image(cv::Rect(j*hsize, i*vsize, hsize, vsize)); 
        // detect the keypoints in grid 
        gridpoints.clear(); 
        ptrFAST->detect(imageROI, gridpoints); 

        // get the strongest FAST features 
        auto itEnd(gridpoints.end()); 
        if (gridpoints.size() > subtotal) {  
          // select the strongest features 
          std::nth_element(gridpoints.begin(),
                           gridpoints.begin() + subtotal, 
                           gridpoints.end(),
                           [](cv::KeyPoint& a,
                           cv::KeyPoint& b) { 
            return a.response > b.response; }); 
          itEnd = gridpoints.begin() + subtotal; 
        } 

        // add them to the global keypoint vector 
        for (auto it = gridpoints.begin(); it != itEnd; ++it) { 
          // convert to image coordinates 
          it->pt += cv::Point2f(j*hsize, i*vsize);  
          keypoints.push_back(*it); 
      } 
    } 

这里的关键思想是使用图像区域(ROI)来在每个子图像中进行关键点检测。结果检测显示出更均匀的关键点分布:

还有更多...

参见

  • OpenCV2 包含了专门的适应特征检测包装类;例如,cv::DynamicAdaptedFeatureDetectorGridAdaptedFeatureDetector

  • E. Rosten 和 T. Drummond 的文章,高速角点检测的机器学习国际欧洲计算机视觉会议,第 430-443 页,2006 年,详细描述了 FAST 特征算法及其变体。

检测尺度不变特征

特征检测的视域不变性在本章引言中被提出作为一个重要概念。虽然方向不变性,即即使图像旋转也能检测到相同点的能力,已经被迄今为止提出的简单特征点检测器相对较好地处理,但尺度变化的不变性更难实现。为了解决这个问题,计算机视觉中引入了尺度不变特征的概念。这里的想法不仅是在任何尺度下都能一致地检测关键点,而且每个检测到的特征点都与一个尺度因子相关联。理想情况下,对于在两个不同图像的两个不同尺度上具有相同对象点的特征点,两个计算出的尺度因子的比率应该对应于它们各自尺度的比率。近年来,已经提出了几种尺度不变特征,本食谱介绍其中之一,即SURF特征。SURF 代表加速鲁棒特征,正如我们将看到的,它们不仅是尺度不变特征,而且计算效率高。

如何操作...

SURF 特征检测器是opencv_contrib仓库的一部分。要使用它,你必须已经构建了 OpenCV 库以及这些额外的模块,如第一章中所述,玩转图像。特别是,我们对此处的cv::xfeatures2d模块感兴趣,它为我们提供了访问cv::xfeatures2d::SurfFeatureDetector类的权限。至于其他检测器,首先创建检测器的一个实例,然后调用其detect方法来检测兴趣点:

    // Construct the SURF feature detector object 
    cv::Ptr<cv::xfeatures2d::SurfFeatureDetector> ptrSURF =   
                cv::xfeatures2d::SurfFeatureDetector::create(2000.0); 
    // detect the keypoints 
    ptrSURF->detect(image, keypoints); 

要绘制这些特征,我们再次使用cv::drawKeypoints OpenCV 函数,但现在使用cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS标志,这样我们就可以可视化相关的尺度因子:

    // 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); 

检测到特征后的图像如下所示:

如何做...

在这里,使用cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS标志得到的特征点圆的大小与每个特征的计算尺度成正比。SURF 算法还与每个特征关联一个方向,使它们对旋转不变。这个方向通过每个绘制圆内的径向线表示。

如果我们用不同尺度的另一张相同物体的照片,特征检测的结果如下:

如何做...

通过仔细观察两张图像上检测到的关键点,可以看出对应圆的大小变化通常与尺度变化成比例。例如,考虑教堂右侧的两个窗户;在两张图像中,都检测到了该位置的一个 SURF 特征,并且两个对应的不同大小的圆包含了相同的视觉元素。当然,并非所有特征都如此,但正如我们将在下一章中发现的,重复率足够高,可以允许两张图像之间进行良好的匹配。

它是如何工作的...

在第六章,过滤图像中,我们了解到可以使用高斯滤波器估计图像的导数。这些滤波器使用一个σ参数,它定义了核的孔径(大小)。正如我们所见,这个σ参数对应于构建滤波器所使用的高斯函数的方差,并隐式地定义了评估导数的尺度。确实,具有较大σ值的滤波器会平滑掉图像的更细的细节。这就是为什么我们可以说它在更粗的尺度上操作。

现在,如果我们使用不同尺度的高斯滤波器计算给定图像点的拉普拉斯算子,那么会得到不同的值。观察不同尺度因子滤波器响应的变化,我们得到一条曲线,最终在给定的σ值处达到最大值。如果我们从两个不同尺度拍摄的两个相同物体的图像中提取这个最大值,这两个σ最大值之比应该对应于拍摄图像的尺度之比。这个重要的观察结果是尺度不变特征提取过程的核心。也就是说,尺度不变特征应该在空间空间(在图像中)和尺度空间(通过在不同尺度上应用导数滤波器获得)中的局部最大值中被检测到。

SURF 通过以下步骤实现这个想法。首先,为了检测特征,计算每个像素处的 Hessian 矩阵。这个矩阵衡量函数的局部曲率,定义为如下:

如何工作...

这个矩阵的行列式给出了这个曲率的强度。因此,定义角点为具有高局部曲率(即在一个以上方向上的高变化)的图像点。由于它由二阶导数组成,这个矩阵可以使用不同尺度的高斯核的拉普拉斯算子来计算,即对于不同的σ值。因此,当这个 Hessian 的行列式在空间和尺度空间中都达到局部最大值时(即需要进行3x3x3非最大值抑制),就声明了一个尺度不变特征。请注意,为了被视为一个有效点,这个行列式必须具有由cv::xfeatures2d::SurfFeatureDetector类的create方法的第一参数指定的最小值。

然而,在不同尺度上计算所有这些导数是计算上代价高昂的。SURF 算法的目的是使这个过程尽可能高效。这是通过使用仅涉及少量整数加法的近似高斯核来实现的。这些核具有以下结构:

如何工作...

左侧的核用于估计混合的二阶导数,而右侧的核用于估计垂直方向上的二阶导数。这个二阶核的旋转版本用于估计水平方向上的二阶导数。最小的核大小为9x9像素,对应于σ≈1.2。为了获得尺度空间表示,依次应用不同大小的核。通过cv::xfeatures2d::SurfFeatureDetector::create方法的附加参数可以指定应用的滤波器的确切数量。默认情况下,使用12种不同大小的核(大小从99x99不等)。请注意,正如在第四章中解释的那样,使用直方图计数像素,使用积分图像可以保证每个滤波器每个叶子的内部和可以通过仅使用三个与滤波器大小无关的加法来计算。

一旦识别出局部极大值,通过在尺度和图像空间中进行插值,就可以获得每个检测到的兴趣点的精确位置。结果是具有亚像素精度的特征点集,并且与一个尺度值相关联。

还有更多...

SURF 算法被开发为另一个著名的尺度不变特征检测器 SIFT(尺度不变特征变换)的高效变体。

SIFT 特征检测算法

SIFT 也在图像和尺度空间中检测特征作为局部极大值,但使用拉普拉斯滤波器响应而不是 Hessian 行列式。这个拉普拉斯在不同的尺度(即σ的增大值)上使用高斯差分滤波器计算,如第六章中所述,图像滤波。为了提高效率,每次σ的值加倍时,图像的大小就减少两倍。每个金字塔层对应一个八度,每个尺度是一个。通常每个八度有三个层。

下图展示了两个八度的金字塔,其中第一个八度的四个高斯滤波图像产生了三个 DoG 层:

SIFT 特征检测算法

SIFT 特征的检测过程与 SURF 非常相似:

    // Construct the SIFT feature detector object 
    cv::Ptr<cv::xfeatures2d::SiftFeatureDetector> ptrSIFT =    
                         cv::xfeatures2d::SiftFeatureDetector::create(); 
    // detect the keypoints 
    ptrSIFT->detect(image, keypoints); 

在这里,我们使用所有默认参数来构建检测器,但你也可以指定所需的 SIFT 点数(保留最强的点),每八度带的层数,以及σ的初始值。如图所示,使用三个八度带进行检测(默认值)会导致相当广泛的尺度范围:

SIFT 特征检测算法

由于特征点的计算基于浮点核,SIFT 通常被认为在空间和尺度方面在特征定位方面更准确。同样,它也更耗费计算资源,尽管这种相对效率取决于每个特定的实现。

注意,在这个配方中,我们使用了cv::xfeatures2d::SurfFeatureDetectorcv::xfeatures2d::SiftFeatureDetector类来明确表示我们正在将它们用作兴趣点检测器。同样,我们也可以使用cv::xfeatures2d::SURFcv::xfeatures2d::SIFT类(它们是类型等效的)。实际上,SURF 和 SIFT 算子涵盖了兴趣点的检测和描述。兴趣点描述是下一章的主题。

最后一点,重要的是要提到 SURF 和 SIFT 算子已被专利,因此它们在商业应用中的使用可能受到许可协议的约束。这种限制是这些特征检测器出现在cv::xfeatures2d包中的原因之一。

参见

  • 第六章中的计算图像的拉普拉斯算子配方,过滤图像,提供了关于高斯拉普拉斯算子和高斯差分的更多细节

  • 第四章中的使用积分图像计数像素配方,使用直方图计数像素,解释了积分图像如何加速像素和的计算

  • 第九章中的描述和匹配局部强度模式配方,描述和匹配兴趣点,解释了这些尺度不变特征如何被描述以实现鲁棒的图像匹配

  • H. Bay、A. Ess、T. Tuytelaars 和 L. Van Gool 在计算机视觉与图像理解,第 110 卷,第 3 期,2008 年,第 346-359 页上发表的SURF:加速鲁棒特征文章,描述了 SURF 特征算法

  • D. Lowe 在国际计算机视觉杂志,第 60 卷,第 2 期,2004 年,第 91-110 页上发表的从尺度不变特征中提取独特图像特征开创性工作,描述了 SIFT 算法

在多个尺度上检测 FAST 特征

FAST 被引入作为一种快速检测图像中关键点的方法。在 SURF 和 SIFT 中,重点是设计尺度不变的特征。最近,提出了新的兴趣点检测器,旨在实现快速检测和对尺度变化的鲁棒性。本食谱介绍了二值鲁棒不变可伸缩关键点BRISK)检测器。它基于我们在本章先前的食谱中描述的 FAST 特征检测器。另一种称为ORB方向性 FAST 和旋转 BRIEF)的检测器也将在本食谱的末尾讨论。这两个特征点检测器构成了在需要快速且可靠图像匹配时的优秀解决方案。当与它们相关的二进制描述符一起使用时,它们尤其有效,这一点将在第九章 描述和匹配兴趣点 中讨论。

如何操作...

按照我们在先前的食谱中所做的那样,我们首先创建检测器的一个实例,然后在一个图像上调用detect方法:

    // Construct the BRISK feature detector object 
    cv::Ptr<cv::BRISK> ptrBRISK = cv::BRISK::create(); 
    // detect the keypoints 
    ptrBRISK->detect(image, keypoints); 

图像结果显示了 BRISK 在多个尺度上检测到的关键点

如何操作...

它是如何工作的...

BRISK 不仅是一个特征点检测器;该方法还包括一个描述每个检测到的关键点邻域的步骤。这一第二个方面将是下一章的主题。在这里,我们描述了如何使用 BRISK 在多个尺度上快速检测关键点。

为了检测不同尺度的兴趣点,该方法首先通过两个下采样过程构建一个图像金字塔。第一个过程从原始图像大小开始,并在每一层(或八度)将其缩小一半。其次,通过将原始图像下采样一个因子1.5来创建中间层,然后从这个缩小后的图像中,通过连续的半采样生成额外的层。

它是如何工作的...

然后将 FAST 特征检测器应用于金字塔中的所有图像。关键点提取基于与 SIFT 使用的类似的标准。首先,一个可接受的兴趣点必须是一个局部最大值,当将其强度与其八个空间邻居之一比较时。如果是这种情况,该点随后将与上下层相邻点的分数进行比较;如果其分数在尺度上更高,则它被接受为兴趣点。BRISK 的一个关键方面在于金字塔的不同层具有不同的分辨率。该方法需要在尺度和空间上进行插值,以精确地定位每个关键点。这种插值基于 FAST 关键点分数。在空间上,插值在3x3邻域内进行。在尺度上,它通过沿尺度轴通过当前点及其上下层中的两个相邻局部关键点拟合一维抛物线来计算;这种尺度上的关键点定位在先前的图中进行了说明。因此,即使 FAST 关键点检测是在离散图像尺度上进行的,与每个关键点相关联的检测尺度也是连续值。

cv::BRISK 检测器有两个主要参数。第一个参数是用于接受 FAST 关键点的阈值值,第二个参数是将在图像金字塔中生成的八分音数量;在我们的例子中,我们使用了5个八分音,这解释了检测到的关键点中存在大量尺度。

还有更多...

BRISK 并不是 OpenCV 中提出的唯一的多尺度快速检测器。另一个是 ORB 特征检测器,它也可以执行高效的关键点检测。

ORB 特征检测算法

ORB 代表定向 FAST 和旋转 BRIEF。这个缩写的第一部分指的是关键点检测部分,而第二部分指的是 ORB 提出的描述符。在这里,我们关注检测方法;描述符将在下一章中介绍。

与 BRISK 一样,ORB 首先创建一个图像金字塔。这个金字塔由多个层组成,每一层都是前一层通过一定比例因子(通常是8个尺度,1.2的比例因子减少;这些是在创建cv::ORB检测器时的默认参数值)的下采样版本。然后接受最强的N个关键点,其中关键点分数由本章第一道菜中定义的 Harris 角点度测量,即(该方法的作者发现 Harris 分数比通常的 FAST 角点强度更可靠的度量)。

ORB 检测器的原始特点在于每个检测到的兴趣点都与一个方向相关联。正如我们将在下一章中看到的,这些信息将有助于对齐在不同图像中检测到的关键点的描述符。在第七章的“计算组件的形状描述符”配方中,“提取线条、轮廓和组件”,我们介绍了图像矩的概念,特别是我们展示了如何从组件的前三个矩计算其质心。ORB 建议使用围绕关键点的圆形邻域的质心的方向。由于,根据定义,FAST 关键点总是有一个偏心的质心,因此连接中心点和质心的线的角度总是定义良好的。

ORB 特征检测如下:

    // Construct the ORB feature detector object 
    cv::Ptr<cv::ORB> ptrORB =  
      cv::ORB::create(75,  // total number of keypoints 
                      1.2, // scale factor between layers 
                      8);  // number of layers in pyramid 
    // detect the keypoints 
    ptrORB->detect(image, keypoints); 

此调用产生以下结果:

ORB 特征检测算法

如所示,由于关键点在每个金字塔层上独立检测,检测器倾向于在不同尺度上重复检测相同的特征点。

参见

  • 第九章的“匹配二进制描述符”配方中,“描述和匹配兴趣点”,解释了如何使用简单的二进制描述符进行高效鲁棒的匹配这些特征。

  • 由 S. Leutenegger、M. Chli 和 R. Y. Siegwart 在 2011 年 IEEE 国际计算机视觉会议上的文章“BRISK:二进制鲁棒可伸缩关键点”描述了 BRISK 特征算法。

  • 由 E. Rublee、V. Rabaud、K. Konolige 和 G. Bradski 在 2011 年 IEEE 国际计算机视觉会议上的文章“ORB:SIFT 或 SURF 的有效替代方案”,第 2564-2571 页,描述了 ORB 特征算法。

第九章:描述和匹配兴趣点

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

  • 匹配局部模板

  • 描述和匹配局部强度模式

  • 使用二进制描述符匹配关键点

简介

在上一章中,我们学习了如何通过检测图像中的特殊点来进行局部图像分析。这些关键点是选择得足够独特,以至于如果一个关键点在某个对象的图像上被检测到,那么在描述同一对象的其它图像中,我们期望同样点也会被检测到。我们还描述了一些更复杂的兴趣点检测器,这些检测器可以为关键点分配一个代表性的尺度因子和/或一个方向。正如我们将在本章中看到的,这些附加信息可以用来根据视点变化对场景表示进行归一化。

为了基于兴趣点进行图像分析,我们现在需要构建丰富的表示,以独特地描述每个关键点。本章将探讨已经提出的从兴趣点中提取描述符的不同方法。这些描述符通常是描述关键点和其邻域的 1D 或 2D 的二进制、整数或浮点数向量。一个好的描述符应该足够独特,能够唯一地表示图像中的每个关键点;它应该足够鲁棒,即使在可能的光照变化或视点变化的情况下,也能以相似的方式表示相同的点。理想情况下,它还应该紧凑,以减少内存负载并提高计算效率。

使用关键点完成的最常见操作之一是图像匹配。这项任务可以用来关联同一场景的两个图像,或者检测图像中目标对象的出现。在这里,我们将研究一些基本的匹配策略,这些策略将在下一章中进一步讨论。

匹配局部模板

特征点匹配是通过将一个图像中的点与另一个图像(或图像集中的点)进行对应的过程。当图像点对应于现实世界中相同场景元素时,图像点应该匹配。

单个像素当然不足以对两个关键点的相似性做出判断。这就是为什么在匹配过程中必须考虑每个关键点周围的图像块。如果两个块对应于同一场景元素,那么可以预期它们的像素将具有相似值。本食谱中提出的解决方案是直接逐像素比较像素块。这可能是特征点匹配中最简单的方法,但正如我们将看到的,它并不是最可靠的。尽管如此,在几种情况下,它仍然可以给出良好的结果。

如何做到这一点...

通常,补丁被定义为以关键点位置为中心的奇数大小的正方形。然后可以通过比较补丁内相应的像素强度值来测量两个正方形补丁之间的相似性。简单的平方差之和SSD)是一个流行的解决方案。特征匹配策略的工作方式如下。首先,在每个图像中检测关键点。在这里,我们使用 FAST 检测器:

    // Define feature detector 
    cv::Ptr<cv::FeatureDetector> ptrDetector;   // generic detector 
    ptrDetector= // we select the FAST detector 
                cv::FastFeatureDetector::create(80);    

    // Keypoint detection 
    ptrDetector->detect(image1,keypoints1); 
    ptrDetector->detect(image2,keypoints2); 

注意我们使用了通用的 cv::Ptr<cv::FeatureDetector> 指针类型,它可以指向任何特征检测器。然后,只需通过更改调用 detect 函数时使用的检测器,就可以在不同的兴趣点检测器上测试此代码。

第二步是定义一个矩形,例如大小为 11x11 的矩形,该矩形将用于定义每个关键点周围的区域:

    // Define a square neighborhood 
    const int nsize(11);                       // size of the neighborhood 
    cv::Rect neighborhood(0, 0, nsize, nsize); // 11x11 
    cv::Mat patch1; 
    cv::Mat patch2; 

一个图像中的关键点与另一个图像中的所有关键点进行比较。对于第一图像中的每个关键点,确定第二图像中最相似的补丁。这个过程通过使用两个嵌套循环来实现,如下面的代码所示:

    // For all keypoints in first image 
    // find best match in second image 
    cv::Mat result; 
    std::vector<cv::DMatch> matches; 

    // for all keypoints in image 1 
    for (int i=0; i<keypoints1.size(); i++) { 

      // define image patch 
      neighborhood.x = keypoints1[i].pt.x-nsize/2; 
      neighborhood.y = keypoints1[i].pt.y-nsize/2; 

      // if neighborhood of points outside image, 
      // then continue with next point 
      if (neighborhood.x<0 || neighborhood.y<0 ||   
          neighborhood.x+nsize >= image1.cols || 
          neighborhood.y+nsize >= image1.rows) 
      continue; 

      // patch in image 1 
      patch1 = image1(neighborhood); 

      // to contain best correlation value; 
      cv::DMatch bestMatch; 

      // for all keypoints in image 2 
      for (int j=0; j<keypoints2.size(); j++) { 

        // define image patch 
        neighborhood.x = keypoints2[j].pt.x-nsize/2; 
        neighborhood.y = keypoints2[j].pt.y-nsize/2; 

        // if neighborhood of points outside image, 
        // then continue with next point 
        if (neighborhood.x<0 || neighborhood.y<0 ||   
            neighborhood.x + nsize >= image2.cols ||   
            neighborhood.y + nsize >= image2.rows) 
        continue; 

       // patch in image 2 
       patch2 = image2(neighborhood); 

       // match the two patches 
       cv::matchTemplate(patch1,patch2,result, cv::TM_SQDIFF); 

       // check if it is a best match 
       if (result.at<float>(0,0) < bestMatch.distance) { 

         bestMatch.distance= result.at<float>(0,0); 
         bestMatch.queryIdx= i; 
         bestMatch.trainIdx= j; 
       } 
     } 

     // add the best match 
     matches.push_back(bestMatch); 
   } 

注意 cv::matchTemplate 函数的使用,我们将在下一节中描述它,该函数计算补丁相似度得分。当识别出潜在的匹配时,此匹配通过使用 cv::DMatch 对象来表示。这个实用类存储了两个匹配 关键点 的索引以及它们的相似度得分。

两个图像补丁越相似,这些补丁对应于同一场景点的概率就越高。这就是为什么按相似度得分对结果匹配点进行排序是一个好主意:

    // extract the 25 best matches 
    std::nth_element(matches.begin(),   
                     matches.begin() + 25,matches.end()); 
    matches.erase(matches.begin() + 25,matches.end()); 

然后,您可以简单地保留通过给定相似度阈值的匹配。在这里,我们选择只保留 N 个最佳匹配点(我们使用 N=25 以便于可视化匹配结果)。

有趣的是,OpenCV 中有一个函数可以通过连接两个图像并使用线条连接每个对应点来显示匹配结果。该函数的使用方法如下:

    //Draw the matching results 
    cv::Mat matchImage; 
    cv::drawMatches(image1,keypoints1,         // first image 
                    image2,keypoints2,         // second image 
                    matches,                   // vector of matches 
                    cv::Scalar(255,255,255),   // color of lines 
                    cv::Scalar(255,255,255));  // color of points 

这里是匹配结果:

如何操作...

它是如何工作的...

获得的结果当然不是完美的,但通过视觉检查匹配的图像点显示有许多成功的匹配。还可以观察到教堂两座塔的对称性造成了一些混淆。此外,由于我们试图将左图像中的所有点与右图像中的点匹配,我们得到了右图像中的一个点与左图像中的多个点匹配的情况。这是一种不对称的匹配情况,可以通过例如,只保留右图像中每个点的最佳得分匹配来纠正。

为了比较每张图像的图像块,我们在这里使用了一个简单的标准,即使用 cv::TM_SQDIFF 标志指定的像素级平方差之和。如果我们比较图像 I1 中的点 (x,y) 与图像 I2 中的潜在匹配点 (x',y'),那么相似度度量如下:

如何工作...

在这里,(i,j) 点的和提供了覆盖以每个点为中心的正方形模板的偏移量。由于相似块中相邻像素之间的差异应该很小,因此最佳匹配的块应该是具有最小和的块。这正是匹配函数主循环中所做的;也就是说,对于一张图像中的每个关键点,我们识别另一张图像中给出平方差异和最低的关键点。我们还可以拒绝那些和超过某个阈值值的匹配。在我们的情况下,我们只是按相似度从高到低排序它们。

在我们的例子中,匹配是通过大小为 11x11 的正方形块完成的。更大的邻域会产生更独特的块,但它也使它们对局部场景变化更敏感。

只要两张图像从相似的角度和相似的光照显示场景,比较两个图像窗口的简单平方差之和就会相对有效。确实,简单的光照变化会增加或减少一个块的所有像素强度,从而导致大的平方差。为了使匹配对光照变化更不变,可以使用其他公式来测量两个图像窗口之间的相似度。OpenCV 提供了这些公式中的许多。一个非常有用的公式是归一化平方差之和(cv::TM_SQDIFF_NORMED 标志):

如何工作...

其他相似度度量基于相关性的概念,这在信号处理理论中定义为以下(使用 cv::TM_CCORR 标志):

如何工作...

当两个块相似时,此值将达到最大。

识别的匹配存储在一个 cv::DMatch 实例的向量中。本质上,cv::DMatch 数据结构包含一个指向第一个关键点向量中元素的第一个索引,以及一个指向第二个关键点向量中匹配特征的第二个索引。它还包含一个代表两个匹配描述符之间距离的实数值。这个距离值用于比较两个 cv::DMatch 实例时 operator< 的定义。

在上一节中绘制匹配时,我们希望限制线条数量以使结果更易于阅读。因此,我们只显示了具有最低距离的 25 个匹配。为此,我们使用了 std::nth_element 函数,该函数将第 N 个元素放置在第 N 个位置,所有较小的元素都放置在此元素之前。一旦完成,向量就简单地清除了其剩余的元素。

更多内容...

cv::matchTemplate函数是我们特征匹配方法的核心。我们在这里以非常具体的方式使用了它,即比较两个图像块。然而,这个函数被设计成可以以更通用的方式使用。

模板匹配

图像分析中的一个常见任务是检测图像中特定模式或对象的发生。这可以通过定义一个对象的小图像、模板,并在给定的图像中搜索相似的发生来实现。通常,搜索被限制在感兴趣的区域,我们认为对象可能位于该区域。然后,模板在这个区域上滑动,并在每个像素位置计算相似度度量。这是cv::matchTemplate函数执行的操作。输入是一个小尺寸的模板图像和搜索图像。

结果是一个对应于每个像素位置相似度得分的浮点值cv::Mat函数。如果模板的大小为MxN,图像的大小为WxH,则结果矩阵的大小将为(W-M+1)x(H-N+1)。通常,你将关注最高相似度的位置;因此,典型的模板匹配代码将如下所示(假设目标变量是我们的模板):

    // define search region 
    cv::Mat roi(image2, // here top half of the image 
    cv::Rect(0,0,image2.cols,image2.rows/2)); 

    // perform template matching 
    cv::matchTemplate(roi,            // search region 
                      target,         // template 
                      result,         // result 
                      cv::TM_SQDIFF); // similarity measure 

    // find most similar location 
    double minVal, maxVal; 
    cv::Point minPt, maxPt; 
    cv::minMaxLoc(result, &minVal, &maxVal, &minPt, &maxPt); 

    // draw rectangle at most similar location 
    // at minPt in this case 
    cv::rectangle(roi, cv::Rect(minPt.x, minPt.y,  
                                target.cols, target.rows), 255); 

记住这是一个代价高昂的操作,所以你应该限制搜索区域,并使用只有几个像素大小的模板。

参见

  • 下一个方法,描述和匹配局部强度模式,描述了cv::BFMatcher类,该类实现了在本方法中使用的匹配策略。

描述和匹配局部强度模式

在第八章 "检测兴趣点"中讨论的 SURF 和 SIFT 关键点检测算法,为每个检测到的特征定义了一个位置、一个方向和一个尺度。尺度因子信息对于定义每个特征点周围分析窗口的大小是有用的。因此,定义的邻域将包含无论对象所属特征的缩放级别如何的相同视觉信息。这个方法将向你展示如何使用特征描述符来描述兴趣点的邻域。在图像分析中,这个邻域包含的视觉信息可以用来表征每个特征点,以便使每个点与其他点区分开来。特征描述符通常是描述特征点的 N 维向量,以对光照变化和小型透视变形不变的方式描述。通常,描述符可以使用简单的距离度量进行比较,例如欧几里得距离。因此,它们构成了一个强大的工具,可以用于对象匹配应用。

如何做...

cv::Feature2D抽象类定义了用于计算一系列关键点描述符的成员函数。由于大多数基于特征的方法都包括检测器和描述符组件,相关的类包括一个detect函数(用于检测兴趣点)和一个compute函数(用于计算它们的描述符)。这是cv::SURFcv::SIFT类的情况。例如,以下是如何使用一个cv::SURF实例在两张图像中检测和描述特征点的方法:

    // Define keypoints vector 
    std::vector<cv::KeyPoint> keypoints1; 
    std::vector<cv::KeyPoint> keypoints2; 

    // Define feature detector 
    cv::Ptr<cv::Feature2D> ptrFeature2D =     
                          cv::xfeatures2d::SURF::create(2000.0); 

    // Keypoint detection 
    ptrFeature2D->detect(image1,keypoints1); 
    ptrFeature2D->detect(image2,keypoints2); 

    // Extract the descriptor 
    cv::Mat descriptors1; 
    cv::Mat descriptors2; 
    ptrFeature2D->compute(image1,keypoints1,descriptors1); 
    ptrFeature2D->compute(image2,keypoints2,descriptors2); 

对于 SIFT,你只需调用cv::SIFT::create函数。计算兴趣点描述符的结果是一个矩阵(即一个cv::Mat实例),它将包含与关键点向量中元素数量一样多的行。这些行中的每一行都是一个 N 维描述符向量。在 SURF 描述符的情况下,它有一个默认大小为64,而对于 SIFT,默认维度是128。这个向量表征了特征点周围的强度模式。两个特征点越相似,它们的描述符向量应该越接近。请注意,你不必一定使用 SURF(SIFT)描述符与 SURF(SIFT)点一起使用;检测器和描述符可以以任何组合使用。

这些描述符现在将被用来匹配我们的关键点。正如我们在之前的食谱中所做的那样,第一张图像中的每个特征描述符向量都会与第二张图像中的所有特征描述符进行比较。获得最佳分数的配对(即两个描述符向量之间距离最低的配对)将被保留为该特征的最佳匹配。这个过程会重复应用于第一张图像中的所有特征。非常方便的是,这个过程在 OpenCV 的cv::BFMatcher类中得到了实现,因此我们不需要重新实现之前构建的双循环。这个类可以这样使用:

    // Construction of the matcher  
    cv::BFMatcher matcher(cv::NORM_L2); 
    // Match the two image descriptors 
    std::vector<cv::DMatch> matches; 
    matcher.match(descriptors1,descriptors2, matches); 

这个类是cv::DescriptorMatcher类的子类,它定义了不同匹配策略的通用接口。结果是cv::DMatch实例的向量。

使用当前 SURF 的 Hessian 阈值,我们为第一张图像获得了74个关键点,为第二张图像获得了71个。 brute-force 方法将产生74个匹配。使用与之前食谱中的cv::drawMatches类产生以下图像:

如何做...

如所示,这些匹配中的几个正确地将左侧的一个点与其右侧的对应点联系起来。你可能会注意到一些错误;其中一些是由于观察到的建筑具有对称的立面,这使得一些局部匹配变得模糊。对于 SIFT,在相同数量的关键点下,我们得到了以下匹配结果:

如何做...

它是如何工作的...

良好的特征描述符必须对光照和视点的微小变化以及图像噪声的存在保持不变性。因此,它们通常基于局部强度差异。这是 SURF 描述符的情况,它们在关键点周围局部应用以下简单的核:

如何工作...

第一个核简单地测量水平方向上的局部强度差异(指定为dx),第二个核测量垂直方向上的这种差异(指定为dy)。用于提取描述符向量的邻域大小通常定义为特征缩放因子的20倍(即20σ)。然后,这个正方形区域被分割成4x4个更小的正方形子区域。对于每个子区域,在5x5均匀分布的位置(核大小为)计算核响应(dxdy)。所有这些响应如下求和,以从每个子区域提取四个描述符值:

如何工作...

由于有4x4=16个子区域,我们总共有64个描述符值。注意,为了给邻近像素更多的权重,即更接近关键点的值,核响应通过一个以关键点为中心的高斯加权(σ=3.3)。

dxdy响应也用于估计特征的方向。这些值在以σ为间隔均匀分布的半径为的圆形邻域内(以的核大小)计算。对于给定的方向,在一定的角度间隔(π/3)内的响应被求和,给出最长向量的方向被定义为主导方向。

SIFT 是一种更丰富的描述符,它使用图像梯度而不是简单的强度差异。它还将每个关键点周围的正方形邻域分割成4x4子区域(也可以使用8x82x2子区域)。在这些区域内部,构建梯度方向的直方图。方向被离散化为8个桶,每个梯度方向条目通过一个与梯度幅度成比例的值增加。

这由以下图示说明,其中每个星形箭头集代表梯度方向的局部直方图:

如何工作...

将每个子区域的168桶直方图连接起来,然后产生一个128维度的描述符。注意,对于 SURF 来说,梯度值通过一个以关键点为中心的高斯滤波器加权,以使描述符对定义的邻域边缘梯度方向突然变化的敏感性降低。然后,将最终的描述符归一化,以使距离测量更加一致。

使用 SURF 和 SIFT 特征和描述符,可以实现尺度不变匹配。以下是一个示例,展示了两个不同尺度图像的 SURF 匹配结果(这里,显示了50个最佳匹配):

它是如何工作的...

注意到cv::Feature2D类包括一个方便的成员函数,它可以同时检测兴趣点并计算它们的描述符,例如:

    ptrFeature2D->detectAndCompute(image, cv::noArray(),  
                                   keypoints, descriptors); 

还有更多...

任何匹配算法产生的匹配结果总是包含大量不正确的匹配。为了提高匹配集的质量,存在许多策略。这里讨论了其中三种。

交叉检查匹配

验证获得的匹配的一个简单方法是在第二次重复相同的程序,但这次,第二幅图像的每个关键点都与第一幅图像的所有关键点进行比较。只有当我们从两个方向获得相同的配对关键点时,才认为匹配是有效的(也就是说,每个关键点是另一个的最佳匹配)。cv::BFMatcher函数提供了使用此策略的选项。这确实是一个标志;当设置为 true 时,它强制函数执行互匹配交叉检查:

    cv::BFMatcher matcher2(cv::NORM_L2,    // distance measure 
                           true);          // cross-check flag 

改进的匹配结果如下所示(以 SURF 为例):

交叉检查匹配

比率测试

我们已经指出,场景对象中的重复元素由于匹配视觉上相似结构的歧义性,会导致不可靠的结果。在这种情况下发生的情况是,一个关键点会与多个其他关键点很好地匹配。由于选择错误对应关系的概率很高,在这些模糊的情况下拒绝匹配可能更可取。

要使用此策略,我们接下来需要找到每个关键点的最佳两个匹配点。这可以通过使用cv::DescriptorMatcher类的knnMatch方法来完成。由于我们只想找到两个最佳匹配,我们指定k=2

    // find the best two matches of each keypoint 
    std::vector<std::vector<cv::DMatch>> matches; 
    matcher.knnMatch(descriptors1,descriptors2,  
                     matches, 2);  // find the k best matches 

下一步是拒绝所有与第二最佳匹配距离相似的最好匹配。由于knnMatch产生一个std::vector类的std::vector(这个第二个向量的大小为k),我们通过遍历每个关键点匹配并执行比率测试来完成此操作,即计算第二最佳距离与最佳距离的比率(如果两个最佳距离相等,则此比率将为 1)。所有具有高比率的匹配都被判断为模糊,因此被拒绝。以下是我们可以这样做的方法:

    //perform ratio test 
    double ratio= 0.85; 
    std::vector<std::vector<cv::DMatch>>::iterator it; 
    for (it= matches.begin(); it!= matches.end(); ++it) { 

      // first best match/second best match 
      if ((*it)[0].distance/(*it)[1].distance < ratio) { 
        // it is an acceptable match 
        newMatches.push_back((*it)[0]); 
      } 
    } 
    // newMatches is the updated match set 

初始由74对组成的匹配集现在已减少到23对:

比率测试

距离阈值法

一种更简单的策略是拒绝那些其描述符之间的距离过高的匹配。这是通过使用cv::DescriptorMatcher类的radiusMatch方法来完成的:

    // radius match 
    float maxDist= 0.4; 
    std::vector<std::vector<cv::DMatch>> matches2; 
    matcher.radiusMatch(descriptors1, descriptors2, matches2, maxDist); 
                       // maximum acceptable distance 
                       // between the 2 descriptors 

结果再次是一个std::vector实例的std::vector,因为该方法将保留所有距离小于指定阈值的匹配。这意味着一个给定的关键点可能在另一张图像中有多于一个的匹配点。相反,其他关键点将没有任何与之关联的匹配(相应的内部std::vector类的大小将为0)。对于我们的示例,结果是50对匹配集:

距离阈值

显然,你可以将这些策略结合起来以提高你的匹配结果。

参见

  • 在第八章检测兴趣点的检测尺度不变特征配方中,介绍了相关的 SURF 和 SIFT 特征检测器,并提供了更多关于该主题的参考资料

  • 在第十章估计图像中的投影关系的使用随机样本一致性匹配图像配方中,解释了如何使用图像和场景几何来获得质量更好的匹配集

  • 在第十四章从示例中学习的使用支持向量机和方向梯度直方图检测对象和人物配方中,描述了 HOG,这是与 SIFT 类似的另一个描述符

  • 由 E. Vincent 和 R. Laganière 在 2001 年发表的《Machine, Graphics and Vision》杂志上的文章立体对中特征点的匹配:一些匹配策略的比较研究,描述了其他可以用来提高匹配集质量的简单匹配策略

使用二进制描述符匹配关键点

在前面的配方中,我们学习了如何使用从图像强度梯度中提取的丰富描述符来描述关键点。这些描述符是具有64128或有时甚至更长的维度的浮点向量,这使得它们在操作上成本较高。为了减少与这些描述符相关的内存和计算负载,引入了使用由简单的位序列(0 和 1)组成的描述符的想法。这里的挑战是使它们易于计算,同时保持它们对场景和视点的鲁棒性。本配方描述了一些这些二进制描述符。特别是,我们将查看 ORB 和 BRISK 描述符,我们在第八章检测兴趣点中介绍了它们相关的特征点检测器。

如何做到这一点...

由于 OpenCV 检测器和描述符具有通用接口,使用二进制描述符如 ORB 与使用 SURF 和 SIFT 等描述符并无区别。完整的基于特征的图像匹配序列如下:

    // Define keypoint vectors and descriptors 
    std::vector<cv::KeyPoint> keypoints1; 
    std::vector<cv::KeyPoint> keypoints2; 
    cv::Mat descriptors1; 
    cv::Mat descriptors2; 

    // Define feature detector/descriptor 
    // Construct the ORB feature object 
    cv::Ptr<cv::Feature2D> feature = cv::ORB::create(60); 
                           // approx. 60 feature points 

    // Keypoint detection and description 
    // Detect the ORB features 
    feature->detectAndCompute(image1, cv::noArray(),  
                              keypoints1, descriptors1); 
    feature->detectAndCompute(image2, cv::noArray(),  
                              keypoints2, descriptors2); 

    // Construction of the matcher  
    cv::BFMatcher matcher(cv::NORM_HAMMING); // always use hamming norm 
    // for binary descriptors 
    // Match the two image descriptors 
    std::vector<cv::DMatch> matches; 
    matcher.match(descriptors1, descriptors2, matches); 

唯一的区别在于使用了汉明范数(cv::NORM_HAMMING 标志),它通过计算两个二进制描述符中不同位的数量来衡量它们之间的距离。在许多处理器上,此操作通过使用异或操作后跟简单的位计数来高效实现。匹配结果如下:

如何做...

使用另一个流行的二进制特征检测器/描述符:BRISK,也会得到相似的结果。在这种情况下,cv::Feature2D 实例是通过 cv::BRISK::create 调用创建的。正如我们在上一章所学,它的第一个参数是一个阈值,用于控制检测到的点的数量。

它是如何工作的...

ORB 算法在多个尺度上检测方向特征点。基于此结果,ORB 描述符通过使用简单的强度比较来提取每个关键点的表示。实际上,ORB 是基于之前提出的描述符 BRIEF 构建的。随后,它通过简单地选择关键点周围定义的邻域内的随机点对来创建二进制描述符。然后比较两个像素点的强度值,如果第一个点具有更高的强度,则将值 1 分配给相应的描述符位值。否则,分配值 0。在多个随机点对上重复此测试会生成一个由多个位组成的描述符;通常,使用 128512 位(成对测试)。

这就是 ORB 所使用的方案。然后,需要做出的决定是使用哪组点对来构建描述符。实际上,即使点对是随机选择的,一旦它们被选中,就必须执行相同的二进制测试集来构建所有关键点的描述符,以确保结果的一致性。为了使描述符更具独特性,直觉告诉我们,某些选择可能比其他选择更好。此外,由于每个关键点的方向是已知的,当将强度模式分布与此方向归一化时(即,当点坐标相对于此关键点方向给出时),这引入了一些偏差。从这些考虑和实验验证中,ORB 已经确定了一组具有高方差和最小成对相关性的 256 个点对。换句话说,所选的二进制测试是那些在各种关键点上具有相等机会为 01 的测试,并且尽可能相互独立。

BRISK 的描述符非常相似。它也是基于成对强度比较,但有两大不同之处。首先,不是从邻域的31x31点中随机选择点,而是从由同心圆(由60个点组成)的采样模式中选择点,这些同心圆的点是等间距的。其次,这些样本点的强度是一个高斯平滑值,其σ值与中心关键点的距离成比例。从这些点中,BRISK 选择512个点对。

还有更多...

存在着几个其他的二进制描述符,感兴趣的读者应该查阅科学文献以了解更多关于这个主题的信息。由于它也包含在 OpenCV contrib 模块中,我们在这里将描述一个额外的描述符。

FREAK

FREAK代表快速视网膜关键点。这同样是一种二进制描述符,但它没有关联的检测器。它可以应用于任何一组检测到的关键点,例如 SIFT、SURF 或 ORB。

与 BRISK 一样,FREAK 描述符也是基于同心圆上的采样模式。然而,为了设计他们的描述符,作者使用了人类眼睛的类比。他们观察到在视网膜上,随着距离黄斑的距离增加,神经节细胞的密度会降低。因此,他们构建了一个由43个点组成的采样模式,其中点的密度在中心点附近要大得多。为了获得其强度,每个点都通过一个大小也随中心距离增加的高斯核进行过滤。

为了确定应该执行成对比较,已经通过遵循与 ORB 使用的类似策略进行了实验验证。通过分析数千个关键点,保留了具有最高方差和最低相关性的二进制测试,从而得到512对。

FREAK 还引入了在级联中进行描述符比较的想法。也就是说,首先执行代表较粗信息(对应于在较大高斯核外围进行的测试)的前128位。只有当比较的描述符通过这个初始步骤后,才会执行剩余的测试。

使用 ORB 检测到的关键点,我们通过简单地创建cv::DescriptorExtractor实例来提取 FREAK 描述符,如下所示:

    // to describe with FREAK  
    feature = cv::xfeatures2d::FREAK::create(); 

匹配结果如下:

FREAK

下图说明了本配方中展示的三个描述符所使用的采样模式:

FREAK

第一个正方形是 ORB/BRIEF 描述符,其中在正方形网格上随机选择点对。通过线条连接的点对代表比较两个像素强度的可能测试。在这里,我们只展示了八个这样的点对;默认的 ORB 使用 256 对。中间的正方形对应于 BRISK 采样模式。点在显示的圆上均匀采样(为了清晰起见,我们在这里只标识第一个圆上的点)。最后,第三个正方形显示了 FREAK 的对数极坐标采样网格。虽然 BRISK 具有均匀的点分布,但 FREAK 在中心附近的点密度更高。例如,在 BRISK 中,你会在外圆上找到 20 个点,而在 FREAK 的情况下,其外圆只包括六个点。

参见

  • 在 第八章 的 Detecting FAST features at multiple scales 菜谱中,介绍了相关的 BRISK 和 ORB 特征检测器,并提供了更多关于该主题的参考资料

  • E. M. CalonderV. LepetitM. OzuysalT. TrzcinskiC. StrechaP. Fua 撰写的 BRIEF: Computing a Local Binary Descriptor Very Fast 文章,发表在 IEEE Transactions on Pattern Analysis and Machine Intelligence,2012 年,描述了启发所提出二值描述符的 BRIEF 特征描述符

  • A. AlahiR. OrtizP. Vandergheynst 撰写的 FREAK: Fast Retina Keypoint 文章,发表在 IEEE Conference on Computer Vision and Pattern Recognition,2012 年,描述了 FREAK 特征描述符

第十章. 图像中的投影关系估计

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

  • 计算图像对的基本矩阵

  • 使用随机样本一致性匹配图像

  • 计算两张图像之间的单应性

  • 在图像中检测平面目标

简介

图像通常是通过使用数字相机生成的,该相机通过将穿过其镜头的光线投射到图像传感器上来捕捉场景。一个图像是通过将 3D 场景投影到 2D 平面上形成的这一事实,意味着场景与其图像之间以及同一场景的不同图像之间存在重要的关系。投影几何是用于用数学术语描述和表征图像形成过程的工具。在本章中,我们将向您介绍多视图图像中存在的一些基本投影关系,并解释这些关系如何在计算机视觉编程中使用。但是,在我们开始介绍食谱之前,让我们探索与场景投影和图像形成相关的基本概念。

图像形成

基本上,自摄影术开始以来,用于生成图像的过程并没有改变。从观察场景发出的光线通过相机的前置孔径被捕捉,捕捉到的光线射向位于相机后部的图像平面(或图像传感器)。此外,还使用一个镜头来集中来自不同场景元素的光线。这个过程如下图所示:

图像形成

在这里,do 是镜头到观察对象的距离,di 是镜头到图像平面的距离,f 是镜头的焦距。这些量通过所谓的薄透镜方程相关联:

图像形成

在计算机视觉中,这个相机模型可以通过多种方式简化。首先,我们可以通过考虑我们有一个具有无穷小孔径的相机来忽略镜头的影响,因为从理论上讲,这不会改变图像的外观。(然而,这样做会通过创建具有无限景深图像的方式忽略聚焦效果。)在这种情况下,因此,只考虑中心光线。其次,由于大多数时候我们有 do>>di,我们可以假设图像平面位于焦距处。最后,我们可以从系统的几何关系中注意到,平面上的图像是倒置的。我们可以通过简单地将图像平面放置在镜头前方来获得一个相同但正立的图像。显然,这在物理上是不切实际的,但从数学角度来看,这是完全等价的。这个简化的模型通常被称为针孔相机模型,如下所示:

图像形成

从这个模型出发,并使用相似三角形定律,我们可以很容易地推导出将摄影物体与其图像相关联的基本投影方程:

图像形成

因此,一个物体(物理高度为ho)的图像大小(hi)与其与相机距离(do)成反比,这是自然而然的事情。一般来说,这种关系描述了在给定相机几何形状的情况下,一个 3D 场景点将在图像平面上投影的位置。更具体地说,如果我们假设参考框架位于焦点处,那么位于位置(X,Y,Z)的 3D 场景点将被投影到图像平面上,即(x,y)=(fX/Z,fY/Z)。在这里,Z坐标对应于点的深度(或到相机的距离,在前面方程中用do表示)。通过引入齐次坐标,可以将这种关系重写为简单的矩阵形式,其中二维点由 3 向量表示,三维点由 4 向量表示(额外的坐标只是一个需要从齐次 3 向量中提取二维坐标时去除的任意比例因子s):

图像形成

这个3x4矩阵被称为投影矩阵。在参考框架未与焦点对齐的情况下,必须引入旋转r和平移t矩阵。这些矩阵的作用仅仅是将投影的 3D 点表达为以相机为中心的参考框架,如下所示:

图像形成

该方程的第一个矩阵据说包含相机的内在参数(在这里,只有焦距,但下一章将介绍一些更多的内在参数)。第二个矩阵包含外参数,这些参数与相机与外部世界相关。应注意的是,在实践中,图像坐标用像素表示,而 3D 坐标用世界测量表示(例如,米)。这一点将在第十一章 重建 3D 场景中探讨。

计算图像对的基本矩阵

本章的引言部分介绍了投影方程,描述了场景点如何投影到单台相机的图像平面上。在本食谱中,我们将探讨显示相同场景的两个图像之间存在的投影关系。这两个图像可能是通过将相机移动到两个不同的位置从两个视点拍照获得的,或者是通过使用两个相机,每个相机拍摄场景的不同图像。当这两个相机通过一个刚体基线分开时,我们使用术语立体视觉

准备中

现在让我们考虑两个小孔相机观察一个给定的场景点,如图所示:

准备中

我们了解到,我们可以通过追踪连接 3D 点 X 和相机中心的直线来找到 3D 点 X 的图像 x。相反,在图像平面上具有图像位置 x 的场景点可以在 3D 空间中的这条直线上任何位置。这意味着,如果我们想在另一幅图像中找到给定图像点的对应点,我们需要沿着这条直线在第二幅图像平面上的投影进行搜索。这条想象中的直线称为点 x极线。它定义了一个必须满足两个对应点的基本约束;也就是说,给定点的匹配必须位于另一视图中的该点的极线上,并且这条极线的确切方向取决于两个相机的相对位置。实际上,可能极线集的配置表征了双视图系统的几何形状。

从这个双视图系统的几何形状中可以得出的另一个观察结果是,所有的极线都通过同一点。这一点对应于一个相机中心在另一个相机上的投影(上图中的点 ee')。这个特殊点称为极点

从数学上讲,图像点与其对应极线之间的关系可以使用一个 3x3 矩阵来表示,如下所示:

准备中

在射影几何中,二维直线也由一个 3 向量表示。它对应于满足方程 l1'x'+ l2'y'+ l3'= 0(上标撇表示这条直线属于第二幅图像)的二维点集 (x',y')。因此,称为基本矩阵的矩阵 F 将一个视图中的二维图像点映射到另一个视图中的极线。

如何操作...

图像对的基本矩阵可以通过解一组方程来估计,这组方程涉及两个图像之间的一定数量的已知匹配点。这种匹配的最小数量是七个。为了说明基本矩阵估计过程,我们从上一章中展示的 SIFT 特征匹配结果中选择了七个良好的匹配点。

这些匹配将用于使用 cv::findFundamentalMat OpenCV 函数计算基本矩阵。显示的是具有其选定匹配的图像对:

如何操作...

这些匹配存储在一个指向 cv::keypoint 实例索引的 cv::DMatch 向量中。这些关键点首先需要转换为 cv::Point2f,以便与 cv::findFundamentalMat 一起使用。可以使用 OpenCV 函数来完成此操作:

    // Convert keypoints into Point2f 
    std::vector<cv::Point2f> selPoints1, selPoints2; 
    std::vector<int> pointIndexes1, pointIndexes2; 
    cv::KeyPoint::convert(keypoints1,selPoints1,pointIndexes1); 
    cv::KeyPoint::convert(keypoints2,selPoints2,pointIndexes2); 

生成的两个向量 selPoints1selPoints2 包含两个图像中对应点的坐标。pointIndexes1pointIndexes2 向量包含要转换的关键点的索引。然后对 cv::findFundamentalMat 函数的调用如下:

    // Compute F matrix from 7 matches 
    cv::Mat fundamental= cv::findFundamentalMat(  
                             selPoints1,      // 7 points in first image 
                             selPoints2,      // 7 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(  
                     selPoints1,  // image points  
                     1,           // in image 1 (can also be 2) 
                     fundamental, // 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)); 
    } 

左图的极线以类似的方式获得。以下图像显示了这些线:

如何操作...

记住,一个图像的极点位于所有极线的交点处。这个点是另一个相机的中心的投影。请注意,极线可以在图像边界之外相交(并且通常确实如此)。在我们的例子中,第二图像的极点位于如果两个图像在同一瞬间拍摄,第一个相机应该可见的位置。还请注意,当仅从七个匹配中计算基本矩阵时,结果可能非常不稳定。确实,用一个匹配替换另一个匹配可能会导致极线集合显著不同。

它是如何工作的...

我们之前解释过,对于一幅图中的一个点,基本矩阵给出了另一个视图中其对应点所在线的方程。如果点(x,y)的对应点是(x',y'),假设我们有两个视图之间的基本矩阵F。由于(x',y')位于通过将F乘以表示为齐次坐标的(x,y)得到的极线上,因此我们必须有以下的方程:

它是如何工作的...

这个方程表达了两个对应点之间的关系,被称为极线约束。使用这个方程,就可以使用已知的匹配来估计矩阵的项。由于F矩阵的项只给出一个比例因子,因此只有八个项需要估计(第九项可以任意设置为1)。每个匹配都对一个方程有贡献。因此,使用八个已知的匹配,可以通过求解得到的线性方程组来完全估计矩阵。这就是当你使用cv::findFundamentalMat函数的cv::FM_8POINT标志时所做的事情。请注意,在这种情况下,输入超过八个匹配是可能的(并且是首选的)。然后,可以在均方意义上求解得到的超定线性方程组。

为了估计基础矩阵,还可以利用一个额外的约束条件。从数学上讲,F 矩阵将一个二维点映射到一条一维直线束(即相交于一个公共点的直线)。所有这些极线都通过这个唯一点(即极点)的事实对矩阵施加了一个约束。这个约束将估计基础矩阵所需的匹配数量减少到七个。用数学术语来说,我们说基础矩阵有 7 个自由度,因此是秩为 2 的。不幸的是,在这种情况下,方程组变得非线性,最多有三个可能的解(在这种情况下,cv::findFundamentalMat 将返回一个大小为 9x3 的基础矩阵,即三个 3x3 矩阵堆叠)。可以通过在 OpenCV 中使用 cv::FM_7POINT 标志来调用 F 矩阵估计的七个匹配解。这正是我们在上一节示例中所做的。

最后,应该提到的是,在图像中选择一个合适的匹配集对于获得基础矩阵的准确估计很重要。一般来说,匹配应该在图像中均匀分布,并包括场景中不同深度的点。否则,解将变得不稳定。特别是,所选的场景点不应共面,因为在这种情况下,基础矩阵(在这种情况下)将变得退化。

参见

  • 《计算机视觉中的多视图几何》,剑桥大学出版社,2004 年,R. HartleyA. Zisserman,是计算机视觉中投影几何最完整的参考书

  • 《使用随机样本一致性匹配图像》菜谱解释了如何从更大的匹配集中稳健地估计基础矩阵

  • 《在两幅图像之间计算单应性》菜谱解释了为什么当匹配点共面或是由纯旋转产生的结果时,无法计算基础矩阵

使用随机样本一致性匹配图像

当两个相机观察同一个场景时,它们看到相同的元素,但处于不同的视点。我们已经在上一章研究了特征点匹配问题。在这个菜谱中,我们回到这个问题,我们将学习如何利用上一菜谱中引入的极线约束来更可靠地匹配图像特征。

我们将遵循的原则很简单:当我们在两幅图像之间匹配特征点时,我们只接受落在对应极线上的匹配。然而,为了能够检查这个条件,必须知道基础矩阵,但我们需要好的匹配来估计这个矩阵。这似乎是一个鸡生蛋的问题。然而,在这个菜谱中,我们提出了一种解决方案,其中基础矩阵和一组好的匹配将共同计算。

如何做到...

目标是能够计算两个视图之间的基本矩阵和一组好的匹配项。为此,我们将使用在前面食谱中引入的极线约束来验证找到的所有特征点对应关系。为此,我们创建了一个封装所提议的鲁棒匹配过程不同步骤的类:

    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> descriptor; 
      int normType; 
      float ratio;         // max ratio between 1st and 2nd NN 
      bool refineF;        // if true will refine the F matrix 
      bool refineM;        // if true will refine the matches  
      double distance;     // min distance to epipolar 
      double confidence;   // confidence level (probability) 

     public: 

      RobustMatcher(const cv::Ptr<cv::FeatureDetector> &detector,         
                    const cv::Ptr<cv::DescriptorExtractor> &descriptor=  
                              cv::Ptr<cv::DescriptorExtractor>()):
                    detector(detector), descriptor(descriptor),                
                    normType(cv::NORM_L2), ratio(0.8f),  
                    refineF(true), refineM(true),  
                    confidence(0.98), distance(1.0) { 

          // in this case use the associated descriptor 
          if (!this->descriptor) {  
            this->descriptor = this->detector; 
        }  
      } 

该类的用户只需提供他们选择的特征检测器和描述符实例。这些也可以使用定义的setFeatureDetectorsetDescriptorExtractor设置方法来指定。

主要方法是匹配方法,它返回匹配项、检测到的关键点和估计的基本矩阵。该方法分为四个不同的步骤(在以下代码的注释中明确标识),我们现在将探讨这些步骤:

    // Match feature points using RANSAC 
    // returns fundamental matrix and output match set 
    cv::Mat match(cv::Mat& image1, cv::Mat& image2,     // input images 
                  std::vector<cv::DMatch>& matches,     // output matches 
                  std::vector<cv::KeyPoint>& keypoints1,//output keypoints 
                  std::vector<cv::KeyPoint>& keypoints2) {  

       // 1\. Detection of the feature points 
      detector->detect(image1,keypoints1); 
      detector->detect(image2,keypoints2); 

      // 2\. Extraction of the feature descriptors 
      cv::Mat descriptors1, descriptors2; 
      descriptor->compute(image1,keypoints1,descriptors1); 
      descriptor->compute(image2,keypoints2,descriptors2); 

      // 3\. Match the two image descriptors 
      // (optionally apply some checking method) 

      // Construction of the matcher with crosscheck 
      cv::BFMatcher matcher(normType,   //distance measure 
                            true);      //crosscheck flag 
      // match descriptors 
      std::vector<cv::DMatch> outputMatches; 
      matcher.match(descriptors1,descriptors2,outputMatches); 

      // 4\. Validate matches using RANSAC 
      cv::Mat fundamental= ransacTest(outputMatches,        
                                      keypoints1, keypoints2,   
                                      matches); 
      // return the found fundamental matrix 
      return fundamental; 
    } 

前两个步骤仅仅是检测特征点并计算它们的描述符。接下来,我们使用cv::BFMatcher类进行特征匹配,就像我们在上一章中所做的那样。我们使用交叉检查标志来获得更好的匹配项。

第四步是本食谱中引入的新概念。它包括一个额外的过滤测试,这次将使用基本矩阵来拒绝不遵守极线约束的匹配项。这个测试基于RANSAC方法,即使匹配集中存在异常值也能计算基本矩阵(这种方法将在下一节中解释)。

使用我们的RobustMatcher类,通过以下调用就可以轻松完成图像对的鲁棒匹配:

    // Prepare the matcher (with default parameters) 
    // SIFT detector and descriptor 
    RobustMatcher rmatcher(cv::xfeatures2d::SIFT::create(250)); 

    // Match the two images 
    std::vector<cv::DMatch> matches; 

    std::vector<cv::KeyPoint> keypoints1, keypoints2; 
    cv::Mat fundamental = rmatcher.match(image1, image2,    
                                         matches,         
                                         keypoints1, keypoints2); 

这导致了54个匹配项,如下面的截图所示:

如何做到这一点...

大多数情况下,得到的匹配项将是好的匹配项。然而,可能还有一些错误的匹配项残留;这些是那些意外落在计算出的基本矩阵的对应极线上的。

它是如何工作的...

在前面的食谱中,我们了解到可以从多个特征点匹配中估计与图像对相关联的基本矩阵。显然,为了精确,这个匹配集必须只由好的匹配项组成。然而,在实际情况下,无法保证通过比较检测到的特征点的描述符获得的匹配集是完全精确的。这就是为什么引入了一种基于RANSACRANdom SAmpling Consensus)策略的基本矩阵估计方法。

RANSAC 算法旨在从一个可能包含多个异常值的数据集中估计一个给定的数学实体。其思路是从集合中随机选择一些数据点,并仅使用这些点进行估计。所选点的数量应该是估计数学实体所需的最小点数。在基本矩阵的情况下,八个匹配对是最小数量(实际上,真正的最小值是七个匹配,但 8 点线性算法计算更快)。一旦从这八个随机匹配中估计出基本矩阵,所有其他匹配集的匹配都将与由此矩阵导出的共线约束进行测试。所有满足此约束的匹配(即对应特征与其共线线距离较短的匹配)都将被识别。这些匹配构成了计算出的基本矩阵的支持集。

RANSAC 算法背后的核心思想是,支持集越大,计算出的矩阵是正确的概率就越高。相反,如果随机选择的匹配中有一个(或多个)是错误的匹配,那么计算出的基本矩阵也将是错误的,其支持集预计会很小。这个过程会重复多次,最后,支持集最大的矩阵将被保留为最可能的矩阵。

因此,我们的目标是多次随机选择八个匹配,以便最终选择出八个好的匹配,这将给我们一个大的支持集。根据整个数据集中错误匹配的比例,选择八个正确匹配集的概率将不同。然而,我们知道,我们选择的次数越多,我们对在这些选择中至少有一个好的匹配集的信心就越高。更精确地说,如果我们假设匹配集由w%的内部点(好的匹配)组成,那么我们选择八个好匹配的概率是w⁸。因此,选择包含至少一个错误匹配的概率是(1-w⁸)。如果我们进行k次选择,只有一个随机集只包含好匹配的概率是1-(1-w⁸)^k

这就是置信概率c,我们希望这个概率尽可能高,因为我们至少需要一个好的匹配集才能获得正确的基本矩阵。因此,在运行 RANSAC 算法时,需要确定需要进行多少次k次选择才能获得给定的置信水平。

使用 RANSAC 方法估计基本矩阵是在我们的RobustMatcher类的ransacTest方法中完成的:

    // Identify good matches using RANSAC 
    // Return fundamental matrix and output matches 
    cv::Mat ransacTest(const std::vector<cv::DMatch>& matches,
                       std::vector<cv::KeyPoint>& keypoints1,  
                       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 
        points1.push_back(keypoints1[it->queryIdx].pt); 
        // Get the position of right keypoints 
        points2.push_back(keypoints2[it->trainIdx].pt); 
      } 

      // Compute F matrix using RANSAC 
      std::vector<uchar> inliers(points1.size(),0); 
      cv::Mat fundamental=  
         cv::findFundamentalMat( points1,
                         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); 
      } 
    } 
    return fundamental; 
   } 

这段代码有点长,因为在使用 F 矩阵计算之前,需要将关键点转换为cv::Point2f。当使用cv::findFundamentalMat函数并选择cv::FM_RANSAC方法时,提供了两个额外的参数。其中一个额外参数是置信水平,它决定了要进行的迭代次数(默认为0.99)。另一个参数是点被认为是内点(inlier)的最大距离到极线的距离。所有点与其极线距离大于指定距离的匹配对将被报告为异常点。该函数还返回一个std::vector字符值,表示输入集中的相应匹配已被识别为异常点(0)或内点(1)。这解释了我们方法中提取良好匹配的最后循环。

在你的初始匹配集中,良好的匹配点越多,RANSAC 给出正确基本矩阵的概率就越高。这就是为什么我们在匹配特征点时应用了交叉检查过滤器。你也可以使用之前菜谱中提到的比率测试来进一步提高最终匹配集的质量。这只是平衡计算复杂度、最终匹配点的数量以及所需置信水平的问题,即获得的匹配集将只包含精确匹配。

还有更多...

本菜谱中提出的鲁棒匹配过程的结果是:1)使用具有最大支持的八个选定匹配点计算的基本矩阵的估计值;2)包含在这个支持集中的匹配集。使用这些信息,可以通过两种方式来优化这些结果。

优化基本矩阵

由于我们现在有一组高质量的匹配点,作为最后一步,使用所有这些点来重新估计基本矩阵可能是个好主意。我们之前提到,存在一个线性 8 点算法来估计这个矩阵。因此,我们可以获得一个超定方程组,以最小二乘法求解基本矩阵。这一步可以添加到我们的ransacTest函数的末尾:

    // Convert the keypoints in support set into Point2f  
    points1.clear(); 
    points2.clear(); 
    for (std::vector<cv::DMatch>::const_iterator it=  
                                      outMatches.begin(); 
         it!= outMatches.end(); ++it) { 
      // Get the position of left keypoints 
      points1.push_back(keypoints1[it->queryIdx].pt); 
      // Get the position of right keypoints 
      points2.push_back(keypoints2[it->trainIdx].pt); 
    } 

    // Compute 8-point F from all accepted matches 
    fundamental= cv::findFundamentalMat(  
                      points1,points2, // matching points 
                      cv::FM_8POINT);  // 8-point method solved using SVD 

cv::findFundamentalMat函数确实可以通过使用奇异值分解求解线性方程组来接受超过8个匹配点。

优化匹配

我们了解到,在双视场系统中,每个点都必须位于其对应点的极线(epipolar line)上。这是由基本矩阵表达的基本约束。因此,如果你有一个基本矩阵的良好估计,你可以使用这个极线约束来通过强制它们位于其极线上来纠正获得的匹配点。这可以通过使用cv::correctMatches OpenCV 函数轻松完成:

    std::vector<cv::Point2f> newPoints1, newPoints2; 
    // refine the matches 
    correctMatches(fundamental,             // F matrix 
                   points1, points2,        // original position 
                   newPoints1, newPoints2); // new position 

此函数通过修改每个对应点的位置,使其满足极线约束,同时最小化累积(平方)位移来执行。

计算两张图像之间的单应性

本章的第一个食谱向您展示了如何从一组匹配中计算图像对的基本矩阵。在射影几何中,还存在另一个非常有用的数学实体。这个实体可以从多视图图像中计算出来,正如我们将要看到的,它是一个具有特殊性质的矩阵。

准备中

再次,让我们考虑一个 3D 点与其在相机上的图像之间的射影关系,这是我们在本章引言部分介绍的。基本上,我们了解到这个方程通过相机的内在属性和该相机的位置(用旋转和平移分量指定)将 3D 点与其图像联系起来。如果我们现在仔细检查这个方程,我们会意识到有两个特别有趣的特殊情况。第一种情况是当场景的两个视图之间仅由纯旋转分离时。我们可以观察到外矩阵的第四列将全部由0s 组成(即平移为零):

准备中

因此,在这种特殊情况下,射影关系变成了一个3x3矩阵。当我们观察到的物体是一个平面时,也会出现一个同样有趣的情况。在这种情况下,我们可以假设(不失一般性)该平面上的点将位于Z=0。因此,我们得到以下方程:

准备中

场景点的这个零坐标将取消掉射影矩阵的第三列,从而使它再次成为一个3x3矩阵。这个特殊的矩阵被称为单应性矩阵,它意味着在特殊情况下(在这里,是纯旋转或平面物体),一个世界点可以通过线性关系与其图像相关联。此外,因为这个矩阵是可逆的,所以如果你知道这两个视图之间是纯旋转,或者它们正在成像一个平面物体,你也可以直接将一个视图上的图像点与其在另一个视图上的对应点联系起来。单应性关系的形式如下:

准备中

这里,H是一个3x3矩阵。这个关系在由s标量值表示的尺度因子下成立。一旦估计出这个矩阵,就可以使用这个关系将一个视图中的所有点转移到第二个视图。这就是本食谱和下一个食谱将要利用的性质。请注意,作为单应性关系的副作用,在这些情况下基本矩阵变得未定义。

如何操作...

假设我们有两个由纯旋转分隔的图像。例如,当你通过旋转自己来拍摄建筑物或风景时,这种情况就会发生;由于你离你的主题足够远,在这种情况下,平移分量是可忽略的。可以使用你选择的特征和cv::BFMatcher函数将这两个图像匹配起来。

结果如下:

如何做...

然后,就像我们在之前的菜谱中做的那样,我们将应用一个 RANSAC 步骤,这次将涉及基于匹配集(显然包含大量异常值)的透视变换估计。这是通过使用cv::findHomography函数来完成的,该函数与cv::findFundamentalMat函数非常相似:

    // Find the homography between image 1 and image 2 
    std::vector<char> inliers; 
    cv::Mat homography= cv::findHomography(       
                            points1,
                            points2,    // corresponding points 
                            inliers,    // outputed inliers matches  
                            cv::RANSAC, // RANSAC method 
                            1.);     //max distance to reprojection point 

回想一下,存在透视变换(而不是基本矩阵),因为我们的两个图像是通过纯旋转分隔的。我们在这里显示由函数的inliers参数识别的内点关键点:

如何做...

透视变换是一个3x3可逆矩阵。因此,一旦计算出来,就可以将一个图像中的图像点转移到另一个图像中。实际上,你可以对图像的每个像素都这样做。因此,你可以将整个图像转移到第二个图像的视角中。这个过程被称为图像拼接或图像缝合,通常用于从多张图像构建大型全景图。执行此操作的确切 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 

下面的图像是结果:

如何做...

它是如何工作的...

当两个视角通过透视变换相关联时,就可以确定一个图像上的给定场景点在另一图像上的位置。这个特性对于落在另一图像边界外的图像中的点尤其有趣。确实,由于第二个视角显示了第一个图像中不可见的场景的一部分,你可以使用透视变换来通过读取另一图像中额外像素的颜色值来扩展图像。这就是我们能够创建一个新图像的原因,它是第二个图像的扩展,在右侧添加了额外的列。

cv::findHomography计算的透视变换是将第一图像中的点映射到第二图像中的点。这个透视变换可以从至少四个匹配中计算出来,并且在这里再次使用 RANSAC 算法。一旦找到具有最佳支持的透视变换,cv::findHomography方法就会使用所有识别出的内点来细化它。

现在,为了将图像1的点转移到图像2上,我们实际上需要的是逆单应性。这正是cv::warpPerspective函数默认执行的操作;也就是说,它使用作为输入提供的单应性的逆来获取输出图像中每个点的颜色值(这就是我们在第二章,操作像素中提到的反向映射)。当一个输出像素被转移到输入图像之外的一个点时,这个像素将被简单地分配一个黑色值(0)。请注意,如果想在像素传输过程中使用直接单应性而不是逆单应性,可以在cv::warpPerspective中将cv::WARP_INVERSE_MAP标志指定为可选的第五个参数。

还有更多...

OpenCV 的contrib包提供了一个完整的拼接解决方案,可以从多张图像生成高质量的全景图。

使用 cv::Stitcher 模块生成图像全景

在这个配方中我们获得的风暴图虽然不错,但仍然存在一些缺陷。图像的对齐并不完美,我们可以清楚地看到两张图像之间的切割,因为两张图像的亮度和对比度并不相同。幸运的是,现在 OpenCV 中有一个拼接解决方案,它考虑了所有这些方面,并试图生成一个质量最优的全景图。这个解决方案相当复杂且详尽,但其核心依赖于在这个配方中学到的原则。也就是说,匹配图像中的特征点并稳健地估计单应性。此外,该解决方案通过补偿曝光条件差异来很好地将图像融合在一起。此函数的高级调用如下:

    // Read input images 
    std::vector<cv::Mat> images; 
    images.push_back(cv::imread("parliament1.jpg")); 
    images.push_back(cv::imread("parliament2.jpg")); 

    cv::Mat panorama;   // output panorama 
    // create the stitcher 
    cv::Stitcher stitcher = cv::Stitcher::createDefault(); 
    // stitch the images 
    cv::Stitcher::Status status = stitcher.stitch(images, panorama); 

实例中的许多参数都可以调整以获得高质量的结果。感兴趣的读者应更深入地探索这个包,以了解更多信息。在我们的案例中,得到的结果如下:

使用 cv::Stitcher 模块生成图像全景

显然,在一般情况下,可以使用任意数量的输入图像来组成一个大全景。

参见

  • 第二章,操作像素中的重映射图像配方讨论了反向映射的概念

  • M. BrownD. Lowe《国际计算机视觉杂志》,第 74 卷,第 1 期,2007 年发表的《使用不变特征的自动全景图像拼接》文章,描述了一种从多张图像构建全景的完整方法

在图像中检测平面目标

在前面的菜谱中,我们解释了如何使用单应性将纯旋转分离的图像拼接在一起以创建全景图。在本菜谱中,我们还了解到不同视角的平面图像也会生成视图之间的单应性。现在我们将看到如何利用这一事实来识别图像中的平面物体。

如何做...

假设你想要检测图像中平面物体的出现。这个物体可能是一张海报、画作、标志、标牌等等。根据我们在本章中学到的知识,策略将包括检测这个平面物体上的特征点,并尝试将它们与图像中的特征点进行匹配。然后,使用与之前类似但这次基于单应性的鲁棒匹配方案对这些匹配进行验证。如果有效匹配的数量很高,那么这必须意味着我们的平面物体在当前图像中是可见的。

在这个菜谱中,我们的任务是检测我们书籍第一版在图像中的出现,更具体地说,是以下图像:

如何做...

让我们定义一个与我们的RobustMatcher类非常相似的TargetMatcher类:

    class TargetMatcher { 
      private: 
      // pointer to the feature point detector object 
      cv::Ptr<cv::FeatureDetector> detector; 
      // pointer to the feature descriptor extractor object 
      cv::Ptr<cv::DescriptorExtractor> descriptor; 
      cv::Mat target;           // target image 
      int normType;             // to compare descriptor vectors 
      double distance;          // min reprojection error 
      int numberOfLevels;       // pyramid size 
      double scaleFactor;       // scale between levels 
      // the pyramid of target images and its keypoints 
      std::vector<cv::Mat> pyramid; 
      std::vector<std::vector<cv::KeyPoint>> pyrKeypoints; 
      std::vector<cv::Mat> pyrDescriptors; 

要匹配的平面物体的参考图像由target属性持有。正如将在下一节中解释的那样,特征点将在目标图像的金字塔中逐级下采样检测。匹配方法与RobustMatcher类的方法类似,只是在ransacTest方法中使用cv::findHomography而不是cv::findFundamentalMat

要使用TargetMatcher类,必须实例化一个特定的特征点检测器和描述符,并将其传递给构造函数:

    // Prepare the matcher 
    TargetMatcher tmatcher(cv::FastFeatureDetector::create(10), 
                           cv::BRISK::create()); 
    tmatcher.setNormType(cv::NORM_HAMMING); 

在这里,我们选择了 FAST 检测器与 BRISK 描述符一起使用,因为它们计算速度快。然后,你必须指定要检测的目标:

    // set the target image 
    tmatcher.setTarget(target); 

在我们的情况下,这是以下图像:

如何做...

你可以通过调用detectTarget方法来检测图像中的这个目标:

    // match image with target 
    tmatcher.detectTarget(image, corners); 

此方法返回目标在图像中的四个角的位置(如果找到的话)。然后可以绘制线条来直观地验证检测:

   // draw the target corners on the image 
    if (corners.size() == 4) { // we have a detection 

      cv::line(image, cv::Point(corners[0]),  
               cv::Point(corners[1]),
               cv::Scalar(255, 255, 255), 3); 
      cv::line(image, cv::Point(corners[1]),  
               cv::Point(corners[2]), 
               cv::Scalar(255, 255, 255), 3); 
      cv::line(image, cv::Point(corners[2]),  
               cv::Point(corners[3]),
               cv::Scalar(255, 255, 255), 3); 
      cv::line(image, cv::Point(corners[3]),  
               cv::Point(corners[0]),
               cv::Scalar(255, 255, 255), 3); 
    } 

结果如下:

如何做...

工作原理...

由于我们不知道图像中目标的大小,我们决定构建一个由不同尺寸的目标图像组成的金字塔。另一种选择是使用尺度不变特征。在金字塔的每个级别上,目标图像的大小会按一定比例(属性scaleFactor,默认为0.9)减小,金字塔由一定数量的级别(属性numberOfLevels,默认为8)组成。为金字塔的每个级别检测特征点:

    // Set the target image 
    void setTarget(const cv::Mat t) { 

      target= t; 
      createPyramid(); 
    } 
    // create a pyramid of target images 
    void createPyramid() { 

      // create the pyramid of target images 
      pyramid.clear(); 
      cv::Mat layer(target); 
      for (int i = 0;  
           i < numberOfLevels; i++) { // reduce size at each layer 
        pyramid.push_back(target.clone()); 
        resize(target, target, cv::Size(), scaleFactor, scaleFactor); 
      } 

      pyrKeypoints.clear(); 
      pyrDescriptors.clear(); 
      // keypoint detection and description in pyramid 
      for (int i = 0; i < numberOfLevels; i++) { 
        // detect target keypoints at level i 
        pyrKeypoints.push_back(std::vector<cv::KeyPoint>()); 
        detector->detect(pyramid[i], pyrKeypoints[i]); 
        // compute descriptor at level i 
        pyrDescriptors.push_back(cv::Mat()); 
        descriptor->compute(pyramid[i],  
                            pyrKeypoints[i],
                            pyrDescriptors[i]); 
      } 
    } 

detectTarget 方法随后进入三个步骤。首先,在输入图像中检测兴趣点。其次,将此图像与目标金字塔中的每一幅图像进行鲁棒匹配。保留具有最高内点数的级别。如果这个级别有足够多的存活匹配,那么我们就找到了目标。第三步包括使用找到的透视变换和 cv::getPerspectiveTransform 函数,将目标的四个角重新投影到输入图像的正确比例上:

    // detect the defined planar target in an image 
    // returns the homography and 
    // the 4 corners of the detected target 
    cv::Mat detectTarget( 
                  const cv::Mat& image, // position of the 
                                        // target corners (clock-wise) 
                  std::vector<cv::Point2f>& detectedCorners) { 

      // 1\. detect image keypoints 
      std::vector<cv::KeyPoint> keypoints; 
      detector->detect(image, keypoints); 
      // compute descriptors 
      cv::Mat descriptors; 
      descriptor->compute(image, keypoints, descriptors); 

      std::vector<cv::DMatch> matches; 
      cv::Mat bestHomography; 
      cv::Size bestSize; 
      int maxInliers = 0; 
      cv::Mat homography; 

      // Construction of the matcher   
      cv::BFMatcher matcher(normType); 

      // 2\. robustly find homography for each pyramid level 
      for (int i = 0; i < numberOfLevels; i++) { 
        // find a RANSAC homography between target and image 
        matches.clear(); 
        // match descriptors 
        matcher.match(pyrDescriptors[i], descriptors, matches); 
        // validate matches using RANSAC 
        std::vector<cv::DMatch> inliers; 
        homography = ransacTest(matches, pyrKeypoints[i],  
                                keypoints, inliers); 

        if (inliers.size() > maxInliers) { // we have a better H 
          maxInliers = inliers.size(); 
          bestHomography = homography; 
          bestSize = pyramid[i].size(); 
        } 

      } 

      // 3\. find the corner position on the image using best homography 
      if (maxInliers > 8) { // the estimate is valid 

        //target corners at best size 
        std::vector<cv::Point2f> corners; 
        corners.push_back(cv::Point2f(0, 0)); 
        corners.push_back(cv::Point2f(bestSize.width - 1, 0)); 
        corners.push_back(cv::Point2f(bestSize.width - 1,  
                                      bestSize.height - 1)); 
        corners.push_back(cv::Point2f(0, bestSize.height - 1)); 

        // reproject the target corners 
        cv::perspectiveTransform(corners, detectedCorners, bestHomography); 
      } 

      return bestHomography; 
    } 

以下图像显示了在我们示例中获得的匹配结果:

如何工作...

参见

  • 实时平面目标检测的快速鲁棒透视变换方案》这篇文章由 H. BazarganiO. BilaniukR. Laganière 撰写,发表在 Journal of Real-Time Image Processing 2015 年 5 月,描述了一种实时检测平面目标的方法。它还描述了 cv::findHomography 函数的 cv::RHO 方法。

第十一章。重建 3D 场景

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

  • 校准相机

  • 恢复相机姿态

  • 从校准相机中重建 3D 场景

  • 从立体图像计算深度

简介

在上一章中,我们学习了相机如何通过在二维传感器平面上投影光线来捕捉 3D 场景。产生的图像是从特定视角捕捉图像时的场景的准确表示。然而,由于图像形成的本质,这个过程消除了关于所表示场景元素深度的所有信息。本章将介绍在特定条件下,场景的 3D 结构和捕获它的相机的 3D 姿态如何被恢复。我们将看到对投影几何概念的良好理解如何使我们能够设计出实现 3D 重建的方法。因此,我们将回顾上一章中引入的图像形成原理;特别是,我们现在将考虑我们的图像是由像素组成的。

数字图像形成

现在我们重新绘制第十章中所示的图像的新版本,该图像描述了针孔相机模型。更具体地说,我们想展示 3D 位置(X,Y,Z)上的一个点与其在指定像素坐标的相机上的图像(x,y)之间的关系:

数字图像形成

注意对原始图像所做的更改。首先,我们在投影中心的参考坐标系中添加了一个参考框架。其次,我们将Y轴指向下方,以获得与通常将图像原点放置在图像右上角的习惯相符的坐标系。最后,我们还识别了图像平面上的一个特殊点:考虑到从焦点发出的垂直于图像平面的线,点(u0,v0)是这条线穿过图像平面的像素位置。这个点被称为主点。从逻辑上讲,可以假设这个主点位于图像平面的中心,但在实践中,这个点可能偏离几个像素,这取决于相机制造的精度。

在上一章中,我们了解到针孔模型中相机的本质参数是其焦距和图像平面的尺寸(它定义了相机的视场)。此外,由于我们处理的是数字图像,图像平面上像素的数量(其分辨率)也是相机的另一个重要特征。我们还了解到,3D 点(X,Y,Z)将被投影到图像平面上,其位置为(fX/Z,fY/Z)

现在,如果我们想将这个坐标转换为像素,我们需要分别将 2D 图像位置除以像素宽度(px)和高度(py)。我们注意到,通过将以世界单位(通常以毫米为单位)给出的焦距除以px,我们得到以(水平)像素表示的焦距。因此,让我们定义这个术语为fx。同样,fy = f / py被定义为以垂直像素单位表示的焦距。因此,完整的投影方程如下:

数字图像形成

回想一下,(u0,v0)是主点,它被添加到结果中以将原点移动到图像的左上角。还要注意,像素的物理尺寸可以通过将图像传感器的尺寸(通常以毫米为单位)除以像素数(水平或垂直)来获得。在现代传感器中,像素通常是正方形的,也就是说,它们的水平和垂直尺寸相同。

前面的方程可以写成矩阵形式,就像我们在第十章“在图像中估计投影关系”中所做的那样。以下是投影方程在其最一般形式下的完整表达式:

数字图像形成

校准相机

相机校准是获取不同相机参数(即在投影方程中出现的参数)的过程。显然,可以使用相机制造商提供的规格,但对于某些任务,如 3D 重建,这些规格可能不够精确。通过进行适当的相机校准步骤,可以获得准确的校准信息。

一个主动的相机校准过程将通过向相机展示已知图案并分析获得的图像来进行。然后,一个优化过程将确定解释观察结果的最佳参数值。这是一个复杂的过程,但由于 OpenCV 校准函数的可用性,这个过程变得简单易行。

如何操作...

要校准相机,其思路是向相机展示一组已知其 3D 位置的场景点。然后,你需要观察这些点在图像上的投影位置。有了足够数量的 3D 点和相关的 2D 图像点的知识,可以从投影方程中推断出确切的相机参数。显然,为了获得准确的结果,我们需要观察尽可能多的点。实现这一目标的一种方法是对具有许多已知 3D 点的场景拍摄一张照片,但在实践中,这很少可行。更方便的方法是从不同的视角对一组 3D 点拍摄多张照片。这种方法更简单,但除了计算内部相机参数外,还需要计算每个相机视图的位置,幸运的是,这是可行的。

OpenCV 建议你使用棋盘图案来生成校准所需的 3D 场景点集。这个图案在每个方格的角上创建点,由于这个图案是平的,我们可以自由地假设板位于Z=0XY轴与网格对齐。

在这种情况下,校准过程简单地包括从不同的视角向相机展示棋盘图案。以下是在校准步骤中捕获的由7x5内角组成的校准图案图像的一个示例:

如何操作...

好处是 OpenCV 有一个函数可以自动检测这个棋盘图案的角。你只需提供一个图像和使用的棋盘大小(水平和垂直内角点的数量)。该函数将返回图像上这些棋盘角的坐标。如果函数无法找到图案,则简单地返回false

    // output vectors of image points 
    std::vector<cv::Point2f> imageCorners; 
    // number of inner corners on the chessboard 
    cv::Size boardSize(7,5); 
    // Get the chessboard corners 
    bool found = cv::findChessboardCorners( 
                         image,         // image of chessboard pattern 
                         boardSize,     // size of pattern 
                         imageCorners); // list of detected corners 

输出参数imageCorners将简单地包含显示图案中检测到的内角的像素坐标。请注意,如果需要调整算法,该函数接受额外的参数,但这在此处未讨论。还有一个特殊函数可以在棋盘图像上绘制检测到的角,并用线条按顺序连接它们:

    // Draw the corners 
    cv::drawChessboardCorners(image, boardSize,  
                      imageCorners, found); // corners have been found 

下面的图像是得到的:

如何操作...

连接点的线条显示了在检测到的图像点向量中点的列表顺序。为了进行校准,我们现在需要指定相应的 3D 点。你可以选择你喜欢的单位来指定这些点(例如,厘米或英寸);然而,最简单的方法是假设每个方格代表一个单位。在这种情况下,第一个点的坐标将是(0,0,0)(假设板位于Z=0的深度),第二个点的坐标将是(1,0,0),以此类推,最后一个点位于(6,4,0)。这个图案总共有35个点,这太少了,无法获得精确的校准。要获得更多点,你需要从不同的视角展示相同校准图案的更多图像。为此,你可以移动图案到相机前,或者移动相机绕板旋转;从数学的角度来看,这是完全等价的。OpenCV 的校准函数假设参考框架固定在校准图案上,并将计算相机相对于参考框架的旋转和平移。

现在我们将校准过程封装在一个CameraCalibrator类中。这个类的属性如下:

    class CameraCalibrator { 

      // input points: 
      // the points in world coordinates 
      // (each square is one unit) 
      std::vector<std::vector<cv::Point3f>> objectPoints; 
      // the image 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; 

注意,场景点和图像点的输入向量实际上是由点实例的std::vector组成的;每个向量元素是一个包含一个视图中点的向量。在这里,我们决定通过指定一个包含棋盘图像文件名的向量作为输入来添加校准点;该方法将负责从这些图像中提取点坐标:

    // Open chessboard images and extract corner points 
    int CameraCalibrator::addChessboardPoints(      
        const std::vector<std::string> & filelist, // list of filenames 
        cv::Size & boardSize) {   // calibration board size 

      // 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,         // image of chessboard pattern  
                         boardSize,     // size of pattern 
                         imageCorners); // list of detected corners 

        // Get subpixel accuracy on the corners 
        if (found) { 
          cv::cornerSubPix(image, imageCorners,  
               cv::Size(5, 5), // half size of serach window 
               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++; 
          } 
        } 

        // 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对象指定的终止准则定义了最大迭代次数和亚像素坐标的最小精度。这两个条件中先达到的那个将停止角细化过程。

当一组棋盘角成功检测到后,这些点将通过我们的addPoints方法添加到图像和场景点的向量中。一旦处理了足够数量的棋盘图像(并且因此,有大量的 3D 场景点/2D 图像点对应关系可用),我们就可以开始计算校准参数,如下所示:

    // Calibrate the camera 
    // returns the re-projection error 
    double CameraCalibrator::calibrate(cv::Size &imageSize) { 
      // 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 
    } 

在实践中,1020个棋盘图像就足够了,但这些图像必须从不同的视点和不同的深度拍摄。这个函数的两个重要输出是相机矩阵和畸变参数。这些内容将在下一节中进行描述。

它是如何工作的...

为了解释校准的结果,我们需要回到本章引言中提出的投影方程。这个方程描述了通过连续应用两个矩阵将一个 3D 点转换为一个 2D 点的过程。第一个矩阵包括所有的相机参数,这些参数被称为相机的内在参数。这个3x3矩阵是cv::calibrateCamera函数返回的输出矩阵之一。还有一个名为cv::calibrationMatrixValues的函数,它明确返回由校准矩阵给出的内在参数值。

第二个矩阵用于将输入点表示为以相机为中心的坐标。它由一个旋转向量(一个 3x3 矩阵)和一个平移向量(一个 3x1 矩阵)组成。记住,在我们的校准示例中,参考坐标系被放置在棋盘上。因此,必须为每个视图计算一个刚体变换(由矩阵条目 r1r9 表示的旋转部分和由 t1t2t3 表示的平移部分)。这些在 cv::calibrateCamera 函数的输出参数列表中。旋转和平移部分通常被称为校准的外参,并且对于每个视图都是不同的。内参对于给定的相机/镜头系统是恒定的。

cv::calibrateCamera 提供的校准结果是通过一个优化过程获得的。这个过程旨在找到内参和外参,以最小化从 3D 场景点的投影计算出的预测图像点位置与实际图像点位置之间的差异。校准过程中指定所有点的这个差异之和被称为重投影误差

我们从基于 27 张棋盘图像的校准中获得的测试相机的内参是 fx=409 像素,fy=408 像素,u0=237 像素,和 v0=171 像素。我们的校准图像大小为 536x356 像素。从校准结果中可以看出,正如预期的那样,主点接近图像中心,但偏离了几像素。校准图像是用尼康 D500 相机和 18mm 镜头拍摄的。查看制造商规格,我们发现该相机的传感器尺寸为 23.5mm x 15.7mm,这给我们一个像素尺寸为 0.0438mm。估计的焦距以像素表示,所以将结果乘以像素尺寸,我们得到一个估计的焦距为 17.8mm,这与我们实际使用的镜头是一致的。

现在我们将注意力转向畸变参数。到目前为止,我们提到,在针孔相机模型下,我们可以忽略镜头的影响。然而,这只在使用的镜头不会引入重要的光学畸变时才可能。不幸的是,对于低质量镜头或焦距非常短的镜头来说,情况并非如此。即使是我们在这次实验中使用的镜头也引入了一些畸变:矩形板的边缘在图像中是弯曲的。请注意,这种畸变随着我们远离图像中心而变得更加重要。这是一种典型的与鱼眼镜头观察到的畸变,被称为径向畸变

通过引入适当的畸变模型,可以补偿这些变形。想法是通过一组数学方程来表示由镜头引起的畸变。一旦建立,这些方程就可以被逆转,以消除图像上可见的畸变。幸运的是,在校准阶段,可以同时获得将纠正畸变的变换的确切参数以及其他相机参数。一旦这样做,任何来自新校准相机的图像都将被校正。因此,我们在校准类中添加了另一种方法:

    // 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; 
    } 

在我们的校准图像之一上运行此代码会产生以下未畸变图像:

工作原理...

为了校正畸变,OpenCV 使用一个多项式函数,该函数应用于图像点,以便将它们移动到未畸变的位置。默认情况下,使用五个系数;还有一个由八个系数组成的模型可供选择。一旦获得这些系数,就可以计算两个cv::Mat映射函数(一个用于x坐标,一个用于y坐标),这将给出在畸变图像上图像点的新的未畸变位置。这是通过cv::initUndistortRectifyMap函数计算的,而cv::remap函数将输入图像的所有点重新映射到新图像。请注意,由于非线性变换,输入图像的一些像素现在超出了输出图像的边界。您可以通过扩展输出图像的大小来补偿这种像素损失,但您现在获得的输出像素在输入图像中没有值(它们将显示为黑色像素)。

更多内容...

在进行相机校准时,有更多选项可供选择。

使用已知内在参数进行校准

当已知相机内在参数的良好估计时,将它们输入到cv::calibrateCamera函数中可能是有益的。然后,它们将在优化过程中用作初始值。为此,您只需添加cv::CALIB_USE_INTRINSIC_GUESS标志并将这些值输入到校准矩阵参数中。还可以为主点(cv::CALIB_FIX_PRINCIPAL_POINT)指定一个固定值,这通常可以假设为中心像素。您还可以为焦距fxfy指定一个固定比率(cv::CALIB_FIX_RATIO),在这种情况下,您假设像素是正方形的。

使用圆形网格进行校准

除了常用的棋盘格图案外,OpenCV 还提供了使用圆形网格校准相机的可能性。在这种情况下,圆的中心被用作校准点。相应的函数与我们用来定位棋盘格角落的函数非常相似,例如:

    cv::Size boardSize(7,7); 
    std::vector<cv::Point2f> centers; 
    bool found = cv:: findCirclesGrid(image, boardSize, centers); 

参见

  • 由 Z. Zhang 在 2000 年发表的,发表在《IEEE Transactions on Pattern Analysis and Machine Intelligence》第 22 卷第 11 期的《一种灵活的相机校准新方法》文章,是关于相机校准问题的一篇经典论文

恢复相机姿态

当相机校准时,就可以将捕获的图像与外部世界联系起来。我们之前解释过,如果知道一个物体的三维结构,那么就可以预测该物体在相机传感器上的成像方式。图像形成的过程实际上完全由本章开头提出的投影方程描述。当这个方程的大部分项已知时,就可以通过观察一些图像来推断其他元素(二维或三维)的值。在这个菜谱中,我们将探讨在观察到已知三维结构时,如何恢复相机姿态的问题。

如何做...

让我们考虑一个简单的物体,一个公园里的长椅。我们使用之前菜谱中校准过的相机/镜头系统拍摄了这张照片。我们还手动识别了长椅上的八个不同的图像点,我们将使用这些点来进行我们的相机姿态估计:

如何做...

能够访问这个物体,就可以进行一些物理测量。这个长椅由一个242.5cm x 53.5cm x 9cm的座位和一个242.5cm x 24cm x 9cm的靠背组成,靠背固定在座位上方12cm处。使用这些信息,我们可以轻松地推导出八个识别点在某个以物体为中心的参考系中的三维坐标(在这里,我们将原点固定在两个平面交点的左侧)。然后我们可以创建一个包含这些坐标的cv::Point3f向量:

    // Input object points 
    std::vector<cv::Point3f> objectPoints; 
    objectPoints.push_back(cv::Point3f(0, 45, 0)); 
    objectPoints.push_back(cv::Point3f(242.5, 45, 0)); 
    objectPoints.push_back(cv::Point3f(242.5, 21, 0)); 
    objectPoints.push_back(cv::Point3f(0, 21, 0)); 
    objectPoints.push_back(cv::Point3f(0, 9, -9)); 
    objectPoints.push_back(cv::Point3f(242.5, 9, -9)); 
    objectPoints.push_back(cv::Point3f(242.5, 9, 44.5)); 
    objectPoints.push_back(cv::Point3f(0, 9, 44.5)); 

现在的问题是,当拍摄所示图片时,相机相对于这些点的位置在哪里。由于已知这些已知点在二维图像平面上的图像坐标,因此使用cv::solvePnP函数就可以很容易地回答这个问题。在这里,三维点和二维点之间的对应关系是手动建立的,但应该能够想出一些方法,使你能够自动获得这些信息:

    // Input image points 
    std::vector<cv::Point2f> imagePoints; 
    imagePoints.push_back(cv::Point2f(136, 113)); 
    imagePoints.push_back(cv::Point2f(379, 114)); 
    imagePoints.push_back(cv::Point2f(379, 150)); 
    imagePoints.push_back(cv::Point2f(138, 135)); 
    imagePoints.push_back(cv::Point2f(143, 146)); 
    imagePoints.push_back(cv::Point2f(381, 166)); 
    imagePoints.push_back(cv::Point2f(345, 194)); 
    imagePoints.push_back(cv::Point2f(103, 161)); 

    // Get the camera pose from 3D/2D points 
    cv::Mat rvec, tvec; 
    cv::solvePnP( 
                 objectPoints, imagePoints,      // corresponding 3D/2D pts  
                 cameraMatrix, cameraDistCoeffs, // calibration  
                 rvec, tvec);                    // output pose 

    //Convert to 3D rotation matrix 
    cv::Mat rotation; 
    cv::Rodrigues(rvec, rotation); 

这个函数实际上计算的是将物体坐标从相机中心参考系(即原点位于焦点处的参考系)转换过来的刚体变换(旋转和平移)。值得注意的是,这个函数计算出的旋转是以 3D 向量的形式给出的。这是一种紧凑的表示方式,其中要应用的旋转由一个单位向量(旋转轴)描述,物体围绕这个轴旋转一定角度。这种轴角表示法也称为罗德里格斯旋转公式。在 OpenCV 中,旋转角度对应于输出旋转向量的范数,后者与旋转轴对齐。这就是为什么我们使用cv::Rodrigues函数来获取出现在我们投影方程中的 3D 旋转矩阵。

这里描述的姿态恢复过程很简单,但我们如何知道我们获得了正确的相机/物体姿态信息呢?我们可以通过使用cv::viz模块来评估结果的质量,该模块使我们能够可视化 3D 信息。这个模块的使用在食谱的最后部分有解释,但让我们显示一个简单的 3D 表示,包括我们的物体和捕获它的相机:

如何做到这一点...

仅通过查看这张图片可能很难判断姿态恢复的质量,但如果你在你的电脑上测试这个食谱的例子,你将有机会使用鼠标在 3D 中移动这个表示,这应该会给你一个更好的解决方案感。

它是如何工作的...

在这个食谱中,我们假设物体的 3D 结构是已知的,以及物体点集和图像点集之间的对应关系。通过校准,我们也知道了相机的内在参数。如果你查看我们在本章引言中“数字图像形成”部分的投影方程,这意味着我们有坐标为(X,Y,Z)(x,y)的点。我们还有第一个矩阵的元素已知(内在参数)。只有第二个矩阵是未知的;这是包含相机外参数的矩阵,也就是相机/物体姿态信息。我们的目标是从对 3D 场景点的观察中恢复这些未知参数。这个问题被称为透视-n-点PnP)问题。

旋转有三个自由度(例如,围绕三个轴的旋转角度)和平移也有三个自由度。因此,我们总共有六个未知数。对于每个对象点/图像点对应关系,投影方程给我们三个代数方程,但由于投影方程有一个比例因子,所以我们只有两个独立的方程。因此,至少需要三个点来解决这个方程组。显然,更多的点可以提供更可靠的估计。

在实践中,已经提出了许多不同的算法来解决这个问题,OpenCV 在其 cv::solvePnP 函数中提出了多种不同的实现。默认方法是在所谓的重投影误差上进行优化。最小化这种类型的误差被认为是获取从相机图像中准确 3D 信息的最佳策略。在我们的问题中,它对应于找到最优的相机位置,以最小化投影 3D 点(通过应用投影方程获得)和作为输入给出的观察到的图像点之间的 2D 距离。

注意,OpenCV 还有一个 cv::solvePnPRansac 函数。正如其名所示,这个函数使用 RANSAC 算法来解决 PnP 问题。这意味着一些对象点/图像点对应关系可能是错误的,并且函数将返回被识别为异常值的那部分。当这些对应关系是通过可能失败于某些点的自动过程获得时,这非常有用。

还有更多...

当处理 3D 信息时,验证获得的解决方案通常很困难。为此,OpenCV 提供了一个简单但功能强大的可视化模块,它有助于 3D 视觉算法的开发和调试。它允许在虚拟 3D 环境中插入点、线、相机和其他对象,你可以从不同的视角交互式地可视化它们。

cv::Viz,一个 3D 可视化模块

cv::Viz 是 OpenCV 库的一个额外模块,它建立在开源库 Visualization Toolkit (VTK) 之上。这是一个用于 3D 计算机图形的强大框架。使用 cv::viz,你可以创建一个 3D 虚拟环境,你可以向其中添加各种对象。创建了一个可视化窗口,该窗口从给定的视角显示环境。你在这个菜谱中看到了一个 cv::viz 窗口中可以显示的示例。这个窗口响应鼠标事件,用于在环境中导航(通过旋转和平移)。本节描述了 cv::viz 模块的基本用法。

首先要做的事情是创建可视化窗口。在这里,我们使用白色背景:

    // Create a viz window 
    cv::viz::Viz3d visualizer("Viz window"); 
    visualizer.setBackgroundColor(cv::viz::Color::white()); 

接下来,你创建你的虚拟对象并将它们插入场景中。这里有许多预定义的对象。其中之一对我们特别有用;它就是创建虚拟针孔相机的那个:

    // Create a virtual camera 
    cv::viz::WCameraPosition cam( 
                    cMatrix,     // matrix of intrinsics 
                    image,       // image displayed on the plane 
                    30.0,        // scale factor 
                    cv::viz::Color::black()); 
    // Add the virtual camera to the environment 
    visualizer.showWidget("Camera", cam); 

cMatrix变量是一个cv::Matx33d(即,一个cv::Matx<double,3,3>)实例,包含从校准中获得的相机内参。默认情况下,这个相机被插入到坐标系的原点。为了表示长凳,我们使用了两个矩形立方体对象:

    // Create a virtual bench from cuboids 
    cv::viz::WCube plane1(cv::Point3f(0.0, 45.0, 0.0),             
                          cv::Point3f(242.5, 21.0, -9.0),   
                          true,     // show wire frame  
                          cv::viz::Color::blue()); 
    plane1.setRenderingProperty(cv::viz::LINE_WIDTH, 4.0); 
    cv::viz::WCube plane2(cv::Point3f(0.0, 9.0, -9.0), 
                          cv::Point3f(242.5, 0.0, 44.5),                
                          true,    // show wire frame  
                          cv::viz::Color::blue()); 
    plane2.setRenderingProperty(cv::viz::LINE_WIDTH, 4.0); 
    // Add the virtual objects to the environment 
    visualizer.showWidget("top", plane1); 
    visualizer.showWidget("bottom", plane2); 

这个虚拟长凳也被添加到原点;然后需要将其移动到从我们的cv::solvePnP函数中找到的以相机为中心的位置。这是setWidgetPose方法执行此操作的责任。这个方法只是应用了估计运动中的旋转和平移分量:

    cv::Mat rotation; 
    // convert vector-3 rotation 
    // to a 3x3 rotation matrix 
    cv::Rodrigues(rvec, rotation); 

    // Move the bench  
    cv::Affine3d pose(rotation, tvec); 
    visualizer.setWidgetPose("top", pose); 
    visualizer.setWidgetPose("bottom", pose); 

最后一步是创建一个循环,持续显示可视化窗口。1ms暂停是为了监听鼠标事件:

    // visualization loop 
    while(cv::waitKey(100)==-1 && !visualizer.wasStopped()) { 

      visualizer.spinOnce(1,      // pause 1ms  
                          true);  // redraw 
    } 

当可视化窗口关闭或在一个 OpenCV 图像窗口上按下键时,这个循环将停止。尝试在这个循环中对一个物体进行一些运动(使用setWidgetPose);这就是创建动画的方法。

参见

  • 基于模型的 25 行代码中的对象姿态,由D. DeMenthonL. S. Davis在 1992 年的欧洲计算机视觉会议上发表,是一种从场景点恢复相机姿态的著名方法

  • 第十章中关于“在图像中估计投影关系”的使用随机样本一致性匹配图像配方描述了 RANSAC 算法

  • 第一章中关于安装 OpenCV 库的配方,玩转图像解释了如何安装 RANSAC cv::viz扩展模块

从校准的相机中重建 3D 场景

在上一个配方中,我们看到了当场景校准时,可以恢复观察 3D 场景的相机的位置。描述的方法利用了这样一个事实:有时,场景中可见的一些 3D 点的坐标可能是已知的。现在我们将学习,如果从多个视角观察场景,即使没有关于 3D 场景的信息,也可以重建 3D 姿态和结构。这次,我们将使用不同视图中图像点之间的对应关系来推断 3D 信息。我们将引入一个新的数学实体,它包含校准相机两个视图之间的关系,并讨论三角测量的原理,以便从 2D 图像中重建 3D 点。

如何做到这一点...

让我们再次使用本章第一个配方中校准的相机,并对某个场景拍摄两张照片。我们可以使用,例如,第八章中介绍的 SIFT 检测器和描述符第八章,检测兴趣点和第九章中介绍的第九章,描述和匹配兴趣点,在这两个视图中匹配特征点。

由于相机的校准参数可用,我们可以使用世界坐标系进行工作;因此,在相机姿态和对应点的位置之间建立物理约束。基本上,我们引入了一个新的数学实体,称为本质矩阵,它是前一章中引入的基本矩阵的校准版本。因此,存在一个cv::findEssentialMat函数,它与第十章中用于计算图像对的基本矩阵cv::findFundametalMat函数相同,该函数在估计图像中的投影关系中。我们可以使用已建立的点对应关系调用此函数,并通过 RANSAC 方案过滤掉异常点,仅保留符合找到的几何形状的匹配:

    // vector of keypoints and descriptors 
    std::vector<cv::KeyPoint> keypoints1; 
    std::vector<cv::KeyPoint> keypoints2; 
    cv::Mat descriptors1, descriptors2; 

    // Construction of the SIFT feature detector  
    cv::Ptr<cv::Feature2D> ptrFeature2D =   
                           cv::xfeatures2d::SIFT::create(500); 

    // Detection of the SIFT features and associated descriptors 
    ptrFeature2D->detectAndCompute(image1, cv::noArray(),  
                                   keypoints1, descriptors1); 
    ptrFeature2D->detectAndCompute(image2, cv::noArray(),  
                                   keypoints2, descriptors2); 

    // Match the two image descriptors 
    // Construction of the matcher with crosscheck  
    cv::BFMatcher matcher(cv::NORM_L2, true); 
    std::vector<cv::DMatch> matches; 
    matcher.match(descriptors1, descriptors2, matches); 

    // 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)); 
    } 

    // Find the essential between image 1 and image 2 
    cv::Mat inliers; 
    cv::Mat essential = cv::findEssentialMat(points1, points2,            
                                Matrix,         // intrinsic parameters 
                                cv::RANSAC,
                                0.9, 1.0,       // RANSAC method 
                                inliers);       // extracted inliers 

结果的匹配内点集如下:

如何做...

如下一节将解释的,本质矩阵封装了分离两个视图的旋转和平移分量。因此,可以直接从这个矩阵中恢复我们两个视图之间的相对姿态。OpenCV 有一个执行此操作的函数,它是cv::recoverPose函数。这个函数的使用方法如下:

    // recover relative camera pose from essential matrix 
    cv::Mat rotation, translation; 
    cv::recoverPose(essential,             // the essential matrix 
                    points1, points2,      // the matched keypoints 
                    cameraMatrix,          // matrix of intrinsics 
                    rotation, translation, // estimated motion 
                    inliers);              // inliers matches 

现在我们有了两个相机之间的相对姿态,就可以估计我们已在这两个视图之间建立对应关系的点的位置。下面的截图说明了这是如何可能的。它显示了两个相机在它们估计的位置(左侧的放置在原点)。我们还选择了一对对应点,并且对于这些图像点,我们绘制了一条射线,根据投影几何模型,这条射线对应于所有可能的关联 3D 点的位置:

如何做...

显然,由于这两个图像点是由同一个 3D 点生成的,因此两条射线必须在一点相交,即 3D 点的位置。当两个相机的相对位置已知时,通过相交两个对应图像点的投影线的方法称为三角测量。这个过程首先需要两个投影矩阵,并且可以重复用于所有匹配。然而,请记住,这些必须用世界坐标系表示;这里是通过使用cv::undistortPoints函数来实现的。

最后,我们调用我们的三角测量函数,该函数计算三角测量点的位置,这将在下一节中描述:

    // compose projection matrix from R,T 
    cv::Mat projection2(3, 4, CV_64F); // the 3x4 projection matrix 
    rotation.copyTo(projection2(cv::Rect(0, 0, 3, 3))); 
    translation.copyTo(projection2.colRange(3, 4)); 

    // compose generic projection matrix  
    cv::Mat projection1(3, 4, CV_64F, 0.); // the 3x4 projection matrix 
    cv::Mat diag(cv::Mat::eye(3, 3, CV_64F)); 
    diag.copyTo(projection1(cv::Rect(0, 0, 3, 3))); 

    // to contain the inliers 
    std::vector<cv::Vec2d> inlierPts1; 
    std::vector<cv::Vec2d> inlierPts2; 

    // create inliers input point vector for triangulation 
    int j(0); 
    for (int i = 0; i < inliers.rows; i++) { 
      if (inliers.at<uchar>(i)) { 
        inlierPts1.push_back(cv::Vec2d(points1[i].x, points1[i].y)); 
        inlierPts2.push_back(cv::Vec2d(points2[i].x, points2[i].y)); 
      } 
    } 

    // undistort and normalize the image points 
    std::vector<cv::Vec2d> points1u; 
    cv::undistortPoints(inlierPts1, points1u,  
                        cameraMatrix, cameraDistCoeffs); 
    std::vector<cv::Vec2d> points2u; 
    cv::undistortPoints(inlierPts2, points2u,  
                        cameraMatrix, cameraDistCoeffs); 

    // triangulation 
    std::vector<cv::Vec3d> points3D; 
    triangulate(projection1, projection2,  
                points1u, points2u, points3D); 

在场景元素表面的 3D 点云因此被发现:

如何做...

注意,从新的角度来看,我们可以看到我们绘制的两条射线并没有像预期的那样相交。这个事实将在下一节中讨论。

工作原理...

校准矩阵是允许我们将像素坐标转换为世界坐标的实体。然后我们可以更容易地将图像点与产生它们的 3D 点联系起来。以下图示展示了这一点,我们将用它来演示世界点与其图像之间的一种简单关系:

工作原理...

图像显示了两个通过旋转R和平移T分开的相机。值得注意的是,平移向量T连接了两个相机的投影中心。我们还有一个向量x连接第一个相机中心到一个图像点,以及一个向量x'连接第二个相机中心到相应的图像点。由于我们有两个相机之间的相对运动,我们可以用第二个相机的参考来表示x的方向,即Rx。现在,如果你仔细观察显示的图像点的几何形状,你会注意到向量TRxx'都是共面的。这个事实可以用以下数学关系来表示:

工作原理...

由于叉积也可以通过矩阵运算来表示,因此可以将第一个关系式简化为一个单一的3x3矩阵E。这个矩阵E被称为基本矩阵,与之相关的方程是第十章“在图像中估计投影关系”中介绍的“计算图像对的基本矩阵”食谱中提出的共线约束的校准等效。然后,我们可以从图像对应关系中估计这个矩阵,就像我们估计基本矩阵一样,但这次是在世界坐标系中表达这些对应关系。此外,正如所演示的,基本矩阵是由两个相机之间运动的旋转和平移分量构成的。这意味着一旦估计了这个矩阵,就可以将其分解以获得相机之间的相对姿态。这正是我们通过使用cv::recoverPose函数所做的事情。这个函数调用cv::decomposeEssentialMat函数,该函数产生四个可能的相对姿态解。正确的一个是通过查看提供的匹配集来确定的,以确定哪个解在物理上是可能的。

一旦获得了相机之间的相对姿态,通过三角测量就可以恢复与匹配对对应的任何点的位置。已经提出了不同的方法来解决三角测量问题。可能最简单的解决方案是考虑两个投影矩阵,PP'。在齐次坐标中寻找 3D 点可以表示为X=[X,Y,Z,1]^T,我们知道x=PXx'=P'X。这两个齐次方程中的每一个都带来两个独立的方程,这足以解决 3D 点位置的三个未知数。这个方程组过定系统可以使用最小二乘法求解,这可以通过一个方便的 OpenCV 实用函数cv::solve来完成。完整的函数如下:

    // triangulate using Linear LS-Method 
    cv::Vec3d triangulate(const cv::Mat &p1,  
                          const cv::Mat &p2,                 
                          const cv::Vec2d &u1,  
                          const cv::Vec2d &u2) { 

    // system of equations assuming image=[u,v] and X=[x,y,z,1] 
    // from u(p3.X)= p1.X and v(p3.X)=p2.X 
    cv::Matx43d A(u1(0)*p1.at<double>(2, 0) - p1.at<double>(0, 0),  
                  u1(0)*p1.at<double>(2, 1) - p1.at<double>(0, 1),                      
                  u1(0)*p1.at<double>(2, 2) - p1.at<double>(0, 2),   
                  u1(1)*p1.at<double>(2, 0) - p1.at<double>(1, 0),                 
                  u1(1)*p1.at<double>(2, 1) - p1.at<double>(1, 1),   
                  u1(1)*p1.at<double>(2, 2) - p1.at<double>(1, 2),  
                  u2(0)*p2.at<double>(2, 0) - p2.at<double>(0, 0),  
                  u2(0)*p2.at<double>(2, 1) - p2.at<double>(0, 1),  
                  u2(0)*p2.at<double>(2, 2) - p2.at<double>(0, 2),  
                  u2(1)*p2.at<double>(2, 0) - p2.at<double>(1, 0),          
                  u2(1)*p2.at<double>(2, 1) - p2.at<double>(1, 1),    
                  u2(1)*p2.at<double>(2, 2) - p2.at<double>(1, 2)); 

    cv::Matx41d B(p1.at<double>(0, 3) - u1(0)*p1.at<double>(2, 3), 
                  p1.at<double>(1, 3) - u1(1)*p1.at<double>(2, 3), 
                  p2.at<double>(0, 3) - u2(0)*p2.at<double>(2, 3),  
                  p2.at<double>(1, 3) - u2(1)*p2.at<double>(2, 3)); 

    // X contains the 3D coordinate of the reconstructed point 
    cv::Vec3d X; 
    // solve AX=B 
    cv::solve(A, B, X, cv::DECOMP_SVD); 
    return X; 
  } 

在前一个章节中,我们已经注意到,由于噪声和数字化,通常应该相交的投影线在实际中并不相交。因此,最小二乘解将因此找到一个位于交点附近的解。此外,如果你尝试重建一个无穷远点,这种方法将不起作用。这是因为,对于这样的点,齐次坐标的第四个元素应该是0,而不是假设的1

最后,重要的是要理解这个 3D 重建仅基于一个比例因子。如果你需要进行实际测量,你需要知道至少一个物理距离,例如,两个相机之间的实际距离或可见物体之一的高度。

还有更多...

3D 重建是计算机视觉中的一个研究领域,在 OpenCV 库中还有更多关于这个主题的内容可以探索。

投影矩阵分解

在这个配方中,我们了解到可以通过分解基本矩阵来恢复两个相机之间的旋转和平移。我们还在上一章中了解到,在平面的两个视图中存在一个单应性。在这种情况下,这个单应性还包含旋转和平移分量。此外,它还包含有关平面的信息,即每个相机相对于平面的法线。可以使用cv::decomposeHomographyMat函数来分解这个矩阵;然而,条件是必须有一个校准过的相机。

套索调整

在这个配方中,我们首先从匹配中估计相机位置,然后通过三角测量重建相关的 3D 点。可以通过使用任意数量的视图来泛化这个过程。对于这些视图中的每一个,都会检测特征点并与其他视图进行匹配。利用这些信息,可以写出关联视图之间旋转和平移的方程,以及 3D 点集和校准信息。所有这些未知数可以通过一个旨在最小化每个视图中所有可见点重投影误差的大规模优化过程一起优化。这个联合优化过程被称为 捆绑调整。看看 cv::detail::BundleAdjusterReproj 类,它实现了一个相机参数细化算法,该算法最小化重投影误差平方和。

参见

  • 《三角测量》R. HartleyP. Sturm《计算机视觉与图像理解》 第 68 卷,第 2 期,1997 年发表,对不同的三角测量方法进行了形式化分析

  • 《从互联网照片集合中建模世界》N. SnavelyS.M. SeitzR. Szeliski《国际计算机视觉杂志》 第 80 卷,第 2 期,2008 年描述了通过捆绑调整进行大规模 3D 重建的应用

从立体图像计算深度

人类用两只眼睛以三维方式观察世界。当机器人装备了两台相机时,它们也能做到同样的事情。这被称为 立体视觉。立体装置是一对安装在设备上的相机,朝同一场景看,并且通过一个固定的基线(两台相机之间的距离)分离。这个配方将向您展示如何通过计算两个视图之间的密集对应关系,从两幅立体图像中计算深度图。

准备中

立体视觉系统通常由两个并排的相机组成,朝同一方向看。以下图示了一个完美对齐的立体系统:

准备中

在这种理想配置下,相机之间仅通过水平平移分离,因此所有极线都是水平的。这意味着对应点具有相同的 y 坐标,这将匹配搜索减少到一维线。它们 x 坐标的差异取决于点的深度。无穷远处的点具有相同的 (x,y) 像素坐标,而点越接近立体装置,它们的 x 坐标差异就越大。这一事实可以通过观察投影方程来形式化证明。当相机通过纯水平平移分离时,第二台相机的投影方程(右侧的相机)变为如下:

准备中

这里,为了简单起见,我们假设像素是正方形的,并且两个相机具有相同的校准参数。现在,如果你计算x-x'的差异(不要忘记除以s以归一化齐次坐标)并隔离z坐标,你将得到以下结果:

准备中

术语(x-x')被称为视差。为了计算立体视觉系统的深度图,必须估计每个像素的视差。这个方法将向你展示如何操作。

如何做...

在上一节中展示的理想配置在实际上非常难以实现。即使它们被精确地定位,立体装置中的相机不可避免地会包含一些额外的平移和旋转分量。但幸运的是,可以通过计算立体系统的基本矩阵来实现图像的校正,从而产生水平共线线。这可以通过使用例如上一章中提到的鲁棒匹配算法来实现。这就是我们为以下立体对(在其上绘制了一些共线线)所做的工作:

如何做...

OpenCV 提供了一个校正函数,它使用单应性变换将每个相机的图像平面投影到完美对齐的虚拟平面上。这种变换是从一组匹配点和基本矩阵计算得出的。一旦计算出来,这些单应性就被用来包裹图像:

    // Compute homographic rectification 
    cv::Mat h1, h2; 
    cv::stereoRectifyUncalibrated(points1, points2,  
                                  fundamental,  
                                  image1.size(), h1, h2); 

    // Rectify the images through warping 
    cv::Mat rectified1; 
    cv::warpPerspective(image1, rectified1, h1, image1.size()); 
    cv::Mat rectified2; 
    cv::warpPerspective(image2, rectified2, h2, image1.size()); 

对于我们的示例,校正后的图像对如下:

如何做...

可以使用假设相机平行性(从而产生水平共线线)的方法来计算视差图:

    // Compute disparity 
    cv::Mat disparity; 
    cv::Ptr<cv::StereoMatcher> pStereo =  
         cv::StereoSGBM::create(0,   // minimum disparity 
                                32,  // maximum disparity 
                                5);  // block size 
    pStereo->compute(rectified1, rectified2, disparity); 

获得的视差图可以显示为图像。亮值对应高视差,根据我们在本食谱中早些时候学到的,这些高视差值对应近处的物体:

如何做...

计算得到的视差质量主要取决于组成场景的不同物体的外观。高度纹理的区域往往会产生更准确的视差估计,因为它们可以被非歧义地匹配。此外,更大的基线增加了可检测深度值的范围。然而,扩大基线也会使视差计算更加复杂且不可靠。

它是如何工作的...

计算视差是一项像素匹配练习。我们之前提到,当图像被正确校正时,搜索空间方便地与图像行对齐。然而,困难在于,在立体视觉中,我们通常寻求密集的视差图,也就是说,我们希望将一个图像的每个像素与另一个图像的像素匹配。

这可能比在图像中选择几个独特的点并找到它们在另一图像中的对应点更具挑战性。因此,视差计算是一个复杂的过程,通常由四个步骤组成:

  1. 匹配成本计算。

  2. 成本聚合。

  3. 差异计算和优化。

  4. 差异细化。

这些步骤在下一段中详细说明。

将一个像素分配给差异,是在立体集中将一对点对应起来。寻找最佳差异图通常被提出为一个优化问题。从这个角度来看,匹配两个点有一个必须按照定义的度量计算的代价。这可以是,例如,简单绝对或平方的强度、颜色或梯度的差异。在寻找最优解的过程中,匹配代价通常在一个区域内聚合,以应对噪声和局部模糊。然后可以通过评估一个包含平滑差异图、考虑任何可能的遮挡并强制唯一性约束的能量函数来估计全局差异图。最后,通常会应用后处理步骤来细化差异估计,在此期间,例如,检测平面区域或检测深度不连续性。

OpenCV 实现了许多差异计算方法。在这里,我们使用了cv::StereoSGBM方法。最简单的方法是cv::StereoBM,它基于块匹配。

最后,需要注意的是,如果你准备进行完整的校准过程,可以执行更精确的校正。在这种情况下,cv::stereoCalibratecv::stereoRectify函数与校准图案一起使用。校正映射随后计算相机的新的投影矩阵,而不是简单的单应性。

参见

  • D. Scharstein 和 R. Szeliski 在 2002 年发表的《International Journal of Computer Vision》第 47 卷上的文章“A Taxonomy and Evaluation of Dense two-Frame Stereo Correspondence Algorithms”是关于差异计算方法的经典参考文献。

  • H. Hirschmuller 在 2008 年发表的《IEEE Transactions on Pattern Analysis and Machine Intelligence》第 30 卷第 2 期上的文章“Stereo processing by semiglobal matching and mutual information”描述了在此配方中计算差异所使用的方法。

第十二章. 处理视频序列

在本章中,我们将涵盖以下配方:

  • 读取视频序列

  • 处理视频帧

  • 写入视频序列

  • 从视频中提取前景对象

简介

视频信号构成了丰富的视觉信息来源。它们由一系列图像组成,称为,这些图像以固定的时间间隔(指定为帧率,通常以每秒帧数表示)拍摄,并显示一个动态场景。随着强大计算机的出现,现在可以在视频序列上执行高级视觉分析——有时接近或甚至超过实际视频帧率。本章将向您展示如何读取、处理和存储视频序列。

我们将看到,一旦提取了视频序列的各个帧,就可以将本书中介绍的不同图像处理函数应用于每个帧。此外,我们还将探讨执行视频序列时间分析的算法,比较相邻帧,并随时间累积图像统计信息,以提取前景对象。

读取视频序列

为了处理视频序列,我们需要能够读取其每一帧。OpenCV 提供了一个易于使用的框架,可以帮助我们从视频文件或甚至从 USB 或 IP 摄像头中提取帧。这个配方将向你展示如何使用它。

如何做到这一点...

基本上,你只需要创建一个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(); 
      return 0; 
    } 

将会弹出一个窗口,视频将在其中播放,如下面的截图所示:

如何做到这一点...

它是如何工作的...

要打开视频,你只需指定视频文件名。这可以通过在cv::VideoCapture对象的构造函数中提供文件名来实现。如果已经创建了cv::VideoCapture对象,也可以使用open方法。一旦视频成功打开(可以通过isOpened方法进行验证),就可以开始帧提取。还可以通过使用带有适当标志的get方法查询与视频文件相关的cv::VideoCapture对象信息。在先前的示例中,我们使用CV_CAP_PROP_FPS标志获取了帧率。由于它是一个通用函数,它总是返回一个双精度浮点数,即使在某些情况下预期返回其他类型。例如,视频文件中的总帧数可以通过以下方式获取(作为一个整数):

    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。请注意,获取或设置特定视频参数的可能性很大程度上取决于用于压缩和存储视频序列的编解码器。如果你在某些参数上不成功,那可能只是因为你使用的特定编解码器。

一旦成功打开捕获的视频,就可以通过重复调用read方法来按顺序获取帧,就像我们在上一节的例子中所做的那样。也可以等价地调用重载的读取操作符:

    capture >> frame; 

还可以调用两个基本方法:

    capture.grab(); 
    capture.retrieve(frame); 

还要注意,在我们的例子中,我们引入了显示每个帧的延迟。这是通过使用cv::waitKey函数实现的。在这里,我们将延迟设置为与输入视频帧率相对应的值(如果fps是每秒的帧数,那么两个帧之间的延迟以毫秒为单位就是1/fps)。显然,你可以改变这个值来以较慢或较快的速度显示视频。然而,如果你打算显示视频帧,确保窗口有足够的时间刷新是很重要的(因为这是一个低优先级的过程,如果 CPU 太忙,它将永远不会刷新)。cv::waitKey函数还允许我们通过按任意键来中断读取过程。在这种情况下,函数返回按下的键的 ASCII 码。请注意,如果指定给cv::waitKey函数的延迟是0,那么它将无限期地等待用户按下键。如果有人想通过逐帧检查结果来跟踪一个过程,这非常有用。

最后一条语句调用release方法,这将关闭视频文件。然而,这个调用不是必需的,因为release也会在cv::VideoCapture析构函数中被调用。

重要的是要注意,为了打开指定的视频文件,你的计算机必须安装相应的编解码器;否则,cv::VideoCapture将无法解码输入文件。通常,如果你能在你的机器上的视频播放器(如 Windows Media Player)中打开你的视频文件,那么 OpenCV 也应该能够读取这个文件。

还有更多...

您还可以读取连接到您计算机的摄像头(例如 USB 摄像头)产生的视频流。在这种情况下,您只需将 ID 号(一个整数)指定给 open 函数,而不是文件名。将 ID 指定为 0 将打开默认安装的摄像头。在这种情况下,cv::waitKey 函数停止处理的作用变得至关重要,因为来自摄像头的视频流将被无限读取。

最后,从 Web 加载视频也是可能的。在这种情况下,您只需提供正确的地址即可,例如:

    cv::VideoCapture capture("http://www.laganiere.name/bike.avi"); 

参见

  • 本章中的 写入视频序列 菜谱提供了有关视频编解码器的更多信息。

  • ffmpeg.org/ 网站提供了一个完整的开源和跨平台解决方案,用于音频/视频的读取、录制、转换和流式传输。OpenCV 处理视频文件的类建立在上述库之上。

处理视频帧

在这个菜谱中,我们的目标是将对视频序列中的每一帧应用一些处理函数。我们将通过将 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::COLOR_BGR2GRAY); 
      // Compute Canny edges 
      cv::Canny(out,out,100,200); 
      // Invert the image 
      cv::threshold(out,out,128,255,cv::THRESH_BINARY_INV); 
    } 

我们的 VideoProcessor 类封装了视频处理任务的所有方面。使用这个类,步骤将是创建一个类实例,指定输入视频文件,将其回调函数附加到它,然后开始处理。程序上,这些步骤是通过我们提出的类来完成的,如下所示:

      // 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(); 

如果运行此代码,则两个窗口将以原始帧率播放输入视频和输出结果(这是由 setDelay 方法引入的延迟的结果)。例如,考虑上一个菜谱中显示的输入视频,输出窗口将如下所示:

如何做...

它是如何工作的...

正如我们在其他菜谱中所做的那样,我们的目标是创建一个类,该类封装了视频处理算法的常见功能。正如人们所预期的那样,该类包括几个成员变量,用于控制视频帧处理的不同方面:

    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; 

第一个成员变量是 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(); 
        // Open the video file 
        return capture.open(filename); 
      } 

通常,在处理过程中显示帧是很有趣的。因此,使用了两种方法来创建显示窗口:

      // to display the input 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); 
      } 

主方法,称为 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 { 
          // no processing 
          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; 
    } 

此方法使用一个 private 方法来读取帧:

    // to get the next frame  
    // could be: video file or camera 
    bool readNextFrame(cv::Mat& frame) { 
      return capture.read(frame); 
    } 

run 方法首先调用 cv::VideoCapture 类的读取方法。然后执行一系列操作,但在调用每个操作之前,都会进行检查以确定是否已请求。只有当指定了输入窗口名称(使用 displayInput 方法)时,才会显示输入窗口;只有当指定了回调函数(使用 setFrameProcessor 方法)时,才会调用回调函数。只有当定义了输出窗口名称(使用 displayOutput)时,才会显示输出窗口;只有当指定了延迟(使用 setDelay 方法)时,才会引入延迟。最后,如果定义了停止帧(使用 stopAtFrameNo 方法),则会检查当前帧号。

可能还希望简单地打开并播放视频文件(不调用回调函数)。因此,我们有两个方法来指定是否调用回调函数:

    // 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;  
    } 

该类还包含了一些 getter 和 setter 方法,基本上只是 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; 
    }; 

一个设置方法允许您将一个FrameProcessor实例输入到VideoProcessor框架中,并将其分配给定义为指向FrameProcessor对象的指针的添加的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(); 
    } 

当指定了一个帧processor类实例时,它将使之前可能已设置的任何帧处理函数无效。如果指定了帧处理函数,同样适用。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类接口的例子。

  • GitHub 项目github.com/asolis/vivaVideo展示了在 OpenCV 中使用多线程处理视频的更复杂的框架。

编写视频序列

在前面的配方中,我们学习了如何读取视频文件并提取其帧。这个配方将向您展示如何写入帧,从而创建视频文件。这将使我们能够完成典型的视频处理链:读取输入视频流,处理其帧,然后将结果存储在新视频文件中。

如何做到这一点...

在 OpenCV 中,使用cv::VideoWriter类来编写视频文件。通过指定文件名、生成视频应播放的帧率、每帧的大小以及视频是否以彩色创建来构造一个实例:

    writer.open(outputFile,     // filename 
                codec,          // codec to be used  
                framerate,      // frame rate of the video 
                frameSize,      // frame size 
                isColor);       // color video? 

此外,您必须指定您想要保存视频数据的方式。这是codec参数;这将在本配方的末尾讨论。

一旦视频文件被打开,可以通过重复调用write方法将其添加帧:

    writer.write(frame);   // add the frame to the video file 

使用cv::VideoWriter类,我们前面配方中引入的VideoProcessor类可以很容易地扩展,以便赋予它写入视频文件的能力。一个简单的程序将读取视频,处理它,并将结果写入视频文件,如下所示:

    // 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",  //prefix 
                        ".jpg",     // extension 
                        3,          // number of digits 
                        0);         // starting index 

使用指定的数字位数,这个调用将创建bikeOut000.jpgbikeOut001.jpgbikeOut002.jpg等文件。

它是如何工作的...

现在让我们描述如何修改我们的VideoProcessor类,以便赋予它写入视频文件的能力。首先,必须向我们的类中添加一个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? 
    } 

一个名为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(); 
    } 
  } 

还有更多...

当将视频写入文件时,它使用编解码器进行保存。编解码器是一个能够对视频流进行编码和解码的软件模块。编解码器定义了文件的格式以及用于存储信息的压缩方案。显然,使用给定编解码器编码的视频必须使用相同的编解码器进行解码。因此,引入了四字符代码来唯一标识编解码器。这样,当软件工具需要写入视频文件时,它通过读取指定的四字符代码来确定要使用的编解码器。

编解码器四字符代码

如其名所示,四字符代码由四个 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 
        nt 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值,然后将其转换为integer。这个整数代表一个代码,可以使用union数据结构从中提取四个字符。如果我们打开我们的测试视频序列,那么我们将有以下语句:

    char codec[4]; 
    processor.getCodec(codec); 
    std::cout << "Codec: " << codec[0] << codec[1] 
              << codec[2] << codec[3] << std::endl; 

从前面的语句中,我们得到以下内容,针对我们的示例:

    Codec : XVID 

当写入视频文件时,必须使用其四字符代码指定编解码器。这是cv::VideoWriteropen方法的第二个参数。例如,您可以使用与输入视频相同的编解码器(这是我们的setOutput方法中的默认选项)。您还可以传递值-1,此时方法将弹出一个窗口,让您从可用的编解码器列表中选择一个编解码器。您将在该窗口中看到的列表对应于您机器上安装的编解码器列表。所选编解码器的代码随后将自动发送到open方法。

参见

  • www.xvid.com/网站为您提供了一个基于 MPEG-4 标准的开源视频编解码器库。Xvid还有一个竞争对手叫做DivX,它提供专有但免费的编解码器和软件工具。

从视频中提取前景对象

本章主要介绍阅读、编写和视频序列的处理。目标是能够分析完整的视频序列。作为一个例子,在本食谱中,你将学习如何执行序列的时间分析,以提取移动的前景物体。实际上,当固定摄像机观察一个场景时,背景基本上保持不变。在这种情况下,有趣的是场景内部移动的物体。为了提取这些前景物体,我们需要构建一个背景模型,然后将其与当前帧进行比较,以检测任何前景物体。这正是本食谱要做的。前景提取是智能监控应用中的基本步骤。

如果我们手头有一幅场景背景的图像(即,一个不包含前景物体的框架),那么通过简单的图像差分就可以轻松地提取当前帧的前景:

    // compute difference between current image and background 
    cv::absdiff(backgroundImage,currentImage,foreground); 

对于这个差值足够高的每个像素,将被宣布为前景像素。然而,大多数情况下,这个背景图像并不容易获得。实际上,可能很难保证给定图像中没有前景物体,在繁忙的场景中,这种情况可能很少发生。此外,背景场景通常会随着时间的推移而变化,例如,由于光照条件的变化(例如,从日出到日落)或因为新物体被添加到背景或从背景中移除。

因此,有必要动态地构建背景场景的模型。这可以通过观察场景一段时间来实现。如果我们假设大多数情况下,背景在每个像素位置都是可见的,那么简单地计算所有观察的平均值可能是一个好的策略。然而,由于多种原因,这并不可行。首先,这需要在计算背景之前存储大量的图像。其次,当我们积累图像来计算平均图像时,不会进行前景提取。这种解决方案还提出了何时以及需要积累多少图像来计算可接受的背景模型的问题。此外,观察到一个像素正在观察前景物体的图像将对平均背景的计算产生影响。

一个更好的策略是动态地通过定期更新来构建背景模型。这可以通过计算所谓的运行平均值(也称为移动平均值)来实现。这是一种计算时间信号平均值的办法,它考虑了最新接收到的值。如果p[t]是给定时间t处的像素值,而μ[t-1]是当前的平均值,那么这个平均值将使用以下公式进行更新:

在视频中提取前景物体

α参数被称为学习率,它定义了当前值对当前估计平均值的影响。这个值越大,运行平均值就越快适应观察值的变化,但与此同时,当学习率设置得太高时,缓慢移动的对象倾向于在背景中消失。实际上,适当的学习率在很大程度上取决于场景的动态。为了构建背景模型,只需对输入帧的每个像素计算运行平均值。然后,根据当前图像与背景模型之间的差异来简单地决定是否声明前景像素。

如何做...

让我们构建一个类,该类将使用移动平均学习背景模型,并通过减法提取前景对象。所需的属性如下:

    class BGFGSegmentor : public FrameProcessor { 
      cv::Mat gray;          // current gray-level image 
      cv::Mat background;    // accumulated background 
      cv::Mat backImage;     // current background image 
      cv::Mat foreground;    // foreground image 
      // learning rate in background accumulation 
      double learningRate; 
      int threshold;         // threshold for foreground extraction 

主要过程包括将当前帧与背景模型进行比较,然后更新此模型:

    // processing method 
    void process(cv:: Mat &frame, cv:: Mat &output) { 
      // convert to gray-level image 
      cv::cvtColor(frame, gray, cv::COLOR_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,  
                             // alpha*gray + (1-alpha)*background 
                             learningRate,  // alpha 
                             output);       // mask 
    } 

使用我们的视频处理框架,前景提取程序将构建如下:

    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(); 
    } 

将要显示的其中一个二值前景图像如下:

如何做...

它是如何工作的...

通过cv::accumulateWeighted函数计算图像的运行平均值是很容易实现的,该函数将运行平均值公式应用于图像的每个像素。请注意,结果图像必须是一个浮点图像。这就是为什么我们不得不在将背景模型与当前帧比较之前将其转换为背景图像。通过cv::absdiff后跟cv::threshold计算的一个简单的阈值绝对差值(提取前景图像)。请注意,我们随后使用前景图像作为掩码来cv::accumulateWeighted,以避免更新被声明为前景的像素。这是因为我们的前景图像在前景像素上定义为false(即0),这也解释了为什么前景对象在结果图像中显示为黑色像素)。

最后,需要注意的是,为了简化,我们程序构建的背景模型基于提取帧的灰度版本。保持彩色背景将需要在某些颜色空间中计算运行平均值。正如参数化视觉算法通常的情况一样,所提出的方法中的主要困难是确定适当的阈值,以便为给定的视频提供良好的结果。

还有更多...

之前简单的方法在场景中提取前景对象效果良好,适用于显示相对稳定背景的简单场景。然而,在许多情况下,背景场景可能在某些区域之间在不同值之间波动,从而造成频繁的错误前景检测。这些可能是由例如移动的背景对象(例如树叶)或刺眼的效果(例如水面上)引起的。投射的阴影也造成问题,因为它们通常被检测为移动对象的一部分。为了应对这些问题,已经引入了更复杂的背景建模方法。

高斯混合方法

这些算法之一是高斯混合方法。它以与本文献中介绍的方法类似的方式进行,但增加了一些改进。

首先,该方法为每个像素维护多个模型(即多个运行平均值)。这样,如果一个背景像素在两个值之间波动,比如,两个运行平均值就会被存储。只有当一个新像素值不属于观察到的最频繁的任何模型时,才会将其宣布为前景。所使用的模型数量是该方法的参数之一,一个典型值是5

其次,不仅为每个模型维护运行平均值,还维护运行方差。计算方法如下:

高斯混合方法

这些计算出的平均值和方差被用来构建一个高斯模型,从而可以估计给定像素值属于背景的概率。这使得确定适当的阈值变得更容易,因为它现在是以概率而不是绝对差异的形式表达的。因此,在背景值波动较大的区域,需要更大的差异来宣布前景对象。

最后,这是一个自适应模型,也就是说,如果一个给定的高斯模型没有被足够频繁地命中,它就会被排除在背景模型之外。相反,当一个像素值被发现目前维护的背景模型之外(即它是前景像素)时,就会创建一个新的高斯模型。如果在未来,这个新模型经常接收像素,那么它就与背景相关联。

这个更复杂的算法显然比我们简单的背景/前景分割器更难实现。幸运的是,存在一个 OpenCV 实现,称为cv::bgsegm::createBackgroundSubtractorMOG,它被定义为更通用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; 
      // background image 
      cv::Mat background; 
      cv::namedWindow("Extracted Foreground"); 

      // The Mixture of Gaussian object 
      // used with all default parameters 
      cv::Ptr<cv::BackgroundSubtractor> ptrMOG =
                      cv::bgsegm::createBackgroundSubtractorMOG(); 
      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 
         ptrMOG->apply(frame,foreground,0.01); 

        // Complement the image 
        cv::threshold(foreground,foreground,128, 
                      255,cv::THRESH_BINARY_INV); 
        //show foreground and background 
        cv::imshow("Extracted Foreground",foreground); 

        // introduce a delay 
        // or press key to stop 
        if (cv::waitKey(10)>=0) 
          stop= true; 
      } 
    } 

如您所见,这只是一个创建类实例并调用同时更新背景并返回前景图像(额外参数为学习率)的方法的问题。此外,请注意,这里的背景模型是按颜色计算的。OpenCV 中实现的方法还包括一个机制,通过检查观察到的像素变化是否仅仅是由于亮度(如果是,那么可能是由于阴影)的局部变化来拒绝阴影,或者它是否还包括一些色度变化。

另一个实现也可用,简单称为 cv::BackgroundSubtractorMOG2。其中一个改进是,现在动态确定每个像素要使用的适当高斯模型的数量。您可以在前面的示例中使用这个代替之前的版本。您应该在多个视频上运行这些不同的方法,以便欣赏它们各自的性能。一般来说,您将观察到 cv::BackgroundSubtractorMOG2 要快得多。

参见

  • C. Stauffer 和 W.E.L. Grimso 在 1999 年的 Conf. on Computer Vision and Pattern Recognition 发表的论文 Adaptive Background Mixture Models for Real-Time Tracking 中,对高斯混合算法给出了更完整的描述。

第十三章。跟踪视觉运动

在这一章中,我们将涵盖以下菜谱:

  • 在视频中追踪特征点

  • 估计光流

  • 在视频中跟踪对象

简介

视频序列很有趣,因为它们展示了运动中的场景和对象。上一章介绍了读取、处理和保存视频的工具。在这一章中,我们将探讨不同的算法,这些算法可以跟踪一系列图像中的可见运动。这种可见的或明显的运动可能是由在不同方向和不同速度上移动的对象引起的,或者是由摄像机的运动(或两者的组合)引起的。

在许多应用中,跟踪明显的运动至关重要。它允许你在对象移动时跟踪特定的对象,以估计其速度并确定它们将去哪里。它还允许你通过消除或减少手持摄像机拍摄的视频的摄像机抖动来稳定视频。运动估计也用于视频编码,以便压缩视频序列,以便于其传输或存储。本章将介绍一些跟踪图像序列中运动的算法,并且正如我们将看到的,这种跟踪可以是稀疏的(即在少数图像位置,这是稀疏运动)或密集的(在图像的每个像素上,这是密集运动)。

在视频中追踪特征点

我们在前面章节中了解到,通过分析图像的一些最显著点可以导致有效且高效的计算机视觉算法。这在图像序列中也是正确的,其中某些兴趣点的运动可以用来理解捕获场景的不同元素是如何移动的。在这个菜谱中,你将学习如何通过跟踪特征点从一帧移动到另一帧来执行序列的时间分析。

如何做...

要开始跟踪过程,首先要做的是在初始帧中检测特征点。然后你尝试在下一帧中跟踪这些点。显然,由于我们处理的是视频序列,所以找到特征点的对象很可能已经移动了(这种运动也可能是由摄像机运动引起的)。因此,你必须在一个点的先前位置周围搜索,以找到它在下一帧中的新位置。这正是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 

      // 3\. 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); 

      // 4\. handle the accepted tracked points 
      handleTrackedPoints(frame, output); 

      // 5\. current points and image become previous ones 
      std::swap(points[1], points[0]); 
      cv::swap(gray_prev, gray); 
    } 

此方法使用四个实用方法。你应该很容易更改这些方法中的任何一个,以定义你自己的跟踪器的新行为。这些方法中的第一个用于检测特征点。请注意,我们已经在第八章的“检测兴趣点”的第一个配方中讨论了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] &&  //status is false if unable to track point 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); 
      } 
    } 

要在视频序列中跟踪特征点的一个简单主函数可以写成如下:

    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.setDelay(1000./processor.getFrameRate()); 

      // Start the process 
      processor.run(); 
    } 

生成的程序将显示随时间推移移动跟踪特征点的演变。例如,这里有两个不同时刻的两个这样的帧。在这段视频中,摄像机是固定的。因此,年轻的自行车手是唯一的移动对象。以下是经过几帧处理后的结果:

如何做...

几秒钟后,我们得到以下帧:

如何做...

工作原理...

要从一帧跟踪到另一帧的特征点,我们必须定位后续帧中特征点的新位置。如果我们假设特征点的强度从一帧到下一帧不会改变,我们正在寻找一个位移(u,v)如下:

工作原理...

在这里,I[t]I[t+1]分别是当前帧和下一个时刻的帧。这个常数强度假设通常适用于在两个相邻时刻拍摄的图像中的小位移。然后我们可以使用泰勒展开来近似这个方程,通过涉及图像导数的方程来近似这个方程:

如何工作...

这个后一个方程引出了另一个方程(作为常数强度假设的后果,该假设抵消了两个强度项):

如何工作...

这个约束是基本的光流约束方程,也称为亮度恒常方程

这个约束条件被所谓的卢卡斯-卡纳德特征跟踪算法所利用。除了使用这个约束条件外,卢卡斯-卡纳德算法还假设特征点周围所有点的位移是相同的。因此,我们可以对所有这些点施加一个独特的(u,v)未知位移的光流约束。这给我们带来了比未知数(两个)更多的方程,因此,我们可以以均方误差的方式来解这个方程组。在实践中,它是通过迭代来解决的,OpenCV 的实现也为我们提供了在不同分辨率下执行此估计的可能性,以使搜索更有效,更能容忍较大的位移。默认情况下,图像级别数为3,窗口大小为15。这些参数显然是可以改变的。您还可以指定终止条件,这些条件定义了停止迭代搜索的条件。cv::calcOpticalFlowPyrLK的第六个参数包含用于评估跟踪质量的残差均方误差。第五个参数包含二进制标志,告诉我们跟踪相应的点是否被认为是成功的。

上述描述代表了卢卡斯-卡纳德跟踪器背后的基本原理。当前的实现包含其他优化和改进,使算法在计算大量特征点位移时更加高效。

参见

  • 第八章,检测兴趣点,其中讨论了特征点检测

  • 本章的视频中的物体跟踪配方使用特征点跟踪来跟踪物体

  • B. 卢卡斯T. 卡纳德撰写的经典文章,一种迭代图像配准技术及其在立体视觉中的应用,在国际人工智能联合会议上,第 674-679 页,1981 年,描述了原始的特征点跟踪算法

  • J. Shi 和 C. Tomasi 在 1994 年的IEEE 计算机视觉和模式识别会议上发表的文章《Good Features to Track》描述了原始特征点跟踪算法的一个改进版本

估计光流

当一个场景被相机观察时,观察到的亮度模式被投影到图像传感器上,从而形成一个图像。在视频序列中,我们通常对捕捉运动模式感兴趣,即不同场景元素在图像平面上的 3D 运动的投影。这个投影 3D 运动矢量的图像被称为运动场。然而,从相机传感器直接测量场景点的 3D 运动是不可能的。我们所观察到的只是从一帧到另一帧的亮度模式在运动。这种亮度模式的明显运动被称为光流。有人可能会认为运动场和光流应该是相等的,但事实并非总是如此。一个明显的例子是观察一个均匀的物体;例如,如果相机在白色墙壁前移动,则不会产生光流。

另一个经典例子是由旋转的理发店旗杆产生的错觉:

估计光流

在这种情况下,运动场应该显示在水平方向上的运动矢量,因为垂直圆柱围绕其主轴旋转。然而,观察者将这种运动感知为向上移动的红蓝条纹,这就是光流将显示的内容。尽管存在这些差异,但光流被认为是对运动场的有效近似。这个菜谱将解释如何估计图像序列的光流。

准备工作

估计光流意味着量化图像序列中亮度模式的明显运动。所以让我们考虑视频在某一给定时刻的一帧。如果我们观察当前帧上的一个特定像素(x,y),我们想知道这个点在后续帧中移动的位置。也就是说,这个点的坐标随时间变化——这可以用(x(t),y(t))来表示——我们的目标是估计这个点的速度(dx/dt,dy/dt)。在时间t时,这个特定点的亮度可以通过查看序列中的对应帧来获得,即I(x(t),y(t),t)

从我们的图像亮度恒常假设出发,我们可以写出这个点的亮度不会随时间变化:

准备工作

链式法则允许我们写出以下内容:

准备工作

这个方程被称为亮度恒常方程,它将光流分量(xy随时间的导数)与图像导数相关联。这正是我们在前一个菜谱中推导出的方程;我们只是以不同的方式演示了它。

这个单一方程(由两个未知数组成)然而不足以计算像素位置处的光流。因此,我们需要添加一个额外的约束。一个常见的选择是假设光流的平滑性,这意味着相邻的光流向量应该是相似的。因此,任何偏离这个假设的情况都应该受到惩罚。这个约束的一个特定公式是基于光流的拉普拉斯算子:

准备就绪

因此,目标是找到最小化亮度恒常方程和光流向量拉普拉斯算子偏差的光流场。

如何做...

已经提出了几种方法来解决密集光流估计问题,OpenCV 实现了其中的一些。让我们使用cv::DualTVL1OpticalFlow类,它被构建为通用cv::Algorithm基类的子类。按照实现的模式,首先要做的是创建这个类的实例并获取它的指针:

    //Create the optical flow algorithm 
    cv::Ptr<cv::DualTVL1OpticalFlow> tvl1 = cv::createOptFlow_DualTVL1(); 

由于我们刚刚创建的对象处于可使用状态,我们只需调用计算两个帧之间光流场的方法:

    cv::Mat oflow;   // image of 2D flow vectors 
    //compute optical flow between frame1 and frame2 
    tvl1->calc(frame1, frame2, oflow); 

结果是一个表示两个帧之间每个像素位移的二维向量(cv::Point)的图像。为了显示结果,我们必须因此显示这些向量。这就是为什么我们创建了一个用于生成光流场图像映射的函数。为了控制向量的可见性,我们使用了两个参数。第一个参数是一个步长值,它被定义为在一定数量的像素中只显示一个向量。这个步长为向量的显示留出了空间。第二个参数是一个缩放因子,它扩展了向量的长度,使其更明显。然后,每个绘制的光流向量都是一个简单的线,以一个普通的圆圈结束,以象征箭头的尖端。因此,我们的映射函数如下:

    // Drawing optical flow vectors on an image 
    void drawOpticalFlow(const cv::Mat& oflow,  // the optical flow 
          cv::Mat& flowImage,      // the produced image 
          int stride,              // the stride for displaying the vectors 
          float scale,             // multiplying factor for the vectors 
          const cv::Scalar& color) // the color of the vectors 
    { 
      // create the image if required 
      if (flowImage.size() != oflow.size()) { 
        flowImage.create(oflow.size(), CV_8UC3); 
        flowImage = cv::Vec3i(255,255,255); 
      } 

      //for all vectors using stride as a step 
      for (int y = 0; y < oflow.rows; y += stride) 
        for (int x = 0; x < oflow.cols; x += stride) { 
          //gets the vector 
          cv::Point2f vector = oflow.at< cv::Point2f>(y, x); 
          // draw the line      
          cv::line(flowImage, cv::Point(x,y), 
                   cv::Point(static_cast<int>(x + scale*vector.x + 0.5),             
                             static_cast<int>(y + scale*vector.y + 0.5)),
                   color); 
          // draw the arrow tip 
          cv::circle(flowImage, 
                     cv::Point(static_cast<int>(x + scale*vector.x + 0.5),  
                               static_cast<int>(y + scale*vector.y + 0.5)),
                     1, color, -1); 
        } 
    } 

考虑以下两个帧:

如何做...

如果使用这些帧,则可以通过调用我们的绘图函数来可视化估计的光流场:

    // Draw the optical flow image 
    cv::Mat flowImage; 
    drawOpticalFlow(oflow,                // input flow vectors 
                    flowImage,            // image to be generated 
                    8,                    // display vectors every 8 pixels 
                    2,                    // multiply size of vectors by 2 
                    cv::Scalar(0, 0, 0)); // vector color 

结果如下:

如何做...

它是如何工作的...

在本食谱的第一节中,我们解释了可以通过最小化一个结合亮度恒常约束和光滑性函数的函数来估计光流场。我们提出的方程构成了问题的经典公式,并且这个公式已经以多种方式得到了改进。

在上一节中我们使用的方法被称为Dual TV L1方法。它有两个主要成分。第一个成分是使用一个平滑约束,旨在最小化光流梯度的绝对值(而不是它的平方)。这种选择减少了平滑项的影响,尤其是在不连续区域,例如,移动物体的光流向量与其背景的光流向量有很大的不同。第二个成分是使用一阶泰勒近似;这线性化了亮度恒常约束的公式。我们在这里不会深入这个公式的细节;只需说这种线性化有助于光流场的迭代估计即可。然而,由于线性近似仅在小的位移下有效,因此该方法需要一个从粗到细的估计方案。

在这个配方中,我们使用默认参数使用这种方法。一些 setter 和 getter 方法允许你修改那些可能影响解决方案质量和计算速度的参数。例如,可以修改在金字塔估计中使用的缩放级别数量,或在每次迭代估计步骤中指定一个更严格或更宽松的停止标准。另一个重要参数是与亮度恒常约束相比的平滑约束的权重。例如,如果我们减少对亮度恒常重要性的考虑,那么我们就会得到一个更平滑的光流场:

    // compute a smoother optical flow between 2 frames 
    tvl1->setLambda(0.075); 
    tvl1->calc(frame1, frame2, oflow); 

工作原理...

参见

  • B.K.P. HornB.G. Schunck撰写的文章《在人工智能中确定光流》,发表于 1981 年,是光流估计的经典参考文献。

  • C. ZachT. PockH. Bischof撰写的文章《基于对偶的实时 tv-l 1 光流方法》,发表在 2007 年的IEEE 计算机视觉与模式识别会议上,详细描述了Dual TV-L1方法的细节。

视频中跟踪物体

在前两个配方中,我们学习了如何跟踪图像序列中点和像素的运动。然而,在许多应用中,要求是跟踪视频中的特定移动物体。首先识别感兴趣的物体,然后必须在一个长序列中跟踪它。这是具有挑战性的,因为随着它在场景中的演变,这个物体的图像将因视角和光照变化、非刚性运动、遮挡等因素而经历许多外观变化。

本食谱展示了在 OpenCV 库中实现的一些目标跟踪算法。这些实现基于一个通用框架,便于用一种方法替换另一种方法。贡献者还提供了一些新的方法。请注意,我们已经在第四章的“使用积分图像计数像素”食谱中提出了解决目标跟踪问题的方案,计数像素使用直方图;这个方案是基于通过积分图像计算出的直方图的使用。

如何操作...

视觉目标跟踪问题通常假设没有关于要跟踪的对象的先验知识。因此,跟踪是通过在帧中识别对象来启动的,跟踪必须从这个点开始。通过指定一个包含目标的边界框来实现对象的初始识别。跟踪模块的目标是在后续帧中重新识别这个对象。

因此,OpenCV 中定义对象跟踪框架的cv::Tracker类有两个主要方法。第一个是init方法,用于定义初始目标边界框。第二个是update方法,它根据新帧输出一个新的边界框。这两个方法都接受一个帧(一个cv::Mat实例)和一个边界框(一个cv::Rect2D实例)作为参数;在一种情况下,边界框是输入,而在第二种情况下,边界框是输出参数。

为了测试所提出的目标跟踪算法之一,我们使用上一章中提出的视频处理框架。特别是,我们定义了一个帧处理子类,当接收到图像序列的每一帧时,将由我们的VideoProcessor类调用。这个子类具有以下属性:

    class VisualTracker : public FrameProcessor { 

      cv::Ptr<cv::Tracker> tracker; 
      cv::Rect2d box; 
      bool reset; 

      public: 
      // constructor specifying the tracker to be used 
      VisualTracker(cv::Ptr<cv::Tracker> tracker) :   
                    reset(true), tracker(tracker) {} 

当通过指定新目标边界框重新初始化跟踪器时,reset属性被设置为true。用于存储新对象位置的setBoundingBox方法就是用来做的:

   // set the bounding box to initiate tracking 
   void setBoundingBox(const cv::Rect2d& bb) { 
      box = bb; 
      reset = true; 
   } 

用于处理每一帧的回调方法只是简单地调用跟踪器的适当方法,并在要显示的帧上绘制新的计算出的边界框:

    // callback processing method 
    void process(cv:: Mat &frame, cv:: Mat &output) { 

      if (reset) { // new tracking session 
        reset = false; 
        tracker->init(frame, box); 

      } else { 
        // update the target's position 
        tracker->update(frame, box); 
      } 

      // draw bounding box on current frame 
      frame.copyTo(output); 
      cv::rectangle(output, box, cv::Scalar(255, 255, 255), 2); 
    } 

为了演示如何使用VideoProcessorFrameProcessor实例跟踪一个对象,我们使用 OpenCV 中定义的中值流跟踪器

    int main(){ 
      // Create video procesor instance 
      VideoProcessor processor; 

      // generate the filename 
      std::vector<std::string> imgs; 
      std::string prefix = "goose/goose"; 
      std::string ext = ".bmp"; 

      // Add the image names to be used for tracking 
      for (long i = 130; i < 317; i++) { 

        std::string name(prefix); 
        std::ostringstream ss; ss << std::setfill('0') <<  
                 std::setw(3) << i; name += ss.str(); 
        name += ext; 
        imgs.push_back(name); 
      } 

      // Create feature tracker instance 
      VisualTracker tracker(cv::TrackerMedianFlow::createTracker()); 

      // Open video file 
      processor.setInput(imgs); 

      // set frame processor 
      processor.setFrameProcessor(&tracker); 

      // Declare a window to display the video 
      processor.displayOutput("Tracked object"); 

      // Define the frame rate for display 
      processor.setDelay(50); 

      // Specify the original target position 
      tracker.setBoundingBox(cv::Rect(290,100,65,40)); 

      // Start the tracking 
      processor.run(); 
    } 

第一个边界框识别了我们测试图像序列中的一个鹅。然后,它将在后续帧中自动跟踪:

如何操作...

不幸的是,随着序列的进行,跟踪器不可避免地会犯错误。这些小错误的累积将导致跟踪器逐渐偏离真实目标位置。例如,这是在处理了130帧之后我们目标的估计位置:

如何操作...

最终,跟踪器将失去对物体的跟踪。跟踪器在长时间内跟踪物体的能力是表征物体跟踪器性能的最重要标准。

它是如何工作的...

在这个菜谱中,我们展示了如何使用通用的cv::Tracker类在图像序列中跟踪一个物体。我们选择了 Median Flow 跟踪算法来展示跟踪结果。这是一个简单但有效的方法来跟踪纹理物体,只要其运动不是太快,并且没有被严重遮挡。

Median Flow 跟踪器基于特征点跟踪。它首先从定义一个要跟踪的物体的点阵开始。人们可以用例如在第八章中介绍的FAST算子来检测物体上的兴趣点。然而,使用预定义位置上的点具有许多优点。它通过避免计算兴趣点来节省时间。它保证了将有足够数量的点可用于跟踪。它还确保这些点在整个物体上分布良好。Median Flow 实现默认使用一个10x10点的网格:

如何工作...

下一步是使用本章第一道菜谱中介绍的 Lukas-Kanade 特征跟踪算法,在视频中追踪特征点。然后,网格中的每个点在下一帧中被跟踪:

如何工作...

然后,Median Flow 算法估计在跟踪这些点时产生的误差。这些误差可以通过计算点在初始位置和跟踪位置周围窗口中的绝对像素差之和来估计。这是cv::calcOpticalFlowPyrLK函数方便计算并返回的错误类型。Median Flow 算法提出的另一种误差度量是所谓的正向-反向误差。在点在帧与下一帧之间被跟踪之后,这些点在新位置被反向跟踪以检查它们是否会返回到初始图像中的原始位置。由此获得的正向-反向位置与初始位置之间的差异是跟踪误差。

一旦计算了每个点的跟踪误差,只有具有最小误差的 50%的点被考虑。这个组被用来计算下一个图像中边界框的新位置。这些点中的每一个都对位移值进行投票,并保留这些可能位移的中位数。对于尺度变化,点被成对考虑。估计初始帧和下一帧中两点之间距离的比率。同样,最终应用的是这些尺度中的中位数。

中值跟踪器是许多基于特征点跟踪的视觉对象跟踪器之一。另一类解决方案是基于模板匹配,我们在 第九章 的“匹配局部模板”食谱中讨论了这个概念。这类方法的一个很好的代表是 核相关滤波器 (KCF) 算法,它在 OpenCV 中实现为 cv::TrackerKCF 类:

     VisualTracker tracker(cv::TrackerKCF::createTracker()); 

基本上,这种方法使用目标的边界框作为模板来搜索下一视图中的新对象位置。这通常通过简单的相关性计算得出,但 KCF 使用一种基于傅里叶变换的特殊技巧,我们在 第六章 的“过滤图像”引言中简要提到了它。不深入细节,信号处理理论告诉我们,在图像上对模板进行相关性计算相当于在频域中的简单图像乘法。这大大加快了下一帧中匹配窗口的识别速度,使 KCF 成为最快且最稳健的跟踪器之一。例如,以下是使用 KCF 进行 130 帧跟踪后的边界框位置:

工作原理...

参见

  • Z. KalalK. MikolajczykJ. Matas 撰写的文章,题为 Forward-backward error: Automatic detection of tracking failures,发表在 Int. Conf. on Pattern Recognition,2010 年,描述了中值流算法。

  • Z. KalalK. MikolajczykJ. Matas 撰写的文章,题为 Tracking-learning-detection,发表在 IEEE Transactions on Pattern Analysis and Machine Intelligence 期刊,第 34 卷,第 7 期,2012 年,介绍了一种使用中值流算法的高级跟踪方法。

  • J.F. HenriquesR. CaseiroP. MartinsJ. Batista 撰写的文章,题为 High-Speed Tracking with Kernelized Correlation Filters,发表在 IEEE Transactions on Pattern Analysis and Machine Intelligence 期刊,第 37 卷,第 3 期,2014 年,描述了 KCF 跟踪器算法。

第十四章. 从示例中学习

本章将涵盖以下食谱:

  • 使用局部二值模式的最近邻识别面部

  • 使用 Haar 特征级联查找对象和面部

  • 使用支持向量机和方向梯度直方图检测对象和人物

简介

现在,机器学习经常被用来解决困难的机器视觉问题。实际上,它是一个包含许多重要概念的丰富研究领域,本身就值得一本完整的食谱。本章概述了一些主要的机器学习技术,并解释了如何使用 OpenCV 将这些技术部署在计算机视觉系统中。

机器学习的核心是开发能够自行学习如何对数据输入做出反应的计算机系统。机器学习系统不是被明确编程的,而是在展示期望行为的示例时自动适应和进化。一旦完成成功的训练阶段,预期训练后的系统将对新的未见查询输出正确的响应。

机器学习可以解决许多类型的问题;我们在这里的关注将是分类问题。形式上,为了构建一个能够识别特定概念类实例的分类器,必须使用大量标注样本对其进行训练。在二分类问题中,这个集合将包括正样本,代表要学习的类的实例,以及负样本,由不属于感兴趣类的不属于该类的实例的逆例组成。从这些观察结果中,必须学习一个决策函数,该函数可以预测任何输入实例的正确类别。

在计算机视觉中,这些样本是图像(或视频片段)。因此,首先要做的是找到一个理想的表达方式,以紧凑和独特的方式描述每张图像的内容。一个简单的表达方式可能是使用固定大小的缩略图图像。这个缩略图图像的像素按行排列形成一个向量,然后可以作为一个训练样本提交给机器学习算法。也可以使用其他替代方案,可能更有效的表示。本章的食谱描述了不同的图像表示,并介绍了一些著名的机器学习算法。我们应该强调,我们无法在食谱中详细涵盖所有讨论的不同机器学习技术的理论方面;我们的目标更多的是展示控制它们功能的主要原则。

使用局部二值模式的最近邻识别面部

我们对机器学习技术的第一次探索将从可能是最简单的方法开始,即最近邻分类。我们还将介绍局部二值模式特征,这是一种流行的表示,以对比度无关的方式编码图像的纹理模式和轮廓。

我们的示例将涉及人脸识别问题。这是一个极具挑战性的问题,在过去 20 年中一直是众多研究的目标。我们在这里提出的基本解决方案是 OpenCV 中实现的人脸识别方法之一。你很快就会意识到这个解决方案并不非常稳健,只在非常有利的情况下才能工作。尽管如此,这种方法构成了对机器学习和人脸识别问题的极好介绍。

如何做...

OpenCV 库提出了一系列作为通用cv::face::FaceRecognizer子类的实现的人脸识别方法。在本例中,我们将探讨cv::face::LBPHFaceRecognizer类,它对我们来说很有趣,因为它基于一种简单但通常非常有效的分类方法,即最近邻分类器。此外,它所使用的图像表示是由局部二值模式特征(LBP)构建的,这是一种描述图像模式非常流行的方法。

为了创建cv::face::LBPHFaceRecognizer的一个实例,需要调用其静态create方法:

    cv::Ptr<cv::face::FaceRecognizer> recognizer =
           cv::face::createLBPHFaceRecognizer(1, // radius of LBP pattern 
                   8,       // the number of neighboring pixels to consider 
                   8, 8,    // grid size 
                   200.8);  // minimum distance to nearest neighbor 

如下一节所述,提供的第一个参数用于描述要使用的 LBP 特征的特性。下一步是将一系列参考人脸图像输入识别器。这是通过提供两个向量来完成的,一个包含人脸图像,另一个包含相关的标签。每个标签是一个任意整数,用于标识特定的个人。想法是通过向识别器展示每个人的不同图像来训练它。正如你可能想象的那样,你提供的代表性图像越多,正确识别某人的机会就越大。在我们的非常简单的例子中,我们只提供了两个参考人物的图像。要调用的是train方法:

    // vectors of reference image and their labels 
    std::vector<cv::Mat> referenceImages; 
    std::vector<int> labels; 
    // open the reference images 
    referenceImages.push_back(cv::imread("face0_1.png",
                              cv::IMREAD_GRAYSCALE)); 
    labels.push_back(0); // person 0 
    referenceImages.push_back(cv::imread("face0_2.png",
                              cv::IMREAD_GRAYSCALE)); 
    labels.push_back(0); // person 0 
    referenceImages.push_back(cv::imread("face1_1.png",
                              cv::IMREAD_GRAYSCALE)); 
    labels.push_back(1); // person 1 
    referenceImages.push_back(cv::imread("face1_2.png",
                              cv::IMREAD_GRAYSCALE)); 
    labels.push_back(1); // person 1 

    // train the recognizer by computing the LBPHs 
    recognizer->train(referenceImages, labels); 

使用的图像如下,第一行是人物0的图像,第二行是人物1的图像:

如何做...

这些参考图像的质量也非常重要。此外,将它们归一化以使主要面部特征位于标准位置是一个好主意。例如,将鼻尖定位在图像中间,并将两只眼睛水平对齐在特定的图像行上。存在面部特征检测方法,可以用来自动以这种方式归一化面部图像。在我们的示例中,我们没有这样做,这会导致识别器的鲁棒性受到影响。尽管如此,这个识别器已经准备好使用,可以提供输入图像,并且它将尝试预测与该面部图像对应的标签:

    // predict the label of this image 
    recognizer->predict(inputImage,      // face image  
                        predictedLabel,  // predicted label of this image  
                        confidence);     // confidence of the prediction 

我们的输入图像如下:

如何做...

识别器不仅返回预测的标签,还返回一个置信度分数。在cv::face::LBPHFaceRecognizer的情况下,这个置信度值越低,识别器对其预测的信心就越大。在这里,我们获得了一个正确的标签预测(1),置信度值为90.3

它是如何工作的...

为了理解本食谱中展示的人脸识别方法的运作原理,我们需要解释其两个主要组件:所使用的图像表示和应用的分类方法。

如其名称所示,cv::face::LBPHFaceRecognizer算法使用 LBP 特征。这是一种不依赖于对比度的描述图像中图像模式的方法。它是一种局部表示,将每个像素转换为一个二进制表示,该表示编码了在邻域中找到的图像强度模式。为了实现这一目标,应用了一个简单的规则;将局部像素与其选定的每个邻居进行比较;如果其值大于其邻居的值,则将0分配给相应的位位置,如果不是,则将1分配。在其最简单和最常见的形式中,每个像素与其8个直接邻居进行比较,这生成一个 8 位模式。例如,让我们考虑以下局部模式:

它是如何工作的...

应用所描述的规则会生成以下二进制值:

它是如何工作的...

以初始位置为左上角的像素,按顺时针方向移动,中心像素将被替换为二进制序列11011000。然后通过遍历图像的所有像素来生成所有相应的 LBP 字节,从而轻松地生成完整的 8 位 LBP 图像。这是通过以下函数实现的:

    //compute the Local Binary Patterns of a gray-level image 
    void lbp(const cv::Mat &image, cv::Mat &result) { 

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

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

        // pointers to the input rows 
        const uchar* previous = image.ptr<const uchar>(j - 1);    
        const uchar* current  = image.ptr<const uchar>(j);       
        const uchar* next     = image.ptr<const uchar>(j + 1);   
        uchar* output = result.ptr<uchar>(j);        //output row 

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

          // compose local binary pattern 
          *output =  previous[i - 1] > current[i] ? 1 : 0; 
          *output |= previous[i] > current[i] ?     2 : 0; 
          *output |= previous[i + 1] > current[i] ? 4 : 0; 
          *output |= current[i - 1] > current[i] ?  8 : 0; 
          *output |= current[i + 1] > current[i] ? 16 : 0; 
          *output |= next[i - 1] > current[i] ?    32 : 0; 
          *output |= next[i] > current[i] ?        64 : 0; 
          *output |= next[i + 1] > current[i] ?   128 : 0; 
          output++; // next pixel 
        } 
      } 
      // 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)); 
    } 

循环体将每个像素与其8个邻居进行比较,并通过简单的位移动来分配位值。以下是一个图像示例:

它是如何工作的...

获得一个 LBP 图像,可以显示为灰度图像:

它是如何工作的...

这种灰度级表示实际上并不容易理解,但它只是简单地说明了发生的编码过程。

回到我们的cv::face::LBPHFaceRecognizer类,可以看到其create方法的前两个参数指定了要考虑的邻域的大小(像素半径)和维度(圆周上的像素数,可能应用插值)。一旦生成了 LBP 图像,该图像被划分为一个网格。这个网格的大小被指定为create方法的第三个参数。对于这个网格的每一个块,构建一个 LBP 值的直方图。通过将这些直方图的 bin 计数连接成一个大的向量,最终获得一个全局图像表示。使用8×8网格,计算出的 256-bin 直方图集形成一个 16384 维向量。

因此,cv::face::LBPHFaceRecognizer类的train方法为提供的每个参考图像生成这个长向量。每个面部图像可以看作是在一个非常高维空间中的一个点。当通过其predict方法提交新图像时,找到与该图像最近的参考点。因此,与该点关联的标签是预测标签,置信度值将是计算出的距离。这是定义最近邻分类器的原则。通常还会添加另一个成分。如果输入点的最近邻点离它太远,这可能意味着这个点实际上不属于任何参考类别。这个点必须离多远才能被认为是异常值?这由cv::face::LBPHFaceRecognizer类的create方法的第四个参数指定。

如您所见,这是一个非常简单的想法,当不同的类别在表示空间中生成不同的点云时,它非常有效。这种方法的好处之一是它隐式地处理多个类别,因为它简单地从其最近邻读取预测类别。主要缺点是它的计算成本。在这样一个可能由许多参考点组成的大空间中找到最近邻可能需要时间。存储所有这些参考点在内存中也很昂贵。

参考以下内容

  • T. AhonenA. HadidM. Pietikainen撰写的文章《使用局部二值模式进行人脸描述:应用于人脸识别》发表在 2006 年的 IEEE《模式分析与机器智能》交易上,描述了 LBP 在人脸识别中的应用。

  • B. FrobaA. Ernst撰写的文章《使用改进的计数变换进行人脸检测》发表在 2004 年的 IEEE 会议《自动人脸和手势识别》上,提出了一种 LBP 特征的变体。

  • M. UricarV. FrancV. Hlavac撰写的文章《基于结构化输出 SVM 学习的人脸特征检测器》发表在 2012 年的《计算机视觉理论与应用国际会议》上,描述了一种基于本章最后一种 SVM 讨论的人脸特征检测器。

使用 Haar 特征的级联查找对象和面部

在前一个菜谱中,我们学习了机器学习的一些基本概念。我们展示了如何通过收集不同类别的样本来构建分类器。然而,对于前一个菜谱中考虑的方法,训练分类器简单地就是存储所有样本的表示。从那里,任何新实例的标签可以通过查看最近的(最近邻)标记点来预测。对于大多数机器学习方法,训练是一个迭代过程,其中通过遍历样本来构建机器。因此,产生的分类器的性能随着更多样本的呈现而逐渐提高。当达到某个性能标准或考虑当前训练数据集时不再能获得更多改进时,学习最终停止。这个菜谱将介绍一个遵循此程序的机器学习算法,即提升分类器的级联

在我们查看这个分类器之前,我们首先将注意力转向 Haar 特征图像表示。我们确实了解到,一个好的表示是生产鲁棒分类器的一个基本要素。正如前一个菜谱中描述的,LBPs(局部二值模式)构成了一种可能的选择;下一节将描述另一种流行的表示。

准备中

生成分类器的第一步是收集一个(最好是)大型的图像样本集合,展示要识别的对象类别的不同实例。这些样本的表示方式已被证明对从它们构建的分类器的性能有重要影响。像素级表示通常被认为太低级,无法鲁棒地描述每个对象类别的内在特征。能够描述图像中存在的独特模式的各种尺度的表示更受欢迎。这就是Haar 特征的目标,有时也称为 Haar-like 特征,因为它们来自 Haar 变换基函数。

Haar 特征定义了像素的小矩形区域,这些区域随后通过简单的减法进行比较。通常考虑三种不同的配置,即 2 矩形、3 矩形和 4 矩形特征

准备中

这些特征可以是任何大小,并且可以应用于要表示的图像的任何区域。例如,这里有两个应用于人脸图像的 Haar 特征:

准备中

构建 Haar 表示包括选择一定数量的 Haar 特征,这些特征具有给定的类型、大小和位置,并将它们应用于图像。从所选的 Haar 特征集中获得的具体值集构成了图像表示。挑战在于确定要选择哪些特征集。确实,为了区分一类对象和另一类对象,某些 Haar 特征必须比其他特征更相关。例如,在面部图像类的情况下,在眼睛之间应用一个 3 矩形 Haar 特征(如图所示)可能是一个好主意,因为我们期望所有面部图像在这种情况下都能产生一个高值。显然,由于存在数十万个可能的 Haar 特征,手动做出好的选择肯定很困难。因此,我们正在寻找一种机器学习方法,该方法将选择给定类别对象的最相关特征。

如何做到这一点...

在这个菜谱中,我们将学习如何使用 OpenCV 构建一个特征级联提升,以生成一个二分类器。但在我们这样做之前,让我们解释一下这里使用的术语。一个二分类器是指能够从其余部分(例如,不包含面部图像的图像)中识别出一个类别的实例(例如,面部图像)。因此,在这种情况下,我们有正样本(即面部图像)和负样本(即非面部图像),这些后者也被称为背景图像。本菜谱中的分类器将由一系列简单分类器组成,这些分类器将依次应用。级联的每个阶段都将基于一小部分特征值快速做出拒绝或不拒绝显示对象的决策。这种级联是提升的,因为每个阶段通过做出更准确的决策来提高(提升)之前阶段的性能。这种方法的主要优势在于,级联的早期阶段由简单的测试组成,这些测试可以快速拒绝肯定不属于感兴趣类别的实例。这些早期拒绝使得级联分类器快速,因为在通过扫描图像查找对象类别时,大多数要测试的子窗口都不会属于感兴趣类别。这样,只有少数窗口需要在被接受或拒绝之前通过所有阶段。

为了为特定类别训练一个提升分类器级联,OpenCV 提供了一个将执行所有必需操作的软件工具。当你安装库时,你应该在适当的bin目录中创建了两个可执行模块,这些是opencv_createsamples.exeopencv_traincascade.exe。确保你的系统PATH指向此目录,这样你就可以在任何地方执行这些工具。

在训练分类器时,首先要做的事情是收集样本。正样本由显示目标类别实例的图像组成。在我们的简单例子中,我们决定训练一个分类器来识别停车标志。以下是我们已经收集的几个正样本:

如何操作...

我们拥有的文本文件中必须指定要使用的正样本列表,这里命名为stop.txt。它包含图像文件名和边界框坐标:

    stop00.png 1 0 0 64 64 
    stop01.png 1 0 0 64 64 
    stop02.png 1 0 0 64 64 
    stop03.png 1 0 0 64 64 
    stop04.png 1 0 0 64 64 
    stop05.png 1 0 0 64 64 
    stop06.png 1 0 0 64 64 
    stop07.png 1 0 0 64 64 

文件名后的第一个数字是图像中可见的正样本数量。接下来是包含此正样本的边界框的左上角坐标,最后是其宽度和高度。在我们的例子中,正样本已经从原始图像中提取出来,这就是为什么我们每个文件只有一个样本,左上角坐标在(0,0)。一旦这个文件可用,您就可以通过运行提取工具来创建正样本文件。

 opencv_createsamples -info stop.txt -vec stop.vec -w 24 -h 24 -num 8

这将创建一个输出文件stop.vec,其中将包含输入文本文件中指定的所有正样本。请注意,我们使样本大小小于原始大小(24×24),而原始大小是64×64。提取工具将所有样本调整到指定的大小。通常,Haar 特征与较小的模板配合得更好,但这需要在每个具体案例中验证。

负样本仅仅是包含感兴趣类别(在我们的例子中即没有停车标志)实例的背景图像。但这些图像应该展示出分类器预期看到的各种情况。这些负图像可以是任何大小,训练工具将从它们中提取随机的负样本。以下是我们希望使用的背景图像的一个示例。

如何操作...

一旦正负样本集已经就绪,分类器级联就准备好进行训练。调用工具的方式如下:

     opencv_traincascade  -data classifier -vec stop.vec   
                     -bg neg.txt -numPos 9  -numNeg 20 
                     -numStages 20 -minHitRate 0.95  
                     -maxFalseAlarmRate 0.5 -w 24 -h 24 

这里使用的参数将在下一节中解释。请注意,这个过程可能需要非常长的时间;在某些具有数千个样本的复杂案例中,执行甚至可能需要几天。在运行过程中,级联训练器将在每个阶段的训练完成后打印出性能报告。特别是,分类器会告诉您当前的命中率HR);这是当前被级联接受(即正确识别为正实例)的正样本的百分比。您希望这个数字尽可能接近1.0。它还会给出当前的误报率FA),即被错误分类为正实例的测试负样本数量(也称为假阳性)。您希望这个数字尽可能接近0.0。这些数字会为每个阶段中引入的每个特征报告。

我们的简单示例只用了几秒钟。产生的分类器结构在训练阶段的 XML 文件中描述。分类器随后就准备好使用了!你可以提交任何样本给它,它将告诉你它认为这是一个正样本还是一个负样本。

在我们的例子中,我们使用 24×24 的图像来训练我们的分类器,但通常,你想要找出图像(任何大小)中是否有任何你的一类对象的实例。为了达到这个目标,你只需扫描输入图像并提取所有可能的样本大小的窗口。如果你的分类器足够准确,只有包含所寻对象的窗口才会返回正检测。但是,只要可见的正样本具有适当的大小,这种方法才会有效。为了在多个尺度上检测实例,你必须通过在每个金字塔级别上以一定比例减小原始图像的大小来构建图像金字塔。这样,较大的对象最终会适应我们在金字塔下降过程中训练的样本大小。这是一个漫长的过程,但好消息是 OpenCV 提供了一个实现此过程的类。它的使用相当简单。首先,通过加载适当的 XML 文件来构建分类器:

    cv::CascadeClassifier cascade; 
    if (!cascade.load("stopSamples/classifier/cascade.xml")) { 
      std::cout << "Error when loading the cascade classfier!"  
                << std::endl;   
      return -1; 
    } 

然后,你调用带有输入图像的 detection 方法:

    cascade.detectMultiScale(inputImage, // input image 
              detections,           // detection results 
              1.1,                  // scale reduction factor 
              2,                 // number of required neighbor detections 
              0,                    // flags (not used) 
              cv::Size(48, 48),     // minimum object size to be detected 
              cv::Size(128, 128));  // maximum object size to be detected 

结果以 cv::Rect 实例的向量形式提供。为了可视化检测结果,你只需在输入图像上绘制这些矩形:

    for (int i = 0; i < detections.size(); i++) 
     cv::rectangle(inputImage, detections[i],  
                   cv::Scalar(255, 255, 255), 2); 

当我们的分类器在图像上进行测试时,这是我们获得的结果:

如何操作...

工作原理...

在上一节中,我们解释了如何使用一类对象的正负样本构建 OpenCV 级联分类器。现在,我们将概述用于训练此级联的学习算法的基本步骤。我们的级联是通过使用本食谱介绍部分中描述的 Haar 特征进行训练的,但正如我们将看到的,任何其他简单特征都可以用来构建一个提升级联。由于提升学习的理论和原理相当复杂,我们不会在本食谱中涵盖所有方面;感兴趣的读者应参考最后部分列出的文章。

首先,让我们重申,在级联提升分类器的背后有两个核心思想。第一个思想是,可以通过组合多个弱分类器(即基于简单特征的分类器)来构建一个强分类器。其次,因为在机器视觉中,负实例比正实例出现得更为频繁,因此可以分阶段进行有效的分类。早期阶段快速拒绝明显的负实例,而在后期阶段对更难处理的样本做出更精细的决策。基于这两个思想,我们现在描述提升级联学习算法。我们的解释基于称为AdaBoost的增强方法,这是最常使用的一种。我们的描述还将使我们能够解释opencv_traincascade工具中使用的某些参数。

在这个算法中,我们使用 Haar 特征来构建我们的弱分类器。当应用一个 Haar 特征(给定类型、大小和位置)时,会得到一个值。然后通过找到最佳阈值值来获得一个简单的分类器,这个阈值值将根据这个特征值将负类和正类实例区分开来。为了找到这个最佳阈值,我们有大量的正样本和负样本可供使用(在opencv_traincascade中,这一步要使用的正样本和负样本的数量由-numPos-numNeg参数给出)。由于我们有许多可能的 Haar 特征,我们检查所有这些特征,并选择那些最能分类我们的样本集的特征。显然,这个非常基础的分类器会犯错误(即错误地分类几个样本);这就是为什么我们需要构建多个这样的分类器。这些分类器是逐个添加的,每次都寻找提供最佳分类的新 Haar 特征。但是,由于在每次迭代中,我们希望专注于当前被错误分类的样本,因此分类性能是通过给予错误分类样本更高的权重来衡量的。因此,我们获得了一系列简单的分类器,然后通过这些弱分类器的加权求和(性能更好的分类器给予更高的权重)构建一个强分类器。按照这种方法,通过结合几百个简单特征,可以获得性能良好的强分类器。

但是,为了构建一个早期拒绝作为核心机制的分类器级联,我们不想使用由大量弱分类器组成的一个强分类器。相反,我们需要找到非常小的分类器,这些分类器将仅使用少量 Haar 特征,以便快速拒绝明显的负样本,同时保留所有正样本。在其经典形式中,AdaBoost 旨在通过计算错误否定(将正样本分类为负样本)和错误肯定(将负样本分类为正样本)的数量来最小化总的分类错误。在当前情况下,我们需要尽可能多,如果不是所有,的正样本被正确分类,同时最小化错误肯定率。幸运的是,可以通过修改 AdaBoost 来使真正的正样本得到更强的奖励。因此,在训练级联的每个阶段时,必须设置两个标准:最小命中率和最大误报率;在opencv_traincascade中,这些参数使用-minHitRate(默认值为0.995)和-maxFalseAlarmRate(默认值为0.5)参数指定。只有当两个性能标准得到满足时,Haar 特征才会被添加到阶段。最小命中率必须设置得相当高,以确保正实例将进入下一阶段;记住,如果一个正实例被某个阶段拒绝,那么这个错误是无法恢复的。因此,为了便于生成低复杂度的分类器,你应该将最大误报率相对设置得较高。否则,你的阶段将需要许多 Haar 特征才能满足性能标准,这与简单快速计算分类器阶段的早期拒绝理念相矛盾。

因此,一个好的级联将包含具有少量特征的前期阶段,每个阶段的特征数量随着级联的上升而增加。在opencv_traincascade中,每个阶段的最大特征数量使用-maxWeakCount(默认为100)参数设置,阶段数量使用-numStages(默认为20)参数设置。

当新阶段的训练开始时,就必须收集新的负样本。这些样本是从提供的背景图像中提取的。这里的困难在于找到能够通过所有先前阶段(即被错误地分类为正样本)的负样本。你训练的阶段越多,收集这些负样本就越困难。这就是为什么提供大量背景图像给分类器很重要。它将能够从中提取出难以分类的片段(因为它们与正样本相似)。请注意,如果在某个阶段,两个性能标准都达到了,而没有添加任何新的特征,那么级联训练将在这一点停止(你可以直接使用它,或者通过提供更困难的样本重新训练它)。相反,如果阶段无法满足性能标准,训练也将停止;在这种情况下,你应该尝试使用更容易的性能标准进行新的训练。

对于由n个阶段组成的级联,可以很容易地证明分类器的全局性能至少会优于minHitRate^nmaxFalseAlarmRate^n。这是由于每个阶段都是建立在先前级联阶段的结果之上的结果。例如,如果我们考虑opencv_traincascade的默认值,我们预计我们的分类器将有一个准确率(命中率)为0.995²⁰和误报率为0.5²⁰。这意味着 90%的正样本将被正确识别,0.001%的负样本将被错误地分类为正样本。请注意,由于我们在级联过程中会丢失一部分正样本,因此你总是需要提供比每个阶段指定的样本数量更多的正样本。在我们刚才给出的数值示例中,我们需要将numPos设置为可用正样本数量的 90%。

一个重要的问题是应该使用多少个样本进行训练?这很难回答,但显然,你的正样本集必须足够大,以涵盖你类别实例可能出现的广泛范围。你的背景图像也应该是相关的。在我们的停车标志检测器的情况下,我们包括了城市图像,因为停车标志预计将在这种背景下被看到。一个常见的经验法则是numNeg= 2*numPos,但这一点需要在你的数据集上进行验证。

最后,我们在这份食谱中解释了如何使用 Haar 特征构建分类器的级联。这样的特征也可以使用其他特征构建,例如在之前的食谱中讨论的局部二进制模式或将在下一食谱中介绍的梯度直方图。opencv_traincascade有一个-featureType参数,允许选择不同的特征类型。

还有更多...

OpenCV 库提供了一些预训练的级联,您可以使用这些级联来检测人脸、面部特征、人物和其他物体。您将在库源目录的数据目录中以 XML 文件的形式找到这些级联。

使用 Haar 级联进行人脸检测

预训练的模型已准备好使用。您只需使用适当的 XML 文件创建cv::CascadeClassifier类的实例即可:

    cv::CascadeClassifier faceCascade; 
    if (!faceCascade.load("haarcascade_frontalface_default.xml")) { 
      std::cout << "Error when loading the face cascade classfier!"  
                << std::endl; 
      return -1; 
    } 

然后要使用 Haar 特征检测人脸,您按以下步骤进行:

    faceCascade.detectMultiScale(picture, // input image 
               detections,           // detection results 
               1.1,                  // scale reduction factor 
               3,                 // number of required neighbor detections 
               0,                    // flags (not used) 
               cv::Size(48, 48),     // minimum object size to be detected 
               cv::Size(128, 128));  // maximum object size to be detected 

    // draw detections on image 
    for (int i = 0; i < detections.size(); i++) 
      cv::rectangle(picture, detections[i],  
                    cv::Scalar(255, 255, 255), 2); 

同样的过程可以用于眼检测器,以下图像是得到的:

使用 Haar 级联进行人脸检测

参见

  • 在第九章的《描述和匹配兴趣点》中,“描述和匹配局部强度模式”食谱描述了 SURF 描述符,它也使用了 Haar-like 特征

  • 2001 年,P. Viola 和 M. Jones 在《计算机视觉和模式识别》会议上发表的《使用简单特征快速检测物体》一文是描述提升分类器级联和 Haar 特征的经典论文

  • 1999 年,Y. Freund 和 R.E. Schapire 在《日本人工智能学会杂志》上发表的《提升的简要介绍》一文描述了提升的理论基础

  • S. Zhang、R. Benenson 和 B. Schiele 在 2015 年《IEEE 计算机视觉和模式识别会议》上发表的《用于行人检测的滤波通道特征》一文提出了类似于 Haar 的特征,可以产生高度准确的检测

使用支持向量机和方向梯度直方图检测物体和人物

本食谱介绍另一种机器学习方法,即支持向量机SVM),它可以从训练数据中生成准确的二分类器。它们已被广泛用于解决许多计算机视觉问题。这次,分类是通过使用一个数学公式来解决的,该公式考虑了高维空间中问题的几何形状。

此外,我们还将介绍一种新的图像表示方法,该方法通常与 SVMs 一起使用,以生成鲁棒的物体检测器。

准备工作

物体的图像主要以其形状和纹理内容为特征。这是由方向梯度直方图HOG)表示所捕捉的方面。正如其名称所示,这种表示是基于从图像梯度构建直方图。特别是,因为我们更感兴趣的是形状和纹理,所以分析的是梯度方向分布。此外,为了考虑这些梯度的空间分布,在将图像划分为区域的网格上计算多个直方图。

因此,构建 HOG 表示的第一步是计算图像的梯度。然后,图像被细分为小的单元(例如,8×8 像素),并为这些单元中的每一个构建梯度方向的直方图。因此,必须将可能的取向范围划分为区间。通常,只考虑梯度方向,而不考虑它们的方向(这些被称为无符号梯度)。在这种情况下,可能的取向范围从 0180 度。在这种情况下,9 个区间的直方图会将可能的取向划分为 20 度的区间。每个单元中的梯度向量都会对与该梯度大小相对应的权重的一个区间做出贡献。

然后,这些单元被分组到块中。一个块由一定数量的单元组成。这些覆盖图像的块可以相互重叠(即,它们可以共享单元)。例如,在块由 2×2 单元组成的情况下,每块单元可以定义一个新的块;这将代表 1 单元的块步长,每个单元(除了行中的最后一个单元)将贡献 2 个块。相反,如果块步长为 2 单元,则块将完全不重叠。一个块包含一定数量的单元直方图(例如,一个由 2×2 单元组成的块中有 4 个)。这些直方图被简单地连接起来形成一个长向量(例如,4 个每个有 9 个区间的直方图将产生一个长度为 36 的向量)。为了使表示对对比度变化不变,这个向量随后被归一化(例如,每个元素被除以向量的幅度)。最后,你还将与图像中所有块的关联的所有向量(按行顺序)连接成一个非常大的向量(例如,在一个 64×64 的图像中,当在 8×8 大小的单元上应用 1 的步长时,你将总共拥有七个 16×16 的块;这代表一个最终向量为 49x36 = 1764 维)。这个长向量是图像的 HOG 表示。

如你所见,图像的 HOG 导致一个非常高维的向量(请参阅本配方中 还有更多... 部分提出的可视化 HOG 表示的方法)。这个向量表征了图像,然后可以用来对属于不同类别对象的图像进行分类。为了实现这个目标,因此我们需要一个可以处理非常高维向量的机器学习方法。

如何做...

在这个配方中,我们将构建另一个停车标志分类器。这显然只是一个玩具示例,用于说明学习过程。正如我们在前面的配方中解释的那样,第一步是收集训练样本。在我们的例子中,我们将使用的正样本集如下:

如何做...

我们(非常小)的负样本集如下:

如何做...

我们现在将学习如何使用cv::svm类中实现的 SVM 来区分这两个类别。为了构建一个健壮的分类器,我们将使用 HOG 来表示我们的类实例,正如在本食谱的介绍部分所描述的。更确切地说,我们将使用由2×2单元格组成的8×8块,块步长为1个单元格:

    cv::HOGDescriptor hogDesc(positive.size(), // size of the window 
                              cv::Size(8, 8),  // block size 
                              cv::Size(4, 4),  // block stride 
                              cv::Size(4, 4),  // cell size 
                              9);              // number of bins 

使用 9-箱直方图和64×64样本,这种配置产生大小为8100的 HOG 向量(由225个块组成)。我们为我们的每个样本计算这个描述符,并将它们转换成一个单独的矩阵(每行一个 HOG):

    // compute first descriptor  
    std::vector<float> desc; 
    hogDesc.compute(positives[0], desc); 

    // the matrix of sample descriptors 
    int featureSize = desc.size(); 
    int numberOfSamples = positives.size() + negatives.size(); 

    // create the matrix that will contain the samples HOG 
    cv::Mat samples(numberOfSamples, featureSize, CV_32FC1); 
    // fill first row with first descriptor 
    for (int i = 0; i < featureSize; i++) 
      samples.ptr<float>(0)[i] = desc[i]; 

    // compute descriptor of the positive samples 
    for (int j = 1; j < positives.size(); j++) { 
      hogDesc.compute(positives[j], desc); 
      // fill the next row with current descriptor 
      for (int i = 0; i < featureSize; i++) 
        samples.ptr<float>(j)[i] = desc[i]; 
    } 
    // compute descriptor of the negative samples 
    for (int j = 0; j < negatives.size(); j++) { 
      hogDesc.compute(negatives[j], desc); 
      // fill the next row with current descriptor 
      for (int i = 0; i < featureSize; i++) 
        samples.ptr<float>(j + positives.size())[i] = desc[i]; 
    } 

注意我们是如何计算第一个 HOG 以获得描述符的大小,然后创建描述符矩阵的。然后创建第二个矩阵来包含与每个样本关联的标签。在我们的例子中,前几行是正样本(必须分配标签1),其余行是负样本(标签为-1):

    // Create the labels 
    cv::Mat labels(numberOfSamples, 1, CV_32SC1); 
    // labels of positive samples 
    labels.rowRange(0, positives.size()) = 1.0; 
    // labels of negative samples 
    labels.rowRange(positives.size(), numberOfSamples) = -1.0; 

下一步是构建用于训练的 SVM 分类器;我们还选择了要使用的 SVM 类型和核函数(这些参数将在下一节中讨论):

    // create SVM classifier 
    cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create(); 
    svm->setType(cv::ml::SVM::C_SVC); 
    svm->setKernel(cv::ml::SVM::LINEAR); 

我们现在准备开始训练。首先将标记的样本提供给分类器,并调用train方法:

    // prepare the training data 
    cv::Ptr<cv::ml::TrainData> trainingData = 
           cv::ml::TrainData::create(samples,
                              cv::ml::SampleTypes::ROW_SAMPLE, labels); 
    // SVM training 
    svm->train(trainingData); 

一旦训练阶段完成,任何未知类别的样本都可以提交给分类器,分类器将尝试预测它所属的类别(在这里我们测试了四个样本):

    cv::Mat queries(4, featureSize, CV_32FC1); 

    // fill the rows with query descriptors 
    hogDesc.compute(cv::imread("stop08.png",  
                           cv::IMREAD_GRAYSCALE), desc); 
    for (int i = 0; i < featureSize; i++) 
      queries.ptr<float>(0)[i] = desc[i]; 
    hogDesc.compute(cv::imread("stop09.png",  
                           cv::IMREAD_GRAYSCALE), desc); 
    for (int i = 0; i < featureSize; i++) 
      queries.ptr<float>(1)[i] = desc[i]; 
    hogDesc.compute(cv::imread("neg08.png",  
                           cv::IMREAD_GRAYSCALE), desc); 
    for (int i = 0; i < featureSize; i++) 
      queries.ptr<float>(2)[i] = desc[i]; 
    hogDesc.compute(cv::imread("neg09.png",  
                           cv::IMREAD_GRAYSCALE), desc); 
    for (int i = 0; i < featureSize; i++) 
      queries.ptr<float>(3)[i] = desc[i]; 
    cv::Mat predictions; 

    // Test the classifier  
    svm->predict(queries, predictions); 
    for (int i = 0; i < 4; i++) 
      std::cout << "query: " << i << ": " <<  
               ((predictions.at<float>(i,) < 0.0)?  
                   "Negative" : "Positive") << std::endl; 

如果分类器已经用代表性样本进行了训练,那么它应该能够正确预测新实例的标签。

工作原理...

在我们的停车标志识别示例中,我们类中的每个实例都由一个在 8100 维 HOG 空间中的点来表示。显然,无法可视化这样一个大的空间,但支持向量机背后的想法是在该空间中绘制一个边界,将属于一个类别的点与属于另一个类别的点分开。更具体地说,这个边界实际上将只是一个简单的超平面。这个想法最好通过考虑一个二维空间来解释,其中每个实例都表示为一个二维点。在这种情况下,超平面是一个简单的直线。

工作原理...

这显然是一个简单的例子,但从概念上讲,在二维空间或 8100 维空间中工作是同一件事。前面的图显示了如何用一条简单的线很好地分离两个类的点。在本例中,还可以看到许多其他线也能实现这种完美的类分离。因此,一个问题就是;应该选择哪条确切的线。为了回答这个问题,你必须首先意识到,我们用来构建分类器的样本只是所有可能实例的一个小快照,当分类器在目标应用中使用时,需要对这些实例进行分类。这意味着我们希望我们的分类器不仅能够正确地分离提供的样本集,而且我们还希望这个分类器对它展示的未来实例做出最佳决策。这个概念通常被称为分类器的泛化能力。直观地,我们相信我们的分离超平面应该位于两个类之间,而不是比另一个类更接近。更正式地说,SVMs 提出将超平面设置在最大化定义边界周围边界的位置。这个边界被定义为分离超平面与正样本集中最近点的最小距离加上超平面与最近负样本的距离。最近的点(定义边界的点)被称为支持向量。SVM 背后的数学定义了一个优化函数,旨在识别这些支持向量。

但是,针对分类问题的提出的解决方案不可能那么简单。如果样本点的分布如下所示,会发生什么?

如何工作...

在这种情况下,一个简单的超平面(这里是一条线)无法实现适当的分离。SVM 通过引入人工变量,通过某些非线性变换将问题引入更高维的空间来解决此问题。例如,在上面的例子中,有人可能会提出添加到原点的距离作为一个额外的变量,即计算每个点的r= sqrt(x²+y²)。我们现在有一个三维空间;为了简单起见,我们只需在(r,x)平面上绘制这些点:

如何工作...

如您所见,我们的样本点现在可以通过一个简单的超平面分离。这意味着您现在必须在这个新空间中找到支持向量。实际上,在 SVM 公式中,您不需要将所有点都带入这个新空间,您只需要定义一种方法来测量这个增强空间中点到超平面的距离。因此,SVM 定义了核函数,允许您在不显式计算该空间中点坐标的情况下测量这个距离。这只是一个数学技巧,解释了为什么产生最大间隔的支持向量可以在非常高的(人工)维空间中有效地计算。这也解释了为什么当您想使用支持向量机时,您需要指定要使用哪种核。正是通过应用这些核,您将使非线性不可分的数据在核空间中变得可分。

然而,这里有一个重要的说明。由于在使用支持向量机时,我们经常处理非常高维的特征(例如,在我们的 HOG 示例中为8100维),因此我们的样本可能非常容易被一个简单的超平面分离。这就是为什么仍然有意义不使用非线性核(或者更精确地说,使用线性核,即cv::ml::SVM::LINEAR)并在原始特征空间中工作。这样得到的分类器在计算上会更简单。但对于更具挑战性的分类问题,核仍然是一个非常有效的工具。OpenCV 为你提供了一系列标准核(例如,径向基函数、Sigmoid 函数等);这些核的目标是将样本发送到一个更大的非线性空间,使得类别可以通过超平面分离。支持向量机有多个变体;最常见的是 C-SVM,它为每个不在超平面右侧的异常样本添加惩罚。

最后,我们强调,由于它们强大的数学基础,支持向量机非常适合处理非常高维的特征。事实上,它们已经被证明在特征空间的维数大于样本数量时表现最佳。它们在内存效率上也很有优势,因为它们只需要存储支持向量(与需要保留所有样本点的最近邻方法相比)。

还有更多...

方向梯度直方图和 SVM 对于构建良好的分类器是一个很好的组合。这种成功的一个原因是 HOG 可以被看作是一个鲁棒的高维描述符,它捕捉了对象类的基本方面。HOG-SVM 分类器已经在许多应用中成功使用;行人检测就是其中之一。

最后,由于这是本书的最后一个配方,因此我们将以机器学习领域最近趋势的视角来结束,这个趋势正在改变计算机视觉和人工智能。

HOG 可视化

HOG 是从重叠的块中组合的单元格构建的。因此,可视化这个描述符很困难。尽管如此,它们通常通过显示与每个单元格关联的直方图来表示。在这种情况下,与在常规条形图中对齐方向箱不同,方向直方图可以更直观地绘制成星形,其中每条线都与它所代表的箱子的方向相关联,线的长度与该箱子的计数成比例。这些 HOG 表示可以随后显示在图像上:

HOG 可视化

每个单元格的 HOG 表示可以通过一个简单的函数生成,该函数接受一个指向直方图的迭代器。然后为每个bin绘制适当方向和长度的线条:

    //draw one HOG over one cell 
    void drawHOG(std::vector<float>::const_iterator hog,  
                       // iterator to the HOG 
                 int numberOfBins,       // number of bins inHOG 
                 cv::Mat &image,         // image of the cell 
                 float scale=1.0) {      // length multiplier 

      const float PI = 3.1415927; 
      float binStep = PI / numberOfBins; 
      float maxLength = image.rows; 
      float cx = image.cols / 2.; 
      float cy = image.rows / 2.; 

      // for each bin 
      for (int bin = 0; bin < numberOfBins; bin++) { 

        // bin orientation 
        float angle = bin*binStep; 
        float dirX = cos(angle); 
        float dirY = sin(angle); 
        // length of line proportion to bin size 
        float length = 0.5*maxLength* *(hog+bin); 

        // drawing the line 
        float x1 = cx - dirX * length * scale; 
        float y1 = cy - dirY * length * scale; 
        float x2 = cx + dirX * length * scale; 
        float y2 = cy + dirY * length * scale; 
        cv::line(image, cv::Point(x1, y1), cv::Point(x2, y2),  
                 CV_RGB(255, 255, 255), 1); 
      } 
    } 

HOG 可视化函数将为每个单元格调用此先前的函数:

    // Draw HOG over an image 
    void drawHOGDescriptors(const cv::Mat &image,  // the input image  
             cv::Mat &hogImage, // the resulting HOG image 
             cv::Size cellSize, // size of each cell (blocks are ignored) 
             int nBins) {       // number of bins 

      // block size is image size 
      cv::HOGDescriptor hog( 
              cv::Size((image.cols / cellSize.width) * cellSize.width,     
                       (image.rows / cellSize.height) * cellSize.height), 
              cv::Size((image.cols / cellSize.width) * cellSize.width,   
                       (image.rows / cellSize.height) * cellSize.height),  
              cellSize,    // block stride (ony 1 block here) 
              cellSize,    // cell size 
              nBins);      // number of bins 

      //compute HOG 
      std::vector<float> descriptors; 
      hog.compute(image, descriptors); 
      ... 
      float scale= 2.0 / * 
                  std::max_element(descriptors.begin(),descriptors.end()); 
      hogImage.create(image.rows, image.cols, CV_8U); 
      std::vector<float>::const_iterator itDesc= descriptors.begin(); 

      for (int i = 0; i < image.rows / cellSize.height; i++) { 
        for (int j = 0; j < image.cols / cellSize.width; j++) { 
          //draw each cell 
             hogImage(cv::Rect(j*cellSize.width, i*cellSize.height,  
                      cellSize.width, cellSize.height)); 
           drawHOG(itDesc, nBins,  
                   hogImage(cv::Rect(j*cellSize.width,                  
                                     i*cellSize.height,  
                                     cellSize.width, cellSize.height)),  
                   scale); 
          itDesc += nBins; 
        } 
      } 
    } 

此函数计算具有指定单元格大小的 HOG 描述符,但由一个大型块(即具有图像大小的块)组成。因此,这种表示忽略了在每个块级别发生的归一化效果。

人体检测

OpenCV 提供了一个基于 HOG 和 SVM 的预训练人体检测器。至于之前菜谱中的分类器级联,这个 SVM 分类器可以通过在图像上扫描多个尺度的窗口来检测整个图像中的实例。然后你只需构建分类器并在图像上执行检测:

    // create the detector 
    std::vector<cv::Rect> peoples; 
    cv::HOGDescriptor peopleHog; 
    peopleHog.setSVMDetector( 
    cv::HOGDescriptor::getDefaultPeopleDetector()); 
    // detect peoples oin an image 
    peopleHog.detectMultiScale(myImage, // input image 
               peoples,           // ouput list of bounding boxes  
               0,       // threshold to consider a detection to be positive 
               cv::Size(4, 4),    // window stride  
               cv::Size(32, 32),  // image padding 
               1.1,               // scale factor 
               2);                // grouping threshold 

窗口步长定义了128×64模板在图像上移动的方式(在我们的例子中,水平垂直方向上每4个像素)。较长的步长会使检测更快(因为评估的窗口更少),但你可能会错过一些落在测试窗口之间的人。图像填充参数简单地在图像的边缘添加像素,以便检测图像边缘的人。SVM 分类器的标准阈值是0(因为1是分配给正实例的值,-1是分配给负实例的值)。但如果你真的想确保你检测到的是人,那么你可以提高这个阈值值(这意味着你希望以牺牲图像中一些人为代价来获得高精度)。相反,如果你想确保检测到所有人(即你希望有一个高召回率),那么你可以降低阈值;在这种情况下,将会发生更多的误检。

这里是一个检测结果的示例:

人体检测

需要注意的是,当分类器应用于整个图像时,在连续位置应用的多窗口通常会围绕正样本产生多个检测。当两个或更多边界框在大约相同的位置重叠时,最好的做法是只保留其中一个。有一个名为 cv::groupRectangles 的函数,它简单地合并相似位置和相似大小的矩形(此函数由 detectMultiScale 自动调用)。实际上,在特定位置获得一组检测甚至可以被视为一个指标,确认我们确实在这个位置有一个正实例。这就是为什么 cv::groupRectangles 函数允许我们指定一个检测簇的最小尺寸,以便将其接受为正检测(即孤立检测应被丢弃)。这是 detectMultiScale 方法的最后一个参数。将其设置为 0 将保留所有检测(不进行分组),在我们的例子中,这导致了以下结果:

人物检测

深度学习和卷积神经网络

我们在介绍机器学习这一章时,不能不提及深度卷积神经网络。这些网络在计算机视觉分类问题中的应用已经取得了令人印象深刻的成果。实际上,它们在解决现实世界问题时表现出的卓越性能,以至于现在为之前无法想象的新一代应用打开了大门。

深度学习基于 20 世纪 50 年代末引入的神经网络理论。那么,为什么它们今天会如此受到关注呢?基本上有两个原因:首先,现在的计算能力允许部署能够解决挑战性问题的神经网络。虽然第一个神经网络(感知器)只有一个层和很少的权重参数需要调整,但今天的网络可以有数百层和数百万个参数需要优化(因此得名深度网络)。其次,今天可用的海量数据使得它们的训练成为可能。为了表现良好,深度网络确实需要数千甚至数百万个标注样本(这是由于需要优化的参数数量非常庞大)。

最受欢迎的深度网络是卷积神经网络CNN)。正如其名所示,它们基于卷积操作(参见第六章,过滤图像)。在这种情况下,要学习的参数是所有组成网络的滤波器内核中的值。这些滤波器被组织成层,其中早期层提取基本形状,如线条和角,而高层则逐步检测更复杂的模式(例如,在人类检测器中,例如眼睛、嘴巴、头发)。

OpenCV3 拥有一个 深度神经网络 模块,但这个模块主要用于导入使用 TensorFlow、Caffe 或 Torch 等其他工具训练的深度网络。当构建你未来的计算机视觉应用时,你肯定会需要查看深度学习理论及其相关工具。

参见

  • Describing and Matching Interest Points 的第九章 “描述和匹配兴趣点” 中,Describing and matching local intensity patterns 菜单描述了与 HOG 描述符相似的 SIFT 描述符

  • N. Dalal 和 B. Triggs 在 2005 年 Computer Vision and Pattern Recognition 会议上的文章 Histograms of Oriented Gradients for Human Detection 是介绍用于人体检测的梯度直方图的经典论文

  • Y. LeCun、Y. Bengio 和 G. Hinton 在 Nature, no 521,2015 年发表的 Deep Learning 文章是探索深度学习世界的良好起点

posted @ 2025-09-21 12:11  绝不原创的飞龙  阅读(27)  评论(0)    收藏  举报