YOLOF 全称是 You Only Look One-level Feature, 其通过详细的实验指出特征金字塔 FPN 模块的成功在于其对目标优化问题的分治解决方案,而不是我们常说的多尺度特征融合。针对该结论,设计了一个简洁优雅的无需复杂 FPN 的网络结构,仅仅依靠单尺度特征即可取得相匹配的效果,并且具备极快的推理速度,是一个不错的算法。
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 比较敏感。
最后还是要感谢作者 Qiang Chen,在复现过程中解答了一些疑问。通过针对训练不稳定的问题,也指明了可能解决的方案。