跳到主要內容

CV Day 19 U-Net 與語意分割實作



CV Day 19 U-Net 與語意分割實作

執行需求:Colab T4 可跑。本篇的完整訓練流程在 Colab 免費 T4(16 GB VRAM)上約 8 分鐘:自寫小型 U-Net 在合成幾何形狀上訓練 10 epoch,驗證集 mIoU 可達 0.85 以上(實際數字會略有不同)。如果你想直接套到 PASCAL VOC 2012 segmentation 子集,把第 2 段的資料載入換成 VOC 的影像與 SegmentationClass PNG 即可,模型與評估程式碼完全不需改動。CPU 也能跑(會慢 5–8 倍),適合只是想看流程的場景。

引言

昨天我們把語意分割、實例分割、全景分割與 mIoU 指標一起看了一遍。語意分割的目標是給影像中「每個像素」分類,不區分同類別的不同個體;這個任務在醫療影像(腫瘤輪廓)、自駕車(道路與行人)、遙測(地表覆蓋)與工業瑕疵檢測(瑕疵輪廓)中都是核心環節。本系列選定的工業瑕疵檢測場景,到了 Day 25 就會用 MVTec AD 資料集做完整的瑕疵分割實戰;今天與明天先把模型工具備齊,U-Net 是這個故事最自然的起點。

U-Net 在 2015 年由 Ronneberger 等人發表(最初是為醫療影像設計),它的關鍵設計是把一個對稱的編碼解碼(encoder-decoder)架構,用「跳躍連接(skip connection)」把編碼端的高解析度特徵直接接到解碼端。這樣做解決了兩個分割任務的核心痛點:第一,純粹用 encoder + 全卷積(FCN)會在多次下採樣後失去精細的邊界資訊;第二,純粹用 decoder 又會缺乏足夠的語意特徵。U-Net 把兩者結合,同時保留「在哪裡」與「是什麼」這兩種資訊,是分割模型中最經典也最容易寫的架構。今天會自寫一個小型 U-Net,從合成資料出發,把「為什麼需要跳躍連接」、「為什麼需要 Dice 損失」、「為什麼 mIoU 比 pixel accuracy 更可靠」這三個問題一次釐清。

本篇會建立一個合成的「幾何形狀」資料集(背景上畫圓與矩形),把訓練資料、U-Net 架構、BCE+Dice 損失、mIoU 評估與視覺化整合在一個可整段執行的範例裡。我們刻意避開大型公開資料集的下載與解壓縮時間,把焦點放在模型與訓練流程本身;當你想套到 PASCAL VOC 2012 segmentation(20 類、約 2,900 張訓練影像)時,只要把第 2 段的資料載入換掉即可,模型、損失、評估都不必改。這也是「先把小資料跑通、再放大到真實資料」的標準學習節奏。

U-Net 的編碼解碼與跳躍連接

U-Net 是一個對稱的編碼解碼網路,看起來像一個大寫的英文字母 U:左半邊是 encoder,反覆做「雙層卷積 + 最大池化(maxpool 2×2)」,把空間尺寸每次縮一半、通道數翻倍,逐步把影像壓縮成高度語意化的特徵圖;右半邊是 decoder,反覆做「上採樣(upsample 2×2)+ 與對應層拼接 + 雙層卷積」,把特徵圖的空間尺寸放大回原圖大小。U-Net 的招牌設計是 skip connection:在 decoder 每一層,把同解析度的 encoder 特徵圖直接抄過來,與上採樣後的特徵在通道維度拼接(concat),再做卷積融合。這條「捷徑」讓 decoder 在上採樣的同時,能拿到 encoder 保留的高解析度邊緣資訊,避免輸出變得模糊。

實作上 encoder 通常用「雙層 3×3 卷積(皆 BN 與 ReLU)→ 2×2 maxpool」四輪,把 256×256 的輸入壓到 16×16;bottleneck 再做一次雙層卷積後,decoder 反過來用「2×2 transposed conv(或 upsample)→ 與 encoder 同層拼接 → 雙層 3×3 卷積」四輪回到 256×256,最後用一個 1×1 卷積把通道數對應到類別數。這個寫法的關鍵細節有兩個:跳躍連接的拼接必須在通道維度做(dim=1),所以 decoder 每層的輸入通道數是「上採樣後通道 + encoder 對應層通道」;padding 模式要選「same」(或 kernel=3、padding=1)以維持特徵圖尺寸,否則拼接時左右兩邊的空間大小會對不上,這是初學者最常踩的第一個雷。

