文章目錄
- 一、Unet網絡簡介
- 1.1 輸入圖像
- 1.2 編碼器部分(Contracting Path)
- 1.3 解碼器部分(Expanding Path)
- 1.4 最后一層(輸出)
- 1.5 跳躍連接(Skip Connections)
- 二、Unet網絡的Pytorch實現
- 2.1 網絡構建
- 2.2 網絡訓練測試
- 三、Unet網絡的Matlab實現
- 3.1 網絡構建
- 3.2 網絡訓練
- 3.3 網絡預測
一、Unet網絡簡介
UNet 是一種常用于圖像分割任務的卷積神經網絡(CNN)架構,在論文《U-Net: Convolutional Networks for Biomedical Image Segmentation》中提出。這個網絡專為生物醫學圖像分割設計,但由于其優越的性能,現在已被廣泛應用于遙感、自動駕駛、醫學圖像處理等領域。
UNet 是一種編碼器-解碼器(Encoder-Decoder)結構的對稱網絡,其名字中的 “U” 來自于其結構在圖像中的形狀像一個 “U”。如下圖所示
1.1 輸入圖像
- 尺寸:
512 x 512
,通道數:1(表示單通道灰度圖),3(表示RGB圖像)
這里 UNet 中使用了 padding=1 的卷積方式,所以卷積輸出尺寸保持不變。
1.2 編碼器部分(Contracting Path)
編碼器類似于傳統的 CNN,用于提取圖像的特征,逐步降低空間維度、增加特征通道。
每一步都包含:
- 兩次
3x3 卷積 + ReLU
- 一次
2x2 max pooling
- 通道數:每次加倍,64 → 128 → 256 → 512 → 1024
- 特征圖尺寸:每次減半
層分析(從上到下):
層級 | 輸入大小 | 卷積后大小 | 通道數變化 |
---|---|---|---|
L1 | 512×512 | 512×512 | 1 → 64 → 64 |
L2 | 256×256 | 256×256 | 128 → 128 |
L3 | 128×128 | 128×128 | 256 → 256 |
L4 | 64×64 | 64×64 | 512 → 512 |
Bottom | 32×32 | 32×32 | 1024 |
1.3 解碼器部分(Expanding Path)
解碼器用于將編碼器壓縮后的特征圖逐步恢復到原始圖像的空間分辨率,生成與輸入大小一致的分割圖。
每一步都包含:
2x2 up-convolution
- 與編碼器相同層進行特征圖拼接
- 兩次
3x3 卷積 + ReLU
- 通道數逐漸減半:1024 → 512 → 256 → 128 → 64
解碼過程分析:
層級 | 上采樣后大小 | 拼接后通道數 | 卷積輸出通道數 | 卷積后尺寸 |
---|---|---|---|---|
L4 | 32×32 → 64×64 | 512 + 512 | 512 | 64×64 |
L3 | 64×64 → 128×128 | 256 + 256 | 256 | 128×128 |
L2 | 128×128 → 256×256 | 128 + 128 | 128 | 256×256 |
L1 | 256×256 →512×512 | 64 + 64 | 64 | 388×388 |
1.4 最后一層(輸出)
- 使用了一個
1x1 卷積
,將通道數變成2
。
1.5 跳躍連接(Skip Connections)
UNet 的核心創新是將編碼器中對應層的特征圖直接傳遞到解碼器的對應層進行拼接。這種做法有兩個好處:
- 保留高分辨率的空間信息
- 有助于訓練時的梯度傳播
二、Unet網絡的Pytorch實現
2.1 網絡構建
import torch
import torch.nn as nnclass UNet(nn.Module):def __init__(self, in_channels=1, out_channels=2):super(UNet, self).__init__()# ========== 編碼器部分 ==========# 每個 encoder 包括兩個卷積(double conv)+ 一個最大池化(下采樣)self.enc1 = self.double_conv(in_channels, 64) # 輸入通道 -> 64self.pool1 = nn.MaxPool2d(2)self.enc2 = self.double_conv(64, 128)self.pool2 = nn.MaxPool2d(2)self.enc3 = self.double_conv(128, 256)self.pool3 = nn.MaxPool2d(2)self.enc4 = self.double_conv(256, 512)self.pool4 = nn.MaxPool2d(2)# ========== Bottleneck ==========# 最底層特征提取器,包含較多通道(1024),增強感受野self.bottom = self.double_conv(512, 1024)self.dropout = nn.Dropout(0.5) # 防止過擬合# ========== 解碼器部分(上采樣 + 拼接 + double conv) ==========self.up4 = nn.ConvTranspose2d(1024, 512, kernel_size=2, stride=2) # 上采樣self.dec4 = self.double_conv(1024, 512) # 拼接后再 double conv(512 來自 skip,512 來自 up)self.up3 = nn.ConvTranspose2d(512, 256, kernel_size=2, stride=2)self.dec3 = self.double_conv(512, 256)self.up2 = nn.ConvTranspose2d(256, 128, kernel_size=2, stride=2)self.dec2 = self.double_conv(256, 128)self.up1 = nn.ConvTranspose2d(128, 64, kernel_size=2, stride=2)self.dec1 = self.double_conv(128, 64)# ========== 輸出層 ==========# 使用 1x1 卷積將通道數變為類別數(像素級分類)self.out_conv = nn.Conv2d(64, out_channels, kernel_size=1)def double_conv(self, in_ch, out_ch):"""包含兩個3x3卷積 + ReLU 的結構"""return nn.Sequential(nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1),nn.ReLU(inplace=True),nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1),nn.ReLU(inplace=True))def forward(self, x):# ========== 編碼器前向傳播 ==========e1 = self.enc1(x) # -> [B, 64, H, W]p1 = self.pool1(e1) # -> [B, 64, H/2, W/2]e2 = self.enc2(p1) # -> [B, 128, H/2, W/2]p2 = self.pool2(e2) # -> [B, 128, H/4, W/4]e3 = self.enc3(p2) # -> [B, 256, H/4, W/4]p3 = self.pool3(e3) # -> [B, 256, H/8, W/8]e4 = self.enc4(p3) # -> [B, 512, H/8, W/8]p4 = self.pool4(e4) # -> [B, 512, H/16, W/16]# ========== Bottleneck ==========b = self.bottom(p4) # -> [B, 1024, H/16, W/16]b = self.dropout(b)# ========== 解碼器前向傳播 + skip connection 拼接 ==========up4 = self.up4(b) # 上采樣 -> [B, 512, H/8, W/8]d4 = self.dec4(torch.cat([up4, e4], dim=1)) # 拼接 encoder 的 e4up3 = self.up3(d4) d3 = self.dec3(torch.cat([up3, e3], dim=1))up2 = self.up2(d3)d2 = self.dec2(torch.cat([up2, e2], dim=1))up1 = self.up1(d2)d1 = self.dec1(torch.cat([up1, e1], dim=1))# ========== 輸出層 ==========out = self.out_conv(d1) # -> [B, num_classes, H, W]return out# ========== 模型測試:構造隨機輸入看看輸出尺寸 ==========
if __name__ == "__main__":model = UNet(in_channels=1, out_channels=2)x = torch.randn(1, 1, 512, 512) # 隨機生成一個 1 張單通道圖像y = model(x)print("Output shape:", y.shape) # 應為 [1, 2, 512, 512],2 表示類別數
2.2 網絡訓練測試
這里用到的數據集是公開數據集(MoNuSeg 2018 Data)
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import transforms
import matplotlib.pyplot as plt
from model import UNet
from dataset import MonuSegDataset # 自定義的數據集類
import os# ========================
# 1?? 數據路徑配置
# ========================
train_image_dir = "TrainingData/TissueImages" # 訓練圖像路徑
train_mask_dir = "TrainingData/Masks" # 訓練標簽路徑test_image_dir = "TestData/TissueImages" # 測試圖像路徑
test_mask_dir = "TestData/Masks" # 測試標簽路徑# ========================
# 2?? 數據預處理方式定義
# ========================
resize_size = (512, 512) # 所有圖像統一 resize 的尺寸,需與模型輸入一致# 圖像預處理:Resize → ToTensor(歸一化到 0~1,3通道 RGB)
transform_img = transforms.Compose([transforms.Resize(resize_size),transforms.ToTensor()
])# 掩碼預處理:Resize → 轉張量 → squeeze → 轉 long 類型(整數標簽)
transform_mask = transforms.Compose([transforms.Resize(resize_size, interpolation=transforms.InterpolationMode.NEAREST), # 保留 mask 標簽值transforms.PILToTensor(), # 依舊是整數值,不做歸一化transforms.Lambda(lambda x: x.squeeze().long()) # [1,H,W] → [H,W],保持 long 類型
])# ========================
# 3?? 加載數據集(訓練 + 測試)
# ========================
train_dataset = MonuSegDataset(train_image_dir, train_mask_dir, transform_img, transform_mask)
train_loader = DataLoader(train_dataset, batch_size=2, shuffle=True)test_dataset = MonuSegDataset(test_image_dir, test_mask_dir, transform_img, transform_mask)
test_loader = DataLoader(test_dataset, batch_size=1, shuffle=False)# ========================
# 4?? 初始化模型、損失函數、優化器
# ========================
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")model = UNet(in_channels=3, out_channels=2).to(device) # 3通道輸入,2類輸出
criterion = nn.CrossEntropyLoss() # 用于像素級分類
optimizer = torch.optim.Adam(model.parameters(), lr=1e-4)# ========================
# 5?? 模型訓練主循環
# ========================
for epoch in range(30): # 總共訓練 30 個 epochmodel.train()running_loss = 0.0for imgs, masks in train_loader:imgs, masks = imgs.to(device), masks.to(device)outputs = model(imgs) # 輸出尺寸 [B, 2, H, W]loss = criterion(outputs, masks) # CrossEntropy 會自動處理 one-hot 標簽optimizer.zero_grad()loss.backward()optimizer.step()running_loss += loss.item()print(f"Epoch {epoch + 1}: Loss = {running_loss / len(train_loader):.4f}")# ========================
# 6?? 測試 + 可視化預測結果
# ========================
model.eval() # 切換為評估模式(關閉 Dropout/BN 等)
with torch.no_grad(): # 不計算梯度,加速推理for img, mask in test_loader:img = img.to(device)output = model(img) # 網絡前向推理,輸出 shape [1, 2, H, W]pred = torch.argmax(output, dim=1) # 取每個像素的最大概率類別 → shape [1, H, W]# 可視化結果plt.figure(figsize=(12, 4))plt.subplot(1, 3, 1)plt.title("Image")plt.imshow(img[0].cpu().permute(1, 2, 0)) # [C,H,W] → [H,W,C] 顯示 RGB 圖plt.subplot(1, 3, 2)plt.title("Prediction")plt.imshow(pred[0].cpu(), cmap="gray") # 顯示預測掩碼plt.subplot(1, 3, 3)plt.title("Ground Truth")plt.imshow(mask[0], cmap="gray") # 顯示真值掩碼plt.tight_layout()plt.show()break # 只可視化第一張測試圖像
三、Unet網絡的Matlab實現
3.1 網絡構建
主函數如下
function lgraph = unetLayers(inputSize, numClasses)
% unetLayers 構建一個標準的 U-Net 分割網絡結構
%
% 輸入參數:
% inputSize - 輸入圖像的尺寸,例如 [512 512 3]
% numClasses - 分割類別數(例如背景+前景=2)
%
% 輸出參數:
% lgraph - layerGraph 對象,包含整個 U-Net 網絡結構% === 輸入層 ===
% 輸入圖像大小為 inputSize(例如 [512 512 3])
layers = imageInputLayer(inputSize, 'Name', 'input');% === 編碼器部分(Encoder)===
% 每個 encoderBlock 包括 2 個卷積 + ReLU + 最大池化,逐步提取特征 & 降維
% 同時記錄每個 block 的輸出,用于后續的 skip connection[enc1, enc1Out] = encoderBlock('enc1', 64); % 第一層編碼器,輸出 64 通道特征圖
[enc2, enc2Out] = encoderBlock('enc2', 128); % 第二層編碼器
[enc3, enc3Out] = encoderBlock('enc3', 256); % 第三層編碼器
[enc4, enc4Out] = encoderBlock('enc4', 512); % 第四層編碼器% === Bottleneck(編碼器與解碼器之間的連接)===
% 特征最抽象、最小尺寸的位置
% 加入 Dropout 層,增強模型泛化能力,避免過擬合
bottleneck = [convolution2dLayer(3, 1024, 'Padding','same','Name','bottom_conv1')reluLayer('Name','bottom_relu1')dropoutLayer(0.5, 'Name','bottom_dropout') % 50% 隨機丟棄通道convolution2dLayer(3, 1024, 'Padding','same','Name','bottom_conv2')reluLayer('Name','bottom_relu2')
];% === 解碼器部分(Decoder)===
% 每個 decoderBlock 先上采樣,再與對應 encoder 輸出進行 skip connection
% 然后通過卷積融合特征圖,逐步恢復空間分辨率dec4 = decoderBlock('dec4', 512); % 解碼器第4層,對應 encoder 的第4層
dec3 = decoderBlock('dec3', 256);
dec2 = decoderBlock('dec2', 128);
dec1 = decoderBlock('dec1', 64);% === 輸出層 ===
outputLayer = [convolution2dLayer(1, numClasses, 'Name', 'final_conv') % 1x1 卷積,通道映射為類別數softmaxLayer('Name', 'softmax') % 每個像素的類別概率pixelClassificationLayer('Name', 'pixelClassLayer') % 自動處理標簽+計算交叉熵損失
];% === 組裝整個網絡 ===
% 把所有層拼成一個大的 LayerGraph(U-Net 主干結構)
lgraph = layerGraph([layers;enc1; enc2; enc3; enc4;bottleneck;dec4; dec3; dec2; dec1;outputLayer
]);% === 添加 skip connections(跳躍連接)===
% 將 encoder 的中間輸出與對應 decoder 的拼接輸入連接起來
% 連接的是 decoderBlock 中的 depthConcatenationLayer 的第 2 個輸入端口(in2)lgraph = connectLayers(lgraph, enc4Out, 'dec4_concat/in2');
lgraph = connectLayers(lgraph, enc3Out, 'dec3_concat/in2');
lgraph = connectLayers(lgraph, enc2Out, 'dec2_concat/in2');
lgraph = connectLayers(lgraph, enc1Out, 'dec1_concat/in2');end
上面函數里用到的兩個輔助函數encoderBlock和decoderBlock。分別如下所示
function [layers, outputName] = encoderBlock(name, outChannels)
% encoderBlock 生成 U-Net 編碼器模塊的一組層
%
% 輸入參數:
% name - 當前模塊的名字前綴,用于給每一層命名(如 "enc1")
% outChannels - 輸出通道數,即卷積核數量,決定了特征圖的深度
%
% 輸出參數:
% layers - 一組 Layer,用于組成 layerGraph 的一部分
% outputName - 最后一層 relu 的名稱,用于后續 skip connection 連接layers = [% 第1個卷積層,使用 3x3 卷積核,輸出通道為 outChannels,padding=same 保持尺寸不變convolution2dLayer(3, outChannels, "Padding", "same", "Name", [name, '_conv1'])% ReLU 激活函數,增加非線性表達能力reluLayer("Name", [name '_relu1'])% 第2個卷積層,繼續提取特征(仍是 3x3 卷積)convolution2dLayer(3, outChannels, "Padding", "same", "Name", [name, '_conv2'])% ReLU 激活函數reluLayer("Name", [name '_relu2'])% 最大池化層,使用 2x2 核,步長為 2,用于降采樣(尺寸縮小一半)maxPooling2dLayer(2, 'Stride', 2, 'Name', [name '_pool'])
];
function layers = decoderBlock(name, outChannels)
% decoderBlock 生成 U-Net 解碼器模塊的一組層
%
% 輸入參數:
% name - 當前模塊的名字前綴(如 "dec1")
% outChannels - 輸出通道數(卷積核數量)
%
% 輸出參數:
% layers - 一組層,用于構建 U-Net 的解碼器部分layers = [% 上采樣層:使用 2x2 轉置卷積進行上采樣,步長為2,使特征圖尺寸擴大一倍transposedConv2dLayer(2, outChannels, 'Stride', 2, 'Name', [name '_upconv'])% 跳躍連接:將 encoder 的輸出與上采樣結果拼接在深度維度上(channel 維)% 注意:輸入端需要通過 connectLayers 手動連接 encoder 的輸出depthConcatenationLayer(2, 'Name', [name '_concat'])% 卷積層1:拼接后做一次卷積提取融合后的特征convolution2dLayer(3, outChannels, 'Padding','same','Name', [name '_conv1'])% 激活層1:ReLU 非線性激活reluLayer('Name',[name '_relu1'])% 卷積層2:再進一步提取特征convolution2dLayer(3, outChannels, 'Padding','same','Name', [name '_conv2'])% 激活層2:ReLUreluLayer('Name',[name '_relu2'])
];
end
用matlab可以簡單畫出這個網絡的結構,如下圖
inputSize = [512 512 1]; % 輸入圖像大小
numClasses = 2; % 前景 / 背景
lgraph = unetLayers(inputSize, numClasses);
plot(lgraph) % 可視化網絡結構
3.2 網絡訓練
數據集用到的是網絡上公開的數據集(MoNuSeg 2018 Data)。
訓練程序如下
% === 路徑設置 ===
% 設置訓練圖像和對應標簽(掩膜)的路徑
imageDir = 'Training Data/Tissue Images'; % 原始圖像路徑
maskDir = 'Training Data/Masks'; % 對應的標簽圖路徑(掩碼)% === 分類標簽設置 ===
% 定義語義分割任務中的類別名稱和對應的像素值
% 這些 pixel 值應該和 mask 圖像中的像素一致
classNames = ["background", "nuclei"]; % 類別名稱
labelIDs = [0, 1]; % 對應的像素值,0=背景,1=細胞核% === 創建圖像和標簽的 Datastore ===
imds = imageDatastore(imageDir); % 加載圖像(支持自動批量讀取)
resizeSize = [512 512]; % 所有圖像統一 resize 的尺寸% pixelLabelDatastore 將 mask 圖像轉為每像素的分類標簽
pxds = pixelLabelDatastore(maskDir, classNames, labelIDs);% === 創建聯合數據源:pixelLabelImageDatastore ===
% 用于將圖像和標簽按順序配對,并自動 resize
trainingData = pixelLabelImageDatastore(imds, pxds, 'OutputSize', resizeSize);
% 返回的對象可直接輸入到 trainNetwork 作為訓練集% === 定義網絡結構 ===
inputSize = [512 512 3]; % 圖像尺寸是 512x512,RGB 三通道
numClasses = 2; % 類別數為 2(背景 + 細胞核)% 創建 U-Net 網絡結構(調用你自定義的 unetLayers 函數)
lgraph = unetLayers(inputSize, numClasses);% === 設置訓練參數 ===
options = trainingOptions('adam', ... % 使用 Adam 優化器'InitialLearnRate', 1e-4, ... % 初始學習率'MaxEpochs', 30, ... % 最大訓練輪數(epoch)'MiniBatchSize', 2, ... % 每次訓練使用 2 張圖像'Shuffle','every-epoch', ... % 每輪訓練時打亂數據順序'VerboseFrequency', 10, ... % 每 10 次迭代輸出一次信息'Plots','training-progress', ... % 實時繪制訓練損失曲線'ExecutionEnvironment','auto'); % 自動選擇 CPU / GPU% === 開始訓練 ===
% 使用訓練數據、U-Net 結構和訓練參數進行模型訓練
net = trainNetwork(trainingData, lgraph, options);% === 保存模型到文件 ===
% 將訓練好的模型保存在本地,方便后續使用或預測
save('trained_unet_monuseg.mat', 'net');
3.3 網絡預測
% === 設置路徑 ===
% 讀取一張測試圖像以及其對應的 ground truth 掩碼(標簽)
testImage = imread('Test Data/Tissue Images/TCGA-2Z-A9J9-01A-01-TS1.tif'); % 測試原圖
gt_mask = imread('Test Data/Masks/TCGA-2Z-A9J9-01A-01-TS1.png'); % 對應的真實標簽圖% === Resize 成訓練時的網絡輸入大小 ===
% 如果訓練時輸入圖像是 [512×512],推理時也要保證尺寸一致
testImage = imresize(testImage, [512 512]);% === 加載訓練好的 U-Net 模型 ===
% 模型應包含名為 net 的變量,即訓練階段保存的網絡結構和權重
load('trained_unet_monuseg.mat'); % 加載變量 net% === 執行語義分割預測 ===
% 調用 MATLAB 的 semanticseg 函數,自動處理輸入并輸出預測分類圖
% 返回的是 categorical 類型的預測圖,每個像素是 "background" 或 "nuclei"
pred = semanticseg(testImage, net);% === 可視化預測分割結果(與原圖疊加)===
% 使用 labeloverlay 將分割掩碼疊加在原圖上,便于直觀觀察分割效果
figure;
imshow(labeloverlay(testImage, pred))
title('預測分割結果')% === 將預測結果轉為二值圖像 ===
% 把 categorical 類型的預測圖轉為 0/1 掩碼圖(uint8)
% nuclei → 1,background → 0
pred_mask = uint8(pred == "nuclei");% === 顯示 原圖、預測掩碼、真實掩碼 的對比 ===
figure;
subplot(1,3,1); imshow(testImage); title('原圖'); % 顯示輸入圖像
subplot(1,3,2); imshow(pred_mask,[]); title('預測'); % 顯示模型預測的掩碼(0/1圖)
subplot(1,3,3); imshow(gt_mask,[]); title('真值'); % 顯示人工標注的 ground truth 掩碼