diff --git a/trans/build-cv-proj-opencv4-cpp/00.md b/trans/build-cv-proj-opencv4-cpp/00.md deleted file mode 100644 index a4afd4c2..00000000 --- a/trans/build-cv-proj-opencv4-cpp/00.md +++ /dev/null @@ -1,133 +0,0 @@ -# 前言 / 开场白 / 序幕 / 引语 - -OpenCV 是开发者可用的最好的开源计算机视觉库之一。 使用 OpenCV,开发人员可以创建完整的图像处理、对象检测和运动检测项目。 本学习路径面向希望学习如何使用工作代码示例从头开始构建 OpenCV 项目的绝对初学者。 我们将首先介绍计算机视觉及其基本概念,如滤波、直方图、对象分割和对象检测。 随着课程的推进,您将更深入地研究图像处理,探索各种计算机视觉算法,并了解机器学习和深度学习的最新进展如何增强目标检测过程。 随着课程的进展,您将通过构建真实的计算机视觉应用来实践这些知识。 - -稍后,您将熟悉 OpenCV 的 API 功能,并深入了解完整的计算机视觉项目中的设计选择。 您还将超越计算机视觉的基础知识,实现复杂图像处理项目的解决方案,例如肤色分析、人脸地标和姿势估计、增强现实应用以及车牌识别。 最后,在学习过程接近尾声时,您将了解构建计算机视觉应用时需要避免的某些最佳实践和常见陷阱。 - -此学习途径包括以下 Packt 产品中的内容: - -* 通过建筑项目学习 OpenCV 4,第二版,作者:David、Millán、Escrivá、Prateek Joshi 和 Vinícius、G.Mendonça -* 掌握 OpenCV 4,第三版,作者:Roy,Shilkrott,David,Millán Escrivá - -# 这本书是写给谁的? - -如果您是一名对计算机视觉和图像处理有基本了解的软件开发人员,并希望使用 OpenCV 开发有趣的计算机视觉应用,那么本课程适合您。 先前的 C++ 知识将帮助您理解本书中涵盖的概念。 - -# 这本书涵盖了哪些内容 - -[第 1 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=79&action=edit)OpenCV 入门,涵盖了在各种操作系统上的安装步骤,并介绍了人类视觉系统以及计算机视觉中的各种主题。 - -[第 2 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=80&action=edit)OpenCV 基础知识简介讨论了如何在 OpenCV 中读/写图像和视频,并解释了如何使用 CMake 构建项目。 - -[第 3 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=76&action=edit),学习图形用户界面和基本过滤,介绍如何构建图形用户界面和鼠标事件检测器来构建交互式应用。 - -[第 4 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=75&action=edit),深入研究直方图和过滤器,探索直方图和过滤器,并展示如何将图像动画化。 - -[第 5 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=78&action=edit),自动光学检测、对象分割和检测,描述了各种图像预处理技术,如噪声去除、阈值和轮廓分析。 - -[第 6 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=73&action=edit),学习对象分类,讨论对象识别和机器学习,以及如何使用支持向量机来构建对象分类系统。 - -[第 7 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=77&action=edit),检测人脸部位和覆盖面具,讨论了人脸检测和 Haar Cascade,然后解释了如何使用这些方法来检测人脸的各个部位。 - -[第 8 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=70&action=edit),视频监控、背景建模和形态运算,探讨了背景减法、视频监控和形态图像处理,并描述了它们如何相互连接。 - -[第 9 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=74&action=edit),学习对象跟踪,介绍如何使用基于颜色和基于特征的跟踪等不同技术跟踪实况视频中的对象。 - -[第 10 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=72&action=edit),开发用于文本识别的分割算法,介绍了光学字符识别、文本分割,并介绍了 Tesseract OCR 引擎。 - -[第 11 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=71&action=edit),使用 Tesseract 进行文本识别,深入研究了 Tesseract OCR 引擎,解释了如何将其用于文本检测、提取和识别。 - -[第 12 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=69&action=edit),深度学习与 OpenCV,探讨了如何使用两种常用的深度学习架构在 OpenCV 中应用深度学习:用于目标检测的 YOLO v3 和用于人脸检测的单镜头检测器。 - -[第 13 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=68&action=edit),RaspberryPI 上的 Cartoonizer 和肤色分析,演示了如何为台式机和小型嵌入式系统(如 Raspberry PI)编写一些图像处理过滤器。 - -[第 14 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=65&action=edit),使用 SfM 模块探索运动中的结构,演示了如何使用 SfM 模块将场景重建为稀疏的点云(包括相机姿势),并使用多视图立体效果获得密集的点云。 - -[第 15 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=62&action=edit),脸部标志和脸部模块姿势,解释了使用脸部模块检测脸部地标(也称为脸部标记)的过程。 - -[第 16 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=63&action=edit),基于深卷积网络的车牌识别,介绍了图像分割与特征提取、模式识别基础知识,以及两种重要的模式识别算法--支持向量机(SVM)和深度神经网络(DNN)。 - -[第 17 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=64&action=edit),DNN 模块的人脸检测和识别,展示了在图像上检测人脸的不同技术,从使用具有 Haar 特征的级联分类器的更经典算法到使用深度学习的较新技术。 - -[第 18 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=67&action=edit),Android 摄像头校准和 AR 使用 ArUco 模块,展示了如何使用 OpenCV 的 ArUco 模块、Android 的 Camera2API 和 JMonkeyEngine 3D 游戏 -引擎在 Android 生态系统中实现增强现实(AR)应用。 - -[第 19 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=59&action=edit),iOS 全景拼接模块,展示了如何使用 OpenCV 的 iOS 预编译库在 iPhone 上构建全景图像拼接应用。 - -[第 20 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=61&action=edit),查找作业的最佳 OpenCV 算法,讨论了在考虑 OpenCV 中的选项时应遵循的一些方法。 - -[第 21 章](https://cdp.packtpub.com/buildingcomputervisionprojectswithopencv4andcplusplus/wp-admin/post.php?post=60&action=edit),避免 OpenCV 中的常见陷阱,回顾了 OpenCV 的历史发展,以及随着计算机视觉的发展,OpenCV 的框架和算法提供的逐渐增加。 - -# 为了最大限度地利用这本书 - -要开始使用本课程,您需要在本地桌面上安装以下软件: - -* OpenCV 4.0 -* CMake 3.3.x 或更高版本 -* Tesseract,Leptonica(Tesseract 的属地) -* Qt(可选)和 OpenGL(可选) -* 有些章节需要安装 Python,其他章节需要安装 Android - -# 下载示例代码文件 - -您可以从您的帐户[www.Packt.com](http://www.packt.com)下载本书的示例代码文件。 如果您在其他地方购买了本书,您可以访问[www.Packt.com/support](http://www.packt.com/support)并注册,让文件直接通过电子邮件发送给您。 - -您可以通过以下步骤下载代码文件: - -1. 登录或注册[www.Packt.com](http://www.packt.com)。 -2. 选择支持选项卡。 -3. 单击 Code Downloads&Errata(代码下载和勘误表)。 -4. 在搜索框中输入图书名称,然后按照屏幕上的说明进行操作。 - -下载文件后,请确保使用以下最新版本解压缩或解压缩该文件夹: - -* WinRar/7-用于 Windows 的 Zip -* 适用于 Mac 的 Zipeg/iZip/UnRarX -* Linux 版 7-Zip/PeaZip - -这本书的代码包也托管在 giHub 上,地址是:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus)。如果代码有更新,它将在现有的 giHub 存储库中进行更新。 - -我们还有来自我们丰富的图书和视频目录的其他代码包,请访问**[https://github.com/PacktPublishing/](https://github.com/PacktPublishing/)**。 看看他们! - -# 使用的约定 - -本书中使用了许多文本约定。 - -`CodeInText`:指示文本中的代码字、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。 下面是一个示例:“`input()`方法用于从用户获取输入。” - -代码块设置如下: - -```cpp -Mat bigImg; -resize(smallImg, bigImg, size, 0,0, INTER_LINEAR); -dst.setTo(0); -bigImg.copyTo(dst, mask); -``` - -任何命令行输入或输出都如下所示: - -```cpp -sudo apt-get purge -y wolfram-engine -``` - -**粗体**:表示您在屏幕上看到的新术语、重要单词或单词。 例如,菜单或对话框中的单词显示在文本中,如下所示。 下面是一个示例:“如果您需要不同的内容,请单击标题中的**Downloads**链接,查看所有可能的下载内容:” - -Warnings or important notes appear like this. Tips and tricks appear like this. - -# 保持联系 - -欢迎读者的反馈。 - -**一般反馈**:如果您对本书的任何方面有疑问,请在邮件主题中提及书名,然后给我们发电子邮件至`customercare@packtpub.com`。 - -**勘误表**:虽然我们已经竭尽全力确保内容的准确性,但错误还是会发生。 如果您在这本书中发现了错误,请向我们报告,我们将不胜感激。 请访问[www.Packt.com/Submit-errata](http://www.packt.com/submit-errata),选择您的图书,单击勘误表提交表链接,然后输入详细信息。 - -**盗版**:如果您在互联网上遇到任何形式的非法复制我们的作品,请您提供地址或网站名称,我们将不胜感激。 请拨打`copyright@packt.com`与我们联系,并提供该材料的链接。 - -**如果您有兴趣成为一名作者**:如果有一个您擅长的主题,并且您有兴趣撰写或投稿一本书,请访问[Auths.Packtpub.com](http://authors.packtpub.com/)。 - -# 评论 - -请留下评论。 一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢? 这样,潜在读者就可以看到并使用您不偏不倚的意见来做出购买决定,我们 Packt 可以了解您对我们产品的看法,我们的作者也可以看到您对他们的书的反馈。 谢谢! - -有关 Packt 的更多信息,请访问[Packt.com](http://www.packt.com/)。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/01.md b/trans/build-cv-proj-opencv4-cpp/01.md deleted file mode 100644 index 44ef4a2e..00000000 --- a/trans/build-cv-proj-opencv4-cpp/01.md +++ /dev/null @@ -1,361 +0,0 @@ -# OpenCV 入门 - -计算机视觉应用是有趣和有用的,但是底层算法是计算密集型的。 随着云计算的到来,我们可以使用更多的处理能力。 - -OpenCV 库使我们能够实时高效地运行计算机视觉算法。 它已经有很多年的历史了,并且已经成为该领域的标准库。 OpenCV 的主要优势之一是它高度优化,几乎可以在所有平台上使用。 - -这本书将涵盖我们将使用的各种算法,我们为什么要使用它们,以及如何在 OpenCV 中实现它们。 - -在本章中,我们将学习如何在各种操作系统上安装 OpenCV。 我们将讨论 OpenCV 提供的开箱即用功能,以及使用内置函数可以做的各种事情。 - -在本章结束时,您将能够回答以下问题: - -* 人类是如何处理视觉数据的,他们又是如何理解图像内容的呢? -* 我们可以使用 OpenCV 做什么,OpenCV 中有哪些模块可以用来实现这些功能? -* 如何在 Windows、Linux 和 Mac OS X 上安装 OpenCV? - -# 了解人类的视觉系统 - -在我们进入 OpenCV 功能之前,我们首先需要了解为什么要构建这些功能。 重要的是要了解人类视觉系统是如何工作的,这样你才能开发出正确的算法。 - -计算机视觉算法的目标是理解图像和视频的内容。 人类似乎可以毫不费力地做到这一点! 那么,我们如何让机器以同样的精确度来做这件事呢? - -让我们考虑下图: - -![](img/17fb5e37-8cf5-48e5-82f9-830f0a0002c2.png) - -人眼可以捕捉到所有的信息,比如颜色、形状、亮度等等。 在上图中,人眼捕捉到关于这两个主要物体的所有信息,并以某种方式进行存储。 一旦我们了解了我们的系统是如何工作的,我们就可以利用它来实现我们想要的。 - -例如,以下是我们需要知道的几件事: - -* 我们的视觉系统对低频内容比高频内容更敏感。 低频内容是指像素值变化不快的平面区域,高频内容是指像素值波动较大的角和边区域。 我们可以很容易地看到平面上是否有斑点,但在高度纹理的表面上很难发现这样的东西。 -* 人眼对亮度的变化比对颜色的变化更敏感。 - -* 我们的视觉系统对运动很敏感。 我们可以很快识别出是否有什么东西在我们的视野中移动,即使我们没有直接看着它。 - -* 我们倾向于在脑海中记下我们视野中的要点。 假设你看到一张白色的桌子,它有四条黑色的腿,桌子表面的一角有一个红点。 当你看着这张桌子时,你会立即在脑海中注意到表面和腿的颜色是相反的,其中一个角上有一个红点。 我们的大脑在这方面真的很聪明! 我们会自动这样做,这样如果我们再次遇到一个物体,我们就可以立即认出它。 - -为了了解我们的视野,让我们看看人类的俯视图,以及我们观察各种事物的角度: - -![](img/7942da39-b1e4-4cfb-b12e-99dfe3089376.png) - -我们的视觉系统实际上有更多的能力,但这应该足以让我们开始。 您可以通过在网络上阅读**人类视觉系统**(**HVS**)模型来进一步探索。 - -# 人类如何理解图像内容? - -如果你环顾四周,你会看到很多东西。 你每天都会遇到许多不同的物体,你几乎可以毫不费力地在瞬间认出它们。 当你看到一把椅子时,你不用等几分钟就会意识到它实际上是一把椅子。 你只要知道它马上就是一把椅子。 - -另一方面,计算机发现很难完成这项任务。 研究人员多年来一直在努力找出为什么计算机在这方面不如我们。 - -为了得到这个问题的答案,我们需要了解人类是如何做到这一点的。 视觉数据处理发生在腹侧视流中。 这种腹侧视觉流指的是我们视觉系统中与物体识别相关的路径。 它基本上是我们大脑中帮助我们识别物体的区域的层次结构。 - -人类可以毫不费力地识别不同的物体,并可以将相似的物体聚集在一起。 我们之所以能做到这一点,是因为我们已经开发出对同一类对象的某种不变性。 当我们看着一个物体时,我们的大脑以这样一种方式提取突出点,即方向、大小、视角和照明等因素都无关紧要。 - -一把椅子的大小是正常大小的两倍,并且旋转了 45 度,它仍然是一把椅子。 我们可以很容易地识别它,因为我们处理它的方式。 机器不能这么容易做到这一点。 人类往往会根据物体的形状和重要特征来记住它。 无论物体是如何放置的,我们仍然可以识别它。 - -在我们的视觉系统中,我们建立了关于位置、比例和视点的层次不变性,这有助于我们变得非常健壮。 如果你更深入地观察我们的系统,你会发现人类的视觉皮层中有能对曲线和线条等形状做出反应的细胞。 - -当我们沿着腹侧流走得更远时,我们会看到更复杂的细胞,它们被训练成对更复杂的物体(如树木、大门等)做出反应。 沿着腹侧流的神经元倾向于显示出感受场的大小增加。 再加上他们喜欢的刺激的复杂性也增加了。 - -# 为什么机器很难理解图像内容? - -我们现在了解了视觉数据是如何进入人类视觉系统的,以及我们的系统是如何处理它的。 问题是我们仍然不能完全理解我们的大脑是如何识别和组织这些视觉数据的。 在机器学习中,我们只是从图像中提取一些特征,然后要求计算机使用算法来学习它们。 我们仍然有这些变化,例如形状、大小、透视、角度、照明、遮挡等等。 - -例如,当您从纵断面图查看时,同一张椅子在机器看来非常不同。 人们可以很容易地认出它是一把椅子,不管它是如何呈现给我们的。 那么,我们该如何向我们的机器解释这一点呢? - -要做到这一点,一种方法是存储对象的所有不同变化,包括大小、角度、透视等。 但这一过程既繁琐又耗时。 而且,实际上不可能收集到涵盖每一种变异的数据。 这些机器将消耗大量的内存和大量的时间来建立一个能够识别这些物体的模型。 - -即便如此,如果一个物体被部分遮挡,计算机仍然无法识别它。 这是因为他们认为这是一个新的物体。 因此,当我们构建计算机视觉库时,我们需要构建底层功能块,这些功能块可以以多种不同的方式组合在一起,以形成复杂的算法。 - -OpenCV 提供了很多这样的功能,而且它们都经过了高度优化。 因此,一旦我们了解了 OpenCV 的能力,我们就可以有效地利用它来构建有趣的应用。 - -让我们在下一节继续探索这一点。 - -# 您可以使用 OpenCV 做什么? - -使用 OpenCV,你几乎可以完成你能想到的每一项计算机视觉任务。 现实生活中的问题需要您一起使用许多计算机视觉算法和模块才能达到预期的结果。 因此,您只需要了解要使用哪些 OpenCV 模块和函数,就可以获得您想要的东西。 - -让我们看看 OpenCV 可以实现哪些开箱即用的功能。 - -# 内置数据结构和输入/输出 - -OpenCV 最好的一点是它提供了大量内置原语来处理与图像处理和计算机视觉相关的操作。 如果必须从头开始编写内容,则必须定义`Image`、`Point`、`Rectangle`等。 这些都是几乎所有计算机视觉算法的基础。 - -OpenCV 附带了所有这些开箱即用的基本结构,包含在核心模块中。 另一个优点是这些结构已经针对速度和内存进行了优化,因此您不必担心实现细节。 - -`imgcodecs`模块处理图像文件的读取和写入。 当您操作输入图像并创建输出图像时,可以用一个简单的命令将其保存为`.jpg`或`.png`文件。 - -当您使用摄像机工作时,您将处理大量视频文件。 `videoio`模块处理与视频文件的输入和输出相关的一切。 您可以轻松地从网络摄像头捕获视频或读取多种不同格式的视频文件。 您甚至可以通过设置属性(如每秒帧数、帧大小等)将一串帧另存为视频文件。 - -# 图像处理操作 - -在编写计算机视觉算法时,有很多基本的图像处理操作需要反复使用。 这些功能中的大多数都存在于`imgproc`模块中。 您可以执行图像过滤、形态操作、几何变换、颜色转换、在图像上绘图、直方图、形状分析、运动分析、特征检测等操作。 - -让我们来看一下下面的照片: - -![](img/b676eeeb-8436-4b28-a840-719c1c1734f4.png) - -右图是左图的旋转版本。 在 OpenCV 中,我们只需一行即可执行此转换。 - -还有另一个模块,称为`ximgproc`,它包含用于边缘检测的结构森林、域变换滤波器、自适应流形滤波器等高级图像处理算法。 - -# 图形用户界面 - -OpenCV 提供了一个名为`highgui`的模块,用于处理所有高级用户界面操作。 假设您正在处理一个问题,并且希望在继续下一步之前检查图像的外观。 此模块具有可用于创建窗口以显示图像和/或视频的功能。 - -有一个等待功能,它会等到你按下键盘上的一个键,然后它才会进入下一步。 还有一个可以检测鼠标事件的功能。 这在开发交互式应用时非常有用。 - -使用此功能,您可以在这些输入窗口上绘制矩形,然后根据所选区域继续。 请考虑以下屏幕截图: - -![](img/4b1a123e-8867-494b-ab3f-b28c501ac940.png) - -如您所见,我们在窗口顶部绘制了一个绿色矩形。 一旦我们有了那个矩形的坐标,我们就只能在那个区域上行动了。 - -# 视频分析 - -视频分析包括分析视频中连续帧之间的运动、跟踪视频中的不同对象、创建视频监控模型等任务。 OpenCV 提供了一个名为`video`的模块,可以处理所有这些问题。 - -还有一个名为`videostab`的模块,用于处理视频稳定。 视频稳定很重要,因为当你用手持相机拍摄视频时,通常会有很多抖动需要纠正。 所有现代设备在将视频呈现给最终用户之前都会使用视频稳定器对视频进行处理。 - -# 三维重建 - -三维重建是计算机视觉中的一个重要课题。 在给定一组 2D 图像的情况下,我们可以使用相关算法重建 3D 场景。 OpenCV 提供了一些算法,可以找到这些 2D 图像中各种对象之间的关系,从而在其`calib3d`模块中计算它们的 3D 位置。 - -该模块还可以处理摄像机校准,这是估计摄像机参数所必需的。 这些参数定义摄影机如何看到它前面的场景。 我们需要知道这些参数来设计算法,否则我们可能会得到意想不到的结果。 - -让我们考虑下图: - -![](img/e5b39e1a-aa0f-4a86-965d-93796daa524c.png) - -正如我们在这里看到的,同一对象是从多个位置捕获的。 我们的工作是使用这些 2D 图像重建原始对象。 - -# 特征提取 - -正如我们前面讨论的,人类视觉系统倾向于从给定的场景中提取显著特征,以便记住它以备以后检索。 为了模仿这一点,人们开始设计各种特征提取器,可以从给定的图像中提取这些显著点。 流行的算法包括**尺度不变特征变换**(**SIFT**)、**加速鲁棒特征**(**SURF**)和**F****EATURES from Accelerated Segment Test**(**FAST**)。 - -名为`features2d`的 OpenCV 模块提供了检测和提取所有这些特征的功能。 另一个名为`xfeatures2d`的模块提供了更多的特征提取器,其中一些仍处于实验阶段。 如果你有机会,你可以玩这些东西。 - -还有一个名为`bioinspired`的模块,它为生物启发的计算机视觉模型提供算法。 - -# 目标检测 - -目标检测是指检测目标在给定图像中的位置。 此过程与对象的类型无关。 如果你设计了一个椅子探测器,它不会告诉你一张给定图像中的椅子是红色的高靠背还是蓝色的低靠背-它只会告诉你椅子的位置。 - -目标位置检测是许多计算机视觉系统中的关键步骤。 请看下面的照片: - -![](img/a556000b-9c67-4577-9e43-07eaacaf258d.png) - -如果你在这张图片上运行椅子探测器,它会在所有的椅子周围放一个绿色的方框-但它不会告诉你这是哪种椅子。 - -对象检测过去是一项计算密集型任务,因为执行各种尺度的检测所需的计算量很大。 为了解决这个问题,Paul Viola 和 Michael Jones 在他们 2001 年的开创性论文中提出了一个很棒的算法,您可以通过以下链接阅读:[https://www.cs.cmu.edu/~efros/courses/LBMV07/Papers/viola-cvpr-01.pdf](https://www.cs.cmu.edu/~efros/courses/LBMV07/Papers/viola-cvpr-01.pdf)。 它们提供了一种为任何对象设计对象检测器的快速方法。 - -OpenCV 有称为`objdetect`和`xobjdetect`的模块,它们提供了设计对象检测器的框架。 您可以使用它来开发用于太阳镜、靴子等随机物品的检测器。 - -# 机器学习 - -机器学习算法被广泛用于构建计算机视觉系统,用于目标识别、图像分类、人脸检测、视觉搜索等。 - -OpenCV 提供了一个名为`ml`的模块,其中捆绑了许多机器学习算法,包括**贝叶斯分类器**、**k 近邻**(**KNN**)、**支持向量机**(**SVM**)、**决策树**、**神经网络**等等。 - -它还有一个名为**快速近似最近邻搜索库**(**FLAN**)的模块,其中包含在大数据集中进行快速最近邻搜索的算法。 - -# 计算摄影 - -计算摄影是指使用先进的图像处理技术来改善相机捕获的图像。 计算摄影使用软件来处理视觉数据,而不是关注光学过程和图像捕捉方法。 应用包括高动态范围成像、全景图像、图像重新照明和光场相机。 - -让我们看下图: - -![](img/0f552b9a-c61e-47d0-bd5b-7a3e7379742a.png) - -看看那些鲜艳的颜色! 这是一个高动态范围图像的例子,使用传统的图像捕捉技术是不可能获得的。 要做到这一点,我们必须在多次曝光时捕捉相同的场景,将这些图像彼此配准,然后将它们很好地混合在一起,以创建这张图像。 - -`photo`和`xphoto`模块包含提供与计算摄影有关的算法的各种算法。 还有一个名为`stitching`的模块,它提供了创建全景图像的算法。 - -The image shown can be found here: [https://pixabay.com/en/hdr-high-dynamic-range-landscape-806260/](https://pixabay.com/en/hdr-high-dynamic-range-landscape-806260/). - -# 形状分析 - -形状的概念在计算机视觉中至关重要。 我们通过识别图像中各种不同的形状来分析视觉数据。 这实际上是许多算法中的重要一步。 - -假设您正在尝试识别图像中的特定徽标。 您知道它可以以各种形状、方向和大小出现。 开始的一个好方法是量化物体形状的特征。 - -`shape`模块提供提取不同形状、测量它们之间的相似性、变换对象形状等所需的所有算法。 - -# 光流算法 - -在视频中使用光流算法来跟踪连续帧上的特征。 假设您想要跟踪视频中的特定对象。 在每个帧上运行特征提取器的计算代价会很高;因此,该过程会很慢。 因此,您只需从当前帧中提取特征,然后在连续的帧中跟踪这些特征。 - -光流算法广泛应用于计算机视觉中基于视频的应用中。 `optflow`模块包含执行光流所需的所有算法。 还有一个名为`tracking`的模块,它包含更多可用于跟踪功能的算法。 - -# 人脸和物体识别 - -人脸识别是指识别给定图像中的人。 这与人脸检测不同,在人脸检测中,您只需识别给定图像中人脸的位置。 - -如果你想构建一个实用的生物识别系统,能够识别摄像头前的人,你首先需要运行人脸检测器来识别人脸的位置,然后运行单独的人脸识别器来识别这个人是谁。 有一个名为`face`的 OpenCV 模块处理人脸识别。 - -正如我们前面讨论的,计算机视觉试图根据人类感知视觉数据的方式对算法进行建模。 因此,在图像中发现显著区域和目标将有助于不同的应用,如目标识别、目标检测和跟踪等。 有一个名为`saliency`的模块就是为此目的而设计的。 它提供了可以检测静态图像和视频中显著区域的算法。 - -# 曲面匹配 - -我们越来越多地与能够捕捉我们周围物体的 3D 结构的设备互动。 这些设备主要捕捉深度信息,以及常规的 2D 彩色图像。 因此,构建能够理解和处理 3D 对象的算法对我们来说非常重要。 - -**Kinect**就是捕获深度信息和视觉数据的设备的一个很好的例子。 手头的任务是通过将输入的 3D 对象与我们数据库中的一个模型进行匹配来识别输入的 3D 对象。 如果我们有一个可以识别和定位物体的系统,那么它可以用于许多不同的应用。 - -有一个名为`surface_matching`的模块,它包含 3D 对象识别算法和使用 3D 特征的姿势估计算法。 - -# 文本检测与识别 - -识别特定场景中的文本和识别内容变得越来越重要。 应用包括车牌识别、自动驾驶汽车的路标识别、书籍扫描以数字化内容等。 - -有一个名为`text`的模块,它包含处理文本检测和识别的各种算法。 - -# 深度学习 - -深度学习对计算机视觉和图像识别的影响很大,比其他机器学习和人工智能算法具有更高的准确率。 深度学习并不是一个新概念;它在 1986 年左右被引入社区,但它在 2012 年左右开始了一场革命,当时新的 GPU 硬件针对并行计算进行了优化,**卷积神经网络**(**CNN**)实现和其他技术允许在合理的时间内训练复杂的神经网络结构。 - -深度学习可以应用于多个用例,例如图像识别、对象检测、语音识别和自然语言处理。 从 3.4 版本开始,OpenCV 就一直在实现深度学习算法--在最新版本中,为**TensorFlow**和**Caffe**等重要框架添加了多个导入器。 - -# 安装 OpenCV - -让我们看看如何在各种操作系统上启动和运行 OpenCV。 - -# Windows 操作系统 - -为简单起见,让我们使用预构建库安装 OpenCV。 转到[opencv.org](https://opencv.org/)并下载 Windows 的最新版本。 目前的版本是 4.0.0,您可以从 OpenCV 主页获取下载链接。 在继续之前,您应该确保您拥有管理员权限。 - -下载的文件将是可执行文件,因此只需双击它即可开始安装。 安装程序将内容展开到文件夹中。 您将能够选择安装路径,并通过检查文件来检查安装。 - -完成上一步后,我们需要设置 OpenCV 环境变量并将它们添加到系统路径以完成安装。 我们将设置一个环境变量,该变量将保存 OpenCV 库的构建目录。 我们将在我们的项目中使用这一点。 - -打开终端并键入以下内容: - -```cpp -C:> setx -m OPENCV_DIR D:OpenCVBuildx64vc14 -``` - -We are assuming that you have a 64-bit machine with Visual Studio 2015 installed. If you have Visual Studio 2012, replace `vc14` with `vc11` in the command. The path specified is where we would have our OpenCV binaries, and you should see two folders inside that path called `lib` and `bin`. If you are using Visual Studio 2018, you should compile OpenCV from scratch. - -让我们继续并将`bin`文件夹的路径添加到我们的系统路径。 之所以需要这样做,是因为我们将使用**动态链接库**(**DLL**s)形式的 OpenCV 库。 实际上,所有的 OpenCV 算法都存储在这里,我们的操作系统只会在运行时加载它们。 - -为了做到这一点,我们的操作系统需要知道它们的位置。 `PATH`系统变量包含它可以在其中找到 DLL 的所有文件夹的列表。 因此,我们自然需要将 OpenCV 库的路径添加到此列表中。 - -我们为什么要做这一切呢? 好的,另一种选择是将所需的 DLL 复制到与应用的可执行文件(`.exe`文件)相同的文件夹中。 这是一个不必要的开销,特别是当我们在处理许多不同的项目时。 - -我们需要编辑`PATH`变量来添加此文件夹。 您可以使用路径编辑器等软件来执行此操作,您可以从此处下载:[https://patheditor2.codeplex.com](https://patheditor2.codeplex.com)。 安装后,启动它并添加以下新条目(您可以右键单击路径以插入新项目): - -```cpp -%OPENCV_DIR%bin -``` - -继续并将其保存到注册表。 我们完了! - -# Mac OS X Mac OS X - -在本节中,我们将了解如何在 Mac OS X 上安装 OpenCV。预编译的二进制文件在 Mac OS X 上不可用,因此我们需要从头开始编译 OpenCV。 - -在继续之前,我们需要安装 CMake。 如果您还没有安装 CMake,可以从这里下载:[https://cmake.org/files/v3.12/cmake-3.12.0-rc1-Darwin-x86_64.dmg](https://cmake.org/files/v3.12/cmake-3.12.0-rc1-Darwin-x86_64.dmg)。 这是一个`.dmg`文件,所以下载后,只需运行安装程序即可。 - -从[opencv.org](https://opencv.org/)下载最新版本的 OpenCV。 当前版本是 4.0.0,您可以从这里下载:[https://github.com/opencv/opencv/archive/4.0.0.zip](https://github.com/opencv/opencv/archive/4.0.0.zip)。 将内容解压缩到您选择的文件夹中。 - -OpenCV 4.0.0 还有一个名为`opencv_contrib`的新包,其中包含尚未被认为是稳定的用户贡献,以及在所有最新的计算机视觉算法中不能免费用于商业用途的一些算法,这一点值得记住。 安装此软件包是可选的-如果您不安装`opencv_contrib`,OpenCV 将工作得很好。 - -因为我们无论如何都要安装 OpenCV,所以最好安装这个软件包,这样您以后就可以试用它了(而不是再次经历整个安装过程)。 这是学习和使用新算法的好方法。 您可以从以下链接下载:[https://github.com/opencv/opencv_contrib/archive/4.0.0.zip](https://github.com/opencv/opencv_contrib/archive/4.0.0.zip)。 - -将 zip 文件的内容解压缩到您选择的文件夹中。 为方便起见,请将其解压缩到与前面相同的文件夹中,以便`opencv-4.0.0`和`opencv_contrib-4.0.0`文件夹位于同一主文件夹中。 - -我们现在就可以构建 OpenCV 了。 打开终端并导航到解压 OpenCV 4.0.0 内容的文件夹。 在命令中替换正确路径后运行以下命令: - -```cpp -$ 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`目录,并在您的终端上运行以下命令: - -```cpp -$ make -j4 -$ make install -``` - -在前面的命令中,**`-j4`**标志指示它应该使用四个内核来安装它。 这样会更快! 现在,让我们设置库路径。 使用`vi ~/.profile`命令在您的终端中打开您的`~/.profile`文件,并添加以下行: - -```cpp -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,则不会发生冲突。 让我们继续这样做: - -```cpp -$ cp /full/path/to/opencv-4.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv4.pc -``` - -我们还需要更新我们的`PKG_CONFIG_PATH`变量。 打开您的`~/.profile`文件并添加以下行: - -```cpp -export PKG_CONFIG_PATH=/usr/local/lib/pkgconfig/:$PKG_CONFIG_PATH -``` - -使用以下命令重新加载`~/.profile`文件: - -```cpp -$ source ~/.profile -``` - -我们完了! 让我们看看它是不是起作用了: - -```cpp -$ 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 -``` - -如果您的终端上显示了欢迎使用 OpenCV 4.0.0,您就可以开始使用了。 在本书中,我们将使用 CMake 来构建我们的 OpenCV 项目。 我们将在[第 2 章](01.html),*OpenCV 基础简介*中更详细地介绍它。 - -# Linux 操作系 - -让我们来看看如何在 Ubuntu 上安装 OpenCV。 在开始之前,我们需要安装一些依赖项。 让我们通过在您的终端中运行以下命令,使用包管理器安装它们: - -```cpp -$ 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: - -```cpp -$ 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`: - -```cpp -$ cp /full/path/to/opencv-4.0.0/build/lib/pkgconfig/opencv.pc /usr/local/lib/pkgconfig/opencv4.pc -``` - -我们完了! 我们现在可以使用它从命令行编译我们的 OpenCV 程序。 此外,如果您已经安装了 OpenCV 3.x.x,则不会发生冲突。 - -让我们检查一下安装是否工作正常: - -```cpp -$ 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 -``` - -如果您的终端上显示了欢迎使用 OpenCV 4.0.0,您应该可以开始使用了。 在接下来的章节中,我们将学习如何使用 CMake 来构建我们的 OpenCV 项目。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们讨论了人类的视觉系统,以及人类如何处理视觉数据。 我们解释了为什么机器很难做到这一点,以及在设计计算机视觉库时需要考虑的问题。 - -我们了解了使用 OpenCV 可以做什么,以及可以用来完成这些任务的各种模块。 最后,我们学习了如何在各种操作系统上安装 OpenCV。 - -在下一章中,我们将讨论如何对图像进行操作,以及如何使用各种函数对其进行操作。 我们还将学习如何为我们的 OpenCV 应用构建项目结构。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/02.md b/trans/build-cv-proj-opencv4-cpp/02.md deleted file mode 100644 index 73e679c9..00000000 --- a/trans/build-cv-proj-opencv4-cpp/02.md +++ /dev/null @@ -1,820 +0,0 @@ -# OpenCV 基础知识简介 - -在[第 1 章](01.html)、*OpenCV*入门中介绍了 OpenCV 在不同操作系统上的安装之后,我们将在本章介绍 OpenCV 开发的基础知识。 它首先展示如何使用 CMake 创建我们的项目。 我们将介绍最基本的图像数据结构和矩阵,以及在我们的项目中工作所需的其他结构。 我们将介绍如何使用 XML/YAML 持久性 OpenCV 函数将变量和数据保存到文件中。 - -在本章中,我们将介绍以下主题: - -* 使用 CMake 配置项目 -* 从磁盘读取图像/向磁盘写入图像 -* 阅读视频和访问摄像设备 -* 主要图像结构(例如,矩阵) -* 其他重要和基本的结构(例如,矢量和标量) -* 基本矩阵运算入门 -* 使用 XML/YAML 持久性 OpenCV API 进行文件存储操作 - -# 技术要求 - -本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 gihub 链接下载:*[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter02](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter02)。*这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。 -查看以下视频了解实际操作的代码: -[http://bit.ly/2QxhNBa](http://bit.ly/2QxhNBa) - -# 基本 CMake 配置文件 - -要配置和检查项目的所有必需依赖项,我们将使用 CMake,但这不是唯一的方法;我们可以在任何其他工具或 IDE 中配置我们的项目,例如**Makefiles**或**Visual Studio**,但 CMake 是配置多平台**C++**项目的一种更可移植的方式。 - -CMake 使用名为`CMakeLists.txt`的配置文件,其中定义了编译和依赖过程。 对于基于从单个源代码文件构建的可执行文件的基本项目,只需包含三行代码的`CMakeLists.txt`文件即可。 该文件如下所示: - -```cpp -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`函数: - -```cpp -# 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.cpp`和`hello.h`是源文件。 我们还添加了头文件,以允许诸如 Visual Studio 之类的 IDE 链接到头文件。 此行将生成共享(`.so`用于 Mac OS X,Unix 或`.dll`用于 Windows)或静态库(`.a`用于 Mac OS X,Unix 或`.lib`用于 Windows)文件,具体取决于我们在库名和源文件之间添加的是`SHARED`还是`STATIC`字。 `target_link_libraries(executable Hello)`是将我们的可执行文件链接到所需库的函数,在我们的例子中,它是`Hello`库。 - -# 管理依赖项 - -CMake 能够搜索我们的依赖项和外部库,使我们能够根据项目中的外部组件构建复杂的项目,并添加一些需求。 - -在本书中,最重要的依赖项当然是 OpenCV,我们将把它添加到我们的所有项目中: - -```cpp - 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}) -``` - -现在,让我们从以下几个方面来了解脚本的工作原理: - -```cpp -cmake_minimum_required (VERSION 3.0) -cmake_policy(SET CMP0012 NEW) -PROJECT(Chapter2) -``` - -第一行定义了 CMake 的最低版本,第二行告诉 CMake 使用 CMake 的新行为来帮助识别正确的数字和布尔常量,而无需取消引用具有此类名称的变量;该策略是在 CMake 2.8.0 中引入的,当该策略未从 3.0.2 版开始设置时,CMake 会发出警告。 最后,最后一行定义了项目标题。 定义项目名称后,我们必须定义需求、库和依赖项: - -```cpp -# 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,并声明它是必需的软件包。 - -The `FIND_PACKAGE` command includes all OpenCV submodules, but you can specify the submodules that you want to include in the project by executing your application smaller and faster. For example, if we are only going to work with the basic OpenCV types and core functionality, we can use the following command: `FIND_PACKAGE(OpenCV 4.0.0 REQUIRED core)`. - -如果 CMake 没有找到它,它会返回一个错误,并且不会阻止我们编译我们的应用。 `MESSAGE`函数在终端或 CMake GUI 中显示消息。 在我们的示例中,我们显示的 OpenCV 版本如下: - -```cpp -OpenCV version : 4.0.0 -``` - -`${OpenCV_VERSION}`是 CMake 存储 OpenCV 包版本的变量。`include_directories()`和`link_directories()`将指定库的头和目录添加到我们的环境中。 OpenCV CMake 的模块将此数据保存在`${OpenCV_INCLUDE_DIRS}`和`${OpenCV_LIB_DIR}`变量中。 并非所有平台(如 Linux)都需要这些行,因为这些路径通常位于环境中,但建议使用多个 OpenCV 版本来选择正确的链接并包含目录。 现在是将我们开发的资源包括在内的时候了: - -```cpp -# Create a variable called SRC - SET(SRC main.cpp) -# Create our executable - ADD_EXECUTABLE(${PROJECT_NAME} ${SRC}) -# Link our library - TARGET_LINK_LIBRARIES(${PROJECT_NAME} ${OpenCV_LIBS}) -``` - -最后一行创建可执行文件,并将可执行文件与 OpenCV 库链接,正如我们在上一节*创建库*中所看到的那样。*这段代码中有一个新函数`SET`;该函数创建一个新变量,并向其添加我们需要的任何值。 在我们的示例中,我们在`SRC`变量中合并了`main.cpp`参数的值。 我们可以向同一变量添加越来越多的值,如以下脚本所示: - -```cpp -SET(SRC main.cpp - utils.cpp - color.cpp -) -``` - -# 使脚本更加复杂 - -在本节中,我们将展示一个更复杂的脚本,它包含子文件夹、库和可执行文件;总共只有两个文件和几行代码,如此脚本所示。 创建多个`CMakeLists.txt`文件不是强制性的,因为我们可以在主`CMakeLists.txt`文件中指定所有内容。 但是,对每个项目子文件夹使用不同的`CMakeLists.txt`文件更为常见,从而使其更加灵活和可移植。 - -此示例有一个代码结构文件夹,其中包含一个用于`utils`库的文件夹和一个根文件夹,根文件夹包含主可执行文件: - -```cpp -CMakeLists.txt -main.cpp -utils/ - CMakeLists.txt - computeTime.cpp - computeTime.h - logger.cpp - logger.h - plotting.cpp - plotting.h -``` - -然后,我们必须定义两个`CMakeLists.txt`文件,一个在根文件夹中,另一个在根文件夹中。 `CMakeLists.txt`根文件夹文件包含以下内容: - -```cpp - 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`个文件中,我们将编写一个新的库以包括在我们的主项目文件夹中: - -```cpp -# 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 所做的那样)。 - -在我们的示例中,我们使用此选项在应用中启用记录器。 要启用记录器,我们在代码中使用预编译器定义,如下所示: - -```cpp -#ifdef LOG - logi("Number of iteration %d", i); -#endif -``` - -此对数宏可以通过调用`add_definitions`函数(`-DLOG`)在我们的`CMakeLists.txt`中定义,该函数本身可以由 CMake 变量`WITH_LOG`运行或隐藏,条件很简单: - -```cpp -if(WITH_LOG) - add_definitions(-DLOG) -endif(WITH_LOG) -``` - -现在我们已经准备好创建我们的 CMake 脚本文件,以便在任何操作系统上编译我们的计算机视觉项目。 然后,在开始示例项目之前,我们将继续学习 OpenCV 基础知识。 - -# 图像和矩阵 - -毫无疑问,计算机视觉中最重要的结构是图像。 计算机视觉中的图像是用数字设备捕获的物理世界的表示。 这张图片只是一个以矩阵格式存储的数字序列(参见下图)。 每个数字都是对所考虑的波长(例如,彩色图像中的红、绿或蓝)或波长范围(对于全色设备)的光强度的测量。 图像中的每个点都称为非**像素**(对于图片元素),每个像素可以存储一个或多个值,具体取决于它是仅存储一个值的黑白图像(也称为二进制图像),如`0`或`1`,存储两个值的灰度图像,还是存储三个值的彩色图像。 这些值通常在整数`0`和`255`之间,但您可以使用其他范围,例如浮点数的`0`到`1`,如**h****高动态范围成像**(**HDRI**)或热像: - -![](img/e4190036-f9b4-48ab-96f5-de9d971b33c4.png) - -图像以矩阵格式存储,其中每个像素都有一个位置,并且可以通过列号和行号来引用。 OpenCV 使用`Mat`类来实现此目的。 对于灰度图像,使用单个矩阵,如下图所示: - -![](img/9afa1db4-0f94-4d90-8e1b-fb84ea5a7dea.png) - -在彩色图像的情况下,如下图所示,我们使用宽度 x 高度 x 颜色通道数的矩阵: - -![](img/06c0b819-e68c-4843-91fd-08feadf5a698.png) - -但是`Mat`类不仅用于存储图像;它还允许您存储任何类型和不同大小的矩阵。 您可以将其用作代数矩阵并对其执行运算。 在接下来的几节中,我们将介绍最重要的矩阵运算,例如加法、乘法、对角化。 但是,在此之前,了解矩阵在计算机内存中的内部存储方式很重要,因为访问内存插槽总是比使用 OpenCV 函数访问每个像素效率更高。 - -在内存中,矩阵保存为按列和行排序的数组或值序列。 下表显示了**BGR**图像格式的像素序列: - -| 第 0 行 | 第 1 行 | 第 2 行 | -| 使用 0 | 使用 1 个 | 使用 2 个 | 使用 0 | 使用 1 个 | 使用 2 个 | 使用 0 | 使用 1 个 | 使用 2 个 | -| 像素ρ1 | 像素 2 | 像素 3 | 像素 4 | 像素 5 | 像素 6 | 像素 7 | 像素 8 | 像素 9 | -| 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | 英语字母表中第二个字母 / B 音 / 乙等 | 英语字母表中第七个字母 / 第七列 | 英语字母中的第十八个字母 / R 类 | - -按照此顺序,我们可以通过遵循以下公式访问任何像素: - -```cpp -Value= Row_i*num_cols*num_channels + Col_i + channel_i -``` - -OpenCV functions are quite optimized for random access, but sometimes, direct access to the memory (work with pointer arithmetic) is more efficient, for example, when we have to access all pixels in a loop. - -# 读/写图像 - -在介绍了矩阵之后,我们将从 OpenCV 代码基础开始。 我们首先要学习的是如何读写图像: - -```cpp -#include -#include -#include -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(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; -} - -``` - -现在让我们继续理解代码: - -```cpp -// OpenCV includes -#include "opencv2/core.hpp" -#include "opencv2/highgui.hpp" -using namespace cv; -``` - -首先,我们必须在示例中包含所需函数的声明。 这些函数来自`core`(基本图像数据处理)和`highgui`(OpenCV 提供的跨平台 I/O 函数包括`core`和`highui;`;第一个函数包括矩阵等基础类,第二个函数包括图形界面读、写和显示图像的函数)。 现在是阅读图片的时候了: - -```cpp -// 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`函数,该函数将矩阵图像存储在我们的计算机中: - -```cpp -// Write images -imwrite("lenaGray.jpg", gray); -``` - -第一个参数是我们要用所需的扩展格式保存图像的路径。 第二个参数是我们要保存的矩阵图像。 在我们的代码示例中,我们创建并存储图像的灰色版本,然后将其另存为`.jpg`文件。 我们加载的灰度图像将存储在第二个灰度变量中: - -```cpp -// Get same pixel with opencv function -int myRow=color.cols-1; -int myCol=color.rows-1; -``` - -使用矩阵的`.cols`和`.rows`属性,我们可以获取图像中的列数和行数,也就是宽度和高度: - -```cpp -Vec3b pixel= color.at(myRow, myCol); -cout << "Pixel value (B,G,R): (" << (int)pixel[0] << "," << (int)pixel[1] << "," << (int)pixel[2] << ")" << endl; -``` - -要访问图像的一个像素,我们使用`Mat`OpenCV 类中的模板函数`cv::Mat::at(row,col)`。 模板参数是所需的返回类型。 8 位彩色图像中的一个类型名称是存储三个无符号字符数据(Vec=向量,3=分量数,b=1 字节)的`Vec3b`类。 对于灰色图像,我们可以直接使用无符号字符,或图像中使用的任何其他数字格式,如`uchar pixel= color.at(myRow, myCol)`。最后,为了显示图像,我们可以使用`imshow`函数,该函数创建一个窗口,第一个参数是标题,第二个参数是图像矩阵: - -```cpp -// show images -imshow("Lena BGR", color); -imshow("Lena Gray", gray); -// wait for any key press -waitKey(0); -``` - -If we want to stop the application from waiting, we can use the OpenCV function `waitKey`, with a parameter of the number of milliseconds we want to wait for a key press. If we set up the parameter to `0`, then the function will wait until a key is pressed. - -上述代码的结果如下图所示。 左边的图像是彩色图像,右边的图像是灰度图像: - -![](img/0b25b775-27e1-472c-89b7-0c5404c14a83.png) - -最后,作为以下示例的示例,我们将创建`CMakeLists.txt`代码文件,并了解如何使用该文件编译代码。 - -下面的代码描述了`CMakeLists.txt`文件: - -```cpp -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`文件夹内,执行 CMake 或在 Windows 中打开 CMake GUI 应用,选择`source`和`build`文件夹,然后按配置和生成按钮。 -3. 如果您使用的是 Linux 或 MacOS,请像往常一样生成一个 Makefile,然后使用*`make`命令编译该项目。 如果您使用的是 Windows,请使用在步骤 2 中选择的编辑器打开项目,然后编译。 - -最后,在编译我们的应用之后,我们将在 Build 文件夹中拥有一个名为`app`的可执行文件,我们可以执行该文件。 - -# 阅读视频和摄像机 - -本节使用这个简单的示例向您介绍视频和相机阅读。 在解释如何读取视频或摄像机输入之前,我们想介绍一个新的、非常有用的类,它可以帮助我们管理输入命令行参数。 此新类是在 OpenCV 3.0 版中引入的,是`CommandLineParser`类: - -```cpp -// 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`向量中需要或允许哪些参数;每行都有以下模式: - -```cpp -"{name_param | default_value | description}" -``` - -`name_param`前面可以有`@`,它将此参数定义为默认输入。 我们可以使用多个`name_param`: - -```cpp -CommandLineParser parser(argc, argv, keys); -``` - -构造函数将获得 Main 函数的输入和先前定义的键常量: - -```cpp -//If requires help show -if (parser.has("help")) -{ - parser.printMessage(); - return 0; -} -``` - -`.has`类方法检查参数是否存在。 在示例中,我们检查用户是否添加了参数`help`或`?`,然后使用类函数`printMessage`显示所有描述参数: - -```cpp - String videoFile= parser.get(0); -``` - -使用`.get(parameterName)`函数,我们可以访问和读取任何输入参数: - -```cpp - // Check if params are correctly parsed in his variables - if (!parser.check()) - { - parser.printErrors(); - return 0; - } -``` - -获取所有必需的参数后,我们可以检查这些参数是否正确解析,如果其中一个参数没有解析,则会显示错误消息,例如,添加字符串而不是数字: - -```cpp -VideoCapture cap; // open the default camera -if(videoFile != "") - cap.open(videoFile); -else - cap.open(0); -if(!cap.isOpened()) // check if we succeeded - return -1; -``` - -视频读取和摄像头读取的类是相同的:与先前版本的 OpenCV 一样,属于`videoio`子模块的是`VideoCapture`类,而不是属于`highgui`子模块的`VideoCapture`类。 创建对象后,我们检查输入命令行参数`videoFile`是否有路径文件名。 如果它是空的,则我们尝试打开网络摄像机;如果它有文件名,则打开视频文件。 为此,我们使用`open`函数,给出我们想要打开的视频文件名或索引摄像机作为参数。 如果我们只有一台摄像机,我们可以使用`0`作为参数。 - -为了检查是否可以读取视频文件名或摄像头,我们使用`isOpened`函数: - -```cpp -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)`使用任何键来停止应用执行。 - -The time required to wait for the next frame using camera access is calculated from the camera speed and our spent algorithm time. For example, if a camera works at 20 fps, and our algorithm spent 10 milliseconds, a great waiting value is *30* = (*1000*/*20*) - *10* milliseconds. This value is calculated considering a wait of a sufficient amount of time to ensure that the next frame is in the buffer. If our camera takes 40 milliseconds to take each image, and we use 10 milliseconds in our algorithm, then we only need to stop with waitKey 30 milliseconds, because 30 milliseconds of wait time, plus 10 milliseconds of our algorithm, is the same amount of time for which each frame of the camera is accessible. - -当用户想要完成应用时,他们所要做的就是按任意键,然后我们必须使用释放功能释放所有的视频资源。 - -It is very important to release all resources that we use in a computer vision application. If we do not, we can consume all RAM memory. We can release the matrices using the `release` function. - -前面代码的结果是一个新窗口,显示 BGR 格式的视频或网络摄像机。 - -# 其他基本对象类型 - -我们已经了解了`Mat`和`Vec3b`类,但我们还需要学习更多的类。 - -在本节中,我们将了解大多数项目所需的最基本的对象类型: - -* `Vec` -* `Scalar` -* `Point` -* `Size` -* `Rect` -* `RotatedRect` - -# VEC 对象类型 - -`Vec`是一个主要用于数值向量的模板类。 我们可以定义任何类型的向量和分量数量: - -```cpp -Vec myVector; -``` - -我们还可以使用任何预定义类型: - -```cpp -typedef Vec Vec2b; -typedef Vec Vec3b; -typedef Vec Vec4b; - -typedef Vec Vec2s; -typedef Vec Vec3s; -typedef Vec Vec4s; - -typedef Vec Vec2i; -typedef Vec Vec3i; -typedef Vec Vec4i; - -typedef Vec Vec2f; -typedef Vec Vec3f; -typedef Vec Vec4f; -typedef Vec Vec6f; - -typedef Vec Vec2d; -typedef Vec Vec3d; -typedef Vec Vec4d; -typedef Vec Vec6d; - -``` - -All the following vector operations are also implemented: -`v1 = v2 + v3` -`v1 = v2 - v3` -`v1 = v2 * scale` -`v1 = scale * v2` -`v1 = -v2` -`v1 += v2`   - -实现的其他扩充操作如下: -`v1 == v2, v1 != v2` -`norm(v1) (euclidean norm)`。 - -# 标量对象类型 - -`Scalar`对象类型是从`Vec`派生的具有四个元素的模板类。 `Scalar`类型在 OpenCV 中广泛用于传递和读取像素值。 - -要访问`Vec`和`Scalar`值,我们使用`[]`操作符,它可以从另一个标量、向量或逐值初始化,如以下示例所示: - -```cpp -Scalar s0(0); -Scalar s1(0.0, 1.0, 2.0, 3.0); -Scalar s2(s1); -``` - -# 点对象类型 - -另一个非常常见的类模板是`Point`。 此类定义由其坐标`x`和`y`指定的二维点。 - -Like `Point`, there is a `Point3` template class for 3D point support. - -与`Vec`类一样,为方便起见,OpenCV 定义了以下`Point`别名: - -```cpp -typedef Point_ Point2i; -typedef Point2i Point; -typedef Point_ Point2f; -typedef Point_ 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; -``` - -# 大小对象类型 - -OpenCV 中另一个非常重要且广泛使用的模板类是用于指定图像或矩形大小的模板类`Size`。 该类添加了两个成员:width 和 Height,以及有用的函数 Size`area()`。*在下面的示例中,我们可以看到使用 SIZE 的多种方法: - -```cpp -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`是定义由以下参数定义的 2D 矩形的另一个重要模板类: - -* 左上角的坐标 - -* 矩形的宽度和高度 - -`Rect`模板类可用于定义图像的**感兴趣区域**和(**ROI**),如下所示: - -```cpp -Mat img=imread("lena.jpg"); -Rect rect_roi(0,0,100,100); -Mat img_roi=img(r); -``` - -# RotatedRect 对象类型 - -最后一个有用的类是一个名为`RotatedRect`的特定矩形。 此类表示由中心点、矩形的宽度和高度以及旋转角度(以度为单位)指定的旋转矩形: - -```cpp -RotatedRect(const Point2f& center, const Size2f& size, float angle); -``` - -这个类的一个有趣的函数是`boundingBox`。 此函数返回`Rect`,其中包含旋转的矩形: - -![](img/d2f5d439-a48d-48fe-bc63-8ee6486b8c27.png) - -# 基本矩阵运算 - -在本节中,我们将学习一些可以应用于图像或任何矩阵数据的基本且重要的矩阵运算。 我们学习了如何加载图像并将其存储在`Mat`变量中,但我们可以手动创建`Mat`。 最常见的构造函数是为矩阵指定大小和类型,如下所示: - -```cpp -Mat a= Mat(Size(5,5), CV_32F); -``` - -You can create a new matrix linking with a stored buffer from third-party libraries without copying data using this constructor: `Mat(size, type, pointer_to_buffer)`. - -支持的类型取决于您要存储的号码类型和频道数量。 最常见的类型如下: - -```cpp -CV_8UC1 -CV_8UC3 -CV_8UC4 -CV_32FC1 -CV_32FC3 -CV_32FC4 -``` - -You can create any type of matrix using `CV_number_typeC(n)`, where the `number_type` is 8 bits unsigned (8U) to 64 float (64F), and where `(n)` is the number of channels; the number of channels permitted ranges from `1` to `CV_CN_MAX`. - -初始化不会设置数据值,因此您可能会得到不需要的值。 为避免不需要的值,您可以使用`0`或`1`值以及它们各自的函数来初始化矩阵: - -```cpp -Mat mz= Mat::zeros(5,5, CV_32F); -Mat mo= Mat::ones(5,5, CV_32F); -``` - -上述矩阵的结果如下: - -![](img/94be0666-71d8-4e29-b87e-cf7191d4410e.png) - -特殊矩阵初始化是 EYE 函数,它创建具有指定类型和大小的单位矩阵: - -```cpp -Mat m= Mat::eye(5,5, CV_32F); -``` - -输出如下: - -![](img/c9e50aa3-b54a-45ce-af48-2f0c84049749.png) - -OpenCV 的`Mat`类中允许所有矩阵运算。 我们可以使用`+`和`-`运算符将两个大小相同的矩阵相加或相减,如以下代码块所示: - -```cpp -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; -``` - -上述操作的结果如下: - -![](img/17298d68-e4c8-4ee8-8e22-03f41f83fe81.png) - -我们可以使用*`*`运算符乘以标量,或者使用*`mul`函数计算每个元素的矩阵,并且可以使用*`*`运算符执行矩阵乘法: - -```cpp -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; -``` - -上述操作的结果如下: - -![](img/5695ad43-0eab-4d4b-85ce-dbd04c17fecb.png) - -其他常见的数学矩阵运算是**转置**和**矩阵求逆**,分别由函数`t()`和`inv()`定义。 OpenCV 提供的其他有趣功能是矩阵中的数组操作,例如,对非零元素进行计数。 这对于计算对象的像素或面积很有用: - -```cpp -int countNonZero(src); -``` - -OpenCV 提供了一些统计功能。 通道的平均值和标准偏差可以使用以下函数`meanStdDev`计算: - -```cpp -meanStdDev(src, mean, stddev); -``` - -另一个有用的统计函数是`minMaxLoc`。 此函数用于查找矩阵或数组的最小值和最大值,并返回位置和值: - -```cpp -minMaxLoc(src, minVal, maxVal, minLoc, maxLoc); -``` - -这里,`src`是输入矩阵,`minVal`和`maxVal`是检测到的双值,`minLoc`和`maxLoc`是检测到的`Point`值。 - -Other core and useful functions are described in detail at: [http://docs.opencv.org/modules/core/doc/core.html](https://docs.opencv.org/master/dc/d84/group__core__basic.html). - -# 基本数据持久化和存储 - -在完成本章之前,我们将探索用于存储和读取数据的 OpenCV 函数。 在许多应用中,例如校准或机器学习,当我们完成一些计算时,我们需要保存这些结果,以便在后续操作中检索它们。 为此,OpenCV 提供了一个 XML/YAML 持久层。 - -# 写入文件存储 - -要使用某些 OpenCV 或其他数字数据写入文件,我们可以使用`FileStorage`类,并使用 STL 流等流`<<`运算符: - -```cpp -#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)的路径文件名,并将第二个参数设置为 WRITE: - -```cpp -FileStorage fs("test.yml", FileStorage::WRITE); -``` - -如果我们想要保存数据,我们只需要使用流运算符,在第一阶段给出一个标识符,然后给出我们想要保存的矩阵或值。 例如,要保存`int`变量,我们只需编写以下代码行: - -```cpp -int fps= 5; -fs << "fps" << fps; -``` - -否则,我们可以写入/保存`mat`,如下所示: - -```cpp -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 格式: - -```cpp -%YAML:1.0 -fps: 5 -Result: !!opencv-matrix - rows: 2 - cols: 3 - dt: f - data: [ 8., 3., 3., 3., 8., 3\. ] -``` - -从文件存储中读取以读取先前保存的文件与`save`功能非常相似: - -```cpp -#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::READ`使用`FileStorage`构造函数打开保存的文件: - -```cpp -FileStorage fs2("test.yml", FileStorage::READ); -``` - -要读取任何存储的变量,我们只需要使用公共流运算符`>>`(使用我们的`FileStorage`对象)和标识符(带有`[]`运算符): - -```cpp -Mat r; -fs2["Result"] >> r; -``` - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了 OpenCV 的基础知识和最重要的类型和操作,图像和视频的访问,以及它们如何存储在矩阵中。 我们学习了存储像素、向量等的基本矩阵运算和其他基本 OpenCV 类。 最后,我们学习了如何将数据保存在文件中,以便在其他应用或其他执行中可以直接读取。 - -在下一章中,我们将学习如何创建我们的第一个应用,学习 OpenCV 提供的图形用户界面的基础知识。 我们将创建按钮和滑块,并介绍一些图像处理基础知识。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/03.md b/trans/build-cv-proj-opencv4-cpp/03.md deleted file mode 100644 index 06f6160c..00000000 --- a/trans/build-cv-proj-opencv4-cpp/03.md +++ /dev/null @@ -1,808 +0,0 @@ -# 学习图形用户界面 - -在[第 2 章](02.html)和*OpenCV 基础知识*中,我们学习了 OpenCV 的基本类和结构,以及最重要的类`Mat`。 我们学习了如何读取和保存图像和视频,以及图像记忆的内部结构。 我们现在已经准备好使用 OpenCV,但在大多数情况下,我们需要显示图像结果,并使用多个用户界面检索用户与图像的交互。 OpenCV 为我们提供了几个基本的用户界面,帮助我们创建应用和原型。为了更好地理解用户界面是如何工作的,我们将在本章末尾创建一个名为**PhotoTool**的小型应用。 在本应用中,我们将学习如何使用滤镜和颜色转换。 - -本章介绍以下主题: - -* OpenCV 基本用户界面 -* OpenCV Qt 界面 -* 滑块和按钮 -* 高级用户界面-OpenGL -* 颜色转换 -* 基本过滤器 - -# 技术要求 - -本章要求熟悉基本的 C++ 编程语言。 本章使用的所有代码都可以从以下 gihub 链接下载:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter03](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter03)。这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。 - -请查看以下视频以了解代码的实际使用情况: -[http://bit.ly/2KH2QXD](http://bit.ly/2KH2QXD) - -# OpenCV 用户界面简介 - -OpenCV 拥有自己的跨操作系统用户界面,允许开发人员创建自己的应用,而无需学习复杂的用户界面库。 OpenCV 用户界面是基本的,但它为计算机视觉开发人员提供了创建和管理其软件开发的基本功能。 所有这些都是原生的,并针对实时使用进行了优化。 - -OpenCV 提供两个用户界面选项: - -* 基于原生用户界面的基本界面,适用于 Mac OS X 的 Cocoa 或 Carbon,以及适用于 Linux 或 Windows 用户界面的 GTK,在编译 OpenCV 时默认选择。 -* 一个基于 Qt 库的更高级的界面,它是一个跨平台的界面。 在编译 OpenCV 之前,您必须在 CMake 中手动启用 Qt 选项。 - -在下面的屏幕截图中,您可以看到左侧的基本用户界面窗口和右侧的 Qt 用户界面: - -![](img/b69bc634-2911-40d0-9411-e81b3669ca1d.png) - -# 使用 OpenCV 的基本图形用户界面 - -我们将使用 OpenCV 创建一个基本的用户界面。 OpenCV 用户界面允许我们创建窗口,向其中添加图像,以及移动、调整大小和销毁窗口。 用户界面在 OpenCV 的`highui`模块中。在下面的代码中,我们将学习如何通过按一个键来显示多个窗口,同时图像在桌面的窗口中移动,从而创建和显示两个图像。 - -不要担心阅读完整的代码;我们将分成小块进行解释: - -```cpp -#include -#include -#include -using namespace std; - -// OpenCV includes -#include -#include -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`模块: - -```cpp -#include -``` - -2. 现在我们已经准备好创建我们的新窗口,我们必须加载一些图像: - -```cpp -// Read images -Mat lena= imread("../lena.jpg"); -Mat photo= imread("../photo.jpg"); -``` - -3. 要创建窗口,我们使用`namedWindow`函数。 该函数有两个参数;第一个参数是包含窗口名称的常量字符串,第二个参数是我们需要的标志。 第二个参数是可选的: - -```cpp -namedWindow("Lena", WINDOW_NORMAL); -namedWindow("Photo", WINDOW_AUTOSIZE); -``` - -4. 在我们的示例中,我们创建了两个窗口:第一个窗口称为`Lena`,第二个窗口称为`Photo`。 - -默认情况下,Qt 和 NATIVE 有三个标志: - -Qt 具有多个附加标志: - -If we compile OpenCV with Qt, all the windows that we create are, by default, in the expanded interface, but we can use native interfaces and more basic ones adding the `CV_GUI_NORMAL` flag. By default, the flags are `WINDOW_AUTOSIZE`, `WINDOW_KEEPRATIO`, and `WINDOW_GUI_EXPANDED`. - -5. 当我们创建多个窗口时,它们是重叠的,但我们可以使用`moveWindow`函数将窗口移动到桌面的任何区域,如下所示: - -```cpp -// Move window -moveWindow("Lena", 10, 10); -moveWindow("Photo", 520, 10); -``` - -6. 在我们的代码中,我们向左移动`Lena`窗口`10`像素,向上移动`10`像素,向左移动`Photo`窗口`520`像素,向上移动`10`像素: - -```cpp -// show images -imshow("Lena", lena); -imshow("Photo", photo); -// Resize window, only non autosize -resizeWindow("Lena", 512, 512); -``` - -7. 在显示了我们之前使用`imshow`函数加载的图像之后,我们调用`resizeWindow`函数,将`Lena`窗口的大小调整为`512`像素。 该函数有三个参数:`window name`、`width`和`height`。 - -The specific window size is for the image area. Toolbars are not counted. Only windows without the `WINDOW_AUTOSIZE` flag enabled can be resized. - -8. 在使用`waitKey`函数等待按键后,我们将使用`destroyWindow`函数移除或删除我们的窗口,其中窗口名称是唯一必需的参数: - -```cpp -waitKey(0); - -// Destroy the windows -destroyWindow("Lena"); -destroyWindow("Photo"); -``` - -9. OpenCV 有一个功能,可以删除我们在一次调用中创建的所有窗口。 该函数称为`destroyAllWindows`。 为了演示其工作原理,我们在样例中创建了 10 个窗口,并等待按键。 当用户按任意键时,它会销毁所有窗口: - -```cpp - // 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 都会在应用终止时自动处理所有窗口的销毁,并且没有必要在应用结束时调用此函数。 - -所有这些代码的结果可以在下面两个步骤的图像中看到。 首先,它显示两个窗口: - -![](img/8b43e8bc-dfb5-4646-af8c-bdac68791dc7.png) - -按下任意键后,应用将继续并绘制几个改变位置的窗口: - -![](img/5c09380d-ac52-4546-8bc8-1ecae6e8a71d.png) - -只需几行代码,我们就能够创建和操作窗口并显示图像。 我们现在已经准备好促进用户与图像的交互,并添加用户界面控件。 - -# 将滑块和鼠标事件添加到我们的界面 - -鼠标事件和滑块控制在计算机视觉和 OpenCV 中非常有用。 使用这些控件用户,我们可以直接与界面交互,并更改输入图像或变量的属性。在本节中,我们将介绍用于基本交互的鼠标事件和滑块控件。 为了便于正确理解,我们创建了以下代码,通过这些代码,我们将使用鼠标事件在图像中绘制绿色圆圈,并使用鼠标滑块模糊图像: - -```cpp -// 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; -} -``` - -让我们来理解一下代码! - -首先,我们创建一个变量来保存滑块位置。 我们需要保存滑块位置,以便从其他功能访问: - -```cpp -// Create a variable to save the position value in track -int blurAmount=15; -``` - -现在,我们定义滑块和鼠标事件的回调,这是 OpenCV 函数`setMouseCallback`和`createTrackbar`所需的: - -```cpp -// 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); - -``` - -在 main 函数中,我们加载一个图像并创建一个名为`Lena`的新窗口: - -```cpp -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`,并调用了`Lena`跟踪条,以便模糊图像。 跟踪条的值存储在我们作为指针传递的第一个`blurAmount`整数中,并将该条的最大值设置为`30`。 我们将`onChange`设置为回调函数,并将 Lena Mat 图像作为用户数据发送: - -```cpp - // create a trackbar - createTrackbar("Lena", "Lena", &blurAmount, 30, onChange, &lena); -``` - -创建滑块后,当用户单击鼠标左键时,我们将鼠标事件添加到绘制圆圈中。 OpenCV 具有`setMouseCallback`测试功能。 此函数有三个参数: - -* 我们在其中获取鼠标事件的窗口名称。 - -* 当有任何鼠标交互时要调用的回调函数。 - -* **用户数据**:这是调用回调函数时将发送给它的任何数据。 在我们的示例中,我们将发送整个`Lena`图像。 - -使用以下代码,我们可以将鼠标回调添加到`Lena`窗口,并将`onMouse`设置为回调函数,将 Lena Mat 图像作为用户数据进行传递: - -```cpp -setMouseCallback("Lena", onMouse, &lena); -``` - -要只完成 Main 函数,我们需要使用与滑块相同的参数来初始化图像。 要执行初始化,我们只需调用参数`onChange`的回调函数,并在使用`destroyWindow`*、*关闭窗口之前等待事件,如以下代码所示: - -```cpp -// Call to onChange to init -onChange(blurAmount, &lena); - -// wait app for a key to exit -waitKey(0); - -// Destroy the windows -destroyWindow("Lena"); -``` - -滑块回调使用滑块值作为模糊量对图像应用基本模糊滤镜: - -```cpp -// 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`之外的任何事件。 丢弃其他事件后,我们会通过滑块回调获得类似的输入图片,并使用 OpenCV 函数中的圆圈来获取图片中的圆圈: - -```cpp -//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 用户界面为我们提供了更多的控制和选项来处理我们的图像。 - -该界面分为以下三个主要区域: - -* 工具栏 -* 图像区域 -* 状态栏 - -我们可以在下图中看到这三个区域。 图片顶部是工具栏,图片是主区域,图片底部可以看到状态栏: - -![](img/3a140d03-40d0-49ff-b28a-52b8de8d1450.png) - -工具栏从左到右有以下按钮: - -* 四个平移按钮 -* 缩放 x1 -* 缩放 x30,显示标签 -* 放大 -* 拉远 / 拉远镜头 -* 保存当前图像 -* 显示属性 - -这些选项在下图中可以清楚地看到: - -![](img/92ecf1c6-62c1-43d9-8655-d447e784184c.png) - -当我们在图像上按鼠标右键时,图像区域会显示一个图像和一个上下文菜单。 该区域可以使用`displayOverlay`函数在区域顶部显示覆盖消息。该函数接受三个参数:窗口名称、我们想要显示的文本以及覆盖文本显示的毫秒周期。 如果将此时间设置为`0`,则文本永远不会消失: - -```cpp -// Display Overlay -displayOverlay("Lena", "Overlay 5secs", 5000); -``` - -我们可以在下图中看到前面代码的结果。 你可以在图片的顶部看到一个小黑框,上面有一句话叠加了 5 秒: - -![](img/aba441bc-2074-4277-81e5-98b24f7cedcb.png) - -最后,状态栏显示窗口的底部,并显示图像中坐标的像素值和位置: - -![](img/51ef8c4f-74dc-461b-b9d3-fcce324f3879.png) - -我们可以使用状态栏以覆盖的方式显示消息。 可以更改状态栏消息的函数是`displayStatusBar`。此函数与覆盖函数具有相同的参数:窗口名称、要显示的文本以及显示它的时间段: - -![](img/73993491-a939-45ba-b3c1-0d0751c71782.png) - -# 向用户界面添加按钮 - -在前面的小节中,我们学习了如何创建普通或 Qt 界面,并使用鼠标和滑块与它们交互,但我们也可以创建不同类型的按钮。 - -Buttons are only supported in Qt windows. - -OpenCV Qt 支持的按钮类型如下: - -* 按钮 -* 校验框 -* 无线电箱 - -这些按钮仅显示在控制面板中。 控制面板是每个程序的一个独立窗口,我们可以在其中附加按钮和轨迹条。要显示控制面板,我们可以按下最后一个工具栏按钮,右键单击 Qt 窗口的任何部分并选择显示属性窗口,或者使用*Ctrl*+*P*快捷键。让我们创建一个带有按钮的基本示例。 代码很丰富,我们将先解释 Main 函数,然后再分别解释每个回调函数,以便更好地理解所有内容。 下面的代码向我们展示了生成用户界面的主要代码函数: - -```cpp -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 滤镜和颜色到灰色的转换。 所有这些都是可选的,用户可以使用我们将要创建的按钮来选择每个选项。 然后,为了获得每个过滤器的状态,我们创建了三个全局布尔变量: - -```cpp -bool applyGray=false; -bool applyBlur=false; -bool applySobel=false; -``` - -在 Main 函数中,在加载图像并创建窗口之后,我们必须使用`createButton`函数来创建每个按钮。 - -OpenCV 中定义了三种按钮类型: - -* `QT_CHECKBOX` -* `QT_RADIOBOX` -* `QT_PUSH_BUTTON` - -每个按钮都有五个参数,顺序如下: - -1. 按钮名称 -2. 回调函数 -3. 指向传递给回调的用户变量数据的指针 -4. 按钮类型 -5. CheckBox 和 RadioBox 按钮类型使用的默认初始化状态 - -然后,我们创建一个模糊复选框按钮、两个用于颜色转换的单选按钮和一个用于 Sobel 滤镜的按钮,如以下代码所示: - -```cpp - // 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`的函数,以便将激活的滤镜添加到输入图像: - -```cpp -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`函数检查每个过滤器的状态变量: - -```cpp -void applyFilters(){ - Mat result; - img.copyTo(result); - if(applyGray){ - cvtColor(result, result, COLOR_BGR2GRAY); - } - if(applyBlur){ - blur(result, result, Size(5,5)); - } - if(applySobel){ - Sobel(result, result, CV_8U, 1, 1); - } - imshow("Lena", result); -} -``` - -要将颜色更改为灰色,我们使用`cvtColor`函数,该函数接受三个参数:输入图像、输出图像和颜色转换类型。 - -最有用的颜色空间转换如下: - -* RGB 或 BGR 变为灰色(`COLOR_RGB2GRAY`、`COLOR_BGR2GRAY`) -* RGB 或 BGR 到 YcrCb(或 YCC)(`COLOR_RGB2YCrCb`,`COLOR_BGR2YCrCb`) -* RGB 或 BGR 到 HSV(`COLOR_RGB2HSV`,`COLOR_BGR2HSV`) -* RGB 或 BGR 到 Luv(`COLOR_RGB2Luv`,`COLOR_BGR2Luv`) -* 灰度到 RGB 或 BGR(`COLOR_GRAY2RGB`,`COLOR_GRAY2BGR`) - -我们可以看到,密码很容易记住。 - -OpenCV works by default with the BGR format, and the color conversion is different for RGB and BGR, even when converted to gray. Some developers think that *R*+*G*+*B*/*3* is true for gray, but the optimal gray value is called **luminosity** and has the formula *0*,*21***R* + *0*,*72***G* + *0*,*07***B.* - -模糊滤镜已在上一节中进行了描述,最后,如果参数`applySobel`为真,我们将应用 Sobel 滤镜。 Sobel 滤波器是使用 Sobel 算子获得的图像导数,通常用于检测边缘。 OpenCV 允许我们生成不同的核大小的导数,但最常见的是用 3x3 核来计算*x*导数或*y*导数。 - -最重要的 Sobel 参数如下: - -* 输入图像 -* 输出图像 -* 输出图像深度(`CV_8U`,`CV_16U`,`CV_32F`,`CV_64F`) -* 导数*x*的阶数 -* 导数*y*的阶数 -* 内核大小(默认为 3) - -要生成 3x3 内核和一阶*x*阶导数,我们必须使用以下参数: - -```cpp -Sobel(input, output, CV_8U, 1, 0); -``` - -以下参数用于*y*阶导数: - -```cpp -Sobel(input, output, CV_8U, 0, 1); -``` - -在我们的示例中,我们同时使用*x*和*y*导数,覆盖输入。 以下代码片段显示如何同时生成*x*和*y*导数,在第四个和第五个参数中添加`1`: - -```cpp -Sobel(result, result, CV_8U, 1, 1); -``` - -同时应用*x*和*y*导数的结果如下图所示,应用于 Lena 图片: - -![](img/639c9969-7c38-46c6-ac73-e9d1f88776b1.png) - -# OpenGL 支持 - -OpenCV 包括 OpenGL 支持。 OpenGL 是一个图形库,作为标准集成在几乎所有图形卡中。 OpenGL 允许我们绘制 2D 到复杂的 3D 场景。 OpenCV 包括 OpenGL 支持,因为在许多任务中表示 3D 空间非常重要。 要在 OpenGL 中支持窗口,我们必须在使用`namedWindow`调用创建窗口时设置`WINDOW_OPENGL`标志。 - -下面的代码创建了一个支持 OpenGL 的窗口,并绘制了一个旋转平面,我们将在该平面上显示网络摄像机帧: - -```cpp -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 纹理: - -```cpp -Mat frame; -GLfloat angle= 0.0; -GLuint texture; -VideoCapture camera; -``` - -在我们的主要功能中,我们必须创建摄像机捕获以检索摄像机帧: - -```cpp -camera.open(0); - if(!camera.isOpened()) - return -1; -``` - -如果摄像机正确打开,我们可以使用`WINDOW_OPENGL`标志创建支持 OpenGL 的窗口: - -```cpp -// Create new windows -namedWindow("OpenGL Camera", WINDOW_OPENGL); -``` - -在我们的示例中,我们希望在平面中绘制来自网络摄像头的图像;然后,我们需要启用 OpenGL 纹理: - -```cpp -// Enable texture -glEnable(GL_TEXTURE_2D); -``` - -现在,我们已经准备好在窗口中使用 OpenGL 进行绘制,但是我们需要像典型的 OpenGL 应用一样设置一个绘制 OpenGL 回调。 OpenCV 提供了`setOpenGLDrawCallback`函数,该函数有两个参数-窗口名称和回调函数: - -```cpp -setOpenGlDrawCallback("OpenGL Camera", on_opengl); -``` - -定义了 OpenCV 窗口和回调函数后,我们需要创建一个循环来加载纹理,更新调用 OpenGL 绘图回调的窗口内容,最后更新角度位置。 要更新窗口内容,我们使用 OpenCV 函数 UPDATE WINDOW,并将窗口名称作为参数: - -```cpp -while(waitKey(30)!='q'){ - camera >> frame; - // Create first texture - loadTexture(); - updateWindow("OpenGL Camera"); - angle =angle+4; - } -``` - -当用户按下*Q*键时,我们就处于循环中。在编译我们的应用示例之前,我们需要定义`loadTexture`函数和我们的`on_opengl`回调绘制函数。 `loadTexture`函数将我们的`Mat`帧转换为 OpenGL 纹理图像,以便在每个回调绘图中加载和使用。 在将图像作为纹理加载之前,我们必须确保帧矩阵中有数据,并检查数据变量对象是否为空: - -```cpp -if (frame.data==NULL) return -1; -``` - -如果矩阵帧中有数据,则可以创建 OpenGL 纹理绑定并将 OpenGL 纹理参数设置为线性插值: - -```cpp -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 格式,我们必须在此函数中设置正确的格式: - -```cpp -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 矩阵以重置之前的所有更改: - -```cpp -glLoadIdentity(); -``` - -我们还必须将框架纹理带到记忆中: - -```cpp - // Load Texture - glBindTexture(GL_TEXTURE_2D, texture); -``` - -在绘制平面之前,我们将所有变换应用于场景。 在我们的示例中,我们将沿`1,1,1`轴旋转平面: - -```cpp - // Rotate plane - glRotatef(angle, 1.0f, 1.0f, 1.0f); -``` - -现在我们已经正确设置了绘制平面的场景,我们将绘制四边形的面(具有四个顶点的面),并为此使用`glBegin (GL_QUADS)`命令: - -```cpp -// Create the plane and set the texture coordinates - glBegin (GL_QUADS); -``` - -接下来,我们将绘制一个以第二`0,0`位置为中心的平面,该平面的大小为 2 个单位。 然后,我们必须使用`glTextCoord2D`和`glVertex2D`函数定义要使用的纹理坐标和顶点位置: - -```cpp - // 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(); -``` - -This OpenGL code becomes obsolete, but it is appropriated to understand better the OpenCV and OpenGL integration without complex OpenGL code. By way of an introduction to modern OpenGL, read *Introduction to Modern OpenGL*, from *Packt Publishing*. - -我们可以在下图中看到结果: - -![](img/b91c5f48-2dd1-4d2d-a3a2-2174123b7983.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了如何使用 OpenGL 创建不同类型的用户界面来显示图像或 3D 界面。 我们学习了如何创建滑块和按钮,或者如何在 3D 中绘图。 我们也通过原生 OpenCV 学习了一些基本的图像处理过滤器,但是有一些新的开源过滤器可以让我们添加更多功能,比如 CVUI([https://dovyski.github.io/cvui/](https://dovyski.github.io/cvui/))或 OpenCVGUI([https://damiles.github.io/OpenCVGUI/](https://damiles.github.io/OpenCVGUI/))。 - -在下一章中,我们将构建一个完整的照片工具应用,在其中我们将应用到目前为止所学的所有知识。 通过图形用户界面,我们将学习如何对输入图像应用多个滤镜。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/04.md b/trans/build-cv-proj-opencv4-cpp/04.md deleted file mode 100644 index 6bbadf58..00000000 --- a/trans/build-cv-proj-opencv4-cpp/04.md +++ /dev/null @@ -1,758 +0,0 @@ -# 深入研究直方图和过滤器 - -在上一章中,我们学习了 OpenCV 中使用 Qt 库或本机库的用户界面的基础知识;我们还学习了如何使用高级 OpenGL 用户界面。 我们了解了基本的颜色转换和允许我们创建第一个应用的过滤器。 本章将向您介绍以下概念: - -* 直方图和直方图均衡化 -* 查找表 -* 模糊和中间模糊 -* 精明过滤器 -* 图像颜色均衡 -* 了解图像类型之间的转换 - -在我们了解了 OpenCV 和用户界面的基础知识之后,我们将在本章中创建我们的第一个完整的应用,一个基本的照片工具,并涵盖以下主题: - -* 生成 CMake 脚本文件 -* 创建图形用户界面 -* 计算和绘制直方图 -* 直方图均衡 -* Lomography 相机效果 -* 卡通化效果 - -这个应用将帮助我们理解如何从头开始创建整个项目,并理解直方图的概念。 我们将结合使用滤镜和查找表,了解如何均衡彩色图像的直方图并创建两种效果。 - -# 技术要求 - -本章要求您熟悉 C++ 编程语言的基础知识。 本章使用的所有代码都可以从以下 giHub 链接下载:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter04](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter04)。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2Sid17y](http://bit.ly/2Sid17y) - -# 生成 CMake 脚本文件 - -在开始创建源文件之前,我们将生成`CMakeLists.txt`文件,以允许我们编译、构造和执行项目。 下面的 CMake 脚本简单而基本,但足以编译和生成可执行文件: - -```cpp -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**版本,如下面的代码片段所示: - -```cpp -cmake_minimum_required (VERSION 3.0) - -PROJECT(Chapter4_Phototool) - -set (CMAKE_CXX_STANDARD 11) -``` - -此外,我们还需要 OpenCV 库。 首先,我们需要找到该库,然后我们将显示一条关于使用`MESSAGE`函数找到的 OpenCV 库版本的消息: - -```cpp -# Requires OpenCV -FIND_PACKAGE( OpenCV 4.0.0 REQUIRED ) -MESSAGE("OpenCV version : ${OpenCV_VERSION}") -``` - -如果找到最低版本为 4.0 的库,则我们将在项目中包含头文件和库文件: - -```cpp -include_directories(${OpenCV_INCLUDE_DIRS}) -link_directories(${OpenCV_LIB_DIR}) -``` - -现在,我们只需要添加要编译并链接到 OpenCV 库的源文件。 项目名称变量用作可执行文件名,我们只使用一个名为`main.cpp`的源文件: - -```cpp -ADD_EXECUTABLE(${PROJECT_NAME} main.cpp) -TARGET_LINK_LIBRARIES(${PROJECT_NAME} ${OpenCV_LIBS}) -``` - -# 创建图形用户界面 - -在开始图像处理算法之前,我们先为应用创建主用户界面。 我们将使用基于 Qt 的用户界面来创建单个按钮。 应用接收一个输入参数来加载要处理的图像,我们将创建四个按钮,如下所示: - -* 显示直方图 -* 均衡化直方图 -* 光照相效果 -* 卡通化效果 - -我们可以在下面的截图中看到四个结果: - -![](img/166b014e-80cc-4c24-9e9d-c8948fe9d9a4.png) - -让我们开始开发我们的项目吧。 首先,我们将包含 OpenCV 所需的标头,定义一个图像矩阵来存储输入图像,并创建一个常量字符串以使用 OpenCV 3.0 中已有的新命令行解析器;在该常量中,我们只允许两个输入参数`help`和所需的图像输入: - -```cpp -// 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}" -}; -``` - -Main 函数从命令行解析器变量开始;接下来,我们设置关于指令并打印帮助消息。 该行设置最终可执行文件的帮助指令: - -```cpp -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()`函数检查是否添加了所有必需的参数: - -```cpp -String imgFile= parser.get(0); - -// Check if params are correctly parsed in his variables -if (!parser.check()) -{ - parser.printErrors(); - return 0; -} -``` - -现在,我们可以使用`imread`函数读取图像文件,然后使用`namedWindow`函数创建稍后将在其中显示输入图像的窗口: - -```cpp -// Load image to process -Mat img= imread(imgFile); - -// Create window -namedWindow("Input"); -``` - -加载图像并创建窗口后,我们只需要为界面创建按钮,并将它们与回调函数链接起来;每个回调函数都在源代码中定义,我们将在本章后面解释这些函数。 我们将使用`createButton`函数创建具有`QT_PUSH_BUTTON`常量的按钮样式: - -```cpp -// 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); -``` - -要完成我们的主要功能,我们显示输入图像并等待按键完成我们的应用: - -```cpp -// Show image -imshow("Input", img); - -waitKey(0); -return 0; -``` - -现在,我们只需定义每个回调函数,在下一节中,我们将这样做。 - -# 绘制直方图 - -直方图是变量分布的统计图形表示,它使我们能够理解数据的密度估计和概率分布。 直方图是通过将整个变量值范围划分为一个小范围的值,然后计算每个区间内有多少值来创建的。 - -如果我们把这个直方图概念应用到一幅图像上,看起来很难理解,但实际上,它很简单。 在灰度图像中,我们的变量值的范围是每个可能的灰度值(从`0`到`255`),密度是具有该值的图像的像素数。 这意味着我们必须计算图像中值为`0`的像素数、值为`1`的像素数,依此类推。 - -显示输入图像直方图的回调函数为`showHistoCallback`;此函数计算每个通道图像的直方图,并在新图像中显示每个直方图通道的结果。 - -现在,检查以下代码: - -```cpp -void showHistoCallback(int state, void* userData) -{ - // Separate image in BRG - vector 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(i-1) )), - Point( binStep*(i), height-cvRound(b_hist.at(i) )), - Scalar(255,0,0) - ); - line(histImage, - Point(binStep*(i-1), height-cvRound(g_hist.at(i-1))), - Point(binStep*(i), height-cvRound(g_hist.at(i))), - Scalar(0,255,0) - ); - line(histImage, - Point(binStep*(i-1), height-cvRound(r_hist.at(i-1))), - Point(binStep*(i), height-cvRound(r_hist.at(i))), - Scalar(0,0,255) - ); - } - - imshow("Histogram", histImage); - -} -``` - -让我们了解如何提取每个通道直方图以及如何绘制它。 首先,我们需要创建三个矩阵来处理每个输入图像通道。 我们使用一个矢量型变量来存储每个变量,并使用`split`OpenCV 函数在这三个通道之间划分输入图像: - -```cpp -// Separate image in BRG - vector bgr; - split(img, bgr); -``` - -现在,我们将定义直方图的箱数,在我们的示例中,每个可能的像素值一个: - -```cpp -int numbins= 256; -``` - -让我们定义变量范围并创建三个矩阵来存储每个直方图: - -```cpp -/// 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`图像 -* **用于计算直方图的数字通道维度**:我们在本例中使用[T0 -* 可选的掩码矩阵。 -* 用于存储计算出的直方图的变量。 -* **直方图维度**:这是图像(这里是灰色平面)取值的空间维度,在我们的示例中为`1` -* **要计算的条柱数量**:在我们的示例中为`256`个条柱,每个像素值一个 -* **输入变量的范围**:在我们的例子中,可能的像素值从`0`到`255` - -每个通道的`calcHist`函数如下所示: - -```cpp -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`像素的彩色图像: - -```cpp -// 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`和最大值之间的直方图矩阵;最大值与输出直方图图像的高度相同: - -```cpp -// Normalize the histograms to height of image -normalize(b_hist, b_hist, 0, height, NORM_MINMAX); -normalize(g_hist, g_hist, 0, height, NORM_MINMAX); -normalize(r_hist, r_hist, 0, height, NORM_MINMAX); -``` - -现在我们必须从 bin`0`到 bin`1`画一条线,依此类推。 在每个 bin 之间,我们必须计算有多少像素;然后,通过宽度除以 bin 的数量计算出一个`binStep`变量。 每条小线从水平位置`i-1`到`i`绘制;垂直位置是对应`i`中的直方图值,并使用颜色通道表示法绘制: - -```cpp -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(i-1))), - Point(binStep*(i), height-cvRound(b_hist.at(i))), - Scalar(255,0,0) - ); - line(histImage, - Point(binStep*(i-1), height-cvRound(g_hist.at(i-1))), - Point( binStep*(i), height-cvRound(g_hist.at(i))), - Scalar(0,255,0) - ); - line(histImage, - Point(binStep*(i-1), height-cvRound(r_hist.at(i-1))), - Point( binStep*(i), height-cvRound(r_hist.at(i))), - Scalar(0,0,255) - ); - } -``` - -最后,我们使用`imshow`函数显示直方图图像: - -```cpp - imshow("Histogram", histImage); -``` - -这是`lena.png`图像的结果: - -![](img/88cfc470-843d-45a6-bfc0-6a0e89826d31.png) - -# 图像色彩均衡 - -在本节中,我们将学习如何均衡彩色图像。 图像均衡化,或直方图均衡化,试图获得值分布均匀的直方图。 均衡的结果是增加了图像的对比度。 均衡可以使局部对比度较低的区域获得高对比度,从而分散最频繁的亮度。 当图像非常暗或很亮,并且背景和前景之间的差异非常小时,这种方法非常有用。 使用直方图均衡化,我们增加了对比度和曝光过多或曝光不足的细节。 这项技术在医学图像(如 X 射线)中非常有用。 - -然而,这种方法有两个主要缺点:背景噪声的增加和有用信号的减少。 我们可以在下面的照片中看到均衡的效果,直方图在增加图像对比度时会发生变化和扩散: - -![](img/cac3607d-9396-4d09-85c8-df67df5609e9.png) - -让我们实现我们的均衡直方图;我们将在用户界面代码中定义的`Callback`函数中实现它: - -```cpp -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 channels; - split(ycrcb, channels); - - // Equalize the Y channel only - equalizeHist(channels[0], channels[0]); - - // Merge the result channels - merge(channels, ycrcb); - - // Convert color ycrcb to BGR - cvtColor(ycrcb, result, COLOR_YCrCb2BGR); - - // Show image - imshow("Equalized", result); -} -``` - -要均衡彩色图像,我们只需均衡亮度通道。 我们可以对每个颜色通道执行此操作,但结果不可用。 或者,我们可以使用分离单个通道中亮度分量的任何其他彩色图像格式,例如**HSV**或**YCrCb**。 因此,我们选择**YCrCb**并使用 Y 通道(亮度)进行均衡。 然后,我们遵循以下步骤: - -1.使用`cvtColor`函数将**bgr**图像转换或输入为**YCrCb**: - -```cpp -Mat result; -// Convert BGR image to YCbCr -Mat ycrcb; -cvtColor(img, ycrcb, COLOR_BGR2YCrCb); -``` - -2.将**YCrCb**镜像拆分成不同的通道矩阵: - -```cpp -// Split image into channels -vector channels; -split(ycrcb, channels); -``` - -3.使用只有两个参数(输入和输出矩阵)的`equalizeHist`函数,仅均衡 Y 通道中的直方图: - -```cpp -// Equalize the Y channel only -equalizeHist(channels[0], channels[0]); -``` - -4.合并生成的通道,并将其转换为**BGR**格式,向用户显示结果: - -```cpp -// Merge the result channels -merge(channels, ycrcb); - -// Convert color ycrcb to BGR -cvtColor(ycrcb, result, COLOR_YCrCb2BGR); - -// Show image -imshow("Equalized", result); -``` - -应用于低对比度`Lena`图像的过程将产生以下结果: - -![](img/643fd55d-4a37-41d4-abad-899710a5805d.png) - -# 光照相效果 - -在本节中,我们将创建另一个图像效果,这是一种在不同的移动应用中非常常见的照片效果,例如 Google Camera 或 Instagram。 我们将了解如何使用**查找表**(**LUT**)。 我们将在同一节后面介绍 LUT。 我们将学习如何添加一个覆盖图像,在本例中是一个暗晕,以创建我们想要的效果。 实现此效果的函数是`lomoCallback`回调,它具有以下代码: - -```cpp -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(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 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); -} -``` - -让我们来看看 Lomography 效果是如何工作的,以及如何实现它。 Lomography 效果分为不同的步骤,但在我们的示例中,我们用两个步骤制作了一个非常简单的 Lomography 效果: - -1. 通过使用查找表将曲线应用于红色通道来实现颜色操纵效果 -2. 通过在图像上应用深色光晕来实现复古效果 - -第一步是通过应用以下函数使用曲线变换来处理红色: - -![](img/eca8d632-7b74-4b6f-8145-3f3b288b1cf5.png) - -此公式生成一条使暗值更暗、亮值更亮的曲线,其中**x**是可能的像素值(`0`到`255`),**s**是我们在示例中设置为`0.1`的常量。 较低的常量值生成的值低于`128`的像素非常暗,高于`128`的像素非常亮。 接近`1`的值将曲线转换为直线,并且不会产生我们想要的效果: - -![](img/849ea6dc-dc83-42af-a348-66f7fd1ea47d.png) - -通过应用 LUT 可以非常容易地实现此功能。 LUT 是返回给定值的预处理值以在内存中执行计算的向量或表。 LUT 是一种常用技术,通过避免重复执行代价高昂的计算来节省 CPU 周期。 我们不是为每个像素调用`exponential`/`divide`函数,而是对每个可能的像素值只执行一次(`256`次),并将结果存储在表中。 因此,我们以牺牲一点内存为代价节省了 CPU 时间。 虽然这在图像尺寸较小的标准 PC 上可能不会有太大的不同,但对于 CPU 受限的硬件(如 Raspberry PI)来说,这就是一个巨大的差异。 - -例如,在我们的示例中,如果要对图像中的每个像素应用函数,则必须进行*width*x*high*操作;例如,在 100x100 像素中,将有 10,000 次计算。 如果我们可以为所有可能的输入预先计算所有可能的结果,我们就可以创建 LUT 表。 在图像中,只有**个**个可能的值作为像素值。 如果我们想要通过应用函数来更改颜色,我们可以预计算出 256 个值,并将它们保存在 LUT 向量中。 在我们的示例代码中,我们定义了`E`变量,并创建了一个由`1`行和`256`列组成的`lut`矩阵。 然后,我们对所有可能的像素值进行循环,方法是应用我们的公式并将其保存到一个`lut`变量中: - -```cpp -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`函数按通道分割输入图像: - -```cpp -// Split the image channels and apply curve transform only to red channel -vector bgr; -split(img, bgr); -``` - -然后,我们将`lut`表变量应用于红色通道。 OpenCV 提供了`LUT`函数,它有三个参数: - -* 输入图像 -* 查找表矩阵 -* 输出图像 - -然后,我们对`LUT`函数和红色通道的调用如下所示: - -```cpp -LUT(bgr[2], lut, bgr[2]); -``` - -现在,我们只需合并我们的计算通道: - -```cpp -// merge result -merge(bgr, result); -``` - -第一步已经完成,我们只需要创建黑暗光环就可以完成我们的效果。 然后,我们创建一个内部有一个白色圆圈的灰色图像,具有相同的输入图像大小: - -```cpp - // 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); -``` - -查看以下屏幕截图: - -![](img/09235ae5-f547-479a-a5aa-3c9e6378b4c5.png) - -如果我们将此图像应用于我们的输入图像,我们将获得从深色到白色的强烈变化;因此,我们可以使用`blur`滤镜函数对我们的圆形光晕图像应用大模糊,以获得平滑的效果: - -```cpp -blur(halo, halo, Size(img.cols/3, img.cols/3)); -``` - -图像将被更改,以提供以下结果: - -![](img/085d480c-7135-4793-b6e1-60a9d870e5ea.png) - -现在,如果我们必须从第一步开始将这个光环应用于我们的图像,一个简单的方法是将两个图像相乘。 但是,我们必须将输入图像从 8 位图像转换为 32 位浮点数,因为我们需要将值在`0`到`1`范围内的模糊图像与具有整数值的输入图像相乘。 下面的代码将为我们做这件事: - -```cpp -// Convert the result to float to allow multiply by 1 factor -Mat resultf; -result.convertTo(resultf, CV_32FC3); -``` - -在转换图像之后,我们只需要将每个元素的每个矩阵相乘: - -```cpp -// Multiply our result with halo -multiply(resultf, halo, resultf); -``` - -最后,我们将浮点图像矩阵结果转换为 8 位图像矩阵: - -```cpp -// convert to 8 bits -resultf.convertTo(result, CV_8UC3); - -// show result -imshow("Lomograpy", result); -``` - -这将是结果: - -![](img/251222ac-208d-43b0-896f-a02570b2b3eb.png) - -# 卡通化效果 - -本章的最后一节致力于创建另一种效果,称为**卡通化**;此效果的目的是创建一个看起来像卡通的图像。 为此,我们将算法分为两个步骤:**边缘检测**和**颜色过滤**。 - -`cartoonCallback`函数定义此效果,其代码如下: - -```cpp -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`,它接受三个参数:输入图像、输出图像和内核大小(内核是一个小矩阵,用于对图像应用一些数学运算,如卷积方法): - -```cpp -Mat imgMedian; -medianBlur(img, imgMedian, 7); -``` - -在去除任何可能的噪声之后,我们使用`Canny`滤波器检测强边缘: - -```cpp -// Detect edges with canny -Mat imgCanny; -Canny(imgMedian, imgCanny, 50, 150); -``` - -`Canny`过滤器接受以下参数: - -* 输入图像 -* 输出图像 -* 第一阈值 -* 第二阈值 -* 索贝尔大小孔径 -* 布尔值,指示我们是否需要使用更精确的图像渐变幅度 - -第一阈值和第二阈值之间的最小值用于边缘链接。 最大值用于查找强边缘的初始分段。 Sobel 大小孔径是算法中将使用的 Sobel 滤波器的核大小。 在检测到边之后,我们将应用一个小的扩张来连接破碎的边: - -```cpp -// Dilate the edges -Mat kernel= getStructuringElement(MORPH_RECT, Size(2,2)); -dilate(imgCanny, imgCanny, kernel); -``` - -与我们在 Lomography 效果中所做的类似,如果我们需要将边缘的结果图像与彩色图像相乘,则需要像素值在`0`和`1`范围内。 为此,我们将用精明的结果除以`256`,并将边缘反转为黑色: - -```cpp -// Scale edges values to 1 and invert values -imgCanny= imgCanny/255; -imgCanny= 1-imgCanny; -``` - -我们还将把 Canny 8 无符号位像素格式转换为浮点矩阵: - -```cpp -// Use float values to allow multiply between 0 and 1 -Mat imgCannyf; -imgCanny.convertTo(imgCannyf, CV_32FC3); -``` - -要获得凉爽的结果,我们可以模糊边缘,要获得平滑的结果线,我们可以应用`blur`滤镜: - -```cpp -// Blur the edgest to do smooth effect -blur(imgCannyf, imgCannyf, Size(5,5)); -``` - -算法的第一步已经完成,现在我们要处理颜色。 要获得卡通外观,我们将使用`bilateral`滤镜: - -```cpp -// Apply bilateral filter to homogenizes color -Mat imgBF; -bilateralFilter(img, imgBF, 9, 150.0, 150.0); -``` - -`bilateral`滤波器是一种在保持边缘的同时降低图像噪声的滤波器。 有了适当的参数,我们将在后面讨论,我们可以得到卡通效果。 - -`bilateral`过滤器的参数如下: - -* 输入图像 -* 输出图像 -* 像素邻域的直径;如果设置为负值,则根据 sigma 空间值计算 - -* 西格玛颜色值 -* 西格玛坐标空间 - -With a diameter greater than five, the `bilateral` filter starts to become slow. With sigma values greater than 150, a cartoonish effect appears. - -为了创建更强的卡通效果,我们将像素值相乘并除以,将可能的颜色值截断为 10: - -```cpp -// truncate colors -Mat result= imgBF/25; -result= result*25; -``` - -最后,我们必须合并颜色和边缘结果。 然后,我们必须创建一个三通道图像,如下所示: - -```cpp -// Create a 3 channles for edges -Mat imgCanny3c; -Mat cannyChannels[]={ imgCannyf, imgCannyf, imgCannyf}; -merge(cannyChannels, 3, imgCanny3c); -``` - -我们可以将颜色结果图像转换为 32 位浮点图像,然后将每个元素的两个图像相乘: - -```cpp -// Convert color result to float -Mat resultf; -result.convertTo(resultf, CV_32FC3); - -// Multiply color and edges matrices -multiply(resultf, imgCanny3c, resultf); -``` - -最后,我们只需要将图像转换为 8 位,然后向用户显示结果图像: - -```cpp -// convert to 8 bits color -resultf.convertTo(result, CV_8UC3); - -// Show image -imshow("Result", result); -``` - -在下一个截图中,我们可以看到输入图像(左图)和应用卡通化效果的结果(右图): - -![](img/c3869e51-f5d3-42f9-aa80-9cfe8a73cc6e.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们了解了如何创建一个完整的项目,通过应用不同的效果来处理图像。 我们还将彩色图像分割成多个矩阵,以便仅将效果应用于一个通道。 我们了解了如何创建查找表、如何将多个矩阵合并为一个、如何使用`Canny`和`bilateral`过滤器、如何绘制圆以及如何将图像相乘以获得光晕效果。 - -在下一章中,我们将学习如何进行对象检测,以及如何将图像分割成不同的部分并对这些部分进行检测。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/05.md b/trans/build-cv-proj-opencv4-cpp/05.md deleted file mode 100644 index 2735082c..00000000 --- a/trans/build-cv-proj-opencv4-cpp/05.md +++ /dev/null @@ -1,628 +0,0 @@ -# 自动光学检查、对象分割和检测 - -在[第 4 章](04.html),*深入研究直方图和过滤器*中,我们了解了直方图和过滤器,它们使我们能够理解图像操作并创建照片应用。 - -在本章中,我们将介绍目标分割和检测的基本概念。 这意味着隔离图像中出现的对象以供将来处理和分析。 - -本章介绍以下主题: - -* 去噪 -* 灯光/背景移除基础知识 -* 阈值设置 -* 用于对象分割的连通分量 -* 寻找轮廓以进行对象分割 - -许多行业使用复杂的计算机视觉系统和硬件。 计算机视觉试图发现问题并将生产过程中产生的错误降至最低,从而提高最终产品的质量。 - -在此区域中,此计算机视觉任务的名称为**自动光学检测**(**AOI**)。 这个名字出现在印刷电路板制造商的检查中,一个或多个摄像头扫描每个电路,以检测关键故障和质量缺陷。 这个术语被用于其他制造业,这样他们就可以使用光学摄像系统和计算机视觉算法来提高产品质量。 如今,光学检测根据需要使用不同的摄像机类型(红外或 3D 摄像机),复杂的算法被用于数千个行业的不同目的,如缺陷检测、分类等。 - -# 技术要求 - -本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 giHub 链接下载:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter05](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter05)。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2DRbMbz](http://bit.ly/2DRbMbz) - -# 隔离场景中的对象 - -在本章中,我们将介绍 AOI 算法的第一步,并尝试分离场景中的不同部分或对象。 我们将以三种对象类型(螺丝、密封环和螺母)的对象检测和分类为例,在本章和[第 6 章](06.html)、*学习对象分类*中对其进行开发。 - -假设我们在一家生产这三种产品的公司。 它们都在同一条载带上。 我们的目标是检测载带中的每个物体,并对每个物体进行分类,以便机器人将每个物体放到正确的架子上: - -![](img/281eaf92-e813-4226-a3af-54f76527aad8.png) - -在本章中,我们将学习如何隔离每个对象并检测其在图像中的位置(以像素为单位)。 在下一章中,我们将学习如何对每个孤立的物体进行分类,以识别它是螺母、螺丝还是密封圈。 - -在下面的屏幕截图中,我们显示了我们想要的结果,其中左侧图像中有几个对象。 在右图中,我们用不同的颜色绘制了每一个,显示了不同的特征,如面积、高度、宽度和轮廓大小: - -![](img/31c4d62a-34ed-4f72-9f34-d72c2284d9a0.png) - -为了达到这一结果,我们将遵循不同的步骤,使我们能够更好地理解和组织我们的算法。 我们可以在下图中看到这些步骤: - -![](img/b92f8c9b-3ba0-415f-9729-ce2eb0ffde38.png) - -我们的申请将分为两章。 在本章中,我们将开发和理解预处理和分割步骤。 在[第 6 章](06.html),*学习对象分类*中,我们将提取每个分割对象的特征,并训练我们的机器学习系统/算法如何识别每个对象类。 - -我们的预处理步骤将分为另外三个子集: - -* **噪声消除** -* **光移除** -* **二值化** - -在分段步骤中,我们将使用两种不同的算法: - -* 轮廓检测 -* **连通分量**提取(标记) - -我们可以在下图中看到这些步骤和应用流程: - -![](img/d2986d6e-caa4-4199-8616-6c27108224d9.png) - -现在,是开始预处理步骤的时候了,这样我们就可以通过去除噪声和光照效果来获得最佳的**二值化**图像。 这最大限度地减少了任何可能的检测错误。 - -# 为 AOI 创建应用 - -要创建我们的新应用,我们需要一些输入参数。 当用户执行应用时,除了要处理的输入图像外,所有这些都是可选的。 输入参数如下: - -* 要处理的输入图像 -* 光像图案 -* 轻操作,用户可以在差或除操作之间进行选择 -* 如果用户将`0`设置为值,则应用差值运算 -* 如果用户将`1`设置为值,则应用除法运算 -* 分段,用户可以在具有或不具有统计数据的连接组件之间进行选择,并查找等高线方法 -* 如果用户将`1`设置为输入值,则应用分段的连通分量方法 -* 如果用户将`2`设置为输入值,则应用带有统计区域的连通分量方法 -* 如果用户将`3`设置为输入值,则会应用查找等值线方法进行分段 - -要启用此用户选择,我们将使用带有以下键的`command line parser`类: - -```cpp -// 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`类。 在*阅读视频和摄像机*部分的[第 2 章](02.html),*OpenCV*基础简介中解释了`CommandLineParser`: - -```cpp -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(0); - String light_pattern_file= parser.get(1); - auto method_light= parser.get("lightMethod"); - auto method_seg= parser.get("segMethod"); - - // Check if params are correctly parsed in his variables - if (!parser.check()) - { - parser.printErrors(); - return 0; - } -``` - -解析命令行用户数据后,我们需要检查输入图像是否已正确加载。 然后,我们加载图像并检查其是否包含数据: - -```cpp -// Load image to process - Mat img= imread(img_file, 0); - if(img.data==NULL){ - cout << "Error loading image "<< img_file << endl; - return 0; - } -``` - -现在,我们准备创建我们的 AOI 细分流程。 我们将从预处理任务开始。 - -# 对输入图像进行预处理 - -本节介绍在对象分割/检测上下文中可以应用于图像预处理的一些最常见的技术。 预处理是我们在开始工作并从中提取所需信息之前对新图像所做的第一个更改。 通常,在预处理步骤中,我们会尽量减少由相机镜头引起的图像噪声、光线条件或图像变形。 这些步骤在检测图像中的对象或片段时将误差降至最低。 - -# 去噪 - -如果我们不去除噪声,我们可以检测到比我们预期更多的目标,因为噪声通常表示为图像中的小点,并且可以被分割为一个目标。 传感器和扫描仪电路通常会产生此噪声。 这种亮度或颜色的变化可以用不同的类型来表示,例如高斯噪声、尖峰噪声和散粒噪声。 - -可以使用不同的技术来消除噪音。 这里,我们将使用平滑操作,但根据噪声类型的不同,有些比另一些要好。 中值滤波器通常用于去除胡椒噪声;例如,请考虑下图: - -![](img/5f8547a4-97cd-4b35-ab3f-418c1a044998.png) - -前一幅图像是带有盐和胡椒噪声的原始输入。 如果我们应用中间模糊,我们会得到一个很棒的结果,其中会丢失一些小细节。 例如,我们丢失了螺钉的边缘,但我们保持了完美的边缘。 请参见下图中的结果: - -![](img/81ba5bdd-4fe2-4096-b876-79a264593bd9.png) - -如果我们应用盒过滤器或高斯过滤器,噪声不会被去除,而是变得平滑,对象的细节也会丢失和平滑。 有关结果,请参见下图: - -![](img/3f63820d-d5bb-4a37-a38a-1417afaa050b.png) - -OpenCV 提供了`medianBlur`函数,它需要三个参数: - -* 具有`1`、`3`或`4`通道图像的输入图像。 当内核大小大于`5`时,图像深度只能为`CV_8U`。 -* 输出图像,它是应用与输入相同类型和深度的中间模糊的结果图像。 - -* 内核大小,它是大于`1`的奇数孔径大小,例如 3、5、7 等等。 - -以下代码用于消除噪音: - -```cpp - Mat img_noise; - medianBlur(img, img_noise, 3); -``` - -# 使用用于分割的光图案去除背景 - -在这一部分中,我们将开发一个基本算法,使我们能够使用灯光模式删除背景。 这种预处理可以给我们更好的分割效果。 无噪声的输入图像如下: - -![](img/d58d7a47-461c-4730-b2db-8b8f42e75ea7.png) - -如果我们应用一个基本阈值,我们将获得如下图像结果: - -![](img/f094e1c1-a98d-49f4-96ed-a351af611573.png) - -我们可以看到上面的图像伪像有很多白噪声。 如果我们应用光模式和背景去除技术,我们可以得到令人惊叹的结果,我们可以看到在图像的顶部没有像以前的阈值操作那样的伪影,当我们需要分割的时候,我们会得到更好的结果。 我们可以在下图中看到背景去除和阈值处理的结果: - -![](img/2cbc71ad-c97e-4593-b7f6-89439d40a694.png) - -现在,我们如何才能将光线从我们的图像中移除呢? 这很简单:我们只需要一张没有任何物体的场景照片,从与拍摄其他图像完全相同的位置和照明条件下拍摄;这是 AOI 中的一种非常常见的技术,因为外部条件是受监督和众所周知的。 本例的图像结果类似于下图: - -![](img/cbcd7c67-e5c7-4b15-889f-9144c8b696b5.png) - -现在,使用一个简单的数学运算,我们可以移除这个光图案。 删除它有两个选项: - -* 差异 / 不同 / 争执 -* 分歧 / 除 / 部分 / 部门 - -差异选项是最简单的方法。 如果我们具有光图案`L`和图像画面`I`,则由此产生的移除`R`是它们之间的差值: - -```cpp -R= L-I -``` - -这种划分比较复杂,但同时也很简单。 如果我们具有光图案矩阵`L`和图像画面矩阵`I`,则结果去除`R`如下: - -```cpp -R= 255*(1-(I/L)) -``` - -在这种情况下,我们将图像除以光图案,并假设如果我们的光图案是白色的,并且对象比背景载体带更暗,则图像像素值始终等于或低于光像素值。 我们从`I/L`得到的结果介于`0`和`1`之间。 最后,我们将该除法的结果倒置以得到相同的颜色方向范围,并将其乘以`255`以得到`0-255`范围内的值。 - -在我们的代码中,我们将使用以下参数创建一个名为`removeLight`的新函数: - -* 用于移除灯光/背景的输入图像 -* 光图案,`Mat` -* 一种方法,用`0`值表示差,`1`表示除法 - -结果是一个没有光/背景的新图像矩阵。 下面的代码通过使用灯光图案实现背景的移除: - -```cpp -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 位浮点型图像,这样我们就可以划分图像,而不是将数字截断为整数。 第一步是将图像和光图案垫转换为 32 位浮点数。 要转换此格式的图像,可以使用`Mat`类的`convertTo`函数。 此函数接受四个参数;输出转换的图像和要转换为所需参数的格式,但您可以定义 alpha 和 beta 参数,这些参数允许您缩放和移动下一个函数后面的值,其中*O*是输出图像,*I*是输入图像: - -*O*(*x*,*y*)=*cast*<*Type*>(*α***I*(*x*,*y*)+*β*) - -下面的代码将图像更改为 32 位浮点: - -```cpp -// Required to change our image to 32 float for division -Mat img32, pattern32; -img.convertTo(img32, CV_32F); -pattern.convertTo(pattern32, CV_32F); -``` - -现在,我们可以对我们的矩阵执行如上所述的数学运算,方法是将图像除以图案并反转结果: - -```cpp -// Divide the image by the pattern -aux= 1-(img32/pattern32); -``` - -现在,我们有了结果,但需要将其返回到 8 位深度图像,然后像前面一样使用 Convert 函数转换图像的`mat`,并使用 alpha 参数从`0`缩放到`255`: - -```cpp -// Convert 8 bits format -aux.convertTo(aux, CV_8U, 255); -``` - -现在,我们可以将`aux`变量与结果一起返回。 对于差分方法,开发非常容易,因为我们不需要转换图像;我们只需要应用模式和图像之间的差异并返回它。 如果我们不假设图案等于或大于图像,则需要进行几次检查并截断值,这些值可以小于`0`或大于`255`: - -```cpp -aux= pattern-img; -``` - -以下图像是将图像灯光图案应用于我们的输入图像的结果: - -![](img/b80f7abd-d551-4741-9dd0-26c786906b7d.png) - -在我们得到的结果中,我们可以检查光线渐变和可能的伪影是如何被去除的。 但是当我们没有灯光/背景图案时会发生什么呢? 有几种不同的技术可以实现这一点;我们将在这里介绍最基本的一种。 使用滤镜,我们可以创建一个可以使用的滤镜,但有更好的算法来了解图像的背景,其中碎片出现在不同的区域。 这项技术有时需要背景估计图像初始化,我们的基本方法可以很好地发挥作用。 这些高级技术将在[第 8 章](08.html)、*视频监控、背景建模和形态运算*中进行探讨。 为了估计背景图像,我们将使用具有较大内核大小的模糊来应用于我们的输入图像。 这是在**光学字符识别***(***OCR**)中使用的常用技术,其中字母相对于整个文档较薄且较小,允许我们对图像中的光图案进行近似。 我们可以在左手图像中看到灯光/背景图案重建,在右手图像中可以看到地面实况: - -![](img/fe2720f9-e701-42be-8c36-bb70c2085a63.png) - -我们可以看到灯光图案有一些细微的差异,但这一结果足以去除背景。 当使用不同的图像时,我们也可以在下图中看到结果。 在下图中,描述了应用原始输入图像和使用前一种方法计算的估计背景图像之间的图像差的结果: - -![](img/e7f7eccd-0439-4af3-80d6-e7b018363dad.png) - -`calculateLightPattern`函数创建此灯光图案或背景近似值: - -```cpp -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; -} -``` - -此基本函数通过使用相对于图像大小较大的内核大小来对输入图像应用模糊。 从代码来看,它是原来宽度和高度的**。** - - **# 阈值设置 - -在去除背景之后,我们只需要对图像进行二值化,以便将来进行分割。 我们要用 Threshold 来做这件事。 `Threshold`是一个简单的函数,它将每个像素的值设置为最大值(例如 255)。 如果像素的值大于**阈值**值,或者如果像素的值小于**阈值**值,则它将被设置为最小值(0): - -![](img/00c05937-dde7-419e-8b58-988c688d97ea.png) - -现在,我们将使用两个不同的`threshold`值来应用`threshold`函数:当我们移除灯光/背景时,我们将使用 30`threshold`值,因为所有不感兴趣的区域都是黑色的。 这是因为我们应用了背景移除。 当我们不使用灯光移除方法时,我们还将使用中值`threshold`(140),因为我们使用的是白色背景。 最后一个选项用于允许我们在删除和不删除背景的情况下检查结果: - -```cpp - // 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**)。 在我们的例子中,这些是螺母、螺丝和环。 - -# 连通分量算法 - -连通分量算法是一种非常常用的算法,用于分割和识别二值图像中的部分。 连通分量是一种迭代算法,其目的是使用八个或四个连通性像素来标记图像。 如果两个像素具有相同的值并且是相邻像素,则这两个像素是相连的。 在图像中,每个像素都有八个相邻像素: - -![](img/8fe23da4-9a99-47f4-8599-6429da876f48.png) - -四连通性意味着,如果**2**、**4**、**5**和**7**邻居的值与中心像素相同,则它们只能连接到中心。 通过八个连接,如果**1**、**2**、**3**、**4**、**5**、**6**、**7**和**8**邻居的值与中心像素相同,则可以连接它们。 我们可以从四连通性算法和八连通性算法中看出以下示例的不同之处。 我们将把每种算法应用于下一幅二值化图像。 我们使用了一幅小的**9x9**图像,并放大显示了连接组件的工作原理以及四连接和八连接之间的区别: - -![](img/5a81d547-9b10-475e-8550-02f8af6685e2.png) - -四连通性算法检测到两个对象;我们可以在左图中看到这一点。 八连通性算法只检测一个对象(右侧图像),因为两个对角线像素是相连的。 八连通性处理对角线连通性,这是与四连通性相比的主要区别,因为在四连通性中只考虑垂直和水平像素。 我们可以在下图中看到结果,其中每个对象都有不同的灰色值: - -![](img/e7f73902-b25a-4f55-bdc9-ec5099c0fff8.png) - -OpenCV 为我们带来了具有两种不同功能的连通分量算法: - -* `connectedComponents`(图像,标签,连接性=`8`,类型=`CV_32S`) -* `connectedComponentsWithStats`(图像,标签,统计信息,质心,连接性=`8`,类型=`CV_32S`) - -这两个函数都返回一个带有检测到的标签数量的整数,其中 Label`0`表示背景。 这两个函数之间的区别基本上在于返回的信息。 让我们检查一下每一台的参数。 `connectedComponents`函数为我们提供以下参数: - -* **Image**:要标记的输入图像。 -* **标签**:与输入图像大小相同的输出垫,其中每个像素都有其标签值,其中所有 OS 表示背景,值为`1`的像素表示第一个连接的组件对象,依此类推。 -* **连接性**:表示我们要使用的连接性的两个可能值`8`或`4`。 -* **类型**:我们要使用的标签图像的类型。 只允许两种类型:`CV32_S`和`CV16_U`。 默认情况下,这是`CV32_S`。 -* `connectedComponentsWithStats`函数还定义了两个参数。 以下是统计数据和质心: - * **Stats**:这是一个输出参数,为我们提供每个标签的以下统计值(包括背景): - * `CC_STAT_LEFT`:连接组件对象最左侧的`x`坐标 - * `CC_STAT_TOP`:连接的组件对象的最上面的`y`坐标 - * `CC_STAT_WIDTH`:由其边界框定义的连接组件对象的宽度 - * `CC_STAT_HEIGHT`:由其边界框定义的连接组件对象的高度 - * `CC_STAT_AREA`:连接组件对象的像素数(面积) - * **质心**:质心指向每个标签的浮动类型,包括考虑用于另一个连接组件的背景。 - -在我们的示例应用中,我们将创建两个函数,以便可以应用这两个 OpenCV 算法。 然后,我们将在具有基本连通分量算法的带有彩色对象的新图像中向用户显示所获得的结果。 如果我们使用 stats 方法选择连通组件,我们将在每个对象上绘制返回此函数的相应计算区域。 - -让我们定义连通组件函数的基本绘图: - -```cpp -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(i) << " with area " << stats.at(i, CC_STAT_AREA) << endl; - Mat mask= labels==i; - output.setTo(randomColor(rng), mask); - // draw text with area - stringstream ss; - ss << "area: " << stats.at(i, CC_STAT_AREA); - - putText(output, - ss.str(), - centroids.at(i), - FONT_HERSHEY_SIMPLEX, - 0.4, - Scalar(255,255,255)); - } - imshow("Result", output); -} -``` - -让我们来理解一下这段代码。 正如我们在非统计函数中所做的那样,我们调用了 Connected Components 算法,但在这里,我们使用`stats`函数来执行此操作,以检查我们是否检测到多个对象: - -```cpp -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; - } -``` - -现在,我们又有了两个输出结果:统计数据和质心变量。 然后,对于每个检测到的标签,我们将通过命令行显示质心和区域: - -```cpp -for(auto i=1; i(i) << " with area " << stats.at(i, CC_STAT_AREA) << endl; -``` - -您可以检查对 stats 变量的调用,以使用列常量`stats.at(I, CC_STAT_AREA)`提取区域。 现在,像以前一样,我们在输出图像上绘制标有`i`的对象: - -```cpp -Mat mask= labels==i; -output.setTo(randomColor(rng), mask); -``` - -最后,在每个分割对象的质心位置,我们希望在生成的图像上绘制一些信息(如面积)。 为此,我们使用`putText`函数的 STATS 和质心变量。 首先,我们必须创建一个`stringstream`,以便可以添加统计区域信息: - -```cpp -// draw text with area -stringstream ss; -ss << "area: " << stats.at(i, CC_STAT_AREA); -``` - -然后,我们需要使用`putText`,使用质心作为文本位置: - -```cpp -putText(output, - ss.str(), - centroids.at(i), - FONT_HERSHEY_SIMPLEX, - 0.4, - Scalar(255,255,255)); -``` - -此函数的结果如下所示: - -![](img/146a592f-3304-4484-9b59-003a5e038e9f.png) - -# FindContours 算法 - -在分割对象时,`findContours`算法是最常用的 OpenCV 算法之一。 这是因为此算法是从 1.0 版开始包含在 OpenCV 中的,它为开发人员提供了更多信息和描述符,包括形状、拓扑组织等: - -```cpp -void findContours(InputOutputArray image, OutputArrayOfArrays contours, OutputArray hierarchy, int mode, int method, Point offset=Point()) -``` - -下面我们来解释一下每个参数: - -* **图像**:输入二进制图像。 -* **轮廓**:轮廓的输出,其中每个检测到的轮廓都是点的矢量。 -* **层次**:这是保存等高线层次的可选输出向量。 这是图像的拓扑结构,在这里我们可以得到每个轮廓之间的关系。 层次表示为四个索引的向量,它们是(下一个轮廓、上一个轮廓、第一个子轮廓、父轮廓)。 在给定的等高线与其他等高线没有关系的情况下,给出负指数。 更详细的解释可以在[https://docs.opencv.org/3.4/d9/d8b/tutorial_py_contours_hierarchy.html](https://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_L1`和`CV_CHAIN_APPROX_TC89_KCOS`应用**特尔钦****链****近似**算法。 -* **偏移**:这是一个可选的点值,用于移动所有等高线。 当我们在 ROI 中工作并需要检索全球位置时,这是非常有用的。 - -Note: The input image is modified by the `findContours` function. Create a copy of your image before sending it to this function if you need it. - -现在我们已经知道了`findContours`函数的参数,让我们将其应用到我们的示例中: - -```cpp -void FindContoursBasic(Mat img) -{ - vector > 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; -vector hierarchy; -findContours(img, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); -``` - -就像我们之前看到的连通组件示例一样,我们首先检查我们检索到了多少轮廓。 如果没有,则退出我们的函数: - -```cpp -// 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 提供了一个函数来绘制查找等高线图像的结果: - -```cpp -for(auto i=0; i& trainData, int flags=0 ); -bool train(InputArray samples, int layout, InputArray responses); -Ptr<_Tp> train(const Ptr& data, int flags=0 ); -``` - -列车功能具有以下参数: - -* `TrainData`:可以从`TrainData`类加载或创建的培训数据。 这个类是 OpenCV 3 中的新增类,帮助开发人员从机器学习算法中创建训练数据和摘要。 这样做是因为不同的算法需要不同类型的阵列结构来进行训练和预测,例如 ANN 算法。 -* `samples`:训练数组样本的数组,例如机器学习算法要求的格式的训练数据。 -* `layout`:`ROW_SAMPLE`(训练样本为矩阵行)或`COL_SAMPLE`(训练样本为矩阵列)。 -* `responses`:与样本数据关联的响应向量。 -* `flags`:每个方法定义的可选标志。 - -最后一个 Train 方法创建并训练`_TP`类类型的模型。 唯一接受的类是实现不带参数或全部使用默认参数值的静态 Create 方法的类。 - -`predict`方法要简单得多,并且只有一个可能的调用: - -```cpp -float StatModel::predict(InputArray samples, OutputArray results=noArray(), int flags=0) -``` - -预测函数具有以下参数: - -* `samples`:用于预测模型结果的输入样本可以由任意数量的数据组成,无论是单个数据还是多个数据。 -* `results`:每个输入行样本的结果(由来自先前训练的模型的算法计算)。 -* `flags`:这些可选标志取决于型号。 某些模型(如 Boost)由 SVM 的`StatModel::RAW_OUTPUT`标志识别,该标志使方法返回原始结果(总和),而不是类标签。 - -`StatModel`类为其他非常有用的方法提供了一个接口: - -现在,我们将介绍一个在计算机视觉应用中使用机器学习的基本应用是如何构建的。 - -# 计算机视觉与机器学习工作流 - -具有机器学习的计算机视觉应用具有共同的基本结构。 此结构分为不同的步骤: - -1. **预处理** -2. **分段** -3. **特征提取** -4. **分类结果** -5. **P****主进程** - -这些在几乎所有的计算机视觉应用中都很常见,而其他的则被省略了。 在下图中,您可以看到涉及的不同步骤: - -![](img/0420a0ae-6b12-40cf-af93-a16e92b95c38.png) - -几乎所有的计算机视觉应用都是从应用于输入图像的**预处理**开始的,包括去除光线和噪声、过滤、模糊等。在对输入图像应用所有所需的预处理之后,第二步是**分割**。 在这一步中,我们必须提取图像中的感兴趣区域,并将每个区域分离为唯一的感兴趣对象。 例如,在人脸检测系统中,我们必须将人脸与场景中的其他部分分开。 在检测到图像内部的所有对象后,我们继续下一步。 在这里,我们必须提取每个对象的特征;这些特征通常是对象的特征向量。 特征描述我们的对象,可以是对象的面积、轮廓、纹理图案、像素等。 - -现在,我们有了对象的描述符,也称为特征向量或特征集。 描述符是描述对象的特征,我们使用它们来训练或预测模型。 要做到这一点,我们必须创建一个包含数千幅图像的大型特征数据集。 然后,我们在我们选择的**列车**模型函数中使用提取的特征(图像/对象特征),例如面积、大小和纵横比。 在下图中,我们可以看到如何将数据集送入**机器学习算法**来训练和**生成****模型**: - -![](img/3042ee1f-c724-44d9-914d-f382ed2cf933.png) - -当我们**用数据集训练**时,**模型**学习能够预测何时具有未知标签的新特征向量作为我们算法的输入所需的所有参数。 在下图中,我们可以看到未知特征向量如何使用生成的**模型**来**预测**,从而返回**分类和结果**或回归: - -![](img/ddc3e777-6359-470e-9f9b-0bfbae50cad8.png) - -在预测结果后,有时需要对输出数据进行后处理,例如合并多个分类以减少预测误差或合并多个标签。 光学字符识别中的一个示例是**分类结果**是根据每个预测字符,并且通过结合字符识别结果来构造一个词。 这意味着我们可以创建一种后处理方法来纠正检测到的单词中的错误。通过对计算机视觉的机器学习的这个小介绍,我们将实现我们自己的应用,使用机器学习来对幻灯片中的对象进行分类。 我们将使用支持向量机作为我们的分类方法,并解释如何使用它们。 其他机器学习算法的使用方式非常相似。 OpenCV 文档在以下链接中提供了有关所有机器学习算法的详细信息:[https://docs.opencv.org/master/dd/ded/group__ml.html](https://docs.opencv.org/master/dd/ded/group__ml.html)。 - -# 物体自动检测分类示例 - -在[第 5 章](05.html),*自动光学检查、对象分割和检测*中,我们查看了一个自动对象检查分段的示例,其中载体带包含三种不同类型的对象:螺母、螺丝和环。 有了计算机视觉,我们将能够识别其中的每一个,这样我们就可以向机器人发送通知,或者把每个机器人放在不同的盒子里。 以下是载带的基本示意图: - -![](img/9bfe6d8e-13bf-48fa-83b3-cb16df9a7bf3.png) - -在[第 5 章](05.html)、*自动光学检测*、*对象分割*、*和*中,我们对输入图像进行预处理并提取感兴趣区域,使用不同的技术分离每个对象。 现在,我们将应用本示例中前面几节中解释的所有概念来提取特征并对每个对象进行分类,从而允许机器人将每个对象放入不同的盒子中。 在我们的应用中,我们不会只显示每个图像的标签,但我们可以将图像中的位置和标签发送给其他设备,如机器人。 此时,我们的目标是提供具有不同对象的输入图像,允许计算机检测对象并在每个图像上显示对象的名称,如下图所示。 然而,为了了解整个过程的步骤,我们将通过创建一个曲线图来训练我们的系统,以显示我们要使用的功能分布,并用不同的颜色将其可视化。 我们还将展示预处理后的输入图像,以及得到的输出分类结果。 最终结果看起来如下: - -![](img/a28e6100-2ad6-4886-af59-30291047ea19.png) - -对于我们的示例应用,我们将遵循以下步骤: - -1. 对于每个输入图像: - - * 对图像进行预处理 - * 分割图像 -2. 对于图像中的每个对象: - * 提取要素 - * 将特征添加到带有相应标签(螺母、螺钉、环)的训练特征向量 -3. 创建一个 SVM 模型。 -4. 用训练后的特征向量训练支持向量机模型。 -5. 对输入图像进行预处理,对每个分割对象进行分类。 -6. 分割输入图像。 -7. 对于检测到的每个对象: - * 提取要素 - * 用支持向量机进行预测 - * 模型 / 模式 / 模范 / 时装模特儿 - * 在输出图像中绘制结果 - -对于预处理和分割,我们将使用[第 5 章](05.html)、*自动光学检测*、*对象分割*、*和检测中的代码。* 然后我们将解释如何提取特征并创建**训练**和**预测**我们的模型所需的向量。 - -# 特征提取 - -接下来我们需要做的是提取每个对象的特征。 为了理解特征向量的概念,我们将在示例中提取非常简单的特征,因为这足以获得良好的结果。 在其他解决方案中,我们可以获得更复杂的特征,如纹理描述符、轮廓描述符等。在我们的示例中,我们只有图像中不同位置和方向的螺母、环和螺钉。 同一对象可以位于图像和方向的任何位置,例如,螺钉或螺母。 我们可以在下图中看到不同的方向: - -![](img/976b4119-460a-449d-8f38-272470d50551.png) - -我们将探索一些特征或特征,这些特征可以提高我们机器学习算法的准确性。 我们的不同对象(螺母、螺丝和环)的这些可能特征如下: - -* 对象的面积 -* 长宽比,即宽度除以边界矩形的高度 -* 孔洞的数量 -* 等高线边数 - -这些特征可以很好地描述我们的对象,如果我们全部使用,分类误差会很小。 但是,在我们实现的示例中,我们只打算使用前两个特征(面积和纵横比)来学习,因为我们可以在 2D 图形中绘制这些特征,并显示这些值能够正确地描述我们的对象。 我们还可以说明,在图形情节中,我们可以在视觉上区分一种对象和另一种对象。 为了提取这些特征,我们将使用黑/白 ROI 图像作为输入,其中只有一个对象以白色显示,背景为黑色。 该输入是[第 5 章](05.html)、*自动光学检测*、*对象分割*、*和检测*的分割结果。 我们将使用`findCountours`算法分割对象,并为此创建`ExtractFeatures`函数,如下面的代码所示: - -```cpp -vector< vector > ExtractFeatures(Mat img, vector* left=NULL, vector* top=NULL) -{ - vector< vector > output; - vector > contours; - Mat input= img.clone(); - - vector 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; i500){ //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 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; -} -``` - -让我们解释一下我们用来提取特征的代码。 我们将创建一个函数,该函数将一张图像作为输入,并返回图像中检测到的每个对象的左位置和顶部位置的两个向量作为参数。 这些数据将用于在每个对象上绘制相应的标签。 函数的输出是浮点数向量的向量。 换句话说,它是一个矩阵,其中每一行都包含检测到的每个对象的特征。 - -首先,我们必须创建将在查找轮廓算法分割中使用的输出向量变量和轮廓变量。 我们还必须创建输入图像的副本,因为前面的`findCoutours`OpenCV 函数修改输入图像: - -```cpp - vector< vector > output; - vector > contours; - Mat input= img.clone(); - vector hierarchy; - findContours(input, contours, hierarchy, RETR_CCOMP, CHAIN_APPROX_SIMPLE); -``` - -现在,我们可以使用`findContours`函数来检索图像中的每个对象。 如果没有检测到任何轮廓,则返回一个空的输出矩阵,如下面的代码片断所示: - -```cpp -if(contours.size() == 0){ - return output; - } -``` - -如果检测到所有对象,对于每个轮廓,我们将在黑色图像(零值)上绘制白色对象。 这将使用`1`值来完成,就像遮罩图像一样。 以下代码生成蒙版图像: - -```cpp -for(auto i=0; iMIN_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 row; -row.push_back(area); -row.push_back(ar); -output.push_back(row); -``` - -如果传递了 Left 和 top 参数,则将左上角的值相加以输出参数: - -```cpp - if(left!=NULL){ - left->push_back((int)r.center.x); - } - if(top!=NULL){ - top->push_back((int)r.center.y); - } -``` - -最后,我们将在窗口中显示检测到的对象以供用户反馈。 处理完图像中的所有对象后,我们将返回输出特征向量,如以下代码片段所述: - -```cpp - miw->addImage("Extract Features", mask*255); - miw->render(); - waitKey(10); - } - } - return output; -``` - -现在我们已经提取了每个输入图像的特征,我们可以继续下一步。 - -# 训练支持向量机模型 - -现在我们将使用监督学习,然后获得每个对象的一组图像及其对应的标签。 数据集中没有图像的最小数量;如果我们为训练过程提供更多的图像,我们将获得更好的分类模型(在大多数情况下)。 然而,对于简单的分类器,训练简单的模型就足够了。 为此,我们创建了三个文件夹(`screw`、`nut`和`ring`),每种类型的所有图像都放在一起。对于文件夹中的每个图像,我们必须提取特征,将它们添加到`train`特征矩阵中,同时创建一个新的向量,每行的标签对应于每个训练矩阵。 为了评估我们的系统,我们将根据测试和培训将每个文件夹拆分成多个图像。 我们将留下大约 20 个图像用于测试,其余的用于培训。 然后,我们将创建两个标签向量和两个矩阵,用于训练和测试。 - -让我们进入我们的代码内部。 首先,我们必须创建我们的模型。 我们将在所有函数中声明该模型,以便能够将其作为全局变量访问。 OpenCV 使用`Ptr`模板类进行指针管理: - -```cpp -Ptr svm; -``` - -在声明指向新的 SVM 模型的指针之后,我们将创建它并训练它。 为此,我们创建了`trainAndTest`函数。 完整的功能代码如下: - -```cpp -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 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); - } -} -``` - -现在,让我们解释一下代码。 首先,我们必须创建存储训练和测试数据所需的变量: - -```cpp - vector< float > trainingData; - vector< int > responsesData; - vector< float > testData; - vector< float > testResponsesData; -``` - -正如我们前面提到的,我们必须读取每个文件夹中的所有图像,提取特征,并将它们保存在我们的培训和测试数据中。 为此,我们将使用`readFolderAndExtractFeatures`函数,如下所示: - -```cpp - 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`函数使用`VideoCapture`OpenCV 函数读取文件夹中的所有图像,包括视频和摄像头帧。 对于读取的每个图像,我们提取特征并将它们添加到相应的输出向量中: - -```cpp -bool readFolderAndExtractFeatures(string folder, int label, int num_for_test, - vector &trainingData, vector &responsesData, - vector &testData, vector &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 > 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`格式,以便可以将其发送到训练函数: - -```cpp -// 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]); -``` - -现在,我们准备创建和训练我们的机器学习模型。 如前所述,我们将使用支持向量机来实现这一点。 首先,我们将设置基本模型参数,如下所示: - -```cpp -// 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 文档:[和 https://docs.opencv.org/master/d1/d2d/classcv_1_1ml_1_1SVM.html](https://docs.opencv.org/master/d1/d2d/classcv_1_1ml_1_1SVM.html)。 创建设置参数后,我们将通过调用`train`方法并使用`trainingDataMat`和响应矩阵作为`TrainData`对象来创建模型: - -```cpp - // Train the SVM - svm->train(tdata); -``` - -我们使用测试向量(将`num_for_test`变量设置为大于`0`)来获得模型的近似误差。 为了得到误差估计,我们将对所有测试向量特征进行预测,以获得 SVM 预测结果,并将这些结果与原始标签进行比较: - -```cpp -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); - } -``` - -我们通过使用`testDataMat`特性和一个新的`Mat`预测结果来使用`predict`函数。 `predict`函数使得同时进行多个预测成为可能,即给出一个矩阵作为结果,而不是只给出一行或向量。预测之后,我们只需计算`testPredict`与我们的`testResponses`(原始标签)的差值。 如果有差异,我们只需要数一下有多少,然后除以测试的总数,就可以计算出误差。 - -We can use the new `TrainData` class to generate the feature vectors, samples, and split our train data between test and train vectors. - -最后,我们将在 2D 绘图中显示训练数据,其中*y*轴是纵横比特征,*x*轴是对象的面积。 每个点都有不同的颜色和形状(十字形、正方形和圆形)来显示每种不同的对象,我们可以清楚地看到下图中的对象组: - -![](img/d2a6e95f-dcae-4d00-b9be-ca716c002118.png) - -我们现在非常接近完成我们的应用示例。 在这一点上,我们已经训练了 SVM 模型;我们现在可以将其用于分类,以检测新传入和未知特征向量的类型。 下一步是预测具有未知对象的输入图像。 - -# 输入图像预测 - -现在我们准备解释主函数,该函数加载输入图像并预测其中出现的对象。 我们将使用类似以下图片的内容作为输入图像。 在这里,多个不同的物体出现在图像中。 我们没有这些的标签或名称,但计算机必须能够识别它们: - -![](img/4e73b0df-745d-49e4-8930-3be0fd31402b.png) - -与所有训练图像一样,我们必须加载并预处理输入图像,如下所示: - -1. 首先,我们加载图像并将其转换为灰色值。 -2. 然后,我们使用`preprocessImage`函数应用预处理任务(如我们在[第 5 章](05.html)、*自动光学检查*离子、*对象分割*、*和检测中了解到的)*: - -```cpp - Mat pre= preprocessImage(img); -``` - -3. 现在,我们将使用前面描述的`ExtractFeatures`来提取图像中出现的所有对象的向量特征以及每个对象的左上角位置: - -```cpp - // Extract features - vector pos_top, pos_left; - vector< vector > - features=ExtractFeatures(pre, &pos_left, &pos_top); -``` - -4. 我们将检测到的每个对象存储为特征行,然后将每行转换为一行和两个特征的`Mat`: - -```cpp - for(int i=0; i< features.size(); i++){ - Mat trainingDataMat(1, 2, CV_32FC1, &features[i][0]); -``` - -5. 之后,我们可以使用我们的`StatModel`支持向量机的`predict`函数对单个目标进行预测,预测的浮动结果就是检测到的目标的标签。 然后,要完成应用,我们必须在输出图像上绘制检测到并分类的每个对象的标签: - -```cpp - float result= svm->predict(trainingDataMat); -``` - -6. 我们将使用`stringstream`存储文本,使用`Scalar`存储每个不同标签的颜色: - -```cpp - 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"; - } -``` - -7. 我们还将使用在`ExtractFeatures`函数中检测到的位置在每个对象上绘制标签文本: - -```cpp - putText(img_output, - ss.str(), - Point2d(pos_left[i], pos_top[i]), - FONT_HERSHEY_SIMPLEX, - 0.4, - color); -``` - -8. 最后,我们将在输出窗口中绘制结果: - -```cpp - miw->addImage("Binary image", pre); - miw->addImage("Result", img_output); - miw->render(); - waitKey(0); -``` - -我们的应用的最终结果显示了一个由四个屏幕组成的平铺窗口。 这里,左上角是输入训练图像,右上角是剧情训练图像,左下角是分析预处理图像的输入图像,右下角是预测的最终结果: - -![](img/90496da6-b911-4085-b9a4-a68635503497.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了机器学习的基础知识,并将其应用到一个小示例应用中。 这让我们了解了可以用来创建我们自己的机器学习应用的基本技术。机器学习很复杂,每个用例都涉及不同的技术(监督学习、无监督学习、聚类等)。 我们还学习了如何使用支持向量机创建最典型的机器学习应用-监督学习应用。监督机器学习中最重要的概念如下:您必须有适当数量的样本或数据集,您必须准确地选择描述我们对象的特征(有关图像特征的更多信息,请转到[第 8 章](08.html)、*视频监控*、*背景建模*、*和形态运算*)*。* 你必须选择一个能给出最好预测的模型。 - -如果我们没有得到正确的预测,我们必须检查这些概念中的每一个来发现问题所在。 - -在下一章中,我们将介绍背景减去方法,这些方法在视频监控应用中非常有用,因为在视频监控应用中,背景没有给我们任何有趣的信息,必须将其丢弃,以便我们可以分割图像来检测和分析图像对象。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/07.md b/trans/build-cv-proj-opencv4-cpp/07.md deleted file mode 100644 index 6d4d7f8f..00000000 --- a/trans/build-cv-proj-opencv4-cpp/07.md +++ /dev/null @@ -1,274 +0,0 @@ -# 检测面部部位和覆盖面具 - -在[第 6 章](06.html)和*学习对象分类*中,我们了解了对象分类以及如何使用机器学习来实现对象分类。 在本章中,我们将学习如何检测和跟踪不同的人脸部位。 我们将从了解人脸检测管道及其构建方式开始讨论。 然后,我们将使用此框架来检测面部部分,如眼睛、耳朵、嘴巴和鼻子。 最后,我们将学习如何在直播视频中将有趣的面具覆盖在这些面部部位上。 - -学完本章后,我们应该熟悉以下主题: - -* 了解哈尔叶栅 -* 整体图像以及我们为什么需要它们 -* 构建通用人脸检测流水线 -* 检测和跟踪来自网络摄像头的实时视频流中的人脸、眼睛、耳朵、鼻子和嘴巴 -* 在视频中自动将口罩、太阳镜和滑稽的鼻子叠加在人的脸上 - -# 技术要求 - -本章要求基本熟悉 C++ 编程语言。 本章中使用的所有代码都可以从以下 gihub 链接下载:*[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter07](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter07)。*这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2SlpTK6](http://bit.ly/2SlpTK6) - -# 了解哈尔叶栅 - -Haar 级联是基于 Haar 特征的级联分类器。 什么是级联分级机? 它只是一组可用于创建强分类器的弱分类器的串联。 我们所说的**弱**和**强**分类器是什么意思? 弱分类器是性能有限的分类器。 他们没有能力正确地对所有东西进行分类。 如果你让问题变得非常简单,他们可能会表现在可以接受的水平。 另一方面,强分类器非常擅长对我们的数据进行正确的分类。 在接下来的几个段落中,我们将看到这一切是如何结合在一起的。 Haar 级联的另一个重要部分是**Haar 特性**。 这些特征是矩形和图像上这些区域的差异的简单总和。 让我们考虑下图: - -![](img/be96e5d1-f7cc-4367-b344-2344f2c9d0c0.png) - -如果要计算 ABCD 区域的 Haar 特征,只需计算该区域的白色像素和蓝色像素之间的差值。 正如我们从这四个图中看到的,我们使用不同的模式来构建 Haar 特性。 还有很多其他的模式也在使用。 我们在多个尺度上这样做,以使系统尺度不变。 当我们说多个比例时,我们只是缩小图像以再次计算相同的特征。 这样,我们就可以使其对给定对象的大小变化具有健壮性。 - -As it turns out, this concatenation system is a very good method for detecting objects in an image. In 2001, Paul Viola and Michael Jones published a seminal paper where they described a fast and effective method for object detection. If you are interested in learning more about it, you can check out their paper at [http://www.cs.ubc.ca/~lowe/425/slides/13-ViolaJones.pdf](http://www.cs.ubc.ca/~lowe/425/slides/13-ViolaJones.pdf). - -让我们更深入地了解他们到底做了什么。 他们基本上描述了一种使用增强型简单分类器级联的算法。 这个系统被用来构建一个性能非常好的强大分类器。 为什么他们用这些简单的量词,而不是复杂的量词,因为复杂的量词可以更准确? 嗯,使用这项技术,他们能够避免必须建立一个可以高精度执行的单一分类器的问题。 这些单步分类器往往比较复杂且计算密集。 他们的技术如此有效的原因是因为简单的量词可能是较弱的学习者,这意味着他们不需要变得复杂。 考虑构建表检测器的问题。 我们想要构建一个系统,该系统将自动学习表格的外观。 基于这些知识,它应该能够识别在任何给定图像中是否有表。 要建立这个系统,第一步是收集图像,这些图像可以用来训练我们的系统。 在机器学习领域,有很多技术可以用来训练这样的系统。 请记住,如果我们希望我们的系统运行良好,我们需要收集大量的表格和非表格图像。 在机器学习术语中,表格图像称为**正**样本,非表格图像称为**负**样本。 我们的系统将摄取这些数据,然后学习区分这两个类别。为了建立一个实时系统,我们需要保持分类器的精致和简单。 唯一令人担忧的是,简单的分类器不是很准确。 如果我们试图使它们更准确,那么这个过程最终将是计算密集型的,因此速度很慢。 这种精度和速度之间的权衡在机器学习中非常常见。 因此,我们通过连接一组弱分类器来创建一个强而统一的分类器来克服这个问题。 我们不需要弱分类器非常准确。 为了确保整体分类器的质量,Viola 和 Jones 在级联步骤中描述了一种巧妙的技术。 你可以通读这篇论文来了解整个系统。 - -现在我们已经了解了一般流程,让我们看看如何构建一个可以在实时视频中检测人脸的系统。 第一步是从所有图像中提取特征。 在这种情况下,算法需要这些功能来学习和理解人脸的样子。 他们在论文中使用了 Haar 特征来构建特征向量。 一旦我们提取了这些特征,我们就将它们通过一系列分类器。 我们只检查所有不同的矩形子区域,并不断丢弃其中没有人脸的区域。 这样,我们就可以快速得到最终答案,看看给定的矩形是否包含人脸。 - -# 什么是整体图像? - -为了提取这些 Haar 特征,我们必须计算包含在图像的许多矩形区域中的像素值的总和。 为了使其具有比例不变性,我们需要在多个比例下计算这些面积(对于不同的矩形大小)。 如果实现得很幼稚,这将是一个计算非常密集的过程;因为我们将不得不迭代每个矩形的所有像素,包括多次读取相同的像素(如果它们包含在不同的重叠矩形中)。 如果你想构建一个可以实时运行的系统,你不能在计算上花费这么多时间。 在面积计算过程中,我们需要找到一种方法来避免这种巨大的冗余,因为我们在相同的像素上迭代了多次。 为了避免这种情况,我们可以使用一种叫做积分图像的东西。 这些图像可以在线性时间初始化(通过在图像上仅迭代两次),然后通过仅读取四个值来提供任意大小的任意矩形内的像素总和。 为了更好地理解它,我们来看下图: - -![](img/fcc7d5ec-5fb2-4754-97e1-9715daf3d335.png) - -如果我们想要计算图表中任何矩形的面积,我们不必遍历该区域中的所有像素。 让我们考虑一个由图像中左上角的点和任意点 P 组成的矩形作为对角点。 设 AP表示该矩形的面积。 例如,在上图中,AB表示将左上点和**B**作为对角点形成的 5x2 矩形的面积。 为了清楚起见,让我们看一下下图: - -![](img/870b00bd-37e3-4957-b162-3fa378e15e8c.png) - -让我们考虑一下上图中的左上角正方形。 蓝色像素表示左上角像素和点**A**之间的区域。 这由 AA表示。 其余的图表由各自的名称表示:AB、AC和 AD。 现在,如果我们要计算矩形的面积,如上图所示,我们将使用以下公式: - -**矩形面积**:*ABCD*=*AC*-(*AB*+*AD*-*AA*) - -这个特别的配方有什么特别之处? 众所周知,从图像中提取 Haar 特征包括计算这些求和,我们将不得不对图像中许多不同尺度的矩形进行求和。 很多这样的计算都是重复的,因为我们会一遍又一遍地重复相同的像素。 速度如此之慢,以至于建立一个实时系统是不可行的。 因此,我们需要这个公式。 如您所见,我们不必多次迭代相同的像素。 如果我们要计算任何矩形的面积,前面公式右侧的所有值在积分图像中都很容易得到。 我们只需选取正确的值,将它们替换到前面的方程式中,然后提取特征即可。 - -# 在直播视频中叠加口罩 - -OpenCV 提供了一个很好的人脸检测框架。 我们只需要加载级联文件,并使用它来检测图像中的人脸。 当我们从网络摄像头捕捉到视频流时,我们可以将滑稽的面具覆盖在我们的脸上。 它看起来如下所示: - -![](img/0f7a664d-669d-4ba2-baea-9af29b67c959.png) - -让我们看一下代码的主要部分,看看如何在输入视频流的面部覆盖这个蒙版。 在随本书提供的可下载代码包中提供了完整的代码: - -```cpp -#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) ); -``` - -让我们停下来看看这里发生了什么。 我们开始读取网络摄像头的输入帧,并根据我们选择的大小调整它的大小。 捕获的帧是彩色图像,人脸检测工作在灰度图像上进行。 因此,我们将其转换为灰度并均衡直方图。 为什么我们需要使直方图均衡? 我们需要这样做,以补偿任何问题,如照明或饱和度。 如果图像太亮或太暗,检测效果会很差。 因此,我们需要均衡直方图,以确保我们的图像具有健康的像素值范围: - -```cpp - // 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)); -``` - -在这一点上,我们知道脸在哪里了。 因此,我们提取感兴趣的区域以在正确的位置覆盖蒙版: - -```cpp - // 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); -``` - -我们分离出与面罩相关的像素。 我们希望以这样一种方式覆盖蒙版,使其看起来不像一个矩形。 我们希望得到覆盖对象的精确边界,以便它看起来很自然。 现在让我们继续覆盖面具: - -```cpp - // 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; -} -``` - -# 代码里发生了什么? - -首先要注意的是,这段代码有两个输入参数-**face ascade XML**文件和**掩码图像**。 您可以使用`resources`文件夹下提供的`haarcascade_frontalface_alt.xml`和`facemask.jpg`文件。 我们需要一个可用于检测图像中人脸的分类器模型,OpenCV 提供了一个可用于此目的的预构建 XML 文件。 我们使用`faceCascade.load()`函数加载 XML 文件,并检查文件是否加载正确。 我们启动视频捕获对象来捕获来自网络摄像头的输入帧。 然后我们将其转换为灰度以运行检测器。 函数的作用是提取输入图像中所有人脸的边界。 我们可能需要根据需要缩小图像,因此此函数中的第二个参数可以解决这一问题。 此比例因子是我们在每个比例下进行的跳跃;由于我们需要在多个比例下查找面,因此下一个大小将是当前大小的 1.1 倍。 最后一个参数是阈值,它指定保留当前矩形所需的相邻矩形的数量。 它可以用来增加人脸检测器的鲁棒性。 我们开始`while`循环,并在每一帧中持续检测人脸,直到用户按下*Esc*键。 一旦我们检测到一张脸,我们需要在它上面覆盖一个面具。 我们可能需要稍微修改一下尺寸,以确保面罩合身。 此自定义稍有主观性,它取决于所使用的遮罩。 现在我们已经提取了感兴趣的区域,我们需要将遮罩放置在该区域的顶部。 如果我们用白色背景覆盖面具,它看起来会很奇怪。 我们必须提取蒙版的精确曲线边界,然后将其覆盖。 我们希望头骨蒙版像素是可见的,其余区域应该是透明的。 - -正如我们所看到的,输入掩码的背景是白色的。 因此,我们通过对蒙版图像应用阈值来创建蒙版。 使用试错法,我们可以看到阈值`240`运行良好。 在图像中,强度值大于`240`的所有像素将变为`0`,所有其他像素将变为`255`。 就感兴趣的区域而言,我们必须将该区域内的所有像素都涂黑。 要做到这一点,我们只需使用刚刚创建的蒙版的反面。 在最后一步中,我们只需添加掩码版本即可生成最终输出图像。 - -# 戴上你的太阳镜 - -既然我们了解了如何检测人脸,我们就可以将这个概念推广到检测人脸的不同部位。 我们将在直播视频中使用眼睛探测器覆盖太阳镜。 重要的是要理解 Viola-Jones 框架可以应用于任何对象。 准确性和健壮性将取决于对象的唯一性。 例如,人脸有非常独特的特征,所以很容易训练我们的系统变得健壮。 另一方面,像毛巾这样的物体太通用了,没有明显的特征,所以很难构建一个健壮的毛巾检测器。 一旦你建造了眼睛探测器并覆盖了眼镜,它看起来就像这样: - -![](img/7a39d1f6-ea84-42df-938f-9a5137fc31da.png) - -让我们看一下代码的主要部分: - -```cpp -... -int main(int argc, char* argv[]) -{ - string faceCascadeName = argv[1]; - string eyeCascadeName = argv[2]; - - // Variable declaration and initialization - .... - // Face detection code - .... - vector centers; - .... - // Draw green circles around the eyes - for( auto& face:faces ) - { - Mat faceROI = frameGray(face[i]); - vector eyes; - - // In each face, detect eyes eyeCascade.detectMultiScale(faceROI, eyes, 1.1, 2, 0 |CV_HAAR_SCALE_IMAGE, Size(30, 30)); -``` - -正如我们在这里看到的,我们只在脸部区域运行眼睛检测器。 我们不需要在整张图片中搜索眼睛,因为我们知道眼睛总是在一张脸上: - -```cpp - // 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]; - } -``` - -只有当我们找到这两只眼睛时,我们才能探测到它们并将它们储存起来。 然后我们使用它们的坐标来确定哪个是左眼,哪个是右眼: - -```cpp - // 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)); -``` - -在前面的代码中,我们调整了太阳镜的大小,以适应网络摄像头上我们的脸的比例。 让我们检查一下剩余的代码: - -```cpp - // 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; -} -``` - -# 查看代码内部 - -您可能已经注意到,代码流看起来与我们在直播视频中的*覆盖面膜部分中讨论的人脸检测代码类似。 我们加载了人脸检测级联分类器和眼睛检测级联分类器。 那么,为什么我们在检测眼睛的时候需要加载人脸级联分类器呢? 嗯,我们并不真的需要使用面部检测器,但它可以帮助我们限制对眼睛位置的搜索。 我们知道眼睛总是位于某人的脸上,所以我们可以将眼睛检测限制在面部区域。 第一步是检测脸部,然后在这个区域运行我们的眼睛探测器代码。 由于我们将在较小的地区开展业务,因此速度会更快,效率也会更高。* - -对于每一帧,我们从检测人脸开始。 然后我们继续对这个区域进行手术来检测眼睛的位置。 在这一步之后,我们需要覆盖太阳镜。 要做到这一点,我们需要调整太阳镜图像的大小,以确保它适合我们的脸。 为了获得合适的比例,我们可以考虑被检测的两只眼睛之间的距离。 只有当我们察觉到两只眼睛时,我们才会戴上太阳镜。 这就是为什么我们首先运行眼睛探测器,收集所有的中心,然后覆盖太阳镜。 一旦我们有了这个,我们只需要盖上太阳镜面具。 用于遮罩的原理与我们用于覆盖面膜的原理非常相似。 您可能需要定制太阳镜的大小和位置,具体取决于您想要的。 你可以玩不同类型的太阳镜,看看它们是什么样子。 - -# 跟踪鼻子、嘴巴和耳朵 - -既然您知道了如何使用该框架跟踪不同的东西,那么您也可以尝试跟踪您的鼻子、嘴巴和耳朵了。 让我们用鼻子探测器覆盖一个滑稽的鼻子: - -![](img/0e21d6b6-9acb-4906-bf30-bc9097a5c0b2.png) - -您可以参考代码文件了解该检测器的完整实现。 `haarcascade_mcs_nose.xml`、`haarcascade_mcs_mouth.xml`、`haarcascade_mcs_leftear.xml`和`haarcascade_mcs_rightear.xml`级联文件可用于跟踪不同的面部分。 和他们一起玩耍,试着把胡子或德古拉耳朵盖在自己身上。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在这一章中,我们讨论了 Haar 级联和积分像。 我们了解了人脸检测管道是如何构建的。 我们学习了如何检测和跟踪实时视频流中的人脸。 我们讨论了使用人脸检测框架来检测各种人脸部位,如眼睛、耳朵、鼻子和嘴巴。 最后,我们学习了如何利用人脸部分检测的结果在输入图像上叠加蒙版。 - -在下一章中,我们将学习视频监控、背景去除和形态学图像处理。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/08.md b/trans/build-cv-proj-opencv4-cpp/08.md deleted file mode 100644 index 78e5fb9d..00000000 --- a/trans/build-cv-proj-opencv4-cpp/08.md +++ /dev/null @@ -1,511 +0,0 @@ -# 视频监控、背景建模和形态学操作 - -在本章中,我们将学习如何在静态摄像机拍摄的视频中检测运动对象。 这在视频监控系统中被广泛使用。 我们将讨论可用于构建此系统的不同特征。 我们将学习背景建模,并了解如何使用它来构建实时视频中的背景模型。 一旦我们这样做了,我们将组合所有的块来检测视频中感兴趣的对象。 - -在本章结束时,您应该能够回答以下问题: - -* 什么是天真的背景减法? -* 什么是帧差? -* 我们如何构建背景模型? -* 我们如何识别静态视频中的新对象? -* 什么是形态学图像处理?它与背景建模有什么关系? -* 如何使用形态运算符实现不同的效果? - -# 技术要求 - -本章要求熟悉 C++ 编程语言的基础知识。 本章使用的所有代码都可以从以下 giHub 链接下载:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter08](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter08)。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。 - -请查看以下视频,了解实际操作中的代码: - -[http://bit.ly/2SfqzRo](http://bit.ly/2SfqzRo) - -# 理解背景减法 - -背景减除在视频监控中非常有用。 基本上,背景减去技术在我们必须检测静态场景中的移动对象的情况下执行得非常好。 这对视频监控有什么用处? 视频监控的过程涉及到处理持续不断的数据流。 数据流不断涌入,我们需要对其进行分析以识别任何可疑活动。 让我们考虑一下酒店大堂的例子。 所有的墙壁和家具都有固定的位置。 如果我们建立一个背景模型,我们就可以用它来识别大厅里的可疑活动。 我们利用了背景场景保持静态的事实(在本例中恰好是这样)。 这有助于我们避免任何不必要的计算开销。 顾名思义,该算法的工作原理是检测图像的每个像素并将其分配给两类,背景(假定是静态的和稳定的)或前景,并从当前帧中减去它以获得前景图像部分,其中包括运动对象,如人物、汽车等。 在静态假设下,前景对象将自然地对应于在背景前面移动的对象或人。 - -为了检测运动目标,我们需要建立背景模型。 这与直接帧差不同,因为我们实际上是在对背景建模,并使用此模型来检测移动对象。 当我们说我们正在对背景建模时,我们基本上是在构建一个可以用来表示背景的数学公式。 这比简单的帧差分技术要好得多。 该技术尝试检测场景的静态部分,然后在背景模型的构建统计公式中包括小的更新。 然后使用该背景模型来检测背景像素。 因此,这是一种可以根据场景进行调整的自适应技术。 - -# 朴素背景减法 - -让我们从头开始讨论吧。 背景减去过程是什么样子的? 请考虑下图: - -![](img/d7b2457d-8007-4ec0-9015-2db936b52551.png) - -上图表示背景场景。 现在,让我们在此场景中引入一个新对象: - -![](img/d2b28c66-191c-4f1b-a8fd-d21de66676b0.png) - -正如我们所看到的,场景中有一个新对象。 因此,如果我们计算这张图像和我们的背景模型之间的差异,您应该能够识别电视遥控器的位置: - -![](img/1bef167b-be5f-459f-b66f-70e38c1ef125.png) - -整个流程如下所示: - -![](img/f4c00cc5-3fe2-4587-8eba-07f8a3e38538.png) - -# 它工作得好吗? - -我们称它为**幼稚**方法是有原因的! 它在理想的条件下工作,正如我们所知,在现实世界中没有什么是理想的。 它在计算给定对象的形状方面做得相当不错,但它是在一些约束条件下这样做的。 这种方法的主要要求之一是目标的颜色和强度应该与背景的颜色和强度有足够的不同。 影响这种算法的一些因素包括图像噪声、照明条件和相机的自动对焦。 - -一旦一个新物体进入我们的场景并停留在那里,就很难检测到它前面的新物体。 这是因为我们没有更新背景模型,而新对象现在是背景的一部分。 请考虑下图: - -![](img/560632e2-556d-4100-8753-f78059df3980.png) - -现在,假设一个新对象进入我们的场景: - -![](img/86f24a30-4cba-4ba6-b0c8-5ed29d01b37f.png) - -我们检测到这是一个新物体,这很好! 假设另一个对象进入场景: - -![](img/0fe9b547-ea42-4968-b7cf-85b118ea1a3f.png) - -很难识别这两个不同对象的位置,因为它们的位置是重叠的。 以下是减去背景并应用阈值后得到的结果: - -![](img/f66adada-3016-444e-9cdf-18d83f3ad513.png) - -在这种方法中,我们假设背景是静态的。 如果背景的某些部分开始移动,这些部分将开始被检测为新对象。 因此,即使是微小的移动,比如飘动的旗帜,也会导致我们的检测算法出现问题。 这种方法对照明的变化也很敏感,并且不能处理任何摄像机移动。 不用说,这是一种微妙的方法! 我们需要一种能够处理现实世界中所有这些事情的东西。 - -# 帧差分 - -我们知道,我们不能保持可用于检测对象的静态背景图像模式。 解决此问题的方法之一是使用帧差。 这是我们可以用来查看视频的哪些部分正在移动的最简单的技术之一。 当我们考虑实时视频流时,连续帧之间的差异提供了大量信息。 这个概念相当简单! 我们只获取连续帧之间的差异,并显示它们之间的差异。 - -如果我快速移动我的笔记本电脑,我们可以看到类似这样的情况: - -![](img/7b97e885-6b05-4f67-b3c6-fdf9d9931e3a.png) - -让我们移动物体,看看会发生什么,而不是笔记本电脑。 如果我迅速摇头,它会是这样的: - -![](img/01e28f11-33b5-42ef-879e-f9e8b46423ae.png) - -正如您从前面的图像中看到的,只有视频的移动部分会高亮显示。 这为我们提供了一个很好的起点来查看视频中哪些区域在移动。 让我们看一下计算帧差的函数: - -```cpp -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 运算符,以便在看到移动对象时获得一定的稳定性。 - -让我们看一下可以从网络摄像头中提取并返回帧的函数: - -```cpp -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; -} -``` - -正如我们所看到的,这是相当直截了当的。 我们只需要调整帧的大小并将其转换为灰度。 现在我们已经准备好了助手函数,让我们来看一下 Main 函数,看看它们是如何组合在一起的: - -```cpp -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; -} -``` - -# 它的效果如何? - -正如我们所看到的,帧差异解决了我们早先面临的几个重要问题。 它可以快速适应光线变化或相机移动。 如果对象进入帧并停留在那里,则在以后的帧中将不会检测到该对象。 这种方法的主要关注点之一是检测颜色均匀的物体。 它只能检测颜色均匀的物体的边缘。 原因是该对象的很大一部分将导致非常低的像素差异: - -![](img/7693e483-6a97-41b9-b2d5-eed251806930.png) - -假设这个物体稍微移动了一下。 如果我们将此帧与上一帧进行比较,它将如下所示: - -![](img/a385ce9c-c74e-4621-8a4c-5b82716aa3de.png) - -因此,我们在该对象上标记的像素非常少。 另一个令人担忧的问题是,很难检测到物体是朝向相机还是远离相机。 - -# 混合高斯方法 - -在我们讨论高斯的**混合**(**MOG**)之前,让我们先看看什么是**混合模型**。 混合模型只是一种统计模型,可以用来表示我们数据中的子总体的存在。 我们并不真正关心每个数据点属于什么类别。 我们所需要做的就是确定数据中包含多个组。 如果我们用高斯函数来表示每个子总体,那么它就叫做高斯混合。 让我们来看一下下面的照片: - -![](img/10e09a07-4487-44db-84cb-2f0eb228c24f.png) - -现在,随着我们在这个场景中收集更多的帧,图像的每个部分都将逐渐成为背景模型的一部分。 这也是我们在*帧差*部分前面讨论的内容。 如果场景是静态的,模型会自动调整以确保更新背景模型。 应该表示前景对象的前景蒙版在这一点上看起来像黑色图像,因为每个像素都是背景模型的一部分。 - -OpenCV has multiple algorithms implemented for the Mixture of Gaussians approach. One of them is called **MOG** and the other is called **MOG2:** refer to this link for a detailed explanation: [http://docs.opencv.org/master/db/d5c/tutorial_py_bg_subtraction.html#gsc.tab=0](http://docs.opencv.org/master/db/d5c/tutorial_py_bg_subtraction.html#gsc.tab=0). You will also be able check out the original research papers that were used to implement these algorithms. - -让我们等待一段时间,然后在场景中引入一个新对象。 让我们使用 MOG2 方法来看看新的前景遮罩是什么样子: - -![](img/ae8535f1-dd6a-42f6-8dd7-af878a3ae0ad.png) - -如您所见,新对象正在被正确识别。 让我们看一下代码中有趣的部分(您可以在`.cpp`文件中获得完整的代码): - -```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; -} -``` - -# 代码里发生了什么? - -让我们快速浏览一下代码,看看那里发生了什么。 我们使用混合高斯模型来创建背景减去对象。 此对象表示当我们遇到来自网络摄像头的新帧时将更新的模型。 我们初始化了两个背景减去模型-`BackgroundSubtractorMOG`和`BackgroundSubtractorMOG2`。 它们代表了用于背景减去的两种不同算法。 第一个是指*P*的论文。 *KadewTraKuPong*和*R*。 *Bowden,*,标题为*一种改进的自适应背景混合模型,用于阴影检测的实时跟踪*。 你可以在[http://personal.ee.surrey.ac.uk/Personal/R.Bowden/publications/avbs01/avbs01.pdf](http://personal.ee.surrey.ac.uk/Personal/R.Bowden/publications/avbs01/avbs01.pdf)上查看。 第二篇引用了*Z*的论文。 *Zivkovic,*,题为*改进的自适应高斯混合模型背景减除*.。 您可以在这里查看:[http://www.zoranz.net/Publications/zivkovic2004ICPR.pdf](http://www.zoranz.net/Publications/zivkovic2004ICPR.pdf)。 -我们开始一个无限的`while`循环,并连续读取网络摄像头的输入帧。 对于每个帧,我们都会更新背景模型,如以下各行所示: - -```cpp -pMOG2->apply(frame, fgMaskMOG2); -``` - -在这些步骤中更新背景模型。 现在,如果新对象进入场景并停留在那里,它将成为背景模型的一部分。 这帮助我们克服了**朴素**背景减去模型的最大缺点之一。 - -# 形态学图像处理 - -正如我们前面讨论的,背景减除方法受许多因素的影响。 它们的准确性取决于我们如何捕捉数据以及如何处理这些数据。 影响这些算法的最大因素之一是噪声水平。 当我们说**噪声**时,我们谈论的是诸如图像中的颗粒性和孤立的黑/白像素之类的东西。 这些问题往往会影响我们算法的质量。 这就是形态学图像处理发挥作用的地方。 形态学图像处理在许多实时系统中得到了广泛的应用,以保证输出的质量。 形态学图像处理是指对图像中特征的形状进行处理;例如,可以使形状变粗或变薄。 形态运算符不取决于像素在图像中的排序方式,而取决于它们的值。 这就是为什么它们非常适合处理二值图像中的形状。 形态学图像处理也可以应用于灰度图像,但像素值不会有太大影响。 - -# 潜在的原则是什么? - -形态运算符使用结构元素修改图像。 什么是结构元素? 结构元素基本上是一个小形状,可以用来检查图像中的一个小区域。 它被定位在图像中的所有像素位置,以便可以检查该邻域。 我们基本上是取一个小窗口,并将其覆盖在一个像素上。 根据响应,我们在该像素位置采取适当的操作。 - -让我们考虑以下输入图像: - -![](img/dec264c3-e2c9-4155-a697-64701866ce62.png) - -我们将对这张图像应用一系列形态学操作,以查看形状是如何变化的。 - -# 瘦身造型 - -我们使用一种称为**侵蚀**的操作来实现这种效果。 这是通过剥离图像中所有形状的边界层来使形状变薄的操作: - -![](img/2a424078-3d94-4b24-b0a1-c2f1fb3e25c2.png) - -让我们看看执行形态侵蚀的函数: - -```cpp -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 函数构建结构化元素。 此对象用作探针,根据特定条件修改每个像素。 这些条件指的是图像中特定像素周围发生的情况。 例如,它是否被白色像素包围? 或者它被黑色像素包围? 一旦我们有了答案,我们就会采取适当的行动。 - -# 加厚形状 - -我们使用一种称为**膨胀**的操作来实现增厚。 这是通过将边界层添加到图像中的所有形状来使形状更厚的操作: - -![](img/00a1949e-bc61-473a-a7ff-621f49f78ac3.png) - -下面是执行此操作的代码: - -```cpp -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; -} -``` - -# 其他形态运算符 - -下面是其他一些有趣的形态运算符。 让我们先看一下输出图像。 我们可以查看本节末尾的代码。 - -# 形态开口 - -这是**打开**形状的操作。 该运算符经常用于去除图像中的噪声。 基本上就是先侵蚀后膨胀。 形态开口通过将小对象放置在背景中,将它们从图像的前景中移除: - -![](img/139422de-1a7f-4d07-b46e-66a90f1a98a5.png) - -以下是执行形态打开的函数: - -```cpp -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; -} -``` - -正如我们在这里看到的,我们对图像应用**侵蚀**和**膨胀**来执行形态学打开。 - -# 形态闭合 - -这是**通过填充间隙来关闭**形状的操作,如下面的屏幕截图所示。 此操作也可用于噪声去除。 它基本上是先膨胀后侵蚀的过程。 此操作通过将背景中的小对象更改为前景来移除前景中的小洞: - -![](img/4b1c042b-d2ae-40ce-aeb5-fd0132edb607.png) - -让我们快速了解一下执行形态闭合的函数: - -```cpp -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; -} -``` - -# 绘制边界 - -我们使用形态梯度来实现这一点。 这是通过计算图像的膨胀和侵蚀之间的差异来在形状周围绘制边界的操作: - -![](img/71000e21-3a57-4fdc-9076-7b71f3a07540.png) - -让我们看一下执行形态渐变的函数: - -```cpp -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; -} -``` - -# Top Hat 变换 - -该变换从图像中提取更精细的细节。 这就是输入图像和其形态开口之间的差异。 这为我们提供了图像中比结构元素更小、比周围环境更亮的对象。 根据结构元素的大小,我们可以提取给定图像中的各种对象: - -![](img/b307977b-3e53-4ad8-bb45-76102f0578c6.png) - -如果你仔细观察输出的图像,你可以看到那些黑色的矩形。 这意味着结构元素能够适应那里,所以这些区域变暗了。 下面是函数: - -```cpp -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; -} -``` - -# 黑帽变换 - -该变换还可以从图像中提取更精细的细节。 这就是图像的形态闭合和图像本身之间的区别。 这为我们提供了图像中比结构元素更小、比其周围环境更暗的对象: - -![](img/fd5545fe-fe97-4c5d-a472-7c0eadba58a2.png) - -让我们看一下执行 Black Hat 转换的函数: - -```cpp -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; -} -``` - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了用于背景建模和形态学图像处理的算法。 我们讨论了朴素的背景减法及其局限性。 我们研究了如何使用帧差来获取运动信息,以及当我们想要跟踪不同类型的对象时,它是如何受到限制的。 这导致了我们对高斯混合的讨论。 我们讨论了这个公式以及我们如何实现它。 然后,我们讨论了可以用于各种目的的形态学图像处理,并介绍了不同的操作来展示用例。 - -在下一章中,我们将讨论目标跟踪以及可以用来实现目标跟踪的各种技术。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/09.md b/trans/build-cv-proj-opencv4-cpp/09.md deleted file mode 100644 index c0f5050b..00000000 --- a/trans/build-cv-proj-opencv4-cpp/09.md +++ /dev/null @@ -1,681 +0,0 @@ -# 学习对象跟踪 - -在上一章中,我们学习了视频监控、背景建模和形态学图像处理。 我们讨论了如何使用不同的形态运算符将很酷的视觉效果应用到输入图像中。 在本章中,我们将学习如何跟踪实况视频中的对象。 我们将讨论可用于跟踪的对象的不同特征。 我们还将学习目标跟踪的不同方法和技术。 目标跟踪广泛应用于机器人、自动驾驶汽车、车辆跟踪、运动中的运动员跟踪和视频压缩。 - -在本章结束时,您将了解以下内容: - -* 如何跟踪特定颜色的对象 -* 如何构建交互式对象跟踪器 -* 什么是拐角探测器? -* 如何检测要跟踪的好特征 -* 如何构建基于光流的特征跟踪器 - -# 技术要求 - -本章要求熟悉 C++ 编程语言的基础知识。 本章中使用的所有代码都可以从以下 gihub 链接下载:*[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter09](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter09)。*这些代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上测试过。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2SidbMc](http://bit.ly/2SidbMc) - -# 跟踪特定颜色的对象 - -为了建立一个好的目标跟踪器,我们需要了解哪些特征可以用来使我们的跟踪健壮和准确。 所以,让我们朝这个方向迈出一小步,看看我们是否能利用色彩空间信息来设计出一个好的视觉跟踪器。 要记住的一件事是,颜色信息对照明条件很敏感。 在真实的应用中,您必须进行一些预处理来处理这些问题。 但现在,让我们假设其他人正在做这件事,我们得到的是清晰的彩色图像。 - -有许多不同的色彩空间,选择一个好的色彩空间将取决于用户使用的不同应用。 虽然 RGB 是计算机屏幕上的原生表示形式,但它对人类来说不一定是理想的。 当涉及到人类时,我们根据颜色的色调更自然地给颜色命名,这就是为什么**色调饱和值**(**HSV**)可能是信息最丰富的颜色空间之一。 它与我们感知颜色的方式密切相关。 色调指的是光谱,饱和度指的是特定颜色的强度,值指的是该像素的亮度。 这实际上是用柱面格式表示的。 你可以在[http://infohost.nmt.edu/tcc/help/pubs/colortheory/web/hsv.html](http://infohost.nmt.edu/tcc/help/pubs/colortheory/web/hsv.html)找到一个简单的解释。 我们可以将图像的像素放到 HSV 颜色空间中,然后使用该颜色空间来测量在该颜色空间中的距离和在该空间中的阈值,以跟踪给定的对象。 - -请考虑视频中的以下帧: - -![](img/a36e48c0-d47c-40b1-a530-5ef4402b3d84.png) - -如果通过颜色空间滤镜运行它并跟踪该对象,您将看到如下所示: - -![](img/5ca4cb32-de67-43cd-86cf-4b9202396394.png) - -正如我们在这里看到的,我们的跟踪器根据颜色特征识别视频中的特定对象。 为了使用这个跟踪器,我们需要知道目标对象的颜色分布。 下面是跟踪彩色对象的代码,它只选择具有特定给定色调的像素。 代码有很好的注释,因此请阅读每个术语的解释以了解发生了什么: - -```cpp -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; -} -``` - -# 构建交互式对象跟踪器 - -基于颜色空间的跟踪器让我们可以自由地跟踪彩色对象,但我们也受到预定义颜色的限制。 如果我们只是想随机挑选一个对象呢? 我们如何构建一个能够学习所选对象的特征并自动跟踪它的对象跟踪器? 这就是**c****连续自适应 Mean Shift**(**CAMShift**)算法出现的地方。 它基本上是均值漂移算法的改进版本。 - -均值漂移的概念实际上很好很简单。 假设我们选择了一个感兴趣的区域,并希望我们的对象跟踪器跟踪该对象。 在该区域中,我们根据颜色直方图选取一组点,并计算空间点的质心。 如果质心位于该区域的中心,我们就知道该物体没有移动。 但是如果质心不在这个区域的中心,那么我们就知道物体在朝某个方向移动。 质心的移动控制对象移动的方向。 因此,我们将对象的边界框移动到一个新位置,以便新的质心成为该边界框的中心。 因此,这种算法被称为均值移位,因为均值(质心)在移动。 这样,我们就可以随时了解对象的当前位置。 - -但是 Mean Shift 的问题是边界框的大小不允许改变。 当您将对象从相机移开时,该对象在人眼看来会变小,但 Mean Shift 不会考虑这一点。 在整个跟踪会话中,边界框的大小将保持不变。 因此,我们需要使用 CAMShift。 CAMShift 的优点是它可以根据对象的大小调整边界框的大小。 除此之外,它还可以跟踪物体的方位。 - -让我们考虑下面的帧,其中对象被高亮显示: - -![](img/032e06d9-6566-452d-9c03-3bda991d66d8.png) - -现在我们已经选择了对象,该算法计算直方图反投影并提取所有信息。 什么是直方图反投影? 这只是一种识别图像是否符合我们直方图模型的方法。 我们计算特定物体的直方图模型,然后使用该模型在图像中找到该物体。 让我们移动对象,看看它是如何被跟踪的: - -![](img/efbfef21-6e14-4705-bd2f-9f8e740a8608.png) - -看起来这个物体被追踪得相当好。 让我们更改方向,看看追踪是否保持不变: - -![](img/b6b58289-ac76-4c6e-9d1c-a1c72b5eb462.png) - -正如我们所看到的,边界椭圆已经改变了它的位置和方向。 让我们改变对象的视角,看看它是否仍然能够跟踪它: - -![](img/be2bc4ef-2890-47e6-9509-756efe461454.png) - -我们还是很棒的! 边界椭圆更改了纵横比,以反映对象现在看起来倾斜的事实(因为透视变换)。 让我们看看代码中的用户界面功能: - -```cpp -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 执行对象跟踪的代码: - -```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; - - // 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 图像等待处理。 让我们继续看看如何使用我们的阈值来处理此图像: - -```cpp - 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 谱中定位所需的颜色,然后在此基础上过滤掉图像。 让我们继续看看如何计算直方图反投影: - -```cpp - // 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); - } -``` - -现在我们准备好显示结果了。 使用旋转的矩形,让我们围绕感兴趣的区域绘制一个椭圆: - -```cpp - // 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 矩阵,然后对特征值进行分析。 这到底是什么意思? 好吧,让我们仔细分析一下,这样我们就能更好地理解它。 让我们考虑一下图像中的一个小补丁。 我们的目标是确定这个补丁中是否有角落。 因此,我们考虑所有的邻域面片,并计算我们的面片与所有邻域面片之间的亮度差。 如果各个方向的差异都很大,那么我们就知道我们的地块有一个角落。 这是对实际算法的过度简化,但它涵盖了要点。 如果你想了解基本的数学细节,你可以在[http://www.bmva.org/bmvc/1988/avc-88-023.pdf](http://www.bmva.org/bmvc/1988/avc-88-023.pdf)查看*Harris*和*Stephens*的原文。 拐角是指沿两个方向的强度差异很大的点。 - -如果我们运行 Harris 角检测器,它将如下所示: - -![](img/12eae1a2-b90f-4e7e-a2a5-806428ba89d1.png) - -正如我们所看到的,电视遥控器上的绿色圆圈是检测到的角落。 这将根据您为检测器选择的参数进行更改。 如果修改参数,您可以看到可能会检测到更多的点。 如果您将其设置为严格,则可能无法检测到软角。 让我们看一下检测哈里斯拐角的代码: - -```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); - - 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`文件中找到完整的代码。 这些参数在将要检测的点数中起着重要作用。 您可以在[https://docs.opencv.org/master/dd/d1a/group__imgproc__feature.html#gac1fc3598018010880e370e2f709b4345](https://docs.opencv.org/master/dd/d1a/group__imgproc__feature.html#gac1fc3598018010880e370e2f709b4345)查看`cornerHarris()`的 OpenCV 文档。 - -我们现在有了我们需要的所有信息。 让我们继续在我们的角落周围画圈,以显示结果: - -```cpp - // 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(j,i) > thresh) - { - circle(frame, Point(i, j), 8, Scalar(0,255,0), 2, 8, 0); - } - } - } - - // Showing the result - imshow(windowName, frame); - - // Get the keyboard input and check if it's 'Esc' - // 27 -> ASCII value of 'Esc' key - ch = waitKey(10); - if (ch == 27) { - break; - } - } - - // Release the video capture object - cap.release(); - - // Close all windows - destroyAllWindows(); - - return 1; -} -``` - -正如我们所看到的,这段代码接受一个输入参数:`blockSize`。 根据您选择的大小,性能会有所不同。 从值 4 开始,试着使用它,看看会发生什么。 - -# 要跟踪的良好功能 - -Harris 角点检测器在很多情况下都表现良好,但仍有改进的余地。 大约在*Harris*和*Stephens*、*Shii*和*Tomasi*最初的论文发表 6 年后,他们将其称为*跟踪*的良好特征。 你可以在这里阅读原文:[http://www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf](http://www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf)。 他们使用了不同的评分函数来提高整体质量。 使用这种方法,我们可以在给定的图像中找到 N 个最强的角点。 当我们不想使用每个角落来从图像中提取信息时,这是非常有用的。 正如我们所讨论的,一个好的兴趣点检测器在目标跟踪、目标识别和图像搜索等应用中非常有用。 - -如果将 Shii-Tomasi 角点检测器应用于图像,您将看到如下所示: - -![](img/ceec9d76-ca6f-41e3-9908-ce3b0431a312.png) - -正如我们在这里看到的,帧中的所有重要点都被捕捉到了。 让我们看一下跟踪这些功能的代码: - -```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); - - // Convert to grayscale - cvtColor(frame, frameGray, COLOR_BGR2GRAY ); - - // Initialize the parameters for Shi-Tomasi algorithm - vector 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`来检测角点。 重要的是要理解,检测到的角点数量将取决于我们选择的参数。 你可以在[http://docs.opencv.org/2.4/modules/imgproc/doc/feature_detection.html?highlight=goodfeaturestotrack#goodfeaturestotrack](http://docs.opencv.org/2.4/modules/imgproc/doc/feature_detection.html?highlight=goodfeaturestotrack#goodfeaturestotrack)上找到详细的解释。 让我们继续在这些点上画圆圈以显示输出图像: - -```cpp - // 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 方法。 我们所说的稀疏是指特征点的数量相对较少。 您可以在这里参考他们的原文:[http://cseweb.ucsd.edu/classes/sp02/cse252/lucaskanade81.pdf](http://cseweb.ucsd.edu/classes/sp02/cse252/lucaskanade81.pdf)。 我们从提取特征点开始这个过程。 对于每个特征点,我们以特征点为中心创建 3 x 3 面片。 这里的假设是,每个面片中的所有点都会有类似的运动。 我们可以根据手头的问题调整此窗口的大小。 - -对于当前帧中的每个特征点,我们将周围的 3x3 面片作为参考点。 对于此补丁,我们在前一帧的邻域中查找,以获得最佳匹配。 这个邻域通常大于 3x3,因为我们希望得到最接近所考虑的面片的面片。 现在,从上一帧中匹配面片的中心像素到当前帧中正在考虑的面片的中心像素的路径将成为运动向量。 我们对所有的特征点都这样做,并提取所有的运动矢量。 - -让我们考虑以下框架: - -![](img/427162ae-a630-4bd0-845a-75605ec51b50.png) - -我们需要添加一些我们想要跟踪的点。 只需用鼠标单击此窗口上的一系列点即可: - -![](img/0ac4f874-b07c-4acd-8552-2dfd16054be8.png) - -如果我移动到不同的位置,您将看到这些点仍在一个小误差范围内被正确跟踪: - -![](img/e6a5dcb2-6aa1-42a4-adc1-1e5b07165208.png) - -让我们加很多分,看看会发生什么: - -![](img/7404ce82-8d63-4cf8-b1ae-ecf289adfbb7.png) - -正如我们所看到的,它将继续跟踪这些点。 但是,你会注意到,一些点会因为突出程度或移动速度等因素而丢失。 如果你想玩这个游戏,你可以继续给它加更多的分数。 您还可以让用户在输入视频中选择感兴趣的区域。 然后,可以从该感兴趣区域提取特征点,并通过绘制边界框追踪对象。 这将是一次有趣的练习! - -以下是执行基于 Lucas-Kanade 的跟踪的代码: - -```cpp -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 statusVector; - - // Error vector to indicate the error for the corresponding feature - vector 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); -``` - -我们使用当前图像和前一图像来计算光流信息。 不用说,输出的质量将取决于所选择的参数。 有关参数的更多详细信息,请访问[http://docs.opencv.org/2.4/modules/video/doc/motion_analysis_and_object_tracking.html#calcopticalflowpyrlk](http://docs.opencv.org/2.4/modules/video/doc/motion_analysis_and_object_tracking.html#calcopticalflowpyrlk)。 为了提高质量和健壮性,我们需要过滤掉彼此非常接近的点,因为它们不会添加新的信息。 让我们继续这样做: - -```cpp - - 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); - } -``` - -我们现在有了跟踪点。 下一步是细化这些点的位置。 在这种情况下,**Refined**的确切含义是什么? 为了提高计算速度,需要进行一定程度的量化。 用外行的话说,你可以把它看作是四舍五入。 现在我们有了大致的区域,我们可以细化该区域内点的位置,以获得更准确的结果。 让我们继续这样做: - -```cpp - - // Refining the location of the feature points - if(pointTrackingFlag && trackingPoints[1].size() < maxNumPoints) - { - vector 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 地图中被广泛使用。 您可以在这里查看原文:[http://www.diva-portal.org/smash/get/diva2:273847/FULLTEXT01.pdf](http://www.diva-portal.org/smash/get/diva2:273847/FULLTEXT01.pdf)。 Lucas-Kanade 方法是一种稀疏技术,这意味着我们只需要处理整个图像中的一些像素。 另一方面,Farneback 算法是一种密集技术,需要我们处理给定图像中的所有像素。 因此,很明显,这是一种权衡。 密集技术更精确,但速度更慢。 稀疏技术的精确度较低,但速度更快。 对于实时应用,人们倾向于使用稀疏技术。 对于时间和复杂性不是一个因素的应用,人们倾向于使用密集技术来提取更精细的细节。 - -在他的论文中,Farneback 描述了一种基于多项式展开的两帧密集光流估计方法。 我们的目标是估计这两个帧之间的运动,这基本上是一个分三步走的过程。 在第一步中,用多项式逼近两帧中的每个邻域。 在这种情况下,我们只对二次多项式感兴趣。 下一步是通过全局位移构造新的信号。 现在每个邻域都用一个多项式来近似,我们需要看看如果这个多项式经过理想的平移会发生什么。 最后一步是通过将这些二次多项式的产量中的系数相等来计算全局位移。 - -现在,这怎么可能呢? 如果你仔细想想,我们假设一个完整的信号是一个单一的多项式,并且这两个信号之间存在全局平移。 这不是一个现实的场景! 那么,我们在找什么呢? 嗯,我们的目标是找出这些误差是否足够小,这样我们就可以建立一个有用的算法来跟踪这些特征。 - -让我们看一张静态图像: - -![](img/545cab44-63df-43a8-ad6f-fbad88aae123.png) - -如果我横向移动,我们可以看到运动矢量指向水平方向。 它只是简单地跟踪我的头部运动: - -![](img/d48b5460-1737-40f3-a09e-31a7a388f285.png) - -如果我离开网络摄像头,您可以看到运动矢量指向垂直于图像平面的方向: - -![](img/4934cb3a-934b-43cd-a71e-1f1c67746ab7.png) - -以下是使用 Farneback 算法进行基于光流的跟踪的代码: - -```cpp -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`的输入参数很重要。 您可以在[http://docs.opencv.org/3.0-beta/modules/video/doc/motion_analysis_and_object_tracking.html](http://docs.opencv.org/3.0-beta/modules/video/doc/motion_analysis_and_object_tracking.html)上找到有关这些参数的详细信息。 让我们继续在输出图像上绘制这些向量: - -```cpp - // 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`的函数来绘制这些光流矢量。 这些矢量表示运动的方向。 让我们看一下该函数,看看我们是如何绘制这些向量的: - -```cpp -// 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(y, x); - line(flowImageGray, Point(x,y), Point(cvRound(x+pt.x), cvRound(y+pt.y)), color); - } - } -} -``` - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了目标跟踪。 我们学习了如何使用 HSV 色彩空间来跟踪特定颜色的对象。 我们讨论了用于对象跟踪的群集技术,以及如何使用 CAMShift 算法构建交互式对象跟踪器。 我们研究了角点探测器,以及如何在现场视频中跟踪角点。 我们讨论了如何使用光流跟踪视频中的特征。 最后,我们理解了 Lucas-Kanade 和 Farneback 算法背后的概念,然后实现了它们。 - -在下一章中,我们将讨论分割算法以及如何将其用于文本识别。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/10.md b/trans/build-cv-proj-opencv4-cpp/10.md deleted file mode 100644 index 4df6152f..00000000 --- a/trans/build-cv-proj-opencv4-cpp/10.md +++ /dev/null @@ -1,474 +0,0 @@ -# 开发用于文本识别的分割算法 - -在前面的章节中,我们学习了广泛的图像处理技术,如阈值、轮廓描述符和数学形态学。 在本章中,我们将讨论您在处理扫描文档时可能遇到的常见问题,例如识别文本位置或调整其旋转。 我们还将学习如何结合前面章节中介绍的技术来解决这些问题。 在本章结束时,我们将拥有可发送到**光学字符识别**(**OCR**)库的文本分段区域。 - -在本章结束时,您应该能够回答以下问题: - -* 存在哪些类型的 OCR 应用? -* 编写 OCR 应用时有哪些常见问题? -* 如何识别文档的区域? -* 如何处理文本中间的歪斜和其他元素等问题? -* 如何使用 Tesseract OCR 识别我的文本? - -# 技术要求 - -本章要求熟悉基本的 C++ 编程语言。 本章中使用的所有代码都可以从以下 giHub 链接下载:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter10[GitHub](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter10)。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2KIoJFX](http://bit.ly/2KIoJFX) - -# 光学字符识别简介 - -识别图像中的文本是计算机视觉中非常流行的应用。 该过程通常被称为**光学字符识别**,其划分如下: - -* **文本预处理和分割**:在此步骤中,计算机必须处理图像噪声和旋转(倾斜),并识别哪些区域是候选文本。 -* **文本识别**:这是识别文本中每个字母的过程,将在后面的章节中介绍。 - -预处理和分割阶段可能会因文本来源的不同而大不相同。 让我们来看看进行预处理的常见情况: - -* **带有扫描仪的生产 OCR 应用**:这是一个非常可靠的文本来源。 在这种情况下,图像的背景通常为白色,文档几乎与扫描仪页边距对齐。 被扫描的内容基本上都是文本,几乎没有噪音。 这类应用依赖于简单的预处理技术,可以快速调整文本并保持快速扫描速度。 在编写生产 OCR 软件时,通常会将重要文本区域的标识委托给用户,并创建用于文本验证和索引的质量管道。 -* **扫描随意拍摄的照片或视频中的文本**:这是一个复杂得多的场景,因为没有指示文本可能在哪里。 此场景称为**场景文本识别**,OpenCV 4.0 包含一个 Conrib 库来处理它。 我们将在[第 11 章](11.html),*使用 Tesseract 进行文本识别*中介绍这一点。 通常,预处理器将使用纹理分析技术来识别文本模式。 -* **为历史文本创建制作质量的 OCR**:历史文本也会被扫描,但它们有几个额外的问题,例如旧纸张颜色和墨水的使用产生的噪音。 其他常见的问题是装饰字母和特定的文本字体,以及由墨水创建的低对比度内容,这些内容会随着时间的推移而被擦除。 为手头的文档编写特定的 OCR 软件并不少见。 -* **扫描地图**、**图表**、**和图表**:地图、图表和图表构成了一个特别困难的场景,因为文本通常位于图像内容的任何方向和中间。 例如,城市名称通常是群集的,海洋名称通常遵循国家海岸等高线。 有些图表颜色浓重,文本以清晰和深色两种色调显示。 - -根据识别目标的不同,OCR 应用策略也会有所不同。 它会用于全文搜索吗? 或者,是否应该将文本分成多个逻辑字段,以便用结构化搜索的信息为数据库编制索引? - -在本章中,我们将重点介绍对扫描的文本或由相机拍摄的文本进行预处理。 我们将考虑文本是图像的主要目的,例如,在一张照片纸或卡片中,例如,在这张停车罚单中: - -![](img/144c48a5-2315-4dc7-961e-bfdba51444c7.png) - -我们将尝试删除常见的噪声,处理文本旋转(如果有的话),并裁剪可能的文本区域。 虽然大多数 OCRAPI 已经自动完成了这些工作--可能还使用了最先进的算法--但了解事情是如何在幕后发生的仍然是值得的。 这将使您更好地了解大多数 OCR API 参数,并使您更好地了解可能面临的潜在 OCR 问题。 - -# 预处理阶段 - -识别字母的软件通过将文本与之前记录的数据进行比较来实现这一点。 如果输入的文本清晰,如果字母处于垂直位置,并且没有其他元素(如发送到分类软件的图像),则分类结果可以大大提高。 在本节中,我们将学习如何使用**预处理**来调整文本。 - -# 对图像进行阈值处理 - -我们通常通过对图像进行阈值处理来开始预处理。 这将消除所有颜色信息。 大多数 OpenCV 函数认为信息是用白色书写的,而背景是黑色的。 因此,让我们首先创建一个阈值函数来匹配此条件: - -```cpp -#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`函数应用阈值,类似于我们在[第 4 章](04.html)、*深入研究直方图和过滤器*中所做的操作。 但在这里,我们将通过在函数的第四个参数中传递`THRESH_OTSU`来使用 Otsu 方法。Otsu 方法最大化类间方差。 由于阈值仅创建两个类别(黑色和白色像素),因此这与最小化类内方差相同。 此方法使用图像直方图工作。 然后,它迭代所有可能的阈值,并为阈值的每一侧(即图像的背景或前景中的像素)计算像素值的散布。 这个过程的目的是找出两个价差之和最小的阈值。 - -阈值设置完成后,该函数计算图像中有多少白色像素。 黑色像素就是图像区域给出的图像中的总像素数减去白色像素数。 由于文本通常是在纯背景上书写的,因此我们将验证是否存在更多的白色像素而不是黑色像素。 在本例中,我们处理的是白色背景上的黑色文本,因此我们将反转图像以进行进一步处理。 - -对停车罚单图像进行阈值处理的结果如下: - -![](img/36a5c85f-7499-44b7-ba7f-cb06ab604ee0.png) - -# 文本分割 - -下一步是找到文本所在的位置并将其提取出来。 为此,有两种常见的策略: - -* **使用连通分量分析**:搜索图像中的连通像素组。 这将是本章将使用的技术。 -* **使用分类器搜索先前训练的字母纹理模式**:对于纹理特征,如**Haralick 和**特征,通常使用小波变换。 另一种选择是在本任务中识别**个最稳定的极值区域**(**MSER**s)。 这种方法对于复杂背景中的文本更加健壮,将在[第 11 章](11.html),*使用 Tesseract*进行文本识别中进行研究。 你可以在他自己的网站上读到关于哈拉里克的特写,可以在[http://haralick.org/journals/TexturalFeatures.pdf](http://haralick.org/journals/TexturalFeatures.pdf)上找到。 - -# 创建连接区域 - -如果仔细观察图像,您会注意到字母总是以块的形式排列在一起,由文本段落组成。 这就给我们留下了一个问题,我们如何检测和删除这些块? - -第一步是让这些障碍更加明显。 我们可以通过使用膨胀形态运算符来实现这一点。 回想一下[第 8 章](08.html)、*视频监控*、*背景建模*、*和形态运算*,这种膨胀会使图像元素变得更厚。 让我们看一小段能做到这一点的代码片段: - -```cpp -auto kernel = getStructuringElement(MORPH_CROSS, Size(3,3)); -Mat dilated; -dilate(input, dilated, kernel, cv::Point(-1, -1), 5); -imshow("Dilated", dilated); -``` - -在前面的代码中,我们首先创建一个将在形态学操作中使用的 3x3 交叉内核。 然后,我们以这个内核为中心,进行五次膨胀。 确切的内核大小和次数因情况而异。 只需确保这些值将同一行中的所有字母粘合在一起即可。 - -此操作的结果显示在以下屏幕截图中: - -![](img/a98b3b54-357a-4182-b6d8-f242dc26ac41.png) - -请注意,我们现在有了巨大的白色方块。 它们与文本的每一段精确匹配,也与其他非文本元素(如图像或边界噪声)匹配。 - -The ticket image that comes with the code is a low resolution image. OCR engines usually work with high resolution images (200 or 300 DPI), so it may be necessary to apply dilation more than five times. - -# 标识段落块 - -下一步是执行连接分量分析,以找到与段落对应的块。 OpenCV 具有此功能,我们之前在[第 5 章](05.html)、*自动光学检测*、*对象分割*、*和检测*中使用过。 这是`findContours`函数: - -```cpp -vector;vector;Point;contours; -findContours(dilated, contours, RETR_EXTERNAL, CHAIN_APPROX_SIMPLE); -``` - -在第一个参数中,我们传递放大的图像。 第二个参数是检测到的轮廓的向量。 然后,我们使用该选项仅检索外部轮廓并使用简单近似。 图像轮廓如下所示。 每种灰色色调代表不同的轮廓: - -![](img/a4123c64-fe0d-4eb1-9a6f-ab4f0bb05e23.png) - -最后一步是确定每个轮廓的最小旋转边界矩形。 OpenCV 为此操作提供了一个名为`minAreaRect`的方便函数。 此函数接收任意点的向量,并返回包含边界框的`RoundedRect`。这也是丢弃不需要的矩形(即明显不是文本的矩形)的好机会。 由于我们正在制作用于 OCR 的软件,我们将假定文本包含一组字母。 在此假设下,我们将在以下情况下丢弃文本: - -* 矩形宽度或大小太小,即小于 20 像素。 这将有助于丢弃边界噪声和其他小型人工制品。 -* 图像的矩形的宽/高比例小于 2。 也就是说,类似于正方形的矩形(如图像图标)或更高的矩形也将被丢弃。 - -在第二种情况下有一个小小的警告。 由于我们处理的是旋转的边界框,因此必须测试边界框角度是否小于-45 度。 如果是,文本是垂直旋转的,所以我们必须考虑的比例是高度/宽度。 - -让我们通过查看以下代码来检查这一点: - -```cpp -//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); -} -``` - -让我们看看该算法选择了哪些框: - -![](img/2795f5c3-fad4-439c-a458-53cd13bb4bf6.png) - -这当然是个好结果! - -我们应该注意到,在前面的代码中,步骤 2 中描述的算法也将丢弃单个字母。 这不是什么大问题,因为我们正在创建一个 OCR 预处理器,单个符号对于上下文信息通常是没有意义的;页码就是这种情况的一个例子。 在此过程中,页码将被丢弃,因为它们通常单独出现在页面底部,并且文本的大小和比例也会受到干扰。 但这不是问题,因为在文本通过 OCR 之后,您将得到大量的文本文件,根本没有分页。 - -我们将把所有这些代码放在一个具有以下签名的函数中: - -```cpp -vector RotatedRect; findTextAreas(Mat input) -``` - -# 文本提取和倾斜调整 - -现在,我们要做的就是提取文本并调整文本倾斜。 这由`deskewAndCrop`函数完成,如下所示: - -```cpp -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 函数来实现这一点。 此函数接受三个参数: - -* **中心**:旋转的中心位置。 旋转将围绕该中心旋转。 在我们的例子中,我们使用盒子中心。 -* **角度**:旋转角度。 如果角度为负,则旋转方向为顺时针。 -* **比例**:各向同性比例因子。 我们将使用`1.0`,因为我们希望保持长方体的原始比例不变。 - -旋转本身通过使用`warpAffine`功能进行。 此函数接受四个必选参数: - -* **SRC**:要转换的输入`mat`数组。 -* **DST**:目标`mat`数组。 -* **M**:变换矩阵。 这个矩阵是 2x3 仿射变换矩阵。 这可以是平移、缩放或旋转矩阵。 在我们的例子中,我们将只使用最近创建的矩阵。 -* **size**:输出图像的大小。 我们将生成一个与输入图像大小相同的图像。 - -以下是另外三个可选参数: - -* **标志**:这些标志指示应该如何对图像进行插值。 我们使用`BICUBIC_INTERPOLATION`来获得更好的质量。 默认值为`LINEAR_INTERPOLATION`。 -* **BORDER**:边框模式。 我们使用默认值`BORDER_CONSTANT`。 -* **边框值**:边框的颜色。 我们使用默认设置,即黑色。 然后,我们使用`getRectSubPix`函数。 旋转图像后,需要裁剪边界框的矩形区域。 此函数接受四个强制参数和一个可选参数,并返回裁剪后的图像: - * **image**:要裁剪的图像。 - * **size**:描述要裁剪的框的宽度和高度的`cv::Size`对象。 - * **Center**:要裁剪区域的中心像素。 请注意,因为我们绕中心旋转,所以这个点很方便地是相同的。 - * **补丁**:目标镜像。 - * **PATCH_TYPE**:目标图像的深度。 我们使用默认值,表示源图像的相同深度。 - -最后一步由`copyMakeBorder`函数完成。 此函数用于在图像周围添加边框。 这一点很重要,因为分类阶段通常要求文本周围留有边距。 函数参数非常简单:输入和输出图像、顶部、底部、左侧和右侧的边框粗细、边框类型和新边框的颜色。 - -对于卡片图像,将生成以下图像: - -![](img/af403921-7a43-4bf0-b950-2c3a2bbd0b06.png) - -现在,是将所有功能组合在一起的时候了。 让我们来介绍执行以下操作的 Main 方法: - -* 加载票证图像 -* 调用我们的二值化函数 -* 查找所有文本区域 -* 在窗口中显示每个区域 - -我们将主要介绍以下方法: - -```cpp -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"); - } -} -``` - -For the complete source code, take a look at the `segment.cpp` file that comes with this book. - -# 在操作系统上安装 Tesseract OCR - -Tesseract 是一种开源的光学字符识别引擎,最初由惠普实验室布里斯托尔和惠普公司开发。它的所有代码都在阿帕奇许可下获得许可,并托管在 GitHub 的[https://github.com/tesseract-ocr](https://github.com/tesseract-ocr)上。 它被认为是目前最精确的 OCR 引擎之一:它可以读取各种各样的图像格式,并可以转换用 60 多种语言书写的文本。 在本课程中,我们将教您如何在 Windows 或 Mac 上安装 Tesseract。 因为有很多 Linux 发行版,我们不会教您如何在这个操作系统上安装它。 通常,Tesseract 在您的包存储库中提供安装包,因此,在您自己编译 Tesseract 之前,只需在那里搜索它即可。 - -# 在 Windows 上安装 Tesseract - -Tesseract 使用**C++ 归档网络**(**CPPAN**)作为其依赖项管理器。 要安装 Tesseract,请执行以下步骤。 - -# 建造最新的图书馆 - -1. 从[https://cppan.org/client/](https://cppan.org/client/)下载最新的 CPPAN 客户端。 -2. 在命令行中,运行 - `cppan --build pvt.cppan.demo.google.tesseract.tesseract-master`。 - -# 在 Visual Studio 中设置 Tesseract - -1. 在[https://github.com/Microsoft/vcpkg](https://github.com/Microsoft/vcpkg)设置 Visual C++ 包管理器`vcpkg`。 -2. 对于 64 位编译,请使用`vcpkg install tesseract:x64-windows`。 您还可以为主分支添加`--head`。 - -# 静态链接 - -还可以在项目中静态链接([https://github.com/tesseract-ocr/tesseract/wiki/Compiling#static-linking](https://github.com/tesseract-ocr/tesseract/wiki/Compiling#static-linking))Tesseract。 这将避免将`dlls`与您的可执行文件打包在一起。 要执行此操作,请像我们之前所做的那样,将`vcpkg`与以下命令一起用于 32 位安装: - -```cpp -vcpkg install tesseract:x86-windows-static -``` - -或者,您也可以使用以下命令进行 64 位安装: - -```cpp -vckpg install tesseract:x64-windows-static -``` - -# 在 Mac 上安装 Tesseract - -在 Mac 上安装 Tesseract OCR 的最简单方法是使用**Homebrew**。 如果您还没有安装 Homebrew,只需转到 Homebrew 的站点([Ruby](http://brew.sh/)),打开您的控制台,然后运行首页上的**http://brew.sh/脚本**。 您可能需要输入管理员密码。 - -安装 HomeBREW 后,只需键入以下内容: - -```cpp -brew install tesseract -``` - -英语已包含在此安装中。 如果要安装其他语言包,只需运行以下命令: - -```cpp -brew install tesseract --all-languages -``` - -这将安装所有语言包。 然后,只需转到 Tesseract 安装目录并删除任何不需要的语言。 自制软件通常在`/usr/local/`目录中安装内容。 - -# 使用 Tesseract OCR 库 - -虽然 Tesseract OCR 已经与 OpenCV 3.0 集成,但它的 API 仍然值得研究,因为它允许对 Tesseract 参数进行更细粒度的控制。 此集成将在[第 11 章](11.html),*与 Tesseract 的文本识别*中进行研究。 - -# 创建 OCR 功能 - -我们将更改前面的示例以使用 Tesseract。 首先将`tesseract/baseapi.h`和`fstream`添加到`include`列表: - -```cpp -#include opencv2/opencv.hpp; -#include tesseract/baseapi.h; - -#include vector; -#include fstream; -``` - -然后,我们将创建一个表示我们的 Tesseract OCR 引擎的全局`TessBaseAPI`对象: - -```cpp -tesseract::TessBaseAPI ocr; -``` - -The `ocr` engine is completely self-contained. If you want to create a multi-threaded piece of OCR software, just add a different `TessBaseAPI` object in each thread, and the execution will be fairly thread-safe. You just need to guarantee that file writing is not done over the same file, otherwise you'll need to guarantee safety for this operation. - -接下来,我们将创建一个名为**Identify Text for**(`identifyText`)的函数,该函数将运行`ocr`: - -```cpp -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`函数来完成的。 此函数具有以下签名: - -```cpp -int Init(const char* datapath, const char* language, - OcrEngineMode oem) -``` - -下面我们来解释一下每个参数: - -* `datapath`:这是指向`tessdata`个文件的根目录的路径。 路径必须以反斜杠`/`字符结束。 `tessdata`目录包含您安装的语言文件。 将`NULL`传递给此参数将使`tesseract`搜索其安装目录,这是此文件夹通常所在的位置。 在部署应用时,通常会将此值更改为`args[0]`,并在应用路径中包含`tessdata`文件夹。 -* `language`:这是语言代码的三个字母的单词(例如,英语为英语,葡萄牙语为 POR,印地语为 HIN)。 Tesseract 支持使用`+`符号加载多语言代码。 因此,通过`eng+por`将同时加载英语和葡萄牙语。 当然,您只能使用以前安装的语言,否则加载过程将失败。 语言配置文件可以指定必须一起加载两种或两种以上语言。 为了防止出现这种情况,您可以使用波浪号`~`。 例如,您可以使用`hin+~eng`来保证英语不会加载印地语,即使它被配置为这样做。 -* `OcrEngineMode`:这些是将使用的 OCR 算法。 它可以具有下列值之一: - * `OEM_TESSERACT_ONLY`:仅使用`tesseract`。 这是最快的方法,但精度也较低。 - * `OEM_CUBE_ONLY`:使用多维数据集引擎。 它更慢,但更精确。 只有当您的语言经过培训以支持此引擎模式时,这才会起作用。 要检查是否如此,请在`tessdata`文件夹中查找您的语言的`.cube`文件。 对英语的支持是有保证的。 - * `OEM_TESSERACT_CUBE_COMBINED`:这结合了 Tesseract 和 Cube,以实现最佳的 OCR 分类。 该引擎具有最好的精确度和最慢的执行时间。 - * `OEM_DEFAULT`:这将根据语言配置文件或命令行配置文件推断策略,如果两者都不存在,则使用`OEM_TESSERACT_ONLY`。 - -需要强调的是,`Init`函数可以多次执行。 如果提供了不同的语言或引擎模式,Tesseract 将清除以前的配置并重新启动。 如果提供了相同的参数,则 Tesseract 足够聪明,可以简单地忽略该命令。 函数`init`在成功的情况下返回`0`,在失败的情况下返回`-1`。 - -然后,我们的程序将继续设置页面分割模式: - -```cpp -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`方法: - -```cpp -void SetImage(const unsigned char* imagedata, int width, - int height, int bytes_per_pixel, int bytes_per_line); -``` - -这些参数几乎是不言而喻的,并且大多数参数都可以直接从我们的`Mat`对象中读取: - -* `data`:包含图像数据的原始字节数组。 OpenCV 在`Mat`类中包含一个名为`data()`的函数,该函数提供指向数据的直接指针。 -* `width`:图像宽度。 -* `height`:图像高度。 -* `bytes_per_pixel`:每个像素的字节数。 我们使用的是`1`,因为我们处理的是二进制图像。 如果希望代码更通用,还可以使用`Mat::elemSize()`函数,该函数提供相同的信息。 -* `bytes_per_line`:单行中的字节数。 我们使用`Mat::step`属性,因为有些图像会添加尾随字节。 - -然后,我们调用`GetUTF8Text`来运行识别本身。 返回识别出的文本,使用 UTF8 编码,不带 BOM。 在返回它之前,我们还打印一些调试信息。 - -`MeanTextConf`返回置信度指数,该指数可以是介于`0`到`100`之间的一个数字: - -```cpp - auto text = ocr.GetUTF8Text(); - cout "Text:" endl; - cout text endl; - cout "Confidence: " ocr.MeanTextConf() endl; -``` - -# 将输出发送到文件 - -让我们更改 Main 方法,将识别的输出发送到文件。 为此,我们使用标准的`ofstream`: - -```cpp -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(); -} -``` - -以下行以二进制模式打开文件: - -```cpp -file.open("ticket.txt", std::ios::out | std::ios::binary); -``` - -这一点很重要,因为 Tesseract 返回以 UTF-8 编码的文本,并考虑了 Unicode 中提供的特殊字符。 我们还使用以下命令直接编写输出: - -```cpp -file.write(text, strlen(text)); -``` - -在此示例中,我们使用葡萄牙语作为输入语言(这是票证编写时使用的语言)调用`identify`函数。 如果你愿意,你可以用另一张照片。 - -The complete source file is provided in the `segmentOcr.cpp` file, which comes with this book. `ticket.png` is a low resolution image, since we imagined you would want to display a window with the image while studying this code. For this image, the Tesseract results are rather poor. If you want to test with a higher resolution image, the code for this book provides you with a `ticketHigh.png` image. To test with this image, change the dilation repetitions to `12` and the minimum box size from `20` to `60`. You'll get a much higher confidence rate (about 87%), and the resulting text will be almost fully readable. The `segmentOcrHigh.cpp` file contains these modifications. - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们简要介绍了 OCR 应用。 我们看到,这类系统的预处理阶段必须根据我们计划识别的文档类型进行调整。 我们了解了预处理文本文件时的常见操作,如阈值、裁剪、倾斜和文本区域分割。 最后,我们学习了如何安装和使用 Tesseract OCR 将图像转换为文本。 - -在下一章中,我们将使用更复杂的 OCR 技术来识别随意拍摄的图片或视频中的文本-这种情况称为场景文本识别。 这是一个复杂得多的场景,因为文本可以在任何地方,使用任何字体,并且具有不同的照明和方向。 甚至可以根本没有文字! 我们还将学习如何使用 OpenCV 3.0 文本贡献模块,该模块与 Tesseract 完全集成。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/11.md b/trans/build-cv-proj-opencv4-cpp/11.md deleted file mode 100644 index 2a09a01a..00000000 --- a/trans/build-cv-proj-opencv4-cpp/11.md +++ /dev/null @@ -1,480 +0,0 @@ -# 使用 Tesseract 进行文本识别 - -在[第 10 章](10.html),*开发用于文本识别的分割算法*中,我们介绍了非常基本的 OCR 处理函数。 虽然它们对于扫描或拍照的文档非常有用,但在处理随意出现在图片中的文本时几乎毫无用处。 - -在本章中,我们将探索 OpenCV 4.0 文本模块,该模块专门处理场景文本检测。 使用此接口,可以检测网络摄像头视频中出现的文本,或者分析拍摄的图像(如街景或监控摄像头拍摄的图像),以实时提取文本信息。 这允许创建范围广泛的应用,从可访问性到营销,甚至是机器人领域。 - -在本章结束时,您将能够执行以下操作: - -* 了解什么是场景文本识别 -* 了解 Text API 的工作原理 -* 使用 OpenCV 4.0 Text API 检测文本 -* 将检测到的文本提取到图像中 -* 使用 Text API 和 Tesseract 集成来识别字母 - -# 技术要求 - -本章要求熟悉基本的 C++ 编程语言。 本章使用的所有代码都可以从以下 giHub 链接下载:[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter11](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter11)。 该代码可以在任何操作系统上执行,尽管它只在 Ubuntu 上进行了测试。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2Slht5A](http://bit.ly/2Slht5A) - -# Text API 的工作原理 - -Text API 实现了*Lukás Neumann*和*Jiri Matas*在 2012 年**计算机视觉和模式识别**(**CVPR**)会议期间在文章*Real*-*Time Scene Text Location and Recognition*中提出的算法。 该算法代表了场景文本检测的显著提高,在 CVPR 数据库和 Google Street View 数据库中都执行了最先进的检测。 在使用 API 之前,让我们先来看看这个算法是如何在幕后工作的,以及它是如何解决场景文本检测问题的。 - -**Remember**: The OpenCV 4.0 text API does not come with the standard OpenCV modules. It's an additional module that's present in the OpenCV `contrib` package. If you installed OpenCV using the Windows Installer, you should take a look back at [Chapter 1](01.html), *Getting Started with OpenCV;* this will guide you on how to install these modules. - -# 场景检测问题 - -检测场景中随机出现的文本是一个比看起来更难的问题。 在与识别的扫描文本进行比较时,需要考虑以下几个新变量: - -* **三维性**:文本可以是任何比例、方向或视角。 此外,文本可能被部分遮挡或中断。 从字面上看,它可能出现在图像中的区域有数千个。 -* **Varity**:文本可以有几种不同的字体和颜色。 字体可能有轮廓边框。 背景可以是深色、浅色或复杂的图像。 -* **照明和阴影**:太阳光的位置和外观颜色随时间变化。 雾或雨等不同的天气条件会产生噪音。 即使在封闭的空间里,照明也可能是个问题,因为光线会反射到彩色物体上,并照射到文本上。 -* **模糊**:文本可能出现在未通过镜头自动对焦确定优先级的区域。 模糊在移动相机、透视文本或有雾的情况下也很常见。 - -下面的图片来自谷歌街景,说明了这些问题。 请注意,其中几种情况是如何在一张图像中同时发生的: - -![](img/95a7ec19-6f59-4c1b-9e32-9897d3840f05.png) - -由于存在**2*n***个像素子集,***n***是图像中的像素数,因此执行文本检测来处理此类情况可能会证明计算代价很高。 - -为了降低复杂性,通常使用两种策略: - -* **使用滑动窗口仅搜索图像矩形的子集**:此策略只是将子集的数量减少到较小的数量。 根据所考虑的文本的复杂程度,区域的数量会有所不同。 与还处理旋转、倾斜、透视等的算法相比,仅处理文本旋转的算法可能使用较小的值。 这种方法的优点在于它的简单性,但它们通常仅限于很小范围的字体,而且通常限于特定单词的词典。 -* **连通分量分析的使用**:此方法假设像素可以分组为具有相似属性的区域。 这些地区被认为有更高的机会被识别为人物。 这种方法的优点是它不依赖于几个文本属性(方向、比例、字体等),而且它们还提供了可用于将文本裁剪到 OCR 的分割区域。 这是我们在[第 10 章](10.html),*开发用于文本识别的分割算法*中使用的方法。 照明也可能影响结果,例如,如果阴影投射在字母上,会产生两个截然不同的区域。 但是,由于场景检测通常用于移动车辆(例如,无人机或汽车)和视频,因此文本最终将被检测到,因为这些照明条件会因帧而异。 - -OpenCV 4.0 算法通过执行连通分量分析和搜索极值区域来使用第二种策略。 - -# 极值区域 - -极值区域是以几乎均匀的强度为特征的连通区域,周围环绕着对比鲜明的背景。 一个区域的稳定性可以通过计算该区域对阈值变化的抵抗力来衡量。 这种差异可以用一种简单的算法来测量: - -1. 应用阈值,生成图像*A*。 检测其连接的像素区域(极值区域)。 -2. 将阈值增加一个增量,生成图像*B*。 检测其连接的像素区域(极值区域)。 -3. 将图像*B*与*A*进行比较。 如果图像 A 中的某个区域与图像*B*中的相同区域相似,则将其添加到树中的同一分支。 相似性的标准可能因实现而异,但通常与图像区域或一般形状有关。 如果图像*A*中的区域似乎在图像*B*中被拆分,则在树中为新区域创建两个新分支,并将其与前一个分支相关联。 -4. 设置*A*=*B*并返回步骤 2,直到应用最大阈值。 - -这将组装一个区域树,如下所示: - -![](img/8950282c-4b14-4191-9f3c-83839ea72b90.png) - -对方差的抵抗力是通过计算同一级别中有多少个节点来确定的。 通过分析这棵树,还可以确定**个最稳定的极值区域**(**MSER**s),即该区域在各种阈值下保持稳定的区域。 在上图中,很明显这些区域将包含字母***O***、***N***和***Y***。 最大极值区域的主要缺点是它们在存在模糊的情况下很弱。 OpenCV 在**Feature 2d**模块中提供了一个 MSER 特性检测器。 极值区域很有趣,因为它们对光照、比例和方向都有很强的不变性。 它们也是很好的文本候选者,因为它们在使用的字体类型方面也是不变的,即使在设置了字体样式的情况下也是如此。 还可以分析每个区域以确定其边界省略,并且可以具有仿射变换和数值确定的面积等属性。 最后,值得一提的是,整个过程速度很快,这使得它成为实时应用的一个非常好的候选者。 - -# 极值区域滤波 - -虽然 MSER 是定义哪些极端区域值得使用的常用方法,但*Neumann*和*Matas*算法使用不同的方法,将所有极端区域提交给经过字符检测训练的顺序分类器。 此分类器在两个不同的阶段工作: - -1. 第一阶段递增地计算每个区域的描述符(边界框、周长、面积和欧拉数)。 这些描述符被提交给分类器,该分类器估计该区域成为字母表中的字符的可能性有多大。 然后,仅为阶段 2 选择高概率区域。 -2. 在这一阶段中,计算了整体面积比、凸壳比、外边界拐点个数等特征。 这提供了更详细的信息,允许分类器丢弃非文本字符,但它们的计算速度也要慢得多。 - -在 OpenCV 下,此过程在名为`ERFilter`的类中实现。 还可以使用不同的图像单通道投影,例如*R*、*G*、*B*、亮度或灰度转换来提高字符识别率。 最后,必须将所有字符分组为文本块(例如单词或段落)。 OpenCV 3.0 为此提供了两种算法: - -* **修剪穷举搜索**:同样是由*Mattas*在 2011 年提出的,该算法不需要任何先前的训练或分类,但仅限于水平对齐的文本 -* **定向文本的分层方法**:它处理任意方向的文本,但需要经过训练的分类器 - -Note that since these operations require classifiers, it is also necessary to provide a trained set as input. OpenCV 4.0 provides some of these trained sets in the following sample package: [https://github.com/opencv/opencv_contrib/tree/master/modules/text/samples](https://github.com/opencv/opencv_contrib/tree/master/modules/text/samples). -This also means that this algorithm is sensitive to the fonts used in classifier training. - -在下面的视频中可以看到该算法的演示,该视频由诺伊曼本人提供:[https://www.youtube.com/watch?v=ejd5gGea2Fo&Feature=youtu.be](https://www.youtube.com/watch?v=ejd5gGea2Fo&feature=youtu.be)。 一旦文本被分割,它只需要被发送到像 Tesseract 这样的 OCR,类似于我们在[第 10 章](10.html),*开发用于文本识别的分割算法*中所做的工作。 唯一的区别是,现在我们将使用 OpenCV 文本模块类与 Tesseract 交互,因为它们提供了一种封装我们正在使用的特定 OCR 引擎的方法。 - -# 使用 Text API - -理论说得够多了。 现在我们来看看文本模块在实践中是如何工作的。 让我们研究一下如何使用它来执行文本检测、提取和识别。 - -# 文本检测 - -让我们从创建一个简单的程序开始,这样我们就可以使用**ERFilters**执行文本分割。 在本程序中,我们将使用文本 API 样本中训练好的分类器。 您可以从 OpenCV 资源库下载,但也可以在本书的配套代码中找到。 - -首先,我们首先包括所有必要的`libs`和`usings`: - -```cpp -#include "opencv2/highgui.hpp" -#include "opencv2/imgproc.hpp" -#include "opencv2/text.hpp" - -#include -#include - -using namespace std; -using namespace cv; -using namespace cv::text; -``` - -回想一下*极值区域过滤*部分,`ERFilter`在每个图像通道中单独工作。 因此,我们必须提供一种在不同的单个通道`cv::Mat`中分离每个所需通道的方法。 这由`separateChannels`函数完成: - -```cpp -vector separateChannels(const Mat& src) -{ - vector 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(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`函数将图像分割成几个通道。 该函数定义如下: - -```cpp -void computeNMChannels(InputArray src, OutputArrayOfArrays channels, int mode = ERFILTER_NM_RGBLGrad); -``` - -以下是其参数: - -* `src`:源输入数组。 它必须是 8UC3 型彩色图像。 -* `channels`:将用结果通道填充的`Mats`的向量。 -* `mode`:定义将计算哪些通道。 可以使用两个可能的值: - * `ERFILTER_NM_RGBLGrad`:指示算法是否使用 RGB 颜色、亮度和渐变幅值作为通道(默认) - * `ERFILTER_NM_IHSGrad`:指示是否按图像的强度、色调、饱和度和渐变大小分割图像 - -我们还附加了向量中所有颜色分量的负片。 由于图像将有三个不同的通道(*R*、*G*和*B*),这通常就足够了。 也可以添加未翻转的通道,就像我们对去灰度化图像所做的那样,但我们最终会得到 6 个通道,这可能会占用大量的计算机资源。 当然,如果这会带来更好的结果,您可以自由地使用您的图像进行测试。 最后,如果提供了另一种图像,该函数将终止程序并显示错误消息。 - -Negatives are appended, so the algorithms will cover both bright text in a dark background and dark text in a bright background. There is no sense in adding a negative for the gradient magnitude. - -让我们继续主要方法。 我们将使用此程序对`easel.png`图像进行分割,该图像随源代码提供: - -![](img/9ed2b8e2-9327-498c-8846-b1942c6425ca.png) - -这张照片是我走在街上时用手机相机拍的。 让我们对此进行编码,以便您也可以通过在第一个程序参数中提供其名称来轻松地使用不同的图像: - -```cpp -int main(int argc, const char * argv[]) -{ - const char* image = argc < 2 ? "easel.png" : argv[1]; - auto input = imread(image); -``` - -接下来,我们将通过调用`separateChannels`函数将图像转换为灰度并分隔其通道: - -```cpp - Mat processed; - cvtColor(input, processed, COLOR_RGB2GRAY); - - auto channels = separateChannels(processed); -``` - -如果要使用彩色图像中的所有通道,只需将此代码摘录的前两行替换为以下内容: - -```cpp -Mat processed = input; -``` - -我们需要分析六个通道(RGB 和反转),而不是两个(灰色和反转)。 事实上,处理时间所增加的,远较我们所能得到的改善为多。 有了通道后,我们需要为算法的两个阶段创建`ERFilters`。 幸运的是,OpenCV 文本贡献模块提供了这样的功能: - -```cpp -// 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`类的实例。 该函数具有以下签名: - -```cpp -Ptr createERFilterNM1(const Ptr& 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**)的最小区域。 这是通过图像大小的百分比来衡量的。 面积小于此值的 ERR 会立即被丢弃。 -* `maxArea`:ER 中可以找到文本的最大区域。 这也是通过图像大小的百分比来衡量的。 面积大于这一范围的 ER 会立即被丢弃。 -* `minProbability`:区域必须是字符才能进入下一阶段的最小概率。 -* `nonMaxSupression`:用于指示是否在每个分支概率中执行非最大抑制。 -* `minProbabilityDiff`:最小和最大极值区域之间的最小概率差。 - -第二阶段的过程与此类似。 我们调用`loadClassifierNM2`来加载第二阶段的分类器模型,调用`createERFilterNM2`来创建第二阶段分类器。 该函数只接受加载的分类模型的输入参数和区域被视为字符所必须达到的最小概率。 因此,让我们在每个通道中调用这些算法来识别所有可能的文本区域: - -```cpp -//Extract text regions using Newmann & Matas algorithm -cout << "Processing " << channels.size() << " channels..."; -cout << endl; -vector > 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`执行),此参数必须包含在阶段 1 中选择的区域。这些区域将由阶段 2 处理和过滤。 - -最后,我们释放这两个过滤器,因为程序中将不再需要它们。 最后的分割步骤是将所有 ERRegion 分组为可能的单词,并定义它们的边界框。 这可以通过调用`erGrouping`函数来完成: - -```cpp -//Separate character groups from regions -vector< vector > groups; -vector groupRects; -erGrouping(input, channels, regions, groups, groupRects, ERGROUPING_ORIENTATION_HORIZ); -``` - -此函数具有以下签名: - -```cpp -void erGrouping(InputArray img, InputArrayOfArrays channels, std::vector > ®ions, std::vector > &groups, std::vector &groups_rects, int method = ERGROUPING_ORIENTATION_HORIZ, const std::string& filename = std::string(), float minProbablity = 0.5); -``` - -让我们来看看每个参数的含义: - -* `img`:输入图像,也称为原始图像。 -* `regions`:提取区域的单通道图像的矢量。 -* `groups`:分组区域的索引的输出向量。 每组区域包含单个单词的所有极值区域。 -* `groupRects`:带有检测到的文本区域的矩形列表。 -* `method`:这是分组的方法。 它可以是以下任一项: - * `ERGROUPING_ORIENTATION_HORIZ`:默认值。 这只会按照*Neumann*和*Matas*最初提出的方法,通过进行详尽的搜索来生成具有水平方向的文本组。 - * `ERGROUPING_ORIENTATION_ANY`:这将使用单一链接聚类和分类器生成具有任意方向的文本的组。 如果使用此方法,则必须在下一个参数中提供分类器模型的文件名。 - * `Filename`:分类器模型的名称。 仅当选择了`ERGROUPING_ORIENTATION_ANY`时才需要此选项。 - * `minProbability`:检测到的接受组的最小概率。 仅当选择了`ERGROUPING_ORIENTATION_ANY`时才需要此选项。 - -代码还提供了对第二个方法的调用,但它被注释掉了。 您可以在两者之间切换以测试这一点。 只需注释上一个调用,并取消对此调用的注释: - -```cpp -erGrouping(input, channels, regions, - groups, groupRects, ERGROUPING_ORIENTATION_ANY, - "trained_classifier_erGrouping.xml", 0.5); -``` - -对于此调用,我们还使用了文本模块示例包中提供的默认训练分类器。 最后,我们绘制区域框并显示结果: - -```cpp -// draw groups boxes -for (const auto& rect : groupRects) - rectangle(input, rect, Scalar(0, 255, 0), 3); - -imshow("grouping",input); -waitKey(0); -``` - -此程序输出以下结果: - -![](img/b94bc651-4ae3-4e06-b8fe-63c6874196f3.png) - -您可以查看`detection.cpp`文件中的整个源代码。 - -While most OpenCV text module functions are written to support both grayscale and colored images as its input parameter, at the time of writing this book, there were bugs preventing us from using grayscale images in functions such as `erGrouping`. For more information, take a look at the following GitHub link: [https://github.com/Itseez/opencv_contrib/issues/309](https://github.com/Itseez/opencv_contrib/issues/309). [](https://github.com/Itseez/opencv_contrib/issues/309) Always remember that the OpenCV contrib modules package is not as stable as the default OpenCV packages. - -# 文本提取 - -既然我们已经检测到区域,我们必须在将文本提交给 OCR 之前对其进行裁剪。 我们可以简单地使用像`getRectSubpix`或`Mat::copy`这样的函数,将每个区域矩形用作感兴趣的**区域**(**ROI**),但是,由于字母倾斜,一些不需要的文本也可能被裁剪。 例如,如果我们仅根据给定的矩形提取 ROI,则其中一个区域的外观如下所示: - -![](img/a92c683a-aa4e-4de7-a86f-b97726fb8bbc.png) - -幸运的是,`ERFilter`为我们提供了一个名为`ERStat`的对象,它包含每个极端区域内的像素。 有了这些像素,我们就可以使用 OpenCV 的`floodFill`函数来重建每个字母。 此函数能够基于种子点绘制相似颜色的像素,就像大多数绘图应用的**bucket**工具一样。 函数签名如下所示: - -```cpp -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`:起点。 它类似于您想要使用图形应用的**Bucket**工具时单击的位置。 -* `newVal`:重新绘制的像素的新值。 -* `loDiff`和`upDiff`:这些参数表示正在处理的像素与其相邻像素之间的上下差异。 如果邻居落在这个范围内,它就会被画出来。 如果使用`FLOODFILL_FIXED_RANGE`标志,则将使用种子点和正在处理的像素之间的差值。 -* `rect`:这是一个可选参数,用于限制将应用泛洪填充的区域。 -* `flags`:该值由位掩码表示: - * 标志的最低有效 8 位包含连接值。 值`4`表示将使用所有四个边缘像素,值`8`表示还必须考虑对角线像素。 我们将使用`4`作为此参数。 - * 接下来的 8 到 16 位包含一个从`1`到`255`的值,用于填充掩码。 因为我们想用白色填充蒙版,所以我们将使用`255 << 8`作为此值。 - * 正如我们已经描述的,可以通过添加`FLOODFILL_FIXED_RANGE`和`FLOODFILL_MASK_ONLY`标志来设置另外两位。 - -我们将创建一个名为`drawER`的函数。 此函数将接收四个参数: - -* 具有所有已处理通道的矢量 -* `ERStat`区域 -* 必须抽签的组 -* 组矩形 - -此函数将返回包含由该组表示的单词的图像。 让我们通过创建遮罩图像并定义标志来开始此函数: - -```cpp -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 -``` - -然后,我们将遍历每组。 有必要找出地区指数及其地位。 这个极端的区域有可能是根,它不包含任何点。 在本例中,我们将忽略它: - -```cpp -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*)表示法,使用与我们在[第 2 章](02.html),*OpenCV*基础简介中看到的公式类似的公式: - -```cpp -int px = er.pixel % channels[idx].cols; -int py = er.pixel / channels[idx].cols; -Point p(px, py); -``` - -然后,我们可以调用`floodFill`函数。 `ERStat`对象为我们提供了要在`loDiff`参数中使用的值: - -```cpp -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 -``` - -在对组中的所有区域执行此操作后,我们将以一个比原始图像稍大的图像结束,该图像的背景为黑色,单词为白色字母。 现在,让我们只裁剪字母的区域。 由于给出了区域矩形,我们首先将其定义为感兴趣的区域: - -```cpp -out = out(rect); -``` - -然后,我们将找到所有非零像素。 这是我们将在`minAreaRect`函数中使用的值,以获得围绕字母旋转的矩形。 最后,我们将借用上一章的`deskewAndCrop`函数为我们裁剪和旋转图像: - -```cpp - vector points; - findNonZero(out, points); - //Use deskew and crop to crop it perfectly - return deskewAndCrop(out, minAreaRect(points)); -} -``` - -这是画架图像处理的结果: - -![](img/83704d1d-0019-456e-be74-3f1111722660.png) - -# 文本识别 - -在[第 10 章](10.html),*开发用于文本识别的分割算法中,*我们直接使用了 Tesseract API 来识别文本区域。 这一次,我们将使用 OpenCV 类来实现相同的目标。 - -在 OpenCV 中,所有特定于 OCR 的类都派生自**BaseOCR**虚拟类。 此类为 OCR 执行方法本身提供公共接口。 特定的实现必须从该类继承。 默认情况下,文本模块提供三种不同的实现:**OCRTesseract**、**OCRHMMDecoder**和**OCRBeamSearchDecoder**。 - -下面的类图描述了此层次结构: - -![](img/16cf8dd4-71e3-404f-89a5-43cdbbf4f38e.png) - -使用这种方法,我们可以将创建 OCR 机制的代码部分与执行本身分开。 这使得将来更容易更改 OCR 实现。 - -因此,让我们从创建一个方法开始,该方法决定我们将基于字符串使用哪个实现。 我们目前只支持 Tesseract,但您可以查看本章的代码,其中还提供了**HMMDecoder**的演示。 此外,我们接受字符串参数中的 OCR 引擎名称,但我们可以通过从外部 JSON 或 XML 配置文件中读取它来提高应用的灵活性: - -```cpp -cv::Ptr initOCR2(const string& ocr) { if (ocr == "tesseract") { return OCRTesseract::create(nullptr, "eng+por"); } throw string("Invalid OCR engine: ") + ocr; } -``` - -您可能已经注意到,该函数返回`Ptr`。 现在,看一下突出显示的代码。 它调用`create`方法来初始化 Tesseract OCR 实例。 我们来看看它的官方签名,因为它允许几个具体的参数: - -```cpp -Ptr 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`将加载英语和葡萄牙语。 当然,您只能使用以前安装的语言,否则加载将失败。 Language`config`文件可以指定必须一起加载两种或两种以上语言。 为了防止出现这种情况,您可以使用波浪号`~`。 例如,您可以使用`hin+~eng`来保证英语不会加载印地语,即使它被配置为这样做。 -* `whitelist`:这是设置为识别的字符。 在传递`nullptr`的情况下,字符将为`0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ`。 -* `oem`:这些是将使用的 OCR 算法。 它可以具有下列值之一: - * `OEM_TESSERACT_ONLY`:仅使用 Tesseract。 这是最快的方法,但精度也较低。 - * `OEM_CUBE_ONLY`:使用多维数据集引擎。 它更慢,但更精确。 只有当您的语言经过培训以支持此引擎模式时,这才会起作用。 要检查是否如此,请在`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 目录来使用常量名称,而不是直接插入它们的值。 最后一步是在我们的主函数中添加文本检测。 为此,只需将以下代码添加到 Main 方法的末尾: - -```cpp -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 引擎,剩余的代码将不会更改,因为 Run 方法签名由`BaseOCR`类保证。 接下来,我们迭代每个检测到的`ERFilter`组。 由于每组代表一个不同的单词,我们将执行以下操作: - -1. 调用前面创建的`drawER`函数来创建包含该单词的图像。 -2. 创建一个名为`word`的文本字符串,并调用`run`函数来识别单词 image。 识别的单词将存储在字符串中。 -3. 在屏幕上打印文本字符串。 - -让我们来看看`run`方法签名。 此方法在`BaseOCR`类中定义,对于所有特定的 OCR 实现都是相同的-即使是将来可能实现的实现: - -```cpp -virtual void run(Mat& image, std::string& output_text, - std::vector* component_rects=NULL, - std::vector* component_texts=NULL, - std::vector* component_confidences=NULL, int component_level=0) = 0; -``` - -当然,这是一个纯虚函数,必须由每个特定类实现(比如我们刚才使用的`OCRTesseract`类): - -* `image`:输入图像。 它必须是 RGB 或灰度图像。 -* `component_rects`:我们可以提供一个向量,用 OCR 引擎检测到的每个组件(单词或文本行)的边界框填充。 -* `component_texts`:如果给定,此向量将填充 OCR 检测到的每个组件的文本字符串。 -* `component_confidences`:如果给定,向量将用浮点数填充,每个分量的置信度值。 -* `component_level`:定义什么是组件。 它可以具有值`OCR_LEVEL_WORD`(默认情况下)或`OCR_LEVEL_TEXT_LINE`。 - -If necessary, you may prefer changing the component level to a word or line in the `run()` method instead of doing the same thing in the `psmode` parameter of the `create()` function. This is preferable since the `run` method will be supported by any OCR engine that decides to implement the `BaseOCR` class. Always remember that the `create()` method is where vendor-specific configurations are set. - -以下是程序的最终输出: - -![](img/e49daae1-4832-4ffb-94e6-152b2dbdcad6.png) - -尽管与`&`符号有一点混淆,但每个单词都被完全识别。 您可以在本章的代码文件中查看`ocr.cpp`文件中的整个源代码。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们看到场景文本识别比处理扫描文本要困难得多。 我们研究了文本模块如何使用*Newmann*和*Matas*算法进行极值区域识别。 我们还了解了如何通过`floodFill`函数使用此 API 将文本提取到图像中,并将其提交给 Tesseract OCR。 最后,我们学习了 OpenCV 文本模块如何与 Tesseract 和其他 OCR 引擎集成,以及如何使用它的类来识别图像中所写的内容。 - -在下一章中,我们将向您介绍 OpenCV 中的深度学习。 您将通过使用**只看一次**(**YOLO**)算法了解对象检测和分类。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/12.md b/trans/build-cv-proj-opencv4-cpp/12.md deleted file mode 100644 index 54deb1f9..00000000 --- a/trans/build-cv-proj-opencv4-cpp/12.md +++ /dev/null @@ -1,421 +0,0 @@ -# 使用 OpenCV 进行深度学习 - -深度学习是一种最先进的机器学习形式,在图像分类和语音识别中达到了最高的准确率。 深度学习也被用于其他领域,如机器人和具有强化学习的人工智能。 这就是 OpenCV 做出重大努力将深度学习纳入其核心的主要原因。 我们将学习 OpenCV 深度学习界面的基本用法,并了解如何在两个用例中使用它们:对象检测和人脸检测。 - -在本章中,我们将学习深度学习的基础知识,并了解如何在 OpenCV 中使用深度学习。 为了达到我们的目标,我们将使用**You Only Look Once(**(**YOLO**)算法学习目标检测和分类。 - -本章将介绍以下主题: - -* 什么是深度学习? -* OpenCV 如何使用深度学习和实施深度学习**神经网络**(**NN**s) -* YOLO 提出了一种非常快速的深度学习目标检测算法 -* 基于单镜头检测器的人脸检测 - -# 技术要求 - -要轻松阅读本章,需要安装 OpenCV 并编译深度学习模块。 如果没有此模块,您将无法编译和运行示例代码。 - -拥有支持 CUDA 的 NVIDIA GPU 非常有用。 您可以在 OpenCV 上启用 CUDA 以提高训练和检测的速度。 - -最后,您可以从[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter12](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter12)下载本章使用的代码。 - -请查看以下视频,了解实际操作中的代码: -[http://bit.ly/2SmbWf7](http://bit.ly/2SmbWf7) - -# 深度学习入门 - -深度学习是当今关于图像分类和语音识别的科学论文中最常见的内容。 这是机器学习的一个子领域,基于传统的神经网络,并受到大脑结构的启发。 要理解这项技术,了解神经网络是什么以及它是如何工作的是非常重要的。 - -# 什么是神经网络?我们如何从数据中学习? - -神经网络的灵感来自大脑的结构,在大脑中,多个神经元相互连接,形成一个网络。 每个神经元都有多个输入和多个输出,就像生物神经元一样。 - -这个网络是分层分布的,每一层都包含许多神经元,这些神经元与前一层的所有神经元相连。 它总是有一个输入层和一个输出层,输入层通常由描述输入图像或数据的要素组成,输出层通常由分类结果组成。 其他中间层称为**隐藏层**。 下图显示了一个基本的三层神经网络,其中输入层包含三个神经元,输出层包含两个神经元,一个隐藏层包含四个神经元: - -![](img/970372c7-bc89-4bdf-a984-f442e7124293.png) - -神经元是神经网络的基本元素,它使用一个简单的数学公式,如下图所示: - -![](img/1fafbc50-9aa9-4549-876e-424b009f43c9.png) - -正如我们所看到的,对于每个神经元**i**,我们数学地将前一个神经元的所有输出(即神经元**i**(**x1**,**x2**...)的输出按权重(**wi1**,**wi2**...)相加。)。 加上偏置值,结果是激活函数的自变量**f**。 最终结果是**i**神经元的输出: - -![](img/70c01f9b-85fd-45f1-ab1a-de9c13fa6a9b.png) - -经典神经网络中最常见的激活函数(**f**)是 Sigmoid 函数或线性函数。 最常用的是 Sigmoid 函数,如下所示: - -![](img/bee52a62-8b27-438c-9f7d-0c2251dd3d10.png) - -但是,我们怎样才能用这个公式和这些联系来学习神经网络呢? 我们如何对输入数据进行分类? 神经网络的学习算法可以称为**有监督**,如果我们知道期望的输出,那么在学习时,输入模式被提供给网络的输入层。 最初,我们将所有权重设置为随机数,然后将输入要素发送到网络中,检查输出结果。 如果这是错误的,我们必须调整网络的所有权重以获得正确的输出。 该算法称为**反向传播**。 如果你想更多地了解神经网络是如何学习的,请查看[http://neuralnetworksanddeeplearning.com/chap2.html](http://neuralnetworksanddeeplearning.com/chap2.html)和[https://youtu.be/IHZwWFHWa-w](https://youtu.be/IHZwWFHWa-w)。 - -现在我们已经简要介绍了什么是神经网络和神经网络的内部结构,我们将探讨神经网络和深度学习之间的区别。 - -# 卷积神经网络 - -深度学习神经网络与经典神经网络有着相同的背景。 然而,在图像分析的情况下,主要区别在于输入层。 在经典的机器学习算法中,研究人员必须确定定义要分类的图像目标的最佳特征。 例如,如果我们想对数字进行分类,我们可以提取每幅图像中数字的边框和线条,测量图像中对象的面积,所有这些特征都是神经网络或任何其他机器学习算法的输入。 然而,在深度学习中,您不必探究特征是什么;相反,您可以直接使用整个图像作为神经网络的输入。 深度学习可以学习最重要的特征是什么,**深度神经网络**(**DNN**)能够检测图像或输入并识别它。 - -为了了解这些特征是什么,我们使用深度学习和神经网络中最重要的层之一:**卷积层**。 卷积层的工作方式类似于卷积运算符,其中将核过滤器应用于前一层,从而提供新的过滤图像,类似于 Sobel 运算符: - -![](img/d89660dc-e1b3-44d8-aa68-94d70f6ca91f.png) - -然而,在卷积层中,我们可以定义不同的参数,其中之一是要应用于前一层或图像的滤镜数量和大小。 这些滤波器是在学习步骤中计算的,就像经典神经网络上的权重一样。 这就是深度学习的魔力:它可以从标记的图像中提取最重要的特征。 - -然而,这些卷积层是名称**Deep**背后的主要原因,我们将在下面的基本示例中了解原因。 假设我们有一幅 100x100 的图像。 在经典的神经网络中,我们将从输入图像中提取我们能想象到的最相关的特征。 这通常会有大约 1000 个特征,对于每个隐藏层,我们可以增加或减少这个数字,但用来计算其权重的神经元数量在正常的计算机中是合理的。 然而,在深度学习中,我们通常会开始应用卷积层--64 个 3x3 大小的滤波器核。 这将生成一个新的 100x100x64 神经元层,其权重为 3x3x64。 如果我们继续添加越来越多的层,这些数字会迅速增加,并且需要巨大的计算能力来学习我们的深度学习架构的良好权重和参数。 - -卷积层是深度学习体系结构最重要的方面之一,但也有其他重要层,如**池化**、**丢弃**、**扁平**和**Softmax**。 在下图中,我们可以看到一个基本的深度学习架构,其中堆叠了一些卷积和池层: - -![](img/9d79b034-5577-4291-8458-b130c55b57de.png) - -然而,还有一件非常重要的事情使深度学习获得最好的结果:标签数据量。 如果您的数据集很小,深度学习算法将无法帮助您进行分类,因为没有足够的数据来学习特征(深度学习体系结构的权重和参数)。 但是,如果你有海量的数据,你会得到非常好的结果。 但是要小心,您将需要大量的时间来计算和学习您的体系结构的权重和参数。 这就是为什么在这个过程的早期没有使用深度学习,因为计算需要大量的时间。 然而,多亏了新的并行架构,如 NVIDIA GPU,我们可以优化学习反向传播并加快学习任务。 - -# OpenCV 中的深度学习 - -深度学习模块作为贡献模块引入到版本 3.1 的 OpenCV 中。 这在 3.3 版本中被转移到 OpenCV 的一部分,但直到 3.4.3 和 4 版本才被开发人员广泛采用。 - -OpenCV 实现的深度学习只用于推理,这意味着您不能创建自己的深度学习架构,也不能在 OpenCV 中进行训练;您只能导入预先训练好的模型,在 OpenCV 库下执行,并将其作为**前馈**(推理)来获得结果。 - -实现前馈算法的最重要原因是对 OpenCV 进行优化,以加快推理的计算时间和性能。 不实现后向方法的另一个原因是为了避免浪费时间开发其他库(如 TensorFlow 或 Caffe)专门从事的东西。 OpenCV 随后为最重要的深度学习库和框架创建了导入器,使导入预先训练好的模型成为可能。 - -然后,如果要创建新的深度学习模型以在 OpenCV 中使用,首先必须使用 TensorFlow、Caffe、Torch 或 Dark Net 框架或可用于以**开放式神经网络交换**(**ONX**)格式导出模型的框架来创建和训练该模型。 使用此框架创建模型可能很简单,也可能很复杂,具体取决于您使用的框架,但本质上您必须堆叠多个层,就像我们在上一个图表中所做的那样,设置 DNN 所需的参数和功能。 现在有其他工具可以帮助您在不编码的情况下创建模型,例如[https://www.tensoreditor.com](https://www.tensoreditor.com)或[Lobe.ai](https://lobe.ai/)。 TensorEditor 允许您下载从可视化设计架构生成的 TensorFlow 代码,以便在您的计算机或云中进行训练。 在下面的屏幕截图中,我们可以看到 TensorEditor: - -![](img/fa446cae-aeda-42e5-b5ea-6db83f3208c7.png) - -对模型进行训练并对结果满意后,可以将其直接导入到 OpenCV 以预测新的输入图像。 在下一节中,您将了解如何在 OpenCV 中导入和使用深度学习模型。 - -# YOLO-实时目标检测 - -为了学习如何在 OpenCV 中使用深度学习,我们将给出一个基于 YOLO 算法的目标检测和分类的例子。 这是最快的物体检测和识别算法之一,在 NVIDIA Titan X 上运行速度约为 30fps。 - -# YOLO v3 深度学习模型架构 - -经典计算机视觉中常见的目标检测使用滑动窗口来检测目标,以不同的窗口大小和比例扫描整个图像。 这里的主要问题是多次扫描图像以查找对象会耗费大量时间。 - -YOLO 使用了一种不同的方法,将图表划分为 S x S 网格。 对于每个网格,YOLO 检查 B 个边界框,然后深度学习模型提取每个面片的边界框、包含可能对象的置信度以及每个框的训练数据集中每个类别的置信度。 以下屏幕截图显示了 S x S 网格: - -![](img/154a283e-8df8-42a3-b3c7-b6a906aef1c6.png) - -YOLO 使用包含 19 个和 5 个边界框的网格进行训练,每个网格使用 80 个类别。 然后,输出结果为 19x19x425,其中 425 来自边界框(x,y,宽,高)、对象置信度和 80 个类别的数据,置信度乘以每个网格的框数; *5_ 边界框**(*x*,*y*,*w*,*h*,*对象*_*置信度*, *分类*_*置信度*[*80*])=*5**(*4*+*1*+*80*): - -![](img/6c45b4c2-82a8-4024-a2d7-ffb277e8913e.png) - -YOLO v3 架构基于暗网,它包含 53 层网络,YOLO 又增加了 53 层,总共有 106 层网络。 如果你想要一个更快的架构,你可以选择版本 2 或 TinyYOLO 版本,它们使用的层更少。 - -# YOLO 数据集、词汇表和模型 - -在我们开始将模型导入到我们的 OpenCV 代码之前,我们必须通过 yolo 网站获得它:[https://pjreddie.com/darknet/yolo/](https://pjreddie.com/darknet/yolo/)。 这提供了基于**COCO**数据集的预先训练的模型文件,该数据集包含 80 个对象类别,例如人、伞、自行车、摩托车、汽车、苹果、香蕉、计算机和椅子。 - -要获取用于可视化的所有类别和用途的名称,请查看[https://github.com/pjreddie/darknet/blob/master/data/coco.names?raw=true](https://github.com/pjreddie/darknet/blob/master/data/coco.names?raw=true)。 - -这些名称的顺序与深度学习模型置信度的结果相同。 如果您想按类别查看 COCO 数据集的一些图像,可以在[http://cocodataset.org/#explore](http://cocodataset.org/#explore)浏览该数据集,并下载其中一些图像来测试我们的示例应用。 - -要获取模型配置和预先训练的权重,您必须下载以下文件: - -* [https://pjreddie.com/media/files/yolov3.weights](https://pjreddie.com/media/files/yolov3.weights) -* [https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg?raw=true](https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg?raw=true) - -现在,我们已经准备好开始将模型导入到 OpenCV 中。 - -# 将 YOLO 导入到 OpenCV - -深度学习 OpenCV 模块位于`opencv2/dnn.hpp`标头下,我们必须将其包括在我们的源标头和`cv::dnn namespace`中。 - -则 OpenCV 的标题必须如下所示: - -```cpp -... -#include -#include -#include -#include -using namespace cv; -using namespace dnn; -... -``` - -我们要做的第一件事是导入 Coco 名称的词汇表,该词汇表位于`coco.names`文件中。 该文件是一个纯文本文件,每行包含一个类类别,其排序方式与置信度结果相同。 然后,我们将读取该文件的每一行,并将其存储在称为类的字符串向量中: - -```cpp -... - 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,它们都有相似的语法。 在我们的示例中,我们将使用权重导入暗网模型,并使用`readNetFromDarknet`OpenCV 函数导入模型: - -```cpp -... - // 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`函数读取图像,并将其转换为可以读取**DotNetNuke**(**DNN**)的张量/BLOB 数据。 要从图像创建斑点,我们将通过传递图像来使用`blobFromImage`函数。 此函数接受以下参数: - -* **image**:输入图像(具有 1、3 或 4 个通道)。 -* **BLOB**:输出`mat`。 -* **scalefactor**:图像值的乘数。 -* **SIZE**:DNN 的输入所需的输出 BLOB 的空间大小。 -* **Mean**:从通道减去平均值的标量。 如果图像具有 BGR 排序且`swapRB`为真,则值应按(Mean-R、Mean-G 和 Mean-B)顺序排列。 -* **swapRB**:3 通道图像中指示交换第一个通道和最后一个通道的标志是必需的。 -* **裁剪**:指示调整大小后是否裁剪图像的标志。 - -您可以在以下代码片段中阅读有关如何读取图像并将其转换为 BLOB 的完整代码: - -```cpp -... -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); -... - -``` - -最后,我们必须将 BLOB 提供给 Deep Net,并使用`forward`函数调用推理,该函数需要两个参数:OUT`mat`结果和输出需要检索的层的名称: - -```cpp -... -//Sets the input to the network -net.setInput(blob); - -// Runs the forward pass to get output of the output layers -vector outs; -net.forward(outs, getOutputsNames(net)); -// Remove the bounding boxes with low confidence -postprocess(input, outs); -... -``` - -在`mat`输出向量中,我们有神经网络检测到的所有边界框,我们必须对输出进行后处理,以仅获得置信度大于阈值(通常为 0.5)的结果,最后应用非最大值抑制来消除多余的重叠框。 您可以在 GitHub 上获得完整的后处理代码。 - -我们示例的最终结果是深度学习中的多目标检测和分类,它显示了一个类似于以下内容的窗口: - -![](img/433ee50b-fbf0-4916-a3c5-d2fefe6e7169.jpg) - -现在我们来学习另一个为人脸检测定制的常用目标检测函数。 - -# 基于 SSD 的人脸检测 - -**单镜头检测**(**SSD**)是另一种快速、准确的深度学习目标检测方法,其概念类似于 YOLO,在同一架构中预测目标和边界框。 - -# 固态硬盘模型架构 - -SSD 算法被称为单镜头算法,因为它在处理同一深度学习模型中的图像时同时预测边界框和类别。 基本上,架构概括为以下几个步骤: - -1. 一幅 300x300 的图像被输入到该架构中。 -2. 输入图像通过多个卷积层,在不同尺度上获得不同的特征。 -3. 对于在 2 中获得的每个特征地图,我们使用 3x3 卷积过滤器来评估一小部分默认边界框。 -4. 对于评估的每个默认框,预测边界框偏移量和类别概率。 - -模型体系结构如下所示: - -![](img/7c1906b5-e110-4994-af6a-8c41591a9585.png) - -SSD 用于预测多个类别,类似于 YOLO 中的预测,但它可以修改为检测单个对象,更改最后一层,只对一个类别进行训练-这就是我们在示例中使用的人脸检测的重新训练模型,其中只预测一个类别。 - -# 将固态硬盘人脸检测导入 OpenCV - -要在我们的代码中使用深度学习,我们必须导入相应的标头: - -```cpp -#include -#include -#include -``` - -之后,我们将导入所需的命名空间: - -```cpp -using namespace cv; -using namespace std; -using namespace cv::dnn; -``` - -现在,我们将定义将在代码中使用的输入图像大小和常量: - -```cpp -const size_t inWidth = 300; -const size_t inHeight = 300; -const double inScaleFactor = 1.0; -const Scalar meanVal(104.0, 177.0, 123.0); -``` - -在本例中,如果我们要处理摄像机或视频输入,我们需要一些参数作为输入,例如模型配置和预先训练的模型。 我们还需要最低的置信度才能接受预测是正确的还是错误的: - -```cpp -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`函数解析参数: - -```cpp -int main(int argc, char** argv) -{ - CommandLineParser parser(argc, argv, params); - - if (parser.get("help")) - { - cout << about << endl; - parser.printMessage(); - return 0; - } -``` - -我们还将加载模型架构和预先训练的模型文件,并将模型加载到深度学习网络中: - -```cpp - String modelConfiguration = parser.get("proto"); - String modelBinary = parser.get("model"); - - //! [Initialize network] - dnn::Net net = readNetFromCaffe(modelConfiguration, modelBinary); - //! [Initialize network] -``` - -检查我们是否正确导入了网络,这一点非常重要。 我们还必须使用`empty`功能检查模型是否已导入,如下所示: - -```cpp -if (net.empty()) - { - cerr << "Can't load network by using the following files" << endl; - exit(-1); - } -``` - -加载网络后,我们将初始化输入源、摄像机或视频文件,并加载到`VideoCapture`中,如下所示: - -```cpp - VideoCapture cap; - if (parser.get("video").empty()) - { - int cameraDevice = parser.get("camera_device"); - cap = VideoCapture(cameraDevice); - if(!cap.isOpened()) - { - cout << "Couldn't find camera: " << cameraDevice << endl; - return -1; - } - } - else - { - cap.open(parser.get("video")); - if(!cap.isOpened()) - { - cout << "Couldn't open image or video: " << parser.get("video") << endl; - return -1; - } - } -``` - -现在,我们准备开始捕捉帧,并将每个帧处理到深度神经网络中,以找到人脸。 - -首先,我们必须捕获循环中的每一帧: - -```cpp -for(;;) - { - Mat frame; - cap >> frame; // get a new frame from camera/video or read image - - if (frame.empty()) - { - waitKey(); - break; - } -``` - -接下来,我们将把输入帧放入可以管理深度神经网络的`Mat`斑点结构中。 我们必须发送 SSD 大小合适的图像,即 300 x 300(我们已经初始化了`inWidth`和`inHeight`常量变量),并从输入图像中减去平均值,这是使用定义的`meanVal`常量变量在 SSD 中所需的: - -```cpp -Mat inputBlob = blobFromImage(frame, inScaleFactor, Size(inWidth, inHeight), meanVal, false, false); -``` - -现在我们可以将数据设置到网络中,并分别使用`net.setInput`和`net.forward`函数获得预测/检测。 这会将检测结果转换为我们可以读取的检测`mat`,其中`detection.size[2]`是检测到的对象的数量,`detection.size[3]`是每次检测的结果数量(边界框数据和置信度): - -```cpp - 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()); - -``` - -`Mat`检测每行包含以下内容: - -* **列 0**:物体存在的置信度 - -* **第 1 列**:包围盒的置信度 - -* **列 2**:检测到的人脸置信度 - -* **列 3**:X 左下边界框 - -* **列 4**:Y 左下边界框 - -* **列 5**:X 个右上边框 - -* **列 6**:Y 右上边界框 - -边界框相对于图像大小(0 比 1)。 - -现在,我们必须应用该阈值,以根据定义的输入阈值仅获得所需的检测: - -```cpp -float confidenceThreshold = parser.get("min_confidence"); - for(int i = 0; i < detectionMat.rows; i++) - { - float confidence = detectionMat.at(i, 2); - - if(confidence > confidenceThreshold) - { -``` - -现在,我们将提取边界框,在每个检测到的面上绘制一个矩形,并如下所示: - -```cpp - int xLeftBottom = static_cast(detectionMat.at(i, 3) * frame.cols); - int yLeftBottom = static_cast(detectionMat.at(i, 4) * frame.rows); - int xRightTop = static_cast(detectionMat.at(i, 5) * frame.cols); - int yRightTop = static_cast(detectionMat.at(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; -} -``` - -最终结果如下所示: - -![](img/a71a738c-e214-4dc9-8e95-b4bb5828ffa4.png) - -在本节中,您学习了一种新的深度学习架构 SSD,以及如何使用它进行人脸检测。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了什么是深度学习,以及如何在 OpenCV 上使用深度学习进行对象检测和分类。 本章是为任何目的使用其他模型和深度神经网络的基础。 - -到目前为止,我们学习了如何获取和编译 OpenCV,如何使用基本图像和`mat`操作,以及如何创建自己的图形用户界面。 您使用了基本过滤器,并在工业检查示例中应用了所有这些过滤器。 我们了解了如何使用 OpenCV 进行人脸检测,以及如何操作它来添加面具。 最后,我们向您介绍了非常复杂的对象跟踪、文本分割和识别用例。 现在,您可以在 OpenCV 中创建自己的应用了,这要归功于这些用例,这些用例向您展示了如何应用每种技术或算法。 在下一章中,我们将学习如何为台式机和小型嵌入式系统(如 Raspberry Pi)编写一些图像处理过滤器。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/13.md b/trans/build-cv-proj-opencv4-cpp/13.md deleted file mode 100644 index d21cd9a4..00000000 --- a/trans/build-cv-proj-opencv4-cpp/13.md +++ /dev/null @@ -1,964 +0,0 @@ -# 树莓猪的卡通剂和肤色分析 - -本章将介绍如何为台式机和小型嵌入式系统(如 Raspberry PI)编写一些图像处理过滤器。 首先,我们为桌面(使用 C/C++)进行开发,然后将项目移植到 Raspberry PI,因为这是为嵌入式设备开发时推荐的场景。 本章将介绍以下主题: - -* 如何将真实图像转换为草图 -* 如何转换成一幅画并叠加素描来制作卡通画 -* 一种可怕的邪恶模式,创造坏角色而不是好角色 -* 一个基本的皮肤探测仪和皮肤变色器,可以让人拥有绿色的外星人皮肤 -* 最后,如何创建一个基于桌面应用的嵌入式系统 - -请注意,**嵌入式系统**基本上是放置在产品或设备内部的计算机主板,旨在执行特定任务,而**Raspberry Pi**则是一款非常低成本且受欢迎的用于构建嵌入式系统的主板: - -![](img/454c05d7-73a1-4d38-9fb3-8c8644ed772e.png) - -上面的图片展示了你在这一章之后可以做什么:一个电池供电的树莓 Pi plus 屏幕,你可以戴着它去参加动漫展,把每个人都变成卡通人物! - -我们希望自动使真实世界的摄影机帧看起来像是卡通中的。 基本的想法是在平坦的部分填上一些颜色,然后在坚固的边缘画出粗线条。 换句话说,平坦的区域应该变得更加平坦,边缘应该变得更加清晰。 我们将检测边缘,平滑平坦的区域,并在顶部绘制增强的边缘,以产生卡通或漫画效果。 - -在开发嵌入式计算机视觉系统时,最好先构建一个完全工作的桌面版本,然后再将其移植到嵌入式系统上,因为桌面程序的开发和调试比嵌入式系统容易得多! 因此,本章将从一个完整的 Cartoonizer 桌面程序开始,您可以使用您喜欢的 IDE(例如,Visual Studio、XCode、Eclipse 或 QtCreator)创建它。 当它在您的桌面上正常工作后,最后一节将展示如何基于桌面版本创建嵌入式系统。 许多嵌入式项目需要嵌入式系统的一些自定义代码,例如使用不同的输入和输出,或者使用一些特定于平台的代码优化。 然而,在本章中,我们实际上将在嵌入式系统和桌面上运行相同的代码,因此我们只需要创建一个项目。 - -该应用使用**OpenCV**的 GUI 窗口,初始化相机,并且对于每个相机帧,它调用包含本章中的大部分代码的`cartoonifyImage()`函数。 然后,它会在 GUI 窗口中显示处理后的图像。 本章将解释如何使用 USB 网络摄像头和基于桌面应用的嵌入式系统,使用 Raspberry PI Camera 模块从头开始创建桌面应用。 因此,首先您将在您喜欢的 IDE 中创建一个 Desktop 项目,其中包含一个`main.cpp`文件来保存以下部分中给出的 GUI 代码,例如主循环、网络摄像头功能和键盘输入,并且您将使用图像处理操作创建一个包含本章大部分代码的`cartoon.cpp`*和*文件,其中的大部分代码位于一个名为`cartoonifyImage()`的函数中。 - -# 访问网络摄像头 - -要访问计算机的网络摄像头或摄像头设备,只需在`cv::VideoCapture`个对象(OpenCV 访问摄像头设备的方法)上调用`open()`函数,并将`0`作为默认摄像头 ID 号传递即可。 有些计算机连接了多台摄像机,或者它们不能使用默认摄像机`0`,因此通常做法是允许用户将所需的摄像机编号作为命令行参数传递,例如,如果他们想尝试摄像机`1`、`2`或`-1`。 我们还将尝试使用`cv::VideoCapture::set()`功能将相机分辨率设置为 640 x 480,以便在高分辨率相机上运行得更快。 - -Depending on your camera model, driver, or system, OpenCV might not change the properties of your camera. It is not important for this project, so don't worry if it does not work with your webcam. - -您也可以将此代码放入您的文件`main.cpp`的`main()`*和*函数中: - -```cpp -auto cameraNumber = 0; -if (argc> 1) -cameraNumber = atoi(argv[1]); - -// Get access to the camera. -cv::VideoCapture camera; -camera.open(cameraNumber); -if (!camera.isOpened()) { - std::cerr<<"ERROR: Could not access the camera or video!"<< std::endl; - exit(1); -} - -// Try to set the camera resolution. -camera.set(cv::CV_CAP_PROP_FRAME_WIDTH, 640); -camera.set(cv::CV_CAP_PROP_FRAME_HEIGHT, 480); -``` - -摄像头初始化完成后,可以将当前摄像头图像抓取为 OPENCV`cv::Mat`对象(OpenCV 的图像容器)。 您可以使用 C++ 流运算符从`cv::VideoCapture`对象中的`cv::Mat`对象抓取每个摄影机帧,就像从控制台获取输入一样。 - -OpenCV makes it very easy to capture frames from a video file (such as an AVI or MP4 file) or network stream instead of a webcam. Instead of passing an integer such as` camera.open(0)`, pass a string such as `camera.open("my_video.avi")` and then grab frames just like it was a webcam. The source code provided with this book has an `initCamera()` function that opens a webcam, video file, or network stream. - -# 桌面应用的主摄像头处理循环 - -如果您希望使用 OpenCV 在屏幕上显示 GUI 窗口,您可以调用*、*、`cv::namedWindow()`函数,然后调用每个图像的`cv::imshow()`函数,但您还必须每帧调用一次`cv::waitKey()`*、*,否则您的窗口根本不会更新! 调用*和*`cv::waitKey(0)`将永远等待,直到用户点击窗口中的某个键,但像`waitKey(20)`或更高的正数将至少等待那么多毫秒。 - -将此主循环放入`main.cpp`文件中,作为您的实时相机应用的基础: - -```cpp -while (true) { - // Grab the next camera frame. - cv::Mat cameraFrame; - camera >> cameraFrame; - if (cameraFrame.empty()) { - std::cerr<<"ERROR: Couldn't grab a camera frame."<< - std::endl; - exit(1); - } - // Create a blank output image, that we will draw onto. - cv::Mat displayedFrame(cameraFrame.size(), cv::CV_8UC3); - - // Run the cartoonifier filter on the camera frame. - cartoonifyImage(cameraFrame, displayedFrame); - - // Display the processed image onto the screen. - imshow("Cartoonifier", displayedFrame); - - // IMPORTANT: Wait for atleast 20 milliseconds, - // so that the image can be displayed on the screen! - // Also checks if a key was pressed in the GUI window. - // Note that it should be a "char" to support Linux. - auto keypress = cv::waitKey(20); // Needed to see anything! - if (keypress == 27) { // Escape Key - // Quit the program! - break; - } - }//end while -``` - -# 生成黑白草图 - -为了获得相机帧的草图(黑白素描),我们将使用边缘检测滤镜,而要获得彩色绘画,我们将使用边缘保持滤镜(双边滤镜)来进一步平滑平坦区域,同时保持边缘完整。 通过将素描叠加在彩画上,我们获得了卡通效果,如前面最终应用的截图所示。 - -有许多不同的边缘检测滤波器,例如 Sobel、Scharr 和 Laplacian 滤波器,或者 Canny 边缘检测器。 我们将使用拉普拉斯边缘过滤器,因为与 Sobel 或 Scharr 相比,它生成的边缘看起来最像手绘草图,并且与 Canny 边缘检测器相比非常一致,后者生成非常干净的线条图,但更多地受到相机帧中随机噪声的影响,因此线条图会在帧之间经常发生剧烈变化。 - -然而,在使用拉普拉斯边缘滤波器之前,我们仍然需要降低图像中的噪声。 我们将使用中值滤波器,因为它能很好地去除噪声,同时保持边缘的锐化,但不像双边滤波器那样慢。 由于拉普拉斯滤镜使用灰度图像,我们必须将 OpenCV 的默认 BGR 格式转换为灰度图像。 在空的`cartoon.cpp`*和*文件中,将此代码放在顶部,这样您就可以访问 OpenCV 和 STD C++ 模板,而无需在任何地方键入`cv::`和`std::`: - -```cpp -// Include OpenCV's C++ Interface - #include - - using namespace cv; - using namespace std; -``` - -将此代码和所有剩余代码放在您的文件`cartoon.cpp`中的一个`cartoonifyImage()`函数中: - -```cpp -Mat gray; - cvtColor(srcColor, gray, CV_BGR2GRAY); - const int MEDIAN_BLUR_FILTER_SIZE = 7; - medianBlur(gray, gray, MEDIAN_BLUR_FILTER_SIZE); - Mat edges; - const int LAPLACIAN_FILTER_SIZE = 5; - Laplacian(gray, edges, CV_8U, LAPLACIAN_FILTER_SIZE); -``` - -拉普拉斯滤镜生成亮度不同的边缘,因此为了使边缘看起来更像草图,我们应用二进制阈值使边缘变为白色或黑色: - -```cpp -Mat mask; - const int EDGES_THRESHOLD = 80; - threshold(edges, mask, EDGES_THRESHOLD, 255, THRESH_BINARY_INV); -``` - -在下图中,您可以看到最原始的图像(左侧)和生成的边缘蒙版(右侧),它们看起来类似于草图。 在生成彩色绘画(稍后解释)之后,我们还将此边缘蒙版放在顶部以绘制黑色线条图: - -![](img/e4a993f9-70db-481d-9894-aca67e3d9b08.png) - -# 生成一幅彩画和一幅卡通 - -一个强大的双边滤镜可以平滑平坦的区域,同时保持边缘的锐利,因此作为自动漫画器或绘画滤镜是很棒的,除了它非常慢(即,以秒甚至分钟衡量,而不是毫秒!)。 因此,我们将使用一些技巧来获得一个好的卡通器,同时仍然以可以接受的速度运行。 我们可以使用的最重要的技巧是,我们可以在更低的分辨率下执行双边过滤,它仍然具有与全分辨率类似的效果,但运行速度要快得多。 让我们将总像素数减少四个(例如,半宽半高): - -```cpp -Size size = srcColor.size(); -Size smallSize; -smallSize.width = size.width/2; -smallSize.height = size.height/2; -Mat smallImg = Mat(smallSize, CV_8UC3); -resize(srcColor, smallImg, smallSize, 0,0, INTER_LINEAR); -``` - -我们不会使用大的双边滤镜,而是应用许多小的双边滤镜,在更短的时间内产生强大的卡通效果。 我们将截断滤镜(参见下图),以便它不执行整个滤镜(例如,当钟形曲线宽为 21 像素时,滤镜大小为 21 x 21),而只使用获得令人信服的结果所需的最小滤镜大小(例如,即使钟形曲线宽为 21 像素,滤镜大小也仅为 9 x 9)。 此截断滤镜将应用滤镜的主要部分(灰色区域),而不会在滤镜的次要部分(曲线下的白色区域)上浪费时间,因此它的运行速度会快几倍: - -![](img/6bdd8462-cd9d-4073-a0d4-c3d656e1c7f9.png) - -因此,我们有四个主要参数来控制双边滤镜:颜色强度、位置强度、大小和重复计数。 我们需要一个 Temp`Mat`,因为`bilateralFilter()`函数不能覆盖其输入(称为**就地处理**),但我们可以应用一个存储 Temp`Mat`的筛选器和另一个存储回输入的筛选器: - -```cpp -Mat tmp = Mat(smallSize, CV_8UC3); -auto repetitions = 7; // Repetitions for strong cartoon effect. -for (auto i=0; i90%*,因为这两个色调都是红色。 因此,我们将改用**Y‘CrCb**颜色空间(OpenCV 中 YUV 的变体),因为它将亮度与颜色分开,并且只有一个典型肤色的值范围,而不是两个。 请注意,在转换为 RGB 之前,大多数相机、图像和视频实际上使用某种类型的 YUV 作为其色彩空间,因此在许多情况下,您可以免费获得 YUV 图像,而无需自己转换它。 - -由于我们希望我们的外星人模式看起来像卡通,我们将在图像已经卡通化后应用最新的外星人滤镜。 换句话说,我们可以访问由双边滤波器产生的缩小的彩色图像,并且可以访问全尺寸的边缘蒙版。 皮肤检测通常在低分辨率下工作得更好,因为它等同于分析每个高分辨率像素的邻居的平均值(或者是低频信号而不是高频噪声信号)。 因此,让我们使用与双边滤镜相同的缩小比例(半宽半高)。 让我们将绘画图像转换为 YUV: - -```cpp -Mat yuv = Mat(smallSize, CV_8UC3); - cvtColor(smallImg, yuv, CV_BGR2YCrCb); -``` - -我们还需要缩小边缘蒙版,使其与绘画图像的比例相同。 OpenCV 的`floodFill()`*和*函数在存储到单独的蒙版图像时有一个复杂之处,即蒙版应该在整个图像周围有一个像素的边界,因此如果输入图像的大小是*W x H*像素,那么单独的蒙版图像的大小应该是*(W+2)x(H+2)*像素。 但 First`floodFill()`函数还允许我们使用泛洪填充算法将确保其不会交叉的边来初始化遮罩。 让我们使用这个功能,希望它能帮助防止泛滥的填充物延伸到脸部之外。 因此,我们需要提供两张蒙版图片:一张是大小为*W x H*的边缘蒙版,另一张是与*(W+2)x(H+2)*大小完全相同的边缘蒙版,因为它应该在图像周围包含一个边框。 可以有多个`cv::Mat`*和*对象(或标题)引用相同的数据,甚至可以有一个引用另一个`cv::Mat`图像的子区域的`cv::Mat`对象。 因此,与其分配两个单独的图像并复制边缘遮罩像素,不如分配一个包括边框的遮罩图像,并额外创建一个标题为*W x H*的额外的`cv::Mat`*和*头(它只引用没有边框的泛洪填充遮罩中的感兴趣区域)。 换句话说,只有一个大小为*(W+2)x(H+2)*的像素数组,但有两个`cv::Mat`*和*对象,其中一个引用整个图像*(W+2)x(H+2)*,另一个引用该图像中间的区域*W x H*: - -```cpp -auto sw = smallSize.width; -auto sh = smallSize.height; -Mat mask, maskPlusBorder; -maskPlusBorder = Mat::zeros(sh+2, sw+2, CV_8UC1); -mask = maskPlusBorder(Rect(1,1,sw,sh)); -// mask is now in maskPlusBorder. -resize(edges, mask, smallSize); // Put edges in both of them. -``` - -边缘遮罩(如下图左侧所示)充满了强边缘和弱边缘,但我们只想要强边缘,因此我们将应用二进制阈值(生成下图中的中间图像)。 要连接边缘之间的一些间隙,我们将结合形态运算符`dilate()`和`erode()`来删除一些间隙(也称为 Close 运算符),从而产生右侧的图像: - -```cpp -const int EDGES_THRESHOLD = 80; - threshold(mask, mask, EDGES_THRESHOLD, 255, THRESH_BINARY); - dilate(mask, mask, Mat()); - erode(mask, mask, Mat()); -``` - -我们可以在下图中看到应用阈值和形态学运算的结果,第一幅图像是输入边缘图,第二幅是阈值滤波器,最后一幅是膨胀和侵蚀形态滤波器: - -![](img/1532d85a-c734-43d7-80c4-78c779ae68d2.png) - -正如前面提到的,我们希望在脸部周围的许多点上应用泛洪填充,以确保包括整个脸部的各种颜色和阴影。 让我们选择鼻子、脸颊和前额周围的六个点,如下面屏幕截图的左侧所示。 请注意,这些值取决于之前绘制的面部轮廓: - -```cpp -auto const NUM_SKIN_POINTS = 6; -Point skinPts[NUM_SKIN_POINTS]; -skinPts[0] = Point(sw/2, sh/2 - sh/6); -skinPts[1] = Point(sw/2 - sw/11, sh/2 - sh/6); -skinPts[2] = Point(sw/2 + sw/11, sh/2 - sh/6); -skinPts[3] = Point(sw/2, sh/2 + sh/16); -skinPts[4] = Point(sw/2 - sw/9, sh/2 + sh/16); -skinPts[5] = Point(sw/2 + sw/9, sh/2 + sh/16); -``` - -现在,我们只需要为洪水填充物找到一些合适的下限和上界。 请记住,这是在 Y‘CrCb 的颜色空间中执行的,因此我们基本上决定了亮度可以变化多少,红色分量可以变化多少,蓝色分量可以变化多少。 我们希望允许亮度变化很大,包括阴影以及高光和反射,但我们根本不希望颜色变化太大: - -```cpp -const int LOWER_Y = 60; - const int UPPER_Y = 80; - const int LOWER_Cr = 25; - const int UPPER_Cr = 15; - const int LOWER_Cb = 20; - const int UPPER_Cb = 15; - Scalar lowerDiff = Scalar(LOWER_Y, LOWER_Cr, LOWER_Cb); - Scalar upperDiff = Scalar(UPPER_Y, UPPER_Cr, UPPER_Cb); -``` - -除了要存储到外部掩码之外,我们将使用带有默认标志的`floodFill()`*和*函数,因此我们必须指定`FLOODFILL_MASK_ONLY`: - -```cpp -const int CONNECTED_COMPONENTS = 4; // To fill diagonally, use 8. -const int flags = CONNECTED_COMPONENTS | FLOODFILL_FIXED_RANGE -| FLOODFILL_MASK_ONLY; -Mat edgeMask = mask.clone(); // Keep a copy of the edge mask. -// "maskPlusBorder" is initialized with edges to block floodFill(). -for (int i = 0; i < NUM_SKIN_POINTS; i++) { - floodFill(yuv, maskPlusBorder, skinPts[i], Scalar(), NULL, - lowerDiff, upperDiff, flags); -} -``` - -左手边的下图显示了六个泛洪填充位置(显示为圆圈),右手边显示的是生成的外部蒙版,其中皮肤显示为灰色,边缘显示为白色。 请注意,本书的右侧图像已修改,以便皮肤像素(值为`1`)清晰可见: - -![](img/8738fc6c-aeb5-4587-bd2b-63f31e9a6d8c.png) - -下面的`mask`图像(显示在上一图像的右侧)现在包含以下内容: - -* 边缘像素的值为 255 的像素 -* 皮肤区域的值为 1 的像素 -* 其余像素的值为 0 - -同时,`edgeMask`仅包含边缘像素(值 255)。 所以要只得到皮肤像素,我们可以去掉它的边缘: - -```cpp -mask -= edgeMask; -``` - -变量`mask`现在只包含皮肤像素的 1 和非皮肤像素的 0。 要更改原始图像的肤色和亮度,我们可以使用带有蒙版的`cv::add()`函数来增加原始 BGR 图像中的绿色分量: - -```cpp -auto Red = 0; -auto Green = 70; -auto Blue = 0; -add(smallImgBGR, CV_RGB(Red, Green, Blue), smallImgBGR, mask); -``` - -下图左边是原始图像,右边是最终的外星人卡通形象,现在脸部至少有六个部分将是绿色的! - -![](img/d36e7a55-bc6e-4250-ba06-e51097e2edee.png) - -请注意,我们已经使皮肤看起来是绿色的,但也更亮了(看起来像在黑暗中发光的外星人)。 如果只想更改肤色而不使其更亮,可以使用其他颜色更改方法,例如将`70`添加到绿色,同时从红色和蓝色减去`70`,或者使用`cvtColor(src, dst, "CV_BGR2HSV_FULL")`转换为 HSV 颜色空间并调整色调和饱和度。 - -# 降低草图图像中的随机胡椒噪声 - -智能手机中的大多数微型摄像头、树莓 PI 摄像头模块和一些网络摄像头都有明显的图像噪音。 这通常是可以接受的,但它对我们的 5x5 拉普拉斯边缘滤波器有很大的影响。 边缘蒙版(显示为草图模式)通常会有数千个称为胡椒噪波的黑色像素小斑点,由白色背景上彼此相邻的几个黑色像素组成。 我们已经在使用中值滤波器,通常它的强度足以去除胡椒噪声,但在我们的情况下,它可能还不够强。 我们的边缘蒙版主要是纯白色背景(值 255),带有一些黑色边缘(值 0)和噪声点(值也是 0)。 我们可以使用标准的闭合形态运算符,但它会去除很多边缘。 因此,我们将应用自定义滤镜来移除完全被白色像素包围的小黑色区域。 这将消除大量噪波,而对实际边缘影响不大。 - -我们将扫描图像中的黑色像素,并且在每个黑色像素处,我们将检查其周围 5x5 正方形的边界,以查看是否所有 5x5 边界像素都是白色的。 如果它们都是白色的,那么我们知道我们有一个黑色噪声的小岛,所以我们用白色像素填充整个区块来移除黑岛。 为简单起见,在我们的 5x5 过滤器中,我们将忽略图像周围的两个边界像素,并保留它们的原样。 - -下图左侧是安卓平板电脑的原始图片,中间是素描模式,显示了胡椒噪点的小黑点,右侧显示了我们去除胡椒噪点的结果,皮肤看起来更干净: - -![](img/156a82bb-57bc-4995-a28a-10945360d217.png) - -为了简单起见,下面的代码也可以命名为`removePepperNoise()`*和*函数,用于在适当的位置编辑图像文件: - -```cpp -void removePepperNoise(Mat &mask) -{ - for (int y=2; y> /etc/apt/sources.list - echo "deb http://www.linux- - projects.org/listing/uv4l_repo/raspbian/jessie main">> - /etc/apt/sources.list - exit - sudo apt-get update - sudo apt-get install uv4l uv4l-raspicam uv4l-server -``` - -2. 手动运行 UV4L 流媒体服务器(在 Raspberry PI 上)以检查其是否正常工作: - -```cpp -sudo killall uv4l - sudo LD_PRELOAD=/usr/lib/uv4l/uv4lext/armv6l/libuv4lext.so - uv4l -v7 -f --sched-rr --mem-lock --auto-video_nr - --driverraspicam --encoding mjpeg - --width 640 --height 480 --framerate15 -``` - -3. 测试摄像头的网络以从您的桌面传输视频,请按照以下步骤检查一切是否正常工作: - * 安装 VLC 媒体播放器。 - * 导航到 Media(媒体)|Open Network Stream(打开网络流媒体),然后输入`http://192.168.2.111:8080/stream/video.mjpeg`。 - * 将 URL 调整为 Raspberry PI 的 IP 地址。 在 Raspberry Pi 上运行`hostname -I`命令以找到其 IP 地址。 -4. 在启动时自动运行 UV4L 服务器: - -```cpp -sudo apt-get install uv4l-raspicam-extras -``` - -5. 编辑您在`uv4l-raspicam.conf`中需要的任何 UV4L 服务器设置,例如分辨率和帧速率,以自定义流媒体: - -```cpp -sudo nano /etc/uv4l/uv4l-raspicam.conf - drop-bad-frames = yes - nopreview = yes - width = 640 - height = 480 - framerate = 24 -``` - -您需要重新启动才能使所有更改生效。 - -6. 告诉 OpenCV 像使用网络摄像头一样使用我们的网络流。 只要您安装的 OpenCV 可以在内部使用 FFMPEG,OpenCV 就可以像网络摄像头一样从 MJPEG 网络流中抓取帧: - -```cpp -./Cartoonifier http://192.168.2.101:8080/stream/video.mjpeg -``` - -您的 Raspberry PI 现在正在使用 UV4L 将实时的 640 x 480 24 FPS 视频流传输到在*草图*模式下运行 Cartoonizer 的 PC,实现了大约 19 FPS(具有 0.4 秒的延迟)。 请注意,这几乎与在 PC 上直接使用 PS3 Eye 网络摄像头(20FPS)的速度相同! - -请注意,当您将视频流式传输到 OpenCV 时,它将无法设置相机分辨率;您需要调整 UV4L 服务器设置以更改相机分辨率。 还要注意的是,我们可以流式传输 H.264 视频,而不是流式传输 MJPEG,这使用了较低的带宽,但是一些计算机视觉算法不能很好地处理视频压缩,例如 H.264,所以 MJPEG 引起的算法问题比 H.264 要少。 - -If you have both the official Raspberry Pi V4L2 driver and the UV4L driver installed, they will both be available as cameras `0` and `1` (`devices /dev/video0` and `/dev/video1`), but you can only use one camera driver at a time. - -# 定制您的嵌入式系统! - -现在你已经创建了一个完整的嵌入式 Cartoonizer 系统,你知道它是如何工作的,哪些部件做什么,你应该定制它! 使视频全屏,更改 GUI,更改应用行为和工作流程,更改 Cartoonizer 过滤器常量或皮肤检测器算法,用您自己的项目想法替换 Cartoonizer 代码,或者将视频流式传输到云中并在那里进行处理! - -您可以从很多方面改进皮肤检测算法,例如使用更复杂的皮肤检测算法(例如,使用最近在[http://www.cvpapers.com](http://www.cvpapers.com/)上的许多 CVPR 或 ICCV 会议论文中训练好的高斯模型),或者将人脸检测(参见[第 17 章](17.html),*人脸检测和识别模块*的第*人脸检测*章节)添加到皮肤检测器中,从而检测用户的人脸在哪里。 而不是要求用户将他们的脸放在屏幕中央。 请注意,在某些设备或高分辨率摄像头上,人脸检测可能需要数秒时间,因此它们当前的实时使用可能会受到限制。 但嵌入式系统平台每年都在变得更快,所以随着时间的推移,这可能不是什么问题。 - -提高嵌入式计算机视觉应用速度的最重要方法是尽可能地降低摄像头分辨率(例如,将摄像头分辨率从 500 万像素降至 50 万像素),尽可能少地分配和释放图像,以及尽可能少地执行图像格式转换。 在某些情况下,可能有一些优化的图像处理或数学库,或者您设备的 CPU 供应商(例如 Broadcom、NVIDIA Tegra、Texas Instruments OMAP 或 Samsung Exynos)或您的 CPU 系列(例如 ARM Cortex-A9)提供的 OpenCV 优化版本。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -本章介绍了几种不同类型的图像处理滤镜,这些滤镜可用于生成各种卡通效果,从看起来像铅笔画的素描模式、看起来像彩画的画图模式,到将*草图*模式覆盖在画图模式之上使其看起来像卡通的卡通模式。 它还表明,还可以获得其他有趣的效果,比如邪恶模式,它大大增强了嘈杂的边缘,以及外星人模式,它改变了脸部的皮肤,使其看起来像明亮的绿色。 - -有许多商业智能手机应用可以在用户的脸上添加类似的有趣效果,比如卡通滤镜和肤色变化。 也有使用类似概念的专业工具,比如皮肤平滑视频后处理工具,试图通过平滑女性的皮肤,同时保持边缘和非皮肤区域的锐利来美化她们的脸,以使她们的脸看起来更年轻。 - -本章介绍如何将应用从台式机移植到嵌入式系统,方法是遵循建议的指导原则,即首先开发工作台式机版本,然后将其移植到嵌入式系统,并创建适合嵌入式应用的用户界面。 图像处理代码在这两个项目之间共享,以便读者可以修改桌面应用的卡通滤镜,也可以很容易地在嵌入式系统中看到这些修改。 - -请记住,本书包括 Linux 的 OpenCV 安装脚本和讨论的所有项目的完整源代码。 - -在下一章中,我们将学习如何使用运动(**SFM**)中的**多视图立体**(**MVS**)和**结构进行 3D 重建,以及如何以 OpenMVG 格式导出最终结果。*** \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/14.md b/trans/build-cv-proj-opencv4-cpp/14.md deleted file mode 100644 index db0e44cd..00000000 --- a/trans/build-cv-proj-opencv4-cpp/14.md +++ /dev/null @@ -1,427 +0,0 @@ -# 使用 SfM 模块从运动中探索结构 - -**运动的结构**(**SfM**)是恢复摄像机注视场景的位置和场景的稀疏几何体的过程。 相机之间的运动施加了几何约束,可以帮助我们恢复物体的*结构*,因此这个过程被称为 Sfm。 从 OpenCV v3.0+开始,添加了一个名为`sfm`的贡献(`"contrib"`)模块,它帮助从多个图像执行端到端的 SfM 处理。 在本章中,我们将学习如何使用 SfM 模块将场景重建为稀疏点云,包括相机姿势。 稍后,我们还将*加密*点云,通过使用名为 OpenMVS 的开放**多视图立体**(**MVS**)软件包向其添加更多点以使其密集。 SFM 被用于高质量的三维扫描,自主导航的视觉里程计,航空摄影测绘,以及更多的应用,使其成为计算机视觉中最基本的追求之一。 计算机视觉工程师应该熟悉 SfM 的核心概念,计算机视觉课程经常讲授这一主题。 - -本章将介绍以下主题: - -* SfM 的核心概念:**多视图几何**(**MVG**)、三维重建和**多视图立体**(**MVS**) -* 使用 OpenCV SfM 模块实施 SfM 管道 -* 可视化重建结果 -* 将重建导出到 OpenMVG,并将稀疏云加密为完全重建 - -# 技术要求 - -构建和运行本章中的代码需要以下技术和安装: - -* OpenCV 4(使用`sfm contrib`模块编译) -* Eigen v3.3+(`sfm`模块要求) -* CERES 求解器 v2+1(`sfm`模块要求) -* CMake 3.12+ -* Boost v1.66+ -* OpenMVS -* CGAL v4.12+(OpenMVS 要求) - -所列组件的构建说明以及实现本章中概念的代码将在附带的代码存储库中提供。 使用 OpenMVS 是可选的,我们可以在得到稀疏重建后停止。 然而,完整的 MVS 重建更令人印象深刻,也更有用;例如,对于 3D 打印复制品。 - -具有足够重叠的任何一组照片对于 3D 重建可能是足够的。 例如,我们可以使用我在南达科他州拍摄的一组疯马纪念头的照片,它与这个章节代码捆绑在一起。 要求拍摄的图像之间应该有足够的移动,但足够有明显的重叠,以便进行强有力的配对匹配。 - -在下面的例子中,从疯马纪念数据集,我们可以注意到图像之间的视角有轻微的变化,重叠非常强烈。 请注意,我们还可以在人们行走的雕像下方看到巨大的变化;这不会干扰石面的 3D 重建: - -![](img/475612f8-034c-4a8b-8314-61cd766479fb.png) - -章节代码文件可从[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter14](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter14)下载。 - -# Sfm 的核心概念 - -在我们深入研究 SfM 管道的实现之前,让我们回顾一下作为该过程的重要部分的一些关键概念。 SfM 中最重要的一类理论主题是**核极几何**(**EG**),即多视图几何或 MVG,它建立在**图像形成**和**相机校准**知识的基础上;然而,我们将只略过这些基本主题。在我们介绍了 EG 的几个基础知识之后,我们将很快讨论**立体重建**,并回顾诸如**从视差**获得深度的主题。 SfM 中的其他关键主题,如**健壮特征匹配**,更多的是机械性的,而不是理论上的,我们将在对系统进行编码的过程中介绍这些主题。 我们有意省略了一些非常有趣的主题,例如**相机分割**、**PNP 算法**和**重构因子分解**,因为这些都是由底层的`sfm`模块处理的,我们不需要调用它们,尽管 OpenCV 中确实存在执行它们的函数。 - -在过去的四十年里,所有这些主题都是大量研究和文献的来源,并成为数以千计的学术论文、专利和其他出版物的主题。Hartley 和 Zisserman 的*多视图几何*是迄今为止 SfM 和 MVG 数学和算法的最重要的资源,尽管令人难以置信的次要资产是 Szeliski 的*计算机视觉:算法和应用*,它非常详细地解释了 SfM,重点是 Richard Szelisiski。 对于第三个解释来源,我建议你买一本普林斯的*计算机视觉:模型、学习和推理*,这本书的特点是漂亮的图形、图表和细致的数学推导。 - -# 校准相机和核线几何 - -我们的图像从投影开始。 他们通过镜头看到的 3D 世界在相机内部的 2D 传感器上被*展平*,基本上失去了所有的深度信息。 那么我们如何才能从 2D 图像回到 3D 结构呢? 在许多情况下,标准强度相机的答案是 MVG。 直观地说,如果我们至少可以从两个视图看到一个物体(2D),我们就可以估计它与摄像机的距离。 作为人类,我们经常这样做,用我们的两只眼睛。 我们人类的深度感知来自多个(两个)视角,但不仅仅是这样。 事实上,人类的视觉感知,因为它与感知深度和 3D 结构有关,是非常复杂的,与眼睛的肌肉和传感器有关,而不仅仅是我们视网膜上的*图像*及其在大脑中的处理。 人类的视觉及其神奇特征远远超出了本章的范围;然而,在不止一个方面,SFM(以及所有的计算机视觉!)。 灵感来自人类的视觉。 - -回到我们的摄像机。 在标准的 SfM 中,我们使用**针孔相机模型**,它简化了真实相机中进行的整个光学、机械、电气和软件过程。 针孔模型描述了现实世界中的对象如何变成像素,并涉及一些我们称为**内部参数**的参数,因为它们描述了相机的内部功能: - -![](img/f2b70fdd-9448-41fe-b56f-5ed6a2cb6ad6.png) - -使用针孔模型,我们通过应用投影来确定 3D 点在图像平面上的 2D 位置。 请注意 3D 点![](img/498936b9-e78b-4b5c-95f8-bc1da85a5ff9.png)和相机原点如何形成直角三角形,其中相邻边等于![](img/0572cfb2-437a-495e-9693-40ac997748a8.png)。 图像点![](img/1c5e7edf-0594-4136-b0ee-501f4b63ef79.png)与相邻的点![](img/cbbf9c79-d306-4641-af63-6bb635458820.png)共享相同的角度,即从原点到图像平面的距离。 这个距离被称为**焦距**,但这个名称可能具有欺骗性,因为图像平面实际上并不是焦平面;为了简单起见,我们将这两个名称融合在一起。 重叠直角三角形的初等几何将告诉我们![](img/d9b518ff-8906-4e09-a419-1211bdaa3127.png);然而,由于我们处理图像,我们必须考虑**原点**和![](img/450a7c7b-6097-4e87-a1bc-36517a8ff506.png),并得出![](img/687a0d5f-0990-45c5-899d-fdf787e07fa0.png)。 如果我们对![](img/0adc346a-eb35-4b08-bf9c-b5a243cf244f.png)轴执行同样的操作,则如下所示: - -![](img/6498d16e-9565-412d-9dae-fba8b0eae6d7.png) - -3x3 矩阵称为**本征参数矩阵**,通常表示为![](img/79546030-18cb-4b6f-a00f-f31ce372081c.png);然而,这个方程式有一些地方似乎不太对劲,需要解释。 首先,我们错过了分区![](img/9149b434-ea6c-4310-81cf-8630dfa0c7d8.png),它去了哪里? 第二,方程式的 LHS 上出现的那个神秘的公式![](img/86b3b8a7-30ed-4984-a66e-a5528946bc87.png)是什么? 答案是**齐次坐标**,这意味着我们在向量的末尾加上一个![](img/990a120c-05fb-448c-b5be-a67382f8355d.png)符号。这个有用的符号允许我们线性化这些运算,并在以后执行除法。 在矩阵乘法步骤的末尾,我们可能会一次对数千个点进行乘法,我们将结果除以向量中的最后一项,这恰好就是我们要寻找的结果![](img/f6cf70b5-fa60-4b7c-aac9-c673d6542d05.png)。 至于![](img/24286605-7bb6-4bfc-beab-7a409ffc9b91.png),这是一个我们必须牢记的未知的任意尺度因素,它来自我们在预测中的一个视角。 想象一下,我们有一辆玩具车离摄像机非常近,旁边是一辆真实大小的车,距离摄像机 10 米远;在图像中,它们看起来大小相同。 换句话说,我们可以将 3D 点![](img/e240393d-1fc2-4f3b-bb0a-d316a6974fbc.png)沿着相机发出的光线移动到任何地方,但仍然可以在图像中获得相同的![](img/1486b837-5e97-4360-a0ee-8931eb6066eb.png)坐标。 这就是透视投影的魔咒:我们失去了我们在本章开头提到的深度信息。 - -还有一件事我们必须考虑,那就是我们的相机在世界上的姿势。 并不是所有的摄像头都放置在原点![](img/0c64c536-f0c1-4fc9-b8fd-e2c6735c4b99.png),特别是如果我们有一个有很多摄像头的系统。 我们可以方便地将一个摄影机放置在原点,但其余摄影机将具有相对于其自身的旋转和平移(刚性变换)组件。 因此,我们将另一个矩阵添加到投影方程式中: - -![](img/9841b888-49fa-4d11-a0e6-2d4c718f45f0.png) - -新的 3x4 矩阵通常称为**外部参数矩阵**,包含 3x3 旋转和 3x1 平移分量。 请注意,我们使用了相同的齐次坐标技巧,通过在![](img/226f6bf5-865b-44d5-a575-52fbcdd8fcdd.png)末尾添加 1 来帮助将平移合并到计算中。 我们经常会在文献中看到写成![](img/f2f70666-b3ac-48d9-a588-9e151d48264d.png)的整个方程式: - -![](img/c4dc6217-9d96-4c82-bba4-c2206f4eba45.png) - -假设两个相机看着相同的对象点。 正如我们刚才所讨论的,我们可以将 3D 点的*真实*位置沿着相机的轴向滑动,但仍然观察到相同的 2D 点,从而丢失深度信息。直观地说,两个视角应该足以找到真实的 3D 位置,因为来自两个视点的光线会聚在这里。但实际上,当我们在光线上滑动该点时,在另一个从不同角度看的相机中,这个位置会改变。 事实上,相机**L 和**(左)中的任何点都将对应于相机**R**(右)中的*线*,称为**核极线****e**(有时称为**尾线**),该线位于由两个相机的光学中心和 3D 点**构成的**核极面**上。** **这可以用作两个视图之间的几何约束,帮助我们找到关系。** - - **我们已经知道,在两个摄像头之间,有一个刚性的变换![](img/4f2768da-fb04-466c-ac0d-e036e68c12c4.png)。 如果我们想在摄像机**L**的坐标系中表示摄像机**R**中的一个点--![](img/e98a79a1-2046-4605-b91b-b2c784ec1918.png),我们可以这样写:![](img/fbd558b4-04cb-4454-8c23-237c62395335.png)。 如果我们取叉积![](img/e105ca00-304f-4b38-af40-8eff2849dca1.png),我们将得到一个垂直于极平面的矢量*。 因此,推论![](img/c1738ddc-d810-4ef0-86a5-2f334415acbc.png),因为![](img/aebede97-b48d-4ffb-a7f5-3e200c63c3ae.png)在*的极面上是*,所以一个点积将产生 0。 我们取叉积的斜对称形式,我们可以写成![](img/76e93164-1d72-461d-b9d9-9fdaf452341d.png),然后把它组合成一个矩阵![](img/1e874185-64d9-490f-855f-365a19afd47c.png)。 我们称![](img/964ea557-0160-47ce-91ec-abfd72ea06c5.png)为**本质矩阵**。 本质矩阵给出了一个**核线约束**,约束范围是摄像机 L 和摄像机 R 之间所有会聚于真实 3D 点的点对。 如果一对点(来自**L**和**R**)不能满足此约束,则它很可能不是有效的配对。 我们也可以使用一些点对来估计本质矩阵,因为它们简单地构造了齐次线性方程组。 用特征值或**奇异值分解**(**奇异值分解**)可以很容易地得到解。* - -到目前为止,在我们的几何学中,我们假设我们的相机是规格化的,本质上是指单位矩阵![](img/45e7cf80-35ac-4f80-9ba3-166c3869e43b.png)。 然而,在具有特定像素大小和焦距的真实图像中,我们必须考虑真实的固有特性。 为此,我们可以在两侧应用![](img/c7b59eb9-58a4-439f-b9ff-cd68ff546c5b.png)的逆:![](img/1fd3890b-2230-42b1-b339-b3ce1b8d7f7a.png)。 我们最终得到的这个新矩阵称为**基本矩阵**,它可以从足够多的像素坐标点对中估计出来。 如果我们知道![](img/e3d9b59b-1dcc-4077-b22b-1de97b31f42b.png),我们就可以得到本质矩阵;然而,基础矩阵本身可以作为一个很好的核线约束。 - -# 立体重建与 SfM - -在 SFM 中,我们希望同时恢复摄像机的姿势和 3D 特征点的位置。 我们刚刚看到了简单的 2D 点对匹配如何帮助我们估计本质矩阵,从而编码视图之间的严格几何关系:![](img/c5c3a167-9300-4cd7-af31-e8b6ac905b0c.png)。 本质矩阵可以通过奇异值分解(SVD)的方式分解为![](img/5dc78f78-fe50-41aa-9c24-6da76f0f79e8.png)和![](img/032b6d43-cf18-4ed0-83fd-11f043be1820.png),在找到![](img/227449b2-a921-45bb-9882-a691972a467b.png)和![](img/4662b537-3103-4520-ad41-d25b3fe9fa6b.png)之后,我们继续寻找 3D 点,并完成这两幅图像的 SfM 任务。 - -我们已经看到了两个 2D 视图和 3D 世界之间的几何关系;但是,我们还没有看到如何从 2D 视图恢复 3D 形状。 我们的一个见解是,给出同一点的两个视图,我们可以从相机的光学中心和图像平面上的 2D 点穿过这两条光线,它们将会聚在 3D 点上。 这是**三角剖分的基本思想。** 求解 3D 点的一种简单方法是写出投影方程并将其等值,因为 3D 点(![](img/f683bdc9-d3fc-4513-bb65-9809b7c37c9b.png))是常见的![](img/a61df32d-c3a9-4226-a4eb-3e8742dc4b21.png),其中第![](img/4c12cd51-4ead-40b2-babd-253e63301184.png)个矩阵是第![](img/af901573-a745-43e1-ae05-12957ddc804b.png)个投影矩阵。 这些方程可以化为齐次线性方程组,并且可以例如用奇异值分解(SVD)来求解。 这被称为三角剖分的**直接线性方法**;然而,它是严重次优的,因为它没有直接最小化有意义的误差函数。 还提出了其他几种方法,包括查看光线之间的最近点(通常不直接相交),称为**中点法**。 - -从两个视图获得基线 3D 重建后,我们可以继续添加更多视图。 这通常以不同的方法完成,在现有的 3D 点和传入的 2D 点之间使用匹配。 这类算法称为**点-n-透视**(**PnP**),我们不在这里讨论。 另一种方法是执行成对立体重建(我们已经看到),并计算比例因子,因为如前所述,重建的每个图像对可能会产生不同的比例。 - -恢复深度信息的另一个有趣的方法是进一步利用核线。 我们知道,图像**L**中的一个点将位于图像**R**中的一条直线上,我们也可以使用![](img/05e112b2-8ef5-4456-885f-4dd9c82646de.png)精确地计算这条直线。 因此,任务是在图像**R**的核线上找到与图像 L 中的点最匹配的点。这种线匹配方法可称为**立体深度重建**,由于我们可以恢复图像中几乎每个像素的深度信息,因此它是**密集**重建的大多数倍。 实际上,核线首先**校正**为完全水平,模仿图像之间的**纯水平平移**。 这减少了仅在*x*轴上匹配的问题: - -![](img/9d447eaf-9d67-414a-8fec-d587c5479e0d.png) - -水平平移的主要吸引力是**视差**、**和**,它描述了兴趣点在两幅图像之间水平移动的距离。 在上图中,我们可以注意到,由于右重叠三角形:![](img/0116281d-2788-4c6c-b8e7-7326ab9eff9b.png),这导致了![](img/ab1d422d-ace3-4456-905c-c112dafa0ea0.png)。 基线![](img/d9ae5b13-4201-475a-9ae7-3cd0c474b558.png)(水平运动)和焦距![](img/19622f3a-e1d4-402c-a9da-9682df2dc713.png)相对于特定的 3D 点及其与相机的距离是恒定的。 因此,我们的洞察力是,**差异与深度**成反比。 视差越小,点离相机越远。 当我们从移动的火车车窗看地平线时,远处的山脉移动得很慢,而近处的树木移动得很快。 这种效果也称为**视差**。 利用视差进行三维重建是所有立体算法的基础。 - -另一个被广泛研究的主题是 MVS,它利用核线约束一次从多个视图中寻找匹配点。 同时扫描多个图像中的尾部可以对匹配特征施加进一步的约束。 只有当找到满足所有约束的匹配项时,才会考虑它。 当我们恢复多个相机位置时,我们可以使用 MVS 进行密集重建,这也是我们在本章后面要做的。 - -# 在 OpenCV 中实现 SfM - -OpenCV 拥有丰富的工具,可以从 First Principle 实现成熟的 SfM 管道。 然而,这样的任务要求非常高,超出了本章的范围。 这本书的前一版只是略微介绍了构建这样一个系统需要做些什么,但幸运的是,现在我们已经掌握了一种经过验证和测试的技术,它直接集成到 OpenCV 的 API 中。尽管`sfm`模块允许我们通过简单地提供一个带有图像列表的非参数函数来处理和接收具有稀疏点云和相机姿势的完全重建的场景,但是我们不会走这条路。 相反,我们将在这一节中看到一些有用的方法,这些方法将使我们能够更好地控制重建,并举例说明我们在上一节中讨论的一些主题,以及更强的抗噪能力。 - -本节将从 sfm:**使用关键点和功能描述符匹配图像**的非常基础开始。 然后,我们将使用匹配图通过图像集查找**个轨迹**以及相似特征的多个视图。 我们继续进行**3D 图像重建**,**3D 可视化**,最后使用 OpenMVS 进行 MVS。 - -# 图像特征匹配 - -如上一节所述,SFM 依赖于理解图像之间的几何关系,因为它与图像中的可见对象相关。 我们看到,我们可以计算出两幅图像之间的精确运动,并且有足够的关于图像中对象如何运动的信息。 可以从图像特征线性估计的基本矩阵或基本矩阵可以分解成定义**3D 刚性变换**的旋转和平移元素。 此后,这种变换可以帮助我们从 3D-2D 投影方程或根据校正后的尾部上的密集立体匹配来三角测量对象的 3D 位置。 这一切都是从图像特征匹配开始的,因此我们将看到如何获得健壮且无噪声的匹配。 - -OpenCV 提供了大量的 2D 特征**检测器**(也称为**提取器**)和**描述符**。 特征被设计为与图像变形不变,因此它们可以通过场景中对象的平移、旋转、缩放和其他更复杂的变换(仿射、投影)来匹配。 OpenCV API 的最新功能之一是`AKAZE`特征提取器和检测器,它在计算速度和对变换的稳健性之间提供了非常好的折衷。 结果显示,`AKAZE`的表现优于其他突出特征,例如**ORB**(**Oriented Brief**的缩写)和**SURF**(**加速健壮特征**的缩写)。 - -以下代码片断将提取一个`AKAZE`关键点,为我们在`imagesFilenames`中收集的每个图像计算`AKAZE`个特征,并将它们分别保存在`keypoints`和`descriptors`数组中: - -```cpp -auto detector = AKAZE::create(); -auto extractor = AKAZE::create(); - -for (const auto& i : imagesFilenames) { - Mat grayscale; - cvtColor(images[i], grayscale, COLOR_BGR2GRAY); - detector->detect(grayscale, keypoints[i]); - extractor->compute(grayscale, keypoints[i], descriptors[i]); - - CV_LOG_INFO(TAG, "Found " + to_string(keypoints[i].size()) + " - keypoints in " + i); -} -``` - -注意,我们还将图像转换为灰度;但是,这一步可能会被省略,结果不会受到影响。 - -这是在两张相邻图像中检测到的特征的可视化。 注意其中有多少是重复的;这称为 Feature**Repeatability**,这是一个好的特征提取器最需要的功能之一: - -![](img/63b6ab90-25d2-4e80-8bd5-e020bb24aaee.png) - -下一步是匹配每对图像之间的特征。 OpenCV 提供了一个出色的功能匹配套件。 `AKAZE`特征描述符是*二进制*,这意味着它们在匹配时不能被视为二进制编码数;它们必须在位级别上与逐位运算符进行比较。 OpenCV 为二进制特征匹配器提供了**汉明距离**度量,该度量实质上计算两位序列之间不正确匹配的数量: - -```cpp -vector matchWithRatioTest(const DescriptorMatcher& matcher, - const Mat& desc1, - const Mat& desc2) -{ - // Raw match - vector< vector > nnMatch; - matcher.knnMatch(desc1, desc2, nnMatch, 2); - - // Ratio test filter - vector ratioMatched; - for (size_t i = 0; i < nnMatch.size(); i++) { - const DMatch first = nnMatch[i][0]; - const float dist1 = nnMatch[i][0].distance; - const float dist2 = nnMatch[i][1].distance; - - if (dist1 < MATCH_RATIO_THRESHOLD * dist2) { - ratioMatched.push_back(first); - } - } - - return ratioMatched; -} -``` - -前面的函数不仅定期调用我们的匹配器(例如,a`BFMatcher(NORM_HAMMING)`),它还执行**比率测试**。 这一简单的测试在许多依赖于特征匹配的计算机视觉算法(如 SfM、全景拼接、稀疏跟踪等)中是一个非常基本的概念。 我们不再为图像*B*中的图像*A*中的特征寻找单个匹配项,而是在图像*B*中查找两个匹配项,并确保*没有混淆*。 如果两个潜在的匹配特征描述符太相似(就它们的距离度量而言),并且我们不能区分它们中的哪一个是查询的正确匹配,则可能会在匹配中出现混淆,因此我们将它们都丢弃以防止混淆。 - -接下来,我们实现一个**互易过滤器**。 此过滤器仅允许在*A*至*B、*以及*B*至*A*中匹配(使用比率测试)的功能。 本质上,这是确保图像*A*和图像*B:*中的特征之间存在对称匹配的一对一匹配。 互易过滤器消除了更多的歧义,有助于实现更清晰、更稳健的匹配: - -```cpp -// Match with ratio test filter -vector match = matchWithRatioTest(matcher, descriptors[imgi], descriptors[imgj]); - -// Reciprocity test filter -vector matchRcp = matchWithRatioTest(matcher, descriptors[imgj], descriptors[imgi]); -vector merged; -for (const DMatch& dmrecip : matchRcp) { - bool found = false; - for (const DMatch& dm : match) { - // Only accept match if 1 matches 2 AND 2 matches 1. - if (dmrecip.queryIdx == dm.trainIdx and dmrecip.trainIdx == - dm.queryIdx) { - merged.push_back(dm); - found = true; - break; - } - } - if (found) { - continue; - } -} -``` - -最后,我们应用**核线约束**。 每两个图像之间有一个有效的刚性变换,它们将遵守对其特征点的核线约束:![](img/772d660f-4e47-4485-a9d9-adfd5652197a.png),而那些没有通过此测试(获得足够成功)的图像很可能不是很好的匹配,并且可能会导致噪声。 我们通过使用投票算法(RANSAC)计算基本矩阵并检查内异值之比来实现这一点。 我们应用一个阈值来丢弃与原始匹配相比存活率较低的匹配: - -```cpp -// Fundamental matrix filter -vector inliersMask(merged.size()); -vector imgiPoints, imgjPoints; -for (const DMatch& m : merged) { - imgiPoints.push_back(keypoints[imgi][m.queryIdx].pt); - imgjPoints.push_back(keypoints[imgj][m.trainIdx].pt); -} -findFundamentalMat(imgiPoints, imgjPoints, inliersMask); - -vector final; -for (size_t m = 0; m < merged.size(); m++) { - if (inliersMask[m]) { - final.push_back(merged[m]); - } -} - -if ((float)final.size() / (float)match.size() < PAIR_MATCH_SURVIVAL_RATE) { - CV_LOG_INFO(TAG, "Final match '" + imgi + "'->'" + imgj + "' has less than "+to_string(PAIR_MATCH_SURVIVAL_RATE)+" inliers from orignal. Skip"); - continue; -} -``` - -我们可以在下图中看到每个过滤步骤(原始匹配、比率、互易性和核线)的效果: - -![](img/8068190b-88c5-49d2-9531-ff1b64a56e5d.png) - -# 查找要素轨迹 - -**特征轨迹**的概念早在 1992 年 Tomasi 和 Kanade 的工作(*Shape and Motion from Image Streams*,1992)中就被引入到 SFM 文献中,并在 Snaful 和 Szeliski 于 2007 年因大规模无约束重建而在其开创性的摄影旅游工作中声名鹊起。 轨迹只是单个场景要素(一个有趣的点)在多个视图上的 2D 位置。 轨迹很重要,因为它们保持了帧之间的一致性,而不是像 Snaful 建议的那样,可以组合成全局优化问题。 轨迹对我们特别重要,因为 OpenCV 的`sfm`模块允许通过仅提供所有视图上的 2D 轨迹来重建场景: - -![](img/4dd02fe4-721a-4a6a-b03c-b5ee9e57a8fd.png) - -在所有视图之间已经找到成对匹配之后,我们就有了在这些匹配特征中查找轨迹所需的信息。 如果我们沿着第一张图中的特征*i*通过匹配到第二张图,那么从第二张图到第三张图通过他们自己的匹配,以此类推,我们可能最终会得到它的轨迹。 这种记账方式很容易变得太难使用标准数据结构以简单的方式实现。 但是,如果我们表示**匹配图**中的所有匹配,就可以简单地完成。 图中的每个节点都是在一张图像中检测到的特征,边是我们恢复的匹配。 从第一个图像的特征节点到第二个图像、第三个图像、第四个图像的特征节点有许多边,依此类推(对于未被过滤器丢弃的匹配项)。 因为我们的匹配是相互的(对称的),所以图可以是无向的。 此外,互易性测试确保对于第一图像中的特征*i*,在第二图像*、*中只有一个匹配特征*j*,反之亦然:特征*j*将仅与特征*i*相匹配。 - -以下是这样的匹配图的可视示例。 节点颜色表示特征点(节点)的来源图像。 边缘表示图像特征之间的匹配。 我们可以注意到从第一张图像到最后一张图像的特征匹配链的非常强的模式: - -![](img/863dc95e-4b4c-4235-9e04-5e24889b7968.png) - -要编码匹配图,我们可以使用**Boost Graph Library**(**bgl**),它具有广泛的图形处理和算法 API。 构建图形很简单;我们只需使用图像 ID 和 Feature ID 来增加节点,这样稍后我们就可以追溯原点: - -```cpp -using namespace boost; - -struct ImageFeature { - string image; - size_t featureID; -}; -typedef adjacency_list < listS, vecS, undirectedS, ImageFeature > Graph; -typedef graph_traits < Graph >::vertex_descriptor Vertex; -map, Vertex> vertexByImageFeature; - -Graph g; - -// Add vertices - image features -for (const auto& imgi : keypoints) { - for (size_t i = 0; i < imgi.second.size(); i++) { - Vertex v = add_vertex(g); - g[v].image = imgi.first; - g[v].featureID = i; - vertexByImageFeature[make_pair(imgi.first, i)] = v; - } -} - -// Add edges - feature matches -for (const auto& match : matches) { - for (const DMatch& dm : match.second) { - Vertex& vI = vertexByImageFeature[make_pair(match.first.first, dm.queryIdx)]; - Vertex& vJ = vertexByImageFeature[make_pair(match.first.second, dm.trainIdx)]; - add_edge(vI, vJ, g); - } -} -``` - -查看结果图的可视化(使用`boost::write_graphviz()`),我们可以看到许多情况下我们的匹配是错误的。 坏的匹配链将涉及来自链中同一图像的多个特征。 我们在下图中标记了几个这样的实例;请注意,有些链具有两个或更多颜色相同的节点: - -![](img/16f3c013-763f-4553-bcd5-5196c4a0c41f.png) - -我们可以注意到,这些链本质上是图中的连通组件。 使用`boost::connected_components()`提取组件很简单: - -```cpp -// Get connected components -std::vector component(num_vertices(gFiltered), -1); -int num = connected_components(gFiltered, &component[0]); -map > components; -for (size_t i = 0; i != component.size(); ++ i) { - if (component[i] >= 0) { - components[component[i]].push_back(i); - } -} -``` - -我们可以过滤掉不好的成分(任何一幅图像中有多个特征),以得到干净的匹配图。 - -# 三维重建和可视化 - -原则上获得轨迹后,我们需要按照 OpenCV 的 SfM 模块期望的数据结构对齐它们。不幸的是,`sfm`模块没有很好的文档记录,所以这一部分我们必须从源代码中自己找出。 我们将调用`cv::sfm::`名称空间下的以下函数,该函数可以在`opencv_contrib/modules/sfm/include/opencv2/sfm/reconstruct.hpp`中找到: - -```cpp -void reconstruct(InputArrayOfArrays points2d, OutputArray Ps, OutputArray points3d, InputOutputArray K, bool is_projective = false); -``` - -下面`opencv_contrib/modules/sfm/src/simple_pipeline.cpp`文件提供了一个重要提示,说明该函数期望作为输入的内容: - -```cpp -static void -parser_2D_tracks( const std::vector &points2d, libmv::Tracks &tracks ) -{ - const int nframes = static_cast(points2d.size()); - for (int frame = 0; frame < nframes; ++ frame) { - const int ntracks = points2d[frame].cols; - for (int track = 0; track < ntracks; ++ track) { - const Vec2d track_pt = points2d[frame].col(track); - if ( track_pt[0] > 0 && track_pt[1] > 0 ) - tracks.Insert(frame, track, track_pt[0], track_pt[1]); - } - } -} -``` - -通常,`sfm`模块使用精简版本的`libmv`https://developer.blender.org/tag/libmv/([https://www.blender.org/](https://developer.blender.org/tag/libmv/)),这是一个成熟的 SFM 软件包,用于使用 Blender 3D([Sfm](https://www.blender.org/))图形软件进行影院制作的 3D 重建。 - -我们可以告诉我们,需要将轨迹放在多个单独`cv::Mat`的向量中,其中每个都包含作为列的`cv::Vec2d`对齐列表,这意味着它有两行`double`。 我们还可以推断,轨迹中缺失(不匹配)的特征点将具有负坐标。 以下代码片断将从匹配图中提取所需数据结构中的轨迹: - -```cpp -vector tracks(nViews); // Initialize to number of views - -// Each component is a track -const size_t nViews = imagesFilenames.size(); -tracks.resize(nViews); -for (int i = 0; i < nViews; i++) { - tracks[i].create(2, components.size(), CV_64FC1); - tracks[i].setTo(-1.0); // default is (-1, -1) - no match -} -int i = 0; -for (auto c = components.begin(); c != components.end(); ++ c, ++ i) { - for (const int v : c->second) { - const int imageID = imageIDs[g[v].image]; - const size_t featureID = g[v].featureID; - const Point2f p = keypoints[g[v].image][featureID].pt; - tracks[imageID].at(0, i) = p.x; - tracks[imageID].at(1, i) = p.y; - } -} -``` - -我们继续运行重建功能,收集稀疏的 3D 点云和每个 3D 点的颜色,然后可视化结果(使用`cv::viz::`中的函数): - -```cpp -cv::sfm::reconstruct(tracks, Rs, Ts, K, points3d, true); -``` - -这将使用点云和相机位置生成稀疏重建,如下图所示: - -![](img/bec42639-0f0b-45e6-ad04-97235a9171d5.png) - -将 3D 点重新投影到 2D 图像上,我们可以验证正确的重建: - -![](img/731a45bb-c175-4e6f-999a-49b54a45c696.png) - -在附带的源代码存储库中查看重构和可视化的完整代码。 - -请注意,重建非常稀疏;我们只看到特征匹配的 3D 点。 在获取场景中对象的几何体时,这不会产生非常吸引人的效果。 在许多情况下,Sfm 管道不会以稀疏重建结束,这对许多应用(如 3D 扫描)没有用处。 接下来,我们将了解如何获得**密集**重建。 - -# 用于密集重建的 MVS - -利用稀疏的三维点云和摄像机的位置,我们可以利用 MVS 进行密集重建。 在第一节中我们已经学习了 MVS 的基本概念;但是,我们不需要从头开始实现它,而是可以使用**OpenMVS**项目。 要使用 OpenMVS 进行云加密,我们必须将我们的项目保存为专门的格式。 OpenMVS 提供了一个用于保存和加载`.mvs`项目的类,即在`MVS/Interface.h`中定义的`MVS::Interface`类。 - -让我们从摄像机开始: - -```cpp -MVS::Interface interface; -MVS::Interface::Platform p; - -// Add camera -MVS::Interface::Platform::Camera c; -c.K = Matx33d(K_); // The intrinsic matrix as refined by the bundle adjustment -c.R = Matx33d::eye(); // Camera doesn't have any inherent rotation -c.C = Point3d(0,0,0); // or translation -c.name = "Camera1"; -const Size imgS = images[imagesFilenames[0]].size(); -c.width = imgS.width; // Size of the image, to normalize the intrinsics -c.height = imgS.height; -p.cameras.push_back(c); - -``` - -在添加相机姿势(视图)时,我们必须小心。 OpenMVS 希望获得相机的旋转和**中心**,而不是点投影![](img/2715d623-fd0b-42a7-85aa-8a6b52bfc7d6.png)的相机姿势矩阵。 因此,我们必须通过应用反向旋转![](img/2b77ab75-4efe-422f-bb7a-c4cefe9c9f29.png)来平移平移向量以表示相机的中心: - -```cpp -// Add views -p.poses.resize(Rs.size()); -for (size_t i = 0; i < Rs.size(); ++ i) { - Mat t = -Rs[i].t() * Ts[i]; // Camera *center* - p.poses[i].C.x = t.at(0); - p.poses[i].C.y = t.at(1); - p.poses[i].C.z = t.at(2); - Rs[i].convertTo(p.poses[i].R, CV_64FC1); - - // Add corresponding image (make sure index aligns) - MVS::Interface::Image image; - image.cameraID = 0; - image.poseID = i; - image.name = imagesFilenames[i]; - image.platformID = 0; - interface.images.push_back(image); -} -p.name = "Platform1"; -interface.platforms.push_back(p); -``` - -在将点云也添加到`Interface`之后,我们可以在命令行中继续进行云的增密: - -```cpp -$ ${openMVS}/build/bin/DensifyPointCloud -i crazyhorse.mvs -18:48:32 [App ] Command line: -i crazyhorse.mvs -18:48:32 [App ] Camera model loaded: platform 0; camera 0; f 0.896x0.896; poses 7 -18:48:32 [App ] Image loaded 0: P1000965.JPG -18:48:32 [App ] Image loaded 1: P1000966.JPG -18:48:32 [App ] Image loaded 2: P1000967.JPG -18:48:32 [App ] Image loaded 3: P1000968.JPG -18:48:32 [App ] Image loaded 4: P1000969.JPG -18:48:32 [App ] Image loaded 5: P1000970.JPG -18:48:32 [App ] Image loaded 6: P1000971.JPG -18:48:32 [App ] Scene loaded from interface format (11ms): -7 images (7 calibrated) with a total of 5.25 MPixels (0.75 MPixels/image) -1557 points, 0 vertices, 0 faces -18:48:32 [App ] Preparing images for dense reconstruction completed: 7 images (125ms) -18:48:32 [App ] Selecting images for dense reconstruction completed: 7 images (5ms) -Estimated depth-maps 7 (100%, 1m44s705ms) -Filtered depth-maps 7 (100%, 1s671ms) -Fused depth-maps 7 (100%, 421ms) -18:50:20 [App ] Depth-maps fused and filtered: 7 depth-maps, 1653963 depths, 263027 points (16%%) (1s684ms) -18:50:20 [App ] Densifying point-cloud completed: 263027 points (1m48s263ms) -18:50:21 [App ] Scene saved (489ms): -7 images (7 calibrated) -263027 points, 0 vertices, 0 faces -18:50:21 [App ] Point-cloud saved: 263027 points (46ms) - -``` - -此过程可能需要几分钟才能完成。 然而,一旦它完成了,结果是非常令人印象深刻的。 密集的点云拥有惊人的**263,027 个 3D 点**,而稀疏云中只有 1,557 个点。 我们可以使用 OpenMVS 中捆绑的`Viewer`应用来可视化密集的 OpenMVS 项目: - -![](img/370ae600-09ba-4e74-9e0a-567b72db6d4a.png) - -OpenMVS 还有几个功能来完成重建,比如从密集的点云中提取三角网格。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -本章重点介绍了 SfM 及其使用 OpenCV 的`sfm`贡献模块和 OpenMVS 的实现。 探讨了多视点几何中的一些理论概念和几个实际问题:关键特征点的提取、匹配、匹配图的创建和分析、重建,最后对稀疏的三维点云进行 MVS 加密。 - -在下一章中,我们将了解如何使用 OpenCV 的`face contrib`模块检测照片中的面部地标,以及如何使用`solvePnP`函数检测人脸指向的方向。** \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/15.md b/trans/build-cv-proj-opencv4-cpp/15.md deleted file mode 100644 index 687a2d9d..00000000 --- a/trans/build-cv-proj-opencv4-cpp/15.md +++ /dev/null @@ -1,254 +0,0 @@ -# 使用 Face 模块进行脸部地标和姿势设置 - -人脸标志点检测是在人脸图像中找到兴趣点的过程。 它最近引起了计算机视觉社区的兴趣,因为它有许多引人注目的应用;例如,通过面部手势检测情感,估计凝视方向,改变面部外观(**脸部交换**),用图形增强脸部,以及虚拟人物的木偶表演。 我们可以在今天的智能手机和 PC 网络摄像头程序中看到许多这样的应用。 要实现这些应用,地标检测器必须在脸部找到几十个点,例如嘴角、眼角、下巴轮廓等等。 为此,开发了许多算法,并在 OpenCV 中实现了一些算法。 在本章中,我们将讨论使用`cv::face`模块检测人脸地标(也称为**人脸标记**)的过程,该模块提供了用于推理的 API,以及人脸标记检测器的培训。 我们将了解如何应用面部标记检测器在 3D 中定位人脸的方向。 - -本章将介绍以下主题: - -* 介绍了人脸标志点检测的历史和原理,并对 OpenCV 中实现的算法进行了说明 -* 利用 OpenCV 的`face`模块进行人脸标志点检测 -* 利用 2D-3D 信息估计人脸的大致方向 - -# 技术要求 - -构建本章中的代码需要以下技术和安装: - -* OpenCV v4(使用`face contrib`模块编译) -* Boost v1.66+ - -附带的代码存储库中将提供前面列出的组件的构建说明,以及实现本章中所示概念的代码。 - -要运行脸标检测器,需要预先训练的模型。虽然使用 OpenCV 中提供的 API 来训练检测器模型当然是可能的,但也提供了一些预先训练的模型供下载。其中一个模型可以从 OpenCV 的算法实现贡献者提供的[https://raw.githubusercontent.com/kurnianggoro/GSOC2017/master/data/lbfmodel.yaml](https://raw.githubusercontent.com/kurnianggoro/GSOC2017/master/data/lbfmodel.yaml)中获得(在 2017 年**Google 代码之夏**(**gsoc**))。 - -面部标记检测器可以处理任何图像;但是,我们可以使用指定的面部照片和视频数据集,这些数据集用于对面部标记算法进行基准测试。 这样的数据集是**300-VW**,可通过**智能行为理解小组**(**iBUG**)获得,该小组是伦敦帝国理工学院的一个计算机视觉小组:https://ibug.doc.ic.ac.uk/resources/300-VW/。 它包含数百个面部在媒体上露面的视频,仔细注释了 68 个面部标志点。 该数据集可用于训练面部标记检测器,以及了解我们使用的预训练模型的性能水平。 以下是 300 辆大众视频中的一段带有基本事实注释的节选: - -![](img/5f631ab0-fc6c-4b78-8fa4-1ed77a200b7d.png) - -Image reproduced under Creative Commons license - -本章的代码文件可从[https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter15](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter15)下载。 - -# 理论与语境 - -人脸地标检测算法自动在人脸图像上找到关键地标点的位置。 这些关键点通常是定位面部成分的突出点,如眼角或嘴角,以实现对脸型的更高层次的理解。 例如,要检测出一系列合适的面部表情,就需要在颌线、嘴巴、眼睛和眉毛周围的点。 事实证明,寻找面部地标是一项困难的任务,原因有很多:对象、光照条件和遮挡之间的差异很大。 为此,计算机视觉研究人员在过去 30 年里提出了数十种地标检测算法。 - -最近关于人脸地标检测的一项调查(Wu and Ji,2018)建议将地标检测器分为三组:整体方法、**约束局部模型**(**CLM**)方法和回归方法: - -* Wu 和 Ji 提出了**整体方法**,作为对脸部像素强度的完整外观进行建模的方法 -* **CLM 方法**结合全球模型检查每个地标周围的*个局部*个斑块 -* **回归方法**反复尝试使用由回归变量学习的一系列小更新来预测地标位置 - -# 活动外观模型和约束局部模型 - -整体方法的一个典型例子是 90 年代末的**主动外观模型**(**AAM**),通常归因于 T.F.Cootes(1998)的工作。 在 AAM 中,目标是将已知的面部绘制(来自训练数据)迭代地匹配到目标输入图像,目标输入图像在收敛时给出形状,从而给出地标。 AAM 方法及其衍生品非常受欢迎,而且仍然受到相当大的关注。 然而,AAM 的继任者 CLM 方法在光照变化和遮挡情况下表现出了更好的性能,并迅速占据了领先地位。 主要归功于 Cristinacce 和 Cootes(2006)和 Saragih 等人的工作。 (2011),CLM 方法对每个地标(补丁)的像素强度外观进行建模,并预先合并全局形状以应对遮挡和错误的局部检测。 - -CLMS 通常可以描述为寻求最小化,其中*p*是可以分解为其标志性的*D*和![](img/7c146c6b-9c56-4585-bed3-dcf195da5002.png)个点的脸型*姿势*点,如下所示: - -![](img/ef2721a0-2b90-4979-8417-9f259f6dc959.png) - -人脸姿势主要通过**主成分分析**(**PCA**)获得,标志点是逆 PCA 运算的结果。 使用 PCA 是很有用的,因为大多数脸型姿势都是强相关的,并且整个地标位置空间是高度冗余的。 距离函数(表示为![](img/1f409063-fd3c-48ad-a901-67a145b98f10.png))用于确定给定的地标模型点与图像观测![](img/160fb839-804b-4feb-ac23-29f61d915f70.png)的距离有多近。 在许多情况下,距离函数是面片到面片的相似性度量(模板匹配),或者使用基于边缘的特征,例如梯度的**直方图**(**HOG**)。 术语![](img/476cc28e-1dde-42d6-afd2-2be8879fc85a.png)表示不太可能的或极端的脸型姿势的正规化。 - -# 回归方法 - -相比之下,*回归方法*采用更简单但功能强大的方法。 这些方法使用机器学习,通过回归的方式,将*更新步骤*更新到地标的初始位置,并迭代直到位置收敛,其中,![](img/70c8f6c9-4993-4249-af7a-ddecf6013877.png)是时间 t*t*的形状,并且![](img/1d83afbc-c222-43db-acaa-a0ae0e2b6846.png)是对图像*i*和当前形状运行回归函数*r*的结果,如下所示: - -![](img/ee1b46e1-2583-493e-969d-ea9df3ee3c1b.png) - -然后通过级联这些更新操作,获得最终的标志性位置。 - -这种方法允许消耗大量的训练数据,并放弃作为 CLM 方法中心的局部相似性和全局约束而手工构建的模型。 流行的回归方法是**梯度增强树**(**GBT**),它提供非常快的推断、简单的实现,并且可以像森林一样并行。 - -利用深度学习,还有更新的面部标志性检测方法。 这些新方法或者通过使用**卷积神经网络**(**CNN**)直接从图像中回归面部标志点的位置,或者使用 CNN 与 3D 模型和级联回归方法的混合方法。 - -OpenCV 的`face`模块(在 OpenCV v3.0 中首次引入),包含 AAM、Ren 等人的实现。 (2014)和 Kazemi 等人。 (2014)回归类型法。 在本章中,我们将使用 Ren 等人(2014)的方法,因为在给定贡献者提供的预训练模型的情况下,它提供了最好的结果。Ren 等人的方法通过**局部二进制特征**(**LBF**)、**和**最好地学习了**局部二进制特征**,**和**一个非常短的二进制代码,它描述了每个地标的点周围的视觉外观,以及通过回归学习形状更新步骤。 - -# OpenCV 中的人脸标志点检测 - -地标检测从**人脸检测**开始,在图像中查找人脸及其范围(边界框)。 长期以来,面部检测一直被认为是一个解决问题,OpenCV 包含了首批向公众免费提供的健壮的人脸检测器之一。 事实上,OpenCV 在其早期主要以其快速的人脸检测功能而闻名并被使用,它实现了规范的 Viola-Jones Boost 级联分类器算法(Viola 等人。 2001、2004),并提供预先训练的模型。 虽然人脸检测在早期已经有了很大的发展,但在 OpenCV 中检测人脸的最快、最简单的方法仍然是使用捆绑的级联分类器,通过`core`模块中提供的`cv::CascadeClassifier`类。 - -我们使用级联分类器实现了一个简单的助手函数来检测人脸,如下所示: - -```cpp -void faceDetector(const Mat& image, - std::vector &faces, - CascadeClassifier &face_cascade) { - Mat gray; - - // The cascade classifier works best on grayscale images - if (image.channels() > 1) { - cvtColor(image, gray, COLOR_BGR2GRAY); - } else { - gray = image.clone(); - } - - // Histogram equalization generally aids in face detection - equalizeHist(gray, gray); - - faces.clear(); - - // Run the cascade classifier - face_cascade.detectMultiScale( - gray, - faces, - 1.4, // pyramid scale factor - 3, // lower thershold for neighbors count - // here we hint the classifier to only look for one face - CASCADE_SCALE_IMAGE + CASCADE_FIND_BIGGEST_OBJECT); -} -``` - -我们可能需要调整控制人脸检测的两个参数:金字塔比例因子和邻居数。 金字塔比例因子用于创建图像金字塔,检测器将尝试在其中查找人脸。 这就是多尺度检测的实现方式,因为裸探测器有固定的孔径。 在图像金字塔的每一步中,图像都会按此比例缩小,因此较小的比例(接近 1.0)会产生更多图像,运行时间更长,但结果更准确。 我们还控制了一些邻居的下限。 这在级联分类器在非常接近的情况下具有多个正面分类时开始起作用。 这里,我们指示总体分类仅在其至少有三个相邻的正面分类时才返回一个面界。 较小的数字(整数,接近 1)将返回更多检测,但也会导致误报。 - -我们必须从 OpenCV 提供的模型初始化级联分类器(序列化模型的 XML 文件在`$OPENCV_ROOT/data/haarcascades`目录中提供)。 我们在正面人脸上使用标准训练的分类器,演示如下: - -```cpp -const string cascade_name = "$OPENCV_ROOT/data/haarcascades/haarcascade_frontalface_default.xml"; - -CascadeClassifier face_cascade; -if (not face_cascade.load(cascade_name)) { - cerr << "Cannot load cascade classifier from file: " << cascade_name << endl; - return -1; -} - -// ... obtain an image in img - -vector faces; -faceDetector(img, faces, face_cascade); - -// Check if any faces were detected or not -if (faces.size() == 0) { - cerr << "Cannot detect any faces in the image." << endl; - return -1; -} -``` - -下面的屏幕截图显示了面部检测器结果的可视化: - -![](img/e12e8570-d12a-48b2-a18f-72f6b654b54c.png) - -面部标记检测器将绕过检测到的面部,从边界框开始。 但是,首先我们必须初始化`cv::face::Facemark`对象,如下所示: - -```cpp -#include - -using namespace cv::face; - -// ... - -const string facemark_filename = "data/lbfmodel.yaml"; -Ptr facemark = createFacemarkLBF(); -facemark->loadModel(facemark_filename); -cout << "Loaded facemark LBF model" << endl; - -``` - -`cv::face::Facemark`抽象 API 用于所有地标检测器风格,并根据特定算法提供用于推理和训练的实现的基本功能。 加载后,可以将`facemark`对象与其`fit`函数一起使用来查找面部形状,如下所示: - -```cpp -vector faces; -faceDetector(img, faces, face_cascade); - -// Check if faces detected or not -if (faces.size() != 0) { - // We assume a single face so we look at the first only - cv::rectangle(img, faces[0], Scalar(255, 0, 0), 2); - - vector > shapes; - - if (facemark->fit(img, faces, shapes)) { - // Draw the detected landmarks - drawFacemarks(img, shapes[0], cv::Scalar(0, 0, 255)); - } -} else { - cout << "Faces not detected." << endl; -} -``` - -地标探测器结果的可视化(使用`cv::face::drawFacemarks`)如以下屏幕截图所示: - -![](img/feabf10c-9e7b-4b73-81de-4a5e0238361a.png) - -# 测量误差 - -从视觉上看,效果似乎很好。 然而,由于我们有基本的真实数据,我们可以选择将其与检测进行分析比较,并得到误差估计。 我们可以使用标准的平均欧几里得距离度量(![](img/783f8992-9486-4158-9d31-060aaeb96190.png))来判断每个预测的地标与地面事实的平均距离: - -```cpp -float MeanEuclideanDistance(const vector& A, const vector& B) { - float med = 0.0f; - for (int i = 0; i < A.size(); ++ i) { - med += cv::norm(A[i] - B[i]); - } - return med / (float)A.size(); -} -``` - -预测(红色)和地面实况(绿色)叠加的可视化结果,如以下屏幕截图所示: - -![](img/68547135-aafb-4a80-a37a-7712587e3b6e.png) - -我们可以看到,对于这些特定的视频帧,所有地标的平均误差大约只有一个像素。 - -# 根据地标估计人脸方向 - -获得面部地标后,我们可以尝试找出人脸的方向。 二维面地标点基本上符合头部的形状。 因此,给定一个通用人头的 3D 模型,我们可以找到多个面部地标的大致对应的 3D 点,如下图所示: - -![](img/081d2f79-9d47-4a24-b4ca-addedd605f60.png) - -# 估计位姿计算 - -根据这些 2D-3D 对应关系,我们可以通过**点-n-透视**(**PNP**)**和**算法来计算头部相对于相机的 3D 姿势(旋转和平移)。 该算法和对象姿态检测的细节超出了本章的范围;但是,我们可以快速合理地解释为什么只有几个 2D-3D 点对应就足以实现这一点。 拍摄上一张照片的相机具有**刚性**变换,这意味着它已相对于对象移动了一定距离,并有一定程度的旋转。 在非常宽泛的术语中,我们可以将图像上的点(相机附近)和对象之间的关系写成如下: - -![](img/35dd4e9f-5afb-4e0b-8d09-8373fe929d30.png) - -这是一个等式,其中![](img/b72bee7a-662e-4db5-8798-c11355134052.png)表示对象的 3D 位置,![](img/f12f37c0-cc8a-475d-8e3d-56539f24848e.png)表示图像中的点。 这个等式还包括一个投影,它由相机内部参数(焦距*f*和中心点*c*)控制,它将 3D 点转换为 2D 图像点,最高可达*s*。如果说我们通过校准相机获得内部参数,或者我们对它们进行近似,我们就需要找到 12 个用于旋转和平移的系数。 如果我们有足够的 2D 和 3D 对应点,我们可以写出一个线性方程组,每个点可以贡献两个方程,来求解所有这些系数。 事实上,事实证明我们不需要 6 个点,因为旋转不到 9 个自由度,我们只需要 4 个点就可以凑合了。 OpenCV 提供了使用`calib3d`模块的`cv::solvePnP`函数查找旋转和平移的实现。 - -我们将 3D 和 2D 点对齐并采用`cv::solvePnP`: - -```cpp -vector objectPoints { - {8.27412, 1.33849, 10.63490}, //left eye corner - {-8.27412, 1.33849, 10.63490}, //right eye corner - {0, -4.47894, 17.73010}, //nose tip - {-4.61960, -10.14360, 12.27940}, //right mouth corner - {4.61960, -10.14360, 12.27940}, //left mouth corner -}; -vector landmarksIDsFor3DPoints {45, 36, 30, 48, 54}; // 0-index - -// ... -vector points2d; -for (int pId : landmarksIDsFor3DPoints) { - points2d.push_back(shapes[0][pId] / scaleFactor); -} - -solvePnP(objectPoints, points2d, K, Mat(), rvec, tvec, true); -``` - -我们根据前面图像的大小估计的相机本征的*K*矩阵。 - -# 将姿势投影到图像上 - -获得旋转和平移后,我们将四个点从对象坐标空间投影到上图:鼻尖、*x*轴方向、*y*轴方向和*z*轴方向,并绘制上图中的箭头: - -```cpp -vector objectPointsForReprojection { - objectPoints[2], // tip of nose - objectPoints[2] + Point3f(0,0,15), // nose and Z-axis - objectPoints[2] + Point3f(0,15,0), // nose and Y-axis - objectPoints[2] + Point3f(15,0,0) // nose and X-axis -}; - -//... - -vector projectionOutput(objectPointsForReprojection.size()); -projectPoints(objectPointsForReprojection, rvec, tvec, K, Mat(), projectionOutput); -arrowedLine(out, projectionOutput[0], projectionOutput[1], Scalar(255,255,0)); -arrowedLine(out, projectionOutput[0], projectionOutput[2], Scalar(0,255,255)); -arrowedLine(out, projectionOutput[0], projectionOutput[3], Scalar(255,0,255)); -``` - -这将显示面指向的方向,如以下屏幕截图所示: - -![](img/4f70801a-71a0-4748-bc55-bd32319d2e87.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,我们学习了如何使用 OpenCV 的`face contrib`模块和`cv::Facemark`接口来检测图像中的人脸地标,然后使用带有`cv::solvePnP()`的地标找到人脸的大致方向。 这些 API 简单明了,但却具有强大的冲击力。 有了地标检测的知识,就可以实现许多令人兴奋的应用,如增强现实、人脸交换、识别和木偶表演。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/16.md b/trans/build-cv-proj-opencv4-cpp/16.md deleted file mode 100644 index 86f43b15..00000000 --- a/trans/build-cv-proj-opencv4-cpp/16.md +++ /dev/null @@ -1,737 +0,0 @@ -# 基于深卷积网络的车牌识别 - -本章向我们介绍为**自动车牌识别**(**ANPR**)创建应用所需的步骤。 根据不同的情况,有不同的方法和技术;例如,红外摄像机,固定的汽车位置,以及光线条件。 我们可以继续构建一个 ANPR 应用,在距离汽车 2 到 3 米的距离拍摄的照片中,在模糊的灯光条件下,在非平行地面上检测汽车牌照,车牌上的透视失真很小。 - -本章的主要目的是介绍图像分割与特征提取、模式识别基础知识,以及使用**卷积网络的**支持向量机**(**SVM**)和**深度神经网络**[**DNN**)这两种重要的模式识别算法。 在本章中,我们将介绍以下主题:** - -* ANPR -* 板材检测 -* 车牌识别 - -# ANPR 简介 - -ANPR 有时也被称为**自动车牌识别**、(**ALPR**)、**自动车辆识别**、(**AVI**)或**车牌识别**、(**CPR**),是一种智能监控方法,它使用**光学字符识别**和(**OCR**) - -使用**红外线**和(**IR**)摄像头可以获得最好的 ANPR 识别系统的结果,因为检测和 OCR 分割的分割步骤简单而干净,而且它们将错误降至最低。 这要归功于光的定律,最基本的一条就是入射角等于反射角。 当我们看到平滑的表面(如平面镜)时,我们可以看到这种基本反射。 纸等粗糙表面的反射会导致一种被称为散射反射或漫反射的反射。 然而,大多数国家的板块都有一个特殊的特征,称为回射:板块的表面由一种材料制成,这种材料覆盖着数千个微小的半球,使光线反射回源,如我们在下图中所看到的: - -![](img/5969b541-0461-44e3-a2ad-308c952abc4f.png) - -如果我们使用带有滤光片耦合的结构化红外线投影仪的摄像头,我们可以只检索红外光,然后我们就有了非常高质量的图像进行分割,随后我们可以独立于任何照明环境来检测和识别车牌号,如下图所示: - -![](img/7ebb0eb8-9749-4003-b98d-457cb7b82a54.png) - -在本章中,我们将不使用红外照片;我们将使用常规照片,因此我们不会获得最佳结果,而且我们会获得比使用红外相机更高水平的检测错误和更高的错误识别率。 但是,两者的步骤是相同的。 - -每个国家都有不同的汽车牌照大小和规格。 了解这些规范对于获得最佳结果和减少错误非常有用。 本章中使用的算法旨在解释 ANPR 的基础知识,并为西班牙使用的车牌而设计,但我们可以将其扩展到任何国家或规范。 - -在本章中,我们将使用来自西班牙的车牌。 在西班牙,车牌有三种不同的大小和形状,但我们将只使用最常见的(大)车牌,它的宽度为 520 毫米,高度为 110 毫米。 两组字符以 41 mm 的间距分隔,每个单独的字符以 14 mm 的间距分隔。 第一组字符是四位数字,第二组是三个字母,不包括元音*A*、*E*、*I*、*O*或*U*,或者字母*N*或*Q*。 所有字符的尺寸均为 45 mm×77 mm。 - -该数据对于字符分割非常重要,因为我们可以同时检查字符和空格,以验证我们得到的是字符,而不是其他图像分段: - -![](img/972e59c8-b52f-4051-8af8-e86e3c4c4d14.png) - -# ANPR 算法 - -在解释完整的 ANPR 算法代码之前,我们需要定义 ANPR 算法中的主要步骤和任务。 车牌识别分为两个主要步骤,车牌检测和车牌识别: - -* 车牌检测的目的是检测车牌在整个摄像机画面中的位置。 -* 当在图像中检测到车牌时,车牌分段被传递到第二步(车牌识别),该步骤使用 OCR 算法来确定车牌上的字母数字字符。 - -在下图中,我们可以看到两个主要的算法步骤,车牌检测和车牌识别。 在这些步骤之后,程序会在相机图像中绘制已检测到的车牌字符。 算法可能返回错误结果,也可能不返回任何结果: - -![](img/a56fb13f-bc4a-4ae5-b3b0-a5f9e433011f.png) - -在上图所示的每个步骤中,我们将定义模式识别算法中常用的另外三个步骤。 这些步骤如下: - -1. **分割**:此步骤检测并移除图像中的每个感兴趣的补丁/区域。 -2. **特征提取**:此步骤从每个补丁中提取一组特征。 -3. **分类**:该步骤从车牌识别步骤中提取每个字符,或者在车牌检测步骤中将每个图像块分类为*车牌*或*无车牌*。 - -在下图中,我们可以看到应用中的这些模式识别步骤作为一个整体: - -![](img/923e969c-7225-454f-b355-2b72b54db021.png) - -除了主要的应用(其目的是检测和识别车牌号码)之外,我们还将简要说明另外两个通常不会解释的任务: - -* 如何训练模式识别系统 -* 如何评价它? - -然而,这些任务可能比主要应用更重要,因为如果我们没有正确训练模式识别系统,我们的系统可能会失败,无法正常工作;不同的模式需要不同的训练和评估过程。 我们需要根据不同的环境、条件和功能对我们的系统进行评估,以获得最佳结果。 这两个任务有时是一起完成的,因为不同的功能可能会产生不同的结果,我们可以在评估部分看到这一点。 - -# 板材检测 - -在这一步中,我们必须对当前摄像机帧中的所有底片进行检测。 为此,我们将其分为两个主要步骤:分段和分段分类。 由于我们使用图像块作为矢量特征,所以没有解释特征步骤。 - -在第一步(分割)中,我们将应用不同的过滤器、形态学操作、轮廓算法和验证来检索可能包含车牌的图像部分。 - -在第二步(分类)中,我们将应用支持向量机分类器来识别每个图像块,也就是我们的特征。 在创建我们的主应用之前,我们将使用两个不同的类进行训练:*板块*和*非板块*。 我们将使用 800 像素宽的平行正面视图彩色图像,这些图像距离汽车 2 到 4 米。 这些要求对于正确分割非常重要。 如果我们创建一个多尺度图像算法,我们就可以进行检测。 - -在下图中,我们将展示车牌检测涉及的每个进程: - -![](img/9d3af586-0216-44b9-af74-4764430c2a4d.png) - -涉及的程序如下: - -* Sobel 滤波器 -* 阈值操作 -* 封闭形态运算 -* 其中一个填充区域的蒙版 -* 红色,可能检测到的车牌(图片特色) -* 用支持向量机分类器检测车牌 - -# 分割 - -分割是将一幅图像分割成多个片段的最新过程。 这一过程是为了简化用于分析的图像,并使特征提取更容易。 - -车牌分割的一个重要特征是车牌中垂直边缘的数量很多,假设图像是正面拍摄的,车牌没有旋转,也没有透视失真。 可以在第一个分割步骤中利用此功能来消除没有任何垂直边缘的区域。 - -在找到垂直边缘之前,我们需要将彩色图像转换为灰度图像(因为颜色不能帮助我们完成此任务),并删除相机或其他环境噪声可能产生的噪声。 我们将应用 5x5 高斯模糊并去除噪波。 如果我们不应用噪声去除方法,我们可能会得到许多产生失败检测的垂直边缘: - -```cpp -//convert image to gray -Mat img_gray; -cvtColor(input, img_gray, CV_BGR2GRAY); -blur(img_gray, img_gray, Size(5,5)); -``` - -要找到垂直边缘,我们将使用第一个`Sobel`过滤器并找到第一个水平导数。 导数是一个数学函数,可以让我们找到图像上的垂直边缘。OpenCV 中的`Sobel`函数定义如下: - -```cpp -void Sobel(InputArray src, OutputArray dst, int ddepth, int xorder, int yorder, int ksize=3, double scale=1, double delta=0, int borderType=BORDER_DEFAULT ) -``` - -这里,*`ddepth`是目标图像深度;*`xorder`是*x*的导数阶数;*`yorder`是*y*的导数阶数;*`ksize`是一、三、五、七的核大小;*`scale`是计算导数值的可选因子;*`delta`是加到结果上的可选值;以及*`borderType`是像素插值 - -然后,对于我们的情况,我们可以使用`xorder=1`、`yorder=0`、和`ksize=3`: - -```cpp -//Find vertical lines. Car plates have high density of vertical -lines -Mat img_sobel; -Sobel(img_gray, img_sobel, CV_8U, 1, 0, 3, 1, 0); -``` - -在应用`Sobel`滤波器之后,我们将应用阈值滤波器来获得具有通过 Otsu 方法获得的阈值的二值图像。 Otsu 的算法需要 8 位输入图像,Otsu 的方法自动确定最佳阈值: - -```cpp -//threshold image -Mat img_threshold; -threshold(img_sobel, img_threshold, 0, 255, CV_THRESH_OTSU+CV_THRESH_BINARY); -``` - -要在 Threshold 函数中定义 Otsu 的方法,我们将把 type 参数与 Threshold`CV_THRESH_OTSU`的值结合起来,并忽略 Threshold 参数。 - -When the `CV_THRESH_OTSU` value is defined, the threshold function returns the optimal threshold value obtained by Otsu's algorithm. - -通过应用封闭的形态运算,我们可以删除每条垂直边缘线之间的空格,并连接所有具有大量边缘的区域。 在这一步中,我们有可能包含板块的区域。 - -首先,我们将定义在形态操作中使用的结构元素。 在我们的例子中,我们将使用`getStructuringElement`函数来定义尺寸为`17`乘以`3`的结构矩形元素;这在其他图像大小中可能有所不同: - -```cpp -Mat element = getStructuringElement(MORPH_RECT, Size(17, 3)); -``` - -然后,我们将使用`morphologyEx`函数在封闭的形态运算中使用此结构元素: - -```cpp -morphologyEx(img_threshold, img_threshold, CV_MOP_CLOSE, element); -``` - -在我们应用了这些功能之后,我们在图像中有可能包含车牌的区域;但是,大多数区域都不包含车牌。 可以通过连通分量分析或使用`findContours`函数来分割这些区域。 最后一个函数使用不同的方法和结果检索二值图像的轮廓。 我们只需要获得具有任何层次关系和任何多边形近似结果的外轮廓: - -```cpp -//Find contours of possibles plates - vector< vector< Point>> contours; - findContours(img_threshold, - contours, // a vector of contours - CV_RETR_EXTERNAL, // retrieve the external contours - CV_CHAIN_APPROX_NONE); // all pixels of each contours -``` - -对于检测到的每个轮廓,提取最小面积的边界矩形。 OpenCV 会调出此任务的`minAreaRect`函数。 此函数返回旋转后的`RotatedRect`个 Rectangle 类。 然后,在每个轮廓上使用向量迭代器,我们可以得到旋转的矩形,并在对每个区域进行分类之前进行一些初步验证: - -```cpp -//Start to iterate to each contour founded - vector>::iterator itc= contours.begin(); - vector rects; - - //Remove patch that has no inside limits of aspect ratio and - area. - while (itc!=contours.end()) { - //Create bounding rect of object - RotatedRect mr= minAreaRect(Mat(*itc)); - if(!verifySizes(mr)){ - itc= contours.erase(itc); - }else{ - ++ itc; - rects.push_back(mr); - } - } -``` - -我们根据区域的面积和纵横比对检测到的区域进行基本验证。 我们会认为,如果一个区域的长宽比约为*520/110=4.727272*(板块宽度除以板块高度),误差幅度为 40%,且区域的高度最小为 1`15`像素,最大为 5`125`像素,则区域可以是板块。 这些值是根据图像大小和相机位置计算的: - -```cpp -bool DetectRegions::verifySizes(RotatedRect candidate ){ - float error=0.4; - //Spain car plate size: 52x11 aspect 4,7272 - const float aspect=4.7272; - //Set a min and max area. All other patchs are discarded - int min= 15*aspect*15; // minimum area - int max= 125*aspect*125; // maximum area - //Get only patches that match to a respect ratio. - float rmin= aspect-aspect*error; - float rmax= aspect+aspect*error; - - int area= candidate.size.height * candidate.size.width; - float r= (float)candidate.size.width - /(float)candidate.size.height; - if(r<1) - r= 1/r; - - if(( area < min || area > max ) || ( r < rmin || r > rmax )){ - return false; - }else{ - return true; - } -} -``` - -我们可以利用车牌的白色背景属性做更多的改进。 所有的板子都有相同的背景颜色,我们可以使用泛洪填充算法来检索旋转的矩形以进行精确的裁剪。 - -裁剪车牌的第一步是在最后旋转的矩形中心附近找到几个种子。 然后,我们将得到介于宽度和高度之间的最小板子大小,并使用它在贴片中心附近生成随机种子。 - -我们希望选择白色区域,并且需要几个种子才能接触到至少一个白色像素。 然后,对于每个种子,我们使用一个`floodFill`函数来绘制新的蒙版图像,以存储新的最近裁剪区域: - -```cpp -for(int i=0; i< rects.size(); i++){ - //For better rect cropping for each possible box - //Make floodFill algorithm because the plate has white background - //And then we can retrieve more clearly the contour box - circle(result, rects[i].center, 3, Scalar(0,255,0), -1); - //get the min size between width and height - float minSize=(rects[i].size.width < rects[i].size.height)? - rects[i].size.width:rects[i].size.height; - minSize=minSize-minSize*0.5; - //initialize rand and get 5 points around center for floodFill algorithm - srand ( time(NULL) ); - //Initialize floodFill parameters and variables - Mat mask; - mask.create(input.rows + 2, input.cols + 2, CV_8UC1); - mask= Scalar::all(0); - int loDiff = 30; - int upDiff = 30; - int connectivity = 4; - int newMaskVal = 255; - int NumSeeds = 10; - Rect ccomp; - int flags = connectivity + (newMaskVal << 8 ) + CV_FLOODFILL_FIXED_RANGE + CV_FLOODFILL_MASK_ONLY; - for(int j=0; j pointsInterest; - Mat_::iterator itMask= mask.begin(); - Mat_::iterator end= mask.end(); - for( ; itMask!=end; ++ itMask) - if(*itMask==255) - pointsInterest.push_back(itMask.pos()); - RotatedRect minRect = minAreaRect(pointsInterest); - if(verifySizes(minRect)){ -``` - -分割过程已经完成,我们得到了有效的区域。 现在,我们可以裁剪每个检测到的区域,删除可能的旋转,裁剪图像区域,调整图像大小,并均衡裁剪图像区域的光线。 - -首先,我们需要生成具有*`getRotationMatrix2D`的变换矩阵,以去除检测到的区域中可能出现的旋转。 我们需要注意高度,因为`RotatedRect`可以返回并以`90`度旋转。 因此,我们必须检查矩形纵横比,如果它小于`1`,则需要将其旋转`90`度: - -```cpp -//Get rotation matrix -float r= (float)minRect.size.width / (float)minRect.size.height; -float angle=minRect.angle; -if(r<1) - angle=90+angle; -Mat rotmat= getRotationMatrix2D(minRect.center, angle,1); -``` - -有了变换矩阵,我们现在可以通过使用`warpAffine`函数的仿射变换(仿射变换保留平行线)来旋转输入图像,其中我们设置了输入和目标图像、变换矩阵、输出大小(与本例中的输入相同)和要使用的插值方法。 如果需要,我们可以定义边框方法和边框值: - -```cpp -//Create and rotate image -Mat img_rotated; -warpAffine(input, img_rotated, rotmat, input.size(), -CV_INTER_CUBIC); -``` - -在旋转图像之后,我们将使用`getRectSubPix`裁剪图像,这将裁剪和复制以点为中心的宽度和高度的图像部分。 如果图像被旋转,我们需要使用 C++ `swap`函数更改宽度和高度大小: - -```cpp -//Crop image -Size rect_size=minRect.size; -if(r < 1) - swap(rect_size.width, rect_size.height); -Mat img_crop; -getRectSubPix(img_rotated, rect_size, minRect.center, img_crop); -``` - -裁剪后的图像不适合用于训练和分类,因为它们的大小不同。 此外,每幅图像包含不同的光线条件,突出了它们之间的差异。 要解决此问题,我们将所有图像的大小调整为相同的宽度和高度,并应用光线直方图均衡化: - -```cpp -Mat resultResized; -resultResized.create(33,144, CV_8UC3); -resize(img_crop, resultResized, resultResized.size(), 0, 0, INTER_CUBIC); -//Equalize croped image -Mat grayResult; -cvtColor(resultResized, grayResult, CV_BGR2GRAY); -blur(grayResult, grayResult, Size(3,3)); -equalizeHist(grayResult, grayResult); -``` - -对于每个检测到的区域,我们将裁剪的图像及其位置存储在向量中: - -```cpp -output.push_back(Plate(grayResult,minRect.boundingRect())); -``` - -现在我们已经有了可能检测到的区域,我们必须对每个可能的区域是否为板块进行分类。 在下一节中,我们将学习如何基于支持向量机创建分类。 - -# 分类 / 同 taxonomy / 种类 / 类别,等级 - -在我们对图像的所有可能部分进行预处理和分割之后,我们现在需要确定每个部分是否是(或不是)车牌。 要做到这一点,我们将使用一种改进的支持向量机算法。 - -支持向量机是一种模式识别算法,它包括在最初为二进制分类创建的监督学习算法家族中。 监督学习是一种用标记数据训练的机器学习算法技术。 我们需要用标记的数据量训练算法;每个数据集都需要有一个类。 - -支持向量机创建一个或多个超平面,用于区分每类数据。 - -经典的示例是定义了两个类的 2D 点集;SVM 搜索区分每个类的最佳直线: - -![](img/e4deb063-0ea2-40b9-a5ea-079ad2a1bd16.png) - -在任何一个新的分类器之前,第一个任务就是培训我们的分类器;这是在主应用之前要承担的一项工作,也就是我们所说的“线下培训”。 这不是一件容易的工作,因为它需要足够的数据来训练系统,但更大的数据集并不总是意味着最好的结果。 在我们的案例中,由于没有公开的车牌数据库,我们没有足够的数据。 因此,我们需要拍摄数百张汽车照片,然后对它们进行预处理和分割。 - -我们用 75 张车牌图像和 35 张没有车牌的图像训练了我们的系统,分辨率为 144x33 像素。 我们可以在下图中看到此数据的示例。 这不是一个很大的数据集,但足以为我们的章节获得令人满意的结果。 在实际应用中,我们需要使用更多数据进行训练: - -![](img/14fd7d4a-cd14-4f8c-86d9-d32fb63013f2.png) - -为了容易理解机器学习的工作原理,我们将继续使用分类器算法的图像像素特征(请记住,有更好的方法和特征来训练 SVM,例如**主成分分析**)(**PCA**)、傅立叶变换和纹理分析)。 - -为了保存图像,我们需要使用`DetectRegions`类重新创建图像以训练我们的系统,并将`savingRegions`变量设置为`true`。 我们可以使用命令`segmentAllFiles.sh`的 bash 脚本对文件夹中的所有图像文件重复该过程。 这可以从本书的源代码中获取。 - -为了方便起见,我们将把所有经过处理和准备的图像训练数据存储到一个 XML 文件中,以便直接与 SVM 函数一起使用。 `trainSVM.cpp `应用使用文件夹和图像文件数量创建此文件。 - -Training data for a machine learning OpenCV algorithm is stored in an *N*x*M* matrix, with *N* samples and *M* features. Each dataset is saved as a row in the training matrix.  - -这些类存储在另一个大小为`nx1`的矩阵中,其中每个类由一个`float`数字标识。 - -OpenCV 提供了一种非常简单的方法来管理 XML 或 YAML 格式的数据文件,它使用`FileStorage`类。 这个类允许我们存储和读取 OpenCV 变量和结构,或者我们的自定义变量。 使用此功能,我们可以读取培训数据矩阵和培训课程,并将其保存在`SVM_TrainingData`和`SVM_Classes`中: - -```cpp -FileStorage fs; -fs.open("SVM.xml", FileStorage::READ); -Mat SVM_TrainingData; -Mat SVM_Classes; -fs["TrainingData"] >>SVM_TrainingData; -fs["classes"] >>SVM_Classes; -``` - -现在,我们在`SVM_TrainingData`变量中有训练数据,在`SVM_Classes`中有标签。 然后,我们只需创建连接要在我们的机器学习算法中使用的数据和标签的训练数据对象。 为此,我们将使用`TrainData`类作为 OpenCV 指针`Ptr`类,如下所示: - -```cpp -Ptr trainData = TrainData::create(SVM_TrainingData, ROW_SAMPLE, SVM_Classes); -``` - -我们将使用`SVM`类创建分类器对象,使用`Ptr`创建分类器对象,或使用 OpenCV 4 创建分类器对象`std::shared_ptr`OpenCV 类: - -```cpp -Ptr svmClassifier = SVM::create() -``` - -现在,我们需要设置支持向量机参数,这些参数定义了在支持向量机算法中使用的基本参数。 要做到这一点,我们只需更改一些对象变量。 经过不同的实验,我们将选择下一个参数的设置: - -```cpp -svmClassifier->setTermCriteria(TermCriteria(TermCriteria::MAX_ITER, 1000, 0.01)); -svmClassifier->setC(0.1); -svmClassifier->setKernel(SVM::LINEAR); -``` - -我们选择了`1000`次进行训练,对`0.1`进行了`C`个参数变量优化,最后选择了一个核函数。 - -我们只需要用`train`函数和训练数据训练我们的分类器: - -```cpp -svmClassifier->train(trainData); -``` - -我们的分类器已经准备好使用我们的 SVM 类的预测函数来预测可能的裁剪图像;该函数返回类标识符`i`。 在我们的例子中,我们将把*plate*类标记为 1,将*no plate*类标记为 0。 然后,对于每个检测到的可能是车牌的区域,我们将使用支持向量机将其分类为*车牌*或*无车牌*,并且只保存正确的响应。 以下代码是称为在线处理的主应用的一部分: - -```cpp -vector plates; -for(int i=0; i< posible_regions.size(); i++) -{ - Mat img=posible_regions[i].plateImg; - Mat p= img.reshape(1, 1);//convert img to 1 row m features - p.convertTo(p, CV_32FC1); - int response = (int)svmClassifier.predict( p ); - if(response==1) - plates.push_back(posible_regions[i]); -} -``` - -# 车牌识别 - -车牌识别的第二步是用 OCR 检索车牌字符。 对于每个检测到的车牌,我们对每个字符进行车牌分割,并使用**人工神经网络**的机器学习算法来识别字符。 另外,在本节中,您将学习如何评估分类算法。 - -# OCR 分割 - -首先,我们将首先获得一个车牌图像块作为 OCR 分割函数的输入,并使用均衡化的直方图。 然后,我们只需要应用阈值过滤器,并使用该阈值图像作为查找轮廓算法的输入。 我们可以在下图中观察到这一过程: - -![](img/9df07c69-7b81-4463-9f30-a9f550386f7b.png) - -此分段过程编码如下: - -```cpp -Mat img_threshold; -threshold(input, img_threshold, 60, 255, CV_THRESH_BINARY_INV); -if(DEBUG) - imshow("Threshold plate", img_threshold); - Mat img_contours; - img_threshold.copyTo(img_contours); - //Find contours of possibles characters - vector< vector< Point>> contours; - findContours(img_contours, contours, // a vector of contours - CV_RETR_EXTERNAL, // retrieve the external contours - CV_CHAIN_APPROX_NONE); // all pixels of each contours -``` - -我们使用参数`CV_THRESH_BINARY_INV`来反转阈值输出,方法是将白色输入值设置为黑色,将黑色输入值设置为白色。 这是获取每个字符的轮廓所必需的,因为轮廓算法搜索白色像素。 - -对于每个检测到的轮廓,我们可以进行尺寸验证,并删除尺寸较小或纵横比不正确的所有区域。 在我们的例子中,字符的纵横比为 45/77,对于旋转或扭曲的字符,我们可以接受 35%的纵横比误差。 如果面积大于 80%,我们将认为该区域是黑色块而不是字符。 为了计算面积,我们可以使用`countNonZero`函数,该函数计算数值大于零的像素数: - -```cpp -bool OCR::verifySizes(Mat r){ - //Char sizes 45x77 - float aspect=45.0f/77.0f; - float charAspect= (float)r.cols/(float)r.rows; - float error=0.35; - float minHeight=15; - float maxHeight=28; - //We have a different aspect ratio for number 1, and it can be ~0.2 - float minAspect=0.2; - float maxAspect=aspect+aspect*error; - //area of pixels - float area=countNonZero(r); - //bb area - float bbArea=r.cols*r.rows; - //% of pixel in area - float percPixels=area/bbArea; - if(percPixels < 0.8 && charAspect > minAspect && charAspect < - maxAspect && r.rows >= minHeight && r.rows < maxHeight) - return true; - else - return false; -} -``` - -如果一个分割后的字符被验证,我们必须对其进行预处理,以便为所有字符设置相同的大小和位置,并使用辅助的`CharSegment`类将其保存在一个向量中。 这个类保存了分割的字符图像和我们对字符进行排序所需的位置,因为查找轮廓算法不会按所需的正确顺序返回轮廓。 - -# 基于卷积神经网络的字符分类 - -在我们开始使用卷积神经网络和深度学习之前,我们将介绍这些主题和创建 DNN 的工具。 - -深度学习是机器学习家族的一部分,可以是有监督的,也可以是半监督的,也可以是无监督的。 在科学界,DNN 并不是一个新概念。 这个术语于 1986 年由 Rina Dechter 引入机器学习领域,并于 2000 年由 Igor Aizenberg 引入人工神经网络。 但这一领域的研究始于 1980 年初,当时的研究如新认知电子管是卷积神经网络的灵感来源。 - -但深度学习并不是在 2009 年之前开始革命的。 2009 年,随着新的研究算法的出现,硬件方面的进步重新燃起了人们对深度学习的兴趣,使用 NVIDIA 图形处理器来加速训练算法,以前可能需要几天或几个月的时间,现在速度提高了 100 倍。 - -卷积神经网络,ConvNet,或 CNN,是一类基于前馈网络的深度学习算法,主要应用于计算机视觉。 CNN 使用多层感知器的一种变体,允许我们自动提取移位不变特征。 与人工经典机器学习相比,CNN 使用的预处理相对较少。 与其他机器学习算法相比,特征提取是一个主要优势。 - -卷积神经网络像经典的人工神经网络一样,由具有多个隐含层的输入输出层组成,不同之处在于输入通常是图像的原始像素,隐含层由卷积层和汇聚层组成,完全连通或归一化。 - -现在,我们来简要介绍一下卷积神经网络中最常用的几个层: - -* **卷积:**本层对输入应用卷积运算过滤器,将结果传递给下一层。 这一层的工作原理类似于典型的计算机视觉过滤器(Sobel、Canny 等),但内核过滤器是在训练阶段学习的。 使用这一层的主要好处是减少了常见的完全连接的前馈神经网络,例如,100 x 100 图像有 10,000 个权重,但使用 CNN,问题就减少到了核大小;例如,应用 5 x 5 和 32 个不同过滤器的核,只有*5*5*32=800*。 同时,这些过滤器激发了特征提取的所有可能性。 -* **汇集:**这一层将一组神经元的输出合并成一个单一的。 最常见的是 max pooling,它返回输入神经元组的最大值。 深度学习中另一种经常使用的方法是平均汇集。 这一层为 CNN 带来了在随后的层中提取更高级别特征的可能性。 -* **FLATEN:**FLATEN 不是 DNN 层,而是将矩阵转换为简单向量的常见操作;此步骤是应用其他层并最终获得分类所必需的。 -* **完全连接:**这与传统的多层感知器相同,在传统的多层感知器中,前一层的每个神经元都通过激活功能连接到下一层。 -* **Dropout:**-这一层是减少过拟合的正则化;-它是对模型执行精确度的常用层。 -* **损失层:**这通常是 DNN 中的最后一层,并指定如何训练和计算误差以执行预测。 一种非常常见的损失层是用于分类的 Softmax。 - -OpenCV 深度学习并不是为培训深度学习模型而设计的,也不受支持,因为有非常稳定和强大的开源项目只专注于深度学习,如 TensorFlow、Caffe 和 Torch。 然后,OpenCV 有一个接口来导入和读取最重要的模型。 - -然后,我们将在 TensorFlow 中开发我们的 CNN 用于 OCR 分类,TensorFlow 是最常用和最流行的深度学习软件库之一,最初是由谷歌研究人员和工程师开发的。 - -# 用 TensorFlow 创建和训练卷积神经网络 - -本节将探讨如何训练新的 TensorFlow 模型,但在开始创建模型之前,我们必须检查图像数据集并生成训练模型所需的资源。 - -# 准备数据 - -我们有 30 个字符和数字,分布在数据集中的 702 个图像上,分布如下。 我们可以检查到有超过 30 个数字图像,但一些字母,如**K**、**M**和**P**,图像样本较少: - -![](img/2ea06723-d657-42d6-8514-4790e08603e6.png) - -在下图中,我们可以看到数据集中的一小部分图像: - -![](img/11f0f23a-4dce-468f-b41c-7a4aeb9d313f.png) - -对于深度学习来说,这个数据集非常小。 深度学习需要大量的样本,是一种常用的技术。 在某些情况下,可以在原始数据集上使用数据集扩充。 数据集增强是一种通过应用不同的变换(如旋转、翻转图像、透视扭曲和添加噪波)来创建新样本的方法。 - -有多种方法可以扩充数据集:我们可以创建自己的脚本或使用开源库来完成此任务。 我们将使用增强器([https://github.com/mdbloice/Augmentor](https://github.com/mdbloice/Augmentor))。 Augmentor 是一个 Python 库,它允许我们通过应用我们认为对我们的问题更方便的转换来创建所需数量的样本。 - -要通过`pip`安装 Augmentor,我们必须执行以下命令: - -```cpp -pip install Augmentor -``` - -在安装库之后,我们创建一个小的 Python 脚本来生成并增加改变变量`number_samples`的样本数量,并应用以下内容:随机扭曲、剪切以及扭曲和旋转扭曲,如我们在下一个 Python 脚本中所看到的: - -```cpp -import Augmentor -number_samples = 20000 -p = Augmentor.Pipeline("./chars_seg/chars/") -p.random_distortion(probability=0.4, grid_width=4, grid_height=4, magnitude=1) -p.shear(probability=0.5, max_shear_left=5, max_shear_right=5) -p.skew_tilt(probability=0.8, magnitude=0.1) -p.rotate(probability=0.7, max_left_rotation=5, max_right_rotation=5) -p.sample(number_samples) -``` - -此脚本将生成一个输出文件夹,其中将存储所有图像,并保持与原始路径相同的路径。 我们需要生成两个数据集,一个用于训练,另一个用于测试我们的算法。 然后,我们将通过更改`number_samples`生成 20,000 个用于训练的图像和 2,000 个用于测试的图像中的一个。 - -现在我们有了足够的图像,我们必须将它们提供给 TensorFlow 算法。 TensorFlow 允许多种输入数据格式,例如带有图像和标签的 CSV 文件、Numpy 数据文件以及推荐的 TFRecordDataset。 - -Visit [http://blog.damiles.com/2018/06/18/tensorflow-tfrecodataset.html](http://blog.damiles.com/2018/06/18/tensorflow-tfrecodataset.html) for more info about why it is better to use TFRecordDataset instead of CSV files with image references. - -在生成 TFRecordDataset 之前,我们需要安装 TensorFlow 软件。 我们可以使用`pip`和以下针对 CPU 的命令来安装它: - -```cpp -pip install tensorflow -``` - -或者,如果您有支持 CUDA 的 NVIDIA 卡,您可以使用 GPU 发行版: - -```cpp -pip install tensorflow-gpu -``` - -现在,我们可以创建数据集文件来训练我们的模型,使用提供的脚本`create_tfrecords_from_dir.py`,传递两个参数,即图像所在的输入文件夹和输出文件。 我们必须调用此脚本两次,一次用于培训,另一次用于测试,以分别生成这两个文件。 我们可以在下面的代码片断中看到该调用的示例: - -```cpp -python ./create_tfrecords_from_dir.py -i ../data/chars_seg/DNN_data/test -o ../data/chars_seg/DNN_data/test.tfrecords -python ./create_tfrecords_from_dir.py -i ../data/chars_seg/DNN_data/train -o ../data/chars_seg/DNN_data/train.tfrecords -``` - -该脚本生成两个`test.tfrecords`和`train.tfrecords`文件,其中标签是自动分配的编号,并按文件夹名称排序。 `train`文件夹必须具有以下结构: - -![](img/d3417ff5-8b75-427c-8f5e-d3bc3e1eca98.png) - -现在,我们有了数据集,我们已经准备好创建我们的模型,并开始训练和评估。 - -# 创建 TensorFlow 模型 - -TensorFlow 是一个开源软件库,专注于高性能数值计算和深度学习,可访问和支持 CPU、GPU 和 TPU(张量处理单元,专用于深度学习的新 Google 硬件)。 这个库不是一个容易的库,学习曲线很高,但引入 Kera(TensorFlow 之上的库)作为 TensorFlow 的一部分,让学习曲线变得更容易,但仍然需要巨大的学习曲线本身。 - -在这一章中,我们不能解释如何使用 TensorFlow,因为我们需要单独为这个主题编写一本书,但我们将解释我们将使用的 CNN 的结构。 我们将展示如何使用名为 TensorEditor 的在线可视化工具在几分钟内生成 TensorFlow 代码,我们可以下载这些代码并在计算机上进行本地训练,或者如果我们没有足够的计算机处理能力,也可以使用相同的在线工具来训练我们的模型。 如果您想阅读和学习 TensorFlow,我们建议您阅读任何相关的 Packt Publishing 书籍或 TensorFlow 教程。 - -我们将要创建的 CNN 层结构是一个简单的卷积网络: - -* **卷积层 1:**32 个 5x5 带 REU 激活功能的滤波器 -* **池化第 2 层:**使用 2 x 2 过滤器和跨度为 2 的最大池化 -* **卷积层 3:**64 个 5x5 滤波器,具有 RELU 激活功能 -* **池化层 4:**使用 2 x 2 过滤器和跨度为 2 的最大池化 -* **致密层 5:↔**1,024 个神经元 -* **丢包层 6:**丢包率为 0.4 的丢包率正则化 -* **致密层 7:包含**30 个神经元,每个数字和字符对应一个神经元 -* **SoftMax Layer 8:**SoftMax Layer Lost 函数,采用梯度下降优化器,学习率为 0.001,训练步数为 20,000 步。 - -我们可以在下图中看到我们必须生成的模型的基本图形: - -![](img/2e1bf06b-9d9f-48b2-abd1-e2ea99e81487.png) - -TensorEditor 是一个在线工具,它允许我们为 TensorFlow 创建模型并在云上进行训练,或者下载 Python2.7 代码并在本地执行。 注册在线免费下载工具后,即可生成模型,如下图所示: - -![](img/2c2f2f0b-6ed5-4a87-bca7-845eaa61fac6.png) - -要添加一个层,我们通过单击左侧菜单来选择它,它将出现在编辑器中。 我们可以拖放来改变它的位置,双击来改变它的参数。 点击每个节点的小点,我们就可以链接到每个节点/层。 这个编辑器向我们展示了我们视觉上选择的参数和每个层的输出大小;我们可以在下图中看到,卷积层的核为 5 x 5 x 32,输出为 n x 20 x 20 x 32;n 变量表示我们可以为每个训练周期同时计算一幅或多幅图像: - -![](img/83fdfd62-b829-4b79-b2d3-f6fadb59a96f.png) - -在 TensorEditor 中创建 CNN 层结构后,我们现在可以通过单击 Generate Code 并下载 Python 代码来下载 TensorFlow 代码,如以下屏幕截图所示: - -![](img/d2a2ae76-9792-4480-86c9-e4867b21fc5b.png) - -现在,我们可以通过以下命令使用 TensorFlow 开始训练我们的算法: - -```cpp -python code.py --job-dir=./model_output -``` - -这里,参数`--job-dir`定义了存储训练好的输出模型的输出文件夹。 在终端中,我们可以看到每一次迭代的输出,以及损失结果和精度。 我们可以在下面的截图中看到一个示例: - -![](img/24c6ccfc-4706-4d7a-92b3-890c5c804537.png) - -Output of the algorithm training command - -我们可以使用 TensorBoard,一个 TensorFlow 工具,它给我们提供了关于训练和图表的信息。 要激活 TensorBoard,我们必须使用以下命令: - -```cpp -tensorboard --logdir ./model_output -``` - -在这里,必须确定保存模型和检查点的参数`--logdir`。 启动 TensorBoard 后,我们可以通过以下 URL 访问它:`http://localhost:6006`。 这个很棒的工具向我们展示了 TensorFlow 生成的图形,我们可以在其中浏览每个操作和变量,单击每个节点,如我们在下一个屏幕截图中所示: - -![](img/488604ac-d342-45d1-87b9-10d26a41ecff.png) - -TensorBoard GRAPHS - -或者,我们可以研究所获得的结果,例如每个历元步长的损失值或精度度量。 使用每个纪元的训练模型获得的结果显示在以下屏幕截图中: - -![](img/6352064c-fbcb-48c5-82c8-600b3348a775.png) - -在一台配备 8 GB 内存的 i7 6700HQ 处理器上进行培训需要很长时间,大约 50 个小时;两天多一点的培训。 如果您使用基本的 NVIDIA GPU,此任务可以减少到大约 2-3 个小时。 - -如果您想在 TensorEditor 中进行训练,可能需要 10-15 分钟,训练完模型后会下载模型,可以下载完整的输出模型,也可以下载冻结优化后的模型。 我们将在下一节*为 OpenCV*准备模型的过程中介绍冷冻的基本概念。 我们可以在下一个屏幕截图中看到 TensorEditor 中的培训结果: - -![](img/44f39db1-941f-4538-969b-5e19f2538993.png) - -Training in TensorEditor - -分析得到的结果,我们获得了 96%左右的准确率水平,比本书第二版中解释的旧算法要好得多,在旧算法中,我们使用特征提取和简单的人工神经网络的准确率只有 92%。 - -完成培训后,所有模型和变量都存储在启动 TensorFlow 脚本时定义的作业文件夹中。 现在,我们必须准备完成的结果,以便将其集成并导入到 OpenCV 中。 - -# 为 OpenCV 准备模型 - -TensorFlow 在我们训练新模型时会生成多个文件,为存储每个步骤中获得的准确性和损失以及其他指标的事件生成文件;此外,一些文件还会存储每个步骤或检查点获得的变量结果。 这些变量是网络在训练中学习的权重。 但在生产中共享所有这些文件并不方便,因为 OpenCV 无法管理它们。 同时,还有一些节点只用于训练而不用于推理。 我们必须从模型中删除这些节点,例如 Dropout 层或训练输入迭代器。 - -要把我们的车型投产,我们需要做好以下几个方面的工作: - -* 冻结我们的图表 -* 移除不需要的节点/层 -* 针对推理进行优化 - -*冻结*获取图形定义和一组检查点,并将它们合并到单个文件中,将变量转换为常量。 要冻结我们的模型,我们必须移动到保存的模型文件夹中,并执行 TensorFlow 提供的以下脚本: - -```cpp -freeze_graph --input_graph=graph.pbtxt --input_checkpoint=model.ckpt-20000 --output_graph frozen_graph.pb --output_node_names=softmax_tensor -``` - -现在,我们生成一个名为*Freeze_raph.pb 的新文件*,它是合并并冻结的图形。 然后,我们必须删除用于训练目的的输入层。 如果我们使用 TensorBoard 查看图表,我们可以看到我们对第一个卷积神经网络的输入是`IteratorGetNext`节点,我们必须将其剪切并设置为一个通道的 20x20 像素图像的单层输入。 然后,我们可以使用 TensorFlow*Transform_graph*应用,该应用允许我们更改图形、剪切或修改 TensorFlow 模型图形。 要删除连接到 ConvNet 的层,我们执行以下代码: - -```cpp -transform_graph --in_graph="frozen_graph.pb" --out_graph="frozen_cut_graph.pb" --inputs="IteratorGetNext" --outputs="softmax_tensor" --transforms='strip_unused_nodes(type=half, shape="1,20,20,1") fold_constants(ignore_errors=true) fold_batch_norms fold_old_batch_norms sort_by_execution_order' -``` - -It's very important to add the `sort_by_execution_order` parameter to ensure that the layers are stored in order in the model graph, to allow OpenCV to correctly import the model. OpenCV sequentially imports the layers from the graph model, checking that all previous layers, operations, or variables are imported; if not, we will receive an import error. TensorEditor doesn't take care of the execution order in the graph to construct and execute it. - -执行`transform_graph`后,我们有一个保存为`frozen_cut_graph.pb`的新模型。 最后一步需要我们优化图表,删除所有训练操作和层,如辍学。 我们将使用以下命令为生产/推理优化我们的模型;此应用由 TensorFlow 提供: - -```cpp -optimize_for_inference.py --input frozen_cut_graph.pb --output frozen_cut_graph_opt.pb --frozen_graph True --input_names IteratorGetNext --output_names softmax_tensor -``` - -它的输出是一个名为`frozen_cut_graph_opt.pb`的文件。 该文件是我们的最终模型,我们可以在 OpenCV 代码中导入并使用它。 - -# 在 OpenCV C++ 代码中导入和使用模型 - -将深度学习模型导入到 OpenCV 非常容易;我们可以从 TensorFlow、Caffe、Torch 和 Darknet 导入模型。 所有导入都非常相似,但在本章中,我们将学习如何导入 TensorFlow 模型。 - -要导入 TensorFlow 模型,我们可以使用`readNetFromTensorflow`方法,该方法只接受两个参数:第一个参数是 Protobuf 格式的模型,第二个参数也是 Protobuf 格式的文本图形定义。 第二个参数不是必需的,但是在我们的例子中,我们必须为推理准备我们的模型,并且我们必须对其进行优化,以便也导入到 OpenCV 中。 然后,我们可以使用以下代码导入模型: - -```cpp -dnn::Net dnn_net= readNetFromTensorflow("frozen_cut_graph_opt.pb"); -``` - -要对我们的车牌的每个检测到的片段进行分类,我们必须将每个图像片段放入我们的`dnn_net`中,并获得概率。 以下是对每个数据段进行分类的完整代码: - -```cpp -for(auto& segment : segments){ - //Preprocess each char for all images have same sizes - Mat ch=preprocessChar(segment.img); - // DNN classify - Mat inputBlob; - blobFromImage(ch, inputBlob, 1.0f, Size(20, 20), Scalar(), true, false); - dnn_net.setInput(inputBlob); - - Mat outs; - dnn_net.forward(outs); - cout << outs << endl; - double max; - Point pos; - minMaxLoc( outs, NULL, &max, NULL, &pos); - cout << "---->" << pos << " prob: " << max << " " << strCharacters[pos.x] << endl; - - input->chars.push_back(strCharacters[pos.x]); - input->charsPos.push_back(segment.pos); -} - -``` - -我们将对这段代码做更多的解释。 首先,我们必须对每个片段进行预处理,以获得 20x20 像素的相同大小的图像。 此预处理图像必须转换为保存在`Mat`结构中的 BLOB。 要将其转换为 BLOB,我们将使用`blobFromImage`Function,该函数可以创建具有可选调整大小、缩放、裁剪或交换通道蓝色和红色的四维数据。 该函数具有以下参数: - -```cpp -void cv::dnn::blobFromImage ( - InputArray image, - OutputArray blob, - double scalefactor = 1.0, - const Size & size = Size(), - const Scalar & mean = Scalar(), - bool swapRB = false, - bool crop = false, - int ddepth = CV_32F -) -``` - -每一项的定义如下: - -* `image`:输入图像(具有一个、三个或四个通道)。 -* `blob`:输出斑点垫。 -* `size`:输出图像的空间大小。 -* `mean`:标量和平均值,从通道中减去。 如果图像具有 BGR 排序,并且`swapRB`是`true`,则值应该是(Mean-R,Mean-G,Mean-B)顺序。 -* `scalefactor`:图像值的乘数。 -* `swapRB`:指示需要交换三通道图像中的第一个通道和最后一个通道的标志。 -* `crop`:指示调整大小后是否裁剪图像的标志 -* `ddepth`:输出深度`blob`。 选择`CV_32F`或`CV_8U`。 - -可以使用`dnn_net.setInput(inputBlob)`将生成的 BLOB 作为输入添加到我们的 DNN 中。 - -一旦为我们的网络设置了输入 blob,我们只需要向前传递输入就可以获得我们的结果。 这就是使用`dnn_net.forward(outs)`函数的目的,该函数返回带有 Softmax 预测结果的`Mat`。 得到的结果是一行`Mat`,其中每一列都是标签;然后,要获得概率最高的标签,我们只需要获得这个`Mat`的最大位置。 我们可以使用`minMaxLoc`函数来检索标签值,如果需要,还可以检索概率值。 - -最后,要关闭 ANPR 应用,我们只需在输入车牌数据中保存新的分段位置和获得的标签。 - -如果我们执行该应用,我们将获得如下结果: - -![](img/2bfe6b92-9a27-4d3c-9657-1149eef40a91.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在本章中,您学习了车牌自动识别程序的工作原理及其两个重要步骤:车牌定位和车牌识别。 - -在第一步中,您学习了如何通过查找我们可能有车牌的补丁来分割图像,并使用简单启发式算法和 SVM 算法对有*个车牌*和*没有车牌*的补丁进行二进制分类。 - -在第二步中,您学习了如何使用查找轮廓算法进行分段,使用 TensorFlow 创建深度学习模型,然后训练该模型并将其导入到 OpenCV 中。 您还学习了如何使用增强技术增加数据集中的样本数。 - -在下一章中,您将学习如何使用特征脸和深度学习创建人脸识别应用。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/17.md b/trans/build-cv-proj-opencv4-cpp/17.md deleted file mode 100644 index bbab572b..00000000 --- a/trans/build-cv-proj-opencv4-cpp/17.md +++ /dev/null @@ -1,1339 +0,0 @@ -# 基于 DNN 模块的人脸检测与识别 - -在本章中,我们将学习人脸检测和识别的主要技术。 人脸检测是在整个图像中定位人脸的过程。 在本章中,我们将介绍不同的人脸检测技术,从使用具有 Haar 特征的级联分类器的经典算法到使用深度学习的新技术。 人脸识别是识别出现在图像中的人的过程。在本章中,我们将讨论以下主题: - -* 基于不同方法的人脸检测 -* 人脸预处理 -* 从收集到的人脸训练机器学习算法 -* 人脸识别 -* 最后的润色 - -# 人脸检测与人脸识别简介 - -人脸识别是给已知人脸贴上标签的过程。 就像人类只通过看到他们的脸就能识别他们的家人、朋友和名人一样,在计算机视觉中有很多识别人脸的技术。 - -这些步骤通常涉及四个主要步骤,定义如下: - -1. **人脸检测**:这是在图像中定位人脸区域(以下截图中心附近的大矩形)的基本过程。 这一步并不关心这个人是谁,只关心它是一张人脸。 -2. **人脸预处理**:这是调整人脸图像,使其看起来更清晰、更接近其他人脸(以下截图中央上方的小灰度人脸)的最新流程。 -3. **收集和学习人脸**:这是一个保存许多预处理过的人脸(对于每个应该识别的人),然后学习如何识别它们的过程。 -4. **人脸识别**:这是检查采集到的哪些人与摄像头中的人脸最相似的最新流程(下面截图右上角的一个小矩形)。 - -Note that the phrase **face recognition** is often used by the general public to refer to finding the positions of faces (that is, face detection, as described in s*tep 1*), but this book will use the formal definition of face recognition referring to s*tep 4*, and face detection referring to *S**tep 1*. - -下面的屏幕截图显示了最终的`WebcamFaceRec`人项目,包括右上角的一个小矩形,突出显示了被识别的人。 此外,请注意预处理后的人脸(标记该人脸的矩形顶部中心的小人脸)旁边的置信条,在本例中,它显示出大约 70%的置信度,表明它已经识别出正确的人: - -![](img/b7e7696f-b444-4dbe-951e-4471bf37eb07.jpg) - -当前的人脸检测技术在现实世界中是相当可靠的,而当前的人脸识别技术在现实世界中使用时可靠性要低得多。 例如,很容易找到显示人脸识别准确率超过 95%的研究论文,但当你自己测试同样的算法时,你可能经常会发现准确率低于 50%。 这是因为目前的人脸识别技术对图像中的精确条件非常敏感,例如照明类型、光照和阴影方向、人脸的准确方向、面部的表情以及人的当前情绪,这是因为人脸识别技术对图像中的确切条件非常敏感,例如光照类型、光照和阴影的方向、人脸的确切方向、面部的表情以及人的当前情绪。 如果在训练(采集图像)和测试(从摄像头图像)时都保持不变,那么人脸识别应该会工作得很好,但如果训练时人站在房间灯光的左侧,然后在用摄像头测试时站在右边,可能会产生相当糟糕的结果。 因此,用于训练的数据集非常重要。 - -脸部预处理旨在通过确保脸部始终看起来具有相似的亮度和对比度,或许还能确保脸部特征始终处于同一位置(例如将眼睛和/或鼻子与某些位置对齐)来减少这些问题。 一个好的人脸预处理阶段将有助于提高整个人脸识别系统的可靠性,因此本章将重点介绍人脸预处理方法。 - -尽管媒体大肆宣扬使用人脸识别进行安全保护,但目前的人脸识别方法本身不太可能足够可靠,适用于任何真正的安全系统。 然而,它们可以用于不需要高可靠性的目的,比如为进入房间的不同人播放个性化音乐,或者机器人看到你时会叫你的名字。 人脸识别还有各种实用的扩展,如性别识别、年龄识别和情感识别。 - -# 人脸检测 - -直到 2000 年,有许多不同的技术用于寻找人脸,但它们要么非常慢,要么非常不可靠,要么两者兼而有之。 一个重大的变化出现在 2001 年,当时 Viola 和 Jones 发明了基于 Haar 的级联分类器用于目标检测,2002 年 Lienhart 和 Maydt 对其进行了改进。 结果是一种既快速(它可以用 VGA 网络摄像头在典型的桌面上实时检测人脸)又可靠(它能正确检测大约 95%的正面人脸)的物体检测器。 这种物体检测器彻底改变了人脸识别领域(以及整个机器人和计算机视觉领域),因为它最终实现了实时人脸检测和人脸识别,特别是当 Lienhart 自己编写了 OpenCV 免费提供的物体检测器时! 它不仅适用于正面,也适用于侧视图面(称为纵断面)、眼睛、嘴巴、鼻子、公司徽标和许多其他对象。 - -此对象检测器在 OpenCV v2.0 中进行了扩展,在 Ahonen、Hahad 和 Pietikäinen 于 2006 年所做工作的基础上也使用了 LBP 功能进行检测,因为基于 LBP 的检测器可能比基于 Haar 的检测器快好几倍,并且不存在许多 Haar 检测器所具有的许可问题。 - -OpenCV 实现了从 V3.4 到 V4.0 的深度学习,在这一章中,我们将展示如何使用**单镜头多盒检测器**和(**SSD**)算法进行人脸检测。 - -基于 Haar 的人脸检测器的基本思想是,如果你看大多数正面脸,眼睛的区域应该比额头和脸颊暗,嘴巴的区域应该比脸颊暗,以此类推。 它通常会进行大约 20 个阶段的比较,以确定它是否是一张脸,但它必须在图像中的每一个可能的位置,以及每种可能的脸大小上进行比较,所以实际上,它通常会对每张图像进行数千次检查。 基于 LBP 的人脸检测器的基本思想类似于基于 Haar 的人脸检测器,但它使用像素强度比较的直方图,例如边缘、角点和平坦区域。 - -无需让人决定哪种比较最适合定义人脸,基于 Haar 和 LBP 的人脸检测器都可以自动训练,从一大组图像中找到人脸,并将信息存储为 XML 文件,供以后使用。这两种人脸检测器都可以被自动训练,从一大组图像中找到人脸,并将信息存储为 XML 文件,以供以后使用。 这些级联分类器检测器通常使用至少 1,000 个独特的人脸图像和 10,000 个非人脸图像(例如,树木、汽车和文本的照片)进行训练,即使在多核台式机上,训练过程也可能需要很长时间(LBP 通常需要几个小时,而 Haar!则需要一周)。 幸运的是,OpenCV 附带了一些经过预先训练的 Haar 和 LBP 检测器供您使用! 事实上,您只需将不同的级联分类器 XML 文件加载到对象检测器中,然后根据您选择的 XML 文件在 Haar 和 LBP 检测器之间进行选择,就可以检测正脸、侧面(侧视)脸、眼睛或鼻子。 - -# 利用 OpenCV 级联分类器实现人脸检测 - -如前所述,OpenCV v2.4 附带了各种经过预先训练的 XML 检测器,您可以将其用于不同的目的。 下表列出了一些最常用的 XML 文件: - -| **级联分类器的类型** | **XML 文件名** | -| 面部检测器(默认) | `haarcascade_frontalface_default.xml` | -| 人脸检测器(FAST HAAR) | `haarcascade_frontalface_alt2.xml` | -| 面部检测器(快速 LBP) | `lbpcascade_frontalface.xml` | -| 侧面(侧视)人脸检测器 | `haarcascade_profileface.xml` | -| 眼睛探测器(左右分开) | `haarcascade_lefteye_2splits.xml` | -| 口腔探测仪 | `haarcascade_mcs_mouth.xml` | -| 鼻部探测器 | `haarcascade_mcs_nose.xml` | -| 全人探测器 | `haarcascade_fullbody.xml` | - -基于 HAAR 的检测器存储在 OpenCV 根文件夹的`data/haarcascades`文件夹中,基于 LBP 的检测器存储在 OpenCV 根文件夹的`datal/bpcascades`文件夹中,例如:`C:\\opencv\\data\\lbpcascades`。 - -对于我们的人脸识别项目,我们想要检测正面人脸,所以让我们使用 LBP 人脸检测器,因为它速度最快,而且不存在专利许可问题。 请注意,OpenCV v2.x 附带的预先训练的 LBP 人脸检测器没有像预先训练的 Haar 人脸检测器一样进行调整,因此如果您想要更可靠的人脸检测,那么您可能需要训练自己的 LBP 人脸检测器或使用 Haar 人脸检测器。 - -# 加载用于目标或面部检测的 Haar 或 LBP 检测器 - -要执行对象或人脸检测,首先必须使用 OpenCV 的`CascadeClassifier`类加载预先训练好的 XML 文件,如下所示: - -```cpp - CascadeClassifier faceDetector; - faceDetector.load(faceCascadeFilename); -``` - -只需给出一个不同的文件名,就可以加载 Haar 或 LBP 检测器。 使用这种方法时,一个非常常见的错误是提供了错误的文件夹或文件名,但是根据您的构建环境的不同,`load()`方法要么返回`false`,要么生成 C++ 异常(并退出程序并返回 Assert 错误)。 因此,最好用一个`try... catch`块包围`load()`方法,并在出现错误时向用户显示一条错误消息。 许多初学者会跳过错误检查,但在某些内容加载不正确时向用户显示帮助消息是至关重要的;否则,您可能需要花费很长时间来调试代码的其他部分,然后才能最终意识到某些内容没有加载。 可以显示一条简单的错误消息,如下所示: - -```cpp - CascadeClassifier faceDetector; - try { - faceDetector.load(faceCascadeFilename); - } catch (cv::Exception e) {} - if ( faceDetector.empty() ) { - cerr << "ERROR: Couldn't load Face Detector ("; - cerr << faceCascadeFilename << ")!" << endl; - exit(1); - } -``` - -# 访问网络摄像头 - -要从计算机的网络摄像头甚至视频文件中抓取帧,您只需调用带有摄像头编号或视频文件名的`VideoCapture::open()`函数,然后使用 C++ 流运算符抓取帧,如第[第 13 章](13.html)第[章](13.html)中的*访问网络摄像头*一节中所述,在 Raspberry Pi 上执行*Cartoonizer and Skin Color Analysis(Raspberry PI*中的*Cartoonizer and Skin Color Analysis)。* - -# 使用 Haar 或 LBP 分类器检测对象 - -现在我们已经加载了分类器(只在初始化期间加载了一次),我们可以使用它来检测每个新摄像机帧中的人脸。 但首先,我们应该通过执行以下步骤对摄像头图像进行一些初始处理,仅用于人脸检测: - -1. **灰度颜色转换**:人脸检测仅适用于灰度图像。 所以我们应该把彩色相机的画面转换成灰度。 -2. **缩小相机图像**:人脸检测的速度取决于输入图像的大小(对于大图像非常慢,但对于小图像很快),但是即使在低分辨率下,检测仍然相当可靠。 因此,我们应该将摄像机图像缩小到更合理的大小(或者在检测器中使用较大的值`minFeatureSize`,如以下各节所述)。 -3. **直方图均衡**:人脸检测在弱光条件下不太可靠。 因此,需要对图像进行直方图均衡化,以提高图像的对比度和亮度。 - -**灰度颜色转换** - -我们可以使用`cvtColor()`函数轻松地将 RGB 彩色图像转换为灰度图像。 但是,只有当我们知道我们有彩色图像(即,它不是灰度相机),并且我们必须指定输入图像的格式(通常台式机上是三通道 BGR 或移动设备上是四通道 BGRA)时,我们才应该这样做。 因此,我们应该允许三种不同的输入颜色格式,如以下代码所示: - -```cpp - Mat gray; - if (img.channels() == 3) { - cvtColor(img, gray, COLOR_BGR2GRAY); - } - else if (img.channels() == 4) { - cvtColor(img, gray, COLOR_BGRA2GRAY); - } - else { - // Access the grayscale input image directly. - gray = img; - } -``` - -**缩小摄像机图像** - -我们可以使用`resize()`函数将图像缩小到一定的大小或比例因子。 人脸检测通常对任何大小大于 240 x 240 像素的图像都非常有效(除非您需要检测远离摄像头的人脸),因为它会查找任何大于`minFeatureSize`像素(通常为 20 x 20 像素)的人脸。 因此,让我们将摄像头图像缩小到 320 像素宽;输入是 VGA 网络摄像头还是 500 万像素高清摄像头都无关紧要。 记住和放大检测结果也很重要,因为如果在缩小的图像中检测人脸,那么结果也会缩小。 请注意,您可以在检测器中使用一个较大的值,而不是缩小输入图像。 我们还必须确保图像不会变得更胖或更瘦。 例如,一张 800x400 的宽屏图像缩小到 300x200 会让人看起来很瘦。 因此,我们必须保持输出的纵横比(宽高比)与输入相同。 让我们计算一下图像宽度要缩小多少,然后对高度应用相同的比例因子,如下所示: - -```cpp - const int DETECTION_WIDTH = 320; - // Possibly shrink the image, to run much faster. - Mat smallImg; - float scale = img.cols / (float) DETECTION_WIDTH; - if (img.cols > DETECTION_WIDTH) { - // Shrink the image while keeping the same aspect ratio. - int scaledHeight = cvRound(img.rows / scale); - resize(img, smallImg, Size(DETECTION_WIDTH, scaledHeight)); - } - else { - // Access the input directly since it is already small. - smallImg = img; - } -``` - -**直方图均衡** - -使用`equalizeHist()`函数,我们可以轻松地执行直方图均衡化,以提高图像的对比度和亮度。 有时这会让图像看起来很奇怪,但一般来说,它应该会提高亮度和对比度,并有助于人脸检测。 *`equalizeHist()`*函数的用法如下: - -```cpp - // Standardize the brightness & contrast, such as - // to improve dark images. - Mat equalizedImg; - equalizeHist(inputImg, equalizedImg); -``` - -# 检测人脸 - -现在我们已经将图像转换为灰度,缩小了图像,并均衡了直方图,接下来我们就可以使用`CascadeClassifier::detectMultiScale()`函数检测人脸了! 我们传递给此函数的参数很多,如下所示: - -* `minFeatureSize`:此参数确定我们关心的最小面部大小,通常为 20x20 或 30x30 像素,但这取决于您的使用案例和图像大小。 如果您在网络摄像头或智能手机上执行人脸检测,其中人脸始终离摄像头很近,则可以将其放大到 80 x 80 以获得更快的检测速度,或者如果您想要检测较远的人脸,如与朋友一起在海滩上,则将其保留为 20 x 20。 -* `searchScaleFactor`:此参数确定要查找多少个不同大小的人脸;通常情况下,如果检测良好,则为`1.1`;如果检测更快,则为`1.2`,这样就不会经常找到人脸。 -* `minNeighbors`:此参数确定检测器应在多大程度上确定它已检测到人脸;它的值通常为 1`3`,但如果您想要更可靠的人脸,即使没有检测到许多人脸,也可以将其设置得更高。 -* `flags`:此参数允许您指定是查找所有面(默认),还是仅查找最大的面(`CASCADE_FIND_BIGGEST_OBJECT`)。 如果你只寻找最大的脸,它应该跑得更快。 您还可以添加其他几个参数,使检测速度提高约 1%或 2%,如`CASCADE_DO_ROUGH_SEARCH`或`CASCADE_SCALE_IMAGE`。 - -参数`detectMultiScale()`函数的输出将是参数`cv::Rect`类型对象的参数`std::vector`。 例如,如果它检测到两个面,那么它将在输出中存储一个由两个矩形组成的数组。 `detectMultiScale()`函数的用法如下: - -```cpp - int flags = CASCADE_SCALE_IMAGE; // Search for many faces. - Size minFeatureSize(20, 20); // Smallest face size. - float searchScaleFactor = 1.1f; // How many sizes to search. - int minNeighbors = 4; // Reliability vs many faces. - -// Detect objects in the small grayscale image. -std::vector faces; -faceDetector.detectMultiScale(img, faces, searchScaleFactor, - minNeighbors, flags, minFeatureSize); -``` - -我们可以通过查看存储在矩形向量中的元素数量(即,通过使用`objects.size()`函数)来查看是否检测到任何人脸。 - -正如前面提到的,如果我们给人脸检测器一个缩小的图像,结果也会缩小,所以如果我们想要看到原始图像的人脸区域,就需要放大它们。 我们还需要确保图像边界上的面完全位于图像内,因为如果发生这种情况,OpenCV 现在将引发异常,如以下代码所示: - -```cpp - // Enlarge the results if the image was temporarily shrunk. - if (img.cols > scaledWidth) { - for (auto& object:objects ) { - object.x = cvRound(object.x * scale); - object.y = cvRound(object.y * scale); - object.width = cvRound(object.width * scale); - object.height = cvRound(object.height * scale); - } - } - // If the object is on a border, keep it in the image. - for (auto& object:objects) { - if (object.x < 0) - object.x = 0; - if (object.y < 0) - object.y = 0; - if (object.x + object.width > img.cols) - object.x = img.cols - object.width; - if (object.y + object.height > img.rows) - object.y = img.rows - object.height; - } -``` - -请注意,前面的代码将查找图像中的所有面孔,但如果您只关心一个面孔,则可以按如下方式更改`flags`变量: - -```cpp - int flags = CASCADE_FIND_BIGGEST_OBJECT | - CASCADE_DO_ROUGH_SEARCH; -``` - -`WebcamFaceRec`项目包括 OpenCV 的 Haar 或 LBP 探测器的包装,以便更容易在图像中找到人脸或眼睛,例如: - -```cpp -Rect faceRect; // Stores the result of the detection, or -1\. -int scaledWidth = 320; // Shrink the image before detection. -detectLargestObject(cameraImg, faceDetector, faceRect, scaledWidth); -if (faceRect.width > 0) -cout << "We detected a face!" << endl; -``` - -现在我们有了一个脸部矩形,我们可以通过多种方式使用它,比如从原始图像中提取或裁剪脸部。 下面的代码允许我们访问面部: - -```cpp - // Access just the face within the camera image. - Mat faceImg = cameraImg(faceRect); -``` - -下图显示了人脸检测器给出的典型矩形区域: - -![](img/0a834129-e206-4691-b96a-7ff896588e7c.jpg) - -# 利用 OpenCV 深度学习模块实现人脸检测 - -从 OpenCV3.4 开始,深度学习模块可以作为补充资源([https://github.com/opencv/opencv_contrib](https://github.com/opencv/opencv_contrib)),但是从 4.0 版开始,深度学习是 OpenCV 核心的一部分。 这意味着 OpenCV 深度学习运行稳定,维护良好。 - -我们可以使用基于 SSD 和深度学习算法的预先训练的 Caffe 模型来进行人脸识别。 该算法允许我们在单个深度学习网络中检测一幅图像中的多个对象,并为每个检测到的对象返回一个类和边界框。 - -要加载预先训练的 Caffe 模型,我们需要加载两个文件: - -* Proto 文件或配置模型;在我们的示例中,该文件保存在`data/deploy.prototxt`中 -* 二进制训练模型,其中包含每个变量的权重;在我们的示例中,文件保存在`data/res10_300x300_ssd_iter_140000_fp16.caffemodel`中 - -下面的代码允许我们将模型加载到 OpenCV 中: - -```cpp -dnn::Net net = readNetFromCaffe("data/deploy.prototxt", "data/res10_300x300_ssd_iter_14000_fp16.caffemodel"); -``` - -加载深度学习网络后,根据我们用摄像头捕捉到的每一帧,我们必须将其转换为深度学习网络可以理解的斑点图像。 我们必须按如下方式使用`blobFromImage`命令函数: - -```cpp -Mat inputBlob = blobFromImage(frame, 1.0, Size(300, 300), meanVal, false, false); -``` - -其中,第一个参数是输入图像,第二个参数是每个像素值的缩放因子,第三个参数是输出空间大小,第四个参数是要从每个通道减去的`Scalar`值,第五个参数是交换*B*和*R*通道的标志,最后一个参数,如果我们将最后一个参数设置为 true,它将在调整大小后裁剪图像。 - -现在,我们已经为深度神经网络准备了输入图像;要将其设置为网络,我们必须调用以下函数: - -```cpp -net.setInput(inputBlob); -``` - -最后,我们可以调用网络进行如下预测: - -```cpp -Mat detection = net.forward(); -``` - -# 人脸预处理 - -如前所述,人脸识别极易受到光照条件、人脸朝向、人脸表情等变化的影响,因此尽可能减少这些差异非常重要。 否则,人脸识别算法通常会认为在相同条件下两个不同人的脸之间比同一人的两幅图像之间有更多的相似性。 - -最简单的人脸预处理方法就是使用`equalizeHist()`函数应用直方图均衡化,就像我们刚才对人脸检测所做的那样。 对于照明和位置条件不会有太大变化的某些项目来说,这可能就足够了。 但是为了在真实世界中的可靠性,我们需要许多复杂的技术,包括面部特征检测(例如,检测眼睛、鼻子、嘴巴和眉毛)。 为简单起见,本章将只使用眼睛检测,而忽略其他面部特征,如嘴巴和鼻子,这些特征用处较小。 - -下图显示了使用本节将介绍的技术的典型预处理面部的放大视图: - -![](img/28565164-b9c8-4087-9d1b-9fe4f89f78cf.png) - -# 眼睛检测 - -眼睛检测对于人脸的预处理也非常有用,因为对于正脸,你总是可以假设一个人的眼睛应该是水平的,在脸部的相对两侧,并且应该在脸部内有一个相当标准的位置和大小,尽管面部表情、光照条件、相机属性、到相机的距离等都发生了变化。 - -当面部检测器说它检测到了一张脸,而它实际上是另一张脸时,丢弃假阳性也很有用。 人脸检测器和两个眼睛检测器同时被愚弄的情况很少见,因此如果只处理带有一个检测到的人脸和两个检测到的眼睛的图像,那么它就不会有太多的假阳性(但也会给出更少的人脸进行处理,因为眼睛检测器不会像人脸检测器那样频繁地工作)。 - -OpenCV v2.4 附带的一些预先训练的眼睛检测器可以检测眼睛是睁开的还是闭着的,而有些只能检测睁开的眼睛。 - -检测眼睛睁开或闭上的眼睛检测器如下: - -* `haarcascade_mcs_lefteye.xml`(and`haarcascade_mcs_righteye.xml`) -* `haarcascade_lefteye_2splits.xml`(and`haarcascade_righteye_2splits.xml`) - -仅检测睁开眼睛的眼睛检测器如下: - -* `haarcascade_eye.xml` -* `haarcascade_eye_tree_eyeglasses.xml` - -As the open or closed eye detectors specify which eye they are trained on, you need to use a different detector for the left and the right eye, whereas the detectors for just open eyes can use the same detector for left or right eyes. -The `haarcascade_eye_tree_eyeglasses.xml` detector can detect the eyes if the person is wearing glasses, but is not reliable if they don't wear glasses. -If the XML filename says *left eye*, it means the actual left eye of the person, so in the camera image it would normally appear on the right-hand side of the face, not on the left-hand side! -The list of four eye detectors mentioned is ranked in approximate order from most reliable to least reliable, so if you know you don't need to find people with glasses, then the first detector is probably the best choice. - -# 眼睛搜索区域 - -对于眼睛检测,重要的是裁剪输入图像以仅显示大致的眼睛区域,就像做面部检测,然后裁剪到左眼应该位于的小矩形(如果您使用的是左眼检测器),右眼检测器的右矩形也是如此。 - -如果你只是在整张脸或整张照片上做眼睛检测,那么速度会慢得多,可靠性也会差得多。 不同的眼睛检测器更适合面部的不同区域;例如,如果只在实际眼睛周围非常紧密的区域进行搜索,则`haarcascade_eye.xml`检测器的工作效果最好,而当眼睛周围有较大区域时,`haarcascade_mcs_lefteye.xml`和`haarcascade_lefteye_2splits.xml`的检测效果最好。 - -下表列出了使用检测到的人脸矩形内的相对坐标(`EYE_SX`是眼睛搜索*x*位置,`EYE_SY`是眼睛搜索*y*位置,`EYE_SW`是眼睛搜索宽度,`EYE_SH`是眼睛搜索高度)对不同眼睛检测器(使用 LBP 脸部检测器时)的脸部的一些较好的搜索区域:(`EYE_SX`是眼睛搜索*x*位置,`EYE_SY`是眼睛搜索*y*位置,`EYE_SW`是眼睛搜索宽度,`EYE_SH`是眼睛搜索高度): - -| **级联分类器** | `EYE_SX` | `EYE_SY` | `EYE_SW` | `EYE_SH` | -| `haarcascade_eye.xml` | 0.16 | 0.26 | 0.30 | 0.28 | -| `haarcascade_mcs_lefteye.xml` | 0.10 | 0.19 | 0.40 | 0.36 | -| `haarcascade_lefteye_2splits.xml` | 0.12 | 0.17 | 0.37 | 0.36 | - -以下是从检测到的人脸中提取左眼和右眼区域的源代码: - -```cpp - int leftX = cvRound(face.cols * EYE_SX); - int topY = cvRound(face.rows * EYE_SY); - int widthX = cvRound(face.cols * EYE_SW); - int heightY = cvRound(face.rows * EYE_SH); - int rightX = cvRound(face.cols * (1.0-EYE_SX-EYE_SW)); - - Mat topLeftOfFace = faceImg(Rect(leftX, topY, widthX, heightY)); - Mat topRightOfFace = faceImg(Rect(rightX, topY, widthX, heightY)); -``` - -下图显示了不同人眼检测器的理想搜索区域,其中,文件`haarcascade_eye.xml`和文件`haarcascade_eye_tree_eyeglasses.xml`的搜索区域最小,文件`haarcascade_mcs_*eye.xml`和文件`haarcascade_*eye_2splits.xml`的搜索区域最大。 请注意,还会显示检测到的面部矩形,以便了解眼睛搜索区域与检测到的面部矩形相比有多大: - -![](img/17535fa3-f86f-4c0d-893e-dcaadf2877e8.jpg) - -下表列出了使用眼睛搜索区域时不同眼睛检测器的近似检测特性: - -| **级联分类器** | **可靠性*** | **速度**** | **发现眼睛** | **眼镜** | -| `haarcascade_mcs_lefteye.xml` | 80% | 18 毫秒 | 打开或关闭 | 完全不 / 决不 / 不 | -| `haarcascade_lefteye_2splits.xml` | 60% | 7 毫秒 | 打开或关闭 | 完全不 / 决不 / 不 | -| `haarcascade_eye.xml` | 40% | 5 毫秒 | 仅打开 | 完全不 / 决不 / 不 | -| `haarcascade_eye_tree_eyeglasses.xml` | 15% | 10 毫秒 | 仅打开 | 肯定的回答 / 赞成 / 是 | - -**可靠性**数值表示在未戴眼镜且双眼睁开的情况下,在 LBP 正面面部检测后检测双眼的频率。 如果闭上眼睛,可靠性可能会下降,如果戴上眼镜,可靠性和速度都会下降。 - -**速度**对于在英特尔酷睿 i7 2.2 GHz(平均为 1,000 张照片)上缩放到 320 x 240 像素大小的图像,其值以毫秒为单位。 发现眼睛时的速度通常比没有眼睛时快得多,因为它必须扫描整个图像,但`haarcascade_mcs_lefteye.xml`仍然比其他眼睛检测器慢得多。 - -例如,如果您将一张照片缩小到 320 x 240 像素,对其执行直方图均衡化,使用 LBP 正面人脸检测器获取人脸,然后使用 LBP`haarcascade_mcs_lefteye.xml`值从人脸提取左眼区域和右眼区域,然后对每个眼睛区域执行直方图均衡化。 然后,如果你在左眼(实际上在图像的右上角)使用`haarcascade_mcs_lefteye.xml`检测器,在右眼(图像的左上角)使用`haarcascade_mcs_righteye.xml`检测器,每个眼睛检测器应该可以在大约 90%的带有 LBP 检测到的正面的照片中工作。 因此,如果你想检测两只眼睛,那么它应该在大约 80%的带有 LBP 检测到的正面脸的照片中起作用。 - -请注意,虽然不建议在检测人脸之前缩小摄像头图像,但您应该在完全摄像头分辨率下检测眼睛,因为眼睛显然会比人脸小得多,所以您需要尽可能获得更高的分辨率。 - -Based on the table, it seems that when choosing an eye detector to use, you should decide whether you want to detect closed eyes or only open eyes. And remember that you can even use one eye detector, and if it does not detect an eye, then you can try with another one. -For many tasks, it is useful to detect eyes whether they are open or closed, so if speed is not crucial, it is best to search with the `mcs_*eye` detector first, and if it fails, then search with the `eye_2splits` detector. -But for face recognition, a person will appear quite different if their eyes are closed, so it is best to search with the plain `haarcascade_eye` detector first, and if it fails, then search with the `haarcascade_eye_tree_eyeglasses` detector. - -我们可以使用与人脸检测相同的`detectLargestObject()`函数来搜索眼睛,但我们没有在眼睛检测之前要求缩小图像,而是指定了完整的眼睛区域宽度,以获得更好的眼睛检测。 使用一个检测器搜索左眼很容易,如果失败,则尝试另一个检测器(右眼也是如此)。 眼睛检测如下: - -```cpp - CascadeClassifier eyeDetector1("haarcascade_eye.xml"); - CascadeClassifier eyeDetector2("haarcascade_eye_tree_eyeglasses.xml"); - ... - Rect leftEyeRect; // Stores the detected eye. - // Search the left region using the 1st eye detector. - detectLargestObject(topLeftOfFace, eyeDetector1, leftEyeRect, - topLeftOfFace.cols); - // If it failed, search the left region using the 2nd eye - // detector. - if (leftEyeRect.width <= 0) - detectLargestObject(topLeftOfFace, eyeDetector2, - leftEyeRect, topLeftOfFace.cols); - // Get the left eye center if one of the eye detectors worked. - Point leftEye = Point(-1,-1); - if (leftEyeRect.width <= 0) { - leftEye.x = leftEyeRect.x + leftEyeRect.width/2 + leftX; - leftEye.y = leftEyeRect.y + leftEyeRect.height/2 + topY; - } - - // Do the same for the right eye - ... - - // Check if both eyes were detected. - if (leftEye.x >= 0 && rightEye.x >= 0) { - ... - } -``` - -在检测到人脸和双眼之后,我们将结合以下步骤进行人脸预处理: - -1. **几何变换和裁剪**:此过程包括缩放、旋转和平移图像以使眼睛对齐,然后从面部图像中移除前额、下巴、耳朵和背景。 -2. **左右两侧的单独直方图均衡**:此过程独立地标准化脸部左右两侧的亮度和对比度。 -3. **平滑**:此过程使用双边滤波器降低图像噪声。 -4. **椭圆蒙版**:椭圆蒙版从脸部图像中去除一些剩余的毛发和背景。 - -下面的照片显示了应用于检测到的面部的面部预处理*步骤 1*至*步骤 4*。 请注意,最终的照片在脸部两侧的亮度和对比度都很好,而原始照片的亮度和对比度却不好: - -![](img/e1f1a608-2a52-4e82-9802-172d43d2b6fa.png) - -# 几何变换 - -重要的是,所有的脸都要对齐,否则人脸识别算法可能会比较鼻子的一部分和眼睛的一部分,以此类推。 我们刚才看到的面部检测的输出将在一定程度上给出对齐的面部,但它不是非常准确(即,面部矩形不会总是从额头上的同一点开始)。 - -为了有更好的视觉对准,我们将使用眼睛检测来对齐面部,这样检测到的两只眼睛的位置就会完美地排在所需的位置。 我们将使用`warpAffine()`函数进行几何变换,这是一个单独的操作,将执行以下四项操作: - -* 旋转面,使两只眼睛水平 -* 缩放面,使两只眼睛之间的距离始终相同 -* 平移面,使眼睛始终水平居中,并位于所需高度 -* 裁剪面部的外部,因为我们要裁剪图像背景、头发、额头、耳朵和下巴 - -仿射扭曲采用仿射矩阵,该矩阵将检测到的两个眼睛位置转换为两个所需的眼睛位置,然后裁剪成所需的大小和位置。 要生成此仿射矩阵,我们将获得两个眼睛之间的中心,计算两个检测到的眼睛出现的角度,并查看它们之间的距离,如下所示: - -```cpp - // Get the center between the 2 eyes. - Point2f eyesCenter; - eyesCenter.x = (leftEye.x + rightEye.x) * 0.5f; - eyesCenter.y = (leftEye.y + rightEye.y) * 0.5f; - - // Get the angle between the 2 eyes. - double dy = (rightEye.y - leftEye.y); - double dx = (rightEye.x - leftEye.x); - double len = sqrt(dx*dx + dy*dy); - - // Convert Radians to Degrees. - double angle = atan2(dy, dx) * 180.0/CV_PI; - - // Hand measurements shown that the left eye center should - // ideally be roughly at (0.16, 0.14) of a scaled face image. - const double DESIRED_LEFT_EYE_X = 0.16; - const double DESIRED_RIGHT_EYE_X = (1.0f - 0.16); - - // Get the amount we need to scale the image to be the desired - // fixed size we want. - const int DESIRED_FACE_WIDTH = 70; - const int DESIRED_FACE_HEIGHT = 70; - double desiredLen = (DESIRED_RIGHT_EYE_X - 0.16); - double scale = desiredLen * DESIRED_FACE_WIDTH / len; -``` - -现在,我们可以对脸部进行变换(旋转、缩放和平移),以使检测到的两只眼睛位于理想脸部中所需的眼睛位置,如下所示: - -```cpp - // Get the transformation matrix for the desired angle & size. - Mat rot_mat = getRotationMatrix2D(eyesCenter, angle, scale); - // Shift the center of the eyes to be the desired center. - double ex = DESIRED_FACE_WIDTH * 0.5f - eyesCenter.x; - double ey = DESIRED_FACE_HEIGHT * DESIRED_LEFT_EYE_Y - - eyesCenter.y; - rot_mat.at(0, 2) += ex; - rot_mat.at(1, 2) += ey; - // Transform the face image to the desired angle & size & - // position! Also clear the transformed image background to a - // default grey. - Mat warped = Mat(DESIRED_FACE_HEIGHT, DESIRED_FACE_WIDTH, - CV_8U, Scalar(128)); - warpAffine(gray, warped, rot_mat, warped.size()); -``` - -# 左右两侧分开的直方图均衡化 - -在现实世界的条件下,半边脸的光线很强,而另一半的光线很弱,这是很常见的。 这对人脸识别算法有很大的影响,因为同一张脸的左右两侧看起来就像是非常不同的人。 因此,我们将在脸部的左右半部分分别进行直方图均衡化,以获得标准化的脸部两侧的亮度和对比度。 - -如果我们简单地在左半部分应用直方图均衡,然后在右半部分应用直方图均衡,我们将在中间看到一个非常明显的边缘,因为左右两侧的平均亮度可能不同。 因此,为了去除这一边缘,我们将从左侧或右侧向中心逐渐应用两个直方图均衡化,并将其与整个面部直方图均衡化混合在一起。 - -然后,最左侧将使用左侧直方图均衡化,最右侧将使用右侧直方图均衡化,中心将使用左右两个值和整个人脸均衡值的平滑混合。 - -下面的屏幕截图显示了左均衡、全均衡和右均衡图像是如何混合在一起的: - -![](img/357505df-a513-4fc9-a477-6dbfb9182408.png) - -要做到这一点,我们需要整张脸的复印件均衡化,以及左半面均衡化和右半面均衡化,具体操作如下: - -```cpp - int w = faceImg.cols; - int h = faceImg.rows; - Mat wholeFace; - equalizeHist(faceImg, wholeFace); - int midX = w/2; - Mat leftSide = faceImg(Rect(0,0, midX,h)); - Mat rightSide = faceImg(Rect(midX,0, w-midX,h)); - equalizeHist(leftSide, leftSide); - equalizeHist(rightSide, rightSide); -``` - -现在,我们将这三幅图像合并在一起。 由于图像较小,即使速度较慢,我们也可以使用`image.at(y,x)`函数直接访问像素;因此,让我们通过直接访问三个输入图像和输出图像中的像素来合并这三个图像,如下所示: - -```cpp - for (int y=0; y(y,x); - } - else if (x < w*2/4) { - // Mid-left 25%: blend the left face & whole face. - int lv = leftSide.at(y,x); - int wv = wholeFace.at(y,x); - // Blend more of the whole face as it moves - // further right along the face. - float f = (x - w*1/4) / (float)(w/4); - v = cvRound((1.0f - f) * lv + (f) * wv); - } - else if (x < w*3/4) { - // Mid-right 25%: blend right face & whole face. - int rv = rightSide.at(y,x-midX); - int wv = wholeFace.at(y,x); - // Blend more of the right-side face as it moves - // further right along the face. - float f = (x - w*2/4) / (float)(w/4); - v = cvRound((1.0f - f) * wv + (f) * rv); - } - else { - // Right 25%: just use the right face. - v = rightSide.at(y,x-midX); - } - faceImg.at(y,x) = v; - } // end x loop - } //end y loop -``` - -这种分开的直方图均衡化应该有助于显著降低不同光照对脸部左右两侧的影响,但我们必须理解,它不会完全消除单面光照的影响,因为脸部是一个复杂的 3D 形状,有很多阴影。 - -# 光滑的 / 平滑的 / 醇和的 / 不苦的 - -为了更好地减少像素噪声的影响,我们将在人脸上使用双边过滤器,因为双边过滤器非常擅长在保持边缘锐利的同时平滑图像的大部分。 直方图均衡会显著增加像素噪波,因此我们将使过滤器强度`20.0`增加以覆盖较大的像素噪波,并且只使用两个像素的邻域,因为我们希望对微小的像素噪波进行大量平滑,而不是对较大的图像区域进行大量平滑,如下所示: - -```cpp - Mat filtered = Mat(warped.size(), CV_8U); - bilateralFilter(warped, filtered, 0, 20.0, 2.0); -``` - -# 椭圆掩模 - -虽然我们在进行几何变换时已经移除了大部分图像背景、额头和头发,但我们可以应用椭圆形蒙版来移除一些角落区域,如颈部,这些区域可能在脸部的阴影中,特别是如果脸部没有完全直视相机的话。 为了创建蒙版,我们将在白色图像上绘制一个黑色填充的椭圆。 要执行此操作的一个椭圆的水平半径为 0.5(即,它完全覆盖了面的宽度),垂直半径为 0.8(因为面通常比宽高),并居中于坐标 0.5,0.4,如下面的屏幕截图所示,其中椭圆遮罩已从面中移除了一些不需要的角: - -![](img/adeced95-9f5c-4fe6-9582-b67539eea4c1.png) - -我们可以在调用`cv::setTo()`函数时应用遮罩,该函数通常会将整个图像设置为某个像素值,但由于我们将给出遮罩图像,因此它只会将某些部分设置为给定的像素值。 我们将用灰色填充图像,使其与脸部其他部分的对比度较小,如下所示: - -```cpp - // Draw a black-filled ellipse in the middle of the image. - // First we initialize the mask image to white (255). - Mat mask = Mat(warped.size(), CV_8UC1, Scalar(255)); - double dw = DESIRED_FACE_WIDTH; - double dh = DESIRED_FACE_HEIGHT; - Point faceCenter = Point( cvRound(dw * 0.5), - cvRound(dh * 0.4) ); - Size size = Size( cvRound(dw * 0.5), cvRound(dh * 0.8) ); - ellipse(mask, faceCenter, size, 0, 0, 360, Scalar(0), - CV_FILLED); - - // Apply the elliptical mask on the face, to remove corners. - // Sets corners to gray, without touching the inner face. - filtered.setTo(Scalar(128), mask); -``` - -下面放大的屏幕截图显示了所有人脸预处理阶段的样本结果。 请注意,在不同亮度、不同脸部旋转、不同摄像头角度、不同背景、不同灯光位置等情况下,人脸识别的一致性要高得多。 无论是在收集用于训练的人脸时,还是在尝试识别输入人脸时,预处理后的人脸都将用作人脸识别阶段的输入: - -![](img/764be5bf-8042-438e-b9b6-55253f8d2188.png) - -# 收集人脸并从中学习 - -收集人脸可以很简单,只需将每个新预处理的人脸图像放入摄像机中的一组预处理人脸中,以及将标签放入一个数组中(以指定该人脸来自哪个人)。 例如,您可以使用第一人称的 10 个预处理人脸和第二个人的 10 个预处理人脸,因此人脸识别算法的输入将是一个由 20 个预处理人脸组成的数组和一个由 20 个整数组成的数组(其中前 10 个数字是 0,后面 10 个数字是 1)。 - -然后,人脸识别算法将学习如何区分不同人的脸。 这称为训练阶段,收集的面称为训练集。 在人脸识别算法完成训练后,您可以将生成的知识保存到文件或内存中,然后使用它来识别摄像机前看到的是哪个人。 这称为测试阶段。 如果直接从相机输入使用它,则预处理的面将被称为测试图像,如果您使用多个图像(例如,来自图像文件的文件夹)进行测试,则它将被称为测试集。 - -重要的是,您要提供一个良好的培训集,涵盖您预期的测试集中可能发生的变化类型。 例如,如果您只测试直视正面的人脸(例如 ID 照片),那么您只需要为训练图像提供正面正视的人脸。 但如果这个人可能看向左边或上面,那么你应该确保训练集也包括这样做的人的脸,否则人脸识别算法将很难识别他们,因为他们的脸看起来会有很大的不同。 这也适用于其他因素,例如面部表情(例如,如果人在训练集中总是微笑,但在测试集中没有微笑)或照明方向(例如,强光在训练集中在左侧,但在测试集中在右侧),则人脸识别算法将难以识别它们。 我们刚才看到的面部预处理步骤将有助于减少这些问题,但它肯定不会消除这些因素,特别是面部所看的方向,因为它对面部所有元素的位置有很大影响。 - -One way to obtain a good training set that will cover many different real-world conditions is for each person to rotate their head from looking left, to up, to right, to down, then looking directly straight. Then, the person tilts their head sideways and then up and down, while also changing their facial expression, such as alternating between smiling, looking angry, and having a neutral face. If each person follows a routine such as this while collecting faces, then there is a much better chance of recognizing everyone in real-world conditions. -For even better results, it should be performed again with one or two more locations or directions, such as by turning the camera around 180 degrees, walking in the opposite direction, and then repeating the whole routine, so that the training set would include many different lighting conditions. - -因此,一般来说,每个人有 100 张训练脸可能比每个人只有 10 张训练脸效果更好,但如果所有 100 张脸看起来几乎一样,那么它的表现仍然会很差,因为更重要的是训练集有足够的多样性来覆盖测试集,而不是只有大量的脸。 因此,为了确保训练集中的人脸不会太相似,我们应该在每个收集到的人脸之间添加明显的延迟。 例如,如果相机以每秒 30 帧的速度运行,那么当人没有时间四处走动时,它可能会在短短几秒钟内采集 100 张脸,所以最好是在人移动脸的时候每秒只采集一张脸。 改进训练集中变化的另一种简单方法是仅在脸部与先前收集的脸部显著不同时才收集脸部。 - -# 采集预处理后的人脸进行训练 - -为了确保收集新面孔之间至少有一秒的差距,我们需要更好地衡量已经过去了多少时间。 此操作如下所示: - -```cpp - // Check how long since the previous face was added. - double current_time = (double)getTickCount(); - double timeDiff_seconds = (current_time - - old_time) / getTickFrequency(); -``` - -要逐个像素比较两个图像的相似性,您可以找到相对的 L2 误差,这只需要将一幅图像减去另一幅图像,将其平方值相加,然后求出它的平方根。 因此,如果这个人根本没有移动,从前一张脸中减去当前脸应该会给出一个非常小的数字,但如果他们只是在任何方向上稍微移动了一下,减去像素就会得到一个很大的数字,所以 L2 误差会很大。 由于结果是对所有像素求和,因此该值将取决于图像分辨率。 所以要得到平均误差,我们应该用这个值除以图像中的总像素数。 让我们将其放在一个方便的函数`getSimilarity()`中,如下所示: - -```cpp - double getSimilarity(const Mat A, const Mat B) { - // Calculate the L2 relative error between the 2 images. - double errorL2 = norm(A, B, CV_L2); - // Scale the value since L2 is summed across all pixels. - double similarity = errorL2 / (double)(A.rows * A.cols); - return similarity; - } - - ... - - // Check if this face looks different from the previous face. - double imageDiff = MAX_DBL; - if (old_prepreprocessedFaceprepreprocessedFace.data) { - imageDiff = getSimilarity(preprocessedFace, - old_prepreprocessedFace); - } -``` - -如果图像移动不多,则相似度通常小于 0.2,如果图像确实移动,则相似度高于 0.4,因此让我们使用 0.3 作为收集新人脸的阈值。 - -我们可以使用许多技巧来获得更多的训练数据,例如使用镜像人脸、添加随机噪波、将人脸移动几个像素、按一定比例缩放人脸或将人脸旋转几度(即使我们在预处理人脸时特别尝试消除这些影响!)。 让我们将镜像的脸添加到训练集,这样我们既有更大的训练集,又减少了不对称脸的问题,或者如果用户在训练期间总是稍微向左或向右,但不是测试。 此操作如下所示: - -```cpp - // Only process the face if it's noticeably different from the - // previous frame and there has been a noticeable time gap. - if ((imageDiff > 0.3) && (timeDiff_seconds > 1.0)) { - // Also add the mirror image to the training set. - Mat mirroredFace; - flip(preprocessedFace, mirroredFace, 1); - - // Add the face & mirrored face to the detected face lists. - preprocessedFaces.push_back(preprocessedFace); - preprocessedFaces.push_back(mirroredFace); - faceLabels.push_back(m_selectedPerson); - faceLabels.push_back(m_selectedPerson); - - // Keep a copy of the processed face, - // to compare on next iteration. - old_prepreprocessedFace = preprocessedFace; - old_time = current_time; - } -``` - -这将收集预处理后的人脸的前`std::vector`个数组、前`preprocessedFaces`、前和后`faceLabels`数组,以及该人的标签或 ID 号(假设它在整数*`m_selectedPerson`变量中)。 - -为了让用户更明显地看到我们已经将他们的当前面孔添加到集合中,您可以通过在整个图像上显示一个大的白色矩形来提供视觉通知,或者只显示他们的面孔一小部分时间,这样他们就会意识到已经拍摄了一张照片。 有了 OpenCV 的 C++ 接口,你可以使用`+`*重载的*`cv::Mat`*运算符为图像中的每个像素添加一个值,并将其裁剪为 255(使用*`saturate_cast`,这样它就不会从白色溢出到黑色!)。 假设`displayedFrame`将是应该显示的彩色摄像机框架的副本,请在前面的人脸采集代码之后插入以下内容: - -```cpp - // Get access to the face region-of-interest. - Mat displayedFaceRegion = displayedFrame(faceRect); - // Add some brightness to each pixel of the face region. - displayedFaceRegion += CV_RGB(90,90,90); -``` - -# 从采集到的人脸训练人脸识别系统 - -在你收集了足够的人脸让每个人都能识别之后,你必须训练系统使用适合人脸识别的机器学习算法来学习数据。 文献中有很多不同的人脸识别算法,其中最简单的是特征脸和人工神经网络。 特征脸往往比人工神经网络工作得更好,尽管它很简单,但它的工作能力几乎与许多更复杂的人脸识别算法一样好,因此它作为初学者的基本人脸识别算法非常受欢迎,也成为新算法的比较对象。 - -任何希望进一步研究人脸识别的电子读者,建议阅读以下内容背后的理论: - -* 特征脸(也称为**主成分分析**)(**PCA**) -* FisherFaces(也称为**线性判别分析**)(**LDA**) -* 其他经典的人脸识别算法(许多都可以在[http://www.facerec.org/algorithms/](http://www.face-rec.org/algorithms/)上找到) -* 较新的人脸识别算法在最近的计算机视觉研究论文中(如[http://www.cvpapers.com/](http://www.cvpapers.com/)的 CCVPR 和 ICCV),因为每年发表的人脸识别论文数以百计 - -然而,您不需要理解这些算法的理论就可以像本书中所示那样使用它们。 由于 OpenCV 团队和 Philipp Wagner 在`libfacerec`中的贡献,OpenCV v2.4.1 提供了一种简单而通用的方法,可以使用几种不同的算法中的一种(甚至可以在运行时选择)来执行人脸识别,而不必了解它们是如何实现的。 您可以使用`Algorithm::getList()`函数在您的 OpenCV 版本中查找可用的算法,例如使用以下代码: - -```cpp - vector algorithms; - Algorithm::getList(algorithms); - cout << "Algorithms: " << algorithms.size() << endl; - for (auto& algorithm:algorithms) { - cout << algorithm << endl; - } -``` - -以下是 OpenCV v2.4.1 中提供的三种人脸识别算法: - -* `FaceRecognizer.Eigenfaces`:特征脸,也被称为 PCA,1991 年首次被土耳其人和宾特兰人使用 -* `FaceRecognizer.Fisherfaces`渔脸,又称 LDA,由 Belhumeur、Hespanha 和 Kriegman 于 1997 年发明 -* `FaceRecognizer.LBPH`:局部二进制模式直方图,由 Ahonen、Hahad 和 Pietikäinen 于 2004 年发明 - -More information on these face recognition algorithm implementations can be found with documentation, samples, and Python equivalents for each of them on Philipp Wagner's websites ([http://bytefish.de/blog](http://bytefish.de/blog) and [http://bytefish.de/dev/libfacerec/](http://bytefish.de/dev/libfacerec/)). - -这些新的人脸识别算法可以通过 OpenCV 的`contrib`模块中的`FaceRecognizer`类获得。 由于动态链接,您的程序可能链接到`contrib`模块,但实际上并未在运行时加载(如果它被认为不是必需的)。 因此,建议在尝试访问`FaceRecognizer`算法之前调用`cv::initModule_contrib()`函数。 此功能仅在 OpenCV v2.4.1 中可用,因此它还可确保您在编译时至少可以使用人脸识别算法: - -```cpp - // Load the "contrib" module is dynamically at runtime. - bool haveContribModule = initModule_contrib(); - if (!haveContribModule) { - cerr << "ERROR: The 'contrib' module is needed for "; - cerr << "FaceRecognizer but hasn't been loaded to OpenCV!"; - cerr << endl; - exit(1); - } -``` - -要使用其中一种人脸识别算法,我们必须使用`cv::Algorithm::create()`函数创建一个`FaceRecognizer`对象。 我们将想要作为字符串使用的人脸识别算法的名称传递给这个`create`函数。 这将使我们能够访问该算法(如果该算法在 OpenCV 版本中可用)。 因此,它可以用作运行时错误检查,以确保用户拥有 OpenCV v2.4.1 或更高版本。 这方面的一个示例如下所示: - -```cpp - string facerecAlgorithm = "FaceRecognizer.Fisherfaces"; - Ptr model; - // Use OpenCV's new FaceRecognizer in the "contrib" module: - model = Algorithm::create(facerecAlgorithm); - if (model.empty()) { - cerr << "ERROR: The FaceRecognizer [" << facerecAlgorithm; - cerr << "] is not available in your version of OpenCV. "; - cerr << "Please update to OpenCV v2.4.1 or newer." << endl; - exit(1); - } -``` - -一旦我们加载了`FaceRecognizer`算法,我们只需使用收集的面部数据调用`FaceRecognizer::train()`函数,如下所示: - -```cpp - // Do the actual training from the collected faces. - model->train(preprocessedFaces, faceLabels); -``` - -这一行代码将运行您选择的整个人脸识别训练算法(例如,Eogen Faces、FisherFaces 或可能的其他算法)。 如果你只有几个人的脸不到 20 张,那么这个训练应该很快就会回来,但是如果你有很多人脸多的话,可能需要几秒钟,甚至几分钟的时间,才能处理完所有的数据。 - -# 查看学到的知识 - -虽然没有必要,但查看人脸识别算法在学习训练数据时生成的内部数据结构非常有用,特别是如果您了解所选算法背后的理论并想要验证它是否有效,或者找出它没有如您所希望的那样工作的原因。 对于不同的算法,内部数据结构可能不同,但幸运的是,对于 Eogen Faces 和 FisherFaces,内部数据结构是相同的,所以让我们只看这两个。 它们都基于一维特征向量矩阵,在作为 2D 图像查看时,这些矩阵看起来有点像人脸;因此,通常在使用**特征脸**算法时将特征向量称为特征脸,或者在使用**鱼脸**算法时将特征向量称为 Fisherfaces。 - -简单地说,特征脸的基本原理是,它会计算一组特殊的图像(特征脸),以及混合比率(特征值),当它们以不同的方式组合时,可以生成训练集中的每一幅图像,但也可以用来区分训练集中的许多人脸图像。 例如,如果训练集中的一些脸有胡子,而另一些脸没有,那么至少有一个特征脸显示胡子,因此有胡子的训练脸对于该特征脸将具有较高的混合比率,以表明它们包含胡子,而没有胡子的脸对于该特征向量的混合比率将会较低。在训练集中,如果训练集中的一些脸有胡子,则至少有一个特征脸显示有胡子,因此,有胡子的训练脸对于该特征向量具有较高的混合比率,以表明它们包含胡子,而没有胡子的脸对于该特征向量具有较低的混合比率。 - -如果训练集有 5 个人,每个人有 20 个人脸,那么将有 100 个特征脸和特征值来区分训练集中的 100 个人脸,实际上这些将被排序,所以前几个特征脸和特征值将是最关键的区分符,而最后几个特征脸和特征值将仅仅是实际上无助于区分数据的随机像素噪声。 因此,通常的做法是丢弃一些最后的特征面,而只保留前 50 个左右的特征面。 - -相比之下,FisherFaces 的基本原理是不为训练集中的每幅图像计算特殊的特征向量和特征值,而是只为每个人计算一个特殊的特征向量和特征值。 因此,在上一个有 5 个人,每个人有 20 个脸的示例中,特征脸算法将使用 100 个特征脸和特征值,而 Fisherfaces 算法将只使用 5 个 Fisherface 和特征值。 - -要访问 Eogen Faces 和 FisherFaces 算法的内部数据结构,我们必须使用`cv::Algorithm::get()`函数在运行时获取它们,因为在编译时无法访问它们。 数据结构在内部用作数学计算的一部分,而不是用于图像处理,因此它们通常存储为通常介于 0.0 和 1.0 之间的浮点数,而不是范围从`0`到`255`的 8 位浮点数`uchar`,类似于常规图像中的像素。 此外,它们通常是 1D 行或列矩阵,或者它们构成更大矩阵的许多 1D 行或列之一。 因此,在可以显示许多这些内部数据结构之前,必须将它们重塑为正确的矩形形状,并将它们转换为介于`0`和`255`之间的 8 位*`uchar`像素。 由于矩阵数据的范围可能在 0.0 到 1.0 之间,或者-1.0 到 1.0 之间,或者其他任何范围,您可以使用带`cv::NORM_MINMAX`选项的函数来确保它输出 0 到 255 之间的数据,而不管输入范围是什么。 让我们创建一个函数来执行此重塑为矩形并转换为 8 位像素的操作,如下所示: - -```cpp - // Convert the matrix row or column (float matrix) to a - // rectangular 8-bit image that can be displayed or saved. - // Scales the values to be between 0 to 255\. - Mat getImageFrom1DFloatMat(const Mat matrixRow, int height) - { - // Make a rectangular shaped image instead of a single row. - Mat rectangularMat = matrixRow.reshape(1, height); - // Scale the values to be between 0 to 255 and store them - // as a regular 8-bit uchar image. - Mat dst; - normalize(rectangularMat, dst, 0, 255, NORM_MINMAX, - CV_8UC1); - return dst; - } -``` - -为了更容易调试 OpenCV 代码,在内部调试`cv::Algorithm`命令数据结构时,我们可以使用命令`ImageUtils.cpp`命令和命令`ImageUtils.h`命令文件轻松地显示有关命令`cv::Mat`命令结构的信息,如下所示: - -```cpp - Mat img = ...; - printMatInfo(img, "My Image"); -``` - -您将在控制台上看到类似以下内容的打印内容: - -```cpp -My Image: 640w480h 3ch 8bpp, range[79,253][20,58][18,87] -``` - -这告诉您它是 640 个元素宽,480 个元素高(即,640 x 480 图像或 480 x 640 矩阵,取决于您如何查看它),每个像素有三个通道,每个通道 8 位(即,一个常规的 BGR 图像),并且它显示图像中每个颜色通道的最小值和最大值。 - -It is also possible to print the actual contents of an image or matrix by using the `printMat()` function instead of the `printMatInfo()` function. This is quite handy for viewing matrices and multichannel-float matrices, as these can be quite tricky to view for beginners. -The `ImageUtils` code is mostly for OpenCV's C interface, but is gradually including more of the C++ interface over time. The most recent version can be found at [http://shervinemami.info/openCV.html](http://shervinemami.info/openCV.html). - -# 平均脸部 - -特征脸算法和 Fisherfaces 算法都是先计算平均人脸,即所有训练图像的数学平均值,这样就可以从每幅人脸图像中减去平均图像,从而获得更好的人脸识别结果。 那么,让我们来看一下我们训练集中的平均脸部。 在特征脸模型和 Fisherfaces 实现中,平均脸被命名为`mean`*,如下所示: - -```cpp - Mat averageFace = model->get("mean"); - printMatInfo(averageFace, "averageFace (row)"); - // Convert a 1D float row matrix to a regular 8-bit image. - averageFace = getImageFrom1DFloatMat(averageFace, faceHeight); - printMatInfo(averageFace, "averageFace"); - imshow("averageFace", averageFace); -``` - -现在,您应该会在屏幕上看到与下面(放大)的照片类似的平均面部图像,这是一个男人、一个女人和一个婴儿的组合。 您还应该看到控制台上显示的类似文本: - -```cpp - averageFace (row): 4900w1h 1ch 64bpp, range[5.21,251.47] - averageFace: 70w70h 1ch 8bpp, range[0,255] -``` - -该图像将如以下屏幕截图所示: - -![](img/40a7f232-1b12-415c-859e-be1a6d3ed3ba.png) - -请注意,*`averageFace (row)`是 64 位浮点数的单行矩阵,而*`averageFace`是 8 位像素的矩形图像,覆盖了从 0 到 255 的整个范围 -。 - -# 特征值、特征面和鱼子面 - -让我们来看看特征值中的实际分量和值(以文本形式),如下所示: - -```cpp - Mat eigenvalues = model->get("eigenvalues"); - printMat(eigenvalues, "eigenvalues"); -``` - -对于特征脸,每个脸都有一个特征值表,所以如果我们有三个人,每个人有四个脸,我们就会得到一个具有 12 个特征值的列向量,从最好到最差排序如下: - -```cpp - eigenvalues: 1w18h 1ch 64bpp, range[4.52e+04,2.02836e+06] - 2.03e+06 - 1.09e+06 - 5.23e+05 - 4.04e+05 - 2.66e+05 - 2.31e+05 - 1.85e+05 - 1.23e+05 - 9.18e+04 - 7.61e+04 - 6.91e+04 - 4.52e+04 -``` - -对于 FisherFaces,每个额外的人只有一个特征值,所以如果有三个人,每个人有四个脸,我们只得到一个行向量,其中有两个特征值,如下所示: - -```cpp - eigenvalues: 2w1h 1ch 64bpp, range[152.4,316.6] - 317, 152 -``` - -要查看特征向量(如特征面或鱼脸图像),必须从大的特征向量矩阵中按列提取它们。 由于 OpenCV 和 C/C++ 中的数据通常以行为主的顺序存储在矩阵中,这意味着要提取一列,我们应该使用`Mat::clone()`函数来确保数据是连续的,否则我们不能将数据整形为矩形。 一旦我们有了一个连续的列,*`Mat`,我们就可以使用*`getImageFrom1DFloatMat()`*函数来显示特征向量,就像我们对平均面部所做的那样: - -```cpp - // Get the eigenvectors - Mat eigenvectors = model->get("eigenvectors"); - printMatInfo(eigenvectors, "eigenvectors"); - - // Show the best 20 Eigenfaces - for (int i = 0; i < min(20, eigenvectors.cols); i++) { - // Create a continuous column vector from eigenvector #i. - Mat eigenvector = eigenvectors.col(i).clone(); - - Mat eigenface = getImageFrom1DFloatMat(eigenvector, - faceHeight); - imshow(format("Eigenface%d", i), eigenface); - } -``` - -下面的屏幕截图将特征向量显示为图像。 您可以看到,对于三个有四张脸的人,有 12 个特征脸(屏幕截图的左侧),或两个渔夫脸(屏幕截图的右侧): - -![](img/d3290410-d43d-4764-91b4-0bd70a12645f.png) - -请注意,特征脸和渔夫脸看起来都与某些面部特征相似,但它们看起来并不真的像脸。 这很简单,因为从它们中减去了平均脸,所以它们只显示了每个特征脸与平均脸的差异。 编号显示的是哪个特征面,因为它们总是从最重要的特征面到最不重要的特征面排序,如果您有 50 个或更多的特征面,那么后面的特征面通常只会显示随机的图像噪声,因此应该被丢弃。 - -# 人脸识别 - -现在我们已经用我们的一组训练图像和人脸标签训练了特征脸或 FisherFaces 机器学习算法,我们终于准备好从面部图像中找出一个人是谁了! 最后一步被称为人脸识别或人脸识别。 - -# 人脸识别技术--从人脸识别人 - -多亏了 OpenCV 的`FaceRecognizer`类,我们可以简单地通过调用面部图像 -上的`FaceRecognizer::predict()`函数来识别照片中的人,如下所示: - -```cpp - int identity = model->predict(preprocessedFace); -``` - -这个`identity`的值将是我们最初收集人脸样本进行训练时使用的标签号,例如,第一人称为零,第二人称为一,依此类推。 - -这种识别的问题是,它总是能预测出某个给定的人,即使输入的照片是一个不知名的人,或者是一辆汽车。 它仍然会告诉你哪个人是照片中最有可能的人,所以很难相信结果! 解决方案是获得一个置信度度量,这样我们就可以判断结果的可靠性,如果置信度似乎太低,那么我们就假设他是一个未知的人。 - -# 人脸验证-确认这就是被认领的人 - -为了确认预测结果是可靠的,还是应该被认为是一个未知的人,我们执行**人脸验证**测试(也称为**人脸认证**),以获得显示单张人脸图像是否与声称的人相似的可信度度量(而不是我们刚刚执行的人脸识别,将单张人脸图像与多人进行比较)。 - -OpenCV 的`FaceRecognizer`类可以在调用`predict()`函数时返回置信度度量,但不幸的是,置信度度量只是基于特征子空间中的距离,因此不是很可靠。 我们将使用的方法是使用第*个特征向量*和第二个特征值来重建面部图像,并将重建的图像与输入图像进行比较。 如果该人的许多面部包括在训练集中,则根据学习的特征向量和特征值进行重建应该工作得相当好,但是如果该人在训练集中没有任何面部(或者没有任何具有与测试图像相似的光照和面部表情的面部),则重建的面部看起来将与输入面部非常不同,这表明它可能是未知的面部。 - -记住,我们在前面说过,特征脸和鱼脸算法是基于这样的概念,即图像可以粗略地表示为一组特征向量(特殊人脸图像)和特征值(混合比率)。 因此,如果我们将所有的特征向量与训练集中某个人脸的特征值相结合,那么我们应该可以得到与原始训练图像相当接近的复制品。 这同样适用于与训练集相似的其他图像;如果我们将训练的特征向量与来自相似测试图像的特征值相结合,我们应该能够重建某种程度上是测试图像的复制品的图像。 - -再一次,OpenCV 的`FaceRecognizer`类通过使用`subspaceProject()`函数投影到特征空间和`subspaceReconstruct()`函数从特征空间返回到图像空间,使得从任何输入图像生成重建脸变得非常容易。 诀窍在于,我们需要将其从浮点行矩阵转换为矩形 8 位图像(就像我们在显示平均人脸和特征脸时所做的那样),但我们不想对数据进行标准化,因为它已经处于与原始图像进行比较的理想比例。 如果对数据进行归一化处理,其亮度和对比度将与输入图像不同,仅用 L2 相对误差来比较图像的相似性将变得困难。 此操作如下所示: - -```cpp - // Get some required data from the FaceRecognizer model. - Mat eigenvectors = model->get("eigenvectors"); - Mat averageFaceRow = model->get("mean"); - - // Project the input image onto the eigenspace. - Mat projection = subspaceProject(eigenvectors, averageFaceRow, - preprocessedFace.reshape(1,1)); - - // Generate the reconstructed face back from the eigenspace. - Mat reconstructionRow = subspaceReconstruct(eigenvectors, - averageFaceRow, projection); - - // Make it a rectangular shaped image instead of a single row. - Mat reconstructionMat = reconstructionRow.reshape(1, - faceHeight); - - // Convert the floating-point pixels to regular 8-bit uchar. - Mat reconstructedFace = Mat(reconstructionMat.size(), CV_8U); - reconstructionMat.convertTo(reconstructedFace, CV_8U, 1, 0); -``` - -下面的屏幕截图显示了两个典型的重建面。 左边的脸重建得很好,因为它来自一个已知的人,而右边的脸重建得很差,因为它来自一个未知的人,或者是一个已知的人,但光照条件/面部表情/脸部方向未知: - -![](img/2f28a8ba-81ed-4236-ad8f-3ef69d498a88.png) - -我们现在可以通过使用我们之前创建的用于比较两幅图像的`getSimilarity()`函数来计算这张重建的人脸与之前输入的人脸有多相似,其中小于 0.3 的值表示这两张图像非常相似。 对于本征面,每个面都有一个特征向量,因此重建效果很好,因此我们通常可以使用 0.5 的阈值,但是 Fisherfaces 对每个人只有一个特征向量,所以重建不会很好地工作,因此需要更高的阈值,比如 0.7。 此操作如下所示: - -```cpp - similarity = getSimilarity(preprocessedFace, reconstructedFace); - if (similarity > UNKNOWN_PERSON_THRESHOLD) { - identity = -1; // Unknown person. - } -``` - -现在,您只需将身份打印到控制台,或者在您的想象力允许的任何地方使用它! 请记住,此人脸识别方法和此人脸验证方法仅在您针对其进行培训的条件下才是可靠的。 因此,为了获得良好的识别准确性,您需要确保每个人的训练集涵盖您期望测试的所有照明条件、面部表情和角度。 脸部预处理阶段有助于减少光照条件和面内旋转(如果人的头朝左肩或右肩倾斜)的一些差异,但对于其他差异,如面外旋转(如果人的头朝向左手边或右手边),只有在训练集中覆盖良好的情况下才能起作用。 - -# 点睛之笔-保存和加载文件 - -您可以添加一个基于命令行的方法来处理输入文件并将其保存到磁盘,甚至可以将人脸检测、人脸预处理和/或人脸识别作为 Web 服务来执行。 对于这些类型的项目,使用`save`类的`save`函数和`load`函数可以很容易地添加所需的功能。 您可能还希望保存训练过的数据,然后在程序启动时加载它。 - -将训练好的模型保存到 XML 或 YML 文件非常容易,如下所示: - -```cpp -model->save("trainedModel.yml"); -``` - -如果以后要向训练集添加更多数据,则可能还需要保存预处理的面和标签的阵列。 - -例如,下面是一些示例代码,用于从文件加载经过训练的模型。 请注意,您必须指定最初用于创建训练模型的人脸识别算法(例如,`FaceRecognizer.Eigenfaces`或`FaceRecognizer.Fisherfaces`): - -```cpp - string facerecAlgorithm = "FaceRecognizer.Fisherfaces"; - model = Algorithm::create(facerecAlgorithm); - Mat labels; - try { - model->load("trainedModel.yml"); - labels = model->get("labels"); - } catch (cv::Exception &e) {} - if (labels.rows <= 0) { - cerr << "ERROR: Couldn't load trained data from " - "[trainedModel.yml]!" << endl; - exit(1); - } -``` - -# 画龙点睛-打造漂亮的交互式图形用户界面 - -虽然本章到目前为止给出的代码对于整个人脸识别系统来说已经足够了,但仍然需要一种方法来将数据输入系统并使用它。 许多用于研究的人脸识别系统会选择理想的输入为文本文件,列出静态图像文件在计算机上的存储位置,以及其他重要数据,如人的真实姓名或身份,或许还有面部区域的真实像素坐标(如面部和眼睛中心实际位置的基本事实)。 这可能是手动收集的,也可能是由另一个人脸识别系统收集的。 - -然后,理想的输出将是将识别结果与基本事实进行比较的文本文件,从而可以获得用于将面部识别系统与其他面部识别系统进行比较的统计数据。 - -然而,由于本章中的人脸识别系统是为学习和实际娱乐目的而设计的,而不是与最新的研究方法竞争,因此拥有易于使用的 GUI 是有用的,它允许从网络摄像头实时交互地进行人脸采集、培训和测试。 因此,本节将向您展示一个提供这些功能的交互式 GUI。 读者应该使用本书附带的图形用户界面,或者根据自己的目的对其进行修改,或者忽略该图形用户界面而设计自己的图形用户界面来执行到目前为止讨论的人脸识别技术。 - -因为我们需要 GUI 来执行多个任务,所以让我们创建一组 GUI 将具有的模式或状态,用户可以使用按钮或鼠标单击来更改模式: - -* **启动**:此状态加载并初始化数据和网络摄像头。 -* **检测**:此状态检测人脸并对其进行预处理,直到用户单击添加人员按钮。 -* **Collection**:此状态收集当前用户的面孔,直到用户单击窗口中的任何位置。 这也显示了每个人的最新面孔。 用户单击现有人员之一或单击添加人员按钮,即可为不同的人员收集人脸。 -* **训练**:在这种状态下,系统在所有收集的人的所有收集的人脸的帮助下进行训练。 -* **识别**:这包括突出显示被识别的人并显示置信度。 用户单击其中一个人员或单击添加人员按钮返回到模式 2(*集合*)。 - -要退出,用户可以随时按窗口中的*Esc*键。 让我们还添加一个重新启动新的人脸识别系统的删除所有功能模式,以及一个切换显示额外调试信息的功能调试功能按钮。 我们可以创建一个枚举的`mode`变量来显示当前模式。 - -# 绘制 GUI 元素 - -为了在屏幕上显示当前模式,让我们创建一个轻松绘制文本的函数。 OpenCV 有一个`cv::putText()`功能,有几种字体和抗锯齿功能,但要把文本放在你想要的位置可能会很棘手。 幸运的是,还有一个用于计算文本周围边界框的函数--`cv::getTextSize()`,因此我们可以创建一个包装器函数,使放置文本变得更容易。 - -我们希望能够沿着窗口的任何边缘放置文本,确保它完全可见,并且还允许将多行或多个单词彼此相邻放置,而不会覆盖。 下面是一个包装器函数,它允许您指定左对齐或右对齐,以及指定上对齐或下对齐,并返回边界框,以便我们可以轻松地在窗口的任何角落或边缘绘制多行文本: - -```cpp - // Draw text into an image. Defaults to top-left-justified - // text, so give negative x coords for right-justified text, - // and/or negative y coords for bottom-justified text. - // Returns the bounding rect around the drawn text. - Rect drawString(Mat img, string text, Point coord, Scalar - color, float fontScale = 0.6f, int thickness = 1, - int fontFace = FONT_HERSHEY_COMPLEX); -``` - -现在要在 GUI 上显示当前模式,因为窗口的背景将是摄像机提要,如果我们简单地在摄像机提要上绘制文本,它很可能与摄像机背景的颜色相同! 所以,让我们只画一个黑色的文本阴影,距离我们想要绘制的前景文本只有一个像素。 我们还在下面画一条有帮助的文本线,这样用户就知道要遵循的步骤。 以下是如何使用`drawString()`函数绘制一些文本的示例: - -```cpp - string msg = "Click [Add Person] when ready to collect faces."; - // Draw it as black shadow & again as white text. - float txtSize = 0.4; - int BORDER = 10; - drawString (displayedFrame, msg, Point(BORDER, -BORDER-2), - CV_RGB(0,0,0), txtSize); - Rect rcHelp = drawString(displayedFrame, msg, Point(BORDER+1, - -BORDER-1), CV_RGB(255,255,255), txtSize); -``` - -以下部分屏幕截图显示了覆盖在摄像机图像上的 GUI 窗口底部的模式和信息: - -![](img/151b2f18-7733-44e7-abcf-81658e0e9096.png) - -我们提到我们需要几个 GUI 按钮,所以让我们创建一个函数来轻松绘制 GUI 按钮,如下所示: - -```cpp - // Draw a GUI button into the image, using drawString(). - // Can give a minWidth to have several buttons of same width. - // Returns the bounding rect around the drawn button. - Rect drawButton(Mat img, string text, Point coord, - int minWidth = 0) - { - const int B = 10; - Point textCoord = Point(coord.x + B, coord.y + B); - // Get the bounding box around the text. - Rect rcText = drawString(img, text, textCoord, - CV_RGB(0,0,0)); - // Draw a filled rectangle around the text. - Rect rcButton = Rect(rcText.x - B, rcText.y - B, - rcText.width + 2*B, rcText.height + 2*B); - // Set a minimum button width. - if (rcButton.width < minWidth) - rcButton.width = minWidth; - // Make a semi-transparent white rectangle. - Mat matButton = img(rcButton); - matButton += CV_RGB(90, 90, 90); - // Draw a non-transparent white border. - rectangle(img, rcButton, CV_RGB(200,200,200), 1, LINE_AA); - - // Draw the actual text that will be displayed. - drawString(img, text, textCoord, CV_RGB(10,55,20)); - - return rcButton; - } -``` - -现在,我们使用`drawButton()`函数创建几个可点击的 GUI 按钮, -将始终显示在 GUI 的左上角,如下面的 -部分屏幕截图所示: - -![](img/80fa4da0-40fc-47fc-adab-3d293163cafb.jpg) - -正如我们提到的,GUI 程序有一些模式可以切换(作为有限状态机),从启动模式开始。 我们将当前模式存储为参数`m_mode`变量。 - -# 启动模式 - -在启动模式下,我们只需要加载 XML 检测器文件来检测人脸和眼睛,并初始化网络摄像头,这一点我们已经介绍过了。 让我们还创建一个带有鼠标回调函数的主 GUI 窗口,每当用户在窗口中移动或单击鼠标时,OpenCV 都会调用该函数。 可能还需要将相机分辨率设置为合理的值;例如,如果相机支持,则分辨率为 640 x 480。 此操作如下所示: - -```cpp - // Create a GUI window for display on the screen. - namedWindow(windowName); - - // Call "onMouse()" when the user clicks in the window. - setMouseCallback(windowName, onMouse, 0); - - // Set the camera resolution. Only works for some systems. - videoCapture.set(CAP_PROP_FRAME_WIDTH, 640); - videoCapture.set(CAP_PROP_FRAME_HEIGHT, 480); - - // We're already initialized, so let's start in Detection mode. - m_mode = MODE_DETECTION; -``` - -# 检测模式 - -在检测模式中,我们想要连续检测人脸和眼睛,在它们周围画矩形或圆圈来显示检测结果,并显示当前预处理的人脸。 事实上,无论我们处于哪种模式,我们都希望显示这些内容。 检测模式唯一的特别之处在于,当用户单击添加人员按钮时,它将切换到下一个模式(*集合*)。 - -如果您还记得检测步骤,在本章中,我们检测阶段的输出如下所示: - -* `Mat preprocessedFace`:预处理的人脸(如果检测到人脸和眼睛 - ) -* `Rect faceRect`:检测到的人脸区域坐标 -* `Point leftEye`,`rightEye`:检测到的左眼和右眼中心坐标 - -因此,我们应该检查是否返回了经过预处理的人脸,如果检测到,则在人脸和眼睛周围画一个矩形和圆圈,如下所示: - -```cpp - bool gotFaceAndEyes = false; - if (preprocessedFace.data) - gotFaceAndEyes = true; - - if (faceRect.width > 0) { - // Draw an anti-aliased rectangle around the detected face. - rectangle(displayedFrame, faceRect, CV_RGB(255, 255, 0), 2, - CV_AA); - - // Draw light-blue anti-aliased circles for the 2 eyes. - Scalar eyeColor = CV_RGB(0,255,255); - if (leftEye.x >= 0) { // Check if the eye was detected - circle(displayedFrame, Point(faceRect.x + leftEye.x, - faceRect.y + leftEye.y), 6, eyeColor, 1, LINE_AA); - } - if (rightEye.x >= 0) { // Check if the eye was detected - circle(displayedFrame, Point(faceRect.x + rightEye.x, - faceRect.y + rightEye.y), 6, eyeColor, 1, LINE_AA); - } - } -``` - -我们将在窗口的顶部中心覆盖当前预处理的面,如下所示: - -```cpp - int cx = (displayedFrame.cols - faceWidth) / 2; - if (preprocessedFace.data) { - // Get a BGR version of the face, since the output is BGR. - Mat srcBGR = Mat(preprocessedFace.size(), CV_8UC3); - cvtColor(preprocessedFace, srcBGR, COLOR_GRAY2BGR); - - // Get the destination ROI. - Rect dstRC = Rect(cx, BORDER, faceWidth, faceHeight); - Mat dstROI = displayedFrame(dstRC); - - // Copy the pixels from src to dst. - srcBGR.copyTo(dstROI); - } - // Draw an anti-aliased border around the face. - rectangle(displayedFrame, Rect(cx-1, BORDER-1, faceWidth+2, - faceHeight+2), CV_RGB(200,200,200), 1, LINE_AA); -``` - -下面的屏幕截图显示了在检测模式下显示的 GUI。 预处理后的人脸显示在上方中心,并标记检测到的人脸和眼睛: - -![](img/3e3f69b5-1a2c-4220-aaf5-031199ec7798.jpg) - -# 采集模式 - -当用户单击“添加人”按钮以表示他们想要开始为新的人收集面孔时,我们进入收集模式。 如前所述,我们将面集合限制为每秒一个面,然后仅当它与之前收集的面有明显变化时才进行。 记住,我们决定不仅要收集经过预处理的人脸,还要收集经过预处理的人脸的镜像。 - -在集合模式中,我们希望显示每个已知人员的最新面孔,并让用户单击其中一个人向其添加更多面孔,或单击添加人员按钮将新的人员添加到集合中。 用户必须单击窗口中间的某个位置才能继续进入下一模式(*培训模式*)。 - -因此,首先我们需要参考为每个人收集的最新面孔。 我们将通过更新整数的前`m_latestFaces`数组来实现这一点,该数组只存储来自大的前`preprocessedFaces`前数组的每个人的数组索引(即所有人的所有面孔的集合)。 由于我们还将镜像面存储在该数组中,因此我们希望引用倒数第二个面,而不是最后一个面。 此代码应附加到将新面(和镜像面)添加到第一`preprocessedFaces`数组的代码中: - -```cpp - // Keep a reference to the latest face of each person. - m_latestFaces[m_selectedPerson] = preprocessedFaces.size() - 2; -``` - -我们只需记住,无论何时添加或删除新的人员(例如,由于用户单击了添加人员按钮),都要始终扩大或缩小`m_latestFaces`成员数组。 现在,让我们在窗口的右侧(稍后在收集模式和识别模式下)显示每个收集到的人的最新面孔,如下所示: - -```cpp - m_gui_faces_left = displayedFrame.cols - BORDER - faceWidth; - m_gui_faces_top = BORDER; - for (int i=0; i= 0 && index < (int)preprocessedFaces.size()) { - Mat srcGray = preprocessedFaces[index]; - if (srcGray.data) { - // Get a BGR face, since the output is BGR. - Mat srcBGR = Mat(srcGray.size(), CV_8UC3); - cvtColor(srcGray, srcBGR, COLOR_GRAY2BGR); - - // Get the destination ROI - int y = min(m_gui_faces_top + i * faceHeight, - displayedFrame.rows - faceHeight); - Rect dstRC = Rect(m_gui_faces_left, y, faceWidth, - faceHeight); - Mat dstROI = displayedFrame(dstRC); - - // Copy the pixels from src to dst. - srcBGR.copyTo(dstROI); - } - } - } -``` - -我们还希望突出显示当前正在收集的人员,在他们的面部周围使用粗红色边框。 此操作如下所示: - -```cpp - if (m_mode == MODE_COLLECT_FACES) { - if (m_selectedPerson >= 0 && - m_selectedPerson < m_numPersons) { - int y = min(m_gui_faces_top + m_selectedPerson * - faceHeight, displayedFrame.rows - faceHeight); - Rect rc = Rect(m_gui_faces_left, y, faceWidth, faceHeight); - rectangle(displayedFrame, rc, CV_RGB(255,0,0), 3, LINE_AA); - } - } -``` - -下面的部分屏幕截图显示了收集了几个人的面孔时的典型显示。 用户可以点击右上角的任何一个人来收集该人的更多面孔: - -![](img/9a66d93f-eaf5-4d82-9b01-00846cecc03a.png) - -# 培训模式 - -当用户最终点击窗口中间时,人脸识别算法将开始对所有收集到的人脸进行训练。 但重要的是要确保收集到足够的面孔或人,否则程序可能会崩溃。 一般来说,这只需要确保训练集中至少有一张脸(这意味着至少有一个人)。 但 FisherFaces 算法寻找人与人之间的比较,因此如果训练集中的人少于两人,它也会崩溃。 因此,我们必须检查所选的人脸识别算法是否为 FisherFaces。 如果是,那么我们至少需要两个有脸的人,否则我们需要至少有一个有脸的人。 如果没有足够的数据,程序将返回到收集模式,以便用户可以在训练前添加更多人脸。 - -为了检查是否至少有两个人具有收集到的人脸,我们可以确保当用户点击添加人按钮时,只有在没有任何空的人(即添加了但还没有任何收集到的脸的人)的情况下才会添加一个新的人。 如果只有两个人,并且我们使用的是 Fisherfaces 算法,那么我们必须确保在收集模式期间为最后一个人设置了`m_latestFaces`引用。 然后,当仍然没有向该人添加任何面孔时,将其初始化为`-1`,一旦添加了该人的面孔,它将变为`0`或更高。 此操作如下所示: - -```cpp - // Check if there is enough data to train from. - bool haveEnoughData = true; - if (!strcmp(facerecAlgorithm, "FaceRecognizer.Fisherfaces")) { - if ((m_numPersons < 2) || - (m_numPersons == 2 && m_latestFaces[1] < 0) ) { - cout << "Fisherfaces needs >= 2 people!" << endl; - haveEnoughData = false; - } - } - if (m_numPersons < 1 || preprocessedFaces.size() <= 0 || - preprocessedFaces.size() != faceLabels.size()) { - cout << "Need data before it can be learnt!" << endl; - haveEnoughData = false; - } - - if (haveEnoughData) { - // Train collected faces using Eigenfaces or Fisherfaces. - model = learnCollectedFaces(preprocessedFaces, faceLabels, - facerecAlgorithm); - - // Now that training is over, we can start recognizing! - m_mode = MODE_RECOGNITION; - } - else { - // Not enough training data, go back to Collection mode! - m_mode = MODE_COLLECT_FACES; - } -``` - -培训可能需要几分之一秒,也可能需要几秒钟甚至几分钟,具体取决于收集的数据量。 一旦采集到的人脸训练完成,人脸识别系统将自动进入下*识别模式*。 - -# 识别模式 - -在识别模式下,预处理后的人脸旁边会显示一个置信度,这样用户就可以知道识别的可靠性有多高。 如果置信度高于未知阈值,则会在识别的人周围画一个绿色矩形,以方便显示结果。 如果用户点击添加人员按钮或现有人员之一,可以添加更多面孔进行进一步培训,这会导致程序返回到集合模式。 - -现在,我们已经获得了识别的身份和与重建的人脸的相似性,如前所述。 为了显示置信度,我们知道对于高置信度,L2 相似性值通常在 0 到 0.5 之间,对于低置信度,L2 相似性值通常在 0.5 到 1.0 之间,所以我们可以从 1.0 中减去它,得到 0.0 到 1.0 之间的置信度级别。 - -然后,我们只需使用置信度作为比率绘制一个填充矩形,如下图所示: - -```cpp - int cx = (displayedFrame.cols - faceWidth) / 2; - Point ptBottomRight = Point(cx - 5, BORDER + faceHeight); - Point ptTopLeft = Point(cx - 15, BORDER); - - // Draw a gray line showing the threshold for "unknown" people. - Point ptThreshold = Point(ptTopLeft.x, ptBottomRight.y - - (1.0 - UNKNOWN_PERSON_THRESHOLD) * faceHeight); - rectangle(displayedFrame, ptThreshold, Point(ptBottomRight.x, - ptThreshold.y), CV_RGB(200,200,200), 1, CV_AA); - - // Crop the confidence rating between 0 to 1 to fit in the bar. - double confidenceRatio = 1.0 - min(max(similarity, 0.0), 1.0); - Point ptConfidence = Point(ptTopLeft.x, ptBottomRight.y - - confidenceRatio * faceHeight); - - // Show the light-blue confidence bar. - rectangle(displayedFrame, ptConfidence, ptBottomRight, - CV_RGB(0,255,255), CV_FILLED, CV_AA); - - // Show the gray border of the bar. - rectangle(displayedFrame, ptTopLeft, ptBottomRight, - CV_RGB(200,200,200), 1, CV_AA); -``` - -为了突出显示被识别的人,我们在他们的脸部周围画了一个绿色矩形,如下所示: - -```cpp - if (identity >= 0 && identity < 1000) { - int y = min(m_gui_faces_top + identity * faceHeight, - displayedFrame.rows - faceHeight); - Rect rc = Rect(m_gui_faces_left, y, faceWidth, faceHeight); - rectangle(displayedFrame, rc, CV_RGB(0,255,0), 3, CV_AA); - } -``` - -以下部分屏幕截图显示了在识别模式下运行时的典型显示,在顶部中心显示了预处理后的人脸旁边的置信度测量仪,并在右上角突出显示了被识别的人: - -![](img/23073665-c46c-439c-b6ea-e3717cd51ac7.png) - -# 检查和处理鼠标点击 - -现在我们已经绘制了所有的 GUI 元素,我们需要处理鼠标事件。 当我们初始化显示窗口时,我们告诉 OpenCV 我们想要一个对我们的`onMouse`函数的鼠标事件回调。 - -我们不关心鼠标移动,只关心鼠标单击,因此首先跳过不是鼠标左键单击的鼠标事件,如下所示: - -```cpp - void onMouse(int event, int x, int y, int, void*) - { - if (event != CV_EVENT_LBUTTONDOWN) - return; - - Point pt = Point(x,y); - - ... (handle mouse clicks) - ... - } -``` - -因为我们在绘制按钮时获得了绘制的矩形边界,所以我们只需通过调用 OpenCV 的`inside()`函数来检查鼠标单击位置是否在我们的任何按钮区域中。 现在,我们可以检查我们创建的每个按钮。 - -当用户单击“Add Person”按钮时,我们会向第一个`m_numPersons`个变量添加一个,在第二个`m_latestFaces`个变量中分配更多空间,选择要收集的新人员,然后开始收集模式(无论我们之前处于哪种模式)。 - -但有一个复杂的问题:为了确保训练时每个人至少有一张脸,我们只会在没有面孔为零的人的情况下为新人分配空间。 这将确保我们始终可以检查`m_latestFaces[m_numPersons-1]`的值,看看是否为每个人收集了一张脸。 此操作如下所示: - -```cpp - if (pt.inside(m_btnAddPerson)) { - // Ensure there isn't a person without collected faces. - if ((m_numPersons==0) || - (m_latestFaces[m_numPersons-1] >= 0)) { - // Add a new person. - m_numPersons++ ; - m_latestFaces.push_back(-1); - } - m_selectedPerson = m_numPersons - 1; - m_mode = MODE_COLLECT_FACES; - } -``` - -此方法可用于测试是否有其他按钮单击,例如切换调试标志,如下所示: - -```cpp - else if (pt.inside(m_btnDebug)) { - m_debug = !m_debug; - } -``` - -要处理 Delete All 按钮,我们需要清空主循环本地的各种数据结构(即,无法从鼠标事件回调函数访问),因此我们切换到 Delete All 模式,然后可以从主循环中删除所有内容。 我们还必须处理用户单击主窗口(即,不是按钮)的问题。 如果他们单击右侧的某个人员,则我们希望选择该人员并切换到收集模式。 或者,如果他们在收集模式下单击主窗口,则我们希望切换到培训模式。 此操作如下所示: - -```cpp - else { - // Check if the user clicked on a face from the list. - int clickedPerson = -1; - for (int i=0; i= 0) { - Rect rcFace = Rect(m_gui_faces_left, - m_gui_faces_top + i * faceHeight, faceWidth, faceHeight); - if (pt.inside(rcFace)) { - clickedPerson = i; - break; - } - } - } - // Change the selected person, if the user clicked a face. - if (clickedPerson >= 0) { - // Change the current person & collect more photos. - m_selectedPerson = clickedPerson; - m_mode = MODE_COLLECT_FACES; - } - // Otherwise they clicked in the center. - else { - // Change to training mode if it was collecting faces. - if (m_mode == MODE_COLLECT_FACES) { - m_mode = MODE_TRAINING; - } - } - } -``` - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -本章已经向您展示了创建实时人脸识别应用所需的所有步骤,并且只需使用基本算法,就可以进行足够的预处理,以允许训练集条件和测试集条件之间存在一些差异。 我们使用人脸检测来找出人脸在相机图像中的位置,然后进行几种形式的人脸预处理,以减少不同光照条件、相机和人脸方向以及面部表情的影响。 - -然后,我们用采集到的经过预处理的人脸训练特征脸或 FisherFaces 机器学习系统,最后进行人脸识别,以确定进行人脸验证的人是谁,并提供一个置信度度量,以防是未知的人。 - -我们没有提供以离线方式处理图像文件的命令行工具,而是将上述所有步骤结合到一个独立的实时 GUI 程序中,以允许立即使用人脸识别系统。 您应该能够根据自己的目的修改系统的行为,例如允许自动登录到您的计算机上,或者如果您对提高识别可靠性感兴趣,那么您可以阅读有关人脸识别的最新进展的会议论文,以潜在地改进程序的每一步,直到它足够可靠,以满足您的特定需求。 例如,基于[http://www.facerec.org/algorithms/](http://www.face-rec.org/algorithms/)和[http://www.cvpapers.com](http://www.cvpapers.com)的方法,您可以改进人脸预处理阶段,或者使用更先进的机器学习算法,或者更好的人脸验证算法。 - -# 参考文献 - -* *使用简单特征的增强级联的快速目标检测*,*P.Viola - 和 M.J.Jones*,*IEEE 关于 CVPR 2001*,*第 1 卷*, - *第 511-518 页*的论文集,*第 1 卷*, - *第 511-518 页*,*第 1 卷*, - *第 511-518 页* -* *一组扩展的用于快速目标检测的类 Haar 特征*,*R.Lienhart 和 J.Maydt*,*ICIP 2002 年 IEEE 会议论文集*,*第 1 卷*,*第 900-903 页* -* *使用局部二值模式的人脸描述:在人脸识别中的应用*,*T.Ahonen,A.Hahad 和 M.Pietikäinen*,*IEEE Pami 2006 论文集*,*第 28 卷*,*第 12 期*,*第 2037-2041 页* -* *学习 OpenCV:使用 OpenCV 库的计算机视觉*,*G.Bradski 和 A.Kaehler*,*第 186-190 页*,*O‘Reilly Media*。 -* *用于识别的特征脸*,*M.Turk 和 A.Pentland*,*认知神经科学杂志 3*,*第 71-86 页* -* *特征脸与渔夫脸:使用类特定线性投影的识别*,*P.N.Belhumeur,J.Hespanha 和 D.Kriegman*,*IEEE 关于 Pami 1997 的会议论文集*,*第 19 卷*,*问题 7*,*第 711-720 页*,*Pami 1997*,*第 19 卷*,*问题 7*,*第 711-720 页* -* *基于局部二值模式的人脸识别*,*T.Ahonen,A.Hahad 和 M.Pietikäinen*,*计算机视觉-ECCV 2004*,*第 469-48 页* \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/18.md b/trans/build-cv-proj-opencv4-cpp/18.md deleted file mode 100644 index e57524f8..00000000 --- a/trans/build-cv-proj-opencv4-cpp/18.md +++ /dev/null @@ -1,375 +0,0 @@ -# 使用 ArUco 模块的 Android 摄像头校准和 AR - -运行谷歌 Android 操作系统的移动设备数量超过了所有其他移动操作系统,近年来,它们拥有令人难以置信的计算能力,并配备了高质量的摄像头,这使得它们能够在最高水平上执行计算机视觉。 移动计算机视觉最受欢迎的应用之一是**增强现实****(AR)**。 融合真实世界和虚拟世界在娱乐和游戏、医疗保健、工业和国防等领域都有应用。 移动 AR 的世界正在快速发展,每天都有新的引人注目的演示涌现,不可否认,它是移动硬件和软件开发的引擎。 在本章中,我们将学习如何使用 OpenCV 的 ArUco`contrib`模块、**Android 的 Camera2 API**以及**jMonkeyEngine 3D 游戏引擎**在 Android 生态系统中从头开始实现 AR 应用。 然而,首先我们将使用 ArUco 的 ChArUco 校准板简单地校准我们的 Android 设备的摄像头,它为 OpenCV 的`calib3d`棋盘提供了一个更强大的替代方案。 - -本章将介绍以下主题: - -* 摄像机内参数的光理论介绍及标定过程 -* 利用 Camera2 接口和 ArUco 在 Android 系统中实现摄像机标定 -* 使用 jMonkeyEngine 和 ArUco 标记实现*透视*的 AR 世界 - -# 技术要求 - -本章使用的技术和软件如下: - -* 使用 ArUco contrib 模块编译的 OpenCV v3 或 v4 Android SDK:[https://github.com/Mainvooid/opencv-android-sdk-with-contrib](https://github.com/Mainvooid/opencv-android-sdk-with-contrib) -* Android Studio 3.2+版 -* 运行 Android OS 6.0+版的 Android 设备 - -随附的代码存储库中将提供这些组件的构建说明,以及实现本章中所示概念的代码。 - -要运行这些示例,需要一块打印的校准板。 电路板图像可以使用 ArUco`cv::aruco::CharucoBoard::draw`函数以编程方式生成,然后可以使用家用打印机打印。 如果将纸板粘在硬质表面,如纸板或塑料板上,效果最好。 打印电路板后,应精确测量电路板标记的大小(使用尺子或卡尺),以使校准结果更准确、更真实。 - -本章代码可通过[giHub:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter18](https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter18)访问。 - -# 增强现实与姿态估计 - -增强现实(AR)是汤姆·考德尔(Tom Caudell)在 20 世纪 90 年代初创造的一个概念。 他提出 AR 是从相机进行的现实世界渲染和计算机生成的图形的混合,这些图形可以流畅地融合在一起,创造出存在于现实世界中的虚拟物体的错觉。 在过去的几十年里,增强现实技术取得了长足的进步,从一项几乎没有真正应用的古怪技术,发展到了许多垂直领域的数十亿美元的行业:国防、制造、医疗、娱乐等。 但是,核心概念保持不变(在基于摄影机的 AR 中):在场景中的 3D 几何体之上注册图形。 因此,增强现实最终是关于从图像重建 3D 几何体,跟踪该几何体,以及注册到该几何体的 3D 图形渲染。 其他类型的增强现实使用的传感器与摄像头不同。 最著名的例子之一是在手机上使用陀螺仪和指南针进行增强现实,比如在精灵宝可梦 Go 应用中。 - -在过去,AR 主要基于使用**基准标记**,对比清晰(主要是黑白),通常是矩形印刷标记(请参阅下一节中此类标记的示例)。 使用它们的原因是很容易在图像中找到它们,因为它们的对比度很高,而且它们有四个(或更多)清晰的角,我们可以根据这些角来计算标记相对于相机的平面。 这是自 90 年代第一次 AR 应用以来的做法,至今在许多 AR 技术原型中仍是一种高度使用的方法。 本章将使用这种类型的 AR 检测,但是,如今 AR 技术已经转向其他 3D 几何重建方法,例如**自然标记**(非矩形,大多是非结构化的)、**运动结构(SFM)**和**映射和跟踪**(也称为**同时定位和映射**)(**SLAM**)。 - -近年来 AR 迅速崛起的另一个原因是移动计算的出现。 在过去,渲染 3D 图形和运行复杂的计算机视觉算法需要一台功能强大的 PC,而今天,即使是低端的移动设备也可以轻松地处理这两项任务。 与基于基准的增强现实相比,今天的移动 GPU 和 CPU 已经足够强大,可以处理要求更高的任务。 主要的移动操作系统开发商,如谷歌和苹果,已经提供了基于 SfM 和 SLAM 的 AR 工具包,带有惯性传感器融合,其运行速度高于实时。 AR 还被整合到其他移动设备中,如头戴式显示器、汽车,甚至配备摄像头的飞行无人机。 - -# 摄像机定标 - -在我们手头的视觉任务中,恢复场景中的几何图形,我们将使用**针孔相机模型**,它大大简化了我们先进的数码相机获取图像的方式。 针孔模型本质上描述了世界对象到相机图像中的像素的变换。 下图说明了此过程: - -![](img/4d6640c7-c39a-4bf6-b516-3f9060613f29.png) - -相机图像具有本地 2D 坐标框架(以像素为单位),而 3D 对象在世界上的位置是以任意长度单位(如毫米、米或英寸)描述的。 为了协调这两个坐标帧,针孔相机模型提供了两种变换:**p****垂直投影**和**相机姿势**。 摄影机姿势变换(在上图中表示为*P*)将对象的坐标与摄影机的局部坐标框对齐,例如,如果对象正好在 10 米之外的摄影机光轴前面,则其坐标在米尺度上变为 0、0、10。 姿势(刚性变换)由旋转*R*和平移*t*组件组成,并产生与摄影机局部坐标系对齐的新 3D 位置,如下所示: - -![](img/f60039eb-a006-43d3-9601-77fc846b0b97.png) - -其中*W*‘是 3D 点*W*的**齐次坐标**,该坐标是通过将 1 加到矢量末尾而获得的。 - -下一步是将对齐的 3D 点投影到图像平面上。 直观地说,在上图中,我们可以看到对齐的 3D 点和 2D 像素点存在于相机中心的光线上,这施加了重叠的直角三角形(90 度)约束。 因此,这意味着如果我们知道*z*坐标和*f*系数,我们就可以通过除以*z;*来计算图像平面上的点(*x*i,*y*i),这称为**透视除**。 首先,我们除以*z*以将点带到标准化坐标(距相机投影中心的距离为 1),然后将其乘以一个因子,该系数将真实相机的焦距与图像平面上的像素大小相关联。 最后,我们将相机投影中心(**主点**)的偏移量相加,以结束于像素位置: - -![](img/730d0f0d-0167-4b52-80bd-da277bd2f3d4.png) - -在现实中,确定物体在图像中的位置不仅仅是焦距,还有更多的因素,例如镜头的畸变(**径向畸变,桶形畸变**),这涉及到非线性计算。 此投影变换通常用单个矩阵表示,称为**摄像机内参数矩阵**,通常由*K*表示: - -![](img/024ebec0-b740-4521-a5ac-4eed54a07a98.png) - -**摄像机标定**的过程是求出*K*系数(以及**畸变参数**)的过程,这是计算机视觉中任何精确工作的基础步骤。 它通常是通过给定相关 3D 和 2D 点的测量的优化问题来完成的。 给定足够的对应图像点(*x*i,*y*i)和 3D 点(*u*,*v*,*w*),可以构造如下的**重投影**成本函数: - -![](img/3b267895-5bf6-483c-b674-6657805088d6.png) - -这里的重新投影成本函数寻求最小化原始 2D 图像点![](img/e6d9e676-08f4-4f55-a21c-d3b0dcb7526b.png)和使用投影和姿势矩阵重新投影到场景上的 3D 图像之间的欧几里德距离:![](img/12a9f463-2e1a-42e7-b202-55b2a6f52714.png)。 - -从*K*矩阵的近似值开始(例如,主点可以是图像的精确中心),我们可以通过建立过约束线性系统或诸如**点-n-透视(PNP)**之类的算法,以直接线性的方式估计*P*的值。 然后,我们可以使用关于*K*的参数的在*L*上的梯度迭代地进行,以使用诸如**Levenberg-MarQuardt**的梯度下降算法慢慢地改进它们直到收敛。 这些算法的详细内容超出了本章的范围;但是,它们是在 OpenCV 中实现的,用于摄像机校准。 - -# 用于平面重建的增强现实标记 - -使用 AR 基准标记是为了方便找到他们躺在上面的飞机来拍摄相机。 AR 标记通常有很强的角点或其他几何特征(例如,圆),这些特征不是很清楚,很容易被检测到。 2D 地标以探测器预先知道的方式排列,因此我们可以很容易地建立 2D-3D 点对应。 以下是 AR 基准标记的示例: - -![](img/5f2e0d7f-9ed5-4148-a3c7-2605879d0dd6.png) - -在本例中,有几种类型的二维地标。 在矩形标记中,这些是矩形和内部矩形的角点,而在二维码(中间)中,这些是三个大的方框矩形。 非矩形标记使用圆的中心作为 2D 位置。 - -给定标记上的 2D 点及其成对的 3D 坐标(以毫米为单位),我们可以使用上一节中介绍的原理为每一对编写以下公式: - -![](img/a0ff23fe-0766-4e5a-ac2f-e7cbe04dbe3a.png) - -请注意,由于标记是平坦的,并且不失一般性,它存在于地平面上,其*z*坐标为零,因此我们可以省略*P*矩阵的第三列。 我们只剩下一个 3x3 矩阵要找了。 注意,我们仍然可以恢复整个旋转矩阵;因为它是正交的,所以我们可以使用前两列通过叉积找到第三列:![](img/4e712d12-7947-46db-8915-df6b342cbfde.png)。 剩下的 3x3 矩阵是**单应**;它在一个平面(图像平面)和另一个平面(标记平面)之间转换。 我们可以通过构造齐次线性方程组来估计矩阵的值,如下所示: - -![](img/750c0089-4ad2-4220-820b-ab5d1cfc7b39.png) - -它可以分解成下面的齐次方程组: - -![](img/933b0e4a-ccb5-4743-ad4e-347d7ea47c6f.png) - -我们可以通过将*A*矩阵的**奇异值分解**、![](img/ec41ae8e-7e65-4fab-8bcc-e91884daab02.png)和*V*的最后一列作为解来解决这个问题,我们可以找到*P*。 这将只适用于平面标记,因为我们在前面的平坦度假设。 对于 3D 对象的校准,需要使用更多的线性系统仪器来恢复有效的正交旋转。 还存在其他算法,例如我们前面提到的**透视-n-点**(**PnP**)算法。 这就是我们创建增强现实效果所需的理论基础。 在下一章中,我们将开始在 Android 中构建一个应用来实现这些想法。 - -# Android 操作系统中的摄像头访问 - -大多数(如果不是全部)运行 Android 的移动电话设备都配备了支持视频的摄像头,Android 操作系统提供了从摄像头访问原始数据流的 API。 在 Android 版本 5(API 级别 21)之前,Google 推荐使用较旧的 CameraAPI;然而,在最近的版本中,该 API 被弃用,取而代之的是新的 Camera2API,我们将使用该 API。 谷歌为安卓开发者提供了一个很好的使用 Camera2API 的示例指南:[https://github.com/googlesamples/android-Camera2Basic](https://github.com/googlesamples/android-Camera2Basic)。 在本节中,我们将只讲述几个重要的元素,完整的代码可以在附带的存储库中查看。 - -首先,使用摄像机需要用户权限。 在`AndroidManifest.xml`文件中,我们标记了以下内容: - -```cpp - - - -``` - -我们还请求文件存储访问以保存中间数据或调试映像。 下一步是在应用启动后立即使用屏幕上的对话框请求用户的权限(如果之前尚未授予权限): - -```cpp -if (context.checkSelfPermission(Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) { - context.requestPermissions(new String[] { Manifest.permission.CAMERA }, REQUEST_PERMISSION_CODE); - return; // break until next time, after user approves -} -``` - -请注意,需要一些进一步的检测来处理权限请求的返回。 - -# 查找并打开相机 - -接下来,我们尝试通过扫描设备上的可用摄像头列表来查找合适的后置摄像头。 如果相机是背面的,则会为其提供特征标志,如下所示: - -```cpp -CameraManager manager = (CameraManager) context.getSystemService(Context.CAMERA_SERVICE); -try { - String camList[] = manager.getCameraIdList(); - mCameraID = camList[0]; // save as a class member - mCameraID - for (String cameraID : camList) { - CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraID); - if(characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK) { - mCameraID = cameraID; - break; - } - } - Log.i(LOGTAG, "Opening camera: " + mCameraID); - CameraCharacteristics characteristics = manager.getCameraCharacteristics(mCameraID); - manager.openCamera(mCameraID, mStateCallback, mBackgroundHandler); -} catch (...) { - /* ... */ -} -``` - -当相机打开时,我们查看可用的图像分辨率列表,并挑选一个合适的大小。 好的尺寸不会太大,所以计算不会太长,分辨率要与屏幕分辨率一致,这样才能覆盖整个屏幕: - -```cpp -final int width = 1280; // 1280x720 is a good wide-format size, but we can query the -final int height = 720; // screen to see precisely what resolution it is. - -CameraCharacteristics characteristics = manager.getCameraCharacteristics(mCameraID); -StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP); -int bestWidth = 0, bestHeight = 0; -final float aspect = (float)width / height; -for (Size psize : map.getOutputSizes(ImageFormat.YUV_420_888)) { - final int w = psize.getWidth(), h = psize.getHeight(); - // accept the size if it's close to our target and has similar aspect ratio - if ( width >= w && height >= h && - bestWidth <= w && bestHeight <= h && - Math.abs(aspect - (float)w/h) < 0.2 ) - { - bestWidth = w; - bestHeight = h; - } -} -``` - -我们现在可以请求访问视频源了。 我们将请求访问来自摄像机的原始数据。 几乎所有的 Android 设备都将提供 YUV 420 流,因此以该格式为目标是一种好的做法;然而,我们需要一个转换步骤才能获得 RGB 数据,如下所示: - -```cpp -mImageReader = ImageReader.newInstance(mPreviewSize.getWidth(), mPreviewSize.getHeight(), ImageFormat.YUV_420_888, 2); -// The ImageAvailableListener will get a function call with each frame -mImageReader.setOnImageAvailableListener(mHandler, mBackgroundHandler); - -mPreviewRequestBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW); -mPreviewRequestBuilder.addTarget(mImageReader.getSurface()); - -mCameraDevice.createCaptureSession(Arrays.asList(mImageReader.getSurface()), - new CameraCaptureSession.StateCallback() { - @Override - public void onConfigured( CameraCaptureSession cameraCaptureSession) { - mCaptureSession = cameraCaptureSession; - // ... setup auto-focus here - mHandler.onCameraSetup(mPreviewSize); // notify interested parties - } - - @Override - public void onConfigureFailed(CameraCaptureSession cameraCaptureSession) { - Log.e(LOGTAG, "createCameraPreviewSession failed"); - } - }, mBackgroundHandler); -``` - -从现在开始,我们实现`ImageReader.OnImageAvailableListener`的类将在每一帧中被调用,我们可以访问像素: - -```cpp -@Override -public void onImageAvailable(ImageReader imageReader) { - android.media.Image image = imageReader.acquireLatestImage(); - - //such as getting a grayscale image by taking just the Y component (from YUV) - mPreviewByteBufferGray.rewind(); - ByteBuffer buffer = image.getPlanes()[0].getBuffer(); - buffer.rewind(); - buffer.get(mPreviewByteBufferGray.array()); - - image.close(); // release the image - Important! -} -``` - -此时,我们可以发送字节缓冲区以在 OpenCV 中进行处理。 接下来,我们将使用`aruco`模块开发摄像机校准过程。 - -# 使用 ArUco 进行摄像机校准 - -要像我们前面讨论的那样执行摄像机校准,我们必须获得相应的 2D-3D 点对。 有了 ArUco 标记检测,这项任务就变得简单了。 ArUco 提供了一个创建**校准板**的工具,这是一个由正方形和 AR 标记组成的网格,其中所有参数都是已知的:标记的数量、大小和位置。 我们可以使用家庭或办公室打印机打印这样的单板,打印图像由 ArUco API 提供: - -```cpp -Ptr dict = aruco::Dictionary::get(aruco::DICT_ARUCO_ORIGINAL); -Ptr board = aruco::GridBoard::create( - 10 /* N markers x */, - 7 /* M markers y */, - 14.0f /* marker width (mm) */, - 9.2f /* marker separation (mm) */, - dict); -Mat boardImage; -board->draw({1000, 700}, boardImage, 25); // an image of 1000x700 pixels -cv::imwrite("ArucoBoard.png", boardImage); -``` - -下面是这样一个电路板图像的示例,它是前面代码的结果: - -![](img/1a91d490-3c87-46ea-b4a0-12f1fa232731.png) - -我们需要通过移动相机或电路板来获得电路板的多个视图。 将纸板粘贴在一块硬纸板或塑料上可以方便地在移动纸板时保持纸张平整,或者在移动相机时将纸板平放在桌子上。 我们可以实现一个非常简单的 Android UI 来捕获图像,只需三个按钮:捕获、校准和完成: - -![](img/5bf5964e-233c-46bc-b655-bc4be63e75b4.png) - -正如我们前面看到的,Capture 按钮只是获取灰度图像缓冲区,并调用本机 C++ 函数来检测 ArUco 标记并将它们保存到内存中: - -```cpp -extern "C" -JNIEXPORT jint JNICALL -Java_com_packt_masteringopencv4_opencvarucoar_CalibrationActivity_addCalibration8UImage( - JNIEnv *env, - jclass type, - jbyteArray data_, // java: byte[] , a 8 uchar grayscale image buffer - jint w, - jint h) -{ - jbyte *data = env->GetByteArrayElements(data_, NULL); - Mat grayImage(h, w, CV_8UC1, data); - - vector< int > ids; - vector< vector< Point2f > > corners, rejected; - - // detect markers - aruco::detectMarkers(grayImage, dict, corners, ids, params, rejected); - __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "found %d markers", ids.size()); - - allCorners.push_back(corners); - allIds.push_back(ids); - allImgs.push_back(grayImage.clone()); - imgSize = grayImage.size(); - - __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "%d captures", allImgs.size()); - - env->ReleaseByteArrayElements(data_, data, 0); - - return allImgs.size(); // return the number of captured images so far -} -``` - -以下是使用上一个函数检测到的 ArUco 标记板的示例。 可以使用`cv::aruco::drawDetectedMarkers`来实现检测到的标记的可视化。 正确检测到的标记中的点将用于校准: - -![](img/d5ce224a-2a67-4b49-a76e-79c4b824d645.png) - -在获得足够的图像(来自不同视点的大约 10 个图像通常就足够了)之后,校准按钮调用另一个运行`aruco::calibrateCameraAruco`函数的本机函数,保存的点对应关系数组如下所示: - -```cpp -extern "C" -JNIEXPORT void JNICALL -Java_com_packt_masteringopencv4_opencvarucoar_CalibrationActivity_doCalibration( - JNIEnv *env, - jclass type) -{ - vector< Mat > rvecs, tvecs; - - cameraMatrix = Mat::eye(3, 3, CV_64F); - cameraMatrix.at< double >(0, 0) = 1.0; - - // prepare data for calibration: put all marker points in a single array - vector< vector< Point2f > > allCornersConcatenated; - vector< int > allIdsConcatenated; - vector< int > markerCounterPerFrame; - markerCounterPerFrame.reserve(allCorners.size()); - for (unsigned int i = 0; i < allCorners.size(); i++) { - markerCounterPerFrame.push_back((int)allCorners[i].size()); - for (unsigned int j = 0; j < allCorners[i].size(); j++) { - allCornersConcatenated.push_back(allCorners[i][j]); - allIdsConcatenated.push_back(allIds[i][j]); - } - } - - // calibrate camera using aruco markers - double arucoRepErr; - arucoRepErr = aruco::calibrateCameraAruco(allCornersConcatenated, - allIdsConcatenated, - markerCounterPerFrame, - board, imgSize, cameraMatrix, - distCoeffs, rvecs, tvecs, CALIB_FIX_ASPECT_RATIO); - - __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "reprojection err: %.3f", arucoRepErr); - stringstream ss; - ss << cameraMatrix << endl << distCoeffs; - __android_log_print(ANDROID_LOG_DEBUG, LOGTAG, "calibration: %s", ss.str().c_str()); - - // save the calibration to file - cv::FileStorage fs("/sdcard/calibration.yml", FileStorage::WRITE); - fs.write("cameraMatrix", cameraMatrix); - fs.write("distCoeffs", distCoeffs); - fs.release(); -} -``` - -Done(完成)按钮将使应用进入 AR 模式,在 AR 模式下,校准值用于姿势估计。 - -# 使用 jMonkeyEngine 实现的增强现实 - -校准好相机后,我们就可以继续执行 AR 应用了。 我们将使用**jMonkeyEngine**(**JME**)3D 呈现套件创建一个非常简单的应用,该应用只在标记顶部显示一个普通的 3D 框。 JME 的功能非常丰富,成熟的游戏都是使用它实现的(比如 Rise World);我们可以通过额外的工作将我们的 AR 应用扩展到真正的 AR 游戏中。 在阅读本章时,创建 JME 应用所需的代码比我们在这里看到的要广泛得多,完整的代码可以在本书的代码库中找到。 - -首先,我们需要配置 JME 以显示覆盖的 3D 图形后面的相机视图。 我们将创建一个纹理来存储 RGB 图像像素,并创建一个四边形来显示纹理。 四边形将由**正交**摄影机(无透视)渲染,因为它是没有深度的简单 2D 图像。 - -下面的代码将创建一个`Quad`,这是一个简单的平面四顶点 3D 对象,它将保存摄影机视图纹理并将其拉伸以覆盖整个屏幕。 然后,一个`Texture2D`对象将被附加到`Quad`,这样我们就可以在新图像到达时替换它。 最后,我们将创建一个具有正交投影的`Camera`,并将纹理`Quad`附加到它: - -```cpp -// A quad to show the background texture -Quad videoBGQuad = new Quad(1, 1, true); -mBGQuad = new Geometry("quad", videoBGQuad); -final float newWidth = (float)screenWidth / (float)screenHeight; -final float sizeFactor = 0.825f; - -// Center the Quad in the middle of the screen. -mBGQuad.setLocalTranslation(-sizeFactor / 2.0f * newWidth, -sizeFactor / 2.0f, 0.f); - -// Scale (stretch) the width of the Quad to cover the wide screen. -mBGQuad.setLocalScale(sizeFactor * newWidth, sizeFactor, 1); - -// Create a new texture which will hold the Android camera preview frame pixels. -Material BGMat = new Material(assetManager, "Common/MatDefs/Misc/Unshaded.j3md"); -mCameraTexture = new Texture2D(); -BGMat.setTexture("ColorMap", mCameraTexture); -mBGQuad.setMaterial(BGMat); - -// Create a custom virtual camera with orthographic projection -Camera videoBGCam = cam.clone(); -videoBGCam.setParallelProjection(true); -// Create a custom viewport and attach the quad -ViewPort videoBGVP = renderManager.createMainView("VideoBGView", videoBGCam); -videoBGVP.attachScene(mBGQuad); - -``` - -接下来,我们设置一个虚拟的**透视图**`Camera`来显示图形增强。 重要的是要使用我们早先获得的校准参数,以便虚拟和真实相机对齐。 我们使用校准中的**焦距**参数来设置新的`Camera`对象的**锥体**(查看梯形),方法是将其转换为**视野(FOV)**角度(以度为单位): - -```cpp -Camera fgCam = new Camera(settings.getWidth(), settings.getHeight()); -fgCam.setLocation(new Vector3f(0f, 0f, 0f)); -fgCam.lookAtDirection(Vector3f.UNIT_Z.negateLocal(), Vector3f.UNIT_Y); - -// intrinsic parameters -final float f = getCalibrationFocalLength(); - -// set up a perspective camera using the calibration parameter -final float fovy = (float)Math.toDegrees(2.0f * (float)Math.atan2(mHeightPx, 2.0f * f)); -final float aspect = (float) mWidthPx / (float) mHeightPx; -fgCam.setFrustumPerspective(fovy, aspect, fgCamNear, fgCamFar); - -``` - -摄影机位于原点,面向*-z*方向,并向上指向*y*轴,以匹配 OpenCV 姿势估计算法中的坐标帧。 - -最后,运行的 demo 展示了背景图像上的虚拟立方体,精确地覆盖了 AR 标记: - -![](img/2a14df78-c26b-4307-841a-3a321ba7cfef.png) - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -本章介绍了计算机视觉中的两个关键问题:摄像机标定和摄像机/物体姿态估计。 我们了解了在实践中实现这些概念的理论背景,以及使用`aruco`Conrib 模块在 OpenCV 中实现这些概念的过程。 最后,我们构建了一个 Android 应用,该应用在本地函数中运行 ArUco 代码来校准相机,然后检测 AR 标记。 我们使用 jMonkeyEngine 3D 渲染引擎创建了一个使用 ArUco 校准和检测的非常简单的增强现实应用。 - -在下一章中,我们将了解如何在 iOS 应用环境中使用 OpenCV 来构建全景拼接应用。 在移动环境中使用 OpenCV 是 OpenCV 的一个非常流行的特性,因为该库为 Android 和 iOS 提供了预先构建的二进制文件和版本。 \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/19.md b/trans/build-cv-proj-opencv4-cpp/19.md deleted file mode 100644 index 89a62115..00000000 --- a/trans/build-cv-proj-opencv4-cpp/19.md +++ /dev/null @@ -1,325 +0,0 @@ -# 带缝合模块的 iOS 全景图 - -全景成像从摄影的早期就已经存在了。 在那些古老的时代,大约 150 年前,它被称为**画法**,它用胶带或胶水仔细地将单个图像组合在一起,重建出全景。 随着计算机视觉的发展,全景拼接成为几乎所有数码相机和移动设备上的便捷工具。 如今,创建全景图很简单,只需在视图中滑动设备或相机,拼接计算就会立即发生,最终展开的场景就可以查看了。 在本章中,我们将使用 OpenCV 的 iOS 预编译库在 iPhone 上实现一个适度的全景图像拼接应用。 我们先来研究一下图像拼接背后的一些数学和理论,然后选择相关的 OpenCV 函数来实现,最后用一个基本的 UI 把它集成到 iOS 应用中。 - -本章将介绍以下主题: - -* 图像拼接与全景图构建概念简介 -* OpenCV 的图像拼接模块及其功能 -* 构建用于全景捕获的 SWIFT iOS 应用 UI -* 将 Objective C++ 编写的 OpenCV 组件与 SWIFT 应用集成 - -# 技术要求 - -重新创建本章内容需要以下技术和安装: - -* 运行 MacOS High Sierra v10.13+的 MacOSX 计算机(例如 MacBook、iMac) -* 运行 iOS v11+的 iPhone 6+ -* Xcode v9+ -* CocoaPods v1.5+:https://cocoapods.org/[CocoaPods](https://cocoapods.org/) -* 通过 CocoaPods 安装 OpenCV V4.0 - -前面组件的构建说明以及实现本章中所示概念的代码将在附带的代码存储库中提供。 - -本章的代码可以通过 gihub:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter19访问。 - -# 全景图像拼接方法 - -全景图本质上是将多幅图像融合成一幅图像。 从多个图像创建全景图的过程涉及许多步骤;其中一些是其他计算机视觉任务所共有的,如下所示: - -* 提取二维特征 -* 基于图像特征的图像对匹配 -* 将图像转换或扭曲为公共边框 -* 使用(混合)图像之间的接缝,以获得较大图像令人愉悦的连续效果 - -其中一些基本操作在**运动结构**(**SFM**)、**3D 重建**、**视觉里程计**、**同步定位和测绘**(**SLAM**)中也很常见。 我们已经在[第 14 章](14.html)、*使用 SfM 模块*和[第 18 章](18.html)、*Android 摄像机校准和 AR 使用 ArUco 模块*中讨论了其中的一些内容。 以下是全景图创建过程的粗略图像: - -![](img/c384956f-9772-4891-b3b2-b129a60e7db4.png) - -在这一部分中,我们将简要回顾一下特征匹配、摄像机姿态估计和图像扭曲。 实际上,全景缝合有多条路径和多个类,具体取决于输入和所需输出的类型。 例如,如果相机有鱼眼镜头(视角极高),则需要特殊处理。 - -# 全景图的特征提取与鲁棒匹配 - -我们从重叠的图像创建全景图。 在重叠区域中,我们寻找**将两个图像配准**(对齐)的共同视觉特征。 在 SFM 或 SLAM 中,我们逐帧执行此操作,在帧之间重叠极高的实时视频序列中寻找匹配特征。 然而,在全景图中,我们得到的帧之间有一个很大的运动分量,重叠部分可能只占图像的 10%-20%。 首先提取图像特征,如**尺度不变特征变换(SIFT)**、**加速稳健特征**(**SURF**)、面向**的 Brief**(**Orb**)等图像特征,然后在全景图中的图像之间进行匹配。 请注意,SIFT 和 SURF 功能受专利保护,不能用于商业目的。 ORB 被认为是一种免费的替代方案,但不是很健壮。 - -下图显示了提取的特征及其匹配: - -![](img/709cb9a8-78bc-4b80-8b28-ff80fba2ed9e.png) - -# 仿射约束 - -对于健壮且有意义的两两匹配,我们通常会应用几何约束。 一个这样的约束可以是**仿射变换**,这是一种只允许缩放、旋转和平移的变换。 在 2D 中,仿射变换可以在 2 x 3 矩阵中建模: - -![](img/3978be4a-cca7-40be-b150-2dd553d7f611.png) - -为了施加约束,我们寻找一个仿射变换![](img/5f30d0c6-0f5a-4d78-a092-52655ad69162.png),它可以最小化来自左![](img/783b0546-9051-4c44-beab-ee0ea4dbfb65.png)和右![](img/a9da41d3-624d-4b50-bc2e-acfb6e0a7860.png)图像的匹配点之间的距离(误差)。 - -# 随机样本一致性(RANSAC) - -在上图中,我们演示了这样一个事实:并非所有的点都符合仿射约束,并且大多数匹配对因不正确而被丢弃。 因此,在大多数情况下,我们使用基于投票的估计方法,如**随机样本一致性(RANSAC)**,即随机选择一组点直接(通过齐次线性系统)求解假设*M*,然后在所有点之间进行投票以支持或拒绝这一假设。 - -以下是 RANSAC 的伪算法: - -1. 查找图像*i*和图像*j*中的点之间的匹配。 -2. 初始化图像*i*和*j*之间的转换假设,支持度最小。 -3. 虽然不是融合的,但是: - 1. 选择一小组随机的点对。 对于仿射变换,三对就足够了。 - 2. 基于对集合直接计算仿射变换*T*,例如使用线性方程组计算仿射变换*T[T1* - 3. 计算支座。 对于整个*i,j*匹配中的每个点*p*: - * 如果图像*j*中的变换点与图像*i*中的匹配点之间的距离(第二**误差**)在小阈值*t*内:*![](img/2e0523de-351b-4322-ab9e-7022cc26facc.png)*,则*向支持计数器加 1。 - 4. 如果支持数大于当前假设的支持,则取*T*作为新假设。 - 5. 可选:如果支持足够大(或不同的中断策略为真),则中断;否则,继续迭代。 -4. 返回最新且支持最好的假设转换。 -5. 另外,返回**支持掩码**:一个二进制变量,说明匹配中的一个点是否支持最终假设。 - -算法的输出将提供具有最高支持度的变换,并且支持掩码可用于丢弃不支持的点。 我们还可以推论支持点的数量,例如,如果我们观察到的支持点少于 50%,我们就可以认为这场比赛不好,根本不会尝试匹配这两个图像。 - -RANSAC 还有其他选择,如**最小中值平方(LMedS)**算法,它与 RANSAC 没有太大区别:它不计算支撑点,而是计算每个变换假设的平方误差中值,最后返回中值误差最小的假设。 - -# 单应约束 - -虽然仿射变换对于缝合扫描的文档很有用(例如,从平板扫描仪缝合),但它们不能用于缝合照片全景图。 对于拼接照片,我们可以使用相同的过程来找到**单应**,即一个平面和另一个平面之间的变换,而不是仿射变换,它有八个自由度,并以 3x3 矩阵表示,如下所示: - -![](img/e5f479b4-92d9-47d5-adf4-acdee0dee012.png) - -一旦找到合适的匹配,我们就可以找到图像的排序,以便为全景图对它们进行排序,本质上是为了了解图像之间是如何相互关联的。 在大多数情况下,在全景图中,假设摄影师(相机)静止不动,只绕其轴线旋转,例如,从左向右扫视。 因此,目标是恢复摄影机姿势之间的旋转分量。 如果我们认为输入是纯旋转的,那么单应可以分解以恢复旋转:![](img/acbd7f4f-4437-4d20-80f8-c2deea6a0925.png)。如果我们假设单应最初是由摄像机固有的(校准)、矩阵*K、*和一个 3x3 旋转矩阵*R*组成的,如果我们知道*K*,我们可以恢复*R*。 本征矩阵可以通过摄像机提前校准来计算,或者可以在全景创建过程中估计。 - -# 捆绑平差 - -当在所有照片*对*之间实现了*局部*转换时,我们可以在*全局*步骤中进一步优化我们的解决方案。 这被称为**束调整**的过程,被广泛地构建为所有重建参数(相机或图像变换)的全局优化。 如果图像之间的所有匹配点都放在相同的坐标框架(例如,3D 空间)中,并且存在跨越两个以上图像的约束,则全局束平差的执行效果最好。 例如,如果一个特征点出现在全景图中的两个以上图像中,则它对于*全局*优化非常有用,因为它涉及注册三个或更多视图。 - -大多数捆绑平差方法的目标是使平均**重建误差**最小化。 这意味着,希望将视图的近似参数(例如相机或图像变换)调整为值,以便重新投影回原始视图上的二维点将以最小的误差对齐。 这可以用数学的方式表示如下: - -![](img/dd2a3827-da00-40e0-a301-4ec659bf4e6c.png) - -其中我们寻找最佳摄像机或图像变换*T*,使得原始点*XI*和重新投影点*Proj(Tj,Xi)*之间的距离最小。 二进制变量*vij*标记点*i*是否可以在图像*j*中看到,并可能导致错误。 这类优化问题可以用**迭代非线性最小二乘法**求解,如**Levenberg-MarQuardt**,因为以前的*Proj*函数通常是非线性的。 - -# 用于全景创建的扭曲图像 - -如果我们知道图像之间的单应关系,我们就可以应用它们的逆来将所有图像投影到同一平面上。 但是,例如,如果所有图像都投影到第一个图像的平面上,则使用单应性的直接扭曲最终会产生拉伸的外观。 在下图中,我们可以看到使用*拼接的*单应(透视)扭曲的 4 个图像的拼接,这意味着所有图像都对准到第一个图像的平面,这说明了笨拙的拉伸: - -![](img/2adc78d0-78fc-4aad-8d0d-59890b0887ae.png) - -为了解决这个问题,我们把全景看作是从圆柱体内部看图像,图像被投影到圆柱体的壁上,我们旋转中心的相机。 要实现此效果,我们首先需要将图像扭曲到**柱面坐标**,就好像圆柱体的圆壁被松开并展平为矩形一样。 下图说明了圆柱形翘曲的过程: - -![](img/ba1e4934-4875-4f06-8b66-be3d97437250.png) - -为了在柱面坐标中包装图像,我们首先应用本征矩阵的逆来获得归一化坐标中的像素。 我们现在假设像素是圆柱体表面上的一个点,该点由高度*h*和角度*θ*参数化。 高度*h*实质上对应于*y*坐标,而*x*和*z*(相对于*y*彼此垂直)存在于单位圆上,因此分别对应于 Sin*θ*和 Cos*θ,Sin*。 要获得与原始图像相同像素大小的扭曲图像,我们可以再次应用固有矩阵*K*;但是,我们可以更改焦距参数*f*,以影响全景图的输出分辨率。 - -在柱面扭曲模型中,图像之间的关系变成纯粹的平移关系,实际上由单个参数控制:*θ**。*要将图像缝合在同一平面上,我们只需找到θ*s,这只是一个自由度,与为每两个连续图像之间的单应性找到八个参数相比,这是很简单的。 圆柱法的一个主要缺点是,我们假设相机的旋转轴运动与其上方轴完全对齐,并且在其位置上保持静止,这在手持相机中几乎从来不是这样的。 尽管如此,柱面全景图仍能产生非常令人满意的效果。 另一个扭曲选项是**球面坐标**,它允许在*x*和*y*轴上缝合图像时有更多选项。* - - *# 项目概述 - -该项目将包括以下两个主要部分: - -* 支持捕捉全景的 iOS 应用 -* OpenCV Objective-用于从图像创建全景图并集成到应用中的 C++ 代码 - -IOS 代码主要涉及构建 UI、访问摄像头和捕获图像。 然后,我们将重点介绍如何将图像转换为 OpenCV 数据结构,并从`stitch`模块运行图像拼接功能。 - -# 使用 CocoaPods 设置 iOS OpenCV 项目 - -要开始在 iOS 中使用 OpenCV,我们必须导入为 iOS 设备编译的库。 使用 CocoaPods 很容易做到这一点,CocoaPods 是一个庞大的 iOS 和 MacOS 外部包存储库,它有一个名为`pod`的方便的命令行包管理器实用程序。 - -我们首先为 iOS 创建一个空的 Xcode 项目,模板为“*Single View App*”。 确保选择 SWIFT 项目,而不是 Objective-C 项目。 稍后将添加我们将看到的 Objective-C++ 代码。 - -在某个目录中初始化工程后,我们在该目录下的终端中执行`pod init`命令。 这将在目录中创建一个名为`Podfile`的新文件。 我们需要编辑该文件,使其如下所示: - -```cpp -# Uncomment the next line to define a global platform for your project -# platform :ios, '9.0' - -target 'OpenCV Stitcher' do - use_frameworks! - # Pods for OpenCV Stitcher - pod 'OpenCV2', '4.0.0.beta' -end -``` - -本质上,只要将`pod 'OpenCV2', '4.0.0'`添加到`target`,就会告诉 CocoaPods 下载并解压缩我们项目中的 OpenCV 框架。 然后,我们在终端的同一目录下运行`pod install`,这将设置我们的项目和工作区以包括所有 Pod(在我们的例子中只有 OpenCVv4)。 要开始处理项目,我们打开`$(PROJECT_NAME).xcworkspace`文件,而不是像 Xcode 项目那样打开`.xcodeproject`文件。 - -# 用于全景捕捉的 iOS UI - -在深入研究将图像集合转换为全景图的 OpenCV 代码之前,我们将首先构建一个 UI 来支持轻松捕获一系列重叠图像。 首先,我们必须确保我们可以访问摄像机以及保存的图像。 打开`Info.plist`文件并添加以下三行: - -![](img/0c7e32e8-47e6-47ca-bcda-e86d735880ae.png) - -要开始构建 UI,我们将创建一个视图,右侧是相机预览的`View`对象,左侧是重叠的`ImageView`对象。 `ImageView`应覆盖摄像机预览视图的某些区域,以帮助指导用户捕获与上一幅图像有足够重叠的图像。 我们还可以在顶部添加几个`ImageView`实例以显示以前捕获的图像,并在底部添加一个捕获按钮和一个缝合按钮来控制应用流: - -![](img/ed59748d-1ae9-4235-8445-14198549eacb.png) - -要将相机预览连接到预览视图,必须执行以下操作: - -1. 启动捕获会话(`AVCaptureSession`) -2. 选择设备(`AVCaptureDevice`) -3. 使用来自设备的输入设置捕获会话(`AVCaptureDeviceInput`) -4. 添加用于捕获照片的输出(`AVCapturePhotoOutput`) - -当它们被初始化为 ViewController 类的成员时,大多数都可以立即设置。 以下代码显示如何动态设置捕获会话、设备和输出: - -```cpp -class ViewController: UIViewController, AVCapturePhotoCaptureDelegate { - - private lazy var captureSession: AVCaptureSession = { - let s = AVCaptureSession() - s.sessionPreset = .photo - return s - }() - private let backCamera: AVCaptureDevice? = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) - - private lazy var photoOutput: AVCapturePhotoOutput = { - let o = AVCapturePhotoOutput() - o.setPreparedPhotoSettingsArray([AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg])], completionHandler: nil) - return o - }() - var capturePreviewLayer: AVCaptureVideoPreviewLayer? -``` - -其余的初始化可以通过`viewDidLoad`函数来完成,例如,将捕获输入添加到会话中,并创建用于在屏幕上显示摄像机视频源的预览层。 以下代码显示了初始化过程的其余部分,将输入和输出添加到捕获会话,并设置预览层。 - -```cpp - override func viewDidLoad() { - super.viewDidLoad() - - let captureDeviceInput = try AVCaptureDeviceInput(device: backCamera!) - captureSession.addInput(captureDeviceInput) - captureSession.addOutput(photoOutput) - - capturePreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession) - capturePreviewLayer?.videoGravity = AVLayerVideoGravity.resizeAspect - capturePreviewLayer?.connection?.videoOrientation = AVCaptureVideoOrientation.portrait - - // add the preview layer to the view we designated for preview - let previewViewLayer = self.view.viewWithTag(1)!.layer - capturePreviewLayer?.frame = previewViewLayer.bounds - previewViewLayer.insertSublayer(capturePreviewLayer!, at: 0) - previewViewLayer.masksToBounds = true - captureSession.startRunning() - } -``` - -设置好预览后,只需单击一下即可处理照片捕获。 下面的代码显示了单击按钮(`TouchUpInside`)将如何通过`delegate`触发`photoOutput`函数,然后简单地将新图像添加到列表中,并将其保存到照片库中的内存中。 - -```cpp -@IBAction func captureButton_TouchUpInside(_ sender: UIButton) { - photoOutput.capturePhoto(with: AVCapturePhotoSettings(), delegate: self) -} - -var capturedImages = [UIImage]() - -func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { - let cgImage = photo.cgImageRepresentation()!.takeRetainedValue() - let image = UIImage(cgImage: cgImage) - prevImageView.image = image // save the last photo, for the overlapping ImageView - capturedImages += [image] // add to array of captured photos - - // save to photo gallery on phone as well - PHPhotoLibrary.shared().performChanges({ - PHAssetChangeRequest.creationRequestForAsset(from: image) - }, completionHandler: nil) -} -``` - -这将允许我们连续捕捉多幅图像,同时帮助用户将一幅图像与另一幅图像对齐。 以下是在手机上运行的 UI 示例: - -![](img/ccb364f9-6abb-4ac4-a5d0-99275e9792ea.png) - -接下来,我们将了解如何将图像置于 Objective-C++ 上下文中,在该上下文中,我们可以使用 OpenCV C++ API 进行全景拼接。 - -# Objective-C++ 包装器中的 OpenCV 拼接 - -为了在 iOS 中工作,OpenCV 提供了可以从 Objective-C++ 调用的常用 C++ 接口。 然而,最近几年,苹果鼓励 iOS 应用开发者使用更通用的 SWIFT 语言来构建应用,放弃 Objective-C。 幸运的是,可以很容易地在 SWIFT 和 Objective-C(以及 Objective-C++)之间建立一座桥梁,使我们能够从 SWIFT 调用 Objective-C 函数。 Xcode 自动化了大部分过程,并创建了必要的粘合代码。 - -首先,我们在 Xcode 中创建一个新文件(`Command-N`),并选择 Cocoa Touch Class,如以下屏幕截图所示: - -![](img/898f47aa-4581-4e40-8157-3ffa5ec0c5b2.png) - -为文件选择一个有意义的名称(例如,StitchingWrapper),并确保选择 Objective-C 作为语言,如以下屏幕截图所示: - -![](img/acafb684-aec8-46b3-9f6a-889bfca2c705.png) - -接下来,如以下屏幕截图所示,确认 Xcode 应该为您的 Objective-C 代码创建**桥头**代码: - -![](img/5f6adf49-9ec3-4961-a78c-07c94e5dce39.png) - -此过程将产生三个文件:`StitchingWrapper.h`、`StitchingWrapper.m`和`OpenCV Stitcher-Bridging-Header.h`。 我们应该手动将`StitchingWrapper.m`重命名为`StitchingWrapper.mm`,以启用 Objective-**C++**而不是普通的 Objective-C。此时,我们准备开始在 Objective-C++ 代码中使用 OpenCV。 - -在 StitchingWrapper.h 中,我们将定义一个新函数,该函数将接受`NSMutableArray*`作为前面的 UI SWIFT 代码捕获的图像列表: - -```cpp -@interface StitchingWrapper : NSObject - -+ (UIImage* _Nullable)stitch:(NSMutableArray*) images; - -@end -``` - -而且,在我们的 ViewController 的 SWIFT 代码中,我们可以实现一个函数来处理单击 Stitch 按钮,其中我们从`UIImage`的`capturedImages`SWIFT 数组创建`NSMutableArray`: - -```cpp -@IBAction func stitch_TouchUpInside(_ sender: Any) { - let image = StitchingWrapper.stitch(NSMutableArray(array: capturedImages, copyItems: true)) - if image != nil { - PHPhotoLibrary.shared().performChanges({ // save stitching result to gallery - PHAssetChangeRequest.creationRequestForAsset(from: image!) - }, completionHandler: nil) - } -} -``` - -回到 Objective-C++ 端,我们首先需要从`UIImage*`的输入获取 OpenCV`cv::Mat`对象,如下所示: - -```cpp -+ (UIImage* _Nullable)stitch:(NSMutableArray*) images { - using namespace cv; - - std::vector imgs; - - for (UIImage* img in images) { - Mat mat; - UIImageToMat(img, mat); - if ([img imageOrientation] == UIImageOrientationRight) { - rotate(mat, mat, cv::ROTATE_90_CLOCKWISE); - } - cvtColor(mat, mat, cv::COLOR_BGRA2BGR); - imgs.push_back(mat); - } -``` - -最后,我们准备对图像数组调用`stitching`函数,如下所示: - -```cpp - Mat pano; - Stitcher::Mode mode = Stitcher::PANORAMA; - Ptr stitcher = Stitcher::create(mode, false); - try { - Stitcher::Status status = stitcher->stitch(imgs, pano); - if (status != Stitcher::OK) { - NSLog(@"Can't stitch images, error code = %d", status); - return NULL; - } - } catch (const cv::Exception& e) { - NSLog(@"Error %s", e.what()); - return NULL; - } -``` - -使用此代码创建的输出全景示例(请注意柱面扭曲的使用)如下所示: - -![](img/58c95457-9cec-4fed-b0eb-a8becdfe9033.png) - -当边缘已混合时,您可能会注意到四个图像之间的照明发生了一些变化。 可以使用`cv::detail::ExposureCompensator`基础 API 在 OpenCV 图像拼接 API 中处理变化的照明。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -在这一章中,我们学习了全景创作。 我们已经看到了在 OpenCV 的`stitching`模块中实现的全景创建的一些基本理论和实践。 然后,我们将重点转向创建一个 iOS 应用,该应用可以帮助用户捕捉与重叠视图拼接的全景图像。 最后,我们了解了如何从 SWIFT 应用调用 OpenCV 代码来对捕获的图像运行`stitching`函数,从而生成完整的全景图。 - -下一章将重点介绍 OpenCV 算法的选择策略,给出一个手头的问题。 我们将了解如何在 OpenCV 中推理计算机视觉问题及其解决方案,以及如何比较竞争算法以便做出明智的选择。 - -# 进一步阅读 - -**里克·塞利斯基关于计算机视觉的书**:[http://szeliski.org/Book/](http://szeliski.org/Book/) - -**OpenCV 的图像拼接教程**:[https://docs.opencv.org/trunk/d8/d19/tutorial_stitcher.html](https://docs.opencv.org/trunk/d8/d19/tutorial_stitcher.html) - -**OpenCV 的单应扭曲教程**:[https://docs.opencv.org/3.4.1/d9/dab/tutorial_homography.html#tutorial_homography_Demo5](https://docs.opencv.org/3.4.1/d9/dab/tutorial_homography.html#tutorial_homography_Demo5)* \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/20.md b/trans/build-cv-proj-opencv4-cpp/20.md deleted file mode 100644 index b7a26a1a..00000000 --- a/trans/build-cv-proj-opencv4-cpp/20.md +++ /dev/null @@ -1,162 +0,0 @@ -# 寻找作业的最佳 OpenCV 算法 - -任何计算机视觉问题都可以用不同的方式解决。 根据数据、资源或目标的不同,每种方法都有其优缺点和成功的相对衡量标准。在使用 OpenCV 时,计算机视觉工程师手头有许多算法选项来解决给定的任务。 在知情的情况下做出正确的选择是极其重要的,因为它可以对整个解决方案的成功产生巨大影响,并防止您被束缚在僵化的实现中。 本章将讨论在考虑 OpenCV 中的选项时应遵循的一些方法。 我们将讨论 OpenCV 覆盖的计算机视觉领域,如果存在多个竞争算法,如何在竞争算法之间进行选择,如何衡量算法的成功,最后讨论如何用流水线以稳健的方式衡量成功。 - -本章将介绍以下主题: - -* 它是否包含在 OpenCV 中? 使用 OpenCV 中提供的算法的计算机视觉主题。 -* 选择哪种算法? OpenCV 中包含多个可用解决方案的主题。 -* 如何知道哪种算法是最好的? 建立衡量算法成功的度量标准。 -* 使用管道在相同的数据上测试不同的算法。 - -# 技术要求 - -本章中使用的技术和安装如下: - -* 具有 Python 绑定的 OpenCV v3 或 v4 -* Jupiter 笔记本服务器 - -上面列出的组件的构建说明以及实现本章中所示概念的代码将在附带的代码存储库中提供。 - -本章的代码可以通过 gihub:https://github.com/PacktPublishing/Building-Computer-Vision-Projects-with-OpenCV4-and-CPlusPlus/tree/master/Chapter20访问。 - -# 它是否包含在 OpenCV 中? - -当第一次处理计算机视觉问题时,任何工程师都应该首先问:我是应该从头开始实施解决方案,还是从纸上或已知方法中实施解决方案,还是使用现有的解决方案并使其适合我的需求? - -这个问题与 OpenCV 中提供的实现密切相关。 幸运的是,OpenCV 对规范的和特定的计算机视觉任务都有非常广泛的覆盖范围。 另一方面,并不是所有的 OpenCV 实现都可以轻松地应用于给定的问题。 例如,虽然 OpenCV 提供了一些先进的对象识别和分类功能,但它远远逊于人们在会议和文学中看到的最先进的计算机视觉。 在过去的几年里,当然是在 OpenCV V4.0 中,有一项努力是将深度卷积神经网络与 OpenCV API 轻松集成(通过核心`dnn`模块),这样工程师就可以享受所有最新和最伟大的工作。 - -我们努力列出了 OpenCVV4.0 中当前提供的算法,以及对它们对宏伟的计算机视觉主题的覆盖面的主观估计。 我们还注意到 OpenCV 是否提供 GPU 实现覆盖范围,以及该主题是在核心模块还是在 Conrib 模块中涵盖。 Contrib 模块各不相同;有些模块非常成熟,并提供文档和教程(例如,`tracking`),而另一些模块是黑盒实现,文档非常差(例如,`xobjectdetect`)。 拥有核心模块实现是一个好兆头,它将会有足够的文档、示例和健壮性。 - -以下是计算机视觉的主题列表及其在 OpenCV 中的最高级别: - -| **主题** | **覆盖范围** | **OpenCV 产品** | _ | **GPU?** | -| 图像加工,图像处理 | 非常好 | 线性和非线性过滤、变换、色彩空间、直方图、形状分析、边缘检测 | 肯定的回答 / 赞成 / 是 | 满意的 / 合格的 / 好的 / 愉快的 | -| 特征检测 | 非常好 | 角点检测、关键点提取、描述符计算 | 是+Conrib | 穷的 / 糟糕的 / 差的 / 可怜的 | -| 分割 | 平庸的 / 平凡的 | 分水岭,轮廓和连通分量分析,二值化和阈值,GrabCut,前景-背景分割,超像素 | 是+Conrib | 穷的 / 糟糕的 / 差的 / 可怜的 | -| 图像对齐、拼接、稳定 | 满意的 / 合格的 / 好的 / 愉快的 | 全景拼接流水线,视频稳定流水线,模板匹配,变换估计,翘曲,无缝拼接 | 是+Conrib | 穷的 / 糟糕的 / 差的 / 可怜的 | -| 从运动到结构 | 穷的 / 糟糕的 / 差的 / 可怜的 | 相机位姿估计、基本和基本矩阵估计、与外部 Sfm 库集成 | 是+Conrib | 毫不 / 绝不 | -| 运动估计、光流、跟踪 | 满意的 / 合格的 / 好的 / 愉快的 | 光流算法、卡尔曼滤波、目标跟踪框架、多目标跟踪 | 主要是人为设计的 | 穷的 / 糟糕的 / 差的 / 可怜的 | -| 立体和三维重建 | 满意的 / 合格的 / 好的 / 愉快的 | 立体匹配框架、三角测量、结构光扫描 | 是+Conrib | 满意的 / 合格的 / 好的 / 愉快的 | -| 摄像机定标 | 非常好 | 从多个模式进行校准,立体声钻机校准 | 是+Conrib | 毫不 / 绝不 | -| 目标检测 | 平庸的 / 平凡的 | 级联分类器、二维码检测器、人脸地标检测器、3D 对象识别、文本检测 | 是+Conrib | 穷的 / 糟糕的 / 差的 / 可怜的 | -| 目标识别、分类 | 穷的 / 糟糕的 / 差的 / 可怜的 | Eigen 和 Fisher 人脸识别,词袋 | 主要是人为设计的 | 毫不 / 绝不 | -| 计算摄影 | 平庸的 / 平凡的 | 去噪、HDR、超分辨率 | 是+Conrib | 毫不 / 绝不 | - -虽然 OpenCV 在传统的计算机视觉算法(如图像处理、摄像机校准、特征提取和其他主题)方面做了大量工作,但它对 SfM 和对象分类等重要主题的覆盖率也很低。 在其他方面,如分段,它提供了一个不错的产品,但仍然没有达到最先进的水平,尽管它几乎完全转移到了卷积网络,基本上可以使用`dnn`模块来实现。 - -在一些主题中,例如特征检测、提取和匹配,以及摄像机校准,OpenCV 被认为是当今最全面、最免费和最可用的库,可能在成千上万的应用中使用。 然而,在计算机视觉项目的过程中,工程师可能会在原型阶段之后考虑与 OpenCV 解耦,因为库很重,并且大大增加了构建和部署的开销(这对移动应用来说是一个严重的问题)。 在这些情况下,OpenCV 是一个很好的原型制作拐杖,因为它提供了广泛的产品,对测试很有用,并且可以在同一任务的不同算法之间进行选择,例如,计算 2D 特征。 除了原型之外,还有许多其他的考虑因素变得更加重要,比如执行环境、代码的稳定性和可维护性、权限和许可等等。 在那个阶段,使用 OpenCV 应该满足产品的要求,包括前面提到的注意事项。 - -# OpenCV 中的算法选项 - -OpenCV 有许多涵盖同一主题的算法。 在实现新的处理流水线时,有时流水线中的一个步骤有多种选择。 例如,在[第 14 章](14.html),*使用 SfM 模块*从运动中探索结构,我们随意决定使用 AKAZE 功能查找图像之间的地标以估计摄像机运动,以及稀疏 3D 结构,然而,在 OpenCV 的`features2D`模块中有更多类型的 2D 功能可用。 更明智的操作模式应该是根据我们的需要,根据其性能选择要使用的特征算法类型。 至少,我们需要意识到不同的选择。 - -同样,我们希望创建一种方便的方法来查看同一任务是否有多个选项。 我们创建了一个表,其中列出了在 OpenCV 中具有多个算法实现的特定计算机视觉任务。 我们还努力标记算法是否有共同的抽象 API,从而在代码中轻松且完全可互换。 虽然 OpenCV 为其大多数算法(如果不是全部算法)提供了`cv::Algorithm`基类抽象,但这种抽象处于非常高的级别,对多态性和互换性的支持很少。 从我们的审查中,我们排除了机器学习算法(`ml`模块和`cv::StatsModel`通用 API),因为它们不是适当的计算机视觉算法,以及实际上确实有重叠实现的低级图像处理算法(例如,Hough 检测器系列)。 我们还排除了阴影几个核心主题的 GPU CUDA 实现,例如对象检测、背景分割、2D 功能等等,因为它们大多是 CPU 实现的副本。 - -以下是 OpenCV 中具有多个实施的主题: - -| **主题** | **实施** | 帖子主题:Re:Колибри | -| 光流 | `video`模块:`SparsePyrLKOpticalFlow`,`FarnebackOpticalFlow`,`DISOpticalFlow`,`VariationalRefinement``optflow`Conrib 模块:`DualTVL1OpticalFlow`,`OpticalFlowPCAFlow` | 肯定的回答 / 赞成 / 是 | -| 目标跟踪 | `track`控制模块:`TrackerBoosting`,`TrackerCSRT`,`TrackerGOTURN`,`TrackerKCF`,`TrackerMedianFlow`,`TrackerMIL`,`TrackerMOSSE`,`TrackerTLD`外部:`DetectionBasedTracker` | 是1 | -| 目标检测 | `objdetect`模块:`CascadeClassifier`,`HOGDescriptor`,`QRCodeDetector`,`linemod`Conrib 模块:`Detector``aruco`Conrib 模块:`aruco::detectMarkers` | 没有Колибри2Колибри | -| 2D 要素 | OpenCV 最成熟的通用 API。`features2D`模块:`AgastFeatureDetector`、`AKAZE`、`BRISK`、`FastFeatureDetector`、`GFTTDetector`、`KAZE`,`MSER`,`ORB`,`SimpleBlobDetector``xfeatures2D`Conrib 模块:`BoostDesc`,`BriefDescriptorExtractor`,`DAISY`,`FREAK`,`HarrisLaplaceFeatureDetector`,`LATCH`,`LUCID`,`MSDDetector`,`SIFT`,`StarDetector`,`SURF`,`VGG` | 肯定的回答 / 赞成 / 是 | -| 特征匹配 | `BFMatcher`,`FlannBasedMatcher` | 肯定的回答 / 赞成 / 是 | -| 背景减去 | `video`模块:`BackgroundSubtractorKNN`,`BackgroundSubtractorMOG2``bgsegm`控制模块:`BackgroundSubtractorCNT`,`BackgroundSubtractorGMG`,`BackgroundSubtractorGSOC`,`BackgroundSubtractorLSBP`,`BackgroundSubtractorMOG` | 肯定的回答 / 赞成 / 是 | -| 摄像机定标 | `calib3d`模块:`calibrateCamera`,`calibrateCameraRO`,`stereoCalibrate``aruco`Conrib 模块:`calibrateCameraArcuo`,`calibrateCameraCharuco``ccalib`Conrib 模块:`omnidir::calibrate`,`omnidir::stereoCalibrate` | 不 / 否决票 / 同 Noh | -| 立体重建 | `calib3d`模块:`StereoBM`,`StereoSGBM``stereo`Conrib 模块:`StereoBinaryBM`,`StereoBinarySGBM``ccalib`Conrib 模块:`omnidir::stereoReconstruct` | 部分3 | -| 估算 | `solveP3P`,`solvePnP`,`solvePnPRansac` | 不 / 否决票 / 同 Noh | - -1仅适用于`track`Conrib 模块中的类。 -2某些类共享同名函数,但没有继承的抽象类。 -3每个模块本身都有一个库,但不能在模块之间共享。 - -在使用几个算法选项处理问题时,重要的是不要过早地执行一条执行路径。 我们可以使用上表来查看存在的选项,然后探索它们。 接下来,我们将讨论如何从选项池中进行选择。 - -# 哪种算法最好? - -计算机视觉是一个知识的世界,是一项长达数十年的研究。 与许多其他学科不同,计算机视觉没有很强的层次性或垂直性,这意味着针对给定问题的新解决方案并不总是更好,也可能不是基于之前的工作。 作为一个应用领域,计算机视觉算法的产生关注了以下几个方面,这可能解释了非垂直发展: - -* **计算资源**:CPU、GPU、嵌入式系统、内存占用、网络连接。 -* **数据**:图像大小、图像数量、图像流(摄像机)数量、数据类型、顺序性、照明条件、场景类型等。 -* **性能要求**:实时输出或其他定时限制(例如,人的感知)、准确性和精确度。 -* **元算法**:算法简单性(交叉引用 Occam‘s Razor 定理)、实现系统和外部工具、形式证明的可用性。 - -由于每个算法都是为了迎合其中某个考虑因素而创建的,如果不正确测试其中的一些或全部,就永远无法确定它是否会优于所有其他算法。 诚然,测试给定问题的所有算法是*不切实际的*,即使实现确实是可用的,而且 OpenCV 肯定有很多实现可用,正如我们在上一节中所看到的。 另一方面,如果计算机视觉工程师不考虑他们的算法选择导致他们的实现不是最优的可能性,那么他们就是玩忽职守。 这在本质上源于*没有免费午餐*定理,该定理概括地说,在整个可能的数据集空间中,没有一个算法是最好的算法。 - -因此,在承诺选择其中最好的算法选项之前,测试一组不同的算法选项是一种非常受欢迎的做法。 但是我们如何找到*最好的*呢? 单词*BEST*意味着每个人都会比其他人好*(或*差*),这反过来又意味着有一个客观的量表或衡量标准,在这个标准或衡量标准中,他们都被重新评分并按顺序排序。 显然,对于所有问题中的所有算法,没有单一的度量(**度量**),每个问题都会有自己的度量。 在许多情况下,成功的度量将形成对**错误**的度量,即与已知的**基本事实**值的偏差,该值来自人类或我们可以信任的其他算法。 在优化中,这被称为**损失函数**或成本函数,我们希望最小化(有时最大化)该函数,以便找到得分最低的最佳选项。 另一类重要的度量不太关心输出性能(例如,错误),而更关心运行时计时、内存占用、容量和吞吐量等。* - - *以下是我们在部分计算机视觉问题中可能会看到的指标的部分列表: - -| **任务** | **示例指标** | -| 重建、配准、特征匹配 | **平均绝对误差**(**MAE**), -**均方误差**(**MSE**), -**均方根误差**(**RMSE**), -**距离平方和**(**SSD**) | -| 目标分类、识别 | 准确率、精确度、召回率、F1 得分、 -**假阳性率**(**FPR**) | -| 分割,目标检测 | **并集交集**(**借条**) | -| 特征检测 | 可重复性, -精度召回 - | - -为什么要为给定的任务找到最佳算法,要么是在测试场景中设置我们可以使用的所有选项,并根据选择的指标衡量它们的性能,要么是在标准实验或数据集上获得其他人的测量结果。 应该选择排名最高的选项,其中排名是从多个指标的组合中得出的(在只有一个指标的情况下,这是一项简单的任务)。 接下来,我们将尝试这样一个任务,并在最佳算法上做出*知情*选择。 - -# 算法比较性能测试实例 - -例如,我们将设置一个场景,要求我们对齐重叠的图像,就像在全景或航空照片拼接中所做的那样。 我们需要测量性能的一个重要特性是具有**基本事实**,这是我们试图用近似方法恢复的真实条件的精确测量。 地面真实数据可以从提供给研究人员用于测试和比较他们的算法的数据集中获得;事实上,许多这样的数据集都存在,并且计算机视觉研究人员一直在使用它们。 寻找计算机视觉数据集的一个很好的资源是**又一个计算机视觉索引到数据集**(**YACVID**),或者[https://riemenschneider.hayko.at/vision/dataset/](https://riemenschneider.hayko.at/vision/dataset/),它在过去八年中一直在积极维护,包含成百上千个到数据集的链接。 以下也是一个很好的数据来源:[https://github.com/jbhuang0604/awesome-computer-vision#datasets](https://github.com/jbhuang0604/awesome-computer-vision#datasets)。 - -然而,我们将选择一种不同的方式来获取基本事实,这在计算机视觉文献中得到了很好的实践。 我们将在我们的参数控制中创造一个人为的情况,并创建一个基准,我们可以改变这个基准来测试我们算法的不同方面。 在我们的示例中,我们将获取单个图像,并将其分割为两个重叠的图像,然后对其中一个图像应用一些变换。 使用我们的算法对图像进行融合将试图重建原始的融合图像,但它可能不会做得很完美。 我们在系统中选择片段时所做的选择(例如,2D 特征的类型、特征匹配算法和变换恢复算法)将影响最终结果,我们将对其进行测量和比较。 通过使用人造地面真实数据,我们可以很好地控制试验的条件和水平。 - -考虑下图及其双向重叠拆分: - -![](img/6d0b9b5b-3892-4e5e-a4ec-25c50b316c36.png) - -Image: https://pixabay.com/en/forest-forests-tucholski-poland-1973952/ - -我们保持左边的图像不变,同时对右边的图像执行人工变换,看看我们的算法能够多好地撤销它们。 为简单起见,我们将只旋转几个括号中的右侧图像,如下所示: - -![](img/540477d2-92a2-4b1b-ba3a-63c196154b26.png) - -我们为*无旋转*的情况添加了一个中间括号,在这种情况下,右侧的图像只被稍微平移了一些。“这构成了我们的地面真实数据,其中我们确切地知道发生了什么变换以及原始输入是什么。 - -我们的目标是衡量不同 2D 特征描述符类型在对齐图像方面的成功程度。 衡量我们是否成功的一个标准可以是最终重新拼接图像像素上的**均方误差(MSE)**。 如果转换恢复做得不是很好,像素将不会完全对齐,因此我们预计会看到很高的 MSE。 当 MSE 接近零时,我们知道缝合做得很好。 出于实际原因,我们可能还想知道哪个特性是最有效的,这样我们还可以测量执行时间。 为此,我们的算法可以非常简单: - -1. 将原始图像分割为*左图像*和*右图像*。 -2. 对于每种要素类型(SURF、SIFT、ORB、AKAZE、BRISK),请执行以下操作: - 1. 在左侧图像中查找关键点和特征。 - 2. 对于每个旋转角度[-90,-67,...,67,90],请执行以下操作: - 1. 按旋转角度旋转右侧图像。 - 2. 在旋转的右侧图像中查找关键点和特征。 - 3. 在旋转的右图像和左图像之间匹配关键点。 - 4. 估计刚性 2D 变换。 - 5. 根据估计进行变换。 - 6. 使用原始未分割图像测量最终结果的**MSE**。 - 7. 测量提取、计算和匹配要素并执行对齐所需的总**时间**。 - -作为一种快速优化,我们可以缓存旋转的图像,而不计算每个特征类型的图像。 算法的其余部分保持不变。 此外,为了在时间上保持公平,我们应该注意为每种特征类型提取相似数量的关键点(例如,2500 个关键点),这可以通过设置关键点提取函数的阈值来实现。 - -注对齐执行管道与特征类型无关,并且在给定匹配关键点的情况下工作方式完全相同。 这是测试许多选项的一个非常重要的功能。 使用 OpenCV 的`cv::Feature2D`和`cv::DescriptorMatcher`公共基础 API 可以实现这一点,因为所有功能和匹配器都实现它们。 但是,如果我们看一下*中的表格,它是否包含在 OpenCV 中?*一节中,我们可以看到这可能不适用于 OpenCV 中的所有视觉问题,因此我们可能需要添加我们自己的仪器代码来进行此比较。 - -在附带的代码中,我们可以找到该例程的 Python 实现,它提供以下结果。 为了测试旋转不变性,我们改变角度并测量重建的均方误差: - -![](img/d5e4e88c-eecf-45bb-a54f-02f22b646bf2.png) - -对于相同的实验,我们记录了一种特征类型的所有实验的平均 MSE,以及平均执行时间,如下所示: - -![](img/ae41a5e8-accc-4d4f-a2ba-3bd8586b201f.png) - -**结果分析**,我们可以清楚地看到一些功能在 MSE 方面表现更好,无论是在不同的旋转角度还是整体上,我们还可以看到时间上的很大差异。 在旋转角度范围内,AKAZE 和 SURF 的对准成功率似乎是最高的,AKAZE 在更高的旋转角度(~60°)时更具优势。 然而,在角度变化非常小的情况下(旋转角接近 0°),SIFT 在 MSE 接近于零的情况下实现了几乎完美的重建,它的重建效果也不亚于旋转在 30°以下的其他图像。 ORB 在整个领域做得非常糟糕,虽然 Bliisk 没有那么糟糕,但很少能够击败任何先行者。 - -考虑到时间,ORB 和 BRISK(本质上是相同的算法)是明显的赢家,但它们在重建精度方面都远远落后于其他算法。 AKAZE 和 SURF 是计时性能不相上下的领先者。 - -现在,由我们作为应用开发人员根据项目的要求对功能进行排序。 根据我们执行的这次测试的数据,应该很容易做出决定。 如果我们在寻找速度,我们会选择轻快,因为它是最快的,而且性能比 ORB 更好。 如果我们追求精确度,我们会选择 AKAZE,因为它是最好的表演者,而且比冲浪更快。 使用 SURF 本身就是一个问题,因为算法不是免费的,而且它是受专利保护的,所以我们很幸运地发现 AKAZE 是一个免费和足够的替代方案。 - -这是一个非常初级的测试,只看了两个简单的测量(MSE 和时间)和一个变化的参数(旋转)。 在实际情况中,我们可能希望根据系统的要求在转换中加入更多的复杂性。 例如,我们可以使用完全透视变换,而不仅仅是刚性旋转。 此外,我们可能希望对结果进行更深入的统计分析。 在此测试中,对于每个旋转条件,我们只运行一次对齐过程,这不利于捕获良好的计时度量,因为某些算法可能受益于连续执行(例如,将静态数据加载到内存)。 如果我们有多次执行,我们可以对执行中的差异进行推理,并计算标准差或误差,以便为我们的决策过程提供更多信息。 最后,给定足够的数据,我们可以执行统计推断过程和假设检验,例如**t 检验**或**方差分析**(**ANOVA**),以确定条件之间的细微差别(例如,AKAZE 和 SURF)是否具有**统计显著性**,或者太过嘈杂而无法区分。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -为这项工作选择最好的计算机视觉算法是一个虚幻的过程,这是许多工程师不执行它的原因。 虽然针对不同选择发布的调查工作提供了基准性能,但在许多情况下,它没有对工程师可能遇到的特定系统需求进行建模,因此必须实施新的测试。 测试算法选项的主要问题是检测代码,这对工程师来说是一项额外的工作,而且并不总是那么简单。 OpenCV 为几个视觉问题领域的算法提供了基础 API,但覆盖年限并不完整。 另一方面,OpenCV 覆盖了非常广泛的计算机视觉问题,是执行此类测试的主要框架之一。 - -在选择算法时做出明智的决定是视觉工程的一个非常重要的方面,有许多要素需要优化,例如,速度、准确性、简单性、内存占用,甚至可用性。 每个视觉系统工程都有特定的要求,这些要求会影响每个元素的权重,从而影响最终的决策。 通过相对简单的 OpenCV 代码,我们了解了如何收集数据、绘制图表,并就玩具问题做出明智的决定。 - -在下一章中,我们将讨论 OpenCV 开源项目的历史,以及使用 OpenCV 时的一些常见陷阱以及针对这些陷阱提出的解决方案。* \ No newline at end of file diff --git a/trans/build-cv-proj-opencv4-cpp/21.md b/trans/build-cv-proj-opencv4-cpp/21.md deleted file mode 100644 index 62a5b399..00000000 --- a/trans/build-cv-proj-opencv4-cpp/21.md +++ /dev/null @@ -1,144 +0,0 @@ -# 避免 OpenCV 中的常见陷阱 - -OpenCV 已经问世超过 15 年了。 它包含许多过时或未优化的实现,是过去遗留下来的。 高级 OpenCV 工程师应该知道如何避免在导航 OpenCV API 时出现基本错误,并看到他们的项目在算法上取得成功。 - -在这一章中,我们将回顾 OpenCV 的历史发展,以及随着计算机视觉的发展,OpenCV 的框架和算法提供的逐渐增加。 我们将使用这些知识来判断 OpenCV 中是否存在用于我们选择的算法的较新的替代方案。 最后,我们将讨论如何识别和避免在使用 OpenCV 创建计算机视觉系统时出现的常见问题或次优选择。 - -本章将介绍以下主题: - -* OpenCV 与最新一波计算机视觉研究的历史回顾 -* 检查某个算法在 OpenCV 中可用的日期,以及它是否是过时的标志 -* 解决在 OpenCV 中构建计算机视觉系统的陷阱 - -# OpenCV 从 v1 到 v4 的历史 - -OpenCV 最初是**格雷·布拉德斯基(Gray Bradsky)**的创意,他曾在**英特尔(Intel)**担任计算机视觉工程师,大约在 21 世纪初。 布拉德斯基和一个主要来自俄罗斯的工程师团队在英特尔内部开发了 OpenCV 的第一个版本,然后在 2002 年将其制成**开源软件**(**OSS**)的 0.9 版。 布拉德斯基随后转到**Willow Garage**,与 OpenCV 的前创始成员一起工作。 其中包括 Viktor Eurkhimov、Sergey Molinov、Alexander Shishkov 和 Vadim Pisarevsky(他最终创办了公司**ItSeez**,该公司于 2016 年被英特尔收购),他们开始支持这个年轻的库作为开源项目。 - -0.9 版主要使用 CAPI,并且已经支持了图像数据操作函数和像素访问、图像处理、过滤、色彩空间变换、几何和形状分析(例如,形态函数、Hough 变换、轮廓查找)、运动分析、基本机器学习(K-Means,HMM)、相机姿态估计、基本线性代数(SVD、特征分解)等功能。 其中许多功能历久弥新,甚至一直延续到今天的 OpenCV 版本。 版本 1.0 于 2006 年发布,它标志着该库作为开放源码软件和计算机视觉领域的主导力量的开始。 2008 年末,Bradsky 和**Adrian Kaehler**出版了基于 OpenCV v1.1pre1 的畅销书《*学习 OpenCV*》,这本书在全球取得了巨大的成功,并在未来几年成为 OpenCV C API 的权威指南。 - -由于其完备性,OpenCV v1 成为学术和工业应用中非常流行的视觉工作框架,特别是在机器人领域,尽管它在功能提供方面与 v0.9 相差不大。 在 1.0 版(2006 年末)发布后,OpenCV 项目进入了多年的冬眠状态,因为创始团队忙于其他项目,开源社区并没有像几年后那样建立起来。 该项目在 2008 年末发布了 v1.1pre1,增加了一些小功能;然而,OpenCV 作为最知名的视觉库的基础是 2.x 版,它引入了非常成功的**C++ API**。 2.x 版作为 OpenCV 的稳定分支持续了*6 年*(2009-2015),直到最近,也就是 2018 年初(最新版本 2.4.13.6 发布于 2018 年 2 月),几乎又晚了*10 年*。 版本 2.4 发布于 2012 年年中,它有一个非常稳定和成功的 API,持续了三年,并且还引入了非常广泛的特性。 - -版本 2.x 引入了**CMake**构建系统,该系统当时也被**MySQL**项目使用,以配合其完全**跨平台**的目标。 除了新的 C++ API,v2.x 还引入了**模块**的概念(在 v2.2 中,大约在 2011 年),这些模块可以根据项目组装的需要单独构建、包含和链接,放弃了 v1.x 的`cv`、`cvaux`、`ml`等。 扩展了 2D 功能套件,以及机器学习功能、内置的人脸识别级联模型、3D 重建功能,最重要的是覆盖了所有**Python**绑定。 早期对 Python 的投资使 OpenCV 成为当时最好的视觉原型开发工具,现在可能仍然如此。 版本 2.4 于 2012 年年中发布,一直开发到 2018 年,由于担心破坏 API 更改,v2.5 从未发布,只是简单地更名为 v3.0(约 2013 年年中)。 2.4.x 版继续引入更重要的特性,比如**Android**和**iOS**支持,**CUDA**和**OpenCL**实现,CPU 优化(例如,SSE 和其他 SIMD 架构),以及难以置信的新算法。 - -3.0 版本于 2015 年底首次发布,社区对此反应冷淡。他们正在寻找一个稳定的 API,因为一些 API 有突破性的变化,不可能临时替换。 标头结构也发生了更改(从`opencv2//.hpp`更改为`opencv2/.hpp`),这使得转换变得更加困难。 版本 2.4.11+(2015 年 2 月)提供了工具来弥合两个版本之间的差距,并安装了文档来帮助开发人员过渡到 v3.0([https://docs.opencv.org/3.4/db/dfa/tutorial_transition_guide.html](https://docs.opencv.org/3.4/db/dfa/tutorial_transition_guide.html))。 2.x 版保持了很强的影响力,许多包管理系统(例如,Ubuntu 的`apt`)仍然作为 OpenCV 的稳定版本提供服务,而 3.x 版则在以非常快的速度前进。 - -经过多年的共存和规划,2.4.x 版让位于 3.x 版,3.x 版拥有改进的 API(引入了许多抽象和基类),并通过新的**透明 API(T-API)**改进了对 GPU 的支持,该 API 允许 GPU 代码与常规 CPU 代码互换使用。 为社区贡献的代码建立了一个单独的存储库`opencv-contrib`,将其作为 2.4.x 版中的一个模块从主代码中移除,改进了构建稳定性和时序。 另一个重大变化是 OpenCV 中的机器学习支持,它在 2.4 版的基础上进行了极大的改进和修订。 3.x 版还通过 OpenCV**HAL**(**硬件加速层**),在 Intel x86(例如,ARM、霓虹灯)之外的 CPU 架构上进行了更好的 Android 支持和优化。OpenCV**硬件加速层**后来合并到了核心模块中。 OpenCV 中首次出现的深度神经网络在 v3.1(2015 年 12 月)被记录为`contrib`模块,近两年后在 v3.3(2017 年 8 月)被升级为核心模块:`opencv-dnn`。 在 Intel、Nvidia、AMD 和 Google 的支持下,3.x 版本在优化和与 GPU 和 CPU 架构的兼容性方面带来了巨大的改进,并成为 OpenCV 作为优化的计算机视觉库的标志。 - -4.0 版标志着 OpenCV 作为当今主要开源项目的成熟状态。 旧的 C API(其中许多函数可以追溯到 v0.9)被放弃,取而代之的是**C++ 11**成为*强制的*,这也去掉了库中的`cv::String`和`cv::Ptr`混合体。 Version 4.0 跟踪了针对 CPU 和 GPU 的进一步优化;然而,最有趣的新增功能是**Graph API(G-API)模块**。 在 Google 的**TensorFlow**深度学习类库和 Facebook 的**PyTorch**取得巨大成功之后,G-API 为 OpenCV 带来了时代精神,支持为计算机视觉构建计算图,在 CPU 和 GPU 上执行异构。 凭借对深度学习技术和机器学习、Python 和其他语言、执行图、交叉兼容性以及广泛提供的优化算法的长期投资,OpenCV 被确立为一个具有非常强大的社区支持的前瞻性项目,这使得它在 15 年后成为现有的领先的开放计算机视觉库。 - -这本丛书*Mastering OpenCV*的历史与 OpenCV 作为开源计算机视觉的主要库的发展历史交织在一起。 2012 年发布的第一版基于永久 v2.4.x 分支。 这在 2009-2016 年间主导了 OpenCV 领域。 2017 年发布的第二版欢呼 OpenCV v3.1+在社区中的主导地位(始于 2016 年年中)。 第三版,也就是您现在正在阅读的版本,欢迎于 2018 年 10 月下旬发布的 OpenCV v4.0.0。 - -# OpenCV 与计算机视觉中的数据革命 - -OpenCV 在计算机视觉的数据革命之前就已经存在了。 在 20 世纪 90 年代末,获取大量数据对于计算机视觉研究人员来说并不是一项简单的任务。 快速上网并不常见,甚至大学和大型研究机构的网络也不是很强。 个人和更大的机构计算机的存储容量有限,不允许研究人员和学生处理大量数据,更不用说拥有这样做所需的计算能力(内存和 CPU)了。 因此,对大规模计算机视觉问题的研究被限制在全球选定的实验室名单中,其中包括麻省理工学院的**计算机科学和人工智能实验室**(**CSAIL**)、牛津大学机器人研究小组、**卡内基梅隆**大学(**CMU**)机器人研究所和**加州理工学院**(**加州理工学院**)。 这些实验室也有资源自己管理大量数据,为当地科学家的工作服务,他们的计算集群足够强大,可以处理这种规模的数据。 - -然而,本世纪初,这一格局发生了变化。 快速的互联网连接使其成为研究和数据交换的中心,同时,计算和存储能力每年呈指数级增长。 大规模计算机视觉工作的民主化带来了计算机视觉工作的开创性大数据集的创建,如**MNIST**(1998)、**C****MU Pie**(2000)、**Caltech 101**(2003)和**麻省理工学院的 LabelMe**(2005)。 这些数据集的发布也推动了围绕大规模图像分类、检测和识别的算法研究。 计算机视觉中一些最具开创性的工作是由这些数据集直接或间接实现的,例如,**LeCun 的**手写识别(约 1990 年)、**Viola 和 Jones 的**级联增强人脸检测器(2001)、**Lowe 的**SIFT(1999、2004)、**Dalal 的**猪人分类器(2005),以及更多。 - -在本世纪头十年的后半段,数据供应量急剧增加,发布了许多大数据集,如**Caltech 256**(2006)、**ImageNet**(2009)、**CIFAR-10**(2009)和**Pascal VOC**(2010),所有这些数据集在今天的研究中仍然起着至关重要的作用。 随着 2010-2012 年左右深度神经网络的出现,以及**Krizhevsky 和 Hinton 的 AlexNet**(2012)在 ImageNet 大规模视觉识别(ILSVRC)竞赛中的重大胜利,大数据集成为时尚,计算机视觉世界也发生了变化。 ImageNet 本身已经发展到了惊人的规模(超过 1400 万张照片),其他大数据集也是如此,比如**微软的 Coco**(2015 年,有 250 万张照片),**OpenImages V4**(2017 年,只有不到 900 万张照片),以及**麻省理工学院的 ADE20K**(2017,有近 50 万个对象分割实例)。 最近的这一趋势促使研究人员进行更大范围的思考,与十年前的几十个参数相比,今天处理这类数据的机器学习通常会有数千万个参数(在深度神经网络中)。 - -OpenCV 早期声名鹊起是因为它内置了 Viola 和 Jones 人脸检测方法,该方法基于一系列增强型分类器,这也是许多人在研究或实践中选择 OpenCV 的原因。 然而,OpenCV 一开始并没有瞄准数据驱动的计算机视觉。 在 v1.0 中,机器学习算法仅有级联 Boosting、隐马尔可夫模型和一些无监督方法(如 K-均值聚类和期望最大化)。 主要集中在图像处理、几何形状和形态分析等方面。2.x 和 3.x 版本为 OpenCV 添加了大量标准的机器学习功能;其中包括决策树、随机森林和梯度增强树、**支持向量机**(**SVM**)、Logistic 回归、朴素贝叶斯分类等。 目前看来,OpenCV 不是一个数据驱动的机器学习库,在最近的版本中,这一点变得更加明显。 `opencv_dnn`核心模块允许开发人员使用通过外部工具(例如,TensorFlow)学习的模型在 OpenCV 环境中运行,OpenCV 提供图像预处理和后处理。 尽管如此,OpenCV 在数据驱动管道中扮演着至关重要的角色,并且在场景中扮演着有意义的角色。 - -# OpenCV 中的历史算法 - -当开始处理 OpenCV 项目时,应该了解它的历史过去。 OpenCV 作为一个开源项目已经存在了 15 年以上,尽管其非常敬业的管理团队致力于改善库并保持其相关性,但有些实现比其他实现更过时。 有些 API 是为了向后兼容以前的版本,而另一些则是针对特定的算法环境,所有这些都是在添加较新算法的同时进行的。 - -任何希望为自己的工作选择最佳性能算法的工程师都应该有工具来查询特定算法,以查看*何时添加*,以及它的*来源是什么*(例如,一篇研究论文)。 这并不是说任何新的*新的*就一定比*好*,因为一些基本的和较旧的算法性能很好,而且在大多数情况下,各种度量之间存在明显的权衡。 例如,数据驱动的深度神经网络执行图像二值化(将彩色或灰度图像转换为黑白)可能会达到最高的*精度。 然而,用于自适应二值阈值的**Otsu 方法**(1979)非常快*,并且在许多情况下执行得相当好。 因此,关键是要知道要求,以及算法的细节。** - - *# 如何检查算法何时添加到 OpenCV - -要更多地了解 OpenCV 算法,最简单的方法之一就是查看它何时被添加到源代码树中。 幸运的是,OpenCV 作为一个开源项目保留了其代码的大部分历史,并且在各个发布版本中记录了更改。 有几个有用的资源可以访问此信息,如下所示: - -* OpenCV 源代码库:[https://github.com/opencv/opencv](https://github.com/opencv/opencv) -* OpenCV 更改日志:[https://github.com/opencv/opencv/wiki/ChangeLog](https://github.com/opencv/opencv/wiki/ChangeLog) -* OpenCV 阁楼:[https://github.com/opencv/opencv_attic](https://github.com/opencv/opencv_attic) -* OpenCV 文档:https://docs.opencv.org/master/index.html - -举个例子,让我们来看看`cv::solvePnP(...)`函数中的算法,该函数也是物体(或相机)姿态估计最有用的函数之一。 此功能在 3D 重建管道中大量使用。 我们可以在`opencv/modules/calib3d/src/solvepnp.cpp`文件中找到`solvePnP`,通过 GitHub 中的搜索功能,我们可以追溯到`solvepnp.cpp`在 2011 年 4 月 4 日的首次提交([https://github.com/opencv/opencv/commit/04461a53f1a484499ce81bcd4e25a714488cf600](https://github.com/opencv/opencv/commit/04461a53f1a484499ce81bcd4e25a714488cf600))。 - -在那里,我们可以看到原始的`solvePnP`函数最初驻留在`calibrate3d.cpp`中,因此我们也可以追溯该函数。 然而,我们很快就发现该文件没有太多的历史记录,因为它起源于 2010 年 5 月首次提交到新的 OpenCV 存储库。 对阁楼储存库的搜索没有发现任何存在于原始储存库之外的东西。 我们最早的`solvePnP`版本是 2010 年 5 月 11 日([https://github.com/opencv/opencv_attic/blob/8173f5ababf09218cc4838e5ac7a70328696a48d/opencv/modules/calib3d/src/calibration.cpp](https://github.com/opencv/opencv_attic/blob/8173f5ababf09218cc4838e5ac7a70328696a48d/opencv/modules/calib3d/src/calibration.cpp)),它看起来是这样的: - -```cpp -void cv::solvePnP( const Mat& opoints, const Mat& ipoints, - const Mat& cameraMatrix, const Mat& distCoeffs, - Mat& rvec, Mat& tvec, bool useExtrinsicGuess ) -{ - CV_Assert(opoints.isContinuous() && opoints.depth() == CV_32F && - ((opoints.rows == 1 && opoints.channels() == 3) || - opoints.cols*opoints.channels() == 3) && - ipoints.isContinuous() && ipoints.depth() == CV_32F && - ((ipoints.rows == 1 && ipoints.channels() == 2) || - ipoints.cols*ipoints.channels() == 2)); - - rvec.create(3, 1, CV_64F); - tvec.create(3, 1, CV_64F); - CvMat _objectPoints = opoints, _imagePoints = ipoints; - CvMat _cameraMatrix = cameraMatrix, _distCoeffs = distCoeffs; - CvMat _rvec = rvec, _tvec = tvec; - cvFindExtrinsicCameraParams2(&_objectPoints, &_imagePoints, &_cameraMatrix, - &_distCoeffs, &_rvec, &_tvec, useExtrinsicGuess ); -} -``` - -我们可以清楚地看到,它是旧 C API`cvFindExtrinsicCameraParams2`的一个简单包装。 此 C API 函数的代码存在于`calibration.cpp`([https://github.com/opencv/opencv/blob/8f15a609afc3c08ea0a5561ca26f1cf182414ca2/modules/calib3d/src/calibration.cpp#L1043](https://github.com/opencv/opencv/blob/8f15a609afc3c08ea0a5561ca26f1cf182414ca2/modules/calib3d/src/calibration.cpp#L1043))中,我们可以验证它,因为它自 2010 年 5 月以来没有更改过。 较新版本的`solvePnP`(2018 年 11 月最新提交)增加了更多功能,增加了另一个函数(允许使用**随机样本共识**(**RANSAC**))和几种特殊的 PnP 算法,如 EPnP、P3P、AP3P、DLS、UPnP,并且在向函数提供`SOLVEPNP_ITERATIVE`标志时还保留了旧的 C API(`cvFindExtrinsicCameraParams2`)方法。 经过检查,旧的 C 函数似乎通过在平面对象的情况下找到**单应**,或者使用**DLT 方法**,然后执行迭代精化来解决姿势估计问题。 - -像往常一样,如果直接认为旧的 C 方法不如其他方法,那就大错特错了。 然而,较新的方法确实是在 DLT 方法(可追溯到 20 世纪 70 年代)几十年后提出的方法。 例如,UPnP 方法是由 Penate-Sanchez 等人在*2013*中提出的。 (2013 年)。 同样,在没有仔细检查手头的特定数据和进行比较研究的情况下,我们无法得出哪种算法在要求(速度、精度、内存等)方面表现最好的结论,尽管我们可以得出结论,计算机视觉研究肯定在从 20 世纪 70 年代到 2010 年代的 40 年*中取得了进步。 Penate-Sanchez 等人。 实际上,他们的论文表明,UPnP 在速度和准确性方面都远远好于 DLT,这是基于他们用真实和模拟数据进行的实证研究。 有关如何比较算法选项的提示,请参阅[章](20.html)、*、*查找作业的最佳 OpenCV 算法**、*。** - - *深入检查 OpenCV 代码应该是严肃的计算机视觉工程师的日常工作。 它不仅揭示了潜在的优化,并通过关注较新的方法来指导选择,而且还可能教会很多关于算法本身的知识。 - -# 常见陷阱和建议的解决方案 - -OpenCV 功能非常丰富,提供了多种解决方案和途径来解决视觉理解问题。 伴随着这种强大的力量,也伴随着艰苦的工作,选择和制作符合项目要求的最好的处理流水线。 拥有多个选项意味着找到精确的最佳性能解决方案几乎是不可能的,因为许多部件是可互换的,并且测试*所有*可能的选项是我们无法实现的。 这个问题的指数复杂性因输入数据而变得更加复杂;输入数据中更多的未知方差将使我们的算法选择更加不稳定。 换句话说,使用 OpenCV 或任何其他计算机视觉库,仍然是经验和艺术的问题。 对于解决方案的一种或另一种方法的成功的先验直觉是计算机视觉工程师通过多年的经验发展起来的,而且在大多数情况下没有捷径。 - -然而,也可以选择从别人的经验中学习。 如果你已经买了这本书,很可能意味着你正打算这么做。 在这一部分,我们准备了一份部分清单,列出了我们作为计算机视觉工程师多年工作中遇到的问题。 我们也希望为这些问题提出解决方案,就像我们在自己的工作中使用的那样。 该列表集中于计算机视觉工程中出现的问题;但是,任何工程师都应该知道*通用软件和系统工程*中的常见问题,我们在这里不会列举这些问题。 在实践中,没有一个系统实现是没有问题、错误或未充分优化的,即使在遵循了我们的列表之后,您也可能会发现还有很多事情要做。 - -任何工程领域的主要常见陷阱都是**进行假设而不是断言**。 对于任何工程师来说,如果有测量某物的选项,那么它应该被测量,即使是通过近似值,设定上下限,或者测量一个不同的高度相关的现象。 有关可用于在 OpenCV 中进行测量的度量的一些示例,请参阅[第 20 章](20.html)、第*章为作业找到最佳 OpenCV 算法*。 最好的决策是基于硬数据和可见性的知情决策;然而,这通常不是工程师的特权。 一些项目需要快速而冷淡的启动,这迫使工程师在没有太多数据或直觉的情况下从头开始快速构建解决方案。 在这种情况下,以下建议可以省去很多悲痛: - -* **不比较算法选项**:**和**工程师经常犯的一个陷阱是,根据他们首先遇到的、他们过去做过并且似乎有效的东西,或者有很好的教程(别人的经验)的东西来明确地选择算法。 这被称为**锚定**或**聚焦**或**认知偏差**,这是决策理论中的一个众所周知的问题。重复上一章的话,算法的选择可以在准确性、速度、资源等方面对整个管道和项目的结果产生巨大的影响。 在选择算法时做出不知情的决定不是一个好主意。 - * **解决方案**:OpenCV 可以通过通用基础 API(如`Feature2D`、`DescriptorMatcher`、`SparseOpticalFlow`等)或通用函数签名(如`solvePnP`和`solvePnPRansac`)来帮助您无缝测试不同的选项。 高级编程语言(如 Python)在交换算法方面甚至具有更大的灵活性;然而,在 C++ 中,除了多态性之外,这也是可能的,只需要一些插装代码。 建立管道后,看看如何交换某些算法(例如,特征类型或匹配器类型、阈值技术)或它们的参数(例如,阈值、算法标志),并测量对最终结果的影响。 严格更改参数通常被称为**超参数调整**,这是机器学习中的标准实践。 - -* **没有对自主开发的解决方案或算法进行单元测试**:**和**程序员通常认为自己的工作没有 bug,并且已经涵盖了所有边缘情况,这是一种谬误。 当涉及到计算机视觉算法时,谨慎行事要好得多,因为在许多情况下,输入空间是非常未知的,因为它的维度高得令人难以置信。 单元测试是确保功能不会因意外输入、无效数据或边缘情况(例如,空图像)而中断并具有优雅降级的优秀工具。 - * **解决方案**:为代码中任何有意义的函数建立单元测试,并确保覆盖重要部分。 例如,任何读取或写入图像数据的函数都是单元测试的理想候选函数。 单元测试是一段简单的代码,通常使用不同的参数多次调用函数,测试函数处理输入的能力(或能力)。 在 C++ 中工作时,测试框架有很多选择;其中一个框架是 Boost C++ 包 Boost.Test([https://www.boost.org/doc/libs/1_66_0/libs/test/doc/html/index.html](https://www.boost.org/doc/libs/1_66_0/libs/test/doc/html/index.html))的一部分。 下面是一个例子: - -```cpp -#define BOOST_TEST_MODULE binarization test -#include - -BOOST_AUTO_TEST_CASE( binarization_test ) -{ - // On empty input should return empty output - BOOST_TEST(binarization_function(cv::Mat()).empty()) - // On 3-channel color input should return 1-channel output - cv::Mat input = cv::imread("test_image.png"); - BOOST_TEST(binarization_function(input).channels() == 1) -} -``` - -编译此文件后,它将创建一个可执行文件,该文件将执行测试,如果所有测试都通过,则以状态`0`退出,如果有任何测试失败,则以状态`1`退出。 通常将此方法与**CMake 的****CTest**([https://cmake.org/cmake/help/latest/manual/ctest.1.html](https://cmake.org/cmake/help/latest/manual/ctest.1.html))特性(通过`CMakeLists.txt`文件中的`ADD_TEST`)混合使用,该特性有助于为代码的许多部分构建测试并根据命令运行它们。 - -* **不检查数据范围**:**和**计算机视觉编程中的一个常见问题是假定数据的范围,例如浮点像素的范围[0,1](`float`,`CV_32F`)或字节像素的范围[0,255](`unsigned char`,`CV_8U`)。 真的不能保证这些假设在任何情况下都成立,因为内存块可以容纳任何值。 在尝试写入大于表示值的值时,这些错误产生的问题主要是值饱和;例如,将 325 写入可容纳[0,255]的字节将饱和为 255,从而损失大量精度。 其他潜在问题是预期数据和实际数据之间的差异,例如,预期深度图像的范围是[0,2048](例如,2 米(毫米)),结果却看到实际范围是[0,1],这意味着它以某种方式被标准化了。 这可能导致算法性能不佳,或者完全崩溃(设想再次将[0,1]范围除以 2048)。 - * **解决方案**:检查输入数据范围并确保它符合您的预期。 如果范围不在可接受的范围内,您可以抛出一个`out_of_range`异常(标准库类,详细信息请访问[https://en.cppreference.com/w/cpp/error/out_of_range](https://en.cppreference.com/w/cpp/error/out_of_range))。 您还可以考虑使用`CV_ASSERT`命令检查范围,这将在失败时触发`cv::error`异常。 -* **数据类型、通道、转换和舍入误差**:**和**OpenCV`cv::Mat`数据结构中最令人头疼的问题之一是它没有携带变量类型的数据类型信息。 `cv::Mat`可以保存任意大小的任何类型的数据(`float`、`uchar`、`int`、`short`等等),接收函数在没有检查或约定的情况下无法知道数组中有什么数据。 通道的数量也使问题更加复杂,因为一个数组可以任意容纳任意数量的通道(例如,`cv::Mat`可以容纳`CV_8UC1`或`CV_8UC3`)。 如果没有已知的数据类型,可能会导致不需要此类数据的 OpenCV 函数在运行时出现异常,从而可能导致整个应用崩溃。 在同一输入`cv::Mat`上处理多种数据类型的问题可能会导致其他转换问题。 例如,如果我们知道传入的数组包含`CV_32F`(通过选中`input.type() == CV_32F`),我们可能会`input.convertTo(out, CV_8U)`将其“规格化”为`uchar`个字符;但是,如果`float`数据在[0,1]范围内,则输出转换将在[0,255]图像中全部为 0 和 1,这可能是一个问题。 - * **解决方案**:优先选择`cv::Mat_<>`类型(例如,`cv::Mat_`)而不是`cv::Mat`来携带数据类型,建立非常明确的变量命名约定(例如,`cv::Mat image_8uc1`),测试以确保您期望的类型就是您获得的类型,或者创建一个“规范化”方案,将任何意外的输入类型转换为您希望在函数中使用的类型。 当担心数据类型不确定时,使用`try .. catch`块也是一个很好的实践。 -* **色彩空间产生的问题:RGB 与感知空间(HSV,L*a*b*)和技术(YUV)**:色彩空间是在像素阵列(图像)中以数值编码颜色信息的一种方式。 但是,这种编码存在许多问题。 最重要的问题是,任何颜色空间最终都会变成存储在数组中的一系列数字,并且 OpenCV 不会跟踪`cv::Mat`中的颜色空间信息(例如,一个数组可能包含 3 字节的 RGB 或 3 字节的 HSV,而变量 user 无法区分)。 这不是一件好事,因为我们倾向于认为,我们可以对数字数据进行任何形式的数字操作,这将是有意义的。 然而,在某些色彩空间中,某些操作需要认识到色彩空间。 例如,在非常有用的**HSV(色调,饱和度,值)**颜色空间中,必须记住,**H(色调)**实际上是*度*[0,360]的度量,通常压缩为[0,180]以适合`uchar`个字符。 因此,在 H 通道中设置值 200 是没有意义的,因为它违反了颜色空间定义并导致意外问题。 线性运算也是如此。 例如,如果我们希望将图像调暗 50%,则在 RGB 中,我们只需将所有通道除以 2 即可;然而,在 HSV(或 L*a*b*、Luv 等)中,必须仅对**V(值)**或**L(亮度)**通道执行除法。 - - 当处理非字节图像(如 YUV420 或 RGB555(16 位色彩空间))时,问题会变得更加严重。 这些图像在*位*级别(而不是字节级别)存储像素值,在同一字节中合成多个像素或一个通道的数据。 例如,RGB555 像素以两个字节(16 位)存储:一位未使用,然后五位用于红色,五位用于绿色,五位用于蓝色。 在这种情况下,所有类型的数值操作(例如,算术)都会失败,并可能导致数据无法修复的损坏。 - * **解决方案**:始终了解您处理的数据的色彩空间。 当使用`cv::imread`从文件读取图像时,您可能会认为它们是按**BGR**顺序读取的(标准 OpenCV 像素数据存储)。 当没有可用的色彩空间信息时,您可以依靠试探法或测试输入。 一般来说,你应该警惕只有两个通道的图像,因为它们很可能是位满的色彩空间。 具有四个通道的图像通常是**ARGB**或**RGBA**,添加了一个**Alpha 通道**,再次引入了一些不确定性。 通过在屏幕上显示通道,可以在视觉上完成感知色彩空间的测试。 位打包问题最严重的原因是处理图像文件、外部库中的内存块或源。 在 OpenCV 中,大多数工作都是在单通道灰度或 BGR 数据上完成的,但在保存到文件或准备图像内存块以在不同的库中使用时,跟踪颜色空间转换非常重要。 请记住,`cv::imwrite`需要*bgr*数据,而不是任何其他格式。 - -* **精度、速度和资源(CPU、内存)的权衡和优化**:**和**计算机视觉中的大多数问题都需要在计算和资源效率之间进行权衡。 有些算法很快,因为它们在内存中缓存关键数据,查找效率很快;其他算法可能很快,因为它们对输入或输出进行了粗略的近似,这会降低准确性。 在大多数情况下,一种吸引人的特质是以牺牲另一种特质为代价的。 不注意这些权衡,或者过于关注它们,可能会成为一个问题。工程师的一个常见陷阱是围绕**优化问题**。 存在欠优化或**过度优化**、**过早优化**、不必要的优化等等。 在寻求优化算法时,有一种倾向是平等对待所有优化,而实际上通常只有一个罪魁祸首(代码行或方法)导致效率最低。处理算法权衡或优化主要是研究和开发*时间*的问题,而不是*结果*的问题。 工程师可能在优化上花费太多或不够的时间,或者在错误的时间进行优化。 - * **解决方案**:在使用算法之前或同时了解算法。 如果您选择一种算法,请通过测试或至少查看 OpenCV 文档页面来确保您了解其复杂性(运行时和资源)。 例如,当匹配图像特征时,应该知道暴力匹配器`BFMatcher`通常比基于 Flann 的近似匹配器`FlannBasedMatcher`慢几个数量级,特别是在预加载和缓存特征是可能的情况下。 - -# 简略的 / 概括的 / 简易判罪的 / 简易的 - -经过 15 年的酝酿,OpenCV 正在成为一个成熟的计算机视觉库。 在此期间,它见证了许多革命的发生,无论是在计算机视觉世界还是在 OpenCV 社区。 - -在本章中,我们通过一个实用的视角回顾了 OpenCV 的过去,了解如何更好地使用它。 我们将重点放在一个特别好的实践上,即检查历史 OpenCV 代码以找到算法的起源,以便做出更好的选择。 为了应对丰富的功能和特性,我们还针对使用 OpenCV 开发计算机视觉应用中的一些常见缺陷提出了解决方案。 - -# 进一步阅读 - -有关详细信息,请参阅以下链接: - -* **OpenCV 更改日志**:11-13[HTTPS://github.com/openCV/openCV/wiki/changelog](https://github.com/opencv/opencv/wiki/ChangeLog) -* **OpenCV 会议记录**:https://github.com/opencv/opencv/wiki/Meeting_notes -* **OpenCV 版本**:https://github.com/opencv/opencv/releases -* **OpenCV Attic Release**:OPENCV[https://github.com/opencv/opencv_attic/releases](https://github.com/opencv/opencv_attic/releases) -* **采访 Gary Bradsky,2011**:[https://www.youtube.com/watch?v=bbnftjY-_lE](https://www.youtube.com/watch?v=bbnftjY-_lE)** \ No newline at end of file