U-Net 的感受野(receptive field)與一般分類 CNN 不同。分類任務只看最後一層的全域特徵,所以深層網路(ResNet-50 等)感受野能擴到整張影像;但 U-Net 是逐層拼接,淺層 encoder 仍然在 decoder 中被使用,因此有效感受野相對小。對於「瑕疵輪廓」這類需要精細邊界的任務,這個特性是優點而非缺點——邊界資訊從淺層一路傳遞到輸出,輸出遮罩可以精確到 1–2 個像素。這也是 U-Net 在醫療影像與工業瑕疵上表現一直很好的根本原因。

損失函式與評估指標

語意分割最直觀的損失是「逐像素的交叉熵(pixel-wise cross entropy)」,把每個像素當成獨立的分類問題。當前景(瑕疵或目標類別)佔比很小時(例如瑕疵只佔影像 1%),純 BCE 會被「預測全部為背景」這種 trivial solution 困住,訓練 loss 看起來在下降,但模型其實什麼都沒學到。為了解決類別不平衡,業界最常見的組合是「BCE + Dice Loss」:BCE 負責逐像素的梯度、Dice Loss(1 - 兩輪交集 / 並集)負責拉高 IoU,兩者加權平均作為最終損失。實務上 Dice 對小目標特別敏感,是瑕疵分割的標配。

評估指標則要從 pixel accuracy 升級到 mIoU(mean Intersection over Union)。pixel accuracy 是「預測正確的像素 / 總像素」,當背景佔 99%、模型全部猜背景時 accuracy 仍有 99%,完全失去訊號;mIoU 則是「對每個類別算 IoU(交集 / 聯集),再對所有類別取平均」,能正確反映「有沒有抓到前景」。二元分割只有前景與背景兩類,mIoU 就是「前景 IoU 與背景 IoU 的平均」;多類分割則是 N 個類別 IoU 的平均。我們今天合成的資料集是二元分割,明天用 smp 套件時會接上 VOC 的 21 類(含背景),屆時 mIoU 就是這 21 類的平均。

完整實作:自寫 U-Net 訓練合成幾何形狀

以下範例在 Colab T4 上跑約 8 分鐘。我們會用 numpy 在 256×256 的灰階影像上畫隨機的圓與矩形當前景(白色)與背景(黑色),產生 200 張訓練影像與 40 張驗證影像;接著定義一個 4 層 encoder + 4 層 decoder 的小型 U-Net(總參數約 7.8 M);最後用 BCE + Dice 聯合損失訓練 10 epoch,並印出每個 epoch 的訓練 loss、驗證 mIoU 與視覺化預測結果。執行前需要:pip install torch torchvision numpy matplotlib(Colab 預裝 PyTorch 2.5 與 torchvision 0.20)。

# 1. 建立合成幾何形狀資料集:隨機圓與矩形
import numpy as np
from pathlib import Path
from PIL import Image

rng = np.random.default_rng(42)
DATA_ROOT = Path("/content/synth_seg")
DATA_ROOT.mkdir(parents=True, exist_ok=True)

def make_sample(size=256, max_shapes=3):
    img = np.zeros((size, size), dtype=np.uint8)
    mask = np.zeros((size, size), dtype=np.uint8)
    n = rng.integers(1, max_shapes + 1)
    for _ in range(n):
        if rng.random() < 0.5:
            # 畫圓
            r = int(rng.integers(15, 50))
            cy, cx = int(rng.integers(r, size - r)), int(rng.integers(r, size - r))
            yy, xx = np.ogrid[:size, :size]
            circle = (yy - cy) ** 2 + (xx - cx) ** 2 <= r ** 2
            img[circle] = rng.integers(160, 240)
            mask[circle] = 1
        else:
            # 畫矩形
            h, w = int(rng.integers(20, 80)), int(rng.integers(20, 80))
            y, x = int(rng.integers(0, size - h)), int(rng.integers(0, size - w))
            rect = np.zeros_like(mask)
            rect[y:y + h, x:x + w] = 1
            img[rect == 1] = rng.integers(160, 240)
            mask[rect == 1] = 1
    # 加一點高斯雜訊讓影像更接近真實
    img = np.clip(img.astype(np.int32) + rng.integers(-15, 15, size), 0, 255).astype(np.uint8)
    return img, mask

for split, n_imgs in [("train", 200), ("val", 40)]:
    (DATA_ROOT / split / "images").mkdir(parents=True, exist_ok=True)
    (DATA_ROOT / split / "masks").mkdir(parents=True, exist_ok=True)
    for i in range(n_imgs):
        img, mask = make_sample()
        Image.fromarray(img).save(DATA_ROOT / split / "images" / f"{i:04d}.png")
        Image.fromarray(mask * 255).save(DATA_ROOT / split / "masks" / f"{i:04d}.png")
print(f"已建立 {200} 張訓練影像與 {40} 張驗證影像")
# 輸出:已建立 200 張訓練影像與 40 張驗證影像

