mxnet深度學習實戰學習筆記-9-目標檢測


1.介紹

目標檢測是指任意給定一張圖像,判斷圖像中是否存在指定類別的目標,如果存在,則返回目標的位置和類別置信度

如下圖檢測人和自行車這兩個目標,檢測結果包括目標的位置、目標的類別和置信度

因為目標檢測算法需要輸出目標的類別和具體坐標,因此在數據標簽上不僅要有目標的類別,還要有目標的坐標信息

 

可見目標檢測比圖像分類算法更復雜。圖像分類算法只租要判斷圖像中是否存在指定目標,不需要給出目標的具體位置;而目標檢測算法不僅需要判斷圖像中是否存在指定類別的目標,還要給出目標的具體位置

因此目標檢測算法實際上是多任務算法,一個任務是目標的分類,一個任務是目標位置的確定;二圖像分類算法是單任務算法,只有一個分類任務

 

2.數據集

目前常用的目標檢測公開數據集是PASCAL VOC(http://host.robots.ox.ac.uk/pascal/VOC) 和COCO(http://cocodataset.org/#home)數據集,PASCAL VOC常用的是PASCAL VOC2007和PASCAL VOC2012兩個數據集,COCO常用的是COCO2014和COCO2017兩個數據集

在評價指標中,目標檢測算法和常見的圖像分類算法不同,目標檢測算法常用mAP(mean average precision)作為評價模型效果的指標。mAP值和設定的IoU(intersection-over-union)閾值相關,不同的IoU閾值會得到不同的mAP。目前在PASCAL VOC數據集上常用IoU=0.5的閾值;在COCO數據集中IoU閾值選擇較多,常在IoU=0.50:0.05:0.95這10個IoU閾值上分別計算AP,然后求均值作為最終的mAP結果

另外在COCO數據集中還有針對目標尺寸而定義的mAP計算方式,可以參考COCO官方網站(http://cocodataset.org/#detection-eval)中對評價指標的介紹

 

目標檢測算法在實際中的應用非常廣泛,比如基於通用的目標檢測算法,做車輛、行人、建築物、生活物品等檢測。在該基礎上,針對一些特定任務或者場景,往往衍生出特定的目標檢測算法,如人臉檢測和文本檢測

人臉檢測:即目前的刷臉,如刷臉支付和刷臉解鎖。包括人臉檢測和人臉識別這兩個主要步驟。人臉檢測就是先從輸入圖像中檢測到人臉所在區域,然后將檢測到的人臉作為識別算法的輸入得到分類結果

文本檢測:文字檢測作為光學字符識別(optical character recognition,OCR)的重要步驟,主要目的在於從輸入圖像中檢測出文字所在的區域,然后作為文字識別器的輸入進行識別

 

目標檢測算法可分為兩種類型:one-stage和two-stage,兩者的區別在於前者是直接基於網絡提取到的特征和預定義的框(anchor)進行目標預測;后者是先通過網絡提取到的特征和預定義的框學習得到候選框(region of interest,RoI),然后基於候選框的特征進行目標檢測

  • one-stage:代表是SSD(sigle shot detection)和YOLO(you only look once)等
  • two-stage:代表是Faster-RCNN 等

兩者的差異主要有兩方面:

一方面是one-stage算法對目標框的預測只進行一次,而two-stage算法對目標框的預測有兩次,類似從粗到細的過程

另一方面one-stage算法的預測是基於整個特征圖進行的,而two-stage算法的預測是基於RoI特征進行的。這個RoI特征就是初步預測得到框(RoI)在整個特征圖上的特征,也就是從整個特征圖上裁剪出RoI區域得到RoI特征

 

3.目標檢測基礎知識

 目標檢測算法在網絡結構方面和圖像分類算法稍有不同,網絡的主干部分基本上采用圖像分類算法的特征提取網絡,但是在網絡的輸出部分一般有兩條支路,一條支路用來做目標分類,這部分和圖像分類算法沒有什么太大差異,也是通過交叉熵損失函數來計算損失;另一條支路用來做目標位置的回歸,這部分通過Smooth L1損失函數計算損失。因此整個網絡在訓練時的損失函數是由分類的損失函數和回歸的損失函數共同組成,網絡參數的更新都是基於總的損失進行計算的,因此目標檢測算法是多任務算法

1)one-stage

SSD算法首先基於特征提取網絡提取特征,然后基於多個特征層設置不同大小和寬高比的anchor,最后基於多個特征層預測目標類別和位置,本章將使用的就是這個算法。

SSD算法在效果和速度上取得了非常好的平衡,但是在檢測小尺寸目標上效果稍差,因此后續的優化算法,如DSSD、RefineDet等,主要就是針對小尺寸目標檢測進行優化

YOLO算法的YOLO v1版本中還未引入anchor的思想,整體也是基於整個特征圖直接進行預測。YOLO v2版本中算法做了許多優化,引入了anchor,有效提升了檢測效果;通過對數據的目標尺寸做聚類分析得到大多數目標的尺寸信息從而初始化anchor。YOLO v3版本主要針對小尺寸目標的檢測做了優化,同時目標的分類支路采用了多標簽分類

2)two-stage

由RCNN 算法發展到Fast RCNN,主要引入了RoI Pooling操作提取RoI特征;再進一步發展到Faster RCNN,主要引入RPN網絡生成RoI,從整個優化過程來看,不僅是速度提升明顯,而且效果非常棒,目前應用廣泛,是目前大多數two-stage類型目標檢測算法的優化基礎

Faster RCNN系列算法的優化算法非常多,比如R-FCN、FPN等。R-FCN主要是通過引入區域敏感(position  sensitive)的RoI Pooling減少了Faster RCNN算法中的重復計算,因此提速十分明顯。FPN算法主要通過引入特征融合操作並基於多個融合后的特征成進行預測,有效提高了模型對小尺寸目標的檢測效果

 

雖然算法分為上面的兩種類別,但是在整體流程上,主要可以分為三大部分:

  • 主網絡部分:主要用來提取特征,常稱為backbone,一般采用圖像分類算法的網絡即可,比如常用的VGG和ResNet網絡,目前也有在研究專門針對於目標檢測任務的特征提取網絡,比如DetNet
  • 預測部分:包含兩個支路——目標類別的分類支路和目標位置的回歸支路。預測部分的輸入特征經歷了從單層特征到多層特征,從多層特征到多層融合特征的過程,算法效果也得到了穩定的提升。其中Faster RCNN算法是基於單層特征進行預測的例子,SSD算法是基於多層特征進行預測的例子,FRN算法是基於多層融合特征進行預測的例子
  • NMS操作:(non maximum suppression,非極大值抑制)是目前目標檢測算法常用的后處理操作,目的是去掉重復的預測框

 

3)准備數據集

VOL2007數據集包括9963張圖像,其中訓練驗證集(trainval)有5011張圖像(2G),測試集(test)有4952張

VOL2012數據集包含17125張圖像,其中訓練驗證集(trainval)有11540張圖像(450M),測試集(test)有5585張

首先使用命令下載數據:

wget http://host.robots.ox.ac.uk/pascal/VOC/voc2012/VOCtrainval_11-May-2012.tar
wget http://host.robots.ox.ac.uk/pascal/VOC/voc2007/VOCtrainval_06-Nov-2007.tar
wget http://host.robots.ox.ac.uk/pascal/VOC/voc2007/VOCtest_06-Nov-2007.tar

下載完后會在當前目錄下看見名為VOCtrainval_11-May-2012.tar、VOCtrainval_06-Nov-2007.tar和VOCtest_06-Nov-2007.tar這三個壓縮包,然后運行下面的命令進行解壓縮:

tar -xvf VOCtrainval_06-Nov-2007.tar
tar -xvf VOCtest_06-Nov-2007.tar 
tar -xvf VOCtrainval_11-May-2012.tar

然后在當前目錄下就會出現一個名為VOCdevkit的文件下,里面有兩個文件為VOL2007和VOL2012,這里以VOL2007為例,可見有5個文件夾:

user@home:/opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007$ ls
Annotations  ImageSets  JPEGImages  SegmentationClass  SegmentationObject

介紹這5個文件夾:

  • Annotations:存放的是關於圖像標注信息文件(后綴為.xml)
  • ImageSets:存放的是訓練和測試的圖像列表信息
  • JPEGImages:存放的是圖像文件
  • SegmentationClass: 存放的是和圖像分割相關的數據,這一章暫不討論,下章再說
  • SegmentationObject:存放的是和圖像分割相關的數據,這一章暫不討論,下章再說

ImageSets文件中有下面的四個文件夾:

  • Action:存儲人的動作
  • Layout:存儲人的部位
  • Main:存儲檢測索引
  • Segmentation :存儲分割

其中Main中,每個類都有對應的classname_train.txt、classname_val.txt和classname_trainval.txt三個索引文件,分別對應訓練集,驗證集和訓練驗證集(即訓練集+驗證集)。

另外還有一個train.txt(5717)、val.txt(5823)和trainval.txt(11540)為所有類別的一個索引。

 

 Annotations包含於圖像數量相等的標簽文件(后綴為.xml),ls命令查看,有000001.xml到009963.xml這9963個文件

查看其中的000001.xml文件:

user@home:/opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007/Annotations$ cat 000001.xml
<annotation>
        <folder>VOC2007</folder> <!--數據集名稱 -->
        <filename>000001.jpg</filename> <!--圖像名稱 -->
        <source>
                <database>The VOC2007 Database</database>
                <annotation>PASCAL VOC2007</annotation>
                <image>flickr</image>
                <flickrid>341012865</flickrid>
        </source>
        <owner>
                <flickrid>Fried Camels</flickrid>
                <name>Jinky the Fruit Bat</name>
        </owner>
        <size> <!--圖像長寬信息 -->
                <width>353</width>
                <height>500</height>
                <depth>3</depth>
        </size>
        <segmented>0</segmented>
        <object> <!--兩個目標的標注信息 -->
                <name>dog</name> <!--目標的類別名,類別名以字符結尾,該類別為dog -->
                <pose>Left</pose>
                <truncated>1</truncated>
                <difficult>0</difficult>
                <bndbox> <!--目標的坐標信息,以字符結尾,包含4個坐標標注信息,且標注框都是矩形框 -->
                        <xmin>48</xmin> <!--矩形框左上角點橫坐標 -->
                        <ymin>240</ymin> <!--矩形框左上角點縱坐標 -->
                        <xmax>195</xmax> <!--矩形框右下角點橫坐標 -->
                        <ymax>371</ymax> <!--矩形框右下角點縱坐標 -->
                </bndbox>
        </object>
        <object>
                <name>person</name> <!--目標的類別名,類別名以字符結尾,該類別為person -->
                <pose>Left</pose>
                <truncated>1</truncated>
                <difficult>0</difficult>
                <bndbox>
                        <xmin>8</xmin>
                        <ymin>12</ymin>
                        <xmax>352</xmax>
                        <ymax>498</ymax>
                </bndbox>
        </object>
</annotation>

 

初了查看標簽文件之外,還可以通過可視化方式查看這些真實框的信息,下面代碼根據VOC數據集的一張圖像和標注信息得到帶有真實框標注的圖像

運行時出現一個問題:

_tkinter.TclError: no display name and no $DISPLAY environment variable

原因是我們不是在Windows下跑的,是在Linux下跑的,不同的系統有不同的用戶圖形接口,所以要更改它的默認配置,把模式更改成Agg。即在代碼最上面添加一行代碼:

import matplotlib
matplotlib.use('Agg')

代碼是:

import mxnet as mx
import xml.etree.ElementTree as ET
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import random

#解析指定的xml標簽文件,得到所有objects的名字和邊框信息
def parse_xml(xml_path):
    bbox = []
    tree = ET.parse(xml_path)
    root = tree.getroot()
    objects = root.findall('object') #得到一個xml文件中的所有目標,這個例子中有dog和person兩個object
    for object in objects:
        name = object.find('name').text #object的名字,即dog或person
        bndbox = object.find('bndbox')#得到object的坐標信息
        xmin = int(bndbox.find('xmin').text)
        ymin = int(bndbox.find('ymin').text)
        xmax = int(bndbox.find('xmax').text)
        ymax = int(bndbox.find('ymax').text)
        bbox_i = [name, xmin, ymin, xmax, ymax]
        bbox.append(bbox_i)
    return bbox
#根據從xml文件中得到的信息,標注object邊框並生成一張圖片實現可視化
def visualize_bbox(image, bbox, name):
    fig, ax = plt.subplots()
    plt.imshow(image)
    colors = dict()#指定標注某個對象的邊框的顏色
    for bbox_i in bbox:
        cls_name = bbox_i[0] #得到object的name
        if cls_name not in colors:
            colors[cls_name] = (random.random(), random.random(), random.random()) #隨機生成標注name為cls_name的object的邊框顏色
        xmin = bbox_i[1]
        ymin = bbox_i[2]
        xmax = bbox_i[3]
        ymax = bbox_i[4]
#指明對應位置和大小的邊框 rect
= patches.Rectangle(xy=(xmin, ymin), width=xmax-xmin, height=ymax-ymin, edgecolor=colors[cls_name],facecolor='None',linewidth=3.5) plt.text(xmin, ymin-2, '{:s}'.format(cls_name), bbox=dict(facecolor=colors[cls_name], alpha=0.5)) ax.add_patch(rect) plt.axis('off') plt.savefig('./{}_gt.png'.format(name)) #將該圖片保存下來 plt.close() if __name__ == '__main__': name = '000001' img_path = '/opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007/JPEGImages/{}.jpg'.format(name) xml_path = '/opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007/Annotations/{}.xml'.format(name) bbox = parse_xml(xml_path=xml_path) image_string = open(img_path, 'rb').read() image = mx.image.imdecode(image_string, flag=1).asnumpy() visualize_bbox(image, bbox, name)

運行:

user@home:/opt/user/.../PASCAL_VOL_datasets$ python2 mxnet_9_1.py
/usr/local/lib/python2.7/dist-packages/h5py/__init__.py:34: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
  from ._conv import register_converters as _register_converters

返回的圖片000001_gt.png是:

 

4)SSD算法簡介

 SSD時目前應用非常廣泛的目標檢測算法,其網絡結構如下圖所示:

該算法采用修改后的16層網絡作為特征提取網絡,修改內容主要是將2個全連接層(圖中的FC6和FC7)替換成了卷積層(圖中的Conv6和Conv7),另外將第5個池化層pool5改成不改變輸入特征圖的尺寸。然后在網絡的后面(即Conv7后面)添加一系列的卷積層(Extra Feature Layer),即圖中的Conv8_2、Conv9_2、Conv10_2和Conv11_2,這樣就構成了SSD網絡的主體結構。

這里要注意Conv8_2、Conv9_2、Conv10_2和Conv11_2並不是4個卷積層,而是4個小模塊,就像是resent網絡中的block一樣。以Conv8_2為例,Conv8_2包含一個卷積核尺寸是1*1的卷積層和一個卷積核尺寸為3*3的卷積層,同時這2個卷積層后面都有relu類型的激活層。當然這4個模塊還有一些差異,Conv8_2和Conv9_2的3*3卷積層的stride參數設置為2、pad參數設置為1,最終能夠將輸入特征圖維度縮小為原來的一半;而Conv10_2和Conv11_2的3*3卷積層的stride參數設置為1、pad參數設置為0

