PyTorch-现代计算机视觉第二版-四-

47 阅读1小时+

PyTorch 现代计算机视觉第二版(四)

原文:zh.annas-archive.org/md5/355d709877e6e04dc1540c8ccd0b447d

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:物体检测基础

在前几章中,我们学习了如何执行图像分类。想象一下利用计算机视觉进行自动驾驶汽车的场景。不仅需要检测图像中是否包含车辆、人行道和行人等物体,还需要准确识别这些物体的位置。在这种场景下,我们将在本章和下一章中学习的各种物体检测技术将非常有用。

在本章和下一章中,我们将学习一些执行物体检测的技术。我们将从学习基础知识开始 - 使用名为ybat的工具对图像中对象的地面实况边界框进行标记,使用selectivesearch方法提取区域提议,并通过交并比IoU)和均值平均精度度量来定义边界框预测的准确性。之后,我们将学习两个基于区域提议的网络 - R-CNN 和 Fast R-CNN - 首先了解它们的工作细节,然后在包含卡车和公共汽车图像的数据集上实施它们。

本章将涵盖以下主题:

  • 引入物体检测

  • 创建用于训练的边界框地面实况

  • 理解区域提议

  • 理解 IoU、非极大值抑制和均值平均精度

  • 训练基于 R-CNN 的自定义物体检测器

  • 训练基于 Fast R-CNN 的自定义物体检测器

    本章中的所有代码片段都可以在 GitHub 存储库的Chapter07文件夹中找到,链接为bit.ly/mcvp-2e

引入物体检测

随着自动驾驶汽车、面部检测、智能视频监控和人数统计解决方案的兴起,快速准确的物体检测系统需求量大增。这些系统不仅包括从图像中对物体进行分类,还包括在每个物体周围绘制适当边界框以定位它们。这(绘制边界框和分类)使得物体检测比其传统的计算机视觉前辈图像分类更为复杂。

在探索物体检测的广泛用例之前,让我们了解它如何增强我们在上一章中介绍的物体分类任务。想象一下图像中有多个物体的情况。我让你预测图像中存在的物体类别。例如,假设图像中既有猫又有狗。你会如何对这样的图像进行分类?物体检测在这种场景中非常有用,它不仅预测物体的位置(边界框),还预测各个边界框内存在的物体类别。

要理解物体检测的输出是什么样的,请查看以下图表:

图 7.1:对象分类与检测之间的区别

在前述图中,我们可以看到,典型的对象分类仅仅提到图像中存在的对象类别,而对象定位则在图像中的对象周围绘制边界框。另一方面,对象检测涉及绘制边界框以及识别图像中多个对象的边界框中对象的类别。

利用对象检测的一些不同用例包括以下内容:

  • 安全性:这对识别入侵者很有用。

  • 自动驾驶汽车:这对识别道路图像中各种对象很有帮助。

  • 图像搜索:这有助于识别包含感兴趣对象(或人员)的图像。

  • 汽车: 这可以帮助识别汽车图像中的车牌号码。

在所有前述情况下,对象检测被利用来在图像中的各种对象周围绘制边界框。

在本章中,我们将学习预测对象的类别,并在图像中围绕对象创建一个紧密的边界框,这是定位任务。我们还将学习检测图像中多个对象对应的类别,以及围绕每个对象的边界框,这是对象检测任务。

训练典型对象检测模型包括以下步骤:

  1. 创建包含图像中各种对象的边界框标签和类别的真值数据

  2. 提出扫描图像以识别可能包含对象的区域(区域建议)的机制

在本章中,我们将学习利用名为SelectiveSearch的方法生成的区域建议。在下一章中,我们将学习如何利用锚框来识别包含对象的区域。

  1. 通过使用 IoU 指标创建目标类别变量

  2. 创建目标边界框偏移变量,以纠正区域建议在步骤 2中的位置

  3. 构建一个模型,可以预测对象的类别,同时还能预测与区域建议对应的目标边界框偏移量

  4. 使用均值平均精度mAP)来衡量对象检测的准确性

现在我们已经对训练对象检测模型要做的事情有了高层次的概述,我们将在下一节学习为边界框创建数据集(这是构建对象检测模型的第一步)。

创建用于训练的边界框真值

我们已经了解到目标检测以边界框的形式给出感兴趣对象的输出图像。为了构建能够检测这些边界框的算法,我们需要创建输入输出组合,其中输入是图像,输出是边界框和物体类别。

请注意,当我们检测到边界框时,我们实际上是检测到围绕图像的边界框的四个角的像素位置。

要训练一个提供边界框的模型,我们需要图像及其图像中所有物体的对应边界框坐标。在本节中,我们将学习创建训练数据集的一种方法,其中图像是输入,而对应的边界框和物体类别存储在 XML 文件中作为输出。

在这里,我们将安装并使用ybat来创建(标注)图像中物体周围的边界框。我们还将检查包含注释类和边界框信息的 XML 文件。

请注意,还有像 CVAT 和 Label Studio 这样的替代图像标注工具。

让我们从 GitHub 下载ybat-master.zipgithub.com/drainingsun/ybat),然后解压缩它。然后,使用您选择的浏览器打开ybat.html

在我们开始创建与图像对应的真实标签之前,让我们指定我们想要跨图像标记的所有可能类,并将其存储在classes.txt文件中,如下所示:

自动生成的图形用户界面、文本、应用程序描述

图 7.2:提供类名

现在,让我们准备与图像对应的真实标签。这涉及到在物体周围绘制边界框(如以下步骤中所见的人物),并为图像中存在的对象分配标签/类别:

  1. 上传您想要标注的所有图像。

  2. 上传classes.txt文件。

  3. 通过首先选择文件名,然后在要标记的每个对象周围绘制十字线来为每个图像进行标记。在绘制十字线之前,请确保在以下图像中步骤 2下正确选择classes区域中的类别(classes窗格可以看到以下图像中步骤 2之下)。

  4. 将数据转储保存为所需格式。每种格式都是由不同的研究团队独立开发的,它们都同样有效。基于它们的流行度和便利性,每个实现都更喜欢不同的格式。

