從零開始PyTorch專案:YOLO v3目標檢測實現

機器之心發表於2018-04-23

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


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

本教程使用的程式碼需要執行在 Python 3.5 和 PyTorch 0.3 版本之上。你可以在以下連結中找到所有程式碼:

https://github.com/ayooshkathuria/YOLO_v3_tutorial_from_scratch 

本教程包含五個部分:

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 個網格。

從零開始PyTorch專案:YOLO v3目標檢測實現

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

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

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

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

錨點框(Anchor Box)

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

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

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

預測

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

從零開始PyTorch專案:YOLO v3目標檢測實現

中心座標

注意:我們使用 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 之間,有效確保中心處於執行預測的網格單元中。

邊界框的維度

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

從零開始PyTorch專案:YOLO v3目標檢測實現

檢測器輸出在最終預測之前的變換過程,圖源: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(不同尺度的錨點不同)。

從零開始PyTorch專案:YOLO v3目標檢測實現

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

輸出處理

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

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

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

從零開始PyTorch專案:YOLO v3目標檢測實現

實現

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

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

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

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

3. YOLO V3: An Incremental Improvement (https://pjreddie.com/media/files/papers/YOLOv3.pdf)

4. Convolutional Neural Networks (http://cs231n.github.io/convolutional-networks/)

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

6. IoU (https://www.youtube.com/watch?v=DNEm4fJ-rto)

7. Non maximum suppresion (https://www.youtube.com/watch?v=A46HZGR5fMw)

8. PyTorch Official Tutorial (http://pytorch.org/tutorials/beginner/deep_learning_60min_blitz.html)

第二部分:建立 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 資料夾下。

配置檔案下載:https://github.com/pjreddie/darknet/blob/master/cfg/yolov3.cfg

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

mkdir cfg
cd cfg
wget https://raw.githubusercontent.com/pjreddie/darknet/master/cfg/yolov3.cfg

如果你開啟配置檔案,你將看到如下一些網路架構:

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

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

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

[shortcut]
from=-3
activation=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,2
anchors = 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326
classes=80
num=9
jitter=.3
ignore_thresh = .5
truth_thresh = 1
random=1

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

Net

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

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

解析配置檔案

在開始之前,我們先在 darknet.py 檔案頂部新增必要的匯入項。

from __future__ import division

import torch 
import torch.nn as nn
import torch.nn.functional as F 
from torch.autograd import Variable
import 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('\n') # store the lines in a list
lines = [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 comments
lines = [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 it's 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 later
for 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 division

import torch 
import torch.nn as nn
import torch.nn.functional as F 
from torch.autograd import Variable
import numpy as np
import 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 函式把檢測特徵圖轉換成二維張量,張量的每一行對應邊界框的屬性,如下所示:

從零開始PyTorch專案:YOLO v3目標檢測實現

上述變換所使用的程式碼:

 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

也可以直接下載影象:https://github.com/ayooshkathuria/pytorch-yolo-v3/raw/master/dog-cycle-car.png

現在,在 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

也可以通過該地址下載:https://pjreddie.com/media/files/yolov3.weights

理解權重檔案

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

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

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

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

從零開始PyTorch專案:YOLO v3目標檢測實現

載入權重

我們寫一個函式來載入權重,它是 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 layers
num_weights = conv.weight.numel()

#Do the same as above for weights
conv_weights = torch.from_numpy(weights[ptr:ptr+num_weights])
ptr = ptr + num_weights

conv_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 置信度閾值和非極大值抑制生成最終的檢測結果。從零開始PyTorch專案:YOLO v3目標檢測實現

原文連結:https://medium.com/paperspace/tutorial-on-implementing-yolo-v3-from-scratch-in-pytorch-part-1-a0054d38ec78

相關文章