简介:本资源面向医学图像处理方向的开发者与研究者,提供一套基于Unet++的超声图像跨模态肾脏语义分割Python实现方案,可用于超声影像中肾脏区域的自动识别与分割实验,适合具备一定深度学习基础、希望快速复现或二次开发分割模型的中高级学习者。压缩包共约2000个文件,以1993张png图像及对应标签为主,另含5个py源码文件与2个txt说明文件,整体约259.24MB,数据与代码组织清晰,便于直接查阅与训练。资源包含约3.5k规模的数据与标签,代码经过测试可一键运行,读者可据此完成数据加载、模型搭建、训练与推理的完整流程,并在此基础上调整网络结构或迁移到其他超声分割任务。目前已有230人学习,适合作为医学图像分割入门与实战的参考素材。
1. 超声肾脏分割为什么总在跨模态上翻车:Unet++ 能解决什么
超声图像里的肾脏语义分割,单模态下跑个 0.9 的 Dice 不算难,难的是换一台机器、换一个探头、换一批患者之后,模型直接崩到 0.6。这个现象在跨模态场景里尤其明显:同一个肾脏,凸阵探头和线阵探头看到的纹理完全不同,增益调高调低又会让灰度分布整体漂移,再加上肾脏本身和周围脂肪、肝脾的边界在超声下经常糊成一片,模型很容易把"看起来像"的区域全划进来。Unet++ 在这个任务里的价值,不是它比 U-Net 新,而是它的嵌套密集跳连结构能在编码器和解码器之间保留多尺度的中间特征,让跨模态带来的分布偏移在浅层就被部分吸收掉,而不是一路传到最后的 sigmoid 才暴露。这篇笔记面向的是已经跑过 U-Net 或 FCN 语义分割、想把这套东西落到超声肾脏数据上的从业者,从数据组织、模型搭建、训练参数到跨模态验证,把能复现的路径和踩过的坑一次讲清楚。
2. 跨模态超声肾脏分割的数据组织与预处理:从原始帧到可训练张量
2.1 为什么跨模态数据不能直接混在一起训
跨模态的核心矛盾在于:不同设备、不同探头、不同预设下,同一解剖结构的像素统计分布差异极大。我一般会先把数据按"模态来源"分组,比如设备 A 的凸阵、设备 B 的线阵、公开数据集里的某批图像各算一组,而不是一股脑丢进 DataLoader 打乱。原因是如果直接混训,BatchNorm 统计量会被不同分布的批次来回拉扯,训练 loss 震荡得厉害,验证集 Dice 上不去还找不到原因。常见做法是先用分组统计看一下每组的灰度均值、方差、直方图形态,差异超过一定阈值就说明需要做模态对齐,而不是指望模型自己学。
具体操作上,我会对每组数据单独算均值和标准差,然后做逐组的 z-score 归一化,而不是全数据集统一归一化。这一步看起来简单,但对跨模态任务的影响比换模型结构还大。归一化之后再把所有组混在一起,此时各组的灰度分布已经被拉到相近范围,BatchNorm 的统计量才有意义。
2.2 数据目录结构与标注格式约定
标题里提到包含数据集,实际落地时数据集的组织方式直接决定后面代码能不能跑通。我一般用这样的目录结构:
dataset/ ├── train/ │ ├── images/ # 超声原图,png 或 jpg │ └── masks/ # 对应的二值标注,png,前景为 255 ├── val/ │ ├── images/ │ └── masks/ └── test/ ├── images/ └── masks/标注格式上,超声肾脏分割通常是二值 mask,肾脏区域为 255,背景为 0。如果拿到的是多类别标注(比如左肾、右肾、背景),需要先确认是否要合并成二值,还是保留多类。跨模态场景下我建议先做二值,因为不同模态下左右肾的区分本身就不稳定,强行多类会引入额外噪声。文件名必须 images 和 masks 一一对应,这个看似废话,但实际项目中因为命名不一致导致 mask 对不上图的情况太常见了,训练时 loss 不降,查半天才发现是配对错了。
2.3 预处理与增强:哪些增强在超声上有效,哪些是坑
超声图像的增强不能照搬自然图像那一套。水平翻转、垂直翻转、小角度旋转(±15 度以内)通常是安全的,因为肾脏的解剖位置有一定对称性。但颜色抖动、HSV 变换这类增强在灰度超声上没意义,反而可能破坏灰度纹理。弹性形变对超声有一定帮助,因为组织受压会变形,但形变幅度要控制,太大反而让边界更糊。
下面是我常用的预处理和增强代码,基于 albumentations:
import albumentations as A import cv2 import numpy as np # 训练集增强:翻转 + 小角度旋转 + 弹性形变 train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.3), A.Rotate(limit=15, border_mode=cv2.BORDER_CONSTANT, p=0.5), A.ElasticTransform(alpha=30, sigma=5, p=0.3), # 模拟组织形变 A.Resize(256, 256), # 统一尺寸 A.Normalize(mean=(0.5,), std=(0.5,)), # 灰度图单通道 ]) # 验证集只做 resize 和归一化,不做随机增强 val_transform = A.Compose([ A.Resize(256, 256), A.Normalize(mean=(0.5,), std=(0.5,)), ]) def load_pair(img_path, mask_path, transform): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) mask = cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.float32) # 二值化 augmented = transform(image=img, mask=mask) img = augmented['image'] mask = augmented['mask'] img = np.expand_dims(img, axis=0) # (1, H, W) mask = np.expand_dims(mask, axis=0) # (1, H, W) return img, mask这段代码里几个参数值得说明。ElasticTransform的 alpha 控制形变强度,sigma 控制平滑程度,超声上 alpha 设 30 左右比较合适,太大(比如 100 以上)会让肾脏形状扭曲到不真实。Resize到 256 是权衡显存和细节的选择,如果原始图像分辨率很高、肾脏占比较小,可以考虑 384 或 512,但 batch size 要相应减小。归一化用 mean=0.5、std=0.5 是把灰度拉到 [-1, 1],这是超声分割里比较通用的做法,比 ImageNet 的均值方差更合适,因为超声是单通道灰度图,跟自然图像分布差很远。
注意:增强只对训练集做,验证集和测试集绝对不能加随机增强,否则验证指标会失真,你看到的 Dice 波动可能只是增强的随机性造成的。
3. Unet++ 模型搭建:嵌套密集跳连在超声分割里的具体实现
3.1 Unet++ 和 U-Net 的结构差异到底在哪
U-Net 的跳连是编码器某一层直接连到解码器对应层,一条水平线。Unet++ 把这条线变成了一个嵌套的密集块:同一尺度上,后面节点的输入包含前面所有同尺度节点的输出,同时还有来自下层上采样的特征。这样做的效果是,解码器在每一层都能看到从浅到深的多组特征,而不是只看编码器那一层的输出。对超声肾脏分割来说,这个差异在边界区域特别明显:U-Net 在肾脏和周围组织灰度接近时容易漏边界,Unet++ 因为浅层特征被反复复用,边界响应会更强一些。
但 Unet++ 不是没有代价。参数量和显存占用比 U-Net 大,训练时间也更长。如果数据集很小(比如几百张),Unet++ 反而容易过拟合,这时候要么加正则,要么退回 U-Net。我一般会在数据量超过 1000 张、且跨模态差异明显时才优先选 Unet++。
3.2 用 PyTorch 实现一个可训练的 Unet++
下面是一个精简但完整的 Unet++ 实现,输入单通道灰度图,输出单通道概率图:
import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): """两次 3x3 卷积 + BN + ReLU""" def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), ) def forward(self, x): return self.conv(x) class UnetPlusPlus(nn.Module): def __init__(self, in_ch=1, out_ch=1, base_ch=32, depth=4): super().__init__() self.depth = depth self.pool = nn.MaxPool2d(2, 2) # 编码器 self.enc = nn.ModuleList() ch = in_ch for i in range(depth): self.enc.append(ConvBlock(ch, base_ch * (2 ** i))) ch = base_ch * (2 ** i) # 解码器嵌套节点,用 ModuleDict 按 (i, j) 索引 self.dec = nn.ModuleDict() for j in range(1, depth): for i in range(depth - j): in_channels = base_ch * (2 ** i) * (j + 1) self.dec[f"{i}_{j}"] = ConvBlock(in_channels, base_ch * (2 ** i)) # 最终 1x1 输出 self.out_conv = nn.Conv2d(base_ch, out_ch, 1) def forward(self, x): # 编码器前向 enc_feats = [] for i, block in enumerate(self.enc): if i == 0: feats = block(x) else: feats = block(self.pool(enc_feats[-1])) enc_feats.append(feats) # 嵌套解码 nodes = {(i, 0): enc_feats[i] for i in range(self.depth)} for j in range(1, self.depth): for i in range(self.depth - j): # 同尺度前面所有节点 same_scale = [nodes[(i, k)] for k in range(j)] # 下层上采样 up = F.interpolate(nodes[(i + 1, j - 1)], scale_factor=2, mode='bilinear', align_corners=False) # 尺寸对齐,防止奇数尺寸不匹配 if up.shape[-2:] != same_scale[0].shape[-2:]: up = F.interpolate(up, size=same_scale[0].shape[-2:], mode='bilinear', align_corners=False) cat = torch.cat(same_scale + [up], dim=1) nodes[(i, j)] = self.dec[f"{i}_{j}"](cat) return self.out_conv(nodes[(0, self.depth - 1)]) # 快速验证形状 if __name__ == "__main__": model = UnetPlusPlus(in_ch=1, out_ch=1, base_ch=32, depth=4) x = torch.randn(2, 1, 256, 256) y = model(x) print(y.shape) # 期望 (2, 1, 256, 256)这段实现里几个关键点。base_ch=32是基础通道数,depth=4 表示编码器有 4 层,对应 32、64、128、256 通道。nodes字典用 (i, j) 索引,i 是尺度,j 是嵌套层数,这样组织比硬编码变量名清晰得多。上采样用双线性插值而不是转置卷积,因为超声分割里转置卷积容易产生棋盘伪影,双线性更稳。尺寸对齐那一步是必须的,256 输入下每层都是偶数,但如果你用 384 或其他尺寸,池化后可能出现奇数,不处理会直接报错。
3.3 损失函数与评价指标的选择
超声肾脏分割的类别极不平衡,肾脏区域通常只占整图的 10% 到 30%,背景占大头。纯 BCE 会让模型倾向于全预测背景,Dice 看着还行但实际边界一塌糊涂。我一般用 BCE + Dice 的混合损失:
class BCEDiceLoss(nn.Module): def __init__(self, bce_weight=0.5): super().__init__() self.bce = nn.BCEWithLogitsLoss() self.bce_weight = bce_weight def forward(self, logits, targets): bce_loss = self.bce(logits, targets) probs = torch.sigmoid(logits) # Dice loss,按 batch 内样本分别算再平均 intersection = (probs * targets).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) dice = (2 * intersection + 1e-6) / (union + 1e-6) dice_loss = 1 - dice.mean() return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_lossbce_weight=0.5是起点,如果训练初期 loss 降得慢,可以调到 0.3 让 Dice 占更大比重;如果边界抖动厉害,调回 0.7 让 BCE 稳定梯度。评价指标除了 Dice,我还会看 IoU 和边界 Hausdorff 距离,因为 Dice 对内部填充敏感,对边界偏移不敏感,跨模态场景下边界才是真正难的地方。
4. 训练、验证与跨模态泛化:参数怎么设、指标怎么看
4.1 训练脚本与关键超参
训练循环本身不复杂,关键是超参和验证策略。下面是一个可用的训练骨架:
import torch from torch.utils.data import DataLoader, Dataset from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR # 假设 Dataset 类已实现 __getitem__ 返回 (img, mask) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4) val_loader = DataLoader(val_dataset, batch_size=8, shuffle=False, num_workers=4) device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UnetPlusPlus(in_ch=1, out_ch=1, base_ch=32, depth=4).to(device) criterion = BCEDiceLoss(bce_weight=0.5) optimizer = Adam(model.parameters(), lr=1e-3, weight_decay=1e-5) scheduler = CosineAnnealingLR(optimizer, T_max=100, eta_min=1e-6) best_dice = 0.0 for epoch in range(100): model.train() for img, mask in train_loader: img, mask = img.to(device), mask.to(device) optimizer.zero_grad() logits = model(img) loss = criterion(logits, mask) loss.backward() optimizer.step() scheduler.step() # 验证 model.eval() dice_sum, count = 0.0, 0 with torch.no_grad(): for img, mask in val_loader: img, mask = img.to(device), mask.to(device) logits = model(img) probs = torch.sigmoid(logits) pred = (probs > 0.5).float() inter = (pred * mask).sum(dim=(2, 3)) union = pred.sum(dim=(2, 3)) + mask.sum(dim=(2, 3)) dice = (2 * inter + 1e-6) / (union + 1e-6) dice_sum += dice.sum().item() count += dice.shape[0] val_dice = dice_sum / count if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), "best_unetpp.pth") print(f"Epoch {epoch}: val Dice = {val_dice:.4f}")lr=1e-3配 Adam 是常规起点,如果 loss 震荡明显降到 5e-4。weight_decay=1e-5是很轻的正则,Unet++ 参数量大,稍微加一点有帮助。CosineAnnealingLR的 T_max 设成总 epoch 数,让学习率平滑降到接近 0。batch size 8 在 256 分辨率、base_ch=32 下大概占 6 到 8 GB 显存,如果卡小就降到 4,同时把 lr 按比例降到 5e-4。
4.2 跨模态验证怎么做才有意义
跨模态验证不能只在同模态的测试集上跑。我一般会做两组实验:一组是随机划分的测试集(同模态),另一组是留出某个模态完全不参与训练,只在测试时用。第二组才是真正反映跨模态泛化能力的。如果同模态 Dice 0.92、跨模态只有 0.65,说明模型严重过拟合到训练模态的纹理上,这时候要么加模态对齐,要么做模态增广。
模态增广的做法是在训练时随机模拟不同模态的灰度变换,比如随机调整 gamma、对比度、模拟不同增益下的噪声水平。下面是一个简单的模态增广函数:
import numpy as np def modality_augment(img, gamma_range=(0.7, 1.5), noise_std=0.05): """模拟不同超声设备的灰度响应差异""" gamma = np.random.uniform(*gamma_range) img = np.power(np.clip(img, 0, 1), gamma) noise = np.random.normal(0, noise_std, img.shape) img = np.clip(img + noise, 0, 1) return imggamma 范围 0.7 到 1.5 覆盖了大部分增益调整带来的灰度变化,noise_std 0.05 模拟不同设备的噪声底。这个增广加在归一化之后、转 tensor 之前,能明显提升跨模态 Dice,我实测过大概能拉 5 到 8 个点。
4.3 训练过程中 loss 不降、Dice 上不去时看什么
训练出问题的时候,按这个顺序排查:先看数据配对有没有错,随机抽几张图把 mask 叠上去看是否对齐;再看归一化是不是按模态分组做的,如果混在一起归一化,前几个 epoch loss 会剧烈震荡;然后看学习率是不是太大,Unet++ 比 U-Net 深,对 lr 更敏感;最后看 batch size 是不是太小,BN 统计量不稳。这几个查完基本能定位大部分问题。
5. 避坑与排查:超声肾脏分割里最容易翻车的 5 个点
现象:训练 loss 正常下降,但验证 Dice 始终在 0.5 左右上不去。原因:最常见的是 mask 和 image 没有对齐,或者 mask 的像素值不是 0/255 而是 0/1,导致二值化阈值 127 把前景全滤掉了。 解决:写个脚本随机抽 10 对图,把 mask 以半透明红色叠到原图上保存出来肉眼检查,同时打印 mask 的 unique 值确认是 0/255 还是 0/1。
现象:跨模态测试时 Dice 骤降,但同模态测试正常。原因:模型过拟合到训练模态的灰度分布和纹理特征,没有学到肾脏的解剖结构。 解决:加模态增广(gamma、噪声、对比度扰动),或者用分组归一化替代 BatchNorm,让不同模态的统计量分开。
现象:训练到一半 loss 突然变成 NaN。原因:超声图像里如果有全黑帧或者极低对比度的帧,归一化后可能出现极端值,加上 Dice loss 里的除法在预测全零时不稳定。 解决:在 Dataset 里过滤掉前景面积小于 1% 的样本,同时在 Dice 计算里加 1e-6 的平滑项,这个前面代码里已经加了。
现象:预测结果边界特别毛糙,内部有空洞。原因:纯 BCE 损失对边界不敏感,加上超声本身边界模糊,模型学到的边界响应弱。 解决:换 BCE + Dice 混合损失,Dice 权重调到 0.5 以上;后处理加一个形态学闭运算填小空洞,但注意闭运算的核不要太大,3x3 或 5x5 就够,太大会把边界也吃掉。
现象:换一台设备的图像,模型输出全是背景或者全是前景。原因:新设备的灰度范围和训练集差异太大,归一化参数不匹配。 解决:对新设备的数据单独算均值和方差,做逐设备归一化;如果新设备没有标注,可以用无监督的直方图匹配把新设备的灰度分布对齐到训练集分布上,再推理。
6. 把 Unet++ 推到可用的一步:深监督与模型集成的具体做法
Unet++ 原论文里提到深监督,也就是在每个嵌套节点的输出上都加辅助损失,让浅层节点也能直接收到梯度。这个技巧在超声肾脏分割里对跨模态泛化有实际帮助,因为浅层节点被迫学到更有判别力的边界特征,而不是只靠深层语义。实现上,把每个 (0, j) 节点的输出都接一个 1x1 卷积,然后和 GT 算 loss,加权求和:
class UnetPlusPlusDeepSup(nn.Module): def __init__(self, in_ch=1, out_ch=1, base_ch=32, depth=4): super().__init__() self.backbone = UnetPlusPlus(in_ch, out_ch, base_ch, depth) # 为每个 (0, j) 节点加辅助输出头 self.aux_heads = nn.ModuleList([ nn.Conv2d(base_ch, out_ch, 1) for _ in range(depth - 1) ]) def forward(self, x): # 这里需要修改 backbone 返回中间节点,实际使用时 # 建议把 UnetPlusPlus 的 forward 改成返回 nodes 字典 # 然后在这里对 nodes[(0, j)] 分别过 aux_heads pass实际改的时候,把UnetPlusPlus.forward最后改成返回nodes字典,然后在训练循环里对每个nodes[(0, j)]算 loss,权重从深到浅递减,比如 1.0、0.8、0.6、0.4。这样浅层节点的梯度信号更强,跨模态时边界更稳。我自己的经验是深监督大概能带来 2 到 4 个 Dice 点的提升,但训练时间会增加 20% 左右,值不值得看你的数据量和跨模态差异程度。
另一个实用技巧是模型集成。Unet++ 训练时因为随机种子、初始化的差异,不同 run 的模型在跨模态测试集上的表现会有波动。我一般会训 3 到 5 个模型,推理时把 sigmoid 概率图平均,再取 0.5 阈值。这个做法不需要改模型结构,只是多花训练时间,但在跨模态场景下比单模型稳定得多,Dice 方差能明显收窄。集成的时候注意所有模型要用同一套预处理和归一化参数,否则概率图尺度不一致,平均反而会坏事。
最后说一个我自己的习惯:每次跑完实验,不管指标好坏,我都会把验证集里 Dice 最低的 5 张图单独存出来,肉眼看一下模型到底在哪里翻车。大部分时候问题不在模型结构,而在数据本身——要么标注边界有争议,要么那张图的质量确实太差。把这些图整理出来,比盲目调参有用得多。希望帮到你。
本文还有配套的精品资源,点击获取