二階段目標檢測網路-FPN 詳解

2022-12-16 15:01:08

本篇文章是論文閱讀筆記和網路理解心得總結而來,部分資料和圖參考論文和網路資料

論文背景

FPN(feature pyramid networks) 是何凱明等作者提出的適用於多尺度目標檢測演演算法。原來多數的 object detection 演演算法(比如 faster rcnn)都是隻採用頂層特徵做預測,但我們知道低層的特徵語意資訊比較少,但是目標位置準確;高層的特徵語意資訊比較豐富,但是目標位置比較粗略。另外雖然也有些演演算法採用多尺度特徵融合的方式,但是一般是採用融合後的特徵做預測,而本文不一樣的地方在於預測是在不同特徵層獨立進行的。

引言(Introduction)

從上圖可以看出,(a)使用影象金字塔構建特徵金字塔。每個影象尺度上的特徵都是獨立計算的,速度很慢。(b)最近的檢測系統選擇(比如 Faster RCNN)只使用單一尺度特徵進行更快的檢測。(c)另一種方法是重用 ConvNet(折積層)計算的金字塔特徵層次結構(比如 SSD),就好像它是一個特徵化的影象金字塔。(d)我們提出的特徵金字塔網路(FPN)與(b)和(c)類似,但更準確。在該圖中,特徵對映用藍色輪廓表示,較粗的輪廓表示語意上較強的特徵

特徵金字塔網路 FPN

作者提出的 FPN 結構如下圖:這個金字塔結構包括一個自底向上的線路,一個自頂向下的線路和橫向連線(lateral connections)

自底向上其實就是折積網路的前向過程。在前向過程中,feature map 的大小在經過某些層後會改變,而在經過其他一些層的時候不會改變,作者將不改變 feature map 大小的層歸為一個 stage,因此這裡金字塔結構中每次抽取的特徵都是每個 stage 的最後一個層的輸出。在程式碼中我們可以看到共有C1、C2、C3、C4、C5五個特徵圖,C1C2 的特徵圖大小是一樣的,所以,FPN 的建立也是基於從 C2C5 這四個特徵層上。

自頂向下的過程採用上取樣(upsampling)進行,而橫向連線則是將上取樣的結果和自底向上生成的相同大小的 feature map 進行融合(merge)。在融合之後還會再採用 3*3 的折積核對每個融合結果進行折積,目的是消除上取樣的混疊效應(aliasing effect)。並假設生成的 feature map 結果是 P2,P3,P4,P5,和原來自底向上的折積結果 C2,C3,C4,C5一一對應。

這裡貼一個 ResNet 的結構圖:論文中作者採用 conv2_x,conv3_x,conv4_x 和 conv5_x 的輸出,對應 C1,C2,C3,C4,C5,因此類似 Conv2就可以看做一個stage。

FPN網路建立

這裡自己沒有總結,因為已經有篇博文總結得很不錯了,在這

通過 ResNet50 網路,得到圖片不同階段的特徵圖,最後利用 C2,C3,C4,C5 建立特徵圖金字塔結構:

  1. 將 C5 經過 256 個 1*1 的折積核操作得到:32*32*256,記為 P5;
  2. 將 P5 進行步長為 2 的上取樣得到 64*64*256,再與 C4 經過的 256 個 1*1 折積核操作得到的結果相加,得到 64*64*256,記為 P4;
  3. 將 P4 進行步長為 2 的上取樣得到 128*128*256,再與 C3 經過的 256 個 1*1 折積核操作得到的結果相加,得到 128*128*256,記為 P3;
  4. 將 P3 進行步長為 2 的上取樣得到 256*256*256,再與 C2 經過的 256 個 1*1 折積核操作得到的結果相加,得到 256*256*256,記為 P2;
  5. 將 P5 進行步長為 2 的最大池化操作得到:16*16*256,記為 P6;

結合從 P2 到 P6 特徵圖的大小,如果原圖大小 1024*1024, 那各個特徵圖對應到原圖的步長依次為 [P2,P3,P4,P5,P6]=>[4,8,16,32,64]。

Anchor錨框生成規則

Faster RCNN 採用 FPN 的網路作 backbone 後,錨框的生成規則也會有所改變。基於上一步得到的特徵圖 [P2,P3,P4,P5,P6],再介紹下采用 FPN 的 Faster RCNN(或者 Mask RCNN)網路中 Anchor 錨框的生成,根據原始碼中介紹的規則,與之前 Faster-RCNN 中的生成規則有一點差別。

  1. 遍歷 P2 到 P6 這五個特徵層,以每個特徵圖上的每個畫素點都生成 Anchor 錨框;
  2. 以 P2 層為例,P2 層的特徵圖大小為 256*256,相對於原圖的步長為4,這樣 P2上的每個畫素點都可以生成一個基於座標陣列 [0,0,3,3] 即 4*4 面積為 16 大小的Anchor錨框,當然,可以設定一個比例 SCALE,將這個基礎的錨框放大或者縮小,比如,這裡設定 P2 層對應的縮放比例為 16,那邊生成的錨框大小就是長和寬都擴大16倍,從 4*4 變成 64*64,面積從 16 變成 4096,當然在保證面積不變的前提下,長寬比可以變換為 32*128、64*64 或 128*32,這樣以長、寬比率 RATIO = [0.5,1,2] 完成了三種變換,這樣一個畫素點都可以生成3個Anchor錨框。在 Faster-RCNN 中可以將 Anchor scale 也可以設定為多個值,而在MasK RCNN 中則是每一特徵層只對應著一個 Anchor scale即對應著上述所設定的 16
  3. P2 層每個畫素點位中心,對應到原圖上,則可生成 256*256*3(長寬三種變換) = 196608 個錨框;
  4. P3 層每個畫素點為中心,對應到原圖上,則可生成 128*128*3 = 49152 個錨框;
  5. P4 層每個畫素點為中心,對應到原圖上,則可生成 64*64*3 = 12288 個錨框;
  6. P5 層每個畫素點為中心,對應到原圖上,則生成 32*32*3 = 3072 個錨框;
  7. P6 層每個畫素點為中心,對應到原圖上,則生成 16*16*3 = 768 個錨框。

從 P2 到 P6 層一共可以在原圖上生成 \(196608 + 49152 + 12288 + 3072 + 768 = 261888\)Anchor 錨框。

實驗

看看加入FPN 的 RPN 網路的有效性,如下表 Table1。網路這些結果都是基於 ResNet-50。評價標準採用 AR,AR 表示 Average Recall,AR 右上角的 100 表示每張影象有 100 個 anchor,AR 的右下角 s,m,l 表示 COCO 資料集中 object 的大小分別是小,中,大。feature 列的大括號 {} 表示每層獨立預測。

從(a)(b)(c)的對比可以看出 FPN 的作用確實很明顯。另外(a)和(b)的對比可以看出高層特徵並非比低一層的特徵有效。

(d)表示只有橫向連線,而沒有自頂向下的過程,也就是僅僅對自底向上(bottom-up)的每一層結果做一個 1*1 的橫向連線和 3*3 的折積得到最終的結果,有點像 Fig1 的(b)。從 feature 列可以看出預測還是分層獨立的。作者推測(d)的結果並不好的原因在於在自底向上的不同層之間的 semantic gaps 比較大。

(e)表示有自頂向下的過程,但是沒有橫向連線,即向下過程沒有融合原來的特徵。這樣效果也不好的原因在於目標的 location 特徵在經過多次降取樣和上取樣過程後變得更加不準確。

(f)採用 finest level 層做預測(參考 Fig2 的上面那個結構),即經過多次特徵上取樣和融合到最後一步生成的特徵用於預測,主要是證明金字塔分層獨立預測的表達能力。顯然 finest level 的效果不如 FPN 好,原因在於 PRN 網路是一個視窗大小固定的滑動視窗檢測器,因此在金字塔的不同層滑動可以增加其對尺度變化的魯棒性。另外(f)有更多的 anchor,說明增加 anchor 的數量並不能有效提高準確率

程式碼解讀

這裡給出一個基於 PytorchFPN 網路的程式碼,來自這裡

## ResNet的block
class Bottleneck(nn.Module):
    expansion = 4
    def __init__(self, in_planes, planes, stride=1):
        super(Bottleneck, self).__init__()
        self.conv1 = nn.Conv2d(in_planes, planes, kernel_size=1, bias=False)
        self.bn1 = nn.BatchNorm2d(planes)
        self.conv2 = nn.Conv2d(planes, planes, kernel_size=3, stride=stride, padding=1, bias=False)
        self.bn2 = nn.BatchNorm2d(planes)
        self.conv3 = nn.Conv2d(planes, self.expansion*planes, kernel_size=1, bias=False)
        self.bn3 = nn.BatchNorm2d(self.expansion*planes)
        self.shortcut = nn.Sequential()
        if stride != 1 or in_planes != self.expansion*planes:
            self.shortcut = nn.Sequential(
                nn.Conv2d(in_planes, self.expansion*planes, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(self.expansion*planes)
            )
    def forward(self, x):
        out = F.relu(self.bn1(self.conv1(x)))
        out = F.relu(self.bn2(self.conv2(out)))
        out = self.bn3(self.conv3(out))
        out += self.shortcut(x)
        out = F.relu(out)
        return out
class FPN(nn.Module):
    def __init__(self, block, num_blocks):
        super(FPN, self).__init__()
        self.in_planes = 64
        self.conv1 = nn.Conv2d(3, 64, kernel_size=7, stride=2, padding=3, bias=False)
        self.bn1 = nn.BatchNorm2d(64)
        # Bottom-up layers, backbone of the network
        self.layer1 = self._make_layer(block, 64, num_blocks[0], stride=1)
        self.layer2 = self._make_layer(block, 128, num_blocks[1], stride=2)
        self.layer3 = self._make_layer(block, 256, num_blocks[2], stride=2)
        self.layer4 = self._make_layer(block, 512, num_blocks[3], stride=2)
        # Top layer
        # 我們需要在C5後面接一個1x1, 256 conv,得到金字塔最頂端的feature
        self.toplayer = nn.Conv2d(2048, 256, kernel_size=1, stride=1, padding=0) # Reduce channels
        # Smooth layers
        # 這個是上面引文中提到的抗aliasing的3x3折積
        self.smooth1 = nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)
        self.smooth2 = nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)
        self.smooth3 = nn.Conv2d(256, 256, kernel_size=3, stride=1, padding=1)
        # Lateral layers
        # 為了匹配channel dimension引入的1x1折積
        # 注意這些backbone之外的extra conv,輸出都是256 channel
        self.latlayer1 = nn.Conv2d(1024, 256, kernel_size=1, stride=1, padding=0)
        self.latlayer2 = nn.Conv2d( 512, 256, kernel_size=1, stride=1, padding=0)
        self.latlayer3 = nn.Conv2d( 256, 256, kernel_size=1, stride=1, padding=0)
    def _make_layer(self, block, planes, num_blocks, stride):
        strides = [stride] + [1]*(num_blocks-1)
        layers = []
        for stride in strides:
            layers.append(block(self.in_planes, planes, stride))
            self.in_planes = planes * block.expansion
        return nn.Sequential(*layers)
    ## FPN的lateral connection部分: upsample以後,element-wise相加
    def _upsample_add(self, x, y):
        '''Upsample and add two feature maps.
        Args:
          x: (Variable) top feature map to be upsampled.
          y: (Variable) lateral feature map.
        Returns:
          (Variable) added feature map.
        Note in PyTorch, when input size is odd, the upsampled feature map
        with `F.upsample(..., scale_factor=2, mode='nearest')`
        maybe not equal to the lateral feature map size.
        e.g.
        original input size: [N,_,15,15] ->
        conv2d feature map size: [N,_,8,8] ->
        upsampled feature map size: [N,_,16,16]
        So we choose bilinear upsample which supports arbitrary output sizes.
        '''
        _,_,H,W = y.size()
        return F.upsample(x, size=(H,W), mode='bilinear') + y
    def forward(self, x):
        # Bottom-up
        c1 = F.relu(self.bn1(self.conv1(x)))
        c1 = F.max_pool2d(c1, kernel_size=3, stride=2, padding=1)
        c2 = self.layer1(c1)
        c3 = self.layer2(c2)
        c4 = self.layer3(c3)
        c5 = self.layer4(c4)
        # Top-down
        # P5: 金字塔最頂上的feature
        p5 = self.toplayer(c5)
        # P4: 上一層 p5 + 側邊來的 c4
        # 其餘同理
        p4 = self._upsample_add(p5, self.latlayer1(c4))
        p3 = self._upsample_add(p4, self.latlayer2(c3))
        p2 = self._upsample_add(p3, self.latlayer3(c2))
        # Smooth
        # 輸出做一下smooth
        p4 = self.smooth1(p4)
        p3 = self.smooth2(p3)
        p2 = self.smooth3(p2)
        return p2, p3, p4, p5

參考資料