SSD(Single Shot MultiBox Detector) 詳解

ss
16 min readNov 18, 2018

--

從RCNN 開啟了一段 用deep learning 用於物件偵測的大航海時代, 從one stage的yolo, ssd, 到 two stage的RCNN系列, RPN, F-RCN, 每個方法跟創作都是非常有意思的, 今天筆記一下有關於SSD 的詳細操作, 並用pytorch 來解釋每個部份的動作

「ssd cnn」的圖片搜尋結果

1. SSD network with mutlilayer

yolo在到v3 版本以前有個小逅病, 就是對於小物件的偵測效果不佳, 當然這邊已經在v3 時提出方法改善, 有機會我們也會來看yolov3, 但SSD 是怎麼做到對於小物件的偵測不會隨著網路層數越深變差呢

當網路層數越深, 由於小物件在經過多次的convolution 迭代, 經過越多層, 能留下來的訊息就會越來越小, 導致最後對於小物體的效果一直都不是很明顯,

所以我們來看SSD的網路架構, 首先會先經過VGG16的神經網路, 接著加上一層layer norm 與 一些convolution 來幫助加深網路, 但這與一般加深神經網路有什麼不一樣呢?

原來是最後要進行輸出時, 我們並不會只取最後的輸出, 而是會將在幾層卷積層的輸出合併起來, 一起來輸出, 這樣越前面的卷積層對小物件的訊息掌握度高, 越後面的卷基層也可以掌握大物件的訊息, 一舉兩得的好作法

作者分別將 l2norm, conv7, conv8_2, conv9_2, conv10_2, conv11_2 的輸出一併收集起來輸入到mutlilayer

我們稍微看一下code

class SSD300(nn.Module):
input_size = 300

def __init__(self):
super(SSD300, self).__init__()

# model
self.base = self.VGG16()
self.norm4 = L2Norm(512, 20) # 38

self.conv5_1 = nn.Conv2d(512, 512, kernel_size=3, padding=1, dilation=1)
self.conv5_2 = nn.Conv2d(512, 512, kernel_size=3, padding=1, dilation=1)
self.conv5_3 = nn.Conv2d(512, 512, kernel_size=3, padding=1, dilation=1)

self.conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)

self.conv7 = nn.Conv2d(1024, 1024, kernel_size=1)

self.conv8_1 = nn.Conv2d(1024, 256, kernel_size=1)
self.conv8_2 = nn.Conv2d(256, 512, kernel_size=3, padding=1, stride=2)

self.conv9_1 = nn.Conv2d(512, 128, kernel_size=1)
self.conv9_2 = nn.Conv2d(128, 256, kernel_size=3, padding=1, stride=2)

self.conv10_1 = nn.Conv2d(256, 128, kernel_size=1)
self.conv10_2 = nn.Conv2d(128, 256, kernel_size=3)

self.conv11_1 = nn.Conv2d(256, 128, kernel_size=1)
self.conv11_2 = nn.Conv2d(128, 256, kernel_size=3)

# multibox layer
self.multibox = MultiBoxLayer()

def forward(self, x):
hs = []

h = self.base(x)
hs.append(self.norm4(h)) # conv4_3
h = F.max_pool2d(h, kernel_size=2, stride=2, ceil_mode=True)

h = F.relu(self.conv5_1(h))
h = F.relu(self.conv5_2(h))
h = F.relu(self.conv5_3(h))
h = F.max_pool2d(h, kernel_size=3, padding=1, stride=1, ceil_mode=True)

h = F.relu(self.conv6(h))
h = F.relu(self.conv7(h))
hs.append(h) # conv7
h = F.relu(self.conv8_1(h))
h = F.relu(self.conv8_2(h))
hs.append(h) # conv8_2
h = F.relu(self.conv9_1(h))
h = F.relu(self.conv9_2(h))
hs.append(h) # conv9_2
h = F.relu(self.conv10_1(h))
h = F.relu(self.conv10_2(h))
hs.append(h) # conv10_2
h = F.relu(self.conv11_1(h))
h = F.relu(self.conv11_2(h))
hs.append(h) # conv11_2
loc_preds, conf_preds = self.multibox(hs)

return loc_preds, conf_preds

這邊我們可以看到在inference的時候有一個 hs 的list, 會將所有的output收集起來並且一起輸入到multibox取得location prediction, confidence prediction

除了上述所說的, 我們還要注意兩個地方, 一個就是 layer norm 與 dilation的使用

Layer Norm

