计算机视觉算法实用指南-全-
计算机视觉算法实用指南(全)
原文:
annas-archive.org/md5/96ae7a0eb0ade91db24b62767897b433译者:飞龙
前言
我们生活在一个非常激动人心的时代。每一天你都可以发现一个全新的应用或数字设备,它可以为你完成某些任务,娱乐你,让你与家人和朋友保持联系,以图片或视频的形式记录你的记忆,等等。这个列表可能永远也说不完。其中大部分应用或设备——无论是你口袋里的小型智能手机,还是你手腕上的小巧智能手表,或者是你的智能汽车——都得益于生产更便宜、更快的处理器的大幅发展,这些处理器拥有出色的框架和库,反过来又包含了高效执行人类曾经只能依赖自然传感器(如眼睛和耳朵)才能完成的任务的算法和技术。
计算机视觉是计算机科学领域的一个分支,它因硬件和软件技术的最新进步而发生了革命性的变化,作为回应,它也对这些硬件和软件技术产生了同样甚至更大的影响。如今,计算机视觉被用于执行从使用数码相机拍照这样简单的任务到理解自动驾驶汽车的环境以避免事故这样复杂的任务。它还被用于在视频录制中将你的脸变成兔子的形象,以及检测显微镜图像中的癌细胞和组织,这可以挽救生命。对于基于计算机视觉的应用或数字设备,其可能性几乎是无限的,尤其是在考虑到库和框架的巨大进步,这些库和框架是企业和技术人员将他们的想法变为现实的关键。
在过去的几年里,OpenCV,即开源计算机视觉库,已经变成了计算机视觉开发者的一整套工具。它几乎包含了实现你所能想到的大多数计算机视觉问题所需的一切,从对合适的计算机视觉库所期望的最基本操作,如调整图像大小和过滤图像,到使用机器学习训练模型,这些模型可用于准确和快速的对象检测。OpenCV 库包含了开发计算机视觉应用所需的所有内容,它还支持一些最流行的编程语言,如 C++和 Python。你甚至可以在.NET 框架等中找到 OpenCV 的绑定。OpenCV 几乎可以在你所能想到的任何主要操作系统上运行,无论是在移动平台还是桌面平台上。
本书的目标是通过一系列动手示例和样例项目,教会开发者使用 OpenCV 库开发计算机视觉应用。本书的每一章都包含与该章节涵盖主题相对应的多个计算机视觉算法,通过按顺序阅读所有章节,您将了解可用于您应用的广泛计算机视觉算法。本书中涵盖的大多数计算机视觉算法都是相互独立的;然而,强烈建议您从开头开始,并尝试根据章节顺序构建您的计算机视觉知识。之所以称之为“动手”书,是因为确保尝试本书中提供的每个示例——所有这些示例都足够有趣和令人兴奋,足以让您继续前进,并在学习过程中建立信心。
本书是数月辛勤工作的结果,没有 Tiksha Sarang 的无价帮助是不可能的,感谢她的耐心和出色的编辑;Adhithya Haridas,感谢他精确和有洞察力的技术审查和评论;Sandeep Mishra,感谢这个美好的机会;非常有帮助的技术审查员朱清亮先生;以及 Packt Publishing 的每一位帮助我创建和交付本书,并为开源和计算机视觉社区服务的人。
本书面向的对象
任何对 C++编程语言有扎实理解,并了解所选操作系统第三方库使用的开发者,会发现本书的主题非常容易理解和操作。另一方面,熟悉 Python 编程语言的开发者也可以使用本书来学习 OpenCV 库的使用;然而,他们需要自己将 C++示例转换为 Python,因为本书的主要关注点将是展示算法的 C++版本。
本书涵盖的内容
第一章,《计算机视觉简介》,概述了计算机视觉科学的基础——它是什么;在哪里使用;图像的定义及其基本属性,如像素、深度和通道等。这是一章完全的入门章节,旨在为那些对计算机视觉世界一无所知的人。
第二章,《OpenCV 入门》,介绍了 OpenCV 库并详细介绍了其核心,通过介绍 OpenCV 开发的最重要构建块。您还将了解到如何获取它以及如何使用它。本章还将简要介绍 CMake 的使用以及如何创建和构建 OpenCV 项目,之后您将学习到 Mat 类及其变体、读取和写入图像和视频以及访问相机(以及其他输入源类型)。
第三章,数组和矩阵运算,涵盖了用于创建或修改矩阵的基本算法。在本章中,你将学习如何执行矩阵运算,例如叉积、点积和求逆。本章还将介绍许多所谓的逐元素矩阵运算,以及如均值、总和和傅里叶变换等数学运算。
第四章,绘制、过滤和变换,尽可能在本书的范围内涵盖广泛的图像处理算法。本章将教你如何在图像上绘制形状和文本。你将学习如何绘制线条、箭头、矩形等。本章还将向你展示一系列用于图像过滤操作的算法,例如平滑滤波、膨胀、腐蚀和图像的形态学操作。到本章结束时,你将熟悉强大的重映射算法以及在计算机视觉中颜色图的用法。
第五章,反向投影和直方图,介绍了直方图的概念,并教你如何从单通道和多通道图像中计算它们。你将了解灰度图像和彩色图像的直方图可视化,或者换句话说,从像素的色调值计算出的直方图。在本章中,你还将学习关于反向投影图像的内容;即直方图提取的逆操作。本章还涵盖了直方图比较和直方图均衡化等主题。
第六章,视频分析 – 运动检测与跟踪,解释了如何使用计算机视觉中最受欢迎的跟踪算法来处理视频,特别是实时目标检测和跟踪操作。在简要介绍了一般如何处理视频之后,你将了解均值漂移和 CAM 漂移算法,以及卡尔曼滤波,通过实际例子和目标跟踪场景进行学习。到本章结束时,你还将了解背景和前景提取算法及其在实际中的应用。
第七章,目标检测 – 特征与描述符,首先简要介绍了使用模板匹配进行目标检测,然后继续教你关于广泛算法的内容,这些算法可用于形状分析。本章涵盖的主题还包括关键点检测链、描述符提取和描述符匹配,这些用于基于特征而不是简单的像素颜色或强度值进行目标检测。
第八章,计算机视觉中的机器学习,涵盖了 OpenCV 的机器学习(ML)和深度神经网络(DNN)模块以及一些最重要的算法、类和函数。从 SVM 算法开始,您将学习如何根据相似的训练组训练模型,然后使用该模型对输入数据进行分类。您将学习如何使用 HOG 描述符与 SVM 对图像进行分类。本章还涵盖了 OpenCV 中人工神经网络的实现,然后继续讲解级联分类。本章的最后部分将教授您如何使用来自第三方库(如 TensorFlow)的预训练模型实时检测不同类型的多个对象。
为了充分利用本书
虽然每个章节的初始部分都提到了每个章节所需的所有工具和软件,但以下是一个可以作为简单快速参考的列表:
-
安装了最新版本的 Windows、macOS 或 Linux 操作系统(如 Ubuntu)的普通计算机
-
Microsoft Visual Studio(在 Windows 上)
-
Xcode(在 macOS 上)
-
CMake
-
OpenCV
首先,为了了解现在的普通计算机是什么样子,您可以在网上搜索或询问当地商店;然而,您现有的计算机很可能已经足够您开始使用了。
此外,您选择的集成开发环境(IDE)或您使用的构建系统(在这种情况下为 CMake)与本书中提供的示例几乎无关。例如,只要您熟悉该 IDE 和构建系统 OpenCV 库的配置,您就可以使用本书中的确切相同的示例与任何代码编辑器或构建系统一起使用。
下载示例代码文件
您可以从 www.packtpub.com 的账户下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问 www.packtpub.com/support 并注册,以便将文件直接通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在 www.packtpub.com 登录或注册。
-
选择 SUPPORT 选项卡。
-
点击 Code Downloads & Errata。
-
在搜索框中输入本书的名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本解压缩或提取文件夹。
-
WinRAR/7-Zip for Windows
-
Zipeg/iZip/UnRarX for Mac
-
7-Zip/PeaZip for Linux
该书的代码包也托管在 GitHub 上,地址为 github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision。如果代码有更新,它将在现有的 GitHub 仓库中更新。
我们还有其他来自我们丰富图书和视频目录的代码包可供选择,请访问 github.com/PacktPublishing/。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/HandsOnAlgorithmsforComputerVision_ColorImages.pdf。
使用的约定
本书中使用了多种文本约定。
CodeInText:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“另一方面,要旋转图像,您可以使用rotate函数。”
代码块设置如下:
HistCompMethods method = HISTCMP_CORREL;
double result = compareHist(histogram1, histogram2, method);
任何命令行输入或输出都按以下方式编写:
pip install opencv-python
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“在我们开始形状分析和特征分析算法之前,我们将学习一种易于使用、功能强大的目标检测方法,称为模板匹配。”
警告或重要注意事项看起来像这样。
小技巧和窍门看起来像这样。
联系我们
我们始终欢迎读者的反馈。
一般反馈:请发送电子邮件至feedback@packtpub.com,并在邮件主题中提及书籍标题。如果您对本书的任何方面有疑问,请发送电子邮件至questions@packtpub.com。
勘误:尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,我们将非常感激您能向我们报告。请访问www.packtpub.com/submit-errata,选择您的书籍,点击勘误提交表单链接,并输入详细信息。
盗版:如果您在互联网上发现我们作品的任何非法副本,我们将非常感激您能提供位置地址或网站名称。请通过链接至材料的方式与我们联系至copyright@packtpub.com。
如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com。
评论
请留下评论。一旦您阅读并使用了这本书,为何不在您购买它的网站上留下评论呢?潜在读者可以查看并使用您的客观意见来做出购买决定,Packt 公司可以了解您对我们产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
有关 Packt 的更多信息,请访问 packtpub.com。
目录
-
标题页
-
版权和致谢
- 计算机视觉的动手算法
-
献词
-
Packt 升级销售
-
为什么订阅?
-
PacktPub.com
-
-
贡献者
-
关于作者
-
关于审稿人
-
Packt 正在寻找像你这样的作者
-
-
前言
-
本书面向对象
-
本书涵盖内容
-
充分利用本书
-
下载示例代码文件
-
下载彩色图像
-
使用的约定
-
-
取得联系
- 评论
-
-
计算机视觉简介
-
技术要求
-
理解计算机视觉
-
深入了解图像
-
颜色空间
-
输入、处理和输出
-
-
计算机视觉框架和库
-
摘要
-
问题
-
-
开始使用 OpenCV
-
技术要求
-
OpenCV 简介
- OpenCV 中的主要模块
-
下载和构建/安装 OpenCV
-
使用 C++ 或 Python 与 OpenCV
-
理解 Mat 类
-
构建 Mat 对象
-
删除 Mat 对象
-
访问像素
-
-
读取和写入图像
-
读取和写入视频
-
访问相机
-
访问 RTSP 和网络流
-
-
Mat-like 类
-
总结
-
问题
-
进一步阅读
-
-
数组和矩阵运算
-
技术要求
-
Mat 类中包含的操作
-
克隆矩阵
-
计算叉积
-
提取对角线
-
计算点积
-
了解单位矩阵
-
矩阵求逆
-
逐元素矩阵乘法
-
全一和全零矩阵
-
转置矩阵
-
重塑 Mat 对象
-
-
逐元素矩阵运算
-
基本操作
-
加法操作
-
加权加法
-
减法操作
-
乘法和除法操作
-
-
位运算逻辑操作
-
比较操作
-
数学运算
-
-
矩阵和数组运算
-
为外推制作边界
-
翻转(镜像)和旋转图像
-
处理通道
-
数学函数
-
矩阵求逆
-
元素的平均值和总和
-
离散傅里叶变换
-
生成随机数
-
-
搜索和定位功能
-
定位非零元素
-
定位最小和最大元素
-
查找表转换
-
-
-
总结
-
问题
-
-
绘制、过滤和转换
-
技术要求
-
在图像上绘制
-
在图像上打印文本
-
绘制形状
-
-
过滤图像
-
模糊/平滑滤波器
-
形态学滤波器
-
基于导数的滤波器
-
任意滤波
-
-
转换图像
-
阈值算法
-
颜色空间和类型转换
-
-
几何变换
-
应用色图
-
总结
-
问题
-
-
逆投影和直方图
-
技术要求
-
理解直方图
- 显示直方图
-
直方图的逆投影
- 了解更多关于逆投影的信息
-
比较直方图
-
直方图均衡化
-
总结
-
问题
-
进一步阅读
-
-
视频分析 - 运动检测和跟踪
-
技术要求
-
处理视频
-
理解均值漂移算法
-
使用连续自适应均值(CAM)变换
-
使用卡尔曼滤波进行跟踪和噪声降低
-
如何提取背景/前景
- 背景分割的示例
-
总结
-
问题
-
-
目标检测 - 特征和描述符
-
技术要求
-
目标检测中的模板匹配
-
检测角点和边缘
-
学习哈里斯角点检测算法
-
边缘检测算法
-
-
轮廓计算和分析
-
检测、描述和匹配特征
-
总结
-
问题
-
-
计算机视觉中的机器学习
-
技术要求
-
支持向量机
- 使用 SVM 和 HOG 进行图像分类
-
使用人工神经网络训练模型
-
级联分类算法
-
使用级联分类器进行目标检测
-
训练级联分类器
-
创建样本
-
创建分类器
-
-
-
使用深度学习模型
-
总结
-
问题
-
-
评估
-
第一章,计算机视觉简介
-
开始使用 OpenCV
-
第三章,数组和矩阵运算
-
第四章,绘图、过滤和变换
-
第五章,反向投影和直方图
-
第六章,视频分析 - 运动检测和跟踪
-
第七章,目标检测 - 特征和描述符
-
第八章,计算机视觉中的机器学习
-
-
您可能喜欢的其他书籍
- 留下评论 - 让其他读者了解您的想法
第一章:计算机视觉简介
毫无疑问,计算机科学,尤其是实现算法的方法,在近年来发展迅速。这得益于你的个人电脑甚至你口袋里的智能手机比它们的 predecessors 快得多,也便宜得多。受这种变化影响的最重要计算机科学领域之一是计算机视觉领域。近年来,计算机视觉算法的实现和使用方式发生了巨大的变化。本书,从这一章开始,旨在使用最新和最现代的技术来教授计算机视觉算法。
这旨在作为简要的入门章节,概述了将在许多(如果不是所有)计算机视觉算法中使用的概念基础。即使你已经熟悉计算机视觉和基础知识,如图像、像素、通道等,简要地浏览这一章也是一个好主意,以确保你理解计算机视觉的基本概念,并刷新你的记忆。
在本章中,我们将从计算机视觉领域的简要介绍开始。我们将探讨一些计算机视觉被广泛应用的最重要的行业,并举例说明。之后,我们将直接深入一些基本的计算机视觉概念,从图像开始。我们将学习在计算机视觉中图像是什么,以及它们的构建块是什么。在这个过程中,我们将涵盖像素、深度和通道等概念,这些对于理解和成功操作计算机视觉算法至关重要。
在本章结束时,你将了解以下内容:
-
什么是计算机视觉以及它在哪里被使用?
-
在计算机视觉中,图像是什么?
-
像素、深度和通道及其关系
技术要求
由于这是一个入门章节,我们只关注理论。因此,没有技术要求。
理解计算机视觉
定义计算机视觉不是一件容易的事情,计算机视觉专家在提供教科书定义时往往意见不一。这样做完全超出了本书的范围和兴趣,因此我们将专注于一个简单实用的定义,以满足我们的目的。从历史上看,计算机视觉与图像处理是同义的,本质上是指那些以图像为输入并基于该输入图像产生输出图像或一系列输出值(或测量值)的方法和技术,这些都是在执行一系列过程之后完成的。快进到现在,你会发现,当计算机视觉工程师谈论计算机视觉时,他们大多数情况下指的是与能够模仿人类视觉的概念相关的算法,例如在图像中看到(检测)物体或人。
那么,我们应该接受哪种定义呢?答案是相当简单的——两者都要。用简短的话来说,计算机视觉指的是以任何可想象的方式处理数字视觉数据(或任何可以可视化的数据)的算法、方法和技术。请注意,这里的视觉数据并不意味着只是使用传统相机拍摄的照片,但它们可能是地图上的图形表示或高程,热强度图,或任何可以无论其现实世界意义如何可视化的数据。
根据这个定义,以下所有问题——以及更多问题——都可以通过计算机视觉来解决:
-
我们如何使图像变柔和或变锐利?
-
我们如何减小图像的大小?
-
我们如何增加或减少图像的亮度?
-
我们如何检测图像中最亮的区域?
-
我们如何在视频中(或一系列连续的图像中)检测和跟踪人脸?
-
我们如何在安全摄像头的视频流中识别人脸?
-
我们如何在视频中检测运动?
在现代计算机视觉科学中,图像处理通常是计算机视觉方法和算法的一个子类别,涉及图像滤波、转换等。尽管如此,许多人还是将计算机视觉和图像处理这两个术语互换使用。
在这个时代,计算机视觉是计算机科学和软件行业中最热门的话题之一。其原因是它被用于各种方式,无论是使应用、数字设备或工业机器中的想法栩栩如生,还是处理或简化通常期望由人眼完成的广泛任务。我们提到的这些例子有很多实际应用,它们跨越了广泛的行业,包括汽车、电影、生物医学设备、国防、照片编辑和分享工具以及视频游戏行业。我们将讨论其中的一些例子,其余的留给你们去研究。
计算机视觉在汽车行业中持续使用,以提高现代车辆的安全性和功能性。车辆能够检测交通标志,警告驾驶员超速或甚至检测道路上的车道和障碍物,并通知驾驶员可能的危险。我们可以提供的关于计算机视觉如何使汽车行业现代化的实际例子是无穷无尽的——这还不包括自动驾驶汽车。主要科技公司正在投入大量资源,甚至与开源社区分享他们的一些成果。正如你在本书的最后一章中看到的,我们将利用其中的一些成果,特别是用于实时检测多种类型的多重对象。
下面的图像展示了汽车行业的一些常见物体、符号和感兴趣的区域,这些图像是通过安装在车辆上的摄像头看到的:

另一个即将迎来技术革命的行业是生物医学行业。不仅人体器官和身体部位的成像方法得到了极大的改进,而且这些图像的解释和可视化也通过计算机视觉算法得到了改善。计算机被用来在显微镜拍摄的图像中检测癌细胞组织,具有极高的精确度。还有来自能够进行手术的机器人的有希望和新兴的结果。
下面的图像是使用计算机视觉在组织扫描区域中计数特定类型的生物对象(在这种情况下是细胞)的示例,这些组织是通过数字显微镜扫描的:

除了汽车和生物医学行业,计算机视觉也被用于成千上万的移动和桌面应用程序中,以执行许多不同的任务。在你的智能手机上浏览在线应用商店,查看一些计算机视觉相关应用示例是个不错的主意。这样做,你将立即意识到,你与你的潜在计算机视觉应用想法之间,几乎只有想象力。
学习所有关于图像的知识
现在,是时候介绍计算机视觉的基础知识了,从图像开始。那么,究竟什么是图像呢?在计算机视觉中,图像只是一个矩阵,或者说是一个二维向量,具有有效的行数、列数等等。这种看待图像的方式不仅简化了对图像本身的描述,还简化了其所有组件的描述,这些组件如下:
-
图像的宽度对应于矩阵中的列数。
-
图像的高度是矩阵中的行数。
-
矩阵的每个元素代表一个像素,这是图像最基本的部分。图像是一系列像素的集合。
-
每个像素,或者矩阵中的每个元素,可以包含一个或多个与它的视觉表示(颜色、亮度等)相对应的数值。我们将在讨论计算机视觉中的颜色空间时了解更多这方面的内容。然而,重要的是要注意,与每个像素相关联的每个数值代表一个通道。例如,灰度图像中的像素通常使用一个介于 0 到 255 之间的单无符号 8 位整数值来表示;因此,灰度图像是单通道图像。在这种表示形式中,0 代表黑色,255 代表白色,而所有其他数字对应于灰度值。另一个例子是标准的 RGB 图像表示,其中每个像素由三个介于 0 到 255 之间的无符号 8 位整数值表示。RGB 图像中代表每个像素的三个通道对应于红色、蓝色和绿色的强度值,这三个值结合可以形成任何可能的颜色。这种图像被称为三通道图像。
以下图像展示了同一图像中同一区域的两个放大版本,一个是灰度格式,另一个是彩色(RGB)格式。注意灰度图像(左侧)中的较高值对应于较亮的值,反之亦然。同样,在彩色图像(右侧)中,你可以看到红色通道的值相当高,这与该区域的红色色调一致,以及白色通道:

除了我们之前提到的内容之外,图像还有一些额外的规格,具体如下:
-
每个像素,或者矩阵中的每个元素,可以是一个整数或浮点数。它可以是一个 8 位数字,16 位,等等。代表每个像素的数值类型以及通道数类似于图像的深度。例如,一个使用 16 位整数值来表示每个通道的四通道图像将具有 16 乘以 4 位的深度,或 64 位(或 4 字节)。
-
图像的分辨率指的是其中的像素数量。例如,宽度为 1920 像素、高度为 1080 像素(正如全高清图像的情况)的图像,其分辨率为 1920 乘以 1080,这略多于 200 万像素,或者说大约是 200 万像素。
正是因为这种图像表示形式,它才能被轻易地视为一个数学实体,这意味着可以设计出许多不同类型的算法来作用于图像。如果我们回到图像最简单的表示形式(灰度图像),通过几个简单的例子,我们可以看到大多数图片编辑软件(以及计算机视觉算法)都使用这种表示形式,以及相当简单的算法和矩阵运算来轻松地修改图像。在以下图像中,一个常数(在我们的例子中是 80)简单地加到输入图像(中间图像)的每个像素上,这使得结果图像变得更亮(右侧图像)。也可以从每个像素中减去一个数字,使结果图像变暗(左侧图像):

现在,我们将只关注计算机视觉的基本概念,而不会深入探讨前面图像修改示例的实现细节。我们将在接下来的章节中学习关于这一点以及许多其他图像处理技术和算法。
本节中提到的图像属性(宽度、高度、分辨率、深度和通道)在计算机视觉中得到广泛使用。例如,在几种情况下,如果一个图像处理算法过于复杂且耗时,那么可以将图像调整大小以使其更小,从而减少处理所需的时间。一旦处理完毕,结果可以映射回原始图像大小并显示给用户。同样的过程也适用于深度和通道。如果一个算法只需要图像的特定通道,你可以提取并单独处理它,或者使用图像的灰度转换版本。请注意,一旦对象检测算法完成其工作,你将希望将结果显示在原始彩色图像上。对这些类型的图像属性有正确的理解将极大地帮助你在面对各种计算机视觉问题和与计算机视觉算法一起工作时。不再赘述,让我们继续讨论色彩空间。
色彩空间
尽管其定义可能有所不同,但通常来说,色彩空间(有时也称为色彩模型)是一种用于解释、存储和重现一组色彩的方法。让我们用一个例子来分解这一点——灰度色彩空间。在灰度色彩空间中,每个像素用一个单一的 8 位无符号整数值表示,该值对应于该像素的亮度或灰度强度。这使得存储 256 种不同的灰度级别成为可能,其中 0 对应绝对黑色,255 对应绝对白色。换句话说,像素的值越高,它就越亮,反之亦然。以下图像显示了灰度色彩空间中存在的所有可能的颜色:

另一个常用的颜色空间是 RGB,其中每个像素由三个不同的 8 位整数值表示,这些值对应于该像素的红色、绿色和蓝色强度。这种颜色空间特别因其被用于电视、LCD 和类似显示器而闻名。你可以通过放大镜观察你的显示器表面来验证这一点。它依赖于这样一个简单的事实:所有颜色都可以通过组合不同量的红色、绿色和蓝色来表示。以下图像展示了三种主要颜色(如黄色或粉色)之间所有其他颜色是如何形成的:

一个在其每个单独像素中都具有相同的 R、G 和 B 值的 RGB 图像将产生一个灰度图像。换句话说,相同的红、绿、蓝强度将产生一种灰色。
另一个在计算机视觉中广泛使用的颜色空间是HSV(色调、饱和度和亮度)颜色空间。在这个颜色空间中,每个像素由三个值表示:色调(颜色)、饱和度(颜色的强度)和亮度(它是多亮或多暗)。如以下图像所示,色调可以是 0 到 360(度)之间的值,它代表该像素的颜色。例如,0 度和附近的度数对应于红色和其他类似颜色:

这种颜色空间在基于物体颜色的计算机视觉检测和跟踪算法中特别受欢迎,正如你将在本书后面的内容中看到的。原因在于 HSV 颜色空间允许我们无论颜色有多暗或多亮都能处理颜色。使用 RGB 和类似颜色空间则难以实现这一点,因为查看单个像素通道的值并不能告诉我们它的颜色。
以下图像是 HSV 颜色空间的另一种表示,它展示了在同一图像中色调(从左到右)、饱和度和亮度值的变化,从而产生所有可能的颜色:

除了本节中提到的颜色空间外,还有很多其他的颜色空间,每个都有其特定的应用场景。例如,四通道CMYK颜色空间(青色、朱红色、黄色和关键色/黑色)在印刷系统中已被证明是最有效的。
请确保从互联网上了解其他流行的颜色空间以及它们可能对任何特定的计算机视觉问题有何用途。
输入、处理和输出
因此,既然我们知道图像基本上是具有宽度、高度、元素类型、通道、深度等基本属性的矩阵式实体,那么唯一剩下的大问题就是它们从何而来,发生了什么,又将去向何方?
让我们用一个简单的相册应用程序为例来进一步分解这个问题。很可能,你的智能手机上默认就有这样一个应用程序。相册应用程序通常允许你使用智能手机内置的相机拍摄新照片或视频,使用之前录制的文件,对图像应用过滤器,甚至通过社交媒体、电子邮件或与你的朋友和家人分享。虽然当你使用它时这个例子可能看起来很简单,但它包含了正确计算机视觉应用程序的所有关键部分。
以这个例子为前提,我们可以这样说,图像是由各种不同的输入设备根据使用案例提供的。以下是一些最常见的图像输入设备:
-
存储在磁盘、内存、网络或任何其他可访问位置的图像文件。请注意,存储的图像文件可以是原始的(包含确切的图像数据)或编码的(如 JPG);然而,它们仍然被认为是图像文件。
-
由相机捕获的图像。请注意,这里的相机指的是个人电脑上的网络摄像头、智能手机上的相机或任何其他专业摄影设备、数字显微镜、望远镜等等。
-
存储在磁盘、内存、网络等位置的连续或非连续的视频帧。与图像文件类似,视频文件可以是编码的,在这种情况下,需要一种特殊的软件(称为编解码器)来在它们可以使用之前对它们进行编码。
-
来自实时视频摄像头流的连续帧。
使用输入设备读取图像后,实际的图像处理过程就开始了。这可能是你在本书中寻找的计算机视觉过程周期的一部分——而且有很好的理由。这是实际使用计算机视觉算法从图像中提取值、以某种方式修改它,或者执行任何类型的计算机视觉任务的地方。这部分通常由特定设备上的软件完成。
现在,整个过程的输出需要被创建。这部分完全取决于计算机视觉算法和计算机视觉过程运行的设备类型,但一般来说,计算机视觉算法期望以下类型的输出:
-
从处理后的图像中派生出的数字、形状、图表或其他非图像类型的输出。例如,一个计算图像中人数的算法只需要输出一个整数或一个表示从安全摄像头连续视频帧中找到的人数图表。
-
存储在磁盘、内存和类似设备上的图像或视频文件。一个典型的例子是你手机或个人电脑上的照片编辑软件,它允许你将修改后的图像保存为 JPG 或 PNG 文件。
-
在显示屏幕上绘制和渲染图像和视频帧。显示器通常由固件(可能位于操作系统上)控制,该固件控制显示在其上的内容。
与输入设备类似,对图像输出设备的略微不同解释将产生更多结果和条目(例如打印机、绘图仪、视频投影仪等)。然而,前面的列表仍然足够,因为它涵盖了我们在处理计算机视觉算法时将遇到的最基本和最重要的输出类型。
计算机视觉框架和库
为了构建计算机视觉应用程序,我们需要一套工具、一个框架或一个支持图像输入、输出和处理的库。选择计算机视觉库是一个非常重要的决定,因为您可能会发现自己需要完全自己“重新发明轮子”。您也可能编写占用大量资源和时间的函数和代码,例如以您所需的格式读取或写入图像。
通常,在开发计算机视觉应用程序时,您可以从两种主要的计算机视觉库中选择;它们如下:
-
专有: 专有计算机视觉库通常由提供它的公司提供良好的文档和支持,但它们需要付费,并且通常针对一组特定的计算机视觉问题
-
开源: 相反,开源库通常涵盖更广泛的与计算机视觉相关的问题,并且可以免费使用和探索
您可以在网上找到许多专有和开源计算机视觉库的示例,以便您自己进行比较。
在本书中我们将使用的库是开源计算机视觉库(OpenCV)。OpenCV 是一个具有以下特性的计算机视觉库:
-
它是开源的,并且可以免费用于学术或商业项目
-
它支持 C++、Python 和 Java 语言
-
它是跨平台的,这意味着它可以用于开发 Windows、macOS、Linux、Android 和 iOS 的应用程序
-
它以模块化方式构建,速度快,文档齐全,支持良好
值得注意的是,OpenCV 还使用一些第三方库来处理各种计算机视觉任务。例如,FFmpeg 库在 OpenCV 中被用于处理读取某些视频文件格式。
摘要
在本章中,我们介绍了计算机视觉科学的最基本概念。我们首先学习了计算机视觉作为一个术语及其用例,然后查看了一些广泛使用它的行业。然后,我们继续学习图像及其最重要的属性,即像素、分辨率、通道、深度等。然后,我们讨论了一些最广泛使用的颜色空间,并学习了它们如何影响图像的通道数和其他属性。之后,我们介绍了计算机视觉中常用的输入和输出设备,以及计算机视觉算法和过程如何在这两者之间进行。我们以对计算机视觉库的非常简短的讨论结束本章,并介绍了我们选择的计算机视觉库,即 OpenCV。
在下一章中,我们将介绍 OpenCV 框架,并开始一些动手的计算机视觉课程。我们将学习如何使用 OpenCV 访问输入设备,执行计算机视觉算法,以及访问输出设备以显示或记录结果。下一章将是本书中的第一个真正的动手章节,并为后续更实用的章节奠定基础。
问题
-
除了本章中提到的行业外,还有哪些行业可以从计算机视觉中获得显著的好处?
-
举例说明一个用于安全目的的计算机视觉应用?(考虑一个你可能没有遇到的应用想法。)
-
举例说明一个用于提高生产力的计算机视觉应用?(再次,考虑一个你可能没有遇到,但怀疑可能存在的应用想法。)
-
存储一个 1920 x 1080 像素、四通道、32 位深度的图像需要多少兆字节?
-
超高清图像,也称为 4K 或 8K 图像,在当今相当普遍,但超高清图像包含多少百万像素?
-
除了本章中提到的颜色空间外,还有哪些常用的颜色空间?
-
将 OpenCV 库与 MATLAB 中的计算机视觉工具进行比较。每个工具的优缺点是什么?
第二章:开始使用 OpenCV
在上一章中,我们介绍了计算机视觉的基础知识,并展示了某些行业如何广泛使用它来改善他们的服务和产品。然后,我们学习了该领域最基本的概念,例如图像和像素。我们了解了色彩空间,并在本章结束时简要讨论了计算机视觉库和框架。我们将从上次结束的地方继续,即向您介绍最强大和最广泛使用的计算机视觉库之一,称为OpenCV。
OpenCV 是一个庞大的类、函数、模块和其他相关资源的集合,用于构建跨平台的计算机视觉应用程序。在本章中,我们将了解 OpenCV 的结构,它包含的模块及其用途,以及它支持的编程语言。我们将学习如何获取 OpenCV,并简要介绍您可以使用它来构建应用程序的可能工具。然后,我们将学习如何使用 CMake 的强大功能轻松创建使用 OpenCV 的项目。尽管这意味着我们的主要焦点将是 C++类和函数,但 wherever it makes sense,我们也会涵盖它们的 Python 等价物,以便熟悉这两种语言的开发者可以跟随本章中介绍的主题。
在了解了使用 OpenCV 库的初始阶段之后,我们将继续学习Mat类。我们将看到上一章中关于图像的所有概念是如何嵌入到 OpenCV 中Mat类的结构中的。我们还将讨论与Mat类兼容(或与之密切相关)的其他各种类。OpenCV 在函数中处理输入和输出参数的方法是本章后面部分将要讨论的一个重要主题。最后,我们将学习如何使用 OpenCV 在计算机视觉应用程序中应用输入、处理和输出的三个步骤。这需要学习使用 OpenCV 访问(并将数据写入)图像和视频文件。
本章作为对上一章入门章节的直接衔接,将阐述使用动手和实践示例学习计算机视觉算法的基础。
在本章中,我们将探讨以下内容:
-
OpenCV 是什么,在哪里可以获取它,以及如何使用它?
-
如何使用 CMake 创建 OpenCV 项目?
-
理解
Mat类及其如何用于访问像素 -
如何使用
Mat_、Matx和UMat类? -
如何使用
imread和imwrite函数读取和写入图像? -
如何使用
VideoCapture和VideoWriter类读取和写入视频? -
如何通过网络(使用实时流协议(RTSP))访问摄像头和视频流?
技术要求
-
Microsoft Visual Studio、Xcode 或任何可以用于开发 C++程序的 IDE
-
Visual Studio Code 或任何其他可以用来编辑 CMake 文件、Python 源文件等的代码编辑器
-
Python 3.X
-
CMake 3.X
-
OpenCV 3.X
尝试使用您正在尝试学习的最新版本的技术和软件总是最好的。本书涵盖的主题,以及计算机视觉总体来说,也不例外,所以请确保下载并安装所提及软件的最新版本。
在必要时,提供了一组简短的安装和配置说明。您可以使用以下 URL 下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter02
OpenCV 简介
OpenCV,或开源计算机视觉,是一组包含构建计算机视觉应用程序所需的类和函数的库、工具和模块。OpenCV 库被全球的计算机视觉开发者下载了数百万次,它速度快,经过优化,适用于现实生活中的项目(包括商业项目)。截至本书编写时,OpenCV 的最新版本是 3.4.1,这也是本书所有示例中使用的版本。OpenCV 支持 C/C++、Python 和 Java 语言,并且可以用于构建适用于桌面和移动操作系统的计算机视觉应用程序,包括 Windows、Linux、macOS、Android 和 iOS。
需要注意的是,OpenCV 库和 OpenCV 框架都用来指代 OpenCV,在计算机视觉社区中,这两个术语大多数时候是互换使用的。出于同样的原因,我们在这本书中也将互换使用这些术语。然而,严格来说,框架通常是指用于实现共同目标的关联库和工具集的术语,例如 OpenCV。
OpenCV 由以下两种类型的模块组成:
-
主要模块:这些模块默认包含在 OpenCV 发布版本中,它们包含了所有核心 OpenCV 功能以及用于图像处理任务、过滤、转换等更多功能的模块,我们将在本节中讨论这些功能。
-
额外模块:这些模块包括所有默认不包含在 OpenCV 库中的 OpenCV 功能,它们主要包含额外的计算机视觉相关功能。例如,额外模块包括用于文本识别和非自由特征检测器的库。请注意,我们的重点是主要模块,并涵盖其中的功能,但 wherever 可能有所帮助,我们也会尝试提及额外模块中的可能选项,供您自行研究。
OpenCV 中的主要模块
如前所述,OpenCV 包含多个主模块,其中包含其所有核心和默认功能。以下是这些模块的列表:
-
core: 此模块包含所有核心 OpenCV 功能。例如,所有基本结构,包括Mat类(我们将在后面详细学习)和矩阵运算,都是嵌入到这个模块中的功能之一。 -
imgproc: 此模块包含所有图像处理功能,如滤波、变换和直方图。 -
imgcodecs: 此模块包括用于读取和写入图像的函数。 -
videoio: 此模块与imgcodecs模块类似,但根据其名称,它用于处理视频。 -
highgui: 本书将广泛使用此模块,它包含用于显示结果和创建 GUI 的所有功能。请注意,尽管highgui模块对于本书的目的以及在学习计算机视觉算法的同时可视化结果来说已经足够,但它并不适用于全面的应用。请参阅本章末尾的进一步阅读部分,以获取更多关于用于全面计算机视觉应用的正确 GUI 创建工具的参考资料。 -
video: 包含 OpenCV 的视频分析功能,如运动检测和跟踪、卡尔曼滤波以及臭名昭著的 CAM Shift 算法(用于对象跟踪)。 -
calib3d: 此模块包括校准和 3D 重建功能。此模块能力的知名示例是两个图像之间变换的估计。 -
features2d: 此模块包含支持的关键点检测和描述符提取算法。正如我们将在即将到来的章节中学习的那样,此模块包含一些最广泛使用的对象检测和分类算法。 -
objdetect: 如其名所示,此模块用于使用 OpenCV 进行对象检测。我们将在本书的最后一章学习这个模块包含的功能。 -
dnn: 与objdetect模块类似,此模块也用于对象检测和分类等目的。dnn模块在 OpenCV 主模块列表中相对较新,它包含了与深度学习相关的所有功能。 -
ml: 此机器学习模块包含用于处理分类和回归的类和函数。简单来说,所有严格相关的机器学习功能都包含在这个模块中。 -
flann: 这是 OpenCV 对快速近似最近邻库(FLANN)的接口。FLANN 包含一套广泛的优化算法,用于处理大型数据集中高维特征的最近邻搜索。这里提到的算法大多与其他模块中的算法结合使用,例如features2d。 -
photo:这是一个有趣的模块,用于处理与摄影相关的计算机视觉任务,它包含用于处理去噪、HDR 成像以及使用其邻域恢复照片区域的类和函数。 -
stitching:此模块包含用于图像拼接的类和函数。请注意,拼接本身是一个非常复杂的任务,它需要旋转估计和图像扭曲等功能,所有这些也都是这个非常有趣的 OpenCV 模块的一部分。 -
shape:此模块用于处理形状变换、匹配和距离相关主题。 -
superres:属于分辨率增强类别的算法包含在超分辨率模块中。 -
videostab:此模块包含用于视频稳定的算法。 -
viz:也称为 3D 可视化模块,它包含用于在 3D 可视化窗口上显示小部件的类和函数。此模块不会成为本书讨论的主题之一,但我们只是提一下。
除了我们刚刚提到的模块之外,OpenCV 还包含一些基于 CUDA(由 Nvidia 创建的 API)的主模块。这些模块很容易通过其名称区分,名称以单词 cuda 开头。由于这些模块的可用性完全取决于特定类型的硬件,并且几乎所有这些模块中的功能都由其他模块以某种方式覆盖,我们现在将跳过它们。但值得注意的是,如果您需要的算法已经实现在这些模块中,并且您的硬件满足它们的最小要求,那么使用 OpenCV 的 cuda 模块可以显著提高您应用程序的性能。
下载和构建/安装 OpenCV
OpenCV 大部分没有预构建和可直接使用的版本(本节中我们将讨论一些例外),类似于大多数开源库,它需要从源代码进行配置和构建。在本节中,我们将简要描述如何在计算机上构建(和安装)OpenCV。但首先,您需要将 OpenCV 源代码获取到您的计算机上。您可以使用以下链接进行此操作:
在本页面上,您可以找到 OpenCV 的发布版本。截至本书编写时,最新版本是 3.4.1,因此您应该下载它,或者如果有更高版本,则直接使用那个版本。
如以下截图所示,对于 OpenCV 的每个发布版本,都有各种可下载条目,例如 Win、iOS 和 Android 套件,但您应该下载源代码并根据自己的平台自行构建 OpenCV:

OpenCV 3.4.1 默认提供 Android、iOS 和 64 位 MSVC14 和 MSVC15(与 Microsoft Visual C++ 2015 和 Microsoft Visual C++ 2017 相同)库的预构建版本。因此,如果您想为这些平台中的任何一个构建应用程序,您可以下载相关的包并完全跳过 OpenCV 的构建过程。
要从源代码构建 OpenCV,您需要在您的计算机上安装以下工具:
-
支持 C++11 的 C/C++ 编译器:在 Windows 上,这意味着任何较新的 Microsoft Visual C++ 编译器,例如 MSVC15(2017)或 MSVC14(2015)。在 Linux 操作系统上,您可以使用最新的 GCC,而在 macOS 上,您可以使用包含所有必需工具的 Xcode 命令行工具。
-
CMake:确保您使用最新版本的 CMake,例如 3.10,以确保与较新版本的 OpenCV 安全兼容,尽管您可以使用 CMake 3.1 及以后的版本。
-
Python:如果您打算使用 Python 编程语言,这一点尤为重要。
OpenCV 包含大量工具和库,您可以通过多种方式自定义您的构建。例如,您可以使用 Qt 框架、Intel 线程构建块(TBB)、Intel 集成性能原语(IPP)和其他第三方库来进一步增强和自定义您的 OpenCV 构建,但由于我们将使用默认设置和工具集使用 OpenCV,所以我们忽略了上述第三方工具的要求列表。
在获取我们刚才提到的所有先决条件后,您可以通过使用 CMake 和相应的编译器配置和构建 OpenCV,具体取决于您的操作系统和所需的平台。
以下截图显示了具有默认配置集的 CMake 工具。通常,除非您想对 OpenCV 的构建应用自己的自定义设置,否则您不需要对配置进行任何更改:

注意,当首次打开 CMake 时,您需要设置源代码文件夹和构建文件夹,分别如前述截图所示,即“源代码在哪里:”和“在哪里构建二进制文件:”。点击“配置”按钮后,您需要设置一个生成器并应用设置,然后按“生成”。
生成后,您只需使用终端或命令提示符实例切换到 CMake 输出文件夹,并执行以下命令:
make
make install
请注意,运行这些命令中的每一个可能需要一些时间,具体取决于您的计算机速度和配置。另外,请注意,make 命令可能因您打算使用的工具集而异。例如,如果您使用 Microsoft Visual Studio,那么您需要将 make 替换为 nmake,或者如果您使用 MinGW,那么您必须将 make 替换为 mingw32-make。
在构建过程完成后,你可以开始使用 OpenCV。你需要注意的是配置你的 C++ 项目,以便它们可以使用你的 OpenCV 库和安装。
在 Windows 操作系统上,你需要确保你正在构建的应用程序可以访问 OpenCV DLL 文件。这可以通过将所有必需的 DLL 文件复制到与你的应用程序构建相同的文件夹中,或者简单地通过将 OpenCV DLL 文件的路径添加到 PATH 环境变量中来实现。在继续之前,请务必注意这一点,否则即使你的应用程序构建成功且在编译时没有报告任何问题,它们在执行时也可能会崩溃。
如果你打算使用 Python 来构建计算机视觉应用程序,那么事情对你来说将会非常简单,因为你可以使用 pip(包管理器)来安装 Python 的 OpenCV,使用以下命令:
pip install opencv-python
这将自动获取最新的 OpenCV 版本及其所有依赖项(如 numpy),或者如果你已经安装了 OpenCV,你可以使用以下命令来确保它升级到最新版本:
pip install --upgrade opencv-python
不言而喻,你需要一个正常工作的互联网连接才能使这些命令生效。
使用 OpenCV 与 C++ 或 Python
在本节中,我们将通过一个非常简单的示例来展示你如何在 C++ 或 Python 项目中使用 OpenCV,我们将这个示例称为 HelloOpenCV。你可能已经知道,这样的项目的目的是以下之一:
-
要开始使用一个全新的库,例如你之前从未使用过的 OpenCV
-
为了确保你的 OpenCV 安装是功能性的并且运行良好
所以,即使你不是 OpenCV 的初学者,仍然值得阅读以下说明并运行本节中的简单示例来测试你的 OpenCV 编译或安装。
我们将开始使用 OpenCV 的必要步骤,在 C++ 项目中:
-
创建一个名为
HelloOpenCV的新文件夹 -
在这个文件夹内创建两个新的文本文件,并将它们命名为
CMakeLists.txt和main.cpp -
确保文件
CMakeLists.txt包含以下内容:
cmake_minimum_required(VERSION 3.1)
project(HelloOpenCV)
set(OpenCV_DIR "path_to_opencv")
find_package(OpenCV REQUIRED)
include_directories(${OpenCV_INCLUDE_DIRS})
add_executable(${PROJECT_NAME} "main.cpp")
target_link_libraries(${PROJECT_NAME} ${OpenCV_LIBS})
在前面的代码中,你需要将 "path_to_opencv" 替换为包含 OpenCVConfig.cmake 和 OpenCVConfig-version.cmake 文件的文件夹路径,这个文件夹是你安装 OpenCV 库的地方。如果你使用的是 Linux 操作系统并且使用了预构建的 OpenCV 库,你可能不需要 OpenCV 文件夹的精确路径。
- 至于
main.cpp文件,请确保它包含以下内容,这是我们将会运行的实际的 C++ 代码:
#include <iostream>
#include <opencv2/opencv.hpp>
using namespace std;
using namespace cv;
int main()
{
Mat image = imread("MyImage.png");
if(!image.empty())
{
imshow("image", image);
waitKey();
}
else
{
cout << "Empty image!" << endl;
}
return 0;
}
我们将在本节和即将到来的章节中逐一介绍前面代码中使用的函数,然而,目前值得注意的是,这个程序正在尝试打开并显示存储在磁盘上的图像。如果成功,图像将一直显示,直到按下任意键,否则将显示Empty image!消息。请注意,在正常情况下,这个程序不应该崩溃,并且应该能够成功构建。所以,如果你遇到相反的情况,那么你需要回顾本章前面讨论的主题。
- 我们的项目已经准备好了。现在,我们可以使用 CMake 来生成 Visual Studio 或其他任何我们想要的类型的项目(取决于我们将要使用的平台、编译器和 IDE),然后构建并运行它。请注意,CMake 只是用来确保创建了一个跨平台且与 IDE 无关的 C++项目。
通过运行此示例项目,你的输入图像(在本例中为MyImage.png)将被读取并显示,直到按下键盘上的任意键。如果在读取图像的过程中出现任何问题,则将显示Empty image!消息。
我们可以通过以下代码在 Python 中创建和运行相同的项目:
import cv2
image = cv2.imread("MyImage.png")
if image is not None :
cv2.imshow("image", image)
cv2.waitKey()
else:
print("Empty image!")
在这里,相似之处非常明显。与 Python 版本中的相同代码完全相同的imshow和waitKey函数也被使用。正如之前提到的,现在不要担心任何函数的确切使用方式,只需确保你能够运行这些程序,无论是 C++还是 Python,或者两者都可以,并且能够看到显示的图像。
如果你能够成功运行本节中的HelloOpenCV示例项目,那么你就可以毫无问题地继续学习本章的下一节以及本书的下一章。如果你在讨论的主题上仍然遇到问题,或者你觉得你需要对这些主题有更深入的理解,你可以从章节的开始重新学习它们,或者更好的是,你可以参考本章末尾的“进一步阅读”部分中提到的附加书籍。
理解Mat类
请参考上一章中提供的计算机视觉中图像的描述,以及任何图像实际上都是一个具有给定宽度、高度、通道数和深度的矩阵。带着这个描述,我们可以说 OpenCV 的Mat类可以用来处理图像数据,并且它支持图像所需的所有属性,如宽度和高度。实际上,Mat类是一个 n 维数组,可以用来存储具有任何给定数据类型的单通道或多通道数据,并且它包含许多成员和方法,可以以多种方式创建、修改或操作它。
在本节中,我们将通过示例用例和代码示例来学习Mat类的一些最重要的成员和方法。
OpenCV C++ Mat 类在 Python 中的等效物最初不是 OpenCV 类,它由 numpy.ndarray 类型表示。NumPy 是一个 Python 库,包含广泛的数值算法和数学运算,并支持处理大型多维数组和矩阵。Python 中的 numpy.ndarray 类型被用作 Mat 的原因是因为它提供了最佳(如果不是相同的)成员和方法集,这些成员和方法是 OpenCV Mat 类在 C++ 中所需的。有关 numpy.ndarray 支持的成员和方法的完整列表,请参阅 NumPy 文档。
构建 Mat 对象
Mat 包含大约 20 个不同的构造函数,可以根据所需的初始化方式创建其实例。让我们看看一些最常用的构造函数和一些示例。
创建一个宽度为 1920 和高度为 1080,包含三个通道且包含 32 位浮点值的 Mat 对象(或类实例)如下所示:
Mat image(1080, 1920, CV_32FC3);
注意,Mat 构造函数中的 type 参数接受一种特殊类型的参数,即包含深度、类型和通道数的常量值。模式如下所示:
CV_<depth><type>C<channels>
<depth> 可以替换为 8、16、32 或 64,这代表用于存储每个像素中每个元素的位数。每个像素实际需要的位数可以通过将此数字乘以通道数来计算,或者换句话说,<channels>。最后,<type> 需要替换为 U、S 或 F,分别代表无符号整数、有符号整数和浮点值。例如,你可以使用以下方式创建宽度为 800 和高度为 600 像素的标准灰度和彩色图像。请注意,只有通道数不同,深度和类型参数代表 8 位无符号整数:
Mat grayscaleImage(600, 800, CV_8UC1);
Mat colorImage(600, 800, CV_8UC3);
你可以使用以下构造函数创建一个宽度为 W、高度为 H、包含 8 位无符号整数元素的三个通道 RGB 图像,并初始化所有元素为 R、G 和 B 颜色值:
int W = 800, H = 600, R = 50, G = 150, B = 200;
Mat image(H, W, CV_8UC3, Scalar(R, G, B));
重要的是要注意,OpenCV 中颜色的默认顺序是 BGR(而不是 RGB),这意味着交换了 B 和 R 值。如果我们希望在应用程序运行时某点显示处理后的图像,这一点尤为重要。
因此,前述代码中标量初始化器的正确方式如下:
Scalar(B, G, R)
如果我们需要更高维度的 Mat 对象,可以使用以下方法。注意,在以下示例中,创建了一个七维度的 Mat 对象。每个维度的尺寸由 sizes 数组提供,并且高维 Mat 中的每个元素,称为 hdm,包含两个 32 位浮点值通道:
const int dimensions = 7;
const int sizes[dimensions] = {800, 600, 3, 2, 1, 1, 1};
Mat hdm(7, sizes, CV_32FC2);
另一种实现相同功能的方法是使用 C++ 向量,如下所示:
vector<int> sizes = {800, 600, 3, 2, 1, 1, 1};
Mat hdm(sizes, CV_32FC2);
同样,你可以提供一个额外的Scalar参数来初始化Mat中的所有值。请注意,Scalar中的值的数量必须与通道数相匹配。例如,为了初始化前面提到的七维Mat中的所有元素,我们可以使用以下构造函数:
Mat hdm(sizes, CV_32FC2, Scalar(1.25, 3.5));
Mat类允许我们使用已存储的图像数据来初始化它。使用此构造函数,你可以使你的Mat类包含data指针指向的数据。请注意,此构造函数不会创建原始数据的完整副本,它只使新创建的Mat对象指向它。这允许非常高效地初始化和构建Mat类,但显然的缺点是在不需要时没有处理内存清理,因此在使用此构造函数时需要格外小心:
Mat image(1080, 1920, CV_8UC3, data);
注意,与之前的构造函数及其初始化器不同,这里的data不是一个Scalar,而是一个指向包含1920 x 1080像素,三通道图像数据的内存块的指针。使用指向内存空间的指针初始化Mat对象的方法也可以用于Mat类的更高维度。
最后一种构造函数类型,也是Mat类最重要的构造函数之一,是感兴趣区域(ROI)构造函数。此构造函数用于使用另一个Mat对象内的区域初始化Mat对象。让我们用一个例子来分解这一点。想象你有一张图片,你想要对该图片中的特定区域进行一些修改,换句话说,就是对 ROI 进行修改。你可以使用以下构造函数创建一个可以访问 ROI 的Mat类,并且对其所做的任何更改都将影响原始图像的相同区域。以下是你可以这样做的步骤:
Mat roi(image, Rect(240, 140, 300, 300));
如果在image(它本身是一个Mat对象)包含以下图像左侧的图片时使用前面的构造函数,那么roi将能够访问该图像中突出显示的区域,并且它将包含右侧看到的图像:

OpenCV 中的Rect类用于表示具有左上角点、宽度和高度的矩形。例如,前面代码示例中使用的Rect类具有左上角点为240和140,宽度为300像素,高度为300像素,如下所示:
Rect(240, 140, 300, 300)
如前所述,以任何方式修改 ROI 将导致原始图像被修改。例如,我们可以将以下类似图像处理算法应用于roi(现在不必担心以下算法的性质,因为我们将在接下来的章节中了解更多关于它的内容,只需关注 ROI 的概念):
dilate(roi, roi, Mat(), Point(-1,-1), 5);
如果我们尝试显示图像,结果将与以下类似。注意,在先前的图像中突出显示的区域在以下图像中已修改(膨胀),尽管我们是在roi上应用了更改,而不是图像本身:

与使用对应于图像矩形区域的Rect类构造 ROI Mat对象类似,你也可以创建一个对应于原始Mat对象中列和行的 ROI。例如,先前的例子中的相同区域也可以使用以下构造函数中看到的范围进行访问:
Mat roi(image, Range(140, 440), Range(240, 540));
OpenCV 中的Range类表示一个具有start和end的区间。根据start和end的值,可以检查Range类是否为空。在先前的构造函数中,第一个Range类对应于原始图像的行,从第140行到第440行。第二个Range对应于原始图像的列,从第240列到第540列。提供的两个区间的交集被认为是最终的 ROI。
删除 Mat 对象
可以通过使用其release函数来清理Mat对象,然而,由于release函数在Mat类的析构函数中被调用,通常没有必要调用此函数。需要注意的是,Mat类在多个指向它的对象之间共享相同的数据。这具有减少数据复制和内存使用的优势,并且由于所有引用计数都是自动完成的,你通常不需要关心任何事情。
在需要特别注意如何以及何时清理你的对象和数据的情况下,你需要格外小心,这种情况发生在你使用数据指针构造Mat对象时,如前所述。在这种情况下,调用Mat类的release函数或其析构函数将与用于构造它的外部数据无关,清理将完全由你负责。
访问像素
除了使用 ROI 访问图像矩形区域的像素,如前几节所述,还有一些其他方法可以实现相同的目标,甚至可以访问图像的个别像素。要能够访问图像中的任何单个像素(换句话说,一个Mat对象),你可以使用at函数,如下例所示:
image.at<TYPE>(R, C)
在前面的示例中,使用 at 函数时,TYPE 必须替换为一个有效的类型名,该类型名必须符合图像的通道数和深度。R 必须替换为像素的行号,而 C 替换为像素的列号,我们想要访问的像素。请注意,这与许多库中常用的像素访问方法略有不同,其中第一个参数是 X(或左),第二个参数是 Y(或顶)。所以基本上,参数在这里是颠倒的。以下是一些访问不同类型 Mat 对象中单个像素的示例。
按如下方式访问具有 8 位整数元素的单一通道 Mat 对象中的像素(灰度图像):
image.at<uchar>(R, C)
按如下方式访问具有浮点元素的单一通道 Mat 对象中的像素:
image.at<float>(R, C)
按如下方式访问具有 8 位整数元素的三个通道 Mat 对象中的像素:
image.at<Vec3b>(R, C)
在前面的代码中,使用了 Vec3b(3 字节向量)类型。OpenCV 定义了各种类似的向量类型以方便使用。以下是您可以使用 at 函数或用于其他目的的 OpenCV Vec 类型模式:
Vec<N><Type>
<N> 可以替换为 2、3、4、6 或 8(在 1 的情况下可以省略)并且它对应于 Mat 对象中的通道数。另一方面,<Type> 可以是以下之一,它们代表了每个像素每个通道中存储的数据类型:
-
b代表uchar(无符号字符) -
s代表short(有符号字) -
w代表ushort(无符号字) -
i代表int -
f代表float -
d代表double
例如,Vec4b 可以用来访问具有 uchar 元素的四个通道 Mat 对象的像素,而 Vec6f 可以用来访问具有 float 元素的六个通道 Mat 对象的像素。重要的是要注意,Vec 类型可以像数组一样处理以访问单个通道。以下是一个如何使用 uchar 元素访问三个通道 Mat 对象的第二个通道的示例:
image.at<Vec3b>(R, C)[1]
重要的是要注意,我们所说的“访问”既包括读取和写入像素及其各个通道。例如,以下示例是应用棕褐色滤镜到图像的一种方法:
for(int i=0; i<image.rows; i++)
{
for(int j=0; j<image.cols; j++)
{
int inputBlue = image.at<Vec3b>(i,j)[0];
int inputGreen = image.at<Vec3b>(i,j)[1];
int inputRed = image.at<Vec3b>(i,j)[2];
int red =
inputRed * 0.393 +
inputGreen * 0.769 +
inputBlue * 0.189;
if(red > 255 ) red = 255;
int green =
inputRed * 0.349 +
inputGreen * 0.686 +
inputBlue * 0.168;
if(green > 255) green = 255;
int blue =
inputRed * 0.272 +
inputGreen * 0.534 +
inputBlue * 0.131;
if(blue > 255) blue = 255;
image.at<Vec3b>(i,j)[0] = blue;
image.at<Vec3b>(i,j)[1] = green;
image.at<Vec3b>(i,j)[2] = red;
}
}
首先,这里需要注意的几点是图像的 rows 和 cols 成员,它们基本上代表了图像中的行数(或高度)和列数(或宽度)。同时注意 at 函数是如何被用来提取通道值和将更新后的值写入其中的。关于示例中用于乘法以获得正确棕褐色调的值,不必担心,因为它们是针对色调本身特定的,并且基本上任何类型的操作都可以应用于单个像素以改变它们。
以下图像展示了将前面的示例代码应用于三通道彩色图像的结果(左——原始图像,右——过滤后的图像):

访问图像中的像素的另一种方法是使用 Mat 类的 forEach 函数。forEach 可以用来并行地对所有像素应用操作,而不是逐个遍历它们。以下是一个简单的示例,展示了如何使用 forEach 将所有像素的值除以 5,如果它在灰度图像上执行,这将导致图像变暗:
image.forEach<uchar>([](uchar &p, const int *)
{
p /= 5;
});
在前面的代码中,第二个参数,或位置参数(这里不需要,因此省略)是像素位置的指针。
使用之前的 for 循环,我们需要编写以下代码:
for(int i=0; i<image.rows; i++)
for(int j=0; j<image.cols; j++)
image.at<uchar>(i,j) /= 5;
OpenCV 还允许使用类似于 STL 的迭代器来访问或修改图像中的单个像素。以下是一个使用类似于 STL 的迭代器编写的相同示例:
MatIterator_<uchar> it_begin = image.begin<uchar>();
MatIterator_<uchar> it_end = image.end<uchar>();
for( ; it_begin != it_end; it_begin++)
{
*it_begin /= 5;
}
值得注意的是,在前三个示例中的相同操作也可以通过以下简单的语句来完成:
image /= 5;
这是因为 OpenCV 中的 Mat 对象将此语句视为逐元素除法操作,我们将在接下来的章节中了解更多关于它的内容。以下图像展示了将前面的示例应用于灰度图像的结果(左——原始图像,右——修改后的图像):

显然,forEach、C++ 的 for 循环和类似于 STL 的迭代器都可以用来访问和修改 Mat 对象内的像素。我们将满足本节讨论的 Mat 类的函数和成员,但请确保探索它提供的用于以高效方式处理图像及其底层属性的庞大功能集。
读取和写入图像
OpenCV 允许使用 imread 函数从磁盘读取图像到 Mat 对象,我们在本章的前一个示例中简要使用过该函数。imread 函数接受一个输入图像文件名和一个 flag 参数,并返回一个填充有输入图像的 Mat 对象。输入图像文件必须具有 OpenCV 支持的图像格式之一。以下是一些最受欢迎的支持格式:
-
Windows 位图:
*.bmp,*.dib -
JPEG 文件:
*.jpeg,*.jpg,*.jpe -
便携式网络图形:
*.png -
便携式图像格式:
*.pbm,*.pgm,*.ppm,*.pxm,*.pnm -
TIFF 文件:
*.tiff,*.tif
请确保始终检查 OpenCV 文档以获取完整和更新的列表,特别是对于可能适用于某些操作系统上某些格式的异常情况和注意事项。至于
flag 参数,它可以是 ImreadModes 枚举中的一个值或其组合,该枚举在 OpenCV 中定义。以下是一些最广泛使用且易于理解的条目:
-
IMREAD_UNCHANGED -
IMREAD_GRAYSCALE -
IMREAD_COLOR -
IMREAD_IGNORE_ORIENTATION
例如,以下代码可以用来从磁盘读取图像,而不读取图像 EXIF 数据中存储的朝向值,并将其转换为灰度:
Mat image = imread("MyImage.png",
IMREAD_GRAYSCALE | IMREAD_IGNORE_ORIENTATION);
可交换图像文件格式 (EXIF) 是一种标准,用于为数字相机拍摄的照片添加标签和附加数据(或元数据)。这些标签可能包括制造商和相机型号以及拍照时相机的方向。OpenCV 能够读取某些标签(如方向)并对其进行解释,或者在前面示例代码的情况下,忽略它们。
在读取图像后,你可以调用 empty 来查看它是否成功读取。你也可以使用 channels 来获取通道数,depth 来获取深度,type 来获取图像类型,等等。或者,你可以调用 imshow 函数来显示它,就像我们在本章前面看到的那样。
类似地,imreadmulti 函数可以用来将多页图像读取到 Mat 对象的向量中。这里明显的区别是 imreadmulti 返回一个 bool 值,可以用来检查页面的成功读取,并通过引用填充传递给它的 vector<Mat> 对象。
要将图像写入磁盘上的文件,你可以使用 imwrite 函数。imwrite 函数接受要写入的文件名、一个 Mat 对象以及包含写入参数的 int 值 vector,在默认参数的情况下可以忽略。请参阅 OpenCV 中的以下枚举,以获取使用 imwrite 函数可以使用的完整参数列表,以改变写入过程的行为:
-
ImwriteFlags -
ImwriteEXRTypeFlags -
ImwritePNGFlags -
ImwritePAMFlags
以下是一个示例代码,展示了如何使用 imwrite 函数将 Mat 对象写入磁盘上的图像文件。请注意,图像的格式是从提供的扩展名派生出来的,在这种情况下是 png:
bool success = imwrite("c:/my_images/image1.png", image);
cout << (success ?
"Image was saved successfully!"
:
"Image could not be saved!")
<< endl;
除了 imread 和 imwrite 函数,这些函数用于从磁盘上的图像文件中读取和写入图像外,你还可以使用 imdecode 和 imencode 函数来读取存储在内存缓冲区中的图像或向其写入。我们将这两个函数留给你去发现,并继续下一个主题,即使用 OpenCV 访问视频。
读取和写入视频
OpenCV,使用其 videoio 模块或更确切地说,使用 VideoCapture 和 VideoWriter 类,允许我们读取和写入视频文件。在视频的情况下,明显的区别是它们包含一系列连续的图像(或者更好的说法,帧),而不是单个图像。因此,它们通常在一个循环中读取和处理或写入,该循环覆盖视频中的全部或任何所需数量的帧。让我们从以下示例代码开始,展示如何使用 OpenCV 的 VideoCapture 类读取和播放视频:
VideoCapture vid("MyVideo.mov");
// check if video file was opened correctly
if(!vid.isOpened())
{
cout << "Can't read the video file";
return -1;
}
// get frame rate per second of the video file
double fps = vid.get(CAP_PROP_FPS);
if(fps == 0)
{
cout << "Can't get video FPS";
return -1;
}
// required delay between frames in milliseconds
int delay_ms = 1000.0 / fps;
// infinite loop
while(true)
{
Mat frame;
vid >> frame;
if(frame.empty())
break;
// process the frame if necessary ...
// display the frame
imshow("Video", frame);
// stop playing if space is pressed
if(waitKey(delay_ms) == ' ')
break;
}
// release the video file
vid.release();
如前述代码所示,视频文件名在构造 VideoCapture 类时传递。如果文件存在且您的计算机(和 OpenCV)支持该格式,则会自动打开视频文件。因此,您可以使用 isOpened 函数检查视频文件是否成功打开。之后,使用 VideoCapture 类的 get 函数检索已打开的视频文件的 每秒帧率(FPS)。get 是 VideoCapture 的一个极其重要的函数,它允许我们检索打开的视频文件的广泛属性。以下是 get 函数可以提供的示例参数,以获取所需的结果:
-
CAP_PROP_POS_FRAMES: 要解码或捕获的下一个帧的基于 0 的索引 -
CAP_PROP_FRAME_WIDTH: 视频流中帧的宽度 -
CAP_PROP_FRAME_HEIGHT: 视频流中帧的高度 -
CAP_PROP_FPS: 视频的帧率 -
CAP_PROP_FRAME_COUNT: 视频文件中的帧数
要获取完整的列表,您可以参考 OpenCV 中 VideoCaptureProperties 枚举文档。回到前面的示例代码,在通过 get 函数检索到帧率之后,它被用来计算两个帧之间所需的延迟,以便在播放时不会太快或太慢。然后,在一个无限循环中,使用 >> 操作符读取帧并显示。请注意,这个操作符实际上是使用 VideoCapture 函数(如 read、grab 和 retrieve)的简化和便捷方式。我们已经熟悉了 imshow 函数及其用法。另一方面,waitKey 函数的使用方式与之前略有不同,它可以用来插入延迟并等待按键。在这种情况下,之前计算出的所需延迟(以毫秒为单位)被插入到显示的帧之间,如果按下空格键,循环将中断。最后的 release 函数基本上是自我解释的。
除了我们使用 VideoCapture 类及其方法的方式之外,我们还可以调用其 open 函数来打开视频文件,如果我们不想将文件名传递给构造函数,或者如果视频文件在 VideoCapture 构造时不存在。VideoCapture 的另一个重要功能是 set 函数。将 set 视为 get 函数的完全相反,因为它允许设置 VideoCapture 和打开的视频文件的参数。尝试使用之前在 VideoCaptureProperties 枚举中提到的不同参数进行实验。
要能够写入视频文件,您可以使用与 VideoCapture 类非常类似的方式使用 VideoWriter 类。以下是一个示例,展示了如何创建 VideoWriter 对象:
VideoWriter wrt("C:/output.avi",
VideoWriter::fourcc('M','J','P','G'),
30, Size(1920, 1080));
这将在"C:/output.avi"创建一个视频文件,分辨率为1920 x 1080像素,每秒30帧,准备好填充帧。但什么是fourcc?四字符代码(FourCC)简单地说是一个四字节的代码,表示(或更准确地说,是编解码器)将要用于记录视频文件的格式。在这个例子中,我们使用了一个最常见的 FourCC 值,但你可以在网上查找更全面的 FourCC 值及其规格列表。
在创建VideoWriter对象之后,你可以使用<<运算符或write函数将图像(与视频大小完全相同)写入视频文件:
wrt << image;
或者你也可以使用以下代码:
vid.write(frame);
最后,你可以调用release函数以确保视频文件被释放,并且所有更改都写入其中。
除了上述使用VideoCapture和VideoWriter类的方法之外,你还可以设置它们所使用的首选后端。有关更多信息,请参阅 OpenCV 文档中的VideoCaptureAPIs枚举。如果省略,如我们示例中所示,则使用计算机支持的默认后端。
访问摄像头
OpenCV 支持通过使用与访问视频文件相同的VideoCapture类来访问系统上可用的摄像头。唯一的区别是,你不需要将文件名传递给VideoCapture类的构造函数或其 open 函数,你必须提供一个对应于每个可用摄像头的基于 0 的索引号。例如,计算机上的默认摄像头可以通过以下示例代码访问和显示:
VideoCapture cam(0);
// check if camera was opened correctly
if(!cam.isOpened())
return -1;
// infinite loop
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
// process the frame if necessary ...
// display the frame
imshow("Camera", frame);
// stop camera if space is pressed
if(waitKey(10) == ' ')
break;
}
cam.release();
如你所见,唯一的区别在于构造函数。这个VideoCapture类的实现允许用户以相同的方式处理任何类型的视频源,因此处理摄像头而不是视频文件时几乎可以编写相同的代码。这与下一节中描述的网络流的情况相同。
访问 RTSP 和网络流
OpenCV 允许用户从网络流中读取视频帧,或者更确切地说,从位于网络上的 RTSP 流中读取,例如本地网络或甚至互联网。要能够做到这一点,你需要将 RTSP 流的 URL 传递给VideoCapture构造函数或其 open 函数,就像它是一个本地硬盘上的文件一样。以下是最常见的模式和可以使用的示例 URL:
rtsp://user:password@website.com/somevideo
在这个 URL 中,user被替换为实际的用户名,password为该用户的密码,依此类推。如果网络流不需要用户名和密码,它们可以被省略。
类似于 Mat 的类
除了Mat类之外,OpenCV 还提供了一些其他与Mat非常相似的类,但它们的使用方式和时机不同。以下是可以替代或与Mat类一起使用的最重要的Mat类似类:
-
Mat_:这是Mat类的一个子类,但它提供了比at函数更好的访问方法,即使用().Mat_是一个模板类,显然需要在编译时提供元素的类型,这是Mat类本身可以避免的。 -
Matx:这最适合用于尺寸较小的矩阵,或者更准确地说,在编译时已知且尺寸较小的矩阵。 -
UMat:这是Mat类的一个较新实现,它允许我们使用 OpenCL 进行更快的矩阵操作。
使用UMat可以显著提高您计算机视觉应用程序的性能,但由于它与Mat类使用方式完全相同,因此在本书的章节中我们将忽略它;然而,在实际应用中,尤其是在实时计算机视觉应用中,您必须始终确保使用更优化、性能更好的类和函数,例如UMat。
摘要
我们已经涵盖了所有关键主题,这些主题使我们能够通过实际示例和真实场景轻松地掌握计算机视觉算法。我们本章从学习 OpenCV 及其整体结构开始,包括其模块和主要构建块。这帮助我们获得了我们将要工作的计算机视觉库的视角,但更重要的是,这让我们对处理计算机视觉算法时可能发生的情况有了概述。然后,我们学习了在哪里以及如何获取 OpenCV,以及如何在我们的系统上安装或构建它。我们还学习了如何创建、构建和运行使用 OpenCV 库的 C++和 Python 项目。然后,通过学习所有关于Mat类以及处理图像中的像素,我们学习了如何修改和显示图像。本章的最后部分包括了我们需要了解的所有关于从磁盘上的文件读取和写入图像的知识,无论是单页(或多页)图像还是视频文件,以及摄像头和网络流。我们通过学习 OpenCV Mat 家族中的一些其他类型来结束本章,这些类型可以帮助提高我们的应用程序。
现在我们已经了解了图像的真实本质(即它本质上是一个矩阵),我们可以从矩阵及其类似实体的可能操作开始。在下一章中,我们将学习所有属于计算机视觉领域的矩阵和数组操作。到下一章结束时,我们将能够使用 OpenCV 执行大量的像素级和图像级操作和转换。
问题
-
列出三个额外的 OpenCV 模块及其用途。
-
启用
BUILD_opencv_world标志构建 OpenCV 3 会有什么影响? -
使用本章中描述的 ROI 像素访问方法,我们如何构建一个
Mat类,使其能够访问中间像素,以及另一个图像中所有相邻的像素(即中间的九个像素)? -
除了本章中提到的之外,请说出
Mat类的另一种像素访问方法。 -
仅使用
at方法和for循环编写一个程序,该程序创建三个单独的颜色图像,每个图像只包含从磁盘读取的 RGB 图像的一个通道。 -
使用类似 STL 的迭代器,计算灰度图像的平均像素值。
-
编写一个使用
VideoCapture、waitKey和imwrite的程序,当按下 S 键时显示您的网络摄像头并保存可见图像。如果按下空格键,此程序将停止网络摄像头并退出。
进一步阅读
-
使用 OpenCV 3 和 Qt5 进行计算机视觉:
www.packtpub.com/application-development/computer-vision-opencv-3-and-qt5 -
Qt5 项目:
www.packtpub.com/application-development/qt-5-projects
第三章:数组和矩阵操作
现在我们已经完成了本书的第一部分,以及计算机视觉的介绍和基本概念,我们可以开始学习 OpenCV 库提供的计算机视觉算法和函数,这些算法和函数几乎涵盖了所有可以想到的计算机视觉主题,并且有优化的实现。正如我们在前面的章节中学到的,OpenCV 使用模块化结构来分类其中包含的计算机视觉功能。我们将带着类似的思路继续本书的主题,这样学到的技能在每一章中都是相互关联的,不仅从理论角度来看,而且从实践角度来看。
在上一章中,我们学习了图像和矩阵之间的关系,并介绍了Mat类最关键的功能,例如使用给定的宽度、高度和类型来构建它。我们还学习了如何从磁盘、网络流、视频文件或摄像头中读取图像。在这个过程中,我们学习了如何使用各种方法访问图像中的像素。现在,我们可以开始实际进行图像和像素的修改和操作功能了。
在这个章节中,我们将学习大量用于处理图像的函数和算法,这些算法要么用于计算可能在其他过程中有用的值,要么用于直接修改图像中像素的值。本章中展示的几乎所有算法都是基于这样一个事实:图像本质上是由矩阵组成的,而且矩阵是通过数据数组实现的,因此本章的名称就是这样!
我们将从这个章节开始,介绍Mat类本身的功能,这些功能虽然不多,但在创建初始矩阵等方面却非常重要。然后,我们将继续学习大量的按元素(或逐元素)算法。通过许多实际案例的学习,我们将了解到这些算法对矩阵的每个单独元素执行特定操作,并且它们不关心任何其他元素(或像素)。最后,我们将学习那些不是逐元素操作的矩阵和数组操作,其结果可能取决于整个图像或元素组。随着我们在这个章节中继续学习算法,这一切都将变得清晰。需要注意的是,这个章节中所有的算法和函数都包含在 OpenCV 库的核心模块中。
到这个章节结束时,你将更好地理解以下内容:
-
Mat类中包含的操作 -
逐元素矩阵操作
-
矩阵和数组操作
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章,OpenCV 入门。
您可以使用以下网址下载本章的源代码和示例:github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter03
Mat 类包含的操作
在本节中,我们将介绍 Mat 类本身包含的数学和其他操作集。尽管 Mat 类中的函数没有通用的使用模式,但它们大多数都与创建新矩阵有关,无论是使用现有的矩阵还是从头开始创建。所以,让我们开始吧。
在本书的整个过程中,单词图像、矩阵、Mat 类等将可以互换使用,并且它们都表示相同的意思,除非有明确的说明。利用这个机会,习惯于像计算机视觉专家那样从矩阵的角度思考图像。
克隆矩阵
您可以使用 Mat::clone 来创建一个完全独立的 Mat 对象的克隆。请注意,此函数创建了一个完整的图像副本,并在内存中为其分配了空间。以下是它的用法:
Mat clone = image.clone();
您也可以使用 copyTo 函数来完成相同的功能,如下所示:
Mat clone;
image.copyTo(clone);
在前面的两个代码示例中,image 是在执行克隆操作之前从图像、摄像头或以任何可能的方式产生的原始矩阵(或图像)。从现在开始,在本章和即将到来的所有章节中,除非另有说明,image 简单地是一个 Mat 对象,它是我们操作的数据源。
计算叉积
您可以使用 Mat::cross 来计算具有三个浮点元素的两个 Mat 对象的叉积,如下面的示例所示:
Mat A(1, 1, CV_32FC3),
B(1, 1, CV_32FC3);
A.at<Vec3f>(0, 0)[0] = 0;
A.at<Vec3f>(0, 0)[1] = 1;
A.at<Vec3f>(0, 0)[2] = 2;
B.at<Vec3f>(0, 0)[0] = 3;
B.at<Vec3f>(0, 0)[1] = 4;
B.at<Vec3f>(0, 0)[2] = 5;
Mat AxB = A.cross(B);
Mat BxA = B.cross(A);
显然,在两个向量的叉积中,AxB 与 BxA 是不同的。
提取对角线
Mat::diag 可以用来从一个 Mat 对象中提取对角线,如下面的示例所示:
int D = 0; // or +1, +2, -1, -2 and so on
Mat dg = image.diag(D);
此函数接受一个索引参数,可以用来提取主对角线以外的其他对角线,如下面的图所示:

如果 D=0,则提取的对角线将包含 1、6、11 和 16,这是主对角线。但根据 D 的值,提取的对角线将位于主对角线之上或之下,如前图所示。
计算点积
要计算两个矩阵的点积、标量积或内积,您可以使用 Mat::dot 函数,如下所示:
double result = A.dot(B);
在这里,A 和 B 都是 OpenCV 的 Mat 对象。
学习单位矩阵
使用 OpenCV 中的 Mat::eye 函数创建单位矩阵。以下是一个示例:
Mat id = Mat::eye(10, 10, CV_32F);
如果你需要一个不同于单位矩阵对角线上的值,你可以使用一个 scale 参数:
double scale = 0.25;
Mat id = Mat::eye(10, 10, CV_32F) * scale;
创建单位矩阵的另一种方法是使用 setIdentity 函数。请确保查看 OpenCV 文档以获取有关此函数的更多信息。
矩阵求逆
你可以使用 Mat::inv 函数来求逆一个矩阵:
Mat inverted = m.inv();
注意,你可以向 inv 函数提供一个矩阵分解类型,这可以是 cv::DecompTypes 枚举中的一个条目。
元素级矩阵乘法
Mat::mul 可以用来执行两个 Mat 对象的元素级乘法。不用说,此函数也可以用于元素级除法。以下是一个示例:
Mat result = A.mul(B);
你还可以提供一个额外的 scale 参数,该参数将用于缩放结果。以下是一个另一个示例:
double scale = 0.75;
Mat result = A.mul(B, scale);
全 1 和全 0 矩阵
Mat::ones 和 Mat::zeroes 可以用来创建一个给定大小的矩阵,其中所有元素分别设置为 1 或 0。这些矩阵通常用于创建初始化矩阵。以下是一些示例:
Mat m1 = Mat::zeroes(240, 320, CV_8UC1);
Mat m2 = Mat::ones(240, 320, CV_8UC1);
如果你需要创建一个填充了除了 1 以外的值的矩阵,你可以使用以下类似的方法:
Mat white = Mat::ones(240, 320, CV_8UC1) * 255;
转置矩阵
你可以使用 Mat::t 来转置一个矩阵。以下是一个示例:
Mat transpose = image.t();
以下是一个演示图像转置的示例:

左侧的图像是原始图像,右侧的图像是原始图像的转置。正如你将在本章后面学到的那样,transpose 或 Mat::t 函数可以与 flip 函数结合使用,以在所有可能的方向上旋转或翻转/镜像图像。
转置一个转置矩阵与原始矩阵相同,运行以下代码将得到原始图像本身:
Mat org = image.t().t();
计算矩阵转置的另一种方法是使用 transpose 函数。以下是如何使用此函数的示例:
transpose(mat, trp);
在这里,mat 和 trp 都是 Mat 对象。
调整 Mat 对象的形状
可以使用 Mat::reshape 函数调整 Mat 对象的形状。请注意,在这个意义上,调整形状意味着改变图像的通道数和行数。以下是一个示例:
int ch = 1;
int rows = 200;
Mat rshpd = image.reshape(ch, rows);
注意,将通道数设置为 0 的值意味着通道数将与源保持相同。同样,将行数设置为 0 的值意味着图像的行数将保持不变。
注意,Mat::resize是另一个用于重塑矩阵的有用函数,但它只允许改变图像中的行数。在重塑矩阵或处理矩阵中的元素数量时,另一个有用的函数是Mat::total函数,它返回图像中的元素总数。
关于Mat类本身嵌入的功能就到这里。请确保阅读Mat类的文档,并熟悉你在这节中学到的方法的可能变体。
逐元素矩阵操作
元素级或逐元素矩阵操作是计算机视觉中的数学函数和算法,它们作用于矩阵的各个单独元素,换句话说,就是图像的像素。需要注意的是,逐元素操作可以并行化,这意味着矩阵元素的处理顺序并不重要。这个特性是本节和本章后续部分中函数和算法之间最重要的区别。
基本操作
OpenCV 提供了所有必要的函数和重载运算符,用于执行两个矩阵或一个矩阵与一个标量之间的所有四个基本操作:加法、减法、乘法和除法。
加法操作
add函数和+运算符可以用来添加两个矩阵的元素,或者一个矩阵和一个标量,如下面的例子所示:
Mat image = imread("Test.png");
Mat overlay = imread("Overlay.png");
Mat result;
add(image, overlay, result);
你可以将前面代码中的最后一行替换为以下代码:
result = image + overlay;
以下图片展示了两个图像加法操作的结果图像:

如果你想将单个标量值添加到Mat对象的全部元素中,你可以简单地使用以下类似的方法:
result = image + 80;
如果前面的代码在灰度图像上执行,结果会比源图像更亮。注意,如果图像有三个通道,你必须使用一个三项向量而不是单个值。例如,为了使 RGB 图像更亮,你可以使用以下代码:
result = image + Vec3b(80, 80, 80);
下面是一张图片,展示了当在它上执行前面的代码时,得到的更亮的结果图像:

在前面的示例代码中,只需简单地增加加数值,就可以得到更亮的图像。
加权加法
除了简单的两个图像相加外,你还可以使用加权加法函数来考虑被加的两个图像的权重。将其视为在add操作中为每个参与者设置不透明度级别。要执行加权加法,你可以使用addWeighted函数:
double alpha = 1.0; // First image weight
double beta = 0.30; // Second image weight
double gamma = 0.0; // Added to the sum
addWeighted(image, alpha, overlay, beta, gamma, result);
如果在上一节中的示例图片上执行加法操作,结果将类似于以下内容:

注意到与通常由照片编辑应用程序应用的水印类似的透明文本。注意代码中关于 alpha、beta 和 gamma 值的注释?显然,提供一个 beta 值为 1.0 将会使此示例与没有任何透明度的常规 add 函数完全相同。
减法运算
与将两个 Mat 对象相加类似,你也可以使用 subtract 函数或 - 运算符从一幅图像中减去另一幅图像的所有元素。以下是一个示例:
Mat image = imread("Test.png");
Mat overlay = imread("Overlay.png");
Mat result;
subtract(image, overlay, result);
前面代码中的最后一行也可以替换为以下内容:
result = image - overlay;
如果我们使用前面示例中的相同两幅图像进行减法运算,以下是结果:

注意从源图像中减去较高像素值(较亮像素)的结果会导致叠加文本的暗色。还要注意,减法运算依赖于其操作数的顺序,这与加法不同。尝试交换操作数,看看会发生什么。
就像加法一样,你也可以将一个常数与图像的所有像素相乘。你可以猜到,从所有像素中减去一个常数将导致图像变暗(取决于减去的值),这与加法运算相反。以下是一个使用简单的减法运算使图像变暗的示例:
result = image - 80;
如果源图像是一个三通道 RGB 图像,你需要使用一个向量作为第二个操作数:
result = image - Vec3b(80, 80, 80);
乘法和除法运算
与加法和减法类似,你也可以将一个 Mat 对象的所有元素与另一个 Mat 对象的所有元素相乘。同样,也可以进行除法运算。再次强调,这两种运算都可以使用矩阵和标量进行。乘法可以使用 OpenCV 的 multiply 函数(类似于 Mat::mul 函数)进行,而除法可以使用 divide 函数进行。
这里有一些示例:
double scale = 1.25;
multiply(imageA, imageB, result1, scale);
divide(imageA, imageB, result2, scale);
前面的代码中的 scale 是可以提供给 multiply 和 divide 函数的附加参数,用于缩放结果 Mat 对象中的所有元素。你还可以像以下示例中那样使用标量进行乘法或除法运算:
resultBrighter = image * 5;
resultDarker = image / 5;
显然,前面的代码将生成两个图像,一个比原始图像亮五倍,另一个比原始图像暗五倍。这里需要注意的是,与加法和减法不同,生成的图像不会均匀地变亮或变暗,你会注意到较亮区域变得非常亮,反之亦然。显然,这是乘法和除法运算的效果,在这些运算中,较亮像素的值在运算后增长或下降的速度比较小的值快得多。值得注意的是,这种相同的技术在大多数照片编辑应用程序中用于调整图像的亮暗区域。
位运算逻辑
就像基本操作一样,您也可以对两个矩阵的所有元素或一个矩阵和一个标量的所有元素执行位逻辑运算。因此,您可以使用以下函数:
-
bitwise_not -
bitwise_and -
bitwise_or -
bitwise_xor
从它们的名字就可以立即看出,这些函数可以执行 Not、And、Or 和 Exclusive OR 操作,但让我们通过一些实际示例来详细看看它们是如何使用的:
首先,bitwise_not 函数用于反转图像中所有像素的所有位。此函数与大多数照片编辑应用中可以找到的翻转操作具有相同的效果。以下是它的使用方法:
bitwise_not(image, result);
上述代码也可以替换为以下代码,它使用了 C++ 中的重载位运算 not 操作符 (~):
result = ~image;
如果图像是单色黑白图像,结果将包含一个所有白色像素被替换为黑色,反之亦然的图像。如果图像是 RGB 颜色图像,结果将是反转的(在二进制像素值的意义上),以下是一个示例图像:

bitwise_and 函数,或 & 操作符,用于对两个图像的像素或一个图像和一个标量的像素执行位运算 And。以下是一个示例:
bitwise_and(image, mask, result);
您可以直接使用 & 操作符,并写成以下内容:
result = image & mask;
bitwise_and 函数可以很容易地用来遮罩和提取图像中的某些区域。例如,以下图像展示了 bitwise_and 如何导致一个只通过白色像素并移除黑色像素的图像:

除了遮罩图像的某些区域外,位运算 And 还可以用来完全过滤掉一个通道。要执行此操作,您需要使用 & 操作符的第二种形式,它接受一个矩阵和一个标量,并对所有像素和该值执行 And 运算。以下是一个示例代码,可以用来遮罩(置零)RGB 颜色图像中的绿色颜色通道:
result = image & Vec3b(0xFF, 0x00, 0xFF);
现在,让我们继续到下一个位运算,即 Or 运算。bitwise_or 和 | 操作符都可以用来对两个图像或一个图像和一个标量执行位运算 Or。以下是一个示例:
bitwise_or(image, mask, result);
与位运算 And 类似,您可以在 Or 运算中使用 | 操作符,并简单地写成以下代码代替前面的代码:
result = image | mask;
如果使用 And 运算来通过非零像素(或非黑色像素),那么可以说 Or 运算用于通过任何输入图像中的像素值较高的像素(或较亮的像素)。以下是执行位运算 Or 的前例图像的结果:

与位运算的 And 操作类似,你也可以使用位运算的 Or 操作来更新单个通道或图像的所有像素。以下是一个示例代码,展示了如何仅更新 RGB 图像中的绿色通道,使其所有像素的值达到最大可能值(即 255,或十六进制 FF),而其他通道保持不变:
result = image | Vec3b(0x00, 0xFF, 0x00);
最后,你可以使用 bitwise_xor 或 ^ 操作符在两个图像的像素之间,或图像与标量之间执行 Exclusive Or 操作。以下是一个示例:
bitwise_xor(image, mask, result);
或者简单地使用 ^ 操作符,并写成以下内容:
result = image ^ mask;
如果在上一节中的示例图像上执行 Exclusive Or 操作,以下是结果图像:

注意这个操作如何导致掩码区域的像素反转?通过在纸上写下像素值并尝试自己计算结果来思考这个原因。如果清楚地理解其行为,Exclusive Or 以及所有位运算都可以用于许多其他计算机视觉任务。
比较操作
比较两个图像(或给定值)非常有用,特别是用于生成可以在各种其他算法中使用的掩码,无论是用于跟踪图像中的某个感兴趣对象,还是在图像的孤立(掩码)区域执行操作。OpenCV 提供了一些函数来执行逐元素比较。例如,compare 函数可以用于比较两个图像。以下是方法:
compare(image1, image2, result, CMP_EQ);
前两个参数是参与比较的第一个和第二个图像。result 将保存到第三个 Mat 对象中,最后一个参数必须是从 CmpTypes 枚举中的一项,用于选择比较类型,可以是以下任何一种:
-
CMP_EQ:表示第一个图像等于第二个图像 -
CMP_GT:表示第一个图像大于第二个图像 -
CMP_GE:表示第一个图像大于或等于第二个图像 -
CMP_LT:表示第一个图像小于第二个图像 -
CMP_LE:表示第一个图像小于或等于第二个图像 -
CMP_NE:表示第一个图像不等于第二个图像
注意,我们仍在讨论逐元素操作,所以当我们说“第一个图像小于或等于第二个图像”时,我们实际上是指“第一个图像中的每个单独像素的值小于或等于第二个图像中其对应像素的值”,依此类推。
注意,你还可以使用重载的 C++ 操作符来实现与 compare 函数相同的目标。以下是每个单独比较类型的方法:
result = image1 == image2; // CMP_EQ
result = image1 > image2; // CMP_GT
result = image1 >= image2; // CMP_GE
result = image1 < image2; // CMP_LT
result = image1 <= image2; // CMP_LE
result = image1 != image2; // CMP_NE
inRange 函数是 OpenCV 中的另一个有用的比较函数,可以用来找到具有特定下限和上限值的像素。你可以使用任何现有的图像作为边界值矩阵,或者你可以自己创建它们。以下是一个示例代码,可以用来在灰度图像中找到介于 0 和 50 之间的像素值:
Mat lb = Mat::zeros(image.rows,
image.cols,
image.type());
Mat hb = Mat::ones(image.rows,
image.cols,
image.type()) * 50;
inRange(image, lb, hb, result);
注意,lb 和 hb 都是与源图像大小和类型相同的 Mat 对象,除了 lb 被填充为零,而 hb 被填充为 50 的值。这样,当调用 inRange 时,它会检查源图像中的每个像素及其对应的 lb 和 hb 中的像素,如果值在提供的边界之间,则将结果中的对应像素设置为白色。
以下图像展示了在示例图像上执行 inRange 函数的结果:

min 和 max 函数,从它们的名字可以轻易猜出,是另外两个可以用来比较两张图像(逐元素)并找出最小或最大像素值的比较函数。以下是一个示例:
min(image1, image2, result);
或者你可以使用 max 来找到最大值:
max(image1, image2, result);
简单来说,这两个函数比较了相同大小和类型的两张图像的像素,并将结果矩阵中对应的像素设置为输入图像中的最小或最大像素值。
数学运算
除了我们之前学过的函数之外,OpenCV 还提供了一些处理逐元素数学运算的函数。在本节中,我们将简要介绍它们,但你可以,也应该亲自实验它们,以确保你熟悉它们,并且可以在你的项目中舒适地使用它们。
OpenCV 中的逐元素数学函数如下:
- 可以使用
absdiff函数来计算相同大小和类型的两张图像或图像和标量的像素之间的绝对差值。以下是一个示例:
absdiff(image1, image2, result);
在前面的代码中,image1、image2 和 result 都是 Mat 对象,结果中的每个元素代表 image1 和 image2 中对应像素的绝对差值。
- 可以使用
exp函数来计算矩阵中所有元素的指数:
exp(mat, result);
- 可以使用
log函数来计算矩阵中每个元素的自然对数:
log(mat, result);
pow函数可以用来将矩阵中的所有元素提升到给定的幂。此函数需要一个矩阵和一个double类型的值,该值将是幂值。以下是一个示例:
pow(mat, 3.0, result);
sqrt函数用于计算矩阵中所有元素的平方根,其用法如下:
sqrt(mat, result);
函数如 log 和 pow 不应与标准 C++ 库中同名的函数混淆。为了提高你代码的可读性,考虑在 C++ 代码中在函数名之前使用 cv 命名空间。例如,你可以这样调用 pow 函数:
cv::pow(image1, 3.0, result);
矩阵和数组操作
与本章中我们迄今为止看到的函数和算法不同,本节中的算法对图像(或矩阵)本身执行原子和完整的操作,并且不被视为迄今为止所描述的逐元素操作。如果你还记得,逐元素操作的规则是它们可以很容易地并行化,因为结果矩阵依赖于两个图像对应的像素,而本章我们将学习的函数和算法不易并行化,或者结果像素和值可能与其对应的源像素几乎没有关系,或者恰恰相反,结果像素可能同时依赖于一些或所有输入像素。
为外推制作边界
正如你将在本节和即将到来的章节中看到的那样,在处理许多计算机视觉算法时,最重要的处理问题之一是外推,或者简单地说,假设图像之外的像素不存在。你可能想知道,为什么我需要考虑不存在的像素,最简单的答案是,有许多计算机视觉算法不仅与单个像素工作,还与周围的像素工作。在这种情况下,当像素位于图像中间时,没有问题。但对于图像边缘的像素(例如,在最顶行),一些周围的像素将超出图像范围。这正是你需要考虑外推和非存在像素假设的地方。你会简单地假设这些像素为零值吗?也许假设它们与边界像素具有相同的值会更好?所有这些问题都在 OpenCV 中的一个名为 copyMakeBorder 的函数中得到了解决。
copyMakeBorder 允许我们在图像外部形成边界,并提供足够的定制选项来处理所有可能的场景。让我们通过几个简单的例子来看看 copyMakeBorder 的用法:
int top = 50;
int bottom = 50;
int left = 50;
int right = 50;
BorderTypes border = BORDER_REPLICATE;
copyMakeBorder(image,
result,
top,
bottom,
left,
right,
border);
如前例所示,copyMakeBorder 接受一个输入图像并生成一个 result 图像,就像我们迄今为止所学的 OpenCV 函数中的大多数一样。此外,此函数必须提供四个整数值,这些值代表添加到图像的 top(顶部)、bottom(底部)、left(左侧)和 right(右侧)边的像素数。然而,这里必须提供的最重要的参数是 border 类型参数,它必须是 BorderTypes 枚举的一个条目。以下是一些最常用的 BorderType 值:
-
BORDER_CONSTANT -
`BORDER_REPLICATE` -
BORDER_REFLECT -
BORDER_WRAP
注意,当使用 BORDER_CONSTANT 作为边界类型参数时,必须向 copyMakeBorder 函数提供一个额外的标量参数,该参数表示创建的边界的常量颜色值。如果省略此值,则假定为零(或黑色)。以下图像显示了在执行 copyMakeBorder 函数时在示例图像上的输出:

copyMakeBorder 以及许多其他 OpenCV 函数,在内部使用 borderInterpolate 函数来计算用于外推和创建非现有像素的捐赠像素的位置。您不需要直接调用此函数,所以我们将其留给您自己探索和发现。
翻转(镜像)和旋转图像
您可以使用 flip 函数翻转或镜像图像。此函数可以用于围绕 x 或 y 轴翻转图像,或者两者同时翻转,具体取决于提供的翻转 code。以下是此函数的使用方法:
int code = +1;
flip(image, result, code);
如果 code 为零,输入图像将垂直翻转/镜像(围绕 x 轴),如果 code 是正值,输入图像将水平翻转/镜像(围绕 y 轴),如果 code 是负值,输入图像将同时围绕 x 和 y 轴翻转/镜像。
另一方面,要旋转图像,您可以使用 rotate 函数。在调用 rotate 函数时,您需要注意提供正确的旋转标志,如下面的示例所示:
RotateFlags rt = ROTATE_90_CLOCKWISE;
rotate(image, result, rt);
RotateFlag 枚举可以是以下自解释的常量值之一:
-
ROTATE_90_CLOCKWISE -
ROTATE_180 -
ROTATE_90_COUNTERCLOCKWISE
以下图像展示了 flip 和 rotate 函数的所有可能结果。请注意,围绕两个轴翻转的结果与以下结果图像中的 180 度旋转相同:

如本章前面所述,Mat::t,即矩阵的转置,也可以与 flip 函数结合使用来旋转图像。
处理通道
OpenCV 提供了一些函数来处理通道,无论我们需要合并、拆分还是对它们执行各种操作。在本节中,我们将学习如何将图像拆分为其组成通道或使用多个单通道图像创建多通道图像。那么,让我们开始吧。
您可以使用 merge 函数合并多个单通道 Mat 对象并创建一个新的多通道 Mat 对象,如下面的示例代码所示:
Mat channels[3] = {ch1, ch2, ch3};
merge(channels, 3, result);
在前面的代码中,ch1、ch2 和 ch3 都是相同大小的单通道图像。结果将是一个三通道的 Mat 对象。
您还可以使用 insertChannel 函数向图像中插入新通道。以下是方法:
int idx = 2;
insertChannel(ch, image, idx);
在前面的代码中,ch是一个单通道Mat对象,image是我们想要在其中添加额外通道的矩阵,而idx指的是通道将被插入的位置的零基于索引号。
可以使用split函数来执行与merge函数相反的操作,即将多通道图像分割成多个单通道图像。以下是一个示例:
Mat channels[3];
split(image, channels);
前面的代码中的channels数组将包含三个大小相同的单通道图像,分别对应于图像中的每个单独的通道。
要从图像中提取单个通道,可以使用extractChannel函数:
int idx = 2;
Mat ch;
extractChannel(image, ch, idx);
在前面的代码中,很明显,位置idx的通道将从图像中提取并保存到ch中。
即使merge、split、insertChannel和extractChannel对于大多数用例已经足够,你仍然可能需要更复杂的通道打乱、提取或操作。因此,OpenCV 提供了一个名为mixChannels的函数,它允许以更高级的方式处理通道。让我们通过一个示例案例来看看mixChannels是如何使用的。假设我们想要将图像的所有通道向右移动。为了能够执行这样的任务,我们可以使用以下示例代码:
Mat image = imread("Test.png");
Mat result(image.rows, image.cols, image.type());
vector<int> fromTo = {0,1,
1,2,
2,0};
mixChannels(image, result, fromTo);
在前面的示例中,唯一重要的代码片段是fromTo向量,它必须包含对应于源图像和目标图像中通道号的值对。通道号,如往常一样,是基于 0 的索引,所以 0, 1 表示源图像中的第一个通道将被复制到结果中的第二个通道,依此类推。
值得注意的是,本节中所有之前的函数(合并、分割等)都是mixChannels函数的部分情况。
数学函数
在本节中我们将学习的函数和算法仅用于非元素级数学计算,这与我们在本章前面所看到的不同。这包括简单的函数,如mean或sum,或更复杂的操作,如离散傅里叶变换。让我们通过一些实例来浏览其中一些最重要的函数。
矩阵求逆
可以使用invert函数来计算矩阵的逆。这不应与bitwise_not函数混淆,后者会反转图像像素中的每个比特。invert函数不是元素级函数,并且需要将反转方法作为参数传递给此函数。以下是一个示例:
DecompTypes dt = DECOMP_LU;
invert(image, result, dt);
DecompTypes枚举包含可以在invert函数中用作分解类型的所有可能条目。以下是它们:
-
DECOMP_LU -
DECOMP_SVD -
DECOMP_EIG -
DECOMP_CHOLESKY -
DECOMP_QR
如果你对每个分解方法的详细描述感兴趣,请参阅 OpenCV 文档中的DecompTypes枚举。
元素的均值和总和
你可以使用mean函数来计算矩阵中元素的均值,或者说是平均值。以下是一个示例,展示了如何读取图像并计算并显示其所有单独通道的均值:
Mat image = imread("Test.png");
Mat result;
Scalar m = mean(image);
cout << m[0] << endl;
cout << m[1] << endl;
cout << m[2] << endl;
你可以使用sum函数以完全相同的方式,来计算矩阵中元素的求和:
Scalar s = sum(image);
OpenCV 还包括一个meanStdDev函数,可以用来同时计算矩阵中所有元素的均值和标准差。以下是一个示例:
Scalar m;
Scalar stdDev;
meanStdDev(image, m, stdDev);
与mean函数类似,meanStdDev函数也会分别计算每个单独通道的结果。
离散傅里叶变换
一维或二维数组的离散傅里叶变换,或者说图像的离散傅里叶变换,是计算机视觉中分析图像的许多方法之一。结果的理解完全取决于其应用的领域,而这本书中我们并不关心这一点,然而,在本节中我们将学习如何执行离散傅里叶变换。
简而言之,你可以使用dft函数来计算图像的傅里叶变换。然而,在安全调用dft函数之前,需要做一些准备工作。傅里叶变换的结果也是如此。让我们用一个示例代码来分解这个过程,并通过计算和显示之前章节中使用的示例图像的傅里叶变换来展示。
dft函数可以更高效地处理特定大小的矩阵(如 2 的幂,例如 2、4 和 8),这就是为什么在调用dft函数之前,最好将矩阵的大小增加到最接近的优化大小,并用零填充。这可以通过使用getOptimalDFTSize函数来完成。假设image是我们想要计算其离散傅里叶变换的输入图像,我们可以编写以下代码来计算并调整其大小以适应dft函数的优化大小:
int optRows = getOptimalDFTSize( image.rows );
int optCols = getOptimalDFTSize( image.cols );
Mat resizedImg;
copyMakeBorder(image,
resizedImg,
0,
optRows - image.rows,
0,
optCols - image.cols,
BORDER_CONSTANT,
Scalar::all(0));
如您所见,必须分别对行和列调用getOptimalDFTSize函数两次。您已经熟悉copyMakeBorder函数。调整图像大小并用零(或任何其他所需值)填充新像素是copyMakeBorder函数无数用例之一。
其余部分相当简单,我们需要形成一个双通道图像,并将其传递给dft函数,以在同一个矩阵中获得复数(实部和虚部)的结果。这将简化后续的显示过程。以下是具体操作方法:
vector<Mat> channels = {Mat_<float>(resizedImg),
Mat::zeros(resizedImg.size(), CV_32F)};
Mat complexImage;
merge(channels, complexImage);
dft(complexImage, complexImage);
我们已经学习了如何使用merge函数。在前面的代码中需要注意的唯一重要的事情是结果被保存到了与输入相同的图像中。complexImage现在包含两个通道,一个用于离散傅里叶变换的实部,另一个用于虚部。就是这样!我们现在有了结果,然而,为了能够显示它,我们必须计算结果的大小。下面是如何操作的:
split(complexImage, channels);
Mat mag;
magnitude(channels[0], channels[1], mag);
在前面的代码中,我们将复数结果分解为其组成通道,然后使用magnitude函数计算幅度。理论上,mag是一个可显示的结果,但在现实中,它包含的值远高于使用 OpenCV 可显示的值,因此我们需要在显示之前进行几个转换。首先,我们需要确保结果是按对数尺度,通过执行以下转换:
mag += Scalar::all(1);
log(mag, mag);
接下来,我们必须确保结果值被缩放并归一化,以便在0.0和1.0之间,以便由 OpenCV 的imshow函数显示。你需要使用normalize函数来完成这个任务:
normalize(mag, mag, 0.0, 1.0, CV_MINMAX);
你现在可以尝试使用imshow函数显示结果。以下是一个显示离散傅里叶变换结果的示例:

这个结果的问题在于,在结果的原点位于图像中心之前,需要交换四个象限。以下图像展示了在结果有一个位于中心的起点之前,必须如何交换四个结果象限:

下面的代码用于交换傅里叶变换结果的四个象限。注意我们首先找到结果中心,然后创建四个感兴趣区域(ROI)矩阵,然后交换它们:
int cx = mag.cols/2;
int cy = mag.rows/2;
Mat Q1(mag, Rect(0, 0, cx, cy));
Mat Q2(mag, Rect(cx, 0, cx, cy));
Mat Q3(mag, Rect(0, cy, cx, cy));
Mat Q4(mag, Rect(cx, cy, cx, cy));
Mat tmp;
Q1.copyTo(tmp);
Q4.copyTo(Q1);
tmp.copyTo(Q4);
Q2.copyTo(tmp);
Q3.copyTo(Q2);
tmp.copyTo(Q3);
dft函数接受一个额外的参数,可以用来进一步自定义其行为。此参数可以是来自DftFlags枚举的值的组合。例如,要执行逆傅里叶变换,你需要使用DFT_INVERSE参数调用dft函数:
dft(input, output, DFT_INVERSE);
这也可以通过使用idft函数来完成:
idft(input, output);
确保查看DftFlags枚举和dft函数文档,以获取有关 OpenCV 中离散傅里叶变换实现方式的更多信息。
生成随机数
随机数生成是计算机视觉中最广泛使用的算法之一,尤其是在测试给定范围内的随机值时。当使用 OpenCV 库时,你可以使用以下函数来生成包含随机值的值或矩阵:
randn函数可以用来填充一个矩阵或数组,使其包含具有给定平均值和标准差的随机数。以下是此函数的使用方法:
randn(rmat, mean, stddev);
randu函数与randn函数类似,用于用随机值填充数组,然而,这个函数使用的是下限和上限(两者都包含)来生成随机值。以下是一个示例:
randu(rmat, lowBand, highBand);
randShuffle函数,从其标题可以猜到,用于随机打乱数组或矩阵的内容。它就像以下示例中展示的那样简单使用:
randShuffle(array);
搜索和定位函数
当你在计算机视觉项目中工作时,你将面临无数的场景和案例,在这些场景和案例中,你需要寻找特定的像素,或者最大值(最亮点),等等。OpenCV 库包含许多可用于此目的的函数,这些函数是本节的主题。
定位非零元素
定位或计数非零元素可能会非常有用,尤其是在对图像进行阈值操作后寻找特定区域,或者寻找特定颜色覆盖的区域时。OpenCV 包含 findNonZero 和 countNonZero 函数,这些函数可以简单地让你找到或计数图像中具有非零(或明亮)值的像素。
下面是一个示例,展示了如何使用 findNonZero 函数在灰度图像中找到第一个非黑色像素并打印其位置:
Mat image = imread("Test.png", IMREAD_GRAYSCALE);
Mat result;
vector<Point> idx;
findNonZero(image, idx);
if(idx.size() > 0)
cout << idx[0].x << "," << idx[0].y << endl;
下面是另一个示例代码,展示了如何找到灰度图像中黑色像素的百分比:
Mat image = imread("Test.png", IMREAD_GRAYSCALE);
Mat result;
int nonZero = countNonZero(image);
float white = float(nonZero) / float(image.total());
float black = 1.0 - white;
cout << black << endl;
定位最小和最大元素
在图像或矩阵中定位最亮(最大)和最暗(最小)点是计算机视觉中图像搜索的最重要类型之一,尤其是在执行某些类型的阈值算法或模板匹配函数(我们将在接下来的章节中学习)之后。OpenCV 提供以下两个函数来定位矩阵中的全局最小和最大值及其位置:
-
minMaxIdx -
minMaxLoc
minMaxLoc 函数在整个图像(仅单通道图像)中搜索最亮和最暗的点,并返回最亮和最暗像素的值,以及它们的位置,而 minMaxIdx 函数返回找到的最小和最大位置的指针,而不是位置(带有 x 和 y 的 Point 对象)。以下是 minMaxLoc 函数的使用方法:
double minVal, maxVal;
Point minLoc, maxLoc;
minMaxLoc(image, &minVal, &maxVal, &minLoc, &maxLoc);
这是一个使用 minMaxIdx 函数的示例:
double minVal, maxVal;
int minIdx, maxIdx;
minMaxIdx(image, &minVal, &maxVal, &minIdx, &maxIdx);
查找表转换
在计算机视觉中,根据给定表中包含的所需替换值替换像素称为 查找表转换。一开始这可能听起来有些令人困惑,但它是一种非常强大且简单的方法,用于使用查找表修改图像。让我们通过一个实际示例来看看它是如何实现的。
假设我们有一个示例图像,我们需要将亮度像素(值大于175)替换为绝对白色,暗像素(值小于125)替换为绝对黑色,其余像素保持不变。为了执行此类任务,我们可以简单地使用查找表Mat对象以及 OpenCV 中的LUT函数:
Mat lut(1, 256, CV_8UC1);
for(int i=0; i<256; i++)
{
if(i < 125)
lut.at<uchar>(0, i) = 0;
else if(i > 175)
lut.at<uchar>(0, i) = 255;
else
lut.at<uchar>(0, i) = i;
}
Mat result;
LUT(image, lut, result);
以下图像展示了当在示例图像上执行此查找表转换时的结果。正如你所见,查找表转换可以在彩色(在右侧)和灰度(在左侧)图像上执行:

聪明地使用LUT函数可以为计算机视觉问题中的许多创意解决方案带来帮助,其中需要用给定值替换像素。
摘要
在本章中我们所看到的是使用 OpenCV 库在计算机视觉中进行矩阵和数组操作所能做到的一小部分。我们从对前几章中计算机视觉基本概念有坚实的背景知识开始,最终通过实际示例学习了大量算法和函数。我们学习了 ones、zeroes、单位矩阵、转置以及其他嵌入到Mat类核心中的函数。然后,我们继续学习许多逐元素算法。我们现在完全能够执行逐元素矩阵运算,如基本运算、比较和位运算。最后,我们学习了 OpenCV 核心模块中的非逐元素操作。创建边界、修改通道和离散傅里叶变换是我们在本章中学到的许多算法之一。
在下一章中,我们将学习用于过滤、绘制和其他功能的计算机视觉算法。即将到来的章节中涵盖的主题是在你步入高级计算机视觉开发世界以及用于高度复杂任务(如人脸或物体检测和跟踪)的算法之前所必需的。
问题
-
哪些逐元素数学运算和位运算会产生完全相同的结果?
-
OpenCV 中的
gemm函数的目的是什么?使用gemm函数,A*B的等价是什么? -
使用
borderInterpolate函数计算点(-10, 50)处不存在像素的值,边框类型为BORDER_REPLICATE。进行此类计算所需的函数调用是什么? -
创建与本章学习单位矩阵部分相同的单位矩阵,但使用
setIdentity函数而不是Mat::eye函数。 -
编写一个使用
LUT函数(查找表转换)的程序,当在灰度图像和彩色(RGB)图像上执行时,与bitwise_not(颜色反转)执行相同的任务。 -
除了对矩阵的值进行归一化外,
normalize函数还可以用来调整图像的亮度或暗度。请编写所需的函数调用,使用normalize函数来使灰度图像变暗和变亮。 -
使用
merge和split函数从图像(使用imread函数创建的 BGR 图像)中移除蓝色通道(第一个通道)。
第四章:绘图、过滤和变换
我们在前一章中从计算机视觉的基本和基本概念开始,最终学习了用于在矩阵和图像上执行广泛操作的许多算法和函数。首先,我们学习了嵌入到Mat类中以方便使用的各种函数,例如克隆(或获取完整且独立的副本)一个矩阵,计算两个矩阵的叉积和点积,获取矩阵的转置或逆,以及生成单位矩阵。然后我们转向学习 OpenCV 中的各种逐元素操作。正如我们所知,逐元素操作是可并行化的算法,它们对图像的所有单个像素(或元素)执行相同的过程。在这个过程中,我们还实验了这些操作对实际图像文件的影响。我们通过学习将图像视为整体的操作和函数来完成前一章,这些操作与逐元素操作不同,但在计算机视觉中它们仍然被视为矩阵操作。
现在,我们已经准备好深入挖掘,学习在计算机视觉应用中用于绘图、过滤和图像变换等任务的众多强大算法。正如前几章所述,这些算法类别整体上被认为是图像处理算法。在本章中,我们将从学习如何在空图像(类似于画布)或现有图像和视频帧上绘制形状和文本开始。本章第一部分中的示例还将包括一个关于如何向 OpenCV 窗口添加滑块条的教程,以便轻松调整所需参数。之后,我们将继续学习图像过滤技术,例如模糊图像、膨胀和腐蚀。本章我们将学习到的过滤算法和函数包括许多流行且广泛使用的算法,尤其是被专业照片编辑应用所采用。本章将包括一个关于图像变换算法的全面部分,包括如简单调整照片大小或复杂如像素重新映射的算法。我们将通过学习如何将色图应用于图像以变换其颜色来结束本章。
在本章中,我们将学习以下内容:
-
在图像上绘制形状和文本
-
将平滑滤波器应用于图像
-
将膨胀、腐蚀以及其他各种过滤器应用于图像
-
在图像上重新映射像素并执行几何变换算法
-
将色图应用于图像
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章,开始使用 OpenCV。
你可以使用以下网址下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter04
利用图像
毫无疑问,在开发计算机视觉应用时,最重要的任务之一就是在图像上绘制。想象一下,你想要在图片上打印时间戳,或者在图像的某些区域绘制矩形或椭圆,以及许多需要你在图像或形状(如矩形)上绘制文本和数字的类似例子。正如你所看到的,可以指出的例子非常明显且数量众多,因此,我们不妨直接从 OpenCV 中用于绘制的函数和算法开始。
在图像上打印文本
OpenCV 包含一个名为 putText 的非常易于使用的函数,用于在图像上绘制或打印文本。此函数需要一个图像作为输入/输出参数,这意味着源图像本身将被更新。因此,在调用此函数之前,务必在内存中制作原始图像的副本。你还需要为此函数提供一个起点,这仅仅是文本将被打印的点。文本的字体必须是 HersheyFonts 枚举中的一个条目,它可以取以下值之一(或它们的组合):
-
FONT_HERSHEY_SIMPLEX -
FONT_HERSHEY_PLAIN -
FONT_HERSHEY_DUPLEX -
FONT_HERSHEY_COMPLEX -
FONT_HERSHEY_TRIPLEX -
FONT_HERSHEY_COMPLEX_SMALL -
FONT_HERSHEY_SCRIPT_SIMPLEX -
FONT_HERSHEY_SCRIPT_COMPLEX -
FONT_ITALIC
关于每个条目打印时的详细情况,你可以查看 OpenCV 或简单地在网上搜索有关 Hershey 字体的更多信息。
除了我们刚才提到的参数之外,你还需要一些额外的参数,例如文本的缩放、颜色、粗细和线型。让我们用一个简单的例子来逐一解释它们。
下面是一个演示 putText 函数用法的示例代码:
string text = "www.amin-ahmadi.com";
int offset = 25;
Point origin(offset, image.rows - offset);
HersheyFonts fontFace = FONT_HERSHEY_COMPLEX;
double fontScale = 1.5;
Scalar color(0, 242, 255);
int thickness = 2;
LineTypes lineType = LINE_AA;
bool bottomLeftOrigin = false;
putText(image,
text,
origin,
fontFace,
fontScale,
color,
thickness,
lineType,
bottomLeftOrigin);
当在上一章的示例图片上执行时,将创建以下结果:

显然,增加或减少 scale 将导致文本大小的增加或减少。thickness 参数对应于打印文本的粗细,等等。唯一值得讨论的参数是 lineType,在我们的示例中它是 LINE_AA,但它可以取 LineTypes 枚举中的任何值。以下是重要的线型及其差异,通过在白色背景上打印 W 字符来演示:

LINE_4 表示四连接线型,LINE_8 表示八连接线型。然而,LINE_AA,即抗锯齿线型,比其他两种线型绘制速度慢,但如图所示,它也提供了更好的质量。
LineTypes 枚举还包括一个 FILLED 条目,它用于用给定的颜色填充图像上绘制的形状。需要注意的是,OpenCV 中几乎所有绘图函数(不仅仅是 putText)都需要一个线型参数。
OpenCV 提供了两个与处理文本相关的函数,但不是用于绘制文本。第一个函数名为 getFontScaleFromHeight,它用于根据字体类型、高度(以像素为单位)和粗细获取所需的缩放值。以下是一个示例:
double fontScale = getFontScaleFromHeight(fontFace,
50, // pixels for height
thickness);
我们可以使用前面的代码代替在之前 putText 函数的示例使用中为 scale 提供一个常量值。显然,我们需要将 50 替换为我们文本所需的任何像素高度值。
除了 getFontScaleFromHeight,OpenCV 还包括一个名为 getTextSize 的函数,可以用来检索打印特定文本所需的宽度和高度。以下是一个示例代码,展示了我们如何使用 getTextSize 函数找出打印 "Example" 单词所需的像素宽度和高度,使用 FONT_HERSHEY_PLAIN 字体类型,缩放为 3.2,粗细为 2:
int baseLine;
Size size = getTextSize("Example",
FONT_HERSHEY_PLAIN,
3.2,
2,
&baseLine);
cout << "Size = " << size.width << " , " << size.height << endl;
cout << "Baseline = " << baseLine << endl;
结果应该看起来像以下这样:
Size = 216 , 30
Baseline = 17
这意味着文本需要 216 by 30 像素的空间来打印,基线将比文本底部远 17 像素。
绘制形状
您可以使用一组非常简单的 OpenCV 函数在图像上绘制各种类型的形状。这些函数都包含在 imgproc 模块中,就像 putText 函数一样,并且可以用来在给定点绘制标记、线条、箭头线条、矩形、椭圆、圆、多边形等。
让我们从 drawMarker 函数开始,该函数用于在图像上绘制给定类型的标记。以下是该函数如何用于在给定图像的中心打印标记的示例:
Point position(image.cols/2,
image.rows/2);
Scalar color = Scalar::all(0);
MarkerTypes markerType = MARKER_CROSS;
int markerSize = 25;
int thickness = 2;
int lineType = LINE_AA;
drawMarker(image,
position,
color,
markerType,
markerSize,
thickness,
lineType);
position 是标记的中心点,其余的参数几乎与我们之前在 putText 函数中看到的是一样的。这是 OpenCV 中大多数(如果不是所有)绘图函数的参数模式。唯一特定于 drawMarker 函数的参数是 markerSize,它只是标记的大小,以及 markerType,它可以取 MarkerTypes 枚举中的以下值之一:
-
MARKER_CROSS -
MARKER_TILTED_CROSS -
MARKER_STAR -
MARKER_DIAMOND -
MARKER_SQUARE -
MARKER_TRIANGLE_UP -
MARKER_TRIANGLE_DOWN
以下图表展示了之前列表中提到的所有可能的标记类型,当它们打印在白色背景上时,从左到右排列:

在 OpenCV 中使用 line 函数可以绘制线条。此函数需要两个点,并将绘制连接给定点的线条。以下是一个示例:
Point pt1(25, image.rows/2);
Point pt2(image.cols/2 - 25, image.rows/2);
Scalar color = Scalar(0,255,0);
int thickness = 5;
int lineType = LINE_AA;
int shift = 0;
line(image,
pt1,
pt2,
color,
thickness,
lineType,
shift);
shift参数对应于给定点的分数位数。您可以省略或简单地传递零以确保它对您的结果没有影响。
与line函数类似,arrowedLine也可以用来绘制带箭头的线。显然,给定点的顺序决定了箭头的方向。这个函数唯一需要的参数是tipLength参数,它对应于用于创建箭头尖端的线长百分比。以下是一个示例:
double tipLength = 0.2;
arrowedLine(image,
pt1,
pt2,
color,
thickness,
lineType,
shift,
tipLength);
要在图像上绘制一个圆,我们可以使用circle函数。以下是这个函数如何用来在图像中心绘制一个圆的示例:
Point center(image.cols/2,
image.rows/2);
int radius = 200;
circle(image,
center,
radius,
color,
thickness,
lineType,
shift);
除了center和radius,它们显然是圆的中心点和半径之外,其余参数与我们在本节中学到的函数和示例相同。
使用rectangle函数可以在图像上绘制矩形或正方形。这个函数与line函数非常相似,因为它只需要两个点。不同之处在于,提供给rectangle函数的点对应于矩形或正方形的左上角和右下角点。以下是一个示例:
rectangle(image,
pt1,
pt2,
color,
thickness,
lineType,
shift);
除了两个单独的Point对象外,这个函数还可以提供一个单一的Rect对象。以下是这样做的方法:
Rect rect(pt1,pt2);
rectangle(image,
color,
thickness,
lineType,
shift);
类似地,可以使用ellipse函数绘制椭圆。这个函数需要提供轴的大小以及椭圆的角度。此外,您可以使用起始和结束角度来绘制椭圆的全部或部分,或者,在其他情况下,绘制一个圆弧而不是椭圆。您可以猜测,将0和360作为起始和结束角度将导致绘制一个完整的椭圆。以下是一个示例:
Size axes(200, 100);
double angle = 20.0;
double startAngle = 0.0;
double endAngle = 360.0;
ellipse(image,
center,
axes,
angle,
startAngle,
endAngle,
color,
thickness,
lineType,
shift);
另一种调用ellipse函数的方法是使用RotatedRect对象。在这个函数的版本中,您必须首先创建一个具有给定宽度和高度(或者说大小)以及一个angle的RotatedRect,然后像下面这样调用ellipse函数:
Size size(150, 300);
double angle = 45.0;
RotatedRect rotRect(center,
axes,
angle);
ellipse(image,
rotRect,
color,
thickness,
lineType);
注意,使用这种方法,您不能绘制圆弧,这仅用于绘制完整的椭圆。
我们已经到了可以使用 OpenCV 绘图函数绘制的最后一种形状类型,那就是多边形形状。您可以通过使用polylines函数来绘制多边形形状。您必须确保创建一个点向量,它对应于绘制多边形所需的顶点。以下是一个示例:
vector<Point> pts;
pts.push_back(Point(100, 100));
pts.push_back(Point(50, 150));
pts.push_back(Point(50, 200));
pts.push_back(Point(150, 200));
pts.push_back(Point(150, 150));
bool isClosed = true;
polylines(image,
pts,
isClosed,
color,
thickness,
lineType,
shift);
isClosed参数用于确定多边形线是否必须闭合,即是否通过将最后一个顶点连接到第一个顶点。
以下图像展示了我们在前面的代码片段中使用的arrowedLine、circle、rectangle和polylines函数在绘制示例图像时的结果:

在继续下一节并学习用于图像过滤的算法之前,我们将学习如何通过在 OpenCV 显示窗口中添加滑块来在运行时调整参数。当你实验不同的值以查看它们的效果时,这是一个非常有用的方法,以便在运行时尝试大量不同的参数,并且它允许通过简单地调整滑块(或滑块)的位置来改变变量的值。
首先,让我们看一个例子,然后进一步分解代码,学习如何使用 OpenCV 函数处理滑块。以下完整的例子展示了我们如何使用滑块在运行时调整图像上绘制的圆的半径:
string window = "Image"; // Title of the image output window
string trackbar = "Radius"; // Label of the trackbar
Mat image = imread("Test.png");
Point center(image.cols/2, image.rows/2); // A Point object that points to the center of the image
int radius = 25;
Scalar color = Scalar(0, 255, 0); // Green color in BGR (OpenCV default) color space
int thickness = 2; LineTypes lineType = LINE_AA; int shift = 0;
// Actual callback function where drawing and displaying happens
void drawCircle(int, void*)
{
Mat temp = image.clone();
circle(temp,
center,
radius,
color,
thickness,
lineType,
shift);
imshow(window, temp);
}
int main()
{
namedWindow(window); // create a window titled "Image" (see above)
createTrackbar(trackbar, // label of the trackbar
window, // label of the window of the trackbar
&radius, // the value that'll be changed by the trackbar
min(image.rows, image.cols) / 2, // maximum accepted value
drawCircle);
setTrackbarMin(trackbar, window, 25); // set min accespted value by trackbar
setTrackbarMax(trackbar, window, min(image.rows, image.cols) / 2); // set max again
drawCircle(0,0); // call the callback function and wait
waitKey();
return 0;
}
在前面的代码中,window 和 trackbar 是 string 对象,用于识别和访问特定窗口上的特定滑块。image 是包含源图像的 Mat 对象。center、radius、color、thickness、lineType 和 shift 是绘制圆所需的参数,正如我们在本章前面所学。drawCircle 是当使用滑块更新我们想要绘制的圆的 radius 值时将被调用的函数(确切地说,是回调函数)。这个函数必须具有示例中使用的签名,它有一个 int 和一个 void 指针作为其参数。这个函数相当简单;它只是克隆原始图像,在其上绘制一个圆,然后显示它。
main 函数是我们实际创建窗口和滑块的地方。首先,必须调用 namedWindow 函数来创建一个具有我们想要的窗口名称的窗口。然后,可以像示例中那样调用 createTrackbar 函数,在该窗口上创建一个滑块。请注意,滑块本身有一个名称,用于访问它。当应用程序运行时,这个名称也将打印在滑块旁边,以向用户显示其目的。调用 setTrackbarMin 和 setTrackbarMax 确保我们的滑块不允许 radius 值小于 25 或大于图像的宽度或高度(取较小者),然后除以 2(因为我们谈论的是半径,而不是直径)。
以下是一个截图,展示了我们的 window 以及其上的 trackbar,可以用来调整圆的 radius:

尝试调整以亲自看到圆的半径是如何根据滑块的位置变化的。确保在你想实验这本书中学到的函数或算法的参数时使用这种方法。请注意,你可以添加你需要的任意数量的滑块。然而,添加更多的滑块会在你的窗口上使用更多的空间,这可能会导致用户界面和体验不佳,从而使得程序难以使用,而不是简化,所以尽量明智地使用滑块。
图像过滤
无论你是在尝试构建一个执行高度复杂任务的计算机视觉应用程序,比如实时目标检测,还是简单地以某种方式修改输入图像,这真的并不重要。几乎不可避免的是,你将不得不应用某种类型的滤波器到你的输入或输出图像上。原因很简单——并不是所有的图片都准备好直接进行处理,大多数时候,应用滤波器使图像更平滑是确保它们可以被我们的算法处理的一种方式。
在计算机视觉中,你可以应用到图像上的滤波器种类繁多,但在这个章节中,我们将学习一些最重要的滤波器,特别是那些在 OpenCV 库中有实现,我们可以使用的滤波器。
模糊/平滑滤波器
模糊图像是图像滤波任务中最重要的一项,有许多算法可以执行这项任务,每个算法都有其自身的优缺点,我们将在本节中讨论这些内容。
让我们从用于平滑图像的最简单的滤波器开始,这个滤波器被称为中值滤波器,可以通过使用medianBlur函数来实现,如下面的示例所示:
int ksize = 5; // must be odd
medianBlur(image, result, ksize);
此滤波器简单地找到图像中每个像素的邻近像素的中值。ksize,或核大小参数,决定了用于模糊的核的大小,换句话说,决定了在模糊算法中考虑邻近像素的距离。以下图像展示了从 1 到 7 增加核大小的结果:

注意,核大小必须是奇数,核大小为 1 将产生与输入图像完全相同的图像。因此,在之前的图像中,你可以看到从原始图像(最左侧)到核大小为 7 的模糊级别的增加。
注意,如果你需要,可以使用非常高的核大小,但这通常是不必要的,通常只有在需要去除极其嘈杂的图像的噪声时才需要。以下是一个核大小为 21 的示例图像,展示了其结果:

另一种模糊图像的方法是使用boxFilter函数。让我们通过一个示例代码来看看它是如何实现的,然后进一步分解以更好地理解其行为:
int ddepth = -1;
Size ksize(7,7);
Point anchor(-1, -1);
bool normalize = true;
BorderTypes borderType = BORDER_DEFAULT;
boxFilter(image,
result,
ddepth,
ksize,
anchor,
normalize,
borderType);
箱形滤波器被称为一种模糊方法,其中使用给定 ksize 和 anchor 点的 1 的矩阵进行图像模糊。与 medianBlur 函数相比,主要区别在于您实际上可以定义 anchor 点为任何非中心点的任何邻域。您还可以定义在此函数中使用的边界类型,而在 medianBlur 函数中,内部使用 BORDER_REPLICATE 并不能更改。有关边界类型的更多信息,您可能需要参考第三章,数组和矩阵操作。最后,normalize 参数允许我们将结果归一化到可显示的结果。
可以使用 ddepth 参数来更改结果的深度。然而,您可以使用 -1 来确保结果具有与源相同的深度。同样,可以提供 -1 值给 anchor 以确保使用默认的锚点。
下面的图像展示了前面示例代码的结果。右侧图像是左侧图像经过箱形滤波后的结果:

我们可以通过使用 blur 函数执行完全相同的任务,换句话说,就是归一化箱形滤波器,如下面的示例代码所示:
Size ksize(7,7);
Point anchor(-1, -1);
BorderTypes borderType = BORDER_DEFAULT;
blur(image,
result,
ksize,
anchor,
borderType);
此示例代码的结果与之前看到的调用 boxFilter 的结果完全相同。这里明显的区别是,此函数不允许我们更改结果的深度,并且默认应用归一化。
除了标准的箱形滤波器外,您还可以使用 sqrBoxFilter 函数应用平方箱形滤波器。在此方法中,不是计算邻近像素的总和,而是计算它们平方的总和。以下是一个示例,与调用 boxFilter 函数非常相似:
int ddepth = -1;
Size ksize(7,7);
Point anchor(-1, -1);
bool normalize = true;
BorderTypes borderType = BORDER_DEFAULT;
sqrBoxFilter(image,
result,
ddepth,
ksize,
anchor,
normalize,
borderType);
boxFilter 和 sqrBoxFilter 函数的非归一化版本也可以用来获取图像中所有像素邻近区域的统计信息,它们的使用场景并不仅限于图像模糊。
计算机视觉中最受欢迎的模糊方法之一是 高斯模糊 算法,在 OpenCV 中可以通过使用 GaussianBlur 函数来实现。这个函数与之前我们了解过的模糊函数类似,需要指定核大小,以及 X 和 Y 方向上的标准差值,分别称为 sigmaX 和 sigmaY。以下是如何使用此函数的示例:
Size ksize(7,7);
double sigmaX = 1.25;
double sigmaY = 0.0;
BorderTypes borderType = BORDER_DEFAULT;
GaussianBlur(image,
result,
ksize,
sigmaX,
sigmaY,
borderType);
注意,sigmaY 的值为零意味着 sigmaX 的值也将用于 Y 方向。有关高斯模糊的更多信息,您可以阅读关于高斯函数的一般信息和 OpenCV 文档页面中的 GaussianBlur 函数。
在本节中我们将学习的最后一个平滑滤波器称为双边滤波器,可以通过使用bilateralFilter函数实现。双边滤波是一种强大的去噪和图像平滑方法,同时保留边缘。与之前看到的模糊算法相比,此函数也慢得多,CPU 密集度更高。让我们通过一个例子来看看如何使用bilateralFilter,然后分解所需的参数:
int d = 9;
double sigmaColor = 250.0;
double sigmaSpace = 200.0;
BorderTypes borderType = BORDER_DEFAULT;
bilateralFilter(image,
result,
d,
sigmaColor,
sigmaSpace,
borderType);
d,或过滤器大小,是参与过滤的像素邻域的直径。sigmaColor和sigmaSpace值都用于定义颜色效果并协调计算过滤值像素的颜色和坐标。以下是演示bilateralFilter函数在我们示例图像上执行效果的截图:

形态学滤波器
与平滑滤波器类似,形态学滤波器是改变每个像素值基于相邻像素值的算法,尽管明显的区别是它们没有模糊效果,并且主要用于在图像上产生某种形式的侵蚀或膨胀效果。这将在本节后面的几个动手示例中进一步阐明,但现在,让我们看看如何使用 OpenCV 函数执行形态学操作(也称为变换)。
您可以使用morphologyEx函数对图像执行形态学操作。此函数可以提供一个来自MorphTypes枚举的条目来指定形态学操作。以下是可用的值:
-
MORPH_ERODE:用于侵蚀操作 -
MORPH_DILATE:用于膨胀操作 -
MORPH_OPEN:用于开运算,或侵蚀图像的膨胀 -
MORPH_CLOSE:用于闭运算,或从膨胀图像中侵蚀 -
MORPH_GRADIENT:用于形态学梯度操作,或从膨胀图像中减去侵蚀图像 -
MORPH_TOPHAT:用于顶帽操作,或从源图像中减去开运算的结果 -
MORPH_BLACKHAT:用于黑帽操作,或从闭运算的结果中减去源图像
要理解前面列表中提到的所有可能的形态学操作,首先理解侵蚀和膨胀(列表中的前两项)的效果是很重要的,因为其余的只是这两种形态学操作的组合。让我们先通过一个例子来尝试侵蚀,同时学习如何使用morphologyEx函数:
MorphTypes op = MORPH_ERODE;
MorphShapes shape = MORPH_RECT;
Size ksize(3,3);
Point anchor(-1, -1);
Mat kernel = getStructuringElement(shape,
ksize,
anchor);
int iterations = 3;
BorderTypes borderType = BORDER_CONSTANT;
Scalar borderValue = morphologyDefaultBorderValue();
morphologyEx(image,
result,
op,
kernel,
anchor,
iterations,
borderType,
borderValue);
op,或操作,是前面提到的MorphTypes枚举中的一个条目。kernel,或结构元素,是用于形态学操作的核矩阵,它可以是手动创建的,也可以通过使用getStructuringElement函数创建。你必须为getStructuringElement提供shape形态、核大小(ksize)和anchor。shape可以是矩形、十字或椭圆,它只是MorphShapes枚举中的一个条目。iterations变量指的是形态学操作在图像上执行次数。borderType的解释方式与迄今为止我们看到的用于像素外推的所有函数完全相同。如果使用常量边界类型值,则morphologyEx函数还必须提供边界值,该值可以通过使用morphologyDefaultBorderValue函数检索,或手动指定。
以下是我们的先前代码(用于腐蚀)在示例图像上执行的结果:

另一方面,膨胀操作是通过在前面示例中将op值替换为MORPH_DILATE来执行的。以下是膨胀操作的结果:

对腐蚀和膨胀操作的高度简化描述是,它们会导致较暗像素的邻近像素变得更暗(在腐蚀的情况下)或较亮像素的邻近像素变得更亮(在膨胀的情况下),经过更多迭代后,这将导致更强烈且易于观察的效果。
如前所述,所有其他形态学操作都是腐蚀和膨胀的组合。以下是当我们对示例图像执行开闭操作时的结果:

确保你自己尝试其余的形态学操作,以查看它们的效果。创建具有滑块的图形用户界面,并尝试更改iteration和其他参数的值,以查看它们如何影响形态学操作的结果。如果使用得当,形态学操作可以产生非常有趣的结果,并进一步简化你想要在图像上执行的操作。
在进入下一节之前,值得注意的是,你也可以使用erode和dilate函数执行完全相同的操作。尽管这些函数不需要操作参数(因为操作已经在它们的名称中),但其余的参数与morphologyEx函数完全相同。
基于导数的过滤器
在本节中,我们将学习基于计算和使用图像导数的滤波算法。为了理解图像中导数的概念,您可以回忆一下事实,即图像是矩阵,因此您可以在X或Y方向上计算(任何阶数)的导数,例如,在最简单的情况下,这相当于在某个方向上寻找像素的变化。
让我们从Sobel函数开始,该函数用于通过Sobel算子计算图像的导数。以下是这个函数在实际中的应用方法:
int ddepth = -1;
int dx = 1;
int dy = 1;
int ksize = 5;
double scale = 0.3;
double delta = 0.0;
BorderTypes borderType = BORDER_DEFAULT;
Sobel(image,
result,
ddepth,
dx,
dy,
ksize,
scale,
delta,
borderType);
ddepth,类似于在本章中之前示例中看到的,用于定义输出深度,使用-1确保结果具有与输入相同的深度。dx和dy用于设置X和Y方向上导数的阶数。ksize是Sobel算子的大小,可以是 1、3、5 或 7。scale用作结果的缩放因子,delta被加到结果上。
以下图像显示了使用前一个示例代码中的参数值调用Sobel函数的结果,但delta值为零(左侧)和 255(右侧):

尝试设置不同的delta和scale值,并实验结果。还可以尝试不同的导数阶数,亲自看看效果。正如您从前面的输出图像中可以看到,计算图像的导数是计算图像边缘的一种方法。
您还可以使用spatialGradient函数,同时使用Sobel算子,在X和Y方向上计算图像的一阶导数。换句话说,调用一次spatialGradient相当于调用两次Sobel函数,用于两个方向的一阶导数。以下是一个示例:
Mat resultDX, resultDY;
int ksize = 3;
BorderTypes borderType = BORDER_DEFAULT;
spatialGradient(image,
resultDX,
resultDY,
ksize,
borderType);
注意,ksize参数必须是3,输入图像类型必须是灰度图,否则此函数将无法执行,尽管这可能在即将到来的 OpenCV 版本中有所改变。
与我们使用Sobel函数的方式类似,您可以使用Laplacian函数来计算图像的拉普拉斯。重要的是要注意,此函数本质上是对使用Sobel算子计算的X和Y方向的二阶导数求和。以下是一个演示Laplacian函数用法的示例:
int ddepth = -1;
int ksize = 3;
double scale = 1.0;
double delta = 0.0;
BorderTypes borderType = BORDER_DEFAULT;
Laplacian(image,
result,
ddepth,
ksize,
scale,
delta,
borderType);
在Laplacian函数中使用的所有参数已在之前的示例中描述,特别是Sobel函数。
任意滤波
OpenCV 通过使用filter2D函数支持在图像上应用任意滤波器。此函数能够创建我们之前学习到的许多算法的结果,但它需要一个kernel矩阵来提供。此函数只是将整个图像与给定的kernel矩阵进行卷积。以下是在图像上应用任意滤波器的示例:
int ddepth = -1;
Mat kernel{+1, -1, +1,
-1, +2, -1,
+1, -1, +1};
Point anchor(-1, -1);
double delta = 0.0;
BorderTypes borderType = BORDER_DEFAULT;
filter2D(image,
result,
ddepth,
kernel,
anchor,
delta,
borderType);
下面是这种任意滤波器的结果。你可以看到左侧的原始图像和右侧的滤波操作结果:

使用filter2D函数可以创建和使用的可能过滤器数量绝对没有限制。确保你尝试不同的核矩阵,并使用filter2D函数进行实验。你还可以在网上搜索流行的滤波核矩阵,并使用filter2D函数应用它们。
图像变换
在本节中,我们将学习用于以某种方式变换图像的计算机视觉算法。本节中我们将学习的算法包括改变图像内容或其内容解释方式的算法。
阈值算法
阈值算法用于将阈值值应用于图像的像素。这些算法可以用来有效地从包含可能区域或感兴趣像素且通过一定阈值值的图像中创建掩码。
你可以使用threshold函数将阈值值应用于图像的所有像素。threshold函数必须提供所需的阈值类型,并且它可以是ThresholdTypes枚举中的一个条目。以下是一个示例,说明如何使用threshold函数找到图像中最亮的部分:
double thresh = 175.0;
double maxval = 255.0;
ThresholdTypes type = THRESH_BINARY;
threshold(image,
result,
thresh,
maxval,
type);
thresh是最低阈值值,而maxval是允许的最大值。简单来说,介于thresh和maxval之间的所有像素值都被允许通过,从而创建出result。以下是一个使用示例图像演示的先前threshold操作的结果:

增加阈值参数(或阈值值),你会注意到允许通过的像素更少。设置正确的阈值值需要经验和关于场景的知识。然而,在某些情况下,你可以开发出自动设置阈值或自适应阈值的程序。请注意,阈值类型完全影响threshold函数的结果。例如,THRESH_BINARY_INV将产生THRESH_BINARY的逆结果,依此类推。确保尝试不同的阈值类型,并亲自实验这个有趣且强大的函数。
另一种更复杂地将阈值应用于图像的方法是使用adaptiveThreshold函数,该函数与灰度图像一起工作。此函数将给定的maxValue参数分配给通过阈值标准的像素。除此之外,你必须提供一个阈值类型、自适应的阈值方法、定义像素邻域直径的块大小,以及从平均值中减去的常数值(取决于自适应阈值方法)。以下是一个示例:
double maxValue = 255.0;
AdaptiveThresholdTypes adaptiveMethod =
ADAPTIVE_THRESH_GAUSSIAN_C;
ThresholdTypes thresholdType = THRESH_BINARY;
int blockSize = 5;
double c = 0.0;
adaptiveThreshold(image,
result,
maxValue,
adaptiveMethod,
thresholdType,
blockSize,
c);
注意adaptiveMethod可以采用以下任何示例:
-
ADAPTIVE_THRESH_MEAN_C -
ADAPTIVE_THRESH_GAUSSIAN_C
blockSize参数值越高,自适应threshold方法中使用的像素就越多。以下是一个示例图像,展示了使用前面示例代码中的值调用adaptiveThreshold方法的结果:

颜色空间和类型转换
将各种颜色空间和类型相互转换非常重要,尤其是在处理来自不同设备类型或打算在不同设备和格式上显示的图像时。让我们用一个非常简单的例子来看看这意味着什么。您将需要用于各种 OpenCV 函数和计算机视觉算法的灰度图像,而有些则需要 RGB 颜色图像。在这种情况下,您可以使用cvtColor函数在各个颜色空间和格式之间进行转换。
以下是将彩色图像转换为灰度的示例:
ColorConversionCodes code = COLOR_RGB2GRAY;
cvtColor(image,
result,
code);
code可以接受一个转换代码,该代码必须是ColorConversionCodes枚举中的一个条目。以下是一些最流行的颜色转换代码的示例,这些代码可以与cvtColor函数一起使用:
-
COLOR_BGR2RGB -
COLOR_RGB2GRAY -
COLOR_BGR2HSV
列表可以一直继续。请确保查看ColorConversionCodes枚举以获取所有可能的颜色转换代码。
几何变换
本节专门介绍几何变换算法和 OpenCV 函数。需要注意的是,几何变换这一名称是基于以下事实:属于这一类别的算法不会改变图像的内容,它们只是变形现有的像素,同时使用外推和内插方法来计算位于现有像素区域之外或重叠的像素。
让我们从最简单的几何变换算法开始,该算法用于调整图像大小。您可以使用resize函数调整图像大小。以下是此函数的使用方法:
Size dsize(0, 0);
double fx = 1.8;
double fy = 0.3;
InterpolationFlags interpolation = INTER_CUBIC;
resize(image,
result,
dsize,
fx,
fy,
interpolation);
如果dsize参数设置为非零大小,则fx和fy参数用于缩放输入图像。否则,如果fx和fy都为零,则输入图像的大小调整为给定的dsize。另一方面,interpolation参数用于设置用于缩放算法的interpolation方法,并且它必须是InterpolationFlags枚举中的条目之一。以下是interpolation参数的一些可能值:
-
INTER_NEAREST -
INTER_LINEAR -
INTER_CUBIC -
INTER_AREA -
INTER_LANCZOS4
请确保查看 OpenCV 文档中的InterpolationFlags页面,以了解每种可能方法的详细信息。
以下图像展示了用于调整图像大小的先前示例代码的结果:

可能是最重要的几何变换算法,它能够执行大多数其他几何变换,这就是重映射算法,可以通过调用remap函数来实现。
remap函数必须提供两个映射矩阵,一个用于X方向,另一个用于Y方向。除此之外,还必须向remap函数提供一种插值和外推(边界类型)方法,以及在恒定边界类型的情况下,提供一个边界值。让我们首先看看这个函数是如何调用的,然后尝试几种不同的映射。以下是一个示例,展示了如何调用remap函数:
Mat mapX(image.size(), CV_32FC1);
Mat mapY(image.size(), CV_32FC1);
// Create maps here...
InterpolationFlags interpolation = INTER_CUBIC;
BorderTypes borderMode = BORDER_CONSTANT;
Scalar borderValue = Scalar(0, 0, 0);
remap(image,
result,
mapX,
mapY,
interpolation,
borderMode,
borderValue);
你可以创建无限数量的不同映射,并使用remap函数来调整图像大小、翻转、扭曲以及进行许多其他变换。例如,以下代码可以用来创建一个映射,这将导致输出图像进行垂直翻转:
for(int i=0; i<image.rows; i++)
for(int j=0; j<image.cols; j++)
{
mapX.at<float>(i,j) = j;
mapY.at<float>(i,j) = image.rows-i;
}
将前一个for循环中的代码替换为以下代码,remap函数调用的结果将是一个水平翻转的图像:
mapX.at<float>(i,j) = image.cols - j;
mapY.at<float>(i,j) = i;
除了简单的翻转之外,你还可以使用remap函数执行许多有趣的像素变形。以下是一个示例:
Point2f center(image.cols/2,image.rows/2);
for(int i=0; i<image.rows; i++)
for(int j=0; j<image.cols; j++)
{
// find i,j in the standard coordinates
double x = j - center.x;
double y = i - center.y;
// Perform any mapping for X and Y
x = x*x/750;
y = y;
// convert back to image coordinates
mapX.at<float>(i,j) = x + center.x;
mapY.at<float>(i,j) = y + center.y;
}
如前一个示例代码的行内注释所示,将 OpenCV 的i和j值(行和列号)转换为标准坐标系,并使用X和Y与已知的数学和几何函数一起使用,然后再将它们转换回 OpenCV 图像坐标,这是常见的做法。以下图像展示了前一个示例代码的结果:

只要mapX和mapY的计算效率高,remap函数就非常强大且高效。请确保尝试这个函数,以了解更多的映射可能性。
计算机视觉和 OpenCV 库中有大量的几何变换算法,要涵盖所有这些算法需要一本自己的书。因此,我们将剩下的几何变换算法留给你去探索和尝试。请参考 OpenCV imgproc(图像处理)模块文档中的几何图像变换部分,以获取更多几何变换算法和函数。特别是,请确保了解包括getPerspectiveTransform和getAffineTransform在内的函数,这些函数用于找到两组点之间的透视和仿射变换。这些函数返回的变换矩阵可以用于通过使用warpPerspective和warpAffine函数来应用透视和仿射变换到图像上。
应用颜色图
我们将通过学习将色图应用于图像来结束本章。这是一个相当简单但功能强大的方法,可以用来修改图像的颜色或其总体色调。该算法简单地使用色图替换输入图像的颜色并创建一个结果。色图是一个包含 256 个颜色值的数组,其中每个元素代表源图像中相应像素值必须使用的颜色。我们将通过几个示例进一步解释,但在那之前,让我们看看色图是如何应用于图像的。
OpenCV 包含一个名为applyColorMap的函数,可以用来应用预定义的色图或用户创建的自定义色图。如果使用预定义的色图,applyColorMap必须提供一个色图类型,这必须是ColormapTypes枚举中的一个条目。以下是一个示例:
ColormapTypes colormap = COLORMAP_JET;
applyColorMap(image,
result,
colormap);
下面的图像展示了可以使用applyColorMap函数应用的各种预定义色图的结果:

如前所述,你还可以创建自己的自定义色图。你只需确保遵循创建色图的说明。你的色图必须包含 256 个元素的大小(一个具有 256 行和 1 列的Mat对象)并且它必须包含颜色或灰度值,具体取决于你打算将色图应用于哪种类型的图像。以下是一个示例,展示了如何通过简单地反转绿色通道颜色来创建自定义色图:
Mat userColor(256, 1, CV_8UC3);
for(int i=0; i<=255; i++)
userColor.at<Vec3b>(i,0) = Vec3b(i, 255-i, i);
applyColorMap(image,
result,
userColor);
下面是前面示例代码的结果:

概述
尽管我们试图涵盖计算机视觉中图像处理算法的最重要主题,但我们还有很长的路要走,还有更多的算法要学习。原因很简单,那就是这些算法可以用于各种应用。在本章中,我们学习了大量的广泛使用的计算机视觉算法,但还应注意的是,本章中我们所学的内容也是为了在你开始自己探索其他图像处理算法时能更容易一些。
我们从学习可以用于在图像上绘制形状和文本的绘图函数开始本章。然后我们转向学习最重要的计算机视觉主题之一:图像滤波。我们学习了如何使用平滑算法,实验了形态学滤波器,并了解了腐蚀、膨胀、开运算和闭运算。我们还尝试了一些简单的边缘检测算法,换句话说,基于导数的滤波器。我们学习了图像阈值处理和改变它们的颜色空间和类型。本章的最后部分介绍了几何变换和将颜色图应用于图像。我们甚至创建了自己的自定义颜色图。你可以在许多专业照片编辑或社交网络和照片共享应用中轻松找到我们在这章中学到的算法的痕迹。
在下一章中,我们将学习所有关于直方图的内容,包括它们的计算方式以及它们在计算机视觉中的应用。我们将涵盖在即将到来的章节中探索的某些对象检测和跟踪算法中至关重要的算法。
问题
-
编写一个程序,在整张图像上绘制一个红色、3 像素粗的十字标记。
-
创建一个带有滑块的窗口,用于更改
medianBlur函数的ksize值。kszise值的可能范围应在 3 到 99 之间。 -
对图像执行梯度形态学操作,考虑结构元素的核大小为 7,矩形形态学形状。
-
使用
cvtColor将彩色图像转换为灰度图,并确保使用threshold函数过滤掉最暗的 100 种灰色调。确保过滤后的像素在结果图像中设置为白色,其余像素设置为黑色。 -
使用
remap函数将图像调整为其原始宽度和高度的一半,从而保留原始图像的宽高比。使用默认的边界类型进行外推。 -
a) 使用颜色图将图像转换为灰度图。b) 将图像转换为灰度图并同时反转其像素。
-
你读过关于透视变换函数的内容吗?哪个 OpenCV 函数覆盖了所有类似的变换在一个单一函数中?
第五章:反向投影和直方图
在上一章中,我们学习了众多计算机视觉算法和 OpenCV 函数,这些函数可用于准备图像以供进一步处理或以某种方式修改它们。我们学习了如何在图像上绘制文本和形状,使用平滑算法对其进行过滤,对其进行形态学变换,并计算其导数。我们还学习了图像的几何和杂项变换,并应用色图来改变图像的色调。
在本章中,我们将学习一些更多用于准备图像以供进一步处理、推理和修改的算法和函数。这一点将在本章后面进一步阐明,在我们学习了计算机视觉中的直方图之后。我们将介绍直方图的概念,然后通过实际示例代码学习它们是如何计算和利用的。本章我们将学习的另一个极其重要的概念被称为反向投影。我们将学习如何使用直方图的反向投影来创建原始图像的修改版本。
除了它们的常规用途外,本章我们将学习的概念和算法在处理一些最广泛使用的目标检测和跟踪算法时也是必不可少的,这些算法将在接下来的章节中学习。
在本章中,我们将涵盖以下内容:
-
理解直方图
-
直方图反向投影
-
直方图比较
-
直方图均衡化
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
请参阅第二章,使用 OpenCV 入门,获取有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息。
您可以使用以下 URL 下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter05
理解直方图
在计算机视觉中,直方图简单地说是表示像素值在像素可能接受值范围内的分布的图表,或者说,是像素的概率分布。嗯,这可能不像你期望的那样清晰,所以让我们以单通道灰度图像作为一个简单的例子来描述直方图是什么,然后扩展到多通道彩色图像等。我们已经知道,标准灰度图像中的像素可以包含 0 到 255 之间的值。考虑到这一点,一个类似于以下图表的图形,它描述了任意图像中每个可能的灰度像素值的像素数量比,就是该给定图像的直方图:

考虑到我们刚刚学到的内容,可以很容易地猜测,例如,三通道图像的直方图将是由三个图表表示的,每个通道的值分布,类似于我们刚才看到的单通道灰度图像的直方图。
您可以使用 OpenCV 库中的 calcHist 函数计算一个或多个图像的直方图,这些图像可以是单通道或多通道。此函数需要提供一些参数,必须仔细提供才能产生所需的结果。让我们通过几个示例来看看这个函数是如何使用的。
以下示例代码(随后是对所有参数的描述)演示了如何计算单个灰度图像的直方图:
Mat image = imread("Test.png");
if(image.empty())
return -1;
Mat grayImg;
cvtColor(image, grayImg, COLOR_BGR2GRAY);
int bins = 256;
int nimages = 1;
int channels[] = {0};
Mat mask;
int dims = 1;
int histSize[] = { bins };
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
bool uniform = true;
bool accumulate = false;
Mat histogram;
calcHist(&grayImg,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform,
accumulate);
从前面的代码中,我们可以推断出以下内容:
-
grayImg是想要计算其直方图的输入灰度图像,而histogram将包含结果。 -
nimages必须包含我们想要计算直方图的图像数量,在这个例子中,只有一个图像。 -
channels是一个数组,它应该包含我们想要计算其直方图的每个图像中通道的零基于索引号。例如,如果我们想要计算多通道图像中第一个、第二个和第四个通道的直方图,channels数组必须包含 0、1 和 3 的值。在我们的例子中,channels只包含0,因为我们正在计算灰度图像中唯一通道的直方图。 -
mask,这是许多其他 OpenCV 函数的共同参数,是一个用于屏蔽(或忽略)某些像素,或者换句话说,防止它们参与计算结果的参数。在我们的情况下,只要我们不在图像的某个部分上工作,mask必须包含一个空矩阵。 -
dims,或维度参数,对应于我们正在计算的直方图的结果维度。它必须不大于CV_MAX_DIM,在当前 OpenCV 版本中为 32。我们大多数情况下将使用1,因为我们期望我们的直方图是一个简单的数组形状矩阵。因此,结果直方图中每个元素的索引号将对应于箱子号。 -
histSize是一个数组,它必须包含每个维度中直方图的大小。在我们的例子中,由于维度是1,histSize必须包含一个单一值。在这种情况下,直方图的大小与直方图中的箱子数量相同。在前面的示例代码中,bins用于定义直方图中的箱子数量,并且它也被用作单一的histSize值。将bins想象为直方图中的像素组数量。这将在稍后的示例中进一步阐明,但就目前而言,重要的是要注意,bins的值为256将导致包含所有可能的单个像素值计数的直方图。 -
当计算图像的直方图时,
ranges必须包含对应于每个可能值范围的上下限值对。在我们的示例中,这意味着单个范围(0,256)中的一个值,这是我们提供给此参数的值。 -
uniform参数用于定义直方图的均匀性。请注意,如果直方图是非均匀的,与我们的示例中展示的不同,则ranges参数必须包含所有维度的下限和上限。 -
accumulate参数用于决定在计算直方图之前是否应该清除它,或者将计算出的值添加到现有的直方图中。当你需要使用多张图像计算单个直方图时,这非常有用。
我们将在本章提供的示例中尽可能多地介绍这里提到的参数。然而,你也可以参考calcHist函数的在线文档以获取更多信息。
显示直方图
显然,尝试使用如imshow之类的函数显示结果直方图是徒劳的,因为存储的直方图的原始格式类似于一个具有bins行数的单列矩阵。直方图的每一行,或者说每个元素,对应于落入该特定 bin 的像素数。考虑到这一点,我们可以使用第四章,绘图、滤波和变换中的绘图函数绘制计算出的直方图。
下面是一个示例,展示了我们如何将前面代码样本中计算出的直方图显示为具有自定义大小和属性的图形:
int gRows = 200; // height
int gCol = 500; // width
Scalar backGroundColor = Scalar(0, 255, 255); // yellow
Scalar graphColor = Scalar(0, 0, 0); // black
int thickness = 2;
LineTypes lineType = LINE_AA;
Mat theGraph(gRows, gCol, CV_8UC(3), backGroundColor);
Point p1(0,0), p2(0,0);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
line(theGraph,
p1,
Point(p1.x,value),
graphColor,
thickness,
lineType);
p1.y = p2.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
line(theGraph,
p1, p2,
Scalar(0,0,0));
p1.x = p2.x;
}
在前面的代码中,gRow和gCol分别代表结果的图形的高度和宽度。其余参数要么是自解释的(如backgroundColor等),或者你在前面的章节中已经了解过它们。注意histogram中的每个值是如何用来计算需要绘制的线的位置的。在前面的代码中,maxVal简单地用于将结果缩放到可见范围。以下是maxVal本身是如何计算的:
double maxVal = 0;
minMaxLoc(histogram,
0,
&maxVal,
0,
0);
如果你需要刷新关于minMaxLoc函数如何使用的记忆,请参阅第三章,数组和矩阵操作。在我们的示例中,我们只需要直方图中最大元素的值,所以我们通过将它们传递零来忽略其余参数。
以下是前面示例代码的结果:

你可以通过提供的backGroundColor或graphColor参数轻松更改背景或图形颜色,或者通过更改thickness参数使图形更细或更粗,等等。
直方图的解释非常重要,尤其是在摄影和照片编辑应用中,因此能够可视化它们对于更容易解释结果至关重要。例如,在前例中,可以从结果直方图中轻松看出,源图像包含比亮色更多的暗色调。我们将在稍后看到更多关于暗色和亮色图像的示例,但在那之前,让我们看看改变桶数会如何影响结果。
下面的直方图是之前示例中相同图像的结果,从左到右分别使用 150、80 和 25 个桶进行柱状图可视化:

你可以很容易地注意到,桶值越低,像素越聚集在一起。尽管这看起来更像是相同数据(从左到右)的较低分辨率,但实际上使用更少的桶值将相似像素聚集在一起是更好的选择。请注意,前例中的柱状图可视化是通过将前例代码中的for循环替换为以下代码产生的:
Point p1(0,0), p2(0, theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value *= 0.95f; // 5% empty at top
value = maxVal - value; // invert
value = value / (maxVal) * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
graphColor,
CV_FILLED,
lineType);
p1.x = p2.x;
}
这两种可视化(图形或柱状图)各有其优缺点,当你尝试计算不同类型图像的直方图时,这些优缺点将更加明显。让我们尝试计算一张彩色图像的直方图。我们需要计算各个通道的直方图,正如之前提到的。以下是一个示例代码,展示了如何进行操作:
Mat image = imread("Test.png");
if(image.empty())
{
cout << "Empty input image!";
return -1;
}
Mat imgChannels[3];
Mat histograms[3];
split(image, imgChannels);
// each imgChannels element is an individual 1-channel image
CvHistGraphColor, and running it would produce a result similar to what is seen in the following diagram:

如前例代码所示,split函数被用来从我们的源彩色图像(默认为 BGR)中创建三个单独的图像,每个图像包含一个单独的通道。前例代码中提到的带有注释行的代码部分,实际上是一个for循环,它遍历imgChannels的元素,并使用与之前看到的完全相同的代码绘制每个图表,但每个图表都有其独特的颜色,该颜色是通过循环中的以下代码计算的:
Scalar graphColor = Scalar(i == 0 ? 255 : 0,
i == 1 ? 255 : 0,
i == 2 ? 255 : 0);
根据i的值,graphColor被设置为蓝色、绿色或红色,因此产生了之前图片中显示的直方图。
除了解释图像内容或查看像素值在图像中的分布情况外,直方图还有许多用途。在接下来的章节中,我们将学习关于反投影和其他算法,这些算法用于在我们的应用中利用直方图。
直方图的反投影
从上一节开头的直方图定义开始考虑,可以说图像上直方图的反向投影意味着用其每个像素的概率分布值替换它们。这在某种程度上(并不完全)是计算图像直方图的逆操作。当我们对图像上的直方图进行反向投影时,我们实际上是用直方图来修改图像。让我们首先看看如何使用 OpenCV 执行反向投影,然后深入了解其实际应用。
您可以使用 calcBackProject 函数来计算图像上直方图的反向投影。此函数需要与 calcHist 函数类似的参数集。让我们看看它是如何调用的,然后进一步分解其参数:
calcBackProject(&image,
nimages,
channels,
histogram,
backProj,
ranges,
scale,
uniform);
calcBackProject 函数中的 nimages、channels、ranges 和 uniform 参数的使用方式与 calcHist 函数中的使用方式完全相同。image 必须包含输入图像,而 histogram 需要通过先前的 calcHist 函数调用或任何其他方法(甚至手动)来计算。结果将通过使用 scale 参数进行缩放,最后将保存在 backProj 中。重要的是要注意,histogram 中的值可能超过正确的可显示范围,因此在进行反向投影后,结果 backProj 对象将无法正确显示。为了解决这个问题,我们需要首先确保 histogram 被归一化到 OpenCV 的可显示范围。以下代码必须在执行前面的 calcBackProject 调用之前执行,以便结果 backProj 可显示:
normalize(histogram,
histogram,
0,
255,
NORM_MINMAX);
以下图像展示了使用其原始直方图(未修改的直方图)进行反向投影的结果。右侧的图像是反向投影算法的结果:

根据直方图和反向投影的定义,可以说前一个反向投影结果图像中的较暗区域包含比原始图像中更不常见的像素,反之亦然。此算法可用于(甚至滥用)使用修改后的或手动制作的直方图来改变图像。这种技术通常用于创建仅提取包含给定颜色或强度的图像部分的掩码。
下面是一个示例,演示了如何使用直方图和反向投影的概念来检测图像中位于可能像素值最亮 10% 范围内的像素:
int bins = 10; // we need 10 slices
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
int channels[] = {0};
Mat histogram(bins, 1, CV_32FC1, Scalar(0.0));
histogram.at<float>(9, 0) = 255.0;
calcBackProject(&imageGray,
1,
channels,
histogram,
backProj,
ranges);
注意,直方图是手动形成的,有 10 个桶,而不是从原始图像中计算得出。然后,最后一个桶,或者说直方图的最后一个元素,被设置为 255,这意味着绝对白色。显然,如果没有这样做,我们就需要执行归一化以确保反向投影的结果在可显示的颜色范围内。
以下图像展示了在执行上述代码片段时,在之前示例中的相同样本图像上的结果:

提取的掩码图像可以用于进一步修改图像,或者在具有独特颜色的对象的情况下,可以用于检测和跟踪该对象。检测和跟踪算法将在接下来的章节中详细讲解,但我们将学习如何具体使用对象的颜色。
学习更多关于反向投影
首先,让我们回顾一下 HSV 颜色空间在处理图像中像素的实际颜色值方面比标准的 RGB(或 BGR 等)颜色空间更适合。你可能需要回顾第一章,计算机视觉简介,以了解更多关于这一现象的信息。我们将利用这一简单事实来找到图像中具有特殊颜色的区域,无论其颜色强度、亮度等。因此,我们需要首先将图像转换为 HSV 颜色空间
让我们用一个示例案例来简化这一点。假设我们想要替换图像中的特定颜色,同时保留高光、亮度等。为了能够执行此类任务,我们需要能够准确检测给定的颜色,并确保我们只更改检测到的像素中的颜色,而不是它们的亮度和其他类似属性。以下示例代码演示了我们可以如何使用手动形成的色调通道直方图及其反向投影来提取具有特定颜色的像素,在这个例子中,假设颜色是蓝色:
- 要执行此类操作,我们需要首先读取一个图像,将其转换为 HSV 颜色空间,并提取色调通道,换句话说,就是第一个通道,如下所示:
Mat image = imread("Test.png");
if(image.empty())
{
cout << "Empty input image!";
return -1;
}
Mat imgHsv, hue;
vector<Mat> hsvChannels;
cvtColor(image, imgHsv, COLOR_BGR2HSV);
split(imgHsv, hsvChannels);
hue = hsvChannels[0];
- 现在我们已经将色调通道存储在
hue对象中,我们需要形成色调通道的适当直方图,其中只包含具有蓝色颜色的像素。色调值可以在0到360(度)之间,蓝色的色调值为240。因此,我们可以使用以下代码创建一个直方图,用于提取具有蓝色颜色的像素,偏移量(或阈值)为50像素:
int bins = 360;
int blueHue = 240;
int hueOffset = 50;
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) =
(i > blueHue - hueOffset)
&&
(i < blueHue + hueOffset)
?
255.0 : 0.0;
}
上述代码像一个简单的阈值,其中直方图中索引为240(加减50)的所有元素都被设置为255,其余的设置为零。
- 通过可视化手动创建的色调通道直方图,我们可以更好地了解将要使用它提取的确切颜色。以下代码可以轻松地可视化色调直方图:
double maxVal = 255.0;
int gW = 800, gH = 100;
Mat theGraph(gH, gW, CV_8UC3, Scalar::all(0));
Mat colors(1, bins, CV_8UC3);
for(int i=0; i<bins; i++)
{
colors.at<Vec3b>(i) =
Vec3b(saturate_cast<uchar>(
(i+1)*180.0/bins), 255, 255);
}
cvtColor(colors, colors, COLOR_HSV2BGR);
Point p1(0,0), p2(0,theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
Scalar(colors.at<Vec3b>(i)),
CV_FILLED);
p1.x = p2.x;
}
在进行下一步之前,让我们分析前面的示例代码。它几乎与可视化灰度直方图或单个红、绿或蓝通道直方图完全相同。然而,关于前面代码的有趣事实是我们在哪里形成colors对象。colors对象将是一个简单的向量,包含色调光谱中所有可能的颜色,但根据我们的 bin 数量。注意我们是如何在 OpenCV 中使用saturate_cast函数来确保色调值饱和到可接受的范围。S 和 V 通道简单地设置为它们可能的最大值,即 255。在colors对象正确创建后,我们使用了之前相同的可视化函数。然而,由于 OpenCV 默认不显示 HSV 颜色空间中的图像(你可以在大多数图像显示函数和库中预期这种行为),我们需要将 HSV 颜色空间转换为 BGR 才能正确显示颜色。
尽管色调可以取(0,360)范围内的值,但无法将其存储在单字节 C++类型(如uchar)中,这些类型能够存储(0,255)范围内的值。这就是为什么在 OpenCV 中,色调值被认为是在(0,180)范围内,换句话说,它们只是简单地除以二。
以下图像描述了如果我们尝试使用imshow函数显示theGraph时前面示例代码的结果:

如果我们使用其相应的直方图来计算图像的反向投影,我们将从中提取这些颜色。这个颜色范围是通过我们在形成直方图时所做的简单阈值(在循环中)创建的。显然,如果你将直方图的全部值设置为255.0而不是仅蓝色范围,你将得到整个颜色光谱。以下是一个简单的示例:
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) = 255.0;
}
可视化输出将是以下内容:

现在,让我们回到我们原始的仅蓝色直方图,并继续进行剩余的步骤。
- 我们已经准备好计算我们示例的第一步中提取的色调通道上的直方图的反向投影。以下是操作方法:
int nimages = 1;
int channels[] = {0};
Mat backProject;
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
double scale = 1.0;
bool uniform = true;
calcBackProject(&hue,
nimages,
channels,
histogram,
backProject,
ranges,
scale,
uniform);
这与我们创建灰度通道的反向投影非常相似,但在这个例子中,范围被调整为正确表示色调通道的可能值,即0到180。
以下图像显示了此类反向投影的结果,其中提取了蓝色像素:

注意,具有灰度颜色(包括白色和黑色)的像素也可能具有与我们想要提取的色调值相似的价值,但由于改变它们的色调值不会对它们的颜色产生任何影响,因此我们可以在我们的示例案例中简单地忽略它们。
- 使用
calcBackProject函数提取像素后,我们需要调整这些像素的色调。我们只需遍历像素并将它们的第一个通道以任何期望的值进行偏移。显然,结果必须在显示之前转换为 BGR 格式。以下是具体步骤:
int shift = -50;
for(int i=0; i<imgHsv.rows; i++)
{
for(int j=0; j<imgHsv.cols; j++)
{
if(backProject.at<uchar>(i, j))
{
imgHsv.at<Vec3b>(i,j)[0] += shift;
}
}
}
Mat imgHueShift;
cvtColor(imgHsv, imgHueShift, CV_HSV2BGR);
在前面的示例中,我们使用了-50的shift值,这将导致蓝色像素变成绿色,同时保持其亮度,依此类推。使用不同的shift值会导致不同的颜色替换蓝色像素。以下是两个示例:

在前面的示例中,我们学到的内容是许多基于颜色的检测和跟踪算法的基础,正如我们将在接下来的章节中学习的那样。能够正确提取特定颜色的像素,无论其亮度如何变化,都非常有用。当使用色调而不是红、绿或蓝通道时,颜色的亮度变化就是当某个颜色的物体上的光照改变,或者在白天和夜晚时发生的情况。
在进入本章的最后一部分之前,值得注意的是,我们用于显示假设色调通道手动制作的直方图的完全相同的可视化方法也可以用于可视化从图像计算出的颜色直方图。让我们通过一个示例来看看如何实现。
在前面的示例中,在初始步骤之后,我们不是手动形成直方图,而是简单地使用calcHist算法进行计算,如下所示:
int bins = 36;
int histSize[] = {bins};
int nimages = 1;
int dims = 1;
int channels[] = {0};
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
bool uniform = true;
bool accumulate = false;
Mat histogram, mask;
calcHist(&hue,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform,
accumulate);
改变 bin 大小的影响与我们在灰度图和单通道直方图中看到的影响相似,即它将附近的值分组在一起。然而,在可视化色调通道时,附近的色调值将被分组在一起,这导致色调直方图更好地表示图像中的相似颜色。以下示例图像展示了前面可视化结果,但使用了不同的bins值。从上到下,计算每个直方图所使用的bins值分别是 360、100、36 和 7。注意,随着 bins 值的减小,直方图的分辨率降低:

选择合适的 bins 值完全取决于你处理的对象类型以及你对相似颜色的定义。从前面的图像中可以看出,显然当我们需要至少一些相似颜色的分组时,选择一个非常高的 bin 值(例如 360)是没有用的。另一方面,选择一个非常小的 bin 大小可能会导致颜色极端分组,计算反向投影时不会产生准确的结果。请确保明智地选择 bins 值,并根据不同的主题进行变化。
比较直方图
可以通过比较直方图来获取对图像内容的某些洞察。OpenCV 允许使用名为compareHist的方法比较直方图,这需要首先设置比较方法。以下示例代码展示了如何使用此函数计算使用之前对calcHist函数的调用计算的两个直方图之间的比较结果:
HistCompMethods method = HISTCMP_CORREL;
double result = compareHist(histogram1, histogram2, method);
在前面的例子中,histogram1和histogram2仅仅是两个不同图像的直方图,或者是一个图像的不同通道。另一方面,method必须包含来自HistCompMethods枚举的有效条目,它定义了compareHist函数使用的比较算法,并且可以是以下方法中的任何一个:
-
HISTCMP_CORREL,用于相关方法 -
HISTCMP_CHISQR,用于卡方方法 -
HISTCMP_INTERSECT,用于交集方法 -
HISTCMP_BHATTACHARYYA,用于 Bhattacharyya 距离方法 -
HISTCMP_HELLINGER,与HISTCMP_BHATTACHARYYA相同 -
HISTCMP_CHISQR_ALT,用于替代卡方方法 -
HISTCMP_KL_DIV,用于 Kullback-Leibler 散度方法
您可以参考最新的 OpenCV 文档以获取有关每种方法的数学细节以及它们如何以及使用哪些直方图属性的信息。同样,这也适用于任何方法的解释结果。让我们通过一个示例来看看这意味着什么。使用以下示例代码,我们可以输出所有直方图比较方法的结果:
cout << "HISTCMP_CORREL: " <<
compareHist(histogram1, histogram2, HISTCMP_CORREL)
<< endl;
cout << "HISTCMP_CHISQR: " <<
compareHist(histogram1, histogram2, HISTCMP_CHISQR)
<< endl;
cout << "HISTCMP_INTERSECT: " <<
compareHist(histogram1, histogram2, HISTCMP_INTERSECT)
<< endl;
cout << "HISTCMP_BHATTACHARYYA: " <<
compareHist(histogram1, histogram2, HISTCMP_BHATTACHARYYA)
<< endl;
cout << "HISTCMP_HELLINGER: " <<
compareHist(histogram1, histogram2, HISTCMP_HELLINGER)
<< endl;
cout << "HISTCMP_CHISQR_ALT: " <<
compareHist(histogram1, histogram2, HISTCMP_CHISQR_ALT)
<< endl;
cout << "HISTCMP_KL_DIV: " <<
compareHist(histogram1, histogram2, HISTCMP_KL_DIV)
<< endl;
我们使用本章中一直使用的示例图像来计算histogram1和histogram2,换句话说,如果我们比较一个直方图与一个等直方图,这里是我们会得到的结果:
HISTCMP_CORREL: 1
HISTCMP_CHISQR: 0
HISTCMP_INTERSECT: 426400
HISTCMP_BHATTACHARYYA: 0
HISTCMP_HELLINGER: 0
HISTCMP_CHISQR_ALT: 0
HISTCMP_KL_DIV: 0
注意基于距离和发散的方法返回零值,而相关方法返回一值,对于完全相关。前面输出中的所有结果都表示等直方图。让我们通过计算以下两个图像的直方图来进一步说明:

如果左边的图像用于创建histogram1,右边的图像用于创建histogram2,或者换句话说,一个任意亮图像与一个任意暗图像进行比较,以下结果将会产生:
HISTCMP_CORREL: -0.0449654
HISTCMP_CHISQR: 412918
HISTCMP_INTERSECT: 64149
HISTCMP_BHATTACHARYYA: 0.825928
HISTCMP_HELLINGER: 0.825928
HISTCMP_CHISQR_ALT: 1.32827e+06
HISTCMP_KL_DIV: 3.26815e+06
重要的是要注意,在某些情况下,传递给compareHist函数的直方图的顺序很重要,例如当使用HISTCMP_CHISQR作为方法时。以下是histogram1和histogram2以相反顺序传递给compareHist函数的结果:
HISTCMP_CORREL: -0.0449654
HISTCMP_CHISQR: 3.26926e+06
HISTCMP_INTERSECT: 64149
HISTCMP_BHATTACHARYYA: 0.825928
HISTCMP_HELLINGER: 0.825928
HISTCMP_CHISQR_ALT: 1.32827e+06
HISTCMP_KL_DIV: 1.15856e+07
比较直方图非常有用,尤其是在我们需要更好地了解各种图像之间的变化时。例如,比较来自摄像机的连续帧的直方图可以给我们一个关于这些连续帧之间强度变化的想法。
直方图均衡化
使用我们迄今为止学到的函数和算法,我们可以增强图像的强度分布,换句话说,调整过暗或过亮图像的亮度,以及其他许多操作。在计算机视觉中,直方图均衡化算法出于完全相同的原因被使用。此算法执行以下任务:
-
计算图像的直方图
-
归一化直方图
-
计算直方图的积分
-
使用更新的直方图修改源图像
除了积分部分,它只是简单地计算所有箱中值的总和之外,其余的都是在本章中以某种方式已经执行过的。OpenCV 包含一个名为 equalizeHist 的函数,该函数执行所有提到的操作,并生成一个具有均衡直方图的图像。让我们首先看看这个函数是如何使用的,然后尝试一个示例来看看我们自己的效果。
以下示例代码展示了如何使用 equalizeHist 函数,这个函数极其容易使用,并且不需要任何特殊参数:
Mat equalized;
equalizeHist(gray, equalized);
让我们考虑以下图像,它极度过度曝光(或明亮),以及其直方图,如右侧所示:

使用 equalizeHist 函数,我们可以得到对比度和亮度更好的图像。以下是前面示例图像在直方图均衡化后的结果图像和直方图:

当我们不得不处理可能过度曝光(太亮)或欠曝光(太暗)的图像时,直方图均衡化非常有帮助。例如,X 射线扫描图像,其中细节只有在使用强背光增加对比度和亮度时才可见,或者当我们与可能具有强烈光线变化的视频帧一起工作时,这些都是可以使用直方图均衡化来确保其余算法始终处理相同的,或者只是略微不同的亮度对比度级别的情况。
摘要
我们从学习直方图开始本章,了解它们是什么,以及如何使用 OpenCV 库计算它们。我们学习了直方图的箱大小及其如何影响直方图中值的准确性或分组。我们继续学习使用我们在第四章绘图、过滤和转换中学到的函数和算法来可视化直方图。在经过各种可视化类型后,我们学习了反向投影以及如何使用直方图更新图像。我们学习了检测具有特定颜色的像素以及如何移动色调值,从而仅改变这些特定像素的颜色。在本章的最后部分,我们学习了比较直方图和直方图均衡化算法。我们进行了可能的直方图比较场景的动手示例,并增强了曝光过度的图像的对比度和亮度。
直方图及其如何通过反向投影增强和修改图像是计算机视觉主题之一,不能轻易跳过或错过,因为它构成了许多图像增强算法和照片编辑应用中的技术基础,或者,正如我们将在接下来的章节中看到的,它是某些最重要的实时检测和跟踪算法的基础。在本章中,我们学习了直方图和反向投影的一些最实用的用例,但如果你开始构建使用直方图的现实生活项目,这些算法肯定还有更多内容。
在下一章中,我们将使用在本章和前几章中学到的所有概念来处理视频和视频帧,以检测具有特定颜色的对象,实时跟踪它们,或在视频中检测运动。
问题
-
计算三通道图像中第二个通道的直方图。使用可选的箱大小和 0 到 100 的范围作为第二个通道的可能值。
-
创建一个直方图,可用于与
calcBackProject函数一起使用,以从灰度图像中提取最暗的像素。考虑最暗的 25% 可能的像素值作为我们想要提取的灰度强度。 -
在上一个问题中,如果我们需要排除而不是提取最暗和最亮的 25% 的像素,在蒙版中会怎样?
-
红色的色调值是多少?应该移动多少才能得到蓝色?
-
创建一个色调直方图,可用于从图像中提取红色像素。考虑将 50 作为被认为是红色的像素的偏移量。最后,可视化计算出的色调直方图。
-
计算直方图的积分。
-
对彩色图像执行直方图均衡化。注意,
equalizeHist函数仅支持单通道 8 位灰度图像的直方图均衡化。
进一步阅读
-
通过示例学习 OpenCV 3.x 与 Python 第二版 (
www.packtpub.com/application-development/opencv-3x-python-example-second-edition) -
使用 OpenCV 3 和 Qt5 进行计算机视觉 (
www.packtpub.com/application-development/computer-vision-opencv-3-and-qt5)
第六章:视频分析——运动检测和跟踪
作为一名计算机视觉开发者,你绝对无法避免处理来自存储的视频文件或摄像头以及其他类似来源的视频流。将视频帧视为单独的图像是处理视频的一种方法,令人惊讶的是,这并不需要比你所学到的更多的努力或算法知识。例如,你可以对视频应用平滑滤波器,或者说,对一组视频帧应用,就像你在对单个图像应用时一样。这里的唯一技巧是,你必须按照第二章“OpenCV 入门”中描述的方法从视频中提取每一帧。然而,在计算机视觉中,有一些算法旨在与连续的视频帧一起工作,并且它们操作的结果不仅取决于单个图像,还取决于对前一个帧进行相同操作的结果。我们刚才提到的两种算法类型将是本章的主要内容。
在上一章学习了直方图和反向投影图像之后,我们已准备好去应对用于实时检测和跟踪物体的计算机视觉算法。这些算法高度依赖于我们对第五章“反向投影和直方图”中所有主题的牢固理解。基于此,我们将从几个简单的例子开始本章,这些例子展示了如何使用我们迄今为止学到的计算机视觉算法来处理视频文件或摄像头捕获的帧,然后我们将继续学习关于两种最著名的对象检测和跟踪算法——均值漂移和 CAM 漂移算法。接着,我们将学习如何使用卡尔曼滤波器来校正我们的对象检测和跟踪算法的结果,以及如何去除结果中的噪声以获得更好的跟踪结果。本章的结尾,我们将学习运动分析和背景/前景提取。
本章将涵盖以下内容:
-
如何在视频上应用过滤器并执行此类操作
-
使用均值漂移算法检测和跟踪对象
-
使用 CAM 漂移算法检测和跟踪对象
-
使用卡尔曼滤波器提高跟踪结果并去除噪声
-
使用背景和前景提取算法
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章“OpenCV 入门”。
您可以使用以下 URL 下载本章的源代码和示例:github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter06。
处理视频
要能够在视频上使用我们迄今为止学到的任何算法,我们需要能够读取视频帧并将它们存储在Mat对象中。本书的前几章我们已经学习了如何处理视频文件、摄像头和 RTSP 流。因此,在此基础上,利用我们之前章节中学到的知识,我们可以使用以下类似的代码,以便将颜色图应用于计算机默认摄像头的视频流:
VideoCapture cam(0);
// check if camera was opened correctly
if(!cam.isOpened())
return -1;
// infinite loop
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
applyColorMap(frame, frame, COLORMAP_JET);
// display the frame
imshow("Camera", frame);
// stop camera if space is pressed
if(waitKey(10) == ' ')
break;
}
cam.release();
正如我们在第二章“使用 OpenCV 入门”中学到的,我们只需创建一个VideoCapture对象,并从默认摄像头(索引为零)读取视频帧。在前面的示例中,我们添加了一行代码,用于在提取的视频帧上应用颜色图。尝试前面的示例代码,你会看到COLORMAP_JET颜色图被应用于摄像头的每一帧,就像我们在第四章“绘制、过滤和变换”中学到的那样,最终结果实时显示。按下空格键将停止视频处理。
同样,我们可以根据按下的特定键实时执行不同的视频处理算法。以下是一个示例,只需替换前面代码中的for循环,除非按下J或H键,否则将显示原始视频:
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
switch (key)
{
case 'j': applyColorMap(frame, frame, COLORMAP_JET);
break;
case 'h': applyColorMap(frame, frame, COLORMAP_HOT);
break;
}
imshow("Camera", frame);
int k = waitKey(10);
if(k > 0)
key = k;
}
除非按下提到的任何键,否则将显示摄像头的原始输出视频。按下J键将触发COLORMAP_JET,而按下H键将触发COLORMAP_HOT颜色图应用于摄像头帧。与前面的示例类似,按下空格键将停止过程。此外,按下除空格、J或H以外的任何键将导致显示原始视频。
前面示例中的applyColorMap函数只是一个随机算法,用于描述实时处理视频所使用的技巧。你可以使用本书中学到的任何在单个图像上执行的算法。例如,你可以编写一个程序对视频执行平滑滤波,或傅里叶变换,甚至编写一个实时显示色调通道直方图的程序。用例无限,然而,用于所有在单个图像上执行单个完整操作算法的方法几乎相同。
除了对单个视频帧执行操作外,还可以执行依赖于任意数量连续帧的操作。让我们看看如何使用一个非常简单但极其重要的用例来完成这项操作。
假设我们想要找到在任何给定时刻从摄像机读取的最后 60 帧的平均亮度。当帧的内容非常暗或非常亮时,这个值在自动调整视频亮度时非常有用。实际上,大多数数码相机的内部处理器以及您口袋里的智能手机通常会执行类似的操作。您可以尝试打开智能手机上的相机,将其对准光源,或太阳,或者进入一个非常黑暗的环境。以下代码演示了如何计算最后 60 帧的平均亮度并将其显示在视频的角落:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
vector<Scalar> avgs;
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
if(avgs.size() > 60) // remove the first item if more than 60
avgs.erase(avgs.begin());
Mat frameGray;
cvtColor(frame, frameGray, CV_BGR2GRAY);
avgs.push_back( mean(frameGray) );
Scalar allAvg = mean(avgs);
putText(frame,
to_string(allAvg[0]),
Point(0,frame.rows-1),
FONT_HERSHEY_PLAIN,
1.0,
Scalar(0,255,0));
imshow("Camera", frame);
int k = waitKey(10);
if(k > 0)
key = k;
}
cam.release();
对于大多数情况,这个示例代码与我们本章前面看到的示例非常相似。这里的主要区别在于,我们将使用 OpenCV 的均值函数计算的最后 60 帧的平均值存储在一个Scalar对象的vector中,然后我们计算所有平均值的总平均值。然后使用putText函数将计算出的值绘制在输入帧上。以下图像显示了执行前面的示例代码时显示的单个帧:

注意图像左下角显示的值,当视频帧的内容变暗时,该值将开始减小,当内容变亮时,该值将增加。基于这个结果,您可以,例如,改变亮度值或警告您的应用程序用户内容太暗或太亮,等等。
本章初始部分的例子旨在教会您如何使用在前几章中学到的算法处理单个帧,以及一些简单的编程技术,这些技术用于根据连续帧计算一个值。在本章接下来的部分,我们将学习一些最重要的视频处理算法,特别是目标检测和跟踪算法,这些算法依赖于我们在本节以及本书前几章中学到的概念和技术。
理解均值漂移算法
均值漂移算法是一个迭代算法,可以用来找到密度函数的最大值。将前面的句子粗略地翻译成计算机视觉术语,可以表达为以下内容——均值漂移算法可以使用反向投影图像在图像中找到对象。但它是如何在实际中实现的呢?让我们一步一步地来探讨。以下是使用均值漂移算法找到对象的单个操作步骤,按顺序如下:
-
图像的后投影是通过使用修改后的直方图来创建的,以找到最有可能包含我们的目标对象的像素。(过滤后投影图像以去除不想要的噪声也是常见的操作,但这是一种可选操作,用于提高结果。)
-
需要一个初始搜索窗口。这个搜索窗口在经过多次迭代后,将包含我们的目标对象,这一点我们将在下一步说明。每次迭代后,搜索窗口都会通过算法进行更新。搜索窗口的更新是通过在后投影图像中计算搜索窗口的质量中心,然后将当前搜索窗口的中心点移动到窗口的质量中心来实现的。以下图片展示了搜索窗口中质量中心的概念以及移动过程:

前一张图中箭头两端的两个点对应于搜索窗口中心和质量中心。
- 正如任何迭代算法一样,均值漂移算法需要一些终止条件来在结果符合预期或达到一个可接受的结果时停止算法。因此,迭代次数和 epsilon 值被用作终止条件。无论是达到算法中的迭代次数,还是找到一个小于给定 epsilon 值的位移距离(收敛),算法都将停止。
现在,让我们通过使用 OpenCV 库来实际看看这个算法是如何应用的。OpenCV 中的 meanShift 函数几乎完全按照前面步骤中描述的那样实现了均值漂移算法。这个函数需要一个后投影图像、一个搜索窗口和终止条件,并在以下示例中展示了其用法:
Rect srchWnd(0, 0, 100, 100);
TermCriteria criteria(TermCriteria::MAX_ITER
+ TermCriteria::EPS,
20, // number of iterations
1.0 // epsilon value
);
// Calculate back-projection image
meanShift(backProject,
srchWnd,
criteria);
srchWnd 是一个 Rect 对象,它只是一个必须包含初始值并随后由 meanShift 函数更新和使用的矩形。backProjection 必须包含一个适当的后投影图像,该图像可以通过我们在第五章 5.3 后投影和直方图中学习到的任何方法计算得出。TermCriteria 类是 OpenCV 中的一个类,它被需要类似终止条件的迭代算法使用。第一个参数定义了终止条件的类型,可以是 MAX_ITER(与 COUNT 相同)、EPS 或两者兼具。在前面的例子中,我们使用了 20 次迭代的终止条件和 1.0 的 epsilon 值,当然,这个值可以根据环境和应用进行更改。这里需要注意的最重要的一点是,更多的迭代次数和更低的 epsilon 值可以产生更准确的结果,但这也可能导致性能变慢,反之亦然。
上述示例只是展示了如何调用meanShift函数。现在,让我们通过一个完整的动手实践示例来学习我们的第一个实时目标跟踪算法:
- 我们将要创建的跟踪示例结构与本章之前的示例非常相似。我们需要使用
VideoCapture类在计算机上打开一个视频或摄像头,然后开始读取帧,如下所示:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
int key = -1;
while(key != ' ')
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
int k = waitKey(10);
if(k > 0)
key = k;
}
cam.release();
再次,我们使用了waitKey函数来停止循环,如果按下空格键。
- 我们将假设我们的目标对象是绿色的。因此,我们将形成一个只包含绿色色调的色调直方图,如下所示:
int bins = 360;
int grnHue = 120; // green color hue value
int hueOffset = 50; // the accepted threshold
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) =
(i > grnHue - hueOffset)
&&
(i < grnHue + hueOffset)
?
255.0 : 0.0;
}
这需要在进入过程循环之前完成,因为我们的直方图将在整个过程中保持不变。
- 在进入实际过程循环和跟踪代码之前,还有一件事需要注意,那就是终止条件,它将在整个过程中保持不变。以下是创建所需终止条件的步骤:
Rect srchWnd(0,0, 100, 100);
TermCriteria criteria(TermCriteria::MAX_ITER
+ TermCriteria::EPS,
20,
1.0);
当使用 Mean Shift 算法跟踪对象时,搜索窗口的初始值非常重要,因为这个算法总是对要跟踪的对象的初始位置做出假设。这是 Mean Shift 算法的一个明显缺点,我们将在本章后面讨论 CAM Shift 算法及其在 OpenCV 库中的实现时学习如何处理它。
- 在我们用于跟踪代码的
while循环中读取每一帧之后,我们必须使用我们创建的绿色色调直方图来计算输入帧的后投影图像。以下是操作步骤:
Mat frmHsv, hue;
vector<Mat> hsvChannels;
cvtColor(frame, frmHsv, COLOR_BGR2HSV);
split(frmHsv, hsvChannels);
hue = hsvChannels[0];
int nimages = 1;
int channels[] = {0};
Mat backProject;
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
double scale = 1.0;
bool uniform = true;
calcBackProject(&hue,
nimages,
channels,
histogram,
backProject,
ranges,
scale,
uniform);
您可以参考第五章,后投影和直方图,以获取更多关于计算后投影图像的详细说明。
- 调用
meanShift函数,使用后投影图像和提供的终止条件来更新搜索窗口,如下所示:
meanShift(backProject,
srchWnd,
criteria);
- 为了可视化搜索窗口,或者说跟踪对象,我们需要在输入帧上绘制搜索窗口矩形。以下是使用矩形函数进行此操作的方法:
rectangle(frame,
srchWnd, // search window rectangle
Scalar(0,0,255), // red color
2 // thickness
);
我们也可以对后投影图像结果做同样的事情,然而,首先我们需要将后投影图像转换为 BGR 颜色空间。请记住,后投影图像的结果包含了一个与输入图像深度相同的单通道图像。以下是我们在后投影图像上搜索窗口位置绘制红色矩形的步骤:
cvtColor(backProject, backProject, COLOR_GRAY2BGR);
rectangle(backProject,
srchWnd,
Scalar(0,0,255),
2);
- 使用B和V键在背投影和原始视频帧之间切换。以下是操作步骤:
switch(key)
{
case 'b': imshow("Camera", backProject);
break;
case 'v': default: imshow("Camera", frame);
break;
}
让我们尝试运行我们的程序,看看它在稍微受控的环境中执行时表现如何。以下图片展示了搜索窗口的初始位置和我们的绿色目标对象,在原始帧视图和后投影视图中:

移动物体将导致meanShift函数更新搜索窗口,从而跟踪物体。以下是另一个结果,展示了物体被跟踪到视图的右下角:

注意到角落处可以看到的一小部分噪声,这将被meanShift函数处理,因为质量中心不太受其影响。然而,如前所述,对后投影图像进行某种类型的过滤以去除噪声是个好主意。例如,在噪声类似于我们在后投影图像中看到的情况下,我们可以使用GaussianBlur函数,或者更好的是erode函数,以去除后投影图像中的不需要的像素。有关如何使用过滤函数的更多信息,您可以参考第四章,绘图、过滤和转换。
在此类跟踪应用中,我们通常需要观察、记录或以任何方式处理在任意给定时刻之前以及所需时间段内感兴趣对象所走过的路线。这可以通过使用搜索窗口的中心点简单地实现,如下面的示例所示:
Point p(srchWnd.x + srchWnd.width/2,
srchWnd.y + srchWnd.height/2);
route.push_back(p);
if(route.size() > 60) // last 60 frames
route.erase(route.begin()); // remove first element
显然,route是一个Point对象的vector。在调用meanShift函数后,需要更新route,然后我们可以使用以下对polylines函数的调用,以便在原始视频帧上绘制route:
polylines(frame,
route, // the vector of Point objects
false, // not a closed polyline
Scalar(0,255,0), // green color
2 // thickness
);
以下图片展示了在从相机读取的原始视频帧上显示跟踪路线(最后 60 帧)的结果:

现在,让我们解决我们在使用meanShift函数时观察到的一些问题。首先,手动创建色调直方图并不方便。一个灵活的程序应该允许用户选择他们想要跟踪的对象,或者至少允许用户方便地选择感兴趣对象的颜色。同样,关于搜索窗口的大小及其初始位置也是如此。有几种方法可以处理这些问题,我们将通过一个实际示例来解决这个问题。
当使用 OpenCV 库时,您可以使用setMouseCallback函数来自定义输出窗口上鼠标点击的行为。这可以与一些简单的方法结合使用,例如bitwise_not,以模拟用户易于使用的对象选择。从其名称可以猜出,setMouseCallback设置一个回调函数来处理给定窗口上的鼠标点击。
以下回调函数与在此定义的变量结合使用,可以创建一个方便的对象选择器:
bool selecting = false;
Rect selection;
Point spo; // selection point origin
void onMouse(int event, int x, int y, int flags, void*)
{
switch(event)
{
case EVENT_LBUTTONDOWN:
{
spo.x = x;
spo.y = y;
selection.x = spo.x;
selection.y = spo.y;
selection.width = 0;
selection.height = 0;
selecting = true;
} break;
case EVENT_LBUTTONUP:
{
selecting = false;
} break;
default:
{
selection.x = min(x, spo.x);
selection.y = min(y, spo.y);
selection.width = abs(x - spo.x);
selection.height = abs(y - spo.y);
} break;
}
}
event包含来自MouseEventTypes枚举的一个条目,它描述了是否按下了鼠标按钮或释放了鼠标按钮。基于这样一个简单的事件,我们可以决定用户何时实际上在屏幕上选择一个可见的对象。这如下所示:
if(selecting)
{
Mat sel(frame, selection);
bitwise_not(sel, sel); // invert the selected area
srchWnd = selection; // set the search window
// create the histogram using the hue of the selection
}
这为我们应用提供了巨大的灵活性,代码也必定能够与任何颜色的对象一起工作。请确保查看在线 Git 仓库中本章的示例代码,以获取一个完整的项目示例,该项目使用了本章迄今为止我们学到的所有主题。
在 OpenCV 库中,选择图像上的对象或区域的一种方法是使用selectROI和selectROIs函数。这些函数允许用户通过简单的鼠标点击和拖动在图像上选择矩形(或多个矩形)。请注意,selectROI和selectROIs函数比使用回调函数处理鼠标点击更容易使用,但它们提供的功能、灵活性和定制程度并不相同。
在进入下一节之前,让我们回顾一下meanShift不处理被跟踪对象大小的增加或减少,也不关心对象的方向。这些问题可能是导致开发更复杂版本的均值漂移算法的主要原因,这是我们将在本章接下来学习的内容。
使用连续自适应均值(CAM)Shift
为了克服均值漂移算法的局限性,我们可以使用其改进版本,这被称为连续自适应均值,或简称CAM算法。OpenCV 在名为CamShift的函数中实现了 CAM 算法,其使用方式几乎与meanShift函数相同。CamShift函数的输入参数与meanShift相同,因为它也使用一个反向投影图像来根据给定的终止条件更新搜索窗口。此外,CamShift还返回一个RotatedRect对象,它包含搜索窗口及其角度。
不使用返回的RotatedRect对象,你可以简单地用CamShift替换任何对meanShift函数的调用,唯一的区别是结果将是尺度不变的,这意味着如果对象更近(或更大),搜索窗口会变大,反之亦然。例如,我们可以将先前的均值漂移算法示例代码中对meanShift函数的调用替换为以下内容:
CamShift(backProject,
srchWnd,
criteria);
以下图像展示了在上一节示例中将meanShift函数替换为CamShift的结果:

注意,现在结果具有尺度不变性,尽管我们除了替换提到的函数外没有做任何改变。当对象远离相机或变得较小时,我们仍然使用相同的 Mean Shift 算法来计算其位置,然而,这次搜索窗口被调整大小以适应对象的精确大小,并计算了旋转,这是我们之前没有使用的。为了能够使用对象的旋转值,我们首先需要将CamShift函数的结果存储在RotatedRect对象中,如下面的示例所示:
RotatedRect rotRect = CamShift(backProject,
srchWnd,
criteria);
要绘制RotatedRect对象,换句话说,是一个旋转矩形,你必须使用RotatedRect的points方法首先提取旋转矩形的4个组成点,然后使用线函数将它们全部绘制出来,如下面的示例所示:
Point2f rps[4];
rotRect.points(rps);
for(int i=0; i<4; i++)
line(frame,
rps[i],
rps[(i+1)%4],
Scalar(255,0,0),// blue color
2);
你还可以使用RotatedRect对象来绘制一个被旋转矩形覆盖的旋转椭圆。以下是方法:
ellipse(frame,
rotRect,
Scalar(255,0,0),
2);
以下图像显示了使用RotatedRect对象同时绘制旋转矩形和椭圆的结果,覆盖在跟踪对象上:

在前面的图像中,红色矩形是搜索窗口,蓝色矩形是结果旋转矩形,绿色椭圆是通过使用结果旋转矩形绘制的。
总结来说,我们可以认为CamShift在处理大小和旋转各不相同的目标方面比meanShift更适合,然而,在使用CamShift算法时,仍然有一些可能的改进可以实施。首先,初始窗口大小仍然需要设置,但由于CamShift负责处理大小变化,因此我们可以直接将初始窗口大小设置为整个图像的大小。这将帮助我们避免处理搜索窗口的初始位置和大小。如果我们还能使用磁盘上预先保存的文件或任何类似的方法来创建感兴趣对象的直方图,那么我们将拥有一个即插即用的对象检测器和跟踪器,至少对于我们的感兴趣对象与周围环境颜色明显不同的所有情况来说是这样的。
通过使用inRange函数对用于计算直方图的 HSV 图像的 S 和 V 通道施加阈值,可以实现对基于颜色的检测和跟踪算法的一个巨大改进。原因是,在我们的例子中,我们只是简单地使用了色调(或H,或第一个)通道,并且没有考虑到可能具有与我们的感兴趣对象相同色调的非常暗或非常亮的像素的高可能性。这可以通过在计算要跟踪的对象的直方图时使用以下代码来完成:
int lbHue = 00 , hbHue = 180;
int lbSat = 30 , hbSat = 256;
int lbVal = 30 , hbVal = 230;
Mat mask;
inRange(objImgHsv,
Scalar(lbHue, lbSat, lbVal),
Scalar(hbHue, hbSat, hbVal),
mask);
calcHist(&objImgHue,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform);
在前面的示例代码中,以lb和hb开头的变量指的是允许通过inRange函数的值的下限和上限。objImgHsv显然是一个包含我们感兴趣的对象或包含我们感兴趣对象的 ROI 的Mat对象。objImgHue是objImgHsv的第一个通道,它是通过之前的split函数调用来提取的。其余的参数没有什么新东西,你已经在之前的函数调用中使用过它们了。
结合本节中描述的所有算法和技术可以帮助你创建一个对象检测器,甚至是一个可以实时工作且速度惊人的面部检测器和跟踪器。然而,你可能仍然需要考虑会干扰的噪声,尤其是在跟踪过程中,由于基于颜色或直方图跟踪器的本质,跟踪几乎是不可避免的。解决这些问题的最广泛使用的方法是本章下一节的主题。
使用卡尔曼滤波进行跟踪和噪声降低
卡尔曼滤波是一种流行的算法,用于降低信号的噪声,例如我们前面章节中使用的跟踪算法的结果。为了更精确,卡尔曼滤波是一种估计算法,用于根据之前的观察预测信号的下一个状态。深入探讨卡尔曼滤波的定义和细节需要单独的一章,但我们将尝试通过几个实际操作的例子来了解这个简单而极其强大的算法是如何在实际中应用的。
对于第一个例子,我们将编写一个程序来跟踪鼠标光标在画布上移动,或者 OpenCV 窗口中的移动。卡尔曼滤波是通过 OpenCV 中的KalmanFilter类实现的,它包括了所有(以及更多)的卡尔曼滤波实现细节,我们将在本节中讨论这些细节。
首先,KalmanFilter必须使用一定数量的动态参数、测量参数和控制参数进行初始化,以及卡尔曼滤波本身所使用的基础数据类型。我们将忽略控制参数,因为它们超出了我们示例的范围,所以我们将它们简单地设置为零。至于数据类型,我们将使用默认的 32 位浮点数,或者用 OpenCV 类型表示为CV_32F。在二维运动中,例如我们的例子,动态参数对应于以下内容:
-
X,或 x 方向的位置
-
Y,或 y 方向的位置
-
X',或 x 方向的速度
-
Y',或 y 方向的速度
参数的高维性也可以被使用,这会导致前面的列表后面跟着 X''(x 方向上的加速度)等等。
关于测量参数,我们只需有 X 和 Y,它们对应于我们第一个例子中的鼠标位置。牢记关于动态和测量参数的讨论,以下是初始化一个适合在二维空间跟踪点的 KalmanFilter 类实例(对象)的方法:
KalmanFilter kalman(4, // dynamic parameters: X,Y,X',Y'
2 // measurement parameters: X,Y
);
注意,在这个例子中,控制参数和类型参数被简单地忽略,并设置为它们的默认值,否则我们就可以像下面这样编写相同的代码:
KalmanFilter kalman(4, 2, 0, CV_32F);
KalmanFilter 类在使用之前需要设置一个转换矩阵才能正确使用。这个转换矩阵用于计算(并更新)参数的估计或下一个状态。在我们的例子中,我们将使用以下转换矩阵来跟踪鼠标位置:
Mat_<float> tm(4, 4); // transition matrix
tm << 1,0,1,0, // next x = 1X + 0Y + 1X' + 0Y'
0,1,0,1, // next y = 0X + 1Y + 0X' + 1Y'
0,0,1,0, // next x'= 0X + 0Y + 1X' + 0Y
0,0,0,1; // next y'= 0X + 0Y + 0X' + 1Y'
kalman.transitionMatrix = tm;
完成这个例子所需的步骤后,明智的做法是返回这里并更新转换矩阵中的值,并观察卡尔曼滤波器的行为。例如,尝试更新对应于估计的 Y(在注释中标记为 next y)的矩阵行,你会注意到跟踪的位置 Y 值会受到它的影响。尝试通过实验所有转换矩阵中的值来更好地理解其影响。
除了转换矩阵之外,我们还需要注意动态参数状态和测量的初始化,这些在我们的例子中是初始鼠标位置。以下是初始化这些值的方法:
Mat_<float> pos(2,1);
pos.at<float>(0) = 0;
pos.at<float>(1) = 0;
kalman.statePre.at<float>(0) = 0; // init x
kalman.statePre.at<float>(1) = 0; // init y
kalman.statePre.at<float>(2) = 0; // init x'
kalman.statePre.at<float>(3) = 0; // init y'
你稍后会看到,KalmanFilter 类需要一个向量而不是 Point 对象,因为它也设计用于处理更高维度。因此,在执行任何计算之前,我们将更新前面代码片段中的 pos 向量,以包含最后一个鼠标位置。除了我们刚才提到的初始化之外,我们还需要初始化卡尔曼滤波器的测量矩阵。这就像下面这样完成:
setIdentity(kalman.measurementMatrix);
OpenCV 中的 setIdentity 函数简单地用于使用缩放后的单位矩阵初始化矩阵。如果只向 setIdentity 函数提供一个参数作为矩阵,它将被设置为单位矩阵;然而,如果还提供了一个额外的 Scalar,则单位矩阵的所有元素都将乘以(或缩放)给定的 Scalar 值。
最后一个初始化是过程噪声协方差。我们将为此使用一个非常小的值,这会导致跟踪具有自然运动感觉,尽管在跟踪时会有一点过冲。以下是初始化过程噪声协方差矩阵的方法:
setIdentity(kalman.processNoiseCov,
Scalar::all(0.000001));
在使用 KalmanFilter 类之前,初始化以下矩阵也是常见的做法:
-
controlMatrix(如果控制参数计数为零则不使用) -
errorCovPost -
errorCovPre -
gain -
measurementNoiseCov
使用前面提到的所有矩阵将为 KalmanFilter 类提供大量的定制,但也需要大量关于所需噪声滤波和跟踪类型以及滤波器将实现的环境的知识。这些矩阵及其使用在控制理论和控制科学中有着深厚的根源,这是一个另一个书籍的主题。请注意,在我们的示例中,我们将简单地使用提到的矩阵的默认值,因此我们完全忽略了它们。
在我们的使用卡尔曼滤波器的跟踪示例中,接下来我们需要的是设置一个窗口,我们可以在这个窗口上跟踪鼠标移动。我们假设鼠标在窗口上的位置是检测和跟踪的对象的位置,我们将使用我们的 KalmanFilter 对象来预测和去噪这些检测,或者用卡尔曼滤波算法的术语来说,我们将纠正这些测量。我们可以使用 namedWindow 函数使用 OpenCV 创建一个窗口。因此,可以使用 setMouseCallback 函数为与该特定窗口的鼠标交互分配回调函数。以下是我们可以这样做的方法:
string window = "Canvas";
namedWindow(window);
setMouseCallback(window, onMouse);
我们将Canvas这个词用于窗口,但显然你可以使用你喜欢的任何其他名字。onMouse 是将被分配以响应与该窗口的鼠标交互的回调函数。它被定义如下:
void onMouse(int, int x, int y, int, void*)
{
objectPos.x = x;
objectPos.y = y;
}
Point object that is used to store the last position of the mouse on the window. It needs to be defined globally in order to be accessible by both onMouse and the main function in which we'll use the KalmanFilter class. Here's the definition:
Point objectPos;
现在,对于实际的跟踪,或者使用更准确的术语,纠正包含噪声的测量值,我们需要使用以下代码,后面将跟随必要的解释:
vector<Point> trackRoute;
while(waitKey(10) < 0)
{
// empty canvas
Mat canvas(500, 1000, CV_8UC3, Scalar(255, 255, 255));
pos(0) = objectPos.x;
pos(1) = objectPos.y;
Mat estimation = kalman.correct(pos);
Point estPt(estimation.at<float>(0),
estimation.at<float>(1));
trackRoute.push_back(estPt);
if(trackRoute.size() > 100)
trackRoute.erase(trackRoute.begin());
polylines(canvas,
trackRoute,
false,
Scalar(0,0,255),
5);
imshow(window, canvas);
kalman.predict();
}
在前面的代码中,我们使用 trackRoute 向量记录过去 100 帧的估计值。按下任何键将导致 while 循环,从而程序返回。在循环内部,以及我们实际使用 KalmanFilter 类的地方,我们简单地按以下顺序执行以下操作:
-
创建一个空的
Mat对象,用作绘制画布,以及跟踪将发生的窗口的内容 -
读取
objectPos,它包含鼠标在窗口上的最后位置,并将其存储在pos向量中,该向量可用于KalmanFilter类 -
使用
KalmanFilter类的正确方法读取估计值 -
将估计结果转换回可用于绘制的
Point对象 -
将估计点(或跟踪点)存储在
trackRoute向量中,并确保trackRoute向量中的项目数量不超过100,因为这是我们想要记录估计点的帧数 -
使用多段线函数绘制路线,将路线存储为
Point对象在trackRoute中 -
使用
imshow函数显示结果 -
使用预测函数更新
KalmanFilter类的内部矩阵
尝试执行跟踪程序并在显示的窗口中移动鼠标光标。你会注意到一个平滑的跟踪结果,它使用以下截图中的粗红色线条绘制:

注意到鼠标光标的位置在跟踪之前,鼠标移动的噪声几乎被完全消除。尝试可视化鼠标移动以更好地比较KalmanFilter结果和实际测量结果是个好主意。只需在前面代码中trackRoute绘制点之后添加以下代码:
mouseRoute.push_back(objectPos);
if(mouseRoute.size() > 100)
mouseRoute.erase(mouseRoute.begin());
polylines(canvas,
mouseRoute,
false,
Scalar(0,0,0),
2);
显然,在进入while循环之前,你需要定义mouseRoute向量,如下所示:
vector<Point> mouseRoute;
让我们尝试使用这个小的更新来运行相同的应用程序,并看看结果如何相互比较。以下是另一个截图,展示了实际鼠标移动和校正后的移动(或跟踪,或滤波,具体取决于术语)在同一窗口中的结果:

在前面的结果中,箭头只是用来表示一个极其嘈杂的测量(鼠标移动或检测到的对象位置,具体取决于应用)的整体方向,它用细黑色线条绘制,而使用卡尔曼滤波算法校正的结果,在图像中用粗红色线条绘制。尝试移动鼠标并直观地比较结果。还记得我们提到的KalmanFilter内部矩阵以及你需要根据用例和应用设置它们的值吗?例如,更大的过程噪声协方差会导致去噪更少,从而滤波更少。让我们尝试将过程噪声协方差值设置为0.001,而不是之前的0.000001值,再次运行相同的程序,并比较结果。以下是设置过程噪声协方差的方法:
setIdentity(kalman.processNoiseCov,
Scalar::all(0.001));
现在,再次运行程序,你可以很容易地注意到,当你将鼠标光标在窗口周围移动时,去噪现象减少:

到目前为止,你可能已经猜到,将过程噪声协方差设置得极高,其结果几乎与完全不使用滤波器相同。这就是为什么设置卡尔曼滤波算法的正确值极其重要,也是它如此依赖于应用的原因。然而,有方法可以通过编程设置大多数这些参数,甚至在跟踪过程中动态地调整以实现最佳结果。例如,使用一个能够确定任何给定时刻可能噪声量的函数,我们可以动态地将过程噪声协方差设置为高或低值,以实现更少或更多的去噪。
现在,让我们使用KalmanFilter来执行一个现实生活中的跟踪校正,使用CamShift函数对物体进行跟踪,而不是鼠标移动。需要注意的是,应用的逻辑完全相同。我们需要初始化一个KalmanFilter对象,并根据噪声量等设置其参数。为了简单起见,你可以从与之前示例中设置跟踪鼠标光标相同的参数集开始,然后尝试调整它们。我们需要从创建与之前章节中编写的相同跟踪程序开始。然而,在调用CamShift(或meanShift函数)来更新搜索窗口后,而不是显示结果,我们将使用KalmanFilter类进行校正以去噪结果。以下是使用类似示例代码的执行方式:
CamShift(backProject,
srchWnd,
criteria);
Point objectPos(srchWnd.x + srchWnd.width/2,
srchWnd.y + srchWnd.height/2);
pos(0) = objectPos.x;
pos(1) = objectPos.y;
Mat estimation = kalman.correct(pos);
Point estPt(estimation.at<float>(0),
estimation.at<float>(1));
drawMarker(frame,
estPt,
Scalar(0,255,0),
MARKER_CROSS,
30,
2);
kalman.predict();
你可以参考本章在线源代码仓库中的完整示例项目,其中包含前面的代码,它与之前章节中看到的内容几乎相同,只是简单的事实是使用了KalmanFilter来校正检测和跟踪到的物体位置。正如你所看到的,objectPos,之前是从鼠标移动位置读取的,现在被设置为搜索窗口的中心点。之后,调用correct函数进行估计,并通过绘制绿色十字标记显示校正后的跟踪结果。除了使用卡尔曼滤波器算法的主要优势,即有助于去除检测和跟踪结果中的噪声外,它还可以帮助处理检测暂时丢失或不可能的情况。虽然从技术上讲,检测丢失是噪声的极端情况,但我们试图从计算机视觉的角度指出这种差异。
通过分析几个示例,并在自己的项目中尝试使用卡尔曼滤波器来帮助解决问题,并为其尝试不同的参数集,你将立即理解它在需要纠正测量(包含噪声)的实用算法时的长期受欢迎。在本节中我们学到的关于卡尔曼滤波器的使用是一个相当简单的情况(这对于我们的用例已经足够了),但重要的是要注意,相同的算法可以用于去噪更高维度的测量,以及具有更多复杂性的情况。
如何提取背景/前景
在图像中分割背景和前景内容是视频和运动分析中最重要的话题之一,在这个领域已经进行了大量的研究,以提供一些非常实用且易于使用的算法,我们将在本章的最后部分学习这些算法。当前版本的 OpenCV 包括两种背景分割算法的实现。
为了使用更简短、更清晰且与 OpenCV 函数和类更兼容的术语,我们将背景/前景提取和背景/前景分割简单地称为背景分割。
OpenCV 默认提供了以下两个算法用于背景分割:
-
BackgroundSubtractorKNN -
BackgroundSubtractorMOG2
这两个类都是BackgroundSubtractor的子类,它包含了一个合适的背景分割算法所必需的所有接口,我们将在稍后讨论。这仅仅允许我们使用多态性在产生相同结果且用法非常相似的算法之间切换。BackgroundSubtractorKNN类实现了 K 最近邻背景分割算法,适用于前景像素计数较低的情况。另一方面,BackgroundSubtractorMOG2实现了基于高斯混合的背景分割算法。你可以参考 OpenCV 在线文档以获取有关这些算法内部行为和实现的详细信息。阅读这些算法的相关文章也是一个好主意,特别是如果你正在寻找自己的自定义背景分割算法的话。
除了我们之前提到的算法外,还有许多其他算法可以使用 OpenCV 进行背景分割,这些算法包含在额外的模块bgsegm中。我们将省略这些算法,因为它们的用法与我们将在本节中讨论的算法非常相似,而且它们在 OpenCV 中默认不存在。
背景分割的一个示例
让我们从BackgroundSubtractorKNN类和一个实际操作示例开始,看看背景分割算法是如何使用的。你可以使用createBackgroundSubtractorKNN函数创建一个BackgroundSubtractorKNN类型的对象。以下是方法:
int history = 500;
double dist2Threshold = 400.0;
bool detectShadows = true;
Ptr<BackgroundSubtractorKNN> bgs =
createBackgroundSubtractorKNN(history,
dist2Threshold,
detectShadows);
要理解BackgroundSubtractorKNN类中使用的参数,首先需要注意的是,此算法使用像素历史记录中的采样技术来创建一个采样背景图像。换句话说,history参数用于定义用于采样背景图像的先前帧数,而dist2Threshold参数是像素当前值与其在采样背景图像中对应像素值的平方距离的阈值。"detectShadows"是一个自解释的参数,用于确定在背景分割过程中是否检测阴影。
现在,我们可以简单地使用bgs从视频中提取前景掩码,并使用它来检测运动或物体进入场景。以下是方法:
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
Mat fgMask; // foreground mask
bgs->apply(frame,
fgMask);
Mat fg; // foreground image
bitwise_and(frame, frame, fg, fgMask);
Mat bg; // background image
bgs->getBackgroundImage(bg);
imshow("Input Image", frame);
imshow("Background Image", bg);
imshow("Foreground Mask", fgMask);
imshow("Foreground Image", fg);
int key = waitKey(10);
if(key == 27) // escape key
break;
}
cam.release();
让我们快速回顾一下之前代码中新的、可能不是那么明显的一部分。首先,我们使用 BackgroundSubtractorKNN 类的 apply 函数执行背景/前景分割操作。此函数还为我们更新了内部采样背景图像。之后,我们使用 bitwise_and 函数与前景掩码结合来提取前景图像的内容。要检索采样背景图像本身,我们只需使用 getBackgroundImage 函数。最后,我们显示所有结果。以下是一些示例结果,描述了一个场景(左上角),提取的背景图像(右上角),前景掩码(左下角),以及前景图像(右下角):

注意,进入场景的手的阴影也被背景分割算法捕获。在我们的示例中,我们使用 apply 函数时省略了 learningRate 参数。此参数可以用来设置学习背景模型更新的速率。值为 0 表示模型将完全不会更新,这在背景在已知时间段内保持不变的情况下非常有用。值为 1.0 表示模型会非常快地更新。正如我们的示例中那样,我们跳过了此参数,导致它使用 -1.0,这意味着算法本身将决定学习率。另一个需要注意的重要事项是,apply 函数的结果可能会产生一个非常嘈杂的掩码,这可以通过使用简单的模糊函数,如 medianBlur,来平滑,如下所示:
medianBlur(fgMask,fgMask,3);
使用 BackgroundSubtractorMOG2 类与 BackgroundSubtractorKNN 类非常相似。以下是一个示例:
int history = 500;
double varThreshold = 16.0;
bool detectShadows = true;
Ptr<BackgroundSubtractorMOG2> bgs =
createBackgroundSubtractorMOG2(history,
varThreshold,
detectShadows);
注意,createBackgroundSubtractorMOG2 函数的使用方式与我们之前看到的使用方式非常相似,用于创建 BackgroundSubtractorMOG2 类的实例。这里唯一的区别是 varThreshold 参数,它对应于用于匹配像素值和背景模型的方差阈值。使用 apply 和 getBackgroundImage 函数在这两个背景分割类中是相同的。尝试修改这两个算法中的阈值值,以了解更多关于参数视觉效果的细节。
背景分割算法在视频编辑软件中具有很大的潜力,甚至可以在背景变化不大的环境中检测和跟踪对象。尝试将它们与本章之前学到的算法结合使用,构建利用多个算法以提高结果跟踪算法。
摘要
OpenCV 中的视频分析模块是一系列极其强大的算法、函数和类的集合,我们在本章中已经了解到了它们。从视频处理的整体概念和基于连续视频帧内容的简单计算开始,我们继续学习了均值漂移算法及其如何通过后投影图像跟踪已知颜色和规格的对象。我们还学习了均值漂移算法的更复杂版本,称为连续自适应均值漂移,或简称 CAM Shift。我们了解到这个算法也能够处理不同尺寸的对象并确定它们的朝向。在跟踪算法的学习过程中,我们了解了强大的卡尔曼滤波器及其在去噪和校正跟踪结果中的应用。我们使用卡尔曼滤波器来跟踪鼠标移动并校正均值漂移和 CAM Shift 算法的跟踪结果。最后,我们学习了实现背景分割算法的 OpenCV 类。我们编写了一个简单的程序来使用背景分割算法并输出计算出的背景和前景图像。到目前为止,我们已经熟悉了一些最流行和最广泛使用的计算机视觉算法,这些算法允许实时检测和跟踪对象。
在下一章中,我们将学习许多特征提取算法、函数和类,以及如何使用特征根据图像的关键点和描述符检测对象或从中提取有用的信息。
问题
-
本章中所有涉及摄像头的示例在出现单个失败或损坏的帧导致检测到空帧时都会返回。需要什么样的修改才能在停止过程之前允许预定义的尝试次数?
-
我们如何调用
meanShift函数以 10 次迭代和 0.5 的 epsilon 值执行均值漂移算法? -
我们如何可视化跟踪对象的色调直方图?假设使用
CamShift进行跟踪。 -
在
KalmanFilter类中设置过程噪声协方差,以便滤波值和测量值重叠。假设只设置了过程噪声协方差,在所有可用的KalmanFilter类行为控制矩阵中。 -
假设窗口中鼠标的Y位置用于描述从窗口左上角开始的填充矩形的长度,该矩形的宽度等于窗口宽度。编写一个卡尔曼滤波器,可以用来校正矩形的长度(单个值)并去除鼠标移动中的噪声,这将导致填充矩形的视觉平滑缩放。
-
创建一个
BackgroundSubtractorMOG2对象来提取前景图像内容,同时避免阴影变化。 -
编写一个程序,使用背景分割算法显示当前(而不是采样)的背景图像。
第七章:目标检测 – 特征和描述符
在上一章中,我们学习了视频处理以及如何将之前章节中的操作和算法应用于从摄像头或视频文件中读取的帧。我们了解到每个视频帧都可以被视为一个单独的图像,因此我们可以轻松地在视频中应用类似于图像的算法,如滤波。在了解了如何使用作用于单个帧的算法处理视频后,我们继续学习需要一系列连续视频帧来执行目标检测、跟踪等操作的视频处理算法。我们学习了如何利用卡尔曼滤波的魔法来提高目标跟踪结果,并以学习背景和前景提取结束本章。
上一章中我们学习到的目标检测(和跟踪)算法在很大程度上依赖于物体的颜色,这已被证明并不太可靠,尤其是如果我们处理的对象和环境在光照方面没有得到控制。我们都知道,在阳光、月光下,或者如果物体附近有不同颜色的光源,如红灯,物体的亮度和颜色可以轻易(有时极其)改变。这些困难是为什么当使用物体的物理形状和特征作为目标检测算法的基础时,物体的检测更加可靠。显然,图像的形状与其颜色无关。一个圆形物体在白天或夜晚都会保持圆形,因此能够提取此类物体形状的算法在检测该物体时将更加可靠。
在本章中,我们将学习如何使用计算机视觉算法、函数和类来检测和识别具有其特征的物体。我们将学习到许多可用于形状提取和分析的算法,然后我们将继续学习关键点检测和描述符提取算法。我们还将学习如何将两个图像中的描述符进行匹配,以检测图像中已知形状的物体。除了我们刚才提到的主题外,本章还将包括用于正确可视化关键点和匹配结果的所需函数。
在本章中,你将学习以下内容:
-
模板匹配用于目标检测
-
检测轮廓并用于形状分析
-
计算和分析轮廓
-
使用霍夫变换提取直线和圆
-
检测、描述和匹配特征
技术要求
-
用于开发 C++ 或 Python 应用的集成开发环境 (IDE)
-
OpenCV 库
请参考第二章,《使用 OpenCV 入门》,以获取更多关于如何设置个人电脑并使其准备好使用 OpenCV 库开发计算机视觉应用程序的信息。
您可以使用以下网址下载本章的源代码和示例:
github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter07.
对象检测的模板匹配
在我们开始形状分析和特征分析算法之前,我们将学习一种易于使用且功能强大的对象检测方法,称为模板匹配。严格来说,这个算法不属于使用任何关于对象形状知识的算法类别,但它使用了一个先前获取的对象模板图像,该图像可以用来提取模板匹配结果,从而识别出已知外观、大小和方向的对象。您可以使用 OpenCV 中的matchTemplate函数执行模板匹配操作。以下是一个演示matchTemplate函数完整使用的示例:
Mat object = imread("Object.png");
Mat objectGr;
cvtColor(object, objectGr, COLOR_BGR2GRAY);
Mat scene = imread("Scene.png");
Mat sceneGr;
cvtColor(scene, sceneGr, COLOR_BGR2GRAY);
TemplateMatchModes method = TM_CCOEFF_NORMED;
Mat result;
matchTemplate(sceneGr, objectGr, result, method);
method必须是从TemplateMatchModes枚举中的一项,可以是以下任何值:
-
TM_SQDIFF -
TM_SQDIFF_NORMED -
TM_CCORR -
TM_CCORR_NORMED -
TM_CCOEFF -
TM_CCOEFF_NORMED
关于每种模板匹配方法的详细信息,您可以参考 OpenCV 文档。对于我们的实际示例,以及了解matchTemplate函数在实际中的应用,重要的是要注意,每种方法都会产生不同类型的结果,因此需要对结果进行不同的解释,我们将在本节中学习这些内容。在前面的例子中,我们试图通过使用对象图像和场景图像来检测场景中的对象。让我们假设以下图像是我们将使用的对象(左侧)和场景(右侧):

模板匹配中的一个非常简单的想法是我们正在寻找场景图像右侧的一个点,该点有最高可能性包含左侧的图像,换句话说,就是模板图像。matchTemplate函数,根据所使用的方法,将提供一个概率分布。让我们可视化matchTemplate函数的结果,以更好地理解这个概念。另一个需要注意的重要事情是,我们只能通过使用以_NORMED结尾的任何方法来正确可视化matchTemplate函数的结果,这意味着它们包含归一化的结果,否则我们必须使用归一化方法来创建一个包含 OpenCV imshow函数可显示范围内的值的输出。以下是实现方法:
normalize(result, result, 0.0, 1.0, NORM_MINMAX, -1);
此函数调用将result中的所有值转换为0.0和1.0的范围,然后可以正确显示。以下是使用imshow函数显示的结果图像的外观:

如前所述,matchTemplate函数的结果及其解释完全取决于所使用的模板匹配方法。在我们使用TM_SQDIFF或TM_SQDIFF_NORMED方法进行模板匹配的情况下,我们需要在结果中寻找全局最小点(前一张图中的箭头所示),它最有可能是包含模板图像的点。以下是我们在模板匹配结果中找到全局最小点(以及全局最大点等)的方法:
double minVal, maxVal;
Point minLoc, maxLoc;
minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc);
由于模板匹配算法仅与固定大小和方向的物体工作,我们可以假设一个矩形,其左上角等于minLoc点,且大小等于模板图像,是我们对象的最佳可能边界矩形。我们可以使用以下示例代码在场景图像上绘制结果,以便更好地比较:
Rect rect(minLoc.x,
minLoc.y,
object.cols,
object.rows);
Scalar color(0, 0, 255);
int thickness = 2;
rectangle(scene,
rect,
color,
thickness);
以下图像展示了使用matchTemplate函数执行的对象检测操作的结果:

如果我们使用TM_CCORR、TM_CCOEFF或它们的归一化版本,我们必须使用全局最大点作为包含我们的模板图像可能性最高的点。以下图像展示了使用matchTemplate函数的TM_CCOEFF_NORMED方法的结果:

如您所见,结果图像中最亮的点对应于场景图像中模板图像的左上角。
在结束我们的模板匹配课程之前,我们也要注意,模板匹配结果的宽度和高度小于场景图像。这是因为模板匹配结果图像只能包含模板图像的左上角,因此从场景图像的宽度和高度中减去模板图像的宽度和高度,以确定模板匹配算法中结果图像的宽度和高度。
检测角点和边缘
并非总是可以仅仅通过像素级比较图像并决定一个物体是否存在于图像中,或者一个物体是否具有预期的形状,以及许多我们甚至无法一一列举的类似场景。这就是为什么更智能的方法是寻找图像中的有意义特征,然后根据这些特征的性质进行解释。在计算机视觉中,特征与关键点同义,所以如果我们在这本书中交替使用它们,请不要感到惊讶。实际上,关键词汇更适合描述这个概念,因为图像中最常用的特征通常是图像中的关键点,在这些点上颜色强度发生突然变化,这可能在图像中形状和物体的角点和边缘处发生。
在本节中,我们将了解一些最重要且最广泛使用的特征点检测算法,即角点和边缘检测算法,这些算法是我们在本章中将学习的几乎所有基于特征的物体检测算法的基础。
学习 Harris 角点检测算法
最著名的角点和边缘检测算法之一是 Harris 角点检测算法,该算法在 OpenCV 的cornerHarris函数中实现。以下是该函数的使用方法:
Mat image = imread("Test.png");
cvtColor(image, image, COLOR_BGR2GRAY);
Mat result;
int blockSize = 2;
int ksize = 3;
double k = 1.0;
cornerHarris(image,
result,
blockSize,
ksize,
k);
blockSize决定了 Harris 角点检测算法将计算 2 x 2 梯度协方差矩阵的正方形块的宽度和高度。ksize是 Harris 算法内部使用的 Sobel 算子的核大小。前一个示例演示了最常用的 Harris 算法参数集之一,但有关 Harris 角点检测算法及其内部数学的更详细信息,您可以参考 OpenCV 文档。需要注意的是,前一个示例代码中的result对象除非使用以下示例代码进行归一化,否则是不可显示的:
normalize(result, result, 0.0, 1.0, NORM_MINMAX, -1);
这里是前一个示例中 Harris 角点检测算法的结果,当使用 OpenCV 的imshow函数进行归一化和显示时:

OpenCV 库还包括另一个著名的角点检测算法,称为Good Features to Track(GFTT)。您可以使用 OpenCV 中的goodFeaturesToTrack函数来使用 GFTT 算法检测角点,如下面的示例所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
vector<Point2f> corners;
int maxCorners = 500;
double qualityLevel = 0.01;
double minDistance = 10;
Mat mask;
int blockSize = 3;
int gradientSize = 3;
bool useHarrisDetector = false;
double k = 0.04;
goodFeaturesToTrack(imgGray,
corners,
maxCorners,
qualityLevel,
minDistance,
mask,
blockSize,
gradientSize,
useHarrisDetector,
k);
如你所见,这个函数需要一个单通道图像,因此,在执行任何其他操作之前,我们已经将我们的 BGR 图像转换为灰度图。此外,这个函数使用maxCorners值来根据它们作为候选者的强度限制检测到的角点的数量,将maxCorners设置为负值或零意味着应返回所有检测到的角点,如果你在寻找图像中的最佳角点,这不是一个好主意,所以请确保根据你将使用它的环境设置一个合理的值。qualityLevel是接受检测到的角点的内部阈值值。minDistance是返回角点之间允许的最小距离。这是另一个完全依赖于该算法将用于的环境的参数。你已经在上一章和前一章的先前算法中看到了剩余的参数。重要的是要注意,此函数还结合了 Harris 角点检测算法,因此,通过将useHarrisDetector设置为true,结果特征将使用 Harris 角点检测算法计算。
你可能已经注意到,goodFeaturesToTrack函数返回一组Point对象(确切地说是Point2f对象)而不是Mat对象。返回的corners向量仅包含使用 GFTT 算法在图像中检测到的最佳可能角点,因此我们可以使用drawMarker函数来正确地可视化结果,如下面的例子所示:
Scalar color(0, 0, 255);
MarkerTypes markerType = MARKER_TILTED_CROSS;
int markerSize = 8;
int thickness = 2;
for(int i=0; i<corners.size(); i++)
{
drawMarker(image,
corners[i],
color,
markerType,
markerSize,
thickness);
}
这是前面例子中使用goodFeaturesToTrack函数检测角点得到的结果:

你也可以使用GFTTDetector类以与goodFeaturesToTrack函数类似的方式检测角点。这里的区别在于返回的类型是KeyPoint对象的向量。许多 OpenCV 函数和类使用KeyPoint类来返回检测到的关键点的各种属性,而不是仅对应于关键点位置的Point对象。让我们通过以下内容来看看这意味着什么:
Ptr<GFTTDetector> detector =
GFTTDetector::create(maxCorners,
qualityLevel,
minDistance,
blockSize,
gradientSize,
useHarrisDetector,
k);
vector<KeyPoint> keypoints;
detector->detect(image, keypoints);
传递给GFTTDetector::create函数的参数与我们使用goodFeaturesToTrack函数时使用的参数没有不同。你也可以省略所有给定的参数,只需简单地写下以下内容即可使用所有参数的默认和最佳值:
Ptr<GFTTDetector> detector = GFTTDetector::create();
但让我们回到之前的例子中的KeyPoint类和detect函数的结果。回想一下,我们使用循环遍历所有检测到的点并在图像上绘制它们。如果我们使用GFTTDetector类,则不需要这样做,因为我们可以使用现有的 OpenCV 函数drawKeypoints来正确地可视化所有检测到的关键点。以下是这个函数的使用方法:
Mat outImg;
drawKeypoints(image,
keypoints,
outImg);
drawKeypoints 函数遍历 keypoints 向量中的所有 KeyPoint 对象,并在 image 上使用随机颜色绘制它们,然后将结果保存到 outImg 对象中,我们可以通过调用 imshow 函数来显示它。以下图像是使用前面示例代码调用 drawKeypoints 函数的结果:

如果我们想使用特定颜色而不是随机颜色,可以为 drawKeypoints 函数提供一个额外的(可选的)颜色参数。此外,我们还可以提供一个标志参数,用于进一步增强检测到的关键点的可视化结果。例如,如果标志设置为 DRAW_RICH_KEYPOINTS,则 drawKeypoints 函数还将使用每个检测到的关键点中的大小和方向值来可视化更多关键点属性。
每个 KeyPoint 对象可能包含以下属性,具体取决于用于计算它的算法:
-
pt:一个包含关键点坐标的Point2f对象。 -
size:有意义的关键点邻域的直径。 -
angle:关键点的方向,以度为单位,如果不适用则为 -1。 -
response:由算法确定的关键点强度。 -
octave:从其中提取关键点的八度或金字塔层。使用八度可以让我们处理来自同一图像但在不同尺度上的关键点。通常设置此值的算法需要输入八度参数,该参数用于定义用于提取关键点的图像的八度(或尺度)数。 -
class_id:这个整数参数可以用来分组关键点,例如,当关键点属于单个对象时,它们可以具有相同的可选class_id值。
除了 Harris 和 GFTT 算法之外,您还可以使用 FastFeatureDetector 类的 FAST 角点检测算法,以及 AgastFeatureDetector 类的 AGAST 角点检测算法(基于加速段测试的自适应和通用角点检测),这与我们使用 GFTTDetector 类的方式非常相似。重要的是要注意,所有这些类都属于 OpenCV 库中的 features2d 模块,并且它们都是 Feature2D 类的子类,因此它们都包含一个静态的 create 函数,用于创建它们对应类的实例,以及一个 detect 函数,可以用来从图像中提取关键点。
下面是一个使用所有默认参数的 FastFeatureDetector 的示例代码:
int threshold = 10;
bool nonmaxSuppr = true;
int type = FastFeatureDetector::TYPE_9_16;
Ptr<FastFeatureDetector> fast =
FastFeatureDetector::create(threshold,
nonmaxSuppr,
type);
vector<KeyPoint> keypoints;
fast->detect(image, keypoints);
如果检测到太多的角点,请尝试增加 threshold 值。同时,请确保查看 OpenCV 文档以获取有关 FastFeatureDetector 类中使用的 type 参数的更多信息。如前所述,您可以直接省略前面示例代码中的所有参数,以使用所有参数的默认值。
使用 AgastFeatureDetector 类与使用 FastFeatureDetector 非常相似。以下是一个示例:
int threshold = 10;
bool nonmaxSuppr = true;
int type = AgastFeatureDetector::OAST_9_16;
Ptr<AgastFeatureDetector> agast =
AgastFeatureDetector::create(threshold,
nonmaxSuppr,
type);
vector<KeyPoint> keypoints;
agast->detect(image, keypoints);
在继续学习边缘检测算法之前,值得注意的是 OpenCV 还包含 AGAST 和 FAST 函数,可以直接使用它们对应的算法,避免处理创建实例来使用它们;然而,使用这些算法的类实现具有使用多态在算法之间切换的巨大优势。以下是一个简单的示例,演示了我们可以如何使用多态从角点检测算法的类实现中受益:
Ptr<Feature2D> detector;
switch (algorithm)
{
case 1:
detector = GFTTDetector::create();
break;
case 2:
detector = FastFeatureDetector::create();
break;
case 3:
detector = AgastFeatureDetector::create();
break;
default:
cout << "Wrong algorithm!" << endl;
return 0;
}
vector<KeyPoint> keypoints;
detector->detect(image, keypoints);
在前面的示例中,algorithm 是一个可以在运行时设置的整数值,它将改变分配给 detector 对象的角点检测算法的类型,该对象具有 Feature2D 类型,换句话说,是所有角点检测算法的基类。
边缘检测算法
既然我们已经了解了角点检测算法,让我们来看看边缘检测算法,这在计算机视觉中的形状分析中至关重要。OpenCV 包含了许多可以用于从图像中提取边缘的算法。我们将要学习的第一个边缘检测算法被称为 线段检测算法,可以通过使用 LineSegmentDetector 类来实现,如下面的示例所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
Ptr<LineSegmentDetector> detector = createLineSegmentDetector();
vector<Vec4f> lines;
detector->detect(imgGray,
lines);
如您所见,LineSegmentDetector 类需要一个单通道图像作为输入,并生成一个 vector 的线条。结果中的每条线都是 Vec4f,即代表 x1、y1、x2 和 y2 值的四个浮点数,换句话说,就是构成每条线的两个点的坐标。您可以使用 drawSegments 函数来可视化 LineSegmentDetector 类的 detect 函数的结果,如下面的示例所示:
Mat result(image.size(),
CV_8UC3,
Scalar(0, 0, 0));
detector->drawSegments(result,
lines);
为了更好地控制结果线条的可视化,您可能需要手动绘制线条向量,如下面的示例所示:
Mat result(image.size(),
CV_8UC3,
Scalar(0, 0, 0));
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
line(result,
Point(lines.at(i)[0],
lines.at(i)[1]),
Point(lines.at(i)[2],
lines.at(i)[3]),
color,
thickness);
}
以下图像展示了前面示例代码中使用的线段检测算法的结果:

如需了解更多关于如何自定义 LineSegmentDetector 类的行为的详细信息,请确保查看 createLineSegmentDetector 的文档及其参数。在我们的示例中,我们简单地省略了所有输入参数,并使用默认值设置了 LineSegmentDetector 类的参数。
LineSegmentDetector 类的另一个功能是比较两组线条以找到非重叠像素的数量,同时将比较结果绘制在输出图像上以进行可视化比较。以下是一个示例:
vector<Vec4f> lines1, lines2;
detector->detect(imgGray1,
lines1);
detector->detect(imgGray2,
lines2);
Mat resultImg(imageSize, CV_8UC3, Scalar::all(0));
int result = detector->compareSegments(imageSize,
lines1,
lines2,
resultImg);
在前面的代码中,imageSize 是一个 Size 对象,它包含从其中提取线条的输入图像的大小。结果是包含比较函数或 compareSegments 函数结果的整数值,在像素完全重叠的情况下将为零。
下一个边缘检测算法可能是计算机视觉中最广泛使用和引用的边缘检测算法之一,称为 Canny 算法,在 OpenCV 中具有相同名称的函数。Canny 函数的最大优点是其输入参数的简单性。让我们首先看看它的一个示例用法,然后详细说明其细节:
Mat image = imread("Test.png");
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
阈值值(threshold1 和 threshold2)是用于阈值化输入图像的下限和上限值。apertureSize 是内部 Sobel 运算符的孔径大小,而 L2gradient 用于在计算梯度图像时启用或禁用更精确的 L2 范数。Canny 函数的结果是一个灰度图像,其中包含检测到边缘处的白色像素和其余像素的黑色像素。这使得 Canny 函数的结果在需要此类掩模的地方非常适合,或者,如你稍后所看到的,提取轮廓的合适点集。
以下图像描述了前面示例中使用的 Canny 函数的结果:

如我们之前提到的,Canny 函数的结果适合用作需要二值图像的算法的输入,换句话说,是一个只包含绝对黑色和绝对白色像素值的灰度图像。我们将学习下一个算法,其中必须使用先前 Canny 函数的结果作为输入,它被称为 霍夫变换。霍夫变换可以用于从图像中提取线条,并在 OpenCV 库中的 HoughLines 函数中实现。
下面是一个完整的示例,展示了如何在实际中使用 HoughLines 函数:
- 调用
Canny函数检测输入图像中的边缘,如下所示:
Mat image = imread("Test.png");
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
- 调用
HoughLines函数从检测到的边缘中提取线条:
vector<Vec2f> lines;
double rho = 1.0; // 1 pixel, r resolution
double theta = CV_PI / 180.0; // 1 degree, theta resolution
int threshold = 100; // minimum number of intersections to "detect" a line
HoughLines(edges,
lines,
rho,
theta,
threshold);
- 使用以下代码在标准坐标系中提取点,并在输入图像上绘制它们:
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
float rho = lines.at(i)[0];
float theta = lines.at(i)[1];
Point pt1, pt2;
double a = cos(theta);
double b = sin(theta);
double x0 = a*rho;
double y0 = b*rho;
pt1.x = int(x0 + 1000*(-b));
pt1.y = int(y0 + 1000*(a));
pt2.x = int(x0 - 1000*(-b));
pt2.y = int(y0 - 1000*(a));
line( image, pt1, pt2, color, thickness);
}
以下图像从左到右描述了前面示例的结果,首先是原始图像,然后是使用 Canny 函数检测到的边缘,接着是使用 HoughLines 函数检测到的线条,最后是输出图像:

为了避免处理坐标系统变化,你可以使用 HoughLinesP 函数直接提取形成每个检测到的线条的点。以下是一个示例:
vector<Vec4f> lines;
double rho = 1.0; // 1 pixel, r resolution
double theta = CV_PI / 180.0; // 1 degree, theta resolution
int threshold = 100; // minimum number of intersections to "detect" a line
HoughLinesP(edges,
lines,
rho,
theta,
threshold);
Scalar color(0,0,255);
int thickness = 2;
for(int i=0; i<lines.size(); i++)
{
line(image,
Point(lines.at(i)[0],
lines.at(i)[1]),
Point(lines.at(i)[2],
lines.at(i)[3]),
color,
thickness);
}
Hough 变换非常强大,OpenCV 包含更多 Hough 变换算法的变体,我们将留给您使用 OpenCV 文档和在线资源去发现。请注意,使用 Canny 算法是 Hough 变换的前提,正如您将在下一节中看到的,也是处理图像中物体形状的许多算法的前提。
轮廓计算和分析
图像中形状和物体的轮廓是一个重要的视觉属性,可以用来描述和分析它们。计算机视觉也不例外,因此计算机视觉中有相当多的算法可以用来计算图像中物体的轮廓或计算它们的面积等。
以下图像展示了从两个 3D 物体中提取的两个轮廓:

OpenCV 包含一个名为 findContours 的函数,可以用来从图像中提取轮廓。此函数必须提供一个合适的二值图像,其中包含轮廓的最佳候选像素;例如,Canny 函数的结果是一个不错的选择。以下示例演示了计算图像轮廓所需的步骤:
- 使用
Canny函数找到边缘,如下所示:
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
- 使用
findContours函数通过检测到的边缘来计算轮廓。值得注意的是,每个轮廓都是一个Point对象的vector,因此所有轮廓都是一个vector的vector的Point对象,如下所示:
vector<vector<Point> > contours;
int mode = CV_RETR_TREE;
int method = CV_CHAIN_APPROX_TC89_KCOS;
findContours(edges,
contours,
mode,
method);
在前面的例子中,轮廓检索模式设置为 CV_RETR_TREE,轮廓近似方法设置为 CV_CHAIN_APPROX_TC89_KCOS。请确保自己查看所有可能的模式和方法的列表,并比较结果以找到最适合您用例的最佳参数。
- 可视化检测到的轮廓的常见方法是通过使用
RNG类,即随机数生成器类,为每个检测到的轮廓生成随机颜色。以下示例演示了如何结合使用RNG类和drawContours函数来正确可视化findContours函数的结果:
RNG rng(12345); // any random number
Mat result(edges.size(), CV_8UC3, Scalar(0));
int thickness = 2;
for( int i = 0; i< contours.size(); i++ )
{
Scalar color = Scalar(rng.uniform(0, 255),
rng.uniform(0,255),
rng.uniform(0,255) );
drawContours(result,
contours,
i,
color,
thickness);
}
以下图像展示了 Canny 和 findContours 函数的结果:

注意右侧图像中的不同颜色,它们对应于使用 findContours 函数检测到的完整轮廓。
计算轮廓之后,我们可以使用轮廓分析函数进一步修改它们或分析图像中物体的形状。让我们从 contourArea 函数开始,该函数可以用来计算给定轮廓的面积。以下是该函数的使用方法:
double area = contourArea(contour);
你可以使用面积作为阈值来忽略不符合某些标准的检测到的轮廓。例如,在前面的示例代码中,我们使用了drawContours函数,我们可以去除面积小于某个预定义阈值值的轮廓。以下是一个示例:
for( int i = 0; i< contours.size(); i++ )
{
if(contourArea(contours[i]) > thresholdArea)
{
drawContours(result,
contours,
i,
color,
thickness);
}
}
将contourArea函数的第二个参数(这是一个布尔参数)设置为true会导致考虑轮廓的朝向,这意味着你可以根据轮廓的朝向得到正或负的面积值。
另一个非常有用的轮廓分析函数是pointPolygonTest函数。从其名称可以猜到,这个函数用于执行点在多边形内测试,换句话说,就是点在轮廓内测试。以下是该函数的使用方法:
Point pt(x, y);
double result = pointPolygonTest(contours[i], Point(x,y), true);
如果结果是零,这意味着测试点正好在轮廓的边缘上。一个负的结果意味着测试点在轮廓外部,而一个正的结果意味着测试点在轮廓内部。这个值本身是测试点到最近的轮廓边缘的距离。
要检查一个轮廓是否是凸的,你可以使用isContourConvex函数,如下例所示:
bool isIt = isContourConvex(contour);
能够比较两个轮廓是处理轮廓和形状分析时最基本的需求之一。你可以使用 OpenCV 中的matchShapes函数来比较并尝试匹配两个轮廓。以下是该函数的使用方法:
ShapeMatchModes method = CONTOURS_MATCH_I1;
double result = matchShapes(cntr1, cntr2, method, 0);
method可以取以下任何值,而最后一个参数必须始终设置为零,除非使用的方法有特殊指定:
-
CONTOURS_MATCH_I1 -
CONTOURS_MATCH_I2 -
CONTOURS_MATCH_I3
关于前面列出的轮廓匹配方法之间的数学差异的详细信息,你可以参考 OpenCV 文档。
能够找到轮廓的边界等同于能够正确地定位它,例如,找到一个可以用于跟踪或执行其他计算机视觉算法的区域。假设我们有一个以下图像及其使用findContours函数检测到的单个轮廓:

有了这个轮廓,我们可以执行我们所学到的任何轮廓和形状分析算法。此外,我们可以使用许多 OpenCV 函数来定位提取的轮廓。让我们从boundingRect函数开始,该函数用于找到包含给定点集或轮廓的最小直立矩形(Rect对象)。以下是该函数的使用方法:
Rect br = boundingRect(contour);
下图是使用前一个示例代码中的boundingRect函数获取的直立矩形的绘制结果:

类似地,你可以使用minAreaRect函数来找到包含给定点集或轮廓的最小旋转矩形。以下是一个示例:
RotatedRect br = minAreaRect(contour);
你可以使用以下代码来可视化结果旋转矩形:
Point2f points[4];
br.points(points);
for (int i=0; i<4; i++)
line(image,
points[i],
points[(i+1)%4],
Scalar(0,0,255),
2);
您可以使用ellipse函数绘制椭圆,或者两者都做,结果将类似于以下内容:

除了用于寻找轮廓的最小垂直和旋转边界矩形的算法外,您还可以使用minEnclosingCircle和minEnclosingTriangle函数来找到给定点集或轮廓的最小边界圆和矩形。以下是如何使用这些函数的示例:
// to detect the minimal bounding circle
Point2f center;
float radius;
minEnclosingCircle(contour, center, radius);
// to detect the minimal bounding triangle
vector<Point2f> triangle;
minEnclosingTriangle(contour, triangle);
轮廓的可能用例清单没有尽头,但在进入下一部分之前,我们将列举其中的一些。您可以尝试将轮廓检测和形状分析算法与阈值算法或后投影图像结合使用,例如,以确保您的跟踪算法除了像素的颜色和强度值外,还使用形状信息。您还可以使用轮廓来计数和分析生产线上的物体形状,在那里背景和视觉环境更加可控。
本章的最后部分将教授您如何使用特征检测、描述符提取和描述符匹配算法来检测已知对象,但具有旋转、缩放甚至透视不变性。
检测、描述和匹配特征
正如我们在本章前面学到的,可以使用各种特征提取(检测)算法从图像中提取特征或关键点,其中大多数依赖于检测强度发生显著变化的点,例如角点。检测正确的关键点等同于能够正确确定哪些图像部分有助于识别它。但仅仅一个关键点,或者说图像中一个显著点的位置,本身并没有什么用处。有人可能会争辩说,图像中关键点位置的集合就足够了,但即便如此,另一个外观完全不同的物体也可能在图像中具有相同位置的关键点,比如说,偶然之间。
这就是特征描述符,或者简单地称为描述符,发挥作用的地方。从名称中你可以猜到,描述符是一种算法依赖的方法,用于描述一个特征,例如,通过使用其相邻像素值、梯度等。有许多不同的描述符提取算法,每个都有自己的优缺点,逐一研究它们并不会带来太多成果,尤其是对于一本实践性书籍来说,但值得注意的是,大多数算法只是从一组关键点中生成一个描述符向量。从一组关键点中提取了一组描述符后,我们可以使用描述符匹配算法来从两张不同的图像中找到匹配的特征,例如,一个物体的图像和该物体存在的场景图像。
OpenCV 包含大量的特征检测器、描述符提取器和描述符匹配器。OpenCV 中所有的特征检测器和描述符提取器算法都是Feature2D类的子类,它们位于features2d模块中,该模块默认包含在 OpenCV 包中,或者位于xfeatures2d(额外模块)模块中。您应谨慎使用这些算法,并始终参考 OpenCV 文档,因为其中一些实际上是受专利保护的,并且在使用商业项目时需要从其所有者处获得许可。以下是 OpenCV 默认包含的一些主要特征检测器和描述符提取器算法列表:
-
BRISK(二值鲁棒可伸缩关键点)
-
KAZE
-
AKAZE(加速 KAZE)
-
ORB,或方向 BRIEF(二值鲁棒独立基本特征)
所有这些算法都在 OpenCV 中实现了与标题完全相同的类,再次强调,它们都是Feature2D类的子类。它们的使用非常简单,尤其是在没有修改任何参数的情况下。在所有这些算法中,您只需使用静态create方法来创建其实例,调用detect方法来检测关键点,最后调用computer来提取检测到的关键点的描述符。
对于描述符匹配算法,OpenCV 默认包含以下匹配算法:
-
FLANNBASED -
BRUTEFORCE -
BRUTEFORCE_L1 -
BRUTEFORCE_HAMMING -
BRUTEFORCE_HAMMINGLUT -
BRUTEFORCE_SL2
您可以使用DescriptorMatcher类,或其子类,即BFMatcher和FlannBasedMatcher,来执行各种匹配算法。您只需使用这些类的静态create方法来创建其实例,然后使用match方法来匹配两组描述符。
让我们通过一个完整的示例来回顾本节中讨论的所有内容,因为将特征检测、描述符提取和匹配分开是不可能的,它们都是一系列过程的一部分,这些过程导致在场景中检测到具有其特征的对象:
- 使用以下代码读取对象的图像,以及将要搜索对象的场景:
Mat object = imread("object.png");
Mat scene = imread("Scene.png");
在以下图片中,假设左边的图像是我们正在寻找的对象,右边的图像是包含该对象的场景:

- 从这两张图像中提取关键点,现在它们存储在
object和scene中。我们可以使用上述任何一种算法进行特征检测,但让我们假设我们在这个例子中使用 KAZE,如下所示:
Ptr<KAZE> detector = KAZE::create();
vector<KeyPoint> objKPs, scnKPs;
detector->detect(object, objKPs);
detector->detect(scene, scnKPs);
- 我们有了对象图像和场景图像的关键点。我们可以继续使用
drawKeypoints函数来查看它们,就像我们在本章中之前学到的。自己尝试一下,然后使用相同的KAZE类从关键点中提取描述符。以下是操作方法:
Mat objDesc, scnDesc;
detector->compute(object, objKPs, objDesc);
detector->compute(scene, scnKPs, scnDesc);
objDesc和scnDesc对应于从物体和场景图像中提取的关键点的描述符。如前所述,描述符是算法相关的,要解释它们中的确切值需要深入了解用于提取它们的特定算法。确保参考 OpenCV 文档以获取更多关于它们的知识,然而,在本步骤中,我们将简单地使用穷举匹配器算法来匹配从两张图像中提取的描述符。以下是操作方法:
Ptr<BFMatcher> matcher = BFMatcher::create();
vector<DMatch> matches;
matcher->match(objDesc, scnDesc, matches);
BFMatcher类是DescriptorMatcher类的子类,实现了穷举匹配算法。描述符匹配的结果存储在一个DMatch对象vector中。每个DMatch对象包含匹配特征所需的所有必要信息,从物体描述符到场景描述符。
- 您现在可以尝试使用
drawMatches函数可视化匹配结果,如下所示:
Mat result;
drawMatches(object,
objKPs,
scene,
scnKPs,
matches,
result,
Scalar(0, 255, 0), // green for matched
Scalar::all(-1), // unmatched color (not used)
vector<char>(), // empty mask
DrawMatchesFlags::NOT_DRAW_SINGLE_POINTS);
如您所见,一些匹配的特征显然是不正确的,一些位于场景图像的顶部,还有一些位于底部:

- 可以通过在
DMatch对象的distance值上设置阈值来过滤掉不良匹配。阈值取决于算法和图像内容类型,但在我们的示例案例中,使用 KAZE 算法,0.1的值似乎对我们来说足够了。以下是过滤阈值以从所有匹配中获取良好匹配的方法:
vector<DMatch> goodMatches;
double thresh = 0.1;
for(int i=0; i<objDesc.rows; i++)
{
if(matches[i].distance < thresh)
goodMatches.push_back(matches[i]);
}
if(goodMatches.size() > 0)
{
cout << "Found " << goodMatches.size() << " good matches.";
}
else
{
cout << "Didn't find a single good match. Quitting!";
return -1;
}
以下图像展示了在goodMatches向量上drawMatches函数的结果:

- 显然,经过过滤的匹配结果现在要好得多。我们可以使用
findHomography函数来找到物体图像到场景图像之间良好的匹配关键点的变换。以下是操作方法:
vector<Point2f> goodP1, goodP2;
for(int i=0; i<goodMatches.size(); i++)
{
goodP1.push_back(objKPs[goodMatches[i].queryIdx].pt);
goodP2.push_back(scnKPs[goodMatches[i].trainIdx].pt);
}
Mat homoChange = findHomography(goodP1, goodP2);
- 正如我们在前面的章节中已经看到的,
findHomography函数的结果可以用来变换一组点。我们可以利用这个事实,使用物体图像的四个角来创建四个点,然后使用perspectiveTransform函数变换这些点,以获取场景图像中这些点的位置。以下是一个示例:
vector<Point2f> corners1(4), corners2(4);
corners1[0] = Point2f(0,0);
corners1[1] = Point2f(object.cols-1, 0);
corners1[2] = Point2f(object.cols-1, object.rows-1);
corners1[3] = Point2f(0, object.rows-1);
perspectiveTransform(corners1, corners2, homoChange);
- 变换后的点可以用来绘制四条线,以定位场景图像中检测到的物体,如下所示:
line(result, corners2[0], corners2[1], Scalar::all(255), 2);
line(result, corners2[1], corners2[2], Scalar::all(255), 2);
line(result, corners2[2], corners2[3], Scalar::all(255), 2);
line(result, corners2[3], corners2[0], Scalar::all(255), 2);
如果您打算在drawMatches图像结果上绘制定位物体的四条线,那么也很重要的是要更改结果点的x值,以考虑物体图像的宽度。以下是一个示例:
for(int i=0; i<4; i++)
corners2[i].x += object.cols;
以下图像展示了我们检测操作的最终结果:

确保亲自尝试其余的特征检测、描述符提取和匹配算法,并比较它们的结果。同时,尝试测量每个算法的计算时间。例如,你可能注意到 AKAZE 比 KAZE 快得多,或者 BRISK 更适合某些图像,而 KAZE 或 ORB 则更适合其他图像。如前所述,基于特征的目标检测方法在尺度、旋转甚至透视变化方面更加可靠。尝试不同视角的同一物体,以确定你自己的项目和用例的最佳参数和算法。例如,这里有一个演示 AKAZE 算法的旋转和尺度不变性的另一个示例,以及暴力匹配:

注意,生成前面输出的源代码是使用本节中我们讨论的完全相同的指令集创建的。
概述
我们从学习模板匹配算法和目标检测算法开始这一章,尽管它很受欢迎,但它缺乏一些适当目标检测算法的最基本方面,如尺度和旋转不变性;此外,它是一个纯像素级目标检测算法。在此基础上,我们学习了如何使用全局最大值和最小值检测算法来解释模板匹配算法的结果。然后,我们学习了关于角点和边缘检测算法,或者换句话说,检测图像中点和重要区域的算法。我们学习了如何可视化它们,然后转向学习轮廓检测和形状分析算法。本章的最后部分包括了一个完整的教程,介绍如何在图像中检测关键点,从这些关键点中提取描述符,并使用匹配器算法在场景中检测目标。我们现在熟悉了一整套算法,可以用来分析图像,不仅基于它们的像素颜色和强度值,还基于它们的内容和现有的关键点。
本书最后一章将带我们了解 OpenCV 中的计算机视觉和机器学习算法,以及它们如何被用于检测使用先前存在的一组图像来检测对象,以及其他许多有趣的人工智能相关主题。
问题
-
模板匹配算法本身不具有尺度和旋转不变性。我们如何使其对于以下情况成立?a) 模板图像的尺度加倍,b) 模板图像旋转 90 度?
-
使用
GFTTDetector类通过 Harris 角点检测算法检测关键点。你可以为角点检测算法设置任何值。 -
Hough 变换也可以用于检测图像中的圆,使用
HoughCircles函数。在 OpenCV 文档中搜索它,并编写一个程序来检测图像中的圆。 -
在图像中检测并绘制凸轮廓。
-
使用
ORB类在两张图像中检测关键点,提取它们的描述符,并将它们进行匹配。 -
哪种特征描述符匹配算法与 ORB 算法不兼容,以及原因是什么?
-
您可以使用以下 OpenCV 函数和提供的示例来计算执行任意多行代码所需的时间。用它来计算您计算机上匹配算法所需的时间:
double freq = getTickFrequency();
double countBefore = getTickCount();
// your code goes here ..
double countAfter = getTickCount();
cout << "Duration: " <<
(countAfter - countBefore) / freq << " seconds";
第八章:计算机视觉中的机器学习
在前面的章节中,我们学习了关于目标检测和跟踪的许多算法。我们学习了如何结合直方图和反向投影图像使用基于颜色的算法,如均值漂移和 CAM 漂移,以极快的速度在图像中定位目标。我们还学习了模板匹配及其如何用于在图像中找到具有已知像素模板的对象。所有这些算法都以某种方式依赖于图像属性,如亮度或颜色,这些属性很容易受到环境光照变化的影响。基于这些事实,我们继续学习基于图像中显著区域知识的算法,称为关键点或特征。我们学习了关于边缘和关键点检测算法以及如何提取这些关键点的描述符。我们还学习了描述符匹配器以及如何使用从感兴趣对象图像和搜索该对象的场景中提取的描述符的良好匹配来检测图像中的对象。
在本章中,我们将迈出一大步,学习可以用于从大量对象图像中提取模型并随后使用该模型在图像中检测对象或简单地分类图像的算法。这些算法是机器学习算法和计算机视觉算法的交汇点。任何熟悉人工智能和一般机器学习算法的人都将很容易继续本章的学习,即使他们不熟悉本章中介绍的精确算法和示例。然而,对于那些对这类概念完全陌生的人来说,可能需要再找一本书,最好是关于机器学习的,以便熟悉我们将在本章学习的算法,例如支持向量机(SVM)、人工神经网络(ANN)、级联分类和深度学习。
在本章中,我们将探讨以下内容:
-
如何训练和使用 SVM 进行分类
-
使用 HOG 和 SVM 进行图像分类
-
如何训练和使用 ANN 进行预测
-
如何训练和使用 Haar 或 LBP 级联分类器进行实时目标检测
-
如何使用第三方深度学习框架中的预训练模型
技术要求
-
用于开发 C++或 Python 应用程序的 IDE
-
OpenCV 库
有关如何设置个人计算机并使其准备好使用 OpenCV 库开发计算机视觉应用程序的更多信息,请参阅第二章,OpenCV 入门。
您可以使用此 URL 下载本章的源代码和示例:github.com/PacktPublishing/Hands-On-Algorithms-for-Computer-Vision/tree/master/Chapter08。
支持向量机
简单来说,支持向量机(SVMs)用于从标记的训练样本集中创建一个模型,该模型可以用来预测新样本的标签。例如,假设我们有一组属于两个不同组的样本数据。我们训练数据集中的每个样本都是一个浮点数向量,它可以对应于任何东西,例如二维或三维空间中的一个简单点,并且每个样本都标记为一个数字,如 1、2 或 3。有了这样的数据,我们可以训练一个 SVM 模型,用来预测新的二维或三维点的标签。让我们再考虑另一个问题。想象一下,我们有了来自世界所有大陆城市的 365 天的温度数据,365 天的温度值向量被标记为 1 代表亚洲,2 代表欧洲,3 代表非洲等等。我们可以使用这些数据来训练一个 SVM 模型,用来预测新的温度值向量(365 天)所属的大陆,并将它们与标签关联起来。尽管这些例子在实践上可能没有用,但它们描述了 SVM 的概念。
我们可以使用 OpenCV 中的 SVM 类来训练和使用 SVM 模型。让我们通过一个完整的示例详细说明 SVM 类的使用方法:
- 由于 OpenCV 中的机器学习算法包含在
ml命名空间下,我们需要确保在我们的代码中包含这些命名空间,以便其中的类可以轻松访问,以下是如何做到这一点的代码:
using namespace cv;
using namespace ml;
- 创建训练数据集。正如我们之前提到的,训练数据集是一组浮点数向量(样本)的集合,每个向量都被标记为该向量的类别 ID 或类别。让我们从样本开始:
const int SAMPLE_COUNT = 8;
float samplesA[SAMPLE_COUNT][2]
= { {250, 50},
{125, 100},
{50, 50},
{150, 150},
{100, 250},
{250, 250},
{150, 50},
{50, 250} };
Mat samples(SAMPLE_COUNT, 2, CV_32F, samplesA);
在这个例子中,我们八个样本的数据集中的每个样本包含两个浮点值,这些值可以用图像上的一个点来表示,该点具有 x 和 y 值。
- 我们还需要创建标签(或响应)数据,显然它必须与样本长度相同。以下是它:
int responsesA[SAMPLE_COUNT]
= {2, 2, 2, 2, 1, 2, 2, 1};
Mat responses(SAMPLE_COUNT, 1, CV_32S, responsesA);
如您所见,我们的样本被标记为 1 和 2 的值,因此我们期望我们的模型能够区分给定两组样本中的新样本。
- OpenCV 使用
TrainData类来简化训练数据集的准备和使用。以下是它的使用方法:
Ptr<TrainData> data;
SampleTypes layout = ROW_SAMPLE;
data = TrainData::create(samples,
layout,
responses);
在前面的代码中,layout 被设置为 ROW_SAMPLE,因为我们的数据集中的每一行包含一个样本。如果数据集的布局是垂直的,换句话说,如果数据集中的每个样本是 samples 矩阵中的一列,我们需要将 layout 设置为 COL_SAMPLE。
- 创建实际的
SVM类实例。这个类在 OpenCV 中实现了各种类型的 SVM 分类算法,并且可以通过设置正确的参数来使用。在这个例子中,我们将使用SVM类最基本(也是最常见)的参数集,但要能够使用此算法的所有可能功能,请确保查阅 OpenCVSVM类文档页面。以下是一个示例,展示了我们如何使用 SVM 执行线性n-类分类:
Ptr<SVM> svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::LINEAR);
svm->setTermCriteria(
TermCriteria(TermCriteria::MAX_ITER +
TermCriteria::EPS,
100,
1e-6));
- 使用
train(或trainAuto)方法训练 SVM 模型,如下所示:
if(!svm->train(data))
{
cout << "training failed" << endl;
return -1;
}
根据我们训练样本数据集中的数据量,训练过程可能需要一些时间。在我们的例子中,应该足够快,因为我们只是使用了一小部分样本来训练模型。
- 我们将使用 SVM 模型来实际预测新样本的标签。记住,我们训练集中的每个样本都是一个图像中的 2D 点。我们将找到图像中宽度为
300像素、高度为300像素的每个 2D 点的标签,然后根据其预测标签是1还是2,将每个像素着色为绿色或蓝色。以下是方法:
Mat image = Mat::zeros(300,
300,
CV_8UC3);
Vec3b blue(255,0,0), green(0,255,0);
for (int i=0; i<image.rows; ++i)
{
for (int j=0; j<image.cols; ++j)
{
Mat_<float> sampleMat(1,2);
sampleMat << j, i;
float response = svm->predict(sampleMat);
if (response == 1)
image.at<Vec3b>(i, j) = green;
else if (response == 2)
image.at<Vec3b>(i, j) = blue;
}
}
- 显示预测结果,但要能够完美地可视化 SVM 算法的分类结果,最好绘制我们用来创建 SVM 模型的训练样本。让我们使用以下代码来完成它:
Vec3b black(0,0,0), white(255,255,255), color;
for(int i=0; i<SAMPLE_COUNT; i++)
{
Point p(samplesA[i][0],
samplesA[i][1]);
if (responsesA[i] == 1)
color = black;
else if (responsesA[i] == 2)
color = white;
circle(image,
p,
5,
color,
CV_FILLED);
}
两种类型的样本(1和2)在结果图像上被绘制为黑色和白色的圆圈。以下图表展示了我们刚刚执行的完整 SVM 分类的结果:

这个演示非常简单,实际上,SVM 可以用于更复杂的分类问题,然而,它实际上展示了 SVM 最基本的一个方面,即分离被标记为相同的数据组。正如您在前面的图像中可以看到,将蓝色区域与绿色区域分开的线是能够最有效地分离图像上黑色点和白色点的最佳单一线。
您可以通过更新标签,或者换句话说,更新前一个示例中的响应来实验这种现象,如下所示:
int responsesA[SAMPLE_COUNT]
= {2, 2, 2, 2, 1, 1, 2, 1};
现在尝试可视化结果会产生类似以下内容,它再次描绘了分离两组点的最有效线:

您可以非常容易地向数据添加更多类别,或者换句话说,为您的训练样本集添加更多标签。以下是一个示例:
int responsesA[SAMPLE_COUNT]
= {2, 2, 3, 2, 1, 1, 2, 1};
我们可以通过添加黄色,例如,为第三类区域添加颜色,为属于该类的训练样本添加灰色点来再次尝试可视化结果。以下是使用三个类别而不是两个类别时相同 SVM 示例的结果:

如果你回想起之前的 365 天示例,很明显我们也可以向 SVM 模型添加更多的维度,而不仅仅是类别,但使用像前面示例那样的简单图像来可视化结果将是不可能的。
在继续使用 SVM 算法进行实际目标检测和图像分类之前,值得注意的是,就像任何其他机器学习算法一样,数据集中样本数量的增加将导致分类效果更好、准确率更高,但也会使模型训练所需的时间更长。
使用 SVM 和 HOG 进行图像分类
方向梯度直方图(HOG)是一种算法,可以用来描述图像,使用从该图像中提取的对应于方向梯度值的浮点描述符向量。HOG 算法非常流行,并且详细阅读它以了解其在 OpenCV 中的实现方法是非常有价值的,但出于本书和特别是本节的目的,我们只需提到,当从具有相同大小和相同 HOG 参数的图像中提取时,浮点描述符的数量始终相同。为了更好地理解这一点,请回忆一下,使用我们在上一章中学习的特征检测算法从图像中提取的描述符可能具有不同数量的元素。然而,HOG 算法在参数不变的情况下,对于同一大小的图像集,总是会生成相同长度的向量。
这使得 HOG 算法非常适合与 SVM 结合使用,以训练一个可以用于图像分类的模型。让我们通过一个例子来看看它是如何实现的。想象一下,我们有一组图像,其中包含一个文件夹中的交通标志图像,另一个文件夹中则包含除该特定交通标志之外的所有图像。以下图片展示了我们的样本数据集中的图像,它们之间用一条黑色线分隔:

使用与前面样本相似的图像,我们将训练 SVM 模型以检测图像是否是我们正在寻找的交通标志。让我们开始吧:
- 创建一个
HOGDescriptor对象。HOGDescriptor,或称 HOG 算法,是一种特殊的描述符算法,它依赖于给定的窗口大小、块大小以及各种其他参数;为了简化,我们将避免除窗口大小之外的所有参数。在我们的例子中,HOG 算法的窗口大小是128像素乘以128像素,如下所示:
HOGDescriptor hog;
hog.winSize = Size(128, 128);
样本图像应该与窗口大小相同,否则我们需要使用resize函数确保它们在后续操作中调整到 HOG 窗口大小。这保证了每次使用 HOG 算法时描述符大小的一致性。
- 正如我们刚才提到的,如果图像大小是恒定的,那么使用
HOGDescriptor提取的描述符的向量长度将是恒定的,并且假设图像大小与winSize相同,你可以使用以下代码来获取描述符长度:
vector<float> tempDesc;
hog.compute(Mat(hog.winSize, CV_8UC3),
tempDesc);
int descriptorSize = tempDesc.size();
我们将在读取样本图像时使用 descriptorSize。
- 假设交通标志的图像存储在一个名为
pos(表示正面)的文件夹中,其余的图像存储在一个名为neg(表示负面)的文件夹中,我们可以使用glob函数来获取这些文件夹中图像文件的列表,如下所示:
vector<String> posFiles;
glob("/pos", posFiles);
vector<String> negFiles;
glob("/neg", negFiles);
- 创建缓冲区以存储来自
pos和neg文件夹的正负样本图像的 HOG 描述符。我们还需要一个额外的缓冲区来存储标签(或响应),如下所示:
int scount = posFiles.size() + negFiles.size();
Mat samples(scount,
descriptorSize,
CV_32F);
Mat responses(scount,
1,
CV_32S);
- 我们需要使用
HOGDescriptor类从正图像中提取 HOG 描述符并将它们存储在samples中,如下所示:
for(int i=0; i<posFiles.size(); i++)
{
Mat image = imread(posFiles.at(i));
if(image.empty())
continue;
vector<float> descriptors;
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
hog.compute(image, descriptors);
Mat(1, descriptorSize, CV_32F, descriptors.data())
.copyTo(samples.row(i));
responses.at<int>(i) = +1; // positive
}
需要注意的是,我们为正样本的标签(响应)添加了 +1。当我们对负样本进行标记时,我们需要使用不同的数字,例如 -1。
- 在正样本之后,我们将负样本及其响应添加到指定的缓冲区中:
for(int i=0; i<negFiles.size(); i++)
{
Mat image = imread(negFiles.at(i));
if(image.empty())
continue;
vector<float> descriptors;
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
hog.compute(image, descriptors);
Mat(1, descriptorSize, CV_32F, descriptors.data())
.copyTo(samples.row(i + posFiles.size()));
responses.at<int>(i + posFiles.size()) = -1;
}
- 与上一节中的示例类似,我们需要使用
samples和responses来形成一个TrainData对象,以便与train函数一起使用。以下是实现方式:
Ptr<TrainData> tdata = TrainData::create(samples,
ROW_SAMPLE,
responses);
- 现在,我们需要按照以下示例代码训练 SVM 模型:
Ptr<SVM> svm = SVM::create();
svm->setType(SVM::C_SVC);
svm->setKernel(SVM::LINEAR);
svm->setTermCriteria(
TermCriteria(TermCriteria::MAX_ITER +
TermCriteria::EPS,
10000,
1e-6));
svm->train(tdata);
训练完成后,SVM 模型就准备好使用与 HOG 窗口大小相同的图像(在这种情况下,128 x 128 像素)进行分类了,使用 SVM 类的 predict 方法。以下是操作方法:
Mat image = imread("image.jpg");
if((image.cols != hog.winSize.width)
||
(image.rows != hog.winSize.height))
{
resize(image, image, hog.winSize);
}
vector<float> descs;
hog.compute(image, descs);
int result = svm->predict(descs);
if(result == +1)
{
cout << "Image contains a traffic sign." << endl;
}
else if(result == -1)
{
cout << "Image does not contain a traffic sign." << endl;
}
在前面的代码中,我们简单地读取一个图像并将其调整到 HOG 窗口大小。然后我们使用 HOGDescriptor 类的 compute 方法,就像我们在训练模型时做的那样。但是,这次我们使用 predict 方法来找到新图像的标签。如果 result 等于 +1,这是我们训练 SVM 模型时为交通标志图像分配的标签,那么我们知道该图像是交通标志的图像,否则不是。
结果的准确性完全取决于你用于训练 SVM 模型的数据的数量和质量。实际上,每个机器学习算法都是如此。你训练模型越多,它就越准确。
这种分类方法假设输入图像与训练图像具有相同的特征。这意味着,如果图像包含交通标志,它将被裁剪得与用于训练模型的图像相似。例如,如果你使用包含我们正在寻找的交通标志图像的图像,但包含得更多,那么结果可能是不正确的。
随着训练集中数据量的增加,训练模型将需要更多时间。因此,每次你想使用模型时避免重新训练模型是很重要的。SVM类允许你使用save和load方法保存和加载 SVM 模型。以下是如何保存训练好的 SVM 模型以供以后使用并避免重新训练的方法:
svm->save("trained_svm_model.xml");
文件将使用提供的文件名和扩展名(XML 或 OpenCV 支持的任何其他文件类型)保存。稍后,使用静态load函数,你可以创建一个包含确切参数和训练模型的 SVM 对象。以下是一个示例:
Ptr<SVM> svm = SVM::load("trained_svm_model.xml ");
尝试使用SVM类和HOGDescriptor来训练模型,这些模型可以使用存储在不同文件夹中的各种对象的图像检测和分类更多类型。
使用人工神经网络训练模型
ANN 可以使用一组样本输入和输出向量来训练模型。ANN 是一种高度流行的机器学习算法,是许多现代人工智能算法的基础,这些算法用于训练用于分类和关联的模型。特别是在计算机视觉中,ANN 算法可以与广泛的特征描述算法一起使用,以了解物体的图像,甚至不同人的面部,然后用于在图像中检测它们。
你可以使用 OpenCV 中的ANN_MLP类(代表人工神经网络——多层感知器)在你的应用程序中实现 ANN。这个类的使用方法与SVM类非常相似,所以我们将给出一个简单的示例来学习差异以及它在实际中的应用,其余的我们将留给你自己探索。
在 OpenCV 中,创建训练样本数据集对所有机器学习算法都是一样的,或者更准确地说,对所有StatsModel类的子类都是这样。ANN_MLP类也不例外,因此,就像SVM类一样,我们首先需要创建一个TrainData对象,该对象包含我们在训练我们的 ANN 模型时需要使用的所有样本和响应数据,如下所示:
SampleTypes layout = ROW_SAMPLE;
data = TrainData::create(samples,
layout,
responses);
在前面的代码中,samples和responses都是Mat对象,它们包含的行数等于我们数据集中所有训练数据的数量。至于它们的列数,让我们回忆一下,ANN 算法可以用来学习输入和输出数据向量之间的关系。这意味着训练输入数据(或samples)中的列数可以不同于训练输出数据(或responses)中的列数。我们将samples中的列数称为特征数,将responses中的列数称为类别数。简单来说,我们将使用训练数据集来学习特征与类别之间的关系。
在处理完训练数据集之后,我们需要使用以下代码创建一个ANN_MLP对象:
Ptr<ANN_MLP> ann = ANN_MLP::create();
我们跳过了所有自定义设置,使用了默认参数集。如果您需要使用完全自定义的ANN_MLP对象,您需要在ANN_MLP类中设置激活函数、终止标准以及各种其他参数。要了解更多信息,请确保参考 OpenCV 文档和关于人工神经网络的网络资源。
在人工神经网络(ANN)算法中设置正确的层大小需要经验和依赖具体的使用场景,但也可以通过几次试错来设置。以下是您如何设置 ANN 算法中每一层的数量和大小,特别是ANN_MLP类:
Mat_<int> layers(4,1);
layers(0) = featureCount; // input layer
layers(1) = classCount * 4; // hidden layer 1
layers(2) = classCount * 2; // hidden layer 2
layers(3) = classCount; // output layer
ann->setLayerSizes(layers);
在前面的代码中,layers对象中的行数表示我们希望在 ANN 中拥有的层数。layers对象中的第一个元素应包含数据集中的特征数量,而layers对象中的最后一个元素应包含类的数量。回想一下,特征的数量等于samples的列数,类的数量等于responses的列数。layers对象中的其余元素包含隐藏层的尺寸。
通过使用train方法来训练 ANN 模型,如下面的示例所示:
if(!ann->train(data))
{
cout << "training failed" << endl;
return -1;
}
训练完成后,我们可以像之前看到的那样使用save和load方法,以保存模型供以后使用,或从保存的文件中重新加载它。
使用ANN_MLP类与SVM类类似。以下是一个示例:
Mat_<float> input(1, featureCount);
Mat_<float> output(1, classCount);
// fill the input Mat
ann->predict(input, output);
为每个问题选择合适的机器学习算法需要经验和知识,了解项目将如何被使用。支持向量机(SVM)相当简单,适用于我们需要对数据进行分类以及在相似数据组的分割中,而人工神经网络(ANN)可以很容易地用来近似输入和输出向量集之间的函数(回归)。确保尝试不同的机器学习问题,以更好地理解何时以及在哪里使用特定的算法。
级联分类算法
级联分类是另一种机器学习算法,可以用来从许多(数百甚至数千)正负图像样本中训练模型。正如我们之前解释的,正图像指的是我们感兴趣的对象(如人脸、汽车或交通信号)中的图像,我们希望我们的模型学习并随后进行分类或检测。另一方面,负图像对应于任何不包含我们感兴趣对象的任意图像。使用此算法训练的模型被称为级联分类器。
级联分类器最重要的方面,正如其名称所暗示的,是其学习检测对象的级联性质,使用提取的特征。在级联分类器中最广泛使用的特征,以及相应的级联分类器类型,是 Haar 和局部二进制模式(LBP)。在本节中,我们将学习如何使用现有的 OpenCV Haar 和 LBP 级联分类器在实时中检测面部、眼睛等,然后学习如何训练我们自己的级联分类器以检测任何其他对象。
使用级联分类器进行目标检测
要在 OpenCV 中使用先前训练的级联分类器,你可以使用CascadeClassifier类及其提供用于从文件加载分类器或执行图像中的尺度不变检测的简单方法。OpenCV 包含许多用于实时检测面部、眼睛等对象的预训练分类器。如果我们浏览到 OpenCV 的安装(或构建)文件夹,它通常包含一个名为etc的文件夹,其中包含以下子文件夹:
-
haarcascades -
lbpcascades
haarcascades包含预训练的 Haar 级联分类器。另一方面,lbpcascades包含预训练的 LBP 级联分类器。与 LBP 级联分类器相比,Haar 级联分类器通常速度较慢,但在大多数情况下也提供了更好的准确性。要了解 Haar 和 LBP 级联分类器的详细信息,请务必参考 OpenCV 文档以及关于 Haar 小波、Haar-like 特征和局部二进制模式的相关在线资源。正如我们将在下一节中学习的,LBP 级联分类器的训练速度也比 Haar 分类器快得多;只要有足够的训练数据样本,你就可以达到这两种分类器类型相似的准确性。
在我们刚才提到的每个分类器文件夹下,你可以找到许多预训练的级联分类器。你可以使用CascadeClassifier类的load方法加载这些分类器,并准备它们进行实时目标检测,如下面的示例所示:
CascadeClassifier detector;
if(!detector.load("classifier.xml"))
{
cout << "Can't load the provided cascade classifier." << endl;
return -1;
}
在成功加载级联分类器之后,你可以使用detectMultiScale方法在图像中检测对象,并返回一个包含检测到的对象边界框的向量,如下面的示例所示:
vector<Rect> objects;
detector.detectMultiScale(frame,
objects);
for(int i=0; i< objects.size(); i++)
{
rectangle(frame,
objects [i],
color,
thickness);
}
color和thickness之前已定义,用于影响为每个检测到的对象绘制的矩形,如下所示:
Scalar color = Scalar(0,0,255);
int thickness = 2;
尝试加载haarcascade_frontalface_default.xml分类器,它位于haarcascades文件夹中,这是 OpenCV 预安装的,以测试前面的示例。尝试使用包含面部图像的图像运行前面的代码,结果将类似于以下内容:

级联分类器的准确度,就像任何其他机器学习模型一样,完全取决于训练样本数据集的质量和数量。正如之前提到的,级联分类器在实时对象检测中非常受欢迎。为了能够在任何计算机上查看级联分类器的性能,你可以使用以下代码:
double t = (double)getTickCount();
detector.detectMultiScale(image,
objects);
t = ((double)getTickCount() - t)/getTickFrequency();
t *= 1000; // convert to ms
上述代码的最后一行用于将时间测量的单位从秒转换为毫秒。你可以使用以下代码在输出图像上打印结果,例如在左下角:
Scalar green = Scalar(0,255,0);
int thickness = 2;
double scale = 0.75;
putText(frame,
"Took " + to_string(int(t)) + "ms to detect",
Point(0, frame.rows-1),
FONT_HERSHEY_SIMPLEX,
scale,
green,
thickness);
这将生成一个包含类似以下示例中的文本的输出图像:

尝试使用 OpenCV 附带的不同预训练级联分类器,并检查它们之间的性能。一个非常明显的观察结果是 LBP 级联分类器的检测速度显著更快。
在前面的示例中,我们只使用了CascadeClassifier类中detectMultiScale方法所需的默认参数集,然而,为了修改其行为,以及在某些情况下显著提高其性能,你将需要调整更多一些参数,如下面的示例所示:
double scaleFactor = 1.1;
int minNeighbors = 3;
int flags = 0; // not used
Size minSize(50,50);
Size maxSize(500, 500);
vector<Rect> objects;
detector.detectMultiScale(image,
objects,
scaleFactor,
minNeighbors,
flags,
minSize,
maxSize);
scaleFactor参数用于指定每次检测后图像的缩放。这意味着内部对图像进行缩放并执行检测。这实际上就是多尺度检测算法的工作方式。在对象中搜索图像,其大小通过给定的scaleFactor减小,然后再次进行搜索。重复进行尺寸减小,直到图像大小小于分类器大小。然后返回所有尺度中所有检测的结果。scaleFactor参数必须始终包含一个大于 1.0 的值(不等于且不低于)。为了在多尺度检测中获得更高的灵敏度,你可以设置一个值,如 1.01 或 1.05,这将导致检测时间更长,反之亦然。minNeighbors参数指的是将彼此靠近或相似的检测分组以保留检测到的对象。
在 OpenCV 的较新版本中,flags参数被简单地忽略。至于minSize和maxSize参数,它们用于指定图像中对象可能的最小和最大尺寸。这可以显著提高detectMultiScale函数的准确性和速度,因为不在给定尺寸范围内的检测到的对象将被简单地忽略,并且仅重新缩放直到达到minSize。
detectMultiScale还有两个其他变体,我们为了简化示例而跳过了,但你应该亲自检查它们,以了解更多关于级联分类器和多尺度检测的信息。确保还要在网上搜索其他计算机视觉开发者提供的预训练分类器,并尝试将它们用于你的应用程序中。
训练级联分类器
如我们之前提到的,如果你有足够的正负样本图像,你也可以创建自己的级联分类器来检测任何其他对象。使用 OpenCV 训练分类器涉及多个步骤和多个特殊的 OpenCV 应用程序,我们将在本节中介绍这些内容。
创建样本
首先,你需要一个名为opencv_createsamples的工具来准备正图像样本集。另一方面,负图像样本在训练过程中自动从包含任意图像的提供的文件夹中提取,这些图像不包含感兴趣的对象。opencv_createsamples应用程序可以在 OpenCV 安装的bin文件夹中找到。它可以用来创建正样本数据集,要么使用感兴趣对象的单个图像并对其应用扭曲和变换,要么使用之前裁剪或注释的感兴趣对象的图像。让我们首先了解前一种情况。
假设你有一个交通标志(或任何其他对象)的以下图像,并且你想使用它创建一个正样本数据集:

你还应该有一个包含负样本源的文件夹。如我们之前提到的,你需要一个包含任意图像的文件夹,这些图像不包含感兴趣的对象。让我们假设我们有一些类似于以下图像,我们将使用这些图像来创建负样本:

注意,负图像的大小和宽高比,或者使用正确的术语,背景图像的大小,并不重要。然而,它们必须至少与最小可检测对象(分类器大小)一样大,并且它们绝不能包含感兴趣对象的图像。
要训练一个合适的级联分类器,有时你需要数百甚至数千个以不同方式扭曲的样本图像,这并不容易创建。实际上,收集训练数据是创建级联分类器中最耗时的步骤之一。opencv_createsamples应用程序可以通过对创建分类器的对象的前一个图像应用扭曲和使用背景图像来生成正样本数据集,从而帮助解决这个问题。以下是如何使用它的一个示例:
opencv_createsamples -vec samples.vec -img sign.png -bg bg.txt
-num 250 -bgcolor 0 -bgthresh 10 -maxidev 50
-maxxangle 0.7 -maxyangle 0.7 -maxzangle 0.5
-w 32 -h 32
以下是前面命令中使用的参数描述:
-
vec用于指定要创建的正样本文件。在这种情况下,它是samples.vec文件。 -
img用于指定用于生成样本的输入图像。在我们的例子中,它是sign.png。 -
bg用于指定背景的描述文件。背景的描述文件是一个简单的文本文件,其中包含所有背景图像的路径(背景描述文件中的每一行包含一个背景图像的路径)。我们创建了一个名为bg.txt的文件,并将其提供给bg参数。 -
num参数确定您想使用给定的输入图像和背景生成的正样本数量;在我们的例子中是 250。当然,您可以使用更高的或更低的数字,这取决于您所需的准确性和训练时间。 -
bgcolor可以用来用灰度强度定义背景颜色。正如您可以在我们的输入图像(交通标志图像)中看到的那样,背景颜色是黑色,因此此参数的值为零。 -
bgthresh参数指定了接受的bgcolor参数的阈值。这在处理某些图像格式中常见的压缩伪影的情况下特别有用,可能会造成相同颜色略有不同的像素值。我们为这个参数使用了 10 的值,以允许对背景像素的一定程度的容忍度。 -
maxidev可以用来设置在生成样本时前景像素值的最大强度偏差。值为 50 表示前景像素的强度可以在其原始值 +/- 50 之间变化。 -
maxxangle,maxyangle, 和maxzangle分别对应在创建新样本时在 x、y 和 z 方向上允许的最大旋转角度。这些值以弧度为单位,我们提供了 0.7、0.7 和 0.5。 -
w和h参数定义了样本的宽度和高度。我们为它们都使用了 32,因为我们想要训练分类器的对象适合正方形形状。这些相同的值将在稍后训练分类器时使用。此外,请注意,这将是您训练的分类器中可以检测到的最小尺寸。
除了前面列表中的参数外,opencv_createsamples 应用程序还接受一个 show 参数,可以用来显示创建的样本,一个 inv 参数可以用来反转样本的颜色,以及一个 randinv 参数可以用来设置或取消样本中像素的随机反转。
执行前面的命令将通过旋转和强度变化对前景像素进行操作,从而生成指定数量的样本。以下是一些生成的样本:

现在我们有了由 opencv_createsamples 生成的正样本向量文件,以及包含背景图像和背景描述文件(前一个示例中的 bg.txt)的文件夹,我们可以开始训练我们的级联分类器了。但在那之前,让我们也了解一下创建正样本向量的第二种方法,即从包含我们感兴趣对象的各个标注图像中提取它们。
第二种方法涉及使用另一个官方 OpenCV 工具,该工具用于在图像中注释正样本。这个工具被称为 opencv_annotation,它可以方便地标记包含我们的正样本(换句话说,即我们打算为它们训练级联分类器的对象)的多个图像中的区域。opencv_annotation 工具在手动注释对象后生成一个注释文本文件,可以用 opencv_createsamples 工具生成适合与 OpenCV 级联训练工具一起使用的正样本向量,我们将在下一节中学习该工具。
假设我们有一个包含类似以下图片的文件夹:

所有这些图像都位于一个文件夹中,并且它们都包含我们正在寻找的交通标志(感兴趣的对象)的一个或多个样本。我们可以使用以下命令启动 opencv_annotation 工具并手动注释样本:
opencv_annotation --images=imgpath --annotations=anno.txt
在前面的命令中,imgpath 必须替换为包含图片的文件夹路径(最好是绝对路径,并使用正斜杠)。anno.txt 或任何其他提供的文件名将被填充注释结果,这些结果可以用 opencv_createsamples 生成正样本向量。执行前面的命令将启动 opencv_annotation 工具并输出以下文本,描述如何使用该工具及其快捷键:
* mark rectangles with the left mouse button,
* press 'c' to accept a selection,
* press 'd' to delete the latest selection,
* press 'n' to proceed with next image,
* press 'esc' to stop.
在前面的输出之后,将显示一个类似于以下窗口:

你可以使用鼠标左键突出显示一个对象,这将导致绘制一个红色矩形。按下 C 键将完成注释,它将变成红色。继续对同一图像中的其余样本(如果有)进行此过程,然后按 N 键转到下一图像。在所有图像都注释完毕后,你可以通过按 Esc 键退出应用程序。
除了 -images 和 -annotations 参数之外,opencv_annotation 工具还包括一个可选参数,称为 -maxWindowHeight,可以用来调整大于指定尺寸的图片大小。在这种情况下,调整因子可以通过另一个名为 -resizeFactor 的可选参数来指定。
由 opencv_annotation 工具创建的注释文件将看起来像以下这样:
signs01.jpg 2 145 439 105 125 1469 335 185 180
signs02.jpg 1 862 468 906 818
signs03.jpg 1 1450 680 530 626
signs04.jpg 1 426 326 302 298
signs05.jpg 0
signs06.jpg 1 1074 401 127 147
signs07.jpg 1 1190 540 182 194
signs08.jpg 1 794 460 470 488
注释文件中的每一行都包含一个图像的路径,后面跟着该图像中感兴趣对象的数量,然后是这些对象的边界框的 x、y、宽度和高度值。你可以使用以下命令使用这个注释文本文件生成样本向量:
opencv_createsamples -info anno.txt -vec samples.vec -w 32 -h 32
注意这次我们使用了带有 -info 参数的 opencv_createsamples 工具,而当我们使用这个工具从图像和任意背景中生成样本时,这个参数是不存在的。我们现在已经准备好训练一个能够检测我们创建的样本的交通标志的分类器。
创建分类器
我们将要学习的最后一个工具叫做 opencv_traincascade,正如你可以猜到的,它用于训练级联分类器。如果你有足够的样本和背景图像,并且如果你已经按照前述章节描述的那样处理了样本向量,那么你唯一需要做的就是运行 opencv_traincascade 工具并等待训练完成。让我们看看一个示例训练命令,然后详细说明参数:
opencv_traincascade -data classifier -vec samples.vec
-bg bg.txt -numPos 200 -numNeg 200 -w 32 -h 32
这是最简单的开始训练过程的方式,并且只使用必须的参数。在这个命令中使用的所有参数都是自解释的,除了 -data 参数,它必须是一个现有的文件夹,该文件夹将用于在训练过程中创建所需的文件,并且最终训练好的分类器(称为 cascade.xml)将在这个文件夹中创建。
numPos 不能包含高于你的 samples.vec 文件中正样本数量的数字,然而,numNeg 可以包含基本上任何数字,因为训练过程将简单地尝试通过提取提供的背景图像的部分来创建随机负样本。
opencv_traincascade 工具将在设置为 -data 参数的文件夹中创建多个 XML 文件,这个文件夹在训练过程完成之前不得修改。以下是每个文件的简要描述:
-
params.xml文件将包含用于训练分类器的参数。 -
stage#.xml文件是在每个训练阶段完成后创建的检查点。如果训练过程因意外原因而终止,可以使用它们稍后继续训练。 -
cascade.xml文件是训练好的分类器,并且是训练工具最后创建的文件。你可以复制这个文件,将其重命名为方便的名字(例如trsign_classifier.xml或类似的名字),然后使用我们之前章节中学到的CascadeClassifier类,来执行多尺度目标检测。
opencv_traincascade 是一个极其可定制和灵活的工具,你可以轻松修改其许多可选参数,以确保训练好的分类器符合你的需求。以下是其中一些最常用参数的描述:
-
可以使用
numStages来设置用于训练级联分类器的阶段数。默认情况下,numStages等于 20,但你可以减小这个值以缩短训练时间,同时牺牲准确性,或者相反。 -
precalcValBufSize和precalcIdxBufSize参数可以用来增加或减少在级联分类器训练过程中用于各种计算的记忆量。你可以修改这些参数以确保训练过程以更高的效率进行。 -
featureType是训练工具最重要的参数之一,它可以用来设置训练分类器的类型为HAAR(如果忽略则为默认值)或LBP。如前所述,LBP 分类器比 Haar 分类器训练得更快,它们的检测速度也显著更快,但它们缺乏 Haar 级联分类器的准确性。有了适当数量的训练样本,你可能能够训练出一个在准确性方面可以与 Haar 分类器相媲美的 LBP 分类器。
要获取参数及其描述的完整列表,请确保查阅 OpenCV 在线文档。
使用深度学习模型
近年来,深度学习领域取得了巨大的进步,或者更准确地说,是 深度神经网络(DNN),越来越多的库和框架被引入,它们使用深度学习算法和模型,特别是用于计算机视觉目的,如实时目标检测。你可以使用 OpenCV 库的最新版本来读取最流行的 DNN 框架(如 Caffe、Torch 和 TensorFlow)的预训练模型,并将它们用于目标检测和预测任务。
OpenCV 中的 DNN 相关算法和类都位于 dnn 命名空间下,因此,为了能够使用它们,你需要在你的代码中确保包含以下内容:
using namespace cv;
using namespace dnn;
我们将逐步介绍在 OpenCV 中加载和使用 TensorFlow 库的预训练模型进行实时目标检测。这个例子演示了如何使用由第三方库(本例中为 TensorFlow)训练的深度神经网络模型的基礎。所以,让我们开始吧:
- 下载一个可用于目标检测的预训练 TensorFlow 模型。对于我们的示例,请确保从搜索中下载
ssd_mobilenet_v1_coco的最新版本,从官方 TensorFlow 模型在线搜索结果中下载。
注意,这个链接未来可能会发生变化(可能不会很快,但提一下是值得的),所以,如果发生这种情况,你需要简单地在网上搜索 TensorFlow 模型动物园,在 TensorFlow 的术语中,这是一个包含预训练目标检测模型的动物园。
- 在下载
ssd_mobilenet_v1_coco模型包文件后,您需要将其解压到您选择的文件夹中。您将得到一个名为frozen_inference_graph.pb的文件,以及一些其他文件。在 OpenCV 中进行实时对象检测之前,您需要从该模型文件中提取一个文本图文件。此提取可以通过使用名为tf_text_graph_ssd.py的脚本完成,这是一个默认包含在 OpenCV 安装中的 Python 脚本,可以在以下路径找到:
opencv-source-files/samples/dnntf_text_graph_ssd.py
您可以使用以下命令执行此脚本:
tf_text_graph_ssd.py --input frozen_inference_graph.pb
--output frozen_inference_graph.pbtxt
注意,此脚本的正确执行完全取决于您是否在计算机上安装了正确的 TensorFlow。
- 您应该有
frozen_inference_graph.pb和frozen_inference_graph.pbtxt文件,这样我们就可以在 OpenCV 中使用它们来检测对象。因此,我们需要创建一个 DNNNetwork对象并将模型文件读入其中,如下例所示:
Net network = readNetFromTensorflow(
"frozen_inference_graph.pb",
"frozen_inference_graph.pbtxt");
if(network.empty())
{
cout << "Can't load TensorFlow model." << endl;
return -1;
}
- 在确保模型正确加载后,您可以使用以下代码在从摄像头读取的帧、图像或视频文件上执行实时对象检测:
const int inWidth = 300;
const int inHeight = 300;
const float meanVal = 127.5; // 255 divided by 2
const float inScaleFactor = 1.0f / meanVal;
bool swapRB = true;
bool crop = false;
Mat inputBlob = blobFromImage(frame,
inScaleFactor,
Size(inWidth, inHeight),
Scalar(meanVal, meanVal, meanVal),
swapRB,
crop);
network.setInput(inputBlob);
Mat result = network.forward();
值得注意的是,传递给blobFromImage函数的值完全取决于模型,如果您使用的是本例中的相同模型,则应使用完全相同的值。blobFromImage函数将创建一个 BLOB,适用于与深度神经网络预测函数一起使用,或者更准确地说,是与forward函数一起使用。
- 在检测完成后,您可以使用以下代码提取检测到的对象及其边界矩形,所有这些都放入一个单独的
Mat对象中:
Mat detections(result.size[2],
result.size[3],
CV_32F,
result.ptr<float>());
- 可以遍历
detections对象以提取具有可接受检测置信水平的单个检测,并在输入图像上绘制结果。以下是一个示例:
const float confidenceThreshold = 0.5f;
for(int i=0; i<detections.rows; i++)
{
float confidence = detections.at<float>(i, 2);
if(confidence > confidenceThreshold)
{
// passed the confidence threshold
}
}
置信度,即detections对象每行的第三个元素,可以调整以获得更准确的结果,但0.5对于大多数情况或至少作为开始来说应该是一个合理的值。
- 在检测通过置信度标准后,我们可以提取检测到的对象 ID 和边界矩形,并在输入图像上绘制,如下所示:
int objectClass = (int)(detections.at<float>(i, 1)) - 1;
int left = static_cast<int>(
detections.at<float>(i, 3) * frame.cols);
int top = static_cast<int>(
detections.at<float>(i, 4) * frame.rows);
int right = static_cast<int>(
detections.at<float>(i, 5) * frame.cols);
int bottom = static_cast<int>(
detections.at<float>(i, 6) * frame.rows);
rectangle(frame, Point(left, top),
Point(right, bottom), Scalar(0, 255, 0));
String label = "ID = " + to_string(objectClass);
if(objectClass < labels.size())
label = labels[objectClass];
int baseLine = 0;
Size labelSize = getTextSize(label, FONT_HERSHEY_SIMPLEX,
0.5, 2, &baseLine);
top = max(top, labelSize.height);
rectangle(frame,
Point(left, top - labelSize.height),
Point(left + labelSize.width, top + baseLine),
white,
CV_FILLED);
putText(frame, label, Point(left, top),
FONT_HERSHEY_SIMPLEX, 0.5, red);
在前面的示例中,objectClass指的是检测到的对象的 ID,它是检测对象每行的第二个元素。另一方面,第三、第四、第五和第六个元素对应于每个检测对象边界框的左、上、右和下值。其余的代码只是绘制结果,这留下了labels对象。labels是一个string值的vector,可以用来检索每个对象 ID 的可读文本。这些标签,类似于我们在本例中使用的其余参数,是模型相关的。例如,在我们的示例案例中,标签可以在以下位置找到:
github.com/tensorflow/models/blob/master/research/object_detection/data/mscoco_label_map.pbtxt
我们已经将其转换为以下标签向量,用于前面的示例:
const vector<string> labels = { "person", "bicycle" ...};
以下图像展示了在 OpenCV 中使用预训练的 TensorFlow 模型进行目标检测的结果:

使用深度学习已被证明非常高效,尤其是在我们需要实时训练和检测多个对象时。确保参考 TensorFlow 和 OpenCV 文档以获取有关如何使用预训练模型或如何为没有已训练模型的物体训练和重新训练 DNN 模型的更多信息。
摘要
我们通过学习 SVM 模型及其如何训练以对相似数据组进行分类来开始这本书的最后一章。我们学习了 SVM 如何与 HOG 描述符结合使用,以了解一个或多个特定对象,然后在新的图像中检测和分类它们。在了解 SVM 模型之后,我们转向使用 ANN 模型,在输入和输出训练样本的多个列的情况下,这些模型提供了更多的功能。本章还包括了如何训练和使用 Haar 和 LBP 级联分类器的完整指南。我们现在熟悉了使用官方 OpenCV 工具从头开始准备训练数据集,然后使用该数据集训练级联分类器的方法。最后,我们通过学习在 OpenCV 中使用预训练的深度学习目标检测模型来结束这一章和这本书。
问题
-
在
SVM类中,train和trainAuto方法之间的区别是什么? -
展示线性与直方图交集之间的区别。
-
如何计算 HOG 窗口大小为 128 x 96 像素的 HOG 描述符大小(其他 HOG 参数保持不变)?
-
如何更新现有的已训练
ANN_MLP,而不是从头开始训练? -
使用
opencv_createsamples创建来自单个公司标志图像的正样本向量所需的命令是什么?假设我们想要有 1,000 个样本,宽度为 24,高度为 32,并且使用默认的旋转和反转参数。 -
训练用于之前问题中的公司标志的 LBP 级联分类器所需的命令是什么?
-
在
opencv_traincascade中训练级联分类器的默认阶段数是多少?我们如何更改它?增加和减少阶段数远超过其默认值有什么缺点?
第九章:评估
第一章,计算机视觉简介
- 除了本章提到的行业外,还有哪些行业可以从计算机视觉中显著受益?
体育产业可以利用计算机视觉对比赛进行更深入的分析。
食品行业可以利用计算机视觉对产品质量进行控制。
- 一个用于安全目的的计算机视觉应用示例是什么?(考虑一个你未曾遇到的应用想法。)
一个非常随机的例子是使用面部识别进行火车、航班等票务检查的应用程序。
- 一个用于提高生产力的计算机视觉应用示例是什么?(再次,考虑一个你未曾遇到的应用想法,即使你可能怀疑它存在。)
一个使用其摄像头帮助视障人士的应用程序。
- 存储一个 1920 x 1080 像素,四通道,32 位深度的图像需要多少兆字节?
大约 31.64 兆字节:

- 超高清图像,也称为 4K 或 8K 图像,现在相当常见,但一个超高清图像包含多少兆像素?
这主要取决于长宽比。对于常见的 16:9 长宽比,以下是答案:
-
4K:8.3 兆像素
-
8K:33.2 兆像素
查看此链接获取更多信息:
en.wikipedia.org/wiki/Ultra-high-definition_television
- 除了本章提到的颜色空间外,还有哪些常用的颜色空间?
YUV 和 LUV 颜色空间
en.wikipedia.org/wiki/List_of_color_spaces_and_their_uses
- 将 OpenCV 库与 MATLAB 中的计算机视觉工具进行比较。比较它们各自的优缺点。
通常,当计算机视觉应用需要模拟和原型设计时,MATLAB 是最好的选择,而当需要现实场景和需要速度和完全控制最终产品时,OpenCV 则更为直接。
第二章,OpenCV 入门
- 列出三个额外的 OpenCV 模块及其用途。
可以使用xfeatures2d模块访问额外的特征检测算法。
可以使用face模块将面部分析算法包含到 OpenCV 中。
可以使用text模块将 OCR 功能(Tesseract OCR)添加到 OpenCV 中。
- 打开
BUILD_opencv_world标志构建 OpenCV 3 会有什么影响?
使用BUILD_opencv_world标志构建 OpenCV 3 会将所有二进制库文件,如core、imcodecs和highgui,合并成一个单世界库。
- 使用本章中描述的 ROI 像素访问方法,我们如何构建一个可以访问中间像素及其所有相邻像素(另一个图像中的中间九个像素)的
Mat类?
这里有一个示例代码可以实现这个目标:
Mat image = imread("Test.png");
if(image.empty())
{
cout << "image empty";
return 0;
}
int centerRow = (image.rows / 2) - 1;
int centerCol = (image.cols / 2) - 1;
Mat roi(image, Rect(centerCol - 1, centerRow - 1, 3, 3));
roi = Scalar(0,0,255); // alter the pixels (make them red)
imshow("image", image);
waitKey();
- 除了本章提到的像素访问方法之外,
Mat类还有另一种像素访问方法。
Mat::row can be used to access a single row
Mat::column can be used to access a single column
- 编写一个程序,只使用
Mat方法和for循环,创建三个单独的颜色图像,每个图像只包含从磁盘读取的 RGB 图像的一个通道。
Mat image = imread("Test.png");
if(image.empty())
{
cout << "image empty";
return 0;
}
Mat red(image.rows, image.cols, CV_8UC3, Scalar::all(0));
Mat green(image.rows, image.cols, CV_8UC3, Scalar::all(0));
Mat blue(image.rows, image.cols, CV_8UC3, Scalar::all(0));
for(int i=0; i<image.rows; i++)
{
for(int j=0; j<image.cols; j++)
{
blue.at<Vec3b>(i, j)[0] = image.at<Vec3b>(i, j)[0];
green.at<Vec3b>(i, j)[1] = image.at<Vec3b>(i, j)[1];
red.at<Vec3b>(i, j)[2] = image.at<Vec3b>(i, j)[2];
}
}
imshow("Blue", blue);
imshow("Green", green);
imshow("Red", red);
waitKey();



- 使用类似 STL 的迭代器,计算灰度图像的平均像素值。
Mat image = imread("Test.png", IMREAD_GRAYSCALE);
if(image.empty())
{
cout << "image empty";
return 0;
}
int sum = 0;
MatIterator_<uchar> it_begin = image.begin<uchar>();
MatIterator_<uchar> it_end = image.end<uchar>();
for( ; it_begin != it_end; it_begin++)
{
sum += (*it_begin);
}
double average = sum / (image.cols * image.rows);
cout << "Pixel count is " << image.cols * image.rows << endl;
cout << "Average pixel value is " << average << endl;
- 编写一个程序,使用
VideoCapture、waitKey和imwrite,在按下S键时显示您的网络摄像头并保存可见图像。如果按下空格键,程序将停止网络摄像头并退出。
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
imshow("Camera", frame);
// stop camera if space is pressed
char key = waitKey(10);
if(key == ' ')
break;
if(key == 's')
imwrite("d:/snapshot.png", frame);
}
cam.release();
第三章,数组和矩阵运算
- 哪些逐元素数学运算和位运算会产生完全相同的结果?
bitwise_xor和absdiff函数会产生相同的结果。
- OpenCV 中的
gemm函数的目的是什么?使用gemm函数,AxB的等价是什么?
gemm函数是 OpenCV 中的通用乘法函数。以下是与两个矩阵简单乘法等价的gemm函数调用:
gemm(image1, image2, 1.0, noArray(), 1.0, result);
- 使用
borderInterpolate函数计算点(-10, 50)处不存在像素的值,边界类型为BORDER_REPLICATE。这种计算所需的函数调用是什么?
Vec3b val = image.at<Vec3b>(borderInterpolate(50,
image.rows,
cv::BORDER_REFLECT_101),
borderInterpolate(-10,
image.cols,
cv::BORDER_WRAP));
- 在本章的“单位矩阵”部分创建相同的单位矩阵,但使用
setIdentity函数而不是Mat::eye函数。
Mat m(10, 10, CV_32F);
setIdentity(m, Scalar(0.25));
- 编写一个程序,使用
LUT函数(查找表变换)执行与bitwise_not(颜色反转)相同的任务,当在灰度和颜色(RGB)图像上执行时。
Mat image = imread("Test.png");
Mat lut(1, 256, CV_8UC1);
for(int i=0; i<256; i++)
{
lut.at<uchar>(0, i) = 255 - i;
}
Mat result;
LUT(image, lut, result);
- 除了归一化矩阵的值之外,
normalize函数还可以用于调整图像的亮度或暗度。使用normalize函数变暗和变亮灰度图像所需的函数调用是什么。
normalize(image, result, 200, 255, CV_MINMAX); // brighten
normalize(image, result, 0, 50, CV_MINMAX); // darken
- 使用
merge和split函数从图像中移除蓝色通道(第一个通道)。
vector<Mat> channels;
split(image, channels);
channels[0] = Scalar::all(0);
merge(channels, result);
第四章,绘制、过滤和变换
- 编写一个程序,在整张图像上绘制一个十字,厚度为 3 像素,颜色为红色。
line(image,
Point(0,0),
Point(image.cols-1,image.rows-1),
Scalar(0,0,255),
3);
line(image,
Point(0,image.rows-1),
Point(image.cols-1,0),
Scalar(0,0,255),
3);
- 创建一个带有滑块的窗口来改变
medianBlur函数的ksize值。kszise值的范围应在 3 到 99 之间。
Mat image;
int ksize = 3;
string window = "Image";
string trackbar = "ksize";
void onChange(int ksize, void*)
{
if(ksize %2 == 1)
{
medianBlur(image,
image,
ksize);
imshow(window, image);
}
}
int main()
{
image = imread("Test.png");
namedWindow(window);
createTrackbar(trackbar, window, &ksize, 99, onChange);
setTrackbarMin(trackbar, window, 3);
setTrackbarMax(trackbar, window, 99);
onChange(3, NULL);
waitKey();
}
- 对图像执行梯度形态学运算,考虑结构元素的核大小为 7,矩形形态学形状。
int ksize = 7;
morphologyEx(image,
result,
MORPH_GRADIENT,
getStructuringElement(MORPH_RECT,
Size(ksize,ksize)));
这里有一个例子:

- 使用
cvtColor将彩色图像转换为灰度图像,并确保使用threshold函数过滤掉最暗的 100 种灰色阴影。确保过滤后的像素在结果图像中设置为白色,其余像素设置为黑色。
Mat imageGray;
cvtColor(image,
imageGray,
COLOR_BGR2GRAY);
threshold(imageGray,
result,
100,
255,
THRESH_BINARY_INV);
这里有一个例子:

- 使用
remap函数将图像大小调整为原始宽度和高度的一半,从而保留原始图像的纵横比。使用默认的边界类型进行外推。
Mat mapX(image.size(), CV_32FC1);
Mat mapY(image.size(), CV_32FC1);
for(int i=0; i<image.rows; i++)
for(int j=0; j<image.cols; j++)
{
mapX.at<float>(i,j) = j*2.0;
mapY.at<float>(i,j) = i*2.0;
}
InterpolationFlags interpolation = INTER_LANCZOS4;
BorderTypes borderMode = BORDER_DEFAULT;
remap(image,
result,
mapX,
mapY,
interpolation,
borderMode);
这里有一个例子:

- a) 使用色图将图像转换为灰度。b) 将图像转换为灰度并同时反转其像素怎么样?
a)
Mat userColor(256, 1, CV_8UC3);
for(int i=0; i<=255; i++)
userColor.at<Vec3b>(i,0) = Vec3b(i, i, i);
applyColorMap(image,
result,
userColor);
b)
Mat userColor(256, 1, CV_8UC3);
for(int i=0; i<=255; i++)
userColor.at<Vec3b>(i,0) = Vec3b(255-i, 255-i, 255-i);
applyColorMap(image,
result,
userColor);
- 你是否了解透视变换函数?哪个 OpenCV 函数在一个函数中涵盖了所有类似的变换?
findHomography函数。
第五章,反向投影和直方图
- 计算三通道图像中第二个通道的直方图。使用可选的箱大小和 0 到 100 的范围作为第二个通道的可能值。
int bins = 25; // optional
int nimages = 1;
int channels[] = {1};
Mat mask;
int dims = 1;
int histSize[] = { bins };
float range[] = {0, 100};
const float* ranges[] = { range };
Mat histogram;
calcHist(&image,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges);
- 创建一个直方图,可以与
calcBackProject函数一起使用,从灰度图像中提取最暗的像素。考虑最暗的 25%可能的像素值作为我们想要提取的灰度强度。
int bins = 4;
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
int channels[] = {0};
Mat histogram(bins, 1, CV_32FC1, Scalar(0.0));
histogram.at<float>(0, 0) = 255.0;
calcBackProject(&imageGray,
1,
channels,
histogram,
backProj,
ranges);
- 在上一个问题中,如果我们需要排除最暗和最亮的 25%,而不是在掩码中提取,会怎样?
int bins = 4;
float rangeGS[] = {0, 256};
const float* ranges[] = { rangeGS };
int channels[] = {0};
Mat histogram(bins, 1, CV_32FC1, Scalar(0.0));
histogram.at<float>(1, 0) = 255.0;
histogram.at<float>(2, 0) = 255.0;
calcBackProject(&imageGray,
1,
channels,
histogram,
backProj,
ranges);
- 红色的色调值是多少?需要将它调整多少才能得到蓝色?
0 和 360 是红色的色调值。调整 240 度可以得到蓝色。
- 创建一个色调直方图,可以用来从图像中提取红色像素。考虑将 50 作为被认为是红色的像素的偏移量。最后,可视化创建的色调直方图。
const int bins = 360;
int hueOffset = 35;
Mat histogram(bins, 1, CV_32FC1);
for(int i=0; i<bins; i++)
{
histogram.at<float>(i, 0) =
(i < hueOffset) || (i > bins - hueOffset) ? 255.0 : 0.0;
}
double maxVal = 255.0;
int gW = 800, gH = 100;
Mat theGraph(gH, gW, CV_8UC3, Scalar::all(0));
Mat colors(1, bins, CV_8UC3);
for(int i=0; i<bins; i++)
{
colors.at<Vec3b>(i) =
Vec3b(saturate_cast<uchar>(
(i+1)*180.0/bins), 255, 255);
}
cvtColor(colors, colors, COLOR_HSV2BGR);
Point p1(0,0), p2(0,theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
Scalar(colors.at<Vec3b>(i)),
CV_FILLED);
p1.x = p2.x;
}

- 计算直方图的积分。
float integral = 0.0;
for(int i=0; i<bins; i++)
{
integral += histogram.at<float>(i, 0);
}
- 对彩色图像执行直方图均衡化。请注意,
equalizeHist函数仅支持单通道 8 位灰度图像的直方图均衡化。
Mat channels[3], equalized[3];
split(image, channels);
equalizeHist(channels[0], equalized[0]);
equalizeHist(channels[1], equalized[1]);
equalizeHist(channels[2], equalized[2]);
Mat output;
cv::merge(equalized, 3, output);
第六章,视频分析 – 运动检测和跟踪
- 本章中所有涉及摄像头的示例,在出现单个失败或损坏的帧导致检测到空帧时返回。需要什么样的修改才能在停止过程之前允许预定义的尝试次数?
const int RETRY_COUNT = 10;
int retries = RETRY_COUNT;
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
{
if(--retries < 0)
break;
else
continue;
}
else
{
retries = RETRY_COUNT;
}
// rest of the process
}
- 我们如何调用
meanShift函数以 10 次迭代和 0.5 的 epsilon 值执行均值漂移算法?
TermCriteria criteria(TermCriteria::MAX_ITER
+ TermCriteria::EPS,
10,
0.5);
meanShift(backProject,
srchWnd,
criteria);
- 如何可视化跟踪对象的色调直方图?假设使用
CamShift进行跟踪。
Having the following function:
void visualizeHue(Mat hue)
{
int bins = 36;
int histSize[] = {bins};
int nimages = 1;
int dims = 1;
int channels[] = {0};
float rangeHue[] = {0, 180};
const float* ranges[] = {rangeHue};
bool uniform = true;
bool accumulate = false;
Mat histogram, mask;
calcHist(&hue,
nimages,
channels,
mask,
histogram,
dims,
histSize,
ranges,
uniform,
accumulate);
double maxVal;
minMaxLoc(histogram,
0,
&maxVal,
0,
0);
int gW = 800, gH = 100;
Mat theGraph(gH, gW, CV_8UC3, Scalar::all(0));
Mat colors(1, bins, CV_8UC3);
for(int i=0; i<bins; i++)
{
colors.at<Vec3b>(i) =
Vec3b(saturate_cast<uchar>(
(i+1)*180.0/bins), 255, 255);
}
cvtColor(colors, colors, COLOR_HSV2BGR);
Point p1(0,0), p2(0,theGraph.rows-1);
for(int i=0; i<bins; i++)
{
float value = histogram.at<float>(i,0);
value = maxVal - value; // invert
value = value / maxVal * theGraph.rows; // scale
p1.y = value;
p2.x = float(i+1) * float(theGraph.cols) / float(bins);
rectangle(theGraph,
p1,
p2,
Scalar(colors.at<Vec3b>(i)),
CV_FILLED);
p1.x = p2.x;
}
imshow("Graph", theGraph);
}
我们可以在CamShift函数调用后立即调用以下代码来可视化检测到的对象的色调:
CamShift(backProject,
srchWnd,
criteria);
visualizeHue(Mat(hue, srchWnd));
- 在
KalmanFilter类中设置过程噪声协方差,以便过滤值和测量值重叠。假设只设置了所有可用的矩阵中KalmanFilter类行为控制的过程噪声协方差。
setIdentity(kalman.processNoiseCov,
Scalar::all(1.0));

- 假设窗口中鼠标的Y位置用于描述从窗口左上角开始的填充矩形的长度,其宽度等于窗口的宽度。编写一个卡尔曼滤波器,可以用来校正矩形的长度(单个值)并去除鼠标移动中的噪声,从而实现填充矩形的视觉平滑缩放。
int fillHeight = 0;
void onMouse(int, int, int y, int, void*)
{
fillHeight = y;
}
int main()
{
KalmanFilter kalman(2,1);
Mat_<float> tm(2, 2); // transition matrix
tm << 1,0,
0,1;
kalman.transitionMatrix = tm;
Mat_<float> h(1,1);
h.at<float>(0) = 0;
kalman.statePre.at<float>(0) = 0; // init x
kalman.statePre.at<float>(1) = 0; // init x'
setIdentity(kalman.measurementMatrix);
setIdentity(kalman.processNoiseCov,
Scalar::all(0.001));
string window = "Canvas";
namedWindow(window);
setMouseCallback(window, onMouse);
while(waitKey(10) < 0)
{
// empty canvas
Mat canvas(500, 500, CV_8UC3, Scalar(255, 255, 255));
h(0) = fillHeight;
Mat estimation = kalman.correct(h);
float estH = estimation.at<float>(0);
rectangle(canvas,
Rect(0,0,canvas.cols, estH),
Scalar(0),
FILLED);
imshow(window, canvas);
kalman.predict();
}
return 0;
}
- 创建一个
BackgroundSubtractorMOG2对象,以提取前景图像的内容,同时避免阴影变化。
Ptr<BackgroundSubtractorMOG2> bgs =
createBackgroundSubtractorMOG2(500, // hist
16, // thresh
false // no shadows
);
- 编写一个程序,使用背景分割算法显示当前(而不是采样)的背景图像。
VideoCapture cam(0);
if(!cam.isOpened())
return -1;
Ptr<BackgroundSubtractorKNN> bgs =
createBackgroundSubtractorKNN();
while(true)
{
Mat frame;
cam >> frame;
if(frame.empty())
break;
Mat mask;
bgs->apply(frame,
mask);
bitwise_not(mask, mask);
Mat bg;
bitwise_and(frame, frame, bg, mask);
imshow("bg", bg);
int key = waitKey(10);
if(key == 27) // escape key
break;
}
cam.release();
第七章,目标检测 - 特征和描述符
- 模板匹配算法本身不具有尺度不变性和旋转不变性。我们如何使其对于以下情况成立)模板图像的尺度加倍,以及 b)模板图像的 90 度旋转版本?
a) 使用resize函数缩放模板图像,然后调用matchTemplate函数:
resize(templ, templ, Size(), 2.0, 2.0);
matchTemplate(image, templ, TM_CCOEFF_NORMED);
b) 将模板旋转 90 度,然后调用matchTemplate函数:
rotate(templ, templ, ROTATE_90_CLOCKWISE);
matchTemplate(image, templ, TM_CCOEFF_NORMED);
- 使用
GFTTDetector类通过 Harris 角点检测算法检测关键点。你可以为角点检测算法设置任何值。
Mat image = imread("Test.png");
Ptr<GFTTDetector> detector =
GFTTDetector::create(500,
0.01,
1,
3,
true);
vector<KeyPoint> keypoints;
detector->detect(image, keypoints);
drawKeypoints(image,
keypoints,
image);
- Hough 变换也可以通过
HoughCircles函数在图像中检测圆。在 OpenCV 文档中搜索它,并编写一个程序来检测图像中的圆。
Mat image = imread("Test.png");
cvtColor(image, image, COLOR_BGR2GRAY);
vector<Vec3f> circles;
HoughCircles(image,
circles,
HOUGH_GRADIENT,
2,
image.rows/4);
for(int i=0; i<circles.size(); i++)
{
Point center(cvRound(circles[i][0]),
cvRound(circles[i][1]));
int radius = cvRound(circles[i][2]);
circle( image, center, radius, Scalar(0,0,255));
}
- 在图像中检测并绘制凸轮廓。
Mat image = imread("Test.png");
Mat imgGray;
cvtColor(image, imgGray, COLOR_BGR2GRAY);
double threshold1 = 100.0;
double threshold2 = 200.0;
int apertureSize = 3;
bool L2gradient = false;
Mat edges;
Canny(image,
edges,
threshold1,
threshold2,
apertureSize,
L2gradient);
vector<vector<Point> > contours;
int mode = CV_RETR_TREE;
int method = CV_CHAIN_APPROX_TC89_KCOS;
findContours(edges,
contours,
mode,
method);
Mat result(image.size(), CV_8UC3, Scalar::all(0));
for( int i = 0; i< contours.size(); i++ )
{
if(isContourConvex(contours[i]))
{
drawContours(result,
contours,
i,
Scalar(0, 255, 0),
2);
}
}
- 使用
ORB类在两个图像中检测关键点,提取它们的描述符,并匹配它们。
Mat object = imread("Object.png");
Mat scene = imread("Scene.png");
Ptr<ORB> orb = ORB::create();
vector<KeyPoint> objKPs, scnKPs;
Mat objDesc, scnDesc;
orb->detectAndCompute(object,
Mat(),
objKPs,
objDesc);
orb->detectAndCompute(scene,
Mat(),
scnKPs,
scnDesc);
Ptr<BFMatcher> matcher = BFMatcher::create();
vector<DMatch> matches;
matcher->match(objDesc, scnDesc, matches);
Mat result;
drawMatches(object,
objKPs,
scene,
scnKPs,
matches,
result);
imshow("image", result);
- 哪个特征描述符匹配算法与 ORB 算法不兼容,为什么?
你不能使用基于 FLANN 的匹配算法与具有位串类型的描述符,如 ORB。
- 你可以使用以下 OpenCV 函数和示例来计算运行任何数量代码所需的时间。使用它来计算你计算机上匹配算法所需的时间。
double freq = getTickFrequency();
double countBefore = getTickCount();
// your code goes here ..
double countAfter = getTickCount();
cout << "Duration: " <<
(countAfter - countBefore) / freq << " seconds";
第八章,计算机视觉中的机器学习
- 在
SVM类中,train方法和trainAuto方法有什么区别?
trainAuto方法选择 SVM 参数的最佳值,如C、Gamma等,并训练模型,而train方法只是使用任何给定的参数。(阅读SVM类文档以获取更多关于trainAuto函数的详细信息以及优化是如何发生的。)
- 展示线性与直方图交点之间的区别。
我们可以使用以下代码将核类型设置为LINEAR:
svm->setKernel(SVM::LINEAR);
如果我们显示了黑色、白色和灰色点的组,这里是分类(分割)的结果:

类似地,我们可以使用以下代码将核类型设置为直方图交点:
svm->setKernel(SVM::INTER);
这里是使用直方图交点核对相同数据进行分割的结果:

- 如何计算 HOG 窗口大小为 128x96 像素的
HOGdescriptor的大小?(其他 HOG 参数保持不变。)
HOGDescriptor hog;
hog.winSize = Size(128, 128);
vector<float> tempDesc;
hog.compute(Mat(hog.winSize, CV_8UC3),
tempDesc);
int descriptorSize = tempDesc.size();
- 如何更新现有的已训练的
ANN_MLP,而不是从头开始训练?
你可以通过在训练过程中设置 UPDATE_WEIGHTS 标志来实现这一点。以下是一个示例:
ann->train(trainData, UPDATE_WEIGHTS);
- 使用
opencv_createsamples命令创建来自单个公司标志图像的正样本向量所需的命令是什么?假设你想要 1,000 个样本,宽度为 24 像素,高度为 32 像素,并且使用默认的旋转和反转参数。
opencv_createsamples -vec samples.vec -img sign.png -bg bg.txt
-num 1000 -w 24 -h 32
- 训练用于之前问题中公司标志的 LBP 级联分类器所需的命令是什么?
opencv_traincascade -data classifier -vec samples.vec
-bg bg.txt -numPos 1000 -numNeg 1000 -w 24 -h 32
-featureType LBP
- 在
opencv_traincascade中训练级联分类器时的默认阶段数是多少?我们如何更改它?将阶段数增加到或减少到远低于默认值有什么缺点?
训练分类器时的默认阶段数是 20,这对于大多数用例来说已经足够了。你可以通过使用 numStages 参数将其设置为任何你想要的值。过多地增加阶段数可能会导致分类器过拟合,并且训练它所需的时间会更长,反之亦然。


浙公网安备 33010602011771号