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

教網路為每個像素上色:FCN、編碼器–解碼器與 U-Net

把分類用的 CNN 改造成能輸出全解析度標籤圖的機器,並學會判斷它好不好的損失函數與評估指標。

稠密預測的問題

在本系列的第一篇導讀中,我們重新定義了影像分割的目標:不再是替整張影像給一個標籤,而是要替每一個像素都給一個標籤。一張街景照會變成一張「按號碼上色」的地圖,每個像素都被標記為「道路」、「汽車」、「行人」或「天空」。這種逐像素的標記就是語意分割,它屬於領域中所謂的稠密預測——我們必須輸出一個稠密的結果,也就是輸入的每一個位置都對應一個答案,而不是只給一個總結性的判斷。

要明白這為什麼困難,先回想一個普通的分類 CNN 怎麼運作——就是 CNN 系列中那種回答「貓還是狗?」的網路。影像流經一疊又一疊的卷積+池化層。每個卷積偵測某種圖樣;每個池化步驟丟掉一半的寬與一半的高,藉此維持低運算量,也讓後面的層一次能看見影像更大的範圍。經過數個階段後,一張 224×224 的影像縮成一個很小的特徵格——也許是 7×7——但帶有許多通道。最後,全連接層把這個格子攤平成一條長向量,再壓成一串分類分數:每個類別一個數字。

分類 CNN:卷積與池化讓空間格子逐步縮小、通道深度逐步加深,最後由全連接層把一切壓成一條分類分數向量。

影像通過一連串越來越小、越來越深的卷積+池化階段,最終變成一條攤平的分類機率向量。

這種設計對分類非常出色,對分割卻是場災難,因為沿途有兩樣東西被摧毀了。第一,池化丟掉了空間解析度:一個 7×7 的格子根本無法指出原本約五萬個像素中哪一個是汽車、哪一個是道路。第二,全連接層則徹底丟掉了空間格子本身——一旦把格子攤平成向量,網路就再也不知道「這個分數來自左上角」、「那個來自右下角」。幾何資訊消失了。我們只剩下影像裡有什麼,卻不再知道它在哪裡

全卷積網路

第一個答案來自 2015 年,就是全卷積網路(FCN),它的名字本身就是承諾:完全沒有全連接層——每一層都是卷積。第一步是個巧妙的轉換。一個把 7×7×512 格子映射成 4096 個數字的稠密層,在數學上等價於一個卷積核為 7×7 的大卷積,所以我們可以拿掉那個稠密分類器,改用 1×1 卷積,讓它在特徵格的每一個位置都輸出每類別各一個分數。好處有二:輸出變成一個粗略的分類分數格——也就是每個類別各一張低解析度熱力圖(一個 7×7×C 的方塊,其中「汽車」通道會在車的位置亮起、「道路」通道在路的位置亮起)——而且因為不再有固定尺寸的稠密層,網路現在可以接受任意大小的輸入;較大的影像只會產生較大的熱力圖。

在 1×1 卷積取代稠密層後,輸出是一疊位於粗略格子上的逐類別熱力圖——每個類別一個通道。

一個多通道的小型格子,每個通道標示出某個類別可能出現的位置。

我們現在握著一張 7×7 的熱力圖,卻需要一張 224×224 的標籤地圖。這就是第二步:上採樣,用轉置卷積來完成(有時稱為反卷積——這名字有點誤導)。把它想成步幅的逆操作。帶步幅的卷積會跳過輸入,讓輸出變小;轉置卷積則把輸入撐開、拉遠,讓輸出變大。具體來說,每一個輸入格都會把一個學到的卷積核(依該格數值縮放後)「蓋章」印到更大的輸出格上,凡是印章重疊之處,數值便相加。這個卷積核是學出來的,所以網路會自行發現該如何填補空缺,而不是遵循像最近鄰那種固定規則。

步幅與填補決定卷積核如何移動。在一般卷積裡,步幅會跳過位置而縮小地圖;在轉置卷積裡,同樣的步幅則把輸入間隔拉開而放大地圖。

一個卷積核在帶填補的格子上滑動,示意步幅長度與填補如何改變輸出尺寸。

O = (I-1)\,s - 2p + k

轉置卷積的輸出尺寸。

