JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

看見整個場景:空洞卷積、ASPP 與上下文

用空洞卷積、多尺度金字塔與 CRF 修整,解決解析度與上下文的拉鋸——這正是 DeepLab 家族背後的關鍵。

解析度與上下文的兩難

在第 2 篇我們遇到一個隱隱的張力:網路很擅長判斷某物「是什麼」,卻在逐像素說出邊界「在哪裡」時弱得多。現在我們把這個張力鋒利成一個具體的工程兩難。在 語意分割 中,每一個像素都必須被指派一個類別標籤——道路、天空、行人、汽車。假設網路正盯著一小塊灰色區域,它到底是道路還是天空?在局部來看,這兩塊幾乎一模一樣。唯一能判斷的方法是看它「周圍」——看到地平線、車道線、建築物。網路需要上下文

網路能在一個像素「周圍」看到多少,有個精確的名字:感受野(receptive field)。一個輸出單元的感受野,就是原始影像中能影響它數值的那塊區域。感受野很小,代表這個單元只看到寥寥幾個像素,很容易被局部的模稜兩可騙過;感受野很大,代表它能把整個場景納入考量。所以我們的第一直覺很簡單:把感受野做大。

感受野:越深層的單元,所總結的原始影像視窗越大。視窗越大=上下文越多。

圖示堆疊在影像上的多層網路;單一深層單元回溯到輸入中一塊寬廣的方形區域,展示它的感受野。

把感受野做大的經典方法是池化(pooling)(或帶步幅的卷積):反覆把特徵圖縮小,例如取每個 2x2 區塊的最大值。每經過一次池化,特徵圖的寬與高就減半,因此每個存活下來的單元現在所總結的區域寬了一倍。疊個幾層,感受野就快速長大。影像分類器正是這樣建立起巨大的感受野,學會像「這是一隻貓」這種全域概念。

池化能把感受野做大——但它丟掉了空間細節。每一次降採樣,解析度就減半。

一個網格經 2x2 最大池化縮成較小的網格,箭頭顯示四個格子塌縮成一個。

空洞(膨脹)卷積

打破這個兩難的關鍵工具是空洞卷積(atrous convolution),也叫膨脹卷積(dilated convolution)。(atrous 是法文「有孔的」à trous。)這個想法美在簡單。一般的 3x3 卷積會取樣九個彼此緊鄰的像素;空洞卷積保留同樣的九個權重,卻把取樣點拉開、在它們之間插入空隙,於是同樣的九個權重現在覆蓋了影像中大得多的區域。

想像你的手放在桌上。五指併攏時,你只能摸到一條窄窄的範圍;把手指張開——用同樣的五個指尖——你現在橫跨的距離寬得多,而你的手卻完全沒有變大。空洞卷積張開的就是卷積核的「手指」。間距由膨脹率(dilation rate)r 控制:r=1 代表沒有空隙(就是一般卷積),r=2 代表每個取樣點之間隔一個空格,r=4 代表隔三個空格,以此類推。

3x3 卷積核的取樣位置。加上膨脹後,九個取樣點仍是九個,卻散布到特徵圖更廣的範圍。

一個 3x3 卷積核疊在網格上,先緊湊地覆蓋 3x3,再帶著空隙覆蓋 5x5 的範圍,但仍只觸及九個格子。

我們把「觸及範圍」算精確。卷積核所覆蓋的跨度——它的有效尺寸——會隨著膨脹率依下面這條小公式增長:

k_e = k + (k-1)(r-1)

膨脹卷積的有效卷積核尺寸。

逐項來讀。k 是真實的卷積核尺寸——單邊上的權重數,例如 3x3 核的 k=3。r 是膨脹率(空隙數 + 1)。k_e 是這個核現在「有效」橫跨的跨度,以輸入的像素為單位。現在代入數字。當 k=3、r=1:k_e = 3 + (2)(0) = 3——一個覆蓋 3x3 視窗的普通卷積。當 k=3、r=2:k_e = 3 + (2)(1) = 5——同樣的九個權重現在橫跨 5x5 的區域。當 k=3、r=4:k_e = 3 + (2)(3) = 9——這九個權重延展到 9x9 的區域。觸及範圍從 3 長到 5 再到 9,但權重數從未變動:永遠是 k*k = 9。而且關鍵在於:因為我們沒有池化、也沒有用步幅,輸出特徵圖的尺寸與輸入「完全相同」。整個把戲就濃縮成一句話:感受野更大、參數不變、輸出解析度不變。

