以前,對SSD的論文進行了解讀,能夠回顧以前的博客:http://www.javashuo.com/article/p-rrxantwu-cm.html。html
爲了加深對SSD的理解,所以對SSD的源碼進行了復現,主要參考的github項目是ssd.pytorch。同時,我本身對該項目增長了大量註釋:https://github.com/Dengshunge/mySSD_pytorchios
搭建SSD的項目,能夠分紅如下三個部分:git
接下來,本篇博客重點分析網絡搭建。github
該部分總體比較簡單,思路也很清晰。網絡
首先,在train.py中,網絡搭建的函數入口是函數build_ssd(),該函數須要傳入如下幾個參數:"train"或者"test"字符串、圖片尺寸、類別數。其中,"train"或者"test"字符串用於區分該網絡是用於訓練仍是測試,這兩個階段的網絡有些許不一樣,本文主要將訓練階段的網絡;而類別數須要加上背景,對於VOC而言,有20個類別,加上1個背景,即類別數是21。app
ssd_net = build_ssd('train', voc['min_dim'], voc['num_classes'])
這裏,先放一張SSD的網絡結構圖,能夠看出,SSD網絡是有3部分組成的,vgg主幹網絡,新增網絡(Conv6以後的層)和用於檢測的頭部網絡(Extra Feature Layers)。ide
接着,在ssd.py中,首先定了一個參數,以下所示。這裏主要以SSD300爲例。這些參數有什麼用呢?字典base的參數指的是用於搭建VGG主幹網絡輸出通道數,其中「M」表示須要進行maxpooling;字典extras的參數一樣表示新增層的輸出通道數,其中「S」表示須要stride=2的降採樣;字典mbo的參數表示用於特徵融合的層中,每一個層對應未知(x,y)的錨點框數量,在SSD300中,使用了6個層進行特徵融合,如Conv_4層中,每一個位置使用4個錨點框進行預測。函數
base = { '300': [64, 64, 'M', 128, 128, 'M', 256, 256, 256, 'M', 512, 512, 512, 'M', 512, 512, 512], # M表示maxpolling '512': [], } extras = { '300': [256, 'S', 512, 128, 'S', 256, 128, 256, 128, 256], # S表示stride=2 '512': [], } mbox = { '300': [4, 6, 6, 6, 4, 4], # 每一個特徵圖的每一個點,對應錨點框的數量 '512': [], }
當定義完須要使用到的參數後,能夠進行如具體搭建的環節。函數build_ssd()的定義以下所示。利用函數multibox()來構建SSD網絡的各個部分,分別是VGG主幹網絡,新增層和用於檢測的頭部網絡(或許能夠理解爲分類頭和迴歸頭)。而VGG主幹網絡是經過函數vgg()來實現,新增層是經過函數add_extras()來實現,而函數multibox()則搭建用於檢測的頭部網絡。最後用這些層來初始化類SSD。測試
def build_ssd(phase, size=300, num_class=21): if phase != 'test' and phase != 'train': raise ("ERROR: Phase: " + phase + " not recognized") base_, extras_, head_ = multibox(vgg(base[str(size)]), add_extras(extras[str(size)], in_channels=1024), mbox[str(size)], num_class) return SSD(phase, size, base_, extras_, head_, num_class)
咱們來看一下VGG主幹網絡是如何搭建的。函數vgg()須要將上述的base字典傳入進去,根據base字典,來搭建卷積層和池化層。做者對vgg網絡進行了改進,即將fc6和fc7更改爲conv6和conv7。值得留意的是,在conv6中,使用了空點卷積,dilation=6,增大感覺野。在SSD論文的最後,也討論了空洞卷積對結果有好的影響。最後,將這些卷積層和池化層放入list中,並返回這個list。ui
def vgg(cfg=base['300'], batch_norm=False): ''' 該函數來源於torchvision.models.vgg19()中的make_layers() ''' layers = [] in_channels = 3 # vgg主體部分,到論文的conv6以前 for v in cfg: if v == 'M': # ceil_mode是向上取整 layers += [nn.MaxPool2d(kernel_size=2, stride=2, ceil_mode=True)] 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, ceil_mode=True) conv6 = nn.Conv2d(512, 1024, kernel_size=3, padding=6, dilation=6) conv7 = nn.Conv2d(1024, 1024, kernel_size=1) layers += [pool5, conv6, nn.ReLU(inplace=True), conv7, nn.ReLU(inplace=True)] return layers
接下來,咱們來了解一下SSD在vgg中新增層,即conv7以後的網絡層。一樣,函數add_extras()須要傳入字典extras,來構建網絡層。這裏能夠留意一下,kernel_size的寫法,(1,3)爲一個元祖tuple,flag來控制取哪一個值,便可變換使用3*3或者1*1的卷積核,減小代碼的冗餘。最後將新構建的層存入list中,並返回這個list。
def add_extras(cfg=extras['300'], in_channels=1024): ''' 完成SSD後半部分的網絡構建,即做者新加上去的網絡,從conv7以後到conv11_2 ''' layers = [] flag = False # 交替控制卷積核,使用1*1或者使用3*3 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])] flag = not flag in_channels = v return layers
當有了vgg的主幹網絡和新增層後,能夠將某些層進行特徵融合和預測了。這裏,就須要使用到函數multibox()。須要將vgg主幹網絡和新增層的list、字典mbox和類別數傳入函數中。首先,函數multibox()會建立兩個list,用於保存位置迴歸的層和置信度的層。對於每一個用於融合的特徵層,會分紅兩部分,一個用於迴歸,使用3*3的卷積,輸出通道數是cfg[k] * 4,其中cfg[k]表示每一個位置上錨點框的數量,4表示[x_min,y_min,x_max,y_max];另一個用於類別的判斷,也是使用3*3的卷積,輸出通道數是cfg[k] * num_class,表示每一個錨點框判斷其屬於哪個類別,在voc中,num_class=21(包含背景)。能夠理解成將此特徵層分紅了分類頭和迴歸頭,每一個錨點框會輸出4個座標和21個類別置信度。最後將vgg主幹網絡、新增層、分類頭和迴歸頭返回。
def multibox(vgg, extra_layers, cfg, num_class): ''' 返回vgg網絡,新增網絡,位置網絡和置信度網絡 ''' loc_layers = [] # 判斷位置 conf_layers = [] # 判斷置信度 vgg_source = [21, -2] # 21表示conv4_3的序號,-2表示conv7的序號 for k, v in enumerate(vgg_source): # vgg[v]表示須要提取的特徵圖 # cfg[k]表明該特徵圖下每一個點對應的錨點框數量 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_class, kernel_size=3, padding=1)] for k, v in enumerate(extra_layers[1::2], 2): # [1::2]表示,從第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_class, kernel_size=3, padding=1)] return vgg, extra_layers, (loc_layers, conf_layers)
函數multibox()返回的各個層,用於初始化類SSD。首先,因爲由於「train」階段和「test」階段是有點區別的,本節依然主要將「train」階段,所以,須要傳入phase參數,參數只能是兩個值(train,test)。函數PriorBox()的做用是來建立先驗錨點框,返回的shape爲[8732,4],其中具備8732個錨點框,4表示每一個錨點框的座標[中心點x,中心點y,寬,高],這裏的座標值有點不太同樣。因爲傳入的網絡層是以list列表的形式,所以,用nn.ModuleList()將其轉換爲pytorch的網絡結構。
接下來看類SSD中的函數forward(),用於前向推理。按順序對輸入圖片進行處理,在conv4中,須要對特徵圖進行L2正則化。並將用於特徵融合的特徵圖存在放sources中。在獲得5個用於融合的特徵圖後,將這些特徵圖輸入到分類頭和迴歸頭中,每一個特徵圖對應各自的分類頭和迴歸頭。這裏注意一下,分類頭或者回歸頭卷積後,使用了permute()函數。該函數的做用是交換維度,本來的維度是[batch_size,channel,height,weight],交換維度後變成了[batch_size,height,weight,channel],這樣作的目的是方便後續的處理。將處理後的結果保存在loc和conf這兩個List中。後續接着對loc和conf進行變換,利用view()函數,最終,loc的shape爲[batch_size,8732*4],conf的shape爲[batch_size,8732*21]。
最後,將loc和conf這兩個List又變換維度,返回出去,用於計算loss損失函數(感受這麼多變換,有點重複呀,應該能夠省略一部分)。"train"階段和"test"階段返回的結果相似,其中不一樣點是,在test階段,置信度須要通過softmax。
class SSD(nn.Module): ''' 構建SSD的主函數,將base(vgg)、新增網絡和位置網絡與置信度網絡組合起來 ''' def __init__(self, phase, size, base, extras, head, num_classes): super(SSD, self).__init__() self.phase = phase self.num_classes = num_classes self.priors = torch.Tensor(PriorBox(voc)) self.size = size # SSD網絡 self.vgg = nn.ModuleList(base) # 對conv4_3的特徵圖進行L2正則化 self.L2Norm = L2Norm(512, 20) self.extras = nn.ModuleList(extras) self.loc = nn.ModuleList(head[0]) self.conf = nn.ModuleList(head[1]) if phase == 'test': self.softmax = nn.Softmax(dim=-1) self.detect = Detect(num_classes=self.num_classes, top_k=200, conf_thresh=0.01, nms_thresh=0.45) def forward(self, x): sources = [] # 保存特徵圖 loc = [] # 保存每一個特徵圖進行位置網絡後的信息 conf = [] # 保存每一個特徵圖進行置信度網絡後的信息 # 處理輸入至conv4_3 for k in range(23): x = self.vgg[k](x) # 對conv4_3進行L2正則化 s = self.L2Norm(x) sources.append(s) # 完成vgg後續的處理 for k in range(23, len(self.vgg)): x = self.vgg[k](x) sources.append(x) # 使用新增網絡進行處理 for k, v in enumerate(self.extras): x = F.relu(v(x), inplace=True) if k % 2 == 1: sources.append(x) # 將特徵圖送入位置網絡和置信度網絡 # l(x)或者c(x)的shape爲[batch_size,channel,height,weight],使用了permute後,變成[batch_size,height,weight,channel] # 這樣作應該是爲了方便後續處理 for (x, l, c) in zip(sources, self.loc, self.conf): loc.append(l(x).permute(0, 2, 3, 1).contiguous()) conf.append(c(x).permute(0, 2, 3, 1).contiguous()) # 進行格式變換 loc = torch.cat([o.view(o.size(0), -1) for o in loc], 1) # [batch_size,34928],錨點框的數量8732*4=34928 conf = torch.cat([o.view(o.size(0), -1) for o in conf], 1) if self.phase == 'train': output = (loc.view(loc.size(0), -1, 4), # [batch_size,num_priors,4] conf.view(conf.size(0), -1, self.num_classes), # [batch_size,num_priors,21] self.priors) # [num_priors,4] else: # Test output = self.detect( loc.view(loc.size(0), -1, 4), # 位置預測 self.softmax(conf.view((conf.size(0), -1, self.num_classes))), # 置信度預測 self.priors.cuda() # 先驗錨點框 ) return output
在上面類SSD中,說起到了先驗錨點框的構建函數PriorBox(),這個函數在models/prior_box.py中。首先,根據用於融合的特徵圖尺寸和product()函數,生成一系列的點,如(0,0),(0,1),(0,2)等。而後根據這些像素點位置,偏移0.5做爲錨點框的中心點,即cx和cy,並將其歸一化。而後計算論文中的$s_k$和${s_k}'$,對應s_k和s_k_prime,先計算$a_r=1$的狀況,再計算其他$a_r$的狀況。此時,mean的shape爲[1,34928],所以,須要使用view()函數,將其切割出來,變成[8732,4]。記得,這裏的錨點框的座標是[中心點x,中心點y,寬,高]。
def PriorBox(cfg): ''' 爲全部特徵圖生成預設的錨點框,返回全部生成的錨點框,尺寸爲[8732,4], 每行表示[中心點x,中心點y,寬,高] ''' image_size = cfg['min_dim'] # 300 feature_maps = cfg['feature_maps'] # [38, 19, 10, 5, 3, 1],特徵圖尺寸 steps = cfg['steps'] # [8, 16, 32, 64, 100, 300] min_sizes = cfg['min_sizes'] # [30, 60, 111, 162, 213, 264] max_sizes = cfg['max_sizes'] # [60, 111, 162, 213, 264, 315] aspect_ratios = cfg['aspect_ratios'] # [[2], [2, 3], [2, 3], [2, 3], [2], [2]] mean = [] # 爲全部特徵圖生成錨點框 for k, f in enumerate(feature_maps): # product(list1,list2)的做用是依次取出list1中的每1個元素,與list2中的每1個元素, # 組成元組,而後,將全部的元組組成一個列表,返回 # 而這裏使用了repeat,說明1個list重複2次 for i, j in product(range(f), repeat=2): f_k = image_size / steps[k] # 計算中心點,這裏的j是沿x方向變化的 cx = (j + 0.5) / f_k cy = (i + 0.5) / f_k # aspect_ratio=1有兩種狀況,s_k=s_k,s_k=sqrt(s_k*s_(k+1)) s_k = min_sizes[k] / image_size mean += [cx, cy, s_k, s_k] s_k_prime = sqrt(s_k * (max_sizes[k] / image_size)) mean += [cx, cy, s_k_prime, s_k_prime] # 剩餘的aspect_ratio for ar in 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)] # 此時的mean是1*34928的list,要4個數就分割出來,因此須要用view,從而變成[8732,4],即有8732個錨點框 output = torch.Tensor(mean).view(-1, 4) if cfg['clip']: # 對每一個元素進行截斷限制,限制爲[0,1]之間 output.clamp_(min=0, max=1) return output
最後,類SSD中還對conv4的特徵層使用了L2正則化,該函數在models/l2norm.py中。在函數forwand()中,按每一個通道對其值進行L2正則化,即除以通道的平方根來實現歸一化。
class L2Norm(nn.Module): ''' 對conv4_3進行l2歸一化 ''' def __init__(self, n_channels, scale): super(L2Norm, self).__init__() self.n_channels = n_channels self.gamma = scale self.eps = 1e-10 self.weight = nn.Parameter(torch.Tensor(self.n_channels)) # n_channels個隨機數 self.reset_parameters() def reset_parameters(self): # 使用gamma來填充weight的每一個值 nn.init.constant_(self.weight, self.gamma) def forward(self, x): # 按通道進行求值 norm = x.pow(2).sum(dim=1, keepdim=True).sqrt() + self.eps # [1,1,38,38] x = torch.div(x, norm) # 將weight經過3個unsqueeze展開成[1,512,1,1],而後經過expand_as進行擴展,造成[1,512,38,38] out = self.weight.unsqueeze(0).unsqueeze(2).unsqueeze(3).expand_as(x) * x return out
至此,SSD的網絡搭建過程已經完成了,經過類SSD的forward()函數,即能返回預測框的座標和類別置信度,以此能夠計算損失函數。