一、輔助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上的全流程說明。
