OpenCV4-项目构建学习指南-全-

OpenCV4 项目构建学习指南(全)

原文:annas-archive.org/md5/46252a52e50baddf77322b7c7e35340e

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

OpenCV 是用于开发计算机视觉应用中最受欢迎的库之一。它使我们能够在实时运行许多不同的计算机视觉算法。它已经存在很多年了,并已成为该领域的标准库。OpenCV 的主要优势之一是它高度优化,几乎在所有平台上都可用。

本书首先简要介绍了计算机视觉的各个领域以及相关的 OpenCV C++功能。每一章都包含真实世界的示例和代码示例,以展示用例。这有助于你轻松掌握主题,并了解它们如何在现实生活中应用。总之,这是一本关于如何使用 OpenCV 在 C++中构建各种应用的实用指南。

本书面向对象

本书面向那些对 OpenCV 新手,并希望使用 OpenCV 在 C++中开发计算机视觉应用的开发者。具备基本的 C++知识将有助于理解本书。本书对那些希望开始学习计算机视觉并理解其基本概念的人也很有用。他们应该了解基本的数学概念,如向量、矩阵和矩阵乘法,以便充分利用本书。在本书的学习过程中,你将学习如何从头开始使用 OpenCV 构建各种计算机视觉应用。

本书涵盖内容

第一章,OpenCV 入门,涵盖了在各个操作系统上的安装步骤,并介绍了人眼视觉系统以及计算机视觉中的各种主题。

第二章,OpenCV 基础知识介绍,讨论了如何在 OpenCV 中读取/写入图像和视频,并解释了如何使用 CMake 构建项目。

第三章,学习图形用户界面和基本滤波,涵盖了如何构建图形用户界面和鼠标事件检测器以构建交互式应用。

第四章,深入直方图和滤波器,探讨了直方图和滤波器,并展示了如何卡通化图像。

第五章,自动化光学检测、对象分割和检测,描述了各种图像预处理技术,如噪声去除、阈值和轮廓分析。

第六章,学习对象分类,涉及对象识别和机器学习,以及如何使用支持向量机构建对象分类系统。

第七章,检测人脸部分和叠加面具,讨论了人脸检测和 Haar 级联,并解释了如何使用这些方法来检测人脸的各个部分。

第八章,视频监控、背景建模和形态学操作,探讨了背景减法、视频监控和形态学图像处理,并描述了它们是如何相互关联的。

第九章,学习对象跟踪,介绍了如何使用不同的技术,如基于颜色和基于特征的技术,在实时视频中跟踪对象。

第十章,为文本识别开发分割算法,涵盖了光学字符识别、文本分割,并介绍了 Tesseract OCR 引擎的简介。

第十一章,使用 Tesseract 进行文本识别,更深入地探讨了 Tesseract OCR 引擎,解释了它如何用于文本检测、提取和识别。

第十二章,使用 OpenCV 进行深度学习,探讨了如何在 OpenCV 中应用深度学习,并介绍了两种常用的深度学习架构:用于目标检测的 YOLO v3 和用于人脸检测的单次检测器。

要充分利用本书

对 C++的基本了解将有助于理解本书。 示例使用以下技术构建:OpenCV 4.0; CMake 3.3.x 或更高版本; Tesseract; Leptonica(Tesseract 的依赖项); Qt(可选);以及 OpenGL(可选)。

详细安装说明提供在相关章节中。

下载示例代码文件

您可以从www.packt.com的账户下载本书的示例代码文件。如果您在其他地方购买了此书,您可以访问www.packt.com/support并注册,以便将文件直接通过电子邮件发送给您。

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

  1. www.packt.com上登录或注册。

  2. 选择 SUPPORT 标签。

  3. 点击代码下载和勘误表。

  4. 在搜索框中输入书籍名称,并遵循屏幕上的说明。

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

  • 适用于 Windows 的 WinRAR/7-Zip

  • 适用于 Mac 的 Zipeg/iZip/UnRarX

  • 适用于 Linux 的 7-Zip/PeaZip

本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition。 如果代码有更新,它将在现有的 GitHub 仓库中更新。

我们还有其他来自我们丰富的图书和视频目录的代码包可供选择,请访问github.com/PacktPublishing/。查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/9781789341225_ColorImages.pdf.

代码实战

访问以下链接查看代码运行的视频:

bit.ly/2Sfrxgu

使用的约定

本书使用了多种文本约定。

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“此外,安装此包是可选的。即使不安装 opencv_contrib,OpenCV 也能正常工作。”

代码块应如下设置:

// Load image to process 
  Mat img= imread(img_file, 0); 
  if(img.data==NULL){ 
    cout << "Error loading image "<< img_file << endl; 
    return 0; 
  } 

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

for(auto 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; 

任何命令行输入或输出都应如下编写:

C:> setx -m OPENCV_DIR D:OpenCVBuildx64vc14

粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”

警告或重要注意事项如下所示。

小技巧如下所示。

联系我们

我们欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,并 通过 customercare@packtpub.com 发送给我们。

勘误表:尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将不胜感激,如果您能向我们报告,我们将不胜感激。请访问 www.packt.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。

盗版:如果您在互联网上发现我们作品的任何形式的非法副本,我们将不胜感激,如果您能提供位置地址或网站名称,我们将不胜感激。请通过 copyright@packt.com 联系我们,并提供材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com.

评论

请留下评论。一旦您阅读并使用过这本书,为什么不在这本书购买的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!

关于 Packt 的更多信息,请访问 packt.com.

第一章:开始使用 OpenCV

计算机视觉应用很有趣且有用,但底层算法计算量很大。随着云计算的出现,我们获得了更多的处理能力。

OpenCV 库使我们能够高效地在实时中运行计算机视觉算法。它已经存在很多年了,并已成为该领域的标准库。OpenCV 的主要优势之一是它高度优化,几乎在所有平台上都可用。

本书将涵盖我们将要使用的各种算法,为什么我们要使用它们,以及如何在 OpenCV 中实现它们。

在本章中,我们将学习如何在各种操作系统上安装 OpenCV。我们将讨论 OpenCV 提供的功能,以及我们可以使用内置函数做些什么。

到本章结束时,你将能够回答以下问题:

  • 人类是如何处理视觉数据,又是如何理解图像内容的?

  • 我们可以用 OpenCV 做什么,OpenCV 中有哪些模块可以用来实现这些功能?

  • 我们如何在 Windows、Linux 和 Mac OS X 上安装 OpenCV?

理解人类视觉系统

在我们深入探讨 OpenCV 的功能之前,我们需要了解这些功能最初为何被构建。理解人类视觉系统的工作原理非常重要,这样你才能开发出正确的算法。

计算机视觉算法的目标是理解图像和视频的内容。人类似乎能毫不费力地做到这一点!那么,我们如何让机器以同样的精度做到这一点呢?

让我们考虑以下图表:

图片

人眼捕捉了所有沿路而来的信息,如颜色、形状、亮度等。在前面的图像中,人眼捕捉了关于两个主要物体的所有信息,并以某种方式存储。一旦我们理解了我们的系统是如何工作的,我们就可以利用它来实现我们的目标。

例如,以下是我们需要了解的一些事情:

  • 我们的视觉系统对低频内容比高频内容更敏感。低频内容指的是像素值变化不快的平面区域,高频内容指的是像素值波动很大的角落和边缘区域。我们很容易在平面表面上看到污点,但在高度纹理化的表面上很难发现类似的东西。

  • 人眼对亮度变化比颜色变化更敏感。

  • 我们的视觉系统对运动非常敏感。即使我们没有直接看它,我们也能迅速识别出视野中是否有物体在移动。

  • 我们倾向于在我们的视野中记住显著点。比如说,你看到一个白色的桌子,有四条黑色的腿,桌面的一个角落有一个红色的点。当你看这个桌子时,你会立即在脑海中记下桌面的颜色和腿的颜色是相反的,并且桌子的一个角落有一个红色的点。我们的头脑真的很聪明!我们这样做是自动的,这样我们就能立即识别出如果我们再次遇到它的话。

为了了解我们的视野,让我们看看人类的俯视图以及我们看到各种事物的角度:

图片

我们的大脑视觉系统实际上能够做到更多,但这应该足以让我们开始。你可以通过在网上阅读有关人类视觉系统(HVS)模型的内容来进一步探索。

人类是如何理解图像内容的?

如果你四处看看,你会看到很多物体。你每天都会遇到许多不同的物体,你几乎可以瞬间识别它们,而不需要任何努力。当你看到一把椅子时,你不需要等待几分钟才意识到它实际上是一把椅子。你立刻就知道它是一把椅子。

相反,计算机发现这项任务非常困难。研究人员已经多年致力于找出为什么计算机在这方面的表现不如我们。

要回答那个问题,我们需要了解人类是如何做到的。视觉数据处理发生在腹侧视觉流中。这个腹侧视觉流指的是我们视觉系统中与物体识别相关联的路径。它基本上是我们大脑中帮助识别物体的区域层次结构。

人类可以毫不费力地识别不同的物体,并将相似物体聚集在一起。我们可以这样做,因为我们已经发展出对同一类物体的一种不变性。当我们看一个物体时,我们的大脑以某种方式提取显著点,使得方向、大小、视角和照明等因素无关紧要。

一个尺寸是正常尺寸的两倍并且旋转了 45 度的椅子仍然是一把椅子。我们之所以能轻易地识别它,是因为我们处理它的方式。机器不能那么容易做到这一点。人类倾向于根据物体的形状和重要特征来记住物体。无论物体如何放置,我们仍然可以识别它。

在我们的视觉系统中,我们建立了关于位置、比例和视点的层次不变性,这使我们非常稳健。如果你深入我们的系统,你会看到人类在他们的视觉皮层中有细胞可以响应曲线和线条等形状。

随着我们沿着腹侧流进一步前进,我们将看到更多经过训练以对更复杂的物体(如树木、大门等)做出反应的复杂细胞。沿着我们的腹侧流,神经元倾向于显示出感受野大小的增加。这与它们首选刺激的复杂性增加的事实相辅相成。

为什么机器理解图像内容这么困难?

我们现在理解了视觉数据是如何进入人类视觉系统的,以及我们的系统是如何处理它的。问题是,我们仍然不完全理解我们的大脑是如何识别和组织这些视觉数据的。在机器学习中,我们只是从图像中提取一些特征,并要求计算机使用算法来学习它们。我们仍然有这些变体,如形状、大小、视角、角度、光照、遮挡等。

例如,当从侧面视角看同一把椅子时,机器对它的感知与人类截然不同。人类可以轻易地识别出它是一把椅子,无论它以何种方式呈现给我们。那么,我们该如何向我们的机器解释这一点呢?

实现这一点的其中一种方法是将一个物体的所有不同变体存储起来,包括大小、角度、视角等。但这个过程既繁琐又耗时。此外,实际上也不可能收集到涵盖所有变体的数据。机器需要消耗大量的内存和时间来构建一个能够识别这些物体的模型。

即使有了所有这些,如果一个物体部分被遮挡,计算机仍然无法识别它。这是因为它们认为这是一个新物体。因此,当我们构建计算机视觉库时,我们需要构建可以以多种方式组合的基本功能模块,以形成复杂的算法。

OpenCV 提供了许多这些功能,并且它们经过了高度优化。因此,一旦我们了解 OpenCV 的能力,我们就可以有效地使用它来构建有趣的应用程序。

让我们继续在下一节中探讨这个问题。

你可以用 OpenCV 做什么?

使用 OpenCV,你可以几乎完成你所能想到的每一个计算机视觉任务。现实生活中的问题需要你使用许多计算机视觉算法和模块一起工作,以达到预期的结果。所以,你只需要了解哪些 OpenCV 模块和函数可以使用,以便得到你想要的结果。

让我们看看 OpenCV 开箱即用的功能。

内置数据结构和输入/输出

OpenCV 最好的事情之一是它提供了许多内置原语来处理与图像处理和计算机视觉相关的操作。如果你必须从头开始编写,你将不得不定义ImagePointRectangle等。这些对于几乎任何计算机视觉算法都是基本的。

OpenCV 自带所有这些基本结构,包含在核心模块中。另一个优点是这些结构已经针对速度和内存进行了优化,因此你不必担心实现细节。

imgcodecs 模块负责图像文件的读取和写入。当你对一个输入图像进行操作并创建一个输出图像时,你可以使用简单的命令将其保存为 .jpg.png 文件。

当你与摄像头一起工作时,你会处理大量的视频文件。videoio 模块处理与视频文件输入和输出相关的所有事情。你可以轻松地从网络摄像头捕获视频或以许多不同的格式读取视频文件。你甚至可以通过设置每秒帧数、帧大小等属性,将一系列帧保存为视频文件。

图像处理操作

当你编写计算机视觉算法时,你会反复使用许多基本的图像处理操作。这些函数中的大多数都包含在 imgproc 模块中。你可以进行诸如图像滤波、形态学操作、几何变换、颜色转换、在图像上绘制、直方图、形状分析、运动分析、特征检测等等操作。

让我们考虑以下照片:

图片

右侧的图像是左侧图像的旋转版本。我们可以在 OpenCV 中用一行代码完成这种变换。

另有一个名为 ximgproc 的模块,其中包含了一些高级图像处理算法,例如用于边缘检测的结构化森林、域变换滤波器、自适应流形滤波器等等。

GUI

OpenCV 提供了一个名为 highgui 的模块,用于处理所有高级用户界面操作。假设你正在处理一个问题,并且想在继续到下一步之前检查图像的外观。此模块包含可以用于创建窗口以显示图像和/或视频的函数。

有一个等待函数,它会在继续到下一步之前等待你按下键盘上的某个键。还有一个可以检测鼠标事件的函数。这在开发交互式应用程序中非常有用。

使用此功能,你可以在这些输入窗口上绘制矩形,然后根据所选区域继续操作。考虑以下截图:

图片

如你所见,我们在窗口顶部绘制了一个绿色矩形。一旦我们有了该矩形的坐标,我们就可以只在该区域进行操作。

视频分析

视频分析包括分析视频中连续帧之间的运动、跟踪视频中的不同对象、创建视频监控模型等任务。OpenCV 提供了一个名为 video 的模块,可以处理所有这些。

还有一个名为videostab的模块,用于处理视频稳定化。视频稳定化非常重要,因为当你用手持相机拍摄视频时,通常会有很多需要校正的抖动。所有现代设备都会在将视频呈现给最终用户之前使用视频稳定化来处理视频。

3D 重建

3D 重建是计算机视觉中的一个重要主题。给定一组二维图像,我们可以使用相关算法来重建三维场景。OpenCV 提供了算法,可以在其calib3d模块中找到这些二维图像中各种对象之间的关系,以计算它们的 3D 位置。

此模块还可以处理相机标定,这对于估计相机的参数至关重要。这些参数定义了相机如何观察其前面的场景。我们需要知道这些参数来设计算法,否则我们可能会得到意外的结果。

让我们考虑以下图表:

如我们所见,相同的对象从多个位置被捕捉。我们的任务是使用这些二维图像重建原始对象。

特征提取

如我们之前讨论的,人类的视觉系统倾向于从给定的场景中提取显著特征以便于后续检索。为了模仿这一点,人们开始设计各种特征提取器,可以从给定的图像中提取这些显著点。流行的算法包括尺度不变特征变换SIFT)、加速鲁棒特征SURF)和加速段测试特征FAST)。

一个名为features2d的 OpenCV 模块提供了检测和提取所有这些特征的功能。另一个名为xfeatures2d的模块提供了一些额外的特征提取器,其中一些仍处于实验阶段。如果你有机会,可以尝试玩弄这些功能。

还有一个名为bioinspired的模块,提供了生物启发的计算机视觉模型算法。

目标检测

目标检测是指检测给定图像中对象的位置。这个过程并不关心对象的类型。如果你设计了一个椅子检测器,它不会告诉你给定图像中的椅子是高背红色还是低背蓝色——它只会告诉你椅子的位置。

在许多计算机视觉系统中,检测对象的位置是一个关键步骤。考虑以下照片:

如果你在该图像上运行一个椅子检测器,它会在所有椅子上放置一个绿色框,但它不会告诉你这是什么类型的椅子。

由于需要在各种尺度上进行检测所需的计算量,物体检测曾经是一个计算密集型任务。为了解决这个问题,Paul Viola 和 Michael Jones 在他们开创性的 2001 年论文中提出了一个伟大的算法,你可以在以下链接中阅读:www.cs.cmu.edu/~efros/courses/LBMV07/Papers/viola-cvpr-01.pdf。他们提供了一种快速设计任何对象检测器的方法。

OpenCV 有名为 objdetectxobjdetect 的模块,它们提供了设计对象检测器的框架。你可以用它来开发用于随机物品(如太阳镜、靴子等)的检测器。

机器学习

机器学习算法被广泛用于构建计算机视觉系统,用于物体识别、图像分类、人脸检测、视觉搜索等。

OpenCV 提供了一个名为 ml 的模块,其中包含了许多打包的机器学习算法,包括贝叶斯分类器k 近邻KNN)、支持向量机SVM)、决策树神经网络等。

它还包含一个名为快速近似最近邻搜索库FLANN)的模块,该模块包含在大数据集中进行快速最近邻搜索的算法。

计算摄影学

计算摄影学指的是使用高级图像处理技术来改善相机捕捉的图像。计算摄影学不是关注光学过程和图像捕捉方法,而是使用软件来操纵视觉数据。应用包括高动态范围成像、全景图像、图像重光照和光场相机。

让我们看看以下图像:

看看那些鲜艳的颜色!这是一个高动态范围图像的例子,使用传统的图像捕捉技术是无法得到这样的效果的。为了做到这一点,我们必须在多个曝光下捕捉相同的场景,将这些图像相互注册,然后很好地融合它们以创建这张图像。

photoxphoto 模块包含各种算法,提供了与计算摄影相关的算法。还有一个名为 stitching 的模块,它提供了创建全景图像的算法。

展示的图像可以在以下链接找到:pixabay.com/en/hdr-high-dynamic-range-landscape-806260/

形状分析

形状的概念在计算机视觉中至关重要。我们通过识别图像中的各种不同形状来分析视觉数据。这实际上是许多算法中的重要步骤。

假设你正在尝试识别图像中的特定标志。你知道它可以以各种形状、方向和大小出现。一个很好的开始方法是量化物体的形状特征。

shape 模块提供了提取不同形状、测量它们之间的相似性、转换物体形状以及更多所需的所有算法。

光流算法

光流算法在视频中用于跟踪连续帧之间的特征。假设你想要在视频中跟踪一个特定的对象。在每一帧上运行特征提取器将是计算上昂贵的;因此,这个过程会变慢。所以,你只需从当前帧中提取特征,然后在后续帧中跟踪这些特征。

光流算法在计算机视觉的视频应用中被广泛使用。optflow 模块包含了执行光流所需的所有算法。还有一个名为 tracking 的模块,其中包含更多可用于跟踪特征的算法。

面部和物体识别

面部识别是指识别给定图像中的人。这不同于面部检测,面部检测只是简单地识别给定图像中面部位置。

如果你想要构建一个实用的生物识别系统,能够识别摄像头前的人,你首先需要运行一个面部检测器来识别面部位置,然后运行一个单独的面部识别器来识别这个人是谁。有一个名为 face 的 OpenCV 模块处理面部识别。

正如我们之前讨论的,计算机视觉试图根据人类感知视觉数据的方式来建模算法。因此,找到图像中突出的区域和对象对于不同的应用,如物体识别、物体检测和跟踪等,将非常有帮助。有一个名为 saliency 的模块就是为了这个目的而设计的。它提供了可以检测静态图像和视频中突出区域的算法。

表面匹配

我们越来越多地与能够捕捉周围物体 3D 结构的设备进行交互。这些设备本质上捕获深度信息,以及常规的 2D 彩色图像。因此,对我们来说,构建能够理解和处理 3D 对象的算法非常重要。

Kinect 是一个捕获深度信息和视觉数据的设备的良好例子。当前的任务是通过将其与数据库中的某个模型进行匹配来识别输入的 3D 对象。如果我们有一个可以识别和定位对象的系统,那么它可以用于许多不同的应用。

有一个名为 surface_matching 的模块,其中包含用于 3D 物体识别和基于 3D 特征的姿态估计算法的算法。

文本检测和识别

在给定场景中识别文本并识别其内容变得越来越重要。应用包括车牌识别、识别自动驾驶汽车的道路标志、书籍扫描以数字化内容等。

有一个名为 text 的模块,其中包含处理文本检测和识别的各种算法。

深度学习

深度学习对计算机视觉和图像识别产生了重大影响,其准确度高于其他机器学习和人工智能算法。深度学习不是一个新概念;它大约在 1986 年引入到社区中,但它在 2012 年左右引发了一场革命,当时新的 GPU 硬件被优化用于并行计算,卷积神经网络CNN)的实现以及其他技术使得在合理的时间内训练复杂的神经网络架构成为可能。

深度学习可以应用于多个用例,如图像识别、目标检测、语音识别和自然语言处理。自 3.4 版本以来,OpenCV 一直在实现深度学习算法——在最新版本中,添加了多个重要框架的导入器,如TensorFlowCaffe

