筆記目錄
1 Inference
Single shot指明瞭SSD算法屬於one-stage方法,MultiBox指明瞭SSD是多框預測。
SSD是一種one-stage方法,其主要思路是均勻地在圖片的不同位置進行密集抽樣,抽樣時可以採用不同尺度和長寬比,然後利用CNN提取特徵後直接進行分類與迴歸,整個過程只需要一步,所以其優勢是速度快。
2 基本結構與設計理念
2.1 default box & feature map cell
- feature map cell 就是將 feature map 切分成 或者 之後的一個個格子;
- 而 default box 就是每一個格子上,一系列固定大小的 box,即圖中虛線所形成的一系列 boxes。
2.2 Model
SSD 是基於一個前向傳播 CNN 網絡,產生一系列 固定大小(fixed-size) 的 bounding boxes,以及每一個 box 中包含物體實例的可能性,即 score。之後,進行一個 非極大值抑制(Non-maximum suppression) 得到最終的 predictions。
SSD 300中輸入圖像的大小是300x300,特徵提取部分使用了VGG16的卷積層,並將VGG16的兩個全連接層轉換成了普通的卷積層(圖中conv6和conv7),之後又接了多個卷積(conv8_1,conv8_2,conv9_1,conv9_2,conv10_1,conv10_2),最後用一個Global Average Pool來變成1x1的輸出(conv11_2)。
2.3 設計理念
2.3.1 多尺度特徵預測
-
採用多尺度特徵圖用於分類和位置迴歸:
SSD將conv4_3、conv7、conv8_2、conv9_2、conv10_2、conv11_2都連接到了最後的檢測、分類層做迴歸。CNN網絡一般前面的特徵圖比較大,後面會逐漸採用stride=2的卷積或者pool來降低特徵圖大小。如下圖所示,一個比較大的特徵圖和一個比較小的特徵圖,它們都用來做檢測。這樣做的好處是比較大的特徵圖來用來檢測相對較小的目標,而小的特徵圖負責檢測大目標,8x8的特徵圖可以劃分更多的單元,但是其每個單元的先驗框尺度比較小,可檢測相對較小的目標。
2.2.2 採用卷積進行檢測
SSD直接採用卷積對不同的特徵圖來進行提取檢測結果。對於形狀爲 的特徵圖,只需要採用 這樣比較小的卷積核得到檢測值。
使用一系列 convolutional filters,去產生一系列固定大小的 predictions,
產生的 predictions有以下兩種:
- 歸屬類別的一個得分
- 相對於 default box coordinate 的 shape offsets。
2.2.3 Anchor
Anchor的生成:
-
特徵圖的每個點都會生成一大一小兩個正方形的anchor。 小方形的邊長用min_size來表示,大方形的邊長用來表示(min_size與max_size的值每一層都不同)。
-
多個長方形的anchor。 長方形anchor的數目在不同層級會有差異,他們的長寬可以用下面的公式來表達,ratio的數目就決定了某層上每一個點對應的長方形anchor的數目:
上面的min_size和max_size由下式計算得到,,m代表全部用於迴歸的層數,比如在SSD 300中m就是6。第k層的,第k層的
多個層級上的anchor迴歸:
如圖所示,左邊較低的層級因爲feature map尺寸比較大,anchor覆蓋的範圍就比較小,遠小於ground truth的尺寸,所以這層上所有anchor對應的IOU都比較小;右邊較高的層級因爲feature map尺寸比較小,anchor覆蓋的範圍就比較大,遠超過ground truth的尺寸,所以IOU也同樣比較小;只有圖2中間的anchor纔有較大的IOU。通過同時對多個層級上的anchor計算IOU,就能找到與ground truth的尺寸、位置最接近(即IOU最大)的一批anchor,在訓練時也就能達到最好的準確度。
2.2.4 Loss
SSD包含三部分的loss:前景分類的loss、背景分類的loss、位置迴歸的loss。計算公式如下:
其中:
-
是與 ground truth box 相匹配的 default boxes 個數
-
localization loss(loc) 是 Smooth L1 Loss,用在 與 參數(即中心座標位置,width、height)中,迴歸 bounding boxes 的中心位置,以及 width、height
-
confidence loss(conf) 是 Softmax Loss,輸入爲每一類的置信度
-
權重項 ,設置爲 1
是前景的分類loss和背景的分類loss的和,是所有用於前景分類的anchor的位置座標的迴歸loss。
N表示被選擇用作前景分類的anchor的數目,在源碼中把的anchor都用於前景分類,在的anchor中選擇部分用作背景分類。只選擇部分的原因是背景anchor的數目一般遠遠大於前景anchor,如果都選爲背景,就會弱化前景loss的值,造成定位不準確。
在作者源碼中背景分類的anchor數目定爲前景分類anchor數的三倍來保持它們的平衡。是第i個anchor對第j個ground truth的分類值,不是1就是0。
位置迴歸仍採用Smooth L1方法其中的α是前景loss和背景loss的調節比例,論文中。
2.2.4.1 迴歸預測
:
-
當預測值與目標值相差很大時, 因爲梯度裏包含了,梯度容易爆炸, ,所以SSD使用SmoothL1Loss損失函數。當差值太大時, 原先L2梯度裏的被替換成了±1, 這樣就避免了梯度爆炸, 也就是它更加健壯。
-
邊界框預測時使用了 L1 損失,但這個函數在 0 點處導數不唯一,因此可能會影響收斂。一個常用改進是在 0 點附近使用平方函數使得它更加平滑。它被稱之爲平滑 L1 損失函數。它通過一個參數 σ 來控制平滑的區域:
曲線圖如下所示:
2.2.4.2 類別預測
:
使用了交叉熵損失函數。假設對真實類別 j 的概率預測是 ,交叉熵損失爲 。我們可以使用一個被稱爲關注損失(focal loss)的函數來對之稍微變形。給定正的 和 ,它的定義如下:
增加 可以減小正類預測值比較大時的損失。
2.2.4.3 SSD在6個層級上進行迴歸
3 總結
4 SSD的Gluon實現
4.1 類別預測
- 假設物體有
n
類,則需要對錨框作n+1
個分類,其中類0表示背景。以輸入像素爲中心輸入a
個錨框,設高、寬分別爲h、w,會有a*h*w
個預測結果。 - 使用卷積層的通道來輸出類別預測。如果使用全連接層作爲輸出,可能會導致有過多的模型參數。NIN
類別預測層使用一個保持輸入高寬的卷積層,其輸出的 (x,y)
像素通道里包含了以輸入 (x,y)
像素爲中心的所有錨框的類別預測。其輸出通道數爲 a(n+1)
,其中通道 i(n+1)
是第 i 個錨框預測的背景置信度,而通道 i(n+1)+j+1
則是第 i 錨框預測的第 j 類物體的置信度。
def cls_predictor(num_anchors, num_classes):
return nn.Conv2D(num_anchors * (num_classes + 1), kernel_size=3,
padding=1)
定義分類器:
指定 a
和n
後,它使用一個填充爲 1 的3×3
卷積層。注意到我們使用了較小的卷積窗口,它可能不能覆蓋錨框定義的區域。所以我們需要保證前面的卷積層能有效的將較大的錨框區域的特徵濃縮到一個 3×3
的窗口裏。
4.2 邊界框預測
對每個錨框我們需要預測如何將其變換到真實的物體邊界框。變換由一個長爲 4 的向量來描述,分別表示左下和右上的 x、y 軸座標偏移。與類別預測類似,這裏我們同樣使用一個保持高寬的卷積層來輸出偏移預測,它有 4a 個輸出通道,對於第 i 個錨框,它的偏移預測在 4i
到 4i+3
這 4 個通道里。
def bbox_predictor(num_anchors):
return nn.Conv2D(num_anchors * 4, kernel_size=3, padding=1)
4.3 合成多層的預測輸出
SSD 中會在多個尺度上進行預測。由於每個尺度上的輸入高寬和錨框的選取不一樣,導致其形狀各不相同。下面例子構造兩個尺度的輸入,其中第二個爲第一個的高寬減半。然後構造兩個類別預測層,其分別對每個輸入像素構造 5 個和 3 個錨框。
def forward(x, block):
block.initialize()
return block(x)
y1 = forward(nd.zeros((2, 8, 20, 20)), cls_predictor(5, 10))
y2 = forward(nd.zeros((2, 16, 10, 10)), cls_predictor(3, 10))
(y1.shape, y2.shape)
out:((2, 55, 20, 20), (2, 33, 10, 10))
預測的輸出格式爲(批量大小,通道數,高,寬)。首先將通道,即預測結果,放到最後。因爲不同尺度下批量大小保持不變,所以將結果轉成二維的(批量大小,高 × 寬 × 通道數)格式,方便之後的拼接。
首先將通道,即預測結果,放到最後。因爲不同尺度下批量大小保持不變,所以將結果轉成二維的(批量大小,高 × 寬 × 通道數)格式,方便之後的拼接。
def flatten_pred(pred):
return pred.transpose(axes=(0, 2, 3, 1)).flatten()
拼接就是簡單將在維度 1 上合併結果。
def concat_preds(preds):
return nd.concat(*[flatten_pred(p) for p in preds], dim=1)
concat_preds([y1, y2]).shape
out:(2, 25300)
4.4 減半模塊
減半模塊將輸入高寬減半來得到不同尺度的特徵,這是通過步幅 2 的 2×2 最大池化層來完成。我們前面提到因爲預測層的窗口爲 3,所以我們需要額外卷積層來擴大其作用窗口來有效覆蓋錨框區域。爲此我們加入兩個 3×3 卷積層,每個卷積層後接批量歸一化層和 ReLU 激活層。這樣,一個尺度上的 3×3 窗口覆蓋了上一個尺度上的 10×10 窗口。
def down_sample_blk(num_filters):
blk = nn.HybridSequential()
for _ in range(2):
blk.add(nn.Conv2D(num_filters, kernel_size=3, padding=1),
nn.BatchNorm(in_channels=num_filters),
nn.Activation('relu'))
blk.add(nn.MaxPool2D(2))
blk.hybridize()
return blk
forward(nd.zeros((2, 3, 20, 20)), down_sample_blk(10)).shape
out:(2, 10, 10, 10)
4.5 主體網絡
主體網絡用來從原始圖像抽取特徵,一般會選擇常用的深度卷積神經網絡。一般使用了 VGG,大家也常用 ResNet 替代。本小節爲了計算簡單,我們構造一個小的主體網絡。網絡中疊加三個減半模塊,輸出通道數從 16 開始,之後每個模塊對其翻倍。
def body_blk():
blk = nn.HybridSequential()
for num_filters in [16, 32, 64]:
blk.add(down_sample_blk(num_filters))
return blk
forward(nd.zeros((2, 3, 256, 256)), body_blk()).shape
out: (2, 64, 32, 32)
4.6 完整的模型
構建整個模型,這個模型有五個模塊,每個模塊對輸入進行特徵抽取,並且預測錨框的類和偏移。第一個模塊使用主體網絡,第二到四模塊使用減半模塊,最後一個模塊則使用全局的最大池化層來將高寬降到 1。
def get_blk(i):
if i == 0:
blk = body_blk()
elif i == 4:
blk = nn.GlobalMaxPool2D()
else:
blk = down_sample_blk(128)
return blk
定義每個模塊前向計算。它跟之前的卷積神經網絡不同在於,不僅輸出卷積塊的輸出,而且還返回在輸出上生成的錨框,以及每個錨框的類別預測和偏移預測。
def single_scale_forward(x, blk, size, ratio, cls_predictor, bbox_predictor):
y = blk(x)
anchor = contrib.ndarray.MultiBoxPrior(y, sizes=size, ratios=ratio)
cls_pred = cls_predictor(y)
bbox_pred = bbox_predictor(y)
return (y, anchor, cls_pred, bbox_pred)
定義其輸出上的錨框如何生成。比例固定成 1、2 和 0.5,但大小上則不同,用於覆蓋不同的尺度。
num_anchors = 4
sizes = [[0.2, 0.272], [0.37, 0.447], [0.54, 0.619], [0.71, 0.79],
[0.88, 0.961]]
ratios = [[1, 2, 0.5]] * 5
4.7完整模型定義
class TinySSD(nn.Block):
def __init__(self, num_classes, verbose=False, **kwargs):
super(TinySSD, self).__init__(**kwargs)
self.num_classes = num_classes
for i in range(5):
setattr(self, 'blk_%d' % i, get_blk(i))
setattr(self, 'cls_%d' % i, cls_predictor(num_anchors,
num_classes))
setattr(self, 'bbox_%d' % i, bbox_predictor(num_anchors))
def forward(self, x):
anchors, cls_preds, bbox_preds = [None] * 5, [None] * 5, [None] * 5
for i in range(5):
x, anchors[i], cls_preds[i], bbox_preds[i] = single_scale_forward(
x, getattr(self, 'blk_%d' % i), sizes[i], ratios[i],
getattr(self, 'cls_%d' % i), getattr(self, 'bbox_%d' % i))
return (nd.concat(*anchors, dim=1),
concat_preds(cls_preds).reshape(
(0, -1, self.num_classes + 1)),
concat_preds(bbox_preds))
net = TinySSD(num_classes=2, verbose=True)
net.initialize()
x = nd.zeros((2, 3, 256, 256))
anchors, cls_preds, bbox_preds = net(x)
print('output achors:', anchors.shape)
print('output class predictions:', cls_preds.shape)
print('output box predictions:', bbox_preds.shape)
output achors: (1, 5444, 4)
output class predictions: (2, 5444, 3)
output box predictions: (2, 21776)
4.8 訓練
4.8.1 讀取數據和初始化訓練
數據集
batch_size = 32
train_data, test_data = gb.load_data_pikachu(batch_size)
# GPU 實現裏要求每張圖像至少有三個邊界框,我們加上兩個標號爲 -1 的邊界框。
train_data.reshape(label_shape=(3, 5))
模型和訓練器的初始化跟之前類似。
ctx = gb.try_gpu()
net = TinySSD(num_classes=2)
net.initialize(init=init.Xavier(), ctx=ctx)
trainer = gluon.Trainer(net.collect_params(),
'sgd', {'learning_rate': 0.1, 'wd': 5e-4})
4.8.2 損失和評估函數
4.8.2.1 損失函數
- 每個錨框的類別預測: Softmax 和交叉熵損失
- 正類錨框的偏移預測:L1 損失函數
cls_loss = gloss.SoftmaxCrossEntropyLoss()
bbox_loss = gloss.L1Loss()
def calc_loss(cls_preds, cls_labels, bbox_preds, bbox_labels, bbox_masks):
cls = cls_loss(cls_preds, cls_labels)
bbox = bbox_loss(bbox_preds * bbox_masks, bbox_labels * bbox_masks)
return cls + bbox
4.8.2.2 評估函數
- 分類:沿用之前的分類精度
- 錨框邊框:因爲使用了 L1 損失,用平均絕對誤差評估邊框預測的性能。
def cls_metric(cls_preds, cls_labels):
# 注意這裏類別預測結果放在最後一維,argmax 的時候指定使用最後一維。
return (cls_preds.argmax(axis=-1) == cls_labels).mean().asscalar()
def bbox_metric(bbox_preds, bbox_labels, bbox_masks):
return (bbox_labels - bbox_preds * bbox_masks).abs().mean().asscalar()
4.8.3 訓練模型
for epoch in range(1, 21):
acc, mae = 0, 0
train_data.reset() # 從頭讀取數據。
tic = time.time()
for i, batch in enumerate(train_data):
# 複製數據到 GPU。
X = batch.data[0].as_in_context(ctx)
Y = batch.label[0].as_in_context(ctx)
with autograd.record():
# 對每個錨框預測輸出。
anchors, cls_preds, bbox_preds = net(X)
# 對每個錨框生成標號。
bbox_labels, bbox_masks, cls_labels = contrib.nd.MultiBoxTarget(
anchors, Y, cls_preds.transpose(axes=(0, 2, 1)))
# 計算類別預測和邊界框預測損失。
l = calc_loss(cls_preds, cls_labels,
bbox_preds, bbox_labels, bbox_masks)
# 計算梯度和更新模型。
l.backward()
trainer.step(batch_size)
# 更新類別預測和邊界框預測評估。
acc += cls_metric(cls_preds, cls_labels)
mae += bbox_metric(bbox_preds, bbox_labels, bbox_masks)
if epoch % 5 == 0:
print('epoch %2d, class err %.2e, bbox mae %.2e, time %.1f sec' % (
epoch, 1 - acc / (i + 1), mae / (i + 1), time.time() - tic))
4.9 預測
在預測階段,我們希望能把圖像裏面所有感興趣的物體找出來。我們首先定義一個圖像預處理函數,它對圖像進行變換然後轉成卷積層需要的四維格式。
def process_image(file_name):
img = image.imread(file_name)
data = image.imresize(img, 256, 256).astype('float32')
return data.transpose((2, 0, 1)).expand_dims(axis=0), img
x, img = process_image('../img/pikachu.jpg')
在預測的時候,我們通過MultiBoxDetection
函數來合併預測偏移和錨框得到預測邊界框,並使用 NMS
去除重複的預測邊界框。
def predict(x):
anchors, cls_preds, bbox_preds = net(x.as_in_context(ctx))
cls_probs = cls_preds.softmax().transpose((0, 2, 1))
out = contrib.nd.MultiBoxDetection(cls_probs, bbox_preds, anchors)
idx = [i for i, row in enumerate(out[0]) if row[0].asscalar() != -1]
return out[0, idx]
out = predict(x)
最後我們將預測出置信度超過某個閾值的邊框畫出來:
gb.set_figsize((5, 5))
def display(img, out, threshold=0.5):
fig = gb.plt.imshow(img.asnumpy())
for row in out:
score = row[1].asscalar()
if score < threshold:
continue
bbox = [row[2:6] * nd.array(img.shape[0:2] * 2, ctx=row.context)]
gb.show_bboxes(fig.axes, bbox, '%.2f' % score, 'w')
display(img, out, threshold=0.4)
4.10 損失函數
邊界框預測時使用了 L1 損失,但這個函數在 0 點處導數不唯一,因此可能會影響收斂。一個常用改進是在 0 點附近使用平方函數使得它更加平滑。它被稱之爲平滑 L1 損失函數。它通過一個參數 σ 來控制平滑的區域:
KaTeX parse error: No such environment: split at position 7: \begin{̲s̲p̲l̲i̲t̲}̲f(x) =
\beg…
當 σ 很大時它類似於 L1 損失,變小時函數更加平滑。
sigmas = [10, 1, 0.5]
lines = ['-', '--', '-.']
x = nd.arange(-2, 2, 0.1)
gb.set_figsize()
for l, s in zip(lines, sigmas):
y = nd.smooth_l1(x, scalar=s)
gb.plt.plot(x.asnumpy(), y.asnumpy(), l, label='sigma=%.1f' % s)
gb.plt.legend();
對於類別預測我們使用了交叉熵損失。
def focal_loss(gamma, x):
return -(1 - x) ** gamma * x.log()
x = nd.arange(0.01, 1, 0.01)
for l, gamma in zip(lines, [0, 1, 5]):
y = gb.plt.plot(x.asnumpy(), focal_loss(gamma, x).asnumpy(), l,
label='gamma=%.1f' % gamma)
gb.plt.legend();
5 目標檢測指標MAP
5.1 IOU
loU(交併比)是模型所預測的檢測框和真實(ground truth)的檢測框的交集和並集之間的比例。
5.2 Precision
- 單個類別
- 單張圖像
圖像的類別C的Precision=圖像正確預測(True Positives)的數量除以在圖像這一類的總的目標數量。
5.3 Average Precision
- 單個類別
- m張圖像
一個C類的平均精度=在驗證集上所有的圖像對於類C的精度值的和/有類C這個目標的所有圖像的數量。
5.4 Mean Average Precision
- 給定n類
- 每類IOU
- 計算精度
- 計算平均精度
- 除以類的個數n
AP有20個不同的平均精度值。使用這些平均精度值,我們可以輕鬆的判斷任何給定類別的模型的性能。 但難以度量整個模型,所以選用一個單一的數字來表示一個模型的表現(一個度量來統一它們),我們可以取所有類的平均精度值的平均值,即MAP(均值平均精度)。
6 參考
- 解讀SSD目標檢測方法
- 論文閱讀:SSD: Single Shot MultiBox Detector
- 單發多框檢測(SSD)
- 目標檢測|SSD原理與實現
- Liu W, Anguelov D, Erhan D, et al. Ssd: Single shot multibox detector[C]//European conference on computer vision. Springer, Cham, 2016: 21-37.
7 下一步工作安排
- 標註乳液泵,製作乳液泵Pascol VOC樣本
- 研究SSD源碼,跑數據
- 深入瞭解SSD原理,並修改源碼,跑數據。