import torch.nn as nn

# Ordinary 3x3 conv: 9 weights, sees a 3x3 window.
conv_plain = nn.Conv2d(256, 256, kernel_size=3, padding=1, dilation=1)

# Dilated 3x3 conv, rate=2: STILL 9 weights, but sees a 5x5 span.
# padding = dilation keeps the output the SAME size as the input.
conv_dilated = nn.Conv2d(256, 256, kernel_size=3, padding=2, dilation=2)

# Both have identical parameter counts:
# weight tensor is [256, 256, 3, 3] either way -> reach grew for free.
assert conv_plain.weight.numel() == conv_dilated.weight.numel()
在 PyTorch 裡,膨脹只是一個參數。把 padding 設成 dilation 就能保住解析度。

現在把這些層疊起來。每一個膨脹層都在「不縮小特徵圖」的前提下擴大感受野,於是網路能累積出足夠的上下文,知道「這團灰色是道路,不是天空」,同時仍對每個像素輸出全解析度的預測——這正是第 1 節那個兩難的直接答案。這也正是 DeepLab 所立基的根本:把分類骨幹後段的池化/步幅換成空洞卷積,把特徵圖留在堪用的解析度(常是 1/8 或 1/16,而不是 1/32),讓膨脹來補回失去的上下文。

空洞空間金字塔池化(ASPP)

單一膨脹率只給你一種固定的觸及範圍。但真實世界不是只有一種尺度。遠遠在路盡頭的一輛車,可能只有 12 個像素高;同一輛車緊貼在鏡頭前,卻塞滿半個畫面。一個對小車剛剛好的膨脹率,對大車來說太短視,反之亦然。所以下一步顯而易見:與其挑一個膨脹率,不如同時、並行地用很多個膨脹率

空洞空間金字塔池化 做的正是這件事。ASPP 取「同一張」特徵圖,並行地在上面跑好幾個空洞卷積——典型是一個 1x1 卷積,加上三個膨脹率如 6、12、18 的 3x3 空洞卷積——然後把它們的輸出串接(concatenate)起來,再用一個 1x1 卷積融合。可以想成同時把好幾顆不同變焦的鏡頭舉向同一個場景:一顆鏡頭緊緊框住遠處的小車,另一顆則把近處整輛車收進來,而你把每顆鏡頭回報的內容合成一個更豐富的視野。因為所有分支都跑在同一張特徵圖上,DeepLab 在「單次前向傳遞」中就能得到穩健的多尺度理解——不需要那個又老又貴的把戲:把輸入影像縮放成許多尺度、再讓網路一遍遍重跑。

ASPP 把同一張特徵圖分散到不同膨脹率的並行空洞分支,再串接起來。

一張特徵圖分送到四個並行分支(1x1 卷積與膨脹率 6、12、18 的空洞卷積),再加上一個全域池化分支,全部匯入一個串接區塊。

ASPP 還有一個值得特別點名的分支:影像層級(全域)池化分支。它把整張特徵圖平均成單一向量,過一個 1x1 卷積,再上採樣回去。為什麼?因為即使是最大的膨脹率,觸及範圍仍是有界的。全域分支注入了一份「整張影像」的摘要——「這是黃昏的高速公路場景」——這能幫忙判斷那些任何局部視窗都無法定奪的像素。當你畫 ASPP 的圖時,要清楚地畫成:好幾條箭頭從一張特徵圖出發、各走各的分支、再在一個串接方塊重新會合,然後才做最後的融合。

# Sketch of an ASPP module (channels omitted for clarity).
# All branches consume the SAME input feature map x.

