前言

目前在一家自动驾驶公司做视觉感知算法实习生。近期主要工作是对某个项目网络模型(本次模型部分参考YOLOv8框架)进行Backbone方面的轻量化,期间涉及的一些知识点,在此Record一下。

如何从轻量化角度改进YOLOv8?

压缩YOLOv8模型

对于YOLOv8模型,我们可以采用模型压缩的方法来减小模型的大小。模型压缩包括模型量化、模型剪枝和模型蒸馏等技术。模型量化是将浮点模型转换为定点模型,可以减小模型大小。模型剪枝是指去除模型中冗余的权重和神经元,可以减少模型的参数量。模型蒸馏是通过在小模型中嵌入大模型的知识来提高小模型的精度。这些方法可以结合使用,以实现更好的效果。

改进YOLOv8的骨干网络

YOLOv8的骨干网络是Darknet53,虽然Darknet53有较好的性能,但在实际应用中,它的计算量较大。为了减小计算量,我们可以考虑使用轻量级网络来替代Darknet53,例如MobileNetShuffleNet等。这些网络具有更小的计算量和更少的参数量,可以提高模型的效率和精度。

优化YOLOv8的损失函数

YOLOv8的损失函数包括分类损失和定位损失。在实际应用中,我们可以根据具体的应用场景,对损失函数进行优化。例如,在一些应用场景中,对于目标的定位更为关键,我们可以考虑提高定位损失的权重,从而提高目标的定位精度。

四、增加YOLOv8的数据增强

数据增强是提高模型泛化能力的重要手段。对于YOLOv8模型,我们可以通过增加数据增强的方法来提高模型的泛化能力。例如,随机裁剪、随机旋转、随机缩放等数据增强方法,可以增加模型的数据多样性。

引入注意力机制

注意力机制是一种提高模型精度的有效手段。通过引入注意力机制,模型可以更加关注关键的目标区域,从而提高模型的精度。在YOLOv8模型中,我们可以引入注意力机制来增强模型对目标的关注程度。例如,SENet(Squeeze-and-Excitation Network)模型通过学习每个通道的权重来加强重要的特征通道,从而提高模型的准确率。

改进YOLOv8的后处理算法

后处理算法是目标检测算法中的重要环节。在YOLOv8模型中,后处理算法负责对模型输出的结果进行筛选和修正。在实际应用中,我们可以根据不同的应用场景对后处理算法进行优化。例如,采用非极大值抑制(NMS)算法来对重叠的目标进行筛选,同时引入一些修正策略,如像素点修正等方法,可以提高模型的准确率和鲁棒性。

结合其他技术进行优化

除了以上几种方法外,我们还可以结合其他的技术来优化YOLOv8模型。例如,我们可以采用超分辨率技术来提高输入图像的分辨率,从而提高模型的精度。此外,我们还可以采用GAN(Generative Adversarial Network)等技术来生成更多的数据样本,从而增加模型的数据多样性。

本次项目尝试使用一些特殊的模型方法来进行改进。

深度学习中常用的Backbone:

  1. AlexNet:在2012年ImageNet挑战赛中首次引入的CNN,具有8层神经网络。
  2. VGG:由Simonyan和Zisserman于2014年提出的一种卷积神经网络,它采用小尺寸的3×3卷积核来替代传统的5×5或7×7卷积核。
  3. ResNet:由Microsoft Research Asia在2015年提出的一种卷积神经网络,通过引入残差连接解决了深度神经网络中的梯度消失问题。
  4. Inception系列网络:由Google在2014年提出的一种卷积神经网络,其特点是使用多个不同大小的卷积核来提取不同层次的特征。
  5. MobileNet:一种轻量级的卷积神经网络,可以在移动设备上快速运行,它使用深度可分离卷积来减少参数数量和计算复杂度。
  6. EfficientNet:由谷歌在2019年提出的一种卷积神经网络,它使用复合系数扩展方法来提高模型的效率和准确性。
  7. ResNeXt:由Facebook在2017年提出的一种卷积神经网络,通过并行连接多个小型卷积核来提高模型的准确性和效率。

这里以resnet50模块代码实例进行网络层级分析:

# 基础网络:resnet50
# ResNet颈部
# Basic_block是两层的残差块,用于resnet18/34;
# Bottleneck是三层的残差块,用于resnet50/101/152。
class Bottleneck (nn.Module):
expansion = 4 # 定义一个类属性expansion为4,表示Bottleneck中的通道数扩展倍数为4
def __init__(self, inplanes, planes, stride=1, downsample=None, **kwargs):
# 超类,调用父类
super(Bottleneck, seLf).__init__()
# 分别定义一个 1x1,3x3,1x1 的卷积层,和对应的3个 BN 层,对其输出分别进行归一化处理
seLf.conv1 = nn.Conv2d(inplanes, planes, kernel.size=1, stride=stride, bias=False)
self.bn1 = nn.BatchNorm2d(planes)
self.conv2 = nn.Conv2d(planes, planes , kernel_size=3, stride=1, padding=1, bias=False)
self.bn2 = nn.BatchNorm2d(planes)
self.Conv3 = nn.Conv2d(planes, planes * 4, kernel_size=1, bias=False)
self.bn3 = nn.BatchNorm2d(pLanes * 4)
# 定义一个ReLU激活函数
self.relu = nn.ReLU(inplace=True)
self.downsample = downsample
self.stride = stride
# 前向传播
def forward(self, x):
residual = 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:
residual = self.downsample(x) # 下采样改变通道数,再相加
out += residual # 将输入x直接加到输出out
out = self.relu(out) # 再进行一次ReLU激活后返回输出out
return out

# ResNet本体
class ResNet(nn.Module):
def__init__(self, block, layers, num_classes=1000, output num=4, **kwargs):
self.inplanes = 64
super(ResNet, self).__init__()
# 512,512,3 -> 256,256,64
self.conv1 = nn.Conv2d(3, 64,kernel size=7, stride=2, padding=3, bias=False)

# 若输入数据维度为WxW,填充值P,卷积核大小FxF,步长S,输出数据维度为NxN,则有如下计算公式:N = [( W - F + 2P)/ S ] + 1, 其中[*]表示向下取整
self.bn1 = nn.BatchNorm2d(64)
self.relu = nn.ReLU(inplace=True)

# 256x256x64 -> 128x128x64, ceil_mode表示向上取整
self.maxpool = nn.MaxPool2d(kernel size=3, stride=2, padding=0, ceil_mode=True)
# 128x128x64 -> 128x128x256
self.layer1 = self._make_layer(block, 64, layers[0])
# 128x128x256 -> 64x64x512
self.layer2 = self._make_layer(block, 128, layers[1], stride=2)
# 64x64x512 -> 32x32x1024
self.layer3 = self._make_layer(block, 256, layers[2], stride=2)
# 32x32x1024 -> 16x16x2048
self.layer4 = self.make_layer(block, 512, layers[3], stride=2)

# 平均池化+全连接
self.avgpool = nn.AvgPool2d(7)
self.fc = nn.Linear(512 * block.expansion, num_classes)
for m in self.modules():
if isinstance(m, nn.Conv2d):
n = m.kernel_size[0] * m.kernel_size[1] * m.out_channels
m.weight.data.normal_(0,math.sqrt(2. / n))
elif isinstance(m, nn.BatchNorm2d):
m.weight.data.fill_(1)
m.bias.data.zero_()

# _make_layer 方法中:
# 第一个输入参数 block 选择要使用的模块是 BasicBlock 还是 Bottleneck 类
# 第二个输入参数 planes 是该模块的输出通道数
# 第三个输入参数 blocks 是每个 blocks 中包含多少个 residual 子结构
def _make_layer(self, block, planes, blocks, stride=1):
downsample = None
# self.inplanes是block的输入通道数,planes是做3x3卷积的空间的通道数,expansion是残差结构中输出维度是输入维度的多少倍,同一个stage内,第一个block,inplanes=planes, 输出为planes*block.expansion
# 第二个block开始,该block的inplanes等于上一层的输出的通道数planes*block.expansion(类似于卷积后的结果进入下一个卷积时,前一个卷积得到的output的输出为下一个卷积的input)
if stride != 1 or self.inplanes != planes * block.expansion:
downsample = nn.Sequential(nn.Conv2d(self.inplanes, planes * block.expansion, kernel_size=1, stride=stride, bias=False), nn.BatchNorm2d(pLanes*block.expansion),)
layers = []
layers.append(block(self.inplanes, planes, stride, downsample))
self.inlanes = planes * block.expansion
for i in range(1, blocks):
layers.append(block(self.inplanes planes))
return nn.Sequential(*layers)

