從零開始PyTorch項目:YOLO v3目標檢測實現(上)

從零開始PyTorch項目:YOLO v3目標檢測實現(上)

來自專欄 機器之心

選自Medium,作者:Ayoosh Kathuria,機器之心編譯。

目標檢測是深度學習近期發展過程中受益最多的領域。隨著技術的進步,人們已經開發出了很多用於目標檢測的演算法,包括 YOLO、SSD、Mask RCNN 和 RetinaNet。在本教程中,我們將使用 PyTorch 實現基於 YOLO v3 的目標檢測器,後者是一種快速的目標檢測演算法。該教程一共有五個部分,本文包含其中的前三部分。

在過去幾個月中,我一直在實驗室中研究提升目標檢測的方法。在這之中我獲得的最大啟發就是意識到:學習目標檢測的最佳方法就是自己動手實現這些演算法,而這正是本教程引導你去做的。

在本教程中,我們將使用 PyTorch 實現基於 YOLO v3 的目標檢測器,後者是一種快速的目標檢測演算法。

本教程使用的代碼需要運行在 Python 3.5 和 PyTorch 0.3 版本之上。你可以在以下鏈接中找到所有代碼:

github.com/ayooshkathur

本教程包含五個部分:

1. YOLO 的工作原理

2. 創建 YOLO 網路層級

3. 實現網路的前向傳播

4. objectness 置信度閾值和非極大值抑制

5. 設計輸入和輸出管道

所需背景知識

在學習本教程之前,你需要了解:

  • 卷積神經網路的工作原理,包括殘差塊、跳過連接和上採樣;
  • 目標檢測、邊界框回歸、IoU 和非極大值抑制;
  • 基礎的 PyTorch 使用。你需要能夠輕鬆地創建簡單的神經網路。

什麼是 YOLO?

YOLO 是 You Only Look Once 的縮寫。它是一種使用深度卷積神經網路學得的特徵來檢測對象的目標檢測器。在我們上手寫代碼之前,我們必須先了解 YOLO 的工作原理。

全卷積神經網路

YOLO 僅使用卷積層,這就使其成為全卷積神經網路(FCN)。它擁有 75 個卷積層,還有跳過連接和上採樣層。它不使用任何形式的池化,使用步幅為 2 的卷積層對特徵圖進行下採樣。這有助於防止通常由池化導致的低級特徵丟失。

作為 FCN,YOLO 對於輸入圖像的大小並不敏感。然而,在實踐中,我們可能想要持續不變的輸入大小,因為各種問題只有在我們實現演算法時才會浮現出來。

這其中的一個重要問題是:如果我們希望按批次處理圖像(批量圖像由 GPU 並行處理,這樣可以提升速度),我們就需要固定所有圖像的高度和寬度。這就需要將多個圖像整合進一個大的批次(將許多 PyTorch 張量合併成一個)。

YOLO 通過被步幅對圖像進行上採樣。例如,如果網路的步幅是 32,則大小為 416×416 的輸入圖像將產生 13×13 的輸出。通常,網路層中的任意步幅都指層的輸入除以輸入。

解釋輸出

典型地(對於所有目標檢測器都是這種情況),卷積層所學習的特徵會被傳遞到分類器/回歸器,從而進行預測(邊界框的坐標、類別標籤等)。

在 YOLO 中,預測是通過卷積層完成的(它是一個全卷積神經網路,請記住!)其核心尺寸為:

1×1×(B×(5+C))

現在,首先要注意的是我們的輸出是一個特徵圖。由於我們使用了 1×1 的卷積,所以預測圖的大小恰好是之前特徵圖的大小。在 YOLO v3(及其更新的版本)上,預測圖就是每個可以預測固定數量邊界框的單元格。

雖然形容特徵圖中單元的正確術語應該是「神經元」,但本文中為了更為直觀,我們將其稱為單元格(cell)。

深度方面,特徵圖中有 (B x (5 + C))* *個條目。B 代表每個單元可以預測的邊界框數量。根據 YOLO 的論文,這些 B 邊界框中的每一個都可能專門用於檢測某種對象。每個邊界框都有 5+C 個屬性,分別描述每個邊界框的中心坐標、維度、objectness 分數和 C 類置信度。YOLO v3 在每個單元中預測 3 個邊界框。

如果對象的中心位於單元格的感受野內,你會希望特徵圖的每個單元格都可以通過其中一個邊界框預測對象。(感受野是輸入圖像對於單元格可見的區域。)

這與 YOLO 是如何訓練的有關,只有一個邊界框負責檢測任意給定對象。首先,我們必須確定這個邊界框屬於哪個單元格。

因此,我們需要切分輸入圖像,把它拆成維度等於最終特徵圖的網格。

讓我們思考下面一個例子,其中輸入圖像大小是 416×416,網路的步幅是 32。如之前所述,特徵圖的維度會是 13×13。隨後,我們將輸入圖像分為 13×13 個網格。

輸入圖像中包含了真值對象框中心的網格會作為負責預測對象的單元格。在圖像中,它是被標記為紅色的單元格,其中包含了真值框的中心(被標記為黃色)。

現在,紅色單元格是網格中第七行的第七個。我們現在使特徵圖中第七行第七個單元格(特徵圖中的對應單元格)作為檢測狗的單元。

現在,這個單元格可以預測三個邊界框。哪個將會分配給狗的真值標籤?為了理解這一點,我們必須理解錨點的概念。