這段用 numpy 直接在記憶體中合成訓練資料,避免下載任何公開資料集。每張 256×256 的灰階影像上畫 1–3 個白色幾何形狀(圓或矩形),對應的二值遮罩是 0/1 標註;前景像素佔比約 5–20%,故意讓類別比例稍微不均,方便看到 Dice Loss 帶來的改善。加上一點高斯雜訊後影像看起來更像真實場景,對 U-Net 學到「邊緣資訊」也有幫助。這份資料集的設計哲學和 Day 25 的 MVTec AD 合成瑕疵章節一致,只是把灰階瑕疵換成幾何形狀,訓練流程可以完全平行。

# 2. 自寫 Dataset 與 DataLoader(含 torchvision 標準化)
import torch
from torch.utils.data import Dataset, DataLoader
from torchvision import transforms
from PIL import Image

IMG_SIZE = 256
MEAN, STD = 0.5, 0.5  # 灰階影像只有一個通道

class SegDataset(Dataset):
    def __init__(self, root, split):
        self.img_dir = root / split / "images"
        self.mask_dir = root / split / "masks"
        self.ids = sorted(self.img_dir.glob("*.png"))
        self.img_tf = transforms.Compose([
            transforms.Resize((IMG_SIZE, IMG_SIZE)),
            transforms.ToTensor(),
            transforms.Normalize([MEAN], [STD]),
        ])
        self.mask_tf = transforms.Resize(
            (IMG_SIZE, IMG_SIZE), interpolation=transforms.InterpolationMode.NEAREST
        )

    def __len__(self):
        return len(self.ids)

    def __getitem__(self, idx):
        iid = self.ids[idx].stem
        img = Image.open(self.img_dir / f"{iid}.png").convert("L")
        mask = Image.open(self.mask_dir / f"{iid}.png").convert("L")
        img_t = self.img_tf(img)
        mask_t = torch.from_numpy(np.array(self.mask_tf(mask)) > 127).float()
        return img_t, mask_t

train_loader = DataLoader(SegDataset(DATA_ROOT, "train"), batch_size=8, shuffle=True, num_workers=2)
val_loader = DataLoader(SegDataset(DATA_ROOT, "val"), batch_size=8, shuffle=False, num_workers=2)
img, mask = next(iter(train_loader))
print(f"批次影像形狀:{img.shape}, 批次遮罩形狀:{mask.shape}")
print(f"前景像素佔比:{mask.mean().item():.3f}")
# 輸出:批次影像形狀:torch.Size([8, 1, 256, 256]), 批次遮罩形狀:torch.Size([8, 256, 256])
# 輸出:前景像素佔比:0.087

這段定義了標準的 PyTorch Dataset 與 DataLoader。img 經過 resize、轉 tensor、Normalize 後形狀是 (B, 1, 256, 256)(灰階只有 1 通道);mask 則用最近鄰插值(NEAREST)resize 以避免引入新標籤值,轉成 0/1 的 float tensor,形狀是 (B, 256, 256)。這裡有兩個關鍵細節值得注意:遮罩的 Normalize 與影像不同——mask 必須保持 0/1 整數語意,不能套 mean/std;最近鄰插值是分割任務的標準選擇(雙線性會把邊界模糊掉)。前景像素佔比約 0.087,這個數字會直接影響 BCE+Dice 的權重設定。

# 3. 自寫小型 U-Net(4 層 encoder + 4 層 decoder,約 7.8 M 參數)
import torch.nn as nn
import torch.nn.functional as F

class DoubleConv(nn.Module):
    def __init__(self, in_ch, out_ch):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
            nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True),
            nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
            nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True),
        )
    def forward(self, x):
        return self.net(x)

class UNet(nn.Module):
    def __init__(self, n_classes=1, base_ch=32):
        super().__init__()
        # encoder:每次下採樣後通道數翻倍
        self.enc1 = DoubleConv(1, base_ch)        # 256x256, 32
        self.enc2 = DoubleConv(base_ch, base_ch * 2)        # 128x128, 64
        self.enc3 = DoubleConv(base_ch * 2, base_ch * 4)    # 64x64, 128
        self.enc4 = DoubleConv(base_ch * 4, base_ch * 8)    # 32x32, 256
        self.pool = nn.MaxPool2d(2)
        # bottleneck:最深的特徵
        self.bottleneck = DoubleConv(base_ch * 8, base_ch * 16)  # 16x16, 512
        # decoder:每次上採樣後通道數減半,並拼接對應 encoder 層
        self.up4 = nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride=2)
        self.dec4 = DoubleConv(base_ch * 16, base_ch * 8)
        self.up3 = nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride=2)
        self.dec3 = DoubleConv(base_ch * 8, base_ch * 4)
        self.up2 = nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride=2)
        self.dec2 = DoubleConv(base_ch * 4, base_ch * 2)
        self.up1 = nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride=2)
        self.dec1 = DoubleConv(base_ch * 2, base_ch)
        # 1x1 卷積輸出類別數
        self.head = nn.Conv2d(base_ch, n_classes, 1)

    def forward(self, x):
        e1 = self.enc1(x)
        e2 = self.enc2(self.pool(e1))
        e3 = self.enc3(self.pool(e2))
        e4 = self.enc4(self.pool(e3))
        b = self.bottleneck(self.pool(e4))
        d4 = self.dec4(torch.cat([self.up4(b), e4], dim=1))
        d3 = self.dec3(torch.cat([self.up3(d4), e3], dim=1))
        d2 = self.dec2(torch.cat([self.up2(d3), e2], dim=1))
        d1 = self.dec1(torch.cat([self.up1(d2), e1], dim=1))
        return self.head(d1)  # 形狀:(B, n_classes, 256, 256)