正如你所看到的,在下图中表示了前述步骤:

图 7.3:标注步骤

例如,当我们下载 PascalVOC 格式时,它会下载一个 XML 文件的压缩包。在 GitHub 上,可以看到绘制矩形边界框后 XML 文件的快照,文件名为sample_xml_file. xml。在那里,您将观察到bndbox字段包含感兴趣图像中对象的xy坐标的最小和最大值。我们还应该能够使用name字段提取图像中对象对应的类别。

现在我们了解了如何创建图像中存在对象的真实对象(类别标签和边界框),让我们深入了解识别图像中对象的基本构建块。首先,我们将介绍有助于突出显示最可能包含对象部分的区域建议。

理解区域建议

想象一个假设情景,感兴趣图像中包含一个人和背景的天空。假设背景(天空)的像素强度变化很小,而前景(人物)的像素强度变化很大。

仅从上述描述本身,我们可以得出这里有两个主要区域 - 人物和天空。此外,在人物图像的区域内,对应头发的像素与对应脸部的像素强度不同,建立了区域内可能存在多个子区域的事实。

区域建议 是一种技术,有助于识别区域岛,其中像素彼此相似。生成区域建议对于目标检测非常有用,其中我们必须识别图像中存在的对象的位置。此外,由于区域建议生成了一个区域的建议,它有助于目标定位,其中的任务是识别一个完全适合对象周围的边界框。我们将在稍后的部分,基于训练 R-CNN 的自定义对象检测器中学习区域建议如何协助对象的定位和检测,但首先让我们了解如何从图像中生成区域建议。

利用 SelectiveSearch 生成区域建议

SelectiveSearch 是用于目标定位的区域建议算法,它根据像素强度生成可能被一起分组的区域建议。SelectiveSearch 根据类似像素的层次分组像素,进而利用图像中内容的颜色、纹理、大小和形状的兼容性进行分组。

首先,SelectiveSearch 通过根据前述属性分组像素来过度分割图像。然后,它遍历这些过度分割的群组,并根据相似性将它们分组。在每次迭代中,它将较小的区域组合成较大的区域。

让我们通过以下示例了解selectivesearch的过程:

在本书的 GitHub 仓库Chapter07文件夹中的Understanding_selectivesearch.ipynb中找到此练习的完整代码,网址为bit.ly/mcvp-2e

  1. 安装所需的包:

    %pip install selectivesearch
    %pip install torch_snippets
    from torch_snippets import *
    import selectivesearch
    from skimage.segmentation import felzenszwalb 
    
  2. 获取并加载所需的图像:

    !wget https://www.dropbox.com/s/l98leemr7/Hemanvi.jpeg
    img = read('Hemanvi.jpeg', 1) 
    
  3. 从图像中提取基于颜色、纹理、大小和形状兼容性的felzenszwalb分段:

    segments_fz = felzenszwalb(img, scale=200) 
    

请注意,在felzenszwalb方法中,scale表示可以在图像段内形成的簇的数量。scale值越高,保留的原始图像细节就越多。

  1. 绘制原始图像和带有分割的图像:

    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 的自定义目标检测器的部分中使用它:

  1. 导入相关包并获取图像:

    %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) 
    
  2. 定义extract_candidates函数,从图像中获取区域提议:

    1. 定义以图像作为输入参数的函数:
    def extract_candidates(img): 
    
    1. 使用selective_search方法在图像内获取候选区域:
     img_lbl, regions = selectivesearch.selective_search(img, 
                                       scale=200,  min_size=100) 
    
    1. 计算图像面积并初始化一个候选列表,用于存储通过定义的阈值的候选者:
     img_area = np.prod(img.shape[:2])
        candidates = [] 
    
    1. 仅获取那些超过总图像面积的 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 
    
  3. 提取候选区域并在图像顶部绘制它们:

    candidates = extract_candidates(img)
    show(img, bbs=candidates) 
    

A dog in a cage  Description automatically generated with medium confidence

图 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 作为输出:

  1. 指定get_iou函数,该函数以boxAboxB作为输入,其中boxAboxB是两个不同的边界框(可以将boxA视为地面真实边界框,boxB视为区域提议):

    def get_iou(boxA, boxB, epsilon=1e-5): 
    

我们定义了epsilon参数来处理罕见的情况,即两个框之间的并集为 0,导致除零错误。请注意,在每个边界框中,将有四个值对应于边界框的四个角。

  1. 计算交集框的坐标:

     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 值,x2y2分别存储交集部分的最右 x 值和最下 y 值。

  1. 计算交集区域的宽度和高度(widthheight):

     width = (x2 - x1)
        height = (y2 - y1) 
    
  2. 计算重叠区域的面积(area_overlap):

     if (width<0) or (height <0):
            return 0.0
        area_overlap = width * height 
    

请注意,在前面的代码中,如果与重叠区域对应的宽度或高度小于 0,则交集的面积为 0。否则,我们计算重叠(交集)的面积类似于计算矩形面积的方式 - 宽度乘以高度。

  1. 计算对应于两个边界框的组合面积:

     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_aarea_b - 然后在计算area_combined时减去重叠的区域,因为在计算area_aarea_b时,area_overlap被计算了两次。

  1. 计算 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 的对象检测有一个高层次的理解:

Diagram  Description automatically generated

图 7.9:R-CNN 步骤序列(图片来源:https://arxiv.org/pdf/1311.2524.pdf

在利用 R-CNN 技术进行对象检测时,我们执行以下步骤:

  1. 从图像中提取区域提案。我们需要确保提取出大量的提案,以免错过图像中的任何潜在对象。

  2. 调整(变形)所有提取的区域以获得相同大小的区域。

  3. 将调整大小后的区域提案通过网络传递。通常情况下,我们会通过预训练模型(如 VGG16 或 ResNet50)传递调整大小后的区域提案,并在全连接层中提取特征。

  4. 创建用于模型训练的数据,其中输入是通过预训练模型传递区域提案提取的特征。输出是每个区域提案对应的类别以及与图像对应的地面实况边界框的区域提案偏移量。

如果一个区域提案与对象的 IoU 大于特定阈值,则创建训练数据。在这种情况下,该区域任务是预测其重叠对象的类别以及与包含感兴趣对象的地面实况边界框相关的区域提案的偏移量。以下展示了样本图像、区域提案和地面实况边界框:

图 7.10:带有区域提案和地面实况边界框的样本图像

在前述图像中,o(红色)表示区域提案的中心(虚线边界框),x表示与cat类别对应的地面实况边界框的中心(实线边界框)。我们计算区域提案边界框与地面实况边界框之间的偏移量,作为两个边界框中心坐标之间的差异(dxdy)及边界框高度和宽度之间的差异(dwdh)。

  1. 连接两个输出头,一个对应图像类别,另一个对应区域提案与地面实况边界框的偏移量,以提取对象的精细边界框。

这个练习类似于预测性别(一个类别变量,类似于本案例研究中的对象类别)和年龄(一个连续变量,类似于对区域提案进行的偏移量),基于第五章中一个人脸图像的用例。

  1. 编写自定义损失函数来训练模型,该函数将最小化对象分类误差和边界框偏移误差。

注意,我们要最小化的损失函数与原始论文中优化的损失函数不同(arxiv.org/pdf/1311.2524.pdf)。我们这样做是为了减少从头开始构建 R-CNN 和 Fast R-CNN 所带来的复杂性。一旦您熟悉了模型的工作原理,并能够使用接下来两节中的代码构建模型,我们强烈建议您从头开始实现原始论文中的模型。

在接下来的部分,我们将学习获取数据集和为训练创建数据。在此之后的部分中,我们将学习设计模型并对其进行训练,然后在新图像中预测存在的对象类别及其边界框。

在自定义数据集上实现物体检测的 R-CNN。

到目前为止,我们已经理解了 R-CNN 的工作原理。现在我们将学习如何为训练创建数据。该过程涉及以下步骤:

  1. 下载数据集。

  2. 准备数据集。

  3. 定义区域提议提取和 IoU 计算函数。

  4. 创建训练数据。

  5. 为模型创建输入数据。

  6. 调整区域提议的大小。

  7. 通过预训练模型将它们传递以获取完全连接层的值。

  8. 为模型创建输出数据。

  9. 为每个区域提议标记类别或背景标签。

  10. 定义区域提议相对于地面真值的偏移量(如果区域提议对应于对象而不是背景)。

  11. 定义并训练模型。

  12. 在新图像上进行预测。

让我们开始编码以下各节。

下载数据集。

对于物体检测的情况,我们将从 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 文件中。

准备数据集。

现在我们已经下载了数据集,我们将准备数据集。该过程涉及以下步骤:

  1. 获取每个图像及其相应的类别和边界框值。

  2. 获取每个图像中的区域提议,它们对应的 IoU 值,以及区域提议相对于地面真值将被校正的增量。

  3. 为每个类别分配数字标签(除了公交车和卡车类别之外,我们还有一个额外的背景类别,其中 IoU 与地面真实边界框低于阈值)。

  4. 将每个区域建议调整为一个公共大小,以便将它们传递给网络

在此练习结束时,我们将已经调整了区域建议的裁剪大小,为每个区域建议分配了真实的类别,并计算了区域建议相对于真实边界框的偏移量。我们将从上一节结束的地方继续编码:

  1. 指定图像的位置,并读取我们下载的 CSV 文件中存在的真实值:

    IMAGE_ROOT = 'images/images'
    DF_RAW = pd.read_csv('df.csv')
    print(DF_RAW.head()) 
    

自动生成的表说明

图 7.11:样本数据

注意,XMinXMaxYMinYMax 对应于图像边界框的真实值。此外,LabelName 提供了图像的类别。

  1. 定义一个类,返回图像及其对应的类和真实值,以及图像的文件路径:

    1. 将数据框(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) 
    
    1. 定义 __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 
    
  2. 检查一个样本图像及其对应的类和边界框真实值:

    ds = OpenImages(df=DF_RAW)
    im, bbs, clss, _ = ds[9]
    show(im, bbs=bbs, texts=clss, sz=10) 
    

图 7.12:带有真实边界框和对象类别的样本图像

  1. 定义 extract_iouextract_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 方法提取的候选区域组成,输出包括与候选区域相对应的类别以及如果候选区域包含对象时其相对于最大重叠边界框的偏移量。

我们将从上一节结束的地方继续编码:

  1. 初始化空列表来存储文件路径(FPATHS),真实边界框(GTBBS),对象类别(CLSS),边界框与区域建议的偏移量(DELTAS),区域建议位置(ROIS)以及区域建议与真实值的 IoU(IOUS):

    FPATHS, GTBBS, CLSS, DELTAS, ROIS, IOUS = [],[],[],[],[],[] 
    
  2. 遍历数据集并填充在步骤 1中初始化的列表:

    1. 对于这个练习,我们可以使用所有数据点进行训练,或者只使用前 500 个数据点进行演示。您可以在两者之间选择,这将决定训练时间和训练准确度(数据点越多,训练时间和准确度越高):
    N = 500
    for ix, (im, bbs, labels, fpath) in enumerate(ds):
        if(ix==N):
            break 
    

    在上述代码中,我们指定将处理 500 张图像:

    1. 使用extract_candidates函数从每个图像(im)中提取候选项的绝对像素值(注意,XMinXmaxYMinYMax是作为已下载数据帧中图像形状的比例提供的),并将提取的区域坐标从(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]) 
    
    1. 初始化iousroisdeltasclss,作为存储每个图像中每个候选项的 IoU、区域提议位置、边界框偏移和对应类别的列表。我们将浏览所有选择性搜索中的提议,并将具有高 IoU 的提议存储为公共汽车/卡车提议(标签中的任意一种类别),其余提议存储为背景提议:
     ious, rois, clss, deltas = [], [], [], [] 
    
    1. 将所有候选项相对于图像中的所有地面真实边界框的 IoU 存储起来,其中bbs是图像中不同对象的地面真实边界框,而candidates包括在前一步骤中获取的区域提议候选项:
     ious = np.array([[extract_iou(candidate, _bb_) for \
                    candidate in candidates] for _bb_ in bbs]).T 
    
    1. 循环遍历每个候选项并存储候选项的 XMin(cx)、YMin(cy)、XMax(cX)和 YMax(cY)值:
     for jx, candidate in enumerate(candidates):
            cx,cy,cX,cY = candidate 
    
    1. 提取与获取了 IoU 列表的所有地面真实边界框相关的候选项对应的 IoU 时:
     candidate_ious = ious[jx] 
    
    1. 找到具有最高 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] 
    
    1. 如果 IoU(best_iou)大于阈值(0.3),我们将为与候选项对应的类分配标签,否则分配背景:
     if best_iou > 0.3: clss.append(labels[best_iou_at])
            else : clss.append('background') 
    
    1. 提取所需的偏移量(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])) 
    
    1. 将文件路径、IoU、RoI、类偏移和地面真实边界框追加到列表中:
     FPATHS.append(fpath)
        IOUS.append(ious)
        ROIS.append(rois)
        CLSS.append(clss)
        DELTAS.append(deltas)
        GTBBS.append(bbs) 
    
    1. 获取图像路径名并将获取的所有信息FPATHSIOUSROISCLSSDELTASGTBBS存储在一个列表的列表中:
    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。

  1. 为每个类别分配索引:

    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'] 
    

