影/一文搞懂 YOLO 演算法!一種簡易快捷的目標檢測演算法

影/一文搞懂 YOLO 演算法!一種簡易快捷的目標檢測演算法
▲一文搞懂 YOLO 演算法。(圖/翻攝自雷鋒網)

【原文:《YOLO,一種簡易快捷的目標檢測演算法》,作者:孔令雙,智慧機器人網編輯整理】

文、圖/雷鋒網

YOLO全稱You Only Look Once,是一個十分容易構造目標檢測演算法,出自於CVPR2016關於目標檢測的方向的一篇優秀論文(https://arxiv.org/abs/1506.02640 ),本文會對YOLO的思路進行總結並給出關鍵代碼的分析,在介紹YOLO前,不妨先看看其所在的領域的發展歷程。

目標檢測

相對於傳統的分類問題,目標檢測顯然更符合現實需求,因為往往現實中不可能在某一個場景只有一個物體(業務需求也很少會只要求分辨這是什麼),但也因此目標檢測的需求變得更為複雜,不僅僅要求detector能夠檢驗出是什麼物體,還的確定這個物體在圖片哪裡。

總的來說,目標檢測先是經歷了最為簡單而又暴力的歷程,這個過程高度的符合人類的直覺。簡單點來說,既然要我要識別出目標在哪裡,那我就將圖片劃分成一個個一個個小圖片扔進detector,但detecror認為某樣物體在這個小區域 上了,OK,那我們就認為這個物體在這個小圖片上了。而這個思路,正是比較早期的目標檢測思路,比如R-CNN。

然後來的Fast R-CNN,Faster R-CNN雖有改進,比如不再是將圖片一塊塊的傳進CNN提取特徵,而是整體放進CNN提取除 featuremap 然後再做進一步處理,但依舊是整體流程分為區域提取 和 目標分類 兩部分(two-stage),這樣做的一個特點是雖然精度是保證了,但速度上不去,於是以YOLO為主要代表的這種一步到位(one-stage)即 End To End 的目標演算法應運而生了。

YOLO詳解

細心的讀者可能已經發現,是的,YOLO的名字You only look once正是自身特點的高度概括。

YOLO的核心思想在於將目標檢測作為回歸問題解決 ,YOLO首先將圖片劃分成SxS個區域,注意這個區域的概念不同於上文提及將圖片劃分成N個區域扔進detector這裡的區域不同。上文提及的區域是真的將圖片進行剪裁,或者說把圖片的某個局部的像素扔進detector,而這裡的劃分區域,只的是邏輯上的劃分。

為什麼是邏輯上的劃分呢?這體現再YOLO最後一層全連接層上,也就是YOLO針對每一幅圖片做出的預測。

其預測的向量是SxSx(B*5+C)長度的向量。其中S是劃分的格子數,一般S=7,B是每個格子預測的邊框數 ,一般B=2,C是跟你實際問題相關的類別數,但要注意的是這裡你應該背景當作一個類別考慮進去。

不難得出,這個預測向量包括:

  • SxSxC 個類別信息,表示每一個格子可能屬於什麼類別

  • SxSxB 個置信度,表示每一個格子的B個框的置信度,再YOLO進行預測后,一般只保留置信度為0.5以上的框。當然這個閾值也可以人工調整。

  • SxSxBx4 個位置信息,4個位置信息分別是xywh,其中xy為box的中心點。

說完YOLO的總體思路后,我們在看看YOLO的網路結構

該網路結構包括 24 個卷積層,最後接 2 個全連接層。文章設計的網路借鑒 GoogleNet 的思想,在每個 1x1 的 歸約層(Reduction layer,1x1的卷積 )之後再接一個 3∗3 的卷積層的結構替代 Inception結構。論文中還提到了 fast 版本的 Yolo,只有 9 個卷積層,其他則保持一致。

因為最後使用了全連接層,預測圖片要和train的圖片大小一致,而其他one-stage的演算法,比如SSD,或者YOLO-V2則沒有這個問題,但這個不在本文討論範圍內。

其實網路架構總體保持一致即可,個人不建議照抄全部參數,還是需要根據你的實際任務或計算資源進行魔改,所以接下來重點會講述訓練的過程和損失函數的構建,其中也會給出MXNET版本的代碼進行解釋。文末會給出全部代碼的開源地址。

損失函數的定義

圖片來源於網路

大體來說,損失函數分別由:

  • 預測框位置的誤差 (1)(2)

  • IOU誤差(3)(4)

  • 類別誤差(5)

其中,每一個組成部分對整體的貢獻度的誤差是不同的,需要乘上一個權重進行調和。相對來說,目標檢測的任務其實更在意位置誤差,故位置誤差的權重一般為5。在此,讀者可能費解,為什麼框的寬和高取的是根號,而非直接計算?

想要了解這個問題,我們不妨來看看的圖像

這裡額外多說一句,如果有打數據挖掘比賽經驗的同學,可能會比較清楚一種數據處理的手段,當某些時候,會對某一特徵進行數據變換,比如和,取 log 這些變換有一個特徵,就是數值越大,懲罰越大(變換的幅度越大,比如4和4的平方,10到10的平方)。

而在損失函數中應用這一方法,起到的作用則是使得小框產生的誤差比大框的誤差更為敏感,其目的是為了解決對小物體的檢測問題。但事實上,這樣的設定只能緩解卻沒有最終解決這個問題。

在說IOU誤差,IOU的定義為實際描框和預測框之間的交集除以兩者之間的並集。

聽上去很複雜,實際我們既然能獲得預測框坐標,只要通過簡單的換算,該比值實際能轉換成面積的計算。而同樣的,這裡也有一個問題,我們感興趣的物體,對於整體圖片來說,畢竟屬於小數。換言之,就是在SxS個格子裡面,預測出來的框大多是無效的框,這些無效框的誤差積累是會對損失函數產生影響,換句話說,我們只希望有物體的預測框有多准,而不在乎沒有物體的框預測得有多差。因此,我們也需要對這些無效框的在損失函數上得貢獻乘上一個權重,進行調整。

也就是λnoobj,該值一般取0.5。

關於分類誤差,論文雖然是採用mse來衡量,但是否採用交叉熵來衡量更合理呢?對於分類問題,採用mse和交叉熵來衡量,又會產生什麼問題?這個問題留給讀者思考。

代碼實現

說完了損失函數,下面來講述如何使用MXNET來實現YOLO,同理的,YOLO的網路結構較為簡單,你可以採用任何的框架搭出,如果像我一樣只是為了演示demo,對網路結構可以修改一下,採取網路拓撲上比較簡單的模型。

同樣的,目標檢測常使用在ImageNet上預訓練(pretrain)的模型 作為特徵抽取器,同樣,因為這裡只是演示demo,同樣也省略這一部分,只是重點講損失函數的構造。

首先,雖然損失函數雖然是邏輯上分成三個部分,但我們不打算分開三個部分計算。

而是將整體式子拆分成 W x loss來計算,這樣在代碼上,實現起來要方便得多,以下時loss函數的計算過程:

def hybrid_forward(self, F, ypre, label):

label_pre, preds_pre, location_pre =self._split_y(ypre)

label_real, preds_real, location_real=self._split_y(label)

batch_size =len(label_real)

loss =nd.square(ypre - label)

class_weight=nd.ones(

shape =(batch_size, self.s*self.s*self.c))*self._scale_class_prob

location_weight = nd.ones(shape = (batch_size, self.s *self.s *self.b, 4))

confs =self._calculate_preds_loss(preds_pre,preds_real, location_pre, location_real)

preds_weight =self._scale_noobject_conf * (1. - confs) +self._scale_object_conf * confs

location_weight = (nd.expand_dims(preds_weight, axis=2) * location_weight) *self._scale_coordinate

location_weight = nd.reshape(location_weight, (-1, self.s *self.s *self.b *4))

W =nd.concat(*[class_weight, preds_weight, location_weight], dim=1)

total_loss = nd.sum(loss * W, 1)

return total_loss

可能會有童鞋好奇,為什麼坐標誤差是用預測值和label直接mse算呢,w和h不是應該要開根號嗎?是的,但我們為了數值穩定,在人工構建label時就已經將wh以開根后的形式存儲好了,這是因為,神經網路的輸出在初始時,正負值時隨機的,儘管在數學上的結果是虛數i,但在DL相關的框架,該操作會直接造成nan,造成損失函數無法優化,而且相應代碼的書寫更為複雜。而採取直接以取根號后的形式我們只要在獲取輸出時,再將wh求一個平方即可。

另外要說的一點就是IOU誤差,雖然很多文章都將這一點直接成為IOU誤差,實際上計算時IOU誤差和置信度的結合。

def_iou(self, box, box_label):

wh = box[:, :, :, 2:4]

wh = nd.power(wh, 2)

center = box[:, :, :, 0:1]

predict_areas = wh[:, :, :, 0] * wh[:, :, :, 1]

predict_bottom_right = center +0.5* wh

predict_top_left =center -0.5* wh

wh = box_label[:, :, :, 2:4]

wh = nd.power(wh, 2)

center = box_label[:, :, :, 0:1]

label_areas = wh[:, :, :, 0] * wh[:, :, :, 1]

label_bottom_right = center +0.5* wh

label_top_left = center -0.5* wh

temp = nd.concat(*[predict_top_left[:, :, :, 0:1], label_top_left[:, :, :, 0:1]], dim=3)

temp_max1 = nd.max(temp, axis=3)

temp_max1 = nd.expand_dims(temp_max1, axis=3)

temp = nd.concat(*[predict_top_left[:, :, :, 1:], label_top_left[:, :, :, 1:]], dim=3)

temp_max2 = nd.max(temp, axis=3)

temp_max2 = nd.expand_dims(temp_max2, axis=3)

intersect_top_left = nd.concat(*[temp_max1, temp_max2], dim=3)

temp = nd.concat(*[predict_bottom_right[:, :, :, 0:1], label_bottom_right[:, :, :, 0:1]], dim=3)

temp_min1 = nd.min(temp, axis=3)

temp_min1 = nd.expand_dims(temp_min1, axis=3)

temp = nd.concat(*[predict_bottom_right[:, :, :, 1:], label_bottom_right[:, :, :, 1:]], dim=3)

temp_min2 = nd.min(temp, axis=3)

temp_min2 = nd.expand_dims(temp_min2, axis=3)

intersect_bottom_right = nd.concat(*[temp_min1, temp_min2], dim=3)

intersect_wh = intersect_bottom_right - intersect_top_left

intersect_wh = nd.relu(intersect_wh) # 把0過濾了

intersect = intersect_wh[:, :, :, 0] * intersect_wh[:, :, :, 1]

ious = intersect / (predict_areas + label_areas - intersect)

max_iou = nd.expand_dims(nd.max(ious,2),axis=2)

best_ = nd.equal(max_iou,ious)

best_boat = nd.ones(shape = ious.shape)

for batch in range(len(best_)):

best_box[batch] = best_[batch]

return nd.reshape(best_box, shape=(-1, self.s*self.s*self.b))

def_calculate_preds_loss(self, ypre, label, local_pre, local_label):

ious =self._iou(local_pre, local_label)

conf = label * ious

returnconf

置信度在label的表現形式時,這個地方有目標物體則為1,沒有則是0,這樣用mse優化后,輸出值會在0~1附近,正好可以代表某個框是否框中物體的置信度。

但為什麼不直接 對置信度用mse呢,這同樣是一個權重的調節的問題,但這裡不能說我們就不care那些沒有物體的框的值了,因為這裡的值是置信度,如果我們任由其發展,萬一沒有物體的框的置信度比有框中物體的置信度還要高,那我們使用閾值過濾時,就可能出現問題了。只能說我們希望loss中更重視有框中物體的框的誤差。

這裡補充另外一個知識點最大值抑制 ,簡單點來說,既然每個格子會生成B個框(一般B>1)這樣就有可能同時兩個框都框中了物體,那麼到底採用那個框作為預測結果呢?答案是採用IOU值高的那個框,而IOU值小的,就會不被重視而受到抑制。

ious = intersect / (predict_areas + label_areas -intersect)

max_iou = nd.expand_dims(nd.max(ious,2),axis=2)

best_ = nd.equal(max_iou,ious)

best_boat =nd.ones(shape = ious.shape)

for batch in range(len(best_)):

best_box[batch] = best_[batch]

return nd.reshape(best_box, shape=(-1, self.s*self.s*self.b))

在求出IOU后,我們求出每一個格子的框中的最大值,再使用equal操作,使得最大值為1,其餘值為0再參與後面的運算即可。

代碼地址

DEMO-github:

代碼使用的是李沐公開課的皮卡丘數據集,用MXNET的Gluon介面實現,enjoy it!

運行效果:

留言

延伸閱讀