(附代码)YOLOF:速度和效果均超过YOLOv4的检测模型
点击左上方蓝字关注我们
作者 | OpenMMLab @知乎
链接 | https://zhuanlan.zhihu.com/p/370758213
摘要
YOLOF 论文核心可以总结如下:
设计了多组实验,深入探讨了 FPN 模块成功的主要因素
基于实验结论,设计了无需 FPN 模块,单尺度简单高效的 Neck 模块 Dilated Encoder
基于 FPN 分治处理多尺度问题,配合 Neck 模块提出 Uniform Matching 正负样本匹配策略
由于不存在复杂且耗内存极多的 FPN 模块,YOLOF 可以在保存高精度的前提下,推理速度快,消耗内存也相对更小
项目地址:github.com/open-mmlab/mmdetection,欢迎 star~
1 FPN 模块分析
首先目标检测算法可以简单按照上述结构进行划分,网络部分主要分为 Backbone、Encoder 和 Decoder,或者按照我们前系列解读文章划分方法分为 Backbone、Neck 和 Head。对于单阶段算法来说,常见的 Backbone 是 ResNet,Encoder 或者 Neck 是 FPN,而 Head 就是对应的输出层结构。
一般我们都认为 FPN 层作用非常大,不可或缺,其通过特征多尺度融合,可以有效解决尺度变换预测问题。而本文认为 FPN 至少有两个主要作用:
多尺度特征融合
分治策略,可以将不同大小的物体分配到不同大小的的输出层上,克服尺度预测问题
作者试图分析上述两个作用中,最核心的部分,故选择最常用的 RetinaNet 进行 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 原理简析
作者为了克服 FPN 存在的内存占用多,速度慢问题,采用了 SiSo 结构,但是精度下降比较严重,从 35.9 变成了 24.6,故后续有针对性的改进,主要包括两个部分: Dilated Encoder 和 Uniform Matching。
2.1 Dilated Encoder
虽然 FPN 的主要作用是分治优化思想,但是多尺度融合也有一定作用,从上述实验也可以看出。并且虽然 C5 提供了足够的上下文,但是其感受野所对应的目标尺寸范围是有限的,无法应对目标检测场景中变化剧烈的目标尺寸。简要理解如上图所示,绿色点表示数据集中的多种目标尺寸,粉红色区域代表特征图能够有效表达的目标尺寸范围
如果仅仅使用 C5 特征,会出现图(a)所示的情况
若使用空洞卷积操作来增大 C5 特征图的感受野,则会出现图(b)所示的情况,感受野变大,能够有效地表达尺寸较大的目标,但是对小目标表达能力会变差
如果采用不同空洞率的叠加,则可以有效避免上述问题
为此,作者设计了 Dilated Encoder 结构,串联多个不同空洞率的模块以覆盖不同大小物体,改善感受野单一问题,如下所示:
对 C5 特征先进行压缩通道,然后串联 4 个不同空洞率的残差模块,从而得到不同感受野的特征图。其实这种做法非常场景,在语义分割算法 ASPP 中和目标检测算法 RFBNet 都采用了类似思想,只不过这两个都是并联结构,而本文是串联, RFBNet 结构如下所示:
2.2 Uniform Matching
前面说过 FPN 的核心功能是分治手段,但是我们知道虽然其输出多个尺度特征图,但是要想发挥分治功能则主要依靠 bbox 正负样本分配策略,也就是说 FPN 和优异的 bbox 正负样本分配策略结合才能最大程度发挥功效,大部分最新的单阶段目标检测算法都在 bbox 分配策略上面做文章,可以借用 AutoAssign 论文中的图说明:
为了充分发挥 FPN 功效,一般会从 scale 和 spatial 两个方面着手进行设计,scale 用于处理不同尺度大小的 gt bbox 应该属于哪些输出层负责,而 spatial 用于处理在某个输出特征图上哪些位置才是最合适的正样本点。不同的 bbox 正负样本分配策略对最终性能影响极大。
一般来说,由于自然场景中,大小物体分布本身就不均匀,并且大物体在图片中所占区域较大,如果不设计好,会导致大物体的正样本数远远多于小物体,最终性能就会偏向大物体,导致整体性能较差。YOLOF 算法采用单尺度特征图输出,锚点的数量会大量的减少(比如从 100K 减少到 5K),导致了稀疏锚点,如果不进行重新设计,会加剧上述现象。为此作者提出了新的均匀匹配策略,核心思想就是不同大小物体都尽量有相同数目的正样本。
所提两个模块的作用如下所示:
Uniform Matching 作用非常大,说明该模块其实发挥了 FPN 的分治作用
Dilated Encoder 配合 Uniform Matching 可以提供额外的变感受野功能,有助于多尺度物体预测
需要特别注意:论文中所描述的 Uniform Matching 和代码中实现的 Uniform Matching 有一定差距,在下一节源码解读时会详细说明。
3 YOLOF 源码解析
和前系列解读一样,依然按照 Backbone、Neck、Head、Bbox Coder、Bbox Assigner 和 Loss 顺序解读。
3.1 Backbone
Backbone 采用了 ResNet50,caffe 模式,和 FCOS 算法配置相同,只不过这里只需要输出 C5 特征图即可,不需要多尺度。
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'),
3.2 Neck
Neck 模块是本文新提出的 Dilated Encoder 模块,包括一个通道压缩模块,然后串联 4 个不同空洞率的残差模块,提供灵活变尺度的感受野。
neck=dict(
type='DilatedEncoder',
in_channels=2048,
out_channels=512,
block_mid_channels=128,
num_residual_blocks=4),
由于比较简单,就不展开分析了。
3.3 Head
Head 模块即上图的 Decoder 结构,包括分类和回归分支,借鉴 AutoAssign 算法,在回归分支上并行引入一个 Objectness 分支,用于抑制背景区域的高响应,然后将其和分类分支相乘。故对外实际上两个分支,Objectness是没有监督 label 的。其 Head forward 如下所示
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
上述得到 normalized_cls_score 的计算过程看起来非常复杂,但是其实是为了能够对融合后的 normalized_cls_score 采用 sigmoid 函数而已,其对应的公式是:
也就是说 normalized_cls_score 是不含 sigmoid 的包括 cls_score 和 objectness 融合的值。可以通过如下简单代码验证:
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 Coder
YOLOF 输出格式采用 RetinaNet 算法中定义的 deltaXYWH ,即回归分支输出的 4 个值表示相对于 anchor 的偏移
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),
只有一个输出特征图,每个位置铺设了 5 个 anchor,宽高比是 1,设置了 5 种 scale。为了稳定训练过程,作者在 DeltaXYWHBBoxCoder 中引入了 add_ctr_clamp 参数即当中心坐标预测相比 anchor 偏离大于 32 个像素,则强制裁剪为 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
前面说过,YOLOF 会引入额外的预测框点作为补充正样本,当 2 次 topk 选择的位置相同时候就会出现意想不到问题。
举个简单例子,当前图片中仅仅有一个 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 并行计算中出现不确定输出
简单来说:indexes 中可能存在重复值,并且重复值位置对应的 pos_gt_index_with_ignore 可能相同也可能不同,注意重复现象可能出现在两个不同类别物体有重叠的情况下,那么上述赋值操作在 CUDA 中是不可预知的,可能会出现同一份数据跑两次输出结果不一样。
这个操作会给后面的回归分支带来歧义,因为回归分支仅仅处理正样本,那么会出现以下几个情况:
如果两个重复索引处对应的 gt bbox 是同一个,那么相当于该 gt bbox 对应的正样本 loss 权重加倍
如果两个重复索引处对应的 gt bbox 不是同一个,那么就会出现歧义,因为特征图上同一个预测点,被同时分配给了两个不同的 gt bbox
总的来说,对于上述重复索引分配现象,会带来几个影响:
读者理解代码运行流程会比较困惑
同一个程序跑多次,可能输出结果不一致
训练过程不稳定
当重复索引出现时候,回归分支 loss 计算过程非常奇怪,难以理解
低版本 CUDA 上会出现非法内存越界错误, 实验发现 CUDA9.0 会出现非法内存越界错误,但是 CUDA10.1 则正常,其余版本没有进行测试
关于第5点,原因暂时不清楚,但是现象是 pos_gt_index_with_ignore、indexes 和 assigned_gt_inds 都不存在越界情况,只不过 indexes 如果存在相同值,在赋值后会出现 4294967295(2^32 -1) 和 -4294967295 (-2^32 +1) 异常值,然后后续基于 assigned_gt_inds 取值后就出现出现 RuntimeError: CUDA error: an illegal memory access was encountered. 经过多次实验发现,错误是必现的。当时也试过其他几个方案,例如 1. 将上述赋值操作放置到 cpu 上进行;2. 将赋值后异常值全部设置为忽略样本,虽然可以避免报错,但是实验结果显示会存在一定程度的掉点,所以最终没有修改。这个问题我们也会持续关注,直到找到一个更加合适的方式以避免上述报错问题。
上述这个写法,给代码复现带来了些问题,并且由于 YOLOF 学习率非常高 lr=0.12,训练过程偶尔会出现 Nan 现象,训练不太稳定,可能对参数设置例如 warmup 比较敏感。
END
整理不易,点赞三连↓