class L2Norm(nn.Module):
'''L2Norm layer across all channels and scale.'''
def __init__(self, in_features,scale):
super(L2Norm, self).__init__()
self.weight = nn.Parameter(torch.Tensor(in_features))
self.reset_parameters(scale)

def reset_parameters(self, scale):
nn.init.constant_(self.weight, scale)

def forward(self, x):
x = F.normalize(x, dim=1)
scale = self.weight[None,:,None,None]
return scale * x

可以看到layer norm 會對dim 為1的位置做normalize, 並且乘上weight為一個scale, 這邊我們不詳細說明layer norm的用意(因為這個原理大概又可以在一篇), 最後輸出 scale * x, 這邊scale = self.weight[None,:,None,None], 這個值是可以訓練的

另外為什麼只有這層要layer norm? 作者在github給出解釋

Why normalization performed only for conv4_3? #241

這比較沒有定性的思維, 我的理解比較像是經驗結果論

Dilation

普通3 x 3的conv, 所能看到的視野就是 3 x 3 , 但使用dilation 1, 視野變 7 * 7

原因以及這部份細部的操作可以參考 如何理解空洞卷积(dilated convolution)? 裡面的敘述非常詳盡, 非常棒的敘述跟圖文解說

2. Anchor的設計

為何不直接對座標做regression呢?主要是要直接預測物體的位置上有一定的難度, 而透過anchor並且regression offset的方式可以有效讓網路的負擔減輕很多喔,結果也比直接預測還要好很多

Anchor box number

這邊有一個想法, 就是希望對於每一層的feature maps 有不同數量的anchor

而總共有三個形狀, 一個是正方形, 其最小邊框的size是min_size, 最大邊框是Sqrt(min_size *max_size)

長方形有兩種, 其中一種長方形的長寬為 Sqrt(aspect_ratio *min_size)與 1/ Sqrt(aspect_ratio *min_size), 另外一種就是長方形的長寬對調

由於aspect_ratio又有值為2跟值為3的

所以我們統計一下所有形狀, 兩個正方形跟四個長方形, 一共六種形狀

所以我們可以看到每層anchor number的參數配置[4,6,6,6,4,4], 4代表只有正方形加上aspect_ratio為2的長方形, 共四種, 6代表全部形狀的anchor box,

而每一層的layer width, layer height 乘以anchor number, 我們就能得出所有的anchor box

每層的feature maps 長寬為{38, 19, 10, 5, 3, 1}

所以總共有

38×38×4+19×19×6+10×10×6+5×5×6+3×3×4+1×1×4=8732

8732個anchor boxes

anchor size

剛剛上面提到了anchor number的計算, 現在我們要來看每層anchor的min_size是多少, 怎麼計算的

論文設定 smin = 0.2, smax = 0.9

按照公式從k = 1到 k =5,

這邊很多文章其實都沒有好好說明, 這邊我們一一計算

s1 = 0.2 + (0.9 - 0.2)/(5–1) * (1–1) = 0.2

s2 = 0.2 + (0.9 - 0.2)/(5–1) * (2–1) = 0.375(這邊大多都四捨五入至0.37)

s3 = 0.2 + (0.9–0.2)/(5–1) * (3–1) = 0.55(由於在計算上幾乎都是直接0.37+0.17 = 0.54, 故我們直接使用0.54)

以此類推

文章中有特地提到

We Set default box with scale 0.1

這很多文章都是直接略過, 百思不得其解, 我回去找原論文才發現這句話

所以第一層的min_size = 300 * 0.1 = 30

而之後的每一層就是

min_size = 300 * s1= 60

min_size = 300 * s2=111

以此類推

+-----------+--------------------+---------------+
| layer | min_size | max_size |
+-----------+--------------------+---------------+
| conv4_3 | 30 | 60 |
| fc7 | 60 | 111 |
| conv6_2 | 111 | 162 |
| conv7_2 | 162 | 213 |
| conv8_2 | 213 | 264 |
| conv9_2 | 264 | 315 |
+-----------+--------------------+---------------+

到此我們就了解每一層的anchor box的數量以及 anchor size

我們來看一小段code實做

class MultiBoxLayer(nn.Module):
num_classes = 21
num_anchors = [4,6,6,6,4,4]
in_planes = [512,1024,512,256,256,256]

def __init__(self):
super(MultiBoxLayer, self).__init__()