安装 OpenCV

让我们看看如何在各种操作系统上启动和运行 OpenCV。

Windows

为了简化操作,让我们使用预构建库来安装 OpenCV。访问opencv.org并下载适用于 Windows 的最新版本。当前版本是 4.0.0,您可以从 OpenCV 主页获取下载链接。在继续之前,请确保您有管理员权限。

下载的文件将是一个可执行文件,所以只需双击它即可开始安装。安装程序会将内容展开到一个文件夹中。您可以选择安装路径,并通过检查文件来验证安装。

完成上一步骤后,我们需要设置 OpenCV 环境变量并将它们添加到系统路径中,以完成安装。我们将设置一个环境变量,它将保存 OpenCV 库的构建目录。我们将在我们的项目中使用这个变量。

打开终端并输入以下命令:

C:> setx -m OPENCV_DIR D:OpenCVBuildx64vc14

我们假设您拥有一台安装了 Visual Studio 2015 的 64 位机器。如果您使用的是 Visual Studio 2012,请在命令中将vc14替换为vc11。指定的路径是我们存放 OpenCV 二进制文件的地方,您应该在该路径下看到两个文件夹,分别命名为libbin。如果您使用的是 Visual Studio 2018,您应该从头开始编译 OpenCV。

让我们继续将bin文件夹的路径添加到我们的系统路径中。我们需要这样做的原因是我们将以动态链接库DLL)的形式使用 OpenCV 库。本质上,所有 OpenCV 算法都存储在这里,我们的操作系统将在运行时加载它们。

为了做到这一点,我们的操作系统需要知道它们的位置。PATH系统变量包含了一个它可以在其中找到 DLL 的所有文件夹的列表。因此,自然地,我们需要将 OpenCV 库的路径添加到这个列表中。

为什么我们需要做所有这些?好吧,另一个选择是将所需的 DLL 文件复制到与应用程序的可执行文件(.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.12/cmake-3.12.0-rc1-Darwin-x86_64.dmg。它是一个 .dmg 文件,因此一旦下载,只需运行安装程序即可。

opencv.org 下载 OpenCV 的最新版本。当前版本是 4.0.0,您可以从这里下载:github.com/opencv/opencv/archive/4.0.0.zip。将内容解压到您选择的文件夹中。

OpenCV 4.0.0 还有一个名为 opencv_contrib 的新软件包,其中包含尚未被认为是稳定的用户贡献和一些在所有最新的计算机视觉算法中不免费提供给商业使用的算法,这一点值得记住。安装此软件包是可选的——如果您不安装 opencv_contrib,OpenCV 仍然可以正常工作。

由于我们无论如何都要安装 OpenCV,因此安装此软件包以便您可以稍后进行实验(而不是再次经历整个安装过程)是个不错的选择。这是一种学习和探索新算法的好方法。您可以从以下链接下载它:github.com/opencv/opencv_contrib/archive/4.0.0.zip

将 zip 文件的内容解压到您选择的文件夹中。为了方便,将其解压到之前的文件夹中,这样 opencv-4.0.0opencv_contrib-4.0.0 文件夹就在同一个主文件夹中。

现在,我们已经准备好构建 OpenCV。打开您的终端,导航到您解压 OpenCV 4.0.0 内容的文件夹。在命令中替换正确的路径后,运行以下命令:

$ cd /full/path/to/opencv-4.0.0/ 
$ mkdir build 
$ cd build 
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/full/path/to/opencv-4.0.0/build -D INSTALL_C_EXAMPLES=ON -D BUILD_EXAMPLES=ON -D OPENCV_EXTRA_MODULES_PATH=/full/path/to/opencv_contrib-4.0.0/modules ../ 

是时候安装 OpenCV 4.0.0 了。前往 /full/path/to/opencv-4.0.0/build 目录,并在您的终端上运行以下命令:

$ make -j4 
$ make install 

在前面的命令中,-j4标志表示应该使用四个核心来安装它。这样更快!现在,让我们设置库路径。在终端中使用vi ~/.profile命令打开你的~/.profile文件,并添加以下行:

export DYLD_LIBRARY_PATH=/full/path/to/opencv-4.0.0/build/lib:$DYLD_LIBRARY_PATH

我们需要将opencv.pc中的pkgconfig文件复制到/usr/local/lib/pkgconfig,并命名为opencv4.pc。这样,如果你已经有一个现有的 OpenCV 3.x.x 安装,将不会有冲突。让我们继续做:

$ cp /full/path/to/opencv-4.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv4.pc 

我们还需要更新我们的PKG_CONFIG_PATH变量。打开你的~/.profile文件并添加以下行:

export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/:$PKG_CONFIG_PATH 

使用以下命令重新加载你的~/.profile文件:

$ source ~/.profile 

我们完成了!让我们看看它是否工作:

$ cd /full/path/to/opencv-4.0.0/samples/cpp 
$ g++ -ggdb `pkg-config --cflags --libs opencv4` opencv_version.cpp -o /tmp/opencv_version && /tmp/opencv_version

如果你看到终端上打印了“Welcome to OpenCV 4.0.0”,那么你可以正常使用了。在这本书中,我们将使用 CMake 来构建我们的 OpenCV 项目。我们将在第二章《OpenCV 基础知识简介》中更详细地介绍它。

Linux

让我们看看如何在 Ubuntu 上安装 OpenCV。在开始之前,我们需要安装一些依赖项。让我们通过在终端中运行以下命令来使用包管理器安装它们:

$ sudo apt-get -y install libopencv-dev build-essential cmake libdc1394-22 libdc1394-22-dev libjpeg-dev libpng12-dev libtiff5-dev libjasper-dev libavcodec-dev libavformat-dev libswscale-dev libxine2-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/opencv/opencv/archive/4.0.0.tar.gz" -O opencv.tar.gz 
$ wget "https://github.com/opencv/opencv_contrib/archive/4.0.0.tar.gz" -O opencv_contrib.tar.gz 
$ tar -zxvf opencv.tar.gz 
$ tar -zxvf opencv_contrib.tar.gz 
$ cd opencv-4.0.0 
$ mkdir build 
$ cd build 
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/full/path/to/opencv-4.0.0/build -D INSTALL_C_EXAMPLES=ON -D BUILD_EXAMPLES=ON -D OPENCV_EXTRA_MODULES_PATH=/full/path/to/opencv_contrib-4.0.0/modules ../ 
$ make -j4 
$ sudo make install 

让我们复制opencv.pc中的pkgconfig文件到/usr/local/lib/pkgconfig,并命名为opencv4.pc

$ cp /full/path/to/opencv-4.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv4.pc 

我们完成了!现在我们将能够使用它从命令行编译我们的 OpenCV 程序。另外,如果你已经有一个现有的 OpenCV 3.x.x 安装,将不会有冲突。

让我们检查安装是否正常工作:

$ cd /full/path/to/opencv-4.0.0/samples/cpp 
$ g++ -ggdb `pkg-config --cflags --libs opencv4` opencv_version.cpp -o /tmp/opencv_version && /tmp/opencv_version 

如果你看到终端上打印了“Welcome to OpenCV 4.0.0”,那么你应该可以正常使用了。在接下来的章节中,我们将学习如何使用 CMake 来构建我们的 OpenCV 项目。

摘要

在本章中,我们讨论了人类视觉系统以及人类如何处理视觉数据。我们解释了为什么机器做同样的事情很困难,以及设计计算机视觉库时需要考虑什么。

我们学习了可以使用 OpenCV 做什么,以及可以用来完成这些任务的各个模块。最后,我们学习了如何在不同的操作系统上安装 OpenCV。

在下一章中,我们将讨论如何操作图像以及我们如何可以使用各种函数来操纵它们。我们还将学习为我们的 OpenCV 应用程序构建项目结构。

第二章:OpenCV 基础知识简介

在第一章“OpenCV 入门”中介绍了在不同操作系统上安装 OpenCV 之后,我们将在本章介绍 OpenCV 开发的基础。它从展示如何使用 CMake 创建我们的项目开始。我们将介绍基本图像数据结构和矩阵,以及其他在我们项目中工作所需的结构。我们将介绍如何使用 XML/YAML 持久化的 OpenCV 函数将我们的变量和数据保存到文件中。

在本章中,我们将涵盖以下主题:

  • 使用 CMake 配置项目

  • 从/到磁盘读取/写入图像

  • 读取视频和访问相机设备

  • 主要图像结构(例如,矩阵)

  • 其他重要且基本的结构(例如,向量和标量)

  • 基本矩阵运算简介

  • 使用 XML/YAML 持久化的 OpenCV API 进行文件存储操作

技术要求

本章要求熟悉基本的 C++编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_02。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频以查看代码的实际应用:

bit.ly/2QxhNBa

基本 CMake 配置文件

为了配置和检查我们项目所需的所有依赖项,我们将使用 CMake,但这不是唯一的方法;我们可以在任何其他工具或 IDE 中配置我们的项目,例如MakefilesVisual Studio,但 CMake 是配置多平台C++项目的更便携方式。

CMake 使用名为CMakeLists.txt的配置文件,其中定义了编译和依赖关系过程。对于一个基于单个源代码文件构建的可执行文件的基本项目,所需的CMakeLists.txt文件仅包含三行。文件看起来如下:

cmake_minimum_required (VERSION 3.0) 
project (CMakeTest) 
add_executable(${PROJECT_NAME} main.cpp) 

第一行定义了所需的 CMake 最低版本。这一行在我们的CMakeLists.txt文件中是强制性的,它允许我们使用从特定版本定义的 CMake 功能;在我们的情况下,我们需要至少 CMake 3.0。第二行定义了项目名称。这个名称保存在一个名为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.cpphello.h 是源文件。我们还添加了头文件,以便允许像 Visual Studio 这样的 IDE 链接到头文件。这一行将根据我们是否在库名称和源文件之间添加 SHAREDSTATIC 单词来生成共享(Mac OS X 的 .so、Unix 或 Windows 的 .dll)或静态库(Mac OS X 的 .a、Unix 或 Windows 的 .lib)文件。target_link_libraries(executable Hello) 是将我们的可执行文件链接到所需库的函数,在我们的例子中,是 Hello 库。

管理依赖项

CMake 有能力搜索我们的依赖项和外部库,这使我们能够构建复杂的项目,根据项目中的外部组件添加一些需求。

在这本书中,最重要的依赖项当然是 OpenCV,我们将将其添加到所有我们的项目中:

    cmake_minimum_required (VERSION 3.0) 
    PROJECT(Chapter2) 
# Requires OpenCV 
    FIND_PACKAGE( OpenCV 4.0.0 REQUIRED ) 
# Show a message with the opencv version detected 
    MESSAGE("OpenCV version : ${OpenCV_VERSION}") 
# Add the paths to the include directories/to the header files
    include_directories(${OpenCV_INCLUDE_DIRS}) 
# Add the paths to the compiled libraries/objects
    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 3.0) 
cmake_policy(SET CMP0012 NEW) 
PROJECT(Chapter2) 

第一行定义了最低的 CMake 版本,第二行告诉 CMake 使用 CMake 的新行为来简化对正确数字和布尔常量的识别,而无需解引用具有此类名称的变量;此策略是在 CMake 2.8.0 中引入的,从版本 3.0.2 开始,CMake 会警告当策略未设置时。最后,最后一行定义了项目标题。在定义项目名称后,我们必须定义需求、库和依赖项:

# Requires OpenCV 
    FIND_PACKAGE( OpenCV 4.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 是一个函数,允许我们查找依赖项、所需的最小版本以及此依赖项是必需的还是可选的。在这个示例脚本中,我们寻找版本 4.0.0 或更高版本的 OpenCV,并声明这是一个必需的包。

FIND_PACKAGE命令包括所有 OpenCV 子模块,但你可以通过执行应用程序来指定你想要在项目中包含的子模块,使应用程序更小、更快。例如,如果我们只将使用基本的 OpenCV 类型和核心功能,我们可以使用以下命令:FIND_PACKAGE(OpenCV 4.0.0 REQUIRED core)

如果 CMake 找不到它,它会返回一个错误,但不会阻止我们编译我们的应用程序。MESSAGE函数在终端或 CMake GUI 中显示消息。在我们的例子中,我们如下显示 OpenCV 版本:

OpenCV version : 4.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;这个函数创建一个新变量并向其中添加任何我们需要的值。在我们的例子中,我们将main.cpp的值合并到SRC变量中。我们可以向同一个变量添加更多值,如下面的脚本所示:

SET(SRC main.cpp 
        utils.cpp 
        color.cpp
)

使脚本更加复杂

在本节中,我们展示了一个更复杂的脚本,该脚本包括子文件夹、库和可执行文件;总共只有两个文件和几行,如脚本所示。创建多个CMakeLists.txt文件不是强制性的,因为我们可以在主CMakeLists.txt文件中指定所有内容。然而,更常见的是为每个项目子文件夹使用不同的CMakeLists.txt文件,从而使它更加灵活和便携。

此示例有一个代码结构文件夹,其中包含一个用于utils库的文件夹和根文件夹,其中包含主可执行文件:

CMakeLists.txt 
main.cpp 
utils/ 
   CMakeLists.txt 
   computeTime.cpp 
   computeTime.h 
   logger.cpp 
   logger.h 
   plotting.cpp 
   plotting.h 

然后,我们必须定义两个CMakeLists.txt文件,一个在根文件夹中,另一个在utils文件夹中。根文件夹的CMakeLists.txt文件包含以下内容:

    cmake_minimum_required (VERSION 3.0) 
    project (Chapter2) 

# Opencv Package required 
    FIND_PACKAGE( OpenCV 4.0.0 REQUIRED ) 

#Add opencv header files to project 
    include_directories(${OpenCV_INCLUDE_DIR}) 
    link_directories(${OpenCV_LIB_DIR}) 

# Add a subdirectory to the build. 
    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 宏可以通过对add_definitions函数的调用(-DLOG)在CMakeLists.txt中定义,该函数本身可以通过 CMake 变量WITH_LOG通过简单条件来运行或隐藏:

if(WITH_LOG) 
   add_definitions(-DLOG) 
endif(WITH_LOG) 

现在我们已经准备好创建我们的 CMake 脚本文件,以便在任何操作系统上编译我们的计算机视觉项目。然后,我们将继续介绍 OpenCV 的基础知识,然后再开始一个示例项目。

图像和矩阵

在计算机视觉中,无疑最重要的是图像结构。计算机视觉中的图像是使用数字设备捕获的物理世界的表示。这幅图只是一个存储在矩阵格式中的数字序列(参见图示)。每个数字是考虑的波长的光强度测量(例如,彩色图像中的红色、绿色或蓝色)或波长范围(对于全色设备)。图像中的每个点称为一个像素(对于图像元素),每个像素可以存储一个或多个值,具体取决于它是否是只存储一个值(如01)的黑白图像(也称为二值图像),灰度级图像存储两个值,或彩色图像存储三个值。这些值通常在整数中介于0255之间,但您可以使用其他范围,例如在浮点数中使用01,如在高动态范围成像HDRI)或热图像中:

图片

图像以矩阵格式存储,其中每个像素都有一个位置,可以通过列和行的编号来引用。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",CV_LOAD_IMAGE_GRAYSCALE); 

  if(! color.data ) // Check for invalid input
 {
 cout << "Could not open or find the image" << std::endl ;
 return -1;
 }
   // 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; 

首先,我们必须包含我们样本中需要的函数的声明。这些函数来自core(基本图像数据处理)和highgui(OpenCV 提供的跨平台 I/O 函数是corehighui;第一个包含基本类,如矩阵,而第二个包含读取、写入和通过图形界面显示图像的函数)。现在是我们读取图像的时候了:

// Read images 
Mat color= imread("../lena.jpg"); 
Mat gray= imread("../lena.jpg",CV_LOAD_IMAGE_GRAYSCALE); 

imread是读取图像的主要函数。此函数打开图像并将其存储为矩阵格式。imread接受两个参数。第一个参数是包含图像路径的字符串,而第二个参数是可选的,默认情况下,将图像加载为彩色图像。第二个参数允许以下选项:

  • cv::IMREAD_UNCHANGED: 如果设置,当输入具有相应的深度时,返回 16 位/32 位图像,否则将其转换为 8 位

  • cv::IMREAD_COLOR: 如果设置,始终将图像转换为彩色图像(BGR,8 位无符号)

  • cv::IMREAD_GRAYSCALE: 如果设置,始终将图像转换为灰度图像(8 位无符号)

要保存图像,我们可以使用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; 

要访问图像中的一个像素,我们使用Mat OpenCV 类的模板函数cv::Mat::at<typename t>(row,col)。模板参数是期望的返回类型。在 8 位彩色图像中,类型名是一个存储三个无符号字符数据的Vec3b类(Vec = vector,3 = component 数量,b = 1 字节)。在灰度图像的情况下,我们可以直接使用无符号字符,或者使用图像中使用的任何其他数字格式,例如uchar pixel= color.at<uchar>(myRow, myCol)。最后,为了显示图像,我们可以使用imshow函数,该函数以标题作为第一个参数,以图像矩阵作为第二个参数创建一个窗口:

// show images 
imshow("Lena BGR", color); 
imshow("Lena Gray", gray); 
// wait for any key press 
waitKey(0); 

如果我们想要停止应用程序等待,我们可以使用 OpenCV 函数waitKey,该函数有一个参数,表示我们希望等待多少毫秒来等待按键。如果我们将参数设置为0,那么函数将一直等待直到按键被按下。

上述代码的结果在以下图像中展示。左侧图像是彩色图像,右侧图像是灰度图像:

图片

最后,作为以下示例的示例,我们将创建CMakeLists.txt文件,并查看如何使用该文件编译代码。

以下代码描述了CMakeLists.txt文件:

cmake_minimum_required (VERSION 3.0) 
cmake_policy(SET CMP0012 NEW) 
PROJECT(project) 

# Requires OpenCV 
FIND_PACKAGE( OpenCV 4.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文件编译我们的代码,我们必须执行以下步骤:

  1. 创建一个build文件夹。

  2. build文件夹中,在 Windows 上执行 CMake 或打开 CMake GUI 应用程序,选择sourcebuild文件夹,然后按下“Configure”和“Generate”按钮。

  3. 如果你使用 Linux 或 macOS,像往常一样生成 Makefile,然后使用make命令编译项目。如果你使用 Windows,使用步骤 2 中选择的编辑器打开项目,然后编译。

最后,在编译我们的应用程序后,我们将在build文件夹中有一个名为app的可执行文件,我们可以执行它。

读取视频和摄像头

本节通过这个简单的示例向您介绍视频和摄像头读取。在解释如何读取视频或摄像头输入之前,我们想要介绍一个新且非常有用的类,该类帮助我们管理输入命令行参数。这个新类是在 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}" 
}; 

对于CommandLineParser,我们首先要做的是在常量char向量中定义我们需要的或允许的参数;每一行都有以下模式:

"{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;  

视频读取和摄像头读取的类是相同的:使用属于videoio子模块的VideoCapture类,而不是像 OpenCV 前一个版本中那样使用highgui子模块。创建对象后,我们检查输入的命令行参数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 的速度工作,而我们的算法消耗了 10 毫秒,一个很大的等待值是30 = (1000/20) - 10毫秒。这个值是在考虑了足够的时间等待以确保下一帧在缓冲区中的情况下计算的。如果我们的摄像头需要 40 毫秒来捕捉每一张图像,而我们算法中使用了 10 毫秒,那么我们只需要使用waitKey停止 30 毫秒,因为 30 毫秒的等待时间加上我们算法中的 10 毫秒,与摄像头每一帧可访问的时间相同。

当用户想要结束应用程序时,他们只需要按任何键,然后我们必须使用release函数释放所有视频资源。

在计算机视觉应用程序中释放我们使用的所有资源非常重要。如果不这样做,我们可能会消耗所有 RAM 内存。我们可以使用release函数释放矩阵。

上一段代码的结果是一个新窗口,显示 BGR 格式的视频或网络摄像头。

其他基本对象类型

我们已经了解了 MatVec3b 类,但还有许多其他类我们必须学习。

在本节中,我们将学习在大多数项目中所需的最基本的对象类型:

  • Vec

  • Scalar

  • Point

  • 大小

  • 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 对象类型

Scalar 对象类型是从 Vec 派生出的模板类,具有四个元素。Scalar 类型在 OpenCV 中被广泛用于传递和读取像素值。

要访问 VecScalar 值,我们使用 [] 操作符,它可以从另一个标量、向量或值初始化,如下面的示例所示:

Scalar s0(0);
Scalar s1(0.0, 1.0, 2.0, 3.0);
Scalar s2(s1);

点对象类型

另一个非常常见的类模板是 Point。这个类定义了一个由坐标 xy 指定的 2D 点。

Point 类似,还有一个 Point3 模板类用于 3D 点支持。

Vec 类类似,OpenCV 为我们的方便定义了以下 Point 别名:

typedef Point_<int> Point2i; 
typedef Point2i Point; 
typedef Point_<float> Point2f; 
typedef Point_<double> Point2d; 
 The following operators are defined for points:
    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 中广泛使用的模板类是用于指定图像或矩形大小的模板类——Size。这个类添加了两个成员,宽度和高度,以及有用的 area() 函数。在下面的示例中,我们可以看到使用大小的多种方法:

Size s(100,100);
Mat img=Mat::zeros(s, CV_8UC1); // 100 by 100 single channel matrix
s.width= 200;
int area= s.area(); returns 100x200

Rect 对象类型

Rect 是另一个重要的模板类,用于定义由以下参数指定的 2D 矩形:

  • 上角坐标

  • 矩形的宽度和高度

Rect 模板类可以用来定义图像的 感兴趣区域ROI),如下所示:

Mat img=imread("lena.jpg");
Rect rect_roi(0,0,100,100);
Mat img_roi=img(r);

RotatedRect 对象类型

最后一个有用的类是一个特定的矩形,称为 RotatedRect。这个类表示一个由中心点、矩形的宽度和高度以及旋转角度(以度为单位)指定的旋转矩形:

RotatedRect(const Point2f& center, const Size2f& size, float angle); 

这个类的一个有趣的功能是 boundingBox。这个函数返回一个包含旋转矩形的 Rect

图片

基本矩阵运算

在本节中,我们将学习许多基本且重要的矩阵运算,这些运算可以应用于图像或任何矩阵数据。我们学习了如何加载图像并将其存储在 Mat 变量中,但我们也可以手动创建 Mat。最常见的构造函数是给矩阵指定大小和类型,如下所示:

Mat a= Mat(Size(5,5), CV_32F); 

您可以使用此构造函数创建一个新矩阵,该矩阵通过第三方库的存储缓冲区链接,而不需要复制数据:Mat(size, type, pointer_to_buffer)

支持的类型取决于您想要存储的数字类型和通道数。最常见类型如下:

CV_8UC1 
CV_8UC3 
CV_8UC4 
CV_32FC1 
CV_32FC3 
CV_32FC4

您可以使用 CV_number_typeC(n) 创建任何类型的矩阵,其中 number_type 是 8 位无符号 (8U) 到 64 位浮点 (64F),并且 (n) 是通道数;允许的通道数范围从 1CV_CN_MAX

初始化不会设置数据值,因此您可能会得到不希望的结果。为了避免不希望的结果,您可以使用各自的函数用 01 值初始化矩阵:

Mat mz= Mat::zeros(5,5, CV_32F); 
Mat mo= Mat::ones(5,5, CV_32F); 

前面矩阵的结果如下:

图片

特殊的矩阵初始化是 eye 函数,它使用指定的类型和大小创建一个单位矩阵:

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.*2n" << m1*2 << endl; 
// matrix per element multiplication 
cout << "n(m1+2).*(m1+3)n" << (m1+1).mul(m1+3) << endl; 
// Matrix multiplication 
cout << "nm1*m2n" << m1*m2 << endl; 

前面操作的结果如下:

图片

其他常见的数学矩阵操作是 转置矩阵求逆,分别由 t()inv() 函数定义。OpenCV 提供的其他有趣的功能包括矩阵中的数组操作,例如计算非零元素的数量。这对于计算对象的像素或面积很有用:

int countNonZero(src); 

OpenCV 提供了一些统计函数。可以使用 meanStdDev 函数计算按通道的均值和标准差:

meanStdDev(src, mean, stddev); 

另一个有用的统计函数是 minMaxLoc。此函数找到矩阵或数组的最小值和最大值,并返回位置和值:

minMaxLoc(src, minVal, maxVal, minLoc, maxLoc); 

在这里 src 是输入矩阵,minValmaxVal 是检测到的双精度值,而 minLocmaxLoc 是检测到的 Point 值。

其他核心和有用的函数在以下位置有详细描述:docs.opencv.org/modules/core/doc/core.html

基本数据持久化和存储

在完成本章之前,我们将探索 OpenCV 中用于存储和读取数据的函数。在许多应用中,例如校准或机器学习,当我们完成一系列计算后,我们需要将这些结果保存起来,以便在后续操作中检索。为此,OpenCV 提供了一个 XML/YAML 持久化层。

写入到 FileStorage

要使用一些 OpenCV 或其他数值数据写入文件,我们可以使用 FileStorage 类,使用类似于 STL 流的流式 << 运算符:

#include "opencv2/opencv.hpp" 
using namespace cv; 

int main(int, char** argv) 
{ 
   // create our writer 
    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)的路径文件名,并将第二个参数设置为写入:

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\. ] 

从文件存储中读取以读取先前保存的文件与save函数非常相似:

#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 提供的图形用户界面的基础知识。我们将创建按钮和滑块,并介绍一些图像处理的基本知识。

第三章:学习图形用户界面

在第二章,《OpenCV 基础知识简介》中,我们学习了 OpenCV 的基本类和结构以及最重要的类,称为Mat。我们学习了如何读取和保存图像和视频以及图像在内存中的内部结构。我们现在准备好使用 OpenCV,但在大多数情况下,我们需要通过多个用户界面来展示我们的图像结果和获取用户与图像的交互。OpenCV 为我们提供了一些基本的用户界面,以方便我们创建应用程序和原型。为了更好地理解用户界面的工作原理,我们将在本章末尾创建一个小应用程序,称为PhotoTool。在这个应用程序中,我们将学习如何使用过滤器进行颜色转换。

本章介绍了以下主题:

  • OpenCV 基本用户界面

  • OpenCV Qt 界面

  • 滑块和按钮

  • 高级用户界面 – OpenGL

  • 颜色转换

  • 基本过滤器

技术要求

本章要求熟悉基本的 C++编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_03。代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。

查看以下视频以查看代码的实际效果:

bit.ly/2KH2QXD

介绍 OpenCV 用户界面

OpenCV 拥有自己跨操作系统的用户界面,允许开发者创建自己的应用程序,无需学习复杂的用户界面库。OpenCV 的用户界面是基本的,但它为计算机视觉开发者提供了创建和管理软件开发的必要基本功能。所有这些功能都是本地的,并且针对实时使用进行了优化。

OpenCV 提供了两种用户界面选项:

  • 基于本地用户界面、Mac OS X 的 cocoa 或 carbon 以及 Linux 或 Windows 用户界面的 GTK 的基本界面,默认情况下在编译 OpenCV 时选择。

  • 基于 Qt 库的稍微高级一些的界面,这是一个跨平台界面。在编译 OpenCV 之前,您必须手动在 CMake 中启用 Qt 选项。

在下面的屏幕截图中,您可以在左侧看到基本用户界面窗口,在右侧看到 Qt 用户界面:

使用 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; 

int main(int argc, const char** argv) 
{ 
   // Read images 
   Mat lena= imread("../lena.jpg"); 
   # Checking if Lena image has been loaded
   if (!lena.data) {
 cout << "Lena image missing!" << enld;
 return -1;
   }
   Mat photo= imread("../photo.jpg"); 
   # Checking if Lena image has been loaded
   if (!photo.data) {
 cout << "Lena image missing!" << enld;
 return -1;
 }

   // Create windows 
   namedWindow("Lena", WINDOW_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; 
} 

让我们理解一下代码:

  1. 为了便于图形用户界面,我们必须做的第一件事是导入 OpenCV 的highui模块:
#include <opencv2/highgui.hpp> 
  1. 现在我们准备创建新的窗口,我们必须加载一些图像:
// Read images 
Mat lena= imread("../lena.jpg"); 
Mat photo= imread("../photo.jpg"); 
  1. 为了创建窗口,我们使用namedWindow函数。此函数有两个参数;第一个是一个常量字符串,包含窗口的名称,第二个是我们需要的标志。第二个参数是可选的:
namedWindow("Lena", WINDOW_NORMAL); 
namedWindow("Photo", WINDOW_AUTOSIZE);
  1. 在我们的情况下,我们创建了两个窗口:第一个称为Lena,第二个称为Photo

Qt 和原生默认有三个标志:

    • WINDOW_NORMAL: 此标志允许用户调整窗口大小

    • WINDOW_AUTOSIZE: 如果设置此标志,则窗口大小将自动调整以适应显示图像,并且无法调整窗口大小

    • WINDOW_OPENGL: 此标志启用 OpenGL 支持

Qt 有许多额外的标志:

    • WINDOW_FREERATIOWINDOW_KEEPRATIO: 如果设置WINDOW_FREERATIO,则图像调整时不考虑其比例。如果设置WINDOW_FREERATIO,则图像调整时考虑其比例。

    • WINDOW_GUI_NORMALWINDOW_GUI_EXPANDED: 第一个标志提供了一个没有状态栏和工具栏的基本界面。第二个标志提供了一个带有状态栏和工具栏的最先进图形用户界面。

如果我们使用 Qt 编译 OpenCV,我们创建的所有窗口默认情况下都在扩展界面中,但我们可以使用原生界面和更基本的界面,通过添加CV_GUI_NORMAL标志。默认情况下,标志是WINDOW_AUTOSIZEWINDOW_KEEPRATIOWINDOW_GUI_EXPANDED

  1. 当我们创建多个窗口时,它们是叠加的,但我们可以使用moveWindow函数将窗口移动到桌面上的任何区域,如下所示:
// Move window 
moveWindow("Lena", 10, 10); 
moveWindow("Photo", 520, 10); 
  1. 在我们的代码中,我们将Lena窗口向左移动10像素,向上移动10像素,并将Photo窗口向左移动520像素,向上移动10像素:
// show images 
imshow("Lena", lena); 
imshow("Photo", photo);  
// Resize window, only non autosize 
resizeWindow("Lena", 512, 512);
  1. 在使用imshow函数显示我们之前加载的图像之后,我们将Lena窗口的大小调整为512像素,调用resizeWindow函数。此函数有三个参数:窗口名称宽度高度

具体的窗口大小是针对图像区域的。工具栏不计入。只有没有启用WINDOW_AUTOSIZE标志的窗口才能调整大小。

  1. 在使用waitKey函数等待按键后,我们将使用destroyWindow函数删除或删除我们的窗口,其中窗口名称是唯一需要的参数:
waitKey(0); 

// Destroy the windows 
destroyWindow("Lena"); 
destroyWindow("Photo"); 
  1. OpenCV 有一个函数可以在一个调用中删除我们创建的所有窗口。该函数名为destroyAllWindows。为了演示其工作原理,我们在我们的示例中创建了 10 个窗口并等待按键。当用户按下任何键时,它将销毁所有窗口:
 // 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(); 

在任何事件中,当应用程序终止时,OpenCV 会自动处理所有窗口的销毁,因此我们不需要在应用程序结束时调用此函数。

所有这些代码的结果可以在以下两个步骤中看到的图像中看到。首先,它显示了两个窗口:

在按下任何键后,应用程序继续并绘制几个窗口,改变它们的位置:

通过几行代码,我们能够创建和操作窗口以及显示图像。我们现在准备好促进用户与图像的交互并添加用户界面控件。

将滑块和鼠标事件添加到我们的界面中

鼠标事件和滑块控制对计算机视觉和 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 trackbar 
   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;

现在,我们定义我们的滑块和鼠标事件回调,这是setMouseCallbackcreateTrackbarOpenCV 函数所需的:

// 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的函数,可以生成具有以下参数的滑块:

  1. 滑块名称。

  2. 窗口名称。

  3. 整数指针用作值;此参数是可选的。如果设置了,则滑块在创建时达到此位置。

  4. 滑块上的最大位置。

  5. 滑块位置改变时的回调函数。

  6. 发送到回调的用户数据。它可以用来在不使用全局变量的情况下将数据发送到回调。

在此代码中,我们为Lena窗口添加trackbar,并调用Lenatrackbar以模糊图像。滑块的值存储在作为指针传递的blurAmount整数中,并将滑块的最大值设置为30。我们将onChange设置为回调函数,并发送lena mat图像作为用户数据:

   // create a trackbar 
   createTrackbar("Lena", "Lena", &blurAmount, 30, onChange, &lena);

创建滑块后,我们添加鼠标事件,当用户在鼠标上点击左键时绘制圆圈。OpenCV 有setMouseCallback函数。此函数有三个参数:

  • 获取鼠标事件的窗口名称。

  • 当有任何鼠标交互时调用的回调函数。

  • 用户数据:这是在触发回调函数时将被发送的任何数据。在我们的例子中,我们将发送整个Lena图像。

使用以下代码,我们可以为Lena窗口添加鼠标回调,并将onMouse设置为回调函数,传递lena mat图像作为用户数据:

setMouseCallback("Lena", onMouse, &lena); 

为了仅最终化主函数,我们需要使用与滑块相同的参数初始化图像。为了执行初始化,我们只需要调用onChange回调函数,并在使用destroyWindow关闭窗口之前等待事件,如下所示:

// 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的空矩阵来存储模糊结果。为了在回调函数中检索通过用户数据发送的图像,我们必须将void* userData转换为正确的图像类型指针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的事件都将被丢弃。在丢弃其他事件后,我们获得像滑动条回调函数那样的输入图像,并在图像中使用circle 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 用户界面为我们提供了更多控制和选项来处理我们的图像。

界面分为以下三个主要区域:

  • 工具栏

  • 图像区域

  • 状态栏

我们可以在以下图片中看到这三个区域。图像顶部是工具栏,图像是主要区域,状态栏位于图像底部:

从左到右,工具栏有以下按钮:

  • 四个用于平移的按钮

  • 放大 x1

  • 放大 x30,显示标签

  • 放大

  • 缩小

  • 保存当前图像

  • 显示属性

这些选项在以下图像中可以清楚地看到:

当我们在图像上按下鼠标右键时,图像区域显示一个图像和一个上下文菜单。此区域可以使用displayOverlay函数在区域的顶部显示覆盖消息。此函数接受三个参数:窗口名称、我们想要显示的文本以及覆盖文本显示的毫秒数。如果此时间设置为0,则文本永远不会消失:

// Display Overlay 
displayOverlay("Lena", "Overlay 5secs", 5000);

我们可以在以下图像中看到前面代码的结果。您可以在图像顶部看到一个带有句子“Overlay 5secs”的小黑框:

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

我们可以使用状态栏显示类似于覆盖的消息。可以更改状态栏消息的函数是displayStatusBar。此函数具有与覆盖函数相同的参数:窗口名称、要显示的文本以及显示它的持续时间:

向用户界面添加按钮

在前面的章节中,我们学习了如何创建普通或 Qt 界面,并使用鼠标和滑动条与之交互,但我们可以创建不同类型的按钮。

按钮仅在 Qt 窗口中受支持。

OpenCV Qt 支持的按钮类型如下:

  • 按钮推压

  • 复选框

  • 单选框

按钮仅出现在控制面板中。控制面板是每个程序的一个独立窗口,我们可以在这里附加按钮和滑块。要显示控制面板,我们可以点击最后一个工具栏按钮,在 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

每个按钮有五个参数,顺序如下:

  1. 按钮名称

  2. 一个回调函数

  3. 指向传递给回调的用户变量数据的指针

  4. 按钮类型

  5. 用于复选框和单选按钮类型的默认初始化状态

然后,我们创建一个模糊复选框按钮,两个用于颜色转换的单选按钮,以及一个用于 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); 

这些是主函数最重要的部分。我们将探讨Callback函数。每个Callback都会更改其状态变量,以调用另一个名为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_RGB2GRAYCOLOR_BGR2GRAY

  • RGB 或 BGR 到 YcrCb(或 YCC)(COLOR_RGB2YCrCbCOLOR_BGR2YCrCb

  • RGB 或 BGR 到 HSV(COLOR_RGB2HSVCOLOR_BGR2HSV

  • RGB 或 BGR 到 Luv(COLOR_RGB2LuvCOLOR_BGR2Luv

  • 灰度到 RGB 或 BGR(COLOR_GRAY2RGBCOLOR_GRAY2BGR

我们可以看到,代码很容易记住。

OpenCV 默认使用 BGR 格式,RGB 和 BGR 的颜色转换不同,即使转换为灰度也是如此。一些开发者认为R+G+B/3对灰度是正确的,但最佳灰度值被称为亮度,其公式为021R + 072G + 007B

模糊滤镜在上一节中已描述,最后,如果 applySobel 变量为真,我们应用 Sobel 滤镜。Sobel 滤镜是通过 Sobel 运算符获得的图像导数,通常用于检测边缘。OpenCV 允许我们使用核大小生成不同的导数,但最常见的是 3x3 核来计算 x 导数或 y 导数。

最重要的 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);      

在我们的例子中,我们同时使用 xy 导数,覆盖输入。以下代码片段展示了如何同时生成 xy 导数,在第四和第五个参数中添加 1

Sobel(result, result, CV_8U, 1, 1); 

同时应用 xy 导数的结果看起来像以下图像,应用于 Lena 图片:

OpenGL 支持

OpenCV 包含 OpenGL 支持。OpenGL 是集成在几乎所有图形卡中的标准图形库。OpenGL 允许我们绘制 2D 到复杂的 3D 场景。OpenCV 包含 OpenGL 支持是因为在许多任务中,表示 3D 空间非常重要。为了允许 OpenGL 窗口支持,我们必须在创建窗口时使用 namedWindow 调用设置 WINDOW_OPENGL 标志。

以下代码创建了一个具有 OpenGL 支持的窗口,并绘制了一个旋转平面,我们将在此平面上显示网络摄像头帧:

Mat frame; 
GLfloat angle= 0.0; 
GLuint texture;  
VideoCapture camera; 

int loadTexture() { 

    if (frame.data==NULL) return -1; 

   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);  
        // second 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 );
    glGenTextures(1, &texture); 
    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位置为中心的平面,大小为 2 个单位。然后,我们必须定义要使用的纹理坐标和顶点位置,使用glTextCoord2DglVertex2D函数:

    // 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 入门》,由Packt Publishing出版。

我们可以在以下图像中看到结果:

图片

摘要

在本章中,我们学习了如何使用 OpenGL 创建不同类型的用户界面来显示图像或 3D 界面。我们学习了如何创建滑块和按钮或在 3D 中绘制。我们还使用原生 OpenCV 学习了一些基本的图像处理过滤器,但有一些新的开源替代品允许我们添加更多功能,例如 cvui (dovyski.github.io/cvui/) 或 OpenCVGUI (damiles.github.io/OpenCVGUI/)。

在下一章中,我们将构建一个完整的照片工具应用程序,我们将应用迄今为止所学的所有知识。通过图形用户界面,我们将学习如何将多个过滤器应用到输入图像上。

第四章:深入直方图和滤波器

在上一章中,我们学习了使用 Qt 库或本地库在 OpenCV 中实现用户界面的基础知识;我们还学习了如何使用高级 OpenGL 用户界面。我们学习了基本的颜色转换和允许我们创建第一个应用程序的过滤器。本章将介绍以下概念:

  • 直方图和直方图均衡

  • 查找表

  • 模糊和中值模糊

  • Canny 滤波器

  • 图像颜色均衡

  • 理解图像类型之间的转换

在我们学习 OpenCV 和用户界面的基础知识之后,我们将在本章中创建我们的第一个完整应用程序,一个基本的照片工具,并涵盖以下主题:

  • 生成 CMake 脚本文件

  • 创建图形用户界面

  • 计算和绘制直方图

  • 直方图均衡

  • 洛马格诺伊相机效果

  • 卡通化效果

此应用程序将帮助我们了解如何从头开始创建整个项目,并理解直方图的概念。我们将看到如何均衡彩色图像的直方图,并创建两种效果,使用滤波器的组合和使用查找表。

技术要求

本章要求熟悉 C++编程语言的基础知识。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_04。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频以查看代码的实际应用:

bit.ly/2Sid17y

生成 CMake 脚本文件

在我们开始创建源文件之前,我们将生成CMakeLists.txt文件,以便我们可以编译我们的项目,对其进行结构化,并执行它。以下 CMake 脚本简单且基本,但足以编译和生成可执行文件:

cmake_minimum_required (VERSION 3.0)

PROJECT(Chapter4_Phototool)

set (CMAKE_CXX_STANDARD 11)

# Requires OpenCV
FIND_PACKAGE( OpenCV 4.0.0 REQUIRED )
MESSAGE("OpenCV version : ${OpenCV_VERSION}")

include_directories(${OpenCV_INCLUDE_DIRS})
link_directories(${OpenCV_LIB_DIR})

ADD_EXECUTABLE(${PROJECT_NAME} main.cpp)
TARGET_LINK_LIBRARIES(${PROJECT_NAME} ${OpenCV_LIBS})

第一行指示生成我们的项目所需的最低 CMake 版本,第二行设置的项目名称可以用作${PROJECT_NAME}变量,第三行设置所需的 C++版本;在我们的案例中,我们需要C++11版本,正如我们可以在下一个片段中看到:

cmake_minimum_required (VERSION 3.0)

PROJECT(Chapter4_Phototool)

set (CMAKE_CXX_STANDARD 11)

此外,我们还需要 OpenCV 库。首先,我们需要找到库,然后我们将使用MESSAGE函数显示找到的 OpenCV 库版本信息:

# Requires OpenCV 
FIND_PACKAGE( OpenCV 4.0.0 REQUIRED ) 
MESSAGE("OpenCV version : ${OpenCV_VERSION}") 

如果找到的库版本至少为 4.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 – 必需的头文件,定义一个图像矩阵来存储输入图像,并创建一个常量字符串来使用 OpenCV 3.0 已提供的新的命令行解析器;在这个常量中,我们只允许两个输入参数,help和必需的图像输入:

// OpenCV includes 
#include "opencv2/core/utility.hpp" 
#include "opencv2/imgproc.hpp" 
#include "opencv2/highgui.hpp" 
using namespace cv; 
// OpenCV command line parser functions 
// Keys accepted by command line parser 
const char* keys = 
{ 
   "{help h usage ? | | print this message}" 
    "{@image | | Image to process}" 
}; 

主函数从命令行解析变量开始;接下来,我们设置关于说明并打印帮助信息。这一行设置了我们的最终可执行文件的帮助说明:

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 
Mat img= imread(imgFile); 

// Create window 
namedWindow("Input"); 

在图像加载和窗口创建后,我们只需要创建我们的界面按钮并将它们与回调函数链接;每个回调函数都在源代码中定义,我们将在本章后面解释这些函数。我们将使用createButton函数和QT_PUSH_BUTTON常量来创建按钮,以按钮样式:

// 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("Cartoonize effect", cartoonCallback, NULL, QT_PUSH_BUTTON, 0); 

为了完成我们的主函数,我们显示输入图像并等待按键以结束我们的应用程序:

// Show image 
imshow("Input", img); 

waitKey(0); 
return 0; 

现在,我们只需要定义每个回调函数,在接下来的章节中,我们将这样做。

绘制直方图

直方图是变量分布的统计图形表示,它允许我们理解数据的密度估计和概率分布。直方图是通过将变量值的整个范围划分为一个小范围值,然后计算落入每个区间的值数量来创建的。

如果我们将这个直方图概念应用到图像上,看起来很难理解,但实际上它非常简单。在灰度图像中,我们的变量值范围是每个可能的灰度值(从0255),密度是具有此值的图像像素数。这意味着我们必须计算具有值为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); 

} 

让我们了解如何提取每个通道的直方图以及如何绘制它。首先,我们需要创建三个矩阵来处理每个输入图像通道。我们使用一个向量类型的变量来存储每个矩阵,并使用split OpenCV 函数将这些通道分配给这三个通道:

// Separate image in BRG 
    vector<Mat> bgr; 
    split(img, bgr); 

现在,我们将定义直方图的分箱数量,在我们的例子中,每个可能的像素值对应一个分箱:

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;

我们可以使用calcHist OpenCV 函数来计算直方图。此函数有多个参数,顺序如下:

  • 输入图像:在我们的例子中,我们使用存储在bgr向量中的一个图像通道

  • 计算直方图所需的输入图像数量:在我们的例子中,我们只使用1个图像

  • 用于计算直方图的通道维度:在我们的例子中,我们使用0

  • 可选的掩码矩阵。

  • 用于存储计算出的直方图的变量。

  • 直方图维度:这是图像(在这里,是一个灰度平面)取值的空间维度,在我们的例子中是1

  • 计算分箱的数量:在我们的例子中是256个分箱,每个分箱对应一个像素值

  • 输入变量的范围:在我们的例子中,从0255可能的像素值

我们为每个通道的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)); 