在SSD算法中采用基於多個特征層進行預測的方式來預測目標框的位置,具體而言就是使用Conv4_3、Conv7、Conv8_2、Conv9_2、Conv10_2和Conv11_2這6個特征層的輸出特征圖來進行預測。假設輸入圖像大小是300*300,那么這6個特征層的輸出特征圖大小分別是38*38、19*19、10*10、5*5、3*3和1*1。每個特征層都會有目標類別的分類支路和目標位置的回歸支路,這兩個支路都是由特定 卷積核數量的卷積層構成的,假設在某個特征層的特征圖上每個點設置了k個anchor,目標的類別數一共是N,那么分類支路的卷積核數量就是K*(N+1),其中1表示背景類別;回歸支路的卷積核數量就是K*4,其中4表示坐標信息。最終將這6個預測層的分類結果和回歸結果分別匯總到一起就構成整個網絡的分類和回歸結果

 

5)anchor

該名詞最早出現在Faster RCNN系列論文中,表示一系列固定大小、寬高比的框,這些框均勻地分布在輸入圖像上, 而檢測模型的目的就是基於這些anchor得到預測狂的偏置信息(offset),使得anchor加上偏置信息后得到的預測框能盡可能地接近真實目標框。在SSD論文中的default box名詞,即默認框,和anchor的含義是類似的

 

MXNet框架提供了生成anchor的接口:mxnet.ndarray.contrib.MultiBoxPrior(),接下來通過具體數據演示anchor的含義

首先假設輸入特征圖大小是2*2,在SSD算法中會在特征圖的每個位置生成指定大小和寬高比的anchor,大小的設定通過mxnet.ndarray.contrib.MultiBoxPrior()接口的sizes參數實現,而寬高比通過ratios參數實現,代碼如下:

import mxnet as mx
import matplotlib.pyplot as plt
import matplotlib.patches as patches

input_h = 2
input_w = 2
input = mx.nd.random.uniform(shape=(1,3,input_h, input_w))
anchors = mx.ndarray.contrib.MultiBoxPrior(data=input, sizes=[0.3], ratios=[1])
print(anchors)

返回:

[[[0.09999999 0.09999999 0.4        0.4       ]
  [0.6        0.09999999 0.9        0.4       ]
  [0.09999999 0.6        0.4        0.9       ]
  [0.6        0.6        0.9        0.9       ]]]
<NDArray 1x4x4 @cpu(0)>

可見因為輸入特征圖大小是2*2,且設定的anchor大小和寬高比都只有1種,因此一共得到4個anchor,每個anchor都是1*4的向量,分別表示[xmin,ymin,xmax,ymax],也就是矩形框的左上角點坐標和右下角點坐標

 

接下來通過維度變換可以更清晰地看到anchor數量和輸入特征維度的關系,最后一維的4表示每個anchor的4個坐標信息:

anchors = anchors.reshape((input_h, input_w, -1, 4))
print(anchors.shape)
anchors

返回:

(2, 2, 1, 4)

[[[[0.09999999 0.09999999 0.4        0.4       ]]

  [[0.6        0.09999999 0.9        0.4       ]]]


 [[[0.09999999 0.6        0.4        0.9       ]]

  [[0.6        0.6        0.9        0.9       ]]]]
<NDArray 2x2x1x4 @cpu(0)>

 

那么這4個anchor在輸入圖像上具體是什么樣子?接下來將這些anchor顯示在一張輸入圖像上,首先定義一個顯示anchor的函數:

def plot_anchors(anchors, sizeNum, ratioNum): #sizeNum和ratioNum只是用於指明生成的圖的不同anchor大小和高寬比
    img = mx.img.imread('./000001.jpg')
    height, width, _ = img.shape
    fig, ax = plt.subplots(1)
    ax.imshow(img.asnumpy())
    edgecolors = ['r', 'g', 'y', 'b']
    for h_i in range(anchors.shape[0]):
        for w_i in range(anchors.shape[1]):
            for index, anchor in enumerate(anchors[h_i, w_i, :, :].asnumpy()):
                xmin = anchor[0]*width
                ymin = anchor[1]*height
                xmax = anchor[2]*width
                ymax = anchor[3]*height
                rect = patches.Rectangle(xy=(xmin,ymin), width=xmax-xmin, 
                                         height=ymax-ymin,edgecolor=edgecolors[index],
                                        facecolor='None', linewidth=1.5)
                ax.add_patch(rect)
    plt.savefig('./mapSize_{}*{}_sizeNum_{}_ratioNum_{}.png'.format(anchors.shape[0], 
                                                                    anchors.shape[1], sizeNum, ratioNum))

調用函數:

plot_anchors(anchors, 1, 1)

返回:

通過修改或增加anchor的寬高比及大小可以得到不同數量的anchor,比如增加寬高比為2和0.5的anchor

input_h = 2
input_w = 2
input = mx.nd.random.uniform(shape=(1,3,input_h, input_w))
anchors = mx.nd.contrib.MultiBoxPrior(data=input, sizes=[0.3],ratios=[1,2,0.5])
anchors = anchors.reshape((input_h, input_w, -1, 4))
print(anchors.shape)
plot_anchors(anchors, 1, 3)

返回:

(2, 2, 3, 4)

圖為:

輸出結果說明在2*2的特征圖上的每個點都生成了3個anchor

 

接下來再增加大小為0.4的anchor:

input_h = 2
input_w = 2
input = mx.nd.random.uniform(shape=(1,3,input_h, input_w))
anchors = mx.nd.contrib.MultiBoxPrior(data=input, sizes=[0.3,0.4],ratios=[1,2,0.5])
anchors = anchors.reshape((input_h, input_w, -1, 4))
print(anchors.shape)
plot_anchors(anchors, 2, 3)

返回:

(2, 2, 4, 4)

圖為:

說明在2*2大小的特征圖上的每個點都生成了4個anchor,為什么得到的是4個,而不是2*3=6個呢?

因為在SSD論文中設定anchor時並不是組合所有設定的尺寸和寬高對比度值,而是分成2部分,一部分是針對每種寬高對比度都與其中一個尺寸size進行組合;另一部分是針對寬高對比度為1時,還會額外增加一個新尺寸與該寬高對比度進行組合

舉例說明sizes=[s1, s2, ..., sm], ratios=[r1, r2,...,rn],計算得到的anchor數量為m+n-1,所以當m=2,n=3時,得到的anchor數就是4

首先第一部分就是sizes[0]會跟所有ratios組合,這就有n個anchor了;第二部分就是sizes[1:]會和ratios[0]組合,這樣就有m-1個anchor了。對應這個例子就是[(0.3,1), (0.3,2), (0.3,0.5), (0.4,1)]。SSD論文中ratios參數的第一個值要設置為1

 

上面的例子使用的是2*2的特征圖,下面改成5*5的特征圖:

input_h = 5
input_w = 5
input = mx.nd.random.uniform(shape=(1,3,input_h, input_w))
anchors = mx.nd.contrib.MultiBoxPrior(data=input, sizes=[0.1,0.15],ratios=[1,2,0.5])
anchors = anchors.reshape((input_h, input_w, -1, 4))
print(anchors.shape)
plot_anchors(anchors, 2, 3)

返回:

(5, 5, 4, 4)

圖為:

需要說明的是上述代碼中設定的anchor大小和特征圖大小都是比較特殊的值,因此特征圖上不同點之間的anchor都沒有重疊,這是為了方便顯示anchor而設置的。在實際的SSD算法中,特征圖上不同點之間的anchor重疊特別多,因此基本上能夠覆蓋所有物體

SSD算法基於多個特征層進行目標的預測,這些特征層的特征圖大小不一,因此設置的anchor大小也不一樣,一般而言在網絡的淺層部分特征圖尺寸較大(如38*38、19*19),此時設置的anchor尺寸較小(比如0.1、0.2),主要用來檢測小尺寸目標;在網絡的深層部分特征圖尺寸較小(比如3*3、1*1),此時設置的anchor尺寸較大(比如0.8、0.9),主要用來檢測大尺寸目標

 

6)IoU

在目標檢測算法中,我們經常需要評價2個矩形框之間的相似性,直觀來看可以通過比較2個框的距離、重疊面積等計算得到相似性,而IoU指標恰好可以實現這樣的度量。簡而言之,IoU(intersection over union,交並比)是目標檢測算法中用來評價2個矩形框之間相似度的指標

IoU = 兩個矩形框相交的面積 / 兩個矩形框相並的面積,如下圖所示:

其作用是在我們設定好了anchor后,需要判斷每個anchor的標簽,而判斷的依據就是anchor和真實目標框的IoU。假設某個anchor和某個真實目標框的IoU大於設定的閾值,那就說明該anchor基本覆蓋了這個目標,因此就可以認為這個anchor的類別就是這個目標的類別

另外在NMS算法中也需要用IoU指標界定2個矩形框的重合度,當2個矩形框的IoU值超過設定的閾值時,就表示二者是重復框

 

 7)模型訓練目標

目標檢測算法中的位置回歸目標一直是該類算法中較難理解的部分, 一開始都會認為回歸部分的訓練目標就是真實框的坐標,其實不是。網絡的回歸支路的訓練目標是offset,這個offset是基於真實框坐標和anchor坐標計算得到的偏置,而回歸支路的輸出值也是offset,這個offset是預測框坐標和anchor坐標之間的偏置。因此回歸的目的就是讓這個偏置不斷地接近真實框坐標和anchor坐標之間的偏置

使用的接口是:mxnet.ndarray.contrib.MultiBoxTarget(),生成回歸和分類的目標

import mxnet as mx
import matplotlib.pyplot as plt
import matplotlib.patches as patches

def plot_anchors(anchors, img, text, linestyle='-'): #定義可視化anchor或真實框的位置的函數
    height, width, _ = img.shape
    colors = ['r','y','b','c','m']
    for num_i in range(anchors.shape[0]):
        for index, anchor in enumerate(anchors[num_i,:,:].asnumpy()):
            xmin = anchor[0]*width
            ymin = anchor[1]*height
            xmax = anchor[2]*width
            ymax = anchor[3]*height
            rect = patches.Rectangle(xy=(xmin,ymin), width=xmax-xmin,
                                     height=ymax-ymin, edgecolor=colors[index],
                                     facecolor='None', linestyle=linestyle,
                                     linewidth=1.5)
            ax.text(xmin, ymin, text[index],
                    bbox=dict(facecolor=colors[index], alpha=0.5))
            ax.add_patch(rect)
