跳至主要內容

    影像分割:FCN、U-Net 與像素級判斷

    從照片與遮罩的差別,拆開全卷積網路、上採樣與跳接,再用小矩陣檢查標籤、損失和評估

    sunny 桑尼· · 33 MIN READ · 153 次瀏覽

    影像分割:FCN、U-Net 與像素級判斷
    本文目錄

    假設你想把一張犀牛照片的背景去掉。分類模型回答「這張照片有犀牛」,物件偵測再給你一個包住犀牛的方框;但方框裡還有地面、欄杆,以及腿與腿之間的空隙。要決定哪些像素應該留下,就需要更細的輸出。

    語義分割把問題變成:每一個像素屬於哪個類別? 影像中的位置還在,模型要為每個位置提供分類分數,最後轉成類別遮罩。難處在於,判斷「這是什麼」需要看周圍脈絡,畫準「邊界在哪裡」又需要保留位置細節。

    這篇從一組真正的照片與遮罩開始,理解 FCN 和 U-Net 如何處理這個矛盾,再檢查常見的實作錯誤。本文實跑的是手設矩陣與 logits 的 CPU 算例,沒有訓練分割網路,也沒有把來源遮罩冒充本站模型的預測。

    照片、類別遮罩與物件個體,是三種不同資訊

    圖 1|左為來源照片,右為互動式分割取得的遮罩,白色表示犀牛前景、黑色表示背景;手機版改為上下排列。這是 MartinThoma 的既有示例,並非 FCN/U-Net 預測或本站實測。照片使用 Commons 的 960×640 縮圖;遮罩從原始 1800×1200 以最近鄰縮為相同尺寸,兩者維持完整 3:2 構圖。照片:原頁,MartinThoma,CC BY 3.0;遮罩:原頁,MartinThoma,CC0。本文只縮放與編排;封面沿用同組照片與遮罩,等比縮放並加白邊。

    照片的 RGB 值描述顏色;訓練用遮罩中的數字描述類別。假設背景是 0、犀牛是 1,遮罩中的 1 不是灰階亮度,也不是「有一隻犀牛」。它表示這個位置的標籤是犀牛類。

    常見的彩色分割圖,通常只是把類別編號映射到方便辨識的顏色。模型真正需要的標籤,可能是一張整數矩陣;若資料提供 RGB 標註圖,還需要按照資料集的色票把每個顏色轉回類別 ID,不能把三個 RGB channel 直接當成三類。

    任務一張有兩隻犀牛的照片,應輸出什麼?能否直接數出兩隻?
    影像分類整張圖有犀牛這個類別不能
    物件偵測各物件的框、類別與分數可依偵測框計數,但受漏檢與重複框影響
    語義分割每個像素的類別,兩隻都可標成犀牛不能只靠類別 ID 區分個體
    實例分割每個物件各有遮罩與類別輸出包含個體區分,但仍可能漏檢或分錯

    把同一類的連通區數量當成物件數,也未必可靠:兩隻靠在一起可能黏成一塊,一隻被遮擋則可能分成多塊。Torchvision 的實例分割範例中,每個 instance 都有自己的 mask,和一張只存語義類別的遮罩不同。

    FCN:把整張圖的一個答案,改成一張分類分數地圖

    在卷積與感受野的討論中,特徵圖的每個位置已經對應輸入中的一片區域。影像分類常把這些空間資訊彙整成整張圖的向量,再產出一個類別;分割則希望保留空間網格,讓不同位置有不同答案。

    2015 年 Long、Shelhamer、Darrell 的 FCN 論文把成熟的分類網路改成密集預測網路,並以像素級損失端到端微調。這不是影像分割的起點,也不是第一個出現卷積式密集輸出的工作;它的重要性在於把分類預訓練、全卷積計算、可學習上採樣與多層融合串成一套有效的方法。

    圖 2|按原圖由下往上讀:CNN 提取特徵,1×1 卷積產生類別分數,轉置卷積提高空間解析度。本圖只保留 D2L 原圖的三個完整網路框與兩支框間箭頭,已移除來源照片、來源遮罩與其連接箭頭;不是原論文 FCN-32s/16s/8s 全部跳接的示意。來源:D2L,Fully Convolutional Networks,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文截取、轉為 PNG;改作同採 CC BY-SA 4.0。

    先看這個基本結構,不必一次記完所有層。假設特徵圖形狀是 N×D×h×w,N 是批次大小、D 是特徵通道數,h×w 是較粗的空間網格。

    1×1 卷積在每個位置混合 D 個特徵通道。若要分 C 類,它可以把形狀轉成 N×C×h×w:每個位置都有 C 個未正規化分數,也就是 logits。1×1 卷積主要在這裡改變通道數,不會自行把粗網格變回細網格。

    接著透過上採樣,把分數地圖對齊目標像素網格,得到例如 N×C×H×W 的輸出。對互斥多類別任務,在類別維度取 argmax,才得到 N×H×W 的類別 ID。上採樣的對齊、裁切及 padding 都要明確安排,不能只看到「全卷積」就認定任何輸入尺寸都會自動原樣對齊。

    例如 D2L 的教學實作以 ResNet-18 特徵為基礎,320×480 輸入得到 10×15 的粗特徵,再安排上採樣。那是教材的一個具體設定,不是 2015 原論文的全部架構,也不代表所有 FCN 都固定輸入 320×480。

    FCN-32s、16s、8s:加入細節時,融合的是什麼?

    如果把非常粗的類別分數圖直接放大,輸出像素數雖然增加,原本丟失的邊界資訊卻不會因此自動回來。FCN 原論文於是把較淺、解析度較高的層接回來。

    原論文版本主要做法名稱在提醒什麼?

    FCN-32s
    主要做法
    從最終 stride 32 的粗分數圖一次上採樣回像素網格
    名稱在提醒什麼?
    粗預測的位置間隔
    FCN-16s
    主要做法
    把粗分數圖上採樣 2 倍,與 pool4 經 1×1 卷積產生的類別分數對齊相加,再上採樣
    名稱在提醒什麼?
    融合到較細的 stride 16 網格
    FCN-8s
    主要做法
    進一步納入 pool3 的類別分數,在 stride 8 網格融合,再上採樣
    名稱在提醒什麼?
    又加入更細的空間資訊

    這裡的數字不是卷積層數、類別數,也不是輸出圖片的固定寬度。原論文的 skip 融合是類別分數逐位置相加。兩邊先成為相同類別數、相同空間對齊的張量,才能合理相加。

    若兩組分數各是 N×C×h×w,相加之後仍是 N×C×h×w。這和稍後 U-Net 把特徵通道接在一起不同。兩者都叫 skip connection,不能因此把操作混為一談。

    想看原始架構與三個版本的結果,可對照 FCN 論文圖 3、圖 4 與第 4.2 節。本文以連結提供原論文圖,不把論文實驗數字寫成本站跑出的成績。

    U-Net:把編碼時的細節交回解碼端

    U-Net 也處理「理解脈絡」與「定位細節」的拉扯。左側的 contracting path 透過卷積與下採樣擷取脈絡;右側的 expanding path 提高空間解析度,再利用左側對應層的特徵改善定位。

    圖 3|這是 Mehrdad Yazdani 的簡化架構,畫的是 3×256×256 輸入到 k×256×256 輸出,沒有標出原版的 crop;不能當成 2015 原始拓樸。橫向連接把編碼特徵交給右側 concat,保留整體圖以確認連接關係;手機版先保留全圖,再放大三條同層串接路徑;局部省略被裁斷的框外跨層連線碎片,跨層關係以全圖為準,不代表新的獨立網路。來源:Wikimedia Commons 作品頁,Mehrdad Yazdani,CC BY-SA 4.0。本文保留原圖並製作手機局部編排,改作同採 CC BY-SA 4.0。

    假設解碼端目前是 N×128×h×w,同一解析度的編碼特徵是 N×64×h×w。沿通道維度 concatenation 之後,得到 N×192×h×w;後面的卷積再學習如何使用這些資訊。這不是把兩張分數圖平均,也不會直接得到已經判好的遮罩。

    右側多了細節來源,不代表辨識必然正確。陰影、標註錯誤或不同設備的影像差異,仍然可能讓模型分錯;skip connection 提供的是資訊路徑,不是保證。

    還要把原版 U-Net與圖 3 這類後來的簡化版本分開。Ronneberger、Fischer、Brox 的 2015 論文使用不補邊的 3×3 卷積,每次卷積都縮小特徵圖,因此橫向特徵要先 crop,再和解碼特徵串接。

    原版圖 1 的位置單一空間邊長變化

    輸入與第一次兩個 valid convolution
    572 → 570 → 568
    第一次 max pooling 與兩個卷積
    284 → 282 → 280
    接下來兩級編碼
    140 → 138 → 136;68 → 66 → 64
    最底層
    32 → 30 → 28
    四級解碼,每級放大後再做兩個卷積
    56 → 54 → 52;104 → 102 → 100;200 → 198 → 196;392 → 390 → 388
    最終分割圖
    388×388,對應輸入中央的區域

    所以,原始圖中的 572×572 輸入對應 388×388 輸出不是算錯,也不是一定要把輸出任意拉伸回 572。原文搭配 overlap-tile 與邊界鏡射處理大圖。現在有些實作用 padding 維持尺寸,是可行的設計選擇;比較或重現時要說是哪一種。

    原論文也使用大量資料增強,並在訓練損失中特別加重接觸細胞之間的分隔區域。這些是完整方法的一部分,不能把成效只歸功於 U 字形。它的前景/背景輸出,也不能因圖中細胞用不同顏色標示,就直接解讀成網路原生輸出每個細胞的 instance ID。

    上採樣增加位置,轉置卷積不會把原圖倒算回來

    上採樣是增加空間網格的操作。它可以採固定插值,也可以採帶有可學習權重的轉置卷積;不同網路會在不同位置使用它們。

    圖 4|輸入與 kernel 都是 [[0,1],[2,3]],stride 1、無 padding。每個輸入數值乘上 kernel,放到對應位置,重疊的數值相加,最後得到 3×3。這是原作者的整數算例,不是訓練結果。來源:D2L,Transposed Convolution,Aston Zhang、Zachary C. Lipton、Mu Li、Alexander J. Smola,CC BY-SA 4.0。本文轉為 PNG,手機另編排原圖局部;改作同採 CC BY-SA 4.0。

    把這張圖想成「分送後相加」會比「還原圖片」更容易檢查:左上角的 0 送出一片零;右上角的 1 送出一片 kernel,位置向右移;左下角的 2 送出兩倍 kernel,位置向下移;右下角的 3 送出三倍 kernel,向右、向下各移一格。中間格會收到多份貢獻,因此要加總。

    stride 1 輸出:
    0   0   1
    0   4   6
    4  12   9
    
    圖 5|同一組輸入和 kernel,改為 stride 2,四片貢獻的起點間隔拉大,輸出變成 4×4。這裡 kernel size 與 stride 都是 2,不能拿它證明所有轉置卷積都會重疊不均。來源、作者與授權同圖 4;本文轉為 PNG,手機另編排原圖局部,改作同採 CC BY-SA 4.0。

    單一方向的尺寸可用 PyTorch 2.3 文件核對:

    輸出長度 = (輸入長度 − 1) × stride − 2 × padding
             + dilation × (kernel_size − 1) + output_padding + 1
    

    圖 4 是 (2−1)×1 + (2−1) + 1 = 3;圖 5 是 (2−1)×2 + (2−1) + 1 = 4。高度與寬度分別代入,不能把通道數拿來套這條式子。

    「轉置」來自對應線性運算的矩陣轉置。若不含 bias 的卷積寫成 y = W x,對應轉置運算是 Wᵀ y,一般並不是 W⁻¹ y。即使輸出形狀與原輸入相同,也不表示內容相同。後面的程式會用一個 3×3 矩陣實際驗證。

    stride 大於 1 時,不同輸入尺寸還可能被普通卷積縮到同一尺寸。output_padding 用來決定轉置卷積的輸出形狀歧義,不是在輸出圖片周圍真的補一圈零,更不是補回失去的資訊。

    轉置卷積也可能出現棋盤格狀 artifact。Distill 的分析指出,kernel 與 stride 造成不均勻重疊時,不同位置接收的貢獻數量可能不同。這是需要檢查的風險,不是「所有轉置卷積一定產生棋盤格」。調整 kernel/stride 或改用 resize 後接卷積,都需要觀察實際輸出,不能只換一個層名就保證消失。

    開始訓練以前,先確認 mask 沒有被處理壞

    分割模型很容易在「程式能跑」的情況下學錯東西。尤其 image 與 mask 看起來都是圖片,卻不應共用所有處理方式。

    第一,兩者要使用同一個空間變換。若照片向左裁 20 像素,遮罩也必須裁相同區域;隨機翻轉、旋轉也要同步。分開呼叫兩次隨機裁切,得到的是兩個各自合法、但彼此不對齊的輸入。

    第二,離散類別 ID 的遮罩縮放,應明確選擇最近鄰這類保留標籤的方式,避免把 ID 當成可混合的亮度。照片可以採雙線性插值,類別遮罩照搬同一設定卻可能製造不存在的類別。

    本文用 PyTorch nearest-exact,把下面的 2×2 遮罩縮放為 3×3:

    原遮罩:       nearest-exact:     bilinear,align_corners=False:
    0  2          0  2  2             0  1  2
    0  2          0  2  2             0  1  2
                  0  2  2             0  1  2
    

    原來只有 0 和 2,雙線性插值卻產生 1。即使 1 剛好也是合法類別,那也不是這張標註真的提供的資訊;最後 .long() 不會自動修復語義。官方 interpolate 文件另說明 nearest 和 nearest-exact 的歷史對齊差異,跨函式庫重現時要記錄具體模式。

    這個建議針對「類別索引遮罩」。已經是連續機率的 soft target 或分數圖,則要按它的定義處理,不能把所有叫 mask 的資料都強制最近鄰。

    第三,要保留 ignore label 的意義。有些資料以 255 表示不評估或未標註,不是第 256 類,也不應直接變成背景。讀圖、色票轉換、裁切、縮放、loss 和 metric 都要沿用相同約定。

    CrossEntropy 與 BCE:先看每個像素允許幾個答案

    如果每個像素只能選一類,例如背景、道路、車輛三選一,可以用多類別交叉熵。以 class-index target 格式為例:

    項目互斥 C 類,使用 CrossEntropy一個獨立二元前景,使用 BCEWithLogits
    logits浮點 N×C×H×W浮點 N×1×H×W
    target整數 long,N×H×W浮點,與 logits 同形狀
    target 的意義每像素一個類別 ID,另可指定 ignore_index每位置的二元目標,值在 0 到 1
    送入 loss 以前不先 softmax不先 sigmoid
    轉成硬預測的常見方式在類別維度 argmaxsigmoid 後依閾值判斷,0.5 是一個選擇

    兩個類別也可以用兩個 logits 加 CrossEntropy;不是「二元任務只能 BCE」。若同一像素允許多個獨立標籤,可以用多個二元 channel,但這和互斥多類別的問題定義不同。

    CrossEntropy 在內部處理 logits 對應的 log-softmax;BCEWithLogits 則把 sigmoid 和 binary cross-entropy 合在一起,以提高數值穩定性。先手動做 sigmoid 再交給 BCEWithLogits,等於把它預期的 logits 換成機率,改掉了問題。CrossEntropy 文件也允許同形狀的機率 target,但那是另一種 target 格式,不能和上表的整數索引要求混用。

    BCEWithLogitsLoss 沒有 CrossEntropy 那樣的 ignore_index 參數。有未標註像素時,要自行選取有效元素或以 unreduced loss 遮罩後除以有效權重;若沒有任何有效元素,須明確跳過或另定處理方式,不能期待空集合平均仍有意義。CrossEntropy 的 mean reduction 在全部 target 都是 ignore 時,同樣沒有有效平均分母;不能把指標函式回傳的 null 當成 loss 會自動安全的證據。

    同樣地,類別不平衡也不會因為網路叫 U-Net 就消失。加權交叉熵、抽樣或 Dice 類型損失可以是處理方向,但權重、空集合與 reduction 定義都會改變目標;本文沒有把其中任何一種宣稱為所有資料的最佳配方。

    IoU 與 Dice:相同預測,也可能得到不同的「平均」

    對某個類別,把預測前景集合叫 A、真實前景集合叫 B:

    IoU  = 交集像素數 / 聯集像素數
         = TP / (TP + FP + FN)
    
    Dice = 2 × 交集像素數 / (預測像素數 + 真實像素數)
         = 2TP / (2TP + FP + FN)
    

    在分母有效、同一組硬遮罩的前提下,Dice = 2×IoU / (1+IoU)。例如 IoU 是 0.5,Dice 是 2/3;不能因 Dice 數字較大,就說同一份預測突然變好了。各類先換算再平均,也不等於把平均 IoU 直接代入這條式子。

    看這個本文手設例子,255 是忽略位置:

    真值 target:    預測 pred:
    0  0   1        0  1  1
    0  2 255        0  0  2
    

    先排除真值為 255 的位置,再建立「列是真值、欄是預測」的混淆矩陣:

    真值\預測0 背景12
    0背景210
    1010
    2100

    五個有效像素有三個判對,pixel accuracy 是 0.6。各類 IoU 分別是 0.5、0.5、0;含背景的 macro mean 是 1/3,只平均前景類 1、2 則是 0.25。同一組輸出,僅改「是否把背景納入平均」,數字就不同。

    本文程式採用的約定是:先在所有有效像素上累積混淆矩陣,再逐類計算、等權平均;不是每張圖各自算分再平均。函式只彙整當次傳入的像素;評估整份資料時,須傳入完整集合,或逐批累加混淆矩陣後再算比值,不能直接平均每批 mIoU。若某類在真值和預測都沒有出現,分母為零,回傳 None 並略過該類平均,不加 epsilon。若真值沒有該類、卻預測出該類,聯集非零,該類 IoU 是 0,仍要受到處罰。

    全部都是 ignore 的情況,程式回傳沒有有效分數,而不是漂亮的 1。別人的工具可能採不同空集合規則,或固定平均全部類別;比較前要把背景、ignore、平均範圍、平滑項與空集合規則一起核對。

    當背景占大部分時,全猜背景也可能得到高 pixel accuracy。IoU/Dice 能補上重疊品質,但也不能單獨描述邊界錯在哪裡、漏掉多少個物件,或應用的錯誤成本。實務評估還應回看原圖、真值和預測,按情境分析細小物件、邊界與不同來源影像的失敗案例。

    用一個 CPU 練習,把處理規則跑過一次

    下方完整程式使用手設 logits、類別遮罩和卷積核,不需要資料集、GPU 或預訓練權重。本文已在 Python 3.10.12、PyTorch 2.3.1+cu118 的 CPU 執行;套件版本中的 CUDA 標記不表示本練習用了 GPU,也不是宣稱這是最新版本。

    在已有 PyTorch 的 Python 環境,把程式存為 segmentation_checks.py,執行 python3 segmentation_checks.py。若要建立獨立 CPU 環境,以下是 WSL/Linux 的準備指令;它會下載套件,與本練習不下載模型權重是不同的事:

    python3 -m venv .venv
    source .venv/bin/activate
    python -m pip install torch==2.3.1 --index-url https://download.pytorch.org/whl/cpu
    python segmentation_checks.py
    

    這是固定版本的重現方式,安裝需求可另查 PyTorch 官方歷史版本頁。本文沒有另行執行新建環境的下載安裝;實際運算驗證使用前述已存在環境。

    程式會檢查插值是否產生新 ID、零 logits 的兩種 loss、含 ignore 的混淆矩陣、缺席類別和全忽略情況,再驗證圖 4/5 的轉置卷積算例,以及「形狀回來、數值卻沒有回來」的反例。

    """CPU arithmetic examples; no training, image inference or downloaded weights.
    
    Checked with Python 3.10.12 / torch 2.3.1+cu118, using CPU only.
    All masks and logits below are hand-specified teaching inputs.
    """
    
    import json
    import math
    import platform
    
    import torch
    from torch.nn import functional as F
    
    
    def class_metrics(pred, target, classes, ignore=255):
        """Dataset-global confusion, rows=true, cols=predicted; ignore by target.
    
        Classes absent in BOTH prediction and truth have undefined IoU/Dice,
        represented as None and omitted from macro means. No epsilon smoothing.
        Background is class 0 and is included in the all-class macro means.
        """
        if pred.shape != target.shape:
            raise ValueError("prediction and target must have the same shape")
        if pred.dtype != torch.long or target.dtype != torch.long:
            raise ValueError("class-index masks must be torch.long")
        valid = target != ignore
        truth, guessed = target[valid], pred[valid]
        if ((truth < 0) | (truth >= classes)).any():
            raise ValueError("target class outside valid range")
        if ((guessed < 0) | (guessed >= classes)).any():
            raise ValueError("predicted class outside valid range")
        cm = torch.bincount(truth * classes + guessed,
                            minlength=classes * classes).reshape(classes, classes)
        tp = cm.diag()
        actual, predicted = cm.sum(1), cm.sum(0)
        union = actual + predicted - tp
        denominator = actual + predicted
        iou = [int(t) / int(u) if u else None for t, u in zip(tp, union)]
        dice = [2 * int(t) / int(d) if d else None
                for t, d in zip(tp, denominator)]
    
        def mean_defined(values):
            defined = [v for v in values if v is not None]
            return sum(defined) / len(defined) if defined else None
    
        return {
            "confusion_true_rows": cm.tolist(),
            "valid_pixels": int(valid.sum()),
            "pixel_accuracy": int(tp.sum()) / len(truth) if len(truth) else None,
            "iou_per_class": iou,
            "dice_per_class": dice,
            "miou_including_background": mean_defined(iou),
            "macro_dice_including_background": mean_defined(dice),
            "miou_foreground_only": mean_defined(iou[1:]),
        }
    
    
    def run():
        # IDs 0 and 2 are classes; class 1 does not occur in the input mask.
        mask = torch.tensor([[[[0., 2.], [0., 2.]]]])
        near = F.interpolate(mask, size=(3, 3), mode="nearest-exact")
        linear = F.interpolate(mask, size=(3, 3), mode="bilinear", align_corners=False)
        assert near.unique().tolist() == [0., 2.]
        assert linear.unique().tolist() == [0., 1., 2.]
    
        target = torch.tensor([[[0, 0, 1], [0, 2, 255]]], dtype=torch.long)
        pred = torch.tensor([[[0, 1, 1], [0, 0, 2]]], dtype=torch.long)
        logits = torch.zeros((1, 3, 2, 3))
        ce = F.cross_entropy(logits, target, ignore_index=255)
        assert math.isclose(float(ce), math.log(3), rel_tol=1e-6)
        # One independent binary foreground channel, same shape and float target.
        binary_target = torch.tensor([[[[0., 0., 1.], [0., 1., 0.]]]])
        bce = F.binary_cross_entropy_with_logits(torch.zeros_like(binary_target),
                                               binary_target)
        assert math.isclose(float(bce), math.log(2), rel_tol=1e-6)
        # BCE has no ignore_index: explicitly select valid elements if applicable.
        valid_binary = (target != 255).unsqueeze(1)
        masked_bce = F.binary_cross_entropy_with_logits(
            torch.zeros_like(binary_target)[valid_binary], binary_target[valid_binary])
        assert math.isclose(float(masked_bce), math.log(2), rel_tol=1e-6)
    
        metrics = class_metrics(pred, target, 3)
        assert metrics["confusion_true_rows"] == [[2, 1, 0], [0, 1, 0], [1, 0, 0]]
        assert metrics["valid_pixels"] == 5
        assert metrics["iou_per_class"] == [0.5, 0.5, 0.0]
        assert math.isclose(metrics["miou_including_background"], 1 / 3)
        assert metrics["miou_foreground_only"] == 0.25
        empty = class_metrics(torch.zeros((1,), dtype=torch.long),
                              torch.zeros((1,), dtype=torch.long), 3)
        assert empty["iou_per_class"] == [1.0, None, None]
        ignored = class_metrics(torch.zeros((1,), dtype=torch.long),
                                torch.full((1,), 255, dtype=torch.long), 3)
        assert ignored["miou_including_background"] is None
    
        x = torch.tensor([[[[0., 1.], [2., 3.]]]])
        kernel = x.clone()
        stride1 = F.conv_transpose2d(x, kernel)
        stride2 = F.conv_transpose2d(x, kernel, stride=2)
        assert stride1[0, 0].tolist() == [[0., 0., 1.], [0., 4., 6.], [4., 12., 9.]]
        assert stride2.shape == (1, 1, 4, 4)
    
        # Transpose is an adjoint, not an inverse. Bias-free, matching geometry.
        original = torch.arange(9.).reshape(1, 1, 3, 3)
        k = torch.ones((1, 1, 2, 2))
        reduced = F.conv2d(original, k)
        returned = F.conv_transpose2d(reduced, k)
        assert returned.shape == original.shape
        assert not torch.equal(returned, original)
        probe = torch.tensor([[[[1., 2.], [3., 4.]]]])
        assert torch.equal((reduced * probe).sum(),
                           (original * F.conv_transpose2d(probe, k)).sum())
        # Both sizes collapse to 2; output_padding selects geometry, not content.
        k3 = torch.ones((1, 1, 3, 3))
        for size in (5, 6):
            assert F.conv2d(torch.zeros(1, 1, size, size), k3, stride=2).shape[-1] == 2
        coarse = torch.zeros(1, 1, 2, 2)
        shapes = [list(F.conv_transpose2d(coarse, k3, stride=2,
                                        output_padding=p).shape[-2:]) for p in (0, 1)]
        assert shapes == [[5, 5], [6, 6]]
    
        return {
            "environment": {"python": platform.python_version(), "torch": torch.__version__,
                            "device": "cpu", "training": False},
            "nearest_exact_mask": near[0, 0].tolist(),
            "bilinear_mask": linear[0, 0].tolist(),
            "ce_zero_logits": float(ce), "bce_zero_logits": float(bce),
            "bce_valid_pixels_only": float(masked_bce),
            "metrics": metrics, "absent_classes": empty, "all_ignored": ignored,
            "transpose_stride1": stride1[0, 0].tolist(),
            "transpose_stride2": stride2[0, 0].tolist(),
            "transpose_is_not_inverse": returned[0, 0].tolist(),
            "output_padding_shapes": shapes,
        }
    
    
    if __name__ == "__main__":
        print(json.dumps(run(), ensure_ascii=False, indent=2, allow_nan=False))
    

    執行成功會輸出 JSON;重要欄位應接近以下數值,浮點數尾端可能因環境顯示略有差異:

    檢查本文實際結果

    三類零 logits 的 CE,忽略 255
    1.0986123,約為 ln(3)
    二元零 logits 的 BCE
    0.6931472,約為 ln(2)
    五個有效像素的 pixel accuracy
    0.6
    各類 IoU
    0.5、0.5、0
    含背景的 mIoU/macro Dice
    1/3;4/9
    只計前景的 mIoU
    0.25
    只有背景的遮罩,缺席兩類的 IoU
    1、null、null;含背景平均為 1
    所有像素皆為 ignore
    有效像素 0,平均分數 null
    3×3 輸入先卷積再轉置卷積
    形狀相同,值不同;中央值由 4 變成 64
    stride 2、kernel 3 的粗輸入 2×2,output_padding 0/1
    輸出 5×5/6×6

    如果看到 AssertionError,先保留報錯位置與環境版本,檢查是否改了矩陣、維度、插值模式或 ignore 值。不要刪掉 assertion 後把「能印出數字」當成重現成功。這些檢查證明的是算例與處理約定,沒有證明任何模型能準確分割犀牛、道路或細胞。

    選模型以前,先把你要交付的遮罩說清楚

    要落地一個分割應用,可以先問:每像素只選一類,還是允許多標籤?需要區分個體嗎?標註裡有哪些忽略區域?細小結構與邊界錯誤的代價是什麼?輸出必須對應原圖哪些位置?這些答案會決定標註、loss、後處理和驗收方式。

    FCN 教我們把分類特徵轉成密集分數,並用不同解析度的預測融合細節;U-Net 則把編碼端的特徵交回解碼端,讓上採樣不必只依賴最底層的粗表示。讀懂兩者時,應能指出資訊從哪裡來、在哪裡相加或串接,以及輸出如何與標註對齊。

    先把照片和 mask 對上,再確認處理後的類別集合與形狀;最後用已寫明約定的指標評估,並回看失敗案例。這幾步成立,架構比較才有可解讀的基礎。

    主要技術來源與版本:

    留言0

    登入後就能留言。

    登入
    sunny 桑尼
    sunny 桑尼

    AI 資料科學 · 產品經理 · 蒙奇 D 桑尼

    專注於 AI 工具應用與資料科學教育,致力於讓每個人都能用 AI 提升工作效率。曾任職於科技業,現為全職教育創作者。

    讀更多文章 →想自己動手試?領免費 Starter Kit →