『MXNet』第十彈_物體檢測SSD


全流程地址

一、輔助API介紹

mxnet.image.ImageDetIter

圖像檢測迭代器,

from mxnet import image
from mxnet import nd

data_shape = 256
batch_size = 32
rgb_mean = nd.array([123, 117, 104])

def get_iterators(data_shape, batch_size):
    """256, 32"""
    class_names = ['pikachu']
    num_class = len(class_names)
    train_iter = image.ImageDetIter(
        batch_size=batch_size,
        data_shape=(3, data_shape, data_shape),
        path_imgrec=data_dir+'train.rec',
        path_imgidx=data_dir+'train.idx',
        shuffle=True,
        mean=True,
        rand_crop=1,
        min_object_covered=0.95,
        max_attempts=200)
    val_iter = image.ImageDetIter(
        batch_size=batch_size,
        data_shape=(3, data_shape, data_shape),
        path_imgrec=data_dir+'val.rec',
        shuffle=False,
        mean=True)
    return train_iter, val_iter, class_names, num_class

train_data, test_data, class_names, num_class = get_iterators(
    data_shape, batch_size)

batch = train_data.next()
# (32, 1, 5)
# 1:圖像中只有一個目標
# 5:第一個元素對應物體的標號,-1表示非法物體;后面4個元素表示邊框,0~1
# 多個目標時list[nd(batch_size, 目標數目, 目標信息)]
print(batch) 
# list[nd(batch_size,channel,width,higth)]
print(batch.data[0].shape)
print(batch.label[0].shape)
DataBatch: data shapes: [(32, 3, 256, 256)] label shapes: [(32, 1, 5)]
(32, 3, 256, 256)
(32, 1, 5)

可以看到標號的形狀是batch_size x num_object_per_image x 5。這里數據里每個圖片里面只有一個標號。每個標號由長為5的數組表示,第一個元素是其對用物體的標號,其中-1表示非法物體,僅做填充使用。后面4個元素表示邊框。

mxnet.metric

from mxnet import metric

cls_metric = metric.Accuracy()
box_metric = metric.MAE() 

cls_metric.update([cls_target], [class_preds.transpose((0,2,1))])
box_metric.update([box_target], [box_preds * box_mask])
cls_metric.get()
box_metric.get()

gluon.loss.Loss

用法類似Block,被繼承用來定義新的損失函數,值得注意的是這里體現了F的用法:代替mx.nd or mx.sym

class FocalLoss(gluon.loss.Loss):
    def __init__(self, axis=-1, alpha=0.25, gamma=2, batch_axis=0, **kwargs):
        super(FocalLoss, self).__init__(None, batch_axis, **kwargs)
        self._axis = axis
        self._alpha = alpha
        self._gamma = gamma

    def hybrid_forward(self, F, output, label):
        # (32, 5444, 2) (32, 5444)
        # Here `F` can be either mx.nd or mx.sym
        # 這里使用F取代在forward中顯式的指定兩者,方便使用
        # 所以非hybrid無此參數
        output = F.softmax(output)
        pj = output.pick(label, axis=self._axis, keepdims=True)
        # print(pj.shape)  (32, 5444, 1):僅僅保留正確類別對應的概率
        # print(self._axis)  -1
        loss = - self._alpha * ((1 - pj) ** self._gamma) * pj.log()
        return loss.mean(axis=self._batch_axis, exclude=True)

 pick:根據label最后一維的值選取output的-2維上的元素

二、框體處理系列函數

框體生成:mxnet.contrib.ndarray.MultiBoxPrior

因為邊框可以出現在圖片中的任何位置,並且可以有任意大小。為了簡化計算,SSD跟Faster R-CNN一樣使用一些默認的邊界框,或者稱之為錨框(anchor box),做為搜索起點。具體來說,對輸入的每個像素,以其為中心采樣數個有不同形狀和不同比例的邊界框。假設輸入大小是 w×hw×h,

  • 給定大小 s(0,1]s∈(0,1],那么生成的邊界框形狀是 
  • 給定比例 r>0r>0,那么生成的邊界框形狀是 

