本文分析的目標檢測網(wǎng)絡(luò)的源碼都是基于Keras, Tensorflow。最近看了李沐大神的新作《動手學(xué)深度學(xué)習(xí)》,感覺MxNet框架用起來很討喜,Github上也有YOLOV3,SSD,F(xiàn)aster RCNN,RetinaNet,Mask RCNN這5種網(wǎng)絡(luò)的MxNet版源碼,不過考慮到Tensorflow框架的普及,還是基于Keras來分析上述5種目標檢測網(wǎng)絡(luò)的代碼實現(xiàn)。
Necessary Prerequisite
1. 準確率判斷
分對的正反例樣本數(shù) / 樣本總數(shù)
用于評估模型的全局準確程度,因為包含的信息有限,一般不用于評估模型的性能
2. 精確率與召回率

一些相關(guān)的定義。假設(shè)現(xiàn)在有這樣一個測試集,測試集中的圖片只由大雁和飛機兩種圖片組成,假設(shè)你的分類系統(tǒng)最終的目的是:能取出測試集中所有飛機的圖片,而不是大雁的圖片。
True positives : 正樣本被正確識別為正樣本,飛機的圖片被正確的識別成了飛機。
True negatives: 負樣本被正確識別為負樣本,大雁的圖片沒有被識別出來,系統(tǒng)正確地認為它們是大雁。
False positives: 假的正樣本,即負樣本被錯誤識別為正樣本,大雁的圖片被錯誤地識別成了飛機。
False negatives: 假的負樣本,即正樣本被錯誤識別為負樣本,飛機的圖片沒有被識別出來,系統(tǒng)錯誤地認為它們是大雁。
Precision其實就是在識別出來的圖片中,True positives所占的比率。也就是本假設(shè)中,所有被識別出來的飛機中,真正的飛機所占的比例。
Recall 是測試集中所有正樣本樣例中,被正確識別為正樣本的比例。也就是本假設(shè)中,被正確識別出來的飛機個數(shù)與測試集中所有真實飛機的個數(shù)的比值。
Precision-recall 曲線:改變識別閾值,使得系統(tǒng)依次能夠識別前K張圖片,閾值的變化同時會導(dǎo)致Precision與Recall值發(fā)生變化,從而得到曲線。
如果一個分類器的性能比較好,那么它應(yīng)該有如下的表現(xiàn):在Recall值增長的同時,Precision的值保持在一個很高的水平。而性能比較差的分類器可能會損失很多Precision值才能換來Recall值的提高。通常情況下,文章中都會使用Precision-recall曲線,來顯示出分類器在Precision與Recall之間的權(quán)衡。
以下面的pr圖為例,我們可以看到PR曲線C是包含于A和B,那么我們可以認為A和B的性能是優(yōu)于C。

3. 平均精度AP 與 多類別平均精度mAP
AP就是Precision-recall 曲線下面的面積,通常來說一個越好的分類器,AP值越高。
mAP是多個類別AP的平均值。這個mean的意思是對每個類的AP再求平均,得到的就是mAP的值,mAP的大小一定在[0,1]區(qū)間,越大越好。該指標是目標檢測算法中最重要的一個。
4. IoU
IoU這一值,可以理解為系統(tǒng)預(yù)測出來的框與原來圖片中標記的框的重合程度。 計算方法即檢測結(jié)果Detection Result與 Ground Truth 的交集比上它們的并集,即為檢測的準確率。
IoU正是表達這種bounding box和groundtruth的差異的指標:

5. 非極大值抑制(NMS)
Non-Maximum Suppression就是需要根據(jù)score矩陣和region的坐標信息,從中找到置信度比較高的bounding box。對于有重疊在一起的預(yù)測框,只保留得分最高的那個。
(1)NMS計算出每一個bounding box的面積,然后根據(jù)score進行排序,把score最大的bounding box作為隊列中首個要比較的對象。
(2)計算其余bounding box與當前最大score與box的IoU,去除IoU大于設(shè)定的閾值的bounding box,保留小的IoU得預(yù)測框。
(3)然后重復(fù)上面的過程,直至候選bounding box為空。
最終,檢測了bounding box的過程中有兩個閾值,一個就是IoU,另一個是在過程之后,從候選的bounding box中剔除score小于閾值的bounding box。需要注意的是:Non-Maximum Suppression一次處理一個類別,如果有N個類別,Non-Maximum Suppression就需要執(zhí)行N次。
6. 卷積神經(jīng)網(wǎng)絡(luò)
卷積神經(jīng)網(wǎng)絡(luò)仿造生物的視知覺(visual perception)機制構(gòu)建,可以進行監(jiān)督學(xué)習(xí)非監(jiān)督學(xué)習(xí),其隱含層內(nèi)的卷積核參數(shù)共享和層間連接的稀疏性使得卷積神經(jīng)網(wǎng)絡(luò)能夠以較小的計算量對格點化(grid-like topology)特征,例如像素和音頻進行學(xué)習(xí)、有穩(wěn)定的效果且對數(shù)據(jù)沒有額外的特征工程(feature engineering)要求。
關(guān)于這塊我打算在下一篇文章給大家做一個詳細的介紹,從最初用于手寫字符識別的LeNet到歷屆ImageNet中那些奪魁的網(wǎng)絡(luò)設(shè)計AlexNet、Vgg、NIN、GoogleNet、ResNet、DenseNet。并給出相應(yīng)的代碼實現(xiàn)和訓(xùn)練結(jié)果展示。
One Stage & Two Stage
目標檢測模型目的是自動定位出圖像中的各類物體,不僅可以給出物體的類別判定,也可以給出物體的定位。目前主流的研究分為兩類:One Stage 和 Two stage, 前者是圖像經(jīng)過網(wǎng)絡(luò)的計算圖,直接預(yù)測出圖中物體的類別和位置;后者則先提取出物體的候選位置(Region Proposal),然后再對物體進行分類,當然這個時候一般也會對篩選出來的目標做一次定位的精修,達到更加準確的目的。
YOLOV3,SSD,RetinaNet都屬于one stage類型的網(wǎng)絡(luò),這類網(wǎng)絡(luò)的特點是訓(xùn)練和識別速度快,但是精度欠佳。
Faster RCNN和Mask RCNN屬于two stage類型的網(wǎng)絡(luò),相比于one stage,識別精度上有所提升,但是訓(xùn)練和識別速度比不上one stage類型的網(wǎng)絡(luò)。
之前用draw.io畫過框架圖,實在是丑,畫出來的圖感覺并不能清晰的表達整個框架的意圖,為了讓讀者可以看得爽一點,下面的框架我都是從網(wǎng)上搞來的一些高清好圖。
YOLOV3
這張圖選自CSDN博主木盞yolo系列之yolo v3【深度解析】

DBL: 卷積層conv + 批標準化層BN + Leaky Relu
res(n): n代表這個res_block內(nèi)含有多少個res_unit,這點借鑒了ResNet的殘差結(jié)構(gòu),使用這種結(jié)構(gòu)的目的是為了加深網(wǎng)絡(luò)深度
concat: 將DarkNet中的某一層與之前的某層的上采樣()
流程如下:
調(diào)整輸入圖像的大小為416 × 416(32的倍數(shù))
圖像向前傳播的過程中經(jīng)過一個1個DBL層和5個res_block,每經(jīng)過一個res_block,圖像的size都要減半,此時圖像的size為416 / 32(2的5次方) = 13 * 13
-
下圖是一張DarkNet-53的結(jié)構(gòu)圖,然而YOLOV3經(jīng)過前面的res_block后不是繼續(xù)采用接下來的Avgpool平均池化層,Connected,全連接層,而是繼續(xù)經(jīng)過5個DBL層。
image 接下來有兩步操作:
(1)、經(jīng)過一個 DBL層和卷積層conv得到輸出y1(13 * 13 * 255),這里的255是9 / 3 * (4 + 1 + 80)。對這幾個數(shù)字的說明如下:
?9是anchors的數(shù)量,這里的anchor的數(shù)量是通過聚類得到的
?除以3是因為最終的輸出的特征圖有3個scale(13,26,52),13 * 13對應(yīng)的是9個anchors里top3大的錨框
?4代表的每個錨框中心的橫坐標x,縱坐標y,寬度w,高度h
?1和80分別表示背景和80目標種類的概率
(2)、 通過一個DBL和一個上采樣層和res_block4的輸出連接起來,然后經(jīng)過5個DBL層步驟4-2的結(jié)果也有兩步操作
(1)、經(jīng)過一個 DBL層和卷積層conv得到輸出y2(26 * 26 * 255),26是因為res_block4的輸出特征圖大小為26,而步驟4-1的輸入經(jīng)過上采樣的操作后特征圖大小也從13變成了26
(2)、 通過一個DBL和一個上采樣層和res_block3的輸出連接起來,然后經(jīng)過5個DBL層將步驟5-2的結(jié)果經(jīng)過一個DBL層和一個上采樣層與res_block3的輸出連接起來,再經(jīng)過6(5+1)個DBL層和一個卷積層conv得到y(tǒng)3(52 * 52 * 255)
loss
使用YOLO做預(yù)測,結(jié)果會給出圖像中物體的中心點坐標(x,y),目標是否是一個物體的置信度C以及物體的類別,比如說person,car,ball等等。圖像經(jīng)過之前的計算圖前向傳播得到3個scale的輸出y1(13),y2(26),y3(52),用yolo_outputs代表這3個變量。將原始圖片(416 * 416)分別除以32,16,8得到與y1,y2,y3大小匹配的ground_truth,在源碼中用y_true表示。
計算損失的時候需要把預(yù)測出來的結(jié)果與ground truth box之間的差距表現(xiàn)出來,下面是YOLOV1的loss function:

1. 坐標誤差
λcoord 在 YOLO v1 中默認為5,因為目標的定位是最重要的部分,所以給定位損失一個比較高的權(quán)重。但是我在看代碼的時候發(fā)現(xiàn)這個值變成了 2 - w * h(w, h 都歸一化到[0,1]),應(yīng)該是降低了一些權(quán)重,同時將物體的大小考慮進去,從公式中可以發(fā)現(xiàn)小的物體擁有更高的權(quán)重,因為對于小物體,幾個像素的誤差帶來的影響是高于大的物體。
對于中心點坐標的(x,y)的計算也從MSE均方差誤差變成了binary_crossentropy二分類交叉熵,為啥變成這個我覺得有點玄學(xué)在里面,反正對于坐標的損失計算我認為MSE是沒問題的。
計算寬高的誤差之前先看下下面這張圖:

網(wǎng)絡(luò)預(yù)測出來的中心點坐標和寬高分別為tx,ty,tw,th,通過計算得到邊框的中心坐標bx,by,和邊框的寬bw,高bh。cx,cy是位移偏差offset,σ()函數(shù)為logistic函數(shù),將坐標歸一化到[0,1]。最終得到的bx,by為歸一化后的相對于grid cell的值。pw,ph為anchor的寬,高。實際在使用中,作者為了將bw,bh也歸一化到[0,1],實際程序中的 pw,ph為anchor的寬,高和featuremap的寬,高的比值。最終得到的pw,ph為歸一化后相對于anchor的值。
raw_true_wh = K.log(y_true[l][..., 2:4] / anchors[anchor_mask[l]] * input_shape[::-1])
raw_true_wh = K.switch(object_mask, raw_true_wh, K.zeros_like(raw_true_wh)) # avoid log(0)=-inf
......此處省略中間的一些代碼,直接看w和h的誤差計算
wh_loss = object_mask * box_loss_scale * 0.5 * K.square(raw_true_wh-raw_pred[...,2:4])
V3跟V1對于寬高的損失計算也有些區(qū)別,V1是(sqrt(w) - sqrt(w'))2;V3是(log(w) - log(w')))2,不過效果是一樣的,都是提高對于小目標的預(yù)測敏感度。舉個簡單的例子,同樣是10個像素的誤差,一個大的目標真實的寬為100,預(yù)測出來為110;而一個小的目標真實寬度為10,預(yù)測出來是20,讓我們來通過這個公式計算一下誤差:
0.5 * (log(110) - log(100))2 = 0.00085667719
0.5 * (log(20) - log(10))2 = 0.04530952914
可以看出對于小的物體,對于同樣像素大小的誤差,懲罰比較大
2. IOU誤差
對于有邊界框的物體,計算出置信度和1之間的差值;對于背景,我們需要計算出置信度與0之間的差值,當然距離計算公式還是用二分類交叉熵。λnoobj在源碼中沒有找到這個參數(shù),V1是設(shè)置來減少正反例分布不均勻帶來的誤差的,作者為什么要這么做,我百度谷歌了半天沒找到原因。我的猜測是對于這種分布不均衡問題我們沒有必要去干預(yù)它,順其自然就好。
3. 分類誤差
這個就比較直觀了
class_loss = object_mask * K.binary_crossentropy(true_class_probs, raw_pred[...,5:], from_logits=True)
detect
借助Opencv,keras-yolov3可以實現(xiàn)影像的目標檢測:

當然也可以進行圖片的目標檢測:

檢測代碼可以見yolo_video.py,其中function detect_video是調(diào)用了Opencv對影像處理的接口,然后復(fù)用了接口detect_image。對于目標檢測的流程可以總結(jié)為以下幾個步驟:
1. 初始化
self.__dict__.update(self._defaults) # set up default values
self.__dict__.update(kwargs) # and update with user overrides
self.class_names = self._get_class()
self.anchors = self._get_anchors()
self.sess = K.get_session()
self.boxes, self.scores, self.classes = self.generate()
載入分類的類名('car','house','people'......)
載入聚類算法計算得到的9個錨框
初始化tensorflow計算圖session
載入訓(xùn)練好的Model
def generate(self):
model_path = os.path.expanduser(self.model_path)
assert model_path.endswith('.h5'), 'Keras model or weights must be a .h5 file.'
# Load model, or construct model and load weights.
num_anchors = len(self.anchors)
num_classes = len(self.class_names)
is_tiny_version = num_anchors==6 # default setting
try:
self.yolo_model = load_model(model_path, compile=False)
except:
self.yolo_model = tiny_yolo_body(Input(shape=(None,None,3)), num_anchors//2, num_classes) \
if is_tiny_version else yolo_body(Input(shape=(None,None,3)), num_anchors//3, num_classes)
self.yolo_model.load_weights(self.model_path) # make sure model, anchors and classes match
else:
assert self.yolo_model.layers[-1].output_shape[-1] == \
num_anchors/len(self.yolo_model.output) * (num_classes + 5), \
'Mismatch between model and given anchor and class sizes'
print('{} model, anchors, and classes loaded.'.format(model_path))
定義網(wǎng)絡(luò)輸出的計算,輸出的shape為[(?,13,13,255),(?,26,26,255),(?,52,52,255)],?表示batch_size,如果你一次檢測一張圖片的話,這個數(shù)字為1。原則上只要GPU的內(nèi)存夠,你可以擴大你的batch_size。
self.input_image_shape = K.placeholder(shape=(2, ))
if self.gpu_num>=2:
self.yolo_model = multi_gpu_model(self.yolo_model, gpus=self.gpu_num)
boxes, scores, classes = yolo_eval(self.yolo_model.output, self.anchors,
len(self.class_names), self.input_image_shape,
score_threshold=self.score, iou_threshold=self.iou)
return boxes, scores, classes
接下來要得到正確的box坐標還有box_score(這個坐標是否包含物體的概率 * 分類的得分)
def yolo_correct_boxes(box_xy, box_wh, input_shape, image_shape):
'''Get corrected boxes'''
box_yx = box_xy[..., ::-1]
box_hw = box_wh[..., ::-1]
input_shape = K.cast(input_shape, K.dtype(box_yx))
image_shape = K.cast(image_shape, K.dtype(box_yx))
new_shape = K.round(image_shape * K.min(input_shape/image_shape))
offset = (input_shape-new_shape)/2./input_shape
scale = input_shape/new_shape
box_yx = (box_yx - offset) * scale
box_hw *= scale
box_mins = box_yx - (box_hw / 2.)
box_maxes = box_yx + (box_hw / 2.)
boxes = K.concatenate([
box_mins[..., 0:1], # y_min
box_mins[..., 1:2], # x_min
box_maxes[..., 0:1], # y_max
box_maxes[..., 1:2] # x_max
])
# Scale boxes back to original image shape.
boxes *= K.concatenate([image_shape, image_shape])
return boxes
def yolo_boxes_and_scores(feats, anchors, num_classes, input_shape, image_shape):
'''Process Conv layer output'''
box_xy, box_wh, box_confidence, box_class_probs = yolo_head(feats,
anchors, num_classes, input_shape)
boxes = yolo_correct_boxes(box_xy, box_wh, input_shape, image_shape)
boxes = K.reshape(boxes, [-1, 4])
box_scores = box_confidence * box_class_probs
box_scores = K.reshape(box_scores, [-1, num_classes])
return boxes, box_scores
def yolo_eval(yolo_outputs,
anchors,
num_classes,
image_shape,
max_boxes=20,
score_threshold=.6,
iou_threshold=.5):
"""Evaluate YOLO model on given input and return filtered boxes."""
num_layers = len(yolo_outputs)
anchor_mask = [[6,7,8], [3,4,5], [0,1,2]] if num_layers==3 else [[3,4,5], [1,2,3]] # default setting
input_shape = K.shape(yolo_outputs[0])[1:3] * 32
boxes = []
box_scores = []
for l in range(num_layers):
_boxes, _box_scores = yolo_boxes_and_scores(yolo_outputs[l],
anchors[anchor_mask[l]], num_classes, input_shape, image_shape)
boxes.append(_boxes)
box_scores.append(_box_scores)
boxes = K.concatenate(boxes, axis=0)
box_scores = K.concatenate(box_scores, axis=0)
這個時候,過濾掉那些得分低于score_threshold(0.6)的候選框
mask = box_scores >= score_threshold
max_boxes_tensor = K.constant(max_boxes, dtype='int32')
再調(diào)用NMS算法,將那些同一分類重合度過高的候選框給篩選掉
boxes_ = []
scores_ = []
classes_ = []
for c in range(num_classes):
# TODO: use keras backend instead of tf.
class_boxes = tf.boolean_mask(boxes, mask[:, c])
class_box_scores = tf.boolean_mask(box_scores[:, c], mask[:, c])
nms_index = tf.image.non_max_suppression(
class_boxes, class_box_scores, max_boxes_tensor, iou_threshold=iou_threshold)
class_boxes = K.gather(class_boxes, nms_index)
class_box_scores = K.gather(class_box_scores, nms_index)
classes = K.ones_like(class_box_scores, 'int32') * c
boxes_.append(class_boxes)
scores_.append(class_box_scores)
classes_.append(classes)
boxes_ = K.concatenate(boxes_, axis=0)
scores_ = K.concatenate(scores_, axis=0)
classes_ = K.concatenate(classes_, axis=0)
return boxes_, scores_, classes_
現(xiàn)在我們得到了目標框以及對應(yīng)的得分和分類
self.boxes, self.scores, self.classes = self.generate()
2. 圖片預(yù)處理
保持圖片的比例,其余部分用灰色填充
def letterbox_image(image, size):
'''resize image with unchanged aspect ratio using padding'''
iw, ih = image.size
w, h = size
scale = min(w/iw, h/ih)
nw = int(iw*scale)
nh = int(ih*scale)
image = image.resize((nw,nh), Image.BICUBIC)
new_image = Image.new('RGB', size, (128,128,128))
new_image.paste(image, ((w-nw)//2, (h-nh)//2))
return new_image
if self.model_image_size != (None, None):
assert self.model_image_size[0]%32 == 0, 'Multiples of 32 required'
assert self.model_image_size[1]%32 == 0, 'Multiples of 32 required'
boxed_image = letterbox_image(image, tuple(reversed(self.model_image_size)))
else:
new_image_size = (image.width - (image.width % 32),
image.height - (image.height % 32))
boxed_image = letterbox_image(image, new_image_size)
image_data = np.array(boxed_image, dtype='float32')
像素各通道值歸一化
image_data /= 255.
image_data = np.expand_dims(image_data, 0) # Add batch dimension.
3. 前向傳播
out_boxes, out_scores, out_classes = self.sess.run(
[self.boxes, self.scores, self.classes],
feed_dict={
self.yolo_model.input: image_data,
self.input_image_shape: [image.size[1], image.size[0]],
K.learning_phase(): 0
})
print('Found {} boxes for {}'.format(len(out_boxes), 'img'))
4. 展示
最后就是調(diào)用PIL的一些輔助接口將這些目標框和得分繪制在原始圖片上。
識別精度和速度


我覺得上面兩張圖已經(jīng)很能說明YOLOV3的性能,不僅可以保障較高的精度,在速度上更是遙遙領(lǐng)先。
接下來很快會給大家送上其余4類網(wǎng)絡(luò)源碼分析,希望大家可以關(guān)注一下小弟。