請注意,我們在這裡討論的單元格是預測特徵圖上的單元格,我們將輸入圖像分隔成網格,以確定預測特徵圖的哪個單元格負責預測對象。

錨點框(Anchor Box)

預測邊界框的寬度和高度看起來非常合理,但在實踐中,訓練會帶來不穩定的梯度。所以,現在大部分目標檢測器都是預測對數空間(log-space)變換,或者預測與預訓練默認邊界框(即錨點)之間的偏移。

然後,這些變換被應用到錨點框來獲得預測。YOLO v3 有三個錨點,所以每個單元格會預測 3 個邊界框。

回到前面的問題,負責檢測狗的邊界框的錨點有最高的 IoU,且有真值框。

預測

下面的公式描述了網路輸出是如何轉換,以獲得邊界框預測結果的。

中心坐標

注意:我們使用 sigmoid 函數進行中心坐標預測。這使得輸出值在 0 和 1 之間。原因如下:

正常情況下,YOLO 不會預測邊界框中心的確切坐標。它預測:

  • 與預測目標的網格單元左上角相關的偏移;
  • 使用特徵圖單元的維度(1)進行歸一化的偏移。

以我們的圖像為例。如果中心的預測是 (0.4, 0.7),則中心在 13 x 13 特徵圖上的坐標是 (6.4, 6.7)(紅色單元的左上角坐標是 (6,6))。

但是,如果預測到的 x,y 坐標大於 1,比如 (1.2, 0.7)。那麼中心坐標是 (7.2, 6.7)。注意該中心在紅色單元右側的單元中,或第 7 行的第 8 個單元。這打破了 YOLO 背後的理論,因為如果我們假設紅色框負責預測目標狗,那麼狗的中心必須在紅色單元中,不應該在它旁邊的網格單元中。

因此,為了解決這個問題,我們對輸出執行 sigmoid 函數,將輸出壓縮到區間 0 到 1 之間,有效確保中心處於執行預測的網格單元中。

邊界框的維度

我們對輸出執行對數空間變換,然後乘錨點,來預測邊界框的維度。

檢測器輸出在最終預測之前的變換過程,圖源:http://christopher5106.github.io/

得出的預測 bw 和 bh 使用圖像的高和寬進行歸一化。即,如果包含目標(狗)的框的預測 bx 和 by 是 (0.3, 0.8),那麼 13 x 13 特徵圖的實際寬和高是 (13 x 0.3, 13 x 0.8)。

Objectness 分數

Object 分數表示目標在邊界框內的概率。紅色網格和相鄰網格的 Object 分數應該接近 1,而角落處的網格的 Object 分數可能接近 0。

objectness 分數的計算也使用 sigmoid 函數,因此它可以被理解為概率。

類別置信度

類別置信度表示檢測到的對象屬於某個類別的概率(如狗、貓、香蕉、汽車等)。在 v3 之前,YOLO 需要對類別分數執行 softmax 函數操作。

但是,YOLO v3 捨棄了這種設計,作者選擇使用 sigmoid 函數。因為對類別分數執行 softmax 操作的前提是類別是互斥的。簡言之,如果對象屬於一個類別,那麼必須確保其不屬於另一個類別。這在我們設置檢測器的 COCO 數據集上是正確的。但是,當出現類別「女性」(Women)和「人」(Person)時,該假設不可行。這就是作者選擇不使用 Softmax 激活函數的原因。

在不同尺度上的預測

YOLO v3 在 3 個不同尺度上進行預測。檢測層用於在三個不同大小的特徵圖上執行預測,特徵圖步幅分別是 32、16、8。這意味著,當輸入圖像大小是 416 x 416 時,我們在尺度 13 x 13、26 x 26 和 52 x 52 上執行檢測。

該網路在第一個檢測層之前對輸入圖像執行下採樣,檢測層使用步幅為 32 的層的特徵圖執行檢測。隨後在執行因子為 2 的上採樣後,並與前一個層的特徵圖(特徵圖大小相同)拼接。另一個檢測在步幅為 16 的層中執行。重複同樣的上採樣步驟,最後一個檢測在步幅為 8 的層中執行。

在每個尺度上,每個單元使用 3 個錨點預測 3 個邊界框,錨點的總數為 9(不同尺度的錨點不同)。

作者稱這幫助 YOLO v3 在檢測較小目標時取得更好的性能,而這正是 YOLO 之前版本經常被抱怨的地方。上採樣可以幫助該網路學習細粒度特徵,幫助檢測較小目標。

輸出處理

對於大小為 416 x 416 的圖像,YOLO 預測 ((52 x 52) + (26 x 26) + 13 x 13)) x 3 = 10647 個邊界框。但是,我們的示例中只有一個對象——一隻狗。那麼我們怎麼才能將檢測次數從 10647 減少到 1 呢?

目標置信度閾值:首先,我們根據它們的 objectness 分數過濾邊界框。通常,分數低於閾值的邊界框會被忽略。

非極大值抑制:非極大值抑制(NMS)可解決對同一個圖像的多次檢測的問題。例如,紅色網格單元的 3 個邊界框可以檢測一個框,或者臨近網格可檢測相同對象。

實現

YOLO 只能檢測出屬於訓練所用數據集中類別的對象。我們的檢測器將使用官方權重文件,這些權重通過在 COCO 數據集上訓練網路而獲得,因此我們可以檢測 80 個對象類別。

