Pytorch—万字入门SSD物体检测

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

前言

  由于初入物体检测领域,我在学习SSD模型的时候遇到了很多的困难。一部分困难在于相关概念不清楚,专业词汇不知其意,相关文章不知所云;另一部分困难在于网上大部分文章要么只是简要介绍了SSD的总体原理,要么就是直接实战。SSD作者的论文也已看过,但是看的还是蒙圈蒙圈的。
  我决定通过代码来了解其原理,首先参考了https://blog.csdn.net/weixin_44791964/article/details/104981486这篇文章的代码,但是并没有看懂(I’m too vegetable),然后去GitHub上去搜了一下,一页的搜索结果恰好点到了这个项目,https://github.com/sgrvinod/a-PyTorch-Tutorial-to-Object-Detection,于是,困扰我的一些问题终于看到了通路,这个作者对SSD关键的介绍非常清晰,代码也是一目了然。我决定在其中添加自己的理解,并对代码进行一些修改加解读,一方面帮助我更清楚的理解物体检测,一方面帮助像我一样的人入门物体检测。

概念

  在实现模型之前,我们需要对一些常见概念词进行了解,后面大多数直接使用原英文单词说明,当然这里只是粗略的解释,是为了让你明白一些专业的术语,即使不理解其含义也并无大碍,在文章中途会结合代码进行更详细的解释。

  • Object Detection(物体检测):懂得都懂
  • Single-Shot Detection(单步检测) : 早期的物体检测模型结构由两部分组成——一个region proposal network(区域建议网络)用来定位物体的位置,一个classifier(分类器)检测建议区域中物体的类别。这种分两步的模型很消耗算力,因此它们不适合用来做实时检测。单步检测模型将定位与分类封装在一次前向计算中,大大提高了检测速度,同时使得模型可以部署在轻量级硬件中。
  • Multiscale Feature Maps(多尺度特征图):在图像分类任务中,我们根据最后一个卷积特征图(原始图像的最小也是最深的表达)进行预测。在物体检测任务中,中间的卷积特征图也非常有用,因为它们代表了原图像不同的缩放比例。因此,由于这些特征图自带缩放,一个固定大小的 filter(滤波器,也就是卷积中的卷积核)在不同的特征图上进行运算,就能够检测到各种大小的物体。
  • Priors(先验,以后只用英文单词):这些 priors boxes(先验框)是在 特定尺度的特征图 上 特定位置 预先生成的 boxes(框),它们有特定的 aspect ratios(长宽比) 和 scales(比例)。它们是精心选择的,用来匹配数据集中物体边界框(也叫 ground truths,这个词经常使用,记住它指的就是数据集中物体的真实标记框)的特征。
  • Multibox(实在不好翻译,在后面会有更加清楚的解释):这是一项将预测物体边界框的任务转变为一个回归任务的技术,被检测物体的坐标将被回归到对应它的真实框的坐标。同时,对每一个预测框,会生成每一个类别的分数(框内的物体就属于分数最高的类别,这与图像分类任务一样)。priors(先验框) 可以作为可行的起点, 因为它们是以 ground truths(真实框) 为依据的。因此将会有和priors一样多的预测框,但是大多数预测框不包含物体。
  • Hard Negative Mining(硬负采样):它明确指明了模型选择预测最错误的假正例(即背景类),强迫模型通过这些样例进行学习。换句话说,我们只对那些最难正确识别的错误因素进行采样。在物体检测任务中,我们生成的先验框非常多(SSD中为8732个),不难想象这些 priors 绝大部分是不包含任何物体的,因此,对于分类任务,这导致了严重的样本不均衡,会使预测结果变得非常差,硬负采样只让模型学习最难被识别的背景类,这极大减少了负样本的数量,有利于平衡正负样本。
  • Non-Maximum Suppression(NMS,非极大抑制):我们生成的 priors 最终预测时将会有一部分重叠程度非常大的预测框,这些重叠程度大的预测框可能预测的是同一个物体, NMS(非极大抑制)是一种消除冗余预测的方法,它将删除除预测最高分之外其他所有重叠程度大的预测框。

综述

  在本部分,我将介绍SSD模型的概述。在我们继续进行下去的过程中,你应该会注意到相当多的工程设计导致了SSD非常特殊的结构和方法。如果你觉得有些步骤和方法显得十分不自然(具有明显的人为倾向,没有理论支持,或者难以理解为何这样做),不用担心,请记住,它是建立在这个领域多年的研究(通常是经验性的)上的。

Bounding box(边界框)

   bounding box(边界框)就是包裹着一个物体的盒子,代表着这个物体的界限。在本教程中我们只会遇到两种类型——普通框(不含物体的框)和边界框。但是所有的框都是在图像上表示的,我们需要能够测量它们的位置、形状、大小和其他属性。

Boundary coordinates(边界坐标)

   表示一个框的最明显的方法就是使用构成其边界的线的像素坐标。一个框的边界坐标很简单的表示为 ( x m i n , y m i n , x m a x , y m a x ) (x_{min}, y_{min}, x_{max}, y_{max}) (xmin,ymin,xmax,ymax) ,也就是框的左上角和右下角坐标 ,如下图所示。
在这里插入图片描述
   但是如果我们不知道图像的实际大小,这种坐标将毫无意义。更好的方法是将所有坐标表示为比例的形式,即将 x x x 坐标值除以图像的宽度,将 y y y 坐标值除以图像的高度。

在这里插入图片描述
   这样,如上图所示,坐标值将被缩放到 [ 0 , 1 ] [0,1] [0,1] 之间,即使我们不知道图片真实的大小,也可以知道框的位置。现在坐标是大小不变的,所有图片上的所有框都以相同比例测量。

Center-Size coordinates(中心坐标)

   这是表示框的位置和尺寸的一种更明确的方法。

在这里插入图片描述

   一个框的中心坐标形式为 ( c x , c y , w , h ) (c_x, c_y, w, h) (cx,cy,w,h),其中各个字母的含义在上图中可以非常明显的看出,这里就不再进行赘述,需要注意的是这里的所有坐标都进行了上述的缩放。
  在这个代码中,你会发现我们通常使用两种坐标系统,这取决于它们对任务的适用性,并且总是以比例分数形式出现的(即进行了缩放)。

两种坐标系统的转换

  • 边界坐标转中心坐标
      从上面的介绍中,我们可以非常清楚的得出:
    c x = x m i n + x m a x 2 , c y = y m i n + y m a x 2 c_x = \frac{x_{min} + x_{max}}{2} ,\quad c_y = \frac{y_{min} + y_{max}}{2} cx=2xmin+xmax,cy=2ymin+ymax
    w = x m a x − x m i n , h = y m a x − y m i n w = x_{max} - x_{min} , \quad h = y_{max} - y_{min} w=xmaxxmin,h=ymaxymin
def xy_to_cxcy(xy):
    """
    边界坐标转中心坐标
    :param xy: 一个shape为 [n,4]的tensor,表示n个边界坐标
    :return: 一个shape为 [n,4]的tensor,表示转换后的n个中心坐标
    """
    return torch.cat([
        (xy[:, 2:] + xy[:, :2]) / 2,  # cx, cy
        xy[:, 2:] - xy[:, :2]  # w, h
    ], dim=1)
  • 中心坐标转边界坐标
      同样,可以非常明显地看出如下关系:
    x m i n = c x − w 2 , y m i n = c y − h 2 x_{min} = c_x - \frac{w}{2}, \quad y_{min} = c_y - \frac{h}{2} xmin=cx2w,ymin=cy2h
    x m a x = c x + w 2 , y m a x = c y + h 2 x_{max} = c_x + \frac{w}{2} , \quad y_{max} = c_y + \frac{h}{2} xmax=cx+2w,ymax=cy+2h
def cxcy_to_xy(cxcy):
    """
    中心坐标转边界坐标
    :param cxcy: 一个shape为 [n,4]的tensor,表示n个中心坐标
    :return: 一个shape为 [n,4]的tensor,表示转换后的n个边界坐标
    """
    return torch.cat([
        cxcy[:, :2] - (cxcy[:, 2:] / 2),  # x_min, y_min
        cxcy[:, :2] + (cxcy[:, 2:] / 2)  # x_max, y_max
    ], dim=1)

Jaccard Index(IoU,重叠程度)

  Jaccard Index(Jaccard 系数) 或称作 Jaccard Overlap(Jaccard重叠) 或称作 Intersection-over-Union(IoU,交并比),测量了两个框的重叠程度,这个指标可以反映预测框与真实框的接近程度,在计算loss和进行NMS(非极大抑制)时会用到。如下图所示。

在这里插入图片描述
  显然,Jaccard系数的取值在 [ 0 , 1 ] [0, 1] [0,1] 之间,取值为0时,说明两个框的交集为0,也就是两个框没有重叠;取值为1时,说明两个框的交集与并集相同,也就是两个框完全重叠。
  Jaccard系数的代码实现可能不能很好的理解,我们需要求两个量,一个是交集面积,一个是并集面积,而且 并 集 面 积 = 框 A 的 面 积 + 框 B 的 面 积 − 交 集 面 积 并集面积=框A的面积+框B的面积-交集面积 =A+B ,框A和框B的面积都非常容易计算,因此我们将目光放在交集面积的计算上。考虑一些常见的交集方式,如下图,绿色点表示交集区域左上角,蓝色点表示交集区域右下角。
在这里插入图片描述
  从上图中,我们可以看出,交集的区域都可以用两个点表示,这正是交集区域的边界坐标,整个交集区域的边界坐标可以表示为: ( I x min ⁡ , I y min ⁡ , I x max ⁡ , I y max ⁡ ) (I_{x_{\min}}, I_{y_{\min}}, I_{x_{\max}}, I_{y_{\max}}) (Ixmin,Iymin,Ixmax,Iymax)
