拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

腹部13类器官语义分割数据集:从U-Net训练到避坑实战指南

腹部13类器官语义分割数据集:从U-Net训练到避坑实战指南

简介:这是面向医学图像分割研究的多器官语义分割数据集,包含腹部13个器官(脾脏、左右肾脏、胆囊、食管、肝脏、胃、主动脉、下腔静脉、胰腺等)加上背景共14个类别,适合医学影像分析、深度学习算法研发人员直接使用。数据集已完成训练集与验证集划分,训练集约900张原图及对应掩膜,验证集约200张,共约1100张已处理数据,免去自行标注和清洗的繁琐流程。整个压缩包共2000个文件,约72.8MB,其中857张JPG原图、1141张PNG掩膜图,另附1个Python可视化脚本和1个JSON类别配置文档;脚本可随机抽取图像,将原图、真实标注及其叠加结果输出保存,便于直观评估数据质量。资源目前已有69人学习下载,可用于UNet、SwinUNet、TransUNet等分割模型的训练、对比与改进实验,是开展腹部器官分割任务的实用数据集。

1. 腹部13类别器官语义分割数据集:它到底解决什么问题?

做医学图像分割的人,十有八九都经历过这种尴尬:论文里说模型在CT上能把肝脏、肾脏、胰腺分得清清楚楚,可自己一到手就发现,公开数据集的标签五花八门,有的只有单器官,有的原始文件连方向都没统一。这款腹部13类别器官语义分割数据集,约1100张已处理的图像和标签,就是为了把“多类别、多器官、可直接训练”这三件事一次补齐。它面向的不是看热闹的读者,而是要做医学图像语义分割实验、写论文、练模型、跑基线的那批人——你不需要再花两周去整理Raw数据,下载后就能直接进入模型训练环节。这也正是它和那些原始CT序列数据集最大的区别:它已经把“脏活”干完了。

2. 数据集的真实构成:文件组织、标签映射与可视化

2.1 从文件命名到目录结构:拿到手先看什么

这类已处理的医学图像数据集,最常见的组织形式是原始图像和标签分属两个目录,文件名一一对应。你看到的目录结构一般是这样:

dataset/ ├── images/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... ├── masks/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... └── labels.txt

文件名通常是case_编号,编号不一定连续,但图像和标签必须同名,否则加载时就对不上。labels.txt存放13个类别的名称和对应的像素值。拿到手第一件事,不是训练,而是先检查labels.txt。真实数据集里,类别值往往不是你想象的0-12连续整数,而是类似1,2,4,8,16这样的稀疏值,甚至是255作为前景、0作为背景。这直接影响后续的损失函数和评估代码,后面避坑章会专门说。

2.2 标签值与颜色映射的关系:把13类画出来

图像分割任务里,标签图有两种存储方式:一种是每个像素存类别索引,另一种是存RGB颜色。医学图像数据集通常用索引图,但有些来源为了可视化方便,会用(0,0,0)表示背景,(255,255,255)表示某个器官,这种图不能直接输入模型,必须转换成索引图。一个通用的颜色映射表长这样:

# labels.txt 示例解析:Tab分隔,第一列是索引,第二列是器官名,第三列是R G B import numpy as np def parse_label_file(path): class_names = [] class_values = [] colors = [] with open(path, 'r') as f: for line in f: parts = line.strip().split('\t') if len(parts) < 3: continue class_values.append(int(parts[0])) class_names.append(parts[1]) colors.append(tuple(map(int, parts[2].split(',')))) return class_names, class_values, colors

这个函数做的事很简单:把文本配置变成了Python里的列表。这样做的理由很实际——后续不管是在可视化时上色,还是在计算分类别指标时跳过背景类,都要依赖这份映射。不要写死类别数量或颜色,因为不同版本的数据集可能重新排过顺序。

2.3 用Python快速检查数据质量的三个脚本

在你开始训练前,先花10分钟做三个检查,能省下后面调试错误的一整天。

第一个检查是确认图像和标签的分辨率、数量完全一致:

import os from PIL import Image img_dir = 'images' mask_dir = 'masks' imgs = sorted(os.listdir(img_dir)) masks = sorted(os.listdir(mask_dir)) assert len(imgs) == len(masks), "图像与标签数量不一致" for img_name, mask_name in zip(imgs, masks): img = Image.open(os.path.join(img_dir, img_name)) mask = Image.open(os.path.join(mask_dir, mask_name)) assert img.size == mask.size, f"{img_name} 尺寸不匹配" print(f"全部 {len(imgs)} 对图像分辨率一致")