def forward(self, x):
x = self.conv1(x)
# print (x.shape )
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)

layer1 = self.layer1(x)
layer2 = self.layer2(layer1)
layer3 = self.layer3(layer2)
layer4 = self.layer4(layer3)
return layer1, layer2, layer3, layer4

def get_resnet50(pretrain=True, **kwargs):
model = ResNet(Bottleneck, [3, 4, 6, 3])
if pretrain:
state_dict = Load_state_dict_from_urL(model_urls['resnet50'])
model.load_state_dict(state_dict)
features = list([model.conv1, model.bn1, model.relu, model.maxpool, model.layer1, model.layer2, model.layer3, model.layer4])
features = nn.Sequential(*features)
return features

def get_resnet50v2(*args, **kwargs):
pretrain = kwargs.get("pretrain", False)
# 该列表定义了ResNet的结构,它指定了在每个ResNet块中使用多少个卷积层和残差块
model = ResNet(BottLeneck, [3, 4, 6, 3])
if pretrain:
state_dict = torch.load("pretrain/resnet50-19c8e357.pth")
model.load_state_dict(state_dict)
return model

ResNet块结构.jpg

部分特殊的网络结构:

RepVGG

我们说的 VGG 式网络结构通常是指:

  1. 没有任何分支结构,即通常所说的 plainfeed-forward 架构。
  2. 仅使用 3×3 类型的卷积。
  3. 仅使用 ReLU 作为激活函数。

RepVGG 主体部分只有一种算子3x3 卷积接 ReLU。在设计专用芯片时,给定芯片尺寸或造价,可以集成海量的 3x3 卷积-ReLU 计算单元来达到很高的效率,同时单路架构省内存的特性也可以帮我们少做存储单元。

在训练时,为每一个 3x3 卷积层添加平行的 1x1 卷积分支和恒等映射分支,构成一个 RepVGG Block。这种设计是借鉴 ResNet 的做法,区别在于 ResNet 是每隔两层或三层加一分支,RepVGG 模型是每层都加两个分支(训练阶段)。

RepVGG架构示意图.png

结构重参数化

卷积运算,可以看作是一串内积运算,等效于矩阵相乘,因此卷积满足交换、结合等定律。

训练时使用多分支卷积结构,推理时将多分支结构进行融合转换成单路 3×3 卷积层,由卷积的线性(具体说就是可加性)原理,每个 RepVGG Block 的三个分支可以合并为一个 3×3 卷积层(等价转换)。注意三个分支都有BN(batch normalization)层,其参数包括累积得到的均值及标准差和学得的缩放因子及bias。这并不会妨碍转换的可行性,因为推理时的卷积层和其后的BN层可以等价转换为一个带bias(偏置)的卷积层(也就是通常所谓的“吸BN”)。推理时,BN是一个线性的操作,也就是一个缩放 + 一个偏移,我们完全可以把这个线性操作叠加到前面的全连接层或者卷积层,只需要把全连接或者卷积层的权重乘以一个系数,偏置从 c 变为 ac+b 就可以了。

对三分支分别“吸BN”之后(注意恒等映射可以看成一个“卷积层”,其参数是一个2x2单位矩阵!),将得到的1x1卷积核用0给pad成3x3。最后,三分支得到的卷积核和bias分别相加即可。这样,每个RepVGG Block转换前后的输出完全相同,因而训练好的模型可以等价转换为只有3x3卷积的单路模型。

RepVGGBlock的结构重参数化.png

ACNet

概念结构重参数化(structural re-parameterization)指的是首先构造一系列结构(一般用于训练),并将其参数等价转换为另一组参数(一般用于推理),从而将这一系列结构等价转换为另一系列结构。