model = UNet(n_classes=1, base_ch=32).to("cuda")
n_params = sum(p.numel() for p in model.parameters()) / 1e6
print(f"U-Net 參數量:{n_params:.2f} M")
# 輸出:U-Net 參數量:7.78 M

這段是本篇的核心。DoubleConv 是 U-Net 標準的「兩個 3×3 卷積 + BN + ReLU」組合,每個解碼層都會用到。UNet 的 forward 流程是:先把影像走完 4 層 encoder(每次 maxpool 解析度減半),再走 bottleneck,最後在 decoder 用 ConvTranspose2d(transposed convolution)上採樣並與對應 encoder 層在通道維度拼接。torch.cat([...], dim=1) 是 skip connection 的實作位置——把上採樣後的特徵與 encoder 對應層特徵沿著通道維度疊起來,所以下一層 DoubleConv 的輸入通道是「兩者通道數相加」。base_ch=32 是分割模型的常見起點,對 256×256 影像剛好;如果你的影像更大或想要更細的特徵,可以放到 64,但參數量會變成約 31 M,Colab T4 跑起來會更慢。

輸入與輸出的形狀對應是分割模型最容易寫錯的地方:輸入 x 是 (B, 1, 256, 256),經過 4 次 maxpool 後 b 是 (B, 512, 16, 16),最後 decoder 走完回到 (B, 1, 256, 256)。這個「輸入輸出同解析度」的設計讓 U-Net 可以逐像素預測,且只需要 1×1 卷積做最後的分類頭。n_classes=1 表示二元分割,輸出形狀是 (B, 1, 256, 256),進入 sigmoid 後就是前景機率;如果改成 VOC 那種 21 類,n_classes=21 並把 sigmoid 換成 softmax。

# 4. Dice Loss + BCE 聯合損失,以及 mIoU 評估函式
import torch

class DiceLoss(nn.Module):
    """1 - Dice 係數;對小目標的梯度比 BCE 強很多。"""
    def __init__(self, smooth=1.0):
        super().__init__()
        self.smooth = smooth
    def forward(self, logits, targets):
        probs = torch.sigmoid(logits)
        intersection = (probs * targets).sum(dim=(1, 2, 3))
        denom = probs.sum(dim=(1, 2, 3)) + targets.sum(dim=(1, 2, 3))
        dice = (2 * intersection + self.smooth) / (denom + self.smooth)
        return 1 - dice.mean()

class BCEDiceLoss(nn.Module):
    def __init__(self, w_bce=0.5, w_dice=0.5):
        super().__init__()
        self.bce = nn.BCEWithLogitsLoss()
        self.dice = DiceLoss()
        self.w_bce, self.w_dice = w_bce, w_dice
    def forward(self, logits, targets):
        targets = targets.unsqueeze(1)  # (B, 1, H, W)
        return self.w_bce * self.bce(logits, targets) + self.w_dice * self.dice(logits, targets)

@torch.no_grad()
def mean_iou(logits, targets, threshold=0.5):
    probs = torch.sigmoid(logits)
    preds = (probs > threshold).float()
    targets = targets.unsqueeze(1).float()
    intersection = (preds * targets).sum(dim=(1, 2, 3))
    union = (preds + targets - preds * targets).sum(dim=(1, 2, 3))
    iou = (intersection + 1e-6) / (union + 1e-6)
    return iou.mean().item()

loss_fn = BCEDiceLoss(w_bce=0.5, w_dice=0.5)
print("損失與指標定義完成:BCEDiceLoss + mean_iou")
# 輸出:損失與指標定義完成:BCEDiceLoss + mean_iou

這段把訓練 U-Net 最常用的兩個工具包好。DiceLoss 的公式是 1 - (2 × |P ∩ G|) / (|P| + |G|),其中 P 是預測機率、G 是 ground truth。smooth=1.0 是避免分母為零的小技巧。BCEDiceLoss 把 BCE 與 Dice 以 0.5/0.5 加權相加——這個權重是最常用的起點,當前景非常稀疏時可以調到 w_dice=0.7。mean_iou 函式計算二元分割的 mIoU(兩類 IoU 的平均 = 前景 IoU = 背景 IoU 對稱),是最直觀的評估指標。每次驗證結束時呼叫一次即可。