现在我们已经为每个区域提议分配了一个类别,并创建了边界框偏移量的其他地面真值。接下来,我们将获取与获取的信息(FPATHSIOUSROISCLSSDELTASGTBBS)对应的数据集和数据加载器。

创建训练数据

到目前为止,我们已经获取了数据,在所有图像中获取了区域提议,准备了每个区域提议中对象的类别的地面真值,并获取了与相应图像中对象具有高重叠(IoU)的每个区域提议相对应的偏移量。

在本节中,我们将基于前一节结尾处获得的区域提议的地面真值准备一个数据集类,从中创建数据加载器。然后,我们将通过将每个区域提议调整为相同形状并缩放它们来标准化每个区域提议。我们将继续从前一节结束的地方编码:

  1. 定义一个函数来在通过类似 VGG16 的预训练模型之前对图像进行标准化处理。对于 VGG16 的标准化实践如下:

    normalize= transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                    std=[0.229, 0.224, 0.225]) 
    
  2. 定义一个函数(preprocess_image)来预处理图像(img),其中我们会切换通道、标准化图像,并将其注册到设备上:

    def preprocess_image(img):
        img = torch.tensor(img).permute(2,0,1)
        img = normalize(img)
        return img.to(device).float() 
    
  3. 定义解码预测的函数decode

  4. def decode(_y):
        _, preds = _y.max(-1)
        return preds 
    
  5. 使用前面步骤中获取的预处理区域提议和地面真值(前一节的第 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) 
    
    1. 根据区域提议获取裁剪图像以及与类别和边界框偏移量相关的其他地面真值:
     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 
    
    1. 定义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 
    
  6. 创建训练和验证数据集以及数据加载器:

    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 网络架构

现在我们已经准备好数据,在本节中,我们将学习如何构建一个模型,该模型能够预测区域提议的类别和相应的偏移量,以便在图像中绘制紧密的边界框。我们采用的策略如下:

  1. 定义一个 VGG 主干。

  2. 通过预训练模型后获取标准化裁剪后的特征。

  3. 将带有 sigmoid 激活的线性层附加到 VGG 主干上,以预测区域提议对应的类别。

  4. 附加一个额外的线性层来预测四个边界框偏移量。

  5. 定义用于两个输出(一个用于预测类别,另一个用于预测四个边界框偏移量)的损失计算方法。

  6. 训练模型,该模型预测区域提议的类别和四个边界框偏移量。

