OpenCV-计算机视觉应用编程秘籍-全-
OpenCV 计算机视觉应用编程秘籍(全)
原文:
annas-archive.org/md5/746e42d5d67354a99dd75a9502953e31译者:飞龙
前言
OpenCV(开源计算机视觉)是一个包含 500 多个针对图像和视频分析优化的算法的开源库。自 1999 年推出以来,它已被计算机视觉研究者和开发者社区广泛采用为主要开发工具。OpenCV 最初由 Gary Bradski 领导的一个团队在英特尔开发,作为推进视觉研究并促进基于丰富视觉、CPU 密集型应用程序开发的倡议。经过一系列的测试版发布后,2006 年推出了 1.0 版本。2009 年,随着 OpenCV 2 的推出,发生了第二次重大发布,提出了重要变化,特别是我们在这本书中使用的新的 C++接口。2012 年,OpenCV 重塑了自己,成为一个非营利性基金会(opencv.org/),它依赖于众筹来支持其未来的发展。
本书是《OpenCV 计算机视觉应用编程食谱》的新版。所有前版的编程食谱都经过了审查和更新。我们还添加了新的内容,为读者提供对库的基本功能的更全面覆盖。本书涵盖了库的许多功能,并展示了如何使用它们来完成特定任务。我们的目标不是提供对 OpenCV 函数和类提供的每个选项的详细覆盖,而是给你构建应用程序所需的基本元素。在这本书中,我们还探讨了图像分析的基本概念,并描述了一些计算机视觉中的重要算法。
本书为你提供了一个进入图像和视频分析世界的机会。然而,这只是一个开始。好消息是 OpenCV 仍在不断发展和扩展。只需查阅 OpenCV 在线文档opencv.org/,即可了解库能为你做什么。你还可以访问作者的网站www.laganiere.name,获取关于此食谱的更新信息。
本书涵盖的内容
第一章, 玩转图像,介绍了 OpenCV 库,并展示了如何构建可以读取和显示图像的简单应用程序。它还介绍了 OpenCV 的基本数据结构。
第二章, 操作像素,解释了如何读取图像。它描述了不同的扫描图像的方法,以便对每个像素执行操作。
第三章, 使用类处理彩色图像,包含了一系列的食谱,展示了各种面向对象的设计模式,这些模式可以帮助你构建更好的计算机视觉应用。它还讨论了图像中颜色的概念。
第四章, 使用直方图计数像素,向您展示如何计算图像直方图以及如何使用它们来修改图像。展示了基于直方图的不同应用,它们实现了图像分割、目标检测和图像检索。
第五章, 使用形态学操作转换图像,探讨了数学形态学的概念。它介绍了不同的算子,并告知您如何使用它们来检测图像中的边缘、角和段。
第六章, 过滤图像,向您介绍频率分析和图像过滤的原理。它展示了如何将低通和高通滤波器应用于图像,并介绍了导数算子的概念。
第七章, 提取线、轮廓和组件,专注于几何图像特征的检测。它解释了如何在图像中提取轮廓、线和连通组件。
第八章, 检测兴趣点,描述了图像中的各种特征点检测器。
第九章, 描述和匹配兴趣点,解释了如何计算兴趣点的描述符并将其用于图像间点的匹配。
第十章, 在图像中估计投影关系,探讨了同一场景的两个图像之间的投影关系。它还描述了相机校准的过程,并重新审视了匹配特征点的问题。
第十一章, 处理视频序列,为您提供了一个读取和写入视频序列并处理其帧的框架。它还向您展示了如何从一帧跟踪到另一帧跟踪特征点,以及如何提取在相机前移动的前景对象。
阅读这本书你需要什么
这本食谱基于 OpenCV 库的 C++ API。因此,假设您对 C++语言有一些经验。为了运行食谱中提供的示例并进行实验,您需要一个良好的 C++开发环境。Microsoft Visual Studio 和 Qt 是两个流行的选择。
这本书面向谁
本烹饪书适合希望学习如何使用 OpenCV 库构建计算机视觉应用的初学者 C++程序员。它也适合希望了解计算机视觉编程概念的资深软件开发人员。它可以作为大学级别计算机视觉课程的配套书籍。它是图像处理和计算机视觉研究生和研究人员的优秀参考书。
习惯用法
在本书中,你会发现许多不同风格的文本,用于区分不同类型的信息。以下是一些这些风格的示例及其含义的解释。
文本中的代码单词、文件夹名称、文件名、文件扩展名、路径名、虚拟 URL 和用户输入如下所示:"非常方便,这个检查被封装在cv::Mat的create方法中"。
代码块设置如下:
// use image with a Mat_ template
cv::Mat_<uchar> im2(image);
im2(50,100)= 0; // access to row 50 and column 100
读者反馈
我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢什么或可能不喜欢什么。读者反馈对我们开发你真正从中受益的标题非常重要。
要发送给我们一般性的反馈,只需发送一封电子邮件到 <feedback@packtpub.com>,并在邮件主题中提及书籍标题。
如果你在某个领域有专业知识,并且对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在你已经是 Packt 书籍的骄傲所有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你购买的所有 Packt 书籍的账户中下载示例代码文件,网址为 www.packtpub.com。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给你。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫败感,并帮助我们改进本书的后续版本。如果你发现任何错误清单,请通过访问 www.packtpub.com/submit-errata,选择你的书籍,点击错误清单提交表单链接,并输入你的错误清单详情。一旦你的错误清单得到验证,你的提交将被接受,错误清单将被上传到我们的网站,或添加到该标题的错误清单部分。任何现有的错误清单都可以通过从 www.packtpub.com/support 选择你的标题来查看。
侵权
在互联网上对版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者方面的帮助,以及我们为您提供有价值内容的能力。
问题
如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。
第一章. 玩转图像
在本章中,我们将带你开始使用 OpenCV 库。你将学习如何执行以下任务:
-
安装 OpenCV 库
-
加载、显示和保存图像
-
探索 cv::Mat 数据结构
-
定义感兴趣区域
简介
本章将教授你 OpenCV 的基本元素,并展示如何完成最基本图像处理任务:读取、显示和保存图像。然而,在开始使用 OpenCV 之前,你需要安装这个库。这是一个简单的过程,在第一章的第一个菜谱中有解释。
你所有的计算机视觉应用程序都将涉及图像的处理。这就是为什么 OpenCV 提供给你的最基本工具是一个用于处理图像和矩阵的数据结构。这是一个功能强大的数据结构,具有许多有用的属性和方法。它还包含一个高级内存管理模型,这极大地简化了应用程序的开发。本章的最后两个菜谱将教你如何使用这个重要的 OpenCV 数据结构。
安装 OpenCV 库
OpenCV 是一个开源库,用于开发在 Windows、Linux、Android 和 Mac OS 上运行的计算机视觉应用程序。它可以在学术和商业应用程序下使用 BSD 许可证,该许可证允许你自由使用、分发和修改它。本菜谱将向你展示如何在你的机器上安装库。
准备工作
当你访问 OpenCV 官方网站 opencv.org/ 时,你将找到库的最新版本、在线文档以及许多其他关于 OpenCV 的有用资源。
如何操作...
从 OpenCV 网站进入对应你选择平台(Unix/Windows 或 Android)的 下载 页面。从那里,你可以下载 OpenCV 软件包。然后你需要解压它,通常在一个与库版本相对应的目录下(例如,在 Windows 中,你可以在 C:\OpenCV2.4.9 下保存未解压的目录)。完成此操作后,你将在选择的位置找到构成库的文件和目录集合。值得注意的是,你将在这里找到 sources 目录,它包含所有源文件。(是的,它是开源的!)然而,为了完成库的安装并使其准备好使用,你需要进行一个额外的步骤:为你的选择环境生成库的二进制文件。这确实是你要决定将用于创建你的 OpenCV 应用程序的目标平台的时候。你应该使用哪个操作系统?Windows 还是 Linux?你应该使用哪个编译器?Microsoft VS2013 还是 MinGW?32 位还是 64 位?你将在项目开发中使用的 集成开发环境(IDE)也将指导你做出这些选择。
注意,如果你在 Windows 操作系统下使用 Visual Studio 进行工作,可执行安装包很可能会不仅安装库源代码,还会安装构建你的应用程序所需的所有预编译的二进制文件。请检查build目录;它应该包含x64和x86子目录(对应 64 位和 32 位版本)。在这些子目录中,你应该找到如vc10、vc11和vc12这样的目录;这些目录包含不同版本 MS Visual Studio 的二进制文件。在这种情况下,你就可以开始使用 OpenCV 了。因此,你可以跳过本食谱中描述的编译步骤,除非你想使用特定选项进行定制构建。
为了完成安装过程并构建 OpenCV 的二进制文件,你需要使用可在cmake.org找到的CMake工具。CMake 是另一个开源软件工具,它使用平台无关的配置文件来控制软件系统的编译过程。它为你生成编译软件库所需的环境中的makefiles或工作空间。因此,你需要下载并安装 CMake。然后你可以通过命令行运行它,但使用 CMake 的 GUI(cmake-gui)会更简单。在后一种情况下,你只需要指定包含 OpenCV 库源代码的文件夹和将包含二进制文件的文件夹。你需要点击Configure来选择你选择的编译器,然后再次点击Configure。

现在,你可以通过点击Generate按钮来生成你的项目文件。这些文件将允许你编译库。这是安装过程的最后一步,它将使库在你的开发环境中准备好使用。例如,如果你选择了 Visual Studio,那么你只需要打开 CMake 为你创建的顶级解决方案文件(最可能是OpenCV.sln文件)。然后你在 Visual Studio 中发出Build Solution命令。为了得到Release和Debug两种构建,你将不得不重复编译过程两次,一次针对每个配置。创建的bin目录包含你的可执行文件在运行时将调用的动态库文件。请确保从控制面板设置你的系统PATH环境变量,以便你的操作系统可以在你运行应用程序时找到dll文件。

在 Linux 环境中,你将通过运行你的make实用工具命令来使用生成的 makefiles。为了完成所有目录的安装,你还需要运行Build INSTALL或sudo make INSTALL命令。
然而,在您构建库之前,请确保检查 OpenCV 安装程序为您安装了什么;您正在寻找的构建库可能已经存在,这将节省您编译步骤。如果您希望使用 Qt 作为您的 IDE,本食谱的 “更多...” 部分描述了编译 OpenCV 项目的另一种方法。
它是如何工作的...
自 2.2 版本以来,OpenCV 库被分为几个模块。这些模块是位于 lib 目录中的内置库文件。以下是一些常用模块:
-
包含库的核心功能,特别是基本数据结构和算术函数的
opencv_core模块 -
包含主要图像处理函数的
opencv_imgproc模块 -
包含图像和视频读写函数以及一些用户界面函数的
opencv_highgui模块 -
包含特征点检测器、描述符和特征点匹配框架的
opencv_features2d模块 -
包含相机标定、双视图几何估计和立体功能的
opencv_calib3d模块 -
包含运动估计、特征跟踪和前景提取函数和类的
opencv_video模块 -
包含人脸和人物检测等对象检测函数的
opencv_objdetect模块
该库还包括其他实用模块,这些模块包含机器学习函数(opencv_ml)、计算几何算法(opencv_flann)、贡献代码(opencv_contrib)、过时代码(opencv_legacy)和 GPU 加速代码(opencv_gpu)。您还会找到其他专门库,它们实现了更高级的功能,例如 opencv_photo 用于计算摄影和 opencv_stitching 用于图像拼接算法。还有一个名为 opencv_nonfree 的库模块,其中包含在使用上可能存在潜在限制的函数。当您编译应用程序时,您必须将程序链接到包含您使用的 OpenCV 函数的库。很可能是前面列出的前三个函数以及一些其他函数,具体取决于您应用程序的范围。
所有这些模块都与它们相关的头文件相关联(位于 include 目录中)。因此,典型的 OpenCV C++ 代码将首先包含所需的模块。例如(这是建议的声明风格):
#include <opencv2/core/core.hpp>
#include <opencv2/imgproc/imgproc.hpp>
#include <opencv2/highgui/highgui.hpp>
小贴士
下载示例代码
您可以从您在 www.packtpub.com 的账户下载您购买的所有 Packt 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册以直接将文件通过电子邮件发送给您。
您可能会看到以下命令开始的 OpenCV 代码:
#include "cv.h"
这是因为它使用了旧式风格,在库被重构为模块之前。最后,请注意,OpenCV 将来将会进行重构;因此,如果你下载的版本比 2.4 更新,你可能不会看到相同的模块划分。
还有更多...
OpenCV 网站opencv.org/包含了关于如何安装库的详细说明。它还包含了一份完整的在线文档,其中包括关于库不同组件的几个教程。
使用 Qt 进行 OpenCV 开发
Qt 是一个用于 C++应用程序的跨平台 IDE,作为一个开源项目开发。它提供 LPGL 开源许可证以及商业(付费)许可证,用于开发专有项目。它由两个独立的元素组成:一个名为 Qt Creator 的跨平台 IDE 以及一组 Qt 类库和开发工具。使用 Qt 开发 C++应用程序有以下好处:
-
这是一个由 Qt 社区开发的开源倡议,它为你提供了访问不同 Qt 组件源代码的权限。
-
它是一个跨平台 IDE,这意味着你可以开发可以在不同操作系统上运行的应用程序,例如 Windows、Linux、Mac OS X 等。
-
它包含了一个完整且跨平台的 GUI 库,遵循有效的面向对象和事件驱动模型。
-
Qt 还包括几个跨平台库,这些库可以帮助你开发多媒体、图形、数据库、多线程、Web 应用程序以及许多其他用于设计高级应用程序的有趣构建块。
你可以从qt-project.org/下载 Qt。当你安装它时,你会被提供选择不同编译器的选项。在 Windows 下,MinGW 是 Visual Studio 编译器的优秀替代品。
使用 Qt 编译 OpenCV 库特别容易,因为它可以读取 CMake 文件。一旦安装了 OpenCV 和 CMake,只需从 Qt 菜单中选择打开文件或项目...,然后打开你将在 OpenCV 的sources目录下找到的CMakeLists.txt文件。这将创建一个 OpenCV 项目,你可以使用 Qt 的构建项目命令来构建它。

