一、輔助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)
可以看到標號的形狀是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+m−1個。其中第 i 個錨框使用
sizes[i]
和ratios[0]
如果 i≤nsizes[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, :, :]
Out[5]:
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的錨框。對於這類錨框有兩點要考慮的:
- 邊框預測的損失函數不應該包括負類錨框,因為它們並沒有對應的真實邊框
- 因為負類錨框數目可能遠多於其他,我們可以只保留其中的一些。而且是保留那些目前預測最不確信它是負類的,就是對類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
,分別是
- 預測的邊框跟真實邊框的偏移,大小是
batch_size x (num_anchors*4)
- 用來遮掩不需要的負類錨框的掩碼,大小跟上面一致
- 錨框的真實的標號,大小是
batch_size x num_anchors
我們可以計算這次只選中了多少個錨框進入損失函數:
out[1].sum()/4
這里不太直觀,我們看看網絡中調用:
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上的全流程說明。