第二个检查是确认标签图里实际出现的类别值,是不是都在labels.txt里:

import numpy as np mask = np.array(Image.open('masks/case_0001.png')) unique = np.unique(mask) print("Mask中出现的像素值:", unique)

如果出现了labels.txt里没有的值,就是脏数据。常见原因是某些像素未标注,值写成了255,但labels.txt里没定义。

第三个检查是算出每个类别的像素占比,这一步直接关系到后续要不要做类别加权:

mask = np.array(Image.open('masks/case_0001.png')) total_pixels = mask.size for cls in class_values: fraction = np.sum(mask == cls) / total_pixels if fraction > 0.01: # 占比超过1%的类别 print(f"类别 {cls} 占比 {fraction:.4f}")

这段代码只需要跑几张图,就能看出肝脏、胃这类大器官体积占比高,胰腺、胆囊可能只占几个百分点。看到这种结果,你就知道为什么医学图像分割的损失函数不能无脑选交叉熵。

3. 用U-Net在这个数据集上跑通最小训练流程

3.1 数据划分:训练集/验证集/测试集的常见比例与随机种子

约1100张图的规模,在医学图像分类里属于中小型,在语义分割里已经不算少,但也没到浪费得起的地步。常见做法是训练集70%、验证集15%、测试集15%。关键是随机种子固定,否则每次跑结果都不同,论文里没法复现。划分时不能只随机打乱文件列表,还要考虑同一个患者的多张连续切片可能出现在两张不同集合里——这一点在腹部CT数据集里尤其常见,容易造成数据泄露。如果文件名里有患者ID,按患者划分更合理。

import os import random imgs = sorted(os.listdir('images')) random.seed(42) random.shuffle(imgs) n_train = int(len(imgs) * 0.7) n_val = int(len(imgs) * 0.15) train_files = imgs[:n_train] val_files = imgs[n_train:n_train + n_val] test_files = imgs[n_train + n_val:]

这里用了random.seed(42),42只是约定俗成的起始值,你完全可以用别的数,但固定下来后,别人复现你的实验时也能得到相同的划分结果。不要每次运行都重新打乱,否则验证集一直在变,模型好坏就说不清。

3.2 数据加载器的写法:图像与标签同步变形

语义分割的数据加载器和分类任务最大的不同是:你必须保证模型输入图像和标签做完全相同的几何变换。旋转30度,图像旋转了,标签也必须旋转同样的角度,否则标签就错位了。PyTorch的torchvision.transforms里没有内置这种“同步变换”,常见做法是自定义一个函数,把随机种子固定后再分别调用:

import torch import numpy as np from PIL import Image from torch.utils.data import Dataset class AbdomenDataset(Dataset): def __init__(self, file_list, transform=None): self.file_list = file_list self.transform = transform def __len__(self): return len(self.file_list) def __getitem__(self, idx): img_path = f'images/{self.file_list[idx]}' mask_path = f'masks/{self.file_list[idx]}' img = Image.open(img_path).convert('RGB') mask = Image.open(mask_path) if self.transform is not None: # 同步随机旋转 seed = torch.randint(0, 1000000, (1,)).item() torch.manual_seed(seed) img = self.transform(img) torch.manual_seed(seed) mask = self.transform(mask) img = np.array(img).astype(np.float32) / 255.0 img = torch.from_numpy(img).permute(2, 0, 1) mask = np.array(mask).astype(np.int64) mask = torch.from_numpy(mask) return img, mask

这个数据加载器的关键在torch.manual_seed(seed)那两行——先记录一个随机种子,图片和标签依次用同一个种子做变换,就能保证旋转角度、缩放比例、平移距离完全一致。否则你大概率会像很多人一样,训练到一半发现Loss降不下去,最后画出预测图一看,器官都叠在背景上了。

3.3 损失函数与评估指标的选择:交叉熵还是Dice损失

腹部13类器官,最大的问题不是分类错误率,而是小器官几乎学不到。肝脏能占一张图的30%以上,胰腺可能只有2%,这种情况下标准交叉熵会偏向大器官,胰腺、胆囊这类小结构直接被当成背景忽略。常见做法是使用Dice损失,或者交叉熵和Dice损失的加权组合。