在采樣的時候我們提供 n 個大小(sizes)和 m 個比例(ratios)。為了計算簡單這里不生成nm個錨框,而是n+m1個。其中第 i 個錨框使用

  • sizes[i]ratios[0] 如果 in
  • sizes[0]ratios[i-n] 如果 i>n

我們可以使用contribe.ndarray里的MultiBoxPrior來采樣錨框。這里錨框通過左下角和右上角兩個點來確定,而且被標准化成了區間[0,1][0,1]的實數。

from mxnet import nd
from mxnet.contrib.ndarray import MultiBoxPrior

# shape: batch x channel x height x weight
n = 40
x = nd.random.uniform(shape=(1, 3, n, n))

y = MultiBoxPrior(x, sizes=[.5,.25,.1], ratios=[1,2,.5])
# 每個像素點(n*n),5個框,4個坐標值
boxes = y.reshape((n, n, -1, 4))
print(boxes.shape)
# The first anchor box centered on (20, 20)
# its format is (x_min, y_min, x_max, y_max)
boxes[20, 20, :, :] 
(40, 40, 5, 4)
Out[5]:
[[ 0.26249999  0.26249999  0.76249999  0.76249999]
 [ 0.38749999  0.38749999  0.63749999  0.63749999]
 [ 0.46249998  0.46249998  0.5625      0.5625    ]
 [ 0.1589466   0.33572328  0.86605334  0.6892767 ]
 [ 0.33572328  0.1589466   0.6892767   0.86605334]]
<NDArray 5x4 @cpu(0)>我們可以畫出以(20,20)為中心的所有錨框:
colors = ['blue', 'green', 'red', 'black', 'magenta']

# 白板背景
plt.imshow(nd.ones((n, n, 3)).asnumpy())
# 提取某個像素點的框子
anchors = boxes[10, 10, :, :]
for i in range(anchors.shape[0]):
    plt.gca().add_patch(box_to_rect(anchors[i,:]*n, colors[i]))
plt.show()
# 可以看到,貼邊框子會被截斷

 

框體篩選:mxnet.contrib.ndarray.MultiBoxTarget

雖然每張圖片里面通常只有幾個標注的邊框,但SSD會生成大量的錨框。可以想象很多錨框都不會框住感興趣的物體,就是說跟任何對應感興趣物體的表框的IoU都小於某個閾值。這樣就會產生大量的負類錨框,或者說對應標號為0的錨框。對於這類錨框有兩點要考慮的:

  1. 邊框預測的損失函數不應該包括負類錨框,因為它們並沒有對應的真實邊框
  2. 因為負類錨框數目可能遠多於其他,我們可以只保留其中的一些。而且是保留那些目前預測最不確信它是負類的,就是對類0預測值排序,選取數值最小的哪一些困難的負類錨框。

我們可以使用MultiBoxTarget來完成上面這兩個操作。

def training_targets(anchors, class_preds, labels):
    """
    得到的全部邊框坐標
    得到的全部邊框各個類別得分
    真實類別及對應邊框坐標
    """
    class_preds = class_preds.transpose(axes=(0,2,1))
    return MultiBoxTarget(anchors, labels, class_preds)

# Output achors: (1, 5444, 4),1張圖共5444個框4個坐標值
# Output class predictions: (1, 5444, 3),1張圖5444個框3個類別(2分類+背景)
# batch.label: (1, 1, 5),1張圖1個對象(1具體類別+4坐標)
out = training_targets(anchors, class_preds, batch.label[0][0:1]) 
[[ 0.  0.  0. ...,  0.  0.  0.]]

它返回三個NDArray,分別是

  1. 預測的邊框跟真實邊框的偏移,大小是batch_size x (num_anchors*4)
  2. 用來遮掩不需要的負類錨框的掩碼,大小跟上面一致
  3. 錨框的真實的標號,大小是batch_size x num_anchors

我們可以計算這次只選中了多少個錨框進入損失函數:

out[1].sum()/4
[ 14.]
<NDArray 1 @cpu(0)>

這里不太直觀,我們看看網絡中調用:

box_target, box_mask, cls_target = training_targets(
                anchors, class_preds, y)
