天天看點

ssd網絡結構_SSD 源碼實作詳細解析 (PyTorch)-模型定義篇

概覽

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_()