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

文章設計了一個簡潔優(yōu)雅的無需復雜 FPN 的網(wǎng)絡結構,僅僅依靠單尺度特征即可取得相匹配的效果,并且具備極快的推理速度,是一個不錯的算法。
1、設計了多組實驗,深入探討了 FPN 模塊成功的主要因素
2、基于實驗結論,設計了無需 FPN 模塊,單尺度簡單高效的 Neck 模塊 Dilated Encoder
3、基于 FPN 分治處理多尺度問題,配合 Neck 模塊提出 Uniform Matching 正負樣本匹配策略
4、由于不存在復雜且耗內(nèi)存極多的 FPN 模塊,YOLOF 可以在保存高精度的前提下,推理速度快,消耗內(nèi)存也相對更小
項目地址:github.com/open-mmlab/mmdetection,歡迎 star~
1 FPN 模塊分析

首先目標檢測算法可以簡單按照上述結構進行劃分,網(wǎng)絡部分主要分為 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 分治優(yōu)化作用遠遠大于 多尺度特征融合
從下表 GFLOPs 和 FPS 可以看出,MiMo 結構由于存在高分辨率特征圖 C3 會帶來較大的計算量,并且拖慢速度
FPN 模塊的主要增益來自于其分治優(yōu)化手段,而不是多尺度特征融合
FPN 模塊中存在高分辨率特征融合過程,導致消耗內(nèi)存比較多,訓練和推理速度也比較慢,對部署不太優(yōu)化
如果想在拋棄 FPN 模塊的前提下精度不丟失,那么主要問題是提供分治優(yōu)化替代手段
2 YOLOF 原理簡析

如果僅僅使用 C5 特征,會出現(xiàn)圖(a)所示的情況
若使用空洞卷積操作來增大 C5 特征圖的感受野,則會出現(xiàn)圖(b)所示的情況,感受野變大,能夠有效地表達尺寸較大的目標,但是對小目標表達能力會變差
如果采用不同空洞率的疊加,則可以有效避免上述問題



一般來說,由于自然場景中,大小物體分布本身就不均勻,并且大物體在圖片中所占區(qū)域較大,如果不設計好,會導致大物體的正樣本數(shù)遠遠多于小物體,最終性能就會偏向大物體,導致整體性能較差。YOLOF 算法采用單尺度特征圖輸出,錨點的數(shù)量會大量的減少(比如從 100K 減少到 5K),導致了稀疏錨點,如果不進行重新設計,會加劇上述現(xiàn)象。為此作者提出了新的均勻匹配策略,核心思想就是不同大小物體都盡量有相同數(shù)目的正樣本。

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

Uniform Matching 作用非常大,說明該模塊其實發(fā)揮了 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_regimport 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 的核心,需要重點分析。首先分析論文中描述,然后再基于代碼說明代碼和論文的差異。論文中描述的非常簡單,核心目的是保證不同尺度物體都盡可能有相同數(shù)目的正樣本
遍歷每個 gt bbox,然后選擇 topk 個距離最近的 anchor 作為其匹配的正樣本
由于存在極端比例物體和小物體,上述強制 topk 操作可能出現(xiàn) anchor 和 gt bbox 的不匹配現(xiàn)象,為了防止噪聲樣本影響,在所有正樣本點中,將 anchor 和 gt bbox 的 iou 低于 0.15 的正樣本(因為不管匹配情況,topk 都會選擇出指定數(shù)目的正樣本)強制認為是忽略樣本,在所有負樣本點中,將 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 的正樣本強制認為是忽略樣本
可以發(fā)現(xiàn)相比于論文描述,實際上代碼額外動態(tài)補充了一定量的正樣本,同時也額外考慮了一些忽略樣本。相比于純粹采用 anchor 和 gt bbox 進行匹配,額外引入預測框,可以動態(tài)調(diào)整正負樣本,理論上會更好。
# 全部任務是負樣本
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 個樣本點作為正樣本,此時正樣本數(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)
# 計算 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,都是常規(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 核心實現(xiàn)過程。至于推理過程和 RetinaNet 算法完全相同。
4 YOLOF 復現(xiàn)心得和體會
如果不仔細思考,可能看不出上述代碼有啥問題,實際上在 Bbox Assigner 環(huán)節(jié)會存在重復索引分配問題,這個問題會帶來幾個影響。具體代碼是:
# 對應 3.5 小節(jié)的源碼分析第 44 行
assigned_gt_inds[indexes] = pos_gt_index_with_ignore舉個簡單例子,當前圖片中僅僅有一個 gt bbox,且預測輸出特征圖大小是 10x10,設置 anchor 個數(shù)是 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 的正樣本強制認為是忽略樣本,注意和上一步的區(qū)別,由于 iou 計算的輸入是不一樣的,可能導致某個被重復計算的正樣本位置出現(xiàn) 2 種情況:1. 兩個步驟都認為是忽略樣本;2. 一個認為是忽略樣本,一個認為是正樣本,而一旦出現(xiàn)第二種情況則在 CUDA 并行計算中出現(xiàn)不確定輸出
如果兩個重復索引處對應的 gt bbox 是同一個,那么相當于該 gt bbox 對應的正樣本 loss 權重加倍
如果兩個重復索引處對應的 gt bbox 不是同一個,那么就會出現(xiàn)歧義,因為特征圖上同一個預測點,被同時分配給了兩個不同的 gt bbox
讀者理解代碼運行流程會比較困惑
同一個程序跑多次,可能輸出結果不一致
訓練過程不穩(wěn)定
當重復索引出現(xiàn)時候,回歸分支 loss 計算過程非常奇怪,難以理解
低版本 CUDA 上會出現(xiàn)非法內(nèi)存越界錯誤, 實驗發(fā)現(xiàn) CUDA9.0 會出現(xiàn)非法內(nèi)存越界錯誤,但是 CUDA10.1 則正常,其余版本沒有進行測試
上述這個寫法,給代碼復現(xiàn)帶來了些問題,并且由于 YOLOF 學習率非常高 lr=0.12,訓練過程偶爾會出現(xiàn) Nan 現(xiàn)象,訓練不太穩(wěn)定,可能對參數(shù)設置例如 warmup 比較敏感。
最后還是要感謝作者 Qiang Chen,在復現(xiàn)過程中解答了一些疑問。通過針對訓練不穩(wěn)定的問題,也指明了可能解決的方案。
END
點贊三連,支持一下吧↓
