SSD之硬的不能再硬的硬核解析

本文是對經典論文 SSD: Single Shot MultiBox Detector 的解析,耗時3周完成,萬字長文,可能是你能看到的最硬核的SSD教程了,如果想一遍搞懂SSD,那就耐心讀下去吧~

一句話總結SSD效果就是:比YOLO快一點且準很多,比Faster R-CNN快很多且精度差不多(僅就當時時間線而言)。可見這篇paper在目標檢測領域的重要性,話不多說,開始正文。

行文大致按照如下的流程進行:

  • 首先嚐試對SSD的設計思想進行解析,帶你從大局觀上把握SSD的精髓
  • 隨後儘可能全面的挖掘paper中的細節,並對照相應的pytorch版本SSD實現代碼進行介紹

一、SSD設計思想解析

結合我自己的理解,在深入論文之前,這裏先來解析下SSD的設計思想。

既然帶檢測目標物體的位置、大小、形狀等情況多種多樣,那麼就暴力的預設大量的不同位置、不同尺度、不同長寬比的衆多候選區域,來儘可能的cover住所有的情況。然後通過CNN端到端的一次性完成這些候選區域是否爲關心的目標類別的預測,以及候選框與真實目標框之間偏差的預測,也就是對候選框進行微調,從而得到更好的目標框迴歸效果。
在這裏插入圖片描述
在這裏插入圖片描述
在這裏插入圖片描述
SSD的設計中有類似Faster R-CNN中anchor的概念,同時也能夠看到YOLO的影子,可以說是對兩者的優點都有借鑑。

下面我們來更爲詳細的解釋這一設計理念:

既然我們不知道待檢測的圖片中的物體大小,形狀和位置,那麼我們就儘可能羅列所有的情況。我們可能需要小一些的候選區域,那我們就按8*8,或者16*16的分辨率對原圖進行初步的分割;同時我們我們也需要大一些的候選區域來檢測大的物體,那我們也需要4*4、2*2、甚至1*1這樣的分辨率。

我們可以這樣認爲,在初步劃分候選區域大小後,每個劃分出的小格子負責預測其相鄰區域內出現的大小相當的物體。對於每個小格,我們可以再預設若干不同尺度以及長寬比之間的組合,來儘可能的涵蓋所有可能遇到的情況。如下圖我們可以看到,同一位置包含了長寬比1:1但尺度不同候選框,同時也包含了面積相同,但長寬比分別爲1:1,2:1,1:2的候選框,從而能更好的囊括可能遇到的情況(下圖僅爲作者在paper中給出的示意圖,實現細節可能會稍有差別)。
在這裏插入圖片描述
通過這樣近乎暴力的候選區域的羅列,總有一些預選框在位置、大小、形狀上大致的和圖片中物體的真實情況向對應。例如藍色和紅色的虛線表示的預選框就分別很好的對應了圖中的貓和狗。

現在我們要做的就是要讓模型學會爲每一個候選框預測以下兩個信息:

  • 候選框的類別(是背景,還是我們關心的目標類別,如貓、狗等)
  • 目標物體的座標(如果非背景,我們要學會預測候選框相對於真實目標框的座標偏移量)

如何讓模型學會爲所有的候選框都完成這樣的預測呢?最直接的想法,我們當然可以像R-CNN的做法一樣爲每個候選區域應用一個CNN來完成類別和座標的預測。

在這裏插入圖片描述
這樣做的一個致命問題就是速度實在堪憂,因爲候選框實在太多。Ross Girshick大神已經在R-CNN -> Fast R-CNN -> Faster R-CNN 的發展歷程中給了我們的答案:

我們用來預測目標類別和座標的輸入不必是原始圖像的rgb信息,也可以是CNN提取的更爲抽象的高層特徵,通過感受野的概念完成原始圖像中某塊感興趣區域與特徵層feature map中相應區域的對應。這樣我們就可以通過一次前向推理,得到所有的候選區域對應的特徵,在此基礎上完成類別和座標預測,進而大幅提高速度。

在這裏插入圖片描述
YOLO也是利用卷積層提取的信息作爲特徵來進行最後的類別和座標預測。
在這裏插入圖片描述
可以看到,這種思路已經成爲了公認的設計思路。需要注意的是,無論是Fast R-CNN、Faster R-CNN還是YOLO,爲每個候選區域提取的特徵都來自同一高層特徵layer對應的feature map。