該教程的第一部分到此結束。這部分詳細講解了 YOLO 演算法。如果你想深度了解 YOLO 的工作原理、訓練過程和與其他檢測器的性能規避,可閱讀原始論文:

1. YOLO V1: You Only Look Once: Unified, Real-Time Object Detection (arxiv.org/pdf/1506.0264)

2. YOLO V2: YOLO9000: Better, Faster, Stronger (arxiv.org/pdf/1612.0824)

3. YOLO V3: An Incremental Improvement (pjreddie.com/media/file)

4. Convolutional Neural Networks (cs231n.github.io/convol)

5. Bounding Box Regression (Appendix C) (arxiv.org/pdf/1311.2524)

6. IoU (youtube.com/watch?)

7. Non maximum suppresion (youtube.com/watch?)

8. PyTorch Official Tutorial (pytorch.org/tutorials/b)

第二部分:創建 YOLO 網路層級

以下是從頭實現 YOLO v3 檢測器的第二部分教程,我們將基於前面所述的基本概念使用 PyTorch 實現 YOLO 的層級,即創建整個模型的基本構建塊。

這一部分要求讀者已經基本了解 YOLO 的運行方式和原理,以及關於 PyTorch 的基本知識,例如如何通過 nn.Module、nn.Sequential 和 torch.nn.parameter 等類來構建自定義的神經網路架構。

開始旅程

首先創建一個存放檢測器代碼的文件夾,然後再創建 Python 文件 darknet.py。Darknet 是構建 YOLO 底層架構的環境,這個文件將包含實現 YOLO 網路的所有代碼。同樣我們還需要補充一個名為 util.py 的文件,它會包含多種需要調用的函數。在將所有這些文件保存在檢測器文件夾下後,我們就能使用 git 追蹤它們的改變。

配置文件

官方代碼(authored in C)使用一個配置文件來構建網路,即 cfg 文件一塊塊地描述了網路架構。如果你使用過 caffe 後端,那麼它就相當於描述網路的.protxt 文件。

我們將使用官方的 cfg 文件構建網路,它是由 YOLO 的作者發布的。我們可以在以下地址下載,並將其放在檢測器目錄下的 cfg 文件夾下。

配置文件下載:github.com/pjreddie/dar

當然,如果你使用 Linux,那麼就可以先 cd 到檢測器網路的目錄,然後運行以下命令行。

mkdir cfgcd cfgwget https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg

如果你打開配置文件,你將看到如下一些網路架構:

[convolutional]batch_normalize=1filters=64size=3stride=2pad=1activation=leaky[convolutional]batch_normalize=1filters=32size=1stride=1pad=1activation=leaky[convolutional]batch_normalize=1filters=64size=3stride=1pad=1activation=leaky[shortcut]from=-3activation=linear

我們看到上面有四塊配置,其中 3 個描述了卷積層,最後描述了 ResNet 中常用的捷徑層或跳過連接。下面是 YOLO 中使用的 5 種層級:

1. 卷積層

[convolutional]batch_normalize=1 filters=64 size=3 stride=1 pad=1 activation=leaky

2. 跳過連接

[shortcut]from=-3 activation=linear

跳過連接與殘差網路中使用的結構相似,參數 from 為-3 表示捷徑層的輸出會通過將之前層的和之前第三個層的輸出的特徵圖與模塊的輸入相加而得出。

3.上採樣

[upsample]stride=2

通過參數 stride 在前面層級中雙線性上採樣特徵圖。

4.路由層(Route)

[route]layers = -4[route]layers = -1, 61

路由層需要一些解釋,它的參數 layers 有一個或兩個值。當只有一個值時,它輸出這一層通過該值索引的特徵圖。在我們的實驗中設置為了-4,所以層級將輸出路由層之前第四個層的特徵圖。

當層級有兩個值時,它將返回由這兩個值索引的拼接特徵圖。在我們的實驗中為-1 和 61,因此該層級將輸出從前一層級(-1)到第 61 層的特徵圖,並將它們按深度拼接。

5.YOLO

[yolo]mask = 0,1,2anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326classes=80num=9jitter=.3ignore_thresh = .5truth_thresh = 1random=1

YOLO 層級對應於上文所描述的檢測層級。參數 anchors 定義了 9 組錨點,但是它們只是由 mask 標籤使用的屬性所索引的錨點。這裡,mask 的值為 0、1、2 表示了第一個、第二個和第三個使用的錨點。而掩碼錶示檢測層中的每一個單元預測三個框。總而言之,我們檢測層的規模為 3,並裝配總共 9 個錨點。

Net

[net]# Testingbatch=1subdivisions=1# Training# batch=64# subdivisions=16width_= 320height = 320channels=3momentum=0.9decay=0.0005angle=0saturation = 1.5exposure = 1.5hue=.1

配置文件中存在另一種塊 net,不過我不認為它是層,因為它只描述網路輸入和訓練參數的相關信息,並未用於 YOLO 的前向傳播。但是,它為我們提供了網路輸入大小等信息,可用於調整前向傳播中的錨點。

解析配置文件

在開始之前,我們先在 darknet.py 文件頂部添加必要的導入項。

from __future__ import divisionimport torch import torch.nn as nnimport torch.nn.functional as F from torch.autograd import Variableimport numpy as np