import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1.0): super().__init__() self.smooth = smooth def forward(self, logits, targets): num_classes = logits.shape[1] probs = F.softmax(logits, dim=1) one_hot = F.one_hot(targets, num_classes).permute(0, 3, 1, 2).float() dims = (0, 2, 3) intersection = torch.sum(probs * one_hot, dims) union = torch.sum(probs, dims) + torch.sum(one_hot, dims) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean()

这里的smooth是用来防止分母为0的平滑项,常用1.0。实际训练时,很多人会把DiceLoss和CrossEntropyLoss按1:1相加,因为DiceLoss对类别不平衡有效,但梯度不稳定,交叉熵梯度稳定但偏向大类别,两者互补。你也可以把类别的频率作为权重传给交叉熵,但腹部多器官场景下,单纯加权重不如混合损失有效。

4. 评估与验证:不只是看Loss,还要看每类器官的Dice

4.1 计算Dice、IoU与体积误差:代码与公式

训练集上的Loss小,不等于模型在测试集上表现好。语义分割的标准评估指标是Dice系数和IoU,尤其是医学图像领域,Dice是论文里最常见的数字。计算方式是对测试集每个样本逐类计算,然后取全类平均。

def compute_dice(pred, mask, num_classes=13): dice_scores = [] for cls in range(1, num_classes): # 跳过背景 pred_inds = (pred == cls) mask_inds = (mask == cls) intersection = np.logical_and(pred_inds, mask_inds).sum() if mask_inds.sum() == 0 and pred_inds.sum() == 0: dice = 1.0 # 两者都没预测到,算作正确 elif mask_inds.sum() == 0: dice = 0.0 # 金标准里没有这个器官,模型却画了,得0分 else: dice = (2 * intersection) / (pred_inds.sum() + mask_inds.sum()) dice_scores.append(dice) return dice_scores

这段代码有一个容易踩坑的边界条件:当某个类在测试集的金标准里完全不存在时,处理方法需要统一。有的论文把它跳过不计入平均,有的记0分,有的记1分。不写明这个规则,你的结果和别人没有可比性。我一般选择跳过——这样对训练充分的大器官公平,也不会让模型因为某一类罕见而得分剧烈波动。

4.2 把预测结果叠加到原图上,做定性检查

指标数字好不代表模型真的学会了解剖结构。医学图像分割经常出现一种奇怪现象:Dice分数很高,但模型把胃和十二指肠糊在一起,或者把胰腺尾部多画了一块。所以必须把预测结果直接画在原图上,和标签并排对比。

import matplotlib.pyplot as plt import numpy as np def visualize_overlay(img, mask, pred, save_path=None): # 把图像归一化到0-1并转成255 img = (img - img.min()) / (img.max() - img.min()) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img, cmap='gray') axes[0].set_title('Original') axes[1].imshow(mask, cmap='tab20', alpha=0.7) axes[1].imshow(img, cmap='gray', alpha=0.3) axes[1].set_title('Ground Truth') axes[2].imshow(pred, cmap='tab20', alpha=0.7) axes[2].imshow(img, cmap='gray', alpha=0.3) axes[2].set_title('Prediction') if save_path: plt.savefig(save_path, dpi=150, bbox_inches='tight') plt.close()

这里使用了cmap='tab20',13个类别刚好在tab20的色域内有足够区分度,肉眼能分清相邻器官。不建议用灰度值显示标签,因为相邻类别灰度很接近,尤其是胰腺和胃这两类,在灰度图上几乎是同一个色。

4.3 多类别混淆矩阵:哪两个器官最容易分不清

腹部器官分割里,最常见的错误不是把器官分割背景,而是把两个相邻且在CT灰度上相近的器官互相混淆。判断这件事的最直接方式是构建像素级混淆矩阵。

def compute_confusion(pred, mask, num_classes=13): matrix = np.zeros((num_classes, num_classes), dtype=np.int64) pred_flat = pred.flatten() mask_flat = mask.flatten() for p, m in zip(pred_flat, mask_flat): matrix[m, p] += 1 return matrix

对于1100张测试图,一张张逐像素循环会很慢,但一次性把全部测试集加载进内存不现实。常见做法是每张图用sklearn.metrics.confusion_matrix累计,或者用np.bincount加速。拿到混淆矩阵后,重点看非对角线的大值——比如胃和十二指肠、左肾和脾脏、肝和胆囊。如果你发现两类之间互相混淆严重,可以考虑在损失函数里增加边界约束,或者在后处理中根据解剖先验把不合理的小连通域滤掉。