這個式子讀作:輸出邊長 O 是從輸入「長大」出來的,而不是縮小。I 是輸入的空間尺寸(一邊有幾格),s 是步幅——在這裡就是上採樣倍率——p 是填補,k 是卷積核大小。代入一個很小的情況:I = 2、s = 2、p = 0、k = 2,得到 O = (2−1)·2 − 0 + 2 = 4,地圖從 2 翻倍成 4——正是步幅為 2 的上採樣該有的效果。把它跟你已學過的一般卷積縮小公式 O = ⌊(I − k + 2p)/s⌋ + 1 對照:當 I = 4、k = 2、s = 2 時得到 2,也就是 4 → 2。兩者互為鏡像——同一個步幅,在一般卷積裡讓你跳過並縮小,在轉置卷積裡卻把輸入撐開並放大。(FCN 真正的地圖需要 32 倍上採樣,因為五次池化各把它減半:2⁵ = 32。)但仍有一個陷阱:一口氣放大 32 倍會糊得無可救藥,因為精細的細節早在池化階段就被摧毀了。FCN 的解法是跳接——先放大一部分,然後加上一個取自較早、解析度較高層(1/16 與 1/8 的地圖)、較銳利的分類分數預測,再繼續放大。其成果(FCN-8s)的邊緣明顯更銳利,而這個「把深但粗淺但細結合起來」的想法,正是接下來兩節的種子。

編碼器–解碼器的核心想法

FCN 的兩半——先縮小、再長回去——凝結成了今日主宰分割領域的模式:編碼器–解碼器架構(你也會聽到「收縮路徑與擴張路徑」的說法)。編碼器是收縮路徑:它用卷積與池化反覆下採樣,就像一個分類骨幹網路,把影像萃取成越來越小、越來越深的特徵圖。

池化在每一步把空間尺寸減半——便宜又強大,卻恰好丟掉了分割所需的精細位置資訊。

一個 4×4 的格子經最大池化縮成 2×2,每個輸出格概括一個 2×2 區塊。

為什麼要下採樣?因為它能擴大感受野——也就是單一個深層特徵能「看見」的那一塊原始影像範圍。一開始,一個特徵只對一條微小的邊緣有反應;經過數次池化後,一個特徵就能概括一大片區域,並認出「這是狗的一部分」。下採樣是用解析度換取脈絡:網路越來越懂得有什麼存在,卻逐步忘記它在哪裡。這裡的直覺是瞇眼——把眼睛半閉,一個雜亂的場景就會化成它的大意(「廚房」、「街道」),即使你已無法看清精細的細節。

每個池化階段都擴大感受野:一個深層特徵會對輸入中一大片區域有反應,獲得脈絡卻犧牲了精確位置。

一組層層相套的方框,顯示單一個深層神經元如何回溯對應到輸入影像中越來越大的區域。

在最深處坐著瓶頸層(bottleneck):那是最小、最壓縮、最具語意的表徵——「什麼」很豐富,「哪裡」幾乎全空。解碼器(擴張路徑)現在要走相反的旅程。它反覆上採樣——用轉置卷積或更單純的內插——一步一步重建空間解析度,直到地圖回到原尺寸,原影像的每個像素各對應一個分類分數。如果說編碼器是瞇眼去抓住大意,那麼解碼器就是重新睜開眼睛,把每一道邊界都精準地放回原位。

U-Net:保住細節的跳接

U-Net 於 2015 年為生醫影像而提出,它是編碼器–解碼器架構被打磨到近乎完美的版本——即使十年後,它仍是預設首選。把它畫出來,就像一個對稱的字母 U:編碼器沿左臂下行、不斷下採樣,解碼器沿右臂上行、不斷上採樣,瓶頸層位在底部。它特別之處在於那一組跳接:它們筆直地橫越 U 字,把每個編碼器層級連到相同解析度的解碼器層級。

關鍵的訣竅在這裡,而且簡單得令人愉快。在每個解碼器層級,於繼續往上之前,U-Net 會取出當初從編碼器存下、解析度相符的那張高解析度特徵圖,把它串接(concatenate)到上採樣後的解碼器特徵圖上——也就是沿著通道軸把兩者疊起來——再用幾個卷積把兩者融合。如此一來,解碼器就永遠不必去憑空想像它在下採樣時失去的精細邊界細節:那些細節會由編碼器以恰好正確的解析度、在恰好需要的時刻,直接交還回來