其 中 : I x min ⁡ = max ⁡ { x min ⁡ , x min ⁡ ′ } , I y min ⁡ = max ⁡ { y min ⁡ , y min ⁡ ′ } , I x max ⁡ = min ⁡ { x max ⁡ , x max ⁡ ′ } , I y max ⁡ = min ⁡ { y max ⁡ , y max ⁡ ′ } 其中: I_{x_{\min}} =\max\{x_{\min}, x^{\prime}_{\min}\}, \quad I_{y_{\min}} =\max\{y_{\min}, y^{\prime}_{\min}\}, \\ I_{x_{\max}} =\min\{x_{\max}, x^{\prime}_{\max}\}, \quad I_{y_{\max}} =\min\{y_{\max}, y^{\prime}_{\max}\} Ixmin=max{xmin,xmin},Iymin=max{ymin,ymin},Ixmax=min{xmax,xmax},Iymax=min{ymax,ymax}
因此可以得到交集面积为 S = ( I y max ⁡ − I y min ⁡ ) ( I x max ⁡ − I x min ⁡ ) S = (I_{y_{\max}} - I_{y_{\min}})(I_{x_{\max}} - I_{x_{\min}}) S=(IymaxIymin)(IxmaxIxmin)
  然而当两个框不重合时,这个式子会计算出什么?考虑不重合情形,如下图所示,绿色点表示上述式子计算的“左上角”,蓝色点表示上述式子计算的“右下角”

在这里插入图片描述

  此时,我们考虑上述面积式子: S = ( I y max ⁡ − I y min ⁡ ) ( I x max ⁡ − I x min ⁡ ) S = (I_{y_{\max}} - I_{y_{\min}})(I_{x_{\max}} - I_{x_{\min}}) S=(IymaxIymin)(IxmaxIxmin),可以发现要么 I y max ⁡ − I y min ⁡ < 0 I_{y_{\max}} - I_{y_{\min}} < 0 IymaxIymin<0 ,要么 I x max ⁡ − I x min ⁡ < 0 I_{x_{\max}} - I_{x_{\min}} < 0 IxmaxIxmin<0 ,要么二者均小于0,因此我们可以这样计算交集的面积:一旦这两个式子小于0,我们就将其设置为0,这样0乘任何数均是0,因此交集面积计算结果为0,否则就用上述式子得到交集面积。
  综上我们可以得到如下求交集的代码:

def find_intersection(set_1, set_2):
    """
    计算第一个集合中每个框与第二个集合中每个框的交集面积

    :param set_1: 一个shape为[m,4]的tensor,代表m个边界坐标
    :param set_2: 一个shape为[n,4]的tensor,代表n个边界坐标
    :return: 一个shape为[m,n]的tensor,例如:[0,:]表示set_1中第1个框与set_2中每个框的交集面积
    """
    # max函数中的两个tensor的shape分别为[m,1,2], [1,n,2],可以应用广播机制,最后得到的tensor的shape为[m,n,2]
    # 例如:[0, :, 2]表示set_1中第一个框与set_2中所有框交集的左上角坐标
    lower_bounds = torch.max(set_1[:, :2].unsqueeze(1), set_2[:, :2].unsqueeze(0))  # [m, n, 2]
    # 计算右下角的坐标
    upper_bounds = torch.min(set_1[:, 2:].unsqueeze(1), set_2[:, 2:].unsqueeze(0))  # [m, n, 2]
    # 将两个减式小于0的设置为0
    intersection_dims = torch.clamp(upper_bounds - lower_bounds, min=0)  # [m, n, 2]
    # 相乘得到交集面积
    return intersection_dims[:, :, 0] * intersection_dims[:, :, 1]  # [m, n]

  利用 并 集 面 积 = 框 A 的 面 积 + 框 B 的 面 积 − 交 集 面 积 并集面积=框A的面积+框B的面积-交集面积 =A+B ,进而得到计算 Jaccard系数的代码:

def find_jaccard_overlap(set_1, set_2):
    """
    计算第一个集合中每个框与第二个集合中每个框的Jaccard系数

    :param set_1: 一个shape为[m,4]的tensor,代表m个边界坐标
    :param set_2: 一个shape为[n,4]的tensor,代表n个边界坐标
    :return: 一个shape为[m,n]的tensor,例如:[0,:]表示set_1中第1个框与set_2中每个框的Jaccard系数
    """
    # 每个框与其他框的交集
    intersection = find_intersection(set_1, set_2)  # [m, n]

    # 计算每个集合中每个框的面积
    areas_set_1 = (set_1[:, 2] - set_1[:, 0]) * (set_1[:, 3] - set_1[:, 1])  # [m]
    areas_set_2 = (set_2[:, 2] - set_2[:, 0]) * (set_2[:, 3] - set_2[:, 1])  # [n]

    # 总面积减去交集就是并集
    # unsqueeze的作用同样是为了满足广播机制的条件
    union = areas_set_1.unsqueeze(1) + areas_set_2.unsqueeze(0) - intersection  # [m, n]
    # Jaccard系数 = 交集面积 / 并集面积
    return intersection / union  # [m, n]

Multibox

  Multibox 是一种检测物体的技术,由两部分组成:

  1. 方框的坐标,该方框可能包含也可能不包含物体,这是个回归任务
  2. 这个框中的物体是每个类别的分数,其中包括一个背景类,这说明框内没有物体,这是一个分类任务。

Single Shot Detector (SSD)

  SSD是一个纯卷积(只有卷积层和池化层)网络,我们可以将它分为三个部分:

  1. Base convolutions(基础卷积):派生自一个现有的图像分类模型,将提供低级别的特征图。
  2. Auxiliary convolutions(辅助卷积):添加在基础卷积之上,将提供较高级别的特征图。
  3. Prediction convolutions(预测卷积):用来在特征图中定位和识别物体。

  论文中提出了该模型的两种变体:SSD300和SSD512,后缀的数字表示输入图片的大小。虽然这两个网络的构造方式略有不同,但它们在原则上是相同的。SSD512只是一个更大的网络,性能更好些。为了简便,我们将介绍SSD300.
在这里插入图片描述

Base convolutions(基础卷积)

  • 首先,我们要知道为什么要使用一个现有网络的卷积?
    1. 因为已经证明:一个在图像分类任务上表现良好的模型已经能够很好的捕获图像的基本特征。同样的卷积特征对物体检测也很有用,尽管是在更局部的意义上——我们对图像整体不太感兴趣,而是对存在物体的特定区域感兴趣。
    2. 还有一个额外的优势:我们可以使用在可靠的分类数据集上预先训练的层,如你所知道的,这叫做 Transfer Learning(迁移学习),通过从不同但密切相关的任务中借取知识,我们甚至在还没开始前就取得了进展。

  作者采用了VGG16结构作为基础卷积,它的原始形式非常简单:
在这里插入图片描述
  作者建议使用在 ImageNet Large Scale Visual Recognition Competition (ILSVRC) 分类任务中训练的模型,幸运的是,在Pytorch中已经有了一个可用的结构,其他流行的框架也是如此。如你所想,你可以选择其他结构作为基础卷积,比如ResNet,但是需要注意,这将导致计算量和内存开销变大。
  我们需要对这个预训练的网络进行调整,以适应我们的物体检测任务。一些调整是合乎逻辑和必要的,一些则是出于方便和偏好。

  • 如前文所述,输入的图片大小为 300,300
  • 第三个最大池化层,用来减半图像大小,我们使用 ceil 替换默认的 floor,来决定输出图像的大小。只有当特征图的大小为奇数时才会有用。通过查看上面的图像,可以计算出当输入图像大小为 300, 300 时,conv3_3 的特征图大小为 75,75 ,通过这个改变它将减半为 38, 38 而不是 37, 37
  • 第五个最大池化层,将原来 2,2 的卷积核和 2,2的步长,改为 3,3 的卷积核和 1,1 的步长。这样做使得它不再将前面的特征图减半。
  • 我们不需要全连接层,因为它们在这里毫无用处。我们将完全抛弃 fc8,但是选择将 fc6fc7 重新制作为卷积层 conv6conv7.

  前三个修改很简单,只需要修改一些参数即可,但是最后一个修改可能需要一些解释。

全连接层转为卷积层(第一部分)

  我们如何将一个全连接层重新参数化为一个卷积层?考虑下面的场景:
  在典型的图像分类任务中,第一个全连接层不能直接对前面的特征图或图像进行计算,需要将特征图 flatten(展平)为一个一维向量。
在这里插入图片描述
  在这个例子中,有一个 shape 为 2,2,3 的图片被展平为大小为 12 的一维向量,输出维度为2时,全连接层通过两个大小也为12的一维向量与展平后的图片进行点积运算得到输出结果,这两个向量由灰色表示,它们是全连接层的参数
  现在考虑另一个场景,我们需要用卷积输出两个值.
在这里插入图片描述
  很明显,这里维度为2,2,3的图片不需要展平操作。卷积层使用与图像维度相同的带有12个参数的两组 filters (滤波器),每个卷积核的大小为 2, 2 ,进行点积运算,得到输出结果。这两组 filters 也由灰色展示,它们是卷积层的参数。
  注意,这里有一个关键点——这两种情景输出的 y 0 y_0 y0 y 1 y_1 y1 是一样的!
在这里插入图片描述
  这两种情景是等价的,这告诉了我们什么?
  大小为 H, W 并且输入通道数为 I 的图片,倘若全连接层的参数 N, H×W×I 与卷积层的参数 N, H, W, I 相同,那么一个输出大小为 N 的全连接层,等价于一个与图片大小相同的 H, W 卷积核和 N 个输出通道的卷积层。
在这里插入图片描述
  因此任何全连接层都可以通过 reshape 其参数的形状转变为等价的卷积层。

