(附代码)YOLOF:速度和效果均超过YOLOv4的检测模型
共 9889字,需浏览 20分钟
·
2021-09-05 10:19
点击左上方蓝字关注我们
文章设计了一个简洁优雅的无需复杂 FPN 的网络结构,仅仅依靠单尺度特征即可取得相匹配的效果,并且具备极快的推理速度,是一个不错的算法。
1、设计了多组实验,深入探讨了 FPN 模块成功的主要因素
2、基于实验结论,设计了无需 FPN 模块,单尺度简单高效的 Neck 模块 Dilated Encoder
3、基于 FPN 分治处理多尺度问题,配合 Neck 模块提出 Uniform Matching 正负样本匹配策略
4、由于不存在复杂且耗内存极多的 FPN 模块,YOLOF 可以在保存高精度的前提下,推理速度快,消耗内存也相对更小
项目地址:github.com/open-mmlab/mmdetection,欢迎 star~
1 FPN 模块分析
首先目标检测算法可以简单按照上述结构进行划分,网络部分主要分为 Backbone、Encoder 和 Decoder,或者按照我们前系列解读文章划分方法分为 Backbone、Neck 和 Head。对于单阶段算法来说,常见的 Backbone 是 ResNet,Encoder 或者 Neck 是 FPN,而 Head 就是对应的输出层结构。
一般我们都认为 FPN 层作用非常大,不可或缺,其通过特征多尺度融合,可以有效解决尺度变换预测问题。而本文认为 FPN 至少有两个主要作用:
多尺度特征融合 分治策略,可以将不同大小的物体分配到不同大小的的输出层上,克服尺度预测问题
对 FPN 模块进一步抽象,如上图所示,可以分成 4 种结构 MiMo、SiMo、MiSo 和 SiSo,其中 MiMo 即为标准的 FPN结构,输入和输出都包括多尺度特征图。
将 FPN 替换为上述 4 个模块,然后基于 RetinaNet 重新训练,计算 mAP 、 GFLOPs 和 FPS 指标
从 mAP 角度分析,SiMo 结果和 MiMo 差距不大,说明 C5 (Backbone 输出)包含了足够的检测不同尺度目标的上下文信息;而 MiSo 和 SiSo 则和 MiMo 差距较大,说明 FPN 分治优化作用远远大于 多尺度特征融合
从下表 GFLOPs 和 FPS 可以看出,MiMo 结构由于存在高分辨率特征图 C3 会带来较大的计算量,并且拖慢速度
FPN 模块的主要增益来自于其分治优化手段,而不是多尺度特征融合
FPN 模块中存在高分辨率特征融合过程,导致消耗内存比较多,训练和推理速度也比较慢,对部署不太优化
如果想在抛弃 FPN 模块的前提下精度不丢失,那么主要问题是提供分治优化替代手段
2 YOLOF 原理简析
如果仅仅使用 C5 特征,会出现图(a)所示的情况
若使用空洞卷积操作来增大 C5 特征图的感受野,则会出现图(b)所示的情况,感受野变大,能够有效地表达尺寸较大的目标,但是对小目标表达能力会变差
如果采用不同空洞率的叠加,则可以有效避免上述问题
一般来说,由于自然场景中,大小物体分布本身就不均匀,并且大物体在图片中所占区域较大,如果不设计好,会导致大物体的正样本数远远多于小物体,最终性能就会偏向大物体,导致整体性能较差。YOLOF 算法采用单尺度特征图输出,锚点的数量会大量的减少(比如从 100K 减少到 5K),导致了稀疏锚点,如果不进行重新设计,会加剧上述现象。为此作者提出了新的均匀匹配策略,核心思想就是不同大小物体都尽量有相同数目的正样本。
所提两个模块的作用如下所示:
Uniform Matching 作用非常大,说明该模块其实发挥了 FPN 的分治作用
Dilated Encoder 配合 Uniform Matching 可以提供额外的变感受野功能,有助于多尺度物体预测
3 YOLOF 源码解析
3.1 BackboneBackbone
pretrained='open-mmlab://detectron/resnet50_caffe',
backbone=dict(
type='ResNet',
depth=50,
num_stages=4,
out_indices=(3, ),
frozen_stages=1,
norm_cfg=dict(type='BN', requires_grad=False),
norm_eval=True,
style='caffe'),
neck=dict(
type='DilatedEncoder',
in_channels=2048,
out_channels=512,
block_mid_channels=128,
num_residual_blocks=4),
3.3 Head
def forward_single(self, feature):
# 分类分支
cls_score = self.cls_score(self.cls_subnet(feature))
N, _, H, W = cls_score.shape
cls_score = cls_score.view(N, -1, self.num_classes, H, W)
# 回归分支
reg_feat = self.bbox_subnet(feature)
bbox_reg = self.bbox_pred(reg_feat)
objectness = self.object_pred(reg_feat)
# implicit objectness
objectness = objectness.view(N, -1, 1, H, W)
normalized_cls_score = cls_score + objectness - torch.log(
1. + torch.clamp(cls_score.exp(), max=INF) +
torch.clamp(objectness.exp(), max=INF))
normalized_cls_score = normalized_cls_score.view(N, -1, H, W)
return normalized_cls_score, bbox_reg
import torch
if __name__ == '__main__':
INF = 1e8
N = 1
num_classes = 2
H = W = 3
cls_score = torch.rand((N, 1, num_classes, H, W))
objectness = torch.rand(N, 1, 1, H, W)
normalized_cls_score = cls_score + objectness - torch.log(
1. + torch.clamp(cls_score.exp(), max=INF) +
torch.clamp(objectness.exp(), max=INF))
cls_score_s = torch.sigmoid(cls_score) * torch.sigmoid(objectness)
assert torch.allclose(cls_score_s, torch.sigmoid(normalized_cls_score))
3.4 Bbox
anchor_generator=dict(
type='AnchorGenerator',
ratios=[1.0],
scales=[1, 2, 4, 8, 16],
strides=[32]),
bbox_coder=dict(
type='DeltaXYWHBBoxCoder',
target_means=[.0, .0, .0, .0],
target_stds=[1., 1., 1., 1.],
add_ctr_clamp=True,
ctr_clamp=32),
3.5 Bbox Assigner
这个部分是 YOLOF 的核心,需要重点分析。首先分析论文中描述,然后再基于代码说明代码和论文的差异。论文中描述的非常简单,核心目的是保证不同尺度物体都尽可能有相同数目的正样本
遍历每个 gt bbox,然后选择 topk 个距离最近的 anchor 作为其匹配的正样本
由于存在极端比例物体和小物体,上述强制 topk 操作可能出现 anchor 和 gt bbox 的不匹配现象,为了防止噪声样本影响,在所有正样本点中,将 anchor 和 gt bbox 的 iou 低于 0.15 的正样本(因为不管匹配情况,topk 都会选择出指定数目的正样本)强制认为是忽略样本,在所有负样本点中,将 anchor 和 gt bbox 的 iou 高于 0.75 的负样本(可能该物体比较大,导致很多 anchor 都能够和该 gt bbox 很好的匹配,这些样本就不适合作为负样本了)强制认为是忽略样本
实际上作者代码的写法如下所示
遍历每个 gt bbox,然后选择 topk 个距离最近的 anchor 作为其匹配的正样本
遍历每个 gt bbox,然后选择 topk 个距离最近的预测框作为补充的匹配正样本
计算 gt bbox 和预测框的 iou,在所有负样本点中,将 iou 高于 0.75 的负样本强制认为是忽略样本
计算 gt bbox 和 anchor 的 iou,在所有正样本点中,将 iou 低于 0.15 的正样本强制认为是忽略样本
可以发现相比于论文描述,实际上代码额外动态补充了一定量的正样本,同时也额外考虑了一些忽略样本。相比于纯粹采用 anchor 和 gt bbox 进行匹配,额外引入预测框,可以动态调整正负样本,理论上会更好。
# 全部任务是负样本
assigned_gt_inds = bbox_pred.new_full((num_bboxes, ),
0,
dtype=torch.long)
# 计算两两直接的距离,包括 预测框和 gt bbox,以及 anchor 和 gt bbox
cost_bbox = torch.cdist(
bbox_xyxy_to_cxcywh(bbox_pred),
bbox_xyxy_to_cxcywh(gt_bboxes),
p=1)
cost_bbox_anchors = torch.cdist(
bbox_xyxy_to_cxcywh(anchor), bbox_xyxy_to_cxcywh(gt_bboxes), p=1)
# 分别提取 topk 个样本点作为正样本,此时正样本数会加倍
index = torch.topk(
C,
k=self.match_times,
dim=0,
largest=False)[1]
# self.match_times x n
index1 = torch.topk(C1, k=self.match_times, dim=0, largest=False)[1]
# (self.match_times*2) x n
indexes = torch.cat((index, index1),
dim=1).reshape(-1).to(bbox_pred.device)
# 计算 iou 矩阵
pred_overlaps = self.iou_calculator(bbox_pred, gt_bboxes)
anchor_overlaps = self.iou_calculator(anchor, gt_bboxes)
pred_max_overlaps, _ = pred_overlaps.max(dim=1)
anchor_max_overlaps, _ = anchor_overlaps.max(dim=0)
# 计算 gt bbox 和预测框的 iou,在所有负样本点中,将 iou 高于 0.75 的负样本强制认为是忽略样本
ignore_idx = pred_max_overlaps > self.neg_ignore_thr
assigned_gt_inds[ignore_idx] = -1
# 计算 gt bbox 和 anchor 的 iou,在所有正样本点中,将 iou 低于 0.15 的正样本强制认为是忽略样本
pos_gt_index = torch.arange(
0, C1.size(1),
device=bbox_pred.device).repeat(self.match_times * 2)
pos_ious = anchor_overlaps[indexes, pos_gt_index]
pos_ignore_idx = pos_ious < self.pos_ignore_thr
pos_gt_index_with_ignore = pos_gt_index + 1
pos_gt_index_with_ignore[pos_ignore_idx] = -1
assigned_gt_inds[indexes] = pos_gt_index_with_ignore
3.6 Loss
在确定了每个特征点位置哪些是正样本和负样本后,就可以计算 loss 了,分类采用 focal loss,回归采用 giou loss,都是常规操作。
loss_cls=dict(
type='FocalLoss',
use_sigmoid=True,
gamma=2.0,
alpha=0.25,
loss_weight=1.0),
loss_bbox=dict(type='GIoULoss', loss_weight=1.0))
上述就是整个 YOLOF 核心实现过程。至于推理过程和 RetinaNet 算法完全相同。
4 YOLOF 复现心得和体会
如果不仔细思考,可能看不出上述代码有啥问题,实际上在 Bbox Assigner 环节会存在重复索引分配问题,这个问题会带来几个影响。具体代码是:
# 对应 3.5 小节的源码分析第 44 行
assigned_gt_inds[indexes] = pos_gt_index_with_ignore
举个简单例子,当前图片中仅仅有一个 gt bbox,且预测输出特征图大小是 10x10,设置 anchor 个数是 1,那么说明输出特征图上只有 10x10 个anchor,并且对应了 10x10 个预测框,topk 设置为 4
计算该 gt bbox 和 100 个 anchor 的距离,然后选择最近的前 4 个位置作为正样本
计算该 gt bbox 和 100 个预测框的距离,然后选择最近的前 4 个位置作为正样本,注意这里选择的 4个位置很可能和前面选择的 4 个位置有重复
计算该 gt bbox 和预测框的 iou,在所有负样本点中,将 iou 高于 0.75 的负样本强制认为是忽略样本
计算该 gt bbox 和 anchor 的 iou,在所有正样本点中,将 iou 低于 0.15 的正样本强制认为是忽略样本,注意和上一步的区别,由于 iou 计算的输入是不一样的,可能导致某个被重复计算的正样本位置出现 2 种情况:1. 两个步骤都认为是忽略样本;2. 一个认为是忽略样本,一个认为是正样本,而一旦出现第二种情况则在 CUDA 并行计算中出现不确定输出
如果两个重复索引处对应的 gt bbox 是同一个,那么相当于该 gt bbox 对应的正样本 loss 权重加倍
如果两个重复索引处对应的 gt bbox 不是同一个,那么就会出现歧义,因为特征图上同一个预测点,被同时分配给了两个不同的 gt bbox
读者理解代码运行流程会比较困惑
同一个程序跑多次,可能输出结果不一致
训练过程不稳定
当重复索引出现时候,回归分支 loss 计算过程非常奇怪,难以理解
低版本 CUDA 上会出现非法内存越界错误, 实验发现 CUDA9.0 会出现非法内存越界错误,但是 CUDA10.1 则正常,其余版本没有进行测试
上述这个写法,给代码复现带来了些问题,并且由于 YOLOF 学习率非常高 lr=0.12,训练过程偶尔会出现 Nan 现象,训练不太稳定,可能对参数设置例如 warmup 比较敏感。
最后还是要感谢作者 Qiang Chen,在复现过程中解答了一些疑问。通过针对训练不稳定的问题,也指明了可能解决的方案。
END
点赞三连,支持一下吧↓