最近在看杨建华的《yolo 目标检测》,打算按章节做一点笔记。封面是这样的:
封面
一编:读了这本书,感觉这本书讲得很不清楚、表述也不够明白(很乱),自己学习过程中搜了很多东西,不是特别推荐,整理的笔记已经尽力了)
文中的源代码可以到该项目的github中找。

二编:有的代码结构和书中表示出来略有差别。对于初学者来说,这本书还是非常不推荐。打算放弃这本书,去看别的资料了。关于这本书的内容就更新这么多了。

01 第一章:目标检测架构浅析

1.1 目标检测发展史

2014年,R-CNN问世。
利用R-CNN,基本思路是先使用一个搜索算法从图像中提取出若干感兴趣区域(region of interest,RoI),然后使用一个卷积神经网络(convolutional neural network,CNN)分别处理每一个感兴趣区域,提取特征,最后用一个支持向量机来完成最终的分类:
R-CNN
搜索算法会先给出约2000个感兴趣区域,然后交给后续的CNN去分别提取每一个感兴趣区域的特征,十分耗时,后来又诞生了Fast R-CNN和Faster R-CNN。
像R-CNN “这样,先提取,后识别”的检测范式,被称为“两阶段”(two-stage)检测。
即,先提取出可能包含目标的区域,再依次对每个区域进行识别,最后经过处理得到最终的检测结果。

2015年,YOLO(You Only Look Once)问世。
YOLO的作者团队认为,提取候选区域(定位)和逐一识别(分类)完全可由一个单独的网络来同时完成,无须分成两个阶段,不需要对每一个特征区域进行依次分类,从而能够减少处理过程中的大量冗余操。
图片
因而YOLO在检测速度上具有天然的优势。
YOLO所采用的这种端到端的检测方式,将定位和分类耦合在一起,同步完成,被称为“单阶段”(one-stage)检测。

在YOLO框架大火之后,目标检测领域正式诞生了以下两大流派:

  • 以R-CNN为代表的two-stage流派;
  • 以YOLO为首的one-stage流派。

one-stage框架相关工作占据了很大的比重,如SSD、RetinaNet和FCOS等。
近来,这一套框架又由基于Transformer的DETR系列做了一次大幅度的革新。
因此,在入门目标检测领域时,学习one-stage框架相关工作是比较契合主流的选择。

1.2 目标检测网络框架

一个常见的目标检测网络往往可以分为三大部分:主干网络(backbone network)、颈部网络(neck network)和检测头(detection head)。

网络框架

  • 主干网络。主干网络是目标检测网络中最核心的部分,其关键作用就是提取输入图像中的高级特征,减少图像中的冗余信息,以便于后续的网络去做深入的处理。
  • 颈部网络。颈部网络的主要作用是将由主干网络输出的特征进行二次处理。其整合方式有很多,最为常见的就是特征金字塔网络(feature pyramid network,FPN),其核心是将不同尺度的特征进行充分的融合,以提升检测器的多尺度检测的性能。(还有RFB,ASPP,YOLOV4所使用的SPP模块等,添加在主干网络之后,可以进一步地处理和丰富特征信息,扩大模型的感受野)
    一点对FPN的解释:
    (FPN认为网络中的不同大小的特征图所包含的信息是不一样的,即浅层特征图包含更多的位置信息,且分辨率较高,感受野较小,便于检测小物体;而深层特征图包含更多的语义信息,且分辨率较低,感受野较大,便于检测大物体。)
    解释
  • 检测头。其主要作用就是提取类别信息和位置信息,输出最终的预测结果。(在某些工作里,检测头也被称为解码器(decoder))

1.2.1 主干网络

为了检测出图像中目标的类别和位置,我们会先从输入的图像中提取出必要的特征信息,比如HOG特征

HOG的一点基本介绍:
基本介绍1
介绍2
不论是基于传统方法还是深度学习方法,提取特征这一步都是至关重要的,区别只在于提取的方式。然后利用这些特征去完成后续的定位和分类。
在目标检测框架中,提取特征通常被称为主干网络。

1.2.2 颈部网络

