首页 > 编程语言 >OpenCV2 计算机视觉应用编程秘籍:1~5

OpenCV2 计算机视觉应用编程秘籍:1~5

时间:2023-04-19 11:26:03浏览次数:50  
标签:Mat 秘籍 image 编程 使用 像素 OpenCV2 图像 cv

原文:OpenCV2 Computer Vision Application Programming Cookbook

协议:CC BY-NC-SA 4.0

译者:飞龙

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

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

一、玩转图像

在本章中,我们将介绍:

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

简介

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

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

安装 OpenCV 库

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

准备

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

操作步骤

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

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

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

How to do it...

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

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

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

工作原理

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

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

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

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

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

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

 #include "cv.h"

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

更多

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

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

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

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

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

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

准备

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

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

操作步骤

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

How to do it...

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

How to do it...

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

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

How to do it...

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

How to do it...

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

How to do it...

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

How to do it...

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

How to do it...

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

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

How to do it...

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

How to do it...

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

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

How to do it...

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

How to do it...

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

How to do it...

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

工作原理

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

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

另见

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

使用 Qt 创建 OpenCV 项目

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

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

准备

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

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

操作步骤

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

How to do it...

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

How to do it...

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

How to do it...

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

How to do it...

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

How to do it...

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

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

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

提示

下载示例代码

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

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

How to do it...

工作原理

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

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

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

更多

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

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

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

unix { 

    CONFIG += link_pkgconfig
    PKGCONFIG += opencv
}

Win32 {

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

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

另见

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

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

加载,显示和保存图像

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

准备

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

操作步骤

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

cv::Mat image;

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

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

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

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

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

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

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

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

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

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

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

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

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

cv::waitKey(0);

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

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

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

工作原理

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

using namespace cv;

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

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

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

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

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

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

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

cv::Mat function() {

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

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

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

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

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

class Test {

   // image attribute
   cv::Mat ima;

  public:

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

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

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

更多

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

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

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

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

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

 cvReleaseImage(&iplImage);

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

使用 Qt 创建 GUI 应用

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

准备

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

操作步骤

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

How to do it...

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

How to do it...

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

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

How to do it...

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

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

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

#ifndef MAINWINDOW_H
#define MAINWINDOW_H

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

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

#endif // MAINWINDOW_H

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

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

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

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

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

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

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

How to do it...

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

工作原理

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

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

private slots:
  void on_pushButton_clicked();

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

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

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

ui->pushButton_2->setEnabled(false);

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

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

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

更多

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

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

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

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

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

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

There's more...

另见

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

二、操纵像素

在本章中,我们将介绍:

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

简介

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

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

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

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

Introduction

访问像素值

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

准备

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

操作步骤

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

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

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

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

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

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

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

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

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

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

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

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

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

生成的图像如下所示:

How to do it...

工作原理

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

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

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

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

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

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

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

更多

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

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

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

另见

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

用指针扫描图像

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

准备

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

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

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

操作步骤

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

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

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

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

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

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

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

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

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

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

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

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

        } // end of line                   
     }
}

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

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

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

How to do it...

工作原理

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

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

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

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

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

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

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

更多

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

其他颜色缩减秘籍

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

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

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

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

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

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

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

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

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

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

具有输入和输出参数

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

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

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

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

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

colorReduce(image,image);

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

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

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

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

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

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

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

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

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

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

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

        } // end of line                   

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

高效扫描连续图像

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

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

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

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

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

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

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

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

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

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

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

          } // end of line                   
     }
}

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

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

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

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

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

低级指针算法

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

uchar *data= image.data;

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

data+= image.step;  // next line

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

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

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

另见

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

使用迭代器扫描图像

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

准备

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

操作步骤

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

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

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

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

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

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

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

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

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

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

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

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

工作原理

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

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

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

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

     while (it!= itend) {

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

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

        ++it;
     }

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

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

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

或者:

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

更多

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

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

另见

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

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

编写有效的图像扫描循环

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

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

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

