Faster R-CNN 主要分為兩個部分:
- RPN(Region Proposal Network)生成高質量的 region proposal;
- Fast R-CNN 利用 region proposal 做出檢測。
在論文中作者將 RPN 比作神經網絡的注意力機制("attention" mechanisms),告訴網絡看哪里。為了更好的理解,下面簡要的敘述論文的關鍵內容。
RPN
- Input:任意尺寸的圖像
- Output:一組帶有目標得分的目標矩形 proposals
為了生成 region proposals,在基網絡的最后一個卷積層 x
上滑動一個小網絡。該小網絡由一個 \(3\times 3\) 卷積 conv1
和一對兄弟卷積(並行的)\(1\times 1\) 卷積 loc
和 score
組成。其中,conv1
的參數 padding=1
,stride=1
以保證其不會改變輸出的特征圖的尺寸。loc
作為 box-regression 用來編碼 box 的坐標,score
作為 box-classifaction 用來編碼每個 proposal 是目標的概率。詳細內容見我的博客:我的目標檢測筆記。論文中把不同 scale 和 aspect ratio 的 \(k\) 個 reference boxes(參數化的 proposal) 稱作 anchors(錨點)。錨點是滑塊的中心。
為了更好的理解 anchors,下面以 Python 來展示其內涵。
錨點
首先利用COCO 數據集的使用中介紹的 API 來獲取一張 COCO 數據集的圖片及其標注。
先載入一些必備的包:
import cv2
from matplotlib import pyplot as plt
import numpy as np
# 載入 coco 相關 api
import sys
sys.path.append(r'D:\API\cocoapi\PythonAPI')
from pycocotools.dataset import Loader
%matplotlib inline
利用 Loader
載入 val2017 數據集,並選擇包含 'cat', 'dog', 'person' 的圖片:
dataType = 'val2017'
root = 'E:/Data/coco'
catNms = ['cat', 'dog', 'person']
annType = 'annotations_trainval2017'
loader = Loader(dataType, catNms, root, annType)
輸出結果:
Loading json in memory ...
used time: 0.762376 s
Loading json in memory ...
creating index...
index created!
used time: 0.401951 s
可以看出,Loader
載入數據的速度很快。為了更加詳細的查看 loader
,下面打印出現一些相關信息:
print(f'總共包含圖片 {len(loader)} 張')
for i, ann in enumerate(loader.images):
w, h = ann['height'], ann['width']
print(f'第 {i+1} 張圖片的高和寬分別為: {w, h}')
顯示:
總共包含圖片 2 張
第 1 張圖片的高和寬分別為: (612, 612)
第 2 張圖片的高和寬分別為: (500, 333)
下面以第 1 張圖片為例來探討 anchors。先可視化:
img, labels = loader[0]
plt.imshow(img);
輸出:
為了讓特征圖的尺寸大一點,可以將其 resize 為 (800, 800, 3):
img = cv2.resize(img, (800, 800))
print(img.shape)
輸出:
(800, 800, 3)
下面借助 MXNet 來完成接下來的代碼編程,為了適配 MXNet 需要將圖片由 (h, w, 3) 轉換為 (3, w, h) 形式。
img = img.transpose(2, 1, 0)
print(img.shape)
輸出:
(3, 800, 800)
由於卷積神經網絡的輸入是四維數據,故而,還需要:
img = np.expand_dims(img, 0)
print(img.shape)
輸出
(1, 3, 800, 800)
為了和論文一致,我們也采用 VGG16 網絡(載入 gluoncv中的權重):
from gluoncv.model_zoo import vgg16
net = vgg16(pretrained=True) # 載入權重
僅僅考慮直至最后一層卷積層(去除池化層)的網絡,下面查看網絡的各個卷積層的輸出情況:
from mxnet import nd
imgs = nd.array(img) # 轉換為 mxnet 的數據類型
x = imgs
for layer in net.features[:29]:
x = layer(x)
if "conv" in layer.name:
print(layer.name, x.shape) # 輸出該卷積層的 shape
結果為:
vgg0_conv0 (1, 64, 800, 800)
vgg0_conv1 (1, 64, 800, 800)
vgg0_conv2 (1, 128, 400, 400)
vgg0_conv3 (1, 128, 400, 400)
vgg0_conv4 (1, 256, 200, 200)
vgg0_conv5 (1, 256, 200, 200)
vgg0_conv6 (1, 256, 200, 200)
vgg0_conv7 (1, 512, 100, 100)
vgg0_conv8 (1, 512, 100, 100)
vgg0_conv9 (1, 512, 100, 100)
vgg0_conv10 (1, 512, 50, 50)
vgg0_conv11 (1, 512, 50, 50)
vgg0_conv12 (1, 512, 50, 50)
由此,可以看出尺寸為 (800, 800) 的原圖變為了 (50, 50) 的特征圖(比原來縮小了 16 倍)。
感受野
上面的 16 不僅僅是針對尺寸為 (800, 800),它適用於任意尺寸的圖片,因為 16 是特征圖的一個像素點的感受野(receptive field )。
感受野的大小是如何計算的?我們回憶卷積運算的過程,便可發現感受野的計算恰恰是卷積計算的逆過程(參考感受野計算[1])。
記 \(F_k, S_k, P_k\) 分別表示第 \(k\) 層的卷積核的高(或者寬)、移動步長(stride)、Padding 個數;記 \(i_k\) 表示第 \(k\) 層的輸出特征圖的高(或者寬)。這樣,很容易得出如下遞推公式:
其中 \(k \in \{1, 2, \cdots\}\),且 \(i_0\) 表示原圖的高或者寬。令 \(t_k = \frac{F_k - 1}{2} - P_k\),上式可以轉換為
反推感受野, 令 \(i_1 = F_1\), 且\(t_k = \frac{F_k -1}{2} - P_k\), 且 \(1\leq j \leq L\), 則有
其中 \(\alpha_L = \prod_{p=1}^{L}S_p\),且有:
由於 VGG16 的卷積核的配置均是 kernel_size=(3, 3), padding=(1, 1),同時只有在經過池化層才使得 \(S_j = 2\),故而 \(\beta_j = 0\),且有 \(\alpha_L = 2^4 = 16\)。
錨點的計算
在編程實現的時候,將感受野的大小使用 base_size
來表示。下面我們討論如何生成錨框?為了計算的方便,先定義一個 Box
:
import numpy as np
class Box:
'''
corner: Numpy, List, Tuple, MXNet.nd, rotch.tensor
'''
def __init__(self, corner):
self._corner = corner
@property
def corner(self):
return self._corner
@corner.setter
def corner(self, new_corner):
self._corner = new_corner
@property
def w(self):
'''
計算 bbox 的 寬
'''
return self.corner[2] - self.corner[0] + 1
@property
def h(self):
'''
計算 bbox 的 高
'''
return self.corner[3] - self.corner[1] + 1
@property
def area(self):
'''
計算 bbox 的 面積
'''
return self.w * self.h
@property
def whctrs(self):
'''
計算 bbox 的 中心坐標
'''
assert isinstance(self.w, (int, float)), 'need int or float'
xctr = self.corner[0] + (self.w - 1) * .5
yctr = self.corner[1] + (self.h - 1) * .5
return xctr, yctr
def __and__(self, other):
'''
運算符:&,實現兩個 box 的交集運算
'''
xmin = max(self.corner[0], other.corner[0]) # xmin 中的大者
xmax = min(self.corner[2], other.corner[2]) # xmax 中的小者
ymin = max(self.corner[1], other.corner[1]) # ymin 中的大者
ymax = min(self.corner[3], other.corner[3]) # ymax 中的小者
w = xmax - xmin
h = ymax - ymin
if w < 0 or h < 0: # 兩個邊界框沒有交集
return 0
else:
return w * h
def __or__(self, other):
'''
運算符:|,實現兩個 box 的並集運算
'''
I = self & other
if I == 0:
return 0
else:
return self.area + other.area - I
def IoU(self, other):
'''
計算 IoU
'''
I = self & other
if I == 0:
return 0
else:
U = self | other
return I / U
類 Box 實現了 bbox 的交集、並集運算以及 IoU 的計算。下面舉一個例子來說明:
bbox = [0, 0, 15, 15] # 邊界框
bbox1 = [5, 5, 12, 12] # 邊界框
A = Box(bbox) # 一個 bbox 實例
B = Box(bbox1) # 一個 bbox 實例
下面便可以輸出 A 與 B 的高寬、中心、面積、交集、並集、Iou:
print('A 與 B 的交集', str(A & B))
print('A 與 B 的並集', str(A | B))
print('A 與 B 的 IoU', str(A.IoU(B)))
print(u'A 的中心、高、寬以及面積', str(A.whctrs), A.h, A.w, A.area)
輸出結果:
A 與 B 的交集 49
A 與 B 的並集 271
A 與 B 的 IoU 0.18081180811808117
A 的中心、高、寬以及面積 (7.5, 7.5) 16 16 256
下面重新考慮 loader。首先定義一個轉換函數:
def getX(img):
# 將 img (h, w, 3) 轉換為 (1, 3, w, h)
img = img.transpose((2, 1, 0))
return np.expand_dims(img, 0)
函數 getX
將圖片由 (h, w, 3) 轉換為 (1, 3, w, h):
img, label = loader[0]
img = cv2.resize(img, (800, 800)) # resize 為 800 x 800
X = getX(img) # 轉換為 (1, 3, w, h)
img.shape, X.shape
輸出結果:
((800, 800, 3), (1, 3, 800, 800))
與此同時,獲取特征圖的數據:
features = net.features[:29]
F = features(imgs)
F.shape
輸出:
(1, 512, 50, 50)
接着需要考慮如何將特征圖 F 映射回原圖?
全卷積(FCN):將錨點映射回原圖
faster R-CNN 中的 FCN 僅僅是有着 FCN 的特性,並不是真正意義上的卷積。faster R-CNN 僅僅是借用了 FCN 的思想來實現將特征圖映射回原圖的目的,同時將輸出許多錨框。
特征圖上的 1 個像素點的感受野為 \(16\times 16\),換言之,特征圖上的錨點映射回原圖的感受區域為 \(16 \times 16\),論文稱其為 reference box。下面相對於 reference box 依據不同的尺度與高寬比例來生成不同的錨框。
base_size = 2**4 # 特征圖的每個像素的感受野大小
scales = [8, 16, 32] # 錨框相對於 reference box 的尺度
ratios = [0.5, 1, 2] # reference box 與錨框的高寬的比率(aspect ratios)
其實 reference box 也對應於論文描述的 window(滑動窗口),這個之后再解釋。我們先看看 scales 與 ratios 的具體含義。
為了更加一般化,假設 reference box 圖片高寬分別為 \(h, w\),而錨框的高寬分別為 \(h_1, w_1\),形式化 scales 與 ratios 為公式 1:
可以將上式轉換為公式 2:
同樣可以轉換為公式3:
基於公式 2 與公式 3 均可以很容易計算出 \(w_1,h_1\). 一般地,\(w=h\),公式 3 亦可以轉換為公式 4:
gluoncv 結合公式 4 來編程,本文依據 3 進行編程。無論原圖的尺寸如何,特征圖的左上角第一個錨點映射回原圖后的 reference box 的 bbox = (xmain, ymin, xmax, ymax) 均為 (0, 0, bas_size-1, base_size-1),為了方便稱呼,我們稱其為 base_reference box。基於 base_reference box 依據不同的 s 與 r 的組合生成不同尺度和高寬比的錨框,且稱其為 base_anchors。編程實現:
class MultiBox(Box):
def __init__(self, base_size, ratios, scales):
if not base_size:
raise ValueError("Invalid base_size: {}.".format(base_size))
if not isinstance(ratios, (tuple, list)):
ratios = [ratios]
if not isinstance(scales, (tuple, list)):
scales = [scales]
super().__init__([0]*2+[base_size-1]*2) # 特征圖的每個像素的感受野大小為 base_size
# reference box 與錨框的高寬的比率(aspect ratios)
self._ratios = np.array(ratios)[:, None]
self._scales = np.array(scales) # 錨框相對於 reference box 的尺度
@property
def base_anchors(self):
ws = np.round(self.w / np.sqrt(self._ratios))
w = ws * self._scales
h = w * self._ratios
wh = np.stack([w.flatten(), h.flatten()], axis=1)
wh = (wh - 1) * .5
return np.concatenate([self.whctrs - wh, self.whctrs + wh], axis=1)
def _generate_anchors(self, stride, alloc_size):
# propagete to all locations by shifting offsets
height, width = alloc_size # 特征圖的尺寸
offset_x = np.arange(0, width * stride, stride)
offset_y = np.arange(0, height * stride, stride)
offset_x, offset_y = np.meshgrid(offset_x, offset_y)
offsets = np.stack((offset_x.ravel(), offset_y.ravel(),
offset_x.ravel(), offset_y.ravel()), axis=1)
# broadcast_add (1, N, 4) + (M, 1, 4)
anchors = (self.base_anchors.reshape(
(1, -1, 4)) + offsets.reshape((-1, 1, 4)))
anchors = anchors.reshape((1, 1, height, width, -1)).astype(np.float32)
return anchors
下面看看具體效果:
base_size = 2**4 # 特征圖的每個像素的感受野大小
scales = [8, 16, 32] # 錨框相對於 reference box 的尺度
ratios = [0.5, 1, 2] # reference box 與錨框的高寬的比率(aspect ratios)
A = MultiBox(base_size,ratios, scales)
A.base_anchors
輸出結果:
array([[ -84., -38., 99., 53.],
[-176., -84., 191., 99.],
[-360., -176., 375., 191.],
[ -56., -56., 71., 71.],
[-120., -120., 135., 135.],
[-248., -248., 263., 263.],
[ -36., -80., 51., 95.],
[ -80., -168., 95., 183.],
[-168., -344., 183., 359.]])
接着考慮將 base_anchors 在整個原圖上進行滑動。比如,特征圖的尺寸為 (5, 5) 而感受野的大小為 50,則 base_reference box 在原圖滑動的情況(移動步長為 50)如下圖:
x, y = np.mgrid[0:300:50, 0:300:50]
plt.pcolor(x, y, x+y); # x和y是網格,z是(x,y)坐標處的顏色值colorbar()
輸出結果:
原圖被划分為了 25 個 block,每個 block 均代表一個 reference box。若 base_anchors 有 9 個,則只需要按照 stride = 50 進行滑動便可以獲得這 25 個 block 的所有錨框(總計 5x5x9=225 個)。針對前面的特征圖 F 有:
stride = 16 # 滑動的步長
alloc_size = F.shape[2:] # 特征圖的尺寸
A._generate_anchors(stride, alloc_size).shape
輸出結果:
(1, 1, 50, 50, 36)
即總共 \(50\times 50 \times 9=22500\) 個錨點(anchors 數量龐大且必然有許多的高度重疊的框。)。至此,我們生成初始錨框的過程便結束了,同時很容易發現,anchors 的生成僅僅借助 Numpy 便完成了,這樣做十分有利於代碼遷移到 Pytorch、TensorFlow 等支持 Numpy 作為輸入的框架。下面僅僅考慮 MXNet,其他框架以后再討論。下面先看看 MultiBox 的設計對於使用 MXNet 進行后續的開發有什么好處吧!
由於 base-net (基網絡)的結構一經確定便是是固定的,針對不同尺寸的圖片,如果每次生成 anchors 都要重新調用 A._generate_anchors() 一次,那么將會產生很多的不必要的冗余計算,gluoncv 提供了一種十分不錯的思路:先生成 base_anchors,然后選擇一個比較大的尺度 alloc_size(比如 \(128\times 128\))用來生成錨框的初選模板;接着把真正的特征圖傳入到 RPNAnchorGenerator 並通過前向傳播計算得到特征圖的錨框。具體的操作細節見如下代碼:
class RPNAnchorGenerator(gluon.HybridBlock):
r"""生成 RPN 的錨框
參數
----------
stride : int
特征圖相對於原圖的滑動步長,或是說是特征圖上單個像素點的感受野。
base_size : int
reference anchor box 的寬或者高
ratios : iterable of float
anchor boxes 的 aspect ratios(高寬比)。我們期望它是 tuple 或者 list
scales : iterable of float
錨框相對於 reference anchor boxes 的尺度
采用如下形式計算錨框的高和寬:
.. math::
width_{anchor} = size_{base} \times scale \times \sqrt{ 1 / ratio}
height_{anchor} = width_{anchor} \times ratio
alloc_size : tuple of int
預設錨框的尺寸為 (H, W),通常用來生成比較大的特征圖(如 128x128)。
在推斷的后期, 我們可以有可變的輸入大小, 在這個時候, 我們可以從這個大的 anchor map 中直接裁剪出對應的 anchors, 以便我們可以避免在每次輸入都要重新生成錨點。
"""
def __init__(self, alloc_size, base_size, ratios, scales, **kwargs):
super().__init__(**kwargs)
# 生成錨框初選模板,之后通過切片獲取特征圖的真正錨框
anchors = MultiBox(base_size, ratios, scales)._generate_anchors(
base_size, alloc_size)
self.anchors = self.params.get_constant('anchor_', anchors)
# pylint: disable=arguments-differ
def hybrid_forward(self, F, x, anchors):
"""Slice anchors given the input image shape.
Inputs:
- **x**: input tensor with (1 x C x H x W) shape.
Outputs:
- **out**: output anchor with (1, N, 4) shape. N is the number of anchors.
"""
a = F.slice_like(anchors, x * 0, axes=(2, 3))
return a.reshape((1, -1, 4))
上面的 RPNAnchorGenerator 直接改寫自 gluoncv。看看 RPNAnchorGenerator 的魅力所在:
base_size = 2**4 # 特征圖的每個像素的感受野大小
scales = [8, 16, 32] # 錨框相對於 reference box 的尺度
ratios = [0.5, 1, 2] # reference box 與錨框的高寬的比率(aspect ratios)
stride = base_size # 在原圖上滑動的步長
alloc_size = (128, 128) # 一個比較大的特征圖的錨框生成模板
# 調用 RPNAnchorGenerator 生成 anchors
A = RPNAnchorGenerator(alloc_size, base_size, ratios, scales)
A.initialize()
A(F) # 直接傳入特征圖 F,獲取 F 的 anchors
輸出結果:
[[[ -84. -38. 99. 53.]
[-176. -84. 191. 99.]
[-360. -176. 375. 191.]
...
[ 748. 704. 835. 879.]
[ 704. 616. 879. 967.]
[ 616. 440. 967. 1143.]]]
<NDArray 1x22500x4 @cpu(0)>
shape = 1x22500x4 符合我們的預期。如果我們更改特征圖的尺寸:
x = nd.zeros((1, 3, 75, 45))
A(x).shape
輸出結果:
(1, 30375, 4)
這里 \(30375 = 75 \times 45 \times 9\) 也符合我們的預期。
至此,我們完成了將特征圖上的所有錨點映射回原圖生成錨框的工作!
平移不變性的錨點
反觀上述的編程實現,很容易便可理解論文提到的錨點的平移不變性。無論是錨點的生成還是錨框的生成都是基於 base_reference box 進行平移和卷積運算(亦可看作是一種線性變換)的。為了敘述方便下文將 RPNAnchorGenerator(被放置在 app/detection/anchor.py) 生成的 anchor boxes 由 corner(記作 \(A\) 坐標形式:(xmin,ymin,xmax,ymax))轉換為 center(形式為:(xctr,yctr,w,h))后的錨框記作 \(B\)。其中(xmin,ymin),(xmax,ymax) 分別表示錨框的最小值與最大值坐標;(xctr,yctr) 表示錨框的中心坐標,w,h 表示錨框的寬和高。且記 \(a = (x_a,y_a,w_a,h_a) \in B\),即使用下標 \(a\) 來標識錨框。\(A\) 與 \(B\) 是錨框的兩種不同的表示形式。
在 gluoncv.nn.bbox
中提供了將 \(A\) 轉換為 \(B\) 的模塊:BBoxCornerToCenter
。下面便利用其進行編程。先載入環境:
cd ../app/
接着載入本小節所需的包:
from mxnet import init, gluon, autograd
from mxnet.gluon import nn
from gluoncv.nn.bbox import BBoxCornerToCenter
# 自定義包
from detection.bbox import MultiBox
from detection.anchor import RPNAnchorGenerator
為了更加容易理解 \(A\) 與 \(B\) 的處理過程,下面先自創一個類(之后會拋棄):
class RPNProposal(nn.HybridBlock):
def __init__(self, channels, stride, base_size, ratios, scales, alloc_size, **kwargs):
super().__init__(**kwargs)
weight_initializer = init.Normal(0.01)
with self.name_scope():
self.anchor_generator = RPNAnchorGenerator(
stride, base_size, ratios, scales, alloc_size)
anchor_depth = self.anchor_generator.num_depth
# conv1 的創建
self.conv1 = nn.HybridSequential()
self.conv1.add(nn.Conv2D(channels, 3, 1, 1,
weight_initializer=weight_initializer))
self.conv1.add(nn.Activation('relu'))
# score 的創建
# use sigmoid instead of softmax, reduce channel numbers
self.score = nn.Conv2D(anchor_depth, 1, 1, 0,
weight_initializer=weight_initializer)
# loc 的創建
self.loc = nn.Conv2D(anchor_depth * 4, 1, 1, 0,
weight_initializer=weight_initializer)
# 具體的操作如下
channels = 256
base_size = 2**4 # 特征圖的每個像素的感受野大小
scales = [8, 16, 32] # 錨框相對於 reference box 的尺度
ratios = [0.5, 1, 2] # reference box 與錨框的高寬的比率(aspect ratios)
stride = base_size # 在原圖上滑動的步長
alloc_size = (128, 128) # 一個比較大的特征圖的錨框生成模板
alloc_size = (128, 128) # 一個比較大的特征圖的錨框生成模板
self = RPNProposal(channels, stride, base_size, ratios, scales, alloc_size)
self.initialize()
下面我們便可以看看如何將 \(A\) 轉換為 \(B\):
img, label = loader[0] # 載入圖片和標注信息
img = cv2.resize(img, (800, 800)) # resize 為 (800,800)
imgs = nd.array(getX(img)) # 轉換為 MXNet 的輸入形式
xs = features(imgs) # 獲取特征圖張量
F = nd
A = self.anchor_generator(xs) # (xmin,ymin,xmax,ymax) 形式的錨框
box_to_center = BBoxCornerToCenter()
B = box_to_center(A) # (x,y,w,h) 形式的錨框
邊界框回歸
手動設計的錨框 \(B\) 並不能很好的滿足后續的 Fast R-CNN 的檢測工作,還需要借助論文介紹的 3 個卷積層:conv1、score、loc。對於論文中的 \(3 \times 3\) 的卷積核 conv1 我的理解是模擬錨框的生成過程:通過不改變原圖尺寸的卷積運算達到降維的目標,同時有着在原圖滑動尺寸為 base_size 的 reference box 的作用。換言之,conv1 的作用是模擬生成錨點。假設通過 RPN 生成的邊界框 bbox 記為 \(G=\{p:(x,y,w,h)\}\),利用 \(1\times 1\) 卷積核 loc 預測 \(p\) 相對於每個像素點(即錨點)所生成的 \(k\) 個錨框的中心坐標與高寬的偏移量,利用 \(1\times 1\) 卷積核 score 判別錨框是目標(objectness, foreground)還是背景(background)。記真實的邊界框集合為 \(G^* = \{p^*:(x^*,y^*,w^*,h^*)\}\)。其中,\((x,y), (x^*,y^*)\) 分別代表預測邊界框、真實邊界框的中心坐標;\((w, h), (w^*, h^*)\) 分別代表預測邊界框、真實邊界框的的寬與高。論文在 Training RPNs 中提到,在訓練階段 conv1、loc、score 使用均值為 \(0\),標准差為 \(0.01\) 的高斯分布來隨機初始化。
接着,看看如何使用 conv1、loc、score:
x = self.conv1(xs)
# score 的輸出
raw_rpn_scores = self.score(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1,1))
rpn_scores = F.sigmoid(F.stop_gradient(raw_rpn_scores)) # 轉換為概率形式
# loc 的輸出
rpn_box_pred = self.loc(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1, 4))
卷積核 loc 的作用是用來學習偏移量的,在論文中給出了如下公式:
這樣可以很好的消除圖像尺寸的不同帶來的影響。為了使得修正后的錨框 G 具備與真實邊界框 \(G^*\) 有相同的均值和標准差,還需要設定:\(\sigma = (\sigma_x, \sigma_y, \sigma_w, \sigma_h), \mu = (\mu_x,\mu_y,\mu_w,\mu_h)\) 表示 G 的 (x, y, w, h) 對應的標准差與均值。故而,為了讓預測的邊界框的的偏移量的分布更加均勻還需要將坐標轉換一下:
對於 \(G^*\) 也是一樣的操作。(略去)一般地,\(\sigma = (0.1, 0.1, 0.2, 0.2), \mu = (0, 0, 0, 0)\)。
由於 loc 的輸出便是 \(\{(t_x, t_y, t_w, t_h)\}\),下面我們需要反推 \((x, y, w, h)\):
通常情況下,\(A\) 形式的邊界框轉換為 \(B\) 形式的邊界框被稱為編碼(encode),反之,則稱為解碼(decode)。在 gluoncv.nn.coder
中的 NormalizedBoxCenterDecoder
類實現了上述的轉換過程,同時也完成了 \(G\) 解碼工作。
from gluoncv.nn.coder import NormalizedBoxCenterDecoder
stds = (0.1, 0.1, 0.2, 0.2)
means = (0., 0., 0., 0.)
box_decoder = NormalizedBoxCenterDecoder(stds, means)
roi = box_decoder(rpn_box_pred, B) # 解碼后的 G
裁剪預測邊界框超出原圖邊界的邊界
為了保持一致性,需要重寫 getX
:
def getX(img):
# 將 img (h, w, 3) 轉換為 (1, 3, h, w)
img = img.transpose((2, 0, 1))
return np.expand_dims(img, 0)
考慮到 RPN 的輸入可以是批量數據:
imgs = []
labels = []
for img, label in loader:
img = cv2.resize(img, (600, 800)) # resize 寬高為 (600,800)
imgs.append(getX(img))
labels.append(label)
imgs = nd.array(np.concatenate(imgs)) # 一個批量的圖片
labels = nd.array(np.stack(labels)) # 一個批量的標注信息
這樣便有:
from gluoncv.nn.coder import NormalizedBoxCenterDecoder
from gluoncv.nn.bbox import BBoxCornerToCenter
stds = (0.1, 0.1, 0.2, 0.2)
means = (0., 0., 0., 0.)
fs = features(imgs) # 獲取特征圖張量
F = nd
A = self.anchor_generator(fs) # (xmin,ymin,xmax,ymax) 形式的錨框
box_to_center = BBoxCornerToCenter()
B = box_to_center(A) # (x,y,w,h) 形式的錨框
x = self.conv1(fs) # conv1 卷積
raw_rpn_scores = self.score(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1,1)) # 激活之前的 score
rpn_scores = F.sigmoid(F.stop_gradient(raw_rpn_scores)) # 激活后的 score
rpn_box_pred = self.loc(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1, 4)) # loc 預測偏移量 (tx,ty,tw,yh)
box_decoder = NormalizedBoxCenterDecoder(stds, means)
roi = box_decoder(rpn_box_pred, B) # 解碼后的 G
print(roi.shape)
此時,便有兩張圖片的預測 G:
(2, 16650, 4)
因為此時生成的 RoI 有許多超出邊界的框,所以,需要進行裁減操作。先裁剪掉所有小於 \(0\) 的邊界:
x = F.maximum(roi, 0.0)
nd.maximum(x)
的作用是 \(\max\{0, x\}\)。接下來裁剪掉大於原圖的邊界的邊界:
shape = F.shape_array(imgs) # imgs 的 shape
size = shape.slice_axis(axis=0, begin=2, end=None) # imgs 的尺寸
window = size.expand_dims(0)
window
輸出結果:
[[800 600]]
<NDArray 1x2 @cpu(0)>
此時是 (高, 寬) 的形式,而錨框的是以 (寬, 高) 的形式生成的,故而還需要:
F.reverse(window, axis=1)
結果:
[[600 800]]
<NDArray 1x2 @cpu(0)>
因而,下面的 m
可以用來判斷是否超出邊界:
m = F.tile(F.reverse(window, axis=1), reps=(2,)).reshape((0, -4, 1, -1))
m
結果:
[[[600 800 600 800]]]
<NDArray 1x1x4 @cpu(0)>
接着,便可以獲取裁剪之后的 RoI:
roi = F.broadcast_minimum(x, F.cast(m, dtype='float32'))
整個裁剪工作可以通過如下操作簡單實現:
from gluoncv.nn.bbox import BBoxClipToImage
clipper = BBoxClipToImage()
roi = clipper(roi, imgs) # 裁剪超出邊界的邊界
移除小於 min_size 的邊界框
移除小於 min_size 的邊界框進一步篩選邊界框:
min_size = 5 # 最小錨框的尺寸
xmin, ymin, xmax, ymax = roi.split(axis=-1, num_outputs=4) # 拆分坐標
width = xmax - xmin # 錨框寬度的集合
height = ymax - ymin # # 錨框高度的集合
invalid = (width < min_size) + (height < min_size) # 所有小於 min_size 的高寬
由於張量的 <
運算有一個特性:滿足條件的設置為 1
,否則為 0
。這樣一來兩個這樣的運算相加便可篩選出同時不滿足條件的對象:
cond = invalid[0,:10]
cond.T
結果:
[[1. 0. 0. 0. 1. 0. 1. 1. 0. 2.]]
<NDArray 1x10 @cpu(0)>
可以看出有 2
存在,代表着兩個條件都滿足,我們可以做篩選如下:
F.where(cond, F.ones_like(cond)* -1, rpn_scores[0,:10]).T
結果:
[[-1. 0.999997 0.0511509 0.9994136 -1. 0.00826993 -1. -1. 0.99783903 -1.]]
<NDArray 1x10 @cpu(0)>
由此可以篩選出所有不滿足條件的對象。更進一步,篩選出所有不滿足條件的對象:
score = F.where(invalid, F.ones_like(invalid) * -1, rpn_scores) # 篩選 score
invalid = F.repeat(invalid, axis=-1, repeats=4)
roi = F.where(invalid, F.ones_like(invalid) * -1, roi) # 篩選 RoI
NMS (Non-maximum suppression)
我們先總結 RPN 的前期工作中 Proposal 的生成階段:
- 利用 base_net 獲取原圖 \(I\) 對應的特征圖 \(X\);
- 依據 base_net 的網絡結構計算特征圖的感受野大小為 base_size;
- 依據不同的 scale 和 aspect ratio 通過 MultiBox 計算特征圖 \(X\) 在 \((0,0)\) 位置的錨點對應的 \(k\) 個錨框 base_anchors;
- 通過 RPNAnchorGenerator 將 \(X\) 映射回原圖並生成 corner 格式 (xmin,ymin,xmax,ymax) 的錨框 \(A\);
- 將錨框 \(A\) 轉換為 center 格式 \((x,y,w,h)\),記作 \(B\);
- \(X\) 通過卷積 conv1 獲得模擬錨點,亦稱之為 rpn_features;
- 通過卷積層 score 獲取 rpn_features 的得分 rpn_score;
- 與 7 並行的通過卷積層 loc 獲取 rpn_features 的邊界框回歸的偏移量 rpn_box_pred;
- 依據 rpn_box_pred 修正錨框 \(B\) 並將其解碼為 \(G\);
- 裁剪掉 \(G\) 的超出原圖尺寸的邊界,並移除小於 min_size 的邊界框。
雖然上面的步驟移除了許多無效的邊界並裁剪掉超出原圖尺寸的邊界,但是,可以想象到 \(G\) 中必然存在許多的高度重疊的邊界框,此時若將 \(G\) 當作 Region Proposal 送入 PoI Pooling 層將給計算機帶來十分龐大的負載,並且 \(G\) 中的背景框遠遠多於目標極為不利於模型的訓練。論文中給出了 NMS 的策略來解決上述難題。根據我們預測的 rpn_score,對 G 進行非極大值抑制操作(NMS),去除得分較低以及重復區域的 RoI。在 MXNet 提供了 nd.contrib.box_nms
來實現此次目標:
nms_thresh = 0.7
n_train_pre_nms = 12000 # 訓練時 nms 之前的 bbox 的數目
n_train_post_nms = 2000 # 訓練時 nms 之后的 bbox 的數目
n_test_pre_nms = 6000 # 測試時 nms 之前的 bbox 的數目
n_test_post_nms = 300 # 測試時 nms 之后的 bbox 的數目
pre = F.concat(scores, rois, dim=-1) # 合並 score 與 roi
# 非極大值抑制
tmp = F.contrib.box_nms(pre, overlap_thresh=nms_thresh, topk=n_train_pre_nms,
coord_start=1, score_index=0, id_index=-1, force_suppress=True)
# slice post_nms number of boxes
result = F.slice_axis(tmp, axis=1, begin=0, end=n_train_post_nms)
rpn_scores = F.slice_axis(result, axis=-1, begin=0, end=1)
rpn_bbox = F.slice_axis(result, axis=-1, begin=1, end=None)
上述的封裝比較徹底,無法獲取具體的實現,並且也不利於我們理解 NMS 的具體實現原理。為了更好的理解 NMS,自己重新實現 NMS 是十分有必要的。
將 scores 按照從大到小進行排序,並返回其索引:
scores = scores.flatten() # 去除無效維度
# 將 scores 按照從大到小進行排序,並返回其索引
orders = scores.argsort()[:,::-1]
由於 loc 生成的錨框實在是太多了,為此,僅僅考慮得分前 n_train_pre_nms 的錨框:
keep = orders[:,:n_train_pre_nms]
下面先考慮一張圖片,之后再考慮多張圖片一起訓練的情況:
order = keep[0] # 第一張圖片的得分降序索引
score = scores[0][order] # 第一張圖片的得分預測降序排列
roi = rois[0][order] # 第一張圖片的邊界框預測按得分降序排列
label = labels[0] # 真實邊界框
雖然 Box 支持 nd 作為輸入,但是計算多個 IoU 效率並不高:
%%timeit
GT = [Box(corner) for corner in label] # 真實邊界框實例化
G = [Box(corner) for corner in roi] # 預測邊界框實例化
ious = nd.zeros((len(G), len(GT))) # 初始化 IoU 的計算
for i, A in enumerate(G):
for j, B in enumerate(GT):
iou = A.IoU(B)
ious[i, j] = iou
輸出計時結果:
1min 10s ± 6.08 s per loop (mean ± std. dev. of 7 runs, 1 loop each)
先轉換為 Numpy 再計算 IoU 效率會更高:
%%timeit
GT = [Box(corner) for corner in label.asnumpy()] # 真實邊界框實例化
G = [Box(corner) for corner in roi.asnumpy()] # 預測邊界框實例化
ious = nd.zeros((len(G), len(GT))) # 初始化 IoU 的計算
for i, A in enumerate(G):
for j, B in enumerate(GT):
iou = A.IoU(B)
ious[i, j] = iou
輸出計時結果:
6.88 s ± 410 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
比使用 nd 快了近 10 倍!但是如果全部使用 Numpy,會有什么變化?
%%timeit
GT = [Box(corner) for corner in label.asnumpy()] # 真實邊界框實例化
G = [Box(corner) for corner in roi.asnumpy()] # 預測邊界框實例化
ious = np.zeros((len(G), len(GT))) # 初始化 IoU 的計算
for i, A in enumerate(G):
for j, B in enumerate(GT):
iou = A.IoU(B)
ious[i, j] = iou
輸出計時結果:
796 ms ± 30.5 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)
速度又提升了 10 倍左右!為此,后續的與 IoU 相關的計算我們僅僅考慮使用 Numpy 來計算。將其封裝進 group_ious 函數:
def group_ious(pred_bbox, true_bbox):
# 計算 pred_bbox 與 true_bbox 的 IoU 組合
GT = [Box(corner) for corner in true_bbox] # 真實邊界框實例化
G = [Box(corner) for corner in pred_bbox] # 預測邊界框實例化
ious = np.zeros((len(G), len(GT))) # 初始化 IoU 的計算
for i, A in enumerate(G):
for j, B in enumerate(GT):
iou = A.IoU(B)
ious[i, j] = iou
return ious
重構代碼
前面的代碼有點混亂,為了后續開發的便利,我們先重新整理一下代碼。先僅僅考慮一張圖片,下面先載入設置 RPN 網絡的輸入以及部分輸出:
# PRN 前期的設定
channels = 256 # conv1 的輸出通道數
base_size = 2**4 # 特征圖的每個像素的感受野大小
scales = [8, 16, 32] # 錨框相對於 reference box 的尺度
ratios = [0.5, 1, 2] # reference box 與錨框的高寬的比率(aspect ratios)
stride = base_size # 在原圖上滑動的步長
alloc_size = (128, 128) # 一個比較大的特征圖的錨框生成模板
# 用來輔助理解 RPN 的類
self = RPNProposal(channels, stride, base_size, ratios, scales, alloc_size)
self.initialize() # 初始化卷積層 conv1, loc, score
stds = (0.1, 0.1, 0.2, 0.2) # 偏移量的標准差
means = (0., 0., 0., 0.) # 偏移量的均值
# 錨框的編碼
box_to_center = BBoxCornerToCenter() # 將 (xmin,ymin,xmax,ymax) 轉換為 (x,y,w,h)
# 將錨框通過偏移量進行修正,並解碼為 (xmin,ymin,xmax,ymax)
box_decoder = NormalizedBoxCenterDecoder(stds, means)
clipper = BBoxClipToImage() # 裁剪超出原圖尺寸的邊界
# 獲取 COCO 的一張圖片用來做實驗
img, label = loader[0] # 獲取一張圖片
img = cv2.resize(img, (800, 800)) # resize 為 (800, 800)
imgs = nd.array(getX(img)) # 轉換為 MXNet 的輸入形式
# 提取最后一層卷積的特征
net = vgg16(pretrained=True) # 載入基網絡的權重
features = net.features[:29] # 卷積層特征提取器
fs = features(imgs) # 獲取特征圖張量
A = self.anchor_generator(fs) # 生成 (xmin,ymin,xmax,ymax) 形式的錨框
B = box_to_center(A) # 編碼 為(x,y,w,h) 形式的錨框
x = self.conv1(fs) # conv1 卷積
# sigmoid 激活之前的 score
raw_rpn_scores = self.score(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1, 1))
rpn_scores = nd.sigmoid(nd.stop_gradient(raw_rpn_scores)) # 激活后的 score
# loc 預測偏移量 (tx,ty,tw,yh)
rpn_box_pred = self.loc(x).transpose(axes=(0, 2, 3, 1)).reshape((0, -1, 4))
# 修正錨框的坐標
roi = box_decoder(rpn_box_pred, B) # 解碼后的預測邊界框 G(RoIs)
print(roi.shape) # 裁剪之前
roi = clipper(roi, imgs) # 裁剪超出原圖尺寸的邊界
雖然,roi 已經裁剪掉超出原圖尺寸的邊界,但是還有一部分邊界框實在有點兒小,不利於后續的訓練,故而需要丟棄。丟棄的方法是將其邊界框與得分均設置為 \(-1\):
def size_control(F, min_size, rois, scores):
# 拆分坐標
xmin, ymin, xmax, ymax = rois.split(axis=-1, num_outputs=4)
width = xmax - xmin # 錨框寬度的集合
height = ymax - ymin # # 錨框高度的集合
# 獲取所有小於 min_size 的高寬
invalid = (width < min_size) + (height < min_size) # 同時滿足條件
# 將不滿足條件的錨框的坐標與得分均設置為 -1
scores = F.where(invalid, F.ones_like(invalid) * -1, scores)
invalid = F.repeat(invalid, axis=-1, repeats=4)
rois = F.where(invalid, F.ones_like(invalid) * -1, rois)
return rois, scores
min_size = 16 # 最小錨框的尺寸
pre_nms = 12000 # nms 之前的 bbox 的數目
post_nms = 2000 # ms 之后的 bbox 的數目
rois, scores = size_control(nd, min_size, roi, rpn_scores)
為了可以讓 Box 一次計算多個 bbox 的 IoU,下面需要重新改寫 Box:
class BoxTransform(Box):
'''
一組 bbox 的運算
'''
def __init__(self, F, corners):
'''
F 可以是 mxnet.nd, numpy, torch.tensor
'''
super().__init__(corners)
self.corner = corners.T
self.F = F
def __and__(self, other):
r'''
運算符 `&` 交集運算
'''
xmin = self.F.maximum(self.corner[0].expand_dims(
0), other.corner[0].expand_dims(1)) # xmin 中的大者
xmax = self.F.minimum(self.corner[2].expand_dims(
0), other.corner[2].expand_dims(1)) # xmax 中的小者
ymin = self.F.maximum(self.corner[1].expand_dims(
0), other.corner[1].expand_dims(1)) # ymin 中的大者
ymax = self.F.minimum(self.corner[3].expand_dims(
0), other.corner[3].expand_dims(1)) # ymax 中的小者
w = xmax - xmin
h = ymax - ymin
cond = (w <= 0) + (h <= 0)
I = self.F.where(cond, nd.zeros_like(cond), w * h)
return I
def __or__(self, other):
r'''
運算符 `|` 並集運算
'''
I = self & other
U = self.area.expand_dims(0) + other.area.expand_dims(1) - I
return self.F.where(U < 0, self.F.zeros_like(I), U)
def IoU(self, other):
'''
交並比
'''
I = self & other
U = self | other
return self.F.where(U == 0, self.F.zeros_like(I), I / U)
我們先測試一下:
a = BoxTransform(nd, nd.array([[[0, 0, 15, 15]]]))
b = BoxTransform(nd, 1+nd.array([[[0, 0, 15, 15]]]))
c = BoxTransform(nd, nd.array([[[-1, -1, -1, -1]]]))
創建了兩個簡單有效的 bbox(a, b) 和一個無效的 bbox(c),接着看看它們的運算:
a & b, a | b, a.IoU(b)
輸出結果:
(
[[[196.]]] <NDArray 1x1x1 @cpu(0)>, [[[316.]]] <NDArray 1x1x1 @cpu(0)>, [[[0.62025315]]] <NDArray 1x1x1 @cpu(0)>)
而與無效的邊界框的計算便是:
a & c, a | c, a.IoU(c)
輸出結果:
(
[[[0.]]]
<NDArray 1x1x1 @cpu(0)>,
[[[257.]]]
<NDArray 1x1x1 @cpu(0)>,
[[[0.]]]
<NDArray 1x1x1 @cpu(0)>)
如果把無效的邊界框看作是空集,則上面的運算結果便符合常識。下面討論如何標記訓練集?
參考 MSCOCO 數據標注詳解 可以知道:COCO 的 bbox 是 (x,y,w,h) 格式的,但是這里的 (x,y) 不是中心坐標,而是左上角坐標。為了統一,需要將其轉換為 (xmin,ymin,xmax,ymax) 格式:
labels = nd.array(label) # (x,y,w,h),其中(x,y) 是左上角坐標
cwh = (labels[:,2:4]-1) * 0.5
labels[:,2:4] = labels[:,:2] + cwh # 轉換為 (xmin,ymin,xmax,ymax)
下面計算真實邊界框與預測邊界框的 ious:
# 將 scores 按照從大到小進行排序,並返回其索引
orders = scores.reshape((0, -1)).argsort()[:, ::-1][:,:pre_nms]
scores = scores[0][orders[0]] # 得分降序排列
rois = rois[0][orders[0]] # 按得分降序排列 rois
# 預測邊界框
G = BoxTransform(nd, rois.expand_dims(0))
# 真實邊界框
GT = BoxTransform(nd, labels[:,:4].expand_dims(0))
ious = G.IoU(GT).T
ious.shape
輸出結果:
(1, 12000, 6)
可以看出,總計一張圖片,預測邊界框 12000 個,真實邊界框 6 個。
更多后續內容見我的 GitHub:CV
一個關於 R-CNN 系列介紹比較不錯的博客:基於深度學習的目標檢測技術演進:R-CNN、Fast R-CNN、Faster R-CNN
Lenc K, Vedaldi A. R-CNN minus R.[J]. british machine vision conference, 2015. ↩︎