ACNet 的创新分为训练和推理阶段:

  • 训练阶段:将现有网络中的每一个 3×3 卷积层换成 3×1 卷积 + 1×3 卷积 + 3×3 卷积共三个卷积层,并将三个卷积层的计算结果进行相加得到最终卷积层的输出。因为这个过程引入的 1×3 卷积和 3×1 卷积是非对称的,所以将其命名为 Asymmetric Convolution(AC)。论文中有实验证明(见论文 Table 4)引入 1×3 这样的水平卷积核可以提升模型对图像上下翻转的鲁棒性,竖直方向的 3×1 卷积核同理。
  • 推理阶段:主要是对三个卷积核进行融合,这部分在实现过程中就是使用融合后的卷积核参数来初始化现有的网络。

推理阶段的卷积融合操作是和 BN 层一起的,融合操作发生在 BN 之后,论文实验证明融合在 BN 之后效果更好些。

DBB-Net

DBB-NetACNet的续作(ACNet v2),同样是对训练时的模块在测试时做结构重参数化,以实现推理时无痛涨点的工作。DBB 模型并不依赖于特定的 backbone 结构,而是采用了一种多分支的设计思路,可以与各种常见的 backbone 结构进行结合。

DBB-Net借鉴Inception的多分支并行结构提出了DBB(Diverse Branch Block),在测试时多分支通过结构重参数化成单分支。

训练时的DBB由4个分支构成:

  • 1x1卷积+BN
  • 1x1卷积+BN+K×K卷积+BN
  • 1x1卷积+BN+平均池化+BN
  • K×K卷积+BN

四个分支的结果相加后,经由激活函数输出结果。

DBB的典型结构设计.png

文章给出了6种DBB形态的转换方式:

6种DBB形态的转换方式.png

DBBRepVGG之间是什么关系?

RepVGG是一个普通架构,RepVGG风格的结构重参是为普通架构设计的。在非普通架构上,与单个 3x3 Conv相比,RepVGG 块没有显示出任何优势( RepVGG 论文中指出,它仅将 Res-50 提高了 0.03%)。DBB 是一个通用构建块(building block),可用于多种架构,取代常规的Conv

如何修改YOLOv8的Backbone部分?

BackboneYOLOv8使用的依旧是CSP的思想,不过YOLOv5中的C3模块被替换成了C2f模块,实现了进一步的轻量化。

针对C3模块,其主要是借助CSPNet提取分流的思想,同时结合残差结构的思想,设计了所谓的C3 Block,这里的CSP主分支梯度模块BottleNeck模块,也就是所谓的残差模块。同时堆叠的个数由参数n来进行控制,也就是说不同规模的模型,n的值是有变化的。

这里的梯度流主分支,可以是任何之前你学习过的模块,比如,美团提出的YOLOv6中就是用来重参模块RepVGGBlock来替换BottleNeck Block来作为主要的梯度流分支,而百度提出的PP-YOLOE则是使用了RepResNet-Block来替换BottleNeck Block来作为主要的梯度流分支。而YOLOv7则是使用了ELAN Block来替换BottleNeck Block来作为主要的梯度流分支。

YOLOv8原框架修改方法:

  1. 修改modules.py,增加待添加的模块,例如MobileNetShuffleNet等;
  2. tasks.py中注册模块;
  3. 修改yaml文件中对应的backbone等部分;

公司代码框架中采用了Encoder(编码器) 和Decoder(解码器) 以及Head(头任务) 的trick来套用各种网络模块。

编码器将网络分为了许多 *块 (block),此处的block不能理解为神经网络的一层 (layer)*。事实上,一个block为 神经网络的几个layer,整个网络为若干个block的堆叠。

简化版Transformer编码器.jpg

如上图所示是一个简化版的Transformer编码器,其中一个block的工作为:先将输入送入自注意力汇聚机制,然后将自注意力层的输出经过全连接 (FC) 层得到block的输出。

解码器输出一个向量,长度为 词表 (Vocabulary) 长度 。这个向量是一个概率分布,表示取得对应词的概率。

下图为一个自回归解码器的示意图:

自回归解码器.png

项目代码主体框架梳理:

multask_frontvision
├─ configs # 主要负责模型配置参数、数据集配置信息
│ ├─ datasets
│ └─ models # 多任务基础模型架构,包含编码器、解码器、多头任务、损失结构
├─ src
│ ├─ configs
│ │ └─ default.yaml # 模型训练配置参数
│ └─ lib
│ ├─ base # 抽象接口类等
│ ├─ dataset # 负责数据集的工厂创建、数据集加载、预处理、增强操作等
│ ├─ models
│ │ ├─ encoder
│ │ │ ├─ __init__.py # 编码器工厂模式创建方法
│ │ │ └─ base.py # 基线编码器backbone具体实现
│ │ ├─ decoder
│ │ │ ├─ __init__.py # 解码器工厂模式创建方法
│ │ │ └─ base.py # 基线解码器decode具体实现
│ │ ├─ heads
│ │ │ ├─ __init__.py # 头任务工厂模式创建方法
│ │ │ └─ base.py # 基线多头任务obj、freespace、 lanline具体实现
│ │ ├─ loss_ decoder
│ │ │ ├─ __init__.py # 多任务头损失计算工厂模式构建方法
│ │ │ └─ base.py # 多任务头损失计算具体实现
│ │ ├─ losses
│ │ │ ├─ __init__.py # 损失工厂模式创建方法
│ │ │ └─ base.py # 损失类具体实现
│ │ ├─ networks # 负责任务的模型组织、构建、推理实现
│ │ │ ├─ __init__.py # 多任务模型工厂模式创建方法
│ │ │ └─ base.py # 多任务基线模型具体实现
│ │ ├─ common.py # 公共组件
│ │ └─ model.py # 负责模型的创建、模型加载、保存
│ ├─ runners
│ │ ├─ __init__.py # 负责构建runner、optimizer、数据迭代器
│ │ ├─ base_runner.py # 模型训练生命周期管理器
│ │ ├─ multi_task_runner.py # 某个多任务训练管理器
│ │ └─ runners_factory.py # 任务训练器工厂创建方法
│ └─ utils
│ ├─ config.py # 负责配置文件解析
│ ├─ options.py # 训练参数配置文件 ★
│ └─ utils.py # 工具函数集合
├─ tools
│ ├─ train.py # 负责训练任务 ★
│ ├─ val.py # 负责评估任务
│ └─ test.py # 负责测试任务
└─ exp # 模型训练输出文件(模型、日志等)

实际调试只需要修改对应Encoder部分及模型定义即可,以RepVGG为例,具体方法为:

  • 替换Encoder对应层级的Block,将普通卷积Conv替换为RepVGGBlock;
# backbone:
# [from, number, module, args]
YOLO_ENCODER_v8_nano = [
[-1, 1, RepVGGBlock, [3,64, 3, 2]], # 0-P1/2
[-1, 1, RepVGGBlock, [64,128, 3, 2]], # 1-P2/4
[-1, 3, C2f, [128,128, True]],
[-1, 1, RepVGGBlock, [128,256, 3, 2]], # 3-P3/8
[-1, 6, C2f, [256,256, True]],
[-1, 1, RepVGGBlock, [256,512, 3, 2]], # 5-P4/16
[-1, 6, C2f, [512,512, True]],
[-1, 1, RepVGGBlock, [512,1024, 3, 2]], # 7-P5/32
[-1, 3, C2f, [1024,1024, True]],
]
  • Common公共组件中添加RepVGGBlock模块类定义;
  • 在模型参数定义语句中添加RepVGGBlock模块类选择。

本次关注指标主要有:

  1. kp_detect:关键点检测。p值在0.7-0.8,r值在0.5-0.6,acc最终稳定在0.70;
  2. driver:骑行者等。acc最终稳定在0.88;
  3. occlude:遮挡。acc最终稳定在0.90。

最终效果(优化前后对比):

FLOPs/MMac Params/M mean_sys/ms std_sys/ms mean_fps
YOLOv8 116.76 1.88 5.516 0.176 181.28
RepVgg(重参数化前) 119.31 1.93 5.973 0.178 167.43
RepVgg(重参数化后) 116.63 1.49 5.197 0.207 192.42
ACNet(重参数化前) 130.9 2.15 6.261 0.21 159.73
ACNet(重参数化后) 116.5 1.88 5.237 0.207 190.93

优化前后,在计算量不变的情况下,对比发现,关键指标的检测精度基本没降(掉点约0.5%),但是推理时帧率能够稳定提升10fps,是一个相对不错的效果。