5. 避坑指南:用这类腹部数据集训练时最容易翻车的5个点

5.1 标签不连续:类别值不是0-12怎么办

现象:训练时损失函数直接报错,或者类别数传错导致维度对不上。

原因:这是已处理医学数据集最常见的隐患。虽然标题说“已处理”,但“已处理”指的可能只是完成了标注和格式转换,并不保证类别值是从0开始连续编号。有的数据集把背景设为1,器官从10开始编号;有的是把某一类器官的值定成了255。如果代码里写死num_classes=13,而实际标签最大值是255,计算损失时torch.one_hot就会因为张量超出类别数而报错。

解决:在训练前,单独跑一段脚本,把标签值重新映射为连续索引。常见做法是:

def remap_labels(mask, class_values): remapped = np.zeros_like(mask, dtype=np.int64) for new_id, old_value in enumerate(class_values): remapped[mask == old_value] = new_id return remapped

这个操作只做一次,把结果存成npy或新的png,后续训练全程用连续标签,避免每次加载都重复计算。

5.2 类别极度不平衡:肝脏占面积大,胰腺难学

现象:训练曲线看起来在收敛,但打印每个类别的Dice分数时,肝脏、肾脏能到0.9,胰腺、胆囊只有0.2甚至0。

原因:腹部CT图像里,器官体积差异天然悬殊。肝脏可能占整张图面积的20%-30%,胰腺只有1%-2%。交叉熵损失对大类别更敏感,模型学会把一切不确定的像素都预测成肝脏或背景,反而比把胰腺挑出来更容易降低Loss。

解决:除了换用DiceLoss,建议在训练时对每个类别的预测概率做类别权重。权重可以取1 - 类别频率,频率越高权重越低。在DiceLoss里,可以为每个类别单独计算Dice,再按权重加权平均。如果你的显存允许,还可以采用更直接的方法——对图像做随机裁剪,强制采样包含小器官的区域,这相当于离线数据增强,比在损失函数里做文章更直观。

5.3 数据增强导致标签错位:同步随机变换的坑

现象:验证集上Dice正常,但测试集上预测的器官边缘明显偏移,或者出现“旋转后的器官形状”这种现象。

原因:这是自己在数据增强时写错了——图片做了水平翻转,但标签没翻转;或者图片用了随机旋转和数据标准化,但标签依然保持原始朝向。很多初学者会用torchvision.transforms里的RandomHorizontalFlip分别应用于图片和标签,看起来是“分别调用”,但两次调用各自消耗一次随机状态,导致翻转概率不同步,图片翻转了,标签没翻。

解决:用我们在3.2节里写的那种方式——在变换前生成一个固定的随机种子,图片和标签都使用同一个种子的变换。也可以用kornia等专门支持同步变换的库。这里再补充一点:强度变换(如颜色抖动、高斯噪声)只需要作用于输入图像,不能作用于标签;几何变换(旋转、翻转、缩放、弹性变形)必须同步作用于两者。

5.4 归一化范围不一致:CT值还是RGB

现象:模型在训练时正常,换了一台设备采集的验证图像后,测试集Dice直接崩掉。

原因:腹部CT原始数据是HU值(亨氏单位),范围通常在-1000到1000左右,但如果是直接从PACS导出的截图或已转换的PNG图像,像素值被压缩到0-255,并且在保存时可能做了窗口化处理(比如只保留-100到200的软组织窗口)。不同数据来源的窗口设置不同,导致相同器官在数值上差别很大。模型学习的是像素强度分布,一旦分布偏移,分割就失效。

解决:如果文件本身是PNG或JPG,基本可以判断是已经被窗口化过的图像,这种情况下不要再用什么CT标准化公式,直接采用x / 255归一化到0-1即可。测试集如果来源不同,必须先手动检查几个样本的像素分布,确认是否与训练集一致。如果是直接提供的原始CT值,则要用均值和标准差做标准归一化。不要盲目套用ImageNet的mean=[0.485,0.456,0.406],那是给自然图像用的。

5.5 防止过拟合:1100张图的增广量与早停

现象:训练集的Dice持续上升,验证集在某个epoch后不升反降,典型的过拟合。1100张图像不算多,而且医学图像本身模式简单,模型很容易把训练集里某些特有的纹理、探测器伪影当作特征。

