简介:本资源是一份面向深度学习初学者与医学图像处理实践者的课程设计项目,聚焦U-Net及其改进模型在医学图像分割中的完整实现与对比分析。资源涵盖数据预处理、PyTorch框架下的U-Net、Attention U-Net等模型训练代码、多轮实验生成的权重文件(.pkl)、分割结果CSV统计及可视化PNG图像,辅以README说明文档与运行脚本,形成从理论到落地的闭环实践路径。压缩包共2000个文件,主体为4356张标注/预测PNG图像(含原始与掩模图)、8个核心Python训练与推理脚本、4个CSV评估结果文件、2个模型权重及1个Shell执行脚本,整体大小336.96MB,结构清晰,模块分离明确。已有5534人学习下载,读者可直接复现BRATS等主流数据集上的分割流程,获取带指标(IoU、Dice)验证的可运行方案、注意力机制集成技巧及多模型性能对比逻辑,显著降低医学图像分割入门门槛。
1. 为什么医学图像分割课程设计选U-Net,而不是直接套用ResNet或ViT?
在医学影像分析的课程设计中,学生常陷入一个典型误区:看到“图像分割”就本能调用ImageNet预训练的ResNet+FCN,或直接堆叠ViT做patch-wise分类。但真实临床场景里,CT/MRI切片中病灶区域往往只占0.5%~3%像素(如早期肺结节、微小脑转移瘤),且边界模糊、对比度低、存在大量伪影。U-Net系列算法不是“又一个CNN变体”,而是专为小样本、高精度、像素级定位设计的编码器-解码器对称结构——它通过跳跃连接(skip connection)把浅层位置信息与深层语义信息强制对齐,使模型能在仅20~50例标注数据下,稳定输出亚像素级分割掩膜。本课程设计不追求SOTA指标,而聚焦三个可验证目标:① 在本地GPU(如RTX 3060)上1小时内完成端到端训练;② 分割结果能被ITK-SNAP或3D Slicer直接加载为NRRD格式;③ 关键参数(如batch_size、learning_rate、loss权重)调整逻辑可追溯到具体医学影像特性。适合刚接触PyTorch、尚未系统学习医学影像处理流程的本科生。
2. U-Net基础结构解析与课程设计最小可行实现
2.1 为什么U-Net比FCN更适合医学图像?从卷积核尺寸说起
传统FCN采用固定3×3卷积核逐层下采样,导致小病灶在深层特征图中彻底丢失。U-Net在编码路径(Encoder)中每层使用双3×3卷积+ReLU+2×2最大池化,但关键在于解码路径(Decoder)的上采样方式:不是简单插值,而是先用2×2转置卷积(nn.ConvTranspose2d)将特征图放大2倍,再与对应编码层的4×4特征图拼接(concat)。这种设计让模型在恢复空间分辨率时,能同时利用深层语义(如“这是肝脏”)和浅层细节(如“肝边缘的毛刺状纹理”)。以肝脏CT分割为例,若编码器第3层输出32×32×256特征图(含器官级语义),解码器第3层输入即为64×64×(256+128)——其中128来自编码器第2层的64×64×128特征图,直接提供血管走向等定位线索。
提示:课程设计中必须禁用
nn.Upsample,因其仅插值不学习;必须用ConvTranspose2d并设置padding=0, stride=2,否则上采样后特征图尺寸错位。
2.2 PyTorch实现U-Net核心模块(含可运行代码)
以下代码实现U-Net最简版本(无Dropout、无BatchNorm),适配课程设计常见硬件限制:
import torch import torch.nn as nn class UNetBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 = nn.Conv2d(in_ch, out_ch, 3, padding=1) # 保持尺寸不变 self.conv2 = nn.Conv2d(out_ch, out_ch, 3, padding=1) self.relu = nn.ReLU(inplace=True) def forward(self, x): x = self.relu(self.conv1(x)) x = self.relu(self.conv2(x)) return x class UNet(nn.Module): def __init__(self, n_classes=1): # 二分类:前景/背景 super().__init__() # 编码器:4次下采样,通道数翻倍 self.enc1 = UNetBlock(1, 64) # 输入单通道灰度图(如CT) self.enc2 = UNetBlock(64, 128) self.enc3 = UNetBlock(128, 256) self.enc4 = UNetBlock(256, 512) self.pool = nn.MaxPool2d(2) # 中间层(bottleneck) self.bottleneck = UNetBlock(512, 1024) # 解码器:4次上采样,通道数减半 self.upconv4 = nn.ConvTranspose2d(1024, 512, 2, stride=2) self.dec4 = UNetBlock(1024, 512) # 512*2: 拼接bottleneck与enc4输出 self.upconv3 = nn.ConvTranspose2d(512, 256, 2, stride=2) self.dec3 = UNetBlock(512, 256) # enc3输出256,拼接后512 self.upconv2 = nn.ConvTranspose2d(256, 128, 2, stride=2) self.dec2 = UNetBlock(256, 128) self.upconv1 = nn.ConvTranspose2d(128, 64, 2, stride=2) self.dec1 = UNetBlock(128, 64) # 输出层 self.final_conv = nn.Conv2d(64, n_classes, 1) # 1×1卷积降维 def forward(self, x): # 编码路径 e1 = self.enc1(x) # [B, 64, H, W] e2 = self.enc2(self.pool(e1)) # [B, 128, H/2, W/2] e3 = self.enc3(self.pool(e2)) # [B, 256, H/4, W/4] e4 = self.enc4(self.pool(e3)) # [B, 512, H/8, W/8] # 瓶颈层 b = self.bottleneck(self.pool(e4)) # [B, 1024, H/16, W/16] # 解码路径(含跳跃连接) d4 = self.upconv4(b) # [B, 512, H/8, W/8] d4 = torch.cat([d4, e4], dim=1) # [B, 1024, H/8, W/8] d4 = self.dec4(d4) # [B, 512, H/8, W/8] d3 = self.upconv3(d4) # [B, 256, H/4, W/4] d3 = torch.cat([d3, e3], dim=1) # [B, 512, H/4, W/4] d3 = self.dec3(d3) d2 = self.upconv2(d3) # [B, 128, H/2, W/2] d2 = torch.cat([d2, e2], dim=1) # [B, 256, H/2, W/2] d2 = self.dec2(d2) d1 = self.upconv1(d2) # [B, 64, H, W] d1 = torch.cat([d1, e1], dim=1) # [B, 128, H, W] d1 = self.dec1(d1) return self.final_conv(d1) # [B, 1, H, W]参数说明与课程设计适配点:
n_classes=1对应二值分割(病灶/非病灶),避免多分类带来的标签不平衡问题;- 所有卷积层
padding=1确保特征图尺寸不因卷积缩小,简化尺寸计算; ConvTranspose2d的stride=2保证上采样后尺寸精确翻倍,避免后续拼接报错;- 跳跃连接使用
torch.cat而非+,因通道数不同(如512+512=1024),加法要求维度一致。
2.3 数据预处理:医学图像特有的归一化与增强策略
医学图像(如DICOM格式CT)像素值范围远超[0,255],例如CT的HU值区间为[-1024, 3071]。直接归一化到[0,1]会压缩病灶对比度。课程设计推荐分段截断归一化:
def preprocess_ct(image_array): # 截断至肺实质常用窗宽窗位:HU ∈ [-1000, 400] image_array = np.clip(image_array, -1000, 400) # 线性归一化到[0,1] image_array = (image_array + 1000) / 1400.0 return image_array.astype(np.float32) # 使用示例(配合PyTorch DataLoader) transform = transforms.Compose([ transforms.Lambda(lambda x: preprocess_ct(x)), # 自定义归一化 transforms.ToTensor(), # 转为C×H×W张量 transforms.RandomRotation(degrees=15), # 小角度旋转防过拟合 transforms.RandomHorizontalFlip(p=0.5), ])关键参数解释:
-1000是空气HU值,400覆盖软组织(如肝脏、肌肉),此范围保留肺结节与血管对比;1400.0是截断后极差(400 - (-1000)),确保归一化后数值稳定;RandomRotation角度限15°以内,因医学图像解剖结构具有方向特异性(如脊柱纵轴不可水平翻转)。
3. 课程设计全流程:从数据准备到模型评估的实操步骤
3.1 数据集构建:用公开数据集快速启动(无需自行标注)
课程设计严禁要求学生采集真实患者数据。推荐使用LiTS(Liver Tumor Segmentation Challenge)数据集,其包含131例腹部CT扫描及专家标注的肝脏与肿瘤掩膜,符合教学安全规范。下载后需执行以下标准化处理:
# 假设数据解压至 ./lits_data/ # 步骤1:提取单张切片(DICOM→PNG) dcm2png -i ./lits_data/train/volume-0.nii.gz -o ./lits_data/pngs/ --slice 50 # 步骤2:生成对应掩膜PNG(需安装nibabel) python -c " import nibabel as nib import numpy as np from PIL import Image mask = nib.load('./lits_data/train/segmentation-0.nii.gz').get_fdata() slice_mask = mask[:, :, 50].astype(np.uint8) * 255 # 转为0/255二值图 Image.fromarray(slice_mask).save('./lits_data/pngs/mask_0050.png') "目录结构要求(课程设计提交必备):
project_root/ ├── data/ │ ├── train_images/ # PNG格式,尺寸统一为512×512 │ ├── train_masks/ # PNG格式,纯黑/白,与images同名 │ └── val_images/ # 验证集,占比20% ├── models/ │ └── unet_basic.py # 上述U-Net实现 ├── train.py # 训练主脚本 └── eval.py # 评估脚本注意:所有PNG必须为8位灰度图(
mode='L'),避免RGB三通道引入冗余信息;掩膜文件像素值只能是0(背景)或255(前景),不可为128等中间值。
3.2 训练脚本核心参数配置表
| 参数 | 推荐值 | 课程设计选择理由 |
|---|---|---|
batch_size | 4 | RTX 3060显存限制(12GB),batch_size=4时显存占用约9.2GB,留出余量加载验证集 |
learning_rate | 1e-4 | U-Net对学习率敏感,过大导致loss震荡,过小收敛缓慢;1e-4在Adam优化器下最稳定 |
num_epochs | 50 | 医学图像小数据集易过拟合,50轮足够收敛,避免学生等待过久 |
loss_fn | nn.BCEWithLogitsLoss() | 直接作用于logits,避免sigmoid+CE的数值不稳定;自动处理单通道输出 |
optimizer | torch.optim.Adam(model.parameters(), lr=1e-4) | 比SGD收敛更快,适合课程设计有限时间 |
训练循环关键代码段(train.py):
model = UNet(n_classes=1).to(device) criterion = nn.BCEWithLogitsLoss() optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) for epoch in range(50): model.train() train_loss = 0.0 for images, masks in train_loader: # images: [B,1,H,W], masks: [B,1,H,W] images, masks = images.to(device), masks.to(device) optimizer.zero_grad() outputs = model(images) # outputs: [B,1,H,W] loss = criterion(outputs, masks) # 自动sigmoid+CE loss.backward() optimizer.step() train_loss += loss.item() # 验证阶段(每5轮) if epoch % 5 == 0: model.eval() with torch.no_grad(): val_dice = 0.0 for v_images, v_masks in val_loader: v_images, v_masks = v_images.to(device), v_masks.to(device) pred = torch.sigmoid(model(v_images)) > 0.5 # 二值化 # Dice系数计算(平滑避免除零) intersection = (pred & v_masks).float().sum((1,2,3)) union = pred.float().sum((1,2,3)) + v_masks.float().sum((1,2,3)) dice_batch = (2. * intersection + 1e-6) / (union + 1e-6) val_dice += dice_batch.mean().item() print(f"Epoch {epoch}, Train Loss: {train_loss/len(train_loader):.4f}, Val Dice: {val_dice/len(val_loader):.4f}")Dice系数说明:课程设计评估必须用Dice(F1-score变体),因其对小目标分割更敏感。公式为2*|A∩B|/(|A|+|B|),值域[0,1],>0.85视为合格。
3.3 模型保存与推理:生成可交付的分割结果
课程设计成果需包含可视化输出。以下代码将模型预测结果保存为PNG,并叠加原图显示:
def save_prediction(image_path, mask_path, model, device, output_dir): image = Image.open(image_path).convert('L') # 确保灰度 mask = Image.open(mask_path).convert('L') transform = transforms.Compose([ transforms.Resize((512, 512)), transforms.ToTensor(), transforms.Lambda(lambda x: (x - x.min()) / (x.max() - x.min() + 1e-6)) # 归一化 ]) img_tensor = transform(image).unsqueeze(0).to(device) # [1,1,512,512] model.eval() with torch.no_grad(): pred = torch.sigmoid(model(img_tensor)) > 0.5 # [1,1,512,512] # 叠加显示:原图(灰度)+ 预测掩膜(红色半透明) overlay = np.array(image) * 0.7 pred_np = pred[0,0].cpu().numpy().astype(np.uint8) * 255 overlay[pred_np == 255] = [255, 0, 0] # 红色标记病灶 result_img = Image.fromarray(overlay.astype(np.uint8)) result_img.save(f"{output_dir}/overlay_{os.path.basename(image_path)}") # 调用示例 save_prediction( image_path="./data/val_images/001.png", mask_path="./data/val_masks/001.png", model=model, device=device, output_dir="./results/" )交付物清单(课程设计评分依据):
models/unet_basic.py:完整可运行U-Net代码;results/overlay_*.png:至少5张原图+预测叠加图;logs/train_log.txt:包含每轮loss与Dice值的文本日志;report.pdf:说明数据来源、参数选择依据、Dice分数及失败案例分析(如某张图分割断裂的原因)。
4. U-Net进阶技巧:针对课程设计场景的3个必调参数与排错指南
4.1 学习率调度器:用ReduceLROnPlateau避免loss平台期
当训练loss连续5轮不再下降时,手动降低学习率是常见操作,但课程设计中易遗漏。torch.optim.lr_scheduler.ReduceLROnPlateau可自动触发:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='min', # 监控loss(越小越好) factor=0.5, # 学习率乘以0.5 patience=5, # 等待5轮无改善 verbose=True, # 打印学习率变化 min_lr=1e-6 # 下限,防止过小 ) # 在验证循环后添加 val_loss = ... # 计算验证集loss scheduler.step(val_loss) # 根据val_loss调整lr排错场景:若训练后期loss停滞在0.15左右,但Dice不升反降,大概率是学习率过高导致模型在局部最优震荡。启用此调度器后,通常在第35~40轮自动将lr从1e-4降至5e-5,Dice提升0.03~0.05。
4.2 损失函数加权:解决前景像素远少于背景的问题
医学图像中病灶像素占比常低于1%,导致模型倾向全预测背景。BCEWithLogitsLoss虽稳定,但需显式加权:
# 计算训练集前景像素占比(需提前统计) foreground_ratio = 0.012 # 示例值,实际需计算 weight = torch.tensor([1.0 / foreground_ratio]).to(device) # 背景权重1,前景权重≈83 criterion = nn.BCEWithLogitsLoss(pos_weight=weight)参数计算方法:遍历所有训练掩膜,统计像素值为255的总数,除以总像素数。例如100张512×512图像中,病灶像素共327680个,则foreground_ratio = 327680/(100*512*512) ≈ 0.0125,pos_weight = 1/0.0125 = 80。
4.3 验证Dice计算陷阱:为何你的Dice总是0.0?
常见错误是直接对sigmoid输出阈值化后计算,但未处理batch维度:
# ❌ 错误写法(忽略batch) pred = (torch.sigmoid(outputs) > 0.5).float() dice = 2 * (pred * masks).sum() / (pred.sum() + masks.sum()) # ✅ 正确写法(按batch计算后平均) pred = (torch.sigmoid(outputs) > 0.5).float() intersection = (pred * masks).sum(dim=(1,2,3)) # [B] union = pred.sum(dim=(1,2,3)) + masks.sum(dim=(1,2,3)) # [B] dice_batch = (2. * intersection + 1e-6) / (union + 1e-6) # [B] dice = dice_batch.mean().item() # 标量调试技巧:在验证循环中打印intersection和union的均值。若intersection恒为0,说明预测全为背景,需检查数据加载(掩膜是否全黑)、损失函数(是否用了BCELoss而非BCEWithLogitsLoss)或学习率(过大导致梯度爆炸)。
5. 3D U-Net扩展:当课程设计需要处理体数据时的最小改动方案
5.1 从2D到3D:仅修改4处代码即可支持CT体数据
课程设计若需处理三维CT(如.nii.gz格式),U-Net只需将2D卷积替换为3D卷积,其余结构不变:
| 模块 | 2D代码 | 3D代码 | 修改说明 |
|---|---|---|---|
| 卷积层 | nn.Conv2d(in_ch, out_ch, 3, padding=1) | nn.Conv3d(in_ch, out_ch, 3, padding=1) | 输入通道数不变,但kernel变为3×3×3 |
| 池化层 | nn.MaxPool2d(2) | nn.MaxPool3d(2) | 下采样在(H,W,D)三维度进行 |
| 转置卷积 | nn.ConvTranspose2d(...) | nn.ConvTranspose3d(...) | 上采样同样作用于三维度 |
| 输入尺寸 | [B,1,H,W] | [B,1,D,H,W] | D为切片数(如32),需保证D,H,W均为2的幂 |
数据加载关键修改(Dataset类):
class Liver3DDataset(Dataset): def __init__(self, image_dir, mask_dir, slice_range=32): self.image_dir = image_dir self.mask_dir = mask_dir self.slice_range = slice_range # 每次取32层连续切片 def __getitem__(self, idx): # 加载整个3D体积(假设已预处理为numpy数组) volume = np.load(f"{self.image_dir}/{idx:03d}_volume.npy") # shape: [D,H,W] mask = np.load(f"{self.mask_dir}/{idx:03d}_mask.npy") # shape: [D,H,W] # 随机截取slice_range层(如32层) start_z = random.randint(0, volume.shape[0] - self.slice_range) vol_slice = volume[start_z:start_z+self.slice_range] # [32,H,W] mask_slice = mask[start_z:start_z+self.slice_range] # 添加通道维度 → [1,32,H,W] vol_tensor = torch.from_numpy(vol_slice).unsqueeze(0).float() mask_tensor = torch.from_numpy(mask_slice).unsqueeze(0).float() return vol_tensor, mask_tensor显存控制技巧:3D U-Net显存消耗约为2D的D/2倍(D为切片数)。当slice_range=32时,batch_size需降至1~2,或使用梯度累积:
# 梯度累积模拟batch_size=4 accumulation_steps = 4 optimizer.zero_grad() for i, (images, masks) in enumerate(train_loader): outputs = model(images) loss = criterion(outputs, masks) loss = loss / accumulation_steps # 缩放loss loss.backward() if (i + 1) % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()5.2 3D分割结果可视化:用ITK-SNAP验证而非仅看切片
课程设计最终成果需被专业工具验证。将PyTorch输出保存为NRRD格式:
import itk def save_as_nrrd(prediction_tensor, output_path): # prediction_tensor: [1,D,H,W],值为0/1 pred_np = prediction_tensor[0].cpu().numpy().astype(np.uint8) image_itk = itk.GetImageFromArray(pred_np) itk.imwrite(image_itk, output_path) # 生成xxx.nrrd # 调用 save_as_nrrd(pred_3d, "./results/liver_pred.nrrd")验证流程:
- 下载ITK-SNAP(免费开源);
- 打开原始CT.nii.gz与生成的
liver_pred.nrrd; - 在Segmentation面板中勾选“Show segmentation”,观察3D渲染效果;
- 使用Measure工具计算分割体积(mL),与真实标注对比误差<15%即达标。
此步骤证明课程设计成果具备临床可解释性,而非仅停留在2D切片层面。
本文还有配套的精品资源,点击获取