# 5. 訓練迴圈:10 epoch,Adam + cosine 學習率
from torch.optim import Adam
from torch.optim.lr_scheduler import CosineAnnealingLR

device = "cuda"
optimizer = Adam(model.parameters(), lr=1e-3)
scheduler = CosineAnnealingLR(optimizer, T_max=10)
EPOCHS = 10

for epoch in range(1, EPOCHS + 1):
    model.train()
    train_loss = 0.0
    for img, mask in train_loader:
        img, mask = img.to(device), mask.to(device)
        logits = model(img)
        loss = loss_fn(logits, mask)
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()
        train_loss += loss.item() * img.size(0)
    scheduler.step()
    train_loss /= len(train_loader.dataset)

    model.eval()
    val_iou = 0.0
    with torch.no_grad():
        for img, mask in val_loader:
            img, mask = img.to(device), mask.to(device)
            logits = model(img)
            val_iou += mean_iou(logits, mask) * img.size(0)
    val_iou /= len(val_loader.dataset)
    print(f"Epoch {epoch:02d}  train_loss={train_loss:.4f}  val_mIoU={val_iou:.4f}")
# 輸出(實際數字會略有不同):
# Epoch 01  train_loss=0.9123  val_mIoU=0.6841
# Epoch 02  train_loss=0.7214  val_mIoU=0.7538
# Epoch 03  train_loss=0.5831  val_mIoU=0.8002
# Epoch 04  train_loss=0.4916  val_mIoU=0.8254
# Epoch 05  train_loss=0.4208  val_mIoU=0.8449
# Epoch 06  train_loss=0.3694  val_mIoU=0.8576
# Epoch 07  train_loss=0.3327  val_mIoU=0.8651
# Epoch 08  train_loss=0.3051  val_mIoU=0.8703
# Epoch 09  train_loss=0.2838  val_mIoU=0.8742
# Epoch 10  train_loss=0.2674  val_mIoU=0.8770

訓練 10 epoch 後驗證集 mIoU 達到約 0.87(實際數字會略有不同)。訓練 loss 從 0.91 一路降到 0.27,說明模型確實學到了形狀輪廓;驗證 mIoU 從 0.68 升到 0.87,沒有過擬合的跡象(訓練 loss 還在下降、驗證 mIoU 還在上升)。這份合成資料集很小(200 張),加上幾何形狀的特徵很單純,所以 U-Net 只用 10 epoch 就能學會;如果換成 VOC 的 21 類自然影像,通常需要 30–80 epoch 才會收斂。CosineAnnealingLR 是分割任務常用的學習率排程,比 StepLR 平滑,對小資料集特別友好。

# 6. 把訓練好的 U-Net 拿來推論,視覺化 5 個樣本
import matplotlib.pyplot as plt

model.eval()
fig, axes = plt.subplots(3, 5, figsize=(12, 7))
for col, (img, mask) in enumerate(val_loader):
    if col >= 5:
        break
    img_cuda = img.to(device)
    with torch.no_grad():
        prob = torch.sigmoid(model(img_cuda))[0, 0].cpu().numpy()
    pred = (prob > 0.5).astype(np.float32)
    axes[0, col].imshow(img[0, 0], cmap="gray"); axes[0, col].set_title("原圖"); axes[0, col].axis("off")
    axes[1, col].imshow(mask[0], cmap="gray"); axes[1, col].set_title("GT 遮罩"); axes[1, col].axis("off")
    axes[2, col].imshow(pred, cmap="gray"); axes[2, col].set_title("U-Net 預測"); axes[2, col].axis("off")
plt.tight_layout()
plt.savefig(DATA_ROOT / "unet_predictions.png", dpi=120)
print(f"預測結果已存到 {DATA_ROOT / 'unet_predictions.png'}")
# 輸出:預測結果已存到 /content/synth_seg/unet_predictions.png

視覺化是把模型結果「翻譯成人話」的最後一步。上排是輸入影像、中排是 ground truth 遮罩、下排是 U-Net 預測,可以看到 U-Net 對圓形與矩形的輪廓都抓得很清楚,邊緣也只有 1–2 像素的誤差。這份圖是部署前最後一輪檢查的標準產出,也是 Day 24 分割評估與後處理章節會更深入討論的內容。今天的重點是把「訓練一個 U-Net」這件事跑通,明天會用 smp 套件接上更現代的 DeepLabV3+ 與 FPN,並把 VOC 的 21 類真實資料接起來。

常見錯誤與踩雷

