OpenCV-示例-全-
OpenCV 示例(全)
原文:
annas-archive.org/md5/3b2c450033f2ee8cef2d72dbdd34f162译者:飞龙
前言
OpenCV 是用于开发计算机视觉应用中最受欢迎的库之一。它使我们能够在实时中运行许多不同的计算机视觉算法。它已经存在多年,并已成为该领域的标准库。OpenCV 的主要优势之一是它高度优化,几乎在所有平台上都可用。
本书首先简要介绍了计算机视觉领域的各个领域及其相关的 OpenCV C++功能。每一章都包含实际案例和代码示例,以展示用例。这有助于你轻松掌握主题,并了解它们如何在现实生活中应用。总的来说,这是一本关于如何使用 OpenCV C++和利用这个库构建各种应用的实用指南。
本书涵盖内容
第一章, 开始使用 OpenCV,涵盖了在各个操作系统上的安装步骤,并介绍了人眼视觉系统以及计算机视觉中的各种主题。
第二章, OpenCV 基础知识介绍,讨论了如何在 OpenCV 中读取/写入图像和视频,并解释了如何使用 CMake 构建项目。
第三章, 学习图形用户界面和基本过滤,介绍了如何构建图形用户界面和鼠标事件检测器以构建交互式应用程序。
第四章, 深入了解直方图和过滤器,探讨了直方图和过滤器,并展示了如何将图像卡通化。
第五章, 自动光学检测、对象分割和检测,描述了各种图像预处理技术,如噪声去除、阈值和轮廓分析。
第六章, 学习对象分类,涉及对象识别和机器学习,以及如何使用支持向量机构建对象分类系统。
第七章, 检测人脸部分和叠加面具,讨论了人脸检测和 Haar 级联,然后解释了这些方法如何用于检测人脸的各个部分。
第八章, 视频监控、背景建模和形态学操作,探讨了背景减法、视频监控和形态学图像处理,并描述了它们之间是如何相互关联的。
第九章, 学习对象跟踪,介绍了如何使用不同的技术,如基于颜色和基于特征的技术,在实时视频中跟踪对象。
第十章, 为文本识别开发分割算法,涵盖了光学字符识别、文本分割,并介绍了 Tesseract OCR 引擎。
第十一章, 使用 Tesseract 进行文本识别,深入探讨了 Tesseract OCR 引擎,解释了如何使用它进行文本检测、提取和识别。
您需要为此书准备什么
示例是使用以下技术构建的:
-
OpenCV 3.0 或更高版本
-
CMake 3.3.x 或更高版本
-
Tesseract
-
Leptonica(Tesseract 的依赖项)
-
QT(可选)
-
OpenGL(可选)
详细安装说明提供在相关章节中。
适合本书的读者
本书是为那些对 OpenCV 新手且希望使用 C++开发计算机视觉应用程序的开发者而编写的。具备基本的 C++知识将有助于理解本书。本书对那些想开始学习计算机视觉并理解其基本概念的人也很有用。他们应该了解基本的数学概念,如向量、矩阵、矩阵乘法等,以便充分利用本书。在本书的过程中,您将学习如何使用 OpenCV 从头开始构建各种计算机视觉应用程序。
规范
在本书中,您会发现多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码词如下所示:“对于一个基于可执行文件构建的基本项目,一个两行的CMakeLists.txt文件就足够了。”
代码块设置如下:
#include "opencv2/opencv.hpp"
using namespace cv;
int main(int, char** argv)
{
FileStorage fs2("test.yml", FileStorage::READ);
Mat r;
fs2["Result"] >> r;
std::cout << r << std::endl;
fs2.release();
return 0;
}
当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:
@Path("departments")
@Produces(MediaType.APPLICATION_JSON)
public class DepartmentResource{
//Class implementation goes here...
}
任何命令行输入或输出都按如下方式编写:
C:\> setx -m OPENCV_DIR D:\OpenCV\Build\x64\vc11
新术语和重要词汇以粗体显示。屏幕上显示的词,例如在菜单或对话框中,在文本中显示如下:“要显示控制面板,我们可以按最后一个工具栏按钮,在任何部分右键单击QT 窗口并选择显示属性窗口。”
注意
警告或重要注意事项以如下框中显示。
小贴士
小技巧和窍门看起来像这样。
读者反馈
我们欢迎读者的反馈。告诉我们您对这本书的看法——您喜欢什么或可能不喜欢什么。读者反馈对我们来说很重要,以便我们开发出真正对您有帮助的标题。
如要向我们发送一般反馈,请简单地将电子邮件发送到 <feedback@packtpub.com>,并在邮件主题中提及书名。
如果您在某个主题上具有专业知识,并且您对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在,您已经成为 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
运行示例的说明可在每个项目的根目录中存在的README.md文件中找到。
下载本书的彩色图像
我们还为您提供了一个包含本书中使用的截图/图表彩色图像的 PDF 文件。这些彩色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/OpenCV_By_Example_ColorImages.pdf下载此文件。
错误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何错误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击错误提交表单链接,并输入您的错误详情来报告它们。一旦您的错误得到验证,您的提交将被接受,错误将被上传到我们的网站,或添加到该标题的错误部分下的现有错误列表中。任何现有错误都可以通过从www.packtpub.com/support选择您的标题来查看。
盗版
互联网上版权材料的盗版是一个跨所有媒体持续存在的问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上遇到我们作品的任何非法副本,无论形式如何,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供涉嫌盗版材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
问题
如果您在本书的任何方面遇到问题,可以通过<questions@packtpub.com>联系我们,我们将尽力解决。
第一章:OpenCV 入门
计算机视觉应用既有趣又实用,但其底层算法计算量很大。随着云计算的出现,我们获得了更多的处理能力。OpenCV 库使你能够高效地在实时中运行计算机视觉算法。它已经存在很多年了,并且已经成为该领域的标准库。OpenCV 的一个主要优点是它高度优化,几乎在所有平台上都可用。本书中的讨论将涵盖一切,包括我们使用的算法、为什么使用它以及如何在 OpenCV 中实现它。
在本章中,我们将学习如何在各种操作系统上安装 OpenCV。我们将讨论 OpenCV 提供的功能以及我们可以使用内置函数做的各种事情。
到本章结束时,你将能够回答以下问题:
-
人类如何处理视觉数据,以及他们如何理解图像内容?
-
我们可以用 OpenCV 做什么?OpenCV 中有哪些模块可以用来实现这些功能?
-
如何在 Windows、Linux 和 Mac OS X 上安装 OpenCV?
理解人类视觉系统
在我们深入 OpenCV 的功能之前,我们需要了解这些功能最初为何被构建。理解人类视觉系统的工作原理非常重要,这样你才能开发出正确的算法。计算机视觉算法的目标是理解图像和视频的内容。人类似乎能毫不费力地做到这一点!那么,我们如何让机器以同样的精度做到这一点呢?
让我们考虑以下图示:

人眼捕捉所有伴随而来的信息,如颜色、形状、亮度等。在前面的图像中,人眼捕捉了两个主要对象的所有信息,并以某种方式存储。一旦我们理解了我们的系统是如何工作的,我们就可以利用这一点来实现我们想要的东西。例如,以下是一些我们需要了解的事情:
-
我们的视觉系统对低频内容比对高频内容更敏感。低频内容指的是像素值变化不快的平面区域,而高频内容指的是有角和边的区域,像素值波动很大。你可能会注意到,我们很容易在平面表面上看到污点,但在高度纹理的表面上很难找到类似的东西。
-
与颜色变化相比,人眼对亮度变化更敏感。
-
我们的视觉系统对运动敏感。即使我们没有直接看它,我们也能迅速识别在我们视野中移动的东西。
-
我们倾向于在我们的视野中记住显著点。让我们考虑一个白色桌子,它有四条黑色腿,桌面的一个角落有一个红色圆点。当你看这个桌子时,你会立即在脑海中记下表面和腿的颜色相反,并且在桌面的一个角落有一个红色圆点。我们的头脑真的很聪明!我们这样做是自动的,这样我们就可以立即识别它,如果我们再次遇到它。
为了了解我们的视野,让我们看看人类的俯视图以及我们看到各种事物的角度:

我们的大脑视觉系统实际上能够做到很多事情,但这已经足够我们开始了。你可以通过在网上阅读有关人类视觉系统模型的内容来进一步探索。
人类是如何理解图像内容的?
如果你环顾四周,你会看到很多物体。你可能每天都会遇到许多不同的物体,而你几乎可以瞬间识别它们,而不需要任何努力。当你看到一把椅子时,你不需要等待几分钟才意识到它实际上是一把椅子。你立刻就知道它是一把椅子!现在,另一方面,计算机发现这项任务非常困难。研究人员已经多年致力于找出为什么计算机在这方面的表现不如我们。
要回答这个问题,我们需要了解人类是如何做到的。视觉数据处理发生在腹侧视觉通路中。这个腹侧视觉通路指的是我们视觉系统中与物体识别相关联的通路。它基本上是我们大脑中帮助识别物体的区域层次。人类可以毫不费力地识别不同的物体,并且我们可以将相似物体聚在一起。我们可以这样做,因为我们已经发展出对同一类物体的一些不变性。当我们看一个物体时,我们的大脑以这种方式提取显著点,使得方向、大小、透视和照明等因素都不重要。
一个尺寸是正常尺寸两倍且旋转了 45 度的椅子仍然是一把椅子。我们之所以能轻易地识别它,是因为我们处理它的方式。机器不能这么容易地做到这一点。人类倾向于根据物体的形状和重要特征来记住物体。无论物体如何放置,我们仍然可以识别它。在我们的视觉系统中,我们根据位置、比例和视点构建了这些层次不变性,这有助于我们非常稳健。
如果你深入我们的系统,你会看到人类在他们的视觉皮层中有细胞可以响应形状,比如曲线和线条。当我们沿着腹侧流进一步前进时,我们会看到更多复杂的细胞,这些细胞被训练来响应更复杂的目标,比如树木、大门等等。腹侧流中的神经元倾向于显示出感受野大小的增加。这与它们首选刺激的复杂性增加的事实相辅相成。
为什么机器难以理解图像内容?
我们现在了解了视觉数据如何进入人类视觉系统以及我们的系统如何处理它。问题是,我们仍然不完全理解我们的大脑是如何识别和组织这些视觉数据的。我们只是从图像中提取一些特征,并要求计算机使用机器学习算法从这些特征中学习。我们仍然有那些变化,比如形状、大小、视角、角度、光照、遮挡等等。例如,当你从侧面看时,同一把椅子对机器来说看起来非常不同。人类可以很容易地识别出它是一把椅子,无论它以何种方式呈现给我们。那么,我们如何向我们的机器解释这一点呢?
做这件事的一种方法是将一个物体的所有不同变体都存储起来,包括尺寸、角度、视角等等。但这个过程既繁琐又耗时!实际上,也不可能收集到涵盖每一个变体的数据。机器将消耗大量的内存和很多时间来构建一个能够识别这些物体的模型。即使有所有这些,如果一个物体部分被遮挡,计算机仍然无法识别它。这是因为它们认为这是一个新物体。所以,当我们构建计算机视觉库时,我们需要构建可以以多种不同方式组合的底层功能模块,以形成复杂的算法。OpenCV 提供了很多这样的函数,并且它们高度优化。因此,一旦我们了解了 OpenCV 提供的现成功能,我们就可以有效地使用它来构建有趣的应用程序。让我们继续在下一节中探索这个问题。
你可以用 OpenCV 做什么?
使用 OpenCV,你可以几乎完成你所能想到的每一个计算机视觉任务。现实生活中的问题需要你使用许多模块一起工作以达到预期的结果。所以,你只需要了解使用哪些模块和函数来得到你想要的结果。让我们了解 OpenCV 可以提供哪些现成的功能。
内置数据结构和输入/输出
OpenCV 的最好之处之一是它提供了许多内置原语来处理与图像处理和计算机视觉相关的操作。如果你必须从头开始编写,你将需要定义一些东西,例如图像、点、矩形等。这些是几乎所有计算机视觉算法的基础。OpenCV 自带所有这些基本结构,它们包含在core模块中。另一个优点是这些结构已经针对速度和内存进行了优化,因此你不必担心实现细节。
imgcodecs模块处理图像文件的读取和写入。当你对一个输入图像进行操作并创建一个输出图像时,你可以使用简单的命令将其保存为jpg或png文件。当你使用摄像头工作时,你将处理大量的视频文件。videoio模块处理与视频文件输入/输出相关的所有操作。你可以轻松地从网络摄像头捕获视频或以许多不同的格式读取视频文件。你甚至可以通过设置每秒帧数、帧大小等属性将一系列帧保存为视频文件。
图像处理操作
当你编写计算机视觉算法时,你会反复使用许多基本的图像处理操作。这些函数中的大多数都存在于imgproc模块中。你可以进行诸如图像滤波、形态学操作、几何变换、颜色转换、在图像上绘制、直方图、形状分析、运动分析、特征检测等操作。让我们考虑以下图示:

右侧图像是左侧图像的旋转版本。我们可以在 OpenCV 中使用一行代码来完成这种转换。还有一个名为ximgproc的模块,其中包含边缘检测、域变换滤波器、自适应流形滤波器等高级图像处理算法。
构建 GUI
OpenCV 提供了一个名为highgui的模块,用于处理所有高级用户界面操作。假设你正在处理一个问题,并且想在继续下一步之前查看图像的外观。此模块包含用于创建显示图像和/或视频窗口的函数。还有一个等待函数,它将在你按下键盘上的键之前等待,然后才进入下一步。还有一个可以检测鼠标事件的函数。这对于开发交互式应用程序非常有用。使用此功能,你可以在这些输入窗口上绘制矩形,然后根据所选区域继续操作。
考虑以下图像:

如你所见,我们在图像上绘制了一个绿色矩形,并应用了负片效果到该区域。一旦我们得到了这个矩形的坐标,我们就可以只在该区域进行操作。
视频分析
视频分析包括分析视频中连续帧之间的运动、跟踪视频中的不同物体、创建视频监控模型等任务。OpenCV 提供了一个名为video的模块,可以处理所有这些任务。还有一个名为videostab的模块,用于处理视频稳定化。视频稳定化是视频相机的一个重要部分。当你用手持相机拍摄视频时,很难保持双手完全稳定。如果你直接观看这段视频,它看起来会很糟糕,会有抖动。所有现代设备都会使用视频稳定化技术来处理视频,在它们呈现给最终用户之前。
3D 重建
3D 重建是计算机视觉中的一个重要主题。给定一组二维图像,我们可以使用相关算法重建三维场景。OpenCV 提供了可以找到这些二维图像中各种物体之间关系的算法,以计算它们的 3D 位置。我们有一个名为calib3d的模块可以处理所有这些。此模块还可以处理相机标定,这对于估计相机的参数至关重要。这些参数基本上是任何给定相机使用它们的内部参数,它们将这些捕获的场景转换为图像。我们需要知道这些参数来设计算法,否则我们可能会得到意外的结果。让我们考虑以下图示:

如前图所示,同一个物体从多个姿态被捕获。我们的任务是使用这些二维图像重建原始物体。
特征提取
如前所述,人眼视觉系统倾向于从给定的场景中提取显著特征,以便以后可以检索。为了模仿这一点,人们开始设计各种特征提取器,可以从给定的图像中提取这些显著点。一些流行的算法包括SIFT(尺度不变特征变换)、SURF(加速鲁棒特征)、FAST(加速段测试特征)等。有一个名为features2d的模块提供了检测和提取所有这些特征的功能。还有一个名为xfeatures2d的模块提供了更多特征提取器,其中一些仍处于实验阶段。如果你有机会,可以尝试使用这些。还有一个名为bioinspired的模块,提供了生物启发的计算机视觉模型算法。
物体检测
物体检测是指检测给定图像中物体的位置。这个过程不关心物体的类型。如果你设计了一个椅子检测器,它只会告诉你给定图像中椅子的位置。它不会告诉你这是一把高背的红椅子还是一把低背的蓝椅子。检测物体的位置是许多计算机视觉系统中一个非常关键的步骤。考虑以下图像:

如果你在这张图像上运行椅子检测器,它会在所有椅子上放置一个绿色的框。它不会告诉你这是什么类型的椅子!由于需要在各种尺度上进行检测所需的计算量很大,因此对象检测曾经是一个计算密集型任务。为了解决这个问题,保罗·维奥拉和迈克尔·琼斯在 2001 年发表了开创性的论文,提出了一个伟大的算法。你可以在www.cs.cmu.edu/~efros/courses/LBMV07/Papers/viola-cvpr-01.pdf上阅读它。他们提供了一种快速设计任何对象检测器的方法。OpenCV 有名为objdetect和xobjdetect的模块,提供了设计对象检测器的框架。你可以用它来开发用于随机物品(如太阳镜、靴子等)的检测器。
机器学习
计算机视觉使用各种机器学习算法来实现不同的功能。OpenCV 提供了一个名为ml的模块,其中包含许多打包的机器学习算法。其中一些算法包括贝叶斯分类器、K 最近邻、支持向量机、决策树、神经网络等。它还有一个名为flann的模块,其中包含用于在大数据集中进行快速最近邻搜索的算法。机器学习算法被广泛用于构建对象识别、图像分类、人脸检测、视觉搜索等系统的系统。
计算摄影
计算摄影是指使用高级图像处理技术来改善相机捕捉的图像。计算摄影不是关注光学过程和图像捕捉方法,而是使用软件来操纵视觉数据。一些应用包括高动态范围成像、全景图像、图像重光照、光场相机等。
小贴士
下载示例代码
你可以从你购买的所有 Packt 书籍的账户中下载示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。运行示例的说明可在每个项目的根目录中的README.md文件中找到。
让我们看看以下图像:

看看那些鲜艳的颜色!这是一个高动态范围图像的例子,使用传统的图像捕捉技术是无法得到这样的效果的。为了做到这一点,我们必须在多个曝光下捕捉相同的场景,将这些图像相互注册,然后很好地融合它们以创建这张图像。photo和xphoto模块包含各种算法,提供与计算摄影相关的算法。有一个名为stitching的模块,它提供了创建全景图像的算法。
注意事项
前面的图像可以在pixabay.com/en/hdr-high-dynamic-range-landscape-806260/找到。
形状分析
在计算机视觉中,形状的概念至关重要。我们通过识别图像中的各种不同形状来分析视觉数据。这实际上是许多算法中的重要步骤。假设你正在尝试识别图像中的一个特定标志。现在,你知道它可以以各种形状、方向、大小等出现。一个很好的开始方法是量化物体的形状特征。shape模块提供了提取不同形状、测量它们之间相似性、转换物体形状等所需的所有算法。
光流算法
光流算法在视频中用于跟踪连续帧之间的特征。假设你想要在视频中跟踪一个特定的物体。对每一帧运行特征提取器将会计算量很大;因此,这个过程会很慢。所以,你只需要从当前帧中提取特征,然后在后续帧中跟踪这些特征。光流算法在计算机视觉的视频应用中被大量使用。optflow模块包含执行光流所需的多个算法。还有一个名为tracking的模块,其中包含更多可用于跟踪特征的算法。
人脸和物体识别
人脸识别是指识别给定图像中的人。这与在给定图像中识别人脸位置的脸部检测不同。所以,如果你想构建一个实用的生物识别系统,能够识别摄像头前的人,你首先需要运行能够识别人脸位置的人脸检测器,然后运行一个能够识别那个人是谁的人脸识别器。有一个名为face的模块专门处理人脸识别。
如前所述,计算机视觉试图根据人类如何感知视觉数据来建模算法。因此,找到图像中可以帮助不同应用(如物体识别、物体检测和跟踪等)的显著区域和物体将是有帮助的。有一个名为saliency的模块,就是为了这个目的而设计的。它提供检测静态图像和视频中的显著区域的算法。
表面匹配
我们越来越多地与能够捕捉我们周围物体 3D 结构的设备互动。这些设备基本上会捕捉深度信息以及常规的 2D 彩色图像。因此,对我们来说,构建能够理解和处理 3D 物体的算法非常重要。Kinect 是一个捕捉深度信息和视觉数据的设备的良好例子。当前的任务是通过将输入的 3D 物体与数据库中的某个模型进行匹配来识别它。如果我们有一个能够识别和定位物体的系统,那么它可以用于许多不同的应用。有一个名为 surface_matching 的模块,其中包含用于 3D 物体识别和姿态估计算法。
文本检测与识别
在给定的场景中识别文本并识别内容变得越来越重要。一些应用包括车牌识别、自动驾驶汽车的道路标志识别、书籍扫描以数字化内容等。有一个名为 text 的模块,其中包含处理文本检测和识别的各种算法。
安装 OpenCV
让我们看看如何在各种操作系统上让 OpenCV 运行起来。
Windows
为了简化过程,让我们使用预构建库来安装 OpenCV。让我们访问 opencv.org 并下载适用于 Windows 的最新版本。当前版本是 3.0.0,您可以通过访问 OpenCV 主页来获取下载包的最新链接。
在继续之前,您需要确保您有管理员权限。下载的文件将是一个可执行文件,所以只需双击它即可开始安装过程。安装程序会将内容展开到一个文件夹中。您将能够选择安装路径并通过检查文件来验证安装。
完成前一个步骤后,我们需要设置 OpenCV 环境变量并将其添加到系统路径以完成安装。我们将设置一个环境变量,它将保存 OpenCV 库的构建目录。我们将在我们的项目中使用它。打开终端并输入以下命令:
C:\> setx -m OPENCV_DIR D:\OpenCV\Build\x64\vc11
注意
我们假设您有一个安装了 Visual Studio 2012 的 64 位机器。如果您有 Visual Studio 2010,请在之前的命令中将 vc11 替换为 vc10。之前指定的路径是我们将拥有 OpenCV 二进制文件的路径,您将在这个路径中看到两个文件夹,分别称为 lib 和 bin。如果您使用 Visual Studio 2015,您应该能够从头开始编译 OpenCV。
让我们继续将路径添加到系统路径的bin文件夹中。我们需要这样做的原因是,我们将以动态链接库(DLLs)的形式使用 OpenCV 库。基本上,所有 OpenCV 算法都存储在这里,我们的操作系统将在运行时加载它们。为了做到这一点,我们的操作系统需要知道它们的位置。系统的PATH变量将包含一个包含它可以找到 DLLs 的所有文件夹的列表。因此,自然地,我们需要将 OpenCV 库的路径添加到这个列表中。现在,为什么我们需要做所有这些?好吧,另一个选择是将所需的 DLLs 复制到与应用程序可执行文件(.exe文件)相同的文件夹中。这会增加不必要的开销,尤其是在我们处理许多不同项目时。
我们需要编辑PATH变量,以便将其添加到这个文件夹中。您可以使用Path Editor等软件来完成此操作。您可以从patheditor2.codeplex.com下载它。安装后,启动它并添加以下新条目(您可以在路径上右键单击以插入新项目):
%OPENCV_DIR%\bin
继续保存到注册表中。我们完成了!
Mac OS X
在本节中,我们将了解如何在 Mac OS X 上安装 OpenCV。预编译的二进制文件对 Mac OS X 不可用,因此我们需要从头开始编译 OpenCV。在继续之前,我们需要安装 CMake。如果您还没有安装 CMake,您可以从cmake.org/files/v3.3/cmake-3.3.2-Darwin-x86_64.dmg下载它。这是一个dmg文件!所以,一旦下载,只需运行安装程序即可。
从 opencv.org 下载 OpenCV 的最新版本。当前版本是 3.0.0,您可以从github.com/Itseez/opencv/archive/3.0.0.zip下载。
将内容解压缩到您选择的文件夹中。OpenCV 3.0.0 还有一个名为opencv_contrib的新包,其中包含尚未被认为是稳定的用户贡献。需要注意的是,opencv_contrib包中的一些算法对商业用途并不免费。此外,安装此包是可选的。如果您不安装opencv_contrib,OpenCV 也能正常运行。既然我们无论如何都要安装 OpenCV,那么安装这个包以便以后可以实验它(而不是再次经历整个安装过程)是个不错的选择。您可以从github.com/Itseez/opencv_contrib/archive/3.0.0.zip下载它。
将 ZIP 文件的内容解压缩到您选择的文件夹中。为了方便,请将其解压缩到前面提到的同一个文件夹中,这样opencv-3.0.0和opencv_contrib-3.0.0文件夹就位于同一个主文件夹中。
我们现在准备好构建 OpenCV。打开你的终端,导航到解压 OpenCV 3.0.0 内容的文件夹。在命令中替换正确的路径后,运行以下命令:
$ cd /full/path/to/opencv-3.0.0/
$ mkdir build
$ cd build
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/full/path/to/opencv-3.0.0/build -D INSTALL_C_EXAMPLES=ON -D BUILD_EXAMPLES=ON -D OPENCV_EXTRA_MODULES_PATH=/full/path/to/opencv_contrib-3.0.0/modules ../
是时候安装 OpenCV 3.0.0 了。进入 /full/path/to/opencv-3.0.0/build 目录,并在终端上运行以下命令:
$ make -j4
$ make install
在前面的命令中,-j4 标志表示它正在使用四个核心来安装,这样更快!现在,让我们设置库路径。使用 vi ~/.profile 命令在终端中打开你的 ~/.profile 文件,并添加以下行:
export DYLD_LIBRARY_PATH=/full/path/to/opencv-3.0.0/build/lib:$DYLD_LIBRARY_PATH
我们需要将 pkg-config 文件 opencv.pc 复制到 /usr/local/lib/pkgconfig 并命名为 opencv3.pc。这样,如果你已经有一个现有的 OpenCV 2.4.x 安装,将不会有冲突。让我们继续做这件事:
$ cp /full/path/to/opencv-3.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv3.pc
我们还需要更新我们的 PKG_CONFIG_PATH 变量。打开你的 ~/.profile 文件,并添加以下行:
export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/:$PKG_CONFIG_PATH
使用以下命令重新加载你的 ~/.profile 文件:
$ source ~/.profile
我们完成了!让我们看看它是否工作:
$ cd /full/path/to/opencv-3.0.0/samples/cpp
$ g++ -ggdb 'pkg-config --cflags --libs opencv3' opencv_version.cpp -o /tmp/opencv_version && /tmp/opencv_version
如果你看到终端上打印出 Welcome to OpenCV 3.0.0,那么你就准备好了。在这本书中,我们将使用 CMake 来构建我们的 OpenCV 项目。我们将在下一章中更详细地介绍这一点。
Linux
让我们看看如何在 Ubuntu 上安装 OpenCV。在我们开始之前,我们需要安装一些依赖项。让我们使用包管理器在终端上运行以下命令来安装它们:
$ sudo apt-get -y install libopencv-dev build-essential cmake libdc1394-22 libdc1394-22-dev libjpeg-dev libpng12-dev libtiff4-dev libjasper-dev libavcodec-dev libavformat-dev libswscale-dev libxine-dev libgstreamer0.10-dev libgstreamer-plugins-base0.10-dev libv4l-dev libtbb-dev libqt4-dev libmp3lame-dev libopencore-amrnb-dev libopencore-amrwb-dev libtheora-dev libvorbis-dev libxvidcore-dev x264 v4l-utils
现在你已经安装了依赖项,让我们下载、构建和安装 OpenCV:
$ wget "https://github.com/Itseez/opencv/archive/3.0.0.zip" -O opencv.zip
$ wget "https://github.com/Itseez/opencv_contrib/archive/3.0.0.zip" -O opencv_contrib.zip
$ unzip opencv.zip –d .
$ unzip opencv_contrib.zip –d .
$ cd opencv-3.0.0
$ mkdir build
$ cd build
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/full/path/to/opencv-3.0.0/build -D INSTALL_C_EXAMPLES=ON -D BUILD_EXAMPLES=ON -D OPENCV_EXTRA_MODULES_PATH=/full/path/to/opencv_contrib-3.0.0/modules ../
$ make –j4
$ sudo make install
让我们将 pkg-config 文件的 opencv.pc 复制到 /usr/local/lib/pkgconfig 并命名为 opencv3.pc:
$ cp /full/path/to/opencv-3.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv3.pc
我们完成了!现在我们可以使用它从命令行编译我们的 OpenCV 程序。另外,如果你已经有一个现有的 OpenCV 2.4.x 安装,将不会有冲突。让我们检查安装是否正常工作:
$ cd /full/path/to/opencv-3.0.0/samples/cpp
$ g++ -ggdb 'pkg-config --cflags --libs opencv3' opencv_version.cpp -o /tmp/opencv_version && /tmp/opencv_version
如果你看到终端上打印出 Welcome to OpenCV 3.0.0,那么你就准备好了。在接下来的章节中,你将学习如何使用 CMake 来构建你的 OpenCV 项目。
摘要
在本章中,我们学习了如何在各种操作系统上安装 OpenCV。我们讨论了人类视觉系统以及人类如何处理视觉数据。我们理解了为什么机器做同样的事情很困难,以及在设计计算机视觉库时需要考虑什么。我们学习了可以使用 OpenCV 做什么,以及可以使用哪些模块来完成这些任务。
在下一章中,我们将讨论如何操作图像以及我们如何使用各种函数来操纵它们。我们还将学习如何为我们的 OpenCV 应用程序构建项目结构。
第二章。OpenCV 基础知识简介
在 第一章 中介绍了在不同操作系统上安装 OpenCV 后,我们将介绍本章的 OpenCV 开发基础知识。
在本章中,你将学习如何使用 CMake 创建你的项目。
我们还将介绍图像基本数据结构、矩阵和其他在我们项目中需要的结构。
我们将学习如何使用 OpenCV 的 XML/YAML 持久性函数将我们的变量和数据保存到文件中。
在本章中,我们将涵盖以下主题:
-
使用 CMake 配置项目
-
从/到磁盘读取/写入图像
-
阅读视频和访问相机设备
-
主要图像结构(矩阵)
-
其他重要和基本的结构(向量、标量等)
-
基本矩阵操作简介
-
使用 XML/YAML 持久性 OpenCV API 进行文件存储操作
基本 CMake 配置文件
为了配置和检查我们项目的所有必需依赖项,我们将使用 CMake;但这不是强制性的,因此我们可以使用任何其他工具或 IDE(如 Makefiles 或 Visual Studio)来配置我们的项目。然而,CMake 是配置多平台 C++ 项目的最便携方式。
CMake 使用名为 CMakeLists.txt 的配置文件,其中定义了编译和依赖过程。对于一个基于单个源代码文件的可执行文件的基本项目,所需的 CMakeLists.txt 文件只有两行。文件看起来像这样:
cmake_minimum_required (VERSION 2.6)
project (CMakeTest)
add_executable(${PROJECT_NAME} main.cpp)
第一行定义了所需的 CMake 的最低版本。这一行在我们的 CMakeLists.txt 文件中是强制性的,并允许您使用第二行中定义的给定版本的 cmake 功能;它定义了项目名称。此名称保存在一个名为 PROJECT_NAME 的变量中。
最后一行在 main.cpp 文件中创建一个可执行命令(add_executable()),给它与我们的项目相同的名称(${PROJECT_NAME}),并将我们的源代码编译成一个名为 CMakeTest 的可执行文件,我们将它设置为项目名称。
${} 表达式允许访问我们环境中定义的任何变量。然后,我们可以使用 ${PROJECT_NAME} 变量作为可执行文件的输出名称。
创建库
CMake 允许你创建库,这确实是 OpenCV 构建系统所使用的。在软件开发中,将共享代码分解为多个应用程序是一种常见且有用的做法。在大型应用程序或当共享代码在多个应用程序中共享时,这种做法非常有用。
在这种情况下,我们不创建二进制可执行文件;相反,我们创建一个包含所有开发的功能、类等的编译文件。然后,我们可以将这个库文件与其他应用程序共享,而无需共享我们的源代码。
CMake 包含 add_library 函数用于此目的:
# Create our hello library
add_library(Hello hello.cpp hello.h)
# Create our application that uses our new library
add_executable(executable main.cpp)
# Link our executable with the new library
target_link_libraries( executable Hello )
以 # 开头的行添加注释,并被 CMake 忽略。
add_library(Hello hello.cpp hello.h)命令定义了我们新的库,名为Hello,其中Hello是库名,hello.cpp和hello.h是源文件。我们添加头文件是为了允许 IDE(如 Visual Studio)链接到头文件。
这行将根据我们的操作系统或是否是动态库或静态库生成共享文件(对于 OS X 和 Unix 是 So,对于 Windows 是.dll)或静态库(对于 OS X 和 Unix 是 A,对于 Windows 是.dll)。
target_link_libraries(executable Hello)是链接我们的可执行文件到所需库的函数;在我们的例子中,它是Hello库。
管理依赖项
CMake 具有搜索我们的依赖项和外部库的能力,这为我们提供了在项目中构建依赖于外部组件的复杂项目的便利性,并且通过添加一些要求。
在这本书中,最重要的依赖当然是 OpenCV,我们将将其添加到所有我们的项目中:
cmake_minimum_required (VERSION 2.6)
cmake_policy(SET CMP0012 NEW)
PROJECT(Chapter2)
# Requires OpenCV
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
# Show a message with the opencv version detected
MESSAGE("OpenCV version : ${OpenCV_VERSION}")
include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})
# Create a variable called SRC
SET(SRC main.cpp )
# Create our executable
ADD_EXECUTABLE( ${PROJECT_NAME} ${SRC} )
# Link our library
TARGET_LINK_LIBRARIES( ${PROJECT_NAME} ${OpenCV_LIBS} )
现在,让我们了解脚本的运行机制:
cmake_minimum_required (VERSION 2.6)
cmake_policy(SET CMP0012 NEW)
PROJECT(Chapter2)
第一行定义了最低的 CMake 版本;第二行告诉 CMake 使用 CMake 的新行为,以便它可以正确识别不需要解引用变量的数字和布尔常量。这项策略是在 CMake 2.8.0 中引入的,CMake 在策略未设置为 3.0.2 时发出警告。最后,最后一行定义了项目标题:
# Requires OpenCV
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
# Show a message with the opencv version detected
MESSAGE("OpenCV version : ${OpenCV_VERSION}")
include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})
这是我们搜索 OpenCV 依赖项的地方。FIND_PACKAGE是允许我们找到依赖项以及如果该依赖项是必需的或可选的,所需的最低版本的函数。在这个示例脚本中,我们寻找版本 3.0.0 或更高版本的 OpenCV,并且它是一个必需的包。
注意
FIND_PACKAGE命令包含所有 OpenCV 子模块,但你可以通过指定你想要包含在项目中的子模块来使你的应用程序更小、更快。例如,如果我们只打算使用基本的 OpenCV 类型和核心功能,我们可以使用以下命令:
FIND_PACKAGE(OpenCV 3.0.0 REQUIRED core)
如果 CMake 找不到它,它将返回一个错误,但不会阻止我们编译我们的应用程序。
MESSAGE 函数在终端或 CMake GUI 上显示消息。在我们的例子中,我们将显示 OpenCV 版本,如下所示:
OpenCV version : 3.0.0
${OpenCV_VERSION}是一个变量,CMake 在其中存储 OpenCV 包的版本。
include_directories()和link_directories()将指定的库的头文件和目录添加到我们的环境中。OpenCV 的 CMake 模块将这些数据保存在${OpenCV_INCLUDE_DIRS}和${OpenCV_LIB_DIR}变量中。这些行在所有平台(如 Linux)中不是必需的,因为这些路径通常在环境中,但建议你拥有多个 OpenCV 版本,以便从正确的链接和包含目录中进行选择:
# Create a variable called SRC
SET(SRC main.cpp )
# Create our executable
ADD_EXECUTABLE( ${PROJECT_NAME} ${SRC} )
# Link our library
TARGET_LINK_LIBRARIES( ${PROJECT_NAME} ${OpenCV_LIBS} )
这最后一行创建了可执行文件并将其链接到 OpenCV 库,正如我们在上一节“创建库”中看到的。
这段代码中有一个新函数叫做 SET。此函数创建一个新变量并将任何需要的值添加到其中。在我们的例子中,我们将 SRC 变量设置为 main.cpp 的值。然而,我们可以向同一个变量添加更多和更多的值,如本脚本所示:
SET(SRC main.cpp
utils.cpp
color.cpp
)
使脚本更加复杂
在本节中,我们将向您展示一个更复杂的脚本,该脚本包含子文件夹、库和可执行文件,所有这些都在两个文件和几行中,如本脚本所示。
不必创建多个 CMakeLists.txt 文件,因为我们可以在主 CMakeLists.txt 文件中指定所有内容。更常见的是为每个项目子文件夹使用不同的 CMakeLists.txt 文件,这使得它更加灵活和可移植。
此示例有一个代码结构文件夹,包含一个用于 utils 库的文件夹,另一个用于包含主可执行文件的 root 文件夹:
CMakeLists.txt
main.cpp
utils/
CMakeLists.txt
computeTime.cpp
computeTime.h
logger.cpp
logger.h
plotting.cpp
plotting.h
然后,我们需要定义两个 CMakeLists.txt 文件:一个在 root 文件夹中,另一个在 utils 文件夹中。CMakeLists.txt 根文件夹文件的内容如下:
cmake_minimum_required (VERSION 2.6)
project (Chapter2)
# Opencv Package required
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
#Add opencv header files to project
include_directories( ${OpenCV_INCLUDE_DIR} )
link_directories(${OpenCV_LIB_DIR})
add_subdirectory(utils)
# Add optional log with a precompiler definition
option(WITH_LOG "Build with output logs and images in tmp" OFF)
if(WITH_LOG)
add_definitions(-DLOG)
endif(WITH_LOG)
# generate our new executable
add_executable( ${PROJECT_NAME} main.cpp )
# link the project with his dependencies
target_link_libraries( ${PROJECT_NAME} ${OpenCV_LIBS} Utils)
几乎所有行都在前面的章节中描述过,除了我们将在后面的章节中解释的一些函数。
add_subdirectory() 告诉 CMake 分析所需子文件夹的 CMakeLists.txt 文件。
在我们继续解释主 CMakeLists.txt 文件之前,我们将解释 utils CMakeLists.txt 文件。
在 utils 文件夹中的 CMakeLists.txt 文件中,我们将编写一个新的库并将其包含在我们的主项目文件夹中:
# Add new variable for src utils lib
SET(UTILS_LIB_SRC
computeTime.cpp
logger.cpp
plotting.cpp
)
# create our new utils lib
add_library(Utils ${UTILS_LIB_SRC} )
# make sure the compiler can find include files for our library
target_include_directories(Utils PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
此 CMake 脚本文件定义了一个 UTILS_LIB_SRC 变量,我们将所有包含在我们的库中的源文件添加到其中,使用 add_library 函数生成库,并使用 target_include_directories 函数允许我们的主项目检测所有头文件。
跳过 utils 子文件夹,继续使用根 cmake 脚本,Option 函数创建了一个新变量——在我们的例子中是 WITH_LOG,并附有简短描述。此变量可以通过 ccmake 命令行或 CMake GUI 界面进行更改,其中描述和一个复选框出现,允许用户启用或禁用此选项。
此函数非常有用,允许用户决定编译时特性,如启用或禁用日志、编译带有 Java 或 Python 支持(如 OpenCV)等。
在我们的例子中,我们使用此选项在应用程序中启用日志记录器。要启用日志记录器,我们在代码中使用预编译定义:
#ifdef LOG
logi("Number of iteration %d", i);
#endif
要告诉我们的编译器我们需要 LOG 编译时定义,我们在 CMakeLists.txt 中使用 add_definitions(-DLOG) 函数。为了允许用户决定是否启用它,我们只需通过一个简单条件验证 WITH_LOG CMake 变量是否被选中:
if(WITH_LOG)
add_definitions(-DLOG)
endif(WITH_LOG)
现在,我们已经准备好创建我们的 CMake 脚本文件,以便在任何操作系统上编译我们的计算机视觉项目。然后,在开始一个示例项目之前,我们将继续介绍 OpenCV 的基础知识。
图像和矩阵
在计算机视觉中,最重要的结构无疑是图像。计算机视觉中的图像是使用数字设备捕获的物理世界的表示。这幅图只是存储在矩阵格式中的数字序列,如下所示。每个数字是考虑的波长的光强度测量(例如,在彩色图像中的红色、绿色或蓝色)或波长范围(对于全色设备)。图像中的每个点称为像素(对于图像元素),每个像素可以存储一个或多个值,具体取决于它是一个灰度、黑色还是白色图像(也称为二值图像,只存储一个值,如 0 或 1),一个只能存储一个值的灰度级图像,或者一个可以存储三个值的彩色图像。这些值通常是介于 0 到 255 之间的整数,但您也可以使用其他范围。例如,HDRI(高动态范围成像)或热图像中使用的浮点数 0 到 1。

图像以矩阵格式存储,其中每个像素都有一个位置,可以通过列和行的编号来引用。OpenCV 使用Mat类来完成这个目的。对于灰度图像,使用单个矩阵,如下图所示:

在彩色图像的情况下,如下图中所示,我们使用一个大小为宽度 x 高度 x 颜色数量的矩阵:

Mat类不仅用于存储图像,还用于存储不同类型的不定大小矩阵。您可以用它作为代数矩阵并对其进行操作。在下一节中,我们将描述最重要的矩阵操作,如加法、矩阵乘法、创建对角矩阵等。
然而,在这样做之前,了解矩阵在计算机内存中的内部存储方式是很重要的,因为始终更有效地访问内存槽而不是使用 OpenCV 函数访问每个像素会更好。
在内存中,矩阵以列和行的顺序保存为一个数组或值序列。以下表格显示了 BGR 图像格式中像素的顺序:
| 行 0 | 行 1 | 行 2 |
|---|---|---|
| 列 0 | 列 1 | 列 2 |
| 像素 1 | 像素 2 | 像素 3 |
| B | G | R |
按照这个顺序,我们可以访问任何像素,如下面的公式所示:
Value= Row_i*num_cols*num_channels + Col_i + channel_i
注意
OpenCV 函数在随机访问方面进行了相当优化,但有时直接访问内存(使用指针算术)更有效率——例如,当我们在一个循环中访问所有像素时。
读取/写入图像
在介绍这个矩阵之后,我们将开始介绍 OpenCV 代码的基础。首先,我们需要学习如何读取和写入图像:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
// OpenCV includes
#include "opencv2/core.hpp"
#include "opencv2/highgui.hpp"
using namespace cv;
int main( int argc, const char** argv )
{
// Read images
Mat color= imread("../lena.jpg");
Mat gray= imread("../lena.jpg", 0);
// Write images
imwrite("lenaGray.jpg", gray);
// Get same pixel with opencv function
int myRow=color.cols-1;
int myCol=color.rows-1;
Vec3b pixel= color.at<Vec3b>(myRow, myCol);
cout << "Pixel value (B,G,R): (" << (int)pixel[0] << "," << (int)pixel[1] << "," << (int)pixel[2] << ")" << endl;
// show images
imshow("Lena BGR", color);
imshow("Lena Gray", gray);
// wait for any key press
waitKey(0);
return 0;
}
让我们尝试理解这段代码:
// OpenCV includes
#include "opencv2/core.hpp"
#include "opencv2/highgui.hpp"
using namespace cv;
首先,我们必须在我们的示例中包含所需函数的声明。这些函数来自核心(基本图像数据处理)和高 GUI(OpenCV 提供的跨平台 I/O 函数是core和highui。第一个包括基本类,如矩阵,第二个包括读取、写入和通过图形界面显示图像的函数)。
// Read images
Mat color= imread("../lena.jpg");
Mat gray= imread("../lena.jpg", 0);
imread是用于读取图像的主要函数。此函数打开图像并将图像存储在矩阵格式中。imread函数接受两个参数:第一个参数是一个包含图像路径的字符串,第二个参数是可选的,默认情况下以彩色图像加载图像。第二个参数允许以下选项:
-
CV_LOAD_IMAGE_ANYDEPTH:如果设置为这个常量,当输入具有相应的深度时返回 16 位/32 位图像;否则,imread函数将其转换为 8 位图像 -
CV_LOAD_IMAGE_COLOR:如果设置为这个常量,总是将图像转换为彩色 -
CV_LOAD_IMAGE_GRAYSCALE:如果设置为这个常量,总是将图像转换为灰度
要保存图像,我们可以使用imwrite函数,该函数将矩阵图像存储在我们的计算机中:
// Write images
imwrite("lenaGray.jpg", gray);
第一个参数是我们想要保存图像的路径,其中包含我们想要的扩展格式。第二个参数是我们想要保存的矩阵图像。在我们的代码示例中,我们创建并存储图像的灰度版本,然后将其保存为 jpg 文件,该灰度图像是我们加载并存储在gray变量中的图像:
// Get same pixel with opencv function
int myRow=color.cols-1;
int myCol=color.rows-1;
使用矩阵的.cols和.rows属性,我们可以访问图像的列数和行数——换句话说,宽度和高度:
Vec3b pixel= color.at<Vec3b>(myRow, myCol);
cout << "Pixel value (B,G,R): (" << (int)pixel[0] << "," << (int)pixel[1] << "," << (int)pixel[2] << ")" << endl;
要访问图像中的一个像素,我们使用cv::Mat::at<typename t>(row,col)模板函数,该函数来自Mat OpenCV 类。模板参数是期望的返回类型。在 8 位彩色图像中,typename是一个存储三个无符号字符数据的Vec3b类(Vec=vector,3=组件数量,b = 1 字节)。对于灰度图像,我们可以直接使用无符号字符或任何其他在图像中使用的数字格式,例如uchar pixel= color.at<uchar>(myRow, myCol):
// show images
imshow("Lena BGR", color);
imshow("Lena Gray", gray);
// wait for any key press
waitKey(0);
最后,为了显示图像,我们可以使用imshow函数,该函数创建一个窗口,第一个参数是标题,第二个参数是图像矩阵。
注意
如果我们想通过等待用户按键来停止我们的应用程序,我们可以使用 OpenCV 的waitKey函数,并将参数设置为我们要等待的毫秒数。如果我们设置参数为0,那么函数将永远等待。
以下图片显示了此代码的结果;左侧图像是彩色图像,右侧图像是灰度图像:

最后,作为以下示例的例子,我们将创建CMakeLists.txt以允许您编译我们的项目,并了解如何编译它。
以下代码描述了CMakeLists.txt文件:
cmake_minimum_required (VERSION 2.6)
cmake_policy(SET CMP0012 NEW)
PROJECT(project)
# Requires OpenCV
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
MESSAGE("OpenCV version : ${OpenCV_VERSION}")
include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})
ADD_EXECUTABLE( sample main.cpp )
TARGET_LINK_LIBRARIES( sample ${OpenCV_LIBS} )
要编译我们的代码,使用CMakeLists.txt文件,我们必须执行以下步骤:
-
创建一个
build文件夹。 -
在
build文件夹内,执行cmake或在 Windows 中打开CMake gui应用程序,选择源文件夹和构建文件夹,然后点击配置和生成按钮。 -
在步骤 2 之后,如果我们处于 Linux 或 OS,生成一个
makefile;然后我们必须使用 make 命令编译项目。如果我们处于 Windows,我们必须使用步骤 2 中选择的编辑器打开项目并编译它。 -
在步骤 3 之后,我们有一个名为
app的可执行文件。
读取视频和摄像头
本节通过这个简单的示例向您介绍如何读取视频和摄像头:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
// OpenCV includes
#include "opencv2/core.hpp"
#include "opencv2/highgui.hpp"
using namespace cv;
// OpenCV command line parser functions
// Keys accecpted by command line parser
const char* keys =
{
"{help h usage ? | | print this message}"
"{@video | | Video file, if not defined try to use webcamera}"
};
int main( int argc, const char** argv )
{
CommandLineParser parser(argc, argv, keys);
parser.about("Chapter 2\. v1.0.0");
//If requires help show
if (parser.has("help"))
{
parser.printMessage();
return 0;
}
String videoFile= parser.get<String>(0);
// Check if params are correctly parsed in his variables
if (!parser.check())
{
parser.printErrors();
return 0;
}
VideoCapture cap; // open the default camera
if(videoFile != "")
cap.open(videoFile);
else
cap.open(0);
if(!cap.isOpened()) // check if we succeeded
return -1;
namedWindow("Video",1);
for(;;)
{
Mat frame;
cap >> frame; // get a new frame from camera
imshow("Video", frame);
if(waitKey(30) >= 0) break;
}
// Release the camera or video cap
cap.release();
return 0;
}
在解释如何读取视频或摄像头输入之前,我们需要介绍一个有用的新类,它将帮助我们管理输入命令行参数;这个新类是在 OpenCV 3.0 版本中引入的,称为CommandLineParser类:
// OpenCV command line parser functions
// Keys accepted by command line parser
const char* keys =
{
"{help h usage ? | | print this message}"
"{@video | | Video file, if not defined try to use webcamera}"
};
对于命令行解析器,我们必须首先在常量字符向量中定义我们需要的或允许的参数;每一行都有这种模式:
{ name_param | default_value | description}
name_param可以前面加上@,这定义了这个参数为默认输入。我们可以使用多个name_param:
CommandLineParser parser(argc, argv, keys);
构造函数将获取主函数的输入和之前定义的关键常量:
//If requires help show
if (parser.has("help"))
{
parser.printMessage();
return 0;
}
.has类方法检查参数是否存在。在这个示例中,我们检查用户是否添加了–help或? 参数,然后,使用printMessage类函数显示所有描述参数:
String videoFile= parser.get<String>(0);
使用.get<typename>(parameterName)函数,我们可以访问和读取任何输入参数:
// Check if params are correctly parsed in his variables
if (!parser.check())
{
parser.printErrors();
return 0;
}
在获取所有必需的参数后,我们可以检查这些参数是否正确解析,如果其中一个参数没有解析,则显示错误消息。例如,添加一个字符串而不是一个数字:
VideoCapture cap; // open the default camera
if(videoFile != "")
cap.open(videoFile);
else
cap.open(0);
if(!cap.isOpened()) // check if we succeeded
return -1;
读取视频和摄像头的类是相同的。VideoCapture 类属于 videoio 子模型,而不是 highgui 子模块,如 OpenCV 的早期版本。创建对象后,我们检查输入命令行 videoFile 参数是否有路径文件名。如果为空,则尝试打开网络摄像头;如果有文件名,则打开视频文件。为此,我们使用 open 函数,将视频文件名或要打开的摄像头索引作为参数。如果我们只有一个摄像头,我们可以使用 0 作为参数。
要检查我们是否可以读取视频文件名或摄像头,我们使用 isOpened 函数:
namedWindow("Video",1);
for(;;)
{
Mat frame;
cap >> frame; // get a new frame from camera
if(frame)
imshow("Video", frame);
if(waitKey(30) >= 0) break;
}
// Release the camera or video cap
cap.release();
最后,我们使用 namedWindow 函数创建一个窗口来显示帧,并通过非结束循环,使用 >> 操作符抓取每一帧,如果正确检索到帧,则使用 imshow 函数显示图像。在这种情况下,我们不想停止应用程序,但想等待 30 毫秒,以检查用户是否想通过按任意键停止应用程序执行,使用 waitKey(30)。
注意
为了选择一个合适的值等待下一帧,使用摄像头访问速度计算。例如,如果摄像头以 20 FPS 的工作,一个很好的等待值是 40 = 1000/20。
当用户想要结束应用程序时,他只需要按下一个键,然后我们必须使用 release 函数释放所有视频资源。
注意
在计算机视觉应用程序中释放我们使用的所有资源非常重要;如果我们不这样做,我们可能会消耗所有 RAM 内存。我们可以使用 release 函数释放矩阵。
代码的结果是一个新窗口,显示 BGR 格式的视频或网络摄像头,如下截图所示:

其他基本对象类型
我们已经学习了 Mat 和 Vec3b 类,但我们需要学习其他类。
在本节中,我们将学习在大多数项目中都需要的最基本的对象类型:
-
Vec -
Scalar -
Point -
Size -
Rect -
RotatedRect
vec 对象类型
vec 是一个模板类,主要用于数值向量。我们可以定义任何类型的向量以及组件数量:
Vec<double,19> myVector;
或者我们可以使用任何预定义的类型:
typedef Vec<uchar, 2> Vec2b;
typedef Vec<uchar, 3> Vec3b;
typedef Vec<uchar, 4> Vec4b;
typedef Vec<short, 2> Vec2s;
typedef Vec<short, 3> Vec3s;
typedef Vec<short, 4> Vec4s;
typedef Vec<int, 2> Vec2i;
typedef Vec<int, 3> Vec3i;
typedef Vec<int, 4> Vec4i;
typedef Vec<float, 2> Vec2f;
typedef Vec<float, 3> Vec3f;
typedef Vec<float, 4> Vec4f;
typedef Vec<float, 6> Vec6f;
typedef Vec<double, 2> Vec2d;
typedef Vec<double, 3> Vec3d;
typedef Vec<double, 4> Vec4d;
typedef Vec<double, 6> Vec6d;
注意
所有预期的向量操作也都已实现,如下所示:
v1 = v2 + v3
v1 = v2 - v3
v1 = v2 * scale
v1 = scale * v2
v1 = -v2
v1 += v2 和其他增加操作
v1 == v2, v1 != v2
norm(v1) (欧几里得范数)
标量对象类型
Scalar 对象类型是从 Vec 派生出的模板类,具有四个元素。Scalar 类型在 OpenCV 中广泛用于传递和读取像素值。
要访问 Vec 和 Scalar 的值,我们使用 [] 操作符。
点对象类型
另一个非常常见的类模板是 Point。这个类定义了一个由其 x 和 y 坐标指定的 2D 点。
注意
与 Point 对象类型类似,还有一个 Point3 模板类用于支持 3D 点。
与 Vec 类类似,OpenCV 定义了以下 Point 别名以方便我们使用:
typedef Point_<int> Point2i;
typedef Point2i Point;
typedef Point_<float> Point2f;
typedef Point_<double> Point2d;
注意
为点定义了以下运算符:
pt1 = pt2 + pt3;
pt1 = pt2 - pt3;
pt1 = pt2 * a;
pt1 = a * pt2;
pt1 = pt2 / a;
pt1 += pt2;
pt1 -= pt2;
pt1 *= a;
pt1 /= a;
double value = norm(pt); // L2 norm
pt1 == pt2;
pt1 != pt2;
Size 对象类型
另一个在 OpenCV 中非常重要且常用的 template 类是用于指定图像或矩形大小的 Size 类。该类添加了两个成员:宽度和高度以及有用的 area() 函数。
Rect 对象类型
Rect 是另一个重要的模板类,用于通过以下参数定义 2D 矩形:
-
顶左角坐标
-
矩形的宽度和高度
Rect 模板类可用于定义图像的 ROI(感兴趣区域)。
RotatedRect 对象类型
最后一个有用的类是特定矩形 RotatedRect。该类表示一个由中心点、矩形的宽度和高度以及旋转角度(以度为单位)指定的旋转矩形:
RotatedRect(const Point2f& center, const Size2f& size, float angle);
该类的一个有趣功能是 boundingBox;此函数返回一个包含旋转矩形的 Rect:

基本矩阵运算
在本节中,我们将学习一些基本且重要的矩阵运算,这些运算可以应用于图像或任何矩阵数据。
我们学习了如何加载图像并将其存储在 Mat 变量中,但我们可以手动创建一个 Mat 变量。最常见的构造函数是提供矩阵大小和类型的如下:
Mat a= Mat(Size(5,5), CV_32F);
注意
您可以使用以下构造函数从第三方库的存储缓冲区创建一个新的 Matrix link,而不复制数据:
Mat(size, type, pointer_to_buffer)
支持的类型取决于您想要存储的数字类型和通道数。最常见的类型如下:
CV_8UC1
CV_8UC3
CV_8UC4
CV_32FC1
CV_32FC3
CV_32FC4
注意
您可以使用 CV_number_typeC(n) 创建任何类型的矩阵,其中 number_type 是 8U(8 位无符号)到 64F(64 位浮点)之间,而 (n) 是通道数。允许的通道数从 1 到 CV_CN_MAX。
此初始化没有设置数据值,您可能会得到不希望的结果。为了避免不希望的结果,您可以使用 zeros 或 ones 函数用 zeros 或 ones 值初始化矩阵:
Mat mz= Mat::zeros(5,5, CV_32F);
Mat mo= Mat::ones(5,5, CV_32F);
之前矩阵的输出如下:

特殊的矩阵初始化是 eye 函数,该函数创建一个指定类型(CV_8UC1, CV_8UC3...)和大小的 identity 矩阵:
Mat m= Mat::eye(5,5, CV_32F);
输出如下:

OpenCV Mat 类允许所有矩阵运算。我们可以使用 + 和 - 运算符添加或减去两个矩阵:
Mat a= Mat::eye(Size(3,2), CV_32F);
Mat b= Mat::ones(Size(3,2), CV_32F);
Mat c= a+b;
Mat d= a-b;
之前操作的结果如下:

我们可以使用 * 运算符将矩阵乘以一个标量,使用 mul 函数将矩阵乘以每个元素,或者使用 * 运算符进行矩阵与矩阵的乘法:
Mat m1= Mat::eye(2,3, CV_32F);
Mat m2= Mat::ones(3,2, CV_32F);
// Scalar by matrix
cout << "\nm1.*2\n" << m1*2 << endl;
// matrix per element multiplication
cout << "\n(m1+2).*(m1+3)\n" << (m1+1).mul(m1+3) << endl;
// Matrix multiplication
cout << "\nm1*m2\n" << m1*m2 << endl;
之前操作的结果如下:

另外常见的数学矩阵运算包括转置和矩阵求逆,分别由t()和inv()函数定义。
OpenCV 为我们提供的其他有趣的功能是矩阵中的数组操作;例如,计算非零元素的数量。这有助于计算对象的像素或面积:
int countNonZero( src );
OpenCV 提供了一些统计函数。可以使用meanStdDev函数计算每个通道的平均值和标准差:
meanStdDev(src, mean, stddev);
另一个有用的统计函数是minMaxLoc。此函数查找矩阵或数组的最小值和最大值,并返回其位置和值:
minMaxLoc(src, minVal, maxVal, minLoc, maxLoc);
在这里,src是输入矩阵,minVal和maxVal是检测到的双精度值,而minLoc和maxLoc是检测到的点值。
注意
其他核心和有用的函数在docs.opencv.org/modules/core/doc/core.html中详细描述。
基本数据持久化和存储
在我们完成本章之前,我们将探索 OpenCV 的存储和读取数据的功能。在许多应用程序中,例如校准或机器学习,当我们完成计算后,我们需要保存结果以便在下次执行中检索。为此,OpenCV 提供了一个 XML/YAML 持久化层。
写入文件存储
要使用一些 OpenCV 数据或其他数值数据写入文件,我们可以使用FileStorage类,并使用如 STL 流之类的流操作符:
#include "opencv2/opencv.hpp"
using namespace cv;
int main(int, char** argv)
{
// create our writter
FileStorage fs("test.yml", FileStorage::WRITE);
// Save an int
int fps= 5;
fs << "fps" << fps;
// Create some mat sample
Mat m1= Mat::eye(2,3, CV_32F);
Mat m2= Mat::ones(3,2, CV_32F);
Mat result= (m1+1).mul(m1+3);
// write the result
fs << "Result" << result;
// release the file
fs.release();
FileStorage fs2("test.yml", FileStorage::READ);
Mat r;
fs2["Result"] >> r;
std::cout << r << std::endl;
fs2.release();
return 0;
}
要创建一个用于保存数据的文件存储,我们只需要通过提供一个带有所需扩展格式(XML 或 YAML)的路径文件名并设置第二个参数为write来调用构造函数:
FileStorage fs("test.yml", FileStorage::WRITE);
如果我们想要保存数据,我们只需要使用流操作符,在第一阶段提供一个标识符,在后续阶段提供我们想要保存的矩阵或值。例如,要保存一个int,我们需要编写以下代码:
int fps= 5;
fs << "fps" << fps;
一个mat如下:
Mat m1= Mat::eye(2,3, CV_32F);
Mat m2= Mat::ones(3,2, CV_32F);
Mat result= (m1+1).mul(m1+3);
// write the result
fs << "Result" << result;
上述代码的结果是 YAML 格式,如下所示:
%YAML:1.0
fps: 5
Result: !!opencv-matrix
rows: 2
cols: 3
dt: f
data: [ 8., 3., 3., 3., 8., 3\. ]
从之前保存的文件中读取与保存函数非常相似:
#include "opencv2/opencv.hpp"
using namespace cv;
int main(int, char** argv)
{
FileStorage fs2("test.yml", FileStorage::READ);
Mat r;
fs2["Result"] >> r;
std::cout << r << std::endl;
fs2.release();
return 0;
}
首先,我们必须使用FileStorage构造函数和适当的路径以及FileStorage::READ参数来打开一个已保存的文件:
FileStorage fs2("test.yml", FileStorage::READ);
要读取任何已存储的变量,我们只需要使用我们的FileStorage对象和带有[]操作符的标识符的常见>>流操作符:
Mat r;
fs2["Result"] >> r;
摘要
在本章中,我们学习了如何访问图像和视频以及它们如何在矩阵中存储的基础知识。
我们学习了基本的矩阵运算和其他基本的 OpenCV 类,用于存储像素、向量等。
最后,我们学习了如何将数据保存到文件中,以便在其他应用程序或执行中读取。
在下一章中,我们将学习如何通过学习 OpenCV 提供的图形用户界面的基础知识来创建我们的第一个应用程序。我们将创建按钮和滑块,并介绍一些图像处理的基本知识。
第三章:学习图形用户界面和基本滤波
在上一章中,我们学习了 OpenCV 的基本类和结构,以及最重要的类Mat。
我们学习了如何读取和保存图片、视频以及图片在内存中的内部结构。
我们现在可以开始工作了,但我们需要展示我们的结果,并与我们的图片进行一些基本的交互。OpenCV 为我们提供了一些基本的用户界面来工作,并帮助我们创建应用程序和原型。
为了更好地理解用户界面是如何工作的,我们将在本章末尾创建一个名为PhotoTool的小应用程序。在这个应用程序中,我们将学习如何使用滤波器和颜色转换。
在本章中,我们将涵盖以下主题:
-
OpenCV 的基本用户界面
-
OpenCV 的 QT 界面
-
滑块和按钮
-
一个高级用户界面——OpenGL
-
颜色转换
-
基本过滤器
介绍 OpenCV 用户界面
OpenCV 拥有自己的跨操作系统用户界面,允许开发者创建自己的应用程序,无需学习复杂的用户界面库。
OpenCV 的用户界面是基本的,但它为计算机视觉开发者提供了创建和管理软件开发的基本功能。所有这些功能都是本地的,并且针对实时使用进行了优化。
OpenCV 为用户界面提供了两种选项:
-
基于本地用户界面(如 OS X 的 Cocoa 或 Carbon,Linux 或 Windows 的 GTK)的基本界面,这些界面在编译 OpenCV 时默认选择。
-
一个基于 QT 库的稍微高级一点的界面,它是跨平台的。在编译 OpenCV 之前,您必须手动在 CMake 中启用 QT 选项。
![介绍 OpenCV 用户界面]()
使用 OpenCV 的基本图形用户界面
我们将使用 OpenCV 创建一个基本的用户界面。OpenCV 用户界面允许我们创建窗口,向其中添加图片,移动它,调整大小,以及销毁它。
用户界面位于 OpenCV 的highui模块中:
#include <iostream>
#include <string>
#include <sstream>
using namespace std;
// OpenCV includes
#include "opencv2/core.hpp"
#include "opencv2/highgui.hpp"
using namespace cv;
const int CV_GUI_NORMAL= 0x10;
int main( int argc, const char** argv )
{
// Read images
Mat lena= imread("../lena.jpg");
Mat photo= imread("../photo.jpg");
// Create windows
namedWindow("Lena", CV_GUI_NORMAL);
namedWindow("Photo", WINDOW_AUTOSIZE);
// Move window
moveWindow("Lena", 10, 10);
moveWindow("Photo", 520, 10);
// show images
imshow("Lena", lena);
imshow("Photo", photo);
// Resize window, only non autosize
resizeWindow("Lena", 512, 512);
// wait for any key press
waitKey(0);
// Destroy the windows
destroyWindow("Lena");
destroyWindow("Photo");
// Create 10 windows
for(int i =0; i< 10; i++)
{
ostringstream ss;
ss << "Photo " << i;
namedWindow(ss.str());
moveWindow(ss.str(), 20*i, 20*i);
imshow(ss.str(), photo);
}
waitKey(0);
// Destroy all windows
destroyAllWindows();
return 0;
}
让我们理解代码。
为了启用图形用户界面,我们需要执行的第一项任务是导入 OpenCV 的highui模块:
#include "opencv2/highgui.hpp"
现在,我们已经准备好创建我们的新窗口,然后我们需要加载一些要显示的图片:
// Read images
Mat lena= imread("../lena.jpg");
Mat photo= imread("../photo.jpg");
为了创建窗口,我们使用namedWindow函数。此函数有两个参数:第一个参数是一个常量字符串,包含窗口的名称,第二个参数是我们需要的标志,这是可选的:
namedWindow("Lena", CV_GUI_NORMAL);
namedWindow("Photo", WINDOW_AUTOSIZE);
在我们的例子中,我们创建了两个窗口:第一个窗口被称作Lena,第二个被称作Photo。
默认情况下,有三个用于 QT 和本地界面的标志:
-
WINDOW_NORMAL:此标志允许用户调整窗口大小 -
WINDOW_AUTOSIZE:如果设置了此标志,窗口大小将自动调整以适应显示的图片,并且无法调整窗口大小 -
WINDOW_OPENGL:此标志启用 OpenGL 支持
QT 有一些其他标志,如下所示:
-
WINDOW_FREERATIO或WINDOW_KEEPRATIO:如果设置为WINDOW_FREERATIO,则图像调整时不考虑其比例。如果设置为WINDOW_FREERATIO,则图像调整时考虑其比例。 -
CV_GUI_NORMAL或CV_GUI_EXPANDED:第一个标志启用不带状态栏和工具栏的基本界面。第二个标志启用带有状态栏和工具栏的最先进图形用户界面。
注意
如果我们使用 QT 编译 OpenCV,则我们创建的所有窗口默认情况下都在扩展界面中,但我们可以通过添加 CV_GUI_NORMAL 标志来使用原生和更基本的界面。
默认情况下,标志是 WINDOW_AUTOSIZE、WINDOW_KEEPRATIO 和 CV_GUI_EXPANDED。
当我们创建多个窗口时,它们一个叠一个,但我们可以使用 moveWindow 函数将窗口移动到桌面上的任何区域:
// Move window
moveWindow("Lena", 10, 10);
moveWindow("Photo", 520, 10);
在我们的代码中,我们将 Lena 窗口向左移动 10 像素,向上移动 10 像素;将 Photo 窗口向左移动 520 像素,向上移动 10 像素:
// show images
imshow("Lena", lena);
imshow("Photo", photo);
// Resize window, only non autosize
resizeWindow("Lena", 512, 512);
在使用 imshow 函数显示我们之前加载的图像后,我们调用 resizeWindow 函数将 Lena 窗口调整为 512 像素。此函数有三个参数:窗口名称、宽度和高度。
注意
具体的窗口大小是针对图像区域的。工具栏不计入。只有未启用 WINDOW_AUTOSIZE 标志的窗口可以调整大小。
使用 waitKey 函数等待按键后,我们将使用 destroyWindow 函数删除或删除我们的窗口,其中窗口名称是唯一必需的参数:
waitKey(0);
// Destroy the windows
destroyWindow("Lena");
destroyWindow("Photo");
OpenCV 有一个函数,用于在一次调用中删除我们创建的所有窗口。该函数名为 destroyAllWindows。为了展示此函数的工作原理,在我们的示例中我们创建了 10 个窗口并等待按键。当用户按下任意键时,我们销毁所有窗口。无论如何,OpenCV 在应用程序终止时自动处理所有窗口的销毁,因此不需要在应用程序末尾调用此函数:
// Create 10 windows
for(int i =0; i< 10; i++)
{
ostringstream ss;
ss << "Photo " << i;
namedWindow(ss.str());
moveWindow(ss.str(), 20*i, 20*i);
imshow(ss.str(), photo);
}
waitKey(0);
// Destroy all windows
destroyAllWindows();
此代码的结果可以在以下图像中分两步看到。第一张图像显示两个窗口:

按下任意键后,应用程序继续并通过改变位置绘制几个窗口:

基于 QT 的图形用户界面
QT 用户界面为我们提供了更多控制和选项来处理我们的图像。
界面分为三个主要区域:
-
工具栏
-
图像区域
-
状态栏
![使用 QT 的图形用户界面]()
工具栏从左到右有以下按钮:
-
四个用于平移的按钮
-
放大 x1
-
放大 x30 并显示标签
-
放大
-
缩小
-
保存当前图像
-
显示属性窗口
这些选项在下面的截图中可以更清楚地看到:

当我们在图像上按下右鼠标按钮时,图像区域显示一个图像和一个上下文菜单。此区域可以使用 displayOverlay 函数在区域的顶部显示叠加消息。此函数接受三个参数:窗口名称、我们想要显示的文本以及叠加文本显示的毫秒数。如果时间设置为 0,则文本永远不会消失:
// Display Overlay
displayOverlay("Lena", "Overlay 5secs", 5000);

最后,状态栏显示窗口的底部部分、像素值和图像中的坐标位置:

我们可以使用状态栏来显示消息,例如叠加。可以更改状态栏消息的函数是 displayStatusBar。此函数具有与叠加函数相同的参数:窗口名称、要显示的文本以及显示它的持续时间:

将滑块和鼠标事件添加到我们的界面中
鼠标事件和滑块控制在计算机视觉和 OpenCV 中非常有用。使用这些控件,用户可以直接与界面交互,并更改其输入图像或变量的属性。
在本节中,我们将向您介绍添加滑块和鼠标事件以进行基本交互的概念。为了正确理解这一点,我们将创建一个小项目,在该项目中,我们使用鼠标事件在图像中绘制绿色圆圈,并使用滑块模糊图像:
// Create a variable to save the position value in track
int blurAmount=15;
// Trackbar call back function
static void onChange(int pos, void* userInput);
//Mouse callback
static void onMouse( int event, int x, int y, int, void* userInput );
int main( int argc, const char** argv )
{
// Read images
Mat lena= imread("../lena.jpg");
// Create windows
namedWindow("Lena");
// create a trackbark
createTrackbar("Lena", "Lena", &blurAmount, 30, onChange, &lena);
setMouseCallback("Lena", onMouse, &lena);
// Call to onChange to init
onChange(blurAmount, &lena);
// wait app for a key to exit
waitKey(0);
// Destroy the windows
destroyWindow("Lena");
return 0;
}
让我们理解一下代码!
首先,我们创建一个变量来保存滑块位置,然后我们需要保存滑块位置以便其他函数访问:
// Create a variable to save the position value in track
int blurAmount=15;
现在,我们定义我们的滑块和鼠标事件回调,这些事件对于 OpenCV 的 setMouseCallback 和 createTrackbar 函数是必需的:
// Trackbar call back function
static void onChange(int pos, void* userInput);
//Mouse callback
static void onMouse( int event, int x, int y, int, void* userInput );
在主函数中,我们加载一个图像并创建一个名为 Lena 的新窗口:
int main( int argc, const char** argv )
{
// Read images
Mat lena= imread("../lena.jpg");
// Create windows
namedWindow("Lena");
是时候创建滑块了。OpenCV 有一个 createTrackbar 函数,用于生成具有以下参数的滑块:
-
轨迹条名称。
-
窗口名称。
-
一个整数指针,用作值;此参数是可选的。如果设置指针值,则滑块在创建时获取此位置。
-
滑块上的最大位置。
-
滑块位置改变时的回调函数。
-
要发送到回调的用户数据。它可以用来向回调发送数据,而无需使用全局变量:
// create a trackbark createTrackbar("Lena", "Lena", &blurAmount, 30, onChange, &lena);
创建滑块后,我们添加允许用户在按下左鼠标按钮时绘制圆圈的鼠标事件。OpenCV 有一个 setMouseCallback 函数。此函数有三个参数,如下所示:
-
获取鼠标事件的窗口名称
-
当有鼠标交互时被调用的
callback函数。 -
用户数据指的是当它被触发时将发送到回调函数的任何数据。在我们的例子中,我们将发送整个
Lena图像:setMouseCallback("Lena", onMouse, &lena);
为了最终化 main 函数,我们只需要使用与滑块相同的参数初始化图像。为了执行初始化,我们只需要手动调用 callback 函数并等待事件,在我们关闭窗口之前:
// Call to onChange to init
onChange(blurAmount, &lena);
// wait app for a key to exit
waitKey(0);
// Destroy the windows
destroyWindow("Lena");
滑块回调函数使用滑块值作为模糊量对图像应用基本的模糊滤镜:
// Trackbar call back function
static void onChange(int pos, void* userData)
{
if(pos <= 0)
return;
// Aux variable for result
Mat imgBlur;
// Get the pointer input image
Mat* img= (Mat*)userInput;
// Apply a blur filter
blur(*img, imgBlur, Size(pos, pos));
// Show the result
imshow("Lena", imgBlur);
}
此函数使用 pos 变量检查滑块值是否为 0;在这种情况下,我们不应用滤镜,因为它会产生不良的执行效果。我们不能应用 0 像素的模糊。
在检查滑块值后,我们创建一个名为 imgBlur 的空矩阵来存储模糊结果。
要在 callback 函数中检索通过用户数据发送的图像,我们必须将 void* userData 强制转换为正确的 pointer Mat* 图像类型。
现在,我们有了应用模糊滤镜的正确变量。模糊函数将一个基本的中值滤波器应用于输入图像,在我们的例子中是 *img,到输出图像。最后一个参数是我们想要应用的模糊核的大小(核是一个用于计算核与图像之间卷积平均的小矩阵)。在我们的例子中,我们使用了一个 pos 大小的平方核。
最后,我们只需要使用 imshow 函数更新图像界面。
鼠标事件回调有五个输入参数:第一个参数定义事件类型,第二个和第三个参数定义鼠标位置,第四个参数定义鼠标滚轮的移动,第五个参数定义用户输入数据。
鼠标事件类型如下表所示:
| 事件类型 | 描述 |
|---|---|
EVENT_MOUSEMOVE |
当用户移动鼠标时 |
EVENT_LBUTTONDOWN |
当用户按下鼠标左键时 |
EVENT_RBUTTONDOWN |
当用户按下鼠标右键时 |
EVENT_MBUTTONDOWN |
当用户按下鼠标中键时 |
EVENT_LBUTTONUP |
当用户释放鼠标左键时 |
EVENT_RBUTTONUP |
当用户释放鼠标右键时 |
EVENT_MBUTTONUP |
当用户释放鼠标中键时 |
EVENT_LBUTTONDBLCLK |
当用户用左键双击时 |
EVENT_RBUTTONDBLCLK |
当用户用鼠标右键双击时 |
EVENT_MBUTTONDBLCLK |
当用户用鼠标中键双击时 |
EVENTMOUSEWHEEL |
当用户用鼠标滚轮进行垂直滚动时 |
EVENT_MOUSEHWHEEL |
当用户用鼠标滚轮进行水平滚动时 |
在我们的示例中,我们只处理来自左键按下的鼠标事件,然后丢弃任何不同于 EVENT_LBUTTONDOWN 的其他事件。在丢弃其他事件后,我们得到输入图像,例如滑块回调,并使用 OpenCV 的圆形函数在图像中绘制一个圆:
//Mouse callback
static void onMouse( int event, int x, int y, int, void* userInput )
{
if( event != EVENT_LBUTTONDOWN )
return;
// Get the pointer input image
Mat* img= (Mat*)userInput;
// Draw circle
circle(*img, Point(x, y), 10, Scalar(0,255,0), 3);
// Call on change to get blurred image
onChange(blurAmount, img);
}
向用户界面添加按钮
在上一章中,我们学习了如何创建普通或 QT 界面,以及如何使用鼠标和滑块与之交互,但我们可以创建不同类型的按钮。
注意
按钮仅在 QT 窗口中受支持。
支持的按钮类型如下:
-
push按钮 -
checkbox -
radiobox
按钮仅在控制面板中显示。控制面板是每个程序的独立窗口,我们可以将其附加到按钮和滑块上。
要显示控制面板,我们可以点击最后一个工具栏按钮,在 QT 窗口的任何部分右键单击,并选择 显示属性 窗口或 Ctrl + P 快捷键。
让我们看看如何创建带有按钮的基本示例。代码量很大,我们首先解释主函数,然后分别解释每个回调以了解它们:
Mat img;
bool applyGray=false;
bool applyBlur=false;
bool applySobel=false;
…
int main( int argc, const char** argv )
{
// Read images
img= imread("../lena.jpg");
// Create windows
namedWindow("Lena");
// create Buttons
createButton("Blur", blurCallback, NULL, QT_CHECKBOX, 0);
createButton("Gray",grayCallback,NULL,QT_RADIOBOX, 0);
createButton("RGB",bgrCallback,NULL,QT_RADIOBOX, 1);
createButton("Sobel",sobelCallback,NULL,QT_PUSH_BUTTON, 0);
// wait app for a key to exit
waitKey(0);
// Destroy the windows
destroyWindow("Lena");
return 0;
}
我们将应用三种类型的模糊过滤器,一个 sobel 过滤器和颜色转换到灰度。所有这些过滤器都是可选的,用户可以使用我们将要创建的按钮中的任何一个来选择。然后,为了获取每个过滤器的状态,我们创建三个全局布尔变量:
bool applyGray=false;
bool applyBlur=false;
bool applySobel=false;
在主函数中,在我们加载图像并创建窗口之后,我们必须使用 createButton 函数来创建每个按钮。
OpenCV 中定义了三种按钮类型,如下所示:
-
QT_CHECKBOX -
QT_RADIOBOX -
QT_PUSH_BUTTON
每个按钮有五个参数,顺序如下:
-
按钮名称
-
回调函数
-
传递给回调的用户变量数据的指针
-
按钮类型
-
用于
checkbox和radiobox按钮类型的默认初始化状态 -
然后,我们创建一个模糊复选框按钮,两个用于颜色转换的单选按钮,以及一个用于 Sobel 过滤器的按钮:
// create Buttons createButton("Blur", blurCallback, NULL, QT_CHECKBOX, 0); createButton("Gray",grayCallback,NULL,QT_RADIOBOX, 0); createButton("RGB",bgrCallback,NULL,QT_RADIOBOX, 1); createButton("Sobel",sobelCallback,NULL,QT_PUSH_BUTTON, 0); -
这是主函数中最重要的部分。我们将探索回调函数。每个回调都会更改其状态变量以调用另一个名为
applyFilters的函数,并添加由输入图像激活的过滤器:void grayCallback(int state, void* userData) { applyGray= true; applyFilters(); } void bgrCallback(int state, void* userData) { applyGray= false; applyFilters(); } void blurCallback(int state, void* userData) { applyBlur= (bool)state; applyFilters(); } void sobelCallback(int state, void* userData) { applySobel= !applySobel; applyFilters(); } -
applyFilters函数检查每个过滤器的状态变量:void applyFilters(){ Mat result; img.copyTo(result); if(applyGray){ cvtColor(result, result, COLOR_BGR2GRAY); } if(applyBlur){ blur(result, result, Size(5,5)); } if(applySobel){ Sobel(result, result, CV_8U, 1, 1); } imshow("Lena", result); }
要将颜色转换为灰度,我们使用 cvtColor 函数,该函数接受三个参数:输入图像、输出图像和颜色转换类型。
最有用的颜色空间转换如下:
-
RGB 或 BGR 到灰度(
COLOR_RGB2GRAY, COLOR_BGR2GRAY) -
RGB 或 BGR 到 YcrCb(或 YCC)(
COLOR_RGB2YCrCb, COLOR_BGR2YCrCb) -
RGB 或 BGR 到 HSV(
COLOR_RGB2HSV, COLOR_BGR2HSV) -
RGB 或 BGR 到 Luv(
COLOR_RGB2Luv, COLOR_BGR2Luv) -
灰度到 RGB 或 BGR(
COLOR_GRAY2RGB, COLOR_GRAY2BGR)
我们可以看到代码很容易记住。
注意
记住,OpenCV 默认使用 BGR 格式,当转换为灰度时,RGB 和 BGR 的颜色转换不同。一些开发者认为灰度等于 R+G+B/3,但最佳灰度值称为 亮度,其公式为 0.21R + 0.72G + 0.07B*。
在上一节中描述了模糊滤镜。最后,如果 applySobel 变量是 true,则应用 Sobel 滤镜。
Sobel 滤波器是一个使用 Sobel 运算符的图像导数,通常用于检测边缘。OpenCV 允许我们使用不同大小的核生成不同的导数,但最常见的是用于计算 x 导数或 y 导数的 3x3 核。
最重要的 Sobel 参数如下:
-
一个输入图像
-
一个输出图像
-
一个输出图像深度(
CV_8U、CV_16U、CV_32F、CV_64F) -
导数的顺序 x
-
导数的顺序 y
-
核大小(默认为 3 个值)
-
要生成一个 3x3 的核和第一个
x阶导数,我们必须使用以下参数:Sobel(input, output, CV_8U, 1, 0); -
要生成
y阶导数,我们使用以下参数:Sobel(input, output, CV_8U, 0, 1);
在我们的例子中,我们同时使用 x 和 y 导数来覆盖输入:
Sobel(result, result, CV_8U, 1, 1);
x 和 y 导数的输出如下:

OpenGL 支持
OpenCV 包括 OpenGL 支持。OpenGL 是一个集成在图形卡中的图形库,作为标准。OpenGL 允许我们从 2D 绘制到复杂的 3D 场景。
由于在有些任务中表示 3D 空间的重要性,OpenCV 包括 OpenGL 支持。为了允许 OpenGL 窗口支持,我们必须在调用 namedWindow 时设置 WINDOW_OPENGL 标志。
以下代码创建了一个具有 OpenGL 支持的窗口,并绘制了一个旋转的平面,该平面显示了网络摄像头的帧:
Mat frame;
GLfloat angle= 0.0;
GLuint texture;
VideoCapture camera;
int loadTexture() {
if (frame.data==NULL) return -1;
glGenTextures(1, &texture);
glBindTexture( GL_TEXTURE_2D, texture );
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, frame.cols, frame.rows,0, GL_BGR, GL_UNSIGNED_BYTE, frame.data);
return 0;
}
void on_opengl(void* param)
{
glLoadIdentity();
// Load frame Texture
glBindTexture( GL_TEXTURE_2D, texture );
// Rotate plane before draw
glRotatef( angle, 1.0f, 1.0f, 1.0f );
// Create the plane and set the texture coordinates
glBegin (GL_QUADS);
// first point and coordinate texture
glTexCoord2d(0.0,0.0);
glVertex2d(-1.0,-1.0);
// seccond point and coordinate texture
glTexCoord2d(1.0,0.0);
glVertex2d(+1.0,-1.0);
// third point and coordinate texture
glTexCoord2d(1.0,1.0);
glVertex2d(+1.0,+1.0);
// last point and coordinate texture
glTexCoord2d(0.0,1.0);
glVertex2d(-1.0,+1.0);
glEnd();
}
int main( int argc, const char** argv )
{
// Open WebCam
camera.open(0);
if(!camera.isOpened())
return -1;
// Create new windows
namedWindow("OpenGL Camera", WINDOW_OPENGL);
// Enable texture
glEnable( GL_TEXTURE_2D );
setOpenGlDrawCallback("OpenGL Camera", on_opengl);
while(waitKey(30)!='q'){
camera >> frame;
// Create first texture
loadTexture();
updateWindow("OpenGL Camera");
angle =angle+4;
}
// Destroy the windows
destroyWindow("OpenGL Camera");
return 0;
}
让我们理解一下代码。
第一个任务是创建所需的全局变量,其中我们存储视频捕获、保存帧、控制动画角度平面和 OpenGL 纹理:
Mat frame;
GLfloat angle= 0.0;
GLuint texture;
VideoCapture camera;
在我们的主函数中,我们必须创建视频摄像头捕获以检索摄像头帧:
camera.open(0);
if(!camera.isOpened())
return -1;
如果摄像头正确打开,那么我们必须使用 WINDOW_OPENGL 标志创建我们的窗口,以支持 OpenGL:
// Create new windows
namedWindow("OpenGL Camera", WINDOW_OPENGL);
在我们的例子中,我们想要绘制来自网络摄像头的图像,然后我们需要启用 OpenGL 纹理:
// Enable texture
glEnable( GL_TEXTURE_2D );
现在,我们已经准备好在我们的窗口中使用 OpenGL 绘制,但我们需要设置一个绘制 OpenGL 回调,例如典型的 OpenGL 应用程序。OpenCV 给我们 setOpenGLDrawCallback 函数,该函数有两个参数:窗口名称和回调函数:
setOpenGlDrawCallback("OpenGL Camera", on_opengl);
在定义了 OpenCV 窗口和回调函数后,我们需要创建一个循环来加载纹理,并通过调用 OpenGL 绘制回调来更新窗口内容;最后,我们需要更新角度位置。
要更新窗口内容,我们使用带有窗口名称作为参数的 OpenCV 函数 updateWindow:
while(waitKey(30)!='q'){
camera >> frame;
// Create first texture
loadTexture();
updateWindow("OpenGL Camera");
angle =angle+4;
}
当用户按下 q 键时,我们处于循环中。
在我们编译应用程序示例之前,我们需要定义 loadTexture 函数和我们的 on_opengl 回调绘制函数。
loadTexture函数将我们的Mat框架转换为 OpenGL 纹理图像,该图像已准备好在每次回调绘制中使用。在我们将图像作为纹理加载之前,我们需要确保我们的框架矩阵中有数据,以检查数据变量对象是否不为空:
if (frame.data==NULL) return -1;
如果我们在矩阵框架中有数据,那么我们可以创建 OpenGL 纹理绑定并设置 OpenGL 纹理参数为线性插值:
glGenTextures(1, &texture);
glBindTexture( GL_TEXTURE_2D, texture );
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MAG_FILTER,GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D,GL_TEXTURE_MIN_FILTER,GL_LINEAR);
现在,我们需要定义像素在矩阵中的存储方式以及如何使用 OpenGL 的glTexImage2D函数生成像素。非常重要的一点是,OpenGL 使用 RGB 格式,而 OpenCV 默认使用 BGR 格式,我们需要在这个函数中正确设置它:
glPixelStorei(GL_UNPACK_ALIGNMENT, 1);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, frame.cols, frame.rows,0, GL_BGR, GL_UNSIGNED_BYTE, frame.data);
return 0;
现在,我们只需要在主循环中调用updateWindow时完成绘制我们的平面。我们使用常见的 OpenGL 函数,然后加载单位 OpenGL 矩阵以重置我们之前的所有更改:
glLoadIdentity();
将框架纹理加载到内存中:
// Load Texture
glBindTexture( GL_TEXTURE_2D, texture );
在我们绘制我们的平面之前,我们将所有变换应用到我们的场景中;在我们的情况下,我们将在(1, 1, 1)轴上旋转我们的平面:
// Rotate plane
glRotatef( angle, 1.0f, 1.0f, 1.0f );
现在,我们已经正确设置了场景来绘制我们的平面,所以我们将绘制四边形面并使用glBegin(GL_QUADS)来完成这个目的:
// Create the plane and set the texture coordinates
glBegin (GL_QUADS);
我们以(0, 0)位置为中心绘制一个大小为两单位的平面。然后,我们必须使用glTextCoord2D和glVertex2D函数定义要使用的纹理坐标和顶点位置:
// first point and coordinate texture
glTexCoord2d(0.0,0.0);
glVertex2d(-1.0,-1.0);
// seccond point and coordinate texture
glTexCoord2d(1.0,0.0);
glVertex2d(+1.0,-1.0);
// third point and coordinate texture
glTexCoord2d(1.0,1.0);
glVertex2d(+1.0,+1.0);
// last point and coordinate texture
glTexCoord2d(0.0,1.0);
glVertex2d(-1.0,+1.0);
glEnd();
注意
这段 OpenGL 代码正在变得过时,但了解 OpenCV 和 OpenGL 集成而不使用复杂的 OpenGL 代码非常重要。为了向您介绍现代 OpenGL,请阅读现代 OpenGL 入门,Pack Publishing。
我们可以在以下图像中看到结果:

摘要
在本章中,我们学习了如何创建不同类型的用户界面来使用 OpenGL 显示图像或 3D 界面。我们学习了如何创建滑块和按钮,并在 3D 中绘制。我们还学习了一些基本的图像处理过滤器。
在下一章中,我们将学习如何使用图形用户界面构建一个完整的照片工具应用程序,使用我们所学到的所有知识。我们还将学习如何将多个过滤器应用到输入图像上。
第四章:深入直方图和滤波器
在上一章中,我们学习了使用 QT 或本地库在 OpenCV 中用户界面的基础知识以及如何使用高级 OpenGL 用户界面。我们学习了基本的颜色转换和过滤器,这些有助于我们创建我们的第一个应用程序。
本章我们将涵盖以下主题:
-
直方图和直方图均衡化
-
查找表
-
模糊和中值模糊
-
高斯 Canny 滤波器
-
图像颜色均衡化
-
理解图像类型之间的转换
在我们学习 OpenCV 和用户界面的基础知识之后,我们将在本章创建我们的第一个完整应用程序和一个基本的照片工具,具有以下功能:
-
计算并绘制直方图
-
直方图均衡化
-
洛马格诺伊相机效果
-
卡通化效果
此应用程序将帮助您了解如何从头开始创建整个项目并理解直方图概念。我们将看到如何使用组合滤波器和查找表来均衡彩色图像的直方图,并创建两种效果。
生成 CMake 脚本文件
在我们开始创建源文件之前,我们将生成CMakeLists.txt文件,这将允许我们编译我们的项目、结构和可执行文件。以下cmake脚本简单且基本,但足以编译和生成可执行文件:
cmake_minimum_required (VERSION 2.6)
cmake_policy(SET CMP0012 NEW)
PROJECT(Chapter4_Phototool)
# Requires OpenCV
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})
ADD_EXECUTABLE( ${PROJECT_NAME} main.cpp )
TARGET_LINK_LIBRARIES( ${PROJECT_NAME} ${OpenCV_LIBS} )
让我们尝试理解脚本文件。
第一行指示生成我们项目所需的最低cmake版本,第二行将CMP0012策略变量设置为允许您识别数字和布尔常量,并在未设置时移除 CMake 警告:
cmake_minimum_required (VERSION 2.6)
cmake_policy(SET CMP0012 NEW)
在这两行之后,我们定义项目名称:
PROJECT(Chapter4_Phototool)
当然,我们需要包含 OpenCV 库。首先要做的是找到库,并使用MESSAGE函数显示有关 OpenCV 库版本的消息:
# Requires OpenCV
FIND_PACKAGE( OpenCV 3.0.0 REQUIRED )
MESSAGE("OpenCV version : ${OpenCV_VERSION}")
如果找到最小版本为 3.0 的库,那么我们将包含头文件和库文件到我们的项目中:
include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})
现在,我们只需要添加要编译的源文件;为了将它们链接到 OpenCV 库,我们使用项目名称变量作为可执行文件名称,并仅使用一个名为main.cpp的单个源文件:
ADD_EXECUTABLE( ${PROJECT_NAME} main.cpp )
TARGET_LINK_LIBRARIES( ${PROJECT_NAME} ${OpenCV_LIBS} )
创建图形用户界面
在我们开始图像处理算法之前,我们将创建应用程序的主要用户界面。我们将使用基于 QT 的用户界面,以便我们可以创建单个按钮。
应用程序接收一个输入参数来加载要处理的图像,我们将创建以下四个按钮:
-
显示直方图
-
均衡直方图
-
洛马格诺伊效果
-
卡通化效果
我们可以在以下屏幕截图中看到四个结果:

让我们开发我们的项目。首先,我们将包含所需的 OpenCV 头文件。我们定义一个img矩阵来存储输入图像,并创建一个常量字符串来使用仅适用于 OpenCV 3.0 的新命令行解析器。在这个常量中,我们只允许两个输入参数:常见帮助和必需的图像输入:
// OpenCV includes
#include "opencv2/core/utility.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"
using namespace cv;
// OpenCV command line parser functions
// Keys accecpted by command line parser
const char* keys =
{
"{help h usage ? | | print this message}"
"{@image | | Image to process}"
};
main函数从命令行解析变量开始。然后我们设置说明并打印帮助信息。以下行将帮助您设置最终可执行文件的帮助说明:
int main( int argc, const char** argv )
{
CommandLineParser parser(argc, argv, keys);
parser.about("Chapter 4\. PhotoTool v1.0.0");
//If requires help show
if (parser.has("help"))
{
parser.printMessage();
return 0;
}
如果用户不需要帮助,那么我们需要在imgFile变量字符串中获取文件路径图像并检查是否将所有必需的参数添加到parser.check()函数中:
String imgFile= parser.get<String>(0);
// Check if params are correctly parsed in his variables
if (!parser.check())
{
parser.printErrors();
return 0;
}
现在,我们可以使用imread函数读取图像文件,然后使用namedWindow函数创建一个窗口,稍后将在其中显示输入图像:
// Load image to process
img= imread(imgFile);
// Create window
namedWindow("Input");
图像加载和窗口创建后,我们只需要创建我们的界面按钮并将它们链接到callback函数。每个callback函数都在源代码中定义,我们将在本章后面解释它们。我们将使用带有QT_PUSH_BUTTON常量的createButton函数创建按钮:
// Create UI buttons
createButton("Show histogram", showHistoCallback, NULL, QT_PUSH_BUTTON, 0);
createButton("Equalize histogram", equalizeCallback, NULL, QT_PUSH_BUTTON, 0);
createButton("Lomography effect", lomoCallback, NULL, QT_PUSH_BUTTON, 0);
createButton("Cartonize effect", cartoonCallback, NULL, QT_PUSH_BUTTON, 0);
为了完成我们的main函数,我们显示输入图像并等待按键以结束我们的应用程序:
// Show image
imshow("Input", img);
waitKey(0);
return 0;
现在,我们只需要定义以下部分中的callback函数,我们将定义并描述每个函数。
绘制直方图
直方图是变量分布的统计图形表示。这使我们能够理解数据的密度估计和概率分布。直方图是通过将变量的整个值域划分为固定数量的区间,然后计算每个区间中落入的值的数量来创建的。
如果我们将这个直方图概念应用到图像上,它看起来很复杂,但实际上非常简单。在灰度图像中,我们的变量值可以取从0到255的任何可能的灰度值,密度是具有此值的图像中的像素数量。这意味着我们必须计算具有值0的图像像素数量,计算值为1的像素数量,依此类推。
显示输入图像直方图的回调函数称为showHistoCallback。此函数计算每个通道图像的直方图,并在新图像中显示每个直方图通道的结果。
现在,让我们检查以下代码:
void showHistoCallback(int state, void* userData)
{
// Separate image in BRG
vector<Mat> bgr;
split( img, bgr );
// Create the histogram for 256 bins
// The number of possibles values [0..255]
int numbins= 256;
/// Set the ranges ( for B,G,R) ), last is not included
float range[] = { 0, 256 } ;
const float* histRange = { range };
Mat b_hist, g_hist, r_hist;
calcHist( &bgr[0], 1, 0, Mat(), b_hist, 1, &numbins, &histRange );
calcHist( &bgr[1], 1, 0, Mat(), g_hist, 1, &numbins, &histRange );
calcHist( &bgr[2], 1, 0, Mat(), r_hist, 1, &numbins, &histRange );
// Draw the histogram
// We go to draw lines for each channel
int width= 512;
int height= 300;
// Create image with gray base
Mat histImage( height, width, CV_8UC3, Scalar(20,20,20) );
// Normalize the histograms to height of image
normalize(b_hist, b_hist, 0, height, NORM_MINMAX );
normalize(g_hist, g_hist, 0, height, NORM_MINMAX );
normalize(r_hist, r_hist, 0, height, NORM_MINMAX );
int binStep= cvRound((float)width/(float)numbins);
for( int i=1; i< numbins; i++)
{
line( histImage,
Point( binStep*(i-1), height-cvRound(b_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(b_hist.at<float>(i) ) ),
Scalar(255,0,0));
line( histImage,
Point( binStep*(i-1), height-cvRound(g_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(g_hist.at<float>(i) ) ),
Scalar(0,255,0));
line( histImage,
Point( binStep*(i-1), height-cvRound(r_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(r_hist.at<float>(i) ) ),
Scalar(0,0,255));
}
imshow("Histogram", histImage);
}
让我们尝试理解如何提取每个通道的直方图以及如何绘制它。
首先,我们需要创建三个矩阵来处理每个输入图像通道。我们使用vector类型变量来存储每个矩阵,并使用split OpenCV 函数将输入图像划分为三个通道:
// Separate image in BRG
vector<Mat> bgr;
split( img, bgr );
现在,我们将定义直方图中的 bin 数量;在我们的情况下,每个可能的像素值一个 bin:
int numbins= 256;
现在,我们需要定义我们的变量范围并创建三个矩阵来存储每个直方图:
/// Set the ranges ( for B,G,R) )
float range[] = { 0, 256 } ;
const float* histRange = { range };
Mat b_hist, g_hist, r_hist;
现在,我们可以使用 OpenCV 的calcHist函数来计算直方图。此函数有几个参数,如下所示:
-
输入图像;在我们的例子中,我们使用存储在
bgr向量中的一个图像通道 -
计算输入中直方图所需的图像数量;在我们的例子中,我们只使用一个图像
-
用于计算直方图的通道数维度;在我们的例子中,我们使用 0
-
可选的掩码矩阵
-
用于存储计算出的直方图的变量
-
直方图维度(图像(在这里,是一个灰度平面)取值的空间的维度);在我们的例子中,它是 1
-
要计算的 bin 数量;在我们的例子中,我们使用 256 个 bin,每个像素值一个
-
输入变量的范围;在我们的例子中,是从
0到255的可能像素值范围
我们为每个通道的calcHist函数如下所示:
calcHist( &bgr[0], 1, 0, Mat(), b_hist, 1, &numbins, &histRange );
calcHist( &bgr[1], 1, 0, Mat(), g_hist, 1, &numbins, &histRange );
calcHist( &bgr[2], 1, 0, Mat(), r_hist, 1, &numbins, &histRange );
现在,我们已经为每个通道计算了直方图。我们需要绘制每个通道的直方图并展示给用户。为此,我们将创建一个大小为 512 x 300 像素的颜色图像:
// Draw the histogram
// We go to draw lines for each channel
int width= 512;
int height= 300;
// Create image with gray base
Mat histImage( height, width, CV_8UC3, Scalar(20,20,20) );
在我们在图像中绘制直方图值之前,我们将直方图矩阵在min值0和max值之间进行归一化;在我们的例子中,这个值与图像的高度相同,300 像素:
// Normalize the histograms to height of image
normalize(b_hist, b_hist, 0, height, NORM_MINMAX );
normalize(g_hist, g_hist, 0, height, NORM_MINMAX );
normalize(r_hist, r_hist, 0, height, NORM_MINMAX );
现在,我们需要从 bin 0 到 bin 1 等绘制线条。我们需要计算每个 bin 之间的像素数,然后通过除以 bin 的数量来计算binStep变量。
每个小线条是从水平位置i-1到i绘制的,垂直位置是相应的i处的直方图值。它使用如下所示的颜色通道表示法绘制:
int binStep= cvRound((float)width/(float)numbins);
for( int i=1; i< numbins; i++)
{
line( histImage,
Point( binStep*(i-1), height-cvRound(b_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(b_hist.at<float>(i) ) ),
Scalar(255,0,0));
line( histImage,
Point( binStep*(i-1), height-cvRound(g_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(g_hist.at<float>(i) ) ),
Scalar(0,255,0));
line( histImage,
Point( binStep*(i-1), height-cvRound(r_hist.at<float>(i-1) ) ),
Point( binStep*(i), height-cvRound(r_hist.at<float>(i) ) ),
Scalar(0,0,255));
}
最后,我们使用imshow函数显示直方图图像:
imshow("Histogram", histImage);
这是 lena.png 图像的结果:

图像颜色均衡化
在本节中,我们将学习如何对彩色图像进行均衡化。图像均衡化和直方图均衡化试图获得一个具有均匀分布值的直方图。均衡化的结果是图像对比度的增加。均衡化允许局部对比度较低的区域获得更高的对比度,使最频繁的强度分布更广。
当图像几乎全暗或完全明亮,背景和前景之间差异非常小的时候,这种方法非常有用。使用直方图均衡化,我们可以增加过曝或欠曝的细节对比度。这种技术在医学图像,如 X 光片中非常有用。
然而,这种方法有两个主要缺点:它增加了背景噪声并减少了有用信号。
我们可以在以下图像中看到均衡化的效果,并看到直方图在增加图像对比度时如何变化和扩散:

让我们尝试实现我们的直方图均衡化。我们将在用户界面代码中定义的回调函数中实现它:
void equalizeCallback(int state, void* userData)
{
Mat result;
// Convert BGR image to YCbCr
Mat ycrcb;
cvtColor( img, ycrcb, COLOR_BGR2YCrCb);
// Split image into channels
vector<Mat> channels;
split( ycrcb, channels );
// Equalize the Y channel only
equalizeHist( channels[0], channels[0] );
// Merge the result channels
merge( channels, ycrcb );
// Convert color ycrcb to BGR
cvtColor( ycrcb, result, COLOR_YCrCb2BGR );
// Show image
imshow("Equalized", result);
}
要均衡一个彩色图像,我们只需要均衡亮度通道。我们可以对每个颜色通道进行此操作,但结果不可用。然后,我们可以使用任何其他颜色图像格式,例如 HSV 或 YCrCb,这些格式将亮度分量分离到单独的通道中。我们选择这种最后的颜色格式,并使用 Y 通道(亮度)来均衡它。然后,我们执行以下步骤:
-
我们使用
cvtColor函数将我们的输入 BGR 图像转换为 YCrCb:Mat result; // Convert BGR image to YCbCr Mat ycrcb; cvtColor( img, ycrcb, COLOR_BGR2YCrCb); -
在将我们的图像转换后,我们将 YCrCb 图像分割成不同的
通道矩阵:// Split image into channels vector<Mat> channels; split( ycrcb, channels ); -
然后,我们仅使用
equalizeHist函数在Y 通道中均衡直方图,该函数只有两个参数:输入和输出矩阵:// Equalize the Y channel only equalizeHist( channels[0], channels[0] ); -
现在,我们只需要合并生成的通道,并将结果转换为 BGR 格式,以便向用户展示结果:
// Merge the result channels merge( channels, ycrcb ); // Convert color ycrcb to BGR cvtColor( ycrcb, result, COLOR_YCrCb2BGR ); // Show image imshow("Equalized", result);对低对比度的
Lena图像应用此过程将得到以下结果:![图像颜色均衡]()
洛马摄影效果
在本节中,我们将创建另一种图像效果,这是一种在 Google Camera 或 Instagram 等不同移动应用中常用的摄影效果。
在本节中,我们将了解如何使用查找表或LUT。我们将在本章后面讨论 LUT。
我们将学习如何添加一个覆盖图像;在这种情况下,添加一个暗晕来达到我们想要的效果。
实现此效果的函数是回调lomoCallback,其代码如下:
void lomoCallback(int state, void* userData)
{
Mat result;
const double exponential_e = std::exp(1.0);
// Create Lookup table for color curve effect
Mat lut(1, 256, CV_8UC1);
for (int i=0; i<256; i++)
{
float x= (float)i/256.0;
lut.at<uchar>(i)= cvRound( 256 * (1/(1 + pow(exponential_e, -((x-0.5)/0.1)) )) );
}
// Split the image channels and apply curve transform only to red channel
vector<Mat> bgr;
split(img, bgr);
LUT(bgr[2], lut, bgr[2]);
// merge result
merge(bgr, result);
// Create image for halo dark
Mat halo( img.rows, img.cols, CV_32FC3, Scalar(0.3,0.3,0.3) );
// Create circle
circle(halo, Point(img.cols/2, img.rows/2), img.cols/3, Scalar(1,1,1), -1);
blur(halo, halo, Size(img.cols/3, img.cols/3));
// Convert the result to float to allow multiply by 1 factor
Mat resultf;
result.convertTo(resultf, CV_32FC3);
// Multiply our result with halo
multiply(resultf, halo, resultf);
// convert to 8 bits
resultf.convertTo(result, CV_8UC3);
// show result
imshow("Lomograpy", result);
}
让我们理解一下代码。
洛马摄影效果分为不同的步骤,但在我们的例子中,我们使用了以下两个非常简单的步骤来实现洛马摄影效果:
-
使用查找表进行颜色操作,并将曲线应用到红色通道上
-
一种复古效果,将暗晕应用到图像上。
第一步是使用曲线变换来调整红色,该变换应用以下函数:

此公式生成一个曲线,使暗值更暗,亮值更亮,其中x是可能的像素值(0 到 255),而s是一个常数,我们在教程中将其设置为0.1。产生像素值低于 128 的较低常数值非常暗,而高于 128 的非常亮。接近1的值将曲线转换为直线,不会产生我们想要的效果:

这个函数通过应用查找表(通常称为 LUT)非常容易实现。LUT 是一个向量或表,它为给定的值返回一个预处理的值以在内存中执行计算。LUT 是一种常用的技术,通过避免重复执行昂贵的计算来节省 CPU 周期。我们不是对每个像素调用指数/除法函数,而是对每个可能的像素值(256 次)只执行一次,并将结果存储在表中。因此,我们以牺牲一些内存为代价节省了 CPU 时间。虽然这可能在标准 PC 和较小图像尺寸的情况下不会产生很大差异,但对于 CPU 受限的硬件(如树莓派)来说,这会产生巨大的差异。在我们的情况下,如果我们想对每个像素应用我们的函数,我们需要通过计算高度来增加宽度;在 100 x 100 像素中,有 10,000 次计算,但像素只有 256 个可能的值。然后,我们可以预先计算像素值并将它们存储在 LUT 向量中。
在我们的示例代码中,我们定义了E变量并创建了一个 1 行 256 列的lut矩阵。然后,我们通过应用我们的公式并保存到lut变量中,对所有可能的像素值进行循环:
const double exponential_e = std::exp(1.0);
// Create Lookup table for color curve effect
Mat lut(1, 256, CV_8UC1);
Uchar* plut= lut.data;
for (int i=0; i<256; i++)
{
double x= (double)i/256.0;
plut[i]= cvRound( 256.0 * (1.0/(1.0 + pow(exponential_e, -((x-0.5)/0.1)) )) );
}
如前所述,在本节中,我们不将函数应用于所有通道。我们需要使用split函数按通道拆分输入图像:
// Split the image channels and apply curve transform only to red channel
vector<Mat> bgr;
split(img, bgr);
然后,我们将我们的lut表变量应用于红色通道。OpenCV 为我们提供了具有以下三个参数的LUT函数:
-
输入图像
-
查找表的矩阵
-
输出图像
然后,我们的LUT函数和红色通道的调用看起来是这样的:
LUT(bgr[2], lut, bgr[2]);
现在,我们只需要合并我们计算出的通道:
// merge result
merge(bgr, result);
第一步已完成,我们只需要创建暗光环来完成我们的效果。然后,我们创建一个与输入图像大小相同的灰色图像,其中包含一个白色圆圈:
// Create image for halo dark
Mat halo( img.rows, img.cols, CV_32FC3, Scalar(0.3,0.3,0.3) );
// Create circle
circle(halo, Point(img.cols/2, img.rows/2), img.cols/3, Scalar(1,1,1), -1);

然而,如果我们将此图像应用于输入图像,它将从暗变亮,然后我们可以使用blur滤镜函数对圆光环图像应用大模糊以获得平滑效果:
blur(halo, halo, Size(img.cols/3, img.cols/3));
应用模糊滤镜后的结果如下所示:

现在,我们需要将这个光环应用到我们的第一步图像上。一种简单的方法是乘以两个图像。但是,我们需要将我们的输入图像从 8 位图像转换为 32 位浮点图像,因为我们需要将值在 0 到 1 之间的模糊图像与具有整数值的输入图像相乘:
// Convert the result to float to allow multiply by 1 factor
Mat resultf;
result.convertTo(resultf, CV_32FC3);
在我们将图像转换后,我们只需要逐元素乘以每个矩阵:
// Multiply our result with halo
multiply(resultf, halo, resultf);
最后,我们将浮点图像矩阵结果转换为 8 位图像并显示结果:
// convert to 8 bits
resultf.convertTo(result, CV_8UC3);
// show result
imshow("Lomograpy", result);

卡通化效果
在本章的最后部分,我们创建了一个名为卡通化的另一种效果。这个效果的目的创建一个看起来像卡通的图像。为此,我们将算法分为两个步骤:边缘检测和颜色过滤。
cartoonCallback函数使用以下代码定义此效果:
void cartoonCallback(int state, void* userData)
{
/** EDGES **/
// Apply median filter to remove possible noise
Mat imgMedian;
medianBlur(img, imgMedian, 7);
// Detect edges with canny
Mat imgCanny;
Canny(imgMedian, imgCanny, 50, 150);
// Dilate the edges
Mat kernel= getStructuringElement(MORPH_RECT, Size(2,2));
dilate(imgCanny, imgCanny, kernel);
// Scale edges values to 1 and invert values
imgCanny= imgCanny/255;
imgCanny= 1-imgCanny;
// Use float values to allow multiply between 0 and 1
Mat imgCannyf;
imgCanny.convertTo(imgCannyf, CV_32FC3);
// Blur the edgest to do smooth effect
blur(imgCannyf, imgCannyf, Size(5,5));
/** COLOR **/
// Apply bilateral filter to homogenizes color
Mat imgBF;
bilateralFilter(img, imgBF, 9, 150.0, 150.0);
// truncate colors
Mat result= imgBF/25;
result= result*25;
/** MERGES COLOR + EDGES **/
// Create a 3 channles for edges
Mat imgCanny3c;
Mat cannyChannels[]={ imgCannyf, imgCannyf, imgCannyf};
merge(cannyChannels, 3, imgCanny3c);
// Convert color result to float
Mat resultf;
result.convertTo(resultf, CV_32FC3);
// Multiply color and edges matrices
multiply(resultf, imgCanny3c, resultf);
// convert to 8 bits color
resultf.convertTo(result, CV_8UC3);
// Show image
imshow("Result", result);
}
让我们尝试理解这段代码。
第一步是检测图像中最重要的边缘。在检测边缘之前,我们需要从输入图像中去除噪声。有几种方法和方式可以做到这一点。我们将使用中值滤波器来去除任何可能的小噪声,但我们可以使用其他方法,如高斯模糊等。OpenCV 函数名为medianBlur,接受三个参数:输入图像、输出图像和核大小(核是一个用于对图像应用某些数学运算,如卷积的小矩阵)。
Mat imgMedian;
medianBlur(img, imgMedian, 7);
在去除任何可能的噪声后,我们使用canny滤波器检测强边缘:
// Detect edges with canny
Mat imgCanny;
Canny(imgMedian, imgCanny, 50, 150);
canny滤波器接受以下参数:
-
一个输入图像
-
一个输出图像
-
第一个阈值
-
第二个阈值
-
Sobel 大小孔径
-
一个布尔值,用于检查是否使用更精确的图像梯度幅度
第一个和第二个阈值之间的最小值用于边缘连接。最大值用于找到强边缘的初始段。solbel大小孔径是算法中将使用的sobel滤波器的核大小。
在检测到边缘后,我们将应用一个小膨胀来连接断裂的边缘:
// Dilate the edges
Mat kernel= getStructuringElement(MORPH_RECT, Size(2,2));
dilate(imgCanny, imgCanny, kernel);
与我们在 Lomography 效果中所做的一样,我们需要将我们的边缘结果图像乘以颜色图像。然后,我们需要一个介于0和1之间的像素值,因此我们将 canny 结果除以256并反转边缘为黑色:
// Scale edges values to 1 and invert values
imgCanny= imgCanny/255;
imgCanny= 1-imgCanny;
将 Canny 8 无符号位格式转换为浮点矩阵:
// Use float values to allow multiply between 0 and 1
Mat imgCannyf;
imgCanny.convertTo(imgCannyf, CV_32FC3);
为了得到一个酷炫的结果,我们可以模糊边缘以得到平滑的结果线,然后我们应用一个模糊滤镜:
// Blur the edgest to do smooth effect
blur(imgCannyf, imgCannyf, Size(5,5));
算法的第一步已经完成,现在我们将处理颜色。
为了得到卡通的外观和感觉,我们将使用双边滤波器:
// Apply bilateral filter to homogenizes color
Mat imgBF;
bilateralFilter(img, imgBF, 9, 150.0, 150.0);
双边滤波器是一种用于减少图像噪声同时保持边缘的滤波器,但我们可以通过适当的参数得到卡通效果,我们将在后面探讨。
双边滤波器参数如下:
-
一个输入图像
-
一个输出图像
-
像素邻域的直径;如果设置为负值,则从 sigma 空间值计算得出
-
一个 sigma 颜色值
-
一个 sigma 坐标空间
注意
当直径大于 5 时,双边滤波器会变慢。当 sigma 值大于 150 时,会出现卡通效果。
为了创建更强的卡通效果,我们将通过除法和乘法将可能的颜色值截断到 10。对于其他值,以及为了更好地理解 sigma 参数,请阅读 OpenCV 文档:
// truncate colors
Mat result= imgBF/25;
result= result*25;
最后,我们需要合并颜色和边缘的结果。然后,我们需要从第一步创建一个3通道图像:
// Create a 3 channles for edges
Mat imgCanny3c;
Mat cannyChannels[]={ imgCannyf, imgCannyf, imgCannyf};
merge(cannyChannels, 3, imgCanny3c);
然后,我们将我们的颜色结果图像转换为 32 位浮点图像,然后对两个图像的每个元素进行乘法运算:
// Convert color result to float
Mat resultf;
result.convertTo(resultf, CV_32FC3);
// Multiply color and edges matrices
multiply(resultf, imgCanny3c, resultf);
最后,我们只需将我们的图像转换为 8 位图像,并将结果图像展示给用户:
// convert to 8 bits color
resultf.convertTo(result, CV_8UC3);
// Show image
imshow("Result", result);
在以下图像中,我们可以看到输入图像(左侧图像)以及应用卡通化效果后的结果(右侧图像):

摘要
在本章中,我们学习了如何创建一个完整的项目,该项目在应用不同效果后操作图像。我们还把彩色图像分割成多个矩阵,以便只对一个通道应用效果。我们学习了如何创建查找表,将多个矩阵合并为一个,使用 Canny 和双边滤波器,绘制圆形,以及通过乘法图像来执行晕影效果。
在下一章中,我们将学习如何进行对象检查以及如何将图像分割成不同的部分并检测它。
第五章:自动光学检测,对象分割和检测
在上一章中,我们学习了直方图和过滤器,这些使我们能够理解图像处理并创建一个照片应用程序。
在本章中,我们将向您介绍对象分割和检测的基本概念,这意味着隔离图像中出现的对象,以便进行未来的处理和分析。
在本章中,我们将涵盖以下主题:
-
噪声消除
-
光/背景去除的基本原理
-
阈值操作
-
对象分割的连通分量
-
寻找对象分割的轮廓
行业领域使用复杂的计算机视觉系统和硬件。计算机视觉试图检测生产过程中的问题,并最小化产生的错误,提高最终产品的质量。
在这个领域,计算机视觉任务的名称是自动光学检测或 AOI。这个名字出现在印刷电路板制造商的检测中,其中一台或多台相机扫描每个电路以检测关键故障和质量缺陷。其他制造商使用光学相机系统和计算机视觉算法来提高产品质量。如今,使用不同类型的相机(如红外相机、3D 相机等)进行光学检测,这取决于问题要求,例如测量对象、检测表面效应等;并且复杂算法在成千上万的行业中用于不同的目的,如缺陷检测、识别、分类等。
场景中隔离对象
在本节中,我们将向您介绍任何 AOI 算法的第一步,即在场景中隔离不同的部分或对象。
我们将以三种对象类型(螺丝、包装环和螺母)的对象检测和分类为例,并在本章和第六章 学习对象分类 中进行开发。
假设我们是一家生产这些三个对象的公司。所有这些都在同一载体带上,我们的目标是检测载体带上的每个对象,并将每个对象分类,以便机器人将每个对象放在正确的货架上:

在本章中,我们将隔离每个对象并在图像中以像素为单位检测其位置。在下一章中,我们将对每个隔离对象进行分类,以检查它是否是螺母、螺丝或包装环。
在以下图像中,我们展示了我们期望的结果,其中左侧图像中有几个对象,而在右侧图像中,我们用不同的颜色绘制每一个。我们可以展示不同的特征,如面积、高度、宽度、轮廓大小等。

要实现此结果,我们将遵循不同的步骤,这些步骤使我们能够更好地理解和组织我们的算法,如下面的图所示:

我们的应用程序分为两个章节。在本章中,我们将开发和理解预处理和分割步骤。在第六章 学习对象分类 中,我们将提取每个分割对象的特征,并训练我们的机器学习系统/算法以识别每个对象类别,以便您可以对对象进行分类。
我们将预处理步骤分为三个更小的子步骤,如下所述:
-
噪声去除
-
光照去除
-
二值化
在分割步骤中,我们将使用两种不同的算法,如下所述:
-
边缘检测算法
-
连通组件提取(标记)
我们可以在以下图中看到这些子步骤以及应用程序流程:

现在,是时候开始预处理步骤,通过去除噪声和光照效果来获取最佳的二值化图像,以最大限度地减少可能的检测错误。
创建 AOI 应用程序
要创建我们的新应用程序,当用户执行它们时,我们需要一些输入参数;所有这些都是可选的,除了要处理的输入图像:
-
要处理的输入图像
-
光照图像模式
-
光照操作,用户可以在差分或除法操作之间选择:
-
如果用户输入的值设置为
0,则应用差分操作 -
如果用户输入的值设置为
1,则应用除法操作
-
-
分割,用户可以在具有或不具有统计的连通组件和
findContours方法之间选择:-
如果用户输入的值设置为
1,则应用分割的连通组件方法 -
如果用户输入的值设置为
2,则应用具有统计面积的连通组件 -
如果用户输入的值设置为
3,则将findContours方法应用于分割
-
为了启用此用户选择,我们将使用带有这些键的命令行 parser 类:
// OpenCV command line parser functions
// Keys accecpted by command line parser
const char* keys =
{
"{help h usage ? | | print this message}"
"{@image || Image to process}"
"{@lightPattern || Image light pattern to apply to image input}"
"{lightMethod | 1 | Method to remove background light, 0 difference, 1 div }"
"{segMethod | 1 | Method to segment: 1 connected Components, 2 connected components with stats, 3 find Contours }"
};
我们使用命令行 parser 类在 main 函数中检查参数:
int main( int argc, const char** argv )
{
CommandLineParser parser(argc, argv, keys);
parser.about("Chapter 5\. PhotoTool v1.0.0");
//If requires help show
if (parser.has("help"))
{
parser.printMessage();
return 0;
}
String img_file= parser.get<String>(0);
String light_pattern_file= parser.get<String>(1);
int method_light= parser.get<int>("lightMethod");
int method_seg= parser.get<int>("segMethod");
// Check if params are correctly parsed in his variables
if (!parser.check())
{
parser.printErrors();
return 0;
}
在 parser 类之后,我们检查命令行用户数据是否正确加载,然后加载图像并检查它是否有数据:
// Load image to process
Mat img= imread(img_file, 0);
if(img.data==NULL){
cout << "Error loading image "<< img_file << endl;
return 0;
}
现在,我们已准备好创建我们的 AOI 分割过程。我们将从预处理任务开始。
预处理输入图像
本节向您介绍一些最常用的技术,这些技术可以在对象分割/检测的上下文中应用于预处理图像。预处理是我们开始工作并从图像中提取所需信息之前对新的图像所做的第一个更改。
通常,在预处理步骤中,我们试图最小化图像噪声、光照条件或由于相机镜头引起的图像变形。这些步骤在尝试检测对象或分割我们的图像时最小化错误。
噪声去除
如果我们不去除噪声,我们可以检测到比预期更多的对象,因为通常噪声在图像中表示为一个小点,可以被分割为对象。传感器和扫描仪电路通常会产生这种噪声。这种亮度或颜色的变化可以表示为不同的类型,如高斯噪声、尖峰噪声和闪烁噪声。有不同技术可以用来去除噪声。我们将使用平滑操作,但根据噪声的类型,我们将使用一些比其他更好的技术。例如,中值滤波器通常用于去除椒盐噪声:

左侧图像是带有椒盐噪声的原始输入图像。如果我们应用中值模糊,我们会得到一个很棒的结果,但会丢失一些细节。例如,我们保持完美的边缘的螺丝的边缘。参见图表右上角。如果我们应用盒式滤波器或高斯滤波器,噪声不会被去除。它只是被平滑,物体的细节也会变得松散和平滑。参见图表右下角。
OpenCV 为我们提供了medianBlur函数,该函数需要以下三个参数:
-
输入图像具有 1、3 或 4 个通道。当核大小大于
5时,图像深度只能为CV_8U。 -
输出图像,即结果图像,其类型和深度与输入图像相同。
-
核大小大于
1且为奇数的孔径大小。例如,3、5、7。
这段用于去除噪声的代码看起来像这样:
Mat img_noise;
medianBlur(img, img_noise, 3);
使用光模式进行分割以去除背景
在本节中,我们将开发一个基本算法,使我们能够使用光模式去除背景。这种预处理使我们能够获得更好的分割。参见图表。左上角图是未添加噪声的输入图像,右上角图是应用阈值操作的结果;我们可以看到顶部的伪影。左下角图是去除背景后的输入图像,右下角图是没有伪影的阈值结果,它更适合分割。

我们如何从我们的图像中去除光线?这非常简单;我们只需要一张没有物体的场景照片,这张照片是从与其他图像相同的精确位置拍摄的,并且具有相同的光照条件。这在 AOI 中是一个非常常见的技巧,因为外部条件是受监督和已知的。我们案例的图像结果类似于以下图:

然后,通过简单的数学运算,我们可以去除这个光模式。有两种去除它的方法,如下所示:
-
差分
-
除法
差分图像是最简单的方法。如果我们有光模式L和图像图片I,去除R的结果是它们之间的差:
R= L-I
这种除法稍微复杂一些,但同时也很简单。如果我们有光模式矩阵L和图像图片矩阵I,去除R的结果如下:
R= 255*(1-(I/L))
在这种情况下,我们通过光模式除以图像。我们假设如果我们的光模式是白色,而物体比背景载体胶带暗,那么图像像素值将始终保持不变或低于光像素值。然后,从I/L获得的我们得到的结果在0和1之间。最后,我们反转这个除法的结果,以获得相同颜色方向范围,并将其乘以 255 以获得0-255范围内的值。
在我们的代码中,我们将创建一个名为removeLight的新函数,具有以下参数:
-
用于去除光/背景的输入图像
-
光模式矩阵
-
方法,
0是差分,1是除法
输出是一个没有光/背景的新图像矩阵。
以下代码实现了使用光模式去除背景:
Mat removeLight(Mat img, Mat pattern, int method)
{
Mat aux;
// if method is normalization
if(method==1)
{
// Require change our image to 32 float for division
Mat img32, pattern32;
img.convertTo(img32, CV_32F);
pattern.convertTo(pattern32, CV_32F);
// Divide the image by the pattern
aux= 1-(img32/pattern32);
// Scale it to convert to 8bit format
aux=aux*255;
// Convert 8 bits format
aux.convertTo(aux, CV_8U);
}else{
aux= pattern-img;
}
return aux;
}
让我们尝试理解这一点。在创建aux变量之后,为了保存结果,我们选择用户选择的方法,并通过参数传递给函数。如果选择的是1,我们应用除法方法。
division方法需要一个 32 位浮点图像,这样我们才能进行图像除法。第一步是将图像和光模式矩阵转换为 32 位深度:
// Require change our image to 32 float for division
Mat img32, pattern32;
img.convertTo(img32, CV_32F);
pattern.convertTo(pattern32, CV_32F);
现在,我们可以执行描述中的矩阵中的数学运算,即通过模式除以图像,并反转结果:
// Divide the image by the pattern
aux= 1-(img32/pattern32);
// Scale it to convert o 8bit format
aux=aux*255;
现在,我们已经得到了结果,但我们需要返回一个 8 位深度的图像,然后,使用convert函数,就像我们之前做的那样,将其转换为 32 位浮点数:
// Convert 8 bits format
aux.convertTo(aux, CV_8U);
现在我们可以将aux变量与结果一起返回。对于差分法,其展开非常简单,因为我们不需要转换我们的图像,我们只需要执行差分并返回。如果我们不假设模式等于或大于图像,那么我们将需要执行一些检查,并截断可能小于0或大于255的值:
aux= pattern-img;
下图是应用图像光模式到我们的输入图像的结果:

在我们获得的结果中,我们可以检查光梯度是如何被去除的,以及可能的伪影是如何被去除的。
然而,当我们没有光/背景模式时会发生什么?有几种不同的技术可以做到这一点,我们将介绍最基本的一种。使用一个过滤器,我们可以创建一个可以使用的过滤器,但还有更好的算法,你可以从中学习如何从几个图像中学习背景,其中碎片出现在不同的区域。这种技术有时需要背景估计图像初始化,其中我们的基本方法可以发挥很好的作用。这些高级技术将在视频监控章节中探讨。
为了估计背景图像,我们将使用一个大型核大小的模糊,该模糊应用于我们的输入图像。这是一种在 OCR 中常用的常见技术,其中字母相对于整个文档又薄又小,并允许我们对图像中的光模式进行近似。我们可以在左侧图上看到光/背景模式重建,在右侧图上看到真实情况:

我们可以看到光模式之间存在一些细微的差异,但这个结果足以移除背景,我们可以在以下图使用差异图像中看到结果。
在以下图中,我们可以看到使用先前方法计算的原输入图像和估计的背景图像之间的图像差异的结果:

calculateLightPattern 函数创建这个光模式或背景近似:
Mat calculateLightPattern(Mat img)
{
Mat pattern;
// Basic and effective way to calculate the light pattern from one image
blur(img, pattern, Size(img.cols/3,img.cols/3));
return pattern;
}
这个 basic 函数使用相对于图像大小的大核大小对输入图像进行模糊处理。从代码中可以看出,它是原始宽度和高度的三分之一。
阈值操作
在移除背景后,我们只需要对图像进行二值化以便进行未来的分割。现在,我们将使用 threshold 函数并应用两个不同的阈值值:当我们移除光/背景时使用一个非常低的值,因为所有非感兴趣区域都是黑色或非常低的值,而在我们不使用光移除方法时使用中等值,因为我们有一个白色背景,而目标图像的值较低。这个最后的选项允许我们检查带有和不带有背景移除的结果:
// Binarize image for segment
Mat img_thr;
if(method_light!=2){
threshold(img_no_light, img_thr, 30, 255, THRESH_BINARY);
}else{
threshold(img_no_light, img_thr, 140, 255, THRESH_BINARY_INV);
}
现在,我们将继续到我们应用程序最重要的部分:分割。我们将使用两种不同的方法或算法:连通分量和轮廓。
对输入图像进行分割
现在,我们将介绍用于分割我们的阈值图像的以下两种技术:
-
连通分量
-
findContours函数
使用这两种技术,我们将能够提取图像中每个感兴趣区域,其中我们的目标对象出现;在我们的案例中,是一个螺母、螺丝和环。
连通分量算法
连通分量是一个非常常用的算法,用于在二值图像中分割和识别部分。连通分量是一个迭代算法,用于使用 8-或 4-连通性像素标记图像。如果两个像素具有相同的值并且是邻居,则它们是连通的。在以下图中,每个像素有八个邻居像素:

4-连通性意味着如果中心像素的邻居像素(2、4、5 和 7)具有相同的值,则它们可以连接到中心。在 8-连通性情况下,如果它们具有相同的值,则 1、2、3、4、5、6、7 和 8 可以连接。
在以下示例中,我们可以看到八连通性和四连通性算法之间的区别。我们将对下一个二值化图像应用每个算法。我们使用了一个小的 9 X 9 图像,并将其放大以显示连通分量以及 4-连通性和 8-连通性之间的区别是如何工作的:

4-连通性算法检测到两个对象,如图中左侧图像所示。8-连通性算法只检测到一个对象(右侧图像),因为两个对角像素是连通的,而在 4-连通性算法中,只有垂直和水平像素是连通的。我们可以在以下图中看到结果,其中每个对象都有不同的灰度颜色值:

OpenCV 3 通过以下两个不同的函数介绍了连通分量算法:
-
connectedComponents(image, labels, connectivity=8, type=CV_32S) -
connectedComponentsWithStats(image, labels, stats, centroids, connectivity=8, ltype=CV_32S)
这两个函数都返回一个整数,表示检测到的标签数量,其中标签0代表背景。
这两个函数之间的区别基本上是每个函数返回的信息。让我们检查每个函数的参数。connectedComponents函数提供了以下参数:
-
图像:这是要标记的输入图像。
-
标签:这是一个与输入图像大小相同的
mat输出,其中每个像素的值为其标签,所有0表示背景,值为1的像素表示第一个连通分量对象,依此类推。 -
连通性:有两个可能的值:
8或4,表示我们想要使用的连通性。 -
类型:这是我们想要使用的标签图像类型:只允许两种类型,
CV32_S或CV16_U。默认情况下,它是CV32_S。
connectedComponentsWithStats函数有两个额外的参数被定义:stats 和 centroids 参数:
-
Stats:这是每个标签的输出参数,包括背景标签。可以通过 stats(标签,列)访问以下统计值,其中列也被定义为如下:-
CC_STAT_LEFT: 这是连通分量对象的最左侧 x 坐标 -
CC_STAT_TOP: 这是连通分量对象的最上侧 y 坐标 -
CC_STAT_WIDTH: 这是通过其边界框定义的连通分量对象的宽度 -
CC_STAT_HEIGHT: 这是通过其边界框定义的连通分量对象的高度 -
CC_STAT_AREA: 这是连通分量对象的像素数(面积)
-
-
Centroids: 每个标签(包括背景)的质心点以float类型表示
在我们的示例应用程序中,我们将创建两个函数,这些函数将应用于这两个 OpenCV 算法,并将用户通过一个新图像显示基本算法中带有彩色对象的结果,并绘制每个对象的统计算法区域。
让我们定义连通分量的基本绘制函数:
void ConnectedComponents(Mat img)
{
// Use connected components to divide our possibles parts of images
Mat labels;
int num_objects= connectedComponents(img, labels);
// Check the number of objects detected
if(num_objects < 2 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << num_objects - 1 << endl;
}
// Create output image coloring the objects
Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3);
RNG rng( 0xFFFFFFFF );
for(int i=1; i<num_objects; i++){
Mat mask= labels==i;
output.setTo(randomColor(rng), mask);
}
imshow("Result", output);
}
首先,我们调用 OpenCV 的connectedComponents函数,该函数返回检测到的对象数量。如果对象数量少于两个,这意味着只检测到了背景对象,那么我们不需要绘制任何内容并结束。如果算法检测到多个对象,那么我们通过终端显示检测到的对象数量:
Mat labels;
int num_objects= connectedComponents(img, labels);
// Check the number of objects detected
if(num_objects < 2 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << num_objects - 1 << endl;
现在,我们将使用不同颜色在一张新图像中绘制所有检测到的对象,然后我们需要创建一个与输入大小相同且具有三个通道的新黑色图像:
Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3);
然后,我们需要遍历每个标签,除了0值,因为它代表背景标签:
for(int i=1; i<num_objects; i++){
为了从标签图像中提取每个对象,我们需要为每个标签i创建一个掩码,使用比较并保存到新图像中:
Mat mask= labels==i;
最后,我们使用掩码将伪随机颜色设置到输出图像中:
output.setTo(randomColor(rng), mask);
}
在我们遍历所有图像后,我们输出图像中就有了不同颜色的所有对象,我们只需要显示输出图像:
mshow("Result", output);
这是结果,每个对象都使用不同的颜色或灰度值进行着色:

现在,我们将解释如何使用带有统计信息的 OpenCV 连通分量算法,并在输出结果图像中显示更多信息。以下函数实现了这一功能:
void ConnectedComponentsStats(Mat img)
{
// Use connected components with stats
Mat labels, stats, centroids;
int num_objects= connectedComponentsWithStats(img, labels, stats, centroids);
// Check the number of objects detected
if(num_objects < 2 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << num_objects - 1 << endl;
}
// Create output image coloring the objects and show area
Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3);
RNG rng( 0xFFFFFFFF );
for(int i=1; i<num_objects; i++){
cout << "Object "<< i << " with pos: " << centroids.at<Point2d>(i) << " with area " << stats.at<int>(i, CC_STAT_AREA) << endl;
Mat mask= labels==i;
output.setTo(randomColor(rng), mask);
// draw text with area
stringstream ss;
ss << "area: " << stats.at<int>(i, CC_STAT_AREA);
putText(output,
ss.str(),
centroids.at<Point2d>(i),
FONT_HERSHEY_SIMPLEX,
0.4,
Scalar(255,255,255));
}
imshow("Result", output);
}
让我们理解代码,就像我们在非统计函数中做的那样。我们调用连通分量算法;但在这个情况下,使用stats函数,我们检查是否可以检测到多个对象:
Mat labels, stats, centroids;
int num_objects= connectedComponentsWithStats(img, labels, stats, centroids);
// Check the number of objects detected
if(num_objects < 2 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << num_objects - 1 << endl;
}
现在,我们还有两个额外的输出结果:stats和centroids变量。然后,对于每个检测到的标签,我们将通过命令行显示其质心和面积:
for(int i=1; i<num_objects; i++){
cout << "Object "<< i << " with pos: " << centroids.at<Point2d>(i) << " with area " << stats.at<int>(i, CC_STAT_AREA) << endl;
您可以通过检查stats变量的调用来提取面积,使用stats.at<int>(I, CC_STAT_AREA)列常量。
现在,如前所述,我们用i号标记的对象的输出图像进行着色:
Mat mask= labels==i;
output.setTo(randomColor(rng), mask);
最后,我们需要在分割对象的质心处添加一些信息,如面积。为此,我们使用stats和centroid变量以及putText函数。首先,我们需要创建一个 stringstream 来添加统计面积信息:
// draw text with area
stringstream ss;
ss << "area: " << stats.at<int>(i, CC_STAT_AREA);
然后,使用质心作为文本位置来调用putText:
putText(output,
ss.str(),
centroids.at<Point2d>(i),
FONT_HERSHEY_SIMPLEX,
0.4,
Scalar(255,255,255));
这个函数的结果看起来像这样:

The findContours algorithm
findContours算法是 OpenCV 中最常用的算法之一,用于分割对象。这个算法自 OpenCV 的第一个版本以来就被包含在内,并为开发者提供了更多信息和描述符,如形状、拓扑组织等:
void findContours(InputOutputArray image, OutputArrayOfArrays contours, OutputArray hierarchy, int mode, int method, Point offset=Point())
让我们逐个解释每个参数,如下所示:
-
图像:这是输入的二值图像。
-
轮廓:这是轮廓输出,其中每个检测到的轮廓都是一个点的向量。
-
层次结构:这是可选的输出向量,用于存储轮廓的层次结构。这是图像的拓扑结构,其中我们可以获取每个轮廓之间的关系。
-
模式:这是检索轮廓的方法:
-
RETR_EXTERNAL: 这只检索外部轮廓。 -
RETR_LIST: 这将检索所有轮廓而不建立层次结构。 -
RETR_CCOMP: 这检索所有具有两个层次结构的轮廓:外部和孔洞。如果一个对象在孔洞内部,那么它将位于层次结构的顶部。 -
RETR_TREE: 这检索创建轮廓之间完整层次结构的所有轮廓。
-
-
方法:这允许你执行近似方法以检索轮廓的形状:
-
CV_CHAIN_APPROX_NONE:这不对轮廓应用任何近似,并存储所有轮廓点。 -
CV_CHAIN_APPROX_SIMPLE: 这压缩了所有只存储起点和终点的水平、垂直和对角线段。 -
CV_CHAIN_APPROX_TC89_L1,CV_CHAIN_APPROX_TC89_KCOS这应用了 Teh-Chin 链近似算法。
-
-
偏移量:这是用于移动所有轮廓的可选点值。当我们在一个 ROI 中工作时,这非常有用,并且需要检索全局位置。
注意
注意
输入图像被findContours函数修改。如果你需要它,在将其发送到该函数之前创建图像的副本。
现在我们知道了findContours函数的参数,让我们将它们应用到我们的示例中:
void FindContoursBasic(Mat img)
{
vector<vector<Point> > contours;
findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
Mat output= Mat::zeros(img.rows,img.cols, CV_8UC3);
// Check the number of objects detected
if(contours.size() == 0 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << contours.size() << endl;
}
RNG rng( 0xFFFFFFFF );
for(int i=0; i<contours.size(); i++)
drawContours(output, contours, i, randomColor(rng));
imshow("Result", output);
}
让我们逐行理解我们的实现。
在我们的情况下,我们不需要任何层次结构,因此我们将只检索所有可能对象的contours的外部轮廓。为此,我们使用RETR_EXTERNAL模式,并使用CHAIN_APPROX_SIMPLE方法的基本轮廓编码方案:
vector<vector<Point> > contours;
vector<Vec4i> hierarchy;
findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
与前面提到的连接组件示例类似,我们首先检查我们检索了多少contours。如果没有,则从我们的函数中退出:
// Check the number of objects detected
if(contours.size() == 0 ){
cout << "No objects detected" << endl;
return;
}else{
cout << "Number of objects detected: " << contours.size() << endl;
}
最后,我们绘制了我们检测到的每个等高线,并在输出图像中以不同的颜色绘制它们。为此,OpenCV 为我们提供了一个函数来绘制查找等高线图像的结果:
for(int i=0; i<contours.size(); i++)
drawContours(output, contours, i, randomColor(rng));
imshow("Result", output);
}
drawContours 函数允许以下参数:
-
图像:这是用于绘制等高线的输出图像。
-
等高线:这是等高线向量。
-
等高线索引:这是一个指示要绘制的等高线的数字;如果它是负数,则绘制所有等高线。
-
颜色:这是绘制等高线所用的颜色。
-
厚度:如果这个值为负,则等高线将用所选颜色填充。
-
线型:当我们想要使用抗锯齿或其他绘图方法时使用。
-
层次结构:这是一个可选参数,只有在你想只绘制一些等高线时才需要。
-
最大层级:这是一个可选参数,仅在存在层次结构参数时才考虑。如果设置为 0,则只绘制指定的等高线;如果设置为 1,则函数绘制当前等高线和嵌套的等高线;如果设置为 2,则算法绘制所有指定的等高线层级。
-
偏移量:这是一个可选参数,用于移动等高线。
我们的结果可以在以下图像中显示:

在二值化图像之后,我们可以看到三种不同的算法被用来分割和分离图像中的每个对象,使我们能够隔离每个对象以便进行操作或提取特征。
我们可以在以下图像中看到整个过程:

摘要
在本章中,我们探讨了在受控情况下进行对象分割的基础,其中摄像机拍摄不同对象的图片。
我们学习了如何去除背景和光线,以便通过最小化噪声来二值化我们的图像,以及三种不同的算法用于分割和分离图像中的每个对象,使我们能够隔离每个对象以便进行操作或提取特征。最后,我们在图像中提取了所有对象,我们将从这些对象中提取特征来训练机器学习系统。
在下一章中,我们将预测图像中任何对象的类别,然后调用机器人或其他系统来选择其中的任何一个,或者检测不在正确载体带上的对象,然后通知人员将其取走。
第六章. 学习对象分类
在上一章中,我们向您介绍了对象分割和检测的基本概念。这意味着将图像中出现的对象隔离出来,以便进行未来的处理和分析。
本章将介绍如何对每个这些孤立的对象进行分类。为了使我们能够对每个对象进行分类,我们需要训练我们的系统,使其能够学习所需的参数,以决定应该将哪个特定的标签分配给检测到的对象(取决于训练阶段考虑的不同类别)。
本章将向您介绍机器学习的基本概念,以对具有不同标签的图像进行分类。
我们将基于第五章中讨论的分割算法创建一个基本应用,第五章,《自动光学检测、对象分割和检测》。这个分割算法提取图像中的部分,其中包含对象。对于每个对象,我们将提取不同的特征并使用机器学习算法进行分析。通过使用机器学习算法,我们能够在用户界面中向最终用户展示输入图像中检测到的每个对象的标签。
在本章中,我们将介绍不同的主题和算法,具体如下:
-
机器学习概念的介绍
-
常见的机器学习算法和过程
-
特征提取
-
支持向量机
-
训练和预测
介绍机器学习概念
机器学习是一个古老的概念,由亚瑟·塞缪尔在 1959 年定义为“一个研究领域,它赋予计算机在没有明确编程的情况下学习的能力”。汤姆·M·米切尔提供了一个更正式的定义。在这个定义中,汤姆将样本或经验、标签和性能测量的概念联系起来。
注意
亚瑟·塞缪尔对机器学习的定义引用自《IBM 研究与发展杂志》中的《使用国际象棋游戏进行机器学习研究》一文(卷:3,期:3),第 210 页,以及同年《纽约客》和《办公室管理》中的一句话。
汤姆·M·米切尔给出的更正式的定义引用自《机器学习书》,McGraw Hill 1997 (www.cs.cmu.edu/afs/cs.cmu.edu/user/mitchell/ftp/mlbook.html)。
机器学习涉及人工智能中的模式识别和学习理论,与计算统计学相关。
机器学习被应用于数百个应用中,例如OCR(光学字符识别)、垃圾邮件过滤、搜索引擎,以及我们在本章中将要开发的数千个计算机视觉应用,其中机器学习算法试图对输入图像中出现的对象进行分类。
根据机器学习算法如何从数据或样本中学习,我们可以将它们分为以下三类:
-
监督学习:计算机从一组标记数据中学习。目标是学习模型的参数和规则,这些规则允许计算机映射数据与输出标签结果之间的关系。
-
无监督学习:没有给出标签,计算机试图发现输入数据的结构。
-
强化学习:计算机与动态环境交互,执行其目标并从其错误中学习。
根据我们从机器学习算法中获得的结果,我们可以将它们分类如下:
-
分类:在分类中,输入空间可以划分为N个类别,给定样本的预测结果是这些训练类别之一。这是最常用的类别之一。一个典型的例子是电子邮件垃圾邮件过滤,其中只有两个类别:垃圾邮件和非垃圾邮件或 OCR,其中只有N个字符可用,每个字符是一个类别。
-
回归:输出是一个连续值,而不是像分类结果这样的离散值。回归的一个例子可以是根据房屋大小、年份和位置预测房价。
-
聚类:使用无监督训练将输入划分为N组。
-
密度估计:这找到输入的(概率)分布。
在我们的例子中,我们将使用一个监督学习分类算法,其中使用带有标签的训练数据集来训练模型,我们模型的结果是对一个标签的预测。
机器学习是人工智能和统计学的一种现代方法,它涉及这两种技术。
在机器学习中,有几种方法和途径,其中一些使用的是SVM(支持向量机)、ANNs(人工神经网络)、聚类如K-Nearest Neighbors、决策树或深度学习,这是一种在某些情况下使用的大的神经网络方法,例如卷积等。
所有这些方法和途径都在 OpenCV 中得到支持、实现和良好记录。我们将在下一节中解释其中之一,即 SVM。
OpenCV 实现了这些机器学习算法中的八个。它们都继承自StatModel类:
-
人工神经网络
-
Boost
-
随机树
-
期望最大化
-
K-Nearest Neighbours
-
逻辑回归
-
正态贝叶斯分类器
-
支持向量机
注意
要了解每个算法的更多细节,请阅读 OpenCV 机器学习文档页面docs.opencv.org/trunk/dc/dd6/ml_intro.html。
在以下图像中,你可以看到机器学习类层次结构:

StatModel类提供了所有重要的read和write函数,这些函数对于保存我们的机器学习参数和训练数据非常重要。
在机器学习中,最耗时的部分是训练方法。对于大型数据集和复杂的机器学习结构,训练可能需要从几秒到几周或几个月;例如,在深度学习和包含超过 10 万张图片的大神经网络结构中。在深度学习算法中,通常使用并行硬件处理;例如,使用 CUDA 技术的 GPU 或显卡来减少训练过程中的计算时间。
这意味着我们每次运行应用程序时都不能重新训练我们的算法,并且建议我们在模型训练后保存我们的模型,因为所有机器学习的训练/预测参数都被保存。接下来,当我们想要在未来运行它时,如果我们需要用更多数据更新我们的模型,我们只需要从我们的保存模型中加载/读取,而无需再次进行训练。
StatModel是一个接口,由其实现的每个实现实现。两个关键函数是train和predict。
train方法负责从训练数据集中学习模型的参数。train函数有以下四个调用方式,可以以四种不同的方式调用:
bool train(const Ptr<TrainData>& trainData, int flags=0 );
bool train(InputArray samples, int layout, InputArray responses);
Ptr<_Tp> train(const Ptr<TrainData>& data, const _Tp::Params& p, int flags=0 );
Ptr<_Tp> train(InputArray samples, int layout, InputArray responses, const _Tp::Params& p, int flags=0 );
它有以下参数:
-
trainData:这是可以从TrainData类加载或创建的训练数据。这个类是 OpenCV 3 中的新功能,帮助开发者创建训练数据,因为不同的算法需要不同的数组结构来训练和预测,例如 ANN 算法。 -
samples:这是训练数组样本的数组,例如机器学习算法所需的格式要求的训练数据。 -
layout:有两种类型的布局:ROW_SAMPLE(训练样本是矩阵行)和COL_SAMPLE(训练样本是矩阵列)。 -
responses:这是与样本数据相关联的响应向量。 -
p:这是StatModel参数。 -
flags:这些是由每个方法定义的可选标志。
predict方法更简单,只有一个调用:
float StatModel::predict(InputArray samples, OutputArray results=noArray(), int flags=0 )
它有以下参数:
-
samples:这些是要预测的输入样本。可以只有一个或多个要预测的数据。 -
results:这是每个输入行样本的结果(由算法从前一个训练模型计算得出)。 -
flags:这些是模型相关的可选标志。一些模型,如 Boost 和 SVM,识别StatModel::RAW_OUTPUT标志,这使得方法返回原始结果(总和)而不是类标签。
StatModel类提供了其他非常实用的方法,具体如下:
-
isTrained():如果模型已训练,则返回true。 -
isClassifier():如果模型是分类器,则返回true;如果是回归,则返回false。 -
getVarCount():返回训练样本中的变量数。 -
save(const string& filename): 这将模型保存到文件名指定的位置 -
Ptr<_Tp> load(const string& filename): 这将从文件名中加载模型,例如:Ptr<SVM> svm = StatModel::load<SVM>("my_svm_model.xml"); -
calcError(const Ptr<TrainData>& data, bool test, OutputArray resp): 这从测试数据中计算误差,其中数据是训练数据。如果测试为true,该方法计算所有训练数据测试子集的误差,否则它计算数据训练子集的误差。最后resp是可选的输出结果。
现在,我们将学习如何构建一个基本的应用程序,该程序在计算机视觉应用中使用机器学习。
计算机视觉和机器学习工作流程
带有机器学习的计算机视觉应用具有一个共同的基本结构。这个结构被划分为不同的步骤,这些步骤几乎在所有计算机视觉应用中都会重复,而有些步骤则被省略。在下面的图中,我们展示了涉及的不同步骤:

几乎任何计算机视觉应用都以一个预处理阶段开始,该阶段应用于输入图像。预处理包括光照条件去除、噪声、阈值、模糊等。
在我们对输入图像应用所有必要的预处理步骤之后,第二步是分割。在分割步骤中,我们需要提取图像中的感兴趣区域,并将每个区域隔离为独特的感兴趣对象。例如,在人脸检测系统中,我们需要将人脸与场景中的其他部分分开。
在获取图像中的对象之后,我们继续下一步。我们需要提取每个检测到的对象的全部特征;特征是对象特性的向量。特性描述我们的对象,可以是对象的面积、轮廓、纹理图案等。
现在,我们有了我们对象的描述符;描述符是描述对象的特征,我们使用这些描述符来训练我们的模型或预测其中的一个。为此,我们需要创建一个包含数百、数千和数百万图像的特征大数据集,这些图像经过预处理,并提取的特征用于我们选择的训练模型函数中:

当我们训练一个数据集时,模型学习所有必要的参数,以便在给定一个具有未知标签的新特征向量时进行预测:

在我们得到预测结果后,有时需要对输出数据进行后处理;例如,合并多个分类以减少预测误差或合并多个标签。一个示例是OCR(光学字符识别),其中分类结果是每个字符,通过结合字符识别的结果,我们构建一个单词。这意味着我们可以创建一个后处理方法来纠正检测到的单词中的错误。
通过对计算机视觉中机器学习的简要介绍,我们将学习如何实现我们自己的应用,该应用使用机器学习对幻灯片带中的对象进行分类。我们将使用支持向量机作为我们的分类方法,并了解如何使用它们。其他机器学习算法有非常相似的应用。OpenCV 文档提供了所有机器学习算法的详细信息。
自动对象检测分类示例
继续上一章的例子,自动对象检测分割,其中载体带包含三种不同类型的对象(螺母、螺丝和环),利用计算机视觉,我们将能够识别每一个,并发送通知给机器人或类似设备将它们放入不同的盒子中。

在第五章自动光学检测、对象分割和检测中,我们预处理了输入图像,并使用不同的技术提取了图像的兴趣区域和隔离每个对象。现在,我们将应用前几节中解释的所有这些概念,在这个例子中提取特征并对每个对象进行分类,以便可能的机器人将它们放入不同的盒子中。在我们的应用中,我们只将展示图像中的每个图像的标签,但我们可以将图像中的位置和标签发送到其他设备,如机器人。
然后,我们的目标是根据以下图像,从包含少量对象的输入图像中显示每个对象的名称,但是为了学习整个过程的全部步骤,我们将训练我们的系统显示每个训练图像,创建一个图表显示每个对象我们将使用不同颜色的特征,预处理后的输入图像,最后,以下结果的输出分类结果:

我们将为我们的示例应用执行以下步骤:
-
对于每个图像的训练:
-
预处理图像
-
分割图像
-
-
对于图像中的每个对象:
-
提取特征
-
将对象及其标签添加到训练特征向量中
-
-
创建 SVM 模型。
-
使用训练特征向量训练我们的 SVM 模型。
-
预处理待分类的输入图像。
-
分割输入图像。
-
对于检测到的每个对象:
-
提取特征
-
使用 SVM 模型进行预测
-
在输出图像上绘制结果
-
对于预处理和分割阶段,我们将使用第五章中讨论的代码,自动光学检测、对象分割和检测,我们将解释如何提取特征并创建训练和预测我们模型所需的向量。
特征提取
现在,让我们提取每个对象的特征。为了理解特征向量的特征概念,我们将提取非常简单的特征,但这足以获得良好的结果。在其他解决方案中,我们可以获得更复杂的特征,例如纹理描述符、轮廓描述符等。
在我们的示例中,我们只有这三种类型的对象,螺母、环和螺丝,在不同的可能位置。所有这些可能的对象和位置都在以下图中展示:

我们将探索有助于计算机识别每个对象的良好特征。特征如下:
-
物体的面积
-
长宽比,即边界矩形的宽度除以高度
-
孔洞的数量
-
轮廓边的数量
这些特征可以很好地描述我们的对象,如果我们使用所有这些特征,分类错误可以非常小。然而,在我们的实现示例中,我们将只使用前两个特征,即面积和宽高比,用于学习目的,因为我们可以在二维图形中绘制这些特征,并且我们可以展示这些值正确地描述了我们的对象。我们可以在图形图中直观地区分一种对象与其他对象。
为了提取这些特征,我们将使用黑色/白色输入 ROI 图像作为输入,其中只有一个对象以白色出现,背景为黑色。这个输入是分割的结果,如第五章中所述,自动光学检测、对象分割和检测。我们将使用 findCountours 算法进行对象分割,并为此创建 ExtractFeatures 函数:
vector< vector<float> > ExtractFeatures(Mat img, vector<int>* left=NULL, vector<int>* top=NULL)
{
vector< vector<float> > output;
vector<vector<Point> > contours;
Mat input= img.clone();
vector<Vec4i> hierarchy;
findContours(input, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE);
// Check the number of objects detected
if(contours.size() == 0 ){
return output;
}
RNG rng( 0xFFFFFFFF );
for(int i=0; i<contours.size(); i++){
Mat mask= Mat::zeros(img.rows, img.cols, CV_8UC1);
drawContours(mask, contours, i, Scalar(1), FILLED, LINE_8, hierarchy, 1);
Scalar area_s= sum(mask);
float area= area_s[0];
if(area>500){ //if the area is greather than min.
RotatedRect r= minAreaRect(contours[i]);
float width= r.size.width;
float height= r.size.height;
float ar=(width<height)?height/width:width/height;
vector<float> row;
row.push_back(area);
row.push_back(ar);
output.push_back(row);
if(left!=NULL){
left->push_back((int)r.center.x);
}
if(top!=NULL){
top->push_back((int)r.center.y);
}
miw->addImage("Extract Features", mask*255);
miw->render();
waitKey(10);
}
}
return output;
}
让我们详细理解一下代码。
我们将创建一个函数,该函数以一个图像作为输入,并返回每个在图像中检测到的对象的左和顶位置的两个向量作为参数;这将用于在每个对象上绘制其标签。该函数的输出是一个浮点向量向量的向量;换句话说,是一个矩阵,其中每一行包含检测到的每个对象的特征。
让我们创建一个函数,在每一个对象上绘制标签:
-
首先,我们需要创建输出向量变量和轮廓变量,这些变量将在我们的
FindContours算法分割中使用,并且我们需要创建输入图像的一个副本,因为findContoursOpenCV 函数会修改输入图像:vector< vector<float> > output; vector<vector<Point> > contours; Mat input= img.clone(); vector<Vec4i> hierarchy; findContours(input, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE); -
现在,我们可以使用
findContours函数检索图像中的每个对象。如果我们没有检测到任何轮廓,我们返回一个空输出矩阵:if(contours.size() == 0 ){ return output; } -
对于我们将要在黑色图像中绘制的每个
contour对象,我们使用1作为颜色值。这是我们用于计算所有特征的掩码图像:for(int i=0; i<contours.size(); i++){ Mat mask= Mat::zeros(img.rows, img.cols, CV_8UC1); drawContours(mask, contours, i, Scalar(1), FILLED, LINE_8, hierarchy, 1); -
使用值
1在形状内部绘制非常重要,因为我们可以通过计算轮廓内的所有值来计算面积:Scalar area_s= sum(mask); float area= area_s[0]; -
这个面积是我们的第一个特征。现在,我们将使用这个面积值作为过滤器,以移除我们需要避免的小对象。所有面积小于最小面积的物体都被丢弃。在通过过滤器之后,我们创建第二个特征,即物体的长宽比。这意味着最大宽度或高度除以最小宽度或高度。这个特征可以很容易地将螺丝与其他物体区分开来:
if(area>MIN_AREA){ //if the area is greather than min. RotatedRect r= minAreaRect(contours[i]); float width= r.size.width; float height= r.size.height; float ar=(width<height)?height/width:width/height; -
现在,我们已经有了这些特征,我们只需要将这些特征添加到输出向量中。为此,我们创建一个浮点行向量并添加这些值,稍后,将这个行向量添加到输出向量中:
vector<float> row; row.push_back(area); row.push_back(ar); output.push_back(row); -
如果传递了左上角
params,则将左上角的值添加到params输出中:if(left!=NULL){ left->push_back((int)r.center.x); } if(top!=NULL){ top->push_back((int)r.center.y); } -
最后,我们将向用户展示检测到的对象,以便获取用户反馈,当我们处理完图像中的所有对象后,我们将返回输出特征向量:
miw->addImage("Extract Features", mask*255); miw->render(); waitKey(10); } } return output;
现在,我们可以提取每个输入图像的特征,并需要继续下一步,即训练我们的模型。
训练 SVM 模型
我们将使用一个监督学习模型,然后,我们需要每个对象的图像及其相应的标签。数据集中没有图像的最小数量限制。如果我们为训练过程提供更多图像,我们将得到一个更好的分类模型(在大多数情况下),但简单的分类器足以训练简单的模型。为此,我们创建了三个文件夹(screw、nut 和 ring),其中每个类型的所有图像都放在一起。
对于文件夹中的每个图像,我们需要提取特征并将它们添加到训练特征矩阵中,同时,我们还需要创建一个新向量,其中包含每行的标签,对应于每个训练矩阵。
为了评估我们的系统,我们将每个文件夹分成用于测试和训练目的的多个图像。我们保留大约 20 个图像用于测试,其余的用于训练。然后,我们需要创建两个标签向量和两个用于训练和测试的矩阵。
然后,让我们理解一下代码。首先,我们需要创建我们的模型。我们需要声明模型以便能够将其作为全局变量访问。OpenCV 使用 Ptr 模板类来处理指针:
Ptr<SVM> svm;
在我们声明新的 SVM 模型指针之后,我们需要创建它并对其进行训练。为此,我们创建了一个 trainAndTest 函数:
void trainAndTest()
{
vector< float > trainingData;
vector< int > responsesData;
vector< float > testData;
vector< float > testResponsesData;
int num_for_test= 20;
// Get the nut images
readFolderAndExtractFeatures("../data/nut/tuerca_%04d.pgm", 0, num_for_test, trainingData, responsesData, testData, testResponsesData);
// Get and process the ring images
readFolderAndExtractFeatures("../data/ring/arandela_%04d.pgm", 1, num_for_test, trainingData, responsesData, testData, testResponsesData);
// get and process the screw images
readFolderAndExtractFeatures("../data/screw/tornillo_%04d.pgm", 2, num_for_test, trainingData, responsesData, testData, testResponsesData);
cout << "Num of train samples: " << responsesData.size() << endl;
cout << "Num of test samples: " << testResponsesData.size() << endl;
// Merge all data
Mat trainingDataMat(trainingData.size()/2, 2, CV_32FC1, &trainingData[0]);
Mat responses(responsesData.size(), 1, CV_32SC1, &responsesData[0]);
Mat testDataMat(testData.size()/2, 2, CV_32FC1, &testData[0]);
Mat testResponses(testResponsesData.size(), 1, CV_32FC1, &testResponsesData[0]);
svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::CHI2);
svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));
svm->train(trainingDataMat, ROW_SAMPLE, responses);
if(testResponsesData.size()>0){
cout << "Evaluation" << endl;
cout << "==========" << endl;
// Test the ML Model
Mat testPredict;
svm->predict(testDataMat, testPredict);
cout << "Prediction Done" << endl;
// Error calculation
Mat errorMat= testPredict!=testResponses;
float error= 100.0f * countNonZero(errorMat) /testResponsesData.size();
cout << "Error: " << error << "\%" << endl;
// Plot training data with error label
plotTrainData(trainingDataMat, responses, &error);
}else{
plotTrainData(trainingDataMat, responses);
}
}
让我们详细理解一下代码。
首先,我们需要创建存储训练和测试数据的所需变量:
vector< float > trainingData;
vector< int > responsesData;
vector< float > testData;
vector< float > testResponsesData;
如前所述,我们需要从每个文件夹中读取所有图像,提取特征,并将它们保存到我们的训练和测试数据中。为此,我们将使用readFolderAndExtractFeatures函数:
int num_for_test= 20;
// Get the nut images
readFolderAndExtractFeatures("../data/nut/tuerca_%04d.pgm", 0, num_for_test, trainingData, responsesData, testData, testResponsesData);
// Get and process the ring images
readFolderAndExtractFeatures("../data/ring/arandela_%04d.pgm", 1, num_for_test, trainingData, responsesData, testData, testResponsesData);
// get and process the screw images
readFolderAndExtractFeatures("../data/screw/tornillo_%04d.pgm", 2, num_for_test, trainingData, responsesData, testData, testResponsesData);
readFolderAndExtractFeatures函数使用 OpenCV 的VideoCapture函数读取文件夹中的所有图像,就像视频或摄像头一样。对于每个读取的图像,我们提取特征,然后将它们添加到相应的输出向量中:
bool readFolderAndExtractFeatures(string folder, int label, int num_for_test,
vector<float> &trainingData, vector<int> &responsesData,
vector<float> &testData, vector<float> &testResponsesData)
{
VideoCapture images;
if(images.open(folder)==false){
cout << "Can not open the folder images" << endl;
return false;
}
Mat frame;
int img_index=0;
while( images.read(frame) ){
//// Preprocess image
Mat pre= preprocessImage(frame);
// Extract features
vector< vector<float> > features= ExtractFeatures(pre);
for(int i=0; i< features.size(); i++){
if(img_index >= num_for_test){
trainingData.push_back(features[i][0]);
trainingData.push_back(features[i][1]);
responsesData.push_back(label);
}else{
testData.push_back(features[i][0]);
testData.push_back(features[i][1]);
testResponsesData.push_back((float)label);
}
}
img_index++;
}
return true;
}
在将所有向量填充好特征和标签后,我们需要将它们转换为 OpenCV mat格式,以便将它们发送到training函数:
// Merge all data
Mat trainingDataMat(trainingData.size()/2, 2, CV_32FC1, &trainingData[0]);
Mat responses(responsesData.size(), 1, CV_32SC1, &responsesData[0]);
Mat testDataMat(testData.size()/2, 2, CV_32FC1, &testData[0]);
Mat testResponses(testResponsesData.size(), 1, CV_32FC1, &testResponsesData[0]);
正如之前提到的,我们现在准备创建和训练我们的机器学习模型,我们将使用支持向量机。首先,我们需要设置基本模型参数:
// Set up SVM's parameters
svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::CHI2);
svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));
我们需要定义要使用的 SVM 类型和核,以及停止学习过程的准则;在我们的情况下,我们将使用最大迭代次数,停止在 100 次迭代。有关每个参数及其功能的更多信息,请参阅 OpenCV 文档。在创建设置参数后,我们需要通过调用train方法并使用trainingDataMat和响应矩阵来创建模型:
// Train the SVM
svm->train(trainingDataMat, ROW_SAMPLE, responses);
我们使用测试向量(通过将num_for_test变量设置为大于0)来获得我们模型的近似误差。为了获得误差估计,我们需要预测所有测试向量特征以获得 SVM 预测结果,然后比较这些结果与原始标签:
if(testResponsesData.size()>0){
cout << "Evaluation" << endl;
cout << "==========" << endl;
// Test the ML Model
Mat testPredict;
svm->predict(testDataMat, testPredict);
cout << "Prediction Done" << endl;
// Error calculation
Mat errorMat= testPredict!=testResponses;
float error= 100.0f * countNonZero(errorMat) / testResponsesData.size();
cout << "Error: " << error << "\%" << endl;
// Plot training data with error label
plotTrainData(trainingDataMat, responses, &error);
}else{
plotTrainData(trainingDataMat, responses);
}
我们使用predict函数,利用testDataMat特征和一个新的mat来预测结果。predict函数允许你同时进行多个预测,返回一个矩阵而不是只有一行。
预测完成后,我们只需要使用我们的testResponses(原始标签)获取testPredict的差异。如果有差异,我们只需要计算差异的数量,并将它们除以测试总数以获得错误。
注意
我们可以使用新的TrainData类生成特征向量和样本,并将训练数据分割成测试和训练向量。
最后,我们需要在 2D 图中显示训练数据,其中y轴是宽高比特征,x轴是物体的面积。每个点都有不同的颜色和形状(交叉、正方形和圆形),表示不同类型的物体,我们可以在以下图中清楚地看到物体的组:

现在,我们非常接近完成我们的应用程序样本。我们有一个训练好的 SVM 模型,我们可以将其用作分类模型来检测新到达的未知特征向量类型。然后,下一步是预测包含未知物体的输入图像。
输入图像预测
现在,我们准备解释主要功能,该功能加载输入图像并预测出现在内部的物体。我们将使用如下所示的内容作为输入图像,其中出现多个不同的物体,如图所示:

对于所有训练图像,我们需要加载和预处理输入图像:
-
首先,我们将图像加载并转换为灰度颜色值。
-
然后,我们应用前面讨论的预处理任务,使用
preprocessImage函数,如第五章,自动光学检测、目标分割和检测:Mat pre= preprocessImage(img); -
现在,我们使用前面提到的
ExtractFeatures提取图像中所有出现的物体的特征以及每个物体的左上角位置:// Extract features vector<int> pos_top, pos_left; vector< vector<float> > features= ExtractFeatures(pre, &pos_left, &pos_top); -
对于我们检测到的每个物体,我们将其存储为特征行,然后,我们将每一行转换为具有一行和两个特征的
Mat:for(int i=0; i< features.size(); i++){ Mat trainingDataMat(1, 2, CV_32FC1, &features[i][0]); -
然后,我们使用我们的
StatModelSVM 的predict函数预测单个物体:float result= svm->predict(trainingDataMat);预测的浮点结果是检测到的物体的标签。然后,为了完成应用程序,我们只需要在输出图像的每个图像上绘制标签。
-
我们将使用
stringstream来存储文本,使用Scalar来存储每个不同标签的颜色:stringstream ss; Scalar color; if(result==0){ color= green; // NUT ss << "NUT"; } else if(result==1){ color= blue; // RING ss << "RING" ; } else if(result==2){ color= red; // SCREW ss << "SCREW"; } -
使用
ExtractFeatures函数中检测到的位置在每个物体上绘制标签文本:putText(img_output, ss.str(), Point2d(pos_left[i], pos_top[i]), FONT_HERSHEY_SIMPLEX, 0.4, color); -
最后,我们将结果绘制在输出窗口中:
miw->addImage("Binary image", pre); miw->addImage("Result", img_output); miw->render(); waitKey(0);
我们应用程序的最终结果显示一个由四个屏幕拼贴的窗口,其中左上角的图像是输入训练图像,右上角的图像是绘图训练图像,左下角的图像是分析预处理后的输入图像,右下角的图像是预测的最终结果:

摘要
在本章中,我们学习了机器学习模型的基础知识以及如何应用一个小型样例应用来理解创建我们自己的 ML 应用所需的所有基本技巧。
机器学习是复杂的,涉及针对每个用例的不同技术(监督学习、无监督学习、聚类等),我们学习了如何创建最典型的 ML 应用以及使用 SVM 的监督学习。
监督机器学习中的最重要的概念是:首先,我们需要有适当数量的样本或数据集;其次,我们需要正确选择描述我们对象的特征。有关图像特征的更多信息,请参阅第八章,视频监控、背景建模和形态学操作。第三,选择给我们最佳预测的最佳模型。
如果我们没有达到正确的预测,我们必须检查这些概念中的每一个,以寻找问题所在。
在下一章中,我们将介绍背景减法方法,这对于视频监控应用非常有用,在这些应用中,背景不提供任何有趣的信息,必须被丢弃,以便对感兴趣的对象进行分割和分析。
第七章:检测面部部位和叠加口罩
在上一章中,我们学习了对象分类以及如何使用机器学习来实现它。在本章中,我们将学习如何检测和跟踪不同的面部部位。我们将从理解面部检测流程及其从底层构建的方式开始讨论。然后,我们将使用这个框架来检测面部部位,如眼睛、耳朵、嘴巴和鼻子。接下来,我们将学习如何在实时视频中将这些面部部位叠加有趣的口罩。
在本章中,我们将涵盖以下主题:
-
使用 Haar 级联进行工作
-
整数图像及其必要性
-
构建通用的面部检测流程
-
在网络摄像头实时视频流中检测和跟踪面部部位,如眼睛、耳朵、鼻子和嘴巴
-
在视频中自动叠加面部口罩、太阳镜和有趣的鼻子
理解 Haar 级联
Haar 级联是基于 Haar 特征的级联分类器。什么是级联分类器?它简单地说是一系列弱分类器的串联,这些弱分类器可以用来创建一个强分类器。那么,我们所说的弱和强分类器是什么意思呢?弱分类器是性能有限的分类器。它们没有正确分类所有事物的能力。如果你把问题简化到极致,它们可能达到可接受的水平。另一方面,强分类器在正确分类我们的数据方面非常出色。我们将在接下来的几段中看到这一切是如何结合在一起的。Haar 级联的另一个重要部分是Haar 特征。这些特征是矩形及其在图像中不同区域差值的简单求和。让我们考虑以下图示:

如果我们要计算区域 ABCD 的 Haar 特征,我们只需要计算该区域中白色像素和彩色像素之间的差异。如图所示,我们使用不同的模式来构建 Haar 特征。还有许多其他模式也被用作此目的。我们在多个尺度上这样做,以使系统对给定对象的尺寸变化具有鲁棒性。当我们说多个尺度时,我们只是将图像缩小以再次计算相同的特征。这样,我们可以使其对给定对象的尺寸变化具有鲁棒性。
注意
事实证明,这种串联系统是检测图像中对象的一种非常好的方法。在 2001 年,保罗·维奥拉和迈克尔·琼斯发表了一篇开创性的论文,其中他们描述了一种快速有效的对象检测方法。如果你对此感兴趣,可以查看他们的论文:www.cs.ubc.ca/~lowe/425/slides/13-ViolaJones.pdf。
让我们深入探讨,了解它们实际上做了什么。他们基本上描述了一个使用简单分类器增强级联的算法。这个系统用于构建一个能够真正表现良好的强大分类器。为什么他们使用这些简单分类器而不是更准确、更复杂的分类器呢?嗯,使用这种技术,他们能够避免构建一个能够以高精度执行的单个分类器的问题。这些单步分类器往往很复杂且计算密集。他们的技术之所以如此有效,是因为简单分类器可以是弱学习器,这意味着它们不需要很复杂。
考虑构建表格检测器的问题。我们希望构建一个能够自动学习表格外观的系统。基于这个知识,它应该能够识别任何给定图像中是否存在表格。为了构建这个系统,第一步是收集可以用来训练我们系统的图像。在机器学习领域有许多技术可以用来训练这样的系统。记住,如果我们想让我们的系统表现良好,我们需要收集大量的表格和非表格图像。在机器学习的术语中,表格图像被称为正样本,而非表格图像被称为负样本。我们的系统将摄取这些数据,然后学习区分这两类。
为了构建一个实时系统,我们需要保持我们的分类器既简洁又简单。唯一的问题是简单的分类器并不非常准确。如果我们试图使它们更准确,那么它们最终会变得计算密集,从而变慢。这种在准确性和速度之间的权衡在机器学习中非常常见。因此,我们将通过连接多个弱分类器来创建一个强大且统一的分类器来克服这个问题。我们不需要弱分类器非常准确。为了确保整体分类器的质量,Viola 和 Jones 在级联步骤中描述了一种巧妙的技术。你可以阅读这篇论文来了解完整的系统。
既然我们已经了解了整个流程,让我们看看如何构建一个能够在实时视频中检测人脸的系统。第一步是从所有图像中提取特征。在这种情况下,算法需要这些特征来学习和理解人脸的外观。他们在论文中使用了 Haar 特征来构建特征向量。一旦我们提取了这些特征,我们就将它们通过一系列分类器。我们只是检查所有不同的矩形子区域,并丢弃其中没有人脸的子区域。这样,我们就可以快速得出结论,看给定的矩形是否包含人脸。
什么是积分图?
为了提取这些 Haar 特征,我们需要计算图像中许多矩形区域内像素值的总和。为了使其尺度不变,我们需要在多个尺度(即不同矩形大小)上计算这些面积。如果天真地实现,这将是一个非常计算密集的过程。我们必须遍历每个矩形的所有像素,包括如果它们包含在不同的重叠矩形中,则多次读取相同的像素。如果你想要构建一个可以在实时运行的系统,你不能在计算上花费这么多时间。我们需要找到一种方法来避免在面积计算中的这种巨大冗余,因为我们多次遍历相同的像素。为了避免这种情况,我们可以使用一种称为积分图像的东西。这些图像可以在线性时间内初始化(通过只遍历图像两次)并且可以通过读取仅四个值来提供任何大小矩形内像素的总和。为了更好地理解它,让我们看一下下面的图:

如果我们想在我们的图像中计算任何矩形的面积,我们不必遍历该区域的所有像素。让我们考虑由图像中的左上角点和任何点 P 作为对角线顶点形成的矩形。让我们用 A[P]表示这个矩形的面积。例如,在前面图中,A[B]表示由左上角点和 B 作为对角线顶点形成的 5 X 2 矩形的面积。为了更清晰地说明,让我们看一下下面的图:

让我们看一下前面图中的左上角图。彩色像素表示从左上角像素到点 A 之间的区域。这表示为 A[A]。其余的图分别用它们各自的名字表示:A[B]、A[C]和 A[D]。现在,如果我们想计算前面图中所示矩形 ABCD 的面积,我们将使用以下公式:
矩形 ABCD 的面积 = A[C] – (A[B] + A[D] - A[A])
这个特定公式有什么特别之处呢?正如我们所知,从图像中提取 Haar 特征包括计算这些求和,而且我们不得不在图像中的多个尺度上对很多矩形进行计算。这些计算中的许多都是重复的,因为我们不得不反复遍历相同的像素。这非常慢,以至于构建实时系统将不可行。因此,我们需要这个公式。正如你所见,我们不必多次遍历相同的像素。如果我们想计算任何矩形的面积,上一个方程右侧的所有值都在我们的积分图像中 readily available。我们只需取出正确的值,将它们代入上一个方程,并提取特征。
在实时视频中叠加人脸面具
OpenCV 提供了一个优秀的面部检测框架。我们只需要加载级联文件,并使用它来检测图像中的面部。当我们从摄像头捕获视频流时,我们可以在我们的脸上叠加有趣的口罩。它看起来可能像这样:

让我们来看看代码的主要部分,看看如何将前面的口罩叠加到输入视频流中的脸部上方。完整的代码包含在本书提供的可下载代码包中:
int main(int argc, char* argv[])
{
string faceCascadeName = argv[1];
// Variable declarations and initializations
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Convert to grayscale
cvtColor(frame, frameGray, CV_BGR2GRAY);
// Equalize the histogram
equalizeHist(frameGray, frameGray);
// Detect faces
faceCascade.detectMultiScale(frameGray, faces, 1.1, 2, 0|CV_HAAR_SCALE_IMAGE, Size(30, 30) );
让我们看看这里发生了什么。我们从摄像头读取输入帧,并将其调整到我们选择的大小。捕获的帧是一个彩色图像,而人脸检测是在灰度图像上进行的。因此,我们将其转换为灰度并均衡直方图。为什么我们需要均衡直方图?我们需要这样做是为了补偿任何类型的问题,例如光照、饱和度等。如果图像太亮或太暗,检测效果会差。因此,我们需要均衡直方图,以确保我们的图像具有健康的像素值范围:
// Draw green rectangle around the face
for(int i = 0; i < faces.size(); i++)
{
Rect faceRect(faces[i].x, faces[i].y, faces[i].width, faces[i].height);
// Custom parameters to make the mask fit your face. You may have to play around with them to make sure it works.
int x = faces[i].x - int(0.1*faces[i].width);
int y = faces[i].y - int(0.0*faces[i].height);
int w = int(1.1 * faces[i].width);
int h = int(1.3 * faces[i].height);
// Extract region of interest (ROI) covering your face
frameROI = frame(Rect(x,y,w,h));
到目前为止,我们知道脸部的位置。因此,我们提取感兴趣的区域,以便在正确的位置叠加口罩:
// Resize the face mask image based on the dimensions of the above ROI
resize(faceMask, faceMaskSmall, Size(w,h));
// Convert the above image to grayscale
cvtColor(faceMaskSmall, grayMaskSmall, CV_BGR2GRAY);
// Threshold the above image to isolate the pixels associated only with the face mask
threshold(grayMaskSmall, grayMaskSmallThresh, 230, 255, CV_THRESH_BINARY_INV);
我们已经隔离了与脸部口罩相关的像素。现在,我们想要以不像是矩形的方式叠加口罩。我们想要叠加对象的精确边界,使其看起来自然。现在我们就来叠加口罩:
// Create mask by inverting the above image (because we don't want the background to affect the overlay)
bitwise_not(grayMaskSmallThresh, grayMaskSmallThreshInv);
// Use bitwise "AND" operator to extract precise boundary of face mask
bitwise_and(faceMaskSmall, faceMaskSmall, maskedFace, grayMaskSmallThresh);
// Use bitwise "AND" operator to overlay face mask
bitwise_and(frameROI, frameROI, maskedFrame, grayMaskSmallThreshInv);
// Add the above masked images and place it in the original frame ROI to create the final image
add(maskedFace, maskedFrame, frame(Rect(x,y,w,h)));
}
// code dealing with memory release and GUi
return 1;
}
代码中发生了什么?
首先要注意的是,此代码接受两个输入参数:面部级联xml文件和口罩图像。你可以使用提供的haarcascade_frontalface_alt.xml和facemask.jpg文件。我们需要一个分类器模型,它可以用来检测图像中的面部,OpenCV 提供了一个预构建的 xml 文件,可用于此目的。我们使用faceCascade.load()函数加载xml文件,并检查文件是否已正确加载。
我们初始化视频捕获对象以捕获摄像头输入的帧。然后我们将其转换为灰度以运行检测器。detectMultiScale函数用于提取输入图像中所有面部的边界。我们可能需要根据需要缩小图像,因此该函数的第二个参数负责这一点。这个缩放因子是我们每次缩放时采取的跳跃。由于我们需要在多个尺度上查找面部,下一个大小将是当前大小的 1.1 倍。最后一个参数是一个阈值,它指定了需要保留当前矩形的相邻矩形数量。它可以用来增加面部检测器的鲁棒性。
我们启动while循环,并在用户按下Esc键之前,在每一帧中持续检测人脸。一旦我们检测到人脸,我们就需要在其上叠加一个掩码。我们可能需要稍微调整尺寸以确保掩码贴合得很好。这种定制略为主观,并且取决于所使用的掩码。现在我们已经提取了感兴趣区域,我们需要在这个区域上方放置我们的掩码。如果我们用带有白色背景的掩码叠加,看起来会很奇怪。我们需要提取掩码的确切曲线边界并叠加它。我们希望颅骨掩码像素可见,而剩余区域透明。
如我们所见,输入掩码有一个白色背景。因此,我们通过对掩码图像应用阈值来创建掩码。通过试错法,我们可以看到 240 的阈值效果很好。在图像中,所有强度值大于 240 的像素将变为 0,而其他所有像素将变为 255。至于图像中的感兴趣区域,我们需要将这个区域内的所有像素变黑。为此,我们只需使用刚刚创建的掩码的逆掩码即可。在最后一步,我们将掩码版本相加以生成最终的输出图像。
戴上你的太阳镜
现在我们已经了解了如何检测人脸,我们可以将这个概念推广到检测人脸的不同部分。我们将使用眼睛检测器在实时视频中叠加太阳镜。重要的是要理解 Viola-Jones 框架可以应用于任何对象。准确性和鲁棒性将取决于对象的独特性。例如,人脸具有非常独特的特征,因此很容易训练我们的系统以使其鲁棒。另一方面,像毛巾这样的对象过于通用,没有这样的区分特征。因此,构建鲁棒的毛巾检测器会更困难。
一旦你构建了眼睛检测器并在其上方叠加眼镜,它看起来会是这样:

让我们来看看代码的主要部分:
int main(int argc, char* argv[])
{
string faceCascadeName = argv[1];
string eyeCascadeName = argv[2];
// Variable declarations and initializations
// Face detection code
vector<Point> centers;
// Draw green circles around the eyes
for(int i = 0; i < faces.size(); i++)
{
Mat faceROI = frameGray(faces[i]);
vector<Rect> eyes;
// In each face, detect eyes
eyeCascade.detectMultiScale(faceROI, eyes, 1.1, 2, 0 |CV_HAAR_SCALE_IMAGE, Size(30, 30));
如我们所见,我们只在面部区域运行眼睛检测器。我们不需要在整个图像中搜索眼睛,因为我们知道眼睛总是在你的脸上:
// For each eye detected, compute the center
for(int j = 0; j < eyes.size(); j++)
{
Point center( faces[i].x + eyes[j].x + int(eyes[j].width*0.5), faces[i].y + eyes[j].y + int(eyes[j].height*0.5) );
centers.push_back(center);
}
}
// Overlay sunglasses only if both eyes are detected
if(centers.size() == 2)
{
Point leftPoint, rightPoint;
// Identify the left and right eyes
if(centers[0].x < centers[1].x)
{
leftPoint = centers[0];
rightPoint = centers[1];
}
else
{
leftPoint = centers[1];
rightPoint = centers[0];
}
我们检测眼睛,并且只有在找到两只眼睛时才将它们存储起来。然后我们使用它们的坐标来确定哪只是左眼哪只是右眼:
// Custom parameters to make the sunglasses fit your face. You may have to play around with them to make sure it works.
int w = 2.3 * (rightPoint.x - leftPoint.x);
int h = int(0.4 * w);
int x = leftPoint.x - 0.25*w;
int y = leftPoint.y - 0.5*h;
// Extract region of interest (ROI) covering both the eyes
frameROI = frame(Rect(x,y,w,h));
// Resize the sunglasses image based on the dimensions of the above ROI
resize(eyeMask, eyeMaskSmall, Size(w,h));
在前面的代码中,我们调整了太阳镜的大小以适应我们在网络摄像头中的人脸比例:
// Convert the above image to grayscale
cvtColor(eyeMaskSmall, grayMaskSmall, CV_BGR2GRAY);
// Threshold the above image to isolate the foreground object
threshold(grayMaskSmall, grayMaskSmallThresh, 245, 255, CV_THRESH_BINARY_INV);
// Create mask by inverting the above image (because we don't want the background to affect the overlay)
bitwise_not(grayMaskSmallThresh, grayMaskSmallThreshInv);
// Use bitwise "AND" operator to extract precise boundary of sunglasses
bitwise_and(eyeMaskSmall, eyeMaskSmall, maskedEye, grayMaskSmallThresh);
// Use bitwise "AND" operator to overlay sunglasses
bitwise_and(frameROI, frameROI, maskedFrame, grayMaskSmallThreshInv);
// Add the above masked images and place it in the original frame ROI to create the final image
add(maskedEye, maskedFrame, frame(Rect(x,y,w,h)));
}
// code for memory release and GUI
return 1;
}
查看代码内部
如果你注意到,代码的流程看起来与我们之前讨论的人脸检测代码相似。我们加载了人脸检测级联分类器以及眼睛检测级联分类器。那么,为什么在检测眼睛时我们需要加载人脸级联分类器呢?好吧,我们实际上并不需要使用人脸检测器,但它帮助我们限制了对眼睛位置的搜索。我们知道眼睛总是位于某人的脸上,因此我们可以将眼睛检测限制在面部区域。第一步是检测人脸,然后在这个区域上运行我们的眼睛检测代码。由于我们将在较小的区域上操作,这将更快、更高效。
对于每一帧,我们首先检测人脸。然后我们继续在这个区域上检测眼睛的位置。在这个步骤之后,我们需要叠加太阳镜。为了做到这一点,我们需要调整太阳镜图像的大小,确保它适合我们的脸。为了得到正确的比例,我们可以考虑被检测到的两只眼睛之间的距离。我们只在检测到两只眼睛时叠加太阳镜。这就是为什么我们首先运行眼睛检测器,收集所有中心点,然后叠加太阳镜。一旦我们有了这个,我们只需要叠加太阳镜的面具。用于面具的原则与我们用来叠加面部面具的原则非常相似。你可能需要根据你的需求自定义太阳镜的大小和位置。你可以尝试不同的太阳镜类型,看看它们看起来如何。
跟踪你的鼻子、嘴巴和耳朵
现在你已经知道了如何使用框架跟踪不同的事物,你可以尝试跟踪你的鼻子、嘴巴和耳朵。让我们使用一个鼻子检测器在上方叠加一个有趣的鼻子:

你可以参考代码文件以获取此检测器的完整实现。有一些级联文件名为haarcascade_mcs_nose.xml、haarcascade_mcs_mouth.xml、haarcascade_mcs_leftear.xml和haarcascade_mcs_rightear.xml,可以用来跟踪不同的面部部位。因此,你可以玩弄它们,尝试给自己叠加一个胡须或德古拉耳朵!
摘要
在本章中,我们讨论了 Haar 级联和积分图像。我们学习了如何构建人脸检测流程。我们学习了如何在实时视频流中检测和跟踪人脸。我们讨论了如何使用人脸检测框架来检测各种面部部位,例如眼睛、耳朵、鼻子和嘴巴。我们还学习了如何使用面部部位检测的结果在输入图像上叠加面具。
在下一章中,我们将学习关于视频监控、背景去除和形态学图像处理的内容。
第八章:视频监控、背景建模和形态学操作
在本章中,我们将学习如何检测从静态摄像机拍摄的视频中的移动对象。这在视频监控系统中被广泛使用。我们将讨论可以用来构建此系统的不同特征。我们将了解背景建模,并看看我们如何可以使用它来构建实时视频中的背景模型。一旦我们这样做,我们将结合所有模块来检测视频中的感兴趣对象。
到本章结束时,你应该能够回答以下问题:
-
什么是天真背景减法?
-
什么是帧差分?
-
如何构建背景模型?
-
如何在静态视频中识别新对象?
-
形态学图像处理是什么?它与背景建模有何关系?
-
如何使用形态学算子实现不同的效果?
理解背景减法
背景减法在视频监控中非常有用。基本上,背景减法技术在需要检测静态场景中移动对象的情况下表现非常好。现在,这对视频监控有什么用呢?视频监控的过程涉及处理恒定的数据流。数据流始终在流入,我们需要分析它以识别任何可疑活动。让我们考虑酒店大堂的例子。所有墙壁和家具都有固定的位置。现在,如果我们构建一个背景模型,我们可以用它来识别大堂中的可疑活动。我们可以利用背景场景保持静态的事实(在这个例子中恰好是真实的)。这有助于我们避免任何不必要的计算开销。
正如其名所示,这个算法通过检测背景并将图像中的每个像素分配到两个类别:背景(假设它是静态和稳定的)或前景。然后它从当前帧中减去背景以获得前景。根据静态假设,前景对象将自然对应于在背景前移动的对象或人。
为了检测移动对象,我们首先需要构建背景模型。这不同于直接帧差分,因为我们实际上是在建模背景并使用此模型来检测移动对象。当我们说我们在“建模背景”时,我们基本上是在构建一个可以用来表示背景的数学公式。因此,这比简单的帧差分技术表现得更好。这种技术试图检测场景中的静态部分,然后更新背景模型。然后,这个背景模型用于检测背景像素。因此,它是一种自适应技术,可以根据场景进行调整。
天真背景减法
让我们从背景减法的讨论开始。背景减法过程是什么样的?考虑以下这张图片:

上一张图片表示的是背景场景。现在,让我们向这个场景中引入一个新的物体:

如前一张图片所示,场景中有一个新的物体。因此,如果我们计算这个图像和我们的背景模型之间的差异,你应该能够识别电视遥控器的位置:

整个过程看起来是这样的:

它是否工作得很好?
我们之所以称之为天真的方法,是有原因的。它在理想条件下是有效的,而我们都知道,在现实世界中没有任何事情是理想的。它能够相当好地计算给定物体的形状,但这是在一定的约束条件下完成的。这种方法的一个主要要求是物体的颜色和强度应该与背景有足够的差异。影响这类算法的一些因素包括图像噪声、光照条件、相机的自动对焦等等。
一旦一个新的物体进入我们的场景并停留下来,就很难检测到它前面的新物体。这是因为我们没有更新我们的背景模型,而新的物体现在成为了我们背景的一部分。考虑以下这张图片:

现在,假设一个新的物体进入我们的场景:

我们将其识别为一个新的物体,这是可以的。假设另一个物体进入场景:

由于这两个不同物体的位置重叠,将很难识别它们的位置。这是在减去背景并应用阈值后的结果:

在这种方法中,我们假设背景是静态的。如果背景的某些部分开始移动,那么这些部分将开始被检测为新的物体。因此,即使移动很小,比如挥动的旗帜,也会导致我们的检测算法出现问题。这种方法对光照变化也很敏感,并且无法处理任何相机移动。不用说,这是一个非常敏感的方法!我们需要能够在现实世界中处理所有这些事情的东西。
帧差分
我们知道我们无法保持一个静态的背景图像来检测对象。因此,解决这个问题的一种方法就是使用帧差分。这是我们能够使用的最简单技术之一,可以用来查看视频的哪些部分在移动。当我们考虑实时视频流时,连续帧之间的差异提供了大量信息。这个概念相当直接。我们只需计算连续帧之间的差异并显示差异。
如果我快速移动我的笔记本电脑,我们可以看到类似这样的情况:

我们不再使用笔记本电脑,而是移动物体并看看会发生什么。如果我快速摇头,它看起来会是这样:

如前图所示,只有视频的移动部分被突出显示。这为我们提供了一个很好的起点,可以看到视频中哪些区域在移动。让我们看看计算帧差异的函数:
Mat frameDiff(Mat prevFrame, Mat curFrame, Mat nextFrame)
{
Mat diffFrames1, diffFrames2, output;
// Compute absolute difference between current frame and the next frame
absdiff(nextFrame, curFrame, diffFrames1);
// Compute absolute difference between current frame and the previous frame
absdiff(curFrame, prevFrame, diffFrames2);
// Bitwise "AND" operation between the above two diff images
bitwise_and(diffFrames1, diffFrames2, output);
return output;
}
帧差分相当直接。你计算当前帧与前一帧以及当前帧与下一帧之间的绝对差异。然后我们应用位与操作符来处理这些帧差异。这将突出显示图像中的移动部分。如果你只是计算当前帧与前一帧之间的差异,它往往会很嘈杂。因此,我们需要在连续帧差异之间使用位与操作符,以便在观察移动对象时获得一些稳定性。
让我们看看可以提取并返回摄像头帧的函数:
Mat getFrame(VideoCapture cap, float scalingFactor)
{
//float scalingFactor = 0.5;
Mat frame, output;
// Capture the current frame
cap >> frame;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Convert to grayscale
cvtColor(frame, output, CV_BGR2GRAY);
return output;
}
如我们所见,这相当直接。我们只需要调整帧的大小并将其转换为灰度图。现在我们已经准备好了辅助函数,让我们看看main函数,看看它是如何整合在一起的:
int main(int argc, char* argv[])
{
Mat frame, prevFrame, curFrame, nextFrame;
char ch;
// Create the capture object
// 0 -> input arg that specifies it should take the input from the webcam
VideoCapture cap(0);
// If you cannot open the webcam, stop the execution!
if( !cap.isOpened() )
return -1;
//create GUI windows
namedWindow("Frame");
// Scaling factor to resize the input frames from the webcam
float scalingFactor = 0.75;
prevFrame = getFrame(cap, scalingFactor);
curFrame = getFrame(cap, scalingFactor);
nextFrame = getFrame(cap, scalingFactor);
// Iterate until the user presses the Esc key
while(true)
{
// Show the object movement
imshow("Object Movement", frameDiff(prevFrame, curFrame, nextFrame));
// Update the variables and grab the next frame
prevFrame = curFrame;
curFrame = nextFrame;
nextFrame = getFrame(cap, scalingFactor);
// Get the keyboard input and check if it's 'Esc'
// 27 -> ASCII value of 'Esc' key
ch = waitKey( 30 );
if (ch == 27) {
break;
}
}
// Release the video capture object
cap.release();
// Close all windows
destroyAllWindows();
return 1;
}
效果如何?
如我们所见,帧差分解决了我们之前遇到的一些重要问题。它可以快速适应光照变化或相机移动。如果一个物体进入画面并停留在那里,它将不会被检测到未来的帧中。这种方法的主要担忧之一是检测颜色均匀的对象。它只能检测颜色均匀对象的边缘。这是因为该对象的大部分区域将导致非常低的像素差异,如下面的图像所示:

假设这个物体稍微移动了一下。如果我们将其与前一帧进行比较,它看起来会是这样:

因此,我们在这个对象上有很少的像素被标记。另一个担忧是,很难检测一个物体是朝向相机移动还是远离相机。
高斯混合方法
在我们讨论高斯混合(MOG)之前,让我们看看什么是混合模型。混合模型只是一个可以用来表示我们数据中存在子群体的统计模型。我们并不真正关心每个数据点属于哪个类别。我们只需要确定数据内部是否有多个组。现在,如果我们用高斯函数来表示每个子群体,那么它就被称为高斯混合。让我们考虑以下图像:

现在,随着我们在这个场景中收集更多的帧,图像的每一部分都将逐渐成为背景模型的一部分。这也是我们之前讨论过的。如果一个场景是静态的,模型会自动调整以确保背景模型得到更新。前景掩码,本应表示前景对象,此时看起来像一张黑图,因为每个像素都是背景模型的一部分。
注意
OpenCV 实现了多个用于高斯混合方法的算法。其中之一被称为MOG,另一个被称为MOG2。要获取详细说明,您可以参考docs.opencv.org/master/db/d5c/tutorial_py_bg_subtraction.html#gsc.tab=0。您还可以查看用于实现这些算法的原始研究论文。
让我们在场景中引入一个新的对象,并看看使用 MOG 方法的前景掩码是什么样的:

让我们等待一段时间,并向场景中引入一个新的对象。让我们看看使用 MOG2 方法的新前景掩码是什么样的:

如您在前面的图像中看到的,新对象被正确地识别了。让我们看看代码的有趣部分(完整的代码可以在.cpp文件中找到):
int main(int argc, char* argv[])
{
// Variable declarations and initializations
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Update the MOG background model based on the current frame
pMOG->operator()(frame, fgMaskMOG);
// Update the MOG2 background model based on the current frame
pMOG2->operator()(frame, fgMaskMOG2);
// Show the current frame
//imshow("Frame", frame);
// Show the MOG foreground mask
imshow("FG Mask MOG", fgMaskMOG);
// Show the MOG2 foreground mask
imshow("FG Mask MOG 2", fgMaskMOG2);
// Get the keyboard input and check if it's 'Esc'
// 27 -> ASCII value of 'Esc' key
ch = waitKey( 30 );
if (ch == 27) {
break;
}
}
// Release the video capture object
cap.release();
// Close all windows
destroyAllWindows();
return 1;
}
代码中发生了什么?
让我们快速浏览一下代码,看看那里发生了什么。我们使用高斯混合模型来创建一个背景减除对象。这个对象代表了一个模型,它将在我们遇到来自摄像头的新的帧时进行更新。正如我们在代码中所看到的,我们初始化了两个背景减除模型:BackgroundSubtractorMOG 和 BackgroundSubtractorMOG2。它们代表了用于背景减除的两个不同算法。第一个指的是由 P. KadewTraKuPong 和 R. Bowden 撰写的论文,标题为 An improved adaptive background mixture model for real-time tracking with shadow detection。您可以在 personal.ee.surrey.ac.uk/Personal/R.Bowden/publications/avbs01/avbs01.pdf 上查看。第二个指的是由 Z.Zivkovic 撰写的论文,标题为 Improved adaptive Gausian Mixture Model for background subtraction。您可以在 www.zoranz.net/Publications/zivkovic2004ICPR.pdf 上查看。我们启动一个无限循环 while,并持续从摄像头读取输入帧。对于每一帧,我们更新背景模型,如下面的代码所示:
pMOG->operator()(frame, fgMaskMOG);
pMOG2->operator()(frame, fgMaskMOG2);
背景模型在这些步骤中更新。现在,如果一个新物体进入场景并停留,它将成为背景模型的一部分。这有助于我们克服朴素背景减除模型的最大缺点之一。
形态学图像处理
如前所述,背景减除方法受许多因素影响。它们的准确性取决于我们如何捕获数据以及如何处理数据。影响这些算法的最大因素之一是噪声水平。当我们说 噪声 时,我们指的是图像中的颗粒感、孤立的黑/白像素等问题。这些问题往往会影响我们算法的质量。这就是形态学图像处理发挥作用的地方。形态学图像处理在许多实时系统中被广泛使用,以确保输出质量。
形态学图像处理是指处理图像中特征形状的过程。例如,你可以使形状变厚或变薄。形态学算子依赖于图像中像素的顺序,但不是它们的值。这就是为什么它们非常适合在二值图像中操作形状。形态学图像处理也可以应用于灰度图像,但像素值不会很重要。
基本原理是什么?
形态学算子使用结构元素来修改图像。什么是结构元素?结构元素基本上是一个可以用来检查图像中小区域的形状。它被放置在图像的所有像素位置,以便它可以检查该邻域。我们基本上取一个小窗口并将其叠加在像素上。根据响应,我们在该像素位置采取适当的行动。
让我们考虑以下输入图像:

我们将对这张图像应用一系列形态学操作,以查看形状如何变化。
瘦身形状
我们可以使用一个称为腐蚀的操作来实现这种效果。这是一个通过剥离图像中所有形状的边界层来使形状变薄的操作:

让我们看看执行形态学腐蚀的函数:
Mat performErosion(Mat inputImage, int erosionElement, int erosionSize)
{
Mat outputImage;
int erosionType;
if(erosionElement == 0)
erosionType = MORPH_RECT;
else if(erosionElement == 1)
erosionType = MORPH_CROSS;
else if(erosionElement == 2)
erosionType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(erosionType, Size(2*erosionSize + 1, 2*erosionSize + 1), Point(erosionSize, erosionSize));
// Erode the image using the structuring element
erode(inputImage, outputImage, element);
// Return the output image
return outputImage;
}
你可以在.cpp文件中查看完整的代码,以了解如何使用此函数。基本上,我们使用内置的 OpenCV 函数构建一个结构元素。此对象用作探针,根据某些条件修改每个像素。这些条件指的是图像中特定像素周围发生的情况。例如,它是被白色像素包围的吗?或者它是被黑色像素包围的吗?一旦我们得到答案,我们就可以在该像素位置采取适当的行动。
加厚形状
我们使用一个称为膨胀的操作来实现加厚。这是一个通过向图像中所有形状添加边界层来使形状变厚的操作:

这里是执行此操作的代码:
Mat performDilation(Mat inputImage, int dilationElement, int dilationSize)
{
Mat outputImage;
int dilationType;
if(dilationElement == 0)
dilationType = MORPH_RECT;
else if(dilationElement == 1)
dilationType = MORPH_CROSS;
else if(dilationElement == 2)
dilationType = MORPH_ELLIPSE;
// Create the structuring element for dilation
Mat element = getStructuringElement(dilationType, Size(2*dilationSize + 1, 2*dilationSize + 1), Point(dilationSize, dilationSize));
// Dilate the image using the structuring element
dilate(inputImage, outputImage, element);
// Return the output image
return outputImage;
}
其他形态学算子
这里有一些其他有趣的形态学算子。让我们首先看看输出图像。我们可以在本节末尾查看代码。
形态学开运算
这是一个打开形状的操作。这个算子常用于图像中的噪声去除。我们可以通过在图像上应用腐蚀后跟膨胀来实现形态学开运算。形态学开运算过程基本上通过将小对象放置在背景中来从图像的前景中移除小对象:

这里是执行形态学开运算的函数:
Mat performOpening(Mat inputImage, int morphologyElement, int morphologySize)
{
Mat outputImage, tempImage;
int morphologyType;
if(morphologyElement == 0)
morphologyType = MORPH_RECT;
else if(morphologyElement == 1)
morphologyType = MORPH_CROSS;
else if(morphologyElement == 2)
morphologyType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));
// Apply morphological opening to the image using the structuring element
erode(inputImage, tempImage, element);
dilate(tempImage, outputImage, element);
// Return the output image
return outputImage;
}
如我们所见,我们对图像应用腐蚀和膨胀来执行形态学开运算。
形态学闭合
这是一个闭合形状的操作,通过填充间隙来实现。这个操作也用于噪声去除。我们通过在图像上应用膨胀后跟腐蚀来实现形态学闭合。这个操作通过将背景中的小对象变成前景来移除前景中的小孔。

