PyTorch实战:手把手教你从零搭建UNet医学图像分割模型(附完整代码)

📅 发布时间:2026/7/10 6:32:39 👁️ 浏览次数:
PyTorch实战:手把手教你从零搭建UNet医学图像分割模型(附完整代码)
PyTorch实战手把手教你从零搭建UNet医学图像分割模型附完整代码如果你正在寻找一个能让你真正理解并亲手搭建UNet模型的实战指南那么你来对地方了。医学图像分割这个听起来有些专业的名词其实离我们并不遥远。从CT影像中自动勾勒出肿瘤区域到显微镜下识别细胞结构再到MRI图像中分割出特定器官这些任务的核心往往都离不开一个经典的网络架构——UNet。它以其独特的U型对称结构和跳跃连接在像素级预测任务中表现出了惊人的鲁棒性。对于刚接触PyTorch和计算机视觉的开发者来说UNet是一个绝佳的起点它结构清晰原理直观却能解决非常实际的问题。本文不会仅仅停留在概念讲解我们将一起从零开始用PyTorch一步步构建一个完整的、可训练的UNet模型。我会带你深入每个模块的细节解释为什么这么设计并提供可以直接运行的代码。无论你是想为自己的研究项目打基础还是单纯对深度学习在医疗影像中的应用感到好奇这篇文章都将为你提供一条清晰的实践路径。1. 理解UNet为何它成为医学图像分割的基石在深入代码之前我们有必要先搞清楚UNet为何如此成功。它诞生于2015年原论文旨在解决生物医学图像分割问题。其核心思想并不复杂一个收缩路径编码器用于捕捉上下文信息一个对称的扩张路径解码器用于精确定位。两者之间通过“跳跃连接”桥接使得解码器在恢复空间分辨率时能复用编码器中的高分辨率特征。提示跳跃连接是UNet的灵魂它有效缓解了深层网络中的梯度消失问题并保留了浅层网络捕获的细节信息这对于需要精确边界的分割任务至关重要。与当时流行的全卷积网络FCN相比UNet的对称结构和对小样本数据的高效利用使其在医疗数据通常有限的情况下脱颖而出。它的设计哲学可以概括为以下几点编码器-解码器对称结构这种结构天然适合像素到像素的映射任务。编码器像是一个信息“压缩”过程逐步提取高级语义特征解码器则是一个“解压缩”过程逐步将语义特征上采样回原始图像尺寸。跳跃连接Skip Connections这是最精妙的设计。它将编码器每一层的特征图直接拼接到解码器对应层的特征图上。这样做的好处是解码器在“想象”物体细节时能直接参考编码器捕获的原始、高分辨率的边缘和纹理信息而不是仅仅依赖经过多次下采样后可能已经模糊的抽象特征。端到端的训练输入是原始图像输出是相同尺寸的分割掩码整个过程可微分能够通过反向传播一次性优化。为了更直观地理解UNet的数据流我们可以看下面这个简化的特征图尺寸变化流程以输入256x256图像为例网络阶段操作特征图尺寸 (C, H, W)说明输入-(3, 256, 256)原始RGB图像编码器 Block 1卷积x2 下采样(64, 128, 128)提取初级特征池化缩小尺寸编码器 Block 2卷积x2 下采样(128, 64, 64)提取中级特征编码器 Block 3卷积x2 下采样(256, 32, 32)提取高级特征编码器 Block 4卷积x2 下采样(512, 16, 16)提取深层语义特征桥接层卷积x2(1024, 8, 8)最底层的特征表示解码器 Block 1上采样 拼接 卷积x2(512, 16, 16)上采样并与编码器Block4特征拼接解码器 Block 2上采样 拼接 卷积x2(256, 32, 32)上采样并与编码器Block3特征拼接解码器 Block 3上采样 拼接 卷积x2(128, 64, 64)上采样并与编码器Block2特征拼接解码器 Block 4上采样 拼接 卷积x2(64, 128, 128)上采样并与编码器Block1特征拼接输出层1x1卷积(N_classes, 256, 256)将通道数映射为类别数这个表格清晰地展示了数据如何在网络中流动、变换。接下来我们就将这种理解转化为PyTorch代码。2. 搭建UNet的核心模块从下采样到上采样我们将采用模块化的构建方式这会让代码更清晰、易于调试和复用。整个UNet可以分解为三个核心组件下采样块、上采样块以及最后的输出卷积层。2.1 下采样块编码器部分下采样块的任务是进行特征提取和空间降维。经典UNet论文中使用的是两个3x3卷积每个后面接ReLU激活加一个2x2最大池化。但在现代实现中我们常用一个步幅stride为2的卷积层来代替池化层进行下采样因为卷积层可以学习可能获得更好的效果。下面是我们实现的DownsampleBlock。它包含两次“卷积批归一化激活”操作以及一次下采样操作。import torch import torch.nn as nn import torch.nn.functional as F class DownsampleBlock(nn.Module): 下采样模块编码器的一个阶段。 包含两个卷积层用于特征提取以及一个下采样层降低空间分辨率。 输出两个值当前阶段的特征图用于跳跃连接和下采样后的特征图传入下一阶段。 def __init__(self, in_channels, out_channels): super(DownsampleBlock, self).__init__() # 特征提取部分两个卷积层 self.conv1 nn.Conv2d(in_channels, out_channels, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(out_channels) # 下采样部分使用最大池化 self.pool nn.MaxPool2d(kernel_size2, stride2) def forward(self, x): # 第一次卷积 批归一化 ReLU x F.relu(self.bn1(self.conv1(x))) # 第二次卷积 批归一化 ReLU x F.relu(self.bn2(self.conv2(x))) # 保存当前特征图用于后续跳跃连接 skip_connection x # 下采样准备进入下一层 x_pooled self.pool(x) return skip_connection, x_pooled在这个实现中forward方法返回两个张量skip_connection是经过两次卷积后的特征图它将直接传递给解码器对应的部分x_pooled是经过池化后的特征图它将作为下一个下采样块的输入。2.2 上采样块解码器部分与特征拼接上采样块是解码器的核心它负责恢复空间分辨率并融合来自编码器的细节信息。这里我们使用转置卷积Transposed Convolution进行上采样也有人喜欢使用双线性插值上采样再接一个卷积层。转置卷积可以学习如何更好地进行上采样。关键的一步是特征拼接Concatenation。我们将上采样后的特征图与来自编码器对应层的skip_connection在通道维度上进行拼接。class UpsampleBlock(nn.Module): 上采样模块解码器的一个阶段。 首先进行上采样然后将结果与编码器对应层的特征图拼接最后通过两个卷积层进行特征融合。 def __init__(self, in_channels, out_channels): super(UpsampleBlock, self).__init__() # 上采样层使用转置卷积将特征图尺寸扩大一倍 self.up_conv nn.ConvTranspose2d( in_channels, out_channels, kernel_size2, stride2 ) # 特征融合部分两个卷积层 # 注意拼接后通道数变为 out_channels * 2 self.conv1 nn.Conv2d(out_channels * 2, out_channels, kernel_size3, padding1) self.bn1 nn.BatchNorm2d(out_channels) self.conv2 nn.Conv2d(out_channels, out_channels, kernel_size3, padding1) self.bn2 nn.BatchNorm2d(out_channels) def forward(self, x, skip): Args: x: 来自上一解码器层的输入低分辨率高语义。 skip: 来自编码器对应层的跳跃连接特征高分辨率细节丰富。 # 上采样 x self.up_conv(x) # 调整skip connection的尺寸可选当尺寸不完全匹配时 # 这里假设尺寸匹配直接进行拼接 # 在通道维度(dim1)上进行拼接 x torch.cat([x, skip], dim1) # 特征融合两次卷积 x F.relu(self.bn1(self.conv1(x))) x F.relu(self.bn2(self.conv2(x))) return x注意在实际操作中由于池化操作可能导致尺寸不是精确的2的倍数编码器和解码器对应层的特征图尺寸可能无法完美对齐。一个常见的技巧是在拼接前使用torch.nn.functional.interpolate或中心裁剪center crop来调整skip connection的尺寸。为了简化我们的示例假设输入图像尺寸是2的幂次方如256x256可以完美对齐。3. 组装完整的UNet模型有了下采样和上采样模块我们就可以像搭积木一样构建完整的UNet了。我们需要定义编码器路径的4个下采样块、桥接层、解码器路径的4个上采样块以及最终的输出层。class UNet(nn.Module): def __init__(self, in_channels3, out_channels1, init_features64): Args: in_channels: 输入图像的通道数RGB为3灰度图为1。 out_channels: 输出分割图的通道数二分类为1多分类为类别数。 init_features: 第一层卷积的滤波器数量后续层以此为基础翻倍。 super(UNet, self).__init__() features init_features # 编码器路径 self.encoder1 DownsampleBlock(in_channels, features) # - (f, H, W) self.encoder2 DownsampleBlock(features, features * 2) # - (2f, H/2, W/2) self.encoder3 DownsampleBlock(features * 2, features * 4) # - (4f, H/4, W/4) self.encoder4 DownsampleBlock(features * 4, features * 8) # - (8f, H/8, W/8) # 桥接层最底层 self.bottleneck nn.Sequential( nn.Conv2d(features * 8, features * 16, kernel_size3, padding1), nn.BatchNorm2d(features * 16), nn.ReLU(inplaceTrue), nn.Conv2d(features * 16, features * 16, kernel_size3, padding1), nn.BatchNorm2d(features * 16), nn.ReLU(inplaceTrue) ) # 解码器路径 self.decoder4 UpsampleBlock(features * 16, features * 8) # 输入来自bottleneck self.decoder3 UpsampleBlock(features * 8, features * 4) self.decoder2 UpsampleBlock(features * 4, features * 2) self.decoder1 UpsampleBlock(features * 2, features) # 最终输出层1x1卷积将通道数映射到输出类别 self.final_conv nn.Conv2d(features, out_channels, kernel_size1) def forward(self, x): # 编码器 skip1, x1 self.encoder1(x) # skip1: (64, H, W), x1: (64, H/2, W/2) skip2, x2 self.encoder2(x1) # skip2: (128, H/2, W/2), x2: (128, H/4, W/4) skip3, x3 self.encoder3(x2) # skip3: (256, H/4, W/4), x3: (256, H/8, W/8) skip4, x4 self.encoder4(x3) # skip4: (512, H/8, W/8), x4: (512, H/16, W/16) # 桥接层 bottleneck self.bottleneck(x4) # (1024, H/16, W/16) # 解码器注意拼接顺序当前输入对应的skip connection d4 self.decoder4(bottleneck, skip4) # (512, H/8, W/8) d3 self.decoder3(d4, skip3) # (256, H/4, W/4) d2 self.decoder2(d3, skip2) # (128, H/2, W/2) d1 self.decoder1(d2, skip1) # (64, H, W) # 最终输出 output self.final_conv(d1) # (out_channels, H, W) # 对于二分类通常使用Sigmoid激活多分类则用Softmax在损失函数中体现 # output torch.sigmoid(output) return output现在一个完整的UNet模型就定义好了。你可以通过以下代码快速测试模型结构if __name__ __main__: # 创建一个模拟输入batch_size2, 3通道256x256图像 dummy_input torch.randn(2, 3, 256, 256) model UNet(in_channels3, out_channels1) output model(dummy_input) print(f输入尺寸: {dummy_input.shape}) print(f输出尺寸: {output.shape}) # 应为 torch.Size([2, 1, 256, 256]) # 打印模型参数量 total_params sum(p.numel() for p in model.parameters()) print(f模型总参数量: {total_params:,})4. 准备数据与训练流程模型搭建完毕下一步就是喂给它数据并开始训练。医学图像数据通常需要一些特殊的预处理比如归一化、尺寸调整和数据增强。这里我们以公开的医学分割数据集为例构建一个简单的数据管道。4.1 构建自定义数据集假设我们有一个包含图像和对应掩码mask的文件夹掩码是单通道的二值图0代表背景1代表目标。import os from PIL import Image from torch.utils.data import Dataset, DataLoader import torchvision.transforms as transforms class MedicalSegmentationDataset(Dataset): def __init__(self, image_dir, mask_dir, transformNone, image_size256): self.image_dir image_dir self.mask_dir mask_dir self.transform transform self.image_size image_size self.images os.listdir(image_dir) # 定义基础转换 self.to_tensor transforms.ToTensor() self.resize transforms.Resize((image_size, image_size)) # 图像归一化使用ImageNet均值和标准差是常见做法也可根据自己数据计算 self.normalize transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) def __len__(self): return len(self.images) def __getitem__(self, idx): img_name self.images[idx] img_path os.path.join(self.image_dir, img_name) # 假设掩码文件名与图像文件名相同或可通过规则转换 mask_path os.path.join(self.mask_dir, img_name.replace(.jpg, _mask.png)) image Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) # 以灰度模式读取掩码 # 调整尺寸 image self.resize(image) mask self.resize(mask) # 转换为Tensor image self.to_tensor(image) mask self.to_tensor(mask) # 对图像进行归一化 image self.normalize(image) # 确保掩码是二值的0或1 mask (mask 0.5).float() return image, mask4.2 配置训练循环训练一个分割模型我们需要选择合适的损失函数和优化器。对于二分类任务二元交叉熵损失BCEWithLogitsLoss是标准选择它内部集成了Sigmoid激活数值上更稳定。对于多分类则使用交叉熵损失CrossEntropyLoss。import torch.optim as optim from torch.utils.data import DataLoader from tqdm import tqdm # 用于显示进度条 def train_model(model, train_loader, val_loader, device, epochs50, lr1e-4): model.to(device) # 使用二元交叉熵损失模型输出不需要经过Sigmoid criterion nn.BCEWithLogitsLoss() optimizer optim.Adam(model.parameters(), lrlr) # 学习率调度器在验证损失平台期时降低学习率 scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, min, patience5, factor0.5) best_val_loss float(inf) train_losses, val_losses [], [] for epoch in range(epochs): model.train() running_loss 0.0 # 训练阶段 loop tqdm(train_loader, descfEpoch [{epoch1}/{epochs}]) for images, masks in loop: images, masks images.to(device), masks.to(device) # 前向传播 outputs model(images) loss criterion(outputs, masks) # 反向传播和优化 optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() * images.size(0) loop.set_postfix(lossloss.item()) epoch_train_loss running_loss / len(train_loader.dataset) train_losses.append(epoch_train_loss) # 验证阶段 model.eval() val_loss 0.0 with torch.no_grad(): for images, masks in val_loader: images, masks images.to(device), masks.to(device) outputs model(images) loss criterion(outputs, masks) val_loss loss.item() * images.size(0) epoch_val_loss val_loss / len(val_loader.dataset) val_losses.append(epoch_val_loss) scheduler.step(epoch_val_loss) print(fEpoch {epoch1}/{epochs} - Train Loss: {epoch_train_loss:.4f}, Val Loss: {epoch_val_loss:.4f}) # 保存最佳模型 if epoch_val_loss best_val_loss: best_val_loss epoch_val_loss torch.save(model.state_dict(), best_unet_model.pth) print(f - 保存最佳模型 (Val Loss: {best_val_loss:.4f})) print(训练完成) return train_losses, val_losses4.3 模型评估与预测训练完成后我们需要评估模型在测试集上的表现。常用的分割指标包括Dice系数和交并比IoU。此外可视化预测结果与真实掩码的对比也至关重要。def calculate_iou(pred_mask, true_mask, threshold0.5): 计算交并比 (Intersection over Union) pred_mask, true_mask: 模型输出的logits和真实掩码 # 将logits通过sigmoid转换为概率然后二值化 pred_mask (torch.sigmoid(pred_mask) threshold).float() true_mask (true_mask threshold).float() intersection (pred_mask * true_mask).sum() union pred_mask.sum() true_mask.sum() - intersection # 避免除零 iou (intersection 1e-6) / (union 1e-6) return iou.item() def visualize_prediction(model, dataloader, device, num_samples3): 可视化模型预测结果 model.eval() fig, axes plt.subplots(num_samples, 3, figsize(12, 4*num_samples)) with torch.no_grad(): for i, (images, masks) in enumerate(dataloader): if i num_samples: break images, masks images.to(device), masks.to(device) outputs model(images) pred_masks torch.sigmoid(outputs).cpu() # 显示原始图像 axes[i, 0].imshow(images[0].cpu().permute(1, 2, 0).numpy() * 0.5 0.5) # 反归一化 axes[i, 0].set_title(Input Image) axes[i, 0].axis(off) # 显示真实掩码 axes[i, 1].imshow(masks[0].cpu().squeeze(), cmapgray) axes[i, 1].set_title(Ground Truth) axes[i, 1].axis(off) # 显示预测掩码 axes[i, 2].imshow(pred_masks[0].squeeze(), cmapgray) axes[i, 2].set_title(Prediction) axes[i, 2].axis(off) plt.tight_layout() plt.show()5. 进阶技巧与实战调优搭建出基础模型并能运行只是第一步。要让UNet在实际任务中表现出色还需要一些技巧和调优策略。数据增强是提升模型泛化能力的利器。对于医学图像常用的增强方法包括几何变换随机水平/垂直翻转、随机旋转小角度、随机缩放、弹性形变。强度变换随机调整亮度、对比度、添加高斯噪声。高级增强使用albumentations库可以方便地实现更复杂的、针对医学图像的增强组合。损失函数的选择。除了标准的BCE Loss在医学图像分割中Dice Loss因其对类别不平衡问题不敏感而被广泛使用。通常可以将BCE Loss和Dice Loss结合使用class DiceBCELoss(nn.Module): def __init__(self, smooth1e-6): super(DiceBCELoss, self).__init__() self.smooth smooth self.bce nn.BCEWithLogitsLoss() def forward(self, logits, targets): # BCE部分 bce_loss self.bce(logits, targets) # Dice部分 probs torch.sigmoid(logits) intersection (probs * targets).sum(dim(2,3)) union probs.sum(dim(2,3)) targets.sum(dim(2,3)) dice_score (2. * intersection self.smooth) / (union self.smooth) dice_loss 1 - dice_score.mean() return bce_loss dice_loss模型初始化与优化器设置。使用He初始化或Xavier初始化可以帮助模型更快收敛。优化器方面Adam是默认的好选择但有时带有动量的SGD配合合适的学习率调度如Cosine Annealing能取得更好的最终效果。处理类别不平衡。医学图像中背景像素通常远多于前景病灶像素。除了使用Dice Loss还可以在BCE Loss中为前景类别设置更高的权重pos_weight参数。最后别忘了TensorBoard或WandB等可视化工具来监控训练过程跟踪损失曲线、学习率变化以及验证集上的指标如IoU这能帮你快速诊断模型是过拟合还是欠拟合从而调整超参数。从理解UNet的对称美和跳跃连接的巧妙到用PyTorch模块化地实现每一个组件再到处理数据、编写训练循环并引入进阶优化技巧这个过程本身就是一个完整的小型项目实践。我自己的经验是在第一个数据集上跑通整个流程后你会对深度学习 pipeline 有更立体的认识。接下来你可以尝试更换不同的数据集如ISIC皮肤病变分割、LUNA肺结节分割调整网络深度如使用更深的编码器ResNet作为骨干或者实验不同的上采样方式如最近邻插值卷积。UNet是一个强大的基础框架围绕它的各种变体如Attention UNet, UNet正在不断拓展其能力边界但万变不离其宗掌握了这个经典版本你就拥有了探索更广阔图像分割世界的坚实跳板。