錯誤一:把 mask 也丟進 Normalize。如果照搬影像的 transforms.Normalize([MEAN], [STD]) 到 mask 上,mask 的 0/1 值會被線性變換到 -1/1 之間,變成浮點數而非 0/1 整數,訓練時 BCE 與 Dice 都會出錯。對應排查方向:mask 永遠用 torch.from_numpy(... > 127).float() 或 torch.tensor(...).long()(多類時)來產生,不要套影像的 transform 鏈。

錯誤二:mask 用雙線性插值 resize 後邊界模糊。如果你在 SegDataset 內對 mask 套 transforms.Resize(...) 而不指定 interpolation=NEAREST,torchvision 預設會用雙線性插值,導致 mask 邊界出現 0–1 之間的小數,訓練時 Dice Loss 會錯亂(0.3、0.7 這類不應該出現的數值)。對應排查方向:影像用 BILINEAR、mask 用 NEAREST,這是分割資料集寫法的鐵律。

錯誤三:skip connection 拼接時空間尺寸對不上。當輸入影像不是 2 的整數次方(例如 257×257)或 padding 模式不一致時,decoder 的特徵圖會比 encoder 大 1 pixel,torch.cat 會直接報 RuntimeError: sizes of tensors must match。對應排查方向:影像尺寸固定為 32 的倍數(256、512、768 等),或把卷積 padding 統一設成 1(kernel=3)。本篇的 DoubleConv 用 padding=1,因此 256 與任何 32 倍數都不會出問題。

錯誤四:二元分割的輸出通道數與損失函式不一致。UNet(n_classes=1) 配 BCEWithLogitsLoss 是正確組合;如果你把 n_classes 改成 2 但繼續用 BCEWithLogitsLoss,輸出形狀會是 (B, 2, H, W) 而 mask 是 (B, H, W),損失函式會因為形狀不符而報錯。對應排查方向:二元分割 n_classes=1 + BCEWithLogits;多類分割 n_classes=N + CrossEntropyLoss,mask 形狀為 (B, H, W) 且為 long 型別。

錯誤五:在 Colab 上忘記把模型搬到 GPU。.to("cuda") 必須在訓練迴圈開始前呼叫,且 img、mask、model 三者都要在同一個裝置上。如果只搬 model 但忘了搬 data,會出現 RuntimeError: expected device cuda:0 but got device cpu。對應排查方向:寫一個 device = "cuda" if torch.cuda.is_available() else "cpu",並在訓練與驗證迴圈內對 img 與 mask 統一套 .to(device)。

效能與實務提醒

在 Colab T4 上跑這份範例(200 張訓練影像、batch=8、10 epoch)約 4 分鐘;驗證階段每個 epoch 約 15 秒;視覺化預測不到 1 秒。模型本身只有 7.8 M 參數,VRAM 峰值約 1.2 GB,因此 batch 可以從 8 調到 16 甚至 32 來加速,每 epoch 可再縮短到 10–15 秒。換成 VOC segmentation 子集(2,900 張訓練影像、batch=16、50 epoch),大約 25 分鐘;想更快可以改用 DeepLabV3+(明天會講)並用混合精度(AMP),同樣條件下能再省 30%。

實務上的兩個常見取捨:第一,base_ch 從 32 升到 64 可以讓 mIoU 再提升約 2–3 個百分點,但參數量變 4 倍、訓練時間也接近 4 倍,除非是針對高解析度影像(如醫療 CT),否則 32 就夠用;第二,Dice Loss 的權重在前景非常稀疏時(< 5%)可以調到 0.7,讓模型更積極學前景;當前景比例約 20–50% 時 0.5/0.5 是最好的平衡點。這兩個參數會在 Day 25 處理 MVTec AD 時再仔細調。

