OpenCV3-蓝图-全-
OpenCV3 蓝图(全)
原文:
annas-archive.org/md5/b2288be5348a598b7d3a1975121fa894
译者:飞龙
前言
开源计算机视觉项目,如 OpenCV 3,使各种用户能够利用机器视觉、机器学习和人工智能的力量。通过掌握这些强大的代码和知识库,专业人士和爱好者可以在任何需要的地方创建更智能、更好的应用程序。
这正是本书关注的焦点,通过一系列动手项目和模板引导你,教你如何结合出色的技术来解决你特定的难题。
在我们研究计算机视觉时,让我们从这些话中汲取灵感:
*"我看见智慧胜过愚昧,正如光明胜过黑暗。" | |
---|---|
--传道书,2:13 |
让我们构建能够清晰“看”的应用程序,并创造知识。
本书涵盖的内容
第一章, 充分利用您的相机系统,讨论了如何选择和配置相机系统以看到不可见的光、快速运动和远距离物体。
第二章, 使用自动相机拍摄自然和野生动物,展示了如何构建自然摄影师使用的“相机陷阱”,并处理照片以创建美丽的效果。
第三章, 使用机器学习识别面部表情,探讨了使用各种特征提取技术和机器学习方法构建面部表情识别系统的途径。
第四章, 使用 Android Studio 和 NDK 进行全景图像拼接应用,专注于构建 Android 全景相机应用程序的项目,借助 OpenCV 3 的拼接模块。我们将使用 C++和 Android NDK。
第五章, 工业应用中的通用目标检测,研究了优化你的目标检测模型,使其具有旋转不变性,并应用特定场景的约束,使其更快、更稳健。
第六章, 使用生物特征属性进行高效的人脸识别,是关于基于该人的生物特征(如指纹、虹膜和面部)构建人脸识别和注册系统。
第七章,陀螺仪视频稳定,展示了融合视频和陀螺仪数据的技术,如何稳定使用手机拍摄的短视频,以及如何创建超高速视频。
您需要为此书准备的材料
作为基本设置,整本书基于 OpenCV 3 软件。如果章节没有特定的操作系统要求,那么它将在 Windows、Linux 和 Mac 上运行。作为作者,我们鼓励您从官方 GitHub 仓库的最新 master 分支(github.com/Itseez/opencv/
)获取 OpenCV 安装,而不是使用官方 OpenCV 网站(opencv.org/downloads.html
)上的可下载包,因为最新 master 分支包含与最新稳定版本相比的大量修复。
对于硬件,作者们期望您有一个基本的计算机系统设置,无论是台式机还是笔记本电脑,至少有 4GB 的 RAM 内存可用。其他硬件要求如下。
以下章节在 OpenCV 3 安装的基础上有特定的要求:
第一章,充分利用您的相机系统:
-
软件:OpenNI2 和 FlyCapture 2。
-
硬件:PS3 Eye 相机或任何其他 USB 网络摄像头,华硕 Xtion PRO live 或任何其他 OpenNI 兼容的深度相机,以及一个或多个镜头的 Point Grey Research (PGR)相机。
-
备注:PGR 相机设置(使用 FlyCapture 2)在 Mac 上无法运行。即使你没有所有必需的硬件,你仍然可以从中受益于本章的一些部分。
第二章,使用自动相机拍摄自然和野生动物:
-
软件:Linux 或 Mac 操作系统。
-
硬件:带有电池的便携式笔记本电脑或单板计算机(SBC),结合一台照相机。
第四章,使用 Android Studio 和 NDK 进行全景图像拼接应用:
-
软件:Android 4.4 或更高版本,Android NDK。
-
硬件:任何支持 Android 4.4 或更高版本的移动设备。
第七章,陀螺仪视频稳定:
-
软件:NumPy、SciPy、Python 和 Android 5.0 或更高版本,以及 Android NDK。
-
硬件:一部支持 Android 5.0 或更高版本的智能手机,用于捕获视频和陀螺仪信号。
基本安装指南
作为作者,我们承认在您的系统上安装 OpenCV 3 有时可能相当繁琐。因此,我们添加了一系列基于您系统上最新的 OpenCV 3 master 分支的基本安装指南,用于安装 OpenCV 3 以及为不同章节工作所需的必要模块。有关更多信息,请参阅github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/installation_tutorials
。
请记住,本书还使用了来自 OpenCV "contrib"(贡献)存储库的模块。安装手册将提供如何安装这些模块的说明。然而,我们鼓励您只安装我们需要的模块,因为我们知道它们是稳定的。对于其他模块,情况可能并非如此。
本书面向对象
如果您渴望构建比竞争对手更智能、更快、更复杂、更实用的计算机视觉系统,这本书非常适合您。这是一本高级书籍,旨在为那些已经有一定 OpenCV 开发环境和使用 OpenCV 构建应用程序经验的读者编写。您应该熟悉计算机视觉概念、面向对象编程、图形编程、IDE 和命令行。
习惯用法
在本书中,您将找到许多不同的文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。
文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称如下所示:"您可以通过访问opencv.org
并点击下载链接来找到 OpenCV 软件。"
代码块如下所示:
Mat input = imread("/data/image.png", LOAD_IMAGE_GRAYSCALE);
GaussianBlur(input, input, Size(7,7), 0, 0);
imshow("image", input);
waitKey(0);
当我们希望将您的注意力引向代码块中的特定部分时,相关的行或项目将以粗体显示:
Mat input = imread("/data/image.png", LOAD_IMAGE_GRAYSCALE);
GaussianBlur(input, input, Size(7,7), 0, 0);
imshow("image", input);
waitKey(0);
任何命令行输入或输出如下所示:
新术语和重要词汇以粗体显示。您在屏幕上看到的单词,例如在菜单或对话框中,在文本中如下所示:"点击下一步按钮将您带到下一屏幕。"
注意
警告或重要注意事项将以如下所示的框中出现。
小贴士
小技巧和窍门如下所示。
读者反馈
我们始终欢迎读者的反馈。请告诉我们您对这本书的看法——您喜欢或不喜欢的地方。读者反馈对我们来说很重要,因为它帮助我们开发出您真正能从中获得最大价值的标题。
要向我们发送一般反馈,只需发送电子邮件至<feedback@packtpub.com>
,并在邮件主题中提及本书的标题。
如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请参阅我们的作者指南www.packtpub.com/authors。
客户支持
现在您是 Packt 图书的骄傲拥有者,我们有一些事情可以帮助您从您的购买中获得最大收益。
下载示例代码
您可以从www.packtpub.com
下载示例代码文件,这是您购买的所有 Packt 出版物的账户。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support
并注册,以便将文件直接通过电子邮件发送给您。
代码也由本书的作者在 GitHub 仓库中维护。此代码仓库可在github.com/OpenCVBlueprints/OpenCVBlueprints
找到。
下载本书的颜色图像
我们还为您提供了一个包含本书中使用的截图/图表的颜色图像的 PDF 文件。这些颜色图像将帮助您更好地理解输出的变化。您可以从www.packtpub.com/sites/default/files/downloads/B04028_ColorImages.pdf
下载此文件。
勘误
尽管我们已经尽一切努力确保我们内容的准确性,但错误仍然会发生。如果您在我们的某本书中发现错误——可能是文本或代码中的错误——如果您能向我们报告这一点,我们将不胜感激。通过这样做,您可以节省其他读者的挫败感,并帮助我们改进本书的后续版本。如果您发现任何勘误,请通过访问www.packtpub.com/submit-errata
,选择您的书籍,点击勘误提交表单链接,并输入您的勘误详情来报告它们。一旦您的勘误得到验证,您的提交将被接受,勘误将被上传到我们的网站或添加到该标题的勘误部分下的现有勘误列表中。
要查看之前提交的勘误表,请访问www.packtpub.com/books/content/support
,并在搜索字段中输入书籍名称。所需信息将出现在勘误部分。
由于本书也有一个分配给它的 GitHub 仓库,您也可以通过在以下页面创建问题来报告内容勘误:github.com/OpenCVBlueprints/OpenCVBlueprints/issues
。
盗版
互联网上版权材料的盗版是一个跨所有媒体的持续问题。在 Packt,我们非常重视我们版权和许可证的保护。如果您在互联网上发现我们作品的任何非法副本,请立即提供位置地址或网站名称,以便我们可以追究补救措施。
请通过mailto:copyright@packtpub.com与我们联系,并提供疑似盗版材料的链接。
我们感谢您在保护我们作者以及为我们提供有价值内容的能力方面提供的帮助。
问题
如果您在这本书的任何方面遇到问题,您可以通过 <questions@packtpub.com>
联系我们,我们将尽力解决问题。或者,如之前所述,您可以在 GitHub 仓库中提出一个问题,作者之一将尽快帮助您。
第一章. 充分利用您的相机系统
克劳德·莫奈,法国印象派绘画的创始人之一,教导他的学生只画他们看到的,而不是他们知道的。他甚至走得更远,说:
“我希望我生来就是盲人,然后突然恢复视力,这样我就可以在不知道面前看到的是什么物体的情况下开始绘画。”
莫奈拒绝传统的艺术主题,这些主题往往具有神秘、英雄、军事或革命性质。相反,他依靠自己对中产阶级生活的观察:社会远足;阳光明媚的花园、池塘、河流和海滨;雾蒙蒙的林荫大道和火车站;以及私人损失。他带着深深的悲伤告诉他的朋友,乔治·克雷孟梭(未来的法国总统):
“有一天,我发现自己在看着我最爱的妻子的死人脸,然后系统地根据自动反射来记录颜色!”
莫奈根据他个人的印象画了一切。晚年,他甚至画出了自己视力衰退的症状。当他患有白内障时,他采用了红色调色板,而在白内障手术后,由于他的眼睛对光更敏感,可能接近紫外范围,他采用了明亮的蓝色调色板。
就像莫奈的学生一样,我们作为计算机视觉学者必须面对看到和知道之间的区别,以及输入和处理之间的区别。光、镜头、相机和数字成像管道可以赋予计算机一种视觉感。从产生的图像数据中,机器学习(ML)算法可以提取知识或者至少是一套元感知,如检测、识别和重建(扫描)。如果没有适当的感觉或数据,系统的学习潜力将受到限制,甚至可能是零。因此,在设计任何计算机视觉系统时,我们必须考虑照明条件、镜头、相机和成像管道的基础要求。
为了清晰地看到某个特定对象,我们需要什么?这是我们第一章的核心问题。在这个过程中,我们将解决五个子问题:
-
我们需要什么才能看到快速运动或光的变化?
-
我们需要什么才能看到远处的物体?
-
我们需要什么才能拥有深度感知?
-
我们在黑暗中需要看到什么?
-
在购买镜头和相机时,我们如何获得物有所值?
小贴士
对于许多计算机视觉的实际应用,环境并不是一个光线充足、白色的房间,而且主题也不是距离 0.6 米(2 英尺)的人脸!
硬件的选择对这些问题至关重要。不同的相机和镜头针对不同的成像场景进行了优化。然而,软件也可以决定解决方案的成功或失败。在软件方面,我们将关注 OpenCV 的高效使用。幸运的是,OpenCV 的videoio模块支持许多类型的相机系统,包括以下几种:
-
在 Windows,Mac 和 Linux 中通过以下框架使用网络摄像头,这些框架是大多数操作系统版本的标准部分:
-
Windows: Microsoft Media Foundation (MSMF), DirectShow 或 Video for Windows (VfW)
-
Mac: QuickTime
-
Linux: Video4Linux (V4L), Video4Linux2 (V4L2), 或 libv4l
-
-
iOS 和 Android 设备中的内置摄像头
-
通过 OpenNI 或 OpenNI2 兼容的深度相机,OpenNI 和 OpenNI2 是在 Apache 许可证下开源的
-
通过专有的 Intel Perceptual Computing SDK 使用其他深度相机
-
通过 libgphoto2 使用照相机,libgphoto2 是在 GPL 许可证下开源的。有关 libgphoto2 支持的照相机列表,请参阅
gphoto.org/proj/libgphoto2/support.php
。注意
注意,GPL 许可证不适用于闭源软件的使用。
-
通过 libdc1394 兼容的 IIDC/DCAM 工业相机,libdc1394 是在 LGPLv2 许可证下开源的
-
对于 Linux,unicap 可以用作 IIDC/DCAM 兼容相机的替代接口,但 unicap 是 GPL 许可证,因此不适用于闭源软件。
-
其他工业相机通过以下专有框架:
-
Allied Vision Technologies (AVT) PvAPI 用于 GigE Vision 相机
-
Smartek Vision Giganetix SDK 用于 GigE Vision 相机
-
XIMEA API
-
注意
OpenCV 3 中的 videoio 模块是新的。在 OpenCV 2 中,视频捕获和录制是 highgui 模块的一部分,但在 OpenCV 3 中,highgui 模块仅负责 GUI 功能。有关 OpenCV 模块的完整索引,请参阅官方文档docs.opencv.org/3.0.0/
。
然而,我们不仅限于 videoio 模块的功能;我们可以使用其他 API 来配置相机和捕获图像。如果一个 API 可以捕获图像数据数组,OpenCV 可以轻松地使用这些数据,通常无需任何复制操作或转换。例如,我们将通过 OpenNI2(无需 videoio 模块)捕获和使用深度相机的图像,以及通过 Point Grey Research (PGR)的 FlyCapture SDK 捕获工业相机的图像。
注意
工业相机或机器视觉相机通常具有可互换的镜头,高速硬件接口(例如 FireWire,千兆以太网,USB 3.0 或 Camera Link),以及所有相机设置的完整编程接口。
大多数工业相机都为 Windows 和 Linux 提供 SDK。PGR 的 FlyCapture SDK 支持 Windows 上的单相机和多相机设置,以及 Linux 上的单相机设置。PGR 的一些竞争对手,如 Allied Vision Technologies (AVT),在 Linux 上提供更好的多相机设置支持。
我们将学习不同类别相机之间的差异,并将测试几种特定镜头、相机和配置的能力。到本章结束时,你将更有资格为自己、实验室、公司或客户设计消费级或工业级视觉系统。我希望通过每个价格点的可能结果来让你感到惊讶!
调色光
人类眼睛对某些电磁辐射的波长很敏感。我们把这些波长称为“可见光”、“颜色”,有时也只称为“光”。然而,我们对“可见光”的定义是以人为中心的,因为不同的动物看到不同的波长。例如,蜜蜂对红光视而不见,但可以看到紫外线(这对人类来说是不可见的)。此外,机器可以根据几乎任何刺激(如光、辐射、声音或磁性)组装出人类可见的图像。为了拓宽我们的视野,让我们考虑八种电磁辐射及其常见来源。以下是按波长递减顺序的列表:
-
无线电波 来自某些天体和闪电。它们也由无线电子设备(无线电、Wi-Fi、蓝牙等)产生。
-
微波 从大爆炸辐射出来,作为宇宙中的背景辐射存在。微波炉也能产生微波。
-
远红外线 (FIR) 光 是来自温暖或热物体(如热血动物和热水管)的无形光芒。
-
近红外线 (NIR) 光 从我们的太阳、火焰和红热或几乎红热的金属中明亮地辐射出来。然而,它在常见的电照明中是一个相对较弱的组成部分。叶子和其他植被会明亮地反射 NIR 光。皮肤和某些织物对 NIR 稍微透明。
-
可见光 从我们的太阳和常见的电光源中明亮地辐射出来。可见光包括红、橙、黄、绿、蓝和紫(按波长递减的顺序)。
-
紫外线 (UV) 光 在阳光下也很丰富。在晴朗的日子里,紫外线可以烧伤我们的皮肤,并且可以以远处的蓝灰色雾霾的形式对我们稍微可见。常见的硅酸盐玻璃对紫外线几乎是透明的,所以我们站在窗户后面(室内或车内)时不会晒伤。同样地,紫外线相机系统依赖于由非硅酸盐材料(如石英)制成的镜头。许多花朵上有紫外线标记,这些标记对昆虫来说是可见的。某些体液(如血液和尿液)对紫外线的透明度比对可见光的透明度更高。
-
X 射线 来自某些天体,如黑洞。在地球上,氡气和某些其他放射性元素是自然的 X 射线来源。
-
伽马射线 来自核爆炸,包括超新星爆炸。伽马射线的一些来源还包括放射性衰变和闪电。
美国国家航空航天局提供了以下与每种光线或辐射相关的波长和温度的视觉表示:
被动成像系统依赖于前面所述的环境(常见)光线或辐射源。主动成像系统包括自己的光源,这样光线或辐射可以以更可预测的方式结构化。例如,一个主动夜视仪可能使用一个近红外线相机和一个近红外线光源。
对于天文学,被动成像在整个电磁谱上都是可行的;浩瀚的宇宙充满了来自新旧来源的各种光线和辐射。然而,对于地球(地面)用途,被动成像主要限于远红外线、近红外线、可见光和紫外线范围。主动成像在整个谱上都是可行的,但功耗、安全性和干扰(在我们的用例与其他用例之间)的实用性限制了我们可以将过多的光线和辐射倾注到环境中的程度。
不论是主动还是被动,成像系统通常使用镜头将光线或辐射聚焦到相机传感器的表面。镜头及其涂层会传输某些波长的光线,同时阻挡其他波长的光线。可以在镜头或传感器前面放置额外的滤光片以阻挡更多波长的光线。最后,传感器本身表现出变化的光谱响应,这意味着对于某些波长的光线,传感器会记录一个强烈的(明亮)信号,而对于其他波长的光线,则记录一个微弱的(暗淡)信号或没有信号。通常,大规模生产的数字传感器对绿色的响应最强,其次是红色、蓝色和近红外线。根据使用情况,这样的传感器可能会配备一个滤光片来阻挡一定范围内的光线(无论是近红外线还是可见光)以及/或一个滤光片来叠加不同颜色的图案。后者允许捕获多通道图像,如 RGB 图像,而未过滤的传感器将捕获单色(灰色)图像。
传感器的表面由许多敏感点或像素组成。这些与捕获的数字图像中的像素类似。然而,像素和像素不一定是一对一对应的。根据相机系统的设计和配置,几个像素的信号可能会混合在一起,以创建一个多通道像素的邻域、一个更亮的像素或一个更少噪声的像素。
考虑以下成对的图像。它们展示了一个带有拜耳滤光片的传感器,这是一种常见的颜色滤光片,每个红色或蓝色像素旁边有两个绿色像素。为了计算单个 RGB 像素,需要混合多个像素值。左侧图像是显微镜下过滤传感器的照片,而右侧图像是展示滤光片和下方像素的剖视图:
注意
前面的图像来自维基媒体。它们是由用户 Natural Philo 贡献的,遵循 Creative Commons Attribution-Share Alike 3.0 Unported 许可协议(左侧),以及 Cburnett 贡献的,遵循 GNU 自由文档许可协议(右侧)。
如此例所示,一个简单的模型(RGB 像素)可能会隐藏关于数据捕获和存储方式的重要细节。为了构建高效的图像处理管道,我们需要考虑的不仅仅是像素,还包括通道和宏像素——共享某些数据通道的像素邻域,它们作为一个块被捕获、存储和处理。让我们考虑三种图像格式类别:
-
原始图像是光电传感器的光电信号的实际表示,缩放到某些范围,如 8、12 或 16 位。对于传感器给定行中的光电传感器,数据是连续的,但对于给定列中的光电传感器,数据不是连续的。
-
打包图像将每个像素或宏像素连续存储在内存中。也就是说,数据是按照它们的邻域进行排序的。如果我们的大部分处理都涉及多个颜色组件,这是一个高效的格式。对于典型的彩色相机,原始图像不是打包的,因为每个邻域的数据分布在多行中。打包彩色图像通常使用 RGB 通道,但也可以使用YUV 通道,其中 Y 是亮度(灰度),U 是蓝色(与绿色相对),V 是红色(也与绿色相对)。
-
平面图像将每个通道连续存储在内存中。也就是说,数据是按照它们所代表的颜色组件进行排序的。如果我们的大部分处理都涉及单个颜色组件,这是一个高效的格式。打包彩色图像通常使用 YUV 通道。在平面格式中有一个 Y 通道对于计算机视觉来说效率很高,因为许多算法都是设计用来处理灰度数据的。
一张来自单色相机的图像可以有效地以原始格式存储和处理,或者(如果它必须无缝集成到彩色成像管道中)作为平面 YUV 格式中的 Y 平面。在本章的后续部分,在Supercharging the PlayStation Eye和Supercharging the GS3-U3-23S6M-C and other Point Grey Research cameras等节中,我们将讨论示例代码,展示如何高效处理各种图像格式。
到目前为止,我们已经简要介绍了光、辐射和颜色的分类——它们的来源、与光学和传感器的相互作用,以及它们作为通道和邻域的表示。现在,让我们探索图像捕获的一些更多维度:时间和空间。
在瞬间捕捉主题
罗伯特·卡帕,一位报道过五场战争并在奥马哈海滩拍摄了 D-Day 登陆第一波次的摄影记者,给出了以下建议:
“如果你的照片不够好,那是因为你离得不够近。”
就像计算机视觉程序一样,摄影师是镜头背后的智慧。(有些人会说摄影师是镜头背后的灵魂。)一位优秀的摄影师会持续执行检测和跟踪任务——扫描环境,选择主题,预测将创造正确拍照时刻的动作和表情,并选择最有效地框定主题的镜头、设置和视角。
通过“足够接近”主题和动作,摄影师可以用肉眼快速观察细节,并且因为距离短且设备通常较轻(与三脚架上的长焦镜头相比),可以快速移动到其他视角。此外,近距离、广角拍摄将摄影师和观众拉入事件的第一人称视角,就像我们成为主题或主题的同伴,在那一刻一样。
照片美学在第二章中进一步探讨,即使用自动相机拍摄自然和野生动物。现在,我们只需确立两条基本规则:不要错过主体,不要错过时机!能见度差和时机不佳是摄影师或计算机视觉实践者能给出的最糟糕的借口。为了自我监督,让我们定义一些与这些基本规则相关的测量标准。
分辨率是镜头和相机能看到的细节的最精细程度。对于许多计算机视觉应用来说,可识别的细节是工作的主题,如果系统的分辨率差,我们可能会完全错过这个主题。分辨率通常用传感器的像素计数或捕获图像的像素计数来表示,但最好的这些测量只能告诉我们一个限制因素。一个更好的、经验性的测量,它反映了镜头、传感器和设置的各个方面,被称为每毫米线对数(lp/mm)。这意味着在给定设置中,镜头和相机可以分辨的最大黑白线条密度。在高于这个密度的任何情况下,捕获图像中的线条会模糊在一起。请注意,lp/mm 会随着主题的距离和镜头的设置(包括变焦镜头的焦距(光学变焦))而变化。当你接近主题,放大或用长焦镜头替换短焦镜头时,系统当然应该捕捉到更多的细节!然而,当你裁剪(数字变焦)捕获的图像时,lp/mm 不会变化。
照明条件和相机的ISO 速度设置也会影响 lp/mm。高 ISO 速度在低光下使用,它们增强了信号(在低光中较弱)和噪声(始终很强)。因此,在高 ISO 速度下,一些细节被增强的噪声所掩盖。
要接近其潜在分辨率,镜头必须正确对焦。当代摄影师丹特·斯特拉描述了现代相机技术的一个问题:
"首先,它缺乏……思维控制的预测自动对焦。"
这意味着,当其算法与特定、智能的使用或场景中条件变化的特定模式不匹配时,自动对焦可能会完全失败。长焦镜头在不当对焦方面尤其不容忍。景深(焦点最近点和最远点之间的距离)在长焦镜头中较浅。对于某些计算机视觉设置——例如,悬挂在装配线上的相机——主题的距离是高度可预测的,在这种情况下,手动对焦是一个可接受的解决方案。
视野(FOV)是镜头视野的范围。通常,视野是以角度来测量的,但也可以测量为从镜头特定深度处两个可观察到的边缘点的距离。例如,90 度的视野也可以表示为在 1 米深度处的 2 米视野或在 2 米深度处的 4 米视野。除非另有说明,否则视野通常指的是对角线视野(镜头视野的对角线),而不是水平视野或垂直视野。长焦镜头的视野较窄。通常,长焦镜头也有更高的分辨率和更少的畸变。如果我们的主题超出了视野范围,我们就会完全错过主题!在视野的边缘,分辨率往往会降低,畸变往往会增加,因此,视野最好足够宽,以便在主题周围留出一定的空间。
相机的吞吐量是它捕获图像数据的速率。对于许多计算机视觉应用,视觉事件可能在一瞬间开始和结束,如果吞吐量低,我们可能会完全错过这一刻,或者我们的图像可能会受到运动模糊的影响。通常,吞吐量以每秒帧数(FPS)来衡量,但将其作为比特率来衡量也可能很有用。吞吐量受以下因素的影响:
-
快门速度(曝光时间):对于曝光良好的图像,快门速度受光照条件、镜头的光圈设置和相机的 ISO 速度设置限制。(相反,较慢的快门速度允许更窄的光圈设置或更慢的 ISO 速度。)在讨论完这个列表后,我们将讨论光圈设置。
-
快门类型:全局快门会在所有光电传感器上同步捕获。滚动快门则不会;相反,捕获是顺序进行的,传感器底部的光电传感器比顶部的光电传感器晚记录信号。滚动快门较差,因为它会在物体或相机快速移动时使物体看起来倾斜。(这有时被称为“果冻效应”,因为视频与摇摆的果冻山相似。)此外,在快速闪烁的照明下,滚动快门会在图像中创建明暗条纹。如果捕获的开始是同步的,但结束不是,则该快门被称为全局重置的滚动快门。
-
相机内置图像处理程序,例如将原始光电传感器信号转换为给定格式中给定数量的像素。随着像素数和每像素字节数的增加,吞吐量会降低。
-
相机与主机计算机之间的接口:按位速率递减的顺序,常见的相机接口包括 CoaXPress 全、Camera Link 全、USB 3.0、CoaXPress 基础、Camera Link 基础、千兆以太网、IEEE 1394b(FireWire 全)、USB 2.0 和 IEEE 1394(FireWire 基础)。
宽光圈设置可以让更多的光线进入,以便允许更快的曝光、更低的 ISO 速度或更明亮的图像。然而,窄光圈具有提供更大景深的优点。镜头支持有限的光圈设置范围。根据镜头的不同,某些光圈设置比其他设置具有更高的分辨率。长焦镜头往往在光圈设置上表现出更稳定的分辨率。
镜头的光圈大小以f 值或f 挡表示,这是镜头焦距与其光圈直径的比率。粗略地说,焦距与镜头的长度有关。更精确地说,当镜头聚焦于无限远的目标时,它是相机传感器与镜头系统光学中心之间的距离。焦距不应与焦距混淆——即对焦物体的距离。以下图表说明了焦距和焦距以及视场(FOV)的含义:
使用更高的 f 值(即比例更窄的光圈),镜头会传输更少的光。具体来说,传输光的强度与 f 值的平方成反比。例如,当比较两个镜头的最大光圈时,摄影师可能会写,“f/2 镜头比 f/2.8 镜头快一倍。”这意味着前一个镜头可以传输两倍的光强度,允许在半数时间内进行等效曝光。
镜头的效率或透射率(透射光的比例)不仅取决于光圈数,还取决于非理想因素。例如,一些光线被镜头元件反射而不是透射。T 数或T 挡是根据特定镜头的透射率经验发现对光圈数进行调整。例如,无论其光圈数如何,T/2.4 镜头的透射率与理想 f/2.4 镜头相同。对于电影镜头,制造商通常提供 T 数规格,但对于其他镜头,更常见的是只提供光圈数规格。
传感器的效率是指镜头传输的光线中到达光电元件并转换为信号的比例。如果效率低下,传感器会错过大部分光线!效率更高的传感器倾向于在更广泛的相机设置、镜头设置和光照条件下拍摄曝光良好的图像。因此,效率为系统提供了更多自由度来自动选择对分辨率和吞吐量最优的设置。对于前一小节中描述的常见传感器类型,彩色光线,颜色滤光片的选取对效率有很大影响。设计用于以灰度形式捕捉可见光的相机具有高效率,因为它可以在每个光电元件上接收所有可见波长。设计用于在多个颜色通道中捕捉可见光的相机通常效率较低,因为每个光电元件会过滤掉一些波长。仅设计用于捕捉近红外光(NIR)的相机,通过过滤掉所有可见光,通常具有更低的效率。
效率是系统在多种光照(或辐射)条件下形成某种图像能力的好指标。然而,根据主题和实际光照,一个相对低效的系统可能具有更高的对比度和更好的分辨率。选择性过滤波长的优势并不一定反映在 lp/mm 上,这是衡量黑白分辨率的指标。
到现在为止,我们已经看到了许多可量化的权衡,这些权衡使得我们捕捉瞬间的主题变得复杂。正如罗伯特·卡帕的建议所暗示的,使用短焦距镜头靠近主题是一个相对稳健的方案。它允许以最小的风险获得良好的分辨率,并且不太可能完全错过构图或焦点。另一方面,这种设置存在高畸变和定义上的短工作距离。超越卡帕时代的相机功能,我们还考虑了允许高吞吐量和高效视频捕获的功能和配置。
在了解了波长、图像格式、相机、镜头、捕获设置和摄影师的常识之后,我们现在可以挑选几个系统来研究。
收集不同寻常的嫌疑人
本章的演示应用程序使用三台摄像头进行测试,这些摄像头在以下表格中描述。演示程序也与许多其他摄像头兼容;我们将在每个演示的详细描述中讨论兼容性。这三台选定的摄像头在价格和功能方面差异很大,但每台都能做普通网络摄像头做不到的事情!
名称 | 价格 | 用途 | 模式 | 光学 |
---|---|---|---|---|
索尼 PlayStation Eye | $10 | 在可见光中进行被动、彩色成像 | 640x480 @ 60 FPS320x240 @ 187 FPS | 视场角:75 度或 56 度(两种缩放设置) |
华硕 Xtion PRO Live | $230 | 在可见光中进行被动、彩色成像,在近红外光中进行主动、单色成像,深度估计 | 彩色或近红外:1280x1024 @ 60 FPS 深度:640x480 @ 30 FPS | 视场角:70 度 |
PGR Grasshopper 3 GS3-U3-23S6M-C | $1000 | 在可见光中进行被动、单色成像 | 1920x1200 @ 162 FPS | C-mount 镜头(不包括) |
注意
关于我们可以在 GS3-U3-23S6M-C 摄像头中使用的镜头示例,请参阅本章后面的“选购玻璃”部分。
我们将尝试将这些摄像头的性能推到极限。使用多个库,我们将编写应用程序来访问不寻常的捕获模式,并快速处理帧,以便输入成为瓶颈。借用 1950 年代肌肉车设计师的术语,我们可以说我们想要“超级充电”我们的系统;我们希望向它们提供专业或过剩的输入,看看它们能做什么!
Supercharging the PlayStation Eye
索尼于 2007 年开发了 Eye 摄像头,作为 PlayStation 3 游戏的输入设备。最初,没有其他系统支持 Eye。从那时起,第三方为 Linux、Windows 和 Mac 开发了驱动程序和 SDK。以下列表描述了这些第三方项目的一些当前状态:
-
对于 Linux 系统,gspca_ov534 驱动程序支持 PlayStation Eye,并且与 OpenCV 的 videoio 模块配合使用时无需额外设置。此驱动程序是大多数最新 Linux 发行版的标准配置。当前版本的驱动程序支持高达 320x240 @ 125 FPS 和 640x480 @ 60 FPS 的模式。即将发布的版本将增加对 320x240 @ 187 FPS 的支持。如果您想今天升级到这个未来版本,您需要熟悉 Linux 内核开发的基础知识,并自行构建驱动程序。
注意
在
github.com/torvalds/linux/blob/master/drivers/media/usb/gspca/ov534.c
查看驱动程序的最新源代码。简要来说,您需要获取您 Linux 发行版内核的源代码,合并新的ov534.c
文件,将驱动程序作为内核的一部分构建,最后加载新构建的 gspca_ov534 驱动程序。 -
对于 Mac 和 Windows,开发人员可以使用名为 PS3EYEDriver 的 SDK 将 PlayStation Eye 支持添加到他们的应用程序中,该 SDK 可在
github.com/inspirit/PS3EYEDriver
找到。尽管名称如此,此项目不是一个驱动程序;它在应用程序级别支持摄像头,但在操作系统级别不支持。支持的模式包括 320x240 @ 187 FPS 和 640x480 @ 60 FPS。该项目附带示例应用程序代码。PS3EYEDriver 中的大部分代码源自 GPL 许可的 gspca_ov534 驱动程序,因此,PS3EYEDriver 的使用可能仅适用于也是 GPL 许可的项目。 -
对于 Windows,可以从 Code Laboratories (CL) 购买商业驱动程序和 SDK,网址为
codelaboratories.com/products/eye/driver/
。在撰写本文时,CL-Eye Driver 的价格为 3 美元。然而,该驱动程序与 OpenCV 3 的 videoio 模块不兼容。依赖于驱动程序的 CL-Eye Platform SDK,额外费用为 5 美元。支持的最快模式是 320x240 @ 187 FPS 和 640x480 @ 75 FPS。 -
对于较新的 Mac 版本,没有可用的驱动程序。一个名为 macam 的驱动程序可在
webcam-osx.sourceforge.net/
找到,但它最后一次更新是在 2009 年,并且不适用于 Mac OS X Mountain Lion 和更新的版本。
因此,Linux 中的 OpenCV 可以直接从 Eye 摄像头捕获数据,但 Windows 或 Mac 中的 OpenCV 需要另一个 SDK 作为中介。
首先,对于 Linux,让我们考虑一个使用 OpenCV 根据 Eye 的高速输入录制慢动作视频的 C++应用程序的最小示例。此外,程序应记录其帧率。让我们称这个应用程序为“不眨眼”Eye。
注意
“不眨眼”Eye 的源代码和构建文件位于本书 GitHub 仓库的github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_1/UnblinkingEye
。
注意,此示例代码也应适用于其他与 OpenCV 兼容的摄像头,尽管与 Eye 相比,帧率较慢。
“不眨眼”Eye 可以实现在一个名为UnblinkingEye.cpp
的单个文件中,包含这些几行代码:
#include <stdio.h>
#include <time.h>
#include <opencv2/core.hpp>
#include <opencv2/videoio.hpp>
int main(int argc, char *argv[]) {
const int cameraIndex = 0;
const bool isColor = true;
const int w = 320;
const int h = 240;
const double captureFPS = 187.0;
const double writerFPS = 60.0;
// With MJPG encoding, OpenCV requires the AVI extension.
const char filename[] = "SlowMo.avi";
const int fourcc = cv::VideoWriter::fourcc('M','J','P','G');
const unsigned int numFrames = 3750;
cv::Mat mat;
// Initialize and configure the video capture.
cv::VideoCapture capture(cameraIndex);
if (!isColor) {
capture.set(cv::CAP_PROP_MODE, cv::CAP_MODE_GRAY);
}
capture.set(cv::CAP_PROP_FRAME_WIDTH, w);
capture.set(cv::CAP_PROP_FRAME_HEIGHT, h);
capture.set(cv::CAP_PROP_FPS, captureFPS);
// Initialize the video writer.
cv::VideoWriter writer(
filename, fourcc, writerFPS, cv::Size(w, h), isColor);
// Get the start time.
clock_t startTicks = clock();
// Capture frames and write them to the video file.
for (unsigned int i = 0; i < numFrames;) {
if (capture.read(mat)) {
writer.write(mat);
i++;
}
}
// Get the end time.
clock_t endTicks = clock();
// Calculate and print the actual frame rate.
double actualFPS = numFrames * CLOCKS_PER_SEC /
(double)(endTicks - startTicks);
printf("FPS: %.1f\n", actualFPS);
}
注意,摄像头指定的模式是 320x240 @ 187 FPS。如果我们的 gspca_ov534 驱动程序版本不支持此模式,我们预计它将回退到 320x240 @ 125 FPS。同时,视频文件指定的模式是 320x240 @ 60 FPS,这意味着视频将以慢于实际速度的速度播放,作为特殊效果。可以使用以下终端命令构建“不眨眼”Eye:
$ g++ UnblinkingEye.cpp -o UnblinkingEye -lopencv_core -lopencv_videoio
构建“不眨眼”Eye,运行它,记录一个移动的物体,观察帧率,并播放录制的视频SlowMo.avi
。你的物体在慢动作中看起来如何?
在 CPU 或存储速度较慢的机器上,Unblinking Eye 可能会因为视频编码或文件输出的瓶颈而丢弃一些捕获的帧。不要被低分辨率所迷惑!在 320x240 @ 187 FPS 模式下,摄像头的数据传输速率大于在 1280x720 @ 15 FPS 模式下(一个稍微卡顿的 HD 分辨率)。通过将像素乘以帧率,可以看到每种模式下每秒传输了多少像素。
假设我们想要通过捕获和记录单色视频来减少每帧的数据量。当 OpenCV 3 在 Linux 上构建时,如果启用了 libv4l 支持,则此选项可用。(相关的 CMake 定义是WITH_LIBV4L
,默认开启。)通过更改 Unblinking Eye 中的以下代码行并重新构建,我们可以切换到灰度捕获:
const bool isColor = false;
注意,对这个布尔值的更改会影响以下代码中突出显示的部分:
cv::VideoCapture capture(cameraIndex);
if (!isColor) {
capture.set(cv::CAP_PROP_MODE, cv::CAP_MODE_GRAY);
}
capture.set(cv::CAP_PROP_FRAME_WIDTH, w);
capture.set(cv::CAP_PROP_FRAME_HEIGHT, h);
capture.set(cv::CAP_PROP_FPS, captureFPS);
cv::VideoWriter writer(
filename, fourcc, writerFPS, cv::Size(w, h), isColor);
在幕后,VideoCapture
和VideoWriter
对象现在使用平面 YUV 格式。捕获的 Y 数据被复制到一个单通道的 OpenCV Mat
中,并最终存储在视频文件的 Y 通道中。同时,视频文件的 U 和 V 颜色通道只是填充了中间值,128,用于灰度。U 和 V 的分辨率低于 Y,因此在捕获时,YUV 格式只有每像素 12 位(bpp),而 OpenCV 的默认 BGR 格式为 24 bpp。
注意
OpenCV 视频 io 模块中的 libv4l 接口目前支持以下cv::CAP_PROP_MODE
的值:
-
cv::CAP_MODE_BGR
(默认)以 BGR 格式捕获 24 bpp 颜色(每个通道 8 bpp)。 -
cv::CAP_MODE_RGB
以 RGB 格式捕获 24 bpp 颜色(每个通道 8 bpp)。 -
cv::CAP_MODE_GRAY
从 12 bpp 平面 YUV 格式中提取 8 bpp 灰度。 -
cv::CAP_MODE_YUYV
以打包的 YUV 格式(Y 为 8 bpp,U 和 V 各为 4 bpp)捕获 16 bpp 颜色。
对于 Windows 或 Mac,我们应该使用 PS3EYEDriver、CL-Eye Platform SDK 或其他库来捕获数据,然后创建一个引用数据的 OpenCV Mat
。以下部分代码示例展示了这种方法:
int width = 320, height = 240;
int matType = CV_8UC3; // 8 bpp per channel, 3 channels
void *pData;
// Use the camera SDK to capture image data.
someCaptureFunction(&pData);
// Create the matrix. No data are copied; the pointer is copied.
cv::Mat mat(height, width, matType, pData);
事实上,几乎任何数据源集成到 OpenCV 的方法都是相同的。相反,为了将 OpenCV 作为其他库的数据源使用,我们可以获取存储在矩阵中的数据的指针:
void *pData = mat.data;
在本章的后面部分,在Supercharging the GS3-U3-23S6M-C and other Point Grey Research cameras中,我们介绍了一个将 OpenCV 与其他库集成的微妙示例,特别是 FlyCapture2 用于捕获和 SDL2 用于显示。PS3EYEDriver 附带了一个类似的示例,其中捕获数据的指针被传递到 SDL2 进行显示。作为一个练习,你可能想要调整这两个示例来构建一个集成 OpenCV 与 PS3EYEDriver 进行捕获和 SDL2 进行显示的演示。
希望经过一些实验后,你会得出结论,PlayStation Eye 相机比其 $10 的价格标签所暗示的更具有能力。对于快速移动的物体,它的高帧率是低分辨率的良好折衷。消除运动模糊!
如果我们愿意投资硬件修改,Eye 相机还有更多隐藏的技巧(或在其插座中)。镜头和红外阻挡滤光片相对容易更换。副厂镜头和滤光片可以允许进行近红外捕捉。此外,副厂镜头可以提供更高的分辨率、不同的视场角、更少的畸变和更高的效率。Peau Productions 不仅销售预修改的 Eye 相机,还提供 DIY 套件,详情请见 peauproductions.com/store/index.php?cPath=136_1
。公司的修改支持带有 m12 或 CS 螺纹接口的互换镜头(两种不同的螺纹接口标准)。网站根据镜头特性(如畸变和红外传输)提供详细的推荐。Peau 的预修改近红外 Eye 相机加镜头的价格起价约为 $85。更昂贵的选项,包括畸变校正镜头,最高可达 $585。然而,在这些价格下,建议在多个供应商之间比较镜头价格,正如本章后面的 购物指南 部分所述。
接下来,我们将考察一款缺乏高速模式的相机,但设计用于分别捕捉可见光和近红外光,并带有主动近红外照明。
为 ASUS Xtion PRO Live 和其他 OpenNI 兼容的深度相机提供加速
ASUS 于 2012 年推出了 Xtion PRO Live,作为动作控制游戏、自然用户界面(NUI)和计算机视觉研究的输入设备。它是基于 PrimeSense 设计的传感器的六款类似相机之一,PrimeSense 是一家以色列公司,苹果公司在 2013 年收购并关闭了该公司。有关 Xtion PRO Live 与使用 PrimeSense 传感器的其他设备的简要比较,请参阅以下表格:
名称 | 价格和可用性 | 最高分辨率近红外模式 | 最高分辨率彩色模式 | 最高分辨率深度模式 | 深度范围 |
---|---|---|---|---|---|
微软 Xbox 360 Kinect | $135 可用 | 640x480 @ 30 FPS | 640x480 @ 30 FPS | 640x480 @ 30 FPS | 0.8m 至 3.5m |
ASUS Xtion PRO | $200 已停售 | 1280x1024 @ 60 FPS | 无 | 640x480 @ 30 FPS | 0.8m 至 3.5m |
ASUS Xtion PRO Live | $230 可用 | 1280x1024 @ 60 FPS | 1280x1024 @ 60 FPS | 640x480 @ 30 FPS | 0.8m 至 3.5m |
PrimeSense Carmine 1.08 | $300 已停售 | 1280x960 @ 60 FPS | 1280x960 @ 60 FPS | 640x480 @ 30 FPS | 0.8m 至 3.5m |
PrimeSense Carmine 1.09 | $325 已停售 | 1280x960 @ 60 FPS | 1280x960 @ 60 FPS | 640x480 @ 30 FPS | 0.35m 至 1.4m |
结构传感器 | $380 可用 | 640x480 @ 30 FPS | 无 | 640x480 @ 30 FPS | 0.4m 至 3.5m |
所有这些设备都包含一个近红外 (NIR) 摄像头和近红外照明源。光源投射出近红外点图案,可能在 0.8m 到 3.5m 的距离内被检测到,具体取决于型号。大多数设备还包含一个 RGB 彩色摄像头。基于主动 NIR 图像(点图案)和被动 RGB 图像,设备可以估计距离并生成所谓的 深度图,包含 640x480 点的距离估计。因此,该设备最多有三种模式:NIR(摄像头图像)、彩色(摄像头图像)和深度(处理后的图像)。
注意
关于在深度成像中有用的主动照明或结构光类型的信息,请参阅以下论文:
David Fofi, Tadeusz Sliwa, Yvon Voisin, "A comparative survey on invisible structured light", SPIE Electronic Imaging - Machine Vision Applications in Industrial Inspection XII, San José, USA, pp. 90-97, January, 2004.
论文可在以下网址在线获取:www.le2i.cnrs.fr/IMG/publications/fofi04a.pdf
。
Xtion、Carmine 和 Structure Sensor 设备以及某些版本的 Kinect 与名为 OpenNI 和 OpenNI2 的开源 SDK 兼容。OpenNI 和 OpenNI2 都可在 Apache 许可证下使用。在 Windows 上,OpenNI2 随带对许多相机的支持。然而,在 Linux 和 Mac 上,Xtion、Carmine 和 Structure Sensor 设备的支持是通过一个名为 PrimeSense Sensor 的额外模块提供的,该模块也是开源的,并遵循 Apache 许可证。Sensor 模块和 OpenNI2 有独立的安装程序,Sensor 模块必须首先安装。根据您的操作系统,从以下 URL 获取 Sensor 模块:
-
Linux x64:
nummist.com/opencv/Sensor-Bin-Linux-x64-v5.1.6.6.tar.bz2
-
Linux x86:
nummist.com/opencv/Sensor-Bin-Linux-x86-v5.1.6.6.tar.bz2
-
Linux ARM:
nummist.com/opencv/Sensor-Bin-Linux-Arm-v5.1.6.6.tar.bz2
下载此存档后,解压缩它并运行解压缩文件夹内的 install.sh
。
注意
对于 Kinect 兼容性,尝试传感器模块的 SensorKinect 分支。SensorKinect 的下载可在github.com/avin2/SensorKinect/downloads
找到。SensorKinect 仅支持 Xbox 360 的 Kinect,不支持型号 1473。(型号编号打印在设备底部。)此外,SensorKinect 仅与 OpenNI(而不是 OpenNI2)的老版本开发构建兼容。有关旧版 OpenNI 的下载链接,请参阅nummist.com/opencv/
。
现在,在任意操作系统上,我们需要从源代码构建 OpenNI2 的最新开发版本。(较旧、稳定的版本在 Xtion PRO Live 上不工作,至少在某些系统上。)源代码可以作为一个 ZIP 存档从github.com/occipital/OpenNI2/archive/develop.zip
下载,或者可以使用以下命令将其作为 Git 仓库克隆:
$ git clone –b develop https://github.com/occipital/OpenNI2.git
让我们将解压后的目录或本地仓库目录称为<openni2_path>
。此路径应包含 Windows 的 Visual Studio 项目和一个 Linux 或 Mac 的 Makefile。构建项目(使用 Visual Studio 或make
命令)。库文件生成在如<openni2_path>/Bin/x64-Release
和<openni2_path>/Bin/x64-Release/OpenNI2/Drivers
(或其他架构的类似名称)的目录中。在 Windows 上,将这些两个文件夹添加到系统的Path
中,以便应用程序可以找到dll
文件。在 Linux 或 Mac 上,编辑您的~/.profile
文件,并添加以下类似的行以创建与 OpenNI2 相关的环境变量:
export OPENNI2_INCLUDE="<openni2_path>/Include"
export OPENNI2_REDIST="<openni2_path>/Bin/x64-Release"
到目前为止,我们已经设置了支持传感器模块的 OpenNI2,因此我们可以为 Xtion PRO Live 或其他基于 PrimeSense 硬件的相机创建应用程序。源代码、Visual Studio 项目和几个示例的 Makefiles 可以在<openni2_path>/Samples
中找到。
注意
可选地,可以将 OpenCV 的 videoio 模块编译为支持通过 OpenNI 或 OpenNI2 捕获图像。然而,我们将直接从 OpenNI2 捕获图像,然后将其转换为与 OpenCV 一起使用。通过直接使用 OpenNI2,我们可以获得更多控制相机模式选择的能力,例如原始近红外捕获。
Xtion 设备是为 USB 2.0 设计的,它们的标准固件不与 USB 3.0 端口兼容。为了实现 USB 3.0 兼容性,我们需要一个非官方的固件更新。固件更新程序只能在 Windows 上运行,但更新应用后,设备在 Linux 和 Mac 上也能实现 USB 3.0 兼容。要获取并应用更新,请按照以下步骤操作:
-
从
github.com/nh2/asus-xtion-fix/blob/master/FW579-RD1081-112v2.zip?raw=true
下载更新,并将其解压到任何目标位置,我们将此位置称为<xtion_firmware_unzip_path>
。 -
确保 Xtion 设备已连接。
-
打开命令提示符并运行以下命令:
> cd <xtion_firmware_unzip_path>\UsbUpdate > !Update-RD108x!
如果固件更新器打印出错误,这些错误并不一定是致命的。请继续使用我们在此展示的演示应用程序测试相机。
为了了解 Xtion PRO Live 作为主动或被动 NIR 相机的功能,我们将构建一个简单的应用程序来捕获和显示设备中的图像。让我们称这个应用程序为 Infravision。
注意
Infravision 的源代码和构建文件位于本书 GitHub 仓库的github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_1/Infravision
。
此项目只需要一个源文件,即Infravision.cpp
。从 C 标准库中,我们将使用格式化和打印字符串的功能。因此,我们的实现从以下导入语句开始:
#include <stdio.h>
#include <stdlib.h>
Infravision 将使用 OpenNI2 和 OpenCV。从 OpenCV 中,我们将使用核心和 imgproc 模块进行基本的图像处理,以及 highgui 模块进行事件处理和显示。以下是相关的导入语句:
#include <opencv2/core.hpp>
#include <opencv2/highgui.hpp>
#include <opencv2/imgproc.hpp>
#include <OpenNI.h>
注意
OpenNI2 以及 OpenNI 的文档可以在网上找到,地址为structure.io/openni
。
Infravision 中只有一个函数,即main
函数。它从定义两个常量开始,我们可能需要配置这些常量。第一个常量指定通过 OpenNI 捕获的传感器数据类型。这可以是SENSOR_IR
(红外相机的单色输出)、SENSOR_COLOR
(彩色相机的 RGB 输出)或SENSOR_DEPTH
(处理过的、反映每个点估计距离的混合数据)。第二个常量是应用程序窗口的标题。以下是相关的定义:
int main(int argc, char *argv[]) {
const openni::SensorType sensorType = openni::SENSOR_IR;
// const openni::SensorType sensorType = openni::SENSOR_COLOR;
// const openni::SensorType sensorType = openni::SENSOR_DEPTH;
const char windowName[] = "Infravision";
根据捕获模式,我们将定义相应的 OpenCV 矩阵的格式。红外和深度模式是单色的,位深为 16 位。彩色模式有三个通道,每个通道位深为 8 位,如下面的代码所示:
int srcMatType;
if (sensorType == openni::SENSOR_COLOR) {
srcMatType = CV_8UC3;
} else {
srcMatType = CV_16U;
}
让我们通过几个步骤来初始化 OpenNI2、连接到相机、配置它并开始捕获图像。以下是第一步的代码,初始化库:
openni::Status status;
status = openni::OpenNI::initialize();
if (status != openni::STATUS_OK) {
printf(
"Failed to initialize OpenNI:\n%s\n",
openni::OpenNI::getExtendedError());
return EXIT_FAILURE;
}
接下来,我们将连接到任何可用的 OpenNI 兼容相机:
openni::Device device;
status = device.open(openni::ANY_DEVICE);
if (status != openni::STATUS_OK) {
printf(
"Failed to open device:\n%s\n",
openni::OpenNI::getExtendedError());
openni::OpenNI::shutdown();
return EXIT_FAILURE;
}
我们将通过尝试获取有关该传感器的信息来确保设备具有适当的传感器类型:
const openni::SensorInfo *sensorInfo =
device.getSensorInfo(sensorType);
if (sensorInfo == NULL) {
printf("Failed to find sensor of appropriate type\n");
device.close();
openni::OpenNI::shutdown();
return EXIT_FAILURE;
}
我们还将创建一个流,但尚未启动:
openni::VideoStream stream;
status = stream.create(device, sensorType);
if (status != openni::STATUS_OK) {
printf(
"Failed to create stream:\n%s\n",
openni::OpenNI::getExtendedError());
device.close();
openni::OpenNI::shutdown();
return EXIT_FAILURE;
}
我们将查询支持的视频模式,并遍历它们以找到具有最高分辨率的模式。然后,我们将选择此模式:
// Select the video mode with the highest resolution.
{
const openni::Array<openni::VideoMode> *videoModes =
&sensorInfo->getSupportedVideoModes();
int maxResolutionX = -1;
int maxResolutionIndex = 0;
for (int i = 0; i < videoModes->getSize(); i++) {
int resolutionX = (*videoModes)[i].getResolutionX();
if (resolutionX > maxResolutionX) {
maxResolutionX = resolutionX;
maxResolutionIndex = i;
}
}
stream.setVideoMode((*videoModes)[maxResolutionIndex]);
}
我们将开始从相机流式传输图像:
status = stream.start();
if (status != openni::STATUS_OK) {
printf(
"Failed to start stream:\n%s\n",
openni::OpenNI::getExtendedError());
stream.destroy();
device.close();
openni::OpenNI::shutdown();
return EXIT_FAILURE;
}
为了准备捕获和显示图像,我们将创建一个 OpenNI 帧、一个 OpenCV 矩阵和一个窗口:
openni::VideoFrameRef frame;
cv::Mat dstMat;
cv::namedWindow(windowName);
接下来,我们将实现应用程序的主循环。在每次迭代中,我们将通过 OpenNI 捕获一帧,将其转换为典型的 OpenCV 格式(要么是 8 bpp 的灰度,要么是每个通道 8 bpp 的 BGR),并通过 highgui 模块显示它。循环在用户按下任何键时结束。以下是实现代码:
// Capture and display frames until any key is pressed.
while (cv::waitKey(1) == -1) {
status = stream.readFrame(&frame);
if (frame.isValid()) {
cv::Mat srcMat(
frame.getHeight(), frame.getWidth(), srcMatType,
(void *)frame.getData(), frame.getStrideInBytes());
if (sensorType == openni::SENSOR_COLOR) {
cv::cvtColor(srcMat, dstMat, cv::COLOR_RGB2BGR);
} else {
srcMat.convertTo(dstMat, CV_8U);
}
cv::imshow(windowName, dstMat);
}
}
注意
OpenCV 的高级 GUI 模块存在许多不足。它不允许处理标准退出事件,例如点击窗口的 X 按钮。因此,我们基于按键来退出。此外,highgui 在轮询事件(如按键)时至少会引入 1ms 的延迟(但可能更多,取决于操作系统在线程之间切换的最小时间)。对于演示低帧率相机(如具有 30 FPS 限制的 Xtion PRO Live)的目的,这种延迟不应产生影响。然而,在下一节“超级提升 GS3-U3-23S6M-C 和其他 Point Gray 研究相机”中,我们将探讨 SDL2 作为比 highgui 更高效的替代方案。
在循环结束(由于用户按下键)后,我们将清理窗口和所有 OpenNI 资源,如下面的代码所示:
cv::destroyWindow(windowName);
stream.stop();
stream.destroy();
device.close();
openni::OpenNI::shutdown();
}
这标志着源代码的结束。在 Windows 上,Infravision 可以在 Visual Studio 中构建为 Visual C++ Win32 控制台项目。请记住右键单击项目并编辑其项目属性,以便C++ | 通用 | 附加包含目录列出 OpenCV 和 OpenNI 的 include
目录的路径。此外,编辑链接器 | 输入 | 附加依赖项,以便它列出 opencv_core300.lib
和 opencv_imgproc300.lib
(或类似命名的 lib
文件,用于除 3.0.0 之外的其他 OpenCV 版本)以及 OpenNI2.lib
的路径。最后,确保 OpenCV 和 OpenNI 的 dll
文件位于系统的 Path
中。
在 Linux 或 Mac 上,可以使用以下终端命令(假设 OPENNI2_INCLUDE
和 OPENNI2_REDIST
环境变量已按本节前面所述定义)编译 Infravision:
$ g++ Infravision.cpp -o Infravision \
-I include -I $OPENNI2_INCLUDE -L $OPENNI2_REDIST \
-Wl,-R$OPENNI2_REDIST -Wl,-R$OPENNI2_REDIST/OPENNI2 \
-lopencv_core -lopencv_highgui -lopencv_imgproc -lOpenNI2
注意
-Wl,-R
标志指定了可执行文件在运行时搜索库文件的附加路径。
在构建 Infravision 后,运行它并观察 Xtion PRO Live 投射到附近物体上的 NIR 点的模式。当从远处的物体反射时,点稀疏分布,但当从附近的物体反射时,点密集分布或甚至难以区分。因此,点的密度是距离的预测指标。以下是显示在阳光照射的房间中效果的截图,其中 NIR 光来自 Xtion 和窗户:
或者,如果你想将 Xtion 用作一个被动近红外相机,只需覆盖住相机的近红外发射器。你的手指不会完全阻挡发射器的光线,但一块电工胶带可以。现在,将相机对准一个中等亮度近红外照明的场景。例如,Xtion 应该能够在阳光照射的房间里或夜晚篝火旁拍摄到良好的被动近红外图像。然而,相机在阳光明媚的户外场景中表现不佳,因为这与设备设计的条件相比要亮得多。以下是截图,显示了与上一个示例相同的阳光照射的房间,但这次 Xtion 的近红外发射器被覆盖了:
注意,所有的点都消失了,新的图像看起来像一张相对正常的黑白照片。然而,是否有任何物体看起来有奇怪的发光?
随意修改代码,使用SENSOR_DEPTH
或SENSOR_COLOR
而不是SENSOR_IR
。重新编译,重新运行应用程序,并观察效果。深度传感器提供深度图,在附近的区域看起来较亮,而在远处的区域或未知距离的区域看起来较暗。颜色传感器提供基于可见光谱的正常外观图像,如下面的同一阳光照射的房间截图所示:
比较前两个截图。注意,玫瑰的叶子在近红外图像中要亮得多。此外,在玫瑰下面的脚凳上的印刷图案在近红外图像中是不可见的。(当设计师选择颜料时,他们通常不会考虑物体在近红外光下的外观!)
可能你想将 Xtion 用作一个主动近红外成像设备——能够在短距离内进行夜视,但你不想看到近红外点的图案。只需用一些东西覆盖照明器以扩散近红外光,比如你的手指或一块布料。
作为这种扩散照明的例子,看看以下截图,显示了女性手腕的近红外图像:
注意,静脉在可见光中比在近红外光中更容易辨认。同样,主动近红外相机在捕捉人眼虹膜中可识别的细节方面具有优越的能力,如第六章中所示,利用生物特征进行高效人员识别。你能找到其他在近红外和可见光波长下看起来大不相同的事物的例子吗?
到目前为止,我们已经看到 OpenNI 兼容的相机可以通过编程和物理方式配置来捕捉多种类型的图像。然而,这些相机是为特定任务——室内场景中的深度估计——设计的,它们可能不适合其他用途,如户外近红外成像。接下来,我们将探讨一个更多样化、更可配置且更昂贵的相机系列。
为 GS3-U3-23S6M-C 和其他 Point Grey Research 相机提供超级加速
Point Grey Research(PGR),一家加拿大公司,制造具有各种功能的工业相机。以下表格列出了一些例子:
Family and Model | Price | Color Sensitivity | Highest Res Mode | Sensor Format and Lens Mount | Interface | Shutter |
---|---|---|---|---|---|---|
--- | --- | --- | --- | --- | --- | --- |
Firefly MVFMVU-03MTC-CS | $275 | 彩色 | 752x480 @ 60 FPS | 1/3"CS mount | USB 2.0 | 全局 |
Firefly MVFMVU-03MTM-CS | $275 | 由可见光产生的灰色 | 752x480 @ 60 FPS | 1/3"CS mount | USB 2.0 | 全局 |
Flea 3FL3-U3-88S2C-C | $900 | 彩色 | 4096x2160 @ 21 FPS | 1/2.5"C mount | USB 3.0 | 具有全局复位功能 |
Grasshopper 3GS3-U3-23S6C-C | $1,000 | 彩色 | 1920x1200 @ 162 FPS | 1/1.2"C mount | USB 3.0 | 全局 |
Grasshopper 3GS3-U3-23S6M-C | $1,000 | 由可见光产生的灰色 | 1920x1200 @ 162 FPS | 1/1.2"C mount | USB 3.0 | 全局 |
Grasshopper 3GS3-U3-41C6C-C | $1,300 | 彩色 | 2048x2048 @ 90 FPS | 1"C mount | USB 3.0 | 全局 |
Grasshopper 3GS3-U3-41C6M-C | $1,300 | 由可见光产生的灰色 | 2048x2048 @ 90 FPS | 1"C mount | USB 3.0 | 全局 |
Grasshopper 3GS3-U3-41C6NIR-C | $1,300 | 由近红外光产生的灰色 | 2048x2048 @ 90 FPS | 1"C mount | USB 3.0 | 全局 |
GazelleGZL-CL-22C5M-C | $1,500 | 由可见光产生的灰色 | 2048x1088 @ 280 FPS | 2/3"C mount | Camera Link | 全局 |
GazelleGZL-CL-41C6M-C | $2,200 | 由可见光产生的灰色 | 2048x2048 @ 150 FPS | 1"C mount | Camera Link | 全局 |
备注
要浏览更多 PGR 相机的功能,请查看公司提供的相机选择工具:www.ptgrey.com/Camera-selector
。有关 PGR 相机中传感器的性能统计信息,请参阅公司发布的相机传感器评论系列出版物,例如在www.ptgrey.com/press-release/10545
上发布的那些。
关于传感器格式和镜头安装的更多信息,请参阅本章后面的“选购镜头”部分。
PGR 的一些最新相机使用索尼 Pregius 品牌的传感器。这种传感器技术以其高分辨率、高帧率和效率的组合而著称,如 PGR 在其白皮书ptgrey.com/white-paper/id/10795
中所述。例如,GS3-U3-23S6M-C(单色相机)和 GS3-U3-23S6C-C(彩色相机)使用名为索尼 IMX174 CMOS 的 Pregius 传感器。得益于传感器和快速的 USB 3.0 接口,这些相机能够以 1920x1200 @ 162 FPS 的帧率捕捉图像。
本节中的代码已在 GS3-U3-23S6M-C 相机上进行了测试。然而,它也应该适用于其他 PGR 相机。作为一个单色相机,GS3-U3-23S6M-C 使我们能够看到传感器的分辨率和效率的完整潜力,而不需要任何颜色滤镜。
GS3-U3-23S6M-C 与大多数 PGR 相机一样,不附带镜头;而是使用标准 C 型接口来安装可互换镜头。本章后面的购物指南部分将讨论这种接口的低成本镜头示例。
GS3-U3-23S6M-C 需要 USB 3.0 接口。对于台式电脑,可以通过 PCIe 扩展卡添加 USB 3.0 接口,这可能需要花费 15 到 60 美元。PGR 销售保证与其相机兼容的 PCIe 扩展卡;然而,我也使用过其他品牌并取得了成功。
一旦我们配备了必要的硬件,我们需要获取一个名为 FlyCapture2 的应用程序来配置和测试我们的 PGR 相机。与此应用程序一起,我们将获得 FlyCapture2 SDK,这是我们 PGR 相机所有功能的完整编程接口。请访问www.ptgrey.com/support/downloads
并下载相关的安装程序。(如果您尚未注册用户账户,您将被提示注册。)在撰写本文时,相关的下载链接具有以下名称:
-
FlyCapture 2.8.3.1 SDK - Windows (64-bit)
-
FlyCapture 2.8.3.1 SDK - Windows (32-bit)
-
FlyCapture 2.8.3.1 SDK - Linux Ubuntu (64-bit)
-
FlyCapture 2.8.3.1 SDK - Linux Ubuntu (32-bit)
-
FlyCapture 2.8.3.1 SDK - ARM Hard Float
注意
PGR 不提供适用于 Mac 的应用程序或 SDK。然而,原则上,第三方应用程序或 SDK 可能能够使用 PGR 相机在 Mac 上运行,因为大多数 PGR 相机都符合 IIDC/DCAM 等标准。
对于 Windows,运行您下载的安装程序。如果不确定,当提示时选择完整安装。一个快捷方式,Point Grey FlyCap2,应该出现在您的开始菜单中。
对于 Linux,解压缩下载的存档。按照解压缩文件夹中的 README 文件中的安装说明进行操作。一个启动器,FlyCap2,应该出现在您的应用程序菜单中。
安装完成后,请插入您的 PGR 相机并打开应用程序。您应该会看到一个标题为FlyCapture2 相机选择的窗口,如下面的截图所示:
确保您的相机已选中,然后点击配置所选按钮。应该会出现另一个窗口。其标题包括相机名称,例如Point Grey Research Grasshopper3 GS3-U3-23S6M。所有相机设置都可以在这个窗口中配置。我发现相机视频模式标签页特别有用。选择它。您应该会看到有关捕获模式、像素格式、裁剪区域(称为感兴趣区域或ROI)和数据传输的选项,如下面的截图所示:
关于可用模式和其它设置的更多信息,请参阅相机的技术参考手册,可以从www.ptgrey.com/support/downloads
下载。请不要担心您可能会永久损坏任何设置;每次您拔掉相机时,它们都会重置。当您对设置满意时,点击应用并关闭窗口。现在,在相机选择窗口中,点击确定按钮。在 Linux 上,FlyCapture2 应用程序现在退出。在 Windows 上,我们应该看到一个新窗口,其标题栏中也包含相机的名称。此窗口显示实时视频流和统计数据。为了确保整个视频可见,选择菜单选项视图|拉伸以适应。现在,您应该会看到视频在窗口内以信封式显示,如下面的截图所示:
如果视频看起来损坏(例如,如果您一次看到多个帧的片段),最可能的原因是主机计算机无法以足够高的速度处理数据传输。有两种可能的方法可以解决这个问题:
-
我们可以传输更少的数据。例如,回到配置窗口的相机视频模式标签页,选择一个较小的感兴趣区域或分辨率较低的模式。
-
我们可以配置操作系统和 BIOS,将处理传入数据任务的高优先级。有关详细信息,请参阅 PGR 的以下技术应用笔记(TAN):
www.ptgrey.com/tan/10367
。
随意尝试 FlyCapture2 应用程序的其他功能,例如视频录制。完成后,关闭应用程序。
既然我们已经看到了 PGR 相机的实际应用,让我们编写自己的应用程序来以高速捕获和显示帧。它将支持 Windows 和 Linux。我们将把这个应用程序命名为 LookSpry。(“Spry”意味着敏捷、灵活或活泼,拥有这些特质的人被称为“看起来敏捷”。如果我们的高速相机应用程序是一个人,我们可能会这样描述它。)
注意
LookSpry 的源代码和构建文件可以在本书的 GitHub 仓库中找到,网址为 github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_1/LookSpry
。
与本章中我们的其他演示一样,LookSpry 可以在一个源文件 LookSpry.cpp
中实现。要开始实现,我们需要导入 C 标准库的一些功能,包括字符串格式化和计时:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
LookSpry 将使用三个额外的库:FlyCapture2 SDK(FC2)、OpenCV 和 Simple DirectMedia Layer 2(SDL2)。(SDL2 是用于编写多媒体应用的跨平台硬件抽象层。)从 OpenCV 中,我们将使用核心和 imgproc 模块进行基本的图像处理,以及 objdetect 模块进行人脸检测。在这个演示中,人脸检测的作用仅仅是展示我们可以使用高分辨率输入和高帧率执行真正的计算机视觉任务。以下是相关的导入语句:
#include <flycapture/C/FlyCapture2_C.h>
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/objdetect.hpp>
#include <SDL2/SDL.h>
注意
FC2 是闭源软件,但 PGR 相机所有者可以获得使用它的许可证。库的文档可以在安装目录中找到。
SDL2 在 zlib 许可下是开源的。库的文档可以在网上找到,网址为 wiki.libsdl.org
。
在 LookSpry 的整个过程中,我们使用一个字符串格式化函数——要么是 Microsoft Visual C 库中的 sprintf_s
,要么是标准 C 库中的 snprintf
。对于我们的目的,这两个函数是等效的。我们将使用以下宏定义,以便在 Windows 上将 snprintf
映射到 sprintf_s
:
#ifdef _WIN32
#define snprintf sprintf_s
#endif
在几个点上,应用程序在调用 FlyCapture2 或 SDL2 中的函数时可能会遇到错误。这种错误应该在对话框中显示。以下两个辅助函数从 FC2 或 SDL2 获取并显示相关的错误消息:
void showFC2Error(fc2Error error) {
if (error != FC2_ERROR_OK) {
SDL_ShowSimpleMessage(SDL_MESSAGEBOX_ERROR,
"FlyCapture2 Error",
fc2ErrorToDescription(error), NULL);
}
}
void showSDLError() {
SDL_ShowSimpleMessageBox(
SDL_MESSAGEBOX_ERROR, "SDL2 Error", SDL_GetError(), NULL);
}
LookSpry 的其余部分简单地实现在 main
函数中。在函数的开始,我们将定义几个可能需要配置的常量,包括图像捕获、人脸检测、帧率测量和显示的参数:
int main(int argc, char *argv[]) {
const unsigned int cameraIndex = 0u;
const unsigned int numImagesPerFPSMeasurement = 240u;
const int windowWidth = 1440;
const int windowHeight = 900;
const char cascadeFilename[] = "haarcascade_frontalface_alt.xml";
const double detectionScaleFactor = 1.25;
const int detectionMinNeighbours = 4;
const int detectionFlags = CV_HAAR_SCALE_IMAGE;
const cv::Size detectionMinSize(120, 120);
const cv::Size detectionMaxSize;
const cv::Scalar detectionDrawColor(255.0, 0.0, 255.0);
char strBuffer[256u];
const size_t strBufferSize = 256u;
我们将声明一个图像格式,这将帮助 OpenCV 解释捕获的图像数据。(当开始捕获图像时,将为此变量分配一个值。)我们还将声明一个 OpenCV 矩阵,它将存储捕获图像的均衡、灰度版本。声明如下:
int matType;
cv::Mat equalizedGrayMat;
注意
均衡是一种对比度调整,它使输出图像中所有亮度级别都同样常见。这种调整使主题的外观在光照变化方面更加稳定。因此,在尝试检测或识别图像中的主题(如人脸)之前,通常会对图像进行均衡。
对于人脸检测,我们将创建一个CascadeClassifier
对象(来自 OpenCV 的 objdetect 模块)。分类器加载一个级联文件,对于 Windows 系统,我们必须指定一个绝对路径;对于 Unix 系统,则指定一个相对路径。以下代码构建了路径、分类器和用于存储人脸检测结果的向量:
#ifdef _WIN32
snprintf(strBuffer, strBufferSize, "%s/../%s", argv[0], cascadeFilename);
cv::CascadeClassifier detector(strBuffer);
#else
cv::CascadeClassifier detector(cascadeFilename);
#endif
if (detector.empty()) {
snprintf(strBuffer, strBufferSize, "%s could not be loaded.",
cascadeFilename);
SDL_ShowSimpleMessageBox(
SDL_MESSAGEBOX_ERROR, "Failed to Load Cascade File", strBuffer,NULL);
return EXIT_FAILURE;
}
std::vector<cv::Rect> detectionRects;
现在,我们必须设置与 FlyCapture2 相关的一些事情。首先,以下代码创建了一个图像头,它将接收捕获的数据和元数据:
fc2Error error;
fc2Image image;
error = fc2CreateImage(&image);
if (error != FC2_ERROR_OK) {
showFC2Error(error);
return EXIT_FAILURE;
}
以下代码创建了一个 FC2 上下文,它负责查询、连接和从可用的摄像头捕获:
fc2Context context;
error = fc2CreateContext(&context);
if (error != FC2_ERROR_OK) {
showFC2Error(error);
return EXIT_FAILURE;
}
以下行使用上下文获取指定索引的摄像头的标识符:
fc2PGRGuid cameraGUID;
error = fc2GetCameraFromIndex(context, cameraIndex, &cameraGUID);
if (error != FC2_ERROR_OK) {
showFC2Error(error);
return EXIT_FAILURE;
}
我们连接到摄像头:
error = fc2Connect(context, &cameraGUID);
if (error != FC2_ERROR_OK) {
showFC2Error(error);
return EXIT_FAILURE;
}
我们通过启动捕获会话来完成 FC2 变量的初始化:
error = fc2StartCapture(context);
if (error != FC2_ERROR_OK) {
fc2Disconnect(context);
showFC2Error(error);
return EXIT_FAILURE;
}
我们使用 SDL2 也需要几个初始化步骤。首先,我们必须加载库的主模块和视频模块,如下所示:
if (SDL_Init(SDL_INIT_VIDEO) < 0) {
fc2StopCapture(context);
fc2Disconnect(context);
showSDLError();
return EXIT_FAILURE;
}
接下来,在以下代码中,我们创建了一个具有指定标题和大小的窗口:
SDL_Window *window = SDL_CreateWindow(
"LookSpry", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
windowWidth, windowHeight, 0u);
if (window == NULL) {
fc2StopCapture(context);
fc2Disconnect(context);
showSDLError();
return EXIT_FAILURE;
}
我们将创建一个渲染器,能够将纹理(图像数据)绘制到窗口表面。以下代码中的参数允许 SDL2 选择任何渲染设备和任何优化:
SDL_Renderer *renderer = SDL_CreateRenderer(window, -1, 0u);
if (renderer == NULL) {
fc2StopCapture(context);
fc2Disconnect(context);
SDL_DestroyWindow(window);
showSDLError();
return EXIT_FAILURE;
}
接下来,我们将查询渲染器以查看 SDL2 选择了哪个渲染后端。可能包括 Direct3D、OpenGL 和软件渲染。根据后端,我们可能需要请求高质量的缩放模式,以便在缩放视频时不会出现像素化。以下是查询和配置渲染器的代码:
SDL_RendererInfo rendererInfo;
SDL_GetRendererInfo(renderer, &rendererInfo);
if (strcmp(rendererInfo.name, "direct3d") == 0) {
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "best");
} else if (strcmp(rendererInfo.name, "opengl") == 0) {
SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, "linear");
}
为了向用户提供反馈,我们将在窗口标题栏中显示渲染后端的名称:
snprintf(strBuffer, strBufferSize, "LookSpry | %s",
rendererInfo.name);
SDL_SetWindowTitle(window, strBuffer);
我们将声明与每一帧渲染的图像数据相关的变量。SDL2 使用纹理作为这些数据的接口:
SDL_Texture *videoTex = NULL;
void *videoTexPixels;
int pitch;
我们还将声明与帧率测量相关的变量:
clock_t startTicks = clock();
clock_t endTicks;
unsigned int numImagesCaptured = 0u;
另外三个变量将跟踪应用程序的状态——是否应该继续运行,是否应该检测人脸,以及是否应该镜像图像(水平翻转)以进行显示。以下是相关声明:
bool running = true;
bool detecting = true;
bool mirroring = true;
现在,我们准备进入应用程序的主循环。在每次迭代中,我们轮询 SDL2 事件队列以获取任何事件。退出事件(例如,当点击窗口的关闭按钮时)会导致running
标志被清除,并在迭代结束时退出main
循环。当用户按下D或M时,detecting
或mirroring
标志将被反转。以下代码实现了事件处理逻辑:
SDL_Event event;
while (running) {
while (SDL_PollEvent(&event)) {
if (event.type == SDL_QUIT) {
running = false;
break;
} else if (event.type == SDL_KEYUP) {
switch(event.key.keysym.sym) {
// When 'd' is pressed, start or stop [d]etection.
case SDLK_d:
detecting = !detecting;
break;
// When 'm' is pressed, [m]irror or un-mirror the video.
case SDLK_m:
mirroring = !mirroring;
break;
default:
break;
}
}
}
仍然在主循环中,我们尝试从摄像头获取下一张图像。以下代码以同步方式执行此操作:
error = fc2RetrieveBuffer(context, &image);
if (error != FC2_ERROR_OK) {
fc2Disconnect(context);
SDL_DestroyTexture(videoTex);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
showFC2Error(error);
return EXIT_FAILURE;
}
小贴士
考虑到 GS3-U3-23S6M-C 和其他许多 Point Grey 相机的较高吞吐量,同步捕获在这里是合理的。图像来得如此之快,以至于我们可以预期在缓冲帧可用之前几乎没有或可以忽略不计的等待时间。因此,用户在事件处理过程中不会体验到任何可感知的延迟。然而,FC2 也提供了通过fc2SetCallbck
函数的回调异步捕获。对于低吞吐量相机,这种异步选项可能更好,在这种情况下,捕获和渲染不会在事件轮询的同一循环中发生。
如果我们刚刚捕获了应用程序这次运行的第一帧,我们仍然需要初始化几个变量;例如,纹理是NULL
。根据捕获图像的尺寸,我们可以设置均衡矩阵和渲染器(预缩放)缓冲区的大小,如下面的代码所示:
if (videoTex == NULL) {
equalizedGrayMat.create(image.rows, image.cols, CV_8UC1);
SDL_RenderSetLogicalSize(renderer, image.cols, image.rows);
根据捕获图像的像素格式,我们可以选择与 OpenCV 矩阵和 SDL2 纹理紧密匹配的格式。对于单色捕获——以及我们假设为单色的原始捕获——我们将使用单通道矩阵和 YUV 纹理(具体来说,是 Y 通道)。以下代码处理相关情况:
Uint32 videoTexPixelFormat;
switch (image.format) {
// For monochrome capture modes, plan to render captured data
// to the Y plane of a planar YUV texture.
case FC2_PIXEL_FORMAT_RAW8:
case FC2_PIXEL_FORMAT_MONO8:
videoTexPixelFormat = SDL_PIXELFORMAT_YV12;
matType = CV_8UC1;
break;
对于 YUV、RGB 或 BGR 格式的彩色捕获,我们将根据格式每像素的字节数选择匹配的纹理格式和矩阵通道数:
// For color capture modes, plan to render captured data
// to the entire space of a texture in a matching color
// format.
case FC2_PIXEL_FORMAT_422YUV8:
videoTexPixelFormat = SDL_PIXELFORMAT_UYVY;
matType = CV_8UC2;
break;
case FC2_PIXEL_FORMAT_RGB:
videoTexPixelFormat = SDL_PIXELFORMAT_RGB24;
matType = CV_8UC3;
break;
case FC2_PIXEL_FORMAT_BGR:
videoTexPixelFormat = SDL_PIXELFORMAT_BGR24;
matType = CV_8UC3;
break;
一些捕获格式,包括每通道 16 bpp 的格式,目前在 LookSpry 中不受支持,被视为失败案例,如下面的代码所示:
default:
fc2StopCapture(context);
fc2Disconnect(context);
SDL_DestroyTexture(videoTex);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
SDL_ShowSimpleMessageBox(
SDL_MESSAGEBOX_ERROR,
"Unsupported FlyCapture2 Pixel Format",
"LookSpry supports RAW8, MONO8, 422YUV8, RGB, and BGR.",
NULL);
return EXIT_FAILURE;
}
我们将创建一个具有给定格式和与捕获图像相同大小的纹理:
videoTex = SDL_CreateTexture(
renderer, videoTexPixelFormat, SDL_TEXTUREACCESS_STREAMING,
image.cols, image.rows);
if (videoTex == NULL) {
fc2StopCapture(context);
fc2Disconnect(context);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
showSDLError();
return EXIT_FAILURE;
}
使用以下代码,让我们更新窗口标题栏以显示捕获图像和渲染图像的像素尺寸,以像素为单位:
snprintf(
strBuffer, strBufferSize, "LookSpry | %s | %dx%d --> %dx%d",
rendererInfo.name, image.cols, image.rows, windowWidth,
windowHeight);
SDL_SetWindowTitle(window, strBuffer);
}
接下来,如果应用程序处于人脸检测模式,我们将图像转换为均衡的灰度版本,如下面的代码所示:
cv::Mat srcMat(image.rows, image.cols, matType, image.pData,
image.stride);
if (detecting) {
switch (image.format) {
// For monochrome capture modes, just equalize.
case FC2_PIXEL_FORMAT_RAW8:
case FC2_PIXEL_FORMAT_MONO8:
cv::equalizeHist(srcMat, equalizedGrayMat);
break;
// For color capture modes, convert to gray and equalize.
cv::cvtColor(srcMat, equalizedGrayMat,
cv::COLOR_YUV2GRAY_UYVY);
cv::equalizeHist(equalizedGrayMat, equalizedGrayMat);
break;
case FC2_PIXEL_FORMAT_RGB:
cv::cvtColor(srcMat, equalizedGrayMat, cv::COLOR_RGB2GRAY);
cv::equalizeHist(equalizedGrayMat, equalizedGrayMat);
break;
case FC2_PIXEL_FORMAT_BGR:
cv::cvtColor(srcMat, equalizedGrayMat, cv::COLOR_BGR2GRAY);
cv::equalizeHist(equalizedGrayMat, equalizedGrayMat);
break;
default:
break;
}
我们将在均衡图像上执行人脸检测。然后,在原始图像中,我们将围绕任何检测到的人脸绘制矩形:
// Run the detector on the equalized image.
detector.detectMultiScale(
equalizedGrayMat, detectionRects, detectionScaleFactor,
detectionMinNeighbours, detectionFlags, detectionMinSize,
detectionMaxSize);
// Draw the resulting detection rectangles on the original image.
for (cv::Rect detectionRect : detectionRects) {
cv::rectangle(srcMat, detectionRect, detectionDrawColor);
}
}
在这个阶段,我们已经完成了这一帧的计算机视觉任务,需要考虑我们的输出任务。图像数据将被复制到纹理中,然后进行渲染。首先,我们将锁定纹理,这意味着我们将获得对其内存的写入访问权。这通过以下 SDL2 函数调用完成:
SDL_LockTexture(videoTex, NULL, &videoTexPixels, &pitch);
记住,如果相机处于单色捕获模式(或我们假设为单色的原始模式),我们正在使用 YUV 纹理。我们需要用中间值 128 填充 U 和 V 通道,以确保纹理是灰度的。以下代码通过使用 C 标准库中的memset
函数有效地完成此操作:
switch (image.format) {
case FC2_PIXEL_FORMAT_RAW8:
case FC2_PIXEL_FORMAT_MONO8:
// Make the planar YUV video gray by setting all bytes in its U
// and V planes to 128 (the middle of the range).
memset(((unsigned char *)videoTexPixels + image.dataSize), 128,
image.dataSize / 2u);
break;
default:
break;
}
现在,我们已经准备好将图像数据复制到纹理中。如果设置了mirroring
标志,我们将同时复制和镜像数据。为了高效地完成这项任务,我们将目标数组包装在 OpenCV 的Mat
中,然后使用 OpenCV 的flip
函数同时翻转和复制数据。如果未设置mirroring
标志,我们将简单地使用标准的 C memcpy
函数复制数据。以下代码实现了这两种替代方案:
if (mirroring) {
// Flip the image data while copying it to the texture.
cv::Mat dstMat(image.rows, image.cols, matType, videoTexPixels,
image.stride);
cv::flip(srcMat, dstMat, 1);
} else {
// Copy the image data, as-is, to the texture.
// Note that the PointGrey image and srcMat have pointers to the
// same data, so the following code does reference the data that
// we modified earlier via srcMat.
memcpy(videoTexPixels, image.pData, image.dataSize);
}
提示
通常,memcpy
函数(来自 C 标准库)编译为块传输指令,这意味着它为复制大型数组提供了最佳可能的硬件加速。然而,它不支持在复制过程中对数据进行修改或重新排序。David Nadeau 的一篇文章对memcpy
与四种其他复制技术进行了基准测试,每种技术使用四个编译器,可以在以下位置找到:nadeausoftware.com/articles/2012/05/c_c_tip_how_copy_memory_quickly
。
现在我们已经将帧的数据写入纹理,我们将解锁纹理(可能会将数据上传到 GPU),并告诉渲染器渲染它:
SDL_UnlockTexture(videoTex);
SDL_RenderCopy(renderer, videoTex, NULL, NULL);
SDL_RenderPresent(renderer);
在指定数量的帧之后,我们将更新我们的 FPS 测量值,并在窗口标题栏中显示它,如下面的代码所示:
numImagesCaptured++;
if (numImagesCaptured >= numImagesPerFPSMeasurement) {
endTicks = clock();
snprintf(
strBuffer, strBufferSize,
"LookSpry | %s | %dx%d --> %dx%d | %ld FPS",
rendererInfo.name, image.cols, image.rows, windowWidth,
windowHeight,
numImagesCaptured * CLOCKS_PER_SEC /
(endTicks - startTicks));
SDL_SetWindowTitle(window, strBuffer);
startTicks = endTicks;
numImagesCaptured = 0u;
}
}
应用程序的主循环中没有其他内容。一旦循环结束(由于用户关闭窗口),我们将清理 FC2 和 SDL2 资源并退出:
fc2StopCapture(context);
fc2Disconnect(context);
SDL_DestroyTexture(videoTex);
SDL_DestroyRenderer(renderer);
SDL_DestroyWindow(window);
return EXIT_SUCCESS;
}
在 Windows 上,可以在 Visual Studio 中将 LookSpry 构建为 Visual C++ Win32 控制台项目。请记住,右键单击项目并编辑其项目属性,以便C++ | 通用 | 附加包含目录列出 OpenCV 的、FlyCapture 2 的以及 SDL 2 的include
目录的路径。同样,编辑链接器 | 输入 | 附加依赖项,以便它列出opencv_core300.lib
、opencv_imgproc300.lib
和opencv_objdetect300.lib
(或 3.0.0 以外的其他 OpenCV 版本的类似命名的lib
文件)以及FlyCapture2_C.lib
、SDL2.lib
和SDL2main.lib
的路径。最后,确保 OpenCV 的dll
文件在系统的Path
中。
在 Linux 上,以下终端命令应该可以成功构建 LookSpry:
$ g++ LookSpry.cpp -o LookSpry `sdl2-config --cflags --libs` \
-lflycapture-c -lopencv_core -lopencv_imgproc -lopencv_objdetect
确保 GS3-U3-23S6M-C 相机(或另一个 PGR 相机)已连接,并且已使用 FlyCap2 GUI 应用程序正确配置。请记住,每次拔掉相机时都会重置配置。
注意
FlyCap2 GUI 应用程序中的所有相机设置也可以通过 FlyCapture2 SDK 进行编程设置。请参阅 SDK 的官方文档和示例。
当你对相机的配置满意时,关闭 FlyCap2 GUI 应用程序并运行 LookSpry。通过按M键取消镜像或镜像视频,按D键停止或重新启动检测来尝试不同的图像处理模式。每种模式下每秒处理多少帧?检测模式中的帧率如何受人脸数量的影响?
希望你已经观察到,在某些或所有模式下,LookSpry 处理帧的速度比典型显示器的 60Hz 刷新率快得多。如果我们在一个高质量的 144Hz 游戏显示器上观看实时视频,它看起来会更加平滑。然而,即使刷新率是瓶颈,我们仍然可以欣赏到这种实时视频的低延迟或响应速度。
由于 GS3-U3-23S6M-C 和其他 PGR 相机使用可互换的 C 口镜头,我们现在应该学习如何承担购买镜头的重大责任!
玻璃的采购
没有什么能比得上一件经过多年岁月考验且因有人呵护而继续闪耀的精心制作的玻璃制品。在我家的壁炉架上,我父母有一些这样的纪念品。其中之一是一朵小小的彩色玻璃花,它来自巴黎的一个购物中心,我和哥哥在我们第一次离家和家人的旅行中在那里吃了许多便宜但美味的饭菜。其他物品比我记得还要早。
我使用的一些二手镜头已有 30 或 40 年的历史;它们的制造国家已不复存在;然而,它们的玻璃和涂层仍然处于完美状态。我喜欢想这些镜头通过为前任主人拍摄许多精美的照片而赢得了如此好的照顾,也许它们在 40 年后仍然能为其他人拍摄精美的照片。
玻璃持久耐用。镜头设计也是如此。例如,蔡司 Planar T*镜头,这个行业中最受尊敬的品牌之一,其光学设计始于 19 世纪 90 年代,涂层工艺始于 20 世纪 30 年代。镜头增加了电子和电动组件以支持自动曝光、自动对焦和图像稳定。然而,光学和涂层的进化相对缓慢。从机械角度来看,许多老镜头都非常出色。
小贴士
色差和球差
理想镜头会使所有入射光线汇聚到一个焦点。然而,实际镜头存在像差,光线不会精确地汇聚到同一个点。如果不同颜色或波长的光线汇聚到不同的点,镜头就会产生色差,这在图像中表现为高对比度边缘周围的彩色光环。如果来自镜头中心和边缘的光线汇聚到不同的点,镜头就会产生球差,这在图像中表现为在失焦区域高对比度边缘周围的明亮光环。
从 20 世纪 70 年代开始,新的制造技术使得高端镜头在色差和球差方面的校正更好。阿波罗色差或“APO”镜头使用高折射率材料(通常是稀土元素)来校正色差。非球面或“ASPH”镜头使用复杂的曲线来校正球差。这些校正镜头通常更贵,但你应该留意它们,因为它们有时可能以优惠价格出现。
由于景深较浅,大光圈会产生最明显的像差。即使是非 APO 和非 ASPH 镜头,在大多数光圈设置和大多数场景下,像差可能微不足道。
通过一些讨价还价的技巧,我们可以用一只新镜头的价格找到六只 20 世纪 60 年代到 80 年代的好镜头。此外,即使是最近推出的镜头型号,在二手市场上也可能以 50%的折扣出售。在讨论具体优惠的例子之前,让我们考虑在购买任何二手镜头时可能遵循的五个步骤:
-
理解需求。正如我们在“捕捉瞬间的主题”部分之前讨论的那样,镜头和相机的许多参数相互作用,以确定我们是否能够拍摄到清晰及时的照片。至少,我们应该考虑适当的焦距、光圈数或 T 数,以及针对特定应用和相机的最近对焦距离。我们还应该尝试评估高分辨率和低畸变对特定应用的重要性。光的波长也很重要。例如,如果镜头针对可见光进行了优化(如大多数镜头那样),它可能并不一定高效地传输近红外光。
-
研究供应。如果你住在大城市,也许当地商家有大量价格低廉的二手镜头。否则,通常可以在 eBay 等拍卖网站上找到最低价格。根据我们在第一步中定义的需求进行搜索,例如,如果我们正在寻找 100mm 焦距和 2.8 的光圈数或 T 数,就搜索“100 2.8 镜头”。你找到的一些镜头可能没有与你的相机相同的类型。检查是否可以提供适配器。适配镜头通常是一个经济的选择,尤其是在长焦镜头的情况下,它们往往不会为小感光器的相机大量生产。创建一个似乎以有吸引力的价格满足要求的可用镜头型号的简短列表。
-
学习镜头模型。在线上,用户们发布了详细的规格、样本图像、测试数据、比较和意见吗?MFlenses (
www.mflenses.com/
) 是关于旧式手动对焦镜头的极好信息来源。它提供了许多评论和一个活跃的论坛。像 Flickr (www.flickr.com/
) 这样的图片和视频托管网站也是寻找旧式和特殊镜头(包括电影镜头、夜视仪等)评论和样本输出的好地方!了解每种镜头模型在其制造年份中的变化。例如,某个镜头的早期版本可能是单层涂层的,而较新的版本则可能是多层涂层,以提高透射率、对比度和耐用性。 -
选择状况良好的物品。镜头应无霉斑或雾气。最好是没有划痕或清洁痕迹(涂层上的污点),尽管如果损坏在前镜片元素(离相机最远的元素)上,对图像质量的影响可能很小。
镜头内部有一点灰尘不应影响图像质量。光圈和聚焦机制应平滑移动,最好是没有油光圈叶片。
-
参与竞标,出价,或者以卖家的价格购买。如果你认为竞标或谈判的价格过高,就为另一笔交易保存你的钱。记住,尽管卖家可能会告诉你,大多数廉价物品并不罕见,大多数廉价价格将会再次出现。继续寻找!
一些品牌因卓越的品质而享有持久的声誉。在 1860 年至 1930 年之间,卡尔·蔡司、莱卡和施耐德·克鲁斯纳赫等德国制造商巩固了他们作为优质光学产品创造者的名声。(施耐德·克鲁斯纳赫以电影镜头最为知名。)其他值得尊敬的欧洲品牌包括瑞士的 Alpa 和法国的 Angéniuex,它们都以其电影镜头最为知名。到 1950 年代,尼康开始获得认可,成为第一个能够与德国镜头质量相媲美的日本制造商。随后,富士和佳能成为电影和摄影相机高端镜头制造商。
尽管 Willy Loman(来自亚瑟·米勒的戏剧《推销员的死亡》)可能会建议我们购买“一个广受欢迎的机器”,但这并不一定是最好的交易。假设我们购买镜头是为了其实用价值而不是收藏价值,如果我们发现量产的无品牌镜头质量优秀,我们会很高兴。一些镜头相当接近这个理想。
东德和苏联大量生产了用于照相机、电影摄像机、投影仪、显微镜、夜视仪和其他设备的优质镜头。光学在从潜艇到宇宙飞船等重大项目中也同样重要!东德制造商包括卡尔·蔡司耶拿、迈耶和潘塔孔。苏联(后来是俄罗斯、乌克兰和白俄罗斯)制造商包括 KMZ、BelOMO、KOMZ、沃洛格达、LOMO、阿森纳等许多其他公司。通常,镜头设计和制造工艺在东欧集团的多家制造商中被复制和修改,赋予了这个地区和这个时代的镜头一个可识别的特征。
备注
东欧集团的一些镜头没有制造商的名称,因此它们是无名商品。一些型号,为了出口,只是标有“制造于苏联”或“aus JENA”(来自东德耶拿)。然而,大多数苏联镜头都带有符号和序列号,这些符号和序列号编码了制造地点和日期。有关这些标记的目录及其历史意义的描述,请参阅 Nathan Dayton 的文章“尝试消除迷雾”,见www.commiecameras.com/sov/
。
一些不太知名的日本品牌也倾向于是廉价的选择。例如,宾得制造出好的镜头,但它从未像其竞争对手那样享有同样的高端地位。公司的老款摄影镜头采用 M42 接口,这些镜头在低价位上特别丰富。此外,寻找公司的 C 接口电影镜头,这些镜头以前被品牌为 Cosmicar。
让我们看看如何将一些廉价镜头与 GS3-U3-23S6M-C 配合使用,以产生优质图像。记住,GS3-U3-23S6M-C 具有 C 接口和 1/1.2 英寸格式的传感器。对于 C 接口和 CS 接口的相机,传感器格式的名称并不指代传感器实际的任何尺寸!相反,由于历史原因,如 1/1.2 英寸这样的测量值指的是如果视频相机仍然使用真空管,那么真空管的直径!以下表格列出了常见传感器格式与传感器实际尺寸之间的转换:
格式名称 | 典型镜头接口 | 典型用途 | 对角线(毫米) | 宽度(毫米) | 高度(毫米) | 宽高比 |
---|---|---|---|---|---|---|
1/4 英寸 | CS | 机械视觉 | 4.0 | 3.2 | 2.4 | 4:3 |
1/3 英寸 | CS | 机械视觉 | 6.0 | 4.8 | 3.6 | 4:3 |
1/2.5 英寸 | C | 机械视觉 | 6.4 | 5.1 | 3.8 | 4:3 |
1/2 英寸 | C | 机械视觉 | 8.0 | 6.4 | 4.8 | 4:3 |
1/1.8 英寸 | C | 机械视觉 | 9.0 | 7.2 | 5.4 | 4:3 |
2/3 英寸 | C | 机械视觉 | 11.0 | 8.8 | 6.6 | 4:3 |
16 毫米 | C | 电影 | 12.7 | 10.3 | 7.5 | 4:3 |
1/1.2 英寸 | C | 机械视觉 | 13.3 | 10.7 | 8.0 | 4:3 |
超级 16 毫米 | C | 电影 | 14.6 | 12.5 | 7.4 | 5:3 |
1 英寸 | C | 机械视觉 | 16.0 | 12.8 | 9.6 | 4:3 |
四第三 | 四第三微四第三 | 摄影 | 电影 | 21.6 | 17.3 | 13.0 |
APS-C | 诸如尼康 F 等各种专有 | 摄影 | 27.3 | 22.7 | 15.1 | 3:2 |
35mm("全画幅") | M42 各种专有型号,如尼康 F | 摄影 | 43.3 | 36.0 | 24.0 | 3:2 |
注意
有关机器视觉相机中典型传感器尺寸的更多信息,请参阅 Vision-Doctor.co.uk 的以下文章:www.vision-doctor.co.uk/camera-technology-basics/sensor-and-pixel-sizes.html
。
镜头投射出圆形图像,需要足够大的直径以覆盖传感器的对角线。否则,捕获的图像将出现暗角,意味着角落会模糊且暗。例如,在 Janet Howse 的画作《雪猴》的以下图像中,暗角很明显。该图像使用 1/1.2"传感器捕获,但镜头是为 1/2"传感器设计的,因此图像模糊且暗,除了中心的一个圆形区域:
为一个格式设计的镜头可以覆盖任何大小相似或更小的格式,而不会出现暗角;然而,我们可能需要一个适配器来安装镜头。系统的对角线视场角(以度为单位),取决于传感器的对角线大小和镜头的焦距,根据以下公式:
diagonalFOVDegrees =
2 * atan(0.5 * sensorDiagonal / focalLength) * 180/pi
例如,如果一个镜头的焦距与传感器的对角线相同,其对角线视场角为 53.1 度。这样的镜头被称为标准镜头(相对于给定的传感器格式),53.1 度的视场角既不算宽也不算窄。
咨询上表,我们看到 1/1.2"格式与 16mm 和 Super 16 格式相似。因此,GS3-U3-23S6M-C(以及类似相机)应该能够使用大多数 C 口电影镜头而不会出现暗角,无需适配器,并且具有与镜头设计师意图相近的视场角。
假设我们想要一个窄视场角来拍摄远处的物体。我们可能需要一个长焦距,这在 C 口并不常见。比较对角线大小,注意 35mm 格式比 1/1.2"格式大 3.25 倍。当安装到 1/1.2"格式时,35mm 格式的标准镜头变成了具有 17.5 度视场角的长焦镜头!一个 M42 到 C 口的适配器可能需要 30 美元,并让我们可以使用大量的镜头!
假设我们想要拍摄近距离的物体,但我们的镜头都无法足够接近地聚焦。这个问题有一个低技术含量的解决方案——延长管,它可以增加镜头和相机之间的距离。对于 C 口,一套不同长度的几个延长管可能需要 30 美元。当镜头的总延伸(从管子加上其内置对焦机构)等于焦距时,物体以 1:1 的放大率投影到传感器上。例如,一个 13.3mm 的物体将填满 1/1.2"传感器 13.3mm 的对角线。以下公式成立:
magnificationRatio = totalExtension / focalLength
然而,为了获得高放大倍数,主题必须非常靠近前镜片元件才能对焦。有时,使用延伸筒会创建一个不切实际的光学系统,甚至无法对焦到其前镜片元件那么远!其他时候,主题的一部分和镜头外壳(如内置镜头遮光罩)可能会相互碰撞。可能需要进行一些实验,以确定镜头延伸的实际极限。
以下表格列出了我为 GS3-U3-23S6M-C 最近购买的某些镜头和镜头配件:
名称 | 价格 | 产地 | 座位和预期格式 | 焦距(mm) | 1/1.2"传感器视野(度) | 最大 f 数或 T 数 |
---|---|---|---|---|---|---|
Cosmicar TV 镜头 12.5mm 1:1.8 | $41 | 日本 1980 年代? | CSuper 16 | 12.5 | 56.1 | T/1.8 |
Vega-73 | $35 | LZOS 工厂利特卡里诺,苏联 1984 | CSuper 16 | 20.0 | 36.9 | T/2.0 |
Jupiter-11A | $50 | KOMZ 工厂喀山,苏联 1975 | M4235mm | 135.0 | 5.7 | f/4.0 |
C 座延伸筒:10mm、20mm 和 40mm | $30 | 日本新品 | C | - | - | - |
Fotodiox M42 to C 适配器 | $30 | 美国新品 | M42 to C | - | - | - |
为了比较,请考虑以下表格,其中给出了专门为机器视觉设计的镜头的示例:
名称 | 价格 | 产地 | 座位和预期格式 | 焦距(mm) | 1/1.2"传感器视野(度) | 最大 f 数或 T 数 |
---|---|---|---|---|---|---|
Fujinon CF12.5HA-1 | $268 | 日本新品 | C1" | 12.5 | 56.1 | T/1.4 |
Fujinon CF25HA-1 | $270 | 日本新品 | C1" | 25.0 | 29.9 | T/1.4 |
Fujinon CF50HA-1 | $325 | 日本新品 | C1" | 50.0 | 15.2 | T/1.8 |
Fujinon CF75HA-1 | $320 | 日本新品 | C1" | 75.0 | 10.2 | T/1.4 |
备注
上述表格中的价格来自 B&H(www.bhphotovideo.com
),它是美国的主要摄影和视频供应商。它提供了一系列机器视觉镜头,通常比工业供应商列出的价格低。
这些新的机器视觉镜头可能非常出色。我还没有测试它们。然而,让我们看看我们选择的旧、二手、摄影和电影镜头的一些样本照片,它们的成本比这低六倍或更多。所有图像都是使用 FlyCapture2 以 1920x1200 的分辨率捕获的,但它们被调整大小以包含在这本书中。
首先,让我们尝试 Cosmicar 12.5mm 镜头。对于这种广角镜头,一个有许多直线的场景可以帮助我们判断畸变程度。以下样本照片显示了一个有许多书架的阅览室:
为了更好地看到细节,例如书籍背面的文本,请查看以下从图像中心裁剪的 480x480 像素(原始宽度的 25%)的图像:
以下图像是我眼睛血管的特写,使用 Vega-73 镜头和 10mm 延伸筒捕获:
再次强调,细节可以在中心 480x480 的裁剪中更好地欣赏,如下所示:
最后,让我们用 Jupiter-11A 捕捉一个远处的物体。这是在晴朗的夜晚的月亮:
再次,让我们检查图像中心的 480x480 裁剪,以了解镜头捕捉到的细节水平:
到目前为止,我们关于廉价镜头的讨论已经跨越了许多年代,从这里到月亮的距离!我鼓励你进一步探索。在给定的预算内,你能组装出哪些最有趣和功能强大的光学系统?
摘要
本章为我们提供了一个机会来考虑计算机视觉系统在硬件和软件层面的输入。此外,我们还解决了从输入阶段到处理和输出阶段高效调度图像数据的需求。我们的具体成就包括以下内容:
-
理解不同波长的光和辐射之间的差异
-
理解传感器、镜头和图像的各种特性
-
使用 PlayStation Eye 相机和 OpenCV 的视频 io 模块捕捉和记录慢动作视频
-
使用 ASUS Xtion PRO Live 相机和 OpenNI2 以及 OpenCV 的高 gui 模块比较可见光和近红外光下物体的外观
-
使用 FlyCapture2、OpenCV、SDL2 和 GS3-U3-23S6M-C 相机在高速捕捉人脸的同时捕捉和渲染高清视频
-
购买便宜、旧、二手的镜头,它们出奇地好!
接下来,在第二章《使用自动相机拍摄自然和野生动物》中,我们将随着构建一个捕捉和编辑自然环境纪录片片段的智能相机,加深我们对高质量图像的欣赏!
第二章. 使用自动相机拍摄自然和野生动物
国家地理以其野生动物的亲密照片而闻名。在杂志的页面上,动物常常显得比生命还要大,仿佛它们属于与背后的风景相同的“地理”尺度。这种放大效果可以通过使用广角镜头在非常近的距离上捕捉主题来实现。例如,史蒂夫·温特拍摄的一张难忘的照片显示了一只咆哮的老虎伸出爪子去击打镜头!
让我们考虑实现这种照片的可能方法。摄影师可以亲自跟踪一只野生老虎,但出于安全考虑,这种方法需要一定的距离和长焦镜头。近距离接触可能会危及人类、老虎或两者。或者,摄影师可以使用遥控车或无人机接近并拍摄老虎。这会更安全,但就像第一种技术一样,它很费力,一次只能覆盖一个地点,并且可能会因为吸引动物的注意而破坏即兴或自然照片的机会。最后,摄影师可以在老虎可能访问的多个地点部署隐蔽和自动化的相机,称为相机陷阱。
本章将探讨编程相机陷阱的技术。也许我们不会捕捉到任何老虎,但总会有东西走进我们的陷阱!
尽管名字叫“陷阱”,但相机陷阱实际上并没有物理上“捕捉”任何东西。它只是在触发器被触发时捕捉照片。不同的相机陷阱可能使用不同的触发器,但在我们的案例中,触发器将是一个对运动、颜色或某些类别的物体敏感的计算机视觉系统。我们系统的软件组件将包括 OpenCV 3、Python 脚本、shell 脚本以及一个名为 gPhoto2 的相机控制工具。在构建我们的系统时,我们将解决以下问题:
-
我们如何从主机计算机配置和触发照片相机?
-
我们如何检测具有摄影价值的主题的存在?
-
我们如何捕捉和处理一个主题的多个照片以创建一个有效的合成图像或视频?
注意
本章项目的所有脚本和数据都可以在本书的 GitHub 仓库中找到,网址为github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_2/CameraTrap
。
本章将重点介绍适用于类 Unix 系统(包括 Linux 和 Mac)的技术。我们假设用户最终将在低成本、低功耗的单板计算机(SBCs)上部署我们的相机陷阱,这些计算机通常运行 Linux 操作系统。一个好的例子是 Raspberry Pi 2 硬件,它通常运行 Raspbian 版本的 Linux。
让我们从我们的软件在图像捕获之前、期间和之后将执行的一些简单任务概述开始。
规划相机陷阱
我们将使用一台配备两个摄像头的计算机来设置相机陷阱。一个摄像头将连续捕捉低分辨率图像。例如,这个第一个摄像头可能是一个普通的网络摄像头。我们的软件将分析低分辨率图像以检测主体的存在。我们将探索基于运动、颜色和物体分类的三个基本检测技术。当检测到主体时,第二个摄像头将激活以捕捉和保存一系列有限的高分辨率图像。这个第二个摄像头将是一个专用的数码相机,拥有自己的电池和存储。我们不一定以最快的速度分析和记录图像;相反,我们将注意节约主机计算机的资源以及数码相机的电池功率和存储,以便我们的相机陷阱可以长时间运行。
可选地,我们的软件将为数码相机配置曝光分级。这意味着系列中的一些照片将被故意曝光不足,而其他照片将被过度曝光。稍后,我们将从相机上传照片到主机计算机,并合并曝光以产生高动态范围(HDR)图像。这意味着合并后的照片将在比任何单一曝光能够捕捉的更广泛的阴影、中色调和高光范围内展示精细的细节和饱和的颜色。例如,以下阵容说明了曝光不足(左侧)、过度曝光(右侧)和合并的 HDR 照片(中间):
HDR 成像在风景摄影中尤为重要。通常,天空比地面亮得多,但我们希望驯服这种对比度,以便在这两个区域都获得饱和的中色调颜色,而不是白色的无特征天空或黑色的无特征土地。我们还将探索将一系列图像转换为时间流逝视频的技术。
注意,本项目中的两个摄像头满足不同的需求。网络摄像头提供用于实时处理的图像流,而数码相机存储用于后期高质量处理的图像。考虑以下比较表:
功能 | 典型网络摄像头 | 典型数码相机 | 高端工业相机 |
---|---|---|---|
价格 | 低 | 中等 | 高 |
功耗 | 低 | 高(但有自己的电池) | 中等 |
配置选项 | 少 | 多 | 多 |
延迟 | 低 | 高 | 低 |
分辨率 | 低 | 非常高 | 高 |
耐用性 | 差 | 好 | 一般 |
可能,高端工业相机可以同时满足实时成像和高质量成像的双重目的。然而,一个网络摄像头和数码相机的组合可能更便宜。考虑以下例子:
名称 | 目的 | 传感器格式 | 最高分辨率模式 | 接口 | 价格 |
---|---|---|---|---|---|
Point Grey Research Grasshopper 3 GS3-U3-120S6M-C | 工业相机 | 1" | 4242x2830 @ 7 FPS | USB 3.0 | $3,700(新品) |
卡尔·蔡司耶拿 DDR Tevidon 10mm f/2 镜头 | 工业相机镜头 | 1" | 清晰,适合高分辨率 | 300 美元(二手) | |
尼康 1 J5 配 10-30mm PD-ZOOM 镜头 | 照相机和镜头 | 1" | 5568x3712 @ 20 FPS | USB 2.0 | 500 美元(新品) |
Odroid USB-Cam 720p | Webcam | 1/4" | 1280x720 @ 30 FPS | USB 2.0 | 20 美元(新品) |
在这里,工业相机和镜头的成本是照相机、镜头和摄像头的八倍,但照相机应该提供最佳图像质量。尽管照相机具有 5568x3712 @ 20 FPS 的 捕获模式,请注意,其 USB 2.0 接口速度过慢,无法支持作为 传输模式。在列出的分辨率和速率下,照相机只能将图像记录到其本地存储。
对于我们的目的,照相机的主要弱点是高延迟。延迟不仅涉及电子设备,还包括移动的机械部件。为了减轻问题,我们可以采取以下步骤:
-
使用比照相机视角略宽的摄像头。这样,相机陷阱可以提前检测到主题,并为照相机提供更多时间进行第一次拍摄。
-
将照相机置于手动对焦模式,并将焦点设置在您计划拍摄主题的距离。手动对焦更快、更安静,因为自动对焦电机不会运行。
-
如果您使用的是 数码单反相机(DSLR),请将其置于 镜锁(MLU)模式(如果支持 MLU)。如果没有 MLU,反射镜(将光线反射到光学取景器)必须在每次拍摄之前移出光学路径。使用 MLU 时,反射镜已经移开(但光学取景器已禁用)。MLU 更快、更安静、振动更小,因为反射镜不会移动。在某些相机上,MLU 被称为 实时视图,因为当光学取景器禁用时,数字(实时)取景器可能会被激活。
控制照相机是这个项目的重要组成部分。一旦你学会了编写摄影命令脚本,也许你将开始以新的方式思考摄影——因为它是一个过程,而不仅仅是快门落下的那一刻。现在让我们将注意力转向这个脚本主题。
使用 gPhoto2 控制 photo camera
gPhoto2 是一个开源、厂商中立的 Unix-like 系统(如 Linux 和 Mac)相机控制工具。它支持多个品牌的相机,包括佳能、尼康、奥林巴斯、宾得、索尼和富士。支持的功能因型号而异。以下表格列出了 gPhoto2 的主要功能,以及每个功能支持的官方相机数量:
功能 | 支持的设备数量 | 描述 |
---|---|---|
文件传输 | 2105 | 在设备和设备之间传输文件 |
图像捕捉 | 489 | 使设备捕获图像到其本地存储 |
配置 | 428 | 更改设备的设置,例如快门速度 |
直播预览 | 309 | 从设备持续抓取实时视频帧 |
这些数字截至版本 2.5.8,是保守的。例如,一些配置功能在尼康 D80 上得到支持,尽管 gPhoto2 文档没有列出此相机为可配置。就我们的目的而言,图像捕获和配置是必需的功能,因此 gPhoto2 至少支持 428 款相机,也许还有更多。这个数字包括各种类型的相机,从便携式紧凑型到专业单反相机。
注意
要检查 gPhoto2 的最新版本是否官方支持特定相机的功能,请查看官方列表www.gphoto.org/proj/libgphoto2/support.php
。
通常,gPhoto2 通过 USB 使用名为图片传输协议(PTP)的协议与相机通信。在继续之前,请检查您的相机是否有关于 PTP 模式的说明。您可能需要更改相机上的设置,以确保主机计算机将其视为 PTP 设备而不是 USB 存储设备。例如,在许多尼康相机上,必须选择设置菜单 | USB | PTP,如下面的图片所示:
此外,如果相机作为磁盘驱动器挂载,gPhoto2 无法与其通信。这有点问题,因为大多数操作系统都会自动将相机作为磁盘驱动器挂载,无论相机是否处于 PTP 模式。因此,在我们继续安装和使用 gPhoto2 之前,让我们看看如何通过编程方式卸载相机驱动器。
编写卸载相机驱动器的 shell 脚本
在 Mac 上,一个名为 PTPCamera 的进程负责代表 iPhoto 等应用程序挂载和控制相机。连接相机后,我们可以在终端中运行以下命令来终止 PTPCamera:
$ killall PTPCamera
然后,相机将可用于接收来自 gPhoto2 的命令。然而,请继续阅读,因为我们想编写支持 Linux 的代码!
在大多数桌面 Linux 系统上,当相机连接时,它将被挂载为Gnome 虚拟文件系统(GVFS)卷。我们可以在终端中运行以下命令来列出挂载的 GVFS 卷:
$ gvfs-mount -l
例如,此命令在连接了尼康 D80 相机的 MacBook Pro 笔记本电脑(通过 USB 连接)的 Ubuntu 上产生以下输出:
Drive(0): APPLE SSD SM1024F
Type: GProxyDrive (GProxyVolumeMonitorUDisks2)
Volume(0): Recovery HD
Type: GProxyVolume (GProxyVolumeMonitorUDisks2)
Volume(1): Macintosh HD
Type: GProxyVolume (GProxyVolumeMonitorUDisks2)
Drive(1): APPLE SD Card Reader
Type: GProxyDrive (GProxyVolumeMonitorUDisks2)
Volume(0): NIKON DSC D80
Type: GProxyVolume (GProxyVolumeMonitorGPhoto2)
Mount(0): NIKON DSC D80 -> gphoto2://[usb:001,007]/
Type: GProxyShadowMount (GProxyVolumeMonitorGPhoto2)
Mount(1): NIKON DSC D80 -> gphoto2://[usb:001,007]/
Type: GDaemonMount
注意,输出包括相机的挂载点,在本例中为gphoto2://[usb:001,007]/
。对于相机驱动器,GVFS 挂载点始终以gphoto2://
开头。我们可以通过运行如下命令来卸载相机驱动器:
$ gvfs-mount –u gphoto2://[usb:001,007]/
现在,如果我们再次运行gvfs-mount -l
,我们应该会看到相机不再列出。因此,它已卸载,应该可以接收来自 gPhoto2 的命令。
小贴士
或者,文件浏览器(如 Nautilus)将显示挂载的相机驱动器,并提供卸载它们的 GUI 控制。然而,作为程序员,我们更喜欢 shell 命令,因为它们更容易自动化。
我们需要在每次插入相机时都卸载相机。为了简化这个过程,让我们编写一个支持多个操作系统(Mac 或任何带有 GVFS 的 Linux 系统)和多个相机的 Bash shell 脚本。创建一个名为 unmount_cameras.sh
的文件,并填充以下 Bash 代码:
#!/usr/bin/env bash
if [ "$(uname)" == "Darwin" ]; then
killall PTPCamera
else
mounted_cameras=`gvfs-mount -l | grep -Po 'gphoto2://.*/' | uniq`
for mounted_camera in $mounted_cameras; do
gvfs-mount -u $mounted_camera
done
fi
注意,此脚本检查操作系统的家族(其中 "Darwin"
是 Mac 的家族)。在 Mac 上,它运行 killall PTPCamera
。在其他系统上,它使用 gvfs-mount
、grep
和 uniq
命令的组合来查找每个唯一的 gphoto2://
挂载点,然后卸载所有相机。
让我们通过运行以下命令给脚本赋予“可执行”权限:
$ chmod +x unmount_cameras.sh
我们随时可以通过执行脚本来确保相机驱动器已卸载:
$ ./unmount_cameras.sh
现在,我们有一个标准的方式来使相机可用,因此我们准备安装和使用 gPhoto2。
设置和测试 gPhoto2
gPhoto2 及相关库在 Unix-like 系统的开源软件仓库中广泛可用。这并不奇怪——连接到数码相机是当今桌面计算中的常见任务!
对于 Mac,Apple 不提供包管理器,但第三方提供了。MacPorts 包管理器拥有最广泛的仓库。
注意
要设置 MacPorts 及其依赖项,请遵循官方指南 www.macports.org/install.php
。
要通过 MacPorts 安装 gPhoto2,请在终端运行以下命令:
$ sudo port install gphoto2
在 Debian 及其衍生版本中,包括 Ubuntu、Linux Mint 和 Raspbian,我们可以通过运行以下命令来安装 gPhoto2:
$ sudo apt-get install gphoto2
在 Fedora 及其衍生版本中,包括 Red Hat Enterprise Linux (RHEL) 和 CentOS,我们可以使用以下安装命令:
$ sudo yum install gphoto2
OpenSUSE 在 software.opensuse.org/package/gphoto
提供了 gPhoto2 的一键安装器。
安装 gPhoto2 后,让我们连接一个相机。确保相机已开启并处于 PTP 模式。然后,运行以下命令来卸载相机驱动器和拍照:
$ ./unmount_cameras.sh
$ gphoto2 --capture-image
如果相机处于自动对焦模式,你可能会看到或听到镜头移动。(确保相机有可观察的物体,以便自动对焦成功。否则,将不会捕捉到任何照片。)然后,你可能会听到快门打开和关闭。断开相机,使用其查看菜单浏览捕获的照片。如果那里有新照片,gPhoto2 正在运行!
要将相机中的所有图像上传到当前工作目录,我们可以重新连接相机并运行以下命令:
$ ./unmount_cameras.sh
$ gphoto2 --get-all-files
要了解 gphoto2 支持的所有标志,我们可以通过运行以下命令打开其手册:
$ man gphoto2
接下来,让我们尝试一个更高级的任务,涉及配置以及图像捕捉。我们将以曝光包围的方式拍摄一系列照片。
编写用于曝光包围的 shell 脚本
gPhoto2 提供了一个标志,--set-config
,允许我们重新配置许多相机参数,包括曝光补偿。例如,假设我们想要通过相当于一整个光圈的等效值(加倍光圈面积或将其半径增加 sqrt(2) 倍)来过度曝光图像。这种偏差称为曝光补偿(或曝光调整)+1.0 曝光值(EV)。以下命令配置相机使用 +1.0 EV,然后拍摄照片:
$ gphoto2 --set-config exposurecompensation=1000 --capture-image
注意,exposurecompensation
的值以千分之一 EV 计价,因此 1000
是 +1.0 EV。要欠曝,我们将使用负值。一系列具有不同 EV 的这些命令将实现曝光包围。
我们可以使用 --set-config
标志来控制许多摄影属性,而不仅仅是曝光补偿。例如,以下命令以一秒的曝光时间捕捉照片,同时以慢同步模式触发闪光灯:
$ gphoto2 --set-config shutterspeed=1s flashmode=2 --capture-image
以下命令列出了给定相机的所有支持属性和值:
$ gphoto2 --list-all-config
注意
关于光圈、曝光和其他摄影属性的进一步讨论,请参阅第一章,充分利用您的相机系统,特别是捕捉瞬间的主题部分。
在拍摄一系列曝光包围照片之前,将您的相机调至光圈优先(A)模式。这意味着光圈将保持不变,而快门速度将根据光线和 EV 值变化。恒定的光圈将有助于确保所有图像中的同一区域保持对焦。
让我们使用另一个 shell 脚本自动化曝光包围命令,我们将称之为 capture_exposure_bracket.sh
。它将接受一个标志 -s
,用于指定帧之间的曝光步长(以千分之一 EV 计),以及另一个标志 -f
,用于指定帧数。默认值将是 3 帧,间隔为 1.0 EV。以下是脚本的实现:
#!/usr/bin/env bash
ev_step=1000
frames=3
while getopts s:f: flag; do
case $flag in
s)
ev_step="$OPTARG"
;;
f)
frames="$OPTARG"
;;
?)
exit
;;
esac
done
min_ev=$((-ev_step * (frames - 1) / 2))
for ((i=0; i<frames; i++)); do
ev=$((min_ev + i * ev_step))
gphoto2 --set-config exposurecompensation=$ev \
--capture-image
done
gphoto2 --set-config exposurecompensation=0
此脚本中的所有命令都适用于 Linux 和 Mac 的跨平台。请注意,我们正在使用 getopts
命令来解析参数,并使用 Bash 算术来计算每张照片的 EV 值。
记得通过运行以下命令给脚本赋予“可执行”权限:
$ chmod +x capture_exposure_bracket.sh
要卸载相机并每隔 1.5 EV 捕捉 5 张照片,我们可以运行以下命令:
$ ./unmount_cameras.sh
$ ./capture_exposure_bracket.sh –s 1500 –f 5
现在我们已经清楚地了解了如何从命令行控制相机,让我们考虑如何将此功能封装在一种通用编程语言中,该语言还可以与 OpenCV 交互。
编写用于封装 gPhoto2 的 Python 脚本
Python 是一种高级、动态的编程语言,拥有强大的第三方数学和科学库。OpenCV 的 Python 绑定既高效又相当成熟,封装了 C++ 库的所有主要功能,除了 GPU 优化。Python 也是一种方便的脚本语言,因为其标准库提供了跨平台的接口,可以访问系统的大部分功能。例如,编写 Python 代码来启动一个子进程(也称为子进程)很容易,它可以运行任何可执行文件,甚至是另一个解释器,如 Bash shell。
注意
有关从 Python 中启动和与子进程通信的更多信息,请参阅 subprocess
模块的文档,网址为 docs.python.org/2/library/subprocess.html
。对于子进程是附加 Python 解释器的特殊情况,请参阅 multiprocessing
模块的文档,网址为 docs.python.org/2/library/multiprocessing.html
。
我们将使用 Python 的标准子进程功能来封装 gPhoto2 和我们自己的 shell 脚本。通过从子进程中发送相机命令,我们将使调用者(在 Python 中)将这些视为“发射并忘记”命令。也就是说,Python 进程中的函数会立即返回,这样调用者就不必等待相机处理命令。这是好事,因为相机通常需要几秒钟来自动对焦并捕获一系列照片。
让我们创建一个新的文件,CameraCommander.py
,并从以下导入语句开始其实现:
import os
import subprocess
我们将编写一个名为 CameraCommander
的类。作为成员变量,它将有一个当前捕获过程(可能为 None
)和一个日志文件。默认情况下,日志文件将是 /dev/null
,这意味着日志输出将被丢弃。在设置成员变量之后,初始化方法将调用一个辅助方法来卸载相机驱动,以便相机准备好接收命令。以下是类的声明和初始化器:
class CameraCommander(object):
def __init__(self, logPath=os.devnull):
self._logFile = open(logPath, 'w')
self._capProc = None
self.unmount_cameras()
当 CameraCommander
的实例被删除时,它应该关闭日志文件,如下面的代码所示:
def __del__(self):
self._logFile.close()
每次打开 CameraCommander
的子进程时,命令应由 shell(Bash)解释,命令的打印输出和错误应重定向到日志文件。让我们在以下辅助方法中标准化子进程的这种配置:
def _open_proc(self, command):
return subprocess.Popen(
command, shell=True, stdout=self._logFile,
stderr=self._logFile)
现在,作为我们对 shell 命令的第一个包装器,让我们编写一个方法来在子进程中运行 unmount_cameras.sh
。卸载相机驱动是一个短暂的过程,它必须在其他相机命令运行之前完成。因此,我们将实现我们的包装器方法,使其在 unmount_cameras.sh
返回之前不返回。也就是说,在这种情况下,子进程将以同步方式运行。以下是包装器实现的代码:
def unmount_cameras(self):
proc = self._open_proc('./unmount_cameras.sh')
proc.wait()
接下来,让我们考虑如何捕获单个图像。我们首先将调用一个辅助方法来停止任何之前的、冲突的命令。然后,我们将使用通常的--capture-image
标志调用gphoto2
命令。以下是包装方法的实现:
def capture_image(self):
self.stop_capture()
self._capProc = self._open_proc(
'gphoto2 --capture-image')
作为另一种捕获模式,我们可以调用gphoto2
来记录延时摄影系列。-I
或--interval
标志,带有一个整数值,指定帧之间的延迟,单位为秒。-F
或--frames
标志也接受一个整数值,指定系列中的帧数。如果使用了-I
标志但省略了-F
,则过程会无限期地捕获帧,直到被迫终止。让我们提供以下用于延时功能的包装器:
def capture_time_lapse(self, interval, frames=0):
self.stop_capture()
if frames <= 0:
# Capture an indefinite number of images.
command = 'gphoto2 --capture-image -I %d' % interval
else:
command = 'gphoto2 --capture-image -I %d -F %d' %\
(interval, frames)
self._capProc = self._open_proc(command)
在拍摄一系列延时摄影照片之前,你可能需要将你的相机调整到手动曝光(M)模式。这意味着光圈和快门速度将保持恒定,使用手动指定的值。假设场景的光线水平大致恒定,恒定的曝光将有助于防止延时视频中出现不愉快的闪烁。另一方面,如果我们预计在延时摄影系列过程中光线条件会有很大变化,那么 M 模式可能不合适,因为这些情况下,它会导致一些帧曝光不足,而其他帧则曝光过度。
为了允许曝光包围,我们可以简单地包装我们的capture_exposure_bracket.sh
脚本,如下面的代码所示:
def capture_exposure_bracket(self, ev_step=1.0, frames=3):
self.stop_capture()
self._capProc = self._open_proc(
'./capture_exposure_bracket.sh -s %d -f %d' %\
(int(ev_step * 1000), frames))
正如我们在前三个方法中看到的,在尝试启动另一个之前终止任何正在进行的捕获过程是合理的。(毕竟,相机一次只能处理一个命令)。此外,调用者可能有其他原因要终止捕获过程。例如,主题可能已经离开。我们将提供以下方法来强制终止任何正在进行的捕获过程:
def stop_capture(self):
if self._capProc is not None:
if self._capProc.poll() is None:
# The process is currently running but might finish
# before the next function call.
try:
self._capProc.terminate()
except:
# The process already finished.
pass
self._capProc = None
同样,我们将提供以下方法来等待任何当前正在运行的捕获过程的完成:
def wait_capture(self):
if self._capProc is not None:
self._capProc.wait()
self._capProc = None
最后,我们将提供以下属性获取器,以便调用者可以检查是否有一个捕获过程目前正在运行:
@property
def capturing(self):
if self._capProc is None:
return False
elif self._capProc.poll() is None:
return True
else:
self._capProc = None
return False
这就完成了CameraCommander
模块。为了测试我们的工作,让我们编写另一个脚本test_camera_commands.py
,其实现如下:
#!/usr/bin/env python
import CameraCommander
def main():
cc = CameraCommander.CameraCommander('test_camera_commands.log')
cc.capture_image()
print('Capturing image...')
cc.wait_capture()
print('Done')
cc.capture_time_lapse(3, 2)
print('Capturing 2 images at time interval of 3 seconds...')
cc.wait_capture()
print('Done')
cc.capture_exposure_bracket(1.0, 3)
print('Capturing 3 images at exposure interval of 1.0 EV...')
cc.wait_capture()
print('Done')
if __name__ == '__main__':
main()
确保你的相机已开启,处于 PTP 模式,并且已连接。然后,使测试脚本可执行并运行,如下所示:
$ chmod +x test_camera_commands.py
$ ./test_camera_commands.py
等待所有命令完成,然后断开相机以查看图像。检查每张照片的时间戳和 EV 值。理想情况下,应该已经捕获了总共六张照片。然而,实际数量可能会根据诸如自动对焦的成功或失败、捕获和保存每张图像所花费的时间等因素而有所不同。如果有任何疑问,请使用文本编辑器查看日志文件test_camera_commands.log
。
查找 libgphoto2 和包装器
作为使用 gPhoto2 命令行工具的替代方案,我们可以使用底层的 C 库,libgphoto2 (github.com/gphoto/libgphoto2
)。该库有几个第三方包装器,包括一组最新的 Python 绑定,称为 python-gphoto2 (github.com/gphoto/libgphoto2-python
)。
OpenCV 3 的 videoio 模块对 libgphoto2 有可选支持。要启用此功能,我们可以使用WITH_GPHOTO2
CMake 定义从源代码配置和构建 OpenCV。当然,为了使此选项工作,系统必须已经安装了 libgphoto2 及其头文件。例如,在 Debian、Ubuntu、Linux Mint、Raspbian 和类似系统上,可以通过以下命令安装:
$ sudo apt-get install libgphoto2-dev
对于我们的目的,通过 libgphoto2 或 OpenCV 的 videoio 模块控制相机是过度的。我们不想抓取帧进行实时处理。我们只想让我们的 Python 脚本启动额外的进程来卸载和配置相机,并使其捕获照片到其本地存储。gPhoto2 命令行工具和我们的 shell 脚本作为子进程使用非常方便,因此我们将继续在本章的其余部分依赖它们。
注意
OpenCV 的一个官方示例演示了通过 videoio 模块使用 gPhoto2 兼容的相机。具体来说,该示例处理焦点控制。请参阅 OpenCV 的 GitHub 仓库中的源代码:github.com/Itseez/opencv/blob/master/samples/cpp/autofocus.cpp
。
检测有摄影价值主题的存在
第一章,《充分利用您的相机系统》,提出了一幅照片应该捕捉一个主题在某个时刻。让我们在寻找检测理想或“有摄影价值”的主题和时刻的方法时进一步探讨这个概念。
作为一种媒介,摄影利用光线、光圈、感光表面和时间来描绘场景的图像。最早期的摄影技术,在 19 世纪 20 年代,缺乏分辨率和速度,无法在精确的时刻传达一个详细的主题,但它能够捕捉到晴朗日子里模糊的场景。后来,随着更好的镜头、闪光灯和感光表面,摄影能够捕捉到清晰的场景、正式的肖像、更快更自然的肖像,最终是动作的瞬间,被冻结在时间中。
考虑以下一系列著名的照片,时间跨度从 1826 年到 1942 年:
为了一般兴趣,以下是关于前面照片的一些细节:
-
右上角:勒格拉的窗景是历史上最早存活的照片,由尼埃普斯在 1826 年或 1827 年在法国圣洛佩-德-瓦雷涅斯拍摄。场景包括尼埃普斯庄园的部分屋顶和乡村。
-
中左:路易·达盖尔在 1838 年拍摄的特姆普尔大道被认为是第一张包含人的照片。场景是巴黎的一条繁忙街道,但由于照片的慢速,大多数路人都是不可见的。在街角附近,一个男人正在擦另一个男人的靴子,所以这两个人在一个地方待了足够长的时间,可以被记录下来。
-
右上角:让-巴蒂斯特·萨巴蒂耶-布洛特在 1844 年捕捉了路易·达盖尔的这张正式肖像。
-
左下角:彩色摄影先驱谢尔盖·普罗库丁-戈尔斯基在 1910 年捕捉了这张相对随意的俄罗斯卡斯利工厂工人的肖像。照片中的男子正在卡斯利铁工厂制作铸件,该工厂在 19 世纪和 20 世纪初生产雕塑和豪华家具。
-
左下角:马克斯·阿尔伯特在 1942 年 7 月 12 日,在卢甘斯克(今天在乌克兰)附近拍摄了这张战斗照片。主题是阿列克谢·戈尔杰耶维奇·耶尔缅科,他是红军的一名 23 岁初级政治军官。在照片的瞬间,耶尔缅科正在召集他的部队进攻。几秒钟后,他被击毙。
即使从这些少数例子中,我们也可以推断出一种历史趋势,即更动态的图像,这些图像捕捉了活动、变化甚至暴力的氛围。让我们在自然和野生动物摄影的背景下思考这一趋势。彩色摄影大约在 1907 年开始进入公众视野,经过几十年的技术改进,它成为比黑白更受欢迎的格式。色彩是动态的。风景、植物甚至动物的颜色会根据季节、天气、一天中的时间和它们的年龄而变化。今天,看到一部黑白自然纪录片似乎会很奇怪。
镜头技术的变化也对自然和野生动物摄影产生了深远的影响。随着更长、更快、更锐利的镜头,摄影师能够从远处窥视野生动物的生活。例如,今天,纪录片中充满了捕食者追逐猎物的场景。要拍摄这些场景,使用 20 世纪 20 年代的镜头将是困难的,甚至是不可能的。同样,微距(特写)镜头的质量也得到了很大提高,这对昆虫和其他小型生物的纪录片工作是一个福音。
最后,正如我们在本章开头的讨论中提到的,自动化技术的进步使得摄影师能够在偏远荒野、行动中部署相机。借助数字技术,远程相机可以存储大量的照片,并且这些照片可以轻松组合,产生效果,如时间流逝(强调运动)或 HDR(强调色彩)。如今,这些技术已被广泛使用,因此纪录片爱好者可能熟悉时间流逝的花朵从地面迅速生长或时间流逝的云朵在饱和的 HDR 天空中疾驰的景象。无论大小,一切都被描绘成动态的。
我们可以设计一些简单的规则来帮助区分动态场景和静态场景。以下是一些有用的线索:
-
运动:我们可以假设场景中的任何运动都代表捕捉主题在行动或变化时刻的机会。无需知道主题是什么,我们就可以检测其运动并捕捉照片。
-
颜色:我们可以假设在特定环境中某些颜色模式是不寻常的,并且它们出现在动态情况下。无需确切知道多彩的主题是什么,我们就可以检测其存在并拍摄它。例如,一大片新颜色可能是云层散开时的日落,或者花朵开放时的景象。
-
分类:我们可以假设某些类型的主题是活着的,并且会与它们的环境互动,从而为动态照片创造机会。当我们检测到某个特定的主题类别时,我们可以通过拍照来响应。例如,我们将检测并拍摄哺乳动物的面部。
无论采用何种检测主题的方法,我们必须确保我们的网络摄像头和照相机对场景有相似的观点。它们应该指向同一目标。网络摄像头的视野角度应该与照相机一样宽,甚至更宽,以便网络摄像头能够在照相机视野之前检测到主题。两个摄像头都应该固定牢固,以防止由于振动、风或其他典型干扰而错位。例如,照相机可以安装在坚固的三脚架上,而网络摄像头可以贴在照相机的热靴上(通常为外部闪光灯或外部取景器预留的插槽)。以下图像显示了这种设置的示例:
为了提供背景,以下图像是同一设置的稍微远一点的视角。注意,网络摄像头和照相机指向同一主题:
我们将实现每种类型的相机陷阱作为单独的脚本,该脚本将接受命令行参数以调整陷阱的灵敏度。首先,让我们开发一个运动敏感的陷阱。
检测移动主题
我们的运动感应摄像头陷阱将依赖于我们之前在编写用于封装 gPhoto2 的 Python 脚本部分中实现的CameraCommander
模块。此外,我们将使用 OpenCV 和 NumPy 来捕捉和分析网络摄像头图像。最后,从 Python 的标准库中,我们将导入argparse
模块,它将帮助我们解析命令行参数,以及time
模块,我们将用它来控制检测尝试之间的时间延迟。让我们创建一个文件,set_motion_trap.py
,并从以下导入开始其实现:
#!/usr/bin/env python
import argparse
import time
import numpy
import cv2
import CameraCommander
此脚本将具有简单的结构,只有一个main()
函数,它读取命令行参数并在循环中进行运动检测。一些参数与网络摄像头的使用有关,我们将称之为检测摄像头。其他参数涉及运动检测算法和相机的使用。main()
函数开始于以下命令行参数的定义:
def main():
parser = argparse.ArgumentParser(
description='This script detects motion using an '
'attached webcam. When it detects '
'motion, it captures photos on an '
'attached gPhoto2-compatible photo '
'camera.')
parser.add_argument(
'--debug', type=bool, default=False,
help='print debugging information')
parser.add_argument(
'--cam-index', type=int, default=-1,
help='device index for detection camera '
'(default=0)')
parser.add_argument(
'--width', type=int, default=320,
help='capture width for detection camera '
'(default=320)')
parser.add_argument(
'--height', type=int, default=240,
help='capture height for detection camera '
'(default=240)')
parser.add_argument(
'--detection-interval', type=float, default=0.25,
help='interval between detection frames, in seconds '
'(default=0.25)')
parser.add_argument(
'--learning-rate', type=float, default=0.008,
help='learning rate for background subtractor, which '
'is used in motion detection (default=0.008)')
parser.add_argument(
'--min-motion', type=float, default=0.15,
help='proportion of frame that must be classified as '
'foreground to trigger motion event '
'(default=0.15, valid_range=[0.0, 1.0])')
parser.add_argument(
'--photo-count', type=int, default=1,
help='number of photo frames per motion event '
'(default=1)')
parser.add_argument(
'--photo-interval', type=float, default=3.0,
help='interval between photo frames, in seconds '
'(default=3.0)')
parser.add_argument(
'--photo-ev-step', type=float, default=None,
help='exposure step between photo frames, in EV. If '
'this is specified, --photo-interval is ignored '
'and --photo-count refers to the length of an '
'exposure bracketing sequence, not a time-lapse '
'sequence.')
注意
当我们运行带有-h
或--help
标志的脚本时,将显示参数的help
文本,如下所示:
$ ./set_motion_trap.py -h
到目前为止,我们只声明了参数。接下来,我们需要解析它们并访问它们的值,如下面的代码所示:
args = parser.parse_args()
debug = args.debug
cam_index = args.cam_index
w, h = args.width, args.height
detection_interval = args.detection_interval
learning_rate = args.learning_rate
min_motion = args.min_motion
photo_count = args.photo_count
photo_interval = args.photo_interval
photo_ev_step = args.photo_ev_step
除了参数之外,我们还将使用几个变量。一个VideoCapture
对象将使我们能够配置并从网络摄像头进行捕捉。矩阵(在 OpenCV 的 Python 包装器中实际上是 NumPy 数组)将使我们能够存储每个网络摄像头的 BGR 和灰度版本,以及一个前景掩码。前景掩码将由运动检测算法输出,它将是一个灰度图像,前景(移动)区域为白色,阴影区域为灰色,背景区域为黑色。具体来说,在我们的案例中,运动检测器将是 OpenCV 的BackgroundSubtractorMOG2
类的实例。最后,我们需要一个CameraCommander
类的实例来控制相机。以下是相关变量的声明:
cap = cv2.VideoCapture(cam_index)
cap.set(cv2.CAP_PROP_FRAME_WIDTH, w)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h)
bgr = None
gray = None
fg_mask = None
bg_sub = cv2.createBackgroundSubtractorMOG2()
cc = CameraCommander.CameraCommander()
main()
函数实现的剩余部分是一个循环。在每次迭代中,我们将线程休眠一个指定的间隔(默认为 0.25 秒),因为这将节省系统资源。因此,我们将跳过一些网络摄像头的帧,但我们可能不需要完整的帧率来检测主题。如果我们没有设置休眠期,摄像头陷阱可能会一直使用 100%的 CPU 核心,尤其是在低功耗 SBC 上的慢速 CPU。以下是循环实现的第一部分:
while True:
time.sleep(detection_interval)
当我们读取帧时,我们将将其转换为灰度并均衡化:
success, bgr = cap.read(bgr)
if success:
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY, gray)
gray = cv2.equalizeHist(gray, gray)
我们将把均衡后的帧和前景掩码传递给BackgroundSubtractorMOG2
的apply
方法。此方法累积帧的历史记录,并根据历史记录中帧之间的差异来估计每个像素是否是前景区域、阴影或背景区域的一部分。作为第三个参数,我们将传递一个学习率,它是一个范围在[0.0, 1.0]之间的值。低值意味着将更多地考虑旧帧,因此估计将缓慢变化。看看我们如何在以下代码行中调用该方法:
fg_mask = bg_sub.apply(gray, fg_mask, learning_rate)
注意
注意,在背景减法算法(如 MOG2)中,前景被定义为像素值在最近历史中发生变化的区域。相反,背景是像素值没有变化的区域。阴影指的是前景的阴影。有关 MOG2 和其他 OpenCV 支持的背景减法算法的详细信息,请参阅官方文档docs.opencv.org/3.0-beta/modules/video/doc/motion_analysis_and_object_tracking.html#backgroundsubtractormog2
。
作为背景减法器输入和输出的示例,考虑以下图像对。上面的图像是视频的 RGB 帧,而下面的图像是基于视频的前景掩码。请注意,场景是一个岩石海岸,前景有波浪拍打,远处有船只经过:
通过计算前景掩码中的白色(前景)值,我们可以得到摄像头在最近历史中捕获的运动量的粗略测量值。我们应该根据帧中的像素数量来归一化这个数值。以下是相关代码:
h, w = fg_mask.shape
motion = numpy.sum(numpy.where(fg_mask == 255, 1, 0))
motion /= float(h * w)
如果脚本以--debug
标志运行,我们将打印运动测量值:
if debug:
print('motion=%f' % motion)
如果运动超过指定的阈值,并且如果我们还没有开始捕获照片,我们现在将开始捕获照片。根据命令行参数,我们可能捕获曝光分级的系列或时间间隔系列,如下面的代码块所示:
if motion >= min_motion and not cc.capturing:
if photo_ev_step is not None:
cc.capture_exposure_bracket(photo_ev_step, photo_count)
else:
cc.capture_time_lapse(photo_interval, photo_count)
在这里,循环和main()
函数结束了。为了确保在执行脚本时main()
运行,我们必须在脚本中添加以下代码:
if __name__ == '__main__':
main()
我们可以给这个 Python 脚本赋予“可执行”权限,然后像其他 shell 脚本一样运行它,如下面的示例所示:
$ chmod +x set_motion_trap.py
$ ./set_motion_trap.py --debug True
考虑下面的图像对。左边的图像显示了运动激活的相机陷阱的物理设置,它恰好运行了带有默认参数的set_motion_trap.py
。右边的图像是结果照片之一:
这些图像是用两个不同的相机拍摄的,因此它们在颜色和对比度上有所不同。然而,它们代表的是同一个场景。
尝试调整可选参数,以查看哪些设置对特定相机和移动主体类型最为有效。一旦我们了解了这个相机陷阱的敏感性,让我们继续进行另一个设计,使用一组颜色值作为触发器。
检测彩色主体
OpenCV 提供了一套用于测量和比较图像中颜色分布的函数。这个领域被称为直方图分析。直方图只是各种颜色或颜色范围的像素计数的数组。因此,对于每个通道有 256 个可能值的 BGR 图像,直方图可以有高达 256³ = 1680 万个元素。要创建此类直方图,我们可以使用以下代码:
images = [myImage] # One or more input images
channels = [0, 1, 2] # The channel indices
mask = None # The image region, or None for everything
histSize = [256, 256, 256] # The channel depths
ranges = [0, 255, 0, 255, 0, 255] # The color bin boundaries
hist = cv2.calcHist(images, channels, mask, histSize, ranges)
直方图值的总和等于输入图像中的像素总数。为了便于比较,我们应该将直方图归一化,使其值的总和为 1.0,换句话说,每个值代表属于给定颜色分组的像素的比例。我们可以使用以下代码执行此类归一化:
normalizedHist = cv2.normalize(hist, norm_type=cv2.NORM_L1)
然后,为了获得两个归一化直方图的相似度测量值,我们可以使用如下代码:
method = cv2.HISTCMP_INTERSECT # A method of comparison
similarity = cv2.compareHist(
normalizedHist, otherNormalizedHist, method)
对于HISTCMP_INTERSECT
方法,相似度是两个直方图的每个元素的最小值的总和。如果我们把直方图看作两条曲线,这个值衡量的是曲线下方的交叠面积。
注意
要查看所有支持的直方图比较方法及其数学定义的列表,请参阅官方文档中的docs.opencv.org/3.0-beta/modules/imgproc/doc/histograms.html#comparehist
。
我们将构建一个使用直方图相似性作为触发器的相机陷阱。当网络摄像头的图像直方图与参考图像的直方图足够相似时,我们将激活照相机。参考图像可以是彩色风景(如果我们对风景的所有颜色都感兴趣),或者它可以是彩色物体的紧密裁剪照片(如果我们只对物体的颜色感兴趣,而不管周围环境如何)。考虑以下紧密裁剪照片的例子:
第一张图像(左)展示了一件橙色夹克,这是狩猎季节期间常见的户外服装。 (鲜艳、温暖的色彩使穿着者更易被看到,从而降低了狩猎事故的风险。) 如果我们想检测树林中的人,这可能是一个好的参考图像。第二张图像(右)展示了一种高山罂粟,其花瓣为红色,花蕊为黄色。如果我们想检测花朵开放时的情况,这可能是一个好的参考图像。
注意
这些以及其他丰富多彩的图像可以在本书的 GitHub 仓库中找到,网址为github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_2/CameraTrap/media
。
让我们在名为set_color_trap.py
的新脚本中实现基于颜色的相机陷阱。大部分代码将与set_motion_trap.py
相似,但我们将在这里介绍差异。
在某些情况下,set_color_trap.py
将打印错误信息到stderr
。为了实现这一功能,Python 2 和 Python 3 有不同的语法。我们将添加以下导入语句以实现兼容性,即使我们在运行 Python 2,也能使 Python 3 的print
语法可用:
from __future__ import print_function
我们的脚本命令行参数将包括参考图像的路径和一个相似度阈值,这将决定陷阱的灵敏度。以下是参数的定义:
def main():
parser = argparse.ArgumentParser(
description='This script detects colors using an '
'attached webcam. When it detects colors '
'that match the histogram of a reference '
'image, it captures photos on an '
'attached gPhoto2-compatible photo '
'camera.')
# ...
parser.add_argument(
'--reference-image', type=str, required=True,
help='path to reference image, whose colors will be '
'detected in scene')
parser.add_argument(
'--min-similarity', type=float, default=0.02,
help='similarity score that histogram comparator '
'must find in order to trigger similarity event '
'(default=0.02, valid_range=[0.0, 1.0])')
# ...
注意
要阅读此脚本中省略的部分,请访问本书的 GitHub 仓库,网址为github.com/OpenCVBlueprints/OpenCVBlueprints/chapter_2/CameraTrap/set_color_trap.py
。
我们将解析参数并尝试从文件中加载参考图像。如果无法加载图像,脚本将打印错误信息并提前退出,如下面的代码所示:
args = parser.parse_args()
# ...
reference_image = cv2.imread(args.reference_image,
cv2.IMREAD_COLOR)
if reference_image is None:
print('Failed to read reference image: %s' %
args.reference_image, file=sys.stderr)
return
min_similarity = args.min_similarity
# ...
我们将创建参考图像的归一化直方图,稍后,我们还将创建来自网络摄像头的每一帧的归一化直方图。为了帮助创建归一化直方图,我们将在本地定义另一个函数。(Python 允许嵌套函数定义。)以下是相关代码:
# ...
channels = range(3)
hist_size = [256] * 3
ranges = [0, 255] * 3
def create_normalized_hist(image, hist=None):
hist = cv2.calcHist(
[image], channels, None, hist_size, ranges, hist)
return cv2.normalize(hist, hist, norm_type=cv2.NORM_L1)
reference_hist = create_normalized_hist(reference_image)
query_hist = None
# ...
再次强调,每次我们从网络摄像头捕获一帧时,我们将找到其归一化直方图。然后,我们将根据比较的HISTCMP_INTERSECT
方法测量参考直方图和当前场景直方图的相似度,这意味着我们只想计算直方图的交集或重叠区域。如果相似度等于或大于阈值,我们将开始捕获照片。
这里是主循环的实现:
while True:
time.sleep(detection_interval)
success, bgr = cap.read(bgr)
if success:
query_hist = create_normalized_hist(
bgr, query_hist)
similarity = cv2.compareHist(
reference_hist, query_hist, cv2.HISTCMP_INTERSECT)
if debug:
print('similarity=%f' % similarity)
if similarity >= min_similarity and not cc.capturing:
if photo_ev_step is not None:
cc.capture_exposure_bracket(photo_ev_step, photo_count)
else:
cc.capture_time_lapse(photo_interval, photo_count)
这就结束了main()
函数。再次强调,为了确保在脚本执行时调用main()
,我们将添加以下代码:
if __name__ == '__main__':
main()
使脚本可执行。然后,例如,我们可以这样运行它:
$ ./set_color_trap.py --reference-image media/OrangeCoat.jpg --min-similarity 0.13 --width 640 --height 480 --debug True
请看以下图像对。左侧图像显示了相机陷阱的物理设置,正在运行set_color_trap.py
,并使用我们刚才提到的自定义参数。右侧图像是结果照片之一:
再次强调,这些图像来自不同的相机,它们以不同的方式呈现场景的颜色和对比度。
你可能想尝试set_color_trap
的参数,特别是参考图像和相似度阈值。请注意,比较方法HISTCMP_INTERSECT
倾向于产生较低的相似度,因此默认阈值仅为 0.02,即直方图的重叠为 2%。如果你修改代码以使用不同的比较方法,你可能需要一个更高的阈值,并且最大相似度可能超过 1.0。
一旦你完成基于颜色的相机陷阱测试,我们就继续使用人脸检测作为我们的最终触发方式。
检测哺乳动物的脸
如你所知,OpenCV 的CascadeClassifier
类对于人脸检测和其他类型的对象检测非常有用,它使用一个称为级联的对象特征模型,该模型从 XML 文件中加载。我们在第一章的Supercharging the GS3-U3-23S6M-C and other Point Grey Research cameras部分使用了CascadeClassifier
和haarcascade_frontalface_alt.xml
进行人脸检测,充分利用您的相机系统。在本书的后续章节中,在第五章的Generic Object Detection for Industrial Applications中,我们将检查CascadeClassifier
的所有功能,以及一组用于创建任何类型对象的级联的工具。目前,我们将继续使用 OpenCV 附带的前训练级联。值得注意的是,OpenCV 为人类和猫的人脸检测提供了以下级联文件:
-
对于人类的前脸:
-
data/haarcascades/haarcascade_frontalface_default.xml
-
data/haarcascades/haarcascade_frontalface_alt.xml
-
data/haarcascades/haarcascade_frontalface_alt2.xml
-
data/lbpcascades/lbpcascade_frontalface.xml
-
-
对于人类的侧面脸:
-
data/haarcascades/haarcascade_profileface.xml
-
data/lbpcascades/lbpcascade_profileface.xml
-
-
对于猫的前脸:
-
data/haarcascades/haarcascade_frontalcatface.xml
-
data/haarcascades/haarcascade_frontalcatface_extended.xml
-
data/lbpcascades/lbpcascade_frontalcatface.xml
-
LBP 级联比 Haar 级联更快,但稍微不太准确。Haar 级联的扩展版本(如haarcascade_frontalcatface_extended.xml
中使用的那样)对水平和对角特征都很敏感,而标准的 Haar 级联只对水平特征敏感。例如,猫的胡须可能会被识别为对角特征。
注意
本书第五章通用工业应用对象检测将详细讨论级联类型。此外,有关如何使用 OpenCV 的猫级联进行训练的完整教程,请参阅 Joseph Howse(Packt Publishing,2015 年)所著的《OpenCV for Secret Agents》一书中的第三章使用机器学习识别面部表情。
顺便说一下,猫脸检测级联也可能检测到其他哺乳动物的面部。以下图片是使用haarcascade_frontalcatface_extended.xml
在猫(左)、红熊猫(右上)和猞猁(右下)的照片上检测结果的可视化:
注意
红熊猫和猞猁的照片由 Mathias Appel 拍摄,他慷慨地将这些以及其他许多图片发布到公共领域。请参阅他的 Flickr 页面www.flickr.com/photos/mathiasappel/
。
让我们在名为set_classifier_trap.py
的新脚本中实现基于分类的相机陷阱。必要的导入与set_color_trap.py
相同。set_classifier_trap.py
的命令行参数包括级联文件的路径以及影响CascadeClassifier
使用的其他参数。以下是相关代码:
def main():
parser = argparse.ArgumentParser(
description='This script detects objects using an '
'attached webcam. When it detects '
'objects that match a given cascade '
'file, it captures photos on an attached '
'gPhoto2-compatible photo camera.')
# ...
parser.add_argument(
'--cascade-file', type=str, required=True,
help='path to cascade file that classifier will use '
'to detect objects in scene')
parser.add_argument(
'--scale-factor', type=float, default=1.05,
help='relative difference in scale between '
'iterations of multi-scale classification '
'(default=1.05)')
parser.add_argument(
'--min-neighbors', type=int, default=8,
help='minimum number of overlapping objects that '
'classifier must detect in order to trigger '
'classification event (default=8)')
parser.add_argument(
'--min-object-width', type=int, default=40,
help='minimum width of each detected object'
'(default=40)')
parser.add_argument(
'--min-object-height', type=int, default=40,
help='minimum height of each detected object'
'(default=40)')
# ...
注意
要阅读此脚本省略的部分,请访问本书的 GitHub 仓库github.com/OpenCVBlueprints/OpenCVBlueprints/chapter_2/CameraTrap/set_classifier_trap.py
。
在像往常一样解析参数之后,我们将使用指定的级联文件初始化一个CascadeClassifier
实例。如果文件加载失败,我们将打印错误消息并提前退出脚本。请参阅以下代码:
args = parser.parse_args()
# ...
classifier = cv2.CascadeClassifier(args.cascade_file)
if classifier.empty():
print('Failed to read cascade file: %s' %
args.cascade_file, file=sys.stderr)
return
scale_factor = args.scale_factor
min_neighbors = args.min_neighbors
min_size = (args.min_object_width, args.min_object_height)
# ...
在脚本主循环的每次迭代中,我们将网络摄像头图像转换为均衡的黑白版本,并将其传递给CascadeClassifier
的detectMultiScale
方法。我们将使用一些命令行参数作为额外的参数来控制detectMultiScale
的灵敏度。如果至少检测到一个面部(或其他相关对象),我们将开始捕获照片,就像往常一样。以下是循环的实现:
while True:
time.sleep(detection_interval)
success, bgr = cap.read(bgr)
if success:
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY, gray)
gray = cv2.equalizeHist(gray, gray)
objects = classifier.detectMultiScale(
gray, scaleFactor=scale_factor,
minNeighbors=min_neighbors, minSize=min_size)
num_objects = len(objects)
if debug:
print('num_objects=%d' % num_objects)
if num_objects > 0 and not cc.capturing:
if photo_ev_step is not None:
cc.capture_exposure_bracket(photo_ev_step, photo_count)
else:
cc.capture_time_lapse(photo_interval, photo_count)
这完成了main()
函数,剩下的只是在脚本执行时像往常一样调用main()
:
if __name__ == '__main__':
main()
使脚本可执行。然后,例如,我们可以这样运行它:
$ ./set_classifier_trap.py --cascade-file cascades/haarcascade_frontalcatface_extended.xml --min-neighbors 16 --scale-factor 1.2 --width 640 --height 480 --debug True
参考以下一组图片。左侧图片展示了相机陷阱的物理设置,它正在运行set_classifier_trap.py
,并使用我们刚刚提到的自定义参数。右侧图片是其中两张结果照片:
左侧图像和右侧图像来自两个不同的相机,因此颜色和对比度不同。此外,两个右侧图像来自set_classifier_trap.py
的单独运行,照明条件和相机位置略有变化。
随意尝试set_classifier_trap.py
的参数。你可能甚至想创建自己的级联文件来检测不同类型的面部或对象。第五章,工业应用中的通用对象检测,将提供大量信息,帮助你更好地使用CascadeClassifier
和级联文件。
接下来,我们将考虑处理我们可能用任何脚本或简单的 gPhoto2 命令捕获的相片的方法。
处理图像以显示细微的颜色和运动
到目前为止,你可能已经捕获了一些曝光包围的相片和延时摄影相片。使用照片管理应用、文件浏览器或以下 gPhoto2 命令将它们上传到你的电脑上:
$ gphoto2 --get-all-files
后者命令会将文件上传到当前工作目录。
我们将合并曝光包围的相片来创建 HDR 图像,这将改善阴影和亮部的色彩表现。同样,我们将合并延时摄影相片来创建延时视频,这将展示加速尺度上的渐进运动。我们将首先处理来自书籍 GitHub 仓库的一些样本相片,该仓库地址为github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_2/CameraTrap/media
,然后你将能够将代码适配以使用你的相片。
创建 HDR 图像
OpenCV 3 有一个名为“photo”的新模块。其中两个类,MergeDebevec
和MergeMertens
,通过合并曝光包围的相片来创建 HDR 图像。无论使用哪个类,生成的 HDR 图像的通道值都在范围[0.0, 1.0]内。MergeDebevec
生成的 HDR 图像在可以显示或打印之前需要伽玛校正。该照片模块提供了几个色调映射函数,能够执行校正。
另一方面,MergeMertens
生成的 HDR 图像不需要伽玛校正。其通道值只需放大到范围[0, 255]。我们将使用MergeMertens
,因为它更简单,并且通常在保留颜色饱和度方面表现更好。
注意
有关 OpenCV 3 中 HDR 成像和色调映射的更多信息,请参阅官方文档docs.opencv.org/3.0-beta/modules/photo/doc/hdr_imaging.html
。还可以查看官方教程docs.opencv.org/3.0-beta/doc/tutorials/photo/hdr_imaging/hdr_imaging.html
。
MergeDebevec
和MergeMertens
类分别基于以下论文:
P. Debevec, and J. Malik, 从照片中恢复高动态范围辐射图, ACM SIGGRAPH 会议论文集,1997 年,369 - 378。
T. Mertens, J. Kautz, and F. Van Reeth, 曝光融合, 第 15 届太平洋计算机图形和应用会议论文集,2007 年,382 - 390。
为了演示目的,GitHub 仓库包含一对名为 Plasma 的猫的曝光包围照片。(她的照片和 HDR 合并版本在本章的规划相机陷阱部分中较早出现。)让我们创建一个脚本,test_hdr_merge.py
,以合并未处理的照片,media/PlasmaWink_0.jpg
和media/PlasmaWink_1.jpg
。以下是实现方式:
#!/usr/bin/env python
import cv2
def main():
ldr_images = [
cv2.imread('media/PlasmaWink_0.jpg'),
cv2.imread('media/PlasmaWink_1.jpg')]
hdr_processor = cv2.createMergeMertens()
hdr_image = hdr_processor.process(ldr_images) * 255
cv2.imwrite('media/PlasmaWink_HDR.jpg', hdr_image)
if __name__ == '__main__':
main()
从仓库中获取脚本和媒体文件,运行脚本,查看生成的 HDR 图像。然后,修改脚本以处理您自己的曝光包围照片。HDR 可以为任何具有强烈光线和深阴影的场景产生戏剧性的效果。风景和阳光照射的房间是很好的例子。
使用 HDR 成像,我们压缩了曝光差异。接下来,通过延时摄影,我们将压缩时间差异。
创建延时视频
在第一章的超级充电 PlayStation Eye部分中,我们创建了一个慢动作视频。记住,我们只是以高速(187 FPS)捕获图像,并将它们放入一个配置为以正常速度(60 FPS)播放的视频中。同样,要创建延时视频,我们将读取以低速(小于 1 FPS)捕获的图像文件,并将它们放入一个配置为以正常速度(60 FPS)播放的视频中。
为了演示目的,本书的 GitHub 仓库包含一组名为 Josephine 的猫的延时照片。当我们为 Josephine 制作延时视频时,我们会看到她非常活跃,即使她坐在椅子上时也是如此!作为预览,以下是延时视频的三个连续帧:
该系列包含 56 张照片,名称从media/JosephineChair_00.jpg
到media/JosephineChair_55.jpg
。以下脚本,我们将称之为test_time_lapse_merge.py
,将读取照片并生成一个名为media/JosephineChair_TimeLapse.avi
的一秒延时视频:
#!/usr/bin/env python
import cv2
def main():
num_input_files = 56
input_filename_pattern = 'media/JosephineChair_%02d.jpg'
output_filename = 'media/JosephineChair_TimeLapse.avi'
fourcc = cv2.VideoWriter_fourcc('M', 'J', 'P', 'G')
fps = 60.0
writer = None
for i in range(num_input_files):
input_filename = input_filename_pattern % i
image = cv2.imread(input_filename)
if writer is None:
is_color = (len(image.shape) > 2)
h, w = image.shape[:2]
writer = cv2.VideoWriter(
output_filename, fourcc, fps, (w, h), is_color)
writer.write(image)
if __name__ == '__main__':
main()
从仓库中获取脚本和媒体,运行脚本,观看约瑟芬从她的椅子上观看世界的视频结果。然后,修改脚本以处理你自己的图像。也许你会捕捉到其他慢动作动物的运动,花朵的绽放,或者阳光和云朵穿越景观的景象。
作为进一步的项目,你可能希望创建 HDR 延时视频。你可以通过修改我们的capture_exposure_bracket.sh
脚本来开始,捕捉多批曝光包围的图像,每批之间有时间延迟。(例如,可以使用sleep 3
命令延迟 3 秒。)将捕获的图像上传到你的电脑后,你可以将每批合并成 HDR 图像,然后将 HDR 图像合并成延时视频。
探索其他摄影技术,然后尝试自动化它们!
进一步学习
计算摄影是一个多样化和受欢迎的领域,它结合了艺术家、技术人员和科学家的工作。因此,有许多类型的作者、讲师和导师可以帮助你成为一名更好的“计算摄影师”。以下是一些有用的指南示例:
-
《使用 OpenCV 学习图像处理》,作者格洛丽亚·布埃诺·加西亚等(Packt Publishing,2015 年),涵盖了 OpenCV 3 在图像捕捉、图像编辑和计算摄影方面的广泛功能。本书使用 C++编写,适合计算机视觉初学者。
-
《国家地理摄影大师视频讲座》(The Great Courses,2015 年)提供了对大师摄影师目标和技术的深刻见解。几位讲师是野生动物摄影师,他们使用相机陷阱的做法为这一章节提供了灵感。
-
《开源天体摄影》,作者卡尔·萨诺(CreateSpace Independent Publishing Platform,2013 年),涵盖了使用 gPhoto2 和其他开源软件,以及摄影硬件来捕捉和处理夜空详细图像的使用方法。
-
《好奇摄影师的科学》,作者查尔斯·S·约翰逊(CRC Press,2010 年),解释了光、镜头和摄影的科学历史和原理。此外,它还提供了解决常见摄影问题的实用解决方案,例如为微距摄影选择和设置良好的设备。
不论是作为爱好还是职业,计算摄影都是一种探索和记录世界的绝佳方式,从特定的视角来看。它需要观察、实验和耐心,所以请放慢脚步!花时间从他人的探索中学习,并分享你的经验。
摘要
本章展示了一套令人惊讶的灵活的命令和类,使我们能够用简短和简单的代码进行计算摄影实验。我们已经编写了控制相机的脚本。在这个过程中,我们熟悉了 gPhoto2、Bash shell、PTP 通信、GVFS 挂载点和 Python 对子进程的支持。我们还编写了几个照片陷阱的变体,以便在主题进入视野时拍照。为此,OpenCV 为我们提供了检测运动、测量颜色相似性和分类对象的能力。最后,我们使用 OpenCV 将一组照片组合成时间流逝视频或 HDR 图像。
到目前为止,这本书已经提供了一种相当广泛的概述,介绍了如何捕捉光作为数据、控制相机、检测主题以及处理照片的方法。接下来的章节将专注于一系列高级技术,这将使我们能够对图像的主题进行更精细的分类和识别,并且能够以考虑相机运动和视角的方式处理照片和视频。
第三章. 使用机器学习识别面部表情
自九十年代初以来,自动面部表情识别引起了广泛关注,尤其是在人机交互领域。随着计算机开始成为我们生活的一部分,它们需要变得越来越智能。表情识别系统将增强人类与计算机之间的智能交互。
虽然人类可以轻易地识别面部表情,但一个可靠的表情识别系统仍然是一个挑战。在本章中,我们将介绍使用 OpenCV 库中的各种算法的基本面部表情实现,包括使用 ml 模块进行特征提取和分类。
在本章中,我们将简要介绍以下主题:
-
一个简单的识别人类面部表情的架构
-
OpenCV 库中的特征提取算法
-
学习和测试阶段,使用各种机器学习算法
介绍面部表情识别
自动面部表情识别是一个有趣且具有挑战性的问题,并在许多领域如人机交互、人类行为理解和数据驱动动画中具有几个重要应用。与面部识别不同,面部表情识别需要区分不同个体中相同的表情。当一个人可能以不同的方式表现出相同的表情时,问题变得更加困难。
当前用于测量面部表情的方法可以分为两种类型:静态图像和图像序列。在静态图像方法中,系统分别分析每个图像帧中的面部表情。在图像序列方法中,系统试图捕捉图像帧序列中面部上看到的运动和变化的时序模式。最近,注意力已经转向图像序列方法。然而,这种方法比静态方法更困难,需要更多的计算。在本章中,我们将遵循静态图像方法,并使用 OpenCV 3 库比较几种算法。
自动面部表情识别的问题包括三个子问题:
-
在图像中找到面部区域:面部在图像中的精确位置对于面部分析非常重要。在这个问题中,我们希望在图像中找到面部区域。这个问题可以被视为一个检测问题。在我们的实现中,我们将使用 OpenCV 的 objdetect 模块中的级联分类器来检测面部。然而,级联分类器容易产生对齐错误。因此,我们应用 flandmark 库从面部区域中提取面部特征点,并使用这些特征点来提取精确的面部区域。
注意
Flandmark 是一个开源的 C 库,实现了人脸关键点检测器。你可以在以下章节中了解更多关于 flandmark 的信息。基本上,你可以使用你想要的任何库来提取关键点。在我们的实现中,我们将使用这个库来降低复杂性,同时将库集成到我们的项目中。
-
从人脸区域提取特征:给定人脸区域,系统将提取面部表情信息作为一个特征向量。特征向量编码了从输入数据中提取的相关信息。在我们的实现中,特征向量是通过结合特征 2d 模块中的特征检测器和核心模块中的 kmeans 算法获得的。
-
将特征分类到情感类别:这是一个分类问题。系统使用分类算法将之前步骤中提取的特征映射到情感类别(如快乐、中性或悲伤)。这是本章的主要内容。我们将评估 ml 模块中的机器学习算法,包括神经网络、支持向量机和 K-Nearest-Neighbor。
在以下章节中,我们将向你展示实现面部表情系统的完整过程。在下一节中,你将找到几种提高系统性能的方法来满足你的需求。
面部表情数据集
为了简化章节,我们将使用数据集来演示过程,而不是使用实时摄像头。我们将使用标准数据集,日本女性面部表情(JAFFE)。数据集中有 10 个人的 214 张图片。每个人有每种表情的三张图片。数据集包括以下图所示的七个表情(快乐、悲伤、愤怒、厌恶、恐惧、惊讶和中性):
注意
你需要从以下链接下载数据集:www.kasrl.org/jaffe.html
JAFFE 数据集的样本图像。
在图像中找到人脸区域
在本节中,我们将向你展示在图像中检测人脸的基本方法。我们将使用 OpenCV 中的级联分类器来检测人脸位置。这种方法可能存在对齐错误。为了获得精确的位置,我们还将提供另一种使用面部关键点来查找人脸区域的高级方法。在我们的实现中,我们只使用人脸区域。然而,许多研究人员使用面部关键点来提取面部组件,如眼睛和嘴巴,并对这些组件分别进行操作。
注意
如果你想了解更多,你应该检查本章中的面部关键点部分。
使用人脸检测算法提取人脸区域
在我们的实现中,我们将在 objdetect 模块中使用基于 Haar 特征的级联分类器。在 OpenCV 中,你也可以使用基于 LBP 的级联提取人脸区域。基于 LBP 的级联比基于 Haar 的级联更快。使用预训练模型,基于 LBP 的级联性能低于基于 Haar 的级联。然而,训练一个基于 LBP 的级联以获得与基于 Haar 的级联相同的性能是可能的。
注意
如果你想要详细了解目标检测,你应该查看第五章,工业应用中的通用目标检测。
检测人脸的代码非常简单。首先,你需要将预训练的级联分类器加载到你的 OpenCV 安装文件夹中:
CascadeClassifier face_cascade;
face_cascade.load("haarcascade_frontalface_default.xml");
然后,以彩色模式加载输入图像,将图像转换为灰度,并应用直方图均衡化以增强对比度:
Mat img, img_gray;
img = imread(imgPath[i], CV_LOAD_IMAGE_COLOR);
cvtColor(img, img_gray, CV_RGB2GRAY);
equalizeHist(img_gray, img_gray);
现在,我们可以在图像中找到人脸。detectMultiScale
函数将所有检测到的人脸存储在向量中,作为 Rect(x, y, w, h):
vector<Rect> faces;
face_cascade.detectMultiScale( img_gray, faces, 1.1, 3 );
在此代码中,第三个参数 1.1 是缩放因子,它指定了在每次缩放时图像大小将如何调整。以下图显示了使用缩放因子的缩放金字塔。在我们的案例中,缩放因子是1.1
。这意味着图像大小减少了 10%。这个因子越低,我们找到人脸的机会就越大。缩放过程从原始图像开始,直到图像分辨率在 X 或 Y 方向达到模型维度为止。然而,如果缩放级别太多,计算成本会很高。因此,如果你想减少缩放级别,可以将缩放因子增加到1.2
(20%)、1.3
(30%)或更高。如果你想增加缩放级别,可以将缩放因子减少到1.05
(5%)或更高。第四个参数3
是每个候选位置应具有的最小邻居数,才能成为人脸位置。
图像尺度金字塔
如果我们将邻居数设置为零,以下图显示了人脸检测的结果:
所有的人脸区域候选者
最后,人脸区域的位置可以按以下方式获得:
int bbox[4] = { faces[i].x, faces[i].y, faces[i].x + faces[i].width, faces[i].y + faces[i].height };
faces 向量中的每个元素都是一个Rect
对象。因此,我们可以通过faces[i].x
和faces[i].y
获取顶点的位置。右下角的位置是faces[i].x + faces[i].width
和faces[i].y + faces[i].height
。这些信息将被用作面部特征点处理过程的初始位置,如以下章节所述。
从人脸区域提取面部特征点
面部检测器的一个缺点是结果可能存在错位。错位可能发生在缩放或平移过程中。因此,所有图像中提取的面部区域不会彼此对齐。这种错位可能导致识别性能不佳,尤其是在使用 DENSE 特征时。借助面部特征点,我们可以对所有的提取面部进行对齐,使得每个面部组件在数据集中位于相同的位置。
许多研究人员利用面部特征点与其他情绪识别方法进行分类。
我们将使用 flandmark 库来找到眼睛、鼻子和嘴巴的位置。然后,我们将使用这些面部特征点来提取精确的面部边界框。
介绍 flandmark 库
Flandmark 是一个开源的 C 语言库,实现了面部特征点检测器。
注意
您可以在以下网址访问 flandmark 库的主页:cmp.felk.cvut.cz/~uricamic/flandmark/
。
给定一张人脸图像,flandmark 库的目标是估计一个代表面部组件位置的 S 形。S 形中的面部形状是一个表示为(x, y)位置的数组:S = [x[0]y[0]x[1]y[1]....x[n]y[n]]。
flandmark 中的预训练模型包含八个点,如图所示:
8 个特征点模型以及每个特征点的对应索引。
在我们的实现中,我们使用 flandmark,因为它很容易集成到 OpenCV 项目中。此外,flandmark 库在许多场景中都非常稳健,即使当人戴着眼镜时也是如此。在以下图中,我们展示了在一个人戴着深色眼镜的图像上使用 flandmark 库的结果。红色点表示面部特征点。
在下一节中,我们将向您展示如何在我们的项目中下载和使用 flandmark。
下载和编译 flandmark 库
Flandmark 是用 C 语言实现的,可以轻松集成到我们的项目中。然而,我们需要修改库源代码中的某些头文件,以便与 OpenCV 3 兼容。以下是从下载和编译库的步骤:
-
前往 flandmark 库的主页并遵循 GitHub 链接:
github.com/uricamic/flandmark
-
使用以下命令将库克隆到您的本地机器上:
git clone http://github.com/uricamic/flandmark
-
将
libflandmark
文件夹复制到您的项目文件夹中。 -
将数据文件夹中的
flandmark_model.dat
复制到您的项目文件夹中。 -
编辑
libflandmark
中的liblbp.h
文件并更改:#include "msvc-compat.h"
将
#include <stdint.h>
-
编辑
libflandmark
中的flandmark_detector.h
文件并更改:#include "msvc-compat.h" #include <cv.h> #include <cvaux.h>
将
#include <stdint.h> #include "opencv2/opencv.hpp" #include "opencv2/objdetect/objdetect.hpp" #include "opencv2/highgui/highgui.hpp" #include "opencv2/imgproc/imgproc.hpp" #include <iostream> #include <stdio.h> using namespace std; using namespace cv;
-
编辑您项目文件夹中的
CMakeLists.txt
以添加 flandmark 库:add_subdirectory(libflandmark) include_directories("${PROJECT_SOURCE_DIR}/libflandmark")
-
将可执行文件链接到 flandmark 静态库。
-
将 flandmark 头文件添加到您的源代码中:
#include "flandmark_detector.h"
使用 flandmark 检测面部特征点
完成上述步骤后,提取面部组件的过程非常简单。
首先,我们创建一个FLANDMARK_Model
变量来加载预训练模型:
FLANDMARK_Model * model = flandmark_init("flandmark_model.dat");
然后,我们将地标数量保存到num_of_landmark
变量中,并创建一个数组来存储输出结果:
int num_of_landmark = model->data.options.M;
double *points = new double[2 * num_of_landmark];
最后,对于每个面部区域,我们创建一个整数数组来存储面部位置,并使用flandmark_detect
函数在points
数组中获得最终结果:
int bbox[4] = { faces[i].x, faces[i].y, faces[i].x + faces[i].width, faces[i].y + faces[i].height };
flandmark_detect(new IplImage(img_gray), bbox, model, points);
flandmark_detect
函数的第一个参数是IplImage
,因此我们需要将我们的灰度图像传递给IplImage
构造函数。
在图像中可视化地标
此步骤是可选的。你不需要在这个部分实现代码。然而,我们建议你尝试并理解结果。以下代码在图像上绘制了地标的位置:
for(int j = 0 ; j < num_of_landmark; j++){
Point landmark = Point((int)points[2 * j], (int)points[2* j + 1]);
circle(img, landmark, 4, Scalar(255, 255, 255), -1);
}
以下图显示了使用上述代码的多个结果示例:
一些在 JAFFE 图像上的 flandmark 结果示例
提取面部区域
现在我们有了眼睛、鼻子和嘴的位置。提取面部区域非常容易。
首先,我们计算左眼的中心为点 2 和点 6 的中点:
Point centerLeft = Point( (int) (points[2 * 6] + points[2 * 2]) / 2, (int) (points[2 * 6 + 1] + points[2 * 2 + 1]) / 2 );
第二,眼区域宽度是点 2 和点 6 的 x 坐标之差:
int widthLeft = abs(points[2 * 6] - points[2 * 2]);
然后,我们找到右眼的中点和宽度:
Point centerRight = Point( (int) (points[2 * 1] + points[2 * 5]) / 2, (int) (points[2 * 1 + 1] + points[2 * 5 + 1]) / 2 );
int widthRight = abs(points[2 * 1] - points[2 * 5]);
我们可以假设面部宽度略大于眼睛之间的距离,面部高度大于面部宽度,因此我们可以得到眉毛。我们可以使用以下代码获得良好的面部位置:
int widthFace = (centerLeft.x + widthLeft) - (centerRight.x - widthRight);
int heightFace = widthFace * 1.2;
最后,可以使用以下代码提取面部区域:
Mat face = img(Rect( centerRight.x - widthFace/4 , centerRight.y - heightFace/4, widthFace, heightFace ));
以下图显示了从我们的实现中提取的一些图像:
从 JAFFE 图像中提取的一些面部区域示例
软件使用指南
我们已经实现了从 JAFFE 数据集中提取面部组件的软件。你可以按照以下方式使用代码:
-
下载源代码。打开终端,切换到源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
你可以使用 facial_components 工具,如下所示:
./facial_components -src <input_folder> -dest <out_folder>
注意
基于 OpenCV 3 的本章软件可以在以下位置找到:github.com/OpenCVBlueprints/OpenCVBlueprints/
为了简化过程,我们将图像路径保存在一个.yaml
文件中,list.yml
。此.yaml
文件的结构很简单。首先,我们将图像数量保存到num_of_image
变量中。之后,我们保存所有图像的路径,如下面的截图所示:
list.yml 文件的图像
特征提取
给定一个包含面部区域的数据集,我们可以使用特征提取来获取特征向量,它提供了表情中最重要信息。以下图显示了我们在实现中用于提取特征向量的过程:
特征提取过程
为了理解本章,您需要了解表情图像的特征表示是图像特征在 k 个簇(在我们的实现中 k = 1000)上的分布。我们已经实现了一些在 OpenCV 中受支持的常见特征类型,例如 SIFT、SURF,以及一些高级特征,如 DENSE-SIFT、KAZE、DAISY。由于这些图像特征是在图像的关键点(如角点)上计算的,除了 DENSE 情况外,图像特征的数量可能在图像之间有所不同。然而,我们希望每个图像都有一个固定的特征大小来进行分类,因为我们将在以后应用机器学习分类技术。重要的是,图像的特征大小必须相同,这样我们才能比较它们以获得最终结果。因此,我们应用聚类技术(在我们的情况下是 kmeans)将图像特征空间分离成 k 个簇。每个图像的最终特征表示是图像特征在 k 个桶上的直方图。此外,为了减少最终特征的维度,我们在最后一步应用主成分分析。
在接下来的章节中,我们将逐步解释这个过程。在本节的末尾,我们将向您展示如何使用我们的实现来获取数据集的最终特征表示。
从面部组件区域提取图像特征
在这一点上,我们将假设您已经拥有了数据集中每个图像的面部区域。下一步是从这些面部区域中提取图像特征。OpenCV 提供了许多知名的关键点检测和特征描述算法的良好实现。
注意
每个算法的详细解释超出了本章的范围。
在本节中,我们将向您展示如何在我们的实现中使用这些算法中的一些。
我们将使用一个函数,该函数接受当前区域、特征类型,并返回一个矩阵,其中包含作为行的图像特征:
Mat extractFeature(Mat face, string feature_name);
在这个extractFeature
函数中,我们将从每个 Mat 中提取图像特征并返回描述符。extractFeature
的实现很简单,如下所示:
Mat extractFeature(Mat img, string feature_name){
Mat descriptors;
if(feature_name.compare("brisk") == 0){
descriptors = extractBrisk(img);
} else if(feature_name.compare("kaze") == 0){
descriptors = extractKaze(img);
} else if(feature_name.compare("sift") == 0){
descriptors = extractSift(img);
} else if(feature_name.compare("dense-sift") == 0){
descriptors = extractDenseSift(img);
} else if(feature_name.compare("daisy") == 0){
descriptors = extractDaisy(img);
}
return descriptors;
}
在上面的代码中,我们为每个特征调用相应的函数。为了简单起见,我们每次只使用一个特征。在本章中,我们将讨论两种类型的特征:
-
贡献特征:SIFT、DAISY 和 DENSE SIFT。在 OpenCV 3 中,SIFT 和 SURF 的实现已被移动到 opencv_contrib 模块。
注意
这些特征是受专利保护的,如果您想在商业应用中使用它们,则必须付费。
在本章中,我们将使用 SIFT 特征及其变体,DENSE SIFT。
注意
如果你想要使用 opencv_contrib 模块,我们建议你查看进一步阅读部分,并查看编译 opencv_contrib 模块部分。
-
高级功能:BRISK 和 KAZE。这些特征在性能和计算时间上都是 SIFT 和 SURF 的良好替代品。DAISY 和 KAZE 仅在 OpenCV 3 中可用。DAISY 在 opencv_contrib 中,KAZE 在主要的 OpenCV 仓库中。
贡献功能
让我们先看看 SIFT 特征。
为了在 OpenCV 3 中使用 SIFT 特征,你需要将 opencv_contrib 模块与 OpenCV 一起编译。
注意
我们将假设你已经遵循了进一步阅读部分中的说明。
提取 SIFT 特征的代码非常简单:
Mat extractSift(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> sift = xfeatures2d::SIFT::create();
sift->detect(img, keypoints, Mat());
sift->compute(img, keypoints, descriptors);
return descriptors;
}
首先,我们使用xfeatures2d::SIFT::create()
创建Feature2D
变量,并使用detect
函数来获取关键点。检测函数的第一个参数是我们想要处理的图像。第二个参数是一个存储检测到的关键点的向量。第三个参数是一个掩码,指定了查找关键点的位置。我们希望在图像的每个位置都找到关键点,所以我们在这里传递一个空的 Mat。
最后,我们使用compute
函数在这些关键点上提取特征描述符。计算出的描述符存储在descriptors
变量中。
接下来,让我们看看 SURF 特征。
获取 SURF 特征的代码与 SIFT 特征的代码大致相同。我们只是将命名空间从 SIFT 更改为 SURF:
Mat extractSurf(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> surf = xfeatures2d::SURF::create();
surf->detect(img, keypoints, Mat());
surf->compute(img, keypoints, descriptors);
return descriptors;
}
现在让我们转向 DAISY。
DAISY 是旋转不变 BRISK 描述符和 LATCH 二进制描述符的改进版本,与较重且较慢的 SURF 相当。DAISY 仅在 OpenCV 3 的 opencv_contrib 模块中可用。实现 DAISY 特征的代码与 Sift 函数相当相似。然而,DAISY 类没有detect
函数,因此我们将使用 SURF 来检测关键点,并使用 DAISY 来提取描述符:
Mat extractDaisy(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<FeatureDetector> surf = xfeatures2d::SURF::create();
surf->detect(img, keypoints, Mat());
Ptr<DescriptorExtractor> daisy = xfeatures2d::DAISY::create();
daisy->compute(img, keypoints, descriptors);
return descriptors;
}
现在是时候看看密集 SIFT 特征了。
密集特征在每个图像的位置和尺度上收集特征。有很多应用都使用了密集特征。然而,在 OpenCV 3 中,提取密集特征的接口已被移除。在本节中,我们展示了使用 OpenCV 2.4 源代码中的函数提取关键点向量的简单方法。
提取密集 Sift 函数的函数与 Sift 函数类似:
Mat extractDenseSift(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<Feature2D> sift = xfeatures2d::SIFT::create();
createDenseKeyPoints(keypoints, img);
sift->compute(img, keypoints, descriptors);
return descriptors;
}
我们可以不用detect
函数,而是使用createDenseKeyPoints
函数来获取关键点。之后,我们将这个密集关键点向量传递给计算函数。createDenseKeyPoints
的代码是从 OpenCV 2.4 源代码中获得的。你可以在 OpenCV 2.4 仓库中的modules/features2d/src/detectors.cpp
找到这段代码:
void createDenseFeature(vector<KeyPoint> &keypoints, Mat image, float initFeatureScale=1.f, int featureScaleLevels=1,
float featureScaleMul=0.1f,
int initXyStep=6, int initImgBound=0,
bool varyXyStepWithScale=true,
bool varyImgBoundWithScale=false){
float curScale = static_cast<float>(initFeatureScale);
int curStep = initXyStep;
int curBound = initImgBound;
for( int curLevel = 0; curLevel < featureScaleLevels; curLevel++ )
{
for( int x = curBound; x < image.cols - curBound; x += curStep )
{
for( int y = curBound; y < image.rows - curBound; y += curStep )
{
keypoints.push_back( KeyPoint(static_cast<float>(x), static_cast<float>(y), curScale) );
}
}
curScale = static_cast<float>(curScale * featureScaleMul);
if( varyXyStepWithScale ) curStep = static_cast<int>( curStep * featureScaleMul + 0.5f );
if( varyImgBoundWithScale ) curBound = static_cast<int>( curBound * featureScaleMul + 0.5f );
}
}
高级功能
OpenCV 3 捆绑了许多新的和高级特性。在我们的实现中,我们只会使用 BRISK 和 KAZE 特征。然而,OpenCV 中还有许多其他特性。
让我们熟悉一下 BRISK 的特点。
BRISK 是一个新特性,是 SURF 的一个很好的替代品。自 2.4.2 版本以来,它已被添加到 OpenCV 中。BRISK 采用 BSD 许可,因此你不必担心专利问题,就像 SIFT 或 SURF 一样。
Mat extractBrisk(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<DescriptorExtractor> brisk = BRISK::create();
brisk->detect(img, keypoints, Mat());
brisk->compute(img, keypoints, descriptors);
return descriptors;
}
注意
关于这些内容有一篇有趣的文章,三个描述符的较量:SURF、FREAK 和 BRISK,可在computer-vision-talks.com/articles/2012-08-18-a-battle-of-three-descriptors-surf-freak-and-brisk/
找到。
让我们继续前进,看看 KAZE 特征。
KAZE 是 OpenCV 3 中的一个新特性。它在许多场景下产生最佳结果,尤其是在图像匹配问题上,并且与 SIFT 相当。KAZE 位于 OpenCV 仓库中,因此你不需要 opencv_contrib 就可以使用它。除了高性能之外,使用 KAZE 的另一个原因是它是开源的,你可以在任何商业应用中自由使用它。使用此特性的代码非常简单:
Mat extractKaze(Mat img){
Mat descriptors;
vector<KeyPoint> keypoints;
Ptr<DescriptorExtractor> kaze = KAZE::create();
kaze->detect(img, keypoints, Mat());
kaze->compute(img, keypoints, descriptors);
return descriptors;
}
注意
KAZE、SIFT 和 SURF 之间的图像匹配比较可在作者仓库中找到:github.com/pablofdezalc/kaze
为每种特征类型可视化关键点
在以下图中,我们可视化每种特征类型的关键点位置。我们在每个关键点处画一个圆圈;圆圈的半径指定了提取关键点的图像的缩放比例。你可以看到这些特征中的关键点和相应的描述符是不同的。因此,系统的性能将根据特征的质量而变化。
注意
我们建议您参考评估部分以获取更多详细信息。
特征提取过程
计算特征表示在 k 个簇上的分布
如果你已经遵循了之前的伪代码,你现在应该有一个描述符向量。你可以看到描述符的大小在不同图像之间是不同的。由于我们希望每个图像的特征表示具有固定的大小,我们将计算特征表示在 k 个簇上的分布。在我们的实现中,我们将在核心模块中使用 kmeans 聚类算法。
将图像特征空间聚类成 k 个簇
首先,我们假设所有图像的描述符都被添加到一个向量中,称为features_vector
。然后,我们需要创建一个Mat rawFeatureData
,它将包含所有图像特征作为行。在这种情况下,num_of_feature
是每张图像中的特征总数,image_feature_size
是每个图像特征的大小。我们根据实验选择簇的数量。我们开始于 100,并在几次迭代中增加数量。这取决于特征和数据类型,因此您应该尝试更改此变量以适应您的具体情况。大量簇的一个缺点是,使用 kmeans 的计算成本会很高。此外,如果簇的数量太大,特性向量将过于稀疏,这可能不利于分类。
Mat rawFeatureData = Mat::zeros(num_of_feature, image_feature_size, CV_32FC1);
我们需要将描述符向量(代码中的features_vector
)中的数据复制到imageFeatureData
:
int cur_idx = 0;
for(int i = 0 ; i < features_vector.size(); i++){
features_vector[i].copyTo(rawFeatureData.rowRange(cur_idx, cur_idx + features_vector[i].rows));
cur_idx += features_vector[i].rows;
}
最后,我们使用kmeans
函数对数据进行聚类,如下所示:
Mat labels, centers;
kmeans(rawFeatureData, k, labels, TermCriteria( TermCriteria::EPS+TermCriteria::COUNT, 100, 1.0), 3, KMEANS_PP_CENTERS, centers);
让我们讨论kmeans
函数的参数:
double kmeans(InputArray data, int K, InputOutputArray bestLabels, TermCriteria criteria, int attempts, int flags, OutputArray centers=noArray())
-
InputArray data: 它包含所有样本作为行。
-
int K: 分割样本的簇数(在我们的实现中 k = 1000)。
-
InputOutputArray bestLabels: 包含每个样本簇索引的整数数组。
-
TermCriteria criteria: 算法终止准则。这包含三个参数(
type
、maxCount
、epsilon
)。 -
Type: 终止准则的类型。有三种类型:
-
COUNT: 在迭代次数达到一定数量(
maxCount
)后停止算法。 -
EPS: 如果达到指定的精度(epsilon),则停止算法。
-
EPS+COUNT: 如果满足 COUNT 和 EPS 条件,则停止算法。
-
-
maxCount: 这是最大迭代次数。
-
epsilon: 这是停止算法所需的精度。
-
int attempts: 这是算法以不同初始质心执行次数的数量。算法返回具有最佳紧致性的标签。
-
int flags: 此标志指定初始质心如何随机。通常使用
KMEANS_RANDOM_CENTERS
和KMEANS_PP_CENTERS
。如果您想提供自己的初始标签,应使用KMEANS_USE_INITIAL_LABELS
。在这种情况下,算法将在第一次尝试中使用您的初始标签。对于进一步的尝试,将应用KMEANS_*_CENTERS
标志。 -
OutputArray centers: 它包含所有簇质心,每行一个质心。
-
double compactness: 这是函数返回的值。这是每个样本到对应质心的平方距离之和。
为每个图像计算最终特征
现在我们已经为数据集中的每个图像特征有了标签。下一步是为每个图像计算一个固定大小的特征。考虑到这一点,我们遍历每个图像,创建一个包含 k 个元素的特性向量,其中 k 是簇的数量。
然后,我们遍历当前图像中的图像特征,并增加特征向量的第 i 个元素,其中 i 是图像特征的标签。
想象一下,我们正在尝试根据 k 个质心来制作特征的历史图表示。这种方法看起来像是一个词袋方法。例如,图像 X 有 100 个特征,图像 Y 有 10 个特征。我们无法比较它们,因为它们的大小不同。然而,如果我们为它们中的每一个都制作一个 1,000 维度的历史图,它们的大小就相同了,我们就可以轻松地比较它们。
维度降低
在本节中,我们将使用主成分分析(PCA)来降低特征空间的维度。在上一个步骤中,我们为每个图像有 1,000 维的特征向量。在我们的数据集中,我们只有 213 个样本。因此,进一步分类器倾向于在高维空间中过拟合训练数据。因此,我们希望使用 PCA 来获取最重要的维度,这个维度具有最大的方差。
接下来,我们将向您展示如何在我们的系统中使用 PCA。
首先,我们假设您可以将所有特征存储在一个名为featureDataOverBins
的 Mat 中。这个 Mat 的行数应等于数据集中的图像数量,列数应为 1,000。featureDataOverBins
中的每一行都是图像的一个特征。
第二,我们创建一个 PCA 变量:
PCA pca(featureDataOverBins, cv::Mat(), CV_PCA_DATA_AS_ROW, 0.90);
第一个参数是包含所有特征的数据。我们没有预先计算的平均向量,因此第二个参数应该是一个空的 Mat。第三个参数表示特征向量以矩阵行存储。最后一个参数指定 PCA 应保留的方差百分比。
最后,我们需要将所有特征从 1,000 维特征空间投影到一个较低的空间。投影后,我们可以将这些特征保存以供进一步处理。
for(int i = 0 ; i < num_of_image; i++){
Mat feature = pca.project(featureDataOverBins.row(i));
// save the feature in FileStorage
}
新特征的数量可以通过以下方式获得:
int feature_size = pca.eigenvectors.rows;
软件使用指南
我们已经实现了先前的过程来为数据集提取固定大小的特征。使用该软件相当简单:
-
下载源代码。打开终端并将目录更改为源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
您可以使用以下方式使用
feature_extraction
工具:./feature_extraction -feature <feature_name> -src <input_folder> -dest <output_folder>
feature_extraction
工具在输出文件夹中创建一个 YAML 文件,该文件包含数据集中每个图像的特征和标签。可用的参数有:
-
feature_name
: 这可以是 sift、surf、opponent-sift 或 opponent-surf。这是在特征提取过程中使用的特征类型的名称。 -
input_folder
: 这是指向面部组件位置的绝对路径。 -
output_folder
: 这是指向您希望保存输出文件的文件夹的绝对路径。
输出文件的结构相当简单。
我们存储了特征的大小、聚类中心、图像数量、训练和测试图像数量、标签数量以及相应的标签名称。我们还存储了 PCA 均值、特征向量和特征值。以下图显示了 YAML 文件的一部分:
features.yml 文件的一部分
对于每个图像,我们存储三个变量,如下所示:
-
image_feature_<idx>
:这是一个包含图像 idx 特征的 Mat -
image_label_<idx>
:这是图像 idx 的标签 -
image_is_train_<idx>
:这是一个布尔值,指定图像是否用于训练。
分类
一旦你从数据集的所有样本中提取了特征,就到了开始分类过程的时候了。这个分类过程的目标是学习如何根据训练示例自动进行准确的预测。对此问题有许多方法。在本节中,我们将讨论 OpenCV 中的机器学习算法,包括神经网络、支持向量机和 k-最近邻。
分类过程
分类被认为是监督学习。在分类问题中,需要一个正确标记的训练集。在训练阶段产生一个模型,该模型在预测错误时进行纠正。然后,该模型用于其他应用的预测。每次你有更多训练数据时,都需要对模型进行训练。以下图显示了分类过程概述:
分类过程概述
选择要使用的机器学习算法是一个关键步骤。对于分类问题,有很多解决方案。在本节中,我们列出了 OpenCV 中的一些流行机器学习算法。每种算法在分类问题上的性能可能会有所不同。你应该进行一些评估,并选择最适合你问题的算法以获得最佳结果。这是非常重要的,因为特征选择可能会影响学习算法的性能。因此,我们还需要评估每个学习算法与每个不同的特征选择。
将数据集分割成训练集和测试集
将数据集分成两部分,即训练集和测试集,这是非常重要的。我们将使用训练集进行学习阶段,测试集用于测试阶段。在测试阶段,我们希望测试训练好的模型如何预测未见过的样本。换句话说,我们希望测试训练模型的泛化能力。因此,测试样本与训练样本不同是很重要的。在我们的实现中,我们将简单地将数据集分成两部分。然而,如果你使用进一步阅读部分中提到的 k 折交叉验证会更好。
没有一种准确的方法可以将数据集分成两部分。常见的比例是 80:20 和 70:30。训练集和测试集都应该随机选择。如果它们有相同的数据,评估将是误导性的。基本上,即使你在测试集上达到了 99%的准确率,模型在真实世界中也无法工作,因为真实世界中的数据与训练数据不同。
在我们的特征提取实现中,我们已经随机分割了数据集,并将选择保存在 YAML 文件中。
注意
k 折交叉验证在“进一步阅读”部分的末尾有更详细的解释。
支持向量机
支持向量机(SVM)是一种适用于分类和回归的监督学习技术。给定标记的训练数据,SVM 的目标是生成一个最佳超平面,该超平面仅根据测试样本的属性预测测试样本的目标值。换句话说,SVM 基于标记的训练数据生成一个从输入到输出的函数。
例如,假设我们想要找到一条线来分离两组 2D 点。以下图显示了该问题的几个解决方案:
有很多超平面可以解决问题
SVM 的目标是找到一个超平面,该超平面最大化到训练样本的距离。这些距离仅计算最接近超平面的向量。以下图显示了分离两组 2D 点的最佳超平面:
一个最大化到训练样本距离的最佳超平面。R 是最大间隔
在接下来的几节中,我们将向您展示如何使用支持向量机(SVM)来训练和测试面部表情数据。
训练阶段
训练 SVM 中最困难的部分之一是参数选择。没有对 SVM 工作原理的深入了解,无法解释所有内容。幸运的是,OpenCV 实现了trainAuto
方法来自动估计参数。如果你对 SVM 有足够的了解,你应该尝试使用自己的参数。在本节中,我们将介绍trainAuto
方法,以向您概述 SVM。
SVM 本质上是构建二进制(2 类)分类中最佳超平面的技术。在我们的面部表情问题中,我们想要对七个表情进行分类。一对一和一对多是我们可以使用 SVM 的两种常见方法。一对一方法为每个类别训练一个 SVM。在我们的例子中有七个 SVM。对于类别 i,所有标签为 i 的样本被视为正样本,其余样本被视为负样本。当数据集样本在类别之间不平衡时,这种方法容易出错。一对多方法为每个不同类别的成对训练一个 SVM。总的 SVM 数量是 N(N-1)/2* 个 SVM。这意味着在我们的例子中有 21 个 SVM。
在 OpenCV 中,你不必遵循这些方法。OpenCV 支持训练一个多类 SVM。然而,为了获得更好的结果,你应该遵循上述方法。我们仍然将使用一个多类 SVM。训练和测试过程将更简单。
接下来,我们将演示我们的实现来解决面部表情问题。
首先,我们创建一个 SVM 的实例:
Ptr<ml::SVM> svm = ml::SVM::create();
如果你想要更改参数,你可以在 svm
变量中调用 set
函数,如下所示:
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::RBF);
-
类型:它是 SVM 公式的类型。有五个可能的值:
C_SVC
、NU_SVC
、ONE_CLASS
、EPS_SVR
和NU_SVR
。然而,在我们的多类分类中,只有C_SVC
和NU_SVC
是合适的。这两个之间的区别在于数学优化问题。目前,我们可以使用C_SVC
。 -
核函数:它是 SVM 核的类型。有四个可能的值:
LINEAR
、POLY
、RBF
和SIGMOID
。核函数是一个将训练数据映射到更高维空间的功能,使得数据线性可分。这也被称为 核技巧。因此,我们可以使用核的支持在非线性情况下使用 SVM。在我们的情况下,我们选择最常用的核函数,RBF。你可以在这几个核函数之间切换并选择最佳选项。
你也可以设置其他参数,如 TermCriteria、Degree、Gamma。我们只是使用默认参数。
第二,我们创建一个 ml::TrainData
变量来存储所有训练集数据:
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
-
train_features
:它是一个 Mat,其中每行包含一个特征向量。train_features
的行数是训练样本的数量,列数是一个特征向量的大小。 -
SampleTypes::ROW_SAMPLE
:它指定每个特征向量位于一行。如果你的特征向量位于列中,你应该使用 COL_SAMPLE。 -
train_labels
:它是一个 Mat,其中包含每个训练特征的标签。在 SVM 中,train_labels
将是一个 Nx1 矩阵,N 是训练样本的数量。每行的值是对应样本的真实标签。在撰写本文时,train_labels
的类型应该是CV_32S
。否则,你可能会遇到错误。以下是我们创建train_labels
变量的代码:Mat train_labels = Mat::zeros( labels.rows, 1, CV_32S); for(int i = 0 ; i < labels.rows; i ++){ train_labels.at<unsigned int>(i, 0) = labels.at<int>(i, 0); }
最后,我们将 trainData
传递给 trainAuto
函数,以便 OpenCV 可以自动选择最佳参数。trainAuto
函数的接口包含许多其他参数。为了保持简单,我们将使用默认参数:
svm->trainAuto(trainData);
测试阶段
在我们训练了 SVM 之后,我们可以将一个测试样本传递给 svm
模型的预测函数,并接收一个标签预测,如下所示:
float predict = svm->predict(sample);
在这种情况下,样本是一个特征向量,就像训练特征中的特征向量一样。响应是样本的标签。
多层感知器
OpenCV 实现了最常见的人工神经网络类型,即多层感知器(MLP)。一个典型的 MLP 由一个输入层、一个输出层和一个或多个隐藏层组成。它被称为监督学习方法,因为它需要期望的输出来进行训练。有了足够的数据,MLP,如果给定足够的隐藏层,可以近似任何函数到任何期望的精度。
具有一个隐藏层的多层感知器可以表示如下图所示:
单隐藏层感知器
MLP 如何学习的一个详细解释和证明超出了本章的范围。其思想是每个神经元的输出是前一层神经元的函数。
在上述单隐藏层 MLP 中,我们使用以下符号:
输入层:x[1] x[2]
隐藏层:h[1] h[2] h[3]
输出层:y
每个神经元之间的每个连接都有一个权重。上图所示的权重是当前层中的神经元 i(即 i = 3)和前一层中的神经元 j(即 j = 2)之间的权重,表示为 w[ij]。每个神经元都有一个权重为 1 的偏置值,表示为 w[i,bias]。
神经元 i 的输出是激活函数f的结果:
激活函数有很多种类型。在 OpenCV 中,有三种类型的激活函数:恒等函数、Sigmoid 和高斯。然而,在撰写本文时,高斯函数并不完全受支持,恒等函数也不常用。我们建议您使用默认的激活函数,即 Sigmoid。
在接下来的章节中,我们将向您展示如何训练和测试一个多层感知器。
训练阶段
在训练阶段,我们首先定义网络,然后训练网络。
定义网络
在我们的面部表情问题中,我们将使用一个简单的四层神经网络。该网络有一个输入层、两个隐藏层和一个输出层。
首先,我们需要创建一个矩阵来保存层的定义。这个矩阵有四行一列:
Mat layers = Mat(3, 1, CV_32S);
然后,我们为每一层分配神经元数量,如下所示:
layers.row(0) = Scalar(feature_size);
layers.row(1) = Scalar(20);
layers.row(2) = Scalar(num_of_labels);
在这个网络中,输入层的神经元数量必须等于每个特征向量的元素数量,输出层的神经元数量是面部表情标签的数量(feature_size
等于train_features.cols
,其中train_features
是包含所有特征的 Mat,num_of_labels
在我们的实现中等于 7)。
我们实现中的上述参数并非最优。您可以尝试为不同数量的隐藏层和神经元数量尝试不同的值。请记住,隐藏神经元的数量不应超过训练样本的数量。在隐藏层中神经元数量和网络的层数选择上非常困难。如果您做一些研究,您会发现一些经验规则和诊断技术。选择这些参数的最佳方式是实验。基本上,层和隐藏神经元越多,网络的能力就越强。然而,更多的能力可能会导致过拟合。最重要的规则之一是训练集中的示例数量应大于网络中的权重数量。根据我们的经验,您应该从一个包含少量神经元的隐藏层开始,并计算泛化误差和训练误差。然后,您应该修改神经元数量并重复此过程。
注意
记得在更改参数时制作图表以可视化误差。请记住,神经元的数量通常介于输入层大小和输出层大小之间。经过几次迭代后,您可以决定是否添加额外的层。
然而,在这种情况下,我们没有太多数据。这使得网络难以训练。我们可能不会添加神经元和层来提高性能。
训练网络
首先,我们创建一个网络变量,ANN_MLP,并将层定义添加到网络中:
Ptr<ml::ANN_MLP> mlp = ml::ANN_MLP::create();
mlp->setLayerSizes(layers);
然后,我们需要为训练算法准备一些参数。MLP 的训练有两种算法:反向传播算法和 RPROP 算法。RPROP 是默认的训练算法。RPROP 有很多参数,所以我们为了简单起见将使用反向传播算法。
下面是我们为反向传播算法设置参数的代码:
mlp->setTrainMethod(ml::ANN_MLP::BACKPROP);
mlp->setActivationFunction(ml::ANN_MLP::SIGMOID_SYM, 0, 0);
mlp->setTermCriteria(TermCriteria(TermCriteria::EPS+TermCriteria::COUNT, 100000, 0.00001f));
我们将TrainMethod
设置为BACKPROP
以使用反向传播算法。选择 Sigmoid 作为激活函数。在 OpenCV 中有三种激活类型:IDENTITY
、GAUSSIAN
和SIGMOID
。您可以查看本节概述以获取更多详细信息。
最后一个参数是TermCriteria
。这是算法终止标准。您可以在前一个部分中关于 kmeans 算法的解释中看到这个参数的解释。
接下来,我们创建一个TrainData
变量来存储所有训练集。其接口与 SVM 部分相同。
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, train_labels);
train_features
是一个 Mat,它存储了所有训练样本,就像在 SVM 部分中做的那样。然而,train_labels
是不同的:
-
train_features
:这是一个 Mat,它包含每个特征向量作为一行,就像我们在 SVM 中做的那样。train_features
的行数是训练样本的数量,列数是一个特征向量的大小。 -
train_labels
:这是一个包含每个训练特征标签的 Mat。与 SVM 中的 Nx1 矩阵不同,MLP 中的train_labels
应该是一个 NxM 矩阵,N 是训练样本的数量,M 是标签的数量。如果第 i 行的特征被分类为标签 j,则train_labels
的位置 (i, j) 将是 1。否则,值将是零。创建train_labels
变量的代码如下:Mat train_labels = Mat::zeros( labels.rows, num_of_label, CV_32FC1); for(int i = 0 ; i < labels.rows; i ++){ int idx = labels.at<int>(i, 0); train_labels.at<float>(i, idx) = 1.0f; }
最后,我们使用以下代码训练网络:
mlp->train(trainData);
训练过程需要几分钟才能完成。如果你有大量的训练数据,可能需要几小时。
测试阶段
一旦我们训练了我们的 MLP,测试阶段就非常简单。
首先,我们创建一个 Mat 来存储网络的响应。响应是一个数组,其长度是标签的数量。
Mat response(1, num_of_labels, CV_32FC1);
然后,我们假设我们有一个名为 sample 的 Mat,它包含一个特征向量。在我们的面部表情案例中,其大小应该是 1x1000。
我们可以调用 mlp
模型的 predict
函数来获取响应,如下所示:
mlp->predict(sample, response);
输入样本的预测标签是响应数组中最大值的索引。你可以通过简单地遍历数组来找到标签。这种类型响应的缺点是,如果你想为每个响应应用一个 softmax
函数以获得概率,你必须这样做。在其他神经网络框架中,通常有一个 softmax 层来处理这种情况。然而,这种类型响应的优点是保留了每个响应的大小。
K-Nearest Neighbors (KNN)
K-Nearest Neighbors (KNN) 是一种非常简单的机器学习算法,但在许多实际问题中表现良好。KNN 的思想是将未知样本分类为 k 个最近已知样本中最常见的类别。KNN 也被称为非参数懒惰学习算法。这意味着 KNN 对数据分布没有任何假设。由于它只缓存所有训练示例,因此训练过程非常快。然而,测试过程需要大量的计算。以下图示展示了 KNN 在二维点情况下的工作原理。绿色点是一个未知样本。KNN 将在空间中找到 k 个最近的已知样本(本例中 k = 5)。有三个红色标签的样本和两个蓝色标签的样本。因此,预测的标签是红色。
解释 KNN 如何预测未知样本的标签
训练阶段
KNN 算法的实现非常简单。我们只需要三行代码来训练一个 KNN 模型:
Ptr<ml::KNearest> knn = ml::KNearest::create();
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
knn->train(trainData);
上述代码与 SVM 相同:
-
train_features
:这是一个包含每个特征向量作为行的 Mat。train_features
中的行数是训练样本的数量,列数是一个特征向量的大小。 -
train_labels
: 这是一个包含每个训练特征标签的 Mat。在 KNN 中,train_labels
是一个 Nx1 的矩阵,N 是训练样本的数量。每一行的值是对应样本的真实标签。这个 Mat 的类型应该是CV_32S
。
测试阶段
测试阶段非常直接。我们只需将一个特征向量传递给knn
模型的findNearest
方法,就可以获得标签:
Mat predictedLabels;
knn->findNearest(sample, K, predictedLabels);
第二个参数是最重要的参数。它是用于分类可能使用的最大邻居数。理论上,如果有无限数量的样本可用,更大的 K 总是意味着更好的分类。然而,在我们的面部表情问题中,我们总共有 213 个样本,其中大约有 170 个样本在训练集中。因此,如果我们使用大的 K,KNN 最终可能会寻找非邻居的样本。在我们的实现中,K 等于 2。
预测标签存储在predictedLabels
变量中,可以按以下方式获取:
float prediction = bestLabels.at<float>(0,0);
正态贝叶斯分类器
正态贝叶斯分类器是 OpenCV 中最简单的分类器之一。正态贝叶斯分类器假设来自每个类别的特征向量是正态分布的,尽管不一定独立。这是一个有效的分类器,可以处理多个类别。在训练步骤中,分类器估计每个类别的分布的均值和协方差。在测试步骤中,分类器计算特征向量到每个类别的概率。在实践中,我们然后测试最大概率是否超过阈值。如果是,样本的标签将是具有最大概率的类别。否则,我们说我们无法识别该样本。
OpenCV 已经在 ml 模块中实现了这个分类器。在本节中,我们将向您展示如何在我们的面部表情问题中使用正态贝叶斯分类器的代码。
训练阶段
实现正态贝叶斯分类器的代码与 SVM 和 KNN 相同。我们只需要调用create
函数来获取分类器并开始训练过程。所有其他参数与 SVM 和 KNN 相同。
Ptr<ml::NormalBayesClassifier> bayes = ml::NormalBayesClassifier::create();
Ptr<ml::TrainData> trainData = ml::TrainData::create(train_features, ml::SampleTypes::ROW_SAMPLE, labels);
bayes->train(trainData);
测试阶段
使用正态贝叶斯分类器测试样本的代码与之前的方法略有不同:
-
首先,我们需要创建两个 Mat 来存储输出类别和概率:
Mat output, outputProb;
-
然后,我们调用模型的
predictProb
函数:bayes->predictProb(sample, output, outputProb);
-
计算出的概率存储在
outputProb
中,相应的标签可以检索如下:unsigned int label = output.at<unsigned int>(0, 0);
软件使用指南
我们已经实现了上述过程,使用训练集进行分类。使用该软件相当简单:
-
下载源代码。打开终端,切换到源代码文件夹。
-
使用以下命令使用
cmake
构建软件:mkdir build && cd build && cmake .. && make
-
您可以使用以下方式使用
train
工具:./train -algo <algorithm_name> -src <input_features> -dest <output_folder>
train
工具执行训练过程并在控制台上输出准确率。学习到的模型将被保存到输出文件夹中,以便进一步使用,文件名为 model.yml
。此外,特征提取的 kmeans 中心信息和 pca 信息也将保存在 features_extraction.yml
文件中。可用的参数包括:
-
algorithm_name
:这可以是mlp
、svm
、knn
、bayes
。这是学习算法的名称。 -
input_features
:这是prepare_dataset
工具的 YAML 特征文件的绝对路径。 -
output_folder
:这是您希望保存输出模型的文件夹的绝对路径。
评估
在本节中,我们将展示我们面部表情识别系统的性能。在我们的测试中,我们将保持每个学习算法的参数相同,仅更改特征提取。我们将使用聚类数量等于 200、500、1,000、1,500、2,000 和 3,000 来评估特征提取。
下表显示了系统在聚类数量等于 200、500、1,000、1,500、2,000 和 3,000 时的准确率。
表 1:系统在 1,000 个聚类下的准确率(%)
K = 1000 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 72.7273 | 93.1818 | 81.8182 | 88.6364 |
SURF | 61.3636 | 79.5455 | 72.7273 | 79.5455 |
BRISK | 61.3636 | 65.9091 | 59.0909 | 68.1818 |
KAZE | 50 | 79.5455 | 61.3636 | 77.2727 |
DAISY | 59.0909 | 77.2727 | 65.9091 | 81.8182 |
DENSE-SIFT | 20.4545 | 45.4545 | 43.1818 | 40.9091 |
表 2:系统在 500 个聚类下的准确率(%)
K = 500 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 56.8182 | 70.4545 | 75 | 77.2727 |
SURF | 54.5455 | 63.6364 | 68.1818 | 79.5455 |
BRISK | 36.3636 | 59.0909 | 52.2727 | 52.2727 |
KAZE | 47.7273 | 56.8182 | 63.6364 | 65.9091 |
DAISY | 54.5455 | 75 | 63.6364 | 75 |
DENSE-SIFT | 27.2727 | 43.1818 | 38.6364 | 43.1818 |
表 3:系统在 200 个聚类下的准确率(%)
K = 200 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 50 | 68.1818 | 65.9091 | 75 |
SURF | 43.1818 | 54.5455 | 52.2727 | 63.6364 |
BRISK | 29.5455 | 47.7273 | 50 | 54.5455 |
KAZE | 50 | 59.0909 | 72.7273 | 59.0909 |
DAISY | 45.4545 | 68.1818 | 65.9091 | 70.4545 |
DENSE-SIFT | 29.5455 | 43.1818 | 40.9091 | 31.8182 |
表 4:系统在 1,500 个聚类下的准确率(%)
K = 1500 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 45.4545 | 84.0909 | 75 | 79.5455 |
SURF | 72.7273 | 88.6364 | 79.5455 | 86.3636 |
BRISK | 54.5455 | 72.7273 | 56.8182 | 68.1818 |
KAZE | 45.4545 | 79.5455 | 72.7273 | 77.2727 |
DAISY | 61.3636 | 88.6364 | 65.9091 | 81.8182 |
DENSE-SIFT | 34.0909 | 47.7273 | 38.6364 | 38.6364 |
表 5:系统在 2,000 个聚类下的准确率(%)
K = 2000 | MLP | SVM | KNN | Normal Bayes |
---|---|---|---|---|
SIFT | 63.6364 | 88.6364 | 81.8182 | 88.6364 |
SURF | 65.9091 | 84.0909 | 68.1818 | 81.8182 |
BRISK | 47.7273 | 68.1818 | 47.7273 | 61.3636 |
KAZE | 47.7273 | 77.2727 | 72.7273 | 75 |
DAISY | 77.2727 | 81.8182 | 72.7273 | 84.0909 |
DENSE-SIFT | 38.6364 | 45.4545 | 36.3636 | 43.1818 |
表 6:具有 3,000 个簇的系统准确率(%)
K = 3000 | MLP | SVM | KNN | 正常贝叶斯 |
---|---|---|---|---|
SIFT | 52.2727 | 88.6364 | 77.2727 | 86.3636 |
SURF | 59.0909 | 79.5455 | 65.9091 | 77.2727 |
BRISK | 52.2727 | 65.9091 | 43.1818 | 59.0909 |
KAZE | 61.3636 | 81.8182 | 70.4545 | 84.0909 |
DAISY | 72.7273 | 79.5455 | 70.4545 | 68.1818 |
DENSE-SIFT | 27.2727 | 47.7273 | 38.6364 | 45.4545 |
使用不同学习算法的评估
我们可以用上述结果创建图表,比较以下图中特征与学习算法之间的性能。我们可以看到,在大多数情况下,SVM 和正常贝叶斯比其他算法有更好的结果。在 1,000 个簇的情况下,SVM 和 SIFT 的最佳结果是 93.1818%。MLP 在几乎所有情况下都有最低的结果。一个原因是 MLP 需要大量数据以防止过拟合。我们只有大约 160 个训练图像。然而,每个样本的特征大小在 100 到 150 之间。即使有两个隐藏神经元,权重的数量也大于样本的数量。KNN 似乎比 MLP 表现更好,但无法击败 SVM 和正常贝叶斯。
不同簇数量下特征性能与机器算法之间的关系
不同机器算法下特征性能与聚类中心数量之间的关系
使用不同特征的评估
在图中,不同簇数量下特征性能与机器算法之间的关系,我们评估了六个特征。在大多数情况下,SIFT 给出了最佳结果。DAISY 与 SIFT 相当。在某些情况下,KAZE 也给出了良好的结果。由于结果较差,DENSE-SIFT 不是我们面部表情问题的好选择。此外,DENSE 特征的计算成本非常高。总之,SIFT 仍然是最佳选择。然而,SIFT 受专利保护。您可能想看看 DAISY 或 KAZE。我们建议您在自己的数据上评估并选择最合适的特征。
使用不同簇数量的评估
在图中,“不同机器算法下特征性能与聚类数量的影响”,我们绘制了一个图表来可视化聚类数量对性能的影响。如您所见,不同特征之间的聚类数量不同。在 SIFT、KAZE 和 BRISK 中,最佳聚类数量是 1,000。然而,在 SURF、DAISY 和 DENSE-SIFT 中,1,500 是更好的选择。基本上,我们不希望聚类数量太大。在 kmeans 中的计算成本随着聚类数量的增加而增加,尤其是在 DENSE-SIFT 中。
系统概述
在本节中,我们将解释如何在您的应用程序中应用训练好的模型。给定一张人脸图像,我们分别检测和处理每个面部。然后,我们找到特征点并提取面部区域。从图像中提取特征并传递给 kmeans 以获得一个 1,000 维的特征向量。对该特征向量应用 PCA 以降低其维度。使用学习到的机器学习模型来预测输入面部表情。
下图显示了预测图像中面部表情的完整过程:
预测新图像中面部表情的过程
进一步阅读
我们介绍了一个基本的面部表情系统。如果您对这个主题真正感兴趣,您可能想阅读本节以获取更多关于如何提高系统性能的指导。在本节中,我们将向您介绍编译 opencv_contrib
模块、Kaggle 面部表情数据集和 k-交叉验证方法。我们还将提供一些关于如何获得更好的特征提取的建议。
编译 opencv_contrib 模块
在本节中,我们将介绍在基于 Linux 的系统中编译 opencv_contrib
的过程。如果您使用 Windows,可以使用具有相同选项的 Cmake GUI。
首先,将 opencv
仓库克隆到您的本地机器上:
git clone https://github.com/Itseez/opencv.git --depth=1
第二,将 opencv_contrib
仓库克隆到您的本地机器上:
git clone https://github.com/Itseez/opencv_contrib --depth=1
切换到 opencv
文件夹并创建一个构建目录:
cd opencv
mkdir build
cd build
使用 opencv_contrib 支持从源代码构建 OpenCV。您应将 OPENCV_EXTRA_MODULES_PATH
修改为您的机器上 opencv_contrib
的位置:
cmake -D CMAKE_BUILD_TYPE=RELEASE -D CMAKE_INSTALL_PREFIX=/usr/local -D OPENCV_EXTRA_MODULES_PATH=~/opencv_contrib/ ..
make -j4
make install
Kaggle 面部表情数据集
Kaggle 是一个优秀的数据科学家社区。Kaggle 主办了许多比赛。2013 年,有一个面部表情识别挑战。
注意
目前,您可以通过以下链接访问完整数据集:
www.kaggle.com/c/challenges-in-representation-learning-facial-expression-recognition-challenge/
数据集包含 48x48 像素的灰度人脸图像。共有 28,709 个训练样本,3,589 个公开测试图像和 3,589 个最终测试图像。数据集包含七种表情(愤怒、厌恶、恐惧、快乐、悲伤、惊讶和中性)。获胜者获得了 69.769 %的分数。由于这个数据集非常大,所以我们认为我们的基本系统可能无法直接使用。我们相信,如果您想使用这个数据集,您应该尝试提高系统的性能。
面部特征点
在我们的面部表情系统中,我们使用人脸检测作为预处理步骤来提取面部区域。然而,人脸检测容易发生错位,因此特征提取可能不可靠。近年来,最常见的方法之一是使用面部特征点。在这种方法中,检测面部特征点并用于对齐面部区域。许多研究人员使用面部特征点来提取面部组件,如眼睛、嘴巴等,并分别进行特征提取。
什么是面部特征点?
面部特征点是面部组件的预定义位置。下面的图显示了 iBUG 组的一个 68 点系统的示例 (ibug.doc.ic.ac.uk/resources/facial-point-annotations
)
iBUG 组的一个 68 个特征点系统的示例
你如何检测面部特征点?
在面部区域内检测面部特征点有几种方法。我们将为您提供一些解决方案,以便您能够轻松开始您的项目。
-
主动形状模型:这是解决此问题最常见的方法之一。您可能会发现以下库很有用:
-
Cao 等人通过显式回归进行人脸对齐:这是关于面部特征点的最新工作之一。这个系统非常高效且非常准确。您可以在以下超链接中找到开源实现:
github.com/soundsilence/FaceAlignment
你如何使用面部特征点?
您可以使用面部特征点以多种方式。我们将为您提供一些指南:
-
您可以使用面部特征点将面部区域对齐到共同的标准,并提取特征向量,就像我们在基本面部表情系统中做的那样。
-
您可以在不同的面部组件(如眼睛和嘴巴)中分别提取特征向量,并将它们组合成一个特征向量进行分类。
-
您可以使用面部特征点的位置作为特征向量,并忽略图像中的纹理。
-
您可以为每个面部组件构建分类模型,并以加权方式组合预测结果。
提高特征提取
特征提取是面部表情分析中最重要的部分之一。最好为你的问题选择合适的特征。在我们的实现中,我们只在 OpenCV 中使用了少数几个特征。我们建议你尝试 OpenCV 中所有可能的特征。以下是 Open CV 支持的特性列表:BRIEF, BRISK, FREAK, ORB, SIFT, SURF, KAZE, AKAZE, FAST, MSER, 和 STAR。
社区中还有其他一些非常适合你问题的优秀特性,例如 LBP, Gabor, HOG 等。
K 折交叉验证
K 折交叉验证是估计分类器性能的常用技术。给定一个训练集,我们将将其划分为 k 个分区。对于 k 次实验中的每个折 i,我们将使用不属于折 i 的所有样本来训练分类器,并使用折 i 中的样本来测试分类器。
K 折交叉验证的优势在于数据集中的所有示例最终都会用于训练和验证。
将原始数据集划分为训练集和测试集是很重要的。然后,训练集将用于 k 折交叉验证,而测试集将用于最终测试。
交叉验证结合了每个实验的预测误差,从而得到模型更准确的估计。它非常有用,尤其是在我们训练数据不多的情况下。尽管计算时间较长,但如果你想提高系统的整体性能,使用复杂特征是一个很好的主意。
摘要
本章展示了 OpenCV 3 中面部表情系统的完整过程。我们走过了系统的每一步,并为每一步提供了许多替代方案。本章还基于特性和学习算法对结果进行了评估。
最后,本章为你提供了一些进一步改进的提示,包括一个面部表情挑战,面部特征点方法,一些特性建议,以及 k 折交叉验证。
第四章:使用 Android Studio 和 NDK 实现全景图像拼接应用程序
全景是应用开发中的一个有趣的主题。在 OpenCV 中,拼接模块可以轻松地从一系列图像中创建全景图像。拼接模块的一个好处是图像序列不必按顺序排列,可以是任何方向。然而,在 OpenCV Android SDK 中,拼接模块不存在。因此,我们必须在 C++接口中使用拼接模块。幸运的是,Android 提供了原生开发工具包(NDK)来支持 C/C++的本地开发。在本章中,我们将指导您通过从 Java 捕获相机帧并在 OpenCV C++中使用 NDK 处理数据的步骤。
在本章中,您将学习:
-
如何制作一个完整的全景拼接应用程序
-
如何使用 Java 本地接口(JNI)在 Android Studio 中使用 OpenCV C++
-
如何使用拼接模块创建全景图像
介绍全景的概念
全景图像比普通图像提供了更广阔的视野,并允许他们完全体验场景。通过将全景范围扩展到 360 度,观众可以模拟转动他们的头部。全景图像可以通过拼接一系列重叠的图像来创建。
以下图展示了使用我们的应用程序捕捉的全景图像的演示。
在水平方向上捕捉的全景图像
为了捕捉全景图像,您必须在场景的不同角度捕捉许多图像,如下面的图所示。例如,您在房间的左侧拍摄第一张照片。然后,您将手机直接移动到新的角度开始捕捉。所有图像将被拼接在一起以创建全景图像。
展示如何平移手机以创建全景图像的插图
通常,全景应用程序仅支持水平方向上的图像捕捉。使用 OpenCV 的拼接模块,我们可以通过在两个方向上捕捉更多图像来扩展图像的高度。以下图显示了可以通过在水平和垂直方向上改变相机视图来捕捉的图像。
在两个方向上捕捉的全景图像
在本章中,我们将使用 OpenCV 3.0.0 在 Android 中实现全景应用程序。本章包含两个主要部分:
-
Android 部分:我们将使用 Android Studio 实现用户界面。在本章中,我们只实现了带有两个按钮(捕获和保存)的全景捕获活动。当捕捉到全景时,我们将将其保存到手机的内部存储中。
-
OpenCV 部分:我们将展示如何将 OpenCV 集成到 Android Studio 中,使用 NDK/JNI,并实现从 Android 部分捕获的一系列图像创建全景图像的代码。
在接下来的章节中,我们将展示在 Android Studio 中创建用户界面的过程。如果您想回顾 OpenCV 代码,您可以前往将 OpenCV 集成到 Android Studio部分,稍后再回来。
安卓部分 - 应用程序用户界面
在本节中,我们将向您展示一个基本用户界面,用于捕获并将全景保存到内部存储。基本上,用户将看到相机预览图像的全屏。当用户按下捕获按钮时,应用程序将捕获当前场景并将捕获的图像放置在当前视图之上的叠加层上。因此,用户知道他们刚刚捕获了什么,并且可以改变手机位置以捕获下一张图像。
以下是在用户打开应用程序后以及用户捕获图像时的应用程序截图:
用户捕获图像前后的用户界面示例
设置活动布局
首先,我们将使用 Android Studio 创建一个新的 Android 项目,其中包含一个空白活动。然后,我们将编辑app/src/main/res/layout/activity_main.xml
中的 MainActivity 布局 xml,如下所示:
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<SurfaceView
android:id="@+id/surfaceView"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<SurfaceView
android:id="@+id/surfaceViewOnTop"
android:layout_width="match_parent"
android:layout_height="match_parent"
/>
<Button
android:id="@+id/capture"
android:text="Capture"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:layout_alignParentBottom="true"
android:layout_centerHorizontal="true"
android:layout_marginBottom="10dp"
android:padding="10dp"
android:textColor="#FFF"
android:background="@android:color/holo_blue_dark"
/>
<Button
android:id="@+id/save"
android:text="Save"
android:layout_width="wrap_content"
android:layout_height="70dp"
android:padding="10dp"
android:textColor="#FFF"
android:background="@android:color/holo_purple"
android:layout_marginRight="10dp"
android:layout_alignTop="@+id/capture"
android:layout_alignParentRight="true"
android:layout_alignParentEnd="true" />
</RelativeLayout>
在此布局 xml 文件中,我们有两个 SurfaceView——一个用于相机预览,一个用于叠加层。我们还有一个按钮用于捕获图像,另一个按钮用于将全景图像保存到内部存储。
捕获相机帧
在本节中,我们将实现捕获相机帧并在 ID 为surfaceView
的 SurfaceView 上查看的过程。
在MainActivity
类的开头,我们将创建一些对象以与布局一起使用:
public class MainActivity extends ActionBarActivity {
private Button captureBtn, saveBtn; // used to interact with capture and save Button in UI
private SurfaceView mSurfaceView, mSurfaceViewOnTop; // used to display the camera frame in UI
private Camera mCam;
private boolean isPreview; // Is the camera frame displaying?
private boolean safeToTakePicture = true; // Is it safe to capture a picture?
在前面的代码中,我们创建了两个按钮和两个SurfaceViews
以与用户界面交互。我们还创建了一个 Camera 对象mCam
以打开相机。在我们的实现中,我们将使用 Android 方法打开相机并获取视频帧。OpenCV 还提供了一些其他打开相机的方法。然而,我们发现它们可能不会在所有 Android 设备上很好地工作,所以我们更喜欢使用 Android 方法中的相机。在本章中,我们只需要 Android API 中的 Camera 对象。这种方法的优势是您可以预期它在几乎任何 Android 设备上都能工作。缺点是您必须进行一些从相机字节数组到 Android Bitmap 的转换,以在 UI 上显示,并将 OpenCV Mat 用于图像处理。
注意
如果您想体验 OpenCV 类与相机交互,您可能想查看本书的第七章 7.陀螺仪视频稳定,陀螺仪视频稳定。
在onCreate
函数中,我们按以下方式设置这些对象:
@Override
protected void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
isPreview = false;
mSurfaceView = (SurfaceView)findViewById(R.id.surfaceView);
mSurfaceView.getHolder().addCallback(mSurfaceCallback);
mSurfaceViewOnTop = (SurfaceView)findViewById(R.id.surfaceViewOnTop);
mSurfaceViewOnTop.setZOrderOnTop(true); // necessary
mSurfaceViewOnTop.getHolder().setFormat(PixelFormat.TRANSPARENT);
captureBtn = (Button) findViewById(R.id.capture);
captureBtn.setOnClickListener(captureOnClickListener);
saveBtn = (Button) findViewById(R.id.save);
saveBtn.setOnClickListener(saveOnClickListener);
}
首先,我们将isPreview
初始化为 false,并将布局中的mSurfaceView
分配给SurfaceView
。然后,我们获取mSurfaceView
的持有者并向其添加一个回调。变量mSurfaceCallback
是我们稍后将要创建的SurfaceHolder.Callback
实例。我们还把mSurfaceViewOnTop
分配给布局中的另一个SurfaceView
,因为我们希望这个SurfaceView
成为相机视图的叠加层。我们需要设置 Z 顺序为 true,并将持有者格式设置为TRANSPARENT
。最后,我们设置捕获和保存按钮,并设置相应的OnClickListener
。在下一部分,我们将专注于在SurfaceView
上显示相机帧。因此,我们将创建一个基本的OnClickListener
,如下所示:
View.OnClickListener captureOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
}
};
View.OnClickListener saveOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
}
};
使用 Camera API 获取相机帧
正如我们之前所说的,我们将使用 Android API 在 Android 中获取相机帧。目前,有两个版本的 Camera API,即android.hardware.Camera
和android.hardware.camera2
。我们将使用android.hardware.Camera
,因为它支持大多数 Android 4.4 及以下版本的设备。在 Android 5.0 及以后的版本中,此 API 已被弃用,并由 camera2 替代。我们仍然可以在 Android 5.0 中使用android.hardware.Camera
。如果您想针对 Android 5.0,我们建议您在您的应用程序中尝试使用 camera2。
为了使用相机,我们需要在AndroidManifest.xml
中添加以下行以获取对相机的权限。此外,我们还请求写入存储的权限,因为我们将会将全景图像保存到内部存储。
<uses-feature android:name="android.hardware.camera" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
我们希望将mSurfaceView
设置为显示相机帧,因此我们将在mSurfaceView
的回调中设置相机参数。我们需要创建变量mSurfaceCallback
,如下所示:
SurfaceHolder.Callback mSurfaceCallback = new SurfaceHolder.Callback(){
@Override
public void surfaceCreated(SurfaceHolder holder) {
try {
// Tell the camera to display the frame on this surfaceview
mCam.setPreviewDisplay(holder);
} catch (IOException e) {
e.printStackTrace();
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// Get the default parameters for camera
Camera.Parameters myParameters = mCam.getParameters();
// Select the best preview size
Camera.Size myBestSize = getBestPreviewSize( myParameters );
if(myBestSize != null){
// Set the preview Size
myParameters.setPreviewSize(myBestSize.width, myBestSize.height);
// Set the parameters to the camera
mCam.setParameters(myParameters);
// Rotate the display frame 90 degree to view in portrait mode
mCam.setDisplayOrientation(90);
// Start the preview
mCam.startPreview();
isPreview = true;
}
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
}
};
在此代码中,我们在surfaceCreated
函数中调用setPreviewDisplay
方法,告诉相机在mSurfaceView
上显示相机帧。之后,在surfaceChanged
函数中,我们设置相机参数,将显示方向更改为 90 度并开始预览过程。getBestPreviewSize
函数是一个获取具有最大像素数的预览尺寸的函数。getBestPreviewSize
很简单,如下所示:
private Camera.Size getBestPreviewSize(Camera.Parameters parameters){
Camera.Size bestSize = null;
List<Camera.Size> sizeList = parameters.getSupportedPreviewSizes();
bestSize = sizeList.get(0);
for(int i = 1; i < sizeList.size(); i++){
if((sizeList.get(i).width * sizeList.get(i).height) >
(bestSize.width * bestSize.height)){
bestSize = sizeList.get(i);
}
}
return bestSize;
}
最后,我们需要在onResume
中添加一些代码来打开相机,并在onPause
中释放相机:
@Override
protected void onResume() {
super.onResume();
mCam = Camera.open(0); // 0 for back camera
}
@Override
protected void onPause() {
super.onPause();
if(isPreview){
mCam.stopPreview();
}
mCam.release();
mCam = null;
isPreview = false;
}
在这个时候,我们可以在真实设备上安装并运行应用程序。以下图显示了在运行 Android 5.1.1 的 Nexus 5 上我们的应用程序的截图:
Nexus 5 在 Android 5.1.1 上运行 Camera 预览模式的截图
在我们的应用程序中,我们不希望布局旋转,因此我们将活动方向设置为纵向模式。这是可选的。如果您想这样做,您只需在AndroidManifest.xml
中更改您的活动,如下所示:
<activity
android:screenOrientation="portrait"
android:name=".MainActivity"
android:label="@string/app_name" >
实现捕获按钮
在本节中,我们将向您展示如何实现捕获按钮的 OnClickListener
。当用户点击捕获按钮时,我们希望应用程序能够拍摄当前场景的图片。使用 Camera API,我们可以使用 takePicture
函数来捕获图片。这个函数的好处是输出图像的分辨率非常高。例如,当我们的应用程序在 Nexus 5 上运行时,尽管预览大小是 1920x1080,但捕获图像的分辨率是 3264x2448。我们将 captureOnClickListener
改动如下:
View.OnClickListener captureOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
if(mCam != null && safeToTakePicture){
// set the flag to false so we don't take two picture at a same time
safeToTakePicture = false;
mCam.takePicture(null, null, jpegCallback);
}
}
};
在 onClick
函数中,我们检查相机是否已初始化,并且标志 safeToTakePicture
是 true
。然后,我们将标志设置为 false
,这样我们就不可能在同一时间拍摄两张图片。Camera 实例的 takePicture
函数需要三个参数。第一个和第二个参数分别是快门回调和原始数据回调。这些函数在不同的设备上可能被调用得不同,所以我们不想使用它们,并将它们设置为 null。最后一个参数是当相机以 JPEG 格式保存图片时被调用的回调。
Camera.PictureCallback jpegCallback = new Camera.PictureCallback() {
public void onPictureTaken(byte[] data, Camera camera) {
// decode the byte array to a bitmap
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length);
// Rotate the picture to fit portrait mode
Matrix matrix = new Matrix();
matrix.postRotate(90);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, false);
// TODO: Save the image to a List to pass them to OpenCV method
Canvas canvas = null;
try {
canvas = mSurfaceViewOnTop.getHolder().lockCanvas(null);
synchronized (mSurfaceViewOnTop.getHolder()) {
// Clear canvas
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
// Scale the image to fit the SurfaceView
float scale = 1.0f * mSurfaceView.getHeight() / bitmap.getHeight();
Bitmap scaleImage = Bitmap.createScaledBitmap(bitmap, (int)(scale * bitmap.getWidth()), mSurfaceView.getHeight() , false);
Paint paint = new Paint();
// Set the opacity of the image
paint.setAlpha(200);
// Draw the image with an offset so we only see one third of image.
canvas.drawBitmap(scaleImage, -scaleImage.getWidth() * 2 / 3, 0, paint);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (canvas != null) {
mSurfaceViewOnTop.getHolder().unlockCanvasAndPost(canvas);
}
}
// Start preview the camera again and set the take picture flag to true
mCam.startPreview();
safeToTakePicture = true;
}
};
首先,onPictureTaken
提供了捕获图像的字节数组,因此我们希望将其解码为 Bitmap 实例。因为相机传感器以横幅模式捕获图像,所以我们希望应用一个旋转矩阵来获得竖幅模式的图像。然后,我们希望将此图像保存以传递一系列图像到 OpenCV 粘合模块。由于此代码需要 OpenCV 库,我们将稍后实现这部分。之后,我们将获得叠加 SurfaceView
的画布,并尝试在屏幕上绘制图像。以下是在预览层之上的叠加层的演示。最后,我们将再次启动预览过程,并将 safeToTakePicture
标志设置为 true
。
用户在 Nexus 5(运行 Android 5.1.1)上捕获图像后的应用程序截图
实现保存按钮
目前,保存按钮相当简单。我们将假设当用户点击保存按钮时,我们将启动一个新线程来执行图像处理任务:
View.OnClickListener saveOnClickListener = new View.OnClickListener() {
@Override
public void onClick(View v) {
Thread thread = new Thread(imageProcessingRunnable);
thread.start();
}
};
在 imageProcessingRunnable
中,我们希望在处理开始时显示一个处理对话框,并在一切完成后关闭对话框。为了实现这一点,我们首先创建一个 ProgressDialog
实例:
ProgressDialog ringProgressDialog;
然后,imageProcessingRunnable
的实现如下:
private Runnable imageProcessingRunnable = new Runnable() {
@Override
public void run() {
showProcessingDialog();
// TODO: implement OpenCV parts
closeProcessingDialog();
}
};
我们将简单地调用 showProcessingDialog
来显示进度对话框,并调用 closeProcessingDialog
来关闭对话框。中间的步骤相当复杂,需要大量的 OpenCV 函数,所以我们将其留到后面的部分。显示和关闭进度对话框的函数如下:
private void showProcessingDialog(){
runOnUiThread(new Runnable() {
@Override
public void run() {
mCam.stopPreview();
ringProgressDialog = ProgressDialog.show(MainActivity.this, "", "Panorama", true);
ringProgressDialog.setCancelable(false);
}
});
}
private void closeProcessingDialog(){
runOnUiThread(new Runnable() {
@Override
public void run() {
mCam.startPreview();
ringProgressDialog.dismiss();
}
});
}
在 showProcessingDialog
中,我们将停止相机预览以减少设备上的不必要的计算成本,而在 closeProcessingDialog
中,我们将再次启动相机预览,以便用户可以捕获更多的全景图像。我们必须将这些代码放在 runOnUiThread
中,因为这些代码与 UI 元素交互。
在下一节中,我们将向您展示如何使用 OpenCV 实现应用程序的剩余部分。
将 OpenCV 集成到 Android Studio 中
在本节中,我们将向您展示如何使用原生开发套件将 OpenCV 集成到 Android Studio 中,并使用 C++ 中的 OpenCV 粘合模块创建最终的全景图像。我们还将使用 OpenCV Android SDK Java 进行一些计算,以展示 Java 和 C++ 接口之间的交互过程。
将 OpenCV Android SDK 编译到 Android Studio 项目
正式来说,OpenCV Android SDK 是一个 Eclipse 项目,这意味着我们无法简单地将其用于我们的 Android Studio 项目。我们需要将 OpenCV Android SDK 转换为 Android Studio 项目,并将其作为模块导入到我们的应用程序中。
注意
我们假设您已从 opencv.org/downloads.html
下载了最新的 OpenCV for Android。在撰写本文时,我们现在有 OpenCV for Android 3.0.0。
让我们将下载的文件解压到您喜欢的路径,例如,/Volumes/Data/OpenCV/OpenCV-android-sdk
。
然后,我们需要打开一个新的 Android Studio 窗口并选择导入项目(Eclipse ADT、Gradle 等等)。在弹出的窗口中,你应该选择 java
文件夹在 OpenCV-android-sdk/sdk/java
,然后点击确定。
导入项目可视化
在下一个窗口中,我们将选择一个路径来存储新的 OpenCV SDK 项目。在我们的例子中,我们选择 /Volumes/Data/OpenCV/opencv-java
并点击下一步。
选择导入目标可视化
在最后一个窗口中,我们只需点击完成并等待 Android Studio 完成 Gradle 构建过程。基本上,Gradle 是 Android Studio 的默认构建系统。在这一步,我们想确保 OpenCV SDK 可以成功编译。一个常见错误是您尚未下载所需的 Android SDK。错误信息非常直接。您可以按照消息解决问题。在我们的例子中,正如以下截图所示,没有问题。
构建竞争可视化
此时,我们可以关闭此项目并打开我们的全景项目。
设置 Android Studio 以与 OpenCV 一起工作
为了在我们的项目中使用 OpenCV,我们需要将 OpenCV Android SDK 导入到我们的项目中。有了这个 SDK,我们可以使用 OpenCV Java API 并轻松执行图像处理任务。此外,我们必须进一步操作,告诉 Android Studio 编译 OpenCV C++ 代码以在 Native Development Kit (NDK) 中使用 OpenCV。我们将把这个部分分成三个小节:导入 Android SDK、创建 Java-C++ 交互和编译 OpenCV C++。
导入 OpenCV Android SDK
我们假设你已经打开了全景项目。我们需要按照以下方式导入上一节中转换的 OpenCV Android SDK:
文件 | 新建 | 导入模块
在 新建模块 窗口中,我们将选择源目录到转换后的项目。在我们的例子中,我们将选择 /Volumes/Data/OpenCV/opencv-java
。然后,我们将勾选导入复选框,将模块名称更改为 :opencv-java
,如以下截图所示,然后点击 完成:
新建模块窗口
接下来,我们需要修改 app
文件夹中的 build.gradle
文件,在 dependencies
部分添加一行:
dependencies {
compile fileTree(dir: 'libs', include: ['*.jar'])
compile 'com.android.support:appcompat-v7:22.1.1'
compile project(":opencv-java")
}
最后,我们必须通过按钮 Sync Project with Gradle Files 同步项目。
注意
如果你只需要 OpenCV Java 接口而不想使用 OpenCV C++,你必须将 OpenCV-android-sdk/sdk/native/libs
中的 libs
文件夹复制到你的 app/src/main
文件夹。然后,你必须将以下 loadLibrary
代码添加到你的类文件中:
static {
//If you use OpenCV 2.*, use "opencv_java"
System.loadLibrary("opencv_java3");
}
使用 Java Native Interface (JNI) 创建 Java 和 C++ 交互
在我们开始编译过程之前,我们将创建一个名为 NativePanorama.java
的类文件,并向 NativePanorama
类添加一个方法:
public class NativePanorama {
public native static void processPanorama(long[] imageAddressArray, long outputAddress);
}
processPanorama
方法将接收每个图像的长地址数组以及一个输出图像的长地址。
你必须重新构建项目才能遵循接下来的步骤。详细说明将在下一段落中:
-
使用
javah
命令行创建 C++ 头文件 -
在
jni
文件夹中为新建的头文件创建一个.cpp
文件以实现 C++ 中的函数
你可能会注意到 processPanorama
方法之前的 native
关键字。这意味着我们将使用此方法在我们的应用程序中实现 Java 和 C++ 之间的交互。因此,我们需要创建一些头文件和源文件来实现我们的 C++ 代码。我们必须遵循 Java Native Interface (JNI) 来使用 C++ 代码,所以这个过程可能有点复杂,并且超出了本书的范围。
在以下部分,我们将向您展示使用 OpenCV C++ 的步骤。
注意
如果你想了解 JNI,你可能想查看以下 JNI 文档:
docs.oracle.com/javase/7/docs/technotes/guides/jni/
还可以查看 API 指南中提供的 JNI 小贴士,地址如下:
developer.android.com/training/articles/perf-jni.html
首先,我们将使用终端中的 javah
命令为 processPanorama
方法创建相应的 C++ 头文件。为了做到这一点,您需要打开您的机器上的终端,然后切换到 Android 应用程序中的 app/src/main
文件夹,并运行以下命令:
javah -d jni -classpath ../../build/intermediates/classes/debug/ com.example.panorama.NativePanorama
您只需要验证包名和类文件名 NativePanorama
。命令在终端上不会显示任何内容,如图所示。如果您遇到以下错误:错误:找不到类文件 'com.example.panorama.NativePanorama',您可能需要重新构建项目。
使用 javah 命令后的终端
javah
命令的结果是,我们现在在 app/src/main
文件夹中有一个名为 jni
的文件夹,其中包含一个名为 com_example_panorama_NativePanorama.h
的文件。这个头文件包含一个用于与 Java 接口工作的函数。当调用 processPanorama
时,这个函数将在 C++ 中运行。
接下来,我们将在 jni
文件夹中创建一个名为 com_example_panorama_NativePanorama.cpp
的源文件。我们建议您将头文件中的函数声明复制到源文件中,并添加以下参数名称:
#include "com_example_panorama_NativePanorama.h"
JNIEXPORT void JNICALL Java_com_example_panorama_NativePanorama_processPanorama
(JNIEnv * env, jclass clazz, jlongArray imageAddressArray, jlong outputAddress){
}
剩下的唯一事情是我们需要编译 OpenCV C++ SDK,以便在先前的源文件中使用它。
使用 NDK/JNI 编译 OpenCV C++
为了在 C++ 代码中使用 OpenCV,我们需要编译 OpenCV C++,并使用 Android.mk
文件作为构建文件来构建和链接我们的 C++ 文件与 OpenCV 库。然而,Android Studio 默认不支持 Android.mk
。我们需要做很多事情才能实现这一点。
首先,我们将打开 local.properties
文件,并将 ndk.dir
设置为 Android NDK 文件夹的路径。在我们的例子中,local.properties
将如下所示:
sdk.dir=/Users/quanhua92/Library/Android/sdk
ndk.dir=/Users/quanhua92/Software/android-ndk-r10e
注意
您可以在以下位置获取 Android NDK:developer.android.com/ndk/index.html
其次,我们打开应用程序文件夹中的 build.gradle
文件,并在顶部添加以下行:
import org.apache.tools.ant.taskdefs.condition.Os
然后,我们需要在 defaultConfig
标签和 buildType
标签之间添加以下代码,以创建一个新的 Gradle 任务来构建 C++ 代码。
// begin NDK OPENCV
sourceSets.main {
jni.srcDirs = [] //disable automatic ndk-build call
}
task ndkBuild(type: Exec, description: 'Compile JNI source via NDK') {
def rootDir = project.rootDir
def localProperties = new File(rootDir, "local.properties")
Properties properties = new Properties()
localProperties.withInputStream { instr ->
properties.load(instr)
}
def ndkDir = properties.getProperty('ndk.dir')
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
commandLine "$ndkDir\\ndk-build.cmd",
'NDK_PROJECT_PATH=build/intermediates/ndk',
'NDK_LIBS_OUT=src/main/jniLibs',
'APP_BUILD_SCRIPT=src/main/jni/Android.mk',
'NDK_APPLICATION_MK=src/main/jni/Application.mk'
} else {
commandLine "$ndkDir/ndk-build",
'NDK_PROJECT_PATH=build/intermediates/ndk',
'NDK_LIBS_OUT=src/main/jniLibs',
'APP_BUILD_SCRIPT=src/main/jni/Android.mk',
'NDK_APPLICATION_MK=src/main/jni/Application.mk'
}
}
tasks.withType(JavaCompile) {
compileTask -> compileTask.dependsOn ndkBuild
}
//end
您可能想查看以下图中的我们的 build.gradle
的截图。
我们的 build.gradle 截图
接下来,我们在 jni
文件夹中创建一个名为 Application.mk
的文件,并将以下行放入其中:
APP_STL := gnustl_static
APP_CPPFLAGS := -frtti -fexceptions
APP_ABI := all
APP_PLATFORM := android-16
最后,我们在 jni
文件夹中创建一个名为 Android.mk
的文件,并按照以下设置来使用 OpenCV 在我们的 C++ 代码中。您可能需要将 OPENCVROOT
变量更改为您机器上 OpenCV-android-sdk 的位置:
LOCAL_PATH := $(call my-dir)
include $(CLEAR_VARS)
#opencv
OPENCVROOT:= /Volumes/Data/OpenCV/OpenCV-android-sdk
OPENCV_CAMERA_MODULES:=on
OPENCV_INSTALL_MODULES:=on
OPENCV_LIB_TYPE:=SHARED
include ${OPENCVROOT}/sdk/native/jni/OpenCV.mk
LOCAL_SRC_FILES := com_example_panorama_NativePanorama.cpp
LOCAL_LDLIBS += -llog
LOCAL_MODULE := MyLib
include $(BUILD_SHARED_LIBRARY)
使用前面的Android.mk
,Android Studio 会将 OpenCV 构建到libopencv_java3.so
中,并将我们的 C++代码构建到app/src/main/jniLibs
文件夹中的libMyLib.so
中。我们必须打开我们的MainActivity.java
并加载这个库,以便在我们的应用程序中使用,如下所示:
public class MainActivity extends ActionBarActivity {
static{
System.loadLibrary("opencv_java3");
System.loadLibrary("MyLib");
}
注意
如果您使用 OpenCV Android SDK 版本 2.*,您应该加载opencv_java
而不是opencv_java3
。
实现 OpenCV Java 代码
在本节中,我们将向您展示 OpenCV 在 Java 端,为 OpenCV C++端的拼接模块准备数据。
首先,当用户按下捕获按钮时,我们将创建一个列表来存储所有捕获的图像:
private List<Mat> listImage = new ArrayList<>();
然后,在jpegCallback
变量的onPictureTaken
方法中,我们想要将捕获的 Bitmap 转换为 OpenCV Mat 并存储在这个listImage
列表中。您需要在 Canvas 绘制部分之前添加这些行:
Mat mat = new Mat();
Utils.bitmapToMat(bitmap, mat);
listImage.add(mat);
最后,当用户点击保存按钮时,我们希望将listImage
中图像的地址发送到 OpenCV C++代码以执行拼接过程。
在imageProcessingRunnable
中,我们将在调用showProcessingDialog
函数之后添加以下代码:
try {
// Create a long array to store all image address
int elems= listImage.size();
long[] tempobjadr = new long[elems];
for (int i=0;i<elems;i++){
tempobjadr[i]= listImage.get(i).getNativeObjAddr();
}
// Create a Mat to store the final panorama image
Mat result = new Mat();
// Call the OpenCV C++ Code to perform stitching process
NativePanorama.processPanorama(tempobjadr, result.getNativeObjAddr());
// Save the image to external storage
File sdcard = Environment.getExternalStorageDirectory();
final String fileName = sdcard.getAbsolutePath() + "/opencv_" + System.currentTimeMillis() + ".png";
Imgcodecs.imwrite(fileName, result);
runOnUiThread(new Runnable() {
@Override
public void run() {
Toast.makeText(getApplicationContext(), "File saved at: " + fileName, Toast.LENGTH_LONG).show();
}
});
listImage.clear();
} catch (Exception e) {
e.printStackTrace();
}
在前面的代码中,我们将创建一个长数组来存储每个 Mat 图像的本地地址。然后,我们将传递这个长数组和一个Mat result
的本地地址,以存储全景图像。OpenCV C++代码将运行以执行拼接模块的拼接。之后,我们将结果保存到外部存储,并做一个简单的 toast 提示用户全景已保存。最后,我们清除listImage
列表以开始新的部分。
实现 OpenCV C++代码
在此刻,我们想要在 OpenCV C++中实现processPanorama
。实现非常简单;我们只需编辑com_example_panorama_NativePanorama.cpp
文件,如下所示:
#include "com_example_panorama_NativePanorama.h"
#include "opencv2/opencv.hpp"
#include "opencv2/stitching.hpp"
using namespace std;
using namespace cv;
JNIEXPORT void JNICALL Java_com_example_panorama_NativePanorama_processPanorama
(JNIEnv * env, jclass clazz, jlongArray imageAddressArray, jlong outputAddress){
// Get the length of the long array
jsize a_len = env->GetArrayLength(imageAddressArray);
// Convert the jlongArray to an array of jlong
jlong *imgAddressArr = env->GetLongArrayElements(imageAddressArray,0);
// Create a vector to store all the image
vector< Mat > imgVec;
for(int k=0;k<a_len;k++)
{
// Get the image
Mat & curimage=*(Mat*)imgAddressArr[k];
Mat newimage;
// Convert to a 3 channel Mat to use with Stitcher module
cvtColor(curimage, newimage, CV_BGRA2RGB);
// Reduce the resolution for fast computation
float scale = 1000.0f / curimage.rows;
resize(newimage, newimage, Size(scale * curimage.rows, scale * curimage.cols));
imgVec.push_back(newimage);
}
Mat & result = *(Mat*) outputAddress;
Stitcher stitcher = Stitcher::createDefault();
stitcher.stitch(imgVec, result);
// Release the jlong array
env->ReleaseLongArrayElements(imageAddressArray, imgAddressArr ,0);
}
在前面的代码中,我们将图像地址的长数组转换为图像并推入一个名为imgVec
的向量中。我们还调整了图像大小以加快计算速度。拼接模块非常容易使用。
首先,我们将创建一个Stitcher
实例。
Stitcher stitcher = Stitcher::createDefault();
然后,我们使用这个拼接器来拼接我们的 Mat 向量图像。全景图像将被保存到一个结果 Mat 中。
下面的截图显示了使用默认配置处理的全景图像示例:
在走廊中捕获的示例图像
应用程序展示
在本节中,我们展示了使用我们的应用程序捕获的一些全景图像。您可以看到,该应用程序能够处理水平和垂直方向的全景图像。
首先,这是从建筑物的五楼捕获的图像。我们通过窗户玻璃拍照,所以光线很暗。然而,全景效果很好,因为有很多细节,所以特征匹配器可以做得很好。
应用程序捕获的示例图像
下面的截图是在傍晚时分从阳台拍摄的。全景图的左上角区域不好,因为这个区域只包含一面空墙和天空。因此,图像之间可比较的特征太少。因此,最终全景图在这个区域并不完美。
应用在傍晚捕获的样本图像
下面的截图是通过窗户拍摄的。图像的下半部分很好。然而,由于缺乏特征,天空仍然存在一些问题,就像之前的图像一样。
应用在傍晚时分捕获的另一张样本图像
以下图像是在下午拍摄的建筑前庭院中的。光线很好,有很多细节,所以最终全景图完美无瑕。
应用在下午捕获的样本图像
这张图片与上一张图片拍摄于同一时期。然而,这张图片以广泛的视角捕捉,每个拍摄角度的光线都不同。因此,最终全景图的照明并不一致。
应用在下午捕获的另一张样本图像
进一步改进
在本节中,我们将展示一些在创建功能齐全的全景应用时可以考虑的改进。
首先,你可以为用户创建一个更好的用户界面来捕捉全景图像。当前的用户界面没有显示应用程序可以双向捕捉图像。建议使用 Android API 中的运动传感器(加速度计和陀螺仪)来获取设备的旋转并调整叠加图像的位置。
注意
运动传感器 API 文档可在developer.android.com/guide/topics/sensors/sensors_motion.html
找到。
其次,当前应用程序调整捕获的图像大小以减少计算时间。你可能想更改 Stitcher 的一些参数以获得更好的性能。我们建议你查看拼接模块的文档以获取更多详细信息。在我们的实现中,我们将使用 Stitcher 类进行简化。然而,OpenCV 仓库中有一个详细的示例在samples/cpp/stitching_detailed.cpp
,其中展示了许多选项来提高最终全景图的稳定性和质量。
注意
使用拼接模块的详细示例可在github.com/Itseez/opencv/blob/master/samples/cpp/stitching_detailed.cpp
找到。
第三,您可以更改我们应用程序的逻辑以执行实时拼接。这意味着每当捕获到两张图像时,我们就制作一张拼接图像。然后,我们借助 360 度用户界面展示结果,以便用户知道如果有的话,哪个是缺失的区域。
摘要
本章展示了在 Android Studio 中使用的完整全景应用程序,其中 OpenCV 3 在 Java 接口和 C++接口中均得到使用,并得到了原生开发工具包(NDK)的支持。本章还介绍了如何使用 OpenCV 库与 Android Camera API 结合。此外,本章还展示了使用 OpenCV 3 拼接模块的一些基本实现,以执行图像拼接。
第五章:工业应用中的通用目标检测
本章将向您介绍通用目标检测的世界,并更详细地探讨工业应用与标准学术研究案例相比的优势。正如许多人所知,OpenCV 3 包含著名的Viola 和 Jones 算法(作为 CascadeClassifier 类嵌入),该算法专门设计用于鲁棒的人脸检测。然而,相同的接口可以有效地用于检测任何满足您需求的物体类别。
注意
关于 Viola 和 Jones 算法的更多信息可以在以下出版物中找到:
使用简单特征的提升级联进行快速目标检测,Viola P. 和 Jones M.,(2001)。在计算机视觉和模式识别,2001 (CVPR 2001)。IEEE 计算机学会会议论文集,第 1 卷,第 I-511 页。IEEE。
本章假设您对 OpenCV 3 的级联分类接口有基本了解。如果没有,以下是一些理解此接口和提供的参数及软件的基本用法的好起点:
-
docs.opencv.org/master/modules/objdetect/doc/cascade_classification.html
-
docs.opencv.org/master/doc/tutorials/objdetect/cascade_classifier/cascade_classifier.html
注意
或者,您可以简单地阅读 PacktPub 出版的关于此主题更详细讨论的书籍之一,例如第三章,训练一个智能警报器来识别恶棍和他的猫,来自 Joseph Howse 的OpenCV for Secret Agents一书。
在本章中,我将带您了解使用 Viola 和 Jones 人脸检测框架进行通用目标检测时的重要元素。您将学习如何调整您的训练数据以适应您的特定设置,如何使您的目标检测模型具有旋转不变性,并且您将找到关于如何通过智能地使用环境参数和情境知识来提高检测器精度的指南。我们将更深入地探讨实际的物体类别模型,并解释发生了什么,结合一些用于可视化目标检测实际过程的智能工具。最后,我们将探讨 GPU 的可能性,这将导致更快的处理时间。所有这些都将结合代码示例和通用目标检测的示例用例。
识别、检测和分类之间的区别
为了完全理解这一章,重要的是你要理解基于级联分类的 Viola 和 Jones 检测框架实际上是一种物体分类技术,并且它与物体识别的概念有很大不同。这导致计算机视觉项目中常见的错误,即人们在事先没有充分分析问题的情况下,错误地决定使用这项技术来解决问题。考虑以下图所示的设置,它由一个连接相机的计算机组成。计算机内部有四个物体的描述(飞机、杯子、汽车和蛇)。现在,我们考虑向系统的相机提供三个新图像的情况。
简单的计算机视觉设置
如果图像 A 呈现给系统,系统会创建给定输入图像的描述,并尝试将其与计算机内存数据库中图像的描述相匹配。由于特定的杯子略微旋转,杯子内存图像的描述符将与内存中其他物体图像的匹配更接近,因此这个系统能够成功识别出已知的杯子。这个过程被称为物体识别,并应用于我们知道我们想要在输入图像中找到的确切物体的情况。
"物体识别的目标是匹配(识别)特定的物体或场景。例如,识别特定的建筑,如比萨斜塔,或特定的画作,如蒙娜丽莎。即使物体在比例、相机视角、光照条件和部分遮挡等方面发生变化,也能识别出该物体。" | ||
---|---|---|
--安德烈亚·韦达利和安德鲁·齐斯泽曼 |
然而,这项技术有一些缺点。如果一个物体呈现给系统,而图像数据库中没有该物体的描述,系统仍然会返回最接近的匹配,因此结果可能会非常误导。为了避免这种情况,我们倾向于在匹配质量上设置一个阈值。如果没有达到阈值,我们就简单地不提供匹配。
当图像 B 呈现给相同的系统时,我们会遇到一个新的问题。给定输入图像与内存中杯子图像的差异如此之大(不同大小、不同形状、不同图案等),以至于图像 B 的描述符将不会与内存中杯子的描述相匹配,这是物体识别的一个大缺点。当图像 C 呈现给系统时,问题甚至进一步加剧。在那里,计算机内存中的已知汽车被呈现给相机系统,但它呈现的设置和背景与内存中的完全不同。这可能导致背景对物体描述符的影响如此之大,以至于物体不再被识别。
物体检测更进一步;它试图通过学习更具体的物体描述而不是仅仅学习图像本身的描述来在变化的设置中找到给定的物体。在可检测的物体类别变得更加复杂,并且物体在多个输入图像中的变化很大——我们不再谈论单个物体检测,而是关于检测一个物体类别——这就是物体分类发挥作用的地方。
在物体分类中,我们试图学习一个通用的模型来处理物体类别中的大量变化,如下面的图所示:
一个具有大量变化的物体类别示例:汽车和椅子/沙发
在这样一个单一物体类别中,我们试图应对不同的变化形式,如下面的图所示:
单个物体类别内的变化:光照变化、物体姿态、杂乱、遮挡、类内外观和视角
如果您计划使用 Viola 和 Jones 物体检测框架,确保您的应用程序实际上属于第三种和后续情况非常重要。在这种情况下,您想要检测的物体实例事先是未知的,并且它们具有很大的类内方差。每个物体实例可能在形状、颜色、大小、方向等方面有所不同。Viola 和 Jones 算法将所有这些方差建模成一个单一的对象模型,该模型能够检测到该类别的任何给定实例,即使该物体实例之前从未见过。这正是物体分类技术强大的地方,它们在给定的一组物体样本上很好地泛化,以学习整个物体类别的具体特征。
这些技术使我们能够为更复杂的类别训练物体检测器,因此物体分类技术在工业应用中非常理想,例如物体检查、物体拣选等,在这些应用中,通常使用的基于阈值的分割技术似乎由于设置中的这种大范围变化而失败。
如果您的应用程序不处理这些困难情况中的物体,那么考虑使用其他技术,如物体识别,如果它符合您的需求的话!
在我们开始实际工作之前,让我花点时间向您介绍在物体检测应用中常见的几个基本步骤。注意所有步骤并确保不要试图跳过其中的一些步骤以节省时间是非常重要的。这些都会影响物体检测接口的最终结果:
-
数据收集:这一步包括收集构建和测试您的对象检测器所需的数据。数据可以从视频序列到由网络摄像头捕获的图像等多种来源获取。这一步还将确保数据格式正确,以便准备好传递到训练阶段。
-
实际模型训练:在这一步,您将使用第一步收集到的数据来训练一个能够检测该模型类的对象模型。在这里,我们将研究不同的训练参数,并专注于定义适合您应用的正确设置。
-
对象检测:一旦您有一个训练好的对象模型,您就可以使用它来尝试在给定的测试图像中检测对象实例。
-
验证:最后,通过将每个检测与测试数据的手动定义的地面真实值进行比较,验证第三步的检测结果非常重要。我们将讨论各种用于效率和精度验证的选项。
让我们继续详细解释第一步,即数据收集,这也是本章的第一个子主题。
智能选择和准备特定于应用的训练数据
在本节中,我们将讨论根据情境背景需要多少训练样本,并强调在准备正训练样本的注释时的一些重要方面。
让我们先定义对象分类的原则及其与训练数据的关系,如图所示:
一个对象模型的正负训练数据的示例
算法的想法是,它接受一组正对象实例,这些实例包含您想要检测的对象的不同表现形式(这意味着在不同光照条件下、不同尺度、不同方向、小的形状变化等对象实例),以及一组负对象实例,这些实例包含您不希望模型检测到的所有内容。然后,这些实例被巧妙地组合成一个对象模型,并用于检测如图所示输入图像中的新对象实例。
训练数据量
许多对象检测算法高度依赖于大量的训练数据,或者至少这是预期的。这种范式是由于学术研究案例的出现,主要关注非常具有挑战性的案例,如行人和汽车检测。这些都是存在大量类内差异的对象类别,导致:
-
一个非常大的正负训练样本集,每个集合可能包含数千甚至数百万个样本。
-
移除所有污染训练集而不是帮助它的信息,例如颜色信息,而仅仅使用对这类类内变异性更鲁棒的特征信息,如边缘信息和像素强度差异。
因此,训练出的模型能够在几乎所有可能的情况下成功检测行人和汽车,但缺点是训练它们需要几周的处理时间。然而,当你观察更具有工业特定性的案例,例如从箱子中挑选水果或从传送带上抓取物体时,你会发现与这些极具挑战性的学术研究案例相比,物体和背景的变异性相当有限。这是一个我们可以利用的事实。
我们知道,最终对象模型的准确性高度依赖于所使用的训练数据。在需要检测器在所有可能情况下工作的案例中,提供大量数据似乎是合理的。复杂的学习算法将决定哪些信息是有用的,哪些不是。然而,在更受限的案例中,我们可以通过考虑我们的对象模型实际上需要做什么来构建对象模型。
例如,Facebook DeepFace 应用程序,使用神经网络方法在所有可能的情况下检测人脸,使用了 440 万张标记人脸。
注意
关于 DeepFace 算法的更多信息可以在以下找到:
Deepface: Closing the gap to human-level performance in face verification, Taigman Y., Yang M., Ranzato M. A., and Wolf L. (2014, June). In Computer Vision and Pattern Recognition (CVPR), 2014, IEEE Conference on (pp. 1701-1708).
因此,我们建议通过遵循一系列简单规则,只为你的对象模型使用有意义的正负样本训练样本:
-
对于正样本,仅使用自然发生样本。市面上有许多工具可以创建人工旋转、平移和倾斜的图像,将小型的训练集变成大型的训练集。然而,研究表明,这样得到的检测器性能不如简单地收集覆盖你应用实际情况的正样本。更好的做法是使用一组质量上乘的高质量对象样本,而不是使用大量低质量且不具有代表性的样本。
-
对于负样本,有两种可能的方法,但两者都始于这样一个原则:你收集负样本的情况是你检测器将要使用的情况,这与通常训练对象检测的方法非常不同,后者只是使用一大组不包含对象的随机样本作为负样本。
-
要么将摄像头对准你的场景,开始随机抓取帧以从负窗口中采样。
-
或者利用您的正图像的优势。裁剪实际的物体区域并将像素变为黑色。使用这些掩码图像作为负训练数据。请记住,在这种情况下,背景信息和窗口中实际发生的物体的比例需要足够大。如果您的图像充满了物体实例,裁剪它们将导致相关背景信息的完全丢失,从而降低负训练集的区分力。
-
-
尽量使用一个非常小的负样本集合。如果你的情况下只有 4 或 5 种背景情况可能发生,那么就没有必要使用 100 个负图像。只需从这五个具体案例中采样负窗口。
以这种方式高效地收集数据确保您最终会得到一个针对您特定应用的非常健壮的模型!然而,请记住,这也带来了一些后果。生成的模型将不会对训练时的情况之外的情景具有鲁棒性。然而,在训练时间和减少训练样本需求方面的好处完全超过了这一缺点。
备注
基于 OpenCV 3 的负样本生成软件可以在github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/generate_negatives/
找到。
您可以使用负样本生成软件生成如图所示的样本,其中草莓的物体注释被移除并用黑色像素替换。
负图像生成工具输出示例,其中注释被裁剪并用黑色像素替换
如您所见,物体像素与背景像素之间的比例仍然足够大,以确保模型不会仅基于这些黑色像素区域训练其背景。请记住,通过简单地收集负图像来避免使用这些黑色像素化图像的方法,始终是更好的。然而,许多公司忘记了数据收集的这个重要部分,最终导致没有对应用有意义的负数据集。我进行的几个测试证明,使用来自应用随机帧的负数据集比基于黑色像素裁剪的图像具有更强的负区分力。
为正样本创建物体注释文件
在准备您的正数据样本时,花些时间在注释上是很重要的,这些注释是您物体实例在较大图像中的实际位置。没有适当的注释,您将永远无法创建出优秀的物体检测器。市面上有许多注释工具,但我基于 OpenCV 3 为您制作了一个,它允许您快速遍历图像并在其上添加注释。
备注
基于 OpenCV 3 的对象标注软件可以在github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/object_annotation/
找到。
OpenCV 团队非常友好,将此工具集成到主仓库的“apps”部分。这意味着,如果您在安装过程中构建并安装了 OpenCV 应用,则可以使用以下命令访问该工具:
/opencv_annotation -images <folder location> -annotations <output file>
使用该软件相当简单:
-
首先,在 GitHub 文件夹中的特定项目内运行 CMAKE 脚本。运行 CMAKE 后,软件将通过可执行文件提供访问。这种方法适用于本章中的每个软件组件。运行 CMAKE 界面相当简单:
cmakemake ./object_annotation -images <folder location> -annotations <output file>
-
这将生成一个需要一些输入参数的可执行文件,包括正图像文件的位置和输出检测文件。
注意
请记住,始终分配所有文件的绝对路径!
-
首先,将您的正图像文件夹的内容解析到文件中(通过在对象标注文件夹内使用的提供的
folder_listing
软件),然后执行标注命令:./folder_listing –folder <folder> -images <images.txt>
-
文件夹列表工具应该生成一个文件,其外观与以下所示完全相同:
文件夹列表工具生成的正样本文件示例
-
现在,使用以下命令启动标注工具:
./object_annotation –images <images.txt> -annotations <annotations.txt>
-
这将启动软件,并在窗口中显示第一张图像,准备应用标注,如图所示:
对象标注工具的示例
-
您可以从选择对象的左上角开始,然后移动鼠标直到达到对象的右下角,这可以在前图的左侧部分看到。然而,该软件允许您从每个可能的角落开始标注。如果您对选择不满意,则重新应用此步骤,直到标注符合您的需求。
-
一旦您同意所选的边界框,请按确认选择的按钮,默认为键 C。这将确认标注,将其颜色从红色变为绿色,并将其添加到标注文件中。请确保只有当您 100%确信选择时才接受标注。
-
对同一图像重复前面的两个步骤,直到您已标注图像中的每个对象实例,如前例图像的右侧部分所示。然后按保存结果的按钮,默认为 N 键。
-
最后,您将得到一个名为
annotations.txt
的文件,它结合了图像文件的存储位置以及训练图像中出现的所有对象实例的地面真实位置。
注意
如果您想调整所有单独操作所需的按钮,那么请打开object_annotation.cpp
文件,浏览到第 100 行和第 103 行。在那里,您可以调整分配给要用于操作的按钮的 ASCII 值。
您可以在www.asciitable.com/
找到分配给键盘键的所有 ASCII 码的概述。
软件输出的结果是在每个正样本文件夹中,一个*.txt
文件的对象检测列表,其结构如下所示(以下图所示):
物体标注工具的示例
它从文件夹中每个图像的绝对文件位置开始。有选择不使用相对路径,因为这样文件将完全依赖于其存储的位置。然而,如果您知道自己在做什么,那么相对于可执行文件使用相对文件位置应该可以正常工作。使用绝对路径使其更通用且更安全。文件位置后面跟着该特定图像的检测数量,这使我们事先知道可以期待多少个地面真实对象。对于每个对象,存储到顶左角的(x, y)坐标与边界框的宽度和高度相结合。这为每个图像继续进行,每次检测输出文件中出现新行。
小贴士
对于进一步模型训练来说,将来自其他标注系统的每一组地面真实值首先转换为这种格式非常重要,以确保 OpenCV 3 中嵌入的级联分类软件能够良好工作。
当处理包含对象实例的正训练图像时,第二个需要注意的点是在实际放置对象实例边界框的方式上。一个良好且准确标注的地面真实集将始终为您提供更可靠的对象模型,并将产生更好的测试和准确度结果。因此,我建议在为您的应用程序进行物体标注时注意以下要点:
-
确保边界框包含整个对象,同时尽可能避免尽可能多的背景信息。对象信息与背景信息的比率应始终大于 80%。否则,背景可能会提供足够多的特征来训练您的模型,最终结果将是您的检测器模型专注于错误图像信息。
-
Viola 和 Jones 建议使用基于 24x24 像素模型的平方标注,因为它适合人脸的形状。然而,这并不是强制性的!如果你的目标类别更像是矩形,那么你应该标注矩形边界框而不是正方形。观察到人们倾向于将矩形形状的对象推入平方模型尺寸,然后 wonder 为什么它没有正确工作。以铅笔检测器为例,模型尺寸将更像是 10x70 像素,这与实际的铅笔尺寸相关。
-
尝试做简洁的图像批次。最好是重启应用程序 10 次,而不是在即将完成一组 1000 张带有相应标注的图像时系统崩溃。如果软件或你的计算机失败了,它确保你只需要重新做一小部分。
将你的正样本数据集解析到 OpenCV 数据向量
在 OpenCV 3 软件允许你训练级联分类器目标模型之前,你需要将你的数据推送到一个 OpenCV 特定的数据向量格式。这可以通过使用提供的 OpenCV 样本创建工具来完成。
注意
样本创建工具可以在github.com/Itseez/opencv/tree/master/apps/createsamples/
找到,并且如果 OpenCV 安装正确,它应该会自动构建,这使得可以通过opencv_createsamples
命令使用。
通过应用以下命令行界面指令创建样本向量非常简单直接:
./opencv_createsamples –info annotations.txt –vec images.vec –bg negatives.txt –num amountSamples –w model_width –h model_height
https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/average_dimensions/.
average haverage] are [60 60].If we would use those [60 60] dimensions, then we would have a model that can only detect apples equal and larger to that size. However, moving away from the tree will result in not a single apple being detected anymore, since the apples will become smaller in size.Therefore, I suggest reducing the dimensions of the model to, for example, [30 30]. This will result in a model that still has enough pixel information to be robust enough and it will be able to detect up to half the apples of the training apples size.Generally speaking, the rule of thumb can be to take half the size of the average dimensions of the annotated data and ensure that your largest dimension is not bigger than 100 pixels. This last guideline is to ensure that training your model will not increase exponentially in time due to the large model size. If your largest dimension is still over 100 pixels, then just keep halving the dimensions until you go below this threshold.
你现在已经准备好了你的正样本训练集。你最后应该做的事情是创建一个包含负图像的文件夹,从这些图像中你将随机采样负窗口,并对其应用文件夹列出功能。这将生成一个负数据引用文件,该文件将由训练界面使用。
训练目标模型时的参数选择
一旦你构建了一个不错的训练样本数据集,它已经准备好处理,那么是时候启动 OpenCV 3 的级联分类器训练软件了,它使用 Viola 和 Jones 级联分类器框架来训练你的目标检测模型。训练本身是基于应用提升算法在 Haar 小波特征或局部二值模式特征上。OpenCV 界面支持多种提升类型,但为了方便,我们使用常用的 AdaBoost 界面。
注意
如果你想了解特征计算的详细技术细节,请查看以下详细描述它们的论文:
-
HAAR: Papageorgiou, Oren 和 Poggio, "一个用于目标检测的通用框架", 国际计算机视觉会议, 1998 年。
-
LBP: T. Ojala, M. Pietikäinen, and D. Harwood (1994), "基于 Kullback 分布判别的纹理度量性能评估",第 12 届 IAPR 国际模式识别会议(ICPR 1994)论文集,第 1 卷,第 582 - 585 页。
本节将更详细地讨论训练过程的几个部分。它将首先阐述 OpenCV 如何运行其级联分类过程。然后,我们将深入探讨所有提供的训练参数以及它们如何影响训练过程和结果的准确性。最后,我们将打开模型文件,更详细地查看其中可以找到的内容。
训练对象模型时涉及的训练参数
-numNeg: This is the amount of negative samples used at each stage. However, this is not the same as the amount of negative images that were supplied by the negative data. The training samples negative windows from these images in a sequential order at the model size dimensions. Choosing the right amount of negatives is highly dependent on your application.
* If your application has close to no variation, then supplying a small number of windows could simply do the trick because they will contain most of the background variance.
* On the other hand, if the background variation is large, a huge number of samples would be needed to ensure that you train as much random background noise as possible into your model.
* A good start is taking a ratio between the number of positive and the number of negative samples equaling 0.5, so double the amount of negative versus positive windows.
* Keep in mind that each negative window that is classified correctly at an early stage will be discarded for training in the next stage since it cannot add any extra value to the training process. Therefore, you must be sure that enough unique windows can be grabbed from the negative images. For example, if a model uses 500 negatives at each stage and 100% of those negatives get correctly classified at each stage, then training a model of 20 stages will need 10,000 unique negative samples! Considering that the sequential grabbing of samples does not ensure uniqueness, due to the limited pixel wise movement, this amount can grow drastically.
`-numStages`: This is the amount of weak classifier stages, which is highly dependent on the complexity of the application.
* The more stages, the longer the training process will take since it becomes harder at each stage to find enough training windows and to find features that correctly separate the data. Moreover, the training time increases in an exponential manner when adding stages.
* Therefore, I suggest looking at the reported acceptance ratio that is outputted at each training stage. Once this reaches values of 10^(-5), you can conclude that your model will have reached the best descriptive and generalizing power it could get, according to the training data provided.
* Avoid training it to levels of 10^(-5) or lower to avoid overtraining your cascade on your training data. Of course, depending on the amount of training data supplied, the amount of stages to reach this level can differ a lot.
`-bg`: This refers to the location of the text file that contains the locations of the negative training images, also called the negative samples description file.`-vec`: This refers to the location of the training data vector that was generated in the previous step using the create_samples application, which is built-in to the OpenCV 3 software.`-precalcValBufSize` and `-precalcIdxBufSize`: These parameters assign the amount of memory used to calculate all features and the corresponding weak classifiers from the training data. If you have enough RAM memory available, increase these values to 2048 MB or 4096 MB, which will speed up the precalculation time for the features drastically.`-featureType`: Here, you can choose which kind of features are used for creating the weak classifiers.
* HAAR wavelets are reported to give higher accuracy models.
* However, consider training test classifiers with the LBP parameter. It decreases training time of an equal sized model drastically due to the integer calculations instead of the floating point calculations.
`-minHitRate`: This is the threshold that defines how much of your positive samples can be misclassified as negatives at each stage. The default value is 0.995, which is already quite high. The training algorithm will select its stage threshold so that this value can be reached.
* Making it 0.999, as many people do, is simply impossible and will make your training stop probably after the first stage. It means that only 1 out of 1,000 samples can be wrongly classified over a complete stage.
* If you have very challenging data, then lowering this, for example, to 0.990 could be a good start to ensure that the training actually ends up with a useful model.
`-maxFalseAlarmRate`: This is the threshold that defines how much of your negative samples need to be classified as negatives before the boosting process should stop adding weak classifiers to the current stage. The default value is 0.5 and ensures that a stage of weak classifier will only do slightly better than random guessing on the negative samples. Increasing this value too much could lead to a single stage that already filters out most of your given windows, resulting in a very slow model at detection time due to the vast amount of features that need to be validated for each window. This will simply remove the large advantage of the concept of early window rejection.
在尝试训练一个成功的分类器时,前面讨论的参数是最重要的几个需要深入挖掘的。一旦这个方法有效,你可以通过查看提升方法形成其弱分类器的方式,进一步提高你分类器的性能。这可以通过-maxDepth
和-maxWeakCount
参数来实现。然而,对于大多数情况,使用树桩弱分类器(单层决策树)对单个特征进行操作是开始的最佳方式,确保单阶段评估不会过于复杂,因此在检测时间上更快。
级联分类过程的详细说明
一旦你选择了正确的训练参数,你就可以开始级联分类器的训练过程,这将构建你的级联分类器对象检测模型。为了完全理解构建你的对象模型所涉及的级联分类过程,了解 OpenCV 如何基于提升过程进行对象模型的训练是非常重要的。
在我们这样做之前,我们将快速浏览一下提升原理的一般概述。
注意
关于提升原理的更多信息可以在 Freund Y., Schapire R., and Abe N (1999)的《提升简明介绍》中找到。Journal-Japanese Society For Artificial Intelligence, 14(771-780), 1612
提升背后的思想是,你有一个非常大的特征池,可以将其塑造成分类器。使用所有这些特征来构建单个分类器意味着你的测试图像中的每一个窗口都需要处理所有这些特征,这将花费非常长的时间,并使检测变慢,尤其是当你考虑到测试图像中可用的负窗口数量时。为了避免这种情况,并尽可能快地拒绝尽可能多的负窗口,提升选择那些最能区分正负数据的特征,并将它们组合成分类器,直到分类器在负样本上的表现略好于随机猜测。这一步被称为弱分类器。提升重复此过程,直到所有这些弱分类器的组合达到算法所需的确切精度。这种组合被称为强分类器。这个过程的主要优势是,大量的负样本将在少数早期阶段被丢弃,只需评估一小组特征,从而大大减少检测时间。
现在,我们将尝试使用 OpenCV 3 中嵌入的级联训练软件生成的输出,来解释整个过程。以下图示说明了如何从一组弱分类器阶段构建一个强大的级联分类器。
结合弱分类器阶段和早期拒绝错误分类窗口,形成了著名的级联结构
级联分类器训练过程遵循迭代过程来训练后续阶段的弱分类器(1…N)。每个阶段由一组弱分类器组成,直到达到该特定阶段的特定标准。以下步骤概述了在 OpenCV 3 中根据输入参数和提供的数据在训练每个阶段时发生的情况。如果你对每个后续步骤的更具体细节感兴趣,那么请阅读 Viola 和 Jones 的研究论文(你可以在本章的第一页查看引用)。这里描述的所有步骤都会在达到强分类器所需的确切精度之前,对每个阶段重复进行。以下图示显示了这样一个阶段输出的样子:
分类器阶段训练的一个示例输出
步骤 1 – 抓取正面和负样本
你会注意到训练的第一步是抓取当前阶段的训练样本——首先是从你提供的数据向量中获取的正面样本,然后是从你提供的负图像中随机获取的负样本窗口。这两个步骤的输出如下:
POS:number_pos_samples_grabbed:total_number_pos_samples_needed NEG:number_neg_samples_grabbed:acceptanceRatioAchieved
如果找不到更多的正样本,将生成错误并停止训练。当你开始丢弃不再有用的正样本时,所需的样本总数会增加。当前阶段的负样本抓取可能比正样本抓取花费更长的时间,因为所有被前阶段正确分类的窗口都被丢弃,并搜索新的窗口。随着阶段数量的增加,这会变得更加困难。只要抓取的样本数量持续增加(是的,这可能会非常慢,所以请耐心等待),你的应用程序仍在运行。如果找不到更多的负样本,应用程序将结束训练,你需要降低每个阶段的负样本数量或添加额外的负图像。
在抓取负窗口后,报告了前一个阶段实现的接受率。这个值表明,到目前为止训练的模型是否足够强大,可以用于你的检测目的!
第 2 步 - 训练数据的积分图像和所有可能特征的预计算
一旦我们有了正负样本窗口大小的样本,预计算将计算窗口大小内所有可能的单个特征,并将其应用于每个训练样本。这可能会花费一些时间,具体取决于你的模型大小和训练样本的数量,特别是当你知道一个 24x24 像素的模型可以产生超过 16,000 个特征时。如前所述,分配更多的内存可以有所帮助,或者你可以选择选择 LBP 特征,其计算速度相对于 HAAR 特征要快得多。
所有特征都是在原始输入窗口的积分图像表示上计算的。这样做是为了加快特征的计算速度。Viola 和 Jones 的论文详细解释了为什么使用这种积分图像表示。
计算出的特征被倒入一个大的特征池中,提升过程可以从这个池中选择训练弱分类器所需的特征。这些弱分类器将在每个阶段中使用。
第 3 步 - 启动提升过程
现在,级联分类器训练已准备好进行实际的提升过程。这发生在几个小步骤中:
-
特征池中所有的可能弱分类器都在被计算。由于我们使用的是基于单个特征的 stumps(基本弱分类器)来构建决策树,因此弱分类器的数量与特征的数量相同。如果你愿意,你可以选择使用预定义的最大深度来训练实际的决策树,但这超出了本章的范围。
-
每个弱分类器都会被训练,以最小化训练样本上的误分类率。例如,当使用 Real AdaBoost 作为提升技术时,Gini 指数会被最小化。
注意
关于用于训练样本误分类率的 Gini 指数的更多信息,可以在以下内容中找到:
Gastwirth, J. L. (1972). The estimation of the Lorenz curve and Gini index. The Review of Economics and Statistics, 306-316.
-
具有最低误分类率的弱分类器被添加到当前阶段的下一个弱分类器。
-
基于已经添加到阶段的弱分类器,算法计算整体阶段阈值,该阈值被设置为保证所需的命中率。
-
现在,样本的权重根据它们在上一次迭代中的分类进行调整,这将产生下一轮迭代中的一组新的弱分类器,因此整个过程可以再次开始。
-
在单个阶段内组合弱分类器(这在训练输出中可视化)时,提升过程确保:
-
整体阶段阈值不会低于由训练参数选择的最低命中率。
-
与前一个阶段相比,负样本上的误报率降低。
-
-
此过程持续进行,直到:
-
在负样本上的误接受率低于设定的最大误报率。然后,过程简单地开始为检测模型训练新的阶段弱分类器。
-
达到了所需的阶段误报率,即
maxFalseAlarmRate^#stages
。这将导致模型训练结束,因为模型满足我们的要求,并且无法再获得更好的结果。这种情况不会经常发生,因为这个值下降得相当快,经过几个阶段后,这意味着你正确分类了超过 99%的正负样本。 -
命中率下降到阶段特定的最小命中率,即
minHitRate^#stages
。在这个阶段,太多的正样本被错误分类,并且你的模型的最大性能已经达到。
-
第 4 步 – 将临时结果保存到阶段文件
在训练每个阶段后,关于弱分类器和阈值的特定阶段细节被存储在数据文件夹中,在一个单独的 XML 文件中。如果达到了所需的阶段数,则将这些子文件合并成一个单一的级联 XML 文件。
然而,每个阶段都单独存储的事实意味着你可以在任何时候停止训练,并通过简单地重新启动训练命令来创建一个中间的对象检测模型,只需将-numStages
参数更改为你想要检查模型性能的阶段值。当你想要在一个验证集上执行评估以确保你的模型不会开始过度拟合训练数据时,这是理想的!
结果对象模型被详细解释
观察到许多使用 OpenCV 3 中嵌入的级联分类器算法的用户不知道存储在 XML 文件中的对象模型内部结构的含义,这有时会导致对算法的错误理解。本节将解释训练对象模型的每个内部部分。我们将讨论基于树桩型弱分类器的模型,但这个想法对于任何其他类型的弱分类器在阶段内部都是相同的,例如决策树。最大的不同是,与使用树桩特征相比,模型内部的权重计算变得更加复杂。至于每个阶段内部的弱分类器结构,我们将讨论基于 HAAR 和 LBP 特征的情况,因为这两个是 OpenCV 中用于训练级联分类器的最常用的特征。
注意
将用于解释所有内容的两个模型可以在以下位置找到
存储的每个 XML 模型的第一个部分描述了指定模型自身特征和一些重要训练参数的参数。随后,我们可以找到所使用的训练类型,目前仅限于提升,以及用于构建弱分类器的特征类型。我们还有将要训练的对象模型的宽度和高度,提升过程的参数,包括使用的提升类型、选定的最小命中比率和选定的最大误接受率。它还包含有关如何构建弱分类器阶段的信息,在我们的案例中,作为称为树桩的单个特征深度树的组合,每个阶段最多有 100 个弱分类器。对于基于 HAAR 小波模型,我们可以看到使用了哪些特征,仅限于基本的垂直特征或组合旋转 45 度的集合。
在训练特定参数之后,事情开始变得有趣。在这里,我们可以找到更多关于级联分类器对象模型实际结构的信息。描述了阶段的数量,然后通过迭代,模型总结了由提升过程生成的每个单独阶段的训练结果和阈值。对象模型的基本结构如下所示:
<stages>
<_>
<maxWeakCount></maxWeakCount>
<stageThreshold</stageThreshold>
<weakClassifiers>
<!-- tree 0 -->
<_>
<internalNodes></internalNodes>
<leafValues></leafValues></_>
<!-- tree 1 -->
<_>
<internalNodes></internalNodes>
<leafValues></leafValues></_>
<!-- tree 2 -->
… … …
<!-- stage 1 -->
… … …
</stages>
<features>
… … …
</features>
我们为每个阶段开始时使用一个空的迭代标签。在每一个阶段,定义了所使用的弱分类器的数量,在我们的情况下,这显示了在阶段内部使用了多少个单层决策树(树桩)。阶段阈值定义了窗口最终阶段得分的阈值。这是通过使用每个弱分类器对窗口进行评分,然后对整个阶段的评分结果进行求和和加权生成的。对于每个单个弱分类器,我们收集基于决策节点和所使用的层的内部结构。现有的值是用于创建决策树和叶值的提升值,这些叶值用于对由弱分类器评估的窗口进行评分。
内部节点结构的具体细节对于 HAAR 小波和基于特征的模型是不同的。叶评分的存储是相同的。然而,内部节点的值指定了与代码底部部分的关系,该部分包含实际的特征区域,并且对于 HAAR 和 LBP 方法都是不同的。这两种技术之间的差异可以在以下部分看到,为两种模型抓取第一阶段的第一个树和特征集的一部分。
HAAR-like wavelet feature models
以下是从基于 HAAR 小波特征的模型中提取的两个代码片段,包含内部节点结构和特征结构:
<internalNodes>
0 -1 445 -1.4772760681807995e-02
</internalNodes>
… … …
<_>
<rects>
<_>23 10 1 3 -1.</_>
<_>23 11 1 1 3.</_>
</rects>
<tilted>0</tilted>
</_>
对于内部节点,每个节点有四个值:
-
节点左和节点右:这些值表示我们有一个有两个叶子的树桩。
-
节点特征索引:这指向该节点在模型特征列表中的位置所使用的特征索引。
-
节点阈值:这是设置在该弱分类器特征值上的阈值,该阈值是从训练阶段的全部正负样本中学习的。由于我们正在查看基于树桩的弱分类器的模型,这也是阶段阈值,它在提升过程中设置。
基于 HAAR 的模型中的特征由一组矩形描述,这些矩形最多可以是三个,以便从窗口中计算每个可能的特征。然后,有一个值表示特征本身是否倾斜超过 45 度。对于每个矩形,即部分特征值,我们有:
-
矩形的定位,由矩形的左上角 x 和 y 坐标以及矩形的宽度和高度定义。
-
该特定部分特征的权重。这些权重用于将两个部分特征矩形组合成一个预定义的特征。这些权重使我们能够用比实际必要的更少的矩形来表示每个特征。以下图示展示了这一例子:
一个三矩形特征可以通过两个矩形加权组合来表示,从而减少了额外面积计算的需求。
特征和最终是通过首先将矩形内所有像素的值相加,然后乘以权重因子来计算的。最后,将这些加权和组合在一起,得到最终的特征值。请记住,为单个特征检索到的所有坐标都与窗口/模型大小相关,而不是整个处理过的图像。
局部二进制模式模型
以下是从基于 LBP 特征模型的两个代码片段,包含内部节点结构和特征结构:
<internalNodes>
0 -1 46 -67130709 -21569 -1426120013 -1275125205 -21585
-16385 587145899 -24005
</internalNodes>
… … …
<_>
<rect>0 0 3 5</rect>
</_>
NoteThe software for visualizing Haar wavelet or LBP models can be found at [`github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/visualize_models/`](https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/visualize_models/).
该软件接受多个输入参数,例如模型位置、需要可视化的图像以及需要存储结果的输出文件夹。然而,为了正确使用该软件,有一些需要注意的点:
-
模型需要基于 HAAR 小波或 LBP 特征。已删除,因为此功能不再支持 OpenCV 3。
-
您需要提供一个用于可视化的实际模型检测图像,并将其调整到模型尺度或训练数据中的正训练样本。这是为了确保您的模型特征放置在正确的位置。
-
在代码中,您可以调整可视化尺度,一个用于您模型的视频输出,另一个用于表示阶段的图像。
以下两个图分别展示了 Haar 小波和 LBP 特征基于的前脸模型的可视化结果,两者都包含在 OpenCV 3 仓库下的数据文件夹中。可视化图像分辨率低的原因非常明显。训练过程是在模型尺度上进行的;因此,我想从一个相同大小的图像开始,以说明物体的具体细节被移除,而物体类别的普遍特性仍然存在,以便能够区分类别。
Haar 小波和局部二进制模式特征的前脸模型视频可视化的一组帧
例如,可视化也清楚地表明,LBP 模型需要更少的特征和因此更少的弱分类器来成功分离训练数据,这使得检测时间更快。
Haar 小波和局部二进制模式特征的前脸模型第一阶段的可视化
使用交叉验证以实现最佳模型
确保在您的训练数据中获取最佳模型,可以通过应用交叉验证方法,如留一法来完成测试数据。其背后的思想是将训练集和测试集结合起来,并从更大的集中改变所使用的测试集。对于每个随机测试集和训练集,您将构建一个单独的模型,并使用本章进一步讨论的精确度-召回率进行评估。最后,提供最佳结果的模型可以采用作为最终解决方案。因此,它可以减轻由于训练集中未表示的新实例而导致的错误的影响。
注意
关于交叉验证主题的更多信息,可以在 Kohavi R. (1995, 八月) 的研究中找到,该研究探讨了在 Ijcai (第 14 卷,第 2 期,第 1137-1145 页) 中使用交叉验证和自助法进行准确度估计和模型选择。
使用场景特定知识和约束来优化检测结果
一旦您的级联分类器对象模型训练完成,您就可以使用它来检测新输入图像中相同对象类的实例,这些图像被提供给系统。然而,一旦应用了您的对象模型,您会发现仍然存在误报检测和未检测到的对象。本节将介绍一些技术,通过例如使用场景特定知识来移除大多数误报检测,以改善您的检测结果。
使用检测命令的参数来影响您的检测结果
如果将对象模型应用于给定的输入图像,必须考虑几个因素。让我们首先看看检测函数以及可以用来过滤检测输出的某些参数。OpenCV 3 提供了三个可能的接口。我们将讨论使用每个接口的优点。
接口 1:
void CascadeClassifier::detectMultiScale(InputArray image, vector<Rect>& objects, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size())
第一个接口是最基本的。它允许您快速评估在给定测试图像上的训练模型。在这个基本界面上有几个元素,可以让您操作检测输出。我们将更详细地讨论这些参数,并强调在选择正确值时需要注意的一些要点。
scaleFactor 是用于将原始图像降级以创建图像金字塔的尺度步长,这使我们能够仅使用单个尺度模型执行多尺度检测。一个缺点是这不允许检测比对象尺寸更小的对象。使用 1.1 的值意味着在每一步中,尺寸相对于前一步减少了 10%。
-
增加此值将使您的检测器运行更快,因为它需要评估的尺度级别更少,但会带来丢失位于尺度步骤之间的检测的风险。
-
减少值会使您的探测器运行得更慢,因为需要评估更多的尺度级别,但会增加检测之前遗漏的对象的机会。此外,它会在实际对象上产生更多的检测,从而提高确定性。
-
请记住,添加尺度级别也会导致更多的假阳性检测,因为这些与图像金字塔的每一层都有关。
另一个有趣的参数是minNeighbors
参数。它描述了由于滑动窗口方法而发生的重叠检测的数量。任何与其他检测重叠超过 50%的检测将被合并为一个非极大值抑制。
-
将此值设为 0 意味着您将获得通过完整级联的所有检测生成的检测。然而,由于滑动窗口方法(以 8 像素的步长)以及级联分类器的性质(它们在对象参数上训练以更好地泛化对象类别),对于单个窗口,许多检测将发生。
-
添加一个值意味着您想要计算应该有多少个窗口,至少那些通过非极大值抑制组合在一起的窗口,以保持检测。这很有趣,因为实际对象应该产生比假阳性更多的检测。因此,增加这个值将减少假阳性检测的数量(它们重叠检测的数量很少)并保持真实检测(它们有大量的重叠检测)。
-
一个缺点是,在某个点上,实际对象由于检测确定性较低和重叠窗口较少而消失,而一些假阳性检测可能仍然存在。
使用minSize
和maxSize
参数有效地减少尺度空间金字塔。在一个工业设置中,例如,固定相机位置,如传送带设置,在大多数情况下可以保证对象将具有特定的尺寸。在这种情况下添加尺度值并定义尺度范围将大大减少单个图像的处理时间,通过去除不需要的尺度级别。作为额外的好处,所有那些不需要的尺度上的假阳性检测也将消失。如果您留这些值为空,算法将从头开始构建图像金字塔,以输入图像尺寸为基础,以等于尺度百分比的步长进行下采样,直到其中一个维度小于最大对象维度。这将成为图像金字塔的顶部,也是检测算法在检测时间开始运行对象检测器的位置。
界面 2:
void CascadeClassifier::detectMultiScale(InputArray image, vector<Rect>& objects, vector<int>& numDetections, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size())
第二个接口通过添加numDetections
参数进行了一些小的改进。这允许你将minNeighbors
的值设置为 1,将重叠窗口的合并视为非极大值抑制,同时返回合并的重叠窗口的值。这个值可以看作是你检测的置信度分数。值越高,检测越好或越确定。
接口 3:
void CascadeClassifier::detectMultiScale(InputArray image, std::vector<Rect>& objects, std::vector<int>& rejectLevels, std::vector<double>& levelWeights, double scaleFactor=1.1, int minNeighbors=3, int flags=0, Size minSize=Size(), Size maxSize=Size(), bool outputRejectLevels=false )
这个接口的一个缺点是,100 个单个检测置信度非常低的窗口可以简单地否定一个单个检测置信度非常高的检测。这就是第三个接口可以为我们提供解决方案的地方。它允许我们查看每个检测窗口的个体分数(由分类器最后阶段的阈值值描述)。然后你可以抓取所有这些值,并设置这些个体窗口的置信度分数阈值。在这种情况下应用非极大值抑制时,所有重叠窗口的阈值值会合并。
小贴士
请记住,如果你想在 OpenCV 3.0 中尝试第三个接口,你必须将参数outputRejectLevels
设置为true
。如果不这样做,那么包含阈值分数的水平权重矩阵将不会被填充。
注意
可以在以下链接找到展示对象检测两种最常用接口的软件:github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/detect_simple
和 github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/detect_score
。OpenCV 的检测接口经常变化,因此可能已经存在这里未讨论的新接口。
提高对象实例检测并减少误报检测
一旦你为你的应用选择了最合适的方法来检索对象检测,你就可以评估你算法的正确输出。在训练对象检测器后,最常见的两个问题是:
-
未检测到的对象实例。
-
过多的误报检测。
第一个问题的原因可以通过查看我们基于该对象类的正样本训练数据训练的通用对象模型来解释。这让我们得出结论,训练要么:
-
没有足够的正样本训练数据,这使得无法很好地泛化到新的对象样本。在这种情况下,重要的是要将那些误检作为正样本添加到训练集中,并使用额外数据重新训练你的模型。这个原则被称为“强化学习”。
-
我们过度训练了模型以适应训练集,这再次减少了模型的泛化能力。为了避免这种情况,逐步减少模型的大小和复杂性。
第二个问题相当普遍,并且经常发生。不可能提供足够的负样本,同时确保在第一次运行时不会有任何负窗口仍然可能产生正检测。这主要是因为我们人类很难理解计算机如何根据特征来识别对象。另一方面,在训练对象检测器时,不可能一开始就掌握所有可能的场景(光照条件、生产过程中的交互、相机上的污垢等)。您应该将创建一个良好且稳定的模型视为一个迭代过程。
注意
避免光照条件影响的处理方法可以是,通过为每个样本生成人工暗淡和人工明亮图像来使训练集翻倍。然而,请记住本章开头讨论的人工数据的缺点。
为了减少误报检测的数量,我们通常需要添加更多的负样本。然而,重要的是不要添加随机生成的负窗口,因为这些窗口为模型带来的额外知识在大多数情况下只是微小的。添加有意义的负窗口,以提高检测器的质量会更好。这被称为使用自举过程的硬负样本挖掘。原理相当简单:
-
首先,根据您的初始正负窗口样本训练集训练第一个对象模型。
-
现在,收集一组负图像,这些图像要么针对您的应用特定(如果您想训练针对您设置的特定对象检测器)或者更通用(如果您希望您的对象检测器能在多种条件下工作)。
-
在这组负图像上运行您的检测器,使用低置信度阈值并保存所有找到的检测。从提供的负图像中裁剪它们,并重新调整大小以适应对象模型尺寸。
-
现在,重新训练您的对象模型,但将所有找到的窗口添加到您的负训练集中,以确保您的模型现在将使用这些额外知识进行训练。
这将确保您的模型精度根据负图像的质量以公平和合理的方式提高。
小贴士
当添加找到的额外且有用的负样本时,请将它们添加到background.txt
文件的顶部!这迫使 OpenCV 训练界面首先获取这些更重要的负样本,然后再采样所有标准负训练图像!确保它们具有精确的模型大小,这样它们就只能作为一次负训练样本使用。
获得旋转不变性对象检测
TipSoftware for performing rotation invariant object detection based on the described third approach can be found at [`github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/rotation_invariant_detection/`](https://github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/rotation_invariant_detection/).
这种方法的最大优点是,你只需要训练一个单方向模型,可以将你的时间投入到更新和调整这个单一模型,使其尽可能高效。另一个优点是,你可以通过提供一些重叠来结合不同旋转的所有检测,然后通过智能地移除在多个方向上没有检测到的假阳性,来增加检测的确定性。所以基本上,这是一种在方法的好处和缺点之间进行权衡。
然而,这种方法仍然存在一些缺点:
-
你需要将多尺度检测器应用于你的 3D 表示矩阵的每一层。这肯定会增加与单方向对象检测相比的对象实例搜索时间。
-
你将在每个方向上创建假阳性检测,这些检测也将被扭曲回,从而增加假阳性检测的总数。
让我们更深入地看看用于执行此旋转不变性的源代码部分,并解释实际上发生了什么。第一个有趣的部分可以在创建旋转图像的 3D 矩阵时找到:
// Create the 3D model matrix of the input image
Mat image = imread(input_image);
int steps = max_angle / step_angle;
vector<Mat> rotated_images;
cvtColor(rotated, rotated, COLOR_BGR2GRAY);
equalizeHist( rotated, rotated );
for (int i = 0; i < steps; i ++){
// Rotate the image
Mat rotated = image.clone();
rotate(image, (i+1)*step_angle, rotated);
// Preprocess the images
// Add to the collection of rotated and processed images
rotated_images.push_back(rotated);
}
基本上,我们做的是读取原始图像,创建一个包含每个旋转输入图像的 Mat 对象向量的数组,并在其上应用旋转函数。正如你将注意到的,我们立即应用所有需要的预处理步骤,以便使用级联分类器接口进行高效的对象检测,例如将图像渲染为灰度值并应用直方图均衡化,以应对光照变化。
旋转函数在这里可以看到:
void rotate(Mat& src, double angle, Mat& dst)
{
Point2f pt(src.cols/2., src.rows/2.);
Mat r = getRotationMatrix2D(pt, angle, 1.0);
warpAffine(src, dst, r, cv::Size(src.cols, src.rows));
}
此代码首先根据我们想要旋转的角度(以度为单位)计算一个旋转矩阵,然后根据这个旋转矩阵应用仿射变换。记住,以这种方式旋转图像可能会导致边缘对象的信息丢失。此代码示例假设你的对象将出现在图像的中心,因此这不会影响结果。你可以通过在原始图像周围添加黑色边框来避免这种情况。图像的宽度和高度相等,这样图像信息损失最小。这可以通过在读取原始输入图像后立即添加以下代码来完成:
Size dimensions = image.size();
if(dimensions.rows > dimensions.cols){
Mat temp = Mat::ones(dimensions.rows, dimensions.rows, image.type()) * 255;
int extra_rows = dimensions.rows - dimensions.cols;
image.copyTo(temp(0, extra_rows/2, image.rows, image.cols));
image = temp.clone();
}
if(dimensions.cols > dimensions.rows){
Mat temp = Mat::ones(dimensions.cols, dimensions.cols, image.type()) * 255;
int extra_cols = dimensions.cols - dimensions.rows;
image.copyTo(temp(extra_cols/2, 0, image.rows, image.cols));
image = temp.clone();
}
此代码将简单地根据最大尺寸将原始图像扩展到匹配一个正方形区域。
最后,在 3D 图像表示的每一层上,都会执行检测,并使用与扭曲原始图像类似的方法将找到的检测扭曲回原始图像:
-
将旋转图像中找到的四个检测到的角落点添加到一个用于旋转扭曲的矩阵中(代码行 95-103)。
-
根据当前旋转图像的角度应用逆变换矩阵(代码行 106-108)。
-
最后,在旋转的四矩阵点信息上绘制一个旋转矩形(代码行 111-128)。
下图显示了将旋转不变性人脸检测应用于具有多个方向的人脸图像的确切结果。
以以下角度步长开始进行旋转不变性人脸检测:[1 度,10 度,25 度,45 度]
我们看到四次建议的技术被应用于相同的输入图像。我们调整了参数以观察对检测时间和返回的检测的影响。在所有情况下,我们都从 0 到 360 度进行了搜索,但在 3D 旋转矩阵的每个阶段之间改变了角度步长,从 0 到 45 度。
应用角度步长 | 执行所有检测的总时间 |
---|---|
1 度 | 220 秒 |
10 度 | 22.5 秒 |
25 度 | 8.6 秒 |
45 度 | 5.1 秒 |
如我们所见,当增加角度步长时,检测时间会大幅减少。知道一个物体模型本身至少可以覆盖总共 20 度,我们可以轻松地减小步长以显著减少处理时间。
第六章:2D 尺度空间关系
另一个在工业案例中可以充分利用的巨大优势是,许多这些设置都有一个固定的相机位置。当需要检测的对象遵循一个固定的地面平面时,这很有趣,例如在行人或通过传送带的物体的情况下。如果这些条件存在,那么实际上就有可能在每个图像位置建模对象的尺度。这产生了两个可能的优势:
-
首先,你可以利用这些知识有效地减少误报检测的数量,同时保持你的置信度阈值足够低,以便低置信度和良好的检测仍然存在。这可以在对象检测阶段之后的某些后处理步骤中完成。
-
其次,这些知识可以用来有效地减少图像金字塔中对象候选者的检测时间和搜索空间。
让我们从关注以下案例开始,该案例在下面的图中进行了说明。考虑这样一个事实:我们想要创建一个行人检测系统,并且我们已经有一个用于此目的的现有模型。我们有一个安装在汽车顶部的 360 度相机,并且以连续的时间间隔抓取这些全景图像。由于这种 360 度全景图像的分辨率非常大,图像金字塔将会非常庞大,导致大量的误报检测和非常长的处理时间。
基于 HAAR 特征的 OpenCV 3 中 Viola 和 Jones 级联分类器行人检测模型的示例
例子清楚地表明,在应用检测器时,很难找到一个合适的分数阈值,以仅检测行人而不再有一系列误报检测。因此,我们选取了一组 40 张全景图像,并使用我们的对象标注工具手动标注了其中的每个行人。如果我们然后可视化边界框的标注高度与图像中出现的 x 位置位置的关系,我们就可以得出以下关系,如图所示:
图像中标注位置与找到的检测尺度之间的尺度空间关系
这张图中的红色点都是我们从 40 个圆柱形场景的测试平台上检索到的所有可能的地面真实标注。红色线条是我们拟合到数据上的线性关系,它描述了在图像的哪个位置应该检测到哪个尺度。然而,我们知道,根据绿色边界定义的特定尺度可能会有轻微的变化,以便尽可能包含更多的标注。我们使用了分配高斯分布的规则,因此同意在[-3sigma,+3sigma]范围内,98%的所有检测都应该落在该范围内。然后,我们根据我们的范围应用最小值和最大值,并定义了一个物体可以自然出现的区域,用蓝色边界标记,并在以下图片中可视化:
行人在同一地面平面上且完全被摄像头系统看到的可能位置
这意味着,如果我们在这个输入图像上运行检测器,我们就可以消除超过 50%的图像,因为训练数据清楚地显示,行人不可能出现在那个位置。这大大减少了搜索空间!这种使用图像掩码限制搜索空间的方法的唯一缺点是,例如,阳台上的行人将被简单地忽略。但再次强调,在这个应用中,没有必要找到这些人,因为他们不在同一个地面平面上。
然后,我们最终将本章中我们知道的所有内容结合起来。我们对所有可能出现的尺度应用了尺度空间关系,这仅限于掩码区域内部,因为在我们应用中,物体不能存在于该区域之外。然后,我们将分数阈值降低,以便有更多的检测,并确保在应用基于尺度空间关系的过滤之前,我们已经检测到尽可能多的行人。结果可以在这里展示。它清楚地表明,在某些应用中,上下文信息可以大大提高检测率!
完整的流程:1)使用低阈值进行检测,2)应用掩码并移除大量误报,3)强制执行尺度空间位置以移除额外的误报检测
性能评估和 GPU 优化
我们正在接近本章的结尾,但在结束之前,我想讨论两个虽然小但仍然重要的话题。让我们首先讨论通过不仅进行视觉检查,而且实际查看我们的模型在更大的数据集上表现如何来评估级联分类器物体检测模型的性能。
物体检测性能测试
我们将通过使用精确度-召回率曲线的概念来完成这项工作。它们与统计学领域更常见的 ROC 曲线略有不同,其缺点是它们依赖于真实负值,而在滑动窗口应用中,这个值变得如此之高,以至于真实正例、假正例和假负例的值相对于真实负例将消失。精确度-召回率曲线避免了这种测量,因此更适合创建对我们级联分类器模型的评估。
精确度 = TP / (TP + FP) 和 召回率 = TP / (TP + FN),其中真实正例(TP)是指既被标注又被检测到的注释,假正例(FP)是指没有标注但被检测到的检测,假负例(FN)是指没有检测到的标注。
这些值描述了你的模型在某个阈值值下的表现。我们使用置信度分数作为阈值值。精确度定义了所找到的检测中有多少是实际对象,而召回率定义了图像中实际找到的对象数量。
注意
在github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/precision_recall/
可以找到用于在变化阈值上创建 PR 曲线的软件。
该软件需要几个输入元素:
-
首先,你需要收集一个与训练集独立的验证/测试集,因为否则你永远无法决定你的模型是否对一组训练数据过拟合,从而在泛化到一组类别实例时表现更差。
-
其次,你需要验证集的标注文件,这可以被视为验证集的基准。这可以使用本章提供的对象标注软件来完成。
-
第三,你需要一个由检测软件创建的检测文件,该文件还输出分数,以便能够改变这些检索到的分数。同时,确保非极大值抑制仅设置为 1,以便在同一位置的检测被合并,但没有任何检测被拒绝。
当在这样一个验证集上运行软件时,你将收到一个精确度-召回率结果文件,如这里所示。结合每个阈值步骤的精确度-召回率坐标,你还将收到阈值本身,这样你就可以在精确度-召回率曲线上选择最适合你应用的最佳工作点,然后找到所需的阈值!
自训练级联分类器对象模型的精确度-召回率结果
这些输出可以通过 MATLAB (nl.mathworks.com/products/matlab/
) 或 Octave (www.gnu.org/software/octave/
) 等软件包进行可视化,这些软件包比 OpenCV 有更好的图形生成支持。前一个文件的结果可以在以下图表中看到。附带的 MATLAB 示例脚本用于生成这些可视化,以及精确度召回率软件。
精确度召回率结果图表
观察图表,我们可以看到精确度和召回率都有一个[0 1]的尺度。图表中最理想的位置是右上角(精确度=1/召回率=1),这意味着图像中的所有对象都被找到,且没有发现任何误报。所以,基本上,你的图表斜率越接近右上角,你的检测器就越好。
为了将准确度值添加到精确度召回率图的某条曲线上(当比较具有不同参数的模型时),计算机视觉研究社区使用曲线下面积(AUC)的原则,用百分比表示,这也可以在生成的图表中看到。再次强调,获得 100%的 AUC 意味着你已经开发出了理想的物体检测器。
使用 GPU 代码进行优化
为了能够重建关于 GPU 使用的讨论中进行的实验,你需要拥有一块与 OpenCV CUDA 模块兼容的 NVIDIA GPU。此外,你需要使用不同的配置(我将在后面强调)重新构建 OpenCV,以获得完全相同的输出。
我端进行的测试是在一台戴尔 Precision T7610 电脑上进行的,该电脑配备了一颗英特尔 Xeon(R) CPU,具有两个处理器,每个处理器支持 12 个核心和 32 GB 的 RAM 内存。作为 GPU 接口,我使用了一块配备 1 GB 独立板载内存的 NVIDIA Quadro K2000。
通过 OpenCL 和 OpenCV 3 中引入的新 T-API,可以使用非 NVIDIA GPU 获得类似的结果。然而,由于这项技术相对较新且尚未完全无错误,我们将坚持使用 CUDA 接口。
OpenCV 3 包含了级联分类器检测系统的 GPU 实现,可以在 CUDA 模块下找到。这个接口可以帮助处理大图像时提高性能。以下图示就是一个例子:
不使用任何 CPU 优化的 CPU-GPU 比较
注意
这些结果是通过使用可以从github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_5/source_code/CPU_GPU_comparison/
获取的软件获得的。
为了实现这个结果,我没有对 OpenCV 进行任何 CPU 优化和 CUDA 支持。为此,你需要禁用几个 CMAKE 标志,从而禁用以下包:IPP、TBB、SSE、SSE2、SSE3、SSE4、OPENCL 和 PTHREAD。为了避免在 CPU 在后台执行某些操作时加载单个图像造成的任何偏差,我连续处理了图像 10 次。
原始输入图像的大小为 8000x4000 像素,但在一些测试之后,似乎 GPU 上的detectMultiScale
函数需要的内存将超过专门的 1 GB。因此,我们只从图像大小为 4000*2000 像素开始进行测试。很明显,当在单个核心 CPU 上处理图像时,GPU 接口的效率要高得多,即使考虑到每次运行都需要将数据从内存推送到 GPU 并取回。我们仍然可以获得大约 4-6 倍的速度提升。
然而,GPU 实现并不总是最佳选择,我们将通过第二次测试来证明这一点。让我们先总结一下为什么 GPU 可能不是一个好主意的原因:
-
如果你的图像分辨率较小,那么初始化 GPU、解析数据到 GPU、处理数据以及将其抓回到内存所需的时间可能会成为你应用程序的瓶颈,实际上可能比在 CPU 上直接处理所需的时间更长。在这种情况下,最好使用基于 CPU 的检测软件实现。
-
GPU 实现不提供返回阶段权重的功能,因此基于 GPU 优化的函数创建精确度召回率曲线将很困难。
-
前面的案例是在没有任何优化且仅使用单个核心 CPU 的情况下进行的测试,这在当今实际上是一个糟糕的参考。OpenCV 已经投入了大量精力,通过大量优化使他们的算法在 CPU 上高效运行。在这种情况下,并不能保证具有数据传输瓶颈的 GPU 仍然会更快地运行。
为了证明 GPU 实现可能比 CPU 实现更差,我们使用以下免费可用的优化参数构建了 OpenCV:IPP(OpenCV 提供的免费紧凑型集)、TBB、SSE2、SSE3、SSE4(由 CMAKE 脚本能动选择为我系统选择的 SSE 指令)、pthread(用于使用并行 for 循环结构),当然,还有 CUDA 接口。
然后,我们将再次运行相同的软件测试,如下所示。
OpenCV 3.0 提供的基本 CPU 优化与 CPU-GPU 比较
现在,我们可以清楚地看到,在我系统上的优化使用在 CPU 上比在 GPU 上产生更好的结果。在这种情况下,如果只看到他/她有 GPU 可用的事实,那么将做出一个糟糕的决定。基本上,这证明了你应该始终注意你将如何优化你的算法。当然,这个结果有点偏颇,因为普通计算机没有 24 个核心和 32GB 的 RAM 内存,但鉴于个人计算机的性能每天都在提高,不久的将来,每个人都能访问这些类型的设置。
我甚至更进一步,通过将原始的 8000*4000 像素图像,由于系统中的 32GB RAM,没有对 CPU 的内存限制,再次在该单一尺寸上执行软件。对于 GPU 来说,这意味着我必须将图像分成两部分并处理它们。再次,我们连续处理了 10 张图像。结果可以在以下图像中看到:
在 GPU 上处理 8000x4000 像素图像与多核 CPU 处理的比较
正如你所看到的,GPU 接口与 CPU 接口相比,仍然存在差异,GPU 接口大约是 CPU 接口的四倍,因此在这种情况下,选择 GPU 解决方案而不是多核 CPU 解决方案将是一个非常糟糕的决定。
实际应用
如果你还在想这种目标检测软件的实际工业应用可能是什么,那么请看以下内容:
工业目标检测的示例
这是我使用此软件获取检测对象准确位置的应用快速概述:
-
包含在一系列不同背景上旋转不变检测饼干和糖果的模拟测试用例。
-
在显微镜下自动检测和计数微生物,而不是自己计数。
-
鲜草莓的成熟度分类定位。
-
在航空影像中对道路标记进行定位,以基于检索数据的自动创建 GIS(地理信息系统)。
-
在传送带上对(绿色、黄色和红色混合)辣椒的旋转不变检测,结合对有效机器人抓取的斯托克检测。
-
用于 ADAS(自动驾驶员辅助系统)系统的交通标志检测。
-
兰花检测,用于自动分类兰花物种。
-
在近红外图像中进行行人检测和跟踪,用于安全应用。
所以,正如你所看到的,可能性是无限的!现在,尝试想出你自己的应用,并用它征服世界。
让我们以对 Viola 和 Jones 目标分类框架进行目标检测的批判性评论来结束本章。只要你的应用专注于检测一个或两个目标类别,那么这种方法相当有效。然而,一旦你想要处理多类别检测问题,可能需要寻找所有其他的目标分类技术,并找到一个更适合你应用的更合适的方法,因为在一个单独的图像上运行大量的级联分类器将花费很长时间。
注意
目前研究焦点中的一些非常有前景的目标分类框架,或者作为新技术的坚实基础,可以在下面找到。它们可能对于想要超越 OpenCV 可能性的人来说是一个有趣的起点。
-
Dollár P., Tu Z., Perona P., 和 Belongie S (2009 年 9 月), 整合通道特征。在 BMVC(第 2 卷第 3 期,第 5 页)。
-
Dollár P., Appel R., Belongie S., 和 Perona P (2014), 快速特征金字塔用于目标检测。模式分析与机器智能,IEEE 交易,36(8), 1532-1545。
-
Krizhevsky A., Sutskever I., 和 Hinton G. E (2012), 使用深度卷积神经网络进行 ImageNet 分类。在神经信息处理系统进展(第 1097-1105 页)。
-
Felzenszwalb P. F., Girshick R. B., McAllester D., 和 Ramanan D (2010), 使用判别性训练的基于部分模型进行目标检测。模式分析与机器智能,IEEE 交易,32(9), 1627-1645。
摘要
本章汇集了关于基于 Viola 和 Jones 框架的 OpenCV 3 中级联分类器对象检测接口的广泛技巧和窍门。我们走过了对象检测管道的每一步,并关注了可能出错的地方。本章为你提供了优化任何所需目标模型级联分类器结果的工具,同时建议从中选择最佳参数。
最后,一些特定场景的示例被用来说明,即使使用较弱训练的目标模型,如果你利用场景知识来去除误报检测,也能表现良好。
第六章. 利用生物特征属性进行高效人员识别
数字媒体的兴起比以往任何时候都要大。人们将越来越多的个人信息放在笔记本电脑、智能手机和平板电脑等数字载体上。然而,许多这些系统并没有提供有效的身份识别和验证系统来确保陌生人无法访问你的个人数据。这就是基于生物特征的识别系统发挥作用并试图使你的数据更加安全、减少对恶意人员的脆弱性的地方。
这些识别系统可以用来锁定你的电脑,防止人们进入安全房间等,但随着技术的每日进步,我们离进一步数字化我们的个人生活只有一步之遥。用你的面部表情来解锁门怎么样?用你的指纹打开汽车怎么样?可能性是无限的。
许多技术和算法已经在开源计算机视觉和机器学习包(如 OpenCV)中可用,可以有效地使用这些个人识别属性。当然,这也为热衷于计算机视觉的程序员创造了基于这些技术的许多不同应用的可能性。
在本章中,我们将关注使用个人生物特征的技术,以便创建超越基于密码的标准登录系统的个人身份验证系统。我们将更深入地探讨虹膜和指纹识别、人脸检测和人脸识别。
我们将首先讨论每种生物特征技术背后的主要原则,然后我们将基于 OpenCV 3 库展示一个实现示例。对于某些生物特征,我们将利用现有的开源框架。所有用于展示技术的数据集都免费在线提供,用于研究目的。然而,如果你想要将它们应用于商业应用,请务必检查它们的许可证!
最后,我们将说明如何结合几种生物特征分类来增加基于个人生物特征的特定人员成功识别的概率。
在本章结束时,你将能够创建一个完全功能化的识别系统,这将帮助你避免个人详细信息被任何恶意方窃取。
生物特征,一种通用方法
使用生物特征属性识别人员的通用思想对所有现有的生物特征都是相同的。如果我们想要获得良好的结果,我们应该遵循正确的顺序进行几个步骤。此外,我们还将指出这些一般步骤中的几个重要点,这将帮助你通过极端措施提高识别率。
第 1 步 – 获取良好的训练数据集并应用应用特定的归一化
大多数生物特征识别系统的关键是收集一个代表你将实际使用该系统的问题的系统训练数据集。研究已经证明,存在一种称为数据集偏差的现象,这意味着如果你在一个具有特定设置、环境因素和记录设备的训练集上训练一个系统,然后将该系统应用于从完全不同的设置、不同的环境因素(如光源)和不同的记录设备中获取的测试集,那么这将会导致性能下降高达 25%。这是一个很大的性能损失,因为你想要确保你的识别系统以最佳性能运行。
因此,在创建你的识别系统的训练集时,有几个方面需要考虑:
-
你应该只收集在应用生物特征识别时将使用的已知设置的训练数据。这意味着在开始训练模型和分类器之前,你需要决定硬件。
-
对于生物特征登录系统,尽可能多地约束你的数据是很重要的。如果你可以消除光照变化、不同的背景设置、运动、非等距定位等问题,那么你可以极大地提高你应用程序的性能。
-
尽量规范化你的数据方向。如果你将所有训练数据对齐到相同的位置,你就可以避免在单个生物特征描述中引入不希望出现的方差。该领域的研究已经证明,这可以提高识别率超过 15%!
-
使用单个生物特征的多个训练实例,并使用平均生物特征描述来验证一个人的身份。单次训练系统的一个缺点是,两个生物特征记录之间的细微差异会对分类率产生很大的影响。单次学习仍然是一个非常活跃的研究课题,并且尚未找到一个非常稳定的解决方案来解决这个问题。
如何将这种归一化应用于特定技术将在相应的子主题中进行讨论;例如,在人脸识别的情况下,它实际上可能很大程度上取决于所使用的技术。一旦你获得了一个好的训练集,包含足够的样本,你就可以准备进行第二步了。
注意
请记住,在某些情况下,应用约束并不总是一个好的选择。考虑一个基于生物特征特征的笔记本电脑登录系统,该系统仅在灯光打开时(如人脸检测和识别)工作。当有人在黑暗的房间里工作时,该系统将无法工作。在这种情况下,你需要重新考虑你的应用程序,并确保有足够的与光照变化无关的生物特征检查。你甚至可以通过网络摄像头检查光强度,如果可以预测人脸检查会失败,则禁用人脸检查。
应用和情境的简化涉及简化本章讨论的算法,从而在这些受限场景中提高性能。
第 2 步 – 创建记录的生物特征的描述符
一旦你收集到构建你的生物识别系统的所需训练数据,找到一种独特描述每个个体的生物参数的方法就变得很重要。这种描述被称为“独特特征向量”,与原始记录图像相比,它具有几个优点:
-
一个全尺寸的高分辨率 RGB 图像(这在生物识别记录中用得很多)包含大量数据。如果我们对整个图像进行分类,它将是:
-
计算成本非常高。
-
并非像期望的那样独特,因为不同人的区域可以是相同的或非常相似的。
-
-
它将输入图像中的重要且独特的信息减少到基于关键点的稀疏表示,这些关键点是每个图像的独特特征。
再次,你如何构建特征描述符取决于你想要用于验证的哪种生物识别。一些方法基于高博滤波器组、局部二值模式描述和 SIFT、SURF、ORB 等关键点描述符。可能性,再次,是无限的。这完全取决于为你的应用找到最好的描述。我们将为每种生物识别提供建议,但为了找到最适合你应用的解决方案,还需要进行更彻底的搜索。
第 3 步 – 使用机器学习匹配检索到的特征向量
从第 2 步创建的每个特征向量都需要是唯一的,以确保基于这些特征向量的机器学习技术能够区分不同测试对象的生物特征。因此,拥有足够维度的描述符非常重要。机器学习技术在分离高维空间中的数据方面比人类要好得多,而在低维特征空间中分离数据时,人类大脑的表现优于系统。
选择最佳的机器学习方法是件非常繁琐的事情。原则上,不同的技术可以提供相似的结果,但找到最佳方案则是一场试错的游戏。你可以在每个机器学习方法内部应用参数优化以获得更好的结果。这种优化对于本章来说过于详细。对此感兴趣的人应该深入了解超参数优化技术。
注意
下面可以找到一些关于这个超参数优化问题的有趣出版物:
-
Bergstra J. S., Bardenet R., Bengio Y. 和 Kégl B. (2011), 超参数优化的算法, 在《神经信息处理系统进展》(第 2546-2554 页)。
-
Bergstra J. 和 Bengio Y. (2012), 超参数优化的随机搜索, 《机器学习研究杂志》, 13(1), 281-305。
-
Snoek J., Larochelle H., and Adams R. P. (2012), 《机器学习算法的实用贝叶斯优化》,收录于《神经信息处理系统进展》(第 2951-2959 页)。
OpenCV 3 中有很多机器学习技术。以下是一些最常用的技术,按复杂度排序:
-
相似度匹配使用距离度量。
-
K-最近邻搜索:基于高维空间中特征向量之间的距离(欧几里得、汉明等)计算的多(K)类分类。
-
朴素贝叶斯分类器:一种使用贝叶斯学习来区分不同类别的二元分类器。
-
支持向量机:主要用作二元分类学习方法,但可以适应多类分类系统。这种方法依赖于通过在高维空间中寻找训练数据云和分离边缘之间的最优分离平面来分离数据。
-
提升和随机森林:将多个弱分类器或学习器组合成一个复杂模型的技术,能够分离二元和多类问题。
-
人工神经网络:一组利用大量神经元(如大脑中的小细胞)的强大能力,通过示例学习连接和决策的技术。由于它们的学习曲线更陡峭,优化步骤复杂,因此在本章中我们将放弃使用它们。
注意
如果你对你的分类问题感兴趣,想使用神经网络,那么请查看这个 OpenCV 文档页面:
docs.opencv.org/master/d0/dce/classcv_1_1ml_1_1ANN__MLP.html
第 4 步 – 思考你的认证过程
一旦你有一个对输入特征向量输出分类的机器学习技术,你需要检索一个确定性。这个确定性是确保分类结果确定性的必要条件。例如,如果某个输出在数据库中与条目 2 和条目 5 都有匹配,那么你需要使用确定性来确保你应该继续哪一个匹配。
在这里,考虑你的认证系统如何运行也很重要。它可以是逐一匹配的方法,即匹配每个数据库条目与你的测试样本,直到获得足够高的匹配分数,或者是一对多方法,即匹配整个数据库,然后查看每个匹配的检索分数,并选择最佳匹配。
一对一可以看作是一对多的一种迭代版本。它们通常使用相同的逻辑;区别在于比较过程中使用的数据结构。一对多方法需要更复杂的数据存储和索引方式,而一对一则使用更直接的暴力方法。
注意
请记住,由于机器学习中总是可能发生假阳性匹配,这两种技术可能会产生不同的结果。
想象一下你系统的输入测试查询。使用一对一对匹配,当你达到足够高的匹配度时,你会停止分析数据库。然而,如果在更远的地方有一个产生更高分数的匹配,那么这个匹配将被丢弃。使用一对多方法,这可以避免,因此在许多情况下,应用一对多方法更好。
为了举例说明在特定情况下应使用哪种方法,想象一个通往秘密实验室的门。如果你想检查一个人是否有权进入实验室,那么需要一个一对多方法来确保匹配所有数据库条目,并且最高匹配分数的确定性高于某个阈值。然而,如果这个最后的秘密实验室门仅用于选择已经允许进入房间的人,那么一对一方法就足够了。
为了避免两个个体对单一生物特征有非常相似描述符的问题,通常会结合多个生物特征以减少假阳性检测的发生。这一点将在本章末尾进一步讨论,当我们在一个有效的身份验证系统中结合多个生物特征时。
人脸检测和识别
大多数现有的身份验证系统都是从检测人脸并尝试将其与使用该系统的已知人员数据库匹配开始的。本小节将更详细地探讨这一点。我们不会深入研究软件的每一个参数。
注意
如果你想要关于完整的人脸检测以及人和猫的识别流程的更多信息,那么请查看 PacktPub 出版的一本名为OpenCV for Secret Agents的书。它更详细地探讨了整个过程。
如果你想要关于基于 Viola 和 Jones 级联分类管道的 OpenCV 人脸检测接口参数的非常详细的解释,那么我建议你去看第五章,工业应用中的通用目标检测,它讨论了用于通用目标检测的通用接口。
无论何时你在关注一个身份验证系统,你都要确保你熟悉需要应用的不同子任务,正如在“使用 Viola 和 Jones 提升级联分类器算法进行人脸检测”一节中图人脸检测软件及其裁剪的人脸区域所示。
-
你应该从使用一个通用人脸检测器开始。这是用来在任意输入中找到人脸的;例如,从你的网络摄像头中。我们将使用 OpenCV 中的 Viola 和 Jones 人脸检测器,它基于 AdaBoost 的级联分类器进行训练。
-
其次,您应该在图像上执行一些归一化操作。在我们的案例中,我们将应用一些灰度化、直方图均衡化以及基于眼和嘴检测的对齐。
-
最后,需要将数据传递到人脸识别接口。我们将简要讨论不同的选项(LBPH、Eigenfaces 和 Fisherfaces),并带您了解整个过程。这将返回数据库中用于匹配的选定用户。
我们将在所有阶段讨论可能的方法的优势、劣势和风险。我们还将建议几个开源软件包,如果您想进一步优化方法,这将给您机会。
注意
本小节的软件可以在以下位置找到:
github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_6/source_code/face/
使用 Viola 和 Jones 增强级联分类器算法进行人脸检测
现在大多数网络摄像头设置都提供高分辨率的 RGB 图像作为输入。然而,请注意,对于所有基于 OpenCV 的操作,OpenCV 都将输入格式化为 BGR 图像。因此,在应用人脸检测器之前,我们应该对输出图像进行一些预处理步骤。
-
首先将图像转换为灰度图像。Viola 和 Jones 方法使用 HAAR 小波或基于局部二值模式的特征,这两种特征都不依赖于颜色。这两种特征类型都寻找像素强度变化的区域。因此,我们可以省略额外的颜色信息,并减少需要处理的数据量。
-
降低图像的分辨率。这取决于网络摄像头的输出格式,但考虑到处理时间会随着分辨率的增加而指数级增加,640x360 的比率对于人脸检测来说已经足够。
-
将直方图均衡化应用于图像以覆盖不同光照下的不变性。基本上,这个操作试图使整个图像的强度直方图平坦化。在 OpenCV 3 中训练检测模型时也进行了同样的操作。
注意
对于算法,始终使用不同的输入和输出容器是好的,因为内联操作往往会非常恶劣地处理输出。如果您不确定内联替换是否受支持,通过声明一个额外的变量来避免问题。
以下代码片段说明了这种行为:
Mat image, image_gray, image_hist;
VideoCapture webcam(0);
Webcam >> image;
resize(image, image, Size(640,360));
cvtColor(image, image_gray, COLOR_BGR2GRAY);
equalizeHist(image_gray, image_hist);
在完成所有预处理后,您可以将以下代码应用于输入图像以获得一个可操作的人脸检测器:
CascadeClassifier cascade('path/to/face/model/');
vector<Rect> faces;
cascade.detectMultiScale(image_hist, faces, 1.1, 3);
您现在可以在图像上绘制检索到的矩形来可视化检测。
注意
如果您想了解更多关于使用参数或检索到的检测信息,请查看第五章,《工业应用中的通用目标检测》,其中更详细地讨论了此接口。
人脸检测将看起来像下面的图:
人脸检测软件示例及其裁剪的人脸区域
最后,您应该裁剪检测到的人脸区域,以便将它们传递给将处理图像的接口。最佳方法是从原始调整大小的图像中获取这些人脸区域,如图中所示,而不是从可视化矩阵中获取,以避免裁剪掉红色边框并污染人脸图像。
for(int i=0; i<faces.size(); i++){
Rect current_face = faces[i];
Mat face_region = image( current_face ).clone();
// do something with this image here
}
注意
执行此人脸检测的软件可以在以下位置找到:
github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_6/source_code/face/face_detection/
.
在检测到的人脸区域上进行数据归一化
如果您只对基本的测试设置感兴趣,那么人脸归一化步骤实际上并不是必需的。它们主要用于提高您的人脸识别软件的质量。
一个好的开始方法是减少图像中的变化量。您已经可以应用转换为灰度图和直方图均衡化来从图像中移除信息,如前一个子主题中所述。如果您只想进行简单的测试设置,这将是足够的,但需要人员保持与为该人员抓取训练数据时相同的位置。如果不是这样,那么由于头部位置不同而产生的数据微小变化就足以触发与数据库中另一人的错误匹配。
为了避免这种情况,并提高以下人脸识别系统的质量,我们建议应用人脸对齐。这可以通过几种方式完成。
-
作为一种基本方法,可以运行基于现有 OpenCV 检测器的眼和嘴检测器,并使用检测的中心作为对齐人脸的方式。
注意
对于非常详细的解释,请参阅 Shervan Emami 所著的《精通 OpenCV》第八章(
github.com/MasteringOpenCV/code/tree/master/Chapter8_FaceRecognition
)。他讨论了使用眼检测对人脸进行对齐的几种方法。此外,请参阅第三章,《使用机器学习识别面部表情》中关于“在图像中找到人脸区域”的部分。
-
更高级的方法是应用面部特征点检测器,并使用所有这些点来归一化和对齐面部。
注意
如果您对更高级的技术感兴趣,请查看 flandmark 库(
cmp.felk.cvut.cz/~uricamic/flandmark/
)。有关使用面部特征点技术的更多信息,请参阅第三章, 使用机器学习识别面部表情,该章节讨论了如何安装此库、配置软件,并在任何给定的面部图像上运行它。
关于面部对齐的良好讨论可以在以下 OpenCV Q&A 论坛找到:answers.opencv.org/question/24670/how-can-i-align-face-images/
。多个活跃的论坛用户聚集了他们的 OpenCV 知识,提出了一种基于基本面部特征点技术的非常有前景的对齐技术。
最基本的对齐可以通过以下方法实现:
-
首先使用提供的眼脸级联检测两个眼睛。
-
找到两个眼睛检测的中心点。
-
计算双眼之间的角度。
-
围绕图像自身的中心旋转图像。
以下代码执行此操作:
CascadeClassifier eye('../haarcascades/haarcascade_eye.xml');
vector<Rect> eyes_found;
eye.detectMultiScale(face_region, eyesfound, 1.1, 3);
// Now let us assume only two eyes (both eyes and no FP) are found
double angle = atan( double(eyes_found[0].y - eyes_found[1].y) / double(eyes_found[0].x - eyes_found[1].x) ) * 180 / CV_PI;
Point2f pt(image.cols/2, image.rows/2);
Mat rotation = getRotationMatrix2D(pt, angle, 1.0);
Mat rotated_face;
warpAffine(face_region, rotated_face, rotation, Size(face_region.cols, face_region.rows));
注意
Ptr<BasicFaceRecognizer> face_model = createEigenFaceRecognizer();
这将生成一个基于特征向量的模型,准备进行训练,该模型将使用所有特征向量(可能较慢)且没有确定性阈值。为了能够使用它们,您需要使用面部识别器的重载接口。
-
Ptr<BasicFaceRecognizer> face_model = createEigenFaceRecognizer(20);
-
Ptr<BasicFaceRecognizer> face_model = createEigenFaceRecognizer(20, 100.0);
在这里,您需要决定您实际上想要实现什么。使用少量特征向量进行训练将很快,但准确性会较低。为了提高准确性,增加使用的特征向量的数量。由于它很大程度上取决于训练数据,因此获取正确的特征向量数量相当繁琐。作为一个启发式方法,您可以使用少量特征向量训练一个识别器,在测试集上测试识别率,然后只要您没有达到识别率目标,就增加特征向量的数量。
然后,可以使用以下代码学习模型:
// train a face recognition model
vector<Mat> faces;
vector<int> labels;
Mat test_image = imread("/path/to/test/image.png");
// do not forget to fill the data before training
face_model.train(faces, labels);
// when you want to predict on a new image given, using the model
int predict = modelàpredict(test_image);
如果您想获得更多关于预测的信息,例如预测置信度,那么您可以替换最后一行:
int predict = -1; // a label that is unexisting for starters
double confidence = 0.0;
modelàpredict(test_image, predict, confidence);
这是一个基本设置。为了提高您模型的质量,您需要记住以下事项:
-
通常,一个人拥有的训练人脸越多,对该人的新样本识别效果越好。然而,请注意,您的训练样本应包含尽可能多的不同情况,包括照明条件、面部毛发、属性等。
-
增加用于投影的特征向量的数量可以提高准确性,但也会使算法变慢。为你的应用找到一个好的折衷方案非常重要。
-
为了避免通过数据库中的最佳匹配原则引入欺诈,你可以使用置信度分数来阈值化掉不够安全的匹配项
注意
如果你想进一步研究算法的细节,我建议阅读一篇更详细描述该技术的论文:
Turk, Matthew, 和 Alex P. Pentland. "使用特征脸进行人脸识别" 计算机视觉与模式识别,1991 年。CVPR'91 会议论文集,IEEE 计算机协会。1991 年。
如果你想要与基于 Eigenface 模型的内部数据进行交互,你可以使用以下代码检索有趣的信息:
// Getting the actual eigenvalues (reprojection values on the eigenvectors for each sample)
Mat eigenvalues = face_model->getEigenValues();
// Get the actual eigenvectors used for projection and dimensionality reduction
Mat eigenvectors = face_model->getEigenVectors();
// Get the mean eigenface
Mat mean = face_model->getMean();
下面这张图中展示了我们在测试中使用的样本的一些输出结果。记住,如果你想展示这些图像,你需要将它们转换到[0 255]的范围内。OpenCV 3 FaceRecognizer 指南清楚地说明了你应该如何进行这一操作。喷射色彩空间常用于可视化 Eigenfaces 数据。
注意
完整且详细的 OpenCV 3 FaceRecognizer 接口指南可以在以下网页找到,并深入讨论了这些参数的进一步使用,比本章更详细。
docs.opencv.org/master/da/d60/tutorial_face_main.html
在最常见的色彩空间中可视化前十个 Eigenfaces,包括灰度和 JET。注意背景的影响。
使用 Fisher 准则的线性判别分析
使用 Eigenface 分解的缺点是,如果你考虑的是给定数据的纯重建,那么这种变换是最佳的,然而,该技术并没有考虑到类别标签。这可能导致最大方差轴实际上是由外部来源而不是人脸本身创建的情况。为了应对这个问题,引入了基于 Fisher 准则的 LDA(线性判别分析)技术。这最小化了单个类别的方差,同时最大化类别之间的方差,从而使技术在长期内更加稳健。
执行此人脸检测的软件可以在以下位置找到:
注意
要使用 Fisher 准则构建 LDA 人脸识别器接口,你应该在 OpenCV 3 中使用以下代码片段:
// Again make sure that the data is available
vector<Mat> faces;
vector<int> labels;
Mat test_image = imread("/path/to/test/image.png");
// Now train the model, again overload functions are available
Ptr<BasicFaceRecognizer> face_model = createFisherFaceRecognizer();
face_modelàtrain(faces, labels);
// Now predict the outcome of a sample test image
int predict = face_modelàpredict(test_image);
如果你想要获取模型更具体的属性,可以通过属性特定的函数来实现,如下所示。记住,如果你想展示这些图像,你需要将它们转换到[0 255]的范围内。骨骼颜色空间常用于可视化 Fisherfaces 数据。
最常见的颜色空间中可视化的前 10 个 Fisherface 维度,包括灰度和 BONE。
注意
注意,与之前的 Eigenfaces 技术相比,这些 Fisherfaces 的背景影响最小。这是 Fisherfaces 相对于 Eigenfaces 的主要优势。
很好知道,Eigenfaces 和 Fisherfaces 都支持在映射到选定的维度时,在某个点上重建任何给定输入到特征空间或 Fisher 空间内。这是通过以下代码实现的:
// Get the eigenvectors or fishervectors and the mean face
Mat mean = face_modelàgetMean();
Mat vectors = face_modelàgetEigenValues();
// Then apply the partial reconstruction
// Do specify at which stage you want to look
int component_index = 5;
Mat current_slice = vectors.col(component_index);
// Images[0] is the first image and used for reshape properties
Mat projection = cv::LDA::subspaceProject(current_slice, mean, images[0].reshape(1,1));
Mat reconstruction = cv::LDA::subspaceReconstruct(current_slice, mean, projection);
// Then normalize and reshape the result if you want to visualize, as explained on the web page which I referred to.
注意
执行此人脸检测的软件可以在以下位置找到:
这将导致以下图所示的输出。我们在特征空间的不同阶段重新投影一个测试对象,随后向表示中添加 25 个特征向量。在这里,你可以清楚地看到我们成功地在 12 步中重建了个体。我们可以对 Fisherfaces 应用类似的程序。然而,由于 Fisherfaces 的维度较低,并且我们只寻找用于区分标签的特征,我们无法期望重建的效果像 Eigenfaces 那样纯净。
Eigenfaces 和 Fisherfaces 的重投影结果
注意
如果你想进一步研究算法的细节,我建议阅读一篇更详细描述该技术的论文:
Belhumeur Peter N.,João P. Hespanha,David J. Kriegman,Eigenfaces vs. Fisherfaces: Recognition using class specific linear projection,Pattern Analysis and Machine Intelligence,IEEE Transactions on 19.7 (1997): 711-720。
局部二值模式直方图
除了简单地降低到通用轴的维度之外,另一种方法是使用局部特征提取。通过观察局部特征而不是特征的完整全局描述,研究人员试图应对部分遮挡、光照和样本量小等问题。使用局部二值模式强度直方图是一种观察局部面部信息而不是观察单个个体的全局面部信息的技术。这种度量比之前的技术更容易受到光照条件变化的影响。
注意
执行此面部检测的软件可以在以下位置找到:
下图展示了 LBPH 特征。它们清楚地表明,与 Eigenfaces 或 Fisherfaces 相比,它们提供了更局部化的特征描述。
示例面部图像及其 ELBP 投影
注意
执行此 LBPH 面部投影的软件可以在以下位置找到:
github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_6/source_code/face/face_to_ELBP/
要使用局部二值模式直方图构建 LBP 面部识别接口,你应该在 OpenCV 3 中使用以下代码片段:
// Again make sure that the data is available
vector<Mat> faces;
vector<int> labels;
Mat test_image = imread("/path/to/test/image.png");
// Now train the model, again overload functions are available
Ptr<LBPHFaceRecognizer> face_model = createLBPHFaceRecognizer();
face_modelàtrain(faces, labels);
// Now predict the outcome of a sample test image
int predict = face_modelàpredict(test_image);
LBPH 接口还有一个重载函数,但这次与 LBPH 模式的结构有关,而不是投影轴。这可以从下面看到:
// functionality createLBPHFaceRecognizer(radius, neighbors, grid_X, grid_Y, treshold)
cv::createLBPHFaceRecognizer(1,8,8,8,123.0);
// Getting the properties can be done using the getInt function.
int radius = model->getRadius();
int neighbors = model->getNeighbors();
int grid_x = model->getGridX();
int grid_y = model->getGridY();
double threshold = model->getThreshold();
再次强调,该函数可以在预先设置阈值的情况下或不设置阈值的情况下运行。获取或设置模型参数也可以通过特定的获取器和设置器函数来完成。
注意
如果你想进一步研究算法的细节,我建议阅读一篇更详细描述该技术的论文:
Ahonen Timo, Abdenour Hadid, 和 Matti Pietikäinen, 《基于局部二值模式的面部识别》, 计算机视觉-eccv 2004, Springer Berlin Heidelberg, 2004. 469-481.
我们为上述三个接口都提供了功能,也计算了正确分类和错误分类的测试样本数量,如下所示。在 LBPH 的情况下,这意味着我们在测试样本上有 96.25%的正确分类率,这对于只有每人八个样本的非常有限的训练数据来说是非常惊人的。
每次运行后都会输出正确分类样本的数量。
当前基于 OpenCV 3 的实现中面部识别的问题
讨论的技术使我们能够识别一个面部并将其与数据集中的某个人联系起来。然而,这个系统仍然存在一些需要解决的问题:
-
当使用特征脸系统时,一个普遍的规则是,你使用的特征向量越多,系统就会变得越好,准确性也会更高。定义你需要多少维度才能得到一个不错的识别结果是令人沮丧的,因为这取决于数据是如何呈现给系统的。原始数据中的变化越多,任务就越具挑战性,因此你需要更多的维度。Philipp Wagner 的实验表明,在 AT&T 数据库中,大约 300 个特征向量应该足够了。
-
你可以将阈值法应用于特征脸和 Fisher 脸。如果你想确保分类的准确性,这是必须的。如果你不应用这个方法,那么系统基本上会返回最佳匹配。如果给定的人不是数据集的一部分,那么你想要避免这种情况,可以通过调用带有阈值值的接口来实现!
-
请记住,对于所有面部识别系统,如果你用一种设置中的数据训练它们,然后用包含完全不同情况和设置的数据测试它们,那么准确性的下降将会很大。
-
如果你基于 2D 图像信息构建一个识别系统,那么欺诈者只需简单地打印出该人的 2D 图像并将其展示给系统,就能破解它。为了避免这种情况,要么包含 3D 知识,要么添加额外的生物识别信息。
注意
更多关于添加 3D 信息以避免欺诈尝试的信息可以在以下出版物中找到:
Akarun Lale, B. Gokberk, 和 Albert Ali Salah,3D 面部识别在生物识别应用中的研究,信号处理会议,2005 年第 13 届欧洲 IEEE,2005 年。
Abate Andrea F. 等,2D 和 3D 面部识别:综述,模式识别快报 28.14(2007):1885-1906。
然而,这个主题过于具体和复杂,超出了本章的范围,因此将不再进一步讨论。
指纹识别,它是如何进行的?
在上一节中,我们讨论了使用第一个生物识别技术,即尝试登录系统的个人的面部。然而,由于我们提到使用单一生物识别技术是危险的,因此最好向系统中添加二级生物识别检查,比如指纹。市面上有几种相当便宜的现成指纹扫描仪,它们可以返回扫描图像。然而,你仍然需要为这些扫描仪编写自己的注册软件,这可以使用 OpenCV 完成。以下是一些指纹图像的示例:
不同扫描仪的单个指纹示例
此数据集可以从博洛尼亚大学发布的 FVC2002 竞赛网站下载。网站(bias.csr.unibo.it/fvc2002/databases.asp
)包含四个指纹数据库,可供公众以下格式下载:
-
四个指纹捕获设备,DB1 - DB4
-
对于每个设备,有 10 个人的指纹可用
-
对于每个人,记录了八个不同的指纹位置
我们将使用这个公开可用的数据集来构建我们的系统。我们将专注于第一个捕获设备,使用每个个体的最多四个指纹来训练系统并制作指纹的平均描述符。然后,我们将使用其他四个指纹来评估我们的系统,并确保人员仍然可以通过我们的系统被识别。
如果您想研究捕获二值图像的系统与捕获灰度图像的系统之间的差异,可以将相同的方法应用于从其他设备获取的数据。然而,我们将提供进行二值化的技术。
在 OpenCV 3 中实现该方法
注意
处理从指纹扫描仪获取的指纹的完整指纹软件可以在以下位置找到:
在本小节中,我们将描述如何在 OpenCV 界面中实现这种方法。我们首先从指纹系统获取图像并应用二值化。这使我们能够从图像中去除任何噪声,同时帮助我们更好地区分皮肤和手指皱褶表面的对比度:
// Start by reading in an image
Mat input = imread("/data/fingerprints/image1.png", IMREAD_GRAYSCALE);
// Binarize the image, through local thresholding
Mat input_binary;
threshold(input, input_binary, 0, 255, THRESH_BINARY | THRESH_OTSU);
Otsu 阈值化将自动选择最佳通用阈值,以在前景和背景信息之间获得良好的对比度。这是因为图像包含双峰分布(这意味着我们有一个具有两个峰值直方图的图像)的像素值。对于该图像,我们可以取那些峰值中间的近似值作为阈值值(对于非双峰分布的图像,二值化将不准确)。Otsu 允许我们避免使用固定的阈值值,使系统更兼容捕获设备。然而,我们也承认,如果您只有一个捕获设备,那么尝试使用固定的阈值值可能对特定设置产生更好的图像。阈值化的结果如下所示。
为了使从下一个骨骼化步骤的细化尽可能有效,我们需要反转二值图像。
灰度化和二值化指纹图像的比较
一旦我们有一个二值图像,我们就准备好计算我们的特征点和特征点描述符。然而,为了进一步提高过程,最好是进行图像骨架化。这将创建更多独特且更强的兴趣点。以下代码片段可以在二值图像上应用骨架化。骨架化基于 Zhang-Suen 线细化方法。
注意
特别感谢 OpenCV Q&A 论坛的@bsdNoobz
,他提供了这种迭代方法。
#include <opencv2/imgproc.hpp>
#include <opencv2/highgui.hpp>
using namespace std;
using namespace cv;
// Perform a single thinning iteration, which is repeated until the skeletization is finalized
void thinningIteration(Mat& im, int iter)
{
Mat marker = Mat::zeros(im.size(), CV_8UC1);
for (int i = 1; i < im.rows-1; i++)
{
for (int j = 1; j < im.cols-1; j++)
{
uchar p2 = im.at<uchar>(i-1, j);
uchar p3 = im.at<uchar>(i-1, j+1);
uchar p4 = im.at<uchar>(i, j+1);
uchar p5 = im.at<uchar>(i+1, j+1);
uchar p6 = im.at<uchar>(i+1, j);
uchar p7 = im.at<uchar>(i+1, j-1);
uchar p8 = im.at<uchar>(i, j-1);
uchar p9 = im.at<uchar>(i-1, j-1);
int A = (p2 == 0 && p3 == 1) + (p3 == 0 && p4 == 1) +
(p4 == 0 && p5 == 1) + (p5 == 0 && p6 == 1) +
(p6 == 0 && p7 == 1) + (p7 == 0 && p8 == 1) +
(p8 == 0 && p9 == 1) + (p9 == 0 && p2 == 1);
int B = p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9;
int m1 = iter == 0 ? (p2 * p4 * p6) : (p2 * p4 * p8);
int m2 = iter == 0 ? (p4 * p6 * p8) : (p2 * p6 * p8);
if (A == 1 && (B >= 2 && B <= 6) && m1 == 0 && m2 == 0)
marker.at<uchar>(i,j) = 1;
}
}
im &= ~marker;
}
// Function for thinning any given binary image within the range of 0-255\. If not you should first make sure that your image has this range preset and configured!
void thinning(Mat& im)
{
// Enforce the range to be in between 0 - 255
im /= 255;
Mat prev = Mat::zeros(im.size(), CV_8UC1);
Mat diff;
do {
thinningIteration(im, 0);
thinningIteration(im, 1);
absdiff(im, prev, diff);
im.copyTo(prev);
}
while (countNonZero(diff) > 0);
im *= 255;
}
上述代码可以简单地应用于我们之前的步骤,通过在之前生成的二值图像上调用细化函数。此代码如下:
// Apply thinning algorithm
Mat input_thinned = input_binary.clone();
thinning(input_thinned);
这将导致以下输出:
使用骨架化技术比较二值化和细化指纹图像
当我们得到这个骨架图像时,下一步是寻找指纹脊上的交叉点,称为特征点。我们可以使用一个寻找局部对比度大变化的特征点检测器来完成这项工作,就像 Harris 角检测器。由于 Harris 角检测器能够检测到强角和边缘,它非常适合指纹问题,其中最重要的特征点是短边缘和分叉——边缘汇合的位置。
注意
关于特征点和 Harris 角检测的更多信息可以在以下出版物中找到:
Ross Arun A.,Jidnya Shah,和 Anil K. Jain,从特征点重建指纹,国防和安全。国际光学和光子学学会,2005。
Harris Chris 和 Mike Stephens,一种结合角和边缘检测器,Alvey 视觉会议,第 15 卷,1988。
在 OpenCV 中对骨架化和二值化图像调用 Harris 角操作相当直接。Harris 角以与图像中其角响应值相对应的位置存储。如果我们想检测具有特定角响应的点,那么我们只需简单地阈值化图像。
Mat harris_corners, harris_normalised;
harris_corners = Mat::zeros(input_thinned.size(), CV_32FC1);
cornerHarris(input_thinned, harris_corners, 2, 3, 0.04, BORDER_DEFAULT);
normalize(harris_corners, harris_normalised, 0, 255, NORM_MINMAX, CV_32FC1, Mat());
我们现在有一个地图,其中包含所有可用的角响应,已重新缩放到[0 255]的范围,并以浮点值存储。我们现在可以手动定义一个阈值,这将为我们应用程序生成大量关键点。调整此参数可能会在其他情况下提高性能。这可以通过以下代码片段来完成:
float threshold = 125.0;
vector<KeyPoint> keypoints;
Mat rescaled;
convertScaleAbs(harris_normalised, rescaled);
Mat harris_c(rescaled.rows, rescaled.cols, CV_8UC3);
Mat in[] = { rescaled, rescaled, rescaled };
int from_to[] = { 0,0, 1,1, 2,2 };
mixChannels( in, 3, &harris_c, 1, from_to, 3 );
for(int x=0; x<harris_normalised.cols; x++){
for(int y=0; y<harris_normalised.rows; y++){
if ( (int)harris_normalised.at<float>(y, x) > threshold ){
// Draw or store the keypoint location here, just like
//you decide. In our case we will store the location of
// the keypoint
circle(harris_c, Point(x, y), 5, Scalar(0,255,0), 1);
circle(harris_c, Point(x, y), 1, Scalar(0,0,255), 1);
keypoints.push_back( KeyPoint (x, y, 1) );
}
}
}
细化指纹与 Harris 角响应以及所选 Harris 角的比较
现在我们已经有一个关键点列表,我们需要为每个关键点周围的区域创建某种形式的正式描述符,以便能够从其他关键点中唯一地识别它。
注意
第三章,使用机器学习识别面部表情,更详细地讨论了现有的广泛关键点。在本章中,我们将主要关注过程。请随意调整界面以适应其他关键点检测器和描述符,无论结果好坏。
由于我们的应用中拇指的方向可能不同(因为它不在固定位置),我们希望有一个关键点描述符能够很好地处理这些细微差异。这种描述符中最常见的一个是 SIFT 描述符,代表尺度不变特征变换。然而,SIFT 不在 BSD 许可之下,这在使用商业软件时可能会引起问题。在 OpenCV 中,一个好的替代品是 ORB 描述符。你可以按照以下方式实现它:
Ptr<Feature2D> orb_descriptor = ORB::create();
Mat descriptors;
orb_descriptor->compute(input_thinned, keypoints, descriptors);
这使我们能够仅使用 ORB 方法计算描述符,因为我们已经使用 Harris 角方法检索了关键点的位置。
到目前为止,我们可以为任何给定指纹的每个检测到的关键点检索一个描述符。描述符矩阵包含每个关键点的一行,包含其表示。
让我们从只有一个参考图像的指纹的例子开始。然后我们有一个包含数据库中训练人员的特征描述符的数据库。我们有一个单一的新条目,由在注册时找到的关键点的多个描述符组成。现在我们必须将这些描述符与数据库中存储的描述符进行匹配,以查看哪一个匹配得最好。
实现这一点的最简单方法是通过使用不同关键点描述符之间的汉明距离标准进行暴力匹配。
// Imagine we have a vector of single entry descriptors as a database
// We will still need to fill those once we compare everything, by using the code snippets above
vector<Mat> database_descriptors;
Mat current_descriptors;
// Create the matcher interface
Ptr<DescriptorMatcher> matcher = DescriptorMatcher::create("BruteForce-Hamming");
// Now loop over the database and start the matching
vector< vector< DMatch > > all_matches;
for(int entry=0; i<database_descriptors.size();entry++){
vector< DMatch > matches;
matcheràmatch(database_descriptors[entry], current_descriptors, matches);
all_matches.push_back(matches);
}
现在我们已经将所有匹配存储为 DMatch 对象。这意味着,对于每一对匹配,我们将有原始关键点、匹配关键点和两个匹配之间的浮点分数,表示匹配点之间的距离。
这看起来相当直接。我们查看匹配过程返回的匹配数量,并按它们的欧几里得距离进行加权,以增加一些确定性。然后我们寻找产生最大分数的匹配过程。这将是我们最佳匹配,也是我们想要从数据库中返回的所选匹配。
如果你想避免冒名顶替者被分配到最佳匹配分数,你可以在评分之上添加一个手动阈值,以避免匹配并忽略那些不够好的匹配。然而,如果你将分数提高太多,那些变化很小的人可能会被系统拒绝,例如,如果有人割伤了手指,从而剧烈改变了他们的模式。
指纹匹配过程的可视化
虹膜识别,是如何进行的?
我们将要使用的最后一种生物识别技术是虹膜扫描的输出。考虑到我们的设置,可能有几种方法来抓取虹膜数据:
-
我们可以使用面部检测来分离面部,并使用高分辨率摄像头应用眼部检测器。我们可以使用得到的区域来进行虹膜分割和分类。
-
我们可以使用特定的眼部摄像头,它抓取用于分类的眼部图像。这可以通过 RGB 或近红外(NIR)来实现。
由于第一种方法容易遇到很多问题,如生成的眼部图像分辨率低,更常见的方法是使用单独的眼部摄像头来抓取眼部图像。这是我们本章将使用的方法。以下是 RGB(可见颜色)和 NIR(近红外)光谱中捕获的眼部图像的示例:
一个基于 RGB 和 NIR 的虹膜图像示例
使用近红外图像可以帮助我们以多种方式:
-
首先,由于在抓取虹膜图像时,许多条件如外部光源等都会影响颜色信息,因此我们省略了颜色信息。
-
其次,瞳孔中心变得更加清晰且完全为黑色,这使得我们可以使用依赖于这一点的技术来分割瞳孔中心。
-
第三,由于近红外光谱,即使在不同的光照条件下,可用的结构也能得到保持。
-
最后,虹膜区域的边缘更加清晰,因此更容易分离。
我们将使用 CASIA 眼部数据集的数据进行虹膜识别,该数据集可在biometrics.idealtest.org/
找到。该数据集可供研究和非商业用途免费使用,并且可以通过网站申请访问。我们软件仓库中有一小部分数据,其中包含一个人的左右眼,由于没有两个虹膜是相同的,我们可以将它们视为两个人。每个眼睛有 10 个样本,我们将使用其中的 8 个进行训练,2 个进行测试。
我们将要实现的虹膜识别方法基于约翰·道格曼(John Daugman)提出的技术。这项技术被广泛接受并在商业系统中使用,从而证明了其质量。
注意
约翰·道格曼(John Daugman)撰写的原始论文可以在以下网址找到:www.cl.cam.ac.uk/~jgd1000/irisrecog.pdf
在 OpenCV 3 中实现此方法
获取虹膜信息的第一步是分割出包含虹膜和瞳孔的实际眼部区域。我们在数据上应用一系列操作以实现所需的结果。这个过程是必要的,以保留所需数据并移除所有多余的眼部数据。
我们首先尝试获取瞳孔。瞳孔是近红外图像中最暗的区域,这些信息可以为我们带来优势。以下步骤将引导我们找到眼图中的瞳孔区域:
-
首先,我们需要对最暗的区域进行分割。我们可以使用
inRange()
图像,因为瞳孔所在的位置值对于捕获系统是特定的。然而,由于它们都使用近红外(NIR),最终结果对于每个独立的系统将是相同的。 -
然后,我们应用轮廓检测以获取瞳孔的外边缘。我们确保只从外部轮廓中获取最大的轮廓,这样我们只保留一个区域。
注意
如果你想提高性能,你也可以先寻找红外 LED 的亮点,将它们从区域中移除,然后再运行轮廓检测。当红外 LED 亮点靠近瞳孔边缘时,这将提高鲁棒性。
单个虹膜的完整过程代码可以在以下位置找到:github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_6/source_code/iris/iris_processing/
通过以下代码片段可以实现这种行为:
// Read in image and perform contour detection
Mat original = imread("path/to/eye/image.png", IMREAD_GRAYSCALE);
Mat mask_pupil;
inRange(original, Scalar(30,30,30), Scalar(80,80,80), mask_pupil);
vector< vector<Point> > contours;
findContours(mask_pupil.clone(), contours, RETR_EXTERNAL, CHAIN_APPROX_NONE);
// Calculate all the corresponding areas which are larger than
vector< vector<Point> > filtered;
for(int i=0; i<contours.size(); i++){
double area = contourArea(contours[i]);
// Remove noisy regions
if(area > 50.0){
filtered.push_back(contours[i]);
}
}
// Now make a last check, if there are still multiple contours left, take the one that has a center closest to the image center
vector<Point> final_contour=filtered[0];
if(filtered.size() > 1){
double distance = 5000;
int index = -1;
Point2f orig_center(original.cols/2, original.rows/2);
for(int i=0; i<filtered.size(); i++){
Moments temp = moments(filtered[i]);
Point2f current_center((temp.m10/temp.m00), (temp.m01/temp.m00));
// Find the Euclidean distance between both positions
double dist = norm(Mat(orig_center), Mat(current_center));
if(dist < distance){
distance = dist;
index = i;
}
}
final_contour = filtered[index];
}
// Now finally make the black contoured image;
vector< vector<Point> > draw;
draw.push_back(final_contour);
Mat blacked_pupil = original.clone();
drawContours(blacked_pupil, draw, -1, Scalar(0,0,0), FILLED);
NoteSometimes, the Hough circle detection will not yield a single circle. This is not the case with the proposed database, but if you encounter this, then looking at other techniques like the Laplacian of Gaussians should help you to find and reconstruct the iris region.
// Make sure that the input image is gray, we took care of that while reading in the original image and making sure that the blacked pupil image is a clone of that.
// Apply a canny edge filter to look for borders
// Then clean it a bit by adding a smoothing filter, reducing noise
Mat preprocessed;
Canny(blacked_pupil, blacked_pupil, 5, 70);
GaussianBlur(blacked_pupil, preprocessed, Size(7,7));
// Now run a set of HoughCircle detections with different parameters
// We increase the second accumulator value until a single circle is left and take that one for granted
int i = 80;
vector<Point3f> found_circle;
while (i < 151){
vector< vector<Point3f> > storage;
// If you use other data than the database provided, tweaking of these parameters will be necessary
HoughCircles(preprocessed, storage, HOUGH_GRADIENT, 2, 100.0, 30, i, 100, 140);
if(storage.size() == 1){
found_circle = storage[0];
break;
}
i++;
}
// Now draw the outer circle of the iris
int radius = found_circle[2];
Mat mask = Mat::zeros(blacked_pupil.rows, blacked_pupil.cols, CV_8UC1);
// The centroid value here must be the same as the one of the inner pupil so we reuse it back here
Moments temp = Moments(final_contour);
Point2f centroid((temp.m10/temp.m00), (temp.m01/temp.m00));
Circle(mask, centroid, radius, Scalar(255,255,255), FILLED);
bitwise_not(mask, mask);
Mat final_result;
subtract(blacked_pupil, blacked_pupil.clone(), final_result, mask);
// Visualize the final result
imshow("final blacked iris region", final_result);
左眼和右眼的 Hough 圆检测结果的示例,它给出了虹膜区域的外边缘
一旦我们成功找到外轮廓,从原始输入中掩码虹膜区域就非常直接了,如图所示:
下面是一个掩码虹膜图像的示例
现在我们有了感兴趣的区域,即只有虹膜区域,如图所示。我们承认该区域内可能仍有一些部分胡须,但现阶段我们将简单地忽略它们。现在,我们希望将这个虹膜图像编码成一个特征向量以进行比较。为了达到这个水平,我们还需要进行两个步骤:
-
将虹膜图案从极坐标系展开到笛卡尔坐标系以进行进一步处理
-
将虹膜图像进行编码并与已知表示的数据库进行匹配
我们首先提供一个代码片段,该代码片段将展开从检索到的最终结果中所需的虹膜区域:
// Lets first crop the final iris region from the image
int x = int(centroid[0] - radius);
int y = int(centroid[1] - radius);
int w = int(radius * 2);
int h = w;
Mat cropped_region = final_result( Rect(x,y,w,h) ).clone();
// Now perform the unwrapping
// This is done by the logpolar function who does Logpolar to Cartesian coordinates, so that it can get unwrapped properly
Mat unwrapped;
int center = (float(cropped_region.cols/2.0), float(cropped_region.cols /2.0));
LogPolar(image, unwrapped, c, 60.0, INTER_LINEAR + WARP_FILL_OUTLIERS);
imshow("unwrapped image", unwrapped); waitKey(0);
这将导致以下转换,它给出了如图所示的虹膜区域的径向展开:
左眼和右眼的径向展开虹膜图像示例
这种径向展开是对每个眼睛的八个训练图像以及我们也有每个眼睛的两个测试图像进行的。Daugman 方法通过相位象限调制来编码虹膜模式。然而,这尚未在 OpenCV 中实现,并且对于本章来说过于复杂。因此,我们决定寻找一个可用的 OpenCV 实现,可以用来匹配虹膜。一个好的方法是用局部二值模式直方图比较,因为我们正在寻找能够识别局部纹理的东西,这也在人脸识别中得到了应用。
注意
可以在以下位置找到用于展开完整套虹膜图像的软件:
创建匹配界面的软件可以在以下位置找到:
最后,在 OpenCV 3 中编码的工作方式如下:
// Try using the facerecognizer interface for these irises
// Choice of using LBPH --> local and good for finding texture
Ptr<LBPHFaceRecognizer> iris_model = createLBPHFaceRecognizer();
// Train the facerecognizer
iris_model->train(train_iris, train_labels);
// Loop over test images and match their labels
int total_correct = 0, total_wrong = 0;
for(int i=0; i<test_iris.size(); i ++){
int predict = iris_model->predict(test_iris[i]);
if(predict == test_labels[i]){
total_correct++;
}else{
total_wrong++;
}
}
我们再次计算测试结果,得到下图中所示的结果:
编码的虹膜图像及其相应的虹膜代码可视化。
结合技术创建高效的人员注册系统
前几节分别讨论了特定的生物识别属性。现在,让我们结合所有这些信息来创建一个高效的识别系统。我们将实现的方法遵循下图中描述的结构:
人员认证流程
如上图所示,第一步是使用相机界面检查相机前是否真的有一个人。这是通过对输入图像执行人脸检测来完成的。我们还测试了其他生物识别系统是否处于活动状态。这留下了两个需要执行的检查:
-
检查虹膜扫描仪是否在使用中。这当然取决于所使用的系统。如果它依赖于从人脸检测中检索到的眼睛,则此检查应被忽略。如果使用实际的眼球扫描仪检索眼睛,那么至少应该检测到一个眼睛以给出一个积极的信号。
-
检查指纹扫描仪是否处于活动状态。我们是否真的有可用于获取指纹图像的手指?这是通过对空场景应用背景减法来检查的。如果手指到位,那么应该对背景前景减法有响应。
当然,我们知道其中一些系统使用基于压力的检测来找到手或手指。在这种情况下,你不必自己执行此检查,而是让系统决定是否继续。
一旦我们有了所有系统,我们就可以开始之前章节中描述的个别识别系统。它们将输出已知人员的身份,这些人员是从为这个目的构建的公共数据库中输出的。然后,将这些结果提交给智能多数投票。这个系统会检查几个方面:
-
它通过从数据库返回匹配结果来检查生物识别系统是否实际成功。如果没有,则不允许人员进入,系统会要求重新确认失败的生物识别信息。
-
如果系统需要连续三次以上测量生物识别信息,系统会卡住,直到系统所有者解锁它才会工作。这是为了避免当前界面中利用系统并试图进入的漏洞。
-
如果生物识别检查成功,则对结果应用智能多数投票。这意味着如果两个生物识别识别到 A 个人,但一个生物识别识别到 B 个人,则输出结果仍然是 A 个人。如果那个人被标记为所有者,则系统将允许访问。
-
根据提供的各个子主题的独立软件,将它们组合成一个单一界面应该相当直接。
-
如果系统仍然失败(这是一个案例研究,不是一个 100%万无一失的系统),可以采取一些措施来实现预期结果。
-
你应该尝试提高每个单独生物识别的检测和匹配质量。这可以通过提供更好的训练数据、尝试不同的特征提取方法或不同的特征比较方法来实现,如本章引言中所述。组合的种类无穷无尽,所以大胆尝试吧。
-
你应该尝试为每个生物识别输出分配一个置信度分数。由于我们有多个系统投票确认一个人的身份,我们可以考虑它们在单个分类上的置信度。例如,当运行数据库时,匹配到最佳匹配的距离可以转换为[0 100]的刻度范围,以给出置信度百分比。然后我们可以将每个生物识别的投票乘以其权重,并进行智能加权多数投票。
-
摘要
在本章中,你了解到,通过使用尝试认证的人的多个生物识别属性,认证系统可以不仅仅是简单的面部识别界面。我们展示了如何使用 OpenCV 库执行虹膜和指纹识别,以创建一个多生物识别认证系统。可以添加更多的生物识别到系统中,因为可能性是无限的。
本章的重点是激发人们对生物识别技术力量和 OpenCV 库无限可能性的兴趣。如果你因此受到启发,不妨进一步实验,并与社区分享你的想法。
注意
我要感谢 OpenCV 问答讨论论坛的用户们,在我遇到难题时,他们帮助我突破了限制。我特别想感谢以下用户提供的指导:Berak、Guanta、Theodore 和 GilLevi。
第七章. 陀螺仪视频稳定化
视频稳定化是计算机视觉中的一个经典问题。其理念很简单——你有一个摇曳的视频流,你试图找到最佳方法来消除相机的运动,以在图像之间产生平滑的运动。结果视频更容易观看,并且具有电影般的视觉效果。
在过去的几年里,已经尝试了多种方法来解决这一问题。传统上,视频是通过使用仅从图像中可用的数据或使用专用硬件来消除相机中的物理运动来进行稳定的。移动设备中的陀螺仪介于这两种方法之间。
在本章中,我们将涵盖以下内容:
-
一个 Android 相机应用程序来记录媒体和陀螺仪轨迹
-
使用视频和陀螺仪轨迹来寻找数学未知数
-
使用物理相机未知数来补偿相机运动
-
识别相机中的滚动快门
注意
相机传感器上的滚动快门会产生不希望的效果。我们将在后面的部分详细讨论这个问题。同时,请参阅第一章,最大限度地发挥您的相机系统的作用,以获取详细讨论。
在我们开始之前,让我们看看过去用来解决这个问题的一些技术。
使用图像进行稳定化
仅使用图像进行视频稳定化似乎是第一个合乎逻辑的步骤。确实,最初关于稳定由相机捕获的视频的研究是基于使用来自相机传感器的可用信息来理解它们是如何逐帧移动的。
理念是在图像序列中找到关键点,以了解它们是如何逐帧移动的。关键点是图像上满足以下标准的像素位置:
-
关键点应该容易识别,并且彼此之间可以区分开来。物体的角点是好的关键点,而空白墙上的一个点则不是。
-
应该能够跟踪多个图像中的关键点以计算运动。你应该能够确切地知道关键点从一个帧移动到另一个帧的确切位置。
-
为了性能,识别这些关键点应该是快速且内存高效的。这通常是低内存和低功耗设备上的瓶颈。
以这些标准进行的研究导致了几个独特的方法,包括一些著名的算法,如 SIFT、SURF、ORB、FREAK 等。这些技术通常效果很好。
OpenCV 附带了一些常见的特征点检测器。这些包括 ORB、FAST、BRISK、SURF 等。有关如何使用这些检测器的更多信息,请参阅 2D 特征框架文档页面:docs.opencv.org/3.0-beta/modules/features2d/doc/feature_detection_and_description.html
。
然而,关键点检测器也有其自身的缺点。首先,稳定化的结果高度依赖于图像的质量。例如,低分辨率图像可能不会产生最佳的特征集。对焦不清晰和模糊的图像也是另一个问题。这限制了可以稳定化序列的类型。一个清晰的蓝天和黄色沙子的场景可能不包含足够多的特征。
重复图案的表面会混淆算法,因为相同的特征在不同的位置反复出现。
注意
其次,如果图像中有大量的运动(如背景中行走的人群或路上行驶的卡车),由于这种运动,稳定化将会偏斜。关键点是跟踪移动的物体,而不是相机的运动本身。这限制了可以使用这种方法成功稳定化的视频类型。有绕过这种限制的方法——然而,这使算法变得更加复杂。
使用硬件进行稳定化
某些行业,如电影行业和军事行业,期望高质量的图像稳定。在这些复杂的环境中仅使用图像是无法工作的。这导致行业创建了基于硬件的图像稳定装置。例如,带有相机的四旋翼无人机即使在(可能)恶劣的照明条件下、风中等情况下,也需要有高质量的视频输出。
注意
这些设备使用陀螺仪来物理移动和旋转相机,从而使图像序列保持稳定。由于实际上是在补偿相机的运动,所以结果看起来非常出色。
这些设备往往价格较高,因此对于普通消费者来说可能负担不起。它们也往往相当庞大。普通人不会想在度假时携带两公斤重的装置。
硬件与软件的混合体
本章介绍了原始纯软件方法和硬件设备之间的混合解决方案。这种解决方案只有在智能手机出现之后才成为可能。人们现在可以在一个小型设备上获得高质量的相机和陀螺仪。
这种方法背后的思想是使用陀螺仪来捕捉运动,使用相机传感器来捕捉光线。然后,将这两个流融合在一起,以便图像始终稳定。
随着传感器密度的增加,我们朝着 4K 相机发展,选择图像的(稳定)子区域变得越来越可行,因为我们可以在不牺牲质量的情况下丢弃更多的图像。
数学
在我们深入代码之前,让我们概述一下算法。有四个关键组件。
-
第一是针孔相机模型。我们试图使用这个矩阵将现实世界的位置近似到像素。
-
第二是相机运动估计。我们需要使用陀螺仪的数据来确定手机在任何给定时刻的朝向。
-
第三是滚动快门计算。我们需要指定滚动快门的方向并估计滚动快门的持续时间。
-
第四是图像扭曲表达式。使用之前计算的所有信息,我们需要生成一个新的图像,使其变得稳定。
相机模型
我们使用标准的针孔相机模型。这个模型在几个算法中使用,并且是实际相机的良好近似。
有三个未知数。o 变量表示图像平面上相机轴的原点(这些可以假设为 0)。矩阵中的两个 1 表示像素的宽高比(我们假设像素是正方形的)。f 表示镜头的焦距。我们假设水平和垂直方向上的焦距相同。
使用这个模型,我们可以看到:
这里,X 是现实世界中的点。还有一个未知的比例因子,q 存在。
注意
如果不知道物体的物理尺寸,对于单目视觉来说,估计这个未知数是不可能的。
K 是内在矩阵,x 是图像上的点。
相机运动
我们可以假设世界原点与相机原点相同。然后,相机的运动可以用相机的朝向来描述。因此,在任意给定的时间 t:
可以通过积分相机的角速度(从陀螺仪获得)来计算旋转矩阵 R。
这里,ω[d] 是陀螺仪漂移,t[d] 是陀螺仪和帧时间戳之间的延迟。这些也是未知数;我们需要一种机制来计算它们。
滚动快门补偿
当你点击一张照片时,常见的假设是整个图像一次性被捕获。对于使用 CCD 传感器捕获的图像来说,这确实是正确的(这些传感器曾经很普遍)。随着 CMOS 图像传感器的商业化,这种情况已经不再适用。一些 CMOS 传感器也支持全局快门,但在这个章节中,我们将假设传感器具有滚动快门。
图像是一行一行地捕获的——通常首先捕获第一行,然后是第二行,依此类推。图像的连续行之间存在非常小的延迟。
这会导致奇怪的效果。当我们纠正相机抖动时(例如,如果相机有大量运动),这种现象非常明显。
扇叶大小相同;然而,由于快速运动,滚动快门导致传感器记录的图像中出现了伪影。
要模拟滚动快门,我们需要确定特定行是在何时被捕获的。这可以通过以下方式完成:
在这里,t[i] 是第 i 个帧被捕获的时间,h 是图像帧的高度,而 t[s] 是滚动快门的持续时间,即从顶部到底部扫描所需的时间。假设每一行花费相同的时间,第 y 行需要额外的 t[s] * y / h 时间来扫描。
注意
这假设滚动快门是从顶部到底部发生的。从底部到顶部的滚动快门可以通过将 t[s] 的值设置为负数来建模。同样,从左到右的滚动快门可以通过将 y / h 替换为 x / w 来建模,其中 w 是帧的宽度。
图像扭曲
到目前为止,我们已经有了估计的相机运动和纠正滚动快门的模型。我们将两者结合起来,并在多个帧之间识别关系:
-
(对于旋转配置为 1 的帧 i)
-
(对于旋转配置为 2 的帧 j)
我们可以将这两个方程组合起来:
从这里,我们可以计算一个扭曲矩阵:
现在,点 x[i] 和 x[j] 之间的关系可以更简洁地描述为:
这个扭曲矩阵同时纠正了视频抖动和滚动快门。
现在,我们可以将原始视频映射到一个具有平滑运动和全局快门(无滚动快门伪影)的人工相机。
这个人工相机可以通过对输入相机的运动进行低通滤波并将滚动快门持续时间设置为零来模拟。低通滤波器从相机方向中移除高频噪声。因此,人工相机的运动将看起来更加平滑。
理想情况下,这个矩阵可以针对图像中的每一行进行计算。然而,在实践中,将图像分成五个子部分也能产生良好的结果(性能更佳)。
项目概述
让我们花点时间来理解本章中的代码是如何组织的。我们有两个移动部分。一个是移动应用程序,另一个是视频稳定器。
移动应用程序只记录视频并在视频期间存储陀螺仪信号。它将这些数据放入两个文件中:一个.mp4
文件和一个.csv
文件。这两个文件是下一步的输入。在移动设备上没有进行计算。在本章中,我们将使用 Android 作为我们的平台。迁移到任何其他平台应该相对容易——我们只进行任何平台都应该支持的基本任务。
视频稳定器在桌面运行。这是为了帮助你更容易地了解稳定化算法中发生的情况。在移动设备上调试、逐步执行代码和查看图像相对较慢,比在桌面迭代要慢。我们有一些非常好的免费科学模块可用(来自 Python 社区。在这个项目中,我们将使用 Scipy、Numpy 和 Matplotlib)。
捕获数据
首先,我们需要创建一个移动设备应用程序,它可以同时捕获图像和陀螺仪信号。有趣的是,这些并不容易获得(至少在 Android 上)。
一旦我们有了视频和陀螺仪流,我们将探讨如何使用这些数据。
创建一个标准的 Android 应用程序(我使用 Android Studio)。我们首先创建一个空白应用程序。目标是创建一个简单的应用程序,在触摸屏幕时开始录制视频和陀螺仪信号。再次触摸时,录制停止,并在手机上保存一个视频文件和一个文本文件。这两个文件然后可以被 OpenCV 用来计算最佳稳定化。
注意
本节中的代码可在本书的 GitHub 仓库中找到:github.com/OpenCVBlueprints/OpenCVBlueprints/tree/master/chapter_7
录制视频
我们将首先实现一个简单的视频录制工具。在 Android Studio 中创建一个新的空白项目(我命名为 GyroRecorder,并将活动命名为 Recorder)。首先,我们开始添加权限到我们的应用程序。在你的项目中打开AndroidManifest.xml
并添加以下权限:
<manifest ...>
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application ...>
这只是让我们的应用程序访问相机并写入存储(陀螺仪文件和视频)。接下来,打开主活动视觉编辑器,在垂直 LinearLayout 中添加一个 TextureView 和一个 Button 元素。
将这些元素的名称分别更改为texturePreview
和btnRecord
。
现在我们开始编写一些代码。在主活动类中,添加以下这些行:
public class Recorder extends AppCompatActivity {
private TextureView mPreview; // For displaying the live camera preview
private Camera mCamera; // Object to contact the camera hardware
private MediaRecorder mMediaRecorder; // Store the camera's image stream as a video
private boolean isRecording = false; // Is video being recoded?
private Button btnRecord; // Button that triggers recording
这些对象将被用来与 Android 通信,指示何时开始录制。
注意
Android Studio 在你键入时自动将导入添加到你的代码中。例如,上面的代码片段会导致以下导入的添加:
import android.hardware.Camera;
import android.media.MediaRecorder;
import android.view.TextureView;
接下来,我们需要初始化这些对象。我们在onCreate
事件中这样做。
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_recorder);
mPreview = (TextureView)findViewById(R.id.texturePreview);
btnRecord = (Button)findViewById(R.id.btnRecord);
btnRecord.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
onCaptureClick(view);
}
});
onCreate
方法已经包含了一些方法(如super.onCreate
、setContentView
等;我们将在之后添加几行)。现在,我们需要定义onCaptureClick
的作用。
public void onCaptureClick(View view) {
if (isRecording) {
// Already recording? Release camera lock for others
mMediaRecorder.stop();
releaseMediaRecorder();
mCamera.lock();
isRecording = false;
releaseCamera();
mGyroFile.close();
mGyroFile = null;
btnRecord.setText("Start");
mStartTime = -1;
} else {
// Not recording – launch new "thread" to initiate!
new MediaPrepareTask().execute(null, null, null);
}
}
注意
如果你想探索strings.xml
,请参考 PacktPub 书籍《使用 OpenCV 的 Android 应用程序编程》的第二章,与相机帧一起工作,部分 0023_split_000.html#LTSU2-940925703e144daa867f510896bffb69。
在这里,我们使用内部的isRecording
变量来通知媒体录制器和相机开始保存流。我们需要创建一个新的线程,因为初始化媒体录制器和相机通常需要几毫秒。如果在主线程中这样做,这种延迟会在 UI 上变得明显。
一旦我们完成录制(用户点击停止按钮,我们需要释放媒体录制器。这发生在releaseMediaRecorder
方法中):
private void releaseMediaRecorder() {
if(mMediaRecorder != null) {
mMediaRecorder.reset();
mMediaRecorder.release();
mMediaRecorder = null;
mCamera.lock();
}
}
现在,我们来看创建一个新的线程。在你的主活动类中创建这个类。
class MediaPrepareTask extends AsyncTask<Void, Void, Boolean>{
@Override
protected Boolean doInBackground(Void... voids) {
if(prepareVideoRecorder()) {
mMediaRecorder.start();
isRecording = true;
} else {
releaseMediaRecorder();
return false;
}
return true;
}
@Override
protected void onPostExecute(Boolean result) {
if(!result) {
Recorder.this.finish();
}
btnRecord.setText("Stop");
}
}
这创建了一个AsyncTask
类型的对象。创建这个类的新对象会自动创建一个新的线程,并在该线程中运行doInBackground
。我们想在同一个线程中准备媒体录制器。准备媒体录制器涉及到从相机识别支持的视频尺寸,找到合适的高度,设置视频的比特率以及指定目标视频文件。
在你的主活动类中,创建一个名为prepareVideoRecorder
的新方法:
@TargetApi(Build.VERSION_CODES.HONEYCOMB)
private boolean prepareVideoRecorder() {
mCamera = Camera.open();
Camera.Parameters parameters = mCamera.getParameters();
List<Camera.Size> mSupportedPreviewSizes = parameters.getSupportedPreviewSizes();
现在我们有了支持的视频尺寸,我们需要找到相机的最佳图像尺寸。这是在这里完成的:
Camera.Size optimalSize = getOptimalPreviewSize(mSupportedPreviewSizes,mPreview.getWidth(),mPreview.getHeight());
parameters.setPreviewSize(optimalSize.width,optimalSize.height);
拥有最佳尺寸后,我们现在可以设置相机录制器设置:
CamcorderProfile profile = CamcorderProfile.get(CamcorderProfile.QUALITY_HIGH);
profile.videoFrameWidth = optimalSize.width;
profile.videoFrameHeight = optimalSize.height;
现在,我们尝试接触相机硬件并设置这些参数:
mCamera.setParameters(parameters);
try {
mCamera.setPreviewTexture(mPreview.getSurfaceTexture());
} catch(IOException e) {
Log.e(TAG,"Surface texture is unavailable or unsuitable" + e.getMessage());
return false;
}
在这里,除了设置相机参数外,我们还指定了一个预览表面。预览表面用于显示相机实时看到的画面。
相机设置完成后,我们现在可以设置媒体录制器:
mMediaRecorder = new MediaRecorder();
mCamera.unlock();
mMediaRecorder.setCamera(mCamera);
mMediaRecorder.setVideoSource(MediaRecorder.VideoSource.CAMERA);
mMediaRecorder.setOutputFormat(profile.fileFormat);
mMediaRecorder.setVideoFrameRate(profile.videoFrameRate);
mMediaRecorder.setVideoSize(profile.videoFrameWidth,profile.videoFrameHeight);
mMediaRecorder.setVideoEncodingBitRate(
profile.videoBitRate);
mMediaRecorder.setVideoEncoder(profile.videoCodec);
mMediaRecorder.setOutputFile(getOutputMediaFile().toString());
这只是设置我们已知的视频流信息——我们只是将收集到的信息传递给媒体录制器。
注意
此配置不记录音频。在这个项目中,我们并不关心音频信号。然而,将媒体录制器配置为存储音频应该是相当直接的。
一切准备就绪后,我们尝试启动媒体录制器:
try {
mMediaRecorder.prepare();
} catch (IllegalStateException e) {
Log.d(TAG, "IllegalStateException preparing MediaRecorder: " + e.getMessage());
releaseMediaRecorder();
return false;
} catch (IOException e) {
Log.d(TAG, "IOException preparing MediaRecorder: " + e.getMessage());
releaseMediaRecorder();
return false;
}
return true;
}
这就是prepareVideoRecorder
方法的结束。我们引用了一些还不存在的变量和函数,所以现在我们将定义其中的一些。
第一个是getOptimalPreviewSize
。在活动类中定义这个方法:
private Camera.Size getOptimalPreviewSize(List<Camera.Size> sizes, int w, int h) {
final double ASPECT_TOLERANCE = 0.1;
double targetRatio = (double)w / h;
if(sizes == null) {
return null;
}
Camera.Size optimalSize = null;
double minDiff = Double.MAX_VALUE;
int targetHeight = h;
for (Camera.Size size : sizes) {
double ratio = (double)size.width / size.height;
double diff = Math.abs(ratio - targetRatio);
if(Math.abs(ratio - targetRatio) > ASPECT_TOLERANCE)
continue;
if(Math.abs(size.height - targetHeight) < minDiff) {
optimalSize = size;
minDiff = Math.abs(size.height - targetHeight);
}
}
if(optimalSize == null) {
minDiff = Double.MAX_VALUE;
for(Camera.Size size : sizes) {
if(Math.abs(size.height-targetHeight) < minDiff) {
optimalSize = size;
minDiff = Math.abs(size.height-targetHeight);
}
}
}
return optimalSize;
}
这个函数只是尝试将所有可能的图像尺寸与预期的宽高比进行匹配。如果找不到接近的匹配项,它将返回最接近的匹配项(基于预期的宽度)。
第二个是getOutputMediaFile
。这个函数使用 Android API 来找到一个可接受的位置来存储我们的视频。在主活动类中定义此方法:
private File getOutputMediaFile() {
if(!Environment.getExternalStorageState().equalsIgnoreCase(Environment.MEDIA_MOUNTED)) {
return null;
}
File mediaStorageDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "Recorder");
if(!mediaStorageDir.exists()) {
if(!mediaStorageDir.mkdirs()) {
Log.d("Recorder", "Failed to create directory");
return null;
}
}
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
File mediaFile;
mediaFile = new File(mediaStorageDir.getPath() + File.separator + "VID_" + timeStamp + ".mp4");
return mediaFile;
}
它会找到图片和应用程序的媒体存储位置,并将时间戳附加到文件名上。
现在我们几乎拥有了开始录制视频所需的一切。再定义两个方法,我们就会有一个工作的视频录制器。
private void releaseCamera() {
if(mCamera != null) {
mCamera.release();
mCamera = null;
}
}
@Override
protected void onPause() {
super.onPause();
releaseMediaRecorder();
releaseCamera();
}
当用户切换到另一个应用程序时,会调用onPause
方法。当你不使用它们时释放硬件依赖是一个好公民的行为。
录制陀螺仪信号
在上一节中,我们只看了录制视频。对于这个项目,我们还需要录制陀螺仪信号。在 Android 中,这是通过使用传感器事件监听器来完成的。我们将为此修改主活动类。添加此implements
子句:
public class Recorder extends Activity implements SensorEventListener {
private TextureView mPreview;
private Camera mCamera;
...
现在,我们需要向我们的类中添加几个新对象:
...
private Button btnRecord;
private SensorManager mSensorManager;
private Sensor mGyro;
private PrintStream mGyroFile;
private long mStartTime = -1;
private static String TAG = "GyroRecorder";
@Override
protected void onCreate(Bundle savedInstanceState) {
....
SensorManager
对象管理硬件上的所有传感器。我们只对陀螺仪感兴趣,所以我们有一个Sensor
对象用于它。PrintStream
将陀螺仪信号写入文本文件。我们现在需要初始化这些对象。我们在onCreate
方法中这样做。修改该方法,使其看起来像这样:
onCaptureClick(view);
}
});
mSensorManager = (SensorManager)getSystemService(Context.SENSOR_SERVICE);
mGyro = mSensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE);
mSensorManager.registerListener(this, mGyro, SensorManager.SENSOR_DELAY_FASTEST);
}
在这里,我们正在获取陀螺仪传感器并注册这个类应该接收事件(registerListener
)。我们还在说明我们想要数据流动的频率。
接下来,我们在prepareVideoRecorder
方法中初始化PrintStream
:
private boolean prepareVideoRecorder() {
mCamera = Camera.open();
...
mMediaRecorder.setOutputFile(getOutputMediaFile().toString());
try {
mGyroFile = new PrintStream(getOutputGyroFile());
mGyroFile.append("gyro\n");
} catch(IOException e) {
Log.d(TAG, "Unable to create acquisition file");
return false;
}
try {
mMediaRecorder.prepare();
...
这尝试打开一个新流到文本文件。我们使用以下方式获取文本文件名:
private File getOutputGyroFile() {
if(!Environment.getExternalStorageState().equalsIgnoreCase(Environment.MEDIA_MOUNTED)) {
return null;
}
File gyroStorageDir = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES), "Recorder");
if(!gyroStorageDir.exists()) {
if(!gyroStorageDir.mkdirs()) {
Log.d("Recorder", "Failed to create directory");
return null;
}
}
String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
File gyroFile;
gyroFile = new File(gyroStorageDir.getPath() + File.separator + "VID_" + timeStamp + "gyro.csv");
return gyroFile;
}
这几乎与getOutputMediaFile
相同,只是它返回的是同一目录下的.csv
文件(而不是.mp4
)。
最后一件事情,我们还将录制陀螺仪信号。将此方法添加到主活动类中:
@Override
public void onAccuracyChanged(Sensor sensor, int accuracy) {
// Empty on purpose
// Required because we implement SensorEventListener
}
@Override
public void onSensorChanged(SensorEvent sensorEvent) {
if(isRecording) {
if(mStartTime == -1) {
mStartTime = sensorEvent.timestamp;
}
mGyroFile.append(sensorEvent.values[0] + "," +
sensorEvent.values[1] + "," +
sensorEvent.values[2] + "," +
(sensorEvent.timestamp-mStartTime) + "\n");
}
}
理念是将传感器返回的值尽可能快地存储到文件中。
Android 特有内容
在本节中,我们将查看一些 Android 特有的任务:一是将叠加层渲染到相机视图之上,二是读取 Android 上的媒体文件。
该叠加层对一般信息和调试很有帮助,而且看起来也很漂亮!想象一下,它就像消费级相机上的抬头显示一样。
读取媒体文件的部分在本章中我们没有使用(我们使用 Python 读取媒体文件)。然而,如果你决定编写一个在设备本身上处理视频的 Android 应用程序,这一部分应该能帮助你入门。
线程化叠加层
现在我们已经使相机预览工作正常,我们想在它上面渲染一些额外的信息。我们将绘制三样东西;首先,一个红色圆圈来指示是否正在录制,其次,当前陀螺仪值(角速度和估计的 theta)仅作为信息,第三,一个安全矩形。在稳定时,我们可能会稍微裁剪一下图像。这个矩形将指导你的视频录制保持在相对安全区域内。
与此同时,我们还将设置它,以便您可以在覆盖层上创建按钮。简单的触摸事件可以用来执行特定功能。
您不需要这个部分来使应用程序工作,但了解如何在录制时在 OpenCV 相机视图中渲染是很重要的。
在我们开始处理覆盖层小部件之前,让我们定义一个支持类,Point3
。创建一个名为Point3
的新类,具有三个 double 属性:
public class Point3 {
public double x;
public double y;
public double z;
}
我们首先定义了一个新的类,CameraOverlayWidget
。
public class CameraOverlayWidget extends SurfaceView implements GestureDetector.OnGestureListener, SurfaceHolder.Callback {
public static String TAG= "SFOCV::Overlay";
protected Paint paintSafeExtents;
protected Button btn;
protected GestureDetector mGestureDetector;
我们从SurfaceView
派生了这个子类,以便能够在它上面渲染事物。它还实现了手势检测类,这样我们就能监控此小部件的触摸事件。
private long sizeWidth = 0, sizeHeight = 0;
// Stuff required to paint the recording sign
protected boolean mRecording = false;
protected Paint paintRecordCircle;
protected Paint paintRecordText;
// Calibrate button
private Paint paintCalibrateText;
private Paint paintCalibrateTextOutline;
private Paint paintTransparentButton;
private RenderThread mPainterThread;
private boolean bStopPainting = false;
private Point3 omega;
private Point3 drift;
private Point3 theta;
public static final double SAFETY_HORIZONTAL = 0.15;
public static final double SAFETY_VERTICAL = 0.15;
我们定义了一组变量,供类使用。其中一些是Paint
对象——这些对象由SurfaceView
用于渲染事物。我们为安全矩形、红色记录圆圈和文本创建了不同的油漆。
接下来,有一些变量描述了记录器的当前状态。这些变量回答了诸如,它目前正在录制吗?视频的大小是多少?最新的陀螺仪读数是什么?我们将使用这些状态变量来渲染适当的覆盖层。
我们还定义了一些安全系数——安全矩形将在每一边都有 0.15 的边距。
protected GestureDetector.OnGestureListener mCustomTouchMethods = null;
protected OverlayEventListener mOverlayEventListener = null;
最后,我们添加了一些事件监听器——我们将使用这些监听器来检测覆盖层特定区域中的触摸(我们实际上不会使用这些)。
让我们看看这个类的构造函数:
public CameraOverlayWidget(Context ctx, AttributeSet attrs) {
super(ctx, attrs);
// Position at the very top and I'm the event handler
setZOrderOnTop(true);
getHolder().addCallback(this);
// Load all the required objects
initializePaints();
// Setup the required handlers/threads
mPainterThread = new RenderThread();
mGestureDetector = new GestureDetector(ctx, this);
}
在这里,我们在对象初始化时设置了一些基本设置。我们在initializePaints
中创建油漆对象,为渲染覆盖层创建一个新的线程,并创建一个手势检测器。
/**
* Initializes all paint objects.
*/
protected void initializePaints() {
paintSafeExtents = new Paint();
paintSafeExtents.setColor(Color.WHITE);
paintSafeExtents.setStyle(Paint.Style.STROKE);
paintSafeExtents.setStrokeWidth(3);
paintRecordCircle = new Paint();
paintRecordCircle.setColor(Color.RED);
paintRecordCircle.setStyle(Paint.Style.FILL);
paintRecordText = new Paint();
paintRecordText.setColor(Color.WHITE);
paintRecordText.setTextSize(20);
paintCalibrateText = new Paint();
paintCalibrateText.setColor(Color.WHITE);
paintCalibrateText.setTextSize(35);
paintCalibrateText.setStyle(Paint.Style.FILL);
paintCalibrateTextOutline = new Paint();
paintCalibrateTextOutline.setColor(Color.BLACK);
paintCalibrateTextOutline.setStrokeWidth(2);
paintCalibrateTextOutline.setTextSize(35);
paintCalibrateTextOutline.setStyle(Paint.Style.STROKE);
paintTransparentButton = new Paint();
paintTransparentButton.setColor(Color.BLACK);
paintTransparentButton.setAlpha(128);
paintTransparentButton.setStyle(Paint.Style.FILL);
}
如您所见,油漆描述了要绘制的事物的物理属性。例如,paintRecordCircle
是红色,并填充我们绘制的任何形状。同样,记录文本以白色显示,文本大小为 20。
现在,让我们看看RenderThread
类——实际绘制覆盖层的那个东西。我们首先定义类本身,并定义run
方法。当线程启动时执行run
方法。从这个方法返回后,线程停止。
class RenderThread extends Thread {
private long start = 0;
@Override
public void run() {
super.run();
start = SystemClock.uptimeMillis();
while(!bStopPainting && !isInterrupted()) {
long tick = SystemClock.uptimeMillis();
renderOverlay(tick);
}
}
现在,让我们将renderOverlay
方法添加到RenderThread
中。我们首先锁定画布并绘制一个透明的颜色背景。这将清除覆盖层上已经存在的内容。
/**
* A renderer for the overlay with no state of its own.
* @returns nothing
*/
public void renderOverlay(long tick) {
Canvas canvas = getHolder().lockCanvas();
long width = canvas.getWidth();
long height = canvas.getHeight();
// Clear the canvas
canvas.drawColor(Color.TRANSPARENT, PorterDuff.Mode.CLEAR);
现在,我们绘制相机视图的安全边界。在稳定视频时,我们不可避免地必须裁剪图像的某些部分。安全线标记了这个边界。在我们的情况下,我们取视图的一定百分比作为安全区域。
// Draw the bounds
long lSafeW = (long)(width * SAFETY_HORIZONTAL);
long lSafeH = (long)(height * SAFETY_VERTICAL);
canvas.drawRect(lSafeW, lSafeH, width-lSafeW, height-lSafeH, paintSafeExtents);
如果我们在录制,我们想要闪烁红色记录圆圈和记录文本。我们通过获取当前时间和开始时间来实现这一点。
if(mRecording) {
// Render this only on alternate 500ms intervals
if(((tick-start) / 500) % 2 == 1) {
canvas.drawCircle(100, 100, 20, paintRecordCircle);
final String s = "Recording";
canvas.drawText(s, 0, s.length(), 130, 110, paintRecordText);
}
}
现在我们绘制一个按钮,上面写着“Record”。
canvas.drawRect((float)(1-SAFETY_HORIZONTAL)*sizeWidth, (float)(1-SAFETY_VERTICAL)*sizeHeight, sizeWidth , sizeHeight, paintTransparentButton);
final String strCalibrate = "Calibrate";
canvas.drawText(strCalibrate, 0, strCalibrate.length(), width-200, height-200, paintCalibrateText);
canvas.drawText(strCalibrate, 0, strCalibrate.length(), width-200, height-200, paintCalibrateTextOutline);
在录制视频时,我们还将显示一些有用的信息——当前的角速度和估计的角度。你可以验证算法是否按预期工作。
if(omega!=null) {
final String strO = "O: ";
canvas.drawText(strO, 0, strO.length(), width - 200, 200, paintCalibrateText);
String strX = Math.toDegrees(omega.x) + "";
String strY = Math.toDegrees(omega.y) + "";
String strZ = Math.toDegrees(omega.z) + "";
canvas.drawText(strX, 0, strX.length(), width - 200, 250, paintCalibrateText);
canvas.drawText(strY, 0, strY.length(), width - 200, 300, paintCalibrateText);
canvas.drawText(strZ, 0, strZ.length(), width - 200, 350, paintCalibrateText);
}
if(theta!=null) {
final String strT = "T: ";
canvas.drawText(strT, 0, strT.length(), width - 200, 500, paintCalibrateText);
String strX = Math.toDegrees(theta.x) + "";
String strY = Math.toDegrees(theta.y) + "";
String strZ = Math.toDegrees(theta.z) + "";
canvas.drawText(strX, 0, strX.length(), width - 200, 550, paintCalibrateText);
canvas.drawText(strY, 0, strY.length(), width - 200, 600, paintCalibrateText);
canvas.drawText(strZ, 0, strZ.length(), width - 200, 650, paintCalibrateText);
}
并且,有了这个,渲染覆盖方法就完成了!
// Flush out the canvas
getHolder().unlockCanvasAndPost(canvas);
}
}
这个类可以用来启动一个新线程,这个线程简单地保持覆盖更新。我们为录制圆圈添加了特殊的逻辑,使其使红色圆圈闪烁。
接下来,让我们看看CameraOverlayWidget
中的某些支持函数。
public void setRecording() {
mRecording = true;
}
public void unsetRecording() {
mRecording = false;
}
两个简单的设置和取消设置方法可以启用或禁用红色圆圈。
@Override
public void onSizeChanged(int w,int h,int oldw,int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
sizeWidth = w;
sizeHeight = h;
}
如果小部件的大小发生变化(我们将在预览窗格中将其设置为全屏),我们应该知道这一点并捕获这些变量中的大小。这将影响各种元素和安全性矩形的定位。
public void setCustomTouchMethods(GestureDetector.SimpleOnGestureListener c){
mCustomTouchMethods = c;
}
public void setOverlayEventListener(OverlayEventListener listener) {
mOverlayEventListener = listener;
}
我们还有一些设置方法,允许你更改要在覆盖上显示的值。
public void setOmega(Point3 omega) {
this.omega = omega;
}
public void setDrift(Point3 drift) {
this.drift = drift;
}
public void setTheta(Point3 theta) {
this.theta = theta;
}
有其他函数可以用来修改正在显示的覆盖。这些函数设置陀螺仪值。
现在,让我们看看一些 Android 特有的生命周期事件,例如暂停、恢复等。
/**
* This method is called during the activity's onResume. This ensures a wakeup
* re-instantiates the rendering thread.
*/
public void resume() {
bStopPainting = false;
mPainterThread = new RenderThread();
}
/**
* This method is called during the activity's onPause method. This ensures
* going to sleep pauses the rendering.
*/
public void pause() {
bStopPainting = true;
try {
mPainterThread.join();
}
catch(InterruptedException e) {
e.printStackTrace();
}
mPainterThread = null;
}
这两个方法确保当应用不在前台时,我们不会使用处理器周期。如果应用进入暂停状态,我们简单地停止渲染线程,当它返回时继续绘制。
@Override
public void surfaceCreated(SurfaceHolder surfaceHolder) {
getHolder().setFormat(PixelFormat.RGBA_8888);
// We created the thread earlier - but we should start it only when
// the surface is ready to be drawn on.
if(mPainterThread != null && !mPainterThread.isAlive()) {
mPainterThread.start();
}
}
@Override
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
// Required for implementation
}
@Override
public void surfaceDestroyed(SurfaceHolder holder) {
// Required for implementation
}
当创建表面时,我们设置像素格式(我们希望它是透明的,所以我们创建一个 RGBA 类型的表面)。此外,我们应该启动一个新线程以开始覆盖渲染。
有了这些,我们的覆盖显示就快完成了。最后一件事是响应触摸事件。让我们现在就做这件事:
@Override
public boolean onTouchEvent(MotionEvent motionEvent) {
boolean result = mGestureDetector.onTouchEvent(motionEvent);
return result;
}
@Override
public boolean onDown(MotionEvent motionEvent) {
MotionEvent.PointerCoords coords =new MotionEvent.PointerCoords();
motionEvent.getPointerCoords(0, coords);
// Handle these only if there is an event listener
if(mOverlayEventListener!=null) {
if(coords.x >= (1-SAFETY_HORIZONTAL)*sizeWidth &&coords.x<sizeWidth &&
coords.y >= (1-SAFETY_VERTICAL)*sizeHeight &&coords.y<sizeHeight) {
return mOverlayEventListener.onCalibrate(motionEvent);
}
}
// Didn't match? Try passing a raw event - just in case
if(mCustomTouchMethods!=null)
return mCustomTouchMethods.onDown(motionEvent);
// Nothing worked - let it bubble up
return false;
}
@Override
public void onShowPress(MotionEvent motionEvent) {
if(mCustomTouchMethods!=null)
mCustomTouchMethods.onShowPress(motionEvent);
}
@Override
public boolean onFling(MotionEvent motionEvent,MotionEvent motionEvent2,float v, float v2) {
Log.d(TAG, "onFling");
if(mCustomTouchMethods!=null)
return mCustomTouchMethods.onFling(motionEvent,motionEvent2,v, v2);
return false;
}
@Override
public void onLongPress(MotionEvent motionEvent) {
Log.d(TAG, "onLongPress");
if(mCustomTouchMethods!=null)
mCustomTouchMethods.onLongPress(motionEvent);
}
@Override
public boolean onScroll(MotionEvent motionEvent,MotionEvent motionEvent2,float v, float v2) {
Log.d(TAG, "onScroll");
if(mCustomTouchMethods!=null)
return mCustomTouchMethods.onScroll(motionEvent,motionEvent2,v, v2);
return false;
}
@Override
public boolean onSingleTapUp(MotionEvent motionEvent) {
Log.d(TAG, "onSingleTapUp");
if(mCustomTouchMethods!=null)
return mCustomTouchMethods.onSingleTapUp(motionEvent);
return false;
}
这些函数只是将事件传递给事件监听器,如果有任何事件监听器的话。我们正在响应以下事件:onTouchEvent
、onDown
、onShowPress
、onFlight
、onLongPress
、onScroll
、onSingleTapUp
。
对于覆盖类,我们还有一些代码需要完成。我们在类的某些地方使用了OverlayEventListener
,但尚未定义它。下面是这个类的样子:
public interface OverlayEventListener {
public boolean onCalibrate(MotionEvent e);
}
}
定义了这一点后,我们现在将能够为覆盖上被触摸的特定按钮创建事件处理器(校准和记录按钮)。
读取媒体文件
一旦你写好了媒体文件,你需要一个机制来从电影中读取单个帧。我们可以使用 Android 的媒体解码器来提取帧并将它们转换为 OpenCV 的本地 Mat 数据结构。我们将首先创建一个名为SequentialFrameExtractor
的新类。
这一部分的大部分内容基于 Andy McFadden 在 bigflake.com 上关于使用 MediaCodec 的教程。
注意
如前所述,你不需要这个类来完成本章的项目。如果你决定编写一个读取媒体文件的 Android 应用,这个类应该能帮助你入门。如果你喜欢,可以随意跳过这一部分!
我们将仅使用 Android 应用来记录视频和陀螺仪信号。
public class SequentialFrameExtractor {
private String mFilename = null;
private CodecOutputSurface outputSurface = null;
private MediaCodec decoder = null;
private FrameAvailableListener frameListener = null;
private static final int TIMEOUT_USEC = 10000;
private long decodeCount = 0;
}
mFilename
是正在读取的文件名,它应该在创建SequentialFrameExtractor
对象时设置。CodecOutputSurface
是从bigflake.com
借用的结构,用于封装使用 OpenGL 渲染帧的逻辑,并为我们获取原始字节。它可以在网站上找到,也可以在配套代码中找到。接下来是MediaCodec
——Android 让你访问解码管道的方式。
FrameAvailableListener
是我们即将创建的接口。它允许我们在帧可用时做出响应。
TIMEOUT_USEC
和decodeCount
是什么?
private long decodeCount = 0;
public SequentialFrameExtractor(String filename) {
mFilename = filename;
}
public void start() {
MediaExtractor mediaExtractor = new MediaExtractor();
try {
mediaExtractor.setDataSource(mFilename);
} catch(IOException e) {
e.printStackTrace();
}
}
我们创建了一个构造函数和一个新的start
方法。start
方法是解码开始的地方,它将在新帧可用时开始触发onFrameAvailable
方法。
e.printStackTrace();
}
MediaFormat format = null;
int numTracks = mediaExtract.getTrackCount();
int track = -1;
for(int i=0;i<numTracks;i++) {
MediaFormat fmt = mediaExtractor.getTrackFormat(i);
String mime = fmt.getString(MediaFormat.KEY_MIME);
if(mime.startswith("video/")) {
mediaExtractor.selectTrack(i);
track = i;
format = fmt;
break;
}
}
if(track==-1) {
// Did the user select an audio file?
}
在这里,我们遍历给定文件中所有可用的轨迹(音频、视频等)并识别一个要处理的视频轨迹。我们假设这是一个单声道视频文件,因此我们应该能够选择第一个出现的视频轨迹。
在选择了轨迹之后,我们现在可以开始实际的解码过程。在此之前,我们必须设置解码表面和一些缓冲区。MediaCodec 的工作方式是它将数据持续积累到缓冲区中。一旦它积累了一个完整的帧,数据就会被传递到表面进行渲染。
int frameWidth = format.getInteger(MediaFormat.KEY_WIDTH);
int frameHeight = format.getInteger(MediaFormat.KEY_HEIGHT);
outputSurface = new CodecOutputSurface(frameWidth,frameHeight);
String mime = format.getString(MediaFormat.KEY_MIME);
decoder = MediaCodec.createDecoderByType(mime);
decoder.configure(format,outputSurface.getSurface(),null,0);
decoder.start();
ByteBuffer[] decoderInputBuffers =
decoder.getInputBuffers();
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
int inputChunk = 0;
boolean outputDone = false, inputDone = false;
long presentationTimeUs = 0;
在完成初始设置后,我们现在进入解码循环:
while(!outputDone) {
if(!inputDone) {
int inputBufIndex =decoder.dequeueInputBuffer(TIMEOUT_USEC);
if(inputBufIndex >= 0) {
ByteBuffer inputBuf =decoderInputBuffers[inputBufIndex];
int chunkSize = mediaExtractor.readSampleData(inputBuf, 0);
if(chunkSize < 0) {
decoder.queueInputBuffer(inputBufIndex,0, 0, 0L,
mediaCodec.BUFFER_FLAG_END_OF_STREAM);
inputDone = true;
} else {
if(mediaExtractor.getSampleTrackIndex()!= track) {
// We somehow got data that did not
// belong to the track we selected
}
presentationTimeUs =mediaExtractor.getSampleTime();
decoder.queueInputBuffer(inputBufIndex,0, chunkSize, presentationTimeUs, 0);
inputChunk++;
mediaExtractor.advance();
}
}
} else {
// We shouldn't reach here – inputDone, protect us
}
}
这是媒体提取的输入部分。这个循环读取文件并将块排队以供解码器使用。随着解码的进行,我们需要将其路由到所需的位置:
} else {
// We shouldn't reach here – inputDone, protect us
}
if(!outputDone) {
int decoderStatus = decoder.dequeueOutputBuffer(
info, TIMEOUT_USEC);
if(decoderStatus ==
MediaCodec.INFO_TRY_AGAIN_LATER) {
// Can't do anything here
} else if(decoderStatus ==
MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) {
// Not important since we're using a surface
} else if(decoderStatus ==
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
MediaFormat newFormat = decoder.getOutputFormat();
// Handled automatically for us
} else if(decoderStatus < 0) {
// Something bad has happened
} else {
if((info.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != ) {
outputDone = true;
}
}
boolean doRender = (info.size != 0);
decoder.releaseOutputBuffer(decoderStatus, doRender);
if(doRender) {
outputSurface.awaitNewImage();
outputSurface.drawImage(true);
try {
Mat img = outputSurface.readFrameAsMat();
if(frameListener != null) {
Frame frame = new Frame(img, presentationTimeUs,
new Point3(), new Point());
frameListener.onFrameAvailable(frame);
}
} catch(IOException e) {
e.printStackTrace();
}
decodeCount++;
if(frameListener!=null)
frameListener.onFrameComplete(decodeCount);
}
}
}
medaiExtractor.release();
mediaExtractor = null;
}
这完成了解码循环的输出部分。每当一个帧完成时,它将原始数据转换为 Mat 结构并创建一个新的Frame
对象。然后,它被传递到onFrameAvailable
方法。
一旦解码完成,媒体提取器就会被释放,我们就完成了!
剩下的唯一事情就是定义FrameAvailableListener
是什么。我们现在就来定义它:
public void setFrameAvailableListener(FrameAvailableListener listener) {
frameListener = listener;
}
public interface FrameAvailableListener {
public void onFrameAvailable(Frame frame);
public void onFrameComplete(long frameDone);
}
}
这是在 Java 中定义此类监听器时的常见模式。监听器包含在特定事件上触发的方法(在我们的案例中,当帧可用或帧处理完成时)。
校准
在讨论数学基础的章节中,我们发现了几个未知的相机参数。这些参数需要确定,以便我们可以处理每一张图像并使其稳定。与任何校准过程一样,我们需要使用预定义的场景。使用这个场景和相对握手,我们将尝试估计未知参数。
未知参数包括:
-
镜头的焦距
-
陀螺仪和帧时间戳之间的延迟
-
陀螺仪的偏差
-
滚动快门的持续时间
通常可以使用平台 API(Android 的getFocalLength()
)检测手机摄像头的焦距(以毫米为单位)。然而,我们感兴趣的是相机空间的焦距。这个数字是物理焦距和转换率的乘积,转换率取决于图像分辨率和相机传感器的物理尺寸,这些可能因相机而异。如果已知传感器和镜头设置的视野(Android 的getVerticalViewAngle()
和getHorizontalViewAngle()
),也可以通过三角学找到转换率。但是,我们将它留作未知,让校准为我们找到它。
注意
如果你对这方面的更多信息感兴趣,请参阅 PacktPub 的《Android 应用编程与 OpenCV》的第五章,结合图像跟踪与 3D 渲染,第五章。
为了提高在急转弯时输出的质量,我们需要估计陀螺仪和帧时间戳之间的延迟。这也抵消了手机在录制视频时引入的任何延迟。
滚动快门效应在高速时可见,估计参数试图纠正这些。
使用一段摇晃的短剪辑可以计算这些参数。我们使用 OpenCV 的特征检测器来完成这个任务。
注意
在这个阶段,我们将使用 Python。SciPy 库为我们提供了可以直接使用的数学函数。虽然可以自己实现这些函数,但这将需要更深入地解释数学优化的工作原理。此外,我们还将使用 Matplotlib 来生成图表。
数据结构
我们将设置三个关键的数据结构:首先,未知参数,其次,用于读取由 Android 应用生成的陀螺仪数据文件的东西,第三,正在处理的视频的表示。
第一个结构是用来存储校准的估计值。它包含四个值:
-
摄像头焦距的估计(以相机单位表示,而不是物理单位)
-
陀螺仪时间戳和帧时间戳之间的延迟
-
陀螺仪偏差
-
滚动快门估计
让我们先创建一个名为calibration.py
的新文件。
import sys, numpy
class CalibrationParameters(object):
def __init__(self):
self.f = 0.0
self.td = 0.0
self.gb = (0.0, 0.0, 0.0)
self.ts = 0.0
读取陀螺仪轨迹
接下来,我们将定义一个类来读取由 Android 应用生成的.csv
文件。
class GyroscopeDataFile(object):
def __init__(self, filepath):
self.filepath = filepath
self.omega = {}
def getfile_object(self):
return open(self.filepath)
我们使用两个主要变量初始化这个类:读取的文件路径和角速度的字典。这个字典将存储时间戳和该时刻角速度之间的映射。我们最终需要从角速度计算实际角度,但这将在类外发生。
现在我们添加了parse
方法。这个方法实际上会读取文件并填充 Omega 字典。
def parse(self):
with self._get_file_object() as fp:
firstline = fp.readline().strip()
if not firstline == 'utk':
raise Exception("The first line isn't valid")
我们验证 csv 文件的第一行是否符合我们的预期。如果不一致,则 csv 文件可能不兼容,并在接下来的几行中出错。
for line in fp.readlines():
line = line.strip()
parts = line.split(",")
在这里,我们开始在整个文件上循环。strip
函数移除了文件中可能存储的任何额外空白(制表符、空格、换行符等)。
在删除空白后,我们用逗号分隔字符串(这是一个逗号分隔的文件!)。
timestamp = int(parts[3])
ox = float(parts[0])
oy = float(parts[1])
oz = float(parts[2])
print("%s: %s, %s, %s" % (timestamp,
ox,
oy,
oz))
self.omega[timestamp] = (ox, oy, oz)
return
从文件中读取的信息是纯字符串,因此我们将其转换为适当的数值类型并存储在 self.omega
中。我们现在可以解析 csv 文件并开始进行数值计算。
在此之前,我们将定义一些更多有用的函数。
def get_timestamps(self):
return sorted(self.omega.keys())
def get_signal(self, index):
return [self.omega[k][index] for k in self.get_timestamps()]
这个类上的 get_timestamps
方法将返回一个按顺序排列的时间戳列表。在此基础上,我们还定义了一个名为 get_signal
的函数。角速度由三个信号组成。这些信号一起打包在 self.omega
中。get_signal
函数使我们能够提取信号的特定组件。
例如,get_signal(0)
返回角速度的 X 分量。
def get_signal_x(self):
return self.get_signal(0)
def get_signal_y(self):
return self.get_signal(1)
def get_signal_z(self):
return self.get_signal(2)
这些实用函数仅返回我们正在查看的特定信号。我们将使用这些信号来平滑单个信号、计算角度等。
另一个我们需要解决的问题是时间戳是离散的。例如,我们可能在时间戳 N 处有角速度,下一次读取可能存在于 N+500000(记住,时间戳是以纳秒为单位的)。然而,视频文件可能在 N+250000 处有一个帧。我们需要在两个角速度读取之间进行插值。
我们将使用简单的线性插值来估计任意时刻的角速度。
def fetch_approximate_omega(self, timestamp):
if timestamp in self.omega:
return self.omega[timestamp]
此方法接受一个时间戳并返回估计的角速度。如果确切的时戳已经存在,则无需进行估计。
i = 0
sorted_timestamps = self.get_timestamps()
for ts in sorted_timestamps:
if ts > timestamp:
break
i += 1
在这里,我们遍历时间戳,找到最接近请求的时间戳。
t_previous = sorted_timestamps[i]
t_current = sorted_timestamps[i+1]
dt = float(t_current – t_previous)
slope = (timestamp – t_previous) / dt
est_x = self.omega[t_previous][0]*(1-slope) + self.omega[t_current][0]*slope
est_y = self.omega[t_previous][1]*(1-slope) + self.omega[t_current][1]*slope
est_z = self.omega[t_previous][2]*(1-slope) + self.omega[t_current][2]*slope
return (est_x, est_y, est_z)
一旦我们有了两个最接近的时间戳(列表 sorted_timestamps
中的 i
和 i+1
),我们就准备好开始线性插值。我们计算估计的 X、Y 和 Z 角速度,并返回这些值。
这就完成了我们对陀螺仪文件的读取工作!
训练视频
我们还将创建一个新的类,使我们能够将整个视频序列视为一个单一实体。我们将单次遍历视频以提取有用的信息,并将其存储以供将来参考,使我们的代码更快、更节省内存。
class GyroVideo(object):def__init__(self, mp4):
self.mp4 = mp4
self.frameInfo = []
self.numFrames = 0
self.duration = 0
self.frameWidth = 0
self.frameHeight = 0
我们使用将在整个过程中使用的变量初始化该类。大多数变量都是不言自明的。frameInfo
存储有关每个帧的详细信息——如给定帧的时间戳和关键点(用于校准)。
def read_video(self, skip_keypoints=False):
vidcap = cv2.VideoCapture(self.mp4)
success, frame = vidcap.read()
prev_frame = None
previous_timestamp = 0
frameCount = 0
我们定义了一个新的方法,将为我们完成所有繁重的工作。我们首先创建 OpenCV 视频读取对象(VideoCapture
)并尝试读取一个帧。
while success:
current_timestamp = vidcap.get(0) * 1000 * 1000
print "Processing frame#%d (%f ns)" % (frameCount, current_timestamp)
在VideoCapture
对象上的get
方法返回有关视频序列的信息。零(0)恰好是获取毫秒级时间戳的常量。我们将此转换为纳秒并打印出有用的信息!
if not prev_frame:
self.frameInfo.append({'keypoints': None,
'timestamp': current_timestamp})
prev_frame = frame
previous_timestamp = current_timestamp
continue
如果这是第一个被读取的帧,我们将没有前一个帧。我们也不感兴趣存储第一个帧的关键点。所以我们直接跳到下一个帧。
if skip_keypoints:
self.frameInfo.append({'keypoints': None,
'timestamp': current_timestamp})
continue
如果你将skip_keypoints
参数设置为true
,它将只存储每个帧的时间戳。你可能会在已经校准了你的设备并且已经得到了各种未知数的值之后使用此参数来读取视频。
old_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
new_gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
old_corners = cv2.goodFeaturesToTrack(old_gray, 1000, 0.3, 30)
我们将前一个和当前帧转换为灰度图并提取一些良好的特征以进行跟踪。我们将使用这些特征并在新帧中跟踪它们。这给我们提供了一个视觉估计,即相机的方向如何改变。我们已经有这个陀螺仪数据;我们只需要校准一些未知数。我们通过使用视觉估计来实现这一点。
if old_corners == None:
self.frameInfo.append({'keypoints': None,
'timestamp': current_timestamp})
frameCount += 1
previous_timestamp = current_timestamp
prev_frame = frame
success, frame = vidcap.read()
continue
如果在旧帧中没有找到任何角,这不是一个好兆头。这是不是一个非常模糊的帧?是否有良好的特征进行跟踪?所以我们简单地跳过处理它。
如果我们确实找到了用于跟踪的关键点,我们使用光流来确定它们在新帧中的位置:
new_corners, status, err = cv2.calcOpticalFlowPyrLK(old_gray,
new_gray,
old_corners,
None,
winSize=(15,15)
maxLevel=2,
criteria=(cv2.TERM_CRITERIA_EPS
| cv2.TERM_CRITERIA_COUNT,
10, 0.03))
这给我们提供了新帧中角的位置。然后我们可以估计前一个帧和当前帧之间的运动,并将其与陀螺仪数据相关联。
goodFeaturesToTrack
的一个常见问题是特征不够稳健。它们经常移动,失去了它们跟踪的位置。为了解决这个问题,我们添加另一个测试,以确保这样的随机异常值不会进入校准阶段。这是通过 RANSAC 来完成的。
注意
RANSAC代表Random Sample Consensus(随机样本一致性)。RANSAC 的关键思想是给定的数据集包含一组内点,这些内点完美地适合给定的模型。它给你一组最接近满足给定约束的点。在我们的情况下,这些内点将解释为点从一个位置移动到另一个位置。数据集的异常值数量多少并不重要。
OpenCV 附带一个计算两个帧之间透视变换的实用函数。当正在估计变换时,该函数还试图找出哪些点是异常值。我们也将利用这个功能来实现我们的目的!
if len(old_corners) > 4:
homography, mask = cv2.findHomography(old_corners, new_corners,
cv2.RANSAC, 5.0)
mask = mask.ravel()
new_corners_homography = [new_corners[i] for i in xrange(len(mask)) if mask[i] == 1])
old_corners_homography = [old_corners[i] for i in xrange(len(mask)) if mask[i] == 1])
new_corners_homography = numpy.asarray(new_corners_homography)
old_corners_homography = numpy.asarray(old_corners_homography)
else:
new_corners_homography = new_corners
old_corners_homography = old_corners
我们至少需要四个关键点来计算两个帧之间的透视变换。如果没有足够的点,我们只需存储我们拥有的任何东西。如果有更多的点并且一些被消除,我们得到的结果会更好。
self.frameInfo.append({'keypoints': (old_corners_homography,
new_corners_homography),
'timestamp': current_timestamp})
frameCount += 1
previous_timestamp = current_timestamp
prev_frame = frame
success, frame = vidcap.read()
self.numFrames = frameCount
self.duration = current_timstamp
return
一旦我们弄清楚了一切,我们只需将其存储在帧信息列表中。这也标志着我们方法的结束!
处理旋转
让我们看看我们如何旋转帧以稳定它们。
旋转图像
在我们探讨如何在我们的项目中旋转图像之前,让我们先看看一般如何旋转图像。目标是产生如下所示的图像:
在 2D 中旋转图像很简单,只有一个轴。2D 旋转可以通过使用仿射变换来实现。
在我们的项目中,我们需要围绕所有三个轴旋转图像。一个仿射变换不足以产生这些变换,因此我们需要转向透视变换。此外,旋转是线性变换;这意味着我们可以将任意旋转分解为其 X、Y 和 Z 分量旋转,并使用这些分量来组合旋转。
为了实现这一点,我们将使用 OpenCV 的 Rodrigues 函数调用生成这些变换矩阵。让我们先写一个函数,它可以任意旋转一个图像。
def rotateImage(src, rx, ry, rz, f, dx=0, dy=0, dz=0, convertToRadians=False):
if convertToRadians:
rx = (rx) * math.pi / 180
ry = (ry) * math.pi / 180
rz = (rz) * math.pi / 180
rx = float(rx)
ry = float(ry)
rz = float(rz)
此方法接受一个需要旋转的源图像,三个旋转角度,一个可选的平移,像素焦距,以及角度是否以弧度表示。如果角度是以度表示的,我们需要将它们转换为弧度。我们还强制将它们转换为float
。
接下来,我们将计算源图像的宽度和高度。这些,连同焦距,用于将旋转矩阵(在现实世界空间中)转换为图像空间。
w = src.shape[1]
h = src.shape[0]
现在,我们使用 Rodrigues 函数生成旋转矩阵:
smallR = cv2.Rodrigues(np.array([rx, ry, rz]))[0]
R = numpy.array([ [smallR[0][0], smallR[0][1], smallR[0][2], 0],
[smallR[1][0], smallR[1][1], smallR[1][2], 0],
[smallR[2][0], smallR[2][1], smallR[2][2], 0],
[0, 0, 0, 1]])
Rodrigues 函数接受一个包含三个旋转角度的向量(列表),并返回一个旋转矩阵。返回的矩阵是一个 3x3 矩阵。我们将将其转换为 4x4 齐次矩阵,这样我们就可以对其应用变换。
注意
通常,保持 dz 等于焦距是一个好主意。这意味着图像是在正确的焦距下捕获的,并且需要围绕这个点旋转。你可以自由地将 dz 更改为其他值,但通常将 dz 设置为 F 可以得到很好的结果。
我们现在将一个简单的平移应用到矩阵上。平移矩阵可以很容易地评估如下:
x = numpy.array([[1.0, 0, 0, dx],
[0, 1.0, 0, dy],
[0, 0, 1.0, dz],
[0, 0, 0, 1]])
T = numpy.asmatrix(x)
到目前为止,所有的变换都是在世界空间中发生的。我们需要将这些变换转换为图像空间。这是通过相机的简单针孔模型实现的。
c = numpy.array([[f, 0, w/2, 0],
[0, f, h/2, 0],
[0, 0, 1, 0]])
cameraMatrix = numpy.asmatrix(c)
结合这些变换是直接的:
transform = cameraMatrix * (T*R)
这个矩阵现在可以用于 OpenCV 的warpPerspective
方法来旋转源图像。
output = cv2.warpPerspective(src, transform, (w, h))
return output
但是这个输出的结果并不是你想要的,图像是围绕图像中的(0, 0)旋转的。我们需要围绕图像中心旋转。为了实现这一点,我们需要在旋转发生之前插入一个额外的平移矩阵。
w = src.shape[1]
h = src.shape[0]
# New code:
x = numpy.array([ [1, 0, -w/2],
[0, 1, -h/2],
[0, 0, 0],
[0, 0, 1]]
A1 = numpy.asmatrix(x)
...
现在,我们在非常开始处插入矩阵 A1:
transform = cameraMatrix * (T*(R*A1))
现在图像应该围绕中心旋转;这正是我们想要的,并且是一个自包含的方法,我们可以使用它来使用 OpenCV 在 3D 空间中任意旋转图像。
累积旋转
使用单个旋转向量旋转图像相当直接。在本节中,我们将扩展该方法,使其更适合我们的项目。
在录制视频时,我们有两个数据源处于活动状态:图像捕获和陀螺仪轨迹。这些是在不同的速率下捕获的——每几毫秒捕获一次图像,每几微秒捕获一次陀螺仪信号。为了计算稳定图像所需的精确旋转,我们需要累积数十个陀螺仪信号。这意味着旋转矩阵需要有关多个不同陀螺仪数据样本的信息。
此外,陀螺仪和图像传感器不同步;我们需要在陀螺仪信号上使用线性插值来使它们同步。
让我们编写一个返回变换矩阵的函数。
def getAccumulatedRotation(w, h,
theta_x, theta_y, theta_z, timestamps,
prev, current,
f,
gyro_delay=None, gyro_drift=None, shutter_duration=None):
if not gyro_delay:
gyro_delay = 0
if not gyro_drift:
gyro_drift = (0, 0, 0)
if not shutter_duration:
shutter_duration = 0
这个函数接受很多参数。让我们逐一介绍它们:
-
w
、h
:我们需要知道图像的大小,以便将其从世界空间转换为图像空间。 -
theta_*:目前,我们可以访问角速度。从那里,我们可以评估实际角度,这正是这个函数作为参数接受的。
-
时间戳:每个样本被采集的时间。
-
prev
、current
:在这些时间戳之间累积旋转。这通常将提供前一帧和当前帧的时间戳。 -
f
、gyro_delay
、gyro_drift
和shutter_duration
被用来提高旋转矩阵估计的准确性。其中最后三个是可选的(如果你没有传递它们,它们将被设置为 0)。
从上一节中,我们知道我们需要先进行平移(否则我们将得到关于(0, 0)的旋转)。
x = numpy.array([[1, 0, -w/2],
[0, 1, -h/2],
[0, 0, 0],
[0, 0, 1]])
A1 = numpy.asmatrix(x)
transform = A1.copy()
我们将使用“变换”矩阵来累积多个陀螺仪样本之间的旋转。
接下来,我们使用gyro_delay
偏移时间戳。这只是在时间戳上添加(或根据其值的符号减去)。
prev = prev + gyro_delay
current = current + gyro_delay
if prev in timestamps and current in timestamps:
start_timestamp = prev
end_timestamp = current
else:
(rot, start_timestamp, t_next) = fetch_closest_trio(theta_x,
theta_y,
theta_z,
timestamps,
prev)
(rot, end_timestamp, t_next) = fetch_closest_trio(theta_x,
theta_y,
theta_z,
timestamps,
current)
如果更新的prev
和current
值存在于时间戳中(这意味着我们在那个时间瞬间从传感器捕获了值)——太好了!不需要插值。否则,我们使用fetch_closest_trio
函数来将信号插值到给定的时间戳。
这个辅助函数返回三件事:
-
请求时间戳的插值旋转
-
传感器数据中最接近的时间戳
-
它之后的那个时间戳
我们现在使用start_timestamp
和end_timestamp
进行迭代。
for time in xrange(timestamps.index(start_timestamp), timestamps.index(end_timestamp)):
time_shifted = timestamps[time] + gyro_delay
trio, t_previous, t_current = fetch_closest_trio(theta_x, theta_y, theta_z, timestamps, time_shifted)
gyro_drifted = (float(trio[0] + gyro_drift[0]),
float(trio[1] + gyro_drift[1]),
float(trio[2] + gyro_drift[2]))
我们遍历物理数据中的每个时间戳。我们添加陀螺仪延迟并使用它来获取最接近的(插值)信号。一旦完成,我们为每个组件添加陀螺仪漂移。这只是一个常数,应该添加以补偿陀螺仪的错误。
使用这些旋转角度,我们现在计算旋转矩阵,就像上一节中那样。
smallR = cv2.Rodrigues(numpy.array([-float(gyro_drifted[1]),
-float(gyro_drifted[0]),
-float(gyro_drifted[2])]))[0]
R = numpy.array([[smallR[0][0], smallR[0][1], smallR[0][2], 0],
[smallR[1][0], smallR[1][1], smallR[1][2], 0],
[smallR[2][0], smallR[2][1], smallR[2][2], 0],
[0, 0, 0, 1]])
transform = R * transform
这段代码几乎与上一节中的相同。尽管如此,也有一些关键的区别。首先,我们向 Rodrigues 提供负值。这是为了抵消运动的影响。其次,X 和 Y 的值被交换了。(gyro_drifted[1]
排在前面,然后是gyro_drifted[0]
)。这是必需的,因为陀螺仪的轴和这些矩阵使用的轴是不同的。
这完成了在指定时间戳之间对陀螺仪样本的迭代。为了完成这个任务,我们需要在 Z 方向上平移,就像在上一节中做的那样。由于这可以硬编码,让我们这样做:
x = numpy.array([[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, f],
[0, 0, 0, 1]])
T = numpy.asmatrix(x)
我们还需要使用相机矩阵将世界空间转换为图像空间。
x = numpy.array([[f, 0, w/2, 0],
[0, f, h/2, 0],
[0, 0, 1, 0]])
A2 = numpy.asmatrix(x)
transform = A2 * (T*transform)
return transform
我们首先在 Z 方向上平移,然后转换为图像空间。这一节实际上允许你使用陀螺仪参数旋转视频帧。
校准类
数据结构准备就绪后,我们就处于开始这个项目关键部分的良好位置。校准建立在之前提到的所有类的基础上。
和往常一样,我们将创建一个新的类,它封装了所有与校准相关的任务。
class CalibrateGyroStabilize(object):
def __init__(self, mp4, csv):
self.mp4 = mp4
self.csv = csv
该对象需要两个东西:视频文件和陀螺仪数据文件。这些将被存储在对象中。
在直接跳入校准方法之前,让我们创建一些在校准时会有帮助的实用函数。
def get_gaussian_kernel(sigma2, v1, v2, normalize=True):
gauss = [math.exp(-(float(x*x) / sigma2)) for x in range(v1, v2+1)]
if normalize:
total = sum(guass)
gauss = [x/total for x in gauss]
return gauss
这个方法生成一个给定大小的高斯核。我们将使用它来平滑角速度信号。
def gaussian_filter(input_array):
sigma = 10000
r = 256
kernel = get_gaussian_kernel(sigma, -r, r)
return numpy.convolve(input_array, kernel, 'same')
这个函数执行信号的真正平滑。给定一个输入信号,它生成高斯核并与输入信号卷积。
注意
卷积是产生新函数的数学工具。你可以把陀螺仪信号看作一个函数;你给它一个时间戳,它就返回一个值。为了使其平滑,我们需要将其与另一个函数结合。这个函数称为高斯函数,是一个平滑的钟形曲线。这两个函数在不同的时间范围内操作(陀螺仪函数可能在 0 秒到 50 秒之间返回值,而高斯函数可能只适用于 0 秒到 5 秒的时间)。卷积这两个函数产生第三个函数,它在某种程度上类似于两者,从而有效地平滑了陀螺仪信号中的微小变化。
接下来,我们编写一个函数,它计算两个点集的错误分数。这将是估计校准效果好坏的一个构建块。
def calcErrorScore(set1, set2):
if len(set1) != len(set2):
raise Exception("The given sets need to have the exact same length")
score = 0
for first, second in zip(set1.tolist(), set2.tolist()):
diff_x = math.pow(first[0][0] – second[0][0], 2)
diff_y = math.pow(first[0][1] – second[0][1], 2)
score += math.sqrt(diff_x + diff_y)
return score
这个错误分数是直接的:你有两个点列表,你计算两个列表上对应点的距离并将它们相加。更高的错误分数意味着两个列表上的点不完全对应。
然而,这种方法只给出了单个帧上的错误。我们关心的是整个视频中的错误。因此,我们编写了另一个方法。
def calcErrorAcrossVideo(videoObj, theta, timestamp, focal_length, gyro_delay=None, gyro_drift=None, rolling_shutter=None):
total_error = 0
for frameCount in xrange(videoObj.numFrames):
frameInfo = videoObj.frameInfo[frameCount]
current_timestamp = frameInfo['timestamp']
if frameCount == 0:
previous_timestamp = current_timestamp
continue
keypoints = frameInfo['keypoints']
if not keypoints:
continue
我们传递视频对象和所有已估计的细节(theta、时间戳、焦距、陀螺仪延迟等)。有了这些细节,我们尝试进行视频稳定并查看视觉跟踪的关键点与基于陀螺仪的转换之间的差异。
由于我们正在计算整个视频的误差,我们需要遍历每一帧。如果帧的信息中没有关键点,我们就简单地忽略该帧。如果帧中有关键点,这里是我们所做的工作:
old_corners = frameInfo['keypoints'][0]
new_corners = frameInfo['keypoints'][1]
transform = getAccumulatedRotation(videoObj.frameWidth,
videoObj.frameHeight,
theta[0], theta[1], theta[2],
timestamps,
int(previous_timestamp),
int(current_timestamp),
focal_length,
gyro_delay,
gyro_drift,
rolling_shutter)
getAccumulatedRotation
函数是我们很快将要编写的。该函数的关键思想是返回给定 theta(我们需要旋转以稳定视频的角度)的转换矩阵。我们可以将此转换应用于 old_corners
并将其与 new_corners
进行比较。
由于 new_corners
是通过视觉获得的,因此它是真实值。我们希望 getAccumulatedRotation
返回一个与视觉真实值完美匹配的转换。这意味着 new_corners
和转换后的 old_corners
之间的误差应该是最小的。这就是 calcErrorScore
帮助我们的地方:
transformed_corners = cv2.perspectiveTransform(old_corners, transform)
error = calcErrorScore(new_corners, transformed_corners)
total_error += error
previous_timestamp = current_timestamp
return total_error
我们已经准备好在整个视频中计算误差!现在让我们转到校准函数:
def calibrate(self):
gdf = GyroscopeDataFile(csv)
gdf.parse()
signal_x = gdf.get_signal_x()
signal_y = gdf.get_signal_y()
signal_z = gdf.get_signal_z()
timestamps = gdf.get_timestamps()
第一步是平滑角速度信号中的噪声。这是具有平滑运动的期望信号。
smooth_signal_x = self.gaussian_filter(signal_x)
smooth_signal_y = self.gaussian_filter(signal_y)
smooth_signal_z = self.gaussian_filter(signal_z)
我们很快将编写 gaussian_filter
方法;现在,让我们记住它返回一个平滑的信号。
接下来,我们计算物理信号和期望信号之间的差异。我们需要为每个组件分别进行此操作。
g = [ [], [] [] ]
g[0] = numpy.subtract(signal_x, smooth_signal_x).tolist()
g[1] = numpy.subtract(signal_y, smooth_signal_y).tolist()
g[2] = numpy.subtract(signal_z, smooth_signal_z).tolist()
我们还需要计算时间戳之间的差值。我们将使用这个差值来进行积分。
dgt = self.diff(timestamps)
接下来,我们将角速度积分以获得实际角度。积分会引入我们的方程中的误差,但这没关系。对于我们的目的来说,这已经足够好了。
theta = [ [], [], [] ]
for component in [0, 1, 2]:
sum_of_consecutives = numpy.add( g[component][:-1], g[component][1:])
dx_0 = numpy.divide(sum_of_consecutives, 2 * 1000000000)
num_0 = numpy.multipy(dx_0, dgt)
theta[component] = [0]
theta[component].extend(numpy.cumsum(num_0))
就这样。我们已经计算了将稳定图像的 theta 数量!然而,这纯粹是从陀螺仪的角度来看。我们仍然需要计算未知数,以便我们可以使用这些 theta 来稳定图像。
要做到这一点,我们将一些未知数初始化为具有任意初始值的变量(大多数情况下为 0)。同时,我们加载视频并处理关键点信息。
focal_length = 1080.0
gyro_delay = 0
gyro_drift = (0, 0, 0)
shutter_duration = 0
videoObj = GyroVideo(mp4)
videoObj.read_video()
现在,我们使用 SciPy 的优化方法来最小化误差。为此,我们必须首先将这些未知数转换为 Numpy 数组。
parameters = numpy.asarray([focal_length,
gyro_delay,
gyro_drift[0], gyro_drift[1], gyro_drift[2]])
由于我们尚未将固定滚动快门纳入其中,因此在参数列表中忽略它。接下来,我们调用实际的优化函数:
result = scipy.optimize.minimize(self.calcErrorAcrossVideoObjective,
parameters,
(videoObj, theta, timestamps),
'Nelder-Mead')
执行此函数需要几秒钟,但它会为我们提供未知数的值。然后我们可以按照以下方式从结果中提取这些值:
focal_length = result['x'][0]
gyro_delay = result['x'][1]
gyro_drift = ( result['x'][2], result['x'][3], result['x'][4] )
print "Focal length = %f" % focal_length
print "Gyro delay = %f" % gyro_delay
print "Gyro drift = (%f, %f, %f)" % gyro_drift
这样,我们就完成了校准!我们只需要返回刚才所做的所有相关计算。
return (delta_theta, timestamps, focal_length, gyro_delay, gyro_drift, shutter_duration)
就这样,大功告成!
矫正图像
在上一节中,我们计算了方程中的所有未知数。现在,我们可以继续修复摇摇晃晃的视频。
我们将首先创建一个新的方法,称为stabilize_video
。这个方法将接受一个视频文件和一个相应的 csv 文件。
def stabilize_video(mp4, csv):
calib_obj = CalibrateGyroStabilize(mp4, csv)
我们创建了一个我们刚刚定义的校准类对象,并传递了所需的信息。现在,我们只需要调用校准函数。
delta_theta, timestamps, focal_length, gyro_delay, gyro_drift, shutter_duration = calib_obj.calibrate()
这个方法调用可能需要一段时间才能执行,但我们只需要为每个设备运行一次。一旦计算出来,我们可以将这些值存储在文本文件中,并从中读取。
一旦我们估计了所有未知数,我们就开始读取每个帧的视频文件。
vidcap = cv2.VideoCapture(mp4)
现在,我们开始遍历每一帧并纠正旋转。
frameCount = 0
success, frame = vidcap.read()
previous_timestamp = 0
while success:
print "Processing frame %d" % frameCount
接下来,我们从视频流中获取时间戳,并使用它来获取最近的旋转样本。
current_timestamp = vidcap.get(cv2.CAP_PROP_POS_MSEC) * 1000 * 1000
VideoCapture
类返回毫秒级的时间戳。我们将它转换为纳秒以保持一致的单位。
rot, prev, current = fetch_closest_trio(delta_theta[0],delta_theta[1],delta_theta[2],timestamps,current_timestamps)
现在我们有了这些部分,我们现在获取累积的旋转。
rot = accumulateRotation(frame, delta_theta[0],delta_theta[1],delta_theta[2],timestamps, previous_timestamp,prev,focal_length,gyro_delay,gyro_drift,shutter_duration)
接下来,我们将变换后的帧写入文件,并继续处理下一帧:
cv2.imwrite("/tmp/rotated%04d.png" % frameCount, rot)
frameCount += 1
previous_timestamp = prev
success, frame = vidcap.read()
return
这样,我们就完成了消除设备抖动的简单函数。一旦我们有了所有图像,我们就可以使用ffmpeg
将它们合并成一个单独的视频。
ffmpeg -f image2 -i image%04d.jpg output.mp4
测试校准结果
校准的有效性取决于它能够多准确地复制视频上的运动。对于任何一帧,我们都有前一帧和当前帧中匹配的关键点。这给出了场景一般运动的感觉。
使用估计的参数,如果我们能够使用前一帧的关键点来生成当前帧的关键点,我们可以假设校准已经成功。
滚动快门补偿
到目前为止,我们的视频已经稳定,然而,当场景中的物体快速移动时,滚动快门效应变得更加明显。
为了解决这个问题,我们需要做几件事情。首先,将滚动快门速度纳入我们的校准代码。其次,在扭曲图像时,我们还需要对滚动快门进行反扭曲。
校准滚动快门
要开始校准滚动快门持续时间,我们需要调整误差函数以包含另一个项。让我们先看看calcErrorAcrossVideo
方法。我们感兴趣的部分是:
def calcErrorAcrossVideo(videoObj, theta, timestamp, focal_length, gyro_delay=None, gyro_drift=None, rolling_shutter=None):
total_error = 0
...
transform = getAccumulatedRotation(...)
transformed_corners = cv2.perspectiveTransform(old_corners, transform)
...
此外,我们还需要添加逻辑来根据角落的位置变换一个角落——图像上半部分的角落与下半部分的角落变换方式不同。
到目前为止,我们只有一个变换矩阵,这通常足够了。然而,现在,我们需要有多个变换矩阵,每个行都有一个。我们可以选择为每个像素行都这样做,但这有点过度。我们只需要包含我们正在跟踪的角落的行的变换。
我们将首先替换上面提到的两行。我们需要逐个遍历每个角落并对其进行扭曲。让我们用一个简单的for
循环来做这件事:
for pt in old_corners:
x = pt[0][0]
y = pt[0][1]
pt_timestamp = int(current_timestamp) + rolling_shutter * (y-frame_height/2) / frame_height
在这里,我们提取旧角点的 x 和 y 坐标,并尝试估计这个特定像素被捕获的时间戳。这里,我假设滚动快门是垂直方向的,从画面顶部到底部。
我们使用当前估计的滚动快门持续时间,并根据角点所在的行估计减去和添加的时间。对于水平滚动快门,这也应该很容易适应。你将不得不使用 x
和 frameWidth
而不是 y
和 frameHeight
——计算将保持不变。目前,我们假设这将是一个垂直滚动快门。
现在我们有了捕获的估计时间戳,我们可以得到那个瞬间的旋转矩阵(记住,陀螺仪产生的数据分辨率高于相机传感器)。
transform = getAccumulatedRotation(videoObj.frameWidth, videoObj.frameHeight, theta[0], theta[1], theta[2], timestamps, int(previous_timestamp), int(pt_timestamp), focal_length, gyro_delay, gyro_drift, doSub=True)
这行几乎与原始的相同;唯一的区别是我们将 current_timestamp
替换为 pt_timestamp
。
接下来,我们需要根据滚动快门持续时间转换这个点。
output = transform * np.matrix("%f;%f;1.0" % (x, y)).tolist()
tx = (output[0][0] / output[2][0]).tolist()[0][0]
ty = (output[1][0] / output[2][0]).tolist()[0][0]
transformed_corners.append( np.array([tx, ty]) )
变换后,我们只需将其追加到 transformed_corners
列表(就像我们之前做的那样)。
使用这个,我们就完成了校准部分。现在,我们转向图像扭曲。
使用网格点进行扭曲
让我们首先编写一个函数,这个函数将为我们执行扭曲。这个函数接受以下输入:
-
原始图像
-
一系列理想上应该整齐排列在完美网格中的点
点列表的大小给我们提供了预期的行数和列数,并且该函数返回一个完美对齐的图像。
让我们定义一个函数:
def meshwarp(src, distorted_grid):
"""
src: The original image
distorted_grid: The list of points that have been distorted
"""
size = src.shape
如前所述,这需要一个图像和控制点的列表。我们存储图像的大小以供将来参考。
mapsize = (size[0], size[1], 1)
dst = np.zeros(size, dtype=np.uint8)
我们之前存储的大小很可能有三个通道。因此,我们创建了一个名为 mapsize
的新变量;它存储了图像的大小,但只有一个通道。我们将在以后使用它来创建 OpenCV 中 remap 函数使用的矩阵。
我们还创建了一个与原始图像大小相同的空白图像。接下来,我们查看计算网格中的行数和列数。
quads_per_row = len(distorted_grid[0]) – 1
quads_per_col = len(distorted_grid) – 1
pixels_per_row = size[1] / quads_per_row
pixels_per_col = size[0] / quads_per_col
我们很快将在一些循环中使用这些变量。
pt_src_all = []
pt_dst_all = []
这些列表存储了所有源(扭曲)点和目标(完美对齐)点。我们将使用 distorted_grid
来填充 pt_src_all
。我们将根据输入数据中的行数和列数程序性地生成目标。
for ptlist in distorted_grid:
pt_src_all.extend(ptlist)
变形网格应该是一个列表的列表。每一行是一个包含其点的列表。
现在,我们使用之前计算的 quads_per_*
变量生成程序性目标点。
for x in range(quads_per_row+1):
for y in range(quads_per_col+1):
pt_dst_all.append( [x*pixels_per_col,
y*pixels_per_row])
这根据我们传递给方法的点数生成理想的网格。
我们现在有了计算源网格和目标网格之间插值所需的所有信息。我们将使用scipy
为我们计算插值。然后我们将其传递给 OpenCV 的 remap 方法,并将其应用于图像。
首先,scipy
需要一个表示预期输出网格的表示,因此我们需要指定一个包含图像所有像素的密集网格。这是通过以下方式完成的:
gx, gt = np.mgrid[0:size[1], 0:size[0]]
一旦我们定义了基本网格,我们就可以使用 Scipy 的interpolate
模块来为我们计算映射。
g_out = scipy.interpolate.griddata(np.array(pt_dst_all),
np.array(pt_src_all),
(gx, gy), method='linear')
g_out
包含重新映射的x
和y
坐标;我们需要将其拆分为单独的组件,以便 OpenCV 的remap
方法能够工作。
mapx = np.append([], [ar[:,0] for ar in g_out]).reshape(mapsize).astype('float32')
mapy = np.append([], [ar[:,1] for ar in g_out]).reshape(mapsize).astype('float32')
这些矩阵正是 remap 所期望的,我们可以现在简单地用适当的参数运行它。
dst = cv2.remap(src, mapx, mapy, cv2.INTER_LINEAR)
return dst
这样就完成了我们的方法。我们可以在我们的稳定化代码中使用它,并修复滚动快门。
使用校准去畸变
在这里,我们讨论了如何根据网格对视频进行稳定化扭曲图像。我们将每一帧分割成 10x10 的网格。我们扭曲网格,这会导致图像扭曲(就像控制点)。使用这种方法,我们应该得到良好的结果和不错的性能。
实际的去畸变发生在accumulateRotation
方法中:
def accumulateRotation(src, theta_x, theta_y, theta_z, timestamps, prev, current, f, gyro_delay=None, gyro_drift=None, shutter_duration=None):
...
transform = getAccumulatedRotation(src.shape[1], src.shape[0], theta_x, theta_y, theta_z, timestamps, prev, current, f, gyro_delay, gyro_drift)
o = cv2.warpPerspective(src, transform (src.shape[1], src.shape[0]))
return o
这里,有一个单一的透视变换正在进行。现在,相反,我们必须对每个 10x10 的控制点进行不同的变换,并使用meshwarp
方法来修复滚动快门。所以用下面的内容替换transform =
行:
...
pts = []
transformed_pts = []
for x in range(10):
current_row = []
current_row_transformed = []
pixel_x = x * (src.shape[1] / 10)
for y in range(10):
pixel_y = y * (src.shape[0] / 10)
current_row.append( [pixel_x, pixel_y] )
pts.append(current_row)
我们现在已经在pts
列表中生成了原始网格。现在,我们需要生成变换坐标:
...
for y in range(10):
pixel_y = y * (src.shape[0] / 10
if shutter_duration:
y_timestamp = current + shutter_duration*(pixel_y - src.shape[0]/2)
else:
y_timestamp = current
...
如果传递了快门持续时间,我们生成记录这个特定像素的时间戳。现在我们可以根据快门旋转变换(pixel_x
,pixel_y
),并将其附加到current_row_transformed
:
...
transform = getAccumulatedRotation(src.shape[1], src.shape[0], theta_x, theta_y, theta_z, timestamps, prev, y_timestamp, f, gyro_delay, gyro_drift)
output = cv2.perspectiveTransform(np.array([[pixel_x, pixel_y]], transform)
current_row_transformed.append(output)
pts.append(current_row)
pts_transformed.append(current_row_transformed)
...
这完成了meshwarp
的网格。现在我们只需要生成扭曲后的图像。这很简单,因为我们已经有了所需的方法:
o = meshwarp(src, pts_transformed)
return o
这样就完成了我们的变换。现在,滚动快门也被纳入了我们的去畸变中。
接下来是什么?
我们现在有一个非常基础的视频稳定化实现。你可以添加一些东西来使其更健壮、更自动化,并且输出更令人满意。以下是一些开始的方法。
识别陀螺仪轴
在本章中,我们硬编码了陀螺仪的轴。这可能并不适用于所有手机制造商。使用类似的校准技术,你应该能够找到一个最小化视频误差的轴配置。
估计滚动快门方向
我们已经硬编码了滚动快门的方向。使用特定的技术(比如在相机上快速闪烁 LED),可以估计滚动快门的方向并将其纳入校准代码。某些相机传感器根本不具有滚动快门的伪影。这个测试还可以确定是否使用了这样的传感器。
更平滑的时间流逝
现在我们已经稳定了视频,我们可以更好地加快(或减慢)视频的速度。有一些商业软件包执行类似任务——现在你的 OpenCV 代码也可以做到这一点!
校准参数仓库
你将不得不校准你遇到的每一种新设备类型。如果你从一个设备类型(比如,从三星 S5 切换到 iPhone 6),你将不得不为此组合的镜头和传感器运行校准。然而,在不同类型的同一设备之间移动不需要重新校准(比如,从一个 iPhone 6 移动到另一个)。如果你能收集足够的校准结果,你的代码几乎可以在任何设备上完美运行。
注意
如果仓库中没有所需的参数,你也可以想出一个后备机制。
翻译的整合
目前,我们只使用旋转。这意味着如果你在单一平面上摇动相机,算法不会做太多。通过使用加速度计的输入和使用关键点的平移,应该可以补偿平移。这将产生更高品质的视频。
额外的提示
在使用 Python 和计算机视觉时,以下是一些需要记住的额外事项。它们应该有助于加快你的工作并让你免受意外崩溃的影响!
使用 Python 的 pickle 模块
Python 为我们提供了一种巧妙的方法,可以将 Python 对象作为文件存储在磁盘上。在我们的项目中,我们有陀螺仪校准类。这个类存储了视频尺寸和关键点等信息,这些信息跨越不同的帧。每次你想测试你的代码时,从头开始计算这些信息是繁琐的。你可以轻松地将这个对象序列化到文件中,并在需要时读取回数据。
下面是我们代码中序列化视频对象的示例代码:
import pickle
fp = open("/path/to/file.data", "w")
videoObj = GyroVideo(mp4)
pickle.dump(videoObj, fp)
fp.close()
为了将对象读回脚本中:
import pickle
fp = open("/path/to/file.data", "r")
videoObj = pickle.load(fp)
fp.close()
这在迭代代码和验证是否按预期工作方面节省了时间。
输出单个图像
当处理视频时,你通常最终会使用类似 OpenCV 中的 VideoWriter 类。你给它提供帧,然后它输出一个视频文件。虽然这是一种完全有效的方法来完成工作,但如果你将单个帧写入磁盘,然后使用视频编码器将图像组合成视频流,你会拥有更多的控制权。
使用 ffmpeg
是合并多个图像的一个简单方法。
ffmpeg -i /tmp/image%04d.png -f image2 output.mp4
不使用差值进行测试
在项目中,我们试图稳定视频——因此我们计算实际陀螺仪信号与信号平滑版本之间的差值。
你可能想只用实际的陀螺仪信号来尝试;这将完全保持视频静止。这可能适用于你希望相机看起来完全静止的情况。
摘要
在这一章中,我们涵盖了相当多的内容:与陀螺仪通信、使用它来找到未知数、抵消相机抖动和滚动快门的影响。
我们首先创建了一个 Android 应用,该应用使用后台任务将媒体记录到视频文件中。在这个过程中,我们发现了如何扩展 OpenCV 的相机视图类以包含自定义 UI 和响应性。有了这个,你现在可以使用 OpenCV 后端创建非常复杂的 UI。此外,我们还捕获了陀螺仪轨迹并将其存储在一个单独的文件中。陀螺仪和媒体的采样率不同——然而,在这个阶段我们并不关心它。我们将让应用存储更高密度的陀螺仪轨迹(每几百微秒与每几十毫秒的媒体相比)。
一旦我们有了媒体/CSV 对,我们就使用 Python 和 numpy/scipy 库来校准系统。最初我们有三个未知数:相机的像素焦距、陀螺仪延迟(陀螺仪记录和媒体时间戳之间的偏移)以及陀螺仪漂移。
我们设计了一个错误函数,该函数接受预期的关键点和变换后的关键点,并返回错误量。然后我们使用这个函数来计算整个视频的错误。利用这个函数和 Scipy 的优化方法,我们能够找到这些未知数的值。这种校准对于每种设备类型只需要进行一次。
然后我们为校准添加了另一个参数——滚动快门。估计这个未知数的值与前面的三个类似,然而,引入去畸变有点棘手。我们不得不创建一个新的方法meshwarp
,它接受一个畸变的网格。这个方法校正网格并消除由于滚动快门产生的伪影。我们针对垂直滚动快门进行了工作,然而将其转换为水平滚动快门应该很容易。
我们触及了许多不同的领域:传感器、校准和几何畸变。我希望这一章能让你对设计自己的图像处理管道有所启发。