原因:网络参数量远远大于训练样本的信息量,尤其是U-Net这类编码器-解码器结构,如果不加正则化和早停,模型在训练后期会开始记忆训练样本。

解决:三个措施组合使用。一是数据增强:在线做随机旋转(±15度)、水平翻转、随机缩放(0.8-1.2)、高斯噪声和弹性变形,增强要适度,过度弹性变形会破坏解剖结构,反而降低验证集精度。二是早停:监控验证集Dice,连续15个epoch不上升就停止训练,并保存Dice最高的权重。三是轻量化模型:如果没有外部预训练需求,可以把U-Net的通道数减半,在1100张图规模下,模型容量过大带来的收益不如正则化带来的收益。

6. 进阶:用预训练模型和伪标签把现有结果再往上推

6.1 加载ImageNet预训练Encoder做迁移学习

当你有1100张训练图,从头训练一个深度模型可能不是最优选择。常见做法是使用在ImageNet上预训练的ResNet或EfficientNet作为编码器,后端接上解码器完成分割。虽然ImageNet是自然图像,但它能提供通用的边缘、纹理、形状特征,这些对于CT器官分割也有帮助。

import torchvision.models as models encoder = models.resnet34(pretrained=True) # 去掉最后的全连接层和平均池化,保留到第4个stage的特征 encoder = torch.nn.Sequential(*list(encoder.children())[:-2])

这个做法的关键点在于:预训练权重针对的是RGB三通道输入,而CT图像如果是单通道,需要把第一个卷积层改掉,常见做法是取预训练权重的三个通道的平均值作为单通道初始化。但如果你的数据是RGB假彩色图像,直接沿用原结构即可。迁移学习阶段,初始学习率要比从头训练低一个数量级,常见设置为1e-4,并且先冻结编码器训练解码器几个epoch,再解冻整体微调。这样做的原因很实际:解码器是随机初始化的,一上来就给大学习率会把预训练编码器破坏。

6.2 使用推理时增强(TTA)提升分割稳定性

推理时增强(Test Time Augmentation)在医学图像分割里是一个几乎零成本的涨分技巧。做法很简单:预测阶段,把输入图像做水平翻转,得到预测概率图,再把翻转后的概率图翻转回来,与原预测概率图取平均,最后用argmax得到最终分割结果。

def predict_with_tta(model, img_tensor, flip=True): model.eval() with torch.no_grad(): pred = torch.softmax(model(img_tensor.unsqueeze(0)), dim=1) if flip: img_flip = torch.flip(img_tensor, dims=[2]) # 水平翻转 pred_flip = torch.flip(torch.softmax(model(img_flip.unsqueeze(0)), dim=1), dims=[2]) pred = (pred + pred_flip) / 2 return pred.squeeze(0)

TTA之所以有效,是因为医学图像分割模型对轻微几何变换不够鲁棒,尤其是在器官边缘。翻转前后两次预测的误差分布不同,平均之后能抵消一部分随机错误。这个技巧能让Dice稳定提高0.5-1个百分点,且不改变模型权重、不需要额外训练,只是测试时间变长。对于腹部13类这种边缘精细的任务,值得在最终测试时启用。

6.3 用模型预测生成伪标签,扩充训练集

训练到最后阶段,当你有一个Dice达到0.9左右的模型时,可以做一个半监督操作:把测试集中模型预测得分高的样本(比如平均概率超过0.95)挑选出来,把预测结果当作标签,加入训练集重新训练。这个做法叫伪标签,在医学图像分割里效果不错,因为同一器官的解剖结构相对稳定。

但这里有一个经验之谈:不要轻易把所有测试样本都加进去。先把测试集按图像切片的层面位置排序,优先选择那些模型确定度高的横断位中部切片,避开容易出错的胰腺尾部、肝脏边缘等区域。伪标签训练的学习率要更小,一般降到原来的0.1,并且训练周期缩短一半。实践中有时候加了伪标签反而让模型覆盖真实分布过深,导致泛化变差。所以我现在的习惯是:先只挑确定度最高的10%样本试水,如果验证集Dice不降再逐渐扩大比例。这比一次性把未知区域全喂给模型要稳得多。希望这个流程对你有参考价值,也祝你在这个数据集上跑出理想的结果。

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

返回列表