Python-OpenCV-示例-全-
Python OpenCV 示例(全)
原文:
annas-archive.org/md5/f7ea10e2017752abaae7798010b8596e译者:飞龙
前言
计算机视觉在现代技术中无处不在。Python 的 OpenCV 允许我们实时运行计算机视觉算法。随着强大机器的出现,我们获得了更多的处理能力。使用这项技术,我们可以无缝地将我们的计算机视觉应用程序集成到云端。网络开发者可以开发复杂的应用程序,而无需重新发明轮子。本书是一本实用的教程,涵盖了不同级别的各种示例,教你了解 OpenCV 的不同功能及其实际应用。
本书涵盖内容
第一章,将几何变换应用于图像,解释了如何将几何变换应用于图像。在本章中,我们将讨论仿射和投影变换,并了解我们如何使用它们来为照片应用酷炫的几何效果。本章将从在多个平台(如 Mac OS X、Linux 和 Windows)上安装 OpenCV-Python 的过程开始。我们还将学习如何以各种方式操作图像,例如调整大小、改变颜色空间等。
第二章,检测边缘和应用图像过滤器,展示了如何使用基本的图像处理算子,以及我们如何使用它们来构建更大的项目。我们将讨论为什么需要边缘检测,以及它在计算机视觉应用中的不同用途。我们将讨论图像过滤,以及我们如何使用它来为照片应用各种视觉效果。
第三章,图像卡通化,展示了如何使用图像过滤器和其它转换来卡通化给定的图像。我们将看到如何使用摄像头来捕获实时视频流。我们将讨论如何构建实时应用程序,其中我们从流中的每一帧中提取信息并显示结果。
第四章,检测和跟踪不同的身体部位,展示了如何在实时视频流中检测和跟踪面部。我们将讨论面部检测流程,并了解我们如何使用它来检测和跟踪不同的身体部位,如眼睛、耳朵、嘴巴、鼻子等。
第五章,从图像中提取特征,是关于检测图像中的显著点(称为关键点)。我们将讨论这些显著点为什么很重要,以及我们如何使用它们来理解图像内容。我们将讨论可以用来检测显著点并从图像中提取特征的不同技术。
第六章,创建全景图像,展示了如何通过拼接同一场景的多个图像来创建全景图像。
第七章, 裁剪缝展示了如何进行内容感知图像调整大小。我们将讨论如何检测图像中的“有趣”部分,并了解如何在不损害这些有趣部分的情况下调整给定图像的大小。
第八章, 检测形状和图像分割展示了如何执行图像分割。我们将讨论如何以最佳方式将给定图像分割成其组成部分。您还将学习如何在图像中分离前景和背景。
第九章, 目标跟踪展示了如何在实时视频流中跟踪不同的对象。到本章结束时,您将能够通过摄像头捕获的实时视频流跟踪任何对象。
第十章, 物体识别展示了如何构建物体识别系统。我们将讨论如何利用这些知识构建视觉搜索引擎。
第十一章, 立体视觉和 3D 重建展示了如何使用立体图像重建深度图。您将学习如何从一系列图像中实现场景的 3D 重建。
第十二章, 增强现实展示了如何构建增强现实应用。到本章结束时,您将能够使用摄像头构建一个有趣的增强现实项目。
您需要为本书准备
您需要以下软件:
-
OpenCV 2.4.9
-
numpy 1.9.2
-
scipy 0.15.1
-
scikit-learn 0.16.1
硬件规格要求是至少有 4GB DDR3 RAM 的任何计算机。
本书面向对象
本书旨在为对 OpenCV 新手的 Python 开发者编写,他们希望使用 OpenCV-Python 开发计算机视觉应用。本书对希望将计算机视觉应用部署到云端的通用软件开发者也很有用。对基本数学概念(如向量、矩阵等)有所了解将有所帮助。
约定
在本书中,您会发现许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:“我们使用一个名为getPerspectiveTransform的函数来获取变换矩阵。”
代码块设置如下:
cv2.imshow('Input', img)
cv2.imshow('Output', img_output)
cv2.waitKey()
任何命令行输入或输出如下所示:
$ make -j4
$ sudo make install
新术语和重要词汇以粗体显示。屏幕上看到的单词,例如在菜单或对话框中,在文本中显示如下:“tx和ty值是 X 和 Y 平移值。”
注意
警告或重要注意事项以如下框显示。
提示
小技巧和窍门看起来是这样的。
读者反馈
我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要发送给我们一般性的反馈,请简单地发送电子邮件至<feedback@packtpub.com>,并在邮件主题中提及书籍的标题。
如果你在一个领域有专业知识,并且你对撰写或为书籍做出贡献感兴趣,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在你已经是 Packt 书籍的骄傲拥有者,我们有许多事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你购买的所有 Packt 出版物的账户中下载示例代码文件。www.packtpub.com。如果你在其他地方购买了这本书,你可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给你。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/B04554_Graphics.pdf下载此文件。
错误清单
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果你在我们的书中发现错误——可能是文本或代码中的错误——如果你能向我们报告这一点,我们将不胜感激。通过这样做,你可以帮助其他读者避免挫折,并帮助我们改进本书的后续版本。如果你发现任何错误清单,请通过访问www.packtpub.com/submit-errata,选择你的书籍,点击错误提交表单链接,并输入你的错误清单的详细信息来报告它们。一旦你的错误清单得到验证,你的提交将被接受,错误清单将被上传到我们的网站或添加到该标题的错误清单部分。
要查看之前提交的错误清单,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将在错误清单部分显示。
盗版
互联网上版权材料的盗版是一个持续存在的问题,所有媒体都存在。在 Packt,我们非常重视我们版权和许可证的保护。如果你在互联网上发现任何形式的我们作品的非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过链接将疑似盗版材料发送至 <copyright@packtpub.com> 与我们联系。
我们感谢您在保护我们作者和我们提供有价值内容的能力方面的帮助。
问题和疑问
如果您对本书的任何方面有问题,您可以通过 <questions@packtpub.com> 与我们联系,我们将尽力解决问题。
第一章. 将几何变换应用于图像
在本章中,我们将学习如何将酷炫的几何效果应用于图像。在我们开始之前,我们需要安装 OpenCV-Python。我们将讨论如何安装必要的工具和包。
到本章结束时,您将知道:
-
如何安装 OpenCV-Python
-
如何读取、显示和保存图像
-
如何在多个颜色空间之间进行转换
-
如何应用几何变换,如平移、旋转和缩放
-
如何使用仿射和投影变换在照片上应用有趣的几何效果
安装 OpenCV-Python
让我们看看如何在多个平台上使用 Python 支持安装 OpenCV。
Windows
为了让 OpenCV-Python 正常运行,我们需要执行以下步骤:
-
安装 Python:确保您的计算机上已安装 Python 2.7.x。如果您没有安装,可以从
www.python.org/downloads/windows/安装。 -
安装 NumPy:NumPy 是一个在 Python 中进行数值计算的优秀包。它非常强大,具有多种功能。OpenCV-Python 与 NumPy 配合良好,我们将在本书的整个过程中大量使用这个包。您可以从
sourceforge.net/projects/numpy/files/NumPy/安装最新版本。
我们需要将这些包安装在其默认位置。一旦我们安装了 Python 和 NumPy,我们需要确保它们运行良好。打开 Python shell 并输入以下内容:
>>> import numpy
如果安装顺利,这不应该抛出任何错误。一旦您确认,您就可以继续下载最新的 OpenCV 版本,从opencv.org/downloads.html。
下载完成后,双击安装。我们需要进行一些更改,如下所示:
-
导航到
opencv/build/python/2.7/ -
您将看到一个名为
cv2.pyd的文件。将此文件复制到C:/Python27/lib/site-packages。
您已经设置好了!让我们确保 OpenCV 正在运行。打开 Python shell 并输入以下内容:
>>> import cv2
如果您没有看到任何错误,那么您就可以开始了!您现在可以使用 OpenCV-Python 了。
Mac OS X
要安装 OpenCV-Python,我们将使用Homebrew。Homebrew 是 Mac OS X 的一个优秀的包管理器,当您在 OS X 上安装各种库和实用程序时,它将非常有用。如果您没有 Homebrew,您可以通过在终端运行以下命令来安装它:
$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
尽管 OS X 自带 Python,但我们需要使用 Homebrew 安装 Python 以简化我们的操作。这个版本被称为 brewed Python。一旦您安装了 Homebrew,下一步就是安装 brewed Python。打开终端并输入以下内容:
$ brew install python
这将自动安装 pip。Pip 是一个用于在 Python 中安装包的包管理工具,我们将使用它来安装其他包。让我们确保已正确安装了 brewed Python。转到你的终端并输入以下内容:
$ which python
你应该在终端看到 /usr/local/bin/python 被打印出来。这意味着我们正在使用 brewed Python 而不是内置的系统 Python。现在我们已经安装了 brewed Python,我们可以继续添加仓库 homebrew/science,这是 OpenCV 所在的位置。打开终端并运行以下命令:
$ brew tap homebrew/science
确保已安装 NumPy 包。如果没有,请在终端运行以下命令:
$ pip install numpy
现在,我们已经准备好安装 OpenCV。从你的终端运行以下命令:
$ brew install opencv --with-tbb --with-opengl
OpenCV 现已安装在你的机器上,你可以在 /usr/local/Cellar/opencv/2.4.9/ 找到它。你现在还不能使用它。我们需要告诉 Python 哪里可以找到我们的 OpenCV 包。让我们继续通过符号链接 OpenCV 文件来完成这个操作。从你的终端运行以下命令:
$ cd /Library/Python/2.7/site-packages/
$ ln -s /usr/local/Cellar/opencv/2.4.9/lib/python2.7/site-packages/cv.py cv.py
$ ln -s /usr/local/Cellar/opencv/2.4.9/lib/python2.7/site-packages/cv2.so cv2.so
你已经准备好了!让我们看看它是否已正确安装。打开 Python 命令行界面并输入以下内容:
>>> import cv2
如果安装顺利,你将不会看到任何错误消息。你现在可以使用 Python 中的 OpenCV 了。
Linux (针对 Ubuntu)
在我们开始之前,我们需要安装一些依赖项。让我们使用以下包管理器安装它们:
$ sudo apt-get -y install libopencv-dev build-essential cmake libdc1394-22 libdc1394-22-dev libjpeg-dev libpng12-dev libtiff4-dev libjasper-dev libavcodec-dev libavformat-dev libswscale-dev libxine-dev libgstreamer0.10-dev libgstreamer-plugins-base0.10-dev libv4l-dev libtbb-dev libqt4-dev libmp3lame-dev libopencore-amrnb-dev libopencore-amrwb-dev libtheora-dev libvorbis-dev libxvidcore-dev x264 v4l-utils python-scipy python-pip python-virtualenv
现在你已经安装了必要的包,让我们继续使用 Python 支持构建 OpenCV:
$ wget "https://github.com/Itseez/opencv/archive/2.4.9.tar.gz" -O ./opencv/opencv.tar.gz
$ cd opencv
$ tar xvzf opencv.tar.gz -C .
$ mkdir release
$ cd release
$ sudo apt-get –y install cmake
$ cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/usr/local -D BUILD_PYTHON_SUPPORT=ON -D WITH_XINE=ON -D WITH_OPENGL=ON -D WITH_TBB=ON -D WITH_EIGEN=ON -D BUILD_EXAMPLES=ON -D BUILD_NEW_PYTHON_SUPPORT=ON -D WITH_V4L=ON ../
$ make –j4
$ sudo make install
让我们确保它已正确安装。打开 Python 命令行界面,并输入以下内容:
>>> import cv2
如果你没有看到任何错误,那么你就可以继续了。
如果你使用的是其他 Linux 发行版,请参考 OpenCV 下载页面 (opencv.org/downloads.html) 以获取安装详情。
读取、显示和保存图像
让我们看看如何在 OpenCV-Python 中加载一个图像。创建一个名为 first_program.py 的文件,并在你喜欢的代码编辑器中打开它。在当前文件夹中创建一个名为 images 的文件夹,并确保在该文件夹中有一个名为 input.jpg 的图像。
一旦你这样做,将以下行添加到那个 Python 文件中:
import cv2
img = cv2.imread('./images/input.jpg')
cv2.imshow('Input image', img)
cv2.waitKey()
如果你运行前面的程序,你将看到一个图像在新窗口中显示。
刚才发生了什么?
让我们逐行理解前面的代码。在第一行,我们正在导入 OpenCV 库。我们需要这个库来使用代码中的所有函数。在第二行,我们正在读取图像并将其存储在一个变量中。OpenCV 使用 NumPy 数据结构来存储图像。你可以在 www.numpy.org 上了解更多关于 NumPy 的信息。
所以如果你打开 Python 命令行界面并输入以下内容,你将在终端看到打印出的数据类型:
>>> import cv2
>>> img = cv2.imread('./images/input.jpg')
>>> type(img)
<type 'numpy.ndarray'>
在下一行,我们在新窗口中显示图像。cv2.imshow 中的第一个参数是窗口的名称,第二个参数是你想要显示的图像。
您可能想知道为什么这里有一行。函数cv2.waitKey()在 OpenCV 中用于键盘绑定。它接受一个数字作为参数,该数字表示毫秒数。基本上,我们使用这个函数来等待指定的时间,直到我们遇到键盘事件。程序在此处停止,等待您按任意键继续。如果我们不传递任何参数或传递0作为参数,此函数将无限期地等待键盘事件。
加载和保存图像
OpenCV 提供了多种加载图像的方法。假设我们想要以灰度模式加载彩色图像。我们可以使用以下代码片段来完成:
import cv2
gray_img = cv2.imread('images/input.jpg', cv2.IMREAD_GRAYSCALE)
cv2.imshow('Grayscale', gray_img)
cv2.waitKey()
在这里,我们使用标志cv2.IMREAD_GRAYSCALE以灰度模式加载图像。您可以从新窗口中显示的图像中看到这一点。接下来,是输入图像:

下面是相应的灰度图像:

我们也可以将此图像保存到文件中:
cv2.imwrite('images/output.jpg', gray_img)
这将把灰度图像保存到名为output.jpg的输出文件中。确保您熟悉在 OpenCV 中读取、显示和保存图像,因为在本书的整个过程中我们将做很多这样的操作。
图像颜色空间
在计算机视觉和图像处理中,颜色空间指的是组织颜色的特定方式。颜色空间实际上是两件事的组合:一个颜色模型和一个映射函数。我们想要颜色模型的原因是它帮助我们使用元组表示像素值。映射函数将颜色模型映射到可以表示的所有可能颜色的集合。
有许多不同的颜色空间非常有用。其中一些更流行的颜色空间是 RGB、YUV、HSV、Lab 等等。不同的颜色空间提供不同的优势。我们只需要选择适合给定问题的颜色空间。让我们来看几个颜色空间,看看它们提供了哪些信息:
-
RGB:这可能是最流行的颜色空间。它代表红色、绿色和蓝色。在这个颜色空间中,每种颜色都表示为红色、绿色和蓝色的加权组合。因此,每个像素值都表示为对应于红色、绿色和蓝色的三个数字的元组。每个值介于 0 到 255 之间。
-
YUV:尽管 RGB 在许多用途中都很好,但它对于许多实际应用来说往往非常有限。人们开始考虑不同的方法来分离强度信息与颜色信息。因此,他们提出了 YUV 颜色空间。Y 代表亮度或强度,U/V 通道代表颜色信息。这在许多应用中都很好,因为人眼对强度信息和颜色信息的感知方式非常不同。
-
HSV:结果证明,即使是 YUV 对于某些应用来说还不够好。因此,人们开始思考人类如何感知颜色,并提出了 HSV 颜色空间。HSV 代表色调、饱和度和亮度。这是一个圆柱系统,我们通过不同的通道来分离颜色的三个最基本属性。这与人类视觉系统理解颜色的方式密切相关。这为我们提供了很多灵活性,关于我们如何处理图像。
颜色空间之间的转换
考虑到所有颜色空间,OpenCV 中大约有 190 种转换选项可用。如果你想查看所有可用标志的列表,请转到 Python 壳中并输入以下内容:
>>> import cv2
>>> print [x for x in dir(cv2) if x.startswith('COLOR_')]
你将看到 OpenCV 中可用于从一种颜色空间转换到另一种颜色空间的选项列表。我们可以将任何颜色空间转换为任何其他颜色空间。让我们看看如何将彩色图像转换为灰度图像:
import cv2
img = cv2.imread('./images/input.jpg')
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
cv2.imshow('Grayscale image', gray_img)
cv2.waitKey()
发生了什么?
我们使用 cvtColor 函数在颜色空间之间进行转换。第一个参数是输入图像,第二个参数指定颜色空间转换。你可以使用以下标志将图像转换为 YUV:
yuv_img = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)
图像看起来可能像以下这样:

这可能看起来像是原始图像的退化版本,但事实并非如此。让我们分离出三个通道:
cv2.imshow('Y channel', yuv_img[:, :, 0])
cv2.imshow('U channel', yuv_img[:, :, 1])
cv2.imshow('V channel', yuv_img[:, :, 2])
cv2.waitKey()
由于 yuv_img 是一个 NumPy 数组,我们可以通过切片来分离出三个通道。如果你查看 yuv_img.shape,你会看到它是一个三维数组,其维度是 NUM_ROWS x NUM_COLUMNS x NUM_CHANNELS。所以一旦运行前面的代码片段,你会看到三幅不同的图像。以下是 Y 通道:

Y 通道基本上是灰度图像。接下来是 U 通道:

最后,是 V 通道:

如我们所见,Y 通道与灰度图像相同。它表示强度值。U 和 V 通道表示颜色信息。
让我们转换到 HSV 看看会发生什么:
hsv_img = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
cv2.imshow('HSV image', hsv_img)

再次,让我们分离通道:
cv2.imshow('H channel', hsv_img[:, :, 0])
cv2.imshow('S channel', hsv_img[:, :, 1])
cv2.imshow('V channel', hsv_img[:, :, 2])
cv2.waitKey()
如果运行前面的代码片段,你会看到三幅不同的图像。看看 H 通道:

接下来是 S 通道:

下面是 V 通道:

这应该给你一个关于如何在颜色空间之间转换的基本概念。你可以尝试更多的颜色空间,看看图像看起来像什么。我们将在后续章节中遇到相关颜色空间时进行讨论。
图像转换
在本节中,我们将讨论如何移动图像。假设我们想要在参考框架内移动图像。在计算机视觉术语中,这被称为平移。让我们看看我们如何实现这一点:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
num_rows, num_cols = img.shape[:2]
translation_matrix = np.float32([ [1,0,70], [0,1,110] ])
img_translation = cv2.warpAffine(img, translation_matrix, (num_cols, num_rows))
cv2.imshow('Translation', img_translation)
cv2.waitKey()
如果你运行上一段代码,你会看到如下所示的内容:

发生了什么?
为了理解前面的代码,我们需要了解扭曲是如何工作的。平移基本上意味着我们通过添加/减去 X 和 Y 坐标来移动图像。为了做到这一点,我们需要创建一个变换矩阵,如下所示:

在这里,tx和ty值是 X 和 Y 平移值,即图像将向右移动X个单位,向下移动Y个单位。因此,一旦我们创建了一个这样的矩阵,我们就可以使用warpAffine函数将其应用于我们的图像。warpAffine函数中的第三个参数指的是结果图像的行数和列数。由于行数和列数与原始图像相同,结果图像将被裁剪。这是因为我们在应用平移矩阵时输出空间不足。为了避免裁剪,我们可以这样做:
img_translation = cv2.warpAffine(img, translation_matrix, (num_cols + 70, num_rows + 110))
如果你将程序中相应的行替换为上一行,你会看到以下图像:

假设你想要将图像移动到更大图像框架的中间;我们可以通过执行以下操作来实现类似的效果:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
num_rows, num_cols = img.shape[:2]
translation_matrix = np.float32([ [1,0,70], [0,1,110] ])
img_translation = cv2.warpAffine(img, translation_matrix, (num_cols + 70, num_rows + 110))
translation_matrix = np.float32([ [1,0,-30], [0,1,-50] ])
img_translation = cv2.warpAffine(img_translation, translation_matrix, (num_cols + 70 + 30, num_rows + 110 + 50))
cv2.imshow('Translation', img_translation)
cv2.waitKey()
如果你运行上一段代码,你会看到如下所示的图像:

图像旋转
在本节中,我们将了解如何通过一定的角度旋转给定的图像。我们可以使用以下代码片段来完成:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
num_rows, num_cols = img.shape[:2]
rotation_matrix = cv2.getRotationMatrix2D((num_cols/2, num_rows/2), 30, 1)
img_rotation = cv2.warpAffine(img, rotation_matrix, (num_cols, num_rows))
cv2.imshow('Rotation', img_rotation)
cv2.waitKey()
如果你运行上一段代码,你会看到如下所示的图像:

发生了什么?
为了理解这一点,让我们看看我们如何从数学上处理旋转。旋转也是一种变换形式,我们可以通过以下变换矩阵来实现:

在这里,θ是逆时针方向的旋转角度。OpenCV 通过getRotationMatrix2D函数提供了对创建此矩阵的更精细的控制。我们可以指定图像将围绕哪个点旋转,旋转的角度(以度为单位),以及图像的缩放因子。一旦我们有了变换矩阵,我们可以使用warpAffine函数将此矩阵应用于任何图像。
如前图所示,图像内容超出了边界并被裁剪。为了防止这种情况,我们需要在输出图像中提供足够的空间。让我们使用之前讨论过的平移功能来这样做:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
num_rows, num_cols = img.shape[:2]
translation_matrix = np.float32([ [1,0,int(0.5*num_cols)], [0,1,int(0.5*num_rows)] ])
2*num_cols, 2*num_rows))
rotation_matrix = cv2.getRotationMatrix2D((num_cols, num_rows), 30, img_translation = cv2.warpAffine(img, translation_matrix, (1)
img_rotation = cv2.warpAffine(img_translation, rotation_matrix, (2*num_cols, 2*num_rows))
cv2.imshow('Rotation', img_rotation)
cv2.waitKey()
如果我们运行上一段代码,我们会看到类似以下的内容:

图像缩放
在本节中,我们将讨论图像的缩放。这是计算机视觉中最常见的操作之一。我们可以使用缩放因子来调整图像大小,或者将其调整到特定的大小。让我们看看如何做到这一点:
img_scaled = cv2.resize(img,None,fx=1.2, fy=1.2, interpolation = cv2.INTER_LINEAR)
cv2.imshow('Scaling - Linear Interpolation', img_scaled) img_scaled = cv2.resize(img,None,fx=1.2, fy=1.2, interpolation = cv2.INTER_CUBIC)
cv2.imshow('Scaling - Cubic Interpolation', img_scaled) img_scaled = cv2.resize(img,(450, 400), interpolation = cv2.INTER_AREA)
cv2.imshow('Scaling - Skewed Size', img_scaled) cv2.waitKey()
刚才发生了什么?
每当我们调整图像大小时,有多种方式来填充像素值。当我们放大图像时,我们需要填充像素位置之间的像素值。当我们缩小图像时,我们需要取最佳代表性值。当我们以非整数值缩放时,我们需要适当地插值值,以保持图像的质量。有多种插值方法。如果我们放大图像,最好使用线性或立方插值。如果我们缩小图像,最好使用基于区域的插值。立方插值在计算上更复杂,因此比线性插值慢。但生成的图像质量会更高。
OpenCV 提供了一个名为resize的函数来实现图像缩放。如果你没有指定大小(通过使用None),那么它期望 X 和 Y 缩放因子。在我们的例子中,图像将按1.2的因子放大。如果我们使用立方插值进行相同的放大,我们可以看到质量有所提高,如下面的图所示。下面的截图显示了线性插值的外观:

这里是相应的立方插值:

如果我们想将其调整到特定的大小,我们可以使用最后一次缩放实例中显示的格式。我们基本上可以扭曲图像并将其调整到我们想要的任何大小。输出将类似于以下内容:

斜变换
在本节中,我们将讨论 2D 图像的各种广义几何变换。在前几节中,我们相当多地使用了warpAffine函数,现在是时候了解其背后的发生了什么。
在讨论斜变换之前,让我们看看什么是欧几里得变换。欧几里得变换是一种保持长度和角度测量的几何变换。也就是说,如果我们对一个几何形状应用欧几里得变换,形状将保持不变。它可能看起来是旋转的、平移的等等,但基本结构不会改变。所以从技术上讲,线仍然是线,平面仍然是平面,正方形仍然是正方形,圆仍然是圆。
回到仿射变换,我们可以这样说,它们是欧几里得变换的推广。在仿射变换的范畴内,直线将保持直线,但正方形可能会变成矩形或平行四边形。基本上,仿射变换不保持长度和角度。
为了构建一个一般的仿射变换矩阵,我们需要定义控制点。一旦我们有了这些控制点,我们需要决定我们希望它们映射到何处。在这种情况下,我们只需要源图像中的三个点和输出图像中的三个点。让我们看看我们如何将图像转换为类似平行四边形的图像:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
rows, cols = img.shape[:2]
src_points = np.float32([[0,0], [cols-1,0], [0,rows-1]])
dst_points = np.float32([[0,0], [int(0.6*(cols-1)),0], [int(0.4*(cols-1)),rows-1]])
affine_matrix = cv2.getAffineTransform(src_points, dst_points)
img_output = cv2.warpAffine(img, affine_matrix, (cols,rows))
cv2.imshow('Input', img)
cv2.imshow('Output', img_output)
cv2.waitKey()
发生了什么?
正如我们之前讨论的,我们正在定义控制点。我们只需要三个点来获取仿射变换矩阵。我们希望src_points中的三个点映射到dst_points中的对应点。我们按照以下方式映射点:

为了获取变换矩阵,我们在 OpenCV 中有一个名为getAffineTransform的函数。一旦我们有了仿射变换矩阵,我们使用warpAffine函数将这个矩阵应用到输入图像上。
以下是我们输入的图像:

如果你运行前面的代码,输出将看起来像这样:

我们也可以得到输入图像的镜像。我们只需要按照以下方式更改控制点:
src_points = np.float32([[0,0], [cols-1,0], [0,rows-1]])
dst_points = np.float32([[cols-1,0], [0,0], [cols-1,rows-1]])
在这里,映射看起来大致是这样的:

如果你将我们的仿射变换代码中的对应行替换为这两行,你将得到以下结果:

投影变换
仿射变换很好,但它们施加了某些限制。另一方面,投影变换给了我们更多的自由。它也被称为单应性。为了理解投影变换,我们需要了解投影几何是如何工作的。我们基本上描述了当视角改变时图像会发生什么。例如,如果你正站在画有正方形的纸张的正前方,它看起来就像一个正方形。现在,如果你开始倾斜那张纸,正方形将开始看起来更像梯形。投影变换允许我们以优雅的数学方式捕捉这种动态。这些变换既不保持大小也不保持角度,但它们确实保持交点和交叉比。
注意
你可以在en.wikipedia.org/wiki/Incidence_(geometry)和en.wikipedia.org/wiki/Cross-ratio上了解更多关于交点和交叉比的信息。
既然我们已经知道了什么是投影变换,让我们看看我们是否可以在这里提取更多信息。我们可以这样说,给定平面上任意两个图像通过单应性相关联。只要它们在同一个平面上,我们可以将任何东西变换成任何其他东西。这有许多实际应用,例如增强现实、图像校正、图像配准或计算两张图像之间的相机运动。一旦从估计的单应性矩阵中提取出相机旋转和平移,这些信息可以用于导航,或者将 3D 物体的模型插入到图像或视频中。这样,它们将以正确的透视渲染,看起来就像它们是原始场景的一部分。
让我们继续看看如何做到这一点:
import cv2
import numpy as np
img = cv2.imread('images/input.jpg')
rows, cols = img.shape[:2]
src_points = np.float32([[0,0], [cols-1,0], [0,rows-1], [cols-1,rows-1]])
dst_points = np.float32([[0,0], [cols-1,0], [int(0.33*cols),rows-1], [int(0.66*cols),rows-1]])
projective_matrix = cv2.getPerspectiveTransform(src_points, dst_points)
img_output = cv2.warpPerspective(img, projective_matrix, (cols,rows))
cv2.imshow('Input', img)
cv2.imshow('Output', img_output)
cv2.waitKey()
如果你运行前面的代码,你会看到一个像以下截图一样的有趣输出:

发生了什么?
我们可以在源图像中选择四个控制点并将它们映射到目标图像。变换后,平行线将不再是平行线。我们使用一个名为getPerspectiveTransform的函数来获取变换矩阵。
让我们使用投影变换来应用一些有趣的效果,看看它们看起来像什么。我们只需要改变控制点来获得不同的效果。
这里有一个例子:

控制点如下所示:
src_points = np.float32([[0,0], [0,rows-1], [cols/2,0], [cols/2,rows-1]])
dst_points = np.float32([[0,100], [0,rows-101], [cols/2,0], [cols/2,rows-1]])
作为练习,你应该在平面上映射上述点,看看点是如何映射的(就像我们之前在讨论仿射变换时做的那样)。这将帮助你更好地理解映射系统,并可以创建自己的控制点。
图像扭曲
让我们再玩一些图像,看看我们还能实现什么。投影变换非常灵活,但它们仍然对我们如何变换点施加一些限制。如果我们想做一些完全随机的事情呢?我们需要更多的控制,对吧?碰巧,我们也可以做到。我们只需要创建自己的映射,这并不难。以下是一些你可以通过图像扭曲实现的效果:

下面是创建这些效果的代码:
import cv2
import numpy as np
import math
img = cv2.imread('images/input.jpg', cv2.IMREAD_GRAYSCALE)
rows, cols = img.shape
#####################
# Vertical wave
img_output = np.zeros(img.shape, dtype=img.dtype)
for i in range(rows):
for j in range(cols):
offset_x = int(25.0 * math.sin(2 * 3.14 * i / 180))
offset_y = 0
if j+offset_x < rows:
img_output[i,j] = img[i,(j+offset_x)%cols]
else:
img_output[i,j] = 0
cv2.imshow('Input', img)
cv2.imshow('Vertical wave', img_output)
#####################
# Horizontal wave
img_output = np.zeros(img.shape, dtype=img.dtype)
for i in range(rows):
for j in range(cols):
offset_x = 0
offset_y = int(16.0 * math.sin(2 * 3.14 * j / 150))
if i+offset_y < rows:
img_output[i,j] = img[(i+offset_y)%rows,j]
else:
img_output[i,j] = 0
cv2.imshow('Horizontal wave', img_output)
#####################
# Both horizontal and vertical
img_output = np.zeros(img.shape, dtype=img.dtype)
for i in range(rows):
for j in range(cols):
offset_x = int(20.0 * math.sin(2 * 3.14 * i / 150))
offset_y = int(20.0 * math.cos(2 * 3.14 * j / 150))
if i+offset_y < rows and j+offset_x < cols:
img_output[i,j] = img[(i+offset_y)%rows,(j+offset_x)%cols]
else:
img_output[i,j] = 0
cv2.imshow('Multidirectional wave', img_output)
#####################
# Concave effect
img_output = np.zeros(img.shape, dtype=img.dtype)
for i in range(rows):
for j in range(cols):
offset_x = int(128.0 * math.sin(2 * 3.14 * i / (2*cols)))
offset_y = 0
if j+offset_x < cols:
img_output[i,j] = img[i,(j+offset_x)%cols]
else:
img_output[i,j] = 0
cv2.imshow('Concave', img_output)
cv2.waitKey()
概述
在本章中,我们学习了如何在各种平台上安装 OpenCV-Python。我们讨论了如何读取、显示和保存图像。我们探讨了各种颜色空间的重要性以及我们如何可以在多个颜色空间之间进行转换。我们学习了如何将几何变换应用于图像,并理解了如何使用这些变换来实现酷炫的几何效果。我们讨论了变换矩阵的底层公式以及我们如何可以根据我们的需求制定不同类型的变换。我们学习了如何根据所需的几何变换选择控制点。我们讨论了投影变换,并学习了如何使用图像扭曲来实现任何给定的几何效果。在下一章中,我们将讨论边缘检测和图像滤波。我们可以使用图像滤波器应用许多视觉效果,其底层结构为我们提供了很多自由度,以创造性地操纵图像。
第二章.检测边缘和应用图像滤波器
在本章中,我们将了解如何将酷炫的视觉效果应用于图像。我们将学习如何使用基本的图像处理算子。我们将讨论边缘检测以及我们如何可以使用图像滤波器在照片上应用各种效果。
到本章结束时,您将了解:
-
什么是 2D 卷积以及如何使用它
-
如何模糊图像
-
如何在图像中检测边缘
-
如何给图像应用运动模糊
-
如何锐化和浮雕图像
-
如何腐蚀和膨胀图像
-
如何创建晕影滤镜
-
如何增强图像对比度
小贴士
下载示例代码
您可以从您在
www.packtpub.com的账户下载示例代码文件,以获取您购买的所有 Packt Publishing 书籍。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
2D 卷积
卷积是图像处理中的一个基本操作。我们基本上将一个数学算子应用于每个像素,并以某种方式改变其值。要应用这个数学算子,我们使用另一个称为内核的矩阵。内核通常比输入图像小得多。对于图像中的每个像素,我们取内核并将其放置在上面,使得内核的中心与正在考虑的像素重合。然后我们将内核矩阵中的每个值与图像中相应的值相乘,然后求和。这就是将在输出图像的这个位置替换的新值。
在这里,内核被称为“图像滤波器”,将此内核应用于给定图像的过程称为“图像滤波”。将内核应用于图像后获得的结果称为滤波图像。根据内核中的值,它执行不同的功能,如模糊、检测边缘等。以下图应有助于您可视化图像滤波操作:

让我们从最简单的情况开始,即单位内核。这个内核实际上并不会改变输入图像。如果我们考虑一个 3x3 的单位内核,它看起来就像以下这样:

模糊
模糊是指在一个邻域内平均像素值。这也称为低通滤波器。低通滤波器是一种允许低频并阻止高频的滤波器。现在,接下来我们可能会想到的问题是——在图像中“频率”是什么意思?嗯,在这个上下文中,频率指的是像素值变化的速率。因此,我们可以说,锐利的边缘会是高频内容,因为在这个区域的像素值变化很快。按照这个逻辑,平面区域会是低频内容。按照这个定义,低通滤波器会尝试平滑边缘。
构建低通滤波器的一个简单方法是均匀平均像素邻域内的值。我们可以根据我们想要平滑图像的程度来选择核的大小,它将相应地产生不同的效果。如果你选择更大的尺寸,那么你将在更大的区域内进行平均。这通常会增强平滑效果。让我们看看 3x3 低通滤波器核的样子:

我们将矩阵除以 9,因为我们希望值加起来等于1。这被称为归一化,它很重要,因为我们不希望人为地增加该像素位置的强度值。所以你应该在将核应用到图像之前进行归一化。归一化是一个非常重要的概念,它在各种场景中使用,所以你应该在网上阅读一些教程,以获得对它的良好理解。
这里是应用低通滤波器到图像的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
rows, cols = img.shape[:2]
kernel_identity = np.array([[0,0,0], [0,1,0], [0,0,0]])
kernel_3x3 = np.ones((3,3), np.float32) / 9.0
kernel_5x5 = np.ones((5,5), np.float32) / 25.0
cv2.imshow('Original', img)
output = cv2.filter2D(img, -1, kernel_identity)
cv2.imshow('Identity filter', output)
output = cv2.filter2D(img, -1, kernel_3x3)
cv2.imshow('3x3 filter', output)
output = cv2.filter2D(img, -1, kernel_5x5)
cv2.imshow('5x5 filter', output)
cv2.waitKey(0)
如果你运行前面的代码,你会看到类似这样的结果:

核大小与模糊程度的关系
在前面的代码中,我们生成了不同的核,分别是kernel_identity、kernel_3x3和kernel_5x5。我们使用filter2D函数将这些核应用到输入图像上。如果你仔细观察图像,你会发现随着核大小的增加,图像变得越来越模糊。这是因为当我们增加核大小时,我们是在更大的区域内进行平均。这通常会带来更大的模糊效果。
另一种实现方式是使用 OpenCV 的blur函数。如果你不想自己生成核,可以直接使用这个函数。我们可以用以下代码行来调用它:
output = cv2.blur(img, (3,3))
这将直接应用 3x3 核并给出输出。
边缘检测
边缘检测的过程涉及检测图像中的尖锐边缘,并生成一个二值图像作为输出。通常,我们在黑色背景上画白色线条来指示这些边缘。我们可以将边缘检测视为高通滤波操作。高通滤波器允许高频内容通过并阻止低频内容。正如我们之前讨论的,边缘是高频内容。在边缘检测中,我们希望保留这些边缘并丢弃其他所有内容。因此,我们应该构建一个相当于高通滤波器的核。
让我们从一种简单的边缘检测滤波器开始,称为Sobel滤波器。由于边缘可以出现在水平和垂直方向,Sobel滤波器由以下两个核组成:

左侧的核检测水平边缘,右侧的核检测垂直边缘。OpenCV 提供了一个函数可以直接将Sobel滤波器应用到给定的图像上。以下是使用 Sobel 滤波器检测边缘的代码:
import cv2
import numpy as np
img = cv2.imread('input_shapes.png', cv2.IMREAD_GRAYSCALE)
rows, cols = img.shape
sobel_horizontal = cv2.Sobel(img, cv2.CV_64F, 1, 0, ksize=5)
sobel_vertical = cv2.Sobel(img, cv2.CV_64F, 0, 1, ksize=5)
cv2.imshow('Original', img)
cv2.imshow('Sobel horizontal', sobel_horizontal)
cv2.imshow('Sobel vertical', sobel_vertical)
cv2.waitKey(0)
输出将看起来像以下这样:

在前面的图中,中间的图像是水平边缘检测器的输出,右边的图像是垂直边缘检测器的输出。如上图所示,Sobel滤波器可以在水平和垂直方向检测边缘,但它不会给我们一个所有边缘的整体视图。为了克服这个问题,我们可以使用Laplacian滤波器。使用这个滤波器的优点是它在两个方向上都使用双重导数。你可以使用以下行来调用该函数:
laplacian = cv2.Laplacian(img, cv2.CV_64F)
输出将看起来像以下截图:

尽管在这个情况下Laplacian核工作得很好,但它并不总是有效。它会在输出中产生很多噪声,如下面的截图所示。这就是Canny 边缘检测器派上用场的地方:

如上图所示,Laplacian核会产生噪声输出,这并不完全有用。为了克服这个问题,我们使用Canny 边缘检测器。要使用Canny 边缘检测器,我们可以使用以下函数:
canny = cv2.Canny(img, 50, 240)
如上图所示,Canny 边缘检测器的质量要好得多。它接受两个数字作为参数来指示阈值。第二个参数被称为低阈值,第三个参数被称为高阈值。如果梯度值高于高阈值,它会被标记为强边缘。Canny 边缘检测器从这个点开始跟踪边缘,并继续这个过程,直到梯度值低于低阈值。当你增加这些阈值时,较弱的边缘将被忽略。输出图像将更干净、更稀疏。你可以调整阈值并观察当增加或减少它们的值时会发生什么。整体公式相当复杂。你可以在www.intelligence.tuc.gr/~petrakis/courses/computervision/canny.pdf了解更多信息。
运动模糊
当我们应用运动模糊效果时,它看起来就像你在特定方向上移动时捕捉到了图片。例如,你可以让图片看起来像是从一个移动的汽车上捕捉到的。
输入和输出图像将看起来像以下这些:

以下是实现这种运动模糊效果的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
cv2.imshow('Original', img)
size = 15
# generating the kernel
kernel_motion_blur = np.zeros((size, size))
kernel_motion_blur[int((size-1)/2), :] = np.ones(size)
kernel_motion_blur = kernel_motion_blur / size
# applying the kernel to the input image
output = cv2.filter2D(img, -1, kernel_motion_blur)
cv2.imshow('Motion Blur', output)
cv2.waitKey(0)
内部结构
我们像往常一样读取图像。然后我们构建一个运动模糊核。一个运动模糊核会在特定方向上平均像素值。它就像一个方向性低通滤波器。一个 3x3 水平运动模糊核看起来是这样的:

这将在水平方向上模糊图像。您可以选择任何方向,它都会相应地工作。模糊的程度将取决于核的大小。因此,如果您想使图像更模糊,只需为核选择更大的尺寸。为了看到完整的效果,我们在前面的代码中使用了 15x15 的核。然后我们使用filter2D将这个核应用到输入图像上,以获得运动模糊的输出。
锐化
应用锐化滤镜将增强图像中的边缘。当我们需要增强不清晰的图像边缘时,这个滤镜非常有用。以下是一些图像,以供您了解图像锐化过程的外观:

如您在前面的图中所见,锐化的程度取决于我们使用的核的类型。在这里,我们有很大的自由度来自定义核,每个核都会给您带来不同类型的锐化。如果我们只想锐化图像,就像我们在前一张图片的右上角所做的那样,我们会使用这样的核:

如果我们要进行过度的锐化,就像左下角的图像那样,我们会使用以下核:

但这两个核的问题在于输出图像看起来经过了人工增强。如果我们想让我们的图像看起来更自然,我们会使用边缘增强滤镜。基本概念保持不变,但我们使用一个近似高斯核来构建这个滤镜。当我们增强边缘时,这将帮助我们平滑图像,从而使图像看起来更自然。
这是实现前面截图中应用效果的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
cv2.imshow('Original', img)
# generating the kernels
kernel_sharpen_1 = np.array([[-1,-1,-1], [-1,9,-1], [-1,-1,-1]])
kernel_sharpen_2 = np.array([[1,1,1], [1,-7,1], [1,1,1]])
kernel_sharpen_3 = np.array([[-1,-1,-1,-1,-1],
[-1,2,2,2,-1],
[-1,2,8,2,-1],
[-1,2,2,2,-1],
[-1,-1,-1,-1,-1]]) / 8.0
# applying different kernels to the input image
output_1 = cv2.filter2D(img, -1, kernel_sharpen_1)
output_2 = cv2.filter2D(img, -1, kernel_sharpen_2)
output_3 = cv2.filter2D(img, -1, kernel_sharpen_3)
cv2.imshow('Sharpening', output_1)
cv2.imshow('Excessive Sharpening', output_2)
cv2.imshow('Edge Enhancement', output_3)
cv2.waitKey(0)
如果您注意到了,在前面的代码中,我们没有将前两个核除以归一化因子。原因是核内的值已经总和为 1,所以我们隐式地将矩阵除以 1。
理解模式
您一定注意到了图像滤波代码示例中的常见模式。我们构建一个核,然后使用filter2D来获取所需的输出。这正是这个代码示例中发生的事情!您可以尝试调整核内的值,看看是否能得到不同的视觉效果。确保在应用核之前对其进行归一化,否则图像会看起来太亮,因为您正在人为地增加图像中的像素值。
浮雕
浮雕滤镜会将图像转换为浮雕图像。我们基本上是将每个像素替换为阴影或高光。比如说我们处理图像中的一个相对平坦的区域。在这里,我们需要用纯灰色替换它,因为那里没有太多信息。如果某个区域有很高的对比度,我们将用白色像素(高光)或黑色像素(阴影)替换它,具体取决于我们浮雕的方向。
这就是它的样子:

让我们看看代码,看看如何做到这一点:
import cv2
import numpy as np
img_emboss_input = cv2.imread('input.jpg')
# generating the kernels
kernel_emboss_1 = np.array([[0,-1,-1],
[1,0,-1],
[1,1,0]])
kernel_emboss_2 = np.array([[-1,-1,0],
[-1,0,1],
[0,1,1]])
kernel_emboss_3 = np.array([[1,0,0],
[0,0,0],
[0,0,-1]])
# converting the image to grayscale
gray_img = cv2.cvtColor(img_emboss_input,cv2.COLOR_BGR2GRAY)
# applying the kernels to the grayscale image and adding the offset
output_1 = cv2.filter2D(gray_img, -1, kernel_emboss_1) + 128
output_2 = cv2.filter2D(gray_img, -1, kernel_emboss_2) + 128
output_3 = cv2.filter2D(gray_img, -1, kernel_emboss_3) + 128
cv2.imshow('Input', img_emboss_input)
cv2.imshow('Embossing - South West', output_1)
cv2.imshow('Embossing - South East', output_2)
cv2.imshow('Embossing - North West', output_3)
cv2.waitKey(0)
如果你运行前面的代码,你会看到输出图像是浮雕的。正如我们可以从上面的核中看到的那样,我们只是在特定方向上用相邻像素值的差值替换当前像素值。浮雕效果是通过将图像中所有像素值偏移 128 来实现的。这个操作为图片添加了高光/阴影效果。
腐蚀和膨胀
腐蚀 和 膨胀 是形态学图像处理操作。形态学图像处理基本上处理图像中的几何结构修改。这些操作主要定义用于二值图像,但我们也可以在灰度图像上使用它们。腐蚀基本上移除了结构的最外层像素,而膨胀则在结构上添加了一层额外的像素。
让我们看看这些操作看起来像什么:

下面是实现这个效果的代码:
import cv2
import numpy as np
img = cv2.imread('input.png', 0)
kernel = np.ones((5,5), np.uint8)
img_erosion = cv2.erode(img, kernel, iterations=1)
img_dilation = cv2.dilate(img, kernel, iterations=1)
cv2.imshow('Input', img)
cv2.imshow('Erosion', img_erosion)
cv2.imshow('Dilation', img_dilation)
cv2.waitKey(0)
反思
OpenCV 提供了直接腐蚀和膨胀图像的函数。它们分别称为 erode 和 dilate。值得注意的是这两个函数的第三个参数。迭代次数将决定你想要腐蚀/膨胀给定图像的程度。它基本上将操作连续应用于结果图像。你可以取一个样本图像并调整这个参数,看看结果如何。
创建 vignette 滤镜
使用我们所拥有的所有信息,让我们看看是否可以创建一个漂亮的 vignette 滤镜。输出将看起来像以下这样:

下面是实现这个效果的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
rows, cols = img.shape[:2]
# generating vignette mask using Gaussian kernels
kernel_x = cv2.getGaussianKernel(cols,200)
kernel_y = cv2.getGaussianKernel(rows,200)
kernel = kernel_y * kernel_x.T
mask = 255 * kernel / np.linalg.norm(kernel)
output = np.copy(img)
# applying the mask to each channel in the input image
for i in range(3):
output[:,:,i] = output[:,:,i] * mask
cv2.imshow('Original', img)
cv2.imshow('Vignette', output)
cv2.waitKey(0)
下面到底发生了什么?
Vignette 滤镜基本上将亮度集中在图像的特定部分,而其他部分看起来则变得模糊。为了实现这一点,我们需要使用高斯核对图像中的每个通道进行过滤。OpenCV 提供了一个执行此操作的函数,称为 getGaussianKernel。我们需要构建一个与图像大小匹配的 2D 核。函数 getGaussianKernel 的第二个参数很有趣。它是高斯的标准差,它控制着明亮中心区域的半径。你可以调整这个参数,看看它如何影响输出。
一旦我们构建了 2D 核,我们需要通过归一化这个核并放大它来构建一个掩码,如下面的行所示:
mask = 255 * kernel / np.linalg.norm(kernel)
这是一个重要的步骤,因为如果你不放大它,图像将看起来是黑色的。这是因为在你将掩码叠加到输入图像上之后,所有像素值都会接近 0。之后,我们遍历所有颜色通道,并将掩码应用于每个通道。
我们如何移动焦点?
我们现在知道如何创建一个聚焦于图像中心的vignette滤镜。假设我们想要达到相同的vignette效果,但我们想聚焦于图像中的不同区域,如图所示:

我们需要做的只是构建一个更大的高斯核,并确保峰值与感兴趣的区域相吻合。以下是实现这一目标的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
rows, cols = img.shape[:2]
# generating vignette mask using Gaussian kernels
kernel_x = cv2.getGaussianKernel(int(1.5*cols),200)
kernel_y = cv2.getGaussianKernel(int(1.5*rows),200)
kernel = kernel_y * kernel_x.T
mask = 255 * kernel / np.linalg.norm(kernel)
mask = mask[int(0.5*rows):, int(0.5*cols):]
output = np.copy(img)
# applying the mask to each channel in the input image
for i in range(3):
output[:,:,i] = output[:,:,i] * mask
cv2.imshow('Input', img)
cv2.imshow('Vignette with shifted focus', output)
cv2.waitKey(0)
增强图像的对比度
无论何时我们在低光条件下捕捉图像,图像都会变得很暗。这通常发生在你在傍晚或昏暗的房间里捕捉图像时。你肯定见过这种情况发生很多次!这种情况发生的原因是因为在这种情况下捕捉图像时,像素值往往会集中在 0 附近。当这种情况发生时,图像中的许多细节对肉眼来说并不清晰可见。人眼喜欢对比度,因此我们需要调整对比度,使图像看起来更美观、更愉快。许多相机和照片应用程序已经隐式地做了这件事。我们使用一个称为直方图均衡化的过程来实现这一点。
为了举例说明,这是对比度增强前后的图像外观:

如此可见,左侧的输入图像非常暗。为了纠正这一点,我们需要调整像素值,使它们分布在整个值谱上,即 0 到255之间。
以下是为调整像素值而编写的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg', 0)
# equalize the histogram of the input image
histeq = cv2.equalizeHist(img)
cv2.imshow('Input', img)
cv2.imshow('Histogram equalized', histeq)
cv2.waitKey(0)
直方图均衡化适用于灰度图像。OpenCV 提供了一个名为equalizeHist的函数来实现这一效果。正如我们所见,代码相当简单,其中我们读取图像并均衡其直方图以调整图像的对比度。
我们如何处理彩色图像?
现在我们知道了如何均衡灰度图像的直方图,你可能想知道如何处理彩色图像。关于直方图均衡化,它是一个非线性过程。因此,我们不能只是将 RGB 图像中的三个通道分开,分别均衡直方图,然后再将它们组合起来形成输出图像。直方图均衡化的概念仅适用于图像中的强度值。因此,我们必须确保在执行此操作时不要修改颜色信息。
为了处理彩色图像的直方图均衡化,我们需要将其转换为一种强度与颜色信息分离的颜色空间。YUV 就是这样一种颜色空间。一旦我们将其转换为 YUV,我们只需要均衡 Y 通道,并将其与其他两个通道组合起来以获得输出图像。
以下是一个示例,展示其外观:

以下是实现彩色图像直方图均衡化的代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
img_yuv = cv2.cvtColor(img, cv2.COLOR_BGR2YUV)
# equalize the histogram of the Y channel
img_yuv[:,:,0] = cv2.equalizeHist(img_yuv[:,:,0])
# convert the YUV image back to RGB format
img_output = cv2.cvtColor(img_yuv, cv2.COLOR_YUV2BGR)
cv2.imshow('Color input image', img)
cv2.imshow('Histogram equalized', img_output)
cv2.waitKey(0)
摘要
在本章中,我们学习了如何使用图像过滤器将酷炫的视觉效果应用到图像上。我们讨论了基本的图像处理算子以及如何使用它们构建各种事物。我们学习了如何使用各种方法检测边缘。我们理解了二维卷积的重要性以及在不同场景中如何使用它。我们讨论了如何平滑、运动模糊、锐化、浮雕、腐蚀和膨胀图像。我们学习了如何创建晕影过滤器,以及如何改变焦点区域。我们讨论了对比度增强以及如何使用直方图均衡化来实现它。在下一章中,我们将讨论如何卡通化给定的图像。
第三章:图像卡通化
在本章中,我们将学习如何将图像转换为卡通风格的图像。我们将学习如何在实时视频流中访问网络摄像头和获取键盘/鼠标输入。我们还将了解一些高级图像过滤器,并了解我们如何使用它们来卡通化图像。
到本章结束时,您将知道:
-
如何访问网络摄像头
-
如何在实时视频流中获取键盘和鼠标输入
-
如何创建交互式应用程序
-
如何使用高级图像过滤器
-
如何卡通化图像
访问网络摄像头
我们可以使用网络摄像头的实时视频流构建非常有趣的应用程序。OpenCV 提供了一个视频捕获对象,它处理与打开和关闭网络摄像头相关的所有事情。我们所需做的只是创建该对象,并从中读取帧。
以下代码将打开网络摄像头,捕获帧,将它们按 2 倍因子缩小,然后在窗口中显示。您可以按 Esc 键退出。
import cv2
cap = cv2.VideoCapture(0)
# Check if the webcam is opened correctly
if not cap.isOpened():
raise IOError("Cannot open webcam")
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
cv2.imshow('Input', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
内部机制
如前述代码所示,我们使用 OpenCV 的 VideoCapture 函数创建视频捕获对象 cap。一旦创建,我们就启动一个无限循环,并从网络摄像头读取帧,直到遇到键盘中断。在 while 循环内的第一行,我们有以下行:
ret, frame = cap.read()
在这里,ret 是由 read 函数返回的布尔值,它表示帧是否成功捕获。如果帧被正确捕获,它将存储在变量 frame 中。这个循环将一直运行,直到我们按下 Esc 键。因此,我们在以下行中持续检查键盘中断:
if c == 27:
如我们所知,Esc 的 ASCII 值为 27。一旦遇到它,我们就中断循环并释放视频捕获对象。cap.release() 这一行很重要,因为它优雅地关闭了网络摄像头。
键盘输入
现在我们知道了如何从网络摄像头捕获实时视频流,让我们看看如何使用键盘与显示视频流的窗口进行交互。
import argparse
import cv2
def argument_parser():
parser = argparse.ArgumentParser(description="Change color space of the \
input video stream using keyboard controls. The control keys are: \
Grayscale - 'g', YUV - 'y', HSV - 'h'")
return parser
if __name__=='__main__':
args = argument_parser().parse_args()
cap = cv2.VideoCapture(0)
# Check if the webcam is opened correctly
if not cap.isOpened():
raise IOError("Cannot open webcam")
cur_char = -1
prev_char = -1
while True:
# Read the current frame from webcam
ret, frame = cap.read()
# Resize the captured image
frame = cv2.resize(frame, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
c = cv2.waitKey(1)
if c == 27:
break
if c > -1 and c != prev_char:
cur_char = c
prev_char = c
if cur_char == ord('g'):
output = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
elif cur_char == ord('y'):
output = cv2.cvtColor(frame, cv2.COLOR_BGR2YUV)
elif cur_char == ord('h'):
output = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
else:
output = frame
cv2.imshow('Webcam', output)
cap.release()
cv2.destroyAllWindows()
与应用程序交互
此程序将显示输入的视频流并等待键盘输入以更改颜色空间。如果您运行上一个程序,您将看到显示来自网络摄像头的输入视频流的窗口。如果您按下 G,您将看到输入流的颜色空间被转换为灰度。如果您按下 Y,输入流将被转换为 YUV 颜色空间。同样,如果您按下 H,您将看到图像被转换为 HSV 颜色空间。
如我们所知,我们使用 waitKey() 函数来监听键盘事件。每当遇到不同的按键时,我们采取适当的行动。我们使用 ord() 函数的原因是 waitKey() 返回键盘输入的 ASCII 值;因此,在检查它们的值之前,我们需要将字符转换为它们的 ASCII 形式。
鼠标输入
在本节中,我们将了解如何使用鼠标与显示窗口进行交互。让我们从简单的事情开始。我们将编写一个程序,该程序将检测鼠标点击发生的位置象限。一旦检测到,我们将突出显示该象限。
import cv2
import numpy as np
def detect_quadrant(event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN:
if x > width/2:
if y > height/2:
point_top_left = (int(width/2), int(height/2))
point_bottom_right = (width-1, height-1)
else:
point_top_left = (int(width/2), 0)
point_bottom_right = (width-1, int(height/2))
else:
if y > height/2:
point_top_left = (0, int(height/2))
point_bottom_right = (int(width/2), height-1)
else:
point_top_left = (0, 0)
point_bottom_right = (int(width/2), int(height/2))
cv2.rectangle(img, (0,0), (width-1,height-1), (255,255,255), -1)
cv2.rectangle(img, point_top_left, point_bottom_right, (0,100,0), -1)
if __name__=='__main__':
width, height = 640, 480
img = 255 * np.ones((height, width, 3), dtype=np.uint8)
cv2.namedWindow('Input window')
cv2.setMouseCallback('Input window', detect_quadrant)
while True:
cv2.imshow('Input window', img)
c = cv2.waitKey(10)
if c == 27:
break
cv2.destroyAllWindows()
输出将类似于以下图像:

发生了什么?
让我们从程序中的主函数开始。我们创建一个白色图像,我们将使用鼠标点击该图像。然后我们创建一个命名窗口,并将鼠标回调函数绑定到该窗口。鼠标回调函数基本上是在检测到鼠标事件时将被调用的函数。有许多种鼠标事件,如点击、双击、拖动等。在我们的情况下,我们只想检测鼠标点击。在函数detect_quadrant中,我们检查第一个输入参数event以查看执行了什么操作。OpenCV 提供了一套预定义的事件,我们可以使用特定的关键字来调用它们。如果您想查看所有鼠标事件的列表,您可以在 Python shell 中输入以下内容:
>>> import cv2
>>> print [x for x in dir(cv2) if x.startswith('EVENT')]
函数detect_quadrant中的第二个和第三个参数提供了鼠标点击事件的 X 和 Y 坐标。一旦我们知道这些坐标,确定它所在的象限就非常直接了。有了这些信息,我们只需使用cv2.rectangle()函数绘制一个指定颜色的矩形。这是一个非常方便的函数,它接受左上角点和右下角点,在图像上绘制一个指定颜色的矩形。
与实时视频流交互
让我们看看如何使用鼠标与来自摄像头的实时视频流进行交互。我们可以使用鼠标选择一个区域,然后在该区域应用“负片”效果,如下所示:

在以下程序中,我们将从摄像头捕获视频流,使用鼠标选择感兴趣的区域,然后应用效果:
import cv2
import numpy as np
def draw_rectangle(event, x, y, flags, params):
global x_init, y_init, drawing, top_left_pt, bottom_right_pt
if event == cv2.EVENT_LBUTTONDOWN:
drawing = True
x_init, y_init = x, y
elif event == cv2.EVENT_MOUSEMOVE:
if drawing:
top_left_pt = (min(x_init, x), min(y_init, y))
bottom_right_pt = (max(x_init, x), max(y_init, y))
img[y_init:y, x_init:x] = 255 - img[y_init:y, x_init:x]
elif event == cv2.EVENT_LBUTTONUP:
drawing = False
top_left_pt = (min(x_init, x), min(y_init, y))
bottom_right_pt = (max(x_init, x), max(y_init, y))
img[y_init:y, x_init:x] = 255 - img[y_init:y, x_init:x]
if __name__=='__main__':
drawing = False
top_left_pt, bottom_right_pt = (-1,-1), (-1,-1)
cap = cv2.VideoCapture(0)
# Check if the webcam is opened correctly
if not cap.isOpened():
raise IOError("Cannot open webcam")
cv2.namedWindow('Webcam')
cv2.setMouseCallback('Webcam', draw_rectangle)
while True:
ret, frame = cap.read()
img = cv2.resize(frame, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
(x0,y0), (x1,y1) = top_left_pt, bottom_right_pt
img[y0:y1, x0:x1] = 255 - img[y0:y1, x0:x1]
cv2.imshow('Webcam', img)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
如果您运行前面的程序,您将看到一个显示视频流的窗口。您只需使用鼠标在窗口上画一个矩形,您将看到该区域被转换为它的“负值”。
我们是如何做到的?
正如我们在程序的主函数中看到的,我们初始化了一个视频捕获对象。然后我们在以下行中将函数draw_rectangle与鼠标回调绑定:
cv2.setMouseCallback('Webcam', draw_rectangle)
我们然后启动一个无限循环并开始捕获视频流。让我们看看函数draw_rectangle中发生了什么。每次我们使用鼠标绘制矩形时,我们基本上必须检测三种类型的鼠标事件:鼠标点击、鼠标移动和鼠标按钮释放。这正是我们在该函数中做的。每次我们检测到鼠标点击事件时,我们就初始化矩形的左上角点。当我们移动鼠标时,我们通过保持当前位置作为矩形的右下角点来选择感兴趣的区域。
一旦我们确定了感兴趣的区域,我们只需反转像素以应用“负片”效果。我们从 255 中减去当前像素值,这样就得到了期望的效果。当鼠标移动停止并且检测到按钮抬起事件时,我们停止更新矩形的右下角位置。我们只需继续显示此图像,直到检测到另一个鼠标点击事件。
卡通化图像
现在我们知道了如何处理网络摄像头和键盘/鼠标输入,让我们继续看看如何将图片转换为类似卡通的图像。我们可以将图像转换为草图或彩色卡通图像。
下面是一个草图将看起来怎样的例子:

如果你将卡通化效果应用于彩色图像,它看起来可能就像下面这张图像:

让我们看看如何实现这一点:
import cv2
import numpy as np
def cartoonize_image(img, ds_factor=4, sketch_mode=False):
# Convert image to grayscale
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Apply median filter to the grayscale image
img_gray = cv2.medianBlur(img_gray, 7)
# Detect edges in the image and threshold it
edges = cv2.Laplacian(img_gray, cv2.CV_8U, ksize=5)
ret, mask = cv2.threshold(edges, 100, 255, cv2.THRESH_BINARY_INV)
# 'mask' is the sketch of the image
if sketch_mode:
return cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
# Resize the image to a smaller size for faster computation
img_small = cv2.resize(img, None, fx=1.0/ds_factor, fy=1.0/ds_factor, interpolation=cv2.INTER_AREA)
num_repetitions = 10
sigma_color = 5
sigma_space = 7
size = 5
# Apply bilateral filter the image multiple times
for i in range(num_repetitions):
img_small = cv2.bilateralFilter(img_small, size, sigma_color, sigma_space)
img_output = cv2.resize(img_small, None, fx=ds_factor, fy=ds_factor, interpolation=cv2.INTER_LINEAR)
dst = np.zeros(img_gray.shape)
# Add the thick boundary lines to the image using 'AND' operator
dst = cv2.bitwise_and(img_output, img_output, mask=mask)
return dst
if __name__=='__main__':
cap = cv2.VideoCapture(0)
cur_char = -1
prev_char = -1
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=0.5, fy=0.5, interpolation=cv2.INTER_AREA)
c = cv2.waitKey(1)
if c == 27:
break
if c > -1 and c != prev_char:
cur_char = c
prev_char = c
if cur_char == ord('s'):
cv2.imshow('Cartoonize', cartoonize_image(frame, sketch_mode=True))
elif cur_char == ord('c'):
cv2.imshow('Cartoonize', cartoonize_image(frame, sketch_mode=False))
else:
cv2.imshow('Cartoonize', frame)
cap.release()
cv2.destroyAllWindows()
代码解构
当你运行前面的程序时,你会看到一个包含来自网络摄像头的视频流的窗口。如果你按S,视频流将切换到草图模式,你会看到它的铅笔轮廓。如果你按C,你会看到输入流的彩色卡通版本。如果你按任何其他键,它将返回到正常模式。
让我们看看函数cartoonize_image,看看我们是如何做到的。我们首先将图像转换为灰度图像,并通过中值滤波器进行处理。中值滤波器非常擅长去除椒盐噪声。这种噪声在图像中表现为孤立的黑色或白色像素。这在网络摄像头和手机摄像头中很常见,因此在我们进一步处理之前需要过滤掉它。为了举例说明,请看以下图像:

正如我们在输入图像中看到的,有很多孤立的绿色像素。它们降低了图像质量,我们需要去除它们。这就是中值滤波器发挥作用的地方。我们只需查看每个像素周围的 NxN 邻域,并选择这些数字的中值。由于在这种情况下孤立的像素具有高值,取中值将去除这些值,并使图像平滑。正如您在输出图像中看到的,中值滤波器去除了所有这些孤立的像素,图像看起来很干净。以下是实现此功能的代码:
import cv2
import numpy as np
img = cv2.imread('input.png')
output = cv2.medianBlur(img, 7)
cv2.imshow('Input', img)
cv2.imshow('Median filter', output)
cv2.waitKey()
代码相当直接。我们只是使用medianBlur函数将中值滤波器应用于输入图像。这个函数的第二个参数指定了我们使用的核的大小。核的大小与我们需要考虑的邻域大小有关。您可以尝试调整这个参数,看看它如何影响输出。
回到cartoonize_image,我们继续在灰度图像上检测边缘。我们需要知道边缘在哪里,这样我们才能创建铅笔线效果。一旦我们检测到边缘,我们就对它们进行阈值处理,使事物变成黑白,字面和比喻意义上的!
在下一步中,我们检查草图模式是否启用。如果是,我们就将其转换为彩色图像并返回。如果我们想使线条更粗呢?比如说,我们想看到以下这样的图像:

如您所见,线条比之前更粗。为了实现这一点,将if代码块替换为以下代码片段:
if sketch_mode:
img_sketch = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
kernel = np.ones((3,3), np.uint8)
img_eroded = cv2.erode(img_sketch, kernel, iterations=1)
return cv2.medianBlur(img_eroded, 5)
我们在这里使用的是 3x3 核的 erode 函数。我们之所以这样做,是因为它给了我们调整线条绘制厚度的机会。现在您可能会问,如果我们想增加某物的厚度,难道不应该使用膨胀吗?嗯,推理是正确的,但这里有一个小转折。请注意,前景是黑色,背景是白色。腐蚀和膨胀将白色像素视为前景,将黑色像素视为背景。因此,如果我们想增加黑色前景的厚度,我们需要使用腐蚀。在我们应用腐蚀之后,我们只需使用中值滤波器来清除噪声并得到最终输出。
在下一步中,我们使用双边滤波来平滑图像。双边滤波是一个有趣的概念,其性能远优于高斯滤波。双边滤波的优点在于它保留了边缘,而高斯滤波则将一切均匀地平滑。为了比较和对比,让我们看看以下输入图像:

让我们将高斯滤波应用于之前的图像:

现在,让我们将双边滤波应用于输入图像:

如您所见,如果我们使用双边滤波,质量会更好。图像看起来很平滑,边缘看起来也很清晰!实现这一点的代码如下:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
img_gaussian = cv2.GaussianBlur(img, (13,13), 0)
img_bilateral = cv2.bilateralFilter(img, 13, 70, 50)
cv2.imshow('Input', img)
cv2.imshow('Gaussian filter', img_gaussian)
cv2.imshow('Bilateral filter', img_bilateral)
cv2.waitKey()
如果你仔细观察两个输出,你可以看到高斯滤波图像中的边缘看起来是模糊的。通常,我们只想平滑图像中的粗糙区域,并保持边缘完整。这就是双边滤波器派上用场的地方。高斯滤波器只查看直接邻域,并使用高斯核平均像素值。双边滤波器通过只平均那些在强度上相似的像素将这个概念提升到下一个层次。它还使用颜色邻域度量来查看是否可以替换当前像素,该像素在强度上与当前像素相似。如果你查看函数调用:
img_small = cv2.bilateralFilter(img_small, size, sigma_color, sigma_space)
在这里最后两个参数指定了颜色和空间邻域。这就是双边滤波器输出边缘看起来清晰的原因。我们在图像上多次运行这个滤波器以平滑它,使其看起来像卡通。然后我们在这个彩色图像上叠加铅笔状的遮罩以创建类似卡通的效果。
摘要
在本章中,我们学习了如何访问摄像头。我们讨论了如何在实时视频流中获取键盘和鼠标输入。我们利用这些知识创建了一个交互式应用程序。我们讨论了中值滤波器和双边滤波器,并谈到了双边滤波器相对于高斯滤波器的优势。我们使用所有这些原理将输入图像转换为类似草图的效果,然后将其卡通化。
在下一章中,我们将学习如何在静态图像和实时视频中检测不同的身体部位。
第四章. 检测和跟踪不同身体部位
在本章中,我们将学习如何在实时视频流中检测和跟踪不同的身体部位。我们将从讨论人脸检测流程及其从头开始构建的方式开始。我们将学习如何使用这个框架来检测和跟踪其他身体部位,例如眼睛、耳朵、嘴巴和鼻子。
到本章结束时,你将知道:
-
如何使用 Haar 级联
-
什么是积分图像
-
什么是自适应提升
-
如何在实时视频流中检测和跟踪人脸
-
如何在实时视频流中检测和跟踪眼睛
-
如何自动在人脸上叠加太阳镜
-
如何检测耳朵、鼻子和嘴巴
-
如何通过形状分析检测瞳孔
使用 Haar 级联检测物体
当我们提到 Haar 级联时,我们实际上是在谈论基于 Haar 特征的级联分类器。为了理解这意味着什么,我们需要退一步,了解为什么我们最初需要这个。回到 2001 年,保罗·维奥拉和迈克尔·琼斯在他们开创性的论文中提出了一种非常有效的目标检测方法。这已经成为机器学习领域的一个主要里程碑。
在他们的论文中,他们描述了一种机器学习技术,其中使用简单分类器的提升级联来获得一个表现非常出色的整体分类器。这样,我们可以绕过构建单个复杂分类器以实现高精度的过程。之所以这如此令人惊叹,是因为构建一个健壮的单步分类器是一个计算密集型的过程。此外,我们需要大量的训练数据来构建这样的分类器。模型最终变得复杂,性能可能无法达到预期标准。
假设我们想要检测一个物体,比如,菠萝。为了解决这个问题,我们需要构建一个机器学习系统,该系统能够学习菠萝的外观。它应该能够告诉我们未知图像中是否包含菠萝。为了实现类似的功能,我们需要训练我们的系统。在机器学习的领域,我们有大量的方法可以用来训练一个系统。这很像训练一只狗,只不过它不会帮你捡球!为了训练我们的系统,我们使用大量的菠萝和非菠萝图像,并将它们输入到系统中。在这里,菠萝图像被称为正图像,而非菠萝图像被称为负图像。
就训练而言,有众多路线可供选择。但所有传统技术都是计算密集型的,并导致复杂的模型。我们不能使用这些模型来构建实时系统。因此,我们需要保持分类器简单。但是,如果我们保持分类器简单,它将不会很准确。速度和准确度之间的权衡在机器学习中很常见。我们通过构建一系列简单的分类器并将它们级联起来形成一个鲁棒的统一分类器来克服这个问题。为了确保整体分类器工作良好,我们需要在级联步骤中发挥创意。这就是为什么Viola-Jones方法如此有效的主要原因之一。
说到面部检测的话题,让我们看看如何训练一个系统来检测面部。如果我们想构建一个机器学习系统,我们首先需要从所有图像中提取特征。在我们的情况下,机器学习算法将使用这些特征来学习面部的外观。我们使用 Haar 特征来构建我们的特征向量。Haar 特征是图像中块片的简单求和和差分。我们在多个图像大小上进行此操作,以确保我们的系统是尺度不变的。
注意
如果你对此好奇,你可以在www.cs.ubc.ca/~lowe/425/slides/13-ViolaJones.pdf上了解更多关于公式的信息。
一旦我们提取了这些特征,我们就将其通过一系列分类器。我们只是检查所有不同的矩形子区域,并丢弃其中没有面部的那些。这样,我们就能快速得出结论,看给定的矩形是否包含面部。
积分图像是什么?
如果我们要计算 Haar 特征,我们将在图像内计算许多不同矩形区域的求和。如果我们想有效地构建特征集,我们需要在多个尺度上计算这些求和。这是一个非常昂贵的进程!如果我们想构建一个实时系统,我们不能在计算这些和上花费这么多周期。所以,我们使用一种叫做积分图像的东西。

要计算图像中任何矩形的和,我们不需要遍历该矩形区域的所有元素。假设 AP 表示由图像中左上角点和点 P(作为两个对角线相对的顶点)形成的矩形的所有元素的和。所以现在,如果我们想计算矩形 ABCD 的面积,我们可以使用以下公式:
矩形 ABCD 的面积 = AC – (AB + AD - AA)
为什么我们关心这个特定的公式?正如我们之前讨论的,提取 Haar 特征包括在多个尺度上计算图像中大量矩形的面积。其中许多计算是重复的,整个过程非常慢。事实上,它如此之慢,以至于我们无法承担实时运行任何东西的费用。这就是我们使用这个公式的理由!这个方法的好处是,我们不必重新计算任何东西。这个方程式右侧的所有面积值都已经可用。所以我们只需使用它们来计算任何给定矩形的面积并提取特征。
面部检测与跟踪
OpenCV 提供了一个不错的面部检测框架。我们只需要加载级联文件并使用它来检测图像中的面部。让我们看看如何做到这一点:
import cv2
import numpy as np
face_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_frontalface_alt.xml')
cap = cv2.VideoCapture(0)
scaling_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
face_rects = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in face_rects:
cv2.rectangle(frame, (x,y), (x+w,y+h), (0,255,0), 3)
cv2.imshow('Face Detector', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
如果你运行上述代码,它看起来可能像以下图像:

更好地理解它
我们需要一个分类器模型,可以用来检测图像中的面部。OpenCV 提供了一个用于此目的的 xml 文件。我们使用CascadeClassifier函数来加载 xml 文件。一旦我们开始从摄像头捕获输入帧,我们就将其转换为灰度图,并使用detectMultiScale函数获取当前图像中所有面部的边界框。这个函数的第二个参数指定了缩放因子的跳跃。也就是说,如果我们当前尺度下找不到图像,下一个要检查的尺寸将是当前尺寸的 1.3 倍。最后一个参数是一个阈值,指定了需要保留当前矩形的相邻矩形数量。它可以用来增加面部检测器的鲁棒性。
面部游戏
现在我们已经知道了如何检测和跟踪面部,让我们来点乐趣。当我们从摄像头捕获视频流时,我们可以在我们的面部上叠加有趣的口罩。它看起来可能像下面这张图像:

如果你喜欢汉尼拔,你可以尝试下一个:

让我们看看代码,看看如何将颅骨面具叠加到输入视频流中的面部上:
import cv2
import numpy as np
face_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_frontalface_alt.xml')
face_mask = cv2.imread('mask_hannibal.png')
h_mask, w_mask = face_mask.shape[:2]
if face_cascade.empty():
raise IOError('Unable to load the face cascade classifier xml file')
cap = cv2.VideoCapture(0)
scaling_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
face_rects = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in face_rects:
if h > 0 and w > 0:
# Adjust the height and weight parameters depending on the sizes and the locations. You need to play around with these to make sure you get it right.
h, w = int(1.4*h), int(1.0*w)
y -= 0.1*h
# Extract the region of interest from the image
frame_roi = frame[y:y+h, x:x+w]
face_mask_small = cv2.resize(face_mask, (w, h), interpolation=cv2.INTER_AREA)
# Convert color image to grayscale and threshold it
gray_mask = cv2.cvtColor(face_mask_small, cv2.COLOR_BGR2GRAY)
ret, mask = cv2.threshold(gray_mask, 180, 255, cv2.THRESH_BINARY_INV)
# Create an inverse mask
mask_inv = cv2.bitwise_not(mask)
# Use the mask to extract the face mask region of interest
masked_face = cv2.bitwise_and(face_mask_small, face_mask_small, mask=mask)
# Use the inverse mask to get the remaining part of the image
masked_frame = cv2.bitwise_and(frame_roi, frame_roi, mask=mask_inv)
# add the two images to get the final output
frame[y:y+h, x:x+w] = cv2.add(masked_face, masked_frame)
cv2.imshow('Face Detector', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
内部机制
就像以前一样,我们首先加载面部级联分类器 xml 文件。面部检测步骤按常规进行。我们启动无限循环并持续检测每一帧中的面部。一旦我们知道面部在哪里,我们需要稍微修改坐标以确保面具合适地贴合。这个操作过程是主观的,取决于所讨论的面具。不同的面具需要不同级别的调整以使其看起来更自然。我们在以下行中从输入帧中提取感兴趣区域:
frame_roi = frame[y:y+h, x:x+w]
现在我们有了所需的感兴趣区域,我们需要在这个区域上方叠加掩码。所以我们将输入掩码调整大小,确保它适合这个感兴趣区域。输入掩码有一个白色背景。所以如果我们直接将其叠加在感兴趣区域上,由于白色背景,它看起来会不自然。我们需要叠加的只有颅骨掩码像素,其余区域应该是透明的。
所以在下一步,我们通过阈值化颅骨图像来创建一个掩码。由于背景是白色的,我们将图像阈值化,使得任何强度值大于 180 的像素变为 0,其余所有像素变为 255。至于感兴趣区域,我们需要在这个掩码区域内将所有内容变黑。我们可以通过简单地使用我们刚刚创建的掩码的逆来做到这一点。一旦我们有了颅骨图像的掩码版本和输入感兴趣区域,我们只需将它们相加即可得到最终图像。
检测眼睛
现在我们已经了解了如何检测人脸,我们可以将这个概念推广到检测其他身体部位。重要的是要理解 Viola-Jones 框架可以应用于任何对象。准确性和鲁棒性将取决于对象的独特性。例如,人脸具有非常独特的特征,因此训练我们的系统变得鲁棒是很容易的。另一方面,像毛巾这样的物体太通用了,没有这样的区分特征;因此,构建一个鲁棒的毛巾检测器会更困难。
让我们看看如何构建一个眼睛检测器:
import cv2
import numpy as np
face_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_frontalface_alt.xml')
eye_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_eye.xml')
if face_cascade.empty():
raise IOError('Unable to load the face cascade classifier xml file')
if eye_cascade.empty():
raise IOError('Unable to load the eye cascade classifier xml file')
cap = cv2.VideoCapture(0)
ds_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=ds_factor, fy=ds_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in faces:
roi_gray = gray[y:y+h, x:x+w]
roi_color = frame[y:y+h, x:x+w]
eyes = eye_cascade.detectMultiScale(roi_gray)
for (x_eye,y_eye,w_eye,h_eye) in eyes:
center = (int(x_eye + 0.5*w_eye), int(y_eye + 0.5*h_eye))
radius = int(0.3 * (w_eye + h_eye))
color = (0, 255, 0)
thickness = 3
cv2.circle(roi_color, center, radius, color, thickness)
cv2.imshow('Eye Detector', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
如果你运行这个程序,输出将类似于以下图像:

反思
如果你注意的话,程序看起来非常类似于人脸检测程序。除了加载人脸检测级联分类器外,我们还加载了眼睛检测级联分类器。技术上,我们不需要使用人脸检测器。但我们知道眼睛总是在某人的脸上。我们使用这个信息,只在相关感兴趣区域内搜索眼睛,即脸部。我们首先检测脸部,然后在子图像上运行眼睛检测器。这样,它更快更高效。
眼睛的乐趣
现在我们知道了如何在图像中检测眼睛,让我们看看我们能否用它来做些有趣的事情。我们可以做类似以下截图所示的事情:

让我们看看代码,看看如何做到这一点:
import cv2
import numpy as np
face_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_frontalface_alt.xml')
eye_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_eye.xml')
if face_cascade.empty():
raise IOError('Unable to load the face cascade classifier xml file')
if eye_cascade.empty():
raise IOError('Unable to load the eye cascade classifier xml file')
img = cv2.imread('input.jpg')
sunglasses_img = cv2.imread('sunglasses.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
centers = []
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in faces:
roi_gray = gray[y:y+h, x:x+w]
roi_color = img[y:y+h, x:x+w]
eyes = eye_cascade.detectMultiScale(roi_gray)
for (x_eye,y_eye,w_eye,h_eye) in eyes:
centers.append((x + int(x_eye + 0.5*w_eye), y + int(y_eye + 0.5*h_eye)))
if len(centers) > 0:
# Overlay sunglasses; the factor 2.12 is customizable depending on the size of the face
sunglasses_width = 2.12 * abs(centers[1][0] - centers[0][0])
overlay_img = np.ones(img.shape, np.uint8) * 255
h, w = sunglasses_img.shape[:2]
scaling_factor = sunglasses_width / w
overlay_sunglasses = cv2.resize(sunglasses_img, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
x = centers[0][0] if centers[0][0] < centers[1][0] else centers[1][0]
# customizable X and Y locations; depends on the size of the face
x -= 0.26*overlay_sunglasses.shape[1]
y += 0.85*overlay_sunglasses.shape[0]
h, w = overlay_sunglasses.shape[:2]
overlay_img[y:y+h, x:x+w] = overlay_sunglasses
# Create mask
gray_sunglasses = cv2.cvtColor(overlay_img, cv2.COLOR_BGR2GRAY)
ret, mask = cv2.threshold(gray_sunglasses, 110, 255, cv2.THRESH_BINARY)
mask_inv = cv2.bitwise_not(mask)
temp = cv2.bitwise_and(img, img, mask=mask)
temp2 = cv2.bitwise_and(overlay_img, overlay_img, mask=mask_inv)
final_img = cv2.add(temp, temp2)
cv2.imshow('Eye Detector', img)
cv2.imshow('Sunglasses', final_img)
cv2.waitKey()
cv2.destroyAllWindows()
放置太阳镜
就像我们之前做的那样,我们加载图像并检测眼睛。一旦我们检测到眼睛,我们就调整太阳镜图像的大小以适应当前感兴趣的区域。为了创建感兴趣的区域,我们考虑眼睛之间的距离。我们相应地调整图像大小,然后继续创建一个掩模。这与我们之前创建头骨掩模的方法类似。太阳镜在脸上的位置是主观的。所以如果你想使用不同的太阳镜,你可能需要调整权重。
检测耳朵
由于我们知道了工作流程的工作原理,让我们直接进入代码:
import cv2
import numpy as np
left_ear_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_mcs_leftear.xml')
right_ear_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_mcs_rightear.xml')
if left_ear_cascade.empty():
raise IOError('Unable to load the left ear cascade classifier xml file')
if right_ear_cascade.empty():
raise IOError('Unable to load the right ear cascade classifier xml file')
img = cv2.imread('input.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
left_ear = left_ear_cascade.detectMultiScale(gray, 1.3, 5)
right_ear = right_ear_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in left_ear:
cv2.rectangle(img, (x,y), (x+w,y+h), (0,255,0), 3)
for (x,y,w,h) in right_ear:
cv2.rectangle(img, (x,y), (x+w,y+h), (255,0,0), 3)
cv2.imshow('Ear Detector', img)
cv2.waitKey()
cv2.destroyAllWindows()
如果你在这个图像上运行上面的代码,你应该会看到如下所示的截图:

检测嘴巴
以下是需要用到的代码:
import cv2
import numpy as np
mouth_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_mcs_mouth.xml')
if mouth_cascade.empty():
raise IOError('Unable to load the mouth cascade classifier xml file')
cap = cv2.VideoCapture(0)
ds_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=ds_factor, fy=ds_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
mouth_rects = mouth_cascade.detectMultiScale(gray, 1.7, 11)
for (x,y,w,h) in mouth_rects:
y = int(y - 0.15*h)
cv2.rectangle(frame, (x,y), (x+w,y+h), (0,255,0), 3)
break
cv2.imshow('Mouth Detector', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
以下是将呈现的输出:

是时候画胡子了
让我们在上面叠加一个胡子:
import cv2
import numpy as np
mouth_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_mcs_mouth.xml')
moustache_mask = cv2.imread('../images/moustache.png')
h_mask, w_mask = moustache_mask.shape[:2]
if mouth_cascade.empty():
raise IOError('Unable to load the mouth cascade classifier xml file')
cap = cv2.VideoCapture(0)
scaling_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
mouth_rects = mouth_cascade.detectMultiScale(gray, 1.3, 5)
if len(mouth_rects) > 0:
(x,y,w,h) = mouth_rects[0]
h, w = int(0.6*h), int(1.2*w)
x -= 0.05*w
y -= 0.55*h
frame_roi = frame[y:y+h, x:x+w]
moustache_mask_small = cv2.resize(moustache_mask, (w, h), interpolation=cv2.INTER_AREA)
gray_mask = cv2.cvtColor(moustache_mask_small, cv2.COLOR_BGR2GRAY)
ret, mask = cv2.threshold(gray_mask, 50, 255, cv2.THRESH_BINARY_INV)
mask_inv = cv2.bitwise_not(mask)
masked_mouth = cv2.bitwise_and(moustache_mask_small, moustache_mask_small, mask=mask)
masked_frame = cv2.bitwise_and(frame_roi, frame_roi, mask=mask_inv)
frame[y:y+h, x:x+w] = cv2.add(masked_mouth, masked_frame)
cv2.imshow('Moustache', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
看起来是这样的:

检测鼻子
以下程序展示了如何检测鼻子:
import cv2
import numpy as np
nose_cascade = cv2.CascadeClassifier('./cascade_files/haarcascade_mcs_nose.xml')
if nose_cascade.empty():
raise IOError('Unable to load the nose cascade classifier xml file')
cap = cv2.VideoCapture(0)
ds_factor = 0.5
while True:
ret, frame = cap.read()
frame = cv2.resize(frame, None, fx=ds_factor, fy=ds_factor, interpolation=cv2.INTER_AREA)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
nose_rects = nose_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in nose_rects:
cv2.rectangle(frame, (x,y), (x+w,y+h), (0,255,0), 3)
break
cv2.imshow('Nose Detector', frame)
c = cv2.waitKey(1)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
输出看起来像以下图像:

检测瞳孔
我们将采取不同的方法。瞳孔太通用,不适合使用 Haar 级联方法。我们还将了解如何根据形状检测事物。以下是将呈现的输出:

让我们看看如何构建瞳孔检测器:
import math
import cv2
import numpy as np
img = cv2.imread('input.jpg')
scaling_factor = 0.7
img = cv2.resize(img, None, fx=scaling_factor, fy=scaling_factor, interpolation=cv2.INTER_AREA)
cv2.imshow('Input', img)
gray = cv2.cvtColor(~img, cv2.COLOR_BGR2GRAY)
ret, thresh_gray = cv2.threshold(gray, 220, 255, cv2.THRESH_BINARY)
contours, hierarchy = cv2.findContours(thresh_gray, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
for contour in contours:
area = cv2.contourArea(contour)
rect = cv2.boundingRect(contour)
x, y, width, height = rect
radius = 0.25 * (width + height)
area_condition = (100 <= area <= 200)
symmetry_condition = (abs(1 - float(width)/float(height)) <= 0.2)
fill_condition = (abs(1 - (area / (math.pi * math.pow(radius, 2.0)))) <= 0.3)
if area_condition and symmetry_condition and fill_condition:
cv2.circle(img, (int(x + radius), int(y + radius)), int(1.3*radius), (0,180,0), -1)
cv2.imshow('Pupil Detector', img)
c = cv2.waitKey()
cv2.destroyAllWindows()
如果你运行这个程序,你将看到之前展示的输出。
代码分解
如我们之前讨论的,我们不会使用 Haar 级联来检测瞳孔。如果我们不能使用预训练的分类器,那么我们该如何检测瞳孔呢?嗯,我们可以使用形状分析来检测瞳孔。我们知道瞳孔是圆形的,因此我们可以利用这一信息在图像中检测它们。我们反转输入图像,然后将其转换为灰度图像,如下所示:
gray = cv2.cvtColor(~img, cv2.COLOR_BGR2GRAY)
如我们所见,我们可以使用波浪号运算符来反转图像。在我们的情况下,反转图像是有帮助的,因为瞳孔是黑色的,而黑色对应于低像素值。然后我们对图像进行阈值处理,以确保只有黑白像素。现在,我们必须找出所有形状的边界。OpenCV 提供了一个很好的函数来实现这一点,那就是findContours。我们将在接下来的章节中对此进行更多讨论。但就目前而言,我们只需要知道这个函数返回图像中找到的所有形状的边界集合。
下一步是识别瞳孔的形状并丢弃其余部分。我们将利用圆的一些特性来精确地定位这个形状。让我们考虑边界矩形的宽高比。如果形状是圆形,这个比例将是 1。我们可以使用 boundingRect 函数来获取边界矩形的坐标。让我们考虑这个形状的面积。如果我们大致计算这个形状的半径并使用圆面积公式,那么它应该接近这个轮廓的面积。我们可以使用 contourArea 函数来计算图像中任何轮廓的面积。因此,我们可以使用这些条件来过滤形状。在这样做之后,图像中剩下两个瞳孔。我们可以通过限制搜索区域为面部或眼睛来进一步细化。由于你知道如何检测面部和眼睛,你可以尝试一下,看看你是否能使其在实时视频流中工作。
摘要
在本章中,我们讨论了 Haar 级联和积分图像。我们了解了面部检测流程是如何构建的。我们学习了如何在实时视频流中检测和跟踪面部。我们讨论了如何使用面部检测流程来检测各种身体部位,如眼睛、耳朵、鼻子和嘴巴。我们学习了如何使用身体部位检测的结果在输入图像上叠加掩码。我们使用了形状分析原理来检测瞳孔。
在下一章中,我们将讨论特征检测及其如何被用来理解图像内容。
第五章:从图像中提取特征
在本章中,我们将学习如何在图像中检测显著点,也称为关键点。我们将讨论这些关键点为什么很重要,以及我们如何利用它们来理解图像内容。我们将讨论可以用来检测这些关键点的不同技术,以及我们如何从给定的图像中提取特征。
到本章结束时,你将知道:
-
什么是关键点,为什么我们关心它们
-
如何检测关键点
-
如何使用关键点进行图像内容分析
-
检测关键点的不同技术
-
如何构建特征提取器
为什么我们关心关键点?
图像内容分析是指理解图像内容的过程,以便我们可以根据该内容采取某些行动。让我们回顾一下人类是如何做到这一点的。我们的大脑是一个极其强大的机器,可以非常快速地完成复杂的事情。当我们看某样东西时,大脑会自动根据该图像的“有趣”方面创建一个足迹。随着本章的进行,我们将讨论“有趣”的含义。
目前,一个有趣的特点是那个区域中独特的东西。如果我们称一个点是有趣的,那么在其邻域内不应该有另一个满足约束条件的点。让我们考虑以下图像:

现在闭上眼睛,尝试想象这幅图像。你看到了什么具体的东西吗?你能回忆起图像的左半部分吗?实际上不行!这是因为图像没有任何有趣的信息。当我们的大脑看到这样的东西时,没有什么值得注意的。所以它往往会四处游荡!让我们看看以下图像:

现在闭上眼睛,尝试想象这幅图像。你会发现回忆是生动的,你记得关于这幅图像的很多细节。这是因为图像中有许多有趣区域。与低频内容相比,人眼对高频内容更为敏感。这就是我们倾向于比第一幅图像更好地回忆第二幅图像的原因。为了进一步演示这一点,让我们看看以下图像:

如果你注意到,你的眼睛立即就转向了电视遥控器,即使它不在图像的中心。我们自动倾向于趋向图像中的有趣区域,因为那里有所有信息。这就是我们的大脑需要存储以便以后回忆起来的内容。
当我们构建物体识别系统时,我们需要检测这些“有趣”的区域来为图像创建一个签名。这些有趣区域的特点是关键点。这就是为什么关键点检测在许多现代计算机视觉系统中至关重要。
什么是关键点?
既然我们已经知道关键点指的是图像中的有趣区域,那么让我们深入探讨一下。关键点由什么组成?这些点在哪里?当我们说“有趣”时,意味着该区域正在发生某些事情。如果该区域只是均匀的,那么它并不很有趣。例如,角落是有趣的,因为两个不同方向上的强度发生了急剧变化。每个角落都是两条边相交的独特点。如果你看前面的图像,你会看到有趣区域并不完全由“有趣”的内容组成。如果你仔细观察,我们仍然可以在繁忙的区域中看到普通区域。例如,考虑以下图像:

如果你看看前面的物体,有趣区域的内部部分是“无趣”的。

因此,如果我们想要描述这个物体,我们需要确保我们选择了有趣点。现在,我们如何定义“有趣点”?我们能否说任何不是无趣的东西都可以是有趣点?让我们考虑以下例子:

现在,我们可以看到在这张图像的边缘有很多高频内容。但我们不能把整个边缘称为“有趣”。重要的是要理解,“有趣”不一定是指颜色或强度值。它可以是指任何东西,只要它是独特的。我们需要隔离其邻域中独特的点。沿着边缘的点相对于其邻居来说并不独特。所以,现在我们知道我们要找什么了,我们如何选择一个有趣点?
那么桌角的角落呢?那很吸引人,对吧?它相对于其邻居是独特的,并且在其附近我们没有类似的东西。现在这个点可以被选为我们中的一个关键点。我们选取这些关键点来描述特定的图像。
当我们进行图像分析时,在推导出任何东西之前,我们需要将其转换为数值形式。这些关键点使用数值形式和这些关键点的组合来创建图像签名。我们希望这个图像签名以最佳方式代表给定的图像。
检测角落
由于我们知道角点是“有趣的”,让我们看看我们如何检测它们。在计算机视觉中,有一个流行的角点检测技术叫做Harris 角点检测器。我们基本上基于灰度图像的偏导数构建一个 2x2 矩阵,然后分析特征值。这实际上是对实际算法的过度简化,但它涵盖了要点。所以,如果你想了解背后的数学细节,你可以查阅 Harris 和 Stephens 在www.bmva.org/bmvc/1988/avc-88-023.pdf上发表的原始论文。一个角点是两个特征值都应有较大值的点。
让我们考虑以下图像:

如果你在这个图像上运行 Harris 角点检测器,你会看到如下情况:

如你所见,所有的黑色点都对应于图像中的角点。如果你注意到,盒底部的角点没有被检测到。原因是这些角点不够尖锐。你可以在角点检测器中调整阈值来识别这些角点。执行此操作的代码如下:
import cv2
import numpy as np
img = cv2.imread('box.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
gray = np.float32(gray)
dst = cv2.cornerHarris(gray, 4,5, 0.04) # to detect only sharp corners
#dst = cv2.cornerHarris(gray, 14, 5, 0.04) # to detect soft corners
# Result is dilated for marking the corners
dst = cv2.dilate(dst,None)
# Threshold for an optimal value, it may vary depending on the image.
img[dst > 0.01*dst.max()] = [0,0,0]
cv2.imshow('Harris Corners',img)
cv2.waitKey()
好的特征追踪
Harris 角点检测器在许多情况下表现良好,但它遗漏了一些东西。在 Harris 和 Stephens 的原始论文发表后的六年左右,Shi-Tomasi 提出了一种更好的角点检测器。你可以在www.ai.mit.edu/courses/6.891/handouts/shi94good.pdf上阅读原始论文。他们使用不同的评分函数来提高整体质量。使用这种方法,我们可以在给定的图像中找到“N”个最强的角点。当我们不想使用图像中的每一个角点来提取信息时,这非常有用。
如果你将 Shi-Tomasi 角点检测器应用于前面显示的图像,你会看到如下情况:

以下代码:
import cv2
import numpy as np
img = cv2.imread('box.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
corners = cv2.goodFeaturesToTrack(gray, 7, 0.05, 25)
corners = np.float32(corners)
for item in corners:
x, y = item[0]
cv2.circle(img, (x,y), 5, 255, -1)
cv2.imshow("Top 'k' features", img)
cv2.waitKey()
尺度不变特征变换(SIFT)
尽管角点特征是“有趣的”,但它们不足以表征真正有趣的部分。当我们谈论图像内容分析时,我们希望图像签名对诸如尺度、旋转、光照等因素保持不变。人类在这些方面非常擅长。即使我给你看一个颠倒且昏暗的苹果图像,你仍然能认出它。如果我给你看这个图像的放大版本,你仍然能认出它。我们希望我们的图像识别系统能够做到同样的事情。
让我们考虑角点特征。如果你放大一个图像,一个角点可能就不再是角点了,如下所示。

在第二种情况下,检测器将不会检测到这个角落。由于它在原始图像中被检测到,因此第二个图像将不会与第一个图像匹配。这基本上是同一张图像,但基于角落特征的算法将完全错过它。这意味着角落检测器并不完全具有尺度不变性。这就是为什么我们需要一个更好的方法来表征图像。
SIFT 是计算机视觉中最受欢迎的算法之一。您可以在www.cs.ubc.ca/~lowe/papers/ijcv04.pdf阅读 David Lowe 的原始论文。我们可以使用这个算法来提取关键点和构建相应的特征描述符。网上有大量的良好文档,因此我们将简要讨论。为了识别一个潜在的关键点,SIFT 通过下采样图像并计算高斯差分来构建一个金字塔。这意味着我们在金字塔的每个级别上运行高斯滤波器,并取差分来构建金字塔的连续级别。为了确定当前点是否为关键点,它不仅查看邻居,还查看金字塔相邻级别中相同位置的像素。如果是最大值,则当前点被选中作为关键点。这确保了我们的关键点具有尺度不变性。
现在我们知道了它是如何实现尺度不变性的,让我们看看它是如何实现旋转不变性的。一旦我们确定了关键点,每个关键点都会被分配一个方向。我们取每个关键点周围的邻域,并计算梯度幅度和方向。这给我们一个关于该关键点方向的感觉。如果我们有这些信息,我们甚至可以在旋转的情况下将这个关键点与另一张图像中的相同点匹配。由于我们知道方向,我们将在比较之前对这些关键点进行归一化。
一旦我们有了所有这些信息,我们如何量化它?我们需要将其转换为一系列数字,以便我们可以对其进行某种匹配。为了实现这一点,我们只需取每个关键点周围的 16x16 邻域,并将其分成 16 个 4x4 大小的块。对于每个块,我们使用 8 个桶计算方向直方图。因此,我们与每个块相关联的向量长度为 8,这意味着邻域由一个大小为 128(8x16)的向量表示。这是最终将使用的关键点描述符。如果我们从一个图像中提取N个关键点,那么我们将有N个长度为 128 的描述符。这个N个描述符的数组表征了给定的图像。
考虑以下图像:

如果您使用 SIFT 提取关键点位置,您将看到如下所示的内容,其中圆圈的大小表示关键点的强度,圆圈内的线条表示方向:

在我们查看代码之前,重要的是要知道 SIFT 是受专利保护的,并且它不能免费用于商业用途。以下是如何实现的代码:
import cv2
import numpy as np
input_image = cv2.imread('input.jpg')
gray_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2GRAY)
sift = cv2.SIFT()
keypoints = sift.detect(gray_image, None)
input_image = cv2.drawKeypoints(input_image, keypoints, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow('SIFT features', input_image)
cv2.waitKey()
我们也可以计算描述符。OpenCV 让我们可以单独计算,或者我们可以通过使用以下方法将检测和计算部分合并到同一步骤中:
keypoints, descriptors = sift.detectAndCompute(gray_image, None)
加速鲁棒特征(SURF)
尽管 SIFT 很好用且很有用,但它计算量很大。这意味着它很慢,如果我们使用 SIFT 来实现实时系统,我们将遇到困难。我们需要一个既快又具有 SIFT 所有优点的系统。如果你还记得,SIFT 使用高斯差分来构建金字塔,这个过程很慢。因此,为了克服这一点,SURF 使用简单的盒式滤波器来近似高斯。好事是这很容易计算,而且速度相当快。关于 SURF,网上有很多文档,可以在opencv-python-tutroals.readthedocs.org/en/latest/py_tutorials/py_feature2d/py_surf_intro/py_surf_intro.html?highlight=surf找到。因此,你可以阅读它,看看他们是如何构建描述符的。你可以参考原始论文www.vision.ee.ethz.ch/~surf/eccv06.pdf。重要的是要知道 SURF 也是受专利保护的,并且它不能免费用于商业用途。
如果你运行 SURF 关键点检测器在之前的图像上,你会看到如下所示的一个:

下面是代码:
import cv2
import numpy as np
img = cv2.imread('input.jpg')
gray= cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
surf = cv2.SURF()
# This threshold controls the number of keypoints
surf.hessianThreshold = 15000
kp, des = surf.detectAndCompute(gray, None)
img = cv2.drawKeypoints(img, kp, None, (0,255,0), 4)
cv2.imshow('SURF features', img)
cv2.waitKey()
加速分割测试(FAST)特征
尽管 SURF 比 SIFT 快,但它对于实时系统来说还不够快,尤其是在资源受限的情况下。当你在一个移动设备上构建实时应用程序时,你不会有使用 SURF 进行实时计算的自由。我们需要的是真正快且计算成本低的系统。因此,Rosten 和 Drummond 提出了 FAST。正如其名所示,它真的很快!
他们没有通过所有昂贵的计算,而是提出了一种高速测试方法,以快速确定当前点是否是一个潜在的关键点。我们需要注意的是,FAST 仅用于关键点检测。一旦检测到关键点,我们需要使用 SIFT 或 SURF 来计算描述符。考虑以下图像:

如果我们在该图像上运行 FAST 关键点检测器,你会看到如下内容:

如果我们清理并抑制不重要的关键点,它看起来会是这样:

下面是这个的代码:
import cv2
import numpy as np
gray_image = cv2.imread('input.jpg', 0)
fast = cv2.FastFeatureDetector()
# Detect keypoints
keypoints = fast.detect(gray_image, None)
print "Number of keypoints with non max suppression:", len(keypoints)
# Draw keypoints on top of the input image
img_keypoints_with_nonmax = cv2.drawKeypoints(gray_image, keypoints, color=(0,255,0))
cv2.imshow('FAST keypoints - with non max suppression', img_keypoints_with_nonmax)
# Disable nonmaxSuppression
fast.setBool('nonmaxSuppression', False)
# Detect keypoints again
keypoints = fast.detect(gray_image, None)
print "Total Keypoints without nonmaxSuppression:", len(keypoints)
# Draw keypoints on top of the input image
img_keypoints_without_nonmax = cv2.drawKeypoints(gray_image, keypoints, color=(0,255,0))
cv2.imshow('FAST keypoints - without non max suppression', img_keypoints_without_nonmax)
cv2.waitKey()
二进制鲁棒独立基本特征(BRIEF)
尽管我们有 FAST 来快速检测关键点,但我们仍然需要使用 SIFT 或 SURF 来计算描述符。我们需要一种快速计算描述符的方法。这就是 BRIEF 发挥作用的地方。BRIEF 是一种提取特征描述符的方法。它本身不能检测关键点,因此我们需要与关键点检测器一起使用它。BRIEF 的好处是它紧凑且快速。
考虑以下图像:

BRIEF 算法接收输入的关键点列表并输出一个更新后的列表。因此,如果你在这个图像上运行 BRIEF 算法,你会看到类似以下的内容:

下面是代码:
import cv2
import numpy as np
gray_image = cv2.imread('input.jpg', 0)
# Initiate FAST detector
fast = cv2.FastFeatureDetector()
# Initiate BRIEF extractor
brief = cv2.DescriptorExtractor_create("BRIEF")
# find the keypoints with STAR
keypoints = fast.detect(gray_image, None)
# compute the descriptors with BRIEF
keypoints, descriptors = brief.compute(gray_image, keypoints)
gray_keypoints = cv2.drawKeypoints(gray_image, keypoints, color=(0,255,0))
cv2.imshow('BRIEF keypoints', gray_keypoints)
cv2.waitKey()
定向快速旋转 BRIEF (ORB)
因此,现在我们已经到达了迄今为止讨论的所有组合中最好的组合。这个算法来自 OpenCV 实验室。它快速、鲁棒且开源!SIFT 和 SURF 算法都是受专利保护的,你不能用于商业目的。这就是为什么 ORB 在很多方面都是好的。
如果你运行前面显示的图像之一的 ORB 关键点提取器,你会看到类似以下的内容:

下面是代码:
import cv2
import numpy as np
input_image = cv2.imread('input.jpg')
gray_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2GRAY)
# Initiate ORB object
orb = cv2.ORB()
# find the keypoints with ORB
keypoints = orb.detect(gray_image, None)
# compute the descriptors with ORB
keypoints, descriptors = orb.compute(gray_image, keypoints)
# draw only the location of the keypoints without size or orientation
final_keypoints = cv2.drawKeypoints(input_image, keypoints, color=(0,255,0), flags=0)
cv2.imshow('ORB keypoints', final_keypoints)
cv2.waitKey()
摘要
在本章中,我们学习了关键点的重要性以及为什么我们需要它们。我们讨论了各种检测关键点和计算特征描述符的算法。我们将在后续的所有章节中,以不同的背景使用这些算法。关键点的概念在计算机视觉中处于核心地位,并在许多现代系统中发挥着重要作用。
在下一章中,我们将讨论如何将同一场景的多个图像拼接在一起以创建全景图像。
第六章:创建全景图像
在本章中,我们将学习如何将同一场景的多个图像拼接在一起以创建全景图像。
到本章结束时,你将知道:
-
如何在多张图像之间匹配关键点描述符
-
如何在图像之间找到重叠区域
-
如何基于匹配的关键点进行图像变换
-
如何将多个图像拼接在一起创建全景图像
匹配关键点描述符
在上一章中,我们学习了如何使用各种方法提取关键点。我们提取关键点的原因在于我们可以使用它们进行图像匹配。让我们考虑以下图像:

如你所见,这是校车的图片。现在,让我们看一下以下图像:

上一张图像是校车图像的一部分,并且已经逆时针旋转了 90 度。我们很容易就能识别出来,因为我们的大脑对缩放和旋转是不变的。我们的目标是找到这两张图像之间的匹配点。如果你这样做,它看起来可能就像这样:

以下代码用于执行此操作:
import sys
import cv2
import numpy as np
def draw_matches(img1, keypoints1, img2, keypoints2, matches):
rows1, cols1 = img1.shape[:2]
rows2, cols2 = img2.shape[:2]
# Create a new output image that concatenates the two images together
output_img = np.zeros((max([rows1,rows2]), cols1+cols2, 3), dtype='uint8')
output_img[:rows1, :cols1, :] = np.dstack([img1, img1, img1])
output_img[:rows2, cols1:cols1+cols2, :] = np.dstack([img2, img2, img2])
# Draw connecting lines between matching keypoints
for match in matches:
# Get the matching keypoints for each of the images
img1_idx = match.queryIdx
img2_idx = match.trainIdx
(x1, y1) = keypoints1[img1_idx].pt
(x2, y2) = keypoints2[img2_idx].pt
# Draw a small circle at both co-ordinates and then draw a line
radius = 4
colour = (0,255,0) # green
thickness = 1
cv2.circle(output_img, (int(x1),int(y1)), radius, colour, thickness)
cv2.circle(output_img, (int(x2)+cols1,int(y2)), radius, colour, thickness)
cv2.line(output_img, (int(x1),int(y1)), (int(x2)+cols1,int(y2)), colour, thickness)
return output_img
if __name__=='__main__':
img1 = cv2.imread(sys.argv[1], 0) # query image (rotated subregion)
img2 = cv2.imread(sys.argv[2], 0) # train image (full image)
# Initialize ORB detector
orb = cv2.ORB()
# Extract keypoints and descriptors
keypoints1, descriptors1 = orb.detectAndCompute(img1, None)
keypoints2, descriptors2 = orb.detectAndCompute(img2, None)
# Create Brute Force matcher object
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
# Match descriptors
matches = bf.match(descriptors1, descriptors2)
# Sort them in the order of their distance
matches = sorted(matches, key = lambda x:x.distance)
# Draw first 'n' matches
img3 = draw_matches(img1, keypoints1, img2, keypoints2, matches[:30])
cv2.imshow('Matched keypoints', img3)
cv2.waitKey()
我们是如何匹配关键点的?
在前面的代码中,我们使用了 ORB 检测器来提取关键点。一旦提取了关键点,我们就使用暴力匹配器来匹配描述符。暴力匹配非常直接!对于第一张图像中的每个描述符,我们将其与第二张图像中的每个描述符进行匹配,并取最近的那个。为了计算最近的描述符,我们使用汉明距离作为度量标准,如下所示:
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
你可以在en.wikipedia.org/wiki/Hamming_distance上了解更多关于汉明距离的信息。上一行中的第二个参数是一个布尔变量。如果这是真的,那么匹配器将只返回在两个方向上彼此最接近的关键点。这意味着如果我们得到(i, j)作为匹配,那么我们可以确信第一张图像中的第 i 个描述符是第二张图像中第 j 个描述符的最近匹配,反之亦然。这增加了描述符匹配的一致性和鲁棒性。
理解匹配器对象
让我们再次考虑以下行:
matches = bf.match(descriptors1, descriptors2)
这里,变量 matches 是一个 DMatch 对象的列表。你可以在 OpenCV 文档中了解更多关于它的信息。我们只需要快速理解它的含义,因为它将在接下来的章节中变得越来越重要。如果我们正在遍历这个 DMatch 对象的列表,那么每个项目将具有以下属性:
-
item.distance:此属性给出了描述符之间的距离。距离越低,匹配越好。
-
item.trainIdx:这个属性给出了训练描述符列表中的索引(在我们的情况下,它是完整图像中的描述符列表)。
-
item.queryIdx:这个属性给出了查询描述符列表中的索引(在我们的情况下,它是旋转子图像中的描述符列表)。
-
item.imgIdx:这个属性给出了训练图像的索引。
绘制匹配的关键点
现在我们已经知道了如何访问匹配器对象的不同属性,让我们看看如何使用它们来绘制匹配的关键点。OpenCV 3.0 提供了一个直接绘制匹配关键点的函数,但我们将不会使用它。最好看看内部发生了什么。
我们需要创建一个大的输出图像,可以容纳两张并排的图像。因此,我们在以下行中这样做:
output_img = np.zeros((max([rows1,rows2]), cols1+cols2, 3), dtype='uint8')
如我们所见,行数设置为两个值中较大的一个,列数是这两个值的总和。对于匹配列表中的每个项目,我们提取匹配关键点的位置,如下所示:
(x1, y1) = keypoints1[img1_idx].pt
(x2, y2) = keypoints2[img2_idx].pt
一旦我们这样做,我们只需在这些点上画圆圈来指示它们的位置,然后画一条连接两个点的线。
创建全景图像
现在我们已经知道了如何匹配关键点,让我们继续看看如何将多张图像拼接在一起。考虑以下图像:

假设我们想要将以下图像与前面的图像拼接起来:

如果我们将这些图像拼接起来,它看起来会像以下这样:

现在假设我们捕捉到了这个房子的另一部分,如下面的图像所示:

如果我们将前面的图像与之前看到的拼接图像拼接起来,它看起来会像这样:

我们可以将图像拼接在一起以创建一个漂亮的全景图像。让我们看看代码:
import sys
import argparse
import cv2
import numpy as np
def argument_parser():
parser = argparse.ArgumentParser(description='Stitch two images together')
parser.add_argument("--query-image", dest="query_image", required=True,
help="First image that needs to be stitched")
parser.add_argument("--train-image", dest="train_image", required=True,
help="Second image that needs to be stitched")
parser.add_argument("--min-match-count", dest="min_match_count", type=int,
required=False, default=10, help="Minimum number of matches required")
return parser
# Warp img2 to img1 using the homography matrix H
def warpImages(img1, img2, H):
rows1, cols1 = img1.shape[:2]
rows2, cols2 = img2.shape[:2]
list_of_points_1 = np.float32([[0,0], [0,rows1], [cols1,rows1], [cols1,0]]).reshape(-1,1,2)
temp_points = np.float32([[0,0], [0,rows2], [cols2,rows2], [cols2,0]]).reshape(-1,1,2)
list_of_points_2 = cv2.perspectiveTransform(temp_points, H)
list_of_points = np.concatenate((list_of_points_1, list_of_points_2), axis=0)
[x_min, y_min] = np.int32(list_of_points.min(axis=0).ravel() - 0.5)
[x_max, y_max] = np.int32(list_of_points.max(axis=0).ravel() + 0.5)
translation_dist = [-x_min,-y_min]
H_translation = np.array([[1, 0, translation_dist[0]], [0, 1, translation_dist[1]], [0,0,1]])
output_img = cv2.warpPerspective(img2, H_translation.dot(H), (x_max-x_min, y_max-y_min))
output_img[translation_dist[1]:rows1+translation_dist[1], translation_dist[0]:cols1+translation_dist[0]] = img1
return output_img
if __name__=='__main__':
args = argument_parser().parse_args()
img1 = cv2.imread(args.query_image, 0)
img2 = cv2.imread(args.train_image, 0)
min_match_count = args.min_match_count
cv2.imshow('Query image', img1)
cv2.imshow('Train image', img2)
# Initialize the SIFT detector
sift = cv2.SIFT()
# Extract the keypoints and descriptors
keypoints1, descriptors1 = sift.detectAndCompute(img1, None)
keypoints2, descriptors2 = sift.detectAndCompute(img2, None)
# Initialize parameters for Flann based matcher
FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
search_params = dict(checks = 50)
# Initialize the Flann based matcher object
flann = cv2.FlannBasedMatcher(index_params, search_params)
# Compute the matches
matches = flann.knnMatch(descriptors1, descriptors2, k=2)
# Store all the good matches as per Lowe's ratio test
good_matches = []
for m1,m2 in matches:
if m1.distance < 0.7*m2.distance:
good_matches.append(m1)
if len(good_matches) > min_match_count:
src_pts = np.float32([ keypoints1[good_match.queryIdx].pt for good_match in good_matches ]).reshape(-1,1,2)
dst_pts = np.float32([ keypoints2[good_match.trainIdx].pt for good_match in good_matches ]).reshape(-1,1,2)
M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
result = warpImages(img2, img1, M)
cv2.imshow('Stitched output', result)
cv2.waitKey()
else:
print "We don't have enough number of matches between the two images."
print "Found only %d matches. We need at least %d matches." % (len(good_matches), min_match_count)
寻找重叠区域
这里的目标是找到匹配的关键点,以便我们可以将图像拼接在一起。因此,第一步是获取这些匹配的关键点。如前所述,我们使用关键点检测器提取关键点,然后使用基于 Flann 的匹配器匹配关键点。
注意
你可以在citeseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.192.5378&rep=rep1&type=pdf了解更多关于 Flann 的信息。
基于 Flann 的匹配器比 Brute Force 匹配更快,因为它不需要将每个点与其他列表上的每个点进行比较。它只考虑当前点的邻域来获取匹配的关键点,从而使其更高效。
一旦我们得到了匹配关键点的列表,我们使用 Lowe 的比率测试来只保留强匹配。David Lowe 提出了这个比率测试,目的是为了提高 SIFT 的鲁棒性。
注意
你可以在www.cs.ubc.ca/~lowe/papers/ijcv04.pdf了解更多相关信息。
基本上,当我们匹配关键点时,我们会拒绝那些到最近邻和第二近邻的距离比大于某个特定阈值的匹配。这有助于我们丢弃不够独特的点。因此,我们在这里使用这个概念来只保留好的匹配并丢弃其余的。如果我们没有足够的匹配,我们不会进一步操作。在我们的情况下,默认值是 10。你可以尝试调整这个输入参数,看看它如何影响输出。
如果我们有足够的匹配数,那么我们将在两张图像中提取关键点的列表并提取单应性矩阵。如果你还记得,我们在第一章中已经讨论了单应性。所以如果你已经忘记了,你可能想快速浏览一下。我们基本上从两张图像中取出一组点并提取变换矩阵。
图像拼接
现在我们有了变换,我们可以继续拼接图像。我们将使用变换矩阵来变换第二组点。我们将第一张图像作为参考框架,创建一个足够大的输出图像来容纳这两张图像。我们需要提取关于第二图像变换的信息。我们需要将其移动到这个参考框架中,以确保它与第一张图像对齐。因此,我们必须提取平移信息并进行变换。然后我们将第一张图像添加进去,构建最终的输出。值得一提的是,这同样适用于不同宽高比的图像。所以,如果你有机会,尝试一下,看看输出是什么样子。
如果图像彼此成角度怎么办?
到目前为止,我们一直在看同一平面上的图像。拼接这些图像是直接的,我们不需要处理任何伪影。在现实生活中,你不可能在完全相同的平面上捕捉到多张图像。当你捕捉同一场景的多张图像时,你不可避免地会倾斜相机并改变平面。所以问题是,我们的算法在那个场景下是否也能工作?实际上,它也能处理这些情况。
让我们考虑以下图像:

现在,让我们考虑同一场景的另一张图像。它与第一张图像成一定角度,并且部分重叠:

让我们将第一张图像作为我们的参考。如果我们使用我们的算法拼接这些图像,它看起来可能就像这样:

如果我们将第二幅图像作为参考,它看起来可能就像这样:

为什么看起来被拉伸了?
如果你观察,输出图像中对应查询图像的部分看起来被拉伸了。这是因为查询图像被转换并调整以适应我们的参考框架。它看起来被拉伸的原因是因为我们代码中的以下几行:
M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
result = warpImages(img2, img1, M)
由于图像彼此之间成角度,查询图像将不得不进行透视变换以适应参考框架。因此,我们首先对查询图像进行变换,然后将其拼接进主图像中,以形成全景图像。
摘要
在本章中,我们学习了如何在多张图像之间匹配关键点。我们讨论了如何将多张图像拼接在一起以创建全景图像。我们学习了如何处理不在同一平面上的图像。
在下一章中,我们将讨论如何通过检测图像中的“有趣”区域来实现内容感知图像缩放。
第七章. 接缝裁剪
在本章中,我们将学习关于内容感知图像缩放,这通常也被称为接缝裁剪。我们将讨论如何检测图像中的“有趣”部分,以及如何利用这些信息在不损害这些有趣部分的情况下调整给定图像的大小。
到本章结束时,您将了解:
-
什么是内容感知
-
如何量化图像中的“有趣”部分
-
如何使用动态规划进行图像内容分析
-
如何在不改变高度的情况下增加和减少图像宽度,同时保持有趣区域不退化
-
如何从图像中移除对象
我们为什么关心接缝裁剪?
在我们开始讨论接缝裁剪之前,我们需要了解为什么它最初是必要的。为什么我们应该关心图像内容?为什么我们不能只是调整给定图像的大小,然后继续我们的生活呢?为了回答这个问题,让我们考虑以下图像:

现在,假设我们想要在保持高度不变的情况下减少图像的宽度。如果你这样做,它看起来可能就像这样:

如您所见,图像中的鸭子看起来是倾斜的,整体图像质量有所下降。直观地说,我们可以认为鸭子是图像中的“有趣”部分。因此,当我们调整图像大小时,我们希望鸭子保持完整。这就是接缝裁剪发挥作用的地方。使用接缝裁剪,我们可以检测这些有趣区域,并确保它们不会退化。
它是如何工作的?
我们一直在讨论图像缩放以及我们在缩放图像时应考虑图像的内容。那么,为什么它被称为接缝裁剪呢?它应该只是被称为内容感知图像缩放,对吧?嗯,有许多不同的术语用来描述这个过程,例如图像重定位、液体缩放、接缝裁剪等等。它被称为接缝裁剪的原因是因为我们调整图像的方式。该算法由 Shai Avidan 和 Ariel Shamir 提出。您可以在dl.acm.org/citation.cfm?id=1276390查阅原始论文。
我们知道目标是调整给定图像的大小,同时保持有趣内容完整。因此,我们通过找到图像中最重要的路径来实现这一点。这些路径被称为接缝。一旦我们找到这些接缝,我们就从图像中移除它们,以获得缩放后的图像。这个过程,即移除或“裁剪”,最终会导致图像大小的调整。这就是我们称之为“接缝裁剪”的原因。考虑以下图像:

在前面的图像中,我们可以看到如何大致地将图像分为有趣和无趣的部分。我们需要确保我们的算法能够检测这些无趣的部分并将它们移除。让我们考虑鸭子图像和我们必须处理的约束条件。我们需要保持高度不变。这意味着我们需要在图像中找到垂直缝合线并将它们移除。这些缝合线从顶部开始,到顶部结束(或反之亦然)。如果我们处理的是垂直缩放,那么缝合线将从左侧开始,到右侧结束。垂直缝合线只是一系列从顶部行开始,到图像最后一行结束的连接像素。
我们如何定义“有趣”?
在我们开始计算缝合线之前,我们需要确定我们将使用什么度量标准来计算这些缝合线。我们需要一种方法来为每个像素分配“重要性”,以便我们可以找到最不重要的路径。在计算机视觉术语中,我们说我们需要为每个像素分配一个能量值,以便我们可以找到能量最低的路径。想出一个好的方法来分配能量值非常重要,因为它将影响输出的质量。
我们可以使用的一个度量标准是每个点的导数值。这是该邻域活动水平的好指标。如果有活动,那么像素值会迅速变化。因此,该点的导数值会很高。另一方面,如果该区域平淡无趣,那么像素值不会迅速变化。因此,在灰度图像中该点的导数值会很低。
对于每个像素位置,我们通过计算该点的 X 和 Y 导数来计算能量。我们通过取当前像素与其邻居之间的差来计算导数。如果你还记得,当我们使用索贝尔滤波器在第二章中进行边缘检测时,我们做了类似的事情,检测边缘和应用图像滤波器。一旦我们计算了这些值,我们就将它们存储在一个称为能量矩阵的矩阵中。
我们如何计算缝合线?
现在我们已经得到了能量矩阵,我们准备计算缝合线。我们需要找到通过图像中能量最低的路径。计算所有可能的路径代价过高,因此我们需要找到一种更智能的方法来完成这项工作。这正是动态规划发挥作用的地方。实际上,缝合线裁剪是动态编程的直接应用。我们需要从第一行的每个像素开始,找到到达最后一行的路径。为了找到能量最低的路径,我们在一个表中计算并存储到达每个像素的最佳路径。一旦我们构建了这个表,就可以通过在该表中回溯行来找到特定像素的路径。
对于当前行的每个像素,我们计算下一个行中可以移动到的三个可能像素位置的能量,即左下角、底部和右下角。我们重复这个过程,直到到达底部。一旦到达底部,我们选择累积值最小的一个,然后回溯到顶部。这将给我们提供最低能量的路径。每次我们移除一个接缝,图像的宽度就会减少1。因此,我们需要继续移除这些接缝,直到达到所需的图像大小。
让我们再次考虑我们的鸭子图像。如果你计算前 30 个接缝,它看起来会像这样:

这些绿色线条表示的是最不重要的路径。正如我们在这里看到的,它们小心翼翼地绕过鸭子,以确保不会触及有趣区域。在图像的上半部分,接缝绕着树枝走,以保持质量。从技术上讲,树枝也是“有趣的”。如果你继续并移除前 100 个接缝,它看起来会像这样:

现在,将这个与简单地调整大小的图像进行比较。它看起来不是好多了吗?这幅图像中的鸭子看起来很漂亮。
让我们看看代码,看看如何实现:
import sys
import cv2
import numpy as np
# Draw vertical seam on top of the image
def overlay_vertical_seam(img, seam):
img_seam_overlay = np.copy(img) x
# Extract the list of points from the seam
x_coords, y_coords = np.transpose([(i,int(j)) for i,j in enumerate(seam)])
# Draw a green line on the image using the list of points
img_seam_overlay[x_coords, y_coords] = (0,255,0)
return img_seam_overlay
# Compute the energy matrix from the input image
def compute_energy_matrix(img):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Compute X derivative of the image
sobel_x = cv2.Sobel(gray,cv2.CV_64F,1,0,ksize=3)
# Compute Y derivative of the image
sobel_y = cv2.Sobel(gray,cv2.CV_64F,0,1,ksize=3)
abs_sobel_x = cv2.convertScaleAbs(sobel_x)
abs_sobel_y = cv2.convertScaleAbs(sobel_y)
# Return weighted summation of the two images i.e. 0.5*X + 0.5*Y
return cv2.addWeighted(abs_sobel_x, 0.5, abs_sobel_y, 0.5, 0)
# Find vertical seam in the input image
def find_vertical_seam(img, energy):
rows, cols = img.shape[:2]
# Initialize the seam vector with 0 for each element
seam = np.zeros(img.shape[0])
# Initialize distance and edge matrices
dist_to = np.zeros(img.shape[:2]) + sys.maxint
dist_to[0,:] = np.zeros(img.shape[1])
edge_to = np.zeros(img.shape[:2])
# Dynamic programming; iterate using double loop and compute the paths efficiently
for row in xrange(rows-1):
for col in xrange(cols):
if col != 0:
if dist_to[row+1, col-1] > dist_to[row, col] + energy[row+1, col-1]:
dist_to[row+1, col-1] = dist_to[row, col] + energy[row+1, col-1]
edge_to[row+1, col-1] = 1
if dist_to[row+1, col] > dist_to[row, col] + energy[row+1, col]:
dist_to[row+1, col] = dist_to[row, col] + energy[row+1, col]
edge_to[row+1, col] = 0
if col != cols-1:
if dist_to[row+1, col+1] > dist_to[row, col] + energy[row+1, col+1]:
dist_to[row+1, col+1] = dist_to[row, col] + energy[row+1, col+1]
edge_to[row+1, col+1] = -1
# Retracing the path
seam[rows-1] = np.argmin(dist_to[rows-1, :])
for i in (x for x in reversed(xrange(rows)) if x > 0):
seam[i-1] = seam[i] + edge_to[i, int(seam[i])]
return seam
# Remove the input vertical seam from the image
def remove_vertical_seam(img, seam):
rows, cols = img.shape[:2]
# To delete a point, move every point after it one step towards the left
for row in xrange(rows):
for col in xrange(int(seam[row]), cols-1):
img[row, col] = img[row, col+1]
# Discard the last column to create the final output image
img = img[:, 0:cols-1]
return img
if __name__=='__main__':
# Make sure the size of the input image is reasonable.
# Large images take a lot of time to be processed.
# Recommended size is 640x480.
img_input = cv2.imread(sys.argv[1])
# Use a small number to get started. Once you get an
# idea of the processing time, you can use a bigger number.
# To get started, you can set it to 20.
num_seams = int(sys.argv[2])
img = np.copy(img_input)
img_overlay_seam = np.copy(img_input)
energy = compute_energy_matrix(img)
for i in xrange(num_seams):
seam = find_vertical_seam(img, energy)
img_overlay_seam = overlay_vertical_seam(img_overlay_seam, seam)
img = remove_vertical_seam(img, seam)
energy = compute_energy_matrix(img)
print 'Number of seams removed =', i+1
cv2.imshow('Input', img_input)
cv2.imshow('Seams', img_overlay_seam)
cv2.imshow('Output', img)
cv2.waitKey()
我们能否扩展一个图像?
我们知道我们可以使用接缝裁剪来减小图像的宽度,而不会损害有趣区域。因此,我们自然会问自己,我们能否在不损害有趣区域的情况下扩展图像?事实证明,我们可以使用相同的逻辑来实现。当我们计算接缝时,我们只需要添加一个额外的列而不是删除它。
如果你天真地扩展鸭子图像,它看起来会像这样:

如果你以更智能的方式来做,即使用接缝裁剪,它看起来会像这样:

如您所见,图像的宽度增加了,鸭子看起来没有被拉伸。以下是实现这一功能的代码:
import sys
import cv2
import numpy as np
# Compute the energy matrix from the input image
def compute_energy_matrix(img):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
abs_sobel_x = cv2.convertScaleAbs(sobel_x)
abs_sobel_y = cv2.convertScaleAbs(sobel_y)
return cv2.addWeighted(abs_sobel_x, 0.5, abs_sobel_y, 0.5, 0)
# Find the vertical seam
def find_vertical_seam(img, energy):
rows, cols = img.shape[:2]
# Initialize the seam vector with 0 for each element
seam = np.zeros(img.shape[0])
# Initialize distance and edge matrices
dist_to = np.zeros(img.shape[:2]) + sys.maxint
dist_to[0,:] = np.zeros(img.shape[1])
edge_to = np.zeros(img.shape[:2])
# Dynamic programming; iterate using double loop and compute
#the paths efficiently
for row in xrange(rows-1):
for col in xrange(cols):
if col != 0:
if dist_to[row+1, col-1] > dist_to[row, col] + energy[row+1, col-1]:
dist_to[row+1, col-1] = dist_to[row, col] + energy[row+1, col-1]
edge_to[row+1, col-1] = 1
if dist_to[row+1, col] > dist_to[row, col] + energy[row+1, col]:
dist_to[row+1, col] = dist_to[row, col] + energy[row+1, col]
edge_to[row+1, col] = 0
if col != cols-1:
if dist_to[row+1, col+1] > dist_to[row, col] + energy[row+1, col+1]:
dist_to[row+1, col+1] = dist_to[row, col] + energy[row+1, col+1]
edge_to[row+1, col+1] = -1
# Retracing the path
seam[rows-1] = np.argmin(dist_to[rows-1, :])
for i in (x for x in reversed(xrange(rows)) if x > 0):
seam[i-1] = seam[i] + edge_to[i, int(seam[i])]
return seam
# Add a vertical seam to the image
def add_vertical_seam(img, seam, num_iter):
seam = seam + num_iter
rows, cols = img.shape[:2]
zero_col_mat = np.zeros((rows,1,3), dtype=np.uint8)
img_extended = np.hstack((img, zero_col_mat))
for row in xrange(rows):
for col in xrange(cols, int(seam[row]), -1):
img_extended[row, col] = img[row, col-1]
# To insert a value between two columns, take the average # value of the neighbors. It looks smooth this way and we # can avoid unwanted artifacts.
for i in range(3):
v1 = img_extended[row, int(seam[row])-1, i]
v2 = img_extended[row, int(seam[row])+1, i]
img_extended[row, int(seam[row]), i] = (int(v1)+int(v2))/2
return img_extended
# Remove vertical seam from the image
def remove_vertical_seam(img, seam):
rows, cols = img.shape[:2]
for row in xrange(rows):
for col in xrange(int(seam[row]), cols-1):
img[row, col] = img[row, col+1]
img = img[:, 0:cols-1]
return img
if __name__=='__main__':
img_input = cv2.imread(sys.argv[1])
num_seams = int(sys.argv[2])
img = np.copy(img_input)
img_output = np.copy(img_input)
energy = compute_energy_matrix(img)
for i in xrange(num_seams):
seam = find_vertical_seam(img, energy)
img = remove_vertical_seam(img, seam)
img_output = add_vertical_seam(img_output, seam, i)
energy = compute_energy_matrix(img)
print 'Number of seams added =', i+1
cv2.imshow('Input', img_input)
cv2.imshow('Output', img_output)
cv2.waitKey()
在这个代码中,我们添加了一个额外的函数,add_vertical_seam。我们使用它来添加垂直接缝,使图像看起来更自然。
我们能否完全移除一个对象?
这可能是接缝裁剪最有趣的应用之一。我们可以使一个对象从图像中完全消失。让我们考虑以下图像:

让我们选择感兴趣的区域:

在你移除右侧的椅子后,它看起来会像这样:

就好像椅子从未存在过一样!在我们查看代码之前,重要的是要知道这需要一段时间才能运行。所以,请耐心等待几分钟,以便了解处理时间。你可以相应地调整输入图像的大小!让我们来看看代码:
import sys
import cv2
import numpy as np
# Draw rectangle on top of the input image
def draw_rectangle(event, x, y, flags, params):
global x_init, y_init, drawing, top_left_pt, bottom_right_pt, img_orig
# Detecting a mouse click
if event == cv2.EVENT_LBUTTONDOWN:
drawing = True
x_init, y_init = x, y
# Detecting mouse movement
elif event == cv2.EVENT_MOUSEMOVE:
if drawing:
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
img[y_init:y, x_init:x] = 255 - img_orig[y_init:y, x_init:x]
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)
# Detecting the mouse button up event
elif event == cv2.EVENT_LBUTTONUP:
drawing = False
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
# Create the "negative" film effect for the selected # region
img[y_init:y, x_init:x] = 255 - img[y_init:y, x_init:x]
# Draw rectangle around the selected region
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)
rect_final = (x_init, y_init, x-x_init, y-y_init)
# Remove the object in the selected region
remove_object(img_orig, rect_final)
# Computing the energy matrix using modified algorithm
def compute_energy_matrix_modified(img, rect_roi):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Compute the X derivative
sobel_x = cv2.Sobel(gray,cv2.CV_64F,1,0,ksize=3)
# Compute the Y derivative
sobel_y = cv2.Sobel(gray,cv2.CV_64F,0,1,ksize=3)
abs_sobel_x = cv2.convertScaleAbs(sobel_x)
abs_sobel_y = cv2.convertScaleAbs(sobel_y)
# Compute weighted summation i.e. 0.5*X + 0.5*Y
energy_matrix = cv2.addWeighted(abs_sobel_x, 0.5, abs_sobel_y, 0.5, 0)
x,y,w,h = rect_roi
# We want the seams to pass through this region, so make sure the energy values in this region are set to 0
energy_matrix[y:y+h, x:x+w] = 0
return energy_matrix
# Compute energy matrix
def compute_energy_matrix(img):
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Compute X derivative
sobel_x = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=3)
# Compute Y derivative
sobel_y = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=3)
abs_sobel_x = cv2.convertScaleAbs(sobel_x)
abs_sobel_y = cv2.convertScaleAbs(sobel_y)
# Return weighted summation i.e. 0.5*X + 0.5*Y
return cv2.addWeighted(abs_sobel_x, 0.5, abs_sobel_y, 0.5, 0)
# Find the vertical seam
def find_vertical_seam(img, energy):
rows, cols = img.shape[:2]
# Initialize the seam vector
seam = np.zeros(img.shape[0])
# Initialize the distance and edge matrices
dist_to = np.zeros(img.shape[:2]) + sys.maxint
dist_to[0,:] = np.zeros(img.shape[1])
edge_to = np.zeros(img.shape[:2])
# Dynamic programming; using double loop to compute the paths
for row in xrange(rows-1):
for col in xrange(cols):
if col != 0:
if dist_to[row+1, col-1] > dist_to[row, col] + energy[row+1, col-1]:
dist_to[row+1, col-1] = dist_to[row, col] + energy[row+1, col-1]
edge_to[row+1, col-1] = 1
if dist_to[row+1, col] > dist_to[row, col] + energy[row+1, col]:
dist_to[row+1, col] = dist_to[row, col] + energy[row+1, col]
edge_to[row+1, col] = 0
if col != cols-1:
if dist_to[row+1, col+1] > dist_to[row, col] + energy[row+1, col+1]:
dist_to[row+1, col+1] = dist_to[row, col] + energy[row+1, col+1]
edge_to[row+1, col+1] = -1
# Retracing the path
seam[rows-1] = np.argmin(dist_to[rows-1, :])
for i in (x for x in reversed(xrange(rows)) if x > 0):
seam[i-1] = seam[i] + edge_to[i, int(seam[i])]
return seam
# Add vertical seam to the input image
def add_vertical_seam(img, seam, num_iter):
seam = seam + num_iter
rows, cols = img.shape[:2]
zero_col_mat = np.zeros((rows,1,3), dtype=np.uint8)
img_extended = np.hstack((img, zero_col_mat))
for row in xrange(rows):
for col in xrange(cols, int(seam[row]), -1):
img_extended[row, col] = img[row, col-1]
# To insert a value between two columns, take the average # value of the neighbors. It looks smooth this way and we # can avoid unwanted artifacts.
for i in range(3):
v1 = img_extended[row, int(seam[row])-1, i]
v2 = img_extended[row, int(seam[row])+1, i]
img_extended[row, int(seam[row]), i] = (int(v1)+int(v2))/2
return img_extended
# Remove vertical seam
def remove_vertical_seam(img, seam):
rows, cols = img.shape[:2]
for row in xrange(rows):
for col in xrange(int(seam[row]), cols-1):
img[row, col] = img[row, col+1]
img = img[:, 0:cols-1]
return img
# Remove the object from the input region of interest
def remove_object(img, rect_roi):
num_seams = rect_roi[2] + 10
energy = compute_energy_matrix_modified(img, rect_roi)
# Start a loop and remove one seam at a time
for i in xrange(num_seams):
# Find the vertical seam that can be removed
seam = find_vertical_seam(img, energy)
# Remove that vertical seam
img = remove_vertical_seam(img, seam)
x,y,w,h = rect_roi
# Compute energy matrix after removing the seam
energy = compute_energy_matrix_modified(img, (x,y,w-i,h))
print 'Number of seams removed =', i+1
img_output = np.copy(img)
# Fill up the region with surrounding values so that the size # of the image remains unchanged
for i in xrange(num_seams):
seam = find_vertical_seam(img, energy)
img = remove_vertical_seam(img, seam)
img_output = add_vertical_seam(img_output, seam, i)
energy = compute_energy_matrix(img)
print 'Number of seams added =', i+1
cv2.imshow('Input', img_input)
cv2.imshow('Output', img_output)
cv2.waitKey()
if __name__=='__main__':
img_input = cv2.imread(sys.argv[1])
drawing = False
img = np.copy(img_input)
img_orig = np.copy(img_input)
cv2.namedWindow('Input')
cv2.setMouseCallback('Input', draw_rectangle)
while True:
cv2.imshow('Input', img)
c = cv2.waitKey(10)
if c == 27:
break
cv2.destroyAllWindows()
我们是如何做到的?
基本逻辑在这里保持不变。我们正在使用 seam carving 来移除对象。一旦我们选择了感兴趣的区域,我们就让所有的 seams 都通过这个区域。我们通过在每次迭代后操作能量矩阵来实现这一点。我们添加了一个名为 compute_energy_matrix_modified 的新函数来完成这个任务。一旦我们计算了能量矩阵,我们就将一个值为 0 的值分配给这个感兴趣的区域。这样,我们就迫使所有的 seams 都通过这个区域。在我们移除与这个区域相关的所有 seams 之后,我们继续添加 seams,直到将图像扩展到其原始宽度。
摘要
在本章中,我们学习了内容感知图像缩放。我们讨论了如何在图像中量化有趣和无趣的区域。我们学习了如何计算图像中的 seams 以及如何使用动态规划来高效地完成这项工作。我们还讨论了如何使用 seam carving 来减小图像的宽度,以及如何使用相同的逻辑来扩展图像。此外,我们还学习了如何完全从图像中移除一个对象。
在下一章中,我们将讨论如何进行形状分析和图像分割。我们将看到如何使用这些原则来找到图像中感兴趣对象的精确边界。
第八章. 检测形状和分割图像
在本章中,我们将学习形状分析和图像分割。我们将学习如何识别形状并估计确切的边界。我们将讨论如何使用各种方法将图像分割成其组成部分。我们还将学习如何将前景与背景分离。
到本章结束时,你将知道:
-
什么是轮廓分析和形状匹配
-
如何匹配形状
-
什么是图像分割
-
如何将图像分割成其组成部分
-
如何将前景与背景分离
-
如何使用各种技术来分割图像
轮廓分析和形状匹配
轮廓分析是计算机视觉领域的一个非常有用的工具。我们在现实世界中处理很多形状,轮廓分析有助于使用各种算法分析这些形状。当我们把图像转换为灰度并对其进行阈值处理时,我们剩下的是一堆线和轮廓。一旦我们了解了不同形状的特性,我们就能从图像中提取详细的信息。
假设我们想在以下图像中识别回力镖形状:

为了做到这一点,我们首先需要知道一个标准的回力镖是什么样子:

现在以上述图像为参考,我们能否识别出原始图像中与回力镖相对应的形状?如果你注意到,我们不能使用基于简单相关性的方法,因为所有形状都发生了扭曲。这意味着我们寻找精确匹配的方法将不会起作用!我们需要了解这个形状的特性,并将相应的特性匹配以识别回力镖形状。OpenCV 提供了一个很好的形状匹配函数,我们可以使用它来实现这一点。匹配基于 Hu 矩的概念,而 Hu 矩又与图像矩相关。你可以参考以下论文了解更多关于矩的信息:zoi.utia.cas.cz/files/chapter_moments_color1.pdf。图像矩的概念基本上是指形状内像素的加权幂次求和。

在上述方程中,p 指的是轮廓内的像素,w 指的是权重,N 指的是轮廓内的点数,k 指的是幂,I 指的是矩。根据我们为 w 和 k 选择的值,我们可以从该轮廓中提取不同的特征。
可能最简单的例子是计算轮廓的面积。为此,我们需要计算该区域内像素的数量。所以从数学的角度讲,在加权求和和幂次求和的形式中,我们只需要将 w 设为 1,将 k 设为 0。这将给出轮廓的面积。根据我们如何计算这些矩,它们将帮助我们理解这些不同的形状。这也引发了一些有趣的属性,有助于我们确定形状相似度指标。
如果我们匹配形状,你会看到类似这样的结果:

让我们看看执行此操作的代码:
import sys
import cv2
import numpy as np
# Extract reference contour from the image
def get_ref_contour(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)
# Find all the contours in the thresholded image. The values
# for the second and third parameters are restricted to a # certain number of possible values. You can learn more # 'findContours' function here: http://docs.opencv.org/modules/imgproc/doc/structural_analysis_and_shape_descriptors.html
contours, hierarchy = cv2.findContours(thresh, 1, 2)
# Extract the relevant contour based on area ratio. We use the # area ratio because the main image boundary contour is # extracted as well and we don't want that. This area ratio # threshold will ensure that we only take the contour inside # the image.
for contour in contours:
area = cv2.contourArea(contour)
img_area = img.shape[0] * img.shape[1]
if 0.05 < area/float(img_area) < 0.8:
return contour
# Extract all the contours from the image
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)
contours, hierarchy = cv2.findContours(thresh, 1, 2)
return contours
if __name__=='__main__':
# Boomerang reference image
img1 = cv2.imread(sys.argv[1])
# Input image containing all the different shapes
img2 = cv2.imread(sys.argv[2])
# Extract the reference contour
ref_contour = get_ref_contour(img1)
# Extract all the contours from the input image
input_contours = get_all_contours(img2)
closest_contour = input_contours[0]
min_dist = sys.maxint
# Finding the closest contour
for contour in input_contours:
# Matching the shapes and taking the closest one
ret = cv2.matchShapes(ref_contour, contour, 1, 0.0)
if ret < min_dist:
min_dist = ret
closest_contour = contour
cv2.drawContours(img2, [closest_contour], -1, (0,0,0), 3)
cv2.imshow('Output', img2)
cv2.waitKey()
近似轮廓
我们在现实生活中遇到的大多数轮廓都很嘈杂。这意味着轮廓看起来不光滑,因此我们的分析受到了影响。那么我们如何处理这个问题呢?一种方法就是获取轮廓上的所有点,然后用平滑的多边形来近似它。
让我们再次考虑回飞镖图像。如果你使用不同的阈值近似轮廓,你会看到轮廓改变形状。让我们从一个因子 0.05 开始:

如果你减少这个因子,轮廓将变得更加平滑。让我们将其设置为 0.01:

如果你把它做得非常小,比如说 0.00001,那么它看起来就像原始图像:

识别被切掉一片的披萨
标题可能有些误导,因为我们不会谈论披萨片。但假设你处于一个情况,你有一个包含不同形状披萨的图像。现在,有人从那些披萨中切掉了一片。我们如何自动识别这个?
我们不能采取之前的方法,因为我们不知道形状看起来像什么。所以我们没有任何模板。我们甚至不确定我们正在寻找什么形状,因此我们不能根据任何先前的信息构建一个模板。我们只知道的事实是从其中一个披萨中切下了一片。让我们考虑以下图像:

这不是一张真实的图像,但你能理解我们的意思。你知道我们在谈论什么形状。由于我们不知道我们在寻找什么,我们需要使用这些形状的一些属性来识别切下的披萨。如果你注意的话,其他所有形状都很好地封闭。也就是说,你可以在那些形状内取任意两点,并在它们之间画一条线,那条线始终位于那个形状内。这类形状被称为凸形状。
如果你观察被切掉的披萨形状,我们可以选择两个点,使得它们之间的线如图所示超出形状:

所以,我们只需要检测图像中的非凸形状,然后我们就完成了。让我们继续做:
import sys
import cv2
import numpy as np
# Input is a color image
def get_contours(img):
# Convert the image to grayscale
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
# Threshold the input image
ret, thresh = cv2.threshold(img_gray, 127, 255, 0)
# Find the contours in the above image
contours, hierarchy = cv2.findContours(thresh, 2, 1)
return contours
if __name__=='__main__':
img = cv2.imread(sys.argv[1])
# Iterate over the extracted contours
for contour in get_contours(img):
# Extract convex hull from the contour
hull = cv2.convexHull(contour, returnPoints=False)
# Extract convexity defects from the above hull
defects = cv2.convexityDefects(contour, hull)
if defects is None:
continue
# Draw lines and circles to show the defects
for i in range(defects.shape[0]):
start_defect, end_defect, far_defect, _ = defects[i,0]
start = tuple(contour[start_defect][0])
end = tuple(contour[end_defect][0])
far = tuple(contour[far_defect][0])
cv2.circle(img, far, 5, [128,0,0], -1)
cv2.drawContours(img, [contour], -1, (0,0,0), 3)
cv2.imshow('Convexity defects',img)
cv2.waitKey(0)
cv2.destroyAllWindows()
如果你运行上面的代码,你将看到类似以下的内容:

等一下,这里发生了什么?看起来太杂乱了。我们是不是做错了什么?实际上,曲线并不平滑。如果你仔细观察,曲线的每处都有微小的脊。所以,如果你只是运行你的凸性检测器,它将不起作用。这就是轮廓近似真正派上用场的地方。一旦我们检测到轮廓,我们需要平滑它们,以便脊不会影响它们。让我们继续做:
import sys
import cv2
import numpy as np
# Input is a color image
def get_contours(img):
img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(img_gray, 127, 255, 0)
contours, hierarchy = cv2.findContours(thresh, 2, 1)
return contours
if __name__=='__main__':
img = cv2.imread(sys.argv[1])
# Iterate over the extracted contours
for contour in get_contours(img):
orig_contour = contour
epsilon = 0.01 * cv2.arcLength(contour, True)
contour = cv2.approxPolyDP(contour, epsilon, True)
# Extract convex hull and the convexity defects
hull = cv2.convexHull(contour, returnPoints=False)
defects = cv2.convexityDefects(contour,hull)
if defects is None:
continue
# Draw lines and circles to show the defects
for i in range(defects.shape[0]):
start_defect, end_defect, far_defect, _ = defects[i,0]
start = tuple(contour[start_defect][0])
end = tuple(contour[end_defect][0])
far = tuple(contour[far_defect][0])
cv2.circle(img, far, 7, [255,0,0], -1)
cv2.drawContours(img, [orig_contour], -1, (0,0,0), 3)
cv2.imshow('Convexity defects',img)
cv2.waitKey(0)
cv2.destroyAllWindows()
如果你运行前面的代码,输出将类似于以下内容:

如何屏蔽形状?
假设你正在处理图像,并且想要屏蔽掉特定的形状。现在,你可能想说你会使用形状匹配来识别形状,然后直接屏蔽它,对吧?但问题在于我们没有可用的模板。那么我们该如何进行呢?形状分析有多种形式,我们需要根据具体情况构建我们的算法。让我们考虑以下图示:

假设我们想要识别所有飞镖形状,然后在不使用任何模板图像的情况下屏蔽它们。正如你所见,图像中还有各种其他奇怪的形状,而飞镖形状并不平滑。我们需要识别将飞镖形状与其他形状区分开来的属性。让我们考虑凸包。如果你将每个形状的面积与凸包的面积之比,我们可以看到这可以是一个区分的指标。这个指标在形状分析中被称为坚实因子。由于会留下空隙,这个指标对于飞镖形状将会有一个较低的值,如下面的图示所示:

黑色边界代表凸包。一旦我们为所有形状计算了这些值,我们该如何将它们分开?我们能否仅仅使用一个固定的阈值来检测飞镖形状?实际上不行!我们不能有一个固定的阈值值,因为你永远不知道你可能会遇到什么形状。所以,更好的方法是用K-Means 聚类。K-Means 是一种无监督学习技术,可以用来将输入数据分离成 K 个类别。在继续之前,你可以快速复习一下 K-Means,请参考docs.opencv.org/master/de/d4d/tutorial_py_kmeans_understanding.html。
我们知道我们想要将形状分为两组,即回旋镖形状和其他形状。因此,我们知道在 K-Means 中我们的K是什么。一旦我们使用它并对值进行聚类,我们选择具有最低固结因子的聚类,这将给我们回旋镖形状。请注意,这种方法只适用于这个特定情况。如果你处理的是其他类型的形状,那么你将不得不使用其他指标来确保形状检测能够工作。正如我们之前讨论的,这很大程度上取决于具体情况。如果你检测到形状并将它们屏蔽掉,它看起来会是这样:

下面是实现它的代码:
import sys
import cv2
import numpy as np
def get_all_contours(img):
ref_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
ret, thresh = cv2.threshold(ref_gray, 127, 255, 0)
contours, hierarchy = cv2.findContours(thresh, 1, 2)
return contours
if __name__=='__main__':
# Input image containing all the shapes
img = cv2.imread(sys.argv[1])
img_orig = np.copy(img)
input_contours = get_all_contours(img)
solidity_values = []
# Compute solidity factors of all the contours
for contour in input_contours:
area_contour = cv2.contourArea(contour)
convex_hull = cv2.convexHull(contour)
area_hull = cv2.contourArea(convex_hull)
solidity = float(area_contour)/area_hull
solidity_values.append(solidity)
# Clustering using KMeans
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 10, 1.0)
flags = cv2.KMEANS_RANDOM_CENTERS
solidity_values = np.array(solidity_values).reshape((len(solidity_values),1)).astype('float32')
compactness, labels, centers = cv2.kmeans(solidity_values, 2, criteria, 10, flags)
closest_class = np.argmin(centers)
output_contours = []
for i in solidity_values[labels==closest_class]:
index = np.where(solidity_values==i)[0][0]
output_contours.append(input_contours[index])
cv2.drawContours(img, output_contours, -1, (0,0,0), 3)
cv2.imshow('Output', img)
# Censoring
for contour in output_contours:
rect = cv2.minAreaRect(contour)
box = cv2.cv.BoxPoints(rect)
box = np.int0(box)
cv2.drawContours(img_orig,[box],0,(0,0,0),-1)
cv2.imshow('Censored', img_orig)
cv2.waitKey()
什么是图像分割?
图像分割是将图像分割成其组成部分的过程。它是现实世界中许多计算机视觉应用的重要步骤。有许许多多的图像分割方法。当我们分割图像时,我们根据颜色、纹理、位置等各种指标来分离区域。每个区域内的所有像素都有一些共同点,这取决于我们使用的指标。让我们看看这里的一些流行方法。
首先,我们将探讨一种称为GrabCut的技术。它是一种基于更通用方法图割的图像分割方法。在图割方法中,我们将整个图像视为一个图,然后根据图中边的强度来分割图。我们通过考虑每个像素为一个节点来构建图,节点之间构建边,其中边的权重是这两个节点像素值的函数。每当存在边界时,像素值会更高。因此,边的权重也会更高。然后通过最小化图的吉布斯能量来分割这个图。这类似于寻找最大熵分割。你可以参考原始论文了解更多信息,请参阅cvg.ethz.ch/teaching/cvl/2012/grabcut-siggraph04.pdf。让我们考虑以下图像:

让我们选择感兴趣的区域:

一旦图像被分割,它看起来可能就像这样:

下面是实现这一功能的代码:
import cv2
import numpy as np
# Draw rectangle based on the input selection
def draw_rectangle(event, x, y, flags, params):
global x_init, y_init, drawing, top_left_pt, bottom_right_pt, img_orig
# Detecting mouse button down event
if event == cv2.EVENT_LBUTTONDOWN:
drawing = True
x_init, y_init = x, y
# Detecting mouse movement
elif event == cv2.EVENT_MOUSEMOVE:
if drawing:
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
img[y_init:y, x_init:x] = 255 - img_orig[y_init:y, x_init:x]
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)
# Detecting mouse button up event
elif event == cv2.EVENT_LBUTTONUP:
drawing = False
top_left_pt, bottom_right_pt = (x_init,y_init), (x,y)
img[y_init:y, x_init:x] = 255 - img[y_init:y, x_init:x]
cv2.rectangle(img, top_left_pt, bottom_right_pt, (0,255,0), 2)
rect_final = (x_init, y_init, x-x_init, y-y_init)
# Run Grabcut on the region of interest
run_grabcut(img_orig, rect_final)
# Grabcut algorithm
def run_grabcut(img_orig, rect_final):
# Initialize the mask
mask = np.zeros(img_orig.shape[:2],np.uint8)
# Extract the rectangle and set the region of
# interest in the above mask
x,y,w,h = rect_final
mask[y:y+h, x:x+w] = 1
# Initialize background and foreground models
bgdModel = np.zeros((1,65), np.float64)
fgdModel = np.zeros((1,65), np.float64)
# Run Grabcut algorithm
cv2.grabCut(img_orig, mask, rect_final, bgdModel, fgdModel, 5, cv2.GC_INIT_WITH_RECT)
# Extract new mask
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')
# Apply the above mask to the image
img_orig = img_orig*mask2[:,:,np.newaxis]
# Display the image
cv2.imshow('Output', img_orig)
if __name__=='__main__':
drawing = False
top_left_pt, bottom_right_pt = (-1,-1), (-1,-1)
# Read the input image
img_orig = cv2.imread(sys.argv[1])
img = img_orig.copy()
cv2.namedWindow('Input')
cv2.setMouseCallback('Input', draw_rectangle)
while True:
cv2.imshow('Input', img)
c = cv2.waitKey(1)
if c == 27:
break
cv2.destroyAllWindows()
它是如何工作的?
我们从用户指定的种子点开始。这是包含我们感兴趣物体的边界框。在表面之下,算法估计物体和背景的颜色分布。算法将图像的颜色分布表示为高斯混合马尔可夫随机场(GMMRF)。你可以参考详细论文了解 GMMRF 的更多信息,请参阅research.microsoft.com/pubs/67898/eccv04-GMMRF.pdf。我们需要物体和背景两者的颜色分布,因为我们将会使用这些知识来分离物体。这些信息被用来通过在马尔可夫随机场上应用最小割算法来找到最大熵分割。一旦我们有了这个,我们就使用图割优化方法来推断标签。
分水岭算法
OpenCV 自带了分水岭算法的默认实现。它相当著名,并且有很多实现可供选择。你可以在docs.opencv.org/master/d3/db4/tutorial_py_watershed.html了解更多相关信息。由于你已经可以访问 OpenCV 的源代码,我们这里将不会查看代码。
我们将只看看输出是什么样子。考虑以下图像:

让我们选择区域:

如果你在这个物体上运行分水岭算法,输出将类似于以下内容:

摘要
在本章中,我们学习了轮廓分析和图像分割。我们学习了如何根据模板匹配形状。我们学习了形状的各种不同属性以及我们如何使用它们来识别不同类型的形状。我们讨论了图像分割以及我们如何使用基于图的方法来分割图像中的区域。我们还简要讨论了分水岭变换。
在下一章中,我们将讨论如何在实时视频中跟踪一个物体。
第九章. 对象跟踪
在本章中,我们将学习如何在实时视频中跟踪对象。我们将讨论可用于跟踪对象的不同特征。我们还将了解对象跟踪的不同方法和技巧。
到本章结束时,您将了解:
-
如何使用帧差分
-
如何使用色彩空间跟踪彩色对象
-
如何构建一个交互式对象跟踪器
-
如何构建特征跟踪器
-
如何构建视频监控系统
帧差分
这可能是我们用来查看视频哪些部分在移动的最简单技术。当我们考虑实时视频流时,连续帧之间的差异为我们提供了大量信息。这个概念相当直接!我们只需计算连续帧之间的差异并显示这些差异。
如果我快速地从左到右移动我的笔记本电脑,我们将看到类似这样的效果:

如果我快速地在手中移动电视遥控器,它看起来可能就像这样:

如您从前面的图像中可以看到,视频中只有运动部件被突出显示。这为我们提供了一个很好的起点,可以看到视频中哪些区域在移动。以下是实现这一功能的代码:
import cv2
# Compute the frame difference
def frame_diff(prev_frame, cur_frame, next_frame):
# Absolute difference between current frame and next frame
diff_frames1 = cv2.absdiff(next_frame, cur_frame)
# Absolute difference between current frame and # previous frame
diff_frames2 = cv2.absdiff(cur_frame, prev_frame)
# Return the result of bitwise 'AND' between the # above two resultant images
return cv2.bitwise_and(diff_frames1, diff_frames2)
# Capture the frame from webcam
def get_frame(cap):
# Capture the frame
ret, frame = cap.read()
# Resize the image
frame = cv2.resize(frame, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
# Return the grayscale image
return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY)
if __name__=='__main__':
cap = cv2.VideoCapture(0)
scaling_factor = 0.5
prev_frame = get_frame(cap)
cur_frame = get_frame(cap)
next_frame = get_frame(cap)
# Iterate until the user presses the ESC key
while True:
# Display the result of frame differencing
cv2.imshow("Object Movement", frame_diff(prev_frame, cur_frame, next_frame))
# Update the variables
prev_frame = cur_frame
cur_frame = next_frame
next_frame = get_frame(cap)
# Check if the user pressed ESC
key = cv2.waitKey(10)
if key == 27:
break
cv2.destroyAllWindows()
基于色彩空间的跟踪
帧差分为我们提供了一些有用的信息,但我们不能用它来构建任何有意义的东西。为了构建一个好的对象跟踪器,我们需要了解哪些特征可以用来使我们的跟踪既稳健又准确。因此,让我们迈出这一步,看看我们如何使用色彩空间来构建一个好的跟踪器。正如我们在前面的章节中讨论的,HSV 色彩空间在人类感知方面非常有信息量。我们可以将图像转换为 HSV 空间,然后使用colorspacethresholding来跟踪给定的对象。
考虑视频中的以下帧:

如果您将其通过色彩空间过滤器并跟踪对象,您将看到类似这样的效果:

正如我们在这里可以看到的,我们的跟踪器根据颜色特征识别视频中的特定对象。为了使用这个跟踪器,我们需要知道目标对象的颜色分布。以下是如何实现这一功能的代码:
import cv2
import numpy as np
# Capture the input frame from webcam
def get_frame(cap, scaling_factor):
# Capture the frame from video capture object
ret, frame = cap.read()
# Resize the input frame
frame = cv2.resize(frame, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
return frame
if __name__=='__main__':
cap = cv2.VideoCapture(0)
scaling_factor = 0.5
# Iterate until the user presses ESC key
while True:
frame = get_frame(cap, scaling_factor)
# Convert the HSV colorspace
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
# Define 'blue' range in HSV colorspace
lower = np.array([60,100,100])
upper = np.array([180,255,255])
# Threshold the HSV image to get only blue color
mask = cv2.inRange(hsv, lower, upper)
# Bitwise-AND mask and original image
res = cv2.bitwise_and(frame, frame, mask=mask)
res = cv2.medianBlur(res, 5)
cv2.imshow('Original image', frame)
cv2.imshow('Color Detector', res)
# Check if the user pressed ESC key
c = cv2.waitKey(5)
if c == 27:
break
cv2.destroyAllWindows()
构建一个交互式对象跟踪器
基于色彩空间的跟踪器为我们提供了跟踪彩色对象的自由,但我们也被限制在预定义的颜色中。如果我们只想随机选择一个对象怎么办?我们如何构建一个可以学习所选对象特征并自动跟踪它的对象跟踪器?这就是CAMShift算法的用武之地,它代表连续自适应均值漂移。它基本上是均值漂移算法的改进版本。
均值漂移的概念实际上很棒且简单。假设我们选择了一个感兴趣的区域,并希望我们的目标跟踪器跟踪该对象。在这个区域内,我们根据颜色直方图选择一些点并计算质心。如果质心位于这个区域的中心,我们知道对象没有移动。但如果质心不在这个区域的中心,那么我们知道对象正在某个方向上移动。质心的移动控制着对象移动的方向。因此,我们将边界框移动到新的位置,使新的质心成为这个边界框的中心。因此,这个算法被称为均值漂移,因为均值(即质心)在移动。这样,我们就能保持对对象当前位置的更新。
但均值漂移的问题在于边界框的大小不允许改变。当你将对象从摄像机移开时,对象在人类眼中会显得更小,但均值漂移不会考虑这一点。边界框的大小在整个跟踪过程中保持不变。因此,我们需要使用 CAMShift。CAMShift 的优势在于它可以调整边界框的大小以适应对象的大小。此外,它还可以跟踪对象的方向。
让我们考虑以下帧,其中对象以橙色突出显示(我手中的框):

现在我们已经选择了对象,算法计算直方图的反投影并提取所有信息。让我们移动对象,看看它是如何被跟踪的:

看起来对象被跟踪得相当好。让我们改变方向,看看跟踪是否保持:

如我们所见,边界椭圆已经改变了其位置和方向。让我们改变对象的角度,看看它是否仍然能够跟踪:

我们仍然做得很好!边界椭圆已经改变了长宽比,以反映对象现在看起来是倾斜的(由于透视变换)。
以下是对应的代码:
import sys
import cv2
import numpy as np
class ObjectTracker(object):
def __init__(self):
# Initialize the video capture object
# 0 -> indicates that frame should be captured
# from webcam
self.cap = cv2.VideoCapture(0)
# Capture the frame from the webcam
ret, self.frame = self.cap.read()
# Downsampling factor for the input frame
self.scaling_factor = 0.5
self.frame = cv2.resize(self.frame, None, fx=self.scaling_factor,
fy=self.scaling_factor, interpolation=cv2.INTER_AREA)
cv2.namedWindow('Object Tracker')
cv2.setMouseCallback('Object Tracker', self.mouse_event)
self.selection = None
self.drag_start = None
self.tracking_state = 0
# Method to track mouse events
def mouse_event(self, event, x, y, flags, param):
x, y = np.int16([x, y])
# Detecting the mouse button down event
if event == cv2.EVENT_LBUTTONDOWN:
self.drag_start = (x, y)
self.tracking_state = 0
if self.drag_start:
if flags & cv2.EVENT_FLAG_LBUTTON:
h, w = self.frame.shape[:2]
xo, yo = self.drag_start
x0, y0 = np.maximum(0, np.minimum([xo, yo], [x, y]))
x1, y1 = np.minimum([w, h], np.maximum([xo, yo], [x, y]))
self.selection = None
if x1-x0 > 0 and y1-y0 > 0:
self.selection = (x0, y0, x1, y1)
else:
self.drag_start = None
if self.selection is not None:
self.tracking_state = 1
# Method to start tracking the object
def start_tracking(self):
# Iterate until the user presses the Esc key
while True:
# Capture the frame from webcam
ret, self.frame = self.cap.read()
# Resize the input frame
self.frame = cv2.resize(self.frame, None, fx=self.scaling_factor,
fy=self.scaling_factor, interpolation=cv2.INTER_AREA)
vis = self.frame.copy()
# Convert to HSV colorspace
hsv = cv2.cvtColor(self.frame, cv2.COLOR_BGR2HSV)
# Create the mask based on predefined thresholds.
mask = cv2.inRange(hsv, np.array((0., 60., 32.)),
np.array((180., 255., 255.)))
if self.selection:
x0, y0, x1, y1 = self.selection
self.track_window = (x0, y0, x1-x0, y1-y0)
hsv_roi = hsv[y0:y1, x0:x1]
mask_roi = mask[y0:y1, x0:x1]
# Compute the histogram
hist = cv2.calcHist( [hsv_roi], [0], mask_roi, [16], [0, 180] )
# Normalize and reshape the histogram
cv2.normalize(hist, hist, 0, 255, cv2.NORM_MINMAX);
self.hist = hist.reshape(-1)
vis_roi = vis[y0:y1, x0:x1]
cv2.bitwise_not(vis_roi, vis_roi)
vis[mask == 0] = 0
if self.tracking_state == 1:
self.selection = None
# Compute the histogram back projection
prob = cv2.calcBackProject([hsv], [0], self.hist, [0, 180], 1)
prob &= mask
term_crit = ( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 )
# Apply CAMShift on 'prob'
track_box, self.track_window = cv2.CamShift(prob, self.track_window, term_crit)
# Draw an ellipse around the object
cv2.ellipse(vis, track_box, (0, 255, 0), 2)
cv2.imshow('Object Tracker', vis)
c = cv2.waitKey(5)
if c == 27:
break
cv2.destroyAllWindows()
if __name__ == '__main__':
ObjectTracker().start_tracking()
基于特征的跟踪
基于特征的跟踪指的是在视频的连续帧中跟踪单个特征点。我们使用一种称为光流的技术来跟踪这些特征。光流是计算机视觉中最受欢迎的技术之一。我们选择一些特征点并通过视频流跟踪它们。
当我们检测到特征点时,我们计算位移向量并显示这些关键点在连续帧之间的运动。这些向量被称为运动向量。有许多方法可以做到这一点,但 Lucas-Kanade 方法可能是所有这些技术中最受欢迎的。您可以参考他们的原始论文cseweb.ucsd.edu/classes/sp02/cse252/lucaskanade81.pdf。我们通过提取特征点开始这个过程。对于每个特征点,我们以特征点为中心创建 3x3 的补丁。这里的假设是每个补丁内的所有点将具有相似的运动。我们可以根据问题的需要调整这个窗口的大小。
对于当前帧中的每个特征点,我们取周围的 3x3 补丁作为我们的参考点。对于这个补丁,我们在前一帧的邻域中寻找最佳匹配。这个邻域通常比 3x3 大,因为我们想要找到与考虑中的补丁最接近的补丁。现在,从前一帧中匹配补丁的中心像素到当前帧中考虑的补丁中心像素的路径将成为运动向量。我们对所有特征点都这样做,并提取所有运动向量。
让我们考虑以下帧:

如果我在水平方向上移动,您将看到水平方向上的运动向量:

如果我远离网络摄像头,您将看到如下所示:

因此,如果您想尝试一下,您可以让用户在输入视频中选择感兴趣的区域(就像我们之前做的那样)。然后,您可以从这个感兴趣的区域提取特征点并通过绘制边界框来跟踪对象。这将是一个有趣的练习!
这里是执行基于光流跟踪的代码:
import cv2
import numpy as np
def start_tracking():
# Capture the input frame
cap = cv2.VideoCapture(0)
# Downsampling factor for the image
scaling_factor = 0.5
# Number of frames to keep in the buffer when you
# are tracking. If you increase this number,
# feature points will have more "inertia"
num_frames_to_track = 5
# Skip every 'n' frames. This is just to increase the speed.
num_frames_jump = 2
tracking_paths = []
frame_index = 0
# 'winSize' refers to the size of each patch. These patches
# are the smallest blocks on which we operate and track
# the feature points. You can read more about the parameters
# here: http://goo.gl/ulwqLk
tracking_params = dict(winSize = (11, 11), maxLevel = 2,
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))
# Iterate until the user presses the ESC key
while True:
# read the input frame
ret, frame = cap.read()
# downsample the input frame
frame = cv2.resize(frame, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
frame_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
output_img = frame.copy()
if len(tracking_paths) > 0:
prev_img, current_img = prev_gray, frame_gray
feature_points_0 = np.float32([tp[-1] for tp in tracking_paths]).reshape(-1, 1, 2)
# Compute feature points using optical flow. You can
# refer to the documentation to learn more about the
# parameters here: http://goo.gl/t6P4SE
feature_points_1, _, _ = cv2.calcOpticalFlowPyrLK(prev_img, current_img, feature_points_0,
None, **tracking_params)
feature_points_0_rev, _, _ = cv2.calcOpticalFlowPyrLK(current_img, prev_img, feature_points_1,
None, **tracking_params)
# Compute the difference of the feature points
diff_feature_points = abs(feature_points_0- feature_points_0_rev).reshape(-1, 2).max(-1)
# threshold and keep the good points
good_points = diff_feature_points < 1
new_tracking_paths = []
for tp, (x, y), good_points_flag in zip(tracking_paths,
feature_points_1.reshape(-1, 2), good_points):
if not good_points_flag:
continue
tp.append((x, y))
# Using the queue structure i.e. first in,
# first out
if len(tp) > num_frames_to_track:
del tp[0]
new_tracking_paths.append(tp)
# draw green circles on top of the output image
cv2.circle(output_img, (x, y), 3, (0, 255, 0), -1)
tracking_paths = new_tracking_paths
# draw green lines on top of the output image
cv2.polylines(output_img, [np.int32(tp) for tp in tracking_paths], False, (0, 150, 0))
# 'if' condition to skip every 'n'th frame
if not frame_index % num_frames_jump:
mask = np.zeros_like(frame_gray)
mask[:] = 255
for x, y in [np.int32(tp[-1]) for tp in tracking_paths]:
cv2.circle(mask, (x, y), 6, 0, -1)
# Extract good features to track. You can learn more
# about the parameters here: http://goo.gl/BI2Kml
feature_points = cv2.goodFeaturesToTrack(frame_gray,
mask = mask, maxCorners = 500, qualityLevel = 0.3,
minDistance = 7, blockSize = 7)
if feature_points is not None:
for x, y in np.float32(feature_points).reshape (-1, 2):
tracking_paths.append([(x, y)])
frame_index += 1
prev_gray = frame_gray
cv2.imshow('Optical Flow', output_img)
# Check if the user pressed the ESC key
c = cv2.waitKey(1)
if c == 27:
break
if __name__ == '__main__':
start_tracking()
cv2.destroyAllWindows()
背景减法
背景减法在视频监控中非常有用。基本上,背景减法技术在需要检测静态场景中移动对象的情况下表现非常出色。正如其名所示,该算法通过检测背景并将其从当前帧中减去以获得前景,即移动对象来工作。为了检测移动对象,我们首先需要建立一个背景模型。这不同于帧差分,因为我们实际上是在建模背景并使用这个模型来检测移动对象。因此,这种方法比简单的帧差分技术表现更好。这种技术试图检测场景中的静态部分,并将其包含在背景模型中。因此,它是一种自适应技术,可以根据场景进行调整。
让我们考虑以下图像:

现在,随着我们在这一场景中收集更多的帧,图像的每一部分都将逐渐成为背景模型的一部分。这正是我们之前讨论过的。如果一个场景是静态的,模型会自动调整以确保背景模型得到更新。这就是它开始时的样子:

注意,我的脸部的一部分已经成为了背景模型的一部分(被变黑的区域)。以下截图显示了几秒钟后我们将看到的情况:

如果我们继续这样做,最终一切都将成为背景模型的一部分:

现在,如果我们引入一个新的移动物体,它将被清晰地检测到,如下所示:

下面是实现这一功能的代码:
import cv2
import numpy as np
# Capture the input frame
def get_frame(cap, scaling_factor=0.5):
ret, frame = cap.read()
# Resize the frame
frame = cv2.resize(frame, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
return frame
if __name__=='__main__':
# Initialize the video capture object
cap = cv2.VideoCapture(0)
# Create the background subtractor object
bgSubtractor = cv2.BackgroundSubtractorMOG()
# This factor controls the learning rate of the algorithm.
# The learning rate refers to the rate at which your model
# will learn about the background. Higher value for
# 'history' indicates a slower learning rate. You
# can play with this parameter to see how it affects
# the output.
history = 100
# Iterate until the user presses the ESC key
while True:
frame = get_frame(cap, 0.5)
# Apply the background subtraction model to the # input frame
mask = bgSubtractor.apply(frame, learningRate=1.0/history)
# Convert from grayscale to 3-channel RGB
mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
cv2.imshow('Input frame', frame)
cv2.imshow('Moving Objects', mask & frame)
# Check if the user pressed the ESC key
c = cv2.waitKey(10)
if c == 27:
break
cap.release()
cv2.destroyAllWindows()
摘要
在本章中,我们学习了物体跟踪。我们学习了如何使用帧差分来获取运动信息,以及当我们想要跟踪不同类型的物体时,它可能受到的限制。我们学习了颜色空间阈值化以及它是如何用于跟踪彩色物体的。我们讨论了物体跟踪的聚类技术以及我们如何使用 CAMShift 算法构建一个交互式物体跟踪器。我们讨论了如何在视频中跟踪特征以及我们如何使用光流来实现相同的效果。我们学习了背景减法以及它是如何用于视频监控的。
在下一章中,我们将讨论物体识别,以及我们如何构建一个视觉搜索引擎。
第十章. 对象识别
在本章中,我们将学习对象识别以及如何使用它来构建视觉搜索引擎。我们将讨论特征检测、构建特征向量以及使用机器学习来构建分类器。我们将学习如何使用这些不同的模块来构建一个对象识别系统。
到本章结束时,你将知道:
-
对象检测与对象识别的区别是什么
-
什么是密集特征检测器
-
什么是视觉词典
-
如何构建特征向量
-
什么是监督学习和无监督学习
-
什么是支持向量机以及如何使用它们来构建分类器
-
如何在未知图像中识别一个对象
对象检测与对象识别的比较
在我们继续之前,我们需要了解本章将要讨论的内容。你一定经常听到“对象检测”和“对象识别”这两个术语,并且它们经常被误认为是同一件事。两者之间有一个非常明显的区别。
对象检测是指检测给定场景中特定对象的存在。我们不知道这个对象可能是什么。例如,我们在第四章中讨论了人脸检测,检测和跟踪不同的身体部位。在讨论中,我们只检测了给定图像中是否存在人脸。我们没有识别出这个人!我们没有识别出这个人的原因是我们在讨论中并不关心这一点。我们的目标是找到给定图像中人脸的位置。商业人脸识别系统同时使用人脸检测和人脸识别来识别一个人。首先,我们需要定位人脸,然后,在裁剪的人脸上进行人脸识别。
对象识别是在给定图像中识别对象的过程。例如,一个对象识别系统可以告诉你一个给定的图像是否包含连衣裙或一双鞋。实际上,我们可以训练一个对象识别系统来识别许多不同的对象。问题是对象识别是一个真正难以解决的问题。它已经让计算机视觉研究人员困惑了几十年,并已成为计算机视觉的圣杯。人类可以很容易地识别各种各样的对象。我们每天都在做,而且毫不费力,但计算机无法以那种精度做到这一点。
让我们考虑以下咖啡杯的图像:

对象检测器会给你以下信息:

现在,考虑以下茶杯的图像:

如果你运行它通过对象检测器,你会看到以下结果:

如您所见,对象检测器检测到了茶杯的存在,但仅此而已。如果您训练一个对象识别器,它将提供以下信息,如下面的图像所示:

如果您考虑第二张图像,它将为您提供以下信息:

如您所见,一个完美的对象识别器会为您提供与该对象相关的所有信息。如果对象识别器知道对象的位置,它将运行得更准确。如果您有一个大图像,而杯子只是其中的一小部分,那么对象识别器可能无法识别它。因此,第一步是检测对象并获取边界框。一旦我们有了这个,我们就可以运行对象识别器来提取更多信息。
什么是密集特征检测器?
为了从图像中提取有意义的信息,我们需要确保我们的特征提取器能够从给定图像的所有部分提取特征。考虑以下图像:

如果你使用特征提取器提取特征,它将看起来像这样:

如果你使用Dense检测器,它将看起来像这样:

我们还可以控制密度。让我们将其变为稀疏的:

通过这样做,我们可以确保图像的每个部分都被处理。以下是实现此功能的代码:
import cv2
import numpy as np
class DenseDetector(object):
def __init__(self, step_size=20, feature_scale=40, img_bound=20):
# Create a dense feature detector
self.detector = cv2.FeatureDetector_create("Dense")
# Initialize it with all the required parameters
self.detector.setInt("initXyStep", step_size)
self.detector.setInt("initFeatureScale", feature_scale)
self.detector.setInt("initImgBound", img_bound)
def detect(self, img):
# Run feature detector on the input image
return self.detector.detect(img)
if __name__=='__main__':
input_image = cv2.imread(sys.argv[1])
input_image_sift = np.copy(input_image)
# Convert to grayscale
gray_image = cv2.cvtColor(input_image, cv2.COLOR_BGR2GRAY)
keypoints = DenseDetector(20,20,5).detect(input_image)
# Draw keypoints on top of the input image
input_image = cv2.drawKeypoints(input_image, keypoints,
flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
# Display the output image
cv2.imshow('Dense feature detector', input_image)
# Initialize SIFT object
sift = cv2.SIFT()
# Detect keypoints using SIFT
keypoints = sift.detect(gray_image, None)
# Draw SIFT keypoints on the input image
input_image_sift = cv2.drawKeypoints(input_image_sift,
keypoints, flags=cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
# Display the output image
cv2.imshow('SIFT detector', input_image_sift)
# Wait until user presses a key
cv2.waitKey()
这使我们能够对提取的信息量进行更精确的控制。当我们使用 SIFT 检测器时,图像的一些部分会被忽略。当我们处理显著特征的检测时,这效果很好,但当我们构建对象识别器时,我们需要评估图像的所有部分。因此,我们使用密集检测器,然后从这些关键点中提取特征。
什么是视觉词典?
我们将使用词袋模型来构建我们的对象识别器。每个图像都表示为视觉词的直方图。这些视觉词基本上是使用从训练图像中提取的所有关键点构建的N个质心。流程如图所示:

从每个训练图像中,我们检测一组关键点并为每个关键点提取特征。每个图像都会产生不同数量的关键点。为了训练一个分类器,每个图像必须使用固定长度的特征向量来表示。这个特征向量实际上就是一个直方图,其中每个 bin 对应一个视觉词。
当我们从训练图像中的所有关键点中提取所有特征后,我们执行 K-Means 聚类并提取 N 个质心。这个 N 是给定图像特征向量的长度。现在,每张图像都将表示为一个直方图,其中每个 bin 对应于这“N”个质心中的一个。为了简单起见,让我们假设 N 设置为 4。现在,在给定的图像中,我们提取K个关键点。在这些 K 个关键点中,一些将最接近第一个质心,一些将最接近第二个质心,依此类推。因此,我们根据每个关键点最近质心构建直方图。这个直方图成为我们的特征向量。这个过程被称为向量量化。
为了理解向量量化,让我们考虑一个例子。假设我们有一个图像,并且我们已经从中提取了一定数量的特征点。现在我们的目标是将以特征向量的形式表示这张图像。考虑以下图像:

如您所见,我们有 4 个质心。请记住,图中的点代表特征空间,而不是这些特征点在图像中的实际几何位置。前一个图中的展示方式是为了便于可视化。来自图像中许多不同几何位置的点可以在特征空间中彼此靠近。我们的目标是表示这张图像为直方图,其中每个 bin 对应于这些质心中的一个。这样,无论我们从图像中提取多少特征点,它都将始终转换为固定长度的特征向量。因此,我们将每个特征点“四舍五入”到其最近的质心,如图中所示:

如果你为这张图像构建直方图,它将看起来像这样:

现在,如果你考虑一个具有不同特征点分布的不同图像,它将看起来像这样:

这些簇看起来如下:

直方图将看起来像这样:

如您所见,尽管点似乎随机分布,但这两个图像的直方图非常不同。这是一个非常强大的技术,并且在计算机视觉和信号处理中得到广泛应用。有许多不同的方法来做这件事,准确性取决于你希望它有多精细。如果你增加质心的数量,你将能够更好地表示图像,从而增加特征向量的唯一性。话虽如此,重要的是要提到,你不能无限制地增加质心的数量。如果你这样做,它将变得过于嘈杂并失去其力量。
什么是监督学习和无监督学习?
如果您熟悉机器学习的基础知识,您当然会知道监督学习和无监督学习是什么。为了快速回顾,监督学习是指基于标记样本构建一个函数。例如,如果我们正在构建一个系统来区分服装图像和鞋类图像,我们首先需要建立一个数据库并对它进行标记。我们需要告诉我们的算法哪些图像对应于服装,哪些图像对应于鞋类。基于这些数据,算法将学习如何识别服装和鞋类,以便当未知图像进入时,它可以识别图像中的内容。
无监督学习是我们刚才讨论内容的反面。这里没有可用的标记数据。假设我们有一堆图像,我们只想将它们分成三个组。我们不知道标准是什么。因此,无监督学习算法将尝试以最佳方式将给定数据集分成 3 组。我们讨论这个问题的原因是因为我们将使用监督学习和无监督学习的组合来构建我们的目标识别系统。
什么是支持向量机?
支持向量机(SVM)是机器学习领域中非常流行的监督学习模型。SVM 在分析标记数据和检测模式方面非常出色。给定一些数据点和相关的标签,SVM 将以最佳方式构建分隔超平面。
等一下,什么是“超平面”?为了理解这一点,让我们考虑以下图示:

如您所见,点被等距离于点的线边界分开。在二维空间中这很容易可视化。如果是在三维空间中,分隔器将是平面。当我们为图像构建特征时,特征向量的长度通常在六位数范围内。因此,当我们进入这样一个高维空间时,“线”的等价物将是超平面。一旦超平面被确定,我们就使用这个数学模型来根据数据在这个地图上的位置对未知数据进行分类。
如果我们无法用简单的直线分离数据怎么办?
在支持向量机(SVM)中,有一种称为核技巧的东西。考虑以下图像:

如我们所见,我们不能简单地画一条直线来将红色点与蓝色点分开。提出一个能满足所有点的优美曲线边界是过于昂贵的。SVMs(支持向量机)在画“直线”方面真的很擅长。那么,我们的答案是什么?SVMs(支持向量机)的好处是它们可以在任意数量的维度上画这些“直线”。所以从技术上讲,如果你将这些点投影到一个高维空间中,在那里它们可以通过一个简单的超平面分开,SVMs(支持向量机)将提出一个精确的边界。一旦我们有了这个边界,我们就可以将其投影回原始空间。这个超平面在我们原始的低维空间上的投影看起来是弯曲的,正如我们在下一张图中可以看到的:

SVMs(支持向量机)的主题真的很深奥,我们在这里无法详细讨论。如果你真的感兴趣,网上有大量的资料可供参考。你可以通过一个简单的教程来更好地理解它。
我们实际上是如何实现这个的?
我们现在已经到达了核心。到目前为止的讨论是必要的,因为它为你提供了构建物体识别系统所需的背景知识。现在,让我们构建一个可以识别给定图像是否包含连衣裙、一双鞋或一个包的物体识别器。我们可以轻松地将这个系统扩展到检测任意数量的物品。我们从一个不同的三个物品开始,这样你以后可以开始实验。
在我们开始之前,我们需要确保我们有一组训练图像。网上有许多数据库可供下载,其中图像已经被分组成组。Caltech256 可能是物体识别中最受欢迎的数据库之一。你可以从www.vision.caltech.edu/Image_Datasets/Caltech256下载它。创建一个名为images的文件夹,并在其中创建三个子文件夹,即dress、footwear和bag。在每个这些子文件夹中,添加对应物品的 20 张图像。你可以直接从互联网上下载这些图像,但请确保这些图像有干净的背景。
例如,一件连衣裙的图片可能如下所示:

一双鞋的图片可能如下所示:

一个包的图片可能如下所示:

现在我们有了 60 张训练图像,我们准备开始。作为旁注,物体识别系统实际上需要成千上万张训练图像才能在现实世界中表现良好。由于我们正在构建一个用于检测 3 种物体的物体识别器,我们将为每种物体只取 20 张训练图像。增加更多的训练图像将提高我们系统的准确性和鲁棒性。
这里的第一步是从所有训练图像中提取特征向量并构建视觉字典(也称为代码簿)。以下是代码:
import os
import sys
import argparse
import cPickle as pickle
import json
import cv2
import numpy as np
from sklearn.cluster import KMeans
def build_arg_parser():
parser = argparse.ArgumentParser(description='Creates features for given images')
parser.add_argument("--samples", dest="cls", nargs="+", action="append",
required=True, help="Folders containing the training images. \
The first element needs to be the class label.")
parser.add_argument("--codebook-file", dest='codebook_file', required=True,
help="Base file name to store the codebook")
parser.add_argument("--feature-map-file", dest='feature_map_file', required=True,
help="Base file name to store the feature map")
return parser
# Loading the images from the input folder
def load_input_map(label, input_folder):
combined_data = []
if not os.path.isdir(input_folder):
raise IOError("The folder " + input_folder + " doesn't exist")
# Parse the input folder and assign the labels
for root, dirs, files in os.walk(input_folder):
for filename in (x for x in files if x.endswith('.jpg')):
combined_data.append({'label': label, 'image': os.path.join(root, filename)})
return combined_data
class FeatureExtractor(object):
def extract_image_features(self, img):
# Dense feature detector
kps = DenseDetector().detect(img)
# SIFT feature extractor
kps, fvs = SIFTExtractor().compute(img, kps)
return fvs
# Extract the centroids from the feature points
def get_centroids(self, input_map, num_samples_to_fit=10):
kps_all = []
count = 0
cur_label = ''
for item in input_map:
if count >= num_samples_to_fit:
if cur_label != item['label']:
count = 0
else:
continue
count += 1
if count == num_samples_to_fit:
print "Built centroids for", item['label']
cur_label = item['label']
img = cv2.imread(item['image'])
img = resize_to_size(img, 150)
num_dims = 128
fvs = self.extract_image_features(img)
kps_all.extend(fvs)
kmeans, centroids = Quantizer().quantize(kps_all)
return kmeans, centroids
def get_feature_vector(self, img, kmeans, centroids):
return Quantizer().get_feature_vector(img, kmeans, centroids)
def extract_feature_map(input_map, kmeans, centroids):
feature_map = []
for item in input_map:
temp_dict = {}
temp_dict['label'] = item['label']
print "Extracting features for", item['image']
img = cv2.imread(item['image'])
img = resize_to_size(img, 150)
temp_dict['feature_vector'] = FeatureExtractor().get_feature_vector(
img, kmeans, centroids)
if temp_dict['feature_vector'] is not None:
feature_map.append(temp_dict)
return feature_map
# Vector quantization
class Quantizer(object):
def __init__(self, num_clusters=32):
self.num_dims = 128
self.extractor = SIFTExtractor()
self.num_clusters = num_clusters
self.num_retries = 10
def quantize(self, datapoints):
# Create KMeans object
kmeans = KMeans(self.num_clusters,
n_init=max(self.num_retries, 1),
max_iter=10, tol=1.0)
# Run KMeans on the datapoints
res = kmeans.fit(datapoints)
# Extract the centroids of those clusters
centroids = res.cluster_centers_
return kmeans, centroids
def normalize(self, input_data):
sum_input = np.sum(input_data)
if sum_input > 0:
return input_data / sum_input
else:
return input_data
# Extract feature vector from the image
def get_feature_vector(self, img, kmeans, centroids):
kps = DenseDetector().detect(img)
kps, fvs = self.extractor.compute(img, kps)
labels = kmeans.predict(fvs)
fv = np.zeros(self.num_clusters)
for i, item in enumerate(fvs):
fv[labels[i]] += 1
fv_image = np.reshape(fv, ((1, fv.shape[0])))
return self.normalize(fv_image)
class DenseDetector(object):
def __init__(self, step_size=20, feature_scale=40, img_bound=20):
self.detector = cv2.FeatureDetector_create("Dense")
self.detector.setInt("initXyStep", step_size)
self.detector.setInt("initFeatureScale", feature_scale)
self.detector.setInt("initImgBound", img_bound)
def detect(self, img):
return self.detector.detect(img)
class SIFTExtractor(object):
def compute(self, image, kps):
if image is None:
print "Not a valid image"
raise TypeError
gray_image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
kps, des = cv2.SIFT().compute(gray_image, kps)
return kps, des
# Resize the shorter dimension to 'new_size'
# while maintaining the aspect ratio
def resize_to_size(input_image, new_size=150):
h, w = input_image.shape[0], input_image.shape[1]
ds_factor = new_size / float(h)
if w < h:
ds_factor = new_size / float(w)
new_size = (int(w * ds_factor), int(h * ds_factor))
return cv2.resize(input_image, new_size)
if __name__=='__main__':
args = build_arg_parser().parse_args()
input_map = []
for cls in args.cls:
assert len(cls) >= 2, "Format for classes is `<label> file`"
label = cls[0]
input_map += load_input_map(label, cls[1])
# Building the codebook
print "===== Building codebook ====="
kmeans, centroids = FeatureExtractor().get_centroids(input_map)
if args.codebook_file:
with open(args.codebook_file, 'w') as f:
pickle.dump((kmeans, centroids), f)
# Input data and labels
print "===== Building feature map ====="
feature_map = extract_feature_map(input_map, kmeans, centroids)
if args.feature_map_file:
with open(args.feature_map_file, 'w') as f:
pickle.dump(feature_map, f)
代码内部发生了什么?
我们首先需要做的是提取质心。这是我们构建视觉字典的方法。FeatureExtractor类中的get_centroids方法被设计用来做这件事。我们持续收集从关键点提取的图像特征,直到我们拥有足够多的特征。由于我们使用的是密集检测器,10 张图像应该足够了。我们只取 10 张图像的原因是,它们会产生大量的特征。即使你添加更多的特征点,质心也不会有太大的变化。
一旦我们提取了质心,我们就准备好进行下一步的特征提取。质心集合是我们的视觉字典。extract_feature_map函数将从每个图像中提取特征向量,并将其与相应的标签关联起来。我们这样做的原因是我们需要这种映射来训练我们的分类器。我们需要一组数据点,并且每个数据点都应该与一个标签相关联。因此,我们从图像开始,提取特征向量,然后将其与相应的标签(如包、连衣裙或鞋类)关联起来。
Quantizer类被设计用来实现向量量化并构建特征向量。对于从图像中提取的每个关键点,get_feature_vector方法会找到我们字典中最接近的视觉词。通过这样做,我们最终基于我们的视觉字典构建了一个直方图。现在每个图像都表示为视觉词集合的组合。因此得名,词袋模型。
下一步是使用这些特征来训练分类器。以下是代码:
import os
import sys
import argparse
import cPickle as pickle
import numpy as np
from sklearn.multiclass import OneVsOneClassifier
from sklearn.svm import LinearSVC
from sklearn import preprocessing
def build_arg_parser():
parser = argparse.ArgumentParser(description='Trains the classifier models')
parser.add_argument("--feature-map-file", dest="feature_map_file", required=True,
help="Input pickle file containing the feature map")
parser.add_argument("--svm-file", dest="svm_file", required=False,
help="Output file where the pickled SVM model will be stored")
return parser
# To train the classifier
class ClassifierTrainer(object):
def __init__(self, X, label_words):
# Encoding the labels (words to numbers)
self.le = preprocessing.LabelEncoder()
# Initialize One vs One Classifier using a linear kernel
self.clf = OneVsOneClassifier(LinearSVC(random_state=0))
y = self._encodeLabels(label_words)
X = np.asarray(X)
self.clf.fit(X, y)
# Predict the output class for the input datapoint
def _fit(self, X):
X = np.asarray(X)
return self.clf.predict(X)
# Encode the labels (convert words to numbers)
def _encodeLabels(self, labels_words):
self.le.fit(labels_words)
return np.array(self.le.transform(labels_words), dtype=np.float32)
# Classify the input datapoint
def classify(self, X):
labels_nums = self._fit(X)
labels_words = self.le.inverse_transform([int(x) for x in labels_nums])
return labels_words
if __name__=='__main__':
args = build_arg_parser().parse_args()
feature_map_file = args.feature_map_file
svm_file = args.svm_file
# Load the feature map
with open(feature_map_file, 'r') as f:
feature_map = pickle.load(f)
# Extract feature vectors and the labels
labels_words = [x['label'] for x in feature_map]
# Here, 0 refers to the first element in the
# feature_map, and 1 refers to the second
# element in the shape vector of that element
# (which gives us the size)
dim_size = feature_map[0]['feature_vector'].shape[1]
X = [np.reshape(x['feature_vector'], (dim_size,)) for x in feature_map]
# Train the SVM
svm = ClassifierTrainer(X, labels_words)
if args.svm_file:
with open(args.svm_file, 'w') as f:
pickle.dump(svm, f)
我们是如何构建训练器的?
我们使用scikit-learn包来构建 SVM 模型。你可以按照下面的步骤进行安装:
$ pip install scikit-learn
我们从标记数据开始,并将其馈送到OneVsOneClassifier方法。我们有一个classify方法,它可以对输入图像进行分类并将其与标签关联起来。
让我们试运行一下,怎么样?确保你有一个名为images的文件夹,其中包含三个类别的训练图像。创建一个名为models的文件夹,用于存储学习模型。在你的终端上运行以下命令以创建特征并训练分类器:
$ python create_features.py --samples bag images/bag/ --samples dress images/dress/ --samples footwear images/footwear/ --codebook-file models/codebook.pkl --feature-map-file models/feature_map.pkl
$ python training.py --feature-map-file models/feature_map.pkl --svm-file models/svm.pkl
现在分类器已经训练好了,我们只需要一个模块来对输入图像进行分类并检测其中的物体。以下是实现这一功能的代码:
import os
import sys
import argparse
import cPickle as pickle
import cv2
import numpy as np
import create_features as cf
from training import ClassifierTrainer
def build_arg_parser():
parser = argparse.ArgumentParser(description='Extracts features \
from each line and classifies the data')
parser.add_argument("--input-image", dest="input_image", required=True,
help="Input image to be classified")
parser.add_argument("--svm-file", dest="svm_file", required=True,
help="File containing the trained SVM model")
parser.add_argument("--codebook-file", dest="codebook_file",
required=True, help="File containing the codebook")
return parser
# Classifying an image
class ImageClassifier(object):
def __init__(self, svm_file, codebook_file):
# Load the SVM classifier
with open(svm_file, 'r') as f:
self.svm = pickle.load(f)
# Load the codebook
with open(codebook_file, 'r') as f:
self.kmeans, self.centroids = pickle.load(f)
# Method to get the output image tag
def getImageTag(self, img):
# Resize the input image
img = cf.resize_to_size(img)
# Extract the feature vector
feature_vector = cf.FeatureExtractor().get_feature_vector(img, self.kmeans, self.centroids)
# Classify the feature vector and get the output tag
image_tag = self.svm.classify(feature_vector)
return image_tag
if __name__=='__main__':
args = build_arg_parser().parse_args()
svm_file = args.svm_file
codebook_file = args.codebook_file
input_image = cv2.imread(args.input_image)
print "Output class:", ImageClassifier(svm_file, codebook_file).getImageTag(input_image)
我们已经准备就绪!我们只需从输入图像中提取特征向量,并将其作为分类器的输入参数。让我们继续看看这行不行。从互联网上下载一张随机的鞋类图像,并确保它有一个干净的背景。用以下命令替换new_image.jpg为正确的文件名:
$ python classify_data.py --input-image new_image.jpg --svm-file models/svm.pkl --codebook-file models/codebook.pkl
我们可以使用相同的技巧来构建一个视觉搜索引擎。视觉搜索引擎会查看输入图像,并显示一系列与它相似的图像。我们可以重用物体识别框架来构建这个系统。从输入图像中提取特征向量,并将其与训练数据集中的所有特征向量进行比较。挑选出最匹配的,并显示结果。这是一种简单的方法!
在现实世界中,我们必须处理数十亿张图片。因此,在你展示输出之前,不可能逐张搜索每一张图片。有许多算法被用来确保在现实世界中这个过程既高效又快速。深度学习在这个领域被广泛使用,并且在近年来展现出了巨大的潜力。它是机器学习的一个分支,专注于学习数据的最佳表示,使得机器更容易学习新的任务。你可以在deeplearning.net了解更多相关信息。
摘要
在本章中,我们学习了如何构建一个物体识别系统。物体检测和物体识别之间的区别被详细讨论。我们学习了密集特征检测器、视觉词典、矢量量化,以及如何使用这些概念来构建特征向量。我们还讨论了监督学习和无监督学习。我们讨论了支持向量机,以及我们如何使用它们来构建分类器。我们学习了如何在未知图像中识别物体,以及如何将这一概念扩展以构建视觉搜索引擎。
在下一章中,我们将讨论立体成像和 3D 重建。我们将讨论如何构建深度图并从给定的场景中提取 3D 信息。
第十一章:立体视觉和 3D 重建
在本章中,我们将学习立体视觉以及我们如何重建场景的 3D 地图。我们将讨论极线几何、深度图和 3D 重建。我们将学习如何从立体图像中提取 3D 信息并构建点云。
到本章结束时,你将知道:
-
什么是立体对应
-
什么是极线几何
-
什么是深度图
-
如何提取 3D 信息
-
如何构建和可视化给定场景的 3D 地图
什么是立体对应?
当我们捕捉图像时,我们将周围的 3D 世界投影到 2D 图像平面上。所以从技术上讲,当我们捕捉这些照片时,我们只有 2D 信息。由于场景中的所有物体都投影到一个平坦的 2D 平面上,深度信息就丢失了。我们无法知道物体距离摄像机的距离或物体在 3D 空间中的相对位置。这就是立体视觉发挥作用的地方。
人类非常擅长从现实世界中推断深度信息。原因是我们的两只眼睛相距几英寸。每只眼睛都像是一个摄像机,我们从两个不同的视角捕捉同一场景的两个图像,即左眼和右眼各捕捉一个图像。因此,我们的大脑通过立体视觉使用这两张图像构建一个 3D 地图。这是我们希望通过立体视觉算法实现的目标。我们可以使用不同的视角捕捉同一场景的两个照片,然后匹配相应的点以获得场景的深度图。
让我们考虑以下图像:

现在,如果我们从不同的角度捕捉相同的场景,它将看起来像这样:

如你所见,图像中物体的位置有大量的移动。如果你考虑像素坐标,这两个图像中初始位置和最终位置的值将有很大的差异。考虑以下图像:

如果我们在第二张图像中考虑相同的距离线,它将看起来像这样:

d1 和 d2 之间的差异很大。现在,让我们将盒子靠近摄像机:

现在,让我们将摄像机移动与之前相同,并从这个角度捕捉相同的场景:

如你所见,物体位置之间的移动并不大。如果你考虑像素坐标,你会看到它们的值很接近。第一张图像中的距离将是:

如果我们在第二张图像中考虑相同的距离线,它将如下所示:

d3 和 d4 之间的差异很小。我们可以说,d1 和 d2 之间的绝对差异大于 d3 和 d4 之间的绝对差异。尽管相机移动了相同的距离,但初始位置和最终位置之间的视距差异很大。这是因为我们可以将物体移得更近;从不同角度捕捉两个图像时,视距的明显变化会减小。这是立体对应背后的概念:我们捕捉两个图像,并利用这些知识从给定的场景中提取深度信息。
什么是极线几何?
在讨论极线几何之前,让我们讨论一下当我们从两个不同的视角捕捉同一场景的两个图像时会发生什么。考虑以下图示:

让我们看看在现实生活中它是如何发生的。考虑以下图像:

现在,让我们从不同的视角捕捉相同的场景:

我们的目标是将这两幅图像中的关键点匹配起来,以提取场景信息。我们这样做是通过提取一个矩阵,该矩阵可以关联两个立体图像之间的对应点。这被称为 基础矩阵。
如我们之前在相机图示中看到的,我们可以画线来看它们在哪里相交。这些线被称为 极线。极线相交的点称为极点。如果你使用 SIFT 匹配关键点,并画出指向左图会合点的线条,它将看起来像这样:

以下是在右图中的匹配特征点:

这些线条是极线。如果你以第二幅图像为参考,它们将看起来如下:

以下是在第一张图像中的匹配特征点:

理解极线几何以及我们如何绘制这些线条非常重要。如果两个帧在三维空间中定位,那么两个帧之间的每条极线都必须与每个帧中相应的特征点和每个相机的原点相交。这可以用来估计相机相对于三维环境的姿态。我们将在稍后使用这些信息,从场景中提取三维信息。让我们看看代码:
import argparse
import cv2
import numpy as np
def build_arg_parser():
parser = argparse.ArgumentParser(description='Find fundamental matrix \
using the two input stereo images and draw epipolar lines')
parser.add_argument("--img-left", dest="img_left", required=True,
help="Image captured from the left view")
parser.add_argument("--img-right", dest="img_right", required=True,
help="Image captured from the right view")
parser.add_argument("--feature-type", dest="feature_type",
required=True, help="Feature extractor that will be used; can be either 'sift' or 'surf'")
return parser
def draw_lines(img_left, img_right, lines, pts_left, pts_right):
h,w = img_left.shape
img_left = cv2.cvtColor(img_left, cv2.COLOR_GRAY2BGR)
img_right = cv2.cvtColor(img_right, cv2.COLOR_GRAY2BGR)
for line, pt_left, pt_right in zip(lines, pts_left, pts_right):
x_start,y_start = map(int, [0, -line[2]/line[1] ])
x_end,y_end = map(int, [w, -(line[2]+line[0]*w)/line[1] ])
color = tuple(np.random.randint(0,255,2).tolist())
cv2.line(img_left, (x_start,y_start), (x_end,y_end), color,1)
cv2.circle(img_left, tuple(pt_left), 5, color, -1)
cv2.circle(img_right, tuple(pt_right), 5, color, -1)
return img_left, img_right
def get_descriptors(gray_image, feature_type):
if feature_type == 'surf':
feature_extractor = cv2.SURF()
elif feature_type == 'sift':
feature_extractor = cv2.SIFT()
else:
raise TypeError("Invalid feature type; should be either 'surf' or 'sift'")
keypoints, descriptors = feature_extractor.detectAndCompute(gray_image, None)
return keypoints, descriptors
if __name__=='__main__':
args = build_arg_parser().parse_args()
img_left = cv2.imread(args.img_left,0) # left image
img_right = cv2.imread(args.img_right,0) # right image
feature_type = args.feature_type
if feature_type not in ['sift', 'surf']:
raise TypeError("Invalid feature type; has to be either 'sift' or 'surf'")
scaling_factor = 1.0
img_left = cv2.resize(img_left, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
img_right = cv2.resize(img_right, None, fx=scaling_factor,
fy=scaling_factor, interpolation=cv2.INTER_AREA)
kps_left, des_left = get_descriptors(img_left, feature_type)
kps_right, des_right = get_descriptors(img_right, feature_type)
# FLANN parameters
FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
search_params = dict(checks=50)
# Get the matches based on the descriptors
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches = flann.knnMatch(des_left, des_right, k=2)
pts_left_image = []
pts_right_image = []
# ratio test to retain only the good matches
for i,(m,n) in enumerate(matches):
if m.distance < 0.7*n.distance:
pts_left_image.append(kps_left[m.queryIdx].pt)
pts_right_image.append(kps_right[m.trainIdx].pt)
pts_left_image = np.float32(pts_left_image)
pts_right_image = np.float32(pts_right_image)
F, mask = cv2.findFundamentalMat(pts_left_image, pts_right_image, cv2.FM_LMEDS)
# Selecting only the inliers
pts_left_image = pts_left_image[mask.ravel()==1]
pts_right_image = pts_right_image[mask.ravel()==1]
# Drawing the lines on left image and the corresponding feature points on the right image
lines1 = cv2.computeCorrespondEpilines (pts_right_image.reshape(-1,1,2), 2, F)
lines1 = lines1.reshape(-1,3)
img_left_lines, img_right_pts = draw_lines(img_left, img_right, lines1, pts_left_image, pts_right_image)
# Drawing the lines on right image and the corresponding feature points on the left image
lines2 = cv2.computeCorrespondEpilines (pts_left_image.reshape(-1,1,2), 1,F)
lines2 = lines2.reshape(-1,3)
img_right_lines, img_left_pts = draw_lines(img_right, img_left, lines2, pts_right_image, pts_left_image)
cv2.imshow('Epi lines on left image', img_left_lines)
cv2.imshow('Feature points on right image', img_right_pts)
cv2.imshow('Epi lines on right image', img_right_lines)
cv2.imshow('Feature points on left image', img_left_pts)
cv2.waitKey()
cv2.destroyAllWindows()
让我们看看如果我们使用 SURF 特征提取器会发生什么。左图中的线条将看起来像这样:

以下是在右图中的匹配特征点:

如果您以第二幅图像为参考,您将看到以下类似图像:

这些是第一幅图像中的匹配特征点:

与 SIFT 相比,为什么线条不同?
SURF 检测到一组不同的特征点,因此相应的极线线也相应地不同。正如您在图像中看到的那样,当我们使用 SURF 时,检测到的特征点更多。由于我们比以前有更多信息,相应的极线线也会相应地改变。
构建 3D 地图
现在我们已经熟悉了极线几何,让我们看看如何使用它根据立体图像构建 3D 地图。让我们考虑以下图:

第一步是从两幅图像中提取视差图。如果您看图,当我们沿着连接线从摄像机靠近物体时,点之间的距离减小。使用这些信息,我们可以推断每个点与摄像机的距离。这被称为深度图。一旦我们在两幅图像中找到匹配点,我们就可以使用极线线来施加极线约束,从而找到视差。
让我们考虑以下图像:

如果我们从不同的位置捕捉相同的场景,我们会得到以下图像:

如果我们重建 3D 地图,它将看起来像这样:

请记住,这些图像不是使用完美对齐的立体相机拍摄的。这就是 3D 地图看起来如此嘈杂的原因!这只是为了展示我们如何使用立体图像重建现实世界。让我们考虑使用正确对齐的立体相机捕获的图像对。以下为左侧视图图像:

接下来是相应的右侧视图图像:

如果您提取深度信息并构建 3D 地图,它将看起来像这样:

让我们旋转它,看看场景中不同物体的深度是否正确:

您需要一个名为MeshLab的软件来可视化 3D 场景。我们很快就会讨论它。正如我们在前面的图像中看到的那样,项目根据它们与摄像机的距离正确对齐。我们可以直观地看到它们以正确的方式排列,包括面具的倾斜位置。我们可以使用这项技术来构建许多有趣的东西。
让我们看看如何在 OpenCV-Python 中实现它:
import argparse
import cv2
import numpy as np
def build_arg_parser():
parser = argparse.ArgumentParser(description='Reconstruct the 3D map from \
the two input stereo images. Output will be saved in \'output.ply\'')
parser.add_argument("--image-left", dest="image_left", required=True,
help="Input image captured from the left")
parser.add_argument("--image-right", dest="image_right", required=True,
help="Input image captured from the right")
parser.add_argument("--output-file", dest="output_file", required=True,
help="Output filename (without the extension) where the point cloud will be saved")
return parser
def create_output(vertices, colors, filename):
colors = colors.reshape(-1, 3)
vertices = np.hstack([vertices.reshape(-1,3), colors])
ply_header = '''ply
format ascii 1.0
element vertex %(vert_num)d
property float x
property float y
property float z
property uchar red
property uchar green
property uchar blue
end_header
'''
with open(filename, 'w') as f:
f.write(ply_header % dict(vert_num=len(vertices)))
np.savetxt(f, vertices, '%f %f %f %d %d %d')
if __name__ == '__main__':
args = build_arg_parser().parse_args()
image_left = cv2.imread(args.image_left)
image_right = cv2.imread(args.image_right)
output_file = args.output_file + '.ply'
if image_left.shape[0] != image_right.shape[0] or \
image_left.shape[1] != image_right.shape[1]:
raise TypeError("Input images must be of the same size")
# downscale images for faster processing
image_left = cv2.pyrDown(image_left)
image_right = cv2.pyrDown(image_right)
# disparity range is tuned for 'aloe' image pair
win_size = 1
min_disp = 16
max_disp = min_disp * 9
num_disp = max_disp - min_disp # Needs to be divisible by 16
stereo = cv2.StereoSGBM(minDisparity = min_disp,
numDisparities = num_disp,
SADWindowSize = win_size,
uniquenessRatio = 10,
speckleWindowSize = 100,
speckleRange = 32,
disp12MaxDiff = 1,
P1 = 8*3*win_size**2,
P2 = 32*3*win_size**2,
fullDP = True
)
print "\nComputing the disparity map ..."
disparity_map = stereo.compute(image_left, image_right).astype(np.float32) / 16.0
print "\nGenerating the 3D map ..."
h, w = image_left.shape[:2]
focal_length = 0.8*w
# Perspective transformation matrix
Q = np.float32([[1, 0, 0, -w/2.0],
[0,-1, 0, h/2.0],
[0, 0, 0, -focal_length],
[0, 0, 1, 0]])
points_3D = cv2.reprojectImageTo3D(disparity_map, Q)
colors = cv2.cvtColor(image_left, cv2.COLOR_BGR2RGB)
mask_map = disparity_map > disparity_map.min()
output_points = points_3D[mask_map]
output_colors = colors[mask_map]
print "\nCreating the output file ...\n"
create_output(output_points, output_colors, output_file)
为了可视化输出,您需要从meshlab.sourceforge.net下载 MeshLab。
只需使用 MeshLab 打开output.ply文件,你就能看到 3D 图像。你可以旋转它以获得重建场景的完整 3D 视图。MeshLab 的一些替代品包括 OS X 和 Windows 上的 Sketchup,以及 Linux 上的 Blender。
摘要
在本章中,我们学习了立体视觉和 3D 重建。我们讨论了如何使用不同的特征提取器提取基本矩阵。我们学习了如何生成两张图像之间的视差图,并使用它来重建给定场景的 3D 地图。
在下一章中,我们将讨论增强现实,以及我们如何构建一个酷炫的应用程序,在这个应用程序中,我们可以在实时视频上叠加图形到现实世界物体之上。
第十二章:增强现实
在本章中,你将学习关于增强现实以及如何使用它来构建酷炫应用的知识。我们将讨论姿态估计和平面跟踪。你将学习如何将坐标从二维映射到三维,以及我们如何可以在实时视频上叠加图形。
到本章结束时,你将知道:
-
增强现实的前提是什么
-
姿态估计是什么
-
如何跟踪平面对象
-
如何将坐标从三维映射到二维
-
如何在实时视频上叠加图形
增强现实的前提是什么?
在我们跳入所有有趣的内容之前,让我们先了解什么是增强现实。你可能已经在各种背景下看到过“增强现实”这个术语被使用。因此,在我们开始讨论实现细节之前,我们应该理解增强现实的前提。增强现实指的是计算机生成的输入,如图像、声音、图形和文本,叠加在现实世界之上的叠加。
增强现实试图通过无缝融合信息和增强我们所看到和感受到的内容,来模糊现实与计算机生成内容之间的界限。实际上,它与一个称为中介现实的概念密切相关,其中计算机修改了我们对现实的看法。因此,这项技术通过增强我们对现实的当前感知来工作。现在的挑战是让用户感觉它看起来是无缝的。仅仅在输入视频上叠加一些东西很容易,但我们需要让它看起来像是视频的一部分。用户应该感觉到计算机生成的输入紧密跟随现实世界。这就是我们构建增强现实系统时想要实现的目标。
在这个背景下,计算机视觉研究探索了如何将计算机生成的图像应用于实时视频流,以便我们可以增强对现实世界的感知。增强现实技术有各种各样的应用,包括但不限于头戴式显示器、汽车、数据可视化、游戏、建筑等等。现在我们有了强大的智能手机和更智能的机器,我们可以轻松构建高端增强现实应用。
增强现实系统看起来是什么样的?
让我们考虑以下图示:

正如我们所看到的,摄像头捕捉现实世界的视频以获取参考点。图形系统生成需要叠加到视频上的虚拟对象。现在,视频合并块是所有魔法发生的地方。这个块应该足够智能,能够理解如何以最佳方式将虚拟对象叠加到现实世界之上。
增强现实中的几何变换
增强现实的结果令人惊叹,但下面有很多数学运算在进行。增强现实利用了很多几何变换和相关的数学函数来确保一切看起来无缝。当我们谈论增强现实的实时视频时,我们需要精确地注册虚拟物体在现实世界之上的位置。为了更好地理解,让我们把它想象成两个摄像机的对齐——一个是真实的,通过它我们看到世界,另一个是虚拟的,它投射出计算机生成的图形物体。
为了构建一个增强现实系统,需要建立以下几何变换:
-
对象到场景:这种变换指的是将虚拟对象的 3D 坐标转换,并在我们现实世界的场景坐标系中表达它们。这确保了我们将虚拟物体定位在正确的位置。
-
场景到摄像机:这种变换指的是摄像机在现实世界中的姿态。通过“姿态”,我们指的是摄像机的方向和位置。我们需要估计摄像机的视角,以便我们知道如何叠加虚拟物体。
-
摄像机到图像:这指的是摄像机的校准参数。这定义了我们可以如何将 3D 物体投影到 2D 图像平面上。这是我们最终将看到的图像。
考虑以下图像:

正如我们所看到的,这辆车试图适应场景,但它看起来非常不自然。如果我们不能正确转换坐标,它看起来就不自然。这就是我们之前所说的对象到场景变换!一旦我们将虚拟物体的 3D 坐标转换到现实世界的坐标系中,我们需要估计摄像机的姿态:

我们需要理解摄像机的位置和旋转,因为这就是用户将看到的。一旦我们估计出摄像机的姿态,我们就可以将这个 3D 场景放置到 2D 图像上。

一旦我们有了这些变换,我们就可以构建完整的系统。
什么是姿态估计?
在我们继续之前,我们需要了解如何估计摄像机的姿态。这是增强现实系统中的一个非常关键的步骤,如果我们想要我们的体验无缝,我们必须做对。在增强现实的世界里,我们实时地在物体上叠加图形。为了做到这一点,我们需要知道摄像机的位置和方向,而且我们需要快速做到。这就是姿态估计变得非常重要的地方。如果你不能正确跟踪姿态,叠加的图形看起来就不会自然。
考虑以下图像:

箭头线表示表面是法线。假设物体改变了其方向:

现在尽管位置相同,但方向已经改变。我们需要这个信息,以便叠加的图形看起来自然。我们需要确保它与此方向以及位置对齐。
如何追踪平面物体?
现在你已经理解了姿态估计是什么,让我们看看你如何可以使用它来追踪平面物体。让我们考虑以下平面物体:

现在如果我们从这个图像中提取特征点,我们会看到类似这样的东西:

让我们倾斜纸板:

如我们所见,在这个图像中纸板是倾斜的。现在如果我们想确保我们的虚拟物体叠加在这个表面上,我们需要收集这个平面倾斜信息。一种方法是通过使用那些特征点的相对位置。如果我们从前面的图像中提取特征点,它将看起来像这样:

如你所见,特征点在平面的远端水平上比近端更靠近。

因此,我们可以利用这些信息从图像中提取方向信息。如果你还记得,我们在讨论几何变换以及全景成像时详细讨论了透视变换。我们所需做的只是使用这两组点并提取单应性矩阵。这个单应性矩阵将告诉我们纸板是如何转动的。
考虑以下图像:

我们首先选择感兴趣的区域。

我们然后从感兴趣的区域提取特征点。由于我们正在追踪平面物体,算法假设这个感兴趣的区域是一个平面。这是显而易见的,但最好明确地说明!所以当你选择这个感兴趣的区域时,确保你手里有一张纸板。此外,如果纸板上有许多模式和独特的点,那么检测和追踪其上的特征点会更容易。
让追踪开始!我们将移动纸板来观察会发生什么:

如你所见,特征点在感兴趣区域内被追踪。让我们倾斜它并看看会发生什么:

看起来特征点被正确追踪。正如我们所见,叠加的矩形根据纸板的表面改变其方向。
这里是完成这个任务的代码:
import sys
from collections import namedtuple
import cv2
import numpy as np
class PoseEstimator(object):
def __init__(self):
# Use locality sensitive hashing algorithm
flann_params = dict(algorithm = 6, table_number = 6,
key_size = 12, multi_probe_level = 1)
self.min_matches = 10
self.cur_target = namedtuple('Current', 'image, rect, keypoints, descriptors, data')
self.tracked_target = namedtuple('Tracked', 'target, points_prev, points_cur, H, quad')
self.feature_detector = cv2.ORB(nfeatures=1000)
self.feature_matcher = cv2.FlannBasedMatcher(flann_params, {})
self.tracking_targets = []
# Function to add a new target for tracking
def add_target(self, image, rect, data=None):
x_start, y_start, x_end, y_end = rect
keypoints, descriptors = [], []
for keypoint, descriptor in zip(*self.detect_features(image)):
x, y = keypoint.pt
if x_start <= x <= x_end and y_start <= y <= y_end:
keypoints.append(keypoint)
descriptors.append(descriptor)
descriptors = np.array(descriptors, dtype='uint8')
self.feature_matcher.add([descriptors])
target = self.cur_target(image=image, rect=rect, keypoints=keypoints,
descriptors=descriptors, data=None)
self.tracking_targets.append(target)
# To get a list of detected objects
def track_target(self, frame):
self.cur_keypoints, self.cur_descriptors = self.detect_features(frame)
if len(self.cur_keypoints) < self.min_matches:
return []
matches = self.feature_matcher.knnMatch(self.cur_descriptors, k=2)
matches = [match[0] for match in matches if len(match) == 2 and
match[0].distance < match[1].distance * 0.75]
if len(matches) < self.min_matches:
return []
matches_using_index = [[] for _ in xrange(len(self.tracking_targets))]
for match in matches:
matches_using_index[match.imgIdx].append(match)
tracked = []
for image_index, matches in enumerate(matches_using_index):
if len(matches) < self.min_matches:
continue
target = self.tracking_targets[image_index]
points_prev = [target.keypoints[m.trainIdx].pt for m in matches]
points_cur = [self.cur_keypoints[m.queryIdx].pt for m in matches]
points_prev, points_cur = np.float32((points_prev, points_cur))
H, status = cv2.findHomography(points_prev, points_cur, cv2.RANSAC, 3.0)
status = status.ravel() != 0
if status.sum() < self.min_matches:
continue
points_prev, points_cur = points_prev[status], points_cur[status]
x_start, y_start, x_end, y_end = target.rect
quad = np.float32([[x_start, y_start], [x_end, y_start], [x_end, y_end], [x_start, y_end]])
quad = cv2.perspectiveTransform(quad.reshape(1, -1, 2), H).reshape(-1, 2)
track = self.tracked_target(target=target, points_prev=points_prev,
points_cur=points_cur, H=H, quad=quad)
tracked.append(track)
tracked.sort(key = lambda x: len(x.points_prev), reverse=True)
return tracked
# Detect features in the selected ROIs and return the keypoints and descriptors
def detect_features(self, frame):
keypoints, descriptors = self.feature_detector.detectAndCompute(frame, None)
if descriptors is None:
descriptors = []
return keypoints, descriptors
# Function to clear all the existing targets
def clear_targets(self):
self.feature_matcher.clear()
self.tracking_targets = []
class VideoHandler(object):
def __init__(self):
self.cap = cv2.VideoCapture(0)
self.paused = False
self.frame = None
self.pose_tracker = PoseEstimator()
cv2.namedWindow('Tracker')
self.roi_selector = ROISelector('Tracker', self.on_rect)
def on_rect(self, rect):
self.pose_tracker.add_target(self.frame, rect)
def start(self):
while True:
is_running = not self.paused and self.roi_selector.selected_rect is None
if is_running or self.frame is None:
ret, frame = self.cap.read()
scaling_factor = 0.5
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor,
interpolation=cv2.INTER_AREA)
if not ret:
break
self.frame = frame.copy()
img = self.frame.copy()
if is_running:
tracked = self.pose_tracker.track_target(self.frame)
for item in tracked:
cv2.polylines(img, [np.int32(item.quad)], True, (255, 255, 255), 2)
for (x, y) in np.int32(item.points_cur):
cv2.circle(img, (x, y), 2, (255, 255, 255))
self.roi_selector.draw_rect(img)
cv2.imshow('Tracker', img)
ch = cv2.waitKey(1)
if ch == ord(' '):
self.paused = not self.paused
if ch == ord('c'):
self.pose_tracker.clear_targets()
if ch == 27:
break
class ROISelector(object):
def __init__(self, win_name, callback_func):
self.win_name = win_name
self.callback_func = callback_func
cv2.setMouseCallback(self.win_name, self.on_mouse_event)
self.selection_start = None
self.selected_rect = None
def on_mouse_event(self, event, x, y, flags, param):
if event == cv2.EVENT_LBUTTONDOWN:
self.selection_start = (x, y)
if self.selection_start:
if flags & cv2.EVENT_FLAG_LBUTTON:
x_orig, y_orig = self.selection_start
x_start, y_start = np.minimum([x_orig, y_orig], [x, y])
x_end, y_end = np.maximum([x_orig, y_orig], [x, y])
self.selected_rect = None
if x_end > x_start and y_end > y_start:
self.selected_rect = (x_start, y_start, x_end, y_end)
else:
rect = self.selected_rect
self.selection_start = None
self.selected_rect = None
if rect:
self.callback_func(rect)
def draw_rect(self, img):
if not self.selected_rect:
return False
x_start, y_start, x_end, y_end = self.selected_rect
cv2.rectangle(img, (x_start, y_start), (x_end, y_end), (0, 255, 0), 2)
return True
if __name__ == '__main__':
VideoHandler().start()
代码内部发生了什么?
首先,我们有一个PoseEstimator类,它在这里做了所有繁重的工作。我们需要某种东西来检测图像中的特征,以及某种东西来匹配连续图像之间的特征。因此,我们使用了 ORB 特征检测器和 Flann 特征匹配器。正如我们所见,我们在构造函数中用这些参数初始化了类。
每当我们选择一个感兴趣的区域时,我们就调用add_target方法将其添加到我们的跟踪目标列表中。这个方法只是从感兴趣的区域中提取特征并存储在类的一个变量中。现在我们有了目标,我们就可以准备跟踪它了!
track_target方法处理所有的跟踪。我们取当前帧并提取所有的关键点。然而,我们并不真正对视频当前帧中的所有关键点感兴趣。我们只想找到属于我们的目标物体的关键点。所以现在,我们的任务是找到当前帧中最接近的关键点。
现在我们有了当前帧中的一组关键点,我们还从前一帧的目标物体中得到了另一组关键点。下一步是从这些匹配点中提取单应性矩阵。这个单应性矩阵告诉我们如何变换叠加的矩形,使其与纸板表面对齐。我们只需要将这个单应性矩阵应用到叠加的矩形上,以获得所有点的新的位置。
如何增强我们的现实?
既然我们已经知道了如何跟踪平面物体,让我们看看如何将 3D 物体叠加到现实世界之上。这些物体是 3D 的,但屏幕上的视频是 2D 的。所以这里的第一个步骤是理解如何将这些 3D 物体映射到 2D 表面上,使其看起来更真实。我们只需要将这些 3D 点投影到平面表面上。
从 3D 到 2D 的坐标映射
一旦我们估计了姿态,我们就将点从 3D 投影到 2D。考虑以下图像:

正如我们在这里看到的,电视遥控器是一个 3D 物体,但我们看到的是它在 2D 平面上。现在如果我们移动它,它看起来会是这样:

这个 3D 物体仍然在 2D 平面上。物体移动到了不同的位置,并且与摄像机的距离也发生了变化。我们如何计算这些坐标?我们需要一个机制将这个 3D 物体映射到 2D 表面上。这就是 3D 到 2D 投影变得非常重要的地方。
我们只需要估计初始相机姿态来开始。现在,假设相机的内在参数已经知道。因此,我们可以直接使用 OpenCV 中的 solvePnP 函数来估计相机的姿态。这个函数用于使用一组点来估计物体的姿态。你可以在docs.opencv.org/modules/calib3d/doc/camera_calibration_and_3d_reconstruction.html#bool solvePnP(InputArray objectPoints, InputArray imagePoints, InputArray cameraMatrix, InputArray distCoeffs, OutputArray rvec, OutputArray tvec, bool useExtrinsicGuess, int flags)中了解更多信息。一旦我们这样做,我们需要将这些点投影到 2D 平面上。我们使用 OpenCV 函数 projectPoints 来完成这个任务。这个函数计算那些 3D 点在 2D 平面上的投影。
如何在视频中叠加 3D 对象?
现在我们有了所有不同的模块,我们准备构建最终的系统。假设我们想在纸箱上叠加一个金字塔,就像这里展示的那样:

让我们倾斜纸箱来看看会发生什么:

看起来金字塔正在跟随表面。让我们添加第二个目标:

你可以继续添加更多目标,所有这些金字塔都将被很好地跟踪。让我们看看如何使用 OpenCV Python 来实现这一点。确保将之前的文件保存为 pose_estimation.py,因为我们将从那里导入几个类:
import cv2
import numpy as np
from pose_estimation import PoseEstimator, ROISelector
class Tracker(object):
def __init__(self):
self.cap = cv2.VideoCapture(0)
self.frame = None
self.paused = False
self.tracker = PoseEstimator()
cv2.namedWindow('Augmented Reality')
self.roi_selector = ROISelector('Augmented Reality', self.on_rect)
self.overlay_vertices = np.float32([[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0],
[0.5, 0.5, 4]])
self.overlay_edges = [(0, 1), (1, 2), (2, 3), (3, 0),
(0,4), (1,4), (2,4), (3,4)]
self.color_base = (0, 255, 0)
self.color_lines = (0, 0, 0)
def on_rect(self, rect):
self.tracker.add_target(self.frame, rect)
def start(self):
while True:
is_running = not self.paused and self.roi_selector.selected_rect is None
if is_running or self.frame is None:
ret, frame = self.cap.read()
scaling_factor = 0.5
frame = cv2.resize(frame, None, fx=scaling_factor, fy=scaling_factor,
interpolation=cv2.INTER_AREA)
if not ret:
break
self.frame = frame.copy()
img = self.frame.copy()
if is_running:
tracked = self.tracker.track_target(self.frame)
for item in tracked:
cv2.polylines(img, [np.int32(item.quad)], True, self.color_lines, 2)
for (x, y) in np.int32(item.points_cur):
cv2.circle(img, (x, y), 2, self.color_lines)
self.overlay_graphics(img, item)
self.roi_selector.draw_rect(img)
cv2.imshow('Augmented Reality', img)
ch = cv2.waitKey(1)
if ch == ord(' '):
self.paused = not self.paused
if ch == ord('c'):
self.tracker.clear_targets()
if ch == 27:
break
def overlay_graphics(self, img, tracked):
x_start, y_start, x_end, y_end = tracked.target.rect
quad_3d = np.float32([[x_start, y_start, 0], [x_end, y_start, 0],
[x_end, y_end, 0], [x_start, y_end, 0]])
h, w = img.shape[:2]
K = np.float64([[w, 0, 0.5*(w-1)],
[0, w, 0.5*(h-1)],
[0, 0, 1.0]])
dist_coef = np.zeros(4)
ret, rvec, tvec = cv2.solvePnP(quad_3d, tracked.quad, K, dist_coef)
verts = self.overlay_vertices * [(x_end-x_start), (y_end-y_start),
-(x_end-x_start)*0.3] + (x_start, y_start, 0)
verts = cv2.projectPoints(verts, rvec, tvec, K, dist_coef)[0].reshape(-1, 2)
verts_floor = np.int32(verts).reshape(-1,2)
cv2.drawContours(img, [verts_floor[:4]], -1, self.color_base, -3)
cv2.drawContours(img, [np.vstack((verts_floor[:2], verts_floor[4:5]))],
-1, (0,255,0), -3)
cv2.drawContours(img, [np.vstack((verts_floor[1:3], verts_floor[4:5]))],
-1, (255,0,0), -3)
cv2.drawContours(img, [np.vstack((verts_floor[2:4], verts_floor[4:5]))],
-1, (0,0,150), -3)
cv2.drawContours(img, [np.vstack((verts_floor[3:4], verts_floor[0:1],
verts_floor[4:5]))], -1, (255,255,0), -3)
for i, j in self.overlay_edges:
(x_start, y_start), (x_end, y_end) = verts[i], verts[j]
cv2.line(img, (int(x_start), int(y_start)), (int(x_end), int(y_end)), self.color_lines, 2)
if __name__ == '__main__':
Tracker().start()
让我们看看代码
Tracker 类用于执行这里的所有计算。我们使用通过边和顶点定义的金字塔结构初始化该类。我们用于跟踪表面的逻辑与之前讨论的相同,因为我们使用的是同一个类。我们只需要使用 solvePnP 和 projectPoints 来将 3D 金字塔映射到 2D 表面上。
让我们添加一些动作
现在我们知道了如何添加一个虚拟金字塔,让我们看看是否可以添加一些动作。让我们看看如何动态地改变金字塔的高度。当你开始时,金字塔看起来会是这样:

如果你等待一段时间,金字塔会变高,看起来会是这样:

让我们看看如何在 OpenCV Python 中实现它。在我们刚刚讨论的增强现实代码中,在 Tracker 类的 __init__ 方法末尾添加以下片段:
self.overlay_vertices = np.float32([[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0], [0.5, 0.5, 4]])
self.overlay_edges = [(0, 1), (1, 2), (2, 3), (3, 0),
(0,4), (1,4), (2,4), (3,4)]
self.color_base = (0, 255, 0)
self.color_lines = (0, 0, 0)
self.graphics_counter = 0
self.time_counter = 0
现在我们有了结构,我们需要添加代码来动态地改变高度。用以下方法替换 overlay_graphics() 方法:
def overlay_graphics(self, img, tracked):
x_start, y_start, x_end, y_end = tracked.target.rect
quad_3d = np.float32([[x_start, y_start, 0], [x_end, y_start, 0],
[x_end, y_end, 0], [x_start, y_end, 0]])
h, w = img.shape[:2]
K = np.float64([[w, 0, 0.5*(w-1)],
[0, w, 0.5*(h-1)],
[0, 0, 1.0]])
dist_coef = np.zeros(4)
ret, rvec, tvec = cv2.solvePnP(quad_3d, tracked.quad, K, dist_coef)
self.time_counter += 1
if not self.time_counter % 20:
self.graphics_counter = (self.graphics_counter + 1) % 8
self.overlay_vertices = np.float32([[0, 0, 0], [0, 1, 0], [1, 1, 0], [1, 0, 0],
[0.5, 0.5, self.graphics_counter]])
verts = self.overlay_vertices * [(x_end-x_start), (y_end-y_start),
-(x_end-x_start)*0.3] + (x_start, y_start, 0)
verts = cv2.projectPoints(verts, rvec, tvec, K, dist_coef)[0].reshape(-1, 2)
verts_floor = np.int32(verts).reshape(-1,2)
cv2.drawContours(img, [verts_floor[:4]], -1, self.color_base, -3)
cv2.drawContours(img, [np.vstack((verts_floor[:2], verts_floor[4:5]))],
-1, (0,255,0), -3)
cv2.drawContours(img, [np.vstack((verts_floor[1:3], verts_floor[4:5]))],
-1, (255,0,0), -3)
cv2.drawContours(img, [np.vstack((verts_floor[2:4], verts_floor[4:5]))],
-1, (0,0,150), -3)
cv2.drawContours(img, [np.vstack((verts_floor[3:4], verts_floor[0:1],
verts_floor[4:5]))], -1, (255,255,0), -3)
for i, j in self.overlay_edges:
(x_start, y_start), (x_end, y_end) = verts[i], verts[j]
cv2.line(img, (int(x_start), int(y_start)), (int(x_end), int(y_end)), self.color_lines, 2)
现在我们知道了如何改变高度,让我们继续让金字塔为我们跳舞。我们可以让金字塔的尖端以优雅的周期性方式振荡。所以当你开始时,它将看起来像这样:

如果你等待一段时间,它将看起来像这样:

你可以查看augmented_reality_motion.py以了解实现细节。
在我们的下一个实验中,我们将使整个金字塔围绕感兴趣的区域移动。我们可以让它以任何我们想要的方式移动。让我们先从在所选感兴趣区域添加线性对角线运动开始。当你开始时,它将看起来像这样:

经过一段时间,它将看起来像这样:

参考到augmented_reality_dancing.py来了解如何更改overlay_graphics()方法以使其跳舞。让我们看看我们能否让金字塔围绕我们的感兴趣区域旋转。当你开始时,它将看起来像这样:

经过一段时间,它将移动到新的位置:

你可以参考augmented_reality_circular_motion.py来了解如何实现这一点。你可以让它做任何你想做的事情。你只需要想出正确的数学公式,金字塔就会真的按照你的旋律跳舞!你还可以尝试其他虚拟物体,看看你能用它做什么。你可以用很多不同的物体做很多事情。这些例子提供了一个很好的参考点,在此基础上,你可以构建许多有趣的增强现实应用。
摘要
在本章中,你了解了增强现实的前提,并了解了增强现实系统看起来是什么样子。我们讨论了增强现实所需的几何变换。你学习了如何使用这些变换来估计相机姿态。你学习了如何跟踪平面物体。我们讨论了如何在现实世界之上添加虚拟物体。你学习了如何以不同的方式修改虚拟物体以添加酷炫效果。记住,计算机视觉的世界充满了无限的可能性!这本书旨在教你开始各种项目所需的必要技能。现在,取决于你和你自己的想象力,使用你在这里获得的技能来构建一些独特且有趣的东西。


浙公网安备 33010602011771号