而SSD的主要貢獻就是來源對於這個機制的改變,完全可以將不同的特徵層的feature map利用起來。既然我們想設置不同尺度的預選框,那麼我們就可以利用不同特徵層感受野不同的這個特點,將小尺度的候選框分配給比較靠近輸入層的感受野小的淺層特徵層來預測,將大尺度的候選框分配給遠離輸入層的感受野大的高層特徵層來預測。
在這裏插入圖片描述
這樣就做到了一次推理完成了多種尺度預選框的預測,即SSD名字The Single Shot Detector (單發多框檢測器)的由來。我認爲這也就是SSD的精髓所在,端到端的一階段方法保證了速度,多尺度的大量預選框保證了召回和map指標。

作者在 第一章 Introduction 中將SSD貢獻總結如下:

  • 提出了SSD,一句話總結其效果就是:比YOLO快一點且準很多,比Faster R-CNN快很多且精度差不多
  • SSD的核心是使用應用於特徵圖上的小的卷積濾波器來預測一組預先設置好的默認bounding box的類別分數和位置座標的偏移。
  • 爲了獲得高準確率,我們在不同分辨率的特徵圖上進行預測,並且使用不同大小和長寬比的默認bbox。
  • 這樣的設計帶來了簡單的端到端訓練和高準確率,即使在低分辨率的輸入圖像上,這進一步提升了速度和準確性的平衡問題。

到這裏我們已經對SSD的設計初衷以及前向推理框架有了大致的瞭解,下面進入到paper中來仔細探索。

paper的內容安排:2.1小節介紹SSD框架,2.2介紹訓練方法,第三者介紹數據集相關的模型細節和實驗結果。

二、模型結構

SSD網絡的靠前的部分基於分類問題的標準網絡架構(在分類layer之前進行截斷),後面我們將其叫做 base network(基礎網絡)。本文使用VGG-16作爲基礎網絡,其它優秀的backbone也是可以的。

在基礎網絡的基礎上我們加上一些輔助結構,以產生具有以下特性的檢測結果:

  • Multi-scale feature maps for detection 多尺度的特徵圖
  • Convolutional predictors for detection 使用卷積進行檢測
  • Default boxes and aspect ratios 設置默認檢測框與寬高比(類似於Faster R-CNN種anchor的設置)

其實前面關於SSD設計思想的討論中已經設計了這些概念,下面我們再對其中的一些細節進行講解。

多尺度的特徵圖

在截斷的base network上添加若干卷積層,使得特徵圖尺寸逐漸平滑的下降到1*1,從而最終得到多個尺度下的檢測框預測。每個尺度的特徵層都使用獨立的卷積層進行預測。(Overfeat和YOLO都只在一個單一尺度的特徵圖上進行預測)

SSD原版的實現輸入shape固定爲300*300,使用了6個不同的尺度,分別是利用了Conv4_3、Conv7、Conv8_2、Conv9_2、Conv10_2和Conv11_ 2 這些層的feature maps作爲檢測框預測的輸入特徵。

其中,Conv4_3,Conv7來自VGG的base network:

在這裏插入圖片描述
其中conv7較爲特殊,爲了後面繼續接卷積層,作者將原版vgg中的全連接fc6,fc7替換爲了卷積層conv6和conv7,同時還起到了加速的作用。

這部分比較trick,也有點炫技的成分~ 作者的原版描述如下,感興趣可以跟進下,這裏就不展開了。

Similar to DeepLab-LargeFOV [17], we convert fc6 and fc7 to convolutional layers, subsample parameters from fc6 and fc7, change pool5 from 2 × 2 - s2 to 3 × 3 - s1, and use the `a trous algorithm [18] to fill the ”holes”.
[18] Holschneider, M., Kronland-Martinet, R., Morlet, J., Tchamitchian, P.: A real-time algorithm for signal analysis with the help of the wavelet transform. In: Wavelets. Springer (1990) 286–297

vgg基礎網絡部分的pytorch代碼實現如下:

# This function is derived from torchvision VGG make_layers()
# https://github.com/pytorch/vision/blob/master/torchvision/models/vgg.py
def vgg(cfg, i, batch_norm=False):
    """ 構造vgg backbone
    cfg: (list), 表示模型參數的列表,示例:[64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'C', 512, 512, 512, 'M', 512, 512, 512]
    i: (int), 輸入channel個數
    """
    layers = []
    in_channels = i
    for v in cfg:
        if v == 'M':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2)]
        elif v == 'C':
            layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)]   # ceil_mode 向上取整
        else:
            conv2d = nn.Conv2d(in_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.Conv2d(1024, 1024, kernel_size=1)
    # 在vgg16 basenet基礎上補的pool5,conv6,conv7這三個層用來替代vgg16原本的全連接層
    # conv6的設置很有意思,參考paper參考文獻[18]
    # dilation參數的作用可以見https://github.com/vdumoulin/conv_arithmetic/blob/master/README.md
    
    layers += [pool5, conv6,
               nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)]
    return layers

Conv8_2、Conv9_2、Conv10_2和Conv11_ 2 這4層來自在基礎網絡上額外添加的卷積層。 其代碼實現如下:

def add_extras(cfg, i, batch_norm=False):
    """
    Extra layers added to VGG for feature scaling
    
    cfg: 配置信息,示例: [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256],
    i: 輸入channel維度

    注:這個函數共包含了4次下采樣,這點容易產生困惑
    由vgg16的base模塊輸入的特徵圖shape爲19,四次下采樣的變化分別爲19->10->5->3->1,注意觀察本函數的實現細節。
    四次特徵圖發生變化的位置: [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256],
                                      ↑              ↑              ↑         ↑
                                    19->10        10->5           5->3       3->1

    """
    layers = []
    in_channels = i
    flag = False
    for k, v in enumerate(cfg):
        if in_channels != 'S':
            if v == 'S':
                layers += [nn.Conv2d(in_channels, cfg[k + 1],
                           kernel_size=(1, 3)[flag], stride=2, padding=1)]
            else:
                layers += [nn.Conv2d(in_channels, v, kernel_size=(1, 3)[flag])]  # 注意這裏的Conv2d沒有padd,因此shape會發送變化
            flag = not flag
        in_channels = v
    return layers

通過上面的介紹和給出的代碼,不難自行驗證出 Conv4_3、Conv7、Conv8_2、Conv9_2、Conv10_2 和 Conv11_ 2 這6層卷積層的feature maps的shape分別爲,38->19->10->5->3->1。

使用卷積進行檢測

對於每個用於檢測的特徵層,通過卷積來進行每個位置對應的預設bbox的類別預測和座標offset的預測。(R-CNN中使用SVM來進行分類的預測,YOLO使用全連接層作爲過渡來完成預測)

這部分對應的代碼實現:

192 def multibox(vgg, extra_layers, cfg, num_classes):
193     """
194     cfg: [4, 6, 6, 6, 4, 4],  # number of boxes per feature map location
195     """
196     loc_layers = []
197     conf_layers = []
198     vgg_source = [21, -2]
199     for k, v in enumerate(vgg_source):
200         loc_layers += [nn.Conv2d(vgg[v].out_channels,
201                                  cfg[k] * 4, kernel_size=3, padding=1)]
202         conf_layers += [nn.Conv2d(vgg[v].out_channels,
203                         cfg[k] * num_classes, kernel_size=3, padding=1)]
204     for k, v in enumerate(extra_layers[1::2], 2):
205         loc_layers += [nn.Conv2d(v.out_channels, cfg[k]
206                                  * 4, kernel_size=3, padding=1)]
207         conf_layers += [nn.Conv2d(v.out_channels, cfg[k]
208                                   * num_classes, kernel_size=3, padding=1)]
209     return vgg, extra_layers, (loc_layers, conf_layers)

完整的網絡結構代碼見ssd.py,篇幅原因這裏就不放了。

默認檢測框與寬高比

我們將特徵圖的每個單元通過位置與若干預設的不同尺寸和長寬比的默認檢測框對應起來。對於特徵圖的每一個單元,我們需要爲其對應的不同尺寸和長寬比的默認檢測框預測其屬於每一個類別的概率以及位置相對於ground truth的偏移量。特別地,假設一個單元對應了k個默認檢測框,每一個檢測框我們需要預測c個類別分數以及4個位置偏移量,因此一個m*n的特徵圖將會產生形狀爲m*n*[(c+4)*k]的輸出。默認檢測框的設置類似Faster R-CNN中anchor boxes的設置,相比paper中作者給出的描述圖,下面的示意圖更符合代碼實現。
在這裏插入圖片描述
我們爲每一個尺度下的候選框,都定義一個 min_size 和 一個 max_size,且某一尺度下候選框的 max_size 是其更大一級尺度的 min_size

以長寬比1:1,大小爲min_size的候選框爲基準(如上圖較小的紅色框),又應用了與其面積相同,但長寬比不同的候選框(如上圖綠色框所示)。此外,單獨應用了一個尺寸爲 minsizemaxsize\sqrt{minsize * maxsize},長寬比1:1的候選框(如上圖較大紅色框所示)。

作者在2.2 training 中的 Choosing scales and aspect ratios for default boxes 一段,對 min_sizemax_size 的設置又做了進一步的闡述。

在這裏插入圖片描述

正如上文所述,作者使用了平鋪的策略來讓多尺度策略均勻的涵蓋所有的目標物體的大小。所謂平鋪就是在設置的左右區間範圍內,讓尺度均勻地分佈。這裏的s_min = 0.2 指的是相對於原圖尺寸的比例,其餘的信息公式表達的已經很清楚了。

爲什麼可以這樣設置呢?我們知道一個網絡中來自不同層的特徵圖具有其特定的感受野,隨着網絡的加深感受野越來越大。使用了平鋪的策略正好可以利用這一點來爲不同的特徵層分配了不同的默認目標框尺寸,淺層特徵層分配的尺度小,深層特徵分配的尺度大,這樣分配的尺度就和其理論的感受野大致相符。

關於這塊的詳細設置,可以在 data/config.py文件中找到

# SSD300 CONFIGS
voc = {
    'num_classes': 21,
    'lr_steps': (80000, 100000, 120000),
    'max_iter': 120000,
    'feature_maps': [38, 19, 10, 5, 3, 1],
    'min_dim': 300,
    'steps': [8, 16, 32, 64, 100, 300],
    'min_sizes': [30, 60, 111, 162, 213, 264],
    'max_sizes': [60, 111, 162, 213, 264, 315],
    'aspect_ratios': [[2], [2, 3], [2, 3], [2, 3], [2], [2]],  # 寬高比,只需設置寬高比大於1的情況,相反的情況將會自動處理
    'variance': [0.1, 0.2],
    'clip': True,  # 設置是否裁剪anchor越界的情況
    'name': 'VOC',
}

代碼中的設置和paper中還是有點小區別的,SSD共有6個不同的尺度前面已經說過了,實現中第一個最小的尺度是單獨設置的,後面5個尺度是按照paper所述的方法進行的平鋪,如下表,我們自己算一下就和代碼對應上了。

min_size 比例值 0.1 0.2 0.375 0.55 0.725 0.9
min_size 絕對值 30 60 111 162 213 264
max_size 絕對值 60 111 162 213 264 315

關於不同的長寬比,從代碼就可以看到,應該是考慮到大小適中的目標框出現的概率更多,作者爲中間的幾種尺度額外設置了1:3和3:1長寬比的默認框。

此外,代碼中的steps 其實就是對應尺度下每一個小格的步長,例如第一個尺度爲38*38,那麼 steps[0] = 300 / 38 ≈ 7.89 向上取整 = 8

這一步涉及了蠻多細節,也有蠻多可調整的參數,我們不用糾結這些參數的取值,而是需要思考作者這些設計背後的用意,從而在我們的實際問題中靈活調整。作者自己也在paper中表述了,如何更好的設置這些候選框或者說anchor,本身就是一個開放問題,後續工作也會繼續研究。

我們已經扣了所有關於候選框生成的細節,下面再對照着代碼鞏固下。在 layers/functions/prior_box.py 中可以找到候選框生成部分的代碼:

class PriorBox(object):
    """Compute priorbox coordinates in center-offset form for each source
    feature map.
    """
    def __init__(self, cfg):
        super(PriorBox, self).__init__()
        self.image_size = cfg['min_dim']
        # number of priors for feature map location (either 4 or 6)
        self.num_priors = len(cfg['aspect_ratios'])
        self.variance = cfg['variance'] or [0.1]
        self.feature_maps = cfg['feature_maps']
        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):
            for i, j in product(range(f), repeat=2):
                f_k = self.image_size / self.steps[k]
                # unit center x,y
                cx = (j + 0.5) / f_k
                cy = (i + 0.5) / f_k

                # aspect_ratio: 1
                # rel size: min_size
                s_k = self.min_sizes[k]/self.image_size
                mean += [cx, cy, s_k, s_k]

                # aspect_ratio: 1
                # 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]

                # rest of aspect ratios
                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)]
        # back to torch land
        output = torch.Tensor(mean).view(-1, 4)
        if self.clip:
            output.clamp_(max=1, min=0)
        return output

三、訓練

回憶一下,我們已經介紹了SSD的模型結構,多尺度默認候選框的選擇和設置,前向推理的過程以及輸出所代表的含義。

下面我們介紹如何訓練SSD來讓模型正確的學會我們所期望其學會的信息:即每個候選框的類別以及相對於真實目標框ground truth的座標偏移。達到這一目標的關鍵是需要將 ground truth 信息分配給網絡輸出的默認候選檢測框。一旦確定了這個分配,就可以端到端地應用損失函數和反向傳播。

paper作者在訓練過程種還使用了負樣本難例挖掘(hard negtive mining)和數據擴充策略(data augmentation strategies),我們也會分別介紹。

匹配策略

訓練過程中我們需要決定哪些默認的候選目標框是和真實的目標框是有匹配關係的,從而標註出每個候選目標框的期望預測結果,進而應用損失函數進行訓練。

正如下圖所示,我們很可能會認爲,兩個藍色虛線框代表的候選目標框和貓的藍色真實目標框是匹配的,紅色虛線框代表的候選目標框和狗狗對應的紅色真實目標框是匹配的,而其餘的黑色虛線候選框和任何真實目標都是不匹配的,無論是貓和狗。一旦建立了這樣的映射,我們就可以很方便的對模型的預測應用損失函數,對錯誤的預測進行懲罰,從而學習到正確的信息。

在這裏插入圖片描述
我們的大腦似乎很自然的就完成了這個匹配過程,那麼我們如何將這個過程歸納成一個有固定模式的匹配策略呢?

首先我們要定義如何衡量單獨的一對目標框之間的匹配程度,像大多數方法一樣,SSD通過 jaccard overlap(對於矩形框來說就是IOU) 來衡量默認目標框和真實目標框之間的匹配程度。
在這裏插入圖片描述
然後我們需要計算所有真實目標框與所有候選框之間的overlap矩陣。矩陣的函數爲真實目標框的個數,列數爲候選框的個數,第i行第j列的元素是第i個真實目標框和第j個候選框之間的IOU。

大體上,我們找到每一列的最大值,就相當於爲每一個預設候選框找到了最匹配的真實框,然後通過一個IOU閾值來判斷這是否是一個滿足要求的匹配。在此基礎上,paper作者加入了一個強制匹配機制,來確保每個真實框都能夠有預設的候選框與其匹配。這裏我們不討論這樣的匹配機制的好與壞,而是先試圖把這個匹配策略的每一個環節解釋清楚。

我們拿個示例來說明可能會更加清晰,假設我們有3個真實的ground truth目標框,5個prior boxes候選框,計算得到了如下的overlap矩陣。

priorA priorB priorC priorD priorE
gtA 0.8 0.1 0 0 0
gtB 0 0.5 0.7 0.9 0.1
gtC 0 0.2 0.1 0 0

匹配策略分爲兩步:

第一步是先將每一個真實框和與其具有最高 jaccard overlap 的默認框進行強制匹配,這樣的操作確保了無論後面的操作如何進行,每一個真實框都至少與一個候選框完成了匹配。

代碼中通過將IOU匹配度強制賦值2來實現(因爲IOU最大爲1,強制也就體現在了這裏),於是overlap矩陣變爲:

priorA priorB priorC priorD priorE
gtA 2 0.1 0 0 0
gtB 0 0.5 0.7 2 0.1
gtC 0 2 0.1 0 0

可以看到,priorB被強制匹配給了gtC,儘管預選框priorB似乎和gtB更加匹配,因爲實在是沒有其它預選框和gtC匹配了。我想這個例子已經很好的解釋了作者的這個強制匹配策略。

然後第二步是將剩餘的默認框和與其具有最高 jaccard overlap 的真實框進行匹配(前提是IOU大於閾值0.5),相當於進行按列求最大值。於是得到匹配結果:

priorA priorB priorC priorD priorE
匹配類別 gtA gtC gtB gtB background

可以看到,SSD的匹配策略是允許多個候選框和同一個真實目標框進行匹配的,且同一個真實目標框至少匹配了一個候選框。

匹配過程實現的細節見layers/box_utils.py 中的 match 函數

目標函數

SSD的目標函數總體形式是分類損失和座標迴歸損失的加權和。

L(x,c,l,g)=1N(Lconf(x,c)+αLloc(x,l,g)) L(x, c, l, g)=\frac{1}{N}\left(L_{c o n f}(x, c)+\alpha L_{l o c}(x, l, g)\right)

其中N是成功匹配的預設候選框的個數,α用於平衡兩種loss的權重,作者給出的取值爲1。

迴歸損失使用的是Smooth L1 Loss,沿用Faster R-CNN的設計,我們迴歸的是預設候選框 d 的中心座標cxcy 以及寬高 wh 相較於真實目標框 g 的偏差,公式如下:

Lloc(x,l,g)=iPosNm{cx,cy,w,h}xijksmoothL1(limg^jm) L_{l o c}(x, l, g)=\sum_{i \in P o s}^{N} \sum_{m \in\{c x, c y, w, h\}} x_{i j}^{k} \operatorname{smooth}_{L 1}\left(l_{i}^{m}-\hat{g}_{j}^{m}\right)

g^jcx=(gjcxdicx)/diwg^jcy=(gjcydicy)/dih \hat{g}_{j}^{c x}=\left(g_{j}^{c x}-d_{i}^{c x}\right) / d_{i}^{w} \quad \hat{g}_{j}^{c y}=\left(g_{j}^{c y}-d_{i}^{c y}\right) / d_{i}^{h}

g^jw=log(gjwdiw)g^jh=log(gjhdih)\hat{g}_{j}^{w}=\log \left(\frac{g_{j}^{w}}{d_{i}^{w}}\right) \quad \hat{g}_{j}^{h}=\log \left(\frac{g_{j}^{h}}{d_{i}^{h}}\right)

其中xijkx_{i j}^{k}是一個標識符,xijk=1x_{i j}^{k}=1代表第i個預設候選框和第j個類別爲k的真實目標框是匹配的,這個符號想表達的就是隻計算匹配的候選框的迴歸損失,不匹配的預測的多麼離譜也不關心。

並且我們注意到,這個偏差的預測,預測的是編碼後的偏差。中心座標cxcy 以及寬高 wh 分別使用了不同的編碼方法,但目的和達到的效果都是類似的,那就是歸一化,幫助模型更好的收斂。

smooth L1的計算方法如下:

smoothL1(x)={0.5x2 if x<1x0.5 otherwise  \operatorname{smooth}_{L_{1}}(x)=\left\{\begin{array}{ll}{0.5 x^{2}} & {\text { if }|x|<1} \\ {|x|-0.5} & {\text { otherwise }}\end{array}\right.

分類損失的部分就是預測出的多類別置信度c的softmax損失

Lconf(x,c)=iPosNxijplog(c^ip)iNeglog(c^i0) where c^ip=exp(cip)pexp(cip) L_{\text {conf}}(x, c)=-\sum_{i \in P o s}^{N} x_{i j}^{p} \log \left(\hat{c}_{i}^{p}\right)-\sum_{i \in N e g} \log \left(\hat{c}_{i}^{0}\right) \quad \text { where } \quad \hat{c}_{i}^{p}=\frac{\exp \left(c_{i}^{p}\right)}{\sum_{p} \exp \left(c_{i}^{p}\right)}

負樣本難例挖掘

由於默認候選框的數量衆多並且在完成匹配操作後,其中絕大多數會屬於負樣本,這就帶來了用於計算分類loss的正負樣本的極度不平衡問題。因此作者使用了難例挖掘,只從負樣本中選出loss最大的那些默認目標框作爲負樣本,其餘不參與loss計算,paper中正負樣
本比設置爲1:3。

數據增強

論文中關於數據增強的描述有兩段,分別是2.2 training的末尾部分,以及3.6 Data Augmentation for Small Object Accuracy

2.2 training 中關於數據增強的描述如下:
在這裏插入圖片描述
這部分介紹了SSD訓練過程使用的數據增強策略,核心就是通過一定概率進行的隨機採樣來加強模型對於輸入物體尺寸的魯棒性。在隨機採樣之後,不同shape的patch都會被縮放到固定的尺寸,也就是300*300,隨後以0.5的概率進行隨機的水平翻轉,並且加上一些類似paper中文獻[14]介紹的光學相關的扭曲和變換(photo-metric distortions)。

由於上面的介紹的數據增強方法一定程度是將圖片進行了“放大”,因此作者在 3.6 Data Augmentation for Small Object Accuracy 小節補充了關於如何通過數據增強來增加小目標檢測準確性的描述。
在這裏插入圖片描述
如上圖所示,作者使用將原圖貼到一張更大的隨機尺寸的背景圖中的方法,來達到“zoom out”的效果,並且證明了有2%-3%mAP的提升,尤其是對小目標檢測的效果。

我們將兩段關於數據增強的描述進行梳理,得到最終的數據增強方案,大致流程如下:

  • 一定概率將原圖貼到一張更大的背景圖中(起到zoom out 作用)
  • 隨機採樣一個patch(起到zoom in作用)
  • 通過resize將不同shape的圖像塊fix到固定尺寸300*300
  • 50%概率進行隨機水平翻轉
  • 光線相關的扭曲變換

實現的時候可能在順序和一些細節上進行靈活調整,定義數據增強整體流程的代碼如下:

class SSDAugmentation(object):
    def __init__(self, size=300, mean=(104, 117, 123)):
        self.mean = mean
        self.size = size
        self.augment = Compose([
            ConvertFromInts(),
            ToAbsoluteCoords(),         # 將取值0-1的相對座標變絕對座標(爲了方便後面的變換)
            PhotometricDistort(),       # 光線相關的扭曲變換,參考文獻[14]
            Expand(self.mean),          # zoom out 操作
            RandomSampleCrop(),         # RandomSample a patch
            RandomMirror(),             # 50%概率進行隨機水平翻轉
            ToPercentCoords(),          # 將絕對座標變換回取值0-1的相對座標
            Resize(self.size),          # 通過resize將不同shape的圖像塊fix到固定尺寸300*300
            SubtractMeans(self.mean)    # 按channel維度減去數據集均值
        ])

    def __call__(self, img, boxes, labels):
        return self.augment(img, boxes, labels)

其中Compose函數的定義如下,

class Compose(object):
    """Composes several augmentations together.
    Args:
        transforms (List[Transform]): list of transforms to compose.
    Example:
        >>> augmentations.Compose([
        >>>     transforms.CenterCrop(10),
        >>>     transforms.ToTensor(),
        >>> ])
    """

    def __init__(self, transforms):
        self.transforms = transforms

    def __call__(self, img, boxes=None, labels=None):
        for t in self.transforms:
            img, boxes, labels = t(img, boxes, labels)
        return img, boxes, labels

下面我們拆解着來看下論文和源碼:

PhotometricDistort()

applying some photo-metric distortions similar to those described in [14].

應用類似參考文獻[14]的光線相關的扭曲變換

14.Howard, A.G.: Some improvements on deep convolutional neural network based image classification. arXiv preprint arXiv:1312.5402 (2013)

class PhotometricDistort(object):
    def __init__(self):
        self.pd = [
            RandomContrast(),
            ConvertColor(transform='HSV'),
            RandomSaturation(),
            RandomHue(),
            ConvertColor(current='HSV', transform='BGR'),
            RandomContrast()
        ]
        self.rand_brightness = RandomBrightness()
        self.rand_light_noise = RandomLightingNoise()

    def __call__(self, image, boxes, labels):
        im = image.copy()
        im, boxes, labels = self.rand_brightness(im, boxes, labels)
        if random.randint(2):
            distort = Compose(self.pd[:-1])
        else:
            distort = Compose(self.pd[1:])
        im, boxes, labels = distort(im, boxes, labels)
        return self.rand_light_noise(im, boxes, labels)

Expand(self.mean)
隨機貼圖從而起到zoom out的效果

To implement a ”zoom out” operation that creates more small training examples, we first randomly place an image on a canvas of 16× of the original image size filled with mean values before we do any random crop operation.

class Expand(object):
    def __init__(self, mean):
        self.mean = mean

    def __call__(self, image, boxes, labels):
        if random.randint(2):  # 一半的概率不進行操作
            return image, boxes, labels

        height, width, depth = image.shape
        ratio = random.uniform(1, 4)
        left = random.uniform(0, width*ratio - width)
        top = random.uniform(0, height*ratio - height)

        expand_image = np.zeros(
            (int(height*ratio), int(width*ratio), depth),
            dtype=image.dtype)
        expand_image[:, :, :] = self.mean
        expand_image[int(top):int(top + height),
                     int(left):int(left + width)] = image
        image = expand_image

        boxes = boxes.copy()
        boxes[:, :2] += (int(left), int(top))
        boxes[:, 2:] += (int(left), int(top))

        return image, boxes, labels

RandomSampleCrop()
隨機採樣一個patch

each training image is randomly sampled by one of the following options:
– Use the entire original input image.
– Sample a patch so that the minimum jaccard overlap with the objects is 0.1, 0.3,
0.5, 0.7, or 0.9.
– Randomly sample a patch.
The size of each sampled patch is [0.1, 1] of the original image size, and the aspect ratio is between 0.5 and 2.
We keep the overlapped part of the ground truth box if the center of it is in the sampled patch.

class RandomSampleCrop(object):
    """Crop
    Arguments:
        img (Image): the image being input during training
        boxes (Tensor): the original bounding boxes in pt form
        labels (Tensor): the class labels for each bbox
        mode (float tuple): the min and max jaccard overlaps
    Return:
        (img, boxes, classes)
            img (Image): the cropped image
            boxes (Tensor): the adjusted bounding boxes in pt form
            labels (Tensor): the class labels for each bbox
    """
    def __init__(self):
        self.sample_options = (
            # using entire original input image
            None,
            # sample a patch s.t. MIN jaccard w/ obj in .1,.3,.4,.7,.9
            (0.1, None),
            (0.3, None),
            (0.7, None),
            (0.9, None),
            # randomly sample a patch
            (None, None),
        )

    def __call__(self, image, boxes=None, labels=None):
        height, width, _ = image.shape
        while True:
            # randomly choose a mode
            mode = random.choice(self.sample_options)
            if mode is None:
                return image, boxes, labels

            min_iou, max_iou = mode
            if min_iou is None:
                min_iou = float('-inf')
            if max_iou is None:
                max_iou = float('inf')

            # max trails (50)
            for _ in range(50):
                current_image = image

                w = random.uniform(0.3 * width, width)
                h = random.uniform(0.3 * height, height)

                # aspect ratio constraint b/t .5 & 2
                if h / w < 0.5 or h / w > 2:
                    continue

                left = random.uniform(width - w)
                top = random.uniform(height - h)

                # convert to integer rect x1,y1,x2,y2
                rect = np.array([int(left), int(top), int(left+w), int(top+h)])

                # calculate IoU (jaccard overlap) b/t the cropped and gt boxes
                overlap = jaccard_numpy(boxes, rect)

                # is min and max overlap constraint satisfied? if not try again
                if overlap.min() < min_iou and max_iou < overlap.max():
                    continue

                # cut the crop from the image
                current_image = current_image[rect[1]:rect[3], rect[0]:rect[2], :]
                
                # keep overlap with gt box IF center in sampled patch
                centers = (boxes[:, :2] + boxes[:, 2:]) / 2.0

                # mask in all gt boxes that above and to the left of centers
                m1 = (rect[0] < centers[:, 0]) * (rect[1] < centers[:, 1])

                # mask in all gt boxes that under and to the right of centers
                m2 = (rect[2] > centers[:, 0]) * (rect[3] > centers[:, 1])

                # mask in that both m1 and m2 are true
                mask = m1 * m2

                # have any valid boxes? try again if not
                if not mask.any():
                    continue

                # take only matching gt boxes
                current_boxes = boxes[mask, :].copy()

                # take only matching gt labels
                current_labels = labels[mask]

                # should we use the box left and top corner or the crop's
                current_boxes[:, :2] = np.maximum(current_boxes[:, :2], rect[:2])
                # adjust to crop (by substracting crop's left,top)
                current_boxes[:, :2] -= rect[:2]

                current_boxes[:, 2:] = np.minimum(current_boxes[:, 2:], rect[2:])
                # adjust to crop (by substracting crop's left,top)
                current_boxes[:, 2:] -= rect[:2]

                return current_image, current_boxes, current_labels

RandomMirror()
50%概率進行隨機水平翻轉

class RandomMirror(object):
    def __call__(self, image, boxes, classes):
        _, width, _ = image.shape
        if random.randint(2):
            image = image[:, ::-1]
            boxes = boxes.copy()
            boxes[:, 0::2] = width - boxes[:, 2::-2]
        return image, boxes, classes

Resize(self.size)
通過resize將不同shape的圖像塊fix到固定尺寸

class Resize(object):
    def __init__(self, size=300):
        self.size = size

    def __call__(self, image, boxes=None, labels=None):
        image = cv2.resize(image, (self.size,
                                 self.size))
        return image, boxes, labels

-------------------------------------分割線-------------------------------------------

呼~ 終於肝完了,希望對各位有所幫助

注: 爲了保證內容的準確性,我幾乎只參考了paper和源碼。但個人水平畢竟有限,如有錯誤,歡迎指出。
注: 本文中的部分配圖非原創,來自以下文章或博客:
https://zhuanlan.zhihu.com/p/113635317
https://zh.d2l.ai/chapter_computer-vision/multiscale-object-detection.html
https://blog.csdn.net/xunan003/article/details/79186162

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章