由于主干网络往往是从图像分类任务中迁移过来的,这些网络的设计很少会考虑到包括目标检测、语义分割等下游任务,它们提取的特征也就有可能不太适合目标检测任务。
因此,在主干网络处理完毕之后,需要设计一些额外的模块来对其特征做进一步的处理,以便适应目标检测任务。
因为这一部分是在主干网络之后、检测头之前,因此被形象地称为颈部网络。

常用的颈部网络,除了前面提到的FPN,还有常用到的SPP结构:
SPP结构包含4条并行的分支,且每条分支用了不同大小的池化核。
SPP结构可以有效地聚合不同尺度的显著特征,同时扩大网络的感受野,进而提升模型的性能。
SPP结构

1.2.3 检测头

当一张输入图像经过主干网络和颈部网络两部分的处理后,得到的特征就可以用于后续的检测了。
大多数的检测框架所采用的技术路线,都是在处理好的特征图上,通过部署数层卷积来完成定位和分类。

如下图,是当前常用的一种检测头结构RetinaNet,采用了“解耦”结构的检测头。
它由两条并行的分支组成,每一条分支都包含若干层普通卷积、非线性激活函数以及用于最终预测的线性卷积层。
结构
(这里没太明白也没事,后面在代码中,再详细去理解)

02 第二章:数据集

03 第三章:YOLO v1

3.1 YOLOv1的网络结构

基本思想:使用一个卷积神经网络来端到端地检测目标。
核心思想是:把目标检测看作一次回归问题,用一个神经网络直接从整张图片预测边界框(Bounding Box)和类别概率,而不是像 R-CNN 那样先生成候选框再分类。

3.1.1 GoogLeNet的结构

先来了解一下GoogLeNet的结构:
主要由:普通卷积层 + Inception 模块 + 辅助分类器组成;
结构大概流程:
示意图
Inception 模块核心思想:
传统 CNN 每一层卷积核的大小是固定的,比如 ResNet 第一层就是 7×7 卷积。

GoogLeNet 的创新是:

  • 同一层并行使用不同卷积核(1×1、3×3、5×5)+ 最大池化;
  • 然后把它们的输出通道在通道维度上拼接(concat);
  • 1×1 卷积在这里不仅用来提取特征,还起到了降维作用(减少计算量)

Inception的示意图为:
示意图

3.1.2 YOLOv1的结构

YOLOv1仿照GoogLeNet网络来设计主干网络,但没有采用GoogLeNet的Inception模块,而是使用串联的11卷积(用来降维)和33卷积(用来提取特征)所组成的模块。
YOLO v1 把整张图像划分为 S×S 网格(论文中 S=7),结构如下:
结构图
在YOLO所处的年代,图像分类网络都会将特征图展平(flatten),得到一个一维特征向量,然后连接全连接层去做预测。

YOLOv1在VOC数据集上与其他模型的性能对比的两个指标:
一个是衡量模型检测性能的平均精度(mean average precision,mAP),另一个是衡量模型检测速度的每秒帧数(frames per second,FPS)。
mAP是目标检测领域常用的性能评价指标,其数值越高,意味着模型的检测性能越好。
FPS是指网络每秒可以处理多少张图像(从输入一张图像到输出图像中的目标的检测结果)​。FPS的数值越大,意味着模型的检测速度越快。

3.2 YOLOv1的检测原理

根据前面3-1的图,原文给出如下理解:
理解
可能有一点费解,大概降采样的特征图是图3-1全连接层处理前的结构(771024的结构)。
经过全连接层后,得到7730的结构。
7730这个结构,可理解为:每一个网格(1*1的维度)网格都是一个特征维度为30的向量。

对于30这个数字,是通过这样的式子计算的:计算式子
解释
为什么上面式子中B前面的系数是5呢?
是因为每个边界框包含的信息有5个:置信度C(表征的是每一类的概率),边界框位置参数(x,y,w,h)(x,y是边界框的中心点相较于网格最上角的偏移量)(w,h是边界框的宽和高)
YOLOv1 中 B=2,用两个边界框是为了解决单个边界框不够灵活的问题。

(有点好笑,当时第一反应,没有反应过来边界框是干什么的)
解释
如下图,可以看出每一个网格的基本结构:
结构
那么提问,为什么边界框是两组,类别概率只是一组呢?
是一个取舍问题,请看gpt老师的解答:
解释

所以,整体维度变化,可以大概用下图理解:
结构
综上,YOLOv1的检测理念就是将输入图像划分成网格,然后在网格上做预测。

3.3 YOLOv1的制作训练正样本的方法

本节主要介绍YOLOv1的这30个参数的定义和学习策略,以及如何为每一个参数制作用于训练的正样本。

3.3.1 边界框的位置参数 x,y,w,h

YOLOv1是通过检测图像中的目标中心点来达到检测目标的目的。
只有包含目标中心点的网格才会被认为是有物体的。
为此,YOLOv1定义了一个“objectness”概念,表示此处是否有物体,即[插图]表示此网格处有物体,反之,[插图]表示此网格处没有物体。

有物体的网格会被标记为正样本候选区域,其他区域的预测都是该目标的负样本。

关于中心点偏移量的计算式,这里还是借gpt老师的回答:
计算公式
图3-7直观地展示了YOLOv1中的中心点偏移量的概念。
在推理阶段,YOLOv1先用预测的边界框置信度来找出包含目标中心点的网格,再通过这一网格所预测出的中心点偏移量得到最终的中心点坐标(上面公式的逆计算就可以得到x和y)
解释图
YOLOv1还要输出边界框的宽和高,以确定边界框大小。如何做呢?
理解
注意上面提到,预测较大数据的时候,容易导致梯度震荡,从而不容易收敛

使用如下公式,进行归一化:
归一化公式
其中,上式中的分子是目标的边界框的宽和高,分母是输入图像的宽和高。
则,归一化后的尺寸就在0~1之间。
这样,既避免了损失过大所导致的训练发散问题,又和其他部分的损失取得了较好的平衡。

3.3.2 置信度

有一个很重要的问题:如何让网络确定中心点的位置,如何确定置信度?

引入一个概念,交并比(intersection of union,IoU):
计算出两个矩形框的交集(intersection)和并集(union),它们的比值即为IoU。
解释
如果预测框接近真实框,IoU则接近1。
基于上面的概念,进行边界框置信度和类别置信度的理解:
解释1
解释2

3.4 损失函数

损失函数的公式如下:
公式

  • 第一行和第二行表示的是边界框的位置参数的损失;
  • 第三行和第四行表示的是边界框的置信度的损失
  • 最后一行就是正样本处的类别的损失。

提问,为什么宽高的损失函数和别的项处理略有不同?
解答如下:
解答

3.5 前向推理

YOLOv1的前向推理,即是算出某个边界框的得分。

为得到最终检测结果,一般的步骤有:

  1. 计算所有预测的边界框的得分;
  2. 得分阈值筛选;
  3. 计算边界框的中心点坐标以及宽和高;
  4. 使用费极大值抑制第二次筛选;
    (由于YOLOv1可能对同一个目标给出多个得分较高的边界框,需要对这种冗余检测进行抑制,以剔除那些不必要的重复检测。
    引入非极大值抑制(non-maximal suppression,NMS)这一概念:
    非极大值抑制的思想很简单,对于某一类别目标的所有边界框,先挑选出得分最高的边界框,再依次计算其他边界框与这个得分最高的边界框的IoU,超过设定的IoU阈值的边界框则被认为是重复检测,将其剔除。对所有类别的边界框都进行上述操作,直到无边界框可剔除为止)

04 第四章:搭建YOLOv1网络

本章搭建的YOLOv1是在原网络进行优化后的。

4.1 改进YOLOv1

我们要构建的YOLOv1网络是一个全卷积结构,其中不包含任何全连接层,这一点可以避免YOLOv1中存在的因全连接层而导致的参数过多的问题。
(但实际上,YOLOv2工作才开始转变为全卷积结构)
需要搭建的网络结构如下:
网络结构

4.1.1 改进主干网络

用ResNet网络代替YOLOv1的GoogLeNet风格的主干网络。
相较于原本的主干网络,ResNet使用了诸如批归一化(batch normalization,BN)、残差连接(residual connection)等操作,有助于稳定训练更大更深的网络。(目前,这两个操作几乎成了绝大多数卷积神经网络的标准结构之一)

