原文:
annas-archive.org/md5/8dae50c67273e660f09fe74447ef722d译者:飞龙
前言
本书的目标是让你通过使用最新版本的 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,它可以在多种操作系统上使用,例如Windows、GNU Linux、macOS以及其他操作系统。我们已尽力只使用这三个操作系统上可用的库。我们将详细介绍我们所使用的每个依赖项的确切版本,这些依赖项可以使用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.8和Python 3.7在Ubuntu 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并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packt.com上登录或注册。
-
选择“支持”选项卡。
-
点击“代码下载”。
-
在搜索框中输入书名,并按照屏幕上的说明操作。
文件下载后,请确保您使用最新版本的软件解压或提取文件夹,例如:
-
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 进行保护。
今天,现代图像编辑程序,如 Photoshop 和 Gimp,提供了在数字图像中模仿这些效果的方法。例如,蒙版仍然用于模仿改变图像曝光时间的效果,其中蒙版中相对强烈的值会 曝光 图像,从而使图像变亮。OpenCV 没有提供原生的函数来实现这些技术;然而,通过一点洞察力和几个技巧,我们将达到我们自己的高效实现,可用于产生美丽的铅笔素描效果。
如果您在网上搜索,可能会遇到以下常见程序,用于从 RGB(红色、绿色和蓝色)彩色图像中实现铅笔素描:
-
首先,将彩色图像转换为灰度图像。
-
然后,将灰度图像反转以得到负片。
-
对步骤 2 中的负片应用 高斯模糊。
-
使用 颜色 dodging 将步骤 1 中的灰度图像与步骤 3 中的模糊负片混合。
虽然 步骤 1 到 3 很直接,但 步骤 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 矩阵(image 和 mask)并返回混合图像:
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函数中找到。
以下过程展示了如何将彩色图像转换为灰度图。之后,我们旨在将灰度图像与其模糊的负图像混合:
- 首先,我们将 RGB 图像 (
imgRGB) 转换为灰度图:
img_gray = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
如您所见,我们已将 cv2.COLOR_RGB2GRAY 作为 cv2.cvtColor 函数的参数,这改变了颜色空间。请注意,输入图像是 RGB 还是 BGR(OpenCV 的默认设置)并不重要;最终我们都会得到一个漂亮的灰度图像。
- 然后,我们使用大小为
(21,21)的大高斯核对图像进行反转和模糊处理:
inv_gray = 255 - gray_image
blurred_image = cv2.GaussianBlur(inv_gray, (21, 21), 0, 0)
- 我们使用
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(蓝色)通道。因此,为了我们的目的,所有 x 和 y 的值都必须保持在 0 到 255 之间。
例如,如果我们想使灰度图像稍微亮一些,我们可以使用以下控制点的曲线过滤器:
这意味着除了 0 和 255 以外的所有输入像素值都会略微增加,从而在图像上产生整体变亮的效果。
如果我们希望这样的过滤器产生看起来自然的图像,那么遵守以下两条规则是很重要的:
-
每组锚点都应该包括 (0,0) 和 (255,255)。这对于防止图像看起来像有整体色调很重要,因为黑色仍然是黑色,白色仍然是白色。
-
f(x) 函数应该是单调递增的。换句话说,通过增加 x,f(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))
要让图像看起来像是在炎热的阳光明媚的日子里拍摄的(可能接近日落),最简单的方法是增加图像中的红色,并通过增加颜色饱和度使颜色看起来更加鲜艳。我们将分两步实现这一点:
- 使用
INCREASE_LOOKUP_TABLE和DECREASE_LOOKUP_TABLE分别增加 RGB 颜色图像中 R 通道(来自 RGB 图像)的像素值,并减少 B 通道的像素值:
interim_img = apply_rgb_filters(rgb_image,
red_filter=INCREASE_LOOKUP_TABLE,
blue_filter=DECREASE_LOOKUP_TABLE)
- 将图像转换为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 中的颜色图像与步骤 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)。
因此,运行我们使用的双边滤波器的最终代码如下:
- 使用多个
pyrDown调用对图像进行下采样:
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)
结果看起来像一幅模糊的彩色画,画的是一个令人毛骨悚然的程序员,如下所示:
下一个部分将向您展示如何检测和强调突出边缘。
检测和强调突出边缘
再次强调,当涉及到边缘检测时,挑战通常不在于底层算法的工作方式,而在于选择哪种特定的算法来完成手头的任务。您可能已经熟悉各种边缘检测器。例如,Canny 边缘检测(cv2.Canny)提供了一种相对简单且有效的方法来检测图像中的边缘,但它容易受到噪声的影响。
Sobel 算子(cv2.Sobel)可以减少这种伪影,但它不是旋转对称的。Scharr 算子(cv2.Scharr)旨在纠正这一点,但它只查看第一图像导数。如果您感兴趣,还有更多算子供您选择,例如Laplacian 脊算子(它包括二阶导数),但它们要复杂得多。最后,对于我们的特定目的,它们可能看起来并不更好,也许是因为它们像任何其他算法一样容易受到光照条件的影响。
对于这个项目,我们将选择一个可能甚至与传统的边缘检测无关的函数——cv2.adaptiveThreshold。像cv2.threshold一样,这个函数使用一个阈值像素值将灰度图像转换为二值图像。也就是说,如果原始图像中的像素值高于阈值,则最终图像中的像素值将是 255。否则,它将是 0。
然而,自适应阈值的美妙之处在于它不会查看图像的整体属性。相反,它独立地检测每个小邻域中最显著的特征,而不考虑全局图像特征。这使得算法对光照条件极为鲁棒,这正是我们在寻求在物体和卡通中的人物周围绘制醒目的黑色轮廓时所希望的。
然而,这也使得算法容易受到噪声的影响。为了对抗这一点,我们将使用中值滤波器对图像进行预处理。中值滤波器做的是它名字所暗示的:它将每个像素值替换为一个小像素邻域中所有像素的中值。因此,为了检测边缘,我们遵循以下简短程序:
- 我们首先将 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)
- 在减少噪声后,现在可以安全地使用自适应阈值检测和增强边缘。即使有一些图像噪声残留,
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脚本。按照以下步骤操作:
- 我们首先开始导入所有必要的模块:
import wx
import cv2
import numpy as np
- 我们还必须导入一个通用的 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
- OpenCV 提供了一个简单的方法来访问计算机的摄像头或相机设备。以下代码片段使用
cv2.VideoCapture打开计算机的默认摄像头 ID(0):
def main():
capture = cv2.VideoCapture(0)
- 为了给我们的应用程序一个公平的机会在实时运行,我们将限制视频流的大小为
640x480像素:
capture.set(cv2.CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)
- 然后,可以将
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模块与事件和回调方法一起工作。当某个事件被触发时,它可以导致某个类方法被执行(换句话说,一个方法可以绑定到事件)。我们将利用这个机制,并使用以下步骤每隔一段时间显示一个新帧:
- 我们创建一个定时器,每当
1000./self.fps毫秒过去时,它就会生成一个wx.EVT_TIMER事件:
self.timer = wx.Timer(self)
self.timer.Start(1000\. / self.fps)
- 每当定时器结束时,我们希望调用
_on_next_frame方法。它将尝试获取一个新的视频帧:
self.Bind(wx.EVT_TIMER, self._on_next_frame)
_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 这样的低成本传感器以及像OpenKinect和OpenNI这样的开源软件,自己开始这个领域从未如此简单。那么,我们该如何利用所有这些技术呢?
在本章中,我们将涵盖以下主题:
-
规划应用
-
设置应用
-
实时跟踪手部手势
-
理解手部区域分割
-
执行手部形状分析
-
执行手部手势识别
我们将在本章中实现的算法的美丽之处在于,它适用于许多手部手势,同时足够简单,可以在普通笔记本电脑上实时运行。此外,如果我们想,我们还可以轻松扩展它以包含更复杂的手部姿态估计。
完成应用后,您将了解如何在自己的应用中使用深度传感器。您将学习如何使用 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 传感器的最简单方法是通过一个名为freenect的OpenKinect模块。有关安装说明,请参阅上一节。
freenect模块具有sync_get_depth()和sync_get_video()等函数,用于从深度传感器和摄像头传感器分别同步获取图像。对于本章,我们只需要 Kinect 深度图,它是一个单通道(灰度)图像,其中每个像素值是从摄像头到视觉场景中特定表面的估计距离。
在这里,我们将设计一个函数,该函数将从传感器读取一个帧并将其转换为所需的格式,并返回帧以及成功状态,如下所示:
def read_frame(): -> Tuple[bool,np.ndarray]:
函数包括以下步骤:
- 获取一个
frame;如果没有获取到帧,则终止函数,如下所示:
frame, timestamp = freenect.sync_get_depth()
if frame is None:
return False, None
sync_get_depth方法返回深度图和时间戳。默认情况下,该图是 11 位格式。传感器的最后 10 位描述深度,而第一位表示当它等于 1 时,距离估计未成功。
- 将数据标准化为 8 位精度格式是一个好主意,因为 11 位格式不适合立即使用
cv2.imshow可视化,以及将来。我们可能想使用返回不同格式的不同传感器,如下所示:
np.clip(depth, 0, 2**10-1, depth)
depth >>= 2
在前面的代码中,我们首先将值裁剪到 1,023(或2**10-1)以适应 10 位。这种裁剪导致未检测到的距离被分配到最远的可能点。接下来,我们将 2 位向右移动以适应 8 位。
- 最后,我们将图像转换为 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 传感器,您必须遵循以下步骤:
- 创建一个连接到您的与 OpenNI 兼容的传感器的视频捕获,如下所示:
device = cv2.cv.CV_CAP_OPENNI
capture = cv2.VideoCapture(device)
如果您想连接到 Asus Xtion,device变量应设置为cv2.CV_CAP_OPENNI_ASUS值。
- 将输入帧大小更改为标准视频图形阵列(VGA)分辨率,如下所示:
capture.set(cv2.cv.CV_CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv2.cv.CV_CAP_PROP_FRAME_HEIGHT, 480)
- 在前面的章节中,我们设计了
read_frame函数,该函数使用freenect访问 Kinect 传感器。为了从视频捕获中读取深度图像,您必须将此函数更改为以下内容:
def read_frame():
if not capture.grab():
return False,None
return capture.retrieve(cv2.CAP_OPENNI_DEPTH_MAP)
您会注意到我们使用了grab和retrieve方法而不是read方法。原因是当我们需要同步一组相机或多头相机,例如 Kinect 时,cv2.VideoCapture的read方法是不合适的。
对于此类情况,您可以使用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 的灰度帧,并在每次迭代中执行以下步骤:
- 使用
recognize函数识别手势,该函数返回估计的展开手指数量(num_fingers)和注释过的 BGR 颜色图像,如下所示:
num_fingers, img_draw = recognize(frame)
- 在注释过的 BGR 图像上调用
draw_helpers函数,以提供手势放置的视觉辅助,如下所示:
draw_helpers(img_draw)
- 最后,
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函数进行分析的;真正的魔法就在这里发生。这个函数处理整个流程,从原始的灰度图像到识别出的手势。它返回手指的数量和插图框架。它实现了以下步骤:
- 它通过分析深度图(
img_gray)提取用户的手部区域,并返回一个手部区域掩码(segment),如下所示:
def recognize(img_gray: np.ndarray) -> Tuple[int,np.ndarray]:
# segment arm region
segment = segment_arm(img_gray)
- 它对手部区域掩码(
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)
- 根据找到的轮廓和凸性缺陷,它检测图像中的展开手指数量(
num_fingers)。然后,它使用segment图像作为模板创建一个插图图像(img_draw),并用contour和defect点进行注释,如下所示:
img_draw = cv2.cvtColor(segment, cv2.COLOR_GRAY2RGB)
num_fingers, img_draw = detect_num_fingers(contour,
defects, img_draw)
- 最后,返回估计的展开手指数量(
num_fingers)以及注释过的输出图像(img_draw),如下所示:
return num_fingers, img_draw
在下一节中,让我们学习如何完成手部区域分割,这是我们程序开始时使用的。
理解手部区域分割
手臂的自动检测——以及后来的手部区域——可以设计得任意复杂,可能通过结合手臂或手部形状和颜色的信息。然而,使用肤色作为确定特征在视觉场景中寻找手可能会在光线条件差或用户戴着手套时失败得很惨。相反,我们选择通过深度图中的形状来识别用户的手。
允许各种手在任何图像区域内存在会无谓地复杂化本章的任务,因此我们做出两个简化的假设:
-
我们将指导我们的应用程序用户将他们的手放在屏幕中心前方,使手掌大致平行于 Kinect 传感器的方向,这样更容易识别手的相应深度层。
-
我们还将指导用户坐在 Kinect 大约 1 到 2 米远的地方,并将手臂稍微向前伸展,使手最终处于比手臂稍深的深度层。然而,即使整个手臂可见,算法仍然可以工作。
这样,仅基于深度层对图像进行分割将会相对简单。否则,我们可能需要首先提出一个手部检测算法,这将无谓地复杂化我们的任务。如果你愿意冒险,你可以自己尝试这样做。
让我们看看如何在下一节中找到图像中心区域的最高深度。
找到图像中心区域的最高深度
一旦手大致放置在屏幕中心,我们就可以开始寻找所有位于与手相同深度平面的图像像素。这是通过以下步骤完成的:
- 首先,我们只需确定图像中心区域的最高
深度值。最简单的方法是只查看中心像素的深度值,如下所示:
width, height = depth.shape
center_pixel_depth = depth[width/2, height/2]
- 然后,创建一个掩码,其中所有深度为
center_pixel_depth的像素都是白色,其他所有像素都是黑色,如下所示:
import numpy as np
depth_mask = np.where(depth == center_pixel_depth, 255,
0).astype(np.uint8)
然而,这种方法可能不会非常稳健,因为有可能以下因素会使其受损:
-
你的手不会完美地平行放置在 Kinect 传感器上。
-
你的手不会完全平坦。
-
Kinect 传感器的值将会是嘈杂的。
因此,你的手的不同区域将具有略微不同的深度值。
segment_arm方法采取了一种稍微更好的方法——它查看图像中心的较小邻域并确定中值深度值。这是通过以下步骤完成的:
- 首先,我们找到图像帧的中心区域(例如,
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]
- 然后,我们确定中值深度值
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)
- 结果看起来会是这样:
你会注意到分割掩码并不平滑。特别是,它包含深度传感器未能做出预测的点处的孔洞。让我们学习如何在下一节中应用形态学闭运算来平滑分割掩码。
应用形态学闭运算以平滑
分割中常见的一个问题是,硬阈值通常会导致分割区域出现小的缺陷(即孔洞,如前一幅图像所示)。这些孔洞可以通过使用形态学开运算和闭运算来缓解。当它被打开时,它会从前景中移除小物体(假设物体在暗前景上较亮),而闭运算则移除小孔(暗区域)。
这意味着我们可以通过应用形态学闭运算(先膨胀后腐蚀)使用一个小的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_index和end_index)之间的角度,如下面的屏幕截图所示:
在之前的屏幕截图中,橙色标记作为视觉辅助工具,将手放在屏幕中间,凸包用绿色勾勒出来。每个红色点对应于每个凸性缺陷检测到的最远的凸包点(farthest_pt_index)。如果我们比较属于两个伸出的手指的典型角度(例如θj)和由一般手部几何形状引起的角度(例如θi),我们会发现前者远小于后者。
这显然是因为人类只能稍微张开手指,从而在远端缺陷点和相邻指尖之间形成一个很窄的角度。因此,我们可以遍历所有凸性缺陷,并计算这些点之间的角度。为此,我们需要一个实用函数来计算两个任意向量(如v1和v2)之间的角度(以弧度为单位),如下所示:
def angle_rad(v1, v2):
return np.arctan2(np.linalg.norm(np.cross(v1, v2)),
np.dot(v1, v2))
此方法使用叉积来计算角度,而不是以标准方式计算。计算两个向量v1和v2之间角度的标准方式是通过计算它们的点积并将其除以v1和v2的norm。然而,这种方法有两个不完美之处:
-
如果
v1的norm或v2的norm为零,你必须手动避免除以零。 -
该方法对于小角度返回相对不准确的结果。
类似地,我们提供了一个简单的函数来将角度从度转换为弧度,如下所示:
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 度的值,所以任何接近这个数字的值都应该可以工作。我们不希望截止角度过高,因为这可能会导致分类错误。完整的函数将返回手指数量和示意图,并包括以下步骤:
- 首先,让我们关注特殊情况。如果我们没有找到任何凸性
defects,这意味着我们在凸包计算过程中可能犯了一个错误,或者帧中根本没有任何伸出的手指,因此我们返回0作为检测到的手指数量,如下所示:
if defects is None:
return [0, img_draw]
- 然而,我们可以将这个想法进一步发展。由于手臂通常比手或拳头细,我们可以假设手部几何形状总是会产生至少两个凸性缺陷(通常属于手腕)。因此,如果没有额外的缺陷,这意味着没有伸出的手指:
if len(defects) <= 2:
return [0, img_draw]
- 现在我们已经排除了所有特殊情况,我们可以开始计数真实的手指。如果有足够多的缺陷,我们将在每对手指之间找到一个缺陷。因此,为了得到正确的手指数量(
num_fingers),我们应该从1开始计数,如下所示:
num_fingers = 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)
- 然后,我们计算从
far到start和从far到end的两条边的夹角。如果角度小于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)
- 在迭代完所有凸性缺陷后,我们
返回检测到的手指数量和组装的输出图像,如下所示:
return min(5, num_fingers), img_draw
计算最小值将确保我们不超过每只手常见的手指数量。
结果可以在以下屏幕截图中看到:
有趣的是,我们的应用程序能够检测到各种手部配置中展开手指的正确数量。展开手指之间的缺陷点很容易被算法分类,而其他点则被成功忽略。
摘要
本章展示了一种相对简单——然而出人意料地鲁棒——的方法,通过计数展开的手指数量来识别各种手部手势。
该算法首先展示了如何使用从微软 Kinect 3D 传感器获取的深度信息来分割图像中的任务相关区域,以及如何使用形态学操作来清理分割结果。通过分析分割的手部区域形状,算法提出了一种根据图像中发现的凸性效应类型来分类手部手势的方法。
再次强调,掌握我们使用 OpenCV 执行所需任务的能力并不需要我们编写大量代码。相反,我们面临的是获得一个重要洞察力,这使我们能够有效地使用 OpenCV 的内置功能。
手势识别是计算机科学中一个流行但具有挑战性的领域,其应用范围广泛,包括人机交互(HCI)、视频监控,甚至视频游戏行业。你现在可以利用自己对分割和结构分析的高级理解来构建自己的最先进手势识别系统。另一种你可能想要用于手部手势识别的方法是在手部手势上训练一个深度图像分类网络。我们将在第九章中讨论用于图像分类的深度网络,学习分类和定位对象。
在下一章中,我们将继续关注在视觉场景中检测感兴趣的对象,但我们将假设一个更为复杂的情况:从任意视角和距离观察对象。为此,我们将结合透视变换和尺度不变特征描述符来开发一个鲁棒的特征匹配算法。
第三章:通过特征匹配和透视变换查找对象
在上一章中,你学习了如何在非常受控的环境中检测和跟踪一个简单对象(手的轮廓)。具体来说,我们指导我们的应用程序用户将手放置在屏幕中央区域,然后对对象(手)的大小和形状做出了假设。在本章中,我们希望检测和跟踪任意大小的对象,这些对象可能从几个不同的角度或部分遮挡下被观察。
为了做到这一点,我们将使用特征描述符,这是一种捕捉我们感兴趣对象重要属性的方法。我们这样做是为了即使对象嵌入在繁忙的视觉场景中,也能定位到该对象。我们将把我们的算法应用于网络摄像头的实时流,并尽力使算法既鲁棒又足够简单,以便实时运行。
本章将涵盖以下主题:
-
列出应用程序执行的任务
-
规划应用程序
-
设置应用程序
-
理解流程过程
-
学习特征提取
-
观察特征检测
-
理解特征描述符
-
理解特征匹配
-
学习特征跟踪
-
观察算法的实际应用
本章的目标是开发一个应用程序,可以在网络摄像头的视频流中检测和跟踪感兴趣的对象——即使对象从不同的角度、距离或部分遮挡下观察。这样的对象可以是书的封面图像、一幅画或任何具有复杂表面结构的其他东西。
一旦提供了模板图像,应用程序将能够检测该对象,估计其边界,然后在视频流中跟踪它。
开始学习
本章已在OpenCV 4.1.1上进行了测试。
注意,你可能需要从github.com/Itseez/opencv_contrib获取所谓的额外模块。
我们通过设置OPENCV_ENABLE_NONFREE和OPENCV_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()函数的执行:
- 函数首先使用
VideoCapture方法通过传递0作为参数来访问网络摄像头,这是一个默认网络摄像头的引用。如果无法访问网络摄像头,应用将被终止:
import cv2 as cv
from feature_matching import FeatureMatching
def main():
capture = cv.VideoCapture(0)
assert capture.isOpened(), "Cannot connect to camera"
- 然后,设置视频流的帧大小和每秒帧数。以下代码片段显示了设置视频帧大小和帧率的代码:
capture.set(cv.CAP_PROP_FPS, 10)
capture.set(cv.CAP_PROP_FRAME_WIDTH, 640)
capture.set(cv.CAP_PROP_FRAME_HEIGHT, 480)
- 接下来,使用指向模板(或训练)文件的路径初始化
FeatureMatching类的实例,该文件描述了感兴趣的对象。以下代码显示了FeatureMatching类:
matching = FeatureMatching(train_image='train.png')
- 之后,为了处理来自相机的帧,我们创建了一个从
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:
以下步骤涵盖了初始化过程:
- 以下行设置了一个 SURF 检测器,我们将使用它来检测和从图像中提取特征(有关更多详细信息,请参阅学习特征提取部分),Hessian 阈值为 300 到 500,即
400:
self.f_extractor = cv.xfeatures2d_SURF.create(hessianThreshold=400)
- 我们加载我们感兴趣的对象的模板(
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}"
- 此外,我们存储图像的形状(
self.sh_train)以方便使用:
self.sh_train = self.img_obj.shape[:2]
我们将模板图像称为训练图像,因为我们的算法将被训练以找到此图像,而每个输入帧称为查询图像,因为我们将使用这些图像来查询训练图像。以下照片是训练图像:
图像版权——Lenna.png 由 Conor Lawless 提供,许可协议为 CC BY 2.0
之前的训练图像大小为 512 x 512 像素,将用于训练算法。
- 接下来,我们将 SURF 应用于感兴趣的对象。这可以通过一个方便的函数调用完成,该调用返回关键点和描述符(你可以参阅学习特征提取部分以获得进一步解释):
self.key_train, self.desc_train = \
self.f_extractor.detectAndCompute(self.img_obj, None)
我们将对每个输入帧做同样的事情,然后比较图像之间的特征列表。
- 现在,我们设置一个 FLANN 对象,该对象将用于匹配训练图像和查询图像的特征(有关更多详细信息,请参阅理解特征匹配部分)。这需要通过字典指定一些额外的参数,例如使用哪种算法以及并行运行多少棵树:
index_params = {"algorithm": 0, "trees": 5}
search_params = {"checks": 50}
self.flann = cv.FlannBasedMatcher(index_params, search_params)
- 最后,初始化一些额外的记账变量。当我们想要使我们的特征跟踪既快又准确时,这些变量将很有用。例如,我们将跟踪最新的计算出的单应性矩阵和未定位我们感兴趣的对象的帧数(有关更多详细信息,请参阅学习特征跟踪部分):
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**方法完成。该方法遵循以下程序:
-
它从每个输入视频帧中提取有趣的图像特征。
-
它在模板图像和视频帧之间匹配特征。这是在
FeatureMatching.match_features中完成的。如果没有找到此类匹配,它将跳到下一帧。 -
它在视频帧中找到模板图像的角点。这是在
detect_corner_points函数中完成的。如果任何角点(显著)位于帧外,它将跳到下一帧。 -
它计算四个角点所围成的四边形的面积。如果面积要么太小要么太大,它将跳到下一帧。
-
它在当前帧中勾勒出模板图像的角点。
-
它找到必要的透视变换,将定位的对象从当前帧带到
frontoparallel平面。如果结果与最近对早期帧的结果显著不同,它将跳到下一帧。 -
它将当前帧的视角进行扭曲,使得感兴趣的对象居中且垂直。
在接下来的几节中,我们将详细讨论之前的步骤。
让我们先看看下一节中的特征提取步骤。这一步是算法的核心。它将在图像中找到信息丰富的区域,并以较低维度表示它们,这样我们就可以在之后使用这些表示来决定两张图像是否包含相似的特征。
学习特征提取
一般而言,在机器学习中,特征提取是一个数据降维的过程,它导致对数据元素的信息丰富描述。
在计算机视觉中,特征通常是指图像的有趣区域。它是关于图像所代表内容的非常信息丰富的可测量属性。通常,单个像素的灰度值(即原始数据)并不能告诉我们太多关于整个图像的信息。相反,我们需要推导出一个更具信息量的属性。
例如,知道图像中有看起来像眼睛、鼻子和嘴巴的区域,这将使我们能够推断出图像代表脸的可能性有多大。在这种情况下,描述数据所需资源的数量会大幅减少。数据指的是,例如,我们是否看到的是一张脸的图像。图像是否包含两只眼睛、一个鼻子或一个嘴巴?
更低层的特征,例如边缘、角点、块或脊的存在,通常更具信息量。某些特征可能比其他特征更好,这取决于应用。
一旦我们决定我们最喜欢的特征是什么,我们首先需要想出一个方法来检查图像是否包含这样的特征。此外,我们还需要找出它们在哪里,然后创建特征的描述符。让我们在下一节学习如何检测特征。
看看特征检测
在计算机视觉中,寻找图像中感兴趣区域的过程被称为特征检测。在底层,对于图像的每一个点,特征检测算法会决定该图像点是否包含感兴趣的特征。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_query 是 cv2.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:
函数接受两个图像,在我们的例子中,是感兴趣物体的图像和当前视频帧。它还接受两个图像的关键点和匹配项。它将在单个插图图像上并排放置图像,在图像上展示匹配项,并返回图像。后者是通过以下步骤实现的:
- 创建一个新输出图像,其大小足以将两个图像放在一起;为了在图像上绘制彩色线条,使其成为三通道:
rows1, cols1 = img1.shape[:2]
rows2, cols2 = img2.shape[:2]
out = np.zeros((max([rows1, rows2]), cols1 + cols2, 3), dtype='uint8')
- 将第一张图像放置在新图像的左侧,第二张图像放置在第一张图像的右侧:
out[:rows1, :cols1, :] = img1[..., None]
out[:rows2, cols1:cols1 + cols2, :] = img2[..., None]
在这些表达式中,我们使用了 NumPy 数组的广播规则,这是当数组的形状不匹配但满足某些约束时的操作规则。在这里,img[...,None]为二维灰度图像(数组)的第三个(通道)维度分配了一个规则。接下来,一旦NumPy遇到一个不匹配的维度,但具有值为一的,它就会广播数组。这意味着相同的值用于所有三个通道。
- 对于两个图像之间的每个匹配点对,我们想在每个图像上画一个小蓝色圆圈,并用线条连接两个圆圈。为此,使用
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 中的元组形式存储,包含两个条目用于x和y坐标。每个匹配项m存储在关键点列表中的索引,其中m.trainIdx指向第一个关键点列表(kp1)中的索引,而m.queryIdx指向第二个关键点列表(kp2)中的索引。
- 在相同的循环中,用四像素半径、蓝色和一像素粗细的圆圈绘制。然后,用线条连接圆圈:
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)
- 最后,
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)中对应特征点相同平面的透视变换:
- 首先,为了方便起见,我们将所有匹配良好的关键点的图像坐标存储在列表中,如下代码片段所示:
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]
- 现在,让我们将角点检测的逻辑封装到一个单独的函数中:
def detect_corner_points(src_points: Sequence[Point],
dst_points: Sequence[Point],
sh_src: Tuple[int, int]) -> np.ndarray:
之前的代码显示了两个点的序列和源图像的形状,函数将返回点的角,这是通过以下步骤实现的:
-
- 为给定的两个坐标序列找到透视变换(单应性矩阵,
H):
- 为给定的两个坐标序列找到透视变换(单应性矩阵,
H, _ = cv2.findHomography(np.array(src_points), np.array(dst_points), cv2.RANSAC)
为了找到变换,cv2.findHomography函数将使用随机样本一致性(RANSAC)方法来探测输入点的不同子集。
-
- 如果方法无法找到单应性矩阵,我们将
raise一个异常,我们将在应用程序中稍后捕获它:
- 如果方法无法找到单应性矩阵,我们将
if H is None:
raise Outlier("Homography not found")
-
- 给定源图像的形状,我们将其角点的坐标存储在一个数组中:
height, width = sh_src
src_corners = np.array([(0, 0), (width, 0),
(width, height),
(0, height)], dtype=np.float32)
-
- 可以使用单应性矩阵将图案中的任何点转换到场景中,例如将训练图像中的角点转换为查询图像中的角点。换句话说,这意味着我们可以通过将角点从训练图像转换到查询图像来在查询图像中绘制书的封面轮廓。
为了做到这一点,我们需要从训练图像的角点列表(src_corners)中取出并通过对查询图像进行透视变换来投影:
return cv2.perspectiveTransform(src_corners[None, :, :], H)[0]
此外,结果会立即返回,即一个包含图像点的数组(二维 NumPY 数组)。
- 现在我们已经定义了我们的函数,我们可以调用它来检测角点:
dst_corners = detect_corner_points(
train_points, query_points, self.sh_train)
- 我们需要做的只是在每个
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绘制它。我们还需要将数据类型更改为整数以进行绘制。
- 最后,书的封面草图绘制如下:
即使物体只部分可见,这种方法也有效,如下所示:
尽管书籍部分在框架之外,但书籍的轮廓是通过超出框架的轮廓边界预测的。
在下一节中,我们将学习如何扭曲图像,使其看起来更接近原始图像。
图像扭曲
我们也可以通过从探测场景到训练模式坐标进行相反的单应性估计/变换。这使得封面可以像直接从上方看它一样被带到前平面。为了实现这一点,我们可以简单地取单应性矩阵的逆来得到逆变换:
Hinv = cv2.linalg.inverse(H)
然而,这将把封面左上角映射到新图像的原点,这将切断封面左上方的所有内容。相反,我们希望在大约中心位置放置封面。因此,我们需要计算一个新的单应性矩阵。
封面的大小应该大约是新图像的一半。因此,我们不是使用训练图像的点坐标,而是以下方法演示了如何转换点坐标,使它们出现在新图像的中心:
- 首先,找到缩放因子和偏差,然后应用线性缩放并转换坐标:
@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]
- 作为输出,我们希望得到一个与模式图像(
sh_query)形状相同的图像:
train_points_scaled = self.scale_and_offset(
train_points, self.sh_train, sh_query)
- 然后,我们可以找到查询图像中的点和训练图像变换后的点之间的单应性矩阵(确保列表被转换为
NumPy数组):
Hinv, _ = cv2.findHomography(
np.array(query_points), np.array(train_points_scaled), cv2.RANSAC)
- 之后,我们可以使用单应性矩阵来转换图像中的每个像素(这也可以称为图像扭曲):
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异常来终止计算。以下步骤展示了匹配过程:
- 首先,我们在模式和查询图像的特征描述符之间找到良好的匹配,然后存储来自训练和查询图像的相应点坐标:
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")
- 然后,我们在查询图像中找到模式的角点(
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")
- 如果恢复的四个角点不能形成一个合理的四边形(一个有四边的多边形),这意味着我们可能没有看到我们感兴趣的对象。四边形的面积可以用以下代码计算:
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")
- 然后,我们缩放训练图像中的良好点并找到将对象带到正面面板的透视矩阵:
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)
- 如果恢复的单应性矩阵与我们上次恢复的矩阵(
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.0和wxPython 4.0.4(www.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 点云,我们需要利用极几何。然而,极几何假设了针孔相机模型,而现实中的相机并不遵循此模型。
-
应用程序的完整流程包括以下步骤:
-
相机标定: 我们将使用棋盘图案来提取内禀相机矩阵以及畸变系数,这些对于执行场景重建非常重要。
-
特征匹配: 我们将在同一视觉场景的两个 2D 图像中匹配点,无论是通过 SIFT 还是通过光流,如以下截图所示:
- 图像校正: 通过从一对图像中估计相机运动,我们将提取基础矩阵并校正图像,如以下截图所示:
-
三角测量法: 我们将通过利用极几何的约束来重建图像点的 3D 真实世界坐标。
-
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():
后者是通过以下步骤在函数体中完成的:
- 首先,连接到相机并设置标准 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)
- 类似于前面的章节,创建一个
wx应用程序和layout类,我们将在本节后面组合它们:
app = wx.App()
layout = CameraCalibration(capture, title='Camera Calibration', fps=2)
- 显示 GUI 并执行
app的MainLoop:
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,接下来我们将初始化一个相机校准算法。
初始化算法
为了执行校准过程,我们需要做一些记账工作。我们将通过以下步骤来完成:
- 现在,让我们专注于一个 10 x 7 的棋盘。算法将检测棋盘的所有
9x6个内部角落(称为对象点)并将这些角落检测到的图像点存储在列表中。因此,我们首先将chessboard_size初始化为内部角落的数量:
self.chessboard_size = (9, 6)
- 接下来,我们需要枚举所有对象点并将它们分配对象点坐标,以便第一个点的坐标为(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)
- 我们还需要跟踪我们是否正在记录对象和图像点。一旦用户点击
self.button_calibrate按钮,我们将启动此过程。之后,算法将尝试在所有后续帧中检测棋盘,直到检测到self.record_min_num_frames个棋盘:
# prepare recording
self.recording = False
self.record_min_num_frames = 15
self._reset_recording()
- 每当点击
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_points和self.img_points)以及将检测到的棋盘数量(self.recordCnt)重置为0:
def _reset_recording(self):
self.record_cnt = 0
self.obj_points = []
self.img_points = []
在下一节中,我们将收集图像和对象点。
收集图像和对象点
process_frame方法负责执行校准技术的艰苦工作,我们将通过以下步骤收集图像和对象点:
- 在点击
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的棋盘。如果图像确实包含棋盘,该函数将返回true(ret)以及一个棋盘角列表(corners)。
- 然后,绘制棋盘是直接的:
if ret:
print(f"{self.record_min_num_frames - self.record_cnt} chessboards remain")
cv2.drawChessboardCorners(frame, self.chessboard_size, corners, ret)
- 结果看起来像这样(用彩色绘制棋盘角以增强效果):
我们现在可以简单地存储检测到的角列表并继续下一帧。然而,为了使校准尽可能准确,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)
函数在成功时返回true(ret),内禀相机矩阵(K),畸变系数(dist),以及两个旋转和平移矩阵(rvecs和tvecs)。目前,我们主要对相机矩阵和畸变系数感兴趣,因为这些将允许我们补偿内部相机硬件的任何不完美。
我们将简单地打印它们到控制台以便于检查:
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():
函数包括以下步骤:
- 我们为拍摄喷泉数据集照片的相机定义了内禀相机矩阵(
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之外的数据集上运行本章中展示的代码,您将必须调整内禀相机矩阵和畸变系数。
- 接下来,我们创建
SceneReconstruction3D类的实例,并加载一对图像,我们希望将这些图像应用于我们的运动结构技术。数据集被下载到名为fountain_dense的子目录中:
scene = SceneReconstruction3D(K, d)
scene.load_image_pair("fountain_dense/0004.png",
"fountain_dense/0005.png")
- 现在,我们已经准备好调用类中执行各种计算的方法:
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.img1和self.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.img1和self.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_pts1和self.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_matrix 和 essential_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)
使用单位矩阵 U 和 V 与一个额外的矩阵 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_cameras 为 True 的那个解:
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 场景中的水平线相对应。因此,我们遵循以下步骤来校正我们的图像:
- 首先,我们执行前一小节中描述的所有步骤,以获得第二个相机的*[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]
- 然后,可以使用两个 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)
- 为了确保校正准确,我们将两个校正后的图像(
img_rect1和img_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
- 我们还在每
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 再次提供了一个用于解决大量线性方程的包装器,这是通过以下步骤完成的:
- 首先,我们必须将我们的匹配特征点列表转换为 NumPy 数组:
first_inliers = np.array(self.match_inliers1).reshape
(-1, 3)[:, :2]second_inliers = np.array(self.match_inliers2).reshape
(-1, 3)[:, :2]
- 三角剖分接下来使用前面提到的两个*[R | t]*矩阵(
self.Rt1用于第一台相机,self.Rt2用于第二台相机)进行:
pts4D = cv2.triangulatePoints(self.Rt1, self.Rt2, first_inliers.T,
second_inliers.T).T
- 这将使用 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)图像。
988

被折叠的 条评论
为什么被折叠?