操作步骤

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

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

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

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

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

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

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

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

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

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

           } // end of line                   
      }

工作原理

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

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

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

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

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

与此:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

          } // end of line                   
     }
}

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

更多

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

另见

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

使用邻居访问扫描图像

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

准备

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

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

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

操作步骤

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

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

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

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

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

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

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

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

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

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

How to do it...

工作原理

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

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

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

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

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

更多

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

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

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

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

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

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

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

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

另见

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

执行简单的图像运算

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

准备

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

Getting ready

操作步骤

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

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

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

How to do it...

工作原理

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

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

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

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

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

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

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

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

更多

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

重载的图像运算符

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

result= 0.7*image1+0.9*image2;

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

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

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

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

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

分割图像通道

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

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

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

定义兴趣区域

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

准备

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

Getting ready

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

操作步骤

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

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

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

然后获得以下图像:

How to do it...

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

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

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

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

然后,结果图像为:

How to do it...

工作原理

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

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

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

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

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

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

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

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

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

三、使用类处理图像

在本章中,我们将介绍:

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

简介

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

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

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

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

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

准备

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

操作步骤

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

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

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

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

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

           *itout= 255;

        } else {

           *itout= 0;
        }

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

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

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

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

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

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

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

*processing loop above goes here*
     ...

     return result;
}

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

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

class ColorDetector {

  private:

     // minimum acceptable distance
     int minDist; 

     // target color
     cv::Vec3b target; 

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

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

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

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

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

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

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

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

        return minDist;
     }

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

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

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

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

        target= color;
     }

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

        return target;
     }

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

工作原理

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

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

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

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

   cv::namedWindow("result");

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

   cv::waitKey();

   return 0;
}

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

How it works...

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

更多

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

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

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

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

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

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

c= a+b;

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

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

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

另见

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

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

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

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

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

准备

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

Getting ready

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

操作步骤

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

class ColorDetectController {

  private:

   // the algorithm class
   ColorDetector *cdetect;

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

  public:

   ColorDetectController() { 

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

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

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

        cdetect->setColorDistanceThreshold(distance);
     }

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

        return cdetect->getColorDistanceThreshold();
     }

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

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

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

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

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

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

        image= cv::imread(filename);

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

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

        return image;
     }

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

     // Performs image processing.
     void process() {

        result= cdetect->process(image);
     }

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

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

        return result;
     }

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

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

        delete cdetect;
     }

工作原理

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

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

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

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

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

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

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

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

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

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

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

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

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

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

另见

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

使用单例设计模式

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

准备

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

操作步骤

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

   class ColorDetectController {

     private:

      // pointer to the singleton
       static ColorDetectController *singleton; 

      ColorDetector *cdetect;

      // private constructor
      ColorDetectController() { 

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

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

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

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

        return singleton;
     }

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

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

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

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

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

#include "colorDetectController.h"

ColorDetectController *ColorDetectController::singleton=0; 

工作原理

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

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

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

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

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

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

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

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

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

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

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

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

准备

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

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

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

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

操作步骤

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

How to do it...

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

How to do it...

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

How to do it...

工作原理

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

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

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

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

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

另见

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

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

转换色彩空间

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

准备

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

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

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

操作步骤

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

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

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

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

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

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

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

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

class ColorDetector {

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

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

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

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

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

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

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

工作原理

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

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

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

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

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

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

另见

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

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

四、使用直方图计算像素

在本章中,我们将介绍:

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

简介

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

计算图像直方图

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

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

入门

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

Getting started

操作步骤

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

class Histogram1D {

  private:

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

  public:

   Histogram1D() {

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

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

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

      cv::MatND hist;

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

      return hist;
   }

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

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

   // The histogram object
   Histogram1D h;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

      return histImg;
   }

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

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

结果如下图:

How to do it...

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

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

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

How to do it...

工作原理

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

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

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

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

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

更多

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

class ColorHistogram {

  private:

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

  public:

   ColorHistogram() {

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

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

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

      cv::MatND hist;

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

      return hist;
   }

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

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

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

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

      return hist;
   }

另见

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

应用查询表修改图像外观

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

操作步骤

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

         newIntensity= lookup[oldIntensity];

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

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

      // the output image
      cv::Mat result;

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

      return result;
   }

工作原理

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

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

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

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

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

How it works...

更多

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

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

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

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

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

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

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

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

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

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

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

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

      return result;
   }

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

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

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

There's more...

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

There's more...

另见

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

均衡图像直方图

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

操作步骤

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

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

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

      return result;
   }

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

How to do it...

该图像具有以下直方图:

How to do it...

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

工作原理

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

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

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

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

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

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

操作步骤

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

How to do it...

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

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

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

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

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

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

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

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

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

How to do it...

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

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

How to do it...

工作原理

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

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

更多

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

class ContentFinder {

  private:

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

   float threshold;
   cv::MatND histogram;

  public:

   ContentFinder() : threshold(-1.0f) {

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

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

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

      threshold= t;
   }

   // Gets the threshold
   float getThreshold() {

      return threshold;
   }

输入直方图必须归一化:

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

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

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

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

      cv::Mat result;

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

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

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

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

      return result;
   }

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

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

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

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

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

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

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

There's more...

另见

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

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

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

操作步骤

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

How to do it...

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

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

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

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

      cv::MatND hist;

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

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

      if (minSaturation>0) {

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

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

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

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

      return hist;
   }

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

   ContentFinder finder;
   finder.setHistogram(colorhist);

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

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

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

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

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

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

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

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

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

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

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

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

How to do it...

工作原理

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

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

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

另见

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

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

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

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

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

操作步骤

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

class ImageComparator {

  private:

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

   ColorHistogram hist;
   int div;

  public:

   ImageComparator() : div(32) {

   }

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

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

      div= factor;
   }

   int getColorReduction() {

      return div;
   }

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

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

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

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

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

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

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

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

   ImageComparator c;
   c.setReferenceImage(image);

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

How to do it...

工作原理

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

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

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

另见

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

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

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

在本章中,我们将介绍:

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

简介

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

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

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

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

准备

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

Getting ready

操作步骤

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

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

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

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

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

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

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

How to do it...

其次是膨胀结果:

How to do it...

工作原理

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

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

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

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

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

How it works...

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

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

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

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

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

更多

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

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

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

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

另见

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

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

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

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

操作步骤

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

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

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

How to do it...

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

How to do it...

这是从以下代码获得的:

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

工作原理

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

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

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

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

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

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

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

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

How it works...

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

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

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

入门

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

Getting started

操作步骤

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

class MorphoFeatures {

  private:

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

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

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

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

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

   return result;
}

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

void applyThreshold(cv::Mat& result) {

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

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

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

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

结果如下图:

How to do it...

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

MorphoFeatures() : threshold(-1), 
        cross(5,5,CV_8U,cv::Scalar(0)),
            diamond(5,5,CV_8U,cv::Scalar(1)), 
        square(5,5,CV_8U,cv::Scalar(1)),
        x(5,5,CV_8U,cv::Scalar(0)){

   // Creating the cross-shaped structuring element
   for (int i=0; i<5; i++) {

      cross.at<uchar>(2,i)= 1;
      cross.at<uchar>(i,2)= 1;                           
   }

   // Creating the diamond-shaped structuring element
   diamond.at<uchar>(0,0)= 0;
   diamond.at<uchar>(0,1)= 0;
   diamond.at<uchar>(1,0)= 0;
   diamond.at<uchar>(4,4)= 0;
   diamond.at<uchar>(3,4)= 0;
   diamond.at<uchar>(4,3)= 0;
   diamond.at<uchar>(4,0)= 0;
   diamond.at<uchar>(4,1)= 0;
   diamond.at<uchar>(3,0)= 0;
   diamond.at<uchar>(0,4)= 0;
   diamond.at<uchar>(0,3)= 0;
   diamond.at<uchar>(1,4)= 0;

   // Creating the x-shaped structuring element
   for (int i=0; i<5; i++) {

     x.at<uchar>(i,i)= 1;
     x.at<uchar>(4-i,i)= 1;                           
   }     
}

在检测角点特征时,所有这些结构元素都会级联应用以获得最终的角点贴图:

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

   cv::Mat result;

   // Dilate with a cross   
   cv::dilate(image,result,cross);

   // Erode with a diamond
   cv::erode(result,result,diamond);

   cv::Mat result2;
   // Dilate with a X   
   cv::dilate(image,result2,x);

   // Erode with a square
   cv::erode(result2,result2,square);

   // Corners are obtained by differencing
   // the two closed images
   cv::absdiff(result2,result,result);

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

   return result;
}

