使用 YOLO 進行實時目標檢測

SwiftGG翻譯組發表於2019-03-03

作者:Matthijs Hollemans,原文連結,原文日期:2018/03/28
譯者:陽仔;校對:numbbbbb小鐵匠Linus;定稿:Forelax

目標檢測是計算機視覺中的經典問題之一:

識別一幅影像中有哪些目標,以及它們在影像中的位置。

檢測是一個比分類更復雜的問題,因為分類也可以識別目標,但不能準確判斷目標在影像中的位置——並且分類不能適用於包含多個目標的影像。

使用 YOLO 進行實時目標檢測

YOLO 是一個實時有效的目標檢測神經網路。

在這篇文章中,我將闡述如何使用 Metal Performance Shaders 來將“簡化版” YOLOv2 執行在 iOS 裝置上。

在繼續閱讀之前,請先 觀看這個令人驚歎的 YOLOv2 介紹視訊

YOLO 的工作原理

你可以使用 VGGNetInception 這樣的分類器,通過一個小的滑動視窗對影像進行遍歷,從而形成一個目標檢測器。每一步遍歷執行一次分類器,來對當前視窗中的目標進行分類。使用這樣的滑動視窗,會對一幅影像輸出成百上千的檢測結果,但你只需要保留分類器最確定的那些結果。

這種方法是可行的,但很顯然會很慢,因為需要執行很多遍分類器。一種效率稍微高一點的方法是,首先判斷影像的哪一部分包含了有效的資訊——也就是候選區域 (region proposals)——然後僅僅在這些區域中執行分類器。這種方法比滑動視窗的方法能減少分類器的執行次數,但仍然很多。

YOLO 採用了一種完全不同的方法。它並不是將傳統的分類器改造成檢測器。YOLO 實際上只對影像進行一次操作(也就是它名字的由來:You Only Look Once),但是是以一種聰明的方式。

YOLO 將影像分成 13×13 的網格單元:

使用 YOLO 進行實時目標檢測

每一個網格單元負責預測 5 個檢測框。一個檢測框是包含一個目標的矩形區域。

YOLO 同時會給出一個置信度,描述了某個檢測框確實包含了某目標的確定程度。這個值和檢測框中是什麼目標毫無關係,只和檢測框的形狀大小匹配程度有關。

預測出的檢測框看起來和下圖類似(置信度越高,框越粗):

使用 YOLO 進行實時目標檢測

對每個檢測框,對應的網格單元還給出了一個分類的預測。這和分類器的工作相似:它給出一個全部可能的類別的概率分佈。我們使用的這個版本的 YOLO 是用 PASCAL VOC dataset 訓練的,可以檢測 20 種不同的類別,比如:

  • 自行車
  • 汽車
  • 等等

檢測框的置信度和分類預測最終被整合成一個最終得分,來告訴我們該檢測框內包含一個特定型別的目標的概率。例如,左邊這個又大又粗的黃色框告訴我們,85% 的概率這裡麵包含一隻狗:

使用 YOLO 進行實時目標檢測

因為整幅影像包含 13×13 = 169 個網格單元,且每個網格單元預測 5 個檢測框,我們最後總計能得到 845 個檢測框。實際上,這其中大多數的檢測框的置信度都很低,所以,我們只需要保留最終得分大於等於 30% 的檢測框就行了(你也可以根據需要的檢測準確度改變這個閾值)。

最終的檢測結果:

使用 YOLO 進行實時目標檢測

從 845 個檢測框中,我們只保留了這三個,因為它們給出的結果最好。儘管我們有 845 個檢測框,但它們都是同時得到的——神經網路只需要執行一次。這也是 YOLO 強大而快速的原因。

