
在实际应用场景中,小目标检测是不可避免的一个难题,除了从数据预处理的角度,突出小目标将小目标变成“大目标”之外。还可以从算法优化的角度出发,使用一些算法常用的 tricks,修改下采样层或者调节模型超参数,让算法更全面的保留小目标的原始图像信息、从更合适的范围内去关注原始图像上的小目标物体。
trick1:优化下采样层
如下图所示,我们以 ResNet-50 的网络架图为例,来说明通用主干网络中下采样层的一些“弊端”,并给出如何修改这些“弊端”的一些算法 tricks,让算法网络尽可能的不丢失原始图像信息,从而提高小目标的检测精准度。
上图中的 Down sampling(下采样)层由于使用了大小为 1x1,步长为 2 的卷积核进行特征提取,使得经过此卷积层的输出特征图丢失了近 3/4 的输入信息。如一个 5x5 的特征输入图,在经过此卷积层之后,输出特征图的尺寸仅为 3x3。
以下网格在经过此卷积层之后仅保留了左上角的 4,其他 7、3、0 等像素值被“无用丢弃”,丢失了网格中3/4的数据信息。
思考🤔:ResNet-50 网络结构的下采样层中,为什么要使用大小为 1x1,步长为 2 的卷积核?
优化 PathA
小目标之所以难检测一个很重要的原因是由于某些通用的骨干网络会丢失原始图像信息,让原本占原始图像很小的小目标在特征图上只剩下几个像素点的大小。根据 Resnet-50 网络架构的下采样层中大小为 1x1,步长为 2 的卷积操作可知,我们可以通过优化此种卷积层,让输出特征图尽可能多的保留原始图像信息。
如下图所示,我们将 PathA 中大小为 1x1,步长为 2 的卷积层替换为大小为 1x1,步长为 1 的卷积层,为了保持下采样层输出特征图的大小不变,我们将原本卷积核大小为 3x3,步长为 1 的下一个卷积层改成卷积核大小为 3x3,步长为 2 的卷积层。
下面给出 PyTorch 中 1x1 的卷积函数以及 3x3 的卷积函数。
# 3x3卷积
def conv_3x3(in_planes, out_planes, stride=1):
"""
:param in_planes: int, 输入通道数
:param out_planes: int, 输出通道数
:param stride: int, 步长
:return: 卷积核大小为3x3,步长为1的2维卷积层
"""
return nn.Conv2d(in_planes, out_planes, kernel_size=3, stride=stride,padding=1, bias=False)
# 1x1卷积
def conv_1x1(in_planes, out_planes, stride=1):
"""
:param in_planes: int, 输入通道数
:param out_planes: int, 输出通道数
:param stride: int, 步长
:return: 卷积核大小为1x1,步长为1的2维卷积层
"""
return nn.Conv2d(in_planes, out_planes, kernel_size=1, stride=stride,bias=False)
# 2x2池化
def avgpool_2x2(stride=2):
"""
:param stride: int, 步长
:return: 2x2,步长为2的2维池化层
"""
return nn.AvgPool2d(kernel_size=2, stride=stride, ceil_mode=True,count_include_pad=False)
优化 PathB
上述 trick 虽然能解决下采样层丢失原始输入信息的弊病,但是依旧没有改变 PathB 中卷积核大小为 1x1,步长为 2 的卷积操作丢失了 3/4 的输入特征图。根据上述 trick,我们总结出一个结论:在需要减少特征图尺寸还要尽量不让输入数据信息丢失,需要替换掉卷积核大小为 1x1,步长为 2 的卷积操作。
除了上述 trick,我们还可以介绍另外一个 trick 来对下采样层中的 PathB 进行优化,如下图所示,我们使用大小为 1x1,步长为 1 的卷积层,为了保持输出特征图的尺寸不变,我们在该卷积层之前,加入一个大小为 2x2,步长为 2 的均值池化层。
如下图所示,大小为 2x2,步长为 2 的 average pool(均值池化层,下文所有的“均值池化层”、“avgPool”均指的此操作)操作,不仅能将输入特征图数量进行减半且不会丢失输入特征图信息(在大小为 2x2,步长为 2 的均值池化操作中,输出特征图上的每一个元素都由输入特征图上一个 2x2 大小的区做均值操作得到,也就是说每个元素都和输入特征图上一个 2x2 大小的区域相关,而不会出现有像素值被“无用丢弃”现象)。
可以看到上图均值池化展示了右对角线的计算过程,请读者写出左对角线的计算过程,是否跟下面的结果一样。
(4+7+3+0)/4 = 3.5
(3+0+2+8)/4 = 3.25
下面我们给出 ResNet 网络架构生成函数,在下采样层 PathB 中加入大小为 2x2,步长为 2 的 avgPool 层。
import torch.nn as nn
from modules import utils
class Bottleneck(nn.Module):
def __init__(self, in_places, places, stride=1, downsampling=False, expansion=4, b_down=False, d_down=False):
"""
:param in_places: int, 输入通道数目
:param places: int, 输出通道数目
:param stride: int, 步长
:param downsampling: bool, 是否需要下采样层
:param expansion: 扩展输出通道的倍数
:param b_down: bool, 是否对下采层的pathA做优化
:param d_down: bool, 是否对下采样层的pathB做优化
"""
super(Bottleneck, self).__init__()
self.expansion = expansion
self.downsampling = downsampling
self.bottleneck = nn.Sequential(
nn.Conv2d(in_channels=in_places, out_channels=places, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(places),
nn.ReLU(inplace=True),
nn.Conv2d(in_channels=places, out_channels=places, kernel_size=3, stride=stride, padding=1, bias=False),
nn.BatchNorm2d(places),
nn.ReLU(inplace=True),
nn.Conv2d(in_channels=places, out_channels=places * self.expansion, kernel_size=1, stride=1, bias=False),
nn.BatchNorm2d(places * self.expansion),
)
if self.downsampling:
"""根据输入参数对下采样的pathA和pathB做优化"""
out_places = places * self.expansion
print("in downsample stride:{} out_places:{} ".format(stride, out_places))
if d_down and stride == 2 and out_places == 2048:
"""使用核大小为2x2,步长为2的均值池化层+核大小为1x1,步长为1的卷积层替换核大小为1x1,步长为2的卷积层"""
self.downsample = nn.Sequential(
utils.avgpool_2x2(stride=2),
utils.conv_1x1(in_planes, out_planes, stride=1),
nn.BatchNorm2d(out_places)
)
elif b_down and stride == 2:
"""使用核大小为1x1,步长为1的卷积层+核大小为3x3,步长为2的卷积层替换核大小为1x1,步长为2的卷积层"""
self.downsample = nn.Sequential(
utils.conv_1x1(in_planes, out_planes, stride=1),
utils.conv_3x3(in_planes, out_planes, stride=2),
nn.BatchNorm2d(out_places)
)
else:
self.downsample = nn.Sequential(
nn.Conv2d(in_channels=in_places, out_channels=out_places, kernel_size=1, stride=stride,
bias=False),
nn.BatchNorm2d(out_places)
)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
residual = x
out = self.bottleneck(x)
if self.downsampling:
residual = self.downsample(x)
out += residual
out = self.relu(out)
return out
trick2:修改损失函数
小目标检测效果差的直观表现效果大概有以下两种情况:
第一种情况为小目标物体检测失败,从算法层面的直观解释是该区域的候选提议框类别判断错误(目标物判别为非目标物);
第二种情况为小目标物体的类别检测错误或者只有某一部分被检测出来,从算法层面的直观解释是该区域的提议候选框被检测成了其他类别的目标物体或者其置信度分数偏低,当我们以某个置信度阈值进行目标物筛选的时候,这些置信度分数偏低的目标框会被认为是不含有目标物的背景框。
为了提高小目标物体的分类和定位精准度,我们需要一些 trick 来让检测器更多的关注小目标物体的分类和定位是否精准。
修改损失函数,改变深度学习检测器学习大目标物体的偏好,更多的注意和学习小目标物体的特征,从而提高其检测效果。通俗的讲,深度学习每训练完一个批次的图片都会计算此时的模型预测值和真实值之间的差距(通常也叫损失函数),其目的是为了跟据这个差距来更新调整模型的权重值,让模型的预测值更加贴合其本来的真实值,如此多次的循环进行下去,直到这个差距值趋向稳定(我们把这个过程叫模型训练过程也叫模型学习过程)。
根据深度学习的模型训练过程,我们不难理解,适当的使用一些 tricks 来影响损失函数的计算值,可以让模型更多的倾向于将具有某些特征(比如小目标物体)的目标物预测的更精准。
下面我们以 Faster RCNN 的损失函数为例子,简要说明损失函数是如何影响深度学习的学习倾向的。公式(1)中损失函数包括两大部分,第一部分为分类损失(衡量类别是否预测正确),第二部分为位置回归损失(衡量预测目标框是否定位精准)。
根据上面的损失函数计算公式可知, 为了让模型更多的关注小目标,只要提高小目标物体在损失函数中的权重即可。
我们知道衡量小目标的重要标准即目标区域占所在图片的面积是否足够“小”,因此我们只要将目标物的面积大小作为一个权重因子带入损失函数,就可以让损失函数的输出值对预测错误的小目标物体更加敏感。
我们以 YOLOv3 的损失函数为例,损失函数的回归损失部分加入权重因子 2-w*h(w 和 h 分别是目标物体标注的宽和高),让损失函数对小目标物体更加敏感,也意味着模型会更加倾向于学习影响小目标分类和定位的图像信息。
最后说一下为什么要使用大小为 1x1,步长为 2 的卷积核:
-
使用卷积核大小为 1x1 的 卷积操作的优点在于:可以调节和改变通道数目,很方便灵活的控制特征图的深度,实现升维和降维功能; -
选择步长为 2,卷积核大小和 1x1 的卷积层除了可以灵活调节通道的数目之外,还能减少输出 feature map 的尺寸,起到压缩特征图的作用; -
此外,还有减少网络参数、线性组合不同通道上的数据信息,实现非线性操作、让输出结果和输入图像的尺寸无关。

