概覽
SSD 和 YOLO 都是非常主流的 one-stage 目标檢測模型, 并且相對于 two-stage 的 RCNN 系列來說, SSD 的實作更加的簡明易懂, 接下來我将從以下幾個方面展開對 SSD 模型的源碼實作講解: - 模型結構定義 - DefaultBox 生成候選框 - 解析預測結果 - MultiBox 損失函數 - Augmentations Trick - 模型訓練 - 模型預測 - 模型驗證 - 其他輔助代碼
可以看出, 雖然 SSD 模型本身并不複雜, 但是也正是由于 one-stage 模型較簡單的原因, 其檢測的準确率相對于 two-stage 模型較低, 是以, 通常需要借助許多訓練和檢測時的 Tricks 來提升模型的精确度, 這些代碼我們會放在第三部分講解. 下面, 我們按照順序首先對 SSD 模型結構定義的源碼進行解析.(項目位址: https://github.com/amdegroot/ssd.pytorch)
模型結構定義
本部分代碼主要位于
ssd.py
檔案裡面, 在本檔案中, 定義了SSD的模型結構. 主要包含以下類和函數, 整體概覽如下:
# ssd.py
class SSD(nn.Module): # 自定義SSD網絡
def __init__(self, phase, size, base, extras, head, num_classes):
# ... SSD 模型初始化
def forward(self, x):
# ... 定義forward函數, 将設計好的layers和ops應用到輸入圖檔 x 上
def load_weights(self, base_file):
# ... 加載參數權重值
def vgg(cfg, i, batch_norm=False):
# ... 搭建vgg網絡
def add_extras(cfg, i, batch_norm=False):
# ... 向VGG網絡中添加額外的層用于feature scaling
def multibox(vgg, extra_layers, cfg, num_classes):
# ... 建構multibox結構
base = {...} # vgg 網絡結構參數
extras = {...} # extras 層參數
mbox = {...} # multibox 相關參數
def build_ssd(phase, size=300, num_classes=21):
# ... 構模組化型函數, 調用上面的函數進行建構
為了友善了解, 我們不按照檔案中的定義順序解析, 而是根據檔案中函數的調用關系來從外而内, 從上而下的進行解析, 解析順序如下: - build_ssd(...) 函數 - vgg(...) 函數 - add_extras(...) 函數 - multibox(...) 函數 - SSD(nn.Module) 類
build_ssd(...) 函數
在其他檔案通常利用
build_ssd(phase, size=300, num_classes=21)
函數來建立模型, 下面先看看該函數的具體實作:
# ssd.py
class SSD(nn.Module): # 自定義SSD網絡
def __init__(self, phase, size, base, extras, head, num_classes):
# ...
def forward(self, x):
# ...
def load_weights(self, base_file):
# ...
def vgg(cfg, i, batch_norm=False):
# ... 搭建vgg網絡
def add_extras(cfg, i, batch_norm=False):
# ... 向VGG網絡中添加額外的層用于feature scaling
def multibox(vgg, extra_layers, cfg, num_classes):
# ... 建構multibox結構
base = { # vgg 網絡結構參數
'300': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M', 512, 512, 512],
'500': []
}
extras = { # extras 層參數
'300': [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256],
'500': []
}
mbox = { # multibox 相關參數
'300': [4, 6, 6, 6, 4, 4],
'500': []
}
def build_ssd(phase, size=300, num_classes=21):
# 構模組化型函數, 調用上面的函數進行建構
if phase != "test" and phase != "train": # 隻能是訓練或者預測階段
print("ERROR: Phase: " + phase + " not recognized")
return
if size != 300:
print("ERROR: You specified size " + repr(size) + ". However, "+
"currently only SSD300 is supported!") # 僅僅支援300size的SSD
return
base_, extras_, head_ = multibox(vgg(base[str(size)], 3),
add_extras(extras[str(size), 1024),
mbox[str(size)], num_classes )
return SSD(phase, size, base_, extras_, head_, num_classes)
可以看到,
build_ssd(...)
函數主要使用了
multibox(...)
函數來擷取
base_, extras_, head_
, 在調用
multibox(...)
函數的同時, 還分别調用了
vgg(...)
函數,
add_extras(...)
函數, 并将其傳回值作為參數. 之後, 利用這些資訊初始化了SSD網絡. 那麼下面, 我們就先檢視一下這些函數定義和作用
vgg(...) 函數
我們以調用順序為依據, 先對
multibox(...)
函數的内部實作進行解析, 但是在檢視
multibox(...)
函數之前, 我們首先需要看看其參數的由來, 首先是
vgg(...)
函數, 因為 SSD 是以 VGG 網絡作為 backbone 的, 是以該函數主要定義了 VGG 網絡的結果, 根據調用語句
vgg(base[str(size)], 3)
可以看出, 調用
vgg
時向其傳入了兩個參數, 分别為
base[str(size)]
和
3
, 對應的就是
base['300']
和3.
# ssd.py
def vgg(cfg, i, batch_norm = False):
# cfg = base['300'] = [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M', 512, 512, 512],
# i = 3
layers = []
in_channels = i
for v in cfg:
if v == 'M':
layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
if v == 'C':
layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]
else:
conv2d = nn.Conv2d(in_channels=in_channels, out_channels=v, kernel_size=3, padding=1)
if batch_norm:
layers += [conv2d, nn.BatchNorm2d(v), nn.ReLU(inplace=True)]
else:
layers += [conv2d, nn.ReLU(inplace=True)]
in_channels = v
pool5 = nn.MaxPool2d(kernel_size=3, stride=1, padding=1)
conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6)
conv7 = nn.Con2d(1024, 1024, kernel_size=1)
layers += [pool5, conv6, nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]
return layers
上面的寫法是
ssd.pytorch
代碼中的原始寫法, 代碼風格展現了 PyTorch 靈活的程式設計特性, 但是這種寫法不是那麼直覺, 需要很詳細的解讀才能看出來這個網絡的整個結構是什麼樣的. 建議大家結合 VGG 網絡的整個結構來解讀這部分代碼, 核心思想就是通過預定義的
cfg=base={...}
裡面的參數來設定 vgg 網絡卷積層和池化層的參數設定, 由于 vgg 網絡的模型結構很經典, 有很多文章都寫的很詳細, 這裡就不再啰嗦了, 我們主要來看一下 SSD 網絡中比較重要的點, 也就是下面的
extras_layers
.
add_extras(...) 函數
想必了解 SSD 模型的朋友都知道, SSD 模型中是利用多個不同層級上的 feature map 來進行同時進行邊框回歸和物體分類任務的, 除了使用 vgg 最深層的卷積層以外, SSD 還添加了幾個卷積層, 專門用于執行回歸和分類任務(如文章開頭圖2所示), 是以, 我們在定義完 VGG 網絡以後, 需要額外定義這些新添加的卷積層. 接下來, 我們根據論文中的參數設定, 來看一下
add_extras(...)
的内部實作, 根據調用語句
add_extras(extras[str(size)], 1024)
可知, 該函數中參數
cfg = extras['300']
,
i=1024
.
# ssd.py
def add_extras(cfg, i, batch_norm=False):
# cfg = [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256]
# i = 1024
layers = []
in_channels = i
flag = False
for k, v in enumerate(cfg):
if in_channels != 'S':
if v == 'S': # (1,3)[True] = 3, (1,3)[False] = 1
layers += [nn.Conv2d(in_channels=in_channels, out_channels=cfg[k+1],
kernel_size=(1, 3)[flag], stride=2, padding=1)]
else:
layers += [nn.Conv2d(in_channels=in_channels, out_channels=v,
kernel_size=(1, 3)[flag])]
flag = not flag
in_channels = v
return layers
注意, 在 extras
中, 卷積層之間并沒有使用 BatchNorm 和 ReLU, 實際上, ReLU 的使用放在了 forward
函數中 同樣的問題, 上面的定義不是很直覺, 是以我将上面的代碼用 PyTorch 重寫了, 重寫後的代碼更容易看出網絡的結構資訊, 同時可讀性也較強, 代碼如下所示(與上面的代碼完全等價):
def add_extras():
exts1_1 = nn.Conv2d(in_channels=1024, out_channels=256, kernel_size=1)
exts1_2 = nn.Conv2d(in_channels=256, out_channels=512, kernel_size=3, stride=2, padding=1)
exts2_1 = nn.Conv2d(512, 128, 1, 1, 0)
exts2_2 = nn.Conv2d(128, 256, 3, 2, 1)
exts3_1 = nn.Conv2d(256, 128, 1, 1, 0)
exts3_2 = nn.Conv2d(128, 256, 3, 1, 0)
exts4_1 = nn.Conv2d(256, 128, 1, 1, 0)
exts4_2 = nn.Conv2d(128, 256, 3, 1, 0)
return [exts1_1, exts1_2, exts2_1, exts2_2, exts3_1, exts3_2, exts4_1, exts4_2]
在定義完整個的網絡結構以後, 我們就需要定義最後的 head 層, 也就是特定的任務層, 因為 SSD 是 one-stage 模型, 是以它是同時在特征圖譜上産生預測邊框和預測分類的, 我們根據類别的數量來設定相應的網絡預測層參數, 注意需要用到多個特征圖譜, 也就是說要有多個預測層(原文中用了6個卷積特征圖譜, 其中2個來自于 vgg 網絡, 4個來自于 extras 層), 代碼實作如下:
multibox(...) 函數
multibox(...)
總共有4個參數, 現在我們已經得到了兩個參數, 分别是
vgg(...)
函數傳回的
layers
, 以及
add_extras(...)
函數傳回的
layers
, 後面兩個參數根據調用語句可知分别為
mbox[str(size)]
(
mbox['300']
)和
num_classes
(預設為21). 下面, 看一下
multibox(...)
函數的具體内部實作:
# ssd.py
def multibox(vgg, extra_layers, cfg, num_classes):
# cfg = [4, 6, 6, 6, 4, 4]
# num_classes = 21
# ssd總共會選擇6個卷積特征圖譜進行預測, 分别為, vggnet的conv4_3, 以及extras_layers的5段卷積的輸出(每段由兩個卷積層組成, 具體可看extras_layers的實作).
# 也就是說, loc_layers 和 conf_layers 分别具有6個預測層.
loc_layers = []
conf_layers = []
vgg_source = [21, -2]
for k, v in enumerate(vgg_source):
loc_layers += [nn.Conv2d(vgg[v].out_channels, cfg[k]*4, kernel_size=3, padding=1]
conf_layers += [nn.Conv2d(vgg[v].out_channels, cfg[k]*num_classes, kernel_size=3, padding=1)]
for k, v in enumerate(extra_layers[1::2], 2):
loc_layers += [nn.Conv2d(v.out_channels, cfg[k]*4, kernel_size=3, padding=1)]
conf_layers += [nn.Conv2d(v.out_channels, cfg[k]*num_classes, kernel_size=3, padding=1)]
return vgg, extra_layers, (loc_layers, conf_layers)
同樣, 我們可以将上面的代碼寫成可讀性更強的形式:
# ssd.py
def multibox(vgg, extras, num_classes):
loc_layers = []
conf_layers = []
#vgg_source=[21, -2] # 21 denote conv4_3, -2 denote conv7
# 定義6個坐标預測層, 輸出的通道數就是每個像素點上會産生的 default box 的數量
loc1 = nn.Conv2d(vgg[21].out_channels, 4*4, 3, 1, 1) # 利用conv4_3的特征圖譜, 也就是 vgg 網絡 List 中的第 21 個元素的輸出(注意不是第21層, 因為這中間還包含了不帶參數的池化層).
loc2 = nn.Conv2d(vgg[-2].out_channels, 6*4, 3, 1, 1) # Conv7
loc3 = nn.Conv2d(vgg[1].out_channels, 6*4, 3, 1, 1) # exts1_2
loc4 = nn.Conv2d(extras[3].out_channels, 6*4, 3, 1, 1) # exts2_2
loc5 = nn.Conv2d(extras[5].out_channels, 4*4, 3, 1, 1) # exts3_2
loc6 = nn.Conv2d(extras[7].out_channels, 4*4, 3, 1, 1) # exts4_2
loc_layers = [loc1, loc2, loc3, loc4, loc5, loc6]
# 定義分類層, 和定位層差不多, 隻不過輸出的通道數不一樣, 因為對于每一個像素點上的每一個default box,
# 都需要預測出屬于任意一個類的機率, 是以通道數為 default box 的數量乘以類别數.
conf1 = nn.Conv2d(vgg[21].out_channels, 4*num_classes, 3, 1, 1)
conf2 = nn.Conv2d(vgg[-2].out_channels, 6*num_classes, 3, 1, 1)
conf3 = nn.Conv2d(extras[1].out_channels, 6*num_classes, 3, 1, 1)
conf4 = nn.Conv2d(extras[3].out_channels, 6*num_classes, 3, 1, 1)
conf5 = nn.Conv2d(extras[5].out_channels, 4*num_classes, 3, 1, 1)
conf6 = nn.Conv2d(extras[7].out_channels, 4*num_classes, 3, 1, 1)
conf_layers = [conf1, conf2, conf3, conf4, conf5, conf6]
# loc_layers: [b×w1×h1×4*4, b×w2×h2×6*4, b×w3×h3×6*4, b×w4×h4×6*4, b×w5×h5×4*4, b×w6×h6×4*4]
# conf_layers: [b×w1×h1×4*C, b×w2×h2×6*C, b×w3×h3×6*C, b×w4×h4×6*C, b×w5×h5×4*C, b×w6×h6×4*C] C為num_classes
# 注意pytorch中卷積層的輸入輸出次元是:[N×C×H×W], 上面的順序有點錯誤, 不過改起來太麻煩
return loc_layers, conf_layers
定義完網絡中所有層的關鍵結構以後, 我們就可以利用這些結構來定義 SSD 網絡了, 下面就介紹一下 SSD 類的實作.
SSD(nn.Module) 類
在
build_ssd(...)
函數的最後, 利用語句
return SSD(phase, size, base_, extras_, head_, num_classes)
調用的傳回了一個
SSD
類的對象, 下面, 我們就來看一下看類的内部細節(這也是SSD模型的主要架構實作)
# ssd.py
class SSD(nn.Module):
# SSD網絡是由 VGG 網絡後街 multibox 卷積層 組成的, 每一個 multibox 層會有如下分支:
# - 用于class conf scores的卷積層
# - 用于localization predictions的卷積層
# - 與priorbox layer相關聯, 産生預設的bounding box
# 參數:
# phase: test/train
# size: 輸入圖檔的尺寸
# base: VGG16的層
# extras: 将輸出結果送到multibox loc和conf layers的額外的層
# head: "multibox head", 包含一系列的loc和conf卷積層.
def __init__(self, phase, size, base, extras, head, num_classes):
# super(SSD, self) 首先找到 SSD 的父類, 然後把類SSD的對象轉換為父類的對象
super(SSD, self).__init__()
self.phase = phase
self.num_classes = num_classes
self.cfg = (coco, voc)[num_classes == 21]
self.priorbox = PriorBox(self.cfg) # layers/functions/prior_box.py class PriorBox(object)
self.priors = Variable(self.priorbox.forward(), volatile=True) # from torch.autograd import Variable
self.size = size
self.vgg = nn.ModuleList(base)
self.L2Norm = L2Norm(512,20) # layers/modules/l2norm.py class L2Norm(nn.Module)
self.extras = nn.ModuleList(extras)
self.loc = nn.ModuleList(head[0]) # head = (loc_layers, conf_layers)
self.conf = nn.ModuleList(head[1])
if phase = "test":
self.softmax = nn.Softmax(dim=-1) # 用于囧穿機率
self.detect = Detect(num_classes, 0, 200, 0.01, 0.45) # layers/functions/detection.py class Detect
# 用于将預測結果轉換成對應的坐标和類别編号形式, 友善可視化.
def forward(self, x):
# 定義forward函數, 将設計好的layers和ops應用到輸入圖檔 x 上
# 參數: x, 輸入的batch 圖檔, Shape: [batch, 3, 300, 300]
# 傳回值: 取決于不同階段
# test: 預測的類别标簽, confidence score, 以及相關的location.
# Shape: [batch, topk, 7]
# train: 關于以下輸出的元素組成的清單
# 1: confidence layers, Shape: [batch*num_priors, num_classes]
# 2: localization layers, Shape: [batch, num_priors*4]
# 3: priorbox layers, Shape: [2, num_priors*4]
sources = list() # 這個清單存儲的是參與預測的卷積層的輸出, 也就是原文中那6個指定的卷積層
loc = list() # 用于存儲預測的邊框資訊
conf = list() # 用于存儲預測的類别資訊
# 計算vgg直到conv4_3的relu
for k in range(23):
x = self.vgg[k](x)
s = self.L2Norm(x)
sources.append(s) # 将 conv4_3 的特征層輸出添加到 sources 中, 後面會根據 sources 中的元素進行預測
# 将vgg應用到fc7
for k in range(23, len(self.vgg)):
x = self.vgg[k](x)
sources.append(x) # 同理, 添加到 sources 清單中
# 計算extras layers, 并且将結果存儲到sources清單中
for k, v in enumerate(self.extras):
x = F.relu(v(x), inplace=True) # import torch.nn.functional as F
if k % 2 = 1: # 在extras_layers中, 第1,3,5,7,9(從第0開始)的卷積層的輸出會用于預測box位置和類别, 是以, 将其添加到 sources清單中
sources.append(x)
# 應用multibox到source layers上, source layers中的元素均為各個用于預測的特征圖譜
# apply multibox to source layers
# 注意pytorch中卷積層的輸入輸出次元是:[N×C×H×W]
for (x, l, c) in zip(sources, self.loc, self.conf):
# permute重新排列次元順序, PyTorch次元的預設排列順序為 (N, C, H, W),
# 是以, 這裡的排列是将其改為 $(N, H, W, C)$.
# contiguous傳回記憶體連續的tensor, 由于在執行permute或者transpose等操作之後, tensor的記憶體位址可能不是連續的,
# 然後 view 操作是基于連續位址的, 是以, 需要調用contiguous語句.
loc.append(l(x).permute(0,2,3,1).contiguous())
conf.append(c(x).permute(0,2,3,1).contiguous())
# loc: [b×w1×h1×4*4, b×w2×h2×6*4, b×w3×h3×6*4, b×w4×h4×6*4, b×w5×h5×4*4, b×w6×h6×4*4]
# conf: [b×w1×h1×4*C, b×w2×h2×6*C, b×w3×h3×6*C, b×w4×h4×6*C, b×w5×h5×4*C, b×w6×h6×4*C] C為num_classes
# cat 是 concatenate 的縮寫, view傳回一個新的tensor, 具有相同的資料但是不同的size, 類似于numpy的reshape
# 在調用view之前, 需要先調用contiguous
loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1)
# 将除batch以外的其他次元合并, 是以, 對于邊框坐标來說, 最終的shape為(兩維):[batch, num_boxes*4]
conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1)
# 同理, 最終的shape為(兩維):[batch, num_boxes*num_classes]
if self.phase == "test":
# 這裡用到了 detect 對象, 該對象主要由于接預測出來的結果進行解析, 以獲得友善可視化的邊框坐标和類别編号, 具體實作會在後文讨論.
output = self.detect(
loc.view(loc.size(0), -1, 4), # 又将shape轉換成: [batch, num_boxes, 4], 即[1, 8732, 4]
self.softmax(conf.view(conf.size(0), -1, self.num_classes)), # 同理, shape 為[batch, num_boxes, num_classes], 即 [1, 8732, 21]
self.priors.type(type(x.data))
# 利用 PriorBox對象擷取特征圖譜上的 default box, 該參數的shape為: [8732,4]. 關于生成 default box 的方法實際上很簡單, 類似于 anchor box, 詳細的代碼實作會在後文解析.
# 這裡的 self.priors.type(type(x.data)) 與 self.priors 就結果而言完全等價(自己試驗過了), 但是為什麼?
)
if self.phase == "train": # 如果是訓練階段, 則無需解析預測結果, 直接傳回然後求損失.
output = (
loc.view(loc.size(0), -1, 4), conf.view(conf.size(0), -1, self.num_classes), self.priors
)
return output
def load_weights(self, base_file): # 加載權重檔案
other, ext = os.path.splitext(base_file)
if ext == ".pkl" or ".pth":
print("Loading weights into state dict...")
self.load_state_dict(torch.load(base_file, map_location=lambda storage, loc: storage))
print("Finished!")
else:
print("Sorry only .pth and .pkl files supported")
在上面的模型定義中, 我們可以看到使用其他幾個類, 分别是 -
layers/functions/prior_box.py class
的
PriorBox(object)
, -
layers/modules/l2norm.py
的
class L2Norm(nn.Module)
-
layers/functions/detection.py
的
class Detect
基本上從他們的名字就可以看出他們的用途, 其中, 最簡單的是 l2norm 類, 該類實際上就是實作了 L2歸一化(也可以利用 PyTorch API 提供的歸一化接口實作). 這一塊沒什麼好讨論的, 朋友們可以自己去源碼去檢視實作方法, 基本看一遍就能明白了.下面我們着重看一下用于生成 Default box(也可以看成是 anchor box) 的
PriorBox
類, 以及用于解析預測結果, 并将其轉換成邊框坐标和類别編号的
Detect
類. 首先來看如何利用卷積圖譜來生成 default box.
DefaultBox 生成候選框
根據 SSD 的原理, 需要在標明的特征圖譜上輸出 Default Box, 然後根據這些 Default Box 進行邊框回歸任務. 首先梳理一下生成 Default Box 的思路. 假如feature maps數量為 $m$, 那麼每一個feature map中的default box的尺寸大小計算如下:
$$s_k = s_{min} + frac{s_{max} - s_{min}}{m-1}(k-1), kin [1,m]$$
上式中, $s_{min} = 0.2 , s_{max} = 0.9$. 對于原文中的設定 $m=6 (4, 6, 6, 6, 4, 4)$, 是以就有 $s = {0.2, 0.34, 0.48, 0.62, 0.76, 0.9}$ 然後, 幾個不同的aspect ratio, 用 $a_r$ 表示: $a_r = {1,2,3,1/2,1/3}$, 則每一個default boxes 的width 和height就可以得到( $w_k^a h_k^a=a_r$ ):
$$w_k^a = s_k sqrt{a_r}$$
$$h_k^a = frac{s_k}{sqrt {a_r}}$$
對于寬高比為1的 default box, 我們額外添加了一個 scale 為 $s_k' = sqrt{s_k s_{k+1}}$ 的 box, 是以 feature map 上的每一個像素點都對應着6個 default boxes (
per feature map localtion). 每一個default box的中心, 設定為: $(frac{i+0.5}{|f_k|}, frac{j+0.5}{f_k})$, 其中, $|f_k|$ 是第 $k$ 個feature map的大小 $i,j$ 對應了 feature map 上所有可能的像素點.
在實際使用中, 可以自己根據資料集的特點來安排不同的 default boxes 參數組合了解原理以後, 就來看一下怎麼實作, 輸出 Default Box 的代碼定義在
layers/functions/prior_box.py
檔案中. 代碼如下所示:
# `layers/functions/prior_box.py`
class PriorBox(object):
# 所謂priorbox實際上就是網格中每一個cell推薦的box
def __init__(self, cfg):
# 在SSD的init中, cfg=(coco, voc)[num_classes=21]
# coco, voc的相關配置都來自于data/cfg.py 檔案
super(PriorBox, self).__init__()
self.image_size = cfg["min_dim"]
self.num_priors = len(cfg["aspect_ratios"])
self.variance = cfg["variance"] or [0.1]
self.min_sizes = cfg["min_sizes"]
self.max_sizes = cfg["max_sizes"]
self.steps = cfg["steps"]
self.aspect_ratios = cfg["aspect_ratios"]
self.clip = cfg["clip"]
self.version = cfg["name"]
for v in self.variance:
if v <= 0:
raise ValueError("Variances must be greater than 0")
def forward(self):
mean = []
for k, f in enumerate(self.feature_maps): # 存放的是feature map的尺寸:38,19,10,5,3,1
# from itertools import product as product
for i, j in product(range(f), repeat=2):
# 這裡實際上可以用最普通的for循環嵌套來代替, 主要目的是産生anchor的坐标(i,j)
f_k = self.image_size / self.steps[k] # steps=[8,16,32,64,100,300]. f_k大約為feature map的尺寸
# 求得center的坐标, 浮點類型. 實際上, 這裡也可以直接使用整數類型的 `f`, 計算上沒太大差别
cx = (j + 0.5) / f_k
cy = (i + 0.5) / f_k # 這裡一定要特别注意 i,j 和cx, cy的對應關系, 因為cy對應的是行, 是以應該零cy與i對應.
# aspect_ratios 為1時對應的box
s_k = self.min_sizes[k]/self.image_size
mean += [cx, cy, s_k, s_k]
# 根據原文, 當 aspect_ratios 為1時, 會有一個額外的 box, 如下:
# rel size: sqrt(s_k * s_(k+1))
s_k_prime = sqrt(s_k * (self.max_sizes[k]/self.image_size))
mean += [cx, cy, s_k_prime, s_k_prime]
# 其餘(2, 或 2,3)的寬高比(aspect ratio)
for ar in self.aspect_ratios[k]:
mean += [cx, cy, s_k*sqrt(ar), s_k/sqrt(ar)]
mean += [cx, cy, s_k/sqrt(ar), s_k*sqrt(ar)]
# 綜上, 每個卷積特征圖譜上每個像素點最終産生的 box 數量要麼為4, 要麼為6, 根據不同情況可自行修改.
output = torch.Tensor(mean).view(-1,4)
if self.clip:
output.clamp_(max=1, min=0) # clamp_ 是clamp的原地執行版本
return output # 輸出default box坐标(可以了解為anchor box)
最終, 輸出的ouput就是一張圖檔中所有的default box的坐标, 對于論文中的預設設定來說産生的box數量為: $$38^2 times 4+19^2 times 6+ 10^2 times 6+5^2 times 6+3^2 times 4+1^2 times 4 = 8732$$
解析預測結果
在模型中, 我們為了加快訓練速度, 促使模型收斂, 是以會将相應的 box 的坐标轉換成與圖檔size成比例的小數形式, 是以, 無法直接将模型産生的預測結果可視化. 下面, 我們首先會通過接受
Detect
類來說明如何解析預測結果, 同時, 還會根據源碼中提過的
demo
檔案來接受如何将對應的結果可視化出來, 首先, 來看一下
Detect
類的定義和實作:
# ./layers/
class Detect(Function):
# 測試階段的最後一層, 負責解碼預測結果, 應用nms選出合适的框和對應類别的置信度.
def __init__(self, num_classes, bkg_label, top_k, conf_thresh, nms_thresh):
self.num_classes = num_classes
self.background_label = bkg_label
self.top_k = top_k
self.conf_thresh = conf_thresh
self.nms_thresh = nms_thresh
self.variance = voc_config["variance"]
def forward(self, loc_data, conf_data, prior_data):
# loc_data: [batch, num_priors, 4], [batch, 8732, 4]
# conf_data: [batch, num_priors, 21], [batch, 8732, 21]
# prior_data: [num_priors, 4], [8732, 4]
num = loc_data.size(0) # batch_size
num_priors = prior_data.size(0)
output = torch.zeros(num, self.num_classes, self.top_k, 5) # output:[b, 21, k, 5]
conf_preds = conf_data.view(num, num_priors, self.num_classes).transpose(2,1) # 次元調換
# 将預測結果解碼
for i in range(num): # 對每一個image進行解碼
decoded_boxes = decode(loc_data[i], prior_data, self.variance)#擷取第i個圖檔的box坐标
conf_scores = conf_preds[i].clone() # 複制第i個image置信度預測結果
for cl in range(1, self.num_classes): # num_classes=21, 是以 cl 的值為 1~20
c_mask = conf_scores[cl].gt(self.conf_thresh) # 傳回由0,1組成的數組, 0代表小于thresh, 1代表大于thresh
scores = conf_scores[cl][c_mask] # 傳回值為1的對應下标的元素值(即傳回conf_scores中大于thresh的元素集合)
if scores.size(0) == 0:
continue # 沒有置信度, 說明沒有框
l_mask = c_mask.unsqueeze(1).expand_as(decoded_boxes) # 擷取對應box的二值矩陣
boxes = decoded_boxes[l_mask].view(-1,4) # 擷取置信度大于thresh的box的左上角和右下角坐标
# 傳回每個類别的最高的score 的下标, 并且除去那些與該box有較大交并比的box
ids, count = nms(boxes, scores, self.nms_thresh, self.top_k) # 從這些box裡面選出top_k個, count<=top_k
# count<=top_k
output[i, cl, :count] = torch.cat((scores[ids[:count]].unsqueeze(1), boxes[:count]), 1)
flt = output.contiguous().view(num,-1,5)
_, idx = flt[:, :, 0].sort(1, descending=True)
_, rank = idx.sort(1)
flt[(rank < self.top_k).unsqueeze(-1).expand_as(flt)].fill_(0)
# 注意, view共享tensor, 是以, 對flt的修改也會反應到output上面
return output
在這裡, 用到了兩個關鍵的函數
decode()
和
nms()
, 這兩個函數定義在
./layers/box_utils.py
檔案中, 代碼如下所示:
def decode(loc, priors, variances):
"""Decode locations from predictions using priors to undo
the encoding we did for offset regression at train time.
Args:
loc (tensor): location predictions for loc layers,
Shape: [num_priors,4]
priors (tensor): Prior boxes in center-offset form.
Shape: [num_priors,4].
variances: (list[float]) Variances of priorboxes
Return:
decoded bounding box predictions
"""
boxes = torch.cat((
priors[:, :2] + loc[:, :2] * variances[0] * priors[:, 2:],
priors[:, 2:] * torch.exp(loc[:, 2:] * variances[1])), 1)
boxes[:, :2] -= boxes[:, 2:] / 2
boxes[:, 2:] += boxes[:, :2]
return boxes
def nms(boxes, scores, overlap=0.5, top_k=200):
"""Apply non-maximum suppression at test time to avoid detecting too many
overlapping bounding boxes for a given object.
Args:
boxes: (tensor) The location preds for the img, Shape: [num_priors,4].
scores: (tensor) The class predscores for the img, Shape:[num_priors].
overlap: (float) The overlap thresh for suppressing unnecessary boxes.
top_k: (int) The Maximum number of box preds to consider.
Return:
The indices of the kept boxes with respect to num_priors.
"""
keep = scores.new(scores.size(0)).zero_().long()
if boxes.numel() == 0:
return keep
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
area = torch.mul(x2 - x1, y2 - y1)
v, idx = scores.sort(0) # sort in ascending order
# I = I[v >= 0.01]
idx = idx[-top_k:] # indices of the top-k largest vals
xx1 = boxes.new()
yy1 = boxes.new()
xx2 = boxes.new()
yy2 = boxes.new()
w = boxes.new()
h = boxes.new()
# keep = torch.Tensor()
count = 0
while idx.numel() > 0:
i = idx[-1] # index of current largest val
# keep.append(i)
keep[count] = i
count += 1
if idx.size(0) == 1:
break
idx = idx[:-1] # remove kept element from view
# load bboxes of next highest vals
torch.index_select(x1, 0, idx, out=xx1)
torch.index_select(y1, 0, idx, out=yy1)
torch.index_select(x2, 0, idx, out=xx2)
torch.index_select(y2, 0, idx, out=yy2)
# store element-wise max with next highest score
xx1 = torch.clamp(xx1, min=x1[i])
yy1 = torch.clamp(yy1, min=y1[i])
xx2 = torch.clamp(xx2, max=x2[i])
yy2 = torch.clamp(yy2, max=y2[i])
w.resize_as_(xx2)
h.resize_as_(yy2)
w = xx2 - xx1
h = yy2 - yy1
# check sizes of xx1 and xx2.. after each iteration
w = torch.clamp(w, min=0.0)
h = torch.clamp(h, min=0.0)
inter = w*h
# IoU = i / (area(a) + area(b) - i)
rem_areas = torch.index_select(area, 0, idx) # load remaining areas)
union = (rem_areas - inter) + area[i]
IoU = inter/union # store result in iou
# keep only elements with an IoU <= overlap
idx = idx[IoU.le(overlap)]
return keep, count
MultiBox 損失函數
在
layers/modules/multibox_loss.py
中定義了SSD模型的損失函數, 在SSD論文中, 損失函數具體定義如下: $$L_{loc}(x,l,g) = sum_{iin Pos}^N sum_{min{cx,cy,w,h}} x_{ij}^k smooth_{L_1}(l_i^m - hat g_j^m)$$
$$L_{conf}(x,c) = -sum_{iin Pos}^N x_{ij}^p log(hat c_i^p) - sum_{iin Neg} log(hat c_i^0), 其中, hat c_i^p = frac{exp(c_i^p)}{sum_p exp(c_i^p)}$$
損失函數定義
根據上面的公式, 我們可以定義下面的損失函數類, 該類繼承了
nn.Module
, 是以可以當做是一個
Module
用在訓練函數中.
# layers/modules/multibox_loss.py
class MultiBoxLoss(nn.Module):
# 計算目标:
# 輸出那些與真實框的iou大于一定門檻值的框的下标.
# 根據與真實框的偏移量輸出localization目标
# 用難樣例挖掘算法去除大量負樣本(預設正負樣本比例為1:3)
# 目标損失:
# L(x,c,l,g) = (Lconf(x,c) + αLloc(x,l,g)) / N
# 參數:
# c: 類别置信度(class confidences)
# l: 預測的框(predicted boxes)
# g: 真實框(ground truth boxes)
# N: 比對到的框的數量(number of matched default boxes)
def __init__(self, num_classes, overlap_thresh, prior_for_matching, bkg_label, neg_mining, neg_pos, neg_overlap, encode_target, use_gpu=True):
super(MultiBoxLoss, self).__init__()
self.use_gpu = use_gpu
self.num_classes= num_classes # 清單數
self.threshold = overlap_thresh # 交并比門檻值, 0.5
self.background_label = bkg_label # 背景标簽, 0
self.use_prior_for_matching = prior_for_matching # True 沒卵用
self.do_neg_mining = neg_mining # True, 沒卵用
self.negpos_ratio = neg_pos # 負樣本和正樣本的比例, 3:1
self.neg_overlap = neg_overlap # 0.5 判定負樣本的門檻值.
self.encode_target = encode_target # False 沒卵用
self.variance = cfg["variance"]
def forward(self, predictions, targets):
loc_data, conf_data, priors = predictions
# loc_data: [batch_size, 8732, 4]
# conf_data: [batch_size, 8732, 21]
# priors: [8732, 4] default box 對于任意的圖檔, 都是相同的, 是以無需帶有 batch 次元
num = loc_data.size(0) # num = batch_size
priors = priors[:loc_data.size(1), :] # loc_data.size(1) = 8732, 是以 priors 維持不變
num_priors = (priors.size(0)) # num_priors = 8732
num_classes = self.num_classes # num_classes = 21 (預設為voc資料集)
# 将priors(default boxes)和ground truth boxes比對
loc_t = torch.Tensor(num, num_priors, 4) # shape:[batch_size, 8732, 4]
conf_t = torch.LongTensor(num, num_priors) # shape:[batch_size, 8732]
for idx in range(num):
# targets是清單, 清單的長度為batch_size, 清單中每個元素為一個 tensor,
# 其 shape 為 [num_objs, 5], 其中 num_objs 為目前圖檔中物體的數量, 第二維前4個元素為邊框坐标, 最後一個元素為類别編号(1~20)
truths = targets[idx][:, :-1].data # [num_objs, 4]
labels = targets[idx][:, -1].data # [num_objs] 使用的是 -1, 而不是 -1:, 是以, 傳回的次元變少了
defaults = priors.data # [8732, 4]
# from ..box_utils import match
# 關鍵函數, 實作候選框與真實框之間的比對, 注意是候選框而不是預測結果框! 這個函數實作較為複雜, 會在後面着重講解
match(self.threshold, truths, defaults, self.variance, labels, loc_t, conf_t, idx) # 注意! 要清楚 Python 中的參數傳遞機制, 此處在函數内部會改變 loc_t, conf_t 的值, 關于 match 的詳細講解可以看後面的代碼解析
if self.use_gpu:
loc_t = loc_t.cuda()
conf_t = conf_t.cuda()
# 用Variable封裝loc_t, 新版本的 PyTorch 無需這麼做, 隻需要将 requires_grad 屬性設定為 True 就行了
loc_t = Variable(loc_t, requires_grad=False)
conf_t = Variable(conf_t, requires_grad=False)
pos = conf_t > 0 # 篩選出 >0 的box下标(大部分都是=0的)
num_pos = pos.sum(dim=1, keepdim=True) # 求和, 取得滿足條件的box的數量, [batch_size, num_gt_threshold]
# 位置(localization)損失函數, 使用 Smooth L1 函數求損失
# loc_data:[batch, num_priors, 4]
# pos: [batch, num_priors]
# pos_idx: [batch, num_priors, 4], 複制下标成坐标格式, 以便擷取坐标值
pos_idx = pos.unsqueeze(pos.dim()).expand_as(loc_data)
loc_p = loc_data[pos_idx].view(-1, 4)# 擷取預測結果值
loc_t = loc_t[pos_idx].view(-1, 4) # 擷取gt值
loss_l = F.smooth_l1_loss(loc_p, loc_t, size_average=False) # 計算損失
# 計算最大的置信度, 以進行難負樣本挖掘
# conf_data: [batch, num_priors, num_classes]
# batch_conf: [batch, num_priors, num_classes]
batch_conf = conf_data.view(-1, self.num_classes) # reshape
# conf_t: [batch, num_priors]
# loss_c: [batch*num_priors, 1], 計算每個priorbox預測後的損失
loss_c = log_sum_exp(batch_conf) - batch_conf.gather(1, conf_t.view(-1,1))
# 難負樣本挖掘, 按照loss進行排序, 取loss最大的負樣本參與更新
loss_c[pos.view(-1, 1)] = 0 # 将所有的pos下标的box的loss置為0(pos訓示的是正樣本的下标)
# 将 loss_c 的shape 從 [batch*num_priors, 1] 轉換成 [batch, num_priors]
loss_c = loss_c.view(num, -1) # reshape
# 進行降序排序, 并擷取到排序的下标
_, loss_idx = loss_c.sort(1, descending=True)
# 将下标進行升序排序, 并擷取到下标的下标
_, idx_rank = loss_idx.sort(1)
# num_pos: [batch, 1], 統計每個樣本中的obj個數
num_pos = pos.long().sum(1, keepdim=True)
# 根據obj的個數, 确定負樣本的個數(正樣本的3倍)
num_neg = torch.clamp(self.negpos_ratio*num_pos, max=pos.size(1)-1)
# 擷取到負樣本的下标
neg = idx_rank < num_neg.expand_as(idx_rank)
# 計算包括正樣本和負樣本的置信度損失
# pos: [batch, num_priors]
# pos_idx: [batch, num_priors, num_classes]
pos_idx = pos.unsqueeze(2).expand_as(conf_data)
# neg: [batch, num_priors]
# neg_idx: [batch, num_priors, num_classes]
neg_idx = neg.unsqueeze(2).expand_as(conf_data)
# 按照pos_idx和neg_idx訓示的下标篩選參與計算損失的預測資料
conf_p = conf_data[(pos_idx+neg_idx).gt(0)].view(-1, self.num_classes)
# 按照pos_idx和neg_idx篩選目标資料
targets_weighted = conf_t[(pos+neg).gt(0)]
# 計算二者的交叉熵
loss_c = F.cross_entropy(conf_p, targets_weighted, size_average=False)
# 将損失函數歸一化後傳回
N = num_pos.data.sum()
loss_l = loss_l / N
loss_c = loss_c / N
return loss_l, loss_c
GT box 與default box 的比對
在上面的代碼中, 有一個很重要的函數, 即
match()
函數, 因為我們知道, 當根據特征圖譜求出這些 prior box(default box, 8732個)以後, 我們僅僅知道這些 box 的 scale 和 aspect_ratios 資訊, 但是如果要計算損失函數, 我們就必須知道與每個 prior box 相對應的 ground truth box 是哪一個, 是以, 我們需要根據交并比來求得這些 box 之間的比對關系. 比對算法的核心思想如下: 1. 首先将找到與每個 gtbox 交并比最高的 defaultbox, 記錄其下标 2. 然後找到與每個 defaultbox 交并比最高的 gtbox. 注意, 這兩步不是一個互相的過程, 假想一種極端情況, 所有的priorbox與某個gtbox(标記為G)的交并比為1, 而其他gtbox分别有一個交并比最高的priorbox, 但是肯定小于1(因為其他的gtbox與G的交并比肯定小于1), 這樣一來, 就會使得所有的priorbox都與G比對. 3. 為了防止上面的情況, 我們将那些對于gtbox來說, 交并比最高的priorbox, 強制進行互相比對, 即令
best_truth_idx[best_prior_idx[j]] = j
, 詳細見下面的for循環. 4. 根據下标擷取每個priorbox對應的gtbox的坐标, 然後對坐标進行相應編碼, 并存儲起來, 同時将gt類别也存儲起來, 到此, 比對完成.
根據上面的求解思想, 我們可以實作相應的比對代碼, 主要用到了以下幾個函數: -
point_form(boxes)
: 将 boxes 的坐标資訊轉換成左上角和右下角的形式 -
intersect(box_a, box_b)
: 傳回 box_a 與 box_b 集合中元素的交集 -
jaccard(box_a, box_b)
: 傳回 box_a 與 box_b 集合中元素的交并比 -
encode(matched, priors, variances)
: 将 box 資訊編碼成小數形式, 友善網絡訓練 -
match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx)
: 比對算法, 通過調用上述函數實作比對功能
完整代碼及解析如下所示(位于
./layers/box_utils.py
檔案中):
# ./layers/box_utils.py
def point_form(boxes):
# 将(cx, cy, w, h) 形式的box坐标轉換成 (xmin, ymin, xmax, ymax) 形式
return torch.cat( (boxes[:2] - boxes[2:]/2), # xmin, ymin
(boxes[:2] + boxes[2:]/2), 1) # xmax, ymax
def intersect(box_a, box_b):
# box_a: (truths), (tensor:[num_obj, 4])
# box_b: (priors), (tensor:[num_priors, 4], 即[8732, 4])
# return: (tensor:[num_obj, num_priors]) box_a 與 box_b 兩個集合中任意兩個 box 的交集, 其中res[i][j]代表box_a中第i個box與box_b中第j個box的交集.(非對稱矩陣)
# 思路: 先将兩個box的次元擴充至相同次元: [num_obj, num_priors, 4], 然後計算面積的交集
# 兩個box的交集可以看成是一個新的box, 該box的左上角坐标是box_a和box_b左上角坐标的較大值, 右下角坐标是box_a和box_b的右下角坐标的較小值
A = box_a.size(0)
B = box_b.size(0)
# box_a 左上角/右下角坐标 expand以後, 次元會變成(A,B,2), 其中, 具體可看 expand 的相關原理. box_b也是同理, 這樣做是為了得到a中某個box與b中某個box的左上角(min_xy)的較大者(max)
# unsqueeze 為增加次元的數量, expand 為擴充次元的大小
min_xy = torch.max(box_a[:, :2].unsqueeze(1).expand(A,B,2),
box_b[:, :2].unsqueeze(0).expand(A,B,2)) # 在box_a的 A 和 2 之間增加一個次元, 并将次元擴充到 B. box_b 同理
# 求右下角(max_xy)的較小者(min)
max_xy = torch.min(box_a[:, 2:].unsqueeze(1).expand(A,B,2),
box_b[:, 2:].unsqueeze(0).expand(A,B,2))
inter = torch.clamp((max_xy, min_xy), min=0) # 右下角減去左上角, 如果為負值, 說明沒有交集, 置為0
return inter[:, :, 0] * inter[:, :, 0] # 高×寬, 傳回交集的面積, shape 剛好為 [A, B]
def jaccard(box_a, box_b):
# A ∩ B / A ∪ B = A ∩ B / (area(A) + area(B) - A ∩ B)
# box_a: (truths), (tensor:[num_obj, 4])
# box_b: (priors), (tensor:[num_priors, 4], 即[8732, 4])
# return: (tensor:[num_obj, num_priors]), 代表了 box_a 和 box_b 兩個集合中任意兩個 box之間的交并比
inter = intersect(box_a, box_b) # 求任意兩個box的交集面積, shape為[A, B], 即[num_obj, num_priors]
area_a = ((box_a[:,2]-box_a[:,0]) * (box_a[:,3]-box_a[:,1])).unsqueeze(1).expand_as(inter) # [A,B]
area_b = ((box_b[:,2]-box_b[:,0]) * (box_b[:,3]-box_b[:,1])).unsqueeze(0).expand_as(inter) # [A,B], 這裡會将A中的元素複制B次
union = area_a + area_b - inter
return inter / union # [A, B], 傳回任意兩個box之間的交并比, res[i][j] 代表box_a中的第i個box與box_b中的第j個box之間的交并比.
def encode(matched, priors, variances):
# 對邊框坐标進行編碼, 需要寬度方差和高度方差兩個參數, 具體公式可以參見原文公式(2)
# matched: [num_priors,4] 存儲的是與priorbox比對的gtbox的坐标. 形式為(xmin, ymin, xmax, ymax)
# priors: [num_priors, 4] 存儲的是priorbox的坐标. 形式為(cx, cy, w, h)
# return : encoded boxes: [num_priors, 4]
g_cxy = (matched[:, :2] + matched[:, 2:])/2 - priors[:, :2] # 用互相比對的gtbox的中心坐标減去priorbox的中心坐标, 獲得中心坐标的偏移量
g_cxy /= (variances[0]*priors[:, 2:]) # 令中心坐标分别除以 d_i^w 和 d_i^h, 正如原文公式所示
#variances[0]為0.1, 令其分别乘以w和h, 得到d_i^w 和 d_i^h
g_wh = (matched[:, 2:] - matched[:, :2]) / priors[:, 2:] # 令互相比對的gtbox的寬高除以priorbox的寬高.
g_wh = torch.log(g_wh) / variances[1] # 這裡這個variances[1]=0.2 不太懂是為什麼.
return torch.cat([g_cxy, g_wh], 1) # 将編碼後的中心坐标和寬高``連接配接起來, 傳回 [num_priors, 4]
def match(threshold, truths, priors, variances, labels, loc_t, conf_t, idx):
# threshold: (float) 确定是否比對的交并比門檻值
# truths: (tensor: [num_obj, 4]) 存儲真實 box 的邊框坐标
# priors: (tensor: [num_priors, 4], 即[8732, 4]), 存儲推薦框的坐标, 注意, 此時的框是 default box, 而不是 SSD 網絡預測出來的框的坐标, 預測的結果存儲在 loc_data中, 其 shape 為[num_obj, 8732, 4].
# variances: cfg['variance'], [0.1, 0.2], 用于将坐标轉換成友善訓練的形式(參考RCNN系列對邊框坐标的處理)
# labels: (tensor: [num_obj]), 代表了每個真實 box 對應的類别的編号
# loc_t: (tensor: [batches, 8732, 4]),
# conf_t: (tensor: [batches, 8732]),
# idx: batches 中圖檔的序号, 辨別目前正在處理的 image 在 batches 中的序号
overlaps = jaccard(truths, point_form(priors)) # [A, B], 傳回任意兩個box之間的交并比, overlaps[i][j] 代表box_a中的第i個box與box_b中的第j個box之間的交并比.
# 二部圖比對(Bipartite Matching)
# [num_objs,1], 得到對于每個 gt box 來說的比對度最高的 prior box, 前者存儲交并比, 後者存儲prior box在num_priors中的位置
best_prior_overlap, best_prior_idx = overlaps.max(1, keepdim=True) # keepdim=True, 是以shape為[num_objs,1]
# [1, num_priors], 即[1,8732], 同理, 得到對于每個 prior box 來說的比對度最高的 gt box
best_truth_overlap, best_truth_idx = overlaps.max(0, keepdim=True)
best_prior_idx.squeeze_(1) # 上面特意保留了次元(keepdim=True), 這裡又都把次元 squeeze/reduce 了, 實際上隻需用預設的 keepdim=False 就可以自動 squeeze/reduce 次元.
best_prior_overlap.squeeze_(1)
best_truth_idx.squeeze_(0)
best_truth_overlap.squeeze_(0)
best_truth_overlap.index_fill_(0, best_prior_idx, 2)
# 次元壓縮後變為[num_priors], best_prior_idx 次元為[num_objs],
# 該語句會将與gt box比對度最好的prior box 的交并比置為 2, 確定其最大, 以免防止某些 gtbox 沒有比對的 priorbox.
# 假想一種極端情況, 所有的priorbox與某個gtbox(标記為G)的交并比為1, 而其他gtbox分别有一個交并比
# 最高的priorbox, 但是肯定小于1(因為其他的gtbox與G的交并比肯定小于1), 這樣一來, 就會使得所有
# 的priorbox都與G比對, 為了防止這種情況, 我們将那些對gtbox來說, 具有最高交并比的priorbox,
# 強制進行互相比對, 即令best_truth_idx[best_prior_idx[j]] = j, 詳細見下面的for循環
# 注意!!: 因為 gt box 的數量要遠遠少于 prior box 的數量, 是以, 同一個 gt box 會與多個 prior box 比對.
for j in range(best_prior_idx.size(0)): # range:0~num_obj-1
best_truth_idx[best_prior_idx[j]] = j
# best_prior_idx[j] 代表與box_a的第j個box交并比最高的 prior box 的下标, 将與該 gtbox
# 比對度最好的 prior box 的下标改為j, 由此,完成了該 gtbox 與第j個 prior box 的比對.
# 這裡的循環隻會進行num_obj次, 剩餘的比對為 best_truth_idx 中原本的值.
# 這裡處理的情況是, priorbox中第i個box與gtbox中第k個box的交并比最高,
# 即 best_truth_idx[i]= k
# 但是對于best_prior_idx[k]來說, 它卻與priorbox的第l個box有着最高的交并比,
# 即best_prior_idx[k]=l
# 而對于gtbox的另一個邊框gtbox[j]來說, 它與priorbox[i]的交并比最大,
# 即但是對于best_prior_idx[j] = i.
# 那麼, 此時, 我們就應該将best_truth_idx[i]= k 修改成 best_truth_idx[i]= j.
# 即令 priorbox[i] 與 gtbox[j]對應.
# 這樣做的原因: 防止某個gtbox沒有比對的 prior box.
mathes = truths[best_truth_idx]
# truths 的shape 為[num_objs, 4], 而best_truth_idx是一個訓示下标的清單, 清單長度為 8732,
# 清單中的下标範圍為0~num_objs-1, 代表的是與每個priorbox比對的gtbox的下标
# 上面的表達式會傳回一個shape為 [num_priors, 4], 即 [8732, 4] 的tensor, 代表的就是與每個priorbox比對的gtbox的坐标值.
conf = labels[best_truth_idx]+1 # 與上面的語句道理差不多, 這裡得到的是每個prior box比對的類别編号, shape 為[8732]
conf[best_truth_overlap < threshold] = 0 # 将與gtbox的交并比小于門檻值的置為0 , 即認為是非物體框
loc = encode(matches, priors, variances) # 傳回編碼後的中心坐标和寬高.
loc_t[idx] = loc # 設定第idx張圖檔的gt編碼坐标資訊
conf_t[idx] = conf # 設定第idx張圖檔的編号資訊.(大于0即為物體編号, 認為有物體, 小于0認為是背景)
模型訓練
在定義了模型結構和相應的随時函數以後, 接下來就是訓練階段, 訓練代碼位于
train.py
檔案中, 下面對該檔案代碼進行解讀:
# train.py
def str2bool(v):
return v.lower() in ("yes", "true", "t", 1)
import argparse
parser = argparse.ArgumentParser(description="Single Shot MultiBox Detection")
#...
parser.add_argument("--cuda", default=True, type=str2bool,
help="Use CUDA to train model")
#...
args = parser.parse_args()
if torch.cuda.is_available():
if args.cuda:
torch.set_default_tensor_type("torch.cuda.FloatTensor")
else:
torch.set_default_tensor_type("torch.FloatTensor")
else:
torch.set_default_tensor_type("torch.FloatTensor")
def train():
# 該檔案中中主要的函數, 在main()中, 僅調用了該函數
if args.dataset == "COCO":
if args.dataset_root == VOC_ROOT:
# ...
cfg = coco # coco位于config.py檔案中
# COCODetection類 位于coco.py檔案中
# SSDAugmentation類 位于utils/augmentations.py檔案中
dataset = COCODetection(root=args.dataset_root,
transform=SSDAugmentation(cfg["min_dim"], MEANS))
elif args.dataset == "VOC":
if args.dataset_root == COCO_ROOT:
#...
cfg = voc
dataset = VOCDetection(root=args.dataset_root,
transform=SSDAugmentation(cfg["min_dim"], MEANS))
if args.visdom:
import visdom
viz = visdom.Visdom()
# from ssd import build_ssd
ssd_net = build_ssd("train", cfg["min_dim"], cfg["num_classes"])
net = ssd_net
if args.cuda:
net = torch.nn.DataParallel(ssd_net)
# import torch.backends.cudnn as cudnn
cudnn.benchmark = True # 大部分情況下, 這個flag可以讓内置的cuDNN的auto-tuner自動尋找最适合目前配置的算法.
if args.resume: # resume 類型為 str, 值為checkpoint state_dict file
ssd_net.load_weights(args.resume)
else:
vgg_weights = torch.load(args.save_folder + args.basenet)
ssd_net.load_state_dict(vgg_weights)
if args.cuda:
net = net.cuda() # 将所有的參數都移送到GPU記憶體中
if not args.resume:
ssd_net.extras.apply(weights_init) # 本檔案的函數: def weights_init(), 對網絡參數執行Xavier初始化.
ssd_net.loc.apply(weights_init)
ssd_net.conf.apply(weights_init)
# import torch.optim as optim
optimizer = optim.SGD(net.parameters(), lr=args.lr, momentum=args.momentum, weight_decay=args.weight_decay)
# MultiBoxLoss類 位于layers/modules/multibox_loss.py檔案中
criterion = MultiBoxLoss(cfg["num_classes"], 0.5, True, 0, True, 3, 0.5, False, args.cuda)
net.train()
# loss計數器
loc_loss = 0
conf_loss = 0
epoch = 0
epoch_size = len(dataset) // args.batch_size
step_index = 0
if args.visdom:
#...
# import torch.utils.data as data
data_loader = data.DataLoader(dataset, args.batch_size, num_workers=args.num_workers, shuffle=True, collate_fn=detection_collate, pin_memory=True)
# 建立batch疊代器
batch_iterator = iter(data_loader)
for iteration in range(args.start_iter, cfg["max_iter"]):
if args.visdom and iteration != 0 and (iteration % epoch_size==0):
update_vis_plot(epoch, loc_loss, conf_loss, epoch_plot, None, "append", epoch_size)
loc_loss = 0
conf_loss = 0
epoch += 1
if iteration in cfg["lr_steps"]:
step_index += 1
# 每經過固定疊代次數, 就将lr衰減1/10
adjust_learning_rate(optimizer, args.gamma, step_index)
# load train data
images, targets = next(batch_iterator)
if args.cuda:
images = Variable(images.cuda())
targets = [Variable(ann.cuda(), volatile=True) for ann in targets]
else:
images = Variable(images)
targets = [Variable(ann, valotile=True) for ann in targets]
# forward
t0 = time.time()
out = net(images)
# backprop
optimizer.zero_grad()
loss_l, loss_c = criterion(out, targets) # criterion = MultiBoxLoss(...)
loss = loss_l + loss_c
loss.backward()
optimizer.step()
t1 = time.time()
loc_loss += loss_l.data[0]
conf_loss += loss_c.data[0]
if iteratioin % 10 == 0:
# print(...) 每隔10次疊代就輸出一次訓練狀态資訊
if args.visdom:
# update_vis_plot(...)
if iteration != 0 and iteration % 5000 ==0:
# save model
模型驗證
下面是模型驗證的相關代碼, 存在于
./test.py
檔案中, 代碼沒有太多特殊的處理, 和
./train.py
檔案略有相似.
def test_net(save_folder, net, cuda, testset, transform, thresh):
filename = save_folder+"test1.txt"
num_images = len(testset)
for i in range(num_images):
print("Testing image {:d}/{:d}...".format(i+1, num_images))
img = testset.pull_image(i)
img_id, annotation = testset.pull_anno(i)
x = torch.from_numpy(transform(img)[0]).permute(2,0,1)
x = Variable(x.unsqueeze(0))
with open(filename, mode='a') as f:
f.write('n GROUND TRUTH FOR: ' + img_id + 'n')
for box in annotation:
f.write("label"+" || ".join(str(b) for b in box) + "n")
if cuda:
x = x.cuda()
y = net(x)
detections = y.data
# 将檢測結果傳回到圖檔上
scale = torch.Tensor([img.shape[1], img.shape[0], img.shape[1], img.shape[0]])
pred_num = 0
for i in range(detections.size(1)):
j = 0
while detections[0, i, j, 0] >= 0.6:
if pred_num == 0:
with open(filename, mode='a') as f:
f.write('PREDICTIONS' + 'n')
score = detections[0, i, j, 0]
label_name = labelmap[i-1]
pt = (detections[0, i, j, 1:]*scale).cpu().numpy()
coords = (pt[0], pt[1], pt[2], pt[3])
pred_num += 1
with open(filename, mode='a') as f:
f.write(str(pred_num)+' label:' + label_name + ' score' + str(socre) + ' '+ ' || '.join(str(c) for c in coords) + 'n')
j += 1
def test_voc():
# 加載網絡
num_classes = len(VOC_CLASSES) + 1 # 1 為背景
net = build_ssd("test", 300, num_classes)
net.load_state_dict(torch.load(args.trained_model))
net.eval() # 将網絡隻與eval狀态, 主要會影響 dropout 和 BN 等網絡層
print("Finished loading model!")
# 加載資料
testset = VOCDetection(args.voc_root, [("2007", "test")], None, VOCAnnotationTransform())
if args.cuda:
net = net.cuda()
cudnn.benchmark = True
# evaluation
test_net(args.save_folder, net, args.cuda, testset, BaseTransform(net.size, (104, 117, 123)), thresh=args.visual_threshold)
if __name__ == '__main__':
test_voc()
其他輔助代碼
學習率衰減
def adjust_learning_rate(optimizer, gamma, step):
lr = args.lr * (gamma ** (step)) ## **為幂乘
for param_group in optimizer.param_groups:
param_group["lr"] = lr
Xavier 初始化
# tran.py
def xavier(param):
init.xavier_uniform(param) # import torch.nn.init as init
def weights_init(m):
if isinstance(m, nn.Conv2d): # 隻對卷積層初始化
xavier(m.weight.data)
m.bias.data.zero_()