如果你想直接套到 VOC 2012 segmentation,請先把 Pascal VOC 2012 segmentation 子集(從 http://host.robots.ox.ac.uk/pascal/VOC/voc2012/ 下載約 2 GB)解開,把 JPEGImages/ 與 SegmentationClass/(21 類的 PNG 標註)對應到本篇的 SegDataset;n_classes 改成 21,BCEDiceLoss 換成 nn.CrossEntropyLoss(),其他程式碼完全不用改。VOC 上 U-Net 通常可以達到 mIoU 0.55–0.60,DeepLabV3+ 則可以到 0.78 以上,這也是為什麼明天要介紹更現代的架構。

小結

今天我們從零寫了一個小型 U-Net(4 層 encoder + 4 層 decoder、約 7.8 M 參數),在合成的幾何形狀資料集(200 張訓練 + 40 張驗證)上用 BCE+Dice 聯合損失訓練 10 epoch,驗證集 mIoU 達到 0.87。重點有三:第一,U-Net 的 skip connection 把 encoder 高解析度特徵直接接到 decoder,是分割模型保留邊緣資訊的關鍵;第二,BCE+Dice 比純 BCE 更能處理前景稀疏的情況,Dice Loss 的梯度直接對應 IoU;第三,mIoU 永遠比 pixel accuracy 更可靠,背景佔大多數時 accuracy 會被 trivial solution 欺騙。明天,我們會用 segmentation_models_pytorch(smp)套件接上 DeepLabV3+ 與 FPN,把 backbone 換成 ImageNet 預訓練權重,並在 VOC 的 21 類真實資料上訓練,看現代語意分割架構能把 mIoU 推到多高。

結語

今天的重點是「自己寫一個 U-Net 並把它訓練起來」。我們從合成的幾何形狀資料集開始,定義了 SegDataset 與 DataLoader、自寫 DoubleConv 與 UNet 兩個模組、用 BCEDiceLoss 聯合損失訓練 10 epoch,最後用 mIoU 與視覺化檢查模型輸出。讀完這篇你應該能回答:U-Net 的 skip connection 在程式碼的哪一行實作?為什麼 BCE+Dice 比純 BCE 更適合前景稀疏的分割任務?mIoU 與 pixel accuracy 的差別是什麼?明天,我們會用 smp(segmentation_models_pytorch)套件接上 ResNet 預訓練權重、ASPP、空洞卷積與 FPN 等現代語意分割的標準工具,把 VOC 2012 segmentation 的 21 類跑起來,看 mIoU 能從今天的 0.87 推到哪裡。

延伸資源

  • Ronneberger 等人,2015,U-Net: Convolutional Networks for Biomedical Image Segmentation(MICCAI 2015):U-Net 原始論文,https://arxiv.org/abs/1505.04597,說明編碼解碼對稱結構與 skip connection 的設計動機。
  • PASCAL VOC 2012 Segmentation 官方網站(自訂學術用途授權):http://host.robots.ox.ac.uk/pascal/VOC/voc2012/,21 類(含背景)語意分割資料集下載與標註格式。
  • Minaee 等人,2022,Image Segmentation Survey(IEEE TPAMI):U-Net、DeepLab、SegNet、HRNet 等分割架構的綜合回顧,https://arxiv.org/abs/2001.05566。
  • torchvision 分割模型文件(0.20,2024):https://pytorch.org/vision/stable/models.html#semantic-segmentation,DeepLabV3、FCN、LR-ASPP 等官方模型的 API。
  • segmentation_models_pytorch(smp,0.3.x,2024):https://github.com/qubvel/segmentation_models.pytorch,包裝 U-Net、DeepLabV3+、FPN、Unet++ 等現代分割架構,並內建 ImageNet 預訓練權重。
  • PyTorch BCEDice 損失實作範例(2024):https://github.com/pytorch/examples,Dice Loss 與 IoU 指標的標準寫法。

留言

這個網誌中的熱門文章

Day 2 變數與資料型別

Day 2 變數與資料型別 引言 寫程式的過程中,變數與資料型別是處理資料的基礎。變數是存放資料的容器,資料型別則決定這筆資料有哪些特性、可以進行哪些操作。學會定義變數、認識各種資料型別,是學好 Python 的關鍵一步。 這篇文章會帶你了解 Python 中變數的觀念、如何定義變數,以及常見的資料型別,包括整數、浮點數、字串、布林值,還有串列、元組、字典與集合等容器型別。我們也會介紹變數的命名規則與撰寫風格建議,以及如何用 type() 檢查資料型別。 什麼是變數?如何在 Python 中定義變數 變數是在程式執行時用來存放資料的名稱。透過定義變數,我們可以給一筆資料一個名字,並在程式的其他地方用這個名字取用該筆資料。在 Python 中,變數不需要事先宣告型別,因為 Python 是動態型別語言,變數的型別由指定給它的值決定。 定義變數的基本語法 在 Python 中定義變數非常簡單,只要用賦值符號 = 把值指定給變數即可。例如: x = 5 # 定義變數 x,並把整數 5 賦值給它 name = "Alice" # 定義變數 name,並把字串 "Alice" 賦值給它 在這裡,x 是一個變數,被賦予整數 5;name 是另一個變數,被賦予字串 "Alice"。 變數的更新與覆寫 變數的值可以修改,也就是說,我們可以在程式的不同地方給同一個變數新的值。例如: x = 10 # x 最初被賦予 10 x = 15 # x 的值現在被更新為 15 這樣就能依照需求,在程式執行過程中靈活調整變數的值。 Python 的動態型別系統 Python 和某些靜態型別語言不同,定義變數時不需要宣告型別。賦值時,Python 會根據值自動判斷變數的型別。例如: x = 5 # x 是整數 x = 3.14 # x 變成浮點數 x = "Hi" # x 變成字串 同一個變數在程式執行過程中可以存放不同型別的值,這是 Python 的彈性之一。 常見資料型別 在 Python 中,資料型別決定我們可以對變數進行哪些操作...

Day 1 Python 簡介與環境設定

Day 1 Python 簡介與環境設定 引言 在現在的科技環境裡,程式設計已經是一項重要技能。無論你是對資料科學有興趣、想成為開發者,或是想踏入人工智慧(AI)領域,學會寫程式都能明顯提升你的競爭力。在眾多程式語言中,Python 因為語法簡單、功能強大、應用範圍廣泛,成為許多人進入程式世界的第一選擇。這篇文章會帶你認識 Python 的背景與優勢,並一步步教你在不同系統上安裝與設定 Python 開發環境,最後寫出第一支 Python 程式。 為什麼選擇 Python? Python 是一種高階程式語言,由 Guido van Rossum 在 1991 年發布。Python 的設計哲學強調程式碼的可讀性,並用縮排來定義程式區塊,這點和許多使用大括號的語言不同。簡潔的語法讓它成為初學者的理想選擇;就算是經驗豐富的開發者,也能用它完成複雜的專案。 Python 的優勢如下: 簡單易學 :Python 的語法清楚、結構簡潔,初學者很快就能上手。和其他語言相比,學習曲線相對平緩,不需要先弄懂一堆複雜觀念,就能開始寫程式。 應用範圍廣泛 :從資料科學、網頁開發、人工智慧、機器學習、自動化測試到網路爬蟲,Python 都有大量開源函式庫與工具支援,而且在這些領域都扮演關鍵角色。 豐富的函式庫與框架 :Python 的函式庫生態系非常龐大。做資料分析有 NumPy、Pandas;開發網站有 Django、Flask;做深度學習有 TensorFlow、PyTorch。各種需求幾乎都能找到對應的套件,讓開發更有效率。 跨平台支援 :Python 支援 Windows、macOS、Linux 等作業系統,程式通常不需要太多修改就能跨平台執行,讓開發與部署更有彈性。 活躍的社群 :Python 擁有龐大的開發者社群。學習或開發上遇到問題,幾乎都能在社群與論壇(例如 Stack Overflow)找到答案,對初學者來說是很強的後盾,也能減少卡關時的挫折感。 Python 的應用領域 Python 的流行與強大功能,讓許多領域都開始大量使用它。以下是幾個常見的應用方向: 資料科學 :隨著大數據與人工智慧興起,資料科學大量使用 Python。NumPy、Pandas 與 Matplotlib 等工具能處理和分析龐...

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門

Python 從入門到 PyTorch 深度學習:開啟 AI 世界的大門 隨著人工智慧(AI)與深度學習(Deep Learning)快速發展,越來越多人對這些技術產生興趣。不論你是想踏入 AI 領域的初學者,還是已經有程式基礎的開發者,學好 Python 與深度學習框架(例如 PyTorch),都能為你打開更多可能。 為什麼選擇 Python? Python 已經是資料科學與人工智慧領域的首選語言。它的語法簡潔、容易上手,而且擁有龐大的生態系與大量開源函式庫。無論是資料處理、資料視覺化,還是建立機器學習與深度學習模型,Python 都能勝任。對想進入 AI 或資料科學領域的人來說,它幾乎是必備工具。 PyTorch 是什麼? PyTorch 是由 Meta(原 Facebook)AI 研究團隊開發的開源深度學習框架,以易用、靈活和動態計算圖著稱,是許多 AI 研究人員與開發者的首選。相較於其他框架,PyTorch 的寫法更貼近原生 Python,對初學者相對友善。無論是簡單的實驗,還是複雜的深度學習模型,PyTorch 都能提供強大的支援。 這個系列能帶給你什麼? 這個系列會從 Python 的基礎開始,帶你一步一步學習,最後能自己用 PyTorch 建立深度學習模型。即使你完全沒有寫過程式,也能跟著文章的節奏累積技能,理解 AI 與深度學習的核心觀念。 本系列涵蓋的主題 Python 基礎:從變數、條件判斷到函式與模組。 資料處理工具:用 NumPy 與 Pandas 有效率地操作資料。 資料視覺化:用 Matplotlib 與 Seaborn 把資料畫成圖表。 深度學習的數學基礎:線性代數、微積分與機率。 PyTorch 入門:理解張量、模型建構與 GPU 加速。 基礎深度學習模型:CNN 與 RNN 的實作應用。 深度學習專案實戰:從資料前處理到模型部署的端到端流程。 誰適合這個系列? 程式初學者 :如果你對 AI 充滿好奇,卻還沒寫過程式,系列的第一部分會帶你快速上手 Python,並幫助你理解深度學習的基本觀念。 資料科學愛好者 :如果你已經熟悉一些資料處理方法,進階部分會教你如何用 PyTorch 建構深度學習模型。 開發者與研究人員 :想更深入了...