def aspp(x):
    b0 = conv1x1(x)                      # plain 1x1: fine, local detail
    b1 = atrous_conv3x3(x, rate=6)       # medium context
    b2 = atrous_conv3x3(x, rate=12)      # large context
    b3 = atrous_conv3x3(x, rate=18)      # very large context

    g  = global_avg_pool(x)              # whole-image summary
    g  = conv1x1(g)
    g  = upsample_to(g, like=x)          # back to feature-map size

    fused = concat([b0, b1, b2, b3, g])  # stack all views
    return conv1x1(fused)                # fuse into one representation
ASPP=多個膨脹率的並行空洞分支 + 一個全域池化分支,串接後融合。

金字塔場景解析(PSPNet)

金字塔場景解析(PSPNet)達到同樣的目標——注入多尺度、全域的上下文——但走的是不同的機制。PSPNet 不用多個膨脹率,而是用它的金字塔池化模組:把特徵圖池化成好幾個不同大小的粗網格,各自處理,再全部上採樣回去,然後與原本的特徵串接起來。

具體來說,這個模組會池化成像 1x1、2x2、3x3、6x6 這樣的網格。1x1 這一層把整張特徵圖塌縮成單一格子——那就是整個場景的要旨,是可能達到的最全域摘要。2x2、3x3、6x6 這幾層則把影像切成越來越細的子區域,每個格子各自總結場景的四分之一、九分之一、三十六分之一。每個被池化的網格都會過一個 1x1 卷積(用來降低通道數),上採樣回完整的特徵圖尺寸,再與原本的特徵並排堆疊。最終的結果,從「整張影像」一路到「這塊局部區域」的資訊,全都並陳在一起。

PSPNet 把特徵圖池化成 1x1、2x2、3x3、6x6 的網格,再上採樣並串接回去。

一張特徵圖被池化成四個由粗到細的網格(1x1、2x2、3x3、6x6),各自上採樣後與原圖串接。

為什麼整體要旨這麼重要?想像一個模稜兩可的矩形物體平躺在平面上。在局部看,它可能是路邊的標誌牌,也可能是枕頭。但如果 1x1 那一層已經告訴網路「這是臥室場景」,標誌牌就變得不合情理,枕頭勝出。知道你身處「什麼樣的地方」,能化解任何局部銳化都修不好的物體歧義。這正是 PSPNet 所實現的核心洞見:場景層級的上下文,能規範像素層級的決策。

用 CRF 銳化邊界

空洞卷積與 ASPP 讓 DeepLab 有了強的「語意」答案——它能可靠地知道這裡有一隻狗、那裡有一張沙發。但早期的 DeepLab(v1、v2)有個顯眼的弱點:它的遮罩邊緣是糊的。原因可以一路追回第 1 節。為了讓運算負擔可承受,網路內部仍在縮小過的解析度(例如 1/8)下運行,所以預測是在粗網格上算出來、再上採樣的。把粗糙的標籤圖上採樣會把邊界抹開:狗耳朵那道俐落的輪廓,變成一條柔軟、塊狀的漸層。

為早期 DeepLab 加裝上去的修補,是一個全連接(fully-connected)條件隨機場精修,當作後處理——它在網路「之後」執行,作用於網路的輸出,把標籤吸附到真正的物體邊界上。先建立直覺。CRF 說:鄰近、看起來相像(顏色相近、位置接近)的像素,多半屬於同一個物體,所以它們應該共用同一個標籤——「除非」網路非常確定它們不同。這是兩股力量的拉鋸:「相信網路逐像素的猜測」與「讓鄰近且相像的像素保持一致」。這正是第 1 篇 GrabCut 背後的平滑想法——鼓勵連貫的區域、尊重強邊界——只是被推廣到讓影像中「每一對」像素都能彼此影響,而不只是相鄰的那些。正是這種「全連接」的觸及範圍,讓它得以還原又細又利的邊界。

E(x) = \sum_i \theta_{\text{unary}}(x_i) + \sum_{i<j} \theta_{\text{pairwise}}(x_i, x_j)

CRF 試圖在所有標籤指派 x 上最小化的能量。