让我们快速看一下执行形态学闭合的函数:
Mat performClosing(Mat inputImage, int morphologyElement, int morphologySize)
{
Mat outputImage, tempImage;
int morphologyType;
if(morphologyElement == 0)
morphologyType = MORPH_RECT;
else if(morphologyElement == 1)
morphologyType = MORPH_CROSS;
else if(morphologyElement == 2)
morphologyType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));
// Apply morphological opening to the image using the structuring element
dilate(inputImage, tempImage, element);
erode(tempImage, outputImage, element);
// Return the output image
return outputImage;
}
绘制边界
我们通过形态学梯度来实现这一点。这是一种通过取图像膨胀和腐蚀的差来绘制形状边界的操作:

让我们来看看执行形态学梯度的函数:
Mat performMorphologicalGradient(Mat inputImage, int morphologyElement, int morphologySize)
{
Mat outputImage, tempImage1, tempImage2;
int morphologyType;
if(morphologyElement == 0)
morphologyType = MORPH_RECT;
else if(morphologyElement == 1)
morphologyType = MORPH_CROSS;
else if(morphologyElement == 2)
morphologyType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));
// Apply morphological gradient to the image using the structuring element
dilate(inputImage, tempImage1, element);
erode(inputImage, tempImage2, element);
// Return the output image
return tempImage1 - tempImage2;
}
白顶帽变换
当顶帽变换,也简称为顶帽变换,从图像中提取更细的细节时。我们可以通过计算输入图像与其形态学开运算的差来应用白顶帽变换。这使我们能够识别出图像中比结构元素小且比周围区域亮的物体。因此,根据结构元素的大小,我们可以从给定图像中提取各种物体:

如果你仔细观察输出图像,你可以看到那些黑色矩形。这意味着结构元素能够适应那里,因此这些区域被涂成了黑色。这里是执行此操作的函数:
Mat performTopHat(Mat inputImage, int morphologyElement, int morphologySize)
{
Mat outputImage;
int morphologyType;
if(morphologyElement == 0)
morphologyType = MORPH_RECT;
else if(morphologyElement == 1)
morphologyType = MORPH_CROSS;
else if(morphologyElement == 2)
morphologyType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));
// Apply top hat operation to the image using the structuring element
outputImage = inputImage - performOpening(inputImage, morphologyElement, morphologySize);
// Return the output image
return outputImage;
}
黑顶帽变换
黑顶帽变换,也简称为黑帽变换,同样可以从图像中提取更细的细节。我们可以通过计算图像的形态学闭运算与图像本身的差来应用黑顶帽变换。这使我们能够识别出图像中比结构元素小且比周围区域暗的物体。

让我们来看看执行黑帽变换的函数:
Mat performBlackHat(Mat inputImage, int morphologyElement, int morphologySize)
{
Mat outputImage;
int morphologyType;
if(morphologyElement == 0)
morphologyType = MORPH_RECT;
else if(morphologyElement == 1)
morphologyType = MORPH_CROSS;
else if(morphologyElement == 2)
morphologyType = MORPH_ELLIPSE;
// Create the structuring element for erosion
Mat element = getStructuringElement(morphologyType, Size(2*morphologySize + 1, 2*morphologySize + 1), Point(morphologySize, morphologySize));
// Apply black hat operation to the image using the structuring element
outputImage = performClosing(inputImage, morphologyElement, morphologySize) - inputImage;
// Return the output image
return outputImage;
}
概述
在本章中,我们学习了用于背景建模和形态学图像处理的算法。我们讨论了简单的背景减法及其局限性。我们学习了如何使用帧差分来获取运动信息,以及它在追踪不同类型物体时可能对我们的约束。我们还讨论了高斯混合模型,包括其公式和实现细节。然后我们讨论了形态学图像处理。我们学习了它可用于各种目的,并通过不同的操作演示了其用例。
在下一章中,我们将讨论如何追踪物体以及可以用来实现这一目标的多种技术。
第九章. 学习目标跟踪
在上一章中,我们学习了视频监控、背景建模和形态学图像处理。我们讨论了如何使用不同的形态学算子将酷炫的视觉效果应用到输入图像上。在本章中,我们将学习如何在实时视频中跟踪一个物体。我们将讨论可用于跟踪物体的不同物体特征。我们还将了解用于物体跟踪的不同方法和技巧。物体跟踪在机器人技术、自动驾驶汽车、车辆跟踪、体育中的运动员跟踪、视频压缩等领域得到了广泛的应用。
到本章结束时,你将学习:
-
如何跟踪彩色物体
-
如何构建一个交互式目标跟踪器
-
什么是角点检测器
-
如何检测用于跟踪的良好特征
-
如何构建基于光流的特征跟踪器
跟踪特定颜色的物体
为了构建一个良好的目标跟踪器,我们需要了解哪些特征可以用来使我们的跟踪既稳健又准确。因此,让我们迈出小小的一步,看看我们如何利用色彩空间来设计一个良好的视觉跟踪器。有一点需要记住的是,色彩信息对光照条件很敏感。在实际应用中,你需要进行一些预处理来处理这个问题。但就目前而言,让我们假设有人在做这件事,而我们正在获取干净的彩色图像。
存在许多不同的色彩空间,选择一个好的取决于人们在不同应用中的使用。虽然 RGB 是计算机屏幕上的原生表示,但它对于人类来说并不一定是理想的。当涉及到人类时,我们给基于色调的颜色命名。这就是为什么HSV(色调饱和度值)可能是最有信息量的色彩空间之一。它与我们的颜色感知非常接近。色调指的是颜色光谱,饱和度指的是特定颜色的强度,而值指的是该像素的亮度。这实际上是以圆柱格式表示的。你可以参考关于这个的简单解释infohost.nmt.edu/tcc/help/pubs/colortheory/web/hsv.html。我们可以将图像的像素转换到 HSV 空间,然后使用该空间中的色彩空间距离和阈值来进行阈值处理以跟踪特定物体。
考虑视频中的以下帧:

如果你通过色彩空间过滤器运行它并跟踪物体,你会看到类似这样的东西:

如您在此处所见,我们的跟踪器根据视频中的颜色特征识别特定对象。为了使用此跟踪器,我们需要知道目标对象的颜色分布。以下代码用于跟踪一个只选择具有特定给定色调的像素的彩色对象。代码注释详尽,因此请阅读之前提到的每行解释以了解发生了什么:
int main(int argc, char* argv[])
{
// Variable declarations and initializations
// Iterate until the user presses the Esc key
while(true)
{
// Initialize the output image before each iteration
outputImage = Scalar(0,0,0);
// Capture the current frame
cap >> frame;
// Check if 'frame' is empty
if(frame.empty())
break;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Convert to HSV colorspace
cvtColor(frame, hsvImage, COLOR_BGR2HSV);
// Define the range of "blue" color in HSV colorspace
Scalar lowerLimit = Scalar(60,100,100);
Scalar upperLimit = Scalar(180,255,255);
// Threshold the HSV image to get only blue color
inRange(hsvImage, lowerLimit, upperLimit, mask);
// Compute bitwise-AND of input image and mask
bitwise_and(frame, frame, outputImage, mask=mask);
// Run median filter on the output to smoothen it
medianBlur(outputImage, outputImage, 5);
// Display the input and output image
imshow("Input", frame);
imshow("Output", outputImage);
// Get the keyboard input and check if it's 'Esc'
// 30 -> wait for 30 ms
// 27 -> ASCII value of 'ESC' key
ch = waitKey(30);
if (ch == 27) {
break;
}
}
return 1;
}
构建交互式对象跟踪器
基于颜色空间的跟踪器为我们提供了跟踪彩色对象的自由,但我们也被限制在预定义的颜色上。如果我们只想随机选择一个对象怎么办?我们如何构建一个可以学习所选对象特征并自动跟踪它的对象跟踪器?这就是 CAMShift 算法出现的地方,它代表连续自适应均值漂移。它基本上是 Meanshift 算法的改进版本。
Meanshift 的概念实际上很好且简单。假设我们选择一个感兴趣的区域,并希望我们的对象跟踪器跟踪该对象。在这个区域中,我们根据颜色直方图选择一些点,并计算空间点的质心。如果质心位于该区域的中心,我们知道对象没有移动。但如果质心不在该区域的中心,那么我们知道对象正在某个方向上移动。质心的移动控制着对象移动的方向。因此,我们将对象的边界框移动到新的位置,使新的质心成为边界框的中心。因此,这个算法被称为 Meanshift,因为均值(即质心)在移动。这样,我们就能保持对对象当前位置的了解。
然而,Meanshift 的问题在于边界框的大小不允许改变。当你将对象从摄像机移开时,人眼会看到对象变得更小,但 Meanshift 不会考虑这一点。在整个跟踪过程中,边界框的大小将保持不变。因此,我们需要使用 CAMShift。CAMShift 的优势在于它可以调整边界框的大小以适应对象的大小。此外,它还可以跟踪对象的方向。
让我们考虑以下图中被突出显示的对象:

现在我们已经选择了对象,算法计算直方图反向投影并提取所有信息。什么是直方图反向投影?它只是识别图像如何适合我们的直方图模型的一种方法。我们计算特定事物的直方图模型,然后使用此模型在图像中找到该事物。让我们移动对象并看看它是如何被跟踪的:

看起来物体被跟踪得相当好。让我们改变方向,并检查跟踪是否保持:

如您所见,边界椭圆已经改变了其位置和方向。让我们改变物体的视角,看看它是否仍然能够跟踪它:

我们仍然做得很好!边界椭圆已经改变了宽高比,以反映物体现在看起来是倾斜的(由于透视变换)。让我们看看以下代码中的用户界面功能:
Mat image;
Point originPoint;
Rect selectedRect;
bool selectRegion = false;
int trackingFlag = 0;
// Function to track the mouse events
void onMouse(int event, int x, int y, int, void*)
{
if(selectRegion)
{
selectedRect.x = MIN(x, originPoint.x);
selectedRect.y = MIN(y, originPoint.y);
selectedRect.width = std::abs(x - originPoint.x);
selectedRect.height = std::abs(y - originPoint.y);
selectedRect &= Rect(0, 0, image.cols, image.rows);
}
switch(event)
{
case CV_EVENT_LBUTTONDOWN:
originPoint = Point(x,y);
selectedRect = Rect(x,y,0,0);
selectRegion = true;
break;
case CV_EVENT_LBUTTONUP:
selectRegion = false;
if( selectedRect.width > 0 && selectedRect.height > 0 )
{
trackingFlag = -1;
}
break;
}
}
这个函数基本上捕获了在窗口中选定的矩形的坐标。用户只需点击它们并用鼠标拖动即可。OpenCV 中有一系列内置函数帮助我们检测这些不同的鼠标事件。
这里是用于基于 CAMShift 进行对象跟踪的代码:
int main(int argc, char* argv[])
{
// Variable declaration and initialization
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Check if 'frame' is empty
if(frame.empty())
break;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Clone the input frame
frame.copyTo(image);
// Convert to HSV colorspace
cvtColor(image, hsvImage, COLOR_BGR2HSV);
到目前为止,我们已经有了一个等待处理的 HSV 图像。让我们继续看看我们如何使用我们的阈值来处理这个图像:
if(trackingFlag)
{
// Check for all the values in 'hsvimage' that are within the specified range
// and put the result in 'mask'
inRange(hsvImage, Scalar(0, minSaturation, minValue), Scalar(180, 256, maxValue), mask);
// Mix the specified channels
int channels[] = {0, 0};
hueImage.create(hsvImage.size(), hsvImage.depth());
mixChannels(&hsvImage, 1, &hueImage, 1, channels, 1);
if(trackingFlag < 0)
{
// Create images based on selected regions of interest
Mat roi(hueImage, selectedRect), maskroi(mask, selectedRect);
// Compute the histogram and normalize it
calcHist(&roi, 1, 0, maskroi, hist, 1, &histSize, &histRanges);
normalize(hist, hist, 0, 255, CV_MINMAX);
trackingRect = selectedRect;
trackingFlag = 1;
}
如您在此处所见,我们使用 HSV 图像来计算区域的直方图。我们使用我们的阈值在 HSV 光谱中定位所需颜色,然后根据该颜色过滤图像。让我们继续看看我们如何计算直方图反向投影:
// Compute the histogram backprojection
calcBackProject(&hueImage, 1, 0, hist, backproj, &histRanges);
backproj &= mask;
RotatedRect rotatedTrackingRect = CamShift(backproj, trackingRect, TermCriteria(CV_TERMCRIT_EPS | CV_TERMCRIT_ITER, 10, 1));
// Check if the area of trackingRect is too small
if(trackingRect.area() <= 1)
{
// Use an offset value to make sure the trackingRect has a minimum size
int cols = backproj.cols, rows = backproj.rows;
int offset = MIN(rows, cols) + 1;
trackingRect = Rect(trackingRect.x - offset, trackingRect.y - offset, trackingRect.x + offset, trackingRect.y + offset) & Rect(0, 0, cols, rows);
}
我们现在准备好显示结果。使用旋转矩形,让我们在我们的感兴趣区域周围画一个椭圆:
// Draw the ellipse on top of the image
ellipse(image, rotatedTrackingRect, Scalar(0,255,0), 3, CV_AA);
}
// Apply the 'negative' effect on the selected region of interest
if(selectRegion && selectedRect.width > 0 && selectedRect.height > 0)
{
Mat roi(image, selectedRect);
bitwise_not(roi, roi);
}
// Display the output image
imshow(windowName, image);
// Get the keyboard input and check if it's 'Esc'
// 27 -> ASCII value of 'Esc' key
ch = waitKey(30);
if (ch == 27) {
break;
}
}
return 1;
}
使用 Harris 角点检测器检测点
角点检测是一种用于在图像中检测兴趣点的技术。这些兴趣点在计算机视觉术语中也被称为特征点或简单地称为特征。一个角基本上是两条边的交点。一个兴趣点基本上是在图像中可以唯一检测到的东西。一个角是兴趣点的特例。这些兴趣点帮助我们描述图像。这些点在许多应用中得到了广泛的应用,例如对象跟踪、图像分类、视觉搜索等等。既然我们知道角是有趣的,让我们看看我们如何检测它们。
在计算机视觉中,有一个流行的角点检测技术称为 Harris 角点检测器。我们根据灰度图像的偏导数构建一个 2x2 矩阵,然后分析特征值。那么这究竟意味着什么呢?好吧,让我们来剖析一下,以便我们更好地理解它。让我们考虑图像中的一个小的区域。我们的目标是检查这个区域中是否有角点。因此,我们考虑所有相邻的区域,并计算我们的区域与所有这些相邻区域之间的强度差异。如果在所有方向上差异都很大,那么我们知道我们的区域中有一个角点。这实际上是实际算法的一个过度简化,但它涵盖了核心内容。如果你想要了解背后的数学细节,你可以查看 Harris 和 Stephens 的原始论文,链接为 www.bmva.org/bmvc/1988/avc-88-023.pdf。角点是一个特征值都会有大值的点。
如果我们运行 Harris 角点检测器,它看起来会是这样:

如你所见,电视遥控器上的绿色圆圈是检测到的角点。这会根据你为检测器选择的参数而改变。如果你修改参数,你可能会看到检测到更多的点。如果你让它更严格,那么你可能无法检测到软角点。让我们看一下以下代码来检测 Harris 角点:
int main(int argc, char* argv[])
{
// Variable declaration and initialization
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
dst = Mat::zeros(frame.size(), CV_32FC1);
// Convert to grayscale
cvtColor(frame, frameGray, COLOR_BGR2GRAY );
// Detecting corners
cornerHarris(frameGray, dst, blockSize, apertureSize, k, BORDER_DEFAULT);
// Normalizing
normalize(dst, dst_norm, 0, 255, NORM_MINMAX, CV_32FC1, Mat());
convertScaleAbs(dst_norm, dst_norm_scaled);
我们将图像转换为灰度,并使用我们的参数检测角点。你可以在 .cpp 文件中找到完整的代码。这些参数在将要检测到的点的数量中起着重要作用。你可以在 OpenCV 的 Harris 角点检测器文档中查看详细信息,链接为 docs.opencv.org/2.4/modules/imgproc/doc/feature_detection.html?highlight=cornerharris#void cornerHarris(InputArray src, OutputArray dst, int blockSize, int ksize, double k, int borderType)。
我们现在有了所有需要的信息。让我们继续在角点周围画圆圈以显示结果:
// Drawing a circle around each corner
for(int j = 0; j < dst_norm.rows ; j++)
{
for(int i = 0; i < dst_norm.cols; i++)
{
if((int)dst_norm.at<float>(j,i) > thresh)
{
circle(frame, Point(i, j), 8, Scalar(0,255,0), 2, 8, 0);
}
}
}
// Showing the result
imshow(windowName, frame);
// Get the keyboard input and check if it's 'Esc'
// 27 -> ASCII value of 'Esc' key
ch = waitKey(10);
if (ch == 27) {
break;
}
}
// Release the video capture object
cap.release();
// Close all windows
destroyAllWindows();
return 1;
}
如你所见,这段代码接受一个 blockSize 输入参数。根据你选择的大小,性能会有所不同。从 4 开始,尝试不同的值以查看会发生什么。
Shi-Tomasi 角点检测器
Harris 角点检测器在许多情况下表现良好,但仍有改进空间。在 Harris 和 Stephens 的原始论文发表后的六年左右,Shi-Tomasi 提出了一种更好的方法,他们称之为Good Features To Track。您可以在www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf阅读原始论文。他们使用不同的评分函数来提高整体质量。使用这种方法,我们可以在给定的图像中找到N个最强的角点。当我们不想使用图像中的每一个角点来提取信息时,这非常有用。如前所述,一个好的兴趣点检测器在诸如对象跟踪、对象识别、图像搜索等应用中非常有用。
如果您将 Shi-Tomasi 角点检测器应用于图像,您将看到类似这样的效果:

如您所见,帧中的所有重要点都被捕捉到了。让我们看一下以下代码来跟踪这些特征:
int main(int argc, char* argv[])
{
// Variable declaration and initialization
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Convert to grayscale
cvtColor(frame, frameGray, COLOR_BGR2GRAY );
// Initialize the parameters for Shi-Tomasi algorithm
vector<Point2f> corners;
double qualityThreshold = 0.02;
double minDist = 15;
int blockSize = 5;
bool useHarrisDetector = false;
double k = 0.07;
// Clone the input frame
Mat frameCopy;
frameCopy = frame.clone();
// Apply corner detection
goodFeaturesToTrack(frameGray, corners, numCorners, qualityThreshold, minDist, Mat(), blockSize, useHarrisDetector, k);
我们提取了帧并使用goodFeaturesToTrack来检测角点。重要的是要理解检测到的角点数量将取决于我们选择的参数。您可以在docs.opencv.org/2.4/modules/imgproc/doc/feature_detection.html?highlight=goodfeaturestotrack#goodfeaturestotrack找到详细的解释。让我们继续在这些点上画圆圈以显示输出图像:
// Parameters for the circles to display the corners
int radius = 8; // radius of the cirles
int thickness = 2; // thickness of the circles
int lineType = 8;
// Draw the detected corners using circles
for(size_t i = 0; i < corners.size(); i++)
{
Scalar color = Scalar(rng.uniform(0,255), rng.uniform(0,255), rng.uniform(0,255));
circle(frameCopy, corners[i], radius, color, thickness, lineType, 0);
}
/// Show what you got
imshow(windowName, frameCopy);
// Get the keyboard input and check if it's 'Esc'
// 27 -> ASCII value of 'Esc' key
ch = waitKey(30);
if (ch == 27) {
break;
}
}
// Release the video capture object
cap.release();
// Close all windows
destroyAllWindows();
return 1;
}
此程序接受一个numCorners输入参数。此值表示您想要跟踪的最大角点数。从100开始,尝试调整这个值以观察会发生什么。如果您增加这个值,您将看到更多特征点被检测到。
基于特征的跟踪
基于特征的跟踪指的是在视频的连续帧中跟踪单个特征点。这里的优势是我们不需要在每一帧中检测特征点。我们只需检测一次,然后继续跟踪。与在每一帧上运行检测器相比,这更有效率。我们使用一种称为光流的技术来跟踪这些特征。光流是计算机视觉中最流行的技术之一。我们选择一些特征点,并通过视频流跟踪它们。当我们检测到特征点时,我们计算位移矢量并显示这些关键点在连续帧之间的运动。这些矢量被称为运动矢量。
特定点的运动矢量只是一个指示该点相对于前一帧移动方向的线条。不同的方法被用来检测这些运动矢量。最流行的两种算法是 Lucas-Kanade 方法和 Farneback 算法。
Lucas-Kanade 方法
Lucas-Kanade 方法用于稀疏光流跟踪。通过稀疏,我们指的是特征点的数量相对较低。你可以参考他们的原始论文cseweb.ucsd.edu/classes/sp02/cse252/lucaskanade81.pdf。我们通过提取特征点开始这个过程。对于每个特征点,我们以特征点为中心创建 3 x 3 的面。我们假设每个面内的所有点将具有相似的运动。我们可以根据手头的问题调整这个窗口的大小。
对于当前帧中的每个特征点,我们将其周围的 3 x 3 面积作为参考点。对于这个面,我们查看前一个帧中的邻域以获取最佳匹配。这个邻域通常比 3 x 3 大,因为我们想要获取与考虑中的面最接近的面。现在,从前一个帧中匹配面的中心像素到当前帧中考虑的面的中心像素的路径将成为运动向量。我们对所有特征点都这样做,并提取所有运动向量。
让我们考虑以下帧:

我们需要添加一些我们想要跟踪的点。只需用鼠标点击这个窗口上的几个点即可:

如果我移动到不同的位置,你会看到点仍然在小的误差范围内被正确跟踪:

让我们添加很多点来看看会发生什么:

如你所见,它将一直跟踪这些点。然而,你会注意到由于突出度、运动速度等因素,其中一些点会在中间丢失。如果你想玩玩,你可以继续添加更多的点。你也可以允许用户在输入视频中选择感兴趣的区域。然后你可以从这个感兴趣的区域中提取特征点,并通过绘制边界框来跟踪对象。这将是一个有趣的练习!
这里是用于执行基于 Lucas-Kanade 跟踪的代码:
int main(int argc, char* argv[])
{
// Variable declaration and initialization
// Iterate until the user hits the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
// Check if the frame is empty
if(frame.empty())
break;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Copy the input frame
frame.copyTo(image);
// Convert the image to grayscale
cvtColor(image, curGrayImage, COLOR_BGR2GRAY);
// Check if there are points to track
if(!trackingPoints[0].empty())
{
// Status vector to indicate whether the flow for the corresponding features has been found
vector<uchar> statusVector;
// Error vector to indicate the error for the corresponding feature
vector<float> errorVector;
// Check if previous image is empty
if(prevGrayImage.empty())
{
curGrayImage.copyTo(prevGrayImage);
}
// Calculate the optical flow using Lucas-Kanade algorithm
calcOpticalFlowPyrLK(prevGrayImage, curGrayImage, trackingPoints[0], trackingPoints[1], statusVector, errorVector, windowSize, 3, terminationCriteria, 0, 0.001);
我们使用当前图像和前一个图像来计算光流信息。不用说,输出的质量将取决于你选择的参数。你可以在docs.opencv.org/2.4/modules/video/doc/motion_analysis_and_object_tracking.html#calcopticalflowpyrlk找到更多关于参数的详细信息。为了提高质量和鲁棒性,我们需要过滤掉彼此非常接近的点,因为它们不会添加新的信息。让我们继续这样做:
int count = 0;
// Minimum distance between any two tracking points
int minDist = 7;
for(int i=0; i < trackingPoints[1].size(); i++)
{
if(pointTrackingFlag)
{
/* If the new point is within 'minDist' distance from an existing point, it will not be tracked */
if(norm(currentPoint - trackingPoints[1][i]) <= minDist)
{
pointTrackingFlag = false;
continue;
}
}
// Check if the status vector is good
if(!statusVector[i])
continue;
trackingPoints[1][count++] = trackingPoints[1][i];
// Draw a filled circle for each of the tracking points
int radius = 8;
int thickness = 2;
int lineType = 8;
circle(image, trackingPoints[1][i], radius, Scalar(0,255,0), thickness, lineType);
}
trackingPoints[1].resize(count);
}
现在我们有了跟踪点。下一步是细化这些点的位置。在这个上下文中,“细化”究竟意味着什么?为了提高计算速度,涉及一定程度的量化。用通俗易懂的话来说,你可以把它想成“四舍五入”。现在我们有了近似区域,我们可以细化点在该区域内的位置,以获得更准确的结果。让我们继续这样做:
// Refining the location of the feature points
if(pointTrackingFlag && trackingPoints[1].size() < maxNumPoints)
{
vector<Point2f> tempPoints;
tempPoints.push_back(currentPoint);
// Function to refine the location of the corners to subpixel accuracy.
// Here, 'pixel' refers to the image patch of size 'windowSize' and not the actual image pixel
cornerSubPix(curGrayImage, tempPoints, windowSize, cvSize(-1,-1), terminationCriteria);
trackingPoints[1].push_back(tempPoints[0]);
pointTrackingFlag = false;
}
// Display the image with the tracking points
imshow(windowName, image);
// Check if the user pressed the Esc key
char ch = waitKey(10);
if(ch == 27)
break;
// Swap the 'points' vectors to update 'previous' to 'current'
std::swap(trackingPoints[1], trackingPoints[0]);
// Swap the images to update previous image to current image
cv::swap(prevGrayImage, curGrayImage);
}
return 1;
}
Farneback 算法
冈纳·法尔内巴克提出了这个光流算法,它用于密集跟踪。密集跟踪在机器人技术、增强现实、3D 制图等领域被广泛使用。你可以在www.diva-portal.org/smash/get/diva2:273847/FULLTEXT01.pdf查看原始论文。Lucas-Kanade 方法是一种稀疏技术,这意味着我们只需要处理整个图像中的一些像素。另一方面,Farneback 算法是一种密集技术,它要求我们处理给定图像中的所有像素。因此,显然这里有一个权衡。密集技术更准确,但速度较慢。稀疏技术不太准确,但速度较快。对于实时应用,人们倾向于更喜欢稀疏技术。对于时间和复杂度不是因素的场合,人们更喜欢密集技术来提取更精细的细节。
在他的论文中,法尔内巴克描述了一种基于多项式展开的密集光流估计方法,用于两个帧。我们的目标是估计这两个帧之间的运动,这基本上是一个三步过程。在第一步中,两个帧中的每个邻域都通过多项式进行近似。在这种情况下,我们只对二次多项式感兴趣。下一步是通过全局位移构建一个新的信号。现在,每个邻域都通过一个多项式近似,我们需要看看如果这个多项式经历一个理想的平移会发生什么。最后一步是通过将二次多项式的系数相等来计算全局位移。
现在,这如何可行呢?如果你仔细想想,我们假设整个信号是一个单一的多项式,并且存在一个全局平移来关联这两个信号。这不是一个现实的场景。那么,我们在寻找什么呢?好吧,我们的目标是找出这些错误是否足够小,以至于我们可以构建一个有用的算法来跟踪特征。
让我们看看下面的静态图像:

如果我向侧面移动,你可以看到运动矢量指向水平方向。它们只是跟踪我头部的移动:

如果我远离摄像头,你可以看到运动矢量指向与图像平面垂直的方向:

下面是使用 Farneback 算法执行基于光流跟踪的代码:
int main(int, char** argv)
{
// Variable declaration and initialization
// Iterate until the user presses the Esc key
while(true)
{
// Capture the current frame
cap >> frame;
if(frame.empty())
break;
// Resize the frame
resize(frame, frame, Size(), scalingFactor, scalingFactor, INTER_AREA);
// Convert to grayscale
cvtColor(frame, curGray, COLOR_BGR2GRAY);
// Check if the image is valid
if(prevGray.data)
{
// Initialize parameters for the optical flow algorithm
float pyrScale = 0.5;
int numLevels = 3;
int windowSize = 15;
int numIterations = 3;
int neighborhoodSize = 5;
float stdDeviation = 1.2;
// Calculate optical flow map using Farneback algorithm
calcOpticalFlowFarneback(prevGray, curGray, flowImage, pyrScale, numLevels, windowSize, numIterations, neighborhoodSize, stdDeviation, OPTFLOW_USE_INITIAL_FLOW);
如您所见,我们使用 Farneback 算法来计算光流向量。calcOpticalFlowFarneback的输入参数对于跟踪质量至关重要。您可以在docs.opencv.org/3.0-beta/modules/video/doc/motion_analysis_and_object_tracking.html找到这些参数的详细信息。让我们继续在输出图像上绘制这些向量:
// Convert to 3-channel RGB
cvtColor(prevGray, flowImageGray, COLOR_GRAY2BGR);
// Draw the optical flow map
drawOpticalFlow(flowImage, flowImageGray);
// Display the output image
imshow(windowName, flowImageGray);
}
// Break out of the loop if the user presses the Esc key
ch = waitKey(10);
if(ch == 27)
break;
// Swap previous image with the current image
std::swap(prevGray, curGray);
}
return 1;
}
我们使用了一个名为drawOpticalFlow的函数来绘制这些光流向量。这些向量指示了运动的方向。让我们看看这个函数,看看我们如何绘制这些向量:
// Function to compute the optical flow map
void drawOpticalFlow(const Mat& flowImage, Mat& flowImageGray)
{
int stepSize = 16;
Scalar color = Scalar(0, 255, 0);
// Draw the uniform grid of points on the input image along with the motion vectors
for(int y = 0; y < flowImageGray.rows; y += stepSize)
{
for(int x = 0; x < flowImageGray.cols; x += stepSize)
{
// Circles to indicate the uniform grid of points
int radius = 2;
int thickness = -1;
circle(flowImageGray, Point(x,y), radius, color, thickness);
// Lines to indicate the motion vectors
Point2f pt = flowImage.at<Point2f>(y, x);
line(flowImageGray, Point(x,y), Point(cvRound(x+pt.x), cvRound(y+pt.y)), color);
}
}
}
摘要
在本章中,我们学习了目标跟踪。我们学习了如何使用 HSV 颜色空间来跟踪彩色物体。我们讨论了用于目标跟踪的聚类技术,以及如何使用 CAMShift 算法构建一个交互式目标跟踪器。我们还学习了角点检测器以及如何在实时视频中跟踪角点。我们讨论了如何使用光流在视频中跟踪特征。我们还学习了 Lucas-Kanade 和 Farneback 算法背后的概念,并将它们实现出来。
在下一章中,我们将讨论分割算法,并了解我们如何将它们用于文本识别。
第十章。开发用于文本识别的分割算法
在前面的章节中,我们学习了许多图像处理技术,如阈值、轮廓描述符和数学形态学。在本章中,我们将讨论处理扫描文档时常见的常见问题,例如识别文本的位置或调整其旋转。我们还将学习如何结合前面章节中介绍的技术来解决这些问题。最后,我们将得到可以发送到OCR(光学字符识别)库的文本分割区域。
到本章结束时,你应该能够回答以下问题:
-
哪些 OCR 应用存在?
-
在编写 OCR 应用时常见的有哪些问题?
-
我们如何识别文档的区域?
-
我们如何处理文本中的倾斜和其他中间元素等问题?
-
我们如何使用 Tesseract OCR 来识别文本?
介绍光学字符识别
在图像中识别文本是计算机视觉中一个非常流行的应用。这个过程通常被称为 OCR,并分为以下步骤:
-
文本预处理和分割:在这个步骤中,计算机必须学会处理图像噪声和旋转(倾斜)并识别哪些区域是候选文本区域。
-
文本识别:这是一个用于识别文本中每个字母的过程。尽管这也是一个计算机视觉主题,但在这本书中,我们不会使用 OpenCV 向您展示如何做这件事。相反,我们将向您展示如何使用 Tesseract 库来完成这一步,因为它与 OpenCV 3.0 集成。如果您对学习如何完全自己完成 Tesseract 所做的一切感兴趣,请查看Mastering OpenCV,Packt Publishing,其中有一章关于车牌识别。
预处理和分割阶段可能因文本来源而异。让我们看看进行预处理的一些常见情况:
-
使用扫描仪的生产 OCR 应用,这是一个非常可靠的文字来源:在这种情况下,图像的背景通常是白色的,文档几乎与扫描仪的边缘对齐。正在扫描的内容基本上包含几乎没有任何噪声的文本。这类应用依赖于简单的预处理技术,可以快速调整文本并保持快速扫描的速度。在编写生产 OCR 软件时,通常会将重要文本区域的识别委托给用户,并创建一个用于文本验证和索引的质量管道。
-
在随意拍摄的图片或视频中扫描文本:这是一个更为复杂的场景,因为没有任何指示说明文本可能的位置。这种场景被称为场景文本识别,OpenCV 3.0 引入了一个全新的库来处理这种情况,我们将在第十一章,使用 Tesseract 进行文本识别中介绍。通常,预处理器会使用纹理分析技术来识别文本模式。
-
为历史文本创建生产质量的 OCR:历史文本也会被扫描。
然而,它们有几个额外的问题,例如旧纸张颜色和墨水使用产生的噪声。其他常见问题包括装饰字母、特定的文本字体以及随着时间的推移而退化的墨水产生的低对比度内容。为手头文档编写特定的 OCR 软件并不罕见。
-
扫描地图、图表和图表:地图、图表和图表构成了一个困难的场景,因为文本通常以任何方向出现在图像内容中。例如,城市名称通常成群,海洋名称通常遵循国家海岸轮廓线。一些图表颜色很深,文本以清晰和暗色调出现。
OCR 应用策略也根据识别的目标而变化。它们将被用于全文搜索吗?或者应该将文本分离到逻辑字段中,以索引包含结构化搜索信息的数据库?
在本章中,我们将专注于预处理扫描的文本或由相机拍摄的文本。我们假设文本是图像的主要目的,例如在照片、纸张或卡片上;例如,看看下面的停车票:

我们将尝试去除常见的噪声,处理文本旋转(如果有),并裁剪可能的文本区域。虽然大多数 OCR API 已经自动执行这些操作,并且可能使用最先进的算法,但这仍然值得了解这些操作背后的原理。这将使您更好地理解大多数 OCR API 的参数,并为您提供更好的知识,了解您可能面临的潜在 OCR 问题。
预处理步骤
识别字母的软件通过将文本与先前记录的数据进行比较来进行。如果输入文本清晰,如果字母处于垂直位置,如果没有其他元素,例如发送到分类软件的图像,分类结果可以大大提高。在本节中,我们将学习如何调整文本。这个阶段被称为预处理。
阈值化图像
我们通常通过阈值化图像开始预处理阶段。这消除了所有颜色信息。大多数 OpenCV 函数都需要信息以白色书写,背景为黑色。因此,让我们从创建一个匹配此标准的阈值函数开始:
#include <opencv2/opencv.hpp>
#include <vector>
using namespace std;
using namespace cv;
Mat binarize(Mat input)
{
//Uses otsu to threshold the input image
Mat binaryImage;
cvtColor(input, input, CV_BGR2GRAY);
threshold(input, binaryImage, 0, 255, THRESH_OTSU);
//Count the number of black and white pixels
int white = countNonZero(binaryImage);
int black = binaryImage.size().area() - white;
//If the image is mostly white (white background), invert it
return white < black ? binaryImage : ~binaryImage;
}
binarize函数应用一个阈值,类似于我们在第四章,深入直方图和滤波器中所做的那样。然而,我们通过将THRESH_OTSU传递给函数的第四个参数来使用Otsu方法。
Otsu方法最大化类间方差。由于阈值只创建两个类别(黑色和白色像素),这等同于最小化类内方差。该方法使用图像直方图进行工作。然后,它遍历所有可能的阈值值,并计算阈值两侧像素值的扩散度,即图像的背景或前景中的像素。目的是找到使两者扩散度之和最小的阈值值。
阈值处理完成后,函数会计算图像中的白色像素数量。黑色像素是图像中像素的总数,由图像面积减去白色像素计数得出。
由于文本通常写在纯背景上,我们将检查是否有比黑色像素更多的白色像素。在这种情况下,我们处理的是黑色文本在白色背景上,因此我们需要对图像进行反转以进行进一步处理。
使用停车票图像进行阈值处理的结果如下所示:

文本分割
下一步是找到文本的位置并提取它。有两种常见的策略可以实现这一点,如下所示:
-
使用连通分量分析,我们在图像中搜索连通像素组。这是我们将在本章中使用的技巧。
-
使用分类器来搜索之前训练过的字母纹理模式。纹理特征,如 Haralick 特征和小波变换,通常被使用。另一个选择是在这个任务中识别最大稳定极值区域(MSERs)。这种方法对于复杂背景中的文本更加鲁棒,将在下一章中进行研究。你可以在他的个人网站上阅读有关 Haralick 特征的更多信息:
haralick.org/journals/TexturalFeatures.pdf。
创建连通区域
如果你仔细观察图像,你会注意到字母总是成块地聚集在一起,这些块是由每个文本段落形成的。那么,我们如何检测和移除这些块呢?
第一步是使这些块更加明显。我们可以使用膨胀形态学算子来完成这项工作。在第八章,视频监控、背景建模和形态学操作中,我们学习了如何通过膨胀使图像元素变厚。让我们看看以下代码片段,它完成了这项工作:
Mat kernel = getStructuringElement(MORPH_CROSS, Size(3,3));
Mat dilated;
dilate(input, dilated, kernel, cv::Point(-1, -1), 5);
imshow("Dilated", dilated);
在此代码中,我们首先创建一个 3 x 3 的交叉核,该核将在形态学操作中使用。然后,我们以该核为中心进行五次膨胀。确切的核大小和次数根据情况而变化。只需确保这些值将同一行上的所有字母粘合在一起即可。
此操作的输出结果如下:

注意,现在我们有了巨大的白色块。它们正好与每个段落文本相匹配,也匹配其他非文本元素,如图像或边缘噪声。
小贴士
代码附带的票据图像是低分辨率图像。OCR 引擎通常使用高分辨率图像(200 或 300 DPI),因此可能需要应用超过五次的膨胀。
识别段落块
下一步是执行连通分量分析以找到对应于段落的块。OpenCV 有一个函数可以做到这一点,我们之前在第五章中使用过,称为自动光学检测、对象分割和检测。它是findContours函数:
vector<vector<Point> > contours;
findContours(dilated, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE);
在第一个参数中,我们传递我们的膨胀图像。第二个参数是检测到的轮廓向量。然后我们使用选项仅检索外部轮廓并使用简单近似。图像轮廓在以下图中展示。每种灰度色调代表不同的轮廓:

最后一步是识别每个轮廓的最小旋转边界矩形。OpenCV 提供了一个方便的函数来完成此操作,称为minAreaRect。此函数接收一个任意点的向量,并返回一个包含边界框的RoundedRect。
这也是一个丢弃不需要的矩形的好机会,即显然不是文本的矩形。由于我们正在制作 OCR 软件,我们将假设文本包含一组字母。基于这个假设,我们将丢弃以下情况下的文本:
-
矩形的宽度或大小太小,即小于 20 像素。这将帮助您丢弃边缘噪声和其他小瑕疵。
-
图像的矩形,其宽/高比例小于 2。也就是说,类似于正方形的矩形,如图像图标,或者那些非常高大的矩形也将被丢弃。
在第二个条件中有一个小问题。由于我们正在处理旋转边界框,我们必须检查边界框的角度是否不小于 45 度。如果是这样,文本将是垂直旋转的,因此必须考虑的比例是高度/宽度。让我们看看这段代码:
//For each contour
vector<RotatedRect> areas;
for (auto contour : contours)
{
//Find it's rotated rect
auto box = minAreaRect(contour);
//Discard very small boxes
if (box.size.width < 20 || box.size.height < 20)
continue;
//Discard squares shaped boxes and boxes
//higher than larger
double proportion = box.angle < -45.0 ?
box.size.height / box.size.width :
box.size.width / box.size.height;
if (proportion < 2)
continue;
//Add the box
areas.push_back(box);
}
让我们看看这个算法选择的盒子:

这当然是一个好结果!
注意,条件 2 中描述的算法也会丢弃单个字母。这不是一个大问题,因为我们正在创建一个 OCR 预处理程序,而单个符号通常与上下文信息无关。一个这样的例子是页码。由于它们通常单独出现在页面底部,并且肯定会因为大小或比例文本而失败,因此它们将在这个过程中被丢弃。然而,这不会成为问题,因为文本通过 OCR 后,将会有一个没有页面分隔的巨大文本文件。
我们将这段代码放在一个具有此签名的函数中:
vector<RotatedRect> findTextAreas(Mat input)
文本提取和倾斜调整
现在,我们只需要提取文本并调整文本倾斜。这是通过 deskewAndCrop 函数完成的,如下所示:
Mat deskewAndCrop(Mat input, const RotatedRect& box)
{
double angle = box.angle;
Size2f size = box.size;
//Adjust the box angle
if (angle < -45.0)
{
angle += 90.0;
std::swap(size.width, size.height);
}
//Rotate the text according to the angle
Mat transform = getRotationMatrix2D(box.center, angle, 1.0);
Mat rotated;
warpAffine(input, rotated, transform, input.size(), INTER_CUBIC);
//Crop the result
Mat cropped;
getRectSubPix(rotated, size, box.center, cropped);
copyMakeBorder(cropped,cropped,10,10,10,10,BORDER_CONSTANT,Scalar(0));
return cropped;
}
首先,我们开始读取所需的区域、角度和大小。如前所述,角度可以小于 45 度。这意味着文本是垂直对齐的,因此我们需要将旋转角度增加 90 度,并切换宽度和高度属性。
接下来,我们需要旋转文本。首先,我们开始创建一个 2D 仿射变换矩阵,该矩阵描述了旋转。我们使用 OpenCV 的 getRotationMatrix2D 函数来完成此操作。此函数接受以下三个参数:
-
CENTER:这是旋转的中心位置。旋转将围绕这个中心旋转。在我们的情况下,我们使用框的中心。 -
ANGLE:这是旋转角度。如果角度为负,则旋转将按顺时针方向进行。 -
SCALE:这是一个各向同性的缩放因子。我们使用 1.0,因为我们想保持框的原始比例不变。
旋转本身是通过 warpAffine 函数实现的。此函数接受四个必选参数,如下所示:
-
SRC:这是要转换的输入mat数组。 -
DST:这是目标mat数组。 -
M:这是一个转换矩阵。这个矩阵是一个 2 x 3 的仿射变换矩阵。这可能是一个平移、缩放或旋转矩阵。在我们的情况下,我们只使用我们最近创建的矩阵。 -
SIZE:这是输出图像的大小。我们将生成与输入图像大小相同的图像。
其他三个可选参数如下:
-
FLAGS:这表示图像应该如何插值。我们使用BICUBIC_INTERPOLATION以获得更好的质量。默认值是LINEAR_INTERPOLATION。 -
BORDER:这是边界模式。我们使用默认的BORDER_CONSTANT。 -
BORDER VALUE:这是边界的颜色。我们使用默认值,即黑色。
然后,我们使用 getRectSubPix 函数。在我们旋转图像后,我们需要裁剪边界框的矩形区域。此函数接受四个必选参数和一个可选参数,并返回裁剪后的图像:
-
IMAGE:这是要裁剪的图像。 -
SIZE:这是一个cv::Size对象,描述了要裁剪的框的宽度和高度。 -
CENTER: 这是裁剪区域的中心像素点。注意,当我们围绕中心旋转时,这个点保持不变。 -
PATCH: 这是目标图像。 -
PATCH_TYPE: 这是目标图像的深度。我们使用默认值,表示与源图像相同的深度。
最后一步由copyMakeBorder函数完成。此函数在图像周围添加边框。这很重要,因为分类阶段通常期望文本周围有边距。函数参数非常简单:输入和输出图像,图像顶部、底部、左侧和右侧的边框厚度,以及新边框的颜色。
对于卡片图像,将生成以下图像:

现在,是时候将每个功能组合在一起了。让我们展示以下主要方法:
-
加载票据图像
-
调用我们的
binarization函数 -
查找所有文本区域
-
在窗口中显示每个区域:
int main(int argc, char* argv[]) { //Loads the ticket image and binarize it Mat ticket = binarize(imread("ticket.png")); auto regions = findTextAreas(ticket); //For each region for (auto& region : regions) { //Crop auto cropped = deskewAndCrop(ticket, region); //Show imshow("Cropped text", cropped); waitKey(0); destroyWindow("Border Skew"); } }
注意
对于完整的源代码,请查看随本书附带的segment.cpp文件。
在您的操作系统上安装 Tesseract OCR
Tesseract 是一个开源 OCR 引擎,最初由惠普实验室,布里斯托尔和惠普公司开发。它所有的代码许可证都在 Apache 许可证下,并在 GitHub 上托管,网址为github.com/tesseract-ocr。
它被认为是可用的最准确的 OCR 引擎之一。它可以读取多种图像格式,并且可以将 60 多种语言中写成的文本转换为文本。
在本次会话中,我们将教您如何在 Windows 或 Mac 上安装 Tesseract。由于有大量的 Linux 发行版,我们不会教您如何在操作系统上安装。
通常,Tesseract 在其软件仓库中提供安装包,因此在编译 Tesseract 之前,只需在那里搜索即可。
在 Windows 上安装 Tesseract
虽然 Tesseract 托管在 GitHub 上,但其最新的 Windows 安装程序仍然可在 Google Code 的旧仓库中找到。最新的安装程序版本是 3.02.02,建议您使用安装程序。从code.google.com/p/tesseract-ocr/downloads/list下载安装程序。
下载安装程序后,执行以下步骤:
-
查找
tesseract-ocr-setup-3.02.02.exe和tesseract-3.02.02-win32-lib-include-dirs.zip文件,下载并运行可执行安装程序![在 Windows 上安装 Tesseract]()
-
要跳过欢迎屏幕,请阅读并接受许可协议。
-
在计算机上为所有用户安装或仅为您的用户安装之间进行选择。
-
然后,选择合适的安装位置。
-
选择安装文件夹。Tesseract 默认指向
程序文件文件夹,因为它有一个命令行界面。如果你想,可以将其更改为更合适的文件夹。然后,转到下一屏幕:![在 Windows 上安装 Tesseract]()
-
确保您选择了Tesseract 开发文件。这将安装
Leptonica库文件和源代码。您还可以选择您母语的语料库。Tesseract 默认选择英语。 -
安装程序将下载并设置 Tesseract 依赖项。
提示
要测试 Tesseract 安装,您可以通过命令行运行它。例如,要在
parkingTicket.png文件上运行 Tesseract,您可以运行以下命令:tesseract parkingTicket.png ticket.txt -
现在,回到下载的
tesseract-3.02.02-win32-lib-include-dirs.zip文件。解压此文件,并将lib和add文件夹复制到您的tesseract安装文件夹中。在这个文件夹中会有相同名称的文件夹,但这是正常的。此文件将包括 Tesseract 文件和库在 Tesseract 安装中。具有讽刺意味的是,Tesseractlibs和dlls不包括在安装程序中。
在 Visual Studio 中设置 Tesseract
由于 Visual Studio 2010 是推荐用于 Windows 开发者与 Tesseract 的 IDE,因此正确设置这一点很重要。
设置过程相当简单,分为以下三个步骤:
-
调整导入和库路径。
-
将库添加到链接器输入。
-
将 Tesseract
dlls添加到 windows 路径。
让我们以下面的部分逐一查看这些步骤。
设置导入和库路径
导入路径告诉 Visual Studio 在执行代码中的#include指令时在哪里搜索可用的.h文件。
在解决方案资源管理器中,右键单击您的项目,然后单击属性。然后,选择配置属性和VC++ 目录。
注意
如果您是从头创建的新项目,请确保您至少向项目中添加了一个c++文件,以便 Visual Studio 知道这是一个C++项目。
接下来,点击包含目录。出现一个箭头。点击此箭头,然后点击编辑:

您必须将两个目录添加到该列表中:
TesseractInstallPath\include
TesseractInstallPath\include\leptonica
将TesseractInstallPath替换为您的 Tesseract 安装路径;例如,c:\Program Files\Tesseract-OCR。
然后,点击库目录,点击箭头,然后点击编辑,就像你为包含目录所做的那样。你必须将一个目录添加到列表中:
TesseractInstallPath\lib
配置链接器
在仍然在属性页上时,转到链接器 | 输入。编辑附加依赖项行,并包含两个库:
liblept168.lib
libtesseract302.lib
注意
由于lib名称中的数字指的是文件版本,因此如果安装了不同版本的 Tesseract,库名称可能会更改。为此,只需在 Windows 资源管理器中打开lib路径即可。
不幸的是,调试库(以 d 字母结尾的库)与 Tesseract 不兼容。如果你真的需要使用它们,你需要自己编译 Tesseract 和 Leptonica。
将库添加到 Windows 路径中
你必须将两个库文件添加到 Windows 路径中。第一个位于 TesseractInstallPath 中,称为 liblept168.dll。第二个位于 TesseractInstallPath\lib 中,称为 libtesseract302.dll。有两种方法可以实现这一点:
-
将这些文件复制到 Visual Studio 生成可执行文件的位置。这不会将这些文件添加到 Windows 路径中,但允许应用程序运行。
-
将这些文件复制到配置在 Windows 路径中的文件夹。你可以通过更改 系统属性 中的环境变量来在 Windows 路径中配置新文件夹。
小贴士
一些互联网教程教你将这些文件包含在文件夹中,例如
Windows\System32。不要这样做。如果你这样做,将来更改库版本可能会很困难,因为这个文件夹包含很多其他的dlls系统,你可能会失去跟踪你已经在那里放置了什么。此外,你总是可以禁用自定义路径来测试安装程序并检查你是否忘记在你的安装包中打包dll。
在 Mac 上安装 Tesseract
在 Mac 上安装 Tesseract OCR 的最简单方法是使用 Homebrew。如果你还没有安装 Homebrew,只需访问 Homebrew 网站 (brew.sh/),打开你的控制台,并运行首页上的 Ruby 脚本。你可能需要输入管理员密码。
安装 homebrew 后,只需输入以下命令:
brew install tesseract
英语语言已经包含在这个安装中。如果你想安装其他语言包,只需运行以下命令:
brew install tesseract --all-languages
这将安装所有语言包。然后,只需转到 Tesseract 安装目录并删除所有不需要的语言。Homebrew 通常将软件安装到 /usr/local/。
使用 Tesseract OCR 库
由于 Tesseract OCR 已经与 OpenCV 3.0 集成,因此研究其 API 仍然值得,因为它允许更精细地控制 Tesseract 参数。集成将在下一章中研究。
创建一个 OCR 函数
我们将修改之前的示例以使用 Tesseract。我们将从向列表中添加 baseapi 和 fstream tesseracts 开始:
#include <opencv2/opencv.hpp>
#include <tesseract/baseapi.h>
#include <vector>
#include <fstream>
然后,我们将创建一个全局的 TessBaseAPI 对象,它代表我们的 Tesseract OCR 引擎:
tesseract::TessBaseAPI ocr;
小贴士
ocr 引擎是完全自包含的。如果你想创建多线程 OCR 软件,只需为每个线程添加不同的 TessBaseAPI 对象,执行将相当线程安全。你只需要保证文件写入不是在同一个文件上;否则,你需要保证此操作的安全性。
接下来,我们将创建一个名为 identify 的函数来运行 OCR:
char* identifyText(Mat input, char* language = "eng")
{
ocr.Init(NULL, language, tesseract::OEM_TESSERACT_ONLY);
ocr.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK);
ocr.SetImage(input.data, input.cols, input.rows, 1, input.step);
char* text = ocr.GetUTF8Text();
cout << "Text:" << endl;
cout << text << endl;
cout << "Confidence: " << ocr.MeanTextConf() << endl << endl;
// Get the text
return text;
}
让我们逐行解释这个函数。在第一行,我们开始初始化 Tesseract。这是通过调用 init 函数来完成的。此函数具有以下签名:
int Init(const char* datapath, const char* language, OcrEngineMode oem)
让我们逐个解释每个参数:
-
Datapath: 这是根目录中
tessdata文件的路径。路径必须以反斜杠 / 字符结尾。tessdata目录包含您安装的语言文件。将NULL传递给此参数将使 Tesseract 在其安装目录中搜索,这通常是此文件夹通常所在的位置。在部署应用程序时,通常将此值更改为args[0],并将tessdata文件夹包含在应用程序路径中。 -
Language: 这是一个由语言代码组成的三个字母的单词(例如,eng 代表英语,por 代表葡萄牙语,或 hin 代表印地语)。Tesseract 支持使用
+符号加载多个语言代码。因此,传递 eng + por 将加载英语和葡萄牙语。当然,您只能使用您之前安装的语言;否则,加载将失败。语言config文件可以指定必须一起加载两个或多个语言。为了防止这种情况,您可以使用波浪号~。例如,您可以使用hin+~eng来确保即使配置了这样做,也不会将英语与印地语一起加载。 -
OcrEngineMode: 这些是将要使用的 OCR 算法。它们可以具有以下值之一:
-
OEM_TESSERACT_ONLY: 这仅使用 Tesseract。这是最快的方法,但精度较低。 -
OEM_CUBE_ONLY: 这使用 cube 引擎。它较慢,但更精确。这将仅适用于您的语言经过训练以支持此引擎模式的情况。要检查是否如此,请查看tessdata文件夹中您语言对应的.cube文件。对英语的支持是保证的。 -
OEM_TESSERACT_CUBE_COMBINED: 这将 Tesseract 和 Cube 结合起来,以实现最佳的 OCR 分类。此引擎具有最佳的准确性和最慢的执行时间。 -
OEM_DEFAULT: 这尝试根据语言config文件和命令行config文件推断策略,或者在两者都缺失的情况下使用OEM_TESSERACT_ONLY。
-
需要强调的是,init 函数可以执行多次。如果提供了不同的语言或引擎模式,Tesseract 将清除之前的配置并重新开始。如果提供了相同的参数,Tesseract 足够智能,可以简单地忽略该命令。init 函数在成功时返回 0,在失败时返回 -1。
然后我们的程序继续设置页面分割模式:
ocr.SetPageSegMode(tesseract::PSM_SINGLE_BLOCK);
有几种分割模式可用,如下所示:
-
PSM_OSD_ONLY: 使用此模式,Tesseract 只运行其预处理算法以检测方向和脚本检测。 -
PSM_AUTO_OSD: 这告诉 Tesseract 执行带有方向和脚本检测的自动页面分割。 -
PSM_AUTO_ONLY: 这执行页面分割,但避免执行方向、脚本检测或 OCR。 -
PSM_AUTO: 这执行页面分割和 OCR,但避免执行方向或脚本检测。 -
PSM_SINGLE_COLUMN: 这假设可变大小的文本显示在单个列中。 -
PSM_SINGLE_BLOCK_VERT_TEXT: 这将图像视为一个垂直对齐的单一均匀文本块。 -
PSM_SINGLE_BLOCK: 这是一个单独的文本块。这是默认配置。我们将使用此标志,因为我们的预处理阶段保证了这种条件。 -
PSM_SINGLE_LINE: 这表示图像只包含一行文本。 -
PSM_SINGLE_WORD: 这表示图像只包含一个单词。 -
PSM_SINGLE_WORD_CIRCLE: 这表示图像是一个圆形排列的单词。 -
PSM_SINGLE_CHAR: 这表示图像只包含一个字符。
注意,Tesseract 已经实现了deskewing和文本分割算法,就像大多数 OCR 库一样。但了解这些算法是有趣的,因为您可以为特定需求提供自己的预处理阶段。这允许您在许多情况下改进文本检测。例如,如果您正在为旧文档创建 OCR 应用程序,Tesseract 默认使用的阈值可能会创建一个深色背景。Tesseract 还可能被边缘或严重的文本倾斜所困惑。
接下来,我们使用以下签名调用SetImage方法:
void SetImage(const unsigned char* imagedata, int width, int height, int bytes_per_pixel, int bytes_per_line);
参数几乎是自我解释的,其中大部分可以直接从我们的mat对象中读取:
-
data: 这是一个包含图像数据的原始字节数组。OpenCV 在Mat类中包含一个名为data()的函数,它提供对数据的直接指针。 -
width: 这是图像宽度。 -
height: 这是图像高度。 -
bytes_per_pixel: 这是每像素的字节数。我们使用1,因为我们处理的是二进制图像。如果您想允许代码更通用,您还可以使用Mat::elemSize()函数,它提供相同的信息。 -
bytes_per_line: 这是指单行中的字节数。我们使用Mat::step属性,因为有些图像会添加尾随字节。
然后,我们调用GetUTF8Text来运行识别本身。识别的文本返回,以 UTF8 编码,不带 BOM(字节顺序标记)。在我们返回之前,我们还打印了一些调试信息。
MeanTextConf返回一个置信度指数,可以是0到100之间的数字:
char* text = ocr.GetUTF8Text();
cout << "Text:" << endl;
cout << text << endl;
cout << "Confidence: " << ocr.MeanTextConf() << endl << endl;
将输出发送到文件
让我们将我们的main方法更改为将识别的输出发送到文件。我们使用标准的ofstream来完成此操作:
int main(int argc, char* argv[])
{
//Loads the ticket image and binarize it
Mat ticket = binarize(imread("ticket.png"));
auto regions = findTextAreas(ticket);
std::ofstream file;
file.open("ticket.txt", std::ios::out | std::ios::binary);
//For each region
for (auto region : regions) {
//Crop
auto cropped = deskewAndCrop(ticket, region);
char* text = identifyText(cropped, "por");
file.write(text, strlen(text));
file << endl;
}
file.close();
}
注意以下行:
file.open("ticket.txt", std::ios::out | std::ios::binary);
这将以二进制模式打开文件。这很重要,因为 Tesseract 返回的文本是以 UTF-8 编码的,考虑到 Unicode 中可用的特殊字符。我们还将输出直接使用以下命令写入:
file.write(text, strlen(text));
在这个示例中,我们使用葡萄牙语作为输入语言调用了 identify(这是票据所写的语言)。如果你喜欢,可以使用另一张照片。
注意
完整的源代码文件包含在 segmentOcr.cpp 文件中,该文件与本书一同提供。
小贴士
ticket.png 是一个低分辨率的图片,因为我们想象你可能希望在研究这段代码的同时显示包含该图片的窗口。对于这张图片,Tesseract 的识别结果相当差。如果你想用更高分辨率的图片进行测试,代码中提供了一个 ticketHigh.png 图片。要测试这张图片,将膨胀重复次数改为 12,并将最小框大小从 20 改为 60。你将获得更高的置信率(大约 87%),并且生成的文本将完全可读。segmentOcrHigh.cpp 文件包含了这些修改。
摘要
在本章中,我们简要介绍了 OCR 应用。我们了解到,这类系统的预处理阶段必须根据我们计划识别的文档类型进行调整。我们学习了预处理文本文件时的常见操作,例如阈值化、裁剪、倾斜和文本区域分割。最后,我们学习了如何安装和使用 Tesseract OCR 将我们的图片转换为文本。
在下一章中,我们将使用更复杂的 OCR 技术来识别随意拍摄的图片或视频中的文本——这种情况被称为场景文本识别。这是一个更加复杂的场景,因为文本可能出现在任何地方,使用任何字体,并且具有不同的照明和方向。甚至可能完全没有文本!我们还将学习如何使用与 Tesseract 完全集成的 OpenCV 3.0 文本贡献模块。
第十一章。使用 Tesseract 进行文本识别
在上一章中,我们介绍了非常基本的 OCR 处理功能。虽然它们对于扫描或拍摄的文档非常有用,但当处理随意出现在图片中的文本时,它们几乎毫无用处。
在本章中,我们将探讨 OpenCV 3.0 文本模块,该模块专门处理场景文本检测。使用此 API,可以检测出现在网络摄像头视频中的文本,或者分析照片图像(如街景或监控摄像头拍摄的图像)以实时提取文本信息。这允许创建从无障碍到营销甚至机器人领域的广泛应用。
到本章结束时,你将能够:
-
理解场景文本识别是什么
-
理解文本 API 的工作原理
-
使用 OpenCV 3.0 文本 API 检测文本
-
将检测到的文本提取到图像中
-
使用文本 API 和 Tesseract 集成来识别字母
文本 API 的工作原理
文本 API 实现了 Lukás Neumann 和 Jiri Matas 在 2012 年 CVPR(计算机视觉和模式识别)会议期间发表的名为 Real-Time Scene Text Localization and Recognition 的文章中提出的算法。该算法在 CVPR 数据库以及 Google Street View 数据库中都实现了场景文本检测的重大突破。
在我们使用 API 之前,让我们看看这个算法在底层是如何工作的,以及它是如何解决场景文本检测问题的。
注意
记住,OpenCV 3.0 文本 API 不包含在标准 OpenCV 模块中。它是一个存在于 OpenCV 贡献包中的附加模块。如果你需要使用 Windows 安装程序安装 OpenCV,请参阅第一章,OpenCV 入门,这将帮助你安装这些模块。
场景检测问题
在场景中检测随机出现的文本是一个比看起来更难的问题。当我们将其与识别扫描文本进行比较时,有几个新的变量,如下所示:
-
三维性:文本可以以任何比例、方向或视角出现。此外,文本可能部分被遮挡或中断。实际上有成千上万的可能性,文本可以出现在图像中的任何区域。
-
多样性:文本可以以几种不同的字体和颜色出现。字体可以有轮廓边框或没有。背景可以是深色、浅色或复杂的图像。
-
光照与阴影:阳光的位置和外观颜色会随时间变化。不同的天气条件,如雾或雨,可以产生噪声。即使在封闭空间中,光照也可能成为问题,因为光线会在彩色物体上反射并击中文本。
-
模糊:文本可能出现在自动对焦镜头不优先考虑的区域。在移动相机、透视文本或雾的存在下,模糊也很常见。
以下图像来自谷歌街景,说明了这些问题。注意这些情况如何在单张图像中同时发生:

执行文本检测以处理这种情况可能会证明计算成本很高,因为文本可以位于图像中像素的 2n 个子集中,其中 n 是图像中的像素数。
为了降低复杂性,通常采用两种策略,具体如下:
-
使用滑动窗口搜索图像矩形的子集。这种策略只是将子集的数量减少到更小的数量。区域的数量根据考虑的文本复杂性而变化。仅处理文本旋转的算法可以使用较小的值,而同时处理旋转、倾斜、透视等的算法则需要较大的值。这种方法的优点在于其简单性,但它通常局限于较窄的字体范围,并且通常局限于特定单词的词汇表。
-
使用连通分量分析。这种方法假设像素可以被分组到具有相似属性的区域内。这些区域更有可能被识别为字符。这种方法的优点是它不依赖于多个文本属性(方向、缩放和字体),并且它们还提供了一个可以用于裁剪文本到 OCR 的分割区域。这是前一章中使用的方法。
-
OpenCV 3.0 算法通过执行连通分量分析和搜索极值区域来使用第二种策略。
极值区域
极值区域是由均匀强度特征和周围对比度背景所包围的连通区域。可以通过计算区域对阈值变化的抵抗程度来衡量区域稳定性。这种变化可以通过一个简单的算法来测量:
-
应用阈值生成图像 A。检测其连通像素区域(极值区域)。
-
通过增加一个阈值增量生成图像 B。检测其连通像素区域(极值区域)。
-
将图像 B 与 A 进行比较。如果图像 A 中的某个区域与图像 B 中相同的区域相似,则将其添加到树中的同一分支。相似性的标准可能因实现而异,但通常与图像面积或一般形状有关。如果图像 A 中的某个区域在图像 B 中看起来被分割,则在树中为新的区域创建两个新的分支,并将它们与之前的分支关联。
-
将 A = B 并回到步骤 2,直到应用最大阈值。
这将组装一个区域树,如图所示:
![极值区域]()
图像来源:
docs.opencv.org/master/da/d56/group__text__detect.html#gsc.tab=0
防变异性是通过计算处于同一级别的节点数量来确定的。
通过分析这棵树,还可以确定MSERs(最大稳定极值区域),即在广泛的各种阈值下区域面积保持稳定的区域。在上一张图像中,很明显这些区域将包含字母O、N和Y。MSERs 的主要缺点是它们在模糊存在的情况下较弱。OpenCV 在feature2d模块中提供了一个 MSER 特征检测器。
极值区域很有趣,因为它们对光照、尺度和方向具有很强的不变性。它们也是文本的良好候选者,因为它们对字体类型也不敏感,即使字体有样式。每个区域也可以进行分析,以确定其边界椭圆和具有可数值确定的属性,如仿射变换和面积。最后,值得一提的是,整个过程非常快,这使得它非常适合实时应用。
极值区域过滤
虽然 MSERs 是定义哪些极值区域值得工作的常见方法,但 Neumann 和 Matas 算法通过将所有极值区域提交给一个用于字符检测的已训练的顺序分类器来采用不同的方法。这个分类器在以下两个不同的阶段工作:
-
第一阶段逐步计算每个区域的描述符(边界框、周长、面积和欧拉数)。这些描述符提交给一个分类器,该分类器估计该区域是字母的概率。然后,只选择高概率的区域进入第二阶段。
-
在这个阶段,计算整个区域比率、凸包比率和外部边界拐点数量。这提供了更详细的信息,使分类器能够丢弃非文本字符,但它们的计算速度也较慢。
在 OpenCV 中,这个过程在ERFilter类中实现。也可以使用不同的图像单通道投影,如 R、G、B、亮度或灰度转换,以提高字符识别率。
最后,所有字符必须分组到文本块中(如单词或段落)。OpenCV 3.0 为此提供了两种算法:
-
剪枝穷举搜索:这也是 Mattas 在 2011 年提出的。这个算法不需要任何先前的训练或分类,但仅限于水平对齐的文本。
-
面向文本的分层方法:这处理任何方向的文本,但需要一个训练好的分类器。
注意
由于这些操作需要分类器,因此还需要提供一个训练集作为输入。OpenCV3.0 在sample包中提供了一些这些训练集。这也意味着这个算法对分类器训练中使用的字体敏感。
可以在 Neumann 提供的视频中看到这个算法的演示:youtu.be/ejd5gGea2Fo。
一旦文本被分割,只需将其发送到 OCR,例如 Tesseract,就像我们在上一章中所做的那样。唯一的区别是现在我们将使用 OpenCV 文本模块类与 Tesseract 交互,因为它们提供了一种封装我们使用特定 OCR 引擎的方法。
使用文本 API
理论已经足够。现在是时候看看文本模块在实际中的应用了。让我们研究如何使用它来进行文本检测、提取和识别。
文本检测
让我们从创建一个简单的程序开始,使用ERFilters进行文本分割。在这个程序中,我们将使用文本 API 示例中的训练分类器。您可以从 OpenCV 仓库中下载它们,但它们也包含在本书的配套代码中。
首先,我们从包含所有必要的libs和使用:
#include "opencv2/highgui.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/text.hpp"
#include <vector>
#include <iostream>
using namespace std;
using namespace cv;
using namespace cv::text;
回想我们之前的部分,ERFilter在图像的每个通道中单独工作。因此,我们必须提供一种方法,在不同的单个cv::Mat通道中分离每个所需的通道。这是通过separateChannels函数完成的:
vector<Mat> separateChannels(Mat& src)
{
vector<Mat> channels;
//Grayscale images
if (src.type() == CV_8U || src.type() == CV_8UC1) {
channels.push_back(src);
channels.push_back(255-src);
return channels;
}
//Colored images
if (src.type() == CV_8UC3) {
computeNMChannels(src, channels);
int size = static_cast<int>(channels.size())-1;
for (int c = 0; c < size; c++)
channels.push_back(255-channels[c]);
return channels;
}
//Other types
cout << "Invalid image format!" << endl;
exit(-1);
}
首先,我们验证图像是否是单通道图像(例如,灰度图像)。如果是这样,我们只需添加这个图像及其负图像进行处理。
否则,我们检查它是否是 RGB 图像。对于彩色图像,我们调用computeNMChannels来将图像分割成其多个通道。该函数定义如下:
void computeNMChannels(InputArray src, OutputArrayOfArrays channels, int mode = ERFILTER_NM_RGBLGrad);
其参数描述如下:
-
src:这是源输入数组。它应该是一个 8UC3 类型的彩色图像。 -
channels:这是一个向量,其中将填充结果通道。 -
mode:这定义了将要计算的通道。可以使用两个可能的值,如下所示: -
ERFILTER_NM_RGBLGrad:这表示算法使用 RGB 颜色、亮度和梯度幅度作为通道(默认)。 -
ERFILTER_NM_IHSGrad:这表示图像将通过其强度、色调、饱和度和梯度幅度进行分割。
我们还在向量中附加了所有颜色成分的负图像。最后,如果提供了另一种类型的图像,函数将使用错误消息终止程序。
注意
负图像被附加,以便算法可以覆盖亮背景上的暗文本和亮背景上的暗文本。没有必要将负图像添加到梯度幅度中。
让我们继续到main方法。我们将使用程序来分割提供的easel.png图像:

这张照片是由手机摄像头拍摄的,当时我正在街上行走。让我们编写代码,以便你可以通过在第一个程序参数中提供其名称来轻松地使用不同的图像:
int main(int argc, const char * argv[])
{
char* image = argc < 2 ? "easel.png" : argv[1];
auto input = imread(image);
接下来,我们将通过调用separateChannels函数将图像转换为灰度并分离其通道:
Mat processed;
cvtColor(input, processed, CV_RGB2GRAY);
auto channels = separateChannels(processed);
如果你想在彩色图像中处理所有通道,只需将前面代码的前两行替换为以下代码:
Mat processed = input;
我们需要分析六个通道(RGB + 反转)而不是两个(灰度 + 反转)。实际上,处理时间将增加得比我们可以通过get.With这些通道获得的改进要多得多。有了这些通道在手,我们需要为算法的两个阶段创建ERFilters。幸运的是,opencv text贡献模块提供了相应的函数:
// Create ERFilter objects with the 1st and 2nd stage classifiers
auto filter1 = createERFilterNM1(loadClassifierNM1("trained_classifierNM1.xml"), 15, 0.00015f, 0.13f, 0.2f,true,0.1f);
auto filter2 = createERFilterNM2( loadClassifierNM2("trained_classifierNM2.xml"),0.5);
对于第一阶段,我们调用loadClassifierNM1函数来加载一个之前训练好的分类模型。包含训练数据的 XML 文件是其唯一参数。然后,我们调用createERFilterNM1来创建一个ERFilter类的实例,该实例将执行分类。该函数具有以下签名:
Ptr<ERFilter> createERFilterNM1(const Ptr<ERFilter::Callback>& cb,
int thresholdDelta = 1,
float minArea = 0.00025, float maxArea = 0.13,
float minProbability = 0.4, bool nonMaxSuppression = true, float minProbabilityDiff = 0.1);
参数描述如下:
-
cb:这是分类模型。这是与我们使用loadCassifierNM1函数加载的相同模型。 -
thresholdDelta:这是在每次算法迭代中要添加到阈值中的量。默认值是1,但我们在示例中会使用15。 -
minArea:这是可以找到文本的 ER 的最小面积。这也在图像大小的%中测量。面积小于此的 ER 将被立即丢弃。 -
maxArea:这是可以找到文本的 ER 的最大面积。这也在图像大小的%中测量。面积大于此的 ER 将被立即丢弃。 -
minProbability:这是一个区域必须具有的最小概率,才能被视为字符并保留到下一阶段。 -
nonMaxSupression:这表示将在每个分支概率中执行非最大抑制。 -
minProbabilityDiff:这是最小和最大极值区域之间的最小概率差异。
第二阶段的处理过程类似。我们调用loadClassifierNM2来加载第二阶段的分类器模型,并调用createERFilterNM2来创建第二阶段的分类器。此函数仅接受加载的分类模型和一个区域必须达到的最小概率作为输入参数。
因此,让我们在每个通道中调用这些算法以识别所有可能的文本区域:
//Extract text regions using Newmann & Matas algorithm
cout << "Processing " << channels.size() << " channels...";
cout << endl;
vector<vector<ERStat> > regions(channels.size());
for (int c=0; c < channels.size(); c++)
{
cout << " Channel " << (c+1) << endl;
filter1->run(channels[c], regions[c]);
filter2->run(channels[c], regions[c]);
}
filter1.release();
filter2.release();
在前面的代码中,我们使用了ERFilter类的run函数。此函数接受以下两个参数:
-
输入通道:这是要处理的图像。
-
区域:在第一阶段算法中,此参数将被填充为检测到的区域。在第二阶段(由
filter2执行),此参数必须包含第一阶段中选择的区域,这些区域将在第二阶段进行处理和过滤。
最后,我们释放了这两个过滤器,因为程序中不再需要它们。
最终的分割步骤是将所有ERRegions分组到可能的单词中,并定义它们的边界框。这是通过调用erGrouping函数来完成的:
//Separate character groups from regions
vector< vector<Vec2i> > groups;
vector<Rect> groupRects;
erGrouping(input, channels, regions, groups, groupRects, ERGROUPING_ORIENTATION_HORIZ);
这个函数具有以下签名:
void erGrouping(InputArray img, InputArrayOfArrays channels,
std::vector<std::vector<ERStat> > ®ions,
std::vector<std::vector<Vec2i> > &groups,
std::vector<Rect> &groups_rects,
int method = ERGROUPING_ORIENTATION_HORIZ,
const std::string& filename = std::string(),
float minProbablity = 0.5);
让我们来看看每个参数的定义:
-
img:这是原始输入图像。你可以参考以下观察。 -
regions:这是一个包含提取区域的单通道图像的向量。 -
groups:这是一个包含分组区域索引的输出向量。每个分组区域包含一个单词的所有极值区域。 -
groupRects:这是一个包含检测到的文本区域的矩形列表。 -
method:这是分组的方法。它可以如下所示:-
ERGROUPING_ORIENTATION_HORIZ:这是默认值。它通过执行穷举搜索来仅生成水平方向的文本组,正如 Neumann 和 Matas 最初提出的那样。 -
ERGROUPING_ORIENTATION_ANY:这会生成任何方向的文本组,使用单链接聚类和分类器。如果你使用这种方法,必须在下一个参数中提供分类器模型的文件名。 -
Filename:这是分类器模型的名称。只有在选择ERGROUPING_ORIENTATION_ANY时才需要。 -
minProbability:这是接受一个组的最低检测概率。它也只有在使用ERGROUPING_ORIENTATION_ANY时才需要。
-
代码还提供了一个对第二个方法的调用,但它是注释掉的。你可以在这两个之间切换以测试它。只需注释掉上一个调用并取消注释这个调用:
erGrouping(input, channels, regions,
groups, groupRects, ERGROUPING_ORIENTATION_ANY,
"trained_classifier_erGrouping.xml", 0.5);
对于这个调用,我们还使用了文本模块示例包中提供的默认训练好的分类器。
最后,我们绘制区域框并显示结果:
// draw groups boxes
for (auto rect : groupRects)
rectangle(input, rect, Scalar(0, 255, 0), 3);
imshow("grouping",input);
waitKey(0);
程序的输出如下所示:

你可以在detection.cpp文件中查看完整的源代码。
注意
尽管大多数 OpenCV 文本模块函数都编写为支持灰度和彩色图像作为它们的输入参数,但在本书编写时,存在一些错误阻止了在诸如 erGrouping 之类的函数中使用灰度图像;例如。请参阅github.com/Itseez/opencv_contrib/issues/309。
总是记住,OpenCV 贡献模块包不如默认的opencv包稳定。
文本提取
现在我们已经检测到了区域,在提交给 OCR 之前,我们必须裁剪文本。我们可以简单地使用一个函数,例如getRectSubpix或Mat::copy,使用每个区域矩形作为ROI(感兴趣区域),但由于字母是倾斜的,一些不需要的文本也可能被裁剪掉;例如,如果我们仅仅基于给定的矩形提取 ROI,这个区域将看起来像这样:

幸运的是,ERFilter为我们提供了一个名为ERStat的对象,它包含每个极值区域内的像素。使用这些像素,我们可以使用 OpenCV 的floodFill函数来重建每个字母。这个函数能够根据种子点绘制类似颜色的像素,就像大多数绘图应用程序的“桶”工具一样。这个函数的签名如下:
int floodFill(InputOutputArray image, InputOutputArray mask,
Point seedPoint, Scalar newVal, CV_OUT Rect* rect=0,
Scalar loDiff = Scalar(), Scalar upDiff = Scalar(),
int flags = 4
);
让我们了解这些参数以及它们如何使用:
-
image:这是输入图像。我们将使用包含极值区域的通道图像。这是函数通常执行 flood fill 的地方,除非提供了FLOODFILL_MASK_ONLY。在这种情况下,图像保持不变,绘制发生在掩码上。这正是我们将要做的。 -
mask:掩码必须比输入图像多两行和两列。当 flood fill 绘制像素时,它会验证掩码中相应的像素是否为零。如果是这样,它将绘制并标记此像素为 1(或通过标志传入的其他值)。如果像素不是零,flood fill 不会绘制像素。在我们的情况下,我们将提供一个空白掩码,因此每个字母都会在掩码中上色。 -
seedPoint:这是起始点。它类似于当你想要使用图形应用程序的“桶”工具时点击的位置。 -
newVal:这是重新绘制像素的新值。 -
loDiff和upDiff:这些参数表示正在处理的像素与其邻居之间的下限和上限差异。如果邻居落在这个范围内,它将被绘制。如果使用了FLOODFILL_FIXED_RANGE标志,则将使用种子点和正在处理的像素之间的差异。 -
rect:这是一个可选参数,它限制了 flood fill 将要应用的区域。 -
flags:这个值由一个位掩码表示。-
标志的最低八位包含一个连通性值。值为 4 表示将使用所有四个边缘像素,而值为 8 则表示必须考虑对角像素。我们将为此参数使用 4。
-
接下来的 8 到 16 位包含一个 1 到 255 之间的值,并用于填充掩码。由于我们希望用白色填充掩码,我们将使用
255 << 8为此值。 -
有两个额外的位可以通过添加
FLOODFILL_FIXED_RANGE和FLOODFILL_MASK_ONLY标志来设置,如前所述。
-
我们将创建一个名为drawER的函数。这个函数将接收四个参数:
-
包含所有处理通道的向量
-
ERStat区域 -
必须绘制的组
-
组矩形
这个函数将返回一个由这个组表示的单词的图像。让我们从这个函数开始,创建掩码图像并定义标志:
Mat out = Mat::zeros(channels[0].rows+2, channels[0].cols+2, CV_8UC1);
int flags = 4 //4 neighbors
+ (255 << 8) //paint mask in white (255)
+ FLOODFILL_FIXED_RANGE //fixed range
+ FLOODFILL_MASK_ONLY; //Paint just the mask
然后,我们将遍历每个组。找到区域索引及其统计信息是必要的。有可能这个极端区域将是根,它不包含任何点。在这种情况下,我们将忽略它:
for (int g=0; g < group.size(); g++)
{
int idx = group[g][0];
ERStat er = regions[idx][group[g][1]];
//Ignore root region
if (er.parent == NULL)
continue;
现在,我们可以从ERStat对象中读取像素坐标。它由像素编号表示,从上到下,从左到右计数。这个线性索引必须转换为一个类似我们在第二章中讨论的公式,即OpenCV 基础知识介绍的行(y)和列(z)表示法:
int px = er.pixel % channels[idx].cols;
int py = er.pixel / channels[idx].cols;
Point p(px, py);
然后,我们可以调用floodFill函数。ERStat对象为我们提供了在loDiff参数中需要使用的值:
floodFill(
channels[idx], out, //Image and mask
p, Scalar(255), //Seed and color
nullptr, /No rect
Scalar(er.level),Scalar(0), //LoDiff and upDiff
flags //Flags
在对组中的所有区域都这样做之后,我们将以一个比原始图像略大的图像结束,背景为黑色,文字为白色。现在,让我们只裁剪字母区域。由于给出了区域矩形,我们首先将其定义为我们的感兴趣区域:
out = out(rect);
然后,我们将找到所有非零像素。这是我们将在minAreaRect函数中使用以获取围绕字母的旋转矩形的值。最后,我们借用前一章的deskewAndCrop函数来为我们裁剪和旋转图像:
vector<Point> points;
findNonZero(out, points);
//Use deskew and crop to crop it perfectly
return deskewAndCrop(out, minAreaRect(points));
}
这是画布图像处理的结果:

文本识别
在第十章,开发文本识别的分割算法中,我们直接使用 Tesseract API 来识别文本区域。这次,我们将使用 OpenCV 类来完成同样的目标。
在 OpenCV 中,所有 OCR 特定的类都从BaseOCR虚拟类派生。这个类为 OCR 执行方法本身提供了一个通用接口。
特定的实现必须从这个类继承。默认情况下,文本模块提供了三种不同的实现:OCRTesseract、OCRHMMDecoder和OCRBeamSearchDecoder。
以下类图展示了这个层次结构:

使用这种方法,我们可以将创建 OCR 机制的部分与执行本身分离。这使得将来更改 OCR 实现变得更加容易。
因此,让我们从创建一个基于字符串决定我们将使用哪种实现的方法开始。我们目前将支持 Tesseract。然而,你可以查看包含使用 HMMDecoder 的演示的章节代码。我们还在字符串参数中接受 OCR 引擎名称,但我们可以通过从外部 JSON 或 XML 配置文件中读取它来提高我们应用程序的灵活性:
cv::Ptr<BaseOCR> initOCR2(const string& ocr)
{
if (ocr == "tesseract") {
return OCRTesseract::create(nullptr, "eng+por");
}
throw string("Invalid OCR engine: ") + ocr;
}
你可能会注意到函数返回一个 Ptr<BaseOCR>。现在,看看高亮显示的代码。它调用创建方法来初始化一个 Tesseract OCR 实例。让我们看看它的官方签名,因为它允许几个特定的参数:
Ptr<OCRTesseract> create(const char* datapath=NULL, const char* language=NULL, const char* char_whitelist=NULL, int oem=3, int psmode=3);
让我们逐一分析这些参数:
-
datapath: 这是根目录中tessdata文件的路径。路径必须以反斜杠/字符结尾。tessdata目录包含你安装的语言文件。将nullptr传递给此参数将使 Tesseract 在其安装目录中搜索,这是该文件夹通常所在的位置。在部署应用程序时,通常将此值更改为args[0]并将tessdata文件夹包含在应用程序路径中。 -
language: 这是一个由三个字母组成的单词,包含语言代码(例如,eng 代表英语,por 代表葡萄牙语,或 hin 代表印地语)。Tesseract 支持使用加号加载多个语言代码。因此,传递 eng+por 将加载英语和葡萄牙语。当然,你只能使用你之前安装的语言;否则,加载将失败。语言配置文件可以指定必须一起加载两个或多个语言。为了防止这种情况,你可以使用波浪号 ~。例如,你可以使用 hin+~eng 来确保英语不会与印地语一起加载,即使配置了这样做。 -
whitelist: 这是用于识别的字符集。如果传递nullptr,则字符将是0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ。 -
oem: 这些是将要使用的 OCR 算法。它们可以有以下值之一:-
OEM_TESSERACT_ONLY: 这仅使用 Tesseract。这是最快的方法,但精度较低。 -
OEM_CUBE_ONLY: 这使用 cube 引擎。它较慢,但更精确。这只有在你的语言被训练以支持这种引擎模式时才会工作。要检查这一点,请查看tessdata文件夹中你语言的.cube文件。对英语语言的支持是保证的。 -
OEM_TESSERACT_CUBE_COMBINED: 这结合了 Tesseract 和 cube 以实现最佳的 OCR 分类。这个引擎具有最高的准确性和最慢的执行时间。 -
OEM_DEFAULT: 这会尝试根据语言config文件、命令行config文件推断策略,或者在两者都缺失的情况下使用OEM_TESSERACT_ONLY。
-
-
psmode: 这是分割模式。模式如下:-
PSM_OSD_ONLY:使用此模式,Tesseract 将仅运行其预处理算法以检测方向和脚本检测。 -
PSM_AUTO_OSD:这告诉 Tesseract 进行自动页面分割,包括方向检测和脚本检测。 -
PSM_AUTO_ONLY:此选项仅进行页面分割,但避免进行方向检测、脚本检测或 OCR。这是默认值。 -
PSM_AUTO:此选项进行页面分割和 OCR,但避免进行方向或脚本检测。 -
PSM_SINGLE_COLUMN:这假设文本以单列的形式显示。 -
PSM_SINGLE_BLOCK_VERT_TEXT:将图像视为一个垂直对齐的文本的单个统一块。 -
PSM_SINGLE_BLOCK:这是一个单独的文本块。这是默认配置。我们将使用此标志,因为我们的预处理阶段保证了这一条件。 -
PSM_SINGLE_LINE:这表示图像中只包含一行文本。 -
PSM_SINGLE_WORD:这表示图像中只包含一个单词。 -
PSM_SINGLE_WORD_CIRCLE:这表示图像是一个单词,且该单词被放置在一个圆圈中。 -
PSM_SINGLE_CHAR:这表示图像中包含单个字符。
-
对于最后两个参数,#include tesseract 目录建议您使用常量名称而不是直接插入它们的值。
最后一步是将文本检测添加到我们的 main 函数中。为此,只需将以下代码添加到 main 方法的末尾:
auto ocr = initOCR("tesseract");
for (int i = 0; i < groups.size(); i++)
{
Mat wordImage = drawER(channels, regions, groups[i], groupRects[i]);
string word;
ocr->run(wordImage, word);
cout << word << endl;
}
在此代码中,我们首先调用我们的 initOCR 方法来创建一个 Tesseract 实例。请注意,如果选择不同的 OCR 引擎,剩余的代码将不会改变,因为运行方法签名由 BaseOCR 类保证。
接下来,我们遍历每个检测到的 ERFilter 组。由于每个组代表不同的单词,我们:
-
调用之前创建的
drawER函数来创建包含单词的图像。 -
创建一个名为
word的文本字符串,并调用run函数来识别单词图像。识别出的单词将被存储在字符串中。 -
在屏幕上打印文本字符串。
让我们看看 run 方法的签名。此方法在 BaseOCR 类中定义,对于所有特定的 OCR 实现都将相同,即使是在未来可能实现的一些实现:
virtual void run(Mat& image, std::string& output_text, std::vector<Rect>* component_rects=NULL, std::vector<std::string>* component_texts=NULL, std::vector<float>* component_confidences=NULL,
int component_level=0) = 0;
当然,这是一个纯虚拟函数,必须由每个特定类(如我们刚刚使用的 OCRTesseract 类)实现:
-
image:这是输入图像。它必须是 RGB 或灰度图像 -
component_rects:我们可以提供一个向量,该向量将被填充,包含 OCR 引擎检测到的每个组件(单词或文本行)的边界框 -
component_texts:如果提供,此向量将被填充,包含 OCR 检测到的每个组件的文本字符串 -
component_confidences:如果提供,该向量将被填充,包含每个组件(单词或文本行)的置信度值 -
component_level:这定义了组件是什么。它可能有OCR_LEVEL_WORD(默认值)或OCR_LEVEL_TEXT_LINE的值
提示
如果需要,我们更倾向于在run()方法中将组件级别更改为单词或行,而不是在create()函数的psmode参数中做同样的事情。这是更可取的,因为run方法将得到任何决定实现BaseOCR类的 OCR 引擎的支持。始终记住,create()方法是在设置供应商特定配置的地方。
这是程序的最终输出:

尽管对&符号有些小困惑,但每个单词都被完美识别了!您可以在章节代码中的ocr.cpp文件中查看完整的源代码。
摘要
在本章中,我们了解到场景文本识别比处理扫描文本的 OCR 情况要困难得多。我们研究了文本模块如何使用Newmann 和 Matas算法通过极值区域识别来解决这个问题。我们还看到了如何使用这个 API 和floodfill函数将文本提取到图像中,并将其提交给 Tesseract OCR。最后,我们研究了 OpenCV 文本模块如何与 Tesseract 和其他 OCR 引擎集成,以及我们如何使用其类来识别图像中的文字。
这标志着我们使用 OpenCV 的旅程的结束。从这本书的开始到结束,我们希望您对计算机视觉领域有一个大致的了解,并对几个应用的工作原理有更好的理解。我们还试图向您展示,尽管 OpenCV 是一个非常令人印象深刻的库,但这个领域已经充满了改进和研究的机遇。
感谢您的阅读!无论您是使用 OpenCV 来创建基于计算机视觉的令人印象深刻的商业程序,还是将其用于可能改变世界的科研,我们都希望您觉得这个内容有用。只需继续运用您的技能——这仅仅是开始!








浙公网安备 33010602011771号