Python-OpenCV3-计算机视觉学习指南-全-
Python OpenCV3 计算机视觉学习指南(全)
原文:
annas-archive.org/md5/a67615bbb4c21c6cbe00ed412173f8c0译者:飞龙
前言
OpenCV 3 是一个最先进的计算机视觉库,用于各种图像和视频处理操作。一些更令人瞩目的未来功能,如人脸识别或对象跟踪,使用 OpenCV 3 轻松实现。学习计算机视觉算法、模型和 OpenCV API 背后的基本概念,将能够开发各种现实世界应用,包括安全和监控工具。
从基本的图像处理操作开始,本书将带您踏上一段探索高级计算机视觉概念的旅程。计算机视觉是一个快速发展的科学,其在现实世界中的应用正在爆炸式增长,因此本书将吸引计算机视觉新手以及想要了解全新 OpenCV 3.0.0 的专家。
本书涵盖内容
第一章,设置 OpenCV,解释了如何在不同的平台上使用 Python 设置 OpenCV 3,还将解决常见问题。
第二章,处理文件、摄像头和 GUI,介绍了 OpenCV 的 I/O 功能。它还将讨论项目概念以及该项目面向对象设计的起点。
第三章,使用 OpenCV 3 处理图像,介绍了改变图像所需的一些技术,例如检测图像中的肤色、锐化图像、标记主题的轮廓以及使用线段检测器检测人行横道。
第四章,深度估计和分割,展示了如何使用深度相机的数据来识别前景和背景区域,这样我们就可以将效果限制在仅前景或背景。
第五章,检测和识别人脸,介绍了 OpenCV 的一些人脸检测功能,以及定义特定类型可跟踪对象的数据文件。
第六章,使用图像描述符检索图像和搜索,展示了如何借助 OpenCV 检测图像特征,并利用这些特征进行匹配和搜索图像。
第七章,检测和识别对象,介绍了检测和识别对象的概念,这是计算机视觉中最常见的挑战之一。
第八章,跟踪对象,探讨了对象跟踪的广泛主题,这是在摄像头的帮助下在电影或视频流中定位移动对象的过程。
第九章,使用 OpenCV 的神经网络简介,将向你介绍 OpenCV 中的人工神经网络,并展示其在实际应用中的使用。
你需要为这本书准备什么
你只需要一台相对较新的电脑,因为第一章将指导你安装所有必要的软件。强烈推荐使用网络摄像头,但不是必需的。
本书面向对象
本书面向具有 Python 实际知识背景的程序员,以及希望使用 OpenCV 库探索计算机视觉主题的人。不需要具备计算机视觉或 OpenCV 的先验经验。建议有编程经验。
术语约定
在本书中,你将找到多种文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称将如下所示:“我们可以通过使用include指令来包含其他上下文。”
代码块将如下设置:
import cv2
import numpy as np
img = cv2.imread('images/chess_board.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = np.float32(gray)
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
img = cv2.imread('images/chess_board.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = np.float32(gray)
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
任何命令行输入或输出将如下所示:
mkdir build && cd build
cmake D CMAKE_BUILD_TYPE=Release -DOPENCV_EXTRA_MODULES_PATH=<opencv_contrib>/modules D CMAKE_INSTALL_PREFIX=/usr/local ..
make
新术语和重要词汇将以粗体显示。屏幕上显示的单词,例如在菜单或对话框中,将在文本中如下所示:“在 Windows Vista / Windows 7 / Windows 8 上,点击开始菜单。”
注意
警告或重要提示将以这样的框显示。
提示
小贴士和技巧如下所示。
读者反馈
我们始终欢迎读者的反馈。告诉我们你对这本书的看法——你喜欢或不喜欢什么。读者反馈对我们来说很重要,因为它帮助我们开发出你真正能从中获得最大收益的标题。
要向我们发送一般反馈,只需发送电子邮件至 <feedback@packtpub.com>,并在邮件主题中提及本书的标题。
如果你在某个领域有专业知识,并且对撰写或参与书籍的编写感兴趣,请参阅我们的作者指南,网址为 www.packtpub.com/authors。
客户支持
现在,你已经是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助你从购买中获得最大收益。
下载示例代码
你可以从你的账户中下载示例代码文件,网址为 www.packtpub.com,适用于你购买的所有 Packt 出版图书。如果你在其他地方购买了这本书,你可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给你。
错误清单
尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在我们的书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。
侵权
互联网上版权材料的侵权是一个持续存在的问题,跨越所有媒体。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现任何形式的非法复制我们的作品,请立即提供位置地址或网站名称,以便我们可以寻求补救措施。
请通过<copyright@packtpub.com>与我们联系,并提供涉嫌侵权材料的链接。
我们感谢您在保护我们作者和我们为您提供有价值内容的能力方面的帮助。
询问
如果您在这本书的任何方面遇到问题,您可以通过<questions@packtpub.com>联系我们,我们将尽力解决问题。
第一章。设置 OpenCV
你选择这本书,可能已经对 OpenCV 有了一定的了解。也许,你听说过一些科幻般的功能,比如人脸检测,并对此产生了兴趣。如果是这样,你做出了完美的选择。OpenCV 代表 开源计算机视觉。它是一个免费的计算机视觉库,允许你操纵图像和视频,以完成各种任务,从显示网络摄像头的视频流到可能教会机器人识别现实生活中的物体。
在这本书中,你将学习如何利用 Python 编程语言充分发挥 OpenCV 的巨大潜力。Python 是一种优雅的语言,学习曲线相对平缓,功能非常强大。本章是快速设置 Python 2.7、OpenCV 以及其他相关库的指南。设置完成后,我们还将查看 OpenCV 的 Python 示例脚本和文档。
注意
如果你希望跳过安装过程,直接进入操作,你可以下载我在 techfort.github.io/pycv/ 提供的 虚拟机(VM)。
此文件与 VirtualBox 兼容,这是一个免费使用的虚拟化应用程序,允许你构建和运行虚拟机。我构建的虚拟机基于 Ubuntu Linux 14.04,并安装了所有必要的软件,以便你可以立即开始编码。
这个虚拟机需要至少 2 GB 的 RAM 才能平稳运行,所以请确保为虚拟机分配至少 2 GB(但理想情况下,超过 4 GB)的 RAM,这意味着你的主机机器至少需要 6 GB 的 RAM 才能维持其运行。
本章涵盖了以下相关库:
-
NumPy:这个库是 OpenCV Python 绑定的依赖项。它提供了包括高效数组在内的数值计算功能。
-
SciPy:这个库是一个与 NumPy 密切相关的科学计算库。它不是 OpenCV 所必需的,但它在操纵 OpenCV 图像中的数据时很有用。
-
OpenNI:这个库是 OpenCV 的可选依赖项。它增加了对某些深度相机(如华硕 XtionPRO)的支持。
-
SensorKinect:这个库是一个 OpenNI 插件,也是 OpenCV 的可选依赖项。它增加了对微软 Kinect 深度相机的支持。
对于本书的目的,OpenNI 和 SensorKinect 可以被认为是可选的。它们在 第四章 中使用,深度估计和分割,但在其他章节或附录中并未使用。
注意
本书专注于 OpenCV 3,这是 OpenCV 库的新版主要发布。有关 OpenCV 的所有附加信息可在 opencv.org 获取,其文档可在 docs.opencv.org/master 获取。
选择和使用正确的设置工具
我们可以根据我们的操作系统和想要进行多少配置来选择各种设置工具。让我们概述一下 Windows、Mac、Ubuntu 和其他类 Unix 系统的工具。
Windows 上的安装
Windows 没有预装 Python。然而,有预编译的 Python、NumPy、SciPy 和 OpenCV 的安装向导可用。或者,我们可以从源代码构建。OpenCV 的构建系统使用 CMake 进行配置,并使用 Visual Studio 或 MinGW 进行编译。
如果我们想要支持深度相机,包括 Kinect,我们首先应该安装 OpenNI 和 SensorKinect,它们作为预编译的二进制文件和安装向导提供。然后,我们必须从源代码构建 OpenCV。
注意
预编译版的 OpenCV 不支持深度相机。
在 Windows 上,OpenCV 2 对 32 位 Python 的支持优于 64 位 Python;然而,由于今天大多数销售的计算机都是 64 位系统,我们的说明将参考 64 位。所有安装程序都有 32 位版本,可以从与 64 位相同的网站下载。
以下步骤中的一些涉及编辑系统的PATH变量。这项任务可以在控制面板的环境变量窗口中完成。
-
在 Windows Vista / Windows 7 / Windows 8 上,点击开始菜单并启动控制面板。现在,导航到系统和安全 | 系统 | 高级系统设置。点击环境变量…按钮。
-
在 Windows XP 上,点击开始菜单并导航到控制面板 | 系统。选择高级选项卡。点击环境变量…按钮。
-
现在,在系统变量下,选择Path并点击编辑…按钮。
-
按指示进行更改。
-
要应用更改,点击所有确定按钮(直到我们回到控制面板的主窗口)。
-
然后,注销并重新登录(或者重新启动)。
使用二进制安装程序(不支持深度相机)
如果您愿意,可以选择单独安装 Python 及其相关库;然而,有一些 Python 发行版包含安装程序,可以设置整个 SciPy 堆栈(包括 Python 和 NumPy),这使得设置开发环境变得非常简单。
其中一个发行版是 Anaconda Python(可在09c8d0b2229f813c1b93c95ac804525aac4b6dba79b00b39d1d3.r79.cf1.rackcdn.com/Anaconda-2.1.0Windows-x86_64.exe下载)。一旦下载了安装程序,运行它并记得按照前面的步骤将 Anaconda 安装路径添加到您的PATH变量中。
这里是设置 Python7、NumPy、SciPy 和 OpenCV 的步骤:
-
从
www.python.org/ftp/python/2.7.9/python-2.7.9.amd64.msi下载并安装 32 位 Python 2.7.9。 -
从
www.lfd.uci.edu/~gohlke/pythonlibs/#numpyhttp://sourceforge.net/projects/numpy/files/NumPy/1.6.2/numpy-1.6.2-win32-superpack-python2.7.exe/download下载并安装 NumPy 1.6.2(注意,由于 Windows 上缺少 NumPy 所依赖的 64 位 Fortran 编译器,在 Windows 64 位上安装 NumPy 有点棘手。前一个链接中的二进制文件是非官方的)。 -
从
www.lfd.uci.edu/~gohlke/pythonlibs/#scipyhttp://sourceforge.net/projects/scipy/files/scipy/0.11.0/scipy-0.11.0win32-superpack-python2.7.exe/download下载并安装 SciPy 11.0(这与 NumPy 相同,这些都是社区安装程序)。 -
从
github.com/Itseez/opencv下载 OpenCV 3.0.0 的自解压 ZIP 文件。运行此 ZIP 文件,并在提示时输入目标文件夹,我们将称之为<unzip_destination>。将创建一个子文件夹<unzip_destination>\opencv。 -
将
<unzip_destination>\opencv\build\python\2.7\cv2.pyd复制到C:\Python2.7\Lib\site-packages(假设我们将 Python 2.7 安装在默认位置)。如果您使用 Anaconda 安装了 Python 2.7,请使用 Anaconda 安装文件夹而不是默认的 Python 安装。现在,新的 Python 安装可以找到 OpenCV。 -
如果我们想要默认使用新的 Python 安装运行 Python 脚本,则需要执行一个最终步骤。编辑系统的
PATH变量,并追加;C:\Python2.7(假设我们将 Python 2.7 安装在默认位置)或您的 Anaconda 安装文件夹。删除任何以前的 Python 路径,例如;C:\Python2.6。注销并重新登录(或者重新启动)。
使用 CMake 和编译器
Windows 不自带任何编译器或 CMake。我们需要安装它们。如果我们想要支持包括 Kinect 在内的深度相机,我们还需要安装 OpenNI 和 SensorKinect。
假设我们已经通过二进制文件(如前所述)或源代码安装了 32 位 Python 2.7、NumPy 和 SciPy。现在,我们可以继续安装编译器和 CMake,可选地安装 OpenNI 和 SensorKinect,然后从源代码构建 OpenCV:
-
从
www.cmake.org/files/v3.1/cmake-3.1.2-win32-x86.exe下载并安装 CMake 3.1.2。在运行安装程序时,选择“将 CMake 添加到系统 PATH 以供所有用户使用”或“将 CMake 添加到当前用户的系统 PATH”。不用担心没有 64 位版本的 CMake,因为 CMake 只是一个配置工具,它本身不执行任何编译。相反,在 Windows 上,它创建可以与 Visual Studio 打开的工程文件。 -
从
www.visualstudio.com/products/free-developer-offers-vs.aspx?slcid=0x409&type=web or MinGW下载并安装 Microsoft Visual Studio 2013(如果您在 Windows 7 上工作,请选择桌面版)。注意,您需要使用您的 Microsoft 账户进行登录,如果您没有,您可以在现场创建一个。安装软件,安装完成后重新启动。
对于 MinGW,从
sourceforge.net/projects/mingw/files/Installer/mingw-get-setup.exe/download和sourceforge.net/projects/mingw/files/OldFiles/mingw-get-inst/mingw-get-inst-20120426/mingw-get-inst-20120426.exe/download获取安装程序。在运行安装程序时,确保目标路径不包含空格,并且包含可选的 C++ 编译器。编辑系统的PATH变量,并追加;C:\MinGW\bin(假设 MinGW 安装在默认位置)。重新启动系统。 -
可选,从 OpenNI 在 GitHub 主页提供的链接中下载并安装 OpenNI 1.5.4.0。
github.com/OpenNI/OpenNI。 -
您可以从
github.com/avin2/SensorKinect/blob/unstable/Bin/SensorKinect093-Bin-Win32-v5.1.2.1.msi?raw=true(32 位)下载并安装 SensorKinect 0.93。对于 64 位 Python,从github.com/avin2/SensorKinect/blob/unstable/Bin/SensorKinect093-Bin-Win64-v5.1.2.1.msi?raw=true(64 位)下载设置文件。请注意,这个存储库已经停用超过三年了。 -
从
github.com/Itseez/opencv下载 OpenCV 3.0.0 的自解压 ZIP 文件。运行自解压 ZIP 文件,当提示时,输入任何目标文件夹,我们将称之为<unzip_destination>。然后创建一个子文件夹,<unzip_destination>\opencv。 -
打开命令提示符,使用以下命令创建一个新文件夹,我们的构建将放在那里:
> mkdir<build_folder>更改
build文件夹的目录:> cd <build_folder> -
现在,我们已经准备好配置我们的构建。为了理解所有选项,我们可以阅读
<unzip_destination>\opencv\CMakeLists.txt中的代码。然而,出于本书的目的,我们只需要使用那些将为我们提供带有 Python 绑定的发布构建的选项,以及可选的通过 OpenNI 和 SensorKinect 的深度相机支持。 -
打开 CMake(
cmake-gui)并指定 OpenCV 源代码的位置以及您希望构建库的文件夹。点击配置。选择要生成的项目。在这种情况下,选择 Visual Studio 12(对应于 Visual Studio 2013)。CMake 完成项目配置后,将输出一个构建选项列表。如果您看到红色背景,这意味着可能需要重新配置项目:CMake 可能会报告它未能找到某些依赖项。OpenCV 的许多依赖项是可选的,所以现在不必过于担心。注意
如果构建未完成或您遇到问题,请尝试安装缺失的依赖项(通常作为预构建的二进制文件提供),然后从这一步重新构建 OpenCV。
您可以选择/取消选择构建选项(根据您在机器上安装的库),然后再次点击配置,直到获得清晰的背景(白色)。
-
在此过程结束时,您可以点击生成,这将创建一个
OpenCV.sln文件在您选择的构建文件夹中。然后,您可以导航到<build_folder>/OpenCV.sln并使用 Visual Studio 2013 打开该文件,然后继续构建项目,ALL_BUILD。您需要构建 OpenCV 的调试和发布版本,因此请先以调试模式构建库,然后选择发布并重新构建它(F7是启动构建的键)。 -
在此阶段,您将在 OpenCV 构建目录中有一个
bin文件夹,其中将包含所有生成的.dll文件,这将允许您将 OpenCV 包含到您的项目中。或者,对于 MinGW,运行以下命令:
> cmake -D:CMAKE_BUILD_TYPE=RELEASE -D:WITH_OPENNI=ON -G "MinGWMakefiles" <unzip_destination>\opencv如果未安装 OpenNI,则省略
-D:WITH_OPENNI=ON。(在这种情况下,将不支持深度相机。)如果 OpenNI 和 SensorKinect 安装到非默认位置,请修改命令以包含-D:OPENNI_LIB_DIR=<openni_install_destination>\Lib -D:OPENNI_INCLUDE_DIR=<openni_install_destination>\Include -D:OPENNI_PRIME_SENSOR_MODULE_BIN_DIR=<sensorkinect_install_destination>\Sensor\Bin。或者,对于 MinGW,运行以下命令:
> mingw32-make -
将
<build_folder>\lib\Release\cv2.pyd(来自 Visual Studio 构建)或<build_folder>\lib\cv2.pyd(来自 MinGW 构建)复制到<python_installation_folder>\site-packages。 -
最后,编辑系统的
PATH变量,并追加;<build_folder>/bin/Release(对于 Visual Studio 构建)或;<build_folder>/bin(对于 MinGW 构建)。重新启动您的系统。
在 OS X 上安装
以前的一些 Mac 版本预装了由 Apple 定制的 Python 2.7 版本,用于满足系统的内部需求。然而,这种情况已经改变,标准的 OS X 版本现在都带有标准的 Python 安装。在 python.org 上,您还可以找到与新的 Intel 系统和旧 PowerPC 兼容的通用二进制文件。
注意
您可以从 www.python.org/downloads/release/python-279/ 获取此安装程序(参考 Mac OS X 32 位 PPC 或 Mac OS X 64 位 Intel 链接)。从下载的 .dmg 文件安装 Python 将简单地覆盖您当前系统上的 Python 安装。
对于 Mac,获取标准 Python 2.7、NumPy、SciPy 和 OpenCV 有几种可能的方法。所有方法最终都需要使用 Xcode 开发者工具从源代码编译 OpenCV。然而,根据方法的不同,这项任务可以通过第三方工具以各种方式自动化。我们将通过使用 MacPorts 或 Homebrew 来查看这些方法。这些工具可以执行 CMake 可以执行的所有操作,并且帮助我们解决依赖关系,并将我们的开发库与系统库分开。
提示
我推荐使用 MacPorts,尤其是如果您想通过 OpenNI 和 SensorKinect 编译具有深度相机支持的 OpenCV。相关的补丁和构建脚本,包括我维护的一些,已经为 MacPorts 准备好。相比之下,Homebrew 目前还没有提供编译具有深度相机支持的 OpenCV 的现成解决方案。
在继续之前,让我们确保 Xcode 开发者工具已正确设置:
-
从 Mac App Store 或
developer.apple.com/xcode/downloads/下载并安装 Xcode。在安装过程中,如果有安装 命令行工具 的选项,请选择它。 -
打开 Xcode 并接受许可协议。
-
如果安装程序没有提供安装 命令行工具 的选项,则需要执行最后一步。导航到 Xcode | 首选项 | 下载,然后点击 命令行工具 旁边的 安装 按钮。等待安装完成并退出 Xcode。
或者,您可以通过运行以下命令(在终端中)来安装 Xcode 命令行工具:
$ xcode-select –install
现在,我们有了任何方法所需的编译器。
使用带有现成软件包的 MacPorts
我们可以使用 MacPorts 软件包管理器帮助我们设置 Python 2.7、NumPy 和 OpenCV。MacPorts 提供了终端命令,可以自动化下载、编译和安装各种开源软件(OSS)。MacPorts 还会根据需要安装依赖项。对于每件软件,依赖项和构建配方都定义在一个名为 Portfile 的配置文件中。MacPorts 存储库是 Portfiles 的集合。
从已经设置好 Xcode 及其命令行工具的系统开始,以下步骤将使用 MacPorts 为我们提供 OpenCV 安装:
-
从
www.macports.org/install.php下载并安装 MacPorts。 -
如果您想支持 Kinect 深度相机,您需要告诉 MacPorts 下载我编写的自定义 Portfiles 的位置。为此,编辑
/opt/local/etc/macports/sources.conf(假设 MacPorts 安装在默认位置)。在以下行rsync://rsync.macports.org/release/ports/ [default]之上添加以下行:http://nummist.com/opencv/ports.tar.gz保存文件。现在,MacPorts 知道它必须首先在我的在线仓库中搜索 Portfiles,然后是默认在线仓库。
-
打开终端并运行以下命令来更新 MacPorts:
$ sudo port selfupdate当提示时,输入您的密码。
-
现在(如果我们使用我的仓库),运行以下命令来安装具有 Python 2.7 绑定和深度相机支持的 OpenCV,包括 Kinect:
$ sudo port install opencv +python27 +openni_sensorkinect或者(无论是否使用我的仓库),运行以下命令来安装具有 Python 2.7 绑定和深度相机支持的 OpenCV,不包括 Kinect:
$ sudo port install opencv +python27 +openni注意
依赖项,包括 Python 2.7、NumPy、OpenNI 和(在第一个示例中)SensorKinect,也将自动安装。
通过在命令中添加
+python27,我们指定我们想要具有 Python 2.7 绑定的opencv变体(构建配置)。同样,+openni_sensorkinect指定具有通过 OpenNI 和 SensorKinect 提供的最广泛支持的深度相机变体。如果您不打算使用深度相机,可以省略+openni_sensorkinect,或者如果您打算使用与 OpenNI 兼容的深度相机但不是 Kinect,可以将其替换为+openni。在安装之前,我们可以输入以下命令来查看所有可用变体的完整列表:$ port variants opencv根据我们的定制需求,我们可以在
install命令中添加其他变体。为了获得更大的灵活性,我们可以编写自己的变体(如下一节所述)。 -
此外,运行以下命令来安装 SciPy:
$ sudo port install py27-scipy -
Python 安装的执行文件名为
python2.7。如果我们想将默认的python可执行文件链接到python2.7,请运行此命令:$ sudo port install python_select $ sudo port select python python27
使用 MacPorts 和您自己的自定义软件包
通过几个额外的步骤,我们可以更改 MacPorts 编译 OpenCV 或其他软件的方式。如前所述,MacPorts 的构建配方定义在名为 Portfiles 的配置文件中。通过创建或编辑 Portfiles,我们可以访问高度可配置的构建工具,如 CMake,同时还能享受 MacPorts 的功能,如依赖关系解析。
假设我们已安装 MacPorts。现在,我们可以配置 MacPorts 以使用我们编写的自定义 Portfiles:
-
在某个位置创建一个文件夹来存放我们的自定义 Portfiles。我们将把这个文件夹称为
<local_repository>。 -
编辑
/opt/local/etc/macports/sources.conf文件(假设 MacPorts 安装到默认位置)。在rsync://rsync.macports.org/release/ports/ [default]行上方,添加此行:file://<local_repository>例如,如果
<local_repository>是/Users/Joe/Portfiles,请添加以下行:file:///Users/Joe/Portfiles注意三重斜杠并保存文件。现在,MacPorts 知道它必须首先在
<local_repository>中搜索 Portfiles,然后是其默认在线仓库。 -
打开终端并更新 MacPorts 以确保我们拥有默认仓库的最新 Portfile:
$ sudo port selfupdate -
以默认仓库的
opencvPortfile 为例,让我们也复制目录结构,这决定了包在 MacPorts 中的分类方式:$ mkdir <local_repository>/graphics/ $ cp /opt/local/var/macports/sources/rsync.macports.org/release/ports/graphics/opencv <local_repository>/graphics或者,对于包含 Kinect 支持的示例,我们可以从
nummist.com/opencv/ports.tar.gz下载我的在线仓库,解压它,并将整个graphics文件夹复制到<local_repository>:$ cp <unzip_destination>/graphics <local_repository> -
编辑
<local_repository>/graphics/opencv/Portfile。注意,此文件指定了 CMake 配置标志、依赖项和变体。有关 Portfile 编辑的详细信息,请参阅guide.macports.org/#development。要查看与 OpenCV 相关的 CMake 配置标志,我们需要查看其源代码。从
github.com/Itseez/opencv/archive/3.0.0.zip下载源代码存档,将其解压到任何位置,并阅读<unzip_destination>/OpenCV-3.0.0/CMakeLists.txt。在对 Portfile 进行任何编辑后,请保存它。
-
现在,我们需要在本地仓库中生成一个索引文件,以便 MacPorts 可以找到新的 Portfile:
$ cd <local_repository> $ portindex -
从现在起,我们可以将我们的自定义
opencv文件视为任何其他 MacPorts 包。例如,我们可以按照以下方式安装它:$ sudo port install opencv +python27 +openni_sensorkinect注意,由于它们在
/opt/local/etc/macports/sources.conf中的列表顺序,我们的本地仓库的 Portfile 优先于默认仓库的 Portfile。
使用带有预装包的 Homebrew(不支持深度相机)
Homebrew 是另一个可以帮助我们的包管理器。通常,MacPorts 和 Homebrew 不应安装在同一台机器上。
从已经设置好 Xcode 及其命令行工具的系统开始,以下步骤将通过 Homebrew 为我们提供 OpenCV 安装:
-
打开终端并运行以下命令以安装 Homebrew:
$ ruby -e "$(curl -fsSkLraw.github.com/mxcl/homebrew/go)" -
与 MacPorts 不同,Homebrew 不会自动将其可执行文件放入
PATH。要这样做,创建或编辑~/.profile文件,并在代码顶部添加此行:export PATH=/usr/local/bin:/usr/local/sbin:$PATH保存文件并运行以下命令以刷新
PATH:$ source ~/.profile注意,现在由 Homebrew 安装的可执行文件优先于由系统安装的可执行文件。
-
要运行 Homebrew 的自我诊断报告,请运行以下命令:
$ brew doctor遵循它提供的任何故障排除建议。
-
现在,更新 Homebrew:
$ brew update -
运行以下命令安装 Python 2.7:
$ brew install python -
现在,我们可以安装 NumPy。Homebrew 对 Python 库包的选择有限,所以我们使用一个名为
pip的单独的包管理工具,它包含在 Homebrew 的 Python 中:$ pip install numpy -
SciPy 包含一些 Fortran 代码,因此我们需要一个合适的编译器。我们可以使用 Homebrew 来安装
gfortran编译器:$ brew install gfortran现在,我们可以安装 SciPy:
$ pip install scipy -
要在 64 位系统上安装 OpenCV(自 2006 年底以来所有新的 Mac 硬件),请运行以下命令:
$ brew install opencv
小贴士
下载示例代码
您可以从您在www.packtpub.com的账户中下载您购买的所有 Packt Publishing 书籍的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。
使用 Homebrew 和自定义包
Homebrew 使得编辑现有的包定义变得容易:
$ brew edit opencv
包定义实际上是 Ruby 编程语言中的脚本。有关编辑它们的提示,可以在 Homebrew Wiki 页面github.com/mxcl/homebrew/wiki/Formula-Cookbook上找到。脚本可以指定 Make 或 CMake 配置标志,以及其他内容。
要查看哪些 CMake 配置标志与 OpenCV 相关,我们需要查看其源代码。从github.com/Itseez/opencv/archive/3.0.0.zip下载源代码存档,将其解压缩到任何位置,并阅读<unzip_destination>/OpenCV-2.4.3/CMakeLists.txt。
在对 Ruby 脚本进行编辑后,保存它。
定制的包可以像普通包一样处理。例如,它可以按照以下方式安装:
$ brew install opencv
在 Ubuntu 及其衍生版上安装
首先最重要的是,这里有一个关于 Ubuntu 操作系统版本的快速说明:Ubuntu 有一个 6 个月的发布周期,其中每个发布都是主要版本(撰写本文时为 14)的.04 或.10 小版本。然而,每两年,Ubuntu 会发布一个被归类为长期支持(LTS)的版本,这将通过 Canonical(Ubuntu 背后的公司)为您提供五年的支持。如果您在企业环境中工作,安装 LTS 版本无疑是明智的。目前可用的最新版本是 14.04。
Ubuntu 预装了 Python 2.7。标准的 Ubuntu 仓库包含没有深度相机支持的 OpenCV 2.4.9 包。在撰写本文时,OpenCV 3 尚未通过 Ubuntu 仓库提供,因此我们必须从源代码构建它。幸运的是,大多数 Unix-like 和 Linux 系统已经预装了所有必要的软件,可以从头开始构建项目。从源代码构建时,OpenCV 可以通过 OpenNI 和 SensorKinect 支持深度相机,它们作为预编译的二进制文件和安装脚本提供。
使用 Ubuntu 仓库(不支持深度相机)
我们可以使用apt包管理器通过运行以下命令安装 Python 及其所有必要的依赖项:
> sudo apt-get install build-essential
> sudo apt-get install cmake git libgtk2.0-dev pkg-config libavcodecdev libavformat-dev libswscale-dev
> sudo apt-get install python-dev python-numpy libtbb2 libtbb-dev libjpeg-dev libpng-dev libtiff-dev libjasper-dev libdc1394-22-dev
同样,我们也可以使用 Ubuntu 软件中心,它是apt包管理器的图形前端。
从源代码构建 OpenCV
现在我们已经安装了整个 Python 栈和cmake,我们可以构建 OpenCV。首先,我们需要从github.com/Itseez/opencv/archive/3.0.0-beta.zip下载源代码。
在终端中解压归档并将其移动到解压文件夹中。
然后,运行以下命令:
> mkdir build
> cd build
> cmake -D CMAKE_BUILD_TYPE=Release -D CMAKE_INSTALL_PREFIX=/usr/local ..
> make
> make install
安装完成后,您可能想查看 OpenCV 的 Python 示例,位于<opencv_folder>/opencv/samples/python和<script_folder>/opencv/samples/python2。
在其他类 Unix 系统上的安装
对于 Ubuntu(如前所述)的方法可能适用于任何从 Ubuntu 14.04 LTS 或 Ubuntu 14.10 衍生出来的 Linux 发行版,如下所示:
-
Kubuntu 14.04 LTS 或 Kubuntu 14.10
-
Xubuntu 14.04 LTS 或 Xubuntu 14.10
-
Linux Mint 17
在 Debian Linux 及其衍生版本中,apt包管理器的工作方式与 Ubuntu 相同,尽管可用的包可能不同。
在 Gentoo Linux 及其衍生版本中,Portage 包管理器与 MacPorts(如前所述)类似,尽管可用的包可能不同。
在 FreeBSD 衍生版本上,安装过程再次类似于 MacPorts;实际上,MacPorts 源自 FreeBSD 采用的ports安装系统。请参考出色的 FreeBSD 手册www.freebsd.org/doc/handbook/以了解软件安装过程的概述。
在其他类 Unix 系统中,包管理器和可用的包可能不同。请查阅您的包管理器文档,并搜索名称中包含 opencv 的包。请记住,OpenCV 及其 Python 绑定可能被拆分为多个包。
此外,寻找系统提供商、仓库维护者或社区发布的任何安装说明。由于 OpenCV 使用相机驱动程序和媒体编解码器,在多媒体支持较差的系统上,使所有功能正常工作可能很棘手。在某些情况下,可能需要重新配置或重新安装系统包以实现兼容性。
如果有 OpenCV 的包可用,请检查它们的版本号。本书建议使用 OpenCV 3 或更高版本。此外,请检查这些包是否提供 Python 绑定和通过 OpenNI 和 SensorKinect 的深度相机支持。最后,检查开发者社区中是否有人报告了使用这些包的成功或失败情况。
如果我们想从源代码自定义构建 OpenCV,可能有助于参考之前讨论的 Ubuntu 安装脚本,并将其适应到另一个系统上的包管理器和包。
安装 Contrib 模块
与 OpenCV 2.4 不同,一些模块包含在名为opencv_contrib的仓库中,该仓库可在github.com/Itseez/opencv_contrib找到。我强烈建议安装这些模块,因为它们包含 OpenCV 中没有的额外功能,例如人脸识别模块。
下载完成后(无论是通过zip还是git,我推荐使用git,这样您可以通过简单的git pull命令保持更新),您可以重新运行cmake命令,包括构建带有opencv_contrib模块的 OpenCV,如下所示:
cmake -DOPENCV_EXTRA_MODULES_PATH=<opencv_contrib>/modules <opencv_source_directory>
因此,如果您已遵循标准程序并在 OpenCV 下载文件夹中创建了一个构建目录,您应该运行以下命令:
mkdir build && cd build
cmake -D CMAKE_BUILD_TYPE=Release -DOPENCV_EXTRA_MODULES_PATH=<opencv_contrib>/modules -D CMAKE_INSTALL_PREFIX=/usr/local ..
make
运行示例
运行几个示例脚本是测试 OpenCV 是否正确设置的好方法。这些示例包含在 OpenCV 的源代码存档中。
在 Windows 上,我们应该已经下载并解压了 OpenCV 的自解压 ZIP 文件。在<unzip_destination>/opencv/samples中找到示例。
在 Unix-like 系统上,包括 Mac,从github.com/Itseez/opencv/archive/3.0.0.zip下载源代码存档,并将其解压到任何位置(如果我们还没有这样做)。在<unzip_destination>/OpenCV-3.0.0/samples中找到示例。
一些示例脚本需要命令行参数。然而,以下脚本(以及其他一些脚本)可以在没有任何参数的情况下运行:
-
python/camera.py:此脚本显示一个网络摄像头流(假设已经插入了网络摄像头)。 -
python/drawing.py:此脚本绘制一系列形状,例如屏幕保护程序。 -
python2/hist.py:此脚本显示一张照片。按A、B、C、D或E查看照片的变体以及相应的颜色或灰度值直方图。 -
python2/opt_flow.py(Ubuntu 包中缺失):此脚本显示带有叠加光流可视化(例如运动方向)的网络摄像头流。例如,慢慢在摄像头前挥手以查看效果。按1或2进行不同的可视化。
要退出脚本,请按Esc(不是窗口的关闭按钮)。
如果我们遇到ImportError: No module named 'cv2.cv'的消息,那么这意味着我们正在从不知道 OpenCV 的 Python 安装中运行脚本。这种情况有两个可能的解释:
-
OpenCV 安装过程中可能有一些步骤失败或被遗漏。返回并检查这些步骤。
-
如果机器上有多个 Python 安装,我们可能使用了错误的 Python 版本来启动脚本。例如,在 Mac 上,可能的情况是 OpenCV 是为 MacPorts Python 安装的,但我们使用的是系统的 Python 来运行脚本。返回并回顾有关编辑系统路径的安装步骤。此外,尝试使用如下命令手动从命令行启动脚本:
$ python python/camera.py您还可以使用以下命令:
$ python2.7 python/camera.py作为选择不同 Python 安装的另一种可能方法,尝试编辑示例脚本以删除
#!行。这些行可能明确地将脚本与错误的 Python 安装关联起来(针对我们的特定设置)。
查找文档、帮助和更新
OpenCV 的文档可以在网上找到,网址为docs.opencv.org/。该文档包括 OpenCV 新 C++ API、新 Python API(基于 C++ API)、旧 C API 及其旧 Python API(基于 C API)的综合 API 参考。在查找类或函数时,请务必阅读有关新 Python API(cv2模块)的部分,而不是旧 Python API(cv模块)的部分。
该文档还可用作几个可下载的 PDF 文件:
-
API 参考:此文档可在
docs.opencv.org/modules/refman.html找到 -
教程:这些文档可在
docs.opencv.org/doc/tutorials/tutorials.html找到(这些教程使用 C++代码;教程代码的 Python 版本可在阿比德·拉赫曼·K.的仓库goo.gl/EPsD1找到)
如果您在飞机或其他没有互联网接入的地方编写代码,您肯定希望保留文档的离线副本。
如果文档似乎没有回答您的问题,请尝试与 OpenCV 社区交流。以下是一些您可以找到有帮助人士的网站:
-
OpenCV 论坛:
www.answers.opencv.org/questions/ -
大卫·米兰·埃斯克里瓦的博客(本书的审稿人之一):
blog.damiles.com/ -
阿比德·拉赫曼·K.的博客(本书的审稿人之一):
www.opencvpython.blogspot.com/ -
阿德里安·罗斯布鲁克的网站(本书的审稿人之一):
www.pyimagesearch.com/ -
乔·米尼奇诺为此书的网站(本书的作者):
techfort.github.io/pycv/ -
乔·豪斯为此书的网站(本书第一版的作者):
nummist.com/opencv/
最后,如果你是一位希望尝试最新(不稳定)OpenCV 源代码中的新功能、错误修复和示例脚本的进阶用户,请查看项目的仓库:github.com/Itseez/opencv/。
摘要
到目前为止,我们应该已经安装了一个可以完成本书中描述的项目所需所有功能的 OpenCV。根据我们采取的方法,我们可能还拥有一套可用的工具和脚本,可用于重新配置和重建 OpenCV 以满足我们未来的需求。
我们知道在哪里可以找到 OpenCV 的 Python 示例。这些示例涵盖了本书范围之外的不同功能范围,但它们作为额外的学习辅助工具是有用的。
在下一章中,我们将熟悉 OpenCV API 的最基本功能,即显示图像、视频,通过摄像头捕获视频,以及处理基本的键盘和鼠标输入。
第二章:处理文件、摄像头和 GUI
安装 OpenCV 并运行示例很有趣,但在这个阶段,我们想亲自尝试。本章介绍了 OpenCV 的 I/O 功能。我们还讨论了项目概念以及这个项目的面向对象设计的开始,我们将在随后的章节中详细阐述。
通过从查看 I/O 能力和设计模式开始,我们将以制作三明治的方式构建我们的项目:从外到内。面包切片和涂抹,或者端点和胶水,在填充或算法之前。我们选择这种方法是因为计算机视觉主要是外向的——它考虑的是我们计算机之外的现实世界——我们希望通过一个公共接口将我们后续的所有算法工作应用到现实世界中。
基本 I/O 脚本
大多数 CV 应用程序需要获取图像作为输入。大多数也会生成图像作为输出。一个交互式 CV 应用程序可能需要一个摄像头作为输入源和一个窗口作为输出目标。然而,其他可能的源和目标包括图像文件、视频文件和原始字节。例如,原始字节可能通过网络连接传输,或者如果我们将过程图形纳入我们的应用程序,它们可能由算法生成。让我们看看这些可能性中的每一个。
读取/写入图像文件
OpenCV 提供了imread()和imwrite()函数,支持各种静态图像的文件格式。支持的格式因系统而异,但应始终包括 BMP 格式。通常,PNG、JPEG 和 TIFF 也应包括在支持的格式中。
让我们探索在 Python 和 NumPy 中图像表示的结构。
不论格式如何,每个像素都有一个值,但区别在于像素的表示方式。例如,我们可以通过简单地创建一个 2D NumPy 数组从头开始创建一个黑色方形图像:
img = numpy.zeros((3,3), dtype=numpy.uint8)
如果我们将此图像打印到控制台,我们将获得以下结果:
array([[0, 0, 0],
[0, 0, 0],
[0, 0, 0]], dtype=uint8)
每个像素由一个单一的 8 位整数表示,这意味着每个像素的值在 0-255 范围内。
现在让我们使用cv2.cvtColor将此图像转换为蓝绿红(BGR):
img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
让我们观察图像是如何变化的:
array([[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]],
[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]],
[[0, 0, 0],
[0, 0, 0],
[0, 0, 0]]], dtype=uint8)
如您所见,每个像素现在由一个包含三个元素的数组表示,其中每个整数分别代表 B、G 和 R 通道。其他颜色空间,如 HSV,将以相同的方式表示,尽管值范围不同(例如,HSV 颜色空间的色调值范围为 0-180)以及通道数量不同。
您可以通过检查shape属性来检查图像的结构,该属性返回行、列和通道数(如果有多个通道)。
考虑以下示例:
>>> img = numpy.zeros((3,3), dtype=numpy.uint8)
>>> img.shape
上述代码将打印(3,3)。如果您然后将图像转换为 BGR,形状将是(3,3,3),这表明每个像素有三个通道。
图像可以从一种文件格式加载并保存到另一种格式。例如,让我们将图像从 PNG 转换为 JPEG:
import cv2
image = cv2.imread('MyPic.png')
cv2.imwrite('MyPic.jpg', image)
注意
我们使用的 OpenCV 功能大多位于 cv2 模块中。你可能会遇到其他依赖 cv 或 cv2.cv 模块的 OpenCV 指南,这些是旧版本。Python 模块被称为 cv2 并不是因为它是 OpenCV 2.x.x 的 Python 绑定模块,而是因为它引入了一个更好的 API,它利用面向对象编程,而不是之前的 cv 模块,后者遵循更过程化的编程风格。
默认情况下,即使文件使用灰度格式,imread() 也返回 BGR 颜色格式的图像。BGR 代表与 红-绿-蓝(RGB)相同的颜色空间,但字节顺序相反。
可以选择指定 imread() 的模式为以下枚举之一:
-
IMREAD_ANYCOLOR = 4 -
IMREAD_ANYDEPTH = 2 -
IMREAD_COLOR = 1 -
IMREAD_GRAYSCALE = 0 -
IMREAD_LOAD_GDAL = 8 -
IMREAD_UNCHANGED = -1
例如,让我们将 PNG 文件作为灰度图像加载(在此过程中丢失任何颜色信息),然后将其保存为灰度 PNG 图像:
import cv2
grayImage = cv2.imread('MyPic.png', cv2.IMREAD_GRAYSCALE)
cv2.imwrite('MyPicGray.png', grayImage)
为了避免不必要的麻烦,在使用 OpenCV 的 API 时,至少使用图像的绝对路径(例如,Windows 上的 C:\Users\Joe\Pictures\MyPic.png 或 Unix 上的 /home/joe/pictures/MyPic.png),路径必须是相对的,除非它是绝对路径。图像的路径,除非是绝对路径,否则相对于包含 Python 脚本的文件夹,所以在前面的例子中,MyPic.png 必须与你的 Python 脚本在同一文件夹中,否则找不到图像。
无论模式如何,imread() 都会丢弃任何 alpha 通道(透明度)。imwrite() 函数要求图像以 BGR 或灰度格式存在,并且每个通道需要支持一定数量的位,该位数为输出格式所能支持。例如,bmp 需要每个通道 8 位,而 PNG 允许每个通道 8 或 16 位。
在图像和原始字节之间进行转换
从概念上讲,一个字节是一个介于 0 到 255 之间的整数。在所有今天的实时图形应用中,一个像素通常由每个通道一个字节表示,尽管其他表示也是可能的。
OpenCV 图像是一个 .array 类型的 2D 或 3D 数组。一个 8 位灰度图像是一个包含字节的 2D 数组。一个 24 位 BGR 图像是一个 3D 数组,它也包含字节值。我们可以通过使用表达式来访问这些值,例如 image[0, 0] 或 image[0, 0, 0]。第一个索引是像素的 y 坐标或行,0 表示顶部。第二个索引是像素的 x 坐标或列,0 表示最左边。如果适用,第三个索引代表一个颜色通道。
例如,在一个 8 位灰度图像中,如果左上角有一个白色像素,image[0, 0] 是 255。对于一个 24 位 BGR 图像,如果左上角有一个蓝色像素,image[0, 0] 是 [255, 0, 0]。
注意
作为使用表达式(如 image[0, 0] 或 image[0, 0] = 128)的替代,我们可以使用表达式,如 image.item((0, 0)) 或 image.setitem((0, 0), 128)。后者的表达式对于单像素操作更有效率。然而,正如我们将在后续章节中看到的,我们通常希望对图像的大块区域进行操作,而不是单个像素。
假设图像每个通道有 8 位,我们可以将其转换为标准的 Python bytearray,它是一维的:
byteArray = bytearray(image)
相反,如果 bytearray 中的字节顺序适当,我们可以将其转换并重塑为 numpy.array 类型,得到一个图像:
grayImage = numpy.array(grayByteArray).reshape(height, width)
bgrImage = numpy.array(bgrByteArray).reshape(height, width, 3)
作为更完整的示例,让我们将包含随机字节的 bytearray 转换为灰度图像和 BGR 图像:
import cv2
import numpy
import os
# Make an array of 120,000 random bytes.
randomByteArray = bytearray(os.urandom(120000))
flatNumpyArray = numpy.array(randomByteArray)
# Convert the array to make a 400x300 grayscale image.
grayImage = flatNumpyArray.reshape(300, 400)
cv2.imwrite('RandomGray.png', grayImage)
# Convert the array to make a 400x100 color image.
bgrImage = flatNumpyArray.reshape(100, 400, 3)
cv2.imwrite('RandomColor.png', bgrImage)
运行此脚本后,我们应该在脚本目录中有一对随机生成的图像,RandomGray.png 和 RandomColor.png。
注意
在这里,我们使用 Python 的标准 os.urandom() 函数生成随机原始字节,然后将其转换为 NumPy 数组。请注意,也可以直接(并且更高效地)使用语句生成随机 NumPy 数组,例如 numpy.random.randint(0, 256, 120000).reshape(300, 400)。我们使用 os.urandom() 的唯一原因是为了帮助演示从原始字节到转换的过程。
使用 numpy.array 访问图像数据
现在你已经更好地理解了图像的形成方式,我们可以开始对其进行基本操作。我们知道,在 OpenCV 中加载图像最简单(也是最常见)的方法是使用 imread 函数。我们也知道这将返回一个图像,实际上是一个数组(二维或三维,取决于你传递给 imread() 的参数)。
y.array 结构针对数组操作进行了很好的优化,并允许进行某些在普通 Python 列表中不可用的批量操作。这类 .array 类型特定的操作在 OpenCV 中的图像处理中非常有用。让我们从最基本的例子开始,逐步探索图像处理:假设你想操作 BGR 图像中坐标为 (0, 0) 的像素,并将其转换为白色像素。
import cv
import numpy as np
img = cv.imread('MyPic.png')
img[0,0] = [255, 255, 255]
如果你使用标准的 imshow() 调用显示图像,你将在图像的左上角看到一个白色点。当然,这并不很有用,但它展示了可以完成的事情。现在让我们利用 numpy.array 的能力,以比普通 Python 数组快得多的速度对数组进行转换操作。
假设你想要改变特定像素的蓝色值,例如,坐标为(150,120)的像素。numpy.array类型提供了一个非常方便的方法,item(),它接受三个参数:x(或左)位置、y(或顶)以及数组中(x,y)位置的索引(记住,在 BGR 图像中,某个位置的数是一个包含 B、G 和 R 值的三个元素的数组,顺序如下)并返回索引位置的值。另一个itemset()方法将特定像素的特定通道的值设置为指定的值(itemset()接受两个参数:一个包含三个元素(x、y 和索引)的元组以及新值)。
在这个例子中,我们将(150,120)处的蓝色值从其当前值(127)更改为任意值 255:
import cv
import numpy as np
img = cv.imread('MyPic.png')
print img.item(150, 120, 0) // prints the current value of B for that pixel
img.itemset( (150, 120, 0), 255)
print img.item(150, 120, 0) // prints 255
记住,我们使用numpy.array做这件事有两个原因:numpy.array是一个针对这类操作进行了高度优化的库,而且我们通过 NumPy 优雅的方法而不是第一个示例中的原始索引访问获得了更易读的代码。
这段特定的代码本身并没有做什么,但它开启了一个可能性的世界。然而,建议你使用内置的过滤器和方法来操作整个图像;上述方法仅适用于小区域。
现在,让我们看看一个非常常见的操作,即操作通道。有时,你可能想要将特定通道(B、G 或 R)的所有值置零。
小贴士
使用循环来操作 Python 数组在运行时间上非常昂贵,应该尽量避免。使用数组索引允许高效地操作像素。这是一个昂贵且缓慢的操作,特别是如果你在操作视频时,你会发现输出会有抖动。然后,一个名为索引的功能就派上用场了。将图像中所有 G(绿色)值设置为0就像使用以下代码一样简单:
import cv
import as np
img = cv.imread('MyPic.png')
img[:, :, 1] = 0
这是一段相当令人印象深刻且易于理解的代码。相关行是最后一行,它基本上指示程序从所有行和列中获取所有像素,并将结果值的三元素数组的索引一设置为0。如果你显示这张图片,你会注意到绿色完全消失。
通过使用 NumPy 的数组索引访问原始像素,我们可以做许多有趣的事情;其中之一是定义感兴趣区域(ROI)。一旦定义了区域,我们可以执行一系列操作,例如,将此区域绑定到一个变量上,然后甚至定义第二个区域并将第一个区域的值赋给它(在图像中将一部分图像复制到另一个位置):
import cv
import numpy as np
img = cv.imread('MyPic.png')
my_roi = img[0:100, 0:100]
img[300:400, 300:400] = my_roi
确保两个区域在大小上是一致的非常重要。如果不是,NumPy 会(正确地)抱怨两个形状不匹配。
最后,我们可以从numpy.array中获得一些有趣的细节,例如使用此代码获取图像属性:
import cv
import numpy as np
img = cv.imread('MyPic.png')
print img.shape
print img.size
print img.dtype
这三个属性按此顺序排列:
-
形状:NumPy 返回一个包含宽度、高度以及如果图像是彩色的则包含通道数的元组。这对于调试图像类型很有用;如果图像是单色或灰度,则不会包含通道值。
-
大小:此属性指图像的像素大小。
-
数据类型:此属性指用于图像的数据类型(通常是未签名的整数类型及其支持的字节数,即
uint8)。
总而言之,强烈建议您在处理 OpenCV 时熟悉 NumPy,特别是numpy.array,因为它是使用 Python 进行图像处理的基础。
读取/写入视频文件
OpenCV 提供了VideoCapture和VideoWriter类,支持各种视频文件格式。支持的格式因系统而异,但应始终包括 AVI。通过其read()方法,VideoCapture类可以轮询新帧,直到达到视频文件的末尾。每个帧都是以 BGR 格式的图像。
相反,可以将图像传递给VideoWriter类的write()方法,该方法将图像追加到VideoWriter中的文件。让我们看看一个例子,从 AVI 文件中读取帧并使用 YUV 编码写入另一个文件:
import cv2
videoCapture = cv2.VideoCapture('MyInputVid.avi')
fps = videoCapture.get(cv2.CAP_PROP_FPS)
size = (int(videoCapture.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(videoCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)))
videoWriter = cv2.VideoWriter(
'MyOutputVid.avi', cv2.VideoWriter_fourcc('I','4','2','0'), fps, size)
success, frame = videoCapture.read()
while success: # Loop until there are no more frames.
videoWriter.write(frame)
success, frame = videoCapture.read()
VideoWriter类构造函数的参数值得特别注意。必须指定视频的文件名。任何具有此名称的现有文件都将被覆盖。还必须指定视频编解码器。可用的编解码器可能因系统而异。以下是一些选项:
-
cv2.VideoWriter_fourcc('I','4','2','0'):此选项是不压缩的 YUV 编码,4:2:0 色度子采样。这种编码广泛兼容,但生成的文件很大。文件扩展名应为.avi。 -
cv2.VideoWriter_fourcc('P','I','M','1'):此选项是 MPEG-1。文件扩展名应为.avi。 -
cv2.VideoWriter_fourcc('X','V','I','D'):此选项是 MPEG-4,如果您希望生成的视频大小为平均大小,这是一个首选选项。文件扩展名应为.avi。 -
cv2.VideoWriter_fourcc('T','H','E','O'):此选项是 Ogg Vorbis。文件扩展名应为.ogv。 -
cv2.VideoWriter_fourcc('F','L','V','1'):此选项是 Flash 视频。文件扩展名应为.flv。
必须指定帧率和帧大小。由于我们是从另一个视频复制视频帧,这些属性可以从VideoCapture类的get()方法中读取。
捕获相机帧
相机帧流也由VideoCapture类表示。然而,对于相机,我们通过传递相机的设备索引而不是视频的文件名来构建一个VideoCapture类。让我们考虑一个例子,从相机捕获 10 秒的视频并将其写入 AVI 文件:
import cv2
cameraCapture = cv2.VideoCapture(0)
fps = 30 # an assumption
size = (int(cameraCapture.get(cv2.CAP_PROP_FRAME_WIDTH)),
int(cameraCapture.get(cv2.CAP_PROP_FRAME_HEIGHT)))
videoWriter = cv2.VideoWriter(
'MyOutputVid.avi', cv2.VideoWriter_fourcc('I','4','2','0'), fps, size)
success, frame = cameraCapture.read()
numFramesRemaining = 10 * fps - 1
while success and numFramesRemaining > 0:
videoWriter.write(frame)
success, frame = cameraCapture.read()
numFramesRemaining -= 1
cameraCapture.release()
很不幸,VideoCapture 类的 get() 方法并不能返回相机帧率的准确值;它总是返回 0。在官方文档docs.opencv.org/modules/highgui/doc/reading_and_writing_images_and_video.html中写道:
"当查询
VideoCapture类后端不支持的一个属性时,返回值0。"
这通常发生在只支持基本功能的驱动程序的系统上。
为了为相机创建一个合适的 VideoWriter 类,我们不得不要么对帧率做出假设(就像我们在之前的代码中所做的那样),要么使用计时器来测量它。后者方法更好,我们将在本章后面讨论。
相机的数量及其顺序当然是系统相关的。不幸的是,OpenCV 并没有提供查询相机数量或其属性的方法。如果使用无效的索引来构造 VideoCapture 类,该类将不会输出任何帧;其 read() 方法将返回 (false, None)。为了避免尝试从未正确打开的 VideoCapture 中检索帧,一个很好的方法是使用 VideoCapture.isOpened 方法,它返回一个布尔值。
当我们需要同步一组相机或多头相机(如立体相机或 Kinect)时,read() 方法是不合适的。这时,我们使用 grab() 和 retrieve() 方法代替。对于一组相机,我们使用以下代码:
success0 = cameraCapture0.grab()
success1 = cameraCapture1.grab()
if success0 and success1:
frame0 = cameraCapture0.retrieve()
frame1 = cameraCapture1.retrieve()
在窗口中显示图像
OpenCV 中最基本的一个操作就是显示图像。这可以通过 imshow() 函数实现。如果你来自任何其他 GUI 框架的背景,你可能会认为调用 imshow() 来显示图像就足够了。这仅部分正确:图像会被显示,然后立即消失。这是设计上的考虑,以便在处理视频时能够不断刷新窗口框架。以下是一个显示图像的非常简单的示例代码:
import cv2
import numpy as np
img = cv2.imread('my-image.png')
cv2.imshow('my image', img)
cv2.waitKey()
cv2.destroyAllWindows()
imshow() 函数接受两个参数:我们想要在其中显示图像的窗口名称,以及图像本身。当我们探讨在窗口中显示帧时,我们将更详细地讨论 waitKey()。
命名为 destroyAllWindows() 的函数会销毁 OpenCV 创建的所有窗口。
在窗口中显示相机帧
OpenCV 允许使用 namedWindow()、imshow() 和 destroyWindow() 函数创建、重绘和销毁命名窗口。此外,任何窗口都可以通过 waitKey() 函数捕获键盘输入,通过 setMouseCallback() 函数捕获鼠标输入。让我们看看一个示例,其中我们展示了实时相机输入的帧:
import cv2
clicked = False
def onMouse(event, x, y, flags, param):
global clicked
if event == cv2.EVENT_LBUTTONUP:
clicked = True
cameraCapture = cv2.VideoCapture(0)
cv2.namedWindow('MyWindow')
cv2.setMouseCallback('MyWindow', onMouse)
print 'Showing camera feed. Click window or press any key to stop.'
success, frame = cameraCapture.read()
while success and cv2.waitKey(1) == -1 and not clicked:
cv2.imshow('MyWindow', frame)
success, frame = cameraCapture.read()
cv2.destroyWindow('MyWindow')
cameraCapture.release()
waitKey() 的参数是等待键盘输入的毫秒数。返回值是 -1(表示没有按键被按下)或一个 ASCII 键码,例如 27 对应于 Esc。有关 ASCII 键码的列表,请参阅 www.asciitable.com/。此外,请注意,Python 提供了一个标准函数 ord(),可以将字符转换为它的 ASCII 键码。例如,ord('a') 返回 97。
小贴士
在某些系统上,waitKey() 可能返回一个编码了不仅仅是 ASCII 键码的值。(已知当 OpenCV 使用 GTK 作为其后端 GUI 库时,Linux 上会发生一个错误。)在所有系统上,我们可以确保通过从返回值中读取最后一个字节来仅提取 ASCII 键码,如下所示:
keycode = cv2.waitKey(1)
if keycode != -1:
keycode &= 0xFF
OpenCV 的窗口函数和 waitKey() 是相互依赖的。只有当调用 waitKey() 时,OpenCV 窗口才会更新,并且只有当 OpenCV 窗口获得焦点时,waitKey() 才会捕获输入。
传递给 setMouseCallback() 的鼠标回调应接受五个参数,如我们的代码示例所示。回调的 param 参数被设置为 setMouseCallback() 的可选第三个参数。默认情况下,它是 0。回调的事件参数是以下动作之一:
-
cv2.EVENT_MOUSEMOVE: 此事件表示鼠标移动 -
cv2.EVENT_LBUTTONDOWN: 此事件表示左键按下 -
cv2.EVENT_RBUTTONDOWN: 这表示右键按下 -
cv2.EVENT_MBUTTONDOWN: 这表示中间按钮按下 -
cv2.EVENT_LBUTTONUP: 这表示左键释放 -
cv2.EVENT_RBUTTONUP: 此事件表示右键释放 -
cv2.EVENT_MBUTTONUP: 此事件表示中间按钮释放 -
cv2.EVENT_LBUTTONDBLCLK: 此事件表示左键被双击 -
cv2.EVENT_RBUTTONDBLCLK: 这表示右键被双击 -
cv2.EVENT_MBUTTONDBLCLK: 这表示中间按钮被双击
鼠标回调的标志参数可能是以下事件的位运算组合:
-
cv2.EVENT_FLAG_LBUTTON: 此事件表示左键被按下 -
cv2.EVENT_FLAG_RBUTTON: 此事件表示右键被按下 -
cv2.EVENT_FLAG_MBUTTON: 此事件表示中间按钮被按下 -
cv2.EVENT_FLAG_CTRLKEY: 此事件表示按下 Ctrl 键 -
cv2.EVENT_FLAG_SHIFTKEY: 此事件表示按下 Shift 键 -
cv2.EVENT_FLAG_ALTKEY: 此事件表示按下 Alt 键
不幸的是,OpenCV 不提供处理窗口事件的方法。例如,当窗口的关闭按钮被点击时,我们无法停止我们的应用程序。由于 OpenCV 有限的的事件处理和 GUI 功能,许多开发者更喜欢将其与其他应用程序框架集成。在本章的后面部分,我们将设计一个抽象层,以帮助将 OpenCV 集成到任何应用程序框架中。
Project Cameo(人脸追踪和图像处理)
OpenCV 通常通过一种类似于食谱的方法来研究,它涵盖了大量的算法,但没有关于高级应用程序开发的内容。在一定程度上,这种方法是可以理解的,因为 OpenCV 的潜在应用非常多样。OpenCV 被用于广泛的领域:照片/视频编辑器、动作控制游戏、机器人的 AI,或者记录参与者眼动行为的心理学实验。在如此不同的用例中,我们真的能够研究出一套有用的抽象吗?
我相信我们可以,而且越早开始创建抽象,越好。我们将围绕单个应用程序来结构化我们对 OpenCV 的研究,但在每个步骤中,我们将设计这个应用程序的一个组件,使其可扩展和可重用。
我们将开发一个交互式应用程序,该应用程序在实时摄像头输入上执行面部跟踪和图像操作。这类应用程序涵盖了 OpenCV 的广泛功能,并挑战我们创建一个高效、有效的实现。
具体来说,我们的应用程序将执行实时面部融合。给定两个摄像头输入流(或者,可选地,预先录制的视频输入),应用程序将把一个流中的面部叠加到另一个流中的面部上。将应用滤镜和扭曲,使这个混合场景看起来和感觉上统一。用户应该体验到参与现场表演的感觉,进入另一个环境和角色。这种用户体验在像迪士尼乐园这样的游乐园中很受欢迎。
在这样的应用程序中,用户会立即注意到缺陷,例如帧率低或跟踪不准确。为了获得最佳结果,我们将尝试使用传统成像和深度成像的几种方法。
我们将把我们的应用程序命名为 Cameo。在珠宝中,Cameo 是指一个人的小肖像,或者在电影中是指名人扮演的非常短暂的角色。
Cameo – 面向对象设计
Python 应用程序可以编写为纯过程式风格。这通常用于小型应用程序,例如我们之前讨论的基本 I/O 脚本。然而,从现在开始,我们将使用面向对象风格,因为它促进了模块化和可扩展性。
从我们对 OpenCV I/O 功能的概述中,我们知道所有图像都是相似的,无论它们的来源或目的地。无论我们如何获取图像流或将其发送到何处作为输出,我们都可以将相同的应用特定逻辑应用于这个流中的每一帧。在像 Cameo 这样的应用程序中,分离 I/O 代码和应用代码变得特别方便,因为它使用多个 I/O 流。
我们将创建名为CaptureManager和WindowManager的类,作为 I/O 流的高级接口。我们的应用程序代码可以使用CaptureManager读取新帧,并且可选地将每个帧派发到一个或多个输出,包括静态图像文件、视频文件和窗口(通过WindowManager类)。WindowManager类允许我们的应用程序代码以面向对象的方式处理窗口和事件。
CaptureManager和WindowManager都是可扩展的。我们可以实现不依赖于 OpenCV 进行 I/O 的版本。实际上,附录 A,与 Pygame 集成,使用 Python 的 OpenCV 计算机视觉,使用了一个WindowManager子类。
使用 CaptureManager 从管理器中抽象视频流。
正如我们所见,OpenCV 可以从视频文件或摄像头捕获、显示和记录一系列图像,但在每种情况下都有一些特殊考虑。我们的CaptureManager类抽象了一些差异,并提供了一个更高级别的接口,将捕获流中的图像派发到一个或多个输出——静态图像文件、视频文件或窗口。
CaptureManager类使用VideoCapture类初始化,并具有enterFrame()和exitFrame()方法,这些方法通常在应用程序主循环的每次迭代中调用。在调用enterFrame()和exitFrame()之间,应用程序可以(任意次数)设置channel属性并获取frame属性。channel属性最初为0,只有多头摄像头使用其他值。frame属性是当调用enterFrame()时对应当前通道状态的图像。
CaptureManager类还具有writeImage()、startWritingVideo()和stopWritingVideo()方法,这些方法可以在任何时候调用。实际的文件写入将推迟到exitFrame()。此外,在exitFrame()方法中,frame属性可能会在窗口中显示,具体取决于应用程序代码是否提供了一个WindowManager类,无论是作为CaptureManager构造函数的参数,还是通过设置previewWindowManager属性。
如果应用程序代码操作frame,则这些操作将反映在记录的文件和窗口中。CaptureManager类有一个名为shouldMirrorPreview的构造函数参数和属性,如果我们要在窗口中镜像(水平翻转)frame但不在记录的文件中,则该参数应为True。通常,当面对摄像头时,用户更喜欢镜像的实时摄像头流。
回想一下,VideoWriter类需要一个帧率,但 OpenCV 并没有提供任何方法来获取摄像头的准确帧率。CaptureManager类通过使用帧计数器和 Python 的标准time.time()函数来估计帧率来绕过这个限制。这种方法并不是万无一失的。根据帧率波动和系统依赖的time.time()实现,在某些情况下,估计的准确性可能仍然很差。然而,如果我们部署到未知的硬件上,这比仅仅假设用户的摄像头具有特定的帧率要好。
让我们创建一个名为managers.py的文件,该文件将包含我们的CaptureManager实现。这个实现相当长。因此,我们将分几个部分来看它。首先,让我们添加导入、构造函数和属性,如下所示:
import cv2
import numpy
import time
class CaptureManager(object):
def __init__(self, capture, previewWindowManager = None,
shouldMirrorPreview = False):
self.previewWindowManager = previewWindowManager
self.shouldMirrorPreview = shouldMirrorPreview
self._capture = capture
self._channel = 0
self._enteredFrame = False
self._frame = None
self._imageFilename = None
self._videoFilename = None
self._videoEncoding = None
self._videoWriter = None
self._startTime = None
self._framesElapsed = long(0)
self._fpsEstimate = None
@property
def channel(self):
return self._channel
@channel.setter
def channel(self, value):
if self._channel != value:
self._channel = value
self._frame = None
@property
def frame(self):
if self._enteredFrame and self._frame is None:
_, self._frame = self._capture.retrieve()
return self._frame
@property
def isWritingImage (self):
return self._imageFilename is not None
@property
def isWritingVideo(self):
return self._videoFilename is not None
注意,大多数的member变量都是非公开的,这可以通过变量名前的下划线前缀来表示,例如self._enteredFrame。这些非公开变量与当前帧的状态以及任何文件写入操作相关。正如之前讨论的,应用程序代码只需要配置一些事情,这些事情作为构造函数参数和可设置的公共属性来实现:摄像头通道、窗口管理器以及是否镜像摄像头预览的选项。
本书假设读者对 Python 有一定程度的熟悉;然而,如果你对那些@注解(例如,@property)感到困惑,请参考 Python 文档中关于decorators的部分,这是语言的一个内置特性,允许一个函数被另一个函数包装,通常用于在应用程序的多个地方应用用户定义的行为(参考docs.python.org/2/reference/compound_stmts.html#grammar-token-decorator))。
注意
Python 没有私有成员变量的概念,单下划线前缀(_)只是一个约定。
根据这个约定,在 Python 中,以单个下划线为前缀的变量应被视为受保护的(只能在类及其子类中访问),而以双下划线为前缀的变量应被视为私有的(只能在类内部访问)。
继续我们的实现,让我们将enterFrame()和exitFrame()方法添加到managers.py中:
def enterFrame(self):
"""Capture the next frame, if any."""
# But first, check that any previous frame was exited.
assert not self._enteredFrame, \
'previous enterFrame() had no matching exitFrame()'
if self._capture is not None:
self._enteredFrame = self._capture.grab()
def exitFrame (self):
"""Draw to the window. Write to files. Release the frame."""
# Check whether any grabbed frame is retrievable.
# The getter may retrieve and cache the frame.
if self.frame is None:
self._enteredFrame = False
return
# Update the FPS estimate and related variables.
if self._framesElapsed == 0:
self._startTime = time.time()
else:
timeElapsed = time.time() - self._startTime
self._fpsEstimate = self._framesElapsed / timeElapsed
self._framesElapsed += 1
# Draw to the window, if any.
if self.previewWindowManager is not None:
if self.shouldMirrorPreview:
mirroredFrame = numpy.fliplr(self._frame).copy()
self.previewWindowManager.show(mirroredFrame)
else:
self.previewWindowManager.show(self._frame)
# Write to the image file, if any.
if self.isWritingImage:
cv2.imwrite(self._imageFilename, self._frame)
self._imageFilename = None
# Write to the video file, if any.
self._writeVideoFrame()
# Release the frame.
self._frame = None
self._enteredFrame = False
注意,enterFrame()的实现只是获取(同步)一个帧,而实际从通道检索则推迟到后续读取frame变量。exitFrame()的实现从当前通道获取图像,估算帧率,通过窗口管理器(如果有)显示图像,并满足任何待处理的将图像写入文件的请求。
其他几个方法也与文件写入有关。为了完成我们的类实现,让我们将剩余的文件写入方法添加到managers.py中:
def writeImage(self, filename):
"""Write the next exited frame to an image file."""
self._imageFilename = filename
def startWritingVideo(
self, filename,
encoding = cv2.VideoWriter_fourcc('I','4','2','0')):
"""Start writing exited frames to a video file."""
self._videoFilename = filename
self._videoEncoding = encoding
def stopWritingVideo (self):
"""Stop writing exited frames to a video file."""
self._videoFilename = None
self._videoEncoding = None
self._videoWriter = None
def _writeVideoFrame(self):
if not self.isWritingVideo:
return
if self._videoWriter is None:
fps = self._capture.get(cv2.CAP_PROP_FPS)
if fps == 0.0:
# The capture's FPS is unknown so use an estimate.
if self._framesElapsed < 20:
# Wait until more frames elapse so that the
# estimate is more stable.
return
else:
fps = self._fpsEstimate
size = (int(self._capture.get(
cv2.CAP_PROP_FRAME_WIDTH)),
int(self._capture.get(
cv2.CAP_PROP_FRAME_HEIGHT)))
self._videoWriter = cv2.VideoWriter(
self._videoFilename, self._videoEncoding,
fps, size)
self._videoWriter.write(self._frame)
writeImage()、startWritingVideo()和stopWritingVideo()公共方法只是记录文件写入操作的参数,而实际的写入操作则推迟到exitFrame()的下一次调用。非公共方法_writeVideoFrame()以我们早期脚本中熟悉的方式创建或追加视频文件。(见读取/写入视频文件部分。)然而,在帧率未知的情况下,我们在捕获会话的开始处跳过一些帧,以便我们有时间建立对帧率的估计。
尽管我们当前的CaptureManager实现依赖于VideoCapture,但我们可以实现不使用 OpenCV 作为输入的其他实现。例如,我们可以创建一个子类,它通过套接字连接实例化,其字节流可以解析为图像流。我们还可以创建一个使用第三方相机库的子类,该库具有与 OpenCV 提供的不同硬件支持。然而,对于 Cameo,我们的当前实现是足够的。
使用managers.WindowManager管理窗口和键盘
正如我们所见,OpenCV 提供了创建窗口、销毁窗口、显示图像和处理事件的函数。这些函数不是窗口类的成员方法,而是需要将窗口的名称作为参数传递。由于这个接口不是面向对象的,它不符合 OpenCV 的一般风格。此外,它可能与我们可能最终想要使用的其他窗口或事件处理接口不兼容。
为了面向对象和适应性,我们将此功能抽象成一个具有createWindow()、destroyWindow()、show()和processEvents()方法的WindowManager类。作为一个属性,WindowManager类有一个名为keypressCallback的函数对象,该对象(如果非None)在processEvents()中响应任何按键时被调用。keypressCallback对象必须接受一个单一参数,例如 ASCII 键码。
让我们在managers.py中添加以下WindowManager的实现:
class WindowManager(object):
def __init__(self, windowName, keypressCallback = None):
self.keypressCallback = keypressCallback
self._windowName = windowName
self._isWindowCreated = False
@property
def isWindowCreated(self):
return self._isWindowCreated
def createWindow (self):
cv2.namedWindow(self._windowName)
self._isWindowCreated = True
def show(self, frame):
cv2.imshow(self._windowName, frame)
def destroyWindow (self):
cv2.destroyWindow(self._windowName)
self._isWindowCreated = False
def processEvents (self):
keycode = cv2.waitKey(1)
if self.keypressCallback is not None and keycode != -1:
# Discard any non-ASCII info encoded by GTK.
keycode &= 0xFF
self.keypressCallback(keycode)
我们当前的实施方案仅支持键盘事件,这对 Cameo 来说将足够。然而,我们可以修改WindowManager以支持鼠标事件。例如,类的接口可以扩展以包括一个mouseCallback属性(以及可选的构造函数参数),但其他方面可以保持不变。通过添加回调属性,我们可以使用除 OpenCV 之外的事件框架以相同的方式支持其他事件类型。
附录 A,与 Pygame 集成,使用 Python 的 OpenCV 计算机视觉展示了使用 Pygame 的窗口处理和事件框架实现的WindowManager子类,而不是使用 OpenCV 的。这个实现通过正确处理退出事件(例如,当用户点击窗口的关闭按钮时)改进了基本的WindowManager类。潜在地,许多其他事件类型也可以通过 Pygame 来处理。
应用所有内容到 cameo.Cameo
我们的应用程序由一个Cameo类表示,包含两个方法:run()和onKeypress()。在初始化时,Cameo类创建一个带有onKeypress()作为回调的WindowManager类,以及使用摄像头和WindowManager类的CaptureManager类。当调用run()时,应用程序执行一个主循环,在该循环中处理帧和事件。由于事件处理的结果,可能会调用onKeypress()。空格键会触发截图,Tab键会导致屏幕录制(视频录制)开始/停止,而Esc键会导致应用程序退出。
在managers.py相同的目录下,让我们创建一个名为cameo.py的文件,包含以下Cameo的实现:
import cv2
from managers import WindowManager, CaptureManager
class Cameo(object):
def __init__(self):
self._windowManager = WindowManager('Cameo',
self.onKeypress)
self._captureManager = CaptureManager(
cv2.VideoCapture(0), self._windowManager, True)
def run(self):
"""Run the main loop."""
self._windowManager.createWindow()
while self._windowManager.isWindowCreated:
self._captureManager.enterFrame()
frame = self._captureManager.frame
# TODO: Filter the frame (Chapter 3).
self._captureManager.exitFrame()
self._windowManager.processEvents()
def onKeypress (self, keycode):
"""Handle a keypress.
space -> Take a screenshot.
tab -> Start/stop recording a screencast.
escape -> Quit.
"""
if keycode == 32: # space
self._captureManager.writeImage('screenshot.png')
elif keycode == 9: # tab
if not self._captureManager.isWritingVideo:
self._captureManager.startWritingVideo(
'screencast.avi')
else:
self._captureManager.stopWritingVideo()
elif keycode == 27: # escape
self._windowManager.destroyWindow()
if __name__=="__main__":
Cameo().run()
当运行应用程序时,请注意,实时摄像头视频流是镜像的,而截图和屏幕录制则不是。这是预期的行为,因为我们初始化CaptureManager类时传递了True给shouldMirrorPreview。
到目前为止,我们除了镜像预览之外,没有以任何方式操作帧。我们将在第三章,过滤图像中开始添加更多有趣的效果。
摘要
到目前为止,我们应该有一个显示摄像头视频流、监听键盘输入,并且(在命令下)记录截图或屏幕录制的应用程序。我们现在准备通过在每一帧的开始和结束之间插入一些图像过滤代码(第三章,过滤图像)来扩展应用程序。可选地,我们也准备集成其他摄像头驱动程序或应用程序框架(附录 A,与 Pygame 集成,使用 Python 的 OpenCV 计算机视觉),除了 OpenCV 支持的那些。
我们现在也拥有了处理图像和理解通过 NumPy 数组进行图像操作原理的知识。这为理解下一个主题,过滤图像,奠定了完美的基础。
第三章:使用 OpenCV 3 处理图像
在处理图像的过程中,迟早你会发现自己需要修改图像:无论是应用艺术滤镜、扩展某些部分、剪切、粘贴,还是你心中能想到的其他任何操作。本章将介绍一些修改图像的技术,到本章结束时,你应该能够执行诸如在图像中检测肤色、锐化图像、标记主题轮廓以及使用线段检测器检测人行横道等任务。
在不同色彩空间之间转换
OpenCV 中实际上有数百种方法与色彩空间的转换相关。一般来说,在现代计算机视觉中,三种色彩空间最为常见:灰度、BGR 和色调、饱和度、明度(HSV)。
-
灰度是一种能够有效消除颜色信息,转换为灰度色调的色彩空间:这种色彩空间在中间处理中极为有用,例如人脸检测。
-
BGR 是蓝绿红色彩空间,其中每个像素是一个包含三个元素的数组,每个值代表蓝色、绿色和红色:网络开发者对颜色的类似定义应该很熟悉,只是颜色的顺序是 RGB。
-
在 HSV 色彩空间中,色调代表一种颜色调,饱和度是颜色的强度,而明度则表示其暗度(或光谱另一端的亮度)。
关于 BGR 的简要说明
当我最初开始处理 BGR 色彩空间时,发现有些事情并不符合预期:[0 255 255]的值(没有蓝色,全绿色和全红色)产生了黄色。如果你有艺术背景,你甚至不需要拿起画笔和画布就能看到绿色和红色混合成一种泥泞的棕色。这是因为计算机中使用的颜色模型被称为加色模型,并且与光有关。光的行为与颜料(遵循减色颜色模型)不同,并且——由于软件在以显示器为媒介的计算机上运行,而显示器会发出光——参考的颜色模型是加色模型。
傅里叶变换
在 OpenCV 中对图像和视频应用的大部分处理都涉及到以某种形式的概念——傅里叶变换。约瑟夫·傅里叶是一位 18 世纪的法国数学家,他发现了许多数学概念并使之流行,他的工作主要集中在研究热量和数学中的波形规律。特别是,他观察到所有波形都是不同频率的简单正弦波的叠加。
换句话说,你周围观察到的所有波形都是其他波形的总和。这个概念在处理图像时非常有用,因为它允许我们识别图像中信号(如图像像素)变化很大的区域,以及变化不那么剧烈的区域。然后我们可以任意标记这些区域作为噪声或感兴趣的区域,背景或前景等。这些就是构成原始图像的频率,我们有能力将它们分离,以便理解图像并推断有趣的数据。
注意
在 OpenCV 的上下文中,实现了一些算法,使我们能够处理图像并理解其中的数据,这些算法也在 NumPy 中重新实现,使我们的工作更加容易。NumPy 有一个快速傅里叶变换(FFT)包,其中包含fft2()方法。此方法允许我们计算图像的离散傅里叶变换(DFT)。
让我们使用傅里叶变换来考察图像的幅度谱概念。图像的幅度谱是另一个图像,它以变化的形式表示原始图像:可以想象成将图像中的所有最亮的像素拖到中心。然后,你逐渐向外工作,直到所有最暗的像素都被推到边缘。立即,你将能够看到图像中包含了多少亮暗像素以及它们分布的百分比。
傅里叶变换的概念是许多用于常见图像处理操作(如边缘检测或线形和形状检测)的算法的基础。
在详细探讨这些内容之前,让我们先看看两个概念,这两个概念与傅里叶变换结合,构成了上述处理操作的基础:高通滤波器和低通滤波器。
高通滤波器
高通滤波器(HPF)是一种检查图像某个区域的滤波器,根据与周围像素强度的差异来增强某些像素的强度。
以以下核为例:
[[0, -0.25, 0],
[-0.25, 1, -0.25],
[0, -0.25, 0]]
注意
核是一组应用于源图像某个区域的权重,用于生成目标图像的单个像素。例如,ksize为7意味着在生成每个目标像素时考虑了49 (7 x 7)个源像素。我们可以将核想象成一块磨砂玻璃在源图像上移动,并让源的光线通过扩散混合。
在计算中心像素与所有直接邻居像素强度差异之和后,如果发现强度变化很大,则中心像素的强度将被增强(或不会增强)。换句话说,如果一个像素与周围像素不同,它将被增强。
这在边缘检测中特别有效,其中使用了一种称为高增强滤波器的高通滤波器。
高通滤波器和低通滤波器都使用一个名为 radius 的属性,它扩展了参与滤波器计算的邻居区域。
让我们通过一个 HPF 的例子来了解:
import cv2
import numpy as np
from scipy import ndimage
kernel_3x3 = np.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]])
kernel_5x5 = np.array([[-1, -1, -1, -1, -1],
[-1, 1, 2, 1, -1],
[-1, 2, 4, 2, -1],
[-1, 1, 2, 1, -1],
[-1, -1, -1, -1, -1]])
注意
注意,这两个滤波器的总和为 0,其原因在 边缘检测 部分有详细解释。
img = cv2.imread("../images/color1_small.jpg", 0)
k3 = ndimage.convolve(img, kernel_3x3)
k5 = ndimage.convolve(img, kernel_5x5)
blurred = cv2.GaussianBlur(img, (11,11), 0)
g_hpf = img - blurred
cv2.imshow("3x3", k3)
cv2.imshow("5x5", k5)
cv2.imshow("g_hpf", g_hpf)
cv2.waitKey()
cv2.destroyAllWindows()
在初始导入之后,我们定义了一个 3x3 的核和一个 5x5 的核,然后以灰度形式加载图像。通常,大多数图像处理都是使用 NumPy 完成的;然而,在这个特定的情况下,我们想要“卷积”一个图像和一个给定的核,而 NumPy 只接受一维数组。
这并不意味着不能使用 NumPy 实现深度数组的卷积,只是这可能会稍微复杂一些。相反,ndimage(它是 SciPy 的一部分,因此您应按照 第一章 中 设置 OpenCV 的说明进行安装),通过其 convolve() 函数使这变得简单,该函数支持 cv2 模块使用的经典 NumPy 数组来存储图像。
我们使用我们定义的两个卷积核应用两个 HPF。最后,我们还实现了一种通过应用低通滤波器并计算与原始图像的差异来获取 HPF 的微分方法。您将注意到第三种方法实际上效果最好,所以让我们也详细说明低通滤波器。
低通滤波器
如果一个 HPF 增强了像素的强度,考虑到它与邻居的差异,那么一个 低通滤波器(LPF)如果与周围像素的差异低于某个阈值,将会平滑该像素。这在去噪和模糊中得到了应用。例如,最流行的模糊/平滑滤波器之一,高斯模糊,就是一个低通滤波器,它衰减高频信号的强度。
创建模块
就像我们的 CaptureManager 和 WindowManager 类一样,我们的过滤器应该在 Cameo 之外可重用。因此,我们应该将过滤器分离到它们自己的 Python 模块或文件中。
让我们在 cameo.py 同一目录下创建一个名为 filters.py 的文件。在 filters.py 中,我们需要以下 import 语句:
import cv2
import numpy
import utils
让我们在同一目录下也创建一个名为 utils.py 的文件。它应该包含以下 import 语句:
import cv2
import numpy
import scipy.interpolate
我们将在 filters.py 中添加滤波函数和类,而更通用的数学函数将放在 utils.py 中。
边缘检测
边缘在人类和计算机视觉中都起着重要作用。作为人类,我们只需通过看到背光轮廓或粗糙草图,就能轻松识别许多物体类型及其姿态。确实,当艺术强调边缘和姿态时,它往往传达出原型的概念,比如罗丹的《思想者》或乔·舒斯特的《超人》。软件也可以对边缘、姿态和原型进行推理。我们将在后面的章节中讨论这些推理。
OpenCV 提供了许多边缘检测过滤器,包括Laplacian()、Sobel()和Scharr()。这些过滤器本应将非边缘区域变为黑色,而将边缘区域变为白色或饱和颜色。然而,它们容易将噪声误识别为边缘。这种缺陷可以通过在尝试寻找边缘之前对图像进行模糊来减轻。OpenCV 还提供了许多模糊过滤器,包括blur()(简单平均)、medianBlur()和GaussianBlur()。边缘检测和模糊过滤器的参数各不相同,但总是包括ksize,这是一个表示过滤器核宽度和高度的奇数整数。
对于模糊,让我们使用medianBlur(),它在去除数字视频噪声方面非常有效,尤其是在彩色图像中。对于边缘检测,让我们使用Laplacian(),它在灰度图像中产生粗壮的边缘线。在应用Laplacian()之前,但在应用medianBlur()之后,我们应该将图像从 BGR 转换为灰度。
一旦我们得到了Laplacian()的结果,我们可以将其反转以在白色背景上得到黑色边缘。然后,我们可以将其归一化(使其值范围从 0 到 1)并与源图像相乘以加深边缘。让我们在filters.py中实现这种方法:
def strokeEdges(src, dst, blurKsize = 7, edgeKsize = 5):
if blurKsize >= 3:
blurredSrc = cv2.medianBlur(src, blurKsize)
graySrc = cv2.cvtColor(blurredSrc, cv2.COLOR_BGR2GRAY)
else:
graySrc = cv2.cvtColor(src, cv2.COLOR_BGR2GRAY)
cv2.Laplacian(graySrc, cv2.CV_8U, graySrc, ksize = edgeKsize)
normalizedInverseAlpha = (1.0 / 255) * (255 - graySrc)
channels = cv2.split(src)
for channel in channels:
channel[:] = channel * normalizedInverseAlpha
cv2.merge(channels, dst)
注意,我们允许将核大小作为strokeEdges()的参数指定。blurKsize参数用作medianBlur()的ksize,而edgeKsize用作Laplacian()的ksize。在我的网络摄像头中,我发现blurKsize值为7和edgeKsize值为5看起来最佳。不幸的是,当ksize较大,如7时,medianBlur()会变得很昂贵。
小贴士
如果你在运行strokeEdges()时遇到性能问题,尝试减小blurKsize的值。要关闭模糊,将其设置为小于3的值。
自定义核 – 变得复杂
正如我们刚才看到的,OpenCV 的许多预定义过滤器使用核。记住,核是一组权重,这些权重决定了每个输出像素是如何从输入像素的邻域中计算出来的。核的另一个术语是卷积矩阵。它在某个区域内混合或卷积像素。同样,基于核的过滤器可能被称为卷积过滤器。
OpenCV 提供了一个非常通用的filter2D()函数,它可以应用我们指定的任何核或卷积矩阵。为了了解如何使用此函数,让我们首先学习卷积矩阵的格式。它是一个行和列都是奇数的二维数组。中心元素对应于一个感兴趣的像素,其他元素对应于该像素的邻居。每个元素包含一个整数或浮点值,这是一个应用于输入像素值的权重。考虑以下示例:
kernel = numpy.array([[-1, -1, -1],
[-1, 9, -1],
[-1, -1, -1]])
在这里,感兴趣像素的权重为9,其直接邻居的权重各为-1。对于感兴趣像素,输出颜色将是其输入颜色的九倍减去所有八个相邻像素的输入颜色。如果感兴趣像素与其邻居已经有点不同,这种差异会变得更加明显。结果是,当邻居之间的对比度增加时,图像看起来会更锐利。
继续我们的示例,我们可以将这个卷积矩阵分别应用于源图像和目标图像,如下所示:
cv2.filter2D(src, -1, kernel, dst)
第二个参数指定了目标图像的每个通道的深度(例如,对于每个通道 8 位,使用cv2.CV_8U)。一个负值(如这里所示)表示目标图像具有与源图像相同的深度。
注意
对于彩色图像,请注意filter2D()对每个通道应用内核是相同的。要使用不同通道的不同内核,我们还需要使用split()和merge()函数。
基于这个简单的示例,让我们向filters.py中添加两个类。一个类,VConvolutionFilter,将代表一般的卷积滤镜。一个子类,SharpenFilter,将代表我们的锐化滤镜。让我们编辑filters.py以实现这两个新类,如下所示:
class VConvolutionFilter(object):
"""A filter that applies a convolution to V (or all of BGR)."""
def __init__(self, kernel):
self._kernel = kernel
def apply(self, src, dst):
"""Apply the filter with a BGR or gray source/destination."""
cv2.filter2D(src, -1, self._kernel, dst)
class SharpenFilter(VConvolutionFilter):
"""A sharpen filter with a 1-pixel radius."""
def __init__(self):
kernel = numpy.array([[-1, -1, -1],
[-1, 9, -1],
[-1, -1, -1]])
VConvolutionFilter.__init__(self, kernel)
注意,权重之和为1。这应该在我们想要保持图像整体亮度不变时始终成立。如果我们稍微修改锐化核,使其权重之和为0,那么我们就有了一个将边缘变为白色、非边缘变为黑色的边缘检测核。例如,让我们将以下边缘检测滤镜添加到filters.py中:
class FindEdgesFilter(VConvolutionFilter):
"""An edge-finding filter with a 1-pixel radius."""
def __init__(self):
kernel = numpy.array([[-1, -1, -1],
[-1, 8, -1],
[-1, -1, -1]])
VConvolutionFilter.__init__(self, kernel)
接下来,让我们制作一个模糊滤镜。一般来说,为了产生模糊效果,权重之和应为1,并且在整个邻域内应为正值。例如,我们可以简单地取邻域的平均值,如下所示:
class BlurFilter(VConvolutionFilter):
"""A blur filter with a 2-pixel radius."""
def __init__(self):
kernel = numpy.array([[0.04, 0.04, 0.04, 0.04, 0.04],
[0.04, 0.04, 0.04, 0.04, 0.04],
[0.04, 0.04, 0.04, 0.04, 0.04],
[0.04, 0.04, 0.04, 0.04, 0.04],
[0.04, 0.04, 0.04, 0.04, 0.04]])
VConvolutionFilter.__init__(self, kernel)
我们使用的锐化、边缘检测和模糊滤镜都采用了高度对称的核。然而,有时使用对称性较低的核会产生有趣的效果。让我们考虑一个在一侧(具有正权重)模糊而在另一侧(具有负权重)锐化的核。这将产生一种脊状或浮雕效果。以下是一个可以添加到filters.py中的实现示例:
class EmbossFilter(VConvolutionFilter):
"""An emboss filter with a 1-pixel radius."""
def __init__(self):
kernel = numpy.array([[-2, -1, 0],
[-1, 1, 1],
[ 0, 1, 2]])
VConvolutionFilter.__init__(self, kernel)
这套自定义卷积滤镜非常基础。实际上,它比 OpenCV 的现成滤镜集更基础。然而,通过一些实验,你应该能够编写出产生独特外观的自己的核。
修改应用
现在我们已经有了几个滤镜的高级函数和类,将它们应用于 Cameo 捕获的帧变得非常简单。让我们编辑cameo.py并添加以下摘录中加粗的行:
import cv2
import filters
from managers import WindowManager, CaptureManager
class Cameo(object):
def __init__(self):
self._windowManager = WindowManager('Cameo',
self.onKeypress)
self._captureManager = CaptureManager(
cv2.VideoCapture(0), self._windowManager, True)
self._curveFilter = filters.BGRPortraCurveFilter()
def run(self):
"""Run the main loop."""
self._windowManager.createWindow()
while self._windowManager.isWindowCreated:
self._captureManager.enterFrame()
frame = self._captureManager.frame
filters.strokeEdges(frame, frame)
self._curveFilter.apply(frame, frame)
self._captureManager.exitFrame()
self._windowManager.processEvents()
# ... The rest is the same as in Chapter 2.
在这里,我选择应用两种效果:描边边缘和模拟 Portra 胶片颜色。请随意修改代码以应用您喜欢的任何滤镜。
下面是 Cameo 的一个截图,展示了描边边缘和类似 Portra 的颜色:

使用 Canny 进行边缘检测
OpenCV 还提供了一个非常方便的函数,称为 Canny(以算法的发明者 John F. Canny 命名),它不仅因其有效性而广受欢迎,而且因其实现简单而广受欢迎,因为它在 OpenCV 程序中是一行代码:
import cv2
import numpy as np
img = cv2.imread("../images/statue_small.jpg", 0)
cv2.imwrite("canny.jpg", cv2.Canny(img, 200, 300))
cv2.imshow("canny", cv2.imread("canny.jpg"))
cv2.waitKey()
cv2.destroyAllWindows()
结果是边缘的非常清晰的识别:

Canny 边缘检测算法相当复杂但也很有趣:它是一个五步过程,使用高斯滤波器对图像进行降噪,计算梯度,对边缘应用非最大 抑制(NMS),对所有检测到的边缘进行双重阈值以消除假阳性,最后分析所有边缘及其相互之间的连接,以保留真实边缘并丢弃较弱的边缘。
轮廓检测
计算机视觉中的另一个重要任务是轮廓检测,这不仅因为检测图像或视频帧中包含的主题轮廓的明显方面,还因为与识别轮廓相关的导数操作。
这些操作包括计算边界多边形、近似形状以及通常计算感兴趣区域,这大大简化了与图像数据的交互,因为使用 NumPy 可以轻松地用数组切片定义矩形区域。在探索对象检测(包括人脸)和对象跟踪的概念时,我们将大量使用这项技术。
让我们按顺序进行,首先通过一个示例熟悉 API:
import cv2
import numpy as np
img = np.zeros((200, 200), dtype=np.uint8)
img[50:150, 50:150] = 255
ret, thresh = cv2.threshold(img, 127, 255, 0)
image, contours, hierarchy = cv2.findContours(thresh, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
color = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
img = cv2.drawContours(color, contours, -1, (0,255,0), 2)
cv2.imshow("contours", color)
cv2.waitKey()
cv2.destroyAllWindows()
首先,我们创建一个 200x200 像素大小的空黑色图像。然后,我们利用 ndarray 在切片上赋值的能力,在图像的中心放置一个白色方块。
我们然后对图像进行阈值处理,并调用findContours()函数。此函数有三个参数:输入图像、层次结构类型和轮廓近似方法。此函数中有几个特别有趣的方面:
-
函数会修改输入图像,因此建议使用原始图像的副本(例如,通过传递
img.copy())。 -
其次,函数返回的层次结构树非常重要:
cv2.RETR_TREE将检索图像中轮廓的整个层次结构,使您能够建立轮廓之间的“关系”。如果您只想检索最外层的轮廓,请使用cv2.RETR_EXTERNAL。这在您想要消除完全包含在其他轮廓中的轮廓时特别有用(例如,在大多数情况下,您不需要检测另一个相同类型的对象内的对象)。
findContours函数返回三个元素:修改后的图像、轮廓及其层次结构。我们使用轮廓在图像的颜色版本上绘制(这样我们可以在绿色上绘制轮廓),并最终显示它。
结果是一个用绿色绘制轮廓的白色正方形。虽然简单,但有效地展示了概念!让我们继续看更有意义的例子。
轮廓 – 包围盒、最小面积矩形和最小包围圆
找到正方形的轮廓是一个简单的任务;不规则、倾斜和旋转的形状最能发挥 OpenCV 的cv2.findContours实用函数的作用。让我们看看下面的图像:

在实际应用中,我们最感兴趣的是确定主题的包围盒、其最小包围矩形和其圆。结合cv2.findContours函数和一些其他 OpenCV 实用工具,这使得实现这一点变得非常容易:
import cv2
import numpy as np
img = cv2.pyrDown(cv2.imread("hammer.jpg", cv2.IMREAD_UNCHANGED))
ret, thresh = cv2.threshold(cv2.cvtColor(img.copy(), cv2.COLOR_BGR2GRAY) , 127, 255, cv2.THRESH_BINARY)
image, contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
# find bounding box coordinates
x,y,w,h = cv2.boundingRect(c)
cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
# find minimum area
rect = cv2.minAreaRect(c)
# calculate coordinates of the minimum area rectangle
box = cv2.boxPoints(rect)
# normalize coordinates to integers
box = np.int0(box)
# draw contours
cv2.drawContours(img, [box], 0, (0,0, 255), 3)
# calculate center and radius of minimum enclosing circle
(x,y),radius = cv2.minEnclosingCircle(c)
# cast to integers
center = (int(x),int(y))
radius = int(radius)
# draw the circle
img = cv2.circle(img,center,radius,(0,255,0),2)
cv2.drawContours(img, contours, -1, (255, 0, 0), 1)
cv2.imshow("contours", img)
在初始导入之后,我们加载图像,然后在原始图像的灰度版本上应用二值阈值。通过这样做,我们在灰度副本上进行所有查找轮廓的计算,但我们绘制在原始图像上,以便利用颜色信息。
首先,让我们计算一个简单的包围盒:
x,y,w,h = cv2.boundingRect(c)
这是一个相当直接的轮廓信息转换为(x, y)坐标的转换,加上矩形的宽度和高度。绘制这个矩形是一个简单的任务,可以使用以下代码完成:
cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
其次,让我们计算包围主题的最小面积:
rect = cv2.minAreaRect(c)
box = cv2.boxPoints(rect)
box = np.int0(box)
这里使用的机制特别有趣:OpenCV 没有直接从轮廓信息计算最小矩形顶点坐标的功能。相反,我们计算最小矩形面积,然后计算这个矩形的顶点。请注意,计算出的顶点是浮点数,但像素是用整数访问的(您不能访问像素的一部分),因此我们需要进行这种转换。接下来,我们绘制这个盒子,这为我们提供了一个介绍cv2.drawContours函数的绝佳机会:
cv2.drawContours(img, [box], 0, (0,0, 255), 3)
首先,这个函数——就像所有绘图函数一样——会修改原始图像。其次,它在其第二个参数中接受一个轮廓数组,因此您可以在一次操作中绘制多个轮廓。因此,如果您有一组代表轮廓多边形的点,您需要将这些点包装成一个数组,就像我们在前面的例子中处理我们的盒子一样。这个函数的第三个参数指定了我们要绘制的轮廓数组的索引:值为-1将绘制所有轮廓;否则,将绘制轮廓数组(第二个参数)中指定索引处的轮廓。
大多数绘图函数将绘图颜色和厚度作为最后两个参数。
我们将要检查的最后一个边界轮廓是最小包围圆:
(x,y),radius = cv2.minEnclosingCircle(c)
center = (int(x),int(y))
radius = int(radius)
img = cv2.circle(img,center,radius,(0,255,0),2)
cv2.minEnclosingCircle 函数的唯一特殊性在于它返回一个包含两个元素的元组,其中第一个元素本身也是一个元组,表示圆心的坐标,第二个元素是这个圆的半径。在将这些值转换为整数后,画圆就变得相当简单了。
最终在原始图像上的结果看起来像这样:

等高线 – 凸等高线和 Douglas-Peucker 算法
大多数时候,当处理等高线时,主题将具有最多样化的形状,包括凸形。凸形是指在这个形状内部有两个点,它们之间的连线会超出形状本身的轮廓。
OpenCV 提供的第一个用于计算形状近似边界多边形的工具是 cv2.approxPolyDP。这个函数有三个参数:
-
等高线
-
一个 epsilon 值,表示原始等高线和近似多边形之间的最大差异(值越低,近似值越接近原始等高线)
-
一个布尔标志,表示多边形是封闭的
epsilon 值对于获得有用的等高线至关重要,因此让我们了解它代表什么。epsilon 是近似多边形周长和原始等高线周长之间的最大差异。这个差异越低,近似多边形就越接近原始等高线。
当我们已经有了一个精确表示的等高线时,你可能想知道为什么我们还需要一个近似的 polygon。答案是,多边形是一系列直线,能够在区域内定义多边形以便进一步的操作和处理,这在许多计算机视觉任务中至关重要。
现在我们已经知道了 epsilon 是什么,我们需要获取等高线周长信息作为参考值。这可以通过 OpenCV 的 cv2.arcLength 函数获得:
epsilon = 0.01 * cv2.arcLength(cnt, True)
approx = cv2.approxPolyDP(cnt, epsilon, True)
实际上,我们是在指示 OpenCV 计算一个近似的多边形,其周长只能在一个 epsilon 比率内与原始等高线不同。
OpenCV 还提供了一个 cv2.convexHull 函数来获取凸形状的处理后的等高线信息,这是一个简单的单行表达式:
hull = cv2.convexHull(cnt)
让我们将原始等高线、近似的多边形等高线和凸包结合在一个图像中,以观察它们之间的差异。为了简化问题,我已经将等高线应用到一张黑底图像上,这样原始主题不可见,但其轮廓是可见的:

如您所见,凸包包围了整个主题,近似多边形是最内层的多边形形状,两者之间是原始等高线,主要由弧线组成。
线和圆检测
检测边缘和轮廓不仅是常见且重要的任务,它们还构成了其他复杂操作的基础。线和形状检测与边缘和轮廓检测是相辅相成的,因此让我们看看 OpenCV 如何实现这些。
线和形状检测背后的理论基于一种称为 Hough 变换的技术,由 Richard Duda 和 Peter Hart 发明,他们扩展(推广)了 Paul Hough 在 20 世纪 60 年代初的工作。
让我们看看 OpenCV 的 Hough 变换 API。
线检测
首先,让我们使用 HoughLines 和 HoughLinesP 函数检测一些线,这是通过 HoughLines 函数调用来完成的。这两个函数之间的唯一区别是,一个使用标准 Hough 变换,另一个使用概率 Hough 变换(因此名称中的 P)。
概率版本之所以被称为概率版本,是因为它只分析点的一个子集,并估计这些点全部属于同一条线的概率。这个实现是标准 Hough 变换的优化版本,在这种情况下,它计算量更小,执行速度更快。
让我们来看一个非常简单的例子:
import cv2
import numpy as np
img = cv2.imread('lines.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
edges = cv2.Canny(gray,50,120)
minLineLength = 20
maxLineGap = 5
lines = cv2.HoughLinesP(edges,1,np.pi/180,100,minLineLength,maxLineGap)
for x1,y1,x2,y2 in lines[0]:
cv2.line(img,(x1,y1),(x2,y2),(0,255,0),2)
cv2.imshow("edges", edges)
cv2.imshow("lines", img)
cv2.waitKey()
cv2.destroyAllWindows()
这个简单脚本的关键点——除了 HoughLines 函数调用之外——是设置最小线长度(较短的线将被丢弃)和最大线间隙,这是在两条线段开始被视为单独的线之前,线中最大间隙的大小。
还要注意,HoughLines 函数需要一个单通道二值图像,该图像通过 Canny 边缘检测滤波器处理。Canny 不是严格的要求;一个去噪后只代表边缘的图像,是 Hough 变换的理想来源,因此你会发现这是一种常见的做法。
HoughLinesP 的参数如下:
-
我们想要处理的图像。
-
线的几何表示,
rho和theta,通常为1和np.pi/180。 -
阈值,表示低于此阈值的线将被丢弃。Hough 变换使用一个由桶和投票组成的系统,每个桶代表一条线,因此任何获得
<阈值>投票的线将被保留,其余的将被丢弃。 -
我们之前提到的
MinLineLength和MaxLineGap。
圆检测
OpenCV 还有一个用于检测圆的功能,称为 HoughCircles。它的工作方式与 HoughLines 非常相似,但 minLineLength 和 maxLineGap 是用于丢弃或保留线的参数,而 HoughCircles 有圆心之间的最小距离、最小和最大半径等参数。下面是一个必看的示例:
import cv2
import numpy as np
planets = cv2.imread('planet_glow.jpg')
gray_img = cv2.cvtColor(planets, cv2.COLOR_BGR2GRAY)
img = cv2.medianBlur(gray_img, 5)
cimg = cv2.cvtColor(img,cv2.COLOR_GRAY2BGR)
circles = cv2.HoughCircles(img,cv2.HOUGH_GRADIENT,1,120,
param1=100,param2=30,minRadius=0,maxRadius=0)
circles = np.uint16(np.around(circles))
for i in circles[0,:]:
# draw the outer circle
cv2.circle(planets,(i[0],i[1]),i[2],(0,255,0),2)
# draw the center of the circle
cv2.circle(planets,(i[0],i[1]),2,(0,0,255),3)
cv2.imwrite("planets_circles.jpg", planets)
cv2.imshow("HoughCirlces", planets)
cv2.waitKey()
cv2.destroyAllWindows()
下面是结果的可视表示:

检测形状
使用霍夫变换检测形状仅限于圆形;然而,当我们讨论approxPolyDP时,我们已经隐式地探索了检测任何形状的方法。这个函数允许对多边形进行近似,所以如果你的图像包含多边形,它们将被相当准确地检测到,这结合了cv2.findContours和cv2.approxPolyDP的使用。
摘要
到目前为止,你应该已经对颜色空间、傅里叶变换以及 OpenCV 提供的用于处理图像的几种类型的过滤器有了很好的理解。
你还应该熟练地检测边缘、线条、圆形以及一般形状。此外,你应该能够找到轮廓并利用它们提供关于图像中包含的主题的信息。这些概念将作为探索下一章主题的理想背景。
第四章:深度估计与分割
本章向您展示如何使用深度相机的数据来识别前景和背景区域,以便我们可以将效果限制在仅前景或仅背景。作为先决条件,我们需要一个深度相机,例如微软 Kinect,并且我们需要构建支持我们深度相机的 OpenCV。有关构建说明,请参阅第一章,设置 OpenCV。
本章我们将处理两个主要主题:深度估计和分割。我们将通过两种不同的方法来探索深度估计:首先,使用深度相机(本章第一部分的先决条件),例如微软 Kinect;然后,使用立体图像,对于普通相机就足够了。有关如何构建支持深度相机的 OpenCV 的说明,请参阅第一章,设置 OpenCV。本章的第二部分是关于分割,这是一种允许我们从图像中提取前景对象的技术。
创建模块
捕获和处理深度相机数据的代码将在Cameo.py外部可重用。因此,我们应该将其分离到一个新的模块中。让我们在Cameo.py相同的目录下创建一个名为depth.py的文件。在depth.py中,我们需要以下import语句:
import numpy
我们还需要修改现有的rects.py文件,以便我们的复制操作可以限制在矩形的非矩形子区域内。为了支持我们将要进行的更改,让我们向rects.py添加以下import语句:
import numpy
import utils
最后,我们应用的新版本将使用与深度相关的功能。因此,让我们向Cameo.py添加以下import语句:
import depth
现在,让我们更深入地探讨深度主题。
从深度相机捕获帧
在第二章,处理文件、相机和 GUI中,我们讨论了计算机可以拥有多个视频捕获设备,并且每个设备可以有多个通道的概念。假设一个给定的设备是立体相机。每个通道可能对应不同的镜头和传感器。此外,每个通道可能对应不同类型的数据,例如正常彩色图像与深度图。OpenCV 的 C++版本定义了一些用于某些设备和通道标识符的常量。然而,这些常量在 Python 版本中并未定义。
为了解决这个问题,让我们在depth.py中添加以下定义:
# Devices.CAP_OPENNI = 900 # OpenNI (for Microsoft Kinect)CAP_OPENNI_ASUS = 910 # OpenNI (for Asus Xtion)
# Channels of an OpenNI-compatible depth generator.CAP_OPENNI_DEPTH_MAP = 0 # Depth values in mm (16UC1)CAP_OPENNI_POINT_CLOUD_MAP = 1 # XYZ in meters (32FC3)CAP_OPENNI_DISPARITY_MAP = 2 # Disparity in pixels (8UC1)CAP_OPENNI_DISPARITY_MAP_32F = 3 # Disparity in pixels (32FC1)CAP_OPENNI_VALID_DEPTH_MASK = 4 # 8UC1
# Channels of an OpenNI-compatible RGB image generator.CAP_OPENNI_BGR_IMAGE = 5CAP_OPENNI_GRAY_IMAGE = 6
深度相关通道需要一些解释,如下所示列表中所述:
-
深度图是一种灰度图像,其中每个像素值代表从相机到表面的估计距离。具体来说,来自
CAP_OPENNI_DEPTH_MAP通道的图像将距离表示为毫米的浮点数。 -
点云图是一个彩色图像,其中每个颜色对应于一个(x,y 或 z)空间维度。具体来说,
CAP_OPENNI_POINT_CLOUD_MAP通道产生一个 BGR 图像,其中 B 是 x(蓝色是右侧),G 是 y(绿色是上方),R 是 z(红色是深度),从相机的视角来看。这些值以米为单位。 -
视差图是一个灰度图像,其中每个像素值是表面的立体视差。为了概念化立体视差,让我们假设我们叠加了从不同视角拍摄的同一场景的两个图像。结果将类似于看到双重图像。对于场景中任何一对孪生物体上的点,我们可以测量像素距离。这种测量是立体视差。靠近的物体表现出比远处的物体更大的立体视差。因此,靠近的物体在视差图中看起来更亮。
-
一个有效的深度掩码显示给定像素处的深度信息是否被认为是有效的(通过非零值表示)或无效的(通过零值表示)。例如,如果深度相机依赖于红外照明器(红外闪光灯),那么从该光源被遮挡(阴影)的区域中的深度信息是无效的。
以下截图显示了一个坐在猫雕塑后面的男人的点云图:

以下截图显示了一个坐在猫雕塑后面的男人的视差图:

以下截图显示了坐在猫雕塑后面的男人的有效深度掩码:

从视差图创建掩码
对于 Cameo 的目的,我们感兴趣的是视差图和有效深度掩码。它们可以帮助我们细化我们对面部区域的估计。
使用 FaceTracker 函数和正常彩色图像,我们可以获得面部区域的矩形估计。通过分析相应的视差图中的这样一个矩形区域,我们可以知道矩形内的某些像素是异常值——太近或太远,实际上不可能是面部的一部分。我们可以细化面部区域以排除这些异常值。然而,我们只应在数据有效的地方应用此测试,正如有效深度掩码所示。
让我们编写一个函数来生成一个掩码,其值对于被拒绝的面部矩形区域为 0,对于接受的区域为 1。这个函数应该接受视差图、有效深度掩码和一个矩形作为参数。我们可以在 depth.py 中实现它如下:
def createMedianMask(disparityMap, validDepthMask, rect = None):
"""Return a mask selecting the median layer, plus shadows."""
if rect is not None:
x, y, w, h = rect
disparityMap = disparityMap[y:y+h, x:x+w]
validDepthMask = validDepthMask[y:y+h, x:x+w]
median = numpy.median(disparityMap)
return numpy.where((validDepthMask == 0) | \
(abs(disparityMap - median) < 12),
1.0, 0.0)
为了在视差图中识别异常值,我们首先使用 numpy.median() 函数找到中位数,该函数需要一个数组作为参数。如果数组长度为奇数,median() 函数返回如果数组排序后位于中间的值。如果数组长度为偶数,median() 函数返回位于数组中间两个排序值之间的平均值。
要根据每个像素的布尔操作生成掩码,我们使用 numpy.where() 并提供三个参数。在第一个参数中,where() 接收一个数组,其元素被评估为真或假。返回一个具有相同维度的输出数组。在输入数组中的任何元素为 true 时,where() 函数的第二参数被分配给输出数组中的相应元素。相反,在输入数组中的任何元素为 false 时,where() 函数的第三参数被分配给输出数组中的相应元素。
我们的实现将具有有效视差值且与中值视差值偏差 12 或更多的像素视为异常值。我仅通过实验选择了 12 这个值。请根据您使用特定相机设置运行 Cameo 时遇到的结果自由调整此值。
对复制操作进行掩码处理
作为前一章工作的部分,我们将 copyRect() 编写为一个复制操作,该操作限制自己仅限于源和目标图像的给定矩形。现在,我们想要进一步限制这个复制操作。我们想要使用一个与源矩形具有相同尺寸的给定掩码。
我们将只复制源矩形中掩码值为非零的像素。其他像素将保留其从目标图像中的旧值。这种逻辑,使用条件数组以及两个可能的输出值数组,可以用我们最近学习的 numpy.where() 函数简洁地表达。
让我们打开 rects.py 并编辑 copyRect() 以添加一个新的掩码参数。这个参数可能是 None,在这种情况下,我们将回退到我们旧的复制操作实现。否则,我们接下来确保掩码和图像具有相同数量的通道。我们假设掩码有一个通道,但图像可能有三个通道(BGR)。我们可以使用 numpy.array 的 repeat() 和 reshape() 方法向掩码添加重复的通道。
最后,我们使用 where() 执行复制操作。完整的实现如下:
def copyRect(src, dst, srcRect, dstRect, mask = None,
interpolation = cv2.INTER_LINEAR):
"""Copy part of the source to part of the destination."""
x0, y0, w0, h0 = srcRect
x1, y1, w1, h1 = dstRect
# Resize the contents of the source sub-rectangle.
# Put the result in the destination sub-rectangle.
if mask is None:
dst[y1:y1+h1, x1:x1+w1] = \
cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
interpolation = interpolation)
else:
if not utils.isGray(src):
# Convert the mask to 3 channels, like the image.
mask = mask.repeat(3).reshape(h0, w0, 3)
# Perform the copy, with the mask applied.
dst[y1:y1+h1, x1:x1+w1] = \
numpy.where(cv2.resize(mask, (w1, h1),
interpolation = \
cv2.INTER_NEAREST),
cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
interpolation = interpolation),
dst[y1:y1+h1, x1:x1+w1])
我们还需要修改我们的 swapRects() 函数,该函数使用 copyRect() 来执行一系列矩形区域的环形交换。对 swapRects() 的修改非常简单。我们只需要添加一个新的 masks 参数,它是一个包含掩码的列表,这些掩码的元素被传递到相应的 copyRect() 调用中。如果给定的 masks 参数值为 None,我们将 None 传递给每个 copyRect() 调用。
以下代码展示了这一实现的完整内容:
def swapRects(src, dst, rects, masks = None,
interpolation = cv2.INTER_LINEAR):
"""Copy the source with two or more sub-rectangles swapped."""
if dst is not src:
dst[:] = src
numRects = len(rects)
if numRects < 2:
return
if masks is None:
masks = [None] * numRects
# Copy the contents of the last rectangle into temporary storage.
x, y, w, h = rects[numRects - 1]
temp = src[y:y+h, x:x+w].copy()
# Copy the contents of each rectangle into the next.
i = numRects - 2
while i >= 0:
copyRect(src, dst, rects[i], rects[i+1], masks[i],
interpolation)
i -= 1
# Copy the temporarily stored content into the first rectangle.
copyRect(temp, dst, (0, 0, w, h), rects[0], masks[numRects - 1],
interpolation)
注意,copyRect() 和 swapRects() 中的 masks 参数默认为 None。因此,我们这些函数的新版本与我们的 Cameo 旧版本向后兼容。
使用普通相机进行深度估计
深度相机是一种捕捉图像并估计物体与相机之间距离的神奇小设备,但是,深度相机是如何检索深度信息的?此外,是否可以使用普通相机重现同样的计算?
深度相机,如 Microsoft Kinect,使用一个传统的相机结合一个红外传感器,这有助于相机区分相似的对象并计算它们与相机的距离。然而,并不是每个人都能接触到深度相机或 Kinect,尤其是在你刚开始学习 OpenCV 时,你可能不会投资昂贵的设备,直到你觉得自己技能已经磨炼得很好,你对这个主题的兴趣也得到了确认。
我们的设置包括一个简单的相机,这很可能是集成在我们的机器中,或者是一个连接到我们电脑的摄像头。因此,我们需要求助于不那么花哨的方法来估计物体与相机之间的距离差异。
在这种情况下,几何学将提供帮助,特别是极线几何,它是立体视觉的几何学。立体视觉是计算机视觉的一个分支,它从同一主题的两个不同图像中提取三维信息。
极线几何是如何工作的呢?从概念上讲,它从相机向图像中的每个对象绘制想象中的线条,然后在第二张图像上做同样的事情,并基于对应对象的线条交点来计算物体的距离。以下是这个概念的一个表示:

让我们看看 OpenCV 是如何应用极线几何来计算所谓的视差图,这基本上是图像中检测到的不同深度的表示。这将使我们能够提取图片的前景并丢弃其余部分。
首先,我们需要从不同的视角拍摄同一主题的两个图像,但要注意,图片是从与物体等距离的位置拍摄的,否则计算将失败,视差图将没有意义。
那么,让我们继续一个例子:
import numpy as np
import cv2
def update(val = 0):
# disparity range is tuned for 'aloe' image pair
stereo.setBlockSize(cv2.getTrackbarPos('window_size', 'disparity'))
stereo.setUniquenessRatio(cv2.getTrackbarPos('uniquenessRatio', 'disparity'))
stereo.setSpeckleWindowSize(cv2.getTrackbarPos('speckleWindowSize', 'disparity'))
stereo.setSpeckleRange(cv2.getTrackbarPos('speckleRange', 'disparity'))
stereo.setDisp12MaxDiff(cv2.getTrackbarPos('disp12MaxDiff', 'disparity'))
print 'computing disparity...'
disp = stereo.compute(imgL, imgR).astype(np.float32) / 16.0
cv2.imshow('left', imgL)
cv2.imshow('disparity', (disp-min_disp)/num_disp)
if __name__ == "__main__":
window_size = 5
min_disp = 16
num_disp = 192-min_disp
blockSize = window_size
uniquenessRatio = 1
speckleRange = 3
speckleWindowSize = 3
disp12MaxDiff = 200
P1 = 600
P2 = 2400
imgL = cv2.imread('images/color1_small.jpg')
imgR = cv2.imread('images/color2_small.jpg')
cv2.namedWindow('disparity')
cv2.createTrackbar('speckleRange', 'disparity', speckleRange, 50, update)
cv2.createTrackbar('window_size', 'disparity', window_size, 21, update)
cv2.createTrackbar('speckleWindowSize', 'disparity', speckleWindowSize, 200, update)
cv2.createTrackbar('uniquenessRatio', 'disparity', uniquenessRatio, 50, update)
cv2.createTrackbar('disp12MaxDiff', 'disparity', disp12MaxDiff, 250, update)
stereo = cv2.StereoSGBM_create(
minDisparity = min_disp,
numDisparities = num_disp,
blockSize = window_size,
uniquenessRatio = uniquenessRatio,
speckleRange = speckleRange,
speckleWindowSize = speckleWindowSize,
disp12MaxDiff = disp12MaxDiff,
P1 = P1,
P2 = P2
)
update()
cv2.waitKey()
在这个例子中,我们取同一主题的两个图像,并计算一个视差图,用较亮的颜色显示地图中靠近相机的点。用黑色标记的区域代表视差。
首先,我们像往常一样导入numpy和cv2。
让我们先暂时跳过update函数的定义,看看主要代码;这个过程相当简单:加载两个图像,创建一个StereoSGBM实例(StereoSGBM代表半全局块匹配,这是一种用于计算视差图的算法),并创建一些滑块来调整算法的参数,然后调用update函数。
update函数将滑块值应用于StereoSGBM实例,然后调用compute方法,该方法生成视差图。总的来说,相当简单!以下是第一个我使用的图像:

这是第二个:

你看:这是一个既好又容易解释的视差图。

StereoSGBM使用的参数如下(摘自 OpenCV 文档):
| 参数 | 描述 |
|---|---|
minDisparity |
此参数表示可能的最小视差值。通常为零,但有时校正算法可以移动图像,因此需要相应地调整此参数。 |
numDisparities |
此参数表示最大视差减去最小视差。结果值始终大于零。在当前实现中,此参数必须是 16 的倍数。 |
windowSize |
此参数表示匹配块的大小。它必须是一个大于或等于 1 的奇数。通常,它应该在 3-11 的范围内。 |
P1 |
此参数表示控制视差平滑度的第一个参数。参见下一点。 |
P2 |
此参数表示控制视差平滑度的第二个参数。值越大,视差越平滑。P1是相邻像素之间视差变化加减 1 的惩罚。P2是相邻像素之间视差变化超过 1 的惩罚。算法要求P2 > P1。请参见stereo_match.cpp示例,其中显示了某些合理的P1和P2值(例如8*number_of_image_channels*windowSize*windowSize和32*number_of_image_channels*windowSize*windowSize,分别)。 |
disp12MaxDiff |
此参数表示左右视差检查允许的最大差异(以整数像素为单位)。将其设置为非正值以禁用检查。 |
preFilterCap |
此参数表示预滤波图像像素的截断值。算法首先计算每个像素的 x 导数,并通过[-preFilterCap, preFilterCap]区间剪辑其值。结果值传递给 Birchfield-Tomasi 像素成本函数。 |
uniquenessRatio |
此参数表示最佳(最小)计算成本函数值相对于第二最佳值应“获胜”的百分比边缘。通常,5-15 范围内的值就足够好了。 |
speckleWindowSize |
此参数表示考虑其噪声斑点和无效化的平滑视差区域的最大大小。将其设置为0以禁用斑点滤波。否则,将其设置为 50-200 范围内的某个值。 |
speckleRange |
此参数指代每个连通组件内的最大视差变化。如果你进行斑点滤波,将参数设置为正值;它将隐式地乘以 16。通常,1 或 2 就足够了。 |
使用前面的脚本,你可以加载图像并调整参数,直到你对StereoSGBM生成的视差图满意为止。
使用 Watershed 和 GrabCut 算法进行对象分割
计算视差图对于检测图像的前景非常有用,但StereoSGBM并非完成此任务的唯一算法,实际上,StereoSGBM更多的是从二维图片中收集三维信息,而不是其他。然而,GrabCut是完成此目的的完美工具。GrabCut 算法遵循一系列精确的步骤:
-
定义一个包含图片主题(s)的矩形。
-
位于矩形外部区域自动定义为背景。
-
背景中的数据用作参考,以区分用户定义矩形内的背景区域和前景区域。
-
高斯混合模型(GMM)对前景和背景进行建模,并将未定义的像素标记为可能的背景和前景。
-
图像中的每个像素通过虚拟边与周围的像素虚拟连接,每个边根据其与周围像素在颜色上的相似性获得成为前景或背景的概率。
-
每个像素(或算法中概念化的节点)连接到前景节点或背景节点,你可以想象成这样:
![使用 Watershed 和 GrabCut 算法进行对象分割]()
-
在节点连接到任一终端(背景或前景,也称为源和汇)之后,属于不同终端的节点之间的边被切断(算法中著名的切割部分),这使得图像部分的分离成为可能。此图充分代表了该算法:
![使用 Watershed 和 GrabCut 算法进行对象分割]()
使用 GrabCut 进行前景检测的示例
让我们来看一个例子。我们从一个美丽的天使雕像的图片开始。

我们想要抓住我们的天使并丢弃背景。为此,我们将创建一个相对简短的脚本,该脚本将实例化 GrabCut,执行分离,然后将生成的图像与原始图像并排显示。我们将使用matplotlib,这是一个非常有用的 Python 库,它使得显示图表和图像变得非常简单:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img = cv2.imread('images/statue_small.jpg')
mask = np.zeros(img.shape[:2],np.uint8)
bgdModel = np.zeros((1,65),np.float64)
fgdModel = np.zeros((1,65),np.float64)
rect = (100,50,421,378)
cv2.grabCut(img,mask,rect,bgdModel,fgdModel,5,cv2.GC_INIT_WITH_RECT)
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')
img = img*mask2[:,:,np.newaxis]
plt.subplot(121), plt.imshow(img)
plt.title("grabcut"), plt.xticks([]), plt.yticks([])
plt.subplot(122), plt.imshow(cv2.cvtColor(cv2.imread('images/statue_small.jpg'), cv2.COLOR_BGR2RGB))
plt.title("original"), plt.xticks([]), plt.yticks([])
plt.show()
这段代码实际上非常直接。首先,我们加载我们想要处理的图像,然后我们创建一个与加载的图像形状相同的零填充掩码:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img = cv2.imread('images/statue_small.jpg')
mask = np.zeros(img.shape[:2],np.uint8)
然后,我们创建零填充的前景和背景模型:
bgdModel = np.zeros((1,65),np.float64)
fgdModel = np.zeros((1,65),np.float64)
我们可以用数据填充这些模型,但我们将使用一个矩形来初始化 GrabCut 算法,以识别我们想要隔离的主题。因此,背景和前景模型将基于初始矩形之外的区域来确定。这个矩形在下一行定义:
rect = (100,50,421,378)
现在来到有趣的部分!我们运行 GrabCut 算法,指定空模型和掩码,以及我们将使用矩形来初始化操作:
cv2.grabCut(img,mask,rect,bgdModel,fgdModel,5,cv2.GC_INIT_WITH_RECT)
你也会注意到fgdModel后面有一个整数,这是算法将在图像上运行的迭代次数。你可以增加这些迭代次数,但像素分类会收敛到一个点,实际上,你只是在增加迭代次数而没有获得任何更多的改进。
之后,我们的掩码将改变,包含介于 0 和 3 之间的值。值0和2将被转换为零,1-3 将被转换为 1,并存储到mask2中,然后我们可以使用它来过滤掉所有零值像素(理论上留下所有前景像素):
mask2 = np.where((mask==2)|(mask==0),0,1).astype('uint8')
img = img*mask2[:,:,np.newaxis]
代码的最后部分显示了并排的图像,这是结果:

这是一个相当令人满意的结果。你会注意到天使的胳膊下留下了一块背景区域。可以通过触摸笔触来应用更多迭代;这种技术在samples/python2目录下的grabcut.py文件中有很好的说明。
使用 Watershed 算法进行图像分割
最后,我们简要了解一下 Watershed 算法。该算法被称为 Watershed,因为其概念化涉及水。想象一下图像中低密度(几乎没有变化)的区域为山谷,高密度(变化很多)的区域为山峰。开始往山谷中注水,直到两个不同山谷的水即将汇合。为了防止不同山谷的水汇合,你建立一道屏障来保持它们分离。形成的屏障就是图像分割。
作为一名意大利人,我喜欢食物,我最喜欢的东西之一就是一份美味的意面配以香蒜酱。所以,这是香蒜酱最重要的成分罗勒的图片:

现在,我们想要分割图像,将罗勒叶从白色背景中分离出来。
再次,我们导入numpy、cv2和matplotlib,然后导入我们的罗勒叶图像:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img = cv2.imread('images/basil.jpg')
gray = cv2.cvtColor(img,cv2.COLOR_BGR2GRAY)
在将颜色转换为灰度后,我们对图像进行阈值处理。这个操作有助于将图像分为黑白两部分:
ret, thresh = cv2.threshold(gray,0,255,cv2.THRESH_BINARY_INV+cv2.THRESH_OTSU)
接下来,我们通过应用morphologyEx变换来从图像中去除噪声,这是一个由膨胀和侵蚀图像以提取特征的操作:
kernel = np.ones((3,3),np.uint8)
opening = cv2.morphologyEx(thresh,cv2.MORPH_OPEN,kernel, iterations = 2)
通过膨胀morphology变换的结果,我们可以获得图像中几乎肯定是背景的区域:
sure_bg = cv2.dilate(opening,kernel,iterations=3)
相反,我们可以通过应用distanceTransform来获得确切的背景区域。在实践中,在所有最可能成为前景的区域中,一个点离背景“边界”越远,它成为前景的可能性就越高。一旦我们获得了图像的distanceTransform表示,我们就应用一个阈值,以高度数学的概率确定这些区域是否为前景:
dist_transform = cv2.distanceTransform(opening,cv2.DIST_L2,5)
ret, sure_fg = cv2.threshold(dist_transform,0.7*dist_transform.max(),255,0)
在这个阶段,我们有一些确切的背景和前景。那么,中间区域怎么办?首先,我们需要确定这些区域,这可以通过从背景中减去确切的背景来完成:
sure_fg = np.uint8(sure_fg)
unknown = cv2.subtract(sure_bg,sure_fg)
现在我们有了这些区域,我们可以构建我们著名的“障碍物”来阻止水合并。这是通过connectedComponents函数完成的。当我们分析 GrabCut 算法时,我们瞥了一眼图论,并将图像视为由边连接的节点集合。给定确切的背景区域,这些节点中的一些将连接在一起,但也有一些不会。这意味着它们属于不同的水谷,它们之间应该有一个障碍物:
ret, markers = cv2.connectedComponents(sure_fg)
现在我们将背景区域的值加 1,因为我们只想让未知区域保持在0:
markers = markers+1
markers[unknown==255] = 0
最后,我们打开大门!让水落下,我们的障碍物用红色绘制:
markers = cv2.watershed(img,markers)
img[markers == -1] = [255,0,0]
plt.imshow(img)
plt.show()
现在,让我们展示结果:

不言而喻,我现在很饿!
摘要
在本章中,我们学习了如何从二维输入(视频帧或图像)中收集三维信息。首先,我们考察了深度相机,然后是极线几何和立体图像,因此我们现在能够计算视差图。最后,我们探讨了两种最流行的图像分割方法:GrabCut 和 Watershed。
本章带我们进入了从图像中解释信息的世界,我们现在准备好探索 OpenCV 的另一个重要特性:特征描述符和关键点检测。
第五章. 识别人脸
在众多使计算机视觉成为一个迷人主题的原因中,计算机视觉使许多听起来非常未来主义的任务成为现实。其中一项功能就是人脸检测。OpenCV 内置了执行人脸检测的功能,这在现实世界的各种环境中几乎有无限的应用,从安全到娱乐。
本章介绍了 OpenCV 的一些人脸检测功能,以及定义特定类型可追踪对象的数据文件。具体来说,我们研究了 Haar 级联分类器,这些分类器通过分析相邻图像区域之间的对比度来确定给定的图像或子图像是否与已知类型匹配。我们考虑了如何将多个 Haar 级联分类器组合成一个层次结构,以便一个分类器识别父区域(在我们的目的中是脸部),而其他分类器识别子区域(眼睛、鼻子和嘴巴)。
我们还简要地探讨了矩形这个谦逊但重要的主题。通过绘制、复制和调整矩形图像区域的大小,我们可以对我们正在追踪的图像区域进行简单的操作。
到本章结束时,我们将把人脸追踪和矩形操作集成到 Cameo 中。最后,我们将实现一些面对面互动!
概念化 Haar 级联
当我们谈论分类对象和追踪它们的位置时,我们究竟希望精确到什么程度?构成物体可识别部分的是什么?
照片图像,即使是来自网络摄像头的,也可能包含大量的细节,以满足我们(人类)观看的愉悦。然而,图像细节在光照、视角、观看距离、相机抖动和数字噪声变化方面往往是不稳定的。此外,即使是物理细节的真实差异,也可能对我们进行分类的目的不感兴趣。我在学校学到的是,在显微镜下,没有两片雪花看起来是相同的。幸运的是,作为一个加拿大孩子,我已经学会了如何在没有显微镜的情况下识别雪花,因为在大批量中,它们的相似性更为明显。
因此,在产生稳定的分类和追踪结果时,抽象图像细节的一些方法是有用的。这些抽象被称为特征,据说它们是从图像数据中提取出来的。尽管任何像素都可能影响多个特征,但特征的数量应该远少于像素。两个图像之间的相似程度可以根据图像对应特征的欧几里得距离来评估。
例如,距离可以定义为空间坐标或颜色坐标。Haar 类似特征是常用于实时人脸跟踪的一种特征类型。它们首次在论文 Robust Real-Time Face Detection 中被用于此目的,作者为 Paul Viola and Michael Jones,出版于 Kluwer Academic Publishers,2001 年(可在 www.vision.caltech.edu/html-files/EE148-2005-Spring/pprs/viola04ijcv.pdf 获取)。每个 Haar 类似特征描述了相邻图像区域之间的对比度模式。例如,边缘、顶点和细线各自生成独特的特征。
对于任何给定的图像,特征可能会根据区域的大小而变化;这可以称为 窗口大小。只有缩放不同的两个图像应该能够产生相似的特征,尽管窗口大小不同。因此,为多个窗口大小生成特征是有用的。这种特征集合被称为 级联。我们可以说 Haar 级联是尺度不变的,换句话说,对尺度变化具有稳健性。OpenCV 提供了一个分类器和跟踪器,用于处理期望以特定文件格式存在的尺度不变 Haar 级联。
OpenCV 中实现的 Haar 级联对旋转变化不稳健。例如,一个颠倒的脸不被认为是直立脸的相似,侧面看脸也不被认为是正面看脸的相似。一个更复杂且资源消耗更大的实现可以通过考虑图像的多个变换以及多个窗口大小来提高 Haar 级联对旋转的稳健性。然而,我们将局限于 OpenCV 中的实现。
获取 Haar 级联数据
一旦你有了 OpenCV 3 的源代码副本,你将找到一个名为 data/haarcascades 的文件夹。
这个文件夹包含 OpenCV 人脸检测引擎用于在静态图像、视频和摄像头流中检测人脸所使用的所有 XML 文件。
一旦找到 haarcascades,为你的项目创建一个目录;在这个文件夹中,创建一个名为 cascades 的子文件夹,并将以下文件从 haarcascades 复制到 cascades:
haarcascade_profileface.xml
haarcascade_righteye_2splits.xml
haarcascade_russian_plate_number.xml
haarcascade_smile.xml
haarcascade_upperbody.xml
如其名称所示,这些级联用于跟踪人脸、眼睛、鼻子和嘴巴。它们需要被检测对象的正面、直立视角。我们将在构建人脸检测器时使用它们。如果你对如何生成这些数据集感兴趣,请参阅 附录 B,为自定义目标生成 Haar 级联,使用 Python 的 OpenCV 计算机视觉。有了足够的耐心和一台强大的计算机,你可以制作自己的级联并为各种类型的对象进行训练。
使用 OpenCV 进行人脸检测
与你一开始可能想到的不同,在静态图像或视频流上执行人脸检测是一个极其相似的操作。后者只是前者的顺序版本:在视频中的人脸检测只是将人脸检测应用到从摄像头读入程序中的每一帧。自然地,许多概念都应用于视频人脸检测,例如跟踪,这在静态图像中不适用,但了解底层理论是相同的。
让我们继续检测一些人脸。
在静态图像上执行人脸检测
执行人脸检测的第一种也是最基本的方法是加载一张图像并在其中检测人脸。为了使结果在视觉上具有意义,我们将在原始图像上的人脸周围绘制矩形。
现在你已经将 haarcascades 包含在你的项目中,让我们继续创建一个基本的脚本来执行人脸检测。
import cv2
filename = '/path/to/my/pic.jpg'
def detect(filename):
face_cascade = cv2.CascadeClassifier('./cascades/haarcascade_frontalface_default.xml')
img = cv2.imread(filename)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in faces:
img = cv2.rectangle(img,(x,y),(x+w,y+h),(255,0,0),2)
cv2.namedWindow('Vikings Detected!!')
cv2.imshow('Vikings Detected!!', img)
cv2.imwrite('./vikings.jpg', img)
cv2.waitKey(0)
detect(filename)
让我们来看一下代码。首先,我们使用必要的 cv2 导入(你会发现这本书中的每个脚本都会这样开始,或者几乎相似)。其次,我们声明 detect 函数。
def detect(filename):
在这个函数中,我们声明一个 face_cascade 变量,它是一个用于人脸的 CascadeClassifier 对象,负责人脸检测。
face_cascade =
cv2.CascadeClassifier('./cascades/haarcascade_frontalface_default.xml')
然后,我们使用 cv2.imread 加载我们的文件,并将其转换为灰度图,因为人脸检测是在这个颜色空间中进行的。
下一步(face_cascade.detectMultiScale)是我们执行实际人脸检测的地方。
img = cv2.imread(filename)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
传递的参数是 scaleFactor 和 minNeighbors,它们决定了在人脸检测过程的每一迭代中图像的百分比缩减,以及每个迭代中每个脸矩形保留的最小邻居数。一开始这可能看起来有点复杂,但你可以在官方文档中查看所有选项。
检测操作返回的值是一个表示脸矩形的元组数组。实用方法 cv2.rectangle 允许我们在指定的坐标处绘制矩形(x 和 y 代表左上坐标,w 和 h 代表脸矩形的宽度和高度)。
我们将通过遍历 faces 变量来绘制我们找到的所有人脸周围的蓝色矩形,确保我们使用原始图像进行绘制,而不是灰度版本。
for (x,y,w,h) in faces:
img = cv2.rectangle(img,(x,y),(x+w,y+h),(255,0,0),2)
最后,我们创建一个 namedWindow 实例,并在其中显示处理后的图像。为了防止图像窗口自动关闭,我们插入一个 waitKey 调用,按下任意键时关闭窗口。
cv2.namedWindow('Vikings Detected!!')
cv2.imshow('Vikings Detected!!', img)
cv2.waitKey(0)
就这样,我们已经在图像中检测到了一整队维京人,如下面的截图所示:

在视频上执行人脸检测
现在我们有一个很好的基础来理解如何在静态图像上执行人脸检测。如前所述,我们可以在视频的各个帧上重复此过程(无论是摄像头流还是视频)并执行人脸检测。
脚本将执行以下任务:它将打开摄像头流,读取一帧,检查该帧中的人脸,扫描检测到的人脸内的眼睛,然后将在脸部周围绘制蓝色矩形,在眼睛周围绘制绿色矩形。
-
让我们创建一个名为
face_detection.py的文件,并首先导入必要的模块:import cv2 -
在此之后,我们声明一个名为
detect()的方法,它将执行人脸检测。def detect(): face_cascade = cv2.CascadeClassifier('./cascades/haarcascade_frontalface_default.xml') eye_cascade = cv2.CascadeClassifier('./cascades/haarcascade_eye.xml') camera = cv2.VideoCapture(0) -
在
detect()方法内部,我们首先需要加载 Haar 级联文件,以便 OpenCV 可以执行人脸检测。由于我们在本地的cascades/文件夹中复制了级联文件,我们可以使用相对路径。然后,我们打开一个VideoCapture对象(摄像头流)。VideoCapture构造函数接受一个参数,表示要使用的摄像头;zero表示第一个可用的摄像头。while (True): ret, frame = camera.read() gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) -
接下来,我们捕获一帧。
read()方法返回两个值:一个布尔值表示读取帧操作的成功,以及帧本身。我们捕获帧,然后将其转换为灰度。这是一个必要的操作,因为 OpenCV 中的人脸检测是在灰度颜色空间中进行的:faces = face_cascade.detectMultiScale(gray, 1.3, 5) -
与单个静态图像示例类似,我们在帧的灰度版本上调用
detectMultiScale。for (x,y,w,h) in faces: img = cv2.rectangle(frame,(x,y),(x+w,y+h),(255,0,0),2) roi_gray = gray[y:y+h, x:x+w] eyes = eye_cascade.detectMultiScale(roi_gray, 1.03, 5, 0, (40,40))注意
在眼检测中还有一些额外的参数。为什么?
detectMultiScale方法的签名接受一些可选参数:在检测人脸的情况下,默认选项已经足够检测到人脸。然而,眼睛是人脸的一个较小特征,我胡须或鼻子的自阴影以及画面中的随机阴影都会触发假阳性。通过将眼睛搜索限制在最小尺寸 40x40 像素,我能够排除所有假阳性。继续测试这些参数,直到你的应用程序达到你期望的性能水平(例如,你可以尝试指定特征的最大尺寸,或者增加缩放因子和邻居数量)。
-
与静态图像示例相比,这里有一个额外的步骤:我们创建一个与脸矩形对应的感兴趣区域,并在该矩形内进行“眼检测”。这很有意义,因为你不希望在人脸之外寻找眼睛(至少对于人类来说是这样!)。
for (ex,ey,ew,eh) in eyes: cv2.rectangle(img,(ex,ey),(ex+ew,ey+eh),(0,255,0),2) -
再次,我们遍历结果眼睛元组,并在它们周围绘制绿色矩形。
cv2.imshow("camera", frame) if cv2.waitKey(1000 / 12) & 0xff == ord("q"): break camera.release() cv2.destroyAllWindows() if __name__ == "__main__": detect() -
最后,我们在窗口中显示结果帧。如果一切顺利,如果相机视野内有任何人脸,你将在其脸部周围看到一个蓝色矩形,在每个眼睛周围看到一个绿色矩形,如图所示:
![在视频上执行人脸检测]()
执行人脸识别
检测人脸是 OpenCV 的一个非常棒的功能,也是构成更高级操作(人脸识别)的基础。什么是人脸识别?它是指一个程序,在给定一个图像或视频流的情况下,能够识别一个人的能力。实现这一目标的一种方法(也是 OpenCV 采用的方法)是通过“训练”程序,给它提供一组分类图片(一个面部数据库),然后对这些图片进行识别操作。
这是 OpenCV 及其人脸识别模块遵循的过程来识别人脸。
人脸识别模块的另一个重要特性是每个识别都有一个置信度分数,这允许我们在实际应用中设置阈值以限制错误读取的数量。
让我们从一开始;要操作人脸识别,我们需要识别的面孔。你可以通过两种方式来做这件事:自己提供图像或获取免费的人脸数据库。互联网上有许多人脸数据库:
-
耶鲁 人脸数据库(Yalefaces):
vision.ucsd.edu/content/yale-face-database -
AT&T:
www.cl.cam.ac.uk/research/dtg/attarchive/facedatabase.html -
扩展耶鲁或耶鲁 B:
www.cl.cam.ac.uk/research/dtg/attarchive/facedatabase.html
要在这些样本上操作人脸识别,你将不得不在一个包含样本人物面孔的图像上运行人脸识别。这可能是一个教育过程,但我发现它不如提供自己的图像那么令人满意。事实上,我可能和许多人有同样的想法:我想知道我能否编写一个程序,以一定程度的置信度识别我的面孔。
生成人脸识别的数据
因此,让我们编写一个脚本来生成这些图像。我们只需要一些包含不同表情的图像,但我们必须确保样本图像符合某些标准:
-
图像将以
.pgm格式的灰度图形式存在 -
正方形形状
-
所有相同大小的图像(我使用了 200 x 200;大多数免费提供的集合都小于这个大小)
这是脚本本身:
import cv2
def generate():
face_cascade = cv2.CascadeClassifier('./cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier('./cascades/haarcascade_eye.xml')
camera = cv2.VideoCapture(0)
count = 0
while (True):
ret, frame = camera.read()
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.3, 5)
for (x,y,w,h) in faces:
img = cv2.rectangle(frame,(x,y),(x+w,y+h),(255,0,0),2)
f = cv2.resize(gray[y:y+h, x:x+w], (200, 200))
cv2.imwrite('./data/at/jm/%s.pgm' % str(count), f)
count += 1
cv2.imshow("camera", frame)
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
camera.release()
cv2.destroyAllWindows()
if __name__ == "__main__":
generate()
这个练习相当有趣的地方在于,我们将基于我们新获得的知识来生成样本图像,这些知识是关于如何在视频流中检测人脸的。实际上,我们正在做的是检测人脸,裁剪灰度帧的该区域,将其调整大小为 200x200 像素,并以特定的文件夹(在我的情况下,是jm;你可以使用你的首字母)中的.pgm格式保存。
我插入了一个变量 count,因为我们需要为图像设置递增的名称。运行脚本几秒钟,改变几次表情,并检查脚本中指定的目标文件夹。您将找到许多您的面部图像,它们被灰度化、调整大小并以 <count>.pgm 格式命名。
现在我们来尝试在视频流中识别我们的面部。这应该很有趣!
识别面部
OpenCV 3 提供了三种主要的面部识别方法,基于三种不同的算法:Eigenfaces、Fisherfaces和局部二值模式直方图(LBPH)。本书的范围不包括深入探讨这些方法之间理论差异的细节,但我们可以提供一个概念的高级概述。
我将为您提供以下链接,以详细描述算法:
-
主成分分析(PCA):Jonathon Shlens 提供了一个非常直观的介绍,可在
arxiv.org/pdf/1404.1100v1.pdf找到。该算法由 K. Pearson 在 1901 年发明,原始论文《On Lines and Planes of Closest Fit to Systems of Points in Space》可在stat.smmu.edu.cn/history/pearson1901.pdf找到。 -
Eigenfaces:论文《Eigenfaces for Recognition》,作者 M. Turk 和 A. Pentland,1991 年,可在
www.cs.ucsb.edu/~mturk/Papers/jcn.pdf找到。 -
Fisherfaces:开创性论文《THE USE OF MULTIPLE MEASUREMENTS IN TAXONOMIC PROBLEMS》,作者 R.A. Fisher,1936 年,可在
onlinelibrary.wiley.com/doi/10.1111/j.1469-1809.1936.tb02137.x/pdf找到。 -
局部二值模式:描述此算法的第一篇论文是《Performance evaluation of texture measures with classification based on Kullback discrimination of distributions》,作者 T. Ojala、M. Pietikainen 和 D. Harwood,可在
ieeexplore.ieee.org/xpl/articleDetails.jsp?arnumber=576366&searchWithin%5B%5D=%22Authors%22%3A.QT.Ojala%2C+T..QT.&newsearch=true找到。
首先,所有方法都遵循一个类似的过程;它们都从一组分类观察(我们的面部数据库,包含每个个体的多个样本)中获取,并在其上进行“训练”,对图像或视频中检测到的面部进行分析,并确定两个要素:是否识别了主题,以及主题真正被识别的置信度度量,这通常被称为置信度分数。
主成分分析(Eigenfaces)执行所谓的 PCA,在所有与计算机视觉相关的数学概念中,这可能是最描述性的。它基本上识别一组特定观察的主成分(再次,你的面部数据库),计算当前观察(在图像或帧中检测到的面部)与数据集之间的发散度,并产生一个值。值越小,面部数据库与检测到的面部之间的差异越小;因此,0 的值是一个完全匹配。
鱼脸(Fisherfaces)源自 PCA 并发展了这一概念,应用了更复杂的逻辑。虽然计算量更大,但它往往比 Eigenfaces 产生更准确的结果。
LBPH(局部二值模式直方图)大致上(再次,从非常高的层面)将检测到的面部划分为小的单元,并将每个单元与模型中的对应单元进行比较,为每个区域生成匹配值的直方图。由于这种灵活的方法,LBPH 是唯一允许模型样本面部和检测到的面部具有不同形状和大小的面部识别算法。就一般而言,我个人认为这是最准确的算法,但每种算法都有其优势和劣势。
准备训练数据
现在我们有了数据,我们需要将这些样本图片加载到我们的面部识别算法中。所有面部识别算法在其 train() 方法中接受两个参数:一个图像数组和一个标签数组。这些标签代表什么?它们是某个个体/面部的 ID,这样当执行面部识别时,我们不仅知道识别了这个人,而且知道——在我们的数据库中的许多人中——这个人是谁。
要做到这一点,我们需要创建一个逗号分隔值(CSV)文件,该文件将包含样本图片的路径,随后是那个人的 ID。以我的情况为例,我使用了之前的脚本生成了 20 张图片,它们位于文件夹 data/at/ 的子文件夹 jm/ 中,该文件夹包含所有个体的图片。
因此,我的 CSV 文件看起来是这样的:
jm/1.pgm;0
jm/2.pgm;0
jm/3.pgm;0
...
jm/20.pgm;0
注意
这些点都是缺失的数字。jm/ 实例表示子文件夹,而末尾的 0 值是我的面部 ID。
好的,在这个阶段,我们已经拥有了指导 OpenCV 识别我们面部所需的一切。
加载数据和识别面部
接下来,我们需要将这些两种资源(图像数组和 CSV 文件)加载到面部识别算法中,以便它可以训练以识别我们的面部。为此,我们构建一个函数,该函数读取 CSV 文件,并且对于文件的每一行——将对应路径的图像加载到图像数组中,并将 ID 加载到标签数组中。
def read_images(path, sz=None):
c = 0
X,y = [], []
for dirname, dirnames, filenames in os.walk(path):
for subdirname in dirnames:
subject_path = os.path.join(dirname, subdirname)
for filename in os.listdir(subject_path):
try:
if (filename == ".directory"):
continue
filepath = os.path.join(subject_path, filename)
im = cv2.imread(os.path.join(subject_path, filename), cv2.IMREAD_GRAYSCALE)
# resize to given size (if given)
if (sz is not None):
im = cv2.resize(im, (200, 200))
X.append(np.asarray(im, dtype=np.uint8))
y.append(c)
except IOError, (errno, strerror):
print "I/O error({0}): {1}".format(errno, strerror)
except:
print "Unexpected error:", sys.exc_info()[0]
raise
c = c+1
return [X,y]
执行主成分分析(Eigenfaces)识别
我们已经准备好测试面部识别算法了。以下是执行它的脚本:
def face_rec():
names = ['Joe', 'Jane', 'Jack']
if len(sys.argv) < 2:
print "USAGE: facerec_demo.py </path/to/images> [</path/to/store/images/at>]"
sys.exit()
[X,y] = read_images(sys.argv[1])
y = np.asarray(y, dtype=np.int32)
if len(sys.argv) == 3:
out_dir = sys.argv[2]
model = cv2.face.createEigenFaceRecognizer()
model.train(np.asarray(X), np.asarray(y))
camera = cv2.VideoCapture(0)
face_cascade = cv2.CascadeClassifier('./cascades/haarcascade_frontalface_default.xml')
while (True):
read, img = camera.read()
faces = face_cascade.detectMultiScale(img, 1.3, 5)
for (x, y, w, h) in faces:
img = cv2.rectangle(img,(x,y),(x+w,y+h),(255,0,0),2)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
roi = gray[x:x+w, y:y+h]
try:
roi = cv2.resize(roi, (200, 200), interpolation=cv2.INTER_LINEAR)
params = model.predict(roi)
print "Label: %s, Confidence: %.2f" % (params[0], params[1])
cv2.putText(img, names[params[0]], (x, y - 20), cv2.FONT_HERSHEY_SIMPLEX, 1, 255, 2)
except:
continue
cv2.imshow("camera", img)
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
cv2.destroyAllWindows()
有几行可能看起来有点神秘,所以让我们分析一下脚本。首先,有一个名字数组被声明了;这些是我存储在人脸数据库中的实际个人名字。将一个人识别为 ID 0 是很棒的,但在正确检测和识别到的人脸上打印 'Joe' 要戏剧性得多。
所以每当脚本识别到一个 ID 时,我们将在names数组中打印相应的名字,而不是 ID。
在此之后,我们按照前一个函数中描述的方式加载图像,使用cv2.createEigenFaceRecognizer()创建人脸识别模型,并通过传递图像和标签(ID)数组来训练它。请注意,Eigenface 识别器接受两个重要的参数,你可以指定:第一个是你想要保留的主成分数量,第二个是一个指定置信度阈值的浮点值。
接下来,我们重复与面部检测操作类似的过程。不过,这次,我们通过在检测到的任何面部上执行人脸识别来扩展帧的处理。
这分为两个步骤:首先,我们将检测到的人脸调整到期望的大小(在我的情况下,样本是 200x200 像素),然后我们在调整大小后的区域上调用predict()函数。
注意
这是一个简化的过程,它的目的是让你能够运行一个基本的应用程序并理解 OpenCV 3 中人脸识别的过程。实际上,你将应用一些额外的优化,例如正确对齐和旋转检测到的人脸,以最大限度地提高识别的准确性。
最后,我们获得识别结果,并且为了效果,我们在帧中绘制它:

使用 Fisherfaces 进行人脸识别
那 Fisherfaces 呢?过程没有太大变化;我们只需要实例化一个不同的算法。所以,我们的模型变量声明将如下所示:
model = cv2.face.createFisherFaceRecognizer()
Fisherface 与 Eigenfaces 具有相同的两个参数:要保留的 Fisherfaces 和置信度阈值。置信度高于此阈值的面孔将被丢弃。
使用 LBPH 进行人脸识别
最后,让我们快速看一下 LBPH 算法。同样,过程非常相似。然而,算法工厂接受的参数要复杂一些,因为它们按顺序指示:radius,neighbors,grid_x,grid_y,以及置信度阈值。如果你不指定这些值,它们将自动设置为 1,8,8,8,和 123.0。模型声明将如下所示:
model = cv2.face.createLBPHFaceRecognizer()
注意
注意,使用 LBPH,你不需要调整图像大小,因为网格的划分允许比较每个单元格中识别出的模式。
丢弃置信度分数的结果
predict()方法返回一个包含两个元素的数组:第一个元素是识别个体的标签,第二个是置信度分数。所有算法都提供了设置置信度分数阈值的选项,这衡量了识别的面与原始模型之间的距离,因此分数为 0 表示完全匹配。
可能会有这样的情况,你宁愿保留所有识别,然后进行进一步的处理,这样你可以提出自己的算法来估计识别的置信度分数;例如,如果你试图在视频中识别人,你可能想分析后续帧中的置信度分数,以确定识别是否成功。在这种情况下,你可以检查算法获得的置信度分数,并得出自己的结论。
注意
置信度分数在 Eigenfaces/Fisherfaces 和 LBPH 中完全不同。Eigenfaces 和 Fisherfaces 将产生(大约)在 0 到 20,000 范围内的值,任何低于 4-5,000 的分数都表示相当有信心的识别。
LBPH 的工作原理类似;然而,良好识别的参考值低于 50,任何高于 80 的值都被认为是低置信度分数。
正常的定制方法是在获得足够数量的具有令人满意的任意置信度分数的帧之前,不绘制识别面的矩形,但你完全自由地使用 OpenCV 的人脸识别模块来定制你的应用程序以满足你的需求。
摘要
到现在为止,你应该已经很好地理解了人脸检测和识别的工作原理,以及如何在 Python 和 OpenCV 3 中实现它们。
人脸检测和识别是计算机视觉中不断发展的分支,算法正在不断发展,并且随着对机器人和物联网的重视,它们将在不久的将来发展得更快。
目前,检测和识别的准确性高度依赖于训练数据的质量,所以请确保为你的应用程序提供高质量的面对面数据库,你将对自己的结果感到满意。
第六章. 使用图像描述符检索图像和搜索
与人眼和大脑类似,OpenCV 可以检测图像的主要特征,并将这些特征提取成所谓的图像描述符。这些特征可以用作数据库,实现基于图像的搜索。此外,我们可以使用关键点将图像拼接起来,组成更大的图像(例如,将多张图片拼成 360 度全景图)。
本章将向您展示如何使用 OpenCV 检测图像特征,并利用这些特征进行图像匹配和搜索。在整个章节中,我们将使用示例图像检测其主要特征,然后尝试使用单应性找到包含在另一图像中的示例图像。
特征检测算法
有许多算法可以用于检测和提取特征,我们将探索其中大部分。在 OpenCV 中最常用的算法如下:
-
Harris:此算法用于检测角点
-
SIFT:此算法用于检测图像块
-
SURF:此算法用于检测图像块
-
FAST:此算法用于检测角点
-
BRIEF:此算法用于检测图像块
-
ORB:此算法代表Oriented FAST and Rotated BRIEF
可以使用以下方法进行特征匹配:
-
暴力匹配
-
基于 FLANN 的匹配
可以使用单应性进行空间验证。
定义特征
特征究竟是什么?为什么图像的某个特定区域可以被归类为特征,而其他区域则不是?广义上讲,特征是图像中一个独特或易于识别的兴趣区域。正如你可以想象的那样,角点和高密度区域是好的特征,而重复性很高的图案或低密度区域(如蓝天)则不是。边缘是好的特征,因为它们倾向于分割图像的两个区域。图像块(与周围区域差异很大的图像区域)也是一个有趣的特征。
大多数特征检测算法都围绕角点、边缘和图像块的识别,其中一些也关注脊的概念,你可以将其视为长形物体的对称轴(例如,考虑在图像中识别道路)。
有些算法在识别和提取特定类型的特征方面做得更好,因此了解你的输入图像很重要,这样你就可以利用 OpenCV 工具箱中的最佳工具。
检测特征 – 角点
让我们先通过利用CornerHarris识别角点,以下是一个示例。如果你继续学习 OpenCV,你会发现——出于许多原因——棋盘是计算机视觉中常见的分析对象,部分原因是因为棋盘图案适合许多类型的特征检测,也许是因为棋类游戏在极客中相当受欢迎。
这是我们的示例图像:

OpenCV 有一个非常方便的实用函数叫做cornerHarris,它可以检测图像中的角落。说明这个功能的代码非常简单:
import cv2
import numpy as np
img = cv2.imread('images/chess_board.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
gray = np.float32(gray)
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
img[dst>0.01 * dst.max()] = [0, 0, 255]
while (True):
cv2.imshow('corners', img)
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
cv2.destroyAllWindows()
让我们分析一下代码:在常规导入之后,我们加载棋盘图像并将其转换为灰度,这样cornerHarris就可以计算它了。然后,我们调用cornerHarris函数:
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
这里最重要的参数是第三个参数,它定义了 Sobel 算子的孔径。Sobel 算子通过在图像的行和列中进行变化检测来检测边缘,它使用一个核来实现这一点。OpenCV 的cornerHarris函数使用一个 Sobel 算子,其孔径由这个参数定义。简单来说,它定义了角落检测的敏感性。它必须在 3 到 31 之间,并且必须是奇数。在值为 3 时,所有那些黑方块的对角线都会在接触到方块边界时被注册为角落。在值为 23 时,只有每个方块的角落会被检测为角落。
考虑以下行:
img[dst>0.01 * dst.max()] = [0, 0, 255]
这里,在检测到角落红色标记的地方,调整cornerHarris的第二个参数将改变这一点,也就是说,值越小,表示角落的标记就越小。
这里是最终的结果:

太好了,我们已经标记了角落点,并且结果一目了然;所有的角落都用红色标记。
使用 DoG 和 SIFT 进行特征提取和描述
前面的技术,使用cornerHarris,非常适合检测角落,并且具有明显的优势,因为角落就是角落;即使图像被旋转,它们也能被检测到。
然而,如果我们减小(或增加)图像的大小,图像的一些部分可能会失去或甚至获得角落的特性。
例如,看看以下 F1 意大利大奖赛赛道角落的检测:

这里是同一张截图的较小版本:

你会注意到角落变得更加紧凑;然而,我们不仅获得了角落,还失去了一些!特别是,看看位于西北/东南直道末端的Variante Ascari弯道。在图像的大版本中,双弯道的入口和顶点都被检测为角落。在较小的图像中,顶点没有被检测为这样的角落。我们越减少图像的大小,就越有可能失去这个弯道的入口。
这种特征丢失引发了一个问题;我们需要一个无论图像尺度如何都能工作的算法。进入SIFT:虽然尺度不变特征变换可能听起来有点神秘,但既然我们知道我们正在尝试解决的问题,它实际上是有意义的。我们需要一个函数(一个变换),它将检测特征(一个特征变换)并且不会根据图像的尺度输出不同的结果(一个尺度不变特征变换)。请注意,SIFT 不检测关键点(这是通过高斯差分完成的),而是通过特征向量描述它们周围区域。
现在对高斯差分(DoG)进行简要介绍;我们之前已经讨论了低通滤波器和模糊操作,特别是使用cv2.GaussianBlur()函数。DoG 是将高斯滤波器应用于同一图像的结果。在第三章,使用 OpenCV 3 处理图像中,我们应用了这种技术来计算非常有效的边缘检测,其思想是相同的。DoG 操作的最后结果包含感兴趣区域(关键点),然后通过 SIFT 对这些关键点进行描述。
让我们看看 SIFT 在一个充满角和特征的图像中的表现:

现在,瓦雷泽(意大利伦巴第)美丽的全景也获得了计算机视觉的意义。以下是获取此处理图像所使用的代码:
import cv2
import sys
import numpy as np
imgpath = sys.argv[1]
img = cv2.imread(imgpath)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptor = sift.detectAndCompute(gray,None)
img = cv2.drawKeypoints(image=img, outImage=img, keypoints = keypoints, flags = cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINT, color = (51, 163, 236))
cv2.imshow('sift_keypoints', img)
while (True):
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
cv2.destroyAllWindows()
在常规导入之后,我们加载我们想要处理的图像。为了使这个脚本通用,我们将使用 Python 的sys模块将图像路径作为命令行参数:
imgpath = sys.argv[1]
img = cv2.imread(imgpath)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
然后,我们将图像转换为灰度。在这个阶段,你可能已经注意到,Python 中的大多数处理算法都需要灰度输入才能工作。
下一步是创建一个 SIFT 对象并计算灰度图像:
sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptor = sift.detectAndCompute(gray,None)
这是一个有趣且重要的过程;SIFT 对象使用 DoG 来检测关键点并为每个关键点周围区域计算特征向量。正如方法名称清楚地表明的那样,这里执行了两个主要操作:检测和计算。操作的返回值是一个包含关键点信息(关键点)和描述符的元组。
最后,我们通过在图像上绘制关键点并使用常规的imshow函数显示它来处理这个图像。
注意,在drawKeypoints函数中,我们传递一个值为 4 的标志。这实际上是cv2模块属性:
cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINT
这段代码使得绘制圆圈和每个关键点的方向成为可能。
关键点的解剖结构
让我们快速查看从 OpenCV 文档中获取的关键点类的定义:
pt
size
angle
response
octave
class_id
一些属性比其他属性更易于解释,但让我们不要想当然,而是逐一解释:
-
pt(点)属性表示关键点在图像中的x和y坐标。 -
size属性表示特征的直径。 -
angle属性表示特征的方向,如前述处理过的图像所示。 -
response属性表示关键点的强度。一些特征被 SIFT 分类为比其他特征更强,而response是您用来评估特征强度属性。 -
octave属性表示在金字塔中找到特征的层。要完全解释这个属性,我们需要写整整一章,所以这里我只介绍基本概念。SIFT 算法的操作方式类似于人脸检测算法,即它按顺序处理相同的图像,但改变计算参数。例如,图像的尺度以及相邻像素是算法每次迭代(
octave)中变化的参数。因此,octave属性表示关键点被检测到的层。 -
最后,对象 ID 是关键点的 ID。
使用快速 Hessian 和 SURF 进行特征提取和检测
计算机视觉是计算机科学相对较新的一个分支,许多算法和技术都是最近发明的。实际上,SIFT 只有 16 年的历史,由 David Lowe 于 1999 年发表。
SURF 是由 Herbert Bay 于 2006 年发表的特征检测算法,比 SIFT 快几倍,并且部分受其启发。
注意
注意,SIFT 和 SURF 都是专利算法,因此它们被包含在 OpenCV 的xfeatures2d模块中。
理解 SURF 在底层是如何工作的,对我们这本书来说并不特别相关,因为我们可以在我们的应用中使用它,并充分利用它。重要的是要理解的是,SURF 是一个 OpenCV 类,使用快速 Hessian 算法进行关键点检测,使用 SURF 进行提取,就像 OpenCV 中的 SIFT 类使用 DoG 进行关键点检测,使用 SIFT 进行提取一样。
另外,好消息是作为一个特征检测算法,SURF 的 API 与 SIFT 没有区别。因此,我们可以简单地编辑之前的脚本,动态选择一个特征检测算法,而不是重写整个程序。
由于我们现在只支持两种算法,因此没有必要为评估要使用的算法寻找特别优雅的解决方案,我们将使用简单的if块,如下面的代码所示:
import cv2
import sys
import numpy as np
imgpath = sys.argv[1]
img = cv2.imread(imgpath)
alg = sys.argv[2]
def fd(algorithm):
if algorithm == "SIFT":
return cv2.xfeatures2d.SIFT_create()
if algorithm == "SURF":
return cv2.xfeatures2d.SURF_create(float(sys.argv[3]) if len(sys.argv) == 4 else 4000)
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
fd_alg = fd(alg)
keypoints, descriptor = fd_alg.detectAndCompute(gray,None)
img = cv2.drawKeypoints(image=img, outImage=img, keypoints = keypoints, flags = 4, color = (51, 163, 236))
cv2.imshow('keypoints', img)
while (True):
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
cv2.destroyAllWindows()
这是使用 SURF 和阈值的结果:

这张图像是通过使用 8000 的 Hessian 阈值对它进行 SURF 算法处理获得的。更准确地说,我运行了以下命令:
> python feat_det.py images/varese.jpg SURF 8000
阈值越高,识别的特征越少,所以尝试调整这些值,直到达到最佳检测。在前面的例子中,你可以清楚地看到单个建筑物是如何被检测为特征的。
在与我们在第四章中采用的过程类似的过程中,当我们计算视差图时,尝试——作为一个练习——创建一个滑块来将 Hessian 阈值值输入到 SURF 实例中,并观察特征数量以相反比例增加和减少。
现在,让我们用 FAST、BRIEF 关键点描述符和 ORB(它使用了这两个)来检查角点检测,并充分利用特征检测。
ORB 特征检测与特征匹配
如果 SIFT 是年轻的,SURF 更年轻,那么 ORB 还处于婴儿期。ORB 首次于 2011 年作为 SIFT 和 SURF 的快速替代方案发表。
该算法发表在论文中,ORB:SIFT 或 SURF 的有效替代方案,并以 PDF 格式在www.vision.cs.chubu.ac.jp/CV-R/pdf/Rublee_iccv2011.pdf提供。
ORB 结合了 FAST 关键点检测和 BRIEF 描述符中使用的技巧,因此确实值得先快速查看 FAST 和 BRIEF。然后我们将讨论暴力匹配——用于特征匹配的算法之一——并展示一个特征匹配的例子。
FAST
加速分割测试(FAST)算法以一种巧妙的方式进行工作;它在包括 16 个像素的周围画一个圆。然后,它将每个像素标记得比圆心亮或暗,与特定的阈值进行比较。一个角点通过识别被标记为亮或暗的连续像素的数量来定义。
FAST 实现了一个高速测试,试图快速跳过整个 16 像素测试。为了理解这个测试是如何工作的,让我们看看这张截图:

如你所见,四个测试像素中的三个(像素编号1、9、5和13)必须在阈值(因此,被标记为亮或暗)内(或超出阈值)和一侧,而另一个必须在阈值另一侧。如果所有四个都被标记为亮或暗,或者两个被标记,两个未被标记,则该像素不是候选角点。
FAST 是一个非常巧妙的算法,但并非没有弱点,为了弥补这些弱点,分析图像的开发者可以实施机器学习方法,将一组图像(与您的应用相关)输入到算法中,以便优化角点检测。
尽管如此,FAST 仍然依赖于一个阈值,因此开发者的输入总是必要的(与 SIFT 不同)。
BRIEF
二进制 鲁棒独立基本特征(BRIEF)另一方面,不是一个特征检测算法,而是一个描述符。我们尚未探讨这个概念,所以让我们解释一下什么是描述符,然后看看 BRIEF。
你会注意到,当我们之前使用 SIFT 和 SURF 分析图像时,整个过程的精髓是调用detectAndCompute函数。这个函数执行两个不同的步骤:检测和计算,如果将它们组合成一个元组,它们会返回两个不同的结果。
检测的结果是一组关键点;计算的结果是描述符。这意味着 OpenCV 的 SIFT 和 SURF 类都是检测器和描述符(尽管,记住,原始算法不是!OpenCV 的 SIFT 实际上是 DoG 加上 SIFT,OpenCV 的 SURF 实际上是快速 Hessian 加上 SURF)。
关键点描述符是图像的一种表示,它是特征匹配的门户,因为你可以比较两个图像的关键点描述符并找到共同点。
BRIEF 是目前可用的最快的描述符之一。BRIEF 背后的理论实际上相当复杂,但简单来说,BRIEF 采用了一系列优化,使其成为特征匹配的一个非常好的选择。
强力匹配
强力匹配器是一种描述符匹配器,它比较两个描述符并生成一个结果,即匹配列表。之所以称为强力匹配器,是因为算法中涉及到的优化很少;第一个描述符中的所有特征都与第二个描述符中的特征进行比较,每个比较都给出一个距离值,最佳结果被认为是匹配。
这就是为什么它被称为强力匹配。在计算中,术语“强力匹配”通常与一种优先考虑穷尽所有可能组合(例如,破解密码的所有可能字符组合)的方法有关,而不是一些巧妙且复杂的算法逻辑。OpenCV 提供了一个BFMatcher对象,它正是这样做的。
使用 ORB 进行特征匹配
现在我们已经对 FAST 和 BRIEF 有了大致的了解,我们可以理解为什么 ORB 背后的团队(当时由 Ethan Rublee、Vincent Rabaud、Kurt Konolige 和 Gary R. Bradski 组成)选择了这两个算法作为 ORB 的基础。
在他们的论文中,作者旨在实现以下结果:
-
将快速且精确的定向组件添加到 FAST 中
-
高效计算定向 BRIEF 特征
-
定向 BRIEF 特征的分析方差和相关性
-
一种在旋转不变性下解相关 BRIEF 特征的学习方法,从而在最近邻应用中提高性能。
除了非常专业的术语之外,主要观点相当清晰;ORB 旨在优化和加速操作,包括利用 BRIEF 的旋转感知方式,这样即使在训练图像与查询图像具有非常不同的旋转的情况下,匹配也可以得到改善。
然而,在这个阶段,我敢打赌您已经对理论感到厌倦,并想要深入一些特征匹配,所以让我们看看一些代码。
作为一位热衷的音乐听众,我首先想到的例子是获取乐队的标志并将其与该乐队的一张专辑进行匹配:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img1 = cv2.imread('images/manowar_logo.png',cv2.IMREAD_GRAYSCALE)
img2 = cv2.imread('images/manowar_single.jpg', cv2.IMREAD_GRAYSCALE)
orb = cv2.ORB_create()
kp1, des1 = orb.detectAndCompute(img1,None)
kp2, des2 = orb.detectAndCompute(img2,None)
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1,des2)
matches = sorted(matches, key = lambda x:x.distance)
img3 = cv2.drawMatches(img1,kp1,img2,kp2, matches[:40], img2,flags=2)
plt.imshow(img3),plt.show()
现在我们一步一步地检查这段代码。
在常规导入之后,我们加载了两张图像(查询图像和训练图像)。
注意,您可能已经看到使用第二个参数(值为 0)加载图像。这是因为 cv2.imread 接受第二个参数,可以是以下标志之一:
IMREAD_ANYCOLOR = 4
IMREAD_ANYDEPTH = 2
IMREAD_COLOR = 1
IMREAD_GRAYSCALE = 0
IMREAD_LOAD_GDAL = 8
IMREAD_UNCHANGED = -1
如您所见,cv2.IMREAD_GRAYSCALE 等于 0,因此您可以传递标志本身或其值;它们是同一件事。
这是我们加载的图像:

这是我们加载的另一个图像:

现在,我们继续创建 ORB 特征检测器和描述符:
orb = cv2.ORB_create()
kp1, des1 = orb.detectAndCompute(img1,None)
kp2, des2 = orb.detectAndCompute(img2,None)
与我们之前对 SIFT 和 SURF 所做的方式类似,我们对两张图像都检测并计算了关键点和描述符。
到目前为止,理论相当简单;遍历描述符并确定它们是否匹配,然后计算这个匹配的质量(距离)并排序匹配,这样我们就可以以一定的置信度显示前 n 个匹配,这些匹配实际上是在两张图像上的匹配特征。
BFMatcher,如 brute-force matching 中所述,为我们做了这件事:
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des1,des2)
matches = sorted(matches, key = lambda x:x.distance)
在这个阶段,我们已经有所有需要的信息,但作为计算机视觉爱好者,我们非常重视数据的可视化表示,所以让我们在 matplotlib 图表中绘制这些匹配:
img3 = cv2.drawMatches(img1,kp1,img2,kp2, matches[:40], img2,flags=2)
plt.imshow(img3),plt.show()
结果如下:

使用 K-最近邻匹配
有许多算法可以用来检测匹配,以便我们可以绘制它们。其中之一是K-最近邻(KNN)。对于不同的任务使用不同的算法可能非常有益,因为每种算法都有其优势和劣势。有些可能比其他更准确,有些可能更快或计算成本更低,所以您需要根据手头的任务来决定使用哪个。
例如,如果您有硬件限制,您可能选择成本较低的算法。如果您正在开发实时应用程序,您可能选择最快的算法,无论它对处理器或内存使用有多重。
在所有机器学习算法中,KNN 可能是最简单的,尽管其背后的理论很有趣,但它超出了本书的范围。相反,我们将简单地展示如何在你的应用程序中使用 KNN,这与前一个例子没有太大区别。
关键的是,将脚本切换到 KNN 的两个地方在于我们使用 Brute-Force 匹配器计算匹配的方式,以及我们绘制这些匹配的方式。经过编辑以使用 KNN 的前一个例子看起来像这样:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img1 = cv2.imread('images/manowar_logo.png',0)
img2 = cv2.imread('images/manowar_single.jpg',0)
orb = cv2.ORB_create()
kp1, des1 = orb.detectAndCompute(img1,None)
kp2, des2 = orb.detectAndCompute(img2,None)
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.knnMatch(des1,des2, k=2)
img3 = cv2.drawMatchesKnn(img1,kp1,img2,kp2, matches, img2,flags=2)
plt.imshow(img3),plt.show()
最终结果与之前的一个相似,那么 match 和 knnMatch 之间的区别是什么?区别在于 match 返回最佳匹配,而 KNN 返回 k 个匹配,这给了开发者进一步操作使用 knnMatch 获得的匹配项的选项。
例如,你可以遍历匹配项并应用一个比率测试,这样你就可以过滤掉不满足用户定义条件的匹配项。
基于 FLANN 的匹配
最后,我们将看看快速近似最近邻库(FLANN)。FLANN 的官方互联网主页是www.cs.ubc.ca/research/flann/。
与 ORB 类似,FLANN 拥有比 SIFT 或 SURF 更宽松的许可证,因此你可以自由地在你的项目中使用它。引用 FLANN 的网站,
"FLANN 是一个用于在高维空间中执行快速近似最近邻搜索的库。它包含了一组我们发现对最近邻搜索效果最好的算法,以及一个根据数据集自动选择最佳算法和最佳参数的系统。"
FLANN 是用 C++编写的,并为以下语言提供了绑定:C、MATLAB 和 Python。"
换句话说,FLANN 拥有内部机制,试图根据数据本身来采用最佳算法处理数据集。FLANN 已被证明比其他最近邻搜索软件快 10 倍。
FLANN 甚至可以在 GitHub 上找到,地址是github.com/mariusmuja/flann。根据我的经验,我发现基于 FLANN 的匹配非常准确、快速,并且易于使用。
让我们看看一个基于 FLANN 的特征匹配的例子:
import numpy as np
import cv2
from matplotlib import pyplot as plt
queryImage = cv2.imread('images/bathory_album.jpg',0)
trainingImage = cv2.imread('images/vinyls.jpg',0)
# create SIFT and detect/compute
sift = cv2.xfeatures2d.SIFT_create()
kp1, des1 = sift.detectAndCompute(queryImage,None)
kp2, des2 = sift.detectAndCompute(trainingImage,None)
# FLANN matcher parameters
FLANN_INDEX_KDTREE = 0
indexParams = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
searchParams = dict(checks=50) # or pass empty dictionary
flann = cv2.FlannBasedMatcher(indexParams,searchParams)
matches = flann.knnMatch(des1,des2,k=2)
# prepare an empty mask to draw good matches
matchesMask = [[0,0] for i in xrange(len(matches))]
# David G. Lowe's ratio test, populate the mask
for i,(m,n) in enumerate(matches):
if m.distance < 0.7*n.distance:
matchesMask[i]=[1,0]
drawParams = dict(matchColor = (0,255,0),
singlePointColor = (255,0,0),
matchesMask = matchesMask,
flags = 0)
resultImage = cv2.drawMatchesKnn(queryImage,kp1,trainingImage,kp2,matches,None,**drawParams)
plt.imshow(resultImage,), plt.show()
在这个阶段,前一个脚本的一些部分可能对你来说很熟悉(模块导入、图像加载和创建 SIFT 对象)。
注意
最有趣的部分是 FLANN 匹配器的声明,它遵循了www.cs.ubc.ca/~mariusm/uploads/FLANN/flann_manual-1.6.pdf上的文档。
我们发现 FLANN 匹配器接受两个参数:一个 indexParams 对象和一个 searchParams 对象。这些参数以字典形式传递给 Python(在 C++ 中为 struct),并决定了 FLANN 内部使用的索引和搜索对象的行为。
在这种情况下,我们可以选择LinearIndex、KTreeIndex、KMeansIndex、CompositeIndex和AutotuneIndex,我们选择了KTreeIndex。为什么?这是因为它足够简单以便配置(只需要用户指定要处理的核密度树的数量;一个良好的值在 1 到 16 之间)并且足够聪明(kd 树是并行处理的)。searchParams字典只包含一个字段(检查),它指定了索引树应该遍历的次数。值越高,匹配计算所需的时间越长,但也会更准确。
在现实中,程序的结果很大程度上取决于你输入的数据。我发现使用 5 个 kd 树和 50 次检查通常能得到相当准确的结果,同时完成时间也很短。
在创建 FLANN 匹配器和创建匹配数组之后,匹配将根据 Lowe 在其论文《Distinctive Image Features from Scale-Invariant Keypoints》中描述的测试进行过滤,该论文可在www.cs.ubc.ca/~lowe/papers/ijcv04.pdf找到。
在其章节“应用于物体识别”中,Lowe 解释说,并非所有匹配都是“好的”,并且根据任意阈值过滤并不总是能得到好的结果。相反,Lowe 博士解释说,
"匹配正确的概率可以通过取最近邻距离与第二近邻距离的比值来确定。"
在前面的例子中,丢弃任何大于 0.7 距离的值将导致只有少数几个好的匹配被过滤掉,同时去除大约 90%的误匹配。
让我们揭示 FLANN 的一个实际例子的结果。这是我提供给脚本的查询图像:

这是训练图像:

在这里,你可能注意到图像中包含查询图像位于这个网格的(5, 3)位置。
这是 FLANN 处理的结果:

完美匹配!!
基于单应性的 FLANN 匹配
首先,什么是单应性?让我们从互联网上读一个定义:
"两个图形之间的关系,使得一个图形的任意一点对应另一个图形中的一个且仅有一个点,反之亦然。因此,在圆上滚动的切线切割圆的两个固定切线,形成两套同构点。"
如果你——像我一样——对前面的定义一无所知,你可能会发现这个解释更清晰:单应性是一种条件,其中两个图形在其中一个是对另一个的透视畸变时找到对方。
与所有前面的例子不同,让我们首先看看我们想要实现什么,这样我们就可以完全理解单应性是什么。然后,我们将通过代码来解释。以下是最终结果:

如从截图所示,我们在左侧取了一个主题,在右侧的图像中正确识别,在关键点之间绘制了匹配线,甚至绘制了一个白色边界,显示了右侧图像中种子主题的透视变形:
import numpy as np
import cv2
from matplotlib import pyplot as plt
MIN_MATCH_COUNT = 10
img1 = cv2.imread('images/bb.jpg',0)
img2 = cv2.imread('images/color2_small.jpg',0)
sift = cv2.xfeatures2d.SIFT_create()
kp1, des1 = sift.detectAndCompute(img1,None)
kp2, des2 = sift.detectAndCompute(img2,None)
FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
search_params = dict(checks = 50)
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches = flann.knnMatch(des1,des2,k=2)
# store all the good matches as per Lowe's ratio test.
good = []
for m,n in matches:
if m.distance < 0.7*n.distance:
good.append(m)
if len(good)>MIN_MATCH_COUNT:
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good ]).reshape(-1,1,2)
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good ]).reshape(-1,1,2)
M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC,5.0)
matchesMask = mask.ravel().tolist()
h,w = img1.shape
pts = np.float32([ [0,0],[0,h-1],[w-1,h-1],[w-1,0] ]).reshape(-1,1,2)
dst = cv2.perspectiveTransform(pts,M)
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)
else:
print "Not enough matches are found - %d/%d" % (len(good),MIN_MATCH_COUNT)
matchesMask = None
draw_params = dict(matchColor = (0,255,0), # draw matches in green color
singlePointColor = None,
matchesMask = matchesMask, # draw only inliers
flags = 2)
img3 = cv2.drawMatches(img1,kp1,img2,kp2,good,None,**draw_params)
plt.imshow(img3, 'gray'),plt.show()
与之前的基于 FLANN 的匹配示例相比,唯一的区别(这也是所有动作发生的地方)在于 if 块中。
下面是这个代码步骤的逐步过程:首先,我们确保至少有足够数量的良好匹配(计算单应性所需的最小数量是四个),我们将任意设置为 10(在现实生活中,你可能使用比这更高的值):
if len(good)>MIN_MATCH_COUNT:
然后,我们在原始图像和训练图像中找到关键点:
src_pts = np.float32([ kp1[m.queryIdx].pt for m in good ]).reshape(-1,1,2)
dst_pts = np.float32([ kp2[m.trainIdx].pt for m in good ]).reshape(-1,1,2)
现在,我们找到单应性:
M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC,5.0)
matchesMask = mask.ravel().tolist()
注意,我们创建了 matchesMask,它将在最终绘制匹配时使用,这样只有位于单应性内的点才会绘制匹配线。
在这个阶段,我们只需计算原始物体到第二张图片的透视畸变,以便我们可以绘制边界:
h,w = img1.shape
pts = np.float32([ [0,0],[0,h-1],[w-1,h-1],[w-1,0] ]).reshape(-1,1,2)
dst = cv2.perspectiveTransform(pts,M)
img2 = cv2.polylines(img2,[np.int32(dst)],True,255,3, cv2.LINE_AA)
然后,我们继续按照所有之前的示例进行绘制。
一个示例应用 – 纹身法医
让我们用一个真实(或类似)的例子来结束这一章。想象一下,你正在为哥谭市法医部门工作,你需要识别一个纹身。你有一张纹身的原始图片(想象一下这是来自监控录像的),属于一个罪犯,但你不知道这个人的身份。然而,你拥有一个纹身数据库,用纹身属于的人的名字进行索引。
因此,让我们将任务分为两部分:首先将图像描述符保存到文件中,然后,扫描这些文件以匹配我们用作查询图像的图片。
将图像描述符保存到文件
我们首先要做的事情是将图像描述符保存到外部文件。这样我们就不必每次想要扫描两张图像以查找匹配和单应性时都重新创建描述符。
在我们的应用中,我们将扫描一个文件夹中的图像,并创建相应的描述符文件,以便我们可以在未来的搜索中随时使用。
要创建描述符并将它们保存到文件中,我们将使用在本章中多次使用的过程,即加载一个图像,创建一个特征检测器,检测,并计算:
# generate_descriptors.py
import cv2
import numpy as np
from os import walk
from os.path import join
import sys
def create_descriptors(folder):
files = []
for (dirpath, dirnames, filenames) in walk(folder):
files.extend(filenames)
for f in files:
save_descriptor(folder, f, cv2.xfeatures2d.SIFT_create())
def save_descriptor(folder, image_path, feature_detector):
img = cv2.imread(join(folder, image_path), 0)
keypoints, descriptors = feature_detector.detectAndCompute(img, None)
descriptor_file = image_path.replace("jpg", "npy")
np.save(join(folder, descriptor_file), descriptors)
dir = sys.argv[1]
create_descriptors(dir)
在这个脚本中,我们传递包含所有图像的文件夹名称,然后在同一文件夹中创建所有描述符文件。
NumPy 有一个非常方便的 save() 工具,它以优化的方式将数组数据写入文件。要在包含你的脚本的文件夹中生成描述符,请运行此命令:
> python generate_descriptors.py <folder containing images>
注意,cPickle/pickle 是 Python 对象序列化的更受欢迎的库。然而,在这个特定的上下文中,我们试图将自己限制在仅使用 OpenCV 和 Python 与 NumPy 和 SciPy 的使用上。
扫描匹配
现在我们已经将描述符保存到文件中,我们只需要对所有描述符重复单应性过程,并找到与查询图像的潜在匹配。
这是我们将要实施的过程:
-
加载一个查询图像并为它创建一个描述符(
tattoo_seed.jpg) -
扫描包含描述符的文件夹
-
对于每个描述符,计算基于 FLANN 的匹配
-
如果匹配的数量超过一个任意的阈值,包括潜在的罪犯文件(记住我们正在调查犯罪)
-
在所有罪犯中,选择匹配数量最多的作为潜在嫌疑人
让我们检查代码以实现这一点:
from os.path import join
from os import walk
import numpy as np
import cv2
from sys import argv
# create an array of filenames
folder = argv[1]
query = cv2.imread(join(folder, "tattoo_seed.jpg"), 0)
# create files, images, descriptors globals
files = []
images = []
descriptors = []
for (dirpath, dirnames, filenames) in walk(folder):
files.extend(filenames)
for f in files:
if f.endswith("npy") and f != "tattoo_seed.npy":
descriptors.append(f)
print descriptors
# create the sift detector
sift = cv2.xfeatures2d.SIFT_create()
query_kp, query_ds = sift.detectAndCompute(query, None)
# create FLANN matcher
FLANN_INDEX_KDTREE = 0
index_params = dict(algorithm = FLANN_INDEX_KDTREE, trees = 5)
search_params = dict(checks = 50)
flann = cv2.FlannBasedMatcher(index_params, search_params)
# minimum number of matches
MIN_MATCH_COUNT = 10
potential_culprits = {}
print ">> Initiating picture scan..."
for d in descriptors:
print "--------- analyzing %s for matches ------------" % d
matches = flann.knnMatch(query_ds, np.load(join(folder, d)), k =2)
good = []
for m,n in matches:
if m.distance < 0.7*n.distance:
good.append(m)
if len(good) > MIN_MATCH_COUNT:
print "%s is a match! (%d)" % (d, len(good))
else:
print "%s is not a match" % d
potential_culprits[d] = len(good)
max_matches = None
potential_suspect = None
for culprit, matches in potential_culprits.iteritems():
if max_matches == None or matches > max_matches:
max_matches = matches
potential_suspect = culprit
print "potential suspect is %s" % potential_suspect.replace("npy", "").upper()
我将这个脚本保存为scan_for_matches.py。这个脚本中唯一的新颖之处在于使用了numpy.load(filename),它将一个npy文件加载到np数组中。
运行脚本会产生以下输出:
>> Initiating picture scan...
--------- analyzing posion-ivy.npy for matches ------------
posion-ivy.npy is not a match
--------- analyzing bane.npy for matches ------------
bane.npy is not a match
--------- analyzing two-face.npy for matches ------------
two-face.npy is not a match
--------- analyzing riddler.npy for matches ------------
riddler.npy is not a match
--------- analyzing penguin.npy for matches ------------
penguin.npy is not a match
--------- analyzing dr-hurt.npy for matches ------------
dr-hurt.npy is a match! (298)
--------- analyzing hush.npy for matches ------------
hush.npy is a match! (301)
potential suspect is HUSH.
如果我们将这个图形化表示,我们会看到如下:

摘要
在本章中,我们学习了在图像中检测特征并将它们提取为描述符。我们探索了 OpenCV 中可用于完成此任务的多种算法,然后将它们应用于实际场景,以了解我们探索的概念在现实世界中的应用。
我们现在熟悉了在图像(或视频帧)中检测特征的概念,这是下一章的良好基础。
第七章. 检测和识别对象
本章将介绍检测和识别对象的概念,这是计算机视觉中最常见的挑战之一。你在本书中已经走得很远了,所以在这个阶段,你可能想知道你离在车内安装一个通过摄像头使用信息来告诉你周围汽车和人的位置还有多远。实际上,你离你的目标并不远。
在本章中,我们将扩展我们在讨论识别人脸时最初探讨的目标检测概念,并将其应用于各种现实生活中的对象,而不仅仅是人脸。
目标检测和识别技术
我们在第五章中区分了“检测和识别人脸”,我们将为了清晰起见重申:检测一个对象是程序确定图像的某个区域是否包含未识别对象的能力,而识别是程序识别此对象的能力。通常只在检测到对象的感兴趣区域中发生识别,例如,我们尝试在最初包含人脸的图像区域识别人脸。
当涉及到识别和检测对象时,计算机视觉中使用了多种技术,我们将对其进行探讨:
-
方向梯度直方图
-
图像金字塔
-
滑动窗口
与特征检测算法不同,这些技术不是相互排斥的,而是互补的。你可以在应用滑动窗口技术的同时执行方向梯度直方图(HOG)。
因此,让我们首先看看 HOG,并了解它是什么。
HOG 描述符
HOG 是一种特征描述符,因此它属于与 SIFT、SURF 和 ORB 相同的算法家族。
它用于图像和视频处理以检测对象。其内部机制非常巧妙;图像被分成部分,并为每个部分计算梯度。当我们谈论通过 LBPH 进行人脸识别时,我们观察到了类似的方法。
然而,HOG 计算的是不基于颜色值的直方图,而是基于梯度。由于 HOG 是一种特征描述符,它能够提供对特征匹配和目标检测/识别至关重要的信息。
在深入探讨 HOG 的工作原理的技术细节之前,让我们首先看看 HOG 是如何“看”世界的;这是一张卡车的图片:

这是它的 HOG 版本:

你可以轻松地识别车轮和车辆的主要结构。那么,HOG“看到”的是什么?首先,你可以看到图像是如何被分成单元格的;这些是 16x16 像素的单元格。每个单元格包含八个方向(N、NW、W、SW、S、SE、E 和 NE)计算出的颜色梯度的视觉表示。
每个细胞中包含的这八个值是著名的直方图。因此,单个细胞获得一个独特的签名,你可以想象它有点像这样:

将直方图外推到描述符是一个相当复杂的过程。首先,为每个细胞计算局部直方图。这些细胞被分组到更大的区域,称为块。这些块可以由任意数量的细胞组成,但 Dalal 和 Triggs 发现,在执行人体检测时,2x2 细胞块产生了最佳结果。创建一个块宽向量,以便它可以被归一化,考虑到光照和阴影的变化(单个细胞区域太小,无法检测这种变化)。这提高了检测的准确性,因为它减少了样本和正在检查的块之间的光照和阴影差异。
简单地比较两张图像中的细胞是不行的,除非这两张图像完全相同(在大小和数据方面)。
有两个主要问题需要解决:
-
位置
-
尺度
尺度问题
例如,假设你的样本是从较大图像中提取的细节(比如,一辆自行车),而你正在尝试比较这两张图片。你将不会获得相同的梯度特征,检测将失败(尽管自行车出现在两张图片中)。
位置问题
一旦我们解决了尺度问题,我们面前又出现了一个障碍:一个可能检测到的物体可以出现在图像的任何位置,因此我们需要分部分扫描整个图像以确保我们可以识别感兴趣的区域,在这些区域内,尝试检测物体。即使样本图像和图像中的物体大小相同,也需要一种方法来指导 OpenCV 定位此物体。因此,剩余的图像被丢弃,并在可能匹配的区域进行比较。
为了避免这些问题,我们需要熟悉图像金字塔和滑动窗口的概念。
图像金字塔
计算机视觉中使用的许多算法都利用一个称为金字塔的概念。
图像金字塔是图像的多尺度表示。这张图应该帮助你理解这个概念:

图像的多尺度表示,或图像金字塔,有助于你解决在不同尺度上检测物体的问题。这个概念的重要性可以通过现实生活中的硬事实轻松解释,例如,一个物体以与我们的样本图像中相同的精确尺度出现在图像中的可能性极低。
此外,你将了解到对象分类器(允许你在 OpenCV 中检测对象的实用工具)需要训练,而这种训练是通过由正匹配和负匹配组成的图像数据库提供的。在正匹配中,我们想要识别的物体在整个训练数据集中以相同的尺度出现的情况再次不太可能。
我们明白了,乔。我们需要从等式中去除比例,现在让我们看看图像金字塔是如何构建的。
图像金字塔是通过以下过程构建的:
-
拿一个图像来说。
-
使用任意的尺度参数调整图像的大小(更小)。
-
平滑图像(使用高斯模糊)。
-
如果图像大于一个任意的最小尺寸,则重复步骤 1。
尽管在本书的这一阶段只探讨了图像金字塔、尺度比和最小尺寸,但你已经处理了它们。如果你还记得第五章,识别和检测人脸,我们使用了CascadeClassifier对象的detectMultiScale方法。
立刻,detectMultiScale不再那么晦涩难懂了;事实上,它已经变得不言自明。级联分类器对象试图在输入图像的不同尺度上检测对象。第二件应该变得非常清楚的信息是detectMultiScale()方法的scaleFactor参数。此参数表示图像在金字塔的每一步中将被重采样到较小尺寸的比例。
scaleFactor参数越小,金字塔的层数越多,操作的速度越慢,计算量越大,尽管在一定程度上结果更准确。
因此,到目前为止,你应该已经理解了图像金字塔是什么,以及为什么它在计算机视觉中使用。现在让我们继续讨论滑动窗口。
滑动窗口
滑动窗口是计算机视觉中的一种技术,它包括检查图像的移动部分(滑动窗口)并在这些部分上使用图像金字塔进行检测。这样做是为了在多尺度级别上检测到对象。
滑动窗口通过扫描较大图像的较小区域来解决位置问题,然后在同一图像的不同尺度上重复扫描。
使用这种技术,每个图像被分解成部分,这允许丢弃不太可能包含对象的区域,而剩余的部分则被分类。
虽然这种方法有一个问题出现:重叠区域。
让我们稍微扩展一下这个概念,以阐明问题的本质。比如说,你正在对图像进行人脸检测,并使用滑动窗口。
每个窗口每次滑动几个像素,这意味着滑动窗口恰好是同一张脸在四个不同位置的正匹配。自然地,我们不想报告四个匹配,而只想报告一个;此外,我们对具有良好分数的图像部分不感兴趣,而只是对具有最高分数的部分感兴趣。
这就是非极大值抑制发挥作用的地方:给定一组重叠区域,我们可以抑制所有未被赋予最大分数的区域。
非极大值(或非极大值)抑制
非极大值抑制(或非极大值)是一种技术,它会抑制与图像中同一区域相关的所有结果,这些结果不是特定区域的最高得分。这是因为类似位置的对齐窗口往往具有更高的得分,并且重叠区域是显著的,但我们只对具有最佳结果的窗口感兴趣,并丢弃得分较低的重叠窗口。
当使用滑动窗口检查图像时,你想要确保保留围绕同一主题的一组窗口中的最佳窗口。
为了做到这一点,你确定所有与阈值x以上共有窗口都将被投入非极大值抑制操作。
这相当复杂,但这还不是这个过程的结束。还记得图像金字塔吗?我们正在迭代地以较小的尺度扫描图像,以确保检测到不同尺度的对象。
这意味着你将获得一系列不同尺度的窗口,然后,将较小尺度获得的窗口大小计算为在原始尺度上检测到的,最后,将这个窗口投入原始混合中。
这听起来有点复杂。幸运的是,我们不是第一个遇到这个问题的人,这个问题已经以几种方式得到了解决。在我经验中,最快的算法是由 Tomasz Malisiewicz 博士在www.computervisionblog.com/2011/08/blazing-fast-nmsm-from-exemplar-svm.html实现的。示例是用 MATLAB 编写的,但在应用示例中,我们显然将使用它的 Python 版本。
非极大值抑制背后的通用方法如下:
-
一旦构建了图像金字塔,就使用滑动窗口方法扫描图像以进行对象检测。
-
收集所有返回了正结果(超过某个任意阈值)的当前窗口,并取一个响应最高的窗口
W。 -
消除与
W显著重叠的所有窗口。 -
将窗口移动到下一个响应最高的窗口,并重复当前尺度的过程。
当这个过程完成时,将图像金字塔中的下一个尺度向上移动并重复前面的过程。为了确保窗口在整个非极大值抑制过程结束时得到正确表示,务必计算与图像原始大小相关的窗口大小(例如,如果在金字塔中检测到原始大小的 50%的窗口,则检测到的窗口实际上在原始图像中将是四倍大)。
在此过程结束时,你将有一组得分最高的窗口。可选地,你可以检查完全包含在其他窗口中的窗口(就像我们在本章开头进行的人体检测过程那样)并消除那些窗口。
现在,我们如何确定窗口的分数?我们需要一个分类系统,该系统确定某个特征是否存在,并为这个分类提供一个置信度分数。这就是支持向量机(SVM)发挥作用的地方。
支持向量机
详细解释 SVM 是什么以及它做什么超出了本书的范围,但简单来说,SVM 是一种算法——给定标记的训练数据,它可以通过输出一个最优的超平面来使这些数据分类,用简单的话说,这就是一个最优的平面,它将不同分类的数据分开。一个视觉表示将有助于你理解这一点:

为什么它在计算机视觉和特别是对象检测中如此有用?这是因为找到属于对象和不属于对象的像素之间的最优分割线是对象检测的一个关键组成部分。
SVM 模型自 20 世纪 60 年代初以来一直存在;然而,其当前形式的实现起源于 Corinna Cortes 和 Vadimir Vapnik 于 1995 年发表的一篇论文,该论文可在link.springer.com/article/10.1007/BF00994018找到。
现在我们已经很好地理解了对象检测中涉及的概念,我们可以开始查看一些示例。我们将从内置函数开始,然后发展到训练我们自己的自定义对象检测器。
人体检测
OpenCV 自带HOGDescriptor,它可以进行人体检测。
这里有一个相当直接的例子:
import cv2
import numpy as np
def is_inside(o, i):
ox, oy, ow, oh = o
ix, iy, iw, ih = i
return ox > ix and oy > iy and ox + ow < ix + iw and oy + oh < iy + ih
def draw_person(image, person):
x, y, w, h = person
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 255), 2)
img = cv2.imread("../images/people.jpg")
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
found, w = hog.detectMultiScale(img)
found_filtered = []
for ri, r in enumerate(found):
for qi, q in enumerate(found):
if ri != qi and is_inside(r, q):
break
else:
found_filtered.append(r)
for person in found_filtered:
draw_person(img, person)
cv2.imshow("people detection", img)
cv2.waitKey(0)
cv2.destroyAllWindows()
在常规导入之后,我们定义了两个非常简单的函数:is_inside和draw_person,它们执行两个最小任务,即确定一个矩形是否完全包含在另一个矩形内,以及在检测到的人周围绘制矩形。
然后,我们加载图像并通过一个非常简单且易于理解的代码创建HOGDescriptor:
cv2.HOGDescriptor()
然后,我们指定HOGDescriptor将使用默认的人体检测器。
这是通过setSVMDetector()方法实现的,在我们介绍了 SVM 之后,它听起来可能没有我们未介绍 SVM 时那么晦涩。
接下来,我们在加载的图像上应用detectMultiScale。有趣的是,与所有的人脸检测算法不同,我们在应用任何形式的对象检测之前不需要将原始图像转换为灰度。
检测方法将返回一个矩形数组,这将是我们开始在图像上绘制形状的良好信息来源。如果我们这样做,然而,你会注意到一些奇怪的事情:一些矩形完全包含在其他矩形内。这明显表明检测有误,我们可以安全地假设完全包含在另一个矩形内的矩形可以被丢弃。
这正是我们定义is_inside函数的原因,也是我们遍历检测结果以丢弃假阳性的原因。
如果你亲自运行脚本,你将看到图像中人的周围有矩形。
创建和训练对象检测器
使用内置特征使得为应用程序快速构建原型变得容易,我们非常感谢 OpenCV 开发者为我们提供了诸如人脸检测或人体检测等优秀功能(真的,我们非常感激)。
然而,无论你是业余爱好者还是计算机视觉专业人士,你很可能不会只处理人和脸。
此外,如果你像我一样,你会想知道人们检测功能最初是如何创建的,以及你是否可以改进它。此外,你可能还会想知道你是否可以将相同的概念应用于检测从汽车到哥布林等各种不同类型的物体。
在企业环境中,你可能必须处理非常具体的检测,例如车牌、书封面,或者你公司可能处理的任何东西。
因此,问题是,我们如何提出自己的分类器?
答案在于 SVM 和词袋技术。
我们已经讨论了 HOG 和 SVM,现在让我们更详细地看看词袋模型。
词袋
词袋(BOW)这个概念最初并不是为计算机视觉设计的,相反,我们在计算机视觉的背景下使用这个概念的演变版本。所以,让我们首先谈谈它的基本版本,正如你可能猜到的,它最初属于语言分析和信息检索领域。
BOW 是一种技术,通过它我们为一系列文档中的每个单词分配一个计数权重;然后我们用代表这些计数的向量重新表示这些文档。让我们看一个例子:
-
文档 1:
我喜欢 OpenCV 和 Python -
文档 2:
我喜欢 C++和 Python -
文档 3:
我不喜欢洋蓟
这三个文档使我们能够构建一个包含这些值的词典(或代码簿):
{
I: 4,
like: 4,
OpenCV: 2,
and: 2,
Python: 2,
C++: 1,
dont: 1,
artichokes: 1
}
我们有八个条目。现在让我们使用八个条目向量重新表示原始文档,每个向量包含词典中的所有单词,其中的值表示文档中每个术语的计数。前三个句子的向量表示如下:
[2, 2, 1, 1, 1, 0, 0, 0]
[1, 1, 0, 1, 1, 1, 0, 0]
[1, 1, 0, 0, 0, 0, 1, 1]
这种文档表示在现实世界中有很多有效的应用,例如垃圾邮件过滤。
这些向量可以被视为文档的直方图表示,或者作为特征(就像我们在前几章中从图像中提取特征一样),可以用来训练分类器。
现在我们已经掌握了计算机视觉中 BOW 或视觉词袋(BOVW)的基本概念,让我们看看它如何应用于计算机视觉领域。
计算机视觉中的 BOW
到现在为止,我们已经熟悉了图像特征的概念。我们使用了特征提取器,如 SIFT 和 SURF,从图像中提取特征,以便我们可以在另一张图像中匹配这些特征。
我们也已经熟悉了代码簿的概念,并且了解 SVM,这是一个可以输入一组特征并利用复杂算法对训练数据进行分类的模型,并且可以预测新数据的分类。
因此,实现 BOW 方法将涉及以下步骤:
-
取一个样本数据集。
-
对于数据集中的每一张图像,提取描述符(使用 SIFT、SURF 等)。
-
将每个描述符添加到 BOW 训练器中。
-
将描述符聚类到k个簇中(好吧,这听起来有些晦涩,但请耐心听我解释)其中心(质心)是我们的视觉词。
到目前为止,我们有一个准备使用的视觉词字典。正如你所想象的那样,一个大的数据集将有助于使我们的字典在视觉词方面更加丰富。在一定程度上,词越多,越好!
在此之后,我们就可以测试我们的分类器并尝试检测了。好消息是这个过程与之前概述的非常相似:给定一个测试图像,我们可以提取特征并根据它们与最近质心的距离进行量化,从而形成一个直方图。
基于此,我们可以尝试识别视觉词并在图像中定位它们。以下是 BOW 过程的视觉表示:

这是在本章中你渴望一个实际例子,并准备好编码的时候。然而,在继续之前,我觉得有必要对 k-means 聚类的理论进行简要的探讨,这样你就可以完全理解视觉词是如何创建的,并且更好地理解使用 BOW 和 SVM 进行对象检测的过程。
k-means 聚类
k-means 聚类是一种向量量化方法,用于数据分析。给定一个数据集,k代表数据集将要被划分成的簇的数量。"means"这个词指的是数学中的均值概念,这相当基础,但为了清晰起见,这就是人们通常所说的平均值;在视觉上表示时,簇的均值是其质心或簇中点的几何中心。
注意
聚类指的是将数据集中的点分组到簇中。
我们将要使用的一个用于执行对象检测的类叫做BagOfWordsKMeansTrainer;到现在你应该能够推断出这个类的职责是创建:
“基于
kmeans()的类,用于使用词袋方法训练视觉词汇”
这是根据 OpenCV 文档的。
这里是一个具有五个簇的 k-means 聚类操作的表示:

在这漫长的理论介绍之后,我们可以看一个例子,并开始训练我们的对象检测器。
检测汽车
在你的图像和视频中,你可以检测到的对象类型没有虚拟限制。然而,为了获得可接受的准确度,你需要一个足够大的数据集,其中包含大小相同的训练图像。
如果我们全部自己来做,这将是一个耗时的操作(这是完全可能的)。
我们可以利用现成的数据集;有许多可以从各种来源免费下载:
注意
注意,训练图像和测试图像分别存储在不同的文件中。
我将在我的例子中使用 UIUC 数据集,但请自由探索互联网上的其他类型的数据集。
现在,让我们看看一个例子:
import cv2
import numpy as np
from os.path import join
datapath = "/home/d3athmast3r/dev/python/CarData/TrainImages/"
def path(cls,i):
return "%s/%s%d.pgm" % (datapath,cls,i+1)
pos, neg = "pos-", "neg-"
detect = cv2.xfeatures2d.SIFT_create()
extract = cv2.xfeatures2d.SIFT_create()
flann_params = dict(algorithm = 1, trees = 5)flann = cv2.FlannBasedMatcher(flann_params, {})
bow_kmeans_trainer = cv2.BOWKMeansTrainer(40)
extract_bow = cv2.BOWImgDescriptorExtractor(extract, flann)
def extract_sift(fn):
im = cv2.imread(fn,0)
return extract.compute(im, detect.detect(im))[1]
for i in range(8):
bow_kmeans_trainer.add(extract_sift(path(pos,i)))
bow_kmeans_trainer.add(extract_sift(path(neg,i)))
voc = bow_kmeans_trainer.cluster()
extract_bow.setVocabulary( voc )
def bow_features(fn):
im = cv2.imread(fn,0)
return extract_bow.compute(im, detect.detect(im))
traindata, trainlabels = [],[]
for i in range(20):
traindata.extend(bow_features(path(pos, i))); trainlabels.append(1)
traindata.extend(bow_features(path(neg, i))); trainlabels.append(-1)
svm = cv2.ml.SVM_create()
svm.train(np.array(traindata), cv2.ml.ROW_SAMPLE, np.array(trainlabels))
def predict(fn):
f = bow_features(fn);
p = svm.predict(f)
print fn, "\t", p[1][0][0]
return p
car, notcar = "/home/d3athmast3r/dev/python/study/images/car.jpg", "/home/d3athmast3r/dev/python/study/images/bb.jpg"
car_img = cv2.imread(car)
notcar_img = cv2.imread(notcar)
car_predict = predict(car)
not_car_predict = predict(notcar)
font = cv2.FONT_HERSHEY_SIMPLEX
if (car_predict[1][0][0] == 1.0):
cv2.putText(car_img,'Car Detected',(10,30), font, 1,(0,255,0),2,cv2.LINE_AA)
if (not_car_predict[1][0][0] == -1.0):
cv2.putText(notcar_img,'Car Not Detected',(10,30), font, 1,(0,0, 255),2,cv2.LINE_AA)
cv2.imshow('BOW + SVM Success', car_img)
cv2.imshow('BOW + SVM Failure', notcar_img)
cv2.waitKey(0)
cv2.destroyAllWindows()
我们刚才做了什么?
这需要相当多的吸收,所以让我们回顾一下我们所做的:
-
首先,我们的常规导入之后是声明我们训练图像的基础路径。这将很有用,可以避免每次在电脑上特定文件夹中处理图像时重写基础路径。
-
之后,我们声明一个函数,
path:def path(cls,i): return "%s/%s%d.pgm" % (datapath,cls,i+1) pos, neg = "pos-", "neg-"注意
关于路径函数的更多内容
这个函数是一个实用方法:给定一个类的名称(在我们的案例中,我们有两个类,
pos和neg)和一个数值索引,我们返回特定测试图像的完整路径。我们的汽车数据集包含以下命名的图像:pos-x.pgm和neg-x.pgm,其中x是一个数字。立即,你会发现这个函数在迭代一系列数字(比如,20)时的有用性,这将允许你加载从
pos-0.pgm到pos-20.pgm的所有图像,对于负类也是如此。 -
接下来,我们将创建两个 SIFT 实例:一个用于提取关键点,另一个用于提取特征:
detect = cv2.xfeatures2d.SIFT_create() extract = cv2.xfeatures2d.SIFT_create() -
每当你看到 SIFT 时,你可以相当肯定会有一些特征匹配算法也涉及其中。在我们的案例中,我们将创建一个 FLANN 匹配器的实例:
flann_params = dict(algorithm = 1, trees = 5)flann = cv2.FlannBasedMatcher(flann_params, {})注意
目前,OpenCV 3 的 Python 版本中缺少 FLANN 的
enum值,因此,作为算法参数传递的数字1代表的是FLANN_INDEX_KDTREE算法。我怀疑最终版本将是cv2.FLANN_INDEX_KDTREE,这会更有帮助。请确保检查enum值以获取正确的标志。 -
接下来,我们提到 BOW 训练器:
bow_kmeans_trainer = cv2.BOWKMeansTrainer(40) -
这个 BOW 训练器使用了 40 个簇。之后,我们将初始化 BOW 提取器。这是一个 BOW 类,它将接收视觉词汇表并尝试在测试图像中检测它们:
extract_bow = cv2.BOWImgDescriptorExtractor(extract, flann) -
要从图像中提取 SIFT 特征,我们构建了一个实用方法,它接受图像的路径,以灰度读取它,并返回描述符:
def extract_sift(fn): im = cv2.imread(fn,0) return extract.compute(im, detect.detect(im))[1]
在这个阶段,我们已经拥有了开始训练 BOW 训练器所需的一切。
-
让我们按类别读取每个类别中的八张图像(八个正例和八个负例):
for i in range(8): bow_kmeans_trainer.add(extract_sift(path(pos,i))) bow_kmeans_trainer.add(extract_sift(path(neg,i))) -
为了创建视觉词的词汇表,我们将调用训练器的
cluster()方法,该方法执行 k-means 分类并返回所说的词汇表。我们将这个词汇表分配给BOWImgDescriptorExtractor,以便它可以从测试图像中提取描述符:vocabulary = bow_kmeans_trainer.cluster() extract_bow.setVocabulary(vocabulary) -
与在此脚本中声明的其他实用函数一样,我们将声明一个函数,该函数接受图像的路径并返回由 BOW 描述符提取器计算出的描述符:
def bow_features(fn): im = cv2.imread(fn,0) return extract_bow.compute(im, detect.detect(im)) -
让我们创建两个数组来容纳训练数据和标签,并用
BOWImgDescriptorExtractor生成的描述符填充它们,将标签与我们要提供的正负图像关联起来(1代表正匹配,-1代表负匹配):traindata, trainlabels = [],[] for i in range(20): traindata.extend(bow_features(path(pos, i))); trainlabels.append(1) traindata.extend(bow_features(path(neg, i))); trainlabels.append(-1) -
现在,让我们创建一个 SVM 的实例:
svm = cv2.ml.SVM_create() -
然后,通过将训练数据和标签包装成 NumPy 数组来对其进行训练:
svm.train(np.array(traindata), cv2.ml.ROW_SAMPLE, np.array(trainlabels))
我们已经准备好了一个训练好的 SVM;剩下要做的就是给 SVM 提供一些样本图像并观察它的表现。
-
让我们先定义另一个实用方法来打印我们
predict方法的结果并返回它:def predict(fn): f = bow_features(fn); p = svm.predict(f) print fn, "\t", p[1][0][0] return p -
让我们定义两个样本图像路径并将它们作为 NumPy 数组读取:
car, notcar = "/home/d3athmast3r/dev/python/study/images/car.jpg", "/home/d3athmast3r/dev/python/study/images/bb.jpg" car_img = cv2.imread(car) notcar_img = cv2.imread(notcar) -
我们将这些图像传递给训练好的 SVM,并获取预测结果:
car_predict = predict(car) not_car_predict = predict(notcar)自然地,我们希望汽车图像被检测为汽车(
predict()的结果应该是1.0),而其他图像则不是(结果应该是-1.0),因此我们只有在结果符合预期时才会在图像上添加文本。 -
最后,我们将图像显示在屏幕上,希望看到每个图像上都有正确的标题:
font = cv2.FONT_HERSHEY_SIMPLEX if (car_predict[1][0][0] == 1.0): cv2.putText(car_img,'Car Detected',(10,30), font, 1,(0,255,0),2,cv2.LINE_AA) if (not_car_predict[1][0][0] == -1.0): cv2.putText(notcar_img,'Car Not Detected',(10,30), font, 1,(0,0, 255),2,cv2.LINE_AA) cv2.imshow('BOW + SVM Success', car_img) cv2.imshow('BOW + SVM Failure', notcar_img) cv2.waitKey(0) cv2.destroyAllWindows()
前面的操作会产生以下结果:

这也导致了以下结果:

SVM 和滑动窗口
检测到一个对象是一个令人印象深刻的成就,但现在我们想以以下方式将其提升到下一个层次:
-
在图像中检测同一类别的多个对象
-
确定检测到的对象在图像中的位置
为了完成这个任务,我们将使用滑动窗口方法。如果从之前对滑动窗口概念的解释中还不清楚,那么通过查看图表,采用这种方法的原因将变得更加明显:

观察块的运动:
-
我们取图像的一个区域,对其进行分类,然后向右移动一个预定义的步长。当我们到达图像的最右边时,我们将x坐标重置为
0并向下移动一个步长,然后重复整个过程。 -
在每个步骤中,我们将使用用 BOW 训练的 SVM 进行分类。
-
记录所有通过 SVM 预测测试的块。
-
当你完成整个图像的分类后,缩小图像并重复整个滑动窗口过程。
继续缩放和分类,直到达到最小尺寸。
这给了你在图像的多个区域和不同尺度上检测对象的机会。
在这个阶段,你将收集有关图像内容的重要信息;然而,有一个问题:你很可能会得到许多重叠的块,这些块给你一个正分数。这意味着你的图像可能包含一个被检测四次或五次的对象,如果你报告检测结果,你的报告将非常不准确,所以这就是非极大值抑制发挥作用的地方。
示例 - 场景中的车辆检测
现在我们已经准备好将迄今为止学到的所有概念应用到实际例子中,并创建一个车辆检测应用程序,该程序扫描图像并在车辆周围绘制矩形。
在我们深入代码之前,让我们总结一下这个过程:
-
获取训练数据集。
-
创建一个 BOW 训练器并创建一个视觉词汇表。
-
使用词汇表训练 SVM。
-
在测试图像的图像金字塔上使用滑动窗口尝试检测。
-
对重叠的框应用非极大值抑制。
-
输出结果。
让我们也看看项目结构,因为它比我们迄今为止采用的经典独立脚本方法要复杂一些。
项目结构如下:
├── car_detector
│ ├── detector.py
│ ├── __init__.py
│ ├── non_maximum.py
│ ├── pyramid.py
│ └── sliding_w112661222.indow.py
└── car_sliding_windows.py
主要程序位于 car_sliding_windows.py,所有实用工具都包含在 car_detector 文件夹中。由于我们使用 Python 2.7,我们需要在文件夹中创建一个 __init__.py 文件,以便将其检测为模块。
car_detector 模块中的四个文件如下:
-
SVM 训练模型
-
非极大值抑制函数
-
图像金字塔
-
滑动窗口函数
让我们逐一检查它们,从图像金字塔开始:
import cv2
def resize(img, scaleFactor):
return cv2.resize(img, (int(img.shape[1] * (1 / scaleFactor)), int(img.shape[0] * (1 / scaleFactor))), interpolation=cv2.INTER_AREA)
def pyramid(image, scale=1.5, minSize=(200, 80)):
yield image
while True:
image = resize(image, scale)
if image.shape[0] < minSize[1] or image.shape[1] < minSize[0]:
break
yield image
此模块包含两个函数定义:
-
Resize 接受一个图像,并按指定因子进行缩放。
-
Pyramid 接受一个图像,并返回一个直到达到最小宽度和高度约束的缩放版本。
注意
你会注意到图像不是通过 return 关键字返回,而是通过 yield 关键字返回。这是因为这个函数是一个所谓的生成器。如果你不熟悉生成器,请查看wiki.python.org/moin/Generators。
这将使我们能够获得一个用于主程序处理的重缩放图像。
接下来是滑动窗口函数:
def sliding_window(image, stepSize, windowSize):
for y in xrange(0, image.shape[0], stepSize):
for x in xrange(0, image.shape[1], stepSize):
yield (x, y, image[y:y + windowSize[1], x:x + windowSize[0]])
这同样是一个生成器。虽然有点深层次嵌套,但这种机制非常简单:给定一个图像,返回一个从左边缘开始,以任意大小的步长向右移动的窗口,直到覆盖整个图像宽度,然后回到左边缘但向下移动一个步长,重复覆盖图像宽度,直到达到图像的右下角。你可以将这想象成在一张纸上写字时使用的相同模式:从左边缘开始,达到右边缘,然后从左边缘开始移动到下一行。
最后一个实用工具是非最大值抑制,它看起来像这样(Malisiewicz/Rosebrock 的代码):
def non_max_suppression_fast(boxes, overlapThresh):
# if there are no boxes, return an empty list
if len(boxes) == 0:
return []
# if the bounding boxes integers, convert them to floats --
# this is important since we'll be doing a bunch of divisions
if boxes.dtype.kind == "i":
boxes = boxes.astype("float")
# initialize the list of picked indexes
pick = []
# grab the coordinates of the bounding boxes
x1 = boxes[:,0]
y1 = boxes[:,1]
x2 = boxes[:,2]
y2 = boxes[:,3]
scores = boxes[:,4]
# compute the area of the bounding boxes and sort the bounding
# boxes by the score/probability of the bounding box
area = (x2 - x1 + 1) * (y2 - y1 + 1)
idxs = np.argsort(scores)[::-1]
# keep looping while some indexes still remain in the indexes
# list
while len(idxs) > 0:
# grab the last index in the indexes list and add the
# index value to the list of picked indexes
last = len(idxs) - 1
i = idxs[last]
pick.append(i)
# find the largest (x, y) coordinates for the start of
# the bounding box and the smallest (x, y) coordinates
# for the end of the bounding box
xx1 = np.maximum(x1[i], x1[idxs[:last]])
yy1 = np.maximum(y1[i], y1[idxs[:last]])
xx2 = np.minimum(x2[i], x2[idxs[:last]])
yy2 = np.minimum(y2[i], y2[idxs[:last]])
# compute the width and height of the bounding box
w = np.maximum(0, xx2 - xx1 + 1)
h = np.maximum(0, yy2 - yy1 + 1)
# compute the ratio of overlap
overlap = (w * h) / area[idxs[:last]]
# delete all indexes from the index list that have
idxs = np.delete(idxs, np.concatenate(([last],
np.where(overlap > overlapThresh)[0])))
# return only the bounding boxes that were picked using the
# integer data type
return boxes[pick].astype("int")
这个函数简单地接受一个矩形列表,并按其分数排序。从分数最高的盒子开始,通过计算交集面积并确定是否大于某个阈值来消除所有超出一定阈值的重叠盒子。
检查 detector.py
现在,让我们检查这个程序的核心,即 detector.py。这有点长且复杂;然而,鉴于我们对 BOW、SVM 和特征检测/提取概念的新认识,一切应该会变得更加清晰。
这里是代码:
import cv2
import numpy as np
datapath = "/path/to/CarData/TrainImages/"
SAMPLES = 400
def path(cls,i):
return "%s/%s%d.pgm" % (datapath,cls,i+1)
def get_flann_matcher():
flann_params = dict(algorithm = 1, trees = 5)
return cv2.FlannBasedMatcher(flann_params, {})
def get_bow_extractor(extract, flann):
return cv2.BOWImgDescriptorExtractor(extract, flann)
def get_extract_detect():
return cv2.xfeatures2d.SIFT_create(), cv2.xfeatures2d.SIFT_create()
def extract_sift(fn, extractor, detector):
im = cv2.imread(fn,0)
return extractor.compute(im, detector.detect(im))[1]
def bow_features(img, extractor_bow, detector):
return extractor_bow.compute(img, detector.detect(img))
def car_detector():
pos, neg = "pos-", "neg-"
detect, extract = get_extract_detect()
matcher = get_flann_matcher()
print "building BOWKMeansTrainer..."
bow_kmeans_trainer = cv2.BOWKMeansTrainer(1000)
extract_bow = cv2.BOWImgDescriptorExtractor(extract, flann)
print "adding features to trainer"
for i in range(SAMPLES):
print i
bow_kmeans_trainer.add(extract_sift(path(pos,i), extract, detect))
bow_kmeans_trainer.add(extract_sift(path(neg,i), extract, detect))
voc = bow_kmeans_trainer.cluster()
extract_bow.setVocabulary( voc )
traindata, trainlabels = [],[]
print "adding to train data"
for i in range(SAMPLES):
print i
traindata.extend(bow_features(cv2.imread(path(pos, i), 0), extract_bow, detect))
trainlabels.append(1)
traindata.extend(bow_features(cv2.imread(path(neg, i), 0), extract_bow, detect))
trainlabels.append(-1)
svm = cv2.ml.SVM_create()
svm.setType(cv2.ml.SVM_C_SVC)
svm.setGamma(0.5)
svm.setC(30)
svm.setKernel(cv2.ml.SVM_RBF)
svm.train(np.array(traindata), cv2.ml.ROW_SAMPLE, np.array(trainlabels))
return svm, extract_bow
让我们来看一下。首先,我们将导入我们常用的模块,然后设置训练图像的路径。
然后,我们将定义多个实用函数:
def path(cls,i):
return "%s/%s%d.pgm" % (datapath,cls,i+1)
这个函数根据基路径和类别名称返回图像的路径。在我们的例子中,我们将使用 neg- 和 pos- 类名称,因为这就是训练图像的名称(即 neg-1.pgm)。最后一个参数是一个整数,用于组成图像路径的最后部分。
接下来,我们将定义一个实用函数来获取 FLANN 匹配器:
def get_flann_matcher():
flann_params = dict(algorithm = 1, trees = 5)
return cv2.FlannBasedMatcher(flann_params, {})
再次强调,传递给算法参数的整数 1 并不代表 FLANN_INDEX_KDTREE。
下两个函数返回 SIFT 特征检测器/提取器以及一个 BOW 训练器:
def get_bow_extractor(extract, flann):
return cv2.BOWImgDescriptorExtractor(extract, flann)
def get_extract_detect():
return cv2.xfeatures2d.SIFT_create(), cv2.xfeatures2d.SIFT_create()
下一个实用工具是一个从图像中返回特征的函数:
def extract_sift(fn, extractor, detector):
im = cv2.imread(fn,0)
return extractor.compute(im, detector.detect(im))[1]
注意
SIFT 检测器检测特征,而 SIFT 提取器提取并返回它们。
我们还将定义一个类似的实用函数来提取 BOW 特征:
def bow_features(img, extractor_bow, detector):
return extractor_bow.compute(img, detector.detect(img))
在 main car_detector 函数中,我们首先创建用于执行特征检测和提取的必要对象:
pos, neg = "pos-", "neg-"
detect, extract = get_extract_detect()
matcher = get_flann_matcher()
bow_kmeans_trainer = cv2.BOWKMeansTrainer(1000)
extract_bow = cv2.BOWImgDescriptorExtractor(extract, flann)
然后,我们将从训练图像中提取的特征添加到训练器中:
print "adding features to trainer"
for i in range(SAMPLES):
print i
bow_kmeans_trainer.add(extract_sift(path(pos,i), extract, detect))
对于每个类别,我们将向训练器添加一个正图像和一个负图像。
之后,我们将指示训练器将数据聚类成 k 个组。
聚类后的数据现在是我们视觉词汇的词汇表,我们可以这样设置 BOWImgDescriptorExtractor 类的词汇表:
vocabulary = bow_kmeans_trainer.cluster()
extract_bow.setVocabulary(vocabulary)
将训练数据与类别关联
准备好视觉词汇表后,我们现在可以将训练数据与类别关联起来。在我们的例子中,我们有两个类别:-1 表示负结果,1 表示正结果。
让我们填充两个数组,traindata 和 trainlabels,包含提取的特征及其相应的标签。通过迭代数据集,我们可以快速使用以下代码设置:
traindata, trainlabels = [], []
print "adding to train data"
for i in range(SAMPLES):
print i
traindata.extend(bow_features(cv2.imread(path(pos, i), 0), extract_bow, detect))
trainlabels.append(1)
traindata.extend(bow_features(cv2.imread(path(neg, i), 0), extract_bow, detect))
trainlabels.append(-1)
你会注意到,在每次循环中,我们会添加一个正图像和一个负图像,然后使用 1 和 -1 的值填充标签,以保持数据与标签同步。
如果你希望训练更多类别,你可以按照以下模式进行:
traindata, trainlabels = [], []
print "adding to train data"
for i in range(SAMPLES):
print i
traindata.extend(bow_features(cv2.imread(path(class1, i), 0), extract_bow, detect))
trainlabels.append(1)
traindata.extend(bow_features(cv2.imread(path(class2, i), 0), extract_bow, detect))
trainlabels.append(2)
traindata.extend(bow_features(cv2.imread(path(class3, i), 0), extract_bow, detect))
trainlabels.append(3)
例如,你可以训练一个检测器来检测汽车和人,并在包含汽车和人的图像上进行检测。
最后,我们将使用以下代码训练 SVM:
svm = cv2.ml.SVM_create()
svm.setType(cv2.ml.SVM_C_SVC)
svm.setGamma(0.5)
svm.setC(30)
svm.setKernel(cv2.ml.SVM_RBF)
svm.train(np.array(traindata), cv2.ml.ROW_SAMPLE, np.array(trainlabels))
return svm, extract_bow
有两个特定的参数我想引起你的注意:
-
C: 使用此参数,你可以概念化分类器的严格性或严重性。值越高,误分类的机会越少,但代价是可能无法检测到一些阳性结果。另一方面,低值可能导致过拟合,因此你可能会得到假阳性。
-
核函数:此参数决定了分类器的性质:
SVM_LINEAR表示线性 超平面,在实际情况中,对于二元分类(测试样本要么属于一个类别,要么不属于)非常有效,而SVM_RBF(径向基函数)使用高斯函数来分离数据,这意味着数据被分割成由这些函数定义的多个核。当训练 SVM 进行超过两个类别的分类时,你必须使用 RBF。
最后,我们将 traindata 和 trainlabels 数组传递给 SVM 的 train 方法,并返回 SVM 和 BOW 提取器对象。这是因为在我们的应用中,我们不希望每次都要重新创建词汇表,所以我们将其公开以供重用。
嘿,我的车在哪里?
我们已经准备好测试我们的汽车检测器了!
让我们先创建一个简单的程序,该程序加载一张图片,然后分别使用滑动窗口和图像金字塔技术进行检测:
import cv2
import numpy as np
from car_detector.detector import car_detector, bow_features
from car_detector.pyramid import pyramid
from car_detector.non_maximum import non_max_suppression_fast as nms
from car_detector.sliding_window import sliding_window
def in_range(number, test, thresh=0.2):
return abs(number - test) < thresh
test_image = "/path/to/cars.jpg"
svm, extractor = car_detector()
detect = cv2.xfeatures2d.SIFT_create()
w, h = 100, 40
img = cv2.imread(test_img)
rectangles = []
counter = 1
scaleFactor = 1.25
scale = 1
font = cv2.FONT_HERSHEY_PLAIN
for resized in pyramid(img, scaleFactor):
scale = float(img.shape[1]) / float(resized.shape[1])
for (x, y, roi) in sliding_window(resized, 20, (w, h)):
if roi.shape[1] != w or roi.shape[0] != h:
continue
try:
bf = bow_features(roi, extractor, detect)
_, result = svm.predict(bf)
a, res = svm.predict(bf, flags=cv2.ml.STAT_MODEL_RAW_OUTPUT)
print "Class: %d, Score: %f" % (result[0][0], res[0][0])
score = res[0][0]
if result[0][0] == 1:
if score < -1.0:
rx, ry, rx2, ry2 = int(x * scale), int(y * scale), int((x+w) * scale), int((y+h) * scale)
rectangles.append([rx, ry, rx2, ry2, abs(score)])
except:
pass
counter += 1
windows = np.array(rectangles)
boxes = nms(windows, 0.25)
for (x, y, x2, y2, score) in boxes:
print x, y, x2, y2, score
cv2.rectangle(img, (int(x),int(y)),(int(x2), int(y2)),(0, 255, 0), 1)
cv2.putText(img, "%f" % score, (int(x),int(y)), font, 1, (0, 255, 0))
cv2.imshow("img", img)
cv2.waitKey(0)
程序的显著部分是位于金字塔/滑动窗口循环内的函数:
bf = bow_features(roi, extractor, detect)
_, result = svm.predict(bf)
a, res = svm.predict(bf, flags=cv2.ml.STAT_MODEL_RAW_OUTPUT)
print "Class: %d, Score: %f" % (result[0][0], res[0][0])
score = res[0][0]
if result[0][0] == 1:
if score < -1.0:
rx, ry, rx2, ry2 = int(x * scale), int(y * scale), int((x+w) * scale), int((y+h) * scale)
rectangles.append([rx, ry, rx2, ry2, abs(score)])
在这里,我们提取了感兴趣区域(ROI)的特征,这对应于当前的滑动窗口,然后我们对提取的特征调用 predict 方法。predict 方法有一个可选参数 flags,它返回预测的分数(位于 [0][0] 的值)。
注意
关于预测分数的一番话:值越低,分类元素真正属于该类的置信度越高。
因此,我们将为分类窗口设置一个任意的阈值 -1.0,所有小于 -1.0 的窗口将被视为良好结果。随着你对你的 SVM 进行实验,你可以调整这个值,直到找到最佳的结果。
最后,我们将滑动窗口的计算坐标(即,我们将当前坐标乘以图像金字塔中当前层的缩放比例,以便在最终绘图中得到正确表示)添加到矩形数组中。
在绘制最终结果之前,我们需要执行最后一个操作:非极大值抑制。
我们将矩形数组转换为 NumPy 数组(以便进行某些只有 NumPy 才能执行的操作),然后应用 NMS:
windows = np.array(rectangles)
boxes = nms(windows, 0.25)
最后,我们继续显示所有结果;为了方便起见,我还打印了所有剩余窗口获得的分数:

这是一个非常准确的结果!
关于支持向量机(SVM)的最后一句话:您不需要每次使用检测器时都对其进行训练,这会非常不切实际。您可以使用以下代码:
svm.save('/path/to/serialized/svmxml')
您可以使用加载方法重新加载它,并为其提供测试图像或帧。
摘要
在本章中,我们讨论了许多目标检测概念,例如 HOG、BOW、SVM 以及一些有用的技术,例如图像金字塔、滑动窗口和非极大值抑制。
我们介绍了机器学习的概念,并探讨了用于训练自定义检测器的各种方法,包括如何创建或获取训练数据集以及如何对数据进行分类。最后,我们通过从头创建一个车辆检测器并验证其正确功能,将这一知识应用于实践。
所有这些概念构成了下一章的基础,我们将利用视频制作中的目标检测和分类技术,并学习如何跟踪对象以保留可能用于商业或应用目的的信息。
第八章:跟踪对象
在本章中,我们将探讨对象跟踪这一广泛的主题,它是从电影或视频输入中定位移动对象的过程。实时对象跟踪是许多计算机视觉应用(如监控、感知用户界面、增强现实、基于对象的视频压缩和驾驶员辅助)中的关键任务。
跟踪对象可以通过多种方式完成,最佳技术很大程度上取决于手头的任务。我们将学习如何识别移动对象并在帧之间跟踪它们。
检测移动对象
为了能够跟踪视频中的任何物体,我们需要完成的第一项任务是识别视频帧中对应于移动对象的区域。
在视频中跟踪对象有许多方法,它们都满足略微不同的目的。例如,你可能想跟踪任何移动的物体,在这种情况下,帧之间的差异将有所帮助;你可能想跟踪视频中移动的手,在这种情况下,基于皮肤颜色的 Meanshift 是最合适的解决方案;你可能想跟踪一个你知道其外观的特定对象,在这种情况下,模板匹配等技术将有所帮助。
目标跟踪技术可能相当复杂,让我们按难度递增的顺序来探讨它们,从最简单的技术开始。
基本运动检测
第一个也是最直观的解决方案是计算帧之间的差异,或者计算一个被认为是“背景”的帧与所有其他帧之间的差异。
让我们看看这种方法的一个例子:
import cv2
import numpy as np
camera = cv2.VideoCapture(0)
es = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9,4))
kernel = np.ones((5,5),np.uint8)
background = None
while (True):
ret, frame = camera.read()
if background is None:
background = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
background = cv2.GaussianBlur(background, (21, 21), 0)
continue
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray_frame = cv2.GaussianBlur(gray_frame, (21, 21), 0)
diff = cv2.absdiff(background, gray_frame)
diff = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
diff = cv2.dilate(diff, es, iterations = 2)
image, cnts, hierarchy = cv2.findContours(diff.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in cnts:
if cv2.contourArea(c) < 1500:
continue
(x, y, w, h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2)
cv2.imshow("contours", frame)
cv2.imshow("dif", diff)
if cv2.waitKey(1000 / 12) & 0xff == ord("q"):
break
cv2.destroyAllWindows()
camera.release()
在完成必要的导入之后,我们打开从默认系统相机获取的视频输入,并将第一帧设置为整个输入的背景。从那时起读取的每一帧都会被处理,以计算背景与帧本身的差异。这是一个简单的操作:
diff = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
然而,在我们开始之前,我们需要为处理准备我们的帧。我们首先做的事情是将帧转换为灰度并稍微模糊一下:
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray_frame = cv2.GaussianBlur(gray_frame, (21, 21), 0)
注意
你可能会对模糊处理感到好奇:我们模糊图像的原因是,在每一帧视频输入中,都存在来自自然振动、光照变化以及相机本身产生的自然噪声。我们希望平滑这些噪声,以免它们被检测为运动并随之被追踪。
现在我们已经将帧转换为灰度并平滑处理,我们可以计算与背景(背景也已被转换为灰度并平滑处理)的差异,并获得差异图。但这不是唯一的处理步骤。我们还将应用一个阈值,以获得黑白图像,并膨胀图像,以便将孔洞和不完美之处标准化,如下所示:
diff = cv2.absdiff(background, gray_frame)
diff = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)[1]
diff = cv2.dilate(diff, es, iterations = 2)
注意,腐蚀和膨胀也可以作为噪声过滤器,就像我们应用的模糊一样,并且也可以通过一个函数调用使用cv2.morphologyEx获得,我们明确展示这两个步骤是为了透明度。此时,我们剩下的唯一任务是找到计算差异图中所有白色团块的轮廓,并将它们显示出来。可选地,我们只显示大于任意阈值的矩形轮廓,这样就不会显示微小的移动。当然,这取决于您和您的应用需求。在恒定照明和非常无噪声的摄像头下,您可能希望没有轮廓最小尺寸的阈值。这就是我们显示矩形的方式:
image, cnts, hierarchy = cv2.findContours(diff.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in cnts:
if cv2.contourArea(c) < 1500:
continue
(x, y, w, h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x + w, y + h), (255, 255, 0), 2)
cv2.imshow("contours", frame)
cv2.imshow("dif", diff)
OpenCV 提供了两个非常实用的函数:
-
cv2.findContours:此函数计算图像中主体的轮廓 -
cv2.boundinRect:此函数计算它们的边界框
所以,这就是它了,一个带有主体周围矩形的简单运动检测器。最终结果可能像这样:

对于这样简单的技术,这相当准确。然而,有一些缺点使得这种方法不适合所有商业需求,最值得注意的是,您需要一个“默认”的第一帧来设置为背景。在例如户外摄像头等情况下,灯光变化相当频繁,这个过程导致了一种相当不灵活的方法,因此我们需要在我们的系统中加入更多的智能。这就是背景减除器发挥作用的地方。
背景减除器 – KNN、MOG2 和 GMG
OpenCV 提供了一个名为BackgroundSubtractor的类,这是一种方便操作前景和背景分割的方法。
这与我们在第三章中分析的 GrabCut 算法类似,即使用 OpenCV 3 处理图像,然而,BackgroundSubtractor是一个功能齐全的类,拥有众多方法,不仅执行背景减除,而且通过机器学习提高背景检测的速度,并允许您将分类器保存到文件中。
为了熟悉BackgroundSubtractor,让我们看看一个基本示例:
import numpy as np
import cv2
cap = cv2.VideoCapture')
mog = cv2.createBackgroundSubtractorMOG2()
while(1):
ret, frame = cap.read()
fgmask = mog.apply(frame)
cv2.imshow('frame',fgmask)
if cv2.waitKey(30) & 0xff:
break
cap.release()
cv2.destroyAllWindows()
让我们按顺序来讲解。首先,让我们谈谈背景减除对象。在 OpenCV 3 中,有三种背景减除器可供选择:K-最近邻(KNN)、高斯混合模型(MOG2)和几何多重网格(GMG),它们分别对应于计算背景减除所使用的算法。
您可能还记得,我们在第五章中已经详细讨论了前景和背景检测的主题,即深度估计和分割,特别是当我们谈到 GrabCut 和 Watershed 时。
那么,为什么我们需要 BackgroundSubtractor 类呢?背后的主要原因是 BackgroundSubtractor 类是专门为视频分析构建的,这意味着 OpenCV 的 BackgroundSubtractor 类会随着每一帧学习有关环境的一些信息。例如,使用 GMG,你可以指定用于初始化视频分析的帧数,默认为 120(大约是平均相机的 5 秒)。BackgroundSubtractor 类的恒定特性是它们在帧之间进行比较并存储历史记录,这使得它们随着时间的推移改进运动分析结果。
BackgroundSubtractor 类的另一个基本(而且坦白说,相当惊人)的特性是计算阴影的能力。这对于准确读取视频帧至关重要;通过检测阴影,你可以通过阈值化排除检测到的物体中的阴影区域,并专注于真实特征。这也大大减少了物体之间不想要的“合并”。图像比较将给你一个很好的概念,即我想说明的概念。以下是没有阴影检测的背景减除的示例:

这里是一个阴影检测(阴影已阈值化)的示例:

注意,阴影检测并不绝对完美,但它有助于将物体轮廓恢复到物体的原始形状。让我们看看使用 BackgroundSubtractorKNN 重新实现的运动检测示例:
import cv2
import numpy as np
bs = cv2.createBackgroundSubtractorKNN(detectShadows = True)
camera = cv2.VideoCapture("/path/to/movie.flv")
while True:
ret, frame = camera.read()
fgmask = bs.apply(frame)
th = cv2.threshold(fgmask.copy(), 244, 255, cv2.THRESH_BINARY)[1]
dilated = cv2.dilate(th, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)), iterations = 2)
image, contours, hier = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 1600:
(x,y,w,h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x,y), (x+w, y+h), (255, 255, 0), 2)
cv2.imshow("mog", fgmask)
cv2.imshow("thresh", th)
cv2.imshow("detection", frame)
if cv2.waitKey(30) & 0xff == 27:
break
camera.release()
cv2.destroyAllWindows()
由于减除器的准确性和检测阴影的能力,我们得到了非常精确的运动检测,即使相邻的物体也不会合并成一个检测,如下面的截图所示:

这是在少于 30 行代码的情况下取得的显著成果!
整个程序的核心是背景减除器的 apply() 方法;它计算前景掩码,这可以作为后续处理的基础:
fgmask = bs.apply(frame)
th = cv2.threshold(fgmask.copy(), 244, 255, cv2.THRESH_BINARY)[1]
dilated = cv2.dilate(th, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)), iterations = 2)
image, contours, hier = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 1600:
(x,y,w,h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x,y), (x+w, y+h), (255, 255, 0), 2)
一旦获得前景掩码,我们可以应用一个阈值:前景掩码对前景使用白色值,对阴影使用灰色值;因此,在阈值图像中,所有几乎不是纯白色的像素(244-255)都被二值化为 0 而不是 1。
从那里,我们继续使用与基本运动检测示例相同的方法:识别物体、检测轮廓,并在原始帧上绘制它们。
均值漂移和 CAMShift
背景减除是一种非常有效的技术,但并不是跟踪视频中物体的唯一方法。Meanshift 是一种通过寻找概率函数(在我们的情况下,是图像中的感兴趣区域)的离散样本的最大密度并重新计算它来跟踪物体的算法,在下一帧中,这为算法提供了物体移动方向的指示。
这个计算会重复进行,直到质心与原始质心匹配,或者即使经过连续的计算迭代后保持不变。这种最终的匹配称为收敛。为了参考,该算法最初在论文《密度函数梯度的估计及其在模式识别中的应用》中描述,作者为 Fukunaga K.和 Hoestetler L.,发表在 IEEE,1975 年,可在ieeexplore.ieee.org/xpl/login.jsp?tp=&arnumber=1055330&url=http%3A%2F%2Fieeexplore.ieee.org%2Fxpls%2Fabs_all.jsp%3Farnumber%3D1055330(请注意,这篇论文不可免费下载)。
这里是这个过程的视觉表示:

除了理论之外,Meanshift 在跟踪视频中的特定感兴趣区域时非常有用,这有一系列的影响;例如,如果你事先不知道你想要跟踪的区域是什么,你将不得不巧妙地管理它,并开发出能够根据任意标准动态开始跟踪(并停止跟踪)视频的某些区域的程序。一个例子可能是你使用训练好的 SVM 进行对象检测,然后开始使用 Meanshift 来跟踪检测到的对象。
我们不要一开始就使生活变得复杂;首先让我们熟悉一下 Meanshift,然后再在更复杂的场景中使用它。
我们将首先简单地标记一个感兴趣的区域,并跟踪它,如下所示:
import numpy as np
import cv2
cap = cv2.VideoCapture(0)
ret,frame = cap.read()
r,h,c,w = 10, 200, 10, 200
track_window = (c,r,w,h)
roi = frame[r:r+h, c:c+w]
hsv_roi = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv_roi, np.array((100., 30.,32.)), np.array((180.,120.,255.)))
roi_hist = cv2.calcHist([hsv_roi],[0],mask,[180],[0,180])
cv2.normalize(roi_hist,roi_hist,0,255,cv2.NORM_MINMAX)
term_crit = ( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 )
while True:
ret ,frame = cap.read()
if ret == True:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
dst = cv2.calcBackProject([hsv],[0],roi_hist,[0,180],1)
# apply meanshift to get the new location
ret, track_window = cv2.meanShift(dst, track_window, term_crit)
# Draw it on image
x,y,w,h = track_window
img2 = cv2.rectangle(frame, (x,y), (x+w,y+h), 255,2)
cv2.imshow('img2',img2)
k = cv2.waitKey(60) & 0xff
if k == 27:
break
else:
break
cv2.destroyAllWindows()
cap.release()
在前面的代码中,我提供了跟踪某些紫罗兰色调的 HSV 值,这里是结果:

如果你在自己的机器上运行了这段代码,你会注意到 Meanshift 窗口实际上是如何寻找指定的颜色范围的;如果找不到,你只会看到窗口在摇摆(它实际上看起来有点不耐烦)。如果具有指定颜色范围的物体进入窗口,窗口就会开始跟踪它。
让我们检查一下代码,以便我们可以完全理解 Meanshift 是如何执行这个跟踪操作的。
颜色直方图
在展示前一个示例的代码之前,这里有一个关于颜色直方图和 OpenCV 的两个非常重要的内置函数calcHist和calcBackProject的简短讨论。
函数 calcHist 计算图像的颜色直方图,因此下一个逻辑步骤是解释颜色直方图的概念。颜色直方图是图像颜色分布的表示。在表示的 x 轴上,我们有颜色值,而在 y 轴上,我们有对应于颜色值的像素数量。
让我们看看这个概念的可视表示,希望谚语“一图胜千言”也适用于这个例子:

图片展示了颜色直方图的表示,每列代表从 0 到 180 的一个值(请注意,OpenCV 使用 H 值 0-180。其他系统可能使用 0-360 或 0-255)。 |
除了 Meanshift 之外,颜色直方图还用于许多不同且有用的图像和视频处理操作。
calcHist 函数
OpenCV 中的 calcHist() 函数具有以下 Python 签名:
calcHist(...)
calcHist(images, channels, mask, histSize, ranges[, hist[, accumulate]]) -> hist
参数的描述(如从官方 OpenCV 文档中获取)如下:
| 参数 | 描述 |
|---|---|
images |
此参数是源数组。它们都应该具有相同的深度,CV_8U 或 CV_32F,并且具有相同的大小。每个数组都可以有任意数量的通道。 |
channels |
此参数是用于计算直方图的 dims 通道列表。 |
mask |
此参数是可选的掩码。如果矩阵不为空,它必须是一个与 images[i] 相同大小的 8 位数组。非零掩码元素标记了在直方图中计数的数组元素。 |
histSize |
此参数是每个维度的直方图大小的数组。 |
ranges |
此参数是每个维度的直方图 bin 边界数组的 dims 数组。 |
hist |
此参数是输出直方图,它是一个密集或稀疏的 dims(维度)数组。 |
accumulate |
此参数是累积标志。如果设置此标志,则在分配时不会清除直方图。此功能使您能够从多个数组集中计算单个直方图,或者实时更新直方图。 |
在我们的例子中,我们像这样计算感兴趣区域的直方图:
roi_hist = cv2.calcHist([hsv_roi],[0],mask,[180],[0,180])
这可以解释为计算包含仅包含 HSV 空间中感兴趣区域的图像数组的颜色直方图。在这个区域,我们仅计算对应于掩码值不等于 0 的图像值,使用 18 个直方图列,并且每个直方图的边界为 0(下限)和 180(上限)。
这相当复杂来描述,但一旦您熟悉了直方图的概念,拼图碎片应该就会对齐。
calcBackProject 函数
在 Meanshift 算法(但不仅限于此)中扮演关键角色的另一个函数是calcBackProject,简称直方图反向 投影(计算)。直方图反向投影之所以被称为如此,是因为它将直方图投影回图像上,结果是每个像素属于最初生成直方图的图像的概率。因此,calcBackProject给出了一个概率估计,即某个图像等于或类似于模型图像(从该图像生成了原始直方图)。
再次,如果你认为calcHist有点复杂,那么calcBackProject可能更加复杂!
总结来说
calcHist函数从一个图像中提取颜色直方图,给出图像中颜色的统计表示,而calcBackProject有助于计算图像中每个像素属于原始图像的概率。
回到代码
让我们回到我们的例子。首先是我们常用的导入,然后我们标记初始感兴趣区域:
cap = cv2.VideoCapture(0)
ret,frame = cap.read()
r,h,c,w = 10, 200, 10, 200
track_window = (c,r,w,h)
然后,我们提取并将 ROI 转换为 HSV 颜色空间:
roi = frame[r:r+h, c:c+w]
hsv_roi = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
现在,我们创建一个掩码,包括 ROI 中所有在上下限之间的 HSV 值的像素:
mask = cv2.inRange(hsv_roi, np.array((100., 30.,32.)), np.array((180.,120.,255.)))
接下来,我们计算 ROI 的直方图:
roi_hist = cv2.calcHist([hsv_roi],[0],mask,[180],[0,180])
cv2.normalize(roi_hist,roi_hist,0,255,cv2.NORM_MINMAX)
直方图计算完成后,值被归一化,以便包含在 0-255 的范围内。
Meanshift 在达到收敛之前会进行多次迭代;然而,这种收敛并不保证。因此,OpenCV 允许我们传递所谓的终止条件,这是一种指定 Meanshift 在终止一系列计算方面的行为的方式:
term_crit = ( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 )
在这个特定的情况下,我们指定了一个行为,指示 Meanshift 在十次迭代后停止计算质心偏移,或者如果质心至少移动了 1 个像素。第一个标志(EPS或CRITERIA_COUNT)表示我们将使用两个标准之一(计数或“epsilon”,即最小移动)。
现在我们已经计算了直方图,并且有了 Meanshift 的终止条件,我们可以开始我们通常的无穷循环,从摄像头中获取当前帧,并开始处理它。我们首先做的事情是切换到 HSV 颜色空间:
if ret == True:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
现在我们有了 HSV 数组,我们可以进行期待已久的直方图反向投影:
dst = cv2.calcBackProject([hsv],[0],roi_hist,[0,180],1)
calcBackProject的结果是一个矩阵。如果你将它打印到控制台,看起来大致如下:
[[ 0 0 0 ..., 0 0 0]
[ 0 0 0 ..., 0 0 0]
[ 0 0 0 ..., 0 0 0]
...,
[ 0 0 20 ..., 0 0 0]
[ 78 20 0 ..., 0 0 0]
[255 137 20 ..., 0 0 0]]
每个像素都用其概率来表示。
这个矩阵最终可以传递给 Meanshift,同时还有由cv2.meanShift的 Python 签名概述的跟踪窗口和终止条件:
meanShift(...)
meanShift(probImage, window, criteria) -> retval, window
所以,这就是它:
ret, track_window = cv2.meanShift(dst, track_window, term_crit)
最后,我们计算窗口的新坐标,画一个矩形来在帧中显示它,然后显示它:
x,y,w,h = track_window
img2 = cv2.rectangle(frame, (x,y), (x+w,y+h), 255,2)
cv2.imshow('img2',img2)
就这样。到现在为止,你应该对颜色直方图、反向投影和 Meanshift 有了一个很好的了解。然而,前一个程序仍然存在一个问题需要解决:窗口的大小不会随着被跟踪帧中对象的大小而改变。
计算机视觉领域的权威人士,也是开创性书籍《Learning OpenCV》的作者 Gary Bradski,O'Reilly,于 1988 年发表了一篇论文,以提高 Meanshift 的准确性,并描述了一种名为 连续自适应均值漂移(CAMShift)的新算法,该算法与 Meanshift 非常相似,但在 Meanshift 达到收敛时也会自适应调整跟踪窗口的大小。
CAMShift
虽然 CAMShift 为 Meanshift 增加了复杂性,但使用 CAMShift 实现的前一个程序与 Meanshift 示例惊人地(或不是吗?)相似,主要区别在于在调用 CamShift 之后,矩形以特定的旋转方式绘制,该旋转方式跟随被跟踪对象的旋转。
下面是使用 CAMShift 重新实现的代码:
import numpy as np
import cv2
cap = cv2.VideoCapture(0)
# take first frame of the video
ret,frame = cap.read()
# setup initial location of window
r,h,c,w = 300,200,400,300 # simply hardcoded the values
track_window = (c,r,w,h)
roi = frame[r:r+h, c:c+w]
hsv_roi = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
mask = cv2.inRange(hsv_roi, np.array((100., 30.,32.)), np.array((180.,120.,255.)))
roi_hist = cv2.calcHist([hsv_roi],[0],mask,[180],[0,180])
cv2.normalize(roi_hist,roi_hist,0,255,cv2.NORM_MINMAX)
term_crit = ( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 )
while(1):
ret ,frame = cap.read()
if ret == True:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
dst = cv2.calcBackProject([hsv],[0],roi_hist,[0,180],1)
ret, track_window = cv2.CamShift(dst, track_window, term_crit)
pts = cv2.boxPoints(ret)
pts = np.int0(pts)
img2 = cv2.polylines(frame,[pts],True, 255,2)
cv2.imshow('img2',img2)
k = cv2.waitKey(60) & 0xff
if k == 27:
break
else:
break
cv2.destroyAllWindows()
cap.release()
CAMShift 代码与 Meanshift 代码之间的区别在于这四行:
ret, track_window = cv2.CamShift(dst, track_window, term_crit)
pts = cv2.boxPoints(ret)
pts = np.int0(pts)
img2 = cv2.polylines(frame,[pts],True, 255,2)
CamShift 的方法签名与 Meanshift 相同。
boxPoints 函数找到旋转矩形的顶点,而 polylines 函数在帧上绘制矩形的线条。
到现在为止,你应该熟悉我们采用的跟踪对象的三种方法:基本运动检测、Meanshift 和 CAMShift。
现在我们来探索另一种技术:卡尔曼滤波器。
卡尔曼滤波器
卡尔曼滤波器是一种主要(但不仅限于)由鲁道夫·卡尔曼在 1950 年代后期开发的算法,并在许多领域找到了实际应用,尤其是在从核潜艇到飞机的各种车辆的导航系统中。
卡尔曼滤波器对噪声输入数据流(在计算机视觉中通常是视频流)进行递归操作,以产生对底层系统状态(视频中的位置)的统计最优估计。
让我们快速举一个例子来概念化卡尔曼滤波器,并将前一个(故意宽泛和通用)定义翻译成更简单的英语。想象一下桌子上一个小红球,并想象你有一个指向场景的相机。你确定球是跟踪的主题,并用手指弹它。球将在桌子上滚动,遵循我们熟悉的运动定律。
如果球以每秒 1 米的速度(1 m/s)沿特定方向滚动,你不需要卡尔曼滤波器来估计 1 秒后球的位置:它将在 1 米之外。卡尔曼滤波器将这些定律应用于根据前一帧收集的观测数据来预测当前视频帧中对象的位置。自然地,卡尔曼滤波器无法知道桌子上铅笔如何改变球的轨迹,但它可以调整这种不可预见的事件。
预测和更新
从前面的描述中,我们了解到卡尔曼滤波器算法分为两个阶段:
-
预测:在第一阶段,卡尔曼滤波器使用到当前时间点计算出的协方差来估计对象的新位置
-
更新:在第二阶段,它记录对象的位置并调整下一次计算周期的协方差
在 OpenCV 术语中,这种调整是一种校正,因此 OpenCV Python 绑定的KalmanFilter类的 API 如下:
class KalmanFilter(__builtin__.object)
| Methods defined here:
|
| __repr__(...)
| x.__repr__() <==> repr(x)
|
| correct(...)
| correct(measurement) -> retval
|
| predict(...)
| predict([, control]) -> retval
我们可以推断,在我们的程序中,我们将调用predict()来估计对象的位置,并调用correct()来指示卡尔曼滤波器调整其计算。
举例说明
最终,我们将旨在将卡尔曼滤波器与 CAMShift 结合使用,以获得最高程度的准确性和性能。然而,在我们进入这种复杂程度之前,让我们分析一个简单的例子,特别是当涉及到卡尔曼滤波器和 OpenCV 时,在网络上似乎非常常见的例子:鼠标跟踪。
在以下示例中,我们将绘制一个空帧和两条线:一条对应于鼠标的实际移动,另一条对应于卡尔曼滤波器的预测。以下是代码:
import cv2
import numpy as np
frame = np.zeros((800, 800, 3), np.uint8)
last_measurement = current_measurement = np.array((2,1), np.float32)
last_prediction = current_prediction = np.zeros((2,1), np.float32)
def mousemove(event, x, y, s, p):
global frame, current_measurement, measurements, last_measurement, current_prediction, last_prediction
last_prediction = current_prediction
last_measurement = current_measurement
current_measurement = np.array([[np.float32(x)],[np.float32(y)]])
kalman.correct(current_measurement)
current_prediction = kalman.predict()
lmx, lmy = last_measurement[0], last_measurement[1]
cmx, cmy = current_measurement[0], current_measurement[1]
lpx, lpy = last_prediction[0], last_prediction[1]
cpx, cpy = current_prediction[0], current_prediction[1]
cv2.line(frame, (lmx, lmy), (cmx, cmy), (0,100,0))
cv2.line(frame, (lpx, lpy), (cpx, cpy), (0,0,200))
cv2.namedWindow("kalman_tracker")
cv2.setMouseCallback("kalman_tracker", mousemove)
kalman = cv2.KalmanFilter(4,2)
kalman.measurementMatrix = np.array([[1,0,0,0],[0,1,0,0]],np.float32)
kalman.transitionMatrix = np.array([[1,0,1,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]],np.float32)
kalman.processNoiseCov = np.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]],np.float32) * 0.03
while True:
cv2.imshow("kalman_tracker", frame)
if (cv2.waitKey(30) & 0xFF) == 27:
break
cv2.destroyAllWindows()
如同往常,让我们一步一步地分析。在包导入之后,我们创建一个 800 x 800 大小的空帧,然后初始化将存储鼠标移动测量和预测坐标的数组:
frame = np.zeros((800, 800, 3), np.uint8)
last_measurement = current_measurement = np.array((2,1), np.float32)
last_prediction = current_prediction = np.zeros((2,1), np.float32)
然后,我们声明鼠标移动的Callback函数,该函数将处理跟踪的绘制。机制相当简单;我们存储最后测量和最后预测,使用当前测量来校正卡尔曼滤波器,计算卡尔曼预测,并最终绘制两条线,一条从最后测量到当前测量,另一条从最后预测到当前:
def mousemove(event, x, y, s, p):
global frame, current_measurement, measurements, last_measurement, current_prediction, last_prediction
last_prediction = current_prediction
last_measurement = current_measurement
current_measurement = np.array([[np.float32(x)],[np.float32(y)]])
kalman.correct(current_measurement)
current_prediction = kalman.predict()
lmx, lmy = last_measurement[0], last_measurement[1]
cmx, cmy = current_measurement[0], current_measurement[1]
lpx, lpy = last_prediction[0], last_prediction[1]
cpx, cpy = current_prediction[0], current_prediction[1]
cv2.line(frame, (lmx, lmy), (cmx, cmy), (0,100,0))
cv2.line(frame, (lpx, lpy), (cpx, cpy), (0,0,200))
下一步是初始化窗口并设置Callback函数。OpenCV 使用setMouseCallback函数处理鼠标事件;必须使用Callback函数的第一个参数(事件)来处理特定事件,该参数确定已触发的事件类型(点击、移动等):
cv2.namedWindow("kalman_tracker")
cv2.setMouseCallback("kalman_tracker", mousemove)
现在我们已经准备好创建卡尔曼滤波器:
kalman = cv2.KalmanFilter(4,2)
kalman.measurementMatrix = np.array([[1,0,0,0],[0,1,0,0]],np.float32)
kalman.transitionMatrix = np.array([[1,0,1,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]],np.float32)
kalman.processNoiseCov = np.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]],np.float32) * 0.03
卡尔曼滤波器类在其构造函数中接受可选参数(来自 OpenCV 文档):
-
dynamParams: 此参数表示状态的维度 -
MeasureParams: 此参数表示测量的维度 -
ControlParams: 此参数表示控制的维度 -
vector.type: 此参数表示创建的矩阵的类型,应为CV_32F或CV_64F
我发现前面的参数(构造函数和卡尔曼属性)工作得非常好。
从这一点开始,程序很简单;每次鼠标移动都会触发一个 Kalman 预测,实际鼠标位置和 Kalman 预测都会在帧中绘制,并持续显示。如果你移动鼠标,你会注意到,如果你以高速突然转弯,预测线将有一个更宽的轨迹,这与当时鼠标移动的动量是一致的。以下是一个示例结果:

一个现实生活中的例子——跟踪行人
到目前为止,我们已经熟悉了运动检测、目标检测和目标跟踪的概念,所以我想你一定渴望将这些新获得的知识应用于现实场景。让我们通过检查监控摄像头的视频流并跟踪其中的行人来实现这一点。
首先,我们需要一个样本视频;如果你下载了 OpenCV 源代码,你将在<opencv_dir>/samples/data/768x576.avi中找到用于此目的的完美视频文件。
现在我们有了完美的分析资产,让我们开始构建应用。
应用工作流程
应用将遵循以下逻辑:
-
检查第一帧。
-
检查以下帧,并执行背景减法以识别场景开始时的行人。
-
为每个行人建立一个 ROI,并使用 Kalman/CAMShift 跟踪,为每个行人分配一个 ID。
-
检查下一帧中进入场景的新行人。
如果这是一个现实世界的应用,你可能需要存储行人信息以获取有关场景中行人平均停留时间和最可能路线等信息。然而,这些都超出了这个示例应用的范围。
在现实世界的应用中,你需要确保识别进入场景的新行人,但到目前为止,我们将专注于跟踪视频开始时场景中的对象,利用 CAMShift 和 Kalman 滤波算法。
你可以在代码仓库的chapter8/surveillance_demo/目录中找到这个应用的代码。
简短地偏离一下——函数式编程与面向对象编程
虽然大多数程序员要么熟悉(或经常工作于)面向对象编程(OOP),但我发现,随着年岁的增长,我越来越偏好函数式编程(FP)解决方案。
对于那些不熟悉术语的人来说,FP 是一种被许多语言采用的编程范式,它将程序视为数学函数的评估,允许函数返回函数,并允许函数作为函数的参数。FP 的强大之处不仅在于它能做什么,还在于它能避免什么,或者旨在避免副作用和状态改变。如果函数式编程的话题激起了你的兴趣,确保查看 Haskell、Clojure 或 ML 等语言。
注意
在编程术语中,副作用是什么?你可以将副作用定义为任何改变任何不依赖于函数输入值的函数。Python,以及许多其他语言,容易产生副作用,因为——就像例如 JavaScript 一样——它允许访问全局变量(有时这种访问全局变量的方式可能是意外的!)。
在非纯函数式语言中遇到的主要另一个问题是,函数的结果会随时间变化,这取决于涉及的变量的状态。如果一个函数接受一个对象作为参数——例如——并且计算依赖于该对象的内部状态,那么函数将根据对象状态的改变返回不同的结果。这在像 C 和 C++ 这样的语言中很典型,在这些语言中,一个或多个参数是对象的引用。
为什么离题?因为到目前为止,我主要使用函数来阐述概念;我没有回避在简单且最稳健的方法下访问全局变量。然而,我们将要检查的下一个程序将包含面向对象编程(OOP)。那么,为什么我选择在提倡函数式编程的同时采用 OOP?因为 OpenCV 有一种相当有偏见的做法,这使得很难用纯函数式或面向对象的方法实现程序。
例如,任何绘图函数,如 cv2.rectangle 和 cv2.circle,都会修改传入它的参数。这种方法违反了函数式编程的一条基本规则,即避免副作用和状态改变。
出于好奇,你可以在 Python 中以更符合函数式编程的方式重新声明这些绘图函数的 API。例如,你可以这样重写 cv2.rectangle:
def drawRect(frame, topLeft, bottomRight, color, thickness, fill = cv2.LINE_AA):
newframe = frame.copy()
cv2.rectangle(newframe, topLeft, bottomRight, color, thickness, fill)
return newframe
这种方法——由于 copy() 操作而计算成本更高——允许显式地重新分配一个帧,如下所示:
frame = camera.read()
frame = drawRect(frame, (0,0), (10,10), (0, 255,0), 1)
为了结束这次离题,我将重申在所有编程论坛和资源中经常提到的一个信念:没有所谓的最佳语言或范式,只有最适合当前任务的工具。
因此,让我们回到我们的程序,并探索监控应用程序的实现,该程序在视频中跟踪移动对象。
Pedestrian 类
创建 Pedestrian 类的主要理由是卡尔曼滤波器的性质。卡尔曼滤波器可以根据历史观察预测对象的位置,并根据实际数据纠正预测,但它只能对单个对象这样做。
因此,我们需要为每个跟踪的对象使用一个卡尔曼滤波器。
因此,Pedestrian 类将作为卡尔曼滤波器、颜色直方图(在对象的首次检测时计算,并用作后续帧的参考)以及感兴趣区域信息的持有者,这些信息将由 CAMShift 算法(track_window 参数)使用。
此外,我们存储每个行人的 ID 以获取一些花哨的实时信息。
让我们看看Pedestrian类:
class Pedestrian():
"""Pedestrian class
each pedestrian is composed of a ROI, an ID and a Kalman filter
so we create a Pedestrian class to hold the object state
"""
def __init__(self, id, frame, track_window):
"""init the pedestrian object with track window coordinates"""
# set up the roi
self.id = int(id)
x,y,w,h = track_window
self.track_window = track_window
self.roi = cv2.cvtColor(frame[y:y+h, x:x+w], cv2.COLOR_BGR2HSV)
roi_hist = cv2.calcHist([self.roi], [0], None, [16], [0, 180])
self.roi_hist = cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)
# set up the kalman
self.kalman = cv2.KalmanFilter(4,2)
self.kalman.measurementMatrix = np.array([[1,0,0,0],[0,1,0,0]],np.float32)
self.kalman.transitionMatrix = np.array([[1,0,1,0],[0,1,0,1],[0,0,1,0],[0,0,0,1]],np.float32)
self.kalman.processNoiseCov = np.array([[1,0,0,0],[0,1,0,0],[0,0,1,0],[0,0,0,1]],np.float32) * 0.03
self.measurement = np.array((2,1), np.float32)
self.prediction = np.zeros((2,1), np.float32)
self.term_crit = ( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 )
self.center = None
self.update(frame)
def __del__(self):
print "Pedestrian %d destroyed" % self.id
def update(self, frame):
# print "updating %d " % self.id
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
back_project = cv2.calcBackProject([hsv],[0], self.roi_hist,[0,180],1)
if args.get("algorithm") == "c":
ret, self.track_window = cv2.CamShift(back_project, self.track_window, self.term_crit)
pts = cv2.boxPoints(ret)
pts = np.int0(pts)
self.center = center(pts)
cv2.polylines(frame,[pts],True, 255,1)
if not args.get("algorithm") or args.get("algorithm") == "m":
ret, self.track_window = cv2.meanShift(back_project, self.track_window, self.term_crit)
x,y,w,h = self.track_window
self.center = center([[x,y],[x+w, y],[x,y+h],[x+w, y+h]])
cv2.rectangle(frame, (x,y), (x+w, y+h), (255, 255, 0), 1)
self.kalman.correct(self.center)
prediction = self.kalman.predict()
cv2.circle(frame, (int(prediction[0]), int(prediction[1])), 4, (0, 255, 0), -1)
# fake shadow
cv2.putText(frame, "ID: %d -> %s" % (self.id, self.center), (11, (self.id + 1) * 25 + 1),
font, 0.6,
(0, 0, 0),
1,
cv2.LINE_AA)
# actual info
cv2.putText(frame, "ID: %d -> %s" % (self.id, self.center), (10, (self.id + 1) * 25),
font, 0.6,
(0, 255, 0),
1,
cv2.LINE_AA)
程序的核心是背景减除对象,它使我们能够识别与移动对象相对应的兴趣区域。
当程序启动时,我们取这些区域中的每一个并实例化一个Pedestrian类,传递 ID(一个简单的计数器)和帧以及跟踪窗口坐标(这样我们可以提取感兴趣区域(ROI),以及从该 ROI 的 HSV 直方图)。
构造函数(Python 中的__init__)基本上是所有先前概念的聚合:给定一个 ROI,我们计算其直方图,设置卡尔曼滤波器,并将其关联到对象的属性(self.kalman)。
在update方法中,我们传递当前帧并将其转换为 HSV,这样我们就可以计算行人的 HSV 直方图的反向投影。
然后,我们使用 CAMShift 或 Meanshift(根据传递的参数;如果没有传递参数,则默认为 Meanshift)来跟踪行人的移动,并使用实际位置来校正该行人的卡尔曼滤波器。
我们还绘制了 CAMShift/Meanshift(带有周围矩形)和卡尔曼(带有点),这样你可以观察到卡尔曼和 CAMShift/Meanshift 几乎手牵手,除了突然的移动会导致卡尔曼需要重新调整。
最后,我们在图像的左上角打印一些行人信息。
主程序
现在我们有一个包含每个对象所有特定信息的Pedestrian类,让我们来看看程序中的主要函数。
首先,我们加载一个视频(可以是网络摄像头),然后初始化一个背景减除器,将 20 帧设置为影响背景模型:
history = 20
bs = cv2.createBackgroundSubtractorKNN(detectShadows = True)
bs.setHistory(history)
我们还创建了主显示窗口,并设置了一个行人字典和一个firstFrame标志,我们将使用这些来允许背景减除器建立一些历史记录,以便更好地识别移动对象。为此,我们还设置了一个帧计数器:
cv2.namedWindow("surveillance")
pedestrians = {}
firstFrame = True
frames = 0
现在我们开始循环。我们逐个读取相机帧(或视频帧):
while True:
print " -------------------- FRAME %d --------------------" % frames
grabbed, frane = camera.read()
if (grabbed is False):
print "failed to grab frame."
break
ret, frame = camera.read()
我们让BackgroundSubtractorKNN为背景模型建立历史记录,所以我们实际上不处理前 20 帧;我们只将它们传递给减除器:
fgmask = bs.apply(frame)
# this is just to let the background subtractor build a bit of history
if frames < history:
frames += 1
continue
然后,我们使用本章前面解释的方法处理帧,通过在前景掩码上应用膨胀和腐蚀过程,以便获得易于识别的 blob 及其边界框。这些在帧中显然是移动对象:
th = cv2.threshold(fgmask.copy(), 127, 255, cv2.THRESH_BINARY)[1]
th = cv2.erode(th, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3,3)), iterations = 2)
dilated = cv2.dilate(th, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (8,3)), iterations = 2)
image, contours, hier = cv2.findContours(dilated, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
一旦识别出轮廓,我们只为第一帧实例化每个轮廓一个行人(注意,我设置了轮廓的最小面积以进一步去噪我们的检测):
counter = 0
for c in contours:
if cv2.contourArea(c) > 500:
(x,y,w,h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x,y), (x+w, y+h), (0, 255, 0), 1)
# only create pedestrians in the first frame, then just follow the ones you have
if firstFrame is True:
pedestrians[counter] = Pedestrian(counter, frame, (x,y,w,h))
counter += 1
然后,对于每个检测到的行人,我们执行一个 update 方法,传递当前帧,这是在原始颜色空间中需要的,因为行人对象负责绘制它们自己的信息(文本和 Meanshift/CAMShift 矩形,以及卡尔曼滤波跟踪):
for i, p in pedestrians.iteritems():
p.update(frame)
我们将 firstFrame 标志设置为 False,这样我们就不创建更多的行人;我们只是跟踪我们已有的:
firstFrame = False
frames += 1
最后,我们在显示窗口中展示结果。可以通过按下 Esc 键退出程序:
cv2.imshow("surveillance", frame)
if cv2.waitKey(110) & 0xff == 27:
break
if __name__ == "__main__":
main()
如此,CAMShift/Meanshift 与卡尔曼滤波协同工作以跟踪移动对象。如果一切顺利,你应该得到类似的结果:

在这个屏幕截图中,蓝色矩形是 CAMShift 检测到的区域,绿色矩形是带有蓝色圆圈中心的卡尔曼滤波预测:
我们接下来该做什么?
这个程序构成了满足你应用领域需求的基础。可以在上述程序的基础上进行许多改进,以满足应用程序的额外要求。考虑以下示例:
-
如果卡尔曼预测行人的位置在帧外,你可以销毁行人对象
-
你可以检查每个检测到的移动对象是否对应于现有的行人实例,如果不是,为其创建一个实例
-
你可以训练一个 SVM 并对每个移动对象进行分类,以确定移动对象是否是你想要跟踪的性质(例如,一只狗可能进入场景,但你的应用程序只需要跟踪人类)
无论你的需求如何,希望这一章能为你提供构建满足你要求的应用程序所需的知识。
摘要
本章探讨了视频分析和跟踪对象的广泛而复杂的话题。
我们学习了使用基本运动检测技术进行视频背景减法,该技术通过计算帧差来计算,然后转向更复杂和高效的工具,如 BackgroundSubtractor。
我们探讨了两个非常重要的视频分析算法:Meanshift 和 CAMShift。在这个过程中,我们详细讨论了颜色直方图和反向投影。我们还熟悉了卡尔曼滤波,及其在计算机视觉环境中的有用性。最后,我们将所有知识汇总到一个示例监控应用程序中,该应用程序跟踪视频中的移动对象。
现在我们对 OpenCV 和机器学习的理解已经巩固,我们准备在下一章中处理人工神经网络,并更深入地使用 OpenCV 和 Python 探索人工智能。
第九章。使用 OpenCV 的神经网络——简介
机器学习是人工智能的一个分支,它专门处理那些使机器能够识别数据中的模式和趋势,并成功进行预测和分类的算法。
OpenCV 用于完成计算机视觉中一些更高级任务的许多算法和技术与人工智能和机器学习直接相关。
本章将向您介绍 OpenCV 中的机器学习概念,如人工神经网络。这是一个温和的介绍,几乎触及了机器学习这个广阔世界的表面,而机器学习是一个不断发展的领域。
人工神经网络
让我们从定义人工神经网络(ANN)的几个逻辑步骤开始,而不是使用经典的单块句子,这种句子使用晦涩的术语,其含义更加晦涩。
首先,人工神经网络(ANN)是一个统计模型。什么是统计模型?统计模型是一对元素,即空间 S(一组观察值)和概率 P,其中 P 是近似 S 的分布(换句话说,一个会生成与 S 非常相似的观察值集合的函数)。
我喜欢从两个方面来考虑 P:作为复杂场景的简化,以及最初生成 S 的函数,或者至少是一组与 S 非常相似的观察值。
所以,人工神经网络(ANNs)是接受复杂现实,简化它,并推导出一个函数来(近似)表示从该现实预期得到的统计观察结果的模型,以数学形式呈现。
我们理解人工神经网络(ANN)的下一步是了解 ANN 如何改进简单统计模型的概念。
如果生成数据集的函数可能需要大量(未知)的输入呢?
人工神经网络采取的方法是将工作委托给多个神经元、节点或单元,每个单元都能够“近似”创建输入的函数。数学上,近似是定义一个更简单的函数来近似一个更复杂的函数,这使我们能够定义误差(相对于应用领域)。此外,为了提高准确性,一个网络通常被认为是一个神经网络,如果神经元或单元能够近似非线性函数。
让我们更仔细地看看神经元。
神经元和感知器
感知器是一个可以追溯到 20 世纪 50 年代的概念,简单来说,感知器是一个接受多个输入并产生单个值的函数。
每个输入都有一个与之相关的权重,表示输入在函数中的重要性。Sigmoid 函数产生一个单一值:

Sigmoid 函数是一个术语,表示该函数产生 0 或 1 的值。判别值是一个阈值;如果输入的加权和大于某个特定的阈值,感知器产生二进制分类 1,否则为 0。
这些权重是如何确定的,它们代表了什么?
神经元相互连接,每个神经元的权重集合(这些只是数值参数)定义了与其他神经元连接的强度。这些权重是“自适应的”,意味着它们会根据学习算法随时间变化。
ANN 的结构
下面是神经网络的一个视觉表示:

如图中所示,神经网络中有三个不同的层:输入层、隐藏层(或中间层)和输出层。
可以有多个隐藏层;然而,一个隐藏层就足以解决大多数现实生活中的问题。
通过例子看网络层
我们如何确定网络的拓扑结构,以及每个层应该创建多少个神经元?让我们一层一层地做出这个决定。
输入层
输入层定义了网络中的输入数量。例如,假设你想创建一个 ANN,它将帮助你根据动物属性的描述确定你所看到的动物。让我们将这些属性固定为重量、长度和牙齿。这是一组三个属性;我们的网络需要包含三个输入节点。
输出层
输出层的数量等于我们识别的类别数。继续使用前面提到的动物分类网络的例子,我们将任意设置输出层为4,因为我们知道我们将处理以下动物:狗、秃鹫、海豚和龙。如果我们输入不属于这些类别的动物的数据,网络将返回最可能类似于这个未分类动物的分类。
隐藏层
隐藏层包含感知器。如前所述,绝大多数问题只需要一个隐藏层;从数学上讲,没有经过验证的理由说需要超过两个隐藏层。因此,我们将坚持使用一个隐藏层并与之工作。
有许多经验法则可以用来确定隐藏层中包含的神经元数量,但没有一条是固定不变的。在这种情况下,经验方法是你的朋友:用不同的设置测试你的网络,并选择最适合的那个。
这些是在构建 ANN 时最常用的规则之一:
-
隐藏神经元的数量应该在输入层和输出层的大小之间。如果输入层和输出层的大小差异很大,根据我的经验,隐藏层的大小更接近输出层是更可取的。
-
对于相对较小的输入层,隐藏神经元的数量是输入层大小的三分之二,加上输出层的大小,或者小于输入层大小的两倍。
需要牢记的一个重要因素是过拟合。当隐藏层中包含的信息量与训练数据提供的信息量不成比例(例如,层中神经元的数量过多)时,就会发生过拟合,这种分类并不很有意义。
隐藏层越大,网络训练所需的训练信息就越多。不用说,这将延长网络正确训练所需的时间。
因此,根据前面提到的第二个经验法则,我们的网络将有一个大小为 8 的隐藏层,因为经过几次网络的运行,我发现它能够产生最佳结果。顺便提一下,在 ANNs 的世界中,经验方法非常受鼓励。最佳的网络拓扑结构取决于网络接收到的数据类型,因此不要犹豫,尝试以试错的方式测试 ANNs。
总结一下,我们的网络具有以下大小:
-
输入层:
3 -
隐藏层:
8 -
输出:
4
学习算法
ANNs 使用了多种学习算法,但我们可以识别出三种主要的学习算法:
-
监督学习: 使用此算法,我们希望从 ANN 中获得一个函数,该函数描述了我们标记的数据。我们知道,事先我们知道数据的性质,并将找到描述数据的函数的过程委托给 ANN。
-
无监督学习: 此算法与监督学习不同;在这种情况下,数据是无标签的。这意味着我们不必选择和标记数据,但也意味着 ANN 有更多的工作要做。数据的分类通常通过(但不限于)聚类等技术获得,我们在第七章中探讨了这些技术,即检测和识别对象。
-
强化学习: 强化学习稍微复杂一些。系统接收一个输入;决策机制确定一个动作,执行并评分(成功/失败以及介于两者之间的等级);最后,输入和动作与它们的评分配对,因此系统学会重复或改变执行特定输入或状态的行动。
现在我们对 ANNs 有一个大致的了解,让我们看看 OpenCV 如何实现它们,以及如何有效地使用它们。最后,我们将逐步过渡到一个完整的应用程序,我们将尝试识别手写数字。
OpenCV 中的 ANNs
毫不奇怪,ANNs 位于 OpenCV 的ml模块中。
让我们通过一个示例来考察 ANNs,作为一个温和的介绍:
import cv2
import numpy as np
ann = cv2.ml.ANN_MLP_create()
ann.setLayerSizes(np.array([9, 5, 9], dtype=np.uint8))
ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP)
ann.train(np.array([[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], dtype=np.float32),
cv2.ml.ROW_SAMPLE,
np.array([[0, 0, 0, 0, 0, 1, 0, 0, 0]], dtype=np.float32))
print ann.predict(np.array([[1.4, 1.5, 1.2, 2., 2.5, 2.8, 3., 3.1, 3.8]], dtype=np.float32))
首先,我们创建一个 ANN:
ann = cv2.ml.ANN_MLP_create()
你可能会对函数名中的MLP这个缩写感到好奇;它代表多层感知器。到现在为止,你应该知道感知器是什么了。
在创建网络之后,我们需要设置其拓扑:
ann.setLayerSizes(np.array([9, 5, 9], dtype=np.uint8))
ann.setTrainMethod(cv2.ml.ANN_MLP_BACKPROP)
层的大小由传递给setLayerSizes方法的 NumPy 数组定义。第一个元素设置输入层的大小,最后一个元素设置输出层的大小,所有中间元素定义隐藏层的大小。
然后我们将训练方法设置为反向传播。这里有两个选择:BACKPROP和RPROP。
BACKPROP和RPROP都是反向传播算法——简单来说,这些算法基于分类中的误差对权重产生影响。
这两个算法在监督学习环境中工作,这正是我们在示例中使用的内容。我们如何知道这个特定细节?看看下一个语句:
ann.train(np.array([[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], dtype=np.float32),
cv2.ml.ROW_SAMPLE,
np.array([[0, 0, 0, 0, 0, 1, 0, 0, 0]], dtype=np.float32))
你应该注意到许多细节。这个方法看起来与支持向量机的train()方法极其相似。该方法包含三个参数:samples、layout和responses。只有samples是必需参数;其他两个是可选的。
这揭示了以下信息:
-
首先,ANN,像 SVM 一样,是一个 OpenCV
StatModel(统计模型);train和predict是从基类StatModel继承的方法。 -
第二,仅使用
samples训练的统计模型采用无监督学习算法。如果我们提供layout和responses,我们处于监督学习环境中。
由于我们使用的是人工神经网络(ANNs),我们可以指定将要使用的反向传播算法类型(BACKPROP或RPROP),因为——正如我们所说的——我们处于一个监督学习环境中。
那么什么是反向传播?反向传播是一个两阶段算法,它计算预测和更新的误差,并更新网络两个方向(输入层和输出层)的权重;然后相应地更新神经元权重。
让我们训练 ANN;由于我们指定了大小为 9 的输入层,我们需要提供 9 个输入,以及 9 个输出以反映输出层的大小:
ann.train(np.array([[1.2, 1.3, 1.9, 2.2, 2.3, 2.9, 3.0, 3.2, 3.3]], dtype=np.float32),
cv2.ml.ROW_SAMPLE,
np.array([[0, 0, 0, 0, 0, 1, 0, 0, 0]], dtype=np.float32))
响应的结构只是一个全零的数组,在表示我们想要将输入与之关联的类别的位置上有一个1值。在我们的前一个例子中,我们指出指定的输入数组对应于类别 5(类别从 0 开始索引)的 0 到 8 类别。
最后,我们进行分类:
print ann.predict(np.array([[1.4, 1.5, 1.2, 2., 2.5, 2.8, 3., 3.1, 3.8]], dtype=np.float32))
这将产生以下结果:
(5.0, array([[-0.06419383, -0.13360272, -0.1681568 , -0.18708915, 0.0970564 ,
0.89237726, 0.05093023, 0.17537238, 0.13388439]], dtype=float32))
这意味着提供的输入被分类为属于类别 5。这只是一个示例,分类几乎没有意义;然而,网络的行为是正确的。在这段代码中,我们只为类别 5 提供了一个训练记录,因此网络将新的输入分类为属于类别 5。
如你所猜,预测的输出是一个元组,第一个值是类别,第二个值是一个包含每个类别概率的数组。预测的类别将具有最高值。让我们继续到一个更有用的例子:动物分类。
ANN-imal 分类
从我们上次停止的地方继续,让我们展示一个 ANN 的非常简单的例子,该 ANN 试图根据动物的统计数据(重量、长度和牙齿)进行分类。我的意图是描述一个模拟的真实场景,以便在我们开始将其应用于计算机视觉和,特别是 OpenCV 之前,提高我们对 ANN 的理解:
import cv2
import numpy as np
from random import randint
animals_net = cv2.ml.ANN_MLP_create()
animals_net.setTrainMethod(cv2.ml.ANN_MLP_RPROP | cv2.ml.ANN_MLP_UPDATE_WEIGHTS)
animals_net.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
animals_net.setLayerSizes(np.array([3, 8, 4]))
animals_net.setTermCriteria(( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 ))
"""Input arrays
weight, length, teeth
"""
"""Output arrays
dog, eagle, dolphin and dragon
"""
def dog_sample():
return [randint(5, 20), 1, randint(38, 42)]
def dog_class():
return [1, 0, 0, 0]
def condor_sample():
return [randint(3,13), 3, 0]
def condor_class():
return [0, 1, 0, 0]
def dolphin_sample():
return [randint(30, 190), randint(5, 15), randint(80, 100)]
def dolphin_class():
return [0, 0, 1, 0]
def dragon_sample():
return [randint(1200, 1800), randint(15, 40), randint(110, 180)]
def dragon_class():
return [0, 0, 0, 1]
SAMPLES = 5000
for x in range(0, SAMPLES):
print "Samples %d/%d" % (x, SAMPLES)
animals_net.train(np.array([dog_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dog_class()], dtype=np.float32))
animals_net.train(np.array([condor_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([condor_class()], dtype=np.float32))
animals_net.train(np.array([dolphin_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dolphin_class()], dtype=np.float32))
animals_net.train(np.array([dragon_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dragon_class()], dtype=np.float32))
print animals_net.predict(np.array([dog_sample()], dtype=np.float32))
print animals_net.predict(np.array([condor_sample()], dtype=np.float32))
print animals_net.predict(np.array([dragon_sample()], dtype=np.float32))
与这个例子和虚拟例子之间有很多不同之处,所以让我们按顺序检查它们。
首先,导入一些常用的库。然后,我们导入randint,因为我们想生成一些相对随机的数据:
import cv2
import numpy as np
from random import randint
然后,我们创建 ANN。这次,我们指定train方法为鲁棒的逆向传播(逆向传播的改进版本)和激活函数为 Sigmoid 函数:
animals_net = cv2.ml.ANN_MLP_create()
animals_net.setTrainMethod(cv2.ml.ANN_MLP_RPROP | cv2.ml.ANN_MLP_UPDATE_WEIGHTS)
animals_net.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
animals_net.setLayerSizes(np.array([3, 8, 4]))
此外,我们指定终止条件与上一章中我们在 CAMShift 算法中所做的方式类似:
animals_net.setTermCriteria(( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 1 ))
现在我们需要一些数据。我们并不真的那么关心准确表示动物,而是需要大量记录作为训练数据。因此,我们基本上定义了四个样本创建函数和四个分类函数,这些函数将帮助我们训练网络:
"""Input arrays
weight, length, teeth
"""
"""Output arrays
dog, eagle, dolphin and dragon
"""
def dog_sample():
return [randint(5, 20), 1, randint(38, 42)]
def dog_class():
return [1, 0, 0, 0]
def condor_sample():
return [randint(3,13), 3, 0]
def condor_class():
return [0, 1, 0, 0]
def dolphin_sample():
return [randint(30, 190), randint(5, 15), randint(80, 100)]
def dolphin_class():
return [0, 0, 1, 0]
def dragon_sample():
return [randint(1200, 1800), randint(15, 40), randint(110, 180)]
def dragon_class():
return [0, 0, 0, 1]
让我们继续创建我们的假动物数据;我们将为每个类别创建 5,000 个样本:
SAMPLES = 5000
for x in range(0, SAMPLES):
print "Samples %d/%d" % (x, SAMPLES)
animals_net.train(np.array([dog_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dog_class()], dtype=np.float32))
animals_net.train(np.array([condor_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([condor_class()], dtype=np.float32))
animals_net.train(np.array([dolphin_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dolphin_class()], dtype=np.float32))
animals_net.train(np.array([dragon_sample()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([dragon_class()], dtype=np.float32))
最后,我们打印出以下代码的结果:
(1.0, array([[ 1.49817729, 1.60551953, -1.56444871, -0.04313202]], dtype=float32))
(1.0, array([[ 1.49817729, 1.60551953, -1.56444871, -0.04313202]], dtype=float32))
(3.0, array([[-1.54576635, -1.68725526, 1.6469276 , 2.23223686]], dtype=float32))
从这些结果中,我们得出以下结论:
-
网络正确分类了三个样本中的两个,这并不完美,但作为一个很好的例子来说明构建和训练 ANN 所涉及的所有元素的重要性。输入层的大小对于在各个类别之间创建多样性非常重要。在我们的例子中,我们只有三个统计数据,并且特征之间存在相对重叠。
-
隐藏层的大小需要测试。你会发现增加神经元可能会在一定点提高准确性,然后它会过拟合,除非你开始用大量的数据来补偿:训练记录的数量。肯定要避免记录太少或提供大量相同的记录,因为 ANN 不会从它们中学到很多东西。
训练轮次
在训练 ANN 时,另一个重要的概念是轮次的概念。训练轮次是对训练数据的迭代,之后数据将用于分类测试。大多数 ANN 会在多个轮次中进行训练;你会发现一些最常用的 ANN 例子,如手写数字分类,其训练数据会迭代数百次。
我个人建议你花很多时间玩 ANN 和时代,直到你达到收敛,这意味着进一步的迭代将不再提高(至少不是明显地)结果的准确性。
以下示例可以通过以下方式修改以利用时代:
def record(sample, classification):
return (np.array([sample], dtype=np.float32), np.array([classification], dtype=np.float32))
records = []
RECORDS = 5000
for x in range(0, RECORDS):
records.append(record(dog_sample(), dog_class()))
records.append(record(condor_sample(), condor_class()))
records.append(record(dolphin_sample(), dolphin_class()))
records.append(record(dragon_sample(), dragon_class()))
EPOCHS = 5
for e in range(0, EPOCHS):
print "Epoch %d:" % e
for t, c in records:
animals_net.train(t, cv2.ml.ROW_SAMPLE, c)
然后,从狗类别开始做一些测试:
dog_results = 0
for x in range(0, 100):
clas = int(animals_net.predict(np.array([dog_sample()], dtype=np.float32))[0])
print "class: %d" % clas
if (clas) == 0:
dog_results += 1
对所有类别重复操作并输出结果:
print "Dog accuracy: %f" % (dog_results)
print "condor accuracy: %f" % (condor_results)
print "dolphin accuracy: %f" % (dolphin_results)
print "dragon accuracy: %f" % (dragon_results)
最后,我们得到以下结果:
Dog accuracy: 100.000000%
condor accuracy: 0.000000%
dolphin accuracy: 0.000000%
dragon accuracy: 92.000000%
考虑到我们只是在玩玩具/假数据,以及训练数据/训练迭代的规模;这教会了我们很多。我们可以诊断 ANN 对某些类别的过度拟合,因此提高你输入训练过程的数据质量非常重要。
话虽如此,现在是时候举一个现实生活中的例子了:手写数字识别。
使用 ANN 进行手写数字识别
机器学习的世界广阔而大部分未被探索,ANN 只是与机器学习相关联的许多概念之一,而机器学习是人工智能的许多子学科之一。为了本章的目的,我们只将在 OpenCV 的背景下探索 ANN 的概念。这绝对不是关于人工智能主题的详尽论述。
最终,我们感兴趣的是看到 ANN 在现实世界中的工作情况。所以,让我们继续前进,让它发生。
MNIST – 手写数字数据库
在 Web 上,用于训练处理 OCR 和手写字符识别的分类器的最流行资源之一是 MNIST 数据库,可在yann.lecun.com/exdb/mnist/公开获取。
这个特定的数据库是一个免费资源,可以启动创建一个利用 ANN 识别手写数字的程序。
定制化训练数据
总是可能构建自己的训练数据。这需要一点耐心,但相当容易;收集大量手写数字,并创建包含单个数字的图像,确保所有图像大小相同且为灰度图。
之后,你需要创建一个机制,以确保训练样本与预期的分类保持同步。
初始参数
让我们看看网络中的各个单独层:
-
输入层
-
隐藏层
-
输出层
输入层
由于我们将利用 MNIST 数据库,输入层将有 784 个输入节点:这是因为 MNIST 样本是 28x28 像素的图像,这意味着 784 个像素。
隐藏层
正如我们所见,隐藏层的大小并没有一个固定的规则,我发现——通过多次尝试——50 到 60 个节点可以得到最佳结果,同时不需要大量的训练数据。
你可以根据数据量增加隐藏层的大小,但超过某个点后,这样做将没有优势;你还得准备好你的网络可能需要数小时才能训练(隐藏神经元越多,训练网络所需的时间越长)。
输出层
输出层将具有 10 个节点的大小。这并不令人惊讶,因为我们想要对 10 个数字(0-9)进行分类。
训练 epoch
我们最初将使用 MNIST 数据集的整个train数据集,该数据集包含超过 60,000 个手写图像,其中一半是由美国政府雇员书写的,另一半是由高中生书写的。数据量很大,所以我们不需要超过一个 epoch 就能达到可接受的检测精度。
从那里开始,如何迭代地在相同的train数据上训练网络取决于你,我的建议是使用准确度测试,并找到准确度“峰值”的 epoch。通过这样做,你将能够精确测量你的网络在其当前配置下所能达到的最高可能精度。
其他参数
我们将使用 sigmoid 激活函数,弹性反向传播(RPROP),并将每个计算的终止标准扩展到 20 次迭代,而不是像本书中涉及cv2.TermCriteria的每个其他操作那样使用 10 次。
注意
关于训练数据和 ANN 库的重要注意事项
在互联网上寻找资源,我发现了一篇由 Michael Nielsen 撰写的令人惊叹的文章,neuralnetworksanddeeplearning.com/chap1.html,它说明了如何从头开始编写 ANN 库,并且这个库的代码在 GitHub 上免费提供,github.com/mnielsen/neural-networks-and-deep-learning;这是 Michael Nielsen 所著的《神经网络与深度学习》一书的源代码。
在data文件夹中,你会找到一个pickle文件,表示通过流行的 Python 库cPickle保存到磁盘上的数据,这使得加载和保存 Python 数据变得非常简单。
这个 pickle 文件是 MNIST 数据的cPickle库序列化版本,由于它非常有用且易于使用,我强烈建议你使用它。阻止你加载 MNIST 数据集的并不是反序列化训练数据的过程,这个过程相当繁琐,严格来说,超出了本书的范围。
其次,我想指出,OpenCV 并不是唯一允许你使用 ANN 的 Python 库,无论如何想象。网上充满了替代方案,我强烈建议你尝试,最著名的是PyBrain,一个名为Lasagna的库(作为一个意大利人,我发现它非常吸引人)以及许多自定义编写的实现,例如前面提到的 Michael Nielsen 的实现。
虽然有足够的介绍性细节,但让我们开始吧。
小型图书馆
在 OpenCV 中设置 ANN 并不困难,但你几乎肯定会发现自己无数次地训练网络,以寻找那个难以捉摸的百分比点,以提升你结果的准确性。
为了尽可能自动化这个过程,我们将构建一个迷你库,它封装了 OpenCV 的 ANN 原生实现,并允许我们轻松地重新运行和重新训练网络。
这里是一个包装库的示例:
import cv2
import cPickle
import numpy as np
import gzip
def load_data():
mnist = gzip.open('./data/mnist.pkl.gz', 'rb')
training_data, classification_data, test_data = cPickle.load(mnist)
mnist.close()
return (training_data, classification_data, test_data)
def wrap_data():
tr_d, va_d, te_d = load_data()
training_inputs = [np.reshape(x, (784, 1)) for x in tr_d[0]]
training_results = [vectorized_result(y) for y in tr_d[1]]
training_data = zip(training_inputs, training_results)
validation_inputs = [np.reshape(x, (784, 1)) for x in va_d[0]]
validation_data = zip(validation_inputs, va_d[1])
test_inputs = [np.reshape(x, (784, 1)) for x in te_d[0]]
test_data = zip(test_inputs, te_d[1])
return (training_data, validation_data, test_data)
def vectorized_result(j):
e = np.zeros((10, 1))
e[j] = 1.0
return e
def create_ANN(hidden = 20):
ann = cv2.ml.ANN_MLP_create()
ann.setLayerSizes(np.array([784, hidden, 10]))
ann.setTrainMethod(cv2.ml.ANN_MLP_RPROP)
ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
ann.setTermCriteria(( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 20, 1 ))
return ann
def train(ann, samples = 10000, epochs = 1):
tr, val, test = wrap_data()
for x in xrange(epochs):
counter = 0
for img in tr:
if (counter > samples):
break
if (counter % 1000 == 0):
print "Epoch %d: Trained %d/%d" % (x, counter, samples)
counter += 1
data, digit = img
ann.train(np.array([data.ravel()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([digit.ravel()], dtype=np.float32))
print "Epoch %d complete" % x
return ann, test
def test(ann, test_data):
sample = np.array(test_data[0][0].ravel(), dtype=np.float32).reshape(28, 28)
cv2.imshow("sample", sample)
cv2.waitKey()
print ann.predict(np.array([test_data[0][0].ravel()], dtype=np.float32))
def predict(ann, sample):
resized = sample.copy()
rows, cols = resized.shape
if (rows != 28 or cols != 28) and rows * cols > 0:
resized = cv2.resize(resized, (28, 28), interpolation = cv2.INTER_CUBIC)
return ann.predict(np.array([resized.ravel()], dtype=np.float32))
让我们按顺序检查它。首先,load_data、wrap_data和vectorized_result函数包含在 Michael Nielsen 的加载pickle文件的代码中。
这是一个相对简单的pickle文件加载过程。不过,值得注意的是,加载的数据已经被分割成train和test数据。train和test数据都是包含两个元素的元组数组:第一个是数据本身;第二个是预期的分类。因此,我们可以使用train数据来训练 ANN,使用test数据来评估其准确性。
vectorized_result函数是一个非常巧妙的函数,它给定一个预期的分类,创建一个包含 10 个零的数组,并将预期的结果设置为单个 1。你可能已经猜到了,这个 10 个元素的数组将被用作输出层的分类。
第一个与 ANN 相关的函数是create_ANN:
def create_ANN(hidden = 20):
ann = cv2.ml.ANN_MLP_create()
ann.setLayerSizes(np.array([784, hidden, 10]))
ann.setTrainMethod(cv2.ml.ANN_MLP_RPROP)
ann.setActivationFunction(cv2.ml.ANN_MLP_SIGMOID_SYM)
ann.setTermCriteria(( cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 20, 1 ))
return ann
这个函数通过指定如初始参数部分所示的大小,创建了一个专门针对 MNIST 手写数字识别的 ANN。
我们现在需要一个训练函数:
def train(ann, samples = 10000, epochs = 1):
tr, val, test = wrap_data()
for x in xrange(epochs):
counter = 0
for img in tr:
if (counter > samples):
break
if (counter % 1000 == 0):
print "Epoch %d: Trained %d/%d" % (x, counter, samples)
counter += 1
data, digit = img
ann.train(np.array([data.ravel()], dtype=np.float32), cv2.ml.ROW_SAMPLE, np.array([digit.ravel()], dtype=np.float32))
print "Epoch %d complete" % x
return ann, test
再次强调,这很简单:给定样本数量和训练轮数,我们加载数据,然后迭代样本 x 次轮数。
这个函数的重要部分是将单个训练记录分解成train数据和预期的分类,然后将其传递给 ANN。
为了做到这一点,我们利用numpy数组的ravel()函数,它可以将任何形状的数组“展平”成一个单行数组。例如,考虑以下数组:
data = [[ 1, 2, 3], [4, 5, 6], [7, 8, 9]]
前面的数组一旦“展平”,就变成了以下数组:
[1, 2, 3, 4, 5, 6, 7, 8, 9]
这是 OpenCV 的 ANN 在train()方法中期望的数据格式。
最后,我们返回了network和test数据。我们本来可以直接返回数据,但手头有test数据用于准确性检查是非常有用的。
我们需要的最后一个函数是predict()函数,用于封装 ANN 的predict()方法:
def predict(ann, sample):
resized = sample.copy()
rows, cols = resized.shape
if (rows != 28 or cols != 28) and rows * cols > 0:
resized = cv2.resize(resized, (28, 28), interpolation = cv2.INTER_CUBIC)
return ann.predict(np.array([resized.ravel()], dtype=np.float32))
这个函数接受一个 ANN 和一个样本图像;它通过确保数据的形状符合预期并在必要时调整大小,然后展平它以进行成功的预测,进行最少的“净化”操作。
我创建的文件还包含一个test函数来验证网络是否工作,并显示用于分类的样本。
主要文件
整个这一章都是一个引导性的旅程,引导我们到达这个点。实际上,我们将要使用的大多数技术都来自前面的章节,所以从某种意义上说,整本书都引导我们到达这个点。因此,让我们充分利用我们的知识。
让我们先看一下文件,然后对其进行分解以便更好地理解:
import cv2
import numpy as np
import digits_ann as ANN
def inside(r1, r2):
x1,y1,w1,h1 = r1
x2,y2,w2,h2 = r2
if (x1 > x2) and (y1 > y2) and (x1+w1 < x2+w2) and (y1+h1 < y2 + h2):
return True
else:
return False
def wrap_digit(rect):
x, y, w, h = rect
padding = 5
hcenter = x + w/2
vcenter = y + h/2
if (h > w):
w = h
x = hcenter - (w/2)
else:
h = w
y = vcenter - (h/2)
return (x-padding, y-padding, w+padding, h+padding)
ann, test_data = ANN.train(ANN.create_ANN(56), 20000)
font = cv2.FONT_HERSHEY_SIMPLEX
path = "./images/numbers.jpg"
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
bw = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
bw = cv2.GaussianBlur(bw, (7,7), 0)
ret, thbw = cv2.threshold(bw, 127, 255, cv2.THRESH_BINARY_INV)
thbw = cv2.erode(thbw, np.ones((2,2), np.uint8), iterations = 2)
image, cntrs, hier = cv2.findContours(thbw.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
rectangles = []
for c in cntrs:
r = x,y,w,h = cv2.boundingRect(c)
a = cv2.contourArea(c)
b = (img.shape[0]-3) * (img.shape[1] - 3)
is_inside = False
for q in rectangles:
if inside(r, q):
is_inside = True
break
if not is_inside:
if not a == b:
rectangles.append(r)
for r in rectangles:
x,y,w,h = wrap_digit(r)
cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
roi = thbw[y:y+h, x:x+w]
try:
digit_class = int(ANN.predict(ann, roi.copy())[0])
except:
continue
cv2.putText(img, "%d" % digit_class, (x, y-1), font, 1, (0, 255, 0))
cv2.imshow("thbw", thbw)
cv2.imshow("contours", img)
cv2.imwrite("sample.jpg", img)
cv2.waitKey()
在进行初始的常规导入之后,我们导入我们创建的小型库,该库存储在digits_ann.py中。
我发现将函数定义在文件顶部是一个好的实践,让我们来检查一下这些函数。inside()函数用于判断一个矩形是否完全包含在另一个矩形内:
def inside(r1, r2):
x1,y1,w1,h1 = r1
x2,y2,w2,h2 = r2
if (x1 > x2) and (y1 > y2) and (x1+w1 < x2+w2) and (y1+h1 < y2 + h2):
return True
else:
return False
wrap_digit()函数接受一个围绕数字的矩形,将其转换为方形,并将其中心对准数字本身,同时添加 5 点填充以确保数字完全包含在内:
def wrap_digit(rect):
x, y, w, h = rect
padding = 5
hcenter = x + w/2
vcenter = y + h/2
if (h > w):
w = h
x = hcenter - (w/2)
else:
h = w
y = vcenter - (h/2)
return (x-padding, y-padding, w+padding, h+padding)
这个函数的目的将在稍后变得清晰;现在我们不要过多地关注它。
现在,让我们创建网络。我们将使用 58 个隐藏节点,并在 20,000 个样本上进行训练:
ann, test_data = ANN.train(ANN.create_ANN(58), 20000)
这对于初步测试来说已经足够好了,可以将训练时间缩短到一分钟或两分钟(取决于你机器的处理能力)。理想的情况是使用完整的训练数据集(50,000 个),并多次迭代,直到达到某种收敛(正如我们之前讨论的,准确性的“峰值”)。你可以通过调用以下函数来完成这项工作:
ann, test_data = ANN.train(ANN.create_ANN(100), 50000, 30)
现在,我们可以准备数据以进行测试。为此,我们将加载一张图片,并进行一些清理:
path = "./images/numbers.jpg"
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
bw = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
bw = cv2.GaussianBlur(bw, (7,7), 0)
现在我们有一个灰度平滑的图像,我们可以应用阈值和一些形态学操作,以确保数字能够从背景中正确突出,并且相对干净,这样就不会因为不规则性而影响预测操作:
ret, thbw = cv2.threshold(bw, 127, 255, cv2.THRESH_BINARY_INV)
thbw = cv2.erode(thbw, np.ones((2,2), np.uint8), iterations = 2)
注意
注意阈值标志,这是用于逆二值阈值的:由于 MNIST 数据库的样本是白色背景上的黑色数字(而不是白色背景上的黑色),我们将图像转换为黑色背景上的白色数字。
在形态学操作之后,我们需要识别并分离图片中的每个数字。为此,我们首先识别图像中的轮廓:
image, cntrs, hier = cv2.findContours(thbw.copy(), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
然后,我们遍历轮廓,并丢弃所有完全包含在其他矩形内的矩形;我们只将那些既不包含在其他矩形内,也不像图像本身那么宽的矩形添加到良好的矩形列表中。在一些测试中,findContours返回整个图像作为一个轮廓本身,这意味着没有其他矩形通过了inside测试:
rectangles = []
for c in cntrs:
r = x,y,w,h = cv2.boundingRect(c)
a = cv2.contourArea(c)
b = (img.shape[0]-3) * (img.shape[1] - 3)
is_inside = False
for q in rectangles:
if inside(r, q):
is_inside = True
break
if not is_inside:
if not a == b:
rectangles.append(r)
现在我们有了良好的矩形列表,我们可以遍历它们,并为每个我们识别的矩形定义一个感兴趣的区域:
for r in rectangles:
x,y,w,h = wrap_digit(r)
这就是我们在程序开头定义的wrap_digit()函数发挥作用的地方:我们需要将一个感兴趣的区域传递给预测函数;如果我们简单地将一个矩形调整成方形,就会破坏我们的测试数据。
你可能会想知道为什么想到数字一。围绕数字一的矩形会非常窄,尤其是如果它没有向任何一侧倾斜太多的话。如果你只是将其调整为大正方形,那么数字一就会变得“丰满”,以至于几乎整个正方形都会变黑,使得预测变得不可能。相反,我们希望围绕识别出的数字创建一个正方形,这正是wrap_digit()所做的事情。
这种方法简单直接;它允许我们在数字周围画一个正方形,并同时将这个正方形作为预测的兴趣区域。一个更纯粹的方法是将原始矩形“中心”到一个numpy数组中,行和列等于原始矩形两个维度中较大的一个。这样做的原因是你会注意到正方形的一部分将包括相邻数字的微小部分,这可能会影响预测。使用由np.zeros()函数创建的正方形,不会意外地引入杂质:
cv2.rectangle(img, (x,y), (x+w, y+h), (0, 255, 0), 2)
roi = thbw[y:y+h, x:x+w]
try:
digit_class = int(ANN.predict(ann, roi.copy())[0])
except:
continue
cv2.putText(img, "%d" % digit_class, (x, y-1), font, 1, (0, 255, 0))
一旦完成对正方形区域的预测,我们就在原始图像上绘制它:
cv2.imshow("thbw", thbw)
cv2.imshow("contours", img)
cv2.imwrite("sample.jpg", img)
cv2.waitKey()
就这样!最终结果将类似于这个:

可能的改进和潜在的应用
我们已经展示了如何构建一个人工神经网络(ANN),给它提供训练数据,并使用它进行分类。根据任务的不同,我们可以从多个方面进行改进,以及我们新获得知识的一些潜在应用。
改进
可以应用到这种方法上的改进有很多,其中一些我们已经讨论过:
-
例如,你可以扩大你的数据集并多次迭代,直到达到性能峰值
-
你还可以尝试几种激活函数(
cv2.ml.ANN_MLP_SIGMOID_SYM并非唯一;还有cv2.ml.ANN_MLP_IDENTITY和cv2.ml.ANN_MLP_GAUSSIAN) -
你可以利用不同的训练标志(
cv2.ml.ANN_MLP_UPDATE_WEIGHTS,cv2.ml.ANN_MLP_NO_INPUT_SCALE,cv2.ml.ANN_MLP_NO_OUTPUT_SCALE),以及训练方法(反向传播或弹性反向传播)
除了这些,请记住软件开发的一条格言:没有单一的最佳技术,只有最适合当前工作的工具。因此,仔细分析应用需求将引导你做出最佳参数选择。例如,并不是每个人都以相同的方式绘制数字。事实上,你甚至会发现一些国家以略不同的方式绘制数字。
MNIST 数据库是在美国编制的,其中数字七被绘制成字符 7. 但你会发现,在欧洲,数字 7 通常在数字的对角线部分中间画一条小横线,这是为了将其与数字 1 区分开来。
注意
要更详细地了解区域手写变体,请查看维基百科上的相关文章,它是一个很好的介绍,可在en.wikipedia.org/wiki/Regional_handwriting_variation找到。
这意味着 MNIST 数据库在应用于欧洲手写时准确性有限;一些数字的分类会比其他数字更准确。因此,你可能需要创建自己的数据集。在几乎所有情况下,利用与当前应用领域相关且属于该领域的train数据都是更可取的。
最后,记住一旦你对网络的准确性满意,你总是可以保存它并在以后重新加载,这样它就可以在不每次都训练神经网络的情况下用于第三方应用。
潜在应用
上述程序只是一个手写识别应用的基石。立即,你可以快速扩展之前的方法到视频,并实时检测手写数字,或者你可以训练你的神经网络来识别整个字母表,以构建一个完整的 OCR 系统。
车牌识别似乎是将所学知识扩展到这一点的明显延伸,并且它应该是一个更容易处理的领域,因为车牌使用的是一致的字符。
此外,为了您自己的教育或商业目的,您可以尝试使用神经网络和普通的 SVMs(带有特征检测器如 SIFT)构建一个分类器,并看看它们的基准如何。
摘要
在本章中,我们仅仅触及了神经网络(ANNs)这个广阔而迷人的世界的表面,重点关注了 OpenCV 对其的实现。我们学习了神经网络的结构,以及如何根据应用需求设计网络拓扑。
最后,我们利用了在前几章中探讨的各种概念来构建一个手写数字识别应用。
勇敢地前行…
我希望您喜欢通过 OpenCV 3 的 Python 绑定进行旅行的过程。尽管涵盖 OpenCV 3 需要一系列书籍,但我们探索了非常有趣和未来派的概念,并鼓励您联系我,让 OpenCV 社区了解您下一个基于计算机视觉的突破性项目是什么!





浙公网安备 33010602011771号