# IN:
# anchors(1, 5444, 4): 1, 框子數, 坐標數 
# 各個框體原本坐標
# class_preds(32, 5444, 2):batch,框子數,類別數  cls_loss 
# 各個框體分類信息
# y(32, 3, 5):batch,對象數,對象信息(類別+坐標) 
# 真實標簽
# OUT:
# box_target(32, 21776):batch,框子數*坐標數  box_loss
# 每個坐標框相較於真實框的偏移,作為被學習標簽
# box_mask(32, 21776) :batch,框子數*坐標數  box_loss
# 每一個框每一個坐標是否保留(是1否0)
# cls_target(32, 5444):batch,框子數  cls_loss
# 每一個框對應的真實類別序號(背景0)

實際上anchors(即mxnet.contrib.ndarray.MultiBoxTarget於各個回歸層生成)是固定不變的,我們使用每一個框子anchors、該框對應的的預測值class_preds、真實框標簽得到:

  • 每一個框體坐標偏移,經過了閾值檢查的,默認overlap_threshold=0.5(值約小閾值越高)
  • 這些框體的掩碼(就是上面向量非零值替換為1,預測基本不會沒有偏差)
  • 每一個框子對應的類別,和上面非0輸出數目保持一致

非極大值抑制:mxnet.contrib.ndarray.MultiBoxDetection

因為我們對每個像素都會生成數個錨框,這樣我們可能會預測出大量相似的表框,從而導致結果非常嘈雜。一個辦法是對於IoU比較高的兩個表框,我們只保留預測執行度比較高的那個。這個算法(稱之為non maximum suppression)在MultiBoxDetection里實現,

from mxnet.contrib.ndarray import MultiBoxDetection

def predict(x):
    anchors, cls_preds, box_preds = net(x.as_in_context(ctx))
    # anchors.shape, class_preds.shape, box_preds.shape
    # (1, 5444, 4)   (32, 5444, 2)      (32, 21776) box_loss
    cls_probs = nd.SoftmaxActivation(
        cls_preds.transpose((0,2,1)), mode='channel')

    return MultiBoxDetection(cls_probs, box_preds, anchors,
                             force_suppress=True, clip=False)

可以看到,函數接收各個框體分類信息,各個框體回歸(修正)信息,各個框體原本坐標

對應的它輸出所有邊框,每個邊框由[class_id, confidence, xmin, ymin, xmax, ymax]表示。其中class_id=-1表示要么這個邊框被預測只含有背景,或者被去重掉了:

x, im = process_image('../img/pikachu.jpg')
out = predict(x)
out.shape

 (1, 5444, 6)

 三、網絡主干

def class_predictor(num_anchors, num_classes):
    """return a layer to predict classes"""
    # 輸入輸出大小相同,輸出的不同通道對應(不同框)的(不同類別)的得分
    # 輸出圖片每一個像素點上通道數:框體數目×(類別數 + 1,背景)
    return nn.Conv2D(num_anchors * (num_classes + 1), 3, padding=1)

def box_predictor(num_anchors):
    """return a layer to predict delta locations"""
    return nn.Conv2D(num_anchors * 4, 3, padding=1)

def down_sample(num_filters):
    """
    定義一個卷積塊,它將輸入特征的長寬減半,以此來獲取多尺度的預測。它由兩個Conv-BatchNorm-Relu
    組成,我們使用填充為1的3×33×3卷積使得輸入和輸入有同樣的長寬,然后再通過跨度為2的最大池化層將長    
    寬減半。
    """
    out = nn.HybridSequential()
    for _ in range(2):
        out.add(nn.Conv2D(num_filters, 3, strides=1, padding=1))
        out.add(nn.BatchNorm(in_channels=num_filters))
        out.add(nn.Activation('relu'))
    out.add(nn.MaxPool2D(2))
    return out

def flatten_prediction(pred):
    # 圖片數,像素數×框數×分類數:值為得分
    return pred.transpose(axes=(0,2,3,1)).flatten()

def concat_predictions(preds):
    # 圖片數,(全部層的)像素數×框數×分類數:值為得分
    return nd.concat(*preds, dim=1)

