1. 程式人生 > >深度學習在 iOS 上的實踐 —— 通過 YOLO 在 iOS 上實現實時物體檢測

深度學習在 iOS 上的實踐 —— 通過 YOLO 在 iOS 上實現實時物體檢測

譯者注:
在閱讀這篇文章之前可能會遇到的一些名詞,這裡是解釋(我自己也查了相當多的資料,為了翻譯地儘可能的簡單易懂一些)

  • Metal:Metal 是蘋果在 iOS 8 之後 提供的一種低層次的渲染應用程式程式設計介面,提供了軟體所需的最低層,保證軟體可以執行在不同的影象晶片上。(和 OpenGL ES 是並列關係)
  • 分類器:該函式或模型能夠把資料庫中的資料紀錄對映到給定類別中的某一個,從而可以應用於資料預測。
  • 批量歸一化:解決在訓練過程中,中間層資料分佈發生改變的問題,以防止梯度消失或爆炸、加快訓練速度。
  • 文中術語主要參照孫遜等人對斯坦福大學深度學習教程UFLDL Tutorial的翻譯

在計算機視覺領域,物體檢測是經典問題之一:
識別一張給定的影象中包含的物體是什麼

,和它們在影象中的位置

檢測是比分類更復雜的一個問題,雖然分類也要識別物體,但是它不需要告訴你物體在影象中的位置,並且分類無法識別包含多個物體的影象。

YOLO 是一個用來處理實時物體檢測的聰明的神經網路。

在這篇部落格裡面我將介紹如何通過 Metal Performance Shaders 讓“迷你”版的 YOLOv2 在 iOS 上執行(譯:MetalPerformanceShaders 是 iOS 9 中 Metal Kit新增的方法)。

YOLO 是怎麼工作的

你可以用一個類似於 VGGNet Inception 的分類器,通過在影象上移動一個小的視窗將分類器轉換成物體檢測器。在每一次移動中,執行分類器來獲取對當前視窗內物體型別的推測。通過滑動視窗可以獲得成百上千個關於該影象的推測,但是隻有那個分類器最確定的那個選項會被保留。

這個方案雖然是可行的但是很明顯它會非常的慢,因為你需要多次執行分類器。一種可以略微改善的方法是首先預測哪些部分的圖片可能包含有趣的資訊 - 所謂的區域建議 - 然後只在這些區域執行分類器。相比移動視窗來說,分類器確實減少了不少工作量,但是它仍會執行較多次數。

YOLO 採用了一種完全不同的實現方式。它不是傳統的分類器,而是被改造成了物件探測器。YOLO 實際上只會看影象一次(因此得名:You Only Look Once(你只用看一次)),但是是通過一種聰明的方式。
YOLO 把影象分割為 13 乘 13 單元的網格:

The 13x13 grid

每個單元都負責預測 5 個邊界框。邊界框代表著這個矩形包含著一個物體。

YOLO 也會輸出一個 確信值 來告訴我們它有多確定邊界框裡是否包含某個物體。這個分數不會包含任何關於邊界框內的物體是什麼的資訊,只是這個框是否符合標準。

預測之後的邊界框可能看上去像下面這樣(確信值越高,盒子的邊界畫的越寬)

對每個邊界框,單元也會推測一個類別。這就像分類器一樣:它提供了所有可能類的可能性分佈情況。這個版本的 YOLO 我們是通過PASCAL VOC dataset 來訓練的,它可以識別 20 種不同的類,比如:

  • 自行車
  • 汽車
  • 等等…

邊界框的確信值和類的預測組合成一個最終分數,告訴我們邊界框中包含一個特定型別的物體的可能性。舉個例子,左側這個又大又粗的黃色方框認為有 85% 的可能性它包含了“狗”這個物體。

The bounding boxes with their class scores

一共有 13×13 = 169 個單元格,每個單元格預測 5 個邊界框,最終我們會有 845 個邊界框。事實證明,大部分的框的確信值都很低,所以我們只保留那些最終得分在 30% 及以上的值(你可以根據你所需要的精確程度來修改這個下限)。

接下來是最後的預測:

The final prediction

從總共 845 的個邊界框中我們只保留了這三個,因為它們給出了最好的結果。但是請注意雖然是 845 個獨立的預測,它們都是同時執行的 - 神經網路只會執行一次。這也是為什麼 YOLO 是如此的強大和快速。

神經網路

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)
---------------------------------------------

這種神經網路只使用了標準的層型別:3x3 核心的卷積層和 2x2 的最大值池化層,沒有複雜的事務。YOLOv2 中沒有全連線層。

注意: 我們將要使用的“迷你”版本的 YOLO 只有 9 個卷積層和 6 個池化層。完整版的 YOLOv2 模型的層數是“迷你”版的 3 倍,並且有一個略微複雜的形狀,但它仍然是一個常規的轉換。

最後的卷積層有個 1x1 的核心用於降低資料到 13x13x125 的尺寸。這個 13x13 看上去很熟悉:這正是影象原來分割之後的網格尺寸。

所以最終我們給每個網格單元生成了 125 個通道。這 125 個數字包含了邊界框中的資料和型別預測。為什麼是 125 個呢?恩,每個單元格預測 5 個邊界框,並且一個邊界框通過 25 個數據元素來描述:

  • 邊界框的矩形的 x 軸座標, y 軸座標,寬度和高度
  • 確信值
  • 20 個型別的可能性分佈

使用 YOLO 很簡單:你給它一個輸入影象(尺寸調節到 416x416 畫素),它在單一傳遞下通過卷積網路,最後轉變為 13x13x125 的張量來描述這些網格單元的邊界框。你所需要做的只是計算這些邊界框的最終分數,將那些小於 30% 的分數遺棄。

提示: 為了學習更多關於 YOLO 的工作原理和訓練方式,看下這個其中一位發明者的精彩的演講。這個視訊實際上描述的是 YOLOv1,一個在構建方面略微有點不同的老版本,但是其主要思想還是一樣的。值得一看!

轉換到 Metal

我剛剛描述的架構是迷你 YOLO 的,正是我們將在 iOS app 中使用的那個。完整的 YOLOv2 網路包含 3 倍的層數,並且這對於目前的 iPhone 來說想快速執行它,有點太大了。因此,迷你 YOLO 用了更少的層數,這使它比它哥哥快了不少,但是也損失了一些精確度。

YOLO 是用 Darknet 寫的,YOLO 作者的一個自定義深度學習框架。可下載到的權重只有 Darknet 格式。雖然 Darknet 已經開源了,但是我不是很願意花太多的時間來弄清楚它是怎麼工作的。

幸運的是,有人已經嘗試並把 Dardnet 模型轉換為 Keras,恰好是我所用的深度學習工具。因此我唯一要做的就是執行這個 ”YAD2K“ 的指令碼來把 Darknet 格式的權重轉換到 Keras 格式,然後再寫我自己的指令碼,把 Keras 權重轉換到 Metal 的格式。

但是,仍然有些奇怪…… YOLO 在卷積層之後使用的是一個常規的技術叫做批量歸一化

在”批量歸一化“背後的想法是資料乾淨的時候神經網路工作效果最好。理想情況下,輸入到層的資料的均值是 0 並且沒有太多的分歧。任何做過任意機器學習的人應該很熟悉這個,因為我們經常使用一個叫做”特徵縮放“或者”白化“在我們的輸入資料上來實現這一效果。

批量歸一化在層與層之間對資料做了一個類似的特徵縮放的工作。這個技術讓神經網路表現的更好因為它暫停了資料由於在網路中流動而導致的汙染。

為了讓你大致瞭解批量歸一的作用,看一看下面這兩個直方圖,分別是第一次應用卷積層後進行歸一化與不進行歸一化的不同結果。

在訓練深度網路的時候,批量歸一化很重要,但是我們證實在推斷時可以不用這個操作。這樣效果不錯,因為不做批量歸一化的計算會讓我們的 app 更快。而且任何情況下,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)

它先減去了輸出畫素的平均值,除以方差,再乘以一個縮放參數 gamma,然後加上偏移量 beta。這四個引數 — meanvariancegamma,和beta。- 正是批量統一層隨著網路訓練之後學到的內容。

為了移除批量歸一化,我們可以把這兩個等式調整一下來給卷積層計算新的權重和偏置量:

           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 這個 target 。

測試這個應用的最簡單的方法是把你的 iPhone 對準這些 YouTube 視訊上:

簡單的應用

有趣的程式碼是在 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...

先把來自攝像頭的輸入縮放至 416x416 畫素,然後輸入到卷積和最大池化層中。這和其他的轉換操作都非常相似。

有趣的是在輸出之後的操作。回想一下輸出的轉換之後是一個 13x13x125 的張量:圖片中的每個網格的單元都有 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 的格式的。我們先把它轉換到一個叫做 features 的 Float 值型別的陣列,以便我們更好的使用它。

fetchResult() 的主體是一個大的巢狀迴圈。它包含了所有的網格單元和每個單元的五次預測:

for cy in0..<13 {
    for cx in0..<13 {
      for b in0..<5 {
         . . .
      }
    }
  }

在這個迴圈裡面,我們給網格單元 (cy, cx) 計算了邊界框 b

首先我們從 features 陣列中讀取邊界框的 x, y, width 和 height ,也包括確信值。

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 個通道不是連續儲存,而是分散儲存的。(想深入分析的話可以去看原始碼)。

我們仍然需要處理 txtytwth tc 這五個引數 ,因為它們的格式有點奇怪。如果你不知道這些處理方法哪來的話,可以看下這篇論文 (這是訓練這個神經網路的附加產物之一)。

譯者注:這篇論文就是 YOLO 的作者寫的。作者在訓練的過程中形成了這篇論文,並作為訓練過程的一個更詳細的描述。

llet 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 代表了在我們使用的輸入到神經網路的 416x416 的影象中邊界框的中心;
wh 則是上述影象空間中邊界框的寬度和高度。邊界框的確信值是 tc ,我們通過 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)
}

上面的程式碼是對網格內的每個單元進行迴圈。當迴圈結束後,我們通常會有了一個包含了 10 到 20 個預測 predictions 陣列。

我們已經過濾掉了那些低分數的邊界框,但是仍然有些框的和其他的框有較多的重疊。因此,在最後一步我們需要在 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 幀每秒。但是當然這是在一個桌面級的 GPU 上,不是在移動裝置上。所以在 iPhone 上它能跑多快呢?

在我的 iPhone 6s 上面處理一張圖片大約需要 0.15 秒 。幀率只有 6 ,這幀率基本滿足實時的呼叫。如果你把你的手機對著開過的汽車,你可以看到有個邊界框在車子後面不遠的地方跟著它。儘管如此,我還是被這個技術深深的震驚了。

注意: 正如我上面所解釋的,邊界框的處理是在 CPU 而不是 GPU 上的。如果完全在 GPU 上執行是不是會更快呢?可能,但是 CPU 的程式碼只用了 0.03 秒, 20% 的執行時間。在 GPU 上處理一部分的工作是可行的,但是我不確定這樣是否值得,因為轉換層仍然佔用了 80% 的時間。

我認為慢的主要原因之一是由於卷積層包含了 512 和 1024 個輸出通道。在我的實驗中,似乎 MPSCNNConvolution 在處理多通道的小圖片比少通道的大圖片時更吃力。

一個讓我想去嘗試的是採用不同的網路構建方式,比如 SqueezeNet ,然後重新訓練網路來在最後一層進行邊界框的預測。換句話說,採用 YOLO 的想法並將它在一個更小更快的轉換之上實現。用準確度的下降來換取速度的提升的做法是否值得呢?

注意: 另外,最近釋出的 Caffe2 框架同樣是通過 Metal 來實現在 iOS 上執行的。Caffe2-iOS 專案來自於迷你 YOLO 的一個版本。它似乎比純 Metal 版本執行的慢 0.17 秒每幀。

鳴謝

想了解更多關於 YOLO 的資訊,看下以下由它的作者們寫的論文吧:

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