执行以下代码。我们将继续从前一节结束的地方编码:

  1. 定义 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) 
    
  2. 定义RCNN网络模块:

    1. 定义类别:
    class RCNN(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 定义骨干网 (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(),
                            ) 
    
    1. 定义损失函数,对应类别预测 (self.cel) 和边界框偏移回归 (self.sl1):
     self.cel = nn.CrossEntropyLoss()
            self.sl1 = nn.L1Loss() 
    
    1. 定义前向传播方法,通过 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 
    
    1. 定义计算损失的函数 (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 
    
  3. 有了模型类别后,我们现在定义批量数据训练和验证数据预测的功能:

    1. 定义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() 
    
    1. 定义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() 
    
  4. 现在,让我们创建一个模型对象,获取损失标准,然后定义优化器和时期数:

    rcnn = RCNN().to(device)
    criterion = rcnn.calc_loss
    optimizer = optim.SGD(rcnn.parameters(), lr=1e-3)
    n_epochs = 5
    log = Report(n_epochs) 
    

现在我们在增加的时期内训练模型:

  1. 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:增加时期内的训练和验证损失

现在我们已经训练了一个模型,我们将在下一节中使用它来预测新图像。

预测新图像

让我们利用到目前为止训练的模型来预测并在新图像上绘制对象周围的边界框及其对应的对象类别,在预测的边界框内。

  1. 从新图像中提取区域建议。

  2. 调整和规范化每个裁剪。

  3. 将处理后的裁剪图像前向传递以预测类别和偏移量。

  4. 执行非最大抑制以仅获取置信度最高的包含对象的框。

我们通过一个函数执行上述策略,该函数以图像作为输入,并提供地面实况边界框(仅用于比较地面实况和预测的边界框)。

我们将继续从前一节结束的地方进行编码:

  1. 定义test_predictions函数以预测新图像:

    1. 该函数以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] 
    
    1. 遍历候选者以调整大小和预处理图像:
     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) 
    
    1. 预测类别和偏移量:
     with torch.no_grad():
            rcnn.eval()
            probs, deltas = rcnn(input)
            probs = torch.nn.functional.softmax(probs, -1)
            confs, clss = torch.max(probs, -1) 
    
    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) 
    
    1. 使用非最大抑制(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]] 
    
    1. 获取置信度最高的边界框:
     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 
    
    1. 绘制图像以及预测的边界框:
     _, 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 
    
  2. 在新图像上执行上述函数:

    image, crops, bbs, labels, deltas, gtbbs, fpath = test_ds[7]
    test_predictions(fpath) 
    

上述代码生成以下图像:

img/B18457_07_14.png

图 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:

img/B18457_07_15.png

图 7.15:Fast R-CNN 的工作细节

让我们通过以下步骤理解上述图表:

  1. 将图像通过预训练模型传递以在扁平化层之前提取特征;我们称它们为输出特征图。

  2. 提取与图像对应的区域建议。

  3. 提取与区域建议对应的特征图区域(请注意,当图像通过 VGG16 架构传递时,输出时图像会缩小 32 倍,因为进行了五次池化操作。因此,如果原始图像中存在边界框(32,64,160,128),则对应于边界框(1,2,5,4)的特征图将对应于完全相同的区域)。

  4. 逐个将对应于区域提议的特征图通过**感兴趣区域(RoI)**池化层,使所有区域提议的特征图具有相似的形状。这是对 R-CNN 技术中执行的扭曲的替代。

  5. 将 RoI 池化层的输出值通过全连接层传递。

  6. 训练模型以预测每个区域提议对应的类别和偏移量。

注意,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

  1. 创建一个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 部分学到的非常相似,唯一的变化是我们返回了更多信息(roisrixs)。

rois 矩阵包含关于批处理中的哪个 RoI 属于哪个图像的信息。注意,input 包含多个图像,而 rois 是一个单独的框列表。我们不会知道第一张图像有多少个 RoI 属于它,第二张图像有多少个 RoI 属于它,依此类推。这就是 rixs 起作用的地方。它是一个索引列表。列表中的每个整数将相应的边界框与适当的图像关联起来;例如,如果 rixs[0,0,0,1,1,2,3,3,3],则我们知道前三个边界框属于批处理中的第一个图像,接下来的两个属于第二个图像,依此类推。

  1. 创建训练和测试数据集:

    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) 
    
  2. 定义一个用于训练数据集的模型:

    1. 首先,导入 torchvision.ops 类中存在的 RoIPool 方法:
    from torchvision.ops import RoIPool 
    
    1. 定义 FRCNN 网络模块:
    class FRCNN(nn.Module):
        def __init__(self):
            super().__init__() 
    
    1. 载入预训练模型并冻结参数:
     rawnet= torchvision.models.vgg16_bn(pretrained=True)
            for param in rawnet.features.parameters():
                param.requires_grad = False 
    
    1. 提取直至最后一层的特征:
     self.seq = nn.Sequential(*list(\rawnet.features.children())[:-1]) 
    
    1. 指定 RoIPool 要提取 7 x 7 的输出。这里,spatial_scale 是建议(来自原始图像)需要缩小的因子,以便在通过展平层之前,每个输出都具有相同的形状。图像大小为 224 x 224,而特征图大小为 14 x 14:
     self.roipool = RoIPool(7, spatial_scale=14/224) 
    
    1. 定义输出头部 – cls_scorebbox
     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(),
                            ) 
    
    1. 定义损失函数:
     self.cel = nn.CrossEntropyLoss()
            self.sl1 = nn.L1Loss() 
    
    1. 定义 forward 方法,该方法将图像、区域建议和区域建议的索引作为网络的输入:
     def forward(self, input, rois, ridx): 
    
    1. input 图像通过预训练模型传递:
     res = input
            res = self.seq(res) 
    
    1. 创建 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 
    
    1. 定义损失值计算 (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 
    
  3. 定义在批处理中训练和验证的函数,就像我们在 训练 基于自定义目标检测器的 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() 
    
  4. 定义并训练模型以增加 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 时的训练和验证损失

  1. 定义一个函数来对测试图像进行预测:

    1. 定义一个函数,该函数以文件名作为输入,然后读取文件并将其调整大小为 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)) 
    
    1. 获取区域建议,将它们转换为 (x1, y1, x2, y2) 格式(左上角像素和右下角像素的坐标),然后将这些值转换为它们所在的宽度和高度的比例:
     candidates = extract_candidates(img)
        candidates = [(x,y,x+w,y+h) for x,y,w,h in candidates] 
    
    1. 预处理图像并缩放 RoIs (rois):
     input = preprocess_image(img/255.)[None]
        rois = [[x/224,y/224,X/224,Y/224] for x,y,X,Y in candidates] 
    
    1. 由于所有建议都属于同一图像,rixs 将是一个零列表(与建议数量相同):
     rixs = np.array([0]*len(rois)) 
    
    1. 将输入、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) 
    
    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 
    
    1. 使用 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) 
    
    1. 绘制获得的边界框:
     _, 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() 
    
  2. 对测试图像进行预测:

    test_predictions(test_ds[29][-1]) 
    