我們定義一個函數 parse_cfg,該函數使用配置文件的路徑作為輸入。

def parse_cfg(cfgfile): """ Takes a configuration file Returns a list of blocks. Each blocks describes a block in the neural network to be built. Block is represented as a dictionary in the list """

這裡的思路是解析 cfg,將每個塊存儲為詞典。這些塊的屬性和值都以鍵值對的形式存儲在詞典中。解析過程中,我們將這些詞典(由代碼中的變數 block 表示)添加到列表 blocks 中。我們的函數將返回該 block。

我們首先將配置文件內容保存在字元串列表中。下面的代碼對該列表執行預處理:

file = open(cfgfile, r)lines = file.read().split(
) # store the lines in a listlines = [x for x in lines if len(x) > 0] # get read of the empty lines lines = [x for x in lines if x[0] != #] # get rid of commentslines = [x.rstrip().lstrip() for x in lines] # get rid of fringe whitespaces

然後,我們遍歷預處理後的列表,得到塊。

block = {}blocks = []for line in lines: if line[0] == "[": # This marks the start of a new block if len(block) != 0: # If block is not empty, implies it is storing values of previous block. blocks.append(block) # add it the blocks list block = {} # re-init the block block["type"] = line[1:-1].rstrip() else: key,value = line.split("=") block[key.rstrip()] = value.lstrip()blocks.append(block)return blocks

創建構建塊

現在我們將使用上面 parse_cfg 返回的列表來構建 PyTorch 模塊,作為配置文件中的構建塊。

列表中有 5 種類型的層。PyTorch 為 convolutional 和 upsample 提供預置層。我們將通過擴展 nn.Module 類為其餘層寫自己的模塊。

create_modules 函數使用 parse_cfg 函數返回的 blocks 列表:

def create_modules(blocks): net_info = blocks[0] #Captures the information about the input and pre-processing module_list = nn.ModuleList() prev_filters = 3 output_filters = []

在迭代該列表之前,我們先定義變數 net_info,來存儲該網路的信息。

nn.ModuleList

我們的函數將會返回一個 nn.ModuleList。這個類幾乎等同於一個包含 nn.Module 對象的普通列表。然而,當添加 nn.ModuleList 作為 nn.Module 對象的一個成員時(即當我們添加模塊到我們的網路時),所有 nn.ModuleList 內部的 nn.Module 對象(模塊)的 parameter 也被添加作為 nn.Module 對象(即我們的網路,添加 nn.ModuleList 作為其成員)的 parameter。

當我們定義一個新的卷積層時,我們必須定義它的卷積核維度。雖然卷積核的高度和寬度由 cfg 文件提供,但卷積核的深度是由上一層的卷積核數量(或特徵圖深度)決定的。這意味著我們需要持續追蹤被應用卷積層的卷積核數量。我們使用變數 prev_filter 來做這件事。我們將其初始化為 3,因為圖像有對應 RGB 通道的 3 個通道。

路由層(route layer)從前面層得到特徵圖(可能是拼接的)。如果在路由層之後有一個卷積層,那麼卷積核將被應用到前面層的特徵圖上,精確來說是路由層得到的特徵圖。因此,我們不僅需要追蹤前一層的卷積核數量,還需要追蹤之前每個層。隨著不斷地迭代,我們將每個模塊的輸出卷積核數量添加到 output_filters 列表上。

現在,我們的思路是迭代模塊的列表,並為每個模塊創建一個 PyTorch 模塊。

for index, x in enumerate(blocks[1:]): module = nn.Sequential() #check the type of block #create a new module for the block #append to module_list

nn.Sequential 類被用於按順序地執行 nn.Module 對象的一個數字。如果你查看 cfg 文件,你會發現,一個模塊可能包含多於一個層。例如,一個 convolutional 類型的模塊有一個批量歸一化層、一個 leaky ReLU 激活層以及一個卷積層。我們使用 nn.Sequential 將這些層串聯起來,得到 add_module 函數。例如,以下展示了我們如何創建卷積層和上採樣層的例子:

if (x["type"] == "convolutional"): #Get the info about the layer activation = x["activation"] try: batch_normalize = int(x["batch_normalize"]) bias = False except: batch_normalize = 0 bias = True filters= int(x["filters"]) padding = int(x["pad"]) kernel_size = int(x["size"]) stride = int(x["stride"]) if padding: pad = (kernel_size - 1) // 2 else: pad = 0 #Add the convolutional layer conv = nn.Conv2d(prev_filters, filters, kernel_size, stride, pad, bias = bias) module.add_module("conv_{0}".format(index), conv) #Add the Batch Norm Layer if batch_normalize: bn = nn.BatchNorm2d(filters) module.add_module("batch_norm_{0}".format(index), bn) #Check the activation. #It is either Linear or a Leaky ReLU for YOLO if activation == "leaky": activn = nn.LeakyReLU(0.1, inplace = True) module.add_module("leaky_{0}".format(index), activn) #If its an upsampling layer #We use Bilinear2dUpsampling elif (x["type"] == "upsample"): stride = int(x["stride"]) upsample = nn.Upsample(scale_factor = 2, mode = "bilinear") module.add_module("upsample_{}".format(index), upsample)

路由層/捷徑層

接下來,我們來寫創建路由層(Route Layer)和捷徑層(Shortcut Layer)的代碼:

#If it is a route layer elif (x["type"] == "route"): x["layers"] = x["layers"].split(,) #Start of a route start = int(x["layers"][0]) #end, if there exists one. try: end = int(x["layers"][1]) except: end = 0 #Positive anotation if start > 0: start = start - index if end > 0: end = end - index route = EmptyLayer() module.add_module("route_{0}".format(index), route) if end < 0: filters = output_filters[index + start] + output_filters[index + end] else: filters= output_filters[index + start] #shortcut corresponds to skip connection elif x["type"] == "shortcut": shortcut = EmptyLayer() module.add_module("shortcut_{}".format(index), shortcut)

創建路由層的代碼需要做一些解釋。首先,我們提取關於層屬性的值,將其表示為一個整數,並保存在一個列表中。

然後我們得到一個新的稱為 EmptyLayer 的層,顧名思義,就是空的層。

route = EmptyLayer()

其定義如下:

class EmptyLayer(nn.Module): def __init__(self): super(EmptyLayer, self).__init__()

等等,一個空的層?

現在,一個空的層可能會令人困惑,因為它沒有做任何事情。而 Route Layer 正如其它層將執行某種操作(獲取之前層的拼接)。在 PyTorch 中,當我們定義了一個新的層,我們在子類 nn.Module 中寫入層在 nn.Module 對象的 forward 函數的運算。

對於在 Route 模塊中設計一個層,我們必須建立一個 nn.Module 對象,其作為 layers 的成員被初始化。然後,我們可以寫下代碼,將 forward 函數中的特徵圖拼接起來並向前饋送。最後,我們執行網路的某個 forward 函數的這個層。

但拼接操作的代碼相當地短和簡單(在特徵圖上調用 torch.cat),像上述過程那樣設計一個層將導致不必要的抽象,增加樣板代碼。取而代之,我們可以將一個假的層置於之前提出的路由層的位置上,然後直接在代表 darknet 的 nn.Module 對象的 forward 函數中執行拼接運算。(如果感到困惑,我建議你讀一下 nn.Module 類在 PyTorch 中的使用)。

在路由層之後的卷積層會把它的卷積核應用到之前層的特徵圖(可能是拼接的)上。以下的代碼更新了 filters 變數以保存路由層輸出的卷積核數量。

if end < 0: #If we are concatenating maps filters = output_filters[index + start] + output_filters[index + end]else: filters= output_filters[index + start]

捷徑層也使用空的層,因為它還要執行一個非常簡單的操作(加)。沒必要更新 filters 變數,因為它只是將前一層的特徵圖添加到後面的層上而已。

YOLO 層

最後,我們將編寫創建 YOLO 層的代碼:

#Yolo is the detection layer elif x["type"] == "yolo": mask = x["mask"].split(",") mask = [int(x) for x in mask] anchors = x["anchors"].split(",") anchors = [int(a) for a in anchors] anchors = [(anchors[i], anchors[i+1]) for i in range(0, len(anchors),2)] anchors = [anchors[i] for i in mask] detection = DetectionLayer(anchors) module.add_module("Detection_{}".format(index), detection)

我們定義一個新的層 DetectionLayer 保存用於檢測邊界框的錨點。

檢測層的定義如下:

class DetectionLayer(nn.Module): def __init__(self, anchors): super(DetectionLayer, self).__init__() self.anchors = anchors

在這個迴路結束時,我們做了一些統計(bookkeeping.)。

module_list.append(module) prev_filters = filters output_filters.append(filters)

這總結了此迴路的主體。在 create_modules 函數後,我們獲得了包含 net_info 和 module_list 的元組。

return (net_info, module_list)

測試代碼

你可以在 darknet.py 後通過輸入以下命令行測試代碼,運行文件。

blocks = parse_cfg("cfg/yolov3.cfg")print(create_modules(blocks))

你會看到一個長列表(確切來說包含 106 條),其中元素看起來如下所示:

(9): Sequential( (conv_9): Conv2d (128, 64, kernel_size=(1, 1), stride=(1, 1), bias=False) (batch_norm_9): BatchNorm2d(64, eps=1e-05, momentum=0.1, affine=True) (leaky_9): LeakyReLU(0.1, inplace) ) (10): Sequential( (conv_10): Conv2d (64, 128, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False) (batch_norm_10): BatchNorm2d(128, eps=1e-05, momentum=0.1, affine=True) (leaky_10): LeakyReLU(0.1, inplace) ) (11): Sequential( (shortcut_11): EmptyLayer( ) )

第三部分:實現網路的前向傳播

第二部分中,我們實現了 YOLO 架構中使用的層。這部分,我們計劃用 PyTorch 實現 YOLO 網路架構,這樣我們就能生成給定圖像的輸出了。

我們的目標是設計網路的前向傳播。

先決條件

  • 閱讀本教程前兩部分;
  • PyTorch 基礎知識,包括如何使用 nn.Module、nn.Sequential 和 torch.nn.parameter 創建自定義架構;
  • 在 PyTorch 中處理圖像。

定義網路

如前所述,我們使用 nn.Module 在 PyTorch 中構建自定義架構。這裡,我們可以為檢測器定義一個網路。在 darknet.py 文件中,我們添加了以下類別:

class Darknet(nn.Module): def __init__(self, cfgfile): super(Darknet, self).__init__() self.blocks = parse_cfg(cfgfile) self.net_info, self.module_list = create_modules(self.blocks)

