Python3 OpenCV4 计算机视觉学习手册:1~5

简介: Python3 OpenCV4 计算机视觉学习手册:1~5

一、设置 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("https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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 的另一种方法。 让我们看看每个过滤器的外观。 作为输入,我们从以下照片开始:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AhbjKU21-1681871519322)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/ea950b78-cf4e-4900-ba55-66dfd5bedfc6.jpg)]

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

您会注意到,如右下图所示,差分 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("https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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()

这是结果的直观表示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BDpsDb1a-1681871519324)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/24bcda52-cd6b-45bb-a461-426da149e7bd.png)]

检测其他形状

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 处理图像”中实现的艺术过滤器。 以下代码完成了CameoDepthrun方法的实现:

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)刷牙的遮罩如下所示:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KSSTvyv1-1681871519326)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/be52d81a-a3ee-49f8-9176-9d2a8f68877a.png)]

结果是好的,但不是完美的。 例如,在图像的右侧(从查看者的角度来看),遮罩错误地在头发后面包括阴影区域,并且错误地排除了肩膀。 可以通过微调在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('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/color1_small.jpg')
imgR = cv2.imread('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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)。 因此,图像被分为两部分。 下图充分代表了该算法:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pFFwPje4-1681871519332)(https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/img/e998eeef-6aab-497a-a7f3-c6f281b94c4e.png)]

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

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

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

import numpy as np
import cv2
from matplotlib import pyplot as plt
original = cv2.imread('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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('https://gitcode.net/apachecn/apachecn-cv-zh/-/raw/master/docs/learn-opencv4-cv-py3/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 章,“检索图像并使用图像描述符进行搜索”中。

相关文章
|
10天前
|
Python 容器
Python学习的自我理解和想法(9)
这是我在B站跟随千锋教育学习Python的第9天,主要学习了赋值、浅拷贝和深拷贝的概念及其底层逻辑。由于开学时间紧张,内容较为简略,但希望能帮助理解这些重要概念。赋值是创建引用,浅拷贝创建新容器但元素仍引用原对象,深拷贝则创建完全独立的新对象。希望对大家有所帮助,欢迎讨论。
|
1天前
|
Python
Python学习的自我理解和想法(10)
这是我在千锋教育B站课程学习Python的第10天笔记,主要学习了函数的相关知识。内容包括函数的定义、组成、命名、参数分类(必须参数、关键字参数、默认参数、不定长参数)及调用注意事项。由于开学时间有限,记录较为简略,望谅解。通过学习,我理解了函数可以封装常用功能,简化代码并便于维护。若有不当之处,欢迎指正。
|
12天前
|
存储 索引 Python
Python学习的自我理解和想法(6)
这是我在B站千锋教育学习Python的第6天笔记,主要学习了字典的使用方法,包括字典的基本概念、访问、修改、添加、删除元素,以及获取字典信息、遍历字典和合并字典等内容。开学后时间有限,内容较为简略,敬请谅解。
|
16天前
|
存储 程序员 Python
Python学习的自我理解和想法(2)
今日学习Python第二天,重点掌握字符串操作。内容涵盖字符串介绍、切片、长度统计、子串计数、大小写转换及查找位置等。通过B站黑马程序员课程跟随老师实践,非原创代码,旨在巩固基础知识与技能。
|
15天前
|
程序员 Python
Python学习的自我理解和想法(3)
这是学习Python第三天的内容总结,主要围绕字符串操作展开,包括字符串的提取、分割、合并、替换、判断、编码及格式化输出等,通过B站黑马程序员课程跟随老师实践,非原创代码。
|
12天前
|
Python
Python学习的自我理解和想法(7)
学的是b站的课程(千锋教育),跟老师写程序,不是自创的代码! 今天是学Python的第七天,学的内容是集合。开学了,时间不多,写得不多,见谅。
|
10天前
|
存储 安全 索引
Python学习的自我理解和想法(8)
这是我在B站千锋教育学习Python的第8天,主要内容是元组。元组是一种不可变的序列数据类型,用于存储一组有序的元素。本文介绍了元组的基本操作,包括创建、访问、合并、切片、遍历等,并总结了元组的主要特点,如不可变性、有序性和可作为字典的键。由于开学时间紧张,内容较为简略,望见谅。
|
12天前
|
存储 索引 Python
Python学习的自我理解和想法(4)
今天是学习Python的第四天,主要学习了列表。列表是一种可变序列类型,可以存储任意类型的元素,支持索引和切片操作,并且有丰富的内置方法。主要内容包括列表的入门、关键要点、遍历、合并、判断元素是否存在、切片、添加和删除元素等。通过这些知识点,可以更好地理解和应用列表这一强大的数据结构。
|
12天前
|
索引 Python
Python学习的自我理解和想法(5)
这是我在B站千锋教育学习Python的第五天笔记,主要内容包括列表的操作,如排序(`sort()`、``sorted()``)、翻转(`reverse()`)、获取长度(`len()`)、最大最小值(`max()`、``min()``)、索引(`index()`)、嵌套列表和列表生成(`range`、列表生成式)。通过这些操作,可以更高效地处理数据。希望对大家有所帮助!
|
18天前
|
安全 程序员 Python
Python学习的自我理解和想法(1)
本篇博客记录了作者跟随B站“黑马程序员”课程学习Python的第一天心得,涵盖了`print()`、`input()`、`if...else`语句、三目运算符以及`for`和`while`循环的基础知识。通过实际编写代码,作者逐步理解并掌握了这些基本概念,为后续深入学习打下了良好基础。文中还特别强调了循环语句的重要性及其应用技巧。