全连接层转为卷积层(第二部分)

  现在我们已经知道怎么将VGG16结构中的 fc6fc7 层转换为 conv6conv7 层。
  在ImageNet中VGG16的输入图片的维度为 224, 224, 3 ,经过 conv5_3 输出后的特征图大小为 7, 7, 512。因此:

  • fc6 层的输入大小为展平后的 7 * 7 * 512,输出大小为 4096,它的参数维度为 4096, 7 * 7 * 512 。这等价于卷积核大小为 7, 7,输出通道数为 4096 的卷积层 conv6。只需将 fc6 的参数 reshape 为 4096, 7, 7, 512即可。
  • fc7 层的输入大小为 4096fc6的输出),输出大小也是 4096,因此它的参数维度为 4096, 4096。此时的输入可以看作一个大小为 1, 1 ,通道数为 4096 的图片。这等价于卷积核大小为 1, 1,输出通道数为 4096 的卷积层 conv7。只需要将 fc7 的参数 reshape 为 4096, 1, 1, 4096

  我们可以看到 conv64096组 filters,每组 filter 的维度为 7, 7, 512conv74096 组 filters,每组维度为1, 1, 4096
  这些 filters 数量众多,参数量很大,会造成很大的计算开销。为了缓解这个情况,作者通过对转换后卷积层的参数进行采样,进而减少 filters 的数量和大小。

  • conv6 将使用 1024 组 filters,每组的维度为 3, 3, 512 。因此参数从原来的 4096, 7, 7, 512 被下采样为 1024, 3, 3, 512
  • conv7 将使用 1024 组 filters,每组的维度为 1, 1, 1024 。因此参数从原来的 4096, 1, 1, 4096 被下采样为 1024, 1, 1, 1024

  根据论文我们将沿着指定的维度,每隔 m 次取一个参数,达到下采样的目的。
  我们以 fc6 为例,介绍一下如何进行采样的,首先看单个卷积核,是如何从 7, 7 变为 3, 3 的,如下图:

在这里插入图片描述
  从图中我们可以看出,当步长取3时,就能将 7, 7 的卷积核采样为 3, 3 的,这个步长的方式与张量切片步长的原理一模一样!同理我们对第一个维度即 4096 设置采样步长为 4 则可以得到 1024 的结果。因此对 fc6 层,我们将每个维度的采样步长设置为 [4, 3, 3, None] 即可从 4096, 7, 7, 512 采样为 1024, 3, 3, 512None表示不对该维度进行采样。 对 fc7 层,我们可以设置步长 [4, None, None, 4] 即可从 4096, 1, 1, 4096 采样为 1024, 1, 1, 1024。这个步骤我们可以使用 torch.index_select() 函数实现。

在这里插入图片描述
  进行采样的代码实现如下,

def decimate(tensor, interval=None):
    """
    根据间隔点,对tensor进行采样
    :param tensor: 一个有n个维度,需要进行采样的tensor
    :param interval: 一个元素为n的列表,每个位置的元素表示该维度的采样步长
    :return: 对每个维度采样后的n维tensor
    """
    # tensor的维度必须和采样的维度数量一样
    if tensor.dim() != len(interval):
        raise ValueError('tensor and interval must have same dimensions !')
        
    for d in range(tensor.dim()):
        # 如果为None则不对该维度进行采样
        if interval[d] is not None:
            # 根据步长进行采样
            tensor = tensor.index_select(
                dim=d,
                index=torch.arange(start=0, end=tensor.size(d), step=interval[d]).long()
            )
    return tensor

  由于 conv6的卷积核从 7, 7采样为 3, 3,步长为3,因此在卷积核中有空洞,如前面的图中所展示一样(橙色之间有蓝色的空洞),因此我们需要使用空洞卷积。由于我们采样的步长为3,因此卷积的参数 dilation 也应设置为3,但是实际上论文作者设置为6,这可能是因为第五个最大池化层也进行了改变,没有将特征图变为一半。
  现在可以展示我们修改后的基础卷积了。
在这里插入图片描述
  在上图中要特别注意 conv4_3conv7 的输出,你很快就能知道为什么。

基础卷积的代码
class VGGBase(nn.Module):
    def __init__(self):
        super(VGGBase, self).__init__()
        # 标准VGG16卷积层
        self.conv1_1 = nn.Conv2d(3, 64, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv1_2 = nn.Conv2d(64, 64, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.pool1 = nn.MaxPool2d(kernel_size=2, stride=2)

        self.conv2_1 = nn.Conv2d(64, 128, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv2_2 = nn.Conv2d(128, 128, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.pool2 = nn.MaxPool2d(kernel_size=2, stride=2)

        self.conv3_1 = nn.Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv3_2 = nn.Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv3_3 = nn.Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=1)
        # 第三个最大池化层需要设置 ceil 模式
        self.pool3 = nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)

        self.conv4_1 = nn.Conv2d(256, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv4_2 = nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv4_3 = nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.pool4 = nn.MaxPool2d(kernel_size=2, stride=2)

        self.conv5_1 = nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv5_2 = nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        self.conv5_3 = nn.Conv2d(512, 512, kernel_size=(3, 3), stride=(1, 1), padding=1)
        # 第五个最大池化层的参数需要进行修改
        self.pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)

        # 原VGG16的fc6和fc7层,需要从全连接层转换为卷积层,同时删除部分权重
        self.conv6 = nn.Conv2d(512, 1024, kernel_size=(3, 3), padding=6, dilation=(6, 6))
        self.conv7 = nn.Conv2d(1024, 1024, kernel_size=(1, 1), stride=(1, 1))
        # 为每个卷积层设置训练好的权重
        self.load_weights()

    def load_weights(self):
        """
        为卷积层加载训练好的参数,同时对全连接层进行转化和采样
        """
        state_dict = self.state_dict()
        param_names = list(state_dict.keys())
        # 获取预训练权重
        pretrained_state_dict = vgg16(pretrained=True).state_dict()
        pretrained_param_names = list(pretrained_state_dict.keys())
        # 除了fc6和fc7层之外,其他的层直接加载权重
        for i, param in enumerate(param_names[:-4]):
            state_dict[param] = pretrained_state_dict[pretrained_param_names[i]]

        def decimate(tensor, interval=None):
            """
            根据间隔点,对tensor进行采样
            :param tensor: 一个有n个维度,需要进行采样的tensor
            :param interval: 一个元素为n的列表,每个位置的元素表示该维度的采样步长
            :return: 对每个维度采样后的n维tensor
            """
            # tensor的维度必须和采样的维度数量一样
            if tensor.dim() != len(interval):
                raise ValueError('tensor and interval must have same dimensions !')
            for d in range(tensor.dim()):
                # 如果为None则不对该维度进行采样
                if interval[d] is not None:
                    # 根据步长进行采样
                    tensor = tensor.index_select(
                        dim=d,
                        index=torch.arange(start=0, end=tensor.size(d), step=interval[d]).long()
                    )
            return tensor

        # 将fc6的权重reshape为 4096,7,7,512。但是pytorch的通道数在前面,因此reshape为 4096,512,7,7
        fc6_weight = pretrained_state_dict['classifier.0.weight'].view(4096, 512, 7, 7)
        # fc6层偏置的权重
        fc6_bias = pretrained_state_dict['classifier.0.bias']
        # 对权重进行采样,[4096, 512, 7, 7] -> [1024, 512, 3, 3]
        state_dict['conv6.weight'] = decimate(fc6_weight, [4, None, 3, 3])
        # 对偏置进行同样的采样,[4096] -> [1024]
        state_dict['conv6.bias'] = decimate(fc6_bias, [4])

        # fc7层与fc6层操作一样
        fc7_weight = pretrained_state_dict['classifier.3.weight'].view(4096, 4096, 1, 1)
        fc7_bias = pretrained_state_dict['classifier.3.bias']
        # [4096, 4096, 1, 1] -> [1024, 1024, 1, 1]
        state_dict['conv7.weight'] = decimate(fc7_weight, [4, 4, None, None])
        # [4096] -> [1024]
        state_dict['conv7.bias'] = decimate(fc7_bias, [4])

        del decimate
        # 加载权重
        self.load_state_dict(state_dict)

    def forward(self, x):
        x = F.relu(self.conv1_1(x))  # [b, 3, 300, 300] -> [b, 64, 300, 300]
        x = F.relu(self.conv1_2(x))  # [b, 64, 300, 300] -> [b, 64, 300, 300]
        x = self.pool1(x)  # [b, 64, 300, 300] -> [b, 64, 150, 150]

        x = F.relu(self.conv2_1(x))  # [b, 64, 150, 150] -> [b, 128, 150, 150]
        x = F.relu(self.conv2_2(x))  # [b, 128, 150, 150] -> [b, 128, 150, 150]
        x = self.pool2(x)  # [b, 128, 150, 150] -> [b, 128, 75, 75]

        x = F.relu(self.conv3_1(x))  # [b, 128, 75, 75] -> [b, 256, 75, 75]
        x = F.relu(self.conv3_2(x))  # [b, 256, 75, 75] -> [b, 256, 75, 75]
        x = F.relu(self.conv3_3(x))  # [b, 256, 75, 75] -> [b, 256, 75, 75]
        x = self.pool3(x)  # [b, 256, 75, 75] -> [b, 256, 38, 38]

        x = F.relu(self.conv4_1(x))  # [b, 256, 38, 38] -> [b, 512, 38, 38]
        x = F.relu(self.conv4_2(x))  # [b, 512, 38, 38] -> [b, 512, 38, 38]
        conv4_3_feats = F.relu(self.conv4_3(x))  # [b, 512, 38, 38] -> [b, 512, 38, 38]
        x = self.pool4(conv4_3_feats)  # [b, 512, 38, 38] -> [b, 512, 19, 19]

        x = F.relu(self.conv5_1(x))  # [b, 512, 19, 19] -> [b, 512, 19, 19]
        x = F.relu(self.conv5_2(x))  # [b, 512, 19, 19] -> [b, 512, 19, 19]
        x = F.relu(self.conv5_3(x))  # [b, 512, 19, 19] -> [b, 512, 19, 19]
        x = self.pool5(x)  # [b, 512, 19, 19] -> [b, 512, 19, 19]

        x = F.relu(self.conv6(x))  # [b, 512, 19, 19] -> [b, 1024, 19, 19]
        conv7_feats = F.relu(self.conv7(x))  # [b, 1024, 19, 19] -> [b, 1024, 19, 19]
		# 注意这里的输出有两个
        return conv4_3_feats, conv7_feats

Auxiliary convolutions(辅助卷积)

  现在我们将在基础卷积上堆叠更多的卷积层。这些卷积提供了额外的特征图,且一个比一个小。

在这里插入图片描述
  我们引入了四个卷积块,每个块有两个卷积层。虽然在基础卷积中特征图大小的改变是通过池化层实现的,但是这里是通过每个块中第二个卷积实现的,其步长为2。
  另外,注意 conv8_2conv9_2conv10_2conv11_2的输出。

辅助卷积的代码
class AuxiliaryConvolutions(nn.Module):
    def __init__(self):
        super(AuxiliaryConvolutions, self).__init__()
        
        self.conv8_1 = nn.Conv2d(1024, 256, kernel_size=(1, 1), stride=(1, 1))
        self.conv8_2 = nn.Conv2d(256, 512, kernel_size=(3, 3), stride=(2, 2), padding=1)

        self.conv9_1 = nn.Conv2d(512, 128, kernel_size=(1, 1), stride=(1, 1))
        self.conv9_2 = nn.Conv2d(128, 256, kernel_size=(3, 3), stride=(2, 2), padding=1)

        self.conv10_1 = nn.Conv2d(256, 128, kernel_size=(1, 1), stride=(1, 1))
        self.conv10_2 = nn.Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1))

        self.conv11_1 = nn.Conv2d(256, 128, kernel_size=(1, 1), stride=(1, 1))
        self.conv11_2 = nn.Conv2d(128, 256, kernel_size=(3, 3), stride=(1, 1))
        # 初始化权重
        self.init_weight()

    def init_weight(self):
        for conv in self.children():
            if isinstance(conv, nn.Conv2d):
                nn.init.xavier_normal_(conv.weight)
                nn.init.constant_(conv.bias, 0.)

    def forward(self, x):
        # 这里的参数x是基础卷积的conv7层输出
        x = F.relu(self.conv8_1(x))  # [b, 1024, 19, 19] -> [b, 256, 19, 19]
        conv8_2_feats = F.relu(self.conv8_2(x))  # [b, 256, 19, 19] -> [b, 512, 10, 10]

        x = F.relu(self.conv9_1(conv8_2_feats))  # [b, 512, 10, 10] -> [b, 128, 10, 10]
        conv9_2_feats = F.relu(self.conv9_2(x))  # [b, 128, 10, 10] -> [b, 256, 5, 5]

        x = F.relu(self.conv10_1(conv9_2_feats))  # [b, 256, 5, 5] -> [b, 128, 5, 5]
        conv10_2_feats = F.relu(self.conv10_2(x))  # [b, 128, 5, 5] -> [b, 256, 3, 3]

        x = F.relu(self.conv11_1(conv10_2_feats))  # [b, 256, 3, 3] -> [b, 128, 3, 3]
        conv11_2_feats = F.relu(self.conv11_2(x))  # [b, 128, 3, 3] -> [b, 256, 1, 1]

        return conv8_2_feats, conv9_2_feats, conv10_2_feats, conv11_2_feats