(以上圖片來自 pjreddie.com

神經網路

YOLO 的結構只是一個簡單的卷積神經網路:

Layer         kernel  stride  output shape
---------------------------------------------
Input                          (416, 416, 3)
Convolution    3×3      1      (416, 416, 16)
MaxPooling     2×2      2      (208, 208, 16)
Convolution    3×3      1      (208, 208, 32)
MaxPooling     2×2      2      (104, 104, 32)
Convolution    3×3      1      (104, 104, 64)
MaxPooling     2×2      2      (52, 52, 64)
Convolution    3×3      1      (52, 52, 128)
MaxPooling     2×2      2      (26, 26, 128)
Convolution    3×3      1      (26, 26, 256)
MaxPooling     2×2      2      (13, 13, 256)
Convolution    3×3      1      (13, 13, 512)
MaxPooling     2×2      1      (13, 13, 512)
Convolution    3×3      1      (13, 13, 1024)
Convolution    3×3      1      (13, 13, 1024)
Convolution    1×1      1      (13, 13, 125)
---------------------------------------------
複製程式碼

該神經網路具有典型的結構:一個 3×3 卷積核的卷積層,取樣視窗 2×2 的池化層。沒有花哨的東西。YOLO 中沒有全連線層。

注意:我們使用的“簡化版” YOLO 只有 9 個卷積層和 6 個池化層。完全版 YOLOv2 模型分層數是這個的三倍,並且會更復雜一些,但仍然是一個常規的卷積神經網路。

最後一個卷積層的卷積核為 1×1,是為了將引數降維至 13×13×125。其中的 13×13 看起來很熟悉:這就是影像被劃分成的網格單元的數量。

因此,每個網格單元有 125 個通道。這 125 個通道包含了檢測框的資料,以及分類預測的資料。為什麼是 125 呢?因為每個網格單元預測 5 個檢測框的結果,每個檢測框由 25 個資料元素來描述:

  • 檢測框矩形的 x,y,寬,高
  • 置信度
  • 在 20 個分類上的概率分佈

YOLO 的使用很簡單:輸入一幅影像(大小為 416×416 畫素),它執行一次卷積網路,輸出一個 13×13×125 的張量,描述網格單元以及檢測框。你需要做的只是計算每個檢測框的最終得分,並拋棄低於 30% 的那些。

小提示:要了解更多有關 YOLO 的工作原理,以及 YOLO 是如何訓練的,請 觀看對它的發明者之一的訪談。這個視訊介紹的是 YOLOv1,由於是老版本,結構稍有不同,但主體思想是相同的。很值得觀看!

轉換成 Metal

上文描述的是簡化版的 YOLO,也是我們將在 iOS 應用中使用的版本。完全版 YOLOv2 的神經網路有三倍的分層數,因為太大,所以在當前的 iPhone 裝置上不能快速執行。簡化版的 YOLO 使用更少的分層數,因此執行會更快,但準確度也稍差。

使用 YOLO 進行實時目標檢測

YOLO 使用 Darknet 編寫,這是 YOLO 作者自己編寫的深度學習框架。能下載到的都是 Darknet 格式。儘管 Darknet 是開源的,我也不想花很多時間去弄清它的工作原理。

幸運的是,有人 已經做了這件事,將 Darknet 模型轉換成了我所使用的深度學習工具 Keras。我所要做的,就是執行這個“YAD2K”指令碼,將 Darknet 轉換成 Keras 格式,然後用我自己寫的指令碼將 Keras 轉換成 Metal。

但是,有一點小麻煩。YOLO 在它的卷積層後,使用了一種叫做”批標準化“的規整化方法。

“批標準化”的思想是,當資料是乾淨的時候,神經網路能夠達到最好的工作效果。理想情況下,一個層級的輸入資料的平均值為 0,且方差較小。每個做過機器學習的人都應比較熟悉這一思想,因為我們經常使用一種稱為“特徵縮放”或“白化”的技術來處理我們的輸入資料,以達到這一目的。

批標準化對層間資料做了類似特徵縮放的處理。這種處理能防止資料在神經網路中傳遞時退化,從而有效提升神經網路的效能。

為了讓你直觀感受到批標準化的作用,以下是第一個卷積層分別在應用和未應用批標準化的情況下的輸出直方圖:

使用 YOLO 進行實時目標檢測

批標準化在訓練一個深度網路時很重要,但事實上,我們在進行推斷的時候可以不需要這一處理。不需要進行批標準化的計算,有助於使我們的應用執行更快。在任何時候,Metal 都沒有一個 MPSCNNBatchNormalization 層。

批標準化通常發生在卷積層之後,啟用函式(YOLO 中的 ReLU 函式)之前。卷積操作和批標準化操作都是對資料進行線性變換,所以我們可以將批標準化層的引數和卷積權重相結合。這稱為將批標準化層“摺疊”到卷積層。

長話短說,使用一些數學方法,我們可以省略批標準化層,但需要改變前序卷積層的權重。

快速闡述一下卷積層的計算過程:設 x 是輸入影像中的畫素,w 是卷積層權重,那麼,經過卷積層計算,輸出的每個畫素的值為:

out[j] = x[i]*w[0] + x[i+1]*w[1] + x[i+2]*w[2] + ... + x[i+k]*w[k] + b
複製程式碼

即輸入畫素矩陣和卷積核權重的點積,再加上偏差項 b

以下是對卷積層輸出進行批標準化處理的計算過程:

        gamma * (out[j] - mean)
bn[j] = ---------------------- + beta
            sqrt(variance)
複製程式碼

批標準化首先對每個畫素的輸出值減去平均值 mean,再除以標準差,乘以一個縮放係數 gamma,再加上一個偏移值 beta。這四個引數 —— meanvariancegammabeta —— 是在網路訓練過程中,批標準化層學習得到的。

為了省略批標準化,我們可以將這兩個公式進行稍微的整合,來為卷積層計算新的權重和偏差項:

           gamma * w
w_new = --------------
        sqrt(variance)

        gamma*(b - mean)
b_new = ---------------- + beta
         sqrt(variance)
複製程式碼

利用這些新的權重和偏差項對輸入 x 進行卷積操作,能夠得到與原來卷積層加上批標準化處理後同樣的結果。

現在,我們可以去掉批標準化層,只使用卷積層,但引數是經過調節的權重和偏差項 w_newb_new。我們對網路中所有的卷積層都重複這一過程。

注意:事實上,YOLO 中的卷積層沒有使用偏差項,因此上述公式中 b 為 0。但請注意,經過整合批標準化的引數後,卷積層就有了偏差項。

一旦我們將所有的批標準化層整合到了它們的前序卷積層,我們就可以將權重轉換到 Metal 了。簡單地將該陣列(Keras 中儲存的順序和 Metal 不同)進行轉置,再將其寫進 32 位浮點數的二進位制檔案。

如果你對這些操作感到好奇,你可以檢視轉換指令碼 yolo2metal.py 來獲得詳細資訊。為了驗證整合批標準化的效果,指令碼建立了一個不包含批標準化層,但使用了調節後的權重的模型,並將其與原始模型的預測結果進行比對。

iOS 應用

我理所當然地使用 Forge 來編寫我的 iOS 應用。?你可以在 YOLO 資料夾中找到原始碼。如果想嘗試一下的話,可以下載或者 clone Forge,在 Xcode 8.3 以上版本中開啟 Forge.xcworkspace,在 iPhone 6 以上裝置上執行 YOLO

最簡單的測試方法是將你的 iPhone 對準某個 YouTube 視訊

使用 YOLO 進行實時目標檢測

YOLO.swift 中有一些有趣的程式碼。首先,這裡建立了卷積網路:

let leaky = MPSCNNNeuronReLU(device: device, a: 0.1)

let input = Input()

let output = input
         --> Resize(width: 416, height: 416)
         --> Convolution(kernel: (3, 3), channels: 16, padding: true, activation: leaky, name: "conv1")
         --> MaxPooling(kernel: (2, 2), stride: (2, 2))
         --> Convolution(kernel: (3, 3), channels: 32, padding: true, activation: leaky, name: "conv2")
         --> MaxPooling(kernel: (2, 2), stride: (2, 2))
         --> ...and so on...
複製程式碼

攝像頭的輸入影像被調整為 416×416 畫素大小,接著被輸入到卷積層和池化層。這和其他卷積神經網路的操作是非常類似的。

真正有意思的是對輸出的操作。回想一下,我們的輸出是一個 13×13×125 的張量:影像中每個網格單元有 125 個通道。這 125 個數字包含了檢測框的資料,以及分類預測的資料。我們需要將這些資料通過某些方法進行整理。這些是通過 fetchResult() 實現的。

注意:fetchResult() 函式在 CPU 中執行,而非 GPU。這種實現方式比較簡單。有人說 GPU 的並行性會對巢狀迴圈的執行比較有利。也許我在將來會重新寫一個 GPU 的版本。

以下是 fetchResult() 的工作原理:

public func fetchResult(inflightIndex: Int) -> NeuralNetworkResult<Prediction> {
  let featuresImage = model.outputImage(inflightIndex: inflightIndex)
  let features = featuresImage.toFloatArray()
複製程式碼

卷積網路的輸出是一個 MPSImage 格式的資料。我們首先將其轉換成一個 Float 的陣列,即 features,以便處理。

fetchResult() 的主體部分是一個大的巢狀迴圈。它對所有的網格單元以及每個網格單元的 5 個預測結果進行遍歷:

  for cy in 0..<13 {
    for cx in 0..<13 {
      for b in 0..<5 {
         . . .
      }
    }
  }
複製程式碼

在每個迴圈中我們對網格單元 (cy, cx) 計算出其檢測框 b

首先,我們從 features 陣列中讀取出檢測框的 x,y,寬,高,以及置信度:

let channel = b*(numClasses + 5)
let tx = features[offset(channel, cx, cy)]
let ty = features[offset(channel + 1, cx, cy)]
let tw = features[offset(channel + 2, cx, cy)]
let th = features[offset(channel + 3, cx, cy)]
let tc = features[offset(channel + 4, cx, cy)]
複製程式碼

offset() 函式的作用是幫助在陣列中尋找讀取資料的合適位置。Metal 將其資料以每 4 個通道為一組進行儲存,這意味著這 125 個通道的資料並不是連續儲存的,而是分散的。(請查閱程式碼以獲得更詳盡的解釋)

我們仍然需要對這 5 個資料 txtytwthtc 進行處理,因為它們的格式有一點奇怪。如果你好奇這些公式是從哪裡來的,它們是在 這篇文章 中提出的(這是網路訓練的副產物)。

let x = (Float(cx) + Math.sigmoid(tx)) * 32
let y = (Float(cy) + Math.sigmoid(ty)) * 32

let w = exp(tw) * anchors[2*b    ] * 32
let h = exp(th) * anchors[2*b + 1] * 32

let confidence = Math.sigmoid(tc)
複製程式碼

現在,xy 代表在 416×416 大小的輸入影像中,檢測框的中心點的座標。wh 是檢測框的寬和高。tc 是檢測框的置信度,我們用 logistic sigmoid 函式將其轉換成百分制的形式。

我們現在有了檢測框,並且我們知道 YOLO 有多確信其中包含了某個目標。下一步,讓我們看一下分類預測的結果,看看 YOLO 認為檢測框中的目標是什麼物體:

var classes = [Float](repeating: 0, count: numClasses)
for c in 0..<numClasses {
  classes[c] = features[offset(channel + 5 + c, cx, cy)]
}
classes = Math.softmax(classes)

let (detectedClass, bestClassScore) = classes.argmax()
複製程式碼

回想一下,features 陣列中的 20 個通道的資料包含了該檢測框的分類檢測結果。我們將這些讀取到一個新的 classes 陣列中。像分類器的一般做法一樣,我們採用 softmax 函式來將陣列轉換成一個概率分佈。然後,我們挑選分數最高的分類作為結果。

現在,我們可以對檢測框計算最終得分了 —— 例如,“我有 85% 的確信度相信這個檢測框包含了一隻狗”。一共會有 845 個檢測框,而我們只想保留最終得分超過某一閾值的那些。

let confidenceInClass = bestClassScore * confidence
if confidenceInClass > 0.3 {
  let rect = CGRect(x: CGFloat(x - w/2), y: CGFloat(y - h/2),
                    width: CGFloat(w), height: CGFloat(h))

  let prediction = Prediction(classIndex: detectedClass,
                              score: confidenceInClass,
                              rect: rect)
  predictions.append(prediction)
}
複製程式碼

對所有網格單元重複上述程式碼。當迴圈結束後,我們就有了一個 predictions 陣列,一般來說,其中會包含 10 到 20 個預測結果。

我們已經過濾掉了那些最終得分非常低的檢測框,但剩下的檢測框中,仍然可能會存在相互重疊特別嚴重的情況。因此,我們在 fetchResult() 中做的最後一件事就是用一種稱為非極大值抑制的方法來減少這種重複的檢測框。

  var result = NeuralNetworkResult<Prediction>()
  result.predictions = nonMaxSuppression(boxes: predictions,
                                         limit: 10, threshold: 0.5)
  return result
}
複製程式碼

nonMaxSuppression() 函式所使用的演算法很簡單:

  1. 從最終得分最高的那個檢測框開始。
  2. 將其他與該檢測框重疊率超過一定閾值(比如超過 50%)的檢測框移除。
  3. 返回第一步,重複直到遍歷完所有的檢測框。

該演算法移除了與更高得分的檢測框有太多重疊的其他檢測框,只保留了最好的那些。

以上就是所有的過程了:一個常規的卷積網路,以及後續對結果的一些處理。

執行效果如何?

YOLO 官方網站 宣稱精簡版 YOLO 最快每秒可處理 200 幀影像。但那是在效能優秀的筆記本上的執行結果,而不是在移動裝置上。那麼,它在 iPhone 上能夠執行多快呢?

在我的 iPhone 6s 上,它處理一幅影像大約需要 0.15 秒。那也只有 6 FPS,幾乎不能稱之為實時。如果你將手機對準一輛開過的汽車,你會看到一個檢測框拖在汽車後面一些。儘管如此,它能夠生效已經使我印象深刻了。?

注意:正如上文解釋,檢測框的處理是在 CPU 上進行,而不是 GPU。如果 YOLO 完全執行在 GPU 上,會變得更快嗎?也許會,但 CPU 程式碼執行時間只有 0.03 秒,佔 20% 的執行時間。將其中一部分工作交給 GPU 做當然是可行的,但考慮到卷積層的計算仍然佔據了 80% 的時間,我不確定是否值得這樣做。

我認為導致變慢的主要原因在於輸出通道為 512 和 1024 卷積層。經過實驗,MPSCNNConvolution 在通道較多的小圖片上的表現比在通道較少的大圖片上會更差。

另一件我比較感興趣的事情是採用另一種不同的網路結構,例如 SqueezeNet,對其重新進行訓練,以在最後一層進行檢測框的預測。換句話說,採用 YOLO 的思想,並將其應用在一個更小更快的網路上。這樣以精確度的損失換來速度上的提升是否值得呢?

注意:順便說一句,最近的 Caffe2 框架也是通過 Metal 的支援,執行在 iOS 裝置上。Caffe2-iOS project 是一個針對 YOLO 的精簡版本。看起來它執行得會比純 Metal 版稍慢,大約 0.17 秒/幀。

後記

想要了解更多 YOLO 相關的知識,可以檢視 YOLO 作者的以下論文:

我的實現一部分基於 TensorFlow Android demo TF Detect,Allan Zelener 的 YAD2K,以及 Darknet 原始碼

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章