为了更好地可视化检测结果,以下方法在二进制图上每个检测到的点上在图像上绘制一个圆:

void drawOnImage(const cv::Mat& binary, 
                   cv::Mat& image) {

   cv::Mat_<uchar>::const_iterator it= 
                        binary.begin<uchar>();
   cv::Mat_<uchar>::const_iterator itend= 
                        binary.end<uchar>();

   // for each pixel   
   for (int i=0; it!= itend; ++it,++i) {
      if (!*it)          
         cv::circle(image,
           cv::Point(i%image.step,i/image.step),
           5,cv::Scalar(255,0,0));
   }
}

然后,使用以下代码在图像上检测角点:

// Get the corners
cv::Mat corners;
corners= morpho.getCorners(image);

// Display the corner on the image
morpho.drawOnImage(corners,image);
cv::namedWindow("Corners on Image");
cv::imshow("Corners on Image",image);

然后,检测到的角的图像如下。

How to do it...

工作原理

帮助理解形态运算符对灰度图像的影响的一种好方法是将图像视为拓扑浮雕,其中灰度对应于海拔(或海拔)。 在这种情况下,明亮的区域对应于山脉,而较暗的区域则构成地形的山谷。 同样,由于边缘对应于较暗像素和较亮像素之间的快速过渡,因此可以将其描绘为陡峭的悬崖。 如果在这样的地形上应用腐蚀运算符,最终结果将是用某个邻域中的最小值替换每个像素,从而减小其高度。 结果,随着山谷的扩大,悬崖将被“侵蚀”。 扩张具有完全相反的效果,即悬崖将在山谷上空获得地形。 但是,在两种情况下,平稳度(即恒定强度的区域)将保持相对不变。

上述观察结果导致了一种检测图像边缘(或悬崖)的简单方法。 这可以通过计算膨胀图像和侵蚀图像之间的差异来完成。 由于这两个变换后的图像大部分在边缘位置不同,因此差异会突出图像的边缘。 输入cv::MORPH_GRADIENT自变量时,这正是cv::morphologyEx函数所做的事情。 显然,结构元素越大,检测到的边缘将越厚。 该边缘检测运算符也称为 Beucher 梯度(下一章将更详细地讨论图像梯度的概念)。 注意,也可以通过简单地从扩张后的图像中减去原始图像或从原始图像中减去侵蚀图像来获得类似的结果。 产生的边缘将更薄。

角点检测要复杂一些,因为它使用了四个不同的结构元素。 该运算符未在 OpenCV 中实现,但我们在这里展示它是为了演示如何定义和组合各种形状的结构化元素。 这个想法是通过使用两个不同的结构元素对图像进行扩张和腐蚀来封闭图像。 选择这些元素以使它们的直边保持不变,但是由于它们各自的作用,将影响角点的边缘。 让我们使用由单个白色正方形组成的以下简单图像更好地了解此非对称关闭操作的效果:

How it works...

