一鬼贱、SSD原理
??本文記錄一階段目標檢測模型SSD的學習筆記及代碼復(fù)現(xiàn)過程移怯。首先個人感覺SSD和YOLO相比稍微簡單一些,理論比較直白吩愧,不過實現(xiàn)起來依然比較繁瑣。本文大體以李沐老師的Gluon教學視頻7-9為基礎(chǔ)增显,在Anchors sampling和Training Targets兩個點上加上了一些自己的挖掘雁佳。
??根據(jù)李沐老師的說法,SSD的算法可以看做Faster-RCNN的一個簡化同云,具體說來就是直接把RPN中那個負責Region Proposal的二分類Softmax擴展了一下糖权,區(qū)分出背景的同時將前景細分成物體的類別,即擴展成了n+1分類炸站,同時bbox regressor一步回歸到位星澳。這樣理論上就足夠得到box和類別信息了。不過為了提高準確性旱易,SSD在此基礎(chǔ)上又加上多尺度預(yù)測禁偎,具體來說就是將主干網(wǎng)絡(luò)輸出的特征通過downsampling Conv降低一半再預(yù)測cls和box,以此類推阀坏。
??看到這里如暖,一個必須要想清楚的問題是:尺寸較小的feature map負責提高大目標還是小目標的精確度?
??這個問題其實本質(zhì)是對anchor概念的理解忌堂。
1.1 Anchors
??關(guān)于Anchors的概念盒至,等到寫Faster-RCNN時再詳細記錄。這里先簡單理解士修,就是在特征圖的每個像素點上采樣一組預(yù)定義尺寸和形狀的先驗框枷遂,然后希望這些所有尺度的先驗框能夠覆蓋圖像中所有可能的boundingbox位置。
??當然棋嘲,anchors的巧妙之處遠不止這些酒唉。比如anchor的引入使我們得以避開了編碼boudingbox規(guī)則這一難題,見[1]沸移。
??上圖回答了1.1的問題黔州。每一組anchors都是以feature map中的像素為中心定義的耍鬓,故anchors的尺寸和像素之間的距離——grid的尺寸成正比,即和feature map的尺寸成反比流妻。所以大的feature map負責檢測小物體牲蜀,小的feature map負責檢測大物體。下面的Anchors Sampling代碼實現(xiàn)部分也有類似的可視化結(jié)果绅这。
??這里有個小問題:上面說的小的feature map負責檢測大物體是因為anchor占feature的比例很大涣达,這個說法應(yīng)該是在我們默認深度神經(jīng)網(wǎng)絡(luò)feature map越小,receptive field越大這一前提下证薇?我們假設(shè)小的feature map的感受野已經(jīng)足夠覆蓋原圖大部分區(qū)域度苔?
二、各部件實現(xiàn):
??由于SSD原來相對簡單浑度,這一部分以理論和代碼塊實現(xiàn)相結(jié)合的形式展開記錄寇窑。基本的組件有如下幾塊:
- Anchors Sampling.
- Class Predictor.
- Bbox Predictor.
- Downsample Block.
- 整合得到完整的SSD模型.
- 數(shù)據(jù)集加載(有時間會記錄rec文件生成方法).
- Training Targets.
- 優(yōu)化:Focal Loss與Negative example mining.
2.1 Anchors Sampling
??原始圖像送入主干網(wǎng)絡(luò)之后箩张,得到一個feature map甩骏,如 。現(xiàn)在我們需要在該feature的每一個像素點(x, y)上sample出多個預(yù)先定義的anchor。這一點和RPN是一致的。不過有個細節(jié)屋休,SSD的anchor不像Faster-RCNN那樣盯荤,指定了一組size和一組寬高比,最后的anchor數(shù)就是size的個數(shù)n乘以ratio的個數(shù)m;SSD簡化了下,將anchor總數(shù)改成
。
??規(guī)則見下圖:簡單的說就是寬高比不變時无午,只使用第一個ratio(通常為1),得到幾個不同大小的正方形祝谚;而size不變時指厌,只使用第一個size,得到幾個相同size不同寬高比的anchor踊跟,這兩種情況會有一個anchor取了兩次(第一個size和第一個ratio對應(yīng)anchor)踩验,去掉一個即可。
??雖然我們可以像課程中那樣商玫,用contrib.ndarray中的MultiBoxPrior來sample anchors, 并且實際模型訓練中肯定也會使用這個函數(shù)以獲取最佳優(yōu)化效果和backward能力箕憾,但是在此之前有必要先用Python簡單實現(xiàn)下,以加深理解拳昌。簡單起見袭异,這里就直接用Numpy和list,不用NDArray了炬藤。因為NDArray對于list的支持不是很友好御铃。
??中間的探索過程比較長碴里,就不列了,剛興趣的同學可以參考此notebook上真。下面直接上代碼:
import numpy as np
import time
import matplotlib.pyplot as plt
from mxnet import nd, autograd
from mxnet.contrib.ndarray import MultiBoxPrior
def sample_anchor(area, ratio, center_coor, input_shape):
"""
實現(xiàn):給定anchor的面積和寬高比咬腋,以及中心坐標,求出anchor的box
area:anchor的絕對面積(size_ratio * size_input)
ratio:單個寬高比
center_coor:tuple: (x, y)
input_shape: tuple: (h, w, c)
輸出:單個長度為4的list
"""
x, y = center_coor
H, W = input_shape[:2]
dh = np.sqrt(area/ratio)
dw = dh * ratio
# 返回對輸入feature尺寸歸一化之后的anchor坐標
anchor = [(x-dw/2)/W, (y-dh/2)/H, (x+dw/2)/W, (y+dh/2)/H]
return anchor
def sample_one_pixel(sizes, ratios, center_coor, input_shape):
"""
實現(xiàn):給定sizes和ratios兩個list以及輸入圖像寬高睡互,在單個坐標點上sample anchors.
sizes: list of size_ratios, 為了便于計算根竿,首先轉(zhuǎn)換為list_of_absolute_areas
輸出:lists of lists, n+m-1個對輸入尺寸歸一化過的輸出anchor,每個anchor為四維list
"""
H, W = input_shape[:2]
areas = [size**2 * H*W for size in sizes] #獲取絕對areas
anchors = []
for a in areas:
anchors.append(sample_anchor(a, ratios[0], center_coor, input_shape))
for r in ratios[1:]:
anchors.append(sample_anchor(areas[0], r, center_coor, input_shape))
return np.array(anchors) # .reshape(-1, 4) # 這里發(fā)現(xiàn)lists of lists 直接變array不需要reshape就珠。但是NDArray不支持類似的模糊轉(zhuǎn)換
def sample_one_feature(feature, sizes, ratios):
input_shape = feature.shape
H, W = input_shape[:2]
n, m = len(sizes), len(ratios)
anchors = np.zeros((H, W, n+m-1, 4))
for h in range(H):
for w in range(W):
# 這里實現(xiàn)需要的組織方式
anchors[h,w,:,:]=sample_one_pixel(sizes, ratios, (w,h), input_shape)
return anchors
def draw_one_pixel(feature, center_coor, anchors):
colors = ['blue', 'green', 'red', 'black', 'magenta']
x, y = center_coor
feature_display = np.array([feature[:,:,0]]*3).transpose((1,2,0))
plt.imshow(np.ones_like(feature_display)*255)
anchors_this_pixel = anchors[y,x,:,:]
for i, anchor in enumerate(anchors_this_pixel):
plt.gca().add_patch(plt.Rectangle((anchor[0]*n, anchor[1]*n),
(anchor[2]-anchor[0])*n, (anchor[3]-anchor[1])*n, fill=False, color=colors[i]))
plt.grid()
plt.show()
if __name__ == "__main__":
sizes = [.5, .25, .1]
ratios = [1, 2., .5]
plt.subplot(121)
n = 10
feature = np.arange(n*n*6).reshape(n, n, -1)
print(feature.shape)
anchors = sample_one_feature(feature, sizes, ratios)
print(anchors.shape)
draw_one_pixel(feature, (4, 4), anchors)
plt.subplot(122)
n = 40
feature = np.arange(n*n*6).reshape(n, n, -1)
print(feature.shape)
anchors = sample_one_feature(feature, sizes, ratios)
print(anchors.shape)
draw_one_pixel(feature, (16, 16), anchors)
??注意:上面的可視化再次顯示出寇壳,anchor的尺寸是隨著grid的尺寸成比例變化的。即妻怎,feature map越小壳炎,gird越少,anchor覆蓋面積越大逼侦。進一步匿辩,小feature map是為了檢測大目標,而大的feature map是為了檢測小目標偿洁。
??現(xiàn)在再來熟悉下Mxnet中C++實現(xiàn)的MultiBoxPrior接口:
可見MultiBoxPrior的輸出已經(jīng)被reshape過了撒汉,維度為(batchSize, total_num_anchors, 4)
??另外這里有一個重要的問題待后續(xù)解決:sizes如何預(yù)定義沟优。YOLO中的Anchor直接通過cluster獲得涕滋,SSD呢?
2.2 預(yù)測物體類別——class_predictor
??SSD的一個重要特點就是挠阁,其在Faster-RCNN RPN的基礎(chǔ)上宾肺,將二分類的Softmax直接擴展為n+1分類,其中n代表類別侵俗,1代表是否包含物體锨用。這里cls_predictor就是將每個scale的feature送入一個Conv layer,該Conv要保證輸出的特征通道數(shù)為num_anchors*(num_classes+1)隘谣,即每個通道對應(yīng)一個anchor對 某個類別的置信度增拥。具體見下面描述:
- 注意,這里存放class_probabilities的形式和YOLO稍有不同寻歧,YOLO是直接將每個grid cell用FC回歸到指定長度向量掌栅,向量每個位置代表啥都是很清楚的。SSD由于不使用FC回歸码泛,而是用"same" conv猾封,因此得到的不是一個長向量,而是一個維度為[batchSize, (numClasses+1) * numAnchors, h_feature, w_feature]的特征圖噪珊。其中的含義如下:每一個h_feature x w_feature大小的通道存放了所有像素點的某一個anchor對某個類的預(yù)測概率晌缘。所以所有的class probabilities總共有h_feature x w_feature x (numClasses+1) * numAnchors個數(shù)值齐莲。
from mxnet.gluon import nn
import mxnet as mx
def cls_predictor(num_anchors, num_classes):
return nn.Conv2D(num_anchors*(num_classes+1), 3, 1, 1)
2.3 預(yù)測邊界框——box_predictor
??同理,將每個scale的feature送入一個新的Conv layer磷箕,該Conv只需要保證輸出channel數(shù)為num_anchors * 4.
def box_predictor(num_anchors):
return nn.Conv2D(num_anchors*4, 3, 1, 1)
2.4 減半(下采樣)模塊
??接下來定義減半模塊选酗。這里直接通過兩個'same'卷積后接一個MaxPooling實現(xiàn). 這種連續(xù)多個conv組成的小block中有一個小細節(jié): 往往最后一個Conv負責輸出指定的通道數(shù), 也就是說前面幾個Conv其實通道數(shù)是可以改變的。這時候一個選項就是把前面第一個卷即塊先通過1x1 Conv減少通道數(shù),然后接下來的Conv都使用這個小通道數(shù), 操作到最后一個Conv時再把通道數(shù)升上來, 這樣相比一開始的Conv就帶著目標通道數(shù)進行一系列連續(xù)運算, 參數(shù)量可以減少一些. 這個就是Residual Block Bottleneck的想法. 不過這里先按教程中的做法來搭建.
def downsample_block(channels):
block = nn.HybridSequential()
for _ in range(2):
block.add(
nn.Conv2D(channels, 3, 1, 1),
nn.BatchNorm(),
nn.Activation('relu')
)
block.add(nn.MaxPool2D(strides=2))
return block
定義好了三種基本網(wǎng)絡(luò)塊, 現(xiàn)在有兩個很自然的問題:
①這三個模塊之間是如何連接的?
②不同scale上的兩個Conv head的輸出應(yīng)該如何組合到一起, 得到最后的, 要送入loss函數(shù)的形式?問題答案如下:
??主干網(wǎng)絡(luò)和若干downsample block是首尾相連的搀捷,然后每個scale上的Conv heads(box_predictor和cls_predictor)均與主干網(wǎng)絡(luò)(或downsample)在當前尺度下的輸出特征圖相連接星掰,這些Conv heads之間沒有連接。在模型前向傳播過程中嫩舟,每個scale經(jīng)過Conv heads之后氢烘,會得到兩個代表anchors的class probability和box的tensor,我們需要將每個scale的這兩個tensor分別(按某種維度)Concat到一起家厌,最終得到兩個較大的tensor播玖,分別代表多尺度anchors的class probability和box信息,最后我們將這兩個合并之后的tensor送入loss函數(shù)饭于。
??SSD中會在多個尺度上進行預(yù)測蜀踏。由于每個尺度上的輸入高寬和錨框的選取不一樣,導(dǎo)致其形狀各不相同掰吕。下面例子中我們構(gòu)造兩個尺度的輸入果覆,其中第二個為第一個的高寬減半。然后構(gòu)造兩個類別預(yù)測層殖熟,其分別對每個輸入像素構(gòu)造5個和3個錨框局待。
??可見, 預(yù)測的輸出格式為(批量大小,通道數(shù)菱属,高钳榨,寬)∨γ牛可以看到除了批量大小外薛耻,其他維度大小均不一樣。因此Concat時可以利用的維度就只有第一個維度. 故我們需要考慮如何將它們變形成統(tǒng)一的格式, 進而將多尺度的輸出合并起來赏陵,讓后續(xù)的處理變得簡單饼齿。
??我們首先將通道,即預(yù)測結(jié)果蝙搔,放到最后(這里是因為通常分類任務(wù)習慣將class probability放到最后一維缕溉,這里不放也沒關(guān)系)。其實這里我不知道為啥要調(diào)整下把通道放最后一維(可能是為了適應(yīng)其他接口杂瘸?)倒淫,根據(jù)2.2部分下面的分析,除了第一維代表batchSize之外,其他所有維度乘起來代表的就是總共的class probabilities敌土。因為不同尺度下批量大小保持不變镜硕,所以將結(jié)果轉(zhuǎn)成二維的(批量大小,高 × 寬 × 通道數(shù))格式返干,方便之后的拼接兴枯。
??然后將多個尺度的preds按最后一個維度拼接就可以了.
def flatten_pred(pred):
return pred.transpose(axes=(0, 2, 3, 1)).flatten()
def concat_preds(preds_multi_scales, F=mx.ndarray):
return F.concat(*[flatten_pred(pred) for pred in preds_multi_scales], dim=1)
## check
concat_preds([y1, y2]).shape
[Out]: (2, 25300)
2.5 完整模型
2.5.1 定義主干網(wǎng)絡(luò)
??這里先用一個簡單的主干網(wǎng)絡(luò)與上述部件結(jié)合起來, 搭建一個完整的ToySSD模型. 后面的實驗中可以再嘗試不同的主干網(wǎng)絡(luò), 如ResNet50.
??直接用連續(xù)三個下采樣模塊構(gòu)成主干網(wǎng)絡(luò), 實現(xiàn)對輸入圖像進行8倍下采樣:
def body_block(channel_list=[16, 32, 64]):
net = nn.HybridSequential()
for channels in channel_list:
net.add(downsample_block(channels))
return net
2.5.2 一個"容器"模型
??在2.4中我們已經(jīng)討論過模型各個部件的關(guān)系了。為了代碼簡潔矩欠,我們定義一個額外的函數(shù)將所有組件放到一個Block(或者直接放到一個list中)财剖。注意:雖然這個Block是一個單獨的HybridBlock對象,但是和之前定義的完整網(wǎng)絡(luò)不一樣癌淮,該對象只是一個容器將所有組件存放起來躺坟,看做list即可(之所以可以當list使用,是利用HybridSequential可以直接unpack的特點)
??對比SSD和前面練習的分類網(wǎng)絡(luò), 一個有趣的地方是, 分類任務(wù)通橙樾睿可以一氣呵成地定義一個完整模型, 然后前向傳播直接pred = model(X)
; 而SSD并不是一個傳統(tǒng)的前后相連的網(wǎng)絡(luò)模型, 而是由幾個相對獨立的部件構(gòu)成, 理論上我們只需要先把不同的組件放到同一個容器中保存, 然后前向傳播時在各個尺度分別輸出預(yù)測結(jié)果咪橙。 換句話說, 其實我們并沒有"定義"模型, 只是定義了部件, 前向傳播過程需要我們具體定義這些部件功能。
??理論上這個用于存儲各部件的容器是很寬泛的, 比如我們可以用list或者tuple, 只要到時候能夠unpack取出各個組件進行前向傳播就可以了. 不過一個實際問題在于, 如果用這種方式, 返回的代表模型的"容器"只能在unpack之后對每個部件分別初始化, 顯然會使代碼顯得比較繁冗. 故我當時放棄了下面這種寫法, 盡管這種寫法更能體現(xiàn)出SSD這種松散的結(jié)構(gòu)特點:
??經(jīng)過上面分析, 我們可以把各個部件放到同一個HybridSequential對象中, 盡管這些部件之間的連接看起來是序貫連接(依次放入了model.add函數(shù)作為參數(shù)), 實際上這個model只是一個容器而已. 其優(yōu)勢在于: ①可以像一般容器一樣unpack; ②初始化時只需要調(diào)用model.initialize()即可初始化所有部件.
def ssd_model(num_anchors, num_classes):
downsamplers, cls_predictors, box_predictors = nn.HybridSequential(), nn.HybridSequential(), nn.HybridSequential()
for _ in range(3):
downsamplers.add(downsample_block(128))
for _ in range(5):
cls_predictors.add(cls_predictor(num_anchors, num_classes))
box_predictors.add(box_predictor(num_anchors))
model = nn.HybridSequential()
model.add(body_block([16, 32, 64]), cls_predictors, box_predictors, downsamplers)
return model
??這種網(wǎng)絡(luò)組織的方式非常有趣, 但是仔細一想又很自然, 因為HybridSequential對象本身就支持索引, 有一些list的特性, 故可以unpack出各個組件也是容易理解的虚倒。我們可以通過下面的驗證進一步確定這一點:
t_model = ssd_model(5, 2)
print(len(t_model))
[Out]: 4
2.5.3 定義前向傳播
??由于反向傳播不需要我們考慮, 模型的前向傳播就成為整個網(wǎng)絡(luò)最重要的部分美侦。我們需要將前面定義的部件都整合起來。
??該前向傳播函數(shù)需要接收的輸入: ①上面的"容器"模型; ②輸入圖像矩陣; ③sizes和ratios, 用于逐個scale中sample anchors, 而sample出的anchor用于和當前batch的labels比較IOU, 進而生成當前batch的training targets(sample anchor應(yīng)該在前向傳播中完成, training_targets的獲取可以放在后面就行)魂奥。
?? 該前向傳播函數(shù)的輸出: ①所有scale的anchors②所有scale的cls_preds③所有scale的box_preds菠剩。
??根據(jù)接口的輸入輸出容易定義出如下ssd_forward函數(shù):
from mxnet.contrib.ndarray import MultiBoxPrior
def ssd_forward(model, x, sizes, ratios, F=mx.ndarray, verbose=True):
# !!注意別寫成 backbone, cls_predictors, box_predictors, downsamplers = ssd_model(5, 2)了...
backbone, cls_predictors, box_predictors, downsamplers = model
x = backbone(x)
anchors, cls_preds, box_preds = [], [], []
#anchors, cls_preds, box_preds = [None]*5, [None]*5, [None]*5
for i in range(5):
# MultiBoxPrior: (batch_size, num_total_anchors_this_scale, 4)
anchors.append(MultiBoxPrior(x, sizes[i], ratios[i]))
cls_preds.append(cls_predictors[i](x))
box_preds.append(box_predictors[i](x))
if verbose:
print('Predict scale {}, {} with {} anchors.'.format(i, x.shape, anchors[-1].shape[1]))
if i < 3:
x = downsamplers[i](x)
elif i == 3:
x = nn.GlobalMaxPool2D()(x)
## i=4時用的是GAP之后的feature,到這里就不用管了耻煤,所以沒有else
# 注意concat anchors時具壮,將各個尺度的anchors按num_anchors那個維度拼接。得到的結(jié)果是(batchSize, num_total_anchors, 4)
return (F.concat(*anchors, dim=1), concat_preds(cls_preds, F), concat_preds(box_preds, F))
- 注意: 這個函數(shù)中我設(shè)置了一個參數(shù)F, 是希望將nd的操作擴展為Symble和NDArray公有的操作, 從而在模型定義完, 調(diào)試無誤之后使用model.hybridize()接口违霞。
- 進一步, 將ssd_model和ssd_forward封裝到一個類中:
class ToySSD(nn.HybridBlock):
def __init__(self, num_classes, **kwags):
super(ToySSD, self).__init__(**kwags)
# 思考:sizes是怎么得到的嘴办?瞬场?
self.sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619],
[0.71, 0.79], [0.88, 0.961]]
self.ratios = [[1, 2, 0.5]] * 5
self.num_classes = num_classes
num_anchors = len(self.sizes[0]) + len(self.ratios[0]) - 1
with self.name_scope():
self.model = ssd_model(num_anchors, num_classes)
def hybrid_forward(self, F, x):
anchors, cls_preds, box_preds = ssd_forward(self.model, x, self.sizes, self.ratios, F, verbose=False)
# 這里還需要額外的一步:將cls_preds整理成最后一個維度代表probability的形式买鸽。這樣維度變成:
# (batchSize, num_total_anchors, class_prob_each_anchor)
cls_preds = cls_preds.reshape(0, -1, self.num_classes+1)
return anchors, cls_preds, box_preds
這段代碼中值得注意的是, 最后需要額外的一步, 將cls_preds整理成
(batchSize, num_total_anchors, class_proba_each_anchor)
的形式。這是分類任務(wù)中一個必要的步驟: 將代表class_probability的tensor單獨放到一個維度(通常是最后一維), 以配合cls_loss = SoftmaxCrossEntropyLoss(axis=-1)
一起使用贯被。語義分割中這個問題會更明顯, 因為目標是對每個像素進行分類, 而FCN最后一個轉(zhuǎn)置卷積層的輸出維度是(batchSize, numClasses, height, width)
, 即原本代表通道的那一維(第1維)存放類別信息, 因此loss用的是SoftmaxCrossEntropyLoss(axis=1)眼五。總而言之, 計算機視覺任務(wù)中, 網(wǎng)絡(luò)輸出的predict_tensor可能是高度濃縮的, 因此搞清楚哪個維度代表了什么含義, 哪一維應(yīng)該拿出來求交叉熵, 是很重要的。
三彤灶、總結(jié)與遺留
??這篇博客記錄了SSD的學習筆記, 除了李沐老師講的知識之外, 加上了一些自己對contrib庫函數(shù)的探索, 以及學習過程中的思考看幼。總結(jié)下, 這篇博客主要涉及一下幾個有趣的點, 以及幾個遺留問題:
3.1 幾個有趣的點:
在Anchor Sampling階段, 每個尺度的特征圖都直接映射到原圖大小(Fig. 1), 特征圖上每個像素點Sample出的anchor尺寸和grid尺寸成正比, 和特征圖大小成反比, 即大特征圖用于檢測小目標, 小特征圖用于檢測大目標幌陕。
SSD和FasterRCNN采集Anchor的數(shù)目不一樣多, SSD是n+m-1個诵姜。
SSD直接將各個尺度的特征圖通過兩個Conv head回歸到分別代表box_preds和num_classes+1分類的cls_preds上。
"容器"模型的思想: 模型并不一定要是嚴格的連接關(guān)系, 甚至有時候并沒有嚴格定義的模型, 只是為了初始化方便將幾個部件放到同一個HybridSequential中搏熄。只要前向傳播能unpack出各個部件, 實現(xiàn)預(yù)期輸出即可棚唆。
Concat時往往先找公共維度, 如果很多維度尺寸不一樣, 可以考慮乘到一起, 再利用公共維度進行拼接暇赤。
定義模型時, 如果用到NDArray的接口, 想辦法用F代替, 這樣或許可以用model.hybridize()。
搞清楚輸出tensor的哪一維要拿出來求交叉熵, 為什么是這一維!!
3.2 遺留問題:
SSD的sizes如何選擇?
每個scale的cls_pred以及box_pred為啥要先用same卷積再flatten宵凌?可以用fc head嗎鞋囊?
答:如果用fc的話可能要求每個scale feature map維度固定。關(guān)于每個scale的cls_predictior輸出維度的含義瞎惫。為什么flatten前要將代表通道的維度放到最后面溜腐?
[1]: DeepLearning for CV with Python. ImageNet Bundle.
[2]: 動手學深度學習