逐項拆解。x 是一份完整的標籤指派——影像中每個像素各選一個類別。E(x) 是這份指派的總「能量」(成本);CRF 要尋找讓 E 盡可能小的那個 x。第一個總和,一元項(unary term)θ_unary(x_i),是把像素 i 指派成它那個特定標籤的成本,它直接來自網路的 softmax 輸出:如果網路很確定像素 i 是「狗」,那麼把它標成「狗」的成本就「低」,任何其他標籤的成本就高。所以一元項的意思是「別忽視網路已經學到的東西」。第二個總和,成對項(pairwise term)θ_pairwise(x_i, x_j),遍歷像素「對」,每當像素 i 與 j 的標籤不一致時就課一筆罰金。這筆罰金由顏色與位置上的高斯核塑形:當兩個像素顏色相近、空間上接近時,不一致的罰金「大」;當它們相距很遠或顏色差很多時,罰金「小」。所以成對項的意思是「如果兩個像素長得像又緊鄰,就讓它們一致」。最小化 E 就是在兩者間取得平衡:留住網路有把握的判斷,同時把標籤平滑化,使標籤的變化恰好落在影像中真正的顏色/位置邊緣上——而那正是真正物體邊界的所在。

有個值得記住的具體畫面。取一個恰好落在狗耳朵上、被網路以僅 55% 信心標成「背景」的像素。它對「背景」的一元成本偏低但並不壓倒性。現在看它的鄰居:它們是同樣的棕色毛色、就緊貼在旁邊,而 CRF 以高信心把它們標成「狗」。成對項對「這個像素與它幾乎一模一樣的鄰居不一致」課了一筆重罰。最小化 E 就把這個像素翻成「狗」——邊界吸附回毛緣上。對整張影像反覆這麼做,糊掉的遮罩就變得俐落。

實務中的 DeepLab 家族

我們把所有東西組裝成一個現代 DeepLab v3+ 的樣子,它是這個家族中最廣為使用的成員。它由三個依序運作的部分組成。(1)一個強大的骨幹編碼器——像 ResNet 或 Xception 這樣的分類網路——但把它後段的降採樣換成空洞卷積,因此它能抽取豐富的語意特徵,同時把特徵圖留在堪用的解析度,而不是塌縮到 1/32。(2)在編碼器之上的一個 空洞空間金字塔池化 模組,在單次傳遞中蒐集多尺度的上下文,外加全域影像層級的摘要。(3)一個輕量的解碼器,把 ASPP 的輸出上採樣,並與編碼器中較早、高解析度的特徵融合,以還原俐落的邊界——做著 CRF 從前做的事,但現在是可學習、可端到端訓練的。

注意編碼器–解碼器的形狀又回來了。第 2 篇的 編碼器–解碼器架構——收縮以理解、擴張以定位,並用跳接(skip connection)把細節跨過中間的鴻溝送過去——在這裡正擔起邊界還原的工作。第 2 篇的整個課題與第 3 篇的整個課題彼此扣合:空洞卷積+ASPP 提供上下文(「是什麼」),解碼器提供定位(「在哪裡」)。第 1 節的兩難,是靠把兩個想法「結合」起來解決的,而不是拿一個去換另一個。

DeepLab v3+:空洞骨幹 → ASPP 取上下文 → 輕量解碼器融合早期特徵以求俐落邊緣。

一張流程圖:影像進入空洞編碼器,接著 ASPP,再到一個同時取用低層編碼器特徵的解碼器,產出全解析度遮罩。

  1. v1——引入空洞卷積以維持高解析度,再用 CRF 作後處理來銳化糊掉的邊緣。
  2. v2——加入 ASPP 模組,讓單一模型一次捕捉多種尺度,仍以 CRF 收尾。
  3. v3——改良並加深 ASPP(包含全域影像層級池化分支),並「拿掉」CRF,因為此時網路自己產出的遮罩已經夠利。
  4. v3+——在 v3 之上加入一個輕量、可學習的解碼器,把邊界還原得更俐落——也就是端到端的編碼器–解碼器想法。

把這份清單讀成「逐步累積的想法」,而不是四個彼此無關的模型:每個版本都保留有效的部分,再加上一道銳化。到了 v3+,你就有一個乾淨、快速、端到端的網路——沒有外掛後處理——它用空洞卷積守住解析度、用 ASPP 看見整個場景、用解碼器畫出俐落的邊緣。