#讀取輸入圖像
img = mx.img.imread("./000001.jpg")
fig,ax = plt.subplots(1)
ax.imshow(img.asnumpy())
#在上面的輸入圖像上標明真實框的位置 ground_truth
= mx.nd.array([[[0, 0.136,0.48,0.552,0.742], #對應類別0 dog的真實框坐標值 [1, 0.023,0.024,0.997,0.996]]])#對應類別1 person的真實框坐標值 plot_anchors(anchors=ground_truth[:, :, 1:], img=img, text=['dog','person'])
#在上面的輸入圖像上標明anchor的位置
#坐標值表示[xmin, ymin, xmax, ymax] anchor
= mx.nd.array([[[0.1, 0.3, 0.4, 0.6], [0.15, 0.1, 0.85, 0.8], [0.1, 0.2, 0.6, 0.4], [0.25, 0.5, 0.55, 0.7], [0.05, 0.08, 0.95, 0.9]]]) plot_anchors(anchors=anchor, img=img, text=['1','2','3','4','5'], linestyle=':') #然后保存圖片,圖片如下圖所示 plt.savefig("./anchor_gt.png")

 圖為:

接下來初始化一個分類預測值,維度是1*2*5,其中1表示圖像數量,2表示目標類別,這里假設只有人和狗兩個類別,5表示anchor數量,然后就可以通過mxnet.ndarray.contrib.MultiBoxTarget()接口獲取模型訓練的目標值。

該接口主要包含一下幾個輸入:

  • anchor :該參數在計算回歸目標offset時需要用到
  • label:該參數在計算回歸目標offset和分類目標時都用到
  • cls_pred :該參數內容其實在這里並未用到,因此只要維度符合要求即可
  • overlap_threshold: 該參數表示當預測框和真實框的IoU大於這個值時,該預測框的分類和回歸目標就和該真實框對應
  • ignore_label :該參數表示計算回歸目標時忽略的真實框類別標簽,因為訓練過程中一個批次有多張圖像,每張圖像的真實框數量都不一定相同,因此會采用全 -1 值來填充標簽使得每張圖像的真實標簽維度相同,因此這里相當於忽略掉這些填充值
  • negative_mining_ratio :該參數表示在對負樣本做過濾時設定的正負樣本比例是1:3
  • variances :該參數表示計算回歸目標時中心點坐標(x和y)的權重是0.1,寬和高的offset權重是0.2
cls_pred = mx.nd.array([[[0.4, 0.3, 0.2, 0.1, 0.1],
                        [0.6, 0.7, 0.8, 0.9, 0.9]]])
tmp = mx.nd.contrib.MultiBoxTarget(anchor=anchor, label=ground_truth,
                                  cls_pred=cls_pred, overlap_threshold=0.5,
                                  ignore_label=-1, negative_mining_ratio=3,
                                  variances=[0.1,0.1,0.2,0.2])
print("location target: {}".format(tmp[0]))
print("location target mask: {}".format(tmp[1]))
print("classification target: {}".format(tmp[2]))

 這里三個變量的含義是:

  • tmp[0] : 輸出的是回歸支路的訓練目標,也就是我們希望模型的回歸支路輸出值和這個目標的smooth L1損失值要越小越好。可以看見tmp[0]的維度是1*20,其中1表示圖像數量,20是4*5的意思,也就是5個anchor,每個anchor有4個坐標信息。另外tmp[0]中有部分是0,表示這些anchor都是負樣本,也就是背景,可以從輸出結果看出1號和3號anchor是背景
  • tmp[1]:輸出的是回歸支路的mask,該mask中對應正樣本anchor的坐標用1填充,對應負樣本anchor的坐標用0填充。該變量是在計算回歸損失時用到,計算回歸損失時負樣本anchor是不參與計算的
  • tmp[2]:輸出的是每個anchor的分類目標,在接口中默認類別0表示背景類,其他類別依次加1,因此dog類別就用類別1表示,person類別就用類別2表示

返回:

location target: 
[[ 0.          0.          0.          0.          0.14285699  0.8571425
   1.6516545   1.6413777   0.          0.          0.          0.
  -1.8666674   0.5499989   1.6345134   1.3501359   0.11111101  0.24390258
   0.3950827   0.8502576 ]]
<NDArray 1x20 @cpu(0)>
location target mask: 
[[0. 0. 0. 0. 1. 1. 1. 1. 0. 0. 0. 0. 1. 1. 1. 1. 1. 1. 1. 1.]]
<NDArray 1x20 @cpu(0)>
classification target: 
[[0. 2. 0. 1. 2.]]
<NDArray 1x5 @cpu(0)>

所以從上面的結果我們可以知道,anchor 1和3是背景,anchor 2和5是person,4是dog

 

那么anchor的類別怎么定義呢?

在SSD算法中,首先每個真實框和N個anchor會計算到N個IoU,這N個IoU中的最大值對應的anchor就是正樣本,而且類別就是這個真實框的類別。比如上面的圖中與person這個真實框計算得到的IoU中最大的是5號anchor,所以5號anchor的分類目標就是person,也就是類別2,所以上面tmp[2][4]的值為2。同理,dog這個真實框的IoU最大的是4號anchor,因此4號anchor的分類目標就是dog,也就是類別1,所以上面的tmp[2][3]等於1。

除了IoU最大的anchor是正樣本外,和真實框的IoU大於設定的IoU閾值的anchor也是正樣本。這個閾值就是mxnet.ndarray.contrib.MultiBoxTarget()接口中的overlap_threshold參數設置的。顯然可以看出2號anchor和person這個真實框的IoU大於設定的0.5閾值,因此2號anchor的預測類別為person,即類別2,tmp[2][1]等於2

 

關於回歸目標的計算,在SSD論文中通過公式2已經介紹非常詳細了。假設第i個anchor(用di表示),第j個真實框(用gi表示),那么回歸目標就是如下這4個值:

按照上面的公式得到的就是輸出的tmp[0]的值

 

8)NMS

在目標檢測算法中,我們希望每一個目標都有一個預測框准確地圈出目標的位置並給出預測類別。但是檢測模型的輸出預測框之間可能存在重疊,也就是說針對一個目標可能會有幾個甚至幾十個預測對的預測框,這顯然不是我們想要的,因此就有了NMS操作

NMS(non maximum suprression,非極大值抑制)是目前目標檢測算法常用的后處理操作,目的是去掉重復的預測框

NMS算法的過程大致如下:

假設網絡輸出的預測框中預測類別為person的框有K個,每個預測框都有1個預測類別、1個類別置信度和4個坐標相關的值。K個預測框中有N個預測框的類別置信度大於0。首先在N個框中找到類別置信度最大的那個框,然后計算剩下的N-1個框和選出來的這個框的IoU值,IoU值大於預先設定的閾值的框即為重復預測框(假設有M個預測框和選出來的框重復),剔除這M個預測框(這里將這M個預測框的類別置信度設置為0,表示剔除),保留IoU小於閾值的預測框。接下來再從N-1-M個預測框中找到類別置信度最大的那個框,然后計算剩下的N-2-M個框和選出來的這個框的IoU值,同樣將IoU值大於預先設定的閾值的框剔除,保留IoU值小於閾值的框,然后再進行下一輪過濾,一直進行到所有框都過濾結束。最終保留的預測框就是輸出結果,這樣任意兩個框的IoU都小於設定的IoU閾值,就達到去掉重復預測框的目的

 

 

 9)評價指標mAP

 在目標檢測算法中常用的評價指標是mAP(mean average precision),這是一個可以用來度量模型預測框類別和位置是否准確的指標。在目標檢測領域常用的公開數據集PASCAL VOC中,有2種mAP計算方式,一種是針對PASCAL VOL 2007數據集的mAP計算方式,另一種是針對PASCAL VOC 2012數據集的mAP計算方式,二者差異較小,這里主要是用第一種

含義和計算過程如下:

假設某輸入圖像中有兩個真實框:person和dog,模型輸出的預測框中預測類別為person的框有5個,預測類別是dog的框有3個,此時的預測框已經經過NMS后處理。

首先以預測類別為person的5個框為例,先對這5個框按照預測的類別置信度進行從大到小排序,然后這5個值依次和person類別的真實框計算IoU值。假設IoU值大於預先設定的閾值(常設為0.5),那就說明這個預測框是對的,此時這個框就是TP(true positive);假設IoU值小於預先設定的閾值(常設為0.5),那就說明這個預測框是錯的,此時這個框就是FP(false positive)。注意如果這5個預測框中有2個預測框和同一個person真實框的IoU大於閾值,那么只有類別置信度最大的那個預測框才算是預測對了,另一個算是FP

假設圖像的真實框類別中不包含預測框類別,此時預測框類別是cat,但是圖像的真實框只有person和dog,那么也算該預測框預測錯了,為FP

FN(false negative)的計算可以通過圖像中真實框的數量間接計算得到,因為圖像中真實框的數量 = TP + FN

 

癌症類別的精確度就是指模型判斷為癌症且真實類別也為癌症的圖像數量/模型判斷為癌症的圖像數量,計算公式如下圖: 

召回率是指模型判為癌症且真實類別也是癌症的圖像數量/真實類別是癌症的圖像數量,計算公式為:

 

得到的person類精確度和召回率都是一個列表,列表的長度和預測類別為person的框相關,因此根據這2個列表就可以在一個坐標系中畫出該類別的precision和recall曲線圖

按照PASCAL VOL 2007的mAP計算方式,在召回率坐標軸均勻選取11個點(0, 0.1, ..., 0.9, 1),然后計算在召回率大於0的所有點中,精確度的最大值是多少;計算在召回率大於0.1的所有點中,精確度的最大值是多少;一直計算到在召回率大於1時,精確度的最大值是多少。這樣我們最終得到11個精確度值,對這11個精確度求均值久得到AP了,因此AP中的A(average)就代表求精確度均值的過程

 

mAP和AP的關系是:

因為我們有兩個類別,所以會得到person和dog兩個類別對應的AP值,這樣將這2個AP求均值就得到了mAP

所以如果有N個類別,就分別求這N個類別的AP值,然后求均值就得到mAP了

 

上面說到有兩種mAP計算方式,兩者的不同在於AP計算的不同,對於2012標准,是以召回率為依據計算AP

 

那么為什么可以使用mAP來評價目標檢測的效果;

目標檢測的效果取決於預測框的位置和類別是否准確,從mAP的計算過程中可以看出通過計算預測框和真實框的IoU來判斷預測框是否准確預測到了位置信息,同時精確度和召回率指標的引用可以評價預測框的類別是否准確,因此mAP是目前目標檢測領域非常常用的評價指標

 

4.通用目標檢測

1)數據准備

當要在自定義數據集上訓練檢測模型時,只需要將自定義數據按照PASCAL VOC數據集的維護方式進行維護,就可以順利進行

本節采用的訓練數據包括VOC2007的trainval.txt和VOC2012的trainval.txt,一共16551張圖像;驗證集采用VOC2007的test.txt,一共4952張圖像,這是常用的划分方式

另外為了使數據集更加通用,將VOC2007和VOC2012的trainval.txt文件(在/ImageSets/Main文件夾中)合並在一起,同時合並對應的圖像文件夾JPEGImages和標簽文件夾Annotations

 手動操作,創建一個新的文件夾PASCAL_VOC_converge將需要的文件在這里合並

首先合並JPEGImages

cp -r /opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007/JPEGImages/. /opt/user/.../PASCAL_VOC_converge/JPEGImages
cp -r /opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2012/JPEGImages/. /opt/user/.../PASCAL_VOC_converge/JPEGImages

然后合並Annotations:

cp -r /opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2007/Annotations/. /opt/user/.../PASCAL_VOC_converge/Annotations
cp -r /opt/user/.../PASCAL_VOL_datasets/VOCdevkit/VOC2012/Annotations/. /opt/user/.../PASCAL_VOC_converge/Annotations

然后將兩者的trainval.txt內容放在同一個trainval.txt中,然后要將trainval.txt和VOC2007的test.txt都放進新創建的lst文件夾中

最后新的文件夾PASCAL_VOC_converge中的文件為:

user@home:/opt/user/.../PASCAL_VOC_converge$ ls
Annotations  create_list.py  JPEGImages  lst
user@home:/opt/user/.../PASCAL_VOC_converge/lst$ ls
test.txt  trainval.txt

 

然后接下來開始基於數據生成.lst文件和RecordIO文件,生成.lst文件的腳本為create_list.py:

import os
import argparse
from PIL import Image
import xml.etree.ElementTree as ET
import random

def parse_args():
    parser = argparse.ArgumentParser()
    parser.add_argument('--set', type=str, default='train')
    parser.add_argument('--save-path', type=str, default='')
    parser.add_argument('--dataset-path', type=str, default='')
    parser.add_argument('--shuffle', type=bool, default=False)
    args = parser.parse_args()
    return args

def main():
    label_dic = {"aeroplane": 0, "bicycle": 1, "bird": 2, "boat": 3, "bottle": 4, "bus": 5,
                 "car": 6, "cat": 7, "chair": 8, "cow": 9, "diningtable": 10, "dog": 11,
                 "horse": 12, "motorbike": 13, "person": 14, "pottedplant": 15, "sheep": 16,
                 "sofa": 17, "train": 18, "tvmonitor": 19}
    args = parse_args()
    if not os.path.exists(os.path.join(args.save_path, "{}.lst".format(args.set))):
        os.mknod(os.path.join(args.save_path, "{}.lst".format(args.set)))
    with open(os.path.join(args.save_path, "{}.txt".format(args.set)), "r") as input_file:
        lines = input_file.readlines()
        if args.shuffle:
            random.shuffle(lines)
        with open(os.path.join(args.save_path, "{}.lst".format(args.set)), "w") as output_file:
            index = 0
            for line in lines:
                line = line.strip()
                out_str = "\t".join([str(index), "2", "6"])
                img = Image.open(os.path.join(args.dataset_path, "JPEGImages", line+".jpg"))
                width, height = img.size
                xml_path = os.path.join(args.dataset_path, "Annotations", line+".xml")
                tree = ET.parse(xml_path)
                root = tree.getroot()
                objects = root.findall('object')
                for object in objects:
                    name = object.find('name').text
                    difficult = ("%.4f" % int(object.find('difficult').text))
                    label_idx = ("%.4f" % label_dic[name])
                    bndbox = object.find('bndbox')
                    xmin = ("%.4f" % (int(bndbox.find('xmin').text)/width))
                    ymin = ("%.4f" % (int(bndbox.find('ymin').text)/height))
                    xmax = ("%.4f" % (int(bndbox.find('xmax').text)/width))
                    ymax = ("%.4f" % (int(bndbox.find('ymax').text)/height))
                    object_str = "\t".join([label_idx, xmin, ymin, xmax, ymax, difficult])
                    out_str = "\t".join([out_str, object_str])
                out_str = "\t".join([out_str, "{}/JPEGImages/".format(args.dataset_path.split("/")[-1])+line+".jpg"+"\n"])
                output_file.writelines(out_str)
                index += 1

if __name__ == '__main__':
    main()

命令為:

user@home:/opt/user/.../PASCAL_VOC_converge$ python create_list.py --set test --save-path /opt/user/.../PASCAL_VOC_converge/lst --dataset-path /opt/user/.../PASCAL_VOC_converge

user@home:/opt/user/.../PASCAL_VOC_converge$ python create_list.py --set trainval --save-path /opt/user/.../PASCAL_VOC_converge/lst --dataset-path /opt/user/.../PASCAL_VOC_converge --shuffle True

然后查看可見/opt/user/.../PASCAL_VOC_converge/lst文件夾下生成了相應的trainval.lst和test.lst文件:

user@home:/opt/user/.../PASCAL_VOC_converge/lst$ ls
test.lst  test.txt  trainval.lst  trainval.txt

對上面的命令進行說明:

  • --set:用來指定生成的列表文件的名字,如test說明是用來生成test.txt文件指明的測試的數據集的.lst文件,生成的文件名為test.lst,trainval則生成trainval.txt文件指明的訓練驗證數據集的.lst文件,生成的文件名為trainval.lst
  • --save-path:用來指定生成的.lst文件的保存路徑,trainval.txt和test.txt文件要保存在該路徑下
  • --dataset-path:用來指定數據集的根目錄

截取train.lst文件中的一個樣本的標簽介紹.lst文件的內容,如下:

0       2       6       6.0000  0.4300  0.4853  0.7540  0.6400  0.0000  PASCAL_VOC_converge/JPEGImages/2008_000105.jpg

該圖為:

列與列之間都是采用Tab鍵進行分割的。

  • 第一列是index,即圖像的標號,默認從0開始,然后遞增。
  • 第二列表示標識符位數,這里第二列的值都為2,因為標識符有2位,也就是第2列和第3列都是標識符,不是圖像標簽
  • 第三列表示每個目標的標簽位數,這里第三列都是6,表示每個目標的標簽都是6個數字
  • 第4列到第9列這6個數字就是第一個目標的標簽,其中6表示該目標的類別,即'car'(PASCAL VOC數據集又20類,該列值為0-19);接下來的4個數字(0.4300 0.4853 0.7540 0.6400)表示目標的位置,即(xmin, ymin, xmax, ymax);第9列的值表示是否是difficult,如果為0則表示該目標能正常預測,1則表示該目標比較難檢測

如果還有第二個目標的話,那么在第一個目標后面就會接着第二個目標的6列信息,依此類推

  • 最后一列是圖像的路徑

多個目標可見:

18      2       6       14.0000 0.5620  0.0027  1.0000  1.0000  0.0000  19.0000 0.0680  0.3013  0.5760  0.9653  0.0000  PASCAL_VOC_conve
rge/JPEGImages/2008_004301.jpg  

圖2008_004301.jpg為:

目標分別是類別14的'person'和類別19的'tvmonitor'

 

生成.lst文件后,就可以基於.lst文件和圖像文件生成RecordIO文件了

使用腳本im2rec.py:

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.

from __future__ import print_function
import os
import sys

curr_path = os.path.abspath(os.path.dirname(__file__))
sys.path.append(os.path.join(curr_path, "../python"))
import mxnet as mx
import random
import argparse
import cv2
import time
import traceback

try:
    import multiprocessing
except ImportError:
    multiprocessing = None

def list_image(root, recursive, exts):
    """Traverses the root of directory that contains images and
    generates image list iterator.
    Parameters
    ----------
    root: string
    recursive: bool
    exts: string
    Returns
    -------
    image iterator that contains all the image under the specified path
    """

    i = 0
    if recursive:
        cat = {}
        for path, dirs, files in os.walk(root, followlinks=True):
            dirs.sort()
            files.sort()
            for fname in files:
                fpath = os.path.join(path, fname)
                suffix = os.path.splitext(fname)[1].lower()
                if os.path.isfile(fpath) and (suffix in exts):
                    if path not in cat:
                        cat[path] = len(cat)
                    yield (i, os.path.relpath(fpath, root), cat[path])
                    i += 1
        for k, v in sorted(cat.items(), key=lambda x: x[1]):
            print(os.path.relpath(k, root), v)
    else:
        for fname in sorted(os.listdir(root)):
            fpath = os.path.join(root, fname)
            suffix = os.path.splitext(fname)[1].lower()
            if os.path.isfile(fpath) and (suffix in exts):
                yield (i, os.path.relpath(fpath, root), 0)
                i += 1

def write_list(path_out, image_list):
    """Hepler function to write image list into the file.
    The format is as below,
    integer_image_index \t float_label_index \t path_to_image
    Note that the blank between number and tab is only used for readability.
    Parameters
    ----------
    path_out: string
    image_list: list
    """
    with open(path_out, 'w') as fout:
        for i, item in enumerate(image_list):
            line = '%d\t' % item[0]
            for j in item[2:]:
                line += '%f\t' % j
            line += '%s\n' % item[1]
            fout.write(line)