Prediction convolutions(预测卷积)

  在我们开始预测卷积之前,我们必须先要了解我们预测的是什么。当然,预测的是物体和它的位置,但是是什么形式的?
  在这里我们必须了解 priors ,它是 SSD 中的关键部分。

Priors(先验)

  物体的检测可以是多种多样的,我指的不仅仅是它们的类型。它们可以出现在任何位置,并且可能是任意大小和形状。请注意,我们不应该走得太远,以至于说一个物体在哪里和如何发生有无限种可能,虽然这在数学上可能是正确的,但是现实中很多可能根本不会发生,是没有意义的。此外,我们不必坚持 boxes 是像素完美的。
  实际上,我们可以将预测的潜在数学空间离散为成千上万种可能。

  priors 是预先计算的,固定大小的框,所有的 prior 共同代表了这个潜在的被离散化的数学空间且它们与预测框接近。

  priors 是手工的但是是根据数据集中 ground truth 的形状和大小精心选择的。我们还考虑了位置的变化,以便将这些 priors 放在特征图上每个可能位置。

  在定义 priors 时,作者指出:

  • 它们将被应用于各种低级和高级的特征图中。也就是前面图中提到的 conv4_3conv7conv8_2conv9_2conv10_2conv11_2
  • 如果一个 prior 有一个scale s, 那么它的面积就等于一个边长为 s 的正方形的面积。最大的特征图 conv4_3 ,它的 priors 的 scale 是0.1,也就是特征图尺寸的10%,其余的特征图为 0.2到0.9线性增长,即0.2,0.375,0.55,0.725,0.9。如你所见,较大的特征图有着较小 scale 的 priors,它们是用来检测较小物体的。
  • 在特征图的每个位置上,都有各种 aspect ratios(宽高比)的 priors。所有的特征图都有 1:1, 1:2, 2:1比例的 prior,而中间的特征图 conv8_2conv9_2conv10_2 会有 1:3, 3:1的 prior 。此外,所有特征图都有一个额外的比例为 1:1的 prior,它的 scale 是 根号下 当前特征图的 scale 乘后一个特征图的 scale。
特征图特征图的维度Prior ScaleAspect Ratios每个位置上 prior 的个数特征图上 prior 的总个数
conv4_338, 380.11:1, 2:1, 1:2 + an extra prior45776
conv719, 190.21:1, 2:1, 1:2, 3:1, 1:3 + an extra prior62166
conv8_210, 100.3751:1, 2:1, 1:2, 3:1, 1:3 + an extra prior6600
conv9_25, 50.551:1, 2:1, 1:2, 3:1, 1:3 + an extra prior6150
conv10_23, 30.7251:1, 2:1, 1:2 + an extra prior436
conv11_21, 10.91:1, 2:1, 1:2 + an extra prior44
Grand Total8732 priors

  总共为SSD300定义了 8732 个 prior。

可视化 prior

  我们根据它们的 scale 和 aspect ratios 定义 prior。我们将 scale 记为 s,将aspect ratios记为 a。前面我们说过 prior 的面积等于 s 2 s^2 s2,则:
w ⋅ h = s 2 w h = a w · h = s^2 \\ \frac{w}{h} = a wh=s2hw=a
  解出这些方程就可以得出 prior 的维度 wh
w = s ⋅ a h = s a w = s· \sqrt{a} \\ h = \frac{s}{\sqrt{a}} w=sa h=a s
  现在我们可以在它们各自的特征图上画出它们了。
  作为一个例子,我们尝试绘制 conv9_2 特征图中心位置的 priors。

在这里插入图片描述

  对于其他位置也存在一样的 prior

在这里插入图片描述
  现在你已经知道prior大概的样子。那就来通俗的说一下prior如何计算。对于conv9_2特征图来说,它的特征图大小为 5, 5,而conv9_2的scale是 0.55,prior的 aspect ratios是 [1, 2:1, 3:1, 1:2, 1:3],因此通过上面的式子 w = s a , h = s a w=s\sqrt{a}, h=\frac{s}{\sqrt{a}} w=sa ,h=a s,我们可以求出每个prior的 wh ,结果为:
[ 0.55 1 0.55 1 0.55 2 0.55 2 0.55 3 0.55 3 0.55 1 2 0.55 1 2 0.55 1 3 0.55 1 3 0.55 × 0.725 0.55 × 0.725 ] = [ 0.5500 0.5500 0.7778 0.3889 0.9526 0.3175 0.3889 0.7778 0.3175 0.9526 0.6315 0.6315 ] \begin{bmatrix} 0.55\sqrt{1} & \frac{0.55}{\sqrt{1}} \\ 0.55\sqrt{2} & \frac{0.55}{\sqrt{2}} \\ 0.55\sqrt{3} & \frac{0.55}{\sqrt{3}} \\ 0.55\sqrt{\frac{1}{2}} & \frac{0.55}{\sqrt{\frac{1}{2}}} \\ 0.55\sqrt{\frac{1}{3}} & \frac{0.55}{\sqrt{\frac{1}{3}}} \\ \sqrt{0.55×0.725} & \sqrt{0.55×0.725} \end{bmatrix} = \begin{bmatrix} 0.5500 & 0.5500 \\ 0.7778 & 0.3889 \\ 0.9526 & 0.3175 \\ 0.3889 & 0.7778 \\ 0.3175 & 0.9526 \\ 0.6315 & 0.6315 \end{bmatrix} 0.551 0.552 0.553 0.5521 0.5531 0.55×0.725 1 0.552 0.553 0.5521 0.5531 0.550.55×0.725 =0.55000.77780.95260.38890.31750.63150.55000.38890.31750.77780.95260.6315
  这就是每个prior的宽和高,而且是缩放后的,如果再加上中心,那不就是中心坐标了吗?中心是什么?从上面的图中可以明显知道,prior的中心就是特征图中每个格子的中心,例如第一个格子的中心是0.5, 0.5,第二个格子的中心是 1.5, 1.5,将这些中心除以 conv9_2 的大小 5 进行缩放,再与计算的prior的wh 合并起来,就得到了每个格子所对应的 priors 的中心坐标,每个格子有6个prior,一共有5×5个格子,因此conv9_2特征图上共有 6 × 5 × 5 = 150 6×5×5= 150 6×5×5=150 个prior。
  需要注意的是,每个特征图上的格子必须按照从左到右,从上到下的顺序进行计算,也就是卷积移动的方式,这样才能让每个prior和其预测的偏移量对应上。

priors的代码

  生成priors的代码位于创建SSD300结构的类中,这里把它单独提出作为演示,因为其没有用到类中的其他变量,因此可以设置为一个静态成员函数。