上述代码导致如下结果:

图表,折线图 自动生成描述

图 7.17: 原始图像及其预测的边界框和类别

上述代码在 1.5 秒内执行。这主要是因为我们仍在使用两个不同的模型,一个用于生成区域建议,另一个用于进行类别预测和修正。在下一章中,我们将学习如何使用单个模型进行预测,以便在实时场景中进行快速推断。

摘要

在本章中,我们首先学习了为目标定位和检测过程创建训练数据集。然后,我们了解了 SelectiveSearch,一种基于相邻像素相似性推荐区域的区域建议技术。我们还学习了计算 IoU 指标以理解围绕图像中对象的预测边界框的好坏程度。

此外,我们研究了执行非极大值抑制以在图像中获取每个对象的一个边界框,然后学习了如何从头构建 R-CNN 和 Fast R-CNN 模型。我们还探讨了为什么 R-CNN 速度慢,以及 Fast R-CNN 如何利用 RoI 池化从特征图获取区域建议以加快推理速度。最后,我们了解到,来自单独模型的区域建议导致在新图像上预测需要更多时间。

在下一章中,我们将学习一些现代目标检测技术,这些技术用于更实时地进行推断。

问题

  1. 区域建议技术如何生成建议?

  2. 如果图像中存在多个对象,如何计算 IoU?

  3. 为什么 Fast R-CNN 比 R-CNN 更快?

  4. RoI 池化是如何工作的?

  5. 在预测边界框修正时,如果没有获取特征图后的多个层,会有什么影响?

  6. 为什么在计算总体损失时必须给回归损失分配更高的权重?

  7. 非极大值抑制是如何工作的?

加入 Discord 以获取更多信息

加入我们的 Discord 社区空间,与作者和其他读者进行讨论:

packt.link/modcv

第八章:高级目标检测

在前一章节中,我们学习了 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 均值聚类来获得的。

现在我们了解了如何获取锚框的高度和宽度,我们将学习如何在流程中利用它们:

  1. 将每个锚框从图像的左上角滑动到右下角。

  2. 具有与对象高度重叠联合(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.10.5)的候选者。

一旦我们训练一个模型来预测区域候选是否包含对象,我们接着执行非极大值抑制,因为多个重叠的区域可能包含对象。

总结一下,RPN 通过以下步骤训练模型,使其能够识别高概率包含对象的区域提议:

  1. 滑动不同长宽比和尺寸的锚框穿过图像,获取图像的裁剪图像。

  2. 计算图像中对象的真实边界框与前一步中获得的裁剪图像之间的 IoU。

  3. 准备训练数据集,使得 IoU 大于阈值的裁剪区域包含对象,而 IoU 小于阈值的裁剪区域不包含对象。

  4. 训练模型以识别包含对象的区域。

  5. 执行非最大抑制以识别概率最高的包含对象的区域候选项,并消除与其高度重叠的其他区域候选项。

现在我们通过一个 RoI 池化层将区域候选项传递,以获得形状的区域。

分类和回归

到目前为止,我们已经学习了以下步骤,以便识别对象并执行边界框的偏移量:

  1. 识别包含对象的区域。

  2. 使用 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

  1. 下载数据集:

    !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 
    
  2. 读取包含图像及其边界框和类信息的元数据的 DataFrame:

    from torch_snippets import *
    from PIL import Image
    IMAGE_ROOT = 'images/images'
    DF_RAW = df = pd.read_csv('df.csv') 
    
  3. 定义与标签和目标对应的索引:

    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) 
    
  4. 定义用于预处理图像的函数 – preprocess_image

    def preprocess_image(img):
        img = torch.tensor(img).permute(2,0,1)
        return img.to(device).float() 
    
  5. 定义数据集类 – OpenDataset

    1. 定义一个 __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() 
    
    1. 定义 __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 实现期望目标包含边界框的绝对坐标和标签信息。

  1. 定义 collate_fn 方法(默认情况下,collate_fn 仅适用于张量作为输入,但在这里,我们处理的是字典列表)和 __len__ 方法:

  2.  def collate_fn(self, batch):
            return tuple(zip(*batch))
        def __len__(self):
            return len(self.image_infos) 
    
  3. 创建训练和验证数据加载器及数据集:

    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) 
    
  4. 定义模型:

    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 架构

  1. 定义在数据批次上训练并计算验证数据上的损失值的函数:

    # 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 
    
  2. 在增加的时期训练模型:

    1. 定义模型:
    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) 
    
    1. 训练模型并计算训练和测试数据集上的损失值:
    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) 
    
  3. 绘制各种损失值随时间增加的变化:

    log.plot_epochs(['trn_loss','val_loss']) 
    

这导致以下输出:

图 8.9:随着时期增加,训练和验证损失值

  1. 在新图像上进行预测:

    1. 训练模型的输出包含与类别对应的盒子、标签和分数。在下面的代码中,我们定义了一个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() 
    
    1. 获取测试图像上的盒子和类别的预测结果:
    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 准备数据。

首先,我们为给定的图像创建一个地面真实值来训练模型:

  1. 让我们考虑一张具有红色边界框的给定地面真实值图像:

图 8.11:具有地面真实边界框的输入图像

  1. 将图像划分为N x N的网格单元格 – 暂时假定N=3:

图 8.12:将输入图像分成一个 3 x 3 的网格

  1. 识别包含至少一个地面真实边界框中心的网格单元格。在我们的 3 x 3 网格图像中,它们是单元格b1b3

  2. 包围框中心点落在的单元格(或单元格)负责预测对象的边界框。让我们为每个单元格创建相应的真实值。

  3. 每个单元格的输出真实值如下所示:

图 8.13:真实值表示

在这里,pc(对象存在分数)是网格单元格包含对象的概率。

  1. 让我们了解如何计算bxbybwbh。首先,我们将网格单元格(假设我们考虑b1网格单元格)作为我们的宇宙,并将其归一化到 0 到 1 的比例,如下所示:

    图 8.14:计算每个真实值的 bx、by、bw 和 bh 的步骤 1

bxby是边界框中心相对于图像(网格单元格)的位置,如前所述定义。在我们的案例中,bx = 0.5,因为边界框的中心点距离原点 0.5 个单位。同样地,by = 0.5:

图 8.15:计算 bx 和 by

到目前为止,我们已经计算了从图像中对象的网格单元格中心到真实中心的偏移量。现在让我们了解如何计算bwbh

  • bw是边界框相对于网格单元格宽度的比率。

  • bh是边界框相对于网格单元格高度的比率。

  1. 接下来,我们将预测与网格单元格对应的类别。如果我们有三个类别(c1truckc2carc3bus),我们将预测网格单元格包含任何类别的对象的概率。请注意,这里我们不需要背景类别,因为pc对应于网格单元格是否包含对象。

  2. 现在我们了解了如何表示每个单元格的输出层之后,让我们了解如何构建我们的 3 x 3 网格单元的输出:

    1. 让我们考虑网格单元格a3的输出:

    图 8.16:计算与单元格 a3 对应的真实值

    单元格a3的输出如前所示的截图。由于网格单元格不包含对象,第一个输出(pc – 对象存在分数)为0,其余值由于单元格不包含任何对象的中心而无关紧要。

    1. 让我们考虑与网格单元格b1对应的输出:

    图 8.17:与单元格 b1 对应的真实值

    之前的输出是因为网格单元格中包含有对象的bxbybwbh值,这些值的获取方式与之前所述相同,最终类别为car,导致 c2 为1,而 c1 和 c3 为0

请注意,对于每个单元格,我们能够获取 8 个输出。因此,对于 3 x 3 网格单元,我们获取 3 x 3 x 8 个输出。

让我们看看接下来的步骤:

  1. 定义一个模型,其输入是图像,输出为 3 x 3 x 8,并且根据前一步骤定义的真实值:

图 8.18:示例模型架构

  1. 通过考虑锚框来定义真实值。

到目前为止,我们一直在为预期只有一个物体存在于网格单元格内的情景进行构建。然而,在现实中,可能存在一个网格单元格内有多个物体的情况。这会导致创建不正确的真实值。让我们通过以下示例图像来理解这一现象:

图 8.19:同一个网格单元格中可能存在多个物体的情景

在上述示例中,汽车和人的真实边界框的中点都落在同一个单元格中 —— 单元格 b1

避免这种情况的一种方法是使用具有更多行和列的网格,例如一个 19 x 19 的网格。然而,仍然可能存在增加网格单元格数量并不起作用的情况。在这种情况下,锚框就显得特别有用。假设我们有两个锚框 —— 一个高度大于宽度(对应于人),另一个宽度大于高度(对应于汽车):

图 8.20:利用锚框

通常,锚框会以网格单元格中心作为它们的中心。在存在两个锚框的情况下,每个单元格的输出表示为两个锚框期望输出的串联:

图 8.21:当存在两个锚框时的真实值表示

这里,bxbybwbh 表示与锚框的偏移(在这种场景中,锚框是宇宙,如图像所示,而不是网格单元格)。

从前面的截图中,我们看到输出为 3 x 3 x 16,因为有两个锚框。期望输出的形状为 N x N x num_classes x num_anchor_boxes,其中 N x N 是网格中单元格的数量,num_classes 是数据集中的类别数,num_anchor_boxes 是锚框的数量。

  1. 现在我们定义损失函数来训练模型。

当计算与模型相关的损失时,我们需要确保在物体性分数低于某个阈值时不计算回归损失和分类损失(这对应于不包含物体的单元格)。

接下来,如果单元格包含一个物体,我们需要确保跨不同类别的分类尽可能准确。

最后,如果单元格包含对象,则边界框偏移应尽可能接近预期。然而,由于宽度和高度的偏移可以比中心的偏移要大得多(因为中心的偏移范围在 0 到 1 之间,而宽度和高度的偏移则不需要),因此我们通过获取平方根值来给予宽度和高度偏移更低的权重。

计算定位和分类损失如下:

在此,我们观察到以下内容:

  • 是与回归损失相关联的权重。

  • 表示单元格是否包含对象

  • 对应于预测类别概率

  • 表示物体性得分

总损失是分类损失和回归损失值的总和。

现在,我们已经能够训练一个模型来预测物体周围的边界框。然而,为了更深入地了解 YOLO 及其变体,我们建议您阅读原始论文,网址为 arxiv.org/pdf/1506.02640

现在我们了解了 YOLO 如何在单次预测中预测物体的边界框和类别之后,我们将在下一节中编写代码。

在自定义数据集上训练 YOLO

在深度学习中,建立在他人工作的基础上是成为成功从业者的重要途径。对于这一实现,我们将使用官方 YOLOv4 实现来识别图像中公共汽车和卡车的位置。我们将克隆 YOLO 作者自己的存储库实现,并根据需要进行定制,如下所示。

