(附代碼)YOLOF:速度和效果均超過YOLOv4的檢測模型
點(diǎn)擊左上方藍(lán)字關(guān)注我們

作者 | OpenMMLab @知乎
鏈接 | https://zhuanlan.zhihu.com/p/370758213
摘要
YOLOF 論文核心可以總結(jié)如下:
設(shè)計(jì)了多組實(shí)驗(yàn),深入探討了 FPN 模塊成功的主要因素
基于實(shí)驗(yàn)結(jié)論,設(shè)計(jì)了無需 FPN 模塊,單尺度簡單高效的 Neck 模塊 Dilated Encoder
基于 FPN 分治處理多尺度問題,配合 Neck 模塊提出 Uniform Matching 正負(fù)樣本匹配策略
由于不存在復(fù)雜且耗內(nèi)存極多的 FPN 模塊,YOLOF 可以在保存高精度的前提下,推理速度快,消耗內(nèi)存也相對更小
項(xiàng)目地址:github.com/open-mmlab/mmdetection,歡迎 star~
1 FPN 模塊分析

首先目標(biāo)檢測算法可以簡單按照上述結(jié)構(gòu)進(jìn)行劃分,網(wǎng)絡(luò)部分主要分為 Backbone、Encoder 和 Decoder,或者按照我們前系列解讀文章劃分方法分為 Backbone、Neck 和 Head。對于單階段算法來說,常見的 Backbone 是 ResNet,Encoder 或者 Neck 是 FPN,而 Head 就是對應(yīng)的輸出層結(jié)構(gòu)。
一般我們都認(rèn)為 FPN 層作用非常大,不可或缺,其通過特征多尺度融合,可以有效解決尺度變換預(yù)測問題。而本文認(rèn)為 FPN 至少有兩個主要作用:
多尺度特征融合
分治策略,可以將不同大小的物體分配到不同大小的的輸出層上,克服尺度預(yù)測問題
作者試圖分析上述兩個作用中,最核心的部分,故選擇最常用的 RetinaNet 進(jìn)行 FPN 模塊深入分析。

對 FPN 模塊進(jìn)一步抽象,如上圖所示,可以分成 4 種結(jié)構(gòu) MiMo、SiMo、MiSo 和 SiSo,其中 MiMo 即為標(biāo)準(zhǔn)的 FPN結(jié)構(gòu),輸入和輸出都包括多尺度特征圖。將 FPN 替換為上述 4 個模塊,然后基于 RetinaNet 重新訓(xùn)練,計(jì)算 mAP 、 GFLOPs 和 FPS 指標(biāo)
從 mAP 角度分析,SiMo 結(jié)果和 MiMo 差距不大,說明 C5 (Backbone 輸出)包含了足夠的檢測不同尺度目標(biāo)的上下文信息;而 MiSo 和 SiSo 則和 MiMo 差距較大,說明 FPN 分治優(yōu)化作用遠(yuǎn)遠(yuǎn)大于 多尺度特征融合
從下表 GFLOPs 和 FPS 可以看出,MiMo 結(jié)構(gòu)由于存在高分辨率特征圖 C3 會帶來較大的計(jì)算量,并且拖慢速度
綜上所示,可以得到一些結(jié)論:
FPN 模塊的主要增益來自于其分治優(yōu)化手段,而不是多尺度特征融合
FPN 模塊中存在高分辨率特征融合過程,導(dǎo)致消耗內(nèi)存比較多,訓(xùn)練和推理速度也比較慢,對部署不太優(yōu)化
如果想在拋棄 FPN 模塊的前提下精度不丟失,那么主要問題是提供分治優(yōu)化替代手段
2 YOLOF 原理簡析
作者為了克服 FPN 存在的內(nèi)存占用多,速度慢問題,采用了 SiSo 結(jié)構(gòu),但是精度下降比較嚴(yán)重,從 35.9 變成了 24.6,故后續(xù)有針對性的改進(jìn),主要包括兩個部分: Dilated Encoder 和 Uniform Matching。
2.1 Dilated Encoder

雖然 FPN 的主要作用是分治優(yōu)化思想,但是多尺度融合也有一定作用,從上述實(shí)驗(yàn)也可以看出。并且雖然 C5 提供了足夠的上下文,但是其感受野所對應(yīng)的目標(biāo)尺寸范圍是有限的,無法應(yīng)對目標(biāo)檢測場景中變化劇烈的目標(biāo)尺寸。簡要理解如上圖所示,綠色點(diǎn)表示數(shù)據(jù)集中的多種目標(biāo)尺寸,粉紅色區(qū)域代表特征圖能夠有效表達(dá)的目標(biāo)尺寸范圍
如果僅僅使用 C5 特征,會出現(xiàn)圖(a)所示的情況
若使用空洞卷積操作來增大 C5 特征圖的感受野,則會出現(xiàn)圖(b)所示的情況,感受野變大,能夠有效地表達(dá)尺寸較大的目標(biāo),但是對小目標(biāo)表達(dá)能力會變差
如果采用不同空洞率的疊加,則可以有效避免上述問題
為此,作者設(shè)計(jì)了 Dilated Encoder 結(jié)構(gòu),串聯(lián)多個不同空洞率的模塊以覆蓋不同大小物體,改善感受野單一問題,如下所示:

對 C5 特征先進(jìn)行壓縮通道,然后串聯(lián) 4 個不同空洞率的殘差模塊,從而得到不同感受野的特征圖。其實(shí)這種做法非常場景,在語義分割算法 ASPP 中和目標(biāo)檢測算法 RFBNet 都采用了類似思想,只不過這兩個都是并聯(lián)結(jié)構(gòu),而本文是串聯(lián), RFBNet 結(jié)構(gòu)如下所示:

2.2 Uniform Matching
前面說過 FPN 的核心功能是分治手段,但是我們知道雖然其輸出多個尺度特征圖,但是要想發(fā)揮分治功能則主要依靠 bbox 正負(fù)樣本分配策略,也就是說 FPN 和優(yōu)異的 bbox 正負(fù)樣本分配策略結(jié)合才能最大程度發(fā)揮功效,大部分最新的單階段目標(biāo)檢測算法都在 bbox 分配策略上面做文章,可以借用 AutoAssign 論文中的圖說明:

為了充分發(fā)揮 FPN 功效,一般會從 scale 和 spatial 兩個方面著手進(jìn)行設(shè)計(jì),scale 用于處理不同尺度大小的 gt bbox 應(yīng)該屬于哪些輸出層負(fù)責(zé),而 spatial 用于處理在某個輸出特征圖上哪些位置才是最合適的正樣本點(diǎn)。不同的 bbox 正負(fù)樣本分配策略對最終性能影響極大。
一般來說,由于自然場景中,大小物體分布本身就不均勻,并且大物體在圖片中所占區(qū)域較大,如果不設(shè)計(jì)好,會導(dǎo)致大物體的正樣本數(shù)遠(yuǎn)遠(yuǎn)多于小物體,最終性能就會偏向大物體,導(dǎo)致整體性能較差。YOLOF 算法采用單尺度特征圖輸出,錨點(diǎn)的數(shù)量會大量的減少(比如從 100K 減少到 5K),導(dǎo)致了稀疏錨點(diǎn),如果不進(jìn)行重新設(shè)計(jì),會加劇上述現(xiàn)象。為此作者提出了新的均勻匹配策略,核心思想就是不同大小物體都盡量有相同數(shù)目的正樣本。

所提兩個模塊的作用如下所示:

Uniform Matching 作用非常大,說明該模塊其實(shí)發(fā)揮了 FPN 的分治作用
Dilated Encoder 配合 Uniform Matching 可以提供額外的變感受野功能,有助于多尺度物體預(yù)測
需要特別注意:論文中所描述的 Uniform Matching 和代碼中實(shí)現(xiàn)的 Uniform Matching 有一定差距,在下一節(jié)源碼解讀時會詳細(xì)說明。
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 模塊,包括一個通道壓縮模塊,然后串聯(lián) 4 個不同空洞率的殘差模塊,提供靈活變尺度的感受野。
neck=dict(
type='DilatedEncoder',
in_channels=2048,
out_channels=512,
block_mid_channels=128,
num_residual_blocks=4),由于比較簡單,就不展開分析了。
3.3 Head

Head 模塊即上圖的 Decoder 結(jié)構(gòu),包括分類和回歸分支,借鑒 AutoAssign 算法,在回歸分支上并行引入一個 Objectness 分支,用于抑制背景區(qū)域的高響應(yīng),然后將其和分類分支相乘。故對外實(shí)際上兩個分支,Objectness是沒有監(jiān)督 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 的計(jì)算過程看起來非常復(fù)雜,但是其實(shí)是為了能夠?qū)θ诤虾蟮?normalized_cls_score 采用 sigmoid 函數(shù)而已,其對應(yīng)的公式是:

也就是說 normalized_cls_score 是不含 sigmoid 的包括 cls_score 和 objectness 融合的值??梢酝ㄟ^如下簡單代碼驗(yàn)證:
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),只有一個輸出特征圖,每個位置鋪設(shè)了 5 個 anchor,寬高比是 1,設(shè)置了 5 種 scale。為了穩(wěn)定訓(xùn)練過程,作者在 DeltaXYWHBBoxCoder 中引入了 add_ctr_clamp 參數(shù)即當(dāng)中心坐標(biāo)預(yù)測相比 anchor 偏離大于 32 個像素,則強(qiáng)制裁剪為 32,防止產(chǎn)生較大的梯度。
3.5 Bbox Assigner
這個部分是 YOLOF 的核心,需要重點(diǎn)分析。首先分析論文中描述,然后再基于代碼說明代碼和論文的差異。
論文中描述的非常簡單,核心目的是保證不同尺度物體都盡可能有相同數(shù)目的正樣本
遍歷每個 gt bbox,然后選擇 topk 個距離最近的 anchor 作為其匹配的正樣本
由于存在極端比例物體和小物體,上述強(qiáng)制 topk 操作可能出現(xiàn) anchor 和 gt bbox 的不匹配現(xiàn)象,為了防止噪聲樣本影響,在所有正樣本點(diǎn)中,將 anchor 和 gt bbox 的 iou 低于 0.15 的正樣本(因?yàn)椴还芷ヅ淝闆r,topk 都會選擇出指定數(shù)目的正樣本)強(qiáng)制認(rèn)為是忽略樣本,在所有負(fù)樣本點(diǎn)中,將 anchor 和 gt bbox 的 iou 高于 0.75 的負(fù)樣本(可能該物體比較大,導(dǎo)致很多 anchor 都能夠和該 gt bbox 很好的匹配,這些樣本就不適合作為負(fù)樣本了)強(qiáng)制認(rèn)為是忽略樣本
實(shí)際上作者代碼的寫法如下所示
遍歷每個 gt bbox,然后選擇 topk 個距離最近的 anchor 作為其匹配的正樣本
遍歷每個 gt bbox,然后選擇 topk 個距離最近的預(yù)測框作為補(bǔ)充的匹配正樣本
計(jì)算 gt bbox 和預(yù)測框的 iou,在所有負(fù)樣本點(diǎn)中,將 iou 高于 0.75 的負(fù)樣本強(qiáng)制認(rèn)為是忽略樣本
計(jì)算 gt bbox 和 anchor 的 iou,在所有正樣本點(diǎn)中,將 iou 低于 0.15 的正樣本強(qiáng)制認(rèn)為是忽略樣本
可以發(fā)現(xiàn)相比于論文描述,實(shí)際上代碼額外動態(tài)補(bǔ)充了一定量的正樣本,同時也額外考慮了一些忽略樣本。相比于純粹采用 anchor 和 gt bbox 進(jìn)行匹配,額外引入預(yù)測框,可以動態(tài)調(diào)整正負(fù)樣本,理論上會更好。
# 全部任務(wù)是負(fù)樣本
assigned_gt_inds = bbox_pred.new_full((num_bboxes, ),
0,
dtype=torch.long)
# 計(jì)算兩兩直接的距離,包括 預(yù)測框和 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 個樣本點(diǎn)作為正樣本,此時正樣本數(shù)會加倍
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)
# 計(jì)算 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)
# 計(jì)算 gt bbox 和預(yù)測框的 iou,在所有負(fù)樣本點(diǎn)中,將 iou 高于 0.75 的負(fù)樣本強(qiáng)制認(rèn)為是忽略樣本
ignore_idx = pred_max_overlaps > self.neg_ignore_thr
assigned_gt_inds[ignore_idx] = -1
# 計(jì)算 gt bbox 和 anchor 的 iou,在所有正樣本點(diǎn)中,將 iou 低于 0.15 的正樣本強(qiáng)制認(rèn)為是忽略樣本
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
在確定了每個特征點(diǎn)位置哪些是正樣本和負(fù)樣本后,就可以計(jì)算 loss 了,分類采用 focal loss,回歸采用 giou loss,都是常規(guī)操作。
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 核心實(shí)現(xiàn)過程。至于推理過程和 RetinaNet 算法完全相同。
4 YOLOF 復(fù)現(xiàn)心得和體會
如果不仔細(xì)思考,可能看不出上述代碼有啥問題,實(shí)際上在 Bbox Assigner 環(huán)節(jié)會存在重復(fù)索引分配問題,這個問題會帶來幾個影響。具體代碼是:
# 對應(yīng) 3.5 小節(jié)的源碼分析第 44 行
assigned_gt_inds[indexes] = pos_gt_index_with_ignore前面說過,YOLOF 會引入額外的預(yù)測框點(diǎn)作為補(bǔ)充正樣本,當(dāng) 2 次 topk 選擇的位置相同時候就會出現(xiàn)意想不到問題。
舉個簡單例子,當(dāng)前圖片中僅僅有一個 gt bbox,且預(yù)測輸出特征圖大小是 10x10,設(shè)置 anchor 個數(shù)是 1,那么說明輸出特征圖上只有 10x10 個anchor,并且對應(yīng)了 10x10 個預(yù)測框,topk 設(shè)置為 4
計(jì)算該 gt bbox 和 100 個 anchor 的距離,然后選擇最近的前 4 個位置作為正樣本
計(jì)算該 gt bbox 和 100 個預(yù)測框的距離,然后選擇最近的前 4 個位置作為正樣本,注意這里選擇的 4個位置很可能和前面選擇的 4 個位置有重復(fù)
計(jì)算該 gt bbox 和預(yù)測框的 iou,在所有負(fù)樣本點(diǎn)中,將 iou 高于 0.75 的負(fù)樣本強(qiáng)制認(rèn)為是忽略樣本
計(jì)算該 gt bbox 和 anchor 的 iou,在所有正樣本點(diǎn)中,將 iou 低于 0.15 的正樣本強(qiáng)制認(rèn)為是忽略樣本,注意和上一步的區(qū)別,由于 iou 計(jì)算的輸入是不一樣的,可能導(dǎo)致某個被重復(fù)計(jì)算的正樣本位置出現(xiàn) 2 種情況:1. 兩個步驟都認(rèn)為是忽略樣本;2. 一個認(rèn)為是忽略樣本,一個認(rèn)為是正樣本,而一旦出現(xiàn)第二種情況則在 CUDA 并行計(jì)算中出現(xiàn)不確定輸出
簡單來說:indexes 中可能存在重復(fù)值,并且重復(fù)值位置對應(yīng)的 pos_gt_index_with_ignore 可能相同也可能不同,注意重復(fù)現(xiàn)象可能出現(xiàn)在兩個不同類別物體有重疊的情況下,那么上述賦值操作在 CUDA 中是不可預(yù)知的,可能會出現(xiàn)同一份數(shù)據(jù)跑兩次輸出結(jié)果不一樣。
這個操作會給后面的回歸分支帶來歧義,因?yàn)榛貧w分支僅僅處理正樣本,那么會出現(xiàn)以下幾個情況:
如果兩個重復(fù)索引處對應(yīng)的 gt bbox 是同一個,那么相當(dāng)于該 gt bbox 對應(yīng)的正樣本 loss 權(quán)重加倍
如果兩個重復(fù)索引處對應(yīng)的 gt bbox 不是同一個,那么就會出現(xiàn)歧義,因?yàn)樘卣鲌D上同一個預(yù)測點(diǎn),被同時分配給了兩個不同的 gt bbox
總的來說,對于上述重復(fù)索引分配現(xiàn)象,會帶來幾個影響:
讀者理解代碼運(yùn)行流程會比較困惑
同一個程序跑多次,可能輸出結(jié)果不一致
訓(xùn)練過程不穩(wěn)定
當(dāng)重復(fù)索引出現(xiàn)時候,回歸分支 loss 計(jì)算過程非常奇怪,難以理解
低版本 CUDA 上會出現(xiàn)非法內(nèi)存越界錯誤, 實(shí)驗(yàn)發(fā)現(xiàn) CUDA9.0 會出現(xiàn)非法內(nèi)存越界錯誤,但是 CUDA10.1 則正常,其余版本沒有進(jìn)行測試
關(guān)于第5點(diǎn),原因暫時不清楚,但是現(xiàn)象是 pos_gt_index_with_ignore、indexes 和 assigned_gt_inds 都不存在越界情況,只不過 indexes 如果存在相同值,在賦值后會出現(xiàn) 4294967295(2^32 -1) 和 -4294967295 (-2^32 +1) 異常值,然后后續(xù)基于 assigned_gt_inds 取值后就出現(xiàn)出現(xiàn) RuntimeError: CUDA error: an illegal memory access was encountered. 經(jīng)過多次實(shí)驗(yàn)發(fā)現(xiàn),錯誤是必現(xiàn)的。當(dāng)時也試過其他幾個方案,例如 1. 將上述賦值操作放置到 cpu 上進(jìn)行;2. 將賦值后異常值全部設(shè)置為忽略樣本,雖然可以避免報錯,但是實(shí)驗(yàn)結(jié)果顯示會存在一定程度的掉點(diǎn),所以最終沒有修改。這個問題我們也會持續(xù)關(guān)注,直到找到一個更加合適的方式以避免上述報錯問題。
上述這個寫法,給代碼復(fù)現(xiàn)帶來了些問題,并且由于 YOLOF 學(xué)習(xí)率非常高 lr=0.12,訓(xùn)練過程偶爾會出現(xiàn) Nan 現(xiàn)象,訓(xùn)練不太穩(wěn)定,可能對參數(shù)設(shè)置例如 warmup 比較敏感。
END
整理不易,點(diǎn)贊三連↓