第一个正方形是原始图像。 当用十字形结构元素进行扩张时,方形边缘会扩大,除了在十字形不会碰到方形的角点处。 这是中间的方块说明的结果。 然后,这个扩张的图像被结构元素侵蚀,这次,该元素具有菱形形状。 这种侵蚀使大多数边缘恢复到其原始位置,但由于它们没有膨胀,因此将角进一步推向了另一端。 然后获得左方格,可以看到它已经失去了尖角。 使用 X 形和方形结构元素重复相同的过程。 这两个元素是先前元素的旋转版本,因此将以 45 度方向捕获角。 最后,对两个结果求差将提取角点特征。

另见

The article, Morphological gradients by J.-F. Rivest, P. Soille, S. Beucher, ISET's symposium on electronic imaging science and technology, SPIE, Feb. 1992, for more on morphological gradient.

The article A modified regulated morphological corner detector by F.Y. Shih, C.-F. Chuang, V. Gaddipati, Pattern Recognition Letters , volume 26, issue 7, May 2005, for more information on morphological corner detection.

使用分水岭分割图像

分水岭变换是一种流行的图像处理算法,用于将图像快速分割为同质区域。 它依赖于这样的想法:当图像被视为拓扑浮雕时,均匀区域对应于由陡峭边缘界定的相对平坦的盆地。 由于其简单性,该算法的原始版本往往会过分分割图像,从而产生多个小区域。 这就是 OpenCV 提出该算法的变体的原因,该变体使用了一组预定义的标记来指导图像段的定义。

操作步骤

分水岭分割是通过使用cv::watershed函数获得的。 此函数的输入是一个 32 位带符号整数标记图像,其中每个非零像素代表一个标签。 想法是标记图像的某些像素,这些像素当然属于给定区域。 根据该初始标记,分水岭算法将确定其他像素所属的区域。 在本秘籍中,我们将首先将标记图像创建为灰度图像,然后将其转换为整数图像。 我们方便地将此步骤封装到WatershedSegmenter类中:

class WatershedSegmenter {

  private:

     cv::Mat markers;

  public:

     void setMarkers(const cv::Mat& markerImage) {

      // Convert to image of ints
      markerImage.convertTo(markers,CV_32S);
     }

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

      // Apply watershed
      cv::watershed(image,markers);

      return markers;
     }

获得这些标记的方式取决于应用。 例如,某些预处理步骤可能导致识别出属于感兴趣对象的某些像素。 然后,分水岭将用于从该初始检测中划定整个对象。 在本秘籍中,我们将仅使用本章中使用的二进制图像来识别相应原始图像的动物(这是在第 4 章开头显示的图像)。

因此,从二进制图像中,我们需要确定肯定属于前景的像素(动物)和肯定属于背景的像素(主要是草)。 在这里,我们将用标签 255 标记前景像素,并用标签 128 标记背景像素(此选择完全是任意的,除 255 以外的任何标签编号都可以使用)。 其他像素(即标记未知的像素)的赋值为 0。就目前而言,二进制图像包含太多属于图像各个部分的白色像素。 然后,我们将严重腐蚀该图像,以便仅保留属于重要对象的像素:

   // Eliminate noise and smaller objects
   cv::Mat fg;
   cv::erode(binary,fg,cv::Mat(),cv::Point(-1,-1),6);

结果如下图:

How to do it...

请注意,仍然存在属于背景林的一些像素。 让我们简单地保留它们。 因此,它们将被认为对应于感兴趣的对象。 类似地,我们还通过对原始二进制图像进行大的扩张来选择背景的一些像素:

   // Identify image pixels without objects
   cv::Mat bg;
   cv::dilate(binary,bg,cv::Mat(),cv::Point(-1,-1),6);
   cv::threshold(bg,bg,1,128,cv::THRESH_BINARY_INV);

产生的黑色像素对应于背景像素。 这就是为什么在膨胀后立即将阈值运算分配给这些像素的值 128 的原因。然后获得以下图像:

How to do it...

这些图像被组合以形成标记图像:

   // Create markers image
   cv::Mat markers(binary.size(),CV_8U,cv::Scalar(0));
   markers= fg+bg;

请注意,我们在此处如何使用重载的operator+来组合图像。 这是将用作分水岭算法输入的图像:

How to do it...

然后按以下方式获得分段:

   // Create watershed segmentation object
   WatershedSegmenter segmenter;

   // Set markers and process
   segmenter.setMarkers(markers);
   segmenter.process(image);

然后更新标记图像,以便为每个零像素分配一个输入标签之一,而属于找到的边界的像素的值为 -1。 标签的结果图像如下:

How to do it...

边界图像为:

How to do it...

工作原理

正如我们在前面的秘籍中所做的那样,我们将在分水岭算法的描述中使用拓扑图类比。 为了创建分水岭分割,其想法是从级别 0 开始逐渐淹没图像。随着“水”级别的逐渐增加(达到级别 1、2、3 等),形成了集水盆地。 这些流域的大小也逐渐增加,因此,两个不同流域的水最终将合并。 发生这种情况时,将创建分水岭,以使两个盆地保持分离。 一旦水位达到最大水位,这些创建的盆地和集水区就构成了集水区分割。

如人们所料,洪水过程最初会形成许多小的单个盆地。 当所有这些合并时,会创建许多分水岭线,从而导致图像过度分割。 为了克服该问题,已经提出了对该算法的修改,其中,泛洪处理从预定的标记像素组开始。 由这些标记创建的盆地根据分配给初始标记的值进行标记。 当两个具有相同标签的盆地合并时,不会创建分水岭,从而防止了过度分割。

这就是调用cv::watershed函数时发生的情况。 输入的标记图像将更新以产生最终的分水岭分割。 用户可以输入带有任意数量标签的标记图像,其中未知标签的像素保留为 0。标记图像被选择为 32 位带符号整数的图像,以便能够定义 255 个以上的标签。 它还允许将特殊值 -1 分配给与分水岭相关的像素。 这是cv::watershed函数返回的内容。 为方便显示结果,我们引入了两种特殊方法。 第一个返回标签的图像(分水岭的值为 0)。 这可以通过阈值轻松完成:

     // Return result in the form of an image
     cv::Mat getSegmentation() {

      cv::Mat tmp;
      // all segment with label higher than 255
      // will be assigned value 255
      markers.convertTo(tmp,CV_8U);

      return tmp;
     }

类似地,第二种方法返回一个图像,其中分水岭线的值设置为 0,其余图像为 255。这一次,cv::convertTo方法用于实现以下结果:

     // Return watershed in the form of an image
     cv::Mat getWatersheds() {

      cv::Mat tmp;
      // Each pixel p is transformed into
      // 255p+255 before conversion
      markers.convertTo(tmp,CV_8U,255,255);

      return tmp;
     }

转换之前应用的线性变换允许将 -1 像素转换为 0(因为-1 * 255 + 255 = 0)。

值大于 255 的像素被分配值为 255。这是由于将有符号整数转换为无符号字符时应用了饱和操作。

另见

The article The viscous watershed transform by C. Vachier, F. Meyer, Journal of Mathematical Imaging and Vision, volume 22, issue 2-3, May 2005, for more information on the watershed transform.

下一个秘籍介绍了另一个图像分割算法,该算法也可以将图像分割为背景和前景对象。

使用 GrabCut 算法提取前景对象

OpenCV 提出了另一种流行的图像分割算法:GrabCut 算法的实现。 该算法不是基于数学形态学的,但是我们在这里介绍它,因为它显示了与前面秘籍中提出的分水岭分割算法的一些相似之处。 GrabCut 在计算上比分水岭贵,但通常可以产生更准确的结果。 当一个人想要在静止图像中提取前景对象(例如,将一个对象从一张图片剪切并粘贴到另一张图片)时,这是最好的算法。

操作步骤

cv::grabCut函数易于使用。 您只需要输入图像并将其某些像素标记为属于背景或前景。 基于此部分标记,该算法将确定完整图像的前景/背景分割。

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

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

   // define bounding rectangle
   // the pixels outside this rectangle
   // will be labeled as background 
   cv::Rect rectangle(10,100,380,180);