def make_list(args):
    """Generates .lst file.
    Parameters
    ----------
    args: object that contains all the arguments
    """
    image_list = list_image(args.root, args.recursive, args.exts)
    image_list = list(image_list)
    if args.shuffle is True:
        random.seed(100)
        random.shuffle(image_list)
    N = len(image_list)
    chunk_size = (N + args.chunks - 1) // args.chunks
    for i in range(args.chunks):
        chunk = image_list[i * chunk_size:(i + 1) * chunk_size]
        if args.chunks > 1:
            str_chunk = '_%d' % i
        else:
            str_chunk = ''
        sep = int(chunk_size * args.train_ratio)
        sep_test = int(chunk_size * args.test_ratio)
        if args.train_ratio == 1.0:
            write_list(args.prefix + str_chunk + '.lst', chunk)
        else:
            if args.test_ratio:
                write_list(args.prefix + str_chunk + '_test.lst', chunk[:sep_test])
            if args.train_ratio + args.test_ratio < 1.0:
                write_list(args.prefix + str_chunk + '_val.lst', chunk[sep_test + sep:])
            write_list(args.prefix + str_chunk + '_train.lst', chunk[sep_test:sep_test + sep])

def read_list(path_in):
    """Reads the .lst file and generates corresponding iterator.
    Parameters
    ----------
    path_in: string
    Returns
    -------
    item iterator that contains information in .lst file
    """
    with open(path_in) as fin:
        while True:
            line = fin.readline()
            if not line:
                break
            line = [i.strip() for i in line.strip().split('\t')]
            line_len = len(line)
            # check the data format of .lst file
            if line_len < 3:
                print('lst should have at least has three parts, but only has %s parts for %s' % (line_len, line))
                continue
            try:
                item = [int(line[0])] + [line[-1]] + [float(i) for i in line[1:-1]]
            except Exception as e:
                print('Parsing lst met error for %s, detail: %s' % (line, e))
                continue
            yield item

def image_encode(args, i, item, q_out):
    """Reads, preprocesses, packs the image and put it back in output queue.
    Parameters
    ----------
    args: object
    i: int
    item: list
    q_out: queue
    """
    fullpath = os.path.join(args.root, item[1])

    if len(item) > 3 and args.pack_label:
        header = mx.recordio.IRHeader(0, item[2:], item[0], 0)
    else:
        header = mx.recordio.IRHeader(0, item[2], item[0], 0)

    if args.pass_through:
        try:
            with open(fullpath, 'rb') as fin:
                img = fin.read()
            s = mx.recordio.pack(header, img)
            q_out.put((i, s, item))
        except Exception as e:
            traceback.print_exc()
            print('pack_img error:', item[1], e)
            q_out.put((i, None, item))
        return

    try:
        img = cv2.imread(fullpath, args.color)
    except:
        traceback.print_exc()
        print('imread error trying to load file: %s ' % fullpath)
        q_out.put((i, None, item))
        return
    if img is None:
        print('imread read blank (None) image for file: %s' % fullpath)
        q_out.put((i, None, item))
        return
    if args.center_crop:
        if img.shape[0] > img.shape[1]:
            margin = (img.shape[0] - img.shape[1]) // 2
            img = img[margin:margin + img.shape[1], :]
        else:
            margin = (img.shape[1] - img.shape[0]) // 2
            img = img[:, margin:margin + img.shape[0]]
    if args.resize:
        if img.shape[0] > img.shape[1]:
            newsize = (args.resize, img.shape[0] * args.resize // img.shape[1])
        else:
            newsize = (img.shape[1] * args.resize // img.shape[0], args.resize)
        img = cv2.resize(img, newsize)

    try:
        s = mx.recordio.pack_img(header, img, quality=args.quality, img_fmt=args.encoding)
        q_out.put((i, s, item))
    except Exception as e:
        traceback.print_exc()
        print('pack_img error on file: %s' % fullpath, e)
        q_out.put((i, None, item))
        return

def read_worker(args, q_in, q_out):
    """Function that will be spawned to fetch the image
    from the input queue and put it back to output queue.
    Parameters
    ----------
    args: object
    q_in: queue
    q_out: queue
    """
    while True:
        deq = q_in.get()
        if deq is None:
            break
        i, item = deq
        image_encode(args, i, item, q_out)

def write_worker(q_out, fname, working_dir):
    """Function that will be spawned to fetch processed image
    from the output queue and write to the .rec file.
    Parameters
    ----------
    q_out: queue
    fname: string
    working_dir: string
    """
    pre_time = time.time()
    count = 0
    fname = os.path.basename(fname)
    fname_rec = os.path.splitext(fname)[0] + '.rec'
    fname_idx = os.path.splitext(fname)[0] + '.idx'
    record = mx.recordio.MXIndexedRecordIO(os.path.join(working_dir, fname_idx),
                                           os.path.join(working_dir, fname_rec), 'w')
    buf = {}
    more = True
    while more:
        deq = q_out.get()
        if deq is not None:
            i, s, item = deq
            buf[i] = (s, item)
        else:
            more = False
        while count in buf:
            s, item = buf[count]
            del buf[count]
            if s is not None:
                record.write_idx(item[0], s)

            if count % 1000 == 0:
                cur_time = time.time()
                print('time:', cur_time - pre_time, ' count:', count)
                pre_time = cur_time
            count += 1

def parse_args():
    """Defines all arguments.
    Returns
    -------
    args object that contains all the params
    """
    parser = argparse.ArgumentParser(
        formatter_class=argparse.ArgumentDefaultsHelpFormatter,
        description='Create an image list or \
        make a record database by reading from an image list')
    parser.add_argument('prefix', help='prefix of input/output lst and rec files.')
    parser.add_argument('root', help='path to folder containing images.')

    cgroup = parser.add_argument_group('Options for creating image lists')
    cgroup.add_argument('--list', action='store_true',
                        help='If this is set im2rec will create image list(s) by traversing root folder\
        and output to <prefix>.lst.\
        Otherwise im2rec will read <prefix>.lst and create a database at <prefix>.rec')
    cgroup.add_argument('--exts', nargs='+', default=['.jpeg', '.jpg', '.png'],
                        help='list of acceptable image extensions.')
    cgroup.add_argument('--chunks', type=int, default=1, help='number of chunks.')
    cgroup.add_argument('--train-ratio', type=float, default=1.0,
                        help='Ratio of images to use for training.')
    cgroup.add_argument('--test-ratio', type=float, default=0,
                        help='Ratio of images to use for testing.')
    cgroup.add_argument('--recursive', action='store_true',
                        help='If true recursively walk through subdirs and assign an unique label\
        to images in each folder. Otherwise only include images in the root folder\
        and give them label 0.')
    cgroup.add_argument('--no-shuffle', dest='shuffle', action='store_false',
                        help='If this is passed, \
        im2rec will not randomize the image order in <prefix>.lst')
    rgroup = parser.add_argument_group('Options for creating database')
    rgroup.add_argument('--pass-through', action='store_true',
                        help='whether to skip transformation and save image as is')
    rgroup.add_argument('--resize', type=int, default=0,
                        help='resize the shorter edge of image to the newsize, original images will\
        be packed by default.')
    rgroup.add_argument('--center-crop', action='store_true',
                        help='specify whether to crop the center image to make it rectangular.')
    rgroup.add_argument('--quality', type=int, default=95,
                        help='JPEG quality for encoding, 1-100; or PNG compression for encoding, 1-9')
    rgroup.add_argument('--num-thread', type=int, default=1,
                        help='number of thread to use for encoding. order of images will be different\
        from the input list if >1. the input list will be modified to match the\
        resulting order.')
    rgroup.add_argument('--color', type=int, default=1, choices=[-1, 0, 1],
                        help='specify the color mode of the loaded image.\
        1: Loads a color image. Any transparency of image will be neglected. It is the default flag.\
        0: Loads image in grayscale mode.\
        -1:Loads image as such including alpha channel.')
    rgroup.add_argument('--encoding', type=str, default='.jpg', choices=['.jpg', '.png'],
                        help='specify the encoding of the images.')
    rgroup.add_argument('--pack-label', action='store_true',
        help='Whether to also pack multi dimensional label in the record file')
    args = parser.parse_args()
    args.prefix = os.path.abspath(args.prefix)
    args.root = os.path.abspath(args.root)
    return args

if __name__ == '__main__':
    args = parse_args()
    # if the '--list' is used, it generates .lst file
    if args.list:
        make_list(args)
    # otherwise read .lst file to generates .rec file
    else:
        if os.path.isdir(args.prefix):
            working_dir = args.prefix
        else:
            working_dir = os.path.dirname(args.prefix)
        files = [os.path.join(working_dir, fname) for fname in os.listdir(working_dir)
                    if os.path.isfile(os.path.join(working_dir, fname))]
        count = 0
        for fname in files:
            if fname.startswith(args.prefix) and fname.endswith('.lst'):
                print('Creating .rec file from', fname, 'in', working_dir)
                count += 1
                image_list = read_list(fname)
                # -- write_record -- #
                if args.num_thread > 1 and multiprocessing is not None:
                    q_in = [multiprocessing.Queue(1024) for i in range(args.num_thread)]
                    q_out = multiprocessing.Queue(1024)
                    # define the process
                    read_process = [multiprocessing.Process(target=read_worker, args=(args, q_in[i], q_out)) \
                                    for i in range(args.num_thread)]
                    # process images with num_thread process
                    for p in read_process:
                        p.start()
                    # only use one process to write .rec to avoid race-condtion
                    write_process = multiprocessing.Process(target=write_worker, args=(q_out, fname, working_dir))
                    write_process.start()
                    # put the image list into input queue
                    for i, item in enumerate(image_list):
                        q_in[i % len(q_in)].put((i, item))
                    for q in q_in:
                        q.put(None)
                    for p in read_process:
                        p.join()

                    q_out.put(None)
                    write_process.join()
                else:
                    print('multiprocessing not available, fall back to single threaded encoding')
                    try:
                        import Queue as queue
                    except ImportError:
                        import queue
                    q_out = queue.Queue()
                    fname = os.path.basename(fname)
                    fname_rec = os.path.splitext(fname)[0] + '.rec'
                    fname_idx = os.path.splitext(fname)[0] + '.idx'
                    record = mx.recordio.MXIndexedRecordIO(os.path.join(working_dir, fname_idx),
                                                           os.path.join(working_dir, fname_rec), 'w')
                    cnt = 0
                    pre_time = time.time()
                    for i, item in enumerate(image_list):
                        image_encode(args, i, item, q_out)
                        if q_out.empty():
                            continue
                        _, s, _ = q_out.get()
                        record.write_idx(item[0], s)
                        if cnt % 1000 == 0:
                            cur_time = time.time()
                            print('time:', cur_time - pre_time, ' count:', cnt)
                            pre_time = cur_time
                        cnt += 1
        if not count:
            print('Did not find and list file with prefix %s'%args.prefix)
View Code

運行命令為:

user@home:/opt/user/.../PASCAL_VOC_converge$ python2 im2rec.py /opt/user/.../PASCAL_VOC_converge/lst/test.lst /opt/user/.../ --no-shuffle --pack-label
/usr/local/lib/python2.7/dist-packages/h5py/__init__.py:34: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
  from ._conv import register_converters as _register_converters
Creating .rec file from /opt/user/.../PASCAL_VOC_converge/lst/test.lst in /opt/user/.../PASCAL_VOC_converge/lst
multiprocessing not available, fall back to single threaded encoding
time: 0.00383400917053  count: 0
time: 3.74169707298  count: 1000
time: 3.72109794617  count: 2000
time: 3.71359992027  count: 3000
time: 3.665184021  count: 4000


user@home:/opt/user/.../PASCAL_VOC_converge$ python2 im2rec.py /opt/user/.../PASCAL_VOC_converge/lst/trainval.lst/opt/user/.../ --pack-label
/usr/local/lib/python2.7/dist-packages/h5py/__init__.py:34: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
  from ._conv import register_converters as _register_converters
Creating .rec file from /opt/user/.../PASCAL_VOC_converge/lst/trainval.lst in /opt/user/.../PASCAL_VOC_converge/lst
multiprocessing not available, fall back to single threaded encoding
time: 0.00398397445679  count: 0
time: 4.04885816574  count: 1000
time: 4.14898204803  count: 2000
time: 4.22944998741  count: 3000
time: 4.15795993805  count: 4000
time: 4.12708187103  count: 5000
time: 4.13473105431  count: 6000
time: 4.04282712936  count: 7000
time: 3.99953484535  count: 8000
time: 4.11092996597  count: 9000
time: 4.0615940094  count: 10000
time: 4.10190010071  count: 11000
time: 4.11535596848  count: 12000
time: 4.05630993843  count: 13000
time: 4.15138602257  count: 14000
time: 4.08989906311  count: 15000
time: 4.03274989128  count: 16000

命令的第一個參數用來指定.lst文件的路徑;第二個參數指定數據集的根目錄;--no-shuffle表示不對數據做隨機打亂操作;--pack-label表示打包標簽信息到RecordIO文件

注意:因為.lst中文件路徑的寫法是PASCAL_VOC_conve rge/JPEGImages/2008_004301.jpg ,所以上面指數據集的根目錄寫的是/opt/user/.../

最終得到的所有數據文件為:

user@home:/opt/user/PASCAL_VOC_converge/lst$ ls
test.idx  test.lst  test.rec  test.txt  trainval.idx  trainval.lst  trainval.rec  trainval.txt

 

ssd的mxnet詳細代碼可見https://github.com/apache/incubator-mxnet/tree/master/example/ssd

 

 2)訓練參數及配置

啟動代碼在https://github.com/miraclewkf/MXNet-Deep-Learning-in-Action/blob/master/chapter9-objectDetection/9.2-objectDetection/train.py,下面一一說明:

 腳本中主要包含模塊導入、命令行參數解析函數parse_arguments()和主函數main()

首先看看導入的模塊:

import mxnet as mx
import argparse
from symbol1.get_ssd import get_ssd #在本地一直無法導入symbol,將其改名為symbol1即可
from tools.custom_metric import MultiBoxMetric
from eval_metric_07 import VOC07MApMetric
import logging
import os
from data.dataiter import CustomDataIter
import re

命令行參數解析函數parse_arguments()與第八章相似,不再介紹

def parse_arguments():
    parser = argparse.ArgumentParser(description='score a model on a dataset')
    parser.add_argument('--lr', type=float, default=0.001) #學習率
    parser.add_argument('--mom', type=float, default=0.9) #優化器動量momentum
    parser.add_argument('--wd', type=float, default=0.0005) #權重衰減
    parser.add_argument('--gpus', type=str, default='0,1') #使用的GPU
    parser.add_argument('--batch-size', type=int, default=32) #batch size
    parser.add_argument('--num-classes', type=int, default=20) #類別數量
    parser.add_argument('--num-examples', type=int, default=16551) #訓練圖片數量
    parser.add_argument('--begin-epoch', type=int, default=0) #從epoch=0開始迭代
    parser.add_argument('--num-epoch', type=int, default=240) #一共進行240次迭代
    parser.add_argument('--step', type=str, default='160,200') #指明在哪個epoch迭代處進行學習率衰減
    parser.add_argument('--factor', type=float, default=0.1) #學習率衰減乘的因子factor
    parser.add_argument('--frequent', type=int, default=20)
    parser.add_argument('--save-result', type=str, default='output/ssd_vgg/') #結果存放的路徑
    parser.add_argument('--save-name', type=str, default='ssd') #存放名字
    parser.add_argument('--class-names', type=str, default='aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor') #20個類別的名字
    parser.add_argument('--train-rec', type=str, default='data/VOCdevkit/VOC/ImageSets/Main/trainval.rec') #使用的訓練數據集的.rec文件
    parser.add_argument('--train-idx', type=str, default='data/VOCdevkit/VOC/ImageSets/Main/trainval.idx')#使用的訓練數據集的.idx文件
    parser.add_argument('--val-rec', type=str, default='data/VOCdevkit/VOC/ImageSets/Main/test.rec') #使用的測試數據集的.rec文件
    parser.add_argument('--backbone-prefix', type=str, default='model/vgg16_reduced') #使用的預訓練模型
    parser.add_argument('--backbone-epoch', type=int, default=1) #指明使用的是哪個epoch存儲下來的預訓練模型,名為vgg16_reduced-0001.params
    parser.add_argument('--freeze-layers', type=str, default="^(conv1_|conv2_).*") #指明訓練時固定不訓練的層
    parser.add_argument('--data-shape', type=int, default=300) #圖片大小
    parser.add_argument('--label-pad-width', type=int, default=420)
    parser.add_argument('--label-name', type=str, default='label')
    args = parser.parse_args()
    return args

 

下面介紹main()函數,順序執行了如下的幾個操作:

  • 調用命令行參數解析函數parse_arguments()得到參數對象args
  • 創建模型保存路徑args.save_result
  • 通過logging模塊創建記錄器logger,同時設定了日志信息的終端顯示和文件保存,最后調用記錄器logger的info()方法顯示配置的參數信息args
  • 設定模型訓練的環境,在這個代碼中默認使用0、1號GPU進行訓練,我自己只有一個GPU,所以設置為0
  • 調用CustomDataIter類讀取訓練及驗證數據集
  • 調用get_ssd()函數構建SSD網絡結構
  • 通過mxnet.model.load_checkpoint()接口導入預訓練的分類模型,在這份代碼中使用的是VGG網絡,得到的arg_params和aux_params將用於SSD網絡的參數初始化
  • 假設設定了在訓練過程中固定部分層參數,那么就會使用fixed_params_names變量維護固定層名,這里設定為固定特征提取網絡(VGG)的部分淺層參數
  • 通過mxnet.mod.Module()接口初始化得到一個Module對象mod
  • 設定學習率變化策略,這里設置為當訓練epoch到達80和160時,將當前學習率乘以args.factor,該參數設置為0.1,也就是將學習率降為當前學習率的0.1倍
  • 構建優化相關的字典optimizer_params,在該字典中包含學習率、動量參數、權重衰減參數、學習率變化策略參數等。通過mxnet.initializer.Xavier()接口設定SSD中新增網絡層的參數初始化方式
  • 通過VOC2017MapMetric類實現驗證階段的評價指標,此時需要傳入的參數包括ovp_thresh。該參數表示當預測框和真實框的IoU大於ovp_thresh時,認為預測框是對的。另外還有個參數是pred_idx,是和這份代碼設計的SSD網絡相關,表示網絡的第3個輸出是預測框的內容。通過MultiBoxMetric類實現訓練階段的評價指標,在該指標中包括分類支路指標(基於softmax的交叉熵值)和回歸支路指標(Smooth L1值)
  • 通過mxnet.callback.Speedometer()接口設置訓練過程中每訓練args.frequent個批次就顯示相關信息;通過mxnet.callback.do_checkpoint()接口設置訓練結果的保存路徑和保存間隔
  • 調用mod的fit()方法啟動訓練

 

def main():
    args = parse_arguments()
    if not os.path.exists(args.save_result): #如果沒有這個存儲路徑就創建
        os.makedirs(args.save_result)
    logger = logging.getLogger()
    logger.setLevel(logging.INFO)
    stream_handler = logging.StreamHandler()
    logger.addHandler(stream_handler)
    file_handler = logging.FileHandler(args.save_result + 'train.log')
    logger.addHandler(file_handler)
    logger.info(args)
    
    if args.gpus == '':
        ctx = mx.cpu()
    else:
        ctx = [mx.gpu(int(i)) for i in args.gpus.split(',')]
    
    train_data = CustomDataIter(args, is_trainData=True)
    val_data = CustomDataIter(args)
    
    ssd_symbol = get_ssd(num_classes=args.num_classes)
    vgg, arg_params, aux_params = mx.model.load_checkpoint(args.backbone_prefix, args.backbone_epoch) #調用預訓練函數
    
    if args.freeze_layers.strip(): #得到固定不訓練的層
        re_prog = re.compile(args.freeze_layers)
        fixed_param_names = [name for name in vgg.list_arguments() if re_prog.match(name)]
    else:
        fix_param_names = None

    mod = mx.mod.Module(symbol=ssd_symbol, label_names=(args.label_name,), context=ctx, fixed_param_names=fix_param_names)#得到Module對象mod
    
    epoch_size = max(int(args.num_examples / args.batch_size), 1)#指明一個epoch中有多少個batch
    step = [int(step_i.strip()) for step_i in args.step.split(',')] #得到學習率衰減的epoch
    step_bs = [epoch_size * (x - args.begin_epoch) for x in step if x - args.begin_epoch > 0]
    if step_bs:
        lr_scheduler = mx.lr_scheduler.MultiFactorScheduler(step=step_bs, factor=args.factor)
    else:
        lr_scheduler = None
    
    optimizer_params = {'learning_rate':args.lr,
                       'momentum':args.mom,
                       'wd':args.wd,
                       'lr_scheduler':lr_scheduler,
                       'rescale_grad':1.0/len(ctx) if len(ctx)>0 else 1.0}
    initializer = mx.init.Xavier(rnd_type='gaussian', factor_type='out', magnitude=2)
    
    class_names = [name_i for name_i in args.class_names.split(',')]
    VOC07_metric = VOC07MApMetric(ovp_thresh=0.5, use_difficult=False, class_names=class_names, pred_idx=3)
    
    eval_metric = mx.metric.CompositeEvalMetric()
    eval_metric.add(MultiBoxMetric(name=['CrossEntropy Loss', 'SmoothL1 Loss']))#指明使用的兩個評價函數
    
    batch_callback = mx.callback.Speedometer(batch_size=args.batch_size, frequent=args.frequent)
    checkpoint_prefix = args.save_result + args.save_name
    epoch_callback = mx.callback.do_checkpoint(prefix=checkpoint_prefix, period=5)
    
    mod.fit(train_data=train_data,
           eval_data=val_data,
           eval_metric=eval_metric,
           validation_metric=VOC07_metric,
           epoch_end_callback=epoch_callback,
           batch_end_callback=batch_callback,
           optimizer='sgd',
           optimizer_params=optimizer_params,
           initializer=initializer,
           arg_params=arg_params,
           aux_params=aux_params,
           allow_missing=True,
           num_epoch=args.num_epoch)
    
if __name__ == '__main__':
    main()

從主函數main()的內容可以看出網絡結構的搭建是通過get_ssd()函數實現的、數據讀取是通過CustomDataIter類實現的、訓練評價指標計算是通過MultiBoxMetric()類實現的,接下來依次介紹

 

3)網絡結構搭建