你可能会得到一些警告,但这些没有后果。
OpenCV 开发者网站
OpenCV 是一个开源项目,欢迎用户贡献。你可以在code.opencv.org访问开发者网站。在其他方面,你可以访问目前正在开发的 OpenCV 版本。社区使用 Git 作为他们的版本控制系统。然后你必须使用它来检出 OpenCV 的最新版本。Git 也是一个免费的开源软件系统;它可能是你可以用来管理你自己的源代码的最佳工具。你可以从git-scm.com/下载它。
参见
-
我的网站 (www.laganiere.name) 也展示了如何逐步安装 OpenCV 库的最新版本
-
下一个菜谱的 还有更多... 部分解释了如何使用 Qt 创建 OpenCV 项目
加载、显示和保存图像
现在是时候运行你的第一个 OpenCV 应用程序了。由于 OpenCV 是关于图像处理的,这个任务将向你展示如何在图像应用程序开发中执行最基本操作。这些操作包括从文件中加载输入图像,在窗口中显示图像,应用处理函数,以及在磁盘上存储输出图像。
准备工作
使用你喜欢的 IDE(例如,MS Visual Studio 或 Qt),创建一个新的控制台应用程序,其中包含一个准备填充的 main 函数。
如何做...
首先要做的是包含头文件,声明你将使用的类和函数。在这里,我们只想显示一个图像,因此我们需要包含声明图像数据结构的核心库和包含所有图形界面函数的 highgui 头文件:
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/highgui.hpp>
我们的主函数首先声明一个将保存图像的变量。在 OpenCV 2 中,定义 cv::Mat 类的对象:
cv::Mat image; // create an empty image
这个定义创建了一个大小为 0 x 0 的图像。这可以通过访问 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_LOAD_IMAGE_GRAYSCALE);
这将生成一个由无符号字节(C++中的unsigned char)组成的图像,OpenCV 使用定义的常量CV_8U来指定。或者,有时即使图像被保存为灰度图像,也必须以 3 通道彩色图像的形式读取图像。这可以通过调用带有正第二个参数的imread函数来实现:
// read the input image as a 3-channel color image
image= cv::imread("puppy.bmp", CV_LOAD_IMAGE_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 的值)或黑色(对于小于 1.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_MOUSEMOVE, CV_EVENT_LBUTTONUP, CV_EVENT_RBUTTONDOWN, 和 CV_EVENT_RBUTTONUP。
在图像上绘制
OpenCV 还提供了一些在图像上绘制形状和写入文本的函数。基本形状绘制函数的示例有 circle, ellipse, line, 和 rectangle。以下是如何使用 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
在我们的测试图像上调用这两个函数将产生以下截图:

使用 Qt 运行示例
如果您想使用 Qt 运行您的 OpenCV 应用程序,您需要创建项目文件。对于本菜谱的示例,以下是项目文件 (loadDisplaySave.pro) 的样子:
QT += core
QT -= gui
TARGET = loadDisplaySave
CONFIG += console
CONFIG -= app_bundle
TEMPLATE = app
SOURCES += loadDisplaySave.cpp
INCLUDEPATH += C:\OpenCV2.4.9\build\include
LIBS += -LC:\OpenCV2.4.9\build\x86\MinGWqt32\lib \
-lopencv_core249 \
-lopencv_imgproc249 \
-lopencv_highgui249
此文件显示了您可以在哪里找到 include and library 文件。它还列出了示例中使用的库模块。请确保使用与 Qt 所使用的编译器兼容的库二进制文件。请注意,如果您下载了本书示例的源代码,您将找到可以与 Qt(或 CMake)一起打开的 CMakeLists 文件,以便创建相关项目。
参见
-
cv::Mat类是用于存储您的图像(以及显然,其他矩阵数据)的数据结构。这个数据结构是所有 OpenCV 类和函数的核心;下一菜谱将详细介绍这个数据结构。 -
您可以从
github.com/laganiere/下载本书示例的源代码。
探索 cv::Mat 数据结构
在前面的菜谱中,您已经了解了 cv::Mat 数据结构。正如所提到的,这是库的关键元素。它用于操作图像和矩阵(实际上,从计算和数学的角度来看,图像是一个矩阵)。由于您将在应用程序开发中广泛使用此数据结构,因此熟悉它是必不可少的。值得注意的是,您将在本菜谱中了解到,此数据结构包含一个优雅的内存管理机制,允许高效使用。
如何做...
让我们编写以下测试程序,以便我们可以测试 cv::Mat 数据结构的不同属性:
#include <iostream>
#include <opencv2/core/core.hpp>
#include <opencv2/highgui/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() {
// define image windows
cv::namedWindow("Image 1");
cv::namedWindow("Image 2");
cv::namedWindow("Image 3");
cv::namedWindow("Image 4");
cv::namedWindow("Image 5");
cv::namedWindow("Image");
// 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 数据结构本质上由两部分组成:一个头和一个数据块。头包含了与矩阵相关联的所有信息(大小、通道数、数据类型等)。前面的食谱向您展示了如何访问该结构头中包含的一些属性(例如,通过使用 cols、rows 或 channels)。数据块包含了一个图像的所有像素值。头中包含一个指针变量,它指向这个数据块;它是 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)。您还可以声明大小为 16 和 32 的整数(例如,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) 来完成。
图像大小也经常需要传递给函数。我们已经提到,cols 和 rows 属性可以用来获取 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();
如果您需要将图像复制到另一个不一定具有相同数据类型的图像中,您必须使用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实例传输到gray图像。当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::Scalar和cv::Vec;这个结构将在下一章中介绍。还有一个cv::OutputArray代理类,用于指定某些方法或函数返回的数组。
旧的 IplImage 结构
在 OpenCV 的版本 2 中,引入了一个新的 C++接口。之前,使用了类似 C 的函数和结构(并且仍然可以使用)。特别是,图像是通过IplImage结构来操作的。这个结构是从IPL库(即Intel Image Processing库)继承的,现在已集成到IPP库(Intel Integrated Performance Primitive库)中。如果你使用的是用旧 C 接口创建的代码和库,你可能需要操作这些IplImage结构。幸运的是,有一个方便的方法可以将IplImage结构转换为cv::Mat对象,如下面的代码所示:
IplImage* iplImage = cvLoadImage("puppy.bmp");
cv::Mat image(iplImage,false);
cvLoadImage函数是用于加载图像的 C 接口函数。cv::Mat对象构造函数中的第二个参数表示数据不会被复制(如果你想要一个新的副本,请将其设置为true;默认值为false,因此可以省略),也就是说,IplImage和image将共享相同的数据。在这里,你需要小心不要创建悬空指针。因此,将IplImage指针封装在 OpenCV 2 提供的引用计数指针类中更安全:
cv::Ptr<IplImage> iplImage = cvLoadImage("puppy.bmp");
否则,如果你需要显式地释放由你的IplImage结构指向的内存,你需要这样做:
cvReleaseImage(&iplImage);
记住,你应该避免使用这个已弃用的数据结构。相反,始终使用cv::Mat数据结构。
参见
-
完整的 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::Mat的operator()函数返回另一个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 操作都给你使用掩码的机会。
参见
- 在第二章的“使用邻接访问扫描图像”食谱中将要使用的
row和col方法,操作像素。这些是rowRange和colRange方法的特殊情况,其中起始和结束索引相等,以便定义单行或单列的 ROI。
第二章. 操作像素
在本章中,我们将介绍以下食谱:
-
访问像素值
-
使用指针扫描图像
-
使用迭代器扫描图像
-
编写高效的图像扫描循环
-
使用邻接访问扫描图像
-
执行简单的图像算术
-
重映射图像
简介
为了构建计算机视觉应用,你需要能够访问图像内容,并最终修改或创建图像。本章将教你如何操作图像元素(也称为像素)。你将学习如何扫描图像并处理其每个像素。你还将学习如何高效地完成这项工作,因为即使是尺寸适中的图像也可能包含数十万个像素。
从本质上讲,图像是一个数值矩阵。这就是为什么,正如我们在第一章中学习的,“玩转图像”,OpenCV 2 使用cv::Mat数据结构来操作它们。矩阵中的每个元素代表一个像素。对于灰度图像(黑白图像),像素是无符号 8 位值,其中0对应黑色,255对应白色。在彩色图像的情况下,需要三个主要颜色值来重现不同的可见颜色。这是由于我们的视觉系统是三原色的;视网膜上的三种锥状细胞将颜色信息传递给大脑。这意味着对于彩色图像,每个像素必须关联三个值。在摄影和数字成像中,常用的主要颜色通道是红色、绿色和蓝色。在这种情况下,矩阵元素由三个 8 位值的组合组成。
注意,尽管 8 位通道通常足够,但在某些特殊应用中(例如医学成像)需要 16 位通道。
正如我们在上一章中看到的,OpenCV 还允许你创建具有其他类型像素值(例如,整数CV_32U或CV_32S)和浮点数(CV_32F)的矩阵(或图像)。这些对于存储某些图像处理任务中的中间值非常有用。大多数操作可以应用于任何类型的矩阵;其他操作需要特定的类型或仅与特定数量的通道一起工作。因此,为了避免常见的编程错误,理解函数或方法的先决条件至关重要。
在本章中,我们使用以下彩色图像作为输入(请参阅书籍的图形 PDF 以查看此图像的颜色):

访问像素值
为了访问矩阵的每个单独元素,你只需指定其行和列号。相应的元素,在多通道图像的情况下可以是单个数值或值的向量,将被返回。
准备工作
为了说明对像素值的直接访问,我们将创建一个简单的函数,该函数向图像添加盐和胡椒噪声。正如其名所示,盐和胡椒噪声是一种特定的噪声,其中一些随机选择的像素被白色或黑色像素所替代。这种类型的噪声可能在通信故障中发生,当某些像素值在传输过程中丢失时。在我们的例子中,我们将简单地随机选择一些像素并将它们指定为白色。
如何做...
我们创建一个函数,该函数接收一个输入图像。这是我们函数将要修改的图像。第二个参数是我们想要覆盖白色值的像素数:
void salt(cv::Mat image, int n) {
int i,j;
for (int k=0; k<n; k++) {
// rand() is the random number generator
i= std::rand()%image.cols;
j= std::rand()%image.rows;
if (image.type() == CV_8UC1) { // gray-level image
image.at<uchar>(j,i)= 255;
} else if (image.type() == CV_8UC3) { // color image
image.at<cv::Vec3b>(j,i)[0]= 255;
image.at<cv::Vec3b>(j,i)[1]= 255;
image.at<cv::Vec3b>(j,i)[2]= 255;
}
}
}
前面的函数由一个循环组成,该循环将 255 的值赋给随机选择的像素 n 次。在这里,使用随机数生成器选择像素列 i 和行 j。请注意,使用 type 方法,我们区分了灰度图像和彩色图像的两种情况。在灰度图像的情况下,将 255 赋给单个 8 位值。对于彩色图像,您需要将 255 赋给三个主颜色通道,以获得白色像素。
您可以通过传递之前已打开的图像来调用此函数。请参考以下代码:
// open the image
cv::Mat image= cv::imread("boldt.jpg");
// call function to add noise
salt(image,3000);
// display image
cv::namedWindow("Image");
cv::imshow("Image",image);
生成的图像将如下所示:

它是如何工作的...
cv::Mat 类包括几个方法来访问图像的不同属性。公共成员变量 cols 和 rows 给出了图像中的列数和行数。对于元素访问,cv::Mat 有 at (int y, int x) 方法。然而,方法的返回类型必须在编译时已知,并且由于 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 按顺序存储通道值:蓝色、绿色和红色(因此,蓝色是通道 0)。
类似向量类型也存在用于 2 元素和 4 元素向量(cv::Vec2b 和 cv::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 是一个对应于 uchar 矩阵的 cv::Mat 变量,那么你可以编写以下代码:
// use image with a Mat_ template
cv::Mat_<uchar> im2(image);
im2(50,100)= 0; // access to row 50 and column 100
由于 cv::Mat_ 元素的类型是在变量创建时声明的,operator() 方法在编译时就知道要返回哪种类型。除了写起来更短之外,使用 operator() 方法提供的结果与 at 方法完全相同。
参见
-
Scanning an image with pointers 菜谱的 There's more… 部分解释了如何创建具有输入和输出参数的函数
-
高效图像扫描循环编写 菜谱提出了关于这种方法效率的讨论
使用指针扫描图像
在大多数图像处理任务中,你需要扫描图像中的所有像素以执行计算。考虑到需要访问的大量像素,以高效的方式执行此任务至关重要。本菜谱以及下一个菜谱将向您展示实现高效扫描循环的不同方法。本菜谱使用指针算术。
准备工作
我们将通过完成一个简单的任务来展示图像扫描过程:减少图像中的颜色数量。
彩色图像由 3 通道像素组成。这些通道中的每一个对应于三种主颜色之一——红色、绿色和蓝色的强度值。由于这些值中的每一个都是 8 位无符号字符,所以总共有256x256x256种颜色,这超过了一千六百万种颜色。因此,为了减少分析复杂性,有时减少图像中的颜色数量是有用的。实现这一目标的一种方法是将 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);
这将给出以下图像(请参考书籍的图形 PDF 查看此图像的颜色):

它是如何工作的...
在彩色图像中,图像数据缓冲区的第一个三个字节给出左上像素的 3 个颜色通道的值,接下来的三个字节是第一行的第二个像素的值,依此类推(记住,OpenCV 默认使用 BGR 通道顺序)。一个宽度为W和高度为H的图像将需要一个WxHx3个uchars的内存块。然而,出于效率的考虑,行的长度可以通过添加一些额外的像素来填充。这是因为某些多媒体处理器芯片(例如,Intel MMX 架构)在它们的行是 4 或 8 的倍数时可以更有效地处理图像。显然,这些额外的像素不会被显示或保存;它们的精确值被忽略。OpenCV 将填充行的长度指定为有效宽度。显然,如果图像没有填充额外的像素,有效宽度将与实际图像宽度相等。我们已经了解到cols和rows属性给出了图像的宽度和高度;同样,step数据属性给出了以字节为单位的实际宽度。即使您的图像类型不是uchar,step数据也会给出每行的字节数。像素元素的大小由elemSize方法给出(例如,对于 3 通道短整数矩阵(CV_16SC3),elemSize将返回6)。回想一下,图像中的通道数由nchannels方法给出(对于灰度图像将是1,对于彩色图像将是3)。最后,total方法返回矩阵中的总像素数(即矩阵条目数)。
每行像素值的数量由以下代码给出:
int nc= image.cols * image.channels();
为了简化指针算术的计算,cv::Mat类提供了一个直接给出图像行地址的方法。这是ptr方法。它是一个模板方法,返回第j行的地址:
uchar* data= image.ptr<uchar>(j);
注意,在处理语句中,我们可以等效地使用指针算术从列到列移动。因此,我们可以编写以下代码:
*data= *data/div*div + div2; data++;
还有更多...
本食谱中提出的颜色减少函数提供了一种完成此任务的方法。您也可以使用其他颜色减少公式。该函数的更通用版本还可以指定不同的输入和输出图像。通过考虑图像数据的连续性,图像扫描也可以变得更加高效。最后,还可以使用常规的低级指针算术来扫描图像缓冲区。所有这些元素都在以下小节中讨论。
其他颜色减少公式
在我们的示例中,颜色减少是通过利用整数除法将除法结果向下取整到最接近的较低整数来实现的,如下所示:
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
通常,位运算可能会导致非常高效的代码,因此当效率是一个要求时,它们可以构成一个强大的替代方案。
有输入和输出参数
在我们的颜色缩减示例中,转换直接应用于输入图像,这被称为就地转换。这样,不需要额外的图像来保存输出结果,这可以在内存使用成为关注点时节省内存。然而,在某些应用中,用户可能希望保持原始图像不变。在这种情况下,用户将被迫在调用函数之前创建图像的副本。请注意,创建图像的相同深度副本的最简单方法是调用 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::Mat 的 create 方法中。这是当矩阵必须用新的大小和类型重新分配时要使用的方法。如果矩阵已经指定了大小和类型,则不执行任何操作,该方法简单地返回而不修改实例。
因此,我们的函数应该简单地从调用 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
}
在输入和输出提供相同图像的情况下,此函数与本章中介绍的第一个版本完全等价。如果提供另一个图像作为输出,则无论在函数调用之前是否已分配图像,该函数都将正确工作。
连续图像的高效扫描
我们之前解释过,出于效率考虑,图像可以在每行的末尾填充额外的像素。然而,值得注意的是,当图像未填充时,它也可以被视为一个WxH像素的长一维数组。一个方便的cv::Mat方法可以告诉我们图像是否已填充。这是isContinuous方法,如果图像不包含填充像素则返回true。请注意,我们也可以通过以下测试来检查矩阵的连续性:
// 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
int nc= image.cols * image.channels();
if (image.isContinuous())
{
// then no padded pixels
nc= nc*nl;
nl= 1; // it is now a long 1D array
}
// this loop is executed only once
// in case of continuous images
for (int j=0; j<nl; j++) {
uchar* data= image.ptr<uchar>(j);
for (int i=0; i<nc; i++) {
// process each pixel ---------------------
data[i]= data[i]/div*div + div/2;
// end of pixel processing ----------------
} // end of line
}
}
现在,当连续性测试告诉我们图像不包含填充像素时,我们通过将宽度设置为1和高度设置为WxH来消除外循环。请注意,这里也可以使用reshape方法。在这种情况下,您将编写以下代码:
if (image.isContinuous())
{
// no padded pixels
image.reshape(1, // new number of channels
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();
然而,即使这种方法在我们的例子中可行,也不建议您这样操作。
参见
- 本章中关于高效图像扫描循环的配方提出了对这里提出扫描方法效率的讨论
使用迭代器扫描图像
在面向对象编程中,遍历数据集合通常使用迭代器。迭代器是专门构建的类,用于遍历集合中的每个元素,隐藏了针对特定集合的迭代方式。这种信息隐藏原则的应用使得扫描集合变得更加容易和安全。此外,它使得无论使用何种类型的集合,形式都相似。标准模板库(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;
然后,您使用通常的 begin 和 end 迭代器方法遍历像素,但要注意,这些方法同样是模板方法。因此,我们的颜色减少函数现在编写如下:
void colorReduce(cv::Mat &image, int div=64) {
// obtain iterator at initial position
cv::Mat_<cv::Vec3b>::iterator it=
image.begin<cv::Vec3b>();
// obtain end position
cv::Mat_<cv::Vec3b>::iterator itend=
image.end<cv::Vec3b>();
// loop over all pixels
for ( ; it!= itend; ++it) {
// process each pixel ---------------------
(*it)[0]= (*it)[0]/div*div + div/2;
(*it)[1]= (*it)[1]/div*div + div/2;
(*it)[2]= (*it)[2]/div*div + div/2;
// end of pixel processing ----------------
}
}
记住,这里的迭代器返回一个 cv::Vec3b 实例,因为我们正在处理彩色图像。每个颜色通道元素都是通过解引用运算符 [] 访问的。
它是如何工作的...
无论扫描哪种类型的集合,使用迭代器进行操作总是遵循相同的模式。
首先,您使用适当的专用类创建迭代器对象,在我们的例子中是 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 像素一次。
最后,在处理循环内部,你使用解引用操作符 * 来访问当前元素,利用它可以读取(例如,element= *it;)或写入(例如,*it= element;)。请注意,也可以创建常量迭代器,如果你收到对 const cv::Mat 的引用,或者如果你希望表明当前循环不会修改 cv::Mat 实例。这些声明如下:
cv::MatConstIterator_<cv::Vec3b> it;
或者,它们可以这样声明:
cv::Mat_<cv::Vec3b>::const_iterator it;
更多...
在本节中,迭代器的起始和结束位置是通过使用 begin 和 end 模板方法获得的。正如我们在本章的第一个部分中所做的那样,我们也可以使用对 cv::Mat_ 实例的引用来获得它们。这将避免在 begin 和 end 方法中指定迭代器类型,因为当创建 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()。这给我们每秒的周期数。为了获得给定函数(或代码片段)的计算时间,通常要使用的模式如下:
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 机器)。观察它们的相对差异相当有趣。这些结果还取决于用于生成可执行文件的特定编译器。我们的测试报告了将分辨率为 4288 x 2848 像素的图像颜色减少的平均时间。
首先,我们比较了在“使用指针扫描图像”配方中的“更多...”部分中提出的计算颜色减少的三个方法。有趣的是观察到,使用位运算符的公式在 9.5 毫秒时比其他方法快得多。使用整数除法的版本为 26 毫秒。然而,基于取模运算符的版本却为 33 毫秒。这代表了最快和最慢之间的超过 3 倍差距!因此,在图像循环中识别计算结果的最有效方式非常重要,因为其净影响可能非常显著。
当指定需要重新分配的输出图像而不是就地处理时,执行时间变为 29 毫秒。额外的时间代表了内存分配的开销。
在循环中,你应该避免重复计算那些可以预先计算的值。这显然会消耗时间。例如,你取以下颜色减少函数的内循环:
int nc= image.cols * image.channels();
uchar div2= div>>1;
for (int i=0; i<nc; i++) {
然后,你将其替换为以下版本:
for (int i=0; i<image.cols * image.channels(); i++) {
// . . .
*data++ += div>>1;
之前的代码是一个需要反复计算一行中元素总数和div>>1结果的循环;你将获得 52 毫秒的运行时间,这比原始版本的 26 毫秒慢得多。然而,请注意,一些编译器可能能够优化这类循环,并仍然获得高效的代码。
如“使用迭代器扫描图像”配方中所示,使用迭代器的颜色减少函数版本在 52 毫秒时给出了较慢的结果。迭代器的主要目标是简化图像扫描过程,并使其更不容易出错。
为了完整性,我们还实现了一个使用at方法进行像素访问的函数版本。这个实现的主循环将简单地如下所示:
for (int j=0; j<nl; j++) {
for (int i=0; i<nc; i++) {
// process each pixel ---------------------
image.at<cv::Vec3b>(j,i)[0]=
image.at<cv::Vec3b>(j,i)[0]/div*div + div/2;
image.at<cv::Vec3b>(j,i)[1]=
image.at<cv::Vec3b>(j,i)[1]/div*div + div/2;
image.at<cv::Vec3b>(j,i)[2]=
image.at<cv::Vec3b>(j,i)[2]/div*div + div/2;
// end of pixel processing ----------------
} // end of line
}
当获得 53 毫秒的运行时间时,这种实现方式会慢得多。因此,这种方法仅应用于图像像素的随机访问,而绝不应在扫描图像时使用。
通常,较短的循环,即使处理的元素总数相同,也比较长的单语句循环执行得更有效。同样,如果你需要对像素应用N种不同的计算,请在单个循环中应用所有这些计算,而不是编写N个连续的循环,每个循环对应一种计算。
我们还进行了连续性测试,对于连续图像,它产生一个循环,而不是常规的行和列的双循环。对于非常大的图像,如我们在测试中使用的图像,这种优化并不显著(25 毫秒而不是 26 毫秒),但总的来说,始终使用这种策略是一个好习惯,因为它可以带来速度的显著提升。
还有更多...
多线程是提高算法效率的另一种方式,尤其是在多核处理器的出现之后。OpenMP和Intel Threading Building Blocks(TBB)是两个在并发编程中用于创建和管理线程的流行 API。此外,C++11 现在提供了对线程的内置支持。
参见
-
执行简单的图像算术菜谱展示了使用 OpenCV 2 算术图像运算符的颜色减少函数(在还有更多...部分描述)的实现,其运行时间为 25 毫秒。
-
将查找表应用于修改图像外观菜谱(第四章,使用直方图计数像素)描述了一个基于查找表的颜色减少函数的实现。想法是预先计算所有强度减少值,从而将运行时间缩短到 22 毫秒。
使用邻居访问扫描图像
在图像处理中,通常有一个处理函数,它根据相邻像素的值在每个像素位置计算一个值。当这个邻域包括上一行和下一行的像素时,您就需要同时扫描图像的几行。这个菜谱向您展示了如何实现。
准备工作
为了说明这个菜谱,我们将应用一个锐化图像的处理函数。它基于拉普拉斯算子(将在第六章,过滤图像中讨论)。在图像处理中,确实有一个众所周知的结果,即如果你从图像中减去拉普拉斯算子,图像边缘会被增强,从而得到一个更清晰的图像。
这个锐化值是这样计算的:
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++) {
*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。在其他情况下,可能为这些像素执行特殊的计算,但大多数情况下,没有必要花费时间去处理这么少的像素。在我们的函数中,这些边界像素通过两种特殊方法设置为0。第一种是row,其对应的是col。它们返回一个特殊的cv::Mat实例,该实例由参数中指定的单行 ROI(或单列 ROI)组成(记住,我们在上一章中讨论了感兴趣区域)。这里没有进行复制,因为如果这个一维矩阵的元素被修改,原始图像中的元素也会被修改。这就是当调用setTo方法时我们所做的。这个方法将值赋给矩阵的所有元素。看看以下语句:
result.row(0).setTo(cv::Scalar(0));
前面的语句将结果图像的第一行的所有像素的值赋为0。在 3 通道彩色图像的情况下,你会使用cv::Scalar(a,b,c)来指定要分配给每个像素通道的三个值。
还有更多...
当在像素邻域内进行计算时,通常用核矩阵来表示这一点。这个核描述了参与计算的像素是如何组合以获得所需结果的。对于本食谱中使用的锐化滤波器,核将如下所示:
| 0 | -1 | 0 |
|---|---|---|
| -1 | 5 | -1 |
| 0 | -1 | 0 |
除非另有说明,当前像素对应于核的中心。核中每个单元格的值代表一个乘以相应像素的因子。核应用于像素的结果是所有这些乘积的总和。核的大小对应于邻域的大小(在这里,3 x 3)。使用这种表示法,可以看出,正如锐化滤波器所要求的,当前像素的四个水平和垂直邻居乘以-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[1]+k2*b[i]+k3;
cv::addWeighted(imageA,k1,imageB,k2,k3,resultC);
// c[i]= k*a[1]+b[i];
cv::scaleAdd(imageA,k,imageB,resultC);
对于某些函数,你也可以指定一个掩码:
// if (mask[i]) c[i]= a[i]+b[i];
cv::add(imageA,imageB,resultC,mask);
如果应用掩码,则仅对掩码值为非空的像素执行操作(掩码必须是单通道)。查看cv::subtract、cv::absdiff、cv::multiply和cv::divide函数的不同形式。位运算符(应用于像素二进制表示中每个单独位的运算符)也是可用的:cv::bitwise_and、cv::bitwise_or、cv::bitwise_xor和cv::bitwise_not。cv::min和cv::max运算符,它们找到每个元素的最大或最小像素值,也非常有用。
在所有情况下,始终使用cv::saturate_cast函数(参见前面的菜谱)来确保结果保持在定义的像素值域内(即避免溢出或下溢)。
图像必须具有相同的大小和类型(如果输出图像的大小不匹配输入大小,则将重新分配)。此外,由于操作是按元素进行的,可以使用其中一个输入图像作为输出。
还有一些仅接受单个图像作为输入的运算符:cv::sqrt、cv::pow、cv::abs、cv::cuberoot、cv::exp和cv::log。实际上,对于您必须应用于图像像素的几乎任何操作,都存在 OpenCV 函数。
还有更多...
还可以在cv::Mat实例或cv::Mat实例的各个通道上使用常规的 C++算术运算符。以下两个小节将解释如何做到这一点。
重载图像运算符
非常方便的是,大多数算术函数在 OpenCV 2 中都有相应的运算符重载。因此,cv::addWeighted的调用可以写成以下形式:
result= 0.7*image1+0.9*image2;
上述代码是一种更紧凑的格式,也更易于阅读。这两种编写加权总和的方式是等效的。特别是,在两种情况下都会调用cv::saturate_cast函数。
大多数 C++运算符都已被重载。其中包含位运算符&、|、^和~;min、max和abs函数。比较运算符<、<=、==、!=、>和>=也已重载,并返回一个 8 位二进制图像。您还将找到矩阵乘法m1*m2(其中m1和m2都是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 是因为我们正在操作彩色图像。执行与我们在 编写高效的图像扫描循环 菜谱中所做的相同测试,我们获得 53 毫秒的执行时间。使用图像运算符使得代码如此简单,程序员如此高效,以至于你应该考虑在大多数情况下使用它们。
分割图像通道
有时你可能想独立处理图像的不同通道。例如,你可能只想对图像的一个通道执行操作。当然,你可以在图像扫描循环中实现这一点。然而,你也可以使用 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, result, srcX, srcY, cv::INTER_LINEAR);
}
结果如下:

它是如何工作的...
重新映射的目标是生成一个新版本的图像,其中像素的位置已经改变。为了构建这个新图像,我们需要知道源图像中每个像素在目标图像中的原始位置。因此,所需的映射函数是能够将原始像素位置作为新像素位置的函数。这被称为 反向 映射,因为这种变换描述了新图像的像素如何映射回原始图像。在 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 函数的最后一个参数允许我们选择将使用的方法。像素插值是图像处理中的一个重要概念;这个主题将在 第六章,滤波图像 中讨论。
参见
-
第六章 中 使用低通滤波器滤波图像 的配方中的 更多内容... 部分,滤波图像,解释了像素插值的概念
-
第十章 中 校准相机 的配方,在图像中估计投影关系,使用重映射来校正图像中的镜头畸变
-
第十章 中 计算两张图像之间的单应性 的配方,在图像中估计投影关系,使用透视图像扭曲来构建图像全景
第三章:使用类处理颜色图像
在本章中,我们将涵盖以下菜谱:
-
在算法设计中使用策略模式
-
使用控制器设计模式与处理模块通信
-
转换颜色表示
-
使用色调、饱和度和亮度表示颜色
简介
良好的计算机视觉程序始于良好的编程实践。构建一个无错误的程序只是开始。你真正想要的是一个你可以和与你一起工作的程序员轻松适应和演化的应用程序。本章将向你展示如何充分利用面向对象编程的一些原则,以构建高质量的软件程序。特别是,我们将介绍一些重要的设计模式,这些模式将帮助你构建易于测试、维护和重用的应用程序组件。
设计模式是软件工程中一个众所周知的概念。基本上,设计模式是对软件设计中频繁出现的通用问题的合理、可重用解决方案。已经引入了许多软件模式,并且得到了很好的记录。优秀的程序员应该掌握这些现有模式的工作知识。
本章还有一个次要目标。它将教会你如何玩转图像颜色。本章使用的示例将展示如何检测给定颜色的像素,最后两个菜谱将解释如何处理不同的颜色空间。
在算法设计中使用策略模式
策略设计模式的目标是将算法封装在一个类中。这样,替换给定算法或链式连接几个算法以构建更复杂的过程就变得更容易了。此外,该模式通过尽可能隐藏其复杂性,简化了算法的部署,提供了一个直观的编程接口。
准备工作
假设我们想要构建一个简单的算法,该算法将识别图像中所有具有给定颜色的像素。为此,该算法必须接受一个图像和一个颜色作为输入,并将返回一个二值图像,显示具有指定颜色的像素。我们希望接受颜色的容差将是运行算法前要指定的另一个参数。
如何做…
一旦使用策略设计模式将算法封装在类中,就可以通过创建这个类的实例来部署它。通常,实例会在程序初始化时创建。在构建时,类实例将使用默认值初始化算法的不同参数,这样它就可以立即投入使用。算法的参数值也可以通过适当的方法进行读取和设置。在具有图形用户界面的应用程序中,这些参数可以通过不同的小部件(文本字段、滑块等)显示和修改,以便用户可以轻松地与之互动。
我们将在下一节中向您展示策略类的结构;让我们从一个示例开始,说明它如何部署和使用。让我们编写一个简单的 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
cv::namedWindow("result");
// 4\. Process the image and display the result
cv::imshow("result",cdetect.process(image));
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 指的是二值输出图像。因此,第一步是设置所需的迭代器。然后扫描循环就变得容易实现了。在每次迭代中,都会评估当前像素颜色与目标颜色之间的距离,以检查它是否在由 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::Mat 的 create 方法。记住,只有当指定的尺寸或深度与当前图像结构不对应时,此方法才会进行重新分配。
现在我们已经定义了核心处理方法,让我们看看还需要添加哪些附加方法来部署这个算法。我们之前已经确定了我们的算法需要哪些输入和输出数据。因此,我们首先将定义将持有这些数据的类属性:
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) {}
到目前为止,创建我们类算法实例的用户可以立即使用有效的图像调用 process 方法并获得有效的输出。这是策略模式的另一个目标,即确保算法始终使用有效的参数运行。显然,这个类的用户将想要使用他们自己的设置。这是通过向用户提供适当的 getter 和 setter 来实现的。让我们从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来存储颜色值。再次强调,目的是为了方便用户使用我们的类算法。用户可以简单地选择最适合他们需求的设置器。
还有更多...
这个配方向您介绍了使用策略设计模式将算法封装在类中的想法。在这个配方中使用的示例算法包括识别图像中颜色足够接近指定目标颜色的像素。这种计算也可以用其他方法完成。此外,可以使用函数对象来补充实现策略设计模式。
计算两个颜色向量之间的距离
要计算两个颜色向量之间的距离,我们使用了以下简单的公式:
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, // input image
output, // output image
maxDist, // threshold (must be < 256)
255, // max value
cv::THRESH_BINARY_INV); // thresholding mode
return output;
}
此方法使用 absdiff 函数,该函数计算图像像素与标量值之间的绝对差值。除了标量值之外,还可以提供另一个图像作为此函数的第二个参数。在后一种情况下,将应用逐像素差异;因此,两个图像必须具有相同的大小。然后使用 split 函数(在 第二章 的 执行简单的图像算术 配方的 更多内容... 部分中讨论)提取差异图像的各个通道,以便能够将它们相加。需要注意的是,这个总和有时可能大于 255,但由于总是应用饱和度,结果将停止在 255。结果是,在这个版本中,maxDist 参数也必须小于 256;如果你认为这种行为不可接受,应该进行修正。最后一步是使用阈值函数创建二值图像。这个函数通常用于将所有像素与阈值值(第三个参数)进行比较,在常规阈值模式(cv::THRESH_BINARY)中,它将定义的最大值(第四个参数)分配给所有大于阈值的像素,并将 0 分配给其他像素。在这里,我们使用了逆模式(cv::THRESH_BINARY_INV),其中定义的最大值分配给值低于或等于阈值的像素。同样值得注意的是 cv::THRESH_TOZERO_INV 和 cv::THRESH_TOZERO 模式,它们将保持高于或低于阈值的像素不变。
使用 OpenCV 函数总是一个好主意。这样,你可以快速构建复杂的应用程序,并可能减少错误数量。结果通常更高效(归功于 OpenCV 贡献者的优化努力)。然而,当执行许多中间步骤时,你可能会发现结果方法消耗了更多的内存。
函数对象或函数
使用 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
如你所见,颜色检测方法的调用现在看起来就像一个函数调用。实际上,colordetector 变量可以被用作函数的名称。
相关阅读
-
由 A. Alexandrescu 引入的策略类设计是策略设计模式的一个有趣变体,其中算法在编译时被选择
-
《设计模式:可复用面向对象软件元素》,Erich Gamma 等著,Addison-Wesley,1994 年,是关于该主题的经典书籍之一
使用控制器设计模式与处理模块进行通信
随着你构建更复杂的应用程序,你需要创建多个算法,这些算法可以组合在一起以完成一些高级任务。因此,正确设置应用程序并让所有类进行通信将变得越来越复杂。这时,将应用程序的控制集中在一个类中就变得有利。这就是控制器设计模式背后的想法。控制器是一个在应用程序中扮演中心角色的特定对象,我们将在本食谱中探讨这一点。
准备中
使用您喜欢的 IDE,创建一个简单的基于对话框的应用程序,包含两个按钮;一个按钮用于选择图像,另一个按钮用于开始处理,如下所示:

这里,我们使用前一个食谱中的 ColorDetector 类。
如何做到这一点...
Controller 类的作用是首先创建执行应用程序所需的类。在这里,只有一个类,但在更复杂的应用程序中,会创建多个类。此外,我们还需要两个成员变量来保存输入和输出结果:
class ColorDetectController {
private:
// the algorithm class
ColorDetector *cdetect;
cv::Mat image; // The image to be processed
cv::Mat result; // The image result
public:
ColorDetectController() {
//setting up the application
cdetect= new ColorDetector();
}
在这里,我们选择为我们类使用动态分配;你也可以简单地声明一个类变量。然后你需要定义所有用户需要控制应用程序的设置器和获取器:
// Sets the color distance threshold
void setColorDistanceThreshold(int distance) {
cdetect->setColorDistanceThreshold(distance);
}
// Gets the color distance threshold
int getColorDistanceThreshold() const {
return cdetect->getColorDistanceThreshold();
}
// Sets the color to be detected
void setTargetColor(unsigned char red,
unsigned char green, unsigned char blue) {
cdetect->setTargetColor(blue,green,red);
}
// Gets the color to be detected
void getTargetColor(unsigned char &red,
unsigned char &green, unsigned char &blue) const {
cv::Vec3b color= cdetect->getTargetColor();
red= color[2];
green= color[1];
blue= color[0];
}
// Sets the input image. Reads it from file.
bool setInputImage(std::string filename) {
image= cv::imread(filename);
return !image.empty();
}
// Returns the current input image.
const cv::Mat getInputImage() const {
return image;
}
你还需要一个将被调用的方法来启动过程:
// Performs image processing.
void process() {
result= cdetect->process(image);
}
此外,你需要一个方法来获取处理的结果:
// Returns the image result from the latest processing.
const cv::Mat getLastResult() const {
return result;
}
最后,当应用程序终止(并且Controller类被释放)时,重要的是要清理一切:
// Deletes processor objects created by the controller.
~ColorDetectController() {
delete cdetect; // release memory of dynamically
} // allocated class instance
它是如何工作的...
使用前面提到的Controller类,程序员可以轻松地为将执行你的算法的应用程序构建一个界面。程序员不需要了解所有类是如何连接在一起的,或者找出哪些类中的哪些方法必须被调用才能使一切正常运行。所有这些都是由Controller类完成的。唯一的要求是创建Controller类的一个实例。
在Controller类中定义的设置器和获取器是部署你的算法所必需的。通常,这些方法只是调用适当类中的相应方法。这里使用的简单示例只包括一个类算法,但在一般情况下,将涉及多个类实例。因此,Controller的作用是将请求重定向到适当的类(在面向对象编程中,这种机制称为委托)。控制器模式的另一个目标是简化应用程序类的接口。作为一个这样的简化的例子,考虑setTargetColor和getTargetColor方法。这两个方法都使用uchar来设置和获取感兴趣的颜色。这消除了应用程序程序员了解cv::Vec3b类的必要性。
在某些情况下,控制器也会准备应用程序程序员提供的数据。这就是我们在setInputImage方法中所做的,其中将对应给定文件名的图像加载到内存中。该方法根据加载操作是否成功返回true或false(也可能抛出异常来处理这种情况)。
最后,process方法是运行算法的方法。此方法不返回结果,必须调用另一个方法才能获取最新处理的结果。
现在,要创建一个使用此控制器的基本基于对话框的应用程序,只需将ColorDetectController成员变量添加到对话框类中(这里称为colordetect)。作为一个例子,使用 MS Visual Studio 框架,MFC 对话框的Open 按钮回调方法如下所示:
// Callback method of "Open" button.
void OnOpen()
{
// MFC widget to select a file of type bmp or jpg
CFileDialog dlg(TRUE, _T("*.bmp"), NULL,
OFN_FILEMUSTEXIST|OFN_PATHMUSTEXIST|OFN_HIDEREADONLY,
_T("image files (*.bmp; *.jpg)
|*.bmp;*.jpg|All Files (*.*)|*.*||"),NULL);
dlg.m_ofn.lpstrTitle= _T("Open Image");
// if a filename has been selected
if (dlg.DoModal() == IDOK) {
// get the path of the selected filename
std::string filename= dlg.GetPathName();
// set and display the input image
colordetect.setInputImage(filename);
cv::imshow("Input Image",colordetect.getInputImage());
}
}
第二个按钮执行Process方法,并显示结果如下:
// Callback method of "Process" button.
void OnProcess()
{
// target color is hard-coded here
colordetect.setTargetColor(130,190,230);
// process the input image and display result
colordetect.process();
cv::imshow("Output Result",colordetect.getLastResult());
}
显然,一个更完整的应用程序将包括额外的小部件,以便允许用户设置算法参数。
还有更多...
当你构建一个应用程序时,总是花时间将其结构化,使其易于维护和演进。存在许多架构模式可以帮助你实现这一目标。
模型-视图-控制器架构
模型-视图-控制器(MVC)架构的目的是生成一个将应用程序逻辑与用户界面明确分离的应用程序。正如其名称所暗示的,MVC 模式涉及三个主要组件。
模型包含有关应用程序的信息。它保存了应用程序处理的所有数据。当生成新数据时,它会通知控制器(通常是异步的),然后控制器会要求视图显示新结果。通常,模型会组合几个算法,这些算法可能遵循策略模式实现。所有这些算法都是模型的一部分。
视图对应于用户界面。它由不同的小部件组成,这些小部件向用户展示数据并允许用户与应用程序交互。其一个角色是将用户发出的命令发送到控制器。当有新数据可用时,它会刷新自己以显示新信息。
控制器是连接视图和模型的模块。它接收来自视图的请求,并将它们转发到模型中适当的方法。当模型改变其状态时,它也会被告知;因此,控制器要求视图刷新以显示新信息。
在 MVC 架构下,用户界面调用控制器方法。它不包含任何应用程序数据,也不实现任何应用程序逻辑。因此,很容易用另一个界面替换它。GUI 的设计者不需要理解应用程序的工作原理。反过来,修改应用程序逻辑也不会影响 GUI。
转换颜色表示
早期的食谱教了你如何将算法封装到类中。这样,算法通过简化的接口变得更容易使用。封装还允许你在不影响使用它的类的情况下修改算法的实现。这一点在下一个食谱中得到了说明,我们将修改ColorDetector类的算法以使用另一个颜色空间。因此,这个食谱也将是一个介绍使用 OpenCV 进行颜色转换的机会。
准备工作
RGB 颜色空间基于红色、绿色和蓝色加色原色的使用。这些颜色被选中是因为当它们组合在一起时,可以产生广泛的颜色范围。实际上,人类视觉系统也是基于颜色的三色感知,其锥体细胞敏感性位于红色、绿色和蓝色光谱周围。它通常是数字图像中的默认颜色空间,因为它们就是这样被获取的。捕获的光线通过红色、绿色和蓝色过滤器。此外,在数字图像中,红色、绿色和蓝色通道被调整,以便当它们以相等比例组合时,可以获得灰度强度,即从黑色(0,0,0)到白色(255,255,255)。
不幸的是,使用 RGB 颜色空间计算颜色之间的距离并不是衡量两种给定颜色相似性的最佳方式。事实上,RGB 不是一个感知均匀的颜色空间。这意味着在给定距离的两个颜色可能看起来非常相似,而相隔相同距离的另外两种颜色可能看起来非常不同。
为了解决这个问题,已经引入了具有感知均匀性的其他颜色表示。特别是,CIE Lab就是这样一种颜色模型。通过将我们的图像转换为这种表示,图像像素与目标颜色之间的欧几里得距离将变成衡量两种颜色视觉相似性的一个有意义的度量。在这个菜谱中,我们将向您展示如何修改前面的应用程序以便与 CIE Lab一起工作。
如何操作…
通过使用cv::cvtColor OpenCV 函数,可以在不同颜色空间之间轻松转换图像。让我们在处理方法的开始将输入图像转换为 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) {
…
转换后的变量包含颜色转换后的图像。在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 位像素,您也可以使用浮点图像进行色彩转换(在这种情况下,像素值通常假设在0和1.0之间变化)或使用整数图像(像素通常在0和65535之间变化)。然而,像素值的精确范围取决于特定的色彩空间和目标图像类型。例如,在 CIE Lab*色彩空间中,表示每个像素亮度的L通道在0和100之间变化,在 8 位图像的情况下,它被重新缩放到0和255之间。a和b通道对应于色度成分。这些通道包含有关像素颜色的信息,与其亮度无关。它们的值在-127和127之间变化;对于 8 位图像,每个值都加上128,以便使其适合0到255的区间。然而,请注意,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);
也可以在另一个方向进行转换,但结果色彩图像的三个通道将完全填充与灰度图像中相应的值。
参见
-
第四章中,“使用均值漂移算法寻找对象”的菜谱在 Counting the Pixels with Histograms 中使用 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 将通道值重新缩放以覆盖 0 到 255 的范围(除了色调,它将在下一节解释,其范围重新缩放为 0 到 180)。这非常方便,因为我们能够将这些通道作为灰度图像显示。城堡图像的值通道将如下所示:

在饱和度通道中的相同图像将如下所示:

最后,色调通道的图像如下所示:

这些图像将在下一节进行解释。
它是如何工作的…
现象颜色空间被引入,因为它们对应于人类倾向于自然组织颜色的方式。确实,人类更喜欢用直观属性来描述颜色,如色调、色彩丰富度和亮度。这三个属性是大多数现象颜色空间的基础。色调表示主导颜色;我们给颜色取的名字(如绿色、黄色、蓝色和红色)对应于不同的色调值。饱和度告诉我们颜色的鲜艳程度;柔和的颜色饱和度低,而彩虹的颜色饱和度高。最后,亮度是一个主观属性,指的是颜色的亮度。其他现象颜色空间使用颜色值或颜色亮度的概念作为表征相对颜色强度的方法。
这些颜色成分试图模仿人类对颜色的直观感知。因此,它们没有标准定义。在文献中,你会找到关于色调、饱和度和亮度的几种不同定义和公式。OpenCV 提出了两种现象颜色空间的实现:HSV 颜色空间和 HLS 颜色空间。转换公式略有不同,但它们给出了非常相似的结果。
价值成分可能是最容易解释的。在 OpenCV 对 HSV 空间的实现中,它被定义为三个 BGR 组件中的最大值。这是一个非常简单的亮度概念的实现。为了更好地匹配人类视觉系统,你应该使用 Lab或 Luv颜色空间的 L 通道。
为了计算饱和度,OpenCV 使用基于 BGR 组件的最小值和最大值的公式:

想法是,当三个 R、G 和 B 分量都相等时,对应的灰度颜色将是一个完美的未饱和颜色;因此,它将有 0 的饱和度值。饱和度是一个介于 0 和 1.0 之间的值。对于 8 位图像,饱和度被缩放到 0 到 255 之间的值,并且当以灰度图像显示时,亮度较高的区域对应于饱和度较高的颜色。例如,从上一节中的饱和度图像中可以看出,水的蓝色比天空的浅蓝色蜡笔颜色饱和度更高,正如预期的那样。根据定义,不同灰度的饱和度值等于零(因为在这种情况下,所有三个 BGR 分量都相等)。这可以在城堡的不同屋顶上观察到,这些屋顶是由深灰色石头制成的。最后,在饱和度图像中,您可能会注意到一些位于原始图像非常暗的区域对应的白色斑点。这是由于饱和度使用的定义造成的。确实,因为饱和度只测量最大和最小 BGR 值之间的相对差异,所以像 (1,0,0) 这样的三元组给出完美的饱和度 1.0,即使这种颜色看起来是黑色的。因此,在暗区域测量的饱和度值是不可靠的,不应予以考虑。
颜色的色调通常用一个介于 0 到 360 度之间的角度值来表示,红色在 0 度。在 8 位图像的情况下,OpenCV 将这个角度除以二以适应单字节范围。因此,每个色调值对应于一个特定的颜色色调,与其亮度和饱和度无关。例如,天空和水都有相同的色调值,大约 200 度(强度,100),这对应于蓝色调;背景中树木的绿色色调大约为 90 度。需要注意的是,当评估饱和度非常低的颜色时,色调的可靠性较低。
HSB 颜色空间通常用一个圆锥体来表示,其中圆锥体内部的每一个点都对应一种特定的颜色。角度位置对应颜色的色调,饱和度是距离中心轴的距离,亮度由高度决定。圆锥体的尖端对应于黑色,其色调和饱和度都是未定义的。

通过调整 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; // under 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
// under maxSat
cv::threshold(channels[1], mask1, maxSat, 255,
cv::THRESH_BINARY_INV);
// over minSat
cv::threshold(channels[1], mask2, minSat, 255,
cv::THRESH_BINARY);
cv::Mat satMask; // saturation mask
satMask = mask1 & mask2;
// combined mask
mask = hueMask&satMask;
}
由于我们有大量的皮肤(和非皮肤)样本可供使用,我们本可以使用一种概率方法,其中观察给定颜色在皮肤类别中的可能性与观察相同颜色在非皮肤类别中的可能性。在这里,我们根据经验定义了我们测试图像的可接受色调-饱和度区间(请记住,色调的 8 位版本从 0 到 180,饱和度从 0 到 255):
// 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);
以下检测图像是结果:

注意,为了简单起见,我们没有考虑检测中的颜色饱和度。在实践中,排除高饱和度的颜色会减少将明亮的红色误检为皮肤的可能性。显然,可靠的肤色检测需要更复杂、基于大量皮肤样本的分析。同时,保证在不同图像上都有良好的检测效果也非常困难,因为许多因素会影响摄影中的颜色渲染,例如白平衡和照明条件。尽管如此,正如本章所示,仅使用色调信息作为初始检测器就给出了可接受的结果。
第四章:使用直方图计数像素
在本章中,我们将介绍以下食谱:
-
计算图像直方图
-
应用查找表以修改图像外观
-
直方图均衡化
-
将直方图反投影以检测特定图像内容
-
使用均值漂移算法寻找对象
-
使用直方图比较检索相似图像
-
使用积分图像计数像素
简介
图像由不同值的像素(颜色)组成。像素值在图像中的分布构成了该图像的一个重要特征。本章介绍了图像直方图的概念。你将学习如何计算直方图以及如何使用它来修改图像的外观。直方图还可以用来表征图像的内容,并在图像中检测特定的对象或纹理。本章将介绍一些这些技术。
计算图像直方图
图像由像素组成,每个像素都有不同的值。例如,在单通道灰度图像中,每个像素的值介于 0(黑色)和 255(白色)之间。根据图片内容的不同,你会在图像内部找到不同数量的每种灰度色调。
直方图是一个简单的表格,它显示了图像(有时是图像集)中具有给定值的像素数量。因此,灰度图像的直方图将有 256 个条目(或称为桶)。桶 0 表示值为 0 的像素数量,桶 1 表示值为 1 的像素数量,以此类推。显然,如果你将直方图的全部条目相加,你应该得到像素的总数。直方图也可以进行归一化,使得桶的总和等于 1。在这种情况下,每个桶都会给你图像中具有这个特定值的像素的百分比。
入门
本章的前三个食谱将使用以下图像:

如何操作...
使用 OpenCV 计算直方图可以通过cv::calcHist函数轻松完成。这是一个通用函数,可以计算任何像素值类型和范围的多个通道图像的直方图。在这里,我们将通过为单通道灰度图像的情况专门化一个类来简化其使用。对于其他类型的图像,你可以直接使用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 histogram
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函数。在这里,为了创建我们的二值图像,我们在直方图向高峰值增加之前的最小值处对图像进行阈值处理(灰度值60):
cv::Mat thresholded; // output binary image
cv::threshold(image,thresholded,
60, // threshold value
255, // value assigned to
// pixels over threshold value
cv::THRESH_BINARY); // thresholding type
生成的二值图像清楚地显示了背景/前景分割:

它是如何工作的...
cv::calcHist函数有许多参数,允许它在许多上下文中使用,如下所示:
void calcHist(const Mat* images, int nimages,
const int* channels, InputArray mask, OutputArray hist,
int dims, const int* histSize, const float** ranges,
bool uniform=true, bool accumulate=false )
大多数情况下,你的直方图将是一张单通道或三通道图像之一。然而,该函数允许你指定一个分布在多个图像上的多通道图像。这就是为什么将图像数组输入到该函数中的原因。第六个参数dims指定直方图的维度,例如,对于一维直方图为 1。即使你正在分析多通道图像,你也不必在直方图的计算中使用所有通道。要考虑的通道列在具有指定维度的channel数组中。在我们的类实现中,这个单通道默认是通道 0。直方图本身由每个维度的 bin 数量(这是整数的histSize数组)以及每个维度的最小(包含)和最大(不包含)值(由 2 元素数组的ranges数组给出)描述。也可以定义一个非均匀直方图;在这种情况下,你需要指定每个 bin 的界限。
与许多 OpenCV 函数一样,可以指定一个掩码,表示您想要包含在计数中的像素(掩码值为 0 的所有像素将被忽略)。还可以指定两个附加的可选参数,它们都是布尔值。第一个参数指示直方图是否均匀(均匀是默认值)。第二个参数允许您累积多个直方图计算的结果。如果最后一个参数为真,则图像的像素计数将添加到当前输入直方图中的值。这在您想要计算一组图像的直方图时非常有用。
结果直方图存储在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
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
channels[1]= 1;
channels[2]= 2;
}
在这种情况下,直方图将是三维的。因此,我们需要为三个维度中的每一个指定一个范围。在我们的 BGR 图像中,三个通道具有相同的[0,255]范围。准备好这些参数后,颜色直方图将通过以下方法计算:
// Computes the histogram.
cv::Mat getHistogram(const cv::Mat &image) {
cv::Mat hist;
// BGR color histogram
hranges[0]= 0.0; // BRG range
hranges[1]= 256.0;
channels[0]= 0; // the three channels
channels[1]= 1;
channels[2]= 2;
// 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);
// BGR color histogram
hranges[0]= 0.0; // BRG range
hranges[1]= 256.0;
channels[0]= 0; // the three channels
channels[1]= 1;
channels[2]= 2;
// 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 uchars
// the output image
cv::Mat result;
// apply lookup table
cv::LUT(image,lookup,result);
return result;
}
它是如何工作的...
当查找表应用于图像时,它会产生一个新的图像,其中像素强度值已根据查找表的规定进行了修改。一个简单的转换可能是以下这样:
// Create an image inversion table
int dim(256);
cv::Mat lut(1, // 1 dimension
&dim, // 256 entries
CV_8U); // uchar
for (int i=0; i<256; i++) {
lut.at<uchar>(i)= 255-i;
}
这种转换只是简单地反转像素强度,也就是说,强度0变为255,1变为254,依此类推。将此类查找表应用于图像将产生原始图像的负片。在上一道菜谱的图像中,结果如下所示:

还有更多...
查找表对于任何所有像素强度都被赋予新强度值的应⽤程序都很有用。然而,这种转换必须是全局的,也就是说,每个强度值的所有像素都必须经历相同的转换。
通过拉伸直方图来提高图像对比度
通过定义一个修改原始图像直方图的查找表,可以改善图像的对比度。例如,如果你观察第一道菜谱中显示的上一张图像的直方图,很容易注意到没有使用所有可能的强度值范围(特别是,对于这张图像,较亮的强度值没有被使用)。因此,我们可以拉伸直方图以产生具有扩展对比度的图像。为此,该程序使用一个百分位数阈值来定义拉伸图像中应该为黑色和白色的像素百分比。
因此,我们必须找到最低的(imin)和最高的(imax)强度值,这样我们就有所需的最低像素数在指定的百分位数以下或以上。然后可以将强度值重新映射,使imin值重新定位到强度0,而imax值被分配值为255。中间的i强度值简单地按以下方式进行线性重新映射:
255.0*(i-imin)/(imax-imin);
因此,完整的图像拉伸方法如下所示:
cv::Mat stretch(const cv::Mat &image, int minValue=0) {
// Compute histogram first
cv::Mat hist= getHistogram(image);
// find left extremity of the histogram
int imin= 0;
for( ; imin < histSize[0]; imin++ ) {
// ignore bins with less than minValue entries
if (hist.at<float>(imin) > minValue)
break;
}
// find right extremity of the histogram
int imax= histSize[0]-1;
for( ; imax >= 0; imax-- ) {
// ignore bins with less than minValue entries
if (hist.at<float>(imax) > minValue)
break;
}
// Create lookup table
int dim(256);
cv::Mat lookup(1, // 1 dimension
&dim, // 256 entries
CV_8U); // uchar
// Build lookup table
for (int i=0; i<256; i++) {
// stretch between imin and imax
if (i < imin) lookup.at<uchar>(i)= 0;
else if (i > imax) lookup.at<uchar>(i)= 255;
// linear mapping
else lookup.at<uchar>(i)=
cvRound(255.0*(i-imin)/(imax-imin));
}
// Apply lookup table
cv::Mat result;
result= applyLookUp(image,lookup);
return result;
}
注意在计算此方法后对applyLookUp方法的调用。实际上,在实践中,除了忽略具有0值的桶外,还可以忽略计数可忽略的条目,例如,小于给定值(在此定义为minValue)。方法的调用方式如下:
// setting 1% of pixels at black and 1% at white
cv::Mat streteched = h.stretch(image,0.01f);
结果拉伸的图像如下:

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

在彩色图像上应用查找表
在第二章,操作像素中,我们定义了一个颜色减少函数,该函数通过修改图像的 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%的像素必须具有低于或等于255p%*的强度值。用于均衡直方图的规则是,强度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
);
结果是以下概率图,参考区域的概率从亮(低概率)到暗(高概率):

如果我们对这张图像应用阈值,我们将获得最可能的“云”像素:
cv::threshold(result, result, threshold,
255, cv::THRESH_BINARY);
结果如下截图所示:

它是如何工作的...
前面的结果令人失望,因为除了云层之外,其他区域也被错误地检测到了。重要的是要理解,概率函数是从简单的灰度直方图中提取出来的。图像中许多其他像素与云层像素具有相同的强度,并且在回投影直方图时,相同强度的像素被相同的概率值替换。为了提高检测结果,一个解决方案是使用颜色信息。然而,为了做到这一点,我们需要修改对cv::calBackProject的调用。
cv::calBackProject函数类似于cv::calcHist函数。第一个参数指定输入图像。然后你需要列出你希望使用的通道号。这次传递给函数的直方图是一个输入参数;其维度应与通道列表数组相匹配。与cv::calcHist一样,ranges参数指定输入直方图的 bin 边界,形式为一个 float 数组的数组,每个数组指定每个通道的范围(最小值和最大值)。生成的输出是一个图像,即计算出的概率图。由于每个像素都被替换为在对应 bin 位置上直方图中找到的值,因此生成的图像的值在0.0和1.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;
}
接下来,我们定义一个阈值参数,该参数将用于创建显示检测结果的二值图。如果此参数设置为负值,则将返回原始概率图。请参考以下代码:
// Sets the threshold on histogram values [0,1]
void setThreshold(float t) {
threshold= t;
}
// Gets the threshold
float getThreshold() {
return threshold;
}
输入直方图已归一化(尽管这不是必需的),如下所示:
// Sets the reference histogram
void setHistogram(const cv::Mat& h) {
histogram= h;
cv::normalize(histogram,histogram,1.0);
}
要回投影直方图,你只需指定图像、范围(我们假设所有通道具有相同的范围)以及使用的通道列表。请参考以下代码:
// All channels used, with range [0,256[
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 at a time
channels, // vector specifying what histogram
// dimensions belong to what image channels
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("waves2.jpg");
// extract region of interest
imageROI= color(cv::Rect(0,0,100,45)); // blue sky area
// Get 3D colour 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 颜色空间的色调通道可能会更有效率。在其他情况下,使用感知均匀空间(如 La*b**)的色度分量可能是一个更好的选择。
参见
- 下一个示例使用 HSV 颜色空间来检测图像中的物体。这是你可以在检测某些图像内容时使用的许多替代方案之一。
使用均值漂移算法寻找物体
直方图反向投影的结果是一个概率图,表示给定图像内容在特定图像位置被找到的概率。假设我们现在知道图像中物体的近似位置;概率图可以用来找到物体的确切位置。最可能的位置将是最大化给定窗口内该概率的位置。因此,如果我们从一个初始位置开始,迭代地移动,应该能够找到物体的确切位置。这就是均值漂移算法所实现的功能。
如何做...
假设我们已经识别出感兴趣的对象——这里是一个狒狒的脸,如下面的截图所示(参考书中图形 PDF 查看此图像的颜色版本):

这次,我们将使用 HSV 颜色空间的色调通道来描述这个物体。这意味着我们需要将图像转换为 HSV 图像,然后提取色调通道并计算定义的 ROI 的 1D 色调直方图。参考以下代码:
// Read reference image
cv::Mat image= cv::imread("baboon1.jpg");
// Baboon's face ROI
cv::Mat imageROI= image(cv::Rect(110,260,35,40));
// Get the Hue histogram
int minSat=65;
ColorHistogram hc;
cv::Mat colorhist=
hc.getHueHistogram(imageROI,minSat);
如所示,色调直方图是通过我们添加到ColorHistogram类的一个方便方法获得的,如下所示:
// Computes the 1D Hue histogram with a mask.
// BGR source image is converted to HSV
// Pixels with low saturation are ignored
cv::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;
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,
10,0.01);
cv::meanShift(result,rect,criteria);
初始(红色)和新(绿色)的面部位置在以下屏幕截图(参考书籍的图形 PDF 以查看此图像的颜色)中显示:

它是如何工作的...
在这个例子中,我们使用了 HSV 颜色空间的色调分量来描述我们正在寻找的对象。我们做出这个选择是因为狒狒的面部有一种非常独特的粉红色;因此,像素的色调应该使面部易于识别。因此,第一步是将图像转换为 HSV 颜色空间。当使用CV_BGR2HSV标志时,色调分量是结果图像的第一个通道。这是一个从0到180(使用cv::cvtColor转换的图像与源图像类型相同)的 8 位分量。为了提取色调图像,使用cv::split函数将 3 通道 HSV 图像分割成三个 1 通道图像。这三个图像被放入一个std::vector实例中,色调图像是向量的第一个条目(即索引0)。
当使用颜色的色调分量时,始终需要考虑其饱和度(这是向量的第二个元素)。实际上,当颜色的饱和度低时,色调信息变得不稳定且不可靠。这是因为对于低饱和度的颜色,B、G 和 R 分量几乎相等。这使得确定确切的颜色变得困难。因此,我们决定忽略低饱和度颜色的色调分量。也就是说,它们不会被计入直方图(使用minSat参数,在getHueHistogram方法中屏蔽掉饱和度低于此阈值的像素)。
均值漂移算法是一个迭代过程,它定位概率函数的局部最大值。它是通过找到预定义窗口内数据点的质心或加权平均值来做到这一点的。然后,算法将窗口中心移动到质心位置,并重复此过程,直到窗口中心收敛到一个稳定点。OpenCV 实现定义了两个停止标准:最大迭代次数和窗口中心位移值,低于此值的位置被认为已收敛到一个稳定点。这两个标准存储在一个cv::TermCriteria实例中。cv::meanShift函数返回已执行的迭代次数。显然,结果的质量取决于在给定初始位置提供的概率图的质量。请注意,在这里,我们使用颜色直方图来表示图像的外观;也可以使用其他特征的直方图来表示对象(例如,边缘方向的直方图)。
参见
-
均值漂移算法已被广泛用于视觉跟踪。第十一章,处理视频序列,将更详细地探讨对象跟踪的问题
-
均值漂移算法在 D. Comaniciu 和 P. Meer 在IEEE transactions on Pattern Analysis and Machine Intelligence, 第 24 卷,第 5 期,2002 年 5 月上发表的Mean Shift: A robust approach toward feature space analysis一文中被介绍。
-
OpenCV 还提供了一个 CamShift 算法的实现,这是均值漂移算法的一个改进版本,其中窗口的大小和方向可以改变。
使用直方图比较检索相似图像
基于内容的图像检索是计算机视觉中的一个重要问题。它包括找到一组与给定查询图像内容相似的图像。由于我们已经了解到直方图是表征图像内容的一种有效方式,因此认为它们可以用来解决基于内容的检索问题是有意义的。
关键在于能够通过简单地比较两个图像的直方图来测量两个图像之间的相似性。需要定义一个测量函数,该函数将估计两个直方图之间的差异或相似程度。过去已经提出了各种此类度量,OpenCV 在其cv::compareHist函数的实现中提出了一些。
如何做到这一点...
为了将参考图像与一组图像进行比较,并找到与查询图像最相似的图像,我们创建了一个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) {
}
为了获得可靠的相似性度量,直方图应该在较少的 bin 上计算。因此,该类允许你指定每个 BGR 通道中使用的 bin 数量。请参阅以下代码:
// Set number of bins used when comparing the histograms
void setNumberOfBins( int bins) {
nBins= bins;
}
查询图像通过一个适当的设置器指定,该设置器还会计算参考直方图,如下所示:
// 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);
return cv::compareHist(refH,inputH,CV_COMP_INTERSECT);
}
上述类可以用来检索与给定查询图像相似的图像。以下代码最初提供给类实例:
ImageComparator c;
c.setReferenceImage(image);
这里,我们使用的查询图像是本章前面在 将直方图反投影以检测特定图像内容 菜谱中显示的海滩图像的颜色版本。此图像与以下一系列图像进行了比较。图像按从最相似到最不相似的顺序显示:

它是如何工作的...
大多数直方图比较度量都是基于每个直方图 bin 的比较。这就是为什么在测量两个颜色直方图的相似性时,与较少的直方图 bin 数量一起工作很重要。调用 cv::compareHist 是直接的。你只需输入两个直方图,函数就会返回测量的距离。你想要使用的特定测量方法是通过一个标志指定的。在 ImageComparator 类中,使用的是交集方法(使用 CV_COMP_INTERSECT 标志)。这种方法简单地比较每个 bin 中每个直方图的两个值,并保留最小的一个。因此,相似性度量是这些最小值的总和。因此,两个没有共同颜色的直方图的图像将得到一个交集值为 0,而两个相同的直方图将得到一个等于像素总数的值。
其他可用的方法包括卡方度量(CV_COMP_CHISQR 标志),它计算 bin 之间的归一化平方差之和,相关方法(CV_COMP_CORREL 标志),它基于在信号处理中用于测量两个信号相似性的归一化互相关算子,以及 Bhattacharyya 度量(CV_COMP_BHATTACHARYYA 标志),它在统计学中用于估计两个概率分布的相似性。
参见
-
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)或浮点值(CV_32F)的图像。例如,在下面的图中,这个积分图像中的像素A将包含位于左上角区域内的像素总和,该区域用双虚线图案标识。参见图:

一旦计算了积分图像,任何矩形区域的求和都可以通过四次像素访问轻松获得,原因如下。再次考虑前面的图,我们可以看到,位于像素A、B、C和D定义的区域内的像素总和可以通过读取像素D的积分值来获得,从该值中减去像素B和C左侧的像素值。然而,这样做会减去A左上角像素总和的两倍;这就是为什么你必须重新加上A的积分和。形式上,A、B、C和D内部像素的总和由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 pixel of a line
for (int i=halfSize; i<nc-halfSize-1; i++) {
// compute sum
int sum= (idata2[i+halfSize+1]-
idata2[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;
}
}
在这个例子中,使用了大小为 21 x 21 的邻域。为了计算每个平均值,我们需要访问界定正方形邻域的四个整数像素:两个位于由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
这个函数调用产生的结果与我们使用积分图像得到的结果完全相同。此外,与使用局部均值进行阈值处理不同,这个函数允许你在这种情况下使用高斯加权求和(方法标志为 ADAPTIVE_THRESH_GAUSSIAN_C)。值得注意的是,我们的实现比 cv::adaptiveThreshold 调用稍微快一些。
最后,值得一提的是,我们还可以使用 OpenCV 图像运算符编写自适应阈值处理程序。这将如下所示:
cv::Mat filtered;
cv::Mat binaryFiltered;
cv::boxFilter(image,filtered,CV_8U,
cv::Size(blockSize,blockSize));
filtered= filtered-threshold;
binaryFiltered= image>= filtered;
图像滤波将在 第六章,滤波图像 中介绍。
使用直方图进行视觉跟踪
正如我们在前面的菜谱中学到的,直方图构成了一个可靠的全球表示,用于描述一个对象的外观。在这个菜谱中,我们将通过展示如何通过搜索与目标对象具有相似直方的图像区域来定位图像中的对象,来展示积分图像的有用性。我们在 使用均值漂移算法查找对象 的菜谱中通过使用直方图反向投影和通过均值漂移进行局部搜索的概念实现了这一点。这次,我们将通过在整幅图像上执行对具有相似直方的区域的显式搜索来找到我们的对象。
在积分图像用于由 0 和 1 值组成的二值图像的特殊情况下,积分和给出了指定区域内值为 1 的像素数量。我们将利用这一事实在本菜谱中计算灰度图像的直方图。
cv::integral 函数也适用于多通道图像。你可以利用这一事实,通过积分图像来计算图像子区域的直方图。你只需将你的图像转换为由二值平面组成的多个通道图像;每个平面都与你的直方图的一个 bin 相关联,并显示哪些像素的值落在这个 bin 中。以下函数将灰度图像转换为这样的多平面图像:
// 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 个 bin 的直方图,如下所示:
// 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包含一个二值图像,显示所有介于0和15之间的像素,而平面1显示介于16和31之间的像素,依此类推。
对象的搜索包括计算给定大小窗口在预定像素范围内的所有直方图。这代表了从我们的积分图像中高效计算出的3200个不同直方图的计算。我们 IntegralImage 类返回的所有直方图都包含在一个 cv::Vec 对象中(由于使用了 at 方法)。然后我们使用 cv::compareHist 函数来识别最相似的直方图(请记住,这个函数,像大多数 OpenCV 函数一样,可以通过方便的 cv::InputArray 泛型参数类型接受 cv::Mat 或 cv::Vec 对象)。
参见
-
第八章, 检测兴趣点 将介绍也依赖于积分图像使用的
SURF操作符。 -
文章《基于积分直方图的鲁棒片段跟踪》(A. Adam, E. Rivlin, 和 I. Shimshoni 在 2006 年国际计算机视觉和模式识别会议论文集中的作品,第 798-805 页)描述了一种有趣的方法,该方法使用积分图像在图像序列中跟踪对象。
第五章 使用形态学操作转换图像
在本章中,我们将介绍以下配方:
-
使用形态学过滤器腐蚀和膨胀图像
-
使用形态学过滤器打开和关闭图像
-
使用形态学过滤器检测边缘和角
-
使用分水岭分割图像
-
使用 MSER 提取特征区域
-
使用 GrabCut 算法提取前景对象
简介
数学形态学是 20 世纪 60 年代为分析和处理离散图像而开发的一种理论。它定义了一系列通过使用预定义的形状元素探测图像来转换图像的算子。这个形状元素与像素邻域的交集方式决定了操作的结果。本章介绍了最重要的形态学算子。它还探讨了使用基于形态学算子的算法进行图像分割和特征检测的问题。
使用形态学过滤器腐蚀和膨胀图像
腐蚀和膨胀是最基本的形态学算子。因此,我们将首先介绍这些算子。数学形态学的基本组成部分是结构元素。结构元素可以简单地定义为定义了原点(也称为锚点)的像素配置(以下图中的正方形形状)。应用形态学过滤器包括使用这个结构元素探测图像中的每个像素。当结构元素的原点与给定的像素对齐时,它与图像的交集定义了一个特定形态学操作应用的像素集(以下图中的九个阴影像素)。原则上,结构元素可以是任何形状,但最常见的是使用具有原点在中心的简单形状,如正方形、圆形或菱形(主要是为了效率原因),如下所示:

准备就绪
由于形态学过滤器通常在二值图像上工作,我们将使用上一章第一个配方中通过阈值创建的二值图像。然而,由于在形态学中,惯例是用高(白色)像素值表示前景对象,用低(黑色)像素值表示背景对象,因此我们已对图像进行了取反。
在形态学术语中,以下图像被认为是上一章创建的图像的补集:

如何操作...
腐蚀和膨胀在 OpenCV 中作为简单的函数实现,分别是cv::erode和cv::dilate。它们的用法简单直接:
// Read input image
cv::Mat image= cv::imread("binary.bmp");
// Erode the image
cv::Mat eroded; // the destination image
cv::erode(image,eroded,cv::Mat());
// Dilate the image
cv::Mat dilated; // the destination image
cv::dilate(image,dilated,cv::Mat());
以下屏幕截图显示了这些函数调用产生的两个图像。第一个屏幕截图显示了腐蚀:

第二个屏幕截图显示了膨胀的结果:

如何工作...
与所有其他形态学过滤器一样,这个菜谱中的两个过滤器都是根据结构元素定义的每个像素(或邻域)的像素集(或邻域)进行操作的。回想一下,当应用于某个像素时,结构元素的锚点与该像素位置对齐,并且所有与结构元素相交的像素都包含在当前集合中。腐蚀用在定义的像素集中找到的最小像素值替换当前像素。膨胀是互补操作,它用在定义的像素集中找到的最大像素值替换当前像素。由于输入的二值图像只包含黑色(0)和白色(255)像素,每个像素要么被替换为白色像素,要么被替换为黑色像素。
将这两个算子的效果形象化的一种好方法是考虑背景(黑色)和前景(白色)对象。在腐蚀过程中,如果结构元素放置在某个像素位置时接触到背景(即,交集中的一组像素是黑色),那么这个像素将被发送到背景。在膨胀的情况下,如果结构元素在背景像素上接触到前景对象,那么这个像素将被赋予白色值。这解释了为什么腐蚀图像中对象的大小已经减小(形状已经被腐蚀)。注意,一些小对象(可以被认为是“噪声”背景像素)也被完全消除了。同样,膨胀的对象现在更大,它们内部的一些“空洞”也被填充了。默认情况下,OpenCV 使用 3 x 3 的正方形结构元素。当在函数调用中将空矩阵(即cv::Mat())指定为第三个参数时,就得到了这个默认的结构元素,就像在先前的例子中那样。您也可以通过提供一个矩阵来指定您想要的(大小和形状)结构元素,其中非零元素定义了结构元素。在下面的例子中,应用了一个 7 x 7 的结构元素:
cv::Mat element(7,7,CV_8U,cv::Scalar(1));
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)表示原点位于矩阵的中心(默认);它可以在结构元素的任何位置定义。得到的图像将与使用 7 x 7 结构元素得到的图像相同。实际上,腐蚀图像两次与腐蚀一个自身膨胀的结构元素相同。这也适用于膨胀。
最后,由于背景/前景的概念是任意的,我们可以做出以下观察(这是腐蚀/膨胀算子的基本属性)。使用结构元素腐蚀前景对象可以看作是图像背景部分的膨胀。换句话说,我们可以得出以下结论:
-
图像的腐蚀等同于补图像膨胀的补集
-
图像的膨胀等同于补图像腐蚀的补集
更多...
注意,尽管我们在这里对二值图像应用了形态学滤波器,但这些滤波器也可以用相同的定义应用于灰度图像甚至彩色图像。
此外,请注意,OpenCV 的形态学函数支持就地处理。这意味着您可以使用输入图像作为目标图像,如下所示:
cv::erode(image,image,cv::Mat());
OpenCV 会为您创建所需的临时图像,以确保其正常工作。
参见
-
使用形态学滤波器进行图像的开放和闭合菜谱将腐蚀和膨胀滤波器级联应用以产生新的算子
-
使用形态学滤波器检测边缘和角点菜谱在灰度图像上应用形态学滤波器
使用形态学滤波器进行图像的开放和闭合
之前菜谱向您介绍了两个基本的形态学算子:膨胀和腐蚀。从这些算子中,可以定义其他算子。接下来的两个菜谱将介绍其中的一些。本菜谱中介绍了开放和闭合算子。
如何操作...
为了应用高级形态学滤波器,您需要使用带有适当功能代码的cv::morphologyEx函数。例如,以下调用将应用闭合算子:
cv::Mat element5(5,5,CV_8U,cv::Scalar(1));
cv::Mat closed;
cv::morphologyEx(image,closed,cv::MORPH_CLOSE,element5);
注意,我们使用了 5 x 5 的结构元素来使滤波器效果更加明显。如果我们使用前一道菜谱的二值图像作为输入,我们将获得类似于以下截图所示的图像:

类似地,应用形态学开放算子将产生以下截图:

上述图像是通过以下代码获得的:
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());
// Apply threshold to obtain a binary image
int threshold= 40;
cv::threshold(result, result,
threshold, 255, cv::THRESH_BINARY);
下面的图像是结果:

为了使用形态学检测角,我们现在定义一个名为MorphoFeatures的类,如下所示:
class MorphoFeatures {
private:
// threshold to produce binary image
int threshold;
// structuring elements used in corner detection
cv::Mat_<uchar> cross;
cv::Mat_<uchar> diamond;
cv::Mat_<uchar> square;
cv::Mat_<uchar> x;
使用形态学角检测角检测稍微复杂一些,因为它需要连续应用几个不同的形态学过滤器。这是一个使用非正方形结构元素的很好例子。确实,这需要在构造函数中定义四个不同形状的结构元素,形状为正方形、菱形、十字形和 X 形(所有这些结构元素都具有固定的 5 x 5 维以简化问题):
MorphoFeatures() : threshold(-1),
cross(5, 5), diamond(5, 5), square(5, 5), x(5, 5) {
// Creating the cross-shaped structuring element
cross <<
0, 0, 1, 0, 0,
0, 0, 1, 0, 0,
1, 1, 1, 1, 1,
0, 0, 1, 0, 0,
0, 0, 1, 0, 0;
// Similarly creating the other elements
在检测角特征时,所有这些结构元素都按级联方式应用,以获得最终的角图:
cv::Mat getCorners(const cv::Mat &image) {
cv::Mat result;
// Dilate with a cross
cv::dilate(image,result,cross);
// Erode with a diamond
cv::erode(result,result,diamond);
cv::Mat result2;
// Dilate with a X
cv::dilate(image,result2,x);
// Erode with a square
cv::erode(result2,result2,square);
// Corners are obtained by differencing
// the two closed images
cv::absdiff(result2,result,result);
// Apply threshold to obtain a binary image
applyThreshold(result);
return result;
}
然后使用以下代码在图像上检测角:
// Get the corners
cv::Mat corners;
corners= morpho.getCorners(image);
// Display the corner on the image
morpho.drawOnImage(corners,image);
cv::namedWindow("Corners on Image");
cv::imshow("Corners on Image",image);
在图像中,检测到的角以圆圈的形式显示,如下面的截图所示:

它如何工作...
要理解形态学算子对灰度图像的影响,可以将图像视为一个拓扑地形,其中灰度级别对应于高度(或海拔)。从这种角度来看,明亮区域对应于山脉,而暗区对应于地形的山谷。此外,由于边缘对应于暗亮像素之间的快速过渡,这些可以想象为陡峭的悬崖。如果在这种地面上应用腐蚀操作符,最终结果将是用一定邻域中的最低值替换每个像素,从而降低其高度。因此,随着山谷的扩张,悬崖将被“腐蚀”。相反,膨胀具有完全相反的效果;也就是说,悬崖将在山谷上方获得地形。然而,在这两种情况下,高原(即强度恒定的区域)将相对保持不变。
这些观察结果导致了一种简单的方法来检测图像的边缘(或悬崖)。这可以通过计算膨胀图像和腐蚀图像之间的差异来实现。由于这两个转换图像主要在边缘位置不同,因此减法将强调图像边缘。这正是当输入cv::MORPH_GRADIENT参数时,cv::morphologyEx函数所做的事情。显然,结构元素越大,检测到的边缘就越粗。这个边缘检测操作符也被称为Beucher梯度(下一章将更详细地讨论图像梯度的概念)。请注意,通过简单地从原始图像减去膨胀图像或从原始图像减去腐蚀图像也可以获得类似的结果。得到的边缘会更细。
角点检测稍微复杂一些,因为它使用了四个不同的结构元素。这个操作符在 OpenCV 中未实现,但我们在这里展示它是如何定义和组合各种形状的结构元素的。其思路是通过使用两个不同的结构元素对图像进行膨胀和腐蚀来封闭图像。这些元素被选择得使得它们不会改变直线边缘,但由于它们各自的效果,角点的边缘将会受到影响。让我们使用以下简单的由单个白色正方形组成的图像来更好地理解这种非对称封闭操作的效果:

第一个正方形是原始图像。当使用十字形结构元素膨胀时,正方形的边缘会扩展,除了在角点处,因为十字形没有接触到正方形。这是中间正方形所展示的结果。然后,使用具有菱形形状的结构元素对膨胀后的图像进行腐蚀。这种腐蚀将大多数边缘恢复到原始位置,但由于角点没有被膨胀,所以它们被推得更远。然后得到最右侧的正方形,它(如所见)已经失去了其锐利的角。使用 X 形和正方形结构元素重复相同的程序。这两个元素是先前元素的旋转版本,因此将捕捉到 45 度方向的角。最后,对这两个结果进行差分将提取角特征。
参考以下内容
-
第六章中的应用方向滤波器以检测边缘配方,过滤图像描述了执行边缘检测的其他滤波器。
-
第八章,检测兴趣点,介绍了执行角点检测的不同算子。
-
文章《形态梯度》,作者 J.-F. Rivest, P. Soille, 和 S. Beucher,发表于 1992 年 2 月的 ISET 电子成像科学和技术研讨会,SPIE,详细讨论了形态梯度的概念。
-
文章《改进的调节形态角点检测器》,作者 F.Y. Shih, C.-F. Chuang, 和 V. Gaddipati,发表于 2005 年 5 月的《Pattern Recognition Letters》,第 26 卷第 7 期,提供了关于形态角点检测的更多信息。
使用水印分割图像
水印变换是一种流行的图像处理算法,用于快速将图像分割成同质区域。它基于这样的想法:当图像被视为拓扑起伏时,同质区域对应于相对平坦的盆地,这些盆地由陡峭的边缘所限定。由于其简单性,该算法的原始版本往往会过度分割图像,从而产生多个小区域。这就是为什么 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;
注意我们在这里如何使用重载的运算符 + 来组合图像。以下图像将被用作水岭算法的输入:

在这个输入图像中,白色区域肯定属于前景对象,灰色区域是背景的一部分,黑色区域具有未知标签。然后通过以下方式获得分割:
// 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 分配给与分水岭相关的像素。这是 cv::watershed 函数返回的。
为了便于显示结果,我们引入了两种特殊方法。第一种方法返回标签图像(分水岭值为 0)。这可以通过阈值化轻松完成,如下所示:
// Return result in the form of an image
cv::Mat getSegmentation() {
cv::Mat tmp;
// all segment with label higher than 255
// will be assigned value 255
markers.convertTo(tmp,CV_8U);
return tmp;
}
同样地,第二种方法返回的图像中,分水岭线被赋予值 0,而图像的其余部分为 255。这次使用 cv::convertTo 方法来实现这一结果,如下所示:
// Return watershed in the form of an image
cv::Mat getWatersheds() {
cv::Mat tmp;
// Each pixel p is transformed into
// 255p+255 before conversion
markers.convertTo(tmp,CV_8U,255,255);
return tmp;
}
在转换之前应用的线性变换允许 -1 像素转换为 0(因为 -1255+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 月,提供了关于流域变换的更多信息
-
本章的最后一个配方,使用 GrabCut 算法提取前景对象,介绍了另一种可以将图像分割为背景和前景对象的图像分割算法
使用 MSER 提取独特区域
在前面的配方中,你学习了如何通过逐渐淹没图像并创建流域来将图像分割成区域。最大稳定极值区域(MSER)算法使用相同的沉浸类比来提取图像中的有意义区域。这些区域也将通过逐级淹没图像来创建,但这次,我们将对在沉浸过程中相对稳定的盆地感兴趣。将观察到这些区域对应于图像中场景对象的某些独特部分。
如何做到这一点...
计算图像 MSER 的基本类是 cv::MSER。可以通过使用默认的空构造函数创建此类的一个实例。在我们的情况下,我们选择通过指定检测到的区域的最小和最大尺寸来初始化它,以限制其数量。然后,我们的调用如下所示:
// basic MSER detector
cv::MSER mser(5, // delta value for extremal region detection
200, // min acceptable area
1500); // max acceptable area
现在,可以通过调用一个函数对象来获得 MSER,指定输入图像和适当的数据结构,如下所示:
// vector of point sets
std::vector<std::vector<cv::Point>> points;
// detect MSER features
mser(image, points);
结果是一个由组成每个区域的像素点表示的区域向量。为了可视化结果,我们在一个空白图像上显示检测到的区域,这些区域将以不同的颜色(随机选择)显示。这是通过以下方式完成的:
// create white image
cv::Mat output(image.size(),CV_8UC3);
output= cv::Scalar(255,255,255);
// random number generator
cv::RNG rng;
// for each detected feature
for (std::vector<std::vector<cv::Point>>::
iterator it= points.begin();
it!= points.end(); ++it) {
// generate a random color
cv::Vec3b c(rng.uniform(0,255),
rng.uniform(0,255),
rng.uniform(0,255));
// 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:

然后,生成的图像将如下所示(请参考书籍的图形 PDF 以查看此图像的颜色)(参照以下内容):

这些是检测的原始结果。尽管如此,可以观察到这个操作员如何能够从这个图像中提取一些有意义的区域(例如,建筑物的窗户)。
它是如何工作的……
MSER 使用与水算法相同的机制;也就是说,它通过从级别0逐渐淹没图像到级别255来执行。随着水位的升高,你可以观察到那些尖锐界定较暗的区域形成了在一定时间内形状相对稳定的盆地(回忆一下,在沉浸类比中,水位对应于强度级别)。这些稳定的盆地就是 MSER。这些是通过考虑每个级别的连通区域并测量它们的稳定性来检测的。这是通过比较区域的当前面积与当级别下降 delta 值时的先前面积来完成的。当这种相对变化达到局部最小值时,该区域被识别为 MSER。用于测量相对稳定性的 delta 值是cv::MSER类构造函数的第一个参数;其默认值是5。此外,要考虑的区域大小必须在某个预定义的范围内。可接受的区域最小和最大大小是构造函数的下一个两个参数。我们还必须确保 MSER 是稳定的(第四个参数),也就是说,其形状的相对变化足够小。稳定的区域可以包含在更大的区域中(称为父区域)。
要有效,父 MSER 必须与其子足够不同;这是多样性标准,它由cv::MSER构造函数的第五个参数指定。在上一节中使用的示例中,使用了这两个最后参数的默认值。(这些最后两个参数的默认值是 MSER 允许的最大变化为0.25,父 MSER 的最小多样性为0.2。)
MSER 检测器的输出是一个点集的向量。由于我们通常更关注整个区域而不是其单个像素位置,因此通常用描述 MSER 位置和大小的简单几何形状来表示 MSER。边界椭圆是一种常用的表示方法。为了获得这些椭圆,我们将利用两个方便的 OpenCV 函数。第一个是cv::minAreaRect函数,它找到绑定集合中所有点的最小面积矩形。这个矩形由一个cv::RotatedRect实例描述。一旦找到这个边界矩形,就可以使用cv::ellipse函数在图像上绘制内切椭圆。让我们将这个完整的过程封装在一个类中。这个类的构造函数基本上重复了cv::MSER类的构造函数。参考以下代码:
class MSERFeatures {
private:
cv::MSER mser; // mser detector
double minAreaRatio; // extra rejection parameter
public:
MSERFeatures(
// aceptable size range
int minArea=60, int maxArea=14400,
// min value for MSER area/bounding-rect area
double minAreaRatio=0.5,
// delta value used for stability measure
int delta=5,
// max allowed area variation
double maxVariation=0.25,
// min size increase between child and parent
double minDiversity=0.2):
mser(delta,minArea,maxArea,
maxVariation,minDiversity),
minAreaRatio(minAreaRatio) {}
添加了一个额外的参数(minAreaRatio)来消除那些边界矩形面积与所代表 MSER 面积差异很大的 MSER。这是为了去除那些不太有趣的细长形状。
代表性边界矩形的列表是通过以下方法计算的:
// get the rotated bounding rectangles
// corresponding to each MSER feature
// if (mser area / bounding rect area) < areaRatio,
// the feature is rejected
void getBoundingRects(const cv::Mat &image,
std::vector<cv::RotatedRect> &rects) {
// detect MSER features
std::vector<std::vector<cv::Point>> points;
mser(image, points);
// for each detected feature
for (std::vector<std::vector<cv::Point>>::
iterator it= points.begin();
it!= points.end(); ++it) {
// Extract bouding rectangles
cv::RotatedRect rr= cv::minAreaRect(*it);
// check area ratio
if (it->size() > minAreaRatio*rr.size.area())
rects.push_back(rr);
}
}
使用以下方法在图像上绘制相应的椭圆:
// draw the rotated ellipses corresponding to each MSER
cv::Mat getImageOfEllipses(const cv::Mat &image,
std::vector<cv::RotatedRect> &rects,
cv::Scalar color=255) {
// image on which to draw
cv::Mat output= image.clone();
// get the MSER features
getBoundingRects(image, rects);
// for each detected feature
for (std::vector<cv::RotatedRect>::
iterator it= rects.begin();
it!= rects.end(); ++it) {
cv::ellipse(output,*it,color);
}
return output;
}
MSER 的检测随后获得如下:
// create MSER feature detector instance
MSERFeatures mserF(200, // min area
1500, // max area
0.5); // ratio area threshold
// default delta is used
// the vector of bounding rotated rectangles
std::vector<cv::RotatedRect> rects;
// detect and get the image
cv::Mat result= mserF.getImageOfEllipses(image,rects);
通过将此函数应用于之前使用的图像,我们将得到以下图像:

将此结果与之前的结果进行比较应该会使您相信这种后来的表示更容易解释。注意,子 MSER 和父 MSER 通常由非常相似的椭圆表示。在某些情况下,然后可能会对这些椭圆应用最小变化标准,以消除这些重复的表示。
参见
-
第七章中的计算组件形状描述符配方,提取线条、轮廓和组件将向您展示如何计算连接点集的其他属性
-
第八章,检测兴趣点将解释如何使用 MSER 作为兴趣点检测器
使用 GrabCut 算法提取前景对象
OpenCV 提出了另一个流行的图像分割算法的实现:GrabCut算法。这个算法不是基于数学形态学,但我们在这里介绍它,因为它在用法上与本章 earlier 提到的水系分割算法有一些相似之处。与水系相比,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_FGD 或 cv::GC_FGD 的像素,可以检查第一个位的值,如下所示:
// checking first bit with bitwise-and
result= result&1; // will be 1 if FG
这之所以可能,是因为这些常量被定义为值 1 和 3,而其他两个(cv::GC_BGD 和 cv::GC_PR_BGD)被定义为 0 和 2。在我们的例子中,得到相同的结果是因为分割图像不包含 cv::GC_FGD 像素(只有 cv::GC_BGD 像素被输入)。
最后,通过以下带有掩膜的复制操作,我们得到前景对象(在白色背景上)的图像:
// Generate output image
cv::Mat foreground(image.size(),CV_8UC3,
cv::Scalar(255,255,255)); // all white image
image.copyTo(foreground,result); // bg pixels not copied
下图是得到的结果:

它是如何工作的...
在前面的例子中,GrabCut 算法能够通过简单地指定一个包含这些对象(四只动物)的矩形来提取前景对象。或者,也可以将 cv::GC_BGD 和 cv::GC_FGD 的值分配给分割图像中的某些特定像素,这些像素作为 cv::grabCut 函数的第二个参数提供。然后,指定 GC_INIT_WITH_MASK 作为输入模式标志。这些输入标签可以通过要求用户交互式标记图像的几个元素来获得。也可以将这两种输入模式结合起来。
使用此输入信息,GrabCut 算法通过以下步骤创建背景/前景分割。最初,将前景标签(cv::GC_PR_FGD)暂时分配给所有未标记的像素。根据当前的分类,算法将像素分组为相似颜色的簇(即,背景和前景各有K个簇)。下一步是通过在前景和背景像素之间引入边界来确定背景/前景分割。这是通过一个优化过程来完成的,该过程试图连接具有相似标签的像素,并对在相对均匀强度区域放置边界施加惩罚。此优化问题可以使用图割算法有效地解决,这是一种通过将其表示为应用切割以组成最优配置的连通图来找到问题最优解的方法。获得的分割为像素产生新的标签。然后可以重复聚类过程,并再次找到新的最优分割,依此类推。因此,GrabCut 算法是一个迭代过程,它逐渐改进分割结果。根据场景的复杂性,可以在更多或更少的迭代次数中找到良好的解决方案(在简单情况下,一次迭代就足够了)。
这解释了函数的参数,用户可以指定要应用的迭代次数。算法维护的两个内部模型作为函数的参数传递(并返回)。因此,如果希望通过执行额外的迭代来改进分割结果,可以再次使用上次运行的模型调用该函数。
参见
- 文章《GrabCut:在 ACM Transactions on Graphics (SIGGRAPH) 第 23 卷第 3 期,2004 年 8 月》中,GrabCut:使用迭代图割进行交互式前景提取,由 C. Rother, V. Kolmogorov 和 A. Blake 描述了 GrabCut 算法的详细信息。
第六章. 图像滤波
在本章中,我们将涵盖以下内容:
-
使用低通滤波器过滤图像
-
使用中值滤波器过滤图像
-
将方向滤波器应用于边缘检测
-
计算图像的拉普拉斯算子
简介
滤波是信号和图像处理中的基本任务之一。它是一个旨在选择性地提取图像中某些方面(在给定应用背景下被认为传达重要信息)的过程。滤波可以去除图像中的噪声,提取有趣的视觉特征,允许图像重采样等。它源于一般的信号与系统理论。我们在此处不会详细讨论这一理论。然而,本章将介绍与滤波相关的一些重要概念,并展示如何在图像处理应用中使用滤波器。但首先,让我们从频域分析的概念进行简要解释。
当我们观察图像时,我们注意到不同的灰度级(或颜色)是如何分布在图像上的。图像之间的差异在于它们有不同的灰度级分布。然而,存在另一种观点,即可以从图像中观察到的灰度级变化。一些图像包含几乎恒定强度的大面积(例如,蓝色的天空),而其他图像的灰度级强度在图像上快速变化(例如,拥挤的繁忙场景中充满了许多小物体)。因此,观察图像中这些变化的频率构成了表征图像的另一种方式。这种观点被称为频域,而通过观察图像的灰度级分布来表征图像被称为空间域。
频域分析将图像分解为其从最低到最高频率的频率内容。图像强度变化缓慢的区域只包含低频,而高频是由强度快速变化产生的。存在几种著名的变换,如傅里叶变换或余弦变换,可以用来明确地显示图像的频率内容。请注意,由于图像是二维实体,它由垂直频率(垂直方向的变化)和水平频率(水平方向的变化)组成。
在频域分析框架下,滤波器是一种操作,它会放大图像中某些频率带的频率,同时阻止(或减少)其他图像频率带。因此,低通滤波器是一种消除图像高频成分的滤波器,而高通滤波器则消除低通成分。本章将介绍一些在图像处理中经常使用的滤波器,并解释它们在应用于图像时的效果。
使用低通滤波器进行图像滤波
在这个第一个菜谱中,我们将介绍一些非常基本的低通滤波器。在本章的介绍部分,我们了解到这类滤波器的目的是降低图像变化幅度。实现这一目标的一种简单方法是将每个像素替换为其周围像素的平均值。通过这样做,快速强度变化将被平滑,从而被更渐进的过渡所取代。
如何做...
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均值滤波器,相应的核如下:
| 1/9 | 1/9 | 1/9 |
|---|---|---|
| 1/9 | 1/9 | 1/9 |
| 1/9 | 1/9 | 1/9 |
cv::boxFilter函数使用由许多1组成的正方形核对图像进行滤波。它与均值滤波器类似,但不需要将结果除以系数的数量。
应用线性滤波器相当于将核移动到图像的每个像素上,并将每个对应的像素与其关联的权重相乘。从数学上讲,这种操作称为卷积,可以正式表示如下:

前面的双重求和将当前像素 (x,y) 与 K 核的中心对齐,假设该中心位于坐标 (0,0)。
观察本菜谱生成的输出图像,可以观察到低通滤波器的净效应是模糊或平滑图像。这并不奇怪,因为这个滤波器衰减了与物体边缘上可见的快速变化相对应的高频分量。
在高斯滤波器的情况下,与像素关联的权重与其与中心像素的距离成正比。回想一下,1D 高斯函数具有以下形式:

归一化系数 A 被选择,使得不同的权重之和为 1。σ(西格玛)值控制着结果高斯函数的宽度。这个值越大,函数就越平坦。例如,如果我们计算区间 [-4, 0, 4] 上 σ = 0.5 的 1D 高斯滤波器的系数,我们得到以下系数:
[0.0 0.0 0.00026 0.10645 0.78657 0.10645 0.00026 0.0 0.0]
对于 σ=1.5,这些系数如下:
[0.00761 0.036075 0.10959 0.21345 0.26666
0.21345 0.10959 0.03608 0.00761 ]
注意,这些值是通过调用 cv::getGaussianKernel 函数并使用适当的 σ 值获得的:
cv::Mat gauss= cv::getGaussianKernel(9, sigma,CV_32F);
高斯函数的对称钟形使其成为滤波的良好选择。参见图下所示:

离中心点更远的像素权重较低,这使得像素间的过渡更平滑。这与平坦的均值滤波器形成对比,其中远离中心的像素可能导致当前均值值的突然变化。从频率的角度来看,这意味着均值滤波器并没有移除所有的高频分量。
要在图像上应用 2D 高斯滤波器,可以先对图像行应用 1D 高斯滤波器(以过滤水平频率),然后应用另一个 1D 高斯滤波器于图像列(以过滤垂直频率)。这是可能的,因为高斯滤波器是一个可分离的滤波器(也就是说,2D 核可以分解为两个 1D 滤波器)。可以使用 cv::sepFilter2D 函数应用一个通用的可分离滤波器。也可以直接使用 cv::filter2D 函数应用 2D 核。一般来说,可分离滤波器比不可分离滤波器计算更快,因为它们需要的乘法操作更少。
使用 OpenCV,要应用于图像的高斯滤波器通过提供系数数量(第三个参数,它是一个奇数)和 σ(第四个参数)的值来指定 cv::GaussianBlur。你也可以简单地设置 σ 的值,让 OpenCV 确定适当的系数数量(此时你输入一个值为 0 的滤波器大小)。反之亦然,你可以输入一个大小和一个值为 0 的 σ。将确定最适合给定大小的 σ 值。
还有更多...
当图像缩放时,也会使用低通滤波器;本节解释了原因。图像缩放可能还需要插值像素值;本节也讨论了这一点。
对图像进行下采样
你可能会认为,你可以通过简单地消除图像的一些列和行来减小图像的大小。不幸的是,结果图像看起来不会很好。以下图示通过显示一个测试图像,该图像相对于其原始大小减小了4倍,仅仅保留了每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 reduced2(image.rows/4,image.cols/4,CV_8U);
for (int i=0; i<reduced2.rows; i++)
for (int j=0; j<reduced2.cols; j++)
reduced2.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高斯滤波器(但系数乘以4)。显然,如果您减小图像大小然后再将其放大,您将无法恢复原始图像。在减小大小过程中丢失的内容无法恢复。这两个函数用于创建图像金字塔。这是一个由不同大小图像的堆叠版本组成的数据结构(在这里,每个级别比前一个级别小 2 倍,但减小因子可以更小,例如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::INTER_NEAREST)将上一节中减小大小的图像按3倍缩放,我们得到以下代码:
cv::resize(reduced, newImage,
cv::Size(), 3, 3,cv::INTER_NEAREST);
结果将在以下屏幕截图中显示:

在这个例子中,插值相当于简单地每个像素的大小乘以3(这就是我们产生上一节图像的方法)。一个更好的方法是通过组合几个相邻像素的值来插值一个新的像素值。因此,我们可以通过考虑围绕它的四个像素来线性插值像素值,如下面的图所示:

这是通过首先垂直插值两个像素值到添加的像素的左侧和右侧来完成的。然后,使用这两个插值像素(在前面的图中以灰色绘制)来水平插值所需位置的像素值。这种双线性插值方案是cv::resize默认使用的方法(也可以通过标志cv::INTER_LINEAR显式指定):
cv::resize(reduced2, newImage,
cv::Size(), 3, 3, cv::INTER_LINEAR);
下面的结果是:

还存在其他方法可以产生更好的结果。使用双三次插值,考虑一个4x4像素的邻域来进行插值。然而,由于这种方法使用了更多的像素,并且涉及到立方项的计算,它比双线性插值要慢。
参见
- 第二章中使用邻域访问扫描图像配方的更多内容…部分介绍了
cv::filter2D函数。这个函数允许你通过输入你选择的核来对一个图像应用线性滤波器。
使用中值滤波器过滤图像
本章的第一种配方介绍了线性滤波器的概念。非线性滤波器也存在,并且可以在图像处理中有效地使用。其中一种滤波器就是我们在本配方中介绍的均值滤波器。
由于中值滤波器特别有用,可以用来对抗盐和胡椒噪声(或者在我们的情况下,只有盐),我们将使用在第二章的第一种配方中创建的图像,操作像素,并且在此处重现:

如何做...
中值滤波函数的调用方式与其他滤波器类似:
cv::medianBlur(image,result,5); // 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);
Sobel 范数可以通过使用convertTo方法的可选缩放参数方便地在图像中显示,以便获得一个图像,其中零值对应白色,而更高的值则分配更深的灰色阴影:
// 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核,其结构如下:
| -1 | 0 | 1 |
|---|---|---|
| -2 | 0 | 2 |
| -1 | 0 | 1 |
| -1 | -2 | -1 |
| 0 | 0 | 0 |
| 1 | 2 | 1 |
如果我们将图像视为一个二维函数,那么 Sobel 算子可以被视为图像在垂直和水平方向上的变化度量。从数学的角度来看,这个度量被称为梯度,它定义为从函数的两个正交方向的一阶导数构成的 2D 向量:

因此,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 掩码对应一个方向上的导数。因此,使用两个参数来指定将要应用的核,即x和y方向上的导数阶数。例如,通过指定xorder和yorder参数为1和0,可以得到水平 Sobel 核;而垂直核将通过指定0和1来生成。其他组合也是可能的,但这两个是最常用的(二阶导数的情况将在下一食谱中讨论)。最后,也可以使用大于3x3大小的核。可能的核大小值为1、3、5和7。大小为 1 的核对应于 1D Sobel 滤波器(1x3或3x1)。参见以下更多内容…部分,了解为什么使用更大的核可能是有用的。
由于梯度是一个二维向量,它有一个范数和一个方向。梯度向量的范数告诉你变化的幅度是多少,它通常被计算为欧几里得范数(也称为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;
cv::cartToPolar(sobelX,sobelY,norm,dir);
默认情况下,方向是以弧度计算的。只需添加一个额外的参数true,就可以以度为单位计算它们。
通过对梯度幅度的阈值处理,我们得到了一个二值边缘图。选择合适的阈值并非易事。如果阈值设置得太低,过多的(粗的)边缘会被保留;而如果我们选择一个更严格的(更高的)阈值,则会得到断裂的边缘。为了说明这种权衡情况,可以将前面的二值边缘图与以下使用更高阈值值得到的图进行比较:

要同时获得低阈值和高阈值的最佳效果,可以使用滞后阈值的概念。这将在下一章中解释,其中我们将介绍 Canny 算子。
还有更多...
其他梯度算子也存在。我们将在本节中介绍其中的一些。在应用导数滤波器之前,也可以先应用高斯平滑滤波器。正如本节所解释的,这会使它对噪声的敏感性降低。
梯度算子
为了估计像素位置的梯度,Prewitt 算子定义了以下核:
| -1 | 0 | 1 |
|---|---|---|
| -1 | 0 | 1 |
| -1 | 0 | 1 |
| -1 | -1 | -1 |
| 0 | 0 | 0 |
| 1 | 1 | 1 |
Roberts 算子基于以下简单的2x2核:
| 1 | 0 |
|---|---|
| 0 | -1 |
| 0 | 1 |
| -1 | 0 |
当需要更精确的梯度方向估计时,首选 Scharr 算子:
| -3 | 0 | 3 |
|---|---|---|
| -10 | 0 | 10 |
| -3 | 0 | 3 |
| -3 | -10 | -3 |
| 0 | 0 | 0 |
| 3 | 10 | 3 |
注意,可以通过使用CV_SCHARR参数调用cv::Sobel函数来使用 Scharr 核:
cv::Sobel(image,sobelX,CV_16S,1,0, CV_SCHARR);
或者,等价地,你可以调用cv::Scharr函数:
cv::Scharr(image,scharrX,CV_16S,1,0,3);
所有这些方向滤波器都试图估计图像函数的一阶导数。因此,在滤波器方向上存在大强度变化区域时,会获得高值,而平坦区域产生低值。这就是为什么计算图像导数的滤波器是高通滤波器。
高斯导数
导数滤波器是高通滤波器。因此,它们倾向于放大图像中的噪声和小的、高度对比的细节。为了减少这些高频元素的影响,在应用导数滤波器之前先平滑图像是一个好习惯。你可能会认为这将是两个步骤,即先平滑图像然后计算导数。然而,仔细观察这些操作可以发现,可以通过适当选择平滑核将这两个步骤合并为一个。我们之前了解到,图像与滤波器的卷积可以表示为项的求和。有趣的是,一个著名的数学性质是,项的求和的导数等于项的导数的求和。
因此,而不是在平滑的结果上应用导数,可以首先对核进行微分,然后将其与图像卷积。由于高斯核是连续可导的,它是一个特别合适的选择。这就是当你用不同核大小调用cv::sobel函数时所做的事情。该函数将计算具有不同σ值的高斯导数核。例如,如果我们选择 x 方向的7x7Sobel 滤波器(即kernel_size=7),则会得到以下结果:

如果你将此图像与之前显示的图像进行比较,可以看出许多细微的细节已被移除,从而使得对更显著的边缘更加重视。请注意,我们现在有一个带通滤波器,高频率通过高斯滤波器移除,低频率通过 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 核:
| 0 | 1 | 0 |
|---|---|---|
| 1 | -4 | 1 |
| 0 | 1 | 0 |
对于 Sobel 算子,也可以使用更大的核来计算拉普拉斯变换,并且由于此算子对图像噪声更加敏感,因此这样做是可取的(除非计算效率是关注点)。由于这些较大的核是使用高斯函数的二阶导数计算的,因此相应的算子通常被称为高斯拉普拉斯(LoG)。请注意,拉普拉斯的核值总和总是0。这保证了拉普拉斯在强度恒定的区域为零。实际上,由于拉普拉斯测量图像函数的曲率,它应该在平坦区域等于0。
初看,拉普拉斯的效果可能难以解释。从核的定义来看,很明显,任何孤立的像素值(即与邻居非常不同的值)都会被算子放大。这是算子对噪声高度敏感的结果。然而,观察图像边缘周围的拉普拉斯值更有趣。图像中边缘的存在是不同灰度强度区域之间快速转换的结果。沿着边缘(例如,由暗到亮的转换引起的)跟踪图像函数的演变,可以观察到灰度值的上升必然意味着从正曲率(当强度值开始上升时)到负曲率(当强度即将达到其高平台时)的逐渐过渡。因此,正负拉普拉斯值(或反之)之间的转换是边缘存在的好指标。另一种表达这个事实的方式是说,边缘将位于拉普拉斯函数的零交叉处。我们将通过观察测试图像中的一个小型窗口中的拉普拉斯值来阐述这个想法。我们选择一个对应于城堡塔楼底部部分创建的边缘。在以下图像中,画了一个白色方框来显示这个感兴趣区域的精确位置:

现在,观察这个窗口内的拉普拉斯值(7x7核),我们得到以下图示:

如图中所示,如果你仔细追踪拉普拉斯的零交叉(位于不同符号的像素之间),你会得到一条与图像窗口中可见的边缘相对应的曲线。在先前的图中,我们在与所选图像窗口中可见的塔楼边缘相对应的零交叉处画了虚线。这意味着,原则上,你甚至可以以亚像素的精度检测图像边缘。
跟随拉普拉斯图像中的零交叉曲线是一项精细的任务。然而,可以使用简化的算法来检测近似零交叉位置。这个算法首先在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;
}
结果是以下二值图:

如您所见,拉普拉斯算子的零交叉检测到所有边缘。没有区分强边缘和弱边缘。我们还提到,拉普拉斯算子对噪声非常敏感。最后,其中一些边缘是由于压缩伪影造成的。所有这些因素都解释了为什么操作器检测到这么多边缘。实际上,拉普拉斯算子通常与其他操作器结合使用来检测边缘(例如,可以在强梯度幅度的零交叉位置声明边缘)。我们还将学习在第八章,检测兴趣点中,拉普拉斯算子和其他二阶算子对于在多个尺度上检测兴趣点非常有用。
还有更多...
拉普拉斯算子是一个高通滤波器。可以通过组合低通滤波器来近似它。但在那之前,让我们先谈谈图像增强,这是一个我们在第二章,操作像素中已经讨论过的主题。
使用拉普拉斯算子增强图像对比度
通过从图像中减去其拉普拉斯算子,可以增强图像的对比度。这就是我们在第二章,操作像素中提到的“使用邻域访问扫描图像”的食谱中所做的。在那里,我们介绍了以下核:
| 0 | -1 | 0 |
|---|---|---|
| -1 | 5 | -1 |
| 0 | -1 | 0 |
这等于 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 滤波器的一个良好近似。此外,如果你从σ值递增序列的连续对值中计算一系列高斯差分,你将获得图像的尺度空间表示。这种多尺度表示很有用,例如,对于尺度不变图像特征检测,正如将在第八章“检测兴趣点”中解释的那样。
参见
- 第八章中“检测兴趣点”的检测尺度不变特征菜谱使用拉普拉斯和高斯差分进行尺度不变特征的检测
第七章:提取线条、轮廓和组件
在本章中,我们将介绍以下内容:
-
使用 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 算法还采用了一种额外的策略来提高边缘图的质量。在应用阈值化之前,所有梯度方向上梯度幅度不是最大的边缘点都被移除。回想一下,梯度方向始终垂直于边缘。因此,该方向上的梯度局部最大值对应于轮廓的最大强度点。这解释了为什么 Canny 轮廓图中会得到细边缘。
参见
- J. Canny 的经典文章,边缘检测的计算方法,IEEE Transactions on Pattern Analysis and Image Understanding,第 18 卷,第 6 期,1986 年
使用霍夫变换在图像中检测线条
在我们人造的世界中,平面和线性结构无处不在。因此,直线在图像中经常可见。这些是有意义的特征,在物体识别和图像理解中起着重要作用。Hough 变换是一个经典算法,常用于在图像中检测这些特定的特征。它最初是为了检测图像中的线而开发的,正如我们将看到的,它还可以扩展到检测其他简单的图像结构。
准备中
使用 Hough 变换,线使用以下方程表示:

ρ参数是线与图像原点(左上角)之间的距离,θ是垂直于线的角度。在这种表示下,图像中可见的线具有θ角度,介于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 搜索所有可能的角度。最后一个参数的作用将在下一节中解释。使用这种特定的参数值,在先前的食谱中的道路图像上检测到 15 条线。为了可视化检测结果,将这些线绘制在原始图像上是有趣的。然而,需要注意的是,此算法检测图像中的线,而不是线段,因为每条线的端点没有给出。因此,我们将绘制贯穿整个图像的线。为此,对于垂直方向的线,我们计算它与图像水平极限(即第一行和最后一行)的交点,并在这两个点之间绘制一条线。我们以类似的方式处理水平方向的线,但使用第一列和最后一列。使用 cv::line 函数绘制线。请注意,即使点坐标超出图像范围,此函数也能很好地工作。因此,没有必要检查计算出的交点是否在图像内。然后通过迭代线向量来绘制线,如下所示:
std::vector<cv::Vec2f>::const_iterator it= lines.begin();
while (it!=lines.end()) {
float rho= (*it)[0]; // first element is distance rho
float theta= (*it)[1]; // second element is angle theta
if (theta < PI/4\.
|| theta > 3.*PI/4.) { // ~vertical line
// point of intersection of the line with first row
cv::Point pt1(rho/cos(theta),0);
// point of intersection of the line with last row
cv::Point pt2((rho-result.rows*sin(theta))/
cos(theta),result.rows);
// draw a white line
cv::line( image, pt1, pt2, cv::Scalar(255), 1);
} else { // ~horizontal line
// point of intersection of the
// line with first column
cv::Point pt1(0,rho/sin(theta));
// point of intersection of the line with last column
cv::Point pt2(result.cols,
(rho-result.cols*cos(theta))/sin(theta));
// draw a white line
cv::line(image, pt1, pt2, cv::Scalar(255), 1);
}
++it;
}
以下结果如下:

如所示,霍夫变换简单地寻找图像中边缘像素的对齐。这可能会由于偶然的像素对齐或当几条线穿过相同的像素对齐时产生一些错误检测。
为了克服这些问题,并允许检测线段(即带有端点),已经提出了一种变换的变体。这是概率霍夫变换,它在 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
std::vector<cv::Vec4i> lines= finder.findLines(contours);
finder.drawDetectedLines(image);
cv::namedWindow("Detected Lines with HoughP");
cv::imshow("Detected Lines with HoughP",image);
以下代码给出以下结果:

它是如何工作的...
霍夫变换的目标是在二值图像中找到所有通过足够多点的线。它通过考虑输入二值图中的每个单独的像素点并识别所有通过它的可能线来实现。当相同的线通过许多点时,这意味着这条线足够重要,值得考虑。
Hough 变换使用一个二维累加器来计数给定线条被识别的次数。这个累加器的大小由指定的步长(如前所述)确定,这些步长是采用的线条表示法中(ρ, θ)参数的步长。为了说明变换的工作原理,让我们创建一个 180×200 的矩阵(对应于θ的步长为π/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,如下所示:
cv::HoughLines(test,lines,1,PI/180,50);
之前代码的结果是,将接受更多线条,如前一小节所示,如下面的截图所示:

概率 Hough 变换对基本算法进行了一些修改。首先,它不是系统地按行扫描图像,而是在二值图中随机选择点。每当累加器的某个条目达到指定的最小值时,就会沿着相应的行扫描图像,并移除所有通过该行的点(即使它们还没有投票)。这种扫描还确定了将被接受的段长度。为此,算法定义了两个额外的参数。一个是段被接受的最小长度,另一个是形成连续段所允许的最大像素间隙。这一额外步骤增加了算法的复杂性,但部分地通过减少参与投票过程中的点数来补偿,因为其中一些点被线扫描过程消除了。
更多内容...
Hough 变换也可以用来检测其他几何实体。实际上,任何可以用参数方程表示的实体都是 Hough 变换的良好候选者。
检测圆
在圆的情况下,相应的参数方程如下:

该方程包括三个参数(圆半径和中心坐标),这意味着需要一个三维累加器。然而,通常发现,随着累加器维度的增加,Hough 变换的可靠性降低。确实,在这种情况下,每个点都会增加累加器的大量条目,因此,局部峰值的精确定位变得更加困难。已经提出了不同的策略来克服这个问题。OpenCV 实现的 Hough 圆检测所使用的策略是两遍扫描。在第一遍扫描中,使用二维累加器来找到候选圆的位置。由于圆周上点的梯度应该指向半径方向,因此对于每个点,只增加累加器沿梯度方向的条目(基于预定义的最小和最大半径值)。一旦检测到可能的圆心(即,已收到预定义数量的投票),在第二遍扫描中构建一个可能的半径的一维直方图。该直方图中的峰值对应于检测到的圆的半径。
实现上述策略的 cv::HoughCircles 函数集成了 Canny 检测和 Hough 变换。其调用方式如下:
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;
}
以下是在测试图像上使用所选参数获得的结果:

参见
-
由 C. Galambos、J. Kittler 和 J. Matas 撰写的文章《Gradient-based Progressive Probabilistic Hough Transform by C. Galambos, J. Kittler, and J. Matas, IEE Vision Image and Signal Processing, vol. 148 no 3, pp. 158-165, 2002》是关于 Hough 变换的众多参考文献之一,描述了 OpenCV 中实现的概率算法
-
由 H.K. Yuen、J. Princen、J. Illingworth 和 J. Kittler 撰写的文章《Comparative Study of Hough Transform Methods for Circle Finding, Image and Vision Computing, vol. 8 no 1, pp. 71-77, 1990》描述了使用 Hough 变换进行圆检测的不同策略
将直线拟合到一组点
在某些应用中,不仅检测图像中的直线,而且获得直线位置和方向的准确估计可能很重要。本食谱将向您展示如何找到最适合给定一组点的直线。
如何做到...
首件事是识别图像中似乎沿直线排列的点。让我们使用前面菜谱中检测到的其中一条线。使用cv::HoughLinesP检测到的线包含在名为lines的std::vector<cv::Vec4i>中。为了提取似乎属于这些线中的第一条线的点集,我们可以这样做。我们在黑色图像上画一条白色线,并与用于检测我们的线的 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的最后两个值)的形式给出了线方程参数。对于我们的例子,这些值是方向向量为(0.83, 0.55),点坐标为(366.1, 289.1)。最后两个参数指定了线参数的请求精度。
通常,线方程将用于计算某些属性(校准是一个需要精确参数表示的好例子)。为了说明,并确保我们计算了正确的线,让我们在图像上绘制估计的线。在这里,我们简单地绘制了一个长度为100像素、厚度为3像素的任意黑色线段:
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,3); // color and thickness
结果可以在以下屏幕截图中看到:

它是如何工作的...
将直线拟合到一组点在数学中是一个经典问题。OpenCV 的实现是通过最小化每个点到直线的距离之和来进行的。提出了几种距离函数,其中最快的选择是使用欧几里得距离,它由CV_DIST_L2指定。这个选择对应于标准的最小二乘法直线拟合。当包含异常值(即不属于直线的点)在点集中时,可以选择对远点影响较小的其他距离函数。最小化是基于 M-估计技术,该技术通过迭代求解具有与到直线的距离成反比的权重的加权最小二乘问题。
使用这个函数,还可以将线拟合到 3D 点集。在这种情况下,输入是一个cv::Point3i或cv::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 of each contours
使用这个调用,获得了以下轮廓:

注意在背景森林中添加的额外轮廓。这些轮廓也可以组织成层次结构。主要组件是父元素,其中的孔是它的子元素,如果这些孔中有组件,它们就变成先前子元素的子元素,依此类推。这个层次结构是通过使用 CV_RETR_TREE 标志获得的,如下所示:
std::vector<cv::Vec4i> hierarchy;
cv::findContours(image,
contours, // a vector of contours
hierarchy, // hierarchical representation
CV_RETR_TREE, // retrieve all contours in tree format
CV_CHAIN_APPROX_NONE); // all pixels of each contours
在这种情况下,每个轮廓都有一个对应于相同索引的层次元素,由四个整数组成。前两个整数给出了同一级别的下一个和前一个轮廓的索引,后两个整数给出了此轮廓的第一个子元素和父元素的索引。负索引表示轮廓列表的结束。CV_RETR_CCOMP 标志类似,但将层次限制在两个级别。
计算组件的形状描述符
连通组件通常对应于图片场景中物体的图像。为了识别这个物体,或者将其与其他图像元素进行比较,对组件进行一些测量以提取其某些特征可能是有用的。在这个菜谱中,我们将查看 OpenCV 中可用的某些形状描述符,这些描述符可以用来描述连通组件的形状。
如何操作...
当涉及到形状描述时,许多 OpenCV 函数都是可用的。我们将应用其中的一些函数在我们前面菜谱中提取的组件上。特别是,我们将使用我们之前识别的四个水牛对应的四个轮廓的向量。在下面的代码片段中,我们在轮廓(contours[0] 到 contours[3])上计算形状描述符,并在轮廓图像(厚度为 1)上绘制结果(厚度为 2)。此图像在本节末尾显示。
第一个是边界框,它应用于右下角的组件:
// testing the bounding box
cv::Rect r0= cv::boundingRect(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);
contour和hull参数分别代表原始和凸包轮廓(均以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 neighbourhood;
// aperture for gradient computation
int aperture;
// Harris parameter
double k;
// maximum strength for threshold computation
double maxStrength;
// calculated threshold (internal)
double threshold;
// size of neighborhood for non-max suppression
int nonMaxSize;
// kernel for non-max suppression
cv::Mat kernel;
public:
HarrisDetector() : neighbourhood(3), aperture(3),
k(0.01), maxStrength(0.0),
threshold(0.01), nonMaxSize(3) {
// create kernel used in non-maxima suppression
setLocalMaxWindowSize(nonMaxSize);
}
要在图像上检测 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 特征的检测分为两种方法,这使得我们可以使用不同的阈值(直到获得适当数量的特征点)来测试检测,而无需重复昂贵的计算。也可以以std::vector的cv::Point形式获取 Harris 特征:
// 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函数的第三个参数)。然后可以计算所有可能方向上的平均强度变化,这导致了一个角点的定义:即在一个方向上平均变化高,而在另一个方向上也高的点。从这个定义出发,Harris 测试如下进行。我们首先获得最大平均强度变化的方向。接下来,我们检查正交方向上的平均强度变化是否也高。如果是这样,那么我们就有一个角点。
从数学上讲,这个条件可以通过使用前一个公式的泰勒展开来近似测试:

然后将其重写为矩阵形式:

这个矩阵是一个协方差矩阵,它表征了所有方向上强度变化的速度。这个定义涉及到图像的第一导数,这些导数通常使用 Sobel 算子来计算。这是 OpenCV 实现的情况,它是函数的第四个参数,对应于用于计算 Sobel 滤波器的孔径。可以证明,协方差矩阵的两个特征值给出了最大平均强度变化和正交方向上的平均强度变化。然后,如果这两个特征值较低,我们处于一个相对均匀的区域。如果一个特征值较高而另一个较低,我们必须处于一个边缘。最后,如果两个特征值都较高,那么我们处于一个角落位置。因此,一个点要被接受为角落的条件是它必须在高于给定阈值的点上具有协方差矩阵的最小特征值。
哈里斯角算法的原始定义使用特征分解理论的一些性质,以避免显式计算特征值的成本。这些性质如下:
-
矩阵的特征值之积等于其行列式
-
矩阵的特征值之和等于矩阵的对角线之和(也称为矩阵的迹)
因此,我们可以通过计算以下分数来验证矩阵的特征值是否较高:

可以很容易地验证,只有当两个特征值都较高时,这个分数才会确实较高。这是cv::cornerHarris函数在每个像素位置计算的分数。k的值被指定为函数的第五个参数。确定这个参数的最佳值可能很困难。然而,在实践中,已经看到在0.05和0.5范围内的值通常给出良好的结果。
为了提高检测结果,前一小节中描述的类别增加了一个额外的非极大值抑制步骤。这里的目的是排除相邻的其他哈里斯角。因此,为了被接受,哈里斯角不仅必须有一个高于指定阈值的分数,而且它还必须是一个局部最大值。这个条件是通过使用一个简单的技巧来测试的,该技巧包括在detect方法中膨胀哈里斯分数的图像:
cv::dilate(cornerStrength,dilated,cv::Mat());
由于膨胀将每个像素值替换为定义的邻域中的最大值,唯一不会修改的点就是局部最大值。这正是以下等式测试所验证的:
cv::compare(cornerStrength,dilated,
localMax,cv::CMP_EQ);
因此,localMax矩阵仅在局部极大值位置为真(即非零)。然后我们使用它在我们自己的getCornerMap方法中来抑制所有非极大特征(使用cv::bitwise_and函数)。
还有更多...
可以对原始的 Harris 角算法进行更多改进。本节描述了 OpenCV 中找到的另一个角检测器,它将 Harris 检测器扩展到使角在图像中分布得更均匀。正如我们将看到的,此操作符在 OpenCV 2 通用接口中为特征检测器提供了一个实现。
跟踪的良好特征
随着浮点处理器的出现,为了避免特征值分解而引入的数学简化变得可以忽略不计,因此,基于显式计算的特征值,可以基于 Harris 角进行检测。原则上,这种修改不应显著影响检测结果,但它避免了使用任意的k参数。请注意,存在两个函数允许您显式获取 Harris 协方差矩阵的特征值(和特征向量);这些是cv::cornerEigenValsAndVecs和cv::cornerMinEigenVal。
第二种修改解决了特征点聚类的问题。事实上,尽管引入了局部极大值条件,但兴趣点在图像中往往分布不均,在高度纹理的位置出现集中。解决这个问题的一个方法是强制两个兴趣点之间保持最小距离。这可以通过以下算法实现。从具有最强 Harris 分数的点(即具有最大最小特征值)开始,只有当兴趣点位于已接受点至少给定距离之外时,才接受兴趣点。这个解决方案在 OpenCV 的cv::goodFeaturesToTrack函数中实现,因此得名,因为检测到的特征可以用作视觉跟踪应用中的良好起始集。其调用方式如下:
// Compute good features to track
std::vector<cv::Point2f> corners;
cv::goodFeaturesToTrack(image, // input image
corners, // corner image
500, // maximum number of corners to be returned
0.01, // quality level
10); // minimum allowed distance between points
除了质量级别阈值值和兴趣点之间可容忍的最小距离之外,该函数还使用可以返回的最大点数(这是可能的,因为点按强度顺序接受)。前面的函数调用产生以下结果:

这种方法增加了检测的复杂性,因为它需要根据 Harris 分数对兴趣点进行排序,但它也明显改善了点在图像中的分布。请注意,此函数还包括一个可选标志,请求使用经典角评分定义(使用协方差矩阵的行列式和迹)来检测 Harris 角。
特征检测器的通用接口
OpenCV 2 为它的不同兴趣点检测器引入了一个通用接口。这个接口允许在同一个应用程序中轻松测试不同的兴趣点检测器。
接口定义了一个cv::Keypoint类,它封装了每个检测到的特征点的属性。对于哈里斯角,仅关键点的位置及其响应强度是相关的。检测尺度不变特征菜谱将讨论可以与关键点关联的其他属性。
cv::FeatureDetector抽象类基本上强制存在一个具有以下签名的detect操作:
void detect( const Mat& image, vector<KeyPoint>& keypoints,
const Mat& mask=Mat() ) const;
void detect( const vector<Mat>& images,
vector<vector<KeyPoint> >& keypoints,
const vector<Mat>& masks=
vector<Mat>() ) const;
第二种方法允许在图像向量中检测兴趣点。该类还包括其他可以读取和写入检测到的点的文件的方法。
cv::goodFeaturesToTrack函数有一个名为cv::GoodFeaturesToTrackDetector的包装类,它继承自cv::FeatureDetector类。它可以像我们处理 Harris 角类那样使用,如下所示:
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Good Feature to Track detector
cv::Ptr<cv::FeatureDetector> gftt=
new cv::GoodFeaturesToTrackDetector(
500, // maximum number of corners to be returned
0.01, // quality level
10); // minimum allowed distance between points
// point detection using FeatureDetector method
gftt->detect(image,keypoints);
结果与之前获得的结果相同,因为包装器最终调用的函数是相同的。注意我们使用了 OpenCV 2 的智能指针类(cv::Ptr),正如在第一章中解释的那样,玩转图像,当引用计数降至零时,会自动释放指向的对象。
参见
-
描述哈里斯算子的经典文章由 C. Harris 和 M.J. Stephens 撰写,A combined corner and edge detector, Alvey Vision Conference, pp. 147–152, 1988
-
J. Shi 和 C. Tomasi 的论文Good features to track, Int. Conference on Computer Vision and Pattern Recognition, pp. 593-600, 1994介绍了这些特征
-
K. Mikolajczyk 和 C. Schmid 的论文Scale and Affine invariant interest point detectors, International Journal of Computer Vision, vol 60, no 1, pp. 63-86, 2004提出了一种多尺度且仿射不变性的哈里斯算子
快速检测特征
哈里斯算子提出了基于两个垂直方向上强度变化率的角(或更一般地说,兴趣点)的正式数学定义。尽管这是一个合理的定义,但它需要计算图像导数,这是一个代价高昂的操作,尤其是考虑到兴趣点检测通常只是更复杂算法的第一步。
在这个菜谱中,我们介绍另一个特征点算子,称为FAST(加速段测试特征)。这个算子是专门设计用来快速检测图像中的兴趣点的;是否接受或拒绝一个关键点的决定仅基于少数像素的比较。
如何操作...
使用 OpenCV 2 通用接口进行特征点检测使得部署任何特征点检测器变得容易。本食谱中介绍的是 FAST 检测器。正如其名所示,它被设计得很快,以便计算以下内容:
// vector of keypoints
std::vector<cv::KeyPoint> keypoints;
// Construction of the Fast feature detector object
cv::Ptr<cv::FeatureDetector> fast=
new cv::FastFeatureDetector(
40); // threshold for detection
// feature point detection
fast->detect(image,keypoints);
注意,OpenCV 还提供了一个通用的函数来在图像上绘制关键点:
cv::drawKeypoints(image, // original image
keypoints, // vector of keypoints
image, // the output image
cv::Scalar(255,255,255), // keypoint color
cv::DrawMatchesFlags::DRAW_OVER_OUTIMG); //drawing flag
通过指定选择的绘制标志,关键点被绘制在输入图像上,从而产生以下输出结果:

一个有趣的选项是为关键点颜色指定一个负值。在这种情况下,将为每个绘制的圆圈选择不同的随机颜色。
它是如何工作的...
与哈里斯点检测器的情况一样,FAST 特征算法源自对“角”的定义。这次,这个定义是基于假设特征点周围的图像强度。是否接受关键点的决定是通过检查以候选点为中心的像素圆来做出的。如果在圆周上找到一个连续点弧,其长度大于圆周长度的 3/4,并且其中所有像素与中心点的强度显著不同(都是较暗或较亮),则宣布存在一个关键点。
这是一个可以快速计算的简单测试。此外,在其原始公式中,该算法使用了一个额外的技巧来进一步加快处理速度。确实,如果我们首先测试圆周上相隔 90 度的四个点(例如,顶部、底部、右侧和左侧点),可以很容易地证明,为了满足之前表达的条件,至少有三个这些点必须与中心像素一样亮或一样暗。
如果不是这种情况,则可以立即拒绝该点,而无需检查圆周上的其他点。这是一个非常有效的测试,因为在实践中,大多数图像点都会被这个简单的 4 比较测试所拒绝。
在原则上,检查像素圆的半径可以是该方法的一个参数。然而,在实践中发现,半径为3既可以得到良好的结果,又具有高效率。因此,圆周上需要考虑的像素有16个,如下所示:

预测试中使用的四个点是1、5、9和13像素,并且需要连续的较暗或较亮的点的数量是12。然而,观察发现,通过将连续段长度减少到9,可以获得更好的检测角在图像间的重复性。这个变体通常被称为FAST-9角检测器,这正是 OpenCV 所使用的。请注意,存在一个cv::FASTX函数,它提出了 FAST 检测器的另一种变体。
要被认为是明显更暗或更亮,一个点的强度必须至少比中心像素的强度高出一个给定的量;这个值对应于函数调用中指定的阈值参数。这个阈值越大,检测到的角点就越少。
至于 Harris 特征,通常对已找到的角点执行非极大值抑制会更好。因此,需要定义一个角点强度度量。可以考虑几种替代度量,而保留的是以下度量。角点的强度由中心像素与识别出的连续弧上的像素之间的绝对差之和给出。请注意,该算法也可以通过直接函数调用获得:
cv::FAST(image, // input image
keypoints, // output vector of keypoints
40, // threshold
false); // non-max suppression? (or not)
然而,由于其灵活性,建议使用cv::FeatureDetector接口。
此算法导致非常快的兴趣点检测,因此在速度是关注点时,这是首选的特征。例如,在实时视觉跟踪或对象识别应用中,必须跟踪或匹配实时视频流中的多个点时,情况就是这样。
还有更多...
为了提高特征点的检测,OpenCV 提供了额外的工具。确实,有多个类适配器可用,以便更好地控制关键点的提取方式。
适应性特征检测
如果你希望更好地控制检测到的点的数量,有一个特殊的cv::FeatureDetector类的子类,称为cv::DynamicAdaptedFeatureDetector,可供使用。这允许你指定可以检测到的兴趣点数量的区间。在 FAST 特征检测器的情况下,使用方法如下:
cv::DynamicAdaptedFeatureDetector fastD(
new cv::FastAdjuster(40), // the feature detector
150, // min number of features
200, // max number of features
50); // max number of iterations
fastD.detect(image,keypoints); // detect points
然后将迭代检测兴趣点。每次迭代后,都会检查检测到的点的数量,并根据需要调整检测器的阈值以产生更多或更少的点;这个过程会重复,直到检测到的点的数量符合指定的区间。指定一个最大迭代次数,以避免该方法在多次检测上花费太多时间。为了以通用方式实现此方法,所使用的cv::FeatureDetector类必须实现cv::AdjusterAdapter接口。这个类包括一个tooFew方法和一个tooMany方法,这两个方法都修改检测器的内部阈值以产生更多或更少的关键点。还有一个good谓词方法,如果检测器的阈值还可以调整,则返回true。使用cv::DynamicAdaptedFeatureDetector类可以是一个获得适当数量特征点的良好策略;然而,你必须理解,为了这个好处,你必须付出性能代价。此外,没有保证你确实能在指定的迭代次数内获得所需数量的特征。
你可能已经注意到,我们传递了一个参数,即动态分配对象的地址,以指定适配器类将使用的特征检测器。你可能想知道是否需要在某个时候释放分配的内存以避免内存泄漏。答案是无需释放,这是因为指针被转移到cv::Ptr<FeatureDetector>参数,该参数会自动释放指向的对象。
网格适应特征检测
第二个有用的类适配器是cv::GridAdaptedFeatureDetector类。正如其名所示,它允许你在图像上定义一个网格。然后,这个网格的每个单元格都被限制只能包含最大数量的元素。这里的想法是将检测到的关键点集在图像上以更好的方式分布。在检测图像中的关键点时,确实常见到在特定纹理区域有大量兴趣点的集中。例如,在教堂图像的两个塔上,检测到了一个非常密集的 FAST 点集。这个类适配器可以这样使用:
cv::GridAdaptedFeatureDetector fastG(
new cv::FastFeatureDetector(10), // the feature detector
1200, // max total number of keypoints
5, // number of rows in grid
2); // number of cols in grid
fastG.detect(image,keypoints);
类适配器简单地通过使用提供的cv::FeatureDetector对象在每个单独的单元格上检测特征点来继续进行。还指定了最大点数。为了不超过指定的最大值,只保留每个单元格中最强的点。
金字塔适应特征检测
cv::PyramidAdaptedFeatureDetector适配器通过在图像金字塔上应用特征检测器来继续进行。结果被组合在关键点的输出向量中。这可以通过以下方式实现:
cv::PyramidAdaptedFeatureDetector fastP(
new cv::FastFeatureDetector(60), // the feature detector
3); // number of levels in the pyramid
fastP.detect(image,keypoints);
每个点的坐标都指定在原始图像坐标中。此外,cv::Keypoint类的特殊size属性被设置为,在原始分辨率的一半处检测到的点被赋予的尺寸是原始图像中检测到的点尺寸的两倍。cv::drawKeypoints函数中有一个特殊标志,它将以与关键点size属性相等的半径绘制关键点。

参考阅读
- E. Rosten 和 T. Drummond的论文《高速角点检测中的机器学习》,载于欧洲计算机视觉会议,第 430-443 页,2006,详细描述了 FAST 特征算法及其变体。
检测尺度不变特征
特征检测的视角不变性在本章的引言中被提出作为一个重要概念。虽然方向不变性,即即使图像旋转也能检测到相同点的能力,已经被迄今为止提出的简单特征点检测器相对较好地处理,但尺度变化的不变性更难实现。为了解决这个问题,计算机视觉中引入了尺度不变特征的概念。这里的想法是,无论物体在何种尺度下被拍摄,都要保持关键点的检测一致性,并且每个检测到的特征点都要关联一个尺度因子。理想情况下,对于在两张不同图像上以不同尺度特征化的同一物体点,两个计算尺度因子的比率应该对应它们各自尺度的比率。近年来,已经提出了几种尺度不变特征,本菜谱介绍了其中之一,即SURF特征。SURF 代表加速鲁棒特征,正如我们将看到的,它们不仅是尺度不变特征,而且计算效率也非常高。
如何做...
SURF 特征检测器在 OpenCV 中通过cv::SURF函数实现。也可以通过cv::FeatureDetector使用它,如下所示:
// Construct the SURF feature detector object
cv::Ptr<cv::FeatureDetector> detector = new cv::SURF(2000.); // threshold
// Detect the SURF features
detector->detect(image,keypoints);
要绘制这些特征,我们再次使用带有DRAW_RICH_KEYPOINTS标志的cv::drawKeypoints OpenCV 函数,这样我们就可以可视化相关的尺度因子:
// Draw the keypoints with scale and orientation information
cv::drawKeypoints(image, // original image
keypoints, // vector of keypoints
featureImage, // the resulting image
cv::Scalar(255,255,255), // color of the points
cv::DrawMatchesFlags::DRAW_RICH_KEYPOINTS); //flag
检测到特征的结果图像如下:

如前一个菜谱中所述,使用DRAW_RICH_KEYPOINTS标志得到的关键点圆圈的大小与每个特征的计算尺度成正比。SURF 算法还与每个特征关联一个方向,以使它们对旋转不变。这个方向通过每个绘制圆圈内的辐射线表示。
如果我们以不同的尺度拍摄同一物体的另一张照片,特征检测的结果如下:

通过仔细观察两张图像上检测到的关键点,可以看出对应圆圈大小的变化通常与尺度变化成正比。例如,考虑教堂右上角窗户的底部部分。在两张图像中,都检测到了该位置的 SURF 特征,并且两个对应的不同大小的圆圈包含相同的视觉元素。当然,并非所有特征都如此,但正如我们在下一章将要发现的,重复率足够高,可以保证两张图像之间良好的匹配。
它是如何工作的...
在第六章,“图像滤波”中,我们了解到可以使用高斯滤波器估计图像的导数。这些滤波器使用一个σ参数,该参数定义了核的孔径(大小)。正如我们所见,这个σ参数对应于构建滤波器使用的高斯函数的方差,并且它隐式地定义了导数评估的尺度。确实,具有较大σ值的滤波器会平滑掉图像的更细的细节。这就是为什么我们可以说它在一个更粗的尺度上操作。
现在,如果我们使用高斯滤波器在不同尺度上计算给定图像点的拉普拉斯算子,那么会得到不同的值。观察不同尺度因子下滤波器响应的变化,我们得到一条曲线,最终在σ值处达到最大值。如果我们从两个不同尺度拍摄的同物图像中提取这个最大值,这两个σ最大值之比将对应于拍摄图像的尺度比。这个重要的观察结果是尺度不变特征提取过程的核心。也就是说,尺度不变特征应该在空间空间(在图像中)和尺度空间(从不同尺度应用导数滤波器获得)中的局部最大值中被检测到。
SURF 通过以下步骤实现这一想法。首先,为了检测特征,计算每个像素处的 Hessian 矩阵。这个矩阵衡量函数的局部曲率,定义为以下:

这个矩阵的行列式给出了这个曲率的强度。因此,定义角点为具有高局部曲率(即,在多个方向上变化很大)的图像点。由于它由二阶导数组成,这个矩阵可以使用不同尺度的高斯拉普拉斯核来计算,例如σ。因此,Hessian 成为一个关于三个变量的函数,即H(x,y,σ)。因此,当这个 Hessian 的行列式在空间和尺度空间中都达到局部最大值时(即,需要进行3x3x3非最大值抑制),就宣布存在一个尺度不变特征。请注意,为了被视为一个有效点,这个行列式必须具有由cv::SURF类的构造函数的第一个参数指定的最小值。
然而,在不同尺度上计算所有这些导数在计算上代价高昂。SURF 算法的目标是尽可能使这个过程高效。这是通过使用仅涉及少量整数加法的近似高斯核来实现的。这些核具有以下结构:

左侧的核心用于估计混合的二阶导数,而右侧的核心用于估计垂直方向上的二阶导数。这个二阶核心的旋转版本用于估计水平方向上的二阶导数。最小的核心大小为9x9像素,对应于σ≈1.2。为了获得尺度空间表示,依次应用不同大小的核心。可以由 SURF 类的附加参数指定应用的确切滤波器数量。默认情况下,使用 12 种不同大小的核心(最大到99x99)。请注意,使用积分图像保证了每个滤波器每个波峰内部的和可以通过仅使用三个与滤波器大小无关的加法来计算。
一旦确定了局部极大值,通过在尺度和图像空间中进行插值,就可以获得每个检测到的兴趣点的精确位置。结果是具有亚像素精度的特征点集,并且与一个尺度值相关联。
更多...
SURF 算法被开发为一个效率更高的变体,称为另一个著名的尺度不变特征检测器SIFT(尺度不变特征变换)。
SIFT 特征检测算法
SIFT 同样在图像和尺度空间中检测特征作为局部极大值,但使用拉普拉斯滤波器响应而不是海森矩阵。这个拉普拉斯在不同的尺度(即σ的增大值)上使用高斯滤波器的差值来计算,如第六章中所述,过滤图像。为了提高效率,每次σ的值加倍时,图像的大小就减少两倍。每个金字塔层对应一个八度,每个尺度是一个层。通常每个八度有三个层。
下图展示了两个八度的金字塔,其中第一个八度的四个高斯滤波图像产生了三个 DoG 层:

OpenCV 有一个类可以检测这些特征,其调用方式与 SURF 类似:
// Construct the SIFT feature detector object
detector = new cv::SIFT();
// Detect the SIFT features
detector->detect(image,keypoints);
在这里,我们使用所有默认参数来构建检测器,但你也可以指定所需的 SIFT 点数(保留最强的点),每个八度的层数,以及σ的初始值。结果是类似于使用 SURF 获得的结果:

然而,由于特征点的计算基于浮点核心,SIFT 在空间和尺度上的特征定位方面通常被认为更准确。同样地,它也更耗费计算资源,尽管这种相对效率取决于每个特定的实现。
最后一点,你可能已经注意到,SURF 和 SIFT 类被放置在 OpenCV 分布的非自由软件包中。这是因为这些算法已经获得专利,因此,它们在商业应用中的使用可能受到许可协议的约束。
参见
-
第六章中的计算图像拉普拉斯算子配方,过滤图像,为你提供了更多关于高斯拉普拉斯算子和高斯差分使用的细节
-
第九章中的描述局部强度模式配方,描述和匹配兴趣点,解释了如何描述这些尺度不变特征以实现鲁棒的图像匹配
-
H. Bay, A. Ess, T. Tuytelaars 和 L. Van Gool 在计算机视觉与图像理解,第 110 卷,第 3 期,第 346-359 页,2008 年上发表的SURF:加速鲁棒特征文章描述了 SURF 特征算法
-
D. Lowe 在国际计算机视觉杂志,第 60 卷,第 2 期,2004 年,第 91-110 页上发表的从尺度不变特征中提取独特图像特征的开创性工作描述了 SIFT 算法
在多个尺度上检测 FAST 特征
FAST 被引入作为一种快速检测图像中关键点的方法。与 SURF 和 SIFT 相比,重点是设计尺度不变特征。最近,新引入了兴趣点检测器,旨在实现快速检测和尺度变化的不变性。本配方介绍了二值鲁棒不变可缩放关键点(BRISK)检测器。它基于我们在本章前面的配方中描述的 FAST 特征检测器。另一种称为ORB(方向性 FAST 和旋转 BRIEF)的检测器也将在本配方的末尾讨论。这两个特征点检测器构成了在需要快速且可靠的图像匹配时的优秀解决方案。当它们与相关的二进制描述符一起使用时,效率尤其高,这将在第九章,描述和匹配兴趣点中讨论。
如何做...
按照我们在前面的配方中所做的,使用 BRISK 检测关键点的过程使用了cv::FeatureDetector抽象类。我们首先创建检测器的实例,然后在图像上调用detect方法:
// Construct the BRISK feature detector object
detector = new cv::BRISK();
// Detect the BRISK features
detector->detect(image,keypoints);
图像结果显示了在多个尺度上检测到的关键点:

它是如何工作的...
BRISK 不仅是一个特征点检测器;该方法还包括描述每个检测到的关键点邻域的程序。这一第二个方面将是下一章的主题。在这里,我们描述了如何使用 BRISK 在多个尺度上快速检测关键点。
为了在不同尺度上检测兴趣点,该方法首先通过两个下采样过程构建一个图像金字塔。第一个过程从原始图像大小开始,并在每一层(或八分音符)上将它减半。其次,通过将原始图像以 1.5 的因子下采样,在中间层之间创建层,然后通过连续的半采样从这个减少的图像生成额外的层。

然后将 FAST 特征检测器应用于这个金字塔的所有图像。关键点提取基于与 SIFT 相似的标准。首先,一个可接受的兴趣点在将其强度与其八个空间邻居之一比较时必须是局部最大值。如果是这样,该点随后将与上层和下层相邻点的分数进行比较;如果其分数在尺度上更高,则它被接受为兴趣点。BRISK 的一个关键方面在于金字塔的不同层具有不同的分辨率。该方法需要在尺度和空间上进行插值,以精确地定位每个关键点。这种插值基于 FAST 关键点分数。在空间上,插值在 3 x 3 邻域内进行。在尺度上,它通过沿尺度轴通过当前点及其上层和下层两个相邻局部关键点拟合一个一维抛物线来计算;这种尺度上的关键点定位在先前的图中进行了说明。因此,即使 FAST 关键点检测是在离散图像尺度上进行的,与每个关键点相关联的检测尺度也是连续值。
cv::BRISK 类提出了两个可选参数来控制关键点的检测。第一个参数是一个阈值值,它接受 FAST 关键点,第二个参数是将在图像金字塔中生成的八分音符的数量:
// Construct another BRISK feature detector object
detector = new cv::BRISK(
20, // threshold for FAST points to be accepted
5); // number of octaves
还有更多...
BRISK 并不是 OpenCV 中唯一被提出的多尺度快速检测器。ORB 特征检测器也能进行高效的关键点检测。
ORB 特征检测算法
ORB 代表 Oriented FAST and Rotated BRIEF。这个缩写的第一部分指的是关键点检测部分,而第二部分指的是 ORB 提出的描述符。在这里,我们主要关注检测方法;描述符将在下一章中介绍。
与 BRISK 类似,ORB 首先创建一个图像金字塔。这个金字塔由多个层组成,每一层是前一层通过一定比例因子(通常是8个比例和1.2的比例因子减小;这些是cv::ORB函数中的参数)下采样得到的。然后接受得分最高的N个关键点,其中关键点得分由本章第一道菜中定义的 Harris 角点度量来定义(该方法作者发现 Harris 得分是一个更可靠的度量)。
ORB 检测器的原始特点在于每个检测到的兴趣点都与一个方向相关联。正如我们将在下一章中看到的,这些信息将有助于对齐在不同图像中检测到的关键点的描述符。在第七章的《计算组件形状描述符》菜谱中,我们介绍了图像矩的概念,特别是我们展示了如何从组件的前三个矩计算其质心。ORB 建议我们使用围绕关键点的圆形邻域的质心的方向。由于,根据定义,FAST 关键点总是有一个偏心的质心,连接中心点和质心的线的角度总是定义良好的。
ORB 特征检测如下:
// Construct the ORB feature detector object
detector = new cv::ORB(200, // total number of keypoints
1.2, // scale factor between layers
8); // number of layers in pyramid
// Detect the ORB features
detector->detect(image,keypoints);
此调用产生以下结果:

如所见,由于关键点在每个金字塔层上独立检测,检测器倾向于在不同尺度上重复检测相同的特征点。
参见
-
第九章的《使用二进制特征描述关键点》菜谱解释了如何使用简单的二进制描述符进行这些特征的效率鲁棒匹配
-
文章《BRISK:基于二值鲁棒不变可伸缩关键点的算法》由 S. Leutenegger、M. Chli 和 R. Y. Siegwart 在 2011 年的《IEEE 国际计算机视觉会议,第 2448-2555 页》中描述了 BRISK 特征算法
-
文章《ORB:SIFT 或 SURF 的有效替代方案》由 E. Rublee、V. Rabaud、K. Konolige 和 G. Bradski 在 2011 年的《IEEE 国际计算机视觉会议,第 2564-2571 页》中描述了 ORB 特征算法
第九章:描述和匹配兴趣点
在本章中,我们将介绍以下食谱:
-
匹配局部模板
-
描述局部强度模式
-
使用二进制特征描述关键点
简介
在上一章中,我们学习了如何通过检测图像中的特殊点来进行局部图像分析。这些关键点是选择得足够独特,以至于如果一个关键点在物体的图像中被检测到,那么在描述同一物体的其他图像中,预期也会检测到相同的点。我们还描述了一些更复杂的兴趣点检测器,这些检测器可以为关键点分配一个代表性的尺度因子和/或一个方向。正如我们将在这份食谱中看到的,这些附加信息可以用来根据视点变化对场景表示进行归一化。
为了基于兴趣点进行图像分析,我们现在需要构建丰富的表示,以独特地描述这些关键点中的每一个。本章探讨了已经提出的从兴趣点中提取描述符的不同方法。这些描述符通常是描述关键点和其邻域的 1D 或 2D 的二进制、整数或浮点数向量。一个好的描述符应该足够独特,以唯一地表示图像中的每个关键点;它应该足够鲁棒,以便在可能的光照变化或视点变化的情况下,具有相似点的相似表示。理想情况下,它还应该紧凑,以方便处理操作。
使用关键点完成的最常见操作之一是图像匹配。执行此任务的目标可能是,例如,将同一场景的两个图像联系起来,或者在图像中检测目标对象的发生。在这里,我们将研究一些基本的匹配策略,这个主题将在下一章中进一步讨论。
匹配局部模板
特征点匹配是通过将一个图像中的点与另一个图像(或图像集中的点)中的点对应起来的操作。当图像点对应于现实世界中同一场景元素(或物体点)的图像时,它们应该匹配。
单个像素当然不足以判断两个关键点的相似性。这就是为什么在匹配过程中必须考虑每个关键点周围的图像块。如果两个块对应于同一场景元素,那么可以预期它们的像素将具有相似值。本食谱中提出的解决方案是直接逐像素比较像素块。这可能是特征点匹配的最简单方法,但正如我们将看到的,它并不是最可靠的。尽管如此,在几种情况下,它仍然可以给出良好的结果。
如何做到这一点...
通常,块被定义为以关键点位置为中心的奇数大小的正方形。然后可以通过比较块内相应的像素强度值来测量两个正方形块之间的相似度。简单的平方差之和(SSD)是一个流行的解决方案。特征匹配策略的工作方式如下。首先,在每个图像中检测关键点。这里,我们使用 FAST 检测器:
// Define keypoints vector
std::vector<cv::KeyPoint> keypoints1;
std::vector<cv::KeyPoint> keypoints2;
// Define feature detector
cv::FastFeatureDetector fastDet(80);
// Keypoint detection
fastDet.detect(image1,keypoints1);
fastDet.detect(image2,keypoints2);
然后,我们定义一个大小为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);
// reset 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_NORMED);
// 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标志指定的像素级平方差之和。如果我们比较图像I [1]中的点(x,y)与图像I [2]中的潜在匹配点(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-N+1xH-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 维向量,以对光照变化和小型透视变形不变的方式描述特征点。通常,描述符可以使用简单的距离度量进行比较,例如欧几里得距离。因此,它们构成了一个强大的工具,可以在特征匹配应用中使用。
如何做到这一点...
OpenCV 2 提出了一种计算关键点列表描述符的通用接口。它被称为cv::DescriptorExtractor,我们将以类似于我们在上一章中使用cv::FeatureDetector接口的方式使用它。事实上,大多数基于特征的方法都包括检测器和描述符组件;这就是为什么cv::SURF和cv::SIFT类实现了这两个接口。这意味着你只需要创建一个对象来检测和描述关键点。以下是如何匹配两张图像的步骤:
// Define feature detector
// Construct the SURF feature detector object
cv::Ptr<cv::FeatureDetector> detector = new cv::SURF(1500.);
// Keypoint detection
// Detect the SURF features
detector->detect(image1,keypoints1);
detector->detect(image2,keypoints2);
// SURF includes both the detector and descriptor extractor
cv::Ptr<cv::DescriptorExtractor> descriptor = detector;
// Extract the descriptor
cv::Mat descriptors1;
cv::Mat descriptors2;
descriptor->compute(image1,keypoints1,descriptors1);
descriptor->compute(image2,keypoints2,descriptors2);
对于 SIFT,你只需简单地创建一个cv::SIFT()对象即可。结果是包含与关键点向量中元素数量一样多的行的矩阵(即cv::Mat实例)。这些行中的每一行都是一个 N 维描述符向量。在 SURF 描述符的情况下,它有一个默认大小为64,而对于 SIFT,默认维度是128。这个向量表征了特征点周围的强度模式。两个特征点越相似,它们的描述符向量应该越接近。
这些描述符现在将被用来匹配我们的关键点。正如我们在前面的食谱中所做的那样,第一幅图中的每个特征描述符向量与第二幅图中的所有特征描述符进行比较。获得最佳分数的配对(即两个描述符向量之间距离最低的配对)被保留为该特征的最佳匹配。这个过程会重复应用于第一幅图中的所有特征。非常方便的是,这个过程在 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 阈值,我们为第一幅图像获得了90个关键点,为第二幅图像获得了80个。然后暴力方法将产生90个匹配。使用与前面食谱中相同的cv::drawMatches类会产生以下图像:

如所示,其中一些匹配正确地将左侧的点与其对应的右侧点联系起来。你可能会注意到一些错误;其中一些是由于观察到的建筑有一个对称的立面,这使得一些局部匹配变得模糊。对于 SIFT,在相同数量的关键点下,我们得到了以下匹配结果:

它是如何工作的...
良好的特征描述符必须对光照和视角的小变化以及图像噪声的存在保持不变。因此,它们通常基于局部强度差异。这是 SURF 描述符的情况,它们在关键点周围局部应用以下简单的核:

第一个核简单地测量水平方向上的局部强度差异(指定为dx),第二个核测量垂直方向上的这种差异(指定为dy)。用于提取描述符向量的邻域大小通常定义为特征缩放因子的 20 倍(即20σ)。然后,将这个正方形区域分割成4x4更小的正方形子区域。对于每个子区域,在5x5规则间隔的位置(核大小为2σ)计算核响应(dx和dy)。所有这些响应都按以下方式汇总,以便为每个子区域提取四个描述符值:

由于有4x4=16个子区域,我们总共有64个描述符值。注意,为了给邻近像素更多的权重,即更接近关键点的值,核响应通过一个以关键点位置为中心的高斯加权(σ=3.3)。
dx和dy响应也用于估计特征的方向。这些值在半径为6σ的圆形邻域内,以σ的间隔规则地计算(核大小为4σ)。对于给定的方向,在某个角度间隔(π/3)内的响应被汇总,给出最长向量的方向被定义为主导方向。
SIFT 是一种更丰富的描述符,它使用图像梯度而不是简单的强度差异。它还将每个关键点周围的正方形邻域分割成4x4子区域(也可以使用8x8或2x2子区域)。在每个这些区域内部,构建一个梯度方向的直方图。方向被离散化到 8 个箱子中,每个梯度方向条目通过一个与梯度幅度成比例的值增加。这可以通过以下图示来说明,其中每个星形箭头集代表一个梯度方向的局部直方图:

将每个子区域的 8 个箱子的16个直方图连接起来,然后产生一个128维度的描述符。注意,对于 SURF 来说,梯度值通过一个以关键点位置为中心的高斯滤波器加权,以使描述符对定义的邻域边缘梯度方向突然变化的敏感性降低。然后,将最终的描述符归一化,以使距离测量更加一致。
使用 SURF 和 SIFT 特征和描述符,可以实现尺度不变匹配。以下是一个示例,显示了不同尺度下两张图像的 SURF 匹配结果(这里显示了 50 个最佳匹配):

更多内容...
任何匹配算法产生的匹配结果总是包含大量的错误匹配。为了提高匹配集的质量,存在多种策略。这里讨论了其中两种。
匹配交叉检查
验证获得的匹配的一个简单方法是在第二次重复相同的程序,但这次,第二幅图像的每个关键点都与第一幅图像的所有关键点进行比较。只有当我们从两个方向获得相同的键点对时,才认为匹配是有效的(也就是说,每个关键点是另一个的最佳匹配)。cv::BFMatcher函数提供了使用此策略的选项。这确实是一个标志;当设置为true时,它强制函数执行互匹配交叉检查:
// Construction of the matcher with cross-check
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>> matches2;
matcher.knnMatch(descriptors1,descriptors2, matches2,
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= matches2.begin(); it!= matches2.end(); ++it) {
// first best match/second best match
if ((*it)[0].distance/(*it)[1].distance < ratio) {
// it is an acceptable match
matches.push_back((*it)[0]);
}
}
// matches is the new match set
初始匹配集由90对组成,现在已减少到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)。这次,初始匹配集的90对减少到37对,如下面的截图所示:

显然,你可以将这些策略结合起来以提高你的匹配结果。
参见
-
第八章 检测尺度不变特征 配方在 检测兴趣点 中介绍了相关的 SURF 和 SIFT 特征检测器,并提供了更多关于该主题的参考文献。
-
第十章 估计图像中的投影关系 中的 使用随机样本一致性匹配图像 配方解释了如何使用图像和场景几何来获得质量更高的匹配集。
-
E. Vincent 和 R. Laganière 在 Machine, Graphics and Vision, pp. 237-260, 2001 发表的 Matching feature points in stereo pairs: A comparative study of some matching strategies 文章,描述了其他一些可以用来提高匹配集质量的简单匹配策略。
使用二进制特征描述关键点
在之前的配方中,我们学习了如何使用从图像强度梯度中提取的丰富描述符来描述一个关键点。这些描述符是具有 64、128 或有时甚至更长的维度的浮点向量。这使得它们在操作上成本较高。为了减少与这些描述符相关的内存和计算负载,最近提出了使用二进制描述符的想法。这里的挑战是使它们易于计算,同时保持它们对场景和视点变化的鲁棒性。本配方描述了这些二进制描述符中的一些。特别是,我们将查看 ORB 和 BRISK 描述符,我们在 第八章 检测兴趣点 中介绍了它们相关的特征点检测器。
如何去做...
由于 OpenCV 检测器和描述符模块构建在良好的通用接口之上,使用二进制描述符(如 ORB)与使用描述符(如 SURF 和 SIFT)没有区别。基于特征点的完整图像匹配序列如下:
// Define keypoints vector
std::vector<cv::KeyPoint> keypoints1, keypoints2;
// Construct the ORB feature detector object
cv::Ptr<cv::FeatureDetector> detector =
new cv::ORB(100); // detect approx 100 ORB points
// Detect the ORB features
detector->detect(image1,keypoints1);
detector->detect(image2,keypoints2);
// ORB includes both the detector and descriptor extractor
cv::Ptr<cv::DescriptorExtractor> descriptor = detector;
// Extract the descriptor
cv::Mat descriptors1, descriptors2;
descriptor->compute(image1,keypoints1,descriptors1);
descriptor->compute(image2,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);
唯一的区别在于使用了 Hamming 范数(cv::NORM_HAMMING 标志),它通过计算两个二进制描述符中不同位的数量来衡量它们之间的距离。在许多处理器上,这个操作可以通过使用异或操作,然后进行简单的位计数来高效地实现。
以下截图显示了匹配的结果:

使用另一个流行的二进制特征检测器/描述符:BRISK,也会得到类似的结果。在这种情况下,cv::DescriptorExtractor 实例是通过新的 cv::BRISK(40) 调用创建的。正如我们在上一章所学,它的第一个参数是一个阈值,用于控制检测到的点的数量。
它是如何工作的...
ORB 算法在多个尺度上检测方向特征点。基于此结果,ORB 描述符通过使用简单的强度比较来提取每个关键点的表示。实际上,ORB 是基于之前提出的描述符 BRIEF 构建的。随后,它通过简单地选择关键点周围定义的邻域内的随机点对来创建二进制描述符。然后比较这两个像素点的强度值,如果第一个点具有更高的强度,则将值 1 分配给相应的描述符位值。否则,分配值 0。在多个随机点对上重复此测试会生成一个由多个位组成的描述符;通常,使用 128 到 512 位(成对测试)。
这是 ORB 使用的方案。然后,需要做出的决定是使用哪组点对来构建描述符。实际上,即使点对是随机选择的,一旦它们被选中,就必须执行相同的二进制测试集来构建所有关键点的描述符,以确保结果的一致性。为了使描述符更具独特性,直觉告诉我们,某些选择可能比其他选择更好。此外,由于每个关键点的方向是已知的,当将强度模式分布与此方向归一化时(即,当点坐标相对于此关键点方向给出时),这引入了一些偏差。从这些考虑和实验验证中,ORB 已经确定了一组具有高方差和最小成对相关性的 256 个点对。换句话说,所选的二进制测试是那些在各种关键点上具有相等机会为 0 或 1 的测试,并且尽可能相互独立。
除了控制特征检测过程的参数外,cv::ORB 构造函数还包括两个与其描述符相关的参数。一个参数用于指定点对选择的内部补丁大小(默认为 31x31)。第二个参数允许您使用三元组或四元组点而不是默认的点对进行测试。请注意,强烈建议您使用默认设置。
BRISK 的描述符也非常相似。它也是基于成对强度比较,但有两点不同。首先,不是从 31x31 邻域点中随机选择点,而是从一组同心圆(由 60 个点组成)的采样模式中选择点,这些圆的点是等间距的。其次,这些采样点的强度是一个与中心关键点距离成比例的高斯平滑值。从这些点中,BRISK 选择 512 个点对。
还有更多...
存在着几个其他的二进制描述符,感兴趣的读者应该查阅科学文献以了解更多关于这个主题的信息。由于它也包含在 OpenCV 中,我们在这里将描述一个额外的描述符。
FREAK
FREAK 代表快速视网膜关键点。这也是一个二进制描述符,但它没有关联的检测器。它可以应用于任何检测到的关键点集,例如 SIFT、SURF 或 ORB。
与 BRISK 一样,FREAK 描述符也是基于同心圆上定义的采样模式。然而,为了设计他们的描述符,作者使用了人类眼睛的类比。他们观察到在视网膜上,神经节细胞的密度随着距离黄斑的增加而减少。因此,他们构建了一个由43个点组成的采样模式,其中点的密度在中心点附近要大得多。为了获得其强度,每个点都通过一个大小也随中心距离增加的高斯核进行过滤。
为了确定应该执行的成对比较,通过遵循与 ORB 使用的类似策略进行了实验验证。通过分析数千个关键点,保留了具有最高方差和最低相关性的二进制测试,从而得到512对。
FREAK 还引入了在级联中进行描述符比较的想法。也就是说,首先执行代表较粗信息(对应于在较大高斯核边缘执行的测试)的前128位。只有当比较的描述符通过这个初始步骤后,才会执行剩余的测试。
使用 ORB 检测到的关键点,我们可以通过简单地创建以下cv::DescriptorExtractor实例来提取 FREAK 描述符:
cv::Ptr<cv::DescriptorExtractor> descriptor =
new cv::FREAK(); // to describe with FREAK
比赛结果如下:

以下图示了本配方中展示的三个描述符所使用的采样模式:

第一个正方形是 ORB 描述符,其中在正方形网格上随机选择点对。通过线的连接的点对代表比较两个像素强度的可能测试。在这里,我们只展示了 8 个这样的对;默认的 ORB 使用 256 对。中间的正方形对应于 BRISK 采样模式。点在显示的圆上均匀采样(为了清晰起见,我们在这里只标识第一圈上的点)。最后,第三个正方形显示了 FREAK 的对数极坐标采样网格。虽然 BRISK 的点分布均匀,但 FREAK 在中心附近的点密度更高。例如,在 BRISK 中,你会在外圈找到 20 个点,而在 FREAK 的情况下,其外圈只包括 6 个点。
参考信息
-
第八章中的 在多个尺度上检测 FAST 特征 菜单,检测兴趣点,介绍了相关的 BRISK 和 ORB 特征检测器,并提供了更多关于该主题的参考资料
-
E. M. Calonder、V. Lepetit、M. Ozuysal、T. Trzcinski、C. Strecha 和 P. Fua 在 IEEE Transactions on Pattern Analysis and Machine Intelligence, 2012 发表的 BRIEF: 计算局部二值描述符非常快速 文章,描述了启发所展示的二值描述符的 BRIEF 特征描述符
-
A.Alahi、R. Ortiz 和 P. Vandergheynst 在 IEEE Conference on Computer Vision and Pattern Recognition, 2012 发表的 FREAK: 快速视网膜关键点 文章,描述了 FREAK 特征描述符
第十章.在图像中估计投影关系
在本章中,我们将介绍以下内容:
-
校准相机
-
计算图像对的基本矩阵
-
使用随机样本一致性匹配图像
-
计算两个图像之间的单应性
简介
图像通常使用数字相机产生,该相机通过将穿过其镜头的光线投射到图像传感器上来捕获场景。一个图像是通过将 3D 场景投影到 2D 平面上形成的,这暗示了场景与其图像以及同一场景的不同图像之间存在重要的关系。投影几何是用于用数学术语描述和表征图像形成过程的工具。在本章中,我们将向您介绍多视图图像中存在的一些基本投影关系,并解释这些关系如何在计算机视觉编程中使用。您将学习如何通过使用投影约束来提高匹配的准确性,以及如何使用双视图关系将多图像拼贴组合在一起。在我们开始食谱之前,让我们探索与场景投影和图像形成相关的基本概念。
图像形成
基本上,自摄影术开始以来,用于产生图像的过程没有改变。来自观察场景的光线通过一个正面的孔径被相机捕获;捕获的光线击中位于相机后部的图像平面(或图像传感器)。此外,使用镜头来集中来自不同场景元素的光线。这个过程如下图所示:

在这里,do 是从镜头到观察对象的距离,di 是从镜头到图像平面的距离,f 是镜头的焦距。这些量通过所谓的薄透镜方程相关联:

在计算机视觉中,这个相机模型可以通过多种方式简化。首先,我们可以通过考虑我们有一个具有无穷小孔径的相机来忽略镜头的影响,因为从理论上讲,这不会改变图像的外观。(然而,这样做会通过创建具有无限景深的图像而忽略聚焦效果。)在这种情况下,因此,只考虑中心光线。其次,由于大多数时候我们有 do>>di,我们可以假设图像平面位于焦距处。最后,我们可以从系统的几何学中注意到,平面上的图像是倒置的。我们可以通过简单地将图像平面放置在镜头前方来获得一个相同但正立的图像。显然,这在物理上是不切实际的,但从数学角度来看,这是完全等价的。这个简化的模型通常被称为针孔相机模型,其表示如下:

从这个模型出发,并使用相似三角形的定律,我们可以轻松推导出将图像中的物体与其图像相关联的基本投影方程:

因此,一个物体(高度为ho)的图像大小(hi)与其与摄像机的距离(do)成反比,这是自然而然的事情。一般来说,这种关系描述了在给定摄像机几何形状的情况下,三维场景点将在图像平面上投影的位置。
标定摄像机
从本章的介绍中,我们了解到在针孔模型下,摄像机的关键参数是其焦距和图像平面的尺寸(这定义了摄像机的视场)。此外,由于我们处理的是数字图像,图像平面上像素的数量(其分辨率)也是摄像机的一个重要特征。最后,为了能够计算图像场景点的像素坐标,我们需要额外的一块信息。考虑到来自焦点的垂直于图像平面的直线,我们需要知道这条直线在哪个像素位置穿透图像平面。这个点被称为主点。从逻辑上讲,这个主点可能位于图像平面的中心,但在实际操作中,这个点可能因为摄像机制造的精度问题而偏离几个像素。
摄像机标定是一个获取不同摄像机参数的过程。显然,可以使用摄像机制造商提供的规格,但对于某些任务,例如三维重建,这些规格可能不够精确。摄像机标定将通过向摄像机展示已知图案并分析获得的图像来进行。然后,一个优化过程将确定解释观察结果的参数值。这是一个复杂的过程,但由于 OpenCV 标定函数的可用性,这个过程变得简单易行。
如何做到这一点...
要标定一个摄像机,想法是向它展示一组已知三维位置的场景点。然后,你需要观察这些点在图像上的投影位置。有了足够数量的三维点和相关的二维图像点的知识,可以从投影方程中推断出精确的摄像机参数。显然,为了获得准确的结果,我们需要观察尽可能多的点。实现这一目标的一种方法是对一个包含许多已知三维点的场景拍摄一张照片,但在实际操作中,这很少可行。更方便的方法是从不同的视角拍摄一组三维点的多张照片。这种方法更简单,但除了计算内部摄像机参数外,还需要计算每个摄像机视图的位置,幸运的是这是可行的。
OpenCV 建议你使用棋盘格图案来生成校准所需的 3D 场景点集。这个图案在每个方格的角上创建点,由于这个图案是平的,我们可以自由地假设棋盘位于Z=0,X和Y轴与网格对齐。在这种情况下,校准过程简单地包括从不同的视角向相机展示棋盘格图案。以下是一个6x4校准图案图像的示例:

好消息是 OpenCV 有一个函数可以自动检测这个棋盘格图案的角点。你只需提供一个图像和棋盘格的大小(水平和垂直内角点的数量)。该函数将返回图像上这些棋盘格角点的位置。如果函数无法找到图案,则简单地返回false:
// output vectors of image points
std::vector<cv::Point2f> imageCorners;
// number of inner corners on the chessboard
cv::Size boardSize(6,4);
// Get the chessboard corners
bool found = cv::findChessboardCorners(image,
boardSize, imageCorners);
输出参数imageCorners将简单地包含检测到的图案内角点的像素坐标。请注意,如果需要调整算法,此函数接受额外的参数,但这里没有讨论。还有一个特殊函数可以在棋盘格图像上绘制检测到的角点,并用线条按顺序连接它们:
//Draw the corners
cv::drawChessboardCorners(image,
boardSize, imageCorners,
found); // corners have been found
下面的图像是得到的:

连接点的线条显示了点在检测到的图像点向量中的列表顺序。为了进行校准,我们现在需要指定相应的 3D 点。你可以选择你喜欢的单位(例如,厘米或英寸)来指定这些点;然而,最简单的是假设每个方格代表一个单位。在这种情况下,第一个点的坐标将是(0,0,0)(假设棋盘位于深度Z=0处),第二个点的坐标将是(1,0,0),以此类推,最后一个点位于(5,3,0)。这个图案总共有24个点,这太少了,无法获得准确的校准。要获得更多点,你需要从不同的视角展示相同校准图案的更多图像。为此,你可以移动图案到相机前,或者移动相机绕棋盘移动;从数学的角度来看,这是完全等价的。OpenCV 校准函数假设参考框架固定在校准图案上,并将计算相机相对于参考框架的旋转和平移。
现在我们将校准过程封装在CameraCalibrator类中。该类的属性如下:
class CameraCalibrator {
// input points:
// the points in world coordinates
std::vector<std::vector<cv::Point3f>> objectPoints;
// the point positions in pixels
std::vector<std::vector<cv::Point2f>> imagePoints;
// output Matrices
cv::Mat cameraMatrix;
cv::Mat distCoeffs;
// flag to specify how calibration is done
int flag;
注意,场景和图像点的输入向量实际上是由点实例的std::vector组成的;每个向量元素是从一个视角的点向量。在这里,我们决定通过指定棋盘图像文件名的向量作为输入来添加校准点:
// Open chessboard images and extract corner points
int CameraCalibrator::addChessboardPoints(
const std::vector<std::string>& filelist,
cv::Size & boardSize) {
// the points on the chessboard
std::vector<cv::Point2f> imageCorners;
std::vector<cv::Point3f> objectCorners;
// 3D Scene Points:
// Initialize the chessboard corners
// in the chessboard reference frame
// The corners are at 3D location (X,Y,Z)= (i,j,0)
for (int i=0; i<boardSize.height; i++) {
for (int j=0; j<boardSize.width; j++) {
objectCorners.push_back(cv::Point3f(i, j, 0.0f));
}
}
// 2D Image points:
cv::Mat image; // to contain chessboard image
int successes = 0;
// for all viewpoints
for (int i=0; i<filelist.size(); i++) {
// Open the image
image = cv::imread(filelist[i],0);
// Get the chessboard corners
bool found = cv::findChessboardCorners(
image, boardSize, imageCorners);
// Get subpixel accuracy on the corners
cv::cornerSubPix(image, imageCorners,
cv::Size(5,5),
cv::Size(-1,-1),
cv::TermCriteria(cv::TermCriteria::MAX_ITER +
cv::TermCriteria::EPS,
30, // max number of iterations
0.1)); // min accuracy
//If we have a good board, add it to our data
if (imageCorners.size() == boardSize.area()) {
// Add image and scene points from one view
addPoints(imageCorners, objectCorners);
successes++;
}
}
return successes;
}
第一个循环输入棋盘的 3D 坐标,相应的图像点是cv::findChessboardCorners函数提供的点。这适用于所有可用的视角。此外,为了获得更精确的图像点位置,可以使用cv::cornerSubPix函数,正如其名称所暗示的,图像点将被定位在亚像素精度。由cv::TermCriteria对象指定的终止准则定义了最大迭代次数和亚像素坐标中的最小精度。这两个条件中先达到的一个将停止角细化过程。
当一组棋盘角成功检测到后,这些点将使用我们的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
}
在实际操作中,10 到 20 张棋盘图像就足够了,但这些图像必须从不同视角和不同深度拍摄。这个函数的两个重要输出是相机矩阵和畸变参数。这些将在下一节中描述。
它是如何工作的...
为了解释校准结果,我们需要回到引言中的图,该图描述了针孔相机模型。更具体地说,我们想展示 3D 空间中位置为(X,Y,Z)的点与其在指定像素坐标的相机上的图像(x,y)之间的关系。让我们通过添加一个参考框架来重新绘制这个图,我们将这个框架放置在投影中心的此处:

注意,y轴向下指向,以获得与通常将图像原点放置在左上角的惯例兼容的坐标系。我们之前了解到,点(X,Y,Z)将被投影到图像平面上,位置为(fX/Z,fY/Z)。现在,如果我们想将这个坐标转换为像素,我们需要分别除以像素的宽度(px)和高度(py)。注意,通过将以世界单位(通常以毫米为单位)给出的焦距除以px,我们得到以(水平)像素表示的焦距。然后,我们定义这个术语为fx。同样,fy =f/py被定义为以垂直像素单位表示的焦距。因此,完整的投影方程如下:


回想一下,(u[0],v[0])是添加到结果中以将原点移动到图像右上角的主点。这些方程可以通过引入齐次坐标来重写为矩阵形式,其中二维点由 3-向量表示,三维点由 4-向量表示(额外的坐标只是一个任意比例因子S,当需要从齐次 3-向量中提取二维坐标时需要移除)。
这里是重写的投影方程:

第二个矩阵是一个简单的投影矩阵。第一个矩阵包括所有摄像机参数,这些参数被称为摄像机的内在参数。这个3x3矩阵是cv::calibrateCamera函数返回的输出矩阵之一。还有一个名为cv::calibrationMatrixValues的函数,它返回由校准矩阵给出的内在参数值。
更一般地,当参考系不在摄像机的投影中心时,我们需要添加一个旋转向量(一个3x3矩阵)和一个平移向量(一个3x1矩阵)。这两个矩阵描述了必须应用于 3D 点以将它们带回到摄像机参考系的刚性变换。因此,我们可以将投影方程重写为其最一般的形式:

记住,在我们的校准示例中,参考系被放置在棋盘上。因此,对于每个视图,必须计算一个刚性变换(由矩阵项r1到r9表示的旋转部分和由t1、t2和t3表示的平移)。这些在cv::calibrateCamera函数的输出参数列表中。旋转和平移部分通常被称为校准的外参数,它们对于每个视图是不同的。对于给定的摄像机/镜头系统,内在参数保持不变。我们从基于 20 张棋盘图像的校准中获得的测试摄像机的内在参数是fx=167、fy=178、u0=156和v0=119。这些结果是通过cv::calibrateCamera通过一个优化过程获得的,该过程旨在找到最小化从 3D 场景点的投影计算出的预测图像点位置与在图像上观察到的实际图像点位置之间的差异的内在和外参数。校准期间指定的所有点的这个差异之和被称为重投影误差。
现在,让我们将注意力转向畸变参数。到目前为止,我们提到,在针孔相机模型下,我们可以忽略镜头的影响。然而,这只在用于捕获图像的镜头不引入重要的光学畸变时才可能。不幸的是,对于低质量镜头或焦距非常短的镜头来说,情况并非如此。您可能已经注意到,我们在示例中使用的图像中显示的棋盘图案明显畸变——矩形板的边缘在图像中是弯曲的。此外,请注意,这种畸变随着我们远离图像中心而变得更加重要。这是鱼眼镜头观察到的典型畸变,被称为径向畸变。常用数字相机中使用的镜头通常不会表现出如此高的畸变程度,但在这里使用的镜头,这些畸变肯定不能被忽略。
通过引入适当的畸变模型,可以补偿这些变形。想法是通过一组数学方程来表示由镜头引起的畸变。一旦建立,这些方程就可以被逆转,以消除图像上可见的畸变。幸运的是,在标定阶段,可以同时获得将纠正畸变的变换的确切参数以及其他相机参数。一旦这样做,任何来自新标定相机的图像都将被去畸变。因此,我们在我们的标定类中添加了一个额外的方法:
// 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)指定一个固定值,这通常可以假设是中心像素。您还可以为焦距fx和fy指定一个固定比率(CV_CALIB_FIX_RATIO);在这种情况下,您假设像素是正方形形状的。
使用圆形网格进行校准
与常用的棋盘格图案不同,OpenCV 还提供了使用圆形网格校准相机的可能性。在这种情况下,圆的中心被用作校准点。相应的函数与我们用来定位棋盘格角落的函数非常相似:
cv::Size boardSize(7,7);
std::vector<cv::Point2f> centers;
bool found = cv:: findCirclesGrid(
image, boardSize, centers);
参见
-
本章中的计算两张图像之间的单应性配方将检查特殊情况下投影方程
-
Z. Zhang 在IEEE Transactions on Pattern Analysis and Machine Intelligence, vol. 22, no 11, 2000上发表的一种灵活的相机校准新方法文章是关于相机校准问题的一篇经典论文
计算图像对的基本矩阵
之前的配方向您展示了如何恢复单相机的投影方程。在本配方中,我们将探讨显示相同场景的两个图像之间存在的投影关系。这两张图像可能是通过在两个不同位置移动相机从两个视点拍照或使用两个相机,每个相机拍摄场景的不同图片而获得的。当这两个相机通过一个刚体基线分离时,我们使用术语立体视觉。
准备工作
现在让我们考虑两个相机观察一个给定的场景点,如图所示:

我们了解到,我们可以通过追踪连接该三维点与相机中心的线来找到三维点X的图像x。相反,场景点在图像平面上其图像位于x位置,可以在三维空间中的这条线上任何位置。这意味着如果我们想在另一张图像中找到给定图像点的对应点,我们需要沿着这条线在第二张图像平面上的投影进行搜索。这条想象中的线被称为点x的极线。它定义了一个必须满足两个对应点的基本约束;也就是说,给定点的匹配必须位于另一个视图中该点的极线上,而这个极线的确切方向取决于两个相机的相对位置。实际上,极线的配置表征了双视系统的几何形状。
从这个双视图系统的几何学中可以得出的另一个观察结果是,所有极线都通过同一点。这个点对应于一个相机的中心在另一个相机上的投影。这个特殊点被称为极点。
从数学上讲,图像点与其对应的极线之间的关系可以用一个 3x3 矩阵表示如下:

在射影几何中,二维直线也由一个三维向量表示。它对应于满足方程 l [1] 'x'+ l [2] 'y'+ l [3] '=0 的二维点集,(x',y')(上标撇表示这条线属于第二图像)。因此,矩阵 F,称为基本矩阵,将一个视图中的二维图像点映射到另一个视图中的极线。
如何做到这一点...
通过解决涉及两个图像之间一定数量的已知匹配点的方程组,可以估计图像对的基本矩阵。这种匹配的最小数量是七个。为了说明基本矩阵估计过程并使用前一章中的图像对,我们可以手动选择七个良好的匹配点。这些点将用于使用 cv::findFundamentalMat OpenCV 函数计算基本矩阵,如下面的截图所示:

如果每个图像中的图像点作为 cv::keypoint 实例(例如,如果它们像在第八章, 检测兴趣点)中使用关键点检测器检测到的那样),它们首先需要转换为 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);
两个向量 selPoints1 和 selPoints2 包含两个图像中相应的点。关键点实例是 keypoints1 和 keypoints2。pointIndexes1 和 pointIndexes2 向量包含要转换的关键点的索引。调用 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));
}
结果可以在下面的截图中看到:

记住,极点位于所有极线的交点处,它是另一台相机的中心的投影。这个极点在前面的图像中是可见的。通常,极线会在图像边界之外相交。在我们的例子中,如果两个图像在同一瞬间拍摄,它将位于第一个相机可见的位置。请注意,当从七个匹配点计算基本矩阵时,结果可能会非常不稳定。确实,用一个匹配点替换另一个匹配点可能会导致极线集合发生显著变化。
它是如何工作的...
我们之前解释过,对于一张图像中的一个点,基本矩阵给出了另一个视图中其对应点所在直线的方程。如果点p(以齐次坐标表示)的对应点是p',并且如果F是两个视图之间的基本矩阵,那么由于p'位于极线Fp上,我们就有以下方程:

这个方程表达了两个对应点之间的关系,被称为极线约束。使用这个方程,就可以通过已知的匹配点来估计矩阵的元素。由于F矩阵的元素只给出一个比例因子,因此只有八个元素需要估计(第九个可以任意设置为1)。每个匹配点对应一个方程。因此,如果有八个已知的匹配点,就可以通过解这组线性方程来完全估计矩阵。这就是当你使用cv::findFundamentalMat函数的CV_FM_8POINT标志时所执行的操作。请注意,在这种情况下,输入超过八个匹配点是可能的(并且是首选的)。然后可以在均方意义上求解得到的超定线性方程组。
为了估计基本矩阵,还可以利用一个额外的约束。从数学上讲,F矩阵将一个二维点映射到一个一维的直线束(即相交于一个共同点的直线)。所有这些极线都通过这个独特的点(即极点)这一事实对矩阵施加了一个约束。这个约束将估计基本矩阵所需的匹配点数量减少到七个。不幸的是,在这种情况下,方程组变得非线性,最多有三个可能的解(在这种情况下,cv::findFundamentalMat将返回一个大小为9x3的基本矩阵,即三个3x3矩阵堆叠)。可以通过使用CV_FM_7POINT标志在 OpenCV 中调用F矩阵估计的七个匹配点解。这就是我们在前一个章节的例子中所做的。
最后,我们想提到,在图像中选择合适的匹配集对于获得基本矩阵的准确估计非常重要。一般来说,匹配应该在整个图像中分布良好,并包括场景中不同深度的点。否则,解将变得不稳定或退化配置。特别是,所选场景点不应共面,因为在这种情况下基本矩阵会退化。
参见
-
《计算机视觉中的多视图几何学》,剑桥大学出版社,2004 年,R. Hartley 和 A. 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> extractor;
int normType;
float ratio; // max ratio between 1st and 2nd NN
bool refineF; // if true will refine the F matrix
double distance; // min distance to epipolar
double confidence; // confidence level (probability)
public:
RobustMatcher(std::string detectorName, // specify by name
std::string descriptorName)
: normType(cv::NORM_L2), ratio(0.8f),
refineF(true), confidence(0.98), distance(3.0) {
// construct by name
if (detectorName.length()>0) {
detector= cv::FeatureDetector::create(detectorName);
extractor= cv::DescriptorExtractor::
create(descriptorName);
}
}
注意我们如何使用cv::FeatureDetector和cv::DescriptorExtractor接口的create方法,以便用户可以通过它们的名称选择create方法。注意,create方法也可以使用定义的setFeatureDetector和setDescriptorExtractor设置方法来指定。
主要方法是我们的match方法,它返回匹配项、检测到的关键点和估计的基本矩阵。该方法通过以下四个不同的步骤进行(在以下代码的注释中明确标识):
// 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;
extractor->compute(image1,keypoints1,descriptors1);
extractor->compute(image2,keypoints2,descriptors2);
// 3\. Match the two image descriptors
// (optionnally 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 fundaental= ransacTest(outputMatches,
keypoints1, keypoints2, matches);
// return the found fundamental matrix
return fundamental;
}
前两步简单地检测特征点并计算它们的描述符。接下来,我们使用cv::BFMatcher类进行特征匹配,就像我们在上一章中做的那样。我们使用交叉检查标志来获得更好的匹配质量。
第四步是本菜谱中引入的新概念。它包括一个额外的过滤测试,这次将使用基矩阵来拒绝不遵守极线约束的匹配。这个测试基于RANSAC方法,即使匹配集中仍然存在异常值,该方法也能计算基矩阵(该方法将在下一节中解释):
// Identify good matches using RANSAC
// Return fundamental matrix and output matches
cv::Mat ransacTest(const std::vector<cv::DMatch>& matches,
const std::vector<cv::KeyPoint>& keypoints1,
const std::vector<cv::KeyPoint>& keypoints2,
std::vector<cv::DMatch>& outMatches) {
// Convert keypoints into Point2f
std::vector<cv::Point2f> points1, points2;
for (std::vector<cv::DMatch>::const_iterator it=
matches.begin(); it!= matches.end(); ++it) {
// Get the position of left keypoints
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。使用这个类,通过以下调用就可以轻松实现图像对的鲁棒匹配:
// Prepare the matcher (with default parameters)
RobustMatcher rmatcher("SURF"); // we use SURF features here
// 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);
这导致了62个匹配,如下面的截图所示:

有趣的是,几乎所有这些匹配都是正确的,即使还有一些错误的匹配存在;这些错误匹配偶然落在了计算出的基矩阵的对应极线上。
它是如何工作的...
在前面的菜谱中,我们了解到可以从多个特征点匹配中估计与图像对相关的基矩阵。显然,为了精确,这个匹配集必须只包含好的匹配。然而,在实际情况下,无法保证通过比较检测到的特征点的描述符获得的匹配集是完全精确的。这就是为什么引入了一种基于RANSAC(随机采样一致性)策略的基矩阵估计方法。
RANSAC 算法旨在从一个可能包含多个异常值的数据集中估计给定的数学实体。想法是从集合中随机选择一些数据点,并仅使用这些点进行估计。所选点的数量应该是估计数学实体所需的最小点数。在基矩阵的情况下,八个匹配对是最小数量(实际上,可能是七个匹配,但 8 点线性算法计算更快)。一旦从这八个随机匹配中估计出基矩阵,就将匹配集中的所有其他匹配与由此矩阵导出的极线约束进行测试。所有满足此约束的匹配(即对应特征与其极线距离较短的匹配)都被识别出来。这些匹配形成了计算出的基矩阵的支持集。
RANSAC 算法背后的核心思想是支持集越大,计算出的矩阵就越可能是正确的。相反,如果随机选择的匹配中有一个(或多个)是错误的匹配,那么计算出的基础矩阵也将是错误的,其支持集预计会很小。这个过程会重复多次,最终,支持集最大的矩阵将被保留为最可能的矩阵。
因此,我们的目标是多次随机选择八个匹配,最终选择出八个好的匹配,这将给我们一个大的支持集。根据整个数据集中错误匹配的数量,选择八个正确匹配的概率会有所不同。然而,我们知道,我们选择的次数越多,我们对我们至少在这些选择中有一个好的匹配集的信心就越高。更精确地说,如果我们假设匹配集由w%的内点(良好匹配)组成,那么我们选择八个良好匹配的概率是w%。因此,一个选择包含至少一个错误匹配的概率是(1-w)。如果我们进行k次选择,只有一个随机集只包含良好匹配的概率是1-(1-w)^k。这是置信概率,c,我们希望这个概率尽可能高,因为我们至少需要一个良好的匹配集来获得正确的基础矩阵。因此,在运行 RANSAC 算法时,需要确定需要进行多少次k次选择才能获得给定的置信水平。
当使用cv::findFundamentalMat函数并采用CV_FM_RANSAC方法时,提供了两个额外的参数。第一个参数是置信水平,它决定了要进行的迭代次数(默认值为0.99)。第二个参数是点到极线距离的最大值,一个点如果被认为是一个内点。所有匹配对中,如果一个点与其极线距离大于指定距离的,将被报告为异常点。该函数还返回一个字符值的std::vector,表示输入集中相应的匹配已被识别为异常点(0)或内点(1)。
在你的初始匹配集中有越多的良好匹配,RANSAC 给出正确基础矩阵的概率就越高。这就是为什么我们在匹配特征点时应用了交叉检查过滤器。你也可以使用前一个菜谱中提到的比率测试来进一步提高最终匹配集的质量。这只是平衡计算复杂度、最终匹配数和所需置信度的问题,即所获得的匹配集只包含精确匹配。
还有更多...
本食谱中提出的鲁棒匹配过程的结果是使用具有最大支持集和包含在此支持集中的匹配集计算出的基本矩阵的估计。使用这些信息,我们可以以两种方式对这些结果进行精炼。
精炼基本矩阵
由于我们现在有一个高质量的匹配集,作为最后一步,使用所有这些匹配来重新估计基本矩阵可能是一个好主意。我们已经提到,存在一个线性 8 点算法来估计这个矩阵。因此,我们可以获得一个超定方程组,以最小二乘法求解基本矩阵。这一步可以添加到我们的ransacTest函数的末尾:
if (refineF) {
// The F matrix will
// be recomputed with all accepted matches
// Convert keypoints 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)上。这是由基本矩阵(fundamental matrix)表达出的极线约束。因此,如果你有一个基本矩阵的良好估计,你可以使用这个极线约束来校正获得的匹配,通过强制它们位于它们的极线上。这可以通过使用cv::correctMatches OpenCV 函数轻松完成:
std::vector<cv::Point2f> newPoints1, newPoints2;
// refine the matches
correctMatches(fundamental, // F matrix
points1, points2, // original position
newPoints1, newPoints2); // new position
此函数通过修改每个对应点的位置来实现,使其满足极线约束,同时最小化累积(平方)位移。
计算两张图像之间的单应性(homography)
本章的第二种方法向你展示了如何从一组匹配中计算图像对的基本矩阵。在射影几何中,还存在另一个非常有用的数学实体。这个实体可以从多视图图像中计算出来,正如我们将看到的,它是一个具有特殊性质的矩阵。
准备工作
再次,让我们考虑一个 3D 点和它在相机上的图像之间的射影关系,这是我们本章的第一个食谱中引入的。基本上,我们了解到这个方程通过相机的内在属性和这个相机的位置(用旋转和平移分量指定)将 3D 点与其图像联系起来。如果我们现在仔细检查这个方程,我们会意识到有两个特别有趣的特殊情况。第一种情况是当场景的两个视图之间只存在纯旋转时。那时可以观察到外矩阵的第四列将全部由 0 组成(即平移为零):

因此,在这个特殊情况下,射影关系变成了一个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<uchar> inliers(points1.size(),0);
cv::Mat homography= cv::findHomography(
points1, points2, // corresponding points
inliers, // outputed inliers matches
CV_RANSAC, // RANSAC method
1.); // max distance to reprojection point
回想一下,存在单应性(而不是基本矩阵)是因为我们的两个图像通过纯旋转分开。图像在此处显示。我们还显示了由函数的inliers参数确定的内点关键点。请参考以下截图:

第二个图像如下所示:

使用以下循环在这些图像上绘制了符合找到的单应性的结果内点:
// Draw the inlier points
std::vector<cv::Point2f>::const_iterator itPts=
points1.begin();
std::vector<uchar>::const_iterator itIn= inliers.begin();
while (itPts!=points1.end()) {
// draw a circle at each inlier location
if (*itIn)
cv::circle(image1,*itPts,3,
cv::Scalar(255,255,255));
++itPts;
++itIn;
}
投影变换是一个可逆的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 标志。
还有更多...
平面图像之间也存在单应性。然后我们可以利用这一点来识别图像中的平面物体。
在图像中检测平面目标
假设你想要检测图像中平面物体的出现。这个物体可能是一张海报、画作、标牌、书封面(如下例所示),等等。根据本章所学的内容,策略将包括检测该物体上的特征点,并尝试将它们与图像中的特征点匹配。然后,使用与之前配方中类似但基于单应性的鲁棒匹配方案来验证这些匹配。
让我们定义一个与我们的 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> extractor;
cv::Mat target; // target image
int normType;
double distance; // min reprojection error
在这里,我们简单地添加了一个 target 属性,它代表要匹配的平面物体的参考图像。匹配方法与 RobustMatcher 类的相同,只是在 ransacTest 方法中使用 cv::findHomography 而不是 cv::findFundamentalMat。我们还添加了一个启动目标匹配并找到目标位置的方法:
// detect the defined planar target in an image
// returns the homography
// the 4 corners of the detected target
// plus matches and keypoints
cv::Mat detectTarget(const cv::Mat& image,
// position of the target corners (clock-wise)
std::vector<cv::Point2f>& detectedCorners,
std::vector<cv::DMatch>& matches,
std::vector<cv::KeyPoint>& keypoints1,
std::vector<cv::KeyPoint>& keypoints2) {
// find a RANSAC homography between target and image
cv::Mat homography= match(target,image,matches,
keypoints1, keypoints2);
// target corners
std::vector<cv::Point2f> corners;
corners.push_back(cv::Point2f(0,0));
corners.push_back(cv::Point2f(target.cols-1,0));
corners.push_back(cv::Point2f(target.cols-1,target.rows-1));
corners.push_back(cv::Point2f(0,target.rows-1));
// reproject the target corners
cv::perspectiveTransform(corners,detectedCorners,
homography);
return homography;
}
一旦通过匹配方法找到单应性,我们就定义目标(即其参考图像的四个角)。然后使用 cv::perspectiveTransform 函数将这些角转移到图像上。这个函数简单地用单应性矩阵乘以输入向量中的每个点。这给出了这些点在另一图像中的坐标。目标匹配随后按以下方式进行:
// Prepare the matcher
TargetMatcher tmatcher("FAST","FREAK");
tmatcher.setNormType(cv::NORM_HAMMING);
// definition of the output data
std::vector<cv::DMatch> matches;
std::vector<cv::KeyPoint> keypoints1, keypoints2;
std::vector<cv::Point2f> corners;
// the reference image
tmatcher.setTarget(target);
// match image with target
tmatcher.detectTarget(image,corners,matches,
keypoints1,keypoints2);
// draw the target corners on the image
cv::Point pt= cv::Point(corners[0]);
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);
使用 cv::drawMatches 函数,我们以如下方式显示结果:

您还可以使用单应性来修改平面物体的视角。例如,如果您有几张从不同角度拍摄的建筑物平坦立面的图片,您可以通过计算这些图片之间的单应性,通过包裹图像并将它们组装在一起来构建一个大型马赛克立面,就像我们在本配方中所做的那样。两个视角之间至少需要四个匹配点来计算单应性。cv::getPerspectiveTransform 函数允许通过四个对应点进行这种变换的计算。
参考内容
-
第二章中的图像重映射配方,操作像素,讨论了反向映射的概念
-
M.Brown 和 D.Lowe 在 International Journal of Computer Vision,74, 1, 2007 发表的使用不变特征自动全景图像拼接文章,描述了从多张图片构建全景的完整方法
第十一章:处理视频序列
在本章中,我们将涵盖以下食谱:
-
读取视频序列
-
处理视频帧
-
写入视频序列
-
在视频中跟踪特征点
-
从视频中提取前景对象
简介
视频信号构成了丰富的视觉信息来源。它们由一系列图像组成,称为帧,这些图像以固定的时间间隔(指定为帧率,通常以每秒帧数表示)拍摄,并显示一个动态场景。随着强大计算机的出现,现在可以在视频序列上执行高级视觉分析——有时接近或甚至超过实际视频帧率。本章将向您展示如何读取、处理和存储视频序列。
我们将看到,一旦提取了视频序列的各个帧,本书中介绍的不同图像处理函数可以应用于每一帧。此外,我们还将探讨一些执行视频序列时间分析的算法,比较相邻帧以跟踪对象,或随时间累积图像统计信息以提取前景对象。
读取视频序列
为了处理视频序列,我们需要能够读取其每一帧。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方法进行验证),就可以开始帧提取。还可以使用cv::VideoCapture对象的 get 方法及其适当的标志来查询与视频文件关联的信息。在先前的示例中,我们使用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 是每秒的帧数,那么两个帧之间的延迟(以毫秒为单位)是 1000/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函数停止处理的作用变得至关重要,因为来自摄像头的视频流将被无限读取。
最后,也可以从网络加载视频。在这种情况下,您只需提供正确的地址,例如:
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_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函数指针,它将指向回调函数。此函数可以使用相应的 setter 方法指定:
// 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 OpenCV 类的 read 方法。然后执行一系列操作,但在调用每个操作之前,都会进行检查以确定是否已请求执行。只有当指定了输入窗口名称(使用 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 框架的通用 set 和 get 方法的包装器。
还有更多...
我们的 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;
};
一个 setter 方法允许您将一个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();
}
当指定帧处理器类实例时,它将使之前可能设置的任何帧处理函数无效。如果指定了帧处理函数,则显然也是如此。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类接口的示例。
写入视频序列
在前面的食谱中,我们学习了如何读取视频文件并提取其帧。本食谱将向您展示如何写入帧,从而创建视频文件。这将使我们能够完成典型的视频处理链:读取输入视频流,处理其帧,然后将结果存储在新视频文件中。
如何做...
在 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.jpg、bikeOut001.jpg和bikeOut002.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);
}
}
对于输出由单个图像文件组成的情况,我们需要一个额外的 setter 方法:
// 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值,然后将其转换为整数。这个整数代表可以从union数据结构中提取四个字符的代码。如果我们打开我们的测试视频序列,那么我们将有以下语句:
char codec[4];
processor.getCodec(codec);
std::cout << "Codec: " << codec[0] << codec[1] << codec[2] << codec[3] << std::endl;
从前面的陈述中,我们得到以下结论:
Codec : XVID
当写入视频文件时,必须使用其四字符代码指定编解码器。这是cv::VideoWriter类open方法的第二个参数。例如,你可以使用与输入视频相同的编解码器(这是我们在setOutput方法中的默认选项)。你也可以传递值-1,方法将弹出一个窗口,让你从可用编解码器列表中选择一个编解码器,如图所示:

你将在该窗口看到的列表对应于你机器上安装的编解码器列表。然后,所选编解码器的代码将自动发送到open方法。
参见
www.xvid.com/网站提供了一个基于 MPEG-4 标准的开源视频编解码器库。Xvid 还有一个竞争对手叫做 DivX,它提供专有但免费的编解码器和软件工具。
视频中的特征点跟踪
本章介绍的是读取、写入和处理视频序列。目标是能够分析完整的视频序列。例如,在本食谱中,你将学习如何执行序列的时间分析,以跟踪特征点从一帧移动到另一帧。
如何操作...
要开始跟踪过程,首先要做的是在初始帧中检测特征点。然后,你尝试在下一帧中跟踪这些点。显然,因为我们处理的是视频序列,所以找到特征点的对象很可能已经移动了(这种运动也可能是由相机移动引起的)。因此,你必须在一个点的先前位置周围搜索,以找到它在下一帧中的新位置。这正是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] &&
// if point has moved
(abs(points[0][i].x-points[1][i].x)+(abs(points[0][i].y-points[1][i].y))>2);
}
最后,第四个方法通过在当前帧上用线将跟踪到的点连接到它们的初始位置(即它们第一次被检测到的位置)来处理跟踪到的特征点:
// handle the currently tracked points
void handleTrackedPoints(cv:: Mat &frame, cv:: Mat &output) {
// for all tracked points
for(int i= 0; i < points[1].size(); i++ ) {
// draw line and circle
cv::line(output,
initial[i], // initial position
points[1][i],// new position
cv::Scalar(255,255,255));
cv::circle(output, points[1][i], 3, cv::Scalar(255,255,255),-1);
}
}
一个简单的用于跟踪视频序列中特征点的 main 函数可以写成如下:
int main()
{
// Create video procesor instance
VideoProcessor processor;
// Create feature tracker instance
FeatureTracker tracker;
// Open video file
processor.setInput("../bike.avi");
// set frame processor
processor.setFrameProcessor(&tracker);
// Declare a window to display the video
processor.displayOutput("Tracked Features");
// Play the video at the original frame rate
processor.etDelayetDelay(1000./processor.getFrameRate());
// Start the process
processor.run();
}
生成的程序将显示随时间推移移动跟踪特征点的演变。例如,以下是两个不同时刻的两个这样的帧。在这个视频中,摄像机是固定的。因此,年轻的自行车手是唯一的移动对象。这里是经过几帧处理后的结果:

几秒钟后,我们获得以下帧:

如何工作...
为了从一帧跟踪到另一帧的特征点,我们必须定位后续帧中特征点的新位置。如果我们假设特征点的强度从一个帧到下一个帧没有变化,我们正在寻找一个位移 (u,v) 如下:

在这里,I [t] 和 I [t+1] 分别是当前帧和下一个时刻的帧。这个恒定强度假设通常适用于在两个相邻时刻拍摄的图像中的小位移。然后我们可以使用泰勒展开来近似这个方程,通过涉及图像导数的方程来实现:

后者方程引出另一个方程(这是由于恒定强度假设抵消了两个强度项的结果):

这个众所周知的约束是基本的光流约束方程。这个约束被所谓的卢卡斯-卡纳达特征跟踪算法所利用,该算法还做出了一个额外的假设,即特征点周围所有点的位移是相同的。因此,我们可以对所有这些点施加光流约束,使用一个独特的 (u,v) 未知位移。这给我们提供了比未知数(2)更多的方程,因此,我们可以以均方误差的方式求解这个方程组。在实践中,它是通过迭代求解的,OpenCV 实现还提供了在不同分辨率下执行此估计的可能性,以提高搜索效率和对较大位移的容忍度。默认情况下,图像级别数量为 3,窗口大小为 15。这些参数显然可以更改。您还可以指定终止条件,这些条件定义了停止迭代搜索的条件。cv::calcOpticalFlowPyrLK 的第六个参数包含用于评估跟踪质量的残差均方误差。第五个参数包含二进制标志,告诉我们是否认为跟踪相应的点是成功的。
上述描述代表了卢卡斯-卡纳达跟踪器背后的基本原理。当前的实现包含其他优化和改进,使得算法在计算大量特征点位移时更加高效。
参见
-
第八章, 《检测兴趣点》一节对特征点检测进行了讨论。
-
B. Lucas 和 T. Kanade 的经典文章《一种迭代图像配准技术及其在立体视觉中的应用》发表于 1981 年的《国际人工智能联合会议》,介绍了原始的特征点跟踪算法。
-
J. Shi 和 C. Tomasi 的文章《在 IEEE 计算机视觉和模式识别会议上的良好跟踪特征》发表于 1994 年,描述了原始特征点跟踪算法的改进版本。
从视频中提取前景物体
当一个固定相机观察一个场景时,背景保持基本不变。在这种情况下,有趣的因素是场景内部移动的物体。为了提取这些前景物体,我们需要建立一个背景模型,然后将其与当前帧进行比较,以检测任何前景物体。这正是本食谱要做的。前景提取是智能监控应用中的基本步骤。
如果我们手头有一幅场景背景的图像(即,一个不包含前景物体的框架),那么通过简单的图像差异就可以轻松地提取当前帧的前景:
// compute difference between current image and background
cv::absdiff(backgroundImage,currentImage,foreground);
对于差异足够高的每个像素,将其宣布为前景像素。然而,大多数情况下,这种背景图像并不容易获得。实际上,很难保证给定图像中没有前景物体,在繁忙的场景中,这种情况可能很少发生。此外,背景场景通常会随着时间的推移而变化,例如,由于光照条件的变化(例如,从日出到日落)或因为新的物体可以添加到背景或从背景中移除。
因此,有必要动态地建立一个背景场景的模型。这可以通过观察场景一段时间来实现。如果我们假设大多数情况下,背景在每个像素位置都是可见的,那么简单地计算所有观察的平均值可能是一个好的策略。然而,由于多种原因,这并不可行。首先,这需要在计算背景之前存储大量的图像。其次,当我们积累图像来计算平均图像时,不会进行前景提取。这种解决方案还提出了何时以及需要积累多少图像来计算可接受的背景模型的问题。此外,观察到一个像素正在观察前景物体的图像将对平均背景的计算产生影响。
更好的策略是动态地通过定期更新来构建背景模型。这可以通过计算所谓的 运行平均数(也称为 移动平均数)来实现。这是一种计算时间信号平均值的方法,它考虑了最新接收到的值。如果 pt 是给定时间 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_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,以避免更新被声明为前景的像素。这是因为我们的前景图像在前景像素上被定义为假(即,0),这也解释了为什么前景对象在结果图像中显示为黑色像素。
最后,应该注意的是,为了简化,我们程序构建的背景模型基于提取帧的灰度版本。保持彩色背景将需要计算某种颜色空间中的运行平均数。然而,在所提出的方法中,主要困难是确定适当的阈值,以便为给定的视频提供良好的结果。
还有更多...
之前提取场景中前景对象的方法对于显示相对稳定背景的简单场景效果很好。然而,在许多情况下,背景场景可能在某些区域之间波动,从而造成频繁的错误前景检测。这些可能是由例如移动的背景对象(例如树叶)或刺眼的效果(例如水面上)引起的。投射的阴影也造成问题,因为它们通常被检测为移动对象的一部分。为了应对这些问题,已经引入了更复杂的背景建模方法。
高斯混合方法
这些算法之一是高斯混合方法。它的过程与本文献中介绍的方法类似,但增加了一些改进。
首先,该方法为每个像素维护多个模型(即多个运行平均值)。这样,如果一个背景像素在两个值之间波动,例如,就会存储两个运行平均值。只有当新的像素值不属于观察到的最频繁的任何模型时,才会将其宣布为前景。所使用的模型数量是该方法的参数,一个典型值是5。
其次,不仅为每个模型维护运行平均值,还维护运行方差。计算方法如下:

这些计算出的平均值和方差被用来构建高斯模型,从这个模型中可以估计给定像素值属于背景的概率。这使得确定合适的阈值变得更加容易,因为它现在是以概率的形式表达,而不是绝对差值。因此,在背景值波动较大的区域,需要更大的差异才能宣布存在前景对象。
最后,当给定的高斯模型不足以被击中时,它被排除在背景模型之外。相反,当发现像素值位于当前维护的背景模型之外(即它是前景像素)时,会创建一个新的高斯模型。如果将来这个新模型被击中,那么它就与背景相关联。
这个更复杂的算法显然比我们简单的背景/前景分割器更难实现。幸运的是,存在一个名为cv::BackgroundSubtractorMOG的 OpenCV 实现,它被定义为更通用cv::BackgroundSubtractor类的子类。当使用其默认参数时,这个类非常容易使用:
int main()
{
// Open the video file
cv::VideoCapture capture("bike.avi");
// check if video successfully opened
if (!capture.isOpened())
return 0;
// current video frame
cv::Mat frame;
// foreground binary image
cv::Mat foreground;
cv::namedWindow("Extracted Foreground");
// The Mixture of Gaussian object
// used with all default parameters
cv::BackgroundSubtractorMOG mog;
bool stop(false);
// for all frames in video
while (!stop) {
// read next frame if any
if (!capture.read(frame))
break;
// update the background
// and return the foreground
mog(frame,foreground,0.01)
// learning rate
// Complement the image
cv::threshold(foreground,foreground,128,255,cv::THRESH_BINARY_INV);
// show foreground
cv::imshow("Extracted Foreground",foreground);
// introduce a delay
// or press key to stop
if (cv::waitKey(10)>=0)
stop= true;
}
}
如您所见,这仅仅是创建类实例并调用同时更新背景并返回前景图像(额外参数为学习率)的方法的问题。此外,请注意,这里的背景模型是按颜色计算的。OpenCV 中实现的方法还包括一个机制,通过检查观察到的像素变化是否仅仅是由于亮度(如果是,那么可能是由于阴影)的局部变化来拒绝阴影。
另一个实现版本也可用,简单称为cv::BackgroundSubtractorMOG2。其中一个改进是现在动态确定每个像素点使用的适当高斯模型数量。您可以用它替换前面示例中的上一个版本。您应该在多个视频上运行这些不同的方法,以便欣赏它们各自的性能。一般来说,您会观察到cv::BackgroundSubtractorMOG2要快得多。
参见
- C. Stauffer 和 W.E.L. Grimson 撰写的文章《Adaptive Background Mixture Models for Real-Time Tracking》,发表于 1999 年的Conf. on Computer Vision and Pattern Recognition,为您提供了对高斯混合算法的更完整描述。


浙公网安备 33010602011771号