ResNet-18网络的结构如下:
结构
提问,将图像分类网络用作目标检测网络的主干网络时,通常是不需要最后的平均池化层和分类层的。如何理解这句话呢?
解释
因此,这里我们去除ResNet-18网络中的最后的平均池化层和分类层。
不同于YOLOv1原本的GoogLeNet网络的64倍降采样,ResNet-18网络的最大降采样倍数为32,故而一张448448的图像输入后,主干网络会输出一个空间大小为1414的特征图。
若输入图像的尺寸为416416,则该尺寸的图像输入后,输出张量为1313*512。

下面是基于pytorch框架resnet-18的部分代码:

import torch
import torch.nn as nn
import torch.utils.model_zoo as model_zoo

# --------------------- ResNet modules ---------------------
def conv3x3(in_planes, out_planes, stride=1):  # 定义3×3 卷积的便捷函数:自动带 padding=1 保持尺寸(在 stride=1 时)
    """3x3 convolution with padding"""
    return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,
                     padding=1, bias=False)

def conv1x1(in_planes, out_planes, stride=1):  # 定义1×1 卷积:常用于通道变换/降维或做下采样时调通道数
    """1x1 convolution"""
    return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride, bias=False)

class BasicBlock(nn.Module):
    expansion = 1

    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super(BasicBlock, self).__init__()
        self.conv1 = conv3x3(inplanes, planes, stride)
        self.bn1 = nn.BatchNorm2d(planes)
        self.relu = nn.ReLU(inplace=True)
        self.conv2 = conv3x3(planes, planes)
        self.bn2 = nn.BatchNorm2d(planes)
        self.downsample = downsample   # downsample:当尺寸或通道不匹配时,用它把“捷径分支(identity)”对齐(通常是 1×1 卷积)
        self.stride = stride

    def forward(self, x):
        identity = x   # 保存捷径分支

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)

        if self.downsample is not None:
            identity = self.downsample(x)  # 对齐尺寸/通道

        out += identity  # 残差相加
        out = self.relu(out)  # 再激活

        return out

class Bottleneck(nn.Module):
    expansion = 4  # 输出通道扩张倍数为 4(1×1 降维 → 3×3 特征 → 1×1 扩维)

    def __init__(self, inplanes, planes, stride=1, downsample=None):
        super(Bottleneck, self).__init__()
        self.conv1 = conv1x1(inplanes, planes)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = conv3x3(planes, planes, stride)
        self.bn2 = nn.BatchNorm2d(planes)
        self.conv3 = conv1x1(planes, planes * self.expansion)
        self.bn3 = nn.BatchNorm2d(planes * self.expansion)
        self.relu = nn.ReLU(inplace=True)
        self.downsample = downsample
        self.stride = stride

    def forward(self, x):
        identity = x

        out = self.conv1(x)
        out = self.bn1(out)
        out = self.relu(out)

        out = self.conv2(out)
        out = self.bn2(out)
        out = self.relu(out)

        out = self.conv3(out)
        out = self.bn3(out)

        if self.downsample is not None:
            identity = self.downsample(x)

        out += identity
        out = self.relu(out)

        return out


# --------------------- ResNet -----------------------
class ResNet(nn.Module):

    def __init__(self, block, layers, zero_init_residual=False):
        super(ResNet, self).__init__()
        self.inplanes = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3,
                               bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        self.relu = nn.ReLU(inplace=True)
        self.maxpool = nn.MaxPool2d(kernel_size=3, stride=2, padding=1)
        # 7×7/2 卷积把步长减半,BN+ReLU;再 3×3/2 最大池化再减半。到此步下采样 4 倍(/2 再 /2)

        self.layer1 = self._make_layer(block, 64, layers[0])  # stride=1, 不降采样
        self.layer2 = self._make_layer(block, 128, layers[1], stride=2)  # /2
        self.layer3 = self._make_layer(block, 256, layers[2], stride=2)  # /2
        self.layer4 = self._make_layer(block, 512, layers[3], stride=2)  # /2

        for m in self.modules():
            if isinstance(m, nn.Conv2d):  # 判断当前子模块m是不是二维卷积层(Conv2d),如果是,就执行下面的卷积层权重初始化
                nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu')
            elif isinstance(m, nn.BatchNorm2d):  # 如果当前模块是批归一化层(BatchNorm2d),执行下面的初始化
                nn.init.constant_(m.weight, 1)
                nn.init.constant_(m.bias, 0)

        # Zero-initialize the last BN in each residual branch,
        # so that the residual branch starts with zeros, and each residual block behaves like an identity.
        # This improves the model by 0.2~0.3% according to https://arxiv.org/abs/1706.02677
        if zero_init_residual: # 把每个残差分支最后一层 BN 的缩放系数 γ 初始化为 0,使初始时残差分支输出≈0,整个块近似恒等映射。这个小技巧能让训练更稳定、精度小涨(论文小结论)
            for m in self.modules():
                if isinstance(m, Bottleneck):
                    nn.init.constant_(m.bn3.weight, 0)
                elif isinstance(m, BasicBlock):
                    nn.init.constant_(m.bn2.weight, 0)

    def _make_layer(self, block, planes, blocks, stride=1):  # 用来创建resnet中的一整层;block:残差块的类,比如BasicBlock或Bottleneck;planes:这一层内部卷积的输出通道数(瓶颈层的通道数);blocks:该层中包含多少个残差块
        downsample = None  # 初始化变量 downsample 为 None,后续用来存放可能的下采样操作(调整残差连接输入维度)

        if stride != 1 or self.inplanes != planes * block.expansion: # 判断是否需要做下采样或改变通道数(如果步长 stride 不等于1,说明要缩小特征图尺寸(下采样);或者输入通道数 self.inplanes 不等于输出通道数(planes * block.expansion),残差连接的维度不匹配)
            downsample = nn.Sequential(  # 如果满足上面条件,就定义 downsample 为一个由两层组成的序列
                conv1x1(self.inplanes, planes * block.expansion, stride),  # conv1x1:1×1卷积,改变通道数和(或)步长,实现尺寸和通道匹配
                nn.BatchNorm2d(planes * block.expansion),  # BatchNorm2d:批归一化,保持训练稳定性
            )

        layers = []
        layers.append(block(self.inplanes, planes, stride, downsample))  # 添加第一个残差块:
        self.inplanes = planes * block.expansion  # 更新 self.inplanes 为该层残差块输出的通道数(planes 乘以扩张倍数 block.expansion)
        for _ in range(1, blocks):  # 通过循环,添加剩余的残差块(从第2个到第blocks个):
            layers.append(block(self.inplanes, planes))

        return nn.Sequential(*layers)

    def forward(self, x):
        """
        Input:
            x: (Tensor) -> [B, C, H, W]
        Output:
            c5: (Tensor) -> [B, C, H/32, W/32]
        """
        c1 = self.conv1(x)     # [B, C, H/2, W/2]
        c1 = self.bn1(c1)      # [B, C, H/2, W/2]
        c1 = self.relu(c1)     # [B, C, H/2, W/2]
        c2 = self.maxpool(c1)  # [B, C, H/4, W/4]

        c2 = self.layer1(c2)   # [B, C, H/4, W/4]
        c3 = self.layer2(c2)   # [B, C, H/8, W/8]
        c4 = self.layer3(c3)   # [B, C, H/16, W/16]
        c5 = self.layer4(c4)   # [B, C, H/32, W/32]

        return c5

4.1.2 添加一个颈部网络

选用改进的SPP模块作为颈部网络:
颈部网络
5×5的最大池化层等效于5×5、9×9和13×13这三条并行的最大池化层分支,从而降低计算开销。

这部分代码为:


# --------------------- Basic modules ---------------------
def get_conv2d(c1, c2, k, p, s, d, g, bias=False): # 返回一个2D卷积层,c1是输入通道数,c2是输出通道数,k是卷积核大小,p(padding),s(stride),d(dilation膨胀卷积系数),g:groups(分组卷积,1=普通卷积,c1=c2=g=depthwise卷积)
    conv = nn.Conv2d(c1, c2, k, stride=s, padding=p, dilation=d, groups=g, bias=bias)

    return conv