要训练最新的 YOLO 模型,我们强烈建议您查阅以下存储库 – github.com/ultralytics/ultralyticsgithub.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

  1. 拉取 Git 存储库:

    !git clone https://github.com/AlexeyAB/darknet
    %cd darknet 
    
  2. 重新配置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 使用以下标志进行安装:OPENCVGPUCUDNNCUDNN_HALF。这些都是重要的优化措施,可加快训练速度。此外,在前面的代码中,有一个称为 sed 的奇特函数,它代表 流编辑器。它是一个强大的 Linux 命令,可以直接从命令提示符中修改文本文件中的信息。具体而言,在这里我们使用它的搜索和替换功能,将 OPENCV=0 替换为 OPENCV=1,以此类推。要理解的语法是 sed 's/<search-string>/<replace-with>/'path/to/text/file

  1. 编译 darknet 源代码:

    !make 
    
  2. 安装 torch_snippets 包:

    !pip install -q torch_snippets 
    
  3. 下载并解压数据集,并删除 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 
    
  4. 获取预训练权重以进行样本预测:

    !wget --quiet\ https://github.com/AlexeyAB/darknet/releases/download/darknet_yolo_v3_optimal/yolov4.weights 
    
  5. 通过运行以下命令测试安装是否成功:

    !./darknet detector test cfg/coco.data cfg/yolov4.cfg\ yolov4.weights
     data/person.jpg 
    

这将使用从 cfg/yolov4.cfg 构建的网络和预训练权重 yolov4.weightsdata/person.jpg 进行预测。此外,它还从 cfg/coco.data 获取类别,这是预训练权重训练的内容。

前述代码将对样本图像 (data/person.jpg) 进行预测,如下所示:

图 8.22:对样本图像的预测

现在我们已经了解了如何安装 darknet,在下一节中,我们将学习如何为自定义数据集创建真实数据,以利用 darknet

设置数据集格式

YOLO 使用固定格式进行训练。一旦我们按要求格式存储图像和标签,就可以用单个命令对数据集进行训练。因此,让我们了解一下 YOLO 训练所需的文件和文件夹结构。

有三个重要的步骤:

  1. 创建一个文本文件 data/obj.names,其中包含一行一个类别名称,通过运行以下命令行来实现(%%writefile 是一个魔术命令,用于在笔记本单元格中创建一个包含内容的文本文件 data/obj.names):

    %%writefile data/obj.names
    bus
    truck 
    
  2. 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 中的一个单独单元格。

  1. 将所有图像和真实文本文件移动到 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.jpg1001.txt,意味着文本文件包含该图像的标签和边界框。如果data/train.txt包含1001.jpg作为其行之一,则它是一个训练图像。如果它存在于val.txt中,则是一个验证图像。

文本文件本身应包含如下信息:clsxcycwh,其中cls是边界框中物体的类索引,位于(xcyc)处,表示矩形的中心点,宽度为w,高度为h。每个xcycwh都是图像宽度和高度的一部分。将每个对象存储在单独的行上。

例如,如果宽度(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 如何帮助解决检测不同尺度对象的问题的工作原理如下:

  1. 我们利用预训练的 VGG 网络,并在其上添加一些额外的层,直到获得一个 1 x 1 的块。

  2. 不仅仅利用最终层进行边界框和类别预测,我们将利用所有最后几层来进行类别和边界框的预测。

  3. 我们将使用具有特定比例和长宽比的默认框代替锚框。

  4. 每个默认框都应该预测对象和边界框偏移量,就像锚框在 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_338 x 38 x 4 = 5,776
FC619 x 19 x 6 = 2,166
conv8_210 x 10 x 6 = 600
conv9_25 x 5 x 6 = 150
conv10_23 x 3 x 4 = 36
conv11_21 x 1 x 4 = 4
总检测数8,732

表 8.1:每类的检测数

请注意,在原始论文描述的架构中,某些层次的默认盒子数量较多(为 6 而不是 4)。

现在,让我们了解默认盒子的不同尺度和长宽比。我们将从尺度开始,然后继续到长宽比。

让我们想象一种情况,其中对象的最小比例为图像高度和宽度的 20%,对象的最大比例为图像高度和宽度的 90%。在这种情况下,我们随着层次的增加逐渐增加尺度(随着向后层次,图像尺寸显著缩小),如下所示:

图 8.25:随着不同层次对象大小比例变化的盒子尺度

使图像逐步缩放的公式如下:

了解了如何在不同层次计算尺度之后,让我们学习如何生成不同长宽比的盒子。可能的长宽比如下所示:

不同层次盒子的中心如下:

这里,ij 一起表示层 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文件中导入SSD300MultiBoxLoss类。让我们分别了解它们。

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 上执行笔记本以重现结果,同时按照文本中各种代码组件的步骤和解释进行操作。

  1. 下载图像数据集并克隆托管代码模型和其他处理数据工具的 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 
    
  2. 预处理数据,就像我们在在自定义数据集上训练 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' 
    
  3. 准备数据集类,就像我们在在自定义数据集上训练 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) 
    
  4. 准备训练和测试数据集以及数据加载器:

    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) 
    
  5. 定义函数以在批量数据上进行训练,并计算验证数据的准确性和损失值:

    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 
    
  6. 导入模型:

    from model import SSD300, MultiBoxLoss
    from detect import * 
    
  7. 初始化模型、优化器和损失函数:

    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 
    
  8. 在增加的 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 增加的训练和验证损失

  1. 获取新图像的预测(获取随机图像):

    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') 
    
  2. 获取与图像中存在的对象对应的边界框、标签和分数:

    bbs, labels, scores = detect(original_image, model,
                                 min_score=0.9, max_overlap=0.5,
                                 top_k=200, device=device) 
    
  3. 将获取的输出覆盖在图像上:

    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 框架,它不仅有助于检测对象,还能在单次操作中对它们进行分割。

问题

  1. 为什么 Faster R-CNN 相对于 Fast R-CNN 更快?

  2. 在与 Faster R-CNN 相比时,YOLO 和 SSD 为何更快?

  3. YOLO 和 SSD 单次检测器算法有何特点?

  4. 目标性分数和类别分数之间有何区别?

在 Discord 上了解更多信息

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/modcv