然后,此矩形之外的所有像素将被标记为背景。 除了输入图像及其分割图像之外,调用cv::grabCut函数还需要定义两个矩阵,其中包含该算法构建的模型:

   cv::Mat result; // segmentation (4 possible values)
   cv::Mat bgModel,fgModel; // the models (internally used)
   // GrabCut segmentation
   cv::grabCut(image,    // input image
            result,      // segmentation result
            rectangle,   // rectangle containing foreground 
            bgModel,fgModel, // models
            5,           // number of iterations
            cv::GC_INIT_WITH_RECT); // use rectangle

注意我们如何使用cv::GC_INIT_WITH_RECT标志作为函数的最后一个参数来指定使用边界矩形模式(下一节将讨论其他可用模式)。 输入/输出分割图像可以具有四个值之一:

  • cv::GC_BGD,用于肯定属于背景的像素(例如,在我们的示例中,矩形外部的像素)
  • cv::GC_FGD,用于肯定属于前景的像素(在我们的示例中没有)
  • cv::GC_PR_BGD,用于可能属于背景的像素
  • cv::GC_PR_FGD用于可能属于前景的像素(在我们的示例中为矩形内像素的初始值)。

通过提取值等于cv::GC_PR_FGD的像素,我们得到了分割的二进制图像:

   // Get the pixels marked as likely foreground
   cv::compare(result,cv::GC_PR_FGD,result,cv::CMP_EQ);
   // Generate output image
   cv::Mat foreground(image.size(),CV_8UC3,
                      cv::Scalar(255,255,255));
   image.copyTo(foreground,// bg pixels are not copied
                result);   

要提取所有前景像素(即,其值等于cv::GC_PR_FGDcv::GC_FGD),可以简单地检查第一位的值:

   // checking first bit with bitwise-and
   result= result&1; // will be 1 if FG 

这是可能的,因为这些常数定义为值 1 和 3,而其他两个常数定义为 0 和 2。在我们的示例中,由于分割图像不包含cv::GC_FGD像素(仅cv::GC_BGD像素已输入)。

最后,通过以下带有遮罩的复制操作,我们获得了前景对象的图像(在白色背景上):

   // Generate output image
   cv::Mat foreground(image.size(),CV_8UC3,
             cv::Scalar(255,255,255)); // all white image
   image.copyTo(foreground,result); // bg pixels not copied

结果图像如下:

How to do it...

工作原理

在前面的示例中,GrabCut 算法能够通过简单地指定在其中包含这些对象(四个动物)的矩形来提取前景对象。 备选地,还可以将值cv::GC_BGDcv::GC_FGD分配给作为cv::grabCut函数的第二自变量提供的分割图像的某些特定像素。 然后,您可以将GC_INIT_WITH_MASK指定为输入模式标志。 这些输入标签可以例如通过要求用户交互式地标记图像的一些元素来获得。 也可以组合这两种输入模式。

使用此输入信息,GrabCut 通过以下步骤创建背景/前景分割。 最初,将前景标签(cv::GC_PR_FGD)临时分配给所有未标记的像素。 基于当前分类,该算法将像素分为相似颜色的群集(即,背景为K群集,前景为K群集)。 下一步是通过在前景像素和背景像素之间引入边界来确定背景/前景分割。 这是通过优化过程来完成的,该过程尝试将像素与相似的标签连接起来,并对在强度相对均匀的区域中放置边界施加了惩罚。 通过使用图切割算法可以有效地解决此优化问题,该方法可以通过将它表示为连通图,在上面应用切割来组成最佳配置,从而找到问题的最佳解决方案。 所获得的分割为像素产生新的标签。 然后可以重复聚类过程,并再次找到新的最佳分割,依此类推。 因此,GrabCut 是一个迭代过程,可逐步改善分割结果。 根据场景的复杂性,可以在或多或少的迭代中找到一个好的解决方案(在简单情况下,一个迭代就足够了!)。

这解释了该函数的上一个最后一个参数,用户可以在其中指定要应用的迭代次数。 算法维护的两个内部模型作为函数的参数传递(并返回),这样,如果希望通过执行其他迭代来改善细分结果,则可以再次使用上次运行的模型调用该函数。

另见

The article by C. Rother, V. Kolmogorov and A. Blake, GrabCut: Interactive Foreground Extraction using Iterated Graph Cuts in ACM Transactions on Graphics (SIGGRAPH) volume 23, issue 3, August 2004, that describes in detail the GrabCut algorithm.

标签:Mat,秘籍,image,编程,使用,像素,OpenCV2,图像,cv
From: https://www.cnblogs.com/apachecn/p/17332664.html

相关文章

  • OpenCV2 计算机视觉应用编程秘籍:6~10
    原文:OpenCV2ComputerVisionApplicationProgrammingCookbook协议:CCBY-NC-SA4.0译者:飞龙本文来自【ApacheCN计算机视觉译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。六、过......
  • 树莓派计算机视觉编程:1~5
    原文:RaspberryPiComputerVisionProgramming协议:CCBY-NC-SA4.0译者:飞龙本文来自【ApacheCN计算机视觉译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。一、计算机视觉和Raspbe......
  • 树莓派计算机视觉编程:6~10
    原文:RaspberryPiComputerVisionProgramming协议:CCBY-NC-SA4.0译者:飞龙本文来自【ApacheCN计算机视觉译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。六、色彩空间,变换和阈值......
  • 树莓派计算机视觉编程:11~13
    原文:RaspberryPiComputerVisionProgramming协议:CCBY-NC-SA4.0译者:飞龙本文来自【ApacheCN计算机视觉译文集】,采用译后编辑(MTPE)流程来尽可能提升效率。当别人说你没有底线的时候,你最好真的没有;当别人说你做过某些事的时候,你也最好真的做过。十一、计算机视觉的实际......
  • 用于图像识别的编程语言,你知道几个?
    图像识别是大多数现代设备和程序中部署的主要功能之一。该功能具有广泛的应用程序,最常见的是安全系统。它可以使设备通过图像来识别人员或物体的身份。而面部识别是图像识别的产物。那么,创建具有图像识别功能的应用程序或软件需要些什么?你只需要使用编程语言对其进行编程。当然,有些......
  • python语言编程能力
    python语言编程能力函数默认参数实例1:classTest(object):defprocess(self,data=[]):data.sort()data.append("end")returndatatest1=Test()print(test1.process())//不会重新创建test2=Test()print(test2.process())//不会重新创......
  • 7.Java 网络编程之 Socket
    Java网络编程之Socket一、课程目标网络模型TCP协议与UDP协议区别Http协议底层实现原理。二、什么是网络模型网络编程的本质是两个设备之间的数据交换,当然,在计算机网络中,设备主要指计算机。数据传递本身没有多大的难度,不就是把一个设备中的数据发送给两外一个设备,然......
  • 14.SpringAOP 编程
    SpringAOP编程课程目标代理设计模式Spring的环境搭建SpringIOC与AOPSpring事物与传播行为一、代理模式1.1概述代理(Proxy)是一种设计模式,提供了对目标对象另外的访问方式;即通过代理访问目标对象。这样好处:可以在目标对象实现的基础上,增强额外的功能操作。(扩......
  • 每日编程一小时(第十天)
    一.问题描述5本新书借给3人,没人最多借一本,有多少种借法二.设计思路1.采用枚举的方法列出所有的选择情况2.利用判定条件删去不符合条件的情况,剩下的全部为符合条件的情况三.流程图 四.代码实现#include<iostream>usingnamespacestd;intmain(){intA,B,C,fl......
  • 变编程一小时2023.4.18
    1.#include<iostream>usingnamespacestd;classShape{ public: virtualdoublearea()const=0;};classCircle:publicShape{ public: Circle(doubler):radius(r) { } virtualdoublearea()const { return3.14159*radius*radius; } protected: dou......