关于如何改进YOLOv3进行红外小目标检测?
- 对于提高效果可以做出努力的方向
- 1. 对数据集进行统计
- 2.修改anchor
- 3. 构建Baseline
- 4.数据集部分改进
- ①过采样
- ②在图片中任意位置复制小目标
- 5.修改Backbone
- ①注意力模块
- ②即插即用模块
- ③ 修改FPN
- ④修改激活函数
- ⑤用成熟的网络替换backbone
- ⑥ SPP系列
- 6.修改Loss
- 经验性总结
- 参考网址
最近看了一篇关于别人是如何改进yolov3的文章我们是如何改进YOLOv3进行红外小目标检测的? ,对里面的方法做一下记录和总结。
对于提高效果可以做出努力的方向
1. 对数据集进行统计
以及通过人工翻看的方式总结其特点,了解其图像的内容和数量 ,如:
2.修改anchor
红外小目标的Anchor和COCO等数据集的Anchor是差距很大的,为了更好更快速的收敛,采用了BBuf总结的一套专门计算Anchor的脚本
#coding=utf-8
import xml.etree.ElementTree as ET
import numpy as np
def iou(box, clusters):
"""
计算一个ground truth边界盒和k个先验框(Anchor)的交并比(IOU)值。
参数box: 元组或者数据,代表ground truth的长宽。
参数clusters: 形如(k,2)的numpy数组,其中k是聚类Anchor框的个数
返回:ground truth和每个Anchor框的交并比。
"""
x = np.minimum(clusters[:, 0], box[0])
y = np.minimum(clusters[:, 1], box[1])
if np.count_nonzero(x == 0) > 0 or np.count_nonzero(y == 0) > 0:
raise ValueError("Box has no area")
intersection = x * y
box_area = box[0] * box[1]
cluster_area = clusters[:, 0] * clusters[:, 1]
iou_ = intersection / (box_area + cluster_area - intersection)
return iou_
def avg_iou(boxes, clusters):
"""
计算一个ground truth和k个Anchor的交并比的均值。
"""
return np.mean([np.max(iou(boxes[i], clusters)) for i in range(boxes.shape[0])])
def kmeans(boxes, k, dist=np.median):
"""
利用IOU值进行K-means聚类
参数boxes: 形状为(r, 2)的ground truth框,其中r是ground truth的个数
参数k: Anchor的个数
参数dist: 距离函数
返回值:形状为(k, 2)的k个Anchor框
"""
# 即是上面提到的r
rows = boxes.shape[0]
# 距离数组,计算每个ground truth和k个Anchor的距离
distances = np.empty((rows, k))
# 上一次每个ground truth"距离"最近的Anchor索引
last_clusters = np.zeros((rows,))
# 设置随机数种子
np.random.seed()
# 初始化聚类中心,k个簇,从r个ground truth随机选k个
clusters = boxes[np.random.choice(rows, k, replace=False)]
# 开始聚类
while True:
# 计算每个ground truth和k个Anchor的距离,用1-IOU(box,anchor)来计算
for row in range(rows):
distances[row] = 1 - iou(boxes[row], clusters)
# 对每个ground truth,选取距离最小的那个Anchor,并存下索引
nearest_clusters = np.argmin(distances, axis=1)
# 如果当前每个ground truth"距离"最近的Anchor索引和上一次一样,聚类结束
if (last_clusters == nearest_clusters).all():
break
# 更新簇中心为簇里面所有的ground truth框的均值
for cluster in range(k):
clusters[cluster] = dist(boxes[nearest_clusters == cluster], axis=0)
# 更新每个ground truth"距离"最近的Anchor索引
last_clusters = nearest_clusters
return clusters
# 加载自己的数据集,只需要所有labelimg标注出来的xml文件即可
def load_dataset(path):
dataset = []
for xml_file in glob.glob("{}/*xml".format(path)):
tree = ET.parse(xml_file)
# 图片高度
height = int(tree.findtext("./size/height"))
# 图片宽度
width = int(tree.findtext("./size/width"))
for obj in tree.iter("object"):
# 偏移量
xmin = int(obj.findtext("bndbox/xmin")) / width
ymin = int(obj.findtext("bndbox/ymin")) / height
xmax = int(obj.findtext("bndbox/xmax")) / width
ymax = int(obj.findtext("bndbox/ymax")) / height
xmin = np.float64(xmin)
ymin = np.float64(ymin)
xmax = np.float64(xmax)
ymax = np.float64(ymax)
if xmax == xmin or ymax == ymin:
print(xml_file)
# 将Anchor的长宽放入dateset,运行kmeans获得Anchor
dataset.append([xmax - xmin, ymax - ymin])
return np.array(dataset)
if __name__ == '__main__':
ANNOTATIONS_PATH = "F:\Annotations" #xml文件所在文件夹
CLUSTERS = 9 #聚类数量,anchor数量
INPUTDIM = 416 #输入网络大小
data = load_dataset(ANNOTATIONS_PATH)
out = kmeans(data, k=CLUSTERS)
print('Boxes:')
print(np.array(out)*INPUTDIM)
print("Accuracy: {:.2f}%".format(avg_iou(data, out) * 100))
final_anchors = np.around(out[:, 0] / out[:, 1], decimals=2).tolist()
print("Before Sort Ratios:\n {}".format(final_anchors))
print("After Sort Ratios:\n {}".format(sorted(final_anchors)))
3. 构建Baseline
在决定开始改进模型算法时,要了解自己改动之后的模型算法的效果如何自离不开一个可靠、可以参照和对比的标杆,这个标杆就是我们要设立的我们自己的一个baseline。之后做出的各种改进效果与baseline做对比,就可以知道模型的好坏了。
4.数据集部分改进
①过采样
采用过采样的方法,这里的过采样可能和别的地方的不太一样,这里指的是将某些背景数量小的图片通过复制的方式扩充。
②在图片中任意位置复制小目标
具体实现思路就是,先将所有小目标抠出来备用。然后在图像上复制这些小目标,要求两两之间重合率不能达到一个阈值并且复制的位置不能超出图像边界。
5.修改Backbone
修改Backbone经常被群友问到这样一件事,修改骨干网络以后无法加载预训练权重了,怎么办?有以下几个办法:
- 干脆不加载,从头训练,简单问题(比如红外小目标)从头收敛效果也不次于有预训练权重的。
- 不想改代码的话,可以选择修改Backbone之后、YOLO Head之前的部分(比如SPP的位置属于这种情况)
- 能力比较强的,可以改一下模型加载部分代码,跳过你新加入的模块,这样也能加载(笔者没试过,别找我)。
修改Backbone我们也从几个方向入的手,分为注意力模块、即插即用模块、修改FPN、修改激活函数、用成熟的网络替换backbone和SPP系列。
①注意力模块
笔者前一段时间公布了一个电子书《卷积神经网络中的即插即用模块》也是因为这个项目中总结了很多注意力模块,所以开始整理得到的结果。具体模块还在继续更新:https://github.com/pprp/SimpleCVReproduction
当时实验的模块有:SE、CBAM等,由于当时Baseline有点高,效果并不十分理想。(注意力模块插进来不可能按照预期一下就提高多少百分点,需要多调参才有可能超过原来的百分点)
根据群友反馈,SE直接插入成功率比较高。笔者在一个目标检测比赛中见到有一个大佬是在YOLOv3的FPN的三个分支上各加了一个CBAM,最终超过Cascade R-CNN等模型夺得冠军。
②即插即用模块
注意力模块也属于即插即用模块,这部分就说的是非注意力模块的部分如 FFM、ASPP、PPM、Dilated Conv、SPP、FRB、CorNerPool、DwConv、ACNet等,效果还可以,但是没有超过当前最好的结果。
③ 修改FPN
FPN这方面花了老久时间,参考了好多版本才搞出了一个dt-6a-bifpn(dt代表dim target红外目标;6a代表6个anchor),令人失望的是,这个BiFPN效果并不好,测试集上效果更差了。可能是因为实现的cfg有问题。
大家都知道通过改cfg的方式改网络结构是一件很痛苦的事情,推荐一个可视化工具:
https://lutzroeder.github.io/netron/
除此以外,为了方便查找行数,笔者写了一个简单脚本用于查找行数(献丑了)
import os
import shutil
cfg_path = "./cfg/yolov3-dwconv-cbam.cfg"
save_path = "./cfg/preprocess_cfg/"
new_save_name = os.path.join(save_path,os.path.basename(cfg_path))
f = open(cfg_path, 'r')
lines = f.readlines()
# 去除以#开头的,属于注释部分的内容
# lines = [x for x in lines if x and not x.startswith('#')]
# lines = [x.rstrip().lstrip() for x in lines]
lines_nums = []
layers_nums = []
layer_cnt = -1
for num, line in enumerate(lines):
if line.startswith('['):
layer_cnt += 1
layers_nums.append(layer_cnt)
lines_nums.append(num+layer_cnt)
print(line)
# s = s.join("")
# s = s.join(line)
for i,num in enumerate(layers_nums):
print(lines_nums[i], num)
lines.insert(lines_nums[i]-1, '# layer-%d\n' % (num-1))
fo = open(new_save_name, 'w')
fo.write(''.join(lines))
fo.close()
f.close()
我们也尝试了只用一个、两个和三个YOLO Head的情况,结果是3>2>1,但是用3个和2个效果几乎一样,差异不大小数点后3位的差异,所以还是选用两个YOLO Head。
④修改激活函数
YOLO默认使用的激活函数是leaky relu,激活函数方面使用了mish。效果并没有提升,所以无疾而终了。
⑤用成熟的网络替换backbone
这里使用了ResNet10(第三方实现)、DenseNet、BBuf修改的DenseNet、ENet、VOVNet(自己改的)、csresnext50-panet(当时AB版darknet提供的)、PRN(作用不大)等网络结构。
当前最强的网络是dense-v3-tiny-spp,也就是BBuf修改的Backbone+原汁原味的SPP组合的结构完虐了其他模型,在测试集上达到了mAP@0.5=0.932、F1=0.951的结果。
⑥ SPP系列
这个得好好说说,我们三人调研了好多论文、参考了好多trick,大部分都无效,其中从来不会让人失望的模块就是SPP。我们对SPP进行了深入研究,在《卷积神经网络中的各种池化操作》中提到过。
SPP是在SPPNet中提出的,SPPNet提出比较早,在RCNN之后提出的,用于解决重复卷积计算和固定输出的两个问题,具体方法如下图所示:
在feature map上通过selective search获得窗口,然后将这些区域输入到CNN中,然后进行分类。
实际上SPP就是多个空间池化的组合,对不同输出尺度采用不同的划窗大小和步长以确保输出尺度相同,同时能够融合金字塔提取出的多种尺度特征,能够提取更丰富的语义信息。常用于多尺度训练和目标检测中的RPN网络。
在YOLOv3中有一个网络结构叫yolov3-spp.cfg, 这个网络往往能达到比yolov3.cfg本身更高的准确率,具体cfg如下:
### SPP ###
[maxpool]
stride=1
size=5
[route]
layers=-2
[maxpool]
stride=1
size=9
[route]
layers=-4
[maxpool]
stride=1
size=13
[route]
layers=-1,-3,-5,-6
### End SPP ###
这里的SPP相当于是原来的SPPNet的变体,通过使用多个kernel size的maxpool,最终将所有feature map进行concate,得到新的特征组合。
再来看一下官方提供的yolov3和yolov3-spp在COCO数据集上的对比:
可以看到,在几乎不增加FLOPS的情况下,YOLOv3-SPP要比YOLOv3-608mAP高接近3个百分点。
分析一下SPP有效的原因:
从感受野角度来讲,之前计算感受野的时候可以明显发现,maxpool的操作对感受野的影响非常大,其中主要取决于kernel size大小。在SPP中,使用了kernel size非常大的maxpool会极大提高模型的感受野,笔者没有详细计算过darknet53这个backbone的感受野,在COCO上有效很可能是因为backbone的感受野还不够大。
第二个角度是从Attention的角度考虑,这一点启发自@小楞,他在文章中这样讲:
出现检测效果提升的原因: 通过spp模块实现局部特征和全局特征(所以空间金字塔池化结构的最大的池化核要尽可能的接近等于需要池化的featherMap的大小)的featherMap级别的融合,丰富最终特征图的表达能力,从而提高MAP。
Attention机制很多都是为了解决远距离依赖问题,通过使用kernel size接近特征图的size可以以比较小的计算代价解决这个问题。另外就是如果使用了SPP模块,就没有必要在SPP后继续使用其他空间注意力模块比如SK block,因为他们作用相似,可能会有一定冗余。
在本实验中,确实也得到了一个很重要的结论,那就是:
SPP是有效的,其中size的设置应该接近这一层的feature map的大小
当前的feature map大小就是13x13,实验结果表示,直接使用13x13的效果和SPP的几乎一样,运算量还减少了。
6.修改Loss
loss方面尝试了focal loss,但是经过调整alpha和beta两个参数,不管用默认的还是自己慢慢调参,网络都无法收敛,所以当时给作者提了一个issue: https://github.com/ultralytics/yolov3/issues/811
glenn-jocher说效果不好就别用:(
经验性总结
在这个实验过程中,和BBuf讨论有了很多启发,也进行了总结,在这里公开出来,(可能部分结论不够严谨,没有经过严格对比实验,感兴趣的话可以做一下对比实验)。
SPP层是有效的,Size设置接近feature map的时候效果更好。
YOLOv3、YOLOv3-SPP、YOLOv3-tiny三者在检测同一个物体的情况下,YOLOv3-tiny给的该物体的置信度相比其他两个模型低。(其实也可以形象化理解,YOLOv3-tiny的脑容量比较小,所以唯唯诺诺不敢确定)
个人感觉Concate的方法要比Add的方法更柔和,对小目标效果更好。本实验结果上是DenseNet作为Backbone的时候效果是最佳的。
多尺度训练问题,这个文中没提。多尺度训练对于尺度分布比较广泛的问题效果明显,比如VOC这类数据集。但是对于尺度单一的数据集反而有反作用,比如红外小目标数据集目标尺度比较统一,都很小。
Anchor对模型影响比较大,Anchor先验不合理会导致更多的失配,从而降低Recall。
当时跟群友讨论的时候就提到一个想法,对于小目标来说,浅层的信息更加有用,那么进行FPN的时候,不应该单纯将两者进行Add或者Concate,而是应该以一定的比例完成,比如对于小目标来说,引入更多的浅层信息,让浅层网络权重增大;大目标则相反。后边通过阅读发现,这个想法被ASFF实现了,而且想法比较完善。
PyTorch中的Upsample层是不可复现的。