Python3-OpenCV4-计算机视觉学习手册-全-

Python3 OpenCV4 计算机视觉学习手册(全)

原文:Learning OpenCV 4 Computer Vision with Python 3

协议:CC BY-NC-SA 4.0

零、前言

现在是第三版,这是有关 OpenCV 的 Python 绑定的原始书。 从经典到最先进的技术,从几何到机器学习,读者将学到各种各样的技术和算法。 所有这些都有助于解决构建良好的应用中的实际计算机视觉问题。 使用 OpenCV 4 和 Python 3,我们采用了计算机视觉新手可以访问的方法,同时也为想要扩展和更新技能的专家提供了有用的信息。

我们从介绍 OpenCV 4 开始,并说明如何在各种*台上使用 Python 3 进行设置。 接下来,您将学习如何执行基本操作,例如读取,写入,操作和显示静止图像,视频和照相机源。 您将学习图像处理和视频分析以及深度估计和分段,并通过构建一个简单的 GUI 应用来进行练习。 接下来,您将解决两个流行的问题:人脸检测和人脸识别。

随着我们的前进,我们将探索对象分类和机器学习的概念,使您能够创建和使用对象检测器和分类器,甚至可以跟踪电影或摄像机供稿中的对象。 然后,我们将把工作扩展到 3D 跟踪和增强现实。 最后,当我们开发应用以识别手写数字,并分类一个人的性别和年龄。

到本书结尾,您将掌握正确的知识和技能,可以进行自己的真实世界的计算机视觉项目。

这本书是给谁的

本书面向有兴趣在实际应用中学习计算机视觉,机器学习和 OpenCV 的人们。 本书将吸引计算机视觉新手以及希望了解 OpenCV 4 和 Python 3 的专家。读者应该熟悉基本的 Python 编程,但是没有图像处理,计算机视觉或机器学习的先验知识。 需要。

本书涵盖的内容

第 1 章,“设置 OpenCV”解释了如何在各种*台上使用 Python 3 设置 OpenCV 4。 它还提供了常见问题的故障排除步骤。

第 2 章,“处理文件,照相机和 GUI”引入了 OpenCV 的 I/O 功能。 它还讨论了 GUI 项目的面向对象设计,我们将在其他章节中进一步进行开发。

第 3 章,“用 OpenCV 处理图像”提出了更改图像所需的一些技术,例如操纵颜色,锐化图像,标记对象的轮廓以及检测几何形状。

第 4 章,“深度估计和分段”向您展示如何使用深度相机中的数据来识别前景和背景区域,以便我们可以将效果限制为仅前景或背景 。

第 5 章,“检测和识别人脸”引入了一些 OpenCV 的人脸检测和识别功能,以及定义特定类型可检测对象的数据文件。

第 6 章,“检索图像并使用图像描述符进行搜索”展示了如何借助 OpenCV 描述图像的特征,以及如何利用特征匹配和搜索图片。

第 7 章,“构建自定义对象检测器”结合了计算机视觉和机器学习算法来对图像中的对象进行定位和分类。 它显示了如何使用 OpenCV 实现这种算法组合。

第 8 章,“跟踪对象”演示了跟踪和预测视频或实时摄像机源中人和物体运动的方法。

第 9 章,“摄像机模型和增强现实”使您能够构建增强现实应用,该应用使用有关摄像机,对象和运动的信息,将 3D 图形实时叠加在跟踪的对象上。

第 10 章,“使用 OpenCV 的神经网络介绍”,向您介绍人工神经网络ANN) OpenCV 中的深度神经网络DNN),并说明了它们在实际应用中的用法。

附录 A,“弯曲带有曲线过滤器的颜色空间”,描述了颜色曲线的概念以及我们使用 SciPy 对其的实现。

充分利用这本书

希望读者至少具有 Python 编程语言的基本知识。

建议使用 Windows,macOS 或 Linux 开发计算机。 您可以参考第 1 章,“设置 OpenCV”,以获取有关设置 OpenCV 4,Python 3 和其他依赖项的说明。

本书采用动手学习的方式,包括 77 个示例脚本以及示例数据。 阅读本书时,通过这些示例进行工作将有助于实现这些概念。

本书的代码是根据 BSD 3-Clause 开源许可证发行的,该许可证与 OpenCV 本身使用的许可证相同。 鼓励读者使用,修改,改进,甚至将其更改发布到这些示例程序。

下载示例代码文件

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

您可以按照以下步骤下载代码文件:

  1. 登录或注册 www.packt.com
  2. 选择支持选项卡。
  3. 单击代码下载。
  4. 在搜索框中输入书籍的名称,然后按照屏幕上的说明进行操作。

下载文件后,请确保使用以下最新版本解压缩或解压缩文件夹:

  • Windows 的 WinRAR/7-Zip
  • Mac 的 Zipeg/iZip/UnRarX
  • Linux 的 7-Zip/PeaZip

本书的代码包也托管在 GitHub 上。 如果代码有更新,它将在现有的 GitHub 存储库中进行更新。

我们还有丰富的书籍和视频目录中的其他代码包,可通过这个页面获得。 去看一下!

代码实战

可以在这个页面上查看本书的“实现中代码”视频。

下载彩色图像

我们还提供了 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。 您可以在此处下载

使用约定

本书中使用了许多文本约定。

CodeInText:指示文本,数据库表名称,文件夹名称,文件名,文件扩展名,路径名,虚拟 URL,用户输入和 Twitter 句柄中的代码字。 这是一个示例:“OpenCV 提供VideoCaptureVideoWriter类,它们支持各种视频文件格式。”

代码块设置如下:

import cv2

grayImage = cv2.imread('MyPic.png', cv2.IMREAD_GRAYSCALE)
cv2.imwrite('MyPicGray.png', grayImage)

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

import cv2

cameraCapture = cv2.VideoCapture(0)
fps = 30  # An assumption
size = (int(cameraCapture.get(cv2.CAP_PROP_FRAME_WIDTH)),
        int(cameraCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)))
videoWriter = cv2.VideoWriter(
    'MyOutputVid.avi', cv2.VideoWriter_fourcc('M','J','P','G'), fps, size)

通常,命令行输入或输出编写如下:

$ pip install opencv-contrib-python

或者,对于 Windows,命令行输入或输出可以编写如下:

> pip install opencv-contrib-python

粗体:表示新术语,重要单词或您在屏幕上看到的单词。 例如,菜单或对话框中的单词会出现在这样的文本中。 这是一个示例:“现在,在系统变量下,选择路径,然后单击编辑...按钮。”

警告或重要提示如下所示。

提示和技巧如下所示。

一、设置 OpenCV

您已经读了这本书,因此您可能已经对 OpenCV 是什么有了个概念。 也许您听说过似乎来自科幻小说的功能,例如训练人工智能模型以识别通过相机看到的任何东西。 如果这是您的兴趣,您将不会感到失望! OpenCV 代表开源计算机视觉。 它是一个免费的计算机视觉库,可让您处理图像和视频以完成各种任务,从显示网络摄像头中的帧到教机器人识别现实中的物体。

在本书中,您将学习利用 Python 编程语言来利用 OpenCV 的巨大潜力。 Python 是一种优雅的语言,具有相对较浅的学习曲线和非常强大的功能。 本章是设置 Python 3,OpenCV 4 和其他依赖项的快速指南。 作为 OpenCV 的一部分,我们将设置opencv_contrib模块,这些模块提供由 OpenCV 社区而不是核心开发团队维护的其他功能。 设置完成后,我们还将查看 OpenCV 的 Python 示例脚本和文档。

本章介绍了以下相关库:

  • NumPy:此库是 OpenCV 的 Python 绑定的依赖项。 它提供数值计算功能,包括有效的数组。
  • SciPy:此库是与 NumPy 密切相关的科学计算库。 OpenCV 不需要它,但是如果您希望在 OpenCV 映像中操作数据,则它很有用。
  • OpenNI 2:此库是 OpenCV 的可选依赖项。 它增加了对某些深度相机的支持,例如 Asus Xtion PRO。

OpenCV 4 放弃了对 OpenNI 1 以及所有 OpenNI 1 模块(例如 SensorKinect)的支持。 此更改意味着 OpenCV 4 中可能不支持某些较旧的深度相机,例如 Microsoft Kinbox 的 Xbox 版本。

出于本书的目的,可以将 OpenNI 2 视为可选的。 它在第 4 章,“深度估计和分段”中使用,但在其他各章或附录中未使用。

本书重点介绍 OpenCV 4,这是 OpenCV 库的新主要发行版。 有关 OpenCV 的更多信息,请访问这个页面,官方文档可以在这个页面获得。

我们将在本章介绍以下主题:

  • OpenCV 4 的新功能
  • 选择和使用正确的设置工具
  • 运行示例
  • 查找文档,帮助和更新

技术要求

本章假定您正在使用以下操作系统之一:

  • Windows 7 SP1 或更高版本
  • macOS 10.7(Lion)或更高版本
  • Debian Jessie 或更高版本,或诸如以下的衍生版本:
    • Ubuntu 14.04 或更高版本
    • Linux Mint 17 或更高版本

为了编辑 Python 脚本和其他文本文件,本书的作者仅建议您使用一个好的文本编辑器。 示例包括以下内容:

  • Windows 的 NotePad++
  • macOS 的 BBEdit(免费版)
  • Linux 的 GNOME 桌面环境的 GEdit
  • Linux 的 KDE Plasma 桌面环境

除操作系统外,此设置章节没有其他先决条件。

OpenCV 4 的新功能

如果您是 OpenCV 的资深人士,则在决定安装 OpenCV 4 之前,可能需要了解有关 OpenCV 4 更改的更多信息。 这儿是一些精彩片段:

  • OpenCV 的 C++ 实现已更新为 C++ 11。 OpenCV 的 Python 绑定包装了 C++ 实现,因此,作为 Python 用户,即使我们不直接使用 C++,我们也可以从此更新中获得一些性能优势。
  • 已删除了不推荐使用的 OpenCV C 实现和不推荐使用的 Python 绑定。
  • 已经实现了许多新的优化。 现有的 OpenCV 3 项目可以利用其中的许多优化功能,而无需更新 OpenCV 版本。 对于 OpenCV C++ 项目,可以使用名为 G-API 的全新优化管道。 但是,OpenCV 的 Python 绑定当前不支持此优化管道。
  • OpenCV 的 DNN 模块中提供了许多新的机器学习模型。
  • 已删除训练 Haar 级联和 LBP 级联(以检测自定义对象)的工具。 建议在以后的 OpenCV 4 更新中重新实现这些工具以及对其他模型的支持。
  • 现在支持 KinectFusion 算法(用于使用 Microsoft Kinect 2 相机进行三维重建)。
  • 已添加了用于密集光流的 DIS 算法。
  • 添加了一个新模块,用于检测和解码 QR 码。

无论您是否使用过 OpenCV 的先前版本,本书都将作为 OpenCV 4 的一般指南,并且某些新功能将在后续章节中得到特别注意。

选择和使用正确的设置工具

我们可以自由选择各种设置工具,具体取决于我们的操作系统以及我们要执行的配置数量。

无论选择哪种操作系统,Python 都会提供一些内置工具,这些工具对于设置开发环境非常有用。 这些工具包括一个名为pip的包管理器和一个名为venv的虚拟环境管理器。 本章的某些说明将专门介绍pip,但如果您想了解venv,请参阅官方 Python 文档

如果您打算维护各种可能具有相互依赖关系的 Python 项目,则应考虑使用venv-例如,依赖于不同版本的 OpenCV 的项目。 venv的每个虚拟环境都有自己的已安装库集,我们可以在这些环境之间切换而无需重新安装任何东西。 在给定的虚拟环境中,可以使用pip或在某些情况下使用其他工具来安装库。

让我们来概述适用于 Windows,macOS,Ubuntu 和其他类似 Unix 的系统的设置工具。

在 Windows 上安装

Windows 未预装 Python。 但是,有一个适用于 Python 的安装向导,Python 提供了一个名为pip的包管理器,它使我们可以轻松地安装 NumPy,SciPy 和 OpenCV 的现成版本。 另外,我们可以从源代码构建 OpenCV,以启用非标准功能,例如通过 OpenNI 2 支持深度相机。OpenCV 的构建系统使用 CMake 来配置系统,并使用 Visual Studio 进行编译。

首先,让我们安装 Python。 访问这个页面并下载并运行适用于 Python 3.8 的最新安装程序。 您可能需要安装 64 位 Python 的安装程序,尽管 OpenCV 也可以使用 32 位 Python。

安装 Python 后,我们可以使用pip安装 NumPy 和 SciPy。 打开命令提示符并运行以下命令:

> pip install numpy scipy

现在,我们必须决定是要使用现成的 OpenCV 版本(不支持深度相机)还是要定制版本(不支持深度相机)。 接下来的两个小节将介绍这些替代方案。

使用现成的 OpenCV 包

包含opencv_contrib模块的 OpenCV 可以作为pip包安装。 这就像运行以下命令一样简单:

> pip install opencv-contrib-python

如果您希望您的 OpenCV 安装包含非免费内容(例如获得专利的算法),则可以改为运行以下命令:

> pip install opencv-contrib-python-nonfree

如果您打算分发依赖于 OpenCV 的非免费内容的软件,则应自己调查专利和许可问题如何在特定国家和特定用例中应用。 OpenCV 的非免费内容包括获得专利的 SIFT 和 SURF 算法的实现,我们将在第 6 章,“检索图像并使用图像描述符进行搜索”的介绍。

您可能会发现这些pip包之一提供了您当前想要的所有 OpenCV 功能。 另一方面,如果您打算使用深度相机,或者想了解制作 OpenCV 的自定义版本的一般过程,则不应安装 OpenCV pip包; 您应该转至下一部分。

从源代码构建 OpenCV

如果要支持深度相机,还应该安装 OpenNI 2,它可以通过安装向导作为一组预编译的二进制文件获得。 然后,我们必须使用 CMake 和 Visual Studio 从源代码构建 OpenCV。

要获取 OpenNI 2,请访问这个页面,然后下载适用于 Windows 和系统架构(x64 或 x86)的最新 ZIP。 解压缩以获得安装文件,例如OpenNI-Windows-x64-2.2.msi。 运行安装程序。

现在,让我们设置 Visual Studio。 要构建 OpenCV 4,我们需要 Visual Studio 2015 或更高版本。 如果您还没有合适的版本,请访问这个页面并下载并运行以下任一安装程序:

  • 免费的 Visual Studio 2019 社区版
  • 任何付费的 Visual Studio 2019 版本,都有 30 天的试用期

在安装过程中,请确保选择了所有可选的 C++ 组件。 安装完成后,请重新启动。

对于 OpenCV 4,构建配置过程需要 CMake 3 或更高版本。 访问这个页面,下载适用于您的架构(x64 或 x86)的最新版本 CMake 的安装程序,然后运行它。 在安装过程中,为所有用户选择将 CMake 添加到系统PATH或为当前用户选择将 CMake 添加到系统PATH

在这个阶段,我们已经为我们的 OpenCV 自定义构建设置了依赖关系和构建环境。 现在,我们需要获取 OpenCV 源代码并进行配置和构建。 我们可以按照以下步骤进行操作:

  1. 访问这个页面并获取适用于 Windows 的最新 OpenCV 下载。 这是一个自解压的 ZIP。 运行它,并在出现提示时输入任何目标文件夹,我们将其称为<opencv_unzip_destination>。 在提取过程中,会在<opencv_unzip_destination>\opencv创建一个子文件夹。
  2. 访问这个页面并下载opencv_contrib模块的最新 ZIP。 将此文件解压缩到任何目标文件夹,我们将其称为<opencv_contrib_unzip_destination>
  3. 打开命令提示符并运行以下命令,以建立将要进行构建的另一个文件夹:
> mkdir <build_folder>

将目录更改为build文件夹:

> cd <build_folder>
  1. 现在,我们准备使用 CMake 的命令行界面配置构建。 要了解所有选项,我们可以阅读<opencv_unzip_destination>\opencv\CMakeLists.txt中的代码。 但是,出于本书的目的,我们只需要使用将为我们提供带有 Python 绑定,opencv_contrib模块,非免费内容以及通过 OpenNI 2 的深度相机支持的发行版本的选项即可。某些选项略有不同,具体取决于 Visual Studio 版本和目标架构(x64 或 x86)。 若要为 Visual Studio 2019 创建 64 位(x64)解决方案,请运行以下命令(但将<opencv_contrib_unzip_destination><opencv_unzip_destination>替换为实际路径):
> cmake -DCMAKE_BUILD_TYPE=RELEASE -DOPENCV_SKIP_PYTHON_LOADER=ON 
-DPYTHON3_LIBRARY=C:/Python37/libs/python37.lib 
-DPYTHON3_INCLUDE_DIR=C:/Python37/include -DWITH_OPENNI2=ON 
-DOPENCV_EXTRA_MODULES_PATH="<opencv_contrib_unzip_destination>
/modules" -DOPENCV_ENABLE_NONFREE=ON -G "Visual Studio 16 2019" -A x64 "<opencv_unzip_destination>/opencv/sources"

或者,要为 Visual Studio 2019 创建 32 位(x86)解决方案,请运行以下命令(但将<opencv_contrib_unzip_destination><opencv_unzip_destination>替换为实际路径):

> cmake -DCMAKE_BUILD_TYPE=RELEASE -DOPENCV_SKIP_PYTHON_LOADER=ON 
-DPYTHON3_LIBRARY=C:/Python37/libs/python37.lib 
-DPYTHON3_INCLUDE_DIR=C:/Python37/include -DWITH_OPENNI2=ON 
-DOPENCV_EXTRA_MODULES_PATH="<opencv_contrib_unzip_destination>
/modules" -DOPENCV_ENABLE_NONFREE=ON -G "Visual Studio 16 2019" -A Win32 "<opencv_unzip_destination>/opencv/sources"

在前面的命令运行时,它将打印有关已找到或缺少的依赖项的信息。 OpenCV 具有许多可选的依赖项,因此不要对丢失依赖项感到恐慌。 但是,如果构建未成功完成,请尝试安装缺少的依赖项。 (许多都是预构建的二进制文件。)然后,重复此步骤。

  1. CMake 将在<opencv_build_folder>/OpenCV.sln处生成 Visual Studio 解决方案文件。 在 Visual Studio 中打开它。 确保在 Visual Studio 窗口顶部附*的工具栏中的下拉列表中选择了 Release 配置(而不是 Debug 配置)。 (由于大多数 Python 发行版不包含调试库,因此 OpenCV 的 Python 绑定可能不会在 Debug 配置中构建。)转到 BUILD 菜​​单并选择 Build Solution。 在窗口底部的“输出”窗格中查看构建消息,然后等待构建完成。

  2. 到此阶段,已经构建了 OpenCV,但尚未将其安装在 Python 可以找到它的位置。 在继续进行之前,让我们确保我们的 Python 环境尚未包含冲突的 OpenCV 版本。 在 Python 的 DLLs 文件夹和site_packages文件夹中查找和删除所有 OpenCV 文件。 例如,这些文件可能与以下模式匹配:C:\Python37\DLLs\opencv_*.dllC:\Python37\Lib\site- packages\opencvC:\Python37\Lib\site-packages\cv2.pyd

  3. 最后,让我们安装我们的自定义版本的 OpenCV。 作为OpenCV.sln Visual Studio 解决方案的一部分,CMake 生成了一个INSTALL项目。 在 Visual Studio 窗口右侧的“解决方案资源管理器”窗格中查找“CMake 目标 | 安装”项目,右键单击它,然后从上下文菜单中选择“生成”。 再次,在窗口底部的“输出”窗格中查看构建消息,并等待构建完成。 然后,退出 Visual Studio。 编辑系统的Path变量,并附加;<build_folder>\install\x64\vc15\bin (for a 64-bit build);<build_folder>\install\x86\vc15\bin(对于 32 位版本)。 该文件夹是INSTALL项目放置 OpenCV DLL 文件的位置,这些文件是 Python 将在运行时动态加载的库文件。 OpenCV Python 模块位于C:\Python37\Lib\site-packages\cv2.pyd之类的路径中。 Python 将在此处找到它,因此您无需将其添加到Path中。 注销并重新登录(或重新启动)。

前面的说明涉及编辑系统的Path变量。 可以在控制面板的环境变量窗口中完成此任务,如以下步骤所述:

  1. 单击“开始”菜单并启动控制面板。 现在,导航到“系统和安全性 | 高级系统设置”。 点击“环境变量...”按钮。

  2. 现在,在系统变量下,选择路径,然后单击“编辑...”按钮。

  3. 按照指示进行更改。

  4. 要应用更改,请单击所有“确定”按钮(直到我们回到“控制面板”的主窗口中)。

  5. 然后,注销并重新登录。(或者,重新启动。)

现在,我们已经完成了 Windows 上的 OpenCV 生成过程,并且有一个适用于本书所有 Python 项目的自定义生成。

将来,如果要更新到 OpenCV 源代码的新版本,请从下载 OpenCV 开始重复上述所有步骤。

在 macOS 上安装

macOS 随附了预安装的 Python 发行版,该发行版已由 Apple 根据系统的内部需求进行了自定义。 要开发我们自己的项目,我们应该进行单独的 Python 安装,以确保我们不与系统的 Python 需求冲突。

对于 macOS,有几种可能的方法来获取 Python 3,NumPy,SciPy 和 OpenCV 的标准版本。 所有方法最终都要求使用 Xcode 命令行工具从源代码编译 OpenCV。 但是,根据方法的不同,第三方工具会以各种方式自动完成此任务。 我们将使用称为 Homebrew 的包管理器来研究这种方法。 包管理器可以完成 CMake 可以做的所有事情,此外它还可以帮助我们解决依赖关系并将开发库与系统库分离。

MacPorts 是另一个流行的 macOS 包管理器。 但是,在撰写本文时,MacPorts 不提供适用于 OpenCV 4 或 OpenNI 2 的包,因此在本书中我们将不再使用它。

在继续之前,请确保正确设置了 Xcode 命令行工具。 打开一个终端并运行以下命令:

    $ xcode-select --install

同意许可协议和任何其他提示。 安装应完成。 现在,我们有了 Homebrew 所需的编译器。

将 Homebrew 与现成的包一起使用

从已经设置了 Xcode 及其命令行工具的系统开始,以下步骤将通过 Homebrew 为我们提供 OpenCV 安装:

  1. 打开终端并运行以下命令来安装 Homebrew:
 $ /usr/bin/ruby -e "$(curl -fsSL https://raw.github
      usercontent.com/Homebrew/install/master/install)"
  1. Homebrew 不会自动将其可执行文件放入PATH。 为此,创建或编辑~/.profile文件,然后在代码顶部添加以下行:
    export PATH=/usr/local/bin:/usr/local/sbin:$PATH

保存文件并运行以下命令以刷新PATH

    $ source ~/.profile

请注意,由 Homebrew 安装的可执行文件现在优先于由系统安装的可执行文件。

  1. 对于 Homebrew 的自我诊断报告,请运行以下命令:
    $ brew doctor

遵循其提供的任何故障排除建议。

  1. 现在,更新 Homebrew:
    $ brew update
  1. 运行以下命令以安装 Python 3.7:
    $ brew install python
  1. 现在,我们要使用opencv_contrib模块安装 OpenCV。 同时,我们要安装依赖项,例如 NumPy。 为此,请运行以下命令:
 $ brew install opencv

Homebrew 没有提供安装具有 OpenNI 2 支持的 OpenCV 的选项。 Homebrew 始终使用opencv_contrib模块安装 OpenCV,包括非免费内容,例如获得专利的 SIFT 和 SURF 算法,我们将在第 6 章“检索图像并使用图像描述符进行搜索”中。 如果您打算分发依赖于 OpenCV 的非免费内容的软件,则应自己调查专利和许可问题如何在特定国家和特定用例中应用。

  1. 同样,运行以下命令来安装 SciPy:
    $ brew install scipy 

现在,我们拥有在 macOS 上使用 Python 开发 OpenCV 项目所需的一切。

将 Homebrew 与您自己的自定义包一起使用

万一您需要自定义包,Homebrew 可让您轻松编辑现有包定义:

$ brew edit opencv

包定义实际上是 Ruby 编程语言中的脚本。 可以在 Homebrew Wiki 页面上找到有关编辑它们的提示。 脚本可以指定 Make 或 CMake 配置标志等。

要查看与 OpenCV 相关的 CMake 配置标志,请参阅 GitHub 上官方 OpenCV 存储库

对 Ruby 脚本进行编辑后,保存它。

定制包装可以视为正常包装。 例如,可以按以下方式安装:

$ brew install opencv

在 Debian,Ubuntu,Linux Mint 和类似系统上安装

Debian,Ubuntu,Linux Mint 和相关的 Linux 发行版使用apt包管理器。 在这些系统上,很容易为 Python 3 和许多 Python 模块(包括 NumPy 和 SciPy)安装包。 也可以通过apt获得 OpenCV 包,但在编写本文时,该包尚未更新为 OpenCV4。相反,我们可以从 Python 的标准包管理器pip获得 OpenCV 4(不支持深度相机)。 ]。 或者,我们可以从源代码构建 OpenCV 4。 从源代码构建时,OpenCV 可以通过 OpenNI 2 支持深度摄像头,它可以作为带有安装脚本的一组预编译二进制文件提供。

无论我们采用哪种方式获取 OpenCV,我们都首先要更新apt,以便我们可以获得最新的包。 打开一个终端并运行以下命令:

$ sudo apt-get update

更新apt后,让我们运行以下命令为 Python 3 安装 NumPy 和 SciPy:

$ sudo apt-get install python3-numpy python3-scipy

等效地,我们可以使用 Ubuntu 软件中心,它是apt包管理器的图形前端。

现在,我们必须决定是要使用现成的 OpenCV 版本(不支持深度相机)还是要定制版本(不支持深度相机)。 接下来的两个小节将介绍这些替代方案。

使用现成的 OpenCV 包

包含opencv_contrib模块的 OpenCV 可以作为pip包安装。 这就像运行以下命令一样简单:

$ pip3 install opencv-contrib-python

如果您希望 OpenCV 安装中包含非免费内容,例如获得专利的算法,则可以改为运行以下命令:

$ pip install opencv-contrib-python-nonfree

如果您打算分发依赖于 OpenCV 的非免费内容的软件,则应自己调查专利和许可问题如何在特定国家和特定用例中应用。 OpenCV 的非免费内容包括获得专利的 SIFT 和 SURF 算法的实现,我们将在第 6 章,“检索图像并使用图像描述符进行搜索”中介绍。

您可能会发现这些pip包之一提供了您当前想要的所有 OpenCV 功能。 另一方面,如果您打算使用深度相机,或者要了解制作 OpenCV 的自定义版本的一般过程,则不应安装 OpenCV pip包; 您应该转至下一部分。

从源代码构建 OpenCV

要从源代码构建 OpenCV,我们需要一个 C++ 构建环境和 CMake 构建配置系统。 具体来说,我们需要 CMake3。在 Ubuntu 14.04,Linux Mint 17 和相关系统上,cmake包是 CMake 2,但也提供了最新的cmake3包。 在这些系统上,运行以下命令以确保安装了必需版本的 CMake 和其他构建工具:

$ sudo apt-get remove cmake
$ sudo apt-get install build-essential cmake3 pkg-config

另一方面,在较新的操作系统上,cmake包是 CMake 3,我们可以简单地运行以下命令:

$ sudo apt-get install build-essential cmake pkg-config

作为 OpenCV 构建过程的一部分,CMake 将需要访问互联网来下载其他依赖项。 如果您的系统使用代理服务器,请确保已正确配置代理服务器的环境变量。 具体来说,CMake 依赖于http_proxyhttps_proxy环境变量。 要定义这些内容,您可以编辑~/.bash_profile脚本并添加以下内容(但请对其进行修改,以使其与您自己的代理 URL 和端口号匹配):

export http_proxy=http://myproxy.com:8080
export https_proxy=http://myproxy.com:8081

如果不确定系统是否使用代理服务器,则可能不会,因此可以忽略此步骤。

要构建 OpenCV 的 Python 绑定,我们需要安装 Python 3 开发标头。 要安装这些文件,请运行以下命令:

$ sudo apt-get install python3-dev

要从典型的 USB 网络摄像头捕获帧,OpenCV 取决于 Linux 的视频V4L)。 在大多数系统上,预先安装了 V4L,但为防万一它丢失,请运行以下命令:

$ sudo apt-get install libv4l-dev

如前所述,要支持深度摄像头,OpenCV 取决于 OpenNI 2.访问这个页面并下载适用于 Linux 和系统架构(x64 ,x86 或 ARM)。 将其解压缩到任何目标,我们将其称为<openni2_unzip_destination>。 运行以下命令:

$ cd <openni2_unzip_destination>
$ sudo ./install.sh

前面的安装脚本对系统进行了配置,以使其将深度相机支持为 USB 设备。 此外,该脚本会创建引用<openni2_unzip_destination>内部库文件的环境变量。 因此,如果您以后移动<openni2_unzip_destination>,则需要再次运行install.sh

现在我们已经安装了构建环境和依赖项,我们可以获取并构建 OpenCV 源代码。 这样做,请按照下列步骤操作:

  1. 访问这个页面并下载最新的源包。 将其解压缩到任何目标文件夹,我们将其称为<opencv_unzip_destination>
  2. 访问这个页面并下载opencv_contrib模块的最新源代码包。 将其解压缩到任何目标文件夹,我们将其称为<opencv_contrib_unzip_destination>
  3. 打开一个终端。 运行以下命令创建一个目录,我们将在其中放置 OpenCV 构建:
 $ mkdir <build_folder>

转到新创建的目录:

 $ cd <build_folder>
  1. 现在,我们可以使用 CMake 为 OpenCV 生成构建配置。 此配置过程的输出将是一组 Makefile,这是我们可以用来构建和安装 OpenCV 的脚本。 <opencv_unzip_destination>/opencv/sources/CMakeLists.txt文件中定义了一套完整的 OpenCV CMake 配置选项。 为了我们的目的,我们关心与 OpenNI 2 支持,Python 绑定,opencv_contrib模块和非自由内容有关的选项。 通过运行以下命令来配置 OpenCV:
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D BUILD_EXAMPLES=ON -D WITH_OPENNI2=ON -D BUILD_opencv_python2=OFF -D BUILD_opencv_python3=ON -D PYTHON3_EXECUTABLE=/usr/bin/python3.6 -D PYTHON3_INCLUDE_DIR=/usr/include/python3.6 -D PYTHON3_LIBRARY=/usr/lib/python3.6/config-3.6m-x86_64-linux- gnu/libpython3.6.so -D OPENCV_EXTRA_MODULES_PATH=<opencv_contrib_unzip_destination> -D OPENCV_ENABLE_NONFREE=ON <opencv_unzip_destination>
  1. 最后,运行以下命令来解释我们新生成的 Makefile,从而构建和安装 OpenCV:
$ make -j8
$ sudo make install

到目前为止,我们已经在 Debian,Ubuntu 或类似系统上完成了 OpenCV 构建过程,并且我们有一个适用于本书所有 Python 项目的自定义构建。

在其他类似 Unix 的系统上安装

在其他类似 Unix 的系统上,包管理器和可用包可能会有所不同。 请查阅包管理员的文档,并搜索名称中带有opencv的包。 请记住,OpenCV 及其 Python 绑定可能会分为多个包。

另外,查找系统提供商,存储库维护者或社区已发布的所有安装说明。 由于 OpenCV 使用摄像机驱动程序和媒体编解码器,因此在多媒体支持较差的系统上,使其所有功能正常工作可能会很棘手。 在某些情况下,可能需要重新配置或重新安装系统包才能兼容。

如果包可用于 OpenCV,请检查其版本号。 为了本书的目的,建议使用 OpenCV 4。 另外,请检查包是否通过 OpenNI 2 提供 Python 绑定和深度相机支持。最后,检查开发人员社区中是否有人报告使用包的成功或失败。

相反,如果您想从源代码进行 OpenCV 的自定义构建,则参考上一节针对 Debian,Ubuntu 和类似系统的步骤可能会有所帮助,并使这些步骤适应于包管理器和包中存在的包。 另一个系统。

运行示例

运行一些示例脚本是测试 OpenCV 是否已正确设置的好方法。 一些示例包含在 OpenCV 的源代码存档中。 如果尚未获得源代码,请访问这个页面并下载以下档案之一:

  • 对于 Windows,下载最新的归档文件,标记为 Windows。 这是一个自解压的 ZIP。 运行它,并在出现提示时输入任何目标文件夹,我们将其称为<opencv_unzip_destination>。 在<opencv_unzip_destination>/opencv/samples/python中找到 Python 示例。
  • 对于其他系统,请下载标记为“来源”的最新存档。 这是一个 ZIP 文件。 将其解压缩到任何目标文件夹,我们将其称为<opencv_unzip_destination>。 在<opencv_unzip_destination>/samples/python中找到 Python 示例。

一些示例脚本需要命令行参数。 但是,以下脚本(以及其他脚本)应该在没有任何参数的情况下运行:

  • hist.py:此脚本显示照片。 按ABCDE查看照片的变化,以及相应的颜色或灰度值直方图。
  • opt_flow.py:此脚本显示网络摄像头,其中叠加了光流的可视化效果,换句话说就是运动方向。 慢慢将手摇到网络摄像头,以查看效果。 按12进行可视化显示。

要退出脚本,请按Esc(不是 Windows 关闭按钮)。

如果遇到ImportError: No module named cv2消息,则表示我们正在从 Python 安装中运行脚本,而该脚本对 OpenCV 一无所知。 有两种可能的解释:

  • OpenCV 安装中的某些步骤可能失败或错过。 返回并查看步骤。
  • 如果在计算机上安装了多个 Python,则可能使用了错误版本的 Python 启动脚本。 例如,在 macOS 上,可能是为 Homebrew Python 安装了 OpenCV 的情况,但是我们正在使用系统版本的 Python 运行脚本。 返回并查看有关编辑系统PATH变量的安装步骤。 另外,请尝试使用以下命令从命令行手动启动脚本:
$ python hist.py

您也可以尝试以下命令:

$ python3.8 python/camera.py

作为选择其他 Python 安装的另一种可能的方法,请尝试编辑示例脚本以删除#!行。 这些行可能会将脚本与错误的 Python 安装(对于我们的特定设置)明确关联。

查找文档,帮助和更新

可以在这个页面中找到 OpenCV 的文档,您可以在其中在线阅读或下载以供离线阅读。 如果您在飞机上或其他无法上网的地方编写代码,则肯定要保留文档的离线副本。

该文档包含有关 OpenCV C++ API 及其 Python API 的组合 API 参考。 查找类或函数时,请确保阅读标题为python下的部分。

OpenCV 的 Python 模块名为cv2cv2中的2与 OpenCV 的版本号无关。 我们确实在使用 OpenCV4。从历史上看,有一个cv Python 模块封装了现在已经过时的 C 版本的 OpenCV。 cv模块在 OpenCV 4 中不再存在。但是,有时 OpenCV 文档错误地将模块名称称为cv而不是cv2。 只需记住,在 OpenCV 4 中,正确的 Python 模块名称始终为cv2

如果文档似乎无法回答您的问题,请尝试与 OpenCV 社区联系。 在一些网站上,您会找到有用的人:

最后,如果您是高级用户,想尝试来自最新(不稳定)的 OpenCV 源代码的新功能,错误修复和示例脚本,请查看项目的资源库

总结

到目前为止,我们应该已经安装了 OpenCV,可以满足本书中描述的各种项目的需求。 根据我们采用的方法,我们可能还会有一组工具和脚本,可用于重新配置和重建 OpenCV 以满足我们的未来需求。

现在,我们也知道在哪里可以找到 OpenCV 的 Python 示例。 这些样本涵盖了本书范围之外的其他功能范围,但它们可作为其他学习辅助工具。

在下一章中,我们将熟悉 OpenCV API 的最基本功能,即显示图像和视频,通过网络摄像头捕获视频以及处理基本的键盘和鼠标输入。

二、处理文件,相机和 GUI

安装 OpenCV 和运行示例很有趣,但是在此阶段,我们想以自己的方式尝试一下。 本章介绍 OpenCV 的 I/O 功能。 我们还将讨论项目的概念以及该项目的面向对象设计的开始,我们将在随后的章节中充实它们。

通过研究 I/O 功能和设计模式,我们将以制作三明治的相同方式构建项目:从外部开始。在填充或算法之前先进行面包切片和涂抹,或将端点和胶粘。 我们之所以选择这种方法,是因为计算机视觉大多是外向的-它考虑了计算机外部的真实世界-并且我们希望通过通用接口将所有后续算法工作应用于真实世界。

具体而言,在本章中,我们的代码示例和讨论将涵盖以下任务:

  • 从图像文件,视频文件,相机设备或内存中的原始字节数据中读取图像
  • 将图像写入图像文件或视频文件
  • 在 NumPy 数组中处理图像数据
  • 在 Windows 中显示图像
  • 处理键盘和鼠标输入
  • 用面向对象的设计实现应用

技术要求

本章使用 Python,OpenCV 和 NumPy。 请参考第 1 章,“设置 OpenCV*”以获得安装说明。

可在本书的 GitHub 存储库中,在Chapter02文件夹中找到本章的完整代码

基本的 I/O 脚本

大多数 CV 应用都需要获取图像作为输入。 大多数还产生图像作为输出。 交互式 CV 应用可能需要摄影机作为输入源,而窗口则作为输出目标。 但是,其他可能的来源和目的地包括图像文件,视频文件和原始字节。 例如,原始字节可能通过网络连接传输,或者如果我们将过程图形合并到应用中,原始字节可能是由算法生成的。 让我们看看每种可能性。

读/写图像文件

OpenCV 提供imread函数以从文件加载图像,以及imwrite函数以将图像写入文件。 这些函数支持静态图像(非视频)的各种文件格式。 支持的格式各不相同-可以在自定义的 OpenCV 版本中添加或删除格式,但是通常 BMP,PNG,JPEG 和 TIFF 属于受支持的格式。

让我们探讨一下 OpenCV 和 NumPy 中图像表示的剖析。 图像是多维数组。 它具有像素的行和列,并且每个像素都有一个值。 对于不同种类的图像数据,可以以不同方式格式化像素值。 例如,我们可以通过简单地创建 2D NumPy 数组从头开始创建3x3正方形黑色图像:

img = numpy.zeros((3, 3), dtype=numpy.uint8)

如果我们将此图像打印到控制台,则会得到以下结果:

array([[0, 0, 0],
       [0, 0, 0],
       [0, 0, 0]], dtype=uint8)

此处,每个像素由单个 8 位整数表示,这意味着每个像素的值都在 0-255 范围内,其中 0 是黑色,255 是白色,中间值是灰色阴影。 这是灰度图像。

现在,使用cv2.cvtColor函数将此图像转换为蓝绿红BGR)格式:

img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)

让我们观察一下图像如何变化:

array([[[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]],

       [[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]],

       [[0, 0, 0],
        [0, 0, 0],
        [0, 0, 0]]], dtype=uint8)

如您所见,每个像素现在都由一个三元素数组表示,每个整数分别代表三个颜色通道之一:B,G 和 R。 其他常见的颜色模型(例如 HSV)将以相同的方式表示,尽管其值范围不同。 例如,HSV 颜色模型的色相值的范围为 0-180。

有关颜色模型的更多信息,请参阅第 3 章,“使用 OpenCV 处理图像”,尤其是“在不同颜色模型之间进行转换”部分。

您可以通过检查shape属性来检查图像的结构,该属性将返回行,列和通道数(如果有多个)。

考虑以下示例:

img = numpy.zeros((5, 3), dtype=numpy.uint8)
print(img.shape)

前面的代码将显示(5, 3); 换句话说,我们有一个5行和3列的灰度图像。 如果然后将图像转换为 BGR,则形状将为(5, 3, 3),表示每个像素存在三个通道。

可以从一种文件格式加载图像并保存为另一种文件格式。 例如,让我们将图像从 PNG 转换为 JPEG:

import cv2

image = cv2.imread('MyPic.png')
cv2.imwrite('MyPic.jpg', image)

即使我们使用的是 OpenCV 4.x 而不是 OpenCV 2.x,OpenCV 的 Python 模块也称为cv2。 从历史上看,OpenCV 具有两个 Python 模块:cv2cv。 后者封装了用 C 实现的旧版 OpenCV。如今,OpenCV 仅具有cv2 Python 模块,该模块封装了用 C++ 实现的最新版 OpenCV。

默认情况下,即使文件使用灰度格式,imread也会以 BGR 颜色格式返回图像。 BGR 表示与红绿蓝RGB)相同的颜色模型,但字节顺序相反。

可选地,我们可以指定imread的模式。 支持的选项包括:

  • cv2.IMREAD_COLOR:这是默认选项,为每个通道提供 3 通道 BGR 图像,每个图像具有 8 位值(0-255)。
  • cv2.IMREAD_GRAYSCALE:这提供了一个 8 位灰度图像。
  • cv2.IMREAD_ANYCOLOR:根据文件中的元数据,它提供每通道 8 位的 BGR 图像或 8 位的灰度图像。
  • cv2.IMREAD_UNCHANGED:读取所有图像数据,包括 alpha 或透明通道(如果有)作为第四通道。
  • cv2.IMREAD_ANYDEPTH:这将以原始位深度加载灰度图像。 例如,如果文件表示此格式的图像,它将提供每通道 16 位的灰度图像。
  • cv2.IMREAD_ANYDEPTH | cv2.IMREAD_COLOR:此组合以原始位深度加载 BGR 颜色的图像。
  • cv2.IMREAD_REDUCED_GRAYSCALE_2:这会以原始分辨率的一半加载灰度图像。 例如,如果文件包含640 x 480的图像,则它将作为640 x 480的图像加载。
  • cv2.IMREAD_REDUCED_COLOR_2:这将以每通道 8 位 BGR 的颜色加载图像,其分辨率为原始分辨率的一半。
  • cv2.IMREAD_REDUCED_GRAYSCALE_4:这会以原始分辨率的四分之一加载灰度图像。
  • cv2.IMREAD_REDUCED_COLOR_4:这将以每通道 8 位的颜色加载原始分辨率的四分之一的图像。
  • cv2.IMREAD_REDUCED_GRAYSCALE_8:这会以原始分辨率的八分之一以灰度加载图像。
  • cv2.IMREAD_REDUCED_COLOR_8:这将以每通道 8 位的颜色加载图像,其分辨率为原始分辨率的八分之一。

例如,让我们将 PNG 文件加载为灰度图像(在处理过程中丢失任何颜色信息),然后将其另存为灰度 PNG 图像:

import cv2

grayImage = cv2.imread('MyPic.png', cv2.IMREAD_GRAYSCALE)
cv2.imwrite('MyPicGray.png', grayImage) 

图像的路径(除非是绝对路径)相对于工作目录(运行 Python 脚本的路径),因此,在前面的示例中,MyPic.png将必须位于工作目录中,否则图像将不被发现。 如果您希望避免使用关于工作目录的假设,则可以使用绝对路径,例如 Windows 上的C:\Users\Joe\Pictures\MyPic.png,Mac 上的/Users/Joe/Pictures/MyPic.png或 Linux 上的/home/joe/pictures/MyPic.png

imwrite()函数要求图像为 BGR 或灰度格式,每通道具有一定数量的位,输出格式可以支持该位。 例如,BMP 文件格式每个通道需要 8 位,而 PNG 允许每个通道 8 位或 16 位。

在图像和原始字节之间转换

从概念上讲,字节是一个介于 0 到 255 之间的整数。在当今的整个实时图形应用中,一个像素通常由每个通道一个字节表示,尽管其他表示形式也是可能的。

OpenCV 图像是numpy.array类型的 2D 或 3D 数组。 8 位灰度图像是包含字节值的 2D 数组。 24 位 BGR 图像是一个 3D 数组,其中也包含字节值。 我们可以通过使用诸如image[0, 0]image[0, 0, 0]的表达式来访问这些值。 第一个索引是像素的y坐标或行,0是顶部。 第二个索引是像素的x坐标或列,0是最左侧。 第三个索引(如果适用)表示颜色通道。 数组的三个维度可以在以下笛卡尔坐标系中显示:

例如,在左上角具有白色像素的 8 位灰度图像中,image[0, 0]255。 对于左上角带有蓝色像素的 24 位(每通道 8 位)BGR 图像,image[0, 0][255, 0, 0]

假设图像每个通道有 8 位,我们可以将其转换为一维的标准 Python bytearray对象:

byteArray = bytearray(image)

相反,只要bytearray包含适当顺序的字节,我们就可以进行铸造然后对其进行整形以获得numpy.array类型的图像:

grayImage = numpy.array(grayByteArray).reshape(height, width)
bgrImage = numpy.array(bgrByteArray).reshape(height, width, 3)

作为更完整的示例,让我们将包含随机字节的bytearray转换为灰度图像和 BGR 图像:

import cv2
import numpy
import os

# Make an array of 120,000 random bytes.
randomByteArray = bytearray(os.urandom(120000))
flatNumpyArray = numpy.array(randomByteArray)

# Convert the array to make a 400x300 grayscale image.
grayImage = flatNumpyArray.reshape(300, 400)
cv2.imwrite('RandomGray.png', grayImage)

# Convert the array to make a 400x100 color image.
bgrImage = flatNumpyArray.reshape(100, 400, 3)
cv2.imwrite('RandomColor.png', bgrImage)

在这里,我们使用 Python 的标准os.urandom函数生成随机原始字节,然后将其转换为 NumPy 数组。 请注意,也可以使用诸如numpy.random.randint(0, 256, 120000).reshape(300, 400)之类的语句直接(更有效地)生成随机 NumPy 数组。 我们使用os.urandom的唯一原因是帮助演示从原始字节的转换。

运行此脚本后,我们应该在脚本目录中有一对随机生成的图像RandomGray.pngRandomColor.png

这是RandomGray.png的示例(尽管您几乎可以肯定会有所不同,因为它是随机的):

同样,这是RandomColor.png的示例:

现在,我们对如何由数据形成图像有了更好的了解,我们可以开始对其执行基本操作。

使用numpy.array访问图像数据

我们已经知道,在 OpenCV 中加载图像的最简单(也是最常见)方法是使用imread函数。 我们也知道这将返回一个图像,它实际上是一个数组(2D 或 3D 的数组,具体取决于传递给imread的参数)。

numpy.array类针对数组操作进行了极大地优化,它允许某些类型的批量操作,这些操作在普通的 Python 列表中不可用。 这些numpy.array类型特定的操作对于 OpenCV 中的图像处理非常有用。 但是,让我们从一个基本示例开始逐步探索图像操作。 假设您要操作 BGR 图像中坐标(0, 0)处的像素并将其变成白色像素:

import cv2

img = cv2.imread('MyPic.png')
img[0, 0] = [255, 255, 255]

然后,如果将修改后的图像保存到文件中并进行查看,则会在图像的左上角看到一个白点。 自然,这种修改不是很有用,但是它开始显示出可能性。 现在,让我们利用numpy.array的功能在数组上执行转换,比使用普通的 Python 列表要快得多。

假设您要更改特定像素的蓝色值,例如坐标(150, 120)处的像素。 numpy.array类型提供了一种方便的方法item,它采用三个参数:x(或左侧)位置,y(或顶部)位置以及索引 (xy)位置处的数组内(请记住,在 BGR 图像中,特定位置的数据是包含 B,G 和 R 值按此顺序排列),并在索引位置返回该值。 另一种方法itemset将特定像素的特定通道的值设置为指定值。 itemset接受两个参数:一个三元素元组(xy和索引)和新值。

在下面的示例中,我们将蓝色通道(150, 120)的值从其当前值更改为任意255

import cv2

img = cv2.imread('MyPic.png')
img.itemset((150, 120, 0), 255)  # Sets the value of a pixel's blue channel
print(img.item(150, 120, 0))  # Prints the value of a pixel's blue channel

为了修改数组中的单个元素,itemset方法比我们在本节第一个示例中看到的索引语法要快一些。

同样,修改数组的元素本身并不能做什么,但是确实打开了无限的可能性。 但是,出于性能原因,这仅适用于感兴趣的小区域。 当您需要处理整个图像或较大的兴趣区域时,建议您使用 OpenCV 的函数或 NumPy 的数组切片。 后者允许您指定索引范围。 让我们考虑一个使用数组切片来操纵色彩通道的示例。 将图像的所有 G(绿色)值设置为0就像下面的代码一样简单:

import cv2

img = cv2.imread('MyPic.png')
img[:, :, 1] = 0

这段代码执行了相当重要的操作,很容易理解。 相关的行是最后一行,它基本上指示程序从所有行和列中获取所有像素,并将绿色值(三元素 BGR 数组的索引之一)设置为0。 如果显示此图像,您会注意到完全没有绿色。

我们可以通过使用 NumPy 的数组切片访问原始像素来做几件有趣的事情。 其中之一是定义兴趣区域ROI)。 定义区域后,我们可以执行许多操作。 例如,我们可以将此区域绑定到变量,定义第二个区域,并将第一个区域的值分配给第二个区域(因此,将图像的一部分复制到图像中的另一个位置):

import cv2

img = cv2.imread('MyPic.png')
my_roi = img[0:100, 0:100]
img[300:400, 300:400] = my_roi

重要的是要确保两个区域的大小相对应。 如果不是,NumPy 将(正确地)抱怨这两个形状不匹配。

最后,我们可以访问numpy.array的属性,如以下代码所示:

import cv2

img = cv2.imread('MyPic.png')
print(img.shape)
print(img.size)
print(img.dtype)

这三个属性定义如下:

  • shape:这是一个描述数组形状的元组。 对于图像,它包含(按顺序)高度,宽度和(如果图像是彩色的)通道数。 shape元组的长度是确定图像是灰度还是彩色的有用方法。 对于灰度图像,我们有len(shape) == 2,对于彩色图像,我们有len(shape) == 3
  • size:这是数组中元素的数量。 在灰度图像的情况下,这与像素数相同。 在 BGR 图像的情况下,它是像素数的三倍,因为每个像素都由三个元素(B,G 和 R)表示。
  • dtype:这是数组元素的数据类型。 对于每通道 8 位图像,数据类型为numpy.uint8

总而言之,强烈建议您在使用 OpenCV 时首先熟悉 NumPy,尤其是numpy.array。 此类是使用 Python 中的 OpenCV 完成任何图像处理的基础。

读/写视频文件

OpenCV 提供VideoCaptureVideoWriter类,它们支持各种视频文件格式。 支持的格式因操作系统和 OpenCV 的内部配置而异,但是通常可以安全地假定支持 AVI 格式。 通过其read方法,可以轮询VideoCapture对象以获取新帧,直到它到达其视频文件的末尾。 每帧都是 BGR 格式的图像。

相反,图像可以传递到VideoWriter类的write方法,该方法将图像附加到VideoWriter中的文件。 让我们看一个示例,该示例从一个 AVI 文件读取帧并将其通过 YUV 编码写入另一个文件:

import cv2

videoCapture = cv2.VideoCapture('MyInputVid.avi')
fps = videoCapture.get(cv2.CAP_PROP_FPS)
size = (int(videoCapture.get(cv2.CAP_PROP_FRAME_WIDTH)),
        int(videoCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)))
videoWriter = cv2.VideoWriter(
    'MyOutputVid.avi', cv2.VideoWriter_fourcc('I','4','2','0'),
    fps, size)

success, frame = videoCapture.read()
while success:  # Loop until there are no more frames.
    videoWriter.write(frame)
    success, frame = videoCapture.read()

VideoWriter类的构造器的参数值得特别注意。 必须指定视频的文件名。 具有该名称的任何先前存在的文件都将被覆盖。 还必须指定视频编解码器。 可用的编解码器可能因系统而异。 支持的选项可能包括以下内容:

  • 0:此选项是未压缩的原始视频文件。 文件扩展名应为.avi
  • cv2.VideoWriter_fourcc('I','4','2','0'):此选项是未压缩的 YUV 编码,4:2:0 色度被二次采样。 这种编码具有广泛的兼容性,但会产生大文件。 文件扩展名应为.avi
  • cv2.VideoWriter_fourcc('P','I','M','1'):此选项是 MPEG-1。 文件扩展名应为.avi
  • cv2.VideoWriter_fourcc('X','V','I','D'):此选项是相对较旧的 MPEG-4 编码。 如果要限制生成的视频的大小,这是一个不错的选择。 文件扩展名应为.avi
  • cv2.VideoWriter_fourcc('M','P','4','V'):此选项是另一种相对较旧的 MPEG-4 编码。 如果要限制生成的视频的大小,这是一个不错的选择。 文件扩展名应为.mp4
  • cv2.VideoWriter_fourcc('X','2','6','4'):此选项是相对较新的 MPEG-4 编码。 如果您想限制最终视频的大小,这可能是最好的选择。 文件扩展名应为.mp4
  • cv2.VideoWriter_fourcc('T','H','E','O'):此选项为 Ogg Vorbis。 文件扩展名应为.ogv
  • cv2.VideoWriter_fourcc('F','L','V','1'):此选项是 Flash 视频。 文件扩展名应为.flv

还必须指定帧速率和帧大小。 由于我们正在从另一个视频复制,因此可以从VideoCapture类的get方法读取这些属性。

捕捉相机帧

相机帧流也由VideoCapture对象表示。 但是,对于摄像机,我们通过传递摄像机的设备索引而不是视频的文件名来构造VideoCapture对象。 让我们考虑以下示例,该示例从摄像机捕获 10 秒的视频并将其写入 AVI 文件。 代码类似于上一节的示例(从视频文件而不是从摄像机捕获的),但更改以粗体标记:

import cv2

cameraCapture = cv2.VideoCapture(0)
fps = 30  # An assumption
size = (int(cameraCapture.get(cv2.CAP_PROP_FRAME_WIDTH)),
        int(cameraCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)))
videoWriter = cv2.VideoWriter(
    'MyOutputVid.avi', cv2.VideoWriter_fourcc('I','4','2','0'),
    fps, size)

success, frame = cameraCapture.read()
numFramesRemaining = 10 * fps - 1 # 10 seconds of frames
while success and numFramesRemaining > 0:
    videoWriter.write(frame)
    success, frame = cameraCapture.read()
    numFramesRemaining -= 1

对于某些系统上的某些相机,cameraCapture.get(cv2.CAP_PROP_FRAME_WIDTH)cameraCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)可能返回不正确的结果。 为了更确定实际的图像尺寸,您可以先捕获一个帧,然后使用h, w = frame.shape[:2]之类的代码获取其高度和宽度。 有时,您甚至可能会遇到一台照相机,在开始产生尺寸稳定的好帧之前,它会产生一些尺寸不稳定的坏帧。 如果您担心这种怪癖,您可能希望在捕获会话开始时阅读并忽略一些帧。

不幸的是,在大多数情况下,VideoCaptureget方法无法返回相机帧频的准确值; 它通常返回0的官方文档 http://docs.opencv.org/modules/highgui/doc/reading_and_writing_images_and_video.html 警告以下内容:

“查询VideoCapture实例使用的后端不支持的属性时,返回值 0。

注意
读/写属性涉及许多层。
VideoCapture -> API Backend -> Operating System -> Device Driver -> Device Hardware
返回的值可能与设备实际使用的不同,或者可以使用设备相关的规则(例如,步骤或百分比)对其进行编码。 有效行为取决于 SIC 设备驱动程序和 API 后端。”

要为相机创建合适的VideoWriter类,我们必须对帧速率进行假设(就像在前面的代码中所做的那样),或者使用计时器对其进行测量。 后一种方法更好,我们将在本章后面介绍。

摄像机的数量及其顺序当然取决于系统。 不幸的是,OpenCV 没有提供任何查询摄像机数量或其属性的方法。 如果使用无效索引来构造VideoCapture类,则VideoCapture类将不会产生任何帧。 其read方法将返回(False, None)。 为避免尝试从未正确打开的VideoCapture对象检索帧,您可能需要首先调用VideoCapture.isOpened方法,该方法返回布尔值。

当我们需要同步一组摄像机或诸如立体摄像机之类的多头摄像机时,read方法不合适。 然后,我们使用grabretrieve方法代替。 对于一组两个摄像机,我们可以使用类似于以下代码:

success0 = cameraCapture0.grab()
success1 = cameraCapture1.grab()
if success0 and success1:
    frame0 = cameraCapture0.retrieve()
    frame1 = cameraCapture1.retrieve() 

在窗口中显示图像

OpenCV 中最基本的操作之一是在窗口中显示图像。 这可以通过imshow函数来完成。 如果您来自任何其他 GUI 框架背景,您可能会认为只需调用imshow以显示图像即可。 但是,在 OpenCV 中,仅当调用另一个函数waitKey时才绘制(或重新绘制)窗口。 后一个函数抽取窗口的事件队列(允许处理各种事件,例如图形),并返回用户可能在指定的超时时间内键入的任何键的键代码。 这种基本的设计在某种程度上简化了开发使用视频或网络摄像头输入的演示的任务。 至少开发人员可以手动控制新帧的捕获和显示。

这是一个非常简单的示例脚本,用于从文件读取图像并显示它:

import cv2
import numpy as np

img = cv2.imread('my-image.png')
cv2.imshow('my image', img)
cv2.waitKey()
cv2.destroyAllWindows() 

imshow函数具有两个参数:我们要在其中显示图像的窗口的名称和图像本身。 我们将在下一节“在窗口中显示摄像机帧”中讨论waitKey的更多细节。

恰当命名的destroyAllWindows函数可处理由 OpenCV 创建的所有窗口。

在窗口中显示摄像机帧

OpenCV 允许使用namedWindowimshowdestroyWindow函数创建,重绘和销毁命名窗口。 此外,任何窗口都可以通过waitKey函数捕获键盘输入,并通过setMouseCallback函数捕获鼠标输入。 让我们看一个示例,其中显示从实时摄像机捕获的帧:

import cv2

clicked = False
def onMouse(event, x, y, flags, param):
    global clicked
    if event == cv2.EVENT_LBUTTONUP:
        clicked = True

cameraCapture = cv2.VideoCapture(0)
cv2.namedWindow('MyWindow')
cv2.setMouseCallback('MyWindow', onMouse)

print('Showing camera feed. Click window or press any key to stop.')
success, frame = cameraCapture.read()
while success and cv2.waitKey(1) == -1 and not clicked:
    cv2.imshow('MyWindow', frame)
    success, frame = cameraCapture.read()

cv2.destroyWindow('MyWindow')
cameraCapture.release() 

waitKey的参数是等待键盘输入的毫秒数。 默认情况下,它是0,这是一个特殊的值,表示无穷大。 返回值是-1(意味着没有按下任何键)或 ASCII 键码,例如Esc27。 有关 ASCII 键码的列表,请访问这个页面。 另外,请注意,Python 提供了一个标准函数ord,该函数可以将字符转换为其 ASCII 键代码。 例如,ord('a')返回97

再次注意,OpenCV 的窗口功能和waitKey是相互依赖的。 仅在调用waitKey时更新 OpenCV 窗口。 相反,waitKey仅在 OpenCV 窗口具有焦点时捕获输入。

如我们的代码示例所示,传递给setMouseCallback的鼠标回调应采用五个参数。 回调的param参数设置为setMouseCallback的可选第三个参数。 默认情况下为0。 回调的事件参数是以下操作之一:

  • cv2.EVENT_MOUSEMOVE:此事件涉及鼠标移动。
  • cv2.EVENT_LBUTTONDOWN:此事件是指按下左按钮时会使其按下。
  • cv2.EVENT_RBUTTONDOWN:此事件是指按下该按钮时向下的右键。
  • cv2.EVENT_MBUTTONDOWN:此事件是指按下中键时按下的中键。
  • cv2.EVENT_LBUTTONUP:此事件是指释放时返回的左按钮。
  • cv2.EVENT_RBUTTONUP:此事件指的是释放按钮时再次弹出的右键。
  • cv2.EVENT_MBUTTONUP:此事件是指释放按钮时中间按钮再次出现。
  • cv2.EVENT_LBUTTONDBLCLK:此事件表示双击左按钮。
  • cv2.EVENT_RBUTTONDBLCLK:此事件表示双击右键。
  • cv2.EVENT_MBUTTONDBLCLK:此事件是指双击中间按钮。

鼠标回调的flags参数可能是以下事件的按位组合:

  • cv2.EVENT_FLAG_LBUTTON:此事件是指按下左按钮。
  • cv2.EVENT_FLAG_RBUTTON:此事件表示按下了右键。
  • cv2.EVENT_FLAG_MBUTTON:此事件是指按下中间按钮。
  • cv2.EVENT_FLAG_CTRLKEY:此事件是指按下Ctrl键。
  • cv2.EVENT_FLAG_SHIFTKEY:此事件是指按下Shift键。
  • cv2.EVENT_FLAG_ALTKEY:此事件是指按下Alt键。

不幸的是,OpenCV 不提供任何手动处理窗口事件的方法。 例如,当单击窗口的关闭按钮时,我们无法停止我们的应用。 由于 OpenCV 有限的事件处理和 GUI 功能,许多开发人员更喜欢将其与其他应用框架集成。 在本章后面的“Cameo – 面向对象设计”部分中,我们将设计一个抽象层,以帮助将 OpenCV 与任何应用框架集成。

Cameo项目(面部跟踪和图像处理)

OpenCV 通常是通过涵盖许多算法的菜谱方法来研究的,但是与高级应用开发无关。 在某种程度上,这种方法是可以理解的,因为 OpenCV 的潜在应用是如此多样。 OpenCV 被广泛用​​于各种应用中,例如照片/视频编辑器,动作控制游戏,机器人的 AI 或我们记录参与者眼睛运动的心理学实验。 在这些不同的用例中,我们可以真正研究一组有用的抽象吗?

该书的作者相信我们可以做到,而且越早开始创建抽象就越好。 我们将围绕一个应用构建许多 OpenCV 示例,但是,在每个步骤中,我们都会将该应用的组件设计为可扩展和可重用。

我们将开发一个交互式应用,该应用可实时对摄像机输入进行面部跟踪和图像处理。 这类应用涵盖了 OpenCV 的广泛功能,并给我们提出了创建高效有效实现的挑战。

具体来说,我们的应用将实时合并人脸。 给定两个摄像机输入流(或可选地,预录制的视频输入),应用会将一个流中的人脸叠加在另一个流中的人脸之上。 将应用过滤器和变形以使此混合场景具有统一的外观。 用户应具有参与现场表演的经验,然后进入另一种环境和角色。 这种类型的用户体验在迪士尼乐园等游乐园中很受欢迎。

在这样的应用中,用户会立即注意到诸如低帧频或不准确跟踪的缺陷。 为了获得最佳结果,我们将尝试使用常规成像和深度成像的几种方法。

我们将调用我们的应用Cameo。 (珠宝中的)客串是人物的小肖像,或者(电影中的)名人的角色很短暂。

Cameo – 面向对象的设计

Python 应用可以以纯粹的程序样式编写。 这通常是通过小型应用完成的,例如前面讨论过的基本 I/O 脚本。 但是,从现在开始,我们将经常使用面向对象的样式,因为它促进了模块化和可扩展性。

从我们对 OpenCV 的 I/O 功能的概述中,我们知道所有图像都是相似的,无论它们的来源还是目的地。 无论我们如何获取图像流或将其作为输出发送到哪里,我们都可以将相同的特定于应用的逻辑应用于该流中的每个帧。 在使用多个 I/O 流的Cameo之类的应用中,I/O 代码和应用程​​序代码的分离变得特别方便。

我们将创建名为CaptureManagerWindowManager的类作为 I/O 流的高级接口。 我们的应用代码可以使用CaptureManager读取新帧,并可以选择将每个帧分派到一个或多个输出,包括静止图像文件,视频文件和窗口(通过WindowManager类)。 WindowManager类允许我们的应用代码以面向对象的方式处理窗口和事件。

CaptureManagerWindowManager都是可扩展的。 我们可以实现不依赖 OpenCV 进行 I/O 的实现。

使用manager.CaptureManager提取视频流

如我们所见,OpenCV 可以捕获,显示和记录来自视频文件或摄像机的图像流,但是在每种情况下都有一些特殊的注意事项。 我们的CaptureManager类抽象了一些差异,并提供了更高级别的接口来将图像从捕获流分派到一个或多个输出(静止图像文件,视频文件或窗口)。

CaptureManager对象用VideoCapture对象初始化,并具有enterFrameexitFrame方法,通常应在应用主循环的每次迭代中调用该方法。 在对enterFrameexitFrame的调用之间,应用可以(任意次数)设置channel属性并获取frame属性。 channel属性最初是0,仅多头相机使用其他值。 frame属性是当调用enterFrame时与当前通道状态相对应的图像。

CaptureManager类还具有可以随时调用的writeImagestartWritingVideostopWritingVideo方法。 实际文件写入被推迟到exitFrame为止。 另外,在exitFrame方法期间,frame可能会显示在窗口中,具体取决于应用代码是否提供WindowManager类作为CaptureManager的构造器的参数,或者通过设置previewWindowManager属性 。

如果应用代码操纵frame,则操纵将反映在记录的文件和窗口中。 CaptureManager类具有构造器参数和称为shouldMirrorPreview的属性,如果我们希望frame在窗口中而不是在记录的文件中进行镜像(水*翻转),则应为True。 通常,面对摄像机时,用户喜欢对实时摄像机源进行镜像。

回想一下VideoWriter对象需要帧速率,但是 OpenCV 并没有提供任何可靠的方法来获取摄像机的准确帧速率。 CaptureManager类通过使用帧计数器和 Python 的标准time.time函数在必要时估计帧速率来解决此限制。 这种方法不是万无一失的。 根据帧频波动和time.time的系统相关实现,在某些情况下,估计的准确率可能仍然很差。 但是,如果我们部署到未知的硬件,则比仅假设用户的摄像机具有特定的帧速率要好。

让我们创建一个名为managers.py的文件,其中将包含CaptureManager的实现。 事实证明,此实现非常长,因此我们将分几部分进行介绍:

  1. 首先,让我们添加导入和构造器,如下所示:
import cv2
import numpy
import time

class CaptureManager(object):

    def __init__(self, capture, previewWindowManager = None,
                 shouldMirrorPreview = False):

        self.previewWindowManager = previewWindowManager
        self.shouldMirrorPreview = shouldMirrorPreview

        self._capture = capture
        self._channel = 0
        self._enteredFrame = False
        self._frame = None
        self._imageFilename = None
        self._videoFilename = None
        self._videoEncoding = None
        self._videoWriter = None

        self._startTime = None
        self._framesElapsed = 0
        self._fpsEstimate = None

  1. 接下来,让我们为CaptureManager的属性添加以下获取器和设置器方法:
    @property
    def channel(self):
        return self._channel

    @channel.setter
    def channel(self, value):
        if self._channel != value:
            self._channel = value
            self._frame = None

    @property
    def frame(self):
        if self._enteredFrame and self._frame is None:
            _, self._frame = self._capture.retrieve(
                self._frame, self.channel)
        return self._frame

    @property
    def isWritingImage(self):
        return self._imageFilename is not None

    @property
    def isWritingVideo(self):
        return self._videoFilename is not None

请注意,大多数member变量都是非公开的,如变量名中的下划线前缀所表示,例如self._enteredFrame。 这些非公共变量与当前帧的状态以及任何文件写入操作有关。 如前所述,应用代码只需要配置一些东西,这些东西就可以作为构造器参数和可设置的公共属性来实现:相机通道,窗口管理器和镜像相机预览的选项。

本书假定您对 Python 有一定程度的了解。 但是,如果您对这些@注解(例如@property)感到困惑,请参阅有关decorators的 Python 文档,该语言是该语言的内置功能,允许通过另一个函数包装一个函数 ,通常用于在应用的多个位置应用用户定义的行为。 具体来说,您可以在这个页面找到相关文档。

Python 没有强制执行非公共成员变量的概念,但是在开发人员希望将变量视为非公共变量的情况下,您经常会看到单下划线前缀(_)或双下划线前缀(__) 。 单下划线前缀只是一个约定,表示应将变量视为受保护的变量(只能在类及其子类中访问)。 实际上,双下划线前缀使 Python 解释器重命名该变量,从而MyClass.__myVariable变为MyClass._MyClass__myVariable。 这被称为名称修改(相当合适)。 按照惯例,此类变量应视为私有变量(只能在该类中访问,而其子类不能访问)。 具有相同含义的相同前缀可以应用于方法和变量。

  1. 继续执行,让我们将enterFrame方法添加到managers.py中:
    def enterFrame(self):
        """Capture the next frame, if any."""

        # But first, check that any previous frame was exited.
        assert not self._enteredFrame, \
            'previous enterFrame() had no matching exitFrame()'

        if self._capture is not None:
            self._enteredFrame = self._capture.grab() 

请注意,enterFrame的实现仅抓取(同步)帧,而从通道的实际检索被推迟到frame变量的后续读取。

  1. 接下来,让我们将exitFrame方法添加到managers.py中:
    def exitFrame(self):
        """Draw to the window. Write to files. Release the 
        frame."""

        # Check whether any grabbed frame is retrievable.
        # The getter may retrieve and cache the frame.
        if self.frame is None:
            self._enteredFrame = False
            return

        # Update the FPS estimate and related variables.
        if self._framesElapsed == 0:
            self._startTime = time.time()
        else:
            timeElapsed = time.time() - self._startTime
            self._fpsEstimate = self._framesElapsed / timeElapsed
        self._framesElapsed += 1

        # Draw to the window, if any.
        if self.previewWindowManager is not None:
            if self.shouldMirrorPreview:
                mirroredFrame = numpy.fliplr(self._frame)
                self.previewWindowManager.show(mirroredFrame)
            else:
                self.previewWindowManager.show(self._frame)

        # Write to the image file, if any.
        if self.isWritingImage:
            cv2.imwrite(self._imageFilename, self._frame)
            self._imageFilename = None

        # Write to the video file, if any.
        self._writeVideoFrame()

        # Release the frame.
        self._frame = None
        self._enteredFrame = False

exitFrame的实现从当前通道获取图像,估计帧速率,通过窗口管理器(如果有)显示图像,并满足将图像写入文件的所有未决请求。

  1. 其他几种方法也与文件写入有关。 让我们将名为writeImagestartWritingVideostopWritingVideo的公共方法的以下实现添加到managers.py中:
    def writeImage(self, filename):
        """Write the next exited frame to an image file."""
        self._imageFilename = filename

    def startWritingVideo(
            self, filename,
            encoding = cv2.VideoWriter_fourcc('M','J','P','G')):
        """Start writing exited frames to a video file."""
        self._videoFilename = filename
        self._videoEncoding = encoding

    def stopWritingVideo(self):
        """Stop writing exited frames to a video file."""
        self._videoFilename = None
        self._videoEncoding = None
        self._videoWriter = None

前述方法仅更新用于文件写入操作的参数,而实际的写入操作被推迟到exitFrame的下一次调用。

  1. 在本节的前面,我们看到exitFrame调用了一个名为_writeVideoFrame的辅助方法。 让我们将_writeVideoFrame的以下实现添加到managers.py中:
    def _writeVideoFrame(self):

        if not self.isWritingVideo:
            return

        if self._videoWriter is None:
            fps = self._capture.get(cv2.CAP_PROP_FPS)
            if fps <= 0.0:
                # The capture's FPS is unknown so use an estimate.
                if self._framesElapsed < 20:
                    # Wait until more frames elapse so that the
                    # estimate is more stable.
                    return
                else:
                    fps = self._fpsEstimate
            size = (int(self._capture.get(
                        cv2.CAP_PROP_FRAME_WIDTH)),
                    int(self._capture.get(
                        cv2.CAP_PROP_FRAME_HEIGHT)))
            self._videoWriter = cv2.VideoWriter(
                self._videoFilename, self._videoEncoding,
                fps, size)

        self._videoWriter.write(self._frame)

前面的方法以我们较早版本的脚本应该熟悉的方式创建或附加到视频文件(请参阅本章前面的“读取/写入视频文件”部分)。 但是,在帧速率未知的情况下,我们会在捕获会话开始时跳过一些帧,以便有时间建立对帧速率的估计。

至此我们完成了CaptureManager的实现。 尽管它依赖VideoCapture,但我们可以进行其他不使用 OpenCV 进行输入的实现。 例如,我们可以创建一个用套接字连接实例化的子类,该子类的字节流可以解析为图像流。 同样,我们可以创建一个子类,该子类使用第三方相机库,其硬件支持与 OpenCV 提供的硬件支持不同。 但是,对于Cameo,我们当前的实现方式就足够了。

使用manager.WindowManager抽象一个窗口和键盘

如我们所见,OpenCV 提供的功能可导致创建窗口,销毁窗口,显示图像以及处理事件。 这些函数不是窗口类的方法,而是需要窗口的名称作为参数传递。 由于此接口不是面向对象的,因此可以说与 OpenCV 的通用样式不一致。 而且,它不太可能与我们最终想要代替 OpenCV 使用的其他窗口或事件处理接口兼容。

为了面向对象和适应性强,我们将此功能抽象为具有createWindowdestroyWindowshowprocessEvents方法的WindowManager类。 作为属性,WindowManager具有一个称为keypressCallback的函数对象,如果有任何按键,则会从processEvents调用该函数对象(如果不是None)。 keypressCallback对象必须是带有单个参数的函数,尤其是 ASCII 键代码。

让我们在managers.py中添加WindowManager的实现。 该实现从以下类声明和__init__方法开始:

class WindowManager(object):

    def __init__(self, windowName, keypressCallback = None):
        self.keypressCallback = keypressCallback

        self._windowName = windowName
        self._isWindowCreated = False

该实现继续使用以下方法来管理窗口及其事件的生命周期:

    @property
    def isWindowCreated(self):
        return self._isWindowCreated

    def createWindow(self):
        cv2.namedWindow(self._windowName)
        self._isWindowCreated = True

    def show(self, frame):
        cv2.imshow(self._windowName, frame)

    def destroyWindow(self):
        cv2.destroyWindow(self._windowName)
        self._isWindowCreated = False

    def processEvents(self):
        keycode = cv2.waitKey(1)
        if self.keypressCallback is not None and keycode != -1:
            self.keypressCallback(keycode)

我们当前的实现仅支持键盘事件,这对于Cameo足够了。 但是,我们也可以修改WindowManager以支持鼠标事件。 例如,可以将类接口扩展为包括mouseCallback属性(和可选的构造器参数),但否则可以保持不变。 使用 OpenCV 以外的事件框架,我们可以通过添加回调属性以相同的方式支持其他事件类型。

使用cameo.Cameo应用所有内容

我们的应用由Cameo类表示,有两种方法:runonKeypress。 初始化时,Cameo对象将使用onKeypress作为回调创建WindowManager对象,并使用摄像机(具体来说是cv2.VideoCapture对象)和相同的WindowManager对象创建CaptureManager对象。 调用run时,应用执行一个主循环,在其中处理帧和事件。

作为事件处理的结果,可以调用onKeypress。 空格键将截取屏幕快照,TAB导致屏幕录像(视频录制)开始/停止,Esc导致应用退出。

在与managers.py相同的目录中,创建一个名为cameo.py的文件,在该文件中将实现Cameo类:

  1. 该实现从以下import语句和__init__方法开始:
import cv2
from managers import WindowManager, CaptureManager

class Cameo(object):

    def __init__(self):
        self._windowManager = WindowManager('Cameo',
                                            self.onKeypress)
        self._captureManager = CaptureManager(
            cv2.VideoCapture(0), self._windowManager, True)
  1. 接下来,让我们添加run()方法的以下实现:
    def run(self):
        """Run the main loop."""
        self._windowManager.createWindow()
        while self._windowManager.isWindowCreated:
            self._captureManager.enterFrame()
            frame = self._captureManager.frame

            if frame is not None:
                # TODO: Filter the frame (Chapter 3).
                pass

            self._captureManager.exitFrame()
            self._windowManager.processEvents()
  1. 要完成Cameo类的实现,请使用onKeypress()方法:
    def onKeypress(self, keycode):
        """Handle a keypress.

        space -> Take a screenshot.
        tab -> Start/stop recording a screencast.
        escape -> Quit.

        """
        if keycode == 32: # space
            self._captureManager.writeImage('screenshot.png')
        elif keycode == 9: # tab
            if not self._captureManager.isWritingVideo:
                self._captureManager.startWritingVideo(
                    'screencast.avi')
            else:
                self._captureManager.stopWritingVideo()
        elif keycode == 27: # escape
            self._windowManager.destroyWindow()
  1. 最后,让我们添加一个__main__块,该块实例化并运行Cameo,如下所示:
if __name__=="__main__":
    Cameo().run()

运行该应用时,请注意,实时摄像机的提要是镜像的,而屏幕截图和截屏不是。 这是预期的行为,因为我们在初始化CaptureManager类时将shouldMirrorPreview传递给True

这是Cameo的屏幕截图,显示了一个窗口(标题为Cameo)和相机的当前帧:

到目前为止,除了镜像它们以进行预览之外,我们不会以其他任何方式操作它们。 我们将在第 3 章“使用 OpenCV 处理图像”中开始添加更多有趣的效果。

总结

到现在为止,我们应该有一个显示相机供稿,监听键盘输入并(按命令)记录屏幕截图或截屏视频的应用。 我们准备通过在每个帧的开始和结尾之间插入一些图像过滤代码(第 3 章,“用 OpenCV 处理图像”)来扩展应用。 (可选)我们还准备集成 OpenCV 支持的其他相机驱动程序或应用框架。

我们还拥有将图像作为 NumPy 数组进行操作的知识。 这构成了我们下一个主题过滤图像的理想基础。

三、使用 OpenCV 处理图像

或早或晚,使用图像时,您会发现需要更改它们:通过应用艺术过滤器,外推某些部分,融合两个图像,或者您能想到的其他任何方式。 本章介绍了一些可用于更改图像的技术。 到最后,您应该能够执行诸如锐化图像,标记对象轮廓以及使用线段检测器检测人行横道等任务。 具体来说,我们的讨论和代码示例将涵盖以下主题:

  • 在不同颜色模型之间转换图像
  • 了解频率和傅立叶变换在图像处理中的重要性
  • 应用高通过滤器HPF),低通过滤器LPF),边缘检测过滤器和自定义卷积过滤器
  • 检测和分析轮廓,线,圆和其他几何形状
  • 编写封装过滤器实现的类和函数

技术要求

本章使用 Python,OpenCV,NumPy 和 SciPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”。

可在本书的 GitHub 存储库的chapter03文件夹中找到本章的完整代码。 示例图像也位于本书的 GitHub 存储库的images文件夹中。

在不同颜色模型之间转换图像

OpenCV 实际上实现了数百种与颜色模型转换有关的公式。 某些颜色模型通常由输入设备(例如相机)使用,而其他颜色模型通常用于输出设备(例如电视,计算机显示器和打印机)。 在输入和输出之间,当我们将计算机视觉技术应用于图像时,通常将使用三种颜色模型:灰度,蓝绿红BGR)和色相饱和度值HSV)。 让我们简单地看一下这些:

  • 灰度是通过将颜色信息转换为灰度或亮度来减少其颜色的模型。 此模型对于仅亮度信息就足够的问题(例如人脸检测)中的图像中间处理非常有用。 通常,灰度图像中的每个像素都由单个 8 位值表示,范围从黑色的 0 到白色的 255。
  • BGR 是蓝绿色-红色模型,其中每个像素都有代表像素颜色的蓝色,绿色和红色分量或通道的三元组值。 Web 开发人员以及使用计算机图形学的任何人都将熟悉类似的颜色定义,但反向通道顺序为红绿蓝RGB)。 通常,BGR 图像中的每个像素都由一个 8 位值的三元组表示,例如[0, 0, 0]表示黑色,[255, 0, 0]表示蓝色,[0, 255, 0]表示绿色,[0, 0, 255]表示红色,[255, 255, 255]表示白色。
  • HSV 模型使用不同的三元组通道。 色相是颜色的色调,饱和度是颜色的强度,值代表颜色的亮度。

默认情况下,OpenCV 使用 BGR 颜色模型(每通道 8 位)表示它从文件加载或从相机捕获的任何图像。

现在我们已经定义了将要使用的颜色模型,让我们考虑默认模型可能与我们对颜色的直观理解有何不同。

光不是油漆

对于刚接触 BGR 颜色空间的人来说,似乎事情不正确地加起来了:例如,(0, 255, 255)三元组(无蓝色,全绿色和全红色)产生黄色。 如果您具有艺术背景,那么您甚至不需要拾起油漆和刷子就可以知道绿色和红色油漆混合在一起变成了泥泞的棕色。 但是,用于计算的颜色模型称为加法模型,它们处理灯光。 灯光的行为与油漆不同(后者遵循减法颜色模型),并且由于软件在计算机上运行,​​该计算机的介质是发光的监视器,因此参考颜色模型是加法的。

探索傅立叶变换

在 OpenCV 中,您应用于图像和视频的许多处理都涉及傅立叶变换的概念。 约瑟夫·傅里叶(Joseph Fourier)是 18 世纪的法国数学家,发现并普及了许多数学概念。 他研究了热物理学,以及可以用波形函数表示的所有事物的数学。 特别是,他观察到所有波形都是不同频率的简单正弦波之和。

换句话说,您在周围观察到的波形是其他波形的总和。 该概念在处理图像时非常有用,因为它使我们能够识别图像中信号(例如图像像素值)变化很大的区域以及变化不那么剧烈的区域。 然后,我们可以将这些区域任意标记为噪声区域或兴趣区域,背景或前景等。 这些是构成原始图像的频率,我们有能力将它们分开以理解图像并推断出有趣的数据。

OpenCV 实现了许多算法,使我们能够处理图像并理解其中包含的数据,并且在 NumPy 中也重新实现了这些算法,以使我们的生活更加轻松。 NumPy 具有快速傅立叶变换FFT)包,其中包含fft2方法。 这种方法允许我们计算图像的离散傅里叶变换DFT)。

让我们使用傅立叶变换研究图像的幅度谱的概念。 图像的幅度谱是另一幅图像,它根据原始图像的变化提供了表示。 将其视为拍摄图像并将所有最亮的像素拖到中心。 然后,您逐渐走到所有最暗像素已被压入的边界。 马上,您将能够看到图像中包含多少个明暗像素及其分布百分比。

傅立叶变换是用于常见图像处理操作(例如边缘检测或线条和形状检测)的许多算法的基础。

在详细研究这些之前,让我们看一下与傅立叶变换结合在一起的两个概念,它们是上述处理操作的基础:HPF 和 LPF。

HPF 和 LPF

HPF 是一种过滤器,可检查图像区域并根据周围像素强度的差异来提高某些像素的强度。

以以下核为例:

[[ 0,    -0.25,  0   ],
[-0.25,  1,    -0.25],
[ 0,    -0.25,  0   ]]

是一组权重,这些权重应用于源图像中的区域以在目标图像中生成单个像素。 例如,如果我们调用带有参数以指定核大小或7ksize的参数的 OpenCV 函数,则这意味着在生成每个目标像素时会考虑使用 49(7 x 7)个源像素。 我们可以将核视为一块磨砂玻璃,它在源图像上移动,并使源光的扩散混合穿过。

前面的核为我们提供了中心像素及其所有直接水*相邻像素之间强度的*均差。 如果一个像素从周围的像素中脱颖而出,则结果值将很高。 这种类型的核代表一种所谓的高增益过滤器,它是 HPF 的一种,在边缘检测中特别有效。

请注意,边缘检测核中的值通常加起来为0。 我们将在本章的“令人费解的自定义核”部分中对此进行介绍。

让我们来看一个将 HPF 应用于图像的示例:

import cv2
import numpy as np
from scipy import ndimage

kernel_3x3 = np.array([[-1, -1, -1],
                       [-1, 8, -1],
                       [-1, -1, -1]])

kernel_5x5 = np.array([[-1, -1, -1, -1, -1],
                       [-1, 1, 2, 1, -1],
                       [-1, 2, 4, 2, -1],
                       [-1, 1, 2, 1, -1],
                       [-1, -1, -1, -1, -1]])

img = cv2.imread("img/statue_small.jpg", 0)

k3 = ndimage.convolve(img, kernel_3x3)
k5 = ndimage.convolve(img, kernel_5x5)

blurred = cv2.GaussianBlur(img, (17,17), 0)
g_hpf = img - blurred

cv2.imshow("3x3", k3)
cv2.imshow("5x5", k5)
cv2.imshow("blurred", blurred)
cv2.imshow("g_hpf", g_hpf)
cv2.waitKey()
cv2.destroyAllWindows()

初始导入后,我们定义3x3核和5x5核,然后以灰度加载图像。 之后,我们想将图像与每个核进行卷积。 有多种库函数可用于此目的。 NumPy 提供convolve函数; 但是,它仅接受一维数组。 尽管可以使用 NumPy 实现多维数组的卷积,但这会有些复杂。 SciPy 的ndimage模块提供了另一个convolve函数,该函数支持多维数组。 最后,OpenCV 提供filter2D函数(用于与 2D 数组进行卷积)和sepFilter2D函数(用于可分解为两个一维核的 2D 核的特殊情况)。 前面的代码示例说明了ndimage.convolve函数。 我们将在“自定义核的其他示例”部分中使用cv2.filter2D函数。

我们的脚本通过将两个 HPF 与我们定义的两个卷积核一起应用来进行。 最后,我们还通过应用 LPF 并计算原始图像之间的差异,来实现获得 HPF 的另一种方法。 让我们看看每个过滤器的外观。 作为输入,我们从以下照片开始:

现在,这是输出的屏幕截图:

您会注意到,如右下图所示,差分 HPF 产生最佳的边缘查找结果。 由于这种差分方法涉及低通过滤器,因此让我们详细介绍一下这种类型的过滤器。 如果 HPF 增强了像素的强度,考虑到与邻居之间的差异,如果与周围像素的差异小于某个阈值,则 LPF 将使像素*滑。 这用于去噪和模糊处理。 例如,最流行的模糊/*滑过滤器之一是高斯模糊,它是一种衰减高频信号强度的低通过滤器。 高斯模糊的结果显示在左下方的照片中。

现在,我们已经在一个基本示例中尝试了这些过滤器,让我们考虑如何将它们集成到更大,更具交互性的应用中。

创建模块

让我们重新回顾在第 2 章,“处理文件,照相机和 GUI”中启动的Cameo项目。 我们可以修改Cameo,以便将过滤器实时应用于捕获的图像。 与我们的CaptureManagerWindowManager类一样,我们的过滤器应可在Cameo之外重用。 因此,我们应该将过滤器分成各自的 Python 模块或文件。

让我们在与cameo.py相同的目录中创建一个名为filters.py的文件。 我们需要filters.py中的以下import语句:

import cv2
import numpy
import utils

我们还要在同一目录中创建一个名为utils.py的文件。 它应包含以下import语句:

import cv2
import numpy
import scipy.interpolate

我们将为filters.py添加过滤器函数和类,而utils.py中将使用更多通用的数学函数。

边缘检测

边缘在人类和计算机视觉中都扮演着重要角色。 我们作为人类,仅通过查看背光轮廓或粗略草图就可以轻松识别许多对象类型及其姿势。 确实,当艺术强调边缘和姿势时,它通常似乎传达了原型的思想,例如罗丹的《思想家》或乔·舒斯特的《超人》。 软件也可以推断出边缘,姿势和原型。 我们将在后面的章节中讨论这类推理。

OpenCV 提供了许多边缘过滤器,包括LaplacianSobelScharr。 这些过滤器应该将非边缘区域变成黑色,并将边缘区域变成白色或饱和色。 但是,它们易于将噪声误识别为边缘。 可以通过在尝试查找边缘之前对图像进行模糊处理来缓解此缺陷。 OpenCV 还提供了许多模糊过滤器,包括blur(一个简单的*均值),medianBlurGaussianBlur。 边缘查找和模糊过滤器的参数有所不同,但始终包含,这是一个奇数,代表过滤器核的宽度和高度(以像素为单位)。

为了模糊,让我们使用medianBlur,它可以有效消除数字视频噪声,尤其是在彩色图像中。 对于边缘查找,让我们使用Laplacian,它会产生粗体的边缘线,尤其是在灰度图像中。 应用medianBlur之后,但应用Laplacian之前,我们应该将图像从 BGR 转换为灰度。

获得Laplacian的结果后,我们可以将其取反以得到白色背景上的黑色边缘。 然后,我们可以对其进行规格化(使其值的范围为 0 到 1),然后将其与源图像相乘以使边缘变暗。 让我们在filters.py中实现这种方法:

def strokeEdges(src, dst, blurKsize = 7, edgeKsize = 5):
    if blurKsize >= 3:
        blurredSrc = cv2.medianBlur(src, blurKsize)
        graySrc = cv2.cvtColor(blurredSrc, cv2.COLOR_BGR2GRAY)
    else:
        graySrc = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)
    cv2.Laplacian(graySrc, cv2.CV_8U, graySrc, ksize = edgeKsize)
    normalizedInverseAlpha = (1.0 / 255) * (255 - graySrc)
    channels = cv2.split(src)
    for channel in channels:
        channel[:] = channel * normalizedInverseAlpha
    cv2.merge(channels, dst)

请注意,我们允许将核大小指定为strokeEdges的参数。

blurKsize自变量用作medianBlurksize,而edgeKsize用作Laplacianksize。 对于典型的网络摄像头,7blurKsize值和5edgeKsize值可能会产生最令人愉悦的效果。 不幸的是,medianBlurksize之类的大型ksize参数比较昂贵。

如果在运行strokeEdges时遇到性能问题,请尝试减小blurKsize的值。 要关闭模糊效果,请将其设置为小于3的值。

在“修改应用”部分中将其集成到Cameo中之后,我们将在本章稍后看到此过滤器的效果。

自定义核 - 令人费解

正如我们已经看到的,许多 OpenCV 的预定义过滤器都使用核。 请记住,核是一组权重,这些权重确定如何从输入像素的邻域计算每个输出像素。 核的另一个术语是卷积矩阵。 它混合或卷积区域中的像素。 类似地,基于核的过滤器可以称为卷积过滤器。

OpenCV 提供了非常通用的filter2D()函数,该函数可应用我们指定的任何核或卷积矩阵。 要了解如何使用此函数,让我们了解卷积矩阵的格式。 它是一个二维数组,具有奇数行和列。 中心元素对应于感兴趣的像素,而其他元素对应于此像素的邻居。 每个元素都包含一个整数或浮点值,该值是应用于输入像素值的权重。 考虑以下示例:

kernel = numpy.array([[-1, -1, -1],
                      [-1,  9, -1],
                      [-1, -1, -1]])

在此,关注像素的权重为9,其相邻像素的权重为-1。 对于感兴趣的像素,输出颜色将是其输入颜色的九倍,减去所有八个相邻像素的输入颜色。 如果感兴趣的像素已经与其相邻像素有所不同,则这种差异会加剧。 效果是,随着邻居之间的对比度增加,图像看起来更清晰

继续我们的示例,我们可以将此卷积矩阵分别应用于源图像和目标图像,如下所示:

cv2.filter2D(src, -1, kernel, dst)

第二个参数指定目标图像的每通道深度(例如,每通道 8 位的cv2.CV_8U)。 负值(例如此处使用的负值)表示目标图像的深度与源图像的深度相同。

对于彩色图像,请注意filter2D()将核均等地应用于每个通道。 要在不同的通道上使用不同的核,我们还必须使用split()merge()函数。

基于这个简单的示例,让我们向filters.py添加两个类。 一类VConvolutionFilter通常代表卷积过滤器。 子类SharpenFilter将专门代表我们的锐化过滤器。 让我们编辑filters.py,以便我们可以实现这两个新类,如下所示:

class VConvolutionFilter(object):
    """A filter that applies a convolution to V (or all of BGR)."""

    def __init__(self, kernel):
        self._kernel = kernel

    def apply(self, src, dst):
        """Apply the filter with a BGR or gray source/destination."""
        cv2.filter2D(src, -1, self._kernel, dst)

class SharpenFilter(VConvolutionFilter):
    """A sharpen filter with a 1-pixel radius."""

    def __init__(self):
        kernel = numpy.array([[-1, -1, -1],
                              [-1,  9, -1],
                              [-1, -1, -1]])
        VConvolutionFilter.__init__(self, kernel)

注意,权重总和为1。 每当我们要保持图像的整体亮度不变时,都应该是这种情况。 如果我们稍微修改锐化核,使其权重总和为0,我们将拥有一个边缘检测核,该边缘会将边缘变成白色,将非边缘变成黑色。 例如,让我们在filters.py中添加以下边缘检测过滤器:

class FindEdgesFilter(VConvolutionFilter):
    """An edge-finding filter with a 1-pixel radius."""

    def __init__(self):
        kernel = numpy.array([[-1, -1, -1],
                              [-1,  8, -1],
                              [-1, -1, -1]])
        VConvolutionFilter.__init__(self, kernel)

接下来,让我们做一个模糊过滤器。 通常,对于模糊效果,权重之和应为1,并且在整个邻域中应为正。 例如,我们可以对邻域进行简单的*均计算,如下所示:

class BlurFilter(VConvolutionFilter):
    """A blur filter with a 2-pixel radius."""

    def __init__(self):
        kernel = numpy.array([[0.04, 0.04, 0.04, 0.04, 0.04],
                              [0.04, 0.04, 0.04, 0.04, 0.04],
                              [0.04, 0.04, 0.04, 0.04, 0.04],
                              [0.04, 0.04, 0.04, 0.04, 0.04],
                              [0.04, 0.04, 0.04, 0.04, 0.04]])
        VConvolutionFilter.__init__(self, kernel) 

我们的锐化,边缘检测和模糊过滤器使用高度对称的核。 但是有时,对称性较低的核会产生有趣的效果。 让我们考虑一个在一侧模糊(权重为正)而在另一侧锐化(权重为负)的核。 它将产生凸纹或浮雕效果。 这是我们可以添加到filters.py的实现:

class EmbossFilter(VConvolutionFilter):
    """An emboss filter with a 1-pixel radius."""

    def __init__(self):
        kernel = numpy.array([[-2, -1, 0],
                              [-1,  1, 1],
                              [ 0,  1, 2]])
        VConvolutionFilter.__init__(self, kernel)

这套自定义卷积过滤器非常基础。 实际上,它比 OpenCV 的现成的过滤器集更基本。 但是,通过一些试验,您应该能够编写自己的核,从而产生独特的外观。

修改应用

现在,我们已经为几个过滤器提供了高级函数和类,将它们中的任何一个应用到Cameo中捕获的帧上都是微不足道的。 让我们编辑cameo.py,并在以下摘录中添加以粗体显示的行。 首先,我们需要将filters模块添加到我们的导入列表中,如下所示:

import cv2
import filters
from managers import WindowManager, CaptureManager

现在,我们需要初始化将要使用的所有过滤器对象。 在以下修改的__init__方法中可以看到一个示例:

class Cameo(object):

    def __init__(self):
        self._windowManager = WindowManager('Cameo',
                                             self.onKeypress)
        self._captureManager = CaptureManager(
            cv2.VideoCapture(0), self._windowManager, True)
 self._curveFilter = filters.BGRPortraCurveFilter()

最后,我们需要修改run方法以应用我们选择的过滤器。 请参考以下示例:

    def run(self):
        """Run the main loop."""
        self._windowManager.createWindow()
        while self._windowManager.isWindowCreated:
            self._captureManager.enterFrame()
            frame = self._captureManager.frame

            if frame is not None:

 filters.strokeEdges(frame, frame)
 self._curveFilter.apply(frame, frame)

            self._captureManager.exitFrame()
            self._windowManager.processEvents()

    # ... The rest is the same as in Chapter 2

在这里,我们应用了两种效果:抚摸边缘并模拟品牌为 Kodak Portra 的摄影胶片的颜色。 随时修改代码以应用您喜欢的任何过滤器。

有关如何实现 Portra 胶片仿真效果的详细信息,请参见附录 A,“使用曲线过滤器的弯曲色彩空间”。

这是来自Cameo的屏幕截图,带有边缘描边和 Portra 般的颜色:

现在我们已经采样了一些可以通过简单过滤器实现的视觉效果,让我们考虑如何将其他简单功能用于分析目的-特别是边缘和形状的检测。

用 Canny 进行边缘检测

OpenCV 提供了一个方便的函数,称为 Canny(在算法的发明者 John F. Canny 之后),该函数之所以受欢迎,不仅是因为它的有效性,而且因为它是单行的,因此在 OpenCV 程序中实现起来很简单。 :

import cv2
import numpy as np

img = cv2.imread("img/statue_small.jpg", 0)
cv2.imwrite("canny.jpg", cv2.Canny(img, 200, 300))  # Canny in one line!
cv2.imshow("canny", cv2.imread("canny.jpg"))
cv2.waitKey()
cv2.destroyAllWindows()

结果是对边缘的清晰识别:

Canny 边缘检测算法很复杂,但也很有趣。 这是一个五步过程:

  1. 用高斯过滤器对图像进行消噪。
  2. 计算梯度。
  3. 在边缘应用非最大抑制NMS)。 基本上,这意味着算法从一组重叠的边缘中选择最佳边缘。 我们将在第 7 章,“构建自定义对象检测器”中详细讨论 NMS 的概念。
  4. 对所有检测到的边缘应用双重阈值以消除任何误报。
  5. 分析所有边缘及其相互之间的连接,以保留真实边缘并丢弃较弱的边缘。

找到 Canny 边缘后,我们可以对边缘进行进一步分析,以确定它们是否与常用形状(例如直线或圆形)匹配。 霍夫变换是一种以这种方式使用 Canny 边缘的算法。 我们将在本章稍后的“检测线,圆或其他形状”部分中对其进行实验。

现在,我们将研究其他分析形状的方法,而不是基于边缘检测,而是基于发现相似像素的斑点的概念。

轮廓检测

计算机视觉中的一项重要任务是轮廓检测。 我们希望检测图像或视频帧中包含的主题的轮廓或轮廓,这不仅是其目的,而且是迈向其他操作的一步。 这些操作就是计算边界多边形,*似形状以及通常计算兴趣区域ROI)。 ROI 大大简化了与图像数据的交互,因为 NumPy 中的矩形区域很容易用数组切片定义。 在探讨对象检测(包括人脸检测)和对象跟踪的概念时,我们将大量使用轮廓检测​​和 ROI。

让我们通过一个示例熟悉一下 API:

import cv2
import numpy as np

img = np.zeros((200, 200), dtype=np.uint8)
img[50:150, 50:150] = 255

ret, thresh = cv2.threshold(img, 127, 255, 0)
contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE,
                                       cv2.CHAIN_APPROX_SIMPLE)
color = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
img = cv2.drawContours(color, contours, -1, (0,255,0), 2)
cv2.imshow("contours", color)
cv2.waitKey()
cv2.destroyAllWindows()

首先,我们创建一个尺寸为200 x 200像素的空白黑色图像。 然后,我们利用数组在切片上分配值的功能在其中心放置一个白色正方形。

然后,我们对图像进行阈值处理并调用findContours函数。 此函数具有三个参数:输入图像,层次结构类型和轮廓*似方法。 第二个参数指定函数返回的层次结构树的类型。 支持的值之一是cv2.RETR_TREE,它告诉函数检索外部和内部轮廓的整个层次结构。 如果我们要在较大对象(或较大区域)内搜索较小对象(或较小区域),则这些关系可能很重要。 如果只想获取最外部的轮廓,请使用cv2.RETR_EXTERNAL。 在对象出现在纯背景上并且我们不关心在对象内查找对象的情况下,这可能是一个不错的选择。

返回代码示例,请注意findContours函数返回两个元素:轮廓及其层次。 我们使用轮廓在图像的彩色版本上绘制绿色轮廓。 最后,我们显示图像。

结果是一个白色的正方形,其轮廓以绿色绘制-一个斯巴达场景,但有效地展示了这个概念! 让我们继续更有意义的例子。

边界框,最小面积矩形和最小封闭圆

找到一个正方形的轮廓是一个简单的任务。 不规则,倾斜和旋转的形状充分发挥了 OpenCV cv2.findContours函数的全部潜力。 让我们看一下下图:

在实际的应用中,我们将最感兴趣的是确定对象的边界框,其最小包围矩形及其包围圆。 cv2.findContours函数与其他一些 OpenCV 工具一起使此操作非常容易实现。 首先,以下代码从文件读取图像,将其转换为灰度图像,将阈值应用于灰度图像,然后在阈值图像中找到轮廓:

import cv2
import numpy as np

img = cv2.pyrDown(cv2.imread("hammer.jpg", cv2.IMREAD_UNCHANGED))

ret, thresh = cv2.threshold(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY), 127, 255, cv2.THRESH_BINARY)
contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

现在,对于每个轮廓,我们可以找到并绘制边界框,最小封闭矩形和最小封闭圆,如以下代码所示:

for c in contours:
    # find bounding box coordinates
    x,y,w,h = cv2.boundingRect(c)
    cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)

    # find minimum area
    rect = cv2.minAreaRect(c)
    # calculate coordinates of the minimum area rectangle
    box = cv2.boxPoints(rect)
    # normalize coordinates to integers
    box = np.int0(box)
    # draw contours
    cv2.drawContours(img, [box], 0, (0,0, 255), 3)

    # calculate center and radius of minimum enclosing circle
    (x, y), radius = cv2.minEnclosingCircle(c)
    # cast to integers
    center = (int(x), int(y))
    radius = int(radius)
    # draw the circle
    img = cv2.circle(img, center, radius, (0, 255, 0), 2)

最后,我们可以使用以下代码绘制轮廓并在窗口中显示图像,直到用户按下一个键:

cv2.drawContours(img, contours, -1, (255, 0, 0), 1)
cv2.imshow("contours", img)

cv2.waitKey()
cv2.destroyAllWindows()

请注意,轮廓检测是在阈值图像上执行的,因此在此阶段已经丢失了颜色信息,但是我们在原始彩色图像上进行绘制,然后以彩色显示结果。

让我们返回并更仔细地查看在前面的for循环中执行的步骤,在该循环中我们处理每个检测到的轮廓。 首先,我们计算一个简单的边界框:

    x,y,w,h = cv2.boundingRect(c)

这是将轮廓信息非常简单地转换为矩形的(x, y)坐标,高度和宽度。 绘制此矩形是一项简单的任务,可以使用以下代码完成:

    cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)

接下来,我们计算包围主题的最小面积矩形:

    rect = cv2.minAreaRect(c)
    box = cv2.boxPoints(rect)
    box = np.int0(box)

这里使用的机制特别有趣:OpenCV 没有直接根据轮廓信息计算最小矩形顶点的坐标的功能。 相反,我们计算最小矩形面积,然后计算该矩形的顶点。 请注意,计算出的顶点是浮点数,但是像素是通过整数访问的(出于 OpenCV 的绘图函数,您不能访问像素的小数),因此我们需要执行此转换。 接下来,我们绘制方框,这为我们提供了引入cv2.drawContours函数的绝佳机会:

    cv2.drawContours(img, [box], 0, (0,0, 255), 3)

像所有 OpenCV 的绘图函数一样,此函数可以修改原始图像。 请注意,它在第二个参数中采用了轮廓数组,因此您可以在一个操作中绘制多个轮廓。 因此,如果您有一组表示轮廓多边形的点,则需要将这些点包装在数组中,就像在上一个示例中使用包装盒一样。 该函数的第三个参数指定我们要绘制的contours数组的索引:-1的值将绘制所有轮廓。 否则,将绘制contours数组中指定索引处的轮廓(第二个参数)。

大多数绘图函数将绘图的颜色(作为 BGR 元组)及其厚度(以像素为单位)作为最后两个参数。

我们要检查的最后一个边界轮廓是最小封闭圆:

    (x, y), radius = cv2.minEnclosingCircle(c)
    center = (int(x), int(y))
    radius = int(radius)
    img = cv2.circle(img, center, radius, (0, 255, 0), 2)

cv2.minEnclosingCircle函数的唯一特点是它返回一个包含两个元素的元组,其中第一个元素是元组本身,代表圆心的坐标,第二个元素是该圆的半径。 将所有这些值转换为整数后,绘制圆是一项微不足道的操作。

当我们将前面的代码应用于原始图像时,最终结果如下所示:

就圆形和矩形紧紧围绕对象而言,这是一个很好的结果。 但是,显然该对象不是圆形或矩形,因此我们可以实现与其他各种形状的紧密配合。 让我们接下来做。

凸轮廓线和 Douglas-Peucker 算法

在处理轮廓时,我们可能会遇到各种形状的对象,包括凸形。 凸形是指在此形状内没有两点的连接线超出形状本身的范围之外的点。

OpenCV 提供的用于计算形状的*似边界多边形的第一个函数是cv2.approxPolyDP。 此函数采用三个参数:

  • 一个轮廓。
  • 代表原始轮廓和*似多边形之间最大差异的ε值(值越低,*似值越接*原始轮廓)。
  • 布尔值标志。 如果为True,则表示多边形已关闭。

ε值对于获得有用的轮廓至关重要,因此让我们了解它代表什么。ε是*似多边形的周长与原始轮廓的周长之间的最大差。 该差异越小,则*似的多边形将与原始轮廓更相似。

您可能会问自己,当轮廓已经可以精确表示时,为什么需要*似多边形。 答案是多边形是一组直线,如果我们可以定义多边形,以便它们界定区域以进行进一步的处理和处理,则许多计算机视觉任务将变得更加简单。

现在我们知道什么是ε,我们需要获取轮廓周长信息作为参考值。 这可以通过 OpenCV 的cv2.arcLength函数获得:

    epsilon = 0.01 * cv2.arcLength(cnt, True)
    approx = cv2.approxPolyDP(cnt, epsilon, True)

实际上,我们指示 OpenCV 计算一个*似的多边形,其周长只能以ε比率与原始轮廓不同-具体来说是原始弧长的 1%。

OpenCV 还提供cv2.convexHull函数,用于获取凸形的已处理轮廓信息。 这是一个简单的单行表达式:

    hull = cv2.convexHull(cnt)

让我们将原始轮廓,*似多边形轮廓和凸包组合成一个图像,以观察它们之间的差异。 为简化起见,我们将在黑色背景上绘制轮廓,以使原始主题不可见,但其轮廓为:

如您所见,凸包围绕着整个主体,*似多边形是最里面的多边形,在两者之间是原始轮廓,主要由圆弧组成。

通过将前面的所有步骤组合到一个脚本中,该脚本加载文件,查找轮廓,将轮廓*似为多边形,查找凸包并显示可视化效果,我们具有以下代码:

import cv2
import numpy as np

img = cv2.pyrDown(cv2.imread("hammer.jpg", cv2.IMREAD_UNCHANGED))

ret, thresh = cv2.threshold(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),
                            127, 255, cv2.THRESH_BINARY)

contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
                                  cv2.CHAIN_APPROX_SIMPLE)

black = np.zeros_like(img)
for cnt in contours:
    epsilon = 0.01 * cv2.arcLength(cnt,True)
    approx = cv2.approxPolyDP(cnt,epsilon,True)
    hull = cv2.convexHull(cnt)
    cv2.drawContours(black, [cnt], -1, (0, 255, 0), 2)
    cv2.drawContours(black, [approx], -1, (255, 255, 0), 2)
    cv2.drawContours(black, [hull], -1, (0, 0, 255), 2)

cv2.imshow("hull", black)
cv2.waitKey()
cv2.destroyAllWindows()

这样的代码可以在简单的图像上很好地工作,在这些图像中,我们只有一个或几个对象,并且只有几种颜色容易被阈值分开。 不幸的是,颜色阈值和轮廓检测在包含多个对象或多色对象的复杂图像上效果较差。 对于这些更具挑战性的情况,我们将不得不考虑更复杂的算法。

检测线,圆和其他形状

检测边缘和寻找轮廓不仅本身就是常见且重要的任务; 它们还构成其他复杂操作的基础。 线条和形状检测与边缘和轮廓检测齐头并进,因此让我们研究一下 OpenCV 如何实现这些功能。

线条和形状检测背后的理论基于一种称为霍夫变换的技术,该技术由 Richard Duda 和 Peter Hart 发明,他们扩展了(概括)了 Paul Hough 在 1960 年代初所做的工作。 让我们看一下霍夫变换的 OpenCV API。

检测线

首先,让我们检测一些行。 我们可以使用HoughLines函数或HoughLinesP函数进行此操作。 前者使用标准的霍夫变换,而后者使用概率性霍夫变换(因此名称为P)。 之所以称为概率版本,是因为它仅分析图像点的子集,并估计这些点全部属于同一条线的概率。 此实现是标准霍夫变换的优化版本; 它的计算量较小,执行速度更快。 实现HoughLinesP使其返回每个检测到的线段的两个端点,而实现HoughLines使其返回每条线的表示为单个点和一个角度,而没有关于端点的信息。

让我们看一个非常简单的示例:

import cv2
import numpy as np

img = cv2.imread('lines.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray, 50, 120)
minLineLength = 20
maxLineGap = 5
lines = cv2.HoughLinesP(edges, 1, np.pi/180.0, 20,
                        minLineLength, maxLineGap)
for x1, y1, x2, y2 in lines[0]:
    cv2.line(img, (x1, y1), (x2, y2), (0,255,0),2)

cv2.imshow("edges", edges)
cv2.imshow("lines", img)
cv2.waitKey()
cv2.destroyAllWindows()

除了HoughLines函数调用之外,此简单脚本的关键部分是设置最小行长(较短的行将被丢弃)和最大行间距,即两个段开始被视为单独的行之前,行内间距的最大尺寸。

另外,请注意HoughLines函数拍摄单通道二进制图像,该图像通过 Canny 边缘检测过滤器进行处理。 Canny 不是严格的要求,但是经过去噪并仅代表边缘的图像是霍夫变换的理想来源,因此您会发现这是一种常见的做法。

HoughLinesP的参数如下:

  • 图片。
  • 搜索线时要使用的分辨率或步长。 rho是像素的位置步长,而theta是弧度的旋转步长。 例如,如果我们指定rho=1theta=np.pi/180.0,我们将搜索相距仅 1 个像素和 1 度的线。
  • threshold,代表阈值,在该阈值以下将丢弃一条线。霍夫变换适用于箱子和表决系统,每个箱子代表一行,因此如果候选行至少具有threshold个表决数,则将其保留; 否则,将其丢弃。
  • 我们先前提到的minLineLengthmaxLineGap

检测圆

OpenCV 还具有用于检测圆的函数,称为HoughCircles。 它的工作方式与HoughLines非常相似,但是在minLineLengthmaxLineGap是用于丢弃或保留线的参数的情况下,HoughCircles在圆心之间的距离最小,以及圆半径的最小和最大值。 这是强制性的示例:

import cv2
import numpy as np

planets = cv2.imread('planet_glow.jpg')
gray_img = cv2.cvtColor(planets, cv2.COLOR_BGR2GRAY)
gray_img = cv2.medianBlur(gray_img, 5)

circles = cv2.HoughCircles(gray_img,cv2.HOUGH_GRADIENT,1,120,
                           param1=100,param2=30,minRadius=0,maxRadius=0)

circles = np.uint16(np.around(circles))

for i in circles[0,:]:
    # draw the outer circle
    cv2.circle(planets,(i[0],i[1]),i[2],(0,255,0),2)
    # draw the center of the circle
    cv2.circle(planets,(i[0],i[1]),2,(0,0,255),3)
cv2.imwrite("planets_circles.jpg", planets)
cv2.imshow("HoughCirlces", planets)
cv2.waitKey()
cv2.destroyAllWindows()

这是结果的直观表示:

检测其他形状

OpenCV 的霍夫变换实现仅限于检测直线和圆。 然而,当我们谈论approxPolyDP时,我们已经隐式地探索了形状检测。 此函数允许*似多边形,因此,如果您的图像包含多边形,则可以通过组合使用cv2.findContourscv2.approxPolyDP来准确检测到它们。

总结

在这一点上,您应该已经对颜色模型,傅里叶变换以及 OpenCV 提供的用于处理图像的几种过滤器有了很好的了解。

通常,您还应该精通检测边缘,直线,圆和形状。 此外,您应该能够找到轮廓并利用轮廓提供的有关图像中包含的主题的信息。 这些概念是下一章主题的补充-即根据深度对图像进行分割并估计图像中对象的距离。

四、深度估计和分割

本章首先向您展示如何使用深度相机中的数据来识别前景和背景区域,以便我们可以将效果限制为仅前景或背景。

在讨论了深度相机之后,本章将继续进行深度估计的其他技术,即立体成像运动结构SfM)。 后一种技术不需要深度摄像头。 取而代之的是,它们依靠一个或多个普通相机从多个角度捕获对象的图像。

最后,本章介绍了分割技术,这些分割技术使我们能够从单个图像中提取前景对象。 在本章结束时,您将学习将图像分割为多个深度或多个对象的几种方法。 具体来说,我们将涵盖以下主题:

  • 使用深度相机捕获深度图,点云图,视差图,基于可见光的图像和基于红外光的图像
  • 将 10 位图像转换为 8 位图像
  • 将视差图转换为可区分前景区域和背景区域的遮罩
  • 使用立体成像或 SfM 创建视差图
  • 使用 GrabCut 算法将图像分割为前景和背景区域
  • 使用分水岭算法将图像分割成可能是不同对象的多个区域

技术要求

本章使用 Python,OpenCV 和 NumPy。 本章的某些部分使用深度相机(例如 Asus Xtion PRO)以及 OpenCV 对 OpenNI 2 的可选支持,以便从此类相机捕获图像。 请参考第 1 章“设置 OpenCV”以获得安装说明。 本章还使用 Matplotlib 制作图表。 要安装 Matplotlib,请运行$ pip install matplotlib(或$ pip3 install matplotlib,具体取决于您的环境)。

本章的完整代码可以在本书的 GitHub 存储库的chapter04文件夹中找到。 样本图像位于images文件夹中的存储库中。

创建模块

为了帮助我们构建深度相机的交互式演示,我们将重用在第 2 章,“处理文件,相机和 GUI”和第 3 章“使用 OpenCV 处理图像”中开发的Cameo项目的大部分内容。 您会记得,我们将Cameo设计为支持各种输入,因此我们可以轻松地使其适应特定的深度相机。 我们将添加用于分析图像中的深度层的代码,以便找到主要区域,例如坐在相机前面的人的面部。 找到该区域后,我们将其他所有区域涂成黑色。 有时在聊天应用中使用这种效果来隐藏背景,以便用户拥有更多的隐私。

一些用于处理深度相机数据的代码可在Cameo.py外部重用,因此我们应将其分成一个新模块。 让我们在与Cameo.py相同的目录中创建一个depth.py文件。 我们需要depth.py中的以下import语句:

import numpy

我们的应用将使用与深度有关的功能,因此让我们在Cameo.py中添加以下import语句:

import depth

我们还将修改CaptureManager.py,但是我们不需要为其添加任何新的import语句。

现在,我们已经简要介绍了将要创建或修改的模块,让我们更深入地研究深度主题。

从深度相机捕获帧

回到第 2 章,“处理文件,相机和 GUI”,我们讨论了计算机可以具有多个视频捕获设备,每个设备可以具有多个通道的概念。 假设给定的设备是深度相机。 每个通道可能对应于不同的镜头和传感器。 而且,每个通道可能对应于不同种类的数据,例如正常彩色图像与深度图。 OpenCV 通过对 OpenNI 2 的可选支持,使我们可以从深度摄像头请求以下任何通道(尽管给定的摄像头可能仅支持其中一些通道​​):

  • cv2.CAP_OPENNI_DEPTH_MAP:这是深度图-灰度图像,其中每个像素值是从相机到表面的估计距离。 具体地,每个像素值是表示以毫米为单位的深度测量值的 16 位无符号整数。
  • cv2.CAP_OPENNI_POINT_CLOUD_MAP:这是点云图-一种彩色图像,其中每种颜色对应于xyz空间尺寸。 具体来说,该通道会产生 BGR 图像,其中 B 为x(蓝色为右),G 为y(绿色为上),R 为z(红色代表很深),从相机的角度来看。 值以米为单位。
  • cv2.CAP_OPENNI_DISPARITY_MAPcv2.CAP_OPENNI_DISPARITY_MAP_32F:这些是视差图-灰度图像,其中每个像素值是表面的立体视差。 为了概念化立体视差,我们假设我们叠加了一个场景的两个图像,这些图像是从不同的角度拍摄的。 结果将类似于看到两倍。 对于场景中任何一对孪生对象上的点,我们都可以以像素为单位测量距离。 该测量是立体差异。 邻*的物体比远处的物体表现出更大的立体视差。 因此,附*的物体在视差图中显得更亮。 cv2.CAP_OPENNI_DISPARITY_MAP是具有 8 位无符号整数值的视差图,cv2.CAP_OPENNI_DISPARITY_MAP_32F是具有 32 位浮点值的视差图。
  • cv2.CAP_OPENNI_VALID_DEPTH_MASK:这是一个有效深度掩码,它显示给定像素处的深度信息是有效的(由非零值表示)还是无效的(由零值表示) 。 例如,如果深度相机依赖于红外照明器(红外闪光灯),则深度信息在被该光遮挡(阴影)的区域中无效。
  • cv2.CAP_OPENNI_BGR_IMAGE:这是来自摄像头的普通 BGR 图像,可捕获可见光。 每个像素的 B,G 和 R 值是无符号的 8 位整数。
  • cv2.CAP_OPENNI_GRAY_IMAGE:这是来自摄像机的普通单色图像,捕获可见光。 每个像素值是一个无符号的 8 位整数。
  • cv2.CAP_OPENNI_IR_IMAGE:这是来自摄像机的单色图像,用于捕获红外IR)光,特别是*红外NIR)的频谱。 每个像素值是一个无符号的 16 位整数。 通常,相机实际上不会使用整个 16 位范围,而只是使用其中的一部分,例如 10 位范围。 数据类型仍然是 16 位整数。 尽管*红外光是人眼看不到的,但它在物理上与红光非常相似。 因此,来自照相机的 NIR 图像对于人类不一定看起来很奇怪。 但是,典型的深度相机不仅可以捕获*红外光,而且可以投影出呈网格状的*红外光,以利于寻找深度算法。 因此,我们可能会在深度相机的 NIR 图像中看到可识别的面部,但是该面部可能会点缀着明亮的白光。

让我们考虑其中一些图像类型的样本。 以下屏幕快照显示了一个人坐在猫雕塑后面的点云图:

这是同一场景的视差图:

最后,这是现在熟悉的猫雕塑和人的有效深度遮罩:

接下来,让我们考虑如何在诸如Cameo之类的交互式应用中使用深度相机的某些通道。

将 10 位图像转换为 8 位

正如我们在上一节中提到的,深度摄像头的某些通道的数据使用大于 8 位的范围。 大范围往往对计算有用,但对显示却不方便,因为大多数计算机监视器每个通道只能使用 8 位范围[0, 255]

OpenCV 的cv2.imshow函数重新缩放并截断给定的输入数据,以便转换图像进行显示。 具体来说,如果输入图像的数据类型是 16 位无符号整数或 32 位有符号整数,则cv2.imshow将数据除以 256,然后将其截断为 8 位无符号整数范围[0, 255]。 如果输入图像的数据类型为 32 位或 64 位浮点数,则cv2.imshow假定数据的范围为[0.0, 1.0],因此它将数据乘以 255 并将其截断为 8 位无符号整数范围[0, 255]。 通过重新缩放数据,cv2.imshow依靠其对原始比例的幼稚假设。 这些假设在某些情况下是错误的。 例如,如果图像的数据类型是 16 位无符号整数,但是实际数据范围是 10 位无符号整数[0, 1023],那么如果我们依靠cv2.imshow进行转换,则图像看起来会很暗。 。

考虑以下用 10 位灰度相机捕获的眼睛图像示例。 在左侧,我们看到了从 10 位标度到 8 位标度正确转换的结果。 在右侧,基于错误的假设,即图像使用 16 位缩放,我们看到转换错误的结果:

转换不正确的图像看起来全是黑色,因为我们对比例的假设偏离了很多:6 位或 64 倍。如果我们依靠cv2.imshow自动执行转换为 8 位比例,可能会为我们出现这种错误。

当然,为了避免此类问题,我们可以进行自己的图像转换,然后将生成的 8 位图像传递给cv2.imshow。 让我们修改managers.pyCameo项目中现有的脚本之一),以便提供将 10 位图像转换为 8 位的选项。 我们将提供一个shouldConvertBitDepth10To8变量,开发人员可以将其设置为TrueFalse。 以下代码块(在粗体中进行了更改)显示了如何初始化此变量:

class CaptureManager(object):

    def __init__(self, capture, previewWindowManager = None,
                 shouldMirrorPreview = False,
 shouldConvertBitDepth10To8 = True):

        self.previewWindowManager = previewWindowManager
        self.shouldMirrorPreview = shouldMirrorPreview
 self.shouldConvertBitDepth10To8 = \
 shouldConvertBitDepth10To8

        # ... The rest of the method is unchanged ...

接下来,我们将修改frame属性的获取器以支持转换。 如果shouldConvertBitDepth10To8True,并且帧的数据类型为 16 位无符号整数,那么我们将假定帧实际上具有 10 位范围,并将其转换为 8 位。 作为转换的一部分,我们将应用右移操作>> 2,该操作将截断两个最低有效位。 这等效于整数除以 4。这是相关代码:

    @property
    def frame(self):
        if self._enteredFrame and self._frame is None:
            _, self._frame = self._capture.retrieve(
                    self._frame, self.channel)
 if self.shouldConvertBitDepth10To8 and \
 self._frame is not None and \
 self._frame.dtype == numpy.uint16:
 self._frame = (self._frame >> 2).astype(
 numpy.uint8)
        return self._frame

通过这些修改,我们将能够更轻松地操纵和显示某些通道的帧,特别是cv2.CAP_OPENNI_IR_IMAGE。 不过,接下来,让我们看一下一个函数示例,该函数操纵cv2.CAP_OPENNI_DISPARITY_MAPcv2.CAP_OPENNI_VALID_DEPTH_MASK通道中的帧,以创建一个可以遮挡用户面部等物体的遮罩。 之后,我们将考虑如何在Cameo中一起使用所有这些渠道。

从视差图创建遮罩

假设用户的脸部或其他感兴趣的对象占据了深度相机的大部分视场。 但是,图像还包含其他一些不感兴趣的内容。 通过分析视差图,我们可以知道矩形内的某些像素离群值-太*或太远,以至于不能真正成为人脸或其他感兴趣对象的一部分。 我们可以做一个遮罩以排除这些异常值。 但是,我们应该仅在数据有效的情况下应用此测试,如有效的深度掩码所示。

让我们编写一个函数来生成一个遮罩,该遮罩的值对于图像的拒绝区域为0,对于接受区域为255。 此函数应使用视差图,有效深度遮罩以及可选的矩形作为参数。 如果指定了矩形,我们将制作一个与指定区域大小相同的遮罩。 稍后在第 5 章“检测和识别人脸”中,这对我们很有用,我们将与人脸检测器一起使用,该检测器在人脸周围找到边界矩形。 让我们调用createMedianMask函数并在depth.py中实现它,如下所示:

def createMedianMask(disparityMap, validDepthMask, rect = None):
    """Return a mask selecting the median layer, plus shadows."""
    if rect is not None:
        x, y, w, h = rect
        disparityMap = disparityMap[y:y+h, x:x+w]
        validDepthMask = validDepthMask[y:y+h, x:x+w]
    median = numpy.median(disparityMap)
    return numpy.where((validDepthMask == 0) | \
                       (abs(disparityMap - median) < 12),
                       255, 0).astype(numpy.uint8)

为了识别视差图中的离群值,我们首先使用numpy.median来找到中值,它以数组作为参数。 如果数组的长度为奇数,则median返回如果对数组进行排序将返回数组中间的值。 如果数组的长度为偶数,则median返回将最接*数组中间排序的两个值的*均值。

为了基于每个像素的布尔运算生成遮罩,我们将numpy.where与三个参数一起使用。 在第一个参数中,where接受一个数组,其元素的值是真或假。 返回相同尺寸的输出数组。 无论输入数组中的元素是True还是where函数的第二个参数都分配给输出数组中的相应元素。 相反,无论输入数组中的元素是False的位置如何,where函数的第三个参数都将分配给输出数组中的相应元素。

当像素的有效视差值与中位数视差值相差 12 或更多时,我们的实现会将像素视为离群值。 我们仅通过实验就选择了 12 的值。 以后根据您使用特定相机设置运行Cameo时遇到的结果,随时调整此值。

修改应用

让我们打开Cameo.py文件,其中包含我们在第 3 章“使用 OpenCV 处理图像”中最后修改的Cameo类。 此类实现了与常规相机配合使用的应用。 我们不一定要替换此类,而是希望创建该类的变体,该变体更改某些方法的实现以代替使用深度相机。 为此,我们将创建一个子类,该子类继承某些Cameo行为并覆盖其他行为。 我们称它为CameoDepth子类。 将以下行添加到Cameo.py(在Cameo类之后和__main__代码块之前),以便将CameoDepth声明为Cameo的子类:

class CameoDepth(Cameo):

我们将覆盖或重新实现CameoDepth中的__init__方法。Cameo使用常规相机的设备索引实例化CaptureManager类,而CameoDepth需要使用深度相机的设备索引。 后者可以是cv2.CAP_OPENNI2(代表 Microsoft Kinect 的设备索引),也可以是cv2.CAP_OPENNI2_ASUS(代表 Asus Xtion PRO 或枕骨结构的设备索引)。 以下代码块显示了CameoDepth__init__方法的示例实现(与粗体中的Cameo__init__方法不同),但您可能需要将适合您的设置的设备索引取消注释:

    def __init__(self):
        self._windowManager = WindowManager('Cameo',
                                            self.onKeypress)
        #device = cv2.CAP_OPENNI2 # uncomment for Kinect
        device = cv2.CAP_OPENNI2_ASUS # uncomment for Xtion or Structure
        self._captureManager = CaptureManager(
            cv2.VideoCapture(device), self._windowManager, True)
        self._curveFilter = filters.BGRPortraCurveFilter()

同样,我们将覆盖run方法,以使用深度相机中的多个通道。 首先,我们将尝试检索视差图,然后检索有效的深度遮罩,最后检索 BGR 彩色图像。 如果无法检索到 BGR 图像,则可能意味着深度相机没有任何 BGR 传感器,因此,在这种情况下,我们将继续检索红外灰度图像。 以下代码段显示了CameoDepthrun方法的开始:

    def run(self):
        """Run the main loop."""
        self._windowManager.createWindow()
        while self._windowManager.isWindowCreated:
            self._captureManager.enterFrame()
 self._captureManager.channel = cv2.CAP_OPENNI_DISPARITY_MAP
 disparityMap = self._captureManager.frame
 self._captureManager.channel = cv2.CAP_OPENNI_VALID_DEPTH_MASK
 validDepthMask = self._captureManager.frame
 self._captureManager.channel = cv2.CAP_OPENNI_BGR_IMAGE
 frame = self._captureManager.frame
 if frame is None:
 # Failed to capture a BGR frame.
 # Try to capture an infrared frame instead.
 self._captureManager.channel = cv2.CAP_OPENNI_IR_IMAGE
 frame = self._captureManager.frame

捕获视差图,有效的深度遮罩以及 BGR 图像或红外灰度图像后,run方法将继续调用上一节中实现的depth.createMedianMask函数,“从视差图创建遮罩”。 我们将视差图和有效深度遮罩传递给后一个函数,作为回报,我们收到的遮罩在深度接*中值深度的区域中为白色,而在其他区域中为黑色。 无论遮罩是黑色(mask == 0)的何处,我们都希望将 BGR 或红外图像绘制成黑色,以使图像中除主要对象之外的所有东西都模糊不清。 最后,对于 BGR 图像,我们想应用先前在第 3 章“使用 OpenCV 处理图像”中实现的艺术过滤器。 以下代码完成了CameoDepth run方法的实现:

            if frame is not None:

 # Make everything except the median layer black.
 mask = depth.createMedianMask(disparityMap, validDepthMask)
 frame[mask == 0] = 0

 if self._captureManager.channel == \
 cv2.CAP_OPENNI_BGR_IMAGE:
 # A BGR frame was captured.
 # Apply filters to it.
 filters.strokeEdges(frame, frame)
 self._curveFilter.apply(frame, frame)

            self._captureManager.exitFrame()
            self._windowManager.processEvents()

CameoDepth不需要自己的任何其他方法实现; 它从其父类或Cameo超类继承适当的实现。

现在,我们只需要修改Cameo.py__main__部分,即可运行CameoDepth类的实例而不是Cameo类。 以下是相关代码:

if __name__=="__main__":
    #Cameo().run() # uncomment for ordinary camera
  CameoDepth().run() # uncomment for depth camera

插入深度摄像机,然后运行脚本。 靠*或远离相机移动,直到可以看到您的脸,但是背景变黑。 以下屏幕快照是使用CameoDepth和 Asus Xtion PRO 相机拍摄的。 我们可以看到作者之一的约瑟夫·豪斯(Joseph Howse)刷牙的红外图像。 该代码已成功使背景变黑,因此图像无法显示他是在房屋,火车还是帐篷中刷牙。 谜仍在继续:

这是考虑我们在上一节中实现的createMedianMask函数的输出的好机会。 如果我们将遮罩为 0 的区域可视化为黑色,而遮罩为 1 的区域可视化为白色,则约瑟夫·霍斯(Joseph Howse)刷牙的遮罩如下所示:

结果是好的,但不是完美的。 例如,在图像的右侧(从查看者的角度来看),遮罩错误地在头发后面包括阴影区域,并且错误地排除了肩膀。 可以通过微调在createMedianMask的实现中与numpy.where一起使用的标准来解决后一个问题。

如果您有幸拥有多台深度相机,请尝试使用所有深度相机,以了解它们在支持彩色图像方面的区别以及它们在区分远*层方面的有效性。 另外,尝试各种物体和照明条件,以查看它们如何影响(或不影响)红外图像。 当您对测试结果感到满意时,让我们继续进行其他技术进行深度估计。 (我们将在后续章节中再次介绍深度相机。)

普通相机的深度估计

深度相机是一种令人印象深刻的设备,但并非每个开发人员或用户都有一个,并且有一些限制。 值得注意的是,典型的深度相机在户外无法很好地工作,因为阳光的红外分量比摄像机自身的红外光源要亮得多。 摄像机被太阳遮住,无法看到通常用于估计深度的红外模式。

作为替代方案,我们可以使用一个或多个普通摄像机,并且可以从不同摄像机角度基于三角测量来估计到对象的相对距离。 如果我们同时使用两个摄像机,则此方法称为立体视觉。 如果我们使用一台摄像机,但是随着时间的推移移动它以获得不同的视角,则此方法称为运动结构。 广义上,立体视觉技术在 SfM 中也有帮助,但是在 SfM 中,如果我们要处理运动中的物体,我们将面临其他问题。 出于本章的目的,让我们假设我们正在处理一个固定的主题。

正如许多哲学家会同意的那样,几何学是我们对世界的理解的基础。 更重要的是,对极几何是立体视觉的基础。 对极几何如何工作? 从概念上讲,它会跟踪从相机到图像中每个对象的假想线,然后在第二个图像上进行操作,并根据与同一对象相对应的线的交点计算到对象的距离。 这是此概念的表示:

让我们看看 OpenCV 如何应用对极几何来计算视差图。 这将使我们能够将图像分割为前景和背景的各个层。 作为输入,我们需要从不同角度拍摄同一主题的两幅图像。

与我们的许多脚本一样,此脚本从导入 NumPy 和 OpenCV 开始:

import numpy as np
import cv2

我们为立体算法的几个参数定义初始值,如以下代码所示:

minDisparity = 16
numDisparities = 192 - minDisparity
blockSize = 5
uniquenessRatio = 1
speckleWindowSize = 3
speckleRange = 3
disp12MaxDiff = 200
P1 = 600
P2 = 2400

使用这些参数,我们创建 OpenCV 的cv2.StereoSGBM类的实例。 SGBM 代表半全局块匹配,这是一种用于计算视差图的算法。 这是初始化对象的代码:

stereo = cv2.StereoSGBM_create(
    minDisparity = minDisparity,
    numDisparities = numDisparities,
    blockSize = blockSize,
    uniquenessRatio = uniquenessRatio,
    speckleRange = speckleRange,
    speckleWindowSize = speckleWindowSize,
    disp12MaxDiff = disp12MaxDiff,
    P1 = P1,
    P2 = P2
)

我们还从文件加载两个图像:

imgL = cv2.imread('img/color1_small.jpg')
imgR = cv2.imread('img/color2_small.jpg')

我们希望提供几个滑块,以使用户能够交互式地调整计算视差图的算法的参数。 每当用户调整任何滑块时,我们都将通过设置StereoSGBM实例的属性来更新立体算法的参数,并通过调用StereoSGBM实例的compute方法重新计算视差图。 让我们看一下update函数的实现,它是滑块的回调函数:

def update(sliderValue = 0):

    stereo.setBlockSize(
        cv2.getTrackbarPos('blockSize', 'Disparity'))
    stereo.setUniquenessRatio(
        cv2.getTrackbarPos('uniquenessRatio', 'Disparity'))
    stereo.setSpeckleWindowSize(
        cv2.getTrackbarPos('speckleWindowSize', 'Disparity'))
    stereo.setSpeckleRange(
        cv2.getTrackbarPos('speckleRange', 'Disparity'))
    stereo.setDisp12MaxDiff(
        cv2.getTrackbarPos('disp12MaxDiff', 'Disparity'))

    disparity = stereo.compute(
        imgL, imgR).astype(np.float32) / 16.0

    cv2.imshow('Left', imgL)
    cv2.imshow('Right', imgR)
    cv2.imshow('Disparity',
               (disparity - minDisparity) / numDisparities)

现在,让我们看一下创建窗口和滑块的代码:

cv2.namedWindow('Disparity')
cv2.createTrackbar('blockSize', 'Disparity', blockSize, 21,
                   update)
cv2.createTrackbar('uniquenessRatio', 'Disparity',
                   uniquenessRatio, 50, update)
cv2.createTrackbar('speckleWindowSize', 'Disparity',
                   speckleWindowSize, 200, update)
cv2.createTrackbar('speckleRange', 'Disparity',
                   speckleRange, 50, update)
cv2.createTrackbar('disp12MaxDiff', 'Disparity',
                   disp12MaxDiff, 250, update)

请注意,我们将update函数作为cv2.createTrackbar函数的参数提供,以便在调整滑块时都会调用update。 接下来,我们手动调用update来初始化视差图:

# Initialize the disparity map. Show the disparity map and images.
update()

当用户按下任意键时,我们将关闭窗口:

# Wait for the user to press any key.
# Meanwhile, update() will be called anytime the user moves a slider.
cv2.waitKey()

让我们回顾一下该示例的功能。 我们拍摄同一对象的两张图像,并计算出视差图,以较亮的色调显示图中更靠*相机的点。 黑色标记的区域代表差异。

这是我们在此示例中使用的第一张图片:

这是第二个:

用户可以看到原始图像,以及精美且易于理解的视差图:

我们使用了StereoSGBM支持的许多参数,但不是全部。 OpenCV 文档提供了所有参数的以下描述:

参数 OpenCV 文档中的描述
minDisparity 最小可能的视差值。 通常为零,但是有时校正算法可以移动图像,因此需要相应地调整此参数。
numDisparities 最大视差减去最小视差。 该值始终大于零。 在当前的实现中,此参数必须可被 16 整除。
blockSize 匹配的块大小。 它必须是>= 1的奇数。 通常,它应该在 3-11 范围内。
P1 控制视差*滑度的第一个参数[请参见P2的说明]。
P2 控制视差*滑度的第二个参数。 值越大,视差越*滑。 P1是相邻像素之间视差变化加或减 1 的代价。 P2是相邻像素之间视差变化大于 1 的惩罚。 该算法需要P2 > P1。 请参见stereo_match.cpp示例,其中显示了一些相当不错的P1P2值,例如分别为8*number_of_image_channels*SADWindowSize*SADWindowSize32*number_of_image_channels*SADWindowSize*SADWindowSize
disp12MaxDiff 左右视差检查中允许的最大差异(以整数像素为单位)。 将其设置为非正值可禁用检查。
preFilterCap 预过滤图像像素的截断值。 该算法首先在每个像素处计算x导数,然后按[-preFilterCap, preFilterCap]间隔裁剪其值。 结果值将传递到 Birchfield-Tomasi 像素成本函数。
uniquenessRatio 最佳(最小)计算成本函数值应赢得次优值以考虑找到的匹配正确的百分比边距。 通常,在 5 到 15 范围内的值就足够了。
speckleWindowSize *滑视差区域的最大大小,以考虑其噪声斑点并使其无效。 将其设置为 0 以禁用斑点过滤。 否则,将其设置在 50-200 范围内。
speckleRange 每个连接组件内的最大视差变化。 如果执行斑点过滤,请将参数设置为正值;否则,将参数设置为正值。 它会隐式乘以 16。通常,1 或 2 就足够了。
mode 将其设置为StereoSGBM::MODE_HH,即可运行满量程,两遍动态规划算法。 它将占用O(WHnumDisparities)字节,对于640x480立体来说很大,而对于 HD 尺寸的图片来说很大。 默认情况下,它设置为false

使用前面的脚本,您将能够加载您选择的图像并使用参数,直到对StereoSGBM生成的视差图满意为止。

用 GrabCut 算法进行前景检测

计算视差图是分割图像前景和背景的有用方法,但是StereoSGBM并不是唯一可以实现此目的的算法,并且StereoSGBM实际上更多的是从二维图像中收集三维信息。 三维图片比什么都重要。 GrabCut 是用于前景/背景分割的理想工具。 GrabCut 算法包括以下步骤:

  1. 定义了包括图片主题的矩形。

  2. 矩形外部的区域会自动定义为背景。

  3. 背景中包含的数据用作区分用户定义矩形内的背景区域和前景区域的参考。

  4. 高斯混合模型GMM)对前景和背景进行建模,并将未定义的像素标记为可能的背景和可能的前景。

  5. 图像中的每个像素都通过虚拟边缘虚拟连接到周围的像素,并且根据边缘与周围像素的颜色相似程度,为每个边缘分配了成为前景或背景的概率。

  6. 每个像素(或在算法中概念化的节点)都连接到前景或背景节点。 您可以将其可视化如下:

  1. 将节点连接到任一终端(分别为背景或前景,也分别称为源或接收器)之后,属于不同终端的节点之间的边缘将被切除(因此,其名称为 GrabCut)。 因此,图像被分为两部分。 下图充分代表了该算法:

让我们来看一个例子。 我们从一个美丽的天使雕像开始:

我们想抓住我们的天使并抛弃背景。 为此,我们将创建一个相对较短的脚本,该脚本将使用 GrabCut 分割图像,然后将结果前景图像与原始图像并排显示。 我们将使用matplotlib这个流行的 Python 库,该函数使显示图表和图像成为一项微不足道的任务。

该代码实际上非常简单。 首先,加载要处理的图像,然后创建一个填充有零的掩码,其形状与加载的图像相同:

import numpy as np
import cv2
from matplotlib import pyplot as plt

original = cv2.imread('img/statue_small.jpg')
img = original.copy()
mask = np.zeros(img.shape[:2], np.uint8)

然后,我们创建零填充的背景和前景模型:

bgdModel = np.zeros((1, 65), np.float64)
fgdModel = np.zeros((1, 65), np.float64)

我们可以用数据填充这些模型,但是我们将使用一个矩形标识要隔离的对象来初始化 GrabCut 算法。 因此,将基于初始矩形之外的区域确定背景和前景模型。 该矩形在下一行中定义:

rect = (100, 1, 421, 378)

现在到有趣的部分! 我们运行 GrabCut 算法。 作为参数,我们指定用于初始化操作的空模型,掩码和矩形:

cv2.grabCut(img, mask, rect, bgdModel, fgdModel, 5, cv2.GC_INIT_WITH_RECT)

注意5整数参数。 这是算法将在图像上运行的迭代次数。 您可以增加它,但是在某些时候,像素分类会收敛,因此有效地,您可能只是添加迭代而对结果没有任何进一步的改进。

此后,我们的遮罩将更改为包含 0 到 3 之间(包括 0 和 3)的值。 这些值具有以下含义:

  • 0(也定义为cv2.GC_BGD)是明显的背景像素。
  • 1(也定义为cv2.GC_FGD)是明显的前景像素。
  • 2(也定义为cv2.GC_PR_BGD)是可能的背景像素。
  • 3(也定义为cv2.GC_PR_FGD)是可能的前景像素。

为了使 GrabCut 的结果可视化,我们想将背景涂成黑色,而前景保持不变。 我们可以制作另一个面具来帮助我们做到这一点。 值02(明显和可能的背景)将转换为 0,而值13(明显且可能是前景)将转换为 1s。 结果将存储在mask2中。 我们将原始图像乘以mask2,以使背景变黑(乘以0),同时保持前景不变(乘以1)。 以下是相关代码:

mask2 = np.where((mask==2) | (mask==0), 0, 1).astype('uint8')
img = img*mask2[:,:,np.newaxis]

脚本的最后部分并排显示图像:

plt.subplot(121)
plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
plt.title("grabcut")
plt.xticks([])
plt.yticks([])

plt.subplot(122)
plt.imshow(cv2.cvtColor(original, cv2.COLOR_BGR2RGB))
plt.title("original")
plt.xticks([])
plt.yticks([])

plt.show()

结果如下:

这是一个令人满意的结果。 您会注意到在天使的手臂下留下了一个三角形的背景。 可以通过手动选择更多的背景区域并应用更多的迭代来完善 GrabCut 结果。 OpenCV 安装的samples/python文件夹中的grabcut.py文件中很好地说明了此技术。

利用分水岭算法进行图像分割

最后,让我们快速看一下分水岭算法。 该算法称为“分水岭”,因为其概念化涉及水。 将图像中密度低(几乎没有变化)的区域想象为谷,而密度高(变化很多)的区域则作为峰。 开始向山谷中注水,直到两个不同山谷中的水汇合为止。 为了防止来自不同山谷的水汇合,您需要建立屏障以使它们分开。 最终的障碍是图像分割。

例如,让我们分割一张扑克牌的图像。 我们想要将点(大的,可数的符号)与背景分开:

  1. 再一次,我们通过导入numpycv2matplotlib开始脚本。 然后,我们从文件加载纸牌图像:
import numpy as np
import cv2
from matplotlib import pyplot as plt

img = cv2.imread('img/5_of_diamonds.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  1. 将图像从彩色转换为灰度后,我们对其运行阈值。 通过将图像分为两个区域(黑色和白色),此操作将有所帮助:
ret, thresh = cv2.threshold(gray, 0, 255,
                            cv2.THRESH_BINARY_INV | cv2.THRESH_OTSU)
  1. 接下来,我们通过对其进行形态转换,从阈值图像中去除噪声。 形态扩张(扩展)或侵蚀(收缩)图像的白色区域组成。 我们将应用形态学上的打开操作,该操作包括腐蚀步骤和扩张步骤。 打开操作使大的白色区域吞没了很少的黑色区域(噪声),而使大的黑色区域(真实物体)相对保持不变。 cv2.morphologyEx函数和cv2.MORPH_OPEN参数允许我们执行以下操作:
# Remove noise.
kernel = np.ones((3,3), np.uint8)
opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel,
                           iterations = 2)
  1. 通过扩大开放变换的结果,我们可以获得最确定的背景图像区域:
# Find the sure background region.
sure_bg = cv2.dilate(opening, kernel, iterations=3)

相反,我们可以通过应用distanceTransform获得肯定的前景区域。 实际上,如果一个点与最接*的前景背景边缘相距甚远,我们可以最有把握地将其视为前景的一部分。

  1. 一旦获得图像的distanceTransform表示形式,便可以应用阈值来选择最肯定是前景部分的区域:
# Find the sure foreground region.
dist_transform = cv2.distanceTransform(opening,cv2.DIST_L2,5)
ret, sure_fg = cv2.threshold(
        dist_transform, 0.7*dist_transform.max(), 255, 0)
sure_fg = sure_fg.astype(np.uint8)

在此阶段,我们可以确定一些前景和背景区域。

  1. 现在,中间的区域呢? 我们可以通过从背景中减去确定的前景来找到这些不确定或未知的区域:
# Find the unknown region.
unknown = cv2.subtract(sure_bg, sure_fg)
  1. 现在我们有了这些区域,我们可以建立著名的屏障来阻止水合并。 这是通过connectedComponents函数完成的。 当我们分析 GrabCut 算法并将图像概念化为一组由边连接的节点时,我们对图论有所了解。 给定肯定的前景区域,这些节点中的一些将连接在一起,但有些则不会。 断开连接的节点属于不同的水谷,它们之间应该有一个障碍:
# Label the foreground objects.
ret, markers = cv2.connectedComponents(sure_fg)
  1. 接下来,我们在所有区域的标签上加 1,因为我们只希望未知数保持在0
# Add one to all labels so that sure background is not 0, but 1.
markers += 1

# Label the unknown region as 0.
markers[unknown==255] = 0
  1. 最后,我们打开大门! 让水流! cv2.watershed函数将标签-1分配给组件之间边缘的像素。 我们在原始图像中将这些边缘涂成蓝色:
markers = cv2.watershed(img, markers)
img[markers==-1] = [255,0,0]

让我们使用matplotlib显示结果:

plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
plt.show()

该图应如下图所示:

这种类型的分割可以用作识别纸牌的系统的一部分。 类似地,分水岭算法可以帮助我们在纯背景上分割和计数任何种类的对象,例如一张纸上的硬币。

总结

在本章中,我们学习了如何分析图像中的简单空间关系,以便我们可以区分多个对象,或前景和背景。 我们的技术包括从二维输入(视频帧或图像)中提取三维信息。 首先,我们检查了深度相机,然后检查了极线几何和立体图像,因此我们现在能够计算视差图。 最后,我们用两种最受欢迎​​的方法研究了图像分割:GrabCut 和 Watershed。

随着本书的发展,我们将继续从图像中提取越来越复杂的信息。 接下来,我们准备探索 OpenCV 用于检测和识别面部和其他对象的功能。

三、检测和识别人脸

计算机视觉使许多具有未来感的任务成为现实。 两个这样的任务是人脸检测(在图像中定位面部)和人脸识别(将人脸识别为特定人)。 OpenCV 实现了多种用于人脸检测和识别的算法。 它们在从安全性到娱乐性的各种现实环境中都有应用。

本章介绍 OpenCV 的一些人脸检测和识别功能,以及定义特定类型的可跟踪对象的数据文件。 具体来说,我们看一下 Haar 级联分类器,它可以分析相邻图像区域之间的对比度,以确定给定图像或子图像是否与已知类型匹配。 我们考虑如何在层次结构中组合多个 Haar 级联分类器,以便一个分类器标识父区域(就我们的目的而言,是人脸),而其他分类器标识子区域(例如眼睛)。

我们还绕过了谦虚但重要的矩形主题。 通过绘制,复制和调整矩形图像区域的大小,我们可以对正在跟踪的图像区域执行简单的操作。

总而言之,我们将涵盖以下主题:

  • 了解 Haar 级联。
  • 查找 OpenCV 附带的经过预先训练的 Haar 级联。 这些包括几个人脸检测器。
  • 使用 Haar 级联来检测静止图像和视频中的面部。
  • 收集图像来训练和测试人脸识别器。
  • 使用几种不同的人脸识别算法:EigenFace,Fisherfaces 和本地二进制模式直方图LBPH)。
  • 使用或不使用遮罩,将矩形区域从一个图像复制到另一个图像。
  • 使用深度相机基于深度来区分面部和背景。
  • 在交互式应用中交换两个人的脸。

在本章结束时,我们将把面部跟踪和矩形操作集成到我们在前几章中开发的交互式应用Cameo中。 最后,我们将进行一些面对面的互动!

技术要求

本章使用 Python,OpenCV 和 NumPy。 作为 OpenCV 的一部分,它使用可选的opencv_contrib模块,其中包括用于人脸识别的功能。 本章的某些部分使用 OpenCV 对 OpenNI 2 的可选支持来捕获深度相机的图像。 请参考第 1 章,“设置 OpenCV”,以获取安装说明。

可以在本书的 GitHub 存储库的chapter05文件夹中找到本章的完整代码。 样本图像位于images文件夹中的存储库中。

概念化级联

当我们谈论对对象进行分类并跟踪它们的位置时,我们究竟要精确指出什么? 什么构成对象的可识别部分?

即使来自网络摄像头的摄影图像也可能包含许多细节,以使我们(人类)观看愉悦。 然而,关于照明,视角,观看距离,相机抖动和数字噪声的变化,图像细节趋于不稳定。 而且,即使是物理细节上的实际差异也可能使我们对分类不感兴趣。 本书的作者之一约瑟夫·霍斯(Joseph Howse)在学校里被教过,在显微镜下没有两朵雪花看起来很像。 幸运的是,作为一个加拿大孩子,他已经学会了如何在没有显微镜的情况下识别雪花,因为它们之间的相似性更加明显。

因此,一些抽象图像细节的方法可用于产生稳定的分类和跟踪结果。 这些抽象称为特征,据说是从图像数据中提取的。 特征应该比像素少得多,尽管任何像素都可能影响多个特征。 一组特征被表示为一个向量,并且可以基于图像的相应特征向量之间距离的某种度量来评估两个图像之间的相似度。

类 Haar 的特征是通常应用于实时人脸检测的一种特征。 Paul Viola 和 Michael Jones 在《鲁棒的实时人脸检测》中首次将它们用于此目的。 可在这个页面上获得本文的电子版本。 每个类似 Haar 的特征都描述了相邻图像区域之间的对比度模式。 例如,边,顶点和细线各自生成一种特征。 从某种意义上说,某些特征是有区别的,它们通常出现在特定类别的对象(例如面部)中,而不出现在其他对象中。 这些独特的特征可以组织为称为级联的层次结构,其中最高层包含具有最大独特性的特征,使分类器可以快速拒绝缺少这些特征的主题。

对于任何给定的对象,特征可能会根据图像的比例和评估对比度的邻域大小而有所不同。 后者称为窗口大小。 为了使 Haar 级联分类器不变标度,或者说要对缩放变化具有鲁棒性,窗口大小保持不变,但是图像会被多次缩放。 因此,在某种程度上进行缩放时,对象(例如面部)的大小可能与窗口大小匹配。 原始图像和重新缩放的图像一起被称为图像金字塔,并且此金字塔中的每个连续级别都是较小的重新缩放图像。 OpenCV 提供了一个尺度不变的分类器,该分类器可以从 XML 文件以特定格式加载 Haar 级联。 在内部,此分类器将任何给定图像转换为图像金字塔。

在 OpenCV 中实现的 Haar 级联对旋转或透视图的更改不可靠。 例如,上下颠倒的脸部不被视为与直立的脸部相似,并且轮廓上观看的脸部不被视为与从正面观看的脸部相似。 考虑到图像的多种转换以及多种窗口大小,更复杂,更耗费资源的实现可以提高 Haar 级联的旋转鲁棒性。 但是,我们将局限于 OpenCV 中的实现。

获取 HAAR 级联

OpenCV 4 源代码或您安装的 OpenCV 4 的预打包版本应包含一个名为data/haarcascades的子文件夹。 如果找不到它,请参考第 1 章,“设置 OpenCV”,以获取获取 OpenCV 4 源代码的说明。

data/haarcascades文件夹包含 XML 文件,可以通过名为cv2.CascadeClassifier的 OpenCV 类加载该文件。 此类的实例将给定的 XML 文件解释为 Haar 级联,Haar 级联提供了针对某种类型的对象(例如面部)的检测模型。 cv2.CascadeClassifier可以在任何图像中检测到此类物体。 像往常一样,我们可以从文件中获取静止图像,也可以从视频文件或摄像机中获取一系列帧。

找到data/haarcascades后,在项目的其他位置创建一个目录; 在此文件夹中,创建一个名为cascades的子文件夹,并将以下文件从data/haarcascades复制到cascades

  • haarcascade_frontalface_default.xml
  • haarcascade_frontalface_alt.xml
  • haarcascade_eye.xml

顾名思义,这些级联用于跟踪面部和眼睛。 他们需要正面,正面查看主题。 稍后在构建人脸检测器时将使用它们。

如果您对如何生成这些级联文件感到好奇,可以在 Joseph Howse 的书《写给秘密特工的 OpenCV 4》 (Packt Publishing,2019 年)中找到更多信息,特别是在第 3 章,“训练智能警报以识别反派和他的猫”中。 有了足够的耐心和一台功能强大的计算机,您可以制作自己的层叠并为各种类型的物体训练它们。

使用 OpenCV 执行人脸检测

使用cv2.CascadeClassifier,无论是对静止图像还是视频源执行人脸检测,都没有什么区别。 后者只是前者的顺序版本:视频上的人脸检测只是应用于每个帧的人脸检测。 自然地,利用更先进的技术,可以跨多个帧连续跟踪检测到的面部,并确定每一帧中的面部是相同的。 但是,很高兴知道基本的顺序方法也可以工作。

让我们继续前进,发现一些面孔。

在静止图像上执行人脸检测

执行人脸检测的第一个也是最基本的方法是加载图像并检测其中的面部。 为了使结果在视觉上有意义,我们将在原始图像中的脸部周围绘制矩形。 记住脸部检测器是为直立的正面脸而设计的,我们将使用一排人的图像,特别是伐木机,肩并肩站立并面向观察者。

将 Haar 级联 XML 文件复制到我们的级联文件夹中后,我们继续创建以下基本脚本来执行人脸检测:

import cv2

face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')
img = cv2.imread('img/woodcutters.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.08, 5)
for (x, y, w, h) in faces:
    img = cv2.rectangle(img, (x, y), (x+w, y+h), (255, 0, 0), 2)

cv2.namedWindow('Woodcutters Detected!')
cv2.imshow('Woodcutters Detected!', img)
cv2.imwrite('./woodcutters_detected.jpg', img)
cv2.waitKey(0)

让我们逐步介绍一下前面的代码。 首先,我们使用在本书的每个脚本中都必须使用的cv2导入。 然后,我们声明一个face_cascade变量,这是一个CascadeClassifier对象,该对象加载用于人脸检测的级联:

face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')

然后,我们用cv2.imread加载图像文件并将其转换为灰度,因为CascadeClassifier需要灰度图像。 下一步face_cascade.detectMultiScale是我们执行实际人脸检测的位置:

img = cv2.imread('img/woodcutters.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.08, 5)

detectMultiScale的参数包括scaleFactorminNeighborsscaleFactor参数应大于 1.0,它确定在人脸检测过程的每次迭代时图像的缩小比例。 正如我们先前在“概念化 Haar 级联”部分中所讨论的那样,这种缩小旨在通过将各种面与窗口大小进行匹配来实现缩放不变性。 minNeighbors自变量是为了保留检测结果而需要的最小重叠检测数。 通常,我们希望可以在多个重叠的窗口中检测到人脸,并且大量的重叠检测使我们更加有信心检测到的人脸是真正的人脸。

从检测操作返回的值是代表脸部矩形的元组列表。 OpenCV 的cv2.rectangle函数允许我们在指定的坐标处绘制矩形。 xy代表左坐标和顶部坐标,而wh代表面部矩形的宽度和高度。 通过遍历faces变量,我们在找到的所有面孔周围绘制蓝​​色矩形,确保使用原始图像进行绘制,而不使用灰色版本:

for (x, y, w, h) in faces:
    img = cv2.rectangle(img, (x, y), (x+w, y+h), (255, 0, 0), 2)

最后,我们调用cv2.imshow显示生成的处理后图像。 与往常一样,为防止图像窗口自动关闭,我们向waitKey插入了一个调用,当用户按下任意键时该调用返回:

cv2.imshow('Woodcutters Detected!', img)
cv2.imwrite('./woodcutters_detected.jpg', img)
cv2.waitKey(0)

到了这里,在我们的图像中检测到整个伐木工,如以下屏幕截图所示:

本例中的照片是彩色摄影的先驱 Sergey Prokudin-Gorsky(1863-1944)的作品。 沙皇尼古拉斯二世赞助普罗库丁·戈尔斯基(Prokudin-Gorsky),拍摄整个俄罗斯帝国的人物和地点,这是一个庞大的纪录片项目。 普罗库丁·高斯基(Prokudin-Gorsky)于 1909 年在俄罗斯西北部的维尔河(Svir River)附*拍摄了这些伐木工的照片。

对视频执行人脸检测

现在,我们了解了如何在静止图像上执行人脸检测。 如前所述,我们可以在视频的每一帧(无论是摄像机供稿还是预先录制的视频文件)上重复进行人脸检测的过程。

下一个脚本将打开一个照相机供稿,读取一个框架,检查该框架中是否有面部,并扫描检测到的面部中的眼睛。 最后,它将在面部周围绘制蓝​​色矩形,在眼睛周围绘制绿色矩形。 这是完整的脚本:

import cv2

face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_eye.xml')

camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
    success, frame = camera.read()
    if success:
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        faces = face_cascade.detectMultiScale(
            gray, 1.3, 5, minSize=(120, 120))
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
            roi_gray = gray[y:y+h, x:x+w]
            eyes = eye_cascade.detectMultiScale(
                roi_gray, 1.03, 5, minSize=(40, 40))
            for (ex, ey, ew, eh) in eyes:
                cv2.rectangle(frame, (x+ex, y+ey),
                              (x+ex+ew, y+ey+eh), (0, 255, 0), 2)
        cv2.imshow('Face Detection', frame)

让我们将前面的示例分解成较小的,可消化的块:

  1. 和往常一样,我们导入cv2模块。 之后,我们初始化两个CascadeClassifier对象,一个用于面部,另一个用于眼睛:
face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_eye.xml')
  1. 与大多数交互式脚本一样,我们打开相机供稿并开始遍历帧。 我们继续操作,直到用户按任意键。 每当我们成功捕获帧时,我们都会将其转换为灰度,这是处理它的第一步:
camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
    success, frame = camera.read()
    if success:
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  1. 我们使用人脸检测器的detectMultiScale方法检测面部。 正如我们之前所做的,我们使用scaleFactorminNeighbors参数。 我们还使用minSize参数指定人脸的最小尺寸,特别是120x120。 不会尝试检测小于此尺寸的脸部。 (假设我们的用户坐在相机旁边,可以肯定地说用户的脸将大于120x120像素。)这是detectMultiScale的调用:
faces = face_cascade.detectMultiScale(
    gray, 1.3, 5, minSize=(120, 120))
  1. 我们遍历检测到的面部的矩形。 我们在原始彩色图像的每个矩形周围绘制一个蓝色边框。 然后,在灰度图像的相同矩形区域内,执行眼睛检测:
for (x, y, w, h) in faces:
    cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
    roi_gray = gray[y:y+h, x:x+w]
    eyes = eye_cascade.detectMultiScale(
        roi_gray, 1.1, 5, minSize=(40, 40))

眼睛检测器的准确率不如人脸检测器。 您可能会看到阴影,眼镜框的一部分或面部其他部分被错误地检测为眼睛。 为了改善效果,您可以尝试将roi_gray定义为面部的较小区域,因为我们可以很好地猜测眼睛在直立的面部中的位置。 您也可以尝试使用maxSize参数来避免过大而不会引起人注意的误报。 另外,您可以调整minSizemaxSize的尺寸,使其与检测到的脸部尺寸wh成比例。 作为练习,可以随时尝试更改这些参数和其他参数。

  1. 我们遍历生成的眼睛矩形,并在它们周围绘制绿色轮廓:
for (ex, ey, ew, eh) in eyes:
    cv2.rectangle(frame, (x+ex, y+ey),
                  (x+ex+ew, y+ey+eh), (0, 255, 0), 2)
  1. 最后,我们在窗口中显示结果帧:
cv2.imshow('Face Detection', frame)

运行脚本。 如果我们的检测器产生准确的结果,并且在摄像头的视野内有任何人脸,您应该在该人脸周围看到一个蓝色矩形,在每只眼睛周围看到一个绿色矩形,如以下屏幕截图所示:

使用此脚本进行试验,以了解面部和眼睛检测器在各种条件下的表现。 尝试在更亮或更暗的房间里。 如果您戴眼镜,请尝试摘下它们。 尝试各种人的面孔和各种表情。 在脚本中调整检测参数,以查看它们如何影响结果。 当您感到满意时,让我们考虑一下我们还能在 OpenCV 中使用面孔做什么。

执行人脸识别

人脸检测是 OpenCV 的一项奇妙功能,它构成了更高级操作的基础:人脸识别。 什么是人脸识别? 给定包含人脸的图像或视频源,程序可以识别该人。 实现此目的的一种方法(以及 OpenCV 所采用的方法)是通过向程序提供一组分类图片(面部数据库)来训练程序,并根据这些图片的特征进行识别。

OpenCV 的人脸识别模块的另一个重要功能是每个识别都有一个置信度分数,这使我们可以在现实应用中设置阈值以限制错误识别的发生率。

让我们从头开始。 要进行人脸识别,我们需要人脸识别。 我们可以通过两种方式做到这一点:自己提供图像或获得免费的人脸数据库。 可以在这个页面上在线获取大量的人脸数据库。 以下是目录中的一些著名示例:

要在这些样本上执行人脸识别,我们将不得不在包含一个被采样人的脸部图像的图像上进行人脸识别。 这个过程可能具有教育意义,但可能不如提供我们自己的图像那样令人满意。 您可能有很多计算机视觉学习者都曾有过这样的想法:我想知道我是否可以编写一个程序来以某种程度的自信识别我的脸。

生成用于人脸识别的数据

让我们继续写一个脚本,它将为我们生成这些图像。 我们只需要几张包含不同表情的图像,但最好训练图像是正方形的且尺寸均相同。 我们的示例脚本使用200x200的大小,但是大多数免费提供的数据集的图像都小于此。

这是脚本本身:

import cv2
import os

output_folder = '../data/at/jm'
if not os.path.exists(output_folder):
    os.makedirs(output_folder)

face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_eye.xml')

camera = cv2.VideoCapture(0)
count = 0
while (cv2.waitKey(1) == -1):
    success, frame = camera.read()
    if success:
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        faces = face_cascade.detectMultiScale(
            gray, 1.3, 5, minSize=(120, 120))
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
            face_img = cv2.resize(gray[y:y+h, x:x+w], (200, 200))
            face_filename = '%s/%d.pgm' % (output_folder, count)
            cv2.imwrite(face_filename, face_img)
            count += 1
        cv2.imshow('Capturing Faces...', frame)

在这里,我们基于对视频源中如何检测人脸的新知识来生成样本图像。 我们正在检测一张脸,裁剪经过灰度转换的帧的该区域,将其大小调整为200x200像素,并将其保存为 PGM 文件,并在特定文件夹中命名(在本例中为jm,这是作者的首字母缩写;您可以使用自己的首字母缩写)。 与我们的许多窗口应用一样,该应用将一直运行到用户按下任意键为止。

存在count变量是因为我们需要图像的渐进名称。 运行脚本几秒钟,更改面部表情几次,然后检查在脚本中指定的目标文件夹。 您会发现许多面部图像,这些图像变灰,调整大小并以<count>.pgm格式命名。

修改output_folder变量,使其与您的名字匹配。 例如,您可以选择'../data/at/my_name'。 运行脚本,等待它以多个帧(例如 20 个或更多)检测到您的脸,然后按任意键退出。 现在,再次修改output_folder变量,使其与您也想识别的朋友的名字匹配。 例如,您可以选择'../data/at/name_of_my_friend'。 不要更改文件夹的基本部分(在本例中为'../data/at'),因为稍后,在“加载用于人脸识别的训练数据”部分中,我们将编写代码以从此基本文件夹的子文件夹的所有位置加载训练图像。 让您的朋友坐在镜头前,再次运行脚本,让脚本在多个帧中检测到您朋友的脸,然后退出。 对您可能想要认识的其他任何人重复此过程。

现在,让我们继续尝试识别视频供稿中的用户面部。 这应该是有趣的!

识别人脸

OpenCV 4 实现了三种不同的算法来识别人脸:EigenFace,Fisherfaces 和本地二进制模式直方图LBPH)。 EigenFace 和 Fisherfaces 是从称为主成分分析PCA)的通用算法衍生而来的。 有关算法的详细说明,请参考以下链接:

  • PCA:Jonathon Shlens 的直观介绍可从这个页面获得。 该算法由卡尔·皮尔森(Karl Pearson)于 1901 年发明,《最接*空间点系统的直线和*面》的原始论文可在这个页面上找到。
  • EigenFace:Matthew Turk 和 Alex Pentland 撰写的论文《用于识别的 EigenFace》(1991),可从这个页面
  • Fisherfaces:RA Fisher 撰写的开创性论文《在分类问题中使用多重度量》(1936),可从这个页面得到。
  • 局部二进制模式:描述此算法的第一篇论文是《纹理度量的表现评估,基于基于分布的 Kullback 判别的分类》(1994),作者 T. Ojala,M. Pietikainen 和 D. 哈伍德。 可在这个页面上获得。

出于本书的目的,我们仅对算法进行高级概述。 首先,它们都遵循相似的过程。 他们进行一系列分类观察(我们的面部数据库,每个人包含许多样本),基于该模型训练模型,对面部图像(可能是我们在图像或视频中检测到的面部区域)进行分析,并确定两件事:受试者的身份,以及对这种识别正确性的信心度量。 后者通常称为置信度分数

Eigenfaces 执行 PCA,该 PCA 识别一组特定观察值(同样是您的面部数据库)的主要成分,计算当前观察值(在图像或帧中检测到的面部)与数据集的差异,并产生一个值。 值越小,面部数据库与检测到的面部之间的差异越小; 因此,值 0 是完全匹配。

Fisherfaces 也源自 PCA,并应用更复杂的逻辑对概念进行了改进。 尽管计算量更大,但与 Eigenfaces 相比,它倾向于产生更准确的结果。

LBPH 相反将检测到的脸部分成小单元,并针对每个单元建立直方图,该直方图描述了在给定方向上比较相邻像素时图像的亮度是否正在增加。 可以将该单元格的直方图与模型中相应单元格的直方图进行比较,以衡量相似度。 在 OpenCV 中的人脸识别器中,LBPH 的实现是唯一一种允许模型样本人脸和检测到的人脸具有不同形状和大小的实现。 因此,这是一个方便的选择,这本书的作者发现它的准确率优于其他两个选择。

加载训练数据以进行人脸识别

无论选择哪种人脸识别算法,我们都可以以相同的方式加载训练图像。 之前,在“生成用于人脸识别的数据”部分中,我们生成了训练图像并将其保存在根据人们的姓名或名字缩写组织的文件夹中。 例如,以下文件夹结构可能包含本书作者 Joseph Howse(J. H.)和 Joe Minichino(J. M.)的样本面部图像:

../
  data/
    at/
      jh/
      jm/

让我们编写一个脚本来加载这些图像并以 OpenCV 的人脸识别器可以理解的方式对其进行标记。 为了处理文件系统和数据,我们将使用 Python 标准库的os模块以及cv2numpy模块。 让我们创建一个以以下import语句开头的脚本:

import os

import cv2
import numpy

让我们添加以下read_images函数,该函数遍历目录的子目录,加载图像,将其调整为指定的大小,然后将调整后的图像放入列表中。 同时,它还建立了另外两个列表:第一,一个人名或首字母的列表(基于子文件夹的名称),第二,一个与加载的图像相关联的标签或数字 ID 的列表。 例如,jh可以是名称,0可以是从jh子文件夹加载的所有图像的标签。 最后,该函数将图像和标签的列表转换为 NumPy 数组,并返回三个变量:名称列表,图像的 NumPy 数组和标签的 NumPy 数组。 这是函数的实现:

def read_images(path, image_size):
    names = []
    training_images, training_labels = [], []
    label = 0
    for dirname, subdirnames, filenames in os.walk(path):
        for subdirname in subdirnames:
            names.append(subdirname)
            subject_path = os.path.join(dirname, subdirname)
            for filename in os.listdir(subject_path):
                img = cv2.imread(os.path.join(subject_path, filename),
                                 cv2.IMREAD_GRAYSCALE)
                if img is None:
                    # The file cannot be loaded as an image.
                    # Skip it.
                    continue
                img = cv2.resize(img, image_size)
                training_images.append(img)
                training_labels.append(label)
            label += 1
    training_images = numpy.asarray(training_images, numpy.uint8)
    training_labels = numpy.asarray(training_labels, numpy.int32)
    return names, training_images, training_labels

让我们通过添加如下代码来调用read_images函数:

path_to_training_images = '../data/at'
training_image_size = (200, 200)
names, training_images, training_labels = read_images(
    path_to_training_images, training_image_size)

在前面的代码块中编辑path_to_training_images变量,以确保它与您先前在“生成用于人脸识别数据”的代码部分中定义的output_folder变量的基本文件夹匹配。

到目前为止,我们已经以有用的格式获得了训练数据,但是我们还没有创建人脸识别器或进行任何训练。 我们将在下一节中继续执行相同的脚本。

用 EigenFace 执行人脸识别

现在我们有了一个训练图像数组和它们的标签数组,我们可以仅用两行代码来创建和训练人脸识别器:

model = cv2.face.EigenFaceRecognizer_create()
model.train(training_images, training_labels)

我们在这里做了什么? 我们使用 OpenCV 的cv2.EigenFaceRecognizer_create函数创建了 Eigenfaces 人脸识别器,并通过传递图像和标签(数字 ID)数组来训练识别器。 (可选)我们可以将两个参数传递给cv2.EigenFaceRecognizer_create

  • num_components:这是 PCA 保留的组件数。
  • threshold:这是一个指定置信度阈值的浮点值。 置信度得分低于阈值的面孔将被丢弃。 默认情况下,阈值为最大浮点值,因此不会丢弃任何面。

为了测试此识别器,让我们使用人脸检测器和来自摄像机的视频。 正如我们在先前脚本中所做的那样,我们可以使用以下代码行初始化人脸检测器:

face_cascade = cv2.CascadeClassifier(
    './cascades/haarcascade_frontalface_default.xml')

以下代码初始化摄像头馈送,遍历帧(直到用户按任意键),并在每个帧上执行人脸检测和识别:

camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
    success, frame = camera.read()
    if success:
        faces = face_cascade.detectMultiScale(frame, 1.3, 5)
        for (x, y, w, h) in faces:
            cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
            gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
            roi_gray = gray[x:x+w, y:y+h]
            if roi_gray.size == 0:
                # The ROI is empty. Maybe the face is at the image edge.
                # Skip it.
                continue
            roi_gray = cv2.resize(roi_gray, training_image_size)
            label, confidence = model.predict(roi_gray)
            text = '%s, confidence=%.2f' % (names[label], confidence)
            cv2.putText(frame, text, (x, y - 20),
                        cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
        cv2.imshow('Face Recognition', frame)

让我们来看一下前面的代码块中最重要的功能。 对于每个检测到的脸部,我们都会对其进行转换并调整其大小,以便获得与预期大小相匹配的灰度版本(在这种情况下,如上一节“人脸识别”中的training_image_size变量所定义,为200x200像素)。 然后,将经过调整大小的灰度面部传递给人脸识别器的predict函数。 这将返回标签和置信度分数。 我们查找与该面孔的数字标签相对应的人名。 (请记住,我们在上一节“加载用于人脸识别的训练数据”中加载了names数组。)我们在识别出的面部上方用蓝色文本绘制名称和置信度得分。 遍历所有检测到的面部之后,我们显示带标注的图像。

我们采用了一种简单的人脸检测和识别方法,其目的是使您能够运行基本应用并了解 OpenCV 4 中的人脸识别过程。 采取其他步骤,例如正确对齐和旋转检测到的面部,以使识别的准确率最大化。

运行脚本时,应该看到类似于以下屏幕截图的内容:

接下来,让我们考虑如何调整这些脚本,以用另一种人脸识别算法替换 Eigenfaces。

用 Fisherfaces 执行人脸识别

那 Fisherfaces 呢? 该过程变化不大; 我们只需要实例化其他算法即可。 使用默认参数,我们的model变量的声明如下所示:

model = cv2.face.FisherFaceRecognizer_create() 

cv2.face.FisherFaceRecognizer_createcv2.createEigenFaceRecognizer_create带有两个相同的可选参数:要保留的主要成分数和置信度阈值。

用 LBPH 执行人脸识别

最后,让我们快速看一下 LBPH 算法。 同样,该过程是相似的。 但是,算法工厂采用以下可选参数(按顺序):

  • radius:用于计算像元直方图的相邻像素之间的像素距离(默认为 1)
  • neighbors:用于计算单元格直方图的邻居数(默认为 8)
  • grid_x:将脸部水*划分为的像元数(默认为 8 个)
  • grid_y:脸部垂直划分的像元数(默认为 8)
  • confidence:置信度阈值(默认情况下,为最大可能的浮点值,因此不会丢弃任何结果)

使用默认参数,模型声明将如下所示:

  model = cv2.face.LBPHFaceRecognizer_create() 

请注意,使用 LBPH,我们无需调整图像大小,因为将其划分为网格可以比较每个单元格中识别出的模式。

根据置信度分数丢弃结果

predict方法返回一个元组,其中第一个元素是识别的个人的标签,第二个元素是置信度得分。 所有算法都带有设置置信度得分阈值的选项,该阈值可测量识别出的人脸与原始模型的距离,因此,得分 0 表示完全匹配。

在某些情况下,您宁愿保留所有识别然后进行进一步处理,因此可以提出自己的算法来估计识别的置信度得分。 例如,如果您试图识别视频中的人物,则可能需要分析后续帧中的置信度得分,以确定识别是否成功。 在这种情况下,您可以检查算法获得的置信度得分并得出自己的结论。

置信度分数的典型范围取决于算法。 EigenFace 和 Fisherfaces 产生的值(大约)在 0 到 20,000 之间,任何低于 4,000-5,000 的分数都是很自信的认可。 对于 LBPH,良好识别的参考值低于 50,任何高于 80 的值都被认为是较差的置信度得分。

通常的自定义方法是推迟在已识别的面部周围绘制矩形,直到我们获得多个具有令人满意的任意置信度得分的帧为止,但是您完全可以使用 OpenCV 的人脸识别模块来根据需要定制应用。

在红外线中交换人脸

人脸检测和识别不限于可见光谱。 使用*红外NIR)相机和 NIR 光源,即使场景在人眼看来完全黑暗的情况下,也可以进行人脸检测和识别。 此功能在安全和监视应用中非常有用。

在第 4 章,“深度估计和分割”中,我们研究了 NIR 深度相机(如 Asus Xtion PRO)的基本用法。 我们扩展了交互式应用Cameo的面向对象代码。 我们从深度相机捕获了帧。 基于深度,我们将每个帧分为一个主要层(例如用户的面部)和其他层。 我们将其他层涂成黑色。 这样就达到了隐藏背景的效果,从而只有主层(用户的脸部)才出现在交互式视频源中的屏幕上。

现在,让我们修改Cameo,以执行我们以前在深度分割方面的技能和我们在人脸检测方面的新技能。 让我们检测一下脸,然后,当我们在一帧中检测到至少两个脸时,让我们交换这些脸,以使一个人的头部出现在另一个人的身体上方。 除了复制在检测到的面部矩形中的所有像素外,我们将仅复制该矩形的主要深度层中的像素。 这应该获得交换面孔的效果,但不能交换面孔周围的背景像素。

更改完成后,Cameo将能够产生输出,例如以下屏幕截图:

在这里,我们看到约瑟夫·豪斯(Joseph Howse)的脸与母亲珍妮特·霍斯(Janet Howse)的脸互换了。 尽管Cameo从矩形区域复制像素(并且在交换区域的底部清晰可见,在前景中很明显),但是某些背景像素没有交换,因此我们在各处都看不到矩形边缘。

您可以在这个页面的本书存储库中找到对Cameo源代码的所有相关更改。 ],特别是在chapter05/cameo文件夹中。 为简洁起见,我们不会在本书中讨论所有更改,但将在接下来的两个小节中介绍一些重点,“修改应用的循环”和“屏蔽复制操作”。

修改应用的循环

为了支持人脸交换,Cameo项目有两个名为rectstrackers的新模块。 rects模块包含用于复制和交换矩形的功能,以及一个可选的掩码,用于将复制或交换操作限制为特定的像素。 trackers模块包含一个名为FaceTracker的类,该类使 OpenCV 的人脸检测功能适应于面向对象的编程风格。

由于我们在本章前面已经介绍了 OpenCV 的人脸检测功能,并且在前面的章节中已经展示了一种面向对象的编程风格,因此在此不再介绍FaceTracker实现。 相反,您可以在本书的资料库中查看它。

让我们打开cameo.py,以便我们逐步了解应用的整体变化:

  1. 在文件顶部附*,我们需要导入新模块,如以下代码块中的粗体所示:
import cv2
import depth
import filters
from managers import WindowManager, CaptureManager
import rects
from trackers import FaceTracker
  1. 现在,我们将注意力转移到CameoDepth类的__init__方法中。 我们更新的应用使用FaceTracker的实例。 作为其功能的一部分,FaceTracker可以在检测到的面部周围绘制矩形。 让我们为Cameo的用户提供启用或禁用面部矩形绘制的选项。 我们将通过布尔变量跟踪当前选择的选项。 以下代码块(以粗体)显示了初始化FaceTracker对象和布尔变量所需的更改:
class CameoDepth(Cameo):

    def __init__(self):
        self._windowManager = WindowManager('Cameo',
                                            self.onKeypress)
        #device = cv2.CAP_OPENNI2 # uncomment for Kinect
        device = cv2.CAP_OPENNI2_ASUS # uncomment for Xtion 
        self._captureManager = CaptureManager(
            cv2.VideoCapture(device), self._windowManager, True)
 self._faceTracker = FaceTracker()
 self._shouldDrawDebugRects = False
        self._curveFilter = filters.BGRPortraCurveFilter()

我们在CameoDepthrun方法中使用FaceTracker对象,该方法包含捕获和处理帧的应用主循环。 每次成功捕获帧时,我们都会调用FaceTracker方法来更新人脸检测结果并获取最新检测到的面部。 然后,针对每张脸,我们根据深度相机的视差图创建一个遮罩。 (以前,在第 4 章,“深度估计和分段”中,我们为整个图像创建了这样一个遮罩,而不是为每个脸部矩形创建了遮罩。)然后,我们调用一个函数, rects.swapRects,以执行遮罩矩形的遮罩交换。 (稍后,我们将在“屏蔽复制操作”部分中查看swapRects的实现。)

  1. 根据当前选择的选项,我们可能会告诉FaceTracker在面周围绘制矩形。 所有相关更改在以下代码块的粗体中显示:
    def run(self):
        """Run the main loop."""
        self._windowManager.createWindow()
        while self._windowManager.isWindowCreated:
            # ... The logic for capturing a frame is unchanged ...

            if frame is not None:
 self._faceTracker.update(frame)
 faces = self._faceTracker.faces
 masks = [
 depth.createMedianMask(
 disparityMap, validDepthMask, 
                        face.faceRect) \
 for face in faces
 ]
 rects.swapRects(frame, frame,
 [face.faceRect for face in faces], 
                                masks)

                if self._captureManager.channel == cv2.CAP_OPENNI_BGR_IMAGE:
                    # A BGR frame was captured.
                    # Apply filters to it.
                    filters.strokeEdges(frame, frame)
                    self._curveFilter.apply(frame, frame)

 if self._shouldDrawDebugRects:
 self._faceTracker.drawDebugRects(frame)

            self._captureManager.exitFrame()
            self._windowManager.processEvents()
  1. 最后,让我们修改onKeypress方法,以便用户可以按X键开始或停止在检测到的脸部周围显示矩形。 同样,相关更改在以下代码块中以粗体显示:
    def onKeypress(self, keycode):
        """Handle a keypress.

        space -> Take a screenshot.
        tab -> Start/stop recording a screencast.
 x -> Start/stop drawing debug rectangles around faces.
        escape -> Quit.

        """
        if keycode == 32: # space
            self._captureManager.writeImage('screenshot.png')
        elif keycode == 9: # tab
            if not self._captureManager.isWritingVideo:
                self._captureManager.startWritingVideo(
                    'screencast.avi')
            else:
                self._captureManager.stopWritingVideo()
 elif keycode == 120: # x
 self._shouldDrawDebugRects = \
 not self._shouldDrawDebugRects
        elif keycode == 27: # escape
            self._windowManager.destroyWindow()

接下来,让我们看一下我们在本节前面导入的rects模块的实现。

遮罩复制操作

rects模块在rects.py中实现。 在上一节中,我们已经看到了对rects.swapRects函数的调用。 但是,在考虑实现swapRects之前,我们首先需要一个更基本的copyRect函数。

早在第 2 章,“处理文件,照相机和 GUI”时,我们就学习了如何从一个矩形兴趣区域ROI)复制数据,使用 NumPy 的切片语法。 在 ROI 之外,源图像和目标图像不受影响。 现在,我们想对该复制操作应用更多限制。 我们要使用与源矩形具有相同尺寸的给定遮罩。

我们将仅复制源矩形中掩码值不为零的那些像素。 其他像素应保留目标图像中的旧值。 具有条件数组和两个可能的输出值数组的逻辑可以使用numpy.where函数简明表示。

考虑到这种方法,让我们考虑一下copyRect函数。 作为参数,它需要一个源和目标图像,一个源和目标矩形以及一个遮罩。 后者可能是None,在这种情况下,我们只需调整源矩形的内容大小以匹配目标矩形,然后将生成的调整大小的内容分配给目标矩形。 否则,我们接下来要确保遮罩和图像具有相同数量的通道。 我们假设遮罩具有一个通道,但是图像可能具有三个通道(BGR)。 我们可以使用numpy.arrayrepeatreshape方法添加重复通道以进行遮罩。 最后,我们使用numpy.where执行复制操作。 完整的实现如下:

def copyRect(src, dst, srcRect, dstRect, mask = None,
             interpolation = cv2.INTER_LINEAR):
    """Copy part of the source to part of the destination."""

    x0, y0, w0, h0 = srcRect
    x1, y1, w1, h1 = dstRect

    # Resize the contents of the source sub-rectangle.
    # Put the result in the destination sub-rectangle.
    if mask is None:
        dst[y1:y1+h1, x1:x1+w1] = \
            cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
                       interpolation = interpolation)
    else:
        if not utils.isGray(src):
            # Convert the mask to 3 channels, like the image.
            mask = mask.repeat(3).reshape(h0, w0, 3)
        # Perform the copy, with the mask applied.
        dst[y1:y1+h1, x1:x1+w1] = \
            numpy.where(cv2.resize(mask, (w1, h1),
                                   interpolation = \
                                   cv2.INTER_NEAREST),
                        cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
                                   interpolation = interpolation),
                        dst[y1:y1+h1, x1:x1+w1])

我们还需要定义一个swapRects函数,该函数使用copyRect执行矩形区域列表的循环交换。 swapRects有一个masks参数,这是一组掩码的列表,其元素传递到相应的copyRect调用。 如果masks参数的值为None,则将None传递给每个copyRect调用。 以下代码显示swapRects的完整实现:

def swapRects(src, dst, rects, masks = None,
              interpolation = cv2.INTER_LINEAR):
    """Copy the source with two or more sub-rectangles swapped."""

    if dst is not src:
        dst[:] = src

    numRects = len(rects)
    if numRects < 2:
        return

    if masks is None:
        masks = [None] * numRects

    # Copy the contents of the last rectangle into temporary storage.
    x, y, w, h = rects[numRects - 1]
    temp = src[y:y+h, x:x+w].copy()

    # Copy the contents of each rectangle into the next.
    i = numRects - 2
    while i >= 0:
        copyRect(src, dst, rects[i], rects[i+1], masks[i],
                 interpolation)
        i -= 1

    # Copy the temporarily stored content into the first rectangle.
    copyRect(temp, dst, (0, 0, w, h), rects[0], masks[numRects - 1],
             interpolation)

请注意,copyRect中的mask参数和swapRects中的masks参数都具有默认值None。 如果未指定掩码,则这些函数将复制或交换矩形的全部内容。

总结

到目前为止,您应该已经对人脸检测和人脸识别如何工作以及如何在 Python 和 OpenCV 4 中实现它们有了很好的了解。

脸部检测和脸部识别是计算机视觉不断发展的分支,算法也在不断发展,随着对机器人技术和物联网IoT)。

目前,检测和识别算法的准确率在很大程度上取决于训练数据的质量,因此请确保为您的应用提供涵盖各种表情,姿势和光照条件的大量训练图像。

作为人类,我们可能倾向于认为人的脸特别容易辨认。 我们甚至可能对自己的人脸识别能力过于自信。 但是,在计算机视觉中,人脸没有什么特别之处,我们可以很容易地使用算法来查找和识别其他事物。 接下来,我们将在第 6 章,“检索图像并使用图像描述符进行搜索”中。

六、检索图像并将图像描述符用于搜索

与人眼和大脑相似,OpenCV 可以检测图像的主要特征并将其提取到所谓的图像描述符中。 然后可以将这些特征用作数据库,从而启用基于图像的搜索。 此外,我们可以使用关键点将图像拼接在一起并组成更大的图像。 (请考虑将许多图片组合在一起以形成 360° 全景图。)

本章将向您展示如何使用 OpenCV 检测图像的特征,并利用它们来匹配和搜索图像。 在本章中,我们将拍摄样本图像并检测其主要特征,然后尝试查找与样本图像匹配的另一幅图像的区域。 我们还将发现样本图像与另一幅图像的匹配区域之间的单应性或空间关系。

更具体地说,我们将介绍以下任务:

  • 使用以下任何一种算法检测关键点并提取关键点周围的局部描述符:Harris 角,SIFT,SURF 或 ORB
  • 使用暴力算法或 FLANN 算法匹配关键点
  • 使用 KNN 和比率测试过滤掉不良匹配
  • 查找两组匹配的关键点之间的单应性
  • 搜索一组图像以确定哪个包含与参考图像最匹配的图像

我们将通过构建概念验证法证应用来结束本章。 给定纹身的参考图像,我们将搜索一组人的图像,以找到具有匹配纹身的人。

技术要求

本章使用 Python,OpenCV 和 NumPy。 关于 OpenCV,我们使用可选的opencv_contrib模块,其中包括用于关键点检测和匹配的其他算法。 要启用 SIFT 和 SURF 算法(已获得专利,并非为商业用途免费提供),我们必须在 CMake 中为opencv_contrib模块配置OPENCV_ENABLE_NONFREE标志。 有关安装说明,请参阅第 1 章,“设置 OpenCV”。 另外,如果尚未安装 Matplotlib,请通过运行$ pip install matplotlib(或$ pip3 install matplotlib(取决于您的环境))进行安装。

本章的完整代码可以在本书的 GitHub 存储库的chapter06文件夹中找到。 样本图像可在images文件夹中找到。

了解特征检测和匹配的类型

许多算法可用于检测和描述特征,我们将在本节中探讨其中的几种。 OpenCV 中最常用的特征检测和描述符提取算法如下:

  • Harris:此算法对于检测角点很有用。
  • SIFT:此算法对于检测斑点很有用。
  • SURF:此算法可用于检测斑点。
  • FAST:此算法对于检测角点很有用。
  • BRIEF:此算法可用于检测斑点。
  • ORB:该算法代表定向 FAST 和旋转 BRIEF。 对于检测角点和斑点的组合很有用。

可以使用以下方法执行匹配功能:

  • 暴力匹配
  • 基于 FLANN 的匹配

然后可以使用单应性进行空间验证。

我们刚刚介绍了许多新的术语和算法。 现在,我们将介绍它们的基本定义。

定义特征

到底有什么特征? 为什么图像的特定区域可以分类为特征,而其他区域则不能分类为特征? 从广义上讲,特征是图像中唯一或易于识别的兴趣区域。 角落和纹理细节密度高的区域是好的特征,而重复很多的图案和低密度区域(例如蓝天)则不是。 边缘是很好的特征,因为它们倾向于划分图像的两个区域。 斑点(图像的区域与其周围区域也大不相同)也是一个有趣的特征。

大多数特征检测算法都围绕角,边和斑点的识别,其中一些算法还关注的概念,您可以将其概念化为细长对象的对称轴。 (例如,考虑识别图像中的道路。)

有些算法更擅长识别和提取某种类型的特征,因此了解您的输入图像很重要,这样您就可以利用 OpenCV 传送带中最好的工具。

使用哈里斯检测角点

让我们开始使用哈里斯角点检测算法查找角点。 我们将通过示例来实现。 如果您在本书之外继续学习 OpenCV,您会发现棋盘格是计算机视觉分析的常见主题,部分原因是棋盘格模式适合于多种类型的特征检测,部分原因是国际象棋是一种流行的消遣方式,特别是在俄罗斯,许多 OpenCV 开发人员居住的地方。

这是我们的棋盘和棋子的示例图像:

OpenCV 具有称为cv2.cornerHarris的便捷函数,该函数可检测图像中的角。 在下面的基本示例中,我们可以看到此函数在起作用:

import cv2

img = cv2.imread('img/chess_board.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
img[dst > 0.01 * dst.max()] = [0, 0, 255]
cv2.imshow('corners', img)
cv2.waitKey()

让我们分析一下代码。 在常规导入之后,我们加载棋盘图像并将其转换为灰度。 然后,我们调用cornerHarris函数:

dst = cv2.cornerHarris(gray, 2, 23, 0.04)

这里最重要的参数是第三个参数,它定义了 Sobel 算子的孔径或核大小。 Sobel 运算符通过测量邻域中像素值之间的水*和垂直差异来检测边缘,并使用核进行此操作。 cv2.cornerHarris函数使用 Sobel 运算符,其光圈由该参数定义。 用简单的英语来说,这些参数定义了敏感角检测的程度。 它必须在 3 到 31 之间,并且是一个奇数值。 在3值低(高度敏感)的情况下,棋盘黑色正方形中的所有对角线在接触正方形的边界时都会注册为角。 对于23较高(较不敏感)的值,将仅将每个正方形的角检测为角。

cv2.cornerHarris返回浮点格式的图像。 该图像中的每个值代表源图像中相应像素的分数。 中等或高分表示该像素可能是一个角。 相反,我们可以将得分最低的像素视为非角。 考虑以下行:

img[dst > 0.01 * dst.max()] = [0, 0, 255]

在这里,我们选择分数至少为最高分数的 1% 的像素,并在原始图像中将这些像素着色为红色。 结果如下:

大! 几乎所有检测到的角都标记为红色。 标记的点几乎包括棋盘正方形的所有角。

如果在cv2.cornerHarris中调整第二个参数,我们将看到较小的区域(对于较小的参数值)或较大的区域(对于较大的参数值)将被检测为角点。 此参数称为块大小。

检测 DoG 特征并提取 SIFT 描述符

先前使用cv2.cornerHarris的技术非常适合检测角点,并且由于角点就是角点而具有明显的优势。 即使旋转图像,也会检测到它们。 但是,如果我们将图像缩放为较小或较大的尺寸,则图像的某些部分可能会丢失甚至获得角点质量。

例如,在 F1 意大利大奖赛赛道图像中查看以下角点检测:

以下是使用同一图像的较小版本的角点检测结果:

您会注意到角落更加凝结了。 但是,即使我们获得了一些优势,我们也失去了一些优势! 特别是,让我们研究一下瓦里安特·阿斯卡里弯锥,它看起来像是从西北向东南一直延伸的那部分赛道尽头的弯弯曲曲。 在较大的图像版本中,双折弯的入口和顶点均被检测为角。 在较小的图像中,无法像这样检测到顶点。 如果我们进一步缩小图像,从某种程度上说,我们也将失去通往那个弯道的入口。

特征的丧失引发了一个问题。 我们需要一种无论图像大小如何都可以工作的算法。 输入比例不变特征变换SIFT)。 尽管这个名称听起来有些神秘,但现在我们知道我们要解决的问题,这实际上是有道理的。 我们需要一个函数(一个变换)来检测特征(一个特征变换),并且不会根据图像的缩放比例输出不同的结果(缩放不变的特征变换)。 请注意,SIFT 不会检测关键点(这是通过高斯差异DoG 来完成的);而是通过特征向量描述了围绕它们的区域。

对 DoG 的快速介绍是有序的。 之前,在第 3 章“使用 OpenCV 处理图像”中,我们讨论了低通过滤器和模糊操作,特别是cv2.GaussianBlur()函数。 DoG 是对同一图像应用不同的高斯过滤器的结果。 以前,我们将这种类型的技术应用于边缘检测,这里的想法是相同的。 DoG 操作的最终结果包含兴趣区域(关键点),然后将通过 SIFT 描述这些区域。

让我们看看下图中的 DoG 和 SIFT 的行为,图中充满了角落和特征:

在这里,瓦雷泽(Varese)的美丽全景(位于意大利伦巴第)作为计算机视觉的主题而声名 new 起。 这是生成此已处理图像的代码:

import cv2

img = cv2.imread('img/varese.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptors = sift.detectAndCompute(gray, None)

cv2.drawKeypoints(img, keypoints, img, (51, 163, 236),
                  cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

cv2.imshow('sift_keypoints', img)
cv2.waitKey()

在常规导入后,我们加载要处理的图像。 然后,我们将图像转换为灰度。 到目前为止,您可能已经收集到 OpenCV 中的许多方法都希望将灰度图像作为输入。 下一步是创建 SIFT 检测对象并计算灰度图像的特征和描述符:

sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptors = sift.detectAndCompute(gray, None)

在幕后,这些简单的代码行执行了一个复杂的过程。 我们创建一个cv2.SIFT对象,该对象使用 DoG 来检测关键点,然后为每个关键点的周围区域计算特征向量。 就像detectAndCompute方法的名称清楚地表明的那样,执行了两个主要操作:特征检测和描述符的计算。 该操作的返回值是一个元组,其中包含一个关键点列表和另一个关键点描述符的列表。

最后,我们通过使用cv2.drawKeypoints函数在其上绘制关键点,然后使用常规的cv2.imshow函数来显示该图像来处理该图像。 作为其参数之一,cv2.drawKeypoints函数接受一个标志,该标志指定我们想要的可视化类型。 在这里,我们指定cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINT以绘制每个关键点的比例和方向的可视化。

关键点剖析

每个关键点都是cv2.KeyPoint类的实例,该类具有以下属性:

  • pt(点)属性包含图像中关键点的xy坐标。
  • size属性指示特征的直径。
  • angle属性指示特征的方向,如先前处理的图像中的径向线所示。
  • response属性指示关键点的强度。 SIFT 将某些特征归类为比其他特征更强,并且response是您要检查以评估特征强度的属性。
  • octave属性指示图像金字塔中找到特征的层。 让我们简要回顾一下图像金字塔的概念,我们在“概念化 Haar 级联”部分的第 5 章,“检测和识别人脸”中进行了讨论。 SIFT 算法以与人脸检测算法相似的方式运行,因为它迭代地处理相同的图像,但是在每次迭代时都会更改输入。 特别地,图像的比例尺是在算法的每次迭代(octave)时都会变化的参数。 因此,octave属性与检测到关键点的图像比例有关。
  • 最后,class_id属性可用于将自定义标识符分配给一个关键点或一组关键点。

检测快速 Hessian 特征并提取 SURF 描述符

计算机视觉是计算机科学中一个相对较年轻的分支,因此许多著名的算法和技术只是最*才发明的。 实际上,SIFT 才 21 岁,由 David Lowe 于 1999 年出版。

SURF 是一种特征检测算法,由 Herbert Bay 于 2006 年发布。 SURF 比 SIFT 快几倍,并且部分受其启发。

请注意,SIFT 和 SURF 都是专利算法,因此,仅在使用OPENCV_ENABLE_NONFREE CMake 标志的opencv_contrib构建中可用。

理解 SURF 是如何在后台运行的,与本书没有特别的关系,因为我们可以在应用中使用它并充分利用它。 重要的是要理解的是cv2.SURF是一个 OpenCV 类,它使用 Fast Hessian 算法执行关键点检测并使用 SURF 执行描述符提取,就像cv2.SIFT类使用 DoG 执行关键点检测并使用 SIFT 执行描述符提取。

同样,好消息是 OpenCV 为其所有受支持的特征检测和描述符提取算法提供了标准化的 API。 因此,仅需很小的更改,我们就可以使我们先前的代码示例适应于使用 SURF 而不是 SIFT。 这是修改后的代码,其中的更改以粗体显示:

import cv2

img = cv2.imread('img/varese.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

surf = cv2.xfeatures2d.SURF_create(8000)
keypoints, descriptor = surf.detectAndCompute(gray, None)

cv2.drawKeypoints(img, keypoints, img, (51, 163, 236),
                  cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)

cv2.imshow('surf_keypoints', img)
cv2.waitKey()

cv2.xfeatures2d.SURF_create的参数是 Fast Hessian 算法的阈值。 通过增加阈值,我们可以减少将保留的特征数量。 阈值为8000,我们得到以下结果:

尝试调整阈值以查看其如何影响结果。 作为练习,您可能希望使用控制阈值的滑块构建 GUI 应用。 这样,用户可以调整阈值并查看特征数量以反比例的方式增加和减少。 我们在第 4 章,“深度估计和分段”中使用滑块构建了 GUI 应用,在“使用普通摄像机的深度估计”中,因此您可能需要回到并参考该部分作为指导。

接下来,我们将检查 FAST 角点检测器,BRIEF 关键点描述符和 ORB(将 FAST 和 BRIEF 一起使用)。

将 ORB 与 FAST 特征和 BRIEF 描述符一起使用

如果 SIFT 年龄较小,而 SURF 年龄较小,则 ORB 处于婴儿期。 ORB 于 2011 年首次发布,是 SIFT 和 SURF 的快速替代方案。

该算法已发表在论文《ORB:SIFT 或 SURF 的有效替代品》中,可通过 PDF 格式在这个页面中找到。

ORB 混合了 FAST 关键点检测器和 BRIEF 关键点描述符中使用的技术,因此值得快速了解 FAST 和 BRIEF。 然后,我们将讨论暴力匹配-一种用于特征匹配的算法-并查看特征匹配的示例。

FAST

来自加速段测试的特征FAST)算法通过分析 16 个像素的圆形邻域来工作。 它将邻*区域中的每个像素标记为比特定阈值更亮或更暗,该特定阈值是相对于圆心定义的。 如果邻域包含多个标记为亮或暗的连续像素,则认为该邻域是一个角。

FAST 还使用高速测试,有时仅检查 2 或 4 个像素(而不是 16 个像素)就可以确定邻域不是角点。要了解该测试的工作原理,请看下面的图表 OpenCV 文档:

在这里,我们可以看到两个不同放大倍数的 16 像素邻域。 位置 1、5、9 和 13 处的像素对应于圆形邻域边缘处的四个基点。 如果邻域是一个角,我们希望在这四个像素中,三个恰好一个比阈值亮。 (另一种说法是,正好一个正好三个都比阈值暗。)如果正好两个都比阈值亮,那么我们有一个边缘,而不是一个角。 如果其中恰好有四个或恰好零个比阈值亮,那么我们有一个相对统一的邻域,既不是角点也不是边缘。

FAST 是一种聪明的算法,但并非没有缺点,为了弥补这些缺点,分析图像的开发人员可以实现机器学习方法,以便将一组图像(与给定应用相关)馈送到算法中,以便优化类似阈值的参数。 无论开发人员直接指定参数还是为机器学习方法提供训练集,FAST 都是一种对开发人员的输入敏感的算法,可能比 SIFT 更为敏感。

BRIEF

另一方面,二进制鲁棒独立基本特征BRIEF)不是特征检测算法,而是描述符。 让我们更深入地了解描述符是什么的概念,然后看一下 BRIEF。

当我们先前使用 SIFT 和 SURF 分析图像时,整个过程的核心是对detectAndCompute函数的调用。 该函数执行两个不同的步骤-检测和计算-并且它们返回两个不同的结果,并以元组为单位。

检测的结果是一组关键点。 计算的结果是这些关键点的一组描述符。 这意味着 OpenCV 的cv2.SIFTcv2.SURF类实现用于检测和描述的算法。 但是请记住,原始的 SIFT 和 SURF 不是特征检测算法。 OpenCV 的cv2.SIFT实现了 DoG 特征检测和 SIFT 描述,而 OpenCV 的cv2.SURF实现了快速黑森特征检测和 SURF 描述。

关键点描述符是图像的表示形式,可以用作特征匹配的网关,因为您可以比较两个图像的关键点描述符并找到共同点。

BRIEF 是当前可用的最快的描述符之一。 BRIEF 背后的理论非常复杂,但可以说 BRIEF 采用了一系列优化,使其成为特征匹配的很好选择。

暴力匹配

暴力匹配器是描述符匹配器,它比较两组关键点描述符并生成结果,该结果是匹配项列表。 之所以称为暴力是因为该算法几乎没有优化。 对于第一组中的每个关键点描述符,匹配器将与第二组中的每个关键点描述符进行比较。 每次比较都会产生一个距离值,并且可以根据最小距离选择最佳匹配。

更一般而言,在计算中,术语暴力与优先使用所有可能组合(例如,所有可能的字符组合以破解已知长度的密码)的方法相关联。 相反,优先考虑速度的算法可能会跳过某些可能性,并尝试采用一条捷径来解决似乎最合理的解决方案。

OpenCV 提供了cv2.BFMatcher类,该类支持多种用于暴力特征匹配的方法。

在两个图像中匹配徽标

现在我们对 FAST 和 BRIEF 有了一个大致的了解,我们可以理解为什么 ORB 背后的团队(由 Ethan Rublee,Vincent Rabaud,Kurt Konolige 和 Gary R. Bradski 组成)选择这两种算法作为 ORB 的基础。

在他们的论文中,作者旨在实现以下结果:

  • 在 FAST 中添加了快速准确的定位组件
  • 定向 BRIEF 特征的有效计算
  • 定向 BRIEF 特征的方差和相关性分析
  • 一种在旋转不变性下解相关简短特征的学习方法,从而在最*邻应用中获得更好的表现

要点很明确:ORB 旨在优化和加速操作,包括非常重要的步骤,即以旋转感知的方式使用 BRIEF,以便即使在训练图像与旋转图像有很大不同的情况下,也可以改善匹配度。 查询图片。

不过,在这个阶段,也许您已经掌握了足够的理论,并且想深入了解某些特征匹配,所以让我们看一些代码。 以下脚本尝试将徽标中的特征与包含徽标的照片中的特征进行匹配:

import cv2
from matplotlib import pyplot as plt

# Load the images.
img0 = cv2.imread('img/nasa_logo.png',
                  cv2.IMREAD_GRAYSCALE)
img1 = cv2.imread('img/kennedy_space_center.jpg',
                  cv2.IMREAD_GRAYSCALE)

# Perform ORB feature detection and description.
orb = cv2.ORB_create()
kp0, des0 = orb.detectAndCompute(img0, None)
kp1, des1 = orb.detectAndCompute(img1, None)

# Perform brute-force matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des0, des1)

# Sort the matches by distance.
matches = sorted(matches, key=lambda x:x.distance)

# Draw the best 25 matches.
img_matches = cv2.drawMatches(
    img0, kp0, img1, kp1, matches[:25], img1,
    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# Show the matches.
plt.imshow(img_matches)
plt.show()

让我们逐步检查该代码。 常规导入后,我们以灰度格式加载两个图像(查询图像和场景)。 这是查询图像,它是 NASA 徽标:

这是肯尼迪航天中心的现场照片:

现在,我们继续创建 ORB 特征检测器和描述符:

# Perform ORB feature detection and description.
orb = cv2.ORB_create()
kp0, des0 = orb.detectAndCompute(img0, None)
kp1, des1 = orb.detectAndCompute(img1, None)

以与 SIFT 和 SURF 相似的方式,我们检测并计算两个图像的关键点和描述符。

从这里开始,概念非常简单:遍历描述符并确定它们是否匹配,然后计算该匹配的质量(距离)并对匹配进行排序,以便我们可以显示顶部的n确实可以匹配两个图像上的特征,因此具有一定的可信度。 cv2.BFMatcher为我们做到这一点:

# Perform brute-force matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des0, des1)

# Sort the matches by distance.
matches = sorted(matches, key=lambda x:x.distance)

在这个阶段,我们已经拥有了所需的所有信息,但是作为计算机视觉爱好者,我们非常重视视觉表示数据,因此让我们在matplotlib图表中绘制这些匹配项:

# Draw the best 25 matches.
img_matches = cv2.drawMatches(
    img0, kp0, img1, kp1, matches[:25], img1,
    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# Show the matches.
plt.imshow(img_matches)
plt.show()

Python 的切片语法非常强大。 如果matches列表包含少于 25 个条目,则matches[:25]切片命令将毫无问题地运行,并为我们提供一个包含与原始元素一样多的元素的列表。

结果如下:

您可能会认为这是令人失望的结果。 确实,我们可以看到大多数匹配项都是错误的匹配项。 不幸的是,这是很典型的。 为了改善结果,我们需要应用其他技术来滤除不良匹配。 接下来,我们将注意力转移到此任务上。

使用 K 最*邻和比率测试过滤匹配

想象一下,一大批著名的哲学家要您对有关生命,宇宙和一切至关重要的问题进行辩论。 您在每个哲学家轮流讲话时会仔细听。 最后,当所有哲学家用尽了所有论点之后,您便会回顾自己的笔记并意识到以下两点:

  • 每个哲学家都不同意
  • 没有一个哲​​学家比其他哲学家更具说服力

从您的第一个观察中,您可以推断出最多一个哲学家是正确的; 但是,所有哲学家都有可能犯错。 然后,从第二个观察中,您开始担心自己有可能选择错误的哲学家,即使其中一位哲学家是正确的。 不管您怎么看,这些人都让您陷入僵局。 您称其为*局,说辩论中最重要的问题仍未解决。

我们可以将判断哲学家辩论的假想问题与排除不良关键点匹配的实际问题进行比较。

首先,我们假设查询图像中的每个关键点在场景中最多只有一个正确的匹配项。 暗示地,如果我们的查询图像是 NASA 徽标,则我们假设另一幅图像-场景-最多包含一个 NASA 徽标。 假设查询关键点最多只有一个正确或良好的匹配项,所以当我们考虑所有可能的匹配项时,我们主要观察到错误的匹配项。 因此,暴力匹配器会为每个可能的匹配计算一个距离得分,可以使我们对不良匹配的距离得分有很多观察。 我们期望良好的比赛比许多不良的比赛具有更好的(较低)距离得分,因此不良比赛的得分可以帮助我们选择良好比赛的门槛。 这样的阈值不一定能在不同的查询关键点或不同的场景之间很好地概括,但至少在个案的基础上可以帮助我们。

现在,让我们考虑一种改进的暴力匹配算法的实现,该算法以我们描述的方式自适应地选择距离阈值。 在上一节的代码示例中,我们使用cv2.BFMatcher类的match方法来获取包含每个查询关键点的单个最佳(最小距离)匹配的列表。 这样,我们就丢弃了所有可能更差的比赛的距离得分的信息,这是我们采用自适应方法所需的信息。 幸运的是,cv2.BFMatcher还提供了knnMatch方法,该方法接受参数k,该参数指定我们要为每个查询关键点保留的最佳(最小距离)匹配的最大数目。 (在某些情况下,我们得到的匹配数可能少于最大值)。KNN 代表 K 最*邻

我们将使用knnMatch方法为每个查询关键点请求两个最佳匹配的列表。 基于我们的假设,即每个查询关键点最多具有一个正确的匹配项,因此我们确信第二好的匹配项是错误的。 我们将次优匹配的距离得分乘以小于 1 的值以获得阈值。

然后,仅当其远处分数小于阈值时,我们才将最佳匹配视为良好匹配。 这种方法称为比率测试,最早由 SIFT 算法的作者 David Lowe 提出。 他在论文《比例不变关键点中的独特图像特征》中描述了比率测试,可从这个页面。 具体来说,在“对象识别”的应用部分中,他指出:

“匹配正确的可能性可以通过计算从最*邻到第二次邻居的距离之比来确定。”

我们可以像上一节代码示例中一样的方式加载图像,检测关键点并计算 ORB 描述符。 然后,我们可以使用以下两行代码执行暴力 KNN 匹配:

# Perform brute-force KNN matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
pairs_of_matches = bf.knnMatch(des0, des1, k=2)

knnMatch返回列表列表; 每个内部列表至少包含一个匹配项,并且不超过k个匹配项,从最佳(最短距离)到最差排序。 以下代码行根据最佳匹配的距离得分对外部列表进行排序:

# Sort the pairs of matches by distance.
pairs_of_matches = sorted(pairs_of_matches, key=lambda x:x[0].distance)

让我们画出前 25 个最佳比赛,以及knnMatch可能与之配对的次佳比赛。 我们无法使用cv2.drawMatches函数,因为它仅接受一维匹配项列表; 相反,我们必须使用cv2.drawMatchesKnn。 以下代码用于选择,绘制和显示匹配项:

# Draw the 25 best pairs of matches.
img_pairs_of_matches = cv2.drawMatchesKnn(
    img0, kp0, img1, kp1, pairs_of_matches[:25], img1,
    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# Show the pairs of matches.
plt.imshow(img_pairs_of_matches)
plt.show()

到目前为止,我们还没有过滤掉任何不正确的比赛-实际上,我们故意包括了第二好的比赛,我们认为这是糟糕的-因此结果看起来很混乱。 这里是:

现在,让我们应用比率测试。 我们将阈值设置为第二好的比赛的距离得分的 0.8 倍。 如果knnMatch无法提供次佳的比赛,我们仍然会拒绝最佳比赛,因为我们无法应用测试。 以下代码适用于这些条件,并为我们提供了通过测试的最佳匹配项列表:

# Apply the ratio test.
matches = [x[0] for x in pairs_of_matches
           if len(x) > 1 and x[0].distance < 0.8 * x[1].distance]

应用了比率测试之后,现在我们仅处理最佳匹配(而不是最佳匹配和次佳匹配对),因此我们可以使用cv2.drawMatches而不是cv2.drawMatchesKnn来绘制它们。 同样,我们将从列表中选择前 25 个匹配项。 以下代码用于选择,绘制和显示匹配项:

# Draw the best 25 matches.
img_matches = cv2.drawMatches(
    img0, kp0, img1, kp1, matches[:25], img1,
    flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)

# Show the matches.
plt.imshow(img_matches)
plt.show()

在这里,我们可以看到通过比率测试的匹配项:

将输出图像与上一节中的图像进行比较,我们可以看到 KNN 和比率测试使我们能够过滤掉许多不良匹配项。 其余比赛并不完美,但几乎所有比赛都指向正确的区域-肯尼迪航天中心侧面的 NASA 徽标。

我们已经有了良好的开端。 接下来,我们将使用名为 FLANN 的更快的匹配器替换暴力匹配器。 之后,我们将学习如何用单应性来描述一组匹配项-即二维变换矩阵,该矩阵表示匹配对象的位置,旋转,比例和其他几何特征。

使用 FLANN 的匹配

FLANN 代表用于*似最*邻的快速库。 根据许可的 2 条款 BSD 许可,这是一个开源库。 FLANN 的官方互联网主页是这个页面。 以下是该网站的报价:

“FLANN 是一个用于在高维空间中执行快速*似最*邻搜索的库。它包含我们发现最适合最*邻搜索的算法集合,以及一个根据数据集自动选择最佳算法和最佳参数的系统
FLANN 用 C++ 编写,并且包含以下语言的绑定:C,MATLAB 和 Python。”

换句话说,FLANN 有一个很大的工具箱,它知道如何为工作选择正确的工具,并且会说几种语言。 这些功能使资料库快速便捷。 实际上,FLANN 的作者声称,对于许多数据集而言,它比其他最*邻搜索软件快 10 倍。

作为独立的库,可以在 GitHub 上找到 FLANN。 但是,我们将 FLANN 用作 OpenCV 的一部分,因为 OpenCV 为此提供了一个方便的包装器。

为了开始我们的 FLANN 匹配的实际示例,让我们导入 NumPy,OpenCV 和 Matplotlib,并从文件中加载两个图像。 以下是相关代码:

import numpy as np
import cv2
from matplotlib import pyplot as plt

img0 = cv2.imread('img/gauguin_entre_les_lys.jpg',
                  cv2.IMREAD_GRAYSCALE)
img1 = cv2.imread('img/gauguin_paintings.png',
                  cv2.IMREAD_GRAYSCALE)

这是我们的脚本正在加载的第一张图像-查询图像:

此艺术品是保罗·高更(Paul Gauguin)在 1889 年绘制的《Entre les lys》(在百合花中)。我们将在包含高更的多幅作品的较大图像中搜索匹配的关键点, 本书的一位作者绘制的一些杂乱无章的形状。 这是更大的图像:

在较大的图像中,《Entre les lys》出现在第三列第三行中。 查询图像和较大图像的对应区域不相同; 他们以略微不同的颜色和不同的比例描绘了《Entre les lys》。 但是,对于我们的匹配器来说,这应该是一个简单的例子。

让我们检测必要的关键点并使用cv2.SIFT类提取特征:

# Perform SIFT feature detection and description.
sift = cv2.xfeatures2d.SIFT_create()
kp0, des0 = sift.detectAndCompute(img0, None)
kp1, des1 = sift.detectAndCompute(img1, None)

到目前为止,该代码应该看起来很熟悉,因为我们已经在本章的几个部分中专门介绍了 SIFT 和其他描述符。 在前面的示例中,我们将描述符提供给cv2.BFMatcher以进行暴力匹配。 这次,我们将改为使用cv2.FlannBasedMatcher。 以下代码使用自定义参数执行基于 FLANN 的匹配:

# Define FLANN-based matching parameters.
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)

# Perform FLANN-based matching.
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches = flann.knnMatch(des0, des1, k=2)

在这里,我们可以看到 FLANN 匹配器采用两个参数:indexParams对象和searchParams对象。 这些参数在 Python 中以字典的形式(在 C++ 中以结构的形式)传递,确定了索引的行为以及搜索对象,FLANN 在内部使用这些对象来计算匹配项。 我们选择的参数在精度和处理速度之间取得合理的*衡。 具体来说,我们使用核密度树kd-tree)索引算法,其中有五棵树,FLANN 可以并行处理。 (FLANN 文档建议在一棵不提供并行性的树和 16 棵树之间(如果系统可以利用它,则可以提供高度的并行性)。)

我们正在对每棵树执行 50 次检查或遍历。 数量更多的支票可以提供更高的准确率,但计算成本更高。

在执行基于 FLANN 的匹配后,我们使用系数为 0.7 的 Lowe 比率测试。 为了演示不同的编码风格,我们将使用比率测试的结果与上一节代码示例中的结果稍有不同。 以前,我们组装了一个仅包含良好匹配项的新列表。 这次,我们将组装一个名为mask_matches的列表,其中每个元素都是长度为k的子列表(与传递给knnMatchk相同)。 如果匹配良好,则将子列表的相应元素设置为1; 否则,我们将其设置为0

例如,如果我们有mask_matches = [[0, 0], [1, 0]],则意味着我们有两个匹配的关键点; 对于第一个关键点,最佳匹配和次佳匹配都不好,而对于第二个关键点,最佳匹配很好,但次佳匹配不好。 记住,我们假设所有次佳的比赛都是不好的。 我们使用以下代码进行比率测试并构建遮罩:

# Prepare an empty mask to draw good matches.
mask_matches = [[0, 0] for i in range(len(matches))]

# Populate the mask based on David G. Lowe's ratio test.
for i, (m, n) in enumerate(matches):
    if m.distance < 0.7 * n.distance:
        mask_matches[i]=[1, 0]

现在,是时候绘制并显示良好的匹配。 我们可以将mask_matches列表作为可选参数传递给cv2.drawMatchesKnn,如以下代码段中的粗体所示:

# Draw the matches that passed the ratio test.
img_matches = cv2.drawMatchesKnn(
    img0, kp0, img1, kp1, matches, None,
    matchColor=(0, 255, 0), singlePointColor=(255, 0, 0),
    matchesMask=mask_matches, flags=0)

# Show the matches.
plt.imshow(img_matches)
plt.show()

cv2.drawMatchesKnn仅在遮罩中绘制我们标记为良好的匹配项(值为1)。 让我们揭晓结果。 我们的脚本对基于 FLANN 的匹配产生以下可视化效果:

这是令人鼓舞的情况:看来几乎所有比赛都在正确的位置。 接下来,让我们尝试将这种类型的结果简化为更简洁的几何表示法-单应性法-它可以描述整个匹配对象的姿态,而不是一堆断开的匹配点的姿态。

通过基于 FLANN 的匹配执行单应性

首先,什么是单应性? 让我们从互联网上阅读一个定义:

“两个图形之间的关系,使得一个图形的任意一点对应一个图形,而另一图形又对应一个图形,反之亦然。因此,在圆上滚动的切线将圆的两个固定切线切成同形的两组点。”

如果您-像本书的作者一样-不是前面定义的明智者,您可能会发现以下解释更清楚:单应性是一种条件,即当一个图是另一个图的透视变形时,两个图会互相发现 。

首先,让我们看一下我们要实现的目标,以便我们可以完全理解单应性。 然后,我们将遍历代码。

假设我们要搜索以下纹身:

尽管存在旋转差异,但作为人类,我们可以轻松地在下图中找到纹身:

作为计算机视觉中的一项练习,我们想编写一个脚本,以产生以下关键点匹配和单应性的可视化效果:

如前面的屏幕快照所示,我们在第一幅图像中拍摄了对象,在第二幅图像中正确地识别了该对象,在关键点之间绘制了匹配线,甚至绘制了一个白色边框,显示了第二幅图像中对象相对于第一张图片的视角的变形。

您可能已经正确地猜到了脚本的实现是通过导入库,读取灰度格式的图像,检测特征以及计算 SIFT 描述符开始的。 我们在前面的示例中做了所有这些操作,因此在此将其省略。 让我们看一下接下来的操作:

  1. 我们通过组装通过 Lowe 比率测试的匹配项列表来进行操作,如以下代码所示:
# Find all the good matches as per Lowe's ratio test.
good_matches = []
for m, n in matches:
    if m.distance < 0.7 * n.distance:
        good_matches.append(m)
  1. 从技术上讲,我们可以通过最少四个匹配来计算单应性。 但是,如果这四个匹配项中的任何一个有缺陷,都会降低结果的准确率。 更实用的最小值是10。 给定额外的匹配项,单应性查找算法可以丢弃一些离群值,以产生与匹配项的实质子集非常契合的结果。 因此,我们继续检查我们是否至少有10个良好匹配项:
MIN_NUM_GOOD_MATCHES = 10

if len(good_matches) >= MIN_NUM_GOOD_MATCHES:
  1. 如果满足此条件,我们将查找匹配的关键点的 2D 坐标,并将这些坐标放置在两个浮点坐标对列表中。 一个列表包含查询图像中的关键点坐标,而另一个列表包含场景中匹配的关键点坐标:
    src_pts = np.float32(
        [kp0[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
    dst_pts = np.float32(
        [kp1[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
  1. 现在,我们找到单应性:
    M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
    mask_matches = mask.ravel().tolist()

请注意,我们创建了一个mask_matches列表,该列表将在比赛的最终绘图中使用,以便仅在单应图中的点才会绘制出匹配线。

  1. 在这一阶段,我们必须执行透视变换,该变换将查询图像的矩形角投影到场景中,以便绘制边框:
    h, w = img0.shape
    src_corners = np.float32(
        [[0, 0], [0, h-1], [w-1, h-1], [w-1, 0]]).reshape(-1, 1, 2)
    dst_corners = cv2.perspectiveTransform(src_corners, M)
    dst_corners = dst_corners.astype(np.int32)

    # Draw the bounds of the matched region based on the homography.
    num_corners = len(dst_corners)
    for i in range(num_corners):
        x0, y0 = dst_corners[i][0]
        if i == num_corners - 1:
            next_i = 0
        else:
            next_i = i + 1
        x1, y1 = dst_corners[next_i][0]
        cv2.line(img1, (x0, y0), (x1, y1), 255, 3, cv2.LINE_AA)

然后,按照前面的示例,我们继续绘制关键点并显示可视化效果。

示例应用–纹身取证

让我们以一个真实的(或者也许是幻想的)例子作为本章的结尾。 假设您正在哥谭法医部门工作,并且需要识别纹身。 您拥有罪犯纹身的原始图片(也许是在闭路电视录像中捕获的),但您不知道该人的身份。 但是,您拥有一个纹身数据库,该数据库以纹身所属的人的名字为索引。

让我们将此任务分为两个部分:

  • 通过将图像描述符保存到文件来构建数据库
  • 加载数据库并扫描查询图像的描述符和数据库中的描述符之间的匹配项

我们将在接下来的两个小节中介绍这些任务。

将图像描述符保存到文件

我们要做的第一件事是将图像描述符保存到外部文件中。 这样,我们不必每次想要扫描两个图像以进行匹配时都重新创建描述符。

就我们的示例而言,让我们扫描文件夹中的图像并创建相应的描述符文件,以便我们可以随时使用它们以供将来搜索。 为了创建描述符,我们将使用本章已经使用过多次的过程:即加载图像,创建特征检测器,检测特征并计算描述符。 要将描述符保存到文件中,我们将使用方便的 NumPy 数组方法save,该方法以优化的方式将数组数据转储到文件中。

Python 标准库中的pickle模块提供了更多通用的序列化功能,该功能支持任何 Python 对象,而不仅仅是 NumPy 数组。 但是,NumPy 的数组序列化是数字数据的不错选择。

让我们将脚本分解为函数。 主要函数将命名为create_descriptors(复数,描述符),它将遍历给定文件夹中的文件。 对于每个文件,create_descriptors将调用一个名为create_descriptor的帮助器函数(单数,描述符),该函数将为给定的图像文件计算并保存我们的描述符。 让我们开始吧:

  1. 首先,这是create_descriptors的实现:
import os

import numpy as np
import cv2

def create_descriptors(folder):
    feature_detector = cv2.xfeatures2d.SIFT_create()
    files = []
    for (dirpath, dirnames, filenames) in os.walk(folder):
        files.extend(filenames)
    for f in files:
        create_descriptor(folder, f, feature_detector)

请注意,create_descriptors创建了特征检测器,因为我们只需要执行一次,而不是每次加载文件时都执行一次。 辅助函数create_descriptor接收特征检测器作为参数。

  1. 现在,让我们看一下后一个函数的实现:
def create_descriptor(folder, image_path, feature_detector):
    if not image_path.endswith('png'):
        print('skipping %s' % image_path)
        return
    print('reading %s' % image_path)
    img = cv2.imread(os.path.join(folder, image_path),
                     cv2.IMREAD_GRAYSCALE)
    keypoints, descriptors = feature_detector.detectAndCompute(
        img, None)
    descriptor_file = image_path.replace('png', 'npy')
    np.save(os.path.join(folder, descriptor_file), descriptors)

请注意,我们将描述符文件与图像保存在同一文件夹中。 此外,我们假设图像文件具有png扩展名。 为了使脚本更加鲁棒,可以对其进行修改,使其支持其他图像文件扩展名,例如jpg。 如果文件具有意外扩展名,我们将其跳过,因为它可能是描述符文件(来自脚本的先前运行)或其他一些非映像文件。

  1. 我们已经完成了函数。 为了完成脚本,我们将使用文件夹名称作为参数来调用create_descriptors
folder = 'tattoos'
create_descriptors(folder)

当我们运行此脚本时,它将以 NumPy 的数组文件格式生成必要的描述符文件,文件扩展名为npy。 这些文件构成我们的纹身描述符数据库,按名称索引。 (每个文件名都是一个人的名字。)接下来,我们将编写一个单独的脚本,以便可以对该数据库运行查询。

扫描描述符

现在,我们已将描述符保存到文件中,我们只需要对每组描述符进行匹配,以确定哪一组与我们的查询图像最匹配。

这是我们将执行的过程:

  1. 加载查询图像(query.png)。
  2. 扫描包含描述符文件的文件夹。 打印描述符文件的名称。
  3. 为查询图像创建 SIFT 描述符。
  4. 对于每个描述符文件,加载 SIFT 描述符并查找基于 FLANN 的匹配项。 根据比率测试过滤匹配项。 打印此人的姓名和匹配数。 如果匹配数超过任意阈值,请打印此人是可疑人员。 (请记住,我们正在调查犯罪。)
  5. 打印主要嫌疑人的名字(匹配次数最多的人)。

让我们考虑一下实现:

  1. 首先,以下代码块加载查询图像:
import os

import numpy as np
import cv2

# Read the query image.
folder = 'tattoos'
query = cv2.imread(os.path.join(folder, 'query.png'),
                   cv2.IMREAD_GRAYSCALE)

  1. 我们继续组装并打印描述符文件列表:
# create files, images, descriptors globals
files = []
images = []
descriptors = []
for (dirpath, dirnames, filenames) in os.walk(folder):
    files.extend(filenames)
    for f in files:
        if f.endswith('npy') and f != 'query.npy':
            descriptors.append(f)
print(descriptors)
  1. 我们设置了典型的cv2.SIFTcv2.FlannBasedMatcher对象,并生成了查询图像的描述符:
# Create the SIFT detector.
sift = cv2.xfeatures2d.SIFT_create()

# Perform SIFT feature detection and description on the
# query image.
query_kp, query_ds = sift.detectAndCompute(query, None)

# Define FLANN-based matching parameters.
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)

# Create the FLANN matcher.
flann = cv2.FlannBasedMatcher(index_params, search_params)
  1. 现在,我们搜索嫌疑犯,我们将其定义为查询纹身至少具有 10 个良好匹配项的人。 我们的搜索需要遍历描述符文件,加载描述符,执行基于 FLANN 的匹配以及根据比率测试过滤匹配。 我们为每个人(每个描述符文件)打印结果:
# Define the minimum number of good matches for a suspect.
MIN_NUM_GOOD_MATCHES = 10

greatest_num_good_matches = 0
prime_suspect = None

print('>> Initiating picture scan...')
for d in descriptors:
    print('--------- analyzing %s for matches ------------' % d)
    matches = flann.knnMatch(
        query_ds, np.load(os.path.join(folder, d)), k=2)
    good_matches = []
    for m, n in matches:
        if m.distance < 0.7 * n.distance:
            good_matches.append(m)
    num_good_matches = len(good_matches)
    name = d.replace('.npy', '').upper()
    if num_good_matches >= MIN_NUM_GOOD_MATCHES:
        print('%s is a suspect! (%d matches)' % \
            (name, num_good_matches))
        if num_good_matches > greatest_num_good_matches:
            greatest_num_good_matches = num_good_matches
            prime_suspect = name
    else:
        print('%s is NOT a suspect. (%d matches)' % \
            (name, num_good_matches))

请注意np.load方法的使用,该方法会将指定的NPY文件加载到 NumPy 数组中。

  1. 最后,我们打印主要嫌疑人的姓名(如果找到嫌疑人,则为):
if prime_suspect is not None:
    print('Prime suspect is %s.' % prime_suspect)
else:
    print('There is no suspect.')

运行前面的脚本将产生以下输出:

>> Initiating picture scan...
--------- analyzing anchor-woman.npy for matches ------------
ANCHOR-WOMAN is NOT a suspect. (2 matches)
--------- analyzing anchor-man.npy for matches ------------
ANCHOR-MAN is a suspect! (44 matches)
--------- analyzing lady-featherly.npy for matches ------------
LADY-FEATHERLY is NOT a suspect. (2 matches)
--------- analyzing steel-arm.npy for matches ------------
STEEL-ARM is NOT a suspect. (0 matches)
--------- analyzing circus-woman.npy for matches ------------
CIRCUS-WOMAN is NOT a suspect. (1 matches)
Prime suspect is ANCHOR-MAN.

如果需要,我们可以像上一节中那样以图形方式表示比赛和单应性。

总结

在本章中,我们学习了有关检测关键点,计算关键点描述符,匹配这些描述符,滤除不匹配项以及查找两组匹配的关键点之间的单应性的方法。 我们探索了 OpenCV 中可以用于完成这些任务的多种算法,并将这些算法应用于各种图像和用例。

如果我们将关键点的新知识与有关相机和透视图的其他知识相结合,则可以跟踪 3D 空间中的对象。 这将是第 9 章,“相机模型和增强现实”的主题。 如果您特别想达到第三个维度,则可以跳到该章。

相反,如果您认为下一步的逻辑步骤是完善对对象检测,识别和跟踪的二维解决方案的了解,则可以按顺序继续进行第 7 章,“构建自定义对象检测器”,然后是第 8 章,“跟踪对象”。 最好了解 2D 和 3D 的组合技术,以便为给定的应用选择一种提供正确的输出种类和正确的计算速度的方法。

七、建立自定义对象检测器

本章将深入研究对象检测的概念,这是计算机视觉中最常见的挑战之一。 在本书中走到这一步,您也许想知道什么时候可以在街头实践计算机视觉。 您是否梦想建立一个检测汽车和人员的系统? 好吧,实际上,您离目标不算太远。

在前面的章节中,我们已经研究了对象检测和识别的一些特定情况。 在第 5 章,“检测和识别人脸”中,我们专注于直立的正面人脸;在第 6 章,“检索图像并使用图像描述符进行搜索”中,我们研究了具有角点或斑点状特征的物体。 现在,在本章中,我们将探索具有良好泛化或外推能力的算法,从某种意义上说,它们可以应对给定对象类别中存在的现实世界的多样性。 例如,不同的汽车具有不同的设计,并且人们可能会根据所穿的衣服而呈现出不同的形状。

具体来说,我们将追求以下目标:

  • 了解另一种特征描述符:定向梯度描述符直方图HOG)。
  • 了解非最大抑制,也称为非最大抑制NMS),这有助于我们从重叠的检测窗口集中选择最佳。
  • 支持向量机SVM)有较高的了解。 这些通用分类器基于有监督的机器学习,类似于线性回归。
  • 使用基于 HOG 描述符的预训练分类器检测人员。
  • 训练词袋BoW)分类器以检测汽车。 对于此示例,我们将使用图像金字塔,滑动窗口和 NMS 的自定义实现,以便我们可以更好地了解这些技术的内部工作原理。

本章中的大多数技术都不是互斥的。 相反,它们作为检测器的组件一起工作。 在本章结束时,您将知道如何训练和使用在大街上有实际应用的分类器!

技术要求

本章使用 Python,OpenCV 和 NumPy。 请参考第 1 章,“设置 OpenCV”,以获得安装说明。

可在本书的 GitHub 存储库中找到本章的完整代码, 在chapter07文件夹中。 样本图像可以在images文件夹的存储库中找到。

了解 HOG 描述符

HOG 是一种特征描述符,因此它与尺度不变特征变换SIFT),加速鲁棒特征SURF)和定向 FAST 和旋转 BRIEFORB),我们在第 6 章“检索图像和使用图像描述符进行搜索”中介绍了此方法。 像其他特征描述符一样,HOG 能够传递对于特征匹配以及对象检测和识别至关重要的信息类型。 最常见的是,HOG 用于对象检测。 Navneet Dalal 和 Bill Triggs 在他们的论文《面向人类检测的梯度梯度直方图》(INRIA,2005)上普及了该算法,尤其是将其用作人体检测器。

HOG 的内部机制确实很聪明; 将图像分为多个单元,并为每个单元计算一组梯度。 每个梯度描述了给定方向上像素强度的变化。 这些梯度一起形成了单元格的直方图表示。 当我们在第 5 章,“检测和识别人脸”中使用局部二进制模式直方图LBPH)研究人脸识别时,遇到了类似的方法。

在深入探讨 HOG 的工作原理的技术细节之前,让我们先看一下 HOG 如何看待世界。

可视化 HOG

Carl Vondrick,Aditya Khosla,Hamed Pirsiavash,Tomasz Malisiewicz 和 Antonio Torralba 开发了一种称为 HOGgles(HOG 护目镜)的 HOG 可视化技术。 有关 HOGgles 的摘要以及代码和出版物的链接,请参见 Carl Vondrick 的 MIT 网页。 作为他们的测试图像之一,Vondrick 等。 使用以下卡车图片:

Vondrick 等。 基于 Dalal 和 Triggs 早期论文的方法,产生了 HOG 描述符的以下可视化:

然后,应用 HOGgles,Vondrick 等。 反转特征描述算法,以按照 HOG 的角度重建卡车的图像,如下所示:

在这两种可视化中,您都可以看到 HOG 已将图像分为多个单元格,并且可以轻松识别出车轮和车辆的主要结构。 在第一个可视化中,每个单元格计算出的梯度显示为一组纵横交错的线,有时看起来像是细长的星星; 恒星的长轴代表更强的梯度。 在第二个可视化中,将梯度显示为沿单元格中各个轴的亮度*滑过渡。

现在,让我们进一步考虑 HOG 的工作方式,以及它对物体检测解决方案的贡献。

使用 HOG 描述图像区域

对于每个 HOG 单元,直方图包含的箱子数量等于梯度的数量,换句话说,就是 HOG 考虑的轴方向的数量。 在计算了所有单元的直方图之后,HOG 处理直方图组以生成更高级别的描述符。 具体而言,将单元分为更大的区域,称为块。 这些块可以由任意数量的单元组成,但是 Dalal 和 Triggs 发现2x2单元块在进行人员检测时产生了最佳结果。 创建一个块范围的向量,以便可以对其进行归一化,以补偿照明和阴影的局部变化。 (单个单元的区域太小而无法检测到这种变化。)这种归一化提高了基于 HOG 的检测器相对于光照条件变化的鲁棒性。

像其他探测器一样,基于 HOG 的探测器也需要应对物体位置和比例的变化。 通过在图像上移动固定大小的滑动窗口,可以满足在各种位置进行搜索的需求。 通过将图像缩放到各种大小,从而形成所谓的图像金字塔,可以解决在各种尺度下进行搜索的需求。 我们先前在第 5 章,“检测和识别人脸”中,特别是在“概念化 Haar 级联”部分中研究了这些技术。 但是,让我们详细说明一个困难:如何处理重叠窗口中的多个检测。

假设我们正在使用滑动窗口对图像执行人物检测。 我们以很小的步幅滑动窗口,一次仅滑动几个像素,因此我们希望它可以多次框住任何给定的人。 假设重叠的检测确实是一个人,我们不想报告多个位置,而只是报告一个我们认为正确的位置。 换句话说,即使在给定位置的检测具有良好的置信度得分,如果重叠检测具有更好的的置信度得分,我们可能会拒绝它; 因此,从一组重叠的检测中,我们将选择最佳置信度得分的检测。

这就是 NMS 发挥作用的地方。 给定一组重叠区域,我们可以抑制(或拒绝)分类器未针对其产生最大得分的所有区域。

了解 NMS

NMS 的概念听起来很简单。 从一组重叠的解决方案中,只需选择最佳方案即可! 但是,实现比您最初想象的要复杂。 还记得图像金字塔吗? 重叠检测可以不同的比例发生。 我们必须收集所有的正面检测结果,并在检查重叠之前将其范围重新转换为通用比例。 NMS 的典型实现采用以下方法:

  1. 构造图像金字塔。
  2. 使用滑动窗口方法扫描金字塔的每个级别,以进行物体检测。 对于每个产生正面检测的窗口(超过某个任意置信度阈值),请将窗口转换回原始图像的比例。 将窗口及其置信度得分添加到正面检测列表中。
  3. 按降序的置信度得分对正面检测列表进行排序,以便最佳检测在列表中排在第一位。
  4. 对于每个窗口,在正面检测列表中,W,删除所有与W明显重叠的所有后续窗口。 我们只剩下满足 NMS 标准的正面检测列表。

除 NMS 之外,过滤正面检测结果的另一种方法是消除任何子窗口。 当我们说子窗口(或子区域)时,是指完全包含在另一个窗口(或区域)内的窗口(或图像中的区域)。 要检查子窗口,我们只需要比较各种窗口矩形的角坐标。 我们将在第一个实际示例中采用这种简单方法,即“使用 HOG 描述符的人脸检测”部分。 可以选择将 NMS 和子窗口抑制合并在一起。

其中几个步骤是迭代的,因此我们面临着一个有趣的优化问题。 Tomasz Malisiewicz 在这个页面提供了 MATLAB 中的快速示例实现。 Adrian Rosebrock 在这个页面提供了此示例实现的一部分到 Python。 我们将在本章稍后的“在场景中检测汽车”部分的基础上,基于后一个示例。

现在,我们如何确定窗口的置信度得分? 我们需要一个分类系统来确定是否存在某个特征,以及该分类的置信度得分。 这就是 SVM 发挥作用的地方。

了解 SVM

在不讨论 SVM 如何工作的细节的情况下,让我们尝试了解它在机器学习和计算机视觉的背景下可以帮助我们完成哪些工作。 给定带标签的训练数据,SVM 会通过找到最佳超*面来学习对相同类型的数据进行分类,用最简单的英语来说,该超*面是用最大可能的余量划分不同标签数据的*面。 为了帮助我们理解,让我们考虑下图,该图由 Zach Weinberg 在“知识共享署名-相同方式共享 3.0 无端口许可”下提供:

超*面H1(显示为绿线)不划分两类(黑点与白点)。 超*面H2(显示为蓝线)和H3(显示为红线)都划分了类别。 但是,只有超*面H3将类别划分为最大余量。

假设我们正在训练 SVM 作为人员检测器。 我们有两类,非人。 作为训练样本,我们提供了包含或不包含人的各种窗口的 HOG 描述符的向量。 这些窗口可能来自各种图像。 SVM 通过找到最佳的超*面来学习,该*面将多维 HOG 描述符空间最大程度地分为人(在超*面的一侧)和非人(在另一侧)。 此后,当我们为训练后的 SVM 提供任何图像中任何其他窗口的 HOG 描述符向量时,SVM 可以判断该窗口是否包含人。 SVM 甚至可以给我们一个与向量到最佳超*面的距离有关的置信度值。

SVM 模型自 1960 年代初就出现了。 但是,此后它就得到了改进,现代 SVM 实现的基础可以在 Corinna Cortes 和 Vladimir Vapnik 的论文《支持向量网络》(《机器学习》,1995 年)中找到 。 可从这个页面获得。

现在,我们对可以组合以构成对象检测器的关键组件有了概念上的理解,我们可以开始看一些示例。 我们将从 OpenCV 的现成对象检测器之一开始,然后我们将继续设计和训练我们自己的自定义对象检测器。

使用 HOG 描述符检测人

OpenCV 带有称为cv2.HOGDescriptor的类,该类能够执行人员检测。 该接口与我们在第 5 章,“检测和识别人脸”中使用的cv2.CascadeClassifier类相似。 但是,与cv2.CascadeClassifier不同,cv2.HOGDescriptor有时会返回嵌套的检测矩形。 换句话说,cv2.HOGDescriptor可能告诉我们它检测到一个人的边界矩形完全位于另一个人的边界矩形内部。 这种情况确实是可能的。 例如,一个孩子可能站在成人的前面,而孩子的边界矩形可能完全在成人的边界矩形内。 但是,在典型情况下,嵌套检测可能是错误,因此cv2.HOGDescriptor通常与代码一起使用,以过滤掉任何嵌套检测。

让我们通过执行测试来确定一个矩形是否嵌套在另一个矩形中来开始示例脚本。 为此,我们将连接一个函数is_inside(i, o),其中i是可能的内部矩形,o是可能的外部矩形。 如果io内部,则函数将返回True; 否则,将返回False。 这是脚本的开始:

import cv2

def is_inside(i, o):
    ix, iy, iw, ih = i
    ox, oy, ow, oh = o
    return ix > ox and ix + iw < ox + ow and \
        iy > oy and iy + ih < oy + oh

现在,我们创建cv2.HOGDescriptor的实例,并指定它使用 OpenCV 内置的默认人员检测器,方法是运行以下代码:

hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())

请注意,我们使用setSVMDetector方法指定了人员检测器。 希望根据本章前面的内容,这是有道理的。 SVM 是分类器,因此 SVM 的选择决定了我们的cv2.HOGDescriptor将检测到的对象类型。

现在,我们继续加载图像(在这种情况下,是一张在干草地上工作的妇女的老照片),并尝试通过运行以下代码来检测图像中的人:

img = cv2.imread('img/haying.jpg')

found_rects, found_weights = hog.detectMultiScale(
    img, winStride=(4, 4), scale=1.02, finalThreshold=1.9)

请注意,cv2.HOGDescriptor具有detectMultiScale方法,该方法返回两个列表:

  1. 检测到的对象(在这种情况下,检测到的人)的包围矩形的列表。
  2. 检测到的物体的权重或置信度得分列表。 值越高,表示检测结果正确的可信度越高。

detectMultiScale接受几个可选参数,包括:

  • winStride:此元组定义了滑动窗口在连续检测尝试之间移动的xy距离。 HOG 在重叠的窗口中效果很好,因此相对于窗口大小,步幅可能较小。 较小的值将以较高的计算成本产生更多的检测结果。 默认的步幅没有重叠。 它与窗口大小相同,对于默认人物检测器为(64, 128)
  • scale:此比例因子应用于图像金字塔的连续级别之间。 较小的值将以较高的计算成本产生更多的检测结果。 该值必须大于1.0。 默认值为1.5
  • finalThreshold:此值确定我们的检测标准有多严格。 较小的值不太严格,导致更多的检测。 默认值为2.0

现在,我们可以过滤检测结果以删除嵌套的矩形。 为了确定矩形是否为嵌套矩形,我们可能需要将其与其他所有矩形进行比较。 请注意在以下嵌套循环中使用我们的is_inside函数:

found_rects_filtered = []
found_weights_filtered = []
for ri, r in enumerate(found_rects):
    for qi, q in enumerate(found_rects):
        if ri != qi and is_inside(r, q):
            break
    else:
        found_rects_filtered.append(r)
        found_weights_filtered.append(found_weights[ri])

最后,让我们绘制其余的矩形和权重以突出显示检测到的人,然后如下所示并显示此可视化效果:

for ri, r in enumerate(found_rects_filtered):
    x, y, w, h = r
    cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 255), 2)
    text = '%.2f' % found_weights_filtered[ri]
    cv2.putText(img, text, (x, y - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)

cv2.imshow('Women in Hayfield Detected', img)
cv2.imwrite('./women_in_hayfield_detected.jpg', img)
cv2.waitKey(0)

如果您自己运行脚本,则图像中的人周围会看到矩形。 结果如下:

这张照片是彩色摄影的先驱 Sergey Prokudin-Gorsky(1863-1944)作品的另一个例子。 在这里,场景是 1909 年俄罗斯西北部 Leushinskii 修道院的一块田地。

在距离相机最*的六位女性中,有五位被成功检测到。 同时,背景中的一个塔被错误地检测为人。 在许多实际应用中,可以通过分析视频中的一系列帧来改善人员检测结果。 例如,假设我们正在观看 Leushinskii 修道院干草地的监视视频,而不是一张照片。 我们应该能够添加代码来确定该塔不能为人,因为它不会移动。 同样,我们应该能够在其他框架中检测到其他人,并跟踪每个人在框架之间的移动。 我们将在第 8 章,“跟踪对象”中研究人员跟踪问题。

同时,让我们继续研究另一种检测器,我们可以训练该检测器来检测给定类别的对象。

创建和训练对象检测器

使用训练有素的检测器使构建快速原型变得容易,我们都非常感谢 OpenCV 开发人员提供了诸如人脸检测和人物检测之类的有用功能。 但是,无论您是业余爱好者还是计算机视觉专业人员,都不太可能只与人和面孔打交道。

此外,如果您像本书的作者一样,您会想知道“人检测器”是如何首先创建的,以及是否可以改进它。 此外,您可能还想知道是否可以将相同的概念应用于检测从汽车到地精的各种物体。

的确,在行业中,您可能不得不处理检测非常具体的对象的问题,例如车牌,书皮或任何对您的雇主或客户最重要的东西。

因此,问题是,我们如何提出自己的分类器?

有许多流行的方法。 在本章的其余部分中,我们将看到一个答案在于 SVM 和 BoW 技术。

我们已经讨论过 SVM 和 HOG。 现在让我们仔细看看 BoW。

了解 BoW

BoW 是最初不用于计算机视觉的概念; 相反,我们在计算机视觉的背景下使用了该概念的演进版本。 让我们首先讨论一下它的基本版本,正如您可能已经猜到的那样,它最初属于语言分析和信息检索领域。

在计算机视觉的背景下,有时 BoW 被称为视觉词袋BoVW)。 但是,我们将仅使用术语 BoW,因为这是 OpenCV 使用的术语。

BoW 是一种技术,通过它我们可以为一系列文档中的每个单词分配权重或计数; 然后,我们用这些计数的向量表示这些文档。 让我们来看一个示例,如下所示:

  • 文档 1:我喜欢 OpenCV,也喜欢 Python。
  • 文档 2:我喜欢 C++ 和 Python。
  • 文档 3:我不喜欢洋蓟。

这三个文档使我们能够使用以下值构建字典-也称为码本或词汇表-如下所示:

{
    I: 4,
    like: 4,
    OpenCV: 1,
    and: 2,
    Python: 2,
    C++: 1,
    don't: 1,
    artichokes: 1
}

我们有八个条目。 现在让我们使用八项向量表示原始文档。 每个向量都包含代表给定文档的字典中所有单词计数的值。 前三个句子的向量表示如下:

[2, 2, 1, 1, 1, 0, 0, 0]
[1, 1, 0, 1, 1, 1, 0, 0]
[1, 1, 0, 0, 0, 0, 1, 1]

这些向量可以概念化为文档的直方图表示形式,也可以概念化为可用于训练分类器的描述符向量。 例如,基于这样的表示,文档可以分类为垃圾邮件非垃圾邮件。 实际上,垃圾邮件过滤是 BoW 的许多实际应用之一。

既然我们已经掌握了 BoW 的基本概念,那么让我们看一下它如何应用于计算机视觉世界。

将 BoW 应用于计算机视觉

现在,我们已经熟悉了特征和描述符的概念。 我们使用了诸如 SIFT 和 SURF 之类的算法从图像特征中提取描述符,以便我们可以在另一幅图像中匹配这些特征。

最*,我们还熟悉了另一种基于密码本或字典的描述符。 我们知道一个 SVM,该模型可以接受标记的描述符向量作为训练数据,可以找到描述符空间按给定类别的最佳划分,并可以预测新数据的类别。

有了这些知识,我们可以采用以下方法来构建分类器:

  1. 取得图像的样本数据集。
  2. 对于数据集中的每个图像,提取描述符(使用 SIFT,SURF,ORB 或类似算法)。
  3. 将每个描述符向量添加到 BoW 训练器中。
  4. 将描述符聚类为k聚类,其中心(质心)是我们的视觉单词。 最后一点听起来可能有些晦涩,但是我们将在下一部分中进一步探讨。

在此过程的最后,我们准备了一个视觉单词词典可供使用。 可以想象,庞大的数据集将使我们的词典中的视觉单词更加丰富。 到现在为止,单词越多越好!

训练完分类器后,我们应该继续对其进行测试。 好消息是测试过程在概念上与前面概述的训练过程非常相似。 给定一个测试图像,我们可以通过计算描述符到质心的距离的直方图来提取描述符并量化它们(或降低其维数)。 基于此,我们可以尝试识别视觉单词,并将其定位在图像中。

这就是本章的要点,在这里,您已经对更深入的实践示例产生了浓厚的兴趣,并且非常喜欢编码。 但是,在继续之前,让我们快速但必要地探讨k-均值聚类的理论,以便您可以完全理解视觉单词的创建方式。 从而,您将更好地了解使用 BoW 和 SVM 进行对象检测的过程。

K 均值聚类

k-均值聚类是一种量化方法,通过此方法,我们分析了大量向量,以找到少量聚类。 给定一个数据集,k代表该数据集将被划分为的群集数。 术语均值是指*均值或*均值的数学概念; 当以视觉方式表示时,群集的均值是其质心或群集中点的几何中心。

聚类是指将数据集中的点分组为聚类的过程。

OpenCV 提供了一个名为cv2.BOWKMeansTrainer的类,我们将使用它来帮助训练我们的分类器。 如您所料,OpenCV 文档提供了此类的以下摘要:

“基于 kmeans 的类,使用词袋方法来训练视觉词汇。”

在进行了长期的理论介绍之后,我们可以看一个示例,然后开始训练我们的自定义分类器。

检测汽车

要训​​练任何种类的分类器,我们必须首先创建或获取训练数据集。 我们将训练汽车探测器,因此我们的数据集必须包含代表汽车的正样本,以及代表检测器在寻找汽车时可能遇到的其他(非汽车)事物的负样本。 例如,如果检测器旨在搜索街道上的汽车,则路边,人行横道,行人或自行车的图片可能比土星环的图片更具代表性。 除了表示预期的主题外,理想情况下,训练样本还应表示我们的特定相机和算法看到主题的方式。

最终,在本章中,我们打算使用固定大小的滑动窗口,因此,重要的是,我们的训练样本必须符合固定大小,并且要对正样本进行严格裁剪以构架没有太多背景的汽车。

在一定程度上,我们希望随着我们不断添加良好的训练图像,分类器的准确率将会提高。 另一方面,较大的数据集会使训练变慢,并且可能过度训练分类器,从而无法推断超出训练集的分类器。 在本节的后面,我们将以一种允许我们轻松修改训练图像的数量的方式编写代码,以便通过实验找到合适的尺寸。

如果我们自己完成所有的工作,那么组装汽车图像数据集将是一项耗时的工作(尽管这完全是可行的)。 为了避免重新发明轮子或整个汽车,我们可以利用现成的数据集,例如:

让我们在示例中使用 UIUC 数据集。 获取此数据集并在脚本中使用它涉及几个步骤,因此让我们一一遍解它们,如下所示:

  1. 这个页面下载 UIUC 数据集。 将其解压缩到某个文件夹,我们将其称为<project_path>。 现在,解压缩的数据应该位于<project_path>/CarData处。 具体来说,我们将使用<project_path>/CarData/TrainImages<project_path>/CarData/TestImages中的某些图像。
  2. 同样在<project_path>中,我们创建一个名为detect_car_bow_svm.py的 Python 脚本。 要开始执行脚本,请编写以下代码以检查CarData子文件夹是否存在:
import cv2
import numpy as np
import os

if not os.path.isdir('CarData'):
    print(
        'CarData folder not found. Please download and unzip '
        'http://l2r.cs.uiuc.edu/~cogcomp/Data/Car/CarData.tar.gz '
        'into the same folder as this script.')
    exit(1)

如果您可以运行此脚本并且不打印任何内容,则表示所有内容均位于正确的位置。

  1. 接下来,让我们在脚本中定义以下常量:
BOW_NUM_TRAINING_SAMPLES_PER_CLASS = 10
SVM_NUM_TRAINING_SAMPLES_PER_CLASS = 100

请注意,我们的分类器将使用两个训练阶段:一个阶段用于 BoW 词汇表,它将使用多个图像作为样本,而另一个阶段则用于 SVM,它将使用多个 BoW 描述符向量作为样本。 我们随意地为每个阶段定义了不同数量的训练样本。 在每个阶段,我们还可以为两个类别(汽车非汽车)定义不同数量的训练样本,但是,我们将使用相同的数量。

  1. 我们将使用cv2.SIFT提取描述符,并使用cv2.FlannBasedMatcher匹配这些描述符。 让我们用以下代码初始化这些算法:
sift = cv2.xfeatures2d.SIFT_create()

FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = {}
flann = cv2.FlannBasedMatcher(index_params, search_params)

请注意,我们已经按照与第 6 章“图像检索和使用图像描述符的搜索”相同的方式,初始化了 SIFT 和用于*似最*邻的 FAST 库FLANN)。但是,这一次,描述符匹配不是我们的最终目标。 相反,它将成为 BoW 特征的一部分。

  1. OpenCV 提供了一个名为cv2.BOWKMeansTrainer的类来训练 BoW 词汇表,以及一个名为cv2.BOWImgDescriptorExtractor的类来将某种较低级的描述符(在我们的示例中为 SIFT 描述符)转换为 BoW 描述符。 让我们用以下代码初始化这些对象:
bow_kmeans_trainer = cv2.BOWKMeansTrainer(40)
bow_extractor = cv2.BOWImgDescriptorExtractor(sift, flann)

初始化cv2.BOWKMeansTrainer时,必须指定群集数-在我们的示例中为 40。在初始化cv2.BOWImgDescriptorExtractor时,必须指定描述符提取器和描述符匹配器-在我们的示例中为我们之前创建的cv2.SIFTcv2.FlannBasedMatcher对象。

  1. 为了训练 BoW 词汇,我们将提供各种汽车非汽车图像的 SIFT 描述符样本。 我们将从CarData/TrainImages子文件夹中加载图像,该图像包含名称为pos-x.pgm的正(汽车)图像和名称为诸如pos-x.pgm的负(非汽车)图像。 neg-x.pgm,其中x是从1开始的数字。 让我们编写以下实用函数,以返回到第i个正负训练图像的路径,其中i是一个以0开头的数字:
def get_pos_and_neg_paths(i):
    pos_path = 'CarData/Trainimg/pos-%d.pgm' % (i+1)
    neg_path = 'CarData/Trainimg/neg-%d.pgm' % (i+1)
    return pos_path, neg_path

在本节的稍后部分,当我们需要获取大量训练样本时,我们将使用i的变化值循环调用前面的函数。

  1. 对于训练样本的每条路径,我们将需要加载图像,提取 SIFT 描述符,并将描述符添加到 BoW 词汇表训练器中。 让我们编写另一个工具函数来精确地做到这一点,如下所示:
def add_sample(path):
    img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
    keypoints, descriptors = sift.detectAndCompute(img, None)
    if descriptors is not None:
        bow_kmeans_trainer.add(descriptors)

如果在图像中未找到特征,则keypointsdescriptors变量将为None

  1. 在此阶段,我们拥有开始训练 BoW 词汇表所需的一切。 让我们为每个类读取一些图像(汽车作为肯定类,非汽车作为否定类),并将它们添加到训练集中,如下所示:
for i in range(BOW_NUM_TRAINING_SAMPLES_PER_CLASS):
    pos_path, neg_path = get_pos_and_neg_paths(i)
    add_sample(pos_path)
    add_sample(neg_path)
  1. 现在我们已经组装了训练集,我们将调用词汇训练器的cluster方法,该方法执行k-均值分类并返回词汇表。 我们将把这个词汇分配给 BoW 描述符提取器,如下所示:
voc = bow_kmeans_trainer.cluster()
bow_extractor.setVocabulary(voc)

请记住,之前我们用 SIFT 描述符提取器和 FLANN 匹配器初始化了 BoW 描述符提取器。 现在,我们还为 BoW 描述符提取器提供了一个词汇,并使用 SIFT 描述符样本进行了训练。 在这个阶段,我们的 BoW 描述符提取器具有从高斯(DoG)特征中提取 BoW 描述符所需的一切。

请记住,cv2.SIFT检测 DoG 特征并提取 SIFT 描述符,正如我们在第 6 章,“检索图像并使用图像描述符”讨论的那样,特别是在“检测 DoG 特征和提取 SIFT 描述符”部分。

  1. 接下来,我们将声明另一个效用函数,该函数获取图像并返回 BoW 描述符提取器计算出的描述符向量。 这涉及提取图像的 DoG 特征,并根据 DoG 特征计算 BoW 描述符向量,如下所示:
def extract_bow_descriptors(img):
    features = sift.detect(img)
    return bow_extractor.compute(img, features)
  1. 我们准备组装另一种训练集,其中包含 BoW 描述符的样本。 让我们创建两个数组来容纳训练数据和标签,并用 BoW 描述符提取器生成的描述符填充它们。 我们将每个描述符向量标记为 1(正样本)和 -1(负样本),如以下代码块所示:
training_data = []
training_labels = []
for i in range(SVM_NUM_TRAINING_SAMPLES_PER_CLASS):
    pos_path, neg_path = get_pos_and_neg_paths(i)
    pos_img = cv2.imread(pos_path, cv2.IMREAD_GRAYSCALE)
    pos_descriptors = extract_bow_descriptors(pos_img)
    if pos_descriptors is not None:
        training_data.extend(pos_descriptors)
        training_labels.append(1)
    neg_img = cv2.imread(neg_path, cv2.IMREAD_GRAYSCALE)
    neg_descriptors = extract_bow_descriptors(neg_img)
    if neg_descriptors is not None:
        training_data.extend(neg_descriptors)
        training_labels.append(-1)

如果您希望训练一个分类器来区分多个肯定类,则可以简单地添加带有其他标签的其他描述符。 例如,我们可以训练一个分类器,该分类器将标签 1 用于汽车,将 2 用于,将 -1 用于背景。 不需要具有否定类或背景类,但如果没有,则分类器将假定一切都属于肯定类之一。

  1. OpenCV 提供了一个名为cv2.ml_SVM的类,表示一个 SVM。 让我们创建一个 SVM,并使用我们先前组装的数据和标签对其进行训练,如下所示:
svm = cv2.ml.SVM_create()
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
          np.array(training_labels))

请注意,在将训练数据和标签从列表转换为 NumPy 数组之前,必须将它们传递给cv2.ml_SVMtrain方法。

  1. 最后,我们准备通过对一些不属于训练集的图像进行分类来测试 SVM。 我们将遍历测试图像的路径列表。 对于每个路径,我们将加载图像,提取 BoW 描述符,并获得 SVM 的预测或分类结果,它们将是 1.0(汽车)或 -1.0(非汽车),具体取决于我们之前使用的训练标签。 我们将在图像上绘制文本以显示分类结果,并在窗口中显示图像。 显示所有图像后,我们将等待用户按下任意键,然后脚本将结束。 所有这些都是通过以下代码块实现的:
for test_img_path in ['CarData/Testimg/test-0.pgm',
                      'CarData/Testimg/test-1.pgm',
                      'img/car.jpg',
                      'img/haying.jpg',
                      'img/statue.jpg',
                      'img/woodcutters.jpg']:
    img = cv2.imread(test_img_path)
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    descriptors = extract_bow_descriptors(gray_img)
    prediction = svm.predict(descriptors)
    if prediction[1][0][0] == 1.0:
        text = 'car'
        color = (0, 255, 0)
    else:
        text = 'not car'
        color = (0, 0, 255)
    cv2.putText(img, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1,
                color, 2, cv2.LINE_AA)
    cv2.imshow(test_img_path, img)
cv2.waitKey(0)

保存并运行脚本。 您应该看到六个具有各种分类结果的窗口。 这是真实正面结果之一的屏幕截图:

下一个屏幕截图显示了真正的负面结果之一:

在我们的简单测试中的六张图像中,只有以下一张被错误分类:

尝试调整训练样本的数量,并尝试在更多图像上测试分类器,以查看可获得的结果。

让我们总结一下到目前为止所做的事情。 我们使用了 SIFT,BoW 和 SVM 的混合来训练分类器,以区分两个类别:汽车非汽车。 我们已将此分类器应用于整个图像。 下一步的逻辑步骤是应用滑动窗口技术,以便我们可以将分类结果缩小到图像的特定区域。

将 SVM 与滑动窗口结合

通过将我们的 SVM 分类器与滑动窗口技术和图像金字塔相结合,我们可以实现以下改进:

  • 检测图像中相同种类的多个对象。
  • 确定图像中每个检测到的对象的位置和大小。

我们将采用以下方法:

  1. 拍摄图像的一个区域,对其进行分类,然后将该窗口向右移动一个预定义的步长。 当我们到达图像的最右端时,将x坐标重置为 0,向下移动一步,然后重复整个过程。
  2. 在每个步骤中,请使用经过 BoW 训练的 SVM 执行分类。
  3. 根据 SVM,跟踪所有检测为正例的窗口。
  4. 在对整个图像中的每个窗口进行分类之后,将图像按比例缩小,然后重复使用滑动窗口的整个过程。 因此,我们正在使用图像金字塔。 继续重新缩放和分类,直到达到最小大小。

到此过程结束时,我们已经收集了有关图像内容的重要信息。 但是,存在一个问题:我们很可能已经发现了许多重叠的块,每个块都有很高的置信度。 也就是说,图像可以包含被多次检测的一个物体。 如果我们报告了这些检测结果,那么我们的报告将具有很大的误导性,因此我们将使用 NMS 筛选结果。

有关更新,您可能希望参考本章前面的“了解 NMS”部分。

接下来,让我们看一下如何修改和扩展前面的脚本,以实现我们刚刚描述的方法。

在场景中检测汽车

现在,我们已经准备好通过创建汽车检测脚本来应用到目前为止学到的所有概念,该脚本可以扫描图像并在汽车周围绘制矩形。 通过复制先前的脚本detect_car_bow_svm.py,创建一个新的 Python 脚本detect_car_bow_svm_sliding_window.py。 (我们之前在“检测汽车”部分中介绍了detect_car_bow_svm.py的实现。)新脚本的大部分实现将保持不变,因为我们仍然希望以几乎相同的方式训练 BoW 描述符提取器和 SVM 像我们以前一样。 但是,训练完成后,我们将以新的方式处理测试图像。 除了将每个图像整体分类之外,我们将每个图像分解为金字塔层和窗口,我们将每个窗口分类,然后将 NMS 应用于产生正面检测结果的窗口列表。

对于 NMS,我们将依靠 Malisiewicz 和 Rosebrock 的实现,如本章前面的“了解 NMS”部分中所述。 您可以在本书的 GitHub 存储库中找到其实现的略微修改的副本,尤其是在chapter7/non_max_suppression.py的 Python 脚本中。 该脚本提供具有以下签名的函数:

def non_max_suppression_fast(boxes, overlapThresh):

作为其第一个参数,该函数采用一个 NumPy 数组,其中包含矩形坐标和分数。 如果我们有N个矩形,则此数组的形状为Nx5。 对于索引为i的给定矩形,数组中的值具有以下含义:

  • boxes[i][0]是最左侧的x坐标。
  • boxes[i][1]是最高的y坐标。
  • boxes[i][2]是最右边的x坐标。
  • boxes[i][3]是最底端的y坐标。
  • boxes[i][4]是分数,其中分数越高表示矩形是正确的检测结果的可信度越高。

作为第二个参数,该函数采用一个阈值,该阈值表示矩形之间重叠的最大比例。 如果两个矩形的重叠比例大于此比例,则得分较低的矩形将被滤除。 最终,该函数将返回剩余矩形的数组。

现在,让我们将注意力转向对detect_car_bow_svm_sliding_window.py脚本的修改,如下所示:

  1. 首先,我们要为 NMS 函数添加一个新的import语句,如以下代码中的粗体所示:
import cv2
import numpy as np
import os

from non_max_suppression import non_max_suppression_fast as nms
  1. 让我们在脚本开头附*定义一些其他参数,如粗体所示:
BOW_NUM_TRAINING_SAMPLES_PER_CLASS = 10
SVM_NUM_TRAINING_SAMPLES_PER_CLASS = 100

SVM_SCORE_THRESHOLD = 1.8
NMS_OVERLAP_THRESHOLD = 0.15

我们将使用SVM_SCORE_THRESHOLD作为阈值来区分正窗口和负窗口。 我们将在本节稍后部分看到如何获得分数。 我们将使用NMS_OVERLAP_THRESHOLD作为 NMS 步骤中重叠的最大可接受比例。 在这里,我们任意选择了 15%,因此我们将剔除重叠超过此比例的窗口。 在试验 SVM 时,您可以根据自己的喜好调整这些参数,直到找到在应用中产生最佳结果的值。

  1. 我们将k-均值群集的数量从40减少到12(根据实验任意选择的数量),如下所示:
bow_kmeans_trainer = cv2.BOWKMeansTrainer(12)
  1. 我们还将调整 SVM 的参数,如下所示:
svm = cv2.ml.SVM_create()
svm.setType(cv2.ml.SVM_C_SVC)
svm.setC(50)
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
          np.array(training_labels))

通过对 SVM 的先前更改,我们指定了分类器的严格性或严重性级别。 随着C参数的值增加,误报的风险减少,但误报的风险增加。 在我们的应用中,假正例将是当其确实是非汽车时被检测为汽车的窗口,而假负例将是当它的实际汽车时被检测为非汽车的窗口。

在训练 SVM 的代码之后,我们想添加两个辅助函数。 基于滑动窗口技术,其中一个将生成图像金字塔的级别,而另一个将生成关注区域。 除了添加这些辅助函数外,我们还需要以不同的方式处理测试图像,以利用滑动窗口和 NMS。 以下步骤介绍了更改:

  1. 首先,让我们看一下处理图像金字塔的辅助函数。 以下代码块显示了此函数:
def pyramid(img, scale_factor=1.25, min_size=(200, 80),
            max_size=(600, 600)):
    h, w = img.shape
    min_w, min_h = min_size
    max_w, max_h = max_size
    while w >= min_w and h >= min_h:
        if w <= max_w and h <= max_h:
            yield img
        w /= scale_factor
        h /= scale_factor
        img = cv2.resize(img, (int(w), int(h)),
                         interpolation=cv2.INTER_AREA)

前面的函数获取图像并生成一系列调整大小的版本。 该系列受最大和最小图像尺寸的限制。

您会注意到,调整大小的图像不是通过return关键字返回的,而是通过yield关键字返回的。 这是因为此函数是所谓的生成器。 它产生一系列图像,我们可以轻松地在循环中使用它们。 如果您不熟悉生成器,请查看这个页面上的官方 Python Wiki。

  1. 接下来是基于滑动窗口技术生成兴趣区域的函数。 以下代码块显示了此函数:
def sliding_window(img, step=20, window_size=(100, 40)):
    img_h, img_w = img.shape
    window_w, window_h = window_size
    for y in range(0, img_w, step):
        for x in range(0, img_h, step):
            roi = img[y:y+window_h, x:x+window_w]
            roi_h, roi_w = roi.shape
            if roi_w == window_w and roi_h == window_h:
                yield (x, y, roi)

同样,这是一个生成器。 尽管有点嵌套,但是该机制非常简单:给定图像,返回左上角坐标和代表下一个窗口的子图像。 连续的窗口从左到右以任意大小的步长移动,直到我们到达一行的末尾;从顶部到底部,直到我们到达图像的末尾。

  1. 现在,让我们考虑对测试图像的处理。 与先前版本的脚本一样,我们循环浏览一系列路径以测试图像,以便加载和处理每个图像。 循环的开始保持不变。 对于上下文,这里是:
for test_img_path in ['CarData/Testimg/test-0.pgm',
                      'CarData/Testimg/test-1.pgm',
                      'img/car.jpg',
                      'img/haying.jpg',
                      'img/statue.jpg',
                      'img/woodcutters.jpg']:
    img = cv2.imread(test_img_path)
    gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  1. 对于每个测试图像,我们迭代金字塔级别,对于每个金字塔级别,我们迭代滑动窗口位置。 对于每个窗口或兴趣区域ROI),我们提取 BoW 描述符并使用 SVM 对它们进行分类。 如果分类产生的正例结果通过了一定的置信度阈值,则将矩形的角坐标和置信度得分添加到正面检测列表中。 从上一个代码块继续,我们继续使用以下代码处理给定的测试图像:
    pos_rects = []
    for resized in pyramid(gray_img):
        for x, y, roi in sliding_window(resized):
            descriptors = extract_bow_descriptors(roi)
            if descriptors is None:
                continue
            prediction = svm.predict(descriptors)
            if prediction[1][0][0] == 1.0:
                raw_prediction = svm.predict(
                    descriptors, 
                    flags=cv2.ml.STAT_MODEL_RAW_OUTPUT)
                score = -raw_prediction[1][0][0]
                if score > SVM_SCORE_THRESHOLD:
                    h, w = roi.shape
                    scale = gray_img.shape[0] / \
                        float(resized.shape[0])
                    pos_rects.append([int(x * scale),
                                      int(y * scale),
                                      int((x+w) * scale),
                                      int((y+h) * scale),
                                      score])

让我们注意一下前面代码中的两个复杂性,如下所示:

到目前为止,我们已经在各种规模和位置进行了汽车检测; 结果,我们有了一个检测到的汽车矩形的列表,包括坐标和分数。 我们期望在此矩形列表内有很多重叠。

  1. 现在,让我们调用 NMS 函数,以便在重叠的情况下挑选得分最高的矩形,如下所示:
 pos_rects = nms(np.array(pos_rects), NMS_OVERLAP_THRESHOLD)

请注意,我们已经将矩形坐标和分数列表转换为 NumPy 数组,这是该函数期望的格式。

在此阶段,我们有一系列检测到的汽车矩形及其得分,并且我们确保了这些是我们可以选择的最佳非重叠检测(在模型的参数范围内)。

  1. 现在,通过在代码中添加以下内部循环来绘制矩形及其分数:
 for x0, y0, x1, y1, score in pos_rects:
 cv2.rectangle(img, (int(x0), int(y0)), (int(x1), int(y1)),
 (0, 255, 255), 2)
 text = '%.2f' % score
 cv2.putText(img, text, (int(x0), int(y0) - 20),
 cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)

与该脚本的先前版本一样,外部循环的主体通过显示当前的测试图像(包括我们在其上绘制的标注)来结束。 循环遍历所有测试图像后,我们等待用户按下任意键; 然后,程序结束,如下所示:

    cv2.imshow(test_img_path, img)
cv2.waitKey(0)

让我们运行修改后的脚本,看看它能如何回答永恒的问题:杜德,我的车在哪里?

以下屏幕截图显示了成功的检测:

我们的另一个测试图像中有两辆车。 碰巧的是,成功检测到一辆汽车,而另一辆则未成功,如以下屏幕截图所示:

有时,其中具有许多特征的背景区域被错误地检测为汽车。 这是一个例子:

请记住,在此示例脚本中,我们的训练集很小。 具有更大背景的更大训练集可以改善结果。 另外,请记住,图像金字塔和滑动窗口会产生大量的 ROI。 考虑这一点时,我们应该意识到检测器的误报率实际上很低。 如果我们要对视频的帧执行检测,则可以通过过滤掉仅出现在单个帧或几个帧中而不是一系列任意的最小长度的检测,来进一步降低误报率。

随意尝试上述脚本的参数和训练集。 当您准备就绪时,让我们用一些结束语来结束本章。

保存和加载经过训练的 SVM

关于 SVM 的最后一条建议是:您不需要每次使用探测器时都对它进行训练–实际上,由于训练速度很慢,因此您应该避免这样做。 您可以使用以下代码将经过训练的 SVM 模型保存到 XML 文件:

svm = cv2.ml.SVM_create()
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
          np.array(training_labels))
svm.save('my_svm.xml')

随后,您可以使用以下代码重新加载经过训练的 SVM:

svm = cv2.ml.SVM_create()
svm.load('my_svm.xml')

通常,您可能有一个脚本用于训练和保存 SVM 模型,而其他脚本则可以加载和使用它来解决各种检测问题。

总结

在本章中,我们涵盖了广泛的概念和技术,包括 HOG,BoW,SVM,图像金字塔,滑动窗口和 NMS。 我们了解到这些技术在对象检测以及其他领域中都有应用。 我们编写了一个脚本,该脚本结合了 BoW,SVM,图像金字塔,滑动窗口和 NMS 等大多数技术,并且通过训练和测试自定义检测器,在机器学习中获得了实践经验。 最后,我们证明了我们可以检测到汽车!

我们的新知识构成下一章的基础,在下一章中,我们将对视频中的帧序列利用对象检测和分类技术。 我们将学习如何跟踪对象并保留有关它们的信息-这是许多实际应用中的重要目标。

八、追踪对象

在本章中,我们将从对象跟踪的广泛主题中探索一系列技术,这是在电影或来自摄像机的视频馈送中定位运动对象的过程。 实时对象跟踪是许多计算机视觉应用中的关键任务,例如监视,感知用户界面,增强现实,基于对象的视频压缩和驾驶员辅助。

跟踪对象可以通过多种方式来完成,而最佳技术则很大程度上取决于手头的任务。 在研究此主题时,我们将采取以下路线:

  • 根据当前帧和代表背景的帧之间的差异检测运动对象。 首先,我们将尝试这种方法的简单实现。 然后,我们将使用 OpenCV 的更高级算法的实现,即高斯混合MOG)和 K 最*邻KNN)背景减法器。 我们还将考虑如何修改脚本以使用 OpenCV 支持的任何其他背景减法器,例如 Godbehere-Matsukawa-GoldbergGMG)背景减法器。
  • 根据对象的颜色直方图跟踪移动的对象。 这种方法涉及直方图反投影,这是计算各个图像区域和直方图之间相似度的过程。 换句话说,直方图用作我们期望对象外观的模板。 我们将使用称为 MeanShift 和 CamShift 的跟踪算法,这些算法对直方图反投影的结果进行运算。
  • 使用卡尔曼过滤器查找对象运动的趋势,并预测对象下一步的移动方向。
  • 回顾 OpenCV 支持面向对象编程OOP)范式的方式,并考虑这与函数式编程FP)范例有何不同。
  • 实现结合了 KNN 背景减法,MeanShift 和卡尔曼滤波的行人跟踪器。

如果您已按顺序阅读本书,那么到本章结束时,您将了解许多以 2D 形式描述,检测,分类和跟踪对象的方法。 届时,您应该准备在第 9 章,“摄像机模型和增强现实”中进行 3D 跟踪。

技术要求

本章使用 Python,OpenCV 和 NumPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”。

可在本书的 GitHub 存储库中找到本章的完整代码和示例视频,位于chapter08文件夹中。

通过背景减法检测运动物体

要跟踪视频中的任何内容,首先,我们必须确定视频帧中与移动对象相对应的区域。 许多运动检测技术都基于背景减法的简单概念。 例如,假设我们有一台固定的摄像机来观看也基本上静止的场景。 除此之外,假设相机的曝光和场景中的照明条件是稳定的,以使帧的亮度变化不大。 在这些条件下,我们可以轻松捕获代表背景的参考图像,换句话说,就是场景的静止部分。 然后,无论何时摄像机捕获新帧,我们都可以从参考图像中减去该帧,并取该差的绝对值,以便获得该帧中每个像素位置的运动测量值。 如果帧的任何区域与参考图像有很大不同,我们可以得出结论,给定区域是运动对象。

背景减法技术通常具有以下局限性:

  • 摄像机的任何运动,曝光变化或照明条件的变化都可能导致整个场景中的像素值立即发生变化。 因此,整个背景模型(或参考图像)已过时。
  • 如果某个对象进入场景,然后在该场景中停留很长一段时间,那么一部分背景模型可能会过时。 例如,假设我们的场景是走廊。 有人进入走廊,将海报放在墙上,然后将海报留在那里。 实际上,海报实际上只是固定背景的另一部分。 但是,它不是我们参考图像的一部分,因此我们的背景模型已经过时了。

这些问题表明需要基于一系列新帧动态更新背景模型。 先进的背景减法技术试图以多种方式解决这一需求。

另一个普遍的限制是阴影和固体对象可能以类似方式影响背景减法器。 例如,由于我们无法将物体与其阴影区分开来,因此我们可能无法获得运动物体的大小和形状的准确图片。 但是,先进的背景减法技术确实尝试使用各种方法来区分阴影区域和实体对象。

背景减法器通常还有另一个局限性:它们无法对其检测到的运动类型提供细粒度的控制。 例如,如果场景显示地铁车在其轨道上行驶时不断晃动,则此重复动作将影响背景减法器。 出于实际目的,我们可以将地铁的振动视为半静止背景下的正常变化。 我们甚至可能知道这些振动的频率。 但是,背景减法器不会嵌入有关运动频率的任何信息,因此它没有提供方便或精确的方法来滤除此类可预测的运动。 为了弥补这些缺点,我们可以应用预处理步骤,例如模糊参考图像,也可以模糊每个新帧。 以这种方式,尽管以不太直观,有效或精确的方式抑制了某些频率。

分析运动频率超出了本书的范围。 但是,有关在计算机视觉环境中对此主题的介绍,请参阅 Joseph Howse 的书《写给秘密特工的 OpenCV 4》(Packt Publishing,2019),特别是第 7 章“用运动放大相机观看心跳”。

现在,我们已经对背景减法进行了概述,并了解了背景减法面临的一些障碍,让我们研究一下背景减法的实现效果如何。 我们将从一个简单但不鲁棒的实现开始,我们可以编写几行代码,然后发展到 OpenCV 为我们提供的更复杂的替代方案。

实现基本的背景减法器

为了实现基本的背景减法器,让我们采用以下方法:

  1. 开始从相机捕获帧。
  2. 丢弃九帧,以便相机有时间适当调整其自动曝光以适合场景中的照明条件。
  3. 拍摄第 10 帧,将其转换为灰度,对其进行模糊处理,然后将此模糊图像用作背景的参考图像。
  4. 对于每个后续帧,请对该帧进行模糊处理,然后将其转换为灰度,然后计算该模糊帧与背景参考图像之间的绝对差。 对差异图像执行阈值化,*滑和轮廓检测。 绘制并显示主要轮廓的边界框。

高斯模糊的使用应该使我们的背景减法器不易受到小振动以及数字噪声的影响。 形态学操作也提供了这些好处。

要模糊图像,我们将使用高斯模糊算法,该算法最初在第 3 章“使用 OpenCV 处理图像”中,特别是在“HPF 和 LPF”部分中讨论过。 为了使阈值图像*滑,我们将使用形态学侵蚀和膨胀,我们最初在第 4 章,“深度估计和分割”中讨论过,特别是在“使用分水岭算法进行图像分割”部分中。 轮廓检测和边界框也是我们在第 3 章“使用 OpenCV 处理图像”,特别是在“轮廓检测”部分中介绍的主题。

将前面的列表扩展为更小的步骤,我们可以考虑在八个顺序的代码块中执行脚本:

  1. 让我们开始导入 OpenCV 并为blurerodedilate操作定义核的大小:
import cv2

BLUR_RADIUS = 21
erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
dilate_kernel = cv2.getStructuringElement(
    cv2.MORPH_ELLIPSE, (9, 9))
  1. 现在,让我们尝试从相机捕获 10 帧:
cap = cv2.VideoCapture(0)

# Capture several frames to allow the camera's autoexposure to adjust.
for i in range(10):
    success, frame = cap.read()
if not success:
    exit(1)
  1. 如果我们无法捕获 10 帧,则退出。 否则,我们将第 10 帧转换为灰度并对其进行模糊处理:
gray_background = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray_background = cv2.GaussianBlur(gray_background,
                                   (BLUR_RADIUS, BLUR_RADIUS), 0)
  1. 在这一阶段,我们有背景的参考图像。 现在,让我们继续捕获更多帧,以检测运动。 我们对每一帧的处理都是从灰度转换和高斯模糊运算开始的:
success, frame = cap.read()
while success:

    gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    gray_frame = cv2.GaussianBlur(gray_frame,
                                  (BLUR_RADIUS, BLUR_RADIUS), 0)
  1. 现在,我们可以将当前帧的模糊灰度版本与背景图像的模糊灰度版本进行比较。 具体来说,我们将使用 OpenCV 的cv2.absdiff函数查找这两个图像之间差异的绝对值(或大小)。 然后,我们将应用阈值以获得纯黑白图像,并应用形态学操作来*滑阈值图像。 以下是相关代码:
diff = cv2.absdiff(gray_background, gray_frame)
_, thresh = cv2.threshold(diff, 40, 255, cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)
  1. 在这一点上,如果我们的技术运行良好,则在有运动物体的任何地方,我们的阈值图像都应包含白色斑点。 现在,我们要查找白色斑点的轮廓并在其周围绘制边界框。 作为过滤掉可能不是真实物体的细微变化的另一种方法,我们将基于轮廓的面积应用阈值。 如果轮廓线太小,我们可以得出结论,它不是真正的运动物体。 (当然,“定义太小”可能会因相机的分辨率和应用而异;在某些情况下,您可能根本不希望进行此测试。)以下代码用于检测轮廓和绘制边界框:
_, contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
                                     cv2.CHAIN_APPROX_SIMPLE)

for c in contours:
    if cv2.contourArea(c) > 4000:
        x, y, w, h = cv2.boundingRect(c)
        cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)
  1. 现在,让我们用边界矩形显示差异图像,阈值图像和检测结果:
cv2.imshow('diff', diff)
cv2.imshow('thresh', thresh)
cv2.imshow('detection', frame)
  1. 我们将继续读取帧,直到用户按下Esc键退出为止:
k = cv2.waitKey(1)
if k == 27: # Escape
    break

success, frame = cap.read()

在那里,您便拥有了一个基本的运动检测器,它可以在移动物体周围绘制矩形。 最终结果是这样的:

为了使用此脚本获得良好的效果,请确保在初始化背景图像之后,您(和其他移动物体)才进入摄像机的视野。

对于这种简单的技术,此结果很有希望。 但是,我们的脚本不努力动态地更新背景图像,因此如果相机移动或照明发生变化,它将很快过时。 因此,我们应该继续使用更加灵活和智能的背景减法器。 幸运的是,OpenCV 提供了几个现成的背景减法器供我们使用。 我们将从实现 MOG 算法的算法开始。

使用 MOG 背景减法器

OpenCV 提供了一个名为cv2.BackgroundSubtractor的类,该类具有实现各种背景减法算法的各种子类。

您可能还记得,我们之前在第 4 章,“深度估计和分段”中,特别是在“GrabCut 算法的前景检测”部分中,使用了 OpenCV 的 GrabCut 算法来执行前景/背景分割。 像cv2.grabCut一样,cv2.BackgroundSubtractor的各种子类实现也可以产生一个掩码,该掩码将不同的值分配给图像的不同段。 具体来说,背景减法器可以将前景段标记为白色(即 255 的 8 位灰度值),将背景段标记为黑色(0),将阴影段标记为灰色(127)。 此外,与 GrabCut 不同的是,背景减法器会随着时间的推移更新前景/背景模型,通常是通过将机器学习应用于一系列帧来实现的。 许多背景减法器是根据统计聚类技术命名的,它们是基于它们的机器学习方法的。 因此,我们将首先查看基于 MOG 聚类技术的背景减法器。

OpenCV 具有 MOG 背景减法器的两种实现。 也许不足为奇,它们被命名为cv2.BackgroundSubtractorMOGcv2.BackgroundSubtractorMOG2。 后者是更新的实现,它增加了对阴影检测的支持,因此我们将使用它。

首先,让我们以上一节中的基本背景减除脚本为基础。 我们将对其进行以下修改:

  1. 用 MOG 背景减法器替换我们的基本背景减法模型。
  2. 作为输入,请使用视频文件而不是摄像机。
  3. 取消使用高斯模糊。
  4. 调整阈值,形态和轮廓分析步骤中使用的参数。

这些修改会影响几行代码,这些代码分散在整个脚本中。 在脚本顶部附*,让我们初始化 MOG 背景减法器并修改形态核的大小,如以下代码块中的粗体所示:

import cv2

bg_subtractor = cv2.createBackgroundSubtractorMOG2(detectShadows=True)

erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))

请注意,OpenCV 提供了cv2.createBackgroundSubtractorMOG2函数来创建cv2.BackgroundSubtractorMOG2的实例。 该函数接受参数detectShadows,我们将其设置为True,这样阴影区域将被标记为此类,而不标记为前景的一部分。

其余更改(包括使用 MOG 背景减法器获取前景/阴影/背景遮罩)在以下代码块中以粗体标记:

cap = cv2.VideoCapture('hallway.mpg')
success, frame = cap.read()
while success:

    fg_mask = bg_subtractor.apply(frame)

    _, thresh = cv2.threshold(fg_mask, 244, 255, cv2.THRESH_BINARY)
    cv2.erode(thresh, erode_kernel, thresh, iterations=2)
    cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)

    contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
                                      cv2.CHAIN_APPROX_SIMPLE)

    for c in contours:
        if cv2.contourArea(c) > 1000:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)

    cv2.imshow('mog', fg_mask)
    cv2.imshow('thresh', thresh)
    cv2.imshow('detection', frame)

    k = cv2.waitKey(30)
    if k == 27:  # Escape
        break

    success, frame = cap.read()

当我们将帧传递给背景减法器的apply方法时,减法器更新其背景的内部模型,然后返回掩码。 如前所述,对于前景段,遮罩为白色(255),对于阴影段为灰色(127),对于背景段为黑色(0)。 出于我们的目的,我们将阴影视为背景,因此我们向遮罩应用了接*白色的阈值(244)。

以下屏幕截图显示了来自 MOG 检测器的遮罩(左上图),该遮罩的阈值和变形版本(右上图)以及检测结果(下图):

为了进行比较,如果通过设置detectShadows=False禁用阴影检测,我们将获得诸如以下屏幕截图的结果:

由于抛光的地板和墙壁,该场景不仅包含阴影,还包含反射。 启用阴影检测后,我们可以使用阈值去除遮罩中的阴影和反射,从而使我们在大厅中的人周围有一个准确的检测矩形。 但是,当禁用阴影检测时,我们可以进行两种检测,这两种检测都可以说是不准确的。 一种检测覆盖了该人,他的阴影以及他在地板上的反射。 第二次检测覆盖了该人在墙上的反射。 这些可以说是不准确的检测结果,因为人的阴影和反射并不是真正的移动物体,即使它们是移动物体的视觉伪像。

到目前为止,我们已经看到,背景减法脚本可以非常简洁,并且进行一些小改动就可以大大改变算法和结果,无论是好是坏。 以同样的方式继续进行下去,让我们看看我们如何轻松修改代码以使用 OpenCV 的另一种高级背景减法器来查找另一种运动对象。

使用 KNN 背景减法器

通过仅在 MOG 背景减法脚本中修改五行代码,我们可以使用不同的背景减法算法,不同的形态参数以及不同的视频作为输入。 借助 OpenCV 提供的高级接口,即使是这些简单的更改,也使我们能够成功处理各种后台扣除任务。

只需将cv2.createBackgroundSubtractorMOG2替换为cv2.createBackgroundSubtractorKNN,我们就可以使用基于 KNN 聚类而非 MOG 聚类的背景减法器:

bg_subtractor = cv2.createBackgroundSubtractorKNN(detectShadows=True)

请注意,尽管算法有所变化,但仍支持detectShadows参数。 此外,apply方法仍然受支持,因此我们在脚本的后面不需要更改与使用背景减法器有关的任何内容。

请记住,cv2.createBackgroundSubtractorMOG2返回cv2.BackgroundSubtractorMOG2类的新实例。 同样,cv2.createBackgroundSubtractorKNN返回cv2.BackgroundSubtractorKNN类的新实例。 这两个类都是cv2.BackgroundSubtractor的子类,它定义了apply之类的常用方法。

进行以下更改后,我们可以使用形态核,这些核稍微更适合水*拉长的物体(在本例中为汽车),并且可以使用交通视频作为输入:

erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 5))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 11))

cap = cv2.VideoCapture('traffic.flv')

为了反映算法的变化,让我们将遮罩窗口的标题从'mog'更改为'knn'

    cv2.imshow('knn', fg_mask)

以下屏幕截图显示了运动检测的结果:

KNN 背景减法器及其在对象和阴影之间进行区分的功能在这里效果很好。 所有汽车都被单独检测到; 即使有些汽车彼此靠*,也没有将它们合并为一个检测。 对于五分之三的汽车,检测矩形是准确的。 对于视频帧左下角的深色汽车,背景减法器无法完全区分汽车的后部和沥青。 对于框架顶部中央部分的白色汽车,背景减法器无法将汽车及其阴影与道路上的白色标记完全区分开。 尽管如此,总的来说,这是一个有用的检测结果,可以使我们计算每个车道上行驶的汽车数量。

如我们所见,脚本上的一些简单变体可以产生非常不同的背景减法结果。 让我们考虑如何进一步探索这一观察。

使用 GMG 和其他背景减法器

您可以自由尝试对我们的背景减法脚本进行自己的修改。 如果已经通过可选的opencv_contrib模块获得了 OpenCV,如第 1 章,“设置 OpenCV”中所述,则cv2.bgsegm模块中还可以使用几个背景减法器 。 可以使用以下函数创建它们:

  • cv2.bgsegm.createBackgroundSubtractorCNT
  • cv2.bgsegm.createBackgroundSubtractorGMG
  • cv2.bgsegm.createBackgroundSubtractorGSOC
  • cv2.bgsegm.createBackgroundSubtractorLSBP
  • cv2.bgsegm.createBackgroundSubtractorMOG
  • cv2.bgsegm.createSyntheticSequenceGenerator

这些函数不支持detectShadows参数,它们创建不支持阴影检测的背景减法器。 但是,所有背景减法器都支持apply方法。

作为如何修改背景减法样本以使用前面列表中的cv2.bgsegm减法器之一的示例,让我们使用 GMG 背景减法器。 在以下代码块中,相关的修改以粗体突出显示:

import cv2

bg_subtractor = cv2.bgsegm.createBackgroundSubtractorGMG()

erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 9))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 11))

cap = cv2.VideoCapture('traffic.flv')
success, frame = cap.read()
while success:

    fg_mask = bg_subtractor.apply(frame)

    _, thresh = cv2.threshold(fg_mask, 244, 255, cv2.THRESH_BINARY)
    cv2.erode(thresh, erode_kernel, thresh, iterations=2)
    cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)

    contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
                                      cv2.CHAIN_APPROX_SIMPLE)

    for c in contours:
        if cv2.contourArea(c) > 1000:
            x, y, w, h = cv2.boundingRect(c)
            cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)

    cv2.imshow('gmg', fg_mask)
    cv2.imshow('thresh', thresh)
    cv2.imshow('detection', frame)

    k = cv2.waitKey(30)
    if k == 27: # Escape
        break

    success, frame = cap.read()

请注意,这些修改类似于我们在上一节“使用 KNN 背景减法器”中看到的修改。 我们只需要使用一个不同的函数来创建 GMG 减法器,就可以将形态核的大小调整为更适合该算法的值,然后将其中一个窗口标题更改为'gmg'

GMG 算法以其作者 Andrew B. Godbehere,Akihiro Matsukawa 和 Ken Goldberg 的名字命名。 他们在论文《在可变照明条件下对观众进行视觉跟踪以进行响应式音频艺术装置》(ACC,2012)中进行了描述,该论文可从这个页面。 GMG 背景减法器在开始生成带有白色(对象)区域的遮罩之前,需要花费一些帧来初始化自身。

与 KNN 背景减法器相比,GMG 背景减法器在我们的交通示例视频中产生的效果更差。 部分原因是 OpenCV 的 GMG 实现无法区分阴影和固体物体,因此检测矩形在汽车的阴影或反射方向上拉长。 这是输出示例:

在完成背景减法器的实验后,让我们继续研究其他跟踪技术,这些技术依赖于我们要跟踪的对象的模板而不是背景的模板。

使用 MeanShift 和 CamShift 跟踪彩色物体

我们已经看到,背景减法可以成为检测运动物体的有效技术。 但是,我们知道它有一些固有的局限性。 值得注意的是,它假定可以基于过去的帧来预测当前背景。 这个假设是脆弱的。 例如,如果照相机移动,则整个背景模型可能突然过时。 因此,在鲁棒的跟踪系统中,重要的是建立某种前景对象模型,而不仅仅是背景模型。

我们已经在第 5 章,“检测和识别人脸”,第 6 章,“检索图像和使用图像描述符进行搜索”中和第 7 章,“构建自定义对象检测器”。 对于物体检测,我们偏爱可以处理一类物体内大量变化的算法,因此我们的汽车检测器不太会检测其形状或颜色。 对于跟踪的对象,我们的需求有所不同。 如果要跟踪汽车,则我们希望场景中的每辆汽车都具有不同的模型,以免红色汽车和蓝色汽车混淆。 我们想分别跟踪每辆车的运动。

一旦检测到移动物体(通过背景减法或其他方式),我们便要以与其他移动物体不同的方式描述该物体。 这样,即使物体与另一个运动物体交叉,我们也可以继续识别和跟踪物体。 颜色直方图可以用作足够独特的描述。 本质上,对象的颜色直方图是对对象中像素颜色的概率分布的估计。 例如,直方图可以指示对象中的每个像素都是蓝色的可能性为 10%。 直方图基于在参考图像的对象区域中观察到的实际颜色。 例如,参考图像可以是我们首先在其中检测到运动对象的视频帧。

与其他描述对象的方式相比,颜色直方图具有一些在运动跟踪方面特别吸引人的属性。 直方图用作直接将像素值映射到概率的查找表,因此它使我们能够以较低的计算成本将每个像素用作特征。 这样,我们可以实时地以非常精细的空间分辨率执行跟踪。 为了找到我们正在跟踪的对象的最可能位置,我们只需要根据直方图找到像素值映射到最大概率的兴趣区域。

自然地,这种方法被具有醒目的名称:MeanShift 的算法所利用。 对于视频中的每个帧,MeanShift 算法通过基于当前跟踪矩形中的概率值计算质心,将矩形的中心移至该质心,基于新矩形中的值重新计算质心,再次移动矩形来进行迭代跟踪 , 等等。 此过程一直持续到收敛达到(意味着质心停止移动或几乎停止移动)或直到达到最大迭代次数为止。 本质上,MeanShift 是一种聚类算法,其应用扩展到了计算机视觉之外。 该算法首先由 K.Fukunaga 和 L.Hostetler 在题为《密度函数梯度的估计及其在模式识别》(IEEE,1975)中的应用中进行了描述。 IEEE 订户可以通过这个页面获得该论文。

在研究示例脚本之前,让我们考虑一下要通过 MeanShift 实现的跟踪结果的类型,并让我们进一步了解 OpenCV 与颜色直方图有关的功能。

规划我们的 MeanShift 示例

对于 MeanShift 的首次演示,我们不关心移动物体的初始检测方法。 我们将采用幼稚的方法,该方法只是选择第一个视频帧的中心部分作为我们感兴趣的初始区域。 (用户必须确保感兴趣的对象最初位于视频的中心。)我们将计算该感兴趣的初始区域的直方图。 然后,在随后的帧中,我们将使用此直方图和 MeanShift 算法来跟踪对象。

在视觉上,MeanShift 演示将类似于我们先前编写的许多对象检测示例。 对于每一帧,我们将在跟踪矩形周围绘制一个蓝色轮廓,如下所示:

在此,玩具电话具有淡紫色,在场景中的任何其他对象中都不存在。 因此,电话具有独特的直方图,因此易于跟踪。 接下来,让我们考虑如何计算直方图,然后将其用作概率查找表。

计算和反投影颜色直方图

为了计算颜色直方图,OpenCV 提供了一个称为cv2.calcHist的函数。 要将直方图用作查找表,OpenCV 提供了另一个名为cv2.calcBackProject的函数。 后者的操作称为直方图反投影,它将基于给定的直方图将给定的图像转换为概率图。 让我们首先可视化这两个函数的输出,然后检查它们的参数。

直方图可以使用任何颜色模型,例如蓝绿红BGR),色相饱和度值HSV)或灰度。 (有关颜色模型的介绍,请参阅第 3 章,“用 OpenCV 处理图像”,特别是“在不同颜色模型之间转换图像”部分。) ,我们将仅使用 HSV 颜色模型的色相(H)通道的直方图。 下图是色调直方图的可视化:

该直方图可视化是来自名为 DPEx 的图像查看应用输出的示例。

在此图的x轴上,有色相,在y轴上,有色相的估计概率,换句话说,就是图像中具有给定的色调的像素比例。 如果您正在阅读本书的电子书版本,则将看到该图根据色相进行了颜色编码。 从左到右,绘图通过色轮的色调进行:红色,黄色,绿色,青色,蓝色,洋红色,最后回到红色。 这个特殊的直方图似乎代表了一个带有很多黄色的物体。

OpenCV 表示 H 值,范围从 0 到 179。某些其他系统使用的范围是 0 到 359(如圆的度数)或 0 到 255。

由于纯黑色和纯白色像素没有有意义的色相,因此在解释色相直方图时需要格外小心。 但是,它们的色相通常表示为 0(红色)。

当我们使用cv2.calcHist生成色调直方图时,它将返回一个在概念上与前面的图相似的一维数组。 或者,根据我们提供的参数,我们可以使用cv2.calcHist生成另一个通道或两个通道的直方图。 在后一种情况下,cv2.calcHist将返回 2D 数组。

有了直方图后,我们可以将直方图反向投影到任何图像上。 cv2.calcBackProject产生 8 位灰度图像格式的反投影,其像素值的范围可能为 0(表示低概率)到 255(表示高概率),具体取决于我们如何缩放这些值。 例如,考虑以下两张照片,分别显示背投和 MeanShift 跟踪结果的可视化:

在这里,我们正在跟踪一个主要颜色为黄色,红色和棕色的小物体。 在实际上是对象一部分的区域中,背投影最亮。 在其他类似颜色的区域中,背投投影也有些明亮,例如约瑟夫·霍斯(Joseph Howse)的棕色胡须,他的眼镜的黄框以及背景中海报之一的红色边框。

现在我们已经可视化了cv2.calcHistcv2.calcBackProject的输出,让我们检查这些函数接受的参数。

了解cv2.calcHist的参数

cv2.calcHist函数具有以下签名:

calcHist(images, channels, mask, histSize, ranges[, hist[,
         accumulate]]) -> hist

下表包含参数的说明(改编自 OpenCV 官方文档):

参数 说明
images 此参数是一个或多个源图像的列表。 它们都应具有相同的位深度(8 位,16 位或 32 位)和相同的大小。
channels 此参数是用于计算直方图的通道索引的列表。 例如,channels=[0]表示仅使用第一个通道(即索引为0的通道)来计算直方图。
mask 此参数是掩码。 如果为None,则不执行任何屏蔽操作; 图像的每个区域都用于直方图计算中。 如果不是None,则它必须是与images中每个图像大小相同的 8 位数组。 遮罩的非零元素标记应在直方图计算中使用的图像区域。
histSize 此参数是每个通道要使用的直方图箱数的列表。 histSize列表的长度必须与channels列表的长度相同。 例如,如果channels=[0]histSize=[180],则直方图对于第一个通道具有 180 个箱子(并且未使用任何其他通道)。
ranges 此参数是一个列表,该列表指定每个通道要使用的值的范围(包括下限和排除上限)。 ranges列表的长度必须是channels列表的长度的两倍。 例如,如果channels=[0]histSize=[180]ranges=[0, 180],则直方图的第一个通道具有 180 个箱子,这些箱子基于 0 到 179 范围内的值; 换句话说,每个仓位只有一个输入值。
hist 此可选参数是输出直方图。 如果它是None(默认值),则将返回一个新数组作为输出直方图。
accumulate 此可选参数是accumulate标志。 默认情况下为False。 如果是True,则不会清除hist的原始内容; 而是将新的直方图添加到hist的原始内容中。 使用此功能,您可以从多个图像列表中计算单个直方图,或者随时间更新直方图。

在我们的样本中,我们将像这样计算兴趣区域的色相直方图:

roi_hist = cv2.calcHist([hsv_roi], [0], mask, [180], [0, 180])

接下来,让我们考虑cv2.calcBackProject的参数。

了解cv2.calcBackProject的参数

cv2.calcBackProject函数具有以下签名:

calcBackProject(images, channels, hist, ranges,
                scale[, dst]) -> dst

下表包含参数的说明(改编自 OpenCV 官方文档):

参数 说明
images 此参数是一个或多个源图像的列表。 它们都应具有相同的位深度(8 位,16 位或 32 位)和相同的大小。
channels 此参数必须与calcHist中使用的channels参数相同。
hist 此参数是直方图。
ranges 此参数必须与calcHist中使用的ranges参数相同。
scale 此参数是比例因子。 反投影乘以该比例因子。
dst 此可选参数是输出反投影。 如果它是None(默认值),将返回一个新数组作为反投影。

在我们的示例中,我们将使用类似于以下行的代码将色相直方图反向投影到 HSV 图像上:

back_proj = cv2.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)

在详细研究了cv2.calcHistcv2.calcBackProject函数之后,现在让我们在使用 MeanShift 进行跟踪的脚本中将它们付诸实践。

实现 MeanShift 示例

让我们依次研究一下 MeanShift 示例的实现:

  1. 像我们的基本背景减除示例一样,MeanShift 示例从捕获(并丢弃)相机的几帧开始,以便自动曝光可以调整:
import cv2

cap = cv2.VideoCapture(0)

# Capture several frames to allow the camera's autoexposure to 
# adjust.
for i in range(10):
    success, frame = cap.read()
if not success:
    exit(1)
  1. 到第 10 帧,我们假设曝光良好; 因此,我们可以提取兴趣区域的准确直方图。 以下代码定义了兴趣区域ROI)的边界:
# Define an initial tracking window in the center of the frame.
frame_h, frame_w = frame.shape[:2]
w = frame_w//8
h = frame_h//8
x = frame_w//2 - w//2
y = frame_h//2 - h//2
track_window = (x, y, w, h)
  1. 然后,以下代码选择 ROI 的像素并将其转换为 HSV 颜色空间:
roi = frame[y:y+h, x:x+w]
hsv_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
  1. 接下来,我们计算 ROI 的色相直方图:
mask = None
roi_hist = cv2.calcHist([hsv_roi], [0], mask, [180], [0, 180])
  1. 在计算直方图之后,我们将值归一化为 0 到 255 之间的范围:
cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)
  1. 请记住,MeanShift 在达到收敛之前执行了许多迭代。 但是,这种融合并不能保证。 因此,OpenCV 允许我们指定所谓的终止标准。 让我们定义终止条件如下:
# Define the termination criteria:
# 10 iterations or convergence within 1-pixel radius.
term_crit = \
    (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_FPS, 10, 1)

基于这些标准,MeanShift 将在 10 次迭代后(计数标准)或当位移不再大于 1 个像素(ε标准)时停止计算质心偏移。 标志(cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS)的组合表示我们正在使用这两个条件。

  1. 现在我们已经计算出直方图并定义了 MeanShift 的终止条件,让我们开始通常的循环,在该循环中我们从相机捕获并处理帧。 对于每一帧,我们要做的第一件事就是将其转换为 HSV 颜色空间:
success, frame = cap.read()
while success:

    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
  1. 现在我们有了 HSV 图像,我们可以执行期待已久的直方图反投影操作:
back_proj = cv2.calcBackProject(
    [hsv], [0], roi_hist, [0, 180], 1)
  1. 反投影,跟踪窗口和终止条件可以传递给cv2.meanShift,这是 OpenCV 对 MeanShift 算法的实现。 这是函数调用:
# Perform tracking with MeanShift.
num_iters, track_window = cv2.meanShift(
    back_proj, track_window, term_crit)

请注意,MeanShift 返回其运行的迭代次数,以及找到的新跟踪窗口。 (可选)我们可以将迭代次数与终止条件进行比较,以确定结果是否收敛。 (如果实际的迭代次数小于最大值,则结果必须收敛。)

  1. 最后,我们绘制并显示更新的跟踪矩形:
# Draw the tracking window.
x, y, w, h = track_window
cv2.rectangle(
    frame, (x, y), (x+w, y+h), (255, 0, 0), 2)

cv2.imshow('back-projection', back_proj)
cv2.imshow('meanshift', frame)

那就是整个例子。 如果运行该程序,它将在“计算和反投影颜色直方图”部分中产生与我们之前看到的屏幕截图类似的输出。

到目前为止,您应该对颜色直方图,反投影和 MeanShift 的工作原理有所了解。 但是,前面的程序(通常是 MeanShift)有一个局限性:窗口的大小不会随被跟踪帧中对象的大小而改变。

OpenCV 项目的创始人之一加里·布拉德斯基(Gary Bradski)于 1988 年发表了一篇论文,以提高 MeanShift 的准确率。 他描述了一种称为连续自适应 MeanShiftCAMShiftCamShift)的新算法,该算法与 MeanShift 非常相似,但在 MeanShift 时也可以调整跟踪窗口的大小来达到收敛。 接下来,让我们看一下 CamShift 的示例。

使用 CamShift

尽管 CamShift 是比 MeanShift 更复杂的算法,但 OpenCV 为这两种算法提供了非常相似的接口。 主要区别在于对cv2.CamShift的调用将返回一个具有特定旋转的矩形,该旋转随被跟踪对象的旋转而变化。 只需对前面的 MeanShift 示例进行一些修改,我们就可以使用 CamShift 并绘制一个旋转的跟踪矩形。 在以下摘录中,所有必需的更改均以粗体突出显示:

import cv2
import numpy as np

# ... Initialize the tracking window and histogram as previously ...

success, frame = cap.read()
while success:

    # Perform back-projection of the HSV histogram onto the frame.
    hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
    back_proj = cv2.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)

 # Perform tracking with CamShift.
 rotated_rect, track_window = cv2.CamShift(
 back_proj, track_window, term_crit)

 # Draw the tracking window.
 box_points = cv2.boxPoints(rotated_rect)
 box_points = np.int0(box_points)
 cv2.polylines(frame, [box_points], True, (255, 0, 0), 2)

    cv2.imshow('back-projection', back_proj)
    cv2.imshow('camshift', frame)

    k = cv2.waitKey(1)
    if k == 27: # Escape
        break

    success, frame = cap.read()

cv2.CamShift的参数未更改; 它们与我们先前示例中的cv2.meanShift的参数具有相同的含义和相同的值。

我们使用cv2.boxPoints函数查找旋转的跟踪矩形的顶点。 然后,我们使用cv2.polylines函数绘制连接这些顶点的线。 以下屏幕截图显示了结果:

到目前为止,您应该熟悉两种跟踪技术。 第一个家庭使用背景减法。 第二种使用直方图反向投影,并结合了 MeanShift 或 CamShift。 现在,让我们认识一下卡尔曼过滤器,它代表了第三族; 它找到趋势,或者换句话说,根据过去的运动预测未来的运动。

使用卡尔曼过滤器查找运动趋势

卡尔曼过滤器是 Rudolf 卡尔曼在 1950 年代后期主要(但并非唯一)开发的算法。 它已经在许多领域中找到了实际应用,特别是从核潜艇到飞机的各种车辆的导航系统。

卡尔曼过滤器对嘈杂的输入数据流进行递归操作,以产生基础系统状态的统计最优估计。 在计算机视觉的背景下,卡尔曼过滤器可以使跟踪对象位置的估计变得*滑。

让我们考虑一个简单的例子。 想一想桌上的一个红色小球,想象一下您有一台照相机对准了现场。 您将球标识为要跟踪的对象,然后用手指轻拂它。 球将根据运动定律开始在桌子上滚动。

如果球在特定方向上以每秒 1 米的速度滚动,则很容易估计一秒钟后球将在哪里:它将在 1 米外。 卡尔曼过滤器应用诸如此类的定律,以基于在先前帧中收集的跟踪结果来预测对象在当前视频帧中的位置。 卡尔曼过滤器本身并没有收集这些跟踪结果,而是基于从另一种算法(例如 MeanShift)得出的跟踪结果来更新其对象运动模型。 自然,卡尔曼过滤器无法预测作用在球上的新力(例如与躺在桌上的铅笔的碰撞),但是它可以根据新的跟踪结果在事后更新其运动模型。 通过使用卡尔曼过滤器,我们可以获得比仅跟踪结果更稳定,更符合运动规律的估计。

了解预测和更新阶段

从前面的描述中,我们得出卡尔曼过滤器的算法具有两个阶段:

  • 预测:在第一阶段,卡尔曼过滤器使用直到当前时间点为止计算出的协方差来估计对象的新位置。
  • 更新:在第二阶段,卡尔曼过滤器记录对象的位置并为下一个计算周期调整协方差。

用 OpenCV 的术语来说,更新阶段是校正。 因此,OpenCV 通过以下方法提供cv2.KalmanFilter类:

predict([, control]) -> retval
correct(measurement) -> retval

为了*滑跟踪对象,我们将调用predict方法估计对象的位置,然后使用correct方法指示卡尔曼过滤器根据另一种算法的新跟踪结果调整其计算,例如 MeanShift。 但是,在将卡尔曼过滤器与计算机视觉算法结合使用之前,让我们检查一下它如何与来自简单运动传感器的位置数据一起执行。

跟踪鼠标光标

运动传感器在用户界面中已经很长时间了。 计算机的鼠标会感觉到自己相对于桌子等表面的运动。 鼠标是真实的物理对象,因此应用运动定律来预测鼠标坐标的变化是合理的。 我们将作为卡尔曼过滤器的演示来进行此操作。

我们的演示将实现以下操作序列:

  1. 首先初始化一个黑色图像和一个卡尔曼过滤器。 在窗口中显示黑色图像。
  2. 每次窗口应用处理输入事件时,请使用卡尔曼过滤器来预测鼠标的位置。 然后,根据实际的鼠标坐标校正卡尔曼过滤器的模型。 在黑色图像的顶部,从旧的预测位置到新的预测位置绘制一条红线,然后从旧的实际位置到新的实际位置绘制一条绿线。 在窗口中显示图形。
  3. 当用户按下Esc键时,退出并将图形保存到文件中。

要开始执行脚本,以下代码将初始化一个800 x 800黑色图像:

import cv2
import numpy as np

# Create a black image.
img = np.zeros((800, 800, 3), np.uint8)

现在,让我们初始化卡尔曼过滤器:

# Initialize the Kalman filter.
kalman = cv2.KalmanFilter(4, 2)
kalman.measurementMatrix = np.array(
    [[1, 0, 0, 0],
     [0, 1, 0, 0]], np.float32)
kalman.transitionMatrix = np.array(
    [[1, 0, 1, 0],
     [0, 1, 0, 1],
     [0, 0, 1, 0],
     [0, 0, 0, 1]], np.float32)
kalman.processNoiseCov = np.array(
    [[1, 0, 0, 0],
     [0, 1, 0, 0],
     [0, 0, 1, 0],
     [0, 0, 0, 1]], np.float32) * 0.03

基于前面的初始化,我们的卡尔曼过滤器将跟踪 2D 对象的位置和速度。 我们将在第 9 章,“相机模型和增强现实”中更深入地研究卡尔曼过滤器的初始化过程,在其中我们将跟踪 3D 对象的位置,速度,加速度,旋转 ,角速度和角加速度。 现在,让我们仅注意cv2.KalmanFilter(4, 2)中的两个参数。 第一个参数是由卡尔曼过滤器跟踪(或预测)的变量数,在这种情况下为4x位置,y位置,x速度,以及y速度。 第二个参数是作为测量提供给卡尔曼过滤器的变量的数量,在这种情况下,2x位置和y位置。 我们还初始化了几个矩阵,这些矩阵描述了所有这些变量之间的关系。

初始化图像和卡尔曼过滤器后,我们还必须声明变量以保存实际(测量)和预测的鼠标坐标。 最初,我们没有坐标,因此我们将None分配给以下变量:

last_measurement = None
last_prediction = None

然后,我们声明一个处理鼠标移动的回调函数。 此函数将更新卡尔曼过滤器的状态,并绘制未过滤鼠标移动和卡尔曼过滤鼠标移动的可视化。 第一次收到鼠标坐标时,我们将初始化卡尔曼过滤器的状态,以便其初始预测与实际的初始鼠标坐标相同。 (如果不这样做,则卡尔曼过滤器将假定鼠标的初始位置为(0, 0)。)随后,每当我们收到新的鼠标坐标时,我们都会用当前测量值校正卡尔曼过滤器,计算卡尔曼预测值,然后, 最后,画两条线:从最后一次测量到当前测量的绿线,以及从最后一次预测到当前预测的红线。 这是回调函数的实现:

def on_mouse_moved(event, x, y, flags, param):
    global img, kalman, last_measurement, last_prediction

    measurement = np.array([[x], [y]], np.float32)
    if last_measurement is None:
        # This is the first measurement.
        # Update the Kalman filter's state to match the measurement.
        kalman.statePre = np.array(
            [[x], [y], [0], [0]], np.float32)
        kalman.statePost = np.array(
            [[x], [y], [0], [0]], np.float32)
        prediction = measurement
    else:
        kalman.correct(measurement)
        prediction = kalman.predict()  # Gets a reference, not a copy

        # Trace the path of the measurement in green.
        cv2.line(img, (last_measurement[0], last_measurement[1]),
                 (measurement[0], measurement[1]), (0, 255, 0))

        # Trace the path of the prediction in red.
        cv2.line(img, (last_prediction[0], last_prediction[1]),
                 (prediction[0], prediction[1]), (0, 0, 255))

    last_prediction = prediction.copy()
    last_measurement = measurement

下一步是初始化窗口并将回调函数传递给cv2.setMouseCallback函数:

cv2.namedWindow('kalman_tracker')
cv2.setMouseCallback('kalman_tracker', on_mouse_moved)

由于大多数程序的逻辑都在鼠标回调中,因此主循环的实现很简单。 我们只是不断地显示更新的图像,直到用户按下Esc键:

while True:
    cv2.imshow('kalman_tracker', img)
    k = cv2.waitKey(1)
    if k == 27:  # Escape
        cv2.imwrite('kalman.png', img)
        break

运行该程序并四处移动鼠标。 如果您突然高速转弯,您会发现预测线(红色)的曲线比测量线(绿色)的曲线宽。 这是因为预测是跟踪到那时鼠标移动的动量。 这是一个示例结果:

前面的图也许会给我们启发下一个示例应用的灵感,我们在其中跟踪行人。

跟踪行人

到目前为止,我们已经熟悉了运动检测,对象检测和对象跟踪的概念。 您可能急于在现实生活中充分利用这些新知识。 让我们通过监视摄像机中的视频跟踪行人来做到这一点。

您可以在samples/data/vtest.avi的 OpenCV 存储库中找到监视视频。 该视频的副本位于chapter08/pedestrians.avi这本书的 GitHub 存储库中。

让我们制定一个计划,然后实现该应用!

规划应用流程

该应用将遵循以下逻辑:

  1. 从视频文件捕获帧。
  2. 使用前 20 帧填充背景减法器的历史记录。
  3. 基于背景减法,使用第 21 帧识别移动的前景对象。 我们将把它们当作行人。 为每个行人分配一个 ID 和一个初始跟踪窗口,然后计算直方图。
  4. 对于每个后续帧,使用卡尔曼过滤器和 MeanShift 跟踪每个行人。

如果这是一个实际应用,则可能会存储每个行人穿过场景的路线的记录,以便用户稍后进行分析。 但是,这种类型的记录保存超出了本示例的范围。

此外,在实际应用中,您将确保识别出新的行人进入现场。 但是,现在,我们将集中精力仅跟踪视频开始附*场景中的那些对象。

您可以在本书的 GitHub 存储库中的chapter08/track_pedestrians.py找到该应用的代码。 在检查实现之前,让我们简要地讨论一下编程范例以及它们与我们对 OpenCV 的使用之间的关系。

比较面向对象和函数式范式

尽管大多数程序员对 OOP 都不熟悉(或不停地工作),但多年以来,另一种称为 FP 的范例一直在偏爱纯数学基础的程序员中获得支持。

塞缪尔·豪斯(Samuel Howse)的作品展示了具有纯数学基础的编程语言规范。 您可以在这个页面以及他的论文《NummSquared:正式方法》

FP 将程序视为对数学函数的评估,允许函数返回函数,并允许函数作为函数中的参数。 FP 的优势不仅在于它可以做什么,还在于它可以避免或旨在避免的事情:例如,副作用和状态变化。 如果 FP 主题引起了人们的兴趣,请确保您看一下 Haskell,Clojure 或元语言ML)之类的语言。

那么,编程方面的副作用是什么? 如果函数产生任何在其本地范围之外可以访问的更改(返回值除外),则该函数具有副作用。 Python 和许多其他语言一样,容易受到副作用的影响,因为它使您可以访问成员变量和全局变量-有时,这种访问可能是偶然的!

在非纯粹函数式的语言中,即使我们反复向其传递相同的参数,其输出也会发生变化。 例如,如果函数将对象作为参数,并且计算依赖于该对象的内部状态,则该函数将根据对象状态的变化返回不同的结果。 这在使用诸如 Python 和 C++ 之类的 OOP 中很常见。

那么,为什么要离题呢? 好吧,这是一个很好的时机,考虑我们自己的样本和 OpenCV 中使用的范例,以及它们与纯数学方法的区别。 在本书中,我们经常使用全局变量或带有成员变量的面向对象的类。 下一个程序是 OOP 的另一个示例。 OpenCV 也包含许多具有副作用的函数和许多面向对象的类。

例如,任何 OpenCV 绘图函数,例如cv2.rectanglecv2.circle,都会修改我们作为参数传递给它的图像。 这种方法违反了 FP 的基本原则之一:避免副作用和状态变化。

作为简短的练习,让我们将cv2.rectangle包装在另一个 Python 函数中以 FP 样式执行绘图,而没有任何副作用。 以下实现依赖于复制输入图像,而不是修改原始图像:

def draw_rect(img, top_left, bottom_right, color,
              thickness, fill=cv2.LINE_AA):
    new_img = img.copy()
    cv2.rectangle(new_img, top_left, bottom_right, color,
                  thickness, fill)
    return new_img

这种方法-尽管由于copy操作而在计算上更加昂贵-但允许以下代码运行而没有副作用:

frame = camera.read()
frame_with_rect = draw_rect(
    frame, (0, 0), (10, 10), (0, 255, 0), 1)

在这里,frameframe_with_rect是对包含不同值的两个不同 NumPy 数组的引用。 如果我们使用cv2.rectangle而不是受 FP 启发的draw_rect包装器,则frameframe_with_rect将被引用到一个相同的 NumPy 数组(在原始图像顶部包含一个矩形图) )。

总结一下这一题外话,请注意,各种编程语言和范例都可以成功地应用于计算机视觉问题。 了解多种语言和范例非常有用,这样您就可以为给定的工作选择正确的工具。

现在,让我们回到程序,探索监视应用的实现,跟踪视频中的移动对象。

实现Pedestrian

卡尔曼过滤器的性质为创建Pedestrian类提供了主要原理。 卡尔曼过滤器可以基于历史观察来预测对象的位置,并且可以基于实际数据来校正预测,但是它只能对一个对象执行此操作。 因此,每个跟踪对象需要一个卡尔曼过滤器。

每个Pedestrian对象都将充当卡尔曼过滤器,彩色直方图(在对象的首次检测时计算并用作后续帧的参考)的支架,以及一个跟踪窗口,MeanShift 算法将使用该跟踪窗口。 此外,每个行人都有一个 ID,我们将显示该 ID,以便我们可以轻松地区分所有被跟踪的行人。 让我们依次完成该类的实现:

  1. 作为参数,Pedestrian类的构造器采用 ID,HSV 格式的初始帧和初始跟踪窗口作为参数。 这是该类及其构造器的声明:
import cv2
import numpy as np

class Pedestrian():
    """A tracked pedestrian with a state including an ID, tracking
    window, histogram, and Kalman filter.
    """

    def __init__(self, id, hsv_frame, track_window):
  1. 为了开始构造器的实现,我们为 ID,跟踪窗口和 MeanShift 算法的终止条件定义变量:
    self.id = id

    self.track_window = track_window
    self.term_crit = \
        (cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 10, 1)
  1. 我们通过在初始 HSV 图像中创建兴趣区域的标准化色相直方图来进行操作:
    # Initialize the histogram.
    x, y, w, h = track_window
    roi = hsv_frame[y:y+h, x:x+w]
    roi_hist = cv2.calcHist([roi], [0], None, [16], [0, 180])
    self.roi_hist = cv2.normalize(roi_hist, roi_hist, 0, 255,
                                  cv2.NORM_MINMAX)
  1. 然后,我们初始化卡尔曼过滤器:
    # Initialize the Kalman filter.
    self.kalman = cv2.KalmanFilter(4, 2)
    self.kalman.measurementMatrix = np.array(
        [[1, 0, 0, 0],
         [0, 1, 0, 0]], np.float32)
    self.kalman.transitionMatrix = np.array(
        [[1, 0, 1, 0],
         [0, 1, 0, 1],
         [0, 0, 1, 0],
         [0, 0, 0, 1]], np.float32)
    self.kalman.processNoiseCov = np.array(
        [[1, 0, 0, 0],
         [0, 1, 0, 0],
         [0, 0, 1, 0],
         [0, 0, 0, 1]], np.float32) * 0.03
    cx = x+w/2
    cy = y+h/2
    self.kalman.statePre = np.array(
        [[cx], [cy], [0], [0]], np.float32)
    self.kalman.statePost = np.array(
        [[cx], [cy], [0], [0]], np.float32)

就像在我们的鼠标跟踪示例中一样,我们正在配置卡尔曼过滤器来预测 2D 点的运动。 作为初始点,我们使用初始跟踪窗口的中心。 这样就完成了构造器的实现。

  1. Pedestrian类还具有update方法,我们将每帧调用一次。 作为参数,update方法采用 BGR 框架(在绘制跟踪结果的可视化时使用)和同一框架的 HSV 版本(用于直方图反投影)。 update方法的实现从熟悉的直方图反投影和 MeanShift 代码开始,如以下几行所示:
def update(self, frame, hsv_frame):

    back_proj = cv2.calcBackProject(
        [hsv_frame], [0], self.roi_hist, [0, 180], 1)

    ret, self.track_window = cv2.meanShift(
        back_proj, self.track_window, self.term_crit)
    x, y, w, h = self.track_window
    center = np.array([x+w/2, y+h/2], np.float32)
  1. 请注意,我们要提取跟踪窗口的中心坐标,因为我们要对其进行卡尔曼滤波。 我们将继续执行此操作,然后更新跟踪窗口,使其以校正后的坐标为中心:
    prediction = self.kalman.predict()
    estimate = self.kalman.correct(center)
    center_offset = estimate[:,0][:2] - center
    self.track_window = (x + int(center_offset[0]),
                         y + int(center_offset[1]), w, h)
    x, y, w, h = self.track_window
  1. 为了总结update方法,我们将卡尔曼过滤器的预测绘制为蓝色圆圈,将校正后的跟踪窗口绘制为青色矩形,并将行人的 ID 绘制为矩形上方的蓝色文本:
    # Draw the predicted center position as a circle.
    cv2.circle(frame, (int(prediction[0]), int(prediction[1])),
               4, (255, 0, 0), -1)

    # Draw the corrected tracking window as a rectangle.
    cv2.rectangle(frame, (x,y), (x+w, y+h), (255, 255, 0), 2)

    # Draw the ID above the rectangle.
    cv2.putText(frame, 'ID: %d' % self.id, (x, y-5),
                cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0),
                1, cv2.LINE_AA)

这就是我们需要与单个行人关联的所有特征和数据。 接下来,我们需要实现一个程序,该程序提供创建和更新Pedestrian对象所需的视频帧。

实现main函数

现在我们有了Pedestrian类来维护有关每个行人跟踪的数据,让我们实现程序的main函数。 我们将依次查看实现的各个部分:

  1. 我们首先加载视频文件,初始化背景减法器,然后设置背景减法器的历史记录长度(即影响背景模型的帧数):
def main():

    cap = cv2.VideoCapture('pedestrians.avi')

    # Create the KNN background subtractor.
    bg_subtractor = cv2.createBackgroundSubtractorKNN()
    history_length = 20
    bg_subtractor.setHistory(history_length)
  1. 然后,我们定义形态核:
erode_kernel = cv2.getStructuringElement(
    cv2.MORPH_ELLIPSE, (3, 3))
dilate_kernel = cv2.getStructuringElement(
    cv2.MORPH_ELLIPSE, (8, 3))
  1. 我们定义了一个名为pedestrians的列表,该列表最初是空的。 稍后,我们将Pedestrian对象添加到此列表中。 我们还设置了一个帧计数器,用于确定是否经过了足够的帧以填充背景减法器的历史记录。 以下是变量的相关定义:
pedestrians = []
num_history_frames_populated = 0
  1. 现在,我们开始循环。 在每次迭代的开始,我们尝试读取视频帧。 如果失败(例如,在视频文件的末尾),则退出循环:
while True:
    grabbed, frame = cap.read()
    if (grabbed is False):
        break
  1. 继续循环的主体,我们根据新捕获的帧更新背景减法器。 如果背景减法器的历史记录尚未满,我们将继续循环的下一个迭代。 以下是相关代码:
# Apply the KNN background subtractor.
fg_mask = bg_subtractor.apply(frame)

# Let the background subtractor build up a history.
if num_history_frames_populated < history_length:
    num_history_frames_populated += 1
    continue
  1. 一旦背景减法器的历史记录已满,我们将对每个新捕获的帧进行更多处理。 具体来说,我们采用与本章前面的背景减法器相同的方法:对前景遮罩执行阈值化,腐蚀和扩张; 然后我们检测轮廓,这些轮廓可能是移动的对象:
# Create the thresholded image.
_, thresh = cv2.threshold(fg_mask, 127, 255,
                          cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)

# Detect contours in the thresholded image.
contours, hier = cv2.findContours(
    thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
  1. 我们还将帧转换为 HSV 格式,因为我们打算将这种格式的直方图用于 MeanShift。 下面的代码行执行转换:
hsv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
  1. 一旦有了轮廓和框架的 HSV 版本,我们就可以检测和跟踪移动的对象了。 我们为每个轮廓找到并绘制一个边界矩形,该矩形足够大以适合行人。 而且,如果尚未填充pedestrians列表,我们现在可以通过基于每个边界矩形(以及 HSV 图像的相应区域)添加一个新的Pedestrian对象来进行填充。 这是按照我们刚刚描述的方式处理轮廓的子循环:
# Draw rectangles around large contours.
# Also, if no pedestrians are being tracked yet, create some.
should_initialize_pedestrians = len(pedestrians) == 0
id = 0
for c in contours:
    if cv2.contourArea(c) > 500:
        (x, y, w, h) = cv2.boundingRect(c)
        cv2.rectangle(frame, (x, y), (x+w, y+h),
                      (0, 255, 0), 1)
        if should_initialize_pedestrians:
            pedestrians.append(
                Pedestrian(id, frame, hsv_frame,
                           (x, y, w, h)))
    id += 1
  1. 现在,我们有了我们要跟踪的行人的列表。 我们将每个Pedestrian对象的update方法都调用,将原始 BGR 帧(用于绘图)和 HSV 帧(用于借助 MeanShift 进行跟踪)传递给该方法。 请记住,每个Pedestrian对象都负责绘制自己的信息(文本,跟踪矩形和卡尔曼过滤器的预测)。 这是更新pedestrians列表的子循环:
        # Update the tracking of each pedestrian.
        for pedestrian in pedestrians:
            pedestrian.update(frame, hsv_frame)
  1. 最后,我们在一个窗口中显示跟踪结果,并允许用户随时按Esc键退出程序:
        cv2.imshow('Pedestrians Tracked', frame)

        k = cv2.waitKey(110)
        if k == 27:  # Escape
            break

if __name__ == "__main__":
    main()

在那里,您可以找到:MeanShift 与卡尔曼过滤器协同工作,以跟踪移动的对象。 一切顺利,您应该可以通过以下方式看到跟踪结果:

在此裁剪的屏幕截图中,带有细边框的绿色矩形是检测到的轮廓,带有粗边框的青色矩形是经过卡尔曼校正的 MeanShift 跟踪矩形,而蓝点是由卡尔曼过滤器预测的中心位置。

像往常一样,随时尝试使用脚本。 您可能需要调整参数,尝试使用 MOG 背景减法器代替 KNN,或尝试使用 CamShift 代替 MeanShift。 这些更改应该只影响几行代码。 完成后,接下来,我们将考虑可能对脚本的结构产生更大影响的其他可能的修改。

考虑下一步

可以根据特定应用的需求以各种方式扩展和改进前面的程序。 请考虑以下示例:

  • 如果卡尔曼过滤器预测行人的位置在框架之外,则可以从pedestrians列表中删除Pedestrian对象(从而销毁Pedestrian对象)。
  • 您可以检查每个检测到的移动对象是否对应于pedestrians列表中的现有Pedestrian实例,如果不存在,则向列表中添加一个新对象,以便在后续帧中对其进行跟踪。
  • 您可以训练支持向量机SVM)并将其用于分类每个运动对象。 使用这些方法,您可以确定运动对象是否是您要跟踪的对象。 例如,一条狗可能会进入场景,但是您的应用可能只需要跟踪人类。 有关训练 SVM 的更多信息,请参阅第 7 章,“构建自定义对象检测器”。

无论您有什么需要,本章都希望为您提供构建满足您要求的 2D 跟踪应用所需的知识。

总结

本章介绍了视频分析,尤其是选择了一些有用的跟踪对象技术。

我们首先通过计算帧差异的基本运动检测技术来学习背景减法。 然后,我们继续使用 OpenCV 的cv2.BackgroundSubtractor类中实现的更复杂和有效的背景减法算法-MOG 和 KNN。

然后,我们继续探索 MeanShift 和 CamShift 跟踪算法。 在此过程中,我们讨论了颜色直方图和反投影。 我们还熟悉卡尔曼过滤器及其在*滑跟踪算法结果中的作用。 最后,我们将所有知识汇总到一个示例监视应用中,该应用能够跟踪视频中的行人(或其他移动物体)。

到目前为止,我们在 OpenCV,计算机视觉和机器学习方面的基础正在巩固。 在本书的其余两章中,我们可以期待几个高级主题。 我们将在第 9 章,“相机模型和增强现实”中扩展对 3D 空间的跟踪知识。 然后,我们将讨论人工神经网络ANN),并在第 10 章“使用 OpenCV 的神经网络”中介绍,从而更深入地研究人工智能。

九、相机模型和增强现实

如果您喜欢几何图形,摄影或 3D 图形,那么本章的主题尤其适合您。 我们将学习 3D 空间和 2D 投影之间的关系。 我们将根据相机和镜头的基本光学参数对这种关系进行建模。 最后,我们将相同的关系应用于在精确的透视投影中绘制 3D 形状的任务。 在所有这些过程中,我们将整合我们之前在图像匹配和对象跟踪方面的知识,以便跟踪其真实世界对象的 3D 运动,该对象的 2D 投影由相机实时捕获。

在实践上,我们将构建一个增强现实应用,该应用使用有关相机,对象和运动的信息,以便将 3D 图形实时叠加在被跟踪对象的顶部。 为此,我们将克服以下技术挑战:

  • 建模相机和镜头的参数
  • 使用 2D 和 3D 关键点建模 3D 对象
  • 通过匹配的关键点来检测对象
  • 使用cv2.solvePnPRansac函数查找对象的 3D 姿势
  • 使用卡尔曼过滤器*滑 3D 姿势
  • 在对象上方绘制图形

在本章的过程中,如果您继续构建自己的增强现实引擎或任何其他依赖 3D 跟踪的系统(例如机器人导航系统),则将获得对您有用的技能。

技术要求

本章使用 Python,OpenCV 和 NumPy。 请返回第 1 章,“设置 OpenCV”,了解安装说明。

本章的完整代码和示例视频可以在本书的 GitHub 存储库中找到,位于chapter09文件夹中。

本章代码包含摘自 Joseph Howse(本书作者之一)称为“可视化不可见”的开源演示项目的摘录。 要了解有关此项目的更多信息,请访问这个页面中的资源库。

了解 3D 图像跟踪和增强现实

我们已经在第 6 章,“检索图像并使用图像描述符搜索”中解决了图像匹配问题。 此外,我们在第 8 章,“跟踪对象”中解决了涉及连续跟踪的问题。 因此,尽管我们尚未解决任何 3D 跟踪问题,但我们熟悉图像跟踪系统的许多组件。

那么,3D 跟踪到底是什么? 嗯,这是一个不断更新 3D 空间中对象姿态估计值的过程,通常使用六个变量:三个变量来表示对象的 3D *移(即位置),以及其他三个变量代表其 3D 旋转。

3D 跟踪的一个更专业的术语是 6DOF 跟踪 –也就是说,使用 6 个自由度的跟踪,即我们刚才提到的 6 个变量。

有 3 种方式将 3D 旋转表示为三个变量。 在其他地方,您可能会遇到各种各样的欧拉角表示形式,它们以围绕xyz的三个单独的 2D 旋转来描述 3D 旋转。 ]轴按特定顺序排列。 OpenCV 不使用欧拉角来表示 3D 旋转。 相反,它使用称为 Rodrigues 旋转向量的表示形式。 具体来说,OpenCV 使用以下六个变量来表示 6DOF 姿态:

  1. t[x]:这是对象沿x轴的*移。
  2. t[y]:这是对象沿y轴的*移。
  3. t[z]:这是对象沿z轴的*移。
  4. r[x]:这是对象 Rodrigues 旋转向量的第一个元素。
  5. r[y]:这是对象 Rodrigues 旋转向量的第二个元素。
  6. r[z]:这是对象 Rodrigues 旋转向量的第三个元素。

不幸的是,在 Rodrigues 表示中,没有简单的方法来解释r[x]r[y]r[z]彼此分开。 总之,它们作为向量r编码旋转轴和围绕该轴的旋转角。 具体来说,以下公式定义了r向量之间的关系; 角度θ,归一化的轴向量,; 和3 x 3旋转矩阵R

作为 OpenCV 程序员,我们没有义务直接计算或解释任何这些变量。 OpenCV 提供了将 Rodrigues 旋转向量作为返回值的函数,我们可以将此旋转向量作为参数传递给其他 OpenCV 函数-无需自己操纵其内容。

出于我们的目的(实际上,对于计算机视觉中的许多问题),相机是 3D 坐标系的原点。 因此,在任何给定的帧中,摄像机的当前t[x]t[y]t[z]r[x]r[y]r[z]值均定义为 0。我们将努力跟踪相对于相机当前姿势的其他对象。

当然,为了便于讲授,我们将可视化 3D 跟踪结果。 这将我们带入增强现实AR)的领域。 广义上讲,AR 是一种持续跟踪现实世界对象之间的关系并将这些关系应用于虚拟对象的过程,以这种方式,用户可以将虚拟对象视为固定在现实世界中的某物上。 通常,视觉 AR 基于 3D 空间和透视投影的关系。 确实,我们的情况很典型。 我们希望通过在框架中跟踪的对象上方绘制一些 3D 图形的投影来可视化 3D 跟踪结果。

稍后,我们将回到透视投影的概念。 同时,让我们概述一下 3D 图像跟踪和可视 AR 的一组典型步骤:

  1. 定义相机和镜头的参数。 我们将在本章中介绍该主题。
  2. 初始化我们将用于稳定 6DOF 跟踪结果的卡尔曼过滤器。 有关卡尔曼滤波的更多信息,请参考第 8 章,“跟踪对象”。
  3. 选择一个参考图像,代表我们要跟踪的对象的表面。 对于我们的演示,对象将是一个*面,例如一张打印图像的纸。
  4. 创建一个 3D 点列表,代表对象的顶点。 坐标可以是任何单位,例如米,毫米或任意单位。 例如,您可以任意定义 1 个单位以等于对象的高度。
  5. 从参考图像中提取特征描述符。 对于 3D 跟踪应用,ORB 是描述符的一种流行选择,因为它甚至可以在智能手机等适度的硬件上实时进行计算。 我们的演示将使用 ORB。 有关 ORB 的更多信息,请参考第 6 章,“检索图像并使用图像描述符搜索”。
  6. 使用与“步骤 4”中相同的映射,将特征描述符从像素坐标转换为 3D 坐标。
  7. 开始从相机捕获帧。 对于每个帧,执行以下步骤:
    1. 提取特征描述符,并尝试在参考图像和框架之间找到良好的匹配。 我们的演示将使用基于 FLANN 的匹配和比率测试。 有关这些用于匹配描述符的方法的更多信息,请参考第 6 章,“检索图像并使用图像描述符搜索”。
    2. 如果找到的匹配次数不足,请继续下一帧。 否则,请继续执行其余步骤。

在继续演示代码之前,让我们进一步讨论此概述的两个方面:第一,相机和镜头的参数;第二,相机和镜头的参数。 第二,神秘函数cv2.solvePnPRansac的作用。

了解相机和镜头参数

通常,当我们捕获图像时,至少涉及三个对象:

  • 主题是我们要在图像中捕获的东西。 通常,它是一个反射光的对象,我们希望该对象在图像中聚焦(清晰)。
  • 透镜透射光,并将所有来自焦*面的反射光聚焦到像*面上。 焦*面是包括主体(如先前定义)的圆形空间切片。 图像*面是一个圆形的空间切片,其中包含图像传感器(稍后定义)。 通常,这些*面垂直于镜头的主轴(长度方向)。 镜头具有光学中心,这是来自焦*面的入射光在聚光回像*面之前会聚的点。 焦距(即,光学中心与焦*面之间的距离)根据光学中心与像*面之间的距离而变化。 如果我们将光学中心移*图像*面,则焦距会增加; 相反,如果我们将光学中心移离图像*面更远,则焦距会减小(通常,在相机系统中,通过简单地前后移动镜头的机制来调整焦点)。 焦距定义为当焦距为无穷远时光学中心与像*面之间的距离。
  • 图像传感器是一种感光表面,可在模拟介质(例如胶片)或数字介质中接收光并将其记录为图像。 通常,图像传感器是矩形的。 因此,它不会覆盖圆形图像*面的角。 图像的对角线视场FOV:要成像的 3D 空间的角度范围)与焦距,图像传感器的宽度和图像传感器的高度具有三角关系 。 我们将尽快探讨这种关系。

这是说明上述定义的图:

对于计算机视觉,我们通常使用固定焦距的镜头,这对于给定的应用是最佳的。 但是,镜头可以具有可变的焦距。 这种镜头称为变焦镜头放大意味着增加焦距,而缩小意味着减少焦距。 在机械上,变焦镜头通过移动镜头内部的光学元件来实现此目的。

让我们使用变量f表示焦距,然后使用变量(c[x]c[y])代表图像传感器在图像*面内的中心点。 OpenCV 使用以下矩阵,称为摄像机矩阵,表示摄像机和镜头的基本参数:

f 0 c[x]
0 f c[y]
0 0 1

假设图像传感器在图像*面中居中(通常应该如此),我们可以计算出c[x]c[y],具体取决于图像传感器的宽度w和高度h,如下所示:

如果我们知道对角 FOV θ,则可以使用以下三角公式来计算焦距:

或者,如果我们不知道对角 FOV,但是我们知道水* FOV ɸ和垂直 FOV ψ,则可以如下计算焦距:

您可能想知道我们如何获取这些变量中任何一个的值作为起点。 有时,相机或镜头的制造商会在产品的规格表中提供有关传感器尺寸,焦距或 FOV 的数据。 例如,规格表可能以毫米为单位列出传感器尺寸和焦距,以度为单位列出 FOV。 但是,如果规格表的信息不足,我们还有其他方法来获取必要的数据。 重要的是,传感器的大小和焦距无需以实际单位(例如毫米)表示。 我们可以用任意单位表示它们,例如像素等效单位

您可能会问,什么是像素等效单位? 好吧,当我们从相机捕获一帧时,图像中的每个像素对应于图像传感器的某个区域,并且该区域具有真实世界的宽度(和真实世界的高度,通常与宽度相同) )。 因此,如果我们要捕获分辨率为1280 x 720的帧,则可以说图像传感器的宽度w为 1280 个像素当量单位,高度为h, 是 720 像素等效单位。 这些单元无法在不同的实际传感器尺寸或分辨率下进行比较。 但是,对于给定的摄像机和分辨率,它们使我们能够进行内部一致的测量,而无需知道这些测量的实际规模。

这个技巧使我们能够为任何图像传感器定义wh(因为我们始终可以检查捕获帧的像素尺寸)。 现在,为了能够计算焦距,我们只需要另一种数据类型:FOV。 我们可以使用一个简单的实验来测量。 拿一张纸并将其粘贴到墙壁(或另一个垂直表面)上。 放置相机和镜头,使其直接面对纸张,并且纸张对角填充框架。 (如果纸张的纵横比与框架的纵横比不匹配,请裁切纸张以使其匹配。)测量从纸张的一个角到对角线对角的对角线大小s。 此外,测量从纸张到镜头镜筒下半点的距离d。 然后,通过三角法计算对角 FOV θ

假设通过该实验,我们确定给定的相机和镜头的对角 FOV 为 70 度。 如果我们知道以1280 x 720的分辨率捕获帧,则可以按像素等效单位计算焦距,如下所示:

除此之外,我们还可以计算图像传感器的中心坐标:

因此,我们有以下相机矩阵:

1048.7 0 640
0 1048.7 360
0 0 1

前面的参数对于 3D​​跟踪是必需的,它们正确地代表了理想的相机和镜头。 但是,实际设备可能会明显偏离此理想状态,并且仅相机矩阵无法代表所有可能的偏差类型。 失真系数是一组附加参数,可以表示与理想模型的以下几种偏差:

  • 径向畸变:这意味着镜头无法*等地放大图像的所有部分; 因此,它会使直边显得弯曲或波浪状。 对于径向失真系数,使用诸如k[n]等变量名(例如通常使用k[1]k[2]k[3]等)。 如果k[1] < 0,则通常表示镜头遭受镜筒变形的影响,这意味着直边似乎朝着镜框的边界向外弯曲。 图片。 相反,k[1] > 0通常表示镜头遭受枕形畸变,这意味着直边似乎向内向图像中心弯曲。 如果符号在整个系列中交替出现(例如k[1] > 0k[2] < 0k[3] > 0),这可能意味着镜头遭受了胡子变形的困扰,这意味着笔直的边缘显得波浪状。
  • 切向失真:这意味着镜头的主轴(长度方向)不垂直于图像传感器; 因此,透视图是倾斜的,直线边缘之间的角度似乎不同于正常透视图投影中的角度。 对于切向失真系数,使用变量名称,例如p[n](例如,通常使用p[1]p[2]等)。 系数的符号取决于镜头相对于图像传感器的倾斜方向。

下图说明了某些类型的径向变形:

OpenCV 提供的功能可处理多达五个失真系数:k[1]k[2]p[1]p[2]k[3]。 (OpenCV 期望它们以此顺序作为数组的元素。)很少地,您可能能够从相机或镜头供应商那里获得有关畸变系数的官方数据。 另外,您可以使用 OpenCV 的棋盘校准过程来估计失真系数以及相机矩阵。 这涉及从各种位置和角度捕获一系列印刷棋盘图案的图像。 有关更多详细信息,请参考官方教程

出于演示目的,我们将简单假设所有失真系数均为 0,这意味着没有失真。 当然,我们并不真正相信我们的网络摄像头是光学工程中的无畸变杰作。 我们只是认为失真还不足以明显影响我们的 3D 跟踪和 AR 演示。 如果我们试图构建一个精确的测量设备而不是视觉演示,那么我们将更加关注失真的影响。

与棋盘校准过程相比,我们在本节中概述的公式和假设产生了更为受限或理想的模型。 但是,我们的方法具有更简单,更容易重现的优点。 棋盘校准过程比较费力,每个用户可能会以不同的方式执行它,从而产生不同的(有时是错误的)结果。

吸收了有关相机和镜头参数的背景信息之后,现在让我们检查一个 OpenCV 函数,该函数使用这些参数作为 6DOF 跟踪问题解决方案的一部分。

了解cv2.solvePnPRansac

cv2.solvePnPRansac函数为所谓的n点透视(PnP)问题实现了求解器。 给定 3D 和 2D 点之间的一组n唯一匹配,以及生成此 3D 点 2D 投影的相机和镜头的参数,求解器将尝试估计 3D 对象相对于相机的 6DOF 姿态。 这个问题有点类似于寻找一组 2D 到 2D 关键点匹配的单应性,就像我们在第 6 章“检索图像并使用图像描述符搜索”中所做的那样。 但是,在 PnP 问题中,我们有足够的其他信息来估计更具体的空间关系(自由度姿势),而不是单应性,后者只是告诉我们一种投影关系。

那么cv2.solvePnPRansac如何工作? 顾名思义,该函数实现了 Ransac 算法,这是一种通用的迭代方法,旨在处理可能包含异常值(在我们的情况下为不匹配)的一组输入。 每次 Ransac 迭代都会找到一个潜在的解决方案,该解决方案可最大程度地减少对输入的*均误差的度量。 然后,在下一次迭代之前,将具有不可接受的大误差的所有输入标记为离群值并丢弃。 此过程一直持续到解收敛为止,这意味着没有发现新的异常值,并且*均误差也可以接受。

对于 PnP 问题,误差是根据重投影误差来衡量的,这意味着根据相机和镜头参数观察到的 2D 点的位置与预测位置之间的距离以及我们得出的 6DOF 姿态,目前正在考虑它作为潜在的解决方案。 在过程的最后,我们希望获得与大多数 3D 到 2D 关键点匹配一致的 6DOF 姿势。 此外,我们想知道该解决方案的匹配项是哪些。

让我们考虑cv2.solvePnPRansac的函数签名:

retval, rvec, tvec, inliers = cv.solvePnPRansac(
    objectPoints,
    imagePoints,
    cameraMatrix,
    distCoeffs,
    rvec=None,
    tvec=None,
    useExtrinsicGuess=False
    iterationsCount=100,
    reprojectionError=8.0,
    confidence=0.98,
    inliers=None,
    flags=cv2.SOLVEPNP_ITERATIVE)

如我们所见,该函数具有四个返回值:

  • retval:如果求解器收敛于一个解,则为True; 否则为False
  • rvec:此数组包含r[x]r[y]r[z]– 6DOF 姿态中的三个旋转自由度。
  • tvec:此数组包含t[x]t[y]t[z]– 6DOF 姿态中的三个*移(位置)自由度。
  • inliers:如果求解器收敛于一个解,则此向量包含与该解一致的输入点的索引(在objectPointsimagePoints中)。

该函数还具有 12 个参数:

  • objectPoints:这是 3D 点的数组,表示没有*移和旋转时(换句话说,当 6DOF 姿态变量都为 0 时)对象的关键点。
  • imagePoints:这是 2D 点的数组,代表图像中对象的关键点匹配。 具体而言,认为imagePoints[i]objectPoints[i]匹配。
  • cameraMatrix:此 2D 数组是相机矩阵,我们可以按照前面的“了解相机和镜头参数”部分中介绍的方式导出。
  • distCoeffs:这是失真系数的数组。 如果我们不知道它们,可以(为简单起见)假定它们全为 0,如上一节所述。
  • rvec:如果求解器收敛于一个解,它将把解的r[x]r[y]r[z]此数组中的值。
  • tvec:如果求解器收敛于一个解,它将把解的t[x]t[y]t[z]值在此数组中。
  • useExtrinsicGuess:如果这是True,则求解器会将rvectvec参数中的值视为初始猜测,然后尝试找到与这些*似的解决方案。 否则,求解器将在搜索解决方案时采取不偏不倚的方法。
  • iterationsCount:这是求解器应尝试的最大迭代次数。 如果经过此迭代次数后仍未收敛于解决方案,则放弃。
  • reprojectionError:这是求解器将接受的最大重投影误差; 如果某个点的重投影误差大于此误差,则求解器会将其视为异常值。
  • confidence:求解器尝试收敛于置信度得分大于或等于此值的解决方案。
  • inliers:如果求解器收敛于一个解,则它将解的内点的索引放入此数组中。
  • flags:这些标志指定求解器的算法。 默认值cv2.SOLVEPNP_ITERATIVE是使重投影误差最小并且没有特殊限制的方法,因此通常是最佳选择。 一个有用的替代方法是cv2.SOLVEPNP_IPPEIPPE,是基于无限小*面的姿势估计的缩写),但它仅限于*面对象。

尽管此函数涉及很多变量,但我们会发现它的使用是对第 6 章,“检索图像和使用图像描述符搜索”的关键点匹配问题的自然扩展。 ,以及本章要介绍的 3D 和投影问题。 考虑到这一点,让我们开始探索本章的示例代码。

实现演示应用

我们将在单个脚本ImageTrackingDemo.py中实现我们的演示,该脚本将包含以下组件:

  1. 导入语句
  2. 用于自定义灰度转换的辅助函数
  3. 辅助函数可将关键点从 2D 空间转换为 3D 空间
  4. 应用类ImageTrackingDemo,它将封装相机和镜头的模型,参考图像的模型,卡尔曼过滤器,6DOF 跟踪结果,以及将跟踪图像并绘制简单的 AR 可视化效果的应用循环
  5. main函数启动应用

该脚本将依赖于另一个文件reference_image.png,它将代表我们要跟踪的图像。

事不宜迟,让我们深入研究脚本的实现。

导入模块

在 Python 标准库中,我们将使用math模块进行三角计算,并使用timeit模块进行精确的时间测量(这将使我们能够更有效地使用卡尔曼过滤器)。 和往常一样,我们还将使用 NumPy 和 OpenCV。 因此,我们对ImageTrackingDemo.py的实现始于以下import语句:

import math
import timeit

import cv2
import numpy

现在,让我们继续执行辅助函数。

执行灰度转换

在本书中,我们使用以下代码执行了灰度转换:

gray_img = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2GRAY)

也许早就应该提出一个问题:此函数如何将 BGR 值准确映射到灰度值? 答案是每个输出像素的灰度值都是相应输入像素的 B,G 和 R 值的加权*均值,如下所示:

gray = (0.114 * blue) + (0.587 * green) + (0.299 * red)

这些砝码被广泛使用。 它们来自于 1982 年发布的称为 CCIR 601 的电信行业标准。 当我们看到明亮的场景时,我们的眼睛对黄绿色光线最为敏感。 此外,这些配重应该在带有淡黄色灯光和带蓝色阴影的场景(例如晴天的室外场景)中产生高对比度。 这些是我们使用 CCIR 601 砝码的充分理由吗? 不,他们不是; 没有科学证据表明 CCIR 601 转换权重可为计算机视觉中的任何特定目的提供最佳的灰度输入。

实际上,出于图像跟踪的目的,有证据支持其他灰度转换算法。 SamuelMacêdo,GivânioMelo 和 Judith Kelner 在他们的论文中谈到了这个主题,《针对应用于 SIFT 描述符的灰度转换技术的比较研究》(SBC 交互式系统杂志,第 6 卷,第 2 期,2015 年) 。 他们测试各种转换算法,包括以下类型:

  • 加权*均转换gray = (0.07 * blue) + (0.71 * green) + (0.21 * red),与 CCIR 601 有点相似
  • 非加权*均转换gray = (blue + green + red) / 3
  • 仅基于单个颜色通道的转换,例如gray = green
  • 经过伽玛校正的转换,例如gray = 255 * (green / 255) ^ (1/2.2),其中,灰度值随输入呈指数(非线性)变化

根据该论文,加权*均转换产生的结果相对不稳定-有利于找到某些图像的匹配和单应性,而对于其他图像则不好。 非加权*均转换和单通道转换产生更一致的结果。 对于某些图像,经伽玛校正的转换可产生最佳结果,但这些转换在计算上更加昂贵。

为了演示的目的,我们将通过获取每个像素的 B,G 和 R 值的简单(未加权)*均值来执行灰度转换。 这种方法在计算上很便宜(在实时跟踪中很理想),并且我们期望它比 OpenCV 中的默认加权*均转换带来更一致的跟踪结果。 这是我们用于执行自定义转换的辅助函数的实现:

def convert_to_gray(src, dst=None):
    weight = 1.0 / 3.0
    m = numpy.array([[weight, weight, weight]], numpy.float32)
    return cv2.transform(src, m, dst)

注意cv2.transform函数的使用。 这是 OpenCV 提供的经过优化的通用矩阵转换函数。 我们可以使用它来执行以下操作:像素输出通道的值是输入通道值的线性组合。 在我们的 BGR 到灰度转换的情况下,我们有一个输出通道和三个输入通道,因此我们的转换矩阵m有一行三列。

编写了用于灰度转换的辅助函数后,让我们继续考虑用于从 2D 到 3D 空间转换的辅助函数。

执行 2D 到 3D 空间的转换

请记住,我们有一个参考图像reference_image.png,并且我们希望 AR 应用跟踪该图像的打印副本。 出于 3D 跟踪的目的,我们可以将此打印图像表示为 3D 空间中的*面。 让我们定义局部坐标系的方式是,通常(当 6DOF 姿势的元素全部为 0 时),此*面对象像悬挂在墙上的图片一样竖立; 它的正面是上面有图像的一侧,其原点是图像的中心。

现在,让我们假设我们想将参考图像中的给定像素映射到此 3D *面上。 给定 2D 像素坐标,图像的像素尺寸以及将像素转换为我们要在 3D 空间中使用的度量单位的比例因子,我们可以使用以下辅助函数将像素映射到*面上:

def map_point_onto_plane(point_2D, image_size, image_scale):
    x, y = point_2D
    w, h = image_size
    return (image_scale * (x - 0.5 * w),
            image_scale * (y - 0.5 * h),
            0.0)

比例因子取决于打印图像的实际大小和我们选择的单位。 例如,我们可能知道我们的打印图像高 20 厘米–或我们可能不在乎绝对比例,在这种情况下,我们可以定义一个任意单位,以使打印图像高一个单位。 无论如何,只要以任何单位(绝对或相对)给出 2D 像素坐标,参考图像的尺寸以及参考图像的实际高度的列表,我们就可以使用以下帮助器函数在列表上获取相应 3D 坐标的列表。 飞机:

def map_points_to_plane(points_2D, image_size, image_real_height):

    w, h = image_size
    image_scale = image_real_height / h

    points_3D = [map_point_onto_plane(
                     point_2D, image_size, image_scale)
                 for point_2D in points_2D]
    return numpy.array(points_3D, numpy.float32)

请注意,我们为多个点map_points_to_plane提供了一个辅助函数,并且为每个点map_point_to_plane都调用了一个辅助函数。

稍后,在“初始化跟踪器”部分中,我们将为参考图像生成 ORB 关键点描述符,并且我们将使用我们的map_points_to_plane辅助函数将关键点坐标从 2D 转换为 3D。 我们还将转换图像的四个 2D 顶点(即其左上角,右上角,右下角和左下角),以获得*面的四个 3D 顶点。 在执行 AR 绘制时,我们将使用这些顶点-特别是在“绘制跟踪结果”部分中。 与绘图相关的功能(在 OpenCV 和许多其他框架中)期望为 3D 形状的每个面按顺时针顺序(从正面角度)指定顶点。 为了满足此要求,让我们实现另一个专用于映射顶点的辅助函数。 这里是:

def map_vertices_to_plane(image_size, image_real_height):

    w, h = image_size

    vertices_2D = [(0, 0), (w, 0), (w, h), (0, h)]
    vertex_indices_by_face = [[0, 1, 2, 3]]

    vertices_3D = map_points_to_plane(
        vertices_2D, image_size, image_real_height)
    return vertices_3D, vertex_indices_by_face

请注意,我们的顶点映射帮助函数map_vertices_to_plane调用了map_points_to_plane帮助函数,该函数又调用了map_point_to_plane。 因此,我们所有的映射函数都有一个共同的核心。

当然,除了*面外,2D 到 3D 关键点映射和顶点映射也可以应用于其他 3D 形状。 若要了解我们的方法如何扩展到 3D 长方体和 3D 圆柱体,请参阅 Joseph Howse 的《可视化不可视》demo 项目,该项目可在这个页面

我们已经完成了辅助函数的实现。 现在,让我们继续进行代码的面向对象部分。

实现应用类

我们将在名为ImageTrackingDemo的类中实现我们的应用,该类将具有以下方法:

  • __init__(self, capture, diagonal_fov_degrees, target_fps, reference_image_path, reference_image_real_height):初始化器将为参考图像设置捕获设备,相机矩阵,卡尔曼过滤器以及 2D 和 3D 关键点。
  • run(self):此方法将运行应用的主循环,该循环捕获,处理和显示帧,直到用户通过按Esc键退出。 在其他方法的帮助下执行每个帧的处理,这些方法将在此列表中接下来提到。
  • _track_object(self):此方法将执行 6DOF 跟踪并绘制跟踪结果的 AR 可视化图像。
  • _init_kalman_transition_matrix(self, fps):此方法将配置卡尔曼过滤器,以确保针对指定的帧速率正确模拟加速度和速度。
  • _apply_kalman(self):此方法将通过应用卡尔曼过滤器来稳定 6DOF 跟踪结果。

让我们从__init__开始一步一步地介绍方法的实现。

初始化追踪器

__init__方法涉及许多步骤来初始化相机矩阵,ORB 描述符提取器,卡尔曼过滤器,参考图像的 2D 和 3D 关键点以及与我们的跟踪算法相关的其他变量:

  1. 首先,让我们看一下__init__接受的参数。 其中包括一个称为capturecv2.VideoCapture对象(相机); 摄像机的对角 FOV,以度为单位; 每秒帧FPS)中的预期帧速率; 包含参考图像的文件的路径; 以及参考图像实际高度的度量(以任何单位):
class ImageTrackingDemo():

    def __init__(self, capture, diagonal_fov_degrees=70.0,
                 target_fps=25.0,
                 reference_image_path='reference_image.png',
                 reference_image_real_height=1.0):
  1. 我们尝试从相机捕获一帧以确定其像素尺寸。 否则,我们将从相机的属性中获取尺寸:
        self._capture = capture
        success, trial_image = capture.read()
        if success:
            # Use the actual image dimensions.
            h, w = trial_image.shape[:2]
        else:
            # Use the nominal image dimensions.
            w = capture.get(cv2.CAP_PROP_FRAME_WIDTH)
            h = capture.get(cv2.CAP_PROP_FRAME_HEIGHT)
        self._image_size = (w, h)
  1. 现在,给定帧的尺寸(以像素为单位)以及相机和镜头的 FOV,我们可以使用三角函数以像素等效单位计算焦距。 (该公式是我们在本章前面的“了解相机和镜头参数”部分中得出的公式。)此外,利用焦距和镜框的中心点,我们可以构建相机矩阵。 以下是相关代码:
        diagonal_image_size = (w ** 2.0 + h ** 2.0) ** 0.5
        diagonal_fov_radians = \
            diagonal_fov_degrees * math.pi / 180.0
        focal_length = 0.5 * diagonal_image_size / math.tan(
            0.5 * diagonal_fov_radians)
        self._camera_matrix = numpy.array(
            [[focal_length, 0.0, 0.5 * w],
             [0.0, focal_length, 0.5 * h],
             [0.0, 0.0, 1.0]], numpy.float32)
  1. 为了简单起见,我们假定镜头不会遭受任何扭曲:
        self._distortion_coefficients = None
  1. 最初,我们不跟踪对象,因此我们无法估计其旋转和位置。 我们只将相关变量定义为None
        self._rotation_vector = None
        self._translation_vector = None
  1. 现在,让我们设置一个卡尔曼过滤器:
        self._kalman = cv2.KalmanFilter(18, 6)

        self._kalman.processNoiseCov = numpy.identity(
            18, numpy.float32) * 1e-5
        self._kalman.measurementNoiseCov = numpy.identity(
            6, numpy.float32) * 1e-2
        self._kalman.errorCovPost = numpy.identity(
            18, numpy.float32)

        self._kalman.measurementMatrix = numpy.array(
            [[1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]],
            numpy.float32)

        self._init_kalman_transition_matrix(target_fps)

如前面的代码cv2.KalmanFilter(18, 6)所示,该卡尔曼过滤器将基于 6 个输入变量(或测量值)跟踪 18 个输出变量(或预测)。 具体来说,输入变量是 6DOF 跟踪结果的元素:t[x]t[y]t[z]r[x]r[y]r[z]。 输出变量是稳定的 6DOF 跟踪结果的元素,以及它们的一阶导数(速度)和二阶导数(加速度),其顺序如下:t[x]t[y]t[z]t[x]'t[y]'t[z]'t[x]'t[y]'t[z]'r[x]r[y]r[z]r[x]'r[y]'r[z]'r[x]'r[y]'r[z]'。 卡尔曼过滤器的测量矩阵有 18 列(代表输出变量)和 6 行(代表输入变量)。 在每一行中,我们在与匹配的输出变量相对应的索引中放入 1.0; 在其他地方,我们放 0.0。 我们还初始化了一个转换矩阵,该矩阵定义了输出变量之间随时间的关系。 初始化的这一部分由辅助方法_init_kalman_transition_matrix(target_fps)处理,我们将在稍后的“初始化和应用卡尔曼过滤器”部分中进行检查。

并非我们的__init__方法都会初始化所有的卡尔曼过滤器矩阵。 由于实际帧速率(以及时间步长)可能会发生变化,因此在跟踪过程中每帧更新过渡矩阵。 每次我们开始跟踪对象时,都会初始化状态矩阵。 我们将在适当的时候在“初始化和应用卡尔曼过滤器”部分中介绍卡尔曼过滤器使用的这些方面。

  1. 我们需要一个布尔变量(最初是False)来指示我们是否成功跟踪了前一帧中的对象:
        self._was_tracking = False
  1. 我们需要定义一些 3D 图形的顶点,作为 AR 可视化的一部分,我们将绘制每一帧。 具体而言,图形将是代表对象的XYZ轴的一组箭头。 这些图形的比例将与实际对象的比例有关,即我们要跟踪的打印图像。 请记住,作为其参数之一,__init__方法采用图像的比例尺-特别是其高度-并且此度量单位可以是任何单位。 让我们将 3D 轴箭头的长度定义为打印图像高度的一半:
        self._reference_image_real_height = \
            reference_image_real_height
        reference_axis_length = 0.5 * reference_image_real_height
  1. 使用我们刚刚定义的长度,让我们定义相对于打印图像中心[0.0, 0.0, 0.0]的轴箭头的顶点:
        self._reference_axis_points_3D = numpy.array(
            [[0.0, 0.0, 0.0],
             [-reference_axis_length, 0.0, 0.0],
             [0.0, -reference_axis_length, 0.0],
             [0.0, 0.0, -reference_axis_length]], numpy.float32)

请注意,OpenCV 的坐标系具有非标准轴方向,如下所示:

  • X(正 X 方向)是对象的左手方向,或者是在对象正视图中查看者的右手方向。
  • Y是向下。
  • Z是对象的后向方向,即在对象的正面视图中观察者的向前方向。

为了获得以下标准右手坐标系,我们必须取反所有上述方向,就像在许多 3D 图形框架(例如 OpenGL)中使用的那样:

  • X是对象正面的方向,即观看者的左手方向。
  • Y是向上。
  • Z是对象的正面方向,或者是查看者在对象的正面视图中的向后方向。

出于本书的目的,我们使用 OpenCV 绘制 3D 图形,因此即使在绘制可视化效果时,我们也可以简单地遵循 OpenCV 的非标准轴方向。 但是,如果将来要进行进一步的 AR 工作,则可能需要使用右手坐标系将计算机视觉代码与 OpenGL 和其他 3D 图形框架集成在一起。 为了更好地为您做好准备,我们将在以 OpenCV 为中心的演示中转换轴方向。

  1. 我们将使用三个数组来保存三种图像:BGR 视频帧(将在其中进行 AR 绘制),帧的灰度版本(将用于关键点匹配)和遮罩(在其中进行绘制) 被跟踪对象的轮廓)。 最初,这些数组都是None
        self._bgr_image = None
        self._gray_image = None
        self._mask = None
  1. 我们将使用cv2.ORB对象来检测关键点,并为参考图像以及随后的相机帧计算描述符。 我们按以下方式初始化cv2.ORB对象:
        # Create and configure the feature detector.
        patchSize = 31
        self._feature_detector = cv2.ORB_create(
            nfeatures=250, scaleFactor=1.2, nlevels=16,
            edgeThreshold=patchSize, patchSize=patchSize)

有关 ORB 算法及其在 OpenCV 中的用法的更新,请参考第 6 章,“检索图像并使用图像描述符进行搜索”,特别是“将 ORB 与 FAST 特征和 BERIEF 描述符一起使用”部分。

在这里,我们为cv2.ORB的构造器指定了几个可选参数。 描述符覆盖的直径为 31 个像素,我们的图像金字塔有 16 个级别,连续级别之间的缩放系数为 1.2,并且每次检测尝试最多需要 250 个关键点和描述符。

  1. 现在,我们从文件中加载参考图像,调整其大小,将其转换为灰度,并为其创建一个空的遮罩:
        bgr_reference_image = cv2.imread(
            reference_image_path, cv2.IMREAD_COLOR)
        reference_image_h, reference_image_w = \
            bgr_reference_image.shape[:2]
        reference_image_resize_factor = \
            (2.0 * h) / reference_image_h
        bgr_reference_image = cv2.resize(
            bgr_reference_image, (0, 0), None,
            reference_image_resize_factor,
            reference_image_resize_factor, cv2.INTER_CUBIC)
        gray_reference_image = convert_to_gray(bgr_reference_image)
        reference_mask = numpy.empty_like(gray_reference_image)

调整参考图像的大小时,我们选择使其比相机框高两倍。 确切的数字是任意的; 但是,我们的想法是我们要使用覆盖了有用放大倍率的图像金字塔来执行关键点检测和描述。 金字塔的底面(即调整大小后的参考图像)应大于摄像头框架,以便即使目标对象离摄像头非常*,以致无法完全适合框架,我们也可以以适当的比例匹配关键点 。 相反,金字塔的顶层应该小于摄影机框架,这样即使目标物体距离无法填满整个框架,我们也可以以适当的比例匹配关键点。

让我们考虑一个例子。 假设我们的原始参考图像为4000 x 3000像素,而我们的相机帧为4000 x 3000像素。 我们将参考图像的尺寸调整为4000 x 3000像素(帧高度的两倍,并且纵横比与原始参考图像相同)。 因此,我们的图像金字塔的底面也是4000 x 3000像素。 由于我们的cv2.ORB对象配置为使用 16 个金字塔等级且比例因子为 1.2,因此图像金字塔的顶部宽度为1920 / (1.2^(16-1)) = 124像素,高度为1440 / (1.2^(16-1)) = 93像素; 换句话说,它是4000 x 3000像素。 因此,即使物体相距太远,以至于它仅占框架宽度或高度的 10%,我们也可以匹配关键点并跟踪该物体。 实际上,要在此级别上执行有用的关键点匹配,我们需要一个好的镜头,该物体需要聚焦,并且照明也必须很好。

  1. 在此阶段,我们有一个大小适当的 BGR 颜色和灰度参考图像,并且对此图像有一个空遮罩。 我们将图像划分为 36 个大小相等的兴趣区域(在6 x 6网格中),并且对于每个区域,我们将尝试生成多达 250 个关键点和描述符(因为已使用最大数量的关键点和描述符配置cv2.ORB对象)。 这种分区方案有助于确保我们在每个区域中都有一些关键点和描述符,因此即使对象的大多数部分在给定帧中不可见,我们也可以潜在地匹配关键点并跟踪对象。 以下代码块显示了我们如何在兴趣区域上进行迭代,并为每个区域创建掩码,执行关键点检测和描述符提取,以及将关键点和描述符附加到主列表中:
        # Find keypoints and descriptors for multiple segments of
        # the reference image.
        reference_keypoints = []
        self._reference_descriptors = numpy.empty(
            (0, 32), numpy.uint8)
        num_segments_y = 6
        num_segments_x = 6
        for segment_y, segment_x in numpy.ndindex(
                (num_segments_y, num_segments_x)):
            y0 = reference_image_h * \
                segment_y // num_segments_y - patchSize
            x0 = reference_image_w * \
                segment_x // num_segments_x - patchSize
            y1 = reference_image_h * \
                (segment_y + 1) // num_segments_y + patchSize
            x1 = reference_image_w * \
                (segment_x + 1) // num_segments_x + patchSize
            reference_mask.fill(0)
            cv2.rectangle(
                reference_mask, (x0, y0), (x1, y1), 255, cv2.FILLED)
            more_reference_keypoints, more_reference_descriptors = \
                self._feature_detector.detectAndCompute(
                    gray_reference_image, reference_mask)
            if more_reference_descriptors is None:
                # No keypoints were found for this segment.
                continue
            reference_keypoints += more_reference_keypoints
            self._reference_descriptors = numpy.vstack(
                (self._reference_descriptors,
                 more_reference_descriptors))
  1. 现在,我们在灰度参考图像上方绘制关键点的可视化效果:
        cv2.drawKeypoints(
            gray_reference_image, reference_keypoints,
            bgr_reference_image,
            flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
  1. 接下来,我们将可视化文件保存到名称后附加_keypoints的文件中。 例如,如果参考图像的文件名是reference_image.png,则将可视化文件另存为reference_image_keypoints.png。 以下是相关代码:
        ext_i = reference_image_path.rfind('.')
        reference_image_keypoints_path = \
            reference_image_path[:ext_i] + '_keypoints' + \
            reference_image_path[ext_i:]
        cv2.imwrite(
            reference_image_keypoints_path, bgr_reference_image)
  1. 我们继续使用自定义参数初始化基于 FLANN 的匹配器:
        FLANN_INDEX_LSH = 6
        index_params = dict(algorithm=FLANN_INDEX_LSH,
                            table_number=6, key_size=12,
                            multi_probe_level=1)
        search_params = dict()
        self._descriptor_matcher = cv2.FlannBasedMatcher(
            index_params, search_params)

这些参数指定我们正在使用具有 6 个哈希表,12 位哈希键大小和 1 个多探针级别的多探针 LSH(位置敏感哈希)索引算法。

有关多探针 LSH 算法的说明,请参阅论文《多探针 LSH:高维相似性搜索的有效索引》(VLDB,2007 年),由 Qin Lv,William Josephson,Zhe Wang, 摩西·查里卡尔(Moses Charikar)和李凯(Kai Li)。 可在这个页面获得电子版本。

  1. 我们通过向其提供参考描述符来训练匹配器:
        self._descriptor_matcher.add([self._reference_descriptors])
  1. 我们获取关键点的 2D 坐标,并将它们馈送到map_points_to_plane辅助函数中,以便在对象*面的表面上获得等效的 3D 坐标:
        reference_points_2D = [keypoint.pt
                               for keypoint in reference_keypoints]
        self._reference_points_3D = map_points_to_plane(
            reference_points_2D, gray_reference_image.shape[::-1],
            reference_image_real_height)
  1. 类似地,我们调用map_vertices_to_plane函数以获得*面的 3D 顶点和 3D 面:
        (self._reference_vertices_3D,
         self._reference_vertex_indices_by_face) = \
            map_vertices_to_plane(
                    gray_reference_image.shape[::-1],
                    reference_image_real_height)

到此结束__init__方法的实现。 接下来,让我们看一下run方法,它表示应用的主循环。

实现主循环

像往常一样,我们的主循环的主要作用是捕获和处理帧,直到用户按下Esc键。 每个帧的处理(包括 3D 跟踪和 AR 绘制)都委托给称为_track_object的辅助方法,稍后将在《跟踪 3D 图像》部分中进行探讨。 主循环还具有辅助作用:即通过测量帧速率并相应地更新卡尔曼过滤器的转换矩阵来执行计时。 此更新委托给另一种辅助方法_init_kalman_transition_matrix,我们将在“初始化和应用卡尔曼过滤器”部分中进行研究。 考虑到这些角色,我们可以在run方法中实现main循环,如下所示:

    def run(self):

        num_images_captured = 0
        start_time = timeit.default_timer()

        while cv2.waitKey(1) != 27:  # Escape
            success, self._bgr_image = self._capture.read(
                self._bgr_image)
            if success:
                num_images_captured += 1
                self._track_object()
                cv2.imshow('Image Tracking', self._bgr_image)
            delta_time = timeit.default_timer() - start_time
            if delta_time > 0.0:
                fps = num_images_captured / delta_time
                self._init_kalman_transition_matrix(fps)

请注意 Python 标准库中timeit.default_timer函数的使用。 此函数提供了以秒为单位的当前系统时间的精确测量值(作为浮点数,因此可以表示秒的分数)。 就像名称timeit所暗示的那样,此模块包含有用的功能,适用于以下情况:您具有时间敏感的代码,并且想要为其计时

让我们继续进行_track_object的实现,因为此助手代表run执行了应用工作的最大部分。

在 3D 中追踪图像

_track_object方法直接负责关键点匹配,关键点可视化和解决 PnP 问题。 此外,它调用其他方法来处理卡尔曼滤波,AR 绘制和掩盖被跟踪的对象:

  1. 为了开始_track_object的实现,我们调用convert_to_gray辅助函数将帧转换为灰度:
    def _track_object(self):

        self._gray_image = convert_to_gray(
            self._bgr_image, self._gray_image)
  1. 现在,我们使用cv2.ORB对象检测灰度图像的遮罩区域中的关键点并计算描述符:
        if self._mask is None:
            self._mask = numpy.full_like(self._gray_image, 255)

        keypoints, descriptors = \
            self._feature_detector.detectAndCompute(
                self._gray_image, self._mask)

如果我们已经在前一帧中跟踪了对象,则遮罩将覆盖我们先前找到该对象的区域。 否则,遮罩会覆盖整个框架,因为我们不知道对象可能在哪里。 稍后,我们将在“绘制跟踪结果并屏蔽被跟踪的对象”部分中了解如何创建遮罩。

  1. 接下来,我们使用 FLANN 匹配器查找参考图像的关键点与帧的关键点之间的匹配项,并根据比率测试过滤这些匹配项:
        # Find the 2 best matches for each descriptor.
        matches = self._descriptor_matcher.knnMatch(descriptors, 2)

        # Filter the matches based on the distance ratio test.
        good_matches = [
            match[0] for match in matches
            if len(match) > 1 and \
            match[0].distance < 0.6 * match[1].distance
        ]

有关 FLANN 匹配和比率测试的详细信息,请参考第 6 章,“检索图像并使用图像描述符进行搜索”。

  1. 在此阶段,我们列出了通过比率测试的良好匹配项。 让我们选择与这些良好匹配相对应的框架关键点的子集,然后在框架上绘制红色圆圈以可视化这些关键点:
        # Select the good keypoints and draw them in red.
        good_keypoints = [keypoints[match.queryIdx]
                          for match in good_matches]
        cv2.drawKeypoints(self._gray_image, good_keypoints,
                          self._bgr_image, (0, 0, 255))
  1. 找到了不错的比赛之后,我们显然知道其中有多少人。 如果计数很小,那么总的来说,这组匹配项可能会令人怀疑且不足以进行跟踪。 我们为良好匹配的最小数量定义了两个不同的阈值:较高的阈值(如果我们只是开始跟踪(即,我们没有在前一帧中跟踪对象))和较低的阈值(如果我们正在继续跟踪) 跟踪前一帧中的对象):
        min_good_matches_to_start_tracking = 8
        min_good_matches_to_continue_tracking = 6
        num_good_matches = len(good_matches)
  1. 如果我们甚至没有达到下限阈值,那么我们会注意到我们没有在该帧中跟踪对象,因此我们将遮罩重置为覆盖整个帧:
        if num_good_matches < min_good_matches_to_continue_tracking:
            self._was_tracking = False
            self._mask.fill(255)
  1. 另一方面,如果我们有足够的匹配项来满足适用的阈值,那么我们将继续尝试跟踪对象。 第一步是在框架中选择良好匹配的 2D 坐标,并在reference对象的模型中选择其 3D 坐标:
        elif num_good_matches >= \
                min_good_matches_to_start_tracking or \
                    self._was_tracking:

            # Select the 2D coordinates of the good matches.
            # They must be in an array of shape (N, 1, 2).
            good_points_2D = numpy.array(
                [[keypoint.pt] for keypoint in good_keypoints],
                numpy.float32)

            # Select the 3D coordinates of the good matches.
            # They must be in an array of shape (N, 1, 3).
            good_points_3D = numpy.array(
                [[self._reference_points_3D[match.trainIdx]]
                 for match in good_matches],
                numpy.float32)
  1. 现在,我们准备使用本章开头在“了解cv2.solvePnPRansac”部分中介绍的各种参数来调用cv2.solvePnPRansac。 值得注意的是,我们仅从良好匹配中使用 3D 参考关键点和 2D 场景关键点:
            # Solve for the pose and find the inlier indices.
            (success, self._rotation_vector,
             self._translation_vector, inlier_indices) = \
                cv2.solvePnPRansac(good_points_3D, good_points_2D,
                                   self._camera_matrix,
                                   self._distortion_coefficients,
                                   self._rotation_vector,
                                   self._translation_vector,
                                   useExtrinsicGuess=False,
                                   iterationsCount=100,
                                   reprojectionError=8.0,
                                   confidence=0.99,
                                   flags=cv2.SOLVEPNP_ITERATIVE)
  1. 解算器可能收敛或未收敛于 PnP 问题的解决方案。 如果没有收敛,则此方法将不再做任何事情。 如果收敛,则下一步是检查是否已在上一帧中跟踪对象。 如果我们尚未跟踪它(换句话说,如果我们开始在此帧中重新跟踪对象),则可以通过调用辅助方法_init_kalman_state_matrices重新初始化卡尔曼过滤器:
            if success:

                if not self._was_tracking:
                    self._init_kalman_state_matrices()
  1. 现在,无论如何,我们都在该帧中跟踪对象,因此我们可以通过调用另一个辅助方法_apply_kalman来应用卡尔曼过滤器:
                self._was_tracking = True

                self._apply_kalman()
  1. 在这一阶段,我们有一个经过卡尔曼滤波的 6DOF 姿态。 我们还列出了cv2.solvePnPRansac中的内部关键点。 为了帮助用户可视化结果,让我们以绿色绘制内部关键点:
                # Select the inlier keypoints.
                inlier_keypoints = [good_keypoints[i]
                                    for i in inlier_indices.flat]

                # Draw the inlier keypoints in green.
                cv2.drawKeypoints(self._bgr_image, inlier_keypoints,
                                  self._bgr_image, (0, 255, 0))

请记住,在此方法的前面,我们用红色绘制了所有关键点。 现在,我们以绿色绘制了内部关键点,只有外部关键点仍然是红色。

  1. 最后,我们再调用两个辅助方法:self._draw_object轴绘制被跟踪对象的 3D 轴,self._make_and_draw_object_mask绘制并绘制包含对象的区域的遮罩:
                # Draw the axes of the tracked object.
                self._draw_object_axes()

                # Make and draw a mask around the tracked object.
                self._make_and_draw_object_mask()

结束我们的_track_object方法的实现。 到目前为止,我们已经大致了解了跟踪算法的实现,但是我们仍然需要实现与卡尔曼过滤器有关的辅助方法(在下一节“初始化和应用卡尔曼过滤器”中)以及遮罩和 AR 绘制(在其后的“绘制跟踪结果并遮盖跟踪的对象”部分中)。

初始化和应用卡尔曼过滤器

我们在“初始化跟踪器”部分中介绍了卡尔曼过滤器初始化的某些方面。 但是,在该部分中,我们注意到,随着应用运行在各种帧以及跟踪或不跟踪的各种状态下,卡尔曼过滤器的某些矩阵需要多次初始化或重新初始化。 具体来说,以下矩阵将发生变化:

  • 转换矩阵:此矩阵表示所有输出变量之间的时间关系。 例如,该矩阵可以模拟加速度对速度的影响以及速度对位置的影响。 我们将每帧重新初始化转换矩阵,因为帧速率(以及帧之间的时间步长)是可变的。 有效地,这是缩放先前的加速度和速度预测以匹配新时间步长的一种方法。
  • 校正前和校正后状态矩阵:这些矩阵包含输出变量的预测。 预校正矩阵中的预测仅考虑先前状态和转换矩阵。 校正后矩阵中的预测还考虑了新的输入和卡尔曼过滤器的其他矩阵。 每当我们从非跟踪状态变为跟踪状态时,换句话说,当我们无法在前一帧中跟踪对象但现在我们成功地在当前帧中跟踪对象时,我们将重新初始化状态矩阵。 实际上,这是一种清除过时的预测并从新的测量重新开始的方法。

让我们先看一下转换矩阵。 其初始化方法将使用一个参数fps,即每秒帧数。 我们可以通过三个步骤来实现该方法:

  1. 我们首先验证fps参数。 如果不是正数,我们将立即返回而不会更新过渡矩阵:
    def _init_kalman_transition_matrix(self, fps):

        if fps <= 0.0:
            return
  1. 确定fps为正后,我们继续计算速度和加速度的过渡速率。 我们希望速度转换速率与时间步长(即每帧的时间)成比例。 因为fps(每秒帧数)是时间步长的倒数(即每帧秒数),所以速度转换率与fps成反比。 加速度变化率与速度变化率的*方成正比(因此,加速度变化率与fps的*方成反比)。 选择 1.0 作为速度转换率的基本比例,选择 0.5 作为加速度转换率的基本比例,我们可以在代码中进行如下计算:
        # Velocity transition rate
        vel = 1.0 / fps

        # Acceleration transition rate
        acc = 0.5 * (vel ** 2.0)
  1. 接下来,我们填充转换矩阵。 由于我们有 18 个输出变量,因此转换矩阵具有 18 行和 18 列。 首先,让我们看一下矩阵的内容,然后,我们将考虑如何解释它:
        self._kalman.transitionMatrix = numpy.array(
            [[1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0, acc],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, vel],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0],
             [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0,
              0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]],
            numpy.float32)

每行表示一个公式,用于根据前一帧的输出值来计算新的输出值。 让我们以第一行为例。 我们可以将其解释如下:

新的t[x]值取决于旧的t[x]t[x]'t[x]'值,以及速度转换率v和加速度转换率a。 正如我们之前在此函数中看到的那样,这些过渡速率可能会有所变化,因为时间步长可能会有所不同。

到此结束了用于初始化或更新转换矩阵的辅助方法的实现。 请记住,由于帧速率(以及时间步长)可能已更改,因此我们每帧都调用此函数。

我们还需要一个辅助函数来初始化状态矩阵。 请记住,每当我们从非跟踪状态过渡到跟踪状态时,我们都会调用此方法。 此过渡是清除以前所有预测的适当时间; 相反,我们重新开始时就相信对象的 6DOF 姿势正是 PnP 求解器所说的。 此外,我们假设物体是静止的,速度为零,加速度为零。 这是辅助方法的实现:

    def _init_kalman_state_matrices(self):

        t_x, t_y, t_z = self._translation_vector.flat
        r_x, r_y, r_z = self._rotation_vector.flat

        self._kalman.statePre = numpy.array(
            [[t_x], [t_y], [t_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0],
             [r_x], [r_y], [r_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0]], numpy.float32)
        self._kalman.statePost = numpy.array(
            [[t_x], [t_y], [t_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0],
             [r_x], [r_y], [r_z],
             [0.0], [0.0], [0.0],
             [0.0], [0.0], [0.0]], numpy.float32)

注意,由于我们有 18 个输出变量,因此状态矩阵有 1 行和 18 列。

现在我们已经介绍了初始化和重新初始化卡尔曼过滤器的矩阵的过程,让我们看一下如何应用过滤器。 正如我们之前在第 8 章,“跟踪对象”中所看到的,我们可以要求卡尔曼过滤器估计对象的新姿态(输出变量的校正前状态),然后我们可以告诉它考虑最新的不稳定跟踪结果(输入变量)以调整其估计值(从而产生校正后的状态),最后,我们可以从调整后的估计值中提取变量以用作稳定后的跟踪结果。 与我们以前的工作相比,这次的唯一区别是我们有更多的输入和输出变量。 以下代码显示了我们如何实现在 6DOF 跟踪器的上下文中应用卡尔曼过滤器的方法:

    def _apply_kalman(self):

        self._kalman.predict()

        t_x, t_y, t_z = self._translation_vector.flat
        r_x, r_y, r_z = self._rotation_vector.flat

        estimate = self._kalman.correct(numpy.array(
            [[t_x], [t_y], [t_z],
             [r_x], [r_y], [r_z]], numpy.float32))

        self._translation_vector = estimate[0:3]
        self._rotation_vector = estimate[9:12]

这里,请注意,estimate[0:3]对应于t[x]t[y]t[z],而estimate[9:12]对应于r[x]r[y]r[z]estimate数组的其余部分对应于一阶导数(速度)和二阶导数(加速度)。

至此,我们几乎完全探索了 3D 跟踪算法的实现,包括使用卡尔曼过滤器来稳定 6DOF 姿态以及速度和加速度。 现在,让我们将注意力转向ImageTrackingDemo类的两个最终实现细节:AR 绘制方法和基于跟踪结果创建遮罩。

绘制跟踪结果并遮盖被跟踪对象

我们将实现一个辅助方法_draw_object_axes,以绘制跟踪对象的XYZ轴的可视化图像。 我们还将实现另一种辅助方法_make_and_draw_object_mask,以将对象的顶点从 3D 投影到 2D,基于对象的轮廓创建遮罩,并将该遮罩的区域染成黄色以显示。

让我们从_draw_object_axes的实现开始。 我们可以分三个阶段来考虑:

  1. 首先,我们要获取一组沿轴放置的 3D 点,并将这些点投影到 2D 图像空间。 请记住,我们在“初始化跟踪器”部分的__init__方法中定义了 3D 轴点。 它们将仅用作我们将绘制的轴箭头的端点。 使用cv2.projectPoints函数,6DOF 跟踪结果和相机矩阵,我们可以找到 2D 投影点,如下所示:
    def _draw_object_axes(self):

        points_2D, jacobian = cv2.projectPoints(
            self._reference_axis_points_3D, self._rotation_vector,
            self._translation_vector, self._camera_matrix,
            self._distortion_coefficients)

除了返回投影的 2D 点之外,cv2.projectPoints还返回雅可比矩阵,该矩阵表示用于计算 2D 点的函数的偏导数(相对于输入参数)。 此信息可能对相机校准很有用,但在本示例中不使用它。

  1. 投影点采用浮点格式,但是我们需要整数才能传递给 OpenCV 的绘图函数。 因此,我们将以下转换为整数格式:
        origin = (int(points_2D[0, 0, 0]), int(points_2D[0, 0, 1]))
        right = (int(points_2D[1, 0, 0]), int(points_2D[1, 0, 1]))
        up = (int(points_2D[2, 0, 0]), int(points_2D[2, 0, 1]))
        forward = (int(points_2D[3, 0, 0]), int(points_2D[3, 0, 1]))
  1. 在计算了端点之后,我们现在可以绘制三个箭头线来表示 X,Y 和 Z 轴:
        # Draw the X axis in red.
        cv2.arrowedLine(self._bgr_image, origin, right, (0, 0, 255))

        # Draw the Y axis in green.
        cv2.arrowedLine(self._bgr_image, origin, up, (0, 255, 0))

        # Draw the Z axis in blue.
        cv2.arrowedLine(
            self._bgr_image, origin, forward, (255, 0, 0))

我们已经完成了_draw_object_axes的实现。 现在,让我们将注意力转移到_make_and_draw_object_mask上,我们也可以从三个步骤来考虑:

  1. 像以前的函数一样,该函数从将点从 3D 投影到 2D 开始。 这次,我们在“初始化跟踪器”部分的__init__方法中定义了参考对象的顶点。 这是投影代码:
    def _make_and_draw_object_mask(self):

        # Project the object's vertices into the scene.
        vertices_2D, jacobian = cv2.projectPoints(
            self._reference_vertices_3D, self._rotation_vector,
            self._translation_vector, self._camera_matrix,
            self._distortion_coefficients)
  1. 同样,我们将投影点从浮点格式转换为整数格式(因为 OpenCV 的绘图函数需要整数):
        vertices_2D = vertices_2D.astype(numpy.int32)
  1. 投影的顶点形成凸多边形。 我们可以将遮罩涂成黑色(作为背景),然后以白色绘制此凸多边形:
        # Make a mask based on the projected vertices.
        self._mask.fill(0)
        for vertex_indices in \
                self._reference_vertex_indices_by_face:
            cv2.fillConvexPoly(
                self._mask, vertices_2D[vertex_indices], 255)

请记住,我们的_track_object方法在处理下一帧时将使用此掩码。 具体来说,_track_object将仅在遮罩区域中查找关键点。 因此,它将尝试在我们最*找到它的区域中找到该对象。

潜在地,我们可以通过应用形态学扩张操作来扩展遮罩区域来改进此技术。 这样,我们不仅可以在最*找到它的区域中搜索对象,而且可以在周围区域中搜索。

  1. 现在,在 BGR 框架中,让我们以黄色突出显示被遮罩的区域,以可视化被跟踪对象的形状。 为了使区域更黄,我们可以从蓝色通道中减去一个值。 cv2.subtract函数适合我们的目的,因为它接受可选的mask参数。 这是我们的用法:
        # Draw the mask in semi-transparent yellow.
        cv2.subtract(
            self._bgr_image, 48, self._bgr_image, self._mask)

当我们告诉cv2.subtract从图像中减去单个标量值(例如 48)时,它仅从图像的第一个通道(在这种情况下(大多数情况下)是 BGR 图像的蓝色通道)中减去该值。 可以说这是一个错误,但可以方便地将其着色为黄色!

那是ImageTrackingDemo类中的最后一个方法。 现在,让我们通过实例化该类并调用其run方法来使演示栩栩如生!

运行和测试应用

为了完成ImageTrackingDemo.py的实现,让我们编写一个main函数,该函数以指定的捕获设备,FOV 和目标帧速率启动应用:

def main():

    capture = cv2.VideoCapture(0)
    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 1280)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 720)
    diagonal_fov_degrees = 70.0
    target_fps = 25.0

    demo = ImageTrackingDemo(
        capture, diagonal_fov_degrees, target_fps)
    demo.run()

if __name__ == '__main__':
    main()

在这里,我们使用的捕获分辨率为 1280 x 720,对角 FOV 为 70 度,目标帧速率为 25 FPS。 您应该选择适合您的相机和镜头以及系统速度的参数。

假设我们运行该应用,并从reference_image.png加载以下图像:

当然,这是约瑟夫·霍斯(Joseph Howse)所著的《OpenCV 4 for Secret Agents》(Packt Publishing,2019)的封面。 它不仅是秘密知识的库,而且还是图像跟踪的良好目标。 您应该购买印刷本!

在初始化期间,应用将参考关键点的以下可视化保存到名为reference_image_keypoints.png的新文件中:

在第 6 章,“检索图像和使用图像描述符进行搜索”之前,我们已经看到了这种类型的可视化。 大圆圈表示可以在小范围内匹配的关键点(例如,当我们从远距离或使用低分辨率相机查看打印的图像时)。 小圆圈代表可以大规模匹配的关键点(例如,当我们*距离观看打印的图像或使用高分辨率相机时)。 最好的关键点是许多同心圆标记的,因为它们可以在不同的比例下匹配。 在每个圆圈内,径向线表示关键点的法线方向。

通过研究此可视化,我们可以推断出该图像的最佳关键点集中在图像顶部的高对比度文本(白色对深灰色)中。 在许多区域中还可以找到其他有用的关键点,包括图像底部的高对比度线(黑与饱和色)。

接下来,我们看到一个相机供稿。 将参考图像打印在相机前面时,我们会看到跟踪结果的 AR 可视化效果:

当然,前面的屏幕快照显示了书封面的*正面视图。 轴方向按预期绘制。 X 轴(红色)指向书套的右侧(查看者的左侧)。 Y 轴(绿色)指向上方。 Z 轴(蓝色)从书的封面指向前方(朝着查看者)。 作为增强现实效果,在跟踪的书的封面(包括由 Joseph Howse 的食指和中指覆盖的部分)上叠加了半透明的黄色高光。 绿色和红色小点的位置表明,在此帧中,良好的关键点匹配集中在书名的区域中,而这些良好的匹配中的大多数都是cv2.solvePnPRansac的整数。

如果您正在阅读本书的印刷版,则屏幕截图将以灰度复制。 为了使 X,Y 和 Z 轴在灰度打印中更容易区分,已将文本标签手动添加到屏幕截图中。 这些文本标签不属于程序输出的一部分。

因为我们努力在整个图像的多个区域中找到良好的关键点,所以即使被跟踪图像的很大一部分处于阴影,被遮盖或在框架外时,跟踪也可以成功。 例如,在下面的屏幕截图中,即使大部分书的封面(包括几乎所有具有最佳关键点的书名)都在框架之外,轴方向和突出显示的区域也是正确的:

继续并使用各种参考图像,照相机和观看条件进行自己的实验。 为参考图像和相机尝试各种分辨率。 切记要测量相机的 FOV 并相应地调整 FOV 参数。 研究关键点的可视化效果和跟踪结果。 在我们的演示中,哪种输入产生良好(或不良)的跟踪结果?

如果您发现使用打印的图像进行跟踪不方便,则可以将相机对准要显示要跟踪的图像的屏幕(例如智能手机屏幕)。 由于屏幕是背光的(也可能是光滑的),因此它可能无法忠实地表示打印图像在任何给定场景中的外观,但通常可以很好地用于跟踪器的目的。

对心脏的内容进行实验后,让我们考虑一些 3D 跟踪器可以改进的方法。

改进 3D 跟踪算法

本质上,我们的 3D 跟踪算法结合了三种方法:

  1. 使用 PnP 求解器查找 6DOF 姿势,该姿势的输入取决于基于 FLANN 的 ORB 描述符匹配。
  2. 使用卡尔曼过滤器来稳定 6DOF 跟踪结果。
  3. 如果在前一帧中跟踪到对象,请使用遮罩将搜索限制到现在最有可能找到该对象的区域。

3D 跟踪的商业解决方案通常涉及其他方法。 我们依靠成功地为每个帧使用描述符匹配器和 PnP 解算器。 但是,更复杂的算法可能会提供一些替代方案,如后备或交叉检查机制。 这是因为描述符匹配器和 PnP 求解器在某些帧中错过了对象,或者它们在计算上过于昂贵而无法用于每个帧。 广泛使用以下替代方法:

  • 根据光流更新先前的关键点匹配,并根据关键点的旧位置和新位置之间的单应性更新前一个 6DOF 姿势(根据光流)。
  • 根据陀螺仪和磁力计(罗盘)更新 6DOF 姿势的旋转分量。 通常,即使在消费类设备中,这些传感器也可以成功地测量旋转的大小变化。
  • 根据气压计和 GPS 更新 6DOF 姿态的位置分量。 通常,在消费类设备中,气压计可以以大约 10cm 的精度测量高度变化,而 GPS 可以以大约 10m 的精度测量经度和纬度的变化。 根据使用情况,这些精度可能是有用的,也可能不是。 如果我们试图在大而远的景观特征上进行增强现实(例如),如果我们想绘制一条栖息在真实山顶上的虚拟巨龙,那么 10m 的精度可能会更好。 对于详细的工作(例如,如果我们想在真实的手指上画一个虚拟的戒指),则无法使用 10 厘米的精度。
  • 根据加速度计更新卡尔曼过滤器的位置加速度分量。 通常,在消费类设备中,加速度计会产生漂移(误差会在一个方向或另一个方向上显示失控的趋势),因此应谨慎使用此选项。

这些替代技术不在本书的讨论范围之内,实际上,其中一些不是计算机视觉技术,因此我们将其留给您进行独立研究。

最后一句话:有时,通过更改预处理算法而不是跟踪算法本身,可以显着改善跟踪结果。 在本章前面的“执行灰度转换”部分中,我们提到了 Macêdo,Melo 和 Kelner 关于灰度转换算法和 SIFT 描述符的论文。 您可能希望阅读该论文并进行自己的实验,以确定在使用 ORB 描述符或其他类型的描述符时,灰度转换算法的选择如何影响跟踪内线的数量。

总结

本章介绍了 AR,以及一组针对 3D 空间中图像跟踪问题的可靠方法。

我们首先学习了 6DOF 跟踪的概念。 我们认识到,熟悉的工具(例如 ORB 描述符,基于 FLANN 的匹配和卡尔曼滤波)在这种跟踪中很有用,但是我们还需要使用相机和镜头参数来解决 PnP 问题。

接下来,我们讨论了如何以灰度图像,一组 2D 关键点和一组 3D 关键点的形式最好地表示参考对象(例如书的封面或照片)的实际考虑。

我们着手实现了一个类,该类封装了 3D 空间中的图像跟踪演示,并以 3D 高亮效果作为 AR 的基本形式。 我们的实现涉及实时考虑,例如需要根据帧速率的波动来更新卡尔曼过滤器的转换矩阵。

最后,我们考虑了使用其他计算机视觉技术或其他基于传感器的技术来潜在改善 3D 跟踪算法的方法。

现在,我们正在接*本书的最后一章,该章对到目前为止我们已经解决的许多问题提供了不同的观点。 我们可以暂时搁置相机和几何学的思想,而开始以统计学家的身份思考,因为我们将通过研究人工神经网络ANN)。

十、使用 OpenCV 的神经网络简介

本章介绍了一系列称为人工神经网络ANNs)或有时仅称为神经网络的机器学习模型。 这些模型的主要特征是它们试图以多层的方式学习变量之间的关系。 在将这些结果合并为一个函数以预测有意义的内容(例如对象的类别)之前,他们学习了多种特征来预测中间结果。 OpenCV 的最新版本包含越来越多的与 ANN 相关的功能-尤其是具有多层的 ANN,称为深度神经网络DNN)。 在本章中,我们将对较浅的 ANN 和 DNN 进行试验。

在其他各章中,我们已经对机器学习有所了解,尤其是在第 7 章,“构建自定义对象检测器”中,我们使用 SURF 描述符开发了汽车/非汽车分类器, BoW 和一个 SVM。 以此为基础进行比较,您可能会想知道,人工神经网络有什么特别之处? 我们为什么将本书的最后一章专门介绍给他们?

人工神经网络旨在在以下情况下提供卓越的准确率:

  • 输入变量很多,它们之间可能具有复杂的非线性关系。
  • 有许多输出变量,这些变量可能与输入变量具有复杂的非线性关系。 (通常,分类问题中的输出变量是类的置信度得分,因此,如果有很多类,那么会有很多输出变量。)
  • 有许多隐藏的(未指定)变量可能与输入和输出变量具有复杂的非线性关系。 DNN 甚至旨在建模多个隐变量层,这些隐层主要彼此相关,而不是主要与输入或输出变量相关。

这些情况存在于许多(也许是大多数)现实世界中的问题中。 因此,人工神经网络和 DNN 的预期优势是诱人的。 另一方面,众所周知,人工神经网络(尤其是 DNN)是不透明的模型,因为它们通过预测是否存在可能与其他所有事物有关的任意数量的无名,隐藏变量而起作用。

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

  • 将人工神经网络理解为统计模型和有监督的机器学习工具。
  • 了解 ANN 拓扑,或者将 ANN 组织到相互连接的神经元层中。 特别地,我们将考虑使 ANN 能够用作一种分类器的拓扑,称为多层感知器MLP)。
  • 在 OpenCV 中训练和使用人工神经网络作为分类器。
  • 生成检测和识别手写数字(0 到 9)的应用。 为此,我们将基于被广泛使用的称为 MNIST 的数据集训练 ANN,该数据集包含手写数字的样本。
  • 在 OpenCV 中加载和使用经过预训练的 DNN。 我们将介绍 DNN 的对象分类,人脸检测和性别分类的示例。

到本章结束时,您将很容易在 OpenCV 中训练和使用 ANN,可以使用来自各种来源的经过预先训练的 DNN,并可以开始探索其他可用来训练自己的 DNN 的库。

技术要求

本章使用 Python,OpenCV 和 NumPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”的

本章的完整代码和示例视频可以在本书的 GitHub 存储库中找到,位于chapter10文件夹中。

了解人工神经网络

让我们根据其基本角色和组成部分来定义 ANN。 尽管有关人工神经网络的许多文献都强调它们是通过神经元在大脑中的连接方式受到生物学启发,但我们并不需要是生物学家或神经科学家来了解人工神经网络的基本概念。

首先,人工神经网络是统计模型。 什么是统计模型? 统计模型是一对元素,即空间S(一组观察值)和概率P,其中P是*似于S的分布(换句话说,一个函数,它生成一组与S非常相似的观察结果。

这是思考P的两种不同方法:

  • P是复杂场景的简化。
  • P是首先生成S或至少与S非常相似的一组观察结果的函数。

因此,人工神经网络是一个模型,它采用一个复杂的现实,对其进行简化,并推导一个函数以(*似)以数学形式表示我们期望从该现实中获得的统计观察结果。

与其他类型的机器学习模型一样,人工神经网络可以通过以下方式之一从观察中学习:

  • 监督学习:在这种方法下,我们希望模型的训练过程产生一个函数,该函数将一组已知的输入变量映射到一组已知的输出变量。 我们知道,先验是预测问题的性质,我们将找到解决该问题的函数的过程委托给了 ANN。 要训​​练模型,我们必须提供输入样本以及正确的相应输出。 对于分类问题,输出变量可以是一个或多个类别的置信度得分。
  • 无监督学习:在这种方法下,先验不知道输出变量的集合。 模型的训练过程必须产生一组输出变量,以及将输入变量映射到这些输出变量的函数。 对于分类问题,无监督学习可能导致发现先前未知的类别,例如医学数据中的先前未知的疾病。 无监督学习可以使用包括(但不限于)聚类的技术,我们在第 7 章,“构建自定义对象检测器”的 BoW 模型的上下文中对此进行了探讨。
  • 强化学习:这种方法可以颠倒典型的预测问题。 在训练模型之前,我们已经有一个系统,当我们为一组已知的输入变量输入值时,该系统会为一组已知的输出变量产生值。 我们知道,先验是一种基于输出的优劣(合意性)或缺乏而对输出序列进行评分的方法。 但是,我们可能不知道将输入映射到输出的实际函数,或者,即使我们知道它,也是如此复杂,以至于无法为最佳输入求解。 因此,我们希望模型的训练过程能够产生一个函数,该函数根据最后的输出来预测序列中的下一个最优输入。 在训练过程中,模型从分数中学习,该分数最终是由其动作(所选输入)产生的。 从本质上讲,该模型必须学会在特定的奖惩系统中成为优秀的决策者。

在本章的其余部分中,我们将讨论仅限于监督学习,因为这是在计算机视觉环境下进行机器学习的最常用方法。

理解 ANN 的下一步是了解 ANN 如何在简单的统计模型和其他类型的机器学习方面进行改进。

如果生成数据集的函数可能需要大量(未知)输入怎么办?

人工神经网络采用的策略是将工作委托给多个神经元节点单元,每个单元都可以*似于创建神经元的功能。 输入。 在数学中,逼*是定义一个更简单的函数的过程,至少对于某些输入范围,其输出类似于更复杂的函数的输出。

*似函数的输出与原始函数的输出之间的差异称为误差。 神经网络的定义特征是神经元必须能够逼*非线性函数。

让我们仔细看看神经元。

了解神经元和感知器

通常,为了解决分类问题,将 ANN 设计为多层感知器MLP),其中每个神经元都充当一种称为感知器的二分类器。 感知器的概念可以追溯到 1950 年代。 简而言之,感知器是一种需要大量输入并产生单个值的函数。 每个输入具有关联的权重,该权重表示其在激活函数中的重要性。 激活函数应具有非线性响应; 例如,Sigmoid 函数(有时称为 S 曲线)是常见的选择。 将阈值函数判别式应用于激活函数的输出,以将其转换为 0 或 1 的二分类。这是此序列的可视化图,左边是输入,激活函数在中间,右边是阈值函数:

输入权重代表什么,如何确定?

在一个神经元的输出可以作为许多其他神经元的输入的范围内,神经元是相互关联的。 每个输入权重定义了两个神经元之间连接的强度。 这些权重是自适应的,这意味着它们会根据学习算法随时间变化。

由于神经元的互连性,网络具有层次。 现在,让我们检查一下通常如何组织这些层。

了解神经网络的各层

这是神经网络的直观表示:

如上图所示,神经网络中至少有三个不同的层:输入层隐藏层输出层。 可以有多个隐藏层。 但是,一个隐藏层足以解决许多现实生活中的问题。 具有多个隐藏层的神经网络有时称为深度神经网络DNN)。

如果我们将 ANN 用作分类器,则每个输出节点的输出值是一个类的置信度得分。 对于给定的样本(即给定的一组输入值),我们想知道哪个输出节点产生最高的输出值。 该得分最高的输出节点对应于预测的类别。

我们如何确定网络的拓扑结构,以及我们需要为每个层创建多少个神经元? 让我们逐层进行此确定。

选择输入层的大小

根据定义,输入层中的节点数是网络的输入数。 例如,假设您要创建一个人工神经网络,以帮助您根据动物的物理属性确定动物的种类。 原则上,我们可以选择任何可测量的属性。 如果我们选择根据重量,长度和牙齿数量对动物进行分类,那就是三个属性的集合,因此我们的网络需要包含三个输入节点。

这三个输入节点是否是物种分类的充分基础? 好吧,对于现实生活中的问题,当然不是-但是在玩具问题中,这取决于我们试图实现的输出,这是我们接下来要考虑的问题。

选择输出层的大小

对于分类器,根据定义,输出层中的节点数就是网络可以区分的分类数。 继续前面的动物分类网络示例,如果我们知道要处理以下动物,则可以使用四个节点的输出层:狗,秃鹰,海豚和龙(!)。 如果我们尝试对不在这些类别之一中的动物的数据进行分类,则网络将预测最有可能与这种无代表性动物相似的类别。

现在,我们遇到了一个困难的问题-隐藏层的大小。

选择隐藏层的大小

选择隐藏层的大小没有公认的经验法则。 必须根据实验进行选择。 对于要在其上应用 ANN 的每个实际问题,都需要对 ANN 进行训练,测试和重新训练,直到找到许多可以接受的准确率的隐藏节点。

当然,即使通过实验选择参数值,您也可能希望专家为您的测试建议一个起始值或一系列值。 不幸的是,在这些方面也没有专家共识。 一些专家根据以下广泛建议提供经验法则(这些建议应加盐):

  • 如果输入层很大,则隐藏神经元的数量应在输入层的大小和输出层的大小之间,并且通常应更接*输出层的大小。
  • 另一方面,如果输入和输出层都较小,则隐藏层应为最大层。
  • 如果输入层较小,但输出层较大,则隐藏层应更接*输入层的大小。

其他专家建议,还应考虑训练样本的数量; 大量的训练样本意味着更多的隐藏节点可能有用。

要记住的一个关键因素是过拟合。 与训练数据实际提供的信息相比,当隐藏层中包含如此大量的伪信息时,就会发生过拟合,因此分类不太有意义。 隐藏层越大,为了正确学习而需要的训练数据就越多。 当然,随着训练数据集的大小增加,训练时间也会增加。

对于本章中的一些 ANN 示例项目,我们将使用 60 的隐藏层大小作为起点。 给定一个庞大的训练集,对于各种分类问题,60 个隐藏节点可以产生不错的准确率。

现在,我们对什么是人工神经网络有了一个大致的了解,让我们看看 OpenCV 如何实现它们,以及如何充分利用它们。 我们将从一个最小的代码示例开始。 然后,我们将充实我们在前两节中讨论的以动物为主题的分类器。 最后,我们将努力开发更现实的应用,在该应用中,我们将基于图像数据对手写数字进行分类。

在 OpenCV 中训练基本的 ANN

OpenCV 提供了cv2.ml_ANN_MLP类,该类将 ANN 实现为多层感知器MLP)。 这正是我们之前在“了解神经元和感知器”部分中描述的模型。

要创建cv2.ml_ANN_MLP的实例并为该 ANN 的训练和使用格式化数据,我们依赖于 OpenCV 的机器学习模块cv2.ml中的功能。 您可能还记得过,这与我们在第 7 章,“构建自定义对象检测器”中用于 SVM 相关功能的模块相同。 此外,cv2.ml_ANN_MLPcv2.ml_SVM共享一个称为cv2.ml_StatModel的公共基类。 因此,您会发现 OpenCV 为 ANN 和 SVM 提供了类似的 API。

让我们来看一个虚拟的例子,作为对 ANN 的简要介绍。 该示例将使用完全无意义的数据,但它将向我们展示用于在 OpenCV 中训练和使用 ANN 的基本 API:

  1. 首先,我们照常导入 OpenCV 和 NumPy:
import cv2
import numpy as np
  1. 现在,我们创建一个未经训练的人工神经网络:
ann = cv2.ml.ANN_MLP_create()
  1. 创建 ANN 后,我们需要配置其层数和节点数:
ann.setLayerSizes(np.array([9, 15, 9], np.uint8))

层大小由传递给setLayerSizes方法的 NumPy 数组定义。 第一个元素是输入层的大小,最后一个元素是输出层的大小,所有中间元素定义隐藏层的大小。 例如,[9, 15, 9]指定 9 个输入节点,9 个输出节点以及具有 15 个节点的单个隐藏层。 如果将其更改为[9, 15, 13, 9],它将指定两个分别具有 15 和 13 个节点的隐藏层。

  1. 我们还可以配置激活函数,训练方法和训练终止标准,如下所示:
ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
ann.setTermCriteria(
    (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0))

在这里,我们使用对称的 Sigmoid 激活函数(cv2.ml.ANN_MLP_SIGMOID_SYM)和反向传播训练方法(cv2.ml.ANN_MLP_BACKPROP)。 反向传播是一种算法,用于计算输出层的预测误差,从先前的层向后追溯误差的来源,并更新权重以减少误差。

  1. 让我们训练 ANN。 我们需要指定训练输入(或 OpenCV 术语中的samples),相应的正确输出(或responses),以及数据的格式(或layout)是每个样本一行还是每个样本一行。 这是一个如何使用单个样本训练模型的示例:
training_samples = np.array(
    [[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], np.float32)
layout = cv2.ml.ROW_SAMPLE
training_responses = np.array(
    [[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0]], np.float32)
data = cv2.ml.TrainData_create(
    training_samples, layout, training_responses)
ann.train(data)

实际上,我们希望使用包含一个以上样本的更大数据集来训练任何 ANN。 我们可以通过扩展training_samplestraining_responses使其包含多个行来表示多个样本及其相应的响应,从而做到这一点。 或者,我们可以多次调用 ANN 的train方法,每次都使用新数据。 后一种方法需要train方法使用一些其他参数,下一节“在多个周期中训练 ANN 分类器”将对此进行演示。

请注意,在这种情况下,我们正在训练 ANN 作为分类器。 每个响应都是一个类的置信度得分,在这种情况下,有 9 个类。 我们将通过基于 0 的索引将它们称为 0 到 8 类。在这种情况下,我们的训练样本的响应为[0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0],这意味着它是 5 类的实例(置信度 1.0),并且它绝对不是任何其他类的实例(因为其他所有类的置信度为 0.0)。

  1. 为了完成对 ANN API 的最小介绍,让我们制作另一个示例,对其进行分类并打印结果:
test_samples = np.array(
    [[1.4, 1.5, 1.2, 2.0, 2.5, 2.8, 3.0, 3.1, 3.8]], np.float32)
prediction = ann.predict(test_samples)
print(prediction)

这将打印以下结果:

(5.0, array([[-0.08763029, -0.01616517, 0.13196233, 0.0402631 , 0.05711843,
         1.1642447 , 0.18130444, 0.1857026 , -0.07486832]],
      dtype=float32))

这意味着所提供的输入被归类为第 5 类。再次,这只是一个虚拟示例,该分类是毫无意义的。 但是,网络行为正常。 在前面的代码中,我们仅提供了一个训练记录,该训练记录是第 5 类的样本,因此网络将新输入归为第 5 类。(据我们有限的训练数据集显示,除 5 以外的其他类可能永远不会发生。)

您可能已经猜到了,预测的输出是一个元组,第一个值是类,第二个值是包含每个类的概率的数组。 预测的类别将具有最高的值。

让我们继续一个更可信的例子-动物分类。

在多个周期中训练 ANN 分类器

让我们创建一个 ANN,尝试根据三种度量对动物进行分类:体重,长度和牙齿数量。 当然,这是一个模拟场景。 实际上,没有人会只用这三个统计数据来描述动物。 但是,我们的目的是在将 ANN 应用于图像数据之前,加深对 ANN 的理解。

与上一节中的最小示例相比,我们的动物分类模型将通过以下方式更加复杂:

  • 我们将增加隐藏层中神经元的数量。
  • 我们将使用更大的训练数据集。 为方便起见,我们将随机生成此数据集。
  • 我们将在多个周期训练 ANN,这意味着我们将使用相同的数据集每次对其进行多次训练和重新训练。

隐藏层中神经元的数量是重要的参数,需要进行测试才能优化任何 ANN 的准确率。 您会发现,较大的隐藏层可以在一定程度上提高准确率,然后过拟合,除非您开始使用庞大的训练数据集进行补偿。 同样,在一定程度上,更多的周期可能会提高准确率,但过多的周期会导致过拟合。

让我们逐步执行一下实现:

  1. 首先,我们照例导入 OpenCV 和 NumPy。 然后,从 Python 标准库中,导入randint函数以生成伪随机整数,并导入uniform函数以生成伪随机浮点数:
import cv2
import numpy as np
from random import randint, uniform
  1. 接下来,我们创建并配置 ANN。 这次,我们使用三个神经元输入层,一个 50 神经元隐藏层和一个四个神经元输出层,如以下代码中以粗体突出显示:
animals_net = cv2.ml.ANN_MLP_create()
animals_net.setLayerSizes(np.array([3, 50, 4]))
animals_net.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
animals_net.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
animals_net.setTermCriteria(
    (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS, 100, 1.0))
  1. 现在,我们需要一些数据。 我们对准确地代表动物并不感兴趣。 我们只需要一堆记录作为训练数据即可。 因此,我们定义四个函数以生成不同类别的随机样本,另外定义四个函数以生成正确的分类结果以进行训练:
"""Input arrays
weight, length, teeth
"""

"""Output arrays
dog, condor, dolphin, dragon
"""

def dog_sample():
    return [uniform(10.0, 20.0), uniform(1.0, 1.5),
        randint(38, 42)]

def dog_class():
    return [1, 0, 0, 0]

def condor_sample():
    return [uniform(3.0, 10.0), randint(3.0, 5.0), 0]

def condor_class():
    return [0, 1, 0, 0]

def dolphin_sample():
    return [uniform(30.0, 190.0), uniform(5.0, 15.0), 
        randint(80, 100)]

def dolphin_class():
    return [0, 0, 1, 0]

def dragon_sample():
    return [uniform(1200.0, 1800.0), uniform(30.0, 40.0), 
        randint(160, 180)]

def dragon_class():
    return [0, 0, 0, 1]
  1. 我们还定义了以下辅助函数,以便将样本和分类转换为一对 NumPy 数组:
def record(sample, classification):
    return (np.array([sample], np.float32),
            np.array([classification], np.float32))
  1. 让我们继续创建假动物数据。 我们将为每个类创建 20,000 个样本:
RECORDS = 20000
records = []
for x in range(0, RECORDS):
    records.append(record(dog_sample(), dog_class()))
    records.append(record(condor_sample(), condor_class()))
    records.append(record(dolphin_sample(), dolphin_class()))
    records.append(record(dragon_sample(), dragon_class()))
  1. 现在,让我们训练 ANN。 正如我们在本节开头所讨论的,我们将使用多个训练周期。 每个周期都是循环的迭代,如以下代码所示:
EPOCHS = 10
for e in range(0, EPOCHS):
    print("epoch: %d" % e)
    for t, c in records:
        data = cv2.ml.TrainData_create(t, cv2.ml.ROW_SAMPLE, c)
        if animals_net.isTrained():
            animals_net.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
        else:
            animals_net.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)

对于具有庞大且多样化的训练数据集的实际问题,ANN 可能会受益于数百个训练周期。 为了获得最佳结果,您可能希望继续训练和测试 ANN,直到达到收敛为止,这意味着进一步的周期将不再对结果的准确率产生明显的改善。

请注意,我们必须将cv2.ml.ANN_MLP_UPDATE_WEIGHTS标志传递给 ANN 的train函数,以更新以前训练的模型,而不是从头开始训练新的模型。 这是每当您逐步训练模型时都必须记住的关键点,就像我们在这里所做的那样。

  1. 训练完我们的人工神经网络后,我们应该进行测试。 对于每个类别,让我们生成 100 个新的随机样本,使用 ANN 对其进行分类,并跟踪正确分类的数量:
TESTS = 100

dog_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dog_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 0:
        dog_results += 1

condor_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([condor_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 1:
        condor_results += 1

dolphin_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dolphin_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 2:
        dolphin_results += 1

dragon_results = 0
for x in range(0, TESTS):
    clas = int(animals_net.predict(
        np.array([dragon_sample()], np.float32))[0])
    print("class: %d" % clas)
    if clas == 3:
        dragon_results += 1
  1. 最后,让我们打印准确率统计信息:
print("dog accuracy: %.2f%%" % (100.0 * dog_results / TESTS))
print("condor accuracy: %.2f%%" % (100.0 * condor_results / TESTS))
print("dolphin accuracy: %.2f%%" % \
    (100.0 * dolphin_results / TESTS))
print("dragon accuracy: %.2f%%" % (100.0 * dragon_results / TESTS))

当我们运行脚本时,前面的代码块应产生以下输出:

dog accuracy: 100.00%
condor accuracy: 100.00%
dolphin accuracy: 100.00%
dragon accuracy: 100.00%

由于我们正在处理随机数据,因此每次您运行脚本时,结果可能会有所不同。 通常,由于我们已经建立了一个简单的分类问题,即输入数据的范围不重叠,因此准确率应该很高甚至是完美的。 (狗的随机权重值的范围与龙的范围不重叠,依此类推。)

您可能需要花一些时间来尝试以下修改(一次进行一次),以便了解 ANN 的准确率如何受到影响:

  • 通过修改RECORDS变量的值来更改训练样本的数量。
  • 通过修改EPOCHS变量的值来更改训练周期的数量。
  • 通过在dog_samplecondor_sampledolphin_sampledragon_sample函数中编辑uniformrandint函数调用的参数,使输入数据的范围部分重叠。

准备就绪后,我们将继续一个包含真实图像数据的示例。 这样,我们将训练 ANN 来识别手写数字。

用人工神经网络识别手写数字

手写数字是 10 个阿拉伯数字(0 到 9)中的任何一个,用笔或铅笔手动书写,而不是用机器打印。 手写数字的外观可能会有很大差异。 不同的人有不同的笔迹,并且-一个熟练的书法家可能会例外-一个人每次书写都不会产生相同的数字。 这种可变性意味着手写数字的视觉识别对于机器学习来说是一个不小的问题。 确实,机器学习的学生和研究人员经常通过尝试训练手写数字的准确识别器来测试他们的技能和新算法。 我们将通过以下方式应对这一挑战:

  1. 从 MNIST 数据库的 Python 友好版本加载数据。 这是一个广泛使用的数据库,其中包含手写数字的图像。
  2. 使用 MNIST 数据,在多个周期训练 ANN。
  3. 加载一张纸上有许多手写数字的图像。
  4. 基于轮廓分析,检测纸张上的各个数字。
  5. 使用我们的人工神经网络对检测到的数字进行分类。
  6. 查看结果,以确定我们的探测器和基于 ANN 的分类器的准确率。

在深入研究实现之前,让我们回顾一下有关 MNIST 数据库的信息。

了解 MNIST 手写数字数据库

可在这个页面上公开获得 MNIST 数据库(或美国国家标准混合技术研究院数据库)。该数据库包括一个包含 60,000 个手写数字图像的训练集。 其中一半是由美国人口普查局的雇员撰写的,而另一半是由美国的高中生撰写的。

该数据库还包括从同一作者那里收集的 10,000 张图像的测试集。 所有训练和测试图像均为灰度格式,尺寸为28 x 28像素。 在黑色背景上,数字为白色(或灰色阴影)。 例如,以下是 MNIST 训练样本中的三个:

作为使用 MNIST 的替代方法,您当然可以自己构建一个类似的数据库。 这将涉及收集大量手写数字的图像,将图像转换为灰度图像,对其进行裁剪以使每个图像在标准化位置均包含一个数字,然后缩放图像以使它们都具有相同的大小。 您还需要标记图像,以便程序可以读取正确的分类,以训练和测试分类器。

许多作者提供了有关如何将 MNIST 数据库与各种机器学习库和算法结合使用的示例-不仅是 OpenCV,还不仅仅是 ANN。 免费在线书籍《神经网络和深度学习》的作者 Michael Nielsen 在这里为 MNIST 和 ANN 专门撰写了一章。 他展示了如何仅使用 NumPy 几乎从头开始实现 ANN,如果您想加深对 OpenCV 公开的高级功能的了解,那么这是一本非常好的读物。 他的代码可在 GitHub 上免费获得。

Nielsen 提供了 MNIST 版本,为PKL.GZ(gzip 压缩的 Pickle)文件,可以轻松地将其加载到 Python 中。 出于本书 OpenCV 示例的目的,我们(作者)采用了 Nielsen 的 MNIST 的PKL.GZ版本,为我们的目的对其进行了重组,并将其放置在本书的chapter10/digits_data/mnist.pkl.gz的 GitHub 存储库中。

既然我们已经了解了 MNIST 数据库,那么让我们考虑一下适合该训练集的 ANN 参数。

为 MNIST 数据库选择训练参数

每个 MNIST 样本都是一个包含 784 像素(即28 x 28像素)的图像。 因此,我们的人工神经网络的输入层将具有 784 个节点。 输出层将有 10 个节点,因为有 10 类数字(0 到 9)。

我们可以自由选择其他参数的值,例如隐藏层中的节点数,要使用的训练样本数以及训练周期数。 与往常一样,实验可以帮助我们找到可提供可接受的训练时间和准确率的值,而不会使模型过度适合训练数据。 根据本书作者所做的一些实验,我们将使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 这些参数足以进行初步测试,将训练时间缩短至几分钟(取决于计算机的处理能力)。

实现训练 ANN 的模块

您也可能希望在未来的项目中基于 MNIST 训练 ANN。 为了使我们的代码更具可重用性,我们可以编写一个专门用于此训练过程的 Python 模块。 然后(在下一节“实现主模块”中),我们将把这个训练模块导入到主模块中,在这里我们将进行数字检测和分类的演示。

让我们在名为digits_ann.py的文件中实现训练模块:

  1. 首先,我们将从 Python 标准库中导入gzippickle模块。 和往常一样,我们还将导入 OpenCV 和 NumPy:
import gzip
import pickle

import cv2
import numpy as np

我们将使用gzippickle模块解压缩并从mnist.pkl.gz文件中加载 MNIST 数据。 我们之前在“了解 MNIST 手写数字数据库”部分中简要提到了此文件。 它包含嵌套元组中的 MNIST 数据,格式如下:

((training_images, training_ids),
 (test_images, test_ids))

反过来,这些元组的元素具有以下格式:

  1. 让我们编写以下帮助函数来解压缩并加载mnist.pkl.gz的内容:
def load_data():
    mnist = gzip.open('./digits_data/mnist.pkl.gz', 'rb')
    training_data, test_data = pickle.load(mnist)
    mnist.close()
    return (training_data, test_data)

注意,在前面的代码中,training_data是一个元组,等效于(training_images, training_ids)test_data也是一个元组,等效于(test_images, test_ids)

  1. 我们必须重新格式化原始数据,以匹配 OpenCV 期望的格式。 具体来说,当我们提供用于训练 ANN 的样本输出时,它必须是具有 10 个元素(用于 10 类数字)的向量,而不是单个数字 ID。 为方便起见,我们还将应用 Python 内置的zip函数以一种可以对匹配的输入和输出向量对(如元组)进行迭代的方式来重组数据。 让我们编写以下辅助函数来重新格式化数据:
def wrap_data():
    tr_d, te_d = load_data()
    training_inputs = tr_d[0]
    training_results = [vectorized_result(y) for y in tr_d[1]]
    training_data = zip(training_inputs, training_results)
    test_data = zip(te_d[0], te_d[1])
    return (training_data, test_data)
  1. 请注意,前面的代码调用load_data和另一个帮助函数vectorized_result。 后者将 ID 转换为分类向量,如下所示:
def vectorized_result(j):
    e = np.zeros((10,), np.float32)
    e[j] = 1.0
    return e

例如,将 ID 1转换为包含值[0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0\. 0.0]的 NumPy 数组。 您可能已经猜到,这个由 10 个元素组成的数组对应于 ANN 的输出层,我们在训练 ANN 时可以将其用作正确输出的样本。

先前的函数load_datawrap_datavectorized_result已从 Nielsen 的代码中进行了修改,以加载他的mnist.pkl.gz版本。 有关 Nielsen 的工作的更多信息,请参阅本章的“了解 MNIST 手写数字数据库”部分。

  1. 到目前为止,我们已经编写了用于加载和重新格式化 MNIST 数据的函数。 现在,让我们编写一个函数来创建未经训练的 ANN:
def create_ann(hidden_nodes=60):
    ann = cv2.ml.ANN_MLP_create()
    ann.setLayerSizes(np.array([784, hidden_nodes, 10]))
    ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM, 0.6, 1.0)
    ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1, 0.1)
    ann.setTermCriteria(
        (cv2.TERM_CRITERIA_MAX_ITER | cv2.TERM_CRITERIA_EPS,
         100, 1.0))
    return ann

请注意,我们已经根据 MNIST 数据的性质对输入和输出层的大小进行了硬编码。 但是,我们允许此函数的调用者指定隐藏层中的节点数。

有关参数的进一步讨论,请参考本章“选择 MNIST 数据库的训练参数”。

  1. 现在,我们需要一个训练函数,允许调用者指定 MNIST 训练样本的数量和周期的数量。 我们以前的 ANN 样本应该熟悉很多训练函数,因此让我们看一下整个实现,然后再讨论一些细节:
def train(ann, samples=50000, epochs=10):

    tr, test = wrap_data()

    # Convert iterator to list so that we can iterate multiple 
    # times in multiple epochs.
    tr = list(tr)

    for epoch in range(epochs):
        print("Completed %d/%d epochs" % (epoch, epochs))
        counter = 0
        for img in tr:
            if (counter > samples):
                break
            if (counter % 1000 == 0):
                print("Epoch %d: Trained on %d/%d samples" % \
                      (epoch, counter, samples))
            counter += 1
            sample, response = img
            data = cv2.ml.TrainData_create(
                np.array([sample], dtype=np.float32),
                cv2.ml.ROW_SAMPLE,
                np.array([response], dtype=np.float32))
            if ann.isTrained():
                ann.train(data, cv2.ml.ANN_MLP_UPDATE_WEIGHTS | cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
            else:
                ann.train(data, cv2.ml.ANN_MLP_NO_INPUT_SCALE | cv2.ml.ANN_MLP_NO_OUTPUT_SCALE)
    print("Completed all epochs!")

    return ann, test

请注意,我们加载数据,然后通过迭代指定数量的训练周期(每个周期中都有指定数量的样本)来递增地训练 ANN。 对于我们处理的每 1,000 个训练样本,我们会打印一条有关训练进度的消息。 最后,我们同时返回经过训练的 ANN 和 MNIST 测试数据。 我们可能刚刚返回了 ANN,但是如果我们想检查 ANN 的准确率,则手头准备测试数据会很有用。

  1. 当然,经过训练的 ANN 的目的是进行预测,因此我们将提供以下predict函数,以便包装 ANN 自己的predict方法:
def predict(ann, sample):
    if sample.shape != (784,):
        if sample.shape != (28, 28):
            sample = cv2.resize(sample, (28, 28),
                                interpolation=cv2.INTER_LINEAR)
        sample = sample.reshape(784,)
    return ann.predict(np.array([sample], dtype=np.float32))

该函数获取训练有素的人工神经网络和样本图像; 它通过确保样本图像为28 x 28并通过调整大小(如果不是)来执行最少的数据清理。 然后,它将图像数据展*为向量,然后再将其提供给 ANN 进行分类。

这就是我们支持演示应用所需的所有与 ANN 相关的函数。 但是,让我们还实现一个test函数,该函数通过对一组给定的测试数据(例如 MNIST 测试数据)进行分类来测量经过训练的 ANN 的准确率。 以下是相关代码:

def test(ann, test_data):
    num_tests = 0
    num_correct = 0
    for img in test_data:
        num_tests += 1
        sample, correct_digit_class = img
        digit_class = predict(ann, sample)[0]
        if digit_class == correct_digit_class:
            num_correct += 1
    print('Accuracy: %.2f%%' % (100.0 * num_correct / num_tests))

现在,让我们走一小段弯路,编写一个利用所有前面的代码和 MNIST 数据集的最小测试。 之后,我们将继续实现演示应用的主要模块。

实现最小的测试模块

让我们创建另一个脚本test_digits_ann.py,以测试digits_ann模块中的功能。 测试脚本非常简单; 这里是:

from digits_ann import create_ann, train, test

ann, test_data = train(create_ann())
test(ann, test_data)

请注意,我们尚未指定隐藏节点的数量,因此create_ann将使用其默认参数值:60 个隐藏节点。 同样,train将使用其默认参数值:50,000 个样本和 10 个周期。

当我们运行此脚本时,它应打印类似于以下内容的训练和测试信息:

Completed 0/10 epochs
Epoch 0: Trained on 0/50000 samples
Epoch 0: Trained on 1000/50000 samples
... [more reports on progress of training] ...
Completed all epochs!
Accuracy: 95.39%

在这里,我们可以看到,对 MNIST 数据集中的 10,000 个测试样本进行分类时,ANN 的准确率达到了 95.39%。 这是一个令人鼓舞的结果,但让我们看一下 ANN 的概括程度。 是否可以对来自与 MNIST 无关的完全不同来源的数据进行准确分类? 我们的主要应用会从我们自己的一张纸的图像中检测数字,这将给分类器带来这种挑战。

实现主要模块

我们的演示程序的主要脚本吸收了本章中有关 ANN 和 MNIST 的所有知识,并将其与我们在前几章中研究的一些对象检测技术相结合。 因此,从很多方面来说,这对我们来说都是一个顶点项目。

让我们在名为detect_and_classify_digits.py的新文件中实现主脚本:

  1. 首先,我们将导入 OpenCV,NumPy 和我们的digits_ann模块:
import cv2
import numpy as np

import digits_ann
  1. 现在,让我们编写一些辅助函数来分析和调整数字和其他轮廓的边界矩形。 如前几章所述,重叠检测是一个常见问题。 以下称为inside的函数将帮助我们确定一个边界矩形是否完全包含在另一个边界矩形内:
def inside(r1, r2):
    x1, y1, w1, h1 = r1
    x2, y2, w2, h2 = r2
    return (x1 > x2) and (y1 > y2) and (x1+w1 < x2+w2) and \
            (y1+h1 < y2+h2)

借助inside函数,我们将能够轻松地为每个数字选择最外面的矩形。 这很重要,因为我们不希望检测器遗漏任何手指的四肢。 这样的检测错误可能使分类器的工作变得不可能。 例如,如果我们仅检测到数字的下半部分 8,则分类器可能会合理地将该区域视为 0。

为了进一步确保边界矩形满足分类器的需求,我们将使用另一个名为wrap_digit的辅助函数,将紧密拟合的边界矩形转换为带有围绕数字填充的正方形。 请记住,MNIST 数据包含28 x 28像素的数字正方形图像,因此在尝试使用 MNIST 训练的 ANN 对其进行分类之前,我们必须将任何兴趣区域重新缩放至此大小。 通过使用填充的边界正方形而不是紧密拟合的边界矩形,我们确保骨感数字(例如 1)和粗体数字(例如 0)不会不同地拉伸。

  1. 让我们看一下wrap_digit的实现。 首先,我们修改矩形的较小尺寸(宽度或高度),使其等于较大尺寸,然后修改矩形的xy位置,以使中心保持不变:
def wrap_digit(rect, img_w, img_h):

    x, y, w, h = rect

    x_center = x + w//2
    y_center = y + h//2
    if (h > w):
        w = h
        x = x_center - (w//2)
    else:
        h = w
        y = y_center - (h//2)
  1. 接下来,我们在所有侧面添加 5 像素填充:
    padding = 5
    x -= padding
    y -= padding
    w += 2 * padding
    h += 2 * padding

在这一点上,我们修改后的矩形可能会延伸到图像外部。

  1. 为了避免超出范围的问题,我们对矩形进行裁剪,使其完全位于图像内。 在这些边缘情况下,这可能会给我们留下非正方形的矩形,但这是可以接受的折衷方案。 我们宁愿使用感兴趣的非正方形区域,而不是仅仅因为它位于图像的边缘而完全抛弃检测到的数字。 这是用于边界检查和裁剪矩形的代码:
    if x < 0:
        x = 0
    elif x > img_w:
        x = img_w

    if y < 0:
        y = 0
    elif y > img_h:
        y = img_h

    if x+w > img_w:
        w = img_w - x

    if y+h > img_h:
        h = img_h - y
  1. 最后,我们返回修改后的矩形的坐标:
    return x, y, w, h

到此结束wrap_digit辅助函数的实现。

  1. 现在,让我们进入程序的主要部分。 在这里,我们首先创建一个 ANN 并在 MNIST 数据上对其进行训练:
ann, test_data = digits_ann.train(
    digits_ann.create_ann(60), 50000, 10)

请注意,我们正在使用digits_ann模块中的create_anntrain函数。 如前所述(“在 MNIST 数据库中选择参数”),我们正在使用 60 个隐藏节点,50,000 个训练样本和 10 个周期。 尽管这些是函数的默认参数值,但无论如何我们还是在这里指定它们,以便以后我们想尝试其他值时更易于查看和修改。*

  1. 现在,让我们在一张白纸上加载一个包含许多手写数字的测试图像:
img_path = "./digit_img/digits_0.jpg"
img = cv2.imread(img_path, cv2.IMREAD_COLOR)

我们使用的是乔·米尼诺(Joe Minichino)手写的以下图像(但是,当然,您可以根据需要替换其他图像):

  1. 让我们将图像转换为灰度并使其模糊,以消除噪点并使墨水的暗度更加均匀:
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv2.GaussianBlur(gray, (7, 7), 0, gray)
  1. 现在我们有了一个*滑的灰度图像,我们可以应用一个阈值和一些形态学操作,以确保数字与背景脱颖而出,并且轮廓相对没有不规则性,这可能会超出预测。 以下是相关代码:
ret, thresh = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)
erode_kernel = np.ones((2, 2), np.uint8)
thresh = cv2.erode(thresh, erode_kernel, thresh, iterations=2)

注意阈值标志cv2.THRESH_BINARY_INV,它是反二进制阈值。 由于 MNIST 数据库中的样本是黑底白字(而不是黑底白字),因此我们将图像转换为带有白色数字的黑色背景。 我们将阈值图像用于检测和分类。

  1. 进行形态学操作后,我们需要分别检测图片中的每个数字。 为此,首先,我们需要找到轮廓:
contours, hier = cv2.findContours(thresh, cv2.RETR_TREE,
                                  cv2.CHAIN_APPROX_SIMPLE)
  1. 然后,我们遍历轮廓并找到其边界矩形。 我们丢弃任何我们认为太大或太小而无法数字化的矩形。 我们还将丢弃完全包含在其他矩形中的所有矩形。 其余的矩形将追加到一个良好的矩形列表中(我们相信),这些矩形包含单个数字。 让我们看下面的代码片段:
rectangles = []

img_h, img_w = img.shape[:2]
img_area = img_w * img_h
for c in contours:

    a = cv2.contourArea(c)
    if a >= 0.98 * img_area or a <= 0.0001 * img_area:
        continue

    r = cv2.boundingRect(c)
    is_inside = False
    for q in rectangles:
        if inside(r, q):
            is_inside = True
            break
    if not is_inside:
        rectangles.append(r)
  1. 现在我们有了一个好的矩形列表,可以遍历它们,使用wrap_digit函数对它们进行清理,并对其中的图像数据进行分类:
for r in rectangles:
    x, y, w, h = wrap_digit(r, img_w, img_h)
    roi = thresh[y:y+h, x:x+w]
    digit_class = int(digits_ann.predict(ann, roi)[0])
  1. 此外,在对每个数字进行分类之后,我们绘制了经过清理的边界矩形和分类结果:
    cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
    cv2.putText(img, "%d" % digit_class, (x, y-5),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
  1. 处理完所有兴趣区域后,我们将保存阈值图像和带有完整标注的图像,并显示它们,直到用户按下任何键以结束程序为止:
cv2.imwrite("detected_and_classified_digits_thresh.png", thresh)
cv2.imwrite("detected_and_classified_digits.png", img)
cv2.imshow("thresh", thresh)
cv2.imshow("detected and classified digits", img)
cv2.waitKey()

脚本到此结束。 运行它时,我们应该看到阈值图像以及检测和分类结果的可视化。 (最初两个窗口可能重叠,因此您可能需要移动一个窗口才能看到另一个窗口。)这是阈值图像:

这是结果的可视化:

该图像包含 110 个采样位:从 0 到 9 的一位数字中的 10 位,再加上从 10 到 59 的两位数字中的 100 位。在这 110 个采样中,可以正确检测到 108 个采样的边界,这意味着,探测器的准确率为 98.18%。 然后,在这 108 个正确检测的样本中,对 80 个样本的分类结果是正确的,这意味着 ANN 分类器的准确率为 74.07%。 这比随机分类器要好得多,后者只能在 10% 的时间内正确分类一个数字。

因此,ANN 显然能够学习一般地对手写数字进行分类,而不仅仅是 MNIST 训练和测试数据集中的数字。 让我们考虑一些改善学习的方法。

试图改善人工神经网络的训练

我们可以对训练 ANN 的问题进行一些潜在的改进。 我们已经提到了其中一些潜在的改进,但让我们在这里进行回顾:

  • 您可以尝试训练数据集的大小,隐藏节点的数量和周期的数量,直到找到最高的准确率。
  • 您可以修改digits_ann.create_ann函数,使其支持多个隐藏层。
  • 您也可以尝试其他激活函数。 我们使用了cv2.ml.ANN_MLP_SIGMOID_SYM,但这不是唯一的选择。 其他包括cv2.ml.ANN_MLP_IDENTITYcv2.ml.ANN_MLP_GAUSSIANcv2.ml.ANN_MLP_RELUcv2.ml.ANN_MLP_LEAKYRELU
  • 同样,您可以尝试不同的训练方法。 我们使用了cv2.ml.ANN_MLP_BACKPROP。 其他选项包括cv2.ml.ANN_MLP_RPROPcv2.ml.ANN_MLP_ANNEAL

有关 OpenCV 中与 ANN 相关的参数的更多信息,请访问这个页面上的官方文档。

除了试验参数外,请仔细考虑您的应用需求。 例如,您的分类器将在哪里和由谁使用? 并非每个人都以相同的方式绘制数字。 确实,不同国家的人们倾向于以略有不同的方式得出数字。

MNIST 数据库是在美国编译的,数字 7 与手写字符 7 一样是手写的。但是,在欧洲,数字 7 通常是用数字的对角线部分中间的一条小水*线手写的。 引入此笔划是为了帮助区分手写数字 7 和手写数字 1。

有关区域手写变化的更详细概述,请查看 Wikipedia 上有关该主题的文章,这是一个很好的介绍,可在这个页面上找到。

这种变化意味着在 MNIST 数据库上训练的 ANN 在应用于欧洲手写数字的分类时可能不太准确。 为了避免这样的结果,您可以选择创建自己的训练数据集。 在几乎所有情况下,最好利用属于当前应用域的训练数据。

最后,请记住,一旦对分类器的准确率感到满意,就可以随时将其保存并稍后重新加载,这样它就可以在应用中使用,而不必每次都训练 ANN。

该界面类似于在“保存和加载受过训练的 SVM”部分中看到的接口,该部分接*第 7 章,“构建自定义对象检测器”。 具体来说,您可以使用以下代码将经过训练的 ANN 保存到 XML 文件:

ann = cv2.ml.ANN_MLP_create()
data = cv2.ml.TrainData_create(
    training_samples, layout, training_responses)
ann.train(data)
ann.save('my_ann.xml')

随后,您可以使用如下代码重新加载经过训练的 ANN:

ann = cv2.ml.ANN_MLP_create()
ann.load('my_ann.xml')

既然我们已经学习了如何为手写数字分类创建可重用的 ANN,让我们考虑一下这种分类器的用例。

寻找其他潜在的应用

前面的演示仅是手写识别应用的基础。 您可以轻松地将方法扩展到视频并实时检测手写数字,也可以训练 ANN 识别整个字母,以实现完整的光学字符识别OCR)系统。

汽车牌照的检测和识别将是到目前为止我们所学课程的另一个有用的扩展。 车牌上的字符具有一致的外观(至少在给定的国家/地区内),这应该是问题的 OCR 部分的简化因素。

您也可以尝试将 ANN 应用于以前使用过 SVM 的问题,反之亦然。 这样,您可以看到它们的准确率如何与不同类型的数据进行比较。 回想一下,在第 7 章,“构建自定义对象检测器”中,我们使用 SIFT 描述符作为 SVM 的输入。 同样,人工神经网络能够处理高级描述符,而不仅仅是普通的旧像素数据。

如我们所见,cv2.ml_ANN_MLP类用途广泛,但实际上,它仅涵盖了 ANN 设计方法的一小部分。 接下来,我们将了解 OpenCV 对更复杂的深度神经网络DNN)的支持,这些网络可以通过其他各种框架进行训练。

在 OpenCV 中使用其他框架的 DNN

OpenCV 可以加载和使用在以下任何框架中经过训练的 DNN:

深度学习部署工具包DLDT)是英特尔 OpenVINO 工具包的一部分。 DLDT 提供了用于优化其他框架中的 DNN 并将其转换为通用格式的工具。 兼容 DLDT 的模型的集合可在称为开放模型动物园的存储库中免费获得。 DLDT,开放模型动物园和 OpenCV 在其开发团队中拥有一些相同的人。 这三个项目均由英特尔赞助。

这些框架使用各种文件格式来存储经过训练的 DNN。 其中一些框架使用了一对文件格式的组合:一个用于描述模型参数的文本文件,以及一个用于存储模型本身的二进制文件。 以下代码段显示了与从每个框架加载模型相关的文件类型和 OpenCV 函数:

caffe_model = cv2.dnn.readNetFromCaffe(
    'my_model_description.protext', 'my_model.caffemodel')

tensor_flow_model = cv2.dnn.readNetFromTensorflow(
    'my_model.pb', 'my_model_description.pbtxt')

# Some Torch models use the .t7 extension and others use
# the .net extension.
torch_model_0 = cv2.dnn.readNetFromTorch('my_model.t7')
torch_model_1 = cv2.dnn.readNetFromTorch('my_model.net')

darknet_model = cv2.dnn.readNetFromDarket(
    'my_model_description.cfg', 'my_model.weights')

onnx_model = cv2.dnn.readNetFromONNX('my_model.onnx')

dldt_model = cv2.dnn.readNetFromModelOptimizer(
    'my_model_description.xml', 'my_model.bin')

加载模型后,我们需要预处理将用于模型的数据。 必要的预处理特定于给定 DNN 的设计和训练方式,因此,每当我们使用第三方 DNN 时,我们都必须了解该 DNN 的设计和训练方式。 OpenCV 提供了cv2.dnn.blobFromImage函数,该函数可以执行一些常见的预处理步骤,具体取决于我们传递给它的参数。 在将数据传递给此函数之前,我们可以手动执行其他预处理步骤。

神经网络的输入向量有时称为张量Blob,因此称为函数名称cv2.dnn.blobFromImage

让我们继续来看一个实际的示例,在该示例中,我们将看到第三方 DNN 的运行。

使用第三方 DNN 检测和分类对象

对于此演示,我们将实时捕获来自网络摄像头的帧,并使用 DNN 来检测和分类任何给定帧中可能存在的 20 种对象。 是的,单个 DNN 可以在程序员可能使用的典型笔记本电脑上实时完成所有这些操作!

在深入研究代码之前,让我们介绍一下我们将使用的 DNN。 它是称为 MobileNet-SSD 的模型的 Caffe 版本,它使用 Google 的 MobileNet 框架与另一个称为单发检测器SSD)MultiBox。 后一个框架在这个页面上有一个 GitHub 存储库。 Caffe 版本的 MobileNet-SSD 的训练技术由 GitHub 上的一个项目提供。 可以在本书的存储库中的chapter10/objects_data文件夹中找到以下 MobileNet-SSD 文件的副本:

  • MobileNetSSD_deploy.caffemodel:这是模型。
  • MobileNetSSD_deploy.prototxt:这是描述模型参数的文本文件。

随着我们的示例代码的进行,该模型的功能和正确用法将很快变得清晰起来:

  1. 与往常一样,我们首先导入 OpenCV 和 NumPy:
import cv2
import numpy as np
  1. 我们以上一节中介绍的相同方式继续使用 OpenCV 加载 Caffe 模型:
model = cv2.dnn.readNetFromCaffe(
    'objects_data/MobileNetSSD_deploy.prototxt',
    'objects_data/MobileNetSSD_deploy.caffemodel')
  1. 我们需要定义一些特定于该模型的预处理参数。 它期望输入图像为 300 像素高。 此外,它期望图像中的像素值在 -1.0 到 1.0 的范围内。 这意味着相对于从 0 到 255 的通常标度,有必要减去 127.5,然后除以 127.5。 我们将参数定义如下:
blob_height = 300
color_scale = 1.0/127.5
average_color = (127.5, 127.5, 127.5)
  1. 我们还定义了一个置信度阈值,表示为了将检测作为真实对象而需要的最低置信度得分:
confidence_threshold = 0.5
  1. 该模型支持 20 类对象,其 ID 为 1 到 20(而不是 0 到 19)。 这些类的标签可以定义如下:
labels = ['airplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus',
    'car', 'cat', 'chair', 'cow', 'dining table', 'dog',
    'horse', 'motorbike', 'person', 'potted plant', 'sheep',
    'sofa', 'train', 'TV or monitor']

稍后,当我们使用类 ID 在列表中查找标签时,必须记住从 ID 中减去 1,以获得 0 到 19(而不是 1 到 20)范围内的索引。

有了模型和参数,我们准备开始捕获帧。

  1. 对于每一帧,我们首先计算纵横比。 请记住,此 DNN 期望输入基于 300 像素高的图像; 但是,宽度可以变化以匹配原始的宽高比。 以下代码段显示了如何捕获帧并计算适当的输入大小:
cap = cv2.VideoCapture(0)

success, frame = cap.read()
while success:

    h, w = frame.shape[:2]
    aspect_ratio = w/h

    # Detect objects in the frame.

    blob_width = int(blob_height * aspect_ratio)
    blob_size = (blob_width, blob_height)
  1. 此时,我们可以简单地使用cv2.dnn.blobFromImage函数及其几个可选参数来执行必要的预处理,包括调整帧的大小并将其像素数据转换为 -1.0 到 1.0 的比例:
    blob = cv2.dnn.blobFromImage(
        frame, scalefactor=color_scale, size=blob_size,
        mean=average_color)
  1. 我们将生成的 Blob 馈送到 DNN 并获取模型的输出:
    model.setInput(blob)
    results = model.forward()

结果是一个数组,其格式特定于我们使用的模型。

  1. 对于此对象检测 DNN(以及使用 SSD 框架训练的其他 DNN),结果包括检测到的对象的子数组,每个对象都有自己的置信度得分,矩形坐标和类 ID。 以下代码显示了如何访问它们,以及如何使用 ID 在我们先前定义的列表中查找标签:
    # Iterate over the detected objects.
    for object in results[0, 0]:
        confidence = object[2]
        if confidence > confidence_threshold:

            # Get the object's coordinates.
            x0, y0, x1, y1 = (object[3:7] * [w, h, w, h]).astype(int)

            # Get the classification result.
            id = int(object[1])
            label = labels[id - 1]
  1. 遍历检测到的对象时,我们绘制检测矩形,分类标签和置信度得分:
            # Draw a blue rectangle around the object.
            cv2.rectangle(frame, (x0, y0), (x1, y1),
                          (255, 0, 0), 2)

            # Draw the classification result and confidence.
            text = '%s (%.1f%%)' % (label, confidence * 100.0)
            cv2.putText(frame, text, (x0, y0 - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
  1. 我们对框架所做的最后一件事就是展示它。 然后,如果用户按下Esc键,则退出; 否则,我们将捕获另一帧并继续循环的下一个迭代:
    cv2.imshow('Objects', frame)

    k = cv2.waitKey(1)
    if k == 27: # Escape
        break

    success, frame = cap.read()

如果插入网络摄像头并运行脚本,则应该看到检测结果和分类结果的可视化图像,并实时更新。 这是一个截图,显示约瑟夫·豪斯和萨尼贝尔·德尔菲姆·安德洛梅达(一只强大,善良和公义的猫)在加拿大一个渔村的客厅中:

DNN 已正确检测并分类了人类(置信度为 99.4%),(85.4%),装饰性瓶子(72.1%),并进行了分类, 沙发的一部分(61.2%),以及的纺织品图片(52.0%)。 显然,该 DNN 能够很好地对航海环境中的客厅进行分类!

这只是 DNN 可以做的事情的第一手–实时! 接下来,让我们看看通过在一个应用中组合三个 DNN 可以实现什么。

使用第三方 DNN 检测和分类人脸

在此演示中,我们将使用一个 DNN 来检测面部,并使用另外两个 DNN 来分类每个检测到的面部的年龄和性别。 具体来说,我们将使用预先训练的 Caffe 模型,这些模型存储在本书 GitHub 存储库的chapter10/faces_data文件夹中的以下文件中。

以下是此文件夹中文件的清单以及这些文件的来源:

  • detection/res10_300x300_ssd_iter_140000.caffemodel:这是用于人脸检测的 DNN。 OpenCV 团队已在这个页面提供了此文件。 这个 Caffe 模型是使用 SSD 框架训练的。 因此,它的拓扑类似于上一节示例中使用的 MobileNet-SSD 模型。
  • detection/deploy.prototxt:这是文本文件,描述了用于人脸检测的先前 DNN 的参数。 OpenCV 团队在这个页面提供此文件。

chapter10/faces_data/age_gender_classification文件夹包含以下文件,这些文件均由 Gil Levi 和 Tal Hassner 在其 GitHub 存储库及其项目页面上提供,他们在年龄和性别分类方面的工作:

  • age_net.caffemodel:这是用于年龄分类的 DNN。
  • age_net_deploy.protext:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。
  • gender_net.caffemodel:这是用于性别分类的 DNN。
  • gender_net_deploy.protext:这是文本文件,描述了用于年龄分类的先前 DNN 的参数。
  • average_face.npyaverage_face.png:这些文件表示分类器训练数据集中的*均面孔。 来自 Levi 和 Hassner 的原始文件称为mean.binaryproto,但我们已将其转换为 NumPy 可读格式和标准图像格式,这对于我们的使用更加方便。

让我们看看如何在代码中使用所有这些文件:

  1. 为了开始示例程序,我们加载人脸检测 DNN,定义其参数,并定义置信度阈值。 我们以与上一节样本中的对象检测 DNN 大致相同的方式执行此操作:
import cv2
import numpy as np

face_model = cv2.dnn.readNetFromCaffe(
    'faces_data/detection/deploy.prototxt',
    'faces_data/detection/res10_300x300_ssd_iter_140000.caffemodel')
face_blob_height = 300
face_average_color = (104, 177, 123)
face_confidence_threshold = 0.995

我们不需要为此 DNN 定义标签,因为它不执行任何分类。 它只是预测面矩形的坐标。

  1. 现在,让我们加载年龄分类器并定义其分类标签:
age_model = cv2.dnn.readNetFromCaffe(
    'faces_data/age_gender_classification/age_net_deploy.prototxt',
    'faces_data/age_gender_classification/age_net.caffemodel')
age_labels = ['0-2', '4-6', '8-12', '15-20',
              '25-32', '38-43', '48-53', '60+']

请注意,在此模型中,年龄标签之间存在间隙。 例如,'0-2'后跟'4-6'。 因此,如果一个人实际上是 3 岁,则分类器没有适合这种情况的标签; 最多可以选择'0-2''4-6'之一。 大概是,模型的作者有意选择了不连续的范围,以确保类别相对于输入而言是可分离的。 让我们考虑替代方案。 根据面部图像中的数据,是否可以将 4 岁以下的人群与每天 4 岁以下的人群分开? 当然不是。 他们看起来一样。 因此,根据连续的年龄范围来制定分类问题是错误的。 可以训练 DNN 将年龄预测为连续变量(例如,浮点数的年数),但这与分类器完全不同,分类器预测各个类别的置信度得分。

  1. 现在,让我们加载性别分类器并定义其标签:
gender_model = cv2.dnn.readNetFromCaffe(
    'faces_data/age_gender_classification/gender_net_deploy.prototxt',
    'faces_data/age_gender_classification/gender_net.caffemodel')
gender_labels = ['male', 'female']
  1. 年龄和性别分类器使用相同的 Blob 大小和相同的*均值。 他们使用的不是*均颜色,而是*均颜色的人脸图像,我们将从NPY文件中加载该图像(作为浮点格式的 NumPy 数组)。 稍后,我们将在执行分类之前从实际的面部图像中减去该*均面部图像。 以下是斑点大小和*均图像的定义:
age_gender_blob_size = (256, 256)
age_gender_average_image = np.load(
    'faces_data/age_gender_classification/average_face.npy')

如果要查看普通脸的外观,请打开chapter10/faces_data/age_gender_classification/average_face.png的文件,该文件包含标准图像格式的相同数据。 这里是:

当然,这只是特定训练数据集的*均面孔。 它不一定代表世界人口或任何特定国家或社区的真实*均面孔。 即使这样,在这里,我们仍可以看到一张由许多面孔组成的模糊面孔,并且没有明显的年龄或性别线索。 请注意,该图像是方形的,以鼻子的尖端为中心,并且从前额的顶部垂直延伸到颈部的底部。 为了获得准确的分类结果,我们应注意将此分类器应用于以相同方式裁剪的面部图像。

  1. 设置好模型及其参数后,让我们继续从相机捕获和处理帧。 对于每一帧,我们首先创建一个与帧相同的宽高比的 blob,然后将此 blob 馈送到人脸检测 DNN:
cap = cv2.VideoCapture(0)

success, frame = cap.read()
while success:

    h, w = frame.shape[:2]
    aspect_ratio = w/h

    # Detect faces in the frame.

    face_blob_width = int(face_blob_height * aspect_ratio)
    face_blob_size = (face_blob_width, face_blob_height)

    face_blob = cv2.dnn.blobFromImage(
        frame, size=face_blob_size, mean=face_average_color)

    face_model.setInput(face_blob)
    face_results = face_model.forward()
  1. 就像我们在上一部分示例中使用的对象检测器一样,人脸检测器提供置信度得分和矩形坐标作为结果的一部分。 对于每个检测到的面部,我们需要检查置信度得分是否可以接受地高,如果是,则将获得面部矩形的坐标:
    # Iterate over the detected faces.
    for face in face_results[0, 0]:
        face_confidence = face[2]
        if face_confidence > face_confidence_threshold:

            # Get the face coordinates.
            x0, y0, x1, y1 = (face[3:7] * [w, h, w, h]).astype(int)
  1. 此人脸检测 DNN 生成的矩形长于宽度。 但是,DNN 的年龄和性别分类要求使用方形面孔。 让我们加宽检测到的脸部矩形以使其成为正方形:
            # Classify the age and gender of the face based on a
            # square region of interest that includes the neck.

            y1_roi = y0 + int(1.2*(y1-y0))
            x_margin = ((y1_roi-y0) - (x1-x0)) // 2
            x0_roi = x0 - x_margin
            x1_roi = x1 + x_margin
            if x0_roi < 0 or x1_roi > w or y0 < 0 or y1_roi > h:
                # The region of interest is partly outside the
                # frame. Skip this face.
                continue

请注意,如果正方形的一部分落在图像的边界之外,我们将跳过此检测结果并继续进行下一个检测。

  1. 此时,我们可以选择正方形兴趣区域ROI),其中包含将用于年龄和性别分类的图像数据。 我们将 ROI 缩放到分类器的斑点大小,将其转换为浮点格式,然后减去*均脸部。 根据生成的缩放后的标准化脸部,创建斑点:
            age_gender_roi = frame[y0:y1_roi, x0_roi:x1_roi]
            scaled_age_gender_roi = cv2.resize(
                age_gender_roi, age_gender_blob_size,
                interpolation=cv2.INTER_LINEAR).astype(np.float32)
            scaled_age_gender_roi[:] -= age_gender_average_image
            age_gender_blob = cv2.dnn.blobFromImage(
                scaled_age_gender_roi, size=age_gender_blob_size)
  1. 我们将斑点输入年龄分类器,选择具有最高置信度得分的类 ID,然后记下该 ID 的标签和置信度得分:
            age_model.setInput(age_gender_blob)
            age_results = age_model.forward()
            age_id = np.argmax(age_results)
            age_label = age_labels[age_id]
            age_confidence = age_results[0, age_id]
  1. 同样,我们将性别分类:
            gender_model.setInput(age_gender_blob)
            gender_results = gender_model.forward()
            gender_id = np.argmax(gender_results)
            gender_label = gender_labels[gender_id]
            gender_confidence = gender_results[0, gender_id]
  1. 我们绘制检测到的脸部矩形,扩展的方形 ROI 和分类结果的可视化图像:
            # Draw a blue rectangle around the face.
            cv2.rectangle(frame, (x0, y0), (x1, y1),
                          (255, 0, 0), 2)

            # Draw a yellow square around the region of interest
            # for age and gender classification.
            cv2.rectangle(frame, (x0_roi, y0), (x1_roi, y1_roi),
                          (0, 255, 255), 2)

            # Draw the age and gender classification results.
            text = '%s years (%.1f%%), %s (%.1f%%)' % (
                age_label, age_confidence * 100.0,
                gender_label, gender_confidence * 100.0)
            cv2.putText(frame, text, (x0_roi, y0 - 20),
                cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
  1. 最后,我们显示带标注的帧,并继续捕获更多帧,直到用户按下Esc键:
cv2.imshow('Faces, age, and gender', frame)

k = cv2.waitKey(1)
if k == 27: # Escape
    break

success, frame = cap.read()

该程序如何报告约瑟夫·豪斯? 让我们来看看:

没有虚荣心,约瑟夫·豪斯(Joseph Howse)将就此结果写几段文字。

首先,让我们考虑面部的检测和 ROI 的选择。 已正确检测到脸部。 ROI 已正确扩展到包括脖子的方形区域,或者在这种情况下为完整的胡须,这对于分类年龄和性别可能是重要的区域。

其次,让我们考虑分类。 事实是,约瑟夫·豪斯(Joseph Howse)是男性,在这张照片拍摄时大约 35.8 岁。 看到约瑟夫·豪斯的脸的其他人也能够完全自信地断定他是男性。 但是,他们对他的年龄的估计差异很大。 性别分类 DNN 满怀信心(100.0%)说约瑟夫·豪斯是男性。 年龄分类 DNN 充满信心(96.6%)表示他年龄在 25-32 岁之间。 取这个范围的中点 28.5 也许很诱人,并说该预测的误差为-7.3 年,从客观上来说,这是一个大大的低估了,它是真实年龄的-20.4%。 但是,这种评估是预测含义的延伸。

请记住,此 DNN 是年龄分类器,而不是连续年龄值的预测指标,并且 DNN 的年龄类别被标记为不连续的范围; '25-32'之后的下一个是'38-43'。 因此,该模型与约瑟夫·豪斯(Joseph Howse)的真实年龄之间存在差距,但至少它设法从边界上选择了两个类别之一。

该演示结束了我们对 ANN 和 DNN 的介绍。 让我们回顾一下我们学到的东西和做过的事情。

总结

本章概述了人工神经网络的广阔而迷人的世界。 我们了解了人工神经网络的结构,以及如何根据应用需求设计网络拓扑。 然后,我们专注于 OpenCV 对 MLP ANN 的实现,以及 OpenCV 对在其他框架中进行过训练的各种 DNN 的支持。

我们将神经网络应用于现实世界中的问题:特别是手写数字识别; 目标检测和分类; 以及实时的人脸识别,年龄分类和性别分类的组合。 我们看到,即使在这些入门演示中,神经网络在多功能性,准确率和速度方面也显示出很大的希望。 希望这可以鼓励您尝试各种作者的经过预先训练的模型,并学习在各种框架中训练自己的高级模型。

带着这种思想和良好的祝愿,我们现在将分开。

本书的作者希望您通过 OpenCV 4 的 Python 绑定一起经历了我们的旅程。尽管涵盖了 OpenCV 4 的所有功能及其所有绑定将涉及一系列书籍,但我们探索了许多有趣而又充满未来感的概念,并且我们鼓励您与我们以及 OpenCV 社区取得联系,让我们了解您在计算机视觉领域的下一个突破性项目!

十一、附录 A:使用“曲线”过滤器弯曲颜色空间

从第 3 章“使用 OpenCV 处理图像”开始,我们的Cameo演示应用合并了一种称为曲线的图像处理效果,用于模拟某些物体的色偏。 摄影胶片。 本附录描述了曲线的概念及其使用 SciPy 的实现。

曲线是一种重新映射颜色的技术。 使用曲线时,目标像素处的通道值是(仅)源像素处的相同通道值的函数。 而且,我们不直接定义函数; 而是,对于每个函数,我们定义一组必须通过插值拟合的控制点。 在伪代码中,对于 BGR 图像,我们具有以下内容:

dst.b = funcB(src.b) where funcB interpolates pointsB
dst.g = funcG(src.g) where funcG interpolates pointsG
dst.r = funcR(src.r) where funcR interpolates pointsR

尽管应避免控制点处的不连续坡度,但会产生曲线,但这种插值方式可能会因实现方式而异。 只要控制点数量足够,我们将使用三次样条插值。

让我们先来看一下如何实现插值。

定义曲线

我们迈向基于曲线的过滤器的第一步是将控制点转换为函数。 大部分工作都是通过名为scipy.interp1d的 SciPy 函数完成的,该函数接受两个数组(xy坐标)并返回一个对点进行插值的函数。 作为scipy.interp1d的可选参数,我们可以指定kind插值; 支持的选项包括'linear''nearest''zero''slinear'(球形线性),'quadratic''cubic'。 另一个可选参数bounds_error可以设置为False,以允许外插和内插。

让我们编辑我们在Cameo演示中使用的utils.py脚本,并添加一个将scipy.interp1d包裹起来的函数,该函数的接口稍微简单一些:

def createCurveFunc(points):
    """Return a function derived from control points."""
    if points is None:
        return None
    numPoints = len(points)
    if numPoints < 2:
        return None
    xs, ys = zip(*points)
    if numPoints < 3:
        kind = 'linear'
    elif numPoints < 4:
        kind = 'quadratic'
    else:
        kind = 'cubic'
    return scipy.interpolate.interp1d(xs, ys, kind,
                                      bounds_error = False)

我们的函数不是使用两个单独的坐标数组,而是采用(xy)对的数组,这可能是指定控制点的一种更易读的方式。 必须对数组进行排序,以使x从一个索引增加到下一个索引。 通常,为获得自然效果,y值也应增加,并且第一个和最后一个控制点应为(0, 0)(255, 255),以保留黑白。 注意,我们将x视为通道的输入值,并将y视为对应的输出值。 例如,(128, 160)将使通道的中间色调变亮。

请注意,三次插值至少需要四个控制点。 如果只有三个控制点,则退回到二次插值;如果只有两个控制点,则退回到线性插值。 为了获得自然效果,应避免这些后备情况。

在本章的其余部分中,我们力求以有效且井井有条的方式使用由createCurveFunc函数生成的曲线。

缓存和应用曲线

现在,我们可以获得插入任意控制点的曲线的函数。 但是,此函数可能很昂贵。 我们不希望每个通道每个像素运行一次(例如,如果应用于640 x 480视频的三个通道,则每帧运行 921,600 次)。 幸运的是,我们通常只处理 256 个可能的输入值(每个通道 8 位),并且可以廉价地预先计算并存储许多输出值。 然后,我们的每通道每像素成本只是对缓存的输出值的查找。

让我们编辑utils.py文件并添加一个将为给定函数创建查找数组的函数:

def createLookupArray(func, length=256):
    """Return a lookup for whole-number inputs to a function.

    The lookup values are clamped to [0, length - 1].

    """
    if func is None:
        return None
    lookupArray = numpy.empty(length)
    i = 0
    while i < length:
        func_i = func(i)
        lookupArray[i] = min(max(0, func_i), length - 1)
        i += 1
    return lookupArray

我们还添加一个函数,该函数会将查找数组(例如前一个函数的结果)应用于另一个数组(例如图像):

def applyLookupArray(lookupArray, src, dst):
    """Map a source to a destination using a lookup."""
    if lookupArray is None:
        return
    dst[:] = lookupArray[src]

请注意,createLookupArray中的方法仅限于输入值为整数(非负整数)的输入值,因为该输入值用作数组的索引。 applyLookupArray函数通过使用源数组的值作为查找数组的索引来工作。 Python 的切片符号([:])用于将查找的值复制到目标数组中。

让我们考虑另一个优化。 如果我们要连续应用两个或更多曲线怎么办? 执行多次查找效率低下,并且可能导致精度降低。 我们可以通过在创建查找数组之前将两个曲线函数组合为一个函数来避免这些问题。 让我们再次编辑utils.py并添加以下函数,该函数返回两个给定函数的组合:

def createCompositeFunc(func0, func1):
    """Return a composite of two functions."""
    if func0 is None:
        return func1
    if func1 is None:
        return func0
    return lambda x: func0(func1(x))

createCompositeFunc中的方法仅限于采用单个参数的输入函数。 参数必须是兼容类型。 请注意,使用 Python 的lambda关键字创建匿名函数。

以下是最终的优化问题。 如果我们想对图像的所有通道应用相同的曲线怎么办? 在这种情况下,拆分和合并通道很浪费,因为我们不需要区分通道。 我们只需要applyLookupArray使用的一维索引。 为此,我们可以使用numpy.ravel函数,该函数将一维接口返回到预先存在的给定数组(可能是多维数组)。 返回类型为numpy.view,其接口与numpy.array几乎相同,除了numpy.view仅拥有对数据的引用,而非副本。

NumPy 数组具有flatten方法,但这将返回一个副本。

numpy.ravel适用于具有任意数量通道的图像。 因此,当我们希望所有通道都相同时,它可以抽象出灰度图像和彩色图像之间的差异。

现在,我们已经解决了与曲线使用有关的几个重要的优化问题,让我们考虑如何组织代码,以便为诸如Cameo之类的应用提供简单且可重用的界面。

设计面向对象的曲线过滤器

由于我们为每个曲线缓存了一个查找数组,因此基于曲线的过滤器具有与之关联的数据。 因此,我们将它们实现为类,而不仅仅是函数。 让我们制作一对曲线过滤器类,以及一些可以应用任何函数而不仅仅是曲线函数的相应高级类:

  • VFuncFilter:这是一个用函数实例化的类,然后可以使用apply将其应用于图像。 该函数适用​​于灰度图像的 V(值)通道或彩色图像的所有通道。
  • VCurveFilter:这是VFuncFilter的子类。 而不是使用函数实例化,而是使用一组控制点实例化,这些控制点在内部用于创建曲线函数。
  • BGRFuncFilter:这是一个用最多四个函数实例化的类,然后可以使用apply将其应用于 BGR 图像。 这些函数之一适用于所有通道,而其他三个函数均适用于单个通道。 首先应用整体函数,然后再应用每通道函数。
  • BGRCurveFilter:这是BGRFuncFilter的子类。 而不是使用四个函数实例化,而是使用四组控制点实例化,这些控制点在内部用于创建曲线函数。

此外,所有这些类都接受数字类型的构造器参数,例如numpy.uint8,每个通道 8 位。 此类型用于确定查找数组中应包含多少个条目。 数值类型应为整数类型,并且查找数组将覆盖从 0 到该类型的最大值(包括该值)的范围。

首先,让我们看一下VFuncFilterVCurveFilter的实现,它们都可以添加到filters.py中:

class VFuncFilter(object):
    """A filter that applies a function to V (or all of BGR)."""

    def __init__(self, vFunc=None, dtype=numpy.uint8):
        length = numpy.iinfo(dtype).max + 1
        self._vLookupArray = utils.createLookupArray(vFunc, length)

    def apply(self, src, dst):
        """Apply the filter with a BGR or gray source/destination."""
        srcFlatView = numpy.ravel(src)
        dstFlatView = numpy.ravel(dst)
        utils.applyLookupArray(self._vLookupArray, srcFlatView,
                               dstFlatView)

class VCurveFilter(VFuncFilter):
    """A filter that applies a curve to V (or all of BGR)."""

    def __init__(self, vPoints, dtype=numpy.uint8):
        VFuncFilter.__init__(self, utils.createCurveFunc(vPoints),
                             dtype)

在这里,我们正在内部使用几个以前的函数:utils.createCurveFuncutils.createLookupArrayutils.applyLookupArray。 我们还使用numpy.iinfo根据给定的数字类型确定相关的查找值范围。

现在,让我们看一下BGRFuncFilterBGRCurveFilter的实现,它们也都可以添加到filters.py中:

class BGRFuncFilter(object):
    """A filter that applies different functions to each of BGR."""

    def __init__(self, vFunc=None, bFunc=None, gFunc=None,
                 rFunc=None, dtype=numpy.uint8):
        length = numpy.iinfo(dtype).max + 1
        self._bLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(bFunc, vFunc), length)
        self._gLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(gFunc, vFunc), length)
        self._rLookupArray = utils.createLookupArray(
            utils.createCompositeFunc(rFunc, vFunc), length)

    def apply(self, src, dst):
        """Apply the filter with a BGR source/destination."""
        b, g, r = cv2.split(src)
        utils.applyLookupArray(self._bLookupArray, b, b)
        utils.applyLookupArray(self._gLookupArray, g, g)
        utils.applyLookupArray(self._rLookupArray, r, r)
        cv2.merge([b, g, r], dst)

class BGRCurveFilter(BGRFuncFilter):
    """A filter that applies different curves to each of BGR."""

    def __init__(self, vPoints=None, bPoints=None,
                 gPoints=None, rPoints=None, dtype=numpy.uint8):
        BGRFuncFilter.__init__(self,
                               utils.createCurveFunc(vPoints),
                               utils.createCurveFunc(bPoints),
                               utils.createCurveFunc(gPoints),
                               utils.createCurveFunc(rPoints), dtype)

同样,我们正在内部使用几个以前的函数:utils.createCurvFuncutils.createCompositeFuncutils.createLookupArrayutils.applyLookupArray。 我们还使用numpy.iinfocv2.splitcv2.merge

这四个类可以按原样使用,在实例化时将自定义函数或控制点作为参数传递。 或者,我们可以创建其他子类,这些子类对某些功能或控制点进行硬编码。 这样的子类可以实例化而无需任何参数。

现在,让我们看一下子类的一些示例。

模拟摄影胶片

曲线的常用用法是模拟数字前摄影中常见的调色板。 每种类型的胶卷都有自己独特的颜色(或灰色)表示法,但我们可以概括一些与数字传感器的区别。 电影往往会损失细节和阴影饱和度,而数字往往会遭受高光的这些缺陷。 而且,胶片在光谱的不同部分上往往具有不均匀的饱和度,因此每张胶片都有某些弹出或跳出的颜色。

因此,当我们想到漂亮的电影照片时,我们可能会想到明亮的且具有某些主导色彩的场景(或副本)。 在另一个极端,也许我们还记得曝光不足的胶卷的暗淡外观,而实验室技术人员的努力并不能改善它。

在本节中,我们将使用曲线创建四个不同的类似于电影的过滤器。 它们受到三种胶片和冲洗技术的启发:

  • 柯达波特拉(Kodak Portra),这是一系列针对肖像和婚礼进行了优化的电影。
  • Fuji Provia,一个通用电影家族。
  • 富士·维尔维亚(Fuji Velvia),针对风景优化的电影系列。
  • 交叉处理是一种非标准的胶片处理技术,有时用于在时装和乐队摄影中产生低劣的外观。

每个电影模拟效果都实现为BGRCurveFilter的非常简单的子类。 在这里,我们只需重写构造器即可为每个通道指定一组控制点。 控制点的选择基于摄影师 Petteri Sulonen 的建议。 有关更多信息,请参见他在这个页面上有关胶片状曲线的文章。

Portra,Provia 和 Velvia 效果应产生看起来正常的图像。 除了前后比较之外,这些效果应该不明显。

让我们从 Portra 过滤器开始,检查四个胶片仿真过滤器中每个过滤器的实现。

模拟柯达 Portra

Portra 具有宽广的高光范围,倾向于暖色(琥珀色),而阴影则较冷(蓝色)。 作为人像电影,它倾向于使人们的肤色更白皙。 而且,它会夸大某些常见的衣服颜色,例如乳白色(例如婚纱)和深蓝色(例如西装或牛仔裤)。 让我们将 Portra 过滤器的此实现添加到filters.py

class BGRPortraCurveFilter(BGRCurveFilter):
    """A filter that applies Portra-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            vPoints = [(0,0),(23,20),(157,173),(255,255)],
            bPoints = [(0,0),(41,46),(231,228),(255,255)],
            gPoints = [(0,0),(52,47),(189,196),(255,255)],
            rPoints = [(0,0),(69,69),(213,218),(255,255)],
            dtype = dtype)

从柯达到富士,接下来我们将模拟 Provia。

模拟富士 Provia

普罗维亚(Provia)具有很强的对比度,并且在大多数色调中略微凉爽(蓝色)。 天空,水和阴影比太阳增强更多。 让我们将 Provia 过滤器的此实现添加到filters.py

class BGRProviaCurveFilter(BGRCurveFilter):
    """A filter that applies Provia-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            bPoints = [(0,0),(35,25),(205,227),(255,255)],
            gPoints = [(0,0),(27,21),(196,207),(255,255)],
            rPoints = [(0,0),(59,54),(202,210),(255,255)],
            dtype = dtype)

接下来是我们的 Fuji Velvia 过滤器。

模拟富士 Velvia

Velvia 具有深阴影和鲜艳的色彩。 它通常可以在白天产生蔚蓝的天空,在日落时产生深红色的云。 这种效果很难模拟,但是这是我们可以添加到filters.py的尝试:

class BGRVelviaCurveFilter(BGRCurveFilter):
    """A filter that applies Velvia-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            vPoints = [(0,0),(128,118),(221,215),(255,255)],
            bPoints = [(0,0),(25,21),(122,153),(165,206),(255,255)],
            gPoints = [(0,0),(25,21),(95,102),(181,208),(255,255)],
            rPoints = [(0,0),(41,28),(183,209),(255,255)],
            dtype = dtype)

现在,让我们来看一下交叉处理的外观!

模拟交叉处理

交叉处理会在阴影中产生强烈的蓝色或绿蓝色调,在高光区域产生强烈的黄色或绿黄色。 黑色和白色不一定要保留。 而且,对比度非常高。 交叉处理的照片看起来很不舒服。 人们看起来黄疸,而无生命的物体看起来很脏。 让我们编辑filters.py并添加以下交叉处理过滤器的实现:

class BGRCrossProcessCurveFilter(BGRCurveFilter):
    """A filter that applies cross-process-like curves to BGR."""

    def __init__(self, dtype=numpy.uint8):
        BGRCurveFilter.__init__(
            self,
            bPoints = [(0,20),(255,235)],
            gPoints = [(0,0),(56,39),(208,226),(255,255)],
            rPoints = [(0,0),(56,22),(211,255),(255,255)],
            dtype = dtype)

现在我们已经看过一些有关如何实现胶片仿真过滤器的示例,我们将包装本附录,以便您可以回到第 3 章“使用 OpenCV 处理图像”中的Cameo应用的主要实现。

总结

scipy.interp1d函数的基础上,我们实现了一系列曲线过滤器,这些过滤器高效(由于使用查找数组)并且易于扩展(由于面向对象的设计)。 我们的工作包括专用曲线过滤器,可以使数字图像看起来更像胶卷照。 这些过滤器可以很容易地集成到诸如Cameo之类的应用中,如第 3 章,“用 OpenCV 处理图像”中使用我们的 Portra 胶片仿真过滤器所示。

posted @ 2025-09-21 12:12  绝不原创的飞龙  阅读(45)  评论(0)    收藏  举报