self.loc_layers = nn.ModuleList()
self.conf_layers = nn.ModuleList()
for i in range(len(self.in_planes)):
self.loc_layers.append(nn.Conv2d(self.in_planes[i], self.num_anchors[i]*4, kernel_size=3, padding=1))
self.conf_layers.append(nn.Conv2d(self.in_planes[i], self.num_anchors[i]*21, kernel_size=3, padding=1))

這邊可以看到, 針對每層會有每種不同的num_anchor, 4跟6的說明已經寫在上方有關anchor box的形狀, 可以往上參考, 而in_planes 指的是每一層input的channel數

而我們也看到

'''Compute default box sizes with scale and aspect transform.'''
scale = 300.
steps = [s / scale for s in (8, 16, 32, 64, 100, 300)]
sizes = [s / scale for s in (30, 60, 111, 162, 213, 264, 315)]
aspect_ratios = ((2,), (2,3), (2,3), (2,3), (2,), (2,))
feature_map_sizes = (38, 19, 10, 5, 3, 1)
# 38×38×4+19×19×6+10×10×6+5×5×6+3×3×4+1×1×4=8732
num_layers = len(feature_map_sizes)

boxes = []
for i in range(num_layers):
fmsize = feature_map_sizes[i] # feature map size
for h,w in itertools.product(range(fmsize), repeat=2):
# for each point in feature map
cx = (w + 0.5)*steps[i]
cy = (h + 0.5)*steps[i]

s = sizes[i]
boxes.append((cx, cy, s, s))

s = math.sqrt(sizes[i] * sizes[i+1])
boxes.append((cx, cy, s, s))

s = sizes[i]
for ar in aspect_ratios[i]:
boxes.append((cx, cy, s * math.sqrt(ar), s / math.sqrt(ar)))
boxes.append((cx, cy, s / math.sqrt(ar), s * math.sqrt(ar)))

self.default_boxes = torch.Tensor(boxes)

這邊則是描述了8732個bbox的初始化值, 由此我們便能從每層feature maps 的點去計算每個anchor的位置(offset為0.5), 並且把每個bbox的size跟shape都紀錄下來

3. Loss function的設計

SSD的output分兩類, 分別是判斷類別的classification, 與為了增加box準度的location offset, 輸出分別為21跟4, 21代表類別總共有21種, 而4代表x, y, w, h的offset

有關loc的loss, 使用的是SmoothL1Loss, 這個loss function並不陌生, 在fasterRCNN也可以看到

這個用意是作什麼的呢

其實目的很簡單, 有做過訓練的很長會遇到一個狀況, 就是值得變化太大難以收斂, 以及梯度爆炸, 為了解決梯度爆炸, 我們避免像L2loss 可能會因為少數output loss值過大而影響整體訓練的結果, 需要一點折衷且robust 的loss

故我們改用smoothL1Loss 來做loc的regression

可以看到

這樣可以避免當x 值過大時對於整體loss的影響, 若想要更清楚這之間的差別可以參考 损失函数:L1 loss, L2 loss, smooth L1 loss

而分類的部份就是用cross entropy這邊我們就不詳細介紹了

最後將兩著加起來, 變得到當次的 loss

看一下代碼的部份

################################################################
# loc_loss = SmoothL1Loss(pos_loc_preds, pos_loc_targets)
################################################################
pos_mask = pos.unsqueeze(2).expand_as(loc_preds)
pos_loc_preds = loc_preds[pos_mask].view(-1,4)
pos_loc_targets = loc_targets[pos_mask].view(-1,4)
loc_loss = F.smooth_l1_loss(pos_loc_preds, pos_loc_targets, size_average=False)

################################################################
# conf_loss = CrossEntropyLoss(pos_conf_preds, pos_conf_targets)
+ CrossEntropyLoss(neg_conf_preds, neg_conf_targets)
################################################################
conf_loss = F.cross_entropy(conf_preds.view(-1,self.num_classes), \
conf_targets.view(-1), reduce=False) # [N*8732,]

4.結論

雖然後面出現很多的論文證明結果都比SSD好, 但是作為object Detection, 這是一個很好入門的方法, 另外還有yolo系列, fcnn系列都也是值得慢慢品味, 最好是可以通過code去試著implement, 除了有助於自己更理解論文, 也可以實際看到效果

程式碼github上現在很多, 也可以參考 ssd-pytorch, 原作者不維護了, 並整合到其他專案, 但我覺的對於ssd可能還是獨立成一個專案才好了解

我其實平實習慣用opencv, 順手改了

閒暇之余隨便兜出來的, 若有錯誤跟疑問都歡迎討論

--

--

ss
ss

No responses yet