在我们将直方图值绘制到我们的图像之前,我们将直方图矩阵在最小值0和最大值之间进行归一化;最大值与我们的输出直方图图像的高度相同:

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

现在,我们必须从分箱0到分箱1画一条线,依此类推。在每个分箱之间,我们必须计算有多少像素;然后,通过除以分箱的数量来计算binStep变量。每条小线是从水平位置i-1i绘制的;垂直位置是相应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 光片中非常有用。

然而,这种方法有两个主要缺点:背景噪声的增加以及有用信号的相应减少。我们可以在以下照片中看到均衡化的效果,当增加图像对比度时,直方图发生变化并扩散:

让我们实现我们的直方图均衡化;我们将在用户界面代码中定义的 Callback 函数中实现它:

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

要均衡彩色图像,我们只需要均衡亮度通道。我们可以对每个颜色通道进行此操作,但结果不可用。或者,我们可以使用任何其他颜色图像格式,例如 HSVYCrCb,这些格式将亮度分量分离到单独的通道中。因此,我们选择 YCrCb 并使用 Y 通道(亮度)进行均衡。然后,我们遵循以下步骤:

1. 使用 cvtColor 函数将 BGR 图像转换为 YCrCb

Mat result; 
// Convert BGR image to YCbCr 
Mat ycrcb; 
cvtColor(img, ycrcb, COLOR_BGR2YCrCb); 

2. 将 YCrCb 图像拆分为不同的通道矩阵:

// Split image into channels 
vector<Mat> channels; 
split(ycrcb, channels); 

3. 仅在 Y 通道中使用 equalizeHist 函数均衡直方图,该函数只有两个参数,输入和输出矩阵:

// Equalize the Y channel only 
equalizeHist(channels[0], channels[0]); 

4. 合并生成的通道并将它们转换为 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 Look-up 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("Lomography", result); 
} 

让我们看看洛马格诺伊效应是如何工作的以及如何实现它。洛马格诺伊效应分为不同的步骤,但在我们的例子中,我们通过两个步骤实现了一个非常简单的洛马格诺伊效应:

  1. 通过使用查找表(LUT)将曲线应用于红色通道来实现颜色操纵效果

  2. 通过在图像上应用暗晕来实现的复古效果

第一步是通过对红色通道应用以下函数,通过曲线变换来调整红色:

这个公式生成一条曲线,使得暗值更暗,亮值更亮,其中 x 是可能的像素值(0255),而 s 是一个常数,在我们的例子中设置为 0.1。较低的常数值会生成小于 128 的像素值,非常暗,而大于 128 的像素值非常亮。接近 1 的值会将曲线转换为一条直线,并且不会产生我们期望的效果:

通过应用查找表(LUT)来实现这个函数非常简单。查找表是一个向量或表,它为给定的值返回预处理的值以在内存中执行计算。查找表是一种常见的技巧,用于通过避免重复执行昂贵的计算来节省 CPU 周期。而不是对每个像素调用 exponential/divide 函数,我们只对每个可能的像素值(256 次)执行一次,并将结果存储在表中。因此,我们在牺牲一点内存的情况下节省了 CPU 时间。虽然这可能在标准 PC 的小图像尺寸上不会产生很大的差异,但对于 CPU 有限的硬件(如 Raspberry Pi)来说,这会产生巨大的影响。

例如,在我们的案例中,如果我们想对图像中的每个像素应用一个函数,那么我们必须进行 width x height 次操作;例如,在 100 x 100 像素的情况下,将有 10,000 次计算。如果我们能预先计算所有可能的输入的所有可能结果,我们就可以创建查找表。在图像中,像素值只有 256 种可能。如果我们想通过应用函数来改变颜色,我们可以预先计算这 256 个值并将它们保存到查找表向量中。在我们的示例代码中,我们定义了 E 变量并创建了一个 lut 矩阵,该矩阵有 1 行和 256 列。然后,我们通过应用我们的公式并保存到 lut 变量中,对所有可能的像素值进行循环:

const double exponential_e = std::exp(1.0); 
// Create look-up 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)); 

图像将被调整以产生以下结果:

现在,如果我们必须将这个光环应用到步骤 1 中的图像上,一个简单的方法是将两个图像相乘。然而,我们必须将我们的输入图像从 8 位图像转换为 32 位浮点数,因为我们需要将我们的模糊图像(其值在01范围内)与我们的输入图像相乘,而输入图像具有整数值。以下代码将为我们完成这项工作:

// 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 大小孔径

  • 布尔值,表示是否需要使用更精确的图像梯度幅度

在第一个阈值和第二个阈值之间最小的值用于边缘链接。最大的值用于找到强边缘的初始段。Sobel 大小孔径是算法中将使用的 Sobel 滤波器的核大小。在检测到边缘后,我们将应用一个小膨胀来连接断裂的边缘:

// Dilate the edges 
Mat kernel= getStructuringElement(MORPH_RECT, Size(2,2)); 
dilate(imgCanny, imgCanny, kernel); 

与我们在 lomography 效果中所做的一样,如果我们需要将我们的边缘结果图像与颜色图像相乘,那么我们需要像素值在01范围内。为此,我们将 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滤镜:

// Blur the edgest to do smooth effect 
blur(imgCannyf, imgCannyf, Size(5,5)); 

算法的第一步已经完成,现在我们将处理颜色。为了得到卡通效果,我们将使用bilateral滤波器:

// Apply bilateral filter to homogenizes color 
Mat imgBF; 
bilateralFilter(img, imgBF, 9, 150.0, 150.0); 

双边滤波器是一种在保持边缘的同时减少图像噪声的滤波器。通过适当的参数,我们将在后面探讨,我们可以得到卡通效果。

双边滤波器的参数如下:

  • 输入图像

  • 输出图像

  • 像素邻域直径;如果设置为负值,则从 sigma 空间值计算得出

  • Sigma 颜色值

  • Sigma 坐标空间

当直径大于五时,双边滤波器开始变得缓慢。当 sigma 值大于 150 时,会出现卡通效果。

为了创建更强的卡通效果,我们可以通过乘以和除以像素值来截断可能的颜色值到 10:

// truncate colors 
Mat result= imgBF/25; 
result= result*25; 

最后,我们必须合并颜色和边缘的结果。然后,我们必须创建一个如下所示的三通道图像:

// 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 相机)进行光学检测,并结合复杂的算法,在成千上万的行业中用于不同的目的,如缺陷检测、分类等。

技术要求

本章需要熟悉基本的 C++ 编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_05。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频,了解代码的实际应用:

bit.ly/2DRbMbz

场景中对象的隔离

在本章中,我们将介绍 AOI 算法的第一步,并尝试隔离场景中的不同部分或对象。我们将以三种对象类型(螺丝、包装环和螺母)的对象检测和分类为例,并在本章以及 第六章,学习对象分类 中进行开发。

想象我们是一家生产这三种产品的公司。所有这些都在同一载体胶带上。我们的目标是检测载体胶带上的每个对象,并对每个对象进行分类,以便机器人将每个对象放在正确的货架上:

图片

在本章中,我们将学习如何隔离每个对象并在像素中检测其位置。在下一章中,我们将学习如何对每个隔离对象进行分类,以识别它是否是螺母、螺丝或包装环。

在以下屏幕截图中,我们展示了我们期望的结果,其中左图中有几个对象。在右图中,我们用不同的颜色标记了每个对象,以显示不同的特征,如面积、高度、宽度和轮廓大小:

为了达到这个结果,我们将遵循不同的步骤,这将使我们更好地理解和组织我们的算法。我们可以在以下图表中看到这些步骤:

我们的应用程序将分为两个章节。在本章中,我们将开发并理解预处理和分割步骤。在第六章“学习对象分类”中,我们将提取每个分割对象的特征,并在如何识别每个对象类别上训练我们的机器学习系统/算法。

我们将预处理步骤分为三个更小的子集:

  • 噪声去除

  • 光去除

  • 二值化

在分割步骤中,我们将使用两种不同的算法:

  • 轮廓检测

  • 连通组件提取(标记)

我们可以在以下图表和应用流程图中看到这些步骤:

现在,是时候开始预处理步骤,以便我们可以通过去除噪声和光照效果来获得最佳的二值化图像。这最小化了任何可能的检测错误。

创建一个用于 AOI 的应用

要创建我们的新应用程序,我们需要一些输入参数。当用户运行应用程序时,所有这些参数都是可选的,除了要处理的输入图像。输入参数如下:

  • 要处理的输入图像

  • 光图像模式

  • 光操作,用户可以在差分或除法操作之间进行选择

  • 如果用户将0设置为值,则应用差分操作

  • 如果用户将1设置为值,则应用除法操作

  • 分割,用户可以在带有或不带有统计的连通组件和查找轮廓方法之间进行选择

  • 如果用户将1设置为输入值,则应用分割的连通组件方法

  • 如果用户将2设置为输入值,则应用带有统计区域的连通组件方法

  • 如果用户将3设置为输入值,则应用查找轮廓方法进行分割

为了启用此用户选择,我们将使用具有以下键的命令行解析器类:

// OpenCV command line parser functions 
// Keys accepted 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 }" 
}; 

我们将在 main 函数中通过检查参数来使用 command line parser 类。CommandLineParserOpenCV 基础入门读取视频和摄像头 部分的 第二章 中有解释:

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); 
  auto method_light= parser.get<int>("lightMethod"); 
  auto method_seg= parser.get<int>("segMethod"); 

  // Check if params are correctly parsed in his variables 
  if (!parser.check()) 
  { 
      parser.printErrors(); 
      return 0; 
  } 

在解析我们的命令行用户数据后,我们需要检查输入图像是否已正确加载。然后我们加载图像并检查它是否有数据:

// 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 函数,该函数需要三个参数:

  • 一个具有 134 通道的输入图像。当核心大小大于 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 获得的结果在 01 之间。最后,我们将这个除法的结果取反,以获得相同颜色的方向范围,并将其乘以 255 以获得 0-255 范围内的值。

在我们的代码中,我们将创建一个名为 removeLight 的新函数,具有以下参数:

  • 要去除光线/背景的输入图像

  • 一个光线模式,Mat

  • 一个方法,差值为 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); 
    // Convert 8 bits format and scale
    aux.convertTo(aux, CV_8U, 255); 
  }else{ 
    aux= pattern-img; 
  } 
  return aux; 
} 

让我们探索一下。在创建用于保存结果的 aux 变量后,我们选择用户选择的方法并将参数传递给函数。如果选择的方法是 1,则应用除法方法。

除法方法需要一个 32 位浮点图像,这样我们才能除以图像,而不会将数字截断为整数。第一步是将图像和光模式mat转换为 32 位浮点。为了转换这种格式的图像,我们可以使用Mat类的convertTo函数。这个函数接受四个参数;输出转换后的图像和您希望转换到的格式,但您可以定义 alpha 和 beta 参数,这些参数允许您根据以下函数缩放和移动值,其中O是输出图像,I是输入图像:

O(x,y)=cast<Type>(α * I(x,y)+β)

以下代码将图像转换为 32 位浮点:

// Required to 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); 

现在,我们已经得到了结果,但需要将其返回到 8 位深度图像,然后使用我们之前使用的转换函数,通过 alpha 参数将图像的mat和缩放从0255

// Convert 8 bits format 
aux.convertTo(aux, CV_8U, 255); 

现在,我们可以返回带有结果的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; 
} 

此基本函数通过使用相对于图像大小的大内核大小对输入图像应用模糊。从代码中可以看出,它是原始宽度和高度的三分之一

阈值化

在移除背景后,我们只需要对图像进行二值化,以便进行未来的分割。我们将使用阈值来完成这项工作。阈值是一个简单的函数,它将每个像素的值设置为最大值(例如,255)。如果像素的值大于阈值值或小于阈值值,它将被设置为最小值(0):

图片

现在,我们将使用两个不同的阈值值应用阈值函数:当我们移除光/背景时,我们将使用 30 的阈值值,因为所有非感兴趣区域都是黑色的。这是因为我们应用了背景移除。当不使用光移除方法时,我们也将使用一个中等值阈值(140),因为我们有一个白色背景。这个最后的选项用于允许我们检查有和无背景移除的结果:

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

现在,我们将继续到我们应用程序最重要的部分:分割。在这里,我们将使用两种不同的方法或算法:连接组件和查找轮廓。

分割我们的输入图像

现在,我们将介绍两种技术来分割我们的阈值图像:

  • 连接组件

  • 查找轮廓

使用这两种技术,我们可以提取图像中感兴趣的区域(ROI),其中我们的目标对象出现。在我们的案例中,这些是螺母、螺丝和环。

连接组件算法

连接组件算法是一个非常常见的算法,用于在二值图像中分割和识别部分。连接组件是一个迭代算法,其目的是使用八个或四个连通像素对图像进行标记。如果两个像素具有相同的值并且是邻居,则它们是连通的。在图像中,每个像素有八个相邻像素:

图片

四连通性意味着如果中心像素的值与中心像素相同,则只有 2457 个邻居可以连接到中心。在八连通性中,如果中心像素的值与中心像素相同,则 12345678 个邻居可以连接。我们可以在以下示例中看到四连通性和八连通性算法之间的差异。我们将应用每个算法到下一个二值化图像。我们使用了一个小的 9 x 9 图像并放大以显示连通组件的工作原理以及四连通性和八连通性之间的差异:

四连通性算法检测到两个对象;我们可以在左边的图像中看到这一点。八连通性算法只检测到一个对象(右边的图像),因为两个对角像素是连通的。八连通性考虑了对角连通性,这是与四连通性的主要区别,因为四连通性只考虑垂直和水平像素。我们可以在以下图像中看到结果,其中每个对象都有不同的灰度颜色值:

OpenCV 通过两个不同的函数为我们带来了连通组件算法:

  • connectedComponents (image, labels, connectivity= 8, type= CV_32S)

  • connectedComponentsWithStats (image, labels, stats, centroids, connectivity= 8, type= CV_32S)

两个函数都返回一个整数,表示检测到的标签数,其中标签 0 表示背景。这两个函数之间的区别基本上是返回的信息。让我们检查每个函数的参数。connectedComponents 函数给出了以下参数:

  • Image: 要标记的输入图像。

  • Labels: 一个输出矩阵,其大小与输入图像相同,其中每个像素的值为其标签的值,其中所有 OS 表示背景,值为 1 的像素表示第一个连通组件对象,依此类推。

  • Connectivity: 两个可能的值,84,表示我们想要使用的连通性。

  • Type: 我们想要使用的标签图像的类型。只允许两种类型:CV32_SCV16_U。默认情况下,这是 CV32_S

  • connectedComponentsWithStats 函数定义了两个额外的参数。这些是 stats 和 centroids:

    • Stats: 这是一个输出参数,它为我们提供了每个标签(包括背景)的以下统计值:

      • CC_STAT_LEFT: 连通组件对象的左侧 x 坐标

      • CC_STAT_TOP: 连通组件对象的顶部 y 坐标

      • CC_STAT_WIDTH: 由其边界框定义的连通组件对象的宽度

      • CC_STAT_HEIGHT: 由其边界框定义的连通组件对象的高度

      • CC_STAT_AREA: 连通组件对象的像素数(面积)

    • 质心:质心指向每个标签的浮点类型,包括被认为属于另一个连通分量的背景。

在我们的示例应用程序中,我们将创建两个函数,以便我们可以应用这两个 OpenCV 算法。然后,我们将以带有彩色对象的新的图像形式向用户展示获得的结果,在基本的连通分量算法中。如果我们选择使用 stats 方法的连通分量,我们将绘制每个对象返回的相应计算面积。

让我们定义连通分量函数的基本绘图:

void ConnectedComponents(Mat img) 
{ 
  // Use connected components to divide our image in multiple connected component objects
     Mat labels; 
     auto 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(auto i=1; i<num_objects; i++){ 
        Mat mask= labels==i; 
        output.setTo(randomColor(rng), mask); 
      } 
     imshow("Result", output); 
} 

首先,我们调用 OpenCV 的connectedComponents函数,它返回检测到的对象数量。如果对象的数量少于两个,这意味着只检测到了背景对象,那么我们不需要绘制任何东西,可以直接结束。如果算法检测到多个对象,我们将在控制台上显示检测到的对象数量:

  Mat labels; 
  auto 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; 

最后,我们使用mask将伪随机颜色设置到输出图像上:

    output.setTo(randomColor(rng), mask); 
  } 

在遍历所有图像之后,我们将在输出中看到不同颜色的所有检测到的对象,我们只需要在窗口中显示输出图像:

imshow("Result", output); 

这是结果,其中每个对象都被涂上不同的颜色或灰度值:

图片

现在,我们将解释如何使用带有stats OpenCV 算法的连通分量,并在结果图像中显示更多信息。以下函数实现了这个功能:

void ConnectedComponentsStats(Mat img) 
{ 
  // Use connected components with stats 
  Mat labels, stats, centroids; 
  auto 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(auto 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; 
  auto 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 和 centroid 变量。然后,对于每个检测到的标签,我们将通过命令行显示质心和面积:

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

最后,在每个分割对象的质心位置,我们想在结果图像上绘制一些信息(例如面积)。为此,我们使用putText函数和 stats 以及 centroid 变量。首先,我们必须创建一个stringstream,以便我们可以添加 stats 面积信息:

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

这个函数的结果如下:

findContours 算法

findContours 算法是 OpenCV 中用于分割对象最常用的算法之一。这是因为该算法自 OpenCV 1.0 版本起就被包含在内,为开发者提供了更多信息和解描述符,包括形状、拓扑组织等:

void findContours(InputOutputArray image, OutputArrayOfArrays contours, OutputArray hierarchy, int mode, int method, Point offset=Point()) 

让我们解释每个参数:

  • 图像:输入二值图像。

  • 轮廓:轮廓的输出,其中每个检测到的轮廓都是一个点的向量。

  • 层次结构:这是一个可选的输出向量,其中保存了轮廓的层次结构。这是图像的拓扑结构,我们可以从中获取每个轮廓之间的关系。层次结构以四个索引的向量表示,分别是(下一个轮廓、前一个轮廓、第一个子轮廓、父轮廓)。如果给定的轮廓与其他轮廓没有关系,则给出负索引。更详细的解释可以在docs.opencv.org/3.4/d9/d8b/tutorial_py_contours_hierarchy.html找到。

  • 模式:此方法用于检索轮廓:

    • RETR_EXTERNAL 仅检索外部轮廓。

    • RETR_LIST 检索所有轮廓而不建立层次结构。

    • RETR_CCOMP 检索所有轮廓,具有两个级别的层次结构,即外部轮廓和孔洞。如果另一个对象在孔洞内部,则将其放在层次结构的顶部。

    • RETR_TREE 检索所有轮廓,并在轮廓之间创建完整的层次结构。

  • 方法:这允许我们使用近似方法来检索轮廓的形状:

    • 如果设置 CV_CHAIN_APPROX_NONE,则不对轮廓应用任何近似,并存储轮廓的点。

    • CV_CHAIN_APPROX_SIMPLE 压缩所有水平、垂直和对角线段,仅存储起点和终点。

    • CV_CHAIN_APPROX_TC89_L1CV_CHAIN_APPROX_TC89_KCOS 应用 Telchin 近似 算法。

  • 偏移:这是一个可选的点值,用于移动所有轮廓。当我们在一个 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(auto i=0; i<contours.size(); i++){ 
    drawContours(output, contours, i, randomColor(rng)); 
    imshow("Result", output); 
  }
} 

让我们逐行解释我们的实现。

在我们的例子中,我们不需要任何层次结构,所以我们只将检索所有可能对象的轮廓。为此,我们可以使用 RETR_EXTERNAL 模式和基本的轮廓编码,通过使用 CHAIN_APPROX_SIMPLE 方法:

vector<vector<Point> > contours; 
vector<Vec4i> hierarchy; 
findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); 

就像我们之前查看的连通组件示例一样,首先我们检查我们检索了多少个轮廓。如果没有,则退出我们的函数:

// 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 为我们提供了一个函数来绘制 find contours 图像的结果:

for(auto i=0; i<contours.size(); i++) 
    drawContours(output, contours, i, randomColor(rng)); 
  imshow("Result", output); 
} 

drawContours函数允许以下参数:

  • 图像:绘制轮廓的输出图像。

  • 轮廓:轮廓的向量。

  • 轮廓索引:一个表示要绘制的轮廓的数字。如果这是负数,则绘制所有轮廓。

  • 颜色:绘制轮廓的颜色。

  • 厚度:如果是负数,则轮廓用所选颜色填充。

  • 线型:这指定了我们是否想要使用抗锯齿或其他绘图方法进行绘制。

  • 层次:这是一个可选参数,仅在需要绘制一些轮廓时才需要。

  • 最大层级:这是一个可选参数,仅在存在层次参数时才考虑。如果设置为0,则只绘制指定的轮廓。如果设置为1,则函数绘制当前轮廓及其嵌套轮廓。如果设置为2,则算法绘制所有指定的轮廓层级。

  • 偏移:这是一个可选参数,用于移动轮廓。

我们的结果可以在以下图像中看到:

图像

摘要

在本章中,我们探讨了在受控情况下进行对象分割的基本知识,其中相机拍摄不同对象的图片。在这里,我们学习了如何去除背景和光线,以便我们更好地二值化图像,从而最小化噪声。在二值化图像后,我们了解了三种不同的算法,我们可以使用这些算法来分割和分离图像中的每个对象,使我们能够隔离每个对象以进行操作或提取特征。

我们可以在以下图像中看到整个流程:

图像

最后,我们从图像中提取了所有对象。您需要这样做才能继续下一章,在下一章中,我们将提取这些对象的特征以训练机器学习系统。

在下一章中,我们将预测图像中任何对象的类别,然后调用机器人或其他系统来挑选其中任何一个,或者检测不在正确载体带上的对象。然后我们将查看通知某人将其取走。

第六章:学习对象分类

在第五章《自动光学检测、对象分割和检测》中,我们介绍了对象分割和检测的基本概念。这指的是将图像中出现的对象隔离出来,以便进行未来的处理和分析。本章解释了如何对每个隔离的对象进行分类。为了使我们能够对每个对象进行分类,我们必须训练我们的系统,使其能够学习所需的参数,以便决定将哪个特定的标签分配给检测到的对象(取决于训练阶段考虑的不同类别)。

本章介绍了机器学习的基本概念,用于对具有不同标签的图像进行分类。为此,我们将基于第五章的分割算法创建一个基本应用程序,该算法是自动光学检测、对象分割和检测。这个分割算法提取包含未知对象的图像部分。对于每个检测到的对象,我们将提取不同的特征,这些特征将使用机器学习算法进行分类。最后,我们将使用我们的用户界面展示获得的结果,包括输入图像中检测到的每个对象的标签。

本章涉及不同的主题和算法,包括以下内容:

  • 机器学习概念简介

  • 常见的机器学习算法和流程

  • 特征提取

  • 支持向量机(SVM)

  • 训练和预测

技术要求

本章需要熟悉基本的 C++编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_06。此代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频,了解代码的实际应用:

bit.ly/2KGD4CO

介绍机器学习概念

机器学习是一个概念,由Arthur Samuel于 1959 年定义,它是一个研究领域,使计算机能够在没有明确编程的情况下学习。Tom M. Mitchel为机器学习提供了一个更正式的定义,其中他将样本的概念与经验数据、标签以及算法的性能测量联系起来。

在《IBM 研究与发展杂志》的《使用国际象棋游戏进行机器学习研究》一文中,引用了Arthur Samuel机器学习的定义,该文发表于同年《The New Yorker》和《Office Management》杂志,(33210)。

来自 Tom M. Mitchel 的更正式的定义在 Machine Learning Book, McGray Hill 1997: 中被引用(www.cs.cmu.edu/afs/cs.cmu.edu/user/mitchell/ftp/mlbook.html)。

机器学习涉及人工智能中的模式识别和学习理论,与计算统计学相关。它被应用于数百个应用中,例如光学字符识别OCR)、垃圾邮件过滤、搜索引擎,以及数千个计算机视觉应用,例如我们将在本章中开发的例子,其中机器学习算法试图对输入图像中出现的对象进行分类。

根据机器学习算法从输入数据中学习的方式,我们可以将它们分为三类:

  • 监督学习:计算机从一组标记的数据中学习。这里的目的是学习模型的参数和规则,这些规则允许计算机将数据与输出标签之间的关系映射出来。

  • 无监督学习:没有给出标签,计算机试图发现给定数据的输入结构。

  • 强化学习:计算机与动态环境交互,达到目标并从错误中学习。

根据我们希望从机器学习算法中获得的结果,我们可以将结果分类如下:

  • 分类:输入空间可以划分为 N 个类别,给定样本的预测结果将是这些训练类别之一。这是最常用的类别之一。一个典型的例子是电子邮件垃圾邮件过滤,其中只有两个类别:垃圾邮件和非垃圾邮件。或者,我们可以使用 OCR,其中只有 N 个字符可用,每个字符是一个类别。

  • 回归:输出是一个连续值,而不是分类结果这样的离散值。回归的一个例子可能是根据房屋的大小、建造以来的年数和位置预测房价。

  • 聚类:输入将被划分为 N 个组,这通常使用无监督训练来完成。

  • 密度估计:找到输入的(概率)分布。

在我们的例子中,我们将使用一个监督学习和分类算法,其中使用带有标签的训练数据集来训练模型,模型的预测结果将是可能的标签之一。在机器学习中,有几种方法和途径可以实现这一点。其中一些更受欢迎的方法包括以下内容:支持向量机SVM)、人工神经网络ANN)、聚类、k-最近邻、决策树和深度学习。几乎所有这些方法和途径都在 OpenCV 中得到支持、实现和良好记录。在本章中,我们将解释支持向量机。

OpenCV 机器学习算法

OpenCV 实现了这八种机器学习算法。所有这些算法都是继承自StatModel类:

  • 人工神经网络

  • 随机树

  • 期望最大化

  • k 最近邻

  • 逻辑回归

  • 正常贝叶斯分类器

  • 支持向量机

  • 随机梯度下降 SVMs

版本 3 在基本级别上支持深度学习,但版本 4 更稳定且更受支持。我们将在后续章节中详细探讨深度学习。

要获取有关每个算法的更多信息,请阅读 OpenCV 机器学习文档页面docs.opencv.org/trunk/dc/dd6/ml_intro.html

以下图表显示了机器学习类层次结构:

图片

StatModel类是所有机器学习算法的基类。这提供了预测以及所有非常重要的读写函数,用于保存和读取我们的机器学习参数和训练数据。

在机器学习中,最耗时和计算资源消耗的部分是训练方法。对于大型数据集和复杂的机器学习结构,训练可能需要从几秒到几周或几个月。例如,在深度学习中,包含超过 10 万个图像数据集的大型神经网络结构可能需要很长时间来训练。使用深度学习算法时,通常使用并行硬件处理,如具有 CUDA 技术的 GPU,或大多数新的芯片设备,如 Intel Movidius,以减少训练过程中的计算时间。这意味着我们每次运行应用程序时都不能训练我们的算法,因此建议保存我们已训练的模型,包括所有已学习的参数。在未来的执行中,我们只需要从保存的模型中加载/读取,除非我们需要用更多的样本数据更新我们的模型。

StatModel是所有机器学习类(如 SVM 或 ANN)的基类,但不包括深度学习方法。StatModel基本上是一个虚拟类,它定义了两个最重要的函数——trainpredicttrain方法是负责使用训练数据集学习模型参数的主要方法。它有以下三种可能的调用方式:

bool train(const Ptr<TrainData>& trainData, int flags=0 ); 
bool train(InputArray samples, int layout, InputArray responses); 
Ptr<_Tp> train(const Ptr<TrainData>& data, int flags=0 ); 

训练函数有以下参数:

  • TrainData:可以从TrainData类加载或创建的训练数据。这个类是 OpenCV 3 中的新功能,帮助开发者创建训练数据并从机器学习算法中抽象出来。这样做是因为不同的算法需要不同类型的数组结构进行训练和预测,例如 ANN 算法。

  • samples:一个训练样本数组,例如机器学习算法所需的格式要求的训练数据。

  • layout: ROW_SAMPLE(训练样本是矩阵行)或COL_SAMPLE(训练样本是矩阵列)。

  • responses:与样本数据关联的响应向量。

  • flags:由每个方法定义的可选标志。

最后的训练方法创建并训练了一个_TP类类型的模型。唯一接受的类是实现了无参数或有所有默认参数值的静态创建方法的类。

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,则该方法从数据的测试子集计算错误;如果为 false,则该方法从所有训练数据计算错误。resp是可选的输出结果。

现在,我们将介绍如何构建一个基本的计算机视觉应用,该应用使用机器学习。

计算机视觉和机器学习工作流程

带有机器学习的计算机视觉应用有一个共同的基本结构。这个结构被分为不同的步骤:

  1. 预处理

  2. 分割

  3. 特征提取

  4. 分类结果

  5. 后处理

这些在几乎所有的计算机视觉应用中都很常见,而其他一些则被省略了。在下面的图中,你可以看到涉及的不同步骤:

几乎所有计算机视觉应用程序都以对输入图像应用的预处理开始,这包括去除光线和噪声、过滤、模糊等。在将所有必要的预处理应用于输入图像之后,第二步是分割。在这一步中,我们必须从图像中提取感兴趣的区域,并将每个区域隔离为独特的感兴趣对象。例如,在人脸检测系统中,我们必须将人脸与场景中的其他部分分开。在检测到图像内的对象之后,我们继续下一步。在这里,我们必须提取每个对象的特征;特征通常是对象特征的向量。特征描述我们的对象,可以是对象的面积、轮廓、纹理图案、像素等。

现在,我们有了我们对象的描述符,也称为特征向量或特征集。描述符是描述对象的特征,我们使用这些特征来训练或预测模型。为此,我们必须创建一个包含数千张图像的大特征数据集。然后,我们在选择的训练模型函数中使用提取的特征(图像/对象特征),如面积、大小和宽高比。在以下图中,我们可以看到数据集是如何被输入到机器学习算法中以进行训练和生成模型的:

图片

当我们使用数据集进行训练时,模型学习所有必要的参数,以便能够在将未知标签的新特征向量作为输入提供给我们的算法时进行预测。在以下图中,我们可以看到如何使用生成的模型使用未知特征向量进行预测,从而返回分类结果或回归:

图片

在预测结果之后,有时需要对输出数据进行后处理,例如,合并多个分类以减少预测误差或合并多个标签。在光学字符识别的一个示例中,分类结果是根据每个预测字符的,通过结合字符识别的结果,我们构建一个单词。这意味着我们可以创建一个后处理方法来纠正检测到的单词中的错误。通过这个对计算机视觉机器学习的简要介绍,我们将实现自己的应用程序,该程序使用机器学习来对幻灯片胶片中的对象进行分类。我们将使用支持向量机作为我们的分类方法,并解释如何使用它们。其他机器学习算法以非常相似的方式使用。OpenCV 文档在以下链接中提供了有关所有机器学习算法的详细信息:docs.opencv.org/master/dd/ded/group__ml.html

自动对象检测分类示例

在第五章,自动化光学检测、目标分割和检测中,我们查看了一个自动对象检测分割的例子,其中载体胶带包含三种不同类型的对象:螺母、螺丝和环。通过计算机视觉,我们将能够识别这些对象中的每一个,以便我们可以向机器人发送通知或将每个对象放入不同的盒子中。以下是载体胶带的基本示意图:

图片

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

图片

我们将遵循以下步骤进行我们的示例应用:

  1. 对于每个输入图像:

    • 预处理图像

    • 分割图像

  2. 对于图像中的每个对象:

    • 提取特征

    • 将特征添加到训练特征向量中,并赋予相应的标签(螺母、螺丝、环)

  3. 创建 SVM 模型。

  4. 使用训练特征向量训练我们的 SVM 模型。

  5. 预处理输入图像以对每个分割的对象进行分类。

  6. 分割输入图像。

  7. 对于检测到的每个对象:

    • 提取特征

    • 使用 SVM 进行预测

    • 模型

    • 在输出图像上绘制结果

对于预处理和分割,我们将使用第五章,自动化光学检测、目标分割和检测中找到的代码。然后我们将解释如何提取特征并创建训练和预测我们模型所需的向量。

特征提取

下一步,我们需要为每个对象提取特征。为了理解特征向量概念,在我们的示例中我们将提取非常简单的特征,因为这已经足够获得良好的结果。在其他解决方案中,我们可以获得更复杂的特征,如纹理描述符、轮廓描述符等。在我们的示例中,我们只有螺母、环和螺丝在图像中的不同位置和方向。相同的对象可以位于图像的任何位置和方向,例如,螺丝或螺母。我们可以在以下图像中看到不同的方向:

图片

我们将要探索一些可能提高我们机器学习算法准确性的特征或特性。我们不同对象(如螺母、螺丝和环)的这些可能特性如下:

  • 对象的面积

  • 长宽比,即边界矩形的宽度除以高度

  • 孔洞的数量

  • 轮廓边的数量

这些特性可以很好地描述我们的对象,如果我们使用所有这些特性,分类错误将会非常小。然而,在我们的实现示例中,我们只打算使用前两个特性,即面积和长宽比,用于学习目的,因为我们可以将这些特性绘制在二维图形中,并展示这些值正确地描述了我们的对象。我们还可以展示我们可以在图形图中直观地区分不同类型的对象。为了提取这些特征,我们将使用黑白 ROI 图像作为输入,其中只有一个对象以白色出现,背景为黑色。这个输入是第五章,“自动光学检测”,“对象分割”和“检测”的分割结果。我们将使用findContours算法进行对象分割,并创建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(auto 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 greater 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); 
      } 

      // Add image to the multiple image window class, See the class on full github code   
      miw->addImage("Extract Features", mask*255); 
      miw->render(); 
      waitKey(10); 
    } 
  } 
  return output; 
} 

让我们解释我们用来提取特征的代码。我们将创建一个函数,它以一个图像作为输入,并返回一个参数,其中包含图像中检测到的每个对象的左和顶部位置的向量。这些数据将用于在每个对象上绘制相应的标签。函数的输出是一个浮点向量向量。换句话说,它是一个矩阵,其中每一行包含检测到的每个对象的特征。

首先,我们必须创建输出向量变量和轮廓变量,这些变量将用于我们的findContours算法分割。我们还需要创建输入图像的副本,因为findContours OpenCV 函数会修改输入图像:

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

如果检测到对象,对于每个轮廓,我们将在黑色图像(零值)上用白色绘制对象。这将使用 1 值完成,就像一个掩码图像。以下代码片段生成掩码图像:

for(auto 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 greater 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; 

现在我们已经提取了每个输入图像的特征,我们可以继续下一步。

训练 SVM 模型

我们现在将使用监督学习,并为每个对象及其相应的标签获取一组图像。数据集中没有图像的最小数量;如果我们为训练过程提供更多图像,我们将得到更好的分类模型(在大多数情况下)。然而,对于简单的分类器,训练简单的模型可能就足够了。为此,我们创建了三个文件夹(screwnutring),其中每个类型的所有图像都放在一起。对于文件夹中的每张图像,我们必须提取特征,将它们添加到 train 特征矩阵中,同时创建一个新向量,其中每行对应于每个训练矩阵的标签。为了评估我们的系统,我们将根据测试和训练将每个文件夹分成一定数量的图像。我们将保留大约 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/nut_%04d.pgm", 0, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // Get and process the ring images 
  readFolderAndExtractFeatures("../data/ring/ring_%04d.pgm", 1, num_for_test, trainingData, responsesData, testData, testResponsesData); 
  // get and process the screw images 
  readFolderAndExtractFeatures("../data/screw/screw_%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]); 

  Ptr<TrainData> tdata= TrainData::create(trainingDataMat, ROW_SAMPLE, responses);

  svm = cv::ml::SVM::create();
  svm->setType(cv::ml::SVM::C_SVC);
  svm->setNu(0.05); 
  svm->setKernel(cv::ml::SVM::CHI2);
  svm->setDegree(1.0);
  svm->setGamma(2.0);
  svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));
  svm->train(tdata); 

  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 格式,以便我们可以将其发送到训练函数:

// 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 = cv::ml::SVM::create();
svm->setType(cv::ml::SVM::C_SVC);
svm->setNu(0.05);
svm->setKernel(cv::ml::SVM::CHI2);
svm->setDegree(1.0);
svm->setGamma(2.0);
svm->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 100, 1e-6));

我们现在将定义要使用的 SVM 类型、核函数以及停止学习过程的准则。在我们的案例中,我们将使用最大迭代次数,并在 100 次迭代后停止。有关每个参数及其功能的更多信息,请查看以下链接中的 OpenCV 文档:docs.opencv.org/master/d1/d2d/classcv_1_1ml_1_1SVM.html。在创建设置参数后,我们将通过调用 train 方法并使用 trainingDataMat 和响应矩阵作为 TrainData 对象来创建模型:

  // Train the SVM 
  svm->train(tdata); 