這裡,我們對 nn.Module 類別進行子分類,並將我們的類別命名為 Darknet。我們用 members、blocks、net_info 和 module_list 對網路進行初始化。

實現該網路的前向傳播

該網路的前向傳播通過覆寫 nn.Module 類別的 forward 方法而實現。

forward 主要有兩個目的。一,計算輸出;二,儘早處理的方式轉換輸出檢測特徵圖(例如轉換之後,這些不同尺度的檢測圖就能夠串聯,不然會因為不同維度不可能實現串聯)。

def forward(self, x, CUDA): modules = self.blocks[1:] outputs = {} #We cache the outputs for the route layer

forward 函數有三個參數:self、輸入 x 和 CUDA(如果是 true,則使用 GPU 來加速前向傳播)。

這裡,我們迭代 self.block[1:] 而不是 self.blocks,因為 self.blocks 的第一個元素是一個 net 塊,它不屬於前向傳播。

由於路由層和捷徑層需要之前層的輸出特徵圖,我們在字典 outputs 中緩存每個層的輸出特徵圖。關鍵在於層的索引,且值對應特徵圖。

正如 create_module 函數中的案例,我們現在迭代 module_list,它包含了網路的模塊。需要注意的是這些模塊是以在配置文件中相同的順序添加的。這意味著,我們可以簡單地讓輸入通過每個模塊來得到輸出。

write = 0 #This is explained a bit laterfor i, module in enumerate(modules): module_type = (module["type"])

卷積層和上採樣層

如果該模塊是一個卷積層或上採樣層,那麼前向傳播應該按如下方式工作:

if module_type == "convolutional" or module_type == "upsample": x = self.module_list[i](x)

路由層/捷徑層

