【Faster R-CNN】3. Faster RCNN程式碼解析第二彈

1. 前言

回顧一下上節推文的內容,我們將 Faster RCNN 的數據預處理以及實現細節弄清楚了,並將其總結為了下圖:

Faster RCNN預處理流程圖,made by BBuf

這一節,我們將重點講講 Faster RCNN 中的 RPN 即候選框生成網路和 ROI Head 的細節。

2. 原理介紹&程式碼詳解

還是先回憶一下上節講到的 Faster RCNN 整體結構,如下所示:

Faster RCNN整體結構,來自知乎陳雲大佬

可以看到原始圖片首先會經過一個特徵提取器 Extrator 這裡是 VGG16,在原始論文中作者使用了 Caffe 的預訓練模型。同時將 VGG16 模型的前4層卷積層的參數凍結(在 Caffe 中將其學習率設為0),並將最後三層全連接層的前兩層保留並用來初始化 ROIHead 裡面部分參數,等我們將程式碼解析到這裡了,就很好理解了,暫時沒理解也不要緊,只是了解一下有這個流程即可。我們可以將 Extrator 用下圖來表示:

Extrator: VGG16 同來自知乎陳雲大佬

可以看到對於一個尺寸為H\times W\times C的圖片,經過這個特徵提取網路之後會得到一個\frac{H}{16}\times \frac{W}{16} \times 3的特徵圖,也即是圖中的紅色箭頭代表的Features

接下來我們來講一下 RPN,我們從整體結構圖中可以看到 RPN 這個候選框生成網路接收了2個輸入,一個是特徵圖也就是我們剛提到的,另外一個是數據集提供的 GT Box,這裡面究竟是怎麼操作呢?

我們知道 RPN 網路使用來提取候選框的,它最大的貢獻就在於它提出了一個Anchor的思想,這也是後面 One-Stage 以及 Two-Stage 的各類目標檢測演算法的出發點,Anchor表示的是大小和尺寸固定的候選框,論文中用到了三種比例和三種尺寸,也就是說對於特徵圖的每個點都將產生3\times 3=9種不同大小的Anchor候選框,其中三種尺寸分別是128(下圖中的藍色),256(下圖中的紅色),512(下圖中的綠色),而三種比例分別為:1:22:11:1。Faster RCNN 的九種 Anchor 的示意圖如下:

Faster RCNN的9種Anchor

然後我們來算一下對於一個尺寸為512\times 62\times 37的特徵圖有多少個 Anchor,上面提到對於特徵圖的每個點都要產生9個 Anchor,那麼這個特徵圖就一共會產生62\times 37 \times 9=20464Anchor。可以看到一張圖片產生這麼多 Anchor,肯定很多 Anchor 和真正的目標框是接近的(IOU 大),這相對於從0開始回歸目標框就大大降低了難度,可以理解為有一些老司機先給出了我們一些經驗,然後我們在這些經驗上去做出判斷和優化,這樣就更容易了。

這裡我們先來看一下生成 Anchor 的過程,具體是在model/util文件夾下,我們首先來看bbox_tools.py文件,其中涉及到了 RCNN 中提到的邊框回歸公式,\hat{G}代表候選框,而回歸學習就是學習d_x,d_y,d_h,d_w4個偏移量,\hat{G}P的關係可以如下表示:

\hat{G_x}=P_wd_x(P)+P_x
\hat{G_y}=P_hd_y(P)+P_y
\hat{G_w}=P_wexp(d_w(P))
\hat{G_h}=P_hexp(d_h(P))

真正的目標框和候選框之間的偏移可以表示為:

t_x=(G_x-P_x)/P_w

t_y=(G_y-P_y)/P_h

t_w=log(G_w/P_w)

t_h=log(G_h/P_h)

bbox_tools.py的具體解釋如下:

# 已知源bbox和位置偏差dx,dy,dh,dw,求目標框G
def loc2bbox(src_bbox, loc):
    
    # src_bbox:(R,4),R為bbox個數,4為左上角和右下角四個坐標
    if src_bbox.shape[0] == 0:
        return xp.zeros((0, 4), dtype=loc.dtype)

    src_bbox = src_bbox.astype(src_bbox.dtype, copy=False)

    #src_height為Ph,src_width為Pw,src_ctr_y為Py,src_ctr_x為Px
    src_height = src_bbox[:, 2] - src_bbox[:, 0]  #ymax-ymin
    src_width = src_bbox[:, 3] - src_bbox[:, 1] #xmax-xmin
    src_ctr_y = src_bbox[:, 0] + 0.5 * src_height #y0+0.5h
    src_ctr_x = src_bbox[:, 1] + 0.5 * src_width #x0+0.5w,計算出中心點坐標

    #python [start:stop:step] 
    dy = loc[:, 0::4]
    dx = loc[:, 1::4]
    dh = loc[:, 2::4]
    dw = loc[:, 3::4]

    # RCNN中提出的邊框回歸:尋找原始proposal與近似目標框G之間的映射關係,公式在上面
    ctr_y = dy * src_height[:, xp.newaxis] + src_ctr_y[:, xp.newaxis] #ctr_y為Gy
    ctr_x = dx * src_width[:, xp.newaxis] + src_ctr_x[:, xp.newaxis] # ctr_x為Gx
    h = xp.exp(dh) * src_height[:, xp.newaxis] #h為Gh
    w = xp.exp(dw) * src_width[:, xp.newaxis] #w為Gw
    # 上面四行得到了回歸後的目標框(Gx,Gy,Gh,Gw)

    # 由中心點轉換為左上角和右下角坐標
    dst_bbox = xp.zeros(loc.shape, dtype=loc.dtype)
    dst_bbox[:, 0::4] = ctr_y - 0.5 * h
    dst_bbox[:, 1::4] = ctr_x - 0.5 * w
    dst_bbox[:, 2::4] = ctr_y + 0.5 * h
    dst_bbox[:, 3::4] = ctr_x + 0.5 * w

    return dst_bbox

# 已知源框和目標框求出其位置偏差
def bbox2loc(src_bbox, dst_bbox):
    
    # 計算出源框中心點坐標
    height = src_bbox[:, 2] - src_bbox[:, 0]
    width = src_bbox[:, 3] - src_bbox[:, 1]
    ctr_y = src_bbox[:, 0] + 0.5 * height
    ctr_x = src_bbox[:, 1] + 0.5 * width

    # 計算出目標框中心點坐標
    base_height = dst_bbox[:, 2] - dst_bbox[:, 0]
    base_width = dst_bbox[:, 3] - dst_bbox[:, 1]
    base_ctr_y = dst_bbox[:, 0] + 0.5 * base_height
    base_ctr_x = dst_bbox[:, 1] + 0.5 * base_width

    # 求出最小的正數
    eps = xp.finfo(height.dtype).eps
    # 將height,width與其比較保證全部是非負
    height = xp.maximum(height, eps)
    width = xp.maximum(width, eps)

    # 根據上面的公式二計算dx,dy,dh,dw
    dy = (base_ctr_y - ctr_y) / height
    dx = (base_ctr_x - ctr_x) / width
    dh = xp.log(base_height / height)
    dw = xp.log(base_width / width)

    # np.vstack按照行的順序把數組給堆疊起來
    loc = xp.vstack((dy, dx, dh, dw)).transpose()
    return loc

# 求兩個bbox的相交的交並比
def bbox_iou(bbox_a, bbox_b):
    # 確保bbox第二維為bbox的四個坐標(ymin,xmin,ymax,xmax)
    if bbox_a.shape[1] != 4 or bbox_b.shape[1] != 4:
        raise IndexError

    # top left
    # l為交叉部分框左上角坐標最大值,為了利用numpy的廣播性質,
    # bbox_a[:, None, :2]的shape是(N,1,2),bbox_b[:, :2]
    # shape是(K,2),由numpy的廣播性質,兩個數組shape都變成(N,K,2),
    # 也就是對a里每個bbox都分別和b里的每個bbox求左上角點坐標最大值
    tl = xp.maximum(bbox_a[:, None, :2], bbox_b[:, :2])
    # bottom right
    # br為交叉部分框右下角坐標最小值
    br = xp.minimum(bbox_a[:, None, 2:], bbox_b[:, 2:])
    # 所有坐標軸上tl<br時,返回數組元素的乘積(y1max-yimin)X(x1max-x1min),
    # bboxa與bboxb相交區域的面積
    area_i = xp.prod(br - tl, axis=2) * (tl < br).all(axis=2)
    # 計算bboxa的面積
    area_a = xp.prod(bbox_a[:, 2:] - bbox_a[:, :2], axis=1)
    # 計算bboxb的面積
    area_b = xp.prod(bbox_b[:, 2:] - bbox_b[:, :2], axis=1)
    # 計算IOU
    return area_i / (area_a[:, None] + area_b - area_i)


def __test():
    pass


if __name__ == '__main__':
    __test()

# 對特徵圖features以基準長度為16、選擇合適的ratios和scales取基準錨點
 # anchor_base。(選擇長度為16的原因是圖片大小為600*800左右,基準長度
 # 16對應的原圖區域是256*256,考慮放縮後的大小有128*128,512*512比較合適)
def generate_anchor_base(base_size=16, ratios=[0.5, 1, 2],
                         anchor_scales=[8, 16, 32]):
    # 根據基準點生成9個基本的anchor的功能,ratios=[0.5,1,2],anchor_scales=
    # [8,16,32]是長寬比和縮放比例,anchor_scales也就是在base_size的基礎上再增
    # 加的量,本程式碼中對應著三種面積的大小(16*8)^2 ,(16*16)^2  (16*32)^2  
    # 也就是128,256,512的平方大小

    py = base_size / 2.
    px = base_size / 2.

    #(9,4),注意:這裡只是以特徵圖的左上角點為基準產生的9個anchor,
    anchor_base = np.zeros((len(ratios) * len(anchor_scales), 4),
                           dtype=np.float32)
    # six.moves 是用來處理那些在python2 和 3裡面函數的位置有變化的,
    # 直接用six.moves就可以屏蔽掉這些變化
    for i in six.moves.range(len(ratios)):
        for j in six.moves.range(len(anchor_scales)):
            # 生成9種不同比例的h和w
            h = base_size * anchor_scales[j] * np.sqrt(ratios[i])
            w = base_size * anchor_scales[j] * np.sqrt(1. / ratios[i])

            index = i * len(anchor_scales) + j
            # 計算出anchor_base畫的9個框的左下角和右上角的4個anchor坐標值
            anchor_base[index, 0] = py - h / 2.
            anchor_base[index, 1] = px - w / 2.
            anchor_base[index, 2] = py + h / 2.
            anchor_base[index, 3] = px + w / 2.
    return anchor_base

在上面的generate_anchor_base函數中,輸出 Anchor 的形狀以及這9個 Anchor 的左上右下坐標如下:

這9個anchor形狀為:
90.50967 *181.01933    = 128^2
181.01933 * 362.03867 = 256^2
362.03867 * 724.07733 = 512^2
128.0 * 128.0 = 128^2
256.0 * 256.0 = 256^2
512.0 * 512.0 = 512^2
181.01933 * 90.50967   = 128^2
362.03867 * 181.01933 = 256^2
724.07733 * 362.03867 = 512^2

9個anchor的左上右下坐標:
-37.2548 -82.5097 53.2548 98.5097
-82.5097	-173.019	98.5097	189.019
-173.019	-354.039	189.019	370.039
-56	-56	72	72
-120	-120	136	136
-248	-248	264	264
-82.5097	-37.2548	98.5097	53.2548
-173.019	-82.5097	189.019	98.5097
-354.039	-173.019	370.039	189.019

需要注意的是:

anchor_base = np.zeros((len(ratios) * len(anchor_scales), 4), dtype=np.float32)

這行程式碼表示的是只是以特徵圖的左上角為基準產生的9個 Anchor,而我們知道 Faster RCNN 是會在特徵圖的每個點產生9個 Anchor 的,這個過程在什麼地方呢?答案是在mode/region_proposal_network.py裡面,這裡面的_enumerate_shifted_anchor這個函數就實現了這一功能,接下來我們就仔細看看這個函數是如何產生整個特徵圖的所有 Anchor 的(一共 20000+ 個左右 Anchor,另外產生的 Anchor 坐標會截斷到影像坐標範圍裡面)。下面來看看model/region_proposal_network.py裡面的_enumerate_shifted_anchor函數:

# 利用anchor_base生成所有對應feature map的anchor
def _enumerate_shifted_anchor(anchor_base, feat_stride, height, width):
    # Enumerate all shifted anchors:
    #
    # add A anchors (1, A, 4) to
    # cell K shifts (K, 1, 4) to get
    # shift anchors (K, A, 4)
    # reshape to (K*A, 4) shifted anchors
    # return (K*A, 4)

    # !TODO: add support for torch.CudaTensor
    # xp = cuda.get_array_module(anchor_base)
    # it seems that it can't be boosed using GPU
    import numpy as xp
    # 縱向偏移量(0,16,32,...)
    shift_y = xp.arange(0, height * feat_stride, feat_stride)
    # 橫向偏移量(0,16,32,...)
    shift_x = xp.arange(0, width * feat_stride, feat_stride)
    # shift_x = [[0,16,32,..],[0,16,32,..],[0,16,32,..]...],
    # shift_y = [[0,0,0,..],[16,16,16,..],[32,32,32,..]...],
    # 就是形成了一個縱橫向偏移量的矩陣,也就是特徵圖的每一點都能夠通過這個
    # 矩陣找到映射在原圖中的具體位置!
    shift_x, shift_y = xp.meshgrid(shift_x, shift_y)
    # #經過剛才的變化,其實大X,Y的元素個數已經相同,看矩陣的結構也能看出,
    # 矩陣大小是相同的,X.ravel()之後變成一行,此時shift_x,shift_y的元
    # 素個數是相同的,都等於特徵圖的長寬的乘積(像素點個數),不同的是此時
    # 的shift_x裡面裝得是橫向看的x的一行一行的偏移坐標,而此時的y裡面裝
    # 的是對應的縱向的偏移坐標!下圖顯示xp.meshgrid(),shift_y.ravel()
    # 操作示例
    shift = xp.stack((shift_y.ravel(), shift_x.ravel(),
                      shift_y.ravel(), shift_x.ravel()), axis=1)
    # A=9
    A = anchor_base.shape[0]
    # 讀取特徵圖中元素的總個數
    K = shift.shape[0]
    #用基礎的9個anchor的坐標分別和偏移量相加,最後得出了所有的anchor的坐標,
    # 四列可以堪稱是左上角的坐標和右下角的坐標加偏移量的同步執行,飛速的從
    # 上往下捋一遍,所有的anchor就都出來了!一共K個特徵點,每一個有A(9)個
    # 基本的anchor,所以最後reshape((K*A),4)的形式,也就得到了最後的所有
    # 的anchor左下角和右上角坐標.          
    anchor = anchor.reshape((K * A, 4)).astype(np.float32)

    anchor = anchor_base.reshape((1, A, 4)) + \
             shift.reshape((1, K, 4)).transpose((1, 0, 2))
    anchor = anchor.reshape((K * A, 4)).astype(np.float32)
    return anchor

我們結合一個例子來看一下shift_x, shift_y = xp.meshgrid(shift_x, shift_y)函數操這個函數到底執行了什麼操作?其中xp就是numpy

np.meshgrid操作例子

然後shift = xp.stack((shift_y.ravel(), shift_x.ravel(),shift_y.ravel(), shift_x.ravel()), axis=1)這行程式碼則是產生坐標偏移對,一個是x方向,一個是y方向。

另外一個問題是這裡為什麼需要將特徵圖對應回原圖呢?這是因為我們要框住的目標是在原圖上,而我們選 Anchor 是在特徵圖上,Pooling 之後特徵之間的相對位置不變,但是尺寸已經減少為了原始圖的\frac{1}{16},而我們的 Anchor 是為了框住原圖上的目標而非特徵圖上的,所以注意一下 Anchor 一定指的是針對原圖的,而非特徵圖。

接下來我們看看訓練 RPN 的一些細節,RPN 的總體架構如下圖所示:

RPN架構

首先我們要明確 Anchor 的數量是和特徵圖相關的,不同的特徵圖對應的 Anchor 數量也不一樣。RPN 在Extractor輸出的特徵圖基礎上先增加了一個3\times 3卷積,然後利用兩個1\times 1卷積分別進行二分類和位置回歸。進行分類的卷積核通道數為9\times 29個 Anchor,每個 Anchor 二分類,使用交叉熵損失),進行回歸的卷積核通道數為9\times 49個 Anchor,每個 Anchor 有4個位置參數)。RPN 是一個全卷積網路,這樣對輸入圖片的尺寸是沒有要求的。

接下來我們就要講到今天的重點部分了,即AnchorTargetCreatorProposalCreatorProposalTargetCreator,也就是 ROI Head 最核心的部分:

AnchorTargetCreator

AnchorTargetCreator 就是將 20000 多個候選的 Anchor 選出 256 個 Anchor 進行分類和回歸,選擇過程如下:

  • 對於每一個 GT bbox,選擇和它交並比最大的一個 Anchor 作為正樣本。
  • 對於剩下的 Anchor,從中選擇和任意一個 GT bbox 交並比超過 0.7 的 Anchor 作為正樣本,正樣本數目不超過 128 個。
  • 隨機選擇和 GT bbox 交並比小於 0.3 的 Anchor 作為負樣本,負樣本和正樣本的總數為256

對於每一個 Anchor 來說,GT_Label 要麼為 1(前景),要麼為 0(背景),而 GT_Loc 則是由4個位置參數組成,也就是上面講的目標框和候選框之間的偏移。

計算分類損失使用的是交叉熵損失,而計算回歸損失則使用了 SmoothL1Loss,在計算回歸損失的時候只計算正樣本(前景)的損失,不計算負樣本的損失。

程式碼實現在model/utils/creator_tool.py裡面,具體如下:

# AnchorTargetCreator作用是生成訓練要用的anchor(正負樣本
# 各128個框的坐標和256個label(0或者1))
# 利用每張圖中bbox的真實標籤來為所有任務分配ground truth
class AnchorTargetCreator(object):
    

    def __init__(self,
                 n_sample=256,
                 pos_iou_thresh=0.7, neg_iou_thresh=0.3,
                 pos_ratio=0.5):
        self.n_sample = n_sample
        self.pos_iou_thresh = pos_iou_thresh
        self.neg_iou_thresh = neg_iou_thresh
        self.pos_ratio = pos_ratio

    def __call__(self, bbox, anchor, img_size):
        # 特徵圖大小
        img_H, img_W = img_size
        # 一般對應20000個左右anchor
        n_anchor = len(anchor)
        # 將那些超出圖片範圍的anchor全部去掉,只保留位於圖片內部的序號
        inside_index = _get_inside_index(anchor, img_H, img_W)
        # 保留位於圖片內部的anchor
        anchor = anchor[inside_index]
        # 篩選出符合條件的正例128個負例128並給它們附上相應的label
        argmax_ious, label = self._create_label(
            inside_index, anchor, bbox)

        # 計算每一個anchor與對應bbox求得iou最大的bbox計算偏移
        # 量(注意這裡是位於圖片內部的每一個)
        loc = bbox2loc(anchor, bbox[argmax_ious])

        # 將位於圖片內部的框的label對應到所有生成的20000個框中
        # (label原本為所有在圖片中的框的)
        label = _unmap(label, n_anchor, inside_index, fill=-1)
        # 將回歸的框對應到所有生成的20000個框中(label原本為
        # 所有在圖片中的框的)
        loc = _unmap(loc, n_anchor, inside_index, fill=0)

        return loc, label
    # 下面為調用的_creat_label() 函數
    def _create_label(self, inside_index, anchor, bbox):
        # inside_index為所有在圖片範圍內的anchor序號
        label = np.empty((len(inside_index),), dtype=np.int32)
        # #全部填充-1
        label.fill(-1)
        # 調用_calc_ious()函數得到每個anchor與哪個bbox的iou最大
        # 以及這個iou值、每個bbox與哪個anchor的iou最大(需要體會從
        # 行和列取最大值的區別)
        argmax_ious, max_ious, gt_argmax_ious = \
            self._calc_ious(anchor, bbox, inside_index)

        # #把每個anchor與對應的框求得的iou值與負樣本閾值比較,若小
        # 於負樣本閾值,則label設為0,pos_iou_thresh=0.7, 
        # neg_iou_thresh=0.3
        label[max_ious < self.neg_iou_thresh] = 0

        # 把與每個bbox求得iou值最大的anchor的label設為1
        label[gt_argmax_ious] = 1

        # 把每個anchor與對應的框求得的iou值與正樣本閾值比較,
        # 若大於正樣本閾值,則label設為1
        label[max_ious >= self.pos_iou_thresh] = 1

        # 按照比例計算出正樣本數量,pos_ratio=0.5,n_sample=256
        n_pos = int(self.pos_ratio * self.n_sample)
        # 得到所有正樣本的索引
        pos_index = np.where(label == 1)[0]
        # 如果選取出來的正樣本數多於預設定的正樣本數,則隨機拋棄,將那些拋棄的樣本的label設為-1
        if len(pos_index) > n_pos:
            disable_index = np.random.choice(
                pos_index, size=(len(pos_index) - n_pos), replace=False)
            label[disable_index] = -1

        # 設定的負樣本的數量
        n_neg = self.n_sample - np.sum(label == 1)
        # 負樣本的索引
        neg_index = np.where(label == 0)[0]
        if len(neg_index) > n_neg:
            # 隨機選擇不要的負樣本,個數為len(neg_index)-neg_index,label值設為-1
            disable_index = np.random.choice(
                neg_index, size=(len(neg_index) - n_neg), replace=False)
            label[disable_index] = -1

        return argmax_ious, label
    # _calc_ious函數
    def _calc_ious(self, anchor, bbox, inside_index):
        # ious between the anchors and the gt boxes
        # 調用bbox_iou函數計算anchor與bbox的IOU, ious:(N,K),
        # N為anchor中第N個,K為bbox中第K個,N大概有15000個
        ious = bbox_iou(anchor, bbox)
        # 1代表行,0代表列
        argmax_ious = ious.argmax(axis=1)
        # 求出每個anchor與哪個bbox的iou最大,以及最大值,max_ious:[1,N]
        max_ious = ious[np.arange(len(inside_index)), argmax_ious]
        gt_argmax_ious = ious.argmax(axis=0)
        # 求出每個bbox與哪個anchor的iou最大,以及最大值,gt_max_ious:[1,K]
        gt_max_ious = ious[gt_argmax_ious, np.arange(ious.shape[1])]
        # 然後返回最大iou的索引(每個bbox與哪個anchor的iou最大),有K個
        gt_argmax_ious = np.where(ious == gt_max_ious)[0]

        return argmax_ious, max_ious, gt_argmax_ious

ProposalCreator

RPN 在自身訓練的時候還會提供 ROIs 給 Faster RCNN 的 ROI Head 作為訓練樣本。RPN 生成 ROIs 的過程就是 ProposalCreator,具體流程如下:

  • 對於每張圖片,利用它的特徵圖,計算\frac{H}{16} \times \frac{W}{16}\times 9(大約 20000 個)Anchor 屬於前景的概率以及對應的位置參數。
  • 選取概率較大的 12000 個 Anchor。
  • 利用回歸的位置參數修正這 12000 個 Anchor 的位置,獲得 ROIs。
  • 利用非極大值抑制,選出概率最大的 2000 個 ROIs。

注意! 在推理階段,為了提高處理速度,12000 和 2000 分別變成了 6000 和 300。並且這部分操作不需要反向傳播,所以可以利用 numpy 或者 tensor 實現。因此,RPN 的輸出就是形如2000\times 4或者300\times 4的 Tensor。

RPN 給出了候選框,然後 ROI Head 就是在候選框的基礎上繼續進行分類和位置參數的回歸獲得最後的結果,ROI Head 的結構圖如下所示:

ROIHead網路結構,來自知乎陳雲大佬

程式碼實現在model/utils/creator_tool.py裡面,具體如下:

# 下面是ProposalCreator的程式碼: 這部分的操作不需要進行反向傳播
# 因此可以利用numpy/tensor實現
class ProposalCreator:
    # 對於每張圖片,利用它的feature map,計算(H/16)x(W/16)x9(大概20000)
    # 個anchor屬於前景的概率,然後從中選取概率較大的12000張,利用位置回歸參
    # 數,修正這12000個anchor的位置, 利用非極大值抑制,選出2000個ROIS以及
    # 對應的位置參數。
    def __init__(self,
                 parent_model,
                 nms_thresh=0.7,
                 n_train_pre_nms=12000,
                 n_train_post_nms=2000,
                 n_test_pre_nms=6000,
                 n_test_post_nms=300,
                 min_size=16
                 ):
        self.parent_model = parent_model
        self.nms_thresh = nms_thresh
        self.n_train_pre_nms = n_train_pre_nms
        self.n_train_post_nms = n_train_post_nms
        self.n_test_pre_nms = n_test_pre_nms
        self.n_test_post_nms = n_test_post_nms
        self.min_size = min_size
    # 這裡的loc和score是經過region_proposal_network中
    # 經過1x1卷積分類和回歸得到的。
    def __call__(self, loc, score,
                 anchor, img_size, scale=1.):
        
        if self.parent_model.training:
            n_pre_nms = self.n_train_pre_nms #12000
            n_post_nms = self.n_train_post_nms #經過NMS後有2000個
        else:
            n_pre_nms = self.n_test_pre_nms #6000
            n_post_nms = self.n_test_post_nms #經過NMS後有300個

        # 將bbox轉換為近似groudtruth的anchor(即rois)
        roi = loc2bbox(anchor, loc)

        # slice表示切片操作
        # 裁剪將rois的ymin,ymax限定在[0,H]
        roi[:, slice(0, 4, 2)] = np.clip(
            roi[:, slice(0, 4, 2)], 0, img_size[0])
        # 裁剪將rois的xmin,xmax限定在[0,W]
        roi[:, slice(1, 4, 2)] = np.clip(
            roi[:, slice(1, 4, 2)], 0, img_size[1])

        # Remove predicted boxes with either height or width < threshold.
        min_size = self.min_size * scale #16
        # rois的寬
        hs = roi[:, 2] - roi[:, 0]
        # rois的高
        ws = roi[:, 3] - roi[:, 1]
        # 確保rois的長寬大於最小閾值
        keep = np.where((hs >= min_size) & (ws >= min_size))[0]
        roi = roi[keep, :]
        # 對剩下的ROIs進行打分(根據region_proposal_network中rois的預測前景概率)
        score = score[keep]

        # Sort all (proposal, score) pairs by score from highest to lowest.
        # Take top pre_nms_topN (e.g. 6000).
        # 將score拉伸並逆序(從高到低)排序
        order = score.ravel().argsort()[::-1]
        # train時從20000中取前12000個rois,test取前6000個
        if n_pre_nms > 0:
            order = order[:n_pre_nms]
        roi = roi[order, :]

        # Apply nms (e.g. threshold = 0.7).
        # Take after_nms_topN (e.g. 300).

        # unNOTE: somthing is wrong here!
        # TODO: remove cuda.to_gpu
        # #(具體需要看NMS的原理以及輸入參數的作用)調用非極大值抑制函數,
        # 將重複的抑制掉,就可以將篩選後ROIS進行返回。經過NMS處理後Train
        # 數據集得到2000個框,Test數據集得到300個框
        keep = non_maximum_suppression(
            cp.ascontiguousarray(cp.asarray(roi)),
            thresh=self.nms_thresh)
        if n_post_nms > 0:
            keep = keep[:n_post_nms]
        # 取出最終的2000或300個rois
        roi = roi[keep]
        return roi

ProposalTargetCreator

ROIs 給出了 2000 個候選框,分別對應了不同大小的 Anchor。我們首先需要利用 ProposalTargetCreator 挑選出 128 個sample_rois,然後使用了 ROI Pooling 將這些不同尺寸的區域全部 Pooling 到同一個尺度(7\times 7)上,關於 ROI Pooling 這裡就不多講了,具體見:實例分割演算法之Mask R-CNN論文解讀 。那麼這裡為什麼要 Pooling 成7\times 7大小呢?

這是為了共享權重,前面在Extrator部分說到 Faster RCNN 除了前面基層卷積被用到之外,最後全連接層的權重也可以繼續利用。當所有的 RoIs 都被 Resize 到512\times 512\times 7的特徵圖之後,將它 Reshape 成一個一維的向量,就可以利用 VGG16 預訓練的權重初始化前兩層全連接層了。最後,再接上兩個全連接層 FC21 用來分類(20 個類 + 背景,VOC)和回歸(21 個類,每個類有 4 個位置參數)。

我們再來看一下 ProposalTargetCreator 具體是如何選擇 128 個 ROIs 進行訓練的?過程如下:

  • RoIs 和 GT box 的 IOU 大於 0.5 的,選擇一些如 32 個。
  • RoIs 和 gt_bboxes 的 IoU 小於等於 0(或者 0.1)的選擇一些(比如 128-32=96 個)作為負樣本。

同時為了方便訓練,對選擇出的 128 個 RoIs 的gt_roi_loc進行標準化處理(減均值除以標準差)。

下面來看看程式碼實現,同樣是在model/utils/creator_tool.py裡面:

# 下面是ProposalTargetCreator程式碼:ProposalCreator產生2000個ROIS,
# 但是這些ROIS並不都用於訓練,經過本ProposalTargetCreator的篩選產生
# 128個用於自身的訓練

class ProposalTargetCreator(object):
    def __init__(self,
                 n_sample=128,
                 pos_ratio=0.25, pos_iou_thresh=0.5,
                 neg_iou_thresh_hi=0.5, neg_iou_thresh_lo=0.0
                 ):
        self.n_sample = n_sample
        self.pos_ratio = pos_ratio
        self.pos_iou_thresh = pos_iou_thresh
        self.neg_iou_thresh_hi = neg_iou_thresh_hi
        self.neg_iou_thresh_lo = neg_iou_thresh_lo  # NOTE:default 0.1 in py-faster-rcnn
    # 輸入:2000個rois、一個batch(一張圖)中所有的bbox ground truth(R,4)、
    # 對應bbox所包含的label(R,1)(VOC2007來說20類0-19)
    # 輸出:128個sample roi(128,4)、128個gt_roi_loc(128,4)、
    # 128個gt_roi_label(128,1)
    def __call__(self, roi, bbox, label,
                 loc_normalize_mean=(0., 0., 0., 0.),
                 loc_normalize_std=(0.1, 0.1, 0.2, 0.2)):
        n_bbox, _ = bbox.shape
        # 首先將2000個roi和m個bbox給concatenate了一下成為
        # 新的roi(2000+m,4)。
        roi = np.concatenate((roi, bbox), axis=0)
        # n_sample = 128,pos_ratio=0.5,round 對傳入的數據進行四捨五入
        pos_roi_per_image = np.round(self.n_sample * self.pos_ratio)
        # 計算每一個roi與每一個bbox的iou
        iou = bbox_iou(roi, bbox)
        # 按行找到最大值,返回最大值對應的序號以及其真正的IOU。
        # 返回的是每個roi與哪個bbox的最大,以及最大的iou值
        gt_assignment = iou.argmax(axis=1)
        # 每個roi與對應bbox最大的iou
        max_iou = iou.max(axis=1)
        # 從1開始的類別序號,給每個類得到真正的label(將0-19變為1-20)
        gt_roi_label = label[gt_assignment] + 1

        # 同樣的根據iou的最大值將正負樣本找出來,pos_iou_thresh=0.5
        pos_index = np.where(max_iou >= self.pos_iou_thresh)[0]
        # 需要保留的roi個數(滿足大於pos_iou_thresh條件的roi與64之間較小的一個)
        pos_roi_per_this_image = int(min(pos_roi_per_image, pos_index.size))
        # 找出的樣本數目過多就隨機丟掉一些
        if pos_index.size > 0:
            pos_index = np.random.choice(
                pos_index, size=pos_roi_per_this_image, replace=False)

        # neg_iou_thresh_hi=0.5,neg_iou_thresh_lo=0.0
        neg_index = np.where((max_iou < self.neg_iou_thresh_hi) &
                             (max_iou >= self.neg_iou_thresh_lo))[0]
        neg_roi_per_this_image = self.n_sample - pos_roi_per_this_image
        neg_roi_per_this_image = int(min(neg_roi_per_this_image,
                                         neg_index.size))
        if neg_index.size > 0:
            neg_index = np.random.choice(
                neg_index, size=neg_roi_per_this_image, replace=False)

        # The indices that we're selecting (both positive and negative).
        keep_index = np.append(pos_index, neg_index)
        gt_roi_label = gt_roi_label[keep_index]
        gt_roi_label[pos_roi_per_this_image:] = 0  # 負樣本label 設為0
        sample_roi = roi[keep_index]
        # 此時輸出的128*4的sample_roi就可以去扔到 RoIHead網路里去進行分類
        # 與回歸了。同樣, RoIHead網路利用這sample_roi+featue為輸入,輸出
        # 是分類(21類)和回歸(進一步微調bbox)的預測值,那麼分類回歸的groud 
        # truth就是ProposalTargetCreator輸出的gt_roi_label和gt_roi_loc。
        # Compute offsets and scales to match sampled RoIs to the GTs.
        # 求這128個樣本的groundtruth
        gt_roi_loc = bbox2loc(sample_roi, bbox[gt_assignment[keep_index]])
        # ProposalTargetCreator首次用到了真實的21個類的label,
        # 且該類最後對loc進行了歸一化處理,所以預測時要進行均值方差處理
        gt_roi_loc = ((gt_roi_loc - np.array(loc_normalize_mean, np.float32)
                       ) / np.array(loc_normalize_std, np.float32))

        return sample_roi, gt_roi_loc, gt_roi_label

3. 總結

這一節主要理清楚了 RPN 和 ROIHead,希望大家能有所收穫,下一節我將解讀 Faster RCNN 的整體結構程式碼,謝謝觀看。

4. 參考


歡迎關注 GiantPandaCV, 在這裡你將看到獨家的深度學習分享,堅持原創,每天分享我們學習到的新鮮知識。( • ̀ ω•́ )✧

有對文章相關的問題,或者想要加入交流群,歡迎添加 BBuf 微信:

二維碼