我们使用测试向量(将 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 函数使得同时进行多个预测成为可能,结果是一个矩阵而不是只有一行或向量。预测后,我们只需要计算 testPredict 与我们的 testResponses(原始标签)之间的差异。如果有差异,我们只需要计算有多少个差异,并将其除以测试的总数来计算误差。

我们可以使用新的 TrainData 类来生成特征向量、样本,并将训练数据在测试和训练向量之间分割。

最后,我们将训练数据展示在二维图中,其中Y轴是长宽比特征,X轴是对象面积。每个点都有不同的颜色和形状(十字、正方形和圆形),表示不同的对象类型,我们可以清楚地看到以下图像中的对象组:

图片

现在,我们非常接近完成我们的应用程序示例。在这个阶段,我们已经训练了 SVM 模型;现在我们可以用它来进行分类,以检测新到达的未知特征向量类型。下一步是预测包含未知对象的输入图像。

输入图像预测

现在,我们将解释主要功能,该功能加载输入图像并预测其中出现的对象。我们将使用以下图片作为输入图像。在这里,图像中出现了多个不同的对象。我们没有这些对象的标签或名称,但计算机必须能够识别它们:

图片

与所有训练图像一样,我们必须加载并预处理输入图像,如下所示:

  1. 首先,我们加载并将图像转换为灰度值。

  2. 然后,我们使用preprocessImage函数应用预处理任务(如我们在第五章,自动光学检测对象分割检测)

    Mat pre= preprocessImage(img); 
  1. 现在,我们将使用之前描述的ExtractFeatures函数提取图像中所有出现的对象的特征以及每个对象的最左上角位置:
    // Extract features 
    vector<int> pos_top, pos_left; 
    vector< vector<float> >
    features=ExtractFeatures(pre, &pos_left,     &pos_top); 
  1. 我们将检测到的每个对象存储为一个特征行,然后将每一行转换为包含一行和两个特征的Mat矩阵:
     for(int i=0; i< features.size(); i++){ 
         Mat trainingDataMat(1, 2, CV_32FC1, &features[i][0]);
  1. 然后,我们可以使用我们的StatModel SVM 的predict函数预测单个对象。预测的浮点结果是检测到的对象的标签。然后,为了完成应用程序,我们必须在输出图像上绘制每个检测和分类的对象的标签:
     float result= svm->predict(trainingDataMat); 
  1. 我们将使用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"; 
     } 
  1. 我们还将使用ExtractFeatures函数中检测到的位置在每个对象上绘制标签文本:
     putText(img_output,  
           ss.str(),  
           Point2d(pos_left[i], pos_top[i]),  
           FONT_HERSHEY_SIMPLEX,  
           0.4,  
           color); 
  1. 最后,我们将在输出窗口中绘制我们的结果:
       miw->addImage("Binary image", pre); 
       miw->addImage("Result", img_output); 
       miw->render(); 
       waitKey(0); 

我们应用程序的最终结果显示了一个由四个屏幕组成的窗口。在这里,左上角的图像是输入训练图像,右上角是绘图训练图像,左下角是分析预处理图像的输入图像,右下角是预测的最终结果:

图片

摘要

在本章中,我们学习了机器学习的基础知识,并将其应用于一个小型示例应用。这使得我们能够理解我们可以用来创建自己的机器学习应用的基本技术。机器学习是复杂的,涉及针对每个用例的不同技术(监督学习、无监督学习、聚类等)。我们还学习了如何使用 SVM 创建最典型的机器学习应用,即监督学习应用。监督机器学习中最重要的概念如下:你必须有适当数量的样本或数据集,你必须准确地选择描述我们的对象的特征(有关图像特征的更多信息,请参阅第八章,视频监控背景建模和形态学操作),你必须选择一个给出最佳预测的模型。

如果我们没有得到正确的预测,我们必须检查这些概念中的每一个来找到问题所在。

在下一章中,我们将介绍背景减法方法,这对于视频监控应用非常有用,在这些应用中,背景不提供任何有趣的信息,必须被丢弃,以便我们可以分割图像以检测和分析图像对象。

第七章:检测面部部位并叠加口罩

在第六章“学习对象分类”中,我们学习了对象分类以及如何使用机器学习来实现它。在本章中,我们将学习如何检测和跟踪不同的面部部位。我们将从理解面部检测流程及其构建方式开始讨论。然后,我们将使用这个框架来检测面部部位,如眼睛、耳朵、嘴巴和鼻子。最后,我们将学习如何在实时视频中将这些面部部位叠加有趣的口罩。

到本章结束时,我们应该熟悉以下主题:

  • 理解 Haar 级联

  • 整数图像及其必要性

  • 构建通用的面部检测流程

  • 在实时视频流中检测和跟踪面部、眼睛、耳朵、鼻子和嘴巴

  • 在视频中自动叠加人脸面具、太阳镜和有趣的鼻子

技术要求

本章需要您对 C++ 编程语言有基本的熟悉度。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_07。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频以查看代码的实际应用:

bit.ly/2SlpTK6

理解 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 提供了一个优秀的面部检测框架。我们只需要加载级联文件,并使用它来检测图像中的面部。当我们从摄像头捕获视频流时,我们可以在我们的脸上叠加有趣的口罩。看起来可能像这样:

让我们看看代码的主要部分,看看如何将这个面具叠加到输入视频流中的面部上。完整的代码可以在本书提供的可下载代码包中找到:

#include "opencv2/core/utility.hpp"
#include "opencv2/objdetect/objdetect.hpp"
#include "opencv2/imgproc.hpp"
#include "opencv2/highgui.hpp"

using namespace cv;
using namespace std;

...

int main(int argc, char* argv[]) 
{ 
    string faceCascadeName = argv[1]; 

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

        // Equalize the histogram 
        equalizeHist(frameGray, frameGray); 

        // Detect faces 
        faceCascade.detectMultiScale(frameGray, faces, 1.1, 2, 0|HAAR_SCALE_IMAGE, Size(30, 30) ); 

让我们快速停下来看看这里发生了什么。我们从摄像头读取输入帧并将其调整到我们选择的大小。捕获的帧是一个彩色图像,面部检测是在灰度图像上进行的。因此,我们将其转换为灰度并均衡直方图。为什么我们需要均衡直方图?我们需要这样做来补偿任何问题,例如光照或饱和度。如果图像太亮或太暗,检测效果会较差。因此,我们需要均衡直方图以确保我们的图像具有健康的像素值范围:

        // Draw green rectangle around the face 
        for(auto& face:faces) 
        { 
            Rect faceRect(face.x, face.y, face.width, face.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 = face.x - int(0.1*face.width); 
            int y = face.y - int(0.0*face.height); 
            int w = int(1.1 * face.width); 
            int h = int(1.3 * face.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 previous image to grayscale 
            cvtColor(faceMaskSmall, grayMaskSmall, COLOR_BGR2GRAY); 

            // Threshold the previous image to isolate the pixels associated only with the face mask 
            threshold(grayMaskSmall, grayMaskSmallThresh, 230, 255, THRESH_BINARY_INV); 

我们隔离与面部面具相关的像素。我们希望以这种方式叠加面具,使其看起来不像一个矩形。我们希望叠加对象的精确边界,使其看起来自然。现在让我们叠加面具:

            // Create mask by inverting the previous 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 previously 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文件和掩码图像。你可以使用在resources文件夹下提供的haarcascade_frontalface_alt.xmlfacemask.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 declaration and initialization
    ....
    // Face detection code 
    ....
    vector<Point> centers; 
    ....     
    // Draw green circles around the eyes 
    for( auto& face:faces ) 
    { 
        Mat faceROI = frameGray(face[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(auto& eyes:eyes) 
            { 
                Point center( face.x + eye.x + int(eye.width*0.5), face.y + eye.y + int(eye.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 previous image to grayscale 
            cvtColor(eyeMaskSmall, grayMaskSmall, COLOR_BGR2GRAY); 

            // Threshold the previous image to isolate the foreground object 
            threshold(grayMaskSmall, grayMaskSmallThresh, 245, 255, THRESH_BINARY_INV); 

            // Create mask by inverting the previous 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 previously 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.xmlhaarcascade_mcs_mouth.xmlhaarcascade_mcs_leftear.xmlhaarcascade_mcs_rightear.xml可以用来跟踪不同的面部部位。尝试使用它们,并尝试给自己叠加一个胡须或德古拉耳朵。

摘要

在本章中,我们讨论了 Haar 级联和积分图像。我们了解了人脸检测流程是如何构建的。我们学习了如何在实时视频流中检测和跟踪人脸。我们讨论了如何使用人脸检测框架来检测各种面部部位,如眼睛、耳朵、鼻子和嘴巴。最后,我们学习了如何使用人脸部位检测的结果在输入图像上叠加面具。

在下一章中,我们将学习关于视频监控、背景去除和形态学图像处理的内容。

第八章:视频监控、背景建模和形态学操作

在本章中,我们将学习如何检测从静态摄像机拍摄的视频中的移动对象。这在视频监控系统中被广泛使用。我们将讨论可以用来构建此系统的不同特性。我们将了解背景建模,并了解我们如何使用它来构建实时视频中的背景模型。一旦我们这样做,我们将结合所有模块来检测视频中的感兴趣对象。

到本章结束时,你应该能够回答以下问题:

  • 什么是朴素背景减法?

  • 什么是帧差分?

  • 我们如何构建背景模型?

  • 我们如何识别静态视频中的新对象?

  • 形态学图像处理是什么,它与背景建模有何关系?

  • 我们如何使用形态学算子实现不同的效果?

技术要求

本章需要熟悉 C++编程语言的基础知识。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_08。代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。

查看以下视频,了解代码的实际应用:

bit.ly/2SfqzRo

理解背景减法

背景减法在视频监控中非常有用。基本上,背景减法技术在需要检测静态场景中移动对象的情况下表现得很出色。这对视频监控有什么用?视频监控的过程涉及处理持续的数据流。数据流不断涌入,我们需要分析它以识别任何可疑活动。让我们以酒店大堂为例。所有墙壁和家具都有固定的位置。如果我们构建一个背景模型,我们可以用它来识别大堂中的可疑活动。我们正在利用背景场景保持静态的事实(在这个例子中恰好是真实的)。这有助于我们避免任何不必要的计算开销。正如其名称所示,该算法通过检测和将图像的每个像素分配到两个类别之一,即背景(假设为静态和稳定)或前景,并将其从当前帧中减去以获得前景图像部分,包括移动对象,如人、汽车等。在静态假设下,前景对象将自然对应于在背景前移动的对象或人。

为了检测移动的物体,我们需要建立一个背景模型。这不同于直接帧差分,因为我们实际上是在建模背景并使用这个模型来检测移动的物体。当我们说我们在建模背景时,我们基本上是在构建一个可以用来表示背景的数学公式。这比简单的帧差分技术要好得多。这种技术试图检测场景中的静态部分,然后在背景模型的构建统计公式中包含小的更新。这个背景模型随后用于检测背景像素。因此,它是一种自适应技术,可以根据场景进行调整。

天真背景减法

让我们从开始讨论。背景减法过程是什么样的?考虑以下图片:

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

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

整个过程看起来像这样:

它工作得怎么样?

我们之所以称之为天真的方法,是有原因的!它在理想条件下工作得很好,而且正如我们所知,现实世界中没有什么是理想的。它对计算给定物体的形状做得相当不错,但它在某些约束条件下这样做。这种方法的主要要求之一是物体的颜色和强度应该与背景有足够的差异。影响这类算法的一些因素包括图像噪声、光照条件和相机的自动对焦。

一旦一个新的物体进入我们的场景并停留下来,就很难检测到它前面的新物体。这是因为我们没有更新我们的背景模型,而新的物体现在已经成为我们背景的一部分。考虑以下图片:

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

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

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

在这种方法中,我们假设背景是静态的。如果背景的某些部分开始移动,这些部分将开始被检测为新物体。因此,即使是微小的移动,比如挥动的旗帜,也会在我们的检测算法中引起问题。这种方法对光照变化也很敏感,并且无法处理任何相机移动。不用说,这是一个很微妙的方法!我们需要能够在现实世界中处理所有这些事情的东西。

帧差分

我们知道,我们不能保持一个静态的背景图像模式,这种模式可以用来检测物体。解决这个问题的一种方法是通过使用帧差分。这是我们能够使用的最简单技术之一,用来查看视频中的哪些部分在移动。当我们考虑实时视频流时,连续帧之间的差异提供了大量信息。这个概念相当直接!我们只需计算连续帧之间的差异,并显示它们之间的差异。

如果我快速移动我的笔记本电脑,我们可以看到类似这样的情况:

图片

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

图片

正如你从之前的图像中可以看到,只有视频中的移动部分被突出显示。这为我们提供了一个很好的起点,可以看到视频中的哪些区域在移动。让我们看看计算帧差异的函数:

Mat frameDiff(Mat prevFrame, Mat curFrame, Mat nextFrame)
{
    Mat diffFrames1, diffFrames2, output;

    // Compute absolute difference between current frame and the next
    absdiff(nextFrame, curFrame, diffFrames1);

    // Compute absolute difference between current frame and the previous 
    absdiff(curFrame, prevFrame, diffFrames2);

    // Bitwise "AND" operation between the previous two diff images
    bitwise_and(diffFrames1, diffFrames2, output);

    return output;
}

帧差分相当直接!你计算当前帧和前一帧,以及当前帧和下一帧之间的绝对差异。然后我们应用这些帧差异的位运算AND操作符。这将突出显示图像中的移动部分。如果你只是计算当前帧和前一帧之间的差异,它往往会很嘈杂。因此,我们需要在连续帧差异之间使用位运算 AND 操作符,以便在看到移动物体时获得一些稳定性。

让我们看看可以从网络摄像头中提取并返回一帧的函数:

Mat getFrame(VideoCapture cap, float scalingFactor)
{
    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, COLOR_BGR2GRAY);

    return output;
}

正如我们所见,这相当直接。我们只需要调整帧的大小并将其转换为灰度图。现在我们有了辅助函数,让我们看看主函数,看看它是如何整合在一起的:

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。您还可以查看用于实现这些算法的原始研究论文。

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

如您所见,新物体被正确识别。让我们看看代码的有趣部分(您可以在.cpp文件中获取完整代码):

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

        // Update the MOG2 background model based on the current frame
        pMOG2->apply(frame, fgMaskMOG2);

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

代码中发生了什么?

让我们快速浏览一下代码,看看那里发生了什么。我们使用高斯混合模型来创建一个背景减法对象。这个对象代表了一个模型,当我们遇到来自网络摄像头的新的帧时,它将随时更新。我们初始化了两个背景减法模型——BackgroundSubtractorMOGBackgroundSubtractorMOG2。它们代表了用于背景减法的两种不同算法。第一个指的是 P. KadewTraKuPong和 R. Bowden的论文,标题为《用于带有阴影检测的实时跟踪的改进自适应背景混合模型》。您可以在personal.ee.surrey.ac.uk/Personal/R.Bowden/publications/avbs01/avbs01.pdf查看。第二个指的是 Z. Zivkovic的论文,标题为《用于背景减法的改进自适应高斯混合模型》。您可以在www.zoranz.net/Publications/zivkovic2004ICPR.pdf查看。

我们启动一个无限while循环,并持续从网络摄像头读取输入帧。随着每个帧,我们更新背景模型,如下所示:

pMOG2->apply(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;
}

摘要

在本章中,我们学习了用于背景建模和形态学图像处理的算法。我们讨论了简单的背景减法和其局限性。我们探讨了如何使用帧差分来获取运动信息,以及当我们想要跟踪不同类型的物体时,它可能存在的局限性。这导致了我们对高斯混合模型的讨论。我们讨论了公式以及如何实现它。然后我们讨论了形态学图像处理,它可以用于各种目的,并介绍了不同的操作以展示用例。

在下一章中,我们将讨论目标跟踪以及可以用来实现这一目标的多种技术。

第九章:学习物体跟踪

在上一章中,我们学习了视频监控、背景建模和形态学图像处理。我们讨论了如何使用不同的形态学算子将酷炫的视觉效果应用到输入图像上。在本章中,我们将学习如何在实时视频中跟踪一个物体。我们将讨论可用于跟踪物体的不同物体特性。我们还将了解不同的物体跟踪方法和技巧。物体跟踪在机器人学、自动驾驶汽车、车辆跟踪、体育中的运动员跟踪和视频压缩等领域得到了广泛的应用。

到本章结束时,你将了解以下内容:

  • 如何跟踪特定颜色的物体

  • 如何构建一个交互式物体跟踪器

  • 角点检测器是什么

  • 如何检测用于跟踪的良好特征

  • 如何构建基于光流的特征跟踪器

技术要求

本章需要熟悉 C++编程语言的基础知识。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_09。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频,了解代码的实际应用:

bit.ly/2SidbMc

跟踪特定颜色的物体

为了构建一个好的物体跟踪器,我们需要了解哪些特性可以用来使我们的跟踪既稳健又准确。因此,让我们迈出第一步,看看我们是否可以使用颜色空间信息来提出一个好的视觉跟踪器。需要注意的是,颜色信息对光照条件敏感。在实际应用中,你将不得不进行一些预处理来处理这个问题。但就目前而言,让我们假设有人正在做这件事,而我们正在获得干净的彩色图像。

存在许多不同的色彩空间,选择一个合适的色彩空间将取决于用户所使用的不同应用。虽然 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 的概念实际上很棒且简单。假设我们选择一个感兴趣的区域,并希望我们的物体追踪器追踪该物体。在这个区域中,我们根据颜色直方图选择一些点,并计算空间点的质心。如果质心位于这个区域的中心,我们知道物体没有移动。但如果质心不在这个区域的中心,那么我们知道物体正在某个方向上移动。质心的移动控制着物体移动的方向。因此,我们将物体的边界框移动到新的位置,使新的质心成为这个边界框的中心。因此,这个算法被称为 mean shift,因为均值(质心)在移动。这样,我们就能保持对物体当前位置的更新。

但 mean shift 的问题在于边界框的大小不允许改变。当你将物体移离相机时,物体在人的眼中会显得更小,但 mean shift 不会考虑这一点。边界框的大小在整个追踪过程中保持不变。因此,我们需要使用 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 EVENT_LBUTTONDOWN: 
            originPoint = Point(x,y); 
            selectedRect = Rect(x,y,0,0); 
            selectRegion = true; 
            break; 

        case 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, NORM_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(TermCriteria::EPS | TermCriteria::COUNT, 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, LINE_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 矩阵,然后分析特征值。这到底是什么意思呢?好吧,让我们剖析一下,以便我们更好地理解它。让我们考虑图像中的一个小的区域。我们的目标是确定这个区域中是否有角点。因此,我们考虑所有相邻的区域,并计算我们的区域与所有这些相邻区域之间的强度差异。如果在所有方向上差异都很大,那么我们知道我们的区域中有一个角点。这是实际算法的过度简化,但它涵盖了要点。如果你想要了解背后的数学细节,你可以查看 HarrisStephenswww.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文件中找到完整的代码。这些参数在检测到的点数中起着重要作用。您可以在docs.opencv.org/master/dd/d1a/group__imgproc__feature.html#gac1fc3598018010880e370e2f709b4345查看cornerHarris()的 OpenCV 文档。

我们现在有了所有需要的信息。让我们继续在角周围画圆圈以显示结果:

        // 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。根据您选择的大小,性能会有所不同。从四个开始,并尝试不同的值以查看会发生什么。

Good features to track

哈里斯角检测器在许多情况下表现良好,但仍有改进空间。在哈里斯和斯蒂芬斯发表原始论文后的六年左右,石和托马西提出了更好的方法,并将其命名为Good Features to Track。您可以在以下链接中阅读原始论文:www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf。他们使用不同的评分函数来提高整体质量。使用这种方法,我们可以在给定的图像中找到 N 个最强的角。当我们不想使用图像中的每一个角来提取信息时,这非常有用。正如我们讨论的那样,一个好的兴趣点检测器在目标跟踪、目标识别和图像搜索等应用中非常有用。

如果您将石-托马西角检测器应用于图像,您将看到类似这样的结果:

如我们所见,框架中的所有重要点都被捕捉到了。让我们看看代码来跟踪这些特征:

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 circles 
        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, Size(-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 算法

Gunnar Farneback 提出了这个光流算法,并且它被用于密集跟踪。密集跟踪在机器人技术、增强现实和 3D 制图中被广泛使用。你可以在以下链接查看原始论文:www.diva-portal.org/smash/get/diva2:273847/FULLTEXT01.pdf。Lucas-Kanade 方法是一种稀疏技术,这意味着我们只需要处理整个图像中的一些像素。而 Farneback 算法则是一种密集技术,它要求我们处理给定图像中的所有像素。因此,显然这里有一个权衡。密集技术更准确,但速度较慢。稀疏技术准确性较低,但速度较快。对于实时应用,人们往往更倾向于使用稀疏技术。对于时间和复杂度不是主要因素的场合,人们往往更倾向于使用密集技术来提取更精细的细节。

在他的论文中,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 来识别我的文本?

技术要求

本章需要熟悉基本的 C++ 编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_10。代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。

观看以下视频,看看代码的实际应用:

bit.ly/2KIoJFX

介绍光学字符识别

在图像中识别文本是计算机视觉中一个非常流行的应用。这个过程通常被称为 光学字符识别,并分为以下几部分:

  • 文本预处理和分割:在这个步骤中,计算机必须处理图像噪声、旋转(倾斜)并识别哪些区域是候选文本区域。

  • 文本识别:这是识别文本中每个字母的过程。尽管这也是一个计算机视觉话题,但在这本书中,我们不会仅使用 OpenCV 展示如何做到这一点。相反,我们将向您展示如何使用 Tesseract 库来完成这一步,因为它集成在 OpenCV 3.0 中。如果您对学习如何自己完成 Tesseract 所做的工作感兴趣,请查看 Packt 的 Mastering OpenCV 书籍,其中有一章介绍车牌识别。

预处理和分割阶段可能会因文本来源而大相径庭。让我们看看预处理通常进行的常见情况:

  • 使用扫描仪生产 OCR 应用: 这是一个非常可靠的文本来源。在这种情况下,图像的背景通常是白色的,文档几乎与扫描仪的边缘对齐。正在扫描的内容基本上是文本,几乎没有任何噪声。这类应用依赖于简单的预处理技术,可以快速调整文本并保持快速的扫描速度。在编写生产 OCR 软件时,通常会将重要文本区域的识别委托给用户,并创建一个用于文本验证和索引的质量管道。

  • 扫描随意拍摄的图片或视频中的文本: 这是一个更加复杂的场景,因为没有指示文本可能的位置。这种情况被称为场景文本识别,OpenCV 4.0 包含一个用于处理此问题的 contrib 库。我们将在第十一章,使用 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, COLOR_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特征这样的纹理特征,通常使用小波变换。另一种选择是在此任务中识别最大稳定极值区域MSER)。这种方法对于复杂背景中的文本更稳健,将在第十一章“使用 Tesseract 进行文本识别”中研究。您可以在 Haralick 特征的官方网站上了解更多信息,网址为haralick.org/journals/TexturalFeatures.pdf

创建连通区域

如果你仔细观察图像,你会注意到字母总是成块地在一起,由文本段落形成。这让我们提出了一个问题,我们如何检测和移除这些块?

第一步是使这些块更加明显。我们可以通过使用膨胀形态学算子来实现这一点。回想一下第八章,视频监控背景建模和形态学操作,膨胀会使图像元素变厚。让我们看看一个小代码片段,它完成了这个任务:

auto 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 (const 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;      
   auto 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 
   auto 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 仿射变换矩阵。我们通过使用 getRotationMatrix2D OpenCV 函数来实现这一点。这个函数有三个参数:

  • 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 函数完成。此函数在图像周围添加边框。这很重要,因为分类阶段通常期望文本周围有边距。函数参数非常简单:输入和输出图像、顶部、底部、左侧和右侧的边框厚度、边框类型以及新边框的颜色。

对于卡片图像,将生成以下图像:

现在,是时候将每个函数组合在一起了。让我们展示以下主要方法:

  • 加载票据图像

  • 调用我们的二值化函数

  • 查找所有文本区域

  • 在窗口中显示每个区域

我们将如下展示主要方法:

int main(int argc, char* argv[])  
{ 
   //Loads the ticket image and binarize it 
   auto ticket = binarize(imread("ticket.png"));     
   auto regions = findTextAreas(ticket); 

   //For each region 
   for (const 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 使用 C++ 归档网络CPPAN)作为其依赖项管理器。要安装 Tesseract,请按照以下步骤操作。

构建最新库

  1. cppan.org/client/ 下载最新的 CPPAN 客户端。

  2. 在命令行中运行

    cppan --build pvt.cppan.demo.google.tesseract.tesseract-master

在 Visual Studio 中设置 Tesseract

  1. github.com/Microsoft/vcpkg设置vcpkg,Visual C++包管理器。

  2. 对于 64 位编译,使用vcpkg install tesseract:x64-windows。你也可以为 master 分支添加--head

静态链接

还可以在你的项目中静态链接{Tesseract}(github.com/tesseract-ocr/tesseract/wiki/Compiling#static-linking)。这将避免dlls与你的可执行文件一起打包。为此,使用与之前相同的vcpkg,对于 32 位安装使用以下命令:

vcpkg install tesseract:x86-windows-static

或者,你可以使用以下命令进行 64 位安装:

vckpg install tesseract:x64-windows-static

在 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 参数进行更细粒度的控制。这种集成将在第十一章文本识别与 Tesseract 中进行研究。

创建一个 OCR 函数

我们将修改之前的示例以使用 Tesseract。首先,将tesseract/baseapi.hfstream添加到include列表中:

#include opencv2/opencv.hpp; 
#include tesseract/baseapi.h; 

#include vector; 
#include fstream; 

然后,我们将创建一个全局的TessBaseAPI对象,它代表我们的 Tesseract OCR 引擎:

tesseract::TessBaseAPI ocr; 

ocr引擎是完全自包含的。如果你想创建一个多线程的 OCR 软件,只需在每个线程中添加一个不同的TessBaseAPI对象,执行将相当线程安全。你只需要确保文件写入不是在同一个文件上,否则你需要确保这个操作的安全性。

接下来,我们将创建一个名为识别文本(identifyText)的函数,该函数将运行ocr

const char* identifyText(Mat input, const 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); 

   const char* text = ocr.GetUTF8Text(); 
   cout  "Text:"  endl; 
   cout  text  endl; 
   cout  "Confidence: "  ocr.MeanTextConf() 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将加载英语和葡萄牙语。当然,您只能使用您之前安装的语言,否则加载过程将失败。语言配置文件可能指定必须一起加载两种或多种语言。为了防止这种情况,您可以使用波浪号~。例如,您可以使用hin+~eng来确保即使配置了这样做,也不会将英语与印地语一起加载。

  • OcrEngineMode: 这些是将要使用的 OCR 算法。它可以有以下值之一:

    • OEM_TESSERACT_ONLY: 仅使用tesseract。这是最快的方法,但精度较低。

    • OEM_CUBE_ONLY: 使用 Cube 引擎。它较慢,但更精确。这仅在您的语言被训练以支持此引擎模式时才会工作。要检查是否如此,请查看tessdata文件夹中您语言的.cube文件。对英语语言的支持是保证的。

    • OEM_TESSERACT_CUBE_COMBINED: 这结合了 Tesseract 和 Cube 以实现最佳的 OCR 分类。此引擎具有最佳精度和最慢的执行时间。

    • OEM_DEFAULT: 这基于语言配置文件、命令行配置文件推断策略,如果两者都不存在,则使用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 已经实现了倾斜校正和文本分割算法,就像大多数 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 返回一个置信度指数,可能是一个从0100的数字:

   auto text = ocr.GetUTF8Text(); 
   cout  "Text:"  endl; 
   cout  text  endl; 
   cout  "Confidence: "  ocr.MeanTextConf()  endl; 

将输出发送到文件

让我们将主方法更改为将识别输出发送到文件。我们通过使用标准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 (const auto& region : regions) { 
         //Crop  
         auto cropped = deskewAndCrop(ticket, region); 
         auto 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 4.0 文本模块,该模块专门用于场景文本检测。使用此 API,可以检测出现在网络摄像头视频中的文本,或分析照片图像(如街景或监控摄像头拍摄的图像)以实时提取文本信息。这允许创建广泛的应用程序,从无障碍、营销甚至机器人领域。

到本章结束时,你将能够做到以下几件事情:

  • 理解场景文本识别是什么

  • 理解文本 API 的工作原理

  • 使用 OpenCV 4.0 文本 API 检测文本

  • 将检测到的文本提取到图像中

  • 使用文本 API 和 Tesseract 集成来识别字母

技术要求

本章需要熟悉基本的 C++ 编程语言。本章中使用的所有代码都可以从以下 GitHub 链接下载:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_11。代码可以在任何操作系统上执行,尽管它仅在 Ubuntu 上进行了测试。

查看以下视频,看看代码的实际应用:

bit.ly/2Slht5A

文本 API 的工作原理

文本 API 实现了在 2012 年 计算机视觉和模式识别CVPR)会议期间由 Lukás NeumannJiri Matas 在文章 Real-Time Scene Text Localization and Recognition 中提出的算法。该算法在场景文本检测方面取得了显著进步,在 CVPR 数据库以及 Google Street View 数据库中都实现了最先进的检测。在使用 API 之前,让我们看看这个算法在幕后是如何工作的,以及它是如何解决场景文本检测问题的。

记住:OpenCV 4.0 文本 API 不包含在标准 OpenCV 模块中。它是一个存在于 OpenCV contrib 包中的附加模块。如果你使用 Windows 安装程序安装了 OpenCV,你应该回顾一下第一章,开始使用 OpenCV;这将指导你如何安装这些模块。

场景检测问题

检测场景中随机出现的文本是一个比看上去更难的问题。当你将检测到的文本与已识别的扫描文本进行比较时,需要考虑以下几个新变量:

  • 三维性:文本可以以任何比例、方向或透视出现。此外,文本可能部分遮挡或中断。实际上有成千上万的可能性,文本可能出现在图像中的任何区域。

  • 多样性:文本可以有多种不同的字体和颜色。字体可能有轮廓边框。背景可以是深色、浅色或复杂的图像。

  • 光照和阴影:阳光的位置和外观颜色会随时间变化。不同的天气条件,如雾或雨,可以产生噪声。即使在封闭空间中,光照也可能成为问题,因为光线会在彩色物体上反射并击中文本。

  • 模糊:文本可能出现在镜头自动对焦不优先考虑的区域。在移动相机、透视文本或雾的存在下,模糊也很常见。

以下图片,来自谷歌街景,说明了这些问题。注意这些情况如何在单一图像中同时发生:

图片

对此类情况进行文本检测可能计算成本高昂,因为有 2^n 个像素子集,其中 n 是图像中的像素数量。

为了降低复杂性,通常采用两种策略:

  • 使用滑动窗口搜索图像矩形的子集:这种策略只是将子集的数量减少到更小的数量。区域的数量根据考虑的文本复杂性而变化。仅处理文本旋转的算法可能使用较小的值,而同时处理旋转、倾斜、透视等的算法则可能使用较大的值。这种方法的优点在于其简单性,但它们通常仅限于较窄的字体范围,并且通常限于特定单词的词汇表。

  • 使用连通分量分析:这种方法假设像素可以分组到具有相似属性的区域内。这些区域更有可能被识别为字符。这种方法的优点是它不依赖于多个文本属性(方向、缩放、字体等),并且它们还提供了一个可以用于裁剪文本到 OCR 的分割区域。这是我们第十章中使用的方法,开发文本识别的分割算法。光照也可能影响结果,例如,如果字母上投下阴影,会形成两个不同的区域。然而,由于场景检测通常用于移动车辆(例如,无人机或汽车)和视频中,由于这些光照条件会逐帧变化,文本最终会被检测到。

OpenCV 4.0 算法通过执行连通分量分析和搜索极端区域来使用第二种策略。

极端区域

极端区域是具有几乎均匀强度且被对比度背景包围的连通区域。一个区域的不变性可以通过计算该区域对阈值变化的抵抗程度来衡量。这种变化可以通过一个简单的算法来测量:

  1. 应用阈值,生成一个图像,A。检测其连通像素区域(极端区域)。

  2. 将阈值增加一个 delta 数量,生成一个图像,B。检测其连通像素区域(极端区域)。

  3. 将图像 BA 进行比较。如果图像 A 中的区域与图像 B 中相同的区域相似,则将其添加到树中的同一分支。相似性的标准可能因实现而异,但通常与图像面积或一般形状有关。如果图像 A 中的区域在图像 B 中看起来被分割,则在树中为新的区域创建两个新的分支,并将其与上一个分支关联。

  4. 将集合 A 设置为 B 并返回步骤 2,直到应用最大阈值。

这将按照以下方式组装一个区域树:

图片

对抗变化的能力是通过计算同一级别的节点数量来确定的。通过分析这个树,还可以确定最大稳定极端区域MSERs),即在广泛的各种阈值下面积保持稳定的区域。在之前的图中,很明显这些区域将包含字母 ONY。最大极端区域的缺点是它们在模糊存在时较弱。OpenCV 在 feature2d 模块中提供了一个 MSER 特征检测器。极端区域很有趣,因为它们对光照、尺度和方向具有很强的不变性。它们也是文本的良好候选者,因为它们对字体类型也不敏感,即使字体有样式。每个区域也可以进行分析以确定其边界椭圆,并具有如仿射变换和面积等数值确定的属性。最后,值得一提的是,整个过程非常快,这使得它非常适合实时应用。

极端区域过滤

虽然 MSERs 是定义哪些极端区域值得工作的常见方法,但NeumannMatas算法采用不同的方法,通过将所有极端区域提交给一个为字符检测而训练的顺序分类器。这个分类器在两个不同的阶段工作:

  1. 第一阶段逐步计算每个区域的描述符(边界框、周长、面积和欧拉数)。这些描述符被提交给一个分类器,该分类器估计该区域成为字母表中字符的可能性。然后,只选择高概率的区域进入第二阶段。

  2. 在这个阶段,计算整个区域比率、凸包比率和外部边界拐点数量。这提供了更多详细的信息,使分类器能够丢弃非文本字符,但它们的计算速度也慢得多。

在 OpenCV 中,这个过程是通过一个名为ERFilter的类实现的。也可以使用不同的图像单通道投影,例如RGB、亮度或灰度转换来提高字符识别率。最后,所有字符都必须被分组到文本块中(如单词或段落)。OpenCV 3.0 为此提供了两个算法:

  • 剪枝穷举搜索:2011 年由Mattas提出,这个算法不需要任何先前的训练或分类,但仅限于水平对齐的文本

  • 面向文本的分层方法:这处理任何方向的文本,但需要一个训练好的分类器

注意,由于这些操作需要分类器,因此还需要提供训练集作为输入。OpenCV 4.0 在以下示例包中提供了一些这些训练集:github.com/opencv/opencv_contrib/tree/master/modules/text/samples

这也意味着这个算法对分类器训练中使用的字体敏感。

以下视频展示了该算法的演示,由 Neumann 本人提供:www.youtube.com/watch?v=ejd5gGea2Fo&feature=youtu.be。一旦文本被分割,只需将其发送到 OCR 如 Tesseract,类似于我们在第十章,开发用于文本识别的分割算法中所做的那样。唯一的区别是现在我们将使用 OpenCV 文本模块类与 Tesseract 接口,因为它们提供了一种封装我们使用特定 OCR 引擎的方法。

使用文本 API

理论已经足够。现在是时候看看文本模块在实际中是如何工作的了。让我们研究一下我们如何使用它来进行文本检测、提取和识别。

文本检测

让我们首先创建一个简单的程序,这样我们就可以使用ERFilters进行文本分割。在这个程序中,我们将使用文本 API 示例中的训练分类器。您可以从 OpenCV 仓库中下载这些示例,但它们也包含在这本书的配套代码中。

首先,我们开始包括所有必要的libsusings

#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(const 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: 一个Mats向量,其中将填充结果通道。

  • mode: 定义将计算哪些通道。可以使用两个可能的值:

    • ERFILTER_NM_RGBLGrad: 表示算法是否将使用 RGB 颜色、亮度和梯度幅度作为通道(默认)

    • ERFILTER_NM_IHSGrad: 表示图像是否将根据其强度、色调、饱和度和梯度幅度进行分割

我们还添加了向量中所有颜色成分的负值。由于图像将具有三个不同的通道(RGB),这通常就足够了。也可以添加非反转通道,就像我们处理去灰度图像时做的那样,但最终我们会得到六个通道,这可能会很费计算机资源。当然,您可以自由地测试您的图像,看看这是否能带来更好的结果。最后,如果提供了另一种类型的图像,函数将以错误消息终止程序。

负值被添加,因此算法将覆盖暗背景中的亮文本和亮背景中的暗文本。添加梯度幅度的负值没有意义。

让我们继续主方法。我们将使用此程序来分割easel.png图像,该图像与源代码一起提供:

图片

这张照片是在我走在街上时用手机摄像头拍摄的。让我们编写代码,以便您也可以通过在第一个程序参数中提供其名称来轻松地使用不同的图像:

int main(int argc, const char * argv[]) 
{ 
   const char* image = argc < 2 ? "easel.png" : argv[1];     
   auto input = imread(image); 

接下来,我们将调用separateChannels函数将图像转换为灰度并分离其通道:

   Mat processed; 
   cvtColor(input, processed, COLOR_RGB2GRAY); 

   auto channels = separateChannels(processed); 

如果你想处理彩色图像中的所有通道,只需将此代码片段的前两行替换为以下内容:

Mat processed = input;

我们需要分析六个通道(RGB 和反转),而不是两个(灰度和反转)。实际上,处理时间将增加得比我们可以获得的改进更多。有了这些通道,我们需要为算法的两个阶段创建ERFilters。幸运的是,OpenCV 文本贡献模块提供了相应的函数:

// 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执行),此参数必须包含第一阶段选定的区域。这些区域将在第二阶段进行处理和过滤。

最后,我们释放这两个过滤器,因为程序中不再需要它们。最终的分割步骤是将所有 ER 区域分组到可能的单词中,并定义它们的边界框。这是通过调用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> > &regions, 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:默认值。这仅通过穷举搜索生成具有水平方向文本的组,正如最初由NeumannMatas提出的。

    • 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 (const auto& rect : groupRects) 
    rectangle(input, rect, Scalar(0, 255, 0), 3); 

imshow("grouping",input); 
waitKey(0);

这个程序输出以下结果:

你可以在detection.cpp文件中查看整个源代码。

虽然大多数 OpenCV 文本模块函数都编写为支持灰度和彩色图像作为其输入参数,但在撰写本书时,存在一些错误阻止我们在erGrouping等函数中使用灰度图像。有关更多信息,请查看以下 GitHub 链接:github.com/Itseez/opencv_contrib/issues/309总是记住,OpenCV contrib 模块包不如默认的 OpenCV 包稳定。

文本提取

现在我们已经检测到了区域,我们必须在提交给 OCR 之前裁剪文本。我们可以简单地使用像getRectSubpixMat::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: 输入图像。我们将使用包含极值区域的通道图像。这是函数通常执行洪水填充的地方,除非提供了FLOODFILL_MASK_ONLY。在这种情况下,图像保持不变,绘制发生在遮罩中。这正是我们将要做的。

  • mask:掩码必须是一个比输入图像大两行两列的图像。当洪水填充绘制像素时,它会验证掩码中相应的像素是否为零。如果是这样,它将绘制并标记此像素为 1(或传递到标志中的另一个值)。如果像素不是零,则洪水填充不会绘制像素。在我们的情况下,我们将提供一个空白掩码,以便在掩码中绘制每个字母。

  • seedPoint:起始点。它类似于你想要使用图形应用程序的工具时点击的位置。

  • newVal:重新绘制像素的新值。

  • loDiffupDiff:这些参数表示正在处理的像素与其邻居之间的上下差异。如果邻居落在这个范围内,它将被着色。如果使用了 FLOODFILL_FIXED_RANGE 标志,则将使用种子点和正在处理的像素之间的差异。

  • rect:这是一个可选参数,它限制了洪水填充将应用到的区域。

  • flags:这个值由一个位掩码表示:

    • 标志的最低 8 位包含一个连通性值。4 的值表示将使用所有四个边缘像素,而 8 的值将表示必须考虑对角像素。我们将为此参数使用 4

    • 接下来的 8 到 16 位包含一个从 1255 的值,用于填充掩码。由于我们想要用白色填充掩码,我们将使用 255 << 8 来表示这个值。

    • 有两个额外的位可以通过添加 FLOODFILL_FIXED_RANGEFLOODFILL_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];         
   auto er = regions[idx][group[g][1]]; 

//Ignore root region 
   if (er.parent == NULL)  
         continue; 

现在,我们可以从 ERStat 对象中读取像素坐标。它由像素编号表示,从上到下,从左到右计数。这个线性索引必须使用类似于我们在第二章中看到的公式转换为行(y)和列(z)表示法,《OpenCV 基础知识简介》

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 执行方法本身提供了一个通用接口。具体的实现必须从这个类继承。默认情况下,文本模块提供了三种不同的实现:OCRTesseractOCRHMMDecoderOCRBeamSearchDecoder

以下是一个类图,展示了这个层次结构:

图片

采用这种方法,我们可以将创建 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>。现在,让我们看看高亮显示的代码。它调用create方法来初始化一个 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 将加载英语和葡萄牙语。当然,您只能使用之前已安装的语言,否则加载将失败。语言 config 文件可能指定必须一起加载两个或多个语言。为了防止这种情况,您可以使用波浪号 ~。例如,您可以使用 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: 根据语言配置文件、命令行配置文件推断策略,如果两者都不存在,则使用 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 目录来使用常量名称,而不是直接插入它们的值。最后一步是在我们的主函数中添加文本检测。为此,只需将以下代码添加到主方法的末尾:

auto ocr = initOCR("tesseract"); 
for (int i = 0; i < groups.size(); i++)  
{ 
     auto wordImage = drawER(channels, regions, groups[i],  
     groupRects[i]); 

     string word; 
     ocr->run(wordImage, word); 
     cout << word << endl; 
}

在这段代码中,我们首先调用我们的initOCR方法来创建一个 Tesseract 实例。请注意,如果我们选择不同的 OCR 引擎,剩余的代码将不会改变,因为BaseOCR类保证了运行方法的签名。接下来,我们遍历每个检测到的ERFilter组。由于每个组代表不同的单词,我们将执行以下操作:

  1. 调用之前创建的drawER函数来创建包含单词的图像。

  2. 创建一个名为word的文本字符串,并调用run函数来识别单词图像。识别出的单词将被存储在字符串中。

  3. 在屏幕上打印文本字符串。

让我们来看看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:如果提供,这个向量将被填充为检测到的每个组件的文本字符串。

  • component_confidences:如果提供,该向量将被填充为浮点数,包含每个组件的置信度值。

  • component_level:定义了一个组件是什么。它可能有OCR_LEVEL_WORD(默认值)或OCR_LEVEL_TEXT_LINE的值。

如果需要,你可能会更喜欢在run()方法中将组件级别更改为单词或行,而不是在create()函数的psmode参数中做同样的事情。这是更可取的,因为run方法将由任何决定实现BaseOCR类的 OCR 引擎支持。始终记住,create()方法是在设置供应商特定配置的地方。

这是程序的最终输出:

尽管与&符号有些小混淆,但每个单词都被完美识别。你可以检查本章代码文件中的ocr.cpp文件中的整个源代码。

摘要

在本章中,我们了解到场景文本识别比处理扫描文本的 OCR 情况要困难得多。我们研究了文本模块如何使用 NewmannMatas 算法通过极值区域识别来解决这个问题。我们还看到了如何使用这个 API 和 floodFill 函数来从图像中提取文本并将其提交给 Tesseract OCR。最后,我们研究了 OpenCV 文本模块如何与 Tesseract 和其他 OCR 引擎集成,以及我们如何使用其类来识别图像中的文字。

在下一章中,您将了解 OpenCV 中的深度学习。您将通过使用 你只看一次YOLO)算法来学习物体检测和分类。

第十二章:使用 OpenCV 进行深度学习

深度学习是机器学习的一种最先进的形式,在图像分类和语音识别方面正达到其最佳精度。深度学习还应用于其他领域,如机器人和增强学习的人工智能。这正是 OpenCV 致力于在其核心中包含深度学习的主要原因。我们将学习 OpenCV 深度学习接口的基本用法,并探讨在以下两个用例中使用它们:目标检测和面部检测。

在本章中,我们将学习深度学习的基础知识,并了解如何在 OpenCV 中使用它。为了达到我们的目标,我们将学习使用你只看一次YOLO)算法进行目标检测和分类。

本章将涵盖以下主题:

  • 什么是深度学习?

  • OpenCV 如何与深度学习协同工作以及实现深度学习神经网络NNs)

  • YOLO - 一种非常快的深度学习目标检测算法

  • 使用单次检测器进行面部检测

技术要求

为了轻松跟随本章内容,您需要安装已编译深度学习模块的 OpenCV。如果您没有这个模块,您将无法编译和运行示例代码。

拥有一个具有 CUDA 支持的 NVIDIA GPU 非常有用。您可以在 OpenCV 上启用 CUDA 以提高训练和检测的速度。

最后,您可以从此处下载本章使用的代码:github.com/PacktPublishing/Learn-OpenCV-4-By-Building-Projects-Second-Edition/tree/master/Chapter_12

查看以下视频,看看代码的实际应用:

bit.ly/2SmbWf7

深度学习简介

现在,关于图像分类和语音识别,深度学习通常在科学论文中讨论。这是一个基于传统神经网络并受到大脑结构启发的机器学习子领域。为了理解这项技术,了解神经网络是什么以及它是如何工作的非常重要。

神经网络是什么?我们如何从数据中学习?

神经网络受到大脑结构的启发,其中多个神经元相互连接,形成一个网络。每个神经元有多个输入和多个输出,就像生物神经元一样。

这个网络是分层分布的,每一层都包含一些与前一层的所有神经元相连的神经元。它总是有一个输入层,通常由描述输入图像或数据的特征组成,以及一个输出层,通常由我们的分类结果组成。其他中间层被称为隐藏层。以下图展示了一个基本的具有三层神经网络的示例,其中输入层包含三个神经元,输出层包含两个神经元,隐藏层包含四个神经元:

图片

神经元是神经网络的基本元素,它使用一个简单的数学公式,我们可以在以下图中看到:

图片

如我们所见,对于每个神经元,i,我们通过权重(wi1wi2...)将所有前一个神经元的输出(即神经元i的输入x1x2...)相加,并加上一个偏置值,然后将结果作为激活函数f的参数。最终结果是i神经元的输出:

图片

在经典神经网络中最常见的激活函数(f)是 Sigmoid 函数或线性函数。Sigmoid 函数使用得最为频繁,其形式如下:

图片

但我们如何通过这个公式和这些连接来学习神经网络?我们如何对输入数据进行分类?如果我们知道期望的输出,神经网络的学习算法可以称为监督学习;在学习过程中,输入模式被提供给网络的输入层。最初,我们将所有权重设置为随机数,并将输入特征送入网络,检查输出结果。如果这是错误的,我们必须调整网络的所有权重以获得正确的输出。这个算法被称为反向传播。如果您想了解更多关于神经网络如何学习的信息,请参阅neuralnetworksanddeeplearning.com/chap2.htmlyoutu.be/IHZwWFHWa-w

现在我们对神经网络及其内部架构有了简要的介绍,我们将探讨神经网络与深度学习之间的区别。

卷积神经网络

深度学习神经网络与经典神经网络有相同的背景。然而,在图像分析的情况下,主要区别在于输入层。在经典机器学习算法中,研究人员必须识别出定义图像目标的最佳特征来进行分类。例如,如果我们想要对数字进行分类,我们可以从每个图像中提取数字的边缘和线条,测量图像中对象的面积,所有这些特征都是神经网络的输入,或者任何其他机器学习算法的输入。然而,在深度学习中,你不必探索特征是什么;相反,你直接将整个图像作为神经网络的输入。深度学习可以学习最重要的特征,深度神经网络DNN)能够检测图像或输入并识别它。

要了解这些特征是什么,我们使用深度学习和神经网络中最重要的一层:卷积层。卷积层的工作原理类似于卷积算子,其中核滤波器应用于整个前一层,为我们提供一个新的滤波图像,就像 sobel 算子:

图片

然而,在卷积层中,我们可以定义不同的参数,其中之一是应用于前一层或图像的滤波器数量和大小。这些滤波器在学习步骤中计算,就像经典神经网络中的权重一样。这是深度学习的魔力:它可以从标记图像中提取最显著的特征。

然而,这些卷积层是“深度”名称背后的主要原因,我们将在以下基本示例中看到原因。想象一下,我们有一个 100 x 100 的图像。在经典神经网络中,我们将从输入图像中提取我们可以想象的最相关特征。这通常大约是 1,000 个特征,并且随着每个隐藏层的增加或减少,这个数字可以增加或减少,但计算其权重的神经元数量在普通计算机中是合理的。然而,在深度学习中,我们通常开始应用一个卷积层——带有 64 个 3 x 3 大小的滤波器核。这将生成一个包含 100 x 100 x 64 个神经元的新层,需要计算 3 x 3 x 64 个权重。如果我们继续添加更多层,这些数字会迅速增加,需要巨大的计算能力来学习我们深度学习架构的良好权重和参数。

卷积层是深度学习架构中最重要方面之一,但还有其他重要的层,例如池化dropout展平softmax。在下面的图中,我们可以看到一个基本的深度学习架构,其中堆叠了一些卷积和池化层:

图片

然而,还有一件非常重要的事情使得深度学习能够取得最佳结果:标注数据的数量。如果你有一个小数据集,深度学习算法将无法帮助你进行分类,因为学习特征(你的深度学习架构的权重和参数)的数据不足。然而,如果你有大量的数据,你会得到非常好的结果。但请注意,你需要大量的时间来计算和学习你架构的权重和参数。这就是为什么深度学习在早期过程中没有被使用,因为计算需要大量的时间。然而,多亏了新的并行架构,例如 NVIDIA GPU,我们可以优化学习反向传播并加快学习任务。

OpenCV 中的深度学习

深度学习模块在 OpenCV 3.1 版本中被引入作为一个贡献模块。这被移动到 OpenCV 的一部分在 3.3 版本中,但它直到版本 3.4.3 和 4 才被开发者广泛采用。

OpenCV 仅实现深度学习用于推理,这意味着你不能在 OpenCV 中创建自己的深度学习架构进行训练;你只能导入一个预训练模型,在 OpenCV 库下执行它,并使用它作为前馈(推理)来获得结果。

实现前馈方法最重要的原因是优化 OpenCV 以加快计算时间和推理性能。不实现反向方法的其他原因是为了避免浪费时间开发其他库,如 TensorFlow 或 Caffe,已经专业化的东西。因此,OpenCV 创建了导入最重要的深度学习库和框架的导入器,以便能够导入预训练模型。

然后,如果你想在 OpenCV 中使用新的深度学习模型,你首先必须使用 TensorFlow、Caffe、Torch、DarkNet 框架或可以用来导出你模型为开放神经网络交换ONNX)格式的框架来创建和训练它。使用这个框架创建模型可能很简单或很复杂,这取决于你使用的框架,但本质上你必须堆叠多个层,就像我们在之前的图中做的那样,设置 DNN 所需的参数和函数。如今,有其他工具可以帮助你创建模型而无需编码,例如www.tensoreditor.comlobe.ai。TensorEditor 允许你下载从可视化设计架构生成的 TensorFlow 代码,以便在计算机或云中训练。在下面的屏幕截图中,我们可以看到 TensorEditor:

图片

当你的模型训练完成并且你对结果感到满意时,你可以直接将其导入 OpenCV 以预测新的输入图像。在下一节中,你将看到如何在 OpenCV 中导入和使用深度学习模型。

YOLO – 实时目标检测

要了解如何在 OpenCV 中使用深度学习,我们将展示一个基于 YOLO 算法的对象检测和分类的示例。这是最快的目标检测和识别算法之一,在 NVIDIA Titan X 上大约可以以 30 fps 的速度运行。

YOLO v3 深度学习模型架构

在经典计算机视觉中,常见的对象检测使用滑动窗口来检测对象,通过不同窗口大小和比例扫描整个图像。这里的主要问题是扫描图像多次以找到对象所消耗的大量时间。

YOLO 通过将图划分为 S x S 网格来采用不同的方法。对于每个网格,YOLO 检查 B 个边界框,然后深度学习模型提取每个补丁的边界框、包含可能对象的置信度以及每个边界框在训练数据集中每个类别的置信度。以下截图显示了 S x S 网格:

YOLO 使用每个网格 19 个和 5 个边界框进行训练,共 80 个类别。然后,输出结果为 19 x 19 x 425,其中 425 来自边界框(x, y, 宽度,高度)、对象置信度和 80 个类别的置信度乘以每个网格的边界框数量;5_bounding boxes**(x,y,w,h,object_confidence, classify_confidence[80])=5**(4 + 1 + 80):

YOLO v3 架构基于 DarkNet,包含 53 层网络,YOLO 再增加 53 层,总共 106 层网络层。如果您需要一个更快的架构,可以查看版本 2 或 TinyYOLO 版本,它们使用更少的层。

YOLO 数据集、词汇表和模型

在我们将模型导入 OpenCV 代码之前,我们必须通过 YOLO 网站获取它:pjreddie.com/darknet/yolo/。该网站提供了基于COCO数据集的预训练模型文件,该数据集包含 80 个对象类别,例如人、雨伞、自行车、摩托车、汽车、苹果、香蕉、计算机和椅子。

要获取所有类别的名称和用于可视化的用途,请查看github.com/pjreddie/darknet/blob/master/data/coco.names?raw=true

名称的顺序与深度学习模型置信度的结果相同。如果您想通过类别查看 COCO 数据集的一些图像,可以探索数据集cocodataset.org/#explore,并下载其中一些来测试我们的示例应用程序。

要获取模型配置和预训练权重,您必须下载以下文件:

现在我们准备开始将模型导入到 OpenCV 中。

将 YOLO 导入到 OpenCV

深度学习 OpenCV 模块位于opencv2/dnn.hpp头文件下,我们必须将其包含在我们的源头文件和cv::dnn 命名空间中。

然后,我们的 OpenCV 头文件必须看起来像这样:

...
#include <opencv2/core.hpp>
#include <opencv2/dnn.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
using namespace cv;
using namespace dnn;
...

我们首先要做的是导入 COCO 名称的词汇表,该词汇表位于coco.names文件中。此文件是一个纯文本文件,每行包含一个类别,并且按照置信度结果的相同方式排序。然后我们将读取此文件的每一行,并将其存储在一个名为classes的字符串向量中:

...
 int main(int argc, char** argv)
 {
     // Load names of classes
     string classesFile = "coco.names";
     ifstream ifs(classesFile.c_str());
     string line;
     while (getline(ifs, line)) classes.push_back(line);
     ...

现在我们将导入深度学习模型到 OpenCV。OpenCV 实现了深度学习框架(如 TensorFlow 和 DarkNet)最常用的读取器/导入器,并且它们都有类似的语法。在我们的案例中,我们将使用权重导入 DarkNet 模型,并使用readNetFromDarknet OpenCV 函数:

...
 // Give the configuration and weight files for the model
 String modelConfiguration = "yolov3.cfg";
 String modelWeights = "yolov3.weights";
// Load the network
Net net = readNetFromDarknet(modelConfiguration, modelWeights);
...

现在我们处于读取图像并将深度神经网络发送到推理的位置。首先,我们必须使用imread函数读取一个图像,并将其转换为可以读取DotNetNukeDNN)的张量/数据块。为了从图像创建数据块,我们将使用blobFromImage函数,通过传递图像。此函数接受以下参数:

  • image:输入图像(具有 1、3 或 4 个通道)。

  • blob:输出mat

  • scalefactor:图像值的乘数。

  • size:输出数据块所需的空间大小,作为 DNN 的输入。

  • mean:从通道中减去的标量,其值旨在(mean-R、mean-G 和 mean-B)的顺序,如果图像具有 BGR 排序且swapRB为真。

  • swapRB:一个标志,表示在 3 通道图像中交换第一个和最后一个通道是必要的。

  • crop:一个标志,表示图像在调整大小后是否将被裁剪。

你可以在以下代码片段中阅读如何读取和将图像转换为数据块的完整代码。

...
input= imread(argv[1]);
// Stop the program if reached end of video
if (input.empty()) {
    cout << "No input image" << endl;
    return 0;
}
// Create a 4D blob from a frame.
blobFromImage(input, blob, 1/255.0, Size(inpWidth, inpHeight), Scalar(0,0,0), true, false);
...

最后,我们必须将数据块喂入深度网络,并使用forward函数进行推理,该函数需要两个参数:输出的mat结果和需要检索的层的名称:

...
//Sets the input to the network
net.setInput(blob);

// Runs the forward pass to get output of the output layers
vector<Mat> outs;
net.forward(outs, getOutputsNames(net));
// Remove the bounding boxes with low confidence
postprocess(input, outs);
...

mat输出向量中,我们包含了神经网络检测到的所有边界框,并且我们必须后处理输出,以获取置信度大于阈值的仅结果,通常为 0.5,最后应用非极大值抑制来消除冗余重叠的框。你可以在 GitHub 上找到完整的后处理代码。

我们示例的最终结果是深度学习中的多目标检测和分类,显示的窗口类似于以下内容:

图片

现在我们将学习另一个针对人脸检测定制的常用目标检测函数。

使用 SSD 进行人脸检测

单次检测SSD)是另一种快速且准确的深度学习目标检测方法,其概念与 YOLO 类似,在该方法中,对象和边界框在相同的架构中被预测。

SSD 模型架构

SSD 算法被称为单次检测,因为它在处理图像时,在相同的深度学习模型中同时预测边界框和类别。基本上,架构可以总结为以下步骤:

  1. 一个 300 x 300 的图像被输入到架构中。

  2. 输入图像通过多个卷积层,在不同的尺度上获得不同的特征。

  3. 对于在步骤 2 中获得的每个特征图,我们使用一个 3 x 3 的卷积滤波器来评估一组默认边界框。

  4. 对于每个评估的默认框,将预测边界框偏移量和类别概率。

模型架构看起来如下:

SSD 用于预测与 YOLO 类似的多类,但它可以被修改为检测单个对象,这需要更改最后一层并仅针对一个类别进行训练——这就是我们在示例中使用的内容,一个用于人脸检测的重新训练模型,其中只预测一个类别。

将 SSD 人脸检测导入 OpenCV

为了在我们的代码中使用深度学习,我们必须导入相应的头文件:

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

之后,我们将导入所需的命名空间:

using namespace cv;
using namespace std;
using namespace cv::dnn;

现在,我们将定义我们将要在代码中使用的目标图像大小和常量:

const size_t inWidth = 300;
const size_t inHeight = 300;
const double inScaleFactor = 1.0;
const Scalar meanVal(104.0, 177.0, 123.0);

在这个例子中,我们需要一些参数作为输入,例如模型配置和预训练模型,如果我们将要处理相机或视频输入。我们还需要确定接受预测为正确或错误的最低置信度:

const char* params
= "{ help | false | print usage }"
"{ proto | | model configuration (deploy.prototxt) }"
"{ model | | model weights (res10_300x300_ssd_iter_140000.caffemodel) }"
"{ camera_device | 0 | camera device number }"
"{ video | | video or image for detection }"
"{ opencl | false | enable OpenCL }"
"{ min_confidence | 0.5 | min confidence }";

现在,我们将从main函数开始,我们将使用CommandLineParser函数解析参数:

int main(int argc, char** argv)
{
 CommandLineParser parser(argc, argv, params);

 if (parser.get<bool>("help"))
 {
 cout << about << endl;
 parser.printMessage();
 return 0;
 }

我们还将加载模型架构和预训练模型文件,并在深度学习网络中加载模型:

 String modelConfiguration = parser.get<string>("proto");
 String modelBinary = parser.get<string>("model");

 //! [Initialize network]
 dnn::Net net = readNetFromCaffe(modelConfiguration, modelBinary);
 //! [Initialize network]

检查我们是否正确导入了网络非常重要。我们还必须检查是否导入了模型,使用empty函数,如下所示:

if (net.empty())
 {
 cerr << "Can't load network by using the following files" << endl;
 exit(-1);
 }

在加载我们的网络后,我们将初始化我们的输入源,一个摄像头或视频文件,并将其加载到VideoCapture中,如下所示:

 VideoCapture cap;
 if (parser.get<String>("video").empty())
 {
 int cameraDevice = parser.get<int>("camera_device");
 cap = VideoCapture(cameraDevice);
 if(!cap.isOpened())
 {
 cout << "Couldn't find camera: " << cameraDevice << endl;
 return -1;
 }
 }
 else
 {
 cap.open(parser.get<String>("video"));
 if(!cap.isOpened())
 {
 cout << "Couldn't open image or video: " << parser.get<String>("video") << endl;
 return -1;
 }
 }

现在,我们已经准备好开始捕获帧并将每个帧处理成深度神经网络以找到人脸。

首先,我们必须在循环中捕获每个帧:

for(;;)
 {
 Mat frame;
 cap >> frame; // get a new frame from camera/video or read image

 if (frame.empty())
 {
 waitKey();
 break;
 }

接下来,我们将输入帧放入一个可以管理深度神经网络的Mat blob 结构中。我们必须发送具有 SSD 正确尺寸的图像,即 300 x 300(我们已初始化inWidthinHeight常量变量),并从输入图像中减去所需的均值值,这是在 SSD 中使用定义的meanVal常量变量:

Mat inputBlob = blobFromImage(frame, inScaleFactor, Size(inWidth, inHeight), meanVal, false, false); 

现在我们已经准备好将数据放入网络,并使用 net.setInputnet.forward 函数分别获取预测/检测结果。这会将检测结果转换为可读取的检测 mat,其中 detection.size[2] 是检测到的对象数量,detection.size[3] 是每个检测的结果数量(边界框数据和置信度):

 net.setInput(inputBlob, "data"); //set the network input
 Mat detection = net.forward("detection_out"); //compute output
 Mat detectionMat(detection.size[2], detection.size[3], CV_32F, detection.ptr<float>());

每行 Mat 检测包含以下内容:

  • 列 0:存在对象的置信度

  • 列 1:边界框的置信度

  • 列 2:检测到的面部置信度

  • 列 3:X 底部左边界框

  • 列 4:Y 底部左边界框

  • 列 5:X 顶部右边界框

  • 列 6:Y 顶部右边界框

边界框相对于图像大小是相对的(从零到一)。

现在我们必须应用阈值,仅基于定义的输入阈值获取所需的检测:

float confidenceThreshold = parser.get<float>("min_confidence");
 for(int i = 0; i < detectionMat.rows; i++)
 {
 float confidence = detectionMat.at<float>(i, 2);

 if(confidence > confidenceThreshold)
 {

现在我们将提取边界框,在每个检测到的面部上画一个矩形,并如下所示显示:

 int xLeftBottom = static_cast<int>(detectionMat.at<float>(i, 3) * frame.cols);
 int yLeftBottom = static_cast<int>(detectionMat.at<float>(i, 4) * frame.rows);
 int xRightTop = static_cast<int>(detectionMat.at<float>(i, 5) * frame.cols);
 int yRightTop = static_cast<int>(detectionMat.at<float>(i, 6) * frame.rows);

 Rect object((int)xLeftBottom, (int)yLeftBottom, (int)(xRightTop - xLeftBottom), (int)(yRightTop - yLeftBottom));

 rectangle(frame, object, Scalar(0, 255, 0));
 }
 }
 imshow("detections", frame);
 if (waitKey(1) >= 0) break;
}

最终结果看起来像这样:

图片

在本节中,你学习了一种新的深度学习架构 SSD,以及如何使用它进行面部检测。

摘要

在本章中,我们学习了什么是深度学习以及如何在 OpenCV 中使用它进行目标检测和分类。本章是使用其他模型和深度神经网络进行任何目的工作的基础。

本书教你如何获取和编译 OpenCV,如何使用基本的图像和 mat 操作,以及如何创建你自己的图形用户界面。你使用了基本的过滤器,并在工业检测示例中应用了所有这些。我们探讨了如何使用 OpenCV 进行面部检测以及如何对其进行操作以添加面具。最后,我们介绍了对象跟踪、文本分割和识别的非常复杂的使用案例。现在,你已准备好利用这些用例创建自己的 OpenCV 应用程序,这些用例展示了如何应用每个技术或算法。

进一步阅读

要了解更多关于 OpenCV 中深度学习的信息,请查看由 Packt Publishing 出版的 《OpenCV 中的深度学习目标检测与识别》

posted @ 2025-09-21 12:12  绝不原创的飞龙  阅读(48)  评论(0)    收藏  举报