@staticmethod
def create_prior_boxes():
    # 特征图的尺寸
    features_dim = {'conv4_3': 38, 'conv7': 19, 'conv8_2': 10,
                    'conv9_2': 5, 'conv10_2': 3, 'conv11_2': 1}
    # prior的scale
    object_scales = {'conv4_3': 0.1, 'conv7': 0.2, 'conv8_2': 0.375,
                     'conv9_2': 0.55, 'conv10_2': 0.725, 'conv11_2': 0.9}
    # prior的aspect ratio
    # conv7,conv8_2和conv9_2会多出 3:1 和 1:3
    aspect_ratios = {
        'conv4_3': [1., 2., 0.5],
        'conv7': [1., 2., 3., 0.5, 0.333],
        'conv8_2': [1., 2., 3., 0.5, 0.333],
        'conv9_2': [1., 2., 3., 0.5, 0.333],
        'conv10_2': [1., 2., 0.5],
        'conv11_2': [1., 2., 0.5]
    }
    # 记录特征图的名称,用来查找当前特征图的下一个特征图
    features_name = list(features_dim.keys())
    # 所有的priors
    prior_boxes = []
    # 每个特征图都会有priors
    for k, feature in enumerate(features_name):
        # 每个特征图的每个位置都有priors
        # 模仿卷积的操作,按照从左到右,从上到下的顺序计算priors,为了与预测卷积相匹配
        for i in range(features_dim[feature]):
            for j in range(features_dim[feature]):
                # 当前特征图的当前格子的中心坐标(需要进行缩放)
                cx = (j + 0.5) / features_dim[feature]
                cy = (i + 0.5) / features_dim[feature]
                # 为当前格子按照aspect ratios生成priors
                for ratio in aspect_ratios[feature]:
                    # w = s * sqrt(a), h = s / sqrt(a)
                    # 计算每个prior的中心坐标
                    prior_boxes.append([cx, cy, object_scales[feature] * sqrt(ratio),
                                        object_scales[feature] / sqrt(ratio)])
                    # 当ratio时,需要额外添加一个prior
                    if ratio == 1.:
                        # 如果当前特征图不是最后一个特征图,即当前特征图不是conv11_2
                        if k != len(features_name) - 1:
                            # 那么这个额外的prior的scale就是 sqrt(当前特征图scale * 下一个特征图scale)
                            additional_scale = sqrt(object_scales[feature] * object_scales[features_name[k + 1]])
                        else:
                            # 如果当前特征图是最后一个,它就不存在下一个特征图,直接将scale设置为1
                            additional_scale = 1.
                        # 添加额外的prior的中心坐标
                        prior_boxes.append([cx, cy, additional_scale, additional_scale])
    # 最后将所有priors转换为一个tensor
    prior_boxes = torch.FloatTensor(prior_boxes).to(device)  # [8732, 4]
    return prior_boxes
预测的方法

  在前面,我们说过将使用回归找到一个物体的边界框,但是,priors 肯定不能代表我们最终预测的边界框吧?肯定不能!
  我想再次重申,priors 只是近似的代表了预测的可能性。
  这意味着,我们使用每个 prior 作为近似的起点,然后找出需要调整多少才能获得一个更精确的边界框。
  因此,每一个预测的边界框都与 prior 有所偏差,我们的目标就是计算这个偏差,我们需要一个方法来测量或量化偏差。
  考虑一只猫,它的预测边界框,以及做出这个预测的 prior:

在这里插入图片描述
  假设它们用我们熟悉的中心坐标表示,然后:

在这里插入图片描述
  这就回答了我们在本节开始时提出的问题。考虑每个 prior 都被调整以获得更精确的预测,这四个偏移量 ( g c x , g c y , g w , g h ) (g_{c_x}, g_{c_y}, g_w, g_h) (gcx,gcy,gw,gh)就是我们要回归到 ground truth 坐标的形式。
  如你所见,每个偏移量都被相应的 prior 的维度标准化。这是有意义的,因为对于较大的 prior 来说,某些偏移的重要性要小于它对于较小的 prior 的重要性。

  类似于中心坐标和边界坐标互相转换一样,我们也需要根据一个框和偏移量计算出真实框,也需要根据两个框计算二者的偏移量,由图中的式子我们知道应该用中心坐标体系。

  • 计算两组框之间的偏移量

  根据图中的式子可以知道如何计算,但是需要注意, g c x 、 g c y g_{c_x}、g_{c_y} gcxgcy 我们乘了10 g w 、 g h g_w、g_h gwgh 乘了5,这是图中的式子没有的。这是作者为了缩放梯度而提出的,完全是经验性的,因此不用纠结为什么偏偏乘10和5而不是其他的数。

def cxcy_to_gcxgcy(cxcy, priors_cxcy):
    """
    将对应的两组框转换为之间的偏移量
    :param cxcy:  维度为 [n, 4] 的tensor,表示一组框的中心坐标
    :param priors_cxcy: 维度为 [n, 4]的tensor,表示一组框的中心坐标
    :return: 返回对应两个框之间的偏移量,维度为 [n, 4]
    """
    # 10和5是为了缩放梯度,完全是经验性的
    return torch.cat([
        (cxcy[:, :2] - priors_cxcy[:, :2]) / priors_cxcy[:, 2:] * 10,  # g_cx, g_cy
        torch.log(cxcy[:, 2:] / priors_cxcy[:, 2:]) * 5  # g_w, g_h
    ], dim=1)
  • 根据一个框和偏移量计算偏移后的框

  因为上面的计算中分别乘了10和5,因此这里,作为逆操作,我们要除以10和5.

def gcxcy_to_cxcy(gcxcy, priors_cxcy):
    """
    偏移量和对应的框,转为偏移后的框
    :param gcxcy:  维度为 [n, 4] 的tensor,表示偏移量
    :param priors_cxcy: 维度为 [n, 4]的tensor,表示框的中心坐标
    :return: 返回偏移后的框,维度为 [n, 4]
    """
    return torch.cat([
        gcxcy[:, :2] * priors_cxcy[:, 2:] / 10 + priors_cxcy[:, :2],  # cx,cy
        torch.exp(gcxcy[:, 2:] / 5) * priors_cxcy[:, 2:]  # w, h
    ], dim=1)
预测卷积

  在前面,我们对六个不同尺度和粒度的特征图指定并定义了 priors,即conv4_3, conv7, conv8_2, conv9_2, conv10_2conv11_2
  然后,对每个特征图上每个位置的每个 prior,我们想要预测:

  • 对边界框的偏移量 ( g c x , g c y , g w , g h ) (g_{c_x}, g_{c_y}, g_w, g_h) (gcx,gcy,gw,gh)
  • 每个边界框的一组类别分数,总共有 n_classes个类别,其中包含背景类

  为了以最简单的做到这些,我们需要为每个特征图设置两个卷积层——

  • 一个预测位置的卷积层,它的卷积核大小为 3, 3(padding和步长均为1)在每个位置进行运算,每个 prior 有4个filters代表着位置。
    一个prior 的4个 filters 计算这个 prior 生成的预测框的偏移量 ( g c x , g c y , g w , g h ) (g_{c_x}, g_{c_y}, g_w, g_h) (gcx,gcy,gw,gh)
  • 一个预测类别的卷积层,它的卷积核大小为 3, 3(padding和步长均为1)在每个位置进行运算,每个 prior 有 n_classes 个 filters。
    一个 prior 的 n_classes 个 filters 计算了这个 prior 的类别分数

在这里插入图片描述
  所有的卷积核大小均为 3, 3
  我们不需要与 priors 相同形状的卷积核,因为不同的 filters 会学习对不同形状的 priors 进行预测。
  让我们看看这些卷积的输出,仍然考虑 conv9_2 特征图:

在这里插入图片描述
  位置预测层用蓝色表示,类别预测层用黄色表示。你可以看到横截面的大小(5, 5)不变。
  我们真正感兴趣的是第三个维度,即通道数。它们包含了实际的预测。
  如果你选择一个面,进行位置预测,并将预测结果展开,你将看到什么?

在这里插入图片描述
  那就是!位置预测结果的每个位置处的通道值代表了那个位置的 priors 的偏移量。
  现在,让我们对类别预测做同样的操作,考虑 n_classes = 3

在这里插入图片描述

  和前面一样,这些通道值代表了每一类的分数。
  现在我们已经了解了 conv9_2 特征图的预测结果的样子,我们可以把它们 reshape 为更容易理解的形式:

在这里插入图片描述

  我们将150个预测结果按顺序排列,对于人来说,这样应该显得更加直观。
  但是我们不能就此止步。我们可以对所有层做同样的预测,然后将结果拼起来。
  我们之前计算过,一共生成了8732个 priors,因此,将有8732个编码为偏移量形式的预测框和8732组类别分数。

在这里插入图片描述
  这就是预测阶段的最终输出,一堆框,如果你愿意的话,还能估计里面有什么。
  一切都在一起,不是吗?如果这是你第一次研究物体检测,我想现在你应该看到了一丝光亮。

