在图像分割任务中,单纯的分类网络往往“认得出来,却画不出来”。比如给模型一张街景图,分类网络能告诉你“图里有机动车、行人、建筑”,却无法告诉你“机动车在哪个像素范围、行人在哪一片区域”。当业务需求从“这张图里有什么”变成“把这个物体的轮廓精确抠出来”时,就需要 U-Net 这类像素级分割网络登场了。
本文将以 PyTorch 为基础框架,从零实现一个经典的 U-Net 图像分割模型,并配有一套完整的合成数据集用于训练和验证。无论你是刚接触 PyTorch 入门的新手,还是已经在分类任务上有一定经验、希望向分割方向拓展的开发者,都可以跟着本文的步骤,亲手完成“数据生成 → 模型搭建 → 训练验证 → 推理可视化”的完整链路。
1. 图像分割任务与 U-Net 的核心思想
1.1 图像分割在解决什么问题
图像分割是计算机视觉中比分类和检测更精细的任务。它的输出不是类别标签,也不是目标框,而是一张和原图尺寸相同(或略小)的掩码图,掩码图中的每个像素都对应一个类别标签。
按精细化程度,图像分割通常可以分成三类:
| 类型 | 输出内容 | 典型场景 |
|---|---|---|
| 语义分割 | 给每个像素分配一个类别,不区分同一类别的不同个体 | 自动驾驶道路区域分割、医学图像中的器官分割 |
| 实例分割 | 给每个像素分配类别,同时区分同一类别的不同个体 | 细胞计数、行人实例分割 |
| 全景分割 | 同时处理“stuff”背景类和“thing”前景实例类 | 城市街景理解 |
U-Net 最初是在医学图像分割任务中提出的,后来被广泛应用到遥感影像分割、工业质检、广告牌图像分割系统等场景中。它的优势在于:标注数据少时仍能获得不错的效果;编码器-解码器结构非常容易扩展到新的数据集;跳跃连接的设计让浅层空间细节和深层语义信息能够融合,输出轮廓更精细。
1.2 U-Net 架构的编码器-解码器与跳跃连接
U-Net 的网络结构可以从名字上直观理解:左侧是一条收缩路径,右侧是一条扩张路径,整体形状像字母“U”。
左侧编码器部分由多个卷积块和下采样层组成。每一次下采样会把特征图尺寸缩小一半,同时把通道数增加一倍。这个过程类似 VGG、ResNet 等分类网络中的特征提取阶段,模型逐步学习从局部纹理到高层语义的特征。
右侧解码器部分做的是相反操作:把低分辨率的语义特征逐步上采样回原图尺寸,并通过跳跃连接将编码器同尺寸的特征图拼接到一起。这一步非常关键,因为单纯的上采样会丢失很多细节,比如物体边缘、小结构;而跳跃连接把下采样之前的细节特征直接传进来,让解码器能够同时参考“全局语义”和“局部细节”。
跳跃连接不仅是通道维度的拼接,还起到了梯度传播的作用。这让 U-Net 在训练时收敛更快,也更稳定。
下面我们直接进入 PyTorch 实战环节,先确定环境与项目结构。
2. 环境准备与项目结构
2.1 Python 与 PyTorch 环境
本文示例代码不依赖复杂第三方库,核心只需要 PyTorch 和基础图像处理库。
Python >= 3.8 PyTorch >= 1.10 numpy Pillow matplotlib如果你还没有配置 PyTorch 环境,可以参考官方提供的安装命令,根据自己电脑是否有 NVIDIA 显卡选择 CPU 版或 CUDA 版。版本兼容问题比较常见,建议在安装时通过以下方式确认:
python -c "import torch; print(torch.__version__, torch.cuda.is_available())"如果输出中torch.cuda.is_available()为False,说明当前环境无法使用 GPU。本文训练的是合成数据,数据量小、模型规模适中,CPU 也能运行,只是速度稍慢。
需要特别提醒的是,PyTorch 与 CUDA 版本存在配套关系,安装时尽量避免直接使用最新版本,先确认本机显卡驱动支持的 CUDA 版本,再去 PyTorch 官网选择对应命令。
2.2 合成数据集设计
为了让大家能完整跑通流程,同时又不需要提前下载动辄几个 GB 的真实分割数据集,本文采用合成数据来完成实验。
数据生成规则如下:
- 图像尺寸 256×256,灰度图。
- 背景为高斯噪声,模拟简单纹理。
- 每张图随机绘制 1~3 个圆形或矩形作为前景目标。
- 掩码图中前景目标区域为白色(255),背景区域为黑色(0)。
这个任务虽然比真实场景简单,但已经包含了图像分割最核心的问题:像素级二分类。模型能够在这个任务上达到较高 IoU 后,再切换真实数据集,只需要更换数据加载部分,模型代码和训练流程可以完全复用。
2.3 项目结构
unet-segmentation/ ├── generate_data.py # 生成合成数据集 ├── dataset.py # Dataset 与数据增强 ├── model.py # U-Net 模型定义 ├── train.py # 训练与验证 ├── inference.py # 单张推理与可视化 ├── data/ │ ├── train/ │ │ ├── images/ │ │ └── masks/ │ └── val/ │ ├── images/ │ └── masks/建议新建一个专门的目录存放以上文件,避免与其它项目混在一起。
3. 从零实现 U-Net 模型
3.1 卷积块与 Down 模块
U-Net 中大量使用“两次卷积 + 批归一化 + ReLU”的组合,我们把它封装成DoubleConv模块。加入BatchNorm2d能有效缓解深层网络训练时的梯度分布问题,也能加快收敛速度。
# 文件路径:model.py import torch import torch.nn as nn class DoubleConv(nn.Module): """两次卷积 + BN + ReLU""" def __init__(self, in_channels, out_channels, mid_channels=None): super().__init__() if mid_channels is None: mid_channels = out_channels self.double_conv = nn.Sequential( nn.Conv2d(in_channels, mid_channels, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(mid_channels), nn.ReLU(inplace=True), nn.Conv2d(mid_channels, out_channels, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), ) def forward(self, x): return self.double_conv(x)Down模块负责下采样,这里使用最大池化将特征图宽高缩小一半,然后接DoubleConv增加通道数。
class Down(nn.Module): """下采样:最大池化 + 双卷积""" def __init__(self, in_channels, out_channels): super().__init__() self.maxpool_conv = nn.Sequential( nn.MaxPool2d(kernel_size=2, stride=2), DoubleConv(in_channels, out_channels), ) def forward(self, x): return self.maxpool_conv(x)3.2 Up 模块与跳跃连接
Up模块是 U-Net 的核心创新点。我们使用转置卷积ConvTranspose2d将特征图的宽高扩大一倍,然后和编码器对应层的特征在通道维上拼接,最后用DoubleConv将拼接后的特征融合输出。
class Up(nn.Module): """上采样:转置卷积 + 跳跃连接拼接 + 双卷积""" def __init__(self, in_channels, out_channels): super().__init__() self.up = nn.ConvTranspose2d(in_channels, out_channels, kernel_size=2, stride=2) self.conv = DoubleConv(out_channels * 2, out_channels) def forward(self, x, skip): x = self.up(x) x = torch.cat([x, skip], dim=1) return self.conv(x)这里有个容易忽略的细节:为什么self.conv的输入通道是out_channels * 2?
因为转置卷积先把x的通道数从in_channels降到out_channels,随后跳跃连接的skip恰好也是out_channels通道,拼接后得到out_channels * 2,再由DoubleConv处理回out_channels。
3.3 完整 U-Net 网络
有了基础模块后,组装完整的 U-Net 就很简单了。整体结构如下:
输入 (1×256×256) ↓ DoubleConv(1 → 64) → skip1 ↓ Down(64 → 128) → skip2 ↓ Down(128 → 256) → skip3 ↓ Down(256 → 512) → skip4 ↓ Down(512 → 1024) → 最底层特征 ↓ Up(1024 → 512) 拼接 skip4 ↓ Up(512 → 256) 拼接 skip3 ↓ Up(256 → 128) 拼接 skip2 ↓ Up(128 → 64) 拼接 skip1 ↓ 输出卷积(64 → n_classes)完整代码如下:
class OutConv(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = nn.Conv2d(in_channels, out_channels, kernel_size=1) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, n_channels=1, n_classes=1): super().__init__() self.inc = DoubleConv(n_channels, 64) self.down1 = Down(64, 128) self.down2 = Down(128, 256) self.down3 = Down(256, 512) self.down4 = Down(512, 1024) self.up1 = Up(1024, 512) self.up2 = Up(512, 256) self.up3 = Up(256, 128) self.up4 = Up(128, 64) self.outc = OutConv(64, n_classes) def forward(self, x): x1 = self.inc(x) x2 = self.down1(x1) x3 = self.down2(x2) x4 = self.down3(x3) x5 = self.down4(x4) x = self.up1(x5, x4) x = self.up2(x, x3) x = self.up3(x, x2) x = self.up4(x, x1) logits = self.outc(x) return logits模型输出层的n_classes=1,最后没有接 Sigmoid,而是输出 logits。这样是为了配合BCEWithLogitsLoss,它在内部同时完成 Sigmoid 与损失计算,数值上更稳定,训练时强烈建议这样写。
为了快速验证网络定义是否正确,可以在model.py末尾加上一段自测代码:
if __name__ == "__main__": model = UNet(n_channels=1, n_classes=1) dummy = torch.randn(1, 1, 256, 256) out = model(dummy) print("输出形状:", out.shape)运行后会输出torch.Size([1, 1, 256, 256]),表示输出和输入分辨率一致,每个像素都对应一个 logit 值。
4. 数据生成与加载
4.1 生成合成图像与掩码
我们采用PIL.ImageDraw来绘制圆形和矩形。为了让生成的数据更有区分度,前景区域使用 60~200 之间的随机灰度值,背景使用高斯噪声,这样模型不能仅凭“是否纯黑”来判断目标区域。
# 文件路径:generate_data.py import os import numpy as np from PIL import Image, ImageDraw def generate_sample(img_size=256, seed=None): rng = np.random.default_rng(seed) # 图像:高斯噪声背景 arr = rng.normal(128, 20, (img_size, img_size)) arr = arr.clip(0, 255).astype(np.uint8) img = Image.fromarray(arr, mode="L") mask = Image.new("L", (img_size, img_size), 0) draw_img = ImageDraw.Draw(img) draw_mask = ImageDraw.Draw(mask) for _ in range(int(rng.integers(1, 4))): shape = rng.choice(["circle", "rect"]) if shape == "circle": r = int(rng.integers(15, 50)) x = int(rng.integers(r, img_size - r)) y = int(rng.integers(r, img_size - r)) draw_img.ellipse([x - r, y - r, x + r, y + r], fill=int(rng.integers(60, 200))) draw_mask.ellipse([x - r, y - r, x + r, y + r], fill=255) else: w = int(rng.integers(30, 80)) h = int(rng.integers(30, 80)) x0 = int(rng.integers(0, img_size - w)) y0 = int(rng.integers(0, img_size - h)) draw_img.rectangle([x0, y0, x0 + w, y0 + h], fill=int(rng.integers(60, 200))) draw_mask.rectangle([x0, y0, x0 + w, y0 + h], fill=255) return np.array(img, dtype=np.uint8), np.array(mask, dtype=np.uint8) def main(): base_dir = "data" train_img_dir = os.path.join(base_dir, "train", "images") train_mask_dir = os.path.join(base_dir, "train", "masks") val_img_dir = os.path.join(base_dir, "val", "images") val_mask_dir = os.path.join(base_dir, "val", "masks") for d in [train_img_dir, train_mask_dir, val_img_dir, val_mask_dir]: os.makedirs(d, exist_ok=True) for i in range(60): img, mask = generate_sample(seed=i) if i < 48: img.save(os.path.join(train_img_dir, f"sample_{i:03d}.png")) Image.fromarray(mask).save(os.path.join(train_mask_dir, f"sample_{i:03d}.png")) else: img.save(os.path.join(val_img_dir, f"sample_{i:03d}.png")) Image.fromarray(mask).save(os.path.join(val_mask_dir, f"sample_{i:03d}.png")) print("合成数据集生成完成:" + f"train 48 张,val 12 张") if __name__ == "__main__": main()执行后在data目录下生成train和val两个子集,每个子集内部再分为images和masks两个目录。目录分离的优点是训练集和验证集天然独立,不会发生数据泄露。
4.2 自定义 Dataset 与数据增强
PyTorch 中加载自定义数据需要继承torch.utils.data.Dataset,实现__len__和__getitem__两个方法。由于本文是灰度图像,输入张量形状是(1, H, W);如果你要处理三通道 RGB 图像,只需要把convert("L")改成convert("RGB"),并调整模型输入的n_channels即可。
# 文件路径:dataset.py import glob import os import numpy as np import torch from PIL import Image from torch.utils.data import Dataset class SegmentationDataset(Dataset): def __init__(self, image_dir, mask_dir, train=False): self.image_paths = sorted(glob.glob(os.path.join(image_dir, "*.png"))) self.mask_paths = sorted(glob.glob(os.path.join(mask_dir, "*.png"))) assert len(self.image_paths) == len(self.mask_paths), "图像与掩码数量不一致" self.train = train def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image = Image.open(self.image_paths[idx]).convert("L") mask = Image.open(self.mask_paths[idx]).convert("L") image = np.array(image, dtype=np.float32) / 255.0 mask = np.array(mask, dtype=np.float32) / 255.0 # 训练集上做随机翻转增强,验证集不做 if self.train: if np.random.rand() < 0.5: image = np.flip(image, axis=1).copy() mask = np.flip(mask, axis=1).copy() if np.random.rand() < 0.5: image = np.flip(image, axis=0).copy() mask = np.flip(mask, axis=0).copy() image_tensor = torch.from_numpy(image).unsqueeze(0) mask_tensor = torch.from_numpy(mask).unsqueeze(0) return image_tensor, mask_tensor这里有几个工程上需要注意的要点:
- 图像和掩码要使用完全相同的翻转操作,否则训练数据的标注就错了。
np.flip返回的是视图,需要调用.copy()避免后续内存访问异常。- 灰度图归一化到
[0, 1],掩码也归一化到[0, 1],这样标签变成 0 或 1。
如果希望使用更丰富的数据增强,比如随机旋转、缩放、亮度调整,推荐引入albumentations库。它专门考虑了分割任务中“图像增强与掩码增强保持一致”的问题,用法比手写更简洁。
5. 训练与验证
5.1 损失函数与评估指标
本文是二分类分割问题,最直接的损失函数是BCEWithLogitsLoss。它期望模型输出是未经过 Sigmoid 的 logits,标签是 0 或 1,数值上更稳定。
不过实际工程中,很多分割任务存在严重的类别不平衡问题,比如肿瘤区域只占整幅图像的很小比例。这种情况下可以尝试DiceLoss,或者把 Dice Loss 与 BCE Loss 按比例结合。下面给出一个通用的 Dice Loss 实现,供后续替换使用:
import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = torch.sigmoid(logits) intersection = (probs * targets).sum() union = probs.sum() + targets.sum() dice = (2.0 * intersection + self.smooth) / (union + self.smooth) return 1.0 - dice评估指标方面,图像分割领域最常用的是 IoU(Intersection over Union),也叫 Jaccard 指数。它的计算方式是预测区域与真实区域的交集面积除以并集面积。
def compute_iou(pred, target, eps=1e-6): """计算每个样本的 IoU,返回 batch 均值""" pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) - intersection iou = (intersection + eps) / (union + eps) return iou.mean().item()当一张图中完全没有前景目标时,交集和并集都可能是 0,加入eps可以避免除零错误。不过在本实验的合成数据中,每张图都一定有前景目标,这个问题并不明显。
5.2 训练流程
有了模型、数据和指标,训练代码的结构就比较清晰了。我们用一个完整脚本来实现训练与验证:
# 文件路径:train.py import os import numpy as np import torch import torch.nn as nn from torch.utils.data import DataLoader from dataset import SegmentationDataset from model import UNet def compute_iou(pred, target, eps=1e-6): pred_flat = pred.view(pred.size(0), -1) target_flat = target.view(target.size(0), -1) intersection = (pred_flat * target_flat).sum(dim=1) union = pred_flat.sum(dim=1) + target_flat.sum(dim=1) - intersection iou = (intersection + eps) / (union + eps) return iou.mean().item() def validate(model, dataloader, criterion, device): model.eval() total_loss = 0.0 ious = [] with torch.no_grad(): for images, masks in dataloader: images = images.to(device) masks = masks.to(device) logits = model(images) loss = criterion(logits, masks) total_loss += loss.item() * images.size(0) probs = torch.sigmoid(logits) preds = (probs > 0.5).float() ious.append(compute_iou(preds, masks)) avg_loss = total_loss / len(dataloader.dataset) avg_iou = float(np.mean(ious)) return avg_loss, avg_iou def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") print("使用设备:", device) batch_size = 4 epochs = 20 lr = 1e-3 train_ds = SegmentationDataset("data/train/images", "data/train/masks", train=True) val_ds = SegmentationDataset("data/val/images", "data/val/masks", train=False) train_loader = DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=0) val_loader = DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=0) model = UNet(n_channels=1, n_classes=1).to(device) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=lr) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="max", patience=3, factor=0.5 ) best_iou = 0.0 for epoch in range(1, epochs + 1): model.train() total_loss = 0.0 for images, masks in train_loader: images = images.to(device) masks = masks.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, masks) loss.backward() optimizer.step() total_loss += loss.item() * images.size(0) train_loss = total_loss / len(train_ds) val_loss, val_iou = validate(model, val_loader, criterion, device) scheduler.step(val_iou) if val_iou > best_iou: best_iou = val_iou torch.save(model.state_dict(), "best_model.pth") print( f"Epoch {epoch:02d} | train_loss={train_loss:.4f} | " f"val_loss={val_loss:.4f} | val_iou={val_iou:.4f} | 保存最佳模型" ) else: print( f"Epoch {epoch:02d} | train_loss={train_loss:.4f} | " f"val_loss={val_loss:.4f} | val_iou={val_iou:.4f}" ) print("训练完成,最佳验证 IoU:", round(best_iou, 4)) if __name__ == "__main__": main()在默认参数下运行 20 个 epoch,合成数据集的验证 IoU 很快就能达到 0.95 以上。如果你的运行环境是 CPU,可以将epochs调小到 10,或者将batch_size调整为 2,减少等待时间。
关于学习率和调度器,这里使用ReduceLROnPlateau根据验证 IoU 自动调整学习率:当指标连续多个 epoch 不上升时,学习率减半。这样可以在接近收敛时避免震荡。
6. 模型推理与可视化
6.1 加载模型
训练结束后,当前目录下会生成best_model.pth。加载模型时要注意两点:
- 保存的是
state_dict,不是整个模型对象。 - 加载时需要提前实例化网络结构,且传入的参数要和训练时一致。
device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(n_channels=1, n_classes=1).to(device) model.load_state_dict(torch.load("best_model.pth", map_location=device)) model.eval()map_location参数很重要。如果模型是在 GPU 上训练的,而现在只有 CPU 环境,不加这个参数会报位置不匹配的错误。
6.2 预测结果可视化
推理时,输入图像要和训练过程保持一致:先转成灰度图或 RGB,再归一化,最后变成(1, C, H, W)的张量。下面以验证集中的sample_048.png为例,完成预测并可视化:
# 文件路径:inference.py import numpy as np import torch import matplotlib.pyplot as plt from PIL import Image from model import UNet def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(n_channels=1, n_classes=1).to(device) model.load_state_dict(torch.load("best_model.pth", map_location=device)) model.eval() image_path = "data/val/images/sample_048.png" mask_path = "data/val/masks/sample_048.png" image_np = np.array(Image.open(image_path).convert("L"), dtype=np.float32) / 255.0 mask_np = np.array(Image.open(mask_path).convert("L"), dtype=np.float32) / 255.0 image_tensor = torch.from_numpy(image_np).unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): logits = model(image_tensor) probs = torch.sigmoid(logits) pred_np = (probs.cpu().numpy().squeeze() > 0.5) base = np.stack([image_np * 255] * 3, axis=-1).astype(np.uint8) overlay = base.copy() overlay[pred_np] = (255, 80, 80) fig, axes = plt.subplots(1, 4, figsize=(16, 4)) axes[0].imshow(image_np, cmap="gray") axes[0].set_title("Original") axes[1].imshow(mask_np, cmap="gray") axes[1].set_title("Ground Truth") axes[2].imshow(pred_np, cmap="gray") axes[2].set_title("Prediction") axes[3].imshow(overlay) axes[3].set_title("Overlay") for ax in axes: ax.axis("off") plt.tight_layout() plt.show() if __name__ == "__main__": main()如果你是在服务器上运行,没有图形界面,可以把plt.show()换成plt.savefig("result.png"),这样同样可以拿到可视化结果。
从实际效果来看,U-Net 在合成数据上的预测 mask 基本能覆盖完整目标区域。由于模型本身有池化和下采样过程,目标边缘会有一两个像素的偏差,但总体上 IoU 指标能保持很高水平。
7. 常见问题与排查思路
无论你是第一次跑分割任务,还是准备把 U-Net 迁移到自己的数据集上,下面这些高频问题都值得提前了解。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 训练时 CUDA out of memory | batch_size 太大,或输入分辨率过高 | 减小 batch_size、降低输入图像尺寸、使用梯度累积 |
| 验证 IoU 始终为 0 | 预测阈值设置不当,或类别极度不平衡 | 检查 Sigmoid 输出分布,调整阈值,换用 Dice Loss |
| 模型输出有棋盘格伪影 | 转置卷积叠加产生重叠区域不均匀 | 可换用双线性上采样 + 普通卷积的组合 |
| 损失不下降 | 学习率过大或过小,特征归一化缺失 | 初始化学习率设为 1e-3/1e-4,检查是否加了 BatchNorm |
| 加载模型报键名错误 | 网络参数与保存的 state_dict 不一致 | 确认加载前实例化的 UNet 参数与训练时一致 |
| 跳跃连接拼接尺寸不一致 | 输入图片宽高不能被 16 整除 | 将图片 resize 到 64 的整数倍,或使用 padding 策略 |
| CPU 训练速度很慢 | 模型参数量大,数据预处理耗时 | 降低输入分辨率、减少 epoch、或切换到 GPU |
其中“跳跃连接拼接尺寸不一致”是大图输入时最容易踩的坑。因为 U-Net 内部有 4 次下采样和 4 次上采样,如果输入尺寸不是 16 的倍数,上采样后的尺寸可能与编码器对应层的输出尺寸对不上,直接拼接会报错。解决办法有两个:一是将输入图片统一 resize 到能被 16 整除的尺寸;二是在每个卷积层中根据尺寸差做适当裁剪。
8. 工程实践建议与后续学习路线
8.1 工程实践建议
把 U-Net 从基础 Demo 迁移到真实项目时,有几个方向值得重点打磨。
第一,损失函数不要只盯着 BCE。医学图像分割、遥感分割等场景中前景占比往往很小,此时单独使用 BCE 会导致模型偏向预测背景。建议使用Dice Loss、Focal Loss,或者将多个损失按权重组合。
第二,数据增强是分割任务的重要收益来源。随机翻转、随机缩放、随机旋转、颜色抖动都能提升模型的泛化能力。但要特别注意,几何变换必须对图像和掩码同时做完全一致的变换,否则模型会学到错误的标注。
第三,分割模型训练时,批量大小不宜过小。BatchNorm在 batch 较小时统计量不稳定,可能影响训练效果。如果显存有限,可以降低分辨率而不是强行缩小 batch。
第四,要保存最佳模型而不是最后一个 epoch 的模型。本文的训练代码中已通过验证 IoU 监控val_iou并保存最优权重,这项习惯在真实项目中能避免很多性能回退问题。
第五,推理阶段可以增加简单的 TTA(Test Time Augmentation)。例如把输入图像水平翻转后分别预测,再将两个预测结果取平均。这个技巧不需要重新训练,通常能带来一点点的 IoU 提升。
8.2 后续学习路线
完成本文的 U-Net 图像分割实战后,你可以继续往几个方向深入探索。
最直接的扩展是把输入从灰度图换成 RGB 三通道,然后在真实数据集上训练,比如著名的CamVid道路分割数据集、VOC语义分割数据集,或者医学影像分割中的LiTS肝脏数据集。真实数据的分布更复杂,你会遇到类别不均衡、边界模糊、标注噪声等问题,这些正是工程中真正需要处理的部分。
模型结构方面,可以对比尝试U-Net++、Attention U-Net、DeepLabV3、SegNet等变体,理解不同改进方案背后的动机。PyTorch 生态下也有一些现成的分割库,但在熟悉底层实现之前,建议先自己手写一遍,这样后续换框架排查问题会更快。
如果你的目标方向是医学图像分割,需要额外关注图像灰度范围不一致、标注标准多样化、多模态影像配准等问题。这类任务往往要求较高的准确率,且训练样本偏少,因此数据增强和迁移学习的重要性会更加突出。
总之,图像分割不只是一套网络结构,更是一套“数据构建 - 模型设计 - 损失优化 - 指标评估 - 边缘场景处理”的完整工程链路。希望本文这份完整的 PyTorch 入门实战,能帮你把 U-Net 真正跑通、用好,并在自己的项目中顺利落地。