☰
U-Net家族四模型对比:从Attention到R2AUet的医学图像分割实践
2026/10/20 17:55:04 网站建设 项目流程

简介:Unet、AttentionUnet、R2Unet、R2AUet四种图像分割模型的完整可运行实现,配套ISIC 2017皮肤病变数据集,面向计算机视觉与医学影像分析领域的学习者、研究者和算法工程师,适合从原理理解到实验复现的进阶练习。代码以Python文件为主,涵盖网络结构、训练脚本与配置;数据集由皮肤镜图像构成,jpg与png格式的图片占绝大多数,另有xml标注、md说明文档及sh脚本辅助使用,方便直接搭建训练环境。压缩包共211个文件,整体大小25.48MB,目录结构清晰,便于按模型或数据模块检索。目前已有2159人学习下载。通过对比残差块与注意力门控在Unet骨架中的融合方式,读者能够理清四种模型的演进脉络,掌握医学图像分割中的预处理、损失函数、优化器选择与超参数调优思路,并可在ISIC 2017数据上直接训练、验证与评估,为论文复现或实际项目落地提供可参考的代码基线。

1. 从四个网络一起调通说起:这个项目解决的是图像分割选型难题

拿到这份源码包的那一刻,我其实没抱太高期望,因为标题里的 Unet、AttentionUnet、R2Unet、R2AUet 四个模型看起来就像是同一套东西换了几层皮。可真在本地把训练跑起来后,发现它们的分割行为差异非常明显:普通 U-Net 边缘糊、Attention U-Net 能把病灶边界抠得更干净、R2U-Net 对小目标更敏感,而 R2AUet 在两者之间做了折中。这个项目把四种模型、训练/验证/预测脚本和一份可直接加载的数据集都打包在了一起,装好依赖后不用改代码就能跑出分割结果。对做医学图像分割、遥感地物识别或工业缺陷检测的开发者来说,它最大的价值是省掉了找数据、对齐接口、调试模型结构的大量重复劳动。

2. 工程结构拆解:模型、数据、训练三者如何分层

2.1 目录结构与职责划分

先看整体布局。这份资源的目录结构非常常见,几乎就是标准 PyTorch 工程的分层方式,方便直接迁移到别的项目里。我用 tree 命令扫了一遍,骨架长这样:

unet-family/ ├── models/ │ ├── unet.py │ ├── attention_unet.py │ ├── r2unet.py │ └── r2attention_unet.py ├── dataset/ │ ├── data_loader.py │ └── data/ ├── utils/ │ ├── losses.py │ ├── metrics.py │ ├── visualize.py │ └── logger.py ├── config.py ├── train.py ├── predict.py └── requirements.txt

models 目录下每个文件只放一个模型类,类的对外接口统一为__init__(in_channels, out_classes, base_channel),这样在 train.py 里可以用字典映射动态实例化,不需要在每个脚本里写死模型类名。dataset/data_loader.py 里实现的是继承torch.utils.data.Dataset的加载器,返回的是(image, mask)元组,mask 是单通道灰度图,值为 0 或 1,背景和前景没有混淆。utils 里把损失函数、Dice 系数计算和图像保存拆开,训练日志也只打印到控制台,想接 TensorBoard 可以再自己加。

需要留意的点是config.py,它是整个工程的全局配置中心。训练参数和路径都集中在这里,而不是散落在各脚本里。我一般拿到项目后先改这个文件而不是去翻训练脚本。

2.2 技术选型:为什么用 PyTorch

依赖文件 requirements.txt 里写的是torch >= 1.8、numpy、opencv-python、Pillow、tqdm,都是图像分割生态里最常用的库。用 PyTorch 的一个好处是动态图调试方便,特别是当你想改模型内部某个层的输出尺寸时,只要在 forward 里加一行打印就能立刻看到张量形状。另一好处是预训练权重容易加载,比如把 ResNet 当作 U-Net 的编码器主干时,直接用torchvision.models的权重就行,这个项目里没有这么做,但如果你二次开发想升级,路径是完全畅通的。数据集如果没有特殊标注,用 opencv 读取图像和掩膜就足够了,不需要引入 SimpleITK 这类重型医学库;如果换成医学影像原格式,比如.nii,那时再增加相应依赖。