预测卷积的代码
class PredictionConvolutions(nn.Module):
    def __init__(self, n_classes):
        super(PredictionConvolutions, self).__init__()

        self.n_classes = n_classes

        # 每个特征层上每一个点所设置的先验框个数
        n_priors_boxes = {'conv4_3': 4, 'conv7': 6, 'conv8_2': 6,
                          'conv9_2': 6, 'conv10_2': 4, 'conv11_2': 4}

        # 位置预测卷积
        self.loc_conv4_3 = nn.Conv2d(512, n_priors_boxes['conv4_3'] * 4, kernel_size=(3, 3), padding=1)
        self.loc_conv7 = nn.Conv2d(1024, n_priors_boxes['conv7'] * 4, kernel_size=(3, 3), padding=1)
        self.loc_conv8_2 = nn.Conv2d(512, n_priors_boxes['conv8_2'] * 4, kernel_size=(3, 3), padding=1)
        self.loc_conv9_2 = nn.Conv2d(256, n_priors_boxes['conv9_2'] * 4, kernel_size=(3, 3), padding=1)
        self.loc_conv10_2 = nn.Conv2d(256, n_priors_boxes['conv10_2'] * 4, kernel_size=(3, 3), padding=1)
        self.loc_conv11_2 = nn.Conv2d(256, n_priors_boxes['conv11_2'] * 4, kernel_size=(3, 3), padding=1)

        # 类别预测卷积
        self.class_conv4_3 = nn.Conv2d(512, n_priors_boxes['conv4_3'] * n_classes, kernel_size=(3, 3), padding=1)
        self.class_conv7 = nn.Conv2d(1024, n_priors_boxes['conv7'] * n_classes, kernel_size=(3, 3), padding=1)
        self.class_conv8_2 = nn.Conv2d(512, n_priors_boxes['conv8_2'] * n_classes, kernel_size=(3, 3), padding=1)
        self.class_conv9_2 = nn.Conv2d(256, n_priors_boxes['conv9_2'] * n_classes, kernel_size=(3, 3), padding=1)
        self.class_conv10_2 = nn.Conv2d(256, n_priors_boxes['conv10_2'] * n_classes, kernel_size=(3, 3), padding=1)
        self.class_conv11_2 = nn.Conv2d(256, n_priors_boxes['conv11_2'] * n_classes, kernel_size=(3, 3), padding=1)
        
        # 初始化权重
        self.init_weight()

    def init_weight(self):
        for conv in self.children():
            if isinstance(conv, nn.Conv2d):
                nn.init.xavier_normal_(conv.weight)
                nn.init.constant_(conv.bias, 0.)

    def forward(self, conv4_3_feats, conv7_feats, conv8_2_feats, conv9_2_feats, conv10_2_feats, conv11_2_feats):
        batch_size = conv4_3_feats.size(0)

        # 预测框的边界
        l_conv4_3 = self.loc_conv4_3(conv4_3_feats)  # [b, 512, 38, 38] -> [b, 16, 38, 38]
        l_conv4_3 = l_conv4_3.permute(0, 2, 3, 1).contiguous()  # [b, 16, 38, 38] -> [b, 38, 38, 16]
        l_conv4_3 = l_conv4_3.view(batch_size, -1, 4)  # [b, 38, 38, 16] -> [b, 5776, 4]

        l_conv7 = self.loc_conv7(conv7_feats)  # [b, 1024, 19, 19] -> [b, 24, 19, 19]
        l_conv7 = l_conv7.permute(0, 2, 3, 1).contiguous()  # [b, 24, 19, 19] -> [b, 19, 19, 24]
        l_conv7 = l_conv7.view(batch_size, -1, 4)  # [b, 19, 19, 24] -> [b, 2166, 4]

        l_conv8_2 = self.loc_conv8_2(conv8_2_feats)  # [b, 512, 10, 10] -> [b, 24, 10, 10]
        l_conv8_2 = l_conv8_2.permute(0, 2, 3, 1).contiguous()  # [b, 24, 10, 10] -> [b, 10, 10, 24]
        l_conv8_2 = l_conv8_2.view(batch_size, -1, 4)  # [b, 10, 10, 24] -> [b, 600, 4]

        l_conv9_2 = self.loc_conv9_2(conv9_2_feats)  # [b, 256, 5, 5] -> [b, 24, 5, 5]
        l_conv9_2 = l_conv9_2.permute(0, 2, 3, 1).contiguous()  # [b, 24, 5, 5] -> [b, 5, 5, 24]
        l_conv9_2 = l_conv9_2.view(batch_size, -1, 4)  # [b, 5, 5, 24] -> [b, 150, 4]

        l_conv10_2 = self.loc_conv10_2(conv10_2_feats)  # [b, 256, 3, 3] -> [b, 16, 3, 3]
        l_conv10_2 = l_conv10_2.permute(0, 2, 3, 1).contiguous()  # [b, 16, 3, 3] -> [b, 3, 3, 16]
        l_conv10_2 = l_conv10_2.view(batch_size, -1, 4)  # [b, 3, 3, 16] -> [b, 36, 4]

        l_conv11_2 = self.loc_conv11_2(conv11_2_feats)  # [b, 256, 1, 1] -> [b, 16, 1, 1]
        l_conv11_2 = l_conv11_2.permute(0, 2, 3, 1).contiguous()  # [b, 16, 1, 1] -> [b, 1, 1, 16]
        l_conv11_2 = l_conv11_2.view(batch_size, -1, 4)  # [b, 1, 1, 16] -> [b, 4, 4]

        # 预测框的类别

        # [b, 512, 38, 38] -> [b, 4 * n_classes, 38, 38]
        c_conv4_3 = self.class_conv4_3(conv4_3_feats)
        # [b, 4 * n_classes, 38, 38] -> [b, 38, 38, 4 * n_classes]
        c_conv4_3 = c_conv4_3.permute(0, 2, 3, 1).contiguous()
        # [b, 38, 38, 4 * n_classes] -> [b, 5776, n_classes]
        c_conv4_3 = c_conv4_3.view(batch_size, -1, self.n_classes)

        # [b, 1024, 19, 19] -> [b, 6 * n_classes, 19, 19]
        c_conv7 = self.class_conv7(conv7_feats)
        # [b, 6 * n_classes, 19, 19] -> [b, 19, 19, 6 * n_classes]
        c_conv7 = c_conv7.permute(0, 2, 3, 1).contiguous()
        # [b, 19, 19, 6 * n_classes] -> [b, 2166, n_classes]
        c_conv7 = c_conv7.view(batch_size, -1, self.n_classes)

        # [b, 512, 10, 10] -> [b, 6 * n_classes, 10, 10]
        c_conv8_2 = self.class_conv8_2(conv8_2_feats)
        # [b, 6 * n_classes, 10, 10] -> [b, 10, 10, 6 * n_classes]
        c_conv8_2 = c_conv8_2.permute(0, 2, 3, 1).contiguous()
        # [b, 10, 10, 6 * n_classes] -> [b, 600, n_classes]
        c_conv8_2 = c_conv8_2.view(batch_size, -1, self.n_classes)

        # [b, 256, 5, 5] -> [b, 6 * n_classes, 5, 5]
        c_conv9_2 = self.class_conv9_2(conv9_2_feats)
        # [b, 6 * n_classes, 5, 5] -> [b, 5, 5, 6 * n_classes]
        c_conv9_2 = c_conv9_2.permute(0, 2, 3, 1).contiguous()
        # [b, 5, 5, 6 * n_classes] -> [b, 150, n_classes]
        c_conv9_2 = c_conv9_2.view(batch_size, -1, self.n_classes)

        # [b, 256, 3, 3] -> [b, 4 * n_classes, 3, 3]
        c_conv10_2 = self.class_conv10_2(conv10_2_feats)
        # [b, 4 * n_classes, 3, 3] -> [b, 3, 3, 4 * n_classes]
        c_conv10_2 = c_conv10_2.permute(0, 2, 3, 1).contiguous()
        # [b, 3, 3, 4 * n_classes] -> [b, 36, n_classes]
        c_conv10_2 = c_conv10_2.view(batch_size, -1, self.n_classes)

        # [b, 256, 1, 1] -> [b, 4 * n_classes, 1, 1]
        c_conv11_2 = self.class_conv11_2(conv11_2_feats)
        # [b, 4 * n_classes, 1, 1] -> [b, 1, 1, 4 * n_classes]
        c_conv11_2 = c_conv11_2.permute(0, 2, 3, 1).contiguous()
        # [b, 1, 1, 4 * n_classes] -> [b, 4, n_classes]
        c_conv11_2 = c_conv11_2.view(batch_size, -1, self.n_classes)

        # [b, 8732, 4]
        loc = torch.cat([l_conv4_3, l_conv7, l_conv8_2, l_conv9_2, l_conv10_2, l_conv11_2], dim=1)
        # [b, 8732, n_classes]
        classes_scores = torch.cat([c_conv4_3, c_conv7, c_conv8_2, c_conv9_2, c_conv10_2, c_conv11_2], dim=1)

        return loc, classes_scores

SSD300模型的代码

  因为SSD300的代码中有一个成员函数是用来对预测结果进行处理的,因此我打算介绍完处理后,再放代码,处理将在最后一部分,如果你想看SSD300的代码,只需到文章最后。

Multibox loss(损失函数)

  根据我们的预测结果,可能很容易理解为什么我们需要一个独特的损失函数。我们很多人以前可能计算过回归和分类任务的损失,但是很少同时计算。
  显然,我们的损失必须是两种预测类型损失的总和——边界框的位置和类别分数。
  然后,这里有一些问题需要回答——

  • 回归边界框将用什么损失函数?
  • 我们会用多类别交叉熵作为类别分数的损失吗?
  • 我们以什么比例合并它们?
  • 我们如何将预测的框与真实框进行匹配?
  • 我们有8732个预测结果!其中大部分是不包含物体吗?我们还需要考虑它们吗?

让我们继续。

将预测框与真实框进行匹配

  记住,任何监督学习算法的关键是我们需要能够将预测结果与真实结果相匹配。这很棘手,因为物体检测比一般学习任务更加开放。
  为了让模型学习任何东西,我们需要以一种能够将我们的预测与图像中实际存在的物体进行比较的方式来构造问题。

  priors 使我们能够做到这一点!

  • 计算 8732 个 priors 与 N个真实框的 Jaccard系数。这将得到一个维度为 [8732, N] 的tensor
  • 找到每一个 prior 匹配最好(重叠程度最大)的真实框
  • 如果一个 prior 与一个真实框的 Jaccard系数小于 0.5,我们就认为它不包含物体,因此它是一个负匹配。考虑到我们有成千上万个 prior ,大多数 prior 都是负匹配。
  • 另一方面,如果一个 prior 与一个真实框的 Jaccard 系数大于0.5,我们认为它包含这个物体,这是一个正匹配。
  • 现在我们已经为 8732个priors 每一个都匹配到了真实框。实际上,我们还将相应的8732个预测匹配真实框。

  让我们用一个例子重现这个逻辑:

在这里插入图片描述
  为了方便起见,我们仅假设有7个priors,由红色显示。真实框由黄色显示,这张图中一共有三个真实物体。
  按照前面概述的步骤,将生成以下匹配:

在这里插入图片描述
  现在每一个prior都有一个匹配,正或者负。扩展来说,每个预测都有一个匹配,正匹配或负匹配。
  预测为正匹配的结果,将存在一个真实框坐标与其对应,这个坐标将作为预测的目标,即回归任务。当然,对于负匹配,没有预测目标。
  所有的预测都有一个标签,如果是正匹配,那么标签就是真实物体标签,如果是负匹配,那么标签就是背景类。这些标签将作为类别预测的目标,即分类任务。

定位损失

  我们没有负匹配对应的真实坐标,这很有道理,我们为什么要训练一个在空白处绘制框的模型?
  因此,定位损失的计算仅取决于我们如何准确地将预测框与相应的真实框坐标进行匹配。
  因为我们预测的定位框的形式是偏移量 ( g c x , g c y , g w , g h ) (g_{c_x}, g_{c_y}, g_w, g_h) (gcx,gcy,gw,gh),在计算损失之前,我们还需要相应的对真实框的坐标进行编码
  定位损失是正匹配的预测偏移量与其对应的真实框之间的 Smooth L1 损失的平均。
在这里插入图片描述

置信损失

  每一个预测,无论是正匹配还是负匹配,都有一个与之相应的标签。重要的是,模型能够同时识别对象和缺少对象。
  然而,考虑到图像中通常只有少量的物体,我们做的成千上万的预测中,绝大多数实际上并不包含一个物体。如果负类比正类数量多很多,我们最终可能得到一个不太会检测物体的模型,因为,它会更倾向预测背景类。
  解决办法或许显而易见,限制将在损失函数中评估的负匹配的数量。但我们该如何选择呢?
  为什么不使用模型输出最错误的那些呢?换句话说,只使用那些模型发现很难识别为没有物体的预测。这叫做硬负采样(Hard Negative Mining)。
  我们通过硬负采样的数量 N h n N_{hn} Nhn,通常是这个图片中正匹配个数的固定倍数。在该模型中,作者使用了3倍硬负采样,即 N h n = 3 ∗ N p N_{hn} = 3 * N_p Nhn=3Np。通过计算每个负匹配预测的交叉熵损失,并选取损失最大的前 N h n N_{hn} Nhn 个,可以得到硬负采样的损失。
  然后,置信损失就是正匹配和硬负匹配中的交叉熵损失之和。
在这里插入图片描述
  从式子中,您将注意到,它是由正匹配的数量平均的,而不是正匹配数量和硬负采样数量之和进行平均。

总损失

  Multibox loss是两种损失的和,以比例 α \alpha α 进行组合。
在这里插入图片描述
  一般来说我们不需要对 α \alpha α 设置值,它可以是一个可训练参数。
  但是在SSD中,作者简单地使用 α = 1 \alpha = 1 α=1,我们也将使用它。

class MultiBoxLoss(nn.Module):
    def __init__(self, priors_cxcy, threshold=0.5, neg_pos_ratio=3, alpha=1.):
        """
        物体检测的损失函数
        :param priors_cxcy:     tensor,默认生成的priors,中心坐标形式
        :param threshold:       标量,表示设定重叠程度的阈值,当Jaccard系数大于阈值时认为是正匹配,默认为0.5
        :param neg_pos_ratio:   标量,表示采样的负样本与正样本的比例,默认为3
        :param alpha:           标量,表示将定位损失和分类损失以什么比例相加,默认为1
        """
        super(MultiBoxLoss, self).__init__()

        self.priors_cxcy = priors_cxcy
        # priors的边界坐标表示
        self.priors_xy = cxcy_to_xy(priors_cxcy)
        self.threshold = threshold
        self.neg_pos_ratio = neg_pos_ratio
        self.alpha = alpha

        self.smooth_l1 = nn.L1Loss()
        self.cross_entropy = nn.CrossEntropyLoss(reduction='none')

    def forward(self, predicted_loc, predicted_scores, boxes, labels):
        """
        前向计算过程
        :param predicted_loc:      SSD300模型预测的位置,[b, 8732, 4]
        :param predicted_scores:   SSD300模型预测的类别分数 [b, 8732, n_classes]
        :param boxes:              真实框 [b, n_objects, 4],注意n_objects不是固定数值,每张图片内的物体个数可能不一样
        :param labels:             真实标签 [b, n_objects]
        :return:                   标量,代表损失
        """
        batch_size = predicted_loc.size(0)
        n_priors = self.priors_cxcy.size(0)
        n_classes = predicted_scores.size(2)

        assert n_priors == predicted_loc.size(1) == predicted_scores.size(1)

        true_loc = torch.zeros((batch_size, n_priors, 4), dtype=torch.float).to(device)  # [b, 8732, 4]
        true_classes = torch.zeros((batch_size, n_priors), dtype=torch.long).to(device)  # [b, 8732]

        # 对每张图片
        for i in range(batch_size):
            n_objects = boxes[i].size(0)
            # 计算先验框与真实框的Jaccard系数
            overlap = find_jaccard_overlap(boxes[i], self.priors_xy)

            # 对于每个先验框,找到具有最大重叠的对象
            overlap_for_each_prior, object_for_each_prior = overlap.max(dim=0)

            # 我们不希望遇到这样的情况:存在某个物体没有被正先验框所表示,这包含两种情况
            # 1. 对每个先验框,我们选择其与真实框重叠最大的那个物体作为最佳检测物体,这可能导致某个物体没有一个先验框与之对应
            # 2. 对于有匹配物体的先验框来说,如果其重叠程度低于设定的阈值(0.5),也将被设置为背景类

            # 首先找到每个物体所对应的重叠程度最大的先验框
            _, prior_for_each_object = overlap.max(dim=1)
            # 然后将每个物体分配给相应的具有最大重叠的先验框,这解决了第1种情况
            object_for_each_prior[prior_for_each_object] = torch.LongTensor(range(n_objects)).to(device)
            # 为了保证这些先验框合格,人为赋予一个大于阈值(0.5)的值,这解决了第2种情况
            overlap_for_each_prior[prior_for_each_object] = 1.

            # 每个先验框的标签
            label_for_each_prior = labels[i][object_for_each_prior]
            # 将重叠程度小于阈值的标签设置为0(背景类)
            label_for_each_prior[overlap_for_each_prior < self.threshold] = 0
            true_classes[i] = label_for_each_prior
            # 将真实框编码为我们预测的偏移量形式,[8732, 4]
            true_loc[i] = cxcy_to_gcxgcy(xy_to_cxcy(boxes[i][object_for_each_prior]), self.priors_cxcy)
        positive_priors = true_classes != 0
        # 仅在正先验条件下计算定位损失
        loc_loss = self.smooth_l1(predicted_loc[positive_priors], true_loc[positive_priors])

        # 置信度损失是在每个图片上的正先验和最困难的负先验上计算的
        n_positives = positive_priors.sum(dim=1)
        # 我们将使用最困难的(neg_pos_ratio * n_positives 个)负先验,拥有最大的loss
        # 这叫做硬负采样,它专注于每张图片上最困难的负先验,同时也最大限度的减少了正负样本不均衡问题
        n_hard_negatives = self.neg_pos_ratio * n_positives

        # 首先计算所有先验的损失
        conf_loss_all = self.cross_entropy(predicted_scores.view(-1, n_classes), true_classes.view(-1))  # [b * 8732]
        conf_loss_all = conf_loss_all.view(batch_size, n_priors)  # [b, 8732]

        # 我们已经知道哪些先验是正的
        conf_loss_pos = conf_loss_all[positive_priors]

        # 接着,我们需要寻找最困难的先验
        # 为了实现目标,我们仅根据每张图片上的负先验按照其loss的降序排列,然后取前 n_hard_negatives 个,作为最困难的负先验
        conf_loss_neg = conf_loss_all.clone()
        conf_loss_neg[positive_priors] = 0.  # 将正先验设置为0,这样按照降序排序的时候,负先验会在前面
        conf_loss_neg, _ = conf_loss_neg.sort(dim=1, descending=True)
        hardness_ranks = torch.LongTensor(range(n_priors)).unsqueeze(0).expand_as(conf_loss_neg).to(device)  # [b, 8732]
        hard_negatives = hardness_ranks < n_hard_negatives.unsqueeze(1)  # [b, 8732]
        conf_loss_hard_neg = conf_loss_neg[hard_negatives]
        # 像论文中一样,仅在正先验上求平均,尽管正先验和负先验都进行了计算
        conf_loss = (conf_loss_hard_neg.sum() + conf_loss_pos.sum()) / n_positives.sum().float()

        # 返回总损失
        return conf_loss + self.alpha * loc_loss

对预测进行处理

  在模型训练后,我们可以将它应用到图片上。然而,这些预测结果是未加工的——两个tensor,包含8732个 priors 的偏移量和 类别分数。这些都需要经过处理,以获得最终人类可以理解的带有标签的边界框。
  这需要以下几点:

  • 我们有8732个表示为 prior 和其偏移量 ( g c x , g c y , g w , g h ) (g_{c_x}, g_{c_{y}}, g_w, g_h) (gcx,gcy,gw,gh) 的预测框。我们需要将它们解码为边界坐标。
  • 然后,对每个非背景类:
    • 为这8732个盒子中的每个盒子提取这个类的分数。
    • 删除该分数低于指定阈值的框。
    • 剩下的框是这个特定类别物体的候选框。

  此时,如果您要在原始图像上绘制这些候选框,您将看到许多高度重叠的框,这些框显然是多余的。这是因为我们处理的数千个priors中,极有可能有多个预测对应于同一个对象。
  例如,考虑下面的图片。
在这里插入图片描述
  很明显,里面只有三个物体——两只狗和一只猫。但是根据模型,有三只狗和两只猫。
  请注意,这只是一个温和的例子。真实情况可能会更糟。
  现在,对你来说,很明显知道哪些盒子指的是同一个物体。这是因为你的大脑可以处理特定的盒子之间以及特定的物体之间显著的重合。
  在实践中,这将如何实现?
  首先,将每个类别的候选框按照它们的可能性排列。

