PyTorch 现代计算机视觉第二版(四)
原文:
zh.annas-archive.org/md5/355d709877e6e04dc1540c8ccd0b447d
译者:飞龙
第七章:物体检测基础
在前几章中,我们学习了如何执行图像分类。想象一下利用计算机视觉进行自动驾驶汽车的场景。不仅需要检测图像中是否包含车辆、人行道和行人等物体,还需要准确识别这些物体的位置。在这种场景下,我们将在本章和下一章中学习的各种物体检测技术将非常有用。
在本章和下一章中,我们将学习一些执行物体检测的技术。我们将从学习基础知识开始 - 使用名为ybat
的工具对图像中对象的地面实况边界框进行标记,使用selectivesearch
方法提取区域提议,并通过交并比(IoU)和均值平均精度度量来定义边界框预测的准确性。之后,我们将学习两个基于区域提议的网络 - R-CNN 和 Fast R-CNN - 首先了解它们的工作细节,然后在包含卡车和公共汽车图像的数据集上实施它们。
本章将涵盖以下主题:
-
引入物体检测
-
创建用于训练的边界框地面实况
-
理解区域提议
-
理解 IoU、非极大值抑制和均值平均精度
-
训练基于 R-CNN 的自定义物体检测器
-
训练基于 Fast R-CNN 的自定义物体检测器
本章中的所有代码片段都可以在 GitHub 存储库的
Chapter07
文件夹中找到,链接为bit.ly/mcvp-2e
。
引入物体检测
随着自动驾驶汽车、面部检测、智能视频监控和人数统计解决方案的兴起,快速准确的物体检测系统需求量大增。这些系统不仅包括从图像中对物体进行分类,还包括在每个物体周围绘制适当边界框以定位它们。这(绘制边界框和分类)使得物体检测比其传统的计算机视觉前辈图像分类更为复杂。
在探索物体检测的广泛用例之前,让我们了解它如何增强我们在上一章中介绍的物体分类任务。想象一下图像中有多个物体的情况。我让你预测图像中存在的物体类别。例如,假设图像中既有猫又有狗。你会如何对这样的图像进行分类?物体检测在这种场景中非常有用,它不仅预测物体的位置(边界框),还预测各个边界框内存在的物体类别。
要理解物体检测的输出是什么样的,请查看以下图表:
图 7.1:对象分类与检测之间的区别
在前述图中,我们可以看到,典型的对象分类仅仅提到图像中存在的对象类别,而对象定位则在图像中的对象周围绘制边界框。另一方面,对象检测涉及绘制边界框以及识别图像中多个对象的边界框中对象的类别。
利用对象检测的一些不同用例包括以下内容:
-
安全性:这对识别入侵者很有用。
-
自动驾驶汽车:这对识别道路图像中各种对象很有帮助。
-
图像搜索:这有助于识别包含感兴趣对象(或人员)的图像。
-
汽车: 这可以帮助识别汽车图像中的车牌号码。
在所有前述情况下,对象检测被利用来在图像中的各种对象周围绘制边界框。
在本章中,我们将学习预测对象的类别,并在图像中围绕对象创建一个紧密的边界框,这是定位任务。我们还将学习检测图像中多个对象对应的类别,以及围绕每个对象的边界框,这是对象检测任务。
训练典型对象检测模型包括以下步骤:
-
创建包含图像中各种对象的边界框标签和类别的真值数据
-
提出扫描图像以识别可能包含对象的区域(区域建议)的机制
在本章中,我们将学习利用名为SelectiveSearch
的方法生成的区域建议。在下一章中,我们将学习如何利用锚框来识别包含对象的区域。
-
通过使用 IoU 指标创建目标类别变量
-
创建目标边界框偏移变量,以纠正区域建议在步骤 2中的位置
-
构建一个模型,可以预测对象的类别,同时还能预测与区域建议对应的目标边界框偏移量
-
使用均值平均精度(mAP)来衡量对象检测的准确性
现在我们已经对训练对象检测模型要做的事情有了高层次的概述,我们将在下一节学习为边界框创建数据集(这是构建对象检测模型的第一步)。
创建用于训练的边界框真值
我们已经了解到目标检测以边界框的形式给出感兴趣对象的输出图像。为了构建能够检测这些边界框的算法,我们需要创建输入输出组合,其中输入是图像,输出是边界框和物体类别。
请注意,当我们检测到边界框时,我们实际上是检测到围绕图像的边界框的四个角的像素位置。
要训练一个提供边界框的模型,我们需要图像及其图像中所有物体的对应边界框坐标。在本节中,我们将学习创建训练数据集的一种方法,其中图像是输入,而对应的边界框和物体类别存储在 XML 文件中作为输出。
在这里,我们将安装并使用ybat
来创建(标注)图像中物体周围的边界框。我们还将检查包含注释类和边界框信息的 XML 文件。
请注意,还有像 CVAT 和 Label Studio 这样的替代图像标注工具。
让我们从 GitHub 下载ybat-master.zip
(github.com/drainingsun/ybat
),然后解压缩它。然后,使用您选择的浏览器打开ybat.html
。
在我们开始创建与图像对应的真实标签之前,让我们指定我们想要跨图像标记的所有可能类,并将其存储在classes.txt
文件中,如下所示:
图 7.2:提供类名
现在,让我们准备与图像对应的真实标签。这涉及到在物体周围绘制边界框(如以下步骤中所见的人物),并为图像中存在的对象分配标签/类别:
-
上传您想要标注的所有图像。
-
上传
classes.txt
文件。 -
通过首先选择文件名,然后在要标记的每个对象周围绘制十字线来为每个图像进行标记。在绘制十字线之前,请确保在以下图像中步骤 2下正确选择
classes
区域中的类别(classes
窗格可以看到以下图像中步骤 2之下)。 -
将数据转储保存为所需格式。每种格式都是由不同的研究团队独立开发的,它们都同样有效。基于它们的流行度和便利性,每个实现都更喜欢不同的格式。
正如你所看到的,在下图中表示了前述步骤:
图 7.3:标注步骤
例如,当我们下载 PascalVOC 格式时,它会下载一个 XML 文件的压缩包。在 GitHub 上,可以看到绘制矩形边界框后 XML 文件的快照,文件名为sample_xml_file. xml
。在那里,您将观察到bndbox
字段包含感兴趣图像中对象的x和y坐标的最小和最大值。我们还应该能够使用name
字段提取图像中对象对应的类别。
现在我们了解了如何创建图像中存在对象的真实对象(类别标签和边界框),让我们深入了解识别图像中对象的基本构建块。首先,我们将介绍有助于突出显示最可能包含对象部分的区域建议。
理解区域建议
想象一个假设情景,感兴趣图像中包含一个人和背景的天空。假设背景(天空)的像素强度变化很小,而前景(人物)的像素强度变化很大。
仅从上述描述本身,我们可以得出这里有两个主要区域 - 人物和天空。此外,在人物图像的区域内,对应头发的像素与对应脸部的像素强度不同,建立了区域内可能存在多个子区域的事实。
区域建议 是一种技术,有助于识别区域岛,其中像素彼此相似。生成区域建议对于目标检测非常有用,其中我们必须识别图像中存在的对象的位置。此外,由于区域建议生成了一个区域的建议,它有助于目标定位,其中的任务是识别一个完全适合对象周围的边界框。我们将在稍后的部分,基于训练 R-CNN 的自定义对象检测器中学习区域建议如何协助对象的定位和检测,但首先让我们了解如何从图像中生成区域建议。
利用 SelectiveSearch 生成区域建议
SelectiveSearch 是用于目标定位的区域建议算法,它根据像素强度生成可能被一起分组的区域建议。SelectiveSearch 根据类似像素的层次分组像素,进而利用图像中内容的颜色、纹理、大小和形状的兼容性进行分组。
首先,SelectiveSearch 通过根据前述属性分组像素来过度分割图像。然后,它遍历这些过度分割的群组,并根据相似性将它们分组。在每次迭代中,它将较小的区域组合成较大的区域。
让我们通过以下示例了解selectivesearch
的过程:
在本书的 GitHub 仓库Chapter07
文件夹中的Understanding_selectivesearch.ipynb
中找到此练习的完整代码,网址为bit.ly/mcvp-2e
。
-
安装所需的包:
%pip install selectivesearch %pip install torch_snippets from torch_snippets import * import selectivesearch from skimage.segmentation import felzenszwalb
-
获取并加载所需的图像:
!wget https://www.dropbox.com/s/l98leemr7/Hemanvi.jpeg img = read('Hemanvi.jpeg', 1)
-
从图像中提取基于颜色、纹理、大小和形状兼容性的
felzenszwalb
分段:segments_fz = felzenszwalb(img, scale=200)
请注意,在felzenszwalb
方法中,scale
表示可以在图像段内形成的簇的数量。scale
值越高,保留的原始图像细节就越多。
-
绘制原始图像和带有分割的图像:
subplots([img, segments_fz], titles=['Original Image', 'Image post\nfelzenszwalb segmentation'], sz=10, nc=2)
图 7.4:原始图像及其相应的分割
从前述输出中,请注意属于同一组的像素具有相似的像素值。
具有相似值的像素形成区域提议。这有助于目标检测,因为我们现在将每个区域提议传递给网络,并要求它预测区域提议是背景还是对象。此外,如果它是对象,它还帮助我们识别获取与对象对应的紧凑边界框的偏移量,以及区域提议内内容对应的类别。
现在我们了解了 SelectiveSearch 的作用,让我们实现selectivesearch
函数来获取给定图像的区域提议。
实施 SelectiveSearch 来生成区域提议
在本节中,我们将定义extract_candidates
函数,使用selectivesearch
来在后续关于训练基于 R-CNN 和 Fast R-CNN 的自定义目标检测器的部分中使用它:
-
导入相关包并获取图像:
%pip install selectivesearch %pip install torch_snippets from torch_snippets import * import selectivesearch !wget https://www.dropbox.com/s/l98leemr7/Hemanvi.jpeg img = read('Hemanvi.jpeg', 1)
-
定义
extract_candidates
函数,从图像中获取区域提议:- 定义以图像作为输入参数的函数:
def extract_candidates(img):
- 使用
selective_search
方法在图像内获取候选区域:
img_lbl, regions = selectivesearch.selective_search(img, scale=200, min_size=100)
- 计算图像面积并初始化一个候选列表,用于存储通过定义的阈值的候选者:
img_area = np.prod(img.shape[:2]) candidates = []
- 仅获取那些超过总图像面积的 5%并且小于或等于图像面积的 100%的候选者(区域),然后返回它们:
for r in regions: if r['rect'] in candidates: continue if r['size'] < (0.05*img_area): continue if r['size'] > (1*img_area): continue x, y, w, h = r['rect'] candidates.append(list(r['rect'])) return candidates
-
提取候选区域并在图像顶部绘制它们:
candidates = extract_candidates(img) show(img, bbs=candidates)
图 7.5:图像中的区域提议
前图中的网格代表来自selective_search
方法的候选区域(区域提议)。
现在我们了解了区域提议生成,还有一个问题没有解答。我们如何利用区域提议进行目标检测和定位?
在感兴趣图像中,与对象位置(地面真实位置)具有高交集的区域提议被标记为包含对象的区域,而交集较低的区域提议被标记为背景。在接下来的部分,我们将学习如何计算区域提议候选与地面真实边界框的交集,在我们理解构建物体检测模型背后的各种技术的旅程中。
理解 IoU
想象一种情况,我们为对象提出了一个边界框的预测。我们如何衡量我们预测的准确性?在这种情况下,IoU 的概念非常有用。
在术语“交并比”中,“交集”一词指的是预测边界框与实际边界框重叠的程度,而“并集”则指的是用于重叠的总体空间。 IoU 是两个边界框之间重叠区域与两个边界框组合区域的比率。
这可以用下图表示:
图 7.6:可视化 IoU
在上述两个边界框(矩形)的图示中,让我们将左边界框视为地面真实位置,右边界框视为对象的预测位置。作为度量标准的 IoU 是两个边界框之间重叠区域与组合区域的比率。在下图中,您可以观察 IoU 度量标准随着边界框重叠变化的情况:
图 7.7:不同情况下 IoU 值的变化
我们可以看到随着重叠减少,IoU 也会减少,在最后一个图中,当没有重叠时,IoU 度量为 0。
现在我们了解了如何测量 IoU,让我们在代码中实现它,并创建一个计算 IoU 的函数,因为我们将在训练 R-CNN 和训练 Fast R-CNN 的部分中利用它。
在 GitHub 的Chapter07
文件夹中的Calculating_Intersection_Over_Union.ipynb
文件中查找以下代码:bit.ly/mcvp-2e
。
让我们定义一个函数,该函数以两个边界框作为输入,并返回 IoU 作为输出:
-
指定
get_iou
函数,该函数以boxA
和boxB
作为输入,其中boxA
和boxB
是两个不同的边界框(可以将boxA
视为地面真实边界框,boxB
视为区域提议):def get_iou(boxA, boxB, epsilon=1e-5):
我们定义了epsilon
参数来处理罕见的情况,即两个框之间的并集为 0,导致除零错误。请注意,在每个边界框中,将有四个值对应于边界框的四个角。
-
计算交集框的坐标:
x1 = max(boxA[0], boxB[0]) y1 = max(boxA[1], boxB[1]) x2 = min(boxA[2], boxB[2]) y2 = min(boxA[3], boxB[3])
请注意,x1
存储两个边界框之间最左侧 x 值的最大值。类似地,y1
存储最上面的 y 值,x2
和y2
分别存储交集部分的最右 x 值和最下 y 值。
-
计算交集区域的宽度和高度(
width
和height
):width = (x2 - x1) height = (y2 - y1)
-
计算重叠区域的面积(
area_overlap
):if (width<0) or (height <0): return 0.0 area_overlap = width * height
请注意,在前面的代码中,如果与重叠区域对应的宽度或高度小于 0,则交集的面积为 0。否则,我们计算重叠(交集)的面积类似于计算矩形面积的方式 - 宽度乘以高度。
-
计算对应于两个边界框的组合面积:
area_a = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) area_b = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) area_combined = area_a + area_b - area_overlap
在前面的代码中,我们计算两个边界框的组合面积 - area_a
和 area_b
- 然后在计算area_combined
时减去重叠的区域,因为在计算area_a
和area_b
时,area_overlap
被计算了两次。
-
计算 IoU 值并返回它:
iou = area_overlap / (area_combined+epsilon) return iou
在前面的代码中,我们计算iou
作为重叠区域的面积(area_overlap
)与两个边界框组合区域的面积(area_combined
)之比,并返回结果。
到目前为止,我们已经学习了如何创建地面实况并计算 IoU,这有助于准备训练数据。
在后续部分,我们将推迟构建模型,因为训练模型涉及更多的步骤,并且在训练模型之前,我们还需要学习更多组件。在下一节中,我们将学习非极大值抑制,它有助于在推断时缩小围绕对象的不同可能预测边界框。
非极大值抑制
想象一种情景,其中生成了多个区域建议,并且彼此显著重叠。基本上,所有预测的边界框坐标(对于区域建议的偏移量)彼此显著重叠。例如,让我们考虑以下图像,在图像中为人生成了多个区域建议:
图 7.8:图像和可能的边界框
我们如何确定在许多区域建议中,我们将考虑作为包含对象的边界框以及我们将放弃的边界框?在这种情况下,非极大值抑制非常有用。让我们解释一下这个术语。
非极大值指的是那些概率最高但不包含对象的边界框,抑制指的是我们放弃这些边界框。在非极大值抑制中,我们确定具有最高概率包含对象的边界框,并且丢弃所有 IoU 低于某个阈值的其他边界框,这些边界框显示具有最高概率包含对象。
在 PyTorch 中,使用 torchvision.ops
模块中的 nms
函数执行非极大值抑制。nms
函数接受边界框坐标、边界框中对象的置信度以及跨边界框的 IoU 阈值,从而确定要保留的边界框。在预测新图像中对象类别和边界框时,您将利用 nms
函数,同时涵盖训练基于 R-CNN 的自定义对象检测器和训练基于 Fast R-CNN 的自定义对象检测器部分。
平均精确度
到目前为止,我们已经看到得到的输出包括图像中每个对象周围的边界框和边界框内对象对应的类别。接下来的问题是:如何量化模型预测的准确性?mAP 在这种情况下派上了用场。
在我们尝试理解 mAP 之前,让我们首先理解精确度,然后是平均精确度,最后是 mAP:
- 精确度: 通常,我们计算精确度如下:
真正的正样本是指预测正确对象类别的边界框,并且其与真实值之间的 IoU 大于某个阈值。错误的正样本是指预测类别错误或与真实值之间的重叠小于定义的阈值的边界框。此外,如果为同一真实边界框识别出多个边界框,则只能有一个边界框是真正的正样本,其他都是错误的正样本。
-
平均精确度: 平均精确度是在各种 IoU 阈值上计算得出的精确度值的平均值。
-
mAP: mAP 是在数据集中所有对象类别上,通过各种 IoU 阈值计算得出的精确度值的平均值。
到目前为止,我们已经看到为模型准备训练数据集,对模型预测执行非极大值抑制,并计算其准确性。在接下来的几节中,我们将学习如何训练一个(基于 R-CNN 和 Fast R-CNN 的)模型来检测新图像中的对象。
训练基于 R-CNN 的自定义对象检测器
R-CNN 代表基于区域的卷积神经网络。在 R-CNN 中,基于区域指的是用于识别图像中对象的区域提议。请注意,R-CNN 协助识别图像中存在的对象及其位置。
在接下来的几节中,我们将学习关于 R-CNN 的工作细节,然后在我们的自定义数据集上对其进行训练。
R-CNN 的工作细节
让我们通过下面的图表来对基于 R-CNN 的对象检测有一个高层次的理解:
图 7.9:R-CNN 步骤序列(图片来源:https://arxiv.org/pdf/1311.2524.pdf
)
在利用 R-CNN 技术进行对象检测时,我们执行以下步骤:
-
从图像中提取区域提案。我们需要确保提取出大量的提案,以免错过图像中的任何潜在对象。
-
调整(变形)所有提取的区域以获得相同大小的区域。
-
将调整大小后的区域提案通过网络传递。通常情况下,我们会通过预训练模型(如 VGG16 或 ResNet50)传递调整大小后的区域提案,并在全连接层中提取特征。
-
创建用于模型训练的数据,其中输入是通过预训练模型传递区域提案提取的特征。输出是每个区域提案对应的类别以及与图像对应的地面实况边界框的区域提案偏移量。
如果一个区域提案与对象的 IoU 大于特定阈值,则创建训练数据。在这种情况下,该区域任务是预测其重叠对象的类别以及与包含感兴趣对象的地面实况边界框相关的区域提案的偏移量。以下展示了样本图像、区域提案和地面实况边界框:
图 7.10:带有区域提案和地面实况边界框的样本图像
在前述图像中,o
(红色)表示区域提案的中心(虚线边界框),x
表示与cat
类别对应的地面实况边界框的中心(实线边界框)。我们计算区域提案边界框与地面实况边界框之间的偏移量,作为两个边界框中心坐标之间的差异(dx
,dy
)及边界框高度和宽度之间的差异(dw
,dh
)。
- 连接两个输出头,一个对应图像类别,另一个对应区域提案与地面实况边界框的偏移量,以提取对象的精细边界框。
这个练习类似于预测性别(一个类别变量,类似于本案例研究中的对象类别)和年龄(一个连续变量,类似于对区域提案进行的偏移量),基于第五章中一个人脸图像的用例。
- 编写自定义损失函数来训练模型,该函数将最小化对象分类误差和边界框偏移误差。
注意,我们要最小化的损失函数与原始论文中优化的损失函数不同(arxiv.org/pdf/1311.2524.pdf
)。我们这样做是为了减少从头开始构建 R-CNN 和 Fast R-CNN 所带来的复杂性。一旦您熟悉了模型的工作原理,并能够使用接下来两节中的代码构建模型,我们强烈建议您从头开始实现原始论文中的模型。
在接下来的部分,我们将学习获取数据集和为训练创建数据。在此之后的部分中,我们将学习设计模型并对其进行训练,然后在新图像中预测存在的对象类别及其边界框。
在自定义数据集上实现物体检测的 R-CNN。
到目前为止,我们已经理解了 R-CNN 的工作原理。现在我们将学习如何为训练创建数据。该过程涉及以下步骤:
-
下载数据集。
-
准备数据集。
-
定义区域提议提取和 IoU 计算函数。
-
创建训练数据。
-
为模型创建输入数据。
-
调整区域提议的大小。
-
通过预训练模型将它们传递以获取完全连接层的值。
-
为模型创建输出数据。
-
为每个区域提议标记类别或背景标签。
-
定义区域提议相对于地面真值的偏移量(如果区域提议对应于对象而不是背景)。
-
定义并训练模型。
-
在新图像上进行预测。
让我们开始编码以下各节。
下载数据集。
对于物体检测的情况,我们将从 Google Open Images v6 数据集中下载数据(网址为storage.googleapis.com/openimages/v5/test-annotations-bbox.csv
)。然而,在代码中,我们将仅处理公交车或卡车的图像,以确保我们可以训练图像(正如您很快会注意到使用selectivesearch
时的内存问题)。在第十章,目标检测和分割的应用中,我们将扩展我们将在其中训练的类别数量(除了公交车和卡车之外还有更多类别):
在 GitHub 的Chapter07
文件夹中的Training_RCNN.ipynb
文件中找到以下代码,网址为bit.ly/mcvp-2e
。该代码包含用于下载数据的 URL,并且长度适中。我们强烈建议您在 GitHub 上执行笔记本,以帮助您重现结果并理解文本中的步骤和各种代码组件。
导入相关包以下载包含图像及其地面真实值的文件:
%pip install -q --upgrade selectivesearch torch_snippets
from torch_snippets import *
import selectivesearch
from google.colab import files
files.upload() # upload kaggle.json file
!mkdir -p ~/.kaggle
!mv kaggle.json ~/.kaggle/
!ls ~/.kaggle
!chmod 600 /root/.kaggle/kaggle.json
!kaggle datasets download -d sixhky/open-images-bus-trucks/
!unzip -qq open-images-bus-trucks.zip
from torchvision import transforms, models, datasets
from torch_snippets import Report
from torchvision.ops import nms
device = 'cuda' if torch.cuda.is_available() else 'cpu'
一旦执行上述代码,我们应该将图像及其相应的地面真实值存储在可用的 CSV 文件中。
准备数据集。
现在我们已经下载了数据集,我们将准备数据集。该过程涉及以下步骤:
-
获取每个图像及其相应的类别和边界框值。
-
获取每个图像中的区域提议,它们对应的 IoU 值,以及区域提议相对于地面真值将被校正的增量。
-
为每个类别分配数字标签(除了公交车和卡车类别之外,我们还有一个额外的背景类别,其中 IoU 与地面真实边界框低于阈值)。
-
将每个区域建议调整为一个公共大小,以便将它们传递给网络
在此练习结束时,我们将已经调整了区域建议的裁剪大小,为每个区域建议分配了真实的类别,并计算了区域建议相对于真实边界框的偏移量。我们将从上一节结束的地方继续编码:
-
指定图像的位置,并读取我们下载的 CSV 文件中存在的真实值:
IMAGE_ROOT = 'images/images' DF_RAW = pd.read_csv('df.csv') print(DF_RAW.head())
图 7.11:样本数据
注意,XMin
、XMax
、YMin
和 YMax
对应于图像边界框的真实值。此外,LabelName
提供了图像的类别。
-
定义一个类,返回图像及其对应的类和真实值,以及图像的文件路径:
- 将数据框(
df
)和包含图像的文件夹路径(image_folder
)作为输入传递给__init__
方法,并获取数据框中存在的唯一ImageID
值(self.unique_images
)。我们这样做是因为一张图像可能包含多个对象,因此多行可以对应相同的ImageID
值:
class OpenImages(Dataset): def __init__(self, df, image_folder=IMAGE_ROOT): self.root = image_folder self.df = df self.unique_images = df['ImageID'].unique() def __len__(self): return len(self.unique_images)
- 定义
__getitem__
方法,在此方法中,我们获取索引(ix
)对应的图像(image_id
),获取其边界框坐标(boxes
)和类别,并返回图像、边界框、类别和图像路径:
def __getitem__(self, ix): image_id = self.unique_images[ix] image_path = f'{self.root}/{image_id}.jpg' # Convert BGR to RGB image = cv2.imread(image_path, 1)[...,::-1] h, w, _ = image.shape df = self.df.copy() df = df[df['ImageID'] == image_id] boxes = df['XMin,YMin,XMax,YMax'.split(',')].values boxes = (boxes*np.array([w,h,w,h])).astype(np.uint16).tolist() classes = df['LabelName'].values.tolist() return image, boxes, classes, image_path
- 将数据框(
-
检查一个样本图像及其对应的类和边界框真实值:
ds = OpenImages(df=DF_RAW) im, bbs, clss, _ = ds[9] show(im, bbs=bbs, texts=clss, sz=10)
图 7.12:带有真实边界框和对象类别的样本图像
-
定义
extract_iou
和extract_candidates
函数:def extract_candidates(img): img_lbl,regions = selectivesearch.selective_search(img, scale=200, min_size=100) img_area = np.prod(img.shape[:2]) candidates = [] for r in regions: if r['rect'] in candidates: continue if r['size'] < (0.05*img_area): continue if r['size'] > (1*img_area): continue x, y, w, h = r['rect'] candidates.append(list(r['rect'])) return candidates def extract_iou(boxA, boxB, epsilon=1e-5): x1 = max(boxA[0], boxB[0]) y1 = max(boxA[1], boxB[1]) x2 = min(boxA[2], boxB[2]) y2 = min(boxA[3], boxB[3]) width = (x2 - x1) height = (y2 - y1) if (width<0) or (height <0): return 0.0 area_overlap = width * height area_a = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1]) area_b = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1]) area_combined = area_a + area_b - area_overlap iou = area_overlap / (area_combined+epsilon) return iou
现在,我们已经定义了准备数据和初始化数据加载器所需的所有函数。接下来,我们将获取区域建议(模型的输入区域)及其与边界框偏移量的真实值,以及对象的类别(预期输出)。
获取区域建议及其偏移量的真实值
在本节中,我们将学习如何创建与我们模型对应的输入和输出值。输入由使用 selectivesearch
方法提取的候选区域组成,输出包括与候选区域相对应的类别以及如果候选区域包含对象时其相对于最大重叠边界框的偏移量。
我们将从上一节结束的地方继续编码:
-
初始化空列表来存储文件路径(
FPATHS
),真实边界框(GTBBS
),对象类别(CLSS
),边界框与区域建议的偏移量(DELTAS
),区域建议位置(ROIS
)以及区域建议与真实值的 IoU(IOUS
):FPATHS, GTBBS, CLSS, DELTAS, ROIS, IOUS = [],[],[],[],[],[]
-
遍历数据集并填充在步骤 1中初始化的列表:
- 对于这个练习,我们可以使用所有数据点进行训练,或者只使用前 500 个数据点进行演示。您可以在两者之间选择,这将决定训练时间和训练准确度(数据点越多,训练时间和准确度越高):
N = 500 for ix, (im, bbs, labels, fpath) in enumerate(ds): if(ix==N): break
在上述代码中,我们指定将处理 500 张图像:
- 使用
extract_candidates
函数从每个图像(im
)中提取候选项的绝对像素值(注意,XMin
、Xmax
、YMin
和YMax
是作为已下载数据帧中图像形状的比例提供的),并将提取的区域坐标从(x,y,w,h)系统转换为(x,y,x+w,y+h)系统:
H, W, _ = im.shape candidates = extract_candidates(im) candidates = np.array([(x,y,x+w,y+h) for x,y,w,h in candidates])
- 初始化
ious
、rois
、deltas
和clss
,作为存储每个图像中每个候选项的 IoU、区域提议位置、边界框偏移和对应类别的列表。我们将浏览所有选择性搜索中的提议,并将具有高 IoU 的提议存储为公共汽车/卡车提议(标签中的任意一种类别),其余提议存储为背景提议:
ious, rois, clss, deltas = [], [], [], []
- 将所有候选项相对于图像中的所有地面真实边界框的 IoU 存储起来,其中
bbs
是图像中不同对象的地面真实边界框,而candidates
包括在前一步骤中获取的区域提议候选项:
ious = np.array([[extract_iou(candidate, _bb_) for \ candidate in candidates] for _bb_ in bbs]).T
- 循环遍历每个候选项并存储候选项的 XMin(
cx
)、YMin(cy
)、XMax(cX
)和 YMax(cY
)值:
for jx, candidate in enumerate(candidates): cx,cy,cX,cY = candidate
- 提取与获取了 IoU 列表的所有地面真实边界框相关的候选项对应的 IoU 时:
candidate_ious = ious[jx]
- 找到具有最高 IoU 的候选项的索引(
best_iou_at
)及其相应的地面真实边界框(best_bb
):
best_iou_at = np.argmax(candidate_ious) best_iou = candidate_ious[best_iou_at] best_bb = _x,_y,_X,_Y = bbs[best_iou_at]
- 如果 IoU(
best_iou
)大于阈值(0.3),我们将为与候选项对应的类分配标签,否则分配背景:
if best_iou > 0.3: clss.append(labels[best_iou_at]) else : clss.append('background')
- 提取所需的偏移量(
delta
),将当前提议转换为最佳区域提议(即地面真实边界框)best_bb
所需的偏移量,换句话说,调整当前提议的左、右、上和下边缘多少,使其与来自地面真实边界框的best_bb
完全对齐:
delta = np.array([_x-cx, _y-cy, _X-cX, _Y-cY]) / np.array([W,H,W,H]) deltas.append(delta) rois.append(candidate / np.array([W,H,W,H]))
- 将文件路径、IoU、RoI、类偏移和地面真实边界框追加到列表中:
FPATHS.append(fpath) IOUS.append(ious) ROIS.append(rois) CLSS.append(clss) DELTAS.append(deltas) GTBBS.append(bbs)
- 获取图像路径名并将获取的所有信息
FPATHS
、IOUS
、ROIS
、CLSS
、DELTAS
和GTBBS
存储在一个列表的列表中:
FPATHS = [f'{IMAGE_ROOT}/{stem(f)}.jpg' for f in FPATHS] FPATHS, GTBBS, CLSS, DELTAS, ROIS = [item for item in \ [FPATHS, GTBBS, \ CLSS, DELTAS, ROIS]]
请注意,到目前为止,类别作为类别名称可用。现在,我们将把它们转换为相应的索引,使得背景类别的类索引为 0,公共汽车类的类索引为 1,卡车类的类索引为 2。
-
为每个类别分配索引:
targets = pd.DataFrame(flatten(CLSS), columns=['label']) label2target = {l:t for t,l in enumerate(targets['label'].unique())} target2label = {t:l for l,t in label2target.items()} background_class = label2target['background']
现在我们已经为每个区域提议分配了一个类别,并创建了边界框偏移量的其他地面真值。接下来,我们将获取与获取的信息(FPATHS
、IOUS
、ROIS
、CLSS
、DELTAS
和GTBBS
)对应的数据集和数据加载器。
创建训练数据
到目前为止,我们已经获取了数据,在所有图像中获取了区域提议,准备了每个区域提议中对象的类别的地面真值,并获取了与相应图像中对象具有高重叠(IoU)的每个区域提议相对应的偏移量。
在本节中,我们将基于前一节结尾处获得的区域提议的地面真值准备一个数据集类,从中创建数据加载器。然后,我们将通过将每个区域提议调整为相同形状并缩放它们来标准化每个区域提议。我们将继续从前一节结束的地方编码:
-
定义一个函数来在通过类似 VGG16 的预训练模型之前对图像进行标准化处理。对于 VGG16 的标准化实践如下:
normalize= transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225])
-
定义一个函数(
preprocess_image
)来预处理图像(img
),其中我们会切换通道、标准化图像,并将其注册到设备上:def preprocess_image(img): img = torch.tensor(img).permute(2,0,1) img = normalize(img) return img.to(device).float()
-
定义解码预测的函数
decode
: -
def decode(_y): _, preds = _y.max(-1) return preds
-
使用前面步骤中获取的预处理区域提议和地面真值(前一节的第 2 步)来定义数据集(
RCNNDataset
):class RCNNDataset(Dataset): def __init__(self, fpaths, rois, labels, deltas, gtbbs): self.fpaths = fpaths self.gtbbs = gtbbs self.rois = rois self.labels = labels self.deltas = deltas def __len__(self): return len(self.fpaths)
- 根据区域提议获取裁剪图像以及与类别和边界框偏移量相关的其他地面真值:
def __getitem__(self, ix): fpath = str(self.fpaths[ix]) image = cv2.imread(fpath, 1)[...,::-1] H, W, _ = image.shape sh = np.array([W,H,W,H]) gtbbs = self.gtbbs[ix] rois = self.rois[ix] bbs = (np.array(rois)*sh).astype(np.uint16) labels = self.labels[ix] deltas = self.deltas[ix] crops = [image[y:Y,x:X] for (x,y,X,Y) in bbs] return image,crops,bbs,labels,deltas,gtbbs,fpath
- 定义
collate_fn
函数,该函数执行对裁剪图像进行大小调整和标准化(preprocess_image
):
def collate_fn(self, batch): input, rois, rixs, labels, deltas =[],[],[],[],[] for ix in range(len(batch)): image, crops, image_bbs, image_labels, \ image_deltas, image_gt_bbs, \ image_fpath = batch[ix] crops = [cv2.resize(crop, (224,224)) for crop in crops] crops = [preprocess_image(crop/255.)[None] for crop in crops] input.extend(crops) labels.extend([label2target[c] for c in image_labels]) deltas.extend(image_deltas) input = torch.cat(input).to(device) labels = torch.Tensor(labels).long().to(device) deltas = torch.Tensor(deltas).float().to(device) return input, labels, deltas
-
创建训练和验证数据集以及数据加载器:
n_train = 9*len(FPATHS)//10 train_ds = RCNNDataset(FPATHS[:n_train],ROIS[:n_train], CLSS[:n_train], DELTAS[:n_train], GTBBS[:n_train]) test_ds = RCNNDataset(FPATHS[n_train:], ROIS[n_train:], CLSS[n_train:], DELTAS[n_train:], GTBBS[n_train:]) from torch.utils.data import TensorDataset, DataLoader train_loader = DataLoader(train_ds, batch_size=2, collate_fn=train_ds.collate_fn, drop_last=True) test_loader = DataLoader(test_ds, batch_size=2, collate_fn=test_ds.collate_fn, drop_last=True)
到目前为止,我们已经了解了为训练准备数据的相关信息。接下来,我们将学习如何定义和训练一个模型,该模型能够预测区域提议的类别和偏移量,以便在图像中绘制紧密的边界框。
R-CNN 网络架构
现在我们已经准备好数据,在本节中,我们将学习如何构建一个模型,该模型能够预测区域提议的类别和相应的偏移量,以便在图像中绘制紧密的边界框。我们采用的策略如下:
-
定义一个 VGG 主干。
-
通过预训练模型后获取标准化裁剪后的特征。
-
将带有 sigmoid 激活的线性层附加到 VGG 主干上,以预测区域提议对应的类别。
-
附加一个额外的线性层来预测四个边界框偏移量。
-
定义用于两个输出(一个用于预测类别,另一个用于预测四个边界框偏移量)的损失计算方法。
-
训练模型,该模型预测区域提议的类别和四个边界框偏移量。
执行以下代码。我们将继续从前一节结束的地方编码:
-
定义 VGG 骨干网:
vgg_backbone = models.vgg16(pretrained=True) vgg_backbone.classifier = nn.Sequential() for param in vgg_backbone.parameters(): param.requires_grad = False vgg_backbone.eval().to(device)
-
定义
RCNN
网络模块:- 定义类别:
class RCNN(nn.Module): def __init__(self): super().__init__()
- 定义骨干网 (
self.backbone
) 和如何计算类别分数 (self.cls_score
) 和边界框偏移值 (self.bbox
):
feature_dim = 25088 self.backbone = vgg_backbone self.cls_score = nn.Linear(feature_dim, len(label2target)) self.bbox = nn.Sequential( nn.Linear(feature_dim, 512), nn.ReLU(), nn.Linear(512, 4), nn.Tanh(), )
- 定义损失函数,对应类别预测 (
self.cel
) 和边界框偏移回归 (self.sl1
):
self.cel = nn.CrossEntropyLoss() self.sl1 = nn.L1Loss()
- 定义前向传播方法,通过 VGG 骨干网 (
self.backbone
) 传递图像以获取特征 (feat
),进一步通过分类和边界框回归方法获取跨类别的概率 (cls_score
) 和边界框偏移量 (bbox
):
def forward(self, input): feat = self.backbone(input) cls_score = self.cls_score(feat) bbox = self.bbox(feat) return cls_score, bbox
- 定义计算损失的函数 (
calc_loss
),其中我们返回检测和回归损失的总和。请注意,如果实际类别是背景类别,则不计算与偏移相对应的回归损失:
def calc_loss(self, probs, _deltas, labels, deltas): detection_loss = self.cel(probs, labels) ixs, = torch.where(labels != 0) _deltas = _deltas[ixs] deltas = deltas[ixs] self.lmb = 10.0 if len(ixs) > 0: regression_loss = self.sl1(_deltas, deltas) return detection_loss + self.lmb *\ regression_loss, detection_loss.detach(), regression_loss.detach() else: regression_loss = 0 return detection_loss + self.lmb regression_loss, \ detection_loss.detach(), regression_loss
-
有了模型类别后,我们现在定义批量数据训练和验证数据预测的功能:
- 定义
train_batch
函数:
def train_batch(inputs, model, optimizer, criterion): input, clss, deltas = inputs model.train() optimizer.zero_grad() _clss, _deltas = model(input) loss, loc_loss, regr_loss = criterion(_clss, _deltas, clss, deltas) accs = clss == decode(_clss) loss.backward() optimizer.step() return loss.detach(), loc_loss, regr_loss, accs.cpu().numpy()
- 定义
validate_batch
函数:
@torch.no_grad() def validate_batch(inputs, model, criterion): input, clss, deltas = inputs with torch.no_grad(): model.eval() _clss,_deltas = model(input) loss,loc_loss,regr_loss = criterion(_clss, _deltas, clss, deltas) _, _clss = _clss.max(-1) accs = clss == _clss return _clss,_deltas,loss.detach(), \ loc_loss, regr_loss, accs.cpu().numpy()
- 定义
-
现在,让我们创建一个模型对象,获取损失标准,然后定义优化器和时期数:
rcnn = RCNN().to(device) criterion = rcnn.calc_loss optimizer = optim.SGD(rcnn.parameters(), lr=1e-3) n_epochs = 5 log = Report(n_epochs)
现在我们在增加的时期内训练模型:
-
for epoch in range(n_epochs): _n = len(train_loader) for ix, inputs in enumerate(train_loader): loss, loc_loss,regr_loss,accs = train_batch(inputs, rcnn, optimizer, criterion) pos = (epoch + (ix+1)/_n) log.record(pos, trn_loss=loss.item(), trn_loc_loss=loc_loss, trn_regr_loss=regr_loss, trn_acc=accs.mean(), end='\r') _n = len(test_loader) for ix,inputs in enumerate(test_loader): _clss, _deltas, loss, loc_loss, regr_loss, \ accs = validate_batch(inputs, rcnn, criterion) pos = (epoch + (ix+1)/_n) log.record(pos, val_loss=loss.item(), val_loc_loss=loc_loss, val_regr_loss=regr_loss, val_acc=accs.mean(), end='\r') # Plotting training and validation metrics log.plot_epochs('trn_loss,val_loss'.split(','))
训练和验证数据的总体损失图如下:
图 7.13:增加时期内的训练和验证损失
现在我们已经训练了一个模型,我们将在下一节中使用它来预测新图像。
预测新图像
让我们利用到目前为止训练的模型来预测并在新图像上绘制对象周围的边界框及其对应的对象类别,在预测的边界框内。
-
从新图像中提取区域建议。
-
调整和规范化每个裁剪。
-
将处理后的裁剪图像前向传递以预测类别和偏移量。
-
执行非最大抑制以仅获取置信度最高的包含对象的框。
我们通过一个函数执行上述策略,该函数以图像作为输入,并提供地面实况边界框(仅用于比较地面实况和预测的边界框)。
我们将继续从前一节结束的地方进行编码:
-
定义
test_predictions
函数以预测新图像:- 该函数以
filename
为输入,读取图像,并提取候选对象:
def test_predictions(filename, show_output=True): img = np.array(cv2.imread(filename, 1)[...,::-1]) candidates = extract_candidates(img) candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates]
- 遍历候选者以调整大小和预处理图像:
input = [] for candidate in candidates: x,y,X,Y = candidate crop = cv2.resize(img[y:Y,x:X], (224,224)) input.append(preprocess_image(crop/255.)[None]) input = torch.cat(input).to(device)
- 预测类别和偏移量:
with torch.no_grad(): rcnn.eval() probs, deltas = rcnn(input) probs = torch.nn.functional.softmax(probs, -1) confs, clss = torch.max(probs, -1)
- 提取不属于背景类别的候选对象,并将候选对象的边界框与预测的边界框偏移值相加:
candidates = np.array(candidates) confs,clss,probs,deltas=[tensor.detach().cpu().numpy() \ for tensor in [confs, clss, probs, deltas]] ixs = clss!=background_class confs,clss,probs,deltas,candidates = [tensor[ixs] for \ tensor in [confs,clss, probs, deltas,candidates]] bbs = (candidates + deltas).astype(np.uint16)
- 使用非最大抑制(
nms
)消除近似重复的边界框(在此情况下,具有 IoU 大于 0.05 的两个框被视为重复)。在重复的框中,我们选择置信度最高的框并丢弃其余的框:
ixs = nms(torch.tensor(bbs.astype(np.float32)), torch.tensor(confs), 0.05) confs,clss,probs,deltas,candidates,bbs=[tensor[ixs] \ for tensor in [confs, clss, probs, deltas, candidates, bbs]] if len(ixs) == 1: confs, clss, probs, deltas, candidates, bbs = \ [tensor[None] for tensor in [confs, clss, probs, deltas, candidates, bbs]]
- 获取置信度最高的边界框:
if len(confs) == 0 and not show_output: return (0,0,224,224), 'background', 0 if len(confs) > 0: best_pred = np.argmax(confs) best_conf = np.max(confs) best_bb = bbs[best_pred] x,y,X,Y = best_bb
- 绘制图像以及预测的边界框:
_, ax = plt.subplots(1, 2, figsize=(20,10)) show(img, ax=ax[0]) ax[0].grid(False) ax[0].set_title('Original image') if len(confs) == 0: ax[1].imshow(img) ax[1].set_title('No objects') plt.show() return ax[1].set_title(target2label[clss[best_pred]]) show(img, bbs=bbs.tolist(), texts=[target2label[c] for c in clss.tolist()], ax=ax[1], title='predicted bounding box and class') plt.show() return (x,y,X,Y),target2label[clss[best_pred]],best_conf
- 该函数以
-
在新图像上执行上述函数:
image, crops, bbs, labels, deltas, gtbbs, fpath = test_ds[7] test_predictions(fpath)
上述代码生成以下图像:
图 7.14:原始图像和预测的边界框与类别
根据上图,我们可以看到图像类别的预测准确,边界框的预测也还不错。请注意,生成上述图像的预测大约需要~1.5 秒。
所有这些时间都花在生成区域建议、调整每个区域建议、通过 VGG 骨干网络传递它们以及使用定义的模型生成预测上。其中大部分时间都花在通过 VGG 骨干网络传递每个建议上。在接下来的部分,我们将学习如何通过使用基于 Fast R-CNN 架构的模型来解决将每个建议传递给 VGG的问题。
训练基于 Fast R-CNN 的自定义对象检测器
R-CNN 的一个主要缺点是生成预测需要相当长的时间。这是因为为每个图像生成区域建议,调整区域的裁剪,以及提取与每个裁剪(区域建议)对应的特征会造成瓶颈。
Fast R-CNN 通过将整个图像通过预训练模型以提取特征,然后获取与原始图像中来自selectivesearch
的区域建议相对应的特征区域,解决了这个问题。在接下来的部分,我们将学习在自定义数据集上训练之前了解 Fast R-CNN 的工作细节。
Fast R-CNN 的工作细节
让我们通过以下图表理解 Fast R-CNN:
图 7.15:Fast R-CNN 的工作细节
让我们通过以下步骤理解上述图表:
-
将图像通过预训练模型传递以在扁平化层之前提取特征;我们称它们为输出特征图。
-
提取与图像对应的区域建议。
-
提取与区域建议对应的特征图区域(请注意,当图像通过 VGG16 架构传递时,输出时图像会缩小 32 倍,因为进行了五次池化操作。因此,如果原始图像中存在边界框(32,64,160,128),则对应于边界框(1,2,5,4)的特征图将对应于完全相同的区域)。
-
逐个将对应于区域提议的特征图通过**感兴趣区域(RoI)**池化层,使所有区域提议的特征图具有相似的形状。这是对 R-CNN 技术中执行的扭曲的替代。
-
将 RoI 池化层的输出值通过全连接层传递。
-
训练模型以预测每个区域提议对应的类别和偏移量。
注意,R-CNN 和快速 R-CNN 之间的主要区别在于,在 R-CNN 中,我们逐个通过预训练模型传递裁剪图像(调整大小的区域提议),而在快速 R-CNN 中,我们裁剪对应于每个区域提议的特征图(通过通过整个图像传递预训练模型获得),从而避免需要通过预训练模型传递每个调整大小的区域提议的需求。
现在了解了快速 R-CNN 的工作原理,下一节我们将使用在 R-CNN 部分中利用的相同数据集构建一个模型。
使用自定义数据集实现快速 R-CNN 进行目标检测
在本节中,我们将致力于使用快速 R-CNN 训练我们的自定义对象检测器。为了保持简洁,本节仅提供此部分中的附加或更改代码(应在关于 R-CNN 的先前章节中的“创建训练数据”小节中运行所有代码直至步骤 2):
在这里,我们仅提供用于训练快速 R-CNN 的附加代码。在 GitHub 的 Chapter07
文件夹中的 Training_Fast_R_CNN.ipynb
文件中找到完整的代码:bit.ly/mcvp-2e
。
-
创建一个
FRCNNDataset
类,返回图像、标签、地面实况、区域提议及每个区域提议对应的增量:class FRCNNDataset(Dataset): def __init__(self, fpaths, rois, labels, deltas, gtbbs): self.fpaths = fpaths self.gtbbs = gtbbs self.rois = rois self.labels = labels self.deltas = deltas def __len__(self): return len(self.fpaths) def __getitem__(self, ix): fpath = str(self.fpaths[ix]) image = cv2.imread(fpath, 1)[...,::-1] gtbbs = self.gtbbs[ix] rois = self.rois[ix] labels = self.labels[ix] deltas = self.deltas[ix] assert len(rois) == len(labels) == len(deltas), \ f'{len(rois)}, {len(labels)}, {len(deltas)}' return image, rois, labels, deltas, gtbbs, fpath def collate_fn(self, batch): input, rois, rixs, labels, deltas = [],[],[],[],[] for ix in range(len(batch)): image, image_rois, image_labels, image_deltas, \ image_gt_bbs, image_fpath = batch[ix] image = cv2.resize(image, (224,224)) input.append(preprocess_image(image/255.)[None]) rois.extend(image_rois) rixs.extend([ix]*len(image_rois)) labels.extend([label2target[c] for c in image_labels]) deltas.extend(image_deltas) input = torch.cat(input).to(device) rois = torch.Tensor(rois).float().to(device) rixs = torch.Tensor(rixs).float().to(device) labels = torch.Tensor(labels).long().to(device) deltas = torch.Tensor(deltas).float().to(device) return input, rois, rixs, labels, deltas
注意,上述代码与我们在 R-CNN 部分学到的非常相似,唯一的变化是我们返回了更多信息(rois
和 rixs
)。
rois
矩阵包含关于批处理中的哪个 RoI 属于哪个图像的信息。注意,input
包含多个图像,而 rois
是一个单独的框列表。我们不会知道第一张图像有多少个 RoI 属于它,第二张图像有多少个 RoI 属于它,依此类推。这就是 rixs
起作用的地方。它是一个索引列表。列表中的每个整数将相应的边界框与适当的图像关联起来;例如,如果 rixs
是 [0,0,0,1,1,2,3,3,3]
,则我们知道前三个边界框属于批处理中的第一个图像,接下来的两个属于第二个图像,依此类推。
-
创建训练和测试数据集:
n_train = 9*len(FPATHS)//10 train_ds = FRCNNDataset(FPATHS[:n_train],ROIS[:n_train], CLSS[:n_train], DELTAS[:n_train], GTBBS[:n_train]) test_ds = FRCNNDataset(FPATHS[n_train:], ROIS[n_train:], CLSS[n_train:], DELTAS[n_train:], GTBBS[n_train:]) from torch.utils.data import TensorDataset, DataLoader train_loader = DataLoader(train_ds, batch_size=2, collate_fn=train_ds.collate_fn, drop_last=True) test_loader = DataLoader(test_ds, batch_size=2, collate_fn=test_ds.collate_fn, drop_last=True)
-
定义一个用于训练数据集的模型:
- 首先,导入
torchvision.ops
类中存在的RoIPool
方法:
from torchvision.ops import RoIPool
- 定义
FRCNN
网络模块:
class FRCNN(nn.Module): def __init__(self): super().__init__()
- 载入预训练模型并冻结参数:
rawnet= torchvision.models.vgg16_bn(pretrained=True) for param in rawnet.features.parameters(): param.requires_grad = False
- 提取直至最后一层的特征:
self.seq = nn.Sequential(*list(\rawnet.features.children())[:-1])
- 指定
RoIPool
要提取 7 x 7 的输出。这里,spatial_scale
是建议(来自原始图像)需要缩小的因子,以便在通过展平层之前,每个输出都具有相同的形状。图像大小为 224 x 224,而特征图大小为 14 x 14:
self.roipool = RoIPool(7, spatial_scale=14/224)
- 定义输出头部 –
cls_score
和bbox
:
feature_dim = 512*7*7 self.cls_score = nn.Linear(feature_dim, len(label2target)) self.bbox = nn.Sequential( nn.Linear(feature_dim, 512), nn.ReLU(), nn.Linear(512, 4), nn.Tanh(), )
- 定义损失函数:
self.cel = nn.CrossEntropyLoss() self.sl1 = nn.L1Loss()
- 定义
forward
方法,该方法将图像、区域建议和区域建议的索引作为网络的输入:
def forward(self, input, rois, ridx):
- 将
input
图像通过预训练模型传递:
res = input res = self.seq(res)
- 创建
rois
的矩阵作为self.roipool
的输入,首先通过将ridx
连接为第一列,接下来四列为区域建议边界框的绝对值来实现:
rois = torch.cat([ridx.unsqueeze(-1), rois*224], dim=-1) res = self.roipool(res, rois) feat = res.view(len(res), -1) cls_score = self.cls_score(feat) bbox=self.bbox(feat)#.view(-1,len(label2target),4) return cls_score, bbox
- 定义损失值计算 (
calc_loss
),就像我们在 R-CNN 部分所做的那样:
def calc_loss(self, probs, _deltas, labels, deltas): detection_loss = self.cel(probs, labels) ixs, = torch.where(labels != background_class) _deltas = _deltas[ixs] deltas = deltas[ixs] self.lmb = 10.0 if len(ixs) > 0: regression_loss = self.sl1(_deltas, deltas) return detection_loss + self.lmb * regression_loss, \ detection_loss.detach(), regression_loss.detach() else: regression_loss = 0 return detection_loss + self.lmb * regression_loss, \ detection_loss.detach(), regression_loss
- 首先,导入
-
定义在批处理中训练和验证的函数,就像我们在 训练 基于自定义目标检测器的 R-CNN 部分所做的那样:
def train_batch(inputs, model, optimizer, criterion): input, rois, rixs, clss, deltas = inputs model.train() optimizer.zero_grad() _clss, _deltas = model(input, rois, rixs) loss, loc_loss, regr_loss =criterion(_clss,_deltas, clss, deltas) accs = clss == decode(_clss) loss.backward() optimizer.step() return loss.detach(), loc_loss, regr_loss, accs.cpu().numpy() def validate_batch(inputs, model, criterion): input, rois, rixs, clss, deltas = inputs with torch.no_grad(): model.eval() _clss,_deltas = model(input, rois, rixs) loss, loc_loss,regr_loss = criterion(_clss, _deltas, clss, deltas) _clss = decode(_clss) accs = clss == _clss return _clss,_deltas,loss.detach(),loc_loss,regr_loss, accs.cpu().numpy()
-
定义并训练模型以增加 epochs:
frcnn = FRCNN().to(device) criterion = frcnn.calc_loss optimizer = optim.SGD(frcnn.parameters(), lr=1e-3) n_epochs = 5 log = Report(n_epochs) for epoch in range(n_epochs): _n = len(train_loader) for ix, inputs in enumerate(train_loader): loss, loc_loss,regr_loss, accs = train_batch(inputs, frcnn, optimizer, criterion) pos = (epoch + (ix+1)/_n) log.record(pos, trn_loss=loss.item(), trn_loc_loss=loc_loss, trn_regr_loss=regr_loss, trn_acc=accs.mean(), end='\r') _n = len(test_loader) for ix,inputs in enumerate(test_loader): _clss, _deltas, loss, \ loc_loss, regr_loss, accs = validate_batch(inputs, frcnn, criterion) pos = (epoch + (ix+1)/_n) log.record(pos, val_loss=loss.item(), val_loc_loss=loc_loss, val_regr_loss=regr_loss, val_acc=accs.mean(), end='\r') # Plotting training and validation metrics log.plot_epochs('trn_loss,val_loss'.split(','))
总损失的变化如下:
图 7.16: 增加 epochs 时的训练和验证损失
-
定义一个函数来对测试图像进行预测:
- 定义一个函数,该函数以文件名作为输入,然后读取文件并将其调整大小为 224 x 224:
import matplotlib.pyplot as plt %matplotlib inline import matplotlib.patches as mpatches from torchvision.ops import nms from PIL import Image def test_predictions(filename): img = cv2.resize(np.array(Image.open(filename)), (224,224))
- 获取区域建议,将它们转换为
(x1, y1, x2, y2)
格式(左上角像素和右下角像素的坐标),然后将这些值转换为它们所在的宽度和高度的比例:
candidates = extract_candidates(img) candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates]
- 预处理图像并缩放 RoIs (
rois
):
input = preprocess_image(img/255.)[None] rois = [[x/224,y/224,X/224,Y/224] for x,y,X,Y in candidates]
- 由于所有建议都属于同一图像,
rixs
将是一个零列表(与建议数量相同):
rixs = np.array([0]*len(rois))
- 将输入、RoIs 通过训练模型前向传播,并为每个建议获取置信度和类别分数:
rois,rixs = [torch.Tensor(item).to(device) for item in [rois, rixs]] with torch.no_grad(): frcnn.eval() probs, deltas = frcnn(input, rois, rixs) confs, clss = torch.max(probs, -1)
- 过滤掉背景类:
candidates = np.array(candidates) confs,clss,probs,deltas=[tensor.detach().cpu().numpy() \ for tensor in [confs, clss, probs, deltas]] ixs = clss!=background_class confs, clss, probs,deltas,candidates= [tensor[ixs] for \ tensor in [confs, clss, probs, deltas,candidates]] bbs = candidates + deltas
- 使用
nms
移除近似重复的边界框,并获取高置信度模型为对象的那些建议的索引:
ixs = nms(torch.tensor(bbs.astype(np.float32)), torch.tensor(confs), 0.05) confs, clss, probs,deltas,candidates,bbs= [tensor[ixs] \ for tensor in [confs,clss,probs, deltas, candidates, bbs]] if len(ixs) == 1: confs, clss, probs, deltas, candidates, bbs = \ [tensor[None] for tensor in [confs,clss, probs, deltas, candidates, bbs]] bbs = bbs.astype(np.uint16)
- 绘制获得的边界框:
_, ax = plt.subplots(1, 2, figsize=(20,10)) show(img, ax=ax[0]) ax[0].grid(False) ax[0].set_title(filename.split('/')[-1]) if len(confs) == 0: ax[1].imshow(img) ax[1].set_title('No objects') plt.show() return else: show(img,bbs=bbs.tolist(), texts=[target2label[c] for c in clss.tolist()],ax=ax[1]) plt.show()
-
对测试图像进行预测:
test_predictions(test_ds[29][-1])
上述代码导致如下结果:
图 7.17: 原始图像及其预测的边界框和类别
上述代码在 1.5 秒内执行。这主要是因为我们仍在使用两个不同的模型,一个用于生成区域建议,另一个用于进行类别预测和修正。在下一章中,我们将学习如何使用单个模型进行预测,以便在实时场景中进行快速推断。
摘要
在本章中,我们首先学习了为目标定位和检测过程创建训练数据集。然后,我们了解了 SelectiveSearch,一种基于相邻像素相似性推荐区域的区域建议技术。我们还学习了计算 IoU 指标以理解围绕图像中对象的预测边界框的好坏程度。
此外,我们研究了执行非极大值抑制以在图像中获取每个对象的一个边界框,然后学习了如何从头构建 R-CNN 和 Fast R-CNN 模型。我们还探讨了为什么 R-CNN 速度慢,以及 Fast R-CNN 如何利用 RoI 池化从特征图获取区域建议以加快推理速度。最后,我们了解到,来自单独模型的区域建议导致在新图像上预测需要更多时间。
在下一章中,我们将学习一些现代目标检测技术,这些技术用于更实时地进行推断。
问题
-
区域建议技术如何生成建议?
-
如果图像中存在多个对象,如何计算 IoU?
-
为什么 Fast R-CNN 比 R-CNN 更快?
-
RoI 池化是如何工作的?
-
在预测边界框修正时,如果没有获取特征图后的多个层,会有什么影响?
-
为什么在计算总体损失时必须给回归损失分配更高的权重?
-
非极大值抑制是如何工作的?
加入 Discord 以获取更多信息
加入我们的 Discord 社区空间,与作者和其他读者进行讨论:
第八章:高级目标检测
在前一章节中,我们学习了 R-CNN 和 Fast R-CNN 技术,它们利用区域建议生成图像中对象位置的预测以及图像中对象对应的类别。此外,我们还了解到推断速度的瓶颈在于具有用于区域建议生成和对象检测的两种不同模型。在本章中,我们将学习不同的现代技术,如 Faster R-CNN、YOLO 和单次检测器(SSD),它们通过使用单一模型来为对象的类别和边界框进行预测,从而克服了慢推断时间的问题。我们将首先学习关于锚框的内容,然后继续学习每种技术的工作原理以及如何实现它们来检测图像中的对象。
我们将在本章节中讨论以下主题:
-
现代目标检测算法的组成部分
-
在自定义数据集上训练 Faster R-CNN
-
YOLO 的工作细节
-
在自定义数据集上训练 YOLO
-
SSD 的工作细节
-
在自定义数据集上训练 SSD
除了上述内容外,作为额外的奖励,我们在 GitHub 仓库中还涵盖了以下内容:
-
训练 YOLOv8
-
训练 EfficientDet 架构
本章中的所有代码片段均可在 GitHub 仓库的
Chapter08
文件夹中找到:bit.ly/mcvp-2e
。随着领域的发展,我们将定期在 GitHub 仓库中添加有价值的补充内容。请查看每章节目录中的
supplementary_sections
文件夹获取新的和有用的内容。
现代目标检测算法的组成部分
R-CNN 和 Fast R-CNN 技术的缺点在于它们有两个不相交的网络——一个用于识别可能包含对象的区域,另一个用于在识别到对象的地方对边界框进行修正。此外,这两个模型都需要与区域建议一样多的前向传播。现代目标检测算法主要集中在训练单个神经网络上,并且具备在一次前向传递中检测所有对象的能力。典型现代目标检测算法的各个组成部分包括:
-
锚框
-
区域建议网络(RPN)
-
区域兴趣(RoI)池化
让我们在以下小节中讨论这些(我们将专注于锚框和 RPN,因为我们在前一章节中已经讨论了 RoI 池化)。
锚框
到目前为止,我们使用了来自 selectivesearch
方法的区域建议。锚框作为 selectivesearch
的便捷替代品将在本节中学习它们如何替代基于 selectivesearch
的区域建议。
通常,大多数对象具有类似的形状 - 例如,在大多数情况下,与人的图像对应的边界框将具有比宽度大的更大的高度,而与卡车图像对应的边界框将具有比高度大的更大的宽度。因此,我们在训练模型之前(通过检查与各种类别对象对应的边界框的真实值)将会对图像中存在的对象的高度和宽度有一个合理的了解。
此外,在某些图像中,感兴趣的对象可能会缩放 - 导致高度和宽度比平均值更小或更大 - 同时保持纵横比(即高度/宽度)。
一旦我们对图像中存在的对象的纵横比、高度和宽度有一个合理的了解(可以从数据集中的真实值获得),我们就会定义具有高度和宽度的锚框,这些锚框表示数据集中大多数对象的边界框。通常,这是通过在图像中存在的对象的地面真实边界框上使用 K 均值聚类来获得的。
现在我们了解了如何获取锚框的高度和宽度,我们将学习如何在流程中利用它们:
-
将每个锚框从图像的左上角滑动到右下角。
-
具有与对象高度重叠联合(IoU)的高的锚框将标记为包含对象,并且其他将标记为
0
。
我们可以通过提到 IoU 的阈值来修改 IoU 的阈值,如果 IoU 大于某个阈值,则对象类别为1
;如果小于另一个阈值,则对象类别为0
,否则未知。
一旦我们按照这里定义的真实值获得了地面真实值,我们可以构建一个模型,该模型可以预测对象的位置,并预测与锚框相匹配的偏移量以与地面真实值匹配。现在让我们了解如何在以下图像中表示锚框:
图 8.1:示例锚框
在上一张图片中,我们有两种锚框,一种高度大于宽度,另一种宽度大于高度,以对应图像中的对象(类别) - 一个人和一辆车。
我们将这两个锚框滑动到图像上,并注意 IoU 与真实标签的位置最高的地方,并指出该特定位置包含对象,而其余位置则不包含对象。
除了上述两种锚框外,我们还会创建具有不同尺度的锚框,以便适应图像中对象可能呈现的不同尺度。以下是不同尺度锚框的示例。请注意,所有锚框都具有相同的中心点,但具有不同的长宽比或尺度:
图 8.2:具有不同尺度和长宽比的锚框
现在我们了解了锚框,接下来的部分中,我们将学习关于 RPN 的知识,RPN 利用锚框来预测可能包含对象的区域。
区域提议网络
想象一个场景,我们有一个 224 x 224 x 3 的图像。进一步假设这个例子中锚框的形状是 8 x 8。如果我们有一个步幅为 8 像素,那么我们每一行可以从图像中获取 224/8 = 28 个裁剪图像 —— 实际上从一张图像中总共可以获取 28*28 = 576 个裁剪图像。然后,我们将每一个裁剪图像通过 RPN 传递,该网络指示裁剪图像中是否包含对象。基本上,RPN 提供了裁剪图像包含对象的可能性。
让我们比较selectivesearch
的输出和 RPN 的输出。
selectivesearch
根据像素值进行一组计算,为我们提供了一个区域候选。然而,RPN 根据锚框和锚框滑过图像的步幅生成区域候选。我们使用这两种方法之一获取区域候选后,会识别最有可能包含对象的候选者。
虽然基于selectivesearch
的区域提议生成是在神经网络之外完成的,但我们可以构建一个作为对象检测网络一部分的 RPN。通过使用 RPN,我们现在无需在网络之外执行不必要的计算来计算区域提议。这样,我们只需要一个单一模型来识别区域、图像中对象的类别以及它们对应的边界框位置。
接下来,我们将学习 RPN 如何确定一个区域候选(在滑动锚框后获得的裁剪图像)是否包含对象。在我们的训练数据中,我们会有与对象对应的真实边界框。现在,我们将每个区域候选与图像中对象的真实边界框进行比较,以确定区域候选与真实边界框之间的 IoU 是否大于某个阈值。如果 IoU 大于某个阈值(比如0.5
),则区域候选包含对象;如果 IoU 小于某个阈值(比如0.1
),则区域候选不包含对象,并且在训练过程中将忽略所有 IoU 介于两个阈值之间(0.1
和0.5
)的候选者。
一旦我们训练一个模型来预测区域候选是否包含对象,我们接着执行非极大值抑制,因为多个重叠的区域可能包含对象。
总结一下,RPN 通过以下步骤训练模型,使其能够识别高概率包含对象的区域提议:
-
滑动不同长宽比和尺寸的锚框穿过图像,获取图像的裁剪图像。
-
计算图像中对象的真实边界框与前一步中获得的裁剪图像之间的 IoU。
-
准备训练数据集,使得 IoU 大于阈值的裁剪区域包含对象,而 IoU 小于阈值的裁剪区域不包含对象。
-
训练模型以识别包含对象的区域。
-
执行非最大抑制以识别概率最高的包含对象的区域候选项,并消除与其高度重叠的其他区域候选项。
现在我们通过一个 RoI 池化层将区域候选项传递,以获得形状的区域。
分类和回归
到目前为止,我们已经学习了以下步骤,以便识别对象并执行边界框的偏移量:
-
识别包含对象的区域。
-
使用 RoI 池化确保所有区域的特征图,无论区域的形状如何,都完全相同(我们在前一章学习过这一点)。
这些步骤存在两个问题如下:
-
区域提议与物体的重叠不密切(在 RPN 中我们设定了
IoU>0.5
的阈值)。 -
我们已经确定了区域是否包含对象,但没有确定区域中对象的类别。
我们在本节中解决了这两个问题,我们将先前获得的均匀形状的特征图传递到网络中。我们期望网络能够预测区域内包含的对象的类别,并且预测区域的偏移量,以确保边界框尽可能紧密地围绕图像中的对象。
让我们通过以下图表来理解这一点:
图 8.3:预测对象类别和预测边界框要进行的偏移量
在前面的图表中,我们将 RoI 池化的输出作为输入(形状为 7 x 7 x 512),将其展平,并将其连接到一个密集层,然后预测两个不同的方面:
-
区域中的对象类别
-
预测边界框的偏移量的数量,以最大化与地面实况的 IoU
因此,如果数据中有 20 个类别,则神经网络的输出总共包含 25 个输出 – 21 个类别(包括背景类别)和用于边界框高度、宽度以及两个中心坐标的 4 个偏移量。
现在我们已经了解了目标检测流水线的不同组成部分,让我们通过以下图表进行总结:
图 8.4:Faster R-CNN 工作流程
更多关于 Faster R-CNN 的细节可以在这篇论文中找到 – arxiv.org/pdf/1506.01497.pdf
。
在 Faster R-CNN 的每个组件的工作细节都已经就位后,在下一节中,我们将编写使用 Faster R-CNN 算法进行目标检测的代码。
在自定义数据集上训练 Faster R-CNN
在以下代码中,我们将训练 Faster R-CNN 算法来检测图像中物体周围的边界框。为此,我们将继续上一章节中的相同卡车与公共汽车检测练习:
在 GitHub 的 Chapter08
文件夹中的 Training_Faster_RCNN.ipynb
文件中找到以下代码:bit.ly/mcvp-2e
。
-
下载数据集:
!pip install -qU torch_snippets import os %%writefile kaggle.json {"username":"XXX", "key":"XXX"} !mkdir -p ~/.kaggle !cp kaggle.json ~/.kaggle/ !chmod 600 /root/.kaggle/kaggle.json !kaggle datasets download -d sixhky/open-images-bus-trucks/ !unzip -qq open-images-bus-trucks.zip !rm open-images-bus-trucks.zip
-
读取包含图像及其边界框和类信息的元数据的 DataFrame:
from torch_snippets import * from PIL import Image IMAGE_ROOT = 'images/images' DF_RAW = df = pd.read_csv('df.csv')
-
定义与标签和目标对应的索引:
label2target = {l:t+1 for t,l in enumerate(DF_RAW['LabelName'].unique())} label2target['background'] = 0 target2label = {t:l for l,t in label2target.items()} background_class = label2target['background'] num_classes = len(label2target)
-
定义用于预处理图像的函数 –
preprocess_image
:def preprocess_image(img): img = torch.tensor(img).permute(2,0,1) return img.to(device).float()
-
定义数据集类 –
OpenDataset
:- 定义一个
__init__
方法,该方法接受包含图像的文件夹和包含图像元数据的 DataFrame 作为输入:
class OpenDataset(torch.utils.data.Dataset): w, h = 224, 224 def __init__(self, df, image_dir=IMAGE_ROOT): self.image_dir = image_dir self.files = glob.glob(self.image_dir+'/*') self.df = df self.image_infos = df.ImageID.unique()
- 定义
__getitem__
方法,在此方法中我们返回预处理的图像和目标值:
def __getitem__(self, ix): # load images and masks image_id = self.image_infos[ix] img_path = find(image_id, self.files) img = Image.open(img_path).convert("RGB") img = np.array(img.resize((self.w, self.h), resample=Image.BILINEAR))/255. data = df[df['ImageID'] == image_id] labels = data['LabelName'].values.tolist() data = data[['XMin','YMin','XMax','YMax']].values # Convert to absolute coordinates data[:,[0,2]] *= self.w data[:,[1,3]] *= self.h boxes = data.astype(np.uint32).tolist() # torch FRCNN expects ground truths as # a dictionary of tensors target = {} target["boxes"] = torch.Tensor(boxes).float() target["labels"]= torch.Tensor([label2target[i] \ for i in labels]).long() img = preprocess_image(img) return img, target
- 定义一个
注意,这是我们第一次将输出作为张量字典而不是张量列表返回。这是因为 FRCNN
类的官方 PyTorch 实现期望目标包含边界框的绝对坐标和标签信息。
-
定义
collate_fn
方法(默认情况下,collate_fn
仅适用于张量作为输入,但在这里,我们处理的是字典列表)和__len__
方法: -
def collate_fn(self, batch): return tuple(zip(*batch)) def __len__(self): return len(self.image_infos)
-
创建训练和验证数据加载器及数据集:
from sklearn.model_selection import train_test_split trn_ids, val_ids = train_test_split(df.ImageID.unique(), test_size=0.1, random_state=99) trn_df, val_df = df[df['ImageID'].isin(trn_ids)], \ df[df['ImageID'].isin(val_ids)] train_ds = OpenDataset(trn_df) test_ds = OpenDataset(val_df) train_loader = DataLoader(train_ds, batch_size=4, collate_fn=train_ds.collate_fn, drop_last=True) test_loader = DataLoader(test_ds, batch_size=4, collate_fn=test_ds.collate_fn, drop_last=True)
-
定义模型:
import torchvision from torchvision.models.detection.faster_rcnn import FastRCNNPredictor device = 'cuda' if torch.cuda.is_available() else 'cpu' def get_model(): model = torchvision.models.detection\ .fasterrcnn_resnet50_fpn(pretrained=True) in_features = model.roi_heads.box_predictor.cls_score.in_features model.roi_heads.box_predictor = \ FastRCNNPredictor(in_features, num_classes) return model
模型包含以下关键子模块:
图 8.5:Faster R-CNN 架构
在上述输出中,我们注意到以下元素:
GeneralizedRCNNTransform
是一个简单的调整大小后跟随标准化变换:
图 8.6:输入上的转换
-
BackboneWithFPN
是将输入转换为特征映射的神经网络。 -
RegionProposalNetwork
生成前述特征映射的锚框,并预测分类和回归任务的单独特征映射:
图 8.7:RPN 架构
RoIHeads
使用前述的映射,通过 ROI 池化对齐它们,处理它们,并为每个提议返回分类概率和相应的偏移量:
图 8.8:roi_heads 架构
-
定义在数据批次上训练并计算验证数据上的损失值的函数:
# Defining training and validation functions def train_batch(inputs, model, optimizer): model.train() input, targets = inputs input = list(image.to(device) for image in input) targets = [{k: v.to(device) for k, v in t.items()} for t in targets] optimizer.zero_grad() losses = model(input, targets) loss = sum(loss for loss in losses.values()) loss.backward() optimizer.step() return loss, losses @torch.no_grad() def validate_batch(inputs, model): model.train() #to obtain losses, model needs to be in train mode only #Note that here we aren't defining the model's forward #method #hence need to work per the way the model class is defined input, targets = inputs input = list(image.to(device) for image in input) targets = [{k: v.to(device) for k, v \ in t.items()} for t in targets] optimizer.zero_grad() losses = model(input, targets) loss = sum(loss for loss in losses.values()) return loss, losses
-
在增加的时期训练模型:
- 定义模型:
model = get_model().to(device) optimizer = torch.optim.SGD(model.parameters(), lr=0.005, momentum=0.9,weight_decay=0.0005) n_epochs = 5 log = Report(n_epochs)
- 训练模型并计算训练和测试数据集上的损失值:
for epoch in range(n_epochs): _n = len(train_loader) for ix, inputs in enumerate(train_loader): loss, losses = train_batch(inputs, model, optimizer) loc_loss, regr_loss, loss_objectness, \ loss_rpn_box_reg = \ [losses[k] for k in ['loss_classifier', \ 'loss_box_reg', 'loss_objectness', \ 'loss_rpn_box_reg']] pos = (epoch + (ix+1)/_n) log.record(pos, trn_loss=loss.item(), trn_loc_loss=loc_loss.item(), trn_regr_loss=regr_loss.item(), trn_objectness_loss=loss_objectness.item(), trn_rpn_box_reg_loss=loss_rpn_box_reg.item(), end='\r') _n = len(test_loader) for ix,inputs in enumerate(test_loader): loss, losses = validate_batch(inputs, model) loc_loss, regr_loss, loss_objectness, \ loss_rpn_box_reg = \ [losses[k] for k in ['loss_classifier', \ 'loss_box_reg', 'loss_objectness', \ 'loss_rpn_box_reg']] pos = (epoch + (ix+1)/_n) log.record(pos, val_loss=loss.item(), val_loc_loss=loc_loss.item(), val_regr_loss=regr_loss.item(), val_objectness_loss=loss_objectness.item(), val_rpn_box_reg_loss=loss_rpn_box_reg.item(), end='\r') if (epoch+1)%(n_epochs//5)==0: log.report_avgs(epoch+1)
-
绘制各种损失值随时间增加的变化:
log.plot_epochs(['trn_loss','val_loss'])
这导致以下输出:
图 8.9:随着时期增加,训练和验证损失值
-
在新图像上进行预测:
- 训练模型的输出包含与类别对应的盒子、标签和分数。在下面的代码中,我们定义了一个
decode_output
函数,它接受模型的输出并在非极大值抑制后提供盒子、分数和类别的列表:
from torchvision.ops import nms def decode_output(output): 'convert tensors to numpy arrays' bbs = output['boxes'].cpu().detach().numpy().astype(np.uint16) labels = np.array([target2label[i] for i in \ output['labels'].cpu().detach().numpy()]) confs = output['scores'].cpu().detach().numpy() ixs = nms(torch.tensor(bbs.astype(np.float32)), torch.tensor(confs), 0.05) bbs, confs, labels = [tensor[ixs] for tensor in [bbs, confs, labels]] if len(ixs) == 1: bbs,confs,labels = [np.array([tensor]) for tensor \ in [bbs, confs, labels]] return bbs.tolist(), confs.tolist(), labels.tolist()
- 获取测试图像上的盒子和类别的预测结果:
model.eval() for ix, (images, targets) in enumerate(test_loader): if ix==3: break images = [im for im in images] outputs = model(images) for ix, output in enumerate(outputs): bbs, confs, labels = decode_output(output) info = [f'{l}@{c:.2f}' for l,c in zip(labels,confs)] show(images[ix].cpu().permute(1,2,0), bbs=bbs, texts=labels, sz=5)
- 训练模型的输出包含与类别对应的盒子、标签和分数。在下面的代码中,我们定义了一个
前述代码提供了以下输出:
图 8.10:预测的边界框和类别
注意,现在生成一张图像的预测需要约 400 毫秒,而 Fast R-CNN 需要 1.5 秒。
在本节中,我们使用 PyTorch models
包中提供的fasterrcnn_resnet50_fpn
模型类训练了一个 Faster R-CNN 模型。在接下来的部分中,我们将了解 YOLO,这是一种现代目标检测算法,它可以在一个步骤中执行目标类别检测和区域校正,无需单独的 RPN。
YOLO 的工作细节
You Only Look Once (YOLO)及其变体是显著的目标检测算法之一。在本节中,我们将高层次地了解 YOLO 的工作原理及其在克服基于 R-CNN 的目标检测框架的潜在限制方面的作用。
首先,让我们了解基于 R-CNN 的检测算法可能存在的限制。在 Faster R-CNN 中,我们使用锚框在图像上滑动并识别可能包含对象的区域,然后进行边界框修正。然而,在全连接层中,仅传递检测到的区域的 RoI 池化输出作为输入,在区域建议的边界框不能完全包含对象的情况下(对象超出了区域建议的边界框的边界),网络必须猜测对象的真实边界,因为它仅看到了部分图像(而不是整个图像)。在这种情况下,YOLO 非常有用,因为它在预测图像对应的边界框时会查看整个图像。此外,Faster R-CNN 仍然较慢,因为我们有两个网络:RPN 和最终预测围绕对象的类别和边界框的网络。
让我们了解 YOLO 如何在整个图像一次性地检测以及使用单一网络进行预测的同时,克服 Faster R-CNN 的限制。我们将通过以下示例了解为 YOLO 准备数据。
首先,我们为给定的图像创建一个地面真实值来训练模型:
- 让我们考虑一张具有红色边界框的给定地面真实值图像:
图 8.11:具有地面真实边界框的输入图像
- 将图像划分为N x N的网格单元格 – 暂时假定N=3:
图 8.12:将输入图像分成一个 3 x 3 的网格
-
识别包含至少一个地面真实边界框中心的网格单元格。在我们的 3 x 3 网格图像中,它们是单元格b1和b3。
-
包围框中心点落在的单元格(或单元格)负责预测对象的边界框。让我们为每个单元格创建相应的真实值。
-
每个单元格的输出真实值如下所示:
图 8.13:真实值表示
在这里,pc(对象存在分数)是网格单元格包含对象的概率。
-
让我们了解如何计算bx、by、bw和bh。首先,我们将网格单元格(假设我们考虑b1网格单元格)作为我们的宇宙,并将其归一化到 0 到 1 的比例,如下所示:
图 8.14:计算每个真实值的 bx、by、bw 和 bh 的步骤 1
bx和by是边界框中心相对于图像(网格单元格)的位置,如前所述定义。在我们的案例中,bx = 0.5,因为边界框的中心点距离原点 0.5 个单位。同样地,by = 0.5:
图 8.15:计算 bx 和 by
到目前为止,我们已经计算了从图像中对象的网格单元格中心到真实中心的偏移量。现在让我们了解如何计算bw和bh:
-
bw是边界框相对于网格单元格宽度的比率。
-
bh是边界框相对于网格单元格高度的比率。
-
接下来,我们将预测与网格单元格对应的类别。如果我们有三个类别(
c1
–truck
,c2
–car
和c3
–bus
),我们将预测网格单元格包含任何类别的对象的概率。请注意,这里我们不需要背景类别,因为pc对应于网格单元格是否包含对象。 -
现在我们了解了如何表示每个单元格的输出层之后,让我们了解如何构建我们的 3 x 3 网格单元的输出:
- 让我们考虑网格单元格a3的输出:
图 8.16:计算与单元格 a3 对应的真实值
单元格a3的输出如前所示的截图。由于网格单元格不包含对象,第一个输出(pc – 对象存在分数)为
0
,其余值由于单元格不包含任何对象的中心而无关紧要。- 让我们考虑与网格单元格b1对应的输出:
图 8.17:与单元格 b1 对应的真实值
之前的输出是因为网格单元格中包含有对象的bx、by、bw和bh值,这些值的获取方式与之前所述相同,最终类别为
car
,导致 c2 为1
,而 c1 和 c3 为0
。 - 让我们考虑网格单元格a3的输出:
请注意,对于每个单元格,我们能够获取 8 个输出。因此,对于 3 x 3 网格单元,我们获取 3 x 3 x 8 个输出。
让我们看看接下来的步骤:
- 定义一个模型,其输入是图像,输出为 3 x 3 x 8,并且根据前一步骤定义的真实值:
图 8.18:示例模型架构
- 通过考虑锚框来定义真实值。
到目前为止,我们一直在为预期只有一个物体存在于网格单元格内的情景进行构建。然而,在现实中,可能存在一个网格单元格内有多个物体的情况。这会导致创建不正确的真实值。让我们通过以下示例图像来理解这一现象:
图 8.19:同一个网格单元格中可能存在多个物体的情景
在上述示例中,汽车和人的真实边界框的中点都落在同一个单元格中 —— 单元格 b1。
避免这种情况的一种方法是使用具有更多行和列的网格,例如一个 19 x 19 的网格。然而,仍然可能存在增加网格单元格数量并不起作用的情况。在这种情况下,锚框就显得特别有用。假设我们有两个锚框 —— 一个高度大于宽度(对应于人),另一个宽度大于高度(对应于汽车):
图 8.20:利用锚框
通常,锚框会以网格单元格中心作为它们的中心。在存在两个锚框的情况下,每个单元格的输出表示为两个锚框期望输出的串联:
图 8.21:当存在两个锚框时的真实值表示
这里,bx,by,bw 和 bh 表示与锚框的偏移(在这种场景中,锚框是宇宙,如图像所示,而不是网格单元格)。
从前面的截图中,我们看到输出为 3 x 3 x 16,因为有两个锚框。期望输出的形状为 N x N x num_classes
x num_anchor_boxes
,其中 N x N 是网格中单元格的数量,num_classes
是数据集中的类别数,num_anchor_boxes
是锚框的数量。
- 现在我们定义损失函数来训练模型。
当计算与模型相关的损失时,我们需要确保在物体性分数低于某个阈值时不计算回归损失和分类损失(这对应于不包含物体的单元格)。
接下来,如果单元格包含一个物体,我们需要确保跨不同类别的分类尽可能准确。
最后,如果单元格包含对象,则边界框偏移应尽可能接近预期。然而,由于宽度和高度的偏移可以比中心的偏移要大得多(因为中心的偏移范围在 0 到 1 之间,而宽度和高度的偏移则不需要),因此我们通过获取平方根值来给予宽度和高度偏移更低的权重。
计算定位和分类损失如下:
在此,我们观察到以下内容:
-
是与回归损失相关联的权重。
-
表示单元格是否包含对象
-
对应于预测类别概率
-
表示物体性得分
总损失是分类损失和回归损失值的总和。
现在,我们已经能够训练一个模型来预测物体周围的边界框。然而,为了更深入地了解 YOLO 及其变体,我们建议您阅读原始论文,网址为 arxiv.org/pdf/1506.02640
。
现在我们了解了 YOLO 如何在单次预测中预测物体的边界框和类别之后,我们将在下一节中编写代码。
在自定义数据集上训练 YOLO
在深度学习中,建立在他人工作的基础上是成为成功从业者的重要途径。对于这一实现,我们将使用官方 YOLOv4 实现来识别图像中公共汽车和卡车的位置。我们将克隆 YOLO 作者自己的存储库实现,并根据需要进行定制,如下所示。
要训练最新的 YOLO 模型,我们强烈建议您查阅以下存储库 – github.com/ultralytics/ultralytics
和 github.com/WongKinYiu/yolov7
。
我们已经在 GitHub 的Chapter08
文件夹中提供了 YOLOv8 的工作实现,文件名为Training_YOLOv8.ipynb
,网址为 bit.ly/mcvp-2e
。
安装 Darknet
首先,从 GitHub 拉取darknet
存储库并在环境中编译它。该模型是用一种称为 Darknet 的独立语言编写的,与 PyTorch 不同。我们将使用以下代码进行操作:
可在 GitHub 上的Chapter08
文件夹中的Training_YOLO.ipynb
文件中找到以下代码,网址为 bit.ly/mcvp-2e
。
-
拉取 Git 存储库:
!git clone https://github.com/AlexeyAB/darknet %cd darknet
-
重新配置
Makefile
文件:!sed -i 's/OPENCV=0/OPENCV=1/' Makefile # In case you dont have a GPU, make sure to comment out the # below 3 lines !sed -i 's/GPU=0/GPU=1/' Makefile !sed -i 's/CUDNN=0/CUDNN=1/' Makefile !sed -i 's/CUDNN_HALF=0/CUDNN_HALF=1/' Makefile
Makefile
是在环境中安装 darknet
所需的配置文件(可以将此过程视为在 Windows 上安装软件时所做的选择)。我们强制 darknet
使用以下标志进行安装:OPENCV
、GPU
、CUDNN
和 CUDNN_HALF
。这些都是重要的优化措施,可加快训练速度。此外,在前面的代码中,有一个称为 sed
的奇特函数,它代表 流编辑器。它是一个强大的 Linux 命令,可以直接从命令提示符中修改文本文件中的信息。具体而言,在这里我们使用它的搜索和替换功能,将 OPENCV=0
替换为 OPENCV=1
,以此类推。要理解的语法是 sed 's/<search-string>/<replace-with>/'path/to/text/file
。
-
编译
darknet
源代码:!make
-
安装
torch_snippets
包:!pip install -q torch_snippets
-
下载并解压数据集,并删除 ZIP 文件以节省空间:
!wget --quiet \ https://www.dropbox.com/s/agmzwk95v96ihic/open-images-bus-trucks.tar.xz !tar -xf open-images-bus-trucks.tar.xz !rm open-images-bus-trucks.tar.xz
-
获取预训练权重以进行样本预测:
!wget --quiet\ https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights
-
通过运行以下命令测试安装是否成功:
!./darknet detector test cfg/coco.data cfg/yolov4.cfg\ yolov4.weights data/person.jpg
这将使用从 cfg/yolov4.cfg
构建的网络和预训练权重 yolov4.weights
对 data/person.jpg
进行预测。此外,它还从 cfg/coco.data
获取类别,这是预训练权重训练的内容。
前述代码将对样本图像 (data/person.jpg
) 进行预测,如下所示:
图 8.22:对样本图像的预测
现在我们已经了解了如何安装 darknet
,在下一节中,我们将学习如何为自定义数据集创建真实数据,以利用 darknet
。
设置数据集格式
YOLO 使用固定格式进行训练。一旦我们按要求格式存储图像和标签,就可以用单个命令对数据集进行训练。因此,让我们了解一下 YOLO 训练所需的文件和文件夹结构。
有三个重要的步骤:
-
创建一个文本文件
data/obj.names
,其中包含一行一个类别名称,通过运行以下命令行来实现(%%writefile
是一个魔术命令,用于在笔记本单元格中创建一个包含内容的文本文件data/obj.names
):%%writefile data/obj.names bus truck
-
在
data/obj.data
创建一个文本文件,描述数据集中的参数以及包含训练和测试图像路径的文本文件的位置,还有包含对象名称的文件位置和保存训练模型的文件夹位置:%%writefile data/obj.data classes = 2 train = data/train.txt valid = data/val.txt names = data/obj.names backup = backup/
前述文本文件的扩展名不是 .txt
。YOLO 使用硬编码的名称和文件夹来识别数据的位置。此外,魔术命令 %%writefile
在 Jupyter 中创建一个带有单元格中指定内容的文件,如前所示。将每个 %%writefile
...
视为 Jupyter 中的一个单独单元格。
-
将所有图像和真实文本文件移动到
data/obj
文件夹中。我们将从bus-trucks
数据集中将图像复制到此文件夹,并包含标签:!mkdir -p data/obj !cp -r open-images-bus-trucks/images/* data/obj/ !cp -r open-images-bus-trucks/yolo_labels/all/{train,val}.txt data/ !cp -r open-images-bus-trucks/yolo_labels/all/labels/*.txt data/obj/
请注意,所有的训练和验证图像都位于同一个data/obj
文件夹中。我们还将一堆文本文件移动到同一个文件夹中。每个包含图像真实标签的文件与图像具有相同的名称。例如,文件夹可能包含1001.jpg
和1001.txt
,意味着文本文件包含该图像的标签和边界框。如果data/train.txt
包含1001.jpg
作为其行之一,则它是一个训练图像。如果它存在于val.txt
中,则是一个验证图像。
文本文件本身应包含如下信息:cls
、xc
、yc
、w
、h
,其中cls
是边界框中物体的类索引,位于(xc
,yc
)处,表示矩形的中心点,宽度为w
,高度为h
。每个xc
、yc
、w
和h
都是图像宽度和高度的一部分。将每个对象存储在单独的行上。
例如,如果宽度(800
)和高度(600
)的图像包含一个卡车和一个公共汽车,中心分别为(500,300
)和(100,400
),宽度和高度分别为(200,100
)和(300,50
),则文本文件如下所示:
1 0.62 0.50 0.25 0.12
0 0.12 0.67 0.38 0.08
现在我们已经创建了数据,让我们在下一节中配置网络架构。
配置架构
YOLO 提供了一长串的架构。有些大,有些小,适用于大或小的数据集。配置可以具有不同的主干。标准数据集有预训练的配置。每个配置都是 GitHub 仓库cfgs
文件夹中的.cfg
文件。
每个文件都包含网络架构的文本文件(与我们使用nn.Module
类建立的方式不同),以及一些超参数,如批处理大小和学习率。我们将选择最小的可用架构,并为我们的数据集进行配置:
# create a copy of existing configuration and modify it in place
!cp cfg/yolov4-tiny-custom.cfg cfg/yolov4-tiny-bus-trucks.cfg
# max_batches to 4000 (since the dataset is small enough)
!sed -i 's/max_batches = 500200/max_batches=4000/' cfg/yolov4-tiny-bus-trucks.cfg
# number of sub-batches per batch
!sed -i 's/subdivisions=1/subdivisions=16/' cfg/yolov4-tiny-bus-trucks.cfg
# number of batches after which learning rate is decayed
!sed -i 's/steps=400000,450000/steps=3200,3600/' cfg/yolov4-tiny-bus-trucks.cfg
# number of classes is 2 as opposed to 80
# (which is the number of COCO classes)
!sed -i 's/classes=80/classes=2/g' cfg/yolov4-tiny-bus-trucks.cfg
# in the classification and regression heads,
# change number of output convolution filters
# from 255 -> 21 and 57 -> 33, since we have fewer classes
# we don't need as many filters
!sed -i 's/filters=255/filters=21/g' cfg/yolov4-tiny-bus-trucks.cfg
!sed -i 's/filters=57/filters=33/g' cfg/yolov4-tiny-bus-trucks.cfg
这样一来,我们已经重新配置了yolov4-tiny
,使其可以在我们的数据集上进行训练。唯一剩下的步骤是加载预训练的权重并训练模型,这将在下一节中进行。
训练和测试模型
我们将从以下 GitHub 位置获取权重,并将它们存储在build/darknet/x64
中:
!wget --quiet https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v4_pre/yolov4-tiny.conv.29
!cp yolov4-tiny.conv.29 build/darknet/x64/
最后,我们将使用以下命令训练模型:
!./darknet detector train data/obj.data \
cfg/yolov4-tiny-bus-trucks.cfg yolov4-tiny.conv.29 -dont_show -mapLastAt
-dont_show
标志跳过显示中间预测图像,而-mapLastAt
会定期打印验证数据上的平均精度。整个训练可能需要 1 到 2 小时与 GPU。权重会定期存储在备份文件夹中,并可以在训练后用于预测,例如以下代码,该代码在新图像上进行预测:
!pip install torch_snippets
from torch_snippets import Glob, stem, show, read
# upload your own images to a folder
image_paths = Glob('images-of-trucks-and-busses')
for f in image_paths:
!./darknet detector test \
data/obj.data cfg/yolov4-tiny-bus-trucks.cfg\
backup/yolov4-tiny-bus-trucks_4000.weights {f}
!mv predictions.jpg {stem(f)}_pred.jpg
for i in Glob('*_pred.jpg'):
show(read(i, 1), sz=20)
前面的代码导致了以下输出:
图 8.23:输入图像上的预测边界框和类别
现在我们已经了解了如何利用 YOLO 在自定义数据集上执行目标检测,接下来,我们将学习另一种目标检测技术——单阶段检测器(SSD)。
SSD 的工作细节
到目前为止,我们已经看到了一个场景,在这个场景中,我们在逐渐对前一层输出进行卷积和池化之后进行预测。然而,我们知道不同的层对原始图像有不同的感知域。例如,初始层的感知域比最终层的感知域要小。在这里,我们将学习 SSD 如何利用这一现象为图像的边界框进行预测。
SSD 如何帮助解决检测不同尺度对象的问题的工作原理如下:
-
我们利用预训练的 VGG 网络,并在其上添加一些额外的层,直到获得一个 1 x 1 的块。
-
不仅仅利用最终层进行边界框和类别预测,我们将利用所有最后几层来进行类别和边界框的预测。
-
我们将使用具有特定比例和长宽比的默认框代替锚框。
-
每个默认框都应该预测对象和边界框偏移量,就像锚框在 YOLO 中预测类别和偏移量一样。
现在我们了解了 SSD 与 YOLO 的主要不同之处(即 SSD 中的默认框取代了 YOLO 中的锚框,并且多个层连接到最终层,而不是 YOLO 中的逐步卷积池化),接下来我们将学习以下内容:
-
SSD 的网络架构
-
如何利用不同层进行边界框和类别预测
-
如何为不同层的默认框分配比例和长宽比
SSD 的网络架构如下:
图 8.24:SSD 工作流程
正如您在前面的图中所看到的,我们正在获取一个大小为 300 x 300 x 3 的图像,并通过预训练的 VGG-16 网络来获得conv5_3
层的输出。此外,我们通过在conv5_3
输出上添加几个额外的卷积来扩展网络。
接下来,我们为每个单元格和每个默认框获取边界框偏移量和类别预测(关于默认框的更多信息将在下一节详细介绍;现在,让我们想象这与锚框类似)。从conv5_3
输出中得到的预测总数为 38 x 38 x 4,其中 38 x 38 是conv5_3
层的输出形状,4 是在conv5_3
层上操作的默认框数量。同样,网络中所有检测的总数如下:
层 | 每类的检测数 |
---|---|
conv5_3 | 38 x 38 x 4 = 5,776 |
FC6 | 19 x 19 x 6 = 2,166 |
conv8_2 | 10 x 10 x 6 = 600 |
conv9_2 | 5 x 5 x 6 = 150 |
conv10_2 | 3 x 3 x 4 = 36 |
conv11_2 | 1 x 1 x 4 = 4 |
总检测数 | 8,732 |
表 8.1:每类的检测数
请注意,在原始论文描述的架构中,某些层次的默认盒子数量较多(为 6 而不是 4)。
现在,让我们了解默认盒子的不同尺度和长宽比。我们将从尺度开始,然后继续到长宽比。
让我们想象一种情况,其中对象的最小比例为图像高度和宽度的 20%,对象的最大比例为图像高度和宽度的 90%。在这种情况下,我们随着层次的增加逐渐增加尺度(随着向后层次,图像尺寸显著缩小),如下所示:
图 8.25:随着不同层次对象大小比例变化的盒子尺度
使图像逐步缩放的公式如下:
了解了如何在不同层次计算尺度之后,让我们学习如何生成不同长宽比的盒子。可能的长宽比如下所示:
不同层次盒子的中心如下:
这里,i 和 j 一起表示层 l 中的一个单元格。另一方面,根据不同长宽比计算的宽度和高度如下所示:
请注意,在某些层次我们考虑了四个盒子,而在另一层次我们考虑了六个盒子。如果要保留四个盒子,则移除 {3,1/3}
长宽比,否则考虑所有六个可能的盒子(五个尺度相同的盒子和一个尺度不同的盒子)。让我们看看如何获得第六个盒子:
有了这些,我们已经得到了所有可能的盒子。接下来让我们了解如何准备训练数据集。
具有 IoU 大于阈值(例如 0.5
)的默认盒子被视为正匹配,其余为负匹配。在 SSD 的输出中,我们预测盒子属于某一类的概率(其中第 0 类表示背景),还预测了相对于默认盒子的真实值偏移量。
最后,通过优化以下损失值来训练模型:
- 分类损失:使用以下方程表示:
在前述方程中,pos 表示与真实值有较高重叠的少数默认盒子,而 neg 表示误分类的盒子,这些盒子预测为某一类别,但实际上未包含对象。最后,我们确保 pos:neg 比例最多为 1:3,如果不进行这种采样,背景类盒子会占主导地位。
- 定位损失: 对于定位,我们仅在物体性得分大于某个阈值时考虑损失值。定位损失计算如下:
在这里,t是预测的偏移量,d是实际偏移量。
欲深入了解 SSD 工作流程,请参阅arxiv.org/abs/1512.02325
。
现在我们理解了如何训练 SSD,让我们在下一节将其用于我们的公共汽车与卡车目标检测练习中。本节的核心实用函数位于 GitHub 仓库:github.com/sizhky/ssd-utils/
。让我们在开始训练过程之前逐个了解它们。
SSD 代码中的组件
GitHub 仓库中有三个文件。让我们稍微深入了解它们,并在训练之前理解它们使用的导入。请注意,此部分不是训练过程的一部分,而是用于理解训练中使用的导入。
我们从 GitHub 仓库的model.py
文件中导入SSD300
和MultiBoxLoss
类。让我们分别了解它们。
SSD300
当你查看SSD300
函数定义时,可以明显看到该模型由三个子模块组成:
class SSD300(nn.Module):
...
def __init__(self, n_classes, device):
...
self.base = VGGBase()
self.aux_convs = AuxiliaryConvolutions()
self.pred_convs = PredictionConvolutions(n_classes)
...
首先我们将输入发送到VGGBase
,它返回两个尺寸为(N, 512, 38, 38)
和(N, 1024, 19, 19)
的特征向量。第二个输出将成为AuxiliaryConvolutions
的输入,它返回更多的特征映射,尺寸为(N, 512, 10, 10)
、(N, 256, 5, 5)
、(N, 256, 3, 3)
和(N, 256, 1, 1)
。最后,来自VGGBase
的第一个输出和这四个特征映射被发送到PredictionConvolutions
,它返回 8,732 个锚框,正如我们之前讨论的那样。
SSD300
类的另一个关键方面是create_prior_boxes
方法。对于每个特征图,与之关联的有三个项目:网格的大小、用于缩小网格单元的比例(这是该特征图的基础锚框),以及单元格中所有锚框的长宽比。使用这三个配置,代码使用三重for
循环并为所有 8,732 个锚框创建(cx, cy, w, h)
列表。
最后,detect_objects
方法接受模型预测的锚框分类和回归值张量,并将它们转换为实际边界框坐标。
MultiBoxLoss
作为人类,我们只关心少数边界框。但是对于 SSD 的工作方式,我们需要比较来自多个特征映射的 8,732 个边界框,并预测一个锚框是否包含有价值的信息。我们将此损失计算任务分配给MultiBoxLoss
。
forward 方法的输入是模型的锚框预测和地面真实边界框。
首先,我们将地面真值框转换为 8,732 个锚框的列表,方法是将模型中的每个锚点与边界框进行比较。如果 IoU(交并比)足够高,则特定的锚框将具有非零回归坐标,并将一个对象关联为分类的地面真值。自然地,大多数计算出的锚框将具有其关联类别为background
,因为它们与实际边界框的 IoU 很小,或者在很多情况下为0
。
一旦地面真实值转换为这些 8,732 个锚框回归和分类张量,就可以轻松地将它们与模型的预测进行比较,因为它们的形状现在是相同的。我们在回归张量上执行MSE-Loss
,在定位张量上执行CrossEntropy-Loss
,并将它们相加作为最终损失返回。
在自定义数据集上训练 SSD
在下面的代码中,我们将训练 SSD 算法以检测图像中物体周围的边界框。我们将使用我们一直在进行的卡车与公共汽车物体检测任务:
在 GitHub 的Chapter08
文件夹中的Training_SSD.ipynb
文件中找到以下代码,网址为bit.ly/mcvp-2e
。代码包含下载数据的 URL,并且相对较长。我们强烈建议在 GitHub 上执行笔记本以重现结果,同时按照文本中各种代码组件的步骤和解释进行操作。
-
下载图像数据集并克隆托管代码模型和其他处理数据工具的 Git 仓库:
import os if not os.path.exists('open-images-bus-trucks'): !pip install -q torch_snippets !wget --quiet https://www.dropbox.com/s/agmzwk95v96ihic/\ open-images-bus-trucks.tar.xz !tar -xf open-images-bus-trucks.tar.xz !rm open-images-bus-trucks.tar.xz !git clone https://github.com/sizhky/ssd-utils/ %cd ssd-utils
-
预处理数据,就像我们在在自定义数据集上训练 Faster R-CNN部分中所做的一样:
from torch_snippets import * DATA_ROOT = '../open-images-bus-trucks/' IMAGE_ROOT = f'{DATA_ROOT}/images' DF_RAW = pd.read_csv(f'{DATA_ROOT}/df.csv') df = DF_RAW.copy() df = df[df['ImageID'].isin(df['ImageID'].unique().tolist())] label2target = {l:t+1 for t,l in enumerate(DF_RAW['LabelName'].unique())} label2target['background'] = 0 target2label = {t:l for l,t in label2target.items()} background_class = label2target['background'] num_classes = len(label2target) device = 'cuda' if torch.cuda.is_available() else 'cpu'
-
准备数据集类,就像我们在在自定义数据集上训练 Faster R-CNN部分中所做的一样:
import collections, os, torch from PIL import Image from torchvision import transforms normalize = transforms.Normalize( mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225] ) denormalize = transforms.Normalize( mean=[-0.485/0.229,-0.456/0.224,-0.406/0.255], std=[1/0.229, 1/0.224, 1/0.255] ) def preprocess_image(img): img = torch.tensor(img).permute(2,0,1) img = normalize(img) return img.to(device).float() class OpenDataset(torch.utils.data.Dataset): w, h = 300, 300 def __init__(self, df, image_dir=IMAGE_ROOT): self.image_dir = image_dir self.files = glob.glob(self.image_dir+'/*') self.df = df self.image_infos = df.ImageID.unique() logger.info(f'{len(self)} items loaded') def __getitem__(self, ix): # load images and masks image_id = self.image_infos[ix] img_path = find(image_id, self.files) img = Image.open(img_path).convert("RGB") img = np.array(img.resize((self.w, self.h), resample=Image.BILINEAR))/255. data = df[df['ImageID'] == image_id] labels = data['LabelName'].values.tolist() data = data[['XMin','YMin','XMax','YMax']].values data[:,[0,2]] *= self.w data[:,[1,3]] *= self.h boxes = data.astype(np.uint32).tolist() # convert to # absolute coordinates return img, boxes, labels def collate_fn(self, batch): images, boxes, labels = [], [], [] for item in batch: img, image_boxes, image_labels = item img = preprocess_image(img)[None] images.append(img) boxes.append(torch.tensor( \ image_boxes).float().to(device)/300.) labels.append(torch.tensor([label2target[c] \ for c in image_labels]).long().to(device)) images = torch.cat(images).to(device) return images, boxes, labels def __len__(self): return len(self.image_infos)
-
准备训练和测试数据集以及数据加载器:
from sklearn.model_selection import train_test_split trn_ids, val_ids = train_test_split(df.ImageID.unique(), test_size=0.1, random_state=99) trn_df, val_df = df[df['ImageID'].isin(trn_ids)], \ df[df['ImageID'].isin(val_ids)] train_ds = OpenDataset(trn_df) test_ds = OpenDataset(val_df) train_loader = DataLoader(train_ds, batch_size=4, collate_fn=train_ds.collate_fn, drop_last=True) test_loader = DataLoader(test_ds, batch_size=4, collate_fn=test_ds.collate_fn, drop_last=True)
-
定义函数以在批量数据上进行训练,并计算验证数据的准确性和损失值:
def train_batch(inputs, model, criterion, optimizer): model.train() N = len(train_loader) images, boxes, labels = inputs _regr, _clss = model(images) loss = criterion(_regr, _clss, boxes, labels) optimizer.zero_grad() loss.backward() optimizer.step() return loss @torch.no_grad() def validate_batch(inputs, model, criterion): model.eval() images, boxes, labels = inputs _regr, _clss = model(images) loss = criterion(_regr, _clss, boxes, labels) return loss
-
导入模型:
from model import SSD300, MultiBoxLoss from detect import *
-
初始化模型、优化器和损失函数:
n_epochs = 5 model = SSD300(num_classes, device) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) criterion = MultiBoxLoss(priors_cxcy=model.priors_cxcy, device=device) log = Report(n_epochs=n_epochs) logs_to_print = 5
-
在增加的 epochs 上训练模型:
for epoch in range(n_epochs): _n = len(train_loader) for ix, inputs in enumerate(train_loader): loss = train_batch(inputs, model, criterion, optimizer) pos = (epoch + (ix+1)/_n) log.record(pos, trn_loss=loss.item(), end='\r') _n = len(test_loader) for ix,inputs in enumerate(test_loader): loss = validate_batch(inputs, model, criterion) pos = (epoch + (ix+1)/_n) log.record(pos, val_loss=loss.item(), end='\r')
随着 epoch 的增加,训练和测试损失值的变化如下:
图 8.26:随着 epoch 增加的训练和验证损失
-
获取新图像的预测(获取随机图像):
image_paths = Glob(f'{DATA_ROOT}/images/*') image_id = choose(test_ds.image_infos) img_path = find(image_id, test_ds.files) original_image = Image.open(img_path, mode='r') original_image = original_image.convert('RGB')
-
获取与图像中存在的对象对应的边界框、标签和分数:
bbs, labels, scores = detect(original_image, model, min_score=0.9, max_overlap=0.5, top_k=200, device=device)
-
将获取的输出覆盖在图像上:
labels = [target2label[c.item()] for c in labels] label_with_conf = [f'{l} @ {s:.2f}' for l,s in zip(labels,scores)] print(bbs, label_with_conf) show(original_image, bbs=bbs, texts=label_with_conf, text_sz=10)
上述代码获取执行迭代中每个输出的样本如下(每次执行都会有一个图像):
图 8.27:输入图像上的预测边界框和类别
从中我们可以看出,我们可以相当准确地检测图像中的物体。
摘要
在本章中,我们了解了现代目标检测算法 Faster R-CNN、YOLO 和 SSD 的工作细节。我们学习了它们如何克服两个独立模型的限制 - 一个用于获取区域提议,另一个用于在区域提议上获取类别和边界框偏移量。此外,我们使用 PyTorch 实现了 Faster R-CNN,使用 darknet
实现了 YOLO,并从头开始实现了 SSD。
在接下来的章节中,我们将学习关于图像分割的内容,这一步进一步超越了仅仅识别对象位置的功能,它还能识别对应对象的像素。此外,在 第十章,目标检测和分割的应用 中,我们将学习 Detectron2 框架,它不仅有助于检测对象,还能在单次操作中对它们进行分割。
问题
-
为什么 Faster R-CNN 相对于 Fast R-CNN 更快?
-
在与 Faster R-CNN 相比时,YOLO 和 SSD 为何更快?
-
YOLO 和 SSD 单次检测器算法有何特点?
-
目标性分数和类别分数之间有何区别?
在 Discord 上了解更多信息
加入我们社区的 Discord 空间,与作者和其他读者进行讨论: