三、检测和识别人脸
计算机视觉使许多具有未来感的任务成为现实。 两个这样的任务是人脸检测(在图像中定位面部)和人脸识别(将人脸识别为特定人)。 OpenCV 实现了多种用于人脸检测和识别的算法。 它们在从安全性到娱乐性的各种现实环境中都有应用。
本章介绍 OpenCV 的一些人脸检测和识别功能,以及定义特定类型的可跟踪对象的数据文件。 具体来说,我们看一下 Haar 级联分类器,它可以分析相邻图像区域之间的对比度,以确定给定图像或子图像是否与已知类型匹配。 我们考虑如何在层次结构中组合多个 Haar 级联分类器,以便一个分类器标识父区域(就我们的目的而言,是人脸),而其他分类器标识子区域(例如眼睛)。
我们还绕过了谦虚但重要的矩形主题。 通过绘制,复制和调整矩形图像区域的大小,我们可以对正在跟踪的图像区域执行简单的操作。
总而言之,我们将涵盖以下主题:
- 了解 Haar 级联。
- 查找 OpenCV 附带的经过预先训练的 Haar 级联。 这些包括几个人脸检测器。
- 使用 Haar 级联来检测静止图像和视频中的面部。
- 收集图像来训练和测试人脸识别器。
- 使用几种不同的人脸识别算法:EigenFace,Fisherfaces 和本地二进制模式直方图(LBPH)。
- 使用或不使用遮罩,将矩形区域从一个图像复制到另一个图像。
- 使用深度相机基于深度来区分面部和背景。
- 在交互式应用中交换两个人的脸。
在本章结束时,我们将把面部跟踪和矩形操作集成到我们在前几章中开发的交互式应用Cameo中。 最后,我们将进行一些面对面的互动!
技术要求
本章使用 Python,OpenCV 和 NumPy。 作为 OpenCV 的一部分,它使用可选的opencv_contrib模块,其中包括用于人脸识别的功能。 本章的某些部分使用 OpenCV 对 OpenNI 2 的可选支持来捕获深度相机的图像。 请参考第 1 章,“设置 OpenCV”,以获取安装说明。
可以在本书的 GitHub 存储库的chapter05文件夹中找到本章的完整代码。 样本图像位于images文件夹中的存储库中。
概念化级联
当我们谈论对对象进行分类并跟踪它们的位置时,我们究竟要精确指出什么? 什么构成对象的可识别部分?
即使来自网络摄像头的摄影图像也可能包含许多细节,以使我们(人类)观看愉悦。 然而,关于照明,视角,观看距离,相机抖动和数字噪声的变化,图像细节趋于不稳定。 而且,即使是物理细节上的实际差异也可能使我们对分类不感兴趣。 本书的作者之一约瑟夫·霍斯(Joseph Howse)在学校里被教过,在显微镜下没有两朵雪花看起来很像。 幸运的是,作为一个加拿大孩子,他已经学会了如何在没有显微镜的情况下识别雪花,因为它们之间的相似性更加明显。
因此,一些抽象图像细节的方法可用于产生稳定的分类和跟踪结果。 这些抽象称为特征,据说是从图像数据中提取的。 特征应该比像素少得多,尽管任何像素都可能影响多个特征。 一组特征被表示为一个向量,并且可以基于图像的相应特征向量之间距离的某种度量来评估两个图像之间的相似度。
类 Haar 的特征是通常应用于实时人脸检测的一种特征。 Paul Viola 和 Michael Jones 在《鲁棒的实时人脸检测》中首次将它们用于此目的。 可在这个页面上获得本文的电子版本。 每个类似 Haar 的特征都描述了相邻图像区域之间的对比度模式。 例如,边,顶点和细线各自生成一种特征。 从某种意义上说,某些特征是有区别的,它们通常出现在特定类别的对象(例如面部)中,而不出现在其他对象中。 这些独特的特征可以组织为称为级联的层次结构,其中最高层包含具有最大独特性的特征,使分类器可以快速拒绝缺少这些特征的主题。
对于任何给定的对象,特征可能会根据图像的比例和评估对比度的邻域大小而有所不同。 后者称为窗口大小。 为了使 Haar 级联分类器不变标度,或者说要对缩放变化具有鲁棒性,窗口大小保持不变,但是图像会被多次缩放。 因此,在某种程度上进行缩放时,对象(例如面部)的大小可能与窗口大小匹配。 原始图像和重新缩放的图像一起被称为图像金字塔,并且此金字塔中的每个连续级别都是较小的重新缩放图像。 OpenCV 提供了一个尺度不变的分类器,该分类器可以从 XML 文件以特定格式加载 Haar 级联。 在内部,此分类器将任何给定图像转换为图像金字塔。
在 OpenCV 中实现的 Haar 级联对旋转或透视图的更改不可靠。 例如,上下颠倒的脸部不被视为与直立的脸部相似,并且轮廓上观看的脸部不被视为与从正面观看的脸部相似。 考虑到图像的多种转换以及多种窗口大小,更复杂,更耗费资源的实现可以提高 Haar 级联的旋转鲁棒性。 但是,我们将局限于 OpenCV 中的实现。
获取 HAAR 级联
OpenCV 4 源代码或您安装的 OpenCV 4 的预打包版本应包含一个名为data/haarcascades的子文件夹。 如果找不到它,请参考第 1 章,“设置 OpenCV”,以获取获取 OpenCV 4 源代码的说明。
data/haarcascades文件夹包含 XML 文件,可以通过名为cv2.CascadeClassifier的 OpenCV 类加载该文件。 此类的实例将给定的 XML 文件解释为 Haar 级联,Haar 级联提供了针对某种类型的对象(例如面部)的检测模型。 cv2.CascadeClassifier可以在任何图像中检测到此类物体。 像往常一样,我们可以从文件中获取静止图像,也可以从视频文件或摄像机中获取一系列帧。
找到data/haarcascades后,在项目的其他位置创建一个目录; 在此文件夹中,创建一个名为cascades的子文件夹,并将以下文件从data/haarcascades复制到cascades:
haarcascade_frontalface_default.xmlhaarcascade_frontalface_alt.xmlhaarcascade_eye.xml
顾名思义,这些级联用于跟踪面部和眼睛。 他们需要正面,正面查看主题。 稍后在构建人脸检测器时将使用它们。
如果您对如何生成这些级联文件感到好奇,可以在 Joseph Howse 的书《写给秘密特工的 OpenCV 4》 (Packt Publishing,2019 年)中找到更多信息,特别是在第 3 章,“训练智能警报以识别反派和他的猫”中。 有了足够的耐心和一台功能强大的计算机,您可以制作自己的层叠并为各种类型的物体训练它们。
使用 OpenCV 执行人脸检测
使用cv2.CascadeClassifier,无论是对静止图像还是视频源执行人脸检测,都没有什么区别。 后者只是前者的顺序版本:视频上的人脸检测只是应用于每个帧的人脸检测。 自然地,利用更先进的技术,可以跨多个帧连续跟踪检测到的面部,并确定每一帧中的面部是相同的。 但是,很高兴知道基本的顺序方法也可以工作。
让我们继续前进,发现一些面孔。
在静止图像上执行人脸检测
执行人脸检测的第一个也是最基本的方法是加载图像并检测其中的面部。 为了使结果在视觉上有意义,我们将在原始图像中的脸部周围绘制矩形。 记住脸部检测器是为直立的正面脸而设计的,我们将使用一排人的图像,特别是伐木机,肩并肩站立并面向观察者。
将 Haar 级联 XML 文件复制到我们的级联文件夹中后,我们继续创建以下基本脚本来执行人脸检测:
import cv2
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
img = cv2.imread('img/woodcutters.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.08, 5)
for (x, y, w, h) in faces:
img = cv2.rectangle(img, (x, y), (x+w, y+h), (255, 0, 0), 2)
cv2.namedWindow('Woodcutters Detected!')
cv2.imshow('Woodcutters Detected!', img)
cv2.imwrite('./woodcutters_detected.jpg', img)
cv2.waitKey(0)
让我们逐步介绍一下前面的代码。 首先,我们使用在本书的每个脚本中都必须使用的cv2导入。 然后,我们声明一个face_cascade变量,这是一个CascadeClassifier对象,该对象加载用于人脸检测的级联:
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
然后,我们用cv2.imread加载图像文件并将其转换为灰度,因为CascadeClassifier需要灰度图像。 下一步face_cascade.detectMultiScale是我们执行实际人脸检测的位置:
img = cv2.imread('img/woodcutters.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(gray, 1.08, 5)
detectMultiScale的参数包括scaleFactor和minNeighbors。 scaleFactor参数应大于 1.0,它确定在人脸检测过程的每次迭代时图像的缩小比例。 正如我们先前在“概念化 Haar 级联”部分中所讨论的那样,这种缩小旨在通过将各种面与窗口大小进行匹配来实现缩放不变性。 minNeighbors自变量是为了保留检测结果而需要的最小重叠检测数。 通常,我们希望可以在多个重叠的窗口中检测到人脸,并且大量的重叠检测使我们更加有信心检测到的人脸是真正的人脸。
从检测操作返回的值是代表脸部矩形的元组列表。 OpenCV 的cv2.rectangle函数允许我们在指定的坐标处绘制矩形。 x和y代表左坐标和顶部坐标,而w和h代表面部矩形的宽度和高度。 通过遍历faces变量,我们在找到的所有面孔周围绘制蓝色矩形,确保使用原始图像进行绘制,而不使用灰色版本:
for (x, y, w, h) in faces:
img = cv2.rectangle(img, (x, y), (x+w, y+h), (255, 0, 0), 2)
最后,我们调用cv2.imshow显示生成的处理后图像。 与往常一样,为防止图像窗口自动关闭,我们向waitKey插入了一个调用,当用户按下任意键时该调用返回:
cv2.imshow('Woodcutters Detected!', img)
cv2.imwrite('./woodcutters_detected.jpg', img)
cv2.waitKey(0)
到了这里,在我们的图像中检测到整个伐木工,如以下屏幕截图所示:
本例中的照片是彩色摄影的先驱 Sergey Prokudin-Gorsky(1863-1944)的作品。 沙皇尼古拉斯二世赞助普罗库丁·戈尔斯基(Prokudin-Gorsky),拍摄整个俄罗斯帝国的人物和地点,这是一个庞大的纪录片项目。 普罗库丁·高斯基(Prokudin-Gorsky)于 1909 年在俄罗斯西北部的维尔河(Svir River)附近拍摄了这些伐木工的照片。
对视频执行人脸检测
现在,我们了解了如何在静止图像上执行人脸检测。 如前所述,我们可以在视频的每一帧(无论是摄像机供稿还是预先录制的视频文件)上重复进行人脸检测的过程。
下一个脚本将打开一个照相机供稿,读取一个框架,检查该框架中是否有面部,并扫描检测到的面部中的眼睛。 最后,它将在面部周围绘制蓝色矩形,在眼睛周围绘制绿色矩形。 这是完整的脚本:
import cv2
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_eye.xml')
camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
success, frame = camera.read()
if success:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(
gray, 1.3, 5, minSize=(120, 120))
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
roi_gray = gray[y:y+h, x:x+w]
eyes = eye_cascade.detectMultiScale(
roi_gray, 1.03, 5, minSize=(40, 40))
for (ex, ey, ew, eh) in eyes:
cv2.rectangle(frame, (x+ex, y+ey),
(x+ex+ew, y+ey+eh), (0, 255, 0), 2)
cv2.imshow('Face Detection', frame)
让我们将前面的示例分解成较小的,可消化的块:
- 和往常一样,我们导入
cv2模块。 之后,我们初始化两个CascadeClassifier对象,一个用于面部,另一个用于眼睛:
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_eye.xml')
- 与大多数交互式脚本一样,我们打开相机供稿并开始遍历帧。 我们继续操作,直到用户按任意键。 每当我们成功捕获帧时,我们都会将其转换为灰度,这是处理它的第一步:
camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
success, frame = camera.read()
if success:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
- 我们使用人脸检测器的
detectMultiScale方法检测面部。 正如我们之前所做的,我们使用scaleFactor和minNeighbors参数。 我们还使用minSize参数指定人脸的最小尺寸,特别是120x120。 不会尝试检测小于此尺寸的脸部。 (假设我们的用户坐在相机旁边,可以肯定地说用户的脸将大于120x120像素。)这是detectMultiScale的调用:
faces = face_cascade.detectMultiScale(
gray, 1.3, 5, minSize=(120, 120))
- 我们遍历检测到的面部的矩形。 我们在原始彩色图像的每个矩形周围绘制一个蓝色边框。 然后,在灰度图像的相同矩形区域内,执行眼睛检测:
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
roi_gray = gray[y:y+h, x:x+w]
eyes = eye_cascade.detectMultiScale(
roi_gray, 1.1, 5, minSize=(40, 40))
眼睛检测器的准确率不如人脸检测器。 您可能会看到阴影,眼镜框的一部分或面部其他部分被错误地检测为眼睛。 为了改善效果,您可以尝试将roi_gray定义为面部的较小区域,因为我们可以很好地猜测眼睛在直立的面部中的位置。 您也可以尝试使用maxSize参数来避免过大而不会引起人注意的误报。 另外,您可以调整minSize和maxSize的尺寸,使其与检测到的脸部尺寸w和h成比例。 作为练习,可以随时尝试更改这些参数和其他参数。
- 我们遍历生成的眼睛矩形,并在它们周围绘制绿色轮廓:
for (ex, ey, ew, eh) in eyes:
cv2.rectangle(frame, (x+ex, y+ey),
(x+ex+ew, y+ey+eh), (0, 255, 0), 2)
- 最后,我们在窗口中显示结果帧:
cv2.imshow('Face Detection', frame)
运行脚本。 如果我们的检测器产生准确的结果,并且在摄像头的视野内有任何人脸,您应该在该人脸周围看到一个蓝色矩形,在每只眼睛周围看到一个绿色矩形,如以下屏幕截图所示:
使用此脚本进行试验,以了解面部和眼睛检测器在各种条件下的表现。 尝试在更亮或更暗的房间里。 如果您戴眼镜,请尝试摘下它们。 尝试各种人的面孔和各种表情。 在脚本中调整检测参数,以查看它们如何影响结果。 当您感到满意时,让我们考虑一下我们还能在 OpenCV 中使用面孔做什么。
执行人脸识别
人脸检测是 OpenCV 的一项奇妙功能,它构成了更高级操作的基础:人脸识别。 什么是人脸识别? 给定包含人脸的图像或视频源,程序可以识别该人。 实现此目的的一种方法(以及 OpenCV 所采用的方法)是通过向程序提供一组分类图片(面部数据库)来训练程序,并根据这些图片的特征进行识别。
OpenCV 的人脸识别模块的另一个重要功能是每个识别都有一个置信度分数,这使我们可以在现实应用中设置阈值以限制错误识别的发生率。
让我们从头开始。 要进行人脸识别,我们需要人脸识别。 我们可以通过两种方式做到这一点:自己提供图像或获得免费的人脸数据库。 可以在这个页面上在线获取大量的人脸数据库。 以下是目录中的一些著名示例:
要在这些样本上执行人脸识别,我们将不得不在包含一个被采样人的脸部图像的图像上进行人脸识别。 这个过程可能具有教育意义,但可能不如提供我们自己的图像那样令人满意。 您可能有很多计算机视觉学习者都曾有过这样的想法:我想知道我是否可以编写一个程序来以某种程度的自信识别我的脸。
生成用于人脸识别的数据
让我们继续写一个脚本,它将为我们生成这些图像。 我们只需要几张包含不同表情的图像,但最好训练图像是正方形的且尺寸均相同。 我们的示例脚本使用200x200的大小,但是大多数免费提供的数据集的图像都小于此。
这是脚本本身:
import cv2
import os
output_folder = '../data/at/jm'
if not os.path.exists(output_folder):
os.makedirs(output_folder)
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
eye_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_eye.xml')
camera = cv2.VideoCapture(0)
count = 0
while (cv2.waitKey(1) == -1):
success, frame = camera.read()
if success:
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
faces = face_cascade.detectMultiScale(
gray, 1.3, 5, minSize=(120, 120))
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
face_img = cv2.resize(gray[y:y+h, x:x+w], (200, 200))
face_filename = '%s/%d.pgm' % (output_folder, count)
cv2.imwrite(face_filename, face_img)
count += 1
cv2.imshow('Capturing Faces...', frame)
在这里,我们基于对视频源中如何检测人脸的新知识来生成样本图像。 我们正在检测一张脸,裁剪经过灰度转换的帧的该区域,将其大小调整为200x200像素,并将其保存为 PGM 文件,并在特定文件夹中命名(在本例中为jm,这是作者的首字母缩写;您可以使用自己的首字母缩写)。 与我们的许多窗口应用一样,该应用将一直运行到用户按下任意键为止。
存在count变量是因为我们需要图像的渐进名称。 运行脚本几秒钟,更改面部表情几次,然后检查在脚本中指定的目标文件夹。 您会发现许多面部图像,这些图像变灰,调整大小并以<count>.pgm格式命名。
修改output_folder变量,使其与您的名字匹配。 例如,您可以选择'../data/at/my_name'。 运行脚本,等待它以多个帧(例如 20 个或更多)检测到您的脸,然后按任意键退出。 现在,再次修改output_folder变量,使其与您也想识别的朋友的名字匹配。 例如,您可以选择'../data/at/name_of_my_friend'。 不要更改文件夹的基本部分(在本例中为'../data/at'),因为稍后,在“加载用于人脸识别的训练数据”部分中,我们将编写代码以从此基本文件夹的子文件夹的所有位置加载训练图像。 让您的朋友坐在镜头前,再次运行脚本,让脚本在多个帧中检测到您朋友的脸,然后退出。 对您可能想要认识的其他任何人重复此过程。
现在,让我们继续尝试识别视频供稿中的用户面部。 这应该是有趣的!
识别人脸
OpenCV 4 实现了三种不同的算法来识别人脸:EigenFace,Fisherfaces 和本地二进制模式直方图(LBPH)。 EigenFace 和 Fisherfaces 是从称为主成分分析(PCA)的通用算法衍生而来的。 有关算法的详细说明,请参考以下链接:
- PCA:Jonathon Shlens 的直观介绍可从这个页面获得。 该算法由卡尔·皮尔森(Karl Pearson)于 1901 年发明,《最接近空间点系统的直线和平面》的原始论文可在这个页面上找到。
- EigenFace:Matthew Turk 和 Alex Pentland 撰写的论文《用于识别的 EigenFace》(1991),可从这个页面。
- Fisherfaces:RA Fisher 撰写的开创性论文《在分类问题中使用多重度量》(1936),可从这个页面得到。
- 局部二进制模式:描述此算法的第一篇论文是《纹理度量的表现评估,基于基于分布的 Kullback 判别的分类》(1994),作者 T. Ojala,M. Pietikainen 和 D. 哈伍德。 可在这个页面上获得。
出于本书的目的,我们仅对算法进行高级概述。 首先,它们都遵循相似的过程。 他们进行一系列分类观察(我们的面部数据库,每个人包含许多样本),基于该模型训练模型,对面部图像(可能是我们在图像或视频中检测到的面部区域)进行分析,并确定两件事:受试者的身份,以及对这种识别正确性的信心度量。 后者通常称为置信度分数。
Eigenfaces 执行 PCA,该 PCA 识别一组特定观察值(同样是您的面部数据库)的主要成分,计算当前观察值(在图像或帧中检测到的面部)与数据集的差异,并产生一个值。 值越小,面部数据库与检测到的面部之间的差异越小; 因此,值 0 是完全匹配。
Fisherfaces 也源自 PCA,并应用更复杂的逻辑对概念进行了改进。 尽管计算量更大,但与 Eigenfaces 相比,它倾向于产生更准确的结果。
LBPH 相反将检测到的脸部分成小单元,并针对每个单元建立直方图,该直方图描述了在给定方向上比较相邻像素时图像的亮度是否正在增加。 可以将该单元格的直方图与模型中相应单元格的直方图进行比较,以衡量相似度。 在 OpenCV 中的人脸识别器中,LBPH 的实现是唯一一种允许模型样本人脸和检测到的人脸具有不同形状和大小的实现。 因此,这是一个方便的选择,这本书的作者发现它的准确率优于其他两个选择。
加载训练数据以进行人脸识别
无论选择哪种人脸识别算法,我们都可以以相同的方式加载训练图像。 之前,在“生成用于人脸识别的数据”部分中,我们生成了训练图像并将其保存在根据人们的姓名或名字缩写组织的文件夹中。 例如,以下文件夹结构可能包含本书作者 Joseph Howse(J. H.)和 Joe Minichino(J. M.)的样本面部图像:
../
data/
at/
jh/
jm/
让我们编写一个脚本来加载这些图像并以 OpenCV 的人脸识别器可以理解的方式对其进行标记。 为了处理文件系统和数据,我们将使用 Python 标准库的os模块以及cv2和numpy模块。 让我们创建一个以以下import语句开头的脚本:
import os
import cv2
import numpy
让我们添加以下read_images函数,该函数遍历目录的子目录,加载图像,将其调整为指定的大小,然后将调整后的图像放入列表中。 同时,它还建立了另外两个列表:第一,一个人名或首字母的列表(基于子文件夹的名称),第二,一个与加载的图像相关联的标签或数字 ID 的列表。 例如,jh可以是名称,0可以是从jh子文件夹加载的所有图像的标签。 最后,该函数将图像和标签的列表转换为 NumPy 数组,并返回三个变量:名称列表,图像的 NumPy 数组和标签的 NumPy 数组。 这是函数的实现:
def read_images(path, image_size):
names = []
training_images, training_labels = [], []
label = 0
for dirname, subdirnames, filenames in os.walk(path):
for subdirname in subdirnames:
names.append(subdirname)
subject_path = os.path.join(dirname, subdirname)
for filename in os.listdir(subject_path):
img = cv2.imread(os.path.join(subject_path, filename),
cv2.IMREAD_GRAYSCALE)
if img is None:
# The file cannot be loaded as an image.
# Skip it.
continue
img = cv2.resize(img, image_size)
training_images.append(img)
training_labels.append(label)
label += 1
training_images = numpy.asarray(training_images, numpy.uint8)
training_labels = numpy.asarray(training_labels, numpy.int32)
return names, training_images, training_labels
让我们通过添加如下代码来调用read_images函数:
path_to_training_images = '../data/at'
training_image_size = (200, 200)
names, training_images, training_labels = read_images(
path_to_training_images, training_image_size)
在前面的代码块中编辑path_to_training_images变量,以确保它与您先前在“生成用于人脸识别数据”的代码部分中定义的output_folder变量的基本文件夹匹配。
到目前为止,我们已经以有用的格式获得了训练数据,但是我们还没有创建人脸识别器或进行任何训练。 我们将在下一节中继续执行相同的脚本。
用 EigenFace 执行人脸识别
现在我们有了一个训练图像数组和它们的标签数组,我们可以仅用两行代码来创建和训练人脸识别器:
model = cv2.face.EigenFaceRecognizer_create()
model.train(training_images, training_labels)
我们在这里做了什么? 我们使用 OpenCV 的cv2.EigenFaceRecognizer_create函数创建了 Eigenfaces 人脸识别器,并通过传递图像和标签(数字 ID)数组来训练识别器。 (可选)我们可以将两个参数传递给cv2.EigenFaceRecognizer_create:
num_components:这是 PCA 保留的组件数。threshold:这是一个指定置信度阈值的浮点值。 置信度得分低于阈值的面孔将被丢弃。 默认情况下,阈值为最大浮点值,因此不会丢弃任何面。
为了测试此识别器,让我们使用人脸检测器和来自摄像机的视频。 正如我们在先前脚本中所做的那样,我们可以使用以下代码行初始化人脸检测器:
face_cascade = cv2.CascadeClassifier(
'./cascades/haarcascade_frontalface_default.xml')
以下代码初始化摄像头馈送,遍历帧(直到用户按任意键),并在每个帧上执行人脸检测和识别:
camera = cv2.VideoCapture(0)
while (cv2.waitKey(1) == -1):
success, frame = camera.read()
if success:
faces = face_cascade.detectMultiScale(frame, 1.3, 5)
for (x, y, w, h) in faces:
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
roi_gray = gray[x:x+w, y:y+h]
if roi_gray.size == 0:
# The ROI is empty. Maybe the face is at the image edge.
# Skip it.
continue
roi_gray = cv2.resize(roi_gray, training_image_size)
label, confidence = model.predict(roi_gray)
text = '%s, confidence=%.2f' % (names[label], confidence)
cv2.putText(frame, text, (x, y - 20),
cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 0, 0), 2)
cv2.imshow('Face Recognition', frame)
让我们来看一下前面的代码块中最重要的功能。 对于每个检测到的脸部,我们都会对其进行转换并调整其大小,以便获得与预期大小相匹配的灰度版本(在这种情况下,如上一节“人脸识别”中的training_image_size变量所定义,为200x200像素)。 然后,将经过调整大小的灰度面部传递给人脸识别器的predict函数。 这将返回标签和置信度分数。 我们查找与该面孔的数字标签相对应的人名。 (请记住,我们在上一节“加载用于人脸识别的训练数据”中加载了names数组。)我们在识别出的面部上方用蓝色文本绘制名称和置信度得分。 遍历所有检测到的面部之后,我们显示带标注的图像。
我们采用了一种简单的人脸检测和识别方法,其目的是使您能够运行基本应用并了解 OpenCV 4 中的人脸识别过程。 采取其他步骤,例如正确对齐和旋转检测到的面部,以使识别的准确率最大化。
运行脚本时,应该看到类似于以下屏幕截图的内容:
接下来,让我们考虑如何调整这些脚本,以用另一种人脸识别算法替换 Eigenfaces。
用 Fisherfaces 执行人脸识别
那 Fisherfaces 呢? 该过程变化不大; 我们只需要实例化其他算法即可。 使用默认参数,我们的model变量的声明如下所示:
model = cv2.face.FisherFaceRecognizer_create()
cv2.face.FisherFaceRecognizer_create与cv2.createEigenFaceRecognizer_create带有两个相同的可选参数:要保留的主要成分数和置信度阈值。
用 LBPH 执行人脸识别
最后,让我们快速看一下 LBPH 算法。 同样,该过程是相似的。 但是,算法工厂采用以下可选参数(按顺序):
radius:用于计算像元直方图的相邻像素之间的像素距离(默认为 1)neighbors:用于计算单元格直方图的邻居数(默认为 8)grid_x:将脸部水平划分为的像元数(默认为 8 个)grid_y:脸部垂直划分的像元数(默认为 8)confidence:置信度阈值(默认情况下,为最大可能的浮点值,因此不会丢弃任何结果)
使用默认参数,模型声明将如下所示:
model = cv2.face.LBPHFaceRecognizer_create()
请注意,使用 LBPH,我们无需调整图像大小,因为将其划分为网格可以比较每个单元格中识别出的模式。
根据置信度分数丢弃结果
predict方法返回一个元组,其中第一个元素是识别的个人的标签,第二个元素是置信度得分。 所有算法都带有设置置信度得分阈值的选项,该阈值可测量识别出的人脸与原始模型的距离,因此,得分 0 表示完全匹配。
在某些情况下,您宁愿保留所有识别然后进行进一步处理,因此可以提出自己的算法来估计识别的置信度得分。 例如,如果您试图识别视频中的人物,则可能需要分析后续帧中的置信度得分,以确定识别是否成功。 在这种情况下,您可以检查算法获得的置信度得分并得出自己的结论。
置信度分数的典型范围取决于算法。 EigenFace 和 Fisherfaces 产生的值(大约)在 0 到 20,000 之间,任何低于 4,000-5,000 的分数都是很自信的认可。 对于 LBPH,良好识别的参考值低于 50,任何高于 80 的值都被认为是较差的置信度得分。
通常的自定义方法是推迟在已识别的面部周围绘制矩形,直到我们获得多个具有令人满意的任意置信度得分的帧为止,但是您完全可以使用 OpenCV 的人脸识别模块来根据需要定制应用。
在红外线中交换人脸
人脸检测和识别不限于可见光谱。 使用近红外(NIR)相机和 NIR 光源,即使场景在人眼看来完全黑暗的情况下,也可以进行人脸检测和识别。 此功能在安全和监视应用中非常有用。
在第 4 章,“深度估计和分割”中,我们研究了 NIR 深度相机(如 Asus Xtion PRO)的基本用法。 我们扩展了交互式应用Cameo的面向对象代码。 我们从深度相机捕获了帧。 基于深度,我们将每个帧分为一个主要层(例如用户的面部)和其他层。 我们将其他层涂成黑色。 这样就达到了隐藏背景的效果,从而只有主层(用户的脸部)才出现在交互式视频源中的屏幕上。
现在,让我们修改Cameo,以执行我们以前在深度分割方面的技能和我们在人脸检测方面的新技能。 让我们检测一下脸,然后,当我们在一帧中检测到至少两个脸时,让我们交换这些脸,以使一个人的头部出现在另一个人的身体上方。 除了复制在检测到的面部矩形中的所有像素外,我们将仅复制该矩形的主要深度层中的像素。 这应该获得交换面孔的效果,但不能交换面孔周围的背景像素。
更改完成后,Cameo将能够产生输出,例如以下屏幕截图:
在这里,我们看到约瑟夫·豪斯(Joseph Howse)的脸与母亲珍妮特·霍斯(Janet Howse)的脸互换了。 尽管Cameo从矩形区域复制像素(并且在交换区域的底部清晰可见,在前景中很明显),但是某些背景像素没有交换,因此我们在各处都看不到矩形边缘。
您可以在这个页面的本书存储库中找到对Cameo源代码的所有相关更改。 ],特别是在chapter05/cameo文件夹中。 为简洁起见,我们不会在本书中讨论所有更改,但将在接下来的两个小节中介绍一些重点,“修改应用的循环”和“屏蔽复制操作”。
修改应用的循环
为了支持人脸交换,Cameo项目有两个名为rects和trackers的新模块。 rects模块包含用于复制和交换矩形的功能,以及一个可选的掩码,用于将复制或交换操作限制为特定的像素。 trackers模块包含一个名为FaceTracker的类,该类使 OpenCV 的人脸检测功能适应于面向对象的编程风格。
由于我们在本章前面已经介绍了 OpenCV 的人脸检测功能,并且在前面的章节中已经展示了一种面向对象的编程风格,因此在此不再介绍FaceTracker实现。 相反,您可以在本书的资料库中查看它。
让我们打开cameo.py,以便我们逐步了解应用的整体变化:
- 在文件顶部附近,我们需要导入新模块,如以下代码块中的粗体所示:
import cv2
import depth
import filters
from managers import WindowManager, CaptureManager
import rects
from trackers import FaceTracker
- 现在,我们将注意力转移到
CameoDepth类的__init__方法中。 我们更新的应用使用FaceTracker的实例。 作为其功能的一部分,FaceTracker可以在检测到的面部周围绘制矩形。 让我们为Cameo的用户提供启用或禁用面部矩形绘制的选项。 我们将通过布尔变量跟踪当前选择的选项。 以下代码块(以粗体)显示了初始化FaceTracker对象和布尔变量所需的更改:
class CameoDepth(Cameo):
def __init__(self):
self._windowManager = WindowManager('Cameo',
self.onKeypress)
#device = cv2.CAP_OPENNI2 # uncomment for Kinect
device = cv2.CAP_OPENNI2_ASUS # uncomment for Xtion
self._captureManager = CaptureManager(
cv2.VideoCapture(device), self._windowManager, True)
self._faceTracker = FaceTracker()
self._shouldDrawDebugRects = False
self._curveFilter = filters.BGRPortraCurveFilter()
我们在CameoDepth的run方法中使用FaceTracker对象,该方法包含捕获和处理帧的应用主循环。 每次成功捕获帧时,我们都会调用FaceTracker方法来更新人脸检测结果并获取最新检测到的面部。 然后,针对每张脸,我们根据深度相机的视差图创建一个遮罩。 (以前,在第 4 章,“深度估计和分段”中,我们为整个图像创建了这样一个遮罩,而不是为每个脸部矩形创建了遮罩。)然后,我们调用一个函数, rects.swapRects,以执行遮罩矩形的遮罩交换。 (稍后,我们将在“屏蔽复制操作”部分中查看swapRects的实现。)
- 根据当前选择的选项,我们可能会告诉
FaceTracker在面周围绘制矩形。 所有相关更改在以下代码块的粗体中显示:
def run(self):
"""Run the main loop."""
self._windowManager.createWindow()
while self._windowManager.isWindowCreated:
# ... The logic for capturing a frame is unchanged ...
if frame is not None:
self._faceTracker.update(frame)
faces = self._faceTracker.faces
masks = [
depth.createMedianMask(
disparityMap, validDepthMask,
face.faceRect) \
for face in faces
]
rects.swapRects(frame, frame,
[face.faceRect for face in faces],
masks)
if self._captureManager.channel == cv2.CAP_OPENNI_BGR_IMAGE:
# A BGR frame was captured.
# Apply filters to it.
filters.strokeEdges(frame, frame)
self._curveFilter.apply(frame, frame)
if self._shouldDrawDebugRects:
self._faceTracker.drawDebugRects(frame)
self._captureManager.exitFrame()
self._windowManager.processEvents()
- 最后,让我们修改
onKeypress方法,以便用户可以按X键开始或停止在检测到的脸部周围显示矩形。 同样,相关更改在以下代码块中以粗体显示:
def onKeypress(self, keycode):
"""Handle a keypress.
space -> Take a screenshot.
tab -> Start/stop recording a screencast.
x -> Start/stop drawing debug rectangles around faces.
escape -> Quit.
"""
if keycode == 32: # space
self._captureManager.writeImage('screenshot.png')
elif keycode == 9: # tab
if not self._captureManager.isWritingVideo:
self._captureManager.startWritingVideo(
'screencast.avi')
else:
self._captureManager.stopWritingVideo()
elif keycode == 120: # x
self._shouldDrawDebugRects = \
not self._shouldDrawDebugRects
elif keycode == 27: # escape
self._windowManager.destroyWindow()
接下来,让我们看一下我们在本节前面导入的rects模块的实现。
遮罩复制操作
rects模块在rects.py中实现。 在上一节中,我们已经看到了对rects.swapRects函数的调用。 但是,在考虑实现swapRects之前,我们首先需要一个更基本的copyRect函数。
早在第 2 章,“处理文件,照相机和 GUI”时,我们就学习了如何从一个矩形兴趣区域(ROI)复制数据,使用 NumPy 的切片语法。 在 ROI 之外,源图像和目标图像不受影响。 现在,我们想对该复制操作应用更多限制。 我们要使用与源矩形具有相同尺寸的给定遮罩。
我们将仅复制源矩形中掩码值不为零的那些像素。 其他像素应保留目标图像中的旧值。 具有条件数组和两个可能的输出值数组的逻辑可以使用numpy.where函数简明表示。
考虑到这种方法,让我们考虑一下copyRect函数。 作为参数,它需要一个源和目标图像,一个源和目标矩形以及一个遮罩。 后者可能是None,在这种情况下,我们只需调整源矩形的内容大小以匹配目标矩形,然后将生成的调整大小的内容分配给目标矩形。 否则,我们接下来要确保遮罩和图像具有相同数量的通道。 我们假设遮罩具有一个通道,但是图像可能具有三个通道(BGR)。 我们可以使用numpy.array的repeat和reshape方法添加重复通道以进行遮罩。 最后,我们使用numpy.where执行复制操作。 完整的实现如下:
def copyRect(src, dst, srcRect, dstRect, mask = None,
interpolation = cv2.INTER_LINEAR):
"""Copy part of the source to part of the destination."""
x0, y0, w0, h0 = srcRect
x1, y1, w1, h1 = dstRect
# Resize the contents of the source sub-rectangle.
# Put the result in the destination sub-rectangle.
if mask is None:
dst[y1:y1+h1, x1:x1+w1] = \
cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
interpolation = interpolation)
else:
if not utils.isGray(src):
# Convert the mask to 3 channels, like the image.
mask = mask.repeat(3).reshape(h0, w0, 3)
# Perform the copy, with the mask applied.
dst[y1:y1+h1, x1:x1+w1] = \
numpy.where(cv2.resize(mask, (w1, h1),
interpolation = \
cv2.INTER_NEAREST),
cv2.resize(src[y0:y0+h0, x0:x0+w0], (w1, h1),
interpolation = interpolation),
dst[y1:y1+h1, x1:x1+w1])
我们还需要定义一个swapRects函数,该函数使用copyRect执行矩形区域列表的循环交换。 swapRects有一个masks参数,这是一组掩码的列表,其元素传递到相应的copyRect调用。 如果masks参数的值为None,则将None传递给每个copyRect调用。 以下代码显示swapRects的完整实现:
def swapRects(src, dst, rects, masks = None,
interpolation = cv2.INTER_LINEAR):
"""Copy the source with two or more sub-rectangles swapped."""
if dst is not src:
dst[:] = src
numRects = len(rects)
if numRects < 2:
return
if masks is None:
masks = [None] * numRects
# Copy the contents of the last rectangle into temporary storage.
x, y, w, h = rects[numRects - 1]
temp = src[y:y+h, x:x+w].copy()
# Copy the contents of each rectangle into the next.
i = numRects - 2
while i >= 0:
copyRect(src, dst, rects[i], rects[i+1], masks[i],
interpolation)
i -= 1
# Copy the temporarily stored content into the first rectangle.
copyRect(temp, dst, (0, 0, w, h), rects[0], masks[numRects - 1],
interpolation)
请注意,copyRect中的mask参数和swapRects中的masks参数都具有默认值None。 如果未指定掩码,则这些函数将复制或交换矩形的全部内容。
总结
到目前为止,您应该已经对人脸检测和人脸识别如何工作以及如何在 Python 和 OpenCV 4 中实现它们有了很好的了解。
脸部检测和脸部识别是计算机视觉不断发展的分支,算法也在不断发展,随着对机器人技术和物联网(IoT)。
目前,检测和识别算法的准确率在很大程度上取决于训练数据的质量,因此请确保为您的应用提供涵盖各种表情,姿势和光照条件的大量训练图像。
作为人类,我们可能倾向于认为人的脸特别容易辨认。 我们甚至可能对自己的人脸识别能力过于自信。 但是,在计算机视觉中,人脸没有什么特别之处,我们可以很容易地使用算法来查找和识别其他事物。 接下来,我们将在第 6 章,“检索图像并使用图像描述符进行搜索”中。
六、检索图像并将图像描述符用于搜索
与人眼和大脑相似,OpenCV 可以检测图像的主要特征并将其提取到所谓的图像描述符中。 然后可以将这些特征用作数据库,从而启用基于图像的搜索。 此外,我们可以使用关键点将图像拼接在一起并组成更大的图像。 (请考虑将许多图片组合在一起以形成 360° 全景图。)
本章将向您展示如何使用 OpenCV 检测图像的特征,并利用它们来匹配和搜索图像。 在本章中,我们将拍摄样本图像并检测其主要特征,然后尝试查找与样本图像匹配的另一幅图像的区域。 我们还将发现样本图像与另一幅图像的匹配区域之间的单应性或空间关系。
更具体地说,我们将介绍以下任务:
- 使用以下任何一种算法检测关键点并提取关键点周围的局部描述符:Harris 角,SIFT,SURF 或 ORB
- 使用暴力算法或 FLANN 算法匹配关键点
- 使用 KNN 和比率测试过滤掉不良匹配
- 查找两组匹配的关键点之间的单应性
- 搜索一组图像以确定哪个包含与参考图像最匹配的图像
我们将通过构建概念验证法证应用来结束本章。 给定纹身的参考图像,我们将搜索一组人的图像,以找到具有匹配纹身的人。
技术要求
本章使用 Python,OpenCV 和 NumPy。 关于 OpenCV,我们使用可选的opencv_contrib模块,其中包括用于关键点检测和匹配的其他算法。 要启用 SIFT 和 SURF 算法(已获得专利,并非为商业用途免费提供),我们必须在 CMake 中为opencv_contrib模块配置OPENCV_ENABLE_NONFREE标志。 有关安装说明,请参阅第 1 章,“设置 OpenCV”。 另外,如果尚未安装 Matplotlib,请通过运行$ pip install matplotlib(或$ pip3 install matplotlib(取决于您的环境))进行安装。
本章的完整代码可以在本书的 GitHub 存储库的chapter06文件夹中找到。 样本图像可在images文件夹中找到。
了解特征检测和匹配的类型
许多算法可用于检测和描述特征,我们将在本节中探讨其中的几种。 OpenCV 中最常用的特征检测和描述符提取算法如下:
- Harris:此算法对于检测角点很有用。
- SIFT:此算法对于检测斑点很有用。
- SURF:此算法可用于检测斑点。
- FAST:此算法对于检测角点很有用。
- BRIEF:此算法可用于检测斑点。
- ORB:该算法代表定向 FAST 和旋转 BRIEF。 对于检测角点和斑点的组合很有用。
可以使用以下方法执行匹配功能:
- 暴力匹配
- 基于 FLANN 的匹配
然后可以使用单应性进行空间验证。
我们刚刚介绍了许多新的术语和算法。 现在,我们将介绍它们的基本定义。
定义特征
到底有什么特征? 为什么图像的特定区域可以分类为特征,而其他区域则不能分类为特征? 从广义上讲,特征是图像中唯一或易于识别的兴趣区域。 角落和纹理细节密度高的区域是好的特征,而重复很多的图案和低密度区域(例如蓝天)则不是。 边缘是很好的特征,因为它们倾向于划分图像的两个区域。 斑点(图像的区域与其周围区域也大不相同)也是一个有趣的特征。
大多数特征检测算法都围绕角,边和斑点的识别,其中一些算法还关注脊的概念,您可以将其概念化为细长对象的对称轴。 (例如,考虑识别图像中的道路。)
有些算法更擅长识别和提取某种类型的特征,因此了解您的输入图像很重要,这样您就可以利用 OpenCV 传送带中最好的工具。
使用哈里斯检测角点
让我们开始使用哈里斯角点检测算法查找角点。 我们将通过示例来实现。 如果您在本书之外继续学习 OpenCV,您会发现棋盘格是计算机视觉分析的常见主题,部分原因是棋盘格模式适合于多种类型的特征检测,部分原因是国际象棋是一种流行的消遣方式,特别是在俄罗斯,许多 OpenCV 开发人员居住的地方。
这是我们的棋盘和棋子的示例图像:
OpenCV 具有称为cv2.cornerHarris的便捷函数,该函数可检测图像中的角。 在下面的基本示例中,我们可以看到此函数在起作用:
import cv2
img = cv2.imread('img/chess_board.png')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
img[dst > 0.01 * dst.max()] = [0, 0, 255]
cv2.imshow('corners', img)
cv2.waitKey()
让我们分析一下代码。 在常规导入之后,我们加载棋盘图像并将其转换为灰度。 然后,我们调用cornerHarris函数:
dst = cv2.cornerHarris(gray, 2, 23, 0.04)
这里最重要的参数是第三个参数,它定义了 Sobel 算子的孔径或核大小。 Sobel 运算符通过测量邻域中像素值之间的水平和垂直差异来检测边缘,并使用核进行此操作。 cv2.cornerHarris函数使用 Sobel 运算符,其光圈由该参数定义。 用简单的英语来说,这些参数定义了敏感角检测的程度。 它必须在 3 到 31 之间,并且是一个奇数值。 在3值低(高度敏感)的情况下,棋盘黑色正方形中的所有对角线在接触正方形的边界时都会注册为角。 对于23较高(较不敏感)的值,将仅将每个正方形的角检测为角。
cv2.cornerHarris返回浮点格式的图像。 该图像中的每个值代表源图像中相应像素的分数。 中等或高分表示该像素可能是一个角。 相反,我们可以将得分最低的像素视为非角。 考虑以下行:
img[dst > 0.01 * dst.max()] = [0, 0, 255]
在这里,我们选择分数至少为最高分数的 1% 的像素,并在原始图像中将这些像素着色为红色。 结果如下:
大! 几乎所有检测到的角都标记为红色。 标记的点几乎包括棋盘正方形的所有角。
如果在cv2.cornerHarris中调整第二个参数,我们将看到较小的区域(对于较小的参数值)或较大的区域(对于较大的参数值)将被检测为角点。 此参数称为块大小。
检测 DoG 特征并提取 SIFT 描述符
先前使用cv2.cornerHarris的技术非常适合检测角点,并且由于角点就是角点而具有明显的优势。 即使旋转图像,也会检测到它们。 但是,如果我们将图像缩放为较小或较大的尺寸,则图像的某些部分可能会丢失甚至获得角点质量。
例如,在 F1 意大利大奖赛赛道图像中查看以下角点检测:
以下是使用同一图像的较小版本的角点检测结果:
您会注意到角落更加凝结了。 但是,即使我们获得了一些优势,我们也失去了一些优势! 特别是,让我们研究一下瓦里安特·阿斯卡里弯锥,它看起来像是从西北向东南一直延伸的那部分赛道尽头的弯弯曲曲。 在较大的图像版本中,双折弯的入口和顶点均被检测为角。 在较小的图像中,无法像这样检测到顶点。 如果我们进一步缩小图像,从某种程度上说,我们也将失去通往那个弯道的入口。
特征的丧失引发了一个问题。 我们需要一种无论图像大小如何都可以工作的算法。 输入比例不变特征变换(SIFT)。 尽管这个名称听起来有些神秘,但现在我们知道我们要解决的问题,这实际上是有道理的。 我们需要一个函数(一个变换)来检测特征(一个特征变换),并且不会根据图像的缩放比例输出不同的结果(缩放不变的特征变换)。 请注意,SIFT 不会检测关键点(这是通过高斯差异(DoG 来完成的);而是通过特征向量描述了围绕它们的区域。
对 DoG 的快速介绍是有序的。 之前,在第 3 章“使用 OpenCV 处理图像”中,我们讨论了低通过滤器和模糊操作,特别是cv2.GaussianBlur()函数。 DoG 是对同一图像应用不同的高斯过滤器的结果。 以前,我们将这种类型的技术应用于边缘检测,这里的想法是相同的。 DoG 操作的最终结果包含兴趣区域(关键点),然后将通过 SIFT 描述这些区域。
让我们看看下图中的 DoG 和 SIFT 的行为,图中充满了角落和特征:
在这里,瓦雷泽(Varese)的美丽全景(位于意大利伦巴第)作为计算机视觉的主题而声名 new 起。 这是生成此已处理图像的代码:
import cv2
img = cv2.imread('img/varese.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptors = sift.detectAndCompute(gray, None)
cv2.drawKeypoints(img, keypoints, img, (51, 163, 236),
cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow('sift_keypoints', img)
cv2.waitKey()
在常规导入后,我们加载要处理的图像。 然后,我们将图像转换为灰度。 到目前为止,您可能已经收集到 OpenCV 中的许多方法都希望将灰度图像作为输入。 下一步是创建 SIFT 检测对象并计算灰度图像的特征和描述符:
sift = cv2.xfeatures2d.SIFT_create()
keypoints, descriptors = sift.detectAndCompute(gray, None)
在幕后,这些简单的代码行执行了一个复杂的过程。 我们创建一个cv2.SIFT对象,该对象使用 DoG 来检测关键点,然后为每个关键点的周围区域计算特征向量。 就像detectAndCompute方法的名称清楚地表明的那样,执行了两个主要操作:特征检测和描述符的计算。 该操作的返回值是一个元组,其中包含一个关键点列表和另一个关键点描述符的列表。
最后,我们通过使用cv2.drawKeypoints函数在其上绘制关键点,然后使用常规的cv2.imshow函数来显示该图像来处理该图像。 作为其参数之一,cv2.drawKeypoints函数接受一个标志,该标志指定我们想要的可视化类型。 在这里,我们指定cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINT以绘制每个关键点的比例和方向的可视化。
关键点剖析
每个关键点都是cv2.KeyPoint类的实例,该类具有以下属性:
pt(点)属性包含图像中关键点的x和y坐标。size属性指示特征的直径。angle属性指示特征的方向,如先前处理的图像中的径向线所示。response属性指示关键点的强度。 SIFT 将某些特征归类为比其他特征更强,并且response是您要检查以评估特征强度的属性。octave属性指示图像金字塔中找到特征的层。 让我们简要回顾一下图像金字塔的概念,我们在“概念化 Haar 级联”部分的第 5 章,“检测和识别人脸”中进行了讨论。 SIFT 算法以与人脸检测算法相似的方式运行,因为它迭代地处理相同的图像,但是在每次迭代时都会更改输入。 特别地,图像的比例尺是在算法的每次迭代(octave)时都会变化的参数。 因此,octave属性与检测到关键点的图像比例有关。- 最后,
class_id属性可用于将自定义标识符分配给一个关键点或一组关键点。
检测快速 Hessian 特征并提取 SURF 描述符
计算机视觉是计算机科学中一个相对较年轻的分支,因此许多著名的算法和技术只是最近才发明的。 实际上,SIFT 才 21 岁,由 David Lowe 于 1999 年出版。
SURF 是一种特征检测算法,由 Herbert Bay 于 2006 年发布。 SURF 比 SIFT 快几倍,并且部分受其启发。
请注意,SIFT 和 SURF 都是专利算法,因此,仅在使用OPENCV_ENABLE_NONFREE CMake 标志的opencv_contrib构建中可用。
理解 SURF 是如何在后台运行的,与本书没有特别的关系,因为我们可以在应用中使用它并充分利用它。 重要的是要理解的是cv2.SURF是一个 OpenCV 类,它使用 Fast Hessian 算法执行关键点检测并使用 SURF 执行描述符提取,就像cv2.SIFT类使用 DoG 执行关键点检测并使用 SIFT 执行描述符提取。
同样,好消息是 OpenCV 为其所有受支持的特征检测和描述符提取算法提供了标准化的 API。 因此,仅需很小的更改,我们就可以使我们先前的代码示例适应于使用 SURF 而不是 SIFT。 这是修改后的代码,其中的更改以粗体显示:
import cv2
img = cv2.imread('img/varese.jpg')
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
surf = cv2.xfeatures2d.SURF_create(8000)
keypoints, descriptor = surf.detectAndCompute(gray, None)
cv2.drawKeypoints(img, keypoints, img, (51, 163, 236),
cv2.DRAW_MATCHES_FLAGS_DRAW_RICH_KEYPOINTS)
cv2.imshow('surf_keypoints', img)
cv2.waitKey()
cv2.xfeatures2d.SURF_create的参数是 Fast Hessian 算法的阈值。 通过增加阈值,我们可以减少将保留的特征数量。 阈值为8000,我们得到以下结果:
尝试调整阈值以查看其如何影响结果。 作为练习,您可能希望使用控制阈值的滑块构建 GUI 应用。 这样,用户可以调整阈值并查看特征数量以反比例的方式增加和减少。 我们在第 4 章,“深度估计和分段”中使用滑块构建了 GUI 应用,在“使用普通摄像机的深度估计”中,因此您可能需要回到并参考该部分作为指导。
接下来,我们将检查 FAST 角点检测器,BRIEF 关键点描述符和 ORB(将 FAST 和 BRIEF 一起使用)。
将 ORB 与 FAST 特征和 BRIEF 描述符一起使用
如果 SIFT 年龄较小,而 SURF 年龄较小,则 ORB 处于婴儿期。 ORB 于 2011 年首次发布,是 SIFT 和 SURF 的快速替代方案。
该算法已发表在论文《ORB:SIFT 或 SURF 的有效替代品》中,可通过 PDF 格式在这个页面中找到。
ORB 混合了 FAST 关键点检测器和 BRIEF 关键点描述符中使用的技术,因此值得快速了解 FAST 和 BRIEF。 然后,我们将讨论暴力匹配-一种用于特征匹配的算法-并查看特征匹配的示例。
FAST
来自加速段测试的特征(FAST)算法通过分析 16 个像素的圆形邻域来工作。 它将邻近区域中的每个像素标记为比特定阈值更亮或更暗,该特定阈值是相对于圆心定义的。 如果邻域包含多个标记为亮或暗的连续像素,则认为该邻域是一个角。
FAST 还使用高速测试,有时仅检查 2 或 4 个像素(而不是 16 个像素)就可以确定邻域不是角点。要了解该测试的工作原理,请看下面的图表 OpenCV 文档:
在这里,我们可以看到两个不同放大倍数的 16 像素邻域。 位置 1、5、9 和 13 处的像素对应于圆形邻域边缘处的四个基点。 如果邻域是一个角,我们希望在这四个像素中,三个或恰好一个比阈值亮。 (另一种说法是,正好一个或正好三个都比阈值暗。)如果正好两个都比阈值亮,那么我们有一个边缘,而不是一个角。 如果其中恰好有四个或恰好零个比阈值亮,那么我们有一个相对统一的邻域,既不是角点也不是边缘。
FAST 是一种聪明的算法,但并非没有缺点,为了弥补这些缺点,分析图像的开发人员可以实现机器学习方法,以便将一组图像(与给定应用相关)馈送到算法中,以便优化类似阈值的参数。 无论开发人员直接指定参数还是为机器学习方法提供训练集,FAST 都是一种对开发人员的输入敏感的算法,可能比 SIFT 更为敏感。
BRIEF
另一方面,二进制鲁棒独立基本特征(BRIEF)不是特征检测算法,而是描述符。 让我们更深入地了解描述符是什么的概念,然后看一下 BRIEF。
当我们先前使用 SIFT 和 SURF 分析图像时,整个过程的核心是对detectAndCompute函数的调用。 该函数执行两个不同的步骤-检测和计算-并且它们返回两个不同的结果,并以元组为单位。
检测的结果是一组关键点。 计算的结果是这些关键点的一组描述符。 这意味着 OpenCV 的cv2.SIFT和cv2.SURF类实现用于检测和描述的算法。 但是请记住,原始的 SIFT 和 SURF 不是特征检测算法。 OpenCV 的cv2.SIFT实现了 DoG 特征检测和 SIFT 描述,而 OpenCV 的cv2.SURF实现了快速黑森特征检测和 SURF 描述。
关键点描述符是图像的表示形式,可以用作特征匹配的网关,因为您可以比较两个图像的关键点描述符并找到共同点。
BRIEF 是当前可用的最快的描述符之一。 BRIEF 背后的理论非常复杂,但可以说 BRIEF 采用了一系列优化,使其成为特征匹配的很好选择。
暴力匹配
暴力匹配器是描述符匹配器,它比较两组关键点描述符并生成结果,该结果是匹配项列表。 之所以称为暴力是因为该算法几乎没有优化。 对于第一组中的每个关键点描述符,匹配器将与第二组中的每个关键点描述符进行比较。 每次比较都会产生一个距离值,并且可以根据最小距离选择最佳匹配。
更一般而言,在计算中,术语暴力与优先使用所有可能组合(例如,所有可能的字符组合以破解已知长度的密码)的方法相关联。 相反,优先考虑速度的算法可能会跳过某些可能性,并尝试采用一条捷径来解决似乎最合理的解决方案。
OpenCV 提供了cv2.BFMatcher类,该类支持多种用于暴力特征匹配的方法。
在两个图像中匹配徽标
现在我们对 FAST 和 BRIEF 有了一个大致的了解,我们可以理解为什么 ORB 背后的团队(由 Ethan Rublee,Vincent Rabaud,Kurt Konolige 和 Gary R. Bradski 组成)选择这两种算法作为 ORB 的基础。
在他们的论文中,作者旨在实现以下结果:
- 在 FAST 中添加了快速准确的定位组件
- 定向 BRIEF 特征的有效计算
- 定向 BRIEF 特征的方差和相关性分析
- 一种在旋转不变性下解相关简短特征的学习方法,从而在最近邻应用中获得更好的表现
要点很明确:ORB 旨在优化和加速操作,包括非常重要的步骤,即以旋转感知的方式使用 BRIEF,以便即使在训练图像与旋转图像有很大不同的情况下,也可以改善匹配度。 查询图片。
不过,在这个阶段,也许您已经掌握了足够的理论,并且想深入了解某些特征匹配,所以让我们看一些代码。 以下脚本尝试将徽标中的特征与包含徽标的照片中的特征进行匹配:
import cv2
from matplotlib import pyplot as plt
# Load the images.
img0 = cv2.imread('img/nasa_logo.png',
cv2.IMREAD_GRAYSCALE)
img1 = cv2.imread('img/kennedy_space_center.jpg',
cv2.IMREAD_GRAYSCALE)
# Perform ORB feature detection and description.
orb = cv2.ORB_create()
kp0, des0 = orb.detectAndCompute(img0, None)
kp1, des1 = orb.detectAndCompute(img1, None)
# Perform brute-force matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des0, des1)
# Sort the matches by distance.
matches = sorted(matches, key=lambda x:x.distance)
# Draw the best 25 matches.
img_matches = cv2.drawMatches(
img0, kp0, img1, kp1, matches[:25], img1,
flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# Show the matches.
plt.imshow(img_matches)
plt.show()
让我们逐步检查该代码。 常规导入后,我们以灰度格式加载两个图像(查询图像和场景)。 这是查询图像,它是 NASA 徽标:
这是肯尼迪航天中心的现场照片:
现在,我们继续创建 ORB 特征检测器和描述符:
# Perform ORB feature detection and description.
orb = cv2.ORB_create()
kp0, des0 = orb.detectAndCompute(img0, None)
kp1, des1 = orb.detectAndCompute(img1, None)
以与 SIFT 和 SURF 相似的方式,我们检测并计算两个图像的关键点和描述符。
从这里开始,概念非常简单:遍历描述符并确定它们是否匹配,然后计算该匹配的质量(距离)并对匹配进行排序,以便我们可以显示顶部的n确实可以匹配两个图像上的特征,因此具有一定的可信度。 cv2.BFMatcher为我们做到这一点:
# Perform brute-force matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=True)
matches = bf.match(des0, des1)
# Sort the matches by distance.
matches = sorted(matches, key=lambda x:x.distance)
在这个阶段,我们已经拥有了所需的所有信息,但是作为计算机视觉爱好者,我们非常重视视觉表示数据,因此让我们在matplotlib图表中绘制这些匹配项:
# Draw the best 25 matches.
img_matches = cv2.drawMatches(
img0, kp0, img1, kp1, matches[:25], img1,
flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# Show the matches.
plt.imshow(img_matches)
plt.show()
Python 的切片语法非常强大。 如果matches列表包含少于 25 个条目,则matches[:25]切片命令将毫无问题地运行,并为我们提供一个包含与原始元素一样多的元素的列表。
结果如下:
您可能会认为这是令人失望的结果。 确实,我们可以看到大多数匹配项都是错误的匹配项。 不幸的是,这是很典型的。 为了改善结果,我们需要应用其他技术来滤除不良匹配。 接下来,我们将注意力转移到此任务上。
使用 K 最近邻和比率测试过滤匹配
想象一下,一大批著名的哲学家要您对有关生命,宇宙和一切至关重要的问题进行辩论。 您在每个哲学家轮流讲话时会仔细听。 最后,当所有哲学家用尽了所有论点之后,您便会回顾自己的笔记并意识到以下两点:
- 每个哲学家都不同意
- 没有一个哲学家比其他哲学家更具说服力
从您的第一个观察中,您可以推断出最多一个哲学家是正确的; 但是,所有哲学家都有可能犯错。 然后,从第二个观察中,您开始担心自己有可能选择错误的哲学家,即使其中一位哲学家是正确的。 不管您怎么看,这些人都让您陷入僵局。 您称其为平局,说辩论中最重要的问题仍未解决。
我们可以将判断哲学家辩论的假想问题与排除不良关键点匹配的实际问题进行比较。
首先,我们假设查询图像中的每个关键点在场景中最多只有一个正确的匹配项。 暗示地,如果我们的查询图像是 NASA 徽标,则我们假设另一幅图像-场景-最多包含一个 NASA 徽标。 假设查询关键点最多只有一个正确或良好的匹配项,所以当我们考虑所有可能的匹配项时,我们主要观察到错误的匹配项。 因此,暴力匹配器会为每个可能的匹配计算一个距离得分,可以使我们对不良匹配的距离得分有很多观察。 我们期望良好的比赛比许多不良的比赛具有更好的(较低)距离得分,因此不良比赛的得分可以帮助我们选择良好比赛的门槛。 这样的阈值不一定能在不同的查询关键点或不同的场景之间很好地概括,但至少在个案的基础上可以帮助我们。
现在,让我们考虑一种改进的暴力匹配算法的实现,该算法以我们描述的方式自适应地选择距离阈值。 在上一节的代码示例中,我们使用cv2.BFMatcher类的match方法来获取包含每个查询关键点的单个最佳(最小距离)匹配的列表。 这样,我们就丢弃了所有可能更差的比赛的距离得分的信息,这是我们采用自适应方法所需的信息。 幸运的是,cv2.BFMatcher还提供了knnMatch方法,该方法接受参数k,该参数指定我们要为每个查询关键点保留的最佳(最小距离)匹配的最大数目。 (在某些情况下,我们得到的匹配数可能少于最大值)。KNN 代表 K 最近邻。
我们将使用knnMatch方法为每个查询关键点请求两个最佳匹配的列表。 基于我们的假设,即每个查询关键点最多具有一个正确的匹配项,因此我们确信第二好的匹配项是错误的。 我们将次优匹配的距离得分乘以小于 1 的值以获得阈值。
然后,仅当其远处分数小于阈值时,我们才将最佳匹配视为良好匹配。 这种方法称为比率测试,最早由 SIFT 算法的作者 David Lowe 提出。 他在论文《比例不变关键点中的独特图像特征》中描述了比率测试,可从这个页面。 具体来说,在“对象识别”的应用部分中,他指出:
“匹配正确的可能性可以通过计算从最近邻到第二次邻居的距离之比来确定。”
我们可以像上一节代码示例中一样的方式加载图像,检测关键点并计算 ORB 描述符。 然后,我们可以使用以下两行代码执行暴力 KNN 匹配:
# Perform brute-force KNN matching.
bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False)
pairs_of_matches = bf.knnMatch(des0, des1, k=2)
knnMatch返回列表列表; 每个内部列表至少包含一个匹配项,并且不超过k个匹配项,从最佳(最短距离)到最差排序。 以下代码行根据最佳匹配的距离得分对外部列表进行排序:
# Sort the pairs of matches by distance.
pairs_of_matches = sorted(pairs_of_matches, key=lambda x:x[0].distance)
让我们画出前 25 个最佳比赛,以及knnMatch可能与之配对的次佳比赛。 我们无法使用cv2.drawMatches函数,因为它仅接受一维匹配项列表; 相反,我们必须使用cv2.drawMatchesKnn。 以下代码用于选择,绘制和显示匹配项:
# Draw the 25 best pairs of matches.
img_pairs_of_matches = cv2.drawMatchesKnn(
img0, kp0, img1, kp1, pairs_of_matches[:25], img1,
flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# Show the pairs of matches.
plt.imshow(img_pairs_of_matches)
plt.show()
到目前为止,我们还没有过滤掉任何不正确的比赛-实际上,我们故意包括了第二好的比赛,我们认为这是糟糕的-因此结果看起来很混乱。 这里是:
现在,让我们应用比率测试。 我们将阈值设置为第二好的比赛的距离得分的 0.8 倍。 如果knnMatch无法提供次佳的比赛,我们仍然会拒绝最佳比赛,因为我们无法应用测试。 以下代码适用于这些条件,并为我们提供了通过测试的最佳匹配项列表:
# Apply the ratio test.
matches = [x[0] for x in pairs_of_matches
if len(x) > 1 and x[0].distance < 0.8 * x[1].distance]
应用了比率测试之后,现在我们仅处理最佳匹配(而不是最佳匹配和次佳匹配对),因此我们可以使用cv2.drawMatches而不是cv2.drawMatchesKnn来绘制它们。 同样,我们将从列表中选择前 25 个匹配项。 以下代码用于选择,绘制和显示匹配项:
# Draw the best 25 matches.
img_matches = cv2.drawMatches(
img0, kp0, img1, kp1, matches[:25], img1,
flags=cv2.DRAW_MATCHES_FLAGS_NOT_DRAW_SINGLE_POINTS)
# Show the matches.
plt.imshow(img_matches)
plt.show()
在这里,我们可以看到通过比率测试的匹配项:
将输出图像与上一节中的图像进行比较,我们可以看到 KNN 和比率测试使我们能够过滤掉许多不良匹配项。 其余比赛并不完美,但几乎所有比赛都指向正确的区域-肯尼迪航天中心侧面的 NASA 徽标。
我们已经有了良好的开端。 接下来,我们将使用名为 FLANN 的更快的匹配器替换暴力匹配器。 之后,我们将学习如何用单应性来描述一组匹配项-即二维变换矩阵,该矩阵表示匹配对象的位置,旋转,比例和其他几何特征。
使用 FLANN 的匹配
FLANN 代表用于近似最近邻的快速库。 根据许可的 2 条款 BSD 许可,这是一个开源库。 FLANN 的官方互联网主页是这个页面。 以下是该网站的报价:
“FLANN 是一个用于在高维空间中执行快速近似最近邻搜索的库。它包含我们发现最适合最近邻搜索的算法集合,以及一个根据数据集自动选择最佳算法和最佳参数的系统
FLANN 用 C++ 编写,并且包含以下语言的绑定:C,MATLAB 和 Python。”
换句话说,FLANN 有一个很大的工具箱,它知道如何为工作选择正确的工具,并且会说几种语言。 这些功能使资料库快速便捷。 实际上,FLANN 的作者声称,对于许多数据集而言,它比其他最近邻搜索软件快 10 倍。
作为独立的库,可以在 GitHub 上找到 FLANN。 但是,我们将 FLANN 用作 OpenCV 的一部分,因为 OpenCV 为此提供了一个方便的包装器。
为了开始我们的 FLANN 匹配的实际示例,让我们导入 NumPy,OpenCV 和 Matplotlib,并从文件中加载两个图像。 以下是相关代码:
import numpy as np
import cv2
from matplotlib import pyplot as plt
img0 = cv2.imread('img/gauguin_entre_les_lys.jpg',
cv2.IMREAD_GRAYSCALE)
img1 = cv2.imread('img/gauguin_paintings.png',
cv2.IMREAD_GRAYSCALE)
这是我们的脚本正在加载的第一张图像-查询图像:
此艺术品是保罗·高更(Paul Gauguin)在 1889 年绘制的《Entre les lys》(在百合花中)。我们将在包含高更的多幅作品的较大图像中搜索匹配的关键点, 本书的一位作者绘制的一些杂乱无章的形状。 这是更大的图像:
在较大的图像中,《Entre les lys》出现在第三列第三行中。 查询图像和较大图像的对应区域不相同; 他们以略微不同的颜色和不同的比例描绘了《Entre les lys》。 但是,对于我们的匹配器来说,这应该是一个简单的例子。
让我们检测必要的关键点并使用cv2.SIFT类提取特征:
# Perform SIFT feature detection and description.
sift = cv2.xfeatures2d.SIFT_create()
kp0, des0 = sift.detectAndCompute(img0, None)
kp1, des1 = sift.detectAndCompute(img1, None)
到目前为止,该代码应该看起来很熟悉,因为我们已经在本章的几个部分中专门介绍了 SIFT 和其他描述符。 在前面的示例中,我们将描述符提供给cv2.BFMatcher以进行暴力匹配。 这次,我们将改为使用cv2.FlannBasedMatcher。 以下代码使用自定义参数执行基于 FLANN 的匹配:
# Define FLANN-based matching parameters.
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)
# Perform FLANN-based matching.
flann = cv2.FlannBasedMatcher(index_params, search_params)
matches = flann.knnMatch(des0, des1, k=2)
在这里,我们可以看到 FLANN 匹配器采用两个参数:indexParams对象和searchParams对象。 这些参数在 Python 中以字典的形式(在 C++ 中以结构的形式)传递,确定了索引的行为以及搜索对象,FLANN 在内部使用这些对象来计算匹配项。 我们选择的参数在精度和处理速度之间取得合理的平衡。 具体来说,我们使用核密度树(kd-tree)索引算法,其中有五棵树,FLANN 可以并行处理。 (FLANN 文档建议在一棵不提供并行性的树和 16 棵树之间(如果系统可以利用它,则可以提供高度的并行性)。)
我们正在对每棵树执行 50 次检查或遍历。 数量更多的支票可以提供更高的准确率,但计算成本更高。
在执行基于 FLANN 的匹配后,我们使用系数为 0.7 的 Lowe 比率测试。 为了演示不同的编码风格,我们将使用比率测试的结果与上一节代码示例中的结果稍有不同。 以前,我们组装了一个仅包含良好匹配项的新列表。 这次,我们将组装一个名为mask_matches的列表,其中每个元素都是长度为k的子列表(与传递给knnMatch的k相同)。 如果匹配良好,则将子列表的相应元素设置为1; 否则,我们将其设置为0。
例如,如果我们有mask_matches = [[0, 0], [1, 0]],则意味着我们有两个匹配的关键点; 对于第一个关键点,最佳匹配和次佳匹配都不好,而对于第二个关键点,最佳匹配很好,但次佳匹配不好。 记住,我们假设所有次佳的比赛都是不好的。 我们使用以下代码进行比率测试并构建遮罩:
# Prepare an empty mask to draw good matches.
mask_matches = [[0, 0] for i in range(len(matches))]
# Populate the mask based on David G. Lowe's ratio test.
for i, (m, n) in enumerate(matches):
if m.distance < 0.7 * n.distance:
mask_matches[i]=[1, 0]
现在,是时候绘制并显示良好的匹配。 我们可以将mask_matches列表作为可选参数传递给cv2.drawMatchesKnn,如以下代码段中的粗体所示:
# Draw the matches that passed the ratio test.
img_matches = cv2.drawMatchesKnn(
img0, kp0, img1, kp1, matches, None,
matchColor=(0, 255, 0), singlePointColor=(255, 0, 0),
matchesMask=mask_matches, flags=0)
# Show the matches.
plt.imshow(img_matches)
plt.show()
cv2.drawMatchesKnn仅在遮罩中绘制我们标记为良好的匹配项(值为1)。 让我们揭晓结果。 我们的脚本对基于 FLANN 的匹配产生以下可视化效果:
这是令人鼓舞的情况:看来几乎所有比赛都在正确的位置。 接下来,让我们尝试将这种类型的结果简化为更简洁的几何表示法-单应性法-它可以描述整个匹配对象的姿态,而不是一堆断开的匹配点的姿态。
通过基于 FLANN 的匹配执行单应性
首先,什么是单应性? 让我们从互联网上阅读一个定义:
“两个图形之间的关系,使得一个图形的任意一点对应一个图形,而另一图形又对应一个图形,反之亦然。因此,在圆上滚动的切线将圆的两个固定切线切成同形的两组点。”
如果您-像本书的作者一样-不是前面定义的明智者,您可能会发现以下解释更清楚:单应性是一种条件,即当一个图是另一个图的透视变形时,两个图会互相发现 。
首先,让我们看一下我们要实现的目标,以便我们可以完全理解单应性。 然后,我们将遍历代码。
假设我们要搜索以下纹身:
尽管存在旋转差异,但作为人类,我们可以轻松地在下图中找到纹身:
作为计算机视觉中的一项练习,我们想编写一个脚本,以产生以下关键点匹配和单应性的可视化效果:
如前面的屏幕快照所示,我们在第一幅图像中拍摄了对象,在第二幅图像中正确地识别了该对象,在关键点之间绘制了匹配线,甚至绘制了一个白色边框,显示了第二幅图像中对象相对于第一张图片的视角的变形。
您可能已经正确地猜到了脚本的实现是通过导入库,读取灰度格式的图像,检测特征以及计算 SIFT 描述符开始的。 我们在前面的示例中做了所有这些操作,因此在此将其省略。 让我们看一下接下来的操作:
- 我们通过组装通过 Lowe 比率测试的匹配项列表来进行操作,如以下代码所示:
# Find all the good matches as per Lowe's ratio test.
good_matches = []
for m, n in matches:
if m.distance < 0.7 * n.distance:
good_matches.append(m)
- 从技术上讲,我们可以通过最少四个匹配来计算单应性。 但是,如果这四个匹配项中的任何一个有缺陷,都会降低结果的准确率。 更实用的最小值是
10。 给定额外的匹配项,单应性查找算法可以丢弃一些离群值,以产生与匹配项的实质子集非常契合的结果。 因此,我们继续检查我们是否至少有10个良好匹配项:
MIN_NUM_GOOD_MATCHES = 10
if len(good_matches) >= MIN_NUM_GOOD_MATCHES:
- 如果满足此条件,我们将查找匹配的关键点的 2D 坐标,并将这些坐标放置在两个浮点坐标对列表中。 一个列表包含查询图像中的关键点坐标,而另一个列表包含场景中匹配的关键点坐标:
src_pts = np.float32(
[kp0[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
dst_pts = np.float32(
[kp1[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
- 现在,我们找到单应性:
M, mask = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 5.0)
mask_matches = mask.ravel().tolist()
请注意,我们创建了一个mask_matches列表,该列表将在比赛的最终绘图中使用,以便仅在单应图中的点才会绘制出匹配线。
- 在这一阶段,我们必须执行透视变换,该变换将查询图像的矩形角投影到场景中,以便绘制边框:
h, w = img0.shape
src_corners = np.float32(
[[0, 0], [0, h-1], [w-1, h-1], [w-1, 0]]).reshape(-1, 1, 2)
dst_corners = cv2.perspectiveTransform(src_corners, M)
dst_corners = dst_corners.astype(np.int32)
# Draw the bounds of the matched region based on the homography.
num_corners = len(dst_corners)
for i in range(num_corners):
x0, y0 = dst_corners[i][0]
if i == num_corners - 1:
next_i = 0
else:
next_i = i + 1
x1, y1 = dst_corners[next_i][0]
cv2.line(img1, (x0, y0), (x1, y1), 255, 3, cv2.LINE_AA)
然后,按照前面的示例,我们继续绘制关键点并显示可视化效果。
示例应用–纹身取证
让我们以一个真实的(或者也许是幻想的)例子作为本章的结尾。 假设您正在哥谭法医部门工作,并且需要识别纹身。 您拥有罪犯纹身的原始图片(也许是在闭路电视录像中捕获的),但您不知道该人的身份。 但是,您拥有一个纹身数据库,该数据库以纹身所属的人的名字为索引。
让我们将此任务分为两个部分:
- 通过将图像描述符保存到文件来构建数据库
- 加载数据库并扫描查询图像的描述符和数据库中的描述符之间的匹配项
我们将在接下来的两个小节中介绍这些任务。
将图像描述符保存到文件
我们要做的第一件事是将图像描述符保存到外部文件中。 这样,我们不必每次想要扫描两个图像以进行匹配时都重新创建描述符。
就我们的示例而言,让我们扫描文件夹中的图像并创建相应的描述符文件,以便我们可以随时使用它们以供将来搜索。 为了创建描述符,我们将使用本章已经使用过多次的过程:即加载图像,创建特征检测器,检测特征并计算描述符。 要将描述符保存到文件中,我们将使用方便的 NumPy 数组方法save,该方法以优化的方式将数组数据转储到文件中。
Python 标准库中的pickle模块提供了更多通用的序列化功能,该功能支持任何 Python 对象,而不仅仅是 NumPy 数组。 但是,NumPy 的数组序列化是数字数据的不错选择。
让我们将脚本分解为函数。 主要函数将命名为create_descriptors(复数,描述符),它将遍历给定文件夹中的文件。 对于每个文件,create_descriptors将调用一个名为create_descriptor的帮助器函数(单数,描述符),该函数将为给定的图像文件计算并保存我们的描述符。 让我们开始吧:
- 首先,这是
create_descriptors的实现:
import os
import numpy as np
import cv2
def create_descriptors(folder):
feature_detector = cv2.xfeatures2d.SIFT_create()
files = []
for (dirpath, dirnames, filenames) in os.walk(folder):
files.extend(filenames)
for f in files:
create_descriptor(folder, f, feature_detector)
请注意,create_descriptors创建了特征检测器,因为我们只需要执行一次,而不是每次加载文件时都执行一次。 辅助函数create_descriptor接收特征检测器作为参数。
- 现在,让我们看一下后一个函数的实现:
def create_descriptor(folder, image_path, feature_detector):
if not image_path.endswith('png'):
print('skipping %s' % image_path)
return
print('reading %s' % image_path)
img = cv2.imread(os.path.join(folder, image_path),
cv2.IMREAD_GRAYSCALE)
keypoints, descriptors = feature_detector.detectAndCompute(
img, None)
descriptor_file = image_path.replace('png', 'npy')
np.save(os.path.join(folder, descriptor_file), descriptors)
请注意,我们将描述符文件与图像保存在同一文件夹中。 此外,我们假设图像文件具有png扩展名。 为了使脚本更加鲁棒,可以对其进行修改,使其支持其他图像文件扩展名,例如jpg。 如果文件具有意外扩展名,我们将其跳过,因为它可能是描述符文件(来自脚本的先前运行)或其他一些非映像文件。
- 我们已经完成了函数。 为了完成脚本,我们将使用文件夹名称作为参数来调用
create_descriptors:
folder = 'tattoos'
create_descriptors(folder)
当我们运行此脚本时,它将以 NumPy 的数组文件格式生成必要的描述符文件,文件扩展名为npy。 这些文件构成我们的纹身描述符数据库,按名称索引。 (每个文件名都是一个人的名字。)接下来,我们将编写一个单独的脚本,以便可以对该数据库运行查询。
扫描描述符
现在,我们已将描述符保存到文件中,我们只需要对每组描述符进行匹配,以确定哪一组与我们的查询图像最匹配。
这是我们将执行的过程:
- 加载查询图像(
query.png)。 - 扫描包含描述符文件的文件夹。 打印描述符文件的名称。
- 为查询图像创建 SIFT 描述符。
- 对于每个描述符文件,加载 SIFT 描述符并查找基于 FLANN 的匹配项。 根据比率测试过滤匹配项。 打印此人的姓名和匹配数。 如果匹配数超过任意阈值,请打印此人是可疑人员。 (请记住,我们正在调查犯罪。)
- 打印主要嫌疑人的名字(匹配次数最多的人)。
让我们考虑一下实现:
- 首先,以下代码块加载查询图像:
import os
import numpy as np
import cv2
# Read the query image.
folder = 'tattoos'
query = cv2.imread(os.path.join(folder, 'query.png'),
cv2.IMREAD_GRAYSCALE)
- 我们继续组装并打印描述符文件列表:
# create files, images, descriptors globals
files = []
images = []
descriptors = []
for (dirpath, dirnames, filenames) in os.walk(folder):
files.extend(filenames)
for f in files:
if f.endswith('npy') and f != 'query.npy':
descriptors.append(f)
print(descriptors)
- 我们设置了典型的
cv2.SIFT和cv2.FlannBasedMatcher对象,并生成了查询图像的描述符:
# Create the SIFT detector.
sift = cv2.xfeatures2d.SIFT_create()
# Perform SIFT feature detection and description on the
# query image.
query_kp, query_ds = sift.detectAndCompute(query, None)
# Define FLANN-based matching parameters.
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = dict(checks=50)
# Create the FLANN matcher.
flann = cv2.FlannBasedMatcher(index_params, search_params)
- 现在,我们搜索嫌疑犯,我们将其定义为查询纹身至少具有 10 个良好匹配项的人。 我们的搜索需要遍历描述符文件,加载描述符,执行基于 FLANN 的匹配以及根据比率测试过滤匹配。 我们为每个人(每个描述符文件)打印结果:
# Define the minimum number of good matches for a suspect.
MIN_NUM_GOOD_MATCHES = 10
greatest_num_good_matches = 0
prime_suspect = None
print('>> Initiating picture scan...')
for d in descriptors:
print('--------- analyzing %s for matches ------------' % d)
matches = flann.knnMatch(
query_ds, np.load(os.path.join(folder, d)), k=2)
good_matches = []
for m, n in matches:
if m.distance < 0.7 * n.distance:
good_matches.append(m)
num_good_matches = len(good_matches)
name = d.replace('.npy', '').upper()
if num_good_matches >= MIN_NUM_GOOD_MATCHES:
print('%s is a suspect! (%d matches)' % \
(name, num_good_matches))
if num_good_matches > greatest_num_good_matches:
greatest_num_good_matches = num_good_matches
prime_suspect = name
else:
print('%s is NOT a suspect. (%d matches)' % \
(name, num_good_matches))
请注意np.load方法的使用,该方法会将指定的NPY文件加载到 NumPy 数组中。
- 最后,我们打印主要嫌疑人的姓名(如果找到嫌疑人,则为):
if prime_suspect is not None:
print('Prime suspect is %s.' % prime_suspect)
else:
print('There is no suspect.')
运行前面的脚本将产生以下输出:
>> Initiating picture scan...
--------- analyzing anchor-woman.npy for matches ------------
ANCHOR-WOMAN is NOT a suspect. (2 matches)
--------- analyzing anchor-man.npy for matches ------------
ANCHOR-MAN is a suspect! (44 matches)
--------- analyzing lady-featherly.npy for matches ------------
LADY-FEATHERLY is NOT a suspect. (2 matches)
--------- analyzing steel-arm.npy for matches ------------
STEEL-ARM is NOT a suspect. (0 matches)
--------- analyzing circus-woman.npy for matches ------------
CIRCUS-WOMAN is NOT a suspect. (1 matches)
Prime suspect is ANCHOR-MAN.
如果需要,我们可以像上一节中那样以图形方式表示比赛和单应性。
总结
在本章中,我们学习了有关检测关键点,计算关键点描述符,匹配这些描述符,滤除不匹配项以及查找两组匹配的关键点之间的单应性的方法。 我们探索了 OpenCV 中可以用于完成这些任务的多种算法,并将这些算法应用于各种图像和用例。
如果我们将关键点的新知识与有关相机和透视图的其他知识相结合,则可以跟踪 3D 空间中的对象。 这将是第 9 章,“相机模型和增强现实”的主题。 如果您特别想达到第三个维度,则可以跳到该章。
相反,如果您认为下一步的逻辑步骤是完善对对象检测,识别和跟踪的二维解决方案的了解,则可以按顺序继续进行第 7 章,“构建自定义对象检测器”,然后是第 8 章,“跟踪对象”。 最好了解 2D 和 3D 的组合技术,以便为给定的应用选择一种提供正确的输出种类和正确的计算速度的方法。
七、建立自定义对象检测器
本章将深入研究对象检测的概念,这是计算机视觉中最常见的挑战之一。 在本书中走到这一步,您也许想知道什么时候可以在街头实践计算机视觉。 您是否梦想建立一个检测汽车和人员的系统? 好吧,实际上,您离目标不算太远。
在前面的章节中,我们已经研究了对象检测和识别的一些特定情况。 在第 5 章,“检测和识别人脸”中,我们专注于直立的正面人脸;在第 6 章,“检索图像并使用图像描述符进行搜索”中,我们研究了具有角点或斑点状特征的物体。 现在,在本章中,我们将探索具有良好泛化或外推能力的算法,从某种意义上说,它们可以应对给定对象类别中存在的现实世界的多样性。 例如,不同的汽车具有不同的设计,并且人们可能会根据所穿的衣服而呈现出不同的形状。
具体来说,我们将追求以下目标:
- 了解另一种特征描述符:定向梯度描述符直方图(HOG)。
- 了解非最大抑制,也称为非最大抑制(NMS),这有助于我们从重叠的检测窗口集中选择最佳。
- 对支持向量机(SVM)有较高的了解。 这些通用分类器基于有监督的机器学习,类似于线性回归。
- 使用基于 HOG 描述符的预训练分类器检测人员。
- 训练词袋(BoW)分类器以检测汽车。 对于此示例,我们将使用图像金字塔,滑动窗口和 NMS 的自定义实现,以便我们可以更好地了解这些技术的内部工作原理。
本章中的大多数技术都不是互斥的。 相反,它们作为检测器的组件一起工作。 在本章结束时,您将知道如何训练和使用在大街上有实际应用的分类器!
技术要求
本章使用 Python,OpenCV 和 NumPy。 请参考第 1 章,“设置 OpenCV”,以获得安装说明。
可在本书的 GitHub 存储库中找到本章的完整代码, 在chapter07文件夹中。 样本图像可以在images文件夹的存储库中找到。
了解 HOG 描述符
HOG 是一种特征描述符,因此它与尺度不变特征变换(SIFT),加速鲁棒特征(SURF)和定向 FAST 和旋转 BRIEF(ORB),我们在第 6 章“检索图像和使用图像描述符进行搜索”中介绍了此方法。 像其他特征描述符一样,HOG 能够传递对于特征匹配以及对象检测和识别至关重要的信息类型。 最常见的是,HOG 用于对象检测。 Navneet Dalal 和 Bill Triggs 在他们的论文《面向人类检测的梯度梯度直方图》(INRIA,2005)上普及了该算法,尤其是将其用作人体检测器。
HOG 的内部机制确实很聪明; 将图像分为多个单元,并为每个单元计算一组梯度。 每个梯度描述了给定方向上像素强度的变化。 这些梯度一起形成了单元格的直方图表示。 当我们在第 5 章,“检测和识别人脸”中使用局部二进制模式直方图(LBPH)研究人脸识别时,遇到了类似的方法。
在深入探讨 HOG 的工作原理的技术细节之前,让我们先看一下 HOG 如何看待世界。
可视化 HOG
Carl Vondrick,Aditya Khosla,Hamed Pirsiavash,Tomasz Malisiewicz 和 Antonio Torralba 开发了一种称为 HOGgles(HOG 护目镜)的 HOG 可视化技术。 有关 HOGgles 的摘要以及代码和出版物的链接,请参见 Carl Vondrick 的 MIT 网页。 作为他们的测试图像之一,Vondrick 等。 使用以下卡车图片:
Vondrick 等。 基于 Dalal 和 Triggs 早期论文的方法,产生了 HOG 描述符的以下可视化:
然后,应用 HOGgles,Vondrick 等。 反转特征描述算法,以按照 HOG 的角度重建卡车的图像,如下所示:
在这两种可视化中,您都可以看到 HOG 已将图像分为多个单元格,并且可以轻松识别出车轮和车辆的主要结构。 在第一个可视化中,每个单元格计算出的梯度显示为一组纵横交错的线,有时看起来像是细长的星星; 恒星的长轴代表更强的梯度。 在第二个可视化中,将梯度显示为沿单元格中各个轴的亮度平滑过渡。
现在,让我们进一步考虑 HOG 的工作方式,以及它对物体检测解决方案的贡献。
使用 HOG 描述图像区域
对于每个 HOG 单元,直方图包含的箱子数量等于梯度的数量,换句话说,就是 HOG 考虑的轴方向的数量。 在计算了所有单元的直方图之后,HOG 处理直方图组以生成更高级别的描述符。 具体而言,将单元分为更大的区域,称为块。 这些块可以由任意数量的单元组成,但是 Dalal 和 Triggs 发现2x2单元块在进行人员检测时产生了最佳结果。 创建一个块范围的向量,以便可以对其进行归一化,以补偿照明和阴影的局部变化。 (单个单元的区域太小而无法检测到这种变化。)这种归一化提高了基于 HOG 的检测器相对于光照条件变化的鲁棒性。
像其他探测器一样,基于 HOG 的探测器也需要应对物体位置和比例的变化。 通过在图像上移动固定大小的滑动窗口,可以满足在各种位置进行搜索的需求。 通过将图像缩放到各种大小,从而形成所谓的图像金字塔,可以解决在各种尺度下进行搜索的需求。 我们先前在第 5 章,“检测和识别人脸”中,特别是在“概念化 Haar 级联”部分中研究了这些技术。 但是,让我们详细说明一个困难:如何处理重叠窗口中的多个检测。
假设我们正在使用滑动窗口对图像执行人物检测。 我们以很小的步幅滑动窗口,一次仅滑动几个像素,因此我们希望它可以多次框住任何给定的人。 假设重叠的检测确实是一个人,我们不想报告多个位置,而只是报告一个我们认为正确的位置。 换句话说,即使在给定位置的检测具有良好的置信度得分,如果重叠检测具有更好的的置信度得分,我们可能会拒绝它; 因此,从一组重叠的检测中,我们将选择最佳置信度得分的检测。
这就是 NMS 发挥作用的地方。 给定一组重叠区域,我们可以抑制(或拒绝)分类器未针对其产生最大得分的所有区域。
了解 NMS
NMS 的概念听起来很简单。 从一组重叠的解决方案中,只需选择最佳方案即可! 但是,实现比您最初想象的要复杂。 还记得图像金字塔吗? 重叠检测可以不同的比例发生。 我们必须收集所有的正面检测结果,并在检查重叠之前将其范围重新转换为通用比例。 NMS 的典型实现采用以下方法:
- 构造图像金字塔。
- 使用滑动窗口方法扫描金字塔的每个级别,以进行物体检测。 对于每个产生正面检测的窗口(超过某个任意置信度阈值),请将窗口转换回原始图像的比例。 将窗口及其置信度得分添加到正面检测列表中。
- 按降序的置信度得分对正面检测列表进行排序,以便最佳检测在列表中排在第一位。
- 对于每个窗口,在正面检测列表中,
W,删除所有与W明显重叠的所有后续窗口。 我们只剩下满足 NMS 标准的正面检测列表。
除 NMS 之外,过滤正面检测结果的另一种方法是消除任何子窗口。 当我们说子窗口(或子区域)时,是指完全包含在另一个窗口(或区域)内的窗口(或图像中的区域)。 要检查子窗口,我们只需要比较各种窗口矩形的角坐标。 我们将在第一个实际示例中采用这种简单方法,即“使用 HOG 描述符的人脸检测”部分。 可以选择将 NMS 和子窗口抑制合并在一起。
其中几个步骤是迭代的,因此我们面临着一个有趣的优化问题。 Tomasz Malisiewicz 在这个页面提供了 MATLAB 中的快速示例实现。 Adrian Rosebrock 在这个页面提供了此示例实现的一部分到 Python。 我们将在本章稍后的“在场景中检测汽车”部分的基础上,基于后一个示例。
现在,我们如何确定窗口的置信度得分? 我们需要一个分类系统来确定是否存在某个特征,以及该分类的置信度得分。 这就是 SVM 发挥作用的地方。
了解 SVM
在不讨论 SVM 如何工作的细节的情况下,让我们尝试了解它在机器学习和计算机视觉的背景下可以帮助我们完成哪些工作。 给定带标签的训练数据,SVM 会通过找到最佳超平面来学习对相同类型的数据进行分类,用最简单的英语来说,该超平面是用最大可能的余量划分不同标签数据的平面。 为了帮助我们理解,让我们考虑下图,该图由 Zach Weinberg 在“知识共享署名-相同方式共享 3.0 无端口许可”下提供:
超平面H1(显示为绿线)不划分两类(黑点与白点)。 超平面H2(显示为蓝线)和H3(显示为红线)都划分了类别。 但是,只有超平面H3将类别划分为最大余量。
假设我们正在训练 SVM 作为人员检测器。 我们有两类,人和非人。 作为训练样本,我们提供了包含或不包含人的各种窗口的 HOG 描述符的向量。 这些窗口可能来自各种图像。 SVM 通过找到最佳的超平面来学习,该平面将多维 HOG 描述符空间最大程度地分为人(在超平面的一侧)和非人(在另一侧)。 此后,当我们为训练后的 SVM 提供任何图像中任何其他窗口的 HOG 描述符向量时,SVM 可以判断该窗口是否包含人。 SVM 甚至可以给我们一个与向量到最佳超平面的距离有关的置信度值。
SVM 模型自 1960 年代初就出现了。 但是,此后它就得到了改进,现代 SVM 实现的基础可以在 Corinna Cortes 和 Vladimir Vapnik 的论文《支持向量网络》(《机器学习》,1995 年)中找到 。 可从这个页面获得。
现在,我们对可以组合以构成对象检测器的关键组件有了概念上的理解,我们可以开始看一些示例。 我们将从 OpenCV 的现成对象检测器之一开始,然后我们将继续设计和训练我们自己的自定义对象检测器。
使用 HOG 描述符检测人
OpenCV 带有称为cv2.HOGDescriptor的类,该类能够执行人员检测。 该接口与我们在第 5 章,“检测和识别人脸”中使用的cv2.CascadeClassifier类相似。 但是,与cv2.CascadeClassifier不同,cv2.HOGDescriptor有时会返回嵌套的检测矩形。 换句话说,cv2.HOGDescriptor可能告诉我们它检测到一个人的边界矩形完全位于另一个人的边界矩形内部。 这种情况确实是可能的。 例如,一个孩子可能站在成人的前面,而孩子的边界矩形可能完全在成人的边界矩形内。 但是,在典型情况下,嵌套检测可能是错误,因此cv2.HOGDescriptor通常与代码一起使用,以过滤掉任何嵌套检测。
让我们通过执行测试来确定一个矩形是否嵌套在另一个矩形中来开始示例脚本。 为此,我们将连接一个函数is_inside(i, o),其中i是可能的内部矩形,o是可能的外部矩形。 如果i在o内部,则函数将返回True; 否则,将返回False。 这是脚本的开始:
import cv2
def is_inside(i, o):
ix, iy, iw, ih = i
ox, oy, ow, oh = o
return ix > ox and ix + iw < ox + ow and \
iy > oy and iy + ih < oy + oh
现在,我们创建cv2.HOGDescriptor的实例,并指定它使用 OpenCV 内置的默认人员检测器,方法是运行以下代码:
hog = cv2.HOGDescriptor()
hog.setSVMDetector(cv2.HOGDescriptor_getDefaultPeopleDetector())
请注意,我们使用setSVMDetector方法指定了人员检测器。 希望根据本章前面的内容,这是有道理的。 SVM 是分类器,因此 SVM 的选择决定了我们的cv2.HOGDescriptor将检测到的对象类型。
现在,我们继续加载图像(在这种情况下,是一张在干草地上工作的妇女的老照片),并尝试通过运行以下代码来检测图像中的人:
img = cv2.imread('img/haying.jpg')
found_rects, found_weights = hog.detectMultiScale(
img, winStride=(4, 4), scale=1.02, finalThreshold=1.9)
请注意,cv2.HOGDescriptor具有detectMultiScale方法,该方法返回两个列表:
- 检测到的对象(在这种情况下,检测到的人)的包围矩形的列表。
- 检测到的物体的权重或置信度得分列表。 值越高,表示检测结果正确的可信度越高。
detectMultiScale接受几个可选参数,包括:
winStride:此元组定义了滑动窗口在连续检测尝试之间移动的x和y距离。 HOG 在重叠的窗口中效果很好,因此相对于窗口大小,步幅可能较小。 较小的值将以较高的计算成本产生更多的检测结果。 默认的步幅没有重叠。 它与窗口大小相同,对于默认人物检测器为(64, 128)。scale:此比例因子应用于图像金字塔的连续级别之间。 较小的值将以较高的计算成本产生更多的检测结果。 该值必须大于1.0。 默认值为1.5。finalThreshold:此值确定我们的检测标准有多严格。 较小的值不太严格,导致更多的检测。 默认值为2.0。
现在,我们可以过滤检测结果以删除嵌套的矩形。 为了确定矩形是否为嵌套矩形,我们可能需要将其与其他所有矩形进行比较。 请注意在以下嵌套循环中使用我们的is_inside函数:
found_rects_filtered = []
found_weights_filtered = []
for ri, r in enumerate(found_rects):
for qi, q in enumerate(found_rects):
if ri != qi and is_inside(r, q):
break
else:
found_rects_filtered.append(r)
found_weights_filtered.append(found_weights[ri])
最后,让我们绘制其余的矩形和权重以突出显示检测到的人,然后如下所示并显示此可视化效果:
for ri, r in enumerate(found_rects_filtered):
x, y, w, h = r
cv2.rectangle(img, (x, y), (x + w, y + h), (0, 255, 255), 2)
text = '%.2f' % found_weights_filtered[ri]
cv2.putText(img, text, (x, y - 20),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
cv2.imshow('Women in Hayfield Detected', img)
cv2.imwrite('./women_in_hayfield_detected.jpg', img)
cv2.waitKey(0)
如果您自己运行脚本,则图像中的人周围会看到矩形。 结果如下:
这张照片是彩色摄影的先驱 Sergey Prokudin-Gorsky(1863-1944)作品的另一个例子。 在这里,场景是 1909 年俄罗斯西北部 Leushinskii 修道院的一块田地。
在距离相机最近的六位女性中,有五位被成功检测到。 同时,背景中的一个塔被错误地检测为人。 在许多实际应用中,可以通过分析视频中的一系列帧来改善人员检测结果。 例如,假设我们正在观看 Leushinskii 修道院干草地的监视视频,而不是一张照片。 我们应该能够添加代码来确定该塔不能为人,因为它不会移动。 同样,我们应该能够在其他框架中检测到其他人,并跟踪每个人在框架之间的移动。 我们将在第 8 章,“跟踪对象”中研究人员跟踪问题。
同时,让我们继续研究另一种检测器,我们可以训练该检测器来检测给定类别的对象。
创建和训练对象检测器
使用训练有素的检测器使构建快速原型变得容易,我们都非常感谢 OpenCV 开发人员提供了诸如人脸检测和人物检测之类的有用功能。 但是,无论您是业余爱好者还是计算机视觉专业人员,都不太可能只与人和面孔打交道。
此外,如果您像本书的作者一样,您会想知道“人检测器”是如何首先创建的,以及是否可以改进它。 此外,您可能还想知道是否可以将相同的概念应用于检测从汽车到地精的各种物体。
的确,在行业中,您可能不得不处理检测非常具体的对象的问题,例如车牌,书皮或任何对您的雇主或客户最重要的东西。
因此,问题是,我们如何提出自己的分类器?
有许多流行的方法。 在本章的其余部分中,我们将看到一个答案在于 SVM 和 BoW 技术。
我们已经讨论过 SVM 和 HOG。 现在让我们仔细看看 BoW。
了解 BoW
BoW 是最初不用于计算机视觉的概念; 相反,我们在计算机视觉的背景下使用了该概念的演进版本。 让我们首先讨论一下它的基本版本,正如您可能已经猜到的那样,它最初属于语言分析和信息检索领域。
在计算机视觉的背景下,有时 BoW 被称为视觉词袋(BoVW)。 但是,我们将仅使用术语 BoW,因为这是 OpenCV 使用的术语。
BoW 是一种技术,通过它我们可以为一系列文档中的每个单词分配权重或计数; 然后,我们用这些计数的向量表示这些文档。 让我们来看一个示例,如下所示:
- 文档 1:我喜欢 OpenCV,也喜欢 Python。
- 文档 2:我喜欢 C++ 和 Python。
- 文档 3:我不喜欢洋蓟。
这三个文档使我们能够使用以下值构建字典-也称为码本或词汇表-如下所示:
{
I: 4,
like: 4,
OpenCV: 1,
and: 2,
Python: 2,
C++: 1,
don't: 1,
artichokes: 1
}
我们有八个条目。 现在让我们使用八项向量表示原始文档。 每个向量都包含代表给定文档的字典中所有单词计数的值。 前三个句子的向量表示如下:
[2, 2, 1, 1, 1, 0, 0, 0]
[1, 1, 0, 1, 1, 1, 0, 0]
[1, 1, 0, 0, 0, 0, 1, 1]
这些向量可以概念化为文档的直方图表示形式,也可以概念化为可用于训练分类器的描述符向量。 例如,基于这样的表示,文档可以分类为垃圾邮件或非垃圾邮件。 实际上,垃圾邮件过滤是 BoW 的许多实际应用之一。
既然我们已经掌握了 BoW 的基本概念,那么让我们看一下它如何应用于计算机视觉世界。
将 BoW 应用于计算机视觉
现在,我们已经熟悉了特征和描述符的概念。 我们使用了诸如 SIFT 和 SURF 之类的算法从图像特征中提取描述符,以便我们可以在另一幅图像中匹配这些特征。
最近,我们还熟悉了另一种基于密码本或字典的描述符。 我们知道一个 SVM,该模型可以接受标记的描述符向量作为训练数据,可以找到描述符空间按给定类别的最佳划分,并可以预测新数据的类别。
有了这些知识,我们可以采用以下方法来构建分类器:
- 取得图像的样本数据集。
- 对于数据集中的每个图像,提取描述符(使用 SIFT,SURF,ORB 或类似算法)。
- 将每个描述符向量添加到 BoW 训练器中。
- 将描述符聚类为
k聚类,其中心(质心)是我们的视觉单词。 最后一点听起来可能有些晦涩,但是我们将在下一部分中进一步探讨。
在此过程的最后,我们准备了一个视觉单词词典可供使用。 可以想象,庞大的数据集将使我们的词典中的视觉单词更加丰富。 到现在为止,单词越多越好!
训练完分类器后,我们应该继续对其进行测试。 好消息是测试过程在概念上与前面概述的训练过程非常相似。 给定一个测试图像,我们可以通过计算描述符到质心的距离的直方图来提取描述符并量化它们(或降低其维数)。 基于此,我们可以尝试识别视觉单词,并将其定位在图像中。
这就是本章的要点,在这里,您已经对更深入的实践示例产生了浓厚的兴趣,并且非常喜欢编码。 但是,在继续之前,让我们快速但必要地探讨k-均值聚类的理论,以便您可以完全理解视觉单词的创建方式。 从而,您将更好地了解使用 BoW 和 SVM 进行对象检测的过程。
K 均值聚类
k-均值聚类是一种量化方法,通过此方法,我们分析了大量向量,以找到少量聚类。 给定一个数据集,k代表该数据集将被划分为的群集数。 术语均值是指平均值或平均值的数学概念; 当以视觉方式表示时,群集的均值是其质心或群集中点的几何中心。
聚类是指将数据集中的点分组为聚类的过程。
OpenCV 提供了一个名为cv2.BOWKMeansTrainer的类,我们将使用它来帮助训练我们的分类器。 如您所料,OpenCV 文档提供了此类的以下摘要:
“基于 kmeans 的类,使用词袋方法来训练视觉词汇。”
在进行了长期的理论介绍之后,我们可以看一个示例,然后开始训练我们的自定义分类器。
检测汽车
要训练任何种类的分类器,我们必须首先创建或获取训练数据集。 我们将训练汽车探测器,因此我们的数据集必须包含代表汽车的正样本,以及代表检测器在寻找汽车时可能遇到的其他(非汽车)事物的负样本。 例如,如果检测器旨在搜索街道上的汽车,则路边,人行横道,行人或自行车的图片可能比土星环的图片更具代表性。 除了表示预期的主题外,理想情况下,训练样本还应表示我们的特定相机和算法看到主题的方式。
最终,在本章中,我们打算使用固定大小的滑动窗口,因此,重要的是,我们的训练样本必须符合固定大小,并且要对正样本进行严格裁剪以构架没有太多背景的汽车。
在一定程度上,我们希望随着我们不断添加良好的训练图像,分类器的准确率将会提高。 另一方面,较大的数据集会使训练变慢,并且可能过度训练分类器,从而无法推断超出训练集的分类器。 在本节的后面,我们将以一种允许我们轻松修改训练图像的数量的方式编写代码,以便通过实验找到合适的尺寸。
如果我们自己完成所有的工作,那么组装汽车图像数据集将是一项耗时的工作(尽管这完全是可行的)。 为了避免重新发明轮子或整个汽车,我们可以利用现成的数据集,例如:
让我们在示例中使用 UIUC 数据集。 获取此数据集并在脚本中使用它涉及几个步骤,因此让我们一一遍解它们,如下所示:
- 从这个页面下载 UIUC 数据集。 将其解压缩到某个文件夹,我们将其称为
<project_path>。 现在,解压缩的数据应该位于<project_path>/CarData处。 具体来说,我们将使用<project_path>/CarData/TrainImages和<project_path>/CarData/TestImages中的某些图像。 - 同样在
<project_path>中,我们创建一个名为detect_car_bow_svm.py的 Python 脚本。 要开始执行脚本,请编写以下代码以检查CarData子文件夹是否存在:
import cv2
import numpy as np
import os
if not os.path.isdir('CarData'):
print(
'CarData folder not found. Please download and unzip '
'http://l2r.cs.uiuc.edu/~cogcomp/Data/Car/CarData.tar.gz '
'into the same folder as this script.')
exit(1)
如果您可以运行此脚本并且不打印任何内容,则表示所有内容均位于正确的位置。
- 接下来,让我们在脚本中定义以下常量:
BOW_NUM_TRAINING_SAMPLES_PER_CLASS = 10
SVM_NUM_TRAINING_SAMPLES_PER_CLASS = 100
请注意,我们的分类器将使用两个训练阶段:一个阶段用于 BoW 词汇表,它将使用多个图像作为样本,而另一个阶段则用于 SVM,它将使用多个 BoW 描述符向量作为样本。 我们随意地为每个阶段定义了不同数量的训练样本。 在每个阶段,我们还可以为两个类别(汽车和非汽车)定义不同数量的训练样本,但是,我们将使用相同的数量。
- 我们将使用
cv2.SIFT提取描述符,并使用cv2.FlannBasedMatcher匹配这些描述符。 让我们用以下代码初始化这些算法:
sift = cv2.xfeatures2d.SIFT_create()
FLANN_INDEX_KDTREE = 1
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
search_params = {}
flann = cv2.FlannBasedMatcher(index_params, search_params)
请注意,我们已经按照与第 6 章“图像检索和使用图像描述符的搜索”相同的方式,初始化了 SIFT 和用于近似最近邻的 FAST 库(FLANN)。但是,这一次,描述符匹配不是我们的最终目标。 相反,它将成为 BoW 特征的一部分。
- OpenCV 提供了一个名为
cv2.BOWKMeansTrainer的类来训练 BoW 词汇表,以及一个名为cv2.BOWImgDescriptorExtractor的类来将某种较低级的描述符(在我们的示例中为 SIFT 描述符)转换为 BoW 描述符。 让我们用以下代码初始化这些对象:
bow_kmeans_trainer = cv2.BOWKMeansTrainer(40)
bow_extractor = cv2.BOWImgDescriptorExtractor(sift, flann)
初始化cv2.BOWKMeansTrainer时,必须指定群集数-在我们的示例中为 40。在初始化cv2.BOWImgDescriptorExtractor时,必须指定描述符提取器和描述符匹配器-在我们的示例中为我们之前创建的cv2.SIFT和cv2.FlannBasedMatcher对象。
- 为了训练 BoW 词汇,我们将提供各种汽车和非汽车图像的 SIFT 描述符样本。 我们将从
CarData/TrainImages子文件夹中加载图像,该图像包含名称为pos-x.pgm的正(汽车)图像和名称为诸如pos-x.pgm的负(非汽车)图像。neg-x.pgm,其中x是从1开始的数字。 让我们编写以下实用函数,以返回到第i个正负训练图像的路径,其中i是一个以0开头的数字:
def get_pos_and_neg_paths(i):
pos_path = 'CarData/Trainimg/pos-%d.pgm' % (i+1)
neg_path = 'CarData/Trainimg/neg-%d.pgm' % (i+1)
return pos_path, neg_path
在本节的稍后部分,当我们需要获取大量训练样本时,我们将使用i的变化值循环调用前面的函数。
- 对于训练样本的每条路径,我们将需要加载图像,提取 SIFT 描述符,并将描述符添加到 BoW 词汇表训练器中。 让我们编写另一个工具函数来精确地做到这一点,如下所示:
def add_sample(path):
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
keypoints, descriptors = sift.detectAndCompute(img, None)
if descriptors is not None:
bow_kmeans_trainer.add(descriptors)
如果在图像中未找到特征,则keypoints和descriptors变量将为None。
- 在此阶段,我们拥有开始训练 BoW 词汇表所需的一切。 让我们为每个类读取一些图像(汽车作为肯定类,非汽车作为否定类),并将它们添加到训练集中,如下所示:
for i in range(BOW_NUM_TRAINING_SAMPLES_PER_CLASS):
pos_path, neg_path = get_pos_and_neg_paths(i)
add_sample(pos_path)
add_sample(neg_path)
- 现在我们已经组装了训练集,我们将调用词汇训练器的
cluster方法,该方法执行k-均值分类并返回词汇表。 我们将把这个词汇分配给 BoW 描述符提取器,如下所示:
voc = bow_kmeans_trainer.cluster()
bow_extractor.setVocabulary(voc)
请记住,之前我们用 SIFT 描述符提取器和 FLANN 匹配器初始化了 BoW 描述符提取器。 现在,我们还为 BoW 描述符提取器提供了一个词汇,并使用 SIFT 描述符样本进行了训练。 在这个阶段,我们的 BoW 描述符提取器具有从高斯(DoG)特征中提取 BoW 描述符所需的一切。
请记住,cv2.SIFT检测 DoG 特征并提取 SIFT 描述符,正如我们在第 6 章,“检索图像并使用图像描述符”讨论的那样,特别是在“检测 DoG 特征和提取 SIFT 描述符”部分。
- 接下来,我们将声明另一个效用函数,该函数获取图像并返回 BoW 描述符提取器计算出的描述符向量。 这涉及提取图像的 DoG 特征,并根据 DoG 特征计算 BoW 描述符向量,如下所示:
def extract_bow_descriptors(img):
features = sift.detect(img)
return bow_extractor.compute(img, features)
- 我们准备组装另一种训练集,其中包含 BoW 描述符的样本。 让我们创建两个数组来容纳训练数据和标签,并用 BoW 描述符提取器生成的描述符填充它们。 我们将每个描述符向量标记为 1(正样本)和 -1(负样本),如以下代码块所示:
training_data = []
training_labels = []
for i in range(SVM_NUM_TRAINING_SAMPLES_PER_CLASS):
pos_path, neg_path = get_pos_and_neg_paths(i)
pos_img = cv2.imread(pos_path, cv2.IMREAD_GRAYSCALE)
pos_descriptors = extract_bow_descriptors(pos_img)
if pos_descriptors is not None:
training_data.extend(pos_descriptors)
training_labels.append(1)
neg_img = cv2.imread(neg_path, cv2.IMREAD_GRAYSCALE)
neg_descriptors = extract_bow_descriptors(neg_img)
if neg_descriptors is not None:
training_data.extend(neg_descriptors)
training_labels.append(-1)
如果您希望训练一个分类器来区分多个肯定类,则可以简单地添加带有其他标签的其他描述符。 例如,我们可以训练一个分类器,该分类器将标签 1 用于汽车,将 2 用于人,将 -1 用于背景。 不需要具有否定类或背景类,但如果没有,则分类器将假定一切都属于肯定类之一。
- OpenCV 提供了一个名为
cv2.ml_SVM的类,表示一个 SVM。 让我们创建一个 SVM,并使用我们先前组装的数据和标签对其进行训练,如下所示:
svm = cv2.ml.SVM_create()
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
np.array(training_labels))
请注意,在将训练数据和标签从列表转换为 NumPy 数组之前,必须将它们传递给cv2.ml_SVM的train方法。
- 最后,我们准备通过对一些不属于训练集的图像进行分类来测试 SVM。 我们将遍历测试图像的路径列表。 对于每个路径,我们将加载图像,提取 BoW 描述符,并获得 SVM 的预测或分类结果,它们将是 1.0(汽车)或 -1.0(非汽车),具体取决于我们之前使用的训练标签。 我们将在图像上绘制文本以显示分类结果,并在窗口中显示图像。 显示所有图像后,我们将等待用户按下任意键,然后脚本将结束。 所有这些都是通过以下代码块实现的:
for test_img_path in ['CarData/Testimg/test-0.pgm',
'CarData/Testimg/test-1.pgm',
'img/car.jpg',
'img/haying.jpg',
'img/statue.jpg',
'img/woodcutters.jpg']:
img = cv2.imread(test_img_path)
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
descriptors = extract_bow_descriptors(gray_img)
prediction = svm.predict(descriptors)
if prediction[1][0][0] == 1.0:
text = 'car'
color = (0, 255, 0)
else:
text = 'not car'
color = (0, 0, 255)
cv2.putText(img, text, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1,
color, 2, cv2.LINE_AA)
cv2.imshow(test_img_path, img)
cv2.waitKey(0)
保存并运行脚本。 您应该看到六个具有各种分类结果的窗口。 这是真实正面结果之一的屏幕截图:
下一个屏幕截图显示了真正的负面结果之一:
在我们的简单测试中的六张图像中,只有以下一张被错误分类:
尝试调整训练样本的数量,并尝试在更多图像上测试分类器,以查看可获得的结果。
让我们总结一下到目前为止所做的事情。 我们使用了 SIFT,BoW 和 SVM 的混合来训练分类器,以区分两个类别:汽车和非汽车。 我们已将此分类器应用于整个图像。 下一步的逻辑步骤是应用滑动窗口技术,以便我们可以将分类结果缩小到图像的特定区域。
将 SVM 与滑动窗口结合
通过将我们的 SVM 分类器与滑动窗口技术和图像金字塔相结合,我们可以实现以下改进:
- 检测图像中相同种类的多个对象。
- 确定图像中每个检测到的对象的位置和大小。
我们将采用以下方法:
- 拍摄图像的一个区域,对其进行分类,然后将该窗口向右移动一个预定义的步长。 当我们到达图像的最右端时,将
x坐标重置为 0,向下移动一步,然后重复整个过程。 - 在每个步骤中,请使用经过 BoW 训练的 SVM 执行分类。
- 根据 SVM,跟踪所有检测为正例的窗口。
- 在对整个图像中的每个窗口进行分类之后,将图像按比例缩小,然后重复使用滑动窗口的整个过程。 因此,我们正在使用图像金字塔。 继续重新缩放和分类,直到达到最小大小。
到此过程结束时,我们已经收集了有关图像内容的重要信息。 但是,存在一个问题:我们很可能已经发现了许多重叠的块,每个块都有很高的置信度。 也就是说,图像可以包含被多次检测的一个物体。 如果我们报告了这些检测结果,那么我们的报告将具有很大的误导性,因此我们将使用 NMS 筛选结果。
有关更新,您可能希望参考本章前面的“了解 NMS”部分。
接下来,让我们看一下如何修改和扩展前面的脚本,以实现我们刚刚描述的方法。
在场景中检测汽车
现在,我们已经准备好通过创建汽车检测脚本来应用到目前为止学到的所有概念,该脚本可以扫描图像并在汽车周围绘制矩形。 通过复制先前的脚本detect_car_bow_svm.py,创建一个新的 Python 脚本detect_car_bow_svm_sliding_window.py。 (我们之前在“检测汽车”部分中介绍了detect_car_bow_svm.py的实现。)新脚本的大部分实现将保持不变,因为我们仍然希望以几乎相同的方式训练 BoW 描述符提取器和 SVM 像我们以前一样。 但是,训练完成后,我们将以新的方式处理测试图像。 除了将每个图像整体分类之外,我们将每个图像分解为金字塔层和窗口,我们将每个窗口分类,然后将 NMS 应用于产生正面检测结果的窗口列表。
对于 NMS,我们将依靠 Malisiewicz 和 Rosebrock 的实现,如本章前面的“了解 NMS”部分中所述。 您可以在本书的 GitHub 存储库中找到其实现的略微修改的副本,尤其是在chapter7/non_max_suppression.py的 Python 脚本中。 该脚本提供具有以下签名的函数:
def non_max_suppression_fast(boxes, overlapThresh):
作为其第一个参数,该函数采用一个 NumPy 数组,其中包含矩形坐标和分数。 如果我们有N个矩形,则此数组的形状为Nx5。 对于索引为i的给定矩形,数组中的值具有以下含义:
boxes[i][0]是最左侧的x坐标。boxes[i][1]是最高的y坐标。boxes[i][2]是最右边的x坐标。boxes[i][3]是最底端的y坐标。boxes[i][4]是分数,其中分数越高表示矩形是正确的检测结果的可信度越高。
作为第二个参数,该函数采用一个阈值,该阈值表示矩形之间重叠的最大比例。 如果两个矩形的重叠比例大于此比例,则得分较低的矩形将被滤除。 最终,该函数将返回剩余矩形的数组。
现在,让我们将注意力转向对detect_car_bow_svm_sliding_window.py脚本的修改,如下所示:
- 首先,我们要为 NMS 函数添加一个新的
import语句,如以下代码中的粗体所示:
import cv2
import numpy as np
import os
from non_max_suppression import non_max_suppression_fast as nms
- 让我们在脚本开头附近定义一些其他参数,如粗体所示:
BOW_NUM_TRAINING_SAMPLES_PER_CLASS = 10
SVM_NUM_TRAINING_SAMPLES_PER_CLASS = 100
SVM_SCORE_THRESHOLD = 1.8
NMS_OVERLAP_THRESHOLD = 0.15
我们将使用SVM_SCORE_THRESHOLD作为阈值来区分正窗口和负窗口。 我们将在本节稍后部分看到如何获得分数。 我们将使用NMS_OVERLAP_THRESHOLD作为 NMS 步骤中重叠的最大可接受比例。 在这里,我们任意选择了 15%,因此我们将剔除重叠超过此比例的窗口。 在试验 SVM 时,您可以根据自己的喜好调整这些参数,直到找到在应用中产生最佳结果的值。
- 我们将
k-均值群集的数量从40减少到12(根据实验任意选择的数量),如下所示:
bow_kmeans_trainer = cv2.BOWKMeansTrainer(12)
- 我们还将调整 SVM 的参数,如下所示:
svm = cv2.ml.SVM_create()
svm.setType(cv2.ml.SVM_C_SVC)
svm.setC(50)
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
np.array(training_labels))
通过对 SVM 的先前更改,我们指定了分类器的严格性或严重性级别。 随着C参数的值增加,误报的风险减少,但误报的风险增加。 在我们的应用中,假正例将是当其确实是非汽车时被检测为汽车的窗口,而假负例将是当它的实际汽车时被检测为非汽车的窗口。
在训练 SVM 的代码之后,我们想添加两个辅助函数。 基于滑动窗口技术,其中一个将生成图像金字塔的级别,而另一个将生成关注区域。 除了添加这些辅助函数外,我们还需要以不同的方式处理测试图像,以利用滑动窗口和 NMS。 以下步骤介绍了更改:
- 首先,让我们看一下处理图像金字塔的辅助函数。 以下代码块显示了此函数:
def pyramid(img, scale_factor=1.25, min_size=(200, 80),
max_size=(600, 600)):
h, w = img.shape
min_w, min_h = min_size
max_w, max_h = max_size
while w >= min_w and h >= min_h:
if w <= max_w and h <= max_h:
yield img
w /= scale_factor
h /= scale_factor
img = cv2.resize(img, (int(w), int(h)),
interpolation=cv2.INTER_AREA)
前面的函数获取图像并生成一系列调整大小的版本。 该系列受最大和最小图像尺寸的限制。
您会注意到,调整大小的图像不是通过return关键字返回的,而是通过yield关键字返回的。 这是因为此函数是所谓的生成器。 它产生一系列图像,我们可以轻松地在循环中使用它们。 如果您不熟悉生成器,请查看这个页面上的官方 Python Wiki。
- 接下来是基于滑动窗口技术生成兴趣区域的函数。 以下代码块显示了此函数:
def sliding_window(img, step=20, window_size=(100, 40)):
img_h, img_w = img.shape
window_w, window_h = window_size
for y in range(0, img_w, step):
for x in range(0, img_h, step):
roi = img[y:y+window_h, x:x+window_w]
roi_h, roi_w = roi.shape
if roi_w == window_w and roi_h == window_h:
yield (x, y, roi)
同样,这是一个生成器。 尽管有点嵌套,但是该机制非常简单:给定图像,返回左上角坐标和代表下一个窗口的子图像。 连续的窗口从左到右以任意大小的步长移动,直到我们到达一行的末尾;从顶部到底部,直到我们到达图像的末尾。
- 现在,让我们考虑对测试图像的处理。 与先前版本的脚本一样,我们循环浏览一系列路径以测试图像,以便加载和处理每个图像。 循环的开始保持不变。 对于上下文,这里是:
for test_img_path in ['CarData/Testimg/test-0.pgm',
'CarData/Testimg/test-1.pgm',
'img/car.jpg',
'img/haying.jpg',
'img/statue.jpg',
'img/woodcutters.jpg']:
img = cv2.imread(test_img_path)
gray_img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
- 对于每个测试图像,我们迭代金字塔级别,对于每个金字塔级别,我们迭代滑动窗口位置。 对于每个窗口或兴趣区域(ROI),我们提取 BoW 描述符并使用 SVM 对它们进行分类。 如果分类产生的正例结果通过了一定的置信度阈值,则将矩形的角坐标和置信度得分添加到正面检测列表中。 从上一个代码块继续,我们继续使用以下代码处理给定的测试图像:
pos_rects = []
for resized in pyramid(gray_img):
for x, y, roi in sliding_window(resized):
descriptors = extract_bow_descriptors(roi)
if descriptors is None:
continue
prediction = svm.predict(descriptors)
if prediction[1][0][0] == 1.0:
raw_prediction = svm.predict(
descriptors,
flags=cv2.ml.STAT_MODEL_RAW_OUTPUT)
score = -raw_prediction[1][0][0]
if score > SVM_SCORE_THRESHOLD:
h, w = roi.shape
scale = gray_img.shape[0] / \
float(resized.shape[0])
pos_rects.append([int(x * scale),
int(y * scale),
int((x+w) * scale),
int((y+h) * scale),
score])
让我们注意一下前面代码中的两个复杂性,如下所示:
到目前为止,我们已经在各种规模和位置进行了汽车检测; 结果,我们有了一个检测到的汽车矩形的列表,包括坐标和分数。 我们期望在此矩形列表内有很多重叠。
- 现在,让我们调用 NMS 函数,以便在重叠的情况下挑选得分最高的矩形,如下所示:
pos_rects = nms(np.array(pos_rects), NMS_OVERLAP_THRESHOLD)
请注意,我们已经将矩形坐标和分数列表转换为 NumPy 数组,这是该函数期望的格式。
在此阶段,我们有一系列检测到的汽车矩形及其得分,并且我们确保了这些是我们可以选择的最佳非重叠检测(在模型的参数范围内)。
- 现在,通过在代码中添加以下内部循环来绘制矩形及其分数:
for x0, y0, x1, y1, score in pos_rects:
cv2.rectangle(img, (int(x0), int(y0)), (int(x1), int(y1)),
(0, 255, 255), 2)
text = '%.2f' % score
cv2.putText(img, text, (int(x0), int(y0) - 20),
cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 255), 2)
与该脚本的先前版本一样,外部循环的主体通过显示当前的测试图像(包括我们在其上绘制的标注)来结束。 循环遍历所有测试图像后,我们等待用户按下任意键; 然后,程序结束,如下所示:
cv2.imshow(test_img_path, img)
cv2.waitKey(0)
让我们运行修改后的脚本,看看它能如何回答永恒的问题:杜德,我的车在哪里?
以下屏幕截图显示了成功的检测:
我们的另一个测试图像中有两辆车。 碰巧的是,成功检测到一辆汽车,而另一辆则未成功,如以下屏幕截图所示:
有时,其中具有许多特征的背景区域被错误地检测为汽车。 这是一个例子:
请记住,在此示例脚本中,我们的训练集很小。 具有更大背景的更大训练集可以改善结果。 另外,请记住,图像金字塔和滑动窗口会产生大量的 ROI。 考虑这一点时,我们应该意识到检测器的误报率实际上很低。 如果我们要对视频的帧执行检测,则可以通过过滤掉仅出现在单个帧或几个帧中而不是一系列任意的最小长度的检测,来进一步降低误报率。
随意尝试上述脚本的参数和训练集。 当您准备就绪时,让我们用一些结束语来结束本章。
保存和加载经过训练的 SVM
关于 SVM 的最后一条建议是:您不需要每次使用探测器时都对它进行训练–实际上,由于训练速度很慢,因此您应该避免这样做。 您可以使用以下代码将经过训练的 SVM 模型保存到 XML 文件:
svm = cv2.ml.SVM_create()
svm.train(np.array(training_data), cv2.ml.ROW_SAMPLE,
np.array(training_labels))
svm.save('my_svm.xml')
随后,您可以使用以下代码重新加载经过训练的 SVM:
svm = cv2.ml.SVM_create()
svm.load('my_svm.xml')
通常,您可能有一个脚本用于训练和保存 SVM 模型,而其他脚本则可以加载和使用它来解决各种检测问题。
总结
在本章中,我们涵盖了广泛的概念和技术,包括 HOG,BoW,SVM,图像金字塔,滑动窗口和 NMS。 我们了解到这些技术在对象检测以及其他领域中都有应用。 我们编写了一个脚本,该脚本结合了 BoW,SVM,图像金字塔,滑动窗口和 NMS 等大多数技术,并且通过训练和测试自定义检测器,在机器学习中获得了实践经验。 最后,我们证明了我们可以检测到汽车!
我们的新知识构成下一章的基础,在下一章中,我们将对视频中的帧序列利用对象检测和分类技术。 我们将学习如何跟踪对象并保留有关它们的信息-这是许多实际应用中的重要目标。
八、追踪对象
在本章中,我们将从对象跟踪的广泛主题中探索一系列技术,这是在电影或来自摄像机的视频馈送中定位运动对象的过程。 实时对象跟踪是许多计算机视觉应用中的关键任务,例如监视,感知用户界面,增强现实,基于对象的视频压缩和驾驶员辅助。
跟踪对象可以通过多种方式来完成,而最佳技术则很大程度上取决于手头的任务。 在研究此主题时,我们将采取以下路线:
- 根据当前帧和代表背景的帧之间的差异检测运动对象。 首先,我们将尝试这种方法的简单实现。 然后,我们将使用 OpenCV 的更高级算法的实现,即高斯混合(MOG)和 K 最近邻(KNN)背景减法器。 我们还将考虑如何修改脚本以使用 OpenCV 支持的任何其他背景减法器,例如 Godbehere-Matsukawa-Goldberg(GMG)背景减法器。
- 根据对象的颜色直方图跟踪移动的对象。 这种方法涉及直方图反投影,这是计算各个图像区域和直方图之间相似度的过程。 换句话说,直方图用作我们期望对象外观的模板。 我们将使用称为 MeanShift 和 CamShift 的跟踪算法,这些算法对直方图反投影的结果进行运算。
- 使用卡尔曼过滤器查找对象运动的趋势,并预测对象下一步的移动方向。
- 回顾 OpenCV 支持面向对象编程(OOP)范式的方式,并考虑这与函数式编程(FP)范例有何不同。
- 实现结合了 KNN 背景减法,MeanShift 和卡尔曼滤波的行人跟踪器。
如果您已按顺序阅读本书,那么到本章结束时,您将了解许多以 2D 形式描述,检测,分类和跟踪对象的方法。 届时,您应该准备在第 9 章,“摄像机模型和增强现实”中进行 3D 跟踪。
技术要求
本章使用 Python,OpenCV 和 NumPy。 有关安装说明,请参阅第 1 章,“设置 OpenCV”。
可在本书的 GitHub 存储库中找到本章的完整代码和示例视频,位于chapter08文件夹中。
通过背景减法检测运动物体
要跟踪视频中的任何内容,首先,我们必须确定视频帧中与移动对象相对应的区域。 许多运动检测技术都基于背景减法的简单概念。 例如,假设我们有一台固定的摄像机来观看也基本上静止的场景。 除此之外,假设相机的曝光和场景中的照明条件是稳定的,以使帧的亮度变化不大。 在这些条件下,我们可以轻松捕获代表背景的参考图像,换句话说,就是场景的静止部分。 然后,无论何时摄像机捕获新帧,我们都可以从参考图像中减去该帧,并取该差的绝对值,以便获得该帧中每个像素位置的运动测量值。 如果帧的任何区域与参考图像有很大不同,我们可以得出结论,给定区域是运动对象。
背景减法技术通常具有以下局限性:
- 摄像机的任何运动,曝光变化或照明条件的变化都可能导致整个场景中的像素值立即发生变化。 因此,整个背景模型(或参考图像)已过时。
- 如果某个对象进入场景,然后在该场景中停留很长一段时间,那么一部分背景模型可能会过时。 例如,假设我们的场景是走廊。 有人进入走廊,将海报放在墙上,然后将海报留在那里。 实际上,海报实际上只是固定背景的另一部分。 但是,它不是我们参考图像的一部分,因此我们的背景模型已经过时了。
这些问题表明需要基于一系列新帧动态更新背景模型。 先进的背景减法技术试图以多种方式解决这一需求。
另一个普遍的限制是阴影和固体对象可能以类似方式影响背景减法器。 例如,由于我们无法将物体与其阴影区分开来,因此我们可能无法获得运动物体的大小和形状的准确图片。 但是,先进的背景减法技术确实尝试使用各种方法来区分阴影区域和实体对象。
背景减法器通常还有另一个局限性:它们无法对其检测到的运动类型提供细粒度的控制。 例如,如果场景显示地铁车在其轨道上行驶时不断晃动,则此重复动作将影响背景减法器。 出于实际目的,我们可以将地铁的振动视为半静止背景下的正常变化。 我们甚至可能知道这些振动的频率。 但是,背景减法器不会嵌入有关运动频率的任何信息,因此它没有提供方便或精确的方法来滤除此类可预测的运动。 为了弥补这些缺点,我们可以应用预处理步骤,例如模糊参考图像,也可以模糊每个新帧。 以这种方式,尽管以不太直观,有效或精确的方式抑制了某些频率。
分析运动频率超出了本书的范围。 但是,有关在计算机视觉环境中对此主题的介绍,请参阅 Joseph Howse 的书《写给秘密特工的 OpenCV 4》(Packt Publishing,2019),特别是第 7 章“用运动放大相机观看心跳”。
现在,我们已经对背景减法进行了概述,并了解了背景减法面临的一些障碍,让我们研究一下背景减法的实现效果如何。 我们将从一个简单但不鲁棒的实现开始,我们可以编写几行代码,然后发展到 OpenCV 为我们提供的更复杂的替代方案。
实现基本的背景减法器
为了实现基本的背景减法器,让我们采用以下方法:
- 开始从相机捕获帧。
- 丢弃九帧,以便相机有时间适当调整其自动曝光以适合场景中的照明条件。
- 拍摄第 10 帧,将其转换为灰度,对其进行模糊处理,然后将此模糊图像用作背景的参考图像。
- 对于每个后续帧,请对该帧进行模糊处理,然后将其转换为灰度,然后计算该模糊帧与背景参考图像之间的绝对差。 对差异图像执行阈值化,平滑和轮廓检测。 绘制并显示主要轮廓的边界框。
高斯模糊的使用应该使我们的背景减法器不易受到小振动以及数字噪声的影响。 形态学操作也提供了这些好处。
要模糊图像,我们将使用高斯模糊算法,该算法最初在第 3 章“使用 OpenCV 处理图像”中,特别是在“HPF 和 LPF”部分中讨论过。 为了使阈值图像平滑,我们将使用形态学侵蚀和膨胀,我们最初在第 4 章,“深度估计和分割”中讨论过,特别是在“使用分水岭算法进行图像分割”部分中。 轮廓检测和边界框也是我们在第 3 章“使用 OpenCV 处理图像”,特别是在“轮廓检测”部分中介绍的主题。
将前面的列表扩展为更小的步骤,我们可以考虑在八个顺序的代码块中执行脚本:
- 让我们开始导入 OpenCV 并为
blur,erode和dilate操作定义核的大小:
import cv2
BLUR_RADIUS = 21
erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
dilate_kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (9, 9))
- 现在,让我们尝试从相机捕获 10 帧:
cap = cv2.VideoCapture(0)
# Capture several frames to allow the camera's autoexposure to adjust.
for i in range(10):
success, frame = cap.read()
if not success:
exit(1)
- 如果我们无法捕获 10 帧,则退出。 否则,我们将第 10 帧转换为灰度并对其进行模糊处理:
gray_background = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray_background = cv2.GaussianBlur(gray_background,
(BLUR_RADIUS, BLUR_RADIUS), 0)
- 在这一阶段,我们有背景的参考图像。 现在,让我们继续捕获更多帧,以检测运动。 我们对每一帧的处理都是从灰度转换和高斯模糊运算开始的:
success, frame = cap.read()
while success:
gray_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
gray_frame = cv2.GaussianBlur(gray_frame,
(BLUR_RADIUS, BLUR_RADIUS), 0)
- 现在,我们可以将当前帧的模糊灰度版本与背景图像的模糊灰度版本进行比较。 具体来说,我们将使用 OpenCV 的
cv2.absdiff函数查找这两个图像之间差异的绝对值(或大小)。 然后,我们将应用阈值以获得纯黑白图像,并应用形态学操作来平滑阈值图像。 以下是相关代码:
diff = cv2.absdiff(gray_background, gray_frame)
_, thresh = cv2.threshold(diff, 40, 255, cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)
- 在这一点上,如果我们的技术运行良好,则在有运动物体的任何地方,我们的阈值图像都应包含白色斑点。 现在,我们要查找白色斑点的轮廓并在其周围绘制边界框。 作为过滤掉可能不是真实物体的细微变化的另一种方法,我们将基于轮廓的面积应用阈值。 如果轮廓线太小,我们可以得出结论,它不是真正的运动物体。 (当然,“定义太小”可能会因相机的分辨率和应用而异;在某些情况下,您可能根本不希望进行此测试。)以下代码用于检测轮廓和绘制边界框:
_, contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 4000:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)
- 现在,让我们用边界矩形显示差异图像,阈值图像和检测结果:
cv2.imshow('diff', diff)
cv2.imshow('thresh', thresh)
cv2.imshow('detection', frame)
- 我们将继续读取帧,直到用户按下
Esc键退出为止:
k = cv2.waitKey(1)
if k == 27: # Escape
break
success, frame = cap.read()
在那里,您便拥有了一个基本的运动检测器,它可以在移动物体周围绘制矩形。 最终结果是这样的:
为了使用此脚本获得良好的效果,请确保在初始化背景图像之后,您(和其他移动物体)才进入摄像机的视野。
对于这种简单的技术,此结果很有希望。 但是,我们的脚本不努力动态地更新背景图像,因此如果相机移动或照明发生变化,它将很快过时。 因此,我们应该继续使用更加灵活和智能的背景减法器。 幸运的是,OpenCV 提供了几个现成的背景减法器供我们使用。 我们将从实现 MOG 算法的算法开始。
使用 MOG 背景减法器
OpenCV 提供了一个名为cv2.BackgroundSubtractor的类,该类具有实现各种背景减法算法的各种子类。
您可能还记得,我们之前在第 4 章,“深度估计和分段”中,特别是在“GrabCut 算法的前景检测”部分中,使用了 OpenCV 的 GrabCut 算法来执行前景/背景分割。 像cv2.grabCut一样,cv2.BackgroundSubtractor的各种子类实现也可以产生一个掩码,该掩码将不同的值分配给图像的不同段。 具体来说,背景减法器可以将前景段标记为白色(即 255 的 8 位灰度值),将背景段标记为黑色(0),将阴影段标记为灰色(127)。 此外,与 GrabCut 不同的是,背景减法器会随着时间的推移更新前景/背景模型,通常是通过将机器学习应用于一系列帧来实现的。 许多背景减法器是根据统计聚类技术命名的,它们是基于它们的机器学习方法的。 因此,我们将首先查看基于 MOG 聚类技术的背景减法器。
OpenCV 具有 MOG 背景减法器的两种实现。 也许不足为奇,它们被命名为cv2.BackgroundSubtractorMOG和cv2.BackgroundSubtractorMOG2。 后者是更新的实现,它增加了对阴影检测的支持,因此我们将使用它。
首先,让我们以上一节中的基本背景减除脚本为基础。 我们将对其进行以下修改:
- 用 MOG 背景减法器替换我们的基本背景减法模型。
- 作为输入,请使用视频文件而不是摄像机。
- 取消使用高斯模糊。
- 调整阈值,形态和轮廓分析步骤中使用的参数。
这些修改会影响几行代码,这些代码分散在整个脚本中。 在脚本顶部附近,让我们初始化 MOG 背景减法器并修改形态核的大小,如以下代码块中的粗体所示:
import cv2
bg_subtractor = cv2.createBackgroundSubtractorMOG2(detectShadows=True)
erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
请注意,OpenCV 提供了cv2.createBackgroundSubtractorMOG2函数来创建cv2.BackgroundSubtractorMOG2的实例。 该函数接受参数detectShadows,我们将其设置为True,这样阴影区域将被标记为此类,而不标记为前景的一部分。
其余更改(包括使用 MOG 背景减法器获取前景/阴影/背景遮罩)在以下代码块中以粗体标记:
cap = cv2.VideoCapture('hallway.mpg')
success, frame = cap.read()
while success:
fg_mask = bg_subtractor.apply(frame)
_, thresh = cv2.threshold(fg_mask, 244, 255, cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)
contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 1000:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)
cv2.imshow('mog', fg_mask)
cv2.imshow('thresh', thresh)
cv2.imshow('detection', frame)
k = cv2.waitKey(30)
if k == 27: # Escape
break
success, frame = cap.read()
当我们将帧传递给背景减法器的apply方法时,减法器更新其背景的内部模型,然后返回掩码。 如前所述,对于前景段,遮罩为白色(255),对于阴影段为灰色(127),对于背景段为黑色(0)。 出于我们的目的,我们将阴影视为背景,因此我们向遮罩应用了接近白色的阈值(244)。
以下屏幕截图显示了来自 MOG 检测器的遮罩(左上图),该遮罩的阈值和变形版本(右上图)以及检测结果(下图):
为了进行比较,如果通过设置detectShadows=False禁用阴影检测,我们将获得诸如以下屏幕截图的结果:
由于抛光的地板和墙壁,该场景不仅包含阴影,还包含反射。 启用阴影检测后,我们可以使用阈值去除遮罩中的阴影和反射,从而使我们在大厅中的人周围有一个准确的检测矩形。 但是,当禁用阴影检测时,我们可以进行两种检测,这两种检测都可以说是不准确的。 一种检测覆盖了该人,他的阴影以及他在地板上的反射。 第二次检测覆盖了该人在墙上的反射。 这些可以说是不准确的检测结果,因为人的阴影和反射并不是真正的移动物体,即使它们是移动物体的视觉伪像。
到目前为止,我们已经看到,背景减法脚本可以非常简洁,并且进行一些小改动就可以大大改变算法和结果,无论是好是坏。 以同样的方式继续进行下去,让我们看看我们如何轻松修改代码以使用 OpenCV 的另一种高级背景减法器来查找另一种运动对象。
使用 KNN 背景减法器
通过仅在 MOG 背景减法脚本中修改五行代码,我们可以使用不同的背景减法算法,不同的形态参数以及不同的视频作为输入。 借助 OpenCV 提供的高级接口,即使是这些简单的更改,也使我们能够成功处理各种后台扣除任务。
只需将cv2.createBackgroundSubtractorMOG2替换为cv2.createBackgroundSubtractorKNN,我们就可以使用基于 KNN 聚类而非 MOG 聚类的背景减法器:
bg_subtractor = cv2.createBackgroundSubtractorKNN(detectShadows=True)
请注意,尽管算法有所变化,但仍支持detectShadows参数。 此外,apply方法仍然受支持,因此我们在脚本的后面不需要更改与使用背景减法器有关的任何内容。
请记住,cv2.createBackgroundSubtractorMOG2返回cv2.BackgroundSubtractorMOG2类的新实例。 同样,cv2.createBackgroundSubtractorKNN返回cv2.BackgroundSubtractorKNN类的新实例。 这两个类都是cv2.BackgroundSubtractor的子类,它定义了apply之类的常用方法。
进行以下更改后,我们可以使用形态核,这些核稍微更适合水平拉长的物体(在本例中为汽车),并且可以使用交通视频作为输入:
erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 5))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 11))
cap = cv2.VideoCapture('traffic.flv')
为了反映算法的变化,让我们将遮罩窗口的标题从'mog'更改为'knn':
cv2.imshow('knn', fg_mask)
以下屏幕截图显示了运动检测的结果:
KNN 背景减法器及其在对象和阴影之间进行区分的功能在这里效果很好。 所有汽车都被单独检测到; 即使有些汽车彼此靠近,也没有将它们合并为一个检测。 对于五分之三的汽车,检测矩形是准确的。 对于视频帧左下角的深色汽车,背景减法器无法完全区分汽车的后部和沥青。 对于框架顶部中央部分的白色汽车,背景减法器无法将汽车及其阴影与道路上的白色标记完全区分开。 尽管如此,总的来说,这是一个有用的检测结果,可以使我们计算每个车道上行驶的汽车数量。
如我们所见,脚本上的一些简单变体可以产生非常不同的背景减法结果。 让我们考虑如何进一步探索这一观察。
使用 GMG 和其他背景减法器
您可以自由尝试对我们的背景减法脚本进行自己的修改。 如果已经通过可选的opencv_contrib模块获得了 OpenCV,如第 1 章,“设置 OpenCV”中所述,则cv2.bgsegm模块中还可以使用几个背景减法器 。 可以使用以下函数创建它们:
cv2.bgsegm.createBackgroundSubtractorCNTcv2.bgsegm.createBackgroundSubtractorGMGcv2.bgsegm.createBackgroundSubtractorGSOCcv2.bgsegm.createBackgroundSubtractorLSBPcv2.bgsegm.createBackgroundSubtractorMOGcv2.bgsegm.createSyntheticSequenceGenerator
这些函数不支持detectShadows参数,它们创建不支持阴影检测的背景减法器。 但是,所有背景减法器都支持apply方法。
作为如何修改背景减法样本以使用前面列表中的cv2.bgsegm减法器之一的示例,让我们使用 GMG 背景减法器。 在以下代码块中,相关的修改以粗体突出显示:
import cv2
bg_subtractor = cv2.bgsegm.createBackgroundSubtractorGMG()
erode_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (13, 9))
dilate_kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (17, 11))
cap = cv2.VideoCapture('traffic.flv')
success, frame = cap.read()
while success:
fg_mask = bg_subtractor.apply(frame)
_, thresh = cv2.threshold(fg_mask, 244, 255, cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)
contours, hier = cv2.findContours(thresh, cv2.RETR_EXTERNAL,
cv2.CHAIN_APPROX_SIMPLE)
for c in contours:
if cv2.contourArea(c) > 1000:
x, y, w, h = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x+w, y+h), (255, 255, 0), 2)
cv2.imshow('gmg', fg_mask)
cv2.imshow('thresh', thresh)
cv2.imshow('detection', frame)
k = cv2.waitKey(30)
if k == 27: # Escape
break
success, frame = cap.read()
请注意,这些修改类似于我们在上一节“使用 KNN 背景减法器”中看到的修改。 我们只需要使用一个不同的函数来创建 GMG 减法器,就可以将形态核的大小调整为更适合该算法的值,然后将其中一个窗口标题更改为'gmg'。
GMG 算法以其作者 Andrew B. Godbehere,Akihiro Matsukawa 和 Ken Goldberg 的名字命名。 他们在论文《在可变照明条件下对观众进行视觉跟踪以进行响应式音频艺术装置》(ACC,2012)中进行了描述,该论文可从这个页面。 GMG 背景减法器在开始生成带有白色(对象)区域的遮罩之前,需要花费一些帧来初始化自身。
与 KNN 背景减法器相比,GMG 背景减法器在我们的交通示例视频中产生的效果更差。 部分原因是 OpenCV 的 GMG 实现无法区分阴影和固体物体,因此检测矩形在汽车的阴影或反射方向上拉长。 这是输出示例:
在完成背景减法器的实验后,让我们继续研究其他跟踪技术,这些技术依赖于我们要跟踪的对象的模板而不是背景的模板。
使用 MeanShift 和 CamShift 跟踪彩色物体
我们已经看到,背景减法可以成为检测运动物体的有效技术。 但是,我们知道它有一些固有的局限性。 值得注意的是,它假定可以基于过去的帧来预测当前背景。 这个假设是脆弱的。 例如,如果照相机移动,则整个背景模型可能突然过时。 因此,在鲁棒的跟踪系统中,重要的是建立某种前景对象模型,而不仅仅是背景模型。
我们已经在第 5 章,“检测和识别人脸”,第 6 章,“检索图像和使用图像描述符进行搜索”中和第 7 章,“构建自定义对象检测器”。 对于物体检测,我们偏爱可以处理一类物体内大量变化的算法,因此我们的汽车检测器不太会检测其形状或颜色。 对于跟踪的对象,我们的需求有所不同。 如果要跟踪汽车,则我们希望场景中的每辆汽车都具有不同的模型,以免红色汽车和蓝色汽车混淆。 我们想分别跟踪每辆车的运动。
一旦检测到移动物体(通过背景减法或其他方式),我们便要以与其他移动物体不同的方式描述该物体。 这样,即使物体与另一个运动物体交叉,我们也可以继续识别和跟踪物体。 颜色直方图可以用作足够独特的描述。 本质上,对象的颜色直方图是对对象中像素颜色的概率分布的估计。 例如,直方图可以指示对象中的每个像素都是蓝色的可能性为 10%。 直方图基于在参考图像的对象区域中观察到的实际颜色。 例如,参考图像可以是我们首先在其中检测到运动对象的视频帧。
与其他描述对象的方式相比,颜色直方图具有一些在运动跟踪方面特别吸引人的属性。 直方图用作直接将像素值映射到概率的查找表,因此它使我们能够以较低的计算成本将每个像素用作特征。 这样,我们可以实时地以非常精细的空间分辨率执行跟踪。 为了找到我们正在跟踪的对象的最可能位置,我们只需要根据直方图找到像素值映射到最大概率的兴趣区域。
自然地,这种方法被具有醒目的名称:MeanShift 的算法所利用。 对于视频中的每个帧,MeanShift 算法通过基于当前跟踪矩形中的概率值计算质心,将矩形的中心移至该质心,基于新矩形中的值重新计算质心,再次移动矩形来进行迭代跟踪 , 等等。 此过程一直持续到收敛达到(意味着质心停止移动或几乎停止移动)或直到达到最大迭代次数为止。 本质上,MeanShift 是一种聚类算法,其应用扩展到了计算机视觉之外。 该算法首先由 K.Fukunaga 和 L.Hostetler 在题为《密度函数梯度的估计及其在模式识别》(IEEE,1975)中的应用中进行了描述。 IEEE 订户可以通过这个页面获得该论文。
在研究示例脚本之前,让我们考虑一下要通过 MeanShift 实现的跟踪结果的类型,并让我们进一步了解 OpenCV 与颜色直方图有关的功能。
规划我们的 MeanShift 示例
对于 MeanShift 的首次演示,我们不关心移动物体的初始检测方法。 我们将采用幼稚的方法,该方法只是选择第一个视频帧的中心部分作为我们感兴趣的初始区域。 (用户必须确保感兴趣的对象最初位于视频的中心。)我们将计算该感兴趣的初始区域的直方图。 然后,在随后的帧中,我们将使用此直方图和 MeanShift 算法来跟踪对象。
在视觉上,MeanShift 演示将类似于我们先前编写的许多对象检测示例。 对于每一帧,我们将在跟踪矩形周围绘制一个蓝色轮廓,如下所示:
在此,玩具电话具有淡紫色,在场景中的任何其他对象中都不存在。 因此,电话具有独特的直方图,因此易于跟踪。 接下来,让我们考虑如何计算直方图,然后将其用作概率查找表。
计算和反投影颜色直方图
为了计算颜色直方图,OpenCV 提供了一个称为cv2.calcHist的函数。 要将直方图用作查找表,OpenCV 提供了另一个名为cv2.calcBackProject的函数。 后者的操作称为直方图反投影,它将基于给定的直方图将给定的图像转换为概率图。 让我们首先可视化这两个函数的输出,然后检查它们的参数。
直方图可以使用任何颜色模型,例如蓝绿红(BGR),色相饱和度值(HSV)或灰度。 (有关颜色模型的介绍,请参阅第 3 章,“用 OpenCV 处理图像”,特别是“在不同颜色模型之间转换图像”部分。) ,我们将仅使用 HSV 颜色模型的色相(H)通道的直方图。 下图是色调直方图的可视化:
该直方图可视化是来自名为 DPEx 的图像查看应用输出的示例。
在此图的x轴上,有色相,在y轴上,有色相的估计概率,换句话说,就是图像中具有给定的色调的像素比例。 如果您正在阅读本书的电子书版本,则将看到该图根据色相进行了颜色编码。 从左到右,绘图通过色轮的色调进行:红色,黄色,绿色,青色,蓝色,洋红色,最后回到红色。 这个特殊的直方图似乎代表了一个带有很多黄色的物体。
OpenCV 表示 H 值,范围从 0 到 179。某些其他系统使用的范围是 0 到 359(如圆的度数)或 0 到 255。
由于纯黑色和纯白色像素没有有意义的色相,因此在解释色相直方图时需要格外小心。 但是,它们的色相通常表示为 0(红色)。
当我们使用cv2.calcHist生成色调直方图时,它将返回一个在概念上与前面的图相似的一维数组。 或者,根据我们提供的参数,我们可以使用cv2.calcHist生成另一个通道或两个通道的直方图。 在后一种情况下,cv2.calcHist将返回 2D 数组。
有了直方图后,我们可以将直方图反向投影到任何图像上。 cv2.calcBackProject产生 8 位灰度图像格式的反投影,其像素值的范围可能为 0(表示低概率)到 255(表示高概率),具体取决于我们如何缩放这些值。 例如,考虑以下两张照片,分别显示背投和 MeanShift 跟踪结果的可视化:
在这里,我们正在跟踪一个主要颜色为黄色,红色和棕色的小物体。 在实际上是对象一部分的区域中,背投影最亮。 在其他类似颜色的区域中,背投投影也有些明亮,例如约瑟夫·霍斯(Joseph Howse)的棕色胡须,他的眼镜的黄框以及背景中海报之一的红色边框。
现在我们已经可视化了cv2.calcHist和cv2.calcBackProject的输出,让我们检查这些函数接受的参数。
了解cv2.calcHist的参数
cv2.calcHist函数具有以下签名:
calcHist(images, channels, mask, histSize, ranges[, hist[,
accumulate]]) -> hist
下表包含参数的说明(改编自 OpenCV 官方文档):
| 参数 | 说明 |
|---|---|
images | 此参数是一个或多个源图像的列表。 它们都应具有相同的位深度(8 位,16 位或 32 位)和相同的大小。 |
channels | 此参数是用于计算直方图的通道索引的列表。 例如,channels=[0]表示仅使用第一个通道(即索引为0的通道)来计算直方图。 |
mask | 此参数是掩码。 如果为None,则不执行任何屏蔽操作; 图像的每个区域都用于直方图计算中。 如果不是None,则它必须是与images中每个图像大小相同的 8 位数组。 遮罩的非零元素标记应在直方图计算中使用的图像区域。 |
histSize | 此参数是每个通道要使用的直方图箱数的列表。 histSize列表的长度必须与channels列表的长度相同。 例如,如果channels=[0]和histSize=[180],则直方图对于第一个通道具有 180 个箱子(并且未使用任何其他通道)。 |
ranges | 此参数是一个列表,该列表指定每个通道要使用的值的范围(包括下限和排除上限)。 ranges列表的长度必须是channels列表的长度的两倍。 例如,如果channels=[0],histSize=[180]和ranges=[0, 180],则直方图的第一个通道具有 180 个箱子,这些箱子基于 0 到 179 范围内的值; 换句话说,每个仓位只有一个输入值。 |
hist | 此可选参数是输出直方图。 如果它是None(默认值),则将返回一个新数组作为输出直方图。 |
accumulate | 此可选参数是accumulate标志。 默认情况下为False。 如果是True,则不会清除hist的原始内容; 而是将新的直方图添加到hist的原始内容中。 使用此功能,您可以从多个图像列表中计算单个直方图,或者随时间更新直方图。 |
在我们的样本中,我们将像这样计算兴趣区域的色相直方图:
roi_hist = cv2.calcHist([hsv_roi], [0], mask, [180], [0, 180])
接下来,让我们考虑cv2.calcBackProject的参数。
了解cv2.calcBackProject的参数
cv2.calcBackProject函数具有以下签名:
calcBackProject(images, channels, hist, ranges,
scale[, dst]) -> dst
下表包含参数的说明(改编自 OpenCV 官方文档):
| 参数 | 说明 |
|---|---|
images | 此参数是一个或多个源图像的列表。 它们都应具有相同的位深度(8 位,16 位或 32 位)和相同的大小。 |
channels | 此参数必须与calcHist中使用的channels参数相同。 |
hist | 此参数是直方图。 |
ranges | 此参数必须与calcHist中使用的ranges参数相同。 |
scale | 此参数是比例因子。 反投影乘以该比例因子。 |
dst | 此可选参数是输出反投影。 如果它是None(默认值),将返回一个新数组作为反投影。 |
在我们的示例中,我们将使用类似于以下行的代码将色相直方图反向投影到 HSV 图像上:
back_proj = cv2.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)
在详细研究了cv2.calcHist和cv2.calcBackProject函数之后,现在让我们在使用 MeanShift 进行跟踪的脚本中将它们付诸实践。
实现 MeanShift 示例
让我们依次研究一下 MeanShift 示例的实现:
- 像我们的基本背景减除示例一样,MeanShift 示例从捕获(并丢弃)相机的几帧开始,以便自动曝光可以调整:
import cv2
cap = cv2.VideoCapture(0)
# Capture several frames to allow the camera's autoexposure to
# adjust.
for i in range(10):
success, frame = cap.read()
if not success:
exit(1)
- 到第 10 帧,我们假设曝光良好; 因此,我们可以提取兴趣区域的准确直方图。 以下代码定义了兴趣区域(ROI)的边界:
# Define an initial tracking window in the center of the frame.
frame_h, frame_w = frame.shape[:2]
w = frame_w//8
h = frame_h//8
x = frame_w//2 - w//2
y = frame_h//2 - h//2
track_window = (x, y, w, h)
- 然后,以下代码选择 ROI 的像素并将其转换为 HSV 颜色空间:
roi = frame[y:y+h, x:x+w]
hsv_roi = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
- 接下来,我们计算 ROI 的色相直方图:
mask = None
roi_hist = cv2.calcHist([hsv_roi], [0], mask, [180], [0, 180])
- 在计算直方图之后,我们将值归一化为 0 到 255 之间的范围:
cv2.normalize(roi_hist, roi_hist, 0, 255, cv2.NORM_MINMAX)
- 请记住,MeanShift 在达到收敛之前执行了许多迭代。 但是,这种融合并不能保证。 因此,OpenCV 允许我们指定所谓的终止标准。 让我们定义终止条件如下:
# Define the termination criteria:
# 10 iterations or convergence within 1-pixel radius.
term_crit = \
(cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_FPS, 10, 1)
基于这些标准,MeanShift 将在 10 次迭代后(计数标准)或当位移不再大于 1 个像素(ε标准)时停止计算质心偏移。 标志(cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS)的组合表示我们正在使用这两个条件。
- 现在我们已经计算出直方图并定义了 MeanShift 的终止条件,让我们开始通常的循环,在该循环中我们从相机捕获并处理帧。 对于每一帧,我们要做的第一件事就是将其转换为 HSV 颜色空间:
success, frame = cap.read()
while success:
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
- 现在我们有了 HSV 图像,我们可以执行期待已久的直方图反投影操作:
back_proj = cv2.calcBackProject(
[hsv], [0], roi_hist, [0, 180], 1)
- 反投影,跟踪窗口和终止条件可以传递给
cv2.meanShift,这是 OpenCV 对 MeanShift 算法的实现。 这是函数调用:
# Perform tracking with MeanShift.
num_iters, track_window = cv2.meanShift(
back_proj, track_window, term_crit)
请注意,MeanShift 返回其运行的迭代次数,以及找到的新跟踪窗口。 (可选)我们可以将迭代次数与终止条件进行比较,以确定结果是否收敛。 (如果实际的迭代次数小于最大值,则结果必须收敛。)
- 最后,我们绘制并显示更新的跟踪矩形:
# Draw the tracking window.
x, y, w, h = track_window
cv2.rectangle(
frame, (x, y), (x+w, y+h), (255, 0, 0), 2)
cv2.imshow('back-projection', back_proj)
cv2.imshow('meanshift', frame)
那就是整个例子。 如果运行该程序,它将在“计算和反投影颜色直方图”部分中产生与我们之前看到的屏幕截图类似的输出。
到目前为止,您应该对颜色直方图,反投影和 MeanShift 的工作原理有所了解。 但是,前面的程序(通常是 MeanShift)有一个局限性:窗口的大小不会随被跟踪帧中对象的大小而改变。
OpenCV 项目的创始人之一加里·布拉德斯基(Gary Bradski)于 1988 年发表了一篇论文,以提高 MeanShift 的准确率。 他描述了一种称为连续自适应 MeanShift(CAMShift 或 CamShift)的新算法,该算法与 MeanShift 非常相似,但在 MeanShift 时也可以调整跟踪窗口的大小来达到收敛。 接下来,让我们看一下 CamShift 的示例。
使用 CamShift
尽管 CamShift 是比 MeanShift 更复杂的算法,但 OpenCV 为这两种算法提供了非常相似的接口。 主要区别在于对cv2.CamShift的调用将返回一个具有特定旋转的矩形,该旋转随被跟踪对象的旋转而变化。 只需对前面的 MeanShift 示例进行一些修改,我们就可以使用 CamShift 并绘制一个旋转的跟踪矩形。 在以下摘录中,所有必需的更改均以粗体突出显示:
import cv2
import numpy as np
# ... Initialize the tracking window and histogram as previously ...
success, frame = cap.read()
while success:
# Perform back-projection of the HSV histogram onto the frame.
hsv = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
back_proj = cv2.calcBackProject([hsv], [0], roi_hist, [0, 180], 1)
# Perform tracking with CamShift.
rotated_rect, track_window = cv2.CamShift(
back_proj, track_window, term_crit)
# Draw the tracking window.
box_points = cv2.boxPoints(rotated_rect)
box_points = np.int0(box_points)
cv2.polylines(frame, [box_points], True, (255, 0, 0), 2)
cv2.imshow('back-projection', back_proj)
cv2.imshow('camshift', frame)
k = cv2.waitKey(1)
if k == 27: # Escape
break
success, frame = cap.read()
cv2.CamShift的参数未更改; 它们与我们先前示例中的cv2.meanShift的参数具有相同的含义和相同的值。
我们使用cv2.boxPoints函数查找旋转的跟踪矩形的顶点。 然后,我们使用cv2.polylines函数绘制连接这些顶点的线。 以下屏幕截图显示了结果:
到目前为止,您应该熟悉两种跟踪技术。 第一个家庭使用背景减法。 第二种使用直方图反向投影,并结合了 MeanShift 或 CamShift。 现在,让我们认识一下卡尔曼过滤器,它代表了第三族; 它找到趋势,或者换句话说,根据过去的运动预测未来的运动。
使用卡尔曼过滤器查找运动趋势
卡尔曼过滤器是 Rudolf 卡尔曼在 1950 年代后期主要(但并非唯一)开发的算法。 它已经在许多领域中找到了实际应用,特别是从核潜艇到飞机的各种车辆的导航系统。
卡尔曼过滤器对嘈杂的输入数据流进行递归操作,以产生基础系统状态的统计最优估计。 在计算机视觉的背景下,卡尔曼过滤器可以使跟踪对象位置的估计变得平滑。
让我们考虑一个简单的例子。 想一想桌上的一个红色小球,想象一下您有一台照相机对准了现场。 您将球标识为要跟踪的对象,然后用手指轻拂它。 球将根据运动定律开始在桌子上滚动。
如果球在特定方向上以每秒 1 米的速度滚动,则很容易估计一秒钟后球将在哪里:它将在 1 米外。 卡尔曼过滤器应用诸如此类的定律,以基于在先前帧中收集的跟踪结果来预测对象在当前视频帧中的位置。 卡尔曼过滤器本身并没有收集这些跟踪结果,而是基于从另一种算法(例如 MeanShift)得出的跟踪结果来更新其对象运动模型。 自然,卡尔曼过滤器无法预测作用在球上的新力(例如与躺在桌上的铅笔的碰撞),但是它可以根据新的跟踪结果在事后更新其运动模型。 通过使用卡尔曼过滤器,我们可以获得比仅跟踪结果更稳定,更符合运动规律的估计。
了解预测和更新阶段
从前面的描述中,我们得出卡尔曼过滤器的算法具有两个阶段:
- 预测:在第一阶段,卡尔曼过滤器使用直到当前时间点为止计算出的协方差来估计对象的新位置。
- 更新:在第二阶段,卡尔曼过滤器记录对象的位置并为下一个计算周期调整协方差。
用 OpenCV 的术语来说,更新阶段是校正。 因此,OpenCV 通过以下方法提供cv2.KalmanFilter类:
predict([, control]) -> retval
correct(measurement) -> retval
为了平滑跟踪对象,我们将调用predict方法估计对象的位置,然后使用correct方法指示卡尔曼过滤器根据另一种算法的新跟踪结果调整其计算,例如 MeanShift。 但是,在将卡尔曼过滤器与计算机视觉算法结合使用之前,让我们检查一下它如何与来自简单运动传感器的位置数据一起执行。
跟踪鼠标光标
运动传感器在用户界面中已经很长时间了。 计算机的鼠标会感觉到自己相对于桌子等表面的运动。 鼠标是真实的物理对象,因此应用运动定律来预测鼠标坐标的变化是合理的。 我们将作为卡尔曼过滤器的演示来进行此操作。
我们的演示将实现以下操作序列:
- 首先初始化一个黑色图像和一个卡尔曼过滤器。 在窗口中显示黑色图像。
- 每次窗口应用处理输入事件时,请使用卡尔曼过滤器来预测鼠标的位置。 然后,根据实际的鼠标坐标校正卡尔曼过滤器的模型。 在黑色图像的顶部,从旧的预测位置到新的预测位置绘制一条红线,然后从旧的实际位置到新的实际位置绘制一条绿线。 在窗口中显示图形。
- 当用户按下
Esc键时,退出并将图形保存到文件中。
要开始执行脚本,以下代码将初始化一个800 x 800黑色图像:
import cv2
import numpy as np
# Create a black image.
img = np.zeros((800, 800, 3), np.uint8)
现在,让我们初始化卡尔曼过滤器:
# Initialize the Kalman filter.
kalman = cv2.KalmanFilter(4, 2)
kalman.measurementMatrix = np.array(
[[1, 0, 0, 0],
[0, 1, 0, 0]], np.float32)
kalman.transitionMatrix = np.array(
[[1, 0, 1, 0],
[0, 1, 0, 1],
[0, 0, 1, 0],
[0, 0, 0, 1]], np.float32)
kalman.processNoiseCov = np.array(
[[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]], np.float32) * 0.03
基于前面的初始化,我们的卡尔曼过滤器将跟踪 2D 对象的位置和速度。 我们将在第 9 章,“相机模型和增强现实”中更深入地研究卡尔曼过滤器的初始化过程,在其中我们将跟踪 3D 对象的位置,速度,加速度,旋转 ,角速度和角加速度。 现在,让我们仅注意cv2.KalmanFilter(4, 2)中的两个参数。 第一个参数是由卡尔曼过滤器跟踪(或预测)的变量数,在这种情况下为4:x位置,y位置,x速度,以及y速度。 第二个参数是作为测量提供给卡尔曼过滤器的变量的数量,在这种情况下,2:x位置和y位置。 我们还初始化了几个矩阵,这些矩阵描述了所有这些变量之间的关系。
初始化图像和卡尔曼过滤器后,我们还必须声明变量以保存实际(测量)和预测的鼠标坐标。 最初,我们没有坐标,因此我们将None分配给以下变量:
last_measurement = None
last_prediction = None
然后,我们声明一个处理鼠标移动的回调函数。 此函数将更新卡尔曼过滤器的状态,并绘制未过滤鼠标移动和卡尔曼过滤鼠标移动的可视化。 第一次收到鼠标坐标时,我们将初始化卡尔曼过滤器的状态,以便其初始预测与实际的初始鼠标坐标相同。 (如果不这样做,则卡尔曼过滤器将假定鼠标的初始位置为(0, 0)。)随后,每当我们收到新的鼠标坐标时,我们都会用当前测量值校正卡尔曼过滤器,计算卡尔曼预测值,然后, 最后,画两条线:从最后一次测量到当前测量的绿线,以及从最后一次预测到当前预测的红线。 这是回调函数的实现:
def on_mouse_moved(event, x, y, flags, param):
global img, kalman, last_measurement, last_prediction
measurement = np.array([[x], [y]], np.float32)
if last_measurement is None:
# This is the first measurement.
# Update the Kalman filter's state to match the measurement.
kalman.statePre = np.array(
[[x], [y], [0], [0]], np.float32)
kalman.statePost = np.array(
[[x], [y], [0], [0]], np.float32)
prediction = measurement
else:
kalman.correct(measurement)
prediction = kalman.predict() # Gets a reference, not a copy
# Trace the path of the measurement in green.
cv2.line(img, (last_measurement[0], last_measurement[1]),
(measurement[0], measurement[1]), (0, 255, 0))
# Trace the path of the prediction in red.
cv2.line(img, (last_prediction[0], last_prediction[1]),
(prediction[0], prediction[1]), (0, 0, 255))
last_prediction = prediction.copy()
last_measurement = measurement
下一步是初始化窗口并将回调函数传递给cv2.setMouseCallback函数:
cv2.namedWindow('kalman_tracker')
cv2.setMouseCallback('kalman_tracker', on_mouse_moved)
由于大多数程序的逻辑都在鼠标回调中,因此主循环的实现很简单。 我们只是不断地显示更新的图像,直到用户按下Esc键:
while True:
cv2.imshow('kalman_tracker', img)
k = cv2.waitKey(1)
if k == 27: # Escape
cv2.imwrite('kalman.png', img)
break
运行该程序并四处移动鼠标。 如果您突然高速转弯,您会发现预测线(红色)的曲线比测量线(绿色)的曲线宽。 这是因为预测是跟踪到那时鼠标移动的动量。 这是一个示例结果:
前面的图也许会给我们启发下一个示例应用的灵感,我们在其中跟踪行人。
跟踪行人
到目前为止,我们已经熟悉了运动检测,对象检测和对象跟踪的概念。 您可能急于在现实生活中充分利用这些新知识。 让我们通过监视摄像机中的视频跟踪行人来做到这一点。
您可以在samples/data/vtest.avi的 OpenCV 存储库中找到监视视频。 该视频的副本位于chapter08/pedestrians.avi这本书的 GitHub 存储库中。
让我们制定一个计划,然后实现该应用!
规划应用流程
该应用将遵循以下逻辑:
- 从视频文件捕获帧。
- 使用前 20 帧填充背景减法器的历史记录。
- 基于背景减法,使用第 21 帧识别移动的前景对象。 我们将把它们当作行人。 为每个行人分配一个 ID 和一个初始跟踪窗口,然后计算直方图。
- 对于每个后续帧,使用卡尔曼过滤器和 MeanShift 跟踪每个行人。
如果这是一个实际应用,则可能会存储每个行人穿过场景的路线的记录,以便用户稍后进行分析。 但是,这种类型的记录保存超出了本示例的范围。
此外,在实际应用中,您将确保识别出新的行人进入现场。 但是,现在,我们将集中精力仅跟踪视频开始附近场景中的那些对象。
您可以在本书的 GitHub 存储库中的chapter08/track_pedestrians.py找到该应用的代码。 在检查实现之前,让我们简要地讨论一下编程范例以及它们与我们对 OpenCV 的使用之间的关系。
比较面向对象和函数式范式
尽管大多数程序员对 OOP 都不熟悉(或不停地工作),但多年以来,另一种称为 FP 的范例一直在偏爱纯数学基础的程序员中获得支持。
塞缪尔·豪斯(Samuel Howse)的作品展示了具有纯数学基础的编程语言规范。 您可以在这个页面以及他的论文《NummSquared:正式方法》。
FP 将程序视为对数学函数的评估,允许函数返回函数,并允许函数作为函数中的参数。 FP 的优势不仅在于它可以做什么,还在于它可以避免或旨在避免的事情:例如,副作用和状态变化。 如果 FP 主题引起了人们的兴趣,请确保您看一下 Haskell,Clojure 或元语言(ML)之类的语言。
那么,编程方面的副作用是什么? 如果函数产生任何在其本地范围之外可以访问的更改(返回值除外),则该函数具有副作用。 Python 和许多其他语言一样,容易受到副作用的影响,因为它使您可以访问成员变量和全局变量-有时,这种访问可能是偶然的!
在非纯粹函数式的语言中,即使我们反复向其传递相同的参数,其输出也会发生变化。 例如,如果函数将对象作为参数,并且计算依赖于该对象的内部状态,则该函数将根据对象状态的变化返回不同的结果。 这在使用诸如 Python 和 C++ 之类的 OOP 中很常见。
那么,为什么要离题呢? 好吧,这是一个很好的时机,考虑我们自己的样本和 OpenCV 中使用的范例,以及它们与纯数学方法的区别。 在本书中,我们经常使用全局变量或带有成员变量的面向对象的类。 下一个程序是 OOP 的另一个示例。 OpenCV 也包含许多具有副作用的函数和许多面向对象的类。
例如,任何 OpenCV 绘图函数,例如cv2.rectangle或cv2.circle,都会修改我们作为参数传递给它的图像。 这种方法违反了 FP 的基本原则之一:避免副作用和状态变化。
作为简短的练习,让我们将cv2.rectangle包装在另一个 Python 函数中以 FP 样式执行绘图,而没有任何副作用。 以下实现依赖于复制输入图像,而不是修改原始图像:
def draw_rect(img, top_left, bottom_right, color,
thickness, fill=cv2.LINE_AA):
new_img = img.copy()
cv2.rectangle(new_img, top_left, bottom_right, color,
thickness, fill)
return new_img
这种方法-尽管由于copy操作而在计算上更加昂贵-但允许以下代码运行而没有副作用:
frame = camera.read()
frame_with_rect = draw_rect(
frame, (0, 0), (10, 10), (0, 255, 0), 1)
在这里,frame和frame_with_rect是对包含不同值的两个不同 NumPy 数组的引用。 如果我们使用cv2.rectangle而不是受 FP 启发的draw_rect包装器,则frame和frame_with_rect将被引用到一个相同的 NumPy 数组(在原始图像顶部包含一个矩形图) )。
总结一下这一题外话,请注意,各种编程语言和范例都可以成功地应用于计算机视觉问题。 了解多种语言和范例非常有用,这样您就可以为给定的工作选择正确的工具。
现在,让我们回到程序,探索监视应用的实现,跟踪视频中的移动对象。
实现Pedestrian类
卡尔曼过滤器的性质为创建Pedestrian类提供了主要原理。 卡尔曼过滤器可以基于历史观察来预测对象的位置,并且可以基于实际数据来校正预测,但是它只能对一个对象执行此操作。 因此,每个跟踪对象需要一个卡尔曼过滤器。
每个Pedestrian对象都将充当卡尔曼过滤器,彩色直方图(在对象的首次检测时计算并用作后续帧的参考)的支架,以及一个跟踪窗口,MeanShift 算法将使用该跟踪窗口。 此外,每个行人都有一个 ID,我们将显示该 ID,以便我们可以轻松地区分所有被跟踪的行人。 让我们依次完成该类的实现:
- 作为参数,
Pedestrian类的构造器采用 ID,HSV 格式的初始帧和初始跟踪窗口作为参数。 这是该类及其构造器的声明:
import cv2
import numpy as np
class Pedestrian():
"""A tracked pedestrian with a state including an ID, tracking
window, histogram, and Kalman filter.
"""
def __init__(self, id, hsv_frame, track_window):
- 为了开始构造器的实现,我们为 ID,跟踪窗口和 MeanShift 算法的终止条件定义变量:
self.id = id
self.track_window = track_window
self.term_crit = \
(cv2.TERM_CRITERIA_COUNT | cv2.TERM_CRITERIA_EPS, 10, 1)
- 我们通过在初始 HSV 图像中创建兴趣区域的标准化色相直方图来进行操作:
# Initialize the histogram.
x, y, w, h = track_window
roi = hsv_frame[y:y+h, x:x+w]
roi_hist = cv2.calcHist([roi], [0], None, [16], [0, 180])
self.roi_hist = cv2.normalize(roi_hist, roi_hist, 0, 255,
cv2.NORM_MINMAX)
- 然后,我们初始化卡尔曼过滤器:
# Initialize the Kalman filter.
self.kalman = cv2.KalmanFilter(4, 2)
self.kalman.measurementMatrix = np.array(
[[1, 0, 0, 0],
[0, 1, 0, 0]], np.float32)
self.kalman.transitionMatrix = np.array(
[[1, 0, 1, 0],
[0, 1, 0, 1],
[0, 0, 1, 0],
[0, 0, 0, 1]], np.float32)
self.kalman.processNoiseCov = np.array(
[[1, 0, 0, 0],
[0, 1, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 1]], np.float32) * 0.03
cx = x+w/2
cy = y+h/2
self.kalman.statePre = np.array(
[[cx], [cy], [0], [0]], np.float32)
self.kalman.statePost = np.array(
[[cx], [cy], [0], [0]], np.float32)
就像在我们的鼠标跟踪示例中一样,我们正在配置卡尔曼过滤器来预测 2D 点的运动。 作为初始点,我们使用初始跟踪窗口的中心。 这样就完成了构造器的实现。
Pedestrian类还具有update方法,我们将每帧调用一次。 作为参数,update方法采用 BGR 框架(在绘制跟踪结果的可视化时使用)和同一框架的 HSV 版本(用于直方图反投影)。update方法的实现从熟悉的直方图反投影和 MeanShift 代码开始,如以下几行所示:
def update(self, frame, hsv_frame):
back_proj = cv2.calcBackProject(
[hsv_frame], [0], self.roi_hist, [0, 180], 1)
ret, self.track_window = cv2.meanShift(
back_proj, self.track_window, self.term_crit)
x, y, w, h = self.track_window
center = np.array([x+w/2, y+h/2], np.float32)
- 请注意,我们要提取跟踪窗口的中心坐标,因为我们要对其进行卡尔曼滤波。 我们将继续执行此操作,然后更新跟踪窗口,使其以校正后的坐标为中心:
prediction = self.kalman.predict()
estimate = self.kalman.correct(center)
center_offset = estimate[:,0][:2] - center
self.track_window = (x + int(center_offset[0]),
y + int(center_offset[1]), w, h)
x, y, w, h = self.track_window
- 为了总结
update方法,我们将卡尔曼过滤器的预测绘制为蓝色圆圈,将校正后的跟踪窗口绘制为青色矩形,并将行人的 ID 绘制为矩形上方的蓝色文本:
# Draw the predicted center position as a circle.
cv2.circle(frame, (int(prediction[0]), int(prediction[1])),
4, (255, 0, 0), -1)
# Draw the corrected tracking window as a rectangle.
cv2.rectangle(frame, (x,y), (x+w, y+h), (255, 255, 0), 2)
# Draw the ID above the rectangle.
cv2.putText(frame, 'ID: %d' % self.id, (x, y-5),
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 0, 0),
1, cv2.LINE_AA)
这就是我们需要与单个行人关联的所有特征和数据。 接下来,我们需要实现一个程序,该程序提供创建和更新Pedestrian对象所需的视频帧。
实现main函数
现在我们有了Pedestrian类来维护有关每个行人跟踪的数据,让我们实现程序的main函数。 我们将依次查看实现的各个部分:
- 我们首先加载视频文件,初始化背景减法器,然后设置背景减法器的历史记录长度(即影响背景模型的帧数):
def main():
cap = cv2.VideoCapture('pedestrians.avi')
# Create the KNN background subtractor.
bg_subtractor = cv2.createBackgroundSubtractorKNN()
history_length = 20
bg_subtractor.setHistory(history_length)
- 然后,我们定义形态核:
erode_kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (3, 3))
dilate_kernel = cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (8, 3))
- 我们定义了一个名为
pedestrians的列表,该列表最初是空的。 稍后,我们将Pedestrian对象添加到此列表中。 我们还设置了一个帧计数器,用于确定是否经过了足够的帧以填充背景减法器的历史记录。 以下是变量的相关定义:
pedestrians = []
num_history_frames_populated = 0
- 现在,我们开始循环。 在每次迭代的开始,我们尝试读取视频帧。 如果失败(例如,在视频文件的末尾),则退出循环:
while True:
grabbed, frame = cap.read()
if (grabbed is False):
break
- 继续循环的主体,我们根据新捕获的帧更新背景减法器。 如果背景减法器的历史记录尚未满,我们将继续循环的下一个迭代。 以下是相关代码:
# Apply the KNN background subtractor.
fg_mask = bg_subtractor.apply(frame)
# Let the background subtractor build up a history.
if num_history_frames_populated < history_length:
num_history_frames_populated += 1
continue
- 一旦背景减法器的历史记录已满,我们将对每个新捕获的帧进行更多处理。 具体来说,我们采用与本章前面的背景减法器相同的方法:对前景遮罩执行阈值化,腐蚀和扩张; 然后我们检测轮廓,这些轮廓可能是移动的对象:
# Create the thresholded image.
_, thresh = cv2.threshold(fg_mask, 127, 255,
cv2.THRESH_BINARY)
cv2.erode(thresh, erode_kernel, thresh, iterations=2)
cv2.dilate(thresh, dilate_kernel, thresh, iterations=2)
# Detect contours in the thresholded image.
contours, hier = cv2.findContours(
thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
- 我们还将帧转换为 HSV 格式,因为我们打算将这种格式的直方图用于 MeanShift。 下面的代码行执行转换:
hsv_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2HSV)
- 一旦有了轮廓和框架的 HSV 版本,我们就可以检测和跟踪移动的对象了。 我们为每个轮廓找到并绘制一个边界矩形,该矩形足够大以适合行人。 而且,如果尚未填充
pedestrians列表,我们现在可以通过基于每个边界矩形(以及 HSV 图像的相应区域)添加一个新的Pedestrian对象来进行填充。 这是按照我们刚刚描述的方式处理轮廓的子循环:
# Draw rectangles around large contours.
# Also, if no pedestrians are being tracked yet, create some.
should_initialize_pedestrians = len(pedestrians) == 0
id = 0
for c in contours:
if cv2.contourArea(c) > 500:
(x, y, w, h) = cv2.boundingRect(c)
cv2.rectangle(frame, (x, y), (x+w, y+h),
(0, 255, 0), 1)
if should_initialize_pedestrians:
pedestrians.append(
Pedestrian(id, frame, hsv_frame,
(x, y, w, h)))
id += 1
- 现在,我们有了我们要跟踪的行人的列表。 我们将每个
Pedestrian对象的update方法都调用,将原始 BGR 帧(用于绘图)和 HSV 帧(用于借助 MeanShift 进行跟踪)传递给该方法。 请记住,每个Pedestrian对象都负责绘制自己的信息(文本,跟踪矩形和卡尔曼过滤器的预测)。 这是更新pedestrians列表的子循环:
# Update the tracking of each pedestrian.
for pedestrian in pedestrians:
pedestrian.update(frame, hsv_frame)
- 最后,我们在一个窗口中显示跟踪结果,并允许用户随时按
Esc键退出程序:
cv2.imshow('Pedestrians Tracked', frame)
k = cv2.waitKey(110)
if k == 27: # Escape
break
if __name__ == "__main__":
main()
在那里,您可以找到:MeanShift 与卡尔曼过滤器协同工作,以跟踪移动的对象。 一切顺利,您应该可以通过以下方式看到跟踪结果:
在此裁剪的屏幕截图中,带有细边框的绿色矩形是检测到的轮廓,带有粗边框的青色矩形是经过卡尔曼校正的 MeanShift 跟踪矩形,而蓝点是由卡尔曼过滤器预测的中心位置。
像往常一样,随时尝试使用脚本。 您可能需要调整参数,尝试使用 MOG 背景减法器代替 KNN,或尝试使用 CamShift 代替 MeanShift。 这些更改应该只影响几行代码。 完成后,接下来,我们将考虑可能对脚本的结构产生更大影响的其他可能的修改。
考虑下一步
可以根据特定应用的需求以各种方式扩展和改进前面的程序。 请考虑以下示例:
- 如果卡尔曼过滤器预测行人的位置在框架之外,则可以从
pedestrians列表中删除Pedestrian对象(从而销毁Pedestrian对象)。 - 您可以检查每个检测到的移动对象是否对应于
pedestrians列表中的现有Pedestrian实例,如果不存在,则向列表中添加一个新对象,以便在后续帧中对其进行跟踪。 - 您可以训练支持向量机(SVM)并将其用于分类每个运动对象。 使用这些方法,您可以确定运动对象是否是您要跟踪的对象。 例如,一条狗可能会进入场景,但是您的应用可能只需要跟踪人类。 有关训练 SVM 的更多信息,请参阅第 7 章,“构建自定义对象检测器”。
无论您有什么需要,本章都希望为您提供构建满足您要求的 2D 跟踪应用所需的知识。
总结
本章介绍了视频分析,尤其是选择了一些有用的跟踪对象技术。
我们首先通过计算帧差异的基本运动检测技术来学习背景减法。 然后,我们继续使用 OpenCV 的cv2.BackgroundSubtractor类中实现的更复杂和有效的背景减法算法-MOG 和 KNN。
然后,我们继续探索 MeanShift 和 CamShift 跟踪算法。 在此过程中,我们讨论了颜色直方图和反投影。 我们还熟悉卡尔曼过滤器及其在平滑跟踪算法结果中的作用。 最后,我们将所有知识汇总到一个示例监视应用中,该应用能够跟踪视频中的行人(或其他移动物体)。
到目前为止,我们在 OpenCV,计算机视觉和机器学习方面的基础正在巩固。 在本书的其余两章中,我们可以期待几个高级主题。 我们将在第 9 章,“相机模型和增强现实”中扩展对 3D 空间的跟踪知识。 然后,我们将讨论人工神经网络(ANN),并在第 10 章“使用 OpenCV 的神经网络”中介绍,从而更深入地研究人工智能。
OpenCV人脸检测与目标跟踪
2322

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