U-Net。請逐層級閱讀:沿左臂下行(編碼/下採樣),跨過每一條水平箭頭把一張編碼器特徵圖複製到解碼器,再沿右臂上行(上採樣+串接+卷積),回到一張原解析度的標籤地圖。

一張 U 字形示意圖,左側是收縮路徑、右側是擴張路徑,水平的跳接箭頭連接著相對應的層級。

為什麼這個做法如此有效,尤其是對銳利的邊緣?邊界屬於高頻資訊——它們存在於最早、解析度最高的特徵圖中,也正是池化最先抹糊掉的東西。藉由把那些早期特徵圖直接接到解碼器,U-Net 讓最後幾層能用真正細緻的證據去銳化物件輪廓,而不是靠一個模糊的猜測。這也是它能稱霸醫學影像的原因:臨床資料集往往很小(幾十張標註過的掃描,而不是數百萬張),而跳接讓 U-Net 的資料效率出奇地高;同時,當你要為一個器官或腫瘤描出輪廓以供治療時,精準的邊界至關重要。

有一個與 FCN 的微妙對比值得點明。FCN 的跳接是把編碼器的*預測*相加進解碼器(逐元素相加);U-Net 的跳接則是把編碼器的*特徵*串接起來(疊加通道),再讓後續的卷積去決定如何結合它們。相加比較省、且能維持通道數固定,但它會逼迫兩個來源擠進同樣的「槽位」。串接則讓兩道資訊流都完整保留,給網路自由去學出最佳的融合方式——代價是通道更多(運算也稍多)。U-Net 選擇串接,正是它的邊界看起來如此乾淨的重要原因之一。

# One U-Net decoder step: upsample, then CONCATENATE the
# matching encoder feature map before convolving to fuse.
def decoder_block(x, skip, up, conv):
    x = up(x)                        # transposed conv: double H and W
    x = torch.cat([x, skip], dim=1)  # glue channels: decoder + encoder detail
    x = conv(x)                      # fuse the two streams with normal convs
    return x

# FCN would instead ADD a prediction:  x = up(x) + skip_pred
用 PyTorch 風格的偽程式碼寫出 U-Net 的一個解碼步驟:先上採樣,沿通道軸串接相符的編碼器特徵圖,再卷積融合。

訓練:為遮罩評分的損失函數

我們究竟要怎麼訓練這些網路?最簡單也最常見的損失是逐像素交叉熵。想法直接得令人意外:把每一個像素都當成它自己的小型分類問題——「這個像素是道路、汽車還是天空?」——在那裡算出一般的分類損失,然後把這個損失對所有像素取平均。如果預測的標籤地圖處處正確,損失就接近零;凡是它很有把握卻答錯的地方,損失就會很大。

\mathcal{L}_{\text{CE}} = -\frac{1}{N}\sum_{i=1}^{N}\sum_{c=1}^{C} y_{i,c}\,\log \hat{y}_{i,c}

逐像素(平均)交叉熵損失。

逐一拆解符號:N 是像素數,C 是類別數。y_{i,c}真實標註,以 one-hot 表示——當像素 i 的真實類別是 c 時為 1,否則為 0。ŷ_{i,c} 是網路預測(經過 softmax 後)像素 i 屬於類別 c 的機率。因為 y 是 one-hot,內層加總只會保留指派給正確類別的那個對數機率;負號把「機率高=好」轉成「損失低=好」,而 1/N 則對整張影像取平均。現在談它的弱點:假設你要分割一個只占 1% 像素的小腫瘤。一個偷懶的網路可以對每一個像素都預測「背景」,瞬間就有 99% 的準確率與極小的交叉熵——但它其實什麼都沒找到。這就是類別不平衡的陷阱,而且它咬得最兇的地方,剛好就是分割最重要的地方:細細的道路標線、微小的病灶、遠處的行人。解方是別再獎勵逐像素的正確,而改為獎勵與目標的重疊程度——這正是Dice 係數所衡量的。

D = \dfrac{2\,|P \cap G|}{|P| + |G|}, \qquad \mathcal{L}_{\text{Dice}} = 1 - D

Dice 係數(重疊分數)與 Dice 損失。