該get_ssd()函數保存在https://github.com/miraclewkf/MXNet-Deep-Learning-in-Action/blob/master/chapter9-objectDetection/9.2-objectDetection/symbol/get_ssd.py

該函數主要執行了下面的幾個操作:

  • 調用config()函數得到模型構建過程中的參數信息,比如anchor(即default box)的尺寸,這些參數信息通過字典config_dict進行維護
  • 調用VGGNet()函數得到特征提取網絡,也就是我們常說的backbone,這里選擇的是修改版VGG網絡,修改的內容主要是將原VGG中的fc6和fc7兩個全連接層用卷積層代替,其中fc6采用卷積核大小為3*3,pad參數為(6,6),dilate參數為(6,6)的卷積層,大大增加了該層的感受域(receptive field)。除了修改網絡層外,還截掉了fc8層及后面所連接的其他層
  • 調用add_extras()函數在VGG主干上添加層,最終返回的就是主干網絡和新增網絡中共6個特征層,這6個特征層將用於預測框的類別和位置
  • 調用create_predictor()函數基於6個特征層構建6個預測層,每個預測層都包含2個支路,分別表示分類支路和回歸支路,其中分類支路的輸出對應cls_preds,回歸支路的輸出對應loc_preds。另外在該函數中還會基於6個特征層中的每個特征層初始化指定尺寸的anchor
  • 調用create_multi_loss()函數構建分類支路和回歸支路的損失函數,從而得到最終的SSD網絡——ssd_symbol
from .vggnet import *
def get_ssd(num_classes):
    config_dict = config()
    backbond = VGGNet()
    from_layers = add_extras(backbond=backbond, config_dict=config_dict)
    loc_preds, cls_preds, anchors = create_predictor(from_layers=from_layers,
                                                    config_dict=config_dict,
                                                    num_classes=num_classes)
    label = mx.sym.Variable('label')
    ssd_symbol = create_multi_loss(label=label, loc_preds=loc_preds, cls_preds=cls_preds, anchors=anchors)
    return ssd_symbol

 

首先介紹一下config()函數

  • 'from_layers' :表示用於預測的特征層名,其中'relu4_3'和'relu7'是VGG網絡自身的網絡層,剩下4個空字符串表示從VGG網絡后面新增的網絡層選擇4個作為預測層所接的層
  • 'num_filters':第1個512表示在relu4_3后面接的L2 Normalization層的參數;第二個-1表示該特征層后面不接額外的卷積層,直接接預測層;后面4個值表示在VGG網絡后面新增的卷積層的卷積核數量
  • 'strides':前面2個-1表示無效,因為這個參數是為VGG網絡后面的新增層服務的,而前面兩個特征層是VGG網絡自身的,所以不需要stride參數;后面4個值表示新增的3*3卷積層的stride參數,具體而言新增的conv8_2、conv9_2的3*3卷積層的stride參數都將設置為2,而conv10_2、conv11_2的3*3卷積層的stride參數設為1
  • 'pads'和'strides'參數同理,只不過設置的是卷積層的pad參數
  • 'normalization':因為在SSD網絡的relu4_3層后用刀L2 Normalization層,因此這里的20就是設置L2 Normalization層的參數,剩下的-1表示無效,因為其他5個層后面都不需要接L2 Normalization層
  • 'sizes':這是設置6個用於預測的特征層的anchor大小,可以看到每個層設置的anchor大小都有2個值,其中第一個值將和所有不同寬高比的anchor進行組合,第二個值只與寬高比是1的anchor進行組合,這和SSD論文的處理方式保持一致。每個列表(假設是第k個列表)中的第一個值可以通過下面的式子計算得到,結果為{0.1, 0.2, 0.37, 0.54, 0.71, 0.88}:

          第二個值則是對第k和第k+1個列表的第一個值相乘后求開方得到的,結果為{0.141, 0.272, 0.447, 0.619, 0.79, 0.961}

         注意當基礎層越深,設置的anchor尺寸越大,這是因為越深的網絡層的感受域越大,主要用來檢測尺寸較大的目標,因此anchor尺寸也設置較大

  • 'ratios':這是設置6個用於預測的特征層的anchor的寬高對比度。在SSD論文中,第一個和最后2個用於預測層中設置anchor個數為4的層(因此這里的寬高對比度值設置為3個),其他3個特征層設置的是6個anchor(因此寬高對比度設置為5個值)。結合剛剛介紹的sizes參數,以第一個用於預測的特征層relu4_3為例,此時的sizes=[0.1,0.141],ratios為[1, 2, 0.5]。因為輸入圖像大小為300*300,所以在size為0.1時,會和所有ratio組合,因此會得到大小為30*30、(30*√2) * (30 / √2)、(30*√0.5)*(30/√0.5)的3個anchor,在size=0.141時,只和ratio=1結合,得到大小為(300*0.141)*(300*0.141)的anchor。最后特征圖的每個位置都能得到4個anchor,其他用於預測的特征層anchor設計也是用理
  • 'steps':這是設置6個用於預測的特征層的特征圖尺寸和輸入圖像尺寸的倍數關系,在初始化anchor時會用到該參數
def config():
    config_dict = {}
    config_dict['from_layers'] = ['relu4_3', 'relu7', '', '', '', '']
    config_dict['num_filters'] = [512, -1, 512, 256, 256, 256]
    config_dict['strides'] = [-1, -1, 2, 2, 1, 1]
    config_dict['pads'] = [-1, -1, 1, 1, 0, 0]
    config_dict['normalization'] = [20, -1, -1, -1, -1, -1]
    config_dict['sizes'] = [[0.1, 0.141], [0.2, 0.272], [0.37, 0.447],
                            [0.54, 0.619], [0.71, 0.79], [0.88, 0.961]]
    config_dict['ratios'] = [[1, 2, 0.5], [1, 2, 0.5, 3, 1.0/3],
                             [1, 2, 0.5, 3, 1.0/3], [1, 2, 0.5, 3, 1.0/3],
                             [1, 2, 0.5], [1, 2, 0.5]]
    config_dict['steps'] = [x / 300.0 for x in [8, 16, 32, 64, 100, 300]]
    return config_dict

 

然后是VGGNet()函數,該函數在16層的VGG網絡上做一定修改即可得到,修改內容上面也說到,VGG更改全連接層后的代碼可見:

import mxnet as mx