def get_activation(act_type=None):  #  功能:根据字符串返回对应激活函数
    if act_type == 'relu':
        return nn.ReLU(inplace=True)  # inplace=True:表示在原地操作,节省内存
    elif act_type == 'lrelu':
        return nn.LeakyReLU(0.1, inplace=True)
    elif act_type == 'mish':
        return nn.Mish(inplace=True)
    elif act_type == 'silu':
        return nn.SiLU(inplace=True)
    elif act_type is None:
        return nn.Identity()  # nn.Identity():什么也不做,直接返回输入
    else:
        raise NotImplementedError

def get_norm(norm_type, dim):  # 功能:根据字符串返回对应归一化层
    if norm_type == 'BN':  # BN → BatchNorm2d;GN → GroupNorm;None → 不做归一化
        return nn.BatchNorm2d(dim)
    elif norm_type == 'GN':
        return nn.GroupNorm(num_groups=32, num_channels=dim)
    elif norm_type is None:
        return nn.Identity()
    else:
        raise NotImplementedError

class BasicConv(nn.Module):  # 功能:封装卷积 + BN + 激活函数模块,可选择depthwise卷积
    def __init__(self,
                 in_dim,                   # in channels
                 out_dim,                  # out channels
                 kernel_size=1,            # kernel size
                 padding=0,                # padding
                 stride=1,                 # padding
                 dilation=1,               # dilation
                 act_type  :str = 'lrelu', # activation
                 norm_type :str = 'BN',    # normalization
                 depthwise :bool = False   # depthwise:是否使用深度可分离卷积。
                ):
        super(BasicConv, self).__init__()
        self.depthwise = depthwise
        use_bias = False if norm_type is not None else True  # 如果有归一化层,则卷积不使用偏置,因为BN中会有偏置项
        if not depthwise:  # 普通卷积
            self.conv = get_conv2d(in_dim, out_dim, k=kernel_size, p=padding, s=stride, d=dilation, g=1, bias=use_bias)
            self.norm = get_norm(norm_type, out_dim)
        else:  # 深度可分离卷积
            self.conv1 = get_conv2d(in_dim, in_dim, k=kernel_size, p=padding, s=stride, d=dilation, g=in_dim, bias=use_bias)
            self.norm1 = get_norm(norm_type, in_dim)
            self.conv2 = get_conv2d(in_dim, out_dim, k=1, p=0, s=1, d=1, g=1)
            self.norm2 = get_norm(norm_type, out_dim)
        self.act  = get_activation(act_type)

    def forward(self, x):
        if not self.depthwise:  # 普通卷积:Conv → BN → 激活
            return self.act(self.norm(self.conv(x)))
        else:  # Depthwise:Depthwise Conv → BN → Pointwise Conv → BN → 激活
            # Depthwise conv
            x = self.norm1(self.conv1(x))
            # Pointwise conv
            x = self.act(self.norm2(self.conv2(x)))
            return x