2.3 数据加载与预处理管线

数据集部分不是玩具,它包含了已经划分好的训练集和测试集文件列表。data_loader.py 核心流程分为三步:先从config.py里读取图像文件夹路径;再对图像做三通道归一化,对 mask 做二值化;最后执行在线数据增强。增强只使用随机翻转、随机旋转和缩放,没有用复杂的弹性畸变,原因是为了保证 mask 和图像同步变化,减少标注错位风险。关键代码如下:

class SegDataset(Dataset): def __init__(self, img_list, mask_list, transform=None, image_size=(256, 256)): self.img_list = img_list self.mask_list = mask_list self.transform = transform self.image_size = image_size def __len__(self): return len(self.img_list) def __getitem__(self, idx): image = cv2.imread(self.img_list[idx]) image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB) image = cv2.resize(image, self.image_size) image = (image.astype(np.float32) / 255.0) # 归一化到 0~1 mask = cv2.imread(self.mask_list[idx], cv2.IMREAD_GRAYSCALE) mask = cv2.resize(mask, self.image_size, interpolation=cv2.INTER_NEAREST) mask = (mask > 127).astype(np.float32) # 二值化,保留前景 if self.transform is not None: image = self.transform(image) # CHW return torch.from_numpy(image.transpose(2, 0, 1)), torch.from_numpy(mask)

这里有两个很容易被忽略的参数:image_size必须和模型里下采样后的特征图尺寸匹配,否则后面的跳跃连接拼接会报错;而INTER_NEAREST在缩放 mask 时是必须的,如果用线性插值,mask 会出现中间灰度值,导致 loss 在取值 0.5 的地方震荡。很多新手在这两个细节上翻车,训练时报 shape 不匹配或者 Dice 指标奇怪地卡在 0.5。

3. 核心模型拆解:从 U-Net 到 R2AUet 的四级递进

3.1 U-Net 的基本骨架

U-Net 的代码是所有变体的地基,先把它看透,后面就好理解。这里的实现用了一个DoubleConv作为基本单元,每个单元包含两次卷积、BN 和 ReLU。编码器每下采样一次,通道数翻倍,特征图尺寸减半;解码器每次上采样后,把编码器对应层的特征图沿通道拼接起来,形成 U 形结构。核心 forward 如下:

def forward(self, x): features = [] # encoder for block, pool in zip(self.down_blocks, self.pools): x = block(x) features.append(x) x = pool(x) x = self.bottleneck(x) # decoder for up, block, skip in zip(self.up_blocks, self.down_blocks[::-1], features[::-1]): x = up(x) x = torch.cat([x, skip], dim=1) # 跳跃连接 x = block(x) return x

这里跳跃连接用的是通道拼接而不是加法,这是 U-Net 最核心的设计。拼接后的通道数是解码器上采样结果和编码器特征图通道数之和,所以DoubleConv的第一个卷积需要动态接收这个拼接后的通道数。在这个实现里,编码器各层输出通道分别为 64、128、256、512,上采样后分别是 64、128、256、512,拼接后对应 128、256、512、1024,这就是各解码块第一层卷积输入维度。如果数据集是 3 通道输入,第一层卷积输入就是 3;如果换成灰度单通道,只需要把in_channels改成 1。

3.2 Attention U-Net:门控信号自动聚焦

Attention U-Net 最大的变化是在跳跃连接前增加了一个注意力门(Attention Gate)。它不直接拼接编码器特征,而是先用解码器的上采样特征作为门控信号,计算一个 0~1 的注意力权重,然后乘到编码器特征上。这样做相当于告诉网络:跳跃连接里哪些区域是对当前目标有用的,哪些是背景噪声。代码实现通常是把编码器特征gating_signal和解码器特征skip分别做 1x1 卷积,相加后过 ReLU,再过 Sigmoid,最后与skip相乘。

class AttentionGate(nn.Module): def __init__(self, in_channels, gate_channels, out_channels): super().__init__() self.W_g = nn.Conv2d(gate_channels, out_channels, kernel_size=1) self.W_x = nn.Conv2d(in_channels, out_channels, kernel_size=1) self.psi = nn.Conv2d(out_channels, 1, kernel_size=1) def forward(self, x, g): g_resized = F.interpolate(g, size=x.shape[2:], mode='bilinear', align_corners=True) theta_x = self.W_x(x) theta_g = self.W_g(g_resized) f = F.relu(theta_x + theta_g) sig = torch.sigmoid(self.psi(f)) return x * sig

在这个资源里,注意力门的输入是解码器当前层上采样后的特征,输出是经过空间加权后的编码器特征。interpolate这一步是必须的,因为解码器特征尺寸和编码器特征尺寸往往差一倍,需要先把门控信号缩放到与跳跃连接相同的尺寸。这个模块引入后,参数量增加不大,但分割边界会明显更平滑,因为模型学会了忽略背景区域。

3.3 R2U-Net:循环残差卷积

R2U-Net 用循环卷积模块(Recurrent Convolutional Block)替换了普通 DoubleConv。它把同一个卷积层重复执行 T 次,每次的输入包含上一次的输出和原始输入,最后再加上一个残差连接。这样做的好处是在不增加太多参数的情况下加深了感受野,对小目标和细长结构更友好。核心替代块是R2Conv:

class R2Conv(nn.Module): def __init__(self, in_ch, out_ch, T=2): super().__init__() self.conv = nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1) self.bn = nn.BatchNorm2d(out_ch) self.relu = nn.ReLU(inplace=True) self.T = T self.residual = nn.Conv2d(in_ch, out_ch, kernel_size=1) if in_ch != out_ch else nn.Identity() def forward(self, x): identity = self.residual(x) out = self.relu(self.bn(self.conv(x))) for _ in range(self.T - 1): out = self.relu(self.bn(self.conv(out))) return out + identity

T就是循环次数,资源里默认设置成 2。很多人以为 T 越多越好,实际上T=4时参数量并不线性增长,但深度增加会导致梯度消散风险变大。如果你在自己数据集上训练发现训练曲线波动,可以先把 T 调回 2 试试,而不是改学习率。

3.4 R2AUet:组合变体

R2AUet 是资源里最完整的模型,将循环残差卷积和注意力门两种机制合在一起。编码器、瓶颈、解码器用的都是 R2Conv,跳跃连接处挂上 AttentionGate。这样做的效果是既聚合了多尺度上下文,又对无关区域做抑制。这个模型的参数量是四个里最大的,训练时显存占用也会增加,但效果通常最好。它在扩充分支上使用x * sig加权,这个sig值是学习出来的,不会出现硬阈值。因此你的数据如果前景和背景像素比例严重失衡,R2AUet 往往能给出更稳的结果,因为它能自动把背景区域权重压低。

4. 训练与评估:一条命令跑通到拿到分割掩膜

4.1 从配置文件到启动训练

修改好config.py里的数据路径,然后执行python train.py --model r2unet --epochs 60 --batch-size 4,训练就开始了。train.py 的开头部分会先打印模型名称、参数量、数据集样本数,这些信息对定位问题非常关键。模型的选择通过一个字典映射:

MODEL_MAP = { 'unet': UNet, 'attention_unet': AttentionUNet, 'r2unet': R2UNet, 'r2auet': R2AttentionUNet, } model = MODEL_MAP[args.model](in_channels=3, out_classes=1, base_channel=64)

base_channel=64决定了第一个卷积层的输出通道数,后面每层的通道数都是它的倍数。如果你的显卡显存不够,把base_channel降到 32,参数量和显存占用会迅速减半。训练时每过一个 epoch,程序会保存当前 epoch 的权重到checkpoints/目录,命名为best_dice.pth,保存标准是验证集 Dice 不再提升。

4.2 损失函数与评估指标的计算方式

损失函数用的是BCEWithLogitsLoss加 Dice Loss 的线性组合,权重各 0.5。只用 BCE 时,由于背景像素远多于前景,模型会倾向把全部预测为背景,Dice 指标显得低。加入 Dice Loss 后,模型被迫关注前景区域,具体实现如下:

class HybridLoss(nn.Module): def __init__(self): super().__init__() self.bce = nn.BCEWithLogitsLoss() def forward(self, logits, targets): bce = self.bce(logits, targets) probs = torch.sigmoid(logits) flat_probs = probs.view(probs.size(0), -1) flat_targs = targets.view(targets.size(0), -1) dice = 1 - (2 * (flat_probs * flat_targs).sum(1) + 1e-6) / \ (flat_probs.sum(1) + flat_targs.sum(1) + 1e-6) return bce + dice.mean()

这里1e-6是平滑项,用来防止分母为零。BCE 和 Dice 的尺度不同,通常 BCE 在 0.2~0.7,Dice 在 0~1,直接相加时 Dice 权重会偏大,所以实际使用也可以给 Dice 加一个系数 0.5。训练脚本里已经调试好了权重,你不一定需要改,只有当损失值出现异常波动时才回头调整。

评估指标除了 loss 外,脚本还计算了验证集的 Dice 和 IoU。IoU 直接从预测概率和真实 mask 计算,预测概率大于 0.5 视为前景,然后统计交集和并集。

4.3 推理与可视化

predict.py 可以把测试集图片批量处理,输出结果到output/。它会将原始图像、预测概率图、二值掩膜、原图叠加掩膜四张图拼成一张大图保存,方便人工检查。如果你想单独导出掩膜,修改save_mask_only = True即可。推理时注意要保证输入尺寸和训练时一致,否则测试结果会变形。如果你在 predict.py 里看到model.eval()配合torch.no_grad(),这是标准做法,可以避免 Dropout 和 BN 在推理时造成输出振荡。

5. 避坑与常见问题:把可运行项目改到自己环境中的实战排查

5.1 跳跃连接时 shape 不匹配

现象:运行训练脚本时出现RuntimeError: Sizes of tensors must match,指向torch.cat一行。

原因:输入图片尺寸不是 2 的整数次幂倍数。U-Net 编码器有 4 次下采样,每次下采样让宽高减半,如果图片尺寸是 257x257,最后一次下采样会出现尺寸除不尽,导致上下采样后的特征图宽高对不上。数据集目录里的原始图片有不少是任意尺寸,data_loader 里虽然做了 resize,但若标定的image_size和模型步长不匹配,就会触发这个问题。

解决:把config.py中的image_size设为 256 或 512 这样的 2 的幂次。不要使用padding='same'去规避,那会改变整个下采样步长,让后面的注意力门尺寸也乱掉。我习惯在模型 forward 里打印一下每层尺寸,快速定位是哪一层出的问题。

5.2 显存溢出,batch size 必须调小

现象:执行python train.py --model r2auet --batch-size 8后,几秒内报CUDA out of memory。

原因:R2AUet 的循环卷积会展开多个时间步,中间激活值比普通 U-Net 多许多,8 张 256x256 图同时过模型,显存占用大约 6GB,如果显卡是 4GB 版本必然溢出。

解决:先降 batch size 到 2,再降base_channel到 32。两步都做完,显存占用能压到 2GB 以内。如果还需要更大批尺寸,启用梯度累积,每 4 步更新一次参数,效果接近 batch size=8。注意torch.cuda.empty_cache()在每次验证后调用,否则显存碎片会累积。

5.3 数据集加载失败,图片路径里带有中文或相对路径问题

现象:FileNotFoundError: No such file or directory,并且文件名是中文。

原因:项目默认环境是 Linux 下的 UTF-8,但 Windows 下部分 OpenCV 版本读取中文路径会失败,这是 cv2.imread 的已知表现。

解决:把整个项目目录放到纯英文路径下,数据集内部文件夹也全部改成英文名。如果你不想动目录,可以在 data_loader 里用np.fromfile加cv2.imdecode替代cv2.imread,我一般会同时保留两种读取逻辑,这样别人拿到项目在不同系统上跑都不会报错。

5.4 训练不收敛,Dice 指标一直横盘

现象:loss 下降很快,头 5 个 epoch 就已很低,但验证集 Dice 一直停在 0.6 附近。

原因:大概率是 mask 的归一化出了问题。有的公开数据集 mask 里背景像素是 255,前景是 0,而这份资源里的样例数据希望背景为 0、前景为 1,如果直接读进去不取反,模型学到的前景其实是背景,Dice 却会暂时维持在一个不高不低的值。

解决:在__getitem__之后打印一行mask.unique(),看 mask 是否只有 0 和 1 两个数值。如果出现255,先做mask = (mask == 255)再转浮点。这种错误非常隐蔽,因为 loss 在降,但你实际要分割的东西完全没被学会。

5.5 加载权重时 strict 报错

现象:换显卡继续训练时,model.load_state_dict(torch.load('best_dice.pth'))报Missing key(s) in state_dict。

原因:上一次训练的base_channel或分类数out_classes和当前模型不一致。这是最常见的历史遗留问题,不是代码 bug。

解决:加载权重前打印state_dict里第一个卷积层的 shape,和当前模型对比。如果不想改全部结构,可以把load_state_dict(..., strict=False),但这会静默丢弃不匹配的层,可能导致指标神秘下降。正确做法是回到config.py,把base_channel、out_classes改成和之前相同的值,然后重新加载。

6. 二次开发:换数据集、加自定义模块与导出部署

6.1 把自己的数据集接进来

换数据集的成本不大,主要是把数据整理成训练脚本认识的格式。如果你有一堆原图和一个 CSV 文件标明每张图的 mask 路径,直接改写dataset/data_loader.py里的初始化函数即可。我习惯在__init__里加一个参数csv_path,然后读取成两个列表。注意 mask 不需要在 Dataset 里做 One-Hot,因为当前是二分类;如果你想做多分类,比如背景、肝脏、肿瘤三类,就把mask的像素值改成 0/1/2,并把out_classes设为 3,最后在HybridLoss里把 BCE 换成交叉熵,Dice 改成对每个类别分别计算再取平均。这个改动大约半天能完成。

# 读取自定义标注的示意 def _parse_csv(self, csv_path): df = pd.read_csv(csv_path) self.img_list = df['image'].tolist() self.mask_list = df['mask'].tolist()

接好之后,第一次跑通先用 20 张图像验证流程,不要直接全量训练。确认 mask 的像素值、图像通道顺序、resize 尺寸、预测输出的值域都符合预期,再放大训练规模。

6.2 在跳跃连接上加一个额外的深度监督

原模型的输出只有一个。你可以给解码器的每层都加一个 1x1 卷积输出预测图,然后和真实 mask 计算损失,再把多个损失相加。这种方式叫深度监督,特别适合改善深层网络的梯度流动。实现也不复杂:在 forward 中收集中间输出,predict 时只保留最后一层。

def forward(self, x): ... outputs = [] for up, block, skip in zip(...): x = up(x) x = block(torch.cat([x, skip], dim=1)) outputs.append(self.heads[len(outputs)](x)) # 每个解码层一个轻量head return outputs[-1], torch.stack(outputs) # 主输出 + 深度监督列表

参数上值得注意:辅助损失权重不要设太大,0.2 比较合适,太重会让浅层卷积过于专注边界细节而忽略语义。训练时你可以在HybridLoss里把辅助损失乘上 0.2,再和主损失相加。这个技巧在目标很小、背景很大的分割任务上非常有用,R2AUet 本身已经具备较强的梯度回传能力,但这个改动仍能继续提升 1~3 个点的 Dice。

6.3 导出 ONNX 并做半精度推理

模型调好之后,如果要做推理加速,我会先把 PyTorch 权重导出成 ONNX。这里有一个注意点:模型里的F.interpolate在导出时需要指定opset_version=11,否则某些算子可能不被支持。导出脚本的核心只有几行:

dummy_input = torch.randn(1, 3, 256, 256) model.cpu().eval() torch.onnx.export( model, dummy_input, "model.onnx", input_names=["image"], output_names=["mask"], dynamic_axes={"image": {0: "batch"}, "mask": {0: "batch"}}, opset_version=11 )

导出后可以用 ONNX Runtime 加载,推理速度通常比原生 PyTorch 快 30%~50%。如果想继续压榨性能,把输入转成半精度fp16,要注意 BN 层在半精度下容易出现数值不稳定,建议把 BN 的 eps 调大一点点,比如从1e-5改成1e-4。这样导出后精度基本不下降,显存占用还能再降一半。

实际拆这个项目时,我踩过的坑大多集中在数据读取和尺寸对齐上,模型本身的代码写得比较规范,没有太多要返工的地方。现在每接到一个新的分割任务,我都会强制先跑一遍predict.py的基线结果,再决定要不要上注意力门或者循环残差。调参再久,不如先把四种模型各自的底线能力摸清楚。这个项目把实验成本降到了最低,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询