Python-OpenCV4-蓝图-全-

Python OpenCV4 蓝图(全)

原文:annas-archive.org/md5/8dae50c67273e660f09fe74447ef722d

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书的目标是让你通过使用最新版本的 OpenCV 4 框架和 Python 3.8 语言,在一系列中级到高级的项目中亲自动手,而不是仅仅在理论课程中涵盖计算机视觉的核心概念。

这本更新的第二版增加了我们用 OpenCV 解决的概念深度。它将引导你通过独立的手动项目,专注于图像处理、3D 场景重建、目标检测和目标跟踪等基本计算机视觉概念。它还将通过实际案例涵盖统计学习和深度神经网络。

你将从理解图像滤镜和特征匹配等概念开始,以及使用自定义传感器,如Kinect 深度传感器。你还将学习如何重建和可视化 3D 场景,如何对齐图像,以及如何将多个图像合并成一个。随着你在书中的进步,你将学习如何使用神经网络识别交通标志和面部表情,即使在物体短暂消失的情况下也能检测和跟踪视频流中的物体。

在阅读完这本 OpenCV 和 Python 书籍之后,你将拥有实际操作经验,并能熟练地根据特定业务需求开发自己的高级计算机视觉应用。在整个书中,你将探索多种机器学习和计算机视觉模型,例如支持向量机SVMs)和卷积神经网络。

本书面向的对象

本书面向的是追求通过使用 OpenCV 和其他机器学习库开发高级实用应用来掌握技能的计算机视觉爱好者。

假设你具备基本的编程技能和 Python 编程知识。

本书涵盖的内容

第一章,与滤镜的乐趣,探讨了多种有趣的图像滤镜(例如黑白铅笔素描、暖色/冷色滤镜和卡通化效果),并将它们实时应用于网络摄像头的视频流中。

第二章,使用 Kinect 深度传感器进行手势识别,帮助你开发一个应用,实时检测和跟踪简单的手势,使用深度传感器的输出,如微软 Kinect 3D 传感器或华硕 Xtion。

第三章,通过特征匹配和透视变换查找对象,帮助你开发一个应用,在摄像头的视频流中检测感兴趣的任意对象,即使对象从不同的角度或距离观看,或者部分遮挡。

第四章,使用运动结构进行 3D 场景重建,展示了如何通过从相机运动中推断其几何特征来重建和可视化 3D 场景。

第五章,使用 OpenCV 进行计算摄影,帮助你开发命令行脚本,这些脚本以图像为输入并生成全景图或高动态范围HDR)图像。这些脚本将图像对齐,以实现像素到像素的对应,或者将它们拼接成全景图,这是图像对齐的一个有趣应用。在全景图中,两个图像不是平面的,而是三维场景的图像。一般来说,3D 对齐需要深度信息。然而,当两个图像是通过围绕其光学轴旋转相机拍摄的(如全景图的情况),我们可以对齐全景图中的两个图像。

第六章,跟踪视觉显著对象,帮助你开发一个应用,可以同时跟踪视频序列中的多个视觉显著对象(如足球比赛中的所有球员)。

第七章,学习识别交通标志,展示了如何训练支持向量机从德国交通标志识别基准GTSRB)数据集中识别交通标志。

第八章,学习识别面部表情,帮助你开发一个能够在实时网络摄像头视频流中检测面部并识别其情感表达的应用程序。

第九章,学习识别面部表情,引导你开发一个使用深度卷积神经网络进行实时对象分类的应用程序。你将修改一个分类网络,使用自定义数据集和自定义类别进行训练。你将学习如何在数据集上训练 Keras 模型以及如何将你的 Keras 模型序列化和保存到磁盘。然后,你将看到如何使用加载的 Keras 模型对新的输入图像进行分类。你将使用你拥有的图像数据训练卷积神经网络,以获得一个具有非常高的准确率的良好分类器。

第十章,学习检测和跟踪对象,指导你开发一个使用深度神经网络进行实时对象检测的应用程序,并将其连接到跟踪器。你将学习对象检测器是如何工作的以及它们是如何训练的。你将实现一个基于卡尔曼滤波器的跟踪器,它将使用对象位置和速度来预测其可能的位置。完成本章后,你将能够构建自己的实时对象检测和跟踪应用程序。

附录 A,分析和加速你的应用,介绍了如何找到应用中的瓶颈,并使用 Numba 实现现有代码的 CPU 和 CUDA 基于 GPU 的加速。

附录 B,设置 Docker 容器,将指导您复制我们用于运行本书中代码的环境。

为了充分利用本书

我们所有的代码都使用Python 3.8,它可以在多种操作系统上使用,例如WindowsGNU LinuxmacOS以及其他操作系统。我们已尽力只使用这三个操作系统上可用的库。我们将详细介绍我们所使用的每个依赖项的确切版本,这些依赖项可以使用pip(Python 的依赖项管理系统)安装。如果您在使用这些依赖项时遇到任何问题,我们提供了 Dockerfile,其中包含了我们测试本书中所有代码的环境,具体内容在附录 B,设置 Docker 容器中介绍。

这里是我们使用过的依赖项列表,以及它们所使用的章节:

所需软件 版本 章节编号 软件下载链接
Python 3.8 All www.python.org/downloads/
OpenCV 4.2 All opencv.org/releases/
NumPy 1.18.1 All www.scipy.org/scipylib/download.html
wxPython 4.0 1, 4, 8 www.wxpython.org/download.php
matplotlib 3.1 4, 5, 6, 7 matplotlib.org/downloads.html
SciPy 1.4 1, 10 www.scipy.org/scipylib/download.html
rawpy 0.14 5 pypi.org/project/rawpy/
ExifRead 2.1.2 5 pypi.org/project/ExifRead/
TensorFlow 2.0 7, 9 www.tensorflow.org/install

为了运行代码,您需要一个普通的笔记本电脑或个人电脑(PC)。某些章节需要摄像头,可以是内置的笔记本电脑摄像头或外置摄像头。第二章,使用 Kinect 深度传感器进行手势识别也要求一个深度传感器,可以是Microsoft 3D Kinect 传感器或任何其他由libfreenect库或 OpenCV 支持的传感器,例如ASUS Xtion

我们使用Python 3.8Python 3.7Ubuntu 18.04上进行了测试。

如果您已经在您的计算机上安装了 Python,您可以直接在终端运行以下命令:

$ pip install -r requirements.txt

在这里,requirements.txt文件已提供在项目的 GitHub 仓库中,其内容如下(这是之前给出的表格以文本文件的形式):

wxPython==4.0.5
numpy==1.18.1
scipy==1.4.1
matplotlib==3.1.2
requests==2.22.0
opencv-contrib-python==4.2.0.32
opencv-python==4.2.0.32
rawpy==0.14.0
ExifRead==2.1.2
tensorflow==2.0.1

或者,您也可以按照附录 B 中的说明,设置 Docker 容器,以使用 Docker 容器使一切正常工作。

下载示例代码文件

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

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

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

  2. 选择“支持”选项卡。

  3. 点击“代码下载”。

  4. 在搜索框中输入书名,并按照屏幕上的说明操作。

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

  • Windows 上的 WinRAR/7-Zip

  • Mac 上的 Zipeg/iZip/UnRarX

  • Linux 上的 7-Zip/PeaZip

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

我们还有其他来自我们丰富的图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们!

代码实战

本书“代码实战”视频可以在bit.ly/2xcjKdS查看。

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:static.packt-cdn.com/downloads/9781789801811_ColorImages.pdf

约定如下

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

CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“我们将使用argparse,因为我们希望我们的脚本接受参数。”

代码块设置如下:

import argparse

import cv2
import numpy as np

from classes import CLASSES_90
from sort import Sort

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

$ python chapter8.py collect

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

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

小贴士和技巧如下所示。

联系我们

我们始终欢迎读者的反馈。

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

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

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

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

评论

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

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

第一章:与过滤器一起玩乐

本章的目标是开发一系列图像处理过滤器,并将它们实时应用于网络摄像头的视频流中。这些过滤器将依赖于各种 OpenCV 函数,通过分割、合并、算术运算和应用查找表来操作矩阵。

我们将介绍以下三种效果,这将帮助您熟悉 OpenCV,并在本书的后续章节中构建这些效果:

  • 暖色和冷色过滤器: 我们将使用查找表实现自己的曲线过滤器

  • 黑白铅笔素描: 我们将利用两种图像混合技术,称为** dodging burning**。

  • 卡通化工具: 我们将结合双边滤波器、中值滤波器和自适应阈值。

OpenCV 是一个高级工具链。它经常引发这样的问题,即,不是如何从头开始实现某物,而是为您的需求选择哪个预定义的实现。如果您有大量的计算资源,生成复杂效果并不难。挑战通常在于找到一个既能完成任务又能按时完成的方法。

我们不会通过理论课程教授图像处理的基本概念,而是将采取一种实用方法,开发一个单一端到端的应用程序,该程序集成了多种图像过滤技术。我们将应用我们的理论知识,得出一个不仅有效而且能加快看似复杂效果解决方案,以便笔记本电脑可以实时生成它们。

在本章中,您将学习如何使用 OpenCV 完成以下操作:

  • 创建黑白铅笔素描

  • 应用铅笔素描变换

  • 生成暖色和冷色过滤器

  • 图像卡通化

  • 将所有内容整合在一起

学习这一点将使您熟悉将图像加载到 OpenCV 中,并使用 OpenCV 对这些图像应用不同的变换。本章将帮助您了解 OpenCV 的基本操作,这样我们就可以专注于以下章节中算法的内部结构。

现在,让我们看看如何让一切运行起来。

开始学习

本书中的所有代码都是针对OpenCV 4.2编写的,并在Ubuntu 18.04上进行了测试。在整个本书中,我们将广泛使用NumPy包(www.numpy.org)。

此外,本章还需要SciPy包的UnivariateSpline模块(www.scipy.org)和wxPython 4.0 图形用户界面 (GUI) (www.wxpython.org/download.php)用于跨平台 GUI 应用程序。我们将尽可能避免进一步的依赖。

对于更多书籍级别的依赖项,请参阅附录 A 分析和加速您的应用程序(Appendix A)和附录 B 设置 Docker 容器(Appendix B)。

您可以在我们的 GitHub 仓库中找到本章中展示的代码:github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter1

让我们从规划本章将要创建的应用程序开始。

规划应用程序

最终的应用程序必须包含以下模块和脚本:

  • wx_gui.py: 这个模块是我们使用 wxpython 实现的基本 GUI,我们将在整本书中广泛使用这个文件。此模块包括以下布局:

    • wx_gui.BaseLayout: 这是一个通用布局类,可以从中构建更复杂的布局。
  • chapter1.py: 这是本章的主要脚本。它包含以下函数和类:

    • chapter1.FilterLayout: 这是一个基于 wx_gui.BaseLayout 的自定义布局,它显示摄像头视频流和一排单选按钮,用户可以通过这些按钮从可用的图像过滤器中选择要应用于摄像头视频流每一帧的过滤器。

    • chapter1.main: 这是启动 GUI 应用程序和访问摄像头的主体函数。

  • tools.py: 这是一个 Python 模块,包含我们在本章中使用的许多辅助函数,您也可以将其用于您的项目。

下一节将演示如何创建黑白铅笔素描。

创建黑白铅笔素描

为了获得摄像头帧的铅笔素描(即黑白绘画),我们将使用两种图像混合技术,称为 ** dodging** 和 burning。这些术语指的是在传统摄影打印过程中采用的技术;在这里,摄影师会操纵暗室打印的某个区域的曝光时间,以使其变亮或变暗。Dodging 使图像变亮,而 burning 使图像变暗。未打算发生变化的区域使用 mask 进行保护。

今天,现代图像编辑程序,如 PhotoshopGimp,提供了在数字图像中模仿这些效果的方法。例如,蒙版仍然用于模仿改变图像曝光时间的效果,其中蒙版中相对强烈的值会 曝光 图像,从而使图像变亮。OpenCV 没有提供原生的函数来实现这些技术;然而,通过一点洞察力和几个技巧,我们将达到我们自己的高效实现,可用于产生美丽的铅笔素描效果。

如果您在网上搜索,可能会遇到以下常见程序,用于从 RGB红色绿色蓝色)彩色图像中实现铅笔素描:

  1. 首先,将彩色图像转换为灰度图像。

  2. 然后,将灰度图像反转以得到负片。

  3. 对步骤 2 中的负片应用 高斯模糊

  4. 使用 颜色 dodging 将步骤 1 中的灰度图像与步骤 3 中的模糊负片混合。

虽然 步骤 13 很直接,但 步骤 4 可能有点棘手。让我们首先解决这个难题。

OpenCV 3 直接提供了铅笔素描效果。cv2.pencilSketch 函数使用了 2011 年论文中引入的领域滤波器,该论文为 Domain Transform for Edge-Aware Image and Video Processing,作者是 Eduardo Gastal 和 Manuel Oliveira。然而,为了本书的目的,我们将开发自己的滤波器。

下一节将向您展示如何在 OpenCV 中实现 dodging 和 burning。

理解使用 dodging 和 burning 技术的方法

Dodging 减少了图像中我们希望变亮(相对于之前)的区域的曝光度,A。在图像处理中,我们通常使用蒙版选择或指定需要更改的图像区域。蒙版 B 是一个与图像相同维度的数组,可以在其上应用(将其想象成一张带有孔的纸,用于覆盖图像)。纸张上的“孔”用 255(或如果我们工作在 0-1 范围内则为 1)表示,在不透明的区域用零表示。

在现代图像编辑工具中,例如 Photoshop,图像 A 与蒙版 B 的颜色 dodging 是通过以下三元语句实现的,该语句对每个像素使用索引 i 进行操作:

((B[i] == 255) ? B[i] : 
    min(255, ((A[i] << 8) / (255 - B[i]))))

之前的代码本质上是将 A[i] 图像像素的值除以 B[i] 蒙版像素值的倒数(这些值在 0-255 范围内),同时确保结果像素值在 (0, 255) 范围内,并且我们不会除以 0。

我们可以将之前看起来复杂的表达式或代码翻译成以下简单的 Python 函数,该函数接受两个 OpenCV 矩阵(imagemask)并返回混合图像:

def dodge_naive(image, mask):
    # determine the shape of the input image
    width, height = image.shape[:2]

    # prepare output argument with same size as image
    blend = np.zeros((width, height), np.uint8)

    for c in range(width):
        for r in range(height):

            # shift image pixel value by 8 bits
            # divide by the inverse of the mask
            result = (image[c, r] << 8) / (255 - mask[c, r])

            # make sure resulting value stays within bounds
            blend[c, r] = min(255, result)
    return blend

如你所猜,尽管之前的代码可能在功能上是正确的,但它无疑会非常慢。首先,该函数使用了 for 循环,这在 Python 中几乎总是不是一个好主意。其次,NumPy 数组(Python 中 OpenCV 图像的底层格式)针对数组计算进行了优化,因此单独访问和修改每个 image[c, r] 像素将会非常慢。

相反,我们应该意识到 <<8 操作与将像素值乘以数字 2⁸=256)相同,并且可以使用 cv2.divide 函数实现像素级的除法。因此,我们的 dodge 函数的改进版本利用了矩阵乘法(这更快),看起来如下:

import cv2 

def dodge(image, mask): 
    return cv2.divide(image, 255 - mask, scale=256) 

在这里,我们将dodge函数简化为单行!新的dodge函数产生的结果与dodge_naive相同,但比原始版本快得多。此外,cv2.divide会自动处理除以零的情况,当255 - mask为零时,结果为零。

这里是Lena.png的一个 dodged 版本,其中我们在像素范围(100:300, 100:300的方块中进行了 dodging:

图片来源——“Lenna”由 Conor Lawless 提供,许可协议为 CC BY 2.0

如您所见,在右侧的照片中,亮化区域非常明显,因为过渡非常尖锐。有方法可以纠正这一点,我们将在下一节中探讨其中一种方法。

让我们学习如何使用二维卷积来获得高斯模糊。

使用二维卷积实现高斯模糊

高斯模糊是通过用高斯值核卷积图像来实现的。二维卷积在图像处理中应用非常广泛。通常,我们有一个大图片(让我们看看该特定图像的 5 x 5 子区域),我们有一个核(或过滤器),它是一个更小的矩阵(在我们的例子中,3 x 3)。

为了获取卷积值,假设我们想要获取位置(2, 3)的值。我们将核中心放在位置(2, 3),并计算叠加矩阵(以下图像中的高亮区域,红色)与核的点积,并取总和。得到的值(即 158.4)是我们写在另一个矩阵位置(2, 3)的值。

我们对所有的元素重复这个过程,得到的矩阵(右侧的矩阵)是核与图像的卷积。在下面的图中,左侧可以看到带有像素值的原始图像(值高于 100)。我们还看到一个橙色过滤器,每个单元格的右下角有值(0.1 或 0.2 的集合,总和为 1)。在右侧的矩阵中,您可以看到当过滤器应用于左侧图像时得到的值:

注意,对于边界上的点,核与矩阵不对齐,因此我们必须想出一个策略来为这些点赋值。没有一种适用于所有情况的单一良好策略;一些方法是将边界扩展为零,或者使用边界值进行扩展。

让我们看看如何将普通图片转换为铅笔素描。

应用铅笔素描转换

我们已经从上一节学到了一些技巧,现在我们可以准备查看整个流程了。

最终代码可以在tools.py文件中的convert_to_pencil_sketch函数中找到。

以下过程展示了如何将彩色图像转换为灰度图。之后,我们旨在将灰度图像与其模糊的负图像混合:

  1. 首先,我们将 RGB 图像 (imgRGB) 转换为灰度图:
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) 

如您所见,我们已将 cv2.COLOR_RGB2GRAY 作为 cv2.cvtColor 函数的参数,这改变了颜色空间。请注意,输入图像是 RGB 还是 BGR(OpenCV 的默认设置)并不重要;最终我们都会得到一个漂亮的灰度图像。

  1. 然后,我们使用大小为 (21,21) 的大高斯核对图像进行反转和模糊处理:
    inv_gray = 255 - gray_image
    blurred_image = cv2.GaussianBlur(inv_gray, (21, 21), 0, 0)
  1. 我们使用 dodge 将原始灰度图像与模糊的逆变换混合:
    gray_sketch = cv2.divide(gray_image, 255 - blurred_image, 
    scale=256)

生成的图像看起来是这样的:

图片来源——“Lenna”由 Conor Lawless 提供,授权协议为 CC BY 2.0

你注意到我们的代码还可以进一步优化吗?让我们看看如何使用 OpenCV 进行优化。

使用高斯模糊的优化版本

高斯模糊基本上是一个与高斯函数的卷积。嗯,卷积的一个特性是它们的结合性质。这意味着我们首先反转图像然后模糊,还是先模糊图像然后反转,并不重要。

如果我们从模糊的图像开始,并将其逆变换传递给 dodge 函数,那么在该函数内部图像将被再次反转(255-mask 部分),本质上得到原始图像。如果我们去掉这些冗余操作,优化的 convert_to_pencil_sketch 函数将看起来像这样:

def convert_to_pencil_sketch(rgb_image):
    gray_image = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    blurred_image = cv2.GaussianBlur(gray_image, (21, 21), 0, 0)
    gray_sketch = cv2.divide(gray_image, blurred_image, scale=256)
    return cv2.cvtColor(gray_sketch, cv2.COLOR_GRAY2RGB)

为了增添乐趣,我们想要将我们的变换图像 (img_sketch) 轻轻地与背景图像 (canvas) 混合,使其看起来像是在画布上绘制的。因此,在返回之前,我们希望如果存在 canvas,则与 canvas 混合:

    if canvas is not None:
        gray_sketch = cv2.multiply(gray_sketch, canvas, scale=1 / 256)

我们将最终的函数命名为 pencil_sketch_on_canvas,它看起来是这样的(包括优化):

def pencil_sketch_on_canvas(rgb_image, canvas=None):
    gray_image = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    blurred_image = cv2.GaussianBlur(gray_image, (21, 21), 0, 0)
    gray_sketch = cv2.divide(gray_image, blurred_image, scale=256)
    if canvas is not None:
        gray_sketch = cv2.multiply(gray_sketch, canvas, scale=1 / 256)
    return cv2.cvtColor(gray_sketch, cv2.COLOR_GRAY2RGB)

这只是我们的 convert_to_pencil_sketch 函数,它有一个可选的 canvas 参数,可以为铅笔素描添加艺术感。

完成了!最终的输出看起来是这样的:

让我们看看如何在下一节中生成暖色和冷色滤镜,你将学习如何使用 查找表 进行图像处理。

生成暖色和冷色滤镜

当我们感知图像时,大脑会捕捉到许多细微的线索来推断场景的重要细节。例如,在晴朗的白天,高光可能带有轻微的黄色调,因为它们处于直射阳光下,而阴影可能因为蓝色天空的环境光而显得略带蓝色。当我们看到具有这种颜色特性的图像时,我们可能会立刻想到一个晴朗的日子。

这种效果对摄影师来说并不神秘,他们有时会故意操纵图像的白平衡来传达某种情绪。暖色通常被认为更愉快,而冷色则与夜晚和单调联系在一起。

为了操纵图像的感知色温,我们将实现一个曲线过滤器。这些过滤器控制颜色在不同图像区域之间的过渡,使我们能够微妙地改变色谱,而不会给图像添加看起来不自然的整体色调。

在下一节中,我们将探讨如何通过曲线平移来操纵颜色。

通过曲线平移进行颜色操纵

曲线过滤器本质上是一个函数,y = f (x),它将输入像素值 x 映射到输出像素值 y。曲线由一组 n + 1 锚点参数化,如下所示:

图片

在这里,每个锚点是一对代表输入和输出像素值的数字。例如,对(30, 90)的配对意味着输入像素值 30 增加到输出值 90。锚点之间的值沿着一条平滑的曲线进行插值(因此得名曲线过滤器)。

这种过滤器可以应用于任何图像通道,无论是单个灰度通道还是 RGB 彩色图像的R红色)、G绿色)和B蓝色)通道。因此,为了我们的目的,所有 xy 的值都必须保持在 0 到 255 之间。

例如,如果我们想使灰度图像稍微亮一些,我们可以使用以下控制点的曲线过滤器:

图片

这意味着除了 0255 以外的所有输入像素值都会略微增加,从而在图像上产生整体变亮的效果。

如果我们希望这样的过滤器产生看起来自然的图像,那么遵守以下两条规则是很重要的:

  • 每组锚点都应该包括 (0,0)(255,255)。这对于防止图像看起来像有整体色调很重要,因为黑色仍然是黑色,白色仍然是白色。

  • f(x) 函数应该是单调递增的。换句话说,通过增加 xf(x) 要么保持不变,要么增加(即,它永远不会减少)。这对于确保阴影仍然是阴影,高光仍然是高光非常重要。

下一节将演示如何使用查找表实现曲线过滤器。

使用查找表实现曲线过滤器

曲线过滤器计算成本较高,因为当 x 不与预指定的锚点之一相匹配时,必须对 f(x) 的值进行插值。对我们遇到的每个图像帧的每个像素执行此计算将对性能产生重大影响。

相反,我们使用查找表。由于我们的目的是只有 256 个可能的像素值,因此我们只需要计算所有 256 个可能的 x 值的 f(x)。插值由 scipy.interpolate 模块的 UnivariateSpline 函数处理,如下面的代码片段所示:

from scipy.interpolate import UnivariateSpline 

def spline_to_lookup_table(spline_breaks: list, break_values: list):
    spl = UnivariateSpline(spline_breaks, break_values)
    return spl(range(256)

函数的 return 参数是一个包含每个可能的 x 值的插值 f(x) 值的 256 个元素的列表。

现在我们需要做的就是提出一组锚点,(x[i], y[i]),然后我们就可以将过滤器应用于灰度输入图像(img_gray):

import cv2 
import numpy as np 

x = [0, 128, 255] 
y = [0, 192, 255] 
myLUT = spline_to_lookup_table(x, y) 
img_curved = cv2.LUT(img_gray, myLUT).astype(np.uint8) 

结果看起来像这样(原始图像在 边,转换后的图像在 边):

图片

在下一节中,我们将设计暖色和冷色效果。你还将学习如何将查找表应用于彩色图像,以及暖色和冷色效果是如何工作的。

设计暖色和冷色效果

由于我们已经有了快速将通用曲线过滤器应用于任何图像通道的机制,我们现在可以转向如何操纵图像感知色温的问题。再次强调,最终的代码将在 tools 模块中拥有自己的函数。

如果你有多余的一分钟时间,我建议你尝试不同的曲线设置一段时间。你可以选择任意数量的锚点,并将曲线过滤器应用于你想到的任何图像通道(红色、绿色、蓝色、色调、饱和度、亮度、明度等等)。你甚至可以将多个通道组合起来,或者降低一个并移动另一个到所需区域。结果会是什么样子?

然而,如果可能性让你眼花缭乱,请采取更保守的方法。首先,通过利用我们在前面步骤中开发的 spline_to_lookup_table 函数,让我们定义两个通用曲线过滤器:一个(按趋势)增加通道的所有像素值,另一个通常减少它们:

INCREASE_LOOKUP_TABLE = spline_to_lookup_table([0, 64, 128, 192, 256],
                                               [0, 70, 140, 210, 256])
DECREASE_LOOKUP_TABLE = spline_to_lookup_table([0, 64, 128, 192, 256],
                                               [0, 30, 80, 120, 192])

现在,让我们看看我们如何将查找表应用于 RGB 图像。OpenCV 有一个名为 cv2.LUT 的不错函数,它接受一个查找表并将其应用于矩阵。因此,首先,我们必须将图像分解为不同的通道:

    c_r, c_g, c_b = cv2.split(rgb_image)

然后,如果需要,我们可以对每个通道应用过滤器:

    if green_filter is not None:
        c_g = cv2.LUT(c_g, green_filter).astype(np.uint8)

对 RGB 图像中的所有三个通道都这样做,我们得到以下辅助函数:

def apply_rgb_filters(rgb_image, *,
                      red_filter=None, green_filter=None, blue_filter=None):
    c_r, c_g, c_b = cv2.split(rgb_image)
    if red_filter is not None:
        c_r = cv2.LUT(c_r, red_filter).astype(np.uint8)
    if green_filter is not None:
        c_g = cv2.LUT(c_g, green_filter).astype(np.uint8)
    if blue_filter is not None:
        c_b = cv2.LUT(c_b, blue_filter).astype(np.uint8)
    return cv2.merge((c_r, c_g, c_b))

要让图像看起来像是在炎热的阳光明媚的日子里拍摄的(可能接近日落),最简单的方法是增加图像中的红色,并通过增加颜色饱和度使颜色看起来更加鲜艳。我们将分两步实现这一点:

  1. 使用 INCREASE_LOOKUP_TABLEDECREASE_LOOKUP_TABLE 分别增加 RGB 颜色图像中 R 通道(来自 RGB 图像)的像素值,并减少 B 通道的像素值:
        interim_img = apply_rgb_filters(rgb_image,
                                        red_filter=INCREASE_LOOKUP_TABLE,
                                        blue_filter=DECREASE_LOOKUP_TABLE)
  1. 将图像转换为HSV颜色空间(H代表色调S代表饱和度V代表亮度),并使用INCREASE_LOOKUP_TABLE增加S 通道。这可以通过以下函数实现,该函数期望一个 RGB 彩色图像和一个要应用的查找表(类似于apply_rgb_filters函数)作为输入:
def apply_hue_filter(rgb_image, hue_filter):
    c_h, c_s, c_v = cv2.split(cv2.cvtColor(rgb_image, cv2.COLOR_RGB2HSV))
    c_s = cv2.LUT(c_s, hue_filter).astype(np.uint8)
    return cv2.cvtColor(cv2.merge((c_h, c_s, c_v)), cv2.COLOR_HSV2RGB)

结果看起来像这样:

图片

类似地,我们可以定义一个冷却滤波器,该滤波器增加 RGB 图像中的 B 通道的像素值,减少 R 通道的像素值,将图像转换为 HSV 颜色空间,并通过 S 通道降低色彩饱和度:

    def _render_cool(rgb_image: np.ndarray) -> np.ndarray:
        interim_img = apply_rgb_filters(rgb_image,
                                        red_filter=DECREASE_LOOKUP_TABLE,
                                        blue_filter=INCREASE_LOOKUP_TABLE)
        return apply_hue_filter(interim_img, DECREASE_LOOKUP_TABLE)

现在的结果看起来像这样:

图片

让我们在下一节中探讨如何卡通化图像,我们将学习双边滤波器是什么以及更多内容。

卡通化图像

在过去的几年里,专业的卡通化软件到处涌现。为了实现基本的卡通效果,我们只需要一个双边滤波器和一些边缘检测

双边滤波器将减少图像的色彩调色板或使用的颜色数量。这模仿了卡通画,其中卡通画家通常只有很少的颜色可供选择。然后,我们可以对生成的图像应用边缘检测以生成醒目的轮廓。然而,真正的挑战在于双边滤波器的计算成本。因此,我们将使用一些技巧以实时产生可接受的卡通效果。

我们将遵循以下步骤将 RGB 彩色图像转换为卡通:

  1. 首先,应用双边滤波器以减少图像的色彩调色板。

  2. 然后,将原始彩色图像转换为灰度图。

  3. 之后,应用中值滤波以减少图像噪声。

  4. 使用自适应阈值在边缘掩码中检测和强调边缘。

  5. 最后,将步骤 1 中的颜色图像与步骤 4 中的边缘掩码结合。

在接下来的章节中,我们将详细介绍之前提到的步骤。首先,我们将学习如何使用双边滤波器进行边缘感知平滑。

使用双边滤波器进行边缘感知平滑

强力的双边滤波器非常适合将 RGB 图像转换为彩色画或卡通,因为它在平滑平坦区域的同时保持边缘锐利。这个滤波器的唯一缺点是它的计算成本——它的速度比其他平滑操作(如高斯模糊)慢得多。

当我们需要降低计算成本时,首先要采取的措施是对低分辨率图像进行操作。为了将 RGB 图像(imgRGB)的大小缩小到原来的四分之一(即宽度高度减半),我们可以使用cv2.resize

    img_small = cv2.resize(img_rgb, (0, 0), fx=0.5, fy=0.5) 

调整大小后的图像中的像素值将对应于原始图像中一个小邻域的像素平均值。然而,这个过程可能会产生图像伪影,这也就是所说的混叠。虽然图像混叠本身就是一个大问题,但后续处理可能会增强其负面影响,例如边缘检测。

一个更好的选择可能是使用高斯金字塔进行下采样(再次减小到原始大小的四分之一)。高斯金字塔由在图像重采样之前执行的一个模糊操作组成,这减少了任何混叠效应:

    downsampled_img = cv2.pyrDown(rgb_image)

然而,即使在这个尺度上,双边滤波器可能仍然运行得太慢,无法实时处理。另一个技巧是反复(比如,五次)对图像应用一个小双边滤波器,而不是一次性应用一个大双边滤波器:

    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)

cv2.bilateralFilter中的三个参数控制像素邻域的直径(d=9)、在颜色空间中的滤波器标准差(sigmaColor=9)和坐标空间中的标准差(sigmaSpace=7)。

因此,运行我们使用的双边滤波器的最终代码如下:

  1. 使用多个pyrDown调用对图像进行下采样:
    downsampled_img = rgb_image
    for _ in range(num_pyr_downs):
        downsampled_img = cv2.pyrDown(downsampled_img)
  1. 然后,应用多个双边滤波器:
    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)
  1. 最后,将其上采样到原始大小:
    filtered_normal_img = filterd_small_img
    for _ in range(num_pyr_downs):
        filtered_normal_img = cv2.pyrUp(filtered_normal_img)

结果看起来像一幅模糊的彩色画,画的是一个令人毛骨悚然的程序员,如下所示:

图片

下一个部分将向您展示如何检测和强调突出边缘。

检测和强调突出边缘

再次强调,当涉及到边缘检测时,挑战通常不在于底层算法的工作方式,而在于选择哪种特定的算法来完成手头的任务。您可能已经熟悉各种边缘检测器。例如,Canny 边缘检测cv2.Canny)提供了一种相对简单且有效的方法来检测图像中的边缘,但它容易受到噪声的影响。

Sobel 算子cv2.Sobel)可以减少这种伪影,但它不是旋转对称的。Scharr 算子cv2.Scharr)旨在纠正这一点,但它只查看第一图像导数。如果您感兴趣,还有更多算子供您选择,例如Laplacian 脊算子(它包括二阶导数),但它们要复杂得多。最后,对于我们的特定目的,它们可能看起来并不更好,也许是因为它们像任何其他算法一样容易受到光照条件的影响。

对于这个项目,我们将选择一个可能甚至与传统的边缘检测无关的函数——cv2.adaptiveThreshold。像cv2.threshold一样,这个函数使用一个阈值像素值将灰度图像转换为二值图像。也就是说,如果原始图像中的像素值高于阈值,则最终图像中的像素值将是 255。否则,它将是 0。

然而,自适应阈值的美妙之处在于它不会查看图像的整体属性。相反,它独立地检测每个小邻域中最显著的特征,而不考虑全局图像特征。这使得算法对光照条件极为鲁棒,这正是我们在寻求在物体和卡通中的人物周围绘制醒目的黑色轮廓时所希望的。

然而,这也使得算法容易受到噪声的影响。为了对抗这一点,我们将使用中值滤波器对图像进行预处理。中值滤波器做的是它名字所暗示的:它将每个像素值替换为一个小像素邻域中所有像素的中值。因此,为了检测边缘,我们遵循以下简短程序:

  1. 我们首先将 RGB 图像(rgb_image)转换为灰度(img_gray),然后使用七像素局部邻域应用中值模糊:
    # convert to grayscale and apply median blur
    img_gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    img_blur = cv2.medianBlur(img_gray, 7)
  1. 在减少噪声后,现在可以安全地使用自适应阈值检测和增强边缘。即使有一些图像噪声残留,cv2.ADAPTIVE_THRESH_MEAN_C算法使用blockSize=9将确保阈值应用于 9 x 9 邻域的均值减去C=2
    gray_edges = cv2.adaptiveThreshold(img_blur, 255,
                                       cv2.ADAPTIVE_THRESH_MEAN_C,
                                       cv2.THRESH_BINARY, 9, 2)

自适应阈值的结果看起来像这样:

图片

接下来,让我们看看如何在下一节中结合颜色和轮廓来制作卡通。

结合颜色和轮廓制作卡通

最后一步是将之前实现的两种效果结合起来。只需使用cv2.bitwise_and将两种效果融合成单个图像。完整的函数如下:

def cartoonize(rgb_image, *,
               num_pyr_downs=2, num_bilaterals=7):
    # STEP 1 -- Apply a bilateral filter to reduce the color palette of
    # the image.
    downsampled_img = rgb_image
    for _ in range(num_pyr_downs):
        downsampled_img = cv2.pyrDown(downsampled_img)

    for _ in range(num_bilaterals):
        filterd_small_img = cv2.bilateralFilter(downsampled_img, 9, 9, 7)

    filtered_normal_img = filterd_small_img
    for _ in range(num_pyr_downs):
        filtered_normal_img = cv2.pyrUp(filtered_normal_img)

    # make sure resulting image has the same dims as original
    if filtered_normal_img.shape != rgb_image.shape:
        filtered_normal_img = cv2.resize(
            filtered_normal_img, rgb_image.shape[:2])

    # STEP 2 -- Convert the original color image into grayscale.
    img_gray = cv2.cvtColor(rgb_image, cv2.COLOR_RGB2GRAY)
    # STEP 3 -- Apply amedian blur to reduce image noise.
    img_blur = cv2.medianBlur(img_gray, 7)

    # STEP 4 -- Use adaptive thresholding to detect and emphasize the edges
    # in an edge mask.
    gray_edges = cv2.adaptiveThreshold(img_blur, 255,
                                       cv2.ADAPTIVE_THRESH_MEAN_C,
                                       cv2.THRESH_BINARY, 9, 2)
    # STEP 5 -- Combine the color image from step 1 with the edge mask
    # from step 4.
    rgb_edges = cv2.cvtColor(gray_edges, cv2.COLOR_GRAY2RGB)
    return cv2.bitwise_and(filtered_normal_img, rgb_edges)

结果看起来像这样:

图片

在下一节中,我们将设置主脚本并设计一个 GUI 应用程序。

将所有内容整合在一起

在前面的章节中,我们实现了一些很好的过滤器,展示了我们如何使用 OpenCV 获得很好的效果。在本节中,我们想要构建一个交互式应用程序,允许您实时将这些过滤器应用到您的笔记本电脑摄像头。

因此,我们需要编写一个用户界面UI),它将允许我们捕获相机流并有一些按钮,以便您可以选择要应用哪个过滤器。我们将首先使用 OpenCV 设置相机捕获。然后,我们将使用wxPython构建一个漂亮的界面。

运行应用程序

要运行应用程序,我们将转向chapter1.py脚本。按照以下步骤操作:

  1. 我们首先开始导入所有必要的模块:
import wx
import cv2
import numpy as np
  1. 我们还必须导入一个通用的 GUI 布局(来自wx_gui)和所有设计的图像效果(来自tools):
from wx_gui import BaseLayout
from tools import apply_hue_filter
from tools import apply_rgb_filters
from tools import load_img_resized
from tools import spline_to_lookup_table
from tools import cartoonize
from tools import pencil_sketch_on_canvas
  1. OpenCV 提供了一个简单的方法来访问计算机的摄像头或相机设备。以下代码片段使用cv2.VideoCapture打开计算机的默认摄像头 ID(0):
def main(): 
    capture = cv2.VideoCapture(0) 
  1. 为了给我们的应用程序一个公平的机会在实时运行,我们将限制视频流的大小为640 x 480像素:
    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
  1. 然后,可以将capture流传递给我们的 GUI 应用程序,该应用程序是FilterLayout类的一个实例:
    # start graphical user interface
    app = wx.App()
    layout = FilterLayout(capture, title='Fun with Filters')
    layout.Center()
    layout.Show()
    app.MainLoop()

在创建FilterLayout之后,我们将居中布局,使其出现在屏幕中央。然后我们调用Show()来实际显示布局。最后,我们调用app.MainLoop(),这样应用程序就开始工作,接收和处理事件。

现在唯一要做的就是设计这个 GUI。

映射 GUI 基类

FilterLayout GUI 将基于一个通用的、简单的布局类,称为BaseLayout,我们将在后续章节中也能使用它。

BaseLayout类被设计为一个抽象基类。你可以将这个类视为一个蓝图或配方,它将适用于我们尚未设计的所有布局,即一个骨架类,它将作为我们所有未来 GUI 代码的骨干。

我们从导入我们将使用的包开始——用于创建 GUI 的wxPython模块、用于矩阵操作的numpy以及当然的 OpenCV:

import numpy as np
import wx
import cv2

该类设计为从蓝图或骨架派生,即wx.Frame类:

class BaseLayout(wx.Frame):

在以后,当我们编写自己的自定义布局(FilterLayout)时,我们将使用相同的记法来指定该类基于BaseLayout蓝图(或骨架)类,例如,在class FilterLayout(BaseLayout):。但到目前为止,让我们专注于BaseLayout类。

抽象类至少有一个抽象方法。我们将通过确保如果该方法未实现,应用程序将无法运行并抛出异常来使其方法抽象:

class BaseLayout(wx.Frame):
    ...
    ...
    ...
    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        """Process the frame of the camera (or other capture device)

        :param frame_rgb: Image to process in rgb format, of shape (H, W, 3)
        :return: Processed image in rgb format, of shape (H, W, 3)
        """
        raise NotImplementedError()

然后,任何从它派生的类,如FilterLayout,都必须指定该方法的完整实现。这将使我们能够创建自定义布局,正如你将在下一刻看到的那样。

但首先,让我们继续到 GUI 构造函数。

理解 GUI 构造函数

BaseLayout构造函数接受一个 ID(-1)、一个标题字符串('Fun with Filters')、一个视频捕获对象和一个可选参数,该参数指定每秒的帧数。在构造函数中,首先要做的事情是尝试从捕获对象中读取一个帧,以确定图像大小:

    def __init__(self,
                 capture: cv2.VideoCapture,
                 title: str = None,
                 parent=None,
                 window_id: int = -1,  # default value
                 fps: int = 10):
        self.capture = capture
        _, frame = self._acquire_frame()
        self.imgHeight, self.imgWidth = frame.shape[:2]

我们将使用图像大小来准备一个缓冲区,该缓冲区将存储每个视频帧作为位图,并设置 GUI 的大小。因为我们想在当前视频帧下方显示一串控制按钮,所以我们把 GUI 的高度设置为self.imgHeight + 20

        super().__init__(parent, window_id, title,
                         size=(self.imgWidth, self.imgHeight + 20))
        self.fps = fps
        self.bmp = wx.Bitmap.FromBuffer(self.imgWidth, self.imgHeight, frame)

在下一节中,我们将使用wxPython构建一个包含视频流和一些按钮的基本布局。

了解基本的 GUI 布局

最基本的布局仅由一个足够大的黑色面板组成,可以提供足够的空间来显示视频流:

        self.video_pnl = wx.Panel(self, size=(self.imgWidth, self.imgHeight))
        self.video_pnl.SetBackgroundColour(wx.BLACK)

为了使布局可扩展,我们将它添加到一个垂直排列的wx.BoxSizer对象中:

        # display the button layout beneath the video stream
        self.panels_vertical = wx.BoxSizer(wx.VERTICAL)
        self.panels_vertical.Add(self.video_pnl, 1, flag=wx.EXPAND | wx.TOP,
                                 border=1)

接下来,我们指定一个抽象方法augment_layout,我们将不会填写任何代码。相反,任何使用我们基类的用户都可以对基本布局进行自己的自定义修改:

        self.augment_layout()

然后,我们只需设置结果的布局的最小尺寸并将其居中:

        self.SetMinSize((self.imgWidth, self.imgHeight))
        self.SetSizer(self.panels_vertical)
        self.Centre()

下一节将向您展示如何处理视频流。

处理视频流

网络摄像头的视频流通过一系列步骤处理,这些步骤从__init__方法开始。这些步骤一开始可能看起来过于复杂,但它们是必要的,以便视频能够平滑运行,即使在更高的帧率下(也就是说,为了对抗闪烁)。

wxPython模块与事件和回调方法一起工作。当某个事件被触发时,它可以导致某个类方法被执行(换句话说,一个方法可以绑定到事件)。我们将利用这个机制,并使用以下步骤每隔一段时间显示一个新帧:

  1. 我们创建一个定时器,每当1000./self.fps毫秒过去时,它就会生成一个wx.EVT_TIMER事件:
        self.timer = wx.Timer(self)
        self.timer.Start(1000\. / self.fps)
  1. 每当定时器结束时,我们希望调用_on_next_frame方法。它将尝试获取一个新的视频帧:
        self.Bind(wx.EVT_TIMER, self._on_next_frame)
  1. _on_next_frame方法将处理新的视频帧并将处理后的帧存储在位图中。这将触发另一个事件,wx.EVT_PAINT。我们希望将此事件绑定到_on_paint方法,该方法将绘制新帧的显示。因此,我们为视频创建一个占位符并将wx.EVT_PAINT绑定到它:
        self.video_pnl.Bind(wx.EVT_PAINT, self._on_paint)

_on_next_frame方法获取一个新帧,完成后,将帧发送到另一个方法process_frame进行进一步处理(这是一个抽象方法,应由子类实现):

    def _on_next_frame(self, event):
        """
        Capture a new frame from the capture device,
        send an RGB version to `self.process_frame`, refresh.
        """
        success, frame = self._acquire_frame()
        if success:
            # process current frame
            frame = self.process_frame(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
            ...

处理后的帧(frame)随后被存储在位图缓冲区(self.bmp)中。调用Refresh会触发上述的wx.EVT_PAINT事件,该事件绑定到_on_paint

            ...
            # update buffer and paint (EVT_PAINT triggered by Refresh)
            self.bmp.CopyFromBuffer(frame)
            self.Refresh(eraseBackground=False)

paint方法随后从缓冲区获取帧并显示它:

    def _on_paint(self, event):
        """ Draw the camera frame stored in `self.bmp` onto `self.video_pnl`.
        """
        wx.BufferedPaintDC(self.video_pnl).DrawBitmap(self.bmp, 0, 0)

下一节将向您展示如何创建自定义过滤器布局。

设计自定义过滤器布局

现在我们几乎完成了!如果我们想使用BaseLayout类,我们需要为之前留空的两个方法提供代码,如下所示:

  • augment_layout:这是我们可以对 GUI 布局进行特定任务修改的地方。

  • process_frame:这是我们对摄像头捕获的每一帧进行特定任务处理的地方。

我们还需要更改构造函数以初始化我们将需要的任何参数——在这种情况下,铅笔素描的画布背景:

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        color_canvas = load_img_resized('pencilsketch_bg.jpg',
                                        (self.imgWidth, self.imgHeight))
        self.canvas = cv2.cvtColor(color_canvas, cv2.COLOR_RGB2GRAY)

要自定义布局,我们水平排列一系列单选按钮——每个图像效果模式一个按钮。在这里,style=wx.RB_GROUP选项确保一次只能选择一个单选按钮。并且为了使这些更改可见,pnl需要添加到现有面板列表self.panels_vertical中:

    def augment_layout(self):
        """ Add a row of radio buttons below the camera feed. """

        # create a horizontal layout with all filter modes as radio buttons
        pnl = wx.Panel(self, -1)
        self.mode_warm = wx.RadioButton(pnl, -1, 'Warming Filter', (10, 10),
                                        style=wx.RB_GROUP)
        self.mode_cool = wx.RadioButton(pnl, -1, 'Cooling Filter', (10, 10))
        self.mode_sketch = wx.RadioButton(pnl, -1, 'Pencil Sketch', (10, 10))
        self.mode_cartoon = wx.RadioButton(pnl, -1, 'Cartoon', (10, 10))
        hbox = wx.BoxSizer(wx.HORIZONTAL)
        hbox.Add(self.mode_warm, 1)
        hbox.Add(self.mode_cool, 1)
        hbox.Add(self.mode_sketch, 1)
        hbox.Add(self.mode_cartoon, 1)
        pnl.SetSizer(hbox)

        # add panel with radio buttons to existing panels in a vertical
        # arrangement
        self.panels_vertical.Add(pnl, flag=wx.EXPAND | wx.BOTTOM | wx.TOP,
                                 border=1

最后要指定的方法是 process_frame。回想一下,每当接收到新的相机帧时,该方法就会被触发。我们所需做的就是选择要应用的正确图像效果,这取决于单选按钮的配置。我们只需检查哪个按钮当前被选中,并调用相应的 render 方法:

    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        """Process the frame of the camera (or other capture device)

        Choose a filter effect based on the which of the radio buttons
        was clicked.

        :param frame_rgb: Image to process in rgb format, of shape (H, W, 3)
        :return: Processed image in rgb format, of shape (H, W, 3)
        """
        if self.mode_warm.GetValue():
            return self._render_warm(frame_rgb)
        elif self.mode_cool.GetValue():
            return self._render_cool(frame_rgb)
        elif self.mode_sketch.GetValue():
            return pencil_sketch_on_canvas(frame_rgb, canvas=self.canvas)
        elif self.mode_cartoon.GetValue():
            return cartoonize(frame_rgb)
        else:
            raise NotImplementedError()

完成了!以下截图展示了使用不同滤镜的输出图片:

上一张截图展示了我们将创建的四个滤镜应用于单个图像的效果。

摘要

在本章中,我们探索了许多有趣的图像处理效果。我们使用 dodge 和 burn 来创建黑白铅笔素描效果,通过查找表实现了曲线滤镜的高效实现,并发挥创意制作了卡通效果。

使用的一种技术是二维卷积,它将一个滤波器和一张图像结合,创建一个新的图像。在本章中,我们提供了获取所需结果的滤波器,但并不总是拥有产生所需结果所需的滤波器。最近,深度学习出现了,它试图学习不同滤波器的值,以帮助它获得所需的结果。

在下一章中,我们将稍微改变方向,探索使用深度传感器,如 Microsoft Kinect 3D,来实时识别手势。

属性

Lenna.png—Lenna 图片由 Conor Lawless 提供,可在 www.flickr.com/photos/15489034@N00/3388463896 找到,并遵循通用 CC 2.0 许可协议。

第二章:使用 Kinect 深度传感器进行手部手势识别

本章的目标是开发一个应用,该应用能够实时检测和跟踪简单的手部手势,使用深度传感器的输出,例如微软 Kinect 3D 传感器或华硕 Xtion 传感器。该应用将分析每个捕获的帧以执行以下任务:

  • 手部区域分割:将通过分析 Kinect 传感器的深度图输出,在每一帧中提取用户的 hand region,这是通过阈值化、应用一些形态学操作和找到连通****组件来完成的。

  • 手部形状分析:将通过确定轮廓凸包凸性缺陷来分析分割后的手部区域形状。

  • 手部手势识别:将通过手部轮廓的凸性缺陷来确定伸出的手指数量,并根据手势进行分类(没有伸出的手指对应拳头,五个伸出的手指对应张开的手)。

手势识别是计算机科学中一个经久不衰的话题。这是因为它不仅使人类能够与机器进行交流(人机交互 (HMI)),而且也是机器开始理解人体语言的第一步。有了像微软 Kinect 或华硕 Xtion 这样的低成本传感器以及像OpenKinectOpenNI这样的开源软件,自己开始这个领域从未如此简单。那么,我们该如何利用所有这些技术呢?

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

  • 规划应用

  • 设置应用

  • 实时跟踪手部手势

  • 理解手部区域分割

  • 执行手部形状分析

  • 执行手部手势识别

我们将在本章中实现的算法的美丽之处在于,它适用于许多手部手势,同时足够简单,可以在普通笔记本电脑上实时运行。此外,如果我们想,我们还可以轻松扩展它以包含更复杂的手部姿态估计。

完成应用后,您将了解如何在自己的应用中使用深度传感器。您将学习如何使用 OpenCV 从深度信息中组合感兴趣的区域形状,以及如何使用它们的几何属性来分析形状。

开始

本章要求您安装微软 Kinect 3D 传感器。或者,您也可以安装华硕 Xtion 传感器或任何 OpenCV 内置支持的深度传感器。

首先,从www.openkinect.org/wiki/Getting_Started安装 OpenKinect 和libfreenect。您可以在我们的 GitHub 仓库中找到本章中展示的代码:github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter2

首先,让我们规划本章将要创建的应用程序。

规划应用程序

最终的应用程序将包括以下模块和脚本:

  • gestures:这是一个包含手势识别算法的模块。

  • gestures.process:这是一个实现手势识别整个流程的函数。它接受一个单通道深度图像(从 Kinect 深度传感器获取)并返回一个带有估计的伸出手指数量的注释过的蓝色、绿色、红色BGR)彩色图像。

  • chapter2:这是本章的主要脚本。

  • chapter2.main:这是主函数流程,它遍历从使用.process手势处理的深度传感器获取的帧,并展示结果。

最终产品看起来像这样:

无论一只手伸出多少根手指,算法都能正确分割手区域(白色),绘制相应的凸包(围绕手的绿色线条),找到属于手指之间空间的所有凸性缺陷(大绿色点),同时忽略其他部分(小红色点),并推断出正确的伸出手指数量(右下角的数字),即使是对拳头也是如此。

现在,让我们在下一节中设置应用程序。

设置应用程序

在我们深入到手势识别算法的细节之前,我们需要确保我们可以访问深度传感器并显示深度帧流。在本节中,我们将介绍以下有助于我们设置应用程序的内容:

  • 访问 Kinect 3D 传感器

  • 利用与 OpenNI 兼容的传感器

  • 运行应用程序和主函数流程

首先,我们将看看如何使用 Kinect 3D 传感器。

访问 Kinect 3D 传感器

访问 Kinect 传感器的最简单方法是通过一个名为freenectOpenKinect模块。有关安装说明,请参阅上一节。

freenect模块具有sync_get_depth()sync_get_video()等函数,用于从深度传感器和摄像头传感器分别同步获取图像。对于本章,我们只需要 Kinect 深度图,它是一个单通道(灰度)图像,其中每个像素值是从摄像头到视觉场景中特定表面的估计距离。

在这里,我们将设计一个函数,该函数将从传感器读取一个帧并将其转换为所需的格式,并返回帧以及成功状态,如下所示:

def read_frame(): -> Tuple[bool,np.ndarray]:

函数包括以下步骤:

  1. 获取一个frame;如果没有获取到帧,则终止函数,如下所示:
    frame, timestamp = freenect.sync_get_depth() 
    if frame is None:
        return False, None

sync_get_depth方法返回深度图和时间戳。默认情况下,该图是 11 位格式。传感器的最后 10 位描述深度,而第一位表示当它等于 1 时,距离估计未成功。

  1. 将数据标准化为 8 位精度格式是一个好主意,因为 11 位格式不适合立即使用cv2.imshow可视化,以及将来。我们可能想使用返回不同格式的不同传感器,如下所示:
np.clip(depth, 0, 2**10-1, depth) 
depth >>= 2 

在前面的代码中,我们首先将值裁剪到 1,023(或2**10-1)以适应 10 位。这种裁剪导致未检测到的距离被分配到最远的可能点。接下来,我们将 2 位向右移动以适应 8 位。

  1. 最后,我们将图像转换为 8 位无符号整数数组并返回结果,如下所示:
return True, depth.astype(np.uint8) 

现在,深度图像可以按照以下方式可视化:

cv2.imshow("depth", read_frame()[1]) 

让我们在下一节中看看如何使用与 OpenNI 兼容的传感器。

利用与 OpenNI 兼容的传感器

要使用与 OpenNI 兼容的传感器,您必须首先确保OpenNI2已安装,并且您的 OpenCV 版本是在 OpenNI 支持下构建的。构建信息可以按照以下方式打印:

import cv2
print(cv2.getBuildInformation())

如果您的版本是带有 OpenNI 支持的构建,您将在Video I/O部分找到它。否则,您必须重新构建带有 OpenNI 支持的 OpenCV,这可以通过将-D WITH_OPENNI2=ON标志传递给cmake来完成。

安装过程完成后,您可以使用cv2.VideoCapture像访问其他视频输入设备一样访问传感器。在这个应用程序中,为了使用与 OpenNI 兼容的传感器而不是 Kinect 3D 传感器,您必须遵循以下步骤:

  1. 创建一个连接到您的与 OpenNI 兼容的传感器的视频捕获,如下所示:
device = cv2.cv.CV_CAP_OPENNI 
capture = cv2.VideoCapture(device) 

如果您想连接到 Asus Xtion,device变量应设置为cv2.CV_CAP_OPENNI_ASUS值。

  1. 将输入帧大小更改为标准视频图形阵列VGA)分辨率,如下所示:
capture.set(cv2.cv.CV_CAP_PROP_FRAME_WIDTH, 640) 
capture.set(cv2.cv.CV_CAP_PROP_FRAME_HEIGHT, 480) 
  1. 在前面的章节中,我们设计了read_frame函数,该函数使用freenect访问 Kinect 传感器。为了从视频捕获中读取深度图像,您必须将此函数更改为以下内容:
def read_frame():
    if not capture.grab():
        return False,None
    return capture.retrieve(cv2.CAP_OPENNI_DEPTH_MAP)

您会注意到我们使用了grabretrieve方法而不是read方法。原因是当我们需要同步一组相机或多头相机,例如 Kinect 时,cv2.VideoCaptureread方法是不合适的。

对于此类情况,您可以使用grab方法在某个时刻从多个传感器中捕获帧,然后使用retrieve方法检索感兴趣传感器的数据。例如,在您的应用程序中,您可能还需要检索一个 BGR 帧(标准相机帧),这可以通过将cv2.CAP_OPENNI_BGR_IMAGE传递给retrieve方法来实现。

因此,现在您可以从传感器读取数据,让我们在下一节中看看如何运行应用程序。

运行应用程序和主函数流程

chapter2.py脚本负责运行应用程序,它首先导入以下模块:

import cv2
import numpy as np
from gestures import recognize
from frame_reader import read_frame

recognize函数负责识别手势,我们将在本章后面编写它。我们还把上一节中编写的read_frame方法放在了一个单独的脚本中,以便于使用。

为了简化分割任务,我们将指导用户将手放在屏幕中央。为了提供视觉辅助,我们创建了以下函数:

def draw_helpers(img_draw: np.ndarray) -> None:
    # draw some helpers for correctly placing hand
    height, width = img_draw.shape[:2]
    color = (0,102,255)
    cv2.circle(img_draw, (width // 2, height // 2), 3, color, 2)
    cv2.rectangle(img_draw, (width // 3, height // 3),
                  (width * 2 // 3, height * 2 // 3), color, 2)

该函数在图像中心绘制一个矩形,并用橙色突出显示图像的中心像素。

所有繁重的工作都由main函数完成,如下面的代码块所示:

def main():
    for _, frame in iter(read_frame, (False, None)):

该函数遍历 Kinect 的灰度帧,并在每次迭代中执行以下步骤:

  1. 使用recognize函数识别手势,该函数返回估计的展开手指数量(num_fingers)和注释过的 BGR 颜色图像,如下所示:
num_fingers, img_draw = recognize(frame)
  1. 在注释过的 BGR 图像上调用draw_helpers函数,以提供手势放置的视觉辅助,如下所示:
 draw_helpers(img_draw)
  1. 最后,main函数在注释过的frame上绘制手指数量,使用cv2.imshow显示结果,并设置终止条件,如下所示:
        # print number of fingers on image
        cv2.putText(img_draw, str(num_fingers), (30, 30),
                    cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255))
        cv2.imshow("frame", img_draw)
        # Exit on escape
        if cv2.waitKey(10) == 27:
            break

因此,现在我们有了主脚本,你会注意到我们缺少的唯一函数就是recognize函数。为了跟踪手势,我们需要编写这个函数,我们将在下一节中完成。

实时跟踪手势

手势是通过recognize函数进行分析的;真正的魔法就在这里发生。这个函数处理整个流程,从原始的灰度图像到识别出的手势。它返回手指的数量和插图框架。它实现了以下步骤:

  1. 它通过分析深度图(img_gray)提取用户的手部区域,并返回一个手部区域掩码(segment),如下所示:
def recognize(img_gray: np.ndarray) -> Tuple[int,np.ndarray]:
    # segment arm region
    segment = segment_arm(img_gray) 
  1. 它对手部区域掩码(segment)执行contour分析。然后,它返回图像中找到的最大轮廓(contour)和任何凸性缺陷(defects),如下所示:
# find the hull of the segmented area, and based on that find the
# convexity defects
contour, defects = find_hull_defects(segment)
  1. 根据找到的轮廓和凸性缺陷,它检测图像中的展开手指数量(num_fingers)。然后,它使用segment图像作为模板创建一个插图图像(img_draw),并用contourdefect点进行注释,如下所示:
img_draw = cv2.cvtColor(segment, cv2.COLOR_GRAY2RGB)
num_fingers, img_draw = detect_num_fingers(contour,
                                             defects, img_draw)
  1. 最后,返回估计的展开手指数量(num_fingers)以及注释过的输出图像(img_draw),如下所示:
return num_fingers, img_draw

在下一节中,让我们学习如何完成手部区域分割,这是我们程序开始时使用的。

理解手部区域分割

手臂的自动检测——以及后来的手部区域——可以设计得任意复杂,可能通过结合手臂或手部形状和颜色的信息。然而,使用肤色作为确定特征在视觉场景中寻找手可能会在光线条件差或用户戴着手套时失败得很惨。相反,我们选择通过深度图中的形状来识别用户的手。

允许各种手在任何图像区域内存在会无谓地复杂化本章的任务,因此我们做出两个简化的假设:

  • 我们将指导我们的应用程序用户将他们的手放在屏幕中心前方,使手掌大致平行于 Kinect 传感器的方向,这样更容易识别手的相应深度层。

  • 我们还将指导用户坐在 Kinect 大约 1 到 2 米远的地方,并将手臂稍微向前伸展,使手最终处于比手臂稍深的深度层。然而,即使整个手臂可见,算法仍然可以工作。

这样,仅基于深度层对图像进行分割将会相对简单。否则,我们可能需要首先提出一个手部检测算法,这将无谓地复杂化我们的任务。如果你愿意冒险,你可以自己尝试这样做。

让我们看看如何在下一节中找到图像中心区域的最高深度。

找到图像中心区域的最高深度

一旦手大致放置在屏幕中心,我们就可以开始寻找所有位于与手相同深度平面的图像像素。这是通过以下步骤完成的:

  1. 首先,我们只需确定图像中心区域的最高深度值。最简单的方法是只查看中心像素的深度值,如下所示:
width, height = depth.shape 
center_pixel_depth = depth[width/2, height/2] 
  1. 然后,创建一个掩码,其中所有深度为center_pixel_depth的像素都是白色,其他所有像素都是黑色,如下所示:
import numpy as np 

depth_mask = np.where(depth == center_pixel_depth, 255, 
     0).astype(np.uint8)

然而,这种方法可能不会非常稳健,因为有可能以下因素会使其受损:

  • 你的手不会完美地平行放置在 Kinect 传感器上。

  • 你的手不会完全平坦。

  • Kinect 传感器的值将会是嘈杂的。

因此,你的手的不同区域将具有略微不同的深度值。

segment_arm方法采取了一种稍微更好的方法——它查看图像中心的较小邻域并确定中值深度值。这是通过以下步骤完成的:

  1. 首先,我们找到图像帧的中心区域(例如,21 x 21 像素),如下所示:
def segment_arm(frame: np.ndarray, abs_depth_dev: int = 14) -> np.ndarray:
    height, width = frame.shape
    # find center (21x21 pixels) region of imageheight frame
    center_half = 10 # half-width of 21 is 21/2-1
    center = frame[height // 2 - center_half:height // 2 + center_half,
                   width // 2 - center_half:width // 2 + center_half]
  1. 然后,我们确定中值深度值med_val如下:
med_val = np.median(center) 

现在,我们可以将med_val与图像中所有像素的深度值进行比较,并创建一个掩码,其中所有深度值在特定范围[med_val-abs_depth_dev, med_val+abs_depth_dev]内的像素都是白色,而其他所有像素都是黑色。

然而,稍后将会变得清楚的原因是,让我们将像素点涂成灰色而不是白色,如下所示:

frame = np.where(abs(frame - med_val) <= abs_depth_dev,
                 128, 0).astype(np.uint8)
  1. 结果看起来会是这样:

图片

你会注意到分割掩码并不平滑。特别是,它包含深度传感器未能做出预测的点处的孔洞。让我们学习如何在下一节中应用形态学闭运算来平滑分割掩码。

应用形态学闭运算以平滑

分割中常见的一个问题是,硬阈值通常会导致分割区域出现小的缺陷(即孔洞,如前一幅图像所示)。这些孔洞可以通过使用形态学开运算和闭运算来缓解。当它被打开时,它会从前景中移除小物体(假设物体在暗前景上较亮),而闭运算则移除小孔(暗区域)。

这意味着我们可以通过应用形态学闭运算(先膨胀后腐蚀)使用一个小的3 x 3-像素核来去除我们掩码中的小黑色区域,如下所示:

kernel = np.ones((3, 3), np.uint8)
frame = cv2.morphologyEx(frame, cv2.MORPH_CLOSE, kernel)

结果看起来要平滑得多,如下所示:

图片

注意,然而,掩码仍然包含不属于手部或手臂的区域,例如左侧看起来像是膝盖之一和右侧的一些家具。这些物体恰好位于我的手臂和手部的同一深度层。如果可能的话,我们现在可以将深度信息与另一个描述符结合,比如一个基于纹理或骨骼的手部分类器,这将剔除所有非皮肤区域。

一个更简单的方法是意识到大多数情况下,手部并不与膝盖或家具相连。让我们学习如何在分割掩码中找到连通组件。

在分割掩码中寻找连通组件

我们已经知道中心区域属于手部。对于这种情况,我们可以简单地应用cv2.floodfill来找到所有连通的图像区域。

在我们这样做之前,我们想要绝对确定洪水填充的种子点属于正确的掩码区域。这可以通过将灰度值128分配给种子点来实现。然而,我们还想确保中心像素不会意外地位于形态学操作未能封闭的空腔内。

因此,让我们设置一个小的 7 x 7 像素区域,其灰度值为128,如下所示:

small_kernel = 3
frame[height // 2 - small_kernel:height // 2 + small_kernel,
      width // 2 - small_kernel:width // 2 + small_kernel] = 128

由于洪填充(以及形态学操作)可能很危险,OpenCV 要求指定一个掩模,以避免整个图像的洪流。这个掩模必须比原始图像宽和高 2 个像素,并且必须与cv2.FLOODFILL_MASK_ONLY标志一起使用。

将洪填充限制在图像的较小区域或特定轮廓中可能非常有帮助,这样我们就不需要连接两个本来就不应该连接的相邻区域。安全总是比后悔好,对吧?

然而,今天,我们感到勇气十足! 让我们将掩模完全涂成黑色,如下所示:

mask = np.zeros((height + 2, width + 2), np.uint8)

然后,我们可以将洪填充应用于中心像素(种子点),并将所有连接区域涂成白色,如下所示:

flood = frame.copy()
cv2.floodFill(flood, mask, (width // 2, height // 2), 255,
              flags=4 | (255 << 8))

到这一点,应该很清楚为什么我们决定先使用一个灰度掩模。我们现在有一个包含白色区域(手臂和手)、灰色区域(既不是手臂也不是手,但同一深度平面中的其他事物)和黑色区域(所有其他事物)的掩模。有了这个设置,很容易应用一个简单的二值阈值来突出显示预分割深度平面中的相关区域,如下所示:

ret, flooded = cv2.threshold(flood, 129, 255, cv2.THRESH_BINARY) 

这就是生成的掩模看起来像:

图片

生成的分割掩模现在可以返回到recognize函数,在那里它将被用作find_hull_defects函数的输入,以及绘制最终输出图像(img_draw)的画布。该函数分析手的形状,以检测对应于手的壳体的缺陷。让我们在下一节学习如何执行手形状分析。

执行手形状分析

现在我们知道(大致)手的位置在哪里,我们旨在了解其形状。在这个应用程序中,我们将根据对应于手的轮廓的凸性缺陷来决定显示的确切手势。

让我们继续学习如何在下一节中确定分割的手区域轮廓,这将是手形状分析的第一步。

确定分割的手区域轮廓

第一步涉及确定分割的手区域轮廓。幸运的是,OpenCV 附带了一个这样的算法的预配置版本——cv2.findContours。此函数作用于二值图像,并返回一组被认为是轮廓部分的点。由于图像中可能存在多个轮廓,因此可以检索整个轮廓层次结构,如下所示:

def find_hull_defects(segment: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
    contours, hierarchy = cv2.findContours(segment, cv2.RETR_TREE,
                                           cv2.CHAIN_APPROX_SIMPLE)

此外,因为我们不知道我们正在寻找哪个轮廓,所以我们不得不做出一个假设来清理轮廓结果,因为即使在形态学闭合之后,也可能会有一些小腔体残留。然而,我们相当确信我们的掩模只包含感兴趣区域的分割区域。我们将假设找到的最大轮廓就是我们正在寻找的。

因此,我们只需遍历轮廓列表,计算轮廓面积(cv2.contourArea),并只存储最大的一个(max_contour),如下所示:

max_contour = max(contours, key=cv2.contourArea) 

我们找到的轮廓可能仍然有太多的角。我们用一个类似的轮廓来近似contour,这个轮廓的边长不超过轮廓周长的 1%,如下所示:

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

让我们在下一节学习如何找到轮廓区域的凸包。

找到轮廓区域的凸包

一旦我们在掩码中识别出最大的轮廓,计算轮廓区域的凸包就很简单了。凸包基本上是轮廓区域的包络。如果你把属于轮廓区域的像素想象成从板上戳出的钉子,那么一个紧绷的橡皮筋围绕着所有钉子形成凸包形状。我们可以直接从最大的轮廓(max_contour)中得到凸包,如下所示:

hull = cv2.convexHull(max_contour, returnPoints=False) 

由于我们现在想查看这个凸包中的凸性缺陷,OpenCV 文档指导我们将returnPoints可选标志设置为False

围绕分割的手部区域绘制的黄色凸包看起来是这样的:

图片

如前所述,我们将根据凸性缺陷确定手势。让我们继续学习如何在下一节中找到凸包的凸性缺陷,这将使我们更接近于识别手势。

找到凸包的凸性缺陷

如前一个截图所示,凸包上的所有点并不都属于分割的手部区域。实际上,所有的手指和手腕都造成了严重的凸性缺陷——即远离凸包的轮廓点。

我们可以通过查看最大的轮廓(max_contour)和相应的凸包(hull)来找到这些缺陷,如下所示:

defects = cv2.convexityDefects(max_contour, hull) 

这个函数(defects)的输出是一个包含所有缺陷的 NumPy 数组。每个缺陷是一个包含四个整数的数组,分别是start_index(缺陷开始的轮廓中点的索引)、end_index(缺陷结束的轮廓中点的索引)、farthest_pt_index(缺陷内离凸包最远的点的索引)和fixpt_depth(最远点与凸包之间的距离)。

我们将在尝试估计展开手指的数量时使用这个信息。

然而,到目前为止,我们的工作已经完成。提取的轮廓(max_contour)和凸性缺陷(defects)可以返回到recognize,在那里它们将被用作detect_num_fingers的输入,如下所示:

return max_contour, defects 

因此,现在我们已经找到了缺陷,让我们继续学习如何使用凸性缺陷进行手势识别,这将使我们向完成应用程序迈进。

执行手势识别

需要完成的工作是根据伸出手指的数量对手势进行分类。例如,如果我们发现五个伸出的手指,我们假设手是张开的,而没有伸出的手指则意味着拳头。我们试图做的只是从零数到五,并让应用识别相应的手指数量。

实际上,这比最初看起来要复杂。例如,在欧洲,人们可能会通过伸出大拇指食指中指来数到三。如果你在美国这么做,那里的人可能会非常困惑,因为他们通常不会在表示数字二时使用大拇指。

这可能会导致挫败感,尤其是在餐厅里(相信我)。如果我们能找到一种方法来泛化这两种情况——也许是通过适当地计数伸出的手指数量,我们就会有一个算法,不仅能够教会机器简单的手势识别,也许还能教会一个智力一般的人。

如你所猜想的,答案与凸性缺陷有关。如前所述,伸出的手指会在凸包上造成缺陷。然而,反之不成立;也就是说,并非所有凸性缺陷都是由手指引起的!还可能有由手腕、整个手或手臂的总体方向引起的额外缺陷。我们如何区分这些不同的缺陷原因呢

在下一节中,我们将区分凸性缺陷的不同情况。

区分不同原因的凸性缺陷

诀窍是观察缺陷内最远的凸包点(farthest_pt_index)与缺陷的起点和终点(分别对应start_indexend_index)之间的角度,如下面的屏幕截图所示:

图片

在之前的屏幕截图中,橙色标记作为视觉辅助工具,将手放在屏幕中间,凸包用绿色勾勒出来。每个红色点对应于每个凸性缺陷检测到的最远的凸包点farthest_pt_index)。如果我们比较属于两个伸出的手指的典型角度(例如θj)和由一般手部几何形状引起的角度(例如θi),我们会发现前者远小于后者。

这显然是因为人类只能稍微张开手指,从而在远端缺陷点和相邻指尖之间形成一个很窄的角度。因此,我们可以遍历所有凸性缺陷,并计算这些点之间的角度。为此,我们需要一个实用函数来计算两个任意向量(如v1v2)之间的角度(以弧度为单位),如下所示:

def angle_rad(v1, v2): 
    return np.arctan2(np.linalg.norm(np.cross(v1, v2)), 
         np.dot(v1, v2))

此方法使用叉积来计算角度,而不是以标准方式计算。计算两个向量v1v2之间角度的标准方式是通过计算它们的点积并将其除以v1v2norm。然而,这种方法有两个不完美之处:

  • 如果v1normv2norm为零,你必须手动避免除以零。

  • 该方法对于小角度返回相对不准确的结果。

类似地,我们提供了一个简单的函数来将角度从度转换为弧度,如下所示:

def deg2rad(angle_deg): 
    return angle_deg/180.0*np.pi 

在下一节中,我们将看到如何根据伸出的手指数量对手势进行分类。

基于伸出的手指数量对手势进行分类

剩下的工作是根据伸出的手指实例数量对手势进行分类。分类是通过以下函数完成的:

def detect_num_fingers(contour: np.ndarray, defects: np.ndarray,
                       img_draw: np.ndarray, thresh_deg: float = 80.0) -> Tuple[int, np.ndarray]:

该函数接受检测到的轮廓(contour)、凸性缺陷(defects)、用于绘制的画布(img_draw)以及用作分类凸性缺陷是否由伸出的手指引起的阈值角度(thresh_deg)。

除了大拇指和食指之间的角度外,很难得到接近 90 度的值,所以任何接近这个数字的值都应该可以工作。我们不希望截止角度过高,因为这可能会导致分类错误。完整的函数将返回手指数量和示意图,并包括以下步骤:

  1. 首先,让我们关注特殊情况。如果我们没有找到任何凸性defects,这意味着我们在凸包计算过程中可能犯了一个错误,或者帧中根本没有任何伸出的手指,因此我们返回0作为检测到的手指数量,如下所示:
if defects is None: 
    return [0, img_draw] 
  1. 然而,我们可以将这个想法进一步发展。由于手臂通常比手或拳头细,我们可以假设手部几何形状总是会产生至少两个凸性缺陷(通常属于手腕)。因此,如果没有额外的缺陷,这意味着没有伸出的手指:
if len(defects) <= 2: 
    return [0, img_draw] 
  1. 现在我们已经排除了所有特殊情况,我们可以开始计数真实的手指。如果有足够多的缺陷,我们将在每对手指之间找到一个缺陷。因此,为了得到正确的手指数量(num_fingers),我们应该从1开始计数,如下所示:
num_fingers = 1 
  1. 然后,我们开始遍历所有凸性缺陷。对于每个缺陷,我们提取三个点并绘制其边界以进行可视化,如下所示:
# Defects are of shape (num_defects,1,4)
for defect in defects[:, 0, :]:
    # Each defect is an array of four integers.
    # First three indexes of start, end and the furthest
    # points respectively
    start, end, far = [contour[i][0] for i in defect[:3]]
    # draw the hull
    cv2.line(img_draw, tuple(start), tuple(end), (0, 255, 0), 2)
  1. 然后,我们计算从farstart和从farend的两条边的夹角。如果角度小于thresh_deg度,这意味着我们正在处理一个最可能是由于两个展开的手指引起的缺陷。在这种情况下,我们想要增加检测到的手指数量(num_fingers)并用绿色绘制该点。否则,我们用红色绘制该点,如下所示:
# if angle is below a threshold, defect point belongs to two
# extended fingers
if angle_rad(start - far, end - far) < deg2rad(thresh_deg):
    # increment number of fingers
    num_fingers += 1

    # draw point as green
    cv2.circle(img_draw, tuple(far), 5, (0, 255, 0), -1)
else:
    # draw point as red
    cv2.circle(img_draw, tuple(far), 5, (0, 0, 255), -1)
  1. 在迭代完所有凸性缺陷后,我们返回检测到的手指数量和组装的输出图像,如下所示:
return min(5, num_fingers), img_draw

计算最小值将确保我们不超过每只手常见的手指数量。

结果可以在以下屏幕截图中看到:

有趣的是,我们的应用程序能够检测到各种手部配置中展开手指的正确数量。展开手指之间的缺陷点很容易被算法分类,而其他点则被成功忽略。

摘要

本章展示了一种相对简单——然而出人意料地鲁棒——的方法,通过计数展开的手指数量来识别各种手部手势。

该算法首先展示了如何使用从微软 Kinect 3D 传感器获取的深度信息来分割图像中的任务相关区域,以及如何使用形态学操作来清理分割结果。通过分析分割的手部区域形状,算法提出了一种根据图像中发现的凸性效应类型来分类手部手势的方法。

再次强调,掌握我们使用 OpenCV 执行所需任务的能力并不需要我们编写大量代码。相反,我们面临的是获得一个重要洞察力,这使我们能够有效地使用 OpenCV 的内置功能。

手势识别是计算机科学中一个流行但具有挑战性的领域,其应用范围广泛,包括人机交互HCI)、视频监控,甚至视频游戏行业。你现在可以利用自己对分割和结构分析的高级理解来构建自己的最先进手势识别系统。另一种你可能想要用于手部手势识别的方法是在手部手势上训练一个深度图像分类网络。我们将在第九章中讨论用于图像分类的深度网络,学习分类和定位对象

在下一章中,我们将继续关注在视觉场景中检测感兴趣的对象,但我们将假设一个更为复杂的情况:从任意视角和距离观察对象。为此,我们将结合透视变换和尺度不变特征描述符来开发一个鲁棒的特征匹配算法。

第三章:通过特征匹配和透视变换查找对象

在上一章中,你学习了如何在非常受控的环境中检测和跟踪一个简单对象(手的轮廓)。具体来说,我们指导我们的应用程序用户将手放置在屏幕中央区域,然后对对象(手)的大小和形状做出了假设。在本章中,我们希望检测和跟踪任意大小的对象,这些对象可能从几个不同的角度或部分遮挡下被观察。

为了做到这一点,我们将使用特征描述符,这是一种捕捉我们感兴趣对象重要属性的方法。我们这样做是为了即使对象嵌入在繁忙的视觉场景中,也能定位到该对象。我们将把我们的算法应用于网络摄像头的实时流,并尽力使算法既鲁棒又足够简单,以便实时运行。

本章将涵盖以下主题:

  • 列出应用程序执行的任务

  • 规划应用程序

  • 设置应用程序

  • 理解流程过程

  • 学习特征提取

  • 观察特征检测

  • 理解特征描述符

  • 理解特征匹配

  • 学习特征跟踪

  • 观察算法的实际应用

本章的目标是开发一个应用程序,可以在网络摄像头的视频流中检测和跟踪感兴趣的对象——即使对象从不同的角度、距离或部分遮挡下观察。这样的对象可以是书的封面图像、一幅画或任何具有复杂表面结构的其他东西。

一旦提供了模板图像,应用程序将能够检测该对象,估计其边界,然后在视频流中跟踪它。

开始学习

本章已在OpenCV 4.1.1上进行了测试。

注意,你可能需要从github.com/Itseez/opencv_contrib获取所谓的额外模块。

我们通过设置OPENCV_ENABLE_NONFREEOPENCV_EXTRA_MODULES_PATH变量来安装 OpenCV,以获取加速鲁棒特征SURF)和快速近似最近邻库FLANN)。你还可以使用存储库中可用的 Docker 文件,这些文件包含所有必需的安装。

此外,请注意,你可能需要获得许可证才能在商业应用程序中使用SURF

本章的代码可以在 GitHub 书籍存储库中找到,该存储库位于github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter3

列出应用程序执行的任务

应用程序将分析每个捕获的帧以执行以下任务:

  • 特征提取:我们将使用加速鲁棒特征SURF)来描述感兴趣的物体,这是一种用于在图像中找到既具有尺度不变性又具有旋转不变性的显著关键点的算法。这些关键点将帮助我们确保在多个帧中跟踪正确的物体,因为物体的外观可能会随时间而变化。找到不依赖于物体观看距离或观看角度的关键点非常重要(因此,具有尺度和旋转不变性)。

  • 特征匹配:我们将尝试使用快速近似最近邻库FLANN)来建立关键点之间的对应关系,以查看帧中是否包含与我们的感兴趣物体中的关键点相似的关键点。如果我们找到一个好的匹配,我们将在每一帧上标记该物体。

  • 特征跟踪:我们将使用各种形式的早期****异常检测异常拒绝来跟踪从帧到帧的定位感兴趣物体,以加快算法的速度。

  • 透视变换:我们将通过扭曲透视来反转物体所经历的任何平移和旋转,使得物体在屏幕中心看起来是垂直的。这会产生一种酷炫的效果,物体似乎被冻结在某个位置,而整个周围场景则围绕它旋转。

以下截图显示了前三个步骤的示例,即特征提取、匹配和跟踪:

图片

截图中包含左边的感兴趣物体的模板图像和右边的手持打印模板图像。两个帧中的匹配特征用蓝色线条连接,右边的定位物体用绿色轮廓标出。

最后一步是将定位的物体转换,使其投影到正面平面上,如图中所示:

图片

图像看起来大致与原始模板图像相似,呈现近距离视图,而整个场景似乎围绕它扭曲。

让我们首先规划本章将要创建的应用程序。

规划应用

最终的应用程序将包括一个用于检测、匹配和跟踪图像特征的 Python 类,以及一个访问摄像头并显示每个处理帧的脚本。

该项目将包含以下模块和脚本:

  • feature_matching:此模块包含特征提取、特征匹配和特征跟踪的算法。我们将此算法从应用程序的其余部分分离出来,以便它可以作为一个独立的模块使用。

  • feature_matching.FeatureMatching:这个类实现了整个特征匹配流程。它接受一个蓝、绿、红(BGR)相机帧,并尝试在其中定位感兴趣的物体。

  • chapter3:这是该章节的主要脚本。

  • chapter3.main:这是启动应用程序、访问相机、将每一帧发送到FeatureMatching类的实例进行处理的主体函数,以及显示结果的主要函数。

在深入到特征匹配算法的细节之前,让我们设置应用。

设置应用

在我们深入到特征匹配算法的细节之前,我们需要确保我们可以访问网络摄像头并显示视频流。

让我们在下一节中学习如何运行应用程序。

运行应用——main()函数主体

要运行我们的应用,我们需要执行main()函数主体。以下步骤显示了main()函数的执行:

  1. 函数首先使用VideoCapture方法通过传递0作为参数来访问网络摄像头,这是一个默认网络摄像头的引用。如果无法访问网络摄像头,应用将被终止:
import cv2 as cv
from feature_matching import FeatureMatching

def main():
    capture = cv.VideoCapture(0)
    assert capture.isOpened(), "Cannot connect to camera"
  1. 然后,设置视频流的帧大小和每秒帧数。以下代码片段显示了设置视频帧大小和帧率的代码:
 capture.set(cv.CAP_PROP_FPS, 10)
 capture.set(cv.CAP_PROP_FRAME_WIDTH, 640)
 capture.set(cv.CAP_PROP_FRAME_HEIGHT, 480)
  1. 接下来,使用指向模板(或训练)文件的路径初始化FeatureMatching类的实例,该文件描述了感兴趣的对象。以下代码显示了FeatureMatching类:
matching = FeatureMatching(train_image='train.png')
  1. 之后,为了处理来自相机的帧,我们创建了一个从capture.read函数的迭代器,该迭代器将在无法返回帧时终止(返回(False,None))。这可以在以下代码块中看到:
    for success, frame in iter(capture.read, (False, None)):
        cv.imshow("frame", frame)
        match_succsess, img_warped, img_flann = matching.match(frame)

在前面的代码块中,FeatureMatching.match方法处理BGR图像(capture.read返回的frame为 BGR 格式)。如果当前帧中检测到对象,match方法将报告match_success=True并返回扭曲的图像以及说明匹配的图像——img_flann

让我们继续,并显示我们的匹配方法将返回的结果。

显示结果

事实上,我们只能在match方法返回结果的情况下显示结果,对吧?这可以在下面的代码块中看到:

        if match_succsess:
            cv.imshow("res", img_warped)
            cv.imshow("flann", img_flann)
        if cv.waitKey(1) & 0xff == 27:
            break

在 OpenCV 中显示图像是直接的,通过imshow方法完成,该方法接受窗口名称和图像。此外,设置了基于Esc按键的循环终止条件。

现在我们已经设置了我们的应用,让我们看看下一节中的流程图。

理解流程

特征由FeatureMatching类提取、匹配和跟踪——特别是通过公共的match方法。然而,在我们开始分析传入的视频流之前,我们还有一些作业要做。这些事情的含义可能一开始并不清楚(特别是对于 SURF 和 FLANN),但我们将详细讨论以下章节中的这些步骤。

目前,我们只需要关注初始化:

class FeatureMatching: 
     def __init__(self, train_image: str = "train.png") -> None:

以下步骤涵盖了初始化过程:

  1. 以下行设置了一个 SURF 检测器,我们将使用它来检测和从图像中提取特征(有关更多详细信息,请参阅学习特征提取部分),Hessian 阈值为 300 到 500,即400
self.f_extractor = cv.xfeatures2d_SURF.create(hessianThreshold=400)
  1. 我们加载我们感兴趣的对象的模板(self.img_obj),或者在找不到时打印错误信息:
self.img_obj = cv.imread(train_image, cv.CV_8UC1)
assert self.img_obj is not None, f"Could not find train image {train_image}"
  1. 此外,我们存储图像的形状(self.sh_train)以方便使用:
self.sh_train = self.img_obj.shape[:2]

我们将模板图像称为训练图像,因为我们的算法将被训练以找到此图像,而每个输入帧称为查询图像,因为我们将使用这些图像来查询训练图像。以下照片是训练图像:

图像版权——Lenna.png 由 Conor Lawless 提供,许可协议为 CC BY 2.0

之前的训练图像大小为 512 x 512 像素,将用于训练算法。

  1. 接下来,我们将 SURF 应用于感兴趣的对象。这可以通过一个方便的函数调用完成,该调用返回关键点和描述符(你可以参阅学习特征提取部分以获得进一步解释):
self.key_train, self.desc_train = \
    self.f_extractor.detectAndCompute(self.img_obj, None)

我们将对每个输入帧做同样的事情,然后比较图像之间的特征列表。

  1. 现在,我们设置一个 FLANN 对象,该对象将用于匹配训练图像和查询图像的特征(有关更多详细信息,请参阅理解特征匹配部分)。这需要通过字典指定一些额外的参数,例如使用哪种算法以及并行运行多少棵树:
index_params = {"algorithm": 0, "trees": 5}
search_params = {"checks": 50}
self.flann = cv.FlannBasedMatcher(index_params, search_params)
  1. 最后,初始化一些额外的记账变量。当我们想要使我们的特征跟踪既快又准确时,这些变量将很有用。例如,我们将跟踪最新的计算出的单应性矩阵和未定位我们感兴趣的对象的帧数(有关更多详细信息,请参阅学习特征跟踪部分):
self.last_hinv = np.zeros((3, 3))
self.max_error_hinv = 50.
self.num_frames_no_success = 0
self.max_frames_no_success = 5

然后,大部分工作由FeatureMatching.match方法完成。该方法遵循以下程序:

  1. 它从每个输入视频帧中提取有趣的图像特征。

  2. 它在模板图像和视频帧之间匹配特征。这是在FeatureMatching.match_features中完成的。如果没有找到此类匹配,它将跳到下一帧。

  3. 它在视频帧中找到模板图像的角点。这是在detect_corner_points函数中完成的。如果任何角点(显著)位于帧外,它将跳到下一帧。

  4. 它计算四个角点所围成的四边形的面积。如果面积要么太小要么太大,它将跳到下一帧。

  5. 它在当前帧中勾勒出模板图像的角点。

  6. 它找到必要的透视变换,将定位的对象从当前帧带到frontoparallel平面。如果结果与最近对早期帧的结果显著不同,它将跳到下一帧。

  7. 它将当前帧的视角进行扭曲,使得感兴趣的对象居中且垂直。

在接下来的几节中,我们将详细讨论之前的步骤。

让我们先看看下一节中的特征提取步骤。这一步是算法的核心。它将在图像中找到信息丰富的区域,并以较低维度表示它们,这样我们就可以在之后使用这些表示来决定两张图像是否包含相似的特征。

学习特征提取

一般而言,在机器学习中,特征提取是一个数据降维的过程,它导致对数据元素的信息丰富描述。

在计算机视觉中,特征通常是指图像的有趣区域。它是关于图像所代表内容的非常信息丰富的可测量属性。通常,单个像素的灰度值(即原始数据)并不能告诉我们太多关于整个图像的信息。相反,我们需要推导出一个更具信息量的属性。

例如,知道图像中有看起来像眼睛、鼻子和嘴巴的区域,这将使我们能够推断出图像代表脸的可能性有多大。在这种情况下,描述数据所需资源的数量会大幅减少。数据指的是,例如,我们是否看到的是一张脸的图像。图像是否包含两只眼睛、一个鼻子或一个嘴巴?

更低层的特征,例如边缘、角点、块或脊的存在,通常更具信息量。某些特征可能比其他特征更好,这取决于应用。

一旦我们决定我们最喜欢的特征是什么,我们首先需要想出一个方法来检查图像是否包含这样的特征。此外,我们还需要找出它们在哪里,然后创建特征的描述符。让我们在下一节学习如何检测特征。

看看特征检测

在计算机视觉中,寻找图像中感兴趣区域的过程被称为特征检测。在底层,对于图像的每一个点,特征检测算法会决定该图像点是否包含感兴趣的特征。OpenCV 提供了一系列的特征检测(及描述)算法。

在 OpenCV 中,算法的细节被封装起来,并且它们都有相似的 API。以下是一些算法:

  • Harris 角点检测:我们知道边缘是所有方向上强度变化都很大的区域。Harris 和 Stephens 提出了这个算法,这是一种快速找到这种区域的方法。这个算法在 OpenCV 中实现为cv2.cornerHarris

  • Shi-Tomasi 角点检测:Shi 和 Tomasi 开发了一种角点检测算法,这个算法通常比 Harris 角点检测更好,因为它找到了N个最强的角点。这个算法在 OpenCV 中实现为cv2.goodFeaturesToTrack

  • 尺度不变特征变换 (SIFT):当图像的尺度发生变化时,角点检测是不够的。为此,David Lowe 开发了一种方法来描述图像中的关键点,这些关键点与方向和大小无关(因此得名 尺度不变)。该算法在 OpenCV2 中实现为 cv2.xfeatures2d_SIFT,但由于其代码是专有的,它已经被移动到 OpenCV3 的 extra 模块中。

  • SURF: SIFT 已经被证明是非常好的,但它的速度对于大多数应用来说还不够快。这就是 SURF 发挥作用的地方,它用箱式滤波器替换了 SIFT 中昂贵的高斯拉普拉斯(函数)。该算法在 OpenCV2 中实现为 cv2.xfeatures2d_SURF,但,就像 SIFT 一样,由于其代码是专有的,它已经被移动到 OpenCV3 的 extra 模块中。

OpenCV 支持更多特征描述符,例如 加速段测试特征 (FAST)、二进制 鲁棒独立基本特征 (BRIEF) 和 方向性 FAST 和旋转 BRIEF (ORB),后者是 SIFT 或 SURF 的开源替代品。

在下一节中,我们将学习如何使用 SURF 在图像中检测特征。

使用 SURF 在图像中检测特征

在本章的剩余部分,我们将使用 SURF 检测器。SURF 算法可以大致分为两个不同的步骤,即检测兴趣点和制定描述符。

SURF 依赖于 Hessian 角点检测器进行兴趣点检测,这需要设置一个最小的 minhessianThreshold。此阈值决定了 Hessian 滤波器的输出必须有多大,才能将一个点用作兴趣点。

当值较大时,获取的兴趣点较少,但理论上它们更明显,反之亦然。请随意尝试不同的值。

在本章中,我们将选择 400 这个值,就像我们在之前的 FeatureMatching.__init__ 中所做的那样,在那里我们使用以下代码片段创建了一个 SURF 描述符:

self.f_extractor = cv2.xfeatures2d_SURF.create(hessianThreshold=400)

图像中的关键点可以一步获得,如下所示:

key_query = self.f_extractor.detect(img_query)

在这里,key_querycv2.KeyPoint 实例的列表,其长度等于检测到的关键点数量。每个 KeyPoint 包含有关位置 (KeyPoint.pt)、大小 (KeyPoint.size) 以及关于我们感兴趣点的其他有用信息。

我们现在可以很容易地使用以下函数绘制关键点:

img_keypoints = cv2.drawKeypoints(img_query, key_query, None,
     (255, 0, 0), 4)
cv2.imshow("keypoints",img_keypoints) 

根据图像的不同,检测到的关键点数量可能非常大且在可视化时不清楚;我们使用 len(keyQuery) 来检查它。如果你只关心绘制关键点,尝试将 min_hessian 设置为一个较大的值,直到返回的关键点数量提供了一个良好的说明。

注意,SURF 受到专利法的保护。因此,如果你希望在商业应用中使用 SURF,你将需要获得许可证。

为了完成我们的特征提取算法,我们需要为检测到的关键点获取描述符,我们将在下一节中这样做。

使用 SURF 获取特征描述符

使用 OpenCV 和 SURF 从图像中提取特征的过程也是一个单步操作。这是通过特征提取器的compute方法完成的。后者接受一个图像和图像的关键点作为参数:

key_query, desc_query = self.f_extractor.compute(img_query, key_query)

在这里,desc_query是一个形状为(num_keypoints, descriptor_size)NumPY ndarray。你可以看到每个描述符都是一个n-维空间中的向量(一个n-长度的数字数组)。每个向量描述了相应的关键点,并提供了关于我们完整图像的一些有意义的信息。

因此,我们已经完成了必须提供关于我们图像在降维后的有意义信息的特征提取算法。算法的创建者决定描述符向量中包含什么类型的信息,但至少这些向量应该使得它们比出现在不同关键点的向量更接近相似的关键点。

我们的特征提取算法还有一个方便的方法来结合特征检测和描述符创建的过程:

key_query, desc_query = self.f_extractor.detectAndCompute (img_query, None)

它在单步中返回关键点和描述符,并接受一个感兴趣区域的掩码,在我们的案例中,是整个图像。

在我们提取了特征之后,下一步是查询和训练包含相似特征的图像,这是通过特征匹配算法实现的。所以,让我们在下一节学习特征匹配。

理解特征匹配

一旦我们从两个(或更多)图像中提取了特征及其描述符,我们就可以开始询问这些特征是否出现在两个(或所有)图像中。例如,如果我们对我们的感兴趣对象(self.desc_train)和当前视频帧(desc_query)都有描述符,我们可以尝试找到当前帧中看起来像我们的感兴趣对象的部分。

这是通过以下方法实现的,它使用了 FLANN:

good_matches = self.match_features(desc_query)

寻找帧间对应关系的过程可以表述为从另一组描述符集中为每个元素寻找最近邻。

第一组描述符通常被称为训练集,因为在机器学习中,这些描述符被用来训练一个模型,例如我们想要检测的对象模型。在我们的案例中,训练集对应于模板图像(我们感兴趣的对象)的描述符。因此,我们称我们的模板图像为训练图像self.img_train)。

第二组通常被称为查询集,因为我们不断地询问它是否包含我们的训练图像。在我们的案例中,查询集对应于每个输入帧的描述符。因此,我们称一个帧为查询图像img_query)。

特征可以通过多种方式匹配,例如,使用暴力匹配器(cv2.BFMatcher)来查找第一集中的每个描述符,并尝试每个描述符以找到第二集中的最近描述符(一种穷举搜索)。

在下一节中,我们将学习如何使用 FLANN 跨图像匹配特征。

使用 FLANN 跨图像匹配特征

另一种选择是使用基于快速第三方库 FLANN 的近似k 最近邻kNN)算法来查找对应关系。以下代码片段展示了如何使用 kNN 与k=2进行匹配:

def match_features(self, desc_frame: np.ndarray) -> List[cv2.DMatch]:
        matches = self.flann.knnMatch(self.desc_train, desc_frame, k=2)

flann.knnMatch的结果是两个描述符集之间的对应关系列表,这两个集都包含在matches变量中。这些是训练集,因为它对应于我们感兴趣的对象的模式图像,而查询集,因为它对应于我们正在搜索我们感兴趣的对象的图像。

现在我们已经找到了特征的最邻近邻居,让我们继续前进,在下一节中找出我们如何去除异常值。

测试用于去除异常值的比率

找到的正确匹配越多(这意味着模式到图像的对应关系越多),模式出现在图像中的可能性就越高。然而,一些匹配可能是假阳性。

一种用于去除异常值的有效技术称为比率测试。由于我们执行了k=2的 kNN 匹配,因此每个匹配返回两个最近的描述符。第一个匹配是最接近的邻居,第二个匹配是第二接近的邻居。直观上,正确的匹配将比其第二接近的邻居有更接近的第一个邻居。另一方面,两个最近的邻居将距离错误的匹配相似。

因此,我们可以通过观察距离之间的差异来找出匹配的好坏。比率测试表明,只有当第一匹配和第二匹配之间的距离比率小于一个给定的数字(通常约为 0.5)时,匹配才是好的。在我们的例子中,这个数字被选为0.7。以下代码片段用于找到好的匹配:

# discard bad matches, ratio test as per Lowe's paper
good_matches = [ x[0] for x in matches 
    if x[0].distance < 0.7 * x[1].distance]

为了去除所有不满足此要求的匹配,我们过滤匹配列表,并将好的匹配存储在good_matches列表中。

然后,我们将我们找到的匹配传递给FeatureMatching.match,以便它们可以进一步处理:

return good_matches 

然而,在我们详细阐述我们的算法之前,让我们首先在下一节中可视化我们的匹配。

可视化特征匹配

在 OpenCV 中,我们可以轻松地使用cv2.drawMatches绘制匹配。在这里,我们创建自己的函数,既是为了教育目的,也是为了便于自定义函数行为:

def draw_good_matches(img1: np.ndarray,
                      kp1: Sequence[cv2.KeyPoint],
                      img2: np.ndarray,
                      kp2: Sequence[cv2.KeyPoint],
                      matches: Sequence[cv2.DMatch]) -> np.ndarray:

函数接受两个图像,在我们的例子中,是感兴趣物体的图像和当前视频帧。它还接受两个图像的关键点和匹配项。它将在单个插图图像上并排放置图像,在图像上展示匹配项,并返回图像。后者是通过以下步骤实现的:

  1. 创建一个新输出图像,其大小足以将两个图像放在一起;为了在图像上绘制彩色线条,使其成为三通道:
rows1, cols1 = img1.shape[:2]
rows2, cols2 = img2.shape[:2]
out = np.zeros((max([rows1, rows2]), cols1 + cols2, 3), dtype='uint8')
  1. 将第一张图像放置在新图像的左侧,第二张图像放置在第一张图像的右侧:
out[:rows1, :cols1, :] = img1[..., None]
out[:rows2, cols1:cols1 + cols2, :] = img2[..., None]

在这些表达式中,我们使用了 NumPy 数组的广播规则,这是当数组的形状不匹配但满足某些约束时的操作规则。在这里,img[...,None]为二维灰度图像(数组)的第三个(通道)维度分配了一个规则。接下来,一旦NumPy遇到一个不匹配的维度,但具有值为一的,它就会广播数组。这意味着相同的值用于所有三个通道。

  1. 对于两个图像之间的每个匹配点对,我们想在每个图像上画一个小蓝色圆圈,并用线条连接两个圆圈。为此,使用for循环遍历匹配关键点的列表,从相应的关键点中提取中心坐标,并调整第二个中心坐标以进行绘制:
for m in matches:
    c1 = tuple(map(int,kp1[m.queryIdx].pt))
    c2 = tuple(map(int,kp2[m.trainIdx].pt))
    c2 = c2[0]+cols1,c2[1]

关键点以 Python 中的元组形式存储,包含两个条目用于xy坐标。每个匹配项m存储在关键点列表中的索引,其中m.trainIdx指向第一个关键点列表(kp1)中的索引,而m.queryIdx指向第二个关键点列表(kp2)中的索引。

  1. 在相同的循环中,用四像素半径、蓝色和一像素粗细的圆圈绘制。然后,用线条连接圆圈:
radius = 4
BLUE = (255, 0, 0)
thickness = 1
# Draw a small circle at both co-ordinates
cv2.circle(out, c1, radius, BLUE, thickness)
cv2.circle(out, c2, radius, BLUE, thickness)

# Draw a line in between the two points
cv2.line(out, c1, c2, BLUE, thickness)
  1. 最后,return结果图像:
return out

因此,现在我们有一个方便的功能,我们可以用以下代码来展示匹配:

cv2.imshow('imgFlann', draw_good_matches(self.img_train, 
     self.key_train, img_query, key_query, good_matches))

蓝色线条将物体(左侧)中的特征与场景(右侧)中的特征连接起来,如图所示:

在这个简单的例子中,这工作得很好,但当场景中有其他物体时会发生什么?由于我们的物体包含一些看起来非常突出的文字,当场景中存在其他文字时会发生什么?

事实上,算法在这样的条件下也能正常工作,如下面的截图所示:

有趣的是,算法没有混淆左侧作者的名字与场景中书本旁边的黑白文字,尽管它们拼写出相同的名字。这是因为算法找到了一个不依赖于纯灰度表示的对象描述。另一方面,一个进行像素级比较的算法可能会轻易混淆。

现在我们已经匹配了我们的特征,让我们继续学习如何使用这些结果来突出显示感兴趣的对象,我们将通过下一节中的单应性估计来实现这一点。

投影单应性估计

由于我们假设我们感兴趣的对象是平面的(即图像)且刚性的,我们可以找到两个图像特征点之间的单应性变换。

在以下步骤中,我们将探讨如何使用单应性来计算将匹配的特征点从对象图像(self.key_train)转换到与当前图像帧(key_query)中对应特征点相同平面的透视变换:

  1. 首先,为了方便起见,我们将所有匹配良好的关键点的图像坐标存储在列表中,如下代码片段所示:
train_points = [self.key_train[good_match.queryIdx].pt
                for good_match in good_matches]
query_points = [key_query[good_match.trainIdx].pt
                for good_match in good_matches]
  1. 现在,让我们将角点检测的逻辑封装到一个单独的函数中:
def detect_corner_points(src_points: Sequence[Point],
                         dst_points: Sequence[Point],
                         sh_src: Tuple[int, int]) -> np.ndarray:

之前的代码显示了两个点的序列和源图像的形状,函数将返回点的角,这是通过以下步骤实现的:

    1. 为给定的两个坐标序列找到透视变换(单应性矩阵,H):
H, _ = cv2.findHomography(np.array(src_points), np.array(dst_points), cv2.RANSAC)

为了找到变换,cv2.findHomography函数将使用随机样本一致性RANSAC)方法来探测输入点的不同子集。

    1. 如果方法无法找到单应性矩阵,我们将raise一个异常,我们将在应用程序中稍后捕获它:
if H is None:
    raise Outlier("Homography not found")
    1. 给定源图像的形状,我们将其角点的坐标存储在一个数组中:
height, width = sh_src
src_corners = np.array([(0, 0), (width, 0),
                        (width, height),
                        (0, height)], dtype=np.float32)
    1. 可以使用单应性矩阵将图案中的任何点转换到场景中,例如将训练图像中的角点转换为查询图像中的角点。换句话说,这意味着我们可以通过将角点从训练图像转换到查询图像来在查询图像中绘制书的封面轮廓。

为了做到这一点,我们需要从训练图像的角点列表(src_corners)中取出并通过对查询图像进行透视变换来投影:

return cv2.perspectiveTransform(src_corners[None, :, :], H)[0]

此外,结果会立即返回,即一个包含图像点的数组(二维 NumPY 数组)。

  1. 现在我们已经定义了我们的函数,我们可以调用它来检测角点:
dst_corners = detect_corner_points(
                train_points, query_points, self.sh_train)
  1. 我们需要做的只是在每个dst_corners中的点之间画线,我们将在场景中看到一个轮廓:
dst_corners[:, 0] += self.sh_train[1]
cv2.polylines(
    img_flann,
    [dst_corners.astype(np.int)],
    isClosed=True,
    color=(0,255,0),
    thickness=3)

注意,为了绘制图像点,首先将点的x坐标偏移图案图像的宽度(因为我们是将两个图像并排放置)。然后,我们将图像点视为一个闭合的多边形线,并使用cv2.polilines绘制它。我们还需要将数据类型更改为整数以进行绘制。

  1. 最后,书的封面草图绘制如下:

图片

即使物体只部分可见,这种方法也有效,如下所示:

尽管书籍部分在框架之外,但书籍的轮廓是通过超出框架的轮廓边界预测的。

在下一节中,我们将学习如何扭曲图像,使其看起来更接近原始图像。

图像扭曲

我们也可以通过从探测场景到训练模式坐标进行相反的单应性估计/变换。这使得封面可以像直接从上方看它一样被带到前平面。为了实现这一点,我们可以简单地取单应性矩阵的逆来得到逆变换:

Hinv = cv2.linalg.inverse(H) 

然而,这将把封面左上角映射到新图像的原点,这将切断封面左上方的所有内容。相反,我们希望在大约中心位置放置封面。因此,我们需要计算一个新的单应性矩阵。

封面的大小应该大约是新图像的一半。因此,我们不是使用训练图像的点坐标,而是以下方法演示了如何转换点坐标,使它们出现在新图像的中心:

  1. 首先,找到缩放因子和偏差,然后应用线性缩放并转换坐标:
@staticmethod
def scale_and_offset(points: Sequence[Point],
                     source_size: Tuple[int, int],
                     dst_size: Tuple[int, int],
                     factor: float = 0.5) -> List[Point]:
    dst_size = np.array(dst_size)
    scale = 1 / np.array(source_size) * dst_size * factor
    bias = dst_size * (1 - factor) / 2
    return [tuple(np.array(pt) * scale + bias) for pt in points]
  1. 作为输出,我们希望得到一个与模式图像(sh_query)形状相同的图像:
train_points_scaled = self.scale_and_offset(
    train_points, self.sh_train, sh_query)
  1. 然后,我们可以找到查询图像中的点和训练图像变换后的点之间的单应性矩阵(确保列表被转换为 NumPy 数组):
Hinv, _ = cv2.findHomography(
    np.array(query_points), np.array(train_points_scaled), cv2.RANSAC)
  1. 之后,我们可以使用单应性矩阵来转换图像中的每个像素(这也可以称为图像扭曲):
img_warp = cv2.warpPerspective(img_query, Hinv, (sh_query[1], sh_query[0]))

结果看起来是这样的(左边的匹配和右边的扭曲图像):

由于透视变换后的图像可能不会与 frontoparallel 平面完美对齐,因为毕竟单应性矩阵只是给出了一个近似。然而,在大多数情况下,我们的方法仍然工作得很好,例如在以下屏幕截图中的示例所示:

现在我们已经对如何使用几幅图像完成特征提取和匹配有了相当好的了解,让我们继续完成我们的应用程序,并学习如何在下一节中跟踪特征。

学习特征跟踪

现在我们知道我们的算法适用于单帧,我们想要确保在一帧中找到的图像也会在下一帧中找到。

FeatureMatching.__init__ 中,我们创建了一些用于特征跟踪的记账变量。主要思想是在从一个帧移动到下一个帧的过程中强制一些一致性。由于我们每秒捕获大约 10 帧,因此可以合理地假设从一个帧到下一个帧的变化不会太剧烈。

因此,我们可以确信在任何给定的帧中得到的任何结果都必须与前一帧中得到的结果相似。否则,我们丢弃该结果并继续到下一帧。

然而,我们必须小心,不要陷入一个我们认为合理但实际上是异常值的结果。为了解决这个问题,我们跟踪我们没有找到合适结果所花费的帧数。我们使用self.num_frames_no_success来保存帧数的值。如果这个值小于某个特定的阈值,比如说self.max_frames_no_success,我们就进行帧之间的比较。

如果它大于阈值,我们假设自上次获得结果以来已经过去了太多时间,在这种情况下,在帧之间比较结果是不合理的。让我们在下一节中了解早期异常值检测和拒绝。

理解早期异常值检测和拒绝

我们可以将异常值拒绝的概念扩展到计算的每一步。那么目标就变成了在最大化我们获得的结果是好的可能性的同时,最小化工作量。

用于早期异常值检测和拒绝的相应过程嵌入在FeatureMatching.match方法中。该方法首先将图像转换为灰度并存储其形状:

def match(self, frame):
    # create a working copy (grayscale) of the frame
    # and store its shape for convenience
    img_query = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
    sh_query = img_query.shape # rows,cols 

如果在计算的任何步骤中检测到异常值,我们将引发一个Outlier异常来终止计算。以下步骤展示了匹配过程:

  1. 首先,我们在模式和查询图像的特征描述符之间找到良好的匹配,然后存储来自训练和查询图像的相应点坐标:
key_query, desc_query = self.f_extractor.detectAndCompute(
    img_query, None)
good_matches = self.match_features(desc_query)
train_points = [self.key_train[good_match.queryIdx].pt
                for good_match in good_matches]
query_points = [key_query[good_match.trainIdx].pt
                for good_match in good_matches]

为了使 RANSAC 在接下来的步骤中工作,我们需要至少四个匹配。如果找到的匹配更少,我们承认失败并引发一个带有自定义信息的Outlier异常。我们将异常检测包裹在一个try块中:

try:
    # early outlier detection and rejection
    if len(good_matches) < 4:
        raise Outlier("Too few matches")
  1. 然后,我们在查询图像中找到模式的角点(dst_corners):
dst_corners = detect_corner_points(
    train_points, query_points, self.sh_train)

如果这些点中的任何一个显著地位于图像之外(在我们的例子中是20像素),这意味着我们可能没有看到我们感兴趣的对象,或者感兴趣的对象并没有完全在图像中。在两种情况下,我们不需要继续,并引发或创建一个Outlier实例:

if np.any((dst_corners < -20) | (dst_corners > np.array(sh_query) + 20)):
    raise Outlier("Out of image")
  1. 如果恢复的四个角点不能形成一个合理的四边形(一个有四边的多边形),这意味着我们可能没有看到我们感兴趣的对象。四边形的面积可以用以下代码计算:
for prev, nxt in zip(dst_corners, np.roll(
        dst_corners, -1, axis=0)):
    area += (prev[0] * nxt[1] - prev[1] * nxt[0]) / 2.

如果面积要么不合理地小,要么不合理地大,我们就丢弃该帧并引发异常:

if not np.prod(sh_query) / 16\. < area < np.prod(sh_query) / 2.:
    raise Outlier("Area is unreasonably small or large")
  1. 然后,我们缩放训练图像中的良好点并找到将对象带到正面面板的透视矩阵:
train_points_scaled = self.scale_and_offset(
    train_points, self.sh_train, sh_query)
Hinv, _ = cv2.findHomography(
    np.array(query_points), np.array(train_points_scaled), cv2.RANSAC)
  1. 如果恢复的单应性矩阵与我们上次恢复的矩阵(self.last_hinv)差异太大,这意味着我们可能正在观察一个不同的对象。然而,我们只想考虑self.last_hinv,如果它是相对较新的,比如说,在最近的self.max_frames_no_success帧内:
similar = np.linalg.norm(
Hinv - self.last_hinv) < self.max_error_hinv
recent = self.num_frames_no_success < self.max_frames_no_success
if recent and not similar:
 raise Outlier("Not similar transformation")

这将帮助我们跟踪同一感兴趣对象随时间的变化。如果由于任何原因,我们超过self.max_frames_no_success帧未能追踪到模式图像,我们将跳过此条件并接受到该点为止恢复的任何单应性矩阵。这确保了我们不会陷入self.last_hinv矩阵,这实际上是一个异常值。

如果在异常值检测过程中检测到异常值,我们将增加self.num_frame_no_success并返回False。我们可能还想打印出异常值的信息,以便看到它确切出现的时间:

except Outlier as e:
    print(f"Outlier:{e}")
    self.num_frames_no_success += 1
    return False, None, None

否则,如果没有检测到异常值,我们可以相当肯定,我们已经成功地在当前帧中定位了感兴趣的对象。在这种情况下,我们首先存储单应性矩阵并重置计数器:

else:
    # reset counters and update Hinv
    self.num_frames_no_success = 0
    self.last_h = Hinv

以下行展示了图像扭曲的示例:

img_warped = cv2.warpPerspective(
    img_query, Hinv, (sh_query[1], sh_query[0]))

最后,我们像之前一样绘制良好的匹配点和角点,并返回结果:

img_flann = draw_good_matches(
    self.img_obj,
    self.key_train,
    img_query,
    key_query,
    good_matches)
# adjust x-coordinate (col) of corner points so that they can be drawn
# next to the train image (add self.sh_train[1])
dst_corners[:, 0] += self.sh_train[1]
cv2.polylines(
    img_flann,
    [dst_corners.astype(np.int)],
    isClosed=True,
    color=(0,255,0),
    thickness=3)
return True, img_warped, img_flann

在前面的代码中,如前所述,我们将角点的x坐标移动了训练图像的宽度,因为查询图像出现在训练图像旁边,我们将角点的数据类型更改为整数,因为polilines方法接受整数作为坐标。

在下一节中,我们将探讨算法的工作原理。

观察算法的实际运行

来自笔记本电脑摄像头的实时流中匹配过程的结果如下所示:

如您所见,模式图像中的大多数关键点都正确地与右侧查询图像中的对应点匹配。现在可以缓慢地移动、倾斜和旋转模式图像的打印输出。只要所有角点都保持在当前帧中,单应性矩阵就会相应更新,并正确绘制模式图像的轮廓。

即使打印输出是颠倒的,这也适用,如下所示:

在所有情况下,扭曲的图像将模式图像带到frontoparallel平面上方直立的中心位置。这创造了一种效果,即模式图像在屏幕中心被冻结,而周围的环境则围绕它扭曲和转动,就像这样:

在大多数情况下,扭曲后的图像看起来相当准确,如前所述。如果由于任何原因,算法接受了一个导致不合理扭曲图像的错误单应性矩阵,那么算法将丢弃异常值并在半秒内(即self.max_frames_no_success帧内)恢复,从而实现准确和高效的跟踪。

摘要

本章探讨了快速且足够鲁棒的特征跟踪方法,当应用于网络摄像头的实时流时,可以在实时中运行。

首先,算法向您展示如何从图像中提取和检测重要特征,这些特征与视角和大小无关,无论是我们感兴趣的对象(列车图像)的模板中,还是我们期望感兴趣的对象嵌入的更复杂的场景中(查询图像)。

通过使用快速最近邻算法的一个版本对关键点进行聚类,然后在两张图像中的特征点之间找到匹配。从那时起,就可以计算出一种透视变换,将一组特征点映射到另一组。有了这些信息,我们可以概述在查询图像中找到的列车图像,并将查询图像扭曲,使感兴趣的物体垂直地出现在屏幕中央。

拥有这些,我们现在有一个很好的起点来设计一个前沿的特征跟踪、图像拼接或增强现实应用。

在下一章中,我们将继续研究场景的几何特征,但这次我们将专注于运动。具体来说,我们将研究如何通过从相机运动中推断其几何特征来重建场景。为此,我们必须将我们对特征匹配的知识与光流和运动结构技术相结合。

归因

Lenna.png——Lenna 的图像可在www.flickr.com/photos/15489034@N00/3388463896由 Conor Lawless 提供,根据 CC 2.0 通用归属权。

第四章:3D 场景重建使用运动结构

在上一章中,你学习了如何在网络摄像头的视频流中检测和跟踪感兴趣的对象,即使对象从不同的角度或距离观看,或者部分遮挡。在这里,我们将进一步跟踪有趣的特征,并通过研究图像帧之间的相似性来了解整个视觉场景。

本章的目标是研究如何通过从相机运动中推断场景的几何特征来重建 3D 场景。这种技术有时被称为运动结构。通过从不同角度观察相同的场景,我们将能够推断场景中不同特征的实世界 3D 坐标。这个过程被称为三角测量,它允许我们将场景重建为一个3D 点云

如果我们从不同角度拍摄同一场景的两张照片,我们可以使用特征匹配光流来估计相机在拍摄两张照片之间所经历的任何平移和旋转运动。然而,为了使这可行,我们首先必须校准我们的相机。

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

  • 学习相机标定

  • 设置应用程序

  • 从一对图像中估计相机运动

  • 重建场景

  • 理解 3D 点云可视化

  • 学习运动结构

一旦完成应用程序,你将了解用于从不同视角拍摄的多张图像中重建场景或对象的经典方法。你将能够将这些方法应用于你自己的应用程序,这些应用程序与从相机图像或视频中构建 3D 模型相关。

开始

本章已使用OpenCV 4.1.0wxPython 4.0.4www.wxpython.org/download.php)进行测试。它还需要 NumPy(www.numpy.org)和 Matplotlib(www.matplotlib.org/downloads.html)。

注意,你可能需要从github.com/Itseez/opencv_contrib获取所谓的额外模块,并设置OPENCV_EXTRA_MODULES_PATH变量来安装尺度不变特征变换SIFT)。此外,请注意,你可能需要获得许可证才能在商业应用中使用 SIFT。

你可以在我们的 GitHub 仓库中找到本章中展示的代码,github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter4

规划应用程序

最终的应用程序将从一对图像中提取并可视化运动结构。我们将假设这两张图像是用同一台相机拍摄的,我们已知其内部相机参数。如果这些参数未知,它们需要在相机标定过程中首先进行估计。

最终的应用程序将包括以下模块和脚本:

  • chapter4.main: 这是启动应用程序的主要函数例程。

  • scene3D.SceneReconstruction3D: 这是一个包含一系列用于计算和可视化运动结构的功能的类。它包括以下公共方法:

    • __init__: 此构造函数将接受内禀相机矩阵和畸变系数。

    • load_image_pair: 这是一个用于从文件中加载之前描述的相机拍摄的两张图像的方法。

    • plot_optic_flow: 这是一个用于可视化两张图像帧之间光流的方法。

    • draw_epipolar_lines: 此方法用于绘制两张图像的极线。

    • plot_rectified_images: 这是一个用于绘制两张图像校正版本的方法。

    • plot_point_cloud: 这是一个用于将场景的真实世界坐标作为 3D 点云可视化的方法。为了得到一个 3D 点云,我们需要利用极几何。然而,极几何假设了针孔相机模型,而现实中的相机并不遵循此模型。

应用程序的完整流程包括以下步骤:

  1. 相机标定: 我们将使用棋盘图案来提取内禀相机矩阵以及畸变系数,这些对于执行场景重建非常重要。

  2. 特征匹配: 我们将在同一视觉场景的两个 2D 图像中匹配点,无论是通过 SIFT 还是通过光流,如以下截图所示:

  1. 图像校正: 通过从一对图像中估计相机运动,我们将提取基础矩阵并校正图像,如以下截图所示:

  1. 三角测量法: 我们将通过利用极几何的约束来重建图像点的 3D 真实世界坐标。

  2. 3D 点云可视化: 最后,我们将使用 Matplotlib 中的散点图来可视化恢复的场景的 3D 结构,这在使用 pyplot 的 Pan 轴按钮进行研究时最为引人入胜。此按钮允许你在三个维度中旋转和缩放点云。在以下截图中,颜色对应于场景中点的深度:

首先,我们需要校正我们的图像,使它们看起来就像是从针孔相机拍摄出来的。为此,我们需要估计相机的参数,这把我们引向了相机标定的领域。

学习相机标定

到目前为止,我们一直使用从我们的网络摄像头直接输出的图像,而没有质疑其拍摄方式。然而,每个相机镜头都有独特的参数,例如焦距、主点和镜头畸变。

当相机拍照时,其背后的过程是这样的:光线穿过镜头,然后通过光圈,最后落在光敏器的表面上。这个过程可以用针孔相机模型来近似。估计现实世界镜头参数的过程,使其适合针孔相机模型,被称为相机标定(或相机重投影,不应与光度学相机标定混淆)。因此,让我们从下一节开始学习针孔相机模型。

理解针孔相机模型

针孔相机模型是对真实相机的简化,其中没有镜头,相机光圈被近似为一个单点(针孔)。这里描述的公式也完全适用于带薄镜头的相机,以及描述任何普通相机的主要参数。

当观察现实世界的 3D 场景(如一棵树)时,光线穿过点大小的孔径,并落在相机内部的 2D 图像平面上,如下所示图所示:

图片

在这个模型中,具有坐标(X, Y, Z)的 3D 点被映射到图像平面上的具有坐标(x, y)的 2D 点上。请注意,这导致树在图像平面上是倒置的。

垂直于图像平面并通过针孔的线称为主光线,其长度称为焦距。焦距是内部相机参数的一部分,因为它可能取决于所使用的相机。在简单的带镜头的相机中,针孔被镜头取代,焦平面放置在镜头的焦距处,以尽可能减少模糊。

哈特利和齐 isserman 发现了一个数学公式,用以描述如何从具有坐标(x, y)的 2D 点推断出具有坐标(X, Y, Z)的 3D 点,以及相机的内在参数,如下所示:

图片

前一个公式中的 3x3 矩阵是内在相机矩阵——一个紧凑地描述所有内部相机参数的矩阵。该矩阵包括焦距(f[x]f[y])和光学中心c[x]c[y],在数字成像中,它们简单地用像素坐标表示。如前所述,焦距是针孔和图像平面之间的距离。

小孔相机只有一个焦距,在这种情况下,f[x] = f[x ]= f[x ]。然而,在实际相机中,这两个值可能不同,例如,由于镜头、焦平面(由数字相机传感器表示)或组装的不完美。这种差异也可能是出于某种目的而故意造成的,这可以通过简单地使用在不同方向上具有不同曲率的镜头来实现。主光线与图像平面相交的点称为主点,其在图像平面上的相对位置由光学中心(或主点偏移)捕捉。

此外,相机可能受到径向或切向畸变的影响,导致鱼眼效应。这是由于硬件不完美和镜头错位造成的。这些畸变可以用畸变系数的列表来描述。有时,径向畸变实际上是一种期望的艺术效果。在其他时候,它们需要被校正。

关于小孔相机模型,网上有许多很好的教程,例如ksimek.github.io/2013/08/13/intrinsic

由于这些参数是针对相机硬件的特定参数(因此得名 intrinsic),我们只需要在相机的整个生命周期中计算一次。这被称为相机校准

接下来,我们将介绍相机的内在参数。

估计相机的内在参数

在 OpenCV 中,相机校准相当直接。官方文档提供了该主题的良好概述和一些示例 C++ 脚本,请参阅docs.opencv.org/doc/tutorials/calib3d/camera_calibration/camera_calibration.html

为了教育目的,我们将使用 Python 开发自己的校准脚本。我们需要向要校准的相机展示一个具有已知几何形状(棋盘格板或白色背景上的黑色圆圈)的特殊图案图像。

由于我们知道图案图像的几何形状,我们可以使用特征检测来研究内部相机矩阵的性质。例如,如果相机受到不希望的径向畸变,棋盘格图案的不同角落将在图像中变形,并且不会位于矩形网格上。通过从不同的视角拍摄大约 10-20 张棋盘格图案的快照,我们可以收集足够的信息来正确推断相机矩阵和畸变系数。

为了做到这一点,我们将使用 calibrate.py 脚本,该脚本首先导入以下模块:

import cv2
import numpy as np
import wx

from wx_gui import BaseLayout

类似于前面的章节,我们将使用基于 BaseLayout 的简单布局,该布局嵌入处理网络摄像头视频流。

脚本的 main 函数将生成 GUI 并执行应用程序的 main 循环:

 def main():

后者是通过以下步骤在函数体中完成的:

  1. 首先,连接到相机并设置标准 VGA 分辨率:
capture = cv2.VideoCapture(0)
assert capture.isOpened(), "Can not connect to camera"
capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
  1. 类似于前面的章节,创建一个wx应用程序和layout类,我们将在本节后面组合它们:
app = wx.App()
layout = CameraCalibration(capture, title='Camera Calibration', fps=2)
  1. 显示 GUI 并执行appMainLoop
layout.Show(True)
app.MainLoop()

在下一节中,我们将准备相机校准 GUI,这是我们将在本节后面使用的。

定义相机校准 GUI

GUI 是通用BaseLayout的定制版本:

class CameraCalibration(BaseLayout): 

布局仅由当前相机帧和其下方的单个按钮组成。此按钮允许我们开始校准过程:

    def augment_layout(self):
        pnl = wx.Panel(self, -1)
        self.button_calibrate = wx.Button(pnl, label='Calibrate Camera')
        self.Bind(wx.EVT_BUTTON, self._on_button_calibrate)
        hbox = wx.BoxSizer(wx.HORIZONTAL)
        hbox.Add(self.button_calibrate)
        pnl.SetSizer(hbox)

为了使这些更改生效,pnl需要添加到现有面板列表中:

self.panels_vertical.Add(pnl, flag=wx.EXPAND | wx.BOTTOM | wx.TOP,
                                 border=1)

剩余的可视化管道由BaseLayout类处理。我们只需要确保初始化所需的变量并提供process_frame方法。

现在我们已经定义了一个用于相机校准的 GUI,接下来我们将初始化一个相机校准算法。

初始化算法

为了执行校准过程,我们需要做一些记账工作。我们将通过以下步骤来完成:

  1. 现在,让我们专注于一个 10 x 7 的棋盘。算法将检测棋盘的所有9 x 6个内部角落(称为对象点)并将这些角落检测到的图像点存储在列表中。因此,我们首先将chessboard_size初始化为内部角落的数量:
self.chessboard_size = (9, 6) 
  1. 接下来,我们需要枚举所有对象点并将它们分配对象点坐标,以便第一个点的坐标为(0,0),第二个点(顶部行)的坐标为(1,0),最后一个点的坐标为(8,5):
        # prepare object points
        self.objp = np.zeros((np.prod(self.chessboard_size), 3),
                             dtype=np.float32)
        self.objp[:, :2] = np.mgrid[0:self.chessboard_size[0],
                                    0:self.chessboard_size[1]]
                                    .T.reshape(-1, 2)
  1. 我们还需要跟踪我们是否正在记录对象和图像点。一旦用户点击self.button_calibrate按钮,我们将启动此过程。之后,算法将尝试在所有后续帧中检测棋盘,直到检测到self.record_min_num_frames个棋盘:
        # prepare recording
        self.recording = False
        self.record_min_num_frames = 15
        self._reset_recording()
  1. 每当点击self.button_calibrate按钮时,我们将重置所有记账变量,禁用按钮,并开始记录:
    def _on_button_calibrate(self, event):
        """Enable recording mode upon pushing the button"""
        self.button_calibrate.Disable()
        self.recording = True
        self._reset_recording()

重置记账变量包括清除记录的对象和图像点列表(self.obj_pointsself.img_points)以及将检测到的棋盘数量(self.recordCnt)重置为0

def _reset_recording(self): 
    self.record_cnt = 0 
    self.obj_points = [] 
    self.img_points = [] 

在下一节中,我们将收集图像和对象点。

收集图像和对象点

process_frame方法负责执行校准技术的艰苦工作,我们将通过以下步骤收集图像和对象点:

  1. 在点击self.button_calibrate按钮后,此方法开始收集数据,直到检测到总共self.record_min_num_frames个棋盘:
    def process_frame(self, frame):
        """Processes each frame"""
        # if we are not recording, just display the frame
        if not self.recording:
            return frame

        # else we're recording
        img_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
                   .astype(np.uint8)
        if self.record_cnt < self.record_min_num_frames:
            ret, corners = cv2.findChessboardCorners(
                               img_gray,
                               self.chessboard_size,
                               None)

cv2.findChessboardCorners函数将解析灰度图像(img_gray)以找到大小为self.chessboard_size的棋盘。如果图像确实包含棋盘,该函数将返回trueret)以及一个棋盘角列表(corners)。

  1. 然后,绘制棋盘是直接的:
            if ret:
                print(f"{self.record_min_num_frames - self.record_cnt} chessboards remain")
                cv2.drawChessboardCorners(frame, self.chessboard_size, corners, ret)
  1. 结果看起来像这样(用彩色绘制棋盘角以增强效果):

图片

我们现在可以简单地存储检测到的角列表并继续下一帧。然而,为了使校准尽可能准确,OpenCV 提供了一个用于细化角点测量的函数:

criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER,
            30, 0.01)
cv2.cornerSubPix(img_gray, corners, (9, 9), (-1, -1), criteria)

这将细化检测到的角的坐标到亚像素精度。现在我们准备好将对象点和图像点添加到列表中并前进帧计数器:

self.obj_points.append(self.objp) 
self.img_points.append(corners) 
self.record_cnt += 1 

在下一节中,我们将学习如何找到相机矩阵,这对于完成适当的 3D 重建是必需的。

寻找相机矩阵

一旦收集到足够的数据(即,一旦self.record_cnt达到self.record_min_num_frames的值),算法就准备好执行校准。这个过程可以通过对cv2.calibrateCamera的单次调用来完成:

else:
    print("Calibrating...")
    ret, K, dist, rvecs, tvecs = cv2.calibrateCamera(self.obj_points,
                                                     self.img_points,
                                                     (self.imgHeight,
                                                      self.imgWidth),
                                                     None, None)

函数在成功时返回trueret),内禀相机矩阵(K),畸变系数(dist),以及两个旋转和平移矩阵(rvecstvecs)。目前,我们主要对相机矩阵和畸变系数感兴趣,因为这些将允许我们补偿内部相机硬件的任何不完美。

我们将简单地打印它们到控制台以便于检查:

print("K=", K)
print("dist=", dist)

例如,我的笔记本电脑网络摄像头的校准恢复了以下值:

K= [[ 3.36696445e+03 0.00000000e+00 2.99109943e+02] 
    [ 0.00000000e+00 3.29683922e+03 2.69436829e+02] 
    [ 0.00000000e+00 0.00000000e+00 1.00000000e+00]] 
dist= [[ 9.87991355e-01 -3.18446968e+02 9.56790602e-02 
         -3.42530800e-02 4.87489304e+03]]

这告诉我们,我的网络摄像头的焦距为fx=3366.9644像素和fy=3296.8392像素,光学中心在cx=299.1099像素和cy=269.4368像素。

一个好主意可能是双重检查校准过程的准确性。这可以通过使用恢复的相机参数将对象点投影到图像上来完成,以便我们可以将它们与我们使用cv2.findChessboardCorners函数收集到的图像点列表进行比较。如果这两个点大致相同,我们知道校准是成功的。甚至更好,我们可以通过将列表中的每个对象点投影来计算重建的平均误差

mean_error = 0 
for obj_point, rvec, tvec, img_point in zip(
        self.obj_points, rvecs, tvecs, self.img_points):
    img_points2, _ = cv2.projectPoints(
        obj_point, rvec, tvec, K, dist)
    error = cv2.norm(img_point, img_points2,
                     cv2.NORM_L2) / len(img_points2)
    mean_error += error

print("mean error=", mean_error)

在我的笔记本电脑的网络摄像头上进行此检查的结果是平均误差为 0.95 像素,这相当接近 0。

在恢复内部相机参数后,我们现在可以开始拍摄美丽、无畸变的照片,可能从不同的视角,以便我们可以从运动中提取一些结构。首先,让我们看看如何设置我们的应用程序。

设置应用程序

接下来,我们将使用一个著名的开源数据集,称为fountain-P11。它展示了从不同角度观看的瑞士喷泉:

数据集包含 11 张高分辨率图像,可以从icwww.epfl.ch/multiview/denseMVS.html下载。如果我们自己拍照,我们就必须通过整个相机标定过程来恢复内禀相机矩阵和畸变系数。幸运的是,这些参数对于拍摄喷泉数据集的相机是已知的,因此我们可以继续在我们的代码中硬编码这些值。

让我们在下一节中准备main主函数。

理解主函数

我们的main主函数将包括创建和与SceneReconstruction3D类的实例进行交互。这段代码可以在chapter4.py文件中找到。该模块的依赖项是numpy以及类本身,它们被导入如下:

import numpy as np 

from scene3D import SceneReconstruction3D

接下来,我们定义main函数:

def main():

函数包括以下步骤:

  1. 我们为拍摄喷泉数据集照片的相机定义了内禀相机矩阵(K)和畸变系数(d):
K = np.array([[2759.48 / 4, 0, 1520.69 / 4, 0, 2764.16 / 4,
               1006.81 / 4, 0, 0, 1]]).reshape(3, 3)
d = np.array([0.0, 0.0, 0.0, 0.0, 0.0]).reshape(1, 5) 

根据摄影师的说法,这些图像已经是无畸变的,因此我们将所有畸变系数设置为 0。

注意,如果您想在除fountain-P11之外的数据集上运行本章中展示的代码,您将必须调整内禀相机矩阵和畸变系数。

  1. 接下来,我们创建SceneReconstruction3D类的实例,并加载一对图像,我们希望将这些图像应用于我们的运动结构技术。数据集被下载到名为fountain_dense的子目录中:
scene = SceneReconstruction3D(K, d) 
scene.load_image_pair("fountain_dense/0004.png", 
     "fountain_dense/0005.png")
  1. 现在,我们已经准备好调用类中执行各种计算的方法:
scene.plot_rectified_images()
scene.plot_optic_flow()
scene.plot_point_cloud()

我们将在本章的其余部分实现这些方法,它们将在接下来的章节中详细解释。

因此,现在我们已经准备好了应用程序的主脚本,让我们开始实现SceneReconstruction3D类,它执行所有繁重的工作,并包含 3D 重建的计算。

实现SceneReconstruction3D

本章所有相关的 3D 场景重建代码都可以在scene3D模块中作为SceneReconstruction3D类的一部分找到。在实例化时,该类存储用于所有后续计算的内禀相机参数:

import cv2 
import numpy as np 
import sys 

from mpl_toolkits.mplot3d import Axes3D 
import matplotlib.pyplot as plt 
from matplotlib import cm

class SceneReconstruction3D: 
    def __init__(self, K, dist): 
        self.K = K 
        self.K_inv = np.linalg.inv(K) 
        self.d = dist 

然后,我们需要加载一对图像来进行操作。

为了做到这一点,首先,我们创建一个静态方法,该方法将加载一个图像,如果它是灰度图像,则将其转换为 RGB 格式,因为其他方法期望一个三通道图像。在喷泉序列的情况下,所有图像都具有相对较高的分辨率。如果设置了可选的downscale标志,则该方法将图像下采样到大约600像素的宽度:

    @staticmethod
    def load_image(
            img_path: str,
            use_pyr_down: bool,
            target_width: int = 600) -> np.ndarray:

        img = cv2.imread(img_path, cv2.CV_8UC3)
        # make sure image is valid
        assert img is not None, f"Image {img_path} could not be loaded."
        if len(img.shape) == 2:
            img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)

        while use_pyr_down and img.shape[1] > 2 * target_width:
            img = cv2.pyrDown(img)
        return img

接下来,我们创建一个方法,加载一对图像,并使用之前指定的畸变系数(如果有)对它们进行径向和切向镜头畸变的补偿:

    def load_image_pair(
            self,
            img_path1: str,
            img_path2: str,
            use_pyr_down: bool = True) -> None:

        self.img1, self.img2 = [cv2.undistort(self.load_image(path, 
                                                              use_pyr_down), 
                                              self.K, self.d)
            for path in (img_path1,img_path2)]

最后,我们准备进入项目的核心——估计相机运动和重建场景!

从一对图像中估计相机运动

现在我们已经加载了同一场景的两个图像(self.img1self.img2),例如来自喷泉数据集的两个示例,我们发现自己处于与上一章类似的情况。我们得到了两张据说显示相同刚性物体或静态场景但来自不同视点的图像。

然而,这次我们想要更进一步——如果两张照片之间唯一改变的是相机的位置,我们能否通过观察匹配特征来推断相对相机运动?

好吧,当然可以。否则,这一章就没有太多意义了,对吧?我们将以第一张图像中相机的位置和方向为已知条件,然后找出我们需要重新定位和重新定向相机多少,以便其视点与第二张图像匹配。

换句话说,我们需要恢复第二张图像中相机的基础矩阵。基础矩阵是一个 4 x 3 的矩阵,它是 3 x 3 旋转矩阵和 3 x 1 平移矩阵的连接。它通常表示为[R | t]。你可以将其视为捕捉第二张图像中相对于第一张图像中相机的位置和方向。

恢复基础矩阵(以及本章中所有其他变换)的关键步骤是特征匹配。我们可以对两张图像应用 SIFT 检测器,或者计算两张图像之间的光流。用户可以通过指定特征提取模式来选择他们喜欢的方

    def _extract_keypoints(self, feat_mode):
        # extract features
        if feat_mode.lower() == "sift":
            # feature matching via sift and BFMatcher
            self._extract_keypoints_sift()
        elif feat_mode.lower() == "flow":
            # feature matching via optic flow
            self._extract_keypoints_flow()
        else:
            sys.exit(f"Unknown feat_mode {feat_mode}. Use 'SIFT' or 
                     'FLOW'")

在下一节中,我们将学习如何使用丰富的特征描述符进行点匹配。

应用具有丰富特征描述符的点匹配

从图像中提取重要特征的一种鲁棒方法是使用 SIFT 检测器。在本章中,我们想要使用它来处理两张图像,self.img1self.img2

    def _extract_keypoints_sift(self):
        # extract keypoints and descriptors from both images
        detector = cv2.xfeatures2d.SIFT_create()
        first_key_points, first_desc = detector.detectAndCompute(self.img1,
                                                                 None)
        second_key_points, second_desc = detector.detectAndCompute(self.img2,
                                                                   None)

对于特征匹配,我们将使用BruteForce匹配器,这样其他匹配器(如FLANN)也可以工作:

        matcher = cv2.BFMatcher(cv2.NORM_L1, True)
        matches = matcher.match(first_desc, second_desc)

对于每个匹配,我们需要恢复相应的图像坐标。这些坐标保存在self.match_pts1self.match_pts2列表中:

        # generate lists of point correspondences
        self.match_pts1 = np.array(
            [first_key_points[match.queryIdx].pt for match in matches])
        self.match_pts2 = np.array(
            [second_key_points[match.trainIdx].pt for match in matches])

以下截图显示了将特征匹配器应用于喷泉序列的两个任意帧的示例:

图片

在下一节中,我们将学习使用光流进行点匹配。

使用光流进行点匹配

使用丰富特征的替代方案是使用光流。光流是通过计算位移向量来估计两个连续图像帧之间的运动的过程。位移向量可以计算图像中的每个像素(密集)或仅计算选定的点(稀疏)。

计算密集光流最常用的技术之一是 Lukas-Kanade 方法。它可以通过使用cv2.calcOpticalFlowPyrLK函数在 OpenCV 中以单行代码实现。

但在这之前,我们需要在图像中选取一些值得追踪的点。同样,这也是一个特征选择的问题。如果我们只想对几个非常突出的图像点获得精确的结果,我们可以使用 Shi-Tomasi 的cv2.goodFeaturesToTrack函数。这个函数可能会恢复出如下特征:

图片

然而,为了从运动中推断结构,我们可能需要更多的特征,而不仅仅是最突出的 Harris 角。一个替代方案是检测加速分割测试(FAST)特征:

def _extract_keypoints_flow(self): 
    fast = cv2.FastFeatureDetector() 
    first_key_points = fast.detect(self.img1, None) 

然后,我们可以计算这些特征的光流。换句话说,我们想要找到第二张图像中最可能对应于第一张图像中的first_key_points的点。为此,我们需要将关键点列表转换为(x, y)坐标的 NumPy 数组:

first_key_list = [i.pt for i in first_key_points] 
first_key_arr = np.array(first_key_list).astype(np.float32) 

然后光流将返回第二张图像中对应特征的一个列表(second_key_arr):

second_key_arr, status, err = 
     cv2.calcOpticalFlowPyrLK(self.img1, self.img2, 
         first_key_arr)

该函数还返回一个状态位向量(status),它指示关键点的光流是否已找到,以及一个估计误差值向量(err)。如果我们忽略这两个附加向量,恢复的光流场可能看起来像这样:

图片

在这张图像中,每个关键点都画了一个箭头,从第一张图像中关键点的位置开始,指向第二张图像中相同关键点的位置。通过检查光流图像,我们可以看到相机主要向右移动,但似乎还有一个旋转分量。

然而,其中一些箭头非常大,而且一些箭头没有意义。例如,图像右下角的像素实际上移动到图像顶部的可能性非常小。更有可能的是,这个特定关键点的光流计算是错误的。因此,我们希望排除所有状态位为 0 或估计误差大于某个值的特征点:

condition = (status == 1) * (err < 5.) 
concat = np.concatenate((condition, condition), axis=1) 
first_match_points = first_key_arr[concat].reshape(-1, 2) 
second_match_points = second_key_arr[concat].reshape(-1, 2) 

self.match_pts1 = first_match_points 
self.match_pts2 = second_match_points 

如果我们再次使用有限的关键点集绘制流场,图像将看起来像这样:

图片

流场可以使用以下公共方法绘制,该方法首先使用前面的代码提取关键点,然后在图像上绘制实际的箭头:

    def plot_optic_flow(self):
        self._extract_keypoints_flow()

        img = np.copy(self.img1)
        for pt1, pt2 in zip(self.match_pts1, self.match_pts2):
            cv2.arrowedLine(img, tuple(pt1), tuple(pt2),
                     color=(255, 0, 0))

        cv2.imshow("imgFlow", img)
        cv2.waitKey()

使用光流而不是丰富特征的优势在于,该过程通常更快,并且可以容纳更多点的匹配,使重建更密集。

在处理光流时需要注意的问题是,它最适合由相同硬件连续拍摄的照片,而丰富的特征对此则大多不敏感。

让我们在下一节中学习如何找到相机矩阵。

寻找相机矩阵

现在我们已经获得了关键点之间的匹配,我们可以计算两个重要的相机矩阵——基础矩阵和本质矩阵。这些矩阵将指定相机的运动,以旋转和平移分量表示。获取基础矩阵 (self.F) 是另一个 OpenCV 一行代码:

def _find_fundamental_matrix(self): 
    self.F, self.Fmask = cv2.findFundamentalMat(self.match_pts1, 
         self.match_pts2, cv2.FM_RANSAC, 0.1, 0.99)

fundamental_matrixessential_matrix 之间的唯一区别是后者作用于校正后的图像:

def _find_essential_matrix(self): 
    self.E = self.K.T.dot(self.F).dot(self.K) 

本质矩阵 (self.E) 可以通过奇异值分解(SVD)分解为旋转和平移分量,表示为 [R | t]

def _find_camera_matrices(self): 
    U, S, Vt = np.linalg.svd(self.E) 
    W = np.array([0.0, -1.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 
         1.0]).reshape(3, 3)

使用单位矩阵 UV 与一个额外的矩阵 W 结合,我们现在可以重建 [R | t]。然而,可以证明这种分解有四个可能的解,其中只有一个才是有效的第二个相机矩阵。我们唯一能做的就是检查所有四个可能的解,找到预测所有图像关键点都位于两个相机前面的那个解。

但在那之前,我们需要将关键点从 2D 图像坐标转换为齐次坐标。我们通过添加一个 z 坐标来实现这一点,并将其设置为 1

        first_inliers = []
        second_inliers = []
        for pt1,pt2, mask in 
        zip(self.match_pts1,self.match_pts2,self.Fmask):
            if mask:
                first_inliers.append(self.K_inv.dot([pt1[0], pt1[1], 1.0]))
                second_inliers.append(self.K_inv.dot([pt2[0], pt2[1], 
                                      1.0]))

我们然后遍历四个可能的解,并选择返回 _in_front_of_both_camerasTrue 的那个解:

        R = T = None
        for r in (U.dot(W).dot(Vt), U.dot(W.T).dot(Vt)):
            for t in (U[:, 2], -U[:, 2]):
                if self._in_front_of_both_cameras(
                        first_inliers, second_inliers, r, t):
                    R, T = r, t

        assert R is not None, "Camera matricies were never found!"

现在,我们最终可以构建两个相机的 [R | t] 矩阵。第一个相机只是一个标准相机(没有平移和旋转):

self.Rt1 = np.hstack((np.eye(3), np.zeros((3, 1)))) 

第二个相机矩阵由之前恢复的 [R | t] 组成:

self.Rt2 = np.hstack((R, T.reshape(3, 1))) 

__InFrontOfBothCameras 私有方法是一个辅助函数,确保每一对关键点都映射到使它们位于两个相机前面的 3D 坐标:

    def _in_front_of_both_cameras(self, first_points, second_points, rot,
                                  trans):
        """Determines whether point correspondences are in front of both
           images"""
        rot_inv = rot
        for first, second in zip(first_points, second_points):
            first_z = np.dot(rot[0, :] - second[0] * rot[2, :],
                             trans) / np.dot(rot[0, :] - second[0] * rot[2, 
                             :],
                                             second)
            first_3d_point = np.array([first[0] * first_z,
                                       second[0] * first_z, first_z])
            second_3d_point = np.dot(rot.T, first_3d_point) - np.dot(rot.T,
                                                                     trans)

如果函数发现任何不在两个相机前面的关键点,它将返回 False

if first_3d_point[2] < 0 or second_3d_point[2] < 0: 
    return False 
return True 

因此,既然我们已经找到了相机矩阵,那么在下一节中校正图像,这是一个验证恢复的矩阵是否正确的好方法。

应用图像校正

确保我们已经恢复了正确的相机矩阵的最简单方法可能是校正图像。如果它们被正确校正,那么第一张图像中的一个点和第二张图像中的一个点将对应于相同的 3D 世界点,并将位于相同的垂直坐标上。

在一个更具体的例子中,比如在我们的案例中,因为我们知道相机是垂直的,我们可以验证校正图像中的水平线与 3D 场景中的水平线相对应。因此,我们遵循以下步骤来校正我们的图像:

  1. 首先,我们执行前一小节中描述的所有步骤,以获得第二个相机的[R | t]矩阵:
def plot_rectified_images(self, feat_mode="SIFT"): 
    self._extract_keypoints(feat_mode) 
    self._find_fundamental_matrix() 
    self._find_essential_matrix() 
    self._find_camera_matrices_rt() 

    R = self.Rt2[:, :3] 
    T = self.Rt2[:, 3] 
  1. 然后,可以使用两个 OpenCV 单行代码执行校正,这些代码根据相机矩阵(self.K)、畸变系数(self.d)、基础矩阵的旋转分量(R)和基础矩阵的平移分量(T)将图像坐标重新映射到校正坐标:
        R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify(
            self.K, self.d, self.K, self.d, 
            self.img1.shape[:2], R, T, alpha=1.0)
        mapx1, mapy1 = cv2.initUndistortRectifyMap(
            self.K, self.d, R1, self.K, self.img1.shape[:2],
            cv2.CV_32F)
        mapx2, mapy2 = cv2.initUndistortRectifyMap(
            self.K, self.d, R2, self.K,
            self.img2.shape[:2],
            cv2.CV_32F)
        img_rect1 = cv2.remap(self.img1, mapx1, mapy1, 
                              cv2.INTER_LINEAR)
        img_rect2 = cv2.remap(self.img2, mapx2, mapy2, 
                              cv2.INTER_LINEAR)
  1. 为了确保校正准确,我们将两个校正后的图像(img_rect1img_rect2)并排放置:
        total_size = (max(img_rect1.shape[0], img_rect2.shape[0]),
                      img_rect1.shape[1] + img_rect2.shape[1], 3)
        img = np.zeros(total_size, dtype=np.uint8)
        img[:img_rect1.shape[0], :img_rect1.shape[1]] = img_rect1
        img[:img_rect2.shape[0], img_rect1.shape[1]:] = img_rect2
  1. 我们还在每25像素后绘制水平蓝色线条,穿过并排的图像,以进一步帮助我们视觉上研究校正过程:
        for i in range(20, img.shape[0], 25):
            cv2.line(img, (0, i), (img.shape[1], i), (255, 0, 0))

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

现在我们很容易就能说服自己,校正已经成功,如下所示:

现在我们已经校正了我们的图像,让我们在下一节学习如何重建 3D 场景。

重建场景

最后,我们可以通过使用称为三角测量的过程来重建 3D 场景。由于极线几何的工作方式,我们能够推断出点的 3D 坐标。通过计算基础矩阵,我们比我们想象的更多地了解了视觉场景的几何形状。因为两个相机描绘了同一个真实世界的场景,我们知道大多数 3D 真实世界点将出现在两个图像中。

此外,我们知道从 2D 图像点到相应 3D 真实世界点的映射将遵循几何规则。如果我们研究足够多的图像点,我们就可以构建并解决一个(大)线性方程组,以获得真实世界坐标的地面真实值。

让我们回到瑞士喷泉数据集。如果我们要求两位摄影师同时从不同的视角拍摄喷泉的照片,不难意识到第一位摄影师可能会出现在第二位摄影师的照片中,反之亦然。图像平面上可以看到另一位摄影师的点被称为共轭点极线点

用更技术性的术语来说,共轭点是另一个相机的投影中心在第一个相机图像平面上的点。值得注意的是,它们各自图像平面上的共轭点和各自的投影中心都位于一条单一的 3D 直线上。

通过观察极点和图像点之间的线条,我们可以限制图像点的可能 3D 坐标数量。实际上,如果已知投影点,那么极线(即图像点和极点之间的线)是已知的,并且反过来,该点在第二张图像上的投影必须位于特定的极线上。困惑吗?我想是的。

让我们来看看这些图像:

这里每一行都是图像中特定点的极线。理想情况下,左手图像中绘制的所有极线都应该相交于一个点,并且该点通常位于图像之外。如果计算准确,那么该点应该与从第一台相机看到的第二台相机的位置重合。

换句话说,左手图像中的极线告诉我们,拍摄右手图像的相机位于我们的右侧(即第一台相机)。类似地,右手图像中的极线告诉我们,拍摄左侧图像的相机位于我们的左侧(即第二台相机)。

此外,对于在一张图像中观察到的每个点,该点必须在另一张图像上以已知的极线观察到。这被称为极线约束。我们可以利用这个事实来证明,如果两个图像点对应于同一个 3D 点,那么这两个图像点的投影线必须精确相交于该 3D 点。这意味着可以从两个图像点计算出 3D 点,这正是我们接下来要做的。

幸运的是,OpenCV 再次提供了一个用于解决大量线性方程的包装器,这是通过以下步骤完成的:

  1. 首先,我们必须将我们的匹配特征点列表转换为 NumPy 数组:
first_inliers = np.array(self.match_inliers1).reshape
     (-1, 3)[:, :2]second_inliers = np.array(self.match_inliers2).reshape
     (-1, 3)[:, :2]
  1. 三角剖分接下来使用前面提到的两个[R | t]矩阵(self.Rt1用于第一台相机,self.Rt2用于第二台相机)进行:
pts4D = cv2.triangulatePoints(self.Rt1, self.Rt2, first_inliers.T,
     second_inliers.T).T
  1. 这将使用 4D 齐次坐标返回三角剖分的真实世界点。要将它们转换为 3D 坐标,我们需要将(X, Y, Z)坐标除以第四个坐标,通常称为W
pts3D = pts4D[:, :3]/np.repeat(pts4D[:, 3], 3).reshape(-1, 3) 

因此,现在我们已经获得了 3D 空间中的点,让我们在下一节中可视化它们,看看它们看起来如何。

理解 3D 点云可视化

最后一步是可视化三角剖分的 3D 真实世界点。创建 3D 散点图的一个简单方法是通过使用 Matplotlib。然而,如果您正在寻找更专业的可视化工具,您可能会对Mayavi(docs.enthought.com/mayavi/mayavi)、VisPy(vispy.org)或点云库(pointclouds.org)感兴趣。

尽管最后一个还没有为点云可视化提供 Python 支持,但它是一个用于点云分割、过滤和样本一致性模型拟合的出色工具。更多信息,请访问Strawlab的 GitHub 仓库github.com/strawlab/python-pcl

在我们能够绘制我们的 3D 点云之前,我们显然必须提取[R | t]矩阵并执行如前所述的三角剖分:


    def plot_point_cloud(self, feat_mode="SIFT"):
        self._extract_keypoints(feat_mode)
        self._find_fundamental_matrix()
        self._find_essential_matrix()
        self._find_camera_matrices_rt()

        # triangulate points
        first_inliers = np.array(self.match_inliers1)[:, :2]
        second_inliers = np.array(self.match_inliers2)[:, :2]
        pts4D = cv2.triangulatePoints(self.Rt1, self.Rt2, first_inliers.T,
                                      second_inliers.T).T

        # convert from homogeneous coordinates to 3D
        pts3D = pts4D[:, :3] / pts4D[:, 3, None]

然后,我们只需要打开一个 Matplotlib 图形并绘制pts3D的每个条目在 3D 散点图中:


        Xs, Zs, Ys = [pts3D[:, i] for i in range(3)]

        fig = plt.figure()
        ax = fig.add_subplot(111, projection='3d')
        ax.scatter(Xs, Ys, Zs, c=Ys, cmap=cm.hsv, marker='o')
        ax.set_xlabel('X')
        ax.set_ylabel('Y')
        ax.set_zlabel('Z')
        plt.title('3D point cloud: Use pan axes button below to inspect')
        plt.show()

使用 pyplot 的pan axes按钮研究结果最为引人入胜,它允许你在三个维度中旋转和缩放点云。在下图中,展示了两个投影。

第一个是从顶部拍摄的,第二个是从喷泉左侧某个垂直角度拍摄的。点的颜色对应于该点的深度(y坐标)。大多数点都位于与XZ平面成角度的平面上(从红色到绿色)。这些点代表喷泉后面的墙壁。其他点(从黄色到蓝色)代表喷泉的其余结构:

从喷泉左侧某个垂直角度的投影显示在下图中:

因此,现在你已经完成了你的第一个 3D 重建应用程序,你已经开始深入到计算机视觉领域中的结构从运动领域。这是一个快速发展领域。让我们在下一节中了解这个研究领域试图解决的问题。

学习从运动中获取结构

到目前为止,在本章中,我们已经介绍了一些数学知识,并且可以根据从不同角度拍摄的一组图像重建场景的深度,这是一个从相机运动重建 3D 结构的问题。

在计算机视觉中,根据图像序列重建场景 3D 结构的过程通常被称为从运动中获取结构。类似的问题集是从立体视觉中获取结构——在立体视觉重建中,有两个相机,它们彼此之间相隔一定距离,而在从运动中获取结构中,有从不同角度和位置拍摄的不同图像。在概念上没有太大的区别,对吧?

让我们思考一下人类视觉。人们擅长估计物体的距离和相对位置。一个人甚至不需要两只眼睛来做这件事——我们可以用一只眼睛看,并且相当准确地估计距离和相对位置。此外,立体视觉只有在眼睛之间的距离与物体到眼睛的投影有显著差异时才会发生。

例如,如果一个物体在足球场外,眼睛的相对位置并不重要,而如果你看你的鼻子,视角会改变很多。为了进一步说明立体视觉并不是我们视觉的本质,我们可以看看一张我们可以很好地描述物体相对位置的相片,但我们实际上看到的是一个平面。

人们在大脑发育的早期并不具备这样的技能;观察表明,婴儿在定位物体位置方面很糟糕。因此,可能一个人在意识生活中通过观察世界和玩物体来学习这种技能。接下来,一个问题出现了——如果一个人学会了世界的 3D 结构,我们能否让计算机也做到这一点?

已经有一些有趣的模型试图做到这一点。例如,Vid2Depth (arxiv.org/pdf/1802.05522.pdf) 是一个深度学习模型,其中作者训练了一个模型,该模型可以预测单张图像中的深度;同时,该模型在没有任何深度标注的视频帧序列上进行了训练。类似的问题现在是研究的热点。

摘要

在本章中,我们探索了一种通过推断由同一相机拍摄的 2D 图像的几何特征来重建场景的方法。我们编写了一个脚本来校准相机,并学习了基本和必要的矩阵。我们利用这些知识来进行三角测量。然后,我们继续使用 Matplotlib 中的简单 3D 散点图来可视化场景的真实世界几何形状。

从这里开始,我们将能够将三角化的 3D 点存储在可以被点云库解析的文件中,或者对不同的图像对重复该过程,以便我们可以生成更密集和更精确的重建。尽管我们在这章中已经涵盖了大量的内容,但还有很多工作要做。

通常,当我们谈论运动结构化流程时,我们会包括两个之前未曾提及的额外步骤——捆绑调整几何拟合。在这样的流程中,最重要的步骤之一是细化 3D 估计,以最小化重建误差。通常,我们还想将不属于我们感兴趣对象的所有点从点云中移除。但是,有了基本的代码在手,你现在可以继续编写你自己的高级运动结构化流程!

在下一章中,我们将使用我们在 3D 场景重建中学到的概念。我们将使用本章中我们学到的关键点和特征来提取,并将应用其他对齐算法来创建全景图。我们还将深入研究计算摄影学的其他主题,理解核心概念,并创建高动态范围HDR)图像。

第五章:使用 OpenCV 进行计算摄影

本章的目标是建立在前面章节中关于摄影和图像处理所涵盖的内容之上,并深入探讨 OpenCV 提供的算法。我们将专注于处理数码摄影和构建能够让您利用 OpenCV 力量的工具,甚至考虑将其作为您编辑照片的首选工具。

在本章中,我们将介绍以下概念:

  • 规划应用

  • 理解 8 位问题

  • 使用伽玛校正

  • 理解高动态范围成像HDRI

  • 理解全景拼接

  • 改进全景拼接

学习数码摄影的基础知识和高动态成像的概念不仅可以帮助您更好地理解计算摄影,还可以使您成为一名更好的摄影师。由于我们将详细探讨这些主题,您还将了解编写新算法需要付出多少努力。

通过本章的学习,您将了解如何直接从数码相机处理(RAW)图像,如何使用 OpenCV 的计算摄影工具,以及如何使用低级 OpenCV API 构建全景拼接算法。

我们有很多主题要介绍,所以让我们挽起袖子开始吧。

开始学习

您可以在我们的 GitHub 仓库中找到本章中展示的代码,网址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter5

我们还将使用rawpyexifreadPython 包来读取 RAW 图像和读取图像元数据。对于完整的需求列表,您可以参考书中 Git 仓库中的requirements.txt文件。

规划应用

我们有几个概念需要熟悉。为了构建您的图像处理工具箱,我们将把我们要熟悉的概念开发成使用 OpenCV 解决实际问题的 Python 脚本。

我们将使用 OpenCV 实现以下脚本,以便您在需要处理照片时可以使用它们:

  • gamma_correct.py:这是一个脚本,它对输入图像应用伽玛校正,并显示结果图像。

  • hdr.py:这是一个脚本,它以图像为输入,并生成一个高动态范围HDR)图像作为输出。

  • panorama.py:这是一个脚本,它以多个图像为输入,并生成一个比单个图像更大的拼接图像。

我们首先讨论数码摄影的工作原理以及为什么我们不需要进行后期处理就无法拍摄完美的照片。让我们从图像的 8 位问题开始。

理解 8 位问题

我们习惯看到的典型联合图像专家小组JPEG)图像,是通过将每个像素编码为 24 位来工作的——每个RGB(红色、绿色、蓝色)颜色组件一个 8 位数字,这给我们一个在 0-255 范围内的整数。这只是一个数字,255,但这足够信息吗?为了理解这一点,让我们尝试了解这些数字是如何记录的以及这些数字代表什么。

大多数当前的数码相机使用拜耳滤波器,或等效的,它使用相同的原则。拜耳滤波器是一个不同颜色传感器的阵列,放置在一个类似于以下图所示的网格上:

图片来源—https://en.wikipedia.org/wiki/Bayer_filter#/media/File:Bayer_pattern_on_sensor.svg (CC SA 3.0)

在前面的图中,每个传感器测量进入它的光的强度,四个传感器一组代表一个单独的像素。这四个传感器的数据被组合起来,为我们提供 R、G 和 B 的三个值。

不同的相机可能会有红色、绿色和蓝色像素的不同布局,但最终,它们都在使用小的传感器,将它们接收到的辐射量离散化到 0-255 范围内的单个值,其中 0 表示完全没有辐射,255 表示传感器可以记录的最亮辐射。

可检测的亮度范围被称为动态范围亮度范围。最小可注册的辐射量(即,1)与最高辐射量(即,255)之间的比率称为对比度比

正如我们所说,JPEG 文件具有255:1的对比度比。大多数当前的 LCD 显示器已经超过了这个比例,对比度比高达1,000:1。我打赌你正在等待你眼睛的对比度比。我不确定你,但大多数人类可以看到高达15,000:1

因此,我们可以看到比我们最好的显示器显示的还要多,比简单的 JPEG 文件存储的还要多。不要过于绝望,因为最新的数码相机已经迎头赶上,现在可以捕捉到高达28,000:1的强度比(真正昂贵的那些)。

小的动态范围是当你拍照时,如果背景有太阳,你要么看到太阳,周围的一切都是白色,没有任何细节,要么前景中的所有东西都极其黑暗的原因。这里是一个示例截图:

图片来源—https://github.com/mamikonyana/winter-hills (CC SA 4.0)

因此,问题是我们要么显示过亮的东西,要么显示过暗的东西。在我们继续前进之前,让我们看看如何读取超过 8 位的文件并将数据导入 OpenCV。

了解 RAW 图像

由于本章是关于计算摄影的,一些阅读本章的人可能是摄影爱好者,喜欢使用相机支持的 RAW 格式拍照——无论是尼康电子格式NEF)还是佳能原始版本 2CR2)。

原始文件通常比 JPEG 文件捕获更多的信息(通常每像素更多位),如果你要进行大量的后期处理,这些文件处理起来会更加方便,因为它们将产生更高品质的最终图像。

因此,让我们看看如何使用 Python 打开 CR2 文件并将其加载到 OpenCV 中。为此,我们将使用一个名为rawpy的 Python 库。为了方便,我们将编写一个名为load_image的函数,它可以处理 RAW 图像和常规 JPEG 文件,这样我们就可以抽象这部分内容,专注于本章剩余部分更有趣的事情:

  1. 首先,我们处理导入(如承诺的那样,只是额外的一个小库):
import rawpy
import cv2
  1. 我们定义了一个函数,添加了一个可选的bps参数,这将让我们控制我们想要图像具有多少精度,也就是说,我们想要检查我们是否想要完整的 16 位,或者 8 位就足够了:
def load_image(path, bps=16):
  1. 然后,如果文件具有.CR2扩展名,我们使用rawpy打开文件并提取图像,而不尝试进行任何后期处理,因为我们想用 OpenCV 来做:
    if path.suffix == '.CR2':
        with rawpy.imread(str(path)) as raw:
            data = raw.postprocess(no_auto_bright=True,
                                   gamma=(1, 1),
                                   output_bps=bps)
  1. 由于佳能(佳能公司——一家光学产品公司)和 OpenCV 使用不同的颜色顺序,我们切换到BGR蓝色绿色红色),这是 OpenCV 中的默认顺序,我们返回生成的图像:
        return cv2.cvtColor(data, cv2.COLOR_RGB2BGR)

对于任何不是.CR2的文件,我们使用 OpenCV:

    else:
        return cv2.imread(str(path))

现在我们知道了如何将所有我们的图像放入 OpenCV,是时候开始使用我们最明亮的算法之一了。

由于我的相机具有 14 位动态范围,我们将使用用我的相机捕获的图像:

def load_14bit_gray(path):
    img = load_image(path, bps=16)
    return (cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) / 4).astype(np.uint16)

一旦我们知道了如何加载我们的图片,让我们尝试看看我们如何在屏幕上最佳地显示它们。

使用伽玛校正

为什么每个人还在使用只能区分 255 个不同级别的 JPEG 文件呢?**这难道意味着它只能捕捉到 1:255 的动态范围吗? 事实上,人们使用了一些巧妙的技巧。

如我们之前提到的,相机传感器捕获的值是线性的,也就是说,4 表示它比 1 有 4 倍的光线,80 比 10 有 8 倍的光线。但是 JPEG 文件格式必须使用线性刻度吗?事实并非如此。 因此,如果我们愿意牺牲两个值之间的差异,例如,100 和 101,我们就可以在那里放入另一个值。

为了更好地理解这一点,让我们来看看 RAW 图像灰度像素值的直方图。以下是生成该直方图的代码——只需加载图像,将其转换为灰度,然后使用pyplot显示直方图:

    images = [load_14bit_gray(p) for p in args.images]
    fig, axes = plt.subplots(2, len(images), sharey=False)
    for i, gray in enumerate(images):
        axes[0, i].imshow(gray, cmap='gray', vmax=2**14)
        axes[1, i].hist(gray.flatten(), bins=256)

这是直方图的结果:

我们有两张图片:左边的是一张正常的图片,你可以看到一些云,但几乎看不到前景中的任何东西,而右边的一张则试图捕捉树中的细节,因此烧毁了所有的云。有没有办法将它们结合起来?

如果我们仔细观察直方图,我们会看到在右侧直方图上可以看见烧毁的部分,因为存在值为 16,000 的数据被编码为 255,即白色像素。但在左侧图片中,没有白色像素。我们将 14 位值编码为 8 位值的方式非常基础:我们只是将值除以64 (=2⁶),因此我们失去了 2,500 和 2,501 以及 2,502 之间的区别;相反,我们只有 39(255 个中的 39 个)因为 8 位格式中的值必须是整数。

这就是伽玛校正发挥作用的地方。我们不会简单地显示记录的值作为强度,我们将进行一些校正,使图像更具有视觉吸引力。

我们将使用非线性函数来尝试强调我们认为更重要的一部分:

让我们尝试可视化这个公式对于两个不同值——γ = 0.3γ = 3

如您所见,小的伽玛值强调较低的值;0-50的像素值映射到0-150的像素值(超过一半的可供值)。对于较高的伽玛值,情况相反——200-250的值映射到100-250的值(超过一半的可供值)。因此,如果你想使你的照片更亮,你应该选择γ < 1的伽玛值,这通常被称为伽玛压缩。如果你想使你的照片变暗以显示更多细节,你应该选择γ > 1的伽玛值,这被称为伽玛扩展

我们可以不用整数来表示I,而是从一个浮点数开始,得到 O,然后将该数字转换为整数以丢失更少的信息。让我们编写一些 Python 代码来实现伽玛校正:

  1. 首先,让我们编写一个函数来应用我们的公式。因为我们使用 14 位数字,所以我们需要将其更改为以下形式:

因此,相关的代码如下:

@functools.lru_cache(maxsize=None)
def gamma_transform(x, gamma, bps=14):
    return np.clip(pow(x / 2**bps, gamma) * 255.0, 0, 255)

在这里,我们使用了@functools.lru_cache装饰器来确保我们不会两次计算相同的内容。

  1. 然后,我们只需遍历所有像素并应用我们的转换函数:
def apply_gamma(img, gamma, bps=14):
    corrected = img.copy()
    for i, j in itertools.product(range(corrected.shape[0]),
                                  range(corrected.shape[1])):
        corrected[i, j] = gamma_transform(corrected[i, j], gamma, bps=bps)
    return corrected

现在,让我们看看如何使用这个方法来显示新图像与常规转换的 8 位图像并排。我们将为此编写一个脚本:

  1. 首先,让我们配置一个parser来加载图像并允许设置gamma值:
 if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('raw_image', type=Path,
                        help='Location of a .CR2 file.')
    parser.add_argument('--gamma', type=float, default=0.3)
    args = parser.parse_args()
  1. gray图像加载为14bit图像:
    gray = load_14bit_gray(args.raw_image)
  1. 使用线性变换来获取输出值作为范围[0-255]内的整数:
    normal = np.clip(gray / 64, 0, 255).astype(np.uint8)
  1. 使用我们之前编写的apply_gamma函数来获取伽玛校正的图像:
    corrected = apply_gamma(gray, args.gamma)
  1. 然后,将这两张图像及其直方图一起绘制出来:
    fig, axes = plt.subplots(2, 2, sharey=False)
    for i, img in enumerate([normal, corrected]):
        axes[0, i].imshow(img, cmap='gray', vmax=255)
        axes[1, i].hist(img.flatten(), bins=256)
  1. 最后,显示图像:
    plt.show()

我们现在已经绘制了直方图,接下来我们将看看以下两张图像及其直方图中所阐述的神奇之处:

图片

看看右上角的图片——你几乎可以看到一切!而我们才刚刚开始。

结果表明,伽玛补偿在黑白图像上效果很好,但它不能做所有的事情!它要么可以校正亮度,我们就会失去大部分的颜色信息,要么它可以校正颜色信息,我们就会失去亮度信息。因此,我们必须找到一个新最好的朋友——那就是,HDRI。

理解高动态范围成像

高动态范围成像(HDR)是一种技术,可以产生比通过显示介质显示或使用单次拍摄用相机捕获的图像具有更大亮度动态范围(即对比度比)的图像。创建此类图像有两种主要方法——使用特殊的图像传感器,例如过采样二进制图像传感器,或者我们在这里将重点关注的,通过组合多个标准动态范围SDR)图像来生成一个组合 HDR 图像。

HDR 成像使用的是每个通道超过 8 位(通常是 32 位浮点值)的图像,这使得动态范围更广。正如我们所知,场景的动态范围是其最亮和最暗部分之间的对比度比。

让我们更仔细地看看我们能看到的某些事物的亮度值。以下图表显示了我们可以轻松看到的值,从黑暗的天空(大约10^(-4) cd/m²)到日落时的太阳(10⁵ cd/m²):

图片

我们可以看到的不仅仅是这些值。因为有些人可以调整他们的眼睛适应甚至更暗的地方,所以当太阳不在地平线上,而是在更高的天空时,我们肯定可以看到太阳,可能高达10⁸ cd/m²,但这个范围已经相当大了,所以我们现在就坚持这个范围。为了比较,一个普通的 8 位图像对比度比为256:1,人眼一次可以看到大约百万到 1 的对比度,而 14 位 RAW 格式显示2¹⁴:1

显示媒体也有局限性;例如,典型的 IPS 显示器对比度比约为1,000:1,而 VA 显示器对比度可能高达6,000:1。因此,让我们将这些值放在这个频谱上,看看它们是如何比较的:

图片

现在,这看起来我们看不到多少,这是真的,因为我们需要时间来适应不同的光照条件。同样的情况也适用于相机。但仅仅一眼,我们的裸眼就能看到比最好的相机还能看到的东西更多。那么我们该如何解决这个问题呢

正如我们所说的,技巧是快速连续拍摄多张照片,大多数相机都能轻松实现这一点。如果我们连续拍摄互补的照片,只需五张 JPEG 图片就能覆盖相当大的光谱范围:

图片

这看起来有点太简单了,但记住,拍摄五张照片相当容易。但是,我们谈论的是一张包含所有动态范围的图片,而不是五张单独的图片。HDR 图像有两个主要问题:

  • 我们如何将多张图像合并成一张图像?

  • 我们如何显示一个比我们的显示媒体动态范围更高的图像?

然而,在我们能够合并这些图像之前,让我们更仔细地看看我们如何可以改变相机的曝光,即其对光线的敏感度。

探索改变曝光的方法

正如我们在本章前面讨论的,现代单镜头反光数码相机DSLR)以及其他数码相机,都有一个固定的传感器阵列(通常放置为拜耳滤镜),它只是测量相机的光强度。

我敢打赌,你见过同一个相机用来捕捉美丽的夜景,其中水面看起来像丝质云朵,以及体育摄影师拍摄的运动员全伸展的照片。那么他们如何使用同一个相机在如此不同的设置中并获得我们在屏幕上看到的结果呢?

在测量曝光时,很难测量被捕获的亮度。相对于以 10 的幂次测量亮度来说,测量相对速度要容易得多,这可能相当难以调整。我们以 2 的幂次来测量速度;我们称之为档位

这个技巧是,尽管相机受到限制,但它必须能够捕捉每张图片的有限亮度范围。这个范围本身可以在亮度光谱上移动。为了克服这一点,让我们研究相机的快门速度、光圈和 ISO 速度参数。

快门速度

快门速度并不是快门的速度,而是在拍照时相机快门开启的时间长度。因此,这是相机内部数字传感器暴露于光线以收集信息的时间长度。这是所有相机控制中最直观的一个,因为我们能感觉到它的发生。

快门速度通常以秒的分数来衡量。例如,1/60 是最快的速度,如果我们手持相机拍照时摇晃相机,它不会在照片中引入模糊。所以如果你要使用自己的照片,确保不要这样做,或者准备一个三脚架。

光圈

光圈是光学镜头中光线通过的孔的直径。以下图片展示了设置为不同光圈值的开口示例:

图片

图片来源—https://en.wikipedia.org/wiki/Aperture#/media/File:Lenses_with_different_apertures.jpg (CC SA 4.0)

光圈通常使用f 数来衡量。f 数是系统焦距与开口直径(入射光瞳)的比值。我们不会关心镜头的焦距;我们唯一需要知道的是,只有变焦镜头才有可变焦距,因此如果我们不改变镜头的放大倍数,焦距将保持不变。所以我们可以通过平方 f 数的倒数来测量入射光瞳的面积

图片

此外,我们知道面积越大,我们图片中的光线就越多。因此,如果我们增加 f 数,这将对应于入射光瞳大小的减小,我们的图片会变暗,使我们能够在下午拍照。

ISO 感光度

ISO 感光度是相机中使用的传感器的灵敏度。它使用数字来衡量数字传感器的灵敏度,这些数字对应于计算机出现之前使用的化学胶片。

ISO 感光度以两个数字来衡量;例如,100/21°,其中第一个数字是算术尺度上的速度,第二个数字是对数尺度上的数字。由于这些数字有一一对应的关系,通常省略第二个数字,我们简单地写成ISO 100。ISO 100 比 ISO 200 对光线的敏感度低两倍,据说这种差异是1 挡

用 2 的幂次方来表示比用 10 的幂次方表示更容易,因此摄影师提出了挡位的概念。一挡是两倍不同,两挡是四倍不同,以此类推。因此,n挡是2^n倍不同。这种类比已经变得如此普遍,以至于人们开始使用分数和实数来表示挡位。

现在我们已经了解了如何控制曝光,让我们来看看可以将多张不同曝光的图片组合成一张图片的算法。

使用多曝光图片生成 HDR 图像

现在,一旦我们知道了如何获取更多的图片,我们就可以拍摄多张几乎没有重叠动态范围的图片。让我们先看看最流行的 HDR 算法,该算法最早由 Paul E Debevec 和 Jitendra Malik 于 2008 年发表。

结果表明,如果你想得到好的结果,你需要有重叠的图片,以确保你有一个好的精度,因为照片中存在噪声。通常,图片之间的差异为 1、2 或最多 3 挡。如果我们拍摄五张 8 位图片,差异为 3 挡,我们将覆盖人眼一百万到一的敏感度比:

图片

现在,让我们更详细地看看 Debevec HDR 算法是如何工作的。

首先,让我们假设相机看到的记录值是场景辐照度的某个函数。我们之前提到这应该是线性的,但现实生活中没有任何东西是完全线性的。让记录值矩阵为Z,辐照度矩阵为X;我们有以下内容:

这里,我们也将Δt作为曝光时间的度量,函数f被称为我们相机的响应函数。我们还假设如果我们加倍曝光并减半辐照度,我们将得到相同的输出,反之亦然。这应该适用于所有图像,而E的值不应该从一张图片到另一张图片改变;只有Z的记录值和曝光时间Δt可以改变。如果我们应用逆响应函数(** f^(-1) )并取两边的对数,那么我们得到对于所有我们有的图片(i**):

现在的技巧是提出一个可以计算f^(-1)的算法,这正是 Debevec 等人所做的事情。

当然,我们的像素值不会完全遵循这个规则,我们不得不拟合一个近似解,但让我们更详细地看看这些值是什么。

在我们继续前进之前,让我们看看如何在下一节中从图片文件中恢复Δt[i]值。

从图像中提取曝光强度

假设我们之前讨论的所有相机参数都遵循互易原理,让我们尝试提出一个函数——exposure_strength——它返回一个等同于曝光时间的时长:

  1. 首先,让我们为 ISO 速度和光圈设置一个参考值:
def exposure_strength(path, iso_ref=100, f_stop_ref=6.375):
  1. 然后,让我们使用exifreadPython 包,它使得读取与图像关联的元数据变得容易。大多数现代相机以这种标准格式记录元数据:
    with open(path, 'rb') as infile:
        tags = exifread.process_file(infile)
  1. 然后,让我们提取f_stop值,看看参考的入射光瞳面积大多少:
    [f_stop] = tags['EXIF ApertureValue'].values
    rel_aperture_area = 1 / (f_stop.num / f_stop.den / f_stop_ref) ** 2
  1. 然后,让我们看看 ISO 设置更加敏感多少:
    [iso_speed] = tags['EXIF ISOSpeedRatings'].values
    iso_multiplier = iso_speed / iso_ref
  1. 最后,让我们将所有值与快门速度结合,并返回exposure_time
    [exposure_time] = tags['EXIF ExposureTime'].values
    exposure_time_float = exposure_time.num / exposure_time.den
    return rel_aperture_area * exposure_time_float * iso_multipli

这里是用于本演示的图片值示例,取自Frozen River图片集:

照片 光圈 ISO 速度 快门速度
AM5D5669.CR2 6 3/8 100 1/60
AM5D5670.CR2 6 3/8 100 1/250
AM5D5671.CR2 6 3/8 100 1/160
AM5D5672.CR2 6 3/8 100 1/100
AM5D5673.CR2 6 3/8 100 1/40
AM5D5674.CR2 6 3/8 160 1/40
AM5D5676.CR2 6 3/8 250 1/40

这是使用exposure_strength函数对这些图片进行时间估计的输出:

0.016666666666666666, 0.004, 0.00625, 0.01, 0.025, 0.04, 0.0625

现在,一旦我们有了曝光时间,让我们看看如何使用它来获取相机响应函数。

估计相机响应函数

让我们在Y轴上绘制 ![,在x轴上绘制Z[i]

我们试图找到一个f^(-1),更重要的是,所有图片的 。这样,当我们把log(E)加到曝光的对数上时,我们将有所有像素在同一个函数上。你可以在下面的屏幕截图中看到 Debevec 算法的结果:

Debevec 算法估计了f^(-1),它大约通过所有像素,以及 E矩阵是我们恢复的 HDR 图像矩阵。

现在让我们看看如何使用 OpenCV 来实现这一点。

使用 OpenCV 编写 HDR 脚本

脚本的第一个步骤将是使用 Python 内置的argparse模块设置脚本参数:

import argparse

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    img_group = parser.add_mutually_exclusive_group(required=True)
    img_group.add_argument('--image-dir', type=Path)
    img_group.add_argument('--images', type=Path, nargs='+')
    args = parser.parse_args()

    if args.image_dir:
        args.images = sorted(args.image_dir.iterdir())

正如你所见,我们设置了两个互斥的参数—--image-dir,一个包含图像的目录,以及 --images,一个我们将要使用的图像列表。我们确保将所有图像的列表填充到args.images中,这样脚本的其他部分就不必担心用户选择了哪个选项。

在我们有了所有的命令行参数之后,接下来的步骤如下:

  1. 将所有images读入内存:
    images = [load_image(p, bps=8) for p in args.images]
  1. 使用exposure_strength读取元数据和估计曝光时间:
    times = [exposure_strength(p)[0] for p in args.images]
    times_array = np.array(times, dtype=np.float32)
  1. 计算相机响应函数crf_debevec
    cal_debevec = cv2.createCalibrateDebevec(int samples=200)
    crf_debevec = cal_debevec.process(images, times=times_array)
  1. 使用相机响应函数来计算 HDR 图像:
    merge_debevec = cv2.createMergeDebevec()
    hdr_debevec = merge_debevec.process(images, times=times_array.copy(),
                                        response=crf_debevec)

注意到 HDR 图像是float32类型,而不是uint8,因为它包含了所有曝光图像的全动态范围。

现在我们有了 HDR 图像,我们已经来到了下一个重要部分。让我们看看我们如何使用我们的 8 位图像表示来显示 HDR 图像。

显示 HDR 图像

显示 HDR 图像很棘手。正如我们所说,HDR 比相机有更多的值,所以我们需要找出一种方法来显示它。幸运的是,OpenCV 在这里帮助我们,而且,正如你现在可能已经猜到的,我们可以使用伽玛校正将所有不同的值映射到范围0255的较小值域中。这个过程被称为色调映射

OpenCV 有一个方法可以做到这一点,它接受gamma作为参数:

    tonemap = cv2.createTonemap(gamma=2.2)
    res_debevec = tonemap.process(hdr_debevec)

现在我们必须将所有值clip成整数:

    res_8bit = np.clip(res_debevec * 255, 0, 255).astype('uint8')

之后,我们可以使用pyplot显示我们的结果 HDR 图像:

    plt.imshow(res_8bit)
    plt.show()

这会产生以下令人惊叹的图像:

现在,让我们看看如何扩展相机的视野—可能到 360 度!

理解全景拼接

计算摄影中另一个非常有趣的话题是全景拼接。我相信你们大多数人手机上都有全景功能。本节将专注于全景拼接背后的思想,而不仅仅是调用一个单独的函数,我们将通过所有创建全景所需的步骤。

编写脚本参数和过滤图像

我们想要编写一个脚本,该脚本将接受一系列图像并生成一张单独的全景图。因此,让我们为我们的脚本设置ArgumentParser

def parse_args():
    parser = argparse.ArgumentParser()
    img_group = parser.add_mutually_exclusive_group(required=True)
    img_group.add_argument('--image-dir', type=Path)
    img_group.add_argument('--images', type=Path, nargs='+')
    args = parser.parse_args()

    if args.image_dir:
        args.images = sorted(args.image_dir.iterdir())
    return args

在这里,我们创建了一个ArgumentParser的实例并添加了参数,以便传递图像目录或图像列表。然后,我们确保如果传递了图像目录,我们获取所有图像,而不是传递图像列表。

现在,正如你可以想象的那样,下一步是使用特征提取器并查看图像共享的共同特征。这非常类似于前两个章节,即第三章,通过特征匹配和透视变换寻找物体和第四章,使用运动结构进行 3D 场景重建。我们还将编写一个函数来过滤具有共同特征的图像,这样脚本就更加灵活。让我们一步一步地通过这个函数:

  1. 创建SURF特征提取器并计算所有图像的所有特征:
def largest_connected_subset(images):
    finder = cv2.xfeatures2d_SURF.create()
    all_img_features = [cv2.detail.computeImageFeatures2(finder, img)
                        for img in images]
  1. 创建一个matcher类,该类将图像与其最接近的邻居匹配,这些邻居共享最多的特征:
    matcher = cv2.detail.BestOf2NearestMatcher_create(False, 0.6)
    pair_matches = matcher.apply2(all_img_features)
    matcher.collectGarbage()
  1. 过滤图像并确保我们至少有两个共享特征的图像,这样我们就可以继续算法:
    _conn_indices = cv2.detail.leaveBiggestComponent(all_img_features, pair_matches, 0.4)
    conn_indices = [i for [i] in _conn_indices]
    if len(conn_indices) < 2:
        raise RuntimeError("Need 2 or more connected images.")

    conn_features = np.array([all_img_features[i] for i in conn_indices])
    conn_images = [images[i] for i in conn_indices]
  1. 再次运行matcher以检查我们是否删除了任何图像,并返回我们将来需要的变量:
    if len(conn_images) < len(images):
        pair_matches = matcher.apply2(conn_features)
        matcher.collectGarbage()

    return conn_images, conn_features, pair_matches

在我们过滤了图像并有了所有特征之后,我们继续下一步,即设置空白画布进行全景拼接。

确定相对位置和最终图片大小

一旦我们分离了所有连接的图片并知道了所有特征,就到了确定合并全景的大小并创建空白画布以开始添加图片的时候了。首先,我们需要找到图片的参数。

寻找相机参数

为了能够合并图像,我们需要计算所有图像的透视矩阵,然后使用这些矩阵调整图像,以便它们可以合并在一起。我们将编写一个函数来完成这项工作:

  1. 首先,我们将创建HomographyBasedEstimator()函数:
def find_camera_parameters(features, pair_matches):
    estimator = cv2.detail_HomographyBasedEstimator()
  1. 一旦我们有了estimator,用于提取所有相机参数,我们就使用来自不同图像的匹配features
    success, cameras = estimator.apply(features, pair_matches, None)
    if not success:
        raise RuntimeError("Homography estimation failed.")
  1. 我们确保R矩阵具有正确的类型:
    for cam in cameras:
        cam.R = cam.R.astype(np.float32)
  1. 然后,我们返回所有参数:
    return cameras

可以使用细化器(例如,cv2.detail_BundleAdjusterRay)来改进这些参数,但现在我们保持简单。

创建全景图的画布

现在是时候创建画布了。为此,我们根据所需的旋转方案创建一个warper对象。为了简单起见,让我们假设一个平面模型:

    warper = cv2.PyRotationWarper('plane', 1)

然后,我们遍历所有连接的图像,并获取每张图像中的所有感兴趣区域:

    stitch_sizes, stitch_corners = [], []
    for i, img in enumerate(conn_images):
        sz = img.shape[1], img.shape[0]
        K = cameras[i].K().astype(np.float32)
        roi = warper.warpRoi(sz, K, cameras[i].R)
        stitch_corners.append(roi[0:2])
        stitch_sizes.append(roi[2:4])

最后,我们根据所有感兴趣区域估计最终的canvas_size

    canvas_size = cv2.detail.resultRoi(corners=stitch_corners, sizes=stitch_sizes)

现在,让我们看看如何使用画布大小来混合所有图像。

合并图像

首先,我们创建一个MultiBandBlender对象,这将帮助我们合并图像。blender不会仅仅从一张或另一张图像中选取值,而是会在可用的值之间进行插值:

    blender = cv2.detail_MultiBandBlender()
    blend_width = np.sqrt(canvas_size[2] * canvas_size[3]) * 5 / 100
    blender.setNumBands((np.log(blend_width) / np.log(2.) - 1.).astype(np.int))
    blender.prepare(canvas_size)

然后,对于每个连接的图像,我们执行以下操作:

  1. 我们warp图像并获取corner位置:
    for i, img in enumerate(conn_images):
        K = cameras[i].K().astype(np.float32)
        corner, image_wp = warper.warp(img, K, cameras[i].R,
                                       cv2.INTER_LINEAR, cv2.BORDER_REFLECT)
  1. 然后,计算画布上图像的mask
        mask = 255 * np.ones((img.shape[0], img.shape[1]), np.uint8)
        _, mask_wp = warper.warp(mask, K, cameras[i].R,
                                 cv2.INTER_NEAREST, cv2.BORDER_CONSTANT)
  1. 之后,将值转换为np.int16并将其feedblender中:
        image_warped_s = image_wp.astype(np.int16)
        blender.feed(cv2.UMat(image_warped_s), mask_wp, stitch_corners[i])
  1. 之后,我们在blender上使用blend函数来获取最终的result,并保存它:
    result, result_mask = blender.blend(None, None)
    cv2.imwrite('result.jpg', result)

我们还可以将图像缩小到 600 像素宽并显示:

    zoomx = 600.0 / result.shape[1]
    dst = cv2.normalize(src=result, dst=None, alpha=255.,
                        norm_type=cv2.NORM_MINMAX, dtype=cv2.CV_8U)
    dst = cv2.resize(dst, dsize=None, fx=zoomx, fy=zoomx)
    cv2.imshow('panorama', dst)
    cv2.waitKey()

当我们使用来自github.com/mamikonyana/yosemite-panorama的图像时,我们最终得到了这张精彩的全景图片:

你可以看到它并不完美,白平衡需要从一张图片到另一张图片进行校正,但这是一个很好的开始。在下一节中,我们将致力于改进拼接输出。

改进全景拼接

你可以玩弄我们已有的脚本,添加或删除某些功能(例如,你可以添加一个白平衡补偿器,以确保从一张图片到另一张图片的过渡更加平滑),或者调整其他参数来学习。

但要知道——当你需要快速全景时,OpenCV 还有一个方便的Stitcher类,它已经完成了我们讨论的大部分工作:

    images = [load_image(p, bps=8) for p in args.images]

    stitcher = cv2.Stitcher_create()
    (status, stitched) = stitcher.stitch(images)

这段代码片段可能比将你的照片上传到全景服务以获得好图片要快得多——所以享受创建全景吧!

不要忘记添加一些代码来裁剪全景,以免出现黑色像素!

摘要

在本章中,我们学习了如何使用有限能力的相机拍摄简单的图像——无论是有限的动态范围还是有限的视野,然后使用 OpenCV 将多张图像合并成一张比原始图像更好的单张图像。

我们留下了三个你可以在此基础上构建的脚本。最重要的是,panorama.py中仍然缺少很多功能,还有很多其他的 HDR 技术。最好的是,可以同时进行 HDR 和全景拼接。想象一下,在日落时分从山顶四处张望,那将是多么美妙!

这是关于相机摄影的最后一章。本书的其余部分将专注于视频监控并将机器学习技术应用于图像处理任务。

在下一章中,我们将专注于跟踪场景中视觉上显著和移动的物体。这将帮助你了解如何处理非静态场景。我们还将探讨如何让算法快速关注场景中的重点,这是一种已知可以加速目标检测、目标识别、目标跟踪和内容感知图像编辑的技术。

进一步阅读

在计算摄影学中还有许多其他主题可以探索:

  • 特别值得一看的是汤姆·梅滕斯等人开发的曝光融合技术。汤姆·梅滕斯、简·考茨和弗兰克·范·里特撰写的《曝光融合》文章,发表于《计算机图形学与应用》,2007 年,太平洋图形学 2007,第 15 届太平洋会议论文集,第 382-390 页,IEEE,2007 年。

  • 由保罗·E·德贝维克和吉滕德拉·马利克撰写的《从照片中恢复高动态范围辐射图》文章,收录于 ACM SIGGRAPH 2008 课程中,2008 年,第 31 页,ACM,2008 年。

归属

Frozen River 照片集可在github.com/mamikonyana/frozen-river找到,并经过 CC-BY-SA-4.0 许可验证。

第六章:跟踪视觉显著性物体

本章的目标是在视频序列中同时跟踪多个视觉显著性物体。我们不会自己标记视频中的感兴趣物体,而是让算法决定视频帧中哪些区域值得跟踪。

我们之前已经学习了如何在严格控制的情况下检测简单的感兴趣物体(如人手)以及如何从相机运动中推断视觉场景的几何特征。在本章中,我们将探讨通过观察大量帧的图像统计信息我们可以了解视觉场景的哪些内容。

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

  • 规划应用

  • 设置应用

  • 映射视觉显著性

  • 理解均值漂移跟踪

  • 了解 OpenCV 跟踪 API

  • 整合所有内容

通过分析自然图像的傅里叶频谱,我们将构建一个显著性图,它允许我们将图像中某些统计上有趣的区域标记为(潜在的或实际的)原型物体。然后我们将所有原型物体的位置输入到一个均值漂移跟踪器中,这将使我们能够跟踪物体从一个帧移动到下一个帧的位置。

开始使用

本章使用OpenCV 4.1.0,以及额外的包NumPy(www.numpy.org)、wxPython 2.8(www.wxpython.org/download.php)和matplotlib(www.matplotlib.org/downloads.html)。尽管本章中提出的部分算法已被添加到OpenCV 3.0.0版本的可选显著性模块中,但目前还没有 Python API,因此我们将编写自己的代码。

本章的代码可以在书的 GitHub 仓库中找到,仓库地址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter6

理解视觉显著性

视觉显著性是来自认知心理学的一个术语,试图描述某些物体或项目的视觉质量,使其能够立即吸引我们的注意力。我们的大脑不断引导我们的目光向视觉场景中的重要区域,并在一段时间内跟踪它们,使我们能够快速扫描周围环境中的有趣物体和事件,同时忽略不那么重要的部分。

下面是一个常规 RGB 图像及其转换为显著性图的示例,其中统计上有趣的突出区域显得明亮,而其他区域则显得暗淡:

图片

傅里叶分析将使我们能够对自然图像统计有一个一般性的了解,这将帮助我们构建一个关于一般图像背景外观的模型。通过将背景模型与特定图像帧进行比较和对比,我们可以定位图像中突出其周围环境的子区域(如图中所示的前一个屏幕截图)。理想情况下,这些子区域对应于当我们观察图像时,往往会立即吸引我们注意力的图像块。

传统模型可能会尝试将特定的特征与每个目标关联起来(类似于我们在第三章中介绍的特征匹配方法,通过特征匹配和透视变换查找对象),这将把问题转化为检测特定类别对象的问题。然而,这些模型需要手动标记和训练。但如果要跟踪的特征或对象的数量是未知的呢?

相反,我们将尝试模仿大脑的工作方式,即调整我们的算法以适应自然图像的统计特性,这样我们就可以立即定位视觉场景中“吸引我们的注意力”的图案或子区域(即偏离这些统计规律的模式)并将它们标记出来进行进一步检查。结果是这样一个算法,它可以适用于场景中任何数量的原型对象,例如跟踪足球场上的所有球员。请参考以下一系列屏幕截图以查看其效果:

图片

正如我们在这四个屏幕截图中所看到的,一旦定位到图像中所有潜在的“有趣”的块,我们可以使用一种简单而有效的方法——对象 均值漂移跟踪——来跟踪它们在多个帧中的运动。由于场景中可能存在多个可能随时间改变外观的原型对象,我们需要能够区分它们并跟踪所有这些对象。

规划应用程序

要构建应用程序,我们需要结合之前讨论的两个主要功能——显著性图和对象跟踪最终的应用程序将把视频序列的每个 RGB 帧转换为显著性图,提取所有有趣的原始对象,并将它们输入到均值漂移跟踪算法中。为此,我们需要以下组件:

  • main: 这是主函数例程(在chapter6.py中),用于启动应用程序。

  • saliency.py: 这是一个模块,用于从 RGB 彩色图像生成显著性图和原型对象图。它包括以下功能:

    • get_saliency_map: 这是一个函数,用于将 RGB 彩色图像转换为显著性图。

    • get_proto_objects_map: 这是一个函数,用于将显著性图转换为包含所有原型对象的二值掩码。

    • plot_power_density: 这是一个函数,用于显示 RGB 彩色图像的二维功率密度,这有助于理解傅里叶变换。

    • plot_power_spectrum:这是一个用于显示 RGB 颜色图像的径向平均功率谱的函数,有助于理解自然图像统计信息。

    • MultiObjectTracker:这是一个使用均值漂移跟踪在视频中跟踪多个对象的类。它包括以下公共方法:

      • MultiObjectTracker.advance_frame:这是一个用于更新新帧跟踪信息的方法,它使用当前帧的显著性图上的均值漂移算法来更新从前一帧到当前帧的框的位置。

      • MultiObjectTracker.draw_good_boxes:这是一个用于展示当前帧跟踪结果的方法。

在以下章节中,我们将详细讨论这些步骤。

设置应用程序

为了运行我们的应用程序,我们需要执行main函数,该函数读取视频流的一帧,生成显著性图,提取原对象的定位,并从一帧跟踪到下一帧。

让我们在下一节学习main函数的常规操作。

实现主函数

主要流程由chapter6.py中的main函数处理,该函数实例化跟踪器(MultipleObjectTracker)并打开显示场地上足球运动员数量的视频文件:

import cv2
from os import path

from saliency import get_saliency_map, get_proto_objects_map
from tracking import MultipleObjectsTracker

def main(video_file='soccer.avi', roi=((140, 100), (500, 600))):
    if not path.isfile(video_file):
        print(f'File "{video_file}" does not exist.')
        raise SystemExit

    # open video file
    video = cv2.VideoCapture(video_file)

    # initialize tracker
    mot = MultipleObjectsTracker()

函数将逐帧读取视频并提取一些有意义的感兴趣区域(用于说明目的):

    while True: 
        success, img = video.read() 
        if success: 
            if roi: 
                # grab some meaningful ROI 
                img = img[roi[0][0]:roi[1][0], 
                     roi[0][1]:roi[1][1]] 

然后,感兴趣区域将被传递到一个函数,该函数将生成该区域的显著性图。然后,基于显著性图生成有趣的原对象,最后将它们与感兴趣区域一起输入到跟踪器中。跟踪器的输出是带有边界框的标注输入区域,如前一组截图所示:

        saliency = get_saliency_map(img, use_numpy_fft=False,
                                    gauss_kernel=(3, 3))
        objects = get_proto_objects_map(saliency, use_otsu=False)
        cv2.imshow('tracker', mot.advance_frame(img, objects))

应用程序将运行到视频文件结束或用户按下q键为止:

if cv2.waitKey(100) & 0xFF == ord('q'): 
    break 

在下一节中,我们将了解MultiObjectTracker类。

理解MultiObjectTracker

跟踪器类的构造函数很简单。它所做的只是设置均值漂移跟踪的终止条件,并存储后续计算步骤中要考虑的最小轮廓面积(min_area)和按对象大小归一化的最小平均速度(min_speed_per_pix)的条件:

    def __init__(self, min_object_area: int = 400,
                 min_speed_per_pix: float = 0.02):
        self.object_boxes = []
        self.min_object_area = min_object_area
        self.min_speed_per_pix = min_speed_per_pix
        self.num_frame_tracked = 0
        # Setup the termination criteria, either 100 iteration or move by at
        # least 1 pt
        self.term_crit = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
                          5, 1)

从那时起,用户可以调用advance_frame方法向跟踪器提供新的帧。

然而,在我们充分利用所有这些功能之前,我们需要了解图像统计信息以及如何生成显著性图。

映射视觉显著性

如本章前面所述,视觉显著性试图描述某些物体或项目的视觉质量,使它们能够吸引我们的即时注意力。我们的大脑不断引导我们的目光向视觉场景中的重要区域,就像在视觉世界的不同子区域上打闪光灯一样,使我们能够快速扫描周围环境中的有趣物体和事件,同时忽略不那么重要的部分。

人们认为,这是一种进化策略,用来应对在视觉丰富的环境中生活所带来的持续信息过载。例如,如果你在丛林中随意散步,你希望在欣赏你面前蝴蝶翅膀上复杂的颜色图案之前,就能注意到你左边灌木丛中的攻击性老虎。因此,视觉显著的物体具有从其周围跳出的显著特性,就像以下截图中的目标条形:

图片

识别使这些目标跳出的视觉质量可能并不总是微不足道的。如果你在彩色图像中查看左侧图像,你可能会立即注意到图像中唯一的红色条形。然而,如果你以灰度查看这张图像,目标条形可能有点难以找到(它是从上往下数的第四条,从左往右数的第五条)。

与颜色显著性类似,在右侧的图像中有一个视觉显著的条形。尽管左侧图像中的目标条形具有独特的颜色,而右侧图像中的目标条形具有独特的方向,但我们把这两个特征结合起来,突然独特的目标条形就不再那么突出:

图片

在前面的显示中,又有一条独特的条形,与其他所有条形都不同。然而,由于干扰物品的设计方式,几乎没有显著性来引导你找到目标条形。相反,你发现自己似乎在随机扫描图像,寻找有趣的东西。(提示:目标是图像中唯一的红色且几乎垂直的条形,从上往下数的第二行,从左往右数的第三列。)

你可能会问,这与计算机视觉有什么关系?实际上,关系很大。人工视觉系统像我们一样,会遭受信息过载的问题,只不过它们对世界的了解甚至比我们还少。 如果我们能从生物学中提取一些见解,并用它们来教我们的算法关于世界的一些知识呢?

想象一下你车上的仪表盘摄像头,它会自动聚焦于最相关的交通标志。想象一下作为野生动物观察站一部分的监控摄像头,它会自动检测和跟踪著名害羞的鸭嘴兽的出现,但会忽略其他一切。我们如何教会算法什么是重要的,什么不是?我们如何让那只鸭嘴兽“跳出”来?

因此,我们进入了傅里叶分析域

学习傅里叶分析

要找到图像的视觉显著子区域,我们需要查看其频谱。到目前为止,我们一直在空间域处理我们的图像和视频帧,即通过分析像素或研究图像强度在不同图像子区域中的变化。然而,图像也可以在频域中表示,即通过分析像素频率或研究像素在图像中出现的频率和周期性。

通过应用傅里叶变换,可以将图像从空间域转换到频域。在频域中,我们不再以图像坐标(xy)为思考单位。相反,我们的目标是找到图像的频谱。傅里叶的激进想法基本上可以归结为以下问题——如果任何信号或图像可以被转换成一系列圆形路径(也称为谐波),会怎样?

例如,想想彩虹。美丽,不是吗?在彩虹中,由许多不同颜色或光谱部分组成的白光被分散到其频谱中。在这里,当光线穿过雨滴(类似于白光穿过玻璃棱镜)时,太阳光的颜色频谱被暴露出来。傅里叶变换的目标就是要做到同样的事情——恢复阳光中包含的所有不同频谱部分。

对于任意图像,也可以实现类似的效果。与彩虹不同,彩虹中的频率对应于电磁频率,而在图像中,我们考虑的是空间频率,即像素值的空间周期性。在一个监狱牢房的图像中,你可以将空间频率视为(两个相邻监狱栏杆之间的)距离的倒数。

从这种视角转变中获得的见解非常强大。不深入细节,我们只需指出,傅里叶频谱既包含幅度也包含相位。幅度描述了图像中不同频率的数量/数量,而相位则讨论这些频率的空间位置。下面的截图显示了左边的自然图像和右边的相应的傅里叶幅度频谱(灰度版本的频谱):

图片

右侧的幅度频谱告诉我们,在左侧图像的灰度版本中,哪些频率成分是最突出的(明亮)的。频谱被调整,使得图像的中心对应于xy方向上的零频率。你越靠近图像的边缘,频率就越高。这个特定的频谱告诉我们,左侧的图像中有许多低频成分(集中在图像的中心附近)。

在 OpenCV 中,这个转换可以通过离散傅里叶变换DFT)来实现。让我们构建一个执行这个任务的函数。它包括以下步骤:

  1. 首先,如果需要,将图像转换为灰度图。该函数接受灰度和 RGB 彩色图像,因此我们需要确保我们在单通道图像上操作:
def calc_magnitude_spectrum(img: np.ndarray):
    if len(img.shape) > 2:
        img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
  1. ****我们调整图像到最佳尺寸。结果发现,DFT 的性能取决于图像大小。对于是 2 的倍数的图像大小,它通常运行得最快。因此,通常一个好的做法是在图像周围填充 0:
    rows, cols = img.shape
    nrows = cv2.getOptimalDFTSize(rows)
    ncols = cv2.getOptimalDFTSize(cols)
    frame = cv2.copyMakeBorder(img, 0, ncols-cols, 0, nrows-rows,
                               cv2.BORDER_CONSTANT, value=0)
  1. 然后我们应用 DFT。这是一个 NumPy 中的单个函数调用。结果是复数的二维矩阵:
img_dft = np.fft.fft2(img) 
  1. 然后,将实部和虚部值转换为幅度。一个复数有一个实部和虚部(虚数)部分。为了提取幅度,我们取绝对值:
magn = np.abs(img_dft) 
  1. 然后我们切换到对数尺度。结果发现,傅里叶系数的动态范围通常太大,无法在屏幕上显示。我们有一些低值和高值的变化,我们无法这样观察。因此,高值将全部显示为白色点,低值则显示为黑色点。

为了使用灰度值进行可视化,我们可以将我们的线性尺度转换为对数尺度:

log_magn = np.log10(magn)
  1. 然后我们进行象限平移,以便将频谱中心对准图像。这使得视觉检查幅度频谱更容易:
spectrum = np.fft.fftshift(log_magn) 
  1. 我们返回结果以进行绘图:
return spectrum/np.max(spectrum)*255 

结果可以用pyplot绘制。

现在我们已经了解了图像的傅里叶频谱以及如何计算它,让我们在下一节分析自然场景的统计信息。

理解自然场景的统计信息

人类的大脑很久以前就找到了如何专注于视觉上显著对象的方法。我们生活的自然世界有一些统计规律性,这使得它独特地自然,而不是棋盘图案或随机的公司标志。最常见的一种统计规律性可能是1/f定律。它表明自然图像集合的幅度遵循1/f分布(如下面的截图所示)。这有时也被称为尺度不变性

一个二维图像的一维功率谱(作为频率的函数)可以用以下plot_power_spectrum函数进行可视化。我们可以使用与之前使用的幅度谱相似的配方,但我们必须确保我们正确地将二维频谱折叠到单个轴上:

  1. 定义函数并在必要时将图像转换为灰度图(这与之前相同):
def plot_power_spectrum(frame: np.ndarray, use_numpy_fft=True) -> None:
    if len(frame.shape) > 2:
        frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
  1. 将图像扩展到其最佳尺寸(这与之前相同):
rows, cols = frame.shape
nrows = cv2.getOptimalDFTSize(rows) 
ncols = cv2.getOptimalDFTSize(cols) 
frame = cv2.copyMakeBorder(frame, 0, ncols-cols, 0, 
     nrows-rows, cv2.BORDER_CONSTANT, value = 0)
  1. 然后我们应用 DFT 并得到对数频谱。这里我们给用户一个选项(通过use_numpy_fft标志)来选择使用 NumPy 的或 OpenCV 的傅里叶工具:
    if use_numpy_fft:
        img_dft = np.fft.fft2(frame)
        spectrum = np.log10(np.real(np.abs(img_dft))**2)
    else:
        img_dft = cv2.dft(np.float32(frame), flags=cv2.DFT_COMPLEX_OUTPUT)
        spectrum = np.log10(img_dft[:, :, 0]**2 + img_dft[:, :, 1]**2)
  1. 我们接下来进行径向平均。这是比较棘手的部分。简单地沿 xy 方向平均二维频谱是错误的。我们感兴趣的是作为频率函数的频谱,与精确的取向无关。这有时也被称为径向平均功率谱RAPS)。

这可以通过从图像中心开始,向所有可能的(径向)方向求和所有频率的幅度来实现,从某个频率 rr+dr。我们使用 NumPy 的直方图函数的 binning 功能来求和数字,并将它们累积在 histo 变量中:

L = max(frame.shape) 
freqs = np.fft.fftfreq(L)[:L/2] 
dists = np.sqrt(np.fft.fftfreq(frame.shape[0])
     [:,np.newaxis]**2 + np.fft.fftfreq
         (frame.shape[1])**2) 
dcount = np.histogram(dists.ravel(), bins=freqs)[0] 
histo, bins = np.histogram(dists.ravel(), bins=freqs,
     weights=spectrum.ravel())
  1. 我们接下来绘制结果,最后,我们可以绘制 histo 中的累积数字,但不要忘记用 bin 大小(dcount)进行归一化:
centers = (bins[:-1] + bins[1:]) / 2 
plt.plot(centers, histo/dcount) 
plt.xlabel('frequency') 
plt.ylabel('log-spectrum') 
plt.show() 

结果是一个与频率成反比的函数。如果你想绝对确定 1/f 属性,你可以对所有的 x 值取 np.log10,并确保曲线以大致线性的方式下降。在线性 x 轴和对数 y 轴上,图表看起来如下截图所示:

图片

这个特性非常显著。它表明,如果我们平均所有自然场景的图像的频谱(当然忽略所有使用花哨图像滤镜拍摄的图像),我们会得到一个看起来非常像前面图像的曲线。

但是,回到平静小船在 Limmat 河上的图像,单张图像又如何呢? 我们刚刚看了这张图像的功率谱,并见证了 1/f 属性。我们如何利用我们对自然图像统计的了解来告诉算法不要盯着左边的树看,而是专注于在水中缓缓行驶的船呢? 以下照片描绘了 Limmat 河上的一个场景:

图片

这是我们真正意识到显著性真正含义的地方。

让我们看看如何在下一节中用频谱残差方法生成显著性图。

使用频谱残差方法生成显著性图

我们在图像中需要注意的事情不是遵循 1/f 法则的图像块,而是突出在平滑曲线之外的图像块,换句话说,是统计异常。这些异常被称为图像的频谱残差,对应于图像中可能有趣的块(或原对象)。显示这些统计异常为亮点的地图称为显著性图

这里描述的频谱残差方法基于 Xiaodi Hou 和 Liqing Zhang 于 2007 年发表的原科学出版物文章《显著性检测:频谱残差方法》(Saliency Detection: A Spectral Residual Approach),IEEE Transactions on Computer Vision and Pattern Recognition (CVPR),第 1-8 页,DOI:10.1109/CVPR.2007.383267。

单个通道的显著性图可以通过_get_channel_sal_magn函数使用以下过程生成。为了基于频谱残差方法生成显著性图,我们需要分别处理输入图像的每个通道(对于灰度输入图像是单个通道,对于 RGB 输入图像是三个单独的通道):

  1. 通过再次使用 NumPy 的fft模块或 OpenCV 功能来计算图像的(幅度和相位)傅里叶频谱:
def _calc_channel_sal_magn(channel: np.ndarray,
                           use_numpy_fft: bool = True) -> np.ndarray:
    if use_numpy_fft:
        img_dft = np.fft.fft2(channel)
        magnitude, angle = cv2.cartToPolar(np.real(img_dft),
                                           np.imag(img_dft))
    else:
        img_dft = cv2.dft(np.float32(channel),
                          flags=cv2.DFT_COMPLEX_OUTPUT)
        magnitude, angle = cv2.cartToPolar(img_dft[:, :, 0],
                                           img_dft[:, :, 1])
  1. 计算傅里叶频谱的对数幅度。我们将幅度下限裁剪到1e-9,以防止在计算对数时除以 0:
log_ampl = np.log10(magnitude.clip(min=1e-9)) 
  1. 通过与局部平均滤波器卷积来近似典型自然图像的平均光谱:
log_ampl_blur = cv2.blur(log_amlp, (3, 3)) 
  1. 计算频谱残差。频谱残差主要包含场景的非平凡(或意外)部分:
residual = np.exp(log_ampl - log_ampl_blur)
  1. 通过使用逆傅里叶变换来计算显著性图,再次通过 NumPy 中的fft模块或 OpenCV:
    if use_numpy_fft:
        real_part, imag_part = cv2.polarToCart(residual, angle)
        img_combined = np.fft.ifft2(real_part + 1j * imag_part)
        magnitude, _ = cv2.cartToPolar(np.real(img_combined),
                                       np.imag(img_combined))
    else:
        img_dft[:, :, 0], img_dft[:, :, 1] =%MCEPASTEBIN% cv2.polarToCart(residual,
                                                             angle)
        img_combined = cv2.idft(img_dft)
        magnitude, _ = cv2.cartToPolar(img_combined[:, :, 0],
                                       img_combined[:, :, 1])

    return magnitude

单通道显著性图(幅度)由get_saliency_map使用,对于输入图像的所有通道重复此过程。如果输入图像是灰度的,我们基本上就完成了:

def get_saliency_map(frame: np.ndarray,
                     small_shape: Tuple[int] = (64, 64),
                     gauss_kernel: Tuple[int] = (5, 5),
                     use_numpy_fft: bool = True) -> np.ndarray:
    frame_small = cv2.resize(frame, small_shape)
    if len(frame.shape) == 2:
        # single channelsmall_shape[1::-1]
        sal = _calc_channel_sal_magn(frame, use_numpy_fft)

然而,如果输入图像具有多个通道,例如 RGB 彩色图像,我们需要分别考虑每个通道:

    else:
        sal = np.zeros_like(frame_small).astype(np.float32)
        for c in range(frame_small.shape[2]):
            small = frame_small[:, :, c]
            sal[:, :, c] = _calc_channel_sal_magn(small, use_numpy_fft)

多通道图像的整体显著性由平均整体通道确定:

sal = np.mean(sal, 2) 

最后,我们需要应用一些后处理,例如可选的模糊阶段,以使结果看起来更平滑:

    if gauss_kernel is not None:
        sal = cv2.GaussianBlur(sal, gauss_kernel, sigmaX=8, sigmaY=0)

此外,我们还需要将sal中的值平方,以突出显示作者在原始论文中概述的高显著性区域。为了显示图像,我们将它缩放回原始分辨率并归一化值,使得最大值为 1。

接下来,将sal中的值归一化,使得最大值为 1,然后平方以突出显示作者在原始论文中概述的高显著性区域,最后将其缩放回原始分辨率以显示图像:

        sal = sal**2 
        sal = np.float32(sal)/np.max(sal) 
        sal = cv2.resize(sal, self.frame_orig.shape[1::-1]) 
    sal /= np.max(sal)
    return cv2.resize(sal ** 2, frame.shape[1::-1])

生成的显著性图看起来如下:

现在,我们可以清楚地看到水中的船(在左下角),它看起来是图像中最显著的子区域之一。还有其他显著的区域,例如右边的格罗斯穆斯特你猜到这个城市了吗?)。

顺便说一句,这两个区域是图像中最显著的,这似乎是明显的、无可争议的证据,表明算法意识到苏黎世市中心教堂塔楼的数量是荒谬的,有效地阻止了它们被标记为"显著的"。

在下一节中,我们将看到如何检测场景中的原型对象。

在场景中检测原型对象

在某种意义上,显著度图已经是一个原型对象的显式表示,因为它只包含图像的有趣部分。因此,现在我们已经完成了所有艰苦的工作,剩下的工作就是将显著度图进行阈值处理,以获得原型对象图。

这里唯一要考虑的开放参数是阈值。设置阈值过低会导致将许多区域标记为原型对象,包括可能不包含任何有趣内容的区域(误报)。另一方面,设置阈值过高会忽略图像中的大多数显著区域,并可能使我们没有任何原型对象。

原始光谱残差论文的作者选择仅将那些显著度大于图像平均显著度三倍的图像区域标记为原型对象。我们给用户提供了选择,要么实现这个阈值,要么通过将输入标志use_otsu设置为True来使用Otsu 阈值

def get_proto_objects_map(saliency: np.ndarray, use_otsu=True) -> np.ndarray: 

然后,我们将显著度转换为uint8精度,以便可以传递给cv2.threshold,设置阈值参数,最后应用阈值并返回原型对象:

    saliency = np.uint8(saliency * 255)
    if use_otsu:
        thresh_type = cv2.THRESH_OTSU
        # For threshold value, simply pass zero.
        thresh_value = 0
    else:
        thresh_type = cv2.THRESH_BINARY
        thresh_value = np.mean(saliency) * 3

    _, img_objects = cv2.threshold(saliency,
                                   thresh_value, 255, thresh_type)
    return img_objects

结果原型对象掩码看起来如下:

图片

原型对象掩码随后作为跟踪算法的输入,我们将在下一节中看到。

理解平均漂移跟踪

到目前为止,我们使用了之前讨论过的显著性检测器来找到原型对象的边界框。我们可以简单地将算法应用于视频序列的每一帧,并得到对象位置的不错概念。然而,丢失的是对应信息。

想象一个繁忙场景的视频序列,比如城市中心或体育场的场景。尽管显著度图可以突出显示记录视频每一帧中的所有原型对象,但算法将无法在上一帧的原型对象和当前帧的原型对象之间建立对应关系。

此外,原型对象映射可能包含一些误报,我们需要一种方法来选择最可能对应于真实世界对象的框。以下例子中可以注意到这些误报

图片

注意,从原型对象映射中提取的边界框在前面的例子中至少犯了三个错误——它没有突出显示一个球员(左上角),将两个球员合并到同一个边界框中,并突出显示了一些额外的可能不是有趣(尽管视觉上显著)的对象。为了改进这些结果并保持对应关系,我们想要利用跟踪算法。

为了解决对应问题,我们可以使用之前学过的方法,例如特征匹配和光流,但在这个情况下,我们将使用平均漂移算法进行跟踪。

均值漂移是一种简单但非常有效的追踪任意对象的技巧。均值漂移背后的直觉是将感兴趣区域(例如,我们想要追踪的对象的边界框)中的像素视为从描述目标的最佳概率密度函数中采样的。

例如,考虑以下图像:

图片

在这里,小的灰色点代表概率分布的样本。假设点越近,它们彼此越相似。直观地说,均值漂移试图做的是找到这个景观中最密集的区域,并在其周围画一个圆。算法可能最初将圆的中心放在景观中完全不密集的区域(虚线圆)。随着时间的推移,它将逐渐移动到最密集的区域(实线圆)并锚定在那里。

如果我们设计景观使其比点更有意义,我们可以使用均值漂移追踪来找到场景中的感兴趣对象。例如,如果我们为每个点分配一个值,表示对象的颜色直方图与相同大小的图像邻域的颜色直方图之间的对应关系,我们就可以在生成的点上使用均值漂移来追踪对象。通常与均值漂移追踪相关的是后一种方法。在我们的情况下,我们将简单地使用显著性图本身。

均值漂移有许多应用(如聚类或寻找概率密度函数的模态),但它也非常适合目标追踪。在 OpenCV 中,该算法在cv2.meanShift中实现,接受一个二维数组(例如,一个灰度图像,如显著性图)和窗口(在我们的情况下,我们使用对象的边界框)作为输入。它根据均值漂移算法返回窗口的新位置,如下所示:

  1. 它固定窗口位置。

  2. 它计算窗口内数据的平均值。

  3. 它将窗口移动到平均值并重复,直到收敛。我们可以通过指定终止条件来控制迭代方法的长度和精度。

接下来,让我们看看算法是如何追踪并在视觉上映射(使用边界框)场上的球员的。

自动追踪足球场上的所有球员

我们的目标是将显著性检测器与均值漂移追踪相结合,以自动追踪足球场上的所有球员。显著性检测器识别出的原型对象将作为均值漂移追踪器的输入。具体来说,我们将关注来自 Alfheim 数据集的视频序列,该数据集可以从home.ifi.uio.no/paalh/dataset/alfheim/免费获取。

将两个算法(显著性图和均值漂移跟踪)结合的原因是为了在不同帧之间保持对象之间的对应信息,以及去除一些误报并提高检测对象的准确性。

之前介绍过的MultiObjectTracker类及其advance_frame方法完成了这项艰苦的工作。每当有新帧到达时,就会调用advance_frame方法,并接受原型对象和显著性作为输入:

    def advance_frame(self,
                      frame: np.ndarray,
                      proto_objects_map: np.ndarray,
                      saliency: np.ndarray) -> np.ndarray:

以下步骤包含在本方法中:

  1. proto_objects_map创建轮廓,并找到面积大于min_object_area的所有轮廓的边界矩形。后者是使用均值漂移算法进行跟踪的候选边界框:
        object_contours, _ = cv2.findContours(proto_objects_map, 1, 2)
        object_boxes = [cv2.boundingRect(contour)
                        for contour in object_contours
                        if cv2.contourArea(contour) > self.min_object_area]
  1. 候选框可能不是在整个帧中跟踪的最佳选择。例如,在这种情况下,如果两个玩家彼此靠近,它们将导致一个单一的对象框。我们需要某种方法来选择最佳的框。我们可以考虑一些算法,该算法将分析从前一帧跟踪的框与从显著性获得的框结合起来,并推断出最可能的框。

但在这里我们将以简单的方式进行——如果显著性图中的框数量没有增加,则使用当前帧的显著性图跟踪从前一帧到当前帧的框,这些框被保存为objcect_boxes

        if len(self.object_boxes) >= len(object_boxes):
            # Continue tracking with meanshift if number of salient objects
            # didn't increase
            object_boxes = [cv2.meanShift(saliency, box, self.term_crit)[1]
                            for box in self.object_boxes]
            self.num_frame_tracked += 1
  1. 如果它确实增加了,我们将重置跟踪信息,即对象被跟踪的帧数以及对象初始中心的计算:
        else:
            # Otherwise restart tracking
            self.num_frame_tracked = 0
            self.object_initial_centers = [
                (x + w / 2, y + h / 2) for (x, y, w, h) in object_boxes]
  1. 最后,保存框并将在帧上绘制跟踪信息:
self.object_boxes = object_boxes
return self.draw_good_boxes(copy.deepcopy(frame))

我们对移动的框感兴趣。为此,我们计算每个框从跟踪开始时的初始位置的位移。我们假设在帧上出现更大的对象应该移动得更快,因此我们在框宽度上归一化位移:

    def draw_good_boxes(self, frame: np.ndarray) -> np.ndarray:
        # Find total displacement length for each object
        # and normalize by object size
        displacements = [((x + w / 2 - cx)**2 + (y + w / 2 - cy)**2)**0.5 / w
                         for (x, y, w, h), (cx, cy)
                         in zip(self.object_boxes, self.object_initial_centers)]

接下来,我们绘制框及其数量,这些框的平均每帧位移(或速度)大于我们在跟踪器初始化时指定的值。为了不在跟踪的第一帧上除以 0,我们添加了一个小的数值:

        for (x, y, w, h), displacement, i in zip(
                self.object_boxes, displacements, itertools.count()):
            # Draw only those which have some avarage speed
            if displacement / (self.num_frame_tracked + 0.01) > self.min_speed_per_pix:
                cv2.rectangle(frame, (x, y), (x + w, y + h),
                              (0, 255, 0), 2)
                cv2.putText(frame, str(i), (x, y),
                            cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255))
        return frame

现在你已经理解了如何使用均值漂移算法实现跟踪。这只是众多跟踪方法中的一种。当感兴趣的对象直接朝向相机快速改变大小时,均值漂移跟踪可能会特别失败。

对于此类情况,OpenCV 有一个不同的算法,cv2.CamShift,它还考虑了旋转和尺寸的变化,其中CAMShift代表连续自适应均值漂移。此外,OpenCV 提供了一系列可用的跟踪器,可以直接使用,被称为OpenCV 跟踪 API。让我们在下一节中了解它们。

了解 OpenCV 跟踪 API

我们已经将均值漂移算法应用于显著性图以跟踪显著对象。当然,世界上并非所有对象都是显著的,因此我们不能使用那种方法来跟踪任何对象。如前所述,我们还可以结合使用 HSV 直方图和均值漂移算法来跟踪对象。后者不需要显著性图——如果选择了区域,那种方法将尝试在后续帧中跟踪所选对象。

在本节中,我们将创建一个脚本,该脚本能够使用 OpenCV 中可用的跟踪算法在整个视频中跟踪一个对象。所有这些算法都有相同的 API,并统称为 OpenCV 跟踪 API。这些算法跟踪单个对象——一旦向算法提供了初始边界框,它将尝试在整个后续帧中维持该框的新位置。当然,也可以通过为每个对象创建一个新的跟踪器来跟踪场景中的多个对象。

首先,我们导入我们将使用的库并定义我们的常量:

import argparse
import time

import cv2
import numpy as np

# Define Constants
FONT = cv2.FONT_HERSHEY_SIMPLEX
GREEN = (20, 200, 20)
RED = (20, 20, 255)

OpenCV 目前有八个内置跟踪器。我们定义了一个所有跟踪器构造函数的映射:

trackers = {
    'BOOSTING': cv2.TrackerBoosting_create,
    'MIL': cv2.TrackerMIL_create,
    'KCF': cv2.TrackerKCF_create,
    'TLD': cv2.TrackerTLD_create,
    'MEDIANFLOW': cv2.TrackerMedianFlow_create,
    'GOTURN': cv2.TrackerGOTURN_create,
    'MOSSE': cv2.TrackerMOSSE_create,
    'CSRT': cv2.TrackerCSRT_create
}

我们的脚本将能够接受跟踪器的名称和视频的路径作为参数。为了实现这一点,我们创建参数,设置它们的默认值,并使用之前导入的argparse模块解析它们:

# Parse arguments
parser = argparse.ArgumentParser(description='Tracking API demo.')
parser.add_argument(
    '--tracker',
    default="KCF",
    help=f"One of {trackers.keys()}")
parser.add_argument(
    '--video',
    help="Video file to use",
    default="videos/test.mp4")
args = parser.parse_args()

然后,我们确保存在这样的跟踪器,并尝试从指定的视频中读取第一帧。

现在我们已经设置了脚本并可以接受参数,下一步要做的是实例化跟踪器:

  1. 首先,使脚本不区分大小写并检查传递的跟踪器是否存在是一个好主意:
tracker_name = args.tracker.upper()
assert tracker_name in trackers, f"Tracker should be one of {trackers.keys()}"
  1. 打开视频并读取第一。然后,如果无法读取视频,则中断脚本:
video = cv2.VideoCapture(args.video)
assert video.isOpened(), "Could not open video"
ok, frame = video.read()
assert ok, "Video file is not readable"
  1. 选择一个感兴趣的区域(使用边界框)以在整个视频中跟踪。OpenCV 为此提供了一个基于用户界面的实现:
bbox = cv2.selectROI(frame, False)

一旦调用此方法,将出现一个界面,您可以在其中选择一个框。一旦按下Enter键,就会返回所选框的坐标。

  1. 使用第一帧和选定的边界框启动跟踪器:
tracker = trackers[tracker_name]()
tracker.init(frame, bbox)

现在我们有一个跟踪器的实例,它已经使用第一帧和选定的感兴趣边界框启动。我们使用下一帧更新跟踪器以找到对象在边界框中的新位置。我们还使用time模块估计所选跟踪算法的每秒帧数FPS):

for ok, frame in iter(video.read, (False, None)): 
    # Time in seconds
    start_time = time.time()
    # Update tracker
    ok, bbox = tracker.update(frame)
    # Calcurlate FPS
    fps = 1 / (time.time() - start_time)

所有计算都在这一点上完成。现在我们展示每个迭代的计算结果:

    if ok:
        # Draw bounding box
        x, y, w, h = np.array(bbox, dtype=np.int)
        cv2.rectangle(frame, (x, y), (x + w, y + w), GREEN, 2, 1)
    else:
        # Tracking failure
        cv2.putText(frame, "Tracking failed", (100, 80), FONT, 0.7, RED, 2)
    cv2.putText(frame, f"{tracker_name} Tracker",
                (100, 20), FONT, 0.7, GREEN, 2)
    cv2.putText(frame, f"FPS : {fps:.0f}", (100, 50), FONT, 0.7, GREEN, 2)
    cv2.imshow("Tracking", frame)

    # Exit if ESC pressed
    if cv2.waitKey(1) & 0xff == 27:
        break

如果算法返回了边界框,我们在帧上绘制该框,否则,我们说明跟踪失败,这意味着所选算法未能找到当前帧中的对象。此外,我们在帧上键入跟踪器的名称和当前 FPS。

你可以用不同的算法在不同的视频上运行这个脚本,以查看算法的行为,特别是它们如何处理遮挡、快速移动的物体以及外观变化很大的物体。尝试了算法之后,你也可能对阅读算法的原始论文感兴趣,以了解实现细节。

为了使用这些算法跟踪多个物体,OpenCV 有一个方便的包装类,它结合了多个跟踪器实例并同时更新它们。为了使用它,首先,我们创建该类的实例:

multiTracker = cv2.MultiTracker_create()

接下来,对于每个感兴趣的边界框,创建一个新的跟踪器(在本例中为 MIL 跟踪器)并将其添加到multiTracker对象中:

for bbox in bboxes:
    multiTracker.add(cv2.TrackerMIL_create(), frame, bbox)

最后,通过用新帧更新multiTracker对象,我们获得了边界框的新位置:

success, boxes = multiTracker.update(frame)

作为练习,你可能想要用本章介绍的一种跟踪器替换应用程序中用于跟踪显著物体的 mean-shift 跟踪。为了做到这一点,你可以使用multiTracker与其中一个跟踪器一起更新原型物体的边界框位置。

整合所有内容

您可以在以下一组屏幕截图中看到我们应用程序的结果:

图片

在整个视频序列中,算法能够通过使用 mean-shift 跟踪识别球员的位置,并逐帧成功跟踪他们。

摘要

在本章中,我们探索了一种标记视觉场景中可能有趣的物体方法,即使它们的形状和数量未知。我们使用傅里叶分析探索了自然图像统计,并实现了一种从自然场景中提取视觉显著区域的方法。此外,我们将显著性检测器的输出与跟踪算法相结合,以跟踪足球比赛视频序列中未知形状和数量的多个物体。

我们介绍了 OpenCV 中可用的其他更复杂的跟踪算法,你可以用它们替换应用程序中的 mean-shift 跟踪,甚至创建自己的应用程序。当然,也可以用之前研究过的技术,如特征匹配或光流,来替换 mean-shift 跟踪器。

在下一章中,我们将进入迷人的机器学习领域,这将使我们能够构建更强大的物体描述符。具体来说,我们将专注于图像中街道标志的检测(位置)和识别(内容)。这将使我们能够训练一个分类器,它可以用于您汽车仪表盘上的摄像头,并使我们熟悉机器学习和物体识别的重要概念。

数据集归属

"足球视频和球员位置数据集," S. A. Pettersen, D. Johansen, H. Johansen, V. Berg-Johansen, V. R. Gaddam, A. Mortensen, R. Langseth, C. Griwodz, H. K. Stensland,P. Halvorsen,在 2014 年 3 月新加坡国际多媒体系统会议(MMSys)论文集中,第 18-23 页。

第七章:学习识别交通标志

我们之前研究了如何通过关键点和特征来描述对象,以及如何在两个不同图像中找到同一物理对象的对应点。然而,我们之前的方法在识别现实世界中的对象并将它们分配到概念类别方面相当有限。例如,在第二章,使用 Kinect 深度传感器进行手势识别中,图像中所需的对象是一只手,并且它必须放置在屏幕中央。如果我们可以去除这些限制会更好吗?

本章的目标是训练一个多类****分类器来识别交通标志。在本章中,我们将涵盖以下概念:

  • 规划应用

  • 监督学习概念的概述

  • 理解德国交通标志识别基准数据集(GTSRB

  • 了解数据集特征提取

  • 了解支持向量机SVMs

  • 整合所有内容

  • 使用神经网络提高结果

在本章中,你将学习如何将机器学习模型应用于现实世界的问题。你将学习如何使用现有的数据集来训练模型。你还将学习如何使用 SVMs 进行多类分类,以及如何使用 OpenCV 提供的机器学习算法进行训练、测试和改进,以实现现实世界任务。

我们将训练一个 SVM 来识别各种交通标志。尽管 SVMs 是二元分类器(也就是说,它们最多可以学习两个类别——正面和负面,动物和非动物等),但它们可以被扩展用于多类分类。为了实现良好的分类性能,我们将探索多个颜色空间,以及方向梯度直方图HOG)特征。最终结果将是一个能够从数据集中区分 40 多种不同标志的分类器,具有非常高的准确性。

学习机器学习的基础对于未来当你想要使你的视觉相关应用更加智能时将非常有用。本章将教你机器学习的基础知识,后续章节将在此基础上展开。

开始学习

GTSRB 数据集可以从benchmark.ini.rub.de/?section=gtsrb&subsection=dataset(见数据集归属部分以获取归属详情)免费获取。

你可以在我们的 GitHub 仓库中找到本章中展示的代码:github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter7

规划应用

为了得到这样一个多类分类器(可以区分数据集中超过 40 个不同的标志),我们需要执行以下步骤:

  1. 预处理数据集:我们需要一种方法来加载我们的数据集,提取感兴趣的区域,并将数据分为适当的训练集和测试集。

  2. 提取特征:原始像素值可能不是数据最有信息量的表示。我们需要一种方法从数据中提取有意义的特征,例如基于不同颜色空间和 HOG 的特征。

  3. 训练分类器:我们将使用一种一对多策略在训练数据上训练多类分类器。

  4. 评估分类器:我们将通过计算不同的性能指标来评估训练的集成分类器的质量,例如准确率精确度召回率

我们将在接下来的章节中详细讨论所有这些步骤。

最终的应用程序将解析数据集,训练集成分类器,评估其分类性能,并可视化结果。这需要以下组件:

  • main:主函数例程(在chapter7.py中)是启动应用程序所必需的。

  • datasets.gtsrb:这是一个解析 GTSRB 数据集的脚本。此脚本包含以下函数:

    • load_data:此函数用于加载 GTSRB 数据集,提取所需特征,并将数据分为训练集和测试集。

    • *_featurizehog_featurize:这些函数被传递给load_data以从数据集中提取所需特征。以下是一些示例函数:

      • gray_featurize:这是一个基于灰度像素值创建特征的函数。

      • surf_featurize:这是一个基于加速鲁棒特征SURF)创建特征的函数。

分类性能将基于准确率、精确率和召回率进行判断。以下章节将详细解释所有这些术语。

监督学习概念的概述

机器学习的一个重要子领域是监督学习。在监督学习中,我们试图从一组标记数据中学习——也就是说,每个数据样本都有一个期望的目标值或真实输出值。这些目标值可能对应于函数的连续输出(例如y = sin(x)中的y),或者对应于更抽象和离散的类别(例如)。

监督学习算法使用已经标记的训练数据,对其进行分析,并从特征到标签产生一个推断函数,该函数可以用于映射新的示例。理想情况下,推断算法将很好地泛化,并为新数据给出正确的目标值。

我们将监督学习任务分为两类:

  • 如果我们处理的是连续输出(例如,降雨的概率),这个过程被称为回归

  • 如果我们处理的是离散输出(例如,动物的物种),这个过程被称为分类

在本章中,我们专注于对 GTSRB 数据集图像进行标签的分类问题,我们将使用一种称为 SVM 的算法来推断图像与其标签之间的映射函数。

让我们先了解机器学习是如何赋予机器像人类一样学习的能力的。这里有一个提示——我们训练它们。

训练过程

例如,我们可能想要学习猫和狗的外观。为了使这个任务成为监督学习任务,首先,我们必须将其作为一个具有分类答案或实值答案的问题来提出。

这里有一些示例问题:

  • 给定的图片中展示了哪种动物?

  • 图片中有没有猫?

  • 图片中有没有狗?

之后,我们必须收集一个与其对应正确答案的示例图片——训练数据

然后,我们必须选择一个学习算法(学习者)并开始以某种方式调整其参数(学习算法),以便当学习者面对训练数据中的数据时,可以给出正确的答案。

我们重复这个过程,直到我们对学习者的性能或分数(可能是准确率精确率或某些其他成本函数)满意为止。如果我们不满意,我们将改变学习者的参数,以随着时间的推移提高分数。

这个过程在以下截图中有概述:

图片

从之前的截图可以看出,训练数据由一组特征表示。对于现实生活中的分类任务,这些特征很少是图像的原始像素值,因为这些往往不能很好地代表数据。通常,寻找最能描述数据的特征的过程是整个学习任务(也称为特征选择特征工程)的一个基本部分。

这就是为什么在考虑设置分类器之前,深入研究你正在处理的训练集的统计和外观总是一个好主意。

如你所知,有一个完整的学习者、成本函数和学习算法的动物园。这些构成了学习过程的核心。学习者(例如,线性分类器或 SVM)定义了如何将输入特征转换为评分函数(例如,均方误差),而学习算法(例如,梯度下降)定义了学习者的参数如何随时间变化。

在分类任务中的训练过程也可以被视为寻找一个合适的决策边界,这是一个将训练集最好地分成两个子集的线,每个类别一个。例如,考虑只有两个特征(x 和 y 值)以及相应的类别标签(正类(+),或负类())的训练样本。

在训练过程的开始,分类器试图画一条线来区分所有正样本和所有负样本。随着训练的进行,分类器看到了越来越多的数据样本。这些样本被用来更新决策边界,如下面的截图所示:

图片

与这个简单的插图相比,SVM 试图在高维空间中找到最优的决策边界,因此决策边界可能比直线更复杂。

我们现在继续了解测试过程。

测试过程

为了使训练好的分类器具有任何实际价值,我们需要知道它在应用于从未见过的数据样本(也称为泛化)时的表现。为了坚持我们之前展示的例子,我们想知道当我们向它展示一只猫或狗的以前未见过的图片时,分类器预测的是哪个类别。

更普遍地说,我们想知道在以下截图中的问号符号对应哪个类别,基于我们在训练阶段学习到的决策边界:

图片

从前面的截图,你可以看到这是一个棘手的问题。如果问号的位置更偏向左边,我们就可以确定相应的类别标签是+。

然而,在这种情况下,有几种方式可以绘制决策边界,使得所有的加号都在它的左边,所有的减号都在它的右边,如下面的截图所示:

图片

因此,问号的标签取决于在训练期间推导出的确切决策边界。如果前面截图中的问号实际上是减号,那么只有一个决策边界(最左边的)会得到正确的答案。一个常见的问题是训练可能导致一个在训练集上工作得太好的决策边界(也称为过拟合),但在应用于未见数据时犯了很多错误。

在那种情况下,学习者很可能会在决策边界上印刻了特定于训练集的细节,而不是揭示关于数据的一般属性,这些属性也可能适用于未见过的数据。

减少过拟合影响的一种常见技术被称为正则化

简而言之:问题总是回到找到最佳分割边界,这个边界不仅分割了训练集,也分割了测试集。这就是为什么分类器最重要的指标是其泛化性能(即它在训练阶段未见过的数据上的分类效果)。

为了将我们的分类器应用于交通标志识别,我们需要一个合适的数据集。一个好的选择可能是 GTSRB 数据集。让我们接下来了解一下它。

理解 GTSRB 数据集

GTSRB 数据集包含超过 50,000 张属于 43 个类别的交通标志图片。

该数据集在 2011 年国际神经网络联合会议IJCNN)期间被专业人士用于分类挑战。GTSRB 数据集非常适合我们的目的,因为它规模大、组织有序、开源且已标注。

尽管实际的交通标志不一定是一个正方形,也不一定位于每个图像的中心,但数据集附带了一个标注文件,指定了每个标志的边界框。

在进行任何类型的机器学习之前,通常一个好的想法是了解数据集、其质量和其挑战。一些好的想法包括手动浏览数据,了解其一些特征,阅读数据描述(如果页面上有)以了解哪些模型可能最适合,等等。

在这里,我们展示了data/gtsrb.py中的一个片段,该片段加载并绘制了训练数据集的随机 15 个样本,并重复 100 次,这样您就可以浏览数据:

if __name__ == '__main__':
    train_data, train_labels = load_training_data(labels=None)
    np.random.seed(75)
    for _ in range(100):
        indices = np.arange(len(train_data))
        np.random.shuffle(indices)
        for r in range(3):
            for c in range(5):
                i = 5 * r + c
                ax = plt.subplot(3, 5, 1 + i)
                sample = train_data[indices[i]]
                ax.imshow(cv2.resize(sample, (32, 32)), cmap=cm.Greys_r)
                ax.axis('off')
        plt.tight_layout()
        plt.show()
        np.random.seed(np.random.randint(len(indices)))

另一个不错的策略是绘制每个 43 个类别中的 15 个样本,看看图像如何随给定类别变化。以下截图显示了该数据集的一些示例:

图片

即使从这个小的数据样本来看,也立即清楚这是一个对任何类型的分类器都具有挑战性的数据集。标志的外观会根据观察角度(方向)、观察距离(模糊度)和光照条件(阴影和亮点)发生剧烈变化。

对于其中一些标志——例如第三行的第二个标志——即使是人类(至少对我来说),也很难立即说出正确的类别标签。我们作为机器学习的追求者真是件好事!

让我们现在学习如何解析数据集,以便将其转换为适合 SVM 用于训练的格式。

解析数据集

GTSRB 数据集包含 21 个文件,我们可以下载。我们选择使用原始数据以使其更具教育意义,并下载官方训练数据——图像和标注 (GTSRB_Final_Training_Images.zip) 用于训练,以及用于IJCNN 2011 比赛的官方训练数据集——图像和标注 (GTSRB-Training_fixed.zip) 用于评分。

以下截图显示了数据集的文件:

图片

我们选择分别下载训练数据和测试数据,而不是从其中一个数据集中构建自己的训练/测试数据,因为在探索数据后,通常会有 30 张来自不同距离的相同标志的图像看起来非常相似。将这些 30 张图像放入不同的数据集中将扭曲问题,并导致结果极好,尽管我们的模型可能无法很好地泛化。

以下代码是一个从哥本哈根大学数据档案下载数据的函数:

ARCHIVE_PATH = 'https://sid.erda.dk/public/archives/daaeac0d7ce1152aea9b61d9f1e19370/'

def _download(filename, *, md5sum=None):
    write_path = Path(__file__).parent / filename
    if write_path.exists() and _md5sum_matches(write_path, md5sum):
        return write_path
    response = requests.get(f'{ARCHIVE_PATH}/{filename}')
    response.raise_for_status()
    with open(write_path, 'wb') as outfile:
        outfile.write(response.content)
    return write_path

之前的代码接受一个文件名(您可以从之前的屏幕截图中看到文件及其名称),并检查该文件是否已存在(如果提供了md5sum,则检查是否匹配),这样可以节省大量带宽和时间,无需反复下载文件。然后,它下载文件并将其存储在包含代码的同一目录中。

标注格式可以在benchmark.ini.rub.de/?section=gtsrb&subsection=dataset#Annotationformat查看。

下载文件后,我们编写一个函数,使用与数据一起提供的标注格式解压缩并提取数据,如下所示:

  1. 首先,我们打开下载的.zip文件(这可能是指训练数据或测试数据),我们遍历所有文件,只打开包含对应类别中每个图像目标信息的.csv文件。这在上面的代码中显示如下:
def _load_data(filepath, labels):
    data, targets = [], []

    with ZipFile(filepath) as data_zip:
        for path in data_zip.namelist():
            if not path.endswith('.csv'):
                continue
            # Only iterate over annotations files
            ...
  1. 然后,我们检查图像的标签是否在我们感兴趣的labels数组中。然后,我们创建一个csv.reader,我们将使用它来遍历.csv文件内容,如下所示:
            ....
            # Only iterate over annotations files
            *dir_path, csv_filename = path.split('/')
            label_str = dir_path[-1]
            if labels is not None and int(label_str) not in labels:
                continue
            with data_zip.open(path, 'r') as csvfile:
                reader = csv.DictReader(TextIOWrapper(csvfile), delimiter=';')
                for img_info in reader:
                    ... 
  1. 文件的每一行都包含一个数据样本的标注。因此,我们提取图像路径,读取数据,并将其转换为 NumPy 数组。通常,这些样本中的对象并不是完美切割的,而是嵌入在其周围环境中。我们使用存档中提供的边界框信息来切割图像,每个标签使用一个.csv文件。在下面的代码中,我们将符号添加到data中,并将标签添加到targets中:
                    img_path = '/'.join([*dir_path, img_info['Filename']])
                    raw_data = data_zip.read(img_path)
                    img = cv2.imdecode(np.frombuffer(raw_data, np.uint8), 1)

                    x1, y1 = np.int(img_info['Roi.X1']), 
                    np.int(img_info['Roi.Y1'])
                    x2, y2 = np.int(img_info['Roi.X2']), 
                    np.int(img_info['Roi.Y2'])

                    data.append(img[y1: y2, x1: x2])
                    targets.append(np.int(img_info['ClassId']))

通常,执行某种形式的特征提取是可取的,因为原始图像数据很少是数据的最佳描述。我们将把这个任务推迟到另一个函数,我们将在稍后详细讨论。

如前一小节所述,将我们用于训练分类器的样本与用于测试的样本分开至关重要。为此,以下代码片段显示我们有两个不同的函数,用于下载训练数据和测试数据并将它们加载到内存中:

def load_training_data(labels):
    filepath = _download('GTSRB-Training_fixed.zip',
                         md5sum='513f3c79a4c5141765e10e952eaa2478')
    return _load_data(filepath, labels)

def load_test_data(labels):
    filepath = _download('GTSRB_Online-Test-Images-Sorted.zip',
                         md5sum='b7bba7dad2a4dc4bc54d6ba2716d163b')
    return _load_data(filepath, labels)

现在我们知道了如何将图像转换为 NumPy 矩阵,我们可以继续到更有趣的部分,即我们可以将数据输入到 SVM 中并对其进行训练以进行预测。所以,让我们继续到下一节,该节涵盖了特征提取。

学习数据集特征提取

很可能,原始像素值不是表示数据的最佳方式,正如我们在第三章,通过特征匹配和透视变换寻找对象中已经意识到的,我们需要从数据中推导出一个可测量的属性,这个属性对分类更有信息量。

然而,通常不清楚哪些特征会表现最好。相反,通常需要尝试不同的特征,这些特征是实践者认为合适的。毕竟,特征的选择可能强烈依赖于要分析的特定数据集或要执行的特定分类任务。

例如,如果你必须区分停车标志和警告标志,那么最显著的特征可能是标志的形状或颜色方案。然而,如果你必须区分两个警告标志,那么颜色和形状将完全帮不上忙,你需要想出更复杂一些的特征。

为了展示特征选择如何影响分类性能,我们将关注以下内容:

  • 一些简单的颜色变换(例如灰度;红色、绿色、蓝色RGB);以及色调、饱和度、亮度HSV)):基于灰度图像的分类将为我们提供分类器的基准性能。RGB 可能会因为某些交通标志独特的颜色方案而提供略好的性能。

预期 HSV 会有更好的性能。这是因为它比 RGB 更稳健地表示颜色。交通标志通常具有非常明亮、饱和的颜色,这些颜色(理想情况下)与周围环境非常不同。

  • SURF:到现在为止,这应该对你来说非常熟悉了。我们之前已经将 SURF 识别为从图像中提取有意义特征的一种高效且稳健的方法。那么,我们能否利用这种技术在分类任务中占得先机?

  • HOG:这是本章要考虑的最先进的特征描述符。该技术沿着图像上密集排列的网格计算梯度方向的出现次数,非常适合与 SVMs 一起使用。

特征提取是通过 data/process.py 文件中的函数完成的,我们将调用不同的函数来构建和比较不同的特征。

这里有一个很好的蓝图,如果你遵循它,将能够轻松地编写自己的特征化函数,并使用我们的代码,比较你的 your_featurize 函数是否能产生更好的结果:

def your_featurize(data: List[np.ndarry], **kwargs) -> np.ndarray: 
    ...

_featurize 函数接收一个图像列表并返回一个矩阵(作为二维 np.ndarray),其中每一行代表一个新的样本,每一列代表一个特征。

对于以下大多数特征,我们将使用 OpenCV 中(已经合适的)默认参数。然而,这些值并不是一成不变的,即使在现实世界的分类任务中,也经常需要在一个称为超参数探索的过程中,在特征提取和特征学习参数的可能值范围内进行搜索。

现在我们知道了我们在做什么,让我们看看一些基于前几节概念并添加了一些新概念的特性化函数。

理解常见的预处理

在我们查看我们得到的结果之前,让我们花时间看看在机器学习任务之前几乎总是应用于任何数据的两种最常见的前处理形式——即,均值减法归一化

均值减法是最常见的预处理形式(有时也称为零中心化或去均值),其中计算数据集中所有样本的每个特征维度的平均值。然后将这个特征维度的平均值从数据集中的每个样本中减去。你可以将这个过程想象为将数据的中心化在原点。

归一化是指对数据维度进行缩放,使它们大致具有相同的尺度。这可以通过将每个维度除以其标准差(一旦它已经被零中心化)或缩放每个维度使其位于[-1, 1]的范围内来实现。

只有在你有理由相信不同的输入特征有不同的尺度或单位时,才适用这一步骤。在图像的情况下,像素的相对尺度已经大致相等(并且在[0, 255]的范围内),因此执行这个额外的预处理步骤并不是严格必要的。

带着这两个概念,让我们来看看我们的特征提取器。

了解灰度特征

最容易提取的特征可能是每个像素的灰度值。通常,灰度值并不非常能说明它们所描述的数据,但在这里我们将包括它们以供说明之用(即,为了达到基线性能)。

对于输入集中的每个图像,我们将执行以下步骤:

  1. 将所有图像调整到相同的大小(通常是更小的尺寸)。我们使用scale_size=(32, 32)来确保我们不会使图像太小。同时,我们希望我们的数据足够小,以便在我们的个人电脑上处理。我们可以通过以下代码来实现:
resized_images = (cv2.resize(x, scale_size) for x in data)
  1. 将图像转换为灰度(值仍在 0-255 范围内),如下所示:
gray_data = (cv2.cvtColor(x, cv2.COLOR_BGR2GRAY) for x in resized_images)
  1. 将每个图像转换为具有(0, 1)范围内的像素值并展平,因此对于每个图像,我们有一个大小为1024的向量,如下所示:
scaled_data = (np.array(x).astype(np.float32).flatten() / 255 for x in gray_data)
  1. 从展平向量的平均像素值中减去,如下所示:
return np.vstack([x - x.mean() for x in scaled_data])

我们使用返回的矩阵作为机器学习算法的训练数据。

现在,让我们看看另一个例子——如果我们也使用颜色中的信息会怎样?

理解色彩空间

或者,你可能发现颜色包含一些原始灰度值无法捕捉的信息。交通标志通常有独特的色彩方案,这可能表明它试图传达的信息(例如,红色表示停车标志和禁止行为;绿色表示信息标志;等等)。我们可以选择使用 RGB 图像作为输入,但在我们的情况下,我们不必做任何事情,因为数据集已经是 RGB 的。

然而,即使是 RGB 可能也不够有信息量。例如,在晴朗的白天,一个停车标志可能非常明亮和清晰,但在雨天或雾天,其颜色可能看起来要暗淡得多。更好的选择可能是 HSV 颜色空间,它使用色调、饱和度和亮度(或亮度)来表示颜色。

在这个颜色空间中,交通标志的最显著特征可能是色调(对颜色或色相的更感知相关的描述),它提供了区分不同标志类型颜色方案的能力。然而,饱和度和亮度可能同样重要,因为交通标志倾向于使用相对明亮和饱和的颜色,这些颜色在自然场景中通常不会出现(即,它们的周围)。

在 OpenCV 中,将图像转换为 HSV 颜色空间只需要一个cv2.cvtColor调用,如下面的代码所示:

    hsv_data = (cv2.cvtColor(x, cv2.COLOR_BGR2HSV) for x in resized_images)

因此,总结一下,特征化几乎与灰度特征相同。对于每张图像,我们执行以下四个步骤:

  1. 将所有图像调整到相同(通常是较小的)大小。

  2. 将图像转换为 HSV(值在 0-255 范围内)。

  3. 将每个图像转换为具有(0,1)范围内的像素值,并将其展平。

  4. 从展平向量的平均像素值中减去。

现在,让我们尝试看一个使用 SURF 的更复杂的特征提取器的例子。

使用 SURF 描述符

但是等等!在第三章“通过特征匹配和透视变换查找对象”中,你了解到 SURF 描述符是描述图像独立于尺度或旋转的最佳和最鲁棒的方法之一。我们能否利用这项技术在分类任务中占得优势?

很高兴你问了!为了使这起作用,我们需要调整 SURF,使其为每张图像返回固定数量的特征。默认情况下,SURF 描述符仅应用于图像中的一小部分有趣的关键点,这些关键点的数量可能因图像而异。这对于我们的当前目的来说是不合适的,因为我们想要在每个数据样本中找到固定数量的特征值。

相反,我们需要将 SURF 应用于图像上铺设的固定密集网格,为此我们创建了一个包含所有像素的关键点数组,如下面的代码块所示:

def surf_featurize(data, *, scale_size=(16, 16)):
    all_kp = [cv2.KeyPoint(float(x), float(y), 1)
              for x, y in itertools.product(range(scale_size[0]),
                                            range(scale_size[1]))]

然后,我们可以为网格上的每个点获得 SURF 描述符,并将该数据样本附加到我们的特征矩阵中。我们像之前一样,使用hessianThreshold值为400初始化 SURF,如下所示:

    surf = cv2.xfeatures2d_SURF.create(hessianThreshold=400)

通过以下代码可以获得关键点和描述符:

    kp_des = (surf.compute(x, kp) for x in data)

因为surf.compute有两个输出参数,所以kp_des实际上将是关键点和描述符的连接。kp_des数组中的第二个元素是我们关心的描述符。

我们从每个数据样本中选择前num_surf_features个,并将其作为图像的特征返回,如下所示:

    return np.array([d.flatten()[:num_surf_features]
                     for _, d in kp_des]).astype(np.float32)

现在,让我们来看一个在社区中非常流行的概念——HOG。

映射 HOG 描述符

需要考虑的最后一种特征描述符是 HOG。之前的研究表明,HOG 特征与 SVMs 结合使用时效果非常好,尤其是在应用于行人识别等任务时。

HOG 特征背后的基本思想是,图像中对象的局部形状和外观可以通过边缘方向的分布来描述。图像被分成小的连通区域,在这些区域内,编译了梯度方向(或边缘方向)的直方图。

下面的截图显示了图片中的一个区域的直方图。角度不是方向性的;这就是为什么范围是(-180,180):

图片

如您所见,它在水平方向上有许多边缘方向(+180 度和-180 度左右的角),因此这似乎是一个很好的特征,尤其是在我们处理箭头和线条时。

然后,通过连接不同的直方图来组装描述符。为了提高性能,局部直方图可以进行对比度归一化,这有助于提高对光照和阴影变化的鲁棒性。您可以看到为什么这种预处理可能非常适合在不同视角和光照条件下识别交通标志。

通过cv2.HOGDescriptor在 OpenCV 中可以方便地访问 HOG 描述符,它接受检测窗口大小(32 x 32)、块大小(16 x 16)、单元格大小(8 x 8)和单元格步长(8 x 8)作为输入参数。对于这些单元格中的每一个,HOG 描述符然后使用九个桶计算 HOG,如下所示:

def hog_featurize(data, *, scale_size=(32, 32)):
    block_size = (scale_size[0] // 2, scale_size[1] // 2)
    block_stride = (scale_size[0] // 4, scale_size[1] // 4)
    cell_size = block_stride
    hog = cv2.HOGDescriptor(scale_size, block_size, block_stride,
                            cell_size, 9)
    resized_images = (cv2.resize(x, scale_size) for x in data)
    return np.array([hog.compute(x).flatten() for x in resized_images])

将 HOG 描述符应用于每个数据样本就像调用hog.compute一样简单。

在提取了我们想要的全部特征之后,我们为每张图像返回一个扁平化的列表。

现在,我们终于准备好在预处理后的数据集上训练分类器了。所以,让我们继续到 SVM。

学习 SVMs

SVM 是一种用于二元分类(和回归)的学习器,它试图通过最大化两个类别之间的间隔来分离来自两个不同类别标签的示例。

让我们回到正负数据样本的例子,每个样本恰好有两个特征(x 和 y)和两个可能的决策边界,如下所示:

图片

这两个决策边界都能完成任务。它们将所有正负样本分割开来,没有错误分类。然而,其中一个看起来直观上更好。我们如何量化“更好”,从而学习“最佳”参数设置?

这就是 SVMs 发挥作用的地方。SVMs 也被称为最大间隔分类器,因为它们可以用来做到这一点——定义决策边界,使得两个云团(+和-)尽可能远;也就是说,尽可能远离决策边界。

对于前面的例子,SVM 会在类别边缘(以下截图中的虚线)上的数据点找到两条平行线,然后将通过边缘中心的线作为决策边界(以下截图中的粗黑线):

图片

结果表明,为了找到最大间隔,只需要考虑位于类别边缘的数据点。这些点有时也被称为支持向量

除了执行线性分类(即决策边界是直线的情况)之外,SVM 还可以使用所谓的核技巧执行非线性分类,隐式地将它们的输入映射到高维特征空间。

现在,让我们看看我们如何将这个二元分类器转换成一个更适合我们试图解决的 43 个类别分类问题的多类分类器。

使用 SVM 进行多类分类

与一些分类算法(如神经网络)自然适用于使用多个类别不同,SVM 本质上是二元分类器。然而,它们可以被转换成多类分类器。

在这里,我们将考虑两种不同的策略:

  • 一对多一对多策略涉及为每个类别训练一个单独的分类器,该类别的样本作为正样本,所有其他样本作为负样本。

对于k个类别,这种策略因此需要训练k个不同的 SVM。在测试期间,所有分类器可以通过预测一个未见样本属于其类别来表示一个+1的投票。

最后,一个未见样本被集成分类器归类为获得最多投票的类别。通常,这种策略会与置信度分数结合使用,而不是预测标签,这样最终可以选取置信度分数最高的类别。

  • 一对一一对一策略涉及为每个类别对训练一个单独的分类器,第一个类别的样本作为正样本,第二个类别的样本作为负样本。对于k个类别,这种策略需要训练k*(k-1)/2个分类器。

然而,分类器必须解决一个显著更简单的问题,因此在考虑使用哪种策略时存在权衡。在测试期间,所有分类器可以为第一个或第二个类别表达一个+1的投票。最后,一个未见样本被集成分类器归类为获得最多投票的类别。

通常,除非你真的想深入研究算法并从你的模型中榨取最后一丝性能,否则你不需要编写自己的分类算法。幸运的是,OpenCV 已经内置了一个良好的机器学习工具包,我们将在本章中使用。OpenCV 使用一对多方法,我们将重点关注这种方法。

现在,让我们动手实践,看看我们如何使用 OpenCV 编写代码并获取一些实际结果。

训练 SVM

我们将把训练方法写入一个单独的函数中;如果我们以后想更改我们的训练方法,这是一个好的实践。首先,我们定义函数的签名,如下所示:

def train(training_features: np.ndarray, training_labels: np.ndarray):

因此,我们想要一个函数,它接受两个参数——training_featurestraining_labels——以及与每个特征对应的正确答案。因此,第一个参数将是一个二维 NumPy 数组的矩阵形式,第二个参数将是一个一维 NumPy 数组。

然后,函数将返回一个对象,该对象应该有一个 predict 方法,该方法接受新的未见数据并将其标记。所以,让我们开始,看看我们如何使用 OpenCV 训练 SVM。

我们将我们的函数命名为 train_one_vs_all_SVM,并执行以下操作:

  1. 使用 cv2.ml.SVM_create 实例化 SVM 类,它使用一对一策略创建一个多类 SVM,如下所示:
def train_one_vs_all_SVM(X_train, y_train):
    svm = cv2.ml.SVM_create()
  1. 设置学习器的超参数。这些被称为 超参数,因为这些参数超出了学习器的控制范围(与学习器在学习过程中更改的参数相对)。可以使用以下代码完成:
    svm.setKernel(cv2.ml.SVM_LINEAR)
    svm.setType(cv2.ml.SVM_C_SVC)
    svm.setC(2.67)
    svm.setGamma(5.383)
  1. 在 SVM 实例上调用 train 方法,OpenCV 会负责训练(使用 GTSRB 数据集,在普通笔记本电脑上这可能需要几分钟),如下所示:
    svm.train(X_train, cv2.ml.ROW_SAMPLE, y_train)
    return svm

OpenCV 会处理其余部分。在底层,SVM 训练使用 拉格朗日乘数来优化一些导致最大边缘决策边界的约束。

优化过程通常是在满足某些终止条件时进行的,这些条件可以通过 SVM 的可选参数指定。

现在我们已经了解了 SVM 的训练过程,让我们来看看如何测试它。

测试 SVM

评估分类器有许多方法,但最常见的是,我们通常只对准确率指标感兴趣——也就是说,测试集中有多少数据样本被正确分类。

为了得到这个指标,我们需要从 SVM 中获取预测结果——同样,OpenCV 为我们提供了 predict 方法,该方法接受一个特征矩阵并返回一个预测标签数组。因此,我们需要按照以下步骤进行:

  1. 因此,我们首先需要对我们的测试数据进行特征化:
        x_train = featurize(train_data)
  1. 然后,我们将特征化后的数据输入到分类器中,并获取预测标签,如下所示:
        y_predict = model.predict(x_test)
  1. 之后,我们可以尝试运行以下代码来查看分类器正确标记了多少个标签:
        num_correct = sum(y_predict == y_test)

现在,我们准备计算所期望的性能指标,这些指标将在后面的章节中详细描述。为了本章的目的,我们选择计算准确率、精确率和召回率。

scikit-learn 机器学习包(可在 scikit-learn.org 找到)直接支持三个指标——准确率、精确率和召回率(以及其他指标),并且还附带了许多其他有用的工具。出于教育目的(以及最小化软件依赖),我们将自己推导这三个指标。

准确率

计算最直接的指标可能是准确率。这个指标简单地计算预测正确的测试样本数量,并以总测试样本数的分数形式返回,如下面的代码块所示:

def accuracy(y_predicted, y_true):
    return sum(y_predicted == y_true) / len(y_true)

之前的代码显示,我们通过调用 model.predict(x_test) 提取了 y_predicted。这很简单,但为了使代码可重用,我们将它放在一个接受 predictedtrue 标签的函数中。现在,我们将继续实现一些更复杂的、有助于衡量分类器性能的指标。

混淆矩阵

混淆矩阵是一个大小为 (num_classes, num_classes) 的二维矩阵,其中行对应于预测的类别标签,列对应于实际的类别标签。然后,[r,c] 矩阵元素包含预测为标签 r 但实际上具有标签 c 的样本数量。通过访问混淆矩阵,我们可以计算精确率和召回率。

现在,让我们实现一种非常简单的方式来计算混淆矩阵。类似于准确率,我们创建一个具有相同参数的函数,这样就可以通过以下步骤轻松重用:

  1. 假设我们的标签是非负整数,我们可以通过取最高整数并加 1 来确定 num_classes,以考虑零,如下所示:
def confusion_matrix(y_predicted, y_true):
    num_classes = max(max(y_predicted), max(y_true)) + 1
    ...
  1. 接下来,我们实例化一个空的矩阵,我们将在这里填充计数,如下所示:
    conf_matrix = np.zeros((num_classes, num_classes))
  1. 接下来,我们遍历所有数据,对于每个数据点,我们取预测值 r 和实际值 c,然后在矩阵中增加相应的值。虽然有许多更快的方法来实现这一点,但没有什么比逐个计数更简单了。我们使用以下代码来完成这项工作:
    for r, c in zip(y_predicted, y_true):
        conf_matrix[r, c] += 1
  1. 在我们处理完训练集中的所有数据后,我们可以返回我们的混淆矩阵,如下所示:
    return conf_matrix
  1. 这是我们的 GTSRB 数据集测试数据的混淆矩阵:

图片

如您所见,大多数值都在对角线上。这意味着乍一看,我们的分类器表现相当不错。

  1. 从混淆矩阵中计算准确率也很容易。我们只需取对角线上的元素数量,然后除以总元素数量,如下所示:
    cm = confusion_matrix(y_predicted, y_true)
    accuracy = cm.trace() / cm.sum()  # 0.95 in this case.

注意,每个类别中的元素数量都不同。每个类别对准确率的贡献不同,我们的下一个指标将专注于每个类别的性能。

精确率

在二元分类中,精度是一个有用的指标,用于衡量检索到的实例中有多少是相关的(也称为阳性预测值)。在分类任务中,真阳性的数量被定义为正确标记为属于正类别的项目数量。

精度被定义为真阳性数量除以总阳性数量。换句话说,在测试集中,一个分类器认为包含猫的所有图片中,精度是实际包含猫的图片的比例。

注意,在这里,我们有一个正标签;因此,精度是每个类别的值。我们通常谈论一个类别的精度,或者猫的精度等等。

正确样本的总数也可以通过真阳性假阳性的总和来计算,后者是指被错误标记为属于特定类别的样本数量。这就是混淆矩阵派上用场的地方,因为它将允许我们通过以下步骤快速计算出假阳性和真阳性的数量:

  1. 因此,在这种情况下,我们必须更改我们的函数参数,并添加正类标签,如下所示:
def precision(y_predicted, y_true, positive_label):
    ...
  1. 让我们使用我们的混淆矩阵,并计算真阳性的数量,这将是在[positive_label, positive_label]位置的元素,如下所示:
    cm = confusion_matrix(y_predicted, y_true)
    true_positives = cm[positive_label, positive_label]
  1. 现在,让我们计算真阳性和假阳性的数量,这将等于positive_label行上所有元素的总和,因为该行表示预测的类别标签,如下所示:
    total_positives = sum(cm[positive_label])
  1. 最后,返回真阳性与所有正性的比率,如下所示:
    return true_positives / total_positives

根据不同的类别,我们得到非常不同的精度值。以下是所有 43 个类别的精度分数直方图:

精度较低的类别是 30,这意味着很多其他标志被错误地认为是以下截图中的标志:

在这种情况下,我们在结冰的道路上驾驶时格外小心是可以的,但可能我们错过了某些重要的事情。因此,让我们看看不同类别的召回值。

召回

召回与精度相似,因为它衡量的是检索到的相关实例的比例(而不是检索到的实例中有多少是相关的)。因此,它将告诉我们对于给定的正类(给定的标志),我们不会注意到它的概率。

在分类任务中,假阴性的数量是指那些没有被标记为属于正类别的项目,但实际上应该被标记的项目数量。

召回是真阳性数量除以真阳性和假阴性总数。换句话说,在世界上所有猫的图片中,召回是正确识别为猫的图片的比例。

这里是如何使用真实标签和预测标签来计算给定正标签的召回率的:

  1. 再次,我们与精度有相同的签名,并且以相同的方式检索真实正例,如下所示:
def recall(y_predicted, y_true, positive_label):
    cm = confusion_matrix(y_predicted, y_true)
    true_positives = cm[positive_label, positive_label]

现在,请注意,真实正例和假负例的总和是给定数据类中的点总数。

  1. 因此,我们只需计算该类别的元素数量,这意味着我们求混淆矩阵中positive_label列的和,如下所示:
    class_members = sum(cm[:, positive_label])
  1. 然后,我们像精度函数一样返回比率,如下所示:
    return true_positives / class_members

现在,让我们看看以下截图所示的所有 43 个交通标志类别的召回值分布:

图片

召回值分布得更广,第 21 个类别的值为 0.66。让我们检查哪个类别的值为 21:

图片

现在,这并不像在覆盖着雪花/冰的道路上驾驶那样有害,但非常重要,不要错过路上的危险弯道。错过这个标志可能会产生不良后果。

下一个部分将演示运行我们的应用程序所需的main()函数例程。

将所有这些放在一起

要运行我们的应用程序,我们需要执行主函数例程(在chapter6.py中)。这加载数据,训练分类器,评估其性能,并可视化结果:

  1. 首先,我们需要导入所有相关模块并设置main函数,如下所示:
import cv2
import numpy as np
import matplotlib.pyplot as plt

from data.gtsrb import load_training_data
from data.gtsrb import load_test_data
from data.process import grayscale_featurize, hog_featurize
  1. 然后,目标是比较不同特征提取方法的分类性能。这包括使用不同特征提取方法运行任务。因此,我们首先加载数据,并重复对每个特征化函数进行过程,如下所示:
def main(labels):
    train_data, train_labels = load_training_data(labels)
    test_data, test_labels = load_test_data(labels)
    y_train, y_test = np.array(train_labels), np.array(test_labels)
    accuracies = {}
    for featurize in [hog_featurize, grayscale_featurize, hsv_featurize, 
    surf_featurize]:
       ...

对于每个featurize函数,我们执行以下步骤:

    1. Featurize数据,以便我们有一个特征矩阵,如下所示:
        x_train = featurize(train_data)
    1. 使用我们的train_one_vs_all_SVM方法训练模型,如下所示:
        model = train_one_vs_all_SVM(x_train, y_train)
    1. 通过对测试数据进行特征化并将结果传递给predict方法(我们必须单独对测试数据进行特征化以确保没有信息泄露),为训练数据预测测试标签,如下所示:
        x_test = featurize(test_data)
        res = model.predict(x_test)
        y_predict = res[1].flatten()
    1. 我们使用accuracy函数对预测标签和真实标签进行评分,并将分数存储在字典中,以便在所有featurize函数的结果出来后进行绘图,如下所示:
        accuracies[featurize.__name__] = accuracy(y_predict, y_test)
  1. 现在,是时候绘制结果了,为此,我们选择了matplotlibbar图功能。我们还确保相应地缩放条形图,以便直观地理解差异的规模。由于准确度是一个介于01之间的数字,我们将y轴限制在[0, 1],如下所示:
    plt.bar(accuracies.keys(), accuracies.values())
    plt.ylim([0, 1])
  1. 我们通过在水平轴上旋转标签、添加gridtitle来为绘图添加一些漂亮的格式化,如下所示:
    plt.axes().xaxis.set_tick_params(rotation=20)
    plt.grid()
    plt.title('Test accuracy for different featurize functions')
    plt.show()
  1. 并且在执行plt.show()的最后一行之后,以下截图所示的绘图在单独的窗口中弹出:

图片

因此,我们看到hog_featurize在这个数据集上是一个赢家,但我们离完美的结果还远着呢——略高于 95%。要了解可能得到多好的结果,你可以快速进行一次谷歌搜索,你会找到很多实现 99%+精度的论文。所以,尽管我们没有得到最前沿的结果,但我们使用现成的分类器和简单的featurize函数做得相当不错。

另一个有趣的事实是,尽管我们认为具有鲜艳颜色的交通标志应该使用 hsv_featurize(它比灰度特征更重要),但事实并非如此。

因此,一个很好的经验法则是你应该对你的数据进行实验,以发展更好的直觉,了解哪些特征对你的数据有效,哪些无效。

说到实验,让我们用一个神经网络来提高我们获得的结果的效率。

使用神经网络提高结果

让我们快速展示一下,如果我们使用一些花哨的深度神经网络DNNs),我们可能会达到多好的水平,并给你一个关于本书未来章节内容的预览。

如果我们使用以下“不太深”的神经网络,在我的笔记本电脑上训练大约需要 2 分钟(而 SVM 的训练只需要 1 分钟),我们得到的准确率大约为 0.964!

这里是训练方法的一个片段(你应该能够将其插入到前面的代码中,并调整一些参数以查看你能否在以后做到):

def train_tf_model(X_train, y_train):
    model = tf.keras.models.Sequential([
        tf.keras.layers.Conv2D(20, (8, 8),
                               input_shape=list(UNIFORM_SIZE) + [3],
                               activation='relu'),
        tf.keras.layers.MaxPooling2D(pool_size=(4, 4), strides=4),
        tf.keras.layers.Dropout(0.15),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(64, activation='relu'),
        tf.keras.layers.Dropout(0.15),
        tf.keras.layers.Dense(43, activation='softmax')
    ])

    model.compile(optimizer='adam',
                  loss='sparse_categorical_crossentropy',
                  metrics=['accuracy'])
    model.fit(x_train, np.array(train_labels), epochs=10)
    return model

代码使用了 TensorFlow 的高级 Keras API(我们将在接下来的章节中看到更多),并创建了一个具有以下结构的神经网络:

  • 卷积层带有最大池化,后面跟着一个 dropout——它只在训练期间存在。

  • 隐藏密集层后面跟着一个 dropout——它只在训练期间存在。

  • 最终密集层输出最终结果;它应该识别输入数据属于哪个类别(在 43 个类别中)。

注意,我们只有一个卷积层,这与 HOG 特征化非常相似。如果我们增加更多的卷积层,性能会显著提高,但让我们把这一点留到下一章去探索。

摘要

在本章中,我们训练了一个多类分类器来识别 GTSRB 数据库中的交通标志。我们讨论了监督学习的基础,探讨了特征提取的复杂性,并简要介绍了深度神经网络(DNNs)。

使用本章中采用的方法,你应该能够将现实生活中的问题表述为机器学习模型,使用你的 Python 技能从互联网上下载一个标记的样本数据集,编写将图像转换为特征向量的特征化函数,并使用 OpenCV 来训练现成的机器学习模型,帮助你解决现实生活中的问题。

值得注意的是,我们在过程中省略了一些细节,例如尝试微调学习算法的超参数(因为它们超出了本书的范围)。我们只关注准确率分数,并没有通过尝试结合所有不同特征集进行很多特征工程。

在这个功能设置和对其底层方法论的充分理解下,你现在可以分类整个 GTSRB 数据集,以获得高于 0.97 的准确率!0.99 呢?这绝对值得查看他们的网站,在那里你可以找到各种分类器的分类结果。也许你的方法很快就会被添加到列表中。

在下一章中,我们将更深入地探讨机器学习的领域。具体来说,我们将专注于使用卷积神经网络CNNs)来识别人类面部表情。这一次,我们将结合分类器与一个目标检测框架,这将使我们能够在图像中找到人脸,然后专注于识别该人脸中包含的情感表情。

数据集归属

J. Stallkamp, M. Schlipsing, J. Salmen, and C. Igel, The German Traffic Sign Recognition Benchmark—A multiclass classification competition, in Proceedings of the IEEE International Joint Conference on Neural Networks, 2011, pages 1453–1460.

第八章:学习识别面部表情

我们之前已经熟悉了对象检测和对象识别的概念。在本章中,我们将开发一个能够同时执行这两项任务的应用程序。该应用程序将能够检测网络摄像头实时流中的每一帧捕获到的您的面部,识别您的面部表情,并在图形用户界面GUI)上对其进行标记。

本章的目标是开发一个结合面部检测面部识别的应用程序,重点关注识别检测到的面部表情。阅读本章后,您将能够在自己的不同应用程序中使用面部检测和识别。

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

  • 计划应用程序

  • 学习面部检测

  • 收集机器学习任务的数据

  • 理解面部表情识别

  • 整合所有内容

我们将简要介绍 OpenCV 附带的两项经典算法——Haar cascade 分类器MLPs。前者可以快速检测(或定位,并回答问题“在哪里?”)图像中各种大小和方向的物体,而后者可以用来识别它们(或识别,并回答问题“是什么?”)。

学习 MLPs 也是学习当今最流行算法之一——深度神经网络DNNs)的第一步。当我们没有大量数据时,我们将使用 PCA 来加速并提高算法的准确性,以改善我们模型的准确性。

我们将自行收集训练数据,以向您展示这个过程是如何进行的,以便您能够为没有现成数据的学习任务训练机器学习模型。不幸的是,没有合适的数据仍然是当今机器学习广泛采用的最大障碍之一。

现在,在我们动手之前,让我们先看看如何开始。

开始

您可以在我们的 GitHub 仓库中找到本章中展示的代码,网址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter8

除了这些,您应该从官方 OpenCV 仓库下载Haar cascade文件,网址为github.com/opencv/opencv/blob/master/data/haarcascades/,或者从您的机器安装目录复制它们到项目仓库中。

计划应用程序

可靠地识别面部和面部表情是人工智能AI)的一个具有挑战性的任务,然而人类能够轻松地完成这些任务。为了使我们的任务可行,我们将限制自己只关注以下有限的情绪表达:

  • Neutral

  • Happy

  • Sad

  • Surprised

  • Angry

  • Disgusted

今天的最先进模型范围从适合卷积神经网络(CNNs)的 3D 可变形人脸模型,到深度学习算法。当然,这些方法比我们的方法要复杂得多。

然而,多层感知器(MLP)是一种经典的算法,它帮助改变了机器学习的领域,因此出于教育目的,我们将坚持使用 OpenCV 附带的一系列算法。

要达到这样的应用,我们需要解决以下两个挑战:

  • 人脸检测:我们将使用 Viola 和 Jones 的流行 Haar 级联分类器,OpenCV 为此提供了一系列预训练的示例。我们将利用人脸级联和眼级联从帧到帧可靠地检测和校准面部区域。

  • 面部表情识别:我们将训练一个 MLP 来识别之前列出的六种不同的情感表达,在每一个检测到的人脸中。这种方法的成功将关键取决于我们组装的训练集,以及我们对每个样本选择的预处理。

为了提高我们自录制的训练集质量,我们将确保所有数据样本都通过仿射变换进行对齐,并通过应用主成分分析(PCA)来降低特征空间的维度。这种表示有时也被称为特征脸

我们将在一个端到端的单一应用中结合前面提到的算法,该应用将使用预训练模型在每个视频直播捕获的每一帧中为检测到的人脸标注相应的面部表情标签。最终结果可能看起来像以下截图,捕捉了我第一次运行代码时的样本反应:

图片

最终的应用将包括一个集成的端到端流程的主脚本——即从人脸检测到面部表情识别,以及一些辅助函数来帮助实现这一过程。

因此,最终产品将需要位于书籍 GitHub 仓库的chapter8/目录中的几个组件,如下所示:

  • chapter8.py: 这是本章的主要脚本和入口点,我们将用它来进行数据收集和演示。它将具有以下布局:

    • chapter8.FacialExpressionRecognizerLayout: 这是一个基于wx_gui.BaseLayout的自定义布局,它将在每个视频帧中检测面部,并使用预训练模型预测相应的类别标签。

    • chapter8.DataCollectorLayout: 这是一个基于wx_gui.BaseLayout的自定义布局,它将收集图像帧,检测其中的面部,使用用户选择的表情标签进行标记,并将帧保存到data/目录中。

  • wx_gui.py: 这是链接到我们在第一章,“滤镜乐趣”中开发的wxpython GUI 文件。

  • detectors.FaceDetector:这是一个将包含基于 Haar 级联的面部检测所有代码的类。它将有两个以下方法:

    • detect_face:这个方法用于检测灰度图像中的面部。可选地,图像会被缩小以提高可靠性。检测成功后,该方法返回提取的头区域。

    • align_head:这个方法通过仿射变换预处理提取的头区域,使得最终的面部看起来居中且垂直。

  • params/:这是一个包含我们用于本书的默认 Haar 级联的目录。

  • data/:我们将在这里编写所有存储和处理自定义数据的代码。代码被分为以下文件:

    • store.py:这是一个包含所有将数据写入磁盘和从磁盘将数据加载到计算机内存中的辅助函数的文件。

    • process.py:这是一个包含所有预处理数据的代码的文件,以便在保存之前。它还将包含从原始数据构建特征的代码。

在接下来的章节中,我们将详细讨论这些组件。首先,让我们看看面部检测算法。

学习关于面部检测

OpenCV 预装了一系列复杂的通用对象检测分类器。这些分类器都具有非常相似的 API,一旦你知道你在寻找什么,它们就很容易使用。可能最广为人知的检测器是用于面部检测的基于 Haar 特征的级联检测器,它最初由 Paul Viola 和 Michael Jones 在 2001 年发表的论文《使用简单特征增强级联的快速对象检测》中提出。

基于 Haar 特征检测器是一种在大量正负标签样本上训练的机器学习算法。在我们的应用中,我们将使用 OpenCV 附带的一个预训练分类器(你可以在“入门”部分找到链接)。但首先,让我们更详细地看看这个分类器是如何工作的。

学习关于基于 Haar 级联分类器

每本关于 OpenCV 的书至少应该提到 Viola-Jones 面部检测器。这个级联分类器在 2001 年发明,它颠覆了计算机视觉领域,因为它最终允许实时面部检测和面部识别。

这个分类器基于Haar-like 特征(类似于Haar 基函数),它们在图像的小区域内求和像素强度,同时捕捉相邻图像区域之间的差异。

下面的截图可视化了四个矩形特征。可视化旨在计算在某个位置应用的特征的值。你应该将暗灰色矩形中的所有像素值加起来,然后从这个值中减去白色矩形中所有像素值的总和:

在前面的截图上,第一行显示了两个边缘特征(即你可以用它们检测边缘)的示例,要么是垂直方向的(左上角)要么是 45 度角方向的(右上角)。第二行显示了线特征(左下角)和中心环绕特征(右下角)。

应用这些过滤器在所有可能的位置,允许算法捕捉到人类面部的一些细节,例如,眼睛区域通常比脸颊周围的区域要暗。

因此,一个常见的 Haar 特征将是一个暗色的矩形(代表眼睛区域)在亮色的矩形(代表脸颊区域)之上。将这个特征与一组旋转和略微复杂的小波结合,Viola 和 Jones 得到了一个强大的人脸特征描述符。在额外的智慧行为中,这些人想出了一个有效的方法来计算这些特征,这使得第一次能够实时检测到人脸。

最终分类器是多个小型弱分类器的加权总和,每个分类器的二进制分类器都基于之前描述的单一特征。最难的部分是确定哪些特征组合有助于检测不同类型的对象。幸运的是,OpenCV 包含了一系列这样的分类器。让我们在下一节中看看其中的一些。

理解预训练的级联分类器

更好的是,这种方法不仅适用于人脸,还适用于眼睛、嘴巴、全身、公司标志;你说的都对。在下表中,展示了一些可以在 OpenCV 安装路径下的data文件夹中找到的预训练分类器:

级联分类器类型 XML 文件名
面部检测器(默认) haarcascade_frontalface_default.xml
面部检测器(快速 Haar) haarcascade_frontalface_alt2.xml
眼睛检测器 haarcascade_eye.xml
嘴巴检测器 haarcascade_mcs_mouth.xml
鼻子检测器 haarcascade_mcs_nose.xml
全身检测器 haarcascade_fullbody.xml

在本章中,我们只使用haarcascade_frontalface_default.xmlhaarcascade_eye.xml

如果你戴着眼镜,请确保使用haarcascade_eye_tree_eyeglasses.xml进行眼睛检测。

我们首先将探讨如何使用级联分类器。

使用预训练的级联分类器

可以使用以下代码加载并应用于图像(灰度)的级联分类器:

import cv2

gray_img = cv2.cvtColor(cv2.imread('example.png'), cv2.COLOR_RGB2GRAY)

cascade_clf = cv2.CascadeClassifier('haarcascade_frontalface_default.xml')
faces = cascade_clf.detectMultiScale(gray_img,
                                     scaleFactor=1.1,
                                     minNeighbors=3,
                                     flags=cv2.CASCADE_SCALE_IMAGE)

从前面的代码中,detectMultiScale函数附带了一些选项:

  • minFeatureSize是考虑的最小人脸大小——例如,20 x 20 像素。

  • searchScaleFactor是我们重置图像(尺度金字塔)的量。例如,1.1的值将逐渐减小输入图像的大小 10%,使得具有更大值的脸(图像)更容易被找到。

  • minNeighbors是每个候选矩形必须保留的邻居数量。通常,我们选择35

  • flags是一个选项对象,用于调整算法——例如,是否寻找所有面部或仅寻找最大的面部(cv2.cv.CASCADE_FIND_BIGGEST_OBJECT)。

如果检测成功,函数将返回一个包含检测到的面部区域坐标的边界框列表(faces),如下所示:

for (x, y, w, h) in faces: 
    # draw bounding box on frame 
    cv2.rectangle(frame, (x, y), (x + w, y + h), (100, 255, 0), 
                  thickness=2) 

在之前的代码中,我们遍历返回的面部,并为每个面部添加一个厚度为2像素的矩形轮廓。

如果你预训练的面部级联没有检测到任何东西,一个常见的原因通常是预训练级联文件的路径找不到。在这种情况下,CascadeClassifier将静默失败。因此,始终检查返回的分类器casc = cv2.CascadeClassifier(filename)是否为空,通过检查casc.empty()

如果你在这张Lenna.png图片上运行代码,你应该得到以下结果:

图片

图片来源——Conor Lawless 的 Lenna.png,许可协议为 CC BY 2.0

从前面的截图来看,在左侧,你可以看到原始图像,在右侧是传递给 OpenCV 的图像以及检测到的面部的矩形轮廓。

现在,让我们尝试将这个检测器封装成一个类,使其适用于我们的应用。

理解FaceDetector

本章所有相关的面部检测代码都可以在detectors模块中的FaceDetector类中找到。在实例化时,这个类加载了两个不同的级联分类器,这些分类器用于预处理——即face_cascade分类器和eye_cascade分类器,如下所示:

import cv2 
import numpy as np 

class FaceDetector:

    def __init__(self, *,
                 face_cascade='params/haarcascade_frontalface_default.xml',
                 eye_cascade='params/haarcascade_lefteye_2splits.xml',
                 scale_factor=4):

由于我们的预处理需要一个有效的面部级联,我们确保文件可以被加载。如果不能,我们将抛出一个ValueError异常,这样程序将终止并通知用户出了什么问题,如下面的代码块所示:

        # load pre-trained cascades
        self.face_clf = cv2.CascadeClassifier(face_cascade)
        if self.face_clf.empty():
            raise ValueError(f'Could not load face cascade 
            "{face_cascade}"')

我们对眼睛分类器也做同样的事情,如下所示:

        self.eye_clf = cv2.CascadeClassifier(eye_cascade)
        if self.eye_clf.empty():
            raise ValueError(
                f'Could not load eye cascade "{eye_cascade}"')

面部检测在低分辨率灰度图像上效果最佳。这就是为什么我们也存储一个缩放因子(scale_factor),以便在必要时操作输入图像的缩放版本,如下所示:

self.scale_factor = scale_factor 

现在我们已经设置了类的初始化,让我们看看检测面部的算法。

在灰度图像中检测面部

现在,我们将之前章节中学到的内容放入一个方法中,该方法将接受一个图像并返回图像中的最大面部。我们返回最大面部是为了简化事情,因为我们知道在我们的应用中,将只有一个用户坐在摄像头前。作为一个挑战,你可以尝试将其扩展以处理多个面部!

我们将检测最大面部的方法称为detect_face。让我们一步一步地来看:

  1. 如同上一节所述,首先,我们将输入的 RGB 图像转换为灰度图,并通过运行以下代码按scale_factor进行缩放:
    def detect_face(self, rgb_img, *, outline=True):
        frameCasc = cv2.cvtColor(cv2.resize(rgb_img, (0, 0),
                                            fx=1.0 / 
                                            self.scale_factor,
                                            fy=1.0 / 
                                            self.scale_factor),
                                 cv2.COLOR_RGB2GRAY)
  1. 然后,我们在灰度图像中检测人脸,如下所示:
    faces = self.face_clf.detectMultiScale(
            frameCasc,
            scaleFactor=1.1,
            minNeighbors=3,
            flags=cv2.CASCADE_SCALE_IMAGE) * self.scale_factor
  1. 我们遍历检测到的人脸,如果将outline=True关键字参数传递给detect_face,则会绘制轮廓。OpenCV 返回给我们头部左上角的x, y坐标以及头部宽度和高度w, h。因此,为了构建轮廓,我们只需计算轮廓的底部和右侧坐标,然后调用cv2.rectangle函数,如下所示:
     for (x, y, w, h) in faces:
            if outline:
                cv2.rectangle(rgb_img, (x, y), (x + w, y + h), 
                              (100, 255, 0), thickness=2)
  1. 我们从原始 RGB 图像中裁剪出头部。如果我们想要对头部进行更多处理(例如,识别面部表情),可以运行以下代码:
        head = cv2.cvtColor(rgb_img[y:y + h, x:x + w],
                            cv2.COLOR_RGB2GRAY)
  1. 我们返回以下 4 元组:

    • 一个布尔值,用于检查检测是否成功

    • 添加了人脸轮廓的原图像(如果需要)

    • 根据需要裁剪的头像

    • 原图像中头部位置的坐标

  2. 在成功的情况下,我们返回以下内容:

         return True, rgb_img, head, (x, y)

在失败的情况下,我们返回没有找到头部的信息,并且对于任何不确定的事项,如这样返回None

        return False, rgb_img, None, (None, None)

现在,让我们看看在检测到人脸之后会发生什么,以便为机器学习算法做好准备。

预处理检测到的人脸

在检测到人脸之后,我们可能想要在对其进行分类之前先预处理提取的头像区域。尽管人脸级联检测相当准确,但对于识别来说,所有的人脸都必须是竖直且居中于图像中。

这是我们要达成的目标:

图片

图片来源——由 Conor Lawless 提供的 Lenna.png,许可协议为 CC BY 2.0

如前一个截图所示,由于这不是护照照片,模型中的头部略微向一侧倾斜,同时看向肩膀。人脸区域,如图像级联提取的,显示在前一个截图的中间缩略图中。

为了补偿检测到的盒子中头部朝向和位置,我们旨在旋转、移动和缩放面部,以便所有数据样本都能完美对齐。这是FaceDetector类中的align_head方法的工作,如下面的代码块所示:

    def align_head(self, head):
        desired_eye_x = 0.25
        desired_eye_y = 0.2
        desired_img_width = desired_img_height = 200

在前面的代码中,我们硬编码了一些用于对齐头部的参数。我们希望所有眼睛都在最终图像顶部下方 25%,并且距离左右边缘各 20%,此函数将返回一个头部处理后的图像,其固定大小为 200 x 200 像素。

处理流程的第一步是检测图像中眼睛的位置,之后我们将使用这些位置来构建必要的转换。

检测眼睛

幸运的是,OpenCV 自带了一些可以检测睁眼和闭眼的眼睛级联,例如haarcascade_eye.xml。这允许我们计算连接两个眼睛中心的线与地平线之间的角度,以便我们可以相应地旋转面部。

此外,添加眼睛检测器将降低我们数据集中出现假阳性的风险,只有当头部和眼睛都成功检测到时,我们才能添加数据样本。

FaceDetector构造函数中从文件加载眼睛级联后,它被应用于输入图像(head),如下所示:

        try:
            eye_centers = self.eye_centers(head)
        except RuntimeError:
            return False, head

如果我们失败,级联分类器找不到眼睛,OpenCV 将抛出一个RuntimeError。在这里,我们正在捕获它并返回一个(False, head)元组,表示我们未能对齐头部。

接下来,我们尝试对分类器找到的眼睛的引用进行排序。我们将left_eye设置为具有较低第一坐标的眼睛——即左侧的眼睛,如下所示:

        if eye_centers[0][0] < eye_centers[0][1]:
            left_eye, right_eye = eye_centers
        else:
            right_eye, left_eye = eye_centers

现在我们已经找到了两个眼睛的位置,我们想要弄清楚我们想要进行什么样的转换,以便将眼睛放置在硬编码的位置——即图像两侧的 25%和顶部以下的 25%。

转换面部

转换面部是一个标准过程,可以通过使用cv2.warpAffine(回忆第三章,通过特征匹配和透视变换查找对象)来实现。我们将遵循以下步骤来完成此转换:

  1. 首先,我们计算连接两个眼睛的线与水平线之间的角度(以度为单位),如下所示:
        eye_angle_deg = 180 / np.pi * np.arctan2(right_eye[1] 
                                                 - left_eye[1],
                                                 right_eye[0] 
                                                 - left_eye[0])
  1. 然后,我们推导出一个缩放因子,将两个眼睛之间的距离缩放到图像宽度的 50%,如下所示:
        eye_dist = np.linalg.norm(left_eye - right_eye)
        eye_size_scale = (1.0 - desired_eye_x * 2) * 
        desired_img_width / eye_dist
  1. 现在我们有了两个参数(eye_angle_degeye_size_scale),我们可以现在提出一个合适的旋转矩阵,将我们的图像转换,如下所示:
        eye_midpoint = (left_eye + right_eye) / 2
        rot_mat = cv2.getRotationMatrix2D(tuple(eye_midpoint), 
                                          eye_angle_deg,
                                          eye_size_scale)
  1. 接下来,我们将确保眼睛的中心将在图像中居中,如下所示:
        rot_mat[0, 2] += desired_img_width * 0.5 - eye_midpoint[0]
        rot_mat[1, 2] += desired_eye_y * desired_img_height - 
        eye_midpoint[1]
  1. 最后,我们得到了一个垂直缩放的图像,看起来像前一个截图中的第三张图像(命名为训练图像),如下所示:
        res = cv2.warpAffine(head, rot_mat, (desired_img_width,
                                             desired_img_width))
        return True, res

在这一步之后,我们知道如何从未处理图像中提取整齐、裁剪和旋转的图像。现在,是时候看看如何使用这些图像来识别面部表情了。

收集数据

面部表情识别管道封装在chapter8.py中。此文件包含一个交互式 GUI,在两种模式(训练测试)下运行,如前所述。

我们的应用程序被分成几个部分,如下所述:

  1. 使用以下命令从命令行以collect模式运行应用程序:
$ python chapter8.py collect

之前的命令将在数据收集模式下弹出一个 GUI,以组装一个训练集,

通过python train_classifier.py在训练集上训练一个 MLP 分类器。因为这个步骤可能需要很长时间,所以这个过程在自己的脚本中执行。训练成功后,将训练好的权重存储在文件中,以便我们可以在下一步加载预训练的 MLP。

  1. 然后,再次以以下方式在demo模式下运行 GUI,我们将能够看到在真实数据上面部识别的效果如何:
$ python chapter8.py demo

在这种模式下,你将有一个 GUI 来实时对实时视频流中的面部表情进行分类。这一步涉及到加载几个预训练的级联分类器以及我们的预训练 MLP 分类器。然后,这些分类器将被应用于每个捕获的视频帧。

现在,让我们看看如何构建一个用于收集训练数据的应用程序。

组装训练数据集

在我们能够训练一个 MLP 之前,我们需要组装一个合适的训练集。这是因为你的脸可能还不是任何现有数据集的一部分(国家安全局NSA)的私人收藏不算),因此我们将不得不自己组装。这可以通过回到前几章中的 GUI 应用程序来完成,该应用程序可以访问网络摄像头,并处理视频流的每一帧。

我们将继承wx_gui.BaseLayout并调整用户界面UI)以满足我们的喜好。我们将有两个类用于两种不同的模式。

GUI 将向用户展示以下六种情感表达之一的选项——即中性、快乐、悲伤、惊讶、愤怒和厌恶。点击按钮后,应用将捕捉到检测到的面部区域并添加到文件中的数据收集。

这些样本然后可以从文件中加载并用于在train_classifier.py中训练机器学习分类器,如步骤 2(之前给出)所述。

运行应用程序

正如我们在前几章中看到的那样,使用wxpython GUI,为了运行这个应用(chapter8.py),我们需要使用cv2.VideoCapture设置屏幕捕获,并将句柄传递给FaceLayout类。我们可以通过以下步骤来完成:

  1. 首先,我们创建一个run_layout函数,它将适用于任何BaseLayout子类,如下所示:
def run_layout(layout_cls, **kwargs):
    # open webcam
    capture = cv2.VideoCapture(0)
    # opening the channel ourselves, if it failed to open.
    if not(capture.isOpened()):
        capture.open()

    capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
    capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)

    # start graphical user interface
    app = wx.App()
    layout = layout_cls(capture, **kwargs)
    layout.Center()
    layout.Show()
    app.MainLoop()

如您所见,代码与之前章节中使用的wxpython代码非常相似。我们打开网络摄像头,设置分辨率,初始化布局,并启动应用程序的主循环。当你需要多次使用相同的函数时,这种类型的优化是好的。

  1. 接下来,我们设置一个参数解析器,它将确定需要运行哪两个布局之一,并使用适当的参数运行它。

为了在两种模式下都使用run_layout函数,我们使用argparse模块在我们的脚本中添加一个命令行参数,如下所示:

if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('mode', choices=['collect', 'demo'])
    parser.add_argument('--classifier')
    args = parser.parse_args()

我们使用了 Python 附带的argparse模块来设置参数解析器,并添加了具有collectdemo选项的参数。我们还添加了一个可选的--classifier参数,我们将在demo模式下使用它。

  1. 现在,我们使用用户传递的所有参数,以适当的参数调用run_layout函数,如下所示:
    if args.mode == 'collect':
        run_layout(DataCollectorLayout, title='Collect Data')
    elif args.mode == 'demo':
        assert args.svm is not None, 'you have to provide --svm'
        run_layout(FacialExpressionRecognizerLayout,
                   title='Facial Expression Recognizer',
                   classifier_path=args.classifier)

如前述代码所示,我们已设置在demo模式下传递额外的classifier_path参数。我们将在本章后面的部分讨论FacialExpresssionRecognizerLayout时看到它是如何被使用的。

现在我们已经建立了如何运行我们的应用程序,让我们构建 GUI 元素。

实现数据收集 GUI

与前几章的一些内容类似,该应用程序的 GUI 是通用BaseLayout的定制版本,如下所示:

import wx
from wx_gui import BaseLayout

class DataCollectorLayout(BaseLayout):

我们通过调用父类的构造函数开始构建 GUI,以确保它被正确初始化,如下所示:

    def __init__(self, *args,
                 training_data='data/cropped_faces.csv',
                 **kwargs):
        super().__init__(*args, **kwargs)

注意,我们在之前的代码中添加了一些额外的参数。这些参数是我们类中所有额外的属性,而父类中没有的属性。

在我们继续添加 UI 组件之前,我们还初始化了一个FaceDetector实例和用于存储数据的文件引用,如下所示:

        self.face_detector = FaceDetector(
            face_cascade='params/haarcascade_frontalface_default.xml',
            eye_cascade='params/haarcascade_eye.xml')
        self.training_data = training_data

注意,我们正在使用硬编码的级联 XML 文件。您可以随意尝试这些文件。

现在,让我们看看我们如何使用wxpython构建 UI。

增强基本布局

布局的创建再次推迟到名为augment_layout的方法中。我们尽可能保持布局简单。我们创建一个用于获取视频帧的面板,并在其下方绘制一排按钮。

然后点击六个单选按钮之一,以指示您要记录哪种面部表情,然后将头部放在边界框内,并点击Take Snapshot按钮。

因此,让我们看看如何构建六个按钮,并将它们正确地放置在wx.Panel对象上。相应的代码如下所示:

    def augment_layout(self):
        pnl2 = wx.Panel(self, -1)
        self.neutral = wx.RadioButton(pnl2, -1, 'neutral', (10, 10),
                                      style=wx.RB_GROUP)
        self.happy = wx.RadioButton(pnl2, -1, 'happy')
        self.sad = wx.RadioButton(pnl2, -1, 'sad')
        self.surprised = wx.RadioButton(pnl2, -1, 'surprised')
        self.angry = wx.RadioButton(pnl2, -1, 'angry')
        self.disgusted = wx.RadioButton(pnl2, -1, 'disgusted')
        hbox2 = wx.BoxSizer(wx.HORIZONTAL)
        hbox2.Add(self.neutral, 1)
        hbox2.Add(self.happy, 1)
        hbox2.Add(self.sad, 1)
        hbox2.Add(self.surprised, 1)
        hbox2.Add(self.angry, 1)
        hbox2.Add(self.disgusted, 1)
        pnl2.SetSizer(hbox2)

您可以看到,尽管代码量很大,但我们所写的大部分内容都是重复的。我们为每种情绪创建一个RadioButton,并将按钮添加到pnl2面板中。

Take Snapshot按钮放置在单选按钮下方,并将绑定到_on_snapshot方法,如下所示:

        # create horizontal layout with single snapshot button
        pnl3 = wx.Panel(self, -1)
        self.snapshot = wx.Button(pnl3, -1, 'Take Snapshot')
        self.Bind(wx.EVT_BUTTON, self._on_snapshot, self.snapshot)
        hbox3 = wx.BoxSizer(wx.HORIZONTAL)
        hbox3.Add(self.snapshot, 1)
        pnl3.SetSizer(hbox3)

如注释所示,我们创建了一个新的面板,并添加了一个带有Take Snapshot标签的常规按钮。重要的是,我们将按钮的点击事件绑定到self._on_snapshot方法,这样我们点击Take Snapshot按钮后,将处理捕获的每一张图片。

布局将如下截图所示:

图片

要使这些更改生效,需要将创建的面板添加到现有面板的列表中,如下所示:

        # arrange all horizontal layouts vertically
        self.panels_vertical.Add(pnl2, flag=wx.EXPAND | wx.BOTTOM, 
                                 border=1)
        self.panels_vertical.Add(pnl3, flag=wx.EXPAND | wx.BOTTOM, 
                                 border=1)

其余的可视化管道由BaseLayout类处理。

现在,让我们看看我们是如何在视频捕获中一旦人脸出现就使用process_frame方法添加边界框的。

处理当前帧

process_frame方法被调用在所有图像上,我们希望在视频流中出现人脸时显示一个围绕人脸的帧。如下所示:

    def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        _, frame, self.head, _ = self.face_detector.detect_face(frame_rgb)
        return frame

我们刚刚调用了在布局类的构造函数中创建的self.face_detector对象的FaceDetector.detect_face方法。记得从上一节中,它使用 Haar 级联在当前帧的降尺度灰度版本中检测人脸。

因此,如果我们识别出人脸,我们就添加一个帧;就是这样。现在,让我们看看我们是如何在_on_snapshot方法中存储训练图像的。

存储数据

当用户点击“拍摄快照”按钮,并调用_on_snapshot事件监听器方法时,我们将存储数据,如下所示:

    def _on_snapshot(self, evt):
        """Takes a snapshot of the current frame

           This method takes a snapshot of the current frame, preprocesses
           it to extract the head region, and upon success adds the data
           sample to the training set.
        """

让我们看看这个方法内部的代码,如下所示:

  1. 首先,我们通过找出哪个单选按钮被选中来确定图像的标签,如下所示:
        if self.neutral.GetValue():
            label = 'neutral'
        elif self.happy.GetValue():
            label = 'happy'
        elif self.sad.GetValue():
            label = 'sad'
        elif self.surprised.GetValue():
            label = 'surprised'
        elif self.angry.GetValue():
            label = 'angry'
        elif self.disgusted.GetValue():
            label = 'disgusted'

如你所见,一旦我们意识到每个单选按钮都有一个GetValue()方法,它仅在它被选中时返回True,这个过程就非常直接。

  1. 接下来,我们需要查看当前帧中检测到的面部区域(由detect_head存储在self.head中)并将其与其他所有收集到的帧对齐。也就是说,我们希望所有的人脸都是直立的,眼睛是对齐的。

否则,如果我们不对齐数据样本,我们面临的风险是分类器会将眼睛与鼻子进行比较。因为这个计算可能很昂贵,所以我们不在process_frame方法的每一帧上应用它,而是在_on_snapshot方法中仅在对快照进行操作时应用,如下所示:

        if self.head is None:
            print("No face detected")
        else:
            success, aligned_head = 
            self.face_detector.align_head(self.head)

由于这发生在调用process_frame之后,我们已经有权限访问self.head,它存储了当前帧中头部的图像。

  1. 接下来,如果我们已经成功对齐了头部(也就是说,如果我们已经找到了眼睛),我们将存储数据。否则,我们将通过向终端发送一个print命令来通知用户,如下所示:
            if success:
                save_datum(self.training_data, label, aligned_head)
                print(f"Saved {label} training datum.")
            else:
                print("Could not align head (eye detection 
                                             failed?)")

实际的保存是在save_datum函数中完成的,我们将其抽象出来,因为它不是 UI 的一部分。此外,如果你想要向文件中添加不同的数据集,这会很有用,如下所示:

def save_datum(path, label, img):
    with open(path, 'a', newline='') as outfile:
        writer = csv.writer(outfile)
        writer.writerow([label, img.tolist()])

如前所述的代码所示,我们使用.csv文件来存储数据,其中每个图像都是一个newline。所以,如果你想回去删除一个图像(也许你忘记梳理头发),你只需要用文本编辑器打开.csv文件并删除那一行。

现在,让我们转向更有趣的部分,找出我们如何使用我们收集的数据来训练一个机器学习模型以检测情感。

理解面部情感识别

在本节中,我们将训练一个 MLP 来识别图片中的面部情感。

我们之前已经指出,找到最能描述数据的特征往往是整个学习任务的一个重要部分。我们还探讨了常见的预处理方法,如均值减法归一化

这里,我们将探讨一个在人脸识别中有着悠久传统的额外方法——那就是 PCA。我们希望即使我们没有收集成千上万的训练图片,PCA 也能帮助我们获得良好的结果。

处理数据集

类似于第七章,学习识别交通标志,我们在data/emotions.py中编写了一个新的数据集解析器,该解析器将解析我们自行组装的训练集。

我们定义了一个load_data函数,该函数将加载训练数据并返回一个包含收集到的数据及其对应标签的元组,如下所示:

def load_collected_data(path):
    data, targets = [], []
    with open(path, 'r', newline='') as infile:
        reader = csv.reader(infile)
        for label, sample in reader:
            targets.append(label)
            data.append(json.loads(sample))
    return data, targets

这段代码,类似于所有处理代码,是自包含的,并位于data/process.py文件中,类似于第七章,学习识别交通标志

本章中的特征化函数将是pca_featurize函数,它将对所有样本执行 PCA。但与第七章,学习识别交通标志不同,我们的特征化函数考虑了整个数据集的特征,而不是单独对每张图像进行操作。

现在,它将返回一个包含训练数据和应用于测试数据所需的所有参数的训练数据元组,如下所示:

def pca_featurize(data) -> (np.ndarray, List)

现在,让我们弄清楚 PCA 是什么,以及为什么我们需要它。

学习 PCA

PCA 是一种降维技术,在处理高维数据时非常有用。从某种意义上说,你可以将图像视为高维空间中的一个点。如果我们通过连接所有行或所有列将高度为m和宽度为n的 2D 图像展平,我们得到一个长度为m x n的(特征)向量。这个向量中第 i 个元素的值是图像中第 i 个像素的灰度值。

为了描述所有可能的具有这些精确尺寸的 2D 灰度图像,我们需要一个m x n维度的向量空间,其中包含256^(m x n)个向量。哇!

当考虑到这些数字时,一个有趣的问题浮现在脑海中——是否可能存在一个更小、更紧凑的向量空间(使用小于 m x n 的特征)来同样好地描述所有这些图像? 因为毕竟,我们之前已经意识到灰度值并不是内容的最有信息量的度量。

这就是主成分分析(PCA)介入的地方。考虑一个数据集,我们从其中提取了恰好两个特征。这些特征可能是某些xy位置的像素的灰度值,但它们也可能比这更复杂。如果我们沿着这两个特征轴绘制数据集,数据可能被映射到某个多元高斯分布中,如下面的截图所示:

图片

PCA 所做的是将所有数据点旋转,直到数据映射到解释数据大部分扩散的两个轴(两个内嵌向量)上。PCA 认为这两个轴是最有信息的,因为如果你沿着它们走,你可以看到大部分数据点分离。用更技术性的术语来说,PCA 旨在通过正交线性变换将数据转换到一个新的坐标系中。

新的坐标系被选择得使得如果你将数据投影到这些新轴上,第一个坐标(称为第一个主成分)观察到最大的方差。在前面的截图中,画的小向量对应于协方差矩阵的特征向量,它们的尾部被移动到分布的均值处。

如果我们之前已经计算了一组基向量(top_vecs)和均值(center),那么转换数据将非常直接,正如前一段所述——我们从每个数据点中减去中心,然后将这些向量乘以主成分,如下所示:

def _pca_featurize(data, center, top_vecs):
    return np.array([np.dot(top_vecs, np.array(datum).flatten() - center)
                     for datum in data]).astype(np.float32)

注意,前面的代码将对任何数量的top_vecs都有效;因此,如果我们只提供num_components数量的顶级向量,它将降低数据的维度到num_components

现在,让我们构建一个pca_featurize函数,它只接受数据,并返回转换以及复制转换所需的参数列表——即centertop_vecs——这样我们就可以在测试数据上应用_pcea_featurize,如下所示:

def pca_featurize(training_data) -> (np.ndarray, List)

幸运的是,有人已经想出了如何在 Python 中完成所有这些操作。在 OpenCV 中,执行 PCA 就像调用cv2.PCACompute一样简单,但我们必须传递正确的参数,而不是重新格式化我们从 OpenCV 得到的内容。以下是步骤:

  1. 首先,我们将training_data转换为一个 NumPy 2D 数组,如下所示:
x_arr = np.array(training_data).reshape((len(training_data), -1)).astype(np.float32)
  1. 然后,我们调用cv2.PCACompute,它计算数据的中心,以及主成分,如下所示:
    mean, eigvecs = cv2.PCACompute(x_arr, mean=None)
  1. 我们可以通过运行以下代码来限制自己只使用num_components中最有信息量的成分:
    # Take only first num_components eigenvectors.
    top_vecs = eigvecs[:num_components]

PCA 的美丽之处在于,根据定义,第一个主成分解释了数据的大部分方差。换句话说,第一个主成分是数据中最有信息量的。这意味着我们不需要保留所有成分来得到数据的良好表示。

  1. 我们还将mean转换为创建一个新的center变量,它是一个表示数据中心的 1D 向量,如下所示:
    center = mean.flatten()
  1. 最后,我们返回由_pca_featurize函数处理过的训练数据,以及传递给_pca_featurize函数的必要参数,以便复制相同的转换,这样测试数据就可以以与训练数据完全相同的方式被特征化,如下所示:
    args = (center, top_vecs)
    return _pca_featurize(training_data, *args), args

现在我们知道了如何清理和特征化我们的数据,是时候看看我们用来学习识别面部情绪的训练方法了。

理解 MLP

MLP 已经存在了一段时间。MLP 是用于将一组输入数据转换为输出数据的人工神经网络(ANNs)。

MLP 的核心是一个感知器,它类似于(但过于简化)生物神经元。通过在多个层中组合大量感知器,MLP 能够对其输入数据进行非线性决策。此外,MLP 可以通过反向传播进行训练,这使得它们对于监督学习非常有兴趣。

以下部分解释了感知器的概念。

理解感知器

感知器是一种二分类器,由 Frank Rosenblatt 在 20 世纪 50 年代发明。感知器计算其输入的加权总和,如果这个总和超过阈值,它输出一个1;否则,它输出一个0

在某种意义上,感知器正在整合证据,其传入信号表示某些对象实例的存在(或不存在),如果这种证据足够强烈,感知器就会活跃(或沉默)。这与研究人员认为生物神经元在大脑中(或可以用来做)做的事情(或可以用来做)松散相关,因此有ANN这个术语。

感知器的一个草图在以下屏幕截图中有展示:

图片

在这里,感知器计算所有输入(x[i])的加权(w[i])总和,加上一个偏置项(b)。这个输入被送入一个非线性激活函数(θ),它决定了感知器的输出(y)。在原始算法中,激活函数是Heaviside 阶跃函数

在现代 ANN 的实现中,激活函数可以是任何从 Sigmoid 到双曲正切函数的范围。Heaviside 阶跃函数和 Sigmoid 函数在以下屏幕截图中有展示:

图片

根据激活函数的不同,这些网络可能能够执行分类或回归。传统上,只有当节点使用 Heaviside 阶跃函数时,人们才谈论 MLP。

了解深度架构

一旦你搞清楚了感知器的工作原理,将多个感知器组合成更大的网络就很有意义了。MLP(多层感知器)通常至少包含三个层,其中第一层为数据集的每个输入特征都有一个节点(或神经元),而最后一层为每个类别标签都有一个节点。

在第一层和最后一层之间的层被称为隐藏层。以下截图展示了这种前馈神经网络的例子:

在前馈神经网络中,输入层的一些或所有节点连接到隐藏层的所有节点,隐藏层的一些或所有节点连接到输出层的一些或所有节点。你通常会选择输入层的节点数等于数据集中的特征数,以便每个节点代表一个特征。

类似地,输出层的节点数通常等于数据集中的类别数,因此当输入样本为类别c时,输出层的第c个节点是活跃的,而其他所有节点都是沉默的。

当然,也可以有多个隐藏层。通常,事先并不清楚网络的理想大小应该是多少。

通常,当你向网络中添加更多神经元时,你会看到训练集上的误差率下降,如下面的截图所示(较细的红色曲线):

这是因为模型的表达能力或复杂性(也称为Vapnik-ChervonenkisVC 维度)随着神经网络规模的增加而增加。然而,对于前面截图中所显示的测试集上的误差率(较粗的蓝色曲线)来说,情况并非如此。

相反,你会发现,随着模型复杂性的增加,测试误差会达到其最小值,向网络中添加更多神经元也不再能提高测试数据的性能。因此,你希望将神经网络的规模保持在前面截图中所标记的“最佳范围”,这是网络实现最佳泛化性能的地方。

你可以这样想——一个弱复杂度模型(在图表的左侧)可能太小,无法真正理解它试图学习的数据集,因此训练集和测试集上的误差率都很大。这通常被称为欠拟合

另一方面,图表右侧的模型可能过于复杂,以至于开始记住训练数据中每个样本的具体细节,而没有注意到使样本与众不同的通用属性。因此,当模型需要预测它以前从未见过的数据时,它将失败,从而在测试集上产生很大的误差率。这通常被称为过拟合

相反,你想要的是一个既不过拟合也不欠拟合的模型。通常,这只能通过试错来实现;也就是说,将网络大小视为一个需要根据要执行的确切任务进行调整和微调的超参数。

MLP 通过调整其权重来学习,当展示一个类c的输入样本时,输出层的第c个节点是活跃的,而其他所有节点都是沉默的。MLP 通过反向传播方法进行训练,这是一种计算网络中任何突触权重或神经元偏置相对于损失函数的偏导数的算法。这些偏导数可以用来更新网络中的权重和偏置,以逐步减少整体损失。

通过向网络展示训练样本并观察网络的输出,可以获得一个损失函数。通过观察哪些输出节点是活跃的,哪些是休眠的,我们可以计算最后一层的输出与通过损失函数提供的真实标签之间的相对误差。

然后,我们对网络中的所有权重进行修正,以便随着时间的推移误差逐渐减小。结果发现,隐藏层的误差取决于输出层,输入层的误差取决于隐藏层和输出层的误差。因此,从某种意义上说,误差会反向传播通过网络。在 OpenCV 中,通过在训练参数中指定cv2.ANN_MLP_TRAIN_PARAMS_BACKPROP来使用反向传播。

梯度下降有两种常见的类型——即随机梯度下降批量学习

在随机梯度下降中,我们在展示每个训练示例后更新权重,而在批量学习中,我们以批量的形式展示训练示例,并且只在每个批量展示后更新权重。在这两种情况下,我们必须确保我们只对每个样本进行轻微的权重调整(通过调整学习率),以便网络随着时间的推移逐渐收敛到一个稳定的解。

现在,在学习了 MLP 的理论之后,让我们动手用 OpenCV 来实现它。

设计用于面部表情识别的 MLP

类似于第七章,学习识别交通标志,我们将使用 OpenCV 提供的机器学习类,即ml.ANN_MLP。以下是创建和配置 OpenCV 中 MLP 的步骤:

  1. 实例化一个空的ANN_MLP对象,如下所示:
    mlp = cv2.ml.ANN_MLP_create()
  1. 设置网络架构——第一层等于数据的维度,最后一层等于所需的输出大小6(用于可能的情绪数量),如下所示:
    mlp.setLayerSizes(np.array([20, 10, 6], dtype=np.uint8)
  1. 我们将训练算法设置为反向传播,并使用对称的 sigmoid 函数作为激活函数,正如我们在前面的章节中讨论的那样,通过运行以下代码:
    mlp.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP, 0.1)
    mlp.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
  1. 最后,我们将终止条件设置为在30次迭代后或当损失达到小于0.000001的值时,如下所示,我们就可以准备训练 MLP 了:
    mlp.setTermCriteria((cv2.TERM_CRITERIA_COUNT | 
                         cv2.TERM_CRITERIA_EPS, 30, 0.000001 ))

为了训练 MLP,我们需要训练数据。我们还想了解我们的分类器做得如何,因此我们需要将收集到的数据分为训练集和测试集。

分割数据最好的方式是确保训练集和测试集中没有几乎相同的图像——例如,用户双击了“捕获快照”按钮,我们有两个相隔毫秒的图像,因此几乎是相同的。不幸的是,这是一个繁琐且手动的过程,超出了本书的范围。

我们定义函数的签名,如下。我们想要得到一个大小为n的数组的索引,我们想要指定训练数据与所有数据的比例:

def train_test_split(n, train_portion=0.8):

带着签名,让我们一步一步地回顾train_test_split函数,如下所示:

  1. 首先,我们创建一个indices列表并对其进行shuffle,如下所示:
    indices = np.arange(n)
    np.random.shuffle(indices)
  1. 然后,我们计算N训练数据集中需要有多少个训练点,如下所示:
    N = int(n * train_portion)
  1. 之后,我们为训练数据的前N个索引创建一个选择器,并为剩余的indices创建一个选择器,用于测试数据,如下所示:
    return indices[:N], indices[N:]

现在我们有一个模型类和一个训练数据生成器,让我们看看如何训练 MLP。

训练 MLP

OpenCV 提供了所有的训练和预测方法,因此我们需要弄清楚如何格式化我们的数据以符合 OpenCV 的要求。

首先,我们将数据分为训练/测试,并对训练数据进行特征化,如下所示:

    train, test = train_test_split(len(data), 0.8)
    x_train, pca_args = pca_featurize(np.array(data)[train])

这里,pca_args是我们如果想要特征化任何未来的数据(例如,演示中的实时帧)需要存储的参数。

因为cv2.ANN_MLP模块的train方法不允许整数值的类别标签,我们需要首先将y_train转换为 one-hot 编码,只包含 0 和 1,然后可以将其输入到train方法中,如下所示:

    encoded_targets, index_to_label = one_hot_encode(targets)
    y_train = encoded_targets[train]
    mlp.train(x_train, cv2.ml.ROW_SAMPLE, y_train)

one-hot 编码在train_classifiers.py中的one_hot_encode函数中处理,如下所示:

  1. 首先,我们确定数据中有多少个点,如下所示:
def one_hot_encode(all_labels) -> (np.ndarray, Callable):
    unique_lebels = list(sorted(set(all_labels)))
  1. all_labels中的每个c类标签都需要转换为一个长度为len(unique_labels)的 0 和 1 的向量,其中所有条目都是 0,除了c^(th),它是一个 1。我们通过分配一个全 0 的向量来准备这个操作,如下所示:
    y = np.zeros((len(all_labels), len(unique_lebels))).astype(np.float32)
  1. 然后,我们创建字典,将列的索引映射到标签,反之亦然,如下所示:
    index_to_label = dict(enumerate(unique_lebels))
    label_to_index = {v: k for k, v in index_to_label.items()
  1. 这些索引处的向量元素需要设置为1,如下所示:
    for i, label in enumerate(all_labels):
        y[i, label_to_index[label]] = 1
  1. 我们还返回index_to_label,这样我们就能从预测向量中恢复标签,如下所示:
    return y, index_to_label

现在我们继续测试我们刚刚训练的 MLP。

测试 MLP

类似于第七章,学习识别交通标志,我们将评估我们的分类器在准确率、精确率和召回率方面的性能。

为了重用我们之前的代码,我们只需要计算 y_hat 并通过以下方式将 y_true 一起传递给度量函数:

  1. 首先,我们使用存储在特征化训练数据时的 pca_args_pca_featurize 函数,对测试数据进行特征化,如下所示:
     x_test = _pca_featurize(np.array(data)[test], *pca_args)
  1. 然后,我们预测新的标签,如下所示:
_, predicted = mlp.predict(x_test)
    y_hat = np.array([index_to_label[np.argmax(y)] for y 
    in predicte
  1. 最后,我们使用存储的测试索引提取真实的测试标签,如下所示:
    y_true = np.array(targets)[test]

剩下的唯一要传递给函数的是 y_haty_true,以计算我们分类器的准确率。

我需要 84 张图片(每种情绪 10-15 张)才能达到 0.92 的训练准确率,并拥有足够好的分类器向我的朋友们展示我的软件。你能打败它吗?

现在,让我们看看如何运行训练脚本,并以演示应用程序能够使用的方式保存输出。

运行脚本

可以使用 train_classifier.py 脚本来训练和测试 MLP 分类器,该脚本执行以下操作:

  1. 脚本首先将 --data 命令行选项设置为保存数据的位置,将 --save 设置为我们想要保存训练模型的目录位置(此参数是可选的),如下所示:
if __name__ == '__main__':
    parser = argparse.ArgumentParser()
    parser.add_argument('--data', required=True)
    parser.add_argument('--save', type=Path)
    args = parser.parse_args()
  1. 然后,我们加载保存的数据,并按照上一节中描述的训练过程进行,如下所示:
    data, targets = load_collected_data(args.data)

    mlp = cv2.ml.ANN_MLP_create()
    ...
    mlp.train(...
  1. 最后,我们通过运行以下代码检查用户是否希望我们保存训练好的模型:
    if args.save:
        print('args.save')
        x_all, pca_args = pca_featurize(np.array(data))
        mlp.train(x_all, cv2.ml.ROW_SAMPLE, encoded_targets)
        mlp.save(str(args.save / 'mlp.xml'))
        pickle_dump(index_to_label, args.save / 'index_to_label')
        pickle_dump(pca_args, args.save / 'pca_args')

之前的代码保存了训练好的模型,index_to_label 字典,以便在演示中显示可读的标签,以及 pca_args,以便在演示中特征化实时摄像头帧。

保存的 mlp.xml 文件包含网络配置和学习的权重。OpenCV 知道如何加载它。所以,让我们看看演示应用程序的样子。

将所有这些放在一起

为了运行我们的应用程序,我们需要执行主函数例程(chapter8.py),该例程加载预训练的级联分类器和预训练的 MLP,并将它们应用于网络摄像头的实时流中的每一帧。

然而,这次,我们不会收集更多的训练样本,而是以不同的选项启动程序,如下所示:

 $ python chapter8.py demo --classifier data/clf1

这将启动应用程序,并使用新的 FacialExpressionRecognizerLayout 布局,它是 BasicLayout 的子类,没有任何额外的 UI 元素。让我们首先看看构造函数,如下所示:

  1. 它读取并初始化由训练脚本存储的所有数据,如下所示:
class FacialExpressionRecognizerLayout(BaseLayout):
    def __init__(self, *args,
                 clf_path=None,
                 **kwargs):
        super().__init__(*args, **kwargs)
  1. 使用 ANN_MLP_load 加载预训练的分类器,如下所示:
self.clf = cv2.ml.ANN_MLP_load(str(clf_path / 'mlp.xml'))
  1. 它加载我们想要从训练中传递的 Python 变量,如下所示:
        self.index_to_label = pickle_load(clf_path 
                                          / 'index_to_label')
        self.pca_args = pickle_load(clf_path / 'pca_args')
  1. 它初始化一个 FaceDetector 类,以便能够进行人脸识别,如下所示:
        self.face_detector = FaceDetector(
            face_cascade='params/
            haarcascade_frontalface_default.xml',
            eye_cascade='params/haarcascade_lefteye_2splits.xml')

一旦我们从训练中获得了所有这些部件,我们就可以继续编写代码来为面部添加标签。在这个演示中,我们没有使用任何额外的按钮;因此,我们唯一要实现的方法是process_frame,它首先尝试在实时流中检测人脸并在其上方放置标签,我们将按以下步骤进行:

  1. 首先,我们尝试通过运行以下代码来检测视频流中是否存在人脸:
   def process_frame(self, frame_rgb: np.ndarray) -> np.ndarray:
        success, frame, self.head, (x, y) = 
        self.face_detector.detect_face(frame_rgb)
  1. 如果没有人脸,我们不做任何操作,并返回一个未处理的frame,如下所示:
        if not success:
            return frame
  1. 一旦检测到人脸,我们尝试将人脸对齐(与收集训练数据时相同),如下所示:
        success, head = self.face_detector.align_head(self.head)
        if not success:
            return frame
  1. 如果我们成功,我们将使用 MLP 对头部进行特征化并预测标签,如下所示:
        _, output = self.clf.predict(self.featruize_head(head))
        label = self.index_to_label[np.argmax(output)]
  1. 最后,我们通过运行以下代码将带有标签的文本放在人脸的边界框上,并将其显示给用户:
        cv2.putText(frame, label, (x, y - 20),
                    cv2.FONT_HERSHEY_COMPLEX, 1, (0, 255, 0), 2)

        return frame

在前面的方法中,我们使用了featurize_head,这是一个方便的函数来调用_pca_featurize,如下面的代码块所示:

    def featurize_head(self, head):
        return _pca_featurize(head[None], *self.pca_args)

最终结果如下所示:

图片

尽管分类器只训练了大约 100 个训练样本,但它能够可靠地检测直播流中每一帧的我的各种面部表情,无论我的脸在那一刻看起来多么扭曲。

这表明之前训练的神经网络既没有欠拟合也没有过拟合数据,因为它能够预测正确的类别标签,即使是对于新的数据样本。

摘要

这本书的这一章真正总结了我们的经验,并使我们能够将各种技能结合起来,最终开发出一个端到端的应用程序,该应用程序包括物体检测和物体识别。我们熟悉了 OpenCV 提供的各种预训练的级联分类器,我们收集并创建了我们的训练数据集,学习了 MLP,并将它们训练来识别面部表情(至少是我的面部表情)。

分类器无疑受益于我是数据集中唯一的主题这一事实,但,凭借你在整本书中学到的所有知识和经验,现在是时候克服这些限制了。

在学习本章的技术之后,你可以从一些较小的东西开始,并在你(室内和室外,白天和夜晚,夏天和冬天)的图像上训练分类器。或者,你可以看看Kaggle 的面部表情识别挑战,那里有很多你可以玩的数据。

如果你对机器学习感兴趣,你可能已经知道有许多可用的库,例如Pylearnscikit-learnPyTorch

在下一章中,你将开始你的深度学习之旅,并亲手操作深度卷积神经网络。你将熟悉多个深度学习概念,并使用迁移学习创建和训练自己的分类和定位网络。为了完成这项任务,你将使用Keras中可用的预训练分类卷积神经网络。在整个章节中,你将广泛使用KerasTensorFlow,它们是当时最受欢迎的深度学习框架之一。

进一步阅读

贡献

Lenna.png—图像 Lenna 可在 www.flickr.com/photos/15489034@N00/3388463896 由 Conor Lawless 提供,授权为 CC 2.0 Generic。

第九章:学习如何分类和定位物体

到目前为止,我们已经研究了一系列算法和方法,你学习了如何借助计算机视觉解决现实世界的问题。近年来,随着图形处理单元GPU)等设备提供的强大硬件计算能力的出现,许多算法应运而生,这些算法利用了这种能力,在计算机视觉任务中实现了最先进的结果。通常,这些算法基于神经网络,这使得算法的创造者能够从数据中提取大量有意义的信息。

同时,与经典方法相比,这些信息通常很难解释。从这个角度来看,你可能会说我们正在接近人工智能——也就是说,我们正在给计算机一个方法,然后它自己找出如何完成剩下的工作。为了不让这一切显得如此神秘,让我们在本章中学习关于深度学习模型的知识。

正如你已经看到的,计算机视觉中的一些经典问题包括目标检测和定位。让我们看看在本章中如何使用深度学习模型来分类和定位物体。

本章的目标是学习重要的深度学习概念,如迁移学习,以及如何将它们应用到构建你自己的物体分类器和定位器中。具体来说,我们将涵盖以下主题:

  • 准备用于训练深度学习模型的大型数据集

  • 理解卷积神经网络CNNs

  • 使用卷积神经网络进行分类和定位

  • 学习迁移学习

  • 实现激活函数

  • 理解反向传播

我们将首先准备一个用于训练的数据集。然后,我们将继续了解如何使用预训练模型来创建一个新的分类器。一旦你明白了这个过程,我们将继续前进,构建更复杂的架构,这些架构将执行定位。

在这些步骤中,我们将使用牛津-IIIT-Pet数据集。最后,我们将运行一个应用,该应用将使用我们训练好的定位网络进行推理。尽管这个网络仅使用宠物的头部边界框进行训练,但你将看到它对于定位人类头部位置也非常有效。后者将展示我们模型泛化的能力。

了解深度学习的这些概念并在实际应用中看到它们的效果,在你使用深度学习模型创建自己的应用或开始研究全新的深度学习架构时,将会非常有用。

开始学习

如我们在这本书的所有章节中提到的,你需要安装 OpenCV。除此之外,你还需要安装 TensorFlow。

牛津-IIIT-Pet 数据集可在www.robots.ox.ac.uk/~vgg/data/pets/下载,以及我们的数据集准备脚本,该脚本将自动为您下载。

您可以在本章节(GitHub 仓库)中找到我们提供的代码(github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition)。您还可以使用仓库中可用的 Docker 文件在本章中运行代码。有关 Docker 文件的更多信息,请参阅附录 B,设置 Docker 容器

规划应用程序

最终的应用程序将包括准备数据集、训练模型以及使用来自相机的输入运行模型推理的模块。这需要以下组件:

  • main.py:这是启动应用程序并在实时中定位宠物头部的主要脚本。

  • data.py:这是一个模块,用于下载和准备训练数据集。

  • classification.py:这是一个用于训练分类网络的脚本。

  • localization.py:这是一个用于训练和保存定位网络的脚本。

在准备训练数据集后,我们将执行以下操作以完成我们的应用:

  1. 我们将首先使用迁移学习训练一个分类网络。

  2. 接下来,我们将使用迁移学习再次训练一个目标定位网络。

  3. 在我们创建和训练定位网络后,我们将运行main.py脚本以实时定位头部。

让我们从学习如何准备将要运行我们应用的推理脚本开始。该脚本将连接到您的相机,使用我们将创建的定位模型在视频流的每一帧中找到头部位置,并在实时中展示结果。

准备推理脚本

我们的推理脚本相当简单。它将首先准备一个绘图函数,然后加载模型并将其连接到相机。然后,它将遍历视频流的帧。在循环中,对于流中的每一帧,它将使用导入的模型进行推理,并使用绘图函数显示结果。让我们按照以下步骤创建一个完整的脚本:

  1. 首先,我们导入所需的模块:
import numpy as np
import cv2
import tensorflow.keras as K

在此代码中,除了导入 NumPy 和 OpenCV 外,我们还导入了Keras。我们将在此脚本中使用 Keras 进行预测;此外,我们将在本章中用它来创建和训练我们的模型。

  1. 然后,我们定义一个函数在帧上绘制定位边界框:
def draw_box(frame: np.ndarray, box: np.ndarray) -> np.ndarray:
    h, w = frame.shape[0:2]
    pts = (box.reshape((2, 2)) * np.array([w, h])).astype(np.int)
    cv2.rectangle(frame, tuple(pts[0]), tuple(pts[1]), (0, 255, 0), 2)
    return frame

前面的 draw_box 函数接受 frame 和一个边界框两个角的归一化坐标作为四个数字的数组。该函数首先将框的一维数组重塑为二维数组,其中第一个索引表示点,第二个索引表示 xy 坐标。然后,它通过乘以一个由图像宽度和高度组成的数组将归一化坐标转换为图像坐标,并将结果转换为同一行的整数值。最后,它使用 cv2.rectangle 函数以绿色绘制边界框,并返回 frame

  1. 然后,我们将导入本章中准备好的模型并将其连接到摄像头:
model = K.models.load_model("localization.h5")
cap = cv2.VideoCapture(0)

model 将存储在一个二进制文件中,可以使用 Keras 中的一个方便的函数来导入。

  1. 之后,我们遍历来自摄像头的帧,将每个 frame 调整到标准大小(即我们将创建的模型的默认图像大小),并将 frame 转换为 RGB红色绿色蓝色)颜色空间,因为我们将在 RGB 图像上训练我们的模型:
for _, frame in iter(cap.read, (False, None)):
    input = cv2.resize(frame, (224, 224))
    input = cv2.cvtColor(input, cv2.COLOR_BGR2RGB)
  1. 在相同的循环中,我们将图像归一化,并将一个添加到帧的形状中,因为模型接受图像批次。然后,我们将结果传递给 model 进行推理:
    box, = model.predict(input[None] / 255)
  1. 我们通过使用先前定义的函数绘制预测的边界框、显示结果,然后设置终止条件来继续循环:
    cv2.imshow("res", frame)
    if(cv2.waitKey(1) == 27):
        break

现在我们已经准备好了推理脚本,让我们开始创建我们自己的模型的旅程,首先在下一节中准备数据集。

准备数据集

如前所述,在本章中,我们将使用牛津-IIIT-Pet 数据集。将数据集的准备封装在一个单独的 data.py 脚本中是一个好主意,这样就可以在本章中重复使用。与任何其他脚本一样,首先,我们必须导入所有必需的模块,如下面的代码片段所示:

import glob
import os

from itertools import count
from collections import defaultdict, namedtuple

import cv2
import numpy as np
import tensorflow as tf
import xml.etree.ElementTree as ET

为了准备我们的数据集以供使用,我们首先将数据集下载并解析到内存中。然后,从解析的数据中,我们将创建一个 TensorFlow 数据集,这使我们能够以方便的方式处理数据集,并在后台准备数据,以便数据准备不会中断神经网络训练过程。因此,让我们继续在下一节中下载和解析数据集。

下载并解析数据集

在本节中,我们首先从官方网站下载数据集,然后将其解析为方便的格式。在这个阶段,我们将省略图像,因为它们占用相当多的内存。我们将在以下步骤中介绍这个程序:

  1. 定义我们想要存储宠物数据集的位置,并使用 Keras 中的方便的 get_file 函数下载它:
DATASET_DIR = "dataset"
for type in ("annotations", "images"):
    tf.keras.utils.get_file(
        type,
        f"https://www.robots.ox.ac.uk/~vgg/data/pets/data/{type}.tar.gz",
        untar=True,
        cache_dir=".",
        cache_subdir=DATASET_DIR)

由于我们的数据集位于存档中,我们还通过传递untar=True提取了它。我们还把cache_dir指向当前目录。一旦文件保存,get_file函数的后续执行将不会采取任何行动。

该数据集超过半吉字节,在第一次运行时,您需要一个稳定且带宽良好的互联网连接。

  1. 下载并提取我们的数据集后,让我们定义数据集和注释文件夹的常量,并设置我们想要将图像调整大小到的大小:
IMAGE_SIZE = 224
IMAGE_ROOT = os.path.join(DATASET_DIR,"images")
XML_ROOT = os.path.join(DATASET_DIR,"annotations")

大小224通常是图像分类网络训练的默认大小。因此,保持该大小可以获得更好的准确性。

  1. 该数据集的注释包含有关图像的 XML 格式信息。在解析 XML 之前,让我们首先定义我们想要的数据:
Data = namedtuple("Data","image,box,size,type,breed")

namedtuple是 Python 中标准元组的扩展,允许您通过名称引用元组中的元素。我们定义的名称对应于我们感兴趣的数据元素。具体来说,这些是图像本身(image)、宠物的头部边界框(box)、图像大小、type(猫或狗)和breed(共有 37 个品种)。

  1. breedstypes在注释中是字符串;我们想要的是与breed对应的数字。为此,我们定义了两个字典:
types = defaultdict(count().__next__ )
breeds = defaultdict(count().__next__ )

defaultdict是一个返回默认值的字典。在这里,当请求时,它将从零开始返回下一个数字。

  1. 接下来,我们定义一个函数,给定一个 XML 文件的路径,将返回我们的数据实例:
def parse_xml(path: str) -> Data:

之前定义的函数涵盖了以下步骤:

    1. 打开 XML 文件并解析它:
with open(path) as f:
    xml_string = f.read()
root = ET.fromstring(xml_string)

XML 文件的内容使用ElementTree模块解析,该模块以方便导航的格式表示 XML。

    1. 然后,获取对应图像的名称并提取品种名称:
img_name = root.find("./filename").text
breed_name = img_name[:img_name.rindex("_")]
    1. 之后,使用之前定义的breeds将品种转换为数字,它为每个未定义的键分配下一个数字:
breed_id = breeds[breed_name]
    1. 同样,获取types的 ID:
type_id = types[root.find("./object/name").text]
    1. 然后,提取边界框并归一化它:
box = np.array([int(root.find(f"./object/bndbox/{tag}").text)
                for tag in "xmin,ymin,xmax,ymax".split(",")])
size = np.array([int(root.find(f"./size/{tag}").text)
                 for tag in "width,height".split(",")])
normed_box = (box.reshape((2, 2)) / size).reshape((4))

将结果作为Data实例返回:

return Data(img_name,normed_box,size,type_id,breed_id)
  1. 现在我们已经下载了数据集并准备了解析器,让我们继续解析数据集:
xml_paths = glob.glob(os.path.join(XML_ROOT,"xmls","*.xml"))
xml_paths.sort()
parsed = np.array([parse_xml(path) for path in xml_paths])

我们还排序了路径,以便在不同的运行环境中以相同的顺序出现。

由于我们已经解析了我们的数据集,我们可能想打印出可用的品种和类型以供说明:

print(f"{len(types)} TYPES:", *types.keys(), sep=", ")
print(f"{len(breeds)} BREEDS:", *breeds.keys(), sep=", ")

之前的代码片段输出了两种类型,即catdog及其breed

2 TYPES:, cat, dog
37 BREEDS:, Abyssinian, Bengal, Birman, Bombay, British_Shorthair, Egyptian_Mau, Maine_Coon, Persian, Ragdoll, Russian_Blue, Siamese, Sphynx, american_bulldog, american_pit_bull_terrier, basset_hound, beagle, boxer, chihuahua, english_cocker_spaniel, english_setter, german_shorthaired, great_pyrenees, havanese, japanese_chin, keeshond, leonberger, miniature_pinscher, newfoundland, pomeranian, pug, saint_bernard, samoyed, scottish_terrier, shiba_inu, staffordshire_bull_terrier, wheaten_terrier, yorkshire_terrier

在本章的后面部分,我们将不得不将数据集分为训练集和测试集。为了进行良好的分割,我们应该从数据集中随机选择数据元素,以便在训练集和测试集中有比例的breed数量。

我们现在可以混合数据集,这样我们就不必担心以后的问题,如下所示:

np.random.seed(1)
np.random.shuffle(parsed)

之前的代码首先设置了一个随机种子,这是每次执行代码时获得相同结果所必需的。seed方法接受一个参数,即指定随机序列的数字。

一旦设置了seed方法,在所有使用随机数的函数中,我们都会有相同的随机数序列。这些数字被称为伪随机数。这意味着,尽管它们看起来是随机的,但它们是预先定义的。在我们的例子中,我们使用shuffle方法,它打乱了parsed数组中元素的位置。

现在我们已经将数据集解析为方便的 NumPy 数组,让我们继续并创建一个 TensorFlow 数据集。

创建 TensorFlow 数据集

我们将使用 TensorFlow 数据集适配器来训练我们的模型。当然,我们可以从我们的数据集中创建一个 NumPy 数组,但想象一下,要保留所有图像在内存中需要多少内存。

相反,数据集适配器允许你在需要时将数据加载到内存中。此外,数据在后台加载和准备,这样它就不会成为我们训练过程中的瓶颈。我们按照以下方式转换我们的解析数组:

ds = tuple(np.array(list(i)) for i in np.transpose(parsed))
ds_slices = tf.data.Dataset.from_tensor_slices(ds)

从前面的代码片段中,from_tensor_slices创建了一个Dataset,其元素是给定张量的切片。在我们的例子中,这些张量是标签的 NumPy 数组(包括盒子、品种、图像位置等)。

在底层,它与 Python 的zip函数有类似的概念。首先,我们已经相应地准备好了输入。现在让我们打印数据集中的一个元素,看看它是什么样子:

for el in ds_slices.take(1):
    print(el)

这将产生以下输出:

(<tf.Tensor: id=14, shape=(), dtype=string, numpy=b'american_pit_bull_terrier_157.jpg'>, <tf.Tensor: id=15, shape=(4,), dtype=float64, numpy=array([0.07490637, 0.07 , 0.58426966, 0.44333333])>, <tf.Tensor: id=16, shape=(2,), dtype=int64, numpy=array([267, 300])>, <tf.Tensor: id=17, shape=(), dtype=int64, numpy=1>, <tf.Tensor: id=18, shape=(), dtype=int64, numpy=13>)

这是 TensorFlow 中的tensor,它包含了我们从单个 XML 文件中解析出的所有信息。给定数据集,我们可以检查我们的所有边界框是否正确:

for el in ds_slices:
    b = el[1].numpy()
    if(np.any((b>1) |(b<0)) or np.any(b[2:]-b[:2] < 0)):
        print(f"Invalid box found {b} image: {el[0].numpy()}")

由于我们已经归一化了盒子,它们应该在[0,1]的范围内。此外,我们确保盒子的第一个点的坐标小于或等于第二个点的坐标。

现在,我们定义一个函数,将我们的数据元素转换,以便我们可以将其输入到神经网络中:

def prepare(image,box,size,type,breed):
    image = tf.io.read_file(IMAGE_ROOT+"/"+image)
    image = tf.image.decode_png(image,channels=3)
    image = tf.image.resize(image,(IMAGE_SIZE,IMAGE_SIZE))
    image /= 255
    return Data(image,box,size,tf.one_hot(type,len(types)),tf.one_hot(breed,len(breeds)))

该函数首先加载相应的图像,将其调整到标准大小,并将其归一化到[0,1]。然后,它使用tf.one_hot方法从typesbreeds创建一个one_hot向量,并将结果作为Data实例返回。

现在剩下的是使用函数map我们的数据集,然后我们就可以开始了:

ds = ds_slices.map(prepare).prefetch(32)

我们还调用了prefetch方法,确保预取一定量的数据,这样我们的网络就不必等待从硬盘加载数据。

如果我们直接运行数据准备脚本,展示一些数据样本可能是个好主意。首先,我们创建一个函数,当给定数据样本时,它会创建一个说明图像:

if __name__ == "__main__":
    def illustrate(sample):
        breed_num = np.argmax(sample.breed)
        for breed, num in breeds.items():
            if num == breed_num:
                break
        image = sample.image.numpy()
        pt1, pt2 = (sample.box.numpy().reshape(
            (2, 2)) * IMAGE_SIZE).astype(np.int32)
        cv2.rectangle(image, tuple(pt1), tuple(pt2), (0, 1, 0))
        cv2.putText(image, breed, (10, 10),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 1, 0))
        return image

该函数将breed独热向量转换回一个数字,在breeds字典中找到品种的名称,并绘制头部边界框以及品种名称。

现在,我们将几个这样的插图连接起来,并展示结果图像:

samples_image = np.concatenate([illustrate(sample)
                                for sample in ds.take(3)], axis=1)
cv2.imshow("samples", samples_image)
cv2.waitKey(0)

结果显示在下一张截图:

图片

上一张截图显示了预期中头部周围有边界框的可爱宠物。请注意,尽管我们在脚本中使用随机数来混合数据集,但你获得的结果与之前展示的相同。因此,你现在可以看到伪随机数的威力。

现在我们已经准备好了数据集,接下来让我们进入下一节,创建和训练分类器。我们将构建两个分类器——一个用于宠物类型,另一个用于品种。

使用 CNN 进行分类

要开始分类,首先,我们必须导入所需的模块:

import tensorflow.keras as K
from data import ds

我们必须导入我们准备好的数据集和 Keras,我们将使用它们来构建我们的分类器。

然而,在我们构建我们的分类器之前,让我们首先了解卷积网络,因为我们将要使用它们来构建我们的分类器。

理解 CNN

在第一章,“玩转滤波器”,你学习了关于滤波器和卷积的知识。特别是,你学习了如何使用滤波器创建铅笔素描图像。在铅笔素描中,你可以看到图像中那些值发生急剧变化的点,也就是说,它们比那些值变化平滑的点要暗。

从那个角度来看,我们应用的滤波器可以被视为边缘检测滤波器。换句话说,滤波器充当特征检测器,其中特征是边缘。或者,你也可以组合一个不同的滤波器,它在角上激活,或者在颜色值没有变化时激活。

我们使用的滤波器作用于单通道图像,并且有两个维度;然而,我们可以通过第三个维度扩展滤波器,这样它就可以应用于多通道图像。例如,如果一个单通道滤波器的大小是3 x 3,那么相应的 3 通道(例如,RGB)滤波器的大小将是3 x 3 x 3,其中最后一个值是滤波器的深度。

这样的滤波器已经可以用于更复杂的功能。例如,你可能想到一个与绿色颜色一起工作,同时通过在滤波器中相应元素设置零来忽略红色和蓝色值的滤波器。

一旦你找到了一组好的滤波器,你就可以将它们应用到原始图像上,然后将它们堆叠成一个新的多通道图像。例如,如果我们对一个图像应用 100 个滤波器,我们将获得 100 个单通道图像,堆叠后将会得到一个 100 通道的图像。因此,我们构建了一个接受 3 个通道并输出 100 个通道的层。

接下来,我们可以组合新的滤波器,这些滤波器的深度为 100,并作用于组合的 100 通道图像。这些滤波器也可以在更复杂的功能上被激活。例如,如果前面的层中有激活在边缘上的滤波器,我们可以组合一个在边缘交点处激活的滤波器。

经过一系列层之后,我们可能会看到激活的滤波器,例如,在人们的鼻子上、头部、车辆的轮子上等等。这正是卷积网络的工作方式。当然,一个问题随之而来:我们如何组合这些滤波器?答案是,我们不需要,因为它们是学习得到的。

我们提供数据,网络学习它需要的滤波器以做出良好的预测。与您使用的卷积滤波器之间的另一个区别是,除了滤波器的可学习参数之外,还有一个额外的可学习值,称为,它是一个添加到滤波器输出的常数项。

此外,在每个卷积滤波器之后,通常会对滤波器的输出应用一个非线性函数,称为 激活函数。由于非线性,网络可以表示更广泛的函数类别,因此构建良好模型的机会相对较高。

现在我们对卷积网络的工作原理有了些了解,让我们先从构建分类器开始。在构建本章中的网络时,您将看到卷积层是如何构建和使用的。如前所述,我们使用预训练模型来构建我们的新模型,换句话说,我们使用迁移学习。让我们在下一节中了解这是什么。

学习迁移学习

通常,一个卷积神经网络有数百万个参数。让我们做一个估计,找出所有这些参数的来源。

假设我们有一个 10 层的网络,每层有 100 个大小为 3 x 3 的滤波器。这些数字相当低,通常表现良好的网络有数十个层和每层数百个滤波器。在我们的情况下,每个滤波器的深度为 100。

因此,每个滤波器有 3 x 3 x 3 = 900 个参数(不包括偏差,偏差的数量为 100),这意味着每个层的参数为 900 x 100,因此整个网络的参数约为 900,000 个。要从头开始学习这么多参数而不发生过拟合,需要相当大的标注数据集。一个问题随之而来:我们还能做什么?

你已经了解到,网络层充当特征提取器。除此之外,自然图像有很多共同之处。因此,使用在大数据集上训练的网络的特征提取器来在不同的、较小的数据集上实现良好的性能是一个好主意。这种技术被称为 迁移学习

让我们选择一个预训练模型作为我们的基础模型,这是 Keras 的一行代码:

base_model = K.applications.MobileNetV2(input_shape=(224,224, 3), include_top=False)

在这里,我们使用的是预训练的MobileNetV2网络,这是一个健壮且轻量级的网络。当然,你也可以使用其他可用的模型,这些模型可以在 Keras 网站上找到,或者通过简单地使用dir(K.applications)列出它们。

我们通过传递include_top=False来排除负责分类的顶层版本的网络,因为我们将在其之上构建一个新的分类器。但仍然,网络包括所有在ImageNet上训练的其他层。ImageNet 是一个包含数百万图像的数据集,每个图像都标注了数据集中 1,000 个类别中的一个。

让我们看看我们基础模型输出的形状:

print(base_model.output.shape)

结果如下:

(None, 7, 7, 1280)

第一个数字是未定义的,表示批处理大小,换句话说,输入图像的数量。假设我们同时向网络传递 10 张图像的堆栈;那么,这里的输出将具有形状(10,7,7,1280),张量的第一个维度将对应于输入图像编号。

接下来的两个索引是输出形状的大小,最后一个是通道数。在原始模型中,这个输出代表了从输入图像中提取的特征,这些特征后来用于对 ImageNet 数据集的图像进行分类。

因此,它们很好地代表了所有图像,以便网络可以根据它们对 ImageNet 的图像进行分类。让我们尝试使用这些特征来分类我们宠物的类型和品种。为了做到这一点,让我们在下一节中首先准备一个分类器。

准备宠物类型和品种分类器

由于我们将直接使用这些特征,让我们首先冻结网络层的权重,这样它们在训练过程中就不会更新:

for layer in base_model.layers:
    layer.trainable = False

通常,激活图中的每个位置都指定了在该位置是否存在对应类型的特征。当我们处理网络的最后一层时,我们可以假设激活图上的不同位置包含相似的信息,并通过平均激活图来减少我们特征的维度:

x = K.layers.GlobalAveragePooling2D()(base_model.output)

这个操作被称为AveragePooling2D——我们在特征张量的两个维度上池化张量的平均值。你可以通过打印操作输入和输出的形状来查看结果:

print(base_model.output.shape, x.shape)

这显示了以下输出:

(None, 7, 7, 1280) (None, 1280)

现在我们已经为每张图像有了1280个特征,让我们立即添加分类层,并准备我们的数据集以在类型或品种上进行训练:

is_breeds = True
if is_breeds:
    out = K.layers.Dense(37,activation="softmax")(x)
    inp_ds = ds.map(lambda d: (d.image,d.breed))
else:
    out = K.layers.Dense(2,activation="softmax")(x)
    inp_ds = ds.map(lambda d: (d.image,d.type))

在类型和品种上训练的不同之处仅在于输出神经元的数量和标签。对于品种,标签的数量是37,而对于类型,这是2(即猫或狗),你可以在代码中看到。密集层代表密集连接的神经元。这意味着层中的每个神经元都连接到层的所有 1,280 个输入。

因此,每个神经元有 1280 + 1 个可学习的参数,其中 1 是用于偏置的。从数学上讲,对于完整的层,核的权重用一个大小为 (1,280,即类别的数量) 的矩阵表示,并且有一个高度为 1280 的列。

层的线性部分可以写成如下形式:

在这里,x 是前一层(在我们的情况下是 1,280 个平均特征)的输出,a 是矩阵,b 是列。

此外,我们还设置了一个 softmax 函数作为激活函数,这对于分类任务是一个很好的选择。后者定义如下:

在这里,x 是激活函数的输入(线性部分的输出)。

你可以看到所有输出加起来等于一;因此,输出可以被认为是相应类别的概率。

我们在数据集上定义的映射将图像作为数据,品种或类型作为标签。

现在我们已经准备好定义我们的模型:

model = K.Model(inputs=base_model.input, outputs=out)

在这里,你可以看到网络的输入是我们的基础模型,输出是我们的分类层。因此,我们已经成功构建了我们的分类网络。

因此,现在我们已经准备好了我们的分类网络,让我们在下一节中对其进行训练和评估:

训练和评估分类器

为了训练分类器,我们必须为它配置训练。我们必须指定一个目标函数(损失函数)和一个训练方法。此外,我们可能还想指定一些指标,以便查看模型的性能。我们可以使用模型的 compile 方法来配置分类器:

model.compile(loss="categorical_crossentropy", optimizer="adam", metrics=["categorical_accuracy","top_k_categorical_accuracy"])

我们将 metrics 传递为 categorical_accuracy,这将显示数据集的哪一部分被正确分类。除此之外,我们还传递了一个名为 top_k_categorical_accuracy 的额外指标,它显示了数据集的哪一部分在网络的前 k 预测中是正确的。

k 的默认值是五,因此该指标显示了数据集中最有可能被神经网络预测的前五个类别。我们还传递了 optimizer="adam",这强制模型使用 Adam Optimizer 作为训练算法。你将在 理解反向传播 部分学习神经网络通常是如何训练的。

在训练之前,我们还把数据集分成训练集和测试集,以便查看网络在未见数据上的表现:

evaluate = inp_ds.take(1000)
train = inp_ds.skip(1000).shuffle(10**4)

在这里,我们取数据集的前 1000 个元素用于测试目的。其余部分用于训练。

通过调用 shuffle 方法,训练部分是混合的,这将确保我们在每个训练周期中都有不同的数据顺序。最后,我们通过调用数据集的 fit 方法来训练我们的网络,并在验证集上评估它:

model.fit(train.batch(32), epochs=4)
model.evaluate(valid.batch(1))

首先,fit 方法接受数据集本身,我们通过32批次的批量传递。这意味着,在训练过程的每一步,将使用数据集中的32张图片。

我们还传递了多个epochs,这意味着我们的数据集将被迭代4次,直到训练过程停止。最后一个epoch的输出如下:

Epoch 4/4
 84/84 [==============================] - 13s 156ms/step - loss: 0.0834 - categorical_accuracy: 0.9717 - top_k_categorical_accuracy: 1.0000

我们在训练集上的分类准确率超过 97%。因此,我们在区分猫和狗方面做得相当不错。当然,top-K 准确率将是 100%,因为我们只有两个类别。现在,让我们看看我们在验证集上的表现。

训练完成后,模型将被评估,你应该获得与测试集相似的结果:

model.evaluate(valid.batch(1))

输出如下所示:

1000/1000 [==============================] - 9s 9ms/step - loss: 0.0954 - categorical_accuracy: 0.9730 - top_k_categorical_accuracy: 1.0000

我们再次获得了超过 97% 的分类准确率。因此,我们的模型没有过拟合,在测试集上的表现良好。

如果我们在品种上进行训练,训练输出的结果如下:

Epoch 4/4
 84/84 [==============================] - 13s 155ms/step - loss: 0.3272 - categorical_accuracy: 0.9233 - top_k_categorical_accuracy:
 0.9963

同时,测试输出的结果如下所示:

1000/1000 [==============================] - 11s 11ms/step - loss: 0.5646 - categorical_accuracy: 0.8080 - top_k_categorical_accuracy: 0.9890

对于品种,我们得到了更差的结果,这是预期的,因为区分一个品种比仅仅判断它是猫还是狗要困难得多。无论如何,模型的表现并不太差。它的第一次尝试猜测有超过 80% 是正确的,我们也可以有大约 99% 的把握,如果它有 5 次尝试,它将猜对品种。

在本节中,我们学习了如何使用预训练分类器网络构建一个新的分类器。在下一节中,让我们继续我们的深度学习之旅,并使用相同的基础模型创建一个对象定位网络——这是一个基础模型从未训练过的任务。

使用 CNN 进行定位

能够创建自己的定位器是了解对象检测网络可能如何工作的一种好方法。这是因为对象检测网络和定位网络之间唯一的理念差异在于,定位网络预测单个边界框,而对象检测网络预测多个边界框。此外,这也是开始理解如何构建一个能够完成其他回归任务的神经网络的好方法。

在本节中,我们将使用与上一节相同的预训练分类器网络,MobileNetV2。然而,这次我们将使用该网络进行对象定位而不是分类。让我们以与上一节相同的方式导入所需的模块和基础模型——尽管这次,我们不会冻结基础模型中的层:

import tensorflow.keras as K

from data import ds

base_model = K.applications.MobileNetV2(
    input_shape=(224, 224, 3), include_top=False)

现在我们已经准备好了所有东西,让我们继续准备我们的定位器模型。

准备模型

首先,让我们思考一下我们如何使用基础模型的输出制作一个定位器。

如前所述,基础模型的输出张量形状为(None, 7, 7, 1280)。输出张量表示使用卷积网络获得的特征。我们可以假设一些空间信息被编码在空间索引(7,7)中。

让我们尝试使用几个卷积层来降低特征图的维度,并创建一个回归器,该回归器应该预测数据集中提供的宠物头部边界框的角坐标。

我们的卷积层将有几个相同的选项:

conv_opts = dict(
    activation='relu',
    padding='same',
    kernel_regularizer="l2")

首先,它们都将使用ReLU(修正线性单元)作为激活函数。后者是一个简单的函数,当输入小于零时为零,当输入大于或等于零时等于输入。

padding=same指定我们不想卷积操作减小特征图的大小。特征图将通过填充零来填充,这样特征图就不会减小大小。这与padding='valid'形成对比,后者只将卷积核应用于特征图的边缘。

经常来说,正则化训练参数、归一化它们或两者都做是一个好主意。后者通常允许你更容易、更快地训练,并且泛化得更好。正则化器允许你在优化期间对层参数应用惩罚。这些惩罚被纳入网络优化的损失函数中。

在我们的情况下,我们使用l2核正则化器,该正则化器正则化了卷积核权重的欧几里得范数。正则化是通过将项添加到损失函数(目标函数)中实现的。在这里,是一个小的常数,而L2范数,它等于层参数平方和的平方根。

这是最常用的正则化项之一。现在我们准备好定义我们的卷积层。第一层如下所示:

x = K.layers.Conv2D(256, (1, 1), **conv_opts)(base_model.output)

在这里,第一个参数是输出通道数,也就是卷积核的数量。第二个参数描述了卷积核的大小。乍一看,单个像素的卷积核可能没有太多意义,因为它不能编码特征图的上下文信息。

这无疑是正确的;然而,在这种情况下,它被用于不同的目的。这是一个快速操作,允许在较低维度中编码输入特征图的深度。深度从 1280 减少到256

下一个层看起来如下:

x = K.layers.Conv2D(256, (3, 3), strides=2, **conv_opts)(x)

在这里,除了我们使用的默认选项之外,我们还使用了步长,它指定了输入上的像素偏移量。在第一章,“与过滤器一起玩乐”,卷积操作在每个位置应用,这意味着过滤器每次移动一个像素,相当于步长等于 1。

strides选项为2时,我们每次移动过滤器两个像素。这个选项以复数形式出现,因为我们可能希望在不同方向上有不同的步长,这可以通过传递一个数字元组来实现。使用大于 1 的stride值是一种在不丢失空间信息的情况下减小激活图大小的手段。

当然,还有其他可以减小激活图大小的操作。例如,可以使用称为最大池化的操作,这是现代卷积网络中最广泛使用的操作之一。后者采用较小的窗口大小(例如,2 x 2),从这个窗口中选取一个最大值,然后移动指定数量的像素(例如,2),并在整个激活图上重复此过程。因此,通过这个过程,激活图的大小将减少 2 倍。

与使用步长的方法相比,最大池化操作更适合我们不太关心空间信息的任务。例如,这些任务包括分类任务,我们对其中的对象确切位置不感兴趣,而只是对其是什么感兴趣。在最大池化中丢失空间信息发生在我们简单地从一个窗口中取最大值而不考虑其在窗口中的位置时。

我们最后想要做的是将一个包含四个神经元的密集层连接到卷积层,这将回归到边界框的两个角坐标(每个角为(x,y)):

out = K.layers.Flatten()(x)
out = K.layers.Dense(4, activation="sigmoid")(out)

由于边界框的坐标是归一化的,因此使用一个值在(0,1)范围内的激活函数,例如sigmoid函数,是个好主意。

所有必要的层都已准备就绪。现在,让我们使用新层定义模型,并编译它以进行训练:

model = K.Model(inputs=base_model.input, outputs=out)
model.compile(
    loss="mean_squared_error",
    optimizer="adam",
    metrics=[
        K.metrics.RootMeanSquaredError(),
        "mae"])

我们使用均方误差MSE)作为loss函数,它是真实值和预测值之间平方差的函数。在训练过程中,这个值将被最小化;因此,模型在训练后应该预测角坐标。

我们添加到卷积层的正则化项也如讨论的那样添加到loss中。这由 Keras 自动完成。此外,我们还使用均方根误差RMSE)和平均绝对误差MAE),后者衡量误差的平均幅度,作为我们的指标。

现在,让我们以与上一节相同的方式分割数据集:

inp_ds = ds.map(lambda d: (d.image,d.box))
valid = inp_ds.take(1000)
train = inp_ds.skip(1000).shuffle(10000)

剩下的工作就是训练我们的模型,就像我们在上一节中所做的那样。然而,在我们继续训练之前,你可能对了解我们新层的训练是如何完成的感兴趣。在多层神经网络中,训练通常使用反向传播算法进行,所以让我们在下一节中首先了解这一点。

理解反向传播

当我们有一些网络的优化权重,使得网络对我们的数据做出良好的预测时,我们认为神经网络已经训练好了。所以,问题是我们是怎样达到这些优化权重的?神经网络通常使用梯度下降算法进行训练。这可能纯粹是梯度下降算法,或者是一些改进的优化方法,如Adam 优化器,它再次基于计算梯度。

在所有这些算法中,我们需要计算损失函数相对于所有权重的梯度。由于神经网络是一个复杂的函数,这可能看起来并不直接。这就是反向传播算法介入的地方,它使我们能够在复杂的网络中轻松地计算梯度,并了解梯度看起来是什么样子。让我们深入了解算法的细节。

假设我们有一个由N个连续层组成的神经网络。一般来说,这样的网络中的i^(th)层是一个可以定义为以下函数:

图片

这里,图片是层的权重,图片是前一层对应的函数。

我们可以将图片定义为网络的输入,这样公式就适用于包括第一层的完整神经网络。

我们也可以定义图片为我们的损失函数,这样公式不仅定义了所有层,还定义了损失函数。当然,这种泛化排除了我们已使用的权重归一化项。然而,这是一个简单的项,它只是加到损失上,因此为了简单起见可以省略。

我们可以通过设置图片并使用链式法则来计算损失函数的梯度:

图片

根据我们的定义,这个公式不仅适用于损失函数,而且适用于所有层。在这个公式中,我们可以看到,某一层相对于前一层中所有权重的偏导数(包括当前层)是用前一层的相同导数表示的,即公式中的  项,以及只能使用当前层计算出的项,即  和 

使用公式,我们现在可以数值地计算梯度。为了做到这一点,我们首先定义一个代表误差信号的变量,并将其初始值设为 1。很快就会清楚为什么它代表误差信号。然后,我们从最后一层(在我们的例子中是损失函数)开始,重复以下步骤,直到我们到达网络的输入层:

  1. 计算当前层相对于其权重的偏导数,并乘以误差信号。这将对应于当前层权重的梯度部分。

  2. 对前一层的偏导数进行计算,乘以误差信号,然后用得到的结果更新误差信号。

  3. 如果网络的输入没有被到达,就移动到前一层并重复这些步骤。

一旦我们到达输入层,我们就有了损失函数相对于可学习权重的所有偏导数;因此,我们得到了损失函数的梯度。现在我们可以注意到,这是在梯度计算过程中通过网络传播的层的偏导数。

那是一个传播信号,它影响每个层对损失函数梯度的贡献。例如,如果在传播过程中某处变为全零,那么所有剩余层对梯度的贡献也将为零。这种现象称为梯度消失问题。此算法可以推广到具有不同类型分支的循环网络。

为了训练我们的网络,我们剩下的唯一要做的事情就是沿着梯度的方向更新我们的权重,并重复这个过程直到收敛。如果使用纯梯度下降算法,我们只需从权重中减去乘以某个小常数的梯度;然而,通常情况下,我们会使用更高级的优化算法,例如 Adam 优化器。

纯梯度下降算法的问题在于,首先,我们应该找到一个最优的小常数值,以便权重的更新既不会太小,导致学习速度慢,也不会太大,因为太大的值会导致不稳定性。另一个问题是,一旦我们找到了一个最优值,一旦网络开始收敛,我们就必须开始减小它。更重要的是,通常情况下,使用不同的因子更新不同的权重是明智的,因为不同的权重可能距离它们的最优值有不同距离。

这些是我们可能想要使用更高级优化技术(如 Adam 优化器或RMSProp)的一些原因,这些技术考虑了这些提到的问题,甚至考虑了一些未提到的问题。同时,在创建你的网络时,你应该注意,优化算法领域的研究仍在进行中,尽管 Adam 优化器对于许多任务来说应该是一个不错的选择,但现有的某些优化器在某些情况下可能比其他优化器更好。

你可能还会注意到,在算法中,我们没有具体说明如何计算层中的偏导数。当然,可以通过改变值并测量响应来数值计算它们,就像使用数值方法计算导数一样。问题是这样的计算既费时又容易出错。更好的方法是定义每个操作的符号表示,然后再次使用链式法则,就像在反向传播中做的那样。

因此,我们现在理解了完整梯度的计算方法。实际上,大多数现代深度学习框架都会为你完成微分。你通常不需要担心它是如何实现的,但如果计划开发新的,即你自己的模型,了解计算的背景可能非常有帮助。

但现在,让我们在下一节中训练我们准备好的模型,看看它的表现如何。

训练模型

在我们进行实际训练之前,有一个保存最佳权重的模型的好方法。为此,我们将使用 Keras 的回调:

checkpoint = K.callbacks.ModelCheckpoint("localization.h5",
    monitor='val_root_mean_squared_error',
    save_best_only=True, verbose=1)

训练回调将在每次训练周期后调用;它将计算验证数据上预测的root_mean_square_error指标,如果指标有所改进,则会将模型保存到localization.h5

现在,我们以与分类相同的方式进行模型训练:

model.fit(
    train.batch(32),
    epochs=12,
    validation_data=valid.batch(1),
    callbacks=[checkpoint])

这里,不同之处在于我们这次使用更多的epochs进行训练,以及传递我们的回调和验证数据集。

在训练过程中,你首先会看到损失和指标在训练和验证数据上都有所下降。经过几个epochs后,你可能会看到验证数据上的指标有所上升。后者可能被认为是过拟合的迹象,但在更多的epochs之后,你可能会看到validation_data上的指标突然下降。后者的现象是因为模型在优化过程中切换到了更好的最小指标。

这里是监控指标最低值的结果:

Epoch 8/12
 83/84 [============================>.] - ETA: 0s - loss: 0.0012 - root_mean_squared_error: 0.0275 - mae: 0.0212
 Epoch 00008: val_root_mean_squared_error improved from 0.06661 to 0.06268, saving model to best_model.hdf5
 84/84 [==============================] - 39s 465ms/step - loss: 0.0012 - root_mean_squared_error: 0.0275 - mae: 0.0212 - val_loss: 0.0044 - val_root_mean_squared_error: 0.0627 - val_mae: 0.0454 

你可以注意到,在这种情况下,是第八个epoch在验证数据上表现最好。你可以注意到,在验证数据上的 RMSE 偏差大约是 6%。MAE 小于 6%。我们可以这样解释这个结果——给定一个验证数据集中的图像,边界框的角坐标通常会被图像大小的 1/20 所偏移,由于边界框的大小与图像大小相当,所以这不是一个坏的结果。

你还可能想尝试使用基础模型的冻结层来训练模型。如果你这样做,你将注意到性能远比未冻结模型差。根据指标,它在验证数据集上的表现大约差两倍。考虑到这些数字,我们可以得出结论,基础模型的层能够在数据集上学习,从而使我们的模型在定位任务上表现更好。

因此,现在我们的模型已经准备好了,让我们在下一节使用我们的推理脚本来看看它能做什么。

观察推理的实际应用

一旦我们运行推理脚本,它将连接到相机并在每一帧上定位一个框,如下面的照片所示:

尽管模型是在宠物头部位置上训练的,但我们可以看到它在定位人的头部方面相当出色。这就是你可以注意到模型泛化能力的时刻。

当你创建自己的深度学习应用时,你可能会发现你为特定应用缺乏数据。然而,如果你将你的特定案例与其他可用的数据集联系起来,你可能会找到一些适用的数据集,尽管它们不同,但可能允许你成功训练你的模型。

摘要

在本章中,我们使用 Oxford-IIIT-Pet 数据集创建和训练了分类和定位模型。我们学习了如何使用迁移学习创建深度学习分类器和定位器。

你已经开始理解如何使用深度学习解决现实世界的问题。你已经理解了 CNN 是如何工作的,并且知道如何使用基础模型创建一个新的 CNN。

我们还介绍了用于计算梯度的反向传播算法。理解这个算法将使你能够对未来可能想要构建的模型架构做出更明智的决定。

在下一章中,我们将继续我们的深度学习之旅。我们将创建一个能够以高精度检测和跟踪物体的应用程序。

数据集归属

牛津-IIIT-Pet 数据集猫和狗,O. M. Parkhi, A. Vedaldi, A. Zisserman, C. V. Jawahar 在 IEEE 计算机视觉与模式识别会议,2012 年。

第十章:学习检测和跟踪对象

在上一章中,你接触了深度卷积神经网络,并使用迁移学习构建了深度分类和定位网络。你已经开始了深度学习之旅,并熟悉了一系列深度学习概念。你现在理解了深度模型的训练过程,并准备好学习更多高级的深度学习概念。

在本章中,你将继续你的深度学习之旅,首先使用目标检测模型在相关场景的视频中检测不同类型的多个对象,例如带有汽车和人的街景视频。之后,你将学习如何构建和训练这样的模型。

通常,鲁棒的目标检测模型在当今有着广泛的应用。这些领域包括但不限于医学、机器人技术、监控以及许多其他领域。了解它们的工作原理将使你能够使用它们来构建自己的实际应用,并在其基础上开发新的模型。

在我们介绍目标检测之后,我们将实现简单在线实时跟踪Sort)算法,该算法能够鲁棒地在帧之间跟踪检测到的对象。在 Sort 算法的实现过程中,你还将熟悉卡尔曼滤波器,它通常是在处理时间序列时的重要算法。

一个好的检测器和跟踪器的组合在工业问题中有着多种应用。在本章中,我们将通过计算相关场景视频中出现的不同类型的总对象数量来限制应用范围。一旦你理解了如何完成这个特定任务,你可能会产生自己的使用想法,这些想法最终会体现在你的应用中。

例如,拥有一个好的对象跟踪器可以使你回答诸如场景的哪个部分看起来更密集?以及,在观察时间内,物体移动得更快或更慢的地方在哪里?在某些情况下,你可能对监控特定物体的轨迹、估计它们的速度或它们在场景不同区域停留的时间感兴趣。一个好的跟踪器是解决所有这些问题的方案。

本章将涵盖以下主题:

  • 准备应用

  • 准备主脚本

  • 使用 SSD 检测对象

  • 理解对象检测器

  • 跟踪检测到的对象

  • 实现 Sort 跟踪器

  • 理解卡尔曼滤波器

  • 观看应用的实际运行

让我们从指出技术要求和规划应用开始本章。

开始

正如本书的所有章节所提到的,你需要安装合适的OpenCVSciPyNumPY

你可以在 GitHub 存储库中找到我们本章中展示的代码,网址为github.com/PacktPublishing/OpenCV-4-with-Python-Blueprints-Second-Edition/tree/master/chapter10

当使用 Docker 运行应用程序时,Docker 容器应能够访问适当的 X11 服务器。此应用程序不能在 无头模式 下运行。使用 Docker 运行应用程序的最佳环境是 Linux 桌面环境。在 macOS 上,你可以使用 xQuartz(参考www.xquartz.org/)来创建可访问的 X11 服务器。

你也可以使用存储库中可用的 Docker 文件来运行应用程序。

规划应用程序

如前所述,最终的应用程序将能够检测、跟踪和计数场景中的对象。这需要以下组件:

  • main.py: 这是用于实时检测、跟踪和计数对象的脚本。

  • sort.py: 这是一个实现跟踪算法的模块。

我们首先准备主脚本。在准备过程中,你将学习如何使用检测网络,以及它们的工作原理和训练方法。在同一脚本中,我们将使用跟踪器来跟踪和计数对象。

准备完主脚本后,我们将准备跟踪算法,并能够运行应用程序。现在让我们开始准备主脚本。

准备主脚本

主脚本将负责应用程序的完整逻辑。它将处理视频流并使用我们将在本章后面准备的深度卷积神经网络进行对象检测,并结合跟踪算法。

该算法用于从帧到帧跟踪对象。它还将负责展示结果。脚本将接受参数并具有一些内在常量,这些常量在脚本以下初始化步骤中定义:

  1. 与任何其他脚本一样,我们首先导入所有必需的模块:
import argparse

import cv2
import numpy as np

from classes import CLASSES_90
from sort import Sort

我们将使用 argparse,因为我们希望我们的脚本接受参数。我们将对象类别存储在单独的文件中,以避免污染脚本。最后,我们导入我们将在本章后面构建的 Sort 跟踪器。

  1. 接下来,我们创建并解析参数:
parser = argparse.ArgumentParser()
parser.add_argument("-i", "--input",
                    help="Video path, stream URI, or camera ID ", default="demo.mkv")
parser.add_argument("-t", "--threshold", type=float, default=0.3,
                    help="Minimum score to consider")
parser.add_argument("-m", "--mode", choices=['detection', 'tracking'], default="tracking",
                    help="Either detection or tracking mode")

args = parser.parse_args()

我们的第一个参数是输入,可以是视频的路径、摄像头的 ID(默认摄像头为 0),或视频流的 统一资源标识符URI)。例如,你将能够使用 实时传输控制协议RTCP)将应用程序连接到远程 IP 摄像头。

我们将使用的网络将预测对象的边界框。每个边界框都将有一个分数,该分数将指定边界框包含特定类型对象的概率有多高。

下一个参数是 threshold,它指定了分数的最小值。如果分数低于 threshold,则我们不考虑该检测。最后一个参数是 mode,我们想要以该模式运行脚本。如果我们以 检测 模式运行,算法的流程将在检测到对象后停止,不会进一步进行跟踪。对象检测的结果将在帧中展示。

  1. OpenCV 接受摄像头的 ID 作为整数。如果我们指定摄像头的 ID,输入参数将是一个字符串而不是整数。因此,如果需要,我们需要将其转换为整数:
if args.input.isdigit():
    args.input = int(args.input)
  1. 接下来,我们定义所需的常量:
TRACKED_CLASSES = ["car", "person"]
BOX_COLOR = (23, 230, 210)
TEXT_COLOR = (255, 255, 255)
INPUT_SIZE = (300,300)

在这个应用中,我们将追踪汽车和行人。我们将用黄色调的色调展示边界框,并用白色书写文字。我们还将定义我们将要用于检测的 单次检测器SSD)模型的标准化输入大小。

使用 SSD 检测对象

OpenCV 有导入使用深度学习框架构建的模型的方法。我们如下加载 TensorFlow SSD 模型:

config = "./ssd_mobilenet_v1_coco_2017_11_17.pbtxt.txt"
model = "frozen_inference_graph.pb"
detector = cv2.dnn.readNetFromTensorflow(model,config)

readNetFromTensorflow 方法的第一个参数接受一个包含二进制 Protobuf协议缓冲区)格式 TensorFlow 模型的文件路径。第二个参数是可选的。它是包含模型图定义的文本文件路径,同样也是以 Protobuf 格式。

当然,模型文件本身可能包含图定义,OpenCV 可以从模型文件中读取该定义。但是,对于许多网络,可能需要创建一个单独的定义,因为 OpenCV 无法解释 TensorFlow 中所有可用的操作,这些操作应该被 OpenCV 可以解释的操作所替代。

让我们现在定义一些有用的函数来展示检测。第一个函数用于展示单个边界框:

def illustrate_box(image: np.ndarray, box: np.ndarray, caption: str) -> None:

从前面的代码中,illustrate_box 函数接受一个图像,一个归一化的边界框,作为包含框两个对角顶点的四个坐标的数组。它还接受一个框的标题。然后,函数中涵盖了以下步骤:

  1. 首先提取图像的大小:
rows, cols = frame.shape[:2]
  1. 然后它提取两个点,按图像大小进行缩放,并将它们转换为整数:
points = box.reshape((2, 2)) * np.array([cols, rows])
p1, p2 = points.astype(np.int32)
  1. 之后,我们使用两个点绘制相应的 矩形
cv2.rectangle(image, tuple(p1), tuple(p2), BOX_COLOR, thickness=4)
  1. 最后,我们在第一个点附近放置标题:
cv2.putText(
    image,
    caption,
    tuple(p1),
    cv2.FONT_HERSHEY_SIMPLEX,
    0.75,
    TEXT_COLOR,
    2)

第二个函数将展示所有 检测,如下所示:

def illustrate_detections(dets: np.ndarray, frame: np.ndarray) -> np.ndarray:
    class_ids, scores, boxes = dets[:, 0], dets[:, 1], dets[:, 2:6]
    for class_id, score, box in zip(class_ids, scores, boxes):
        illustrate_box(frame, box, f"{CLASSES_90[int(class_id)]} {score:.2f}")
    return frame

从前面的代码片段中,第二个函数接受检测作为二维 numpy 数组和用于展示检测的帧。每个检测包括被检测对象的类别 ID、一个分数,表示边界框包含指定类别对象的概率,以及检测本身的边界框。

函数首先提取所有检测的先前所述值,然后使用illustrate_box方法展示每个检测的边界框。类别名称和score作为框的标题添加。

现在让我们连接到摄像头:

cap = cv2.VideoCapture(args.input)

我们将input参数传递给VideoCapture,正如之前提到的,它可以是一个视频文件、流或摄像头 ID。

现在我们已经加载了网络,定义了所需的说明函数,并打开了视频捕获,我们准备遍历帧、检测对象并展示结果。我们使用for循环来完成这个目的:

for res, frame in iter(cap.read, (False, None)):

循环体包含以下步骤:

  1. 它将帧设置为detector网络的输入:
detector.setInput(
    cv2.dnn.blobFromImage(
        frame,
        size=INPUT_SIZE,
        swapRB=True,
        crop=False))

blobFromImage从提供的图像创建一个四维输入网络。它还将图像调整到输入大小,并将图像的红蓝通道交换,因为网络是在 RGB 图像上训练的,而 OpenCV 读取帧为 BGR。

  1. 然后它使用网络进行预测,并得到所需格式的输出:
detections = detector.forward()[0, 0, :, 1:]

从前面的代码中,forward代表前向传播。结果是二维的numpy数组。数组的第一个索引指定检测编号,第二个索引表示一个特定的检测,它通过对象类别、得分和四个值来表示边界框的两个角坐标。

  1. 之后,它从detections中提取scores,并过滤掉得分非常低的那些:
scores = detections[:, 1]
detections = detections[scores > 0.3]
  1. 当脚本以detection模式运行时,立即展示detections
if args.mode == "detection":
    out = illustrate_detections(detections, frame)
    cv2.imshow("out", out)
  1. 然后我们必须设置终止条件:
if cv2.waitKey(1) == 27:
    exit()

现在我们已经准备好以检测模式运行我们的脚本。接下来的图像显示了样本结果:

图片

你可以在前一张图像的帧中注意到,SSD 模型已经成功检测到场景中所有的汽车和单个个体(人)。现在让我们看看如何使用其他目标检测器。

使用其他检测器

在本章中,我们使用一个目标检测器来获取带有其对象类型的边界框,这些边界框将被 Sort 算法进一步处理以进行跟踪。一般来说,获取这些边界框的确切方式并不重要。在我们的案例中,我们使用了 SSD 预训练模型。现在让我们了解如何用不同的模型替换它。

让我们首先了解如何使用 YOLO 来完成这个目的。YOLO 也是一个单阶段检测器,代表You Only Look OnceYOLO)。原始 YOLO 模型基于Darknet,这是一个另一个开源神经网络框架,用 C++和 CUDA 编写。OpenCV 能够加载基于 Darknet 的网络,类似于它加载 TensorFlow 模型的方式。

为了加载 YOLO 模型,您首先需要下载包含网络配置和网络权重的文件。

这可以通过访问pjreddie.com/darknet/yolo/来完成。在我们的例子中,我们将使用YOLOv3-tiny,这是当时最轻量级的版本。

下载了网络配置和权重后,你可以像加载 SSD 模型一样加载它们:

detector = cv2.dnn.readNetFromDarknet("yolov3-tiny.cfg", "yolov3-tiny.weights")

不同之处在于,使用readNetFromDarknet函数而不是readNetFromTensorflow

为了使用这个检测器代替 SSD,我们有一些事情要做:

  • 我们必须更改输入的大小:
INPUT_SIZE = (320, 320)

网络最初是在指定的尺寸下训练的。如果你有一个高分辨率的输入视频流,并且希望网络检测场景中的小物体,你可以将输入设置为不同的尺寸,例如 160 的倍数,例如尺寸(640,480)。输入尺寸越大,检测到的物体越小,但网络预测会变慢。

  • 我们必须更改类名:
with open("coco.names") as f:
    CLASSES_90 = f.read().split("\n")

尽管 YOLO 网络是在COCO数据集上训练的,但对象的 ID 是不同的。在这种情况下,你仍然可以使用之前的类名运行,但那样你会得到错误的类名。

你可以从 darknet 仓库github.com/pjreddie/darknet下载文件。

  • 我们必须稍微更改输入:
detector.setInput(
    cv2.dnn.blobFromImage(
        frame,
        scalefactor=1 / 255.0,
        size=INPUT_SIZE,
        swapRB=True,
        crop=False))

与 SSD 的输入相比,我们添加了scalefactor,它对输入进行归一化。

现在我们已经准备好成功地进行预测。尽管如此,我们还没有完全准备好使用这个检测器显示结果。问题是 YOLO 模型的预测格式不同。

每个检测都包括边界框中心的坐标:边界框的宽度和高度,以及一个表示边界框中每种类型对象概率的 one-hot 向量。为了最终完成集成,我们必须将检测转换为我们在应用中使用的格式。这可以通过以下步骤完成:

  1. 我们提取边界框的中心坐标:
centers = detections[:, 0:2]
  1. 然后,我们也提取边界框的宽度和高度:
sizes = detections[:, 2:4]
  1. 然后,我们提取scores_one_hot
scores_one_hot = detections[:, 5:]
  1. 然后,我们找到最大分数的class_ids
class_ids = np.argmax(scores_one_hot, axis=1)
  1. 之后,我们提取最大分数:
scores = np.max(scores_one_hot, axis=1)
  1. 然后,我们使用前一步骤获得的结果,以应用其他部分所需格式构建detections
detections = np.concatenate(
    (class_ids[:, None], scores[:, None], centers - sizes / 2, centers + sizes / 2), axis=1)
detections = detections[scores > 0.3]

现在我们可以成功运行带有新检测器的应用。根据你的需求、可用资源和所需的精度,你可能希望使用其他检测模型,例如 SSD 的其他版本或Mask-RCNN,这是当时最准确的对象检测网络之一,尽管它的速度比 SSD 模型慢得多。

你可以尝试使用 OpenCV 加载你选择的模型,就像我们在本章中为 YOLO 和 SSD 所做的那样。使用这种方法,你可能会遇到加载模型的困难。例如,你可能需要调整网络配置,以便网络中的所有操作都可以由 OpenCV 处理。

后者尤其是因为现代深度学习框架发展非常快,而 OpenCV 至少需要时间来赶上,以便包括所有新的操作。你可能更喜欢的一种方法是使用原始框架运行模型,就像我们在第九章,“学习分类和定位物体”中所做的那样。

因此,既然我们已经了解了如何使用检测器,那么让我们在下一节中看看它们是如何工作的。

理解物体检测器

在第九章,“学习分类和定位物体”中,我们学习了如何使用卷积神经网络某一层的特征图来预测场景中物体的边界框,在我们的例子中是头部。

你可能会注意到,我们组成的定位网络与检测网络(我们在本章中使用)之间的区别在于,检测网络预测多个边界框而不是一个,并为每个边界框分配一个类别。

现在让我们在这两种架构之间进行平滑过渡,以便你可以理解像 YOLO 和 SSD 这样的物体检测网络是如何工作的。

单物体检测器

首先,让我们看看如何与边界框并行预测类别。在第九章,“学习分类和定位物体”中,你也学习了如何制作一个分类器。没有什么限制我们只能在单个网络中将分类与定位结合起来。这是通过将分类和定位块连接到基础网络的相同特征图,并使用损失函数(定位和分类损失的加和)一起训练来实现的。你可以作为一个练习创建并训练这样的网络。

问题仍然存在,如果场景中没有物体怎么办? 为了解决这个问题,我们可以在训练时简单地添加一个与背景相对应的额外类别,并将边界框预测器的损失设置为 0。结果,你将拥有一个可以检测多个类别的物体检测器,但只能检测场景中的一个物体。现在让我们看看我们如何预测多个边界框而不是一个,从而得出物体检测器的完整架构。

滑动窗口方法

最早用于创建能够检测场景中多个对象的架构的方法之一是滑动窗口方法。使用这种方法,你首先为感兴趣的对象构建一个分类器。然后,你选择一个矩形(窗口)的大小,这个大小是图像大小的几倍或许多倍,你希望在图像中检测对象。之后,你将其滑动到图像的所有可能位置,并判断矩形中的每个位置是否包含所选类型的对象。

在滑动过程中,使用介于框大小的一部分和完整框大小之间的滑动窗口大小。使用不同大小的滑动窗口重复此过程。最后,你选择那些具有高于某个阈值的类别分数的窗口位置,并报告这些窗口位置及其大小是所选对象类别的边界框。

这种方法的缺点是,首先,需要在单张图像上执行大量的分类,因此检测器的架构将会相当庞大。另一个问题是,对象仅以滑动窗口大小的精度进行定位。此外,检测边界框的大小必须等于滑动窗口的大小。当然,如果减小滑动窗口的大小并增加窗口大小的数量,检测效果可能会得到改善,但这将导致更高的计算成本。

你可能已经想到的一个想法是将单目标检测器与滑动窗口方法结合起来,并利用两者的优势。例如,你可以将图像分割成区域。例如,我们可以取一个 5 x 5 的网格,并在网格的每个单元格中运行单目标检测器。

你甚至可以更进一步,通过创建更大或更小的网格,或者使网格单元格重叠。作为一个迷你项目,为了深入理解所涵盖的想法,你可能喜欢实现它们并玩弄结果。然而,使用这些方法,我们使架构更重,也就是说,一旦我们扩大网格大小或网格数量以改进准确性。

单次遍历检测器

在之前提到的想法中,我们使用了单目标分类或检测网络来实现多目标检测。在所有场景中,对于每个预定义的区域,我们多次将整个图像或其部分输入到网络中。换句话说,我们有多重遍历,导致架构庞大。

拥有一个网络,一旦输入图像,就能在单次遍历中检测场景中的所有对象,这不是很好吗? 你可以尝试的一个想法是为我们的单目标检测器创建更多的输出,使其预测多个框而不是一个。这是一个好主意,但存在问题。假设场景中有多个狗,它们可能出现在不同的位置和不同的数量。

我们应该如何使狗和输出之间建立一种不变对应关系呢? 如果我们尝试通过将盒子分配给输出,例如从左到右,来训练这样一个网络,那么我们最终得到的预测结果将接近所有位置的平均值。

SSD 和 YOLO 等网络处理这些问题,并在单次遍历中实现多尺度和多框检测。我们可以用以下三个组件来总结它们的架构:

  • 首先,它们有一个位置感知的多框检测器连接到特征图。我们已经讨论了将多个框预测器连接到完整特征图时出现的训练问题。SSD 和 YOLO 的问题通过有一个连接到特征图小区域的预测器来解决,而不是连接到完整特征图。

它预测图像中与特征图对应区域的框。然后,相同的预测器在整个特征图的所有可能位置上进行预测。这个操作是通过卷积层实现的。存在一些卷积核,它们的激活在特征图上滑动,并具有坐标和类别作为它们的输出特征图。

例如,如果你回到定位模型的代码,并用具有四个核的卷积层替换最后两层,这些层将输出展平并创建四个全连接神经元来预测框坐标,你就可以获得类似的操作。此外,由于预测器在特定区域工作,并且只对该区域有意识,它们预测的坐标是相对于该区域的,而不是相对于完整图像的坐标。

  • YOLO 和 SSD 在每个位置都预测多个框,而不是一个。它们从几个默认框预测偏移坐标,这些默认框也称为锚框。这些框的大小和形状与数据集中或自然场景中的对象非常接近,因此相对坐标值较小,甚至默认框与对象边界框也非常吻合。

例如,一辆车通常表现为一个宽框,而一个人通常表现为一个高框。多个框允许你达到更高的精度,并在同一区域进行多个预测。例如,如果图像中某处有一个人坐在自行车上,而我们只有一个框,那么我们就会忽略其中一个对象。使用多个锚框,对象将对应于不同的锚框。

  • 除了具有多尺寸锚框外,它们还使用不同尺寸的多个特征图来完成多尺度预测。如果预测模块连接到网络的小尺寸顶部特征图,它负责大对象。

如果它与底部的某个特征图相连,它负责小物体。一旦在所选特征图中做出所有多框预测,结果将被转换为图像的绝对坐标并连接起来。因此,我们获得了本章中使用的预测形式。

如果你感兴趣更多实现细节,我们建议你阅读相应的论文,以及分析相应的实现代码。

既然你现在已经了解了探测器的工作原理,你可能也对它们的训练原理感兴趣。然而,在我们理解这些原理之前,让我们先了解一个称为交并比的度量,它在训练和评估这些网络以及过滤它们的预测时被广泛使用。

我们还将实现一个计算此度量的函数,我们将在构建跟踪的排序算法时使用它。因此,你应该注意,理解这个度量不仅对目标检测很重要,对跟踪也很重要。

学习交并比

交并比IoU),也称为Jaccard 指数,定义为交集大小除以并集大小,其公式如下:

图片

该公式等同于以下公式:

图片

在下面的图中,我们展示了两个框的交并比(IoU):

图片

在前面的图中,并集是整个图形的总面积,交集是框重叠的部分。交并比(IoU)的值在(0,1)范围内,只有当框完全匹配时才达到最大值。一旦框分离,它变为零。

让我们定义一个函数,它接受两个边界框并返回它们的iou值:

def iou(a: np.ndarray, b: np.ndarray) -> float:

为了计算iou值,以下步骤是必要的:

  1. 我们首先提取两个边界框的左上角和右下角坐标:
a_tl, a_br = a[:4].reshape((2, 2))
b_tl, b_br = b[:4].reshape((2, 2))
  1. 然后,我们得到两个左上角元素级的最大值
int_tl = np.maximum(a_tl, b_tl)

两个数组进行元素级比较,结果将是一个新数组,包含数组中相应索引的较大值。在我们的情况下,获得并存储在int_tl中的最大xy坐标。如果框相交,这是交集的左上角。

  1. 然后,我们得到右下角元素级的最小值
int_br = np.minimum(a_br, b_br)

与前一种情况类似,如果框相交,这是交集的右下角。

  1. 然后,我们计算边界框的面积:
a_area = np.product(a_br - a_tl)
b_area = np.product(b_br - b_tl)

框的右下角和左上角的坐标差是框的宽度和高度,因此结果数组的元素乘积是边界框的面积。

  1. 然后,我们计算交集区域:
int_area = np.product(np.maximum(0., int_br - int_tl))

如果框没有重叠,结果数组中至少有一个元素将是负数。负值被替换为零。因此,在这种情况下,面积为零,正如预期的那样。

  1. 最后,我们计算 IoU 并返回结果:
return int_area / (a_area + b_area - int_area)

所以,现在你已经理解了什么是 IoU,并且已经构建了一个计算它的函数,你就可以学习如何训练所使用的检测网络了。

训练 SSD 和 YOLO 类似的网络

你已经知道,YOLO 和 SSD 等网络使用预定义的锚框来预测对象。在所有可用的框中,只有一个框被选中,对应于对象。在预测时间,该框被分配给对象的类别,并预测偏移量。

那么,问题来了,我们如何选择那个单独的框呢? 你可能已经猜到了,IoU 就是用来这个目的的。真实框和锚框之间的对应关系可以如下建立:

  1. 创建一个矩阵,包含所有可能的真实框和锚框对的 IoU 值。比如说,行对应于真实框,列对应于锚框。

  2. 在矩阵中找到最大元素,并将相应的框分配给对方。从矩阵中移除最大元素的行和列。

  3. 重复步骤 2,直到没有可用的真实框,换句话说,直到矩阵的所有行都被移除。

一旦完成分配的任务,剩下的就是为每个框定义一个损失函数,将结果相加作为总损失,并训练网络。对于包含对象的框的偏移量损失可以简单地定义为 IoU——IoU 值越大,边界框就越接近真实值,因此其负值应该减少。

不包含对象的锚框不会对损失做出贡献。对象类别的损失也很直接——没有分配的锚框用背景类别训练,而有分配的锚框则用它们对应的类别训练。

考虑到的每个网络都对描述的损失进行了一些修改,以便在特定网络上实现更好的性能。你可以选择一个网络,并自己定义这里描述的损失,这将是一个很好的练习。如果你正在构建自己的应用程序,并且需要在有限的时间内获得相对高精度的相应训练网络,你可能会考虑使用相应网络代码库中附带的训练方法。

所以,现在你已经了解了如何训练这些网络,让我们继续应用程序的main脚本,并在下一节中将其与 Sort 跟踪器集成。

跟踪检测到的对象

一旦我们能够在每一帧中成功检测到物体,我们就可以通过关联帧之间的检测来跟踪它们。如前所述,在本章中,我们使用 Sort 算法进行多目标跟踪,该算法代表简单在线实时跟踪

给定多个边界框的序列,此算法关联序列元素的边界框,并根据物理原理微调边界框坐标。其中一个原则是物理对象不能迅速改变其速度或运动方向。例如,在正常条件下,一辆行驶的汽车不能在连续两帧之间改变其运动方向。

我们假设检测器正确标注了物体,并且为我们要跟踪的每个物体类别实例化一个多目标跟踪器mots):

TRACKED_CLASSES = ["car", "person"]
mots = {CLASSES_90.index(tracked_class): Sort()
            for tracked_class in TRACKED_CLASSES}

我们将实例存储在字典中。字典的键设置为相应的类 ID。我们将使用以下函数跟踪检测到的物体:

def track(dets: np.ndarray,
          illustration_frame: np.ndarray = None):
    for class_id, mot in mots.items():

该函数接受检测和可选的插图帧。函数的主循环遍历我们所实例化的多目标跟踪器。然后,对于每个多目标跟踪器,以下步骤被覆盖:

  1. 我们首先从所有传递的检测中提取当前多目标跟踪器类型的物体检测。
class_dets = dets[dets[:, 0] == class_id]
  1. 然后,我们通过将当前物体类型的边界框传递给跟踪器的update方法来更新跟踪器:
sort_boxes = mot.update(class_dets[:, 2:6])

update方法返回与物体 ID 关联的跟踪物体的边界框坐标。

  1. 如果提供了插图帧,则在帧中描绘边界框:
if illustration_frame is not None:
    for box in sort_boxes:
        illustrate_box(illustration_frame, box[:4],
            f"{CLASSES_90[class_id]} {int(box[4])}")

对于每个返回的结果,将使用我们之前定义的illustrate_box函数绘制相应的边界框。每个框将标注类别名称和框的 ID。

我们还想要定义一个函数,该函数将打印关于跟踪在帧上的通用信息:

def illustrate_tracking_info(frame: np.ndarray) -> np.ndarray:
    for num, (class_id, tracker) in enumerate(trackers.items()):
        txt = f"{CLASSES_90[class_id]}:Total:{tracker.count} Now:{len(tracker.trackers)}"
        cv2.putText(frame, txt, (0, 50 * (num + 1)),
                    cv2.FONT_HERSHEY_SIMPLEX, 0.75, TEXT_COLOR, 2)
    return frame

对于每个跟踪物体的类别,该函数将写入跟踪物体的总数和当前跟踪物体的数量。

现在我们已经定义了跟踪和插图函数,我们准备修改主循环,该循环遍历帧,以便我们可以以跟踪模式运行我们的应用程序:

if args.mode == "tracking"
    out = frame
    track(detections, frame)
    illustrate_tracking_info(out)

从前面的代码片段中,如果应用程序以跟踪模式运行,所选类别的检测到的物体将使用我们的track函数在整个帧中进行跟踪,并且跟踪信息将显示在帧上。

剩下的工作是对跟踪算法进行详细阐述,以便最终完成整个应用程序。我们将借助 Sort 跟踪器在下一节中完成这项工作。

实现 Sort 跟踪器

排序算法是一种简单而健壮的实时跟踪算法,用于视频序列中检测到的对象的多个对象跟踪。该算法具有一种关联检测和跟踪器的机制,结果是在每个跟踪对象上最多有一个检测框。

对于每个跟踪对象,算法创建一个单一对象跟踪类的实例。基于物体不能快速改变大小或速度的物理原理,该类实例可以预测物体的特征位置,并从一帧到另一帧保持跟踪。后者是通过 卡尔曼 滤波器实现的。

我们按照以下方式导入在算法实现中将要使用的模块:

import numpy as np
from scipy.optimize import linear_sum_assignment
from typing import Tuple
import cv2

如往常一样,主要依赖项是 numpy 和 OpenCV。在将检测到的对象与跟踪对象关联时,将使用不熟悉的 linear_sum_assignment 方法。

现在,让我们深入算法,首先了解卡尔曼滤波器是什么,这是在下一节实现单个框跟踪器时使用的。

理解卡尔曼滤波器

卡尔曼滤波器是一个在信号处理、控制理论和统计学中有广泛应用的统计模型。卡尔曼滤波器是一个复杂的模型,但可以将其视为一个算法,当我们对系统的动力学有一定准确性的了解时,它可以去噪包含大量噪声的物体的观察结果。

让我们来看一个例子,以说明卡尔曼滤波器是如何工作的。想象一下,我们想要找到在铁轨上移动的火车的位置。火车将有一个速度,但不幸的是,我们唯一拥有的测量数据来自雷达,它只显示火车的位置。

我们希望准确地测量火车的位置。如果我们查看每个雷达测量值,我们可以从中学习到火车的位置,但如果雷达不太可靠并且有高测量噪声怎么办。例如,雷达报告的位置如下所示:

图片 2

我们如何了解火车在下午 3 点的实际位置? 好吧,有一种可能性是火车在位置 5,但因为我们知道火车很重,速度变化很慢,所以火车很难在短时间内两次改变行驶方向,到达位置 5 然后再返回。因此,我们可以利用对事物工作原理的一些了解以及之前的观察,来对火车的位置做出更可靠的预测。

例如,如果我们假设我们可以用位置和速度来描述火车,我们会定义状态如下:

图片 1

在这里,x 是火车的位置,v 是火车的速度。

现在我们需要一种方式来描述我们的世界模型,这被称为 状态转换模型——对于火车来说,它是简单的:

图片

我们可以使用状态变量 s 将其写成矩阵形式:

图片

矩阵 F 被称为状态转移矩阵

因此,我们相信火车不会改变其速度,并以恒定速度移动。这意味着在观测值的图表上应该有一条直线,但这太严格了,我们知道没有真实的系统会这样表现,所以我们允许系统中存在一些噪声,即过程噪声

图片

一旦我们对过程噪声的性质做出统计假设,这将成为一个统计框架,这通常是发生的情况。但是,这样,如果我们对我们的状态转移模型不确定,但对观测值确定,那么最好的解决方案仍然是仪器报告的内容。因此,我们需要将我们的状态与我们的观测值联系起来。注意,我们正在观测 x,因此观测值可以通过将状态乘以一个简单的行矩阵来恢复:

图片

但是,正如我们所说的,我们必须允许观测值不完美(也许我们的雷达非常旧,有时会有错误的读数),也就是说,我们需要允许观测噪声;因此,最终的观测值如下:

图片

现在,如果我们能够描述过程噪声和观测噪声,卡尔曼滤波器将能够仅使用该时间之前的观测值,为我们提供关于火车在每个位置的好预测。最佳参数化噪声的方式是使用协方差矩阵:

图片

卡尔曼滤波器有一个递归的状态转移模型,因此我们必须提供状态初始值。如果我们选择它为 (0, 0),并且如果我们假设过程噪声测量噪声是等可能的(这在现实生活中是一个糟糕的假设),卡尔曼滤波器将为每个时间点提供以下预测:

图片

由于我们相信我们的观测值和我们的假设一样,即速度不会改变,我们得到了一条(蓝色)不那么极端的平滑曲线,但它仍然不够令人信服。因此,我们必须确保我们在选择的变量中编码我们的直觉。

现在,如果我们说信噪比,即协方差比率的平方根是 10,我们将得到以下结果:

图片

如您所见,速度确实移动得很慢,但我们似乎低估了火车行驶的距离。或者,是我们高估了吗?

调整卡尔曼滤波器是一个非常困难的任务,有许多算法可以做到这一点,但不幸的是,没有一个完美无缺。对于本章,我们不会介绍这些;我们将尝试选择有意义的参数,并且我们将看到这些参数给出了相当好的结果。

现在让我们回顾一下我们的单辆车跟踪模型,看看我们应该如何建模系统动力学。

使用带有卡尔曼滤波器的框跟踪器

首先,我们必须弄清楚如何建模每辆车的状态。可能从观测模型开始会更好;也就是说,我们可以测量每辆车的哪些内容?

好吧,目标检测器给我们提供了框,但它们呈现的方式并不是最好的物理解释;类似于之前给出的火车示例,我们想要可以推理的变量,并且更接近交通的潜在动力学。因此,我们使用以下观测模型:

在这里,uv 是目标中心的水平和垂直像素位置,而 sr 分别代表目标边界框的尺寸(面积)和宽高比。由于我们的汽车在屏幕周围移动,并且越来越远或越来越近,因此坐标和边界框的大小会随时间变化。

假设没有人在像疯子一样开车,图像中汽车的速率应该保持大致恒定;这就是为什么我们可以将我们的模型限制在物体的位置和速率上。因此,我们将采取的状态如下:

我们使用了一种表示法,其中变量上方的点表示该变量的变化率。

状态转换模型将是速度和宽高比随时间保持恒定(带有一些过程噪声)。在下面的屏幕截图中,我们可视化了所有的边界框及其对应的状态(中心位置和速度矢量):

如您所见,我们已经设置了模型,使其观察到的与从我们的跟踪器接收到的略有不同。因此,在下一节中,我们将介绍从边界框到卡尔曼滤波器状态空间的转换函数。

将边界框转换为观测值

为了将边界框传递给卡尔曼滤波器,我们必须定义一个从每个边界框到观测模型的转换函数,并且,为了使用预测的边界框进行目标跟踪,我们需要定义一个从状态到边界框的函数。

让我们从边界框到观测值的转换函数开始:

  1. 首先,我们计算边界框的中心坐标:
def bbox_to_observation(bbox):
    x, y = (bbox[0:2] + bbox[2:4]) / 2
  1. 接下来,我们计算框的宽度和高度,我们将使用这些值来计算尺寸(即面积)和比例:
    w, h = bbox[2:4] - bbox[0:2]
  1. 然后,我们计算 bbox 的大小,即面积:
    s = w * h
  1. 接着,我们计算宽高比,这仅仅是通过将宽度除以高度来完成的:
    r = w / h
  1. 然后返回结果作为一个 4 x 1 矩阵:
    return np.array([x, y, s, r])[:, None].astype(np.float64)

现在,既然我们知道我们必须定义逆变换,那么让我们定义state_to_bbox

  1. 它接受一个 7 x 1 矩阵作为参数,并解包我们构建边界框所需的所有组件:
def state_to_bbox(x):
    center_x, center_y, s, r, _, _, _ = x.flatten()
  1. 然后,它根据宽高比和比例计算边界框的宽度和高度:
    w = np.sqrt(s * r)
    h = s / w
  1. 之后,它计算中心坐标:
    center = np.array([center_x, center_y])
  1. 然后,它计算盒子的半尺寸作为一个numpy元组,并使用它来计算盒子的对角线顶点坐标:
    half_size = np.array([w, h]) / 2
    corners = center - half_size, center + half_size
  1. 然后,我们将边界框作为一维numpy数组返回:
    return np.concatenate(corners).astype(np.float64)

配备了转换函数,让我们看看如何使用 OpenCV 构建卡尔曼滤波器。

实现卡尔曼滤波器

现在,有了我们的模型,让我们动手编写一个处理所有这些魔法的类。我们将编写一个自定义类,该类将使用cv2.KalmanFilter作为卡尔曼滤波器,但我们将添加一些辅助属性来跟踪每个对象。

首先,让我们看看类的初始化,我们将通过传递状态模型、转换矩阵和初始参数来设置我们的卡尔曼滤波器:

  1. 我们首先通过初始化类,包括边界框bboxlabel对象的标签:
class KalmanBoxTracker:
    def __init__(self, bbox, label):
  1. 然后,我们设置一些辅助变量,这将使我们能够过滤在跟踪器中出现和消失的框:
        self.id = label
        self.time_since_update = 0
        self.hit_streak = 0
  1. 然后,我们使用正确的维数和数据类型初始化cv2.KalmanFilter
        self.kf = cv2.KalmanFilter(dynamParams=7, measureParams=4, type=cv2.CV_64F)
  1. 我们设置了转换矩阵和相应的过程噪声协方差矩阵。协方差矩阵是一个简单的模型,涉及每个对象在水平和垂直方向上的当前恒定速度运动,并使用恒定速率变大或变小:
        self.kf.transitionMatrix = np.array(
            [[1, 0, 0, 0, 1, 0, 0],
             [0, 1, 0, 0, 0, 1, 0],
             [0, 0, 1, 0, 0, 0, 1],
             [0, 0, 0, 1, 0, 0, 0],
             [0, 0, 0, 0, 1, 0, 0],
             [0, 0, 0, 0, 0, 1, 0],
             [0, 0, 0, 0, 0, 0, 1]], dtype=np.float64)
  1. 我们还设置了我们对恒定速度过程的确定性。我们选择一个对角协方差矩阵;也就是说,我们的状态变量不相关,我们将位置变量的方差设置为10,速度变量的方差设置为 10,000。我们相信位置变化比速度变化更可预测:
        self.kf.processNoiseCov = np.diag([10, 10, 10, 10, 1e4, 1e4, 1e4]).astype(np.float64)
  1. 然后,我们将观测模型设置为以下矩阵,这意味着我们只是在测量状态中的前四个变量,即所有位置变量:
        self.kf.measurementMatrix = np.array(
            [[1, 0, 0, 0, 0, 0, 0],
             [0, 1, 0, 0, 0, 0, 0],
             [0, 0, 1, 0, 0, 0, 0],
             [0, 0, 0, 1, 0, 0, 0]], dtype=np.float64)
  1. 现在我们已经设置了噪声协方差的测量,我们相信水平和垂直位置大于宽高比和缩放,因此我们给这两个测量方差赋予较小的值:
        self.kf.measurementNoiseCov = np.diag([10, 10, 1e3, 1e3]).astype(np.float64)

  1. 最后,我们设置卡尔曼滤波器的初始位置和与之相关的不确定性:
        self.kf.statePost = np.vstack((convert_bbox_to_z(bbox), [[0], [0], [0]]))
        self.kf.errorCovPost = np.diag([1, 1, 1, 1, 1e-2, 1e-2, 1e-4]).astype(np.float64)

在我们设置好卡尔曼滤波器之后,我们需要能够预测物体移动时的新位置。我们将通过定义另外两个方法——updatepredict 来实现这一点。update 方法将根据新的观测值更新卡尔曼滤波器,而 predict 方法将根据先前证据预测新位置。现在让我们看看 update 方法:

    def update(self, bbox):
        self.time_since_update = 0
        self.hit_streak += 1

        self.kf.correct(bbox_to_observation(bbox))

如您所见,update 方法接受新位置的边界框 bbox,将其转换为观测值,并在 OpenCV 实现上调用 correct 方法。我们只添加了一些变量来跟踪我们更新正在跟踪的对象有多长时间了。

现在,让我们看看 predict 函数;其过程将在以下步骤中解释:

  1. 它首先检查我们是否连续两次调用了 predict;如果我们连续两次调用了它,那么它将 self.hit_streak 设置为 0
    def predict(self):
        if self.time_since_update > 0:
            self.hit_streak = 0
  1. 然后它将 self.time_since_update 增加 1,这样我们就可以跟踪我们跟踪这个对象有多长时间了:
        self.time_since_update += 1
  1. 然后我们调用 OpenCV 实现的 predict 方法,并返回与预测相对应的边界框:
        return state_to_bbox(self.kf.predict())

因此,现在我们已经实现了一个单目标跟踪器,下一步是创建一个可以将检测框与跟踪器关联的机制,我们将在下一节中完成。

将检测与跟踪器关联

在 Sort 算法中,是否将两个边界框视为同一对象的决策是基于交并比(IoU)。在本章之前,您已经学习了这个指标并实现了一个计算它的函数。在这里,我们将定义一个函数,它将根据它们的 IoU 值将检测框和跟踪框关联起来:

def associate_detections_to_trackers(detections: np.ndarray, trackers: np.ndarray,
          iou_threshold: float = 0.3) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:

该函数接受检测的边界框和跟踪器的预测框,以及一个 IoU 阈值。它返回匹配项,作为对应数组中对应索引的数组,未匹配检测框的索引和未匹配跟踪器框的索引。为了实现这一点,它采取以下步骤:

  1. 首先,它初始化一个矩阵,其中将存储每个可能框对之间的 IoU 值:
iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float32)
  1. 然后,我们遍历检测和跟踪框,计算每一对的 IoU,并将结果值存储在矩阵中:
for d, det in enumerate(detections):
    for t, trk in enumerate(trackers):
        iou_matrix[d, t] = iou(det, trk)
  1. 使用 iou_matrix,我们将找到匹配的对,使得这些对中 IoU 值的总和达到最大可能值:
row_ind, col_ind = linear_sum_assignment(-iou_matrix)

为了这个目的,我们使用了 匈牙利算法,它作为 linear_sum_assignment 函数实现。它是一个组合优化算法,用于解决 分配问题

为了使用此算法,我们已传递iou_matrix的相反值。该算法将索引关联起来,使得总和最小。因此,当我们取矩阵的负值时,我们找到最大值。找到这些关联的直接方法是对所有可能的组合进行迭代,并选择具有最大值的那个。

后者方法的缺点是,其时间复杂度将是指数级的,因此一旦我们有多个检测和跟踪器,它将会非常慢。同时,匈牙利算法的时间复杂度为O(n³)

  1. 然后,我们更改算法结果的格式,使其以numpy数组中匹配索引对的格式出现:
matched_indices = np.transpose(np.array([row_ind, col_ind]))
  1. 然后从iou_matrix中获取匹配项的交并比值:
iou_values = np.array([iou_matrix[detection, tracker]
                       for detection, tracker in matched_indices])
  1. 过滤掉具有过低 IoU 值的匹配项:
good_matches = matched_indices[iou_values > 0.3]
  1. 然后,找到未匹配的检测框的索引:
unmatched_detections = np.array(
    [i for i in range(len(detections)) if i not in good_matches[:, 0]])
  1. 之后,找到未匹配的跟踪器框的索引:
unmatched_trackers = np.array(
    [i for i in range(len(trackers)) if i not in good_matches[:, 1]])
  1. 最后,它返回匹配项以及未匹配检测和跟踪框的索引:
return good_matches, unmatched_detections, unmatched_trackers

因此,现在我们已经有了跟踪单个对象和将检测与单个对象跟踪器关联起来的机制,接下来要做的就是创建一个类,该类将使用这些机制在帧之间跟踪多个对象。我们将在下一节中这样做,然后算法将完成。

定义跟踪器的主要类

类的构造函数如下所示:

class Sort:
    def __init__(self, max_age=2, min_hits=3):
        self.max_age = max_age
        self.min_hits = min_hits
        self.trackers = []
        self.count = 0

它存储了两个参数:

  • 第一个参数是max_age,它指定某个对象的跟踪器在没有关联框的情况下可以连续多少次存在,我们才认为该对象已从场景中消失并删除跟踪器。

  • 第二个参数是min_hits,它指定跟踪器应连续多少次与一个框关联,我们才将其视为某个对象。它还创建属性以存储跟踪器并在实例生命周期内计算跟踪器的总数。

我们还定义了一个用于创建跟踪器 ID 的方法:

def next_id(self):
    self.count += 1
    return self.count

该方法将跟踪器的计数增加一个,并返回该数字作为 ID。

现在,我们已经准备好定义update方法,它将执行繁重的工作:

def update(self, dets):

update方法接受检测框,并包括以下步骤:

  1. 对于所有可用的trackers,它预测它们的新位置,并立即删除预测失败的trackers
self.trackers = [
    tracker for tracker in self.trackers if not np.any(
        np.isnan(
            tracker.predict()))]
  1. 然后,我们得到跟踪器的预测框:
trks = np.array([tracker.current_state for tracker in self.trackers])
  1. 然后,我们将跟踪器预测的框与检测框关联起来:
matched, unmatched_dets, unmatched_trks = associate_detections_to_trackers(
    dets, trks)
  1. 然后,我们更新匹配的trackers与关联的检测:
for detection_num, tracker_num in matched:
    self.trackers[tracker_num].update(dets[detection_num])
  1. 对于所有未匹配的检测,我们创建新的trackers,并使用相应的边界框进行初始化:
for i in unmatched_dets:
    self.trackers.append(KalmanBoxTracker(dets[i, :], self.next_id()))
  1. 然后,我们将return值组合成一个包含相关跟踪器框和跟踪器 ID 连接的array
ret = np.array([np.concatenate((trk.current_state, [trk.id + 1]))
                for trk in self.trackers
                if trk.time_since_update < 1 and trk.hit_streak >= self.min_hits])

在之前的代码片段中,我们只考虑了那些在当前帧中更新了检测框并且至少有一个hit_streak连续关联检测框的trackers。根据算法的特定应用,您可能想要更改此行为,使其更适合您的需求。

  1. 然后,我们通过移除一段时间内没有更新新边界框的trackers来清理跟踪器:
self.trackers = [
    tracker for tracker in self.trackers if tracker.time_since_update <= self.max_age]
  1. 最后,我们返回结果:
return ret

因此,现在我们已经完成了算法的实现,我们一切准备就绪,可以运行应用程序并看到它的实际效果。

观看应用程序的实际运行

一旦我们运行我们的应用程序,它将使用传递的视频或另一个视频流,然后处理它并展示结果:

图片

在每个处理的帧中,它将显示物体类型、边界框以及每个跟踪物体的数量。它还会在帧的左上角显示有关跟踪的一般信息。这些一般信息包括每种跟踪物体类型在整个过程中跟踪的视频物体的总数,以及场景中当前可用的跟踪物体。

摘要

在本章中,我们使用了一个物体检测网络,并将其与跟踪器结合使用,以跟踪和计数随时间推移的物体。阅读完本章后,您现在应该理解检测网络是如何工作的,以及它们的训练机制。

您已经学会了如何将使用其他框架构建的模型导入 OpenCV,并将其绑定到处理视频或使用其他视频流(如您的相机或远程 IP 相机)的应用程序中。您实现了一个简单但稳健的跟踪算法,它与稳健的检测网络结合使用,可以回答与视频数据相关的多个统计问题。

您现在可以使用并训练您选择的物体检测网络,以便创建您自己的高度精确的应用程序,这些应用程序的功能围绕物体检测和跟踪实现。

在本书的整个过程中,您已经熟悉了机器学习主要分支之一,即计算机视觉的背景。您从使用简单的图像滤波器和形状分析技术开始。然后,您继续使用经典的特征提取方法,并基于这些方法构建了几个实际的应用程序。之后,您学习了自然场景的统计特性,并能够使用这些特性来跟踪未知物体。

接下来,你开始学习、使用和训练监督模型,例如支持向量机SVMs)和级联分类器。在掌握了所有关于经典计算机视觉方法的理论和实践知识后,你深入研究了深度学习模型,这些模型如今在许多机器学习问题中提供了最先进的结果,尤其是在计算机视觉领域。

你现在已经理解了卷积网络的工作原理以及深度学习模型的训练方式,你还在其他预训练模型的基础上构建并训练了自己的网络。拥有所有这些知识和实践经验,你就可以分析、理解和应用其他计算机视觉模型,一旦你有了新的想法,你还可以详细阐述新的模型。你现在可以开始着手自己的计算机视觉项目了,这可能会改变世界!

第十一章:性能分析和加速你的应用程序

当你遇到一个运行缓慢的应用程序时,首先,你需要找出你的代码中哪些部分花费了相当多的处理时间。找到这些代码部分(也称为 瓶颈)的一个好方法是对应用程序进行性能分析。一个允许在不修改应用程序的情况下对应用程序进行性能分析的好分析器是 pyinstrumentgithub.com/joerick/pyinstrument)。在这里,我们使用 pyinstrument 对 第十章,学习检测和跟踪对象 的应用程序进行性能分析,如下所示:

$ pyinstrument -o profile.html -r html  main.py

我们使用 -o 选项传递了一个输出 .html 文件,其中包含性能分析报告信息要保存的位置。

我们还使用 -r 选项指定了报告的渲染方式,以声明我们想要 HTML 输出。一旦应用程序终止,性能分析报告将被生成,并且可以在浏览器中查看。

你可以省略这两个选项。

在后一种情况下,报告将在控制台中显示。一旦我们终止应用程序,我们可以在浏览器中打开生成的 .html 文件,它将显示类似于以下内容的报告:

图片

首先,我们可以注意到在脚本本身上花费了相当多的时间。这是可以预料的,因为目标检测模型正在对每一帧进行推理,这是一个相当重的操作。我们还可以注意到跟踪也花费了相当多的时间,尤其是在 iou 函数中。

通常,根据应用程序的特定应用,为了加速跟踪,只需将 iou 函数替换为更高效的另一个函数就足够了。在这个应用程序中,iou 函数被用来计算 iou_matrix,它存储了每个可能的检测和跟踪框对的 交集与并集IOU)度量。当你致力于加速你的代码时,为了节省时间,将代码替换为加速版本并再次进行性能分析,以检查它是否满足你的需求可能是个好主意。

但让我们从应用程序中提取适当的代码,并分析使用 Numba 加速的可能性,我们将在下一节中介绍。

使用 Numba 加速

Numba 是一个编译器,它使用 低级虚拟机LLVM)编译器基础设施优化纯 Python 编写的代码。它有效地将数学密集型的 Python 代码编译成与 CC++Fortran 相似性能的代码。它理解一系列 numpy 函数、Python construct 库和运算符,以及标准库中的一系列数学函数,并为 图形处理单元GPU)和 中央处理单元CPU)生成相应的本地代码,只需简单的注释。

在本节中,我们将使用IPython交互式解释器来处理代码。它是一个增强的交互式 Python 外壳,特别支持所谓的魔法命令,在我们的案例中,我们将使用这些命令来计时函数。一个选项是直接在控制台中使用解释器。其他几个选项是使用Jupyter NotebookJupyterLab。如果您使用的是Atom编辑器,您可能想要考虑Hydrogen插件,该插件在编辑器中实现了一个交互式编码环境。

要导入 NumPy 和 Numba,请运行以下代码:

import numpy as np
import numba

我们正在使用Numba 版本 0.49,这是撰写本文时的最新版本。在整个这一节中,您会注意到我们不得不以这种方式更改代码,以便可以使用这个版本的 Numba 进行编译。

据说,在未来的版本中,Numba 将支持更多函数,并且一些或所有修改可能不再需要。当您在您的应用程序代码上工作时,请参考撰写本文时的Numba文档,以了解支持的功能,文档可在numba.pydata.org/numba-doc/latest/index.html找到。

在这里,我们介绍了 Numba 的一些重要可能性,并在某个示例上展示了结果,以便您了解 Numba 如何帮助您加速您自己的应用程序代码。

现在我们将我们想要加速的代码隔离出来,如下所示:

  1. 首先,这是一个计算两个框的iou的函数:
def iou(a: np.ndarray, b: np.ndarray) -> float:
    a_tl, a_br = a[:4].reshape((2, 2))
    b_tl, b_br = b[:4].reshape((2, 2))
    int_tl = np.maximum(a_tl, b_tl)
    int_br = np.minimum(a_br, b_br)
    int_area = np.product(np.maximum(0., int_br - int_tl))
    a_area = np.product(a_br - a_tl)
    b_area = np.product(b_br - b_tl)
    return int_area / (a_area + b_area - int_area)

目前,我们将其保留为第十章中的原样,学习检测和跟踪对象

  1. 接下来是使用先前函数计算iou_matrix的代码部分,如下所示:
def calc_iou_matrix(detections,trackers):
    iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float32)

    for d, det in enumerate(detections):
        for t, trk in enumerate(trackers):
            iou_matrix[d, t] = iou(det, trk)
    return iou_matrix

我们已经将相应的循环和矩阵定义封装在一个新的函数中。

  1. 为了测试性能,让我们定义两组随机边界框,如下所示:
A = np.random.rand(100,4)
B = np.random.rand(100,4)

我们定义了两组100个边界框。

  1. 现在,我们可以通过运行以下代码来估计计算这些边界框的iou_matrix所需的时间:
%timeit calc_iou_matrix(A,B)

%timeit魔法命令会多次执行函数,计算平均执行时间以及与平均值的偏差,并输出结果,如下所示:

307 ms ± 3.15 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

我们可以注意到计算矩阵大约需要 1/3 秒。因此,如果我们场景中有 100 个对象,并且我们想在 1 秒内处理多个帧,应用程序中将会出现巨大的瓶颈。现在让我们在 CPU 上加速这段代码。

使用 CPU 加速

Numba 有几个代码生成实用工具,可以将 Python 代码转换为机器代码。其核心特性之一是@numba.jit装饰器。这个装饰器允许您通过 Numba 编译器优化标记一个函数。例如,以下函数计算数组中所有元素的总乘积:

@numba.jit(nopython=True)
def product(a):
    result = 1
    for i in range(len(a)):
        result*=a[i]
    return result

它可以被视为一个np.product的自定义实现。装饰器告诉 Numba 将函数编译成机器代码,这比 Python 版本有更快的执行时间。Numba 总是尝试编译指定的函数。在函数中的操作无法完全编译的情况下,Numba 会回退到所谓的对象模式,使用Python/C API,并将所有值作为 Python 对象处理,以对它们进行操作。

后者比前者慢得多。当我们传递nopython=True时,我们明确告诉它,当函数无法编译为完整的机器代码时,抛出异常。

我们可以使用与iou函数相同的装饰器,如下所示:

@numba.jit(nopython=True)
def iou(a: np.ndarray, b: np.ndarray) -> float:
    a_tl, a_br = a[0:2],a[2:4]
    b_tl, b_br = b[0:2],b[2:4]
    int_tl = np.maximum(a_tl, b_tl)
    int_br = np.minimum(a_br, b_br)
    int_area = product(np.maximum(0., int_br - int_tl))
    a_area = product(a_br - a_tl)
    b_area = product(b_br - b_tl)
    return int_area / (a_area + b_area - int_area)

我们可以注意到这个函数与 Python 函数略有不同。首先,我们使用了我们自定义的np.product实现。如果我们尝试使用当前版本的 Numba 的本地实现,我们将遇到异常,因为本地的np.product目前不被 Numba 编译器支持。这与函数的前两行类似,Numba 无法解释数组的自动解包。

现在,我们准备像之前一样计时我们的函数,如下所示:

%timeit calc_iou_matrix(A,B)

后者产生以下输出:

14.5 ms ± 24.5 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

我们可以注意到,我们已经实现了巨大的加速(大约 20 倍),但让我们继续前进。我们可以注意到calc_iou_matrix仍然是用纯 Python 编写的,并且有嵌套循环,这可能会花费相当多的时间。让我们创建它的编译版本,如下所示:

@numba.jit(nopython=True)
def calc_iou_matrix(detections,trackers):
    iou_matrix = np.zeros((len(detections), len(trackers)), dtype=np.float32)
    for d in range(len(detections)):
        det = detections[d]
        for t in range(len(trackers)):
            trk = trackers[t]
            iou_matrix[d, t] = iou(det, trk)

再次,这个函数与原始函数不同,因为 Numba 无法解释enumerate。对这个实现进行计时将产生类似于以下输出的结果:

7.08 ms ± 31 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

我们再次实现了加速。这个版本比之前的版本快一倍。让我们继续加速,尽可能让它变得更快,但在做之前,让我们首先熟悉一下vectorize装饰器。

vectorize装饰器允许创建函数,这些函数可以用作 NumPyufuncs类,从在标量参数上工作的函数中创建,如下面的函数所示:

@numba.vectorize
def custom_operation(a,b):
    if b == 0:
        return 0
    return a*b if a>b else a/b

当给函数一对标量时,该函数执行一些特定的操作,而vectorize装饰器使得在 NumPy 数组上执行相同的操作成为可能,例如,如下所示:

custom_operation(A,B)

NumPy 类型转换规则也适用——例如,你可以用一个标量替换一个数组,或者用一个形状为(1,4)的数组替换一个数组,如下所示:

custom_operation(A,np.ones((1,4)))

我们将使用另一个装饰器guvectorize来加速我们的iou_matrix计算。这个装饰器将vectorize的概念推进了一步。它允许编写返回不同维度数组的ufuncs。我们可以注意到,在计算 IOU 矩阵时,输出数组的形状由每个传递数组中边界框的数量组成。我们如下使用装饰器来计算矩阵:

@numba.guvectorize(['(f8[:, :], f8[:, :], f8[:, :])'], '(m,k),(n,k1)->(m, n)')
def calc_iou_matrix(x, y, z):
    for i in range(x.shape[0]):
        for j in range(y.shape[1]):
            z[i, j] = iou(x[i],y[i])

第一个参数告诉 Numba 编译一个在 8 字节浮点数(float64)上工作的函数。它还使用分号指定输入和输出数组的维度。第二个参数是签名,它指定了输入和输出数组的维度是如何相互匹配的。一旦我们用输入执行了函数,z输出就会在那里等待,具有正确的形状,只需要在函数中填充即可。

如果我们像之前那样计时这个实现,我们会得到一个类似于以下输出的结果:

196 µs ± 2.46 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)

再次,我们的速度比之前的情况快了大约 30 倍。与最初的纯 Python 实现相比,我们快了大约 1,000 倍,这相当令人印象深刻。

理解 Numba、CUDA 和 GPU 加速

你已经看到了使用 Numba 创建 CPU 加速代码是多么简单。Numba 还提供了一个类似的接口,使用Compute Unified Device ArchitectureCUDA)进行 GPU 计算。让我们将我们的 IOU 矩阵计算函数移植到使用 Numba 在 GPU 上进行计算。

我们可以通过稍微修改装饰器参数来指示 Numba 在 GPU 上进行计算,如下所示:

@numba.guvectorize(['(f8[:, :], f8[:, :], f8)'], '(m,k),(n,k1)->()',target="cuda")
def mat_mul(x, y, z):
    for i in range(x.shape[0]):
        for j in range(y.shape[1]):
            z=iou(x[i],y[j])

在这里,我们通过传递target="cuda"来指示 Numba 在 GPU 上进行计算。我们还需要在iou函数上做一些工作。新的函数如下所示:

@numba.cuda.jit(device=True)
def iou(a: np.ndarray, b: np.ndarray) -> float:
    xx1 = max(a[0], b[0])
    yy1 = max(a[1], b[1])
    xx2 = min(a[2], b[2])
    yy2 = min(a[3], b[3])
    w = max(0., xx2 - xx1)
    h = max(0., yy2 - yy1)
    wh = w * h
    result = wh / ((a[2]-a[0])*(a[3]-a[1])
        + (b[2]-b[0])*(b[3]-b[1]) - wh)
    return result

首先,我们更改了装饰器,现在使用numba.cuda.jit而不是numba.jit。后者指示 Numba 创建一个在 GPU 上执行的功能。这个函数本身是从在 GPU 设备上运行的功能中调用的。为此,我们传递了device=True,这明确指出这个函数打算用于从在 GPU 上计算的功能中调用。

你还可以注意到,我们做了相当多的修改,以便我们消除了所有的 NumPy 函数调用。正如 CPU 加速一样,这是因为numba.cuda目前无法执行函数中所有可用的操作,我们将它们替换成了numba.cuda支持的那些操作。

通常,在计算机视觉中,你的应用程序只有在处理深度神经网络DNNs)时才需要 GPU 加速。大多数现代深度学习框架,如TensorFlowPyTorchMXNet,都支持开箱即用的 GPU 加速,让你远离底层 GPU 编程,专注于你的模型。在分析了这些框架之后,如果你发现自己有一个你认为必须直接使用 CUDA 实现的特定算法,你可能想分析numba.cuda API,它支持大多数 CUDA 功能。

第十二章:设置 Docker 容器

Docker 是一个方便的平台,可以将应用程序及其依赖项打包在一个可复制的虚拟环境中,该环境可以在不同的操作系统上运行。特别是,它与任何Linux 系统很好地集成。

可复制的虚拟环境在一个Dockerfile中进行了描述,该文件包含了一系列指令,这些指令应该被执行以实现所需的虚拟环境。这些指令主要包括安装过程,这与使用 Linux shell 的安装过程非常相似。一旦环境创建完成,您可以确信您的应用程序将在任何其他机器上具有相同的行为。

在 Docker 术语中,生成的虚拟环境被称为Docker 镜像。您可以创建虚拟环境的实例,这被称为Docker 容器。容器创建后,您可以在容器内执行您的代码。

请按照官方网站上的安装说明操作,以便在您选择的操作系统上安装并运行 Docker:docs.docker.com/install/

为了您的方便,我们包括了 Dockerfile,这将使复制我们在本书中运行代码所使用的环境变得非常容易,无论您的计算机上安装了什么操作系统。首先,我们描述了一个仅使用 CPU 而不使用 GPU 加速的 Dockerfile。

定义 Dockerfile

Dockerfile 中的说明从基镜像开始,然后在那个镜像之上执行所需的安装和修改。

在撰写本文时,TensorFlow 不支持Python 3.8。如果您计划运行第七章,学习识别交通标志,或第九章,学习分类和定位对象,其中使用了 TensorFlow,您可以从Python 3.7开始,然后使用pip安装 TensorFlow,或者您可以选择tensorflow/tensorflow:latest-py3作为基镜像。

让我们回顾一下创建我们环境的步骤:

  1. 我们从一个基镜像开始,这是一个基于Debian的基本 Python 镜像:
FROM python:3.8
  1. 我们安装了一些有用的包,这些包将在 OpenCV 和其他依赖项的安装过程中特别使用:
RUN apt-get update && apt-get install -y \
        build-essential \
        cmake \
        git \
        wget \
        unzip \
        yasm \
        pkg-config \
        libswscale-dev \
        libtbb2 \
        libtbb-dev \
        libjpeg-dev \
        libpng-dev \
        libtiff-dev \
        libavformat-dev \
        libpq-dev \
        libgtk2.0-dev \
        libtbb2 libtbb-dev \
        libjpeg-dev \
        libpng-dev \
        libtiff-dev \
        libv4l-dev \
        libdc1394-22-dev \
        qt4-default \
        libatk-adaptor \
        libcanberra-gtk-module \
        x11-apps \
        libgtk-3-dev \
    && rm -rf /var/lib/apt/lists/*
  1. 我们一起下载OpenCV 4.2以及贡献者包,这些包对于非免费算法,如尺度不变特征变换SIFT)和加速鲁棒特征SURF)是必需的:
WORKDIR /
RUN wget --output-document cv.zip https://github.com/opencv/opencv/archive/${OPENCV_VERSION}.zip \
    && unzip cv.zip \
    && wget --output-document contrib.zip https://github.com/opencv/opencv_contrib/archive/${OPENCV_VERSION}.zip \
    && unzip contrib.zip \
    && mkdir /opencv-${OPENCV_VERSION}/cmake_binary
  1. 我们安装了一个与OpenCV 4.2兼容的 NumPy 版本:
RUN pip install --upgrade pip && pip install --no-cache-dir numpy==1.18.1
  1. 我们使用适当的标志编译 OpenCV:
RUN cd /opencv-${OPENCV_VERSION}/cmake_binary \
    && cmake -DBUILD_TIFF=ON \
        -DBUILD_opencv_java=OFF \
        -DWITH_CUDA=OFF \
        -DWITH_OPENGL=ON \
        -DWITH_OPENCL=ON \
        -DWITH_IPP=ON \
        -DWITH_TBB=ON \
        -DWITH_EIGEN=ON \
        -DWITH_V4L=ON \
        -DBUILD_TESTS=OFF \
        -DBUILD_PERF_TESTS=OFF \
        -DCMAKE_BUILD_TYPE=RELEASE \
        -D OPENCV_EXTRA_MODULES_PATH=/opencv_contrib-${OPENCV_VERSION}/modules \
        -D OPENCV_ENABLE_NONFREE=ON \
        -DCMAKE_INSTALL_PREFIX=$(python3.8 -c "import sys; print(sys.prefix)") \
        -DPYTHON_EXECUTABLE=$(which python3.8) \
        -DPYTHON_INCLUDE_DIR=$(python3.8 -c "from distutils.sysconfig import get_python_inc; print(get_python_inc())") \
        -DPYTHON_PACKAGES_PATH=$(python3.8 -c "from distutils.sysconfig import get_python_lib; print(get_python_lib())") \
        .. \
    && make install \
    && rm /cv.zip /contrib.zip \
    && rm -r /opencv-${OPENCV_VERSION} /opencv_contrib-${OPENCV_VERSION}
  1. 我们将 OpenCV Python 二进制文件链接到适当的位置,以便解释器可以找到它:
RUN ln -s \
  /usr/local/python/cv2/python-3.8/cv2.cpython-38m-x86_64-linux-gnu.so \
  /usr/local/lib/python3.8/site-packages/cv2.so

如果您使用的基镜像与python:3.8不同,这种链接可能重复或导致错误。

  1. 我们安装了本书中使用的其他 Python 包:
RUN pip install --upgrade pip && pip install --no-cache-dir pathlib2 wxPython==4.0.5

RUN pip install --upgrade pip && pip install --no-cache-dir scipy==1.4.1 matplotlib==3.1.2 requests==2.22.0 ipython numba==0.48.0 jupyterlab==1.2.6 rawpy==0.14.0

因此,现在我们已经组合了 Dockerfile,我们可以按照以下方式构建相应的 Docker 镜像:

$ docker build -f dockerfiles/Dockerfile  -t cv  dockerfiles

我们将镜像命名为 cv,并将位于 dockerfiles/Dockerfile 的 Dockerfile 传递给构建镜像。当然,您可以将您的 Dockerfile 放在任何其他位置。Docker 的最后一个参数是必需的,它指定了一个可能被使用的上下文;例如,如果 Dockerfile 包含从相对路径复制文件的指令。在我们的情况下,我们没有这样的指令,并且它可以是任何有效的路径。

一旦构建了镜像,我们就可以按照以下方式启动 docker 容器:

$ docker run --device /dev/video0 --env DISPLAY=$DISPLAY  -v="/tmp/.X11-unix:/tmp/.X11-unix:rw"  -v `pwd`:/book -it book

在这里,我们传递了 DISPLAY 环境变量,挂载了 /tmp/.X11-unix,并指定了 /dev/video0 设备,以便容器可以使用桌面环境并连接到相机,其中容器在本书的大部分章节中使用。

如果 Docker 容器无法连接到您系统的 X 服务器,您可能需要在您的系统上运行 $ xhost +local:docker 以允许连接。

因此,现在我们已经启动并运行了组合的 Docker 镜像,让我们来探讨如何使用 Docker 支持 GPU 加速。

使用 GPU

我们使用 Docker 创建的环境对机器的设备访问有限。特别是,您已经看到,在运行 Docker 容器时,我们已经指定了相机设备,并且挂载了 /tmp/.X11-unix 以允许 Docker 容器连接到正在运行的桌面环境。

当我们拥有自定义设备,如 GPU 时,集成过程变得更加复杂,因为 Docker 容器需要适当的方式与设备通信。幸运的是,对于 NVIDIA GPU,这个问题通过 NVIDIA Container Toolkitgithub.com/NVIDIA/nvidia-docker)得到了解决。

在安装工具包之后,您可以构建和运行带有 GPU 加速的 Docker 容器。Nvidia 提供了一个基础镜像,这样您就可以在其上构建您的镜像,而无需担心对 GPU 的适当访问。要求是在您的系统上安装了适当的 Nvidia 驱动程序,并且有一个 Nvidia GPU。

在我们的情况下,我们主要使用 GPU 来加速 TensorFlow。TensorFlow 本身提供了一个可以用于带有 GPU 加速运行 TensorFlow 的镜像。因此,为了有一个带有 GPU 加速的容器,我们可以简单地选择 TensorFlow 的 Docker 镜像,并在其上安装所有其他软件,如下所示:

FROM tensorflow/tensorflow:2.1.0-gpu-py3

这个声明将选择带有 GPU 加速和 Python 3 支持的 TensorFlow 版本 2.1.0。请注意,这个版本的 TensorFlow 镜像使用 Python 3.6。尽管如此,您可以使用 Dockerfile 的剩余部分来构建 附录 A 中描述的 CPU,分析和加速您的应用程序,您将能够运行本书中的代码。

一旦创建完图像,在启动容器时,你唯一需要做的修改就是传递一个额外的参数:--runtime=nvidia

posted @ 2025-09-21 12:13  绝不原创的飞龙  阅读(9)  评论(0)    收藏  举报