如果你查看路由層的代碼,我們必須說明兩個案例(正如第二部分中所描述的)。對於第一個案例,我們必須使用 torch.cat 函數將兩個特徵圖級聯起來,第二個參數設為 1。這是因為我們希望將特徵圖沿深度級聯起來。(在 PyTorch 中,卷積層的輸入和輸出的格式為`B X C X H X W。深度對應通道維度)。

elif module_type == "route": layers = module["layers"] layers = [int(a) for a in layers] if (layers[0]) > 0: layers[0] = layers[0] - i if len(layers) == 1: x = outputs[i + (layers[0])] else: if (layers[1]) > 0: layers[1] = layers[1] - i map1 = outputs[i + layers[0]] map2 = outputs[i + layers[1]] x = torch.cat((map1, map2), 1) elif module_type == "shortcut": from_ = int(module["from"]) x = outputs[i-1] + outputs[i+from_]

YOLO(檢測層)

YOLO 的輸出是一個卷積特徵圖,包含沿特徵圖深度的邊界框屬性。邊界框屬性由彼此堆疊的單元格預測得出。因此,如果你需要在 (5,6) 處訪問單元格的第二個邊框,那麼你需要通過 map[5,6, (5+C): 2*(5+C)] 將其編入索引。這種格式對於輸出處理過程(例如通過目標置信度進行閾值處理、添加對中心的網格偏移、應用錨點等)很不方便。

另一個問題是由於檢測是在三個尺度上進行的,預測圖的維度將是不同的。雖然三個特徵圖的維度不同,但對它們執行的輸出處理過程是相似的。如果能在單個張量而不是三個單獨張量上執行這些運算,就太好了。

為了解決這些問題,我們引入了函數 predict_transform。

變換輸出

函數 predict_transform 在文件 util.py 中,我們在 Darknet 類別的 forward 中使用該函數時,將導入該函數。

在 util.py 頂部添加導入項:

from __future__ import divisionimport torch import torch.nn as nnimport torch.nn.functional as F from torch.autograd import Variableimport numpy as npimport cv2

predict_transform 使用 5 個參數:prediction(我們的輸出)、inp_dim(輸入圖像的維度)、anchors、num_classes、CUDA flag(可選)。

def predict_transform(prediction, inp_dim, anchors, num_classes, CUDA = True):

predict_transform 函數把檢測特徵圖轉換成二維張量,張量的每一行對應邊界框的屬性,如下所示:

上述變換所使用的代碼:

batch_size = prediction.size(0) stride = inp_dim // prediction.size(2) grid_size = inp_dim // stride bbox_attrs = 5 + num_classes num_anchors = len(anchors) prediction = prediction.view(batch_size, bbox_attrs*num_anchors, grid_size*grid_size) prediction = prediction.transpose(1,2).contiguous() prediction = prediction.view(batch_size, grid_size*grid_size*num_anchors, bbox_attrs)

錨點的維度與 net 塊的 height 和 width 屬性一致。這些屬性描述了輸入圖像的維度,比檢測圖的規模大(二者之商即是步幅)。因此,我們必須使用檢測特徵圖的步幅分割錨點。

anchors = [(a[0]/stride, a[1]/stride) for a in anchors]

現在,我們需要根據第一部分討論的公式變換輸出。

對 (x,y) 坐標和 objectness 分數執行 Sigmoid 函數操作。

#Sigmoid the centre_X, centre_Y. and object confidencce prediction[:,:,0] = torch.sigmoid(prediction[:,:,0]) prediction[:,:,1] = torch.sigmoid(prediction[:,:,1]) prediction[:,:,4] = torch.sigmoid(prediction[:,:,4])

將網格偏移添加到中心坐標預測中:

#Add the center offsets grid = np.arange(grid_size) a,b = np.meshgrid(grid, grid) x_offset = torch.FloatTensor(a).view(-1,1) y_offset = torch.FloatTensor(b).view(-1,1) if CUDA: x_offset = x_offset.cuda() y_offset = y_offset.cuda() x_y_offset = torch.cat((x_offset, y_offset), 1).repeat(1,num_anchors).view(-1,2).unsqueeze(0) prediction[:,:,:2] += x_y_offset

將錨點應用到邊界框維度中:

#log space transform height and the width anchors = torch.FloatTensor(anchors) if CUDA: anchors = anchors.cuda() anchors = anchors.repeat(grid_size*grid_size, 1).unsqueeze(0) prediction[:,:,2:4] = torch.exp(prediction[:,:,2:4])*anchors

將 sigmoid 激活函數應用到類別分數中:

prediction[:,:,5: 5 + num_classes] = torch.sigmoid((prediction[:,:, 5 : 5 + num_classes]))

最後,我們要將檢測圖的大小調整到與輸入圖像大小一致。邊界框屬性根據特徵圖的大小而定(如 13 x 13)。如果輸入圖像大小是 416 x 416,那麼我們將屬性乘 32,或乘 stride 變數。

prediction[:,:,:4] *= stride

loop 部分到這裡就大致結束了。

函數結束時會返回預測結果:

return prediction

重新訪問的檢測層

我們已經變換了輸出張量,現在可以將三個不同尺度的檢測圖級聯成一個大的張量。注意這必須在變換之後進行,因為你無法級聯不同空間維度的特徵圖。變換之後,我們的輸出張量把邊界框表格呈現為行,級聯就比較可行了。

一個阻礙是我們無法初始化空的張量,再向其級聯一個(不同形態的)非空張量。因此,我們推遲收集器(容納檢測的張量)的初始化,直到獲得第一個檢測圖,再把這些檢測圖級聯起來。

注意 write = 0 在函數 forward 的 loop 之前。write flag 表示我們是否遇到第一個檢測。如果 write 是 0,則收集器尚未初始化。如果 write 是 1,則收集器已經初始化,我們只需要將檢測圖與收集器級聯起來即可。

現在,我們具備了 predict_transform 函數,我們可以寫代碼,處理 forward 函數中的檢測特徵圖。

在 darknet.py 文件的頂部,添加以下導入項:

from util import *

然後在 forward 函數中定義:

elif module_type == yolo: anchors = self.module_list[i][0].anchors #Get the input dimensions inp_dim = int (self.net_info["height"]) #Get the number of classes num_classes = int (module["classes"]) #Transform x = x.data x = predict_transform(x, inp_dim, anchors, num_classes, CUDA) if not write: #if no collector has been intialised. detections = x write = 1 else: detections = torch.cat((detections, x), 1) outputs[i] = x

現在,只需返回檢測結果。

return detections

測試前向傳播

下面的函數將創建一個偽造的輸入,我們可以將該輸入傳入我們的網路。在寫該函數之前,我們可以使用以下命令行將這張圖像保存到工作目錄:

wget https://github.com/ayooshkathuria/pytorch-yolo-v3/raw/master/dog-cycle-car.png

也可以直接下載圖像:github.com/ayooshkathur

現在,在 darknet.py 文件的頂部定義以下函數:

def get_test_input(): img = cv2.imread("dog-cycle-car.png") img = cv2.resize(img, (416,416)) #Resize to the input dimension img_ = img[:,:,::-1].transpose((2,0,1)) # BGR -> RGB | H X W C -> C X H X W img_ = img_[np.newaxis,:,:,:]/255.0 #Add a channel at 0 (for batch) | Normalise img_ = torch.from_numpy(img_).float() #Convert to float img_ = Variable(img_) # Convert to Variable return img_

我們需要鍵入以下代碼:

model = Darknet("cfg/yolov3.cfg")inp = get_test_input()pred = model(inp)print (pred)

你將看到如下輸出:

( 0 ,.,.) = 16.0962 17.0541 91.5104 ... 0.4336 0.4692 0.5279 15.1363 15.2568 166.0840 ... 0.5561 0.5414 0.5318 14.4763 18.5405 409.4371 ... 0.5908 0.5353 0.4979 ? ... 411.2625 412.0660 9.0127 ... 0.5054 0.4662 0.5043 412.1762 412.4936 16.0449 ... 0.4815 0.4979 0.4582 412.1629 411.4338 34.9027 ... 0.4306 0.5462 0.4138[torch.FloatTensor of size 1x10647x85]

張量的形狀為 1×10647×85,第一個維度為批量大小,這裡我們只使用了單張圖像。對於批量中的圖像,我們會有一個 100647×85 的表,它的每一行表示一個邊界框(4 個邊界框屬性、1 個 objectness 分數和 80 個類別分數)。

現在,我們的網路有隨機權重,並且不會輸出正確的類別。我們需要為網路載入權重文件,因此可以利用官方權重文件。

下載預訓練權重

下載權重文件並放入檢測器目錄下,我們可以直接使用命令行下載:

wget https://pjreddie.com/media/files/yolov3.weights

也可以通過該地址下載:pjreddie.com/media/file

理解權重文件

官方的權重文件是一個二進位文件,它以序列方式儲存神經網路權重。

我們必須小心地讀取權重,因為權重只是以浮點形式儲存,沒有其它信息能告訴我們到底它們屬於哪一層。所以如果讀取錯誤,那麼很可能權重載入就全錯了,模型也完全不能用。因此,只閱讀浮點數,無法區別權重屬於哪一層。因此,我們必須了解權重是如何存儲的。

首先,權重只屬於兩種類型的層,即批歸一化層(batch norm layer)和卷積層。這些層的權重儲存順序和配置文件中定義層級的順序完全相同。所以,如果一個 convolutional 後面跟隨著 shortcut 塊,而 shortcut 連接了另一個 convolutional 塊,則你會期望文件包含了先前 convolutional 塊的權重,其後則是後者的權重。

當批歸一化層出現在卷積模塊中時,它是不帶有偏置項的。然而,當卷積模塊不存在批歸一化,則偏置項的「權重」就會從文件中讀取。下圖展示了權重是如何儲存的。

載入權重

我們寫一個函數來載入權重,它是 Darknet 類的成員函數。它使用 self 以外的一個參數作為權重文件的路徑。

def load_weights(self, weightfile):

第一個 160 比特的權重文件保存了 5 個 int32 值,它們構成了文件的標頭。

#Open the weights file fp = open(weightfile, "rb") #The first 5 values are header information # 1. Major version number # 2. Minor Version Number # 3. Subversion number # 4,5. Images seen by the network (during training) header = np.fromfile(fp, dtype = np.int32, count = 5) self.header = torch.from_numpy(header) self.seen = self.header[3]

之後的比特代表權重,按上述順序排列。權重被保存為 float32 或 32 位浮點數。我們來載入 np.ndarray 中的剩餘權重。

weights = np.fromfile(fp, dtype = np.float32)

現在,我們迭代地載入權重文件到網路的模塊上。

ptr = 0 for i in range(len(self.module_list)): module_type = self.blocks[i + 1]["type"] #If module_type is convolutional load weights #Otherwise ignore.

在循環過程中,我們首先檢查 convolutional 模塊是否有 batch_normalize(True)。基於此,我們載入權重。

if module_type == "convolutional": model = self.module_list[i] try: batch_normalize = int(self.blocks[i+1]["batch_normalize"]) except: batch_normalize = 0 conv = model[0]

我們保持一個稱為 ptr 的變數來追蹤我們在權重數組中的位置。現在,如果 batch_normalize 檢查結果是 True,則我們按以下方式載入權重:

if (batch_normalize): bn = model[1] #Get the number of weights of Batch Norm Layer num_bn_biases = bn.bias.numel() #Load the weights bn_biases = torch.from_numpy(weights[ptr:ptr + num_bn_biases]) ptr += num_bn_biases bn_weights = torch.from_numpy(weights[ptr: ptr + num_bn_biases]) ptr += num_bn_biases bn_running_mean = torch.from_numpy(weights[ptr: ptr + num_bn_biases]) ptr += num_bn_biases bn_running_var = torch.from_numpy(weights[ptr: ptr + num_bn_biases]) ptr += num_bn_biases #Cast the loaded weights into dims of model weights. bn_biases = bn_biases.view_as(bn.bias.data) bn_weights = bn_weights.view_as(bn.weight.data) bn_running_mean = bn_running_mean.view_as(bn.running_mean) bn_running_var = bn_running_var.view_as(bn.running_var) #Copy the data to model bn.bias.data.copy_(bn_biases) bn.weight.data.copy_(bn_weights) bn.running_mean.copy_(bn_running_mean) bn.running_var.copy_(bn_running_var)

如果 batch_normalize 的檢查結果不是 True,只需要載入卷積層的偏置項。

else: #Number of biases num_biases = conv.bias.numel() #Load the weights conv_biases = torch.from_numpy(weights[ptr: ptr + num_biases]) ptr = ptr + num_biases #reshape the loaded weights according to the dims of the model weights conv_biases = conv_biases.view_as(conv.bias.data) #Finally copy the data conv.bias.data.copy_(conv_biases)

最後,我們載入卷積層的權重。

#Let us load the weights for the Convolutional layersnum_weights = conv.weight.numel()#Do the same as above for weightsconv_weights = torch.from_numpy(weights[ptr:ptr+num_weights])ptr = ptr + num_weightsconv_weights = conv_weights.view_as(conv.weight.data)conv.weight.data.copy_(conv_weights)

該函數的介紹到此為止,你現在可以通過調用 darknet 對象上的 load_weights 函數來載入 Darknet 對象中的權重。

model = Darknet("cfg/yolov3.cfg")model.load_weights("yolov3.weights")

通過模型構建和權重載入,我們終於可以開始進行目標檢測了。未來,我們還將介紹如何利用 objectness 置信度閾值和非極大值抑制生成最終的檢測結果。

原文鏈接:medium.com/paperspace/t


推薦閱讀:

總結近期CNN模型的發展(一)
關於 PyTorch 0.3.0 在Windows下的安裝和使用
深度學習入門該用PyTorch還是Keras?熱門公開課換框架背後的學問
深度學習新手必學:使用 Pytorch 搭建一個 N-Gram 模型
關於Windows PRs併入PyTorch的master分支

TAG:PyTorch | 人工智慧 |