def body():
    """
    主體網絡用來從原始像素抽取特征。通常前面介紹的用來圖片分類的卷積神經網絡,例如ResNet,
    都可以用來作為主體網絡。這里為了示范,我們簡單疊加幾個減半模塊作為主體網絡。
    """
    out = nn.HybridSequential()
    for nfilters in [16, 32, 64]:
        out.add(down_sample(nfilters))
    return out

def toy_ssd_model(num_anchors, num_classes):
    """
    創建一個玩具SSD模型了。我們稱之為玩具是因為這個網絡不管是層數還是錨框個數都比較小,
    僅僅適合之后我們之后使用的一個小數據集。但這個模型不會影響我們介紹SSD。
    這個網絡包含四塊。主體網絡,三個減半模塊,以及五個物體類別和邊框預測模塊。其中預測分
    別應用在在主體網絡輸出,減半模塊輸出,和最后的全局池化層上。
    """
    # 含三個減半模塊
    downsamplers = nn.Sequential()
    for _ in range(3): 
        downsamplers.add(down_sample(128))
    # 含五個分類預測模塊
    class_predictors = nn.Sequential()
    # 含五個邊框回歸模塊
    box_predictors = nn.Sequential()    
    for _ in range(5):
        class_predictors.add(class_predictor(num_anchors, num_classes))
        box_predictors.add(box_predictor(num_anchors))
    
    # 主體網絡 + 減半 + 分類 + 回歸
    model = nn.Sequential()
    model.add(body(), downsamplers, class_predictors, box_predictors)
    return model

def toy_ssd_forward(x, model, sizes, ratios, verbose=False):
    """
    給定模型和每層預測輸出使用的錨框大小和形狀,我們可以定義前向函數
    """
    body, downsamplers, class_predictors, box_predictors = model
    anchors, class_preds, box_preds = [], [], []
    # feature extraction
    # 流過body主體網絡
    x = body(x)
    # 循環式分類回歸網絡
    for i in range(5):
        # 逐像素生成第i型網絡
        anchors.append(MultiBoxPrior(
            x, sizes=sizes[i], ratios=ratios[i]))
        # 逐像素分類i型網絡,結果拉伸后收集
        class_preds.append(
            flatten_prediction(class_predictors[i](x)))
        # 逐像素回歸i型網絡,結果拉伸后收集
        box_preds.append(
            flatten_prediction(box_predictors[i](x)))
        # 狀態報告
        if verbose:
            print('Predict scale', i, x.shape, 'with', 
                  anchors[-1].shape[1], 'anchors')
        # 下采樣
        if i < 3:
            x = downsamplers[i](x)
        elif i == 3:
            x = nd.Pooling(
                x, global_pool=True, pool_type='max', 
                kernel=(x.shape[2], x.shape[3]))
    # concat data
    # 圖片數目,后續長向量
    return (concat_predictions(anchors),
            concat_predictions(class_preds),
            concat_predictions(box_preds))

from mxnet import gluon
# 完整的模型
class ToySSD(gluon.Block):
    def __init__(self, num_classes, verbose=False, **kwargs):
        super(ToySSD, self).__init__(**kwargs)
        # anchor box sizes and ratios for 5 feature scales
        self.sizes = [[.2,.272], [.37,.447], [.54,.619], 
                      [.71,.79], [.88,.961]]
        self.ratios = [[1,2,.5]]*5
        self.num_classes = num_classes
        self.verbose = verbose
        num_anchors = len(self.sizes[0]) + len(self.ratios[0]) - 1
        # use name_scope to guard the names
        with self.name_scope():
            self.model = toy_ssd_model(num_anchors, num_classes)

    def forward(self, x):
        anchors, class_preds, box_preds = toy_ssd_forward(
            x, self.model, self.sizes, self.ratios, 
            verbose=self.verbose)
        # it is better to have class predictions reshaped for softmax computation
        # 圖片數,像素數×類別數×框數 -> 圖片數,像素數×框數(總框數),類別數
        class_preds = class_preds.reshape(shape=(0, -1, self.num_classes+1))
        return anchors, class_preds, box_preds

訓練邏輯並不復雜,理解了前兩個函數就知道了大概,不過特別說明,我們會生成很多框體,回歸層輸出的4個值實際上就是對於框體修正值的預測。其他詳見github上的全流程說明。

 


免責聲明!

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



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