def VGGNet():
    data = mx.sym.Variable(name='data')

    conv1_1 = mx.sym.Convolution(data=data, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=64, name='conv1_1')
    relu1_1 = mx.sym.Activation(data=conv1_1, act_type='relu', name='relu1_1')
    conv1_2 = mx.sym.Convolution(data=relu1_1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=64, name='conv1_2')
    relu1_2 = mx.sym.Activation(data=conv1_2, act_type='relu', name='relu1_2')
    pool1 = mx.sym.Pooling(data=relu1_2, kernel=(2,2), stride=(2,2), pool_type='max',
                           pooling_convention='full', name='pool1')

    conv2_1 = mx.sym.Convolution(data=pool1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=128, name='conv2_1')
    relu2_1 = mx.sym.Activation(data=conv2_1, act_type='relu', name='relu2_1')
    conv2_2 = mx.sym.Convolution(data=relu2_1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=128, name='conv2_2')
    relu2_2 = mx.sym.Activation(data=conv2_2, act_type='relu', name='relu2_2')
    pool2 = mx.sym.Pooling(data=relu2_2, kernel=(2,2), stride=(2,2), pool_type='max',
                           pooling_convention='full', name='pool2')

    conv3_1 = mx.sym.Convolution(data=pool2, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=256, name='conv3_1')
    relu3_1 = mx.sym.Activation(data=conv3_1, act_type='relu', name='relu3_1')
    conv3_2 = mx.sym.Convolution(data=relu3_1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=256, name='conv3_2')
    relu3_2 = mx.sym.Activation(data=conv3_2, act_type='relu', name='relu3_2')
    conv3_3 = mx.sym.Convolution(data=relu3_2, kernel=(3, 3), pad=(1, 1), stride=(1, 1),
                                 num_filter=256, name='conv3_3')
    relu3_3 = mx.sym.Activation(data=conv3_3, act_type='relu', name='relu3_3')
    pool3 = mx.sym.Pooling(data=relu3_3, kernel=(2, 2), stride=(2, 2), pool_type='max',
                           pooling_convention='full', name='pool3')

    conv4_1 = mx.sym.Convolution(data=pool3, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv4_1')
    relu4_1 = mx.sym.Activation(data=conv4_1, act_type='relu', name='relu4_1')
    conv4_2 = mx.sym.Convolution(data=relu4_1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv4_2')
    relu4_2 = mx.sym.Activation(data=conv4_2, act_type='relu', name='relu4_2')
    conv4_3 = mx.sym.Convolution(data=relu4_2, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv4_3')
    relu4_3 = mx.sym.Activation(data=conv4_3, act_type='relu', name='relu4_3')
    pool4 = mx.sym.Pooling(data=relu4_3, kernel=(2,2), stride=(2,2), pool_type='max',
                           pooling_convention='full', name='pool4')

    conv5_1 = mx.sym.Convolution(data=pool4, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv5_1')
    relu5_1 = mx.sym.Activation(data=conv5_1, act_type='relu', name='relu5_1')
    conv5_2 = mx.sym.Convolution(data=relu5_1, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv5_2')
    relu5_2 = mx.sym.Activation(data=conv5_2, act_type='relu', name='relu5_2')
    conv5_3 = mx.sym.Convolution(data=relu5_2, kernel=(3,3), pad=(1,1), stride=(1,1),
                                 num_filter=512, name='conv5_3')
    relu5_3 = mx.sym.Activation(data=conv5_3, act_type='relu', name='relu5_3')
    pool5 = mx.sym.Pooling(data=relu5_3, kernel=(3,3), pad=(1,1), stride=(1,1),
                           pool_type='max', pooling_convention='full', name='pool5') 
    #這上面都是VGGNet中D網絡的設置
    #下面則是更改,將FC6和FC7全連接層改成卷積層
    conv6 = mx.sym.Convolution(data=pool5, kernel=(3,3), pad=(6,6), stride=(1,1),
                               num_filter=1024, dilate=(6,6), name='fc6')
    relu6 = mx.sym.Activation(data=conv6, act_type='relu', name='relu6')

    conv7 = mx.sym.Convolution(data=relu6, kernel=(1,1), stride=(1,1),
                               num_filter=1024, name='fc7')
    relu7 = mx.sym.Activation(data=conv7, act_type='relu', name='relu7')
    return relu7

 

然后接下來介紹新增網絡函數:add_extras(),該函數用於在修改后的VGG網絡后面添加conv8_2、conv9_2、conv10_2和conv11_2

從該函數可以看出,只要內容是通過循環判斷config['from_layers']列表判斷添加層的起始位置,而添加層的內容主要就是卷積層和激活層

最后將6個用於預測的特征層都保存在layers列表中,用於后續構造預測層

def add_extras(backbone, config_dict):
    layers = []
    body = backbone.get_internals() #得到Symbol對象的所有層信息,截取到想要的層
    for i, from_layer in enumerate(config_dict['from_layers']):
        if from_layer is '': #如果為空,這里的i從2開始,5結尾,所以用於生成conv8、conv9、conv10和conv11 block
            layer = layers[-1] #
            num_filters = config_dict['num_filters'][i] #得到卷積核的輸出大小
            s = config_dict['strides'][i] #stride值
            p = config_dict['pads'][i] #pad值
            #聲明兩個卷積層+激活函數
            conv_1x1 = mx.sym.Convolution(data=layer, kernel=(1,1),
                                          num_filter=num_filters // 2,
                                          pad=(0,0), stride=(1,1),
                                          name="conv{}_1".format(i+6))
            relu_1 = mx.sym.Activation(data=conv_1x1, act_type='relu',
                                       name="relu{}_1".format(i+6))
            conv_3x3 = mx.sym.Convolution(data=relu_1, kernel=(3,3),
                                          num_filter=num_filters,
                                          pad=(p,p), stride=(s,s),
                                          name="conv{}_2".format(i+6))
            relu_2 = mx.sym.Activation(data=conv_3x3, act_type='relu',
                                       name="relu{}_2".format(i+6))
            layers.append(relu_2)
        else: #layers前兩個值加入的是VGGNet從頭到relu4_3,從頭到relu7層的兩個網絡,后面的就是從頭到conv8,c從頭到conv9...的網絡
            layers.append(body[from_layer + '_output'])
    return layers

 

接下來介紹添加預測層的函數:create_predictor(),在該函數中基於上面的add_extras()函數返回的6個特征層layers構造預測層,預測層包括分類支路和回歸支路。因此create_predictor()函數整體上就是不斷循環讀取輸入的from_layers列表(6個特征層)的過程。在這個大循環中主要執行了一下幾個操作:

  • 通過config_dict['normalization']的值判斷是否需要添加L2 Normalization層,在SSD算法中,只有relu4_3層后面才需要添加L2 Normalization層
  • 計算每個基礎層的anchor數量,具體而言是通過設定的anchor大小數量和寬高比數量計算得到的:num_anchors = len(anchor_size)-1+len(anchor_ratio)
  • 基於基礎層構造回歸支路,回歸支路的構造是通過卷積層實現的,該卷積層的卷積核數量是通過num_loc_pred = num_anchors*4計算得到的,因為每個anchor都會有4個坐標信息,所以最后得到的預測位置值有num_loc_pred個
  • 基於基礎層構造分類支路,分類支路的構造也是通過卷積層實現的,該卷積層的卷積核數量是通過num_cls_pred = num_anchors * num_classes計算得到的,其中num_classes等於目標類別數+1,1表示背景類別
  • 通過mxnet.symbol.contrib.MultiBoxPrior()接口初始化anchor

循環結束后通過mxnet.symbol.concat()接口將6個回歸預測層輸出合並在一起得到loc_preds,將6個分類預測層輸出合並在一起的到cls_preds,將6個特征層的anchor合並在一起的到anchors,最后返回這3個對象

mxnet.symbol.concat(*data, **kwargs)融合接口定義的data參數是可變參數,即傳入的參數個數是可變的。因此假如我們采用列表形式傳入參數,那么需要在列表前加一個*符號

def create_predictor(from_layers, config_dict, num_classes):
    loc_pred_layers = []
    cls_pred_layers = []
    anchor_layers = []
    num_classes += 1

    for i, from_layer in enumerate(from_layers):
        from_name = from_layer.name
        if config_dict['normalization'][i] > 0: #添加L2 Normalization層
            num_filters = config_dict['num_filters'][i]
            init = mx.init.Constant(config_dict['normalization'][i])
            L2_normal = mx.sym.L2Normalization(data=from_layer, mode="channel",
                                              name="{}_norm".format(from_name))
            scale = mx.sym.Variable(name="{}_scale".format(from_name),
                                    shape=(1, num_filters, 1, 1),
                                    init=init, attr={'__wd_mult__': '0.1'})
            from_layer = mx.sym.broadcast_mul(lhs=scale, rhs=L2_normal)

        anchor_size = config_dict['sizes'][i]
        anchor_ratio = config_dict['ratios'][i]
        num_anchors = len(anchor_size) - 1 + len(anchor_ratio) #計算得到anchor數量

        # regression layer,回歸層
        num_loc_pred = num_anchors * 4 #卷積核的數量num_filter
        weight = mx.sym.Variable(name="{}_loc_pred_conv_weight".format(from_name),
                                 init=mx.init.Xavier(magnitude=2))
        loc_pred = mx.sym.Convolution(data=from_layer, kernel=(3,3),
                                      weight=weight, pad=(1,1), 
                                      num_filter=num_loc_pred,
                                      name="{}_loc_pred_conv".format(
                                      from_name))
        loc_pred = mx.sym.transpose(loc_pred, axes=(0,2,3,1)) 
        loc_pred = mx.sym.Flatten(data=loc_pred)
        loc_pred_layers.append(loc_pred)

        # classification part,分類層
        num_cls_pred = num_anchors * num_classes #卷積核的數量num_filter
        weight = mx.sym.Variable(name="{}_cls_pred_conv_weight".format(from_name),
                                 init=mx.init.Xavier(magnitude=2))
        cls_pred = mx.sym.Convolution(data=from_layer, kernel=(3,3),
                                      weight=weight, pad=(1,1), 
                                      num_filter=num_cls_pred,
                                      name="{}_cls_pred_conv".format(
                                      from_name))
        cls_pred = mx.sym.transpose(cls_pred, axes=(0,2,3,1))
        cls_pred = mx.sym.Flatten(data=cls_pred)
        cls_pred_layers.append(cls_pred)

        # anchor part,該特征層的anchor
        anchor_step = config_dict['steps'][i]
        anchors = mx.sym.contrib.MultiBoxPrior(from_layer, sizes=anchor_size,
                                               ratios=anchor_ratio, clip=False,
                                               steps=(anchor_step,anchor_step),
                                               name="{}_anchors".format(from_name))
        anchors = mx.sym.Flatten(data=anchors)
        anchor_layers.append(anchors)
    loc_preds = mx.sym.concat(*loc_pred_layers, name="multibox_loc_preds")
    cls_preds = mx.sym.concat(*cls_pred_layers)
    cls_preds = mx.sym.reshape(data=cls_preds, shape=(0,-1,num_classes))
    cls_preds = mx.sym.transpose(cls_preds, axes=(0,2,1), name="multibox_cls_preds")
    anchors = mx.sym.concat(*anchor_layers)
    anchors = mx.sym.reshape(data=anchors, shape=(0,-1,4), name="anchors")
    return loc_preds, cls_preds, anchors

 

構建好預測層后基本上就完成了SSD網絡主體結構的搭建,只需要再構造損失函數就可以完成整個網絡結構的搭建了。

損失函數層的構建是通過create_multi_loss()函數實現的,主要執行了如下的操作:

  • 通過mxnet.symbol.contric.MultiBoxTarget()接口得到模型的訓練目標,這個訓練目標包括回歸支路的訓練目標loc_target,也就是anchor和真實框之間的offset;回歸支路的mask:loc_target_mask,因為只有正樣本anchor采用回歸目標,因此這個loc_target_mask是用來標識哪些anchor有回歸的訓練目標。分類支路的訓練目標:cls_target,也就是每個anchor的真實類別標簽
  • 通過mxnet.symbol.SoftmaxOutput()接口創建分類支路的損失函數,即基於softmax的交叉熵損失函數,注意參數use_ignore和ignore_label,參數use_ignore設置為True表示在回傳損失時忽略參數ignore_label設定的標簽(這里ignore_label設置為-1,表示背景)所對應的樣本,換句話說就是負樣本anchor的分類是不會對梯度更新產生貢獻的
  • 通過mxnet.symbol.smooth_l1()接口創建回歸支路的損失函數,這里采用Smooth L1損失函數。該函數的輸入參數data設置為loc_target_mask * (loc_preds - loc_target),因為loc_target_mask會將負樣本anchor置零,因此回歸部分只有正樣本anchor才會對損失值計算產生貢獻
  • 通過mxnet.symbol.MakeLoss()接口將cls_target作為網絡的一個輸出,這部分是為了后期計算評價指標使用,因此可以看見其grad_scale參數設置為0,表示不傳遞損失
  • 通過mxnet.symbol.contrib.MultiBoxDetection()接口計算預測結果,這部分得到的是預測框的坐標值,用於計算mAP。同樣,在這一層后面也用mxnet.symbol.MakeLoss()接口進行封裝同時把grad_scale參數設置為0,這是在不影響網絡訓練的前提下獲取除了網絡正常輸出層以外的其他輸出時常用的方法
  • 通過mxnet.symbol.Group()接口將幾個輸出合並在一起並返回。一般常通過mxnet.symbol.Group()接口組合多個損失函數。例如當設置mxnet.symbol.Group([loss1, loss2])時,表示整個網絡的損失函數是loss1+loss2,二者之間的權重是一樣的。這里通過mxnet.symbol.Group()接口將[cls_prob, loc_loss, cls_label, det]合並在一起,但是因為cls_label和det在構造時將grad_scale參數設置為0,所以是不回傳損失值的,因此實際上還是只有分類和回歸兩部分損失函數
def create_multi_loss(label, loc_preds, cls_preds, anchors):
    loc_target,loc_target_mask,cls_target = mx.sym.contrib.MultiBoxTarget(
        anchor=anchors,
        label=label,
        cls_pred=cls_preds,
        overlap_threshold=0.5,
        ignore_label=-1,
        negative_mining_ratio=3,
        negative_mining_thresh=0.5,
        minimum_negative_samples=0,
        variances=(0.1, 0.1, 0.2, 0.2),
        name="multibox_target")
    #分類損失結果
#cls_prob,也就是目標的分類概率,維度是[B, C+1, N]
#其中B表示批次大小,C表示目標類別數,對於PASCAL VOC數據集C=20,N表示anchor數量,對於SSD算法來說,N默認為8732 cls_prob
= mx.sym.SoftmaxOutput(data=cls_preds, label=cls_target, ignore_label=-1, use_ignore=True, multi_output=True, normalization='valid', name="cls_prob") loc_loss_ = mx.sym.smooth_l1(data=loc_target_mask*(loc_preds-loc_target), scalar=1.0, name="loc_loss_") #回歸損失結果
#loc_loss,也就是回歸支路的損失值,維度是[B, N*4],已經計算出來了,該損失值會回傳 loc_loss
= mx.sym.MakeLoss(loc_loss_, normalization='valid', name="loc_loss") #分類真實值,用於后期計算評價指標使用 #grad_scale=0,所以不回傳損失值
#cls_label是目標的真實標簽,即真實類別,維度是[B, N] cls_label
= mx.sym.MakeLoss(data=cls_target, grad_scale=0, name="cls_label") #得到預測框的坐標值,用於計算mAP
#det對應預測框的坐標,維度為[B, N, 6],6包括1個預測類別、4個坐標信息和1個類別置信度 det
= mx.sym.contrib.MultiBoxDetection(cls_prob=cls_prob, loc_pred=loc_preds, anchor=anchors, nms_threshold=0.45, force_suppress=False, nms_topk=400, variances=(0.1,0.1,0.2,0.2), name="detection") #grad_scale=0,所以不回傳損失值 det = mx.sym.MakeLoss(data=det, grad_scale=0, name="det_out") output = mx.sym.Group([cls_prob, loc_loss, cls_label, det]) return output

 

4)數據讀取

通過CustomDataIter()類實現,腳本在https://github.com/miraclewkf/MXNet-Deep-Learning-in-Action/blob/master/chapter9-objectDetection/9.2-objectDetection/data/dataiter.py

該類的主要操作是對MXNet官方提供的檢測數據讀取接口mxnet.io.ImageDetRecordIter()做一定的封裝,使其能夠用於模型訓練。具體的封裝過程是針對讀取得到的標簽進行的,通過該類的內部函數_read_data()將讀取到的每個批次數據中的原始標簽(原始標簽中包含一些標識位等信息,可查看.lst文件,上面數據中有提到)轉換成維度為[批次大小, 標簽數量, 6],這里的標簽數量包括圖像的真實目標數量和填充(下面有解釋)的標簽數量,這樣能夠保證每一張圖像的標簽數量都相等

數據讀取部分涉及比較多的數據增強操作對模型的訓練結果影響較大,下面第3到7點都是數據增強的內容,依次是色彩變換、圖像填充、隨機裁剪、resize和隨機鏡像操作,基本上接口代碼執行數據增強的順序也是這樣的。下面解釋數據讀取接口中參數的含義:

  • 首先patch_imgrec、batch_size、data_shape、mean_r、mean_g、mean_b這幾個參數比較容易理解,即圖像.rec文件的路徑,batch size的大小、圖片的寬高大小、RGB中red的均值、green的均值和blue的均值
  • label_pad_width參數表示標簽填充長度,因為在目標檢測算法中,每一張輸入圖像中的目標數量不一樣,但是在訓練過程中每個批次數據的維度要保持一致才能進行訓練,因此就有了標簽填充這個操作,迷人填充值是-1,這樣就不會和真實的標簽混合,最終每張圖像的標簽長度都一致
  • 接下來從random_hue_prob到max_random_contrast這8個參數都是和色彩相關的數據增強操作,其中以prob結尾的參數表示使用該數據增強操作的概率,比如random_hue_prob、ramdom_saturation_prob、random_illumination_prob和random_contrast_prob,剩下4個參數是對應的色彩操作相關的參數
  • rand_pad_prob、fill_value和max_pad_scale這3個參數是用來填充邊界的。假設輸入圖像大小是h*w(暫不討論通道),那么首先會在[1, max_pad_scale]之間隨機選擇一個值,比如2,那么就會先初始化一個大小為2h*2w,使用fill_value進行填充的背景圖像,然后將輸入圖像隨機貼在這個背景圖像上,這樣得到的圖像將用於接下來的隨機裁剪操作。而rand_pad_prob參數表示隨機執行這個填充操作的概率
  • 接下來從rand_crop_prob到num_crop_sampler這13個參數都是和隨機裁剪相關的。首先rand_crop_prob參數表示執行隨機裁剪操作的概率。num_crop_sampler=5參數表示執行多少組不同參數配置的裁剪操作,該參數的值要和裁剪參數列表的長度相等,否則會報錯,最終會從設定的num_crop_sampler=5個裁剪結果中隨機選擇一個輸出。max_crop_aspect_ratios和min_crop_aspect_ratios這兩個參數表示裁剪時將圖像的寬高比變化成這兩個值之間的一個隨機值,因此這2個參數會對輸入圖像做一定形變。max_crop_overlaps和min_crop_overlaps這2個參數表示裁剪后圖像的標注框和原圖像的標注框之間的IoU最小值要大於min_crop_overlaps,同時小於max_crop_overlaps,這是為了防止裁剪后圖像的標注框缺失太多。max_crop_trials參數表示在每組max_crop_overlaps和min_crop_overlaps參數下可執行的裁剪次數上限,因為裁剪過程中可能需要裁剪多次才能保證原有的真實框不會被裁掉太多,而只要有某一次裁剪結果符合這個IoU上下限要求,則不再裁剪,轉而進入下一組max_crop_overlaps和min_crop_overlaps參數的裁剪
  • 接下來是resize操作,對應參數inter_method,表示resize操作時的插值算法,這里選擇隨機插值,另外還需要的最終圖像尺寸已經在data_shape參數中指定
  • 最后是隨機鏡像操作,對應參數rand_mirror_prob,常用的默認值是0.5
import mxnet as mx

class CustomDataIter(mx.io.DataIter):
    def __init__(self, args, is_trainData=False):
        self.args = args
        data_shape = (3, args.data_shape, args.data_shape)
        if is_trainData:#如果要得到的是訓練數據
            self.data=mx.io.ImageDetRecordIter(
                path_imgrec=args.train_rec,
                batch_size=args.batch_size,
                data_shape=data_shape,
                mean_r=123.68,
                mean_g=116.779,
                mean_b=103.939,
                label_pad_width=420,
                random_hue_prob=0.5,
                max_random_hue=18,
                random_saturation_prob=0.5,
                max_random_saturation=32,
                random_illumination_prob=0.5,
                max_random_illumination=32,
                random_contrast_prob=0.5,
                max_random_contrast=0.5,
                rand_pad_prob=0.5,
                fill_value=127,
                max_pad_scale=4,
                rand_crop_prob=0.833333,
                max_crop_aspect_ratios=[2.0, 2.0, 2.0, 2.0, 2.0],
                max_crop_object_coverages=[1.0, 1.0, 1.0, 1.0, 1.0],
                max_crop_overlaps=[1.0, 1.0, 1.0, 1.0, 1.0],
                max_crop_sample_coverages=[1.0, 1.0, 1.0, 1.0, 1.0],
                max_crop_scales=[1.0, 1.0, 1.0, 1.0, 1.0],
                max_crop_trials=[25, 25, 25, 25, 25],
                min_crop_aspect_ratios=[0.5, 0.5, 0.5, 0.5, 0.5],
                min_crop_object_coverages=[0.0, 0.0, 0.0, 0.0, 0.0],
                min_crop_overlaps=[0.1, 0.3, 0.5, 0.7, 0.9],
                min_crop_sample_coverages=[0.0, 0.0, 0.0, 0.0, 0.0],
                min_crop_scales=[0.3, 0.3, 0.3, 0.3, 0.3],
                num_crop_sampler=5,
                inter_method=10,
                rand_mirror_prob=0.5,
                shuffle=True
            )
        else:#得到的是測試數據
            self.data=mx.io.ImageDetRecordIter(
                path_imgrec=args.val_rec,
                batch_size=args.batch_size,
                data_shape=data_shape,
                mean_r=123.68,
                mean_g=116.779,
                mean_b=103.939,
                label_pad_width=420,
                shuffle=False
            )
        self._read_data()
        self.reset()

    @property
    def provide_data(self):
        return self.data.provide_data

    @property
    def provide_label(self):
        return self.new_provide_label

    def reset(self):
        self.data.reset()

    #讀取到的每個批次數據中的原始標簽(原始標簽中包含一些標識位等信息)
    def _read_data(self):
        self._data_batch = next(self.data)#得到下一個bath size大小的數據集
        if self._data_batch is None:
            return False
        else:
            original_label = self._data_batch.label[0]
            original_label_length = original_label.shape[1]
            label_head_length = int(original_label[0][4].asscalar())
            object_label_length = int(original_label[0][5].asscalar())
            label_start_idx = 4+label_head_length
            label_num = (original_label_length-
                         label_start_idx+1)//object_label_length
            self.new_label_shape = (self.args.batch_size, label_num,
                                    object_label_length)
            self.new_provide_label = [(self.args.label_name,
                                       self.new_label_shape)]
            new_label = original_label[:,label_start_idx:
                                object_label_length*label_num+label_start_idx]
            self._data_batch.label = [new_label.reshape((-1,label_num,
                                                         object_label_length))]
        return True

    def iter_next(self):
        return self._read_data()

    def next(self):
        if self.iter_next():
            return self._data_batch
        else:
            raise StopIteration

 

5)定義訓練評價指標

訓練評價指標腳本在https://github.com/miraclewkf/MXNet-Deep-Learning-in-Action/blob/master/chapter9-objectDetection/9.2-objectDetection/tools/custom_metric.py

在MXNet中,一般通過繼承mxnet.metric.EvalMetric類並重寫部分方法實現評價指標的自定義。在大多數目標檢測算法中,分類支路都采用基於softmax的交叉熵損失函數,回歸支路都采用Smooth L1損失函數,在MultiBoxMetric類中同時執行了這兩個指標的計算

在MultiBoxMetric類中主要涉及__init__()、重置方法reset()、指標更新方法update()和指標獲取方法get()

注意在重置方法reset()中將self.num_inst和self.num_metric的長度都設置為2,正是和交叉熵及Smooth L1損失對應。指標更新方法update()是這個類的核心,該方法有2個重要輸入,分別是標簽labels和網絡的預測輸出preds。在介紹SSD網絡結構構造(即上面get_ssd()腳本中的create_multi_loss()函數),在構造函數的最后通過mxnet.symbol.Group()接口將4個輸出值組合在一起,而這里的preds變量就是這4個輸出值。因此可以看見preds[0]對應cls_prob,也就是目標的分類概率,維度是[B, C+1, N],其中B表示批次大小,C表示目標類別數,對於PASCAL VOC數據集C=20,N表示anchor數量,對於SSD算法來說,N默認為8732。preds[1]對應loc_loss,也就是回歸支路的損失值,維度是[B, N*4],因此這個部分就是這個類要輸出的損失值之一:Smooth L1。preds[2]對應目標的真實標簽,即真實類別,維度是[B, N],這個變量是計算交叉熵損失值的重要輸出之一。preds[3]對應預測框的坐標,維度為[B, N, 6],6包括1個預測類別、4個坐標信息和1個類別置信度,preds[3]在這里暫時用不到

注意到update()方法中有一個重要的操作:valid_count = np.sum(cls_label >= 0)這一行是計算有效anchor的數量,該數量用於評價指標的計算

變量mask和indices用於篩選類別預測概率cls_prob和有效anchor對應的真實類別的預測概率,得到這個概率后就可以作為交叉熵函數的輸入用於計算交叉熵值

指標獲取方法get()內容相對簡單,主要就是對update()方法中計算得到的變量self.sum_metric和self.num_inst執行除法操作,需要注意的是當除數為0時,這里設置為輸出'nan'

import mxnet as mx
import numpy as np

class MultiBoxMetric(mx.metric.EvalMetric):
    def __init__(self, name):
        super(MultiBoxMetric, self).__init__('MultiBoxMetric')
        self.name = name
        self.eps = 1e-18
        self.reset()

    def reset(self):
        self.num = 2#標明一個位置記錄CrossEntropy Loss,一個位置記錄SmoothL1 Loss
        self.num_inst = [0] * self.num
        self.sum_metric = [0.0] * self.num

    def update(self, labels, preds):
        cls_prob = preds[0].asnumpy() #圖像類別的預測概率
        loc_loss = preds[1].asnumpy() #回歸支路的損失值,在create_multi_loss()函數中已經計算得到的Smooth L1損失值
        cls_label = preds[2].asnumpy() #圖像目標的真實類別

        valid_count = np.sum(cls_label >= 0) #計算有效anchor的數量,該數量用於評價指標的計算
        label = cls_label.flatten()
        mask = np.where(label >= 0)[0]
        indices = np.int64(label[mask])
        prob = cls_prob.transpose((0, 2, 1)).reshape((-1, cls_prob.shape[1]))
        prob = prob[mask, indices]

        # CrossEntropy Loss
        self.sum_metric[0] += (-np.log(prob + self.eps)).sum()
        self.num_inst[0] += valid_count

        # SmoothL1 Loss
        self.sum_metric[1] += np.sum(loc_loss)
        self.num_inst[1] += valid_count

    def get(self):
        result = [sum / num if num != 0 else float('nan') for sum, num in zip(self.sum_metric, self.num_inst)]
        return (self.name, result)

在主函數main()中調用自定義的評價指標類時需要先導入對應類,然后通過mxnet.metric.CompositeMetric()接口得到指標管理對象eval_metric,然后調用eval_metric的add()方法添加對應的評價指標類,比如這里的multiBoxMetric類,參數name的設定和訓練過程中的日志信息相關,最后將eval_metric作為fit()方法的參數輸入即可

from tools.custom_metric import MultiBoxMetric
eval_metric = mx.metric.CompositeEvalMetric()
eval_metric.add(MultiBoxMetric(name=['CrossEntropy Loss', 'SmoothL1 Loss']))

 

6)訓練模型

下載VGG預訓練模型https://drive.google.com/open?id=1W-4xGKJZbHCZXIZY4fXfSekoQ2sE3-Ty,並將其放在model目錄下

然后開始訓練:

nohup python2 train.py --gpus 0 --train-rec /opt/user/PASCAL_VOC_converge/lst/trainval.rec --train-idx /opt/user/PASCAL_VOC_converge/lst/trainval.idx --val-rec /opt/user/PASCAL_VOC_converge/lst/test.rec >> train_1.out &

 如果報錯:

Traceback (most recent call last):
  File "train.py", line 3, in <module>
    from symbol.get_ssd import get_ssd
ImportError: No module named get_ssd 

Traceback (most recent call last):
  File "train.py", line 4, in <module>
    from tools.custom_metric import MultiBoxMetric
ImportError: No module named tools.custom_metric

這些錯誤可能是因為本地文件中沒有__init__.py文件引起的,創建一個空白的__init__.py文件即可

 訓練結果為:

/usr/local/lib/python2.7/dist-packages/h5py/__init__.py:34: FutureWarning: Conversion of the second argument of issubdtype from `float` to `np.floating` is deprecated. In future, it will be treated as `np.float64 == np.dtype(float).type`.
  from ._conv import register_converters as _register_converters
Namespace(backbone_epoch=1, backbone_prefix='model/vgg16_reduced', batch_size=32, begin_epoch=0, class_names='aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor', data_shape=300, factor=0.1, freeze_layers='^(conv1_|conv2_).*', frequent=20, gpus='0', label_name='label', label_pad_width=420, lr=0.001, mom=0.9, num_classes=20, num_epoch=240, num_examples=16551, save_name='ssd', save_result='output/ssd_vgg/', step='160,200', train_idx='/opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/trainval.idx', train_rec='/opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/trainval.rec', val_rec='/opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/test.rec', wd=0.0005)
[10:53:07] src/io/iter_image_det_recordio.cc:281: ImageDetRecordIOParser: /opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/trainval.rec, use 4 threads for decoding..
[10:53:07] src/io/iter_image_det_recordio.cc:334: ImageDetRecordIOParser: /opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/trainval.rec, label padding width: 420
[10:53:07] src/io/iter_image_det_recordio.cc:281: ImageDetRecordIOParser: /opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/test.rec, use 4 threads for decoding..
[10:53:07] src/io/iter_image_det_recordio.cc:334: ImageDetRecordIOParser: /opt/wanghui/ageAndGender/PASCAL_VOC_converge/lst/test.rec, label padding width: 420
[10:53:08] src/nnvm/legacy_json_util.cc:209: Loading symbol saved by previous version v0.8.0. Attempting to upgrade...
[10:53:08] src/nnvm/legacy_json_util.cc:217: Symbol successfully upgraded!
[10:53:11] src/operator/nn/./cudnn/./cudnn_algoreg-inl.h:107: Running performance tests to find the best convolution algorithm, this can take a while... (setting env variable MXNET_CUDNN_AUTOTUNE_DEFAULT to 0 to disable)
Epoch[0] Batch [20]    Speed: 54.01 samples/sec    CrossEntropy Loss=3.678806    SmoothL1 Loss=0.801627
Epoch[0] Batch [40]    Speed: 53.12 samples/sec    CrossEntropy Loss=2.281143    SmoothL1 Loss=0.752175
Epoch[0] Batch [60]    Speed: 55.09 samples/sec    CrossEntropy Loss=1.779993    SmoothL1 Loss=0.702911
Epoch[0] Batch [80]    Speed: 55.56 samples/sec    CrossEntropy Loss=1.467279    SmoothL1 Loss=0.669974
Epoch[0] Batch [100]    Speed: 54.36 samples/sec    CrossEntropy Loss=1.334813    SmoothL1 Loss=0.659752
Epoch[0] Batch [120]    Speed: 55.33 samples/sec    CrossEntropy Loss=1.298537    SmoothL1 Loss=0.623168
Epoch[0] Batch [140]    Speed: 54.41 samples/sec    CrossEntropy Loss=1.246134    SmoothL1 Loss=0.619177
Epoch[0] Batch [160]    Speed: 54.33 samples/sec    CrossEntropy Loss=1.262615    SmoothL1 Loss=0.609437
Epoch[0] Batch [180]    Speed: 53.99 samples/sec    CrossEntropy Loss=1.256767    SmoothL1 Loss=0.587360
Epoch[0] Batch [200]    Speed: 53.62 samples/sec    CrossEntropy Loss=1.216275    SmoothL1 Loss=0.558847
Epoch[0] Batch [220]    Speed: 53.15 samples/sec    CrossEntropy Loss=1.227030    SmoothL1 Loss=0.549904
Epoch[0] Batch [240]    Speed: 53.78 samples/sec    CrossEntropy Loss=1.222743    SmoothL1 Loss=0.567165
Epoch[0] Batch [260]    Speed: 53.21 samples/sec    CrossEntropy Loss=1.208321    SmoothL1 Loss=0.594028
Epoch[0] Batch [280]    Speed: 53.32 samples/sec    CrossEntropy Loss=1.210765    SmoothL1 Loss=0.569921
Epoch[0] Batch [300]    Speed: 53.68 samples/sec    CrossEntropy Loss=1.226451    SmoothL1 Loss=0.548242
Epoch[0] Batch [320]    Speed: 55.09 samples/sec    CrossEntropy Loss=1.195439    SmoothL1 Loss=0.535762
Epoch[0] Batch [340]    Speed: 53.16 samples/sec    CrossEntropy Loss=1.190477    SmoothL1 Loss=0.537575
Epoch[0] Batch [360]    Speed: 52.66 samples/sec    CrossEntropy Loss=1.193106    SmoothL1 Loss=0.519501
Epoch[0] Batch [380]    Speed: 53.71 samples/sec    CrossEntropy Loss=1.198750    SmoothL1 Loss=0.499681
Epoch[0] Batch [400]    Speed: 53.86 samples/sec    CrossEntropy Loss=1.178342    SmoothL1 Loss=0.514135
Epoch[0] Batch [420]    Speed: 53.36 samples/sec    CrossEntropy Loss=1.179339    SmoothL1 Loss=0.496589
Epoch[0] Batch [440]    Speed: 53.04 samples/sec    CrossEntropy Loss=1.175921    SmoothL1 Loss=0.526165
Epoch[0] Batch [460]    Speed: 53.83 samples/sec    CrossEntropy Loss=1.159254    SmoothL1 Loss=0.500332
Epoch[0] Batch [480]    Speed: 54.13 samples/sec    CrossEntropy Loss=1.154471    SmoothL1 Loss=0.506516
Epoch[0] Batch [500]    Speed: 54.64 samples/sec    CrossEntropy Loss=1.159192    SmoothL1 Loss=0.481005
Epoch[0] Train-CrossEntropy Loss=1.144381
Epoch[0] Train-SmoothL1 Loss=0.481581
Epoch[0] Time cost=314.980
Epoch[0] Validation-aeroplane=0.052555
Epoch[0] Validation- bicycle=0.000000
Epoch[0] Validation- bird=0.001830
Epoch[0] Validation- boat=0.090909
Epoch[0] Validation- bottle=0.000000
Epoch[0] Validation- bus=0.000000
Epoch[0] Validation- car=0.150089
Epoch[0] Validation- cat=0.175901
Epoch[0] Validation- chair=0.008707
Epoch[0] Validation- cow=0.022727
Epoch[0] Validation- diningtable=0.005510
Epoch[0] Validation- dog=0.134398
Epoch[0] Validation- horse=0.000000
Epoch[0] Validation- motorbike=0.045455
Epoch[0] Validation- person=0.303137
Epoch[0] Validation- pottedplant=0.000000
Epoch[0] Validation- sheep=0.090909
Epoch[0] Validation- sofa=0.003497
Epoch[0] Validation- train=0.013722
Epoch[0] Validation- tvmonitor=0.000000
Epoch[0] Validation-mAP=0.054967

...
Epoch[224] Train-CrossEntropy Loss=0.419221
Epoch[224] Train-SmoothL1 Loss=0.184243
Epoch[224] Time cost=298.695
Saved checkpoint to "output/ssd_vgg/ssd-0225.params"
Epoch[224] Validation-aeroplane=0.746456
Epoch[224] Validation- bicycle=0.807950
Epoch[224] Validation- bird=0.731754
Epoch[224] Validation- boat=0.654885
Epoch[224] Validation- bottle=0.422976
Epoch[224] Validation- bus=0.788919
Epoch[224] Validation- car=0.837169
Epoch[224] Validation- cat=0.862399
Epoch[224] Validation- chair=0.545606
Epoch[224] Validation- cow=0.770324
Epoch[224] Validation- diningtable=0.722630
Epoch[224] Validation- dog=0.840437
Epoch[224] Validation- horse=0.804539
Epoch[224] Validation- motorbike=0.778533
Epoch[224] Validation- person=0.756137
Epoch[224] Validation- pottedplant=0.482842
Epoch[224] Validation- sheep=0.742795
Epoch[224] Validation- sofa=0.704094
Epoch[224] Validation- train=0.841322
Epoch[224] Validation- tvmonitor=0.744847
Epoch[224] Validation-mAP=0.729331 

...
Epoch[239] Batch [20]    Speed: 56.65 samples/sec    CrossEntropy Loss=0.426443    SmoothL1 Loss=0.183919
Epoch[239] Batch [40]    Speed: 57.01 samples/sec    CrossEntropy Loss=0.431296    SmoothL1 Loss=0.200060
Epoch[239] Batch [60]    Speed: 52.16 samples/sec    CrossEntropy Loss=0.429510    SmoothL1 Loss=0.200417
Epoch[239] Batch [80]    Speed: 56.65 samples/sec    CrossEntropy Loss=0.418150    SmoothL1 Loss=0.184664
Epoch[239] Batch [100]    Speed: 53.66 samples/sec    CrossEntropy Loss=0.462474    SmoothL1 Loss=0.211466
Epoch[239] Batch [120]    Speed: 54.13 samples/sec    CrossEntropy Loss=0.435430    SmoothL1 Loss=0.196258
Epoch[239] Batch [140]    Speed: 55.91 samples/sec    CrossEntropy Loss=0.442141    SmoothL1 Loss=0.206768
Epoch[239] Batch [160]    Speed: 55.58 samples/sec    CrossEntropy Loss=0.404212    SmoothL1 Loss=0.185162
Epoch[239] Batch [180]    Speed: 56.38 samples/sec    CrossEntropy Loss=0.433802    SmoothL1 Loss=0.204757
Epoch[239] Batch [200]    Speed: 53.80 samples/sec    CrossEntropy Loss=0.428380    SmoothL1 Loss=0.190957
Epoch[239] Batch [220]    Speed: 54.36 samples/sec    CrossEntropy Loss=0.414101    SmoothL1 Loss=0.182156
Epoch[239] Batch [240]    Speed: 55.74 samples/sec    CrossEntropy Loss=0.438334    SmoothL1 Loss=0.200135
Epoch[239] Batch [260]    Speed: 56.93 samples/sec    CrossEntropy Loss=0.425816    SmoothL1 Loss=0.198305
Epoch[239] Batch [280]    Speed: 55.92 samples/sec    CrossEntropy Loss=0.444168    SmoothL1 Loss=0.216135
Epoch[239] Batch [300]    Speed: 55.69 samples/sec    CrossEntropy Loss=0.439784    SmoothL1 Loss=0.204467
Epoch[239] Batch [320]    Speed: 55.63 samples/sec    CrossEntropy Loss=0.435857    SmoothL1 Loss=0.188829
Epoch[239] Batch [340]    Speed: 55.63 samples/sec    CrossEntropy Loss=0.447643    SmoothL1 Loss=0.215523
Epoch[239] Batch [360]    Speed: 55.76 samples/sec    CrossEntropy Loss=0.438492    SmoothL1 Loss=0.201567
Epoch[239] Batch [380]    Speed: 54.95 samples/sec    CrossEntropy Loss=0.410612    SmoothL1 Loss=0.180233
Epoch[239] Batch [400]    Speed: 55.87 samples/sec    CrossEntropy Loss=0.436391    SmoothL1 Loss=0.199497
Epoch[239] Batch [420]    Speed: 54.86 samples/sec    CrossEntropy Loss=0.414704    SmoothL1 Loss=0.185385
Epoch[239] Batch [440]    Speed: 55.74 samples/sec    CrossEntropy Loss=0.426018    SmoothL1 Loss=0.197811
Epoch[239] Batch [460]    Speed: 55.83 samples/sec    CrossEntropy Loss=0.415611    SmoothL1 Loss=0.189821
Epoch[239] Batch [480]    Speed: 54.99 samples/sec    CrossEntropy Loss=0.430231    SmoothL1 Loss=0.201216
Epoch[239] Batch [500]    Speed: 55.18 samples/sec    CrossEntropy Loss=0.427003    SmoothL1 Loss=0.193450
Epoch[239] Train-CrossEntropy Loss=0.435620
Epoch[239] Train-SmoothL1 Loss=0.198597
Epoch[239] Time cost=298.379
Saved checkpoint to "output/ssd_vgg/ssd-0240.params"
Epoch[239] Validation-aeroplane=0.753590
Epoch[239] Validation- bicycle=0.805885
Epoch[239] Validation- bird=0.724330
Epoch[239] Validation- boat=0.663261
Epoch[239] Validation- bottle=0.426557
Epoch[239] Validation- bus=0.791799
Epoch[239] Validation- car=0.836057
Epoch[239] Validation- cat=0.860048
Epoch[239] Validation- chair=0.541711
Epoch[239] Validation- cow=0.780643
Epoch[239] Validation- diningtable=0.723618
Epoch[239] Validation- dog=0.845353
Epoch[239] Validation- horse=0.805521
Epoch[239] Validation- motorbike=0.778549
Epoch[239] Validation- person=0.756852
Epoch[239] Validation- pottedplant=0.484081
Epoch[239] Validation- sheep=0.745561
Epoch[239] Validation- sofa=0.704528
Epoch[239] Validation- train=0.846411
Epoch[239] Validation- tvmonitor=0.742994
Epoch[239] Validation-mAP=0.730868
View Code

 

7)測試模型

訓練得到模型后,就可以基於訓練得到的模型進行測試,腳本為https://github.com/miraclewkf/MXNet-Deep-Learning-in-Action/blob/master/chapter9-objectDetection/9.2-objectDetection/demo.py

其主函數main()主要執行了一下幾個部分:

  • 調用load_model()函數導入模型,需要傳入的參數包括模型保存路徑、prefix、模型保存名稱中的index和環境信息context
  • 定義數據的預處理操作,比如通過mxnet.image.CastAug()接口將輸入圖像的數值類型轉成float32類型,通過mxnet.image.ForceResizeAug()接口將輸入圖像縮放到指定尺寸,通過mxnet.image.ColorNormalizeAug()接口對輸入圖像的數值做歸一化
  • 讀取測試文件夾中的圖像數據,讀取進來的圖像先通過transform()函數進行第2點的預處理操作,然后通過mxnet.ndarray.transpose()接口調整通道維度的順序,再通過mxnet.ndarray.expand_dims()接口增加批次維度,最終數據是維度為[N, C, H, W]的4維NDArray變量,維度表示批次、通道、高和寬。這里因為輸入只有一張圖,所以N=1
  • 通過mxnet.io.DataBatch()接口封裝數據並作為model的forward()方法的輸入進行前向計算
  • 調用model的get_outputs()方法得到輸出。有四個輸出,其中第四個輸出是通過mxnet.symbol.contrib.MultiBoxDetection()接口實現的,該接口用於得到檢測結果,並對預測框也做了NMS操作,因此其就是我們想要的檢測結果model.get_outputs()[3]
  • 去掉檢測結果中類別為-1的預測框,剩下的就是目標的預測框了,對應變量det。最后調用plit_pred()函數將預測框畫在測試圖像上並保存在detection_result文件夾下
def main():
    model = load_model(prefix="output/ssd_vgg/ssd",
                       index=225,
                       context=mx.gpu(0))

    cast_aug = mx.image.CastAug()
    resize_aug = mx.image.ForceResizeAug(size=[300, 300])
    normalization = mx.image.ColorNormalizeAug(mx.nd.array([123, 117, 104]),
                                               mx.nd.array([1, 1, 1]))
    augmenters = [cast_aug, resize_aug, normalization]

    img_list = os.listdir('demo_img')
    for img_i in img_list:
        data = mx.image.imread('demo_img/'+img_i)
        det_data = transform(data, augmenters)
        det_data = mx.nd.transpose(det_data, axes=(2, 0, 1))
        det_data = mx.nd.expand_dims(det_data, axis=0)

        model.forward(mx.io.DataBatch((det_data,)))
        det_result = model.get_outputs()[3].asnumpy()
        det = det_result[np.where(det_result[:,:,0] >= 0)]
        plot_pred(det=det, data=data, img_i=img_i)

if __name__ == '__main__':
    main()

 

主函數中調用的模型導入函數load_model(),會先對圖像尺寸、數據名、標簽名、標簽維度做初始化;然后通過mxnet.model.load_checkpoint()接口導入訓練好的模型,得到模型參數arg_params和aux_params;接着通過調用get_ssd()函數構造SSD網絡結構。

准備好這些內容后就可以通過mxnet.mod.Module()接口初始化得到一個Module對象——model;然后調用model的bind()方法將數據信息和網絡結構添加到執行器,從而構成一個完整的能夠運行的對象;最后調用model的set_params()方法用導入的模型參數初始化構建的SSD網絡,這樣就得到了最終的檢測模型

def load_model(prefix, index, context):
    batch_size = 1
    data_width, data_height = 300, 300
    data_names = ['data']
    data_shapes = [('data', (batch_size, 3, data_height, data_width))]
    label_names = ['label']
    label_shapes = [('label', (batch_size, 3, 6))]
    body, arg_params, aux_params = mx.model.load_checkpoint(prefix, index)
    symbol = get_ssd(num_classes=20)
    model = mx.mod.Module(symbol=symbol, context=context,
                          data_names=data_names,
                          label_names=label_names)
    model.bind(for_training=False,
               data_shapes=data_shapes,
               label_shapes=label_shapes)
    model.set_params(arg_params=arg_params, aux_params=aux_params,
                     allow_missing=True)
    return model

運行測試:

user@home:/opt/user/mxnet-learning/9.2-objectDetection$ nohup python2 demo.py >> test1.out &

若出現錯誤:

_tkinter.TclError: no display name and no $DISPLAY environment variable

還是和上面一樣,在demo.py代碼中添加:

import matplotlib
matplotlib.use('Agg')

 

還出現另一個錯誤:

IOError: cannot write mode RGBA as JPEG

這是因為得到的圖片有RGBA4個通道,JPEG格式只有RGB3個通道,而PNG有RGBA4個通道

將圖片保存為PNG格式即可,添加下面一樣命令重置img_i為png:

    plt.axis('off')
    img_i = img_i.strip().split('.')[0] + '.png'
    plt.savefig("detection_result/{}".format(img_i))

 

然后可以到detection_result文件夾中去查看結果

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM