GGCNN2 論文導讀:用空洞卷積擴大感受野的即時抓取網路

Morrison, Corke & Leitner · 2020原文連結

論文資訊卡

  • 作者:Douglas Morrison、Peter Corke、Jürgen Leitner
  • 關係GG-CNN 的架構改良版,輸入輸出定義完全相同,差異在中間的特徵提取架構
  • 原始碼dougsm/ggcnn(同一個倉庫同時包含 GG-CNN 與 GGCNN2 的模型定義)

問題背景

GG-CNN 證明了用一個 62K 參數的輕量全卷積網路做像素級抓取地圖生成,可以達到 50Hz 的即時閉迴路抓取。但這個架構有兩個具體的、可以歸因到設計細節的問題:其一,三層卷積、完全不含 Pooling 的 encoder,能「看到」的空間範圍天花板較低——夾爪需要橫跨的物體寬度,往往超過小卷積核單獨能覆蓋的範圍;其二,decoder 用 Transposed Convolution 做上採樣,這類反卷積在 kernel 大小與 stride 不整除時,容易在輸出上形成規律的棋盤格偽影(checkerboard artifacts),干擾抓取品質圖與角度圖的精確定位。

GGCNN2 不是重新設計一個模型,而是針對這兩個具體問題做架構改良,目標是在幾乎不犧牲 GG-CNN 引以為傲的即時推論速度的前提下,提升抓取精度。

方法核心:三個架構改動

GGCNN2 的輸入輸出與訓練方式跟 GG-CNN 完全一樣——同樣是從深度影像生成 Quality、cos(2Φ)sin(2Φ)、Width 四張像素級輸出圖。改動全部集中在中間的特徵提取架構,可以歸納成三點:

1. 加入 MaxPooling

GG-CNN 的三層卷積全部靠 stride 縮小特徵圖尺寸,沒有 Pooling 層。GGCNN2 用兩次 MaxPooling(每次 stride=2)把特徵圖尺寸各縮小一半:

python
nn.Conv2d(input_channels, filter_sizes[0], kernel_size=11, stride=1, padding=5, bias=True)
nn.ReLU(inplace=True)
nn.Conv2d(filter_sizes[0], filter_sizes[0], kernel_size=5, stride=1, padding=2, bias=True)
nn.ReLU(inplace=True)
nn.MaxPool2d(kernel_size=2, stride=2)

nn.Conv2d(filter_sizes[0], filter_sizes[1], kernel_size=5, stride=1, padding=2, bias=True)
nn.ReLU(inplace=True)
nn.Conv2d(filter_sizes[1], filter_sizes[1], kernel_size=5, stride=1, padding=2, bias=True)
nn.ReLU(inplace=True)
nn.MaxPool2d(kernel_size=2, stride=2)

這帶來三個好處:感受野隨著空間解析度下降而自然放大、特徵更抽象、後續層的運算量因為特徵圖變小而降低——這是 GGCNN2 能在層數比 GG-CNN 多的情況下,仍維持接近的推論速度的原因之一。

2. 加入 Dilated Convolution(空洞卷積)

這是全篇最核心的改動。在兩次 MaxPooling 之後,GGCNN2 接了兩層 dilation rate 分別為 2 與 4 的卷積:

python
nn.Conv2d(filter_sizes[1], filter_sizes[2], kernel_size=l3_k_size,
          dilation=dilations[0], stride=1,
          padding=(l3_k_size // 2 * dilations[0]), bias=True)
nn.ReLU(inplace=True)
nn.Conv2d(filter_sizes[2], filter_sizes[2], kernel_size=l3_k_size,
          dilation=dilations[1], stride=1,
          padding=(l3_k_size // 2 * dilations[1]), bias=True)
nn.ReLU(inplace=True)

一般 3×3 卷積核涵蓋的是連續九個像素:

text
X X X
X X X
X X X

dilation=2 的卷積核,權重數量還是九個,但涵蓋的實際輸入範圍變成間隔取樣的更大區域:

text
X . X . X
. . . . .
X . X . X
. . . . .
X . X . X

也就是說,不增加任何參數與計算量,卻能讓每個輸出像素「看到」更大範圍的輸入。這正好對應前面提到的問題:模型如果看不到物體的完整邊界,估計出來的抓取寬度與角度就容易失準。疊加兩層不同 dilation rate 的卷積,讓有效感受野在只多兩層的代價下大幅擴張。

3. 用 Bilinear Upsampling 取代 Transposed Convolution

decoder 段不再用 ConvTranspose2d,改成先用 UpsamplingBilinear2d 做純插值放大(不引入權重),再用一般 Conv2d 做特徵融合:

python
nn.UpsamplingBilinear2d(scale_factor=2)
nn.Conv2d(filter_sizes[2], filter_sizes[3], 3, padding=1)
nn.ReLU(inplace=True)
nn.UpsamplingBilinear2d(scale_factor=2)
nn.Conv2d(filter_sizes[3], filter_sizes[3], 3, padding=1)
nn.ReLU(inplace=True)

插值本身平滑、不會產生棋盤格效應,卷積層再負責在放大後的特徵圖上重新學習細節。這是圖像分割、影像生成任務裡常見的替代方案。

完整架構對照

比較項目GG-CNNGGCNN2
Encoder3 層 Conv,無 Pooling4 層 Conv + 2 次 MaxPool
感受野(Context)較小較大(Dilated Conv, d=2、4)
DecoderConvTransposeBilinear Upsampling + Conv
棋盤格偽影容易出現明顯減少
Output Headkernel_size=2 Convkernel_size=1 Conv(純 channel 映射)
Filter 配置[32, 16, 8] 系列[16, 16, 32, 16]
推論速度即時等級與 GG-CNN 相近,仍為即時等級

輸出頭部分,GG-CNN 用 kernel_size=2 的卷積做最終映射,GGCNN2 簡化成 kernel_size=1,純粹做 channel 之間的線性組合、不再混合空間資訊——因為空間層面的特徵提取在前面的 Dilated Conv 已經做得比較充分。

完整原始碼

python
import torch
import torch.nn as nn
import torch.nn.functional as F


class GGCNN2(nn.Module):
    def __init__(self, input_channels=1, filter_sizes=None, l3_k_size=5, dilations=None):
        super().__init__()

        if filter_sizes is None:
            filter_sizes = [16,  # First set of convs
                            16,  # Second set of convs
                            32,  # Dilated convs
                            16]  # Transpose Convs

        if dilations is None:
            dilations = [2, 4]

        self.features = nn.Sequential(
            # 4 conv layers.
            nn.Conv2d(input_channels, filter_sizes[0], kernel_size=11, stride=1, padding=5, bias=True),
            nn.ReLU(inplace=True),
            nn.Conv2d(filter_sizes[0], filter_sizes[0], kernel_size=5, stride=1, padding=2, bias=True),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),

            nn.Conv2d(filter_sizes[0], filter_sizes[1], kernel_size=5, stride=1, padding=2, bias=True),
            nn.ReLU(inplace=True),
            nn.Conv2d(filter_sizes[1], filter_sizes[1], kernel_size=5, stride=1, padding=2, bias=True),
            nn.ReLU(inplace=True),
            nn.MaxPool2d(kernel_size=2, stride=2),

            # Dilated convolutions.
            nn.Conv2d(filter_sizes[1], filter_sizes[2], kernel_size=l3_k_size, dilation=dilations[0], stride=1,
                       padding=(l3_k_size // 2 * dilations[0]), bias=True),
            nn.ReLU(inplace=True),
            nn.Conv2d(filter_sizes[2], filter_sizes[2], kernel_size=l3_k_size, dilation=dilations[1], stride=1,
                       padding=(l3_k_size // 2 * dilations[1]), bias=True),
            nn.ReLU(inplace=True),

            # Output layers
            nn.UpsamplingBilinear2d(scale_factor=2),
            nn.Conv2d(filter_sizes[2], filter_sizes[3], 3, padding=1),
            nn.ReLU(inplace=True),
            nn.UpsamplingBilinear2d(scale_factor=2),
            nn.Conv2d(filter_sizes[3], filter_sizes[3], 3, padding=1),
            nn.ReLU(inplace=True),
        )

        self.pos_output = nn.Conv2d(filter_sizes[3], 1, kernel_size=1)
        self.cos_output = nn.Conv2d(filter_sizes[3], 1, kernel_size=1)
        self.sin_output = nn.Conv2d(filter_sizes[3], 1, kernel_size=1)
        self.width_output = nn.Conv2d(filter_sizes[3], 1, kernel_size=1)

        for m in self.modules():
            if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
                nn.init.xavier_uniform_(m.weight, gain=1)

    def forward(self, x):
        x = self.features(x)

        pos_output = self.pos_output(x)
        cos_output = self.cos_output(x)
        sin_output = self.sin_output(x)
        width_output = self.width_output(x)

        return pos_output, cos_output, sin_output, width_output

    def compute_loss(self, xc, yc):
        y_pos, y_cos, y_sin, y_width = yc
        pos_pred, cos_pred, sin_pred, width_pred = self(xc)

        p_loss = F.mse_loss(pos_pred, y_pos)
        cos_loss = F.mse_loss(cos_pred, y_cos)
        sin_loss = F.mse_loss(sin_pred, y_sin)
        width_loss = F.mse_loss(width_pred, y_width)

        return {
            'loss': p_loss + cos_loss + sin_loss + width_loss,
            'losses': {
                'p_loss': p_loss,
                'cos_loss': cos_loss,
                'sin_loss': sin_loss,
                'width_loss': width_loss
            },
            'pred': {
                'pos': pos_pred,
                'cos': cos_pred,
                'sin': sin_pred,
                'width': width_pred
            }
        }

作為對照,GG-CNN 原始的 encoder-decoder 定義(ConvTranspose2d 解碼、無 Pooling、無 Dilated Conv)可參考 GG-CNN 論文導讀 一文中的架構表。

影響與定位

GGCNN2 的三個改動——MaxPooling、Dilated Convolution、Bilinear Upsampling——沒有一個是深度學習裡的新技術,論文的價值在於證明這幾個運算成本低的元件組合起來,就能在不引入 ResNet、U-Net、Transformer 這類重量級骨幹的前提下,同時解決 GG-CNN 感受野不足與反卷積偽影兩個具體問題,維持住即時抓取偵測需要的低延遲。這也是理解「輕量模型架構改良」的一個很好的案例:不是靠堆更多參數換精度,而是靠更適合任務特性的算子組合。

相關論文推薦