在这里插入图片描述
  我们已经按分数对它们进行了排序。
  下一步是找出哪些候选框是多余的。我们已经有了一个工具来判断两个盒子有多相似——Jaccard系数。
  因此,如果我们计算所有相同类别盒子的两两之间的Jaccard系数,如下图所示,我们可以查看每一对盒子之间的重叠程度,如果其显著重叠(超过给定阈值),那么我们就留下分数最高的候选框。

在这里插入图片描述
  此时我们就淘汰了所有动物类别中的流氓候选框。
  这个步骤叫做非极大抑制(NMS),因为当发现多个候选框显著重叠时,它们可能预测的是同一个物体,我们只保留分数最高的那一个,将其他的候选框全部抑制。

  在算法上,它的实现如下:

  • 在为每个候选框挑选其对应的最可能的类别后,对每个类别:
    • 按照分数递减的顺序排列候选框
    • 考虑得分最高的候选框。删除所有得分较低且与该最高分候选框重叠程度超过0.5的候选框
    • 若存在下一个得分最高的候选框,则根据该候选框再次进行上述操作
    • 重复这个操作直到对所有候选框都进行处理过

  最终的结果是,对于图像中的每个物体,你将只有一个单独的盒子——最好的一个。

在这里插入图片描述
  非极大抑制对获得高质量的预测是非常有用的。
  令人高兴的是,这也是最后一步。

SSD300代码

class SSD300(nn.Module):
    def __init__(self, n_classes):
        super(SSD300, self).__init__()

        self.n_classes = n_classes

        self.base = VGGBase()  # 基础卷积
        self.aux_convs = AuxiliaryConvolutions()  # 辅助卷积
        self.pred_convs = PredictionConvolutions(n_classes)  # 预测卷积

        # 我们认为低级特征有很大的规模,因此使用 L2范数 重新进行缩放, 这是一个可训练参数
        # conv4_3_feats 有512个channels
        self.rescale_factors = nn.Parameter(torch.FloatTensor(1, 512, 1, 1))
        nn.init.constant_(self.rescale_factors, 20)

        # 先验框 priors
        self.prior_cxcy_boxes = self.create_prior_boxes()

    def forward(self, x):
        # x shape -> [b, 3, 300, 300]
        conv4_3_feats, conv7_feats = self.base(x)  # [b, 512, 38, 38], [b, 1024, 19, 19]

        # 对conv4_3使用L2规范化
        norm = conv4_3_feats.pow(2).sum(dim=1, keepdim=True).sqrt()  # [b, 1, 38, 38]
        conv4_3_feats = conv4_3_feats / norm  # [b, 512, 38, 38]
        conv4_3_feats = conv4_3_feats * self.rescale_factors  # [b, 512, 38, 38]

        # [b, 512, 10, 10], [b, 256, 5, 5], [b, 256, 3, 3], [b, 256, 1, 1]
        conv8_2_feats, conv9_2_feats, conv10_2_feats, conv11_2_feats = self.aux_convs(conv7_feats)

        # [b, 8732, 4], [b, 8732, n_classes]
        loc, classes_scores = self.pred_convs(conv4_3_feats, conv7_feats, conv8_2_feats,
                                              conv9_2_feats, conv10_2_feats, conv11_2_feats)

        return loc, classes_scores

    @staticmethod
    def create_prior_boxes():
        # 特征图的尺寸
        features_dim = {'conv4_3': 38, 'conv7': 19, 'conv8_2': 10,
                        'conv9_2': 5, 'conv10_2': 3, 'conv11_2': 1}
        # prior的scale
        object_scales = {'conv4_3': 0.1, 'conv7': 0.2, 'conv8_2': 0.375,
                         'conv9_2': 0.55, 'conv10_2': 0.725, 'conv11_2': 0.9}
        # prior的aspect ratio
        # conv7,conv8_2和conv9_2会多出 3:1 和 1:3
        aspect_ratios = {
            'conv4_3': [1., 2., 0.5],
            'conv7': [1., 2., 3., 0.5, 0.333],
            'conv8_2': [1., 2., 3., 0.5, 0.333],
            'conv9_2': [1., 2., 3., 0.5, 0.333],
            'conv10_2': [1., 2., 0.5],
            'conv11_2': [1., 2., 0.5]
        }
        # 记录特征图的名称,用来查找当前特征图的下一个特征图
        features_name = list(features_dim.keys())
        # 所有的priors
        prior_boxes = []
        # 每个特征图都会有priors
        for k, feature in enumerate(features_name):
            # 每个特征图的每个位置都有priors
            # 模仿卷积的操作,按照从左到右,从上到下的顺序计算priors,为了与预测卷积相匹配
            for i in range(features_dim[feature]):
                for j in range(features_dim[feature]):
                    # 当前特征图的当前格子的中心坐标(需要进行缩放)
                    cx = (j + 0.5) / features_dim[feature]
                    cy = (i + 0.5) / features_dim[feature]
                    # 为当前格子按照aspect ratios生成priors
                    for ratio in aspect_ratios[feature]:
                        # w = s * sqrt(a), h = s / sqrt(a)
                        # 计算每个prior的中心坐标
                        prior_boxes.append([cx, cy, object_scales[feature] * sqrt(ratio),
                                            object_scales[feature] / sqrt(ratio)])
                        # 当ratio时,需要额外添加一个prior
                        if ratio == 1.:
                            # 如果当前特征图不是最后一个特征图,即当前特征图不是conv11_2
                            if k != len(features_name) - 1:
                                # 那么这个额外的prior的scale就是 sqrt(当前特征图scale * 下一个特征图scale)
                                additional_scale = sqrt(object_scales[feature] * object_scales[features_name[k + 1]])
                            else:
                                # 如果当前特征图是最后一个,它就不存在下一个特征图,直接将scale设置为1
                                additional_scale = 1.
                            # 添加额外的prior的中心坐标
                            prior_boxes.append([cx, cy, additional_scale, additional_scale])
        # 最后将所有priors转换为一个tensor
        prior_boxes = torch.FloatTensor(prior_boxes).to(device)  # [8732, 4]
        return prior_boxes

    def detect_objects(self, predicted_loc, predicted_scores, min_score, max_overlap, top_k):
        """
        根据预测结果检测物体
        :param predicted_loc: 预测的偏移量 [b, 8732, 4]
        :param predicted_scores: 预测的分数 [b, 8732, n_classes]
        :param min_score:  类别最低分数,如果低于此分数则认为这个物体不是该类
        :param max_overlap: 最大重叠程度的阈值,非极大抑制所需
        :param top_k: 最终只保留前k个结果
        :return: 经过一系列筛选后的预测结果
        """
        batch_size = predicted_loc.size(0)
        n_priors = self.prior_cxcy_boxes.size(0)
        predicted_scores = F.softmax(predicted_scores, dim=2)  # [b, 8732, n_classes]

        all_images_boxes = list()
        all_images_labels = list()
        all_images_scores = list()

        assert n_priors == predicted_loc.size(1) == predicted_scores.size(1)

        for i in range(batch_size):
            # 将对priors预测的偏移量转化为边界坐标
            decoded_loc = cxcy_to_xy(gcxcy_to_cxcy(predicted_loc[i], self.prior_cxcy_boxes))  # [8732, 4]

            image_boxes = list()
            image_labels = list()
            image_scores = list()

            # 检查每一个类别
            for c in range(1, self.n_classes):  # 类别从1开始,0表示背景类
                # 仅保留预测类别分数超过最低分数的预测框和类别
                class_scores = predicted_scores[i][:, c]  # [8732]
                score_above_min_score = class_scores > min_score  # torch.uint8 tensor, 索引
                n_above_min_score = score_above_min_score.sum().item()
                # 如果预测分数没有超过最低分的,则该图片认为不含物体
                if n_above_min_score == 0:
                    continue

                class_scores = class_scores[score_above_min_score]
                class_decoded_loc = decoded_loc[score_above_min_score]

                # 对预测框和类别,按照类别得分排序
                class_scores, sort_index = class_scores.sort(dim=0, descending=True)
                class_decoded_loc = class_decoded_loc[sort_index]

                # 查找预测框之间的重叠
                # 返回一个 [n, n] 的张量,表示每个预测框与其他所有预测框的IoU值
                overlap = find_jaccard_overlap(class_decoded_loc, class_decoded_loc)  # [n, n]

                # 非极大抑制
                # 记录要抑制的box,1表示抑制,0表示不抑制
                suppress = torch.zeros(n_above_min_score, dtype=torch.uint8).to(device)
                for box in range(class_decoded_loc.size(0)):
                    # 如果该box已经标记为抑制,则不必再次进行检测
                    if suppress[box] == 1:
                        continue
                    # 抑制重叠大于允许最大重叠的box
                    suppress = torch.max(suppress, overlap[box] > max_overlap)
                    # 自身与自身重叠为1,但是不应该抑制本身
                    suppress[box] = 0
                image_boxes.append(class_decoded_loc[1 - suppress])
                image_labels.append(torch.LongTensor((1 - suppress).sum().item() * [c]).to(device))
                image_scores.append(class_scores[1 - suppress])

            if len(image_boxes) == 0:
                # 如果没有任何类别被检测到,则为背景存一个占位符
                image_boxes.append(torch.FloatTensor([[0., 0., 1., 1.]]).to(device))
                image_labels.append(torch.FloatTensor([0]).to(device))
                image_scores.append(torch.FloatTensor([0.]).to(device))

            # 拼接为单个tensor
            image_boxes = torch.cat(image_boxes, dim=0)
            image_labels = torch.cat(image_labels, dim=0)
            image_scores = torch.cat(image_scores, dim=0)
            n_objects = image_scores.size(0)

            # 仅保留前k个对象
            if n_objects > top_k:
                image_scores, sort_index = image_scores.sort(dim=0, descending=True)
                image_scores = image_scores[:top_k]  # (top_k)
                image_boxes = image_boxes[sort_index][:top_k]  # (top_k, 4)
                image_labels = image_labels[sort_index][:top_k]  # (top_k)

            all_images_boxes.append(image_boxes)
            all_images_labels.append(image_labels)
            all_images_scores.append(image_scores)

        return all_images_boxes, all_images_labels, all_images_scores
  • 30
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值