Dice 係數可以讀成:兩倍的共同面積,除以雙方各自宣稱的總面積P 是模型預測為該類別的像素集合;G 是該類別的真實標註像素集合;|·| 表示「數像素個數」;而 |P ∩ G| 是交集——雙方都同意的那些像素。為什麼要乘以 2?若沒有它,即使是完美預測(P = G)也只會得到 |P|/(|P|+|P|) = 0.5;乘上 2 才能把完美吻合重新縮放成 1,而完全沒有重疊則是 0。我們用一張 2×2 影像把它具體化,其中 1 代表該類別。真實標註 G = [[1,1],[0,0]],所以 |G| = 2;預測 P = [[1,1],[1,0]],所以 |P| = 3;它們在上排兩個像素上一致,所以 |P ∩ G| = 2。於是 D = 2·2 / (3 + 2) = 4/5 = 0.8。要把一個分數(越高越好)轉成一個損失(越低越好),我們只需定義 Dice 損失 = 1 − D = 0.2,再用梯度下降去最小化它。因為分子與分母都會隨物件大小一起縮放,龐大的背景就不會再淹沒一個微小的目標——這正是我們想要的不平衡解方。

import numpy as np

# One class on a 2x2 image; 1 = "this pixel is the class"
G = np.array([[1, 1],
              [0, 0]])              # ground truth, |G| = 2
P = np.array([[1, 1],
              [1, 0]])              # prediction,   |P| = 3

inter = np.sum((P == 1) & (G == 1))      # pixels both agree -> 2
dice  = 2 * inter / (P.sum() + G.sum())  # 2*2 / (3 + 2) = 0.8
dice_loss = 1 - dice                     # 0.2  (this is what we minimize)
print(dice, dice_loss)                   # 0.8 0.2
用程式碼重現同一個 2×2 範例:交集除以面積總和得到 Dice = 0.8,因此 Dice 損失是 0.2。

衡量成效:IoU 與相關指標

訓練只是故事的一半;我們也需要誠實地衡量成效,而同樣的不平衡陷阱也潛伏在評估裡。單純的像素準確率——也就是被正確標記的像素比例——之所以會誤導人,原因和交叉熵一模一樣:那個 99% 準確的腫瘤偵測器其實什麼都沒找到。標準的解方是交集除以聯集(Intersection-over-Union,IoU),又稱 Jaccard 指數。對單一類別而言,它問的是:在所有被預測真實標註認定為此類別的像素中,雙方一致的占多少比例?

IoU 是重疊區(交集)除以合併區(聯集)。此處以方框示意;在分割中,完全相同的概念被套用在像素集合上。

兩個重疊的矩形;中間小小的重疊區是交集,整個被覆蓋的區域是聯集。

\text{IoU} = \dfrac{|P \cap G|}{|P \cup G|}

單一類別的交集除以聯集(Jaccard 指數)。

沿用前面的 P 與 G:P ∩ G 是一致處(2 個像素),而 P ∪ G聯集——凡是被預測標註為該類別的每個像素,各算一次。在我們的 2×2 範例中,P(3 個像素)與 G(2 個像素)合起來涵蓋三個相異的格子,所以 |P ∪ G| = 3,IoU = 2/3 ≈ 0.667。比較兩者的分母:Dice 除以 |P| + |G|(把重疊處算了兩次),而 IoU 除以聯集(只算一次)。因為 IoU 的分母實際上較大,所以 IoU 永遠是比較嚴格、比較小的那個數字——事實上恆有 Dice ≥ IoU,這裡就是 0.8 ≥ 0.667。它們是表親:都是純粹的重疊度量,彼此以 D = 2·IoU/(1+IoU) 相關。Dice 常被當成損失;IoU 則是慣用的回報指標。

\text{mIoU} = \dfrac{1}{C}\sum_{c=1}^{C}\text{IoU}_c

對全部 C 個類別取平均的 mIoU。

一個場景有許多類別,所以我們對每個類別各算一次 IoU,再取平均:IoU_c 是類別 c 的 IoU,C 是類別數,而 mIoU——平均 IoU——就是它們的簡單平均。這種逐類別平均正是重點所在。若改成把所有像素混在一起算,「天空」與「道路」這種動輒數百萬像素的類別就會主宰分數,模型即使完全忽略「行人」也不太會被懲罰。藉由讓每個類別都只有一票,mIoU 逼著網路在罕見、微小的類別上也要表現好——這正是為什麼在 Cityscapes、Pascal VOC 這類基準上,mIoU 是最受矚目的數字。有了這些指標在手,下一篇導讀就能把什麼 vs 哪裡的拉鋸推得更遠:如何讓編碼器擁有極大的視野,卻又不摧毀解碼器所需的解析度。