# Spatial Pyramid Pooling - Fast (SPPF) layer for YOLOv5 by Glenn Jocher
class SPPF(nn.Module):
    """
        This code referenced to https://github.com/ultralytics/yolov5
    """
    def __init__(self, cfg, in_dim, out_dim):
        super().__init__()
        ## ----------- Basic Parameters -----------
        inter_dim = round(in_dim * cfg.neck_expand_ratio)
        self.out_dim = out_dim
        ## ----------- Network Parameters -----------
        self.cv1 = BasicConv(in_dim, inter_dim,
                             kernel_size=1, padding=0, stride=1,
                             act_type=cfg.neck_act, norm_type=cfg.neck_norm) # 1×1卷积降低通道数到中间维度
        self.cv2 = BasicConv(inter_dim * 4, out_dim,
                             kernel_size=1, padding=0, stride=1,
                             act_type=cfg.neck_act, norm_type=cfg.neck_norm) # 1×1卷积融合SPPF的池化结果
        self.m = nn.MaxPool2d(kernel_size=cfg.spp_pooling_size,
                              stride=1,
                              padding=cfg.spp_pooling_size // 2)

        # Initialize all layers
        self.init_weights()

    def init_weights(self): # 初始化卷积层参数
        """Initialize the parameters."""
        for m in self.modules():
            if isinstance(m, torch.nn.Conv2d):
                # In order to be consistent with the source code,
                # reset the Conv2d initialization parameters
                m.reset_parameters()

    def forward(self, x):
        x = self.cv1(x)
        y1 = self.m(x)
        y2 = self.m(y1)

        return self.cv2(torch.cat((x, y1, y2, self.m(y2)), 1))

4.1.3 修改检测头

采用解耦检测头作为YOLOv1的检测头,由类别分支和回归分支组成,分别提取类别特征和位置特征。
图片
实现代码:

class Yolov1DetHead(nn.Module):
    def __init__(self, cfg, in_dim: int = 256):  # cfg 是一个配置对象,存储网络超参数
        super().__init__()
        # --------- Basic Parameters ----------
        self.in_dim = in_dim
        self.cls_head_dim = cfg.head_dim
        self.reg_head_dim = cfg.head_dim
        self.num_cls_head = cfg.num_cls_head
        self.num_reg_head = cfg.num_reg_head
        self.act_type = cfg.head_act
        self.norm_type = cfg.head_norm
        self.depthwise = cfg.head_depthwise

        # --------- Network Parameters ----------
        ## cls head
        cls_feats = []  # cls_feats:存放分类头卷积层
        for i in range(self.num_cls_head):
            if i == 0:
                cls_feats.append(
                    BasicConv(in_dim, self.cls_head_dim,
                              kernel_size=3, padding=1, stride=1,
                              act_type=self.act_type,
                              norm_type=self.norm_type,
                              depthwise=self.depthwise)
                )
            else:
                cls_feats.append(
                    BasicConv(self.cls_head_dim, self.cls_head_dim,
                              kernel_size=3, padding=1, stride=1,
                              act_type=self.act_type,
                              norm_type=self.norm_type,
                              depthwise=self.depthwise)
                )
        ## reg head
        reg_feats = []
        for i in range(self.num_reg_head):
            if i == 0:
                reg_feats.append(
                    BasicConv(in_dim, self.reg_head_dim,
                              kernel_size=3, padding=1, stride=1,
                              act_type=self.act_type,
                              norm_type=self.norm_type,
                              depthwise=self.depthwise)
                )
            else:
                reg_feats.append(
                    BasicConv(self.reg_head_dim, self.reg_head_dim,
                              kernel_size=3, padding=1, stride=1,
                              act_type=self.act_type,
                              norm_type=self.norm_type,
                              depthwise=self.depthwise)
                )
        # 将卷积层组合成顺序模块
        self.cls_feats = nn.Sequential(*cls_feats)  # 用 nn.Sequential 将列表里的卷积层串联成一个模块
        self.reg_feats = nn.Sequential(*reg_feats)

        self.init_weights()

    def init_weights(self):
        """Initialize the parameters."""
        for m in self.modules():
            if isinstance(m, torch.nn.Conv2d):
                # In order to be consistent with the source code,
                # reset the Conv2d initialization parameters
                m.reset_parameters()

    def forward(self, x):
        """
            in_feats: (Tensor) [B, C, H, W]
        """
        cls_feats = self.cls_feats(x)
        reg_feats = self.reg_feats(x)

        return cls_feats, reg_feats

4.1.4 修改预测层

在最后的预测层,使用1*1的卷积层在特征图上做预测。如下图:
图像
其中通道维度上的1表示边界框的置信度,c表示类别的总数,4表示边界框的4个位置参数。这里不再有表示每个网格的边界框数量的B。
结构分析:
结构分析

4.1.5 修改损失函数

置信度损失:
置信度损失

类别损失:
类别损失

边界框位置参数的损失:
损失函数
总的损失:
总的损失

4.2 搭建YOLOv1网络

实现代码为:

# YOLOv1
class Yolov1(nn.Module):
    def __init__(self,
                 cfg,
                 is_val=False,
                 ) -> None:
        super(Yolov1, self).__init__()
        # ---------------------- Basic setting ----------------------
        self.cfg = cfg
        self.num_classes = cfg.num_classes
        ## Post-process parameters
        self.topk_candidates = cfg.val_topk if is_val else cfg.test_topk
        self.conf_thresh = cfg.val_conf_thresh if is_val else cfg.test_conf_thresh
        self.nms_thresh = cfg.val_nms_thresh if is_val else cfg.test_nms_thresh
        self.no_multi_labels = False if is_val else True

        # ---------------------- Network Parameters ----------------------
        self.backbone = Yolov1Backbone(cfg)
        self.neck = SPPF(cfg, self.backbone.feat_dim, cfg.head_dim)
        self.head = Yolov1DetHead(cfg, self.neck.out_dim)
        self.pred = Yolov1DetPredLayer(cfg)

    def post_process(self, obj_preds, cls_preds, box_preds):
        """
        We process predictions at each scale hierarchically
        Input:
            obj_preds: torch.Tensor -> [B, M, 1], B=1
            cls_preds: torch.Tensor -> [B, M, C], B=1
            box_preds: torch.Tensor -> [B, M, 4], B=1
        Output:
            bboxes: np.array -> [N, 4]
            scores: np.array -> [N,]
            labels: np.array -> [N,]
        """
        obj_preds = obj_preds[0]
        cls_preds = cls_preds[0]
        box_preds = box_preds[0]
        if self.no_multi_labels:
            # [M,]
            scores, labels = torch.max(
                torch.sqrt(obj_preds.sigmoid() * cls_preds.sigmoid()), dim=1)

            # Keep top k top scoring indices only.
            num_topk = min(self.topk_candidates, box_preds.size(0))

            # topk candidates
            predicted_prob, topk_idxs = scores.sort(descending=True)
            topk_scores = predicted_prob[:num_topk]
            topk_idxs = topk_idxs[:num_topk]

            # filter out the proposals with low confidence score
            keep_idxs = topk_scores > self.conf_thresh
            scores = topk_scores[keep_idxs]
            topk_idxs = topk_idxs[keep_idxs]

            labels = labels[topk_idxs]
            bboxes = box_preds[topk_idxs]
        else:
            # [M, C] -> [MC,]
            scores = torch.sqrt(obj_preds.sigmoid() * cls_preds.sigmoid()).flatten()

            # Keep top k top scoring indices only.
            num_topk = min(self.topk_candidates, box_preds.size(0))

            # torch.sort is actually faster than .topk (at least on GPUs)
            predicted_prob, topk_idxs = scores.sort(descending=True)
            topk_scores = predicted_prob[:num_topk]
            topk_idxs = topk_idxs[:num_topk]

            # filter out the proposals with low confidence score
            keep_idxs = topk_scores > self.conf_thresh
            scores = topk_scores[keep_idxs]
            topk_idxs = topk_idxs[keep_idxs]

            anchor_idxs = torch.div(topk_idxs, self.num_classes, rounding_mode='floor')
            labels = topk_idxs % self.num_classes

            bboxes = box_preds[anchor_idxs]

        # to cpu & numpy
        scores = scores.cpu().numpy()
        labels = labels.cpu().numpy()
        bboxes = bboxes.cpu().numpy()

        # nms
        scores, labels, bboxes = multiclass_nms(
            scores, labels, bboxes, self.nms_thresh, self.num_classes)

        return bboxes, scores, labels

    def forward(self, x):
        # ---------------- Backbone ----------------
        x = self.backbone(x)

        # ---------------- Neck ----------------
        x = self.neck(x)

        # ---------------- Heads ----------------
        cls_feats, reg_feats = self.head(x)

        # ---------------- Preds ----------------
        outputs = self.pred(cls_feats, reg_feats)
        outputs['image_size'] = [x.shape[2], x.shape[3]]

        if not self.training:
            obj_preds = outputs['pred_obj']
            cls_preds = outputs['pred_cls']
            box_preds = outputs['pred_box']

            # post process
            bboxes, scores, labels = self.post_process(
                obj_preds, cls_preds, box_preds)
            outputs = {
                "scores": scores,
                "labels": labels,
                "bboxes": bboxes
            }

        return outputs

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