简介:这份资源面向医学影像分割方向的深度学习学习者与研究者,提供MRI背景下的腹部多器官分割数据集,可用于细粒度分割任务的训练与验证。数据图像分辨率为512×512,jpg格式原图搭配png格式mask标签,覆盖主动脉、胆囊、脾、左肾、右肾、肝、胰腺、胃等8类器官,具体类别可在classes文本中核对。压缩包共约2000个文件,以974个jpg图像、1024个png掩膜为主,另含1个txt标签说明与1个py可视化脚本,整体约37.54MB,运行show脚本即可查看gt在images上的掩膜叠加效果,图像经过对比度变换后更清晰。数据集划分为约1024张训练集与256张验证集,目录结构清晰,便于直接接入分割网络训练流程。目前已有183人学习下载,适合希望快速开展腹部多脏器分割实验、验证模型效果或进行算法对比的读者参考使用。
1. 从一张 512×512 的 MRI 切片说起:这套腹部多脏器分割数据集到底能干什么
如果你正在找一份能直接跑通、不用自己从 DICOM 开始清洗的腹部器官分割数据,这套 MRI 数据集值得先看一眼。它把 1024 张左右的训练图像和 256 张左右的验证图像整理成了 jpg + png 的配对形式,图像统一 512×512,mask 是单通道 png,配套一个 classes 文本说明 8 类标签。场景很明确:做腹部多脏器语义分割,尤其是肝、脾、肾、胰腺、胆囊、主动脉、胃这些结构。适合两类人——一类是刚接触医学图像分割、想找一个规模适中、格式友好的数据集练手;另一类是已经在做 MRI 器官分割、需要一个能快速验证网络结构或损失函数的基准集。它不解决数据标注质量问题,也不承诺跨中心泛化,但作为入门到中阶的实战素材,省掉了最耗时的格式转换和掩膜对齐环节。
2. 数据组织与标签体系:先搞清楚 8 类标签和文件配对逻辑
2.1 目录结构与文件命名规律
拿到压缩包后,第一件事不是急着写 DataLoader,而是把目录结构看清楚。这套数据典型组织方式是训练集和验证集分开,图像和 mask 各自成目录,文件名一一对应。从项目正文给出的样例文件名能看出命名规律:case0033_slice068.jpg这种形式,case编号代表不同病例,slice编号代表该病例下的不同切片。mask 文件同名但扩展名为 png,放在对应 mask 目录下。
常见做法是保持这种扁平结构,不要按病例再分子目录,否则写 Dataset 时路径拼接会多一层。我一般会先跑一段脚本统计文件数量和配对情况,确认没有孤儿文件。
import os from pathlib import Path img_dir = Path("data/train/images") mask_dir = Path("data/train/masks") imgs = sorted([f.stem for f in img_dir.glob("*.jpg")]) masks = sorted([f.stem for f in mask_dir.glob("*.png")]) print(f"图像数量: {len(imgs)}") print(f"掩膜数量: {len(masks)}") # 检查配对 img_set, mask_set = set(imgs), set(masks) only_img = img_set - mask_set only_mask = mask_set - img_set print(f"只有图像没有掩膜: {len(only_img)}") print(f"只有掩膜没有图像: {len(only_mask)}") # 抽查前5个文件名 for name in imgs[:5]: print(name, "->", (mask_dir / f"{name}.png").exists())这段脚本做三件事:统计图像和掩膜总数、找出不配对的文件、抽查前几个文件确认命名一致。参数上注意glob的扩展名要和实际一致,有些数据集图像是 jpg 但 mask 是 png,这里已经按实际情况区分。如果输出里only_img或only_mask不为零,说明数据整理有问题,需要先解决再往下走。
2.2 classes 文件与 8 类标签的对应关系
项目摘要里列了 8 类:主动脉、胆囊、脾、左肾、右肾、肝、胰腺、胃。注意摘要里“脾”出现了两次,这大概率是笔误,实际以 classes 文本为准。常见做法是打开 classes.txt 逐行确认,行号从 0 或 1 开始决定了你在 mask 里读到的像素值对应哪个器官。
这里有个血泪经验:很多医学分割数据集的 mask 像素值不是连续的 0-7,而是跳着的,比如 0 是背景,1 是肝,2 是脾,5 是肾。如果你直接按mask == class_id去取,很容易漏掉或错位。我一般会先统计 mask 里出现的唯一像素值。
import numpy as np from PIL import Image from pathlib import Path mask_dir = Path("data/train/masks") sample_masks = list(mask_dir.glob("*.png"))[:20] unique_vals = set() for mp in sample_masks: arr = np.array(Image.open(mp)) unique_vals.update(np.unique(arr).tolist()) print("mask 中出现的像素值:", sorted(unique_vals))如果输出是[0, 1, 2, 3, 4, 5, 6, 7, 8]这种,说明有 8 个前景类加背景;如果缺了某个值,就要去 classes 文件里核对是不是该类在采样中没出现,还是标签本身就不连续。参数上sample_masks取前 20 张足够看出分布,不用全量跑。这一步做完,你才能确定后续损失函数里num_classes到底设 9 还是别的数。
2.3 训练集与验证集的划分逻辑
摘要写的是训练集 1024 张左右、验证集 256 张左右,比例大致 4:1。这个划分如果是按切片随机分的,要注意同一个病例的不同切片可能同时出现在训练和验证里,导致验证指标虚高。常见做法是尽量按病例划分,但这份数据已经分好了目录,你只能接受现有划分。如果要做更严格的评估,可以自己按 case 编号重新分。
我一般会先统计训练集和验证集里各有多少个不同的 case,判断是否存在病例重叠。
import re from pathlib import Path from collections import Counter def count_cases(img_dir): cases = [] for f in Path(img_dir).glob("*.jpg"): m = re.match(r"(case\d+)", f.stem) if m: cases.append(m.group(1)) return Counter(cases) train_cases = count_cases("data/train/images") val_cases = count_cases("data/val/images") print("训练集病例数:", len(train_cases)) print("验证集病例数:", len(val_cases)) print("训练集切片数 top5:", train_cases.most_common(5)) print("验证集切片数 top5:", val_cases.most_common(5)) overlap = set(train_cases) & set(val_cases) print("训练验证重叠病例:", overlap)这段代码用正则从文件名提取 case 编号,统计每个病例的切片数,并检查训练验证是否有重叠。如果overlap非空,说明验证集里有些病例的切片在训练集里也出现过,评估时心里要有数。参数上正则(case\d+)假设文件名以 case 开头,如果实际命名不同需要调整。
3. 从 jpg/png 到训练张量:Dataset 与增强的落地写法
3.1 自定义 Dataset 的完整实现
PyTorch 里写一个适配这套数据的 Dataset 并不复杂,关键是把图像和 mask 的路径配对、同步做增强、把 mask 的像素值映射到 0 到 num_classes-1。下面是一个可以直接抄的版本。
import torch from torch.utils.data import Dataset import numpy as np from PIL import Image from pathlib import Path class AbdominalMRIDataset(Dataset): def __init__(self, img_dir, mask_dir, transform=None, num_classes=9): self.img_dir = Path(img_dir) self.mask_dir = Path(mask_dir) self.transform = transform self.num_classes = num_classes self.names = sorted([f.stem for f in self.img_dir.glob("*.jpg")]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img_path = self.img_dir / f"{name}.jpg" mask_path = self.mask_dir / f"{name}.png" image = np.array(Image.open(img_path).convert("RGB")) mask = np.array(Image.open(mask_path)) # 如果 mask 像素值不连续,这里需要做映射 # 假设 classes 文件里定义的 id 就是像素值,且连续 mask = mask.astype(np.int64) if self.transform: augmented = self.transform(image=image, mask=mask) image = augmented["image"] mask = augmented["mask"] # 图像归一化到 [0,1] 并转 CHW if isinstance(image, np.ndarray): image = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0 if isinstance(mask, np.ndarray): mask = torch.from_numpy(mask).long() return image, mask逻辑说明:__init__里用sorted保证图像和 mask 顺序一致,num_classes默认 9 是背景加 8 类。__getitem__里先读图像和 mask,如果用了 albumentations 做增强,注意图像和 mask 要同步变换。最后图像转成 float 并归一化,mask 保持 long 类型供交叉熵使用。参数上num_classes要根据 classes 文件实际类别数调整,如果 mask 像素值不连续,需要在mask = mask.astype(np.int64)之前加映射表。
3.2 增强策略:对比度变换与几何变换的取舍
摘要提到“数据经过对比度变换,图像更清晰”,说明原始数据已经做过一定预处理。你在训练时再加增强,要注意别把对比度拉过头,否则 MRI 的灰度层次会丢失。常见做法是几何变换为主,颜色变换为辅。
import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform = A.Compose([ A.Resize(512, 512), A.HorizontalFlip(p=0.5), A.RandomRotate90(p=0.5), A.ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), A.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)), ToTensorV2(), ]) val_transform = A.Compose([ A.Resize(512, 512), A.Normalize(mean=(0.5, 0.5, 0.5), std=(0.5, 0.5, 0.5)), ToTensorV2(), ])参数说明:HorizontalFlip对腹部器官分割通常安全,因为左右翻转后器官位置虽然变了但解剖结构合理;RandomRotate90要谨慎,MRI 切片方向固定,旋转 90 度可能产生不合理的解剖方向,我一般只在小角度旋转里用ShiftScaleRotate。RandomBrightnessContrast的幅度控制在 0.1 以内,避免破坏已经做过的对比度变换。Normalize的均值和标准差用 0.5 是常见做法,如果数据分布偏暗可以改成实际统计值。
3.3 损失函数与类别不平衡处理
腹部器官分割里,肝、脾、肾体积大,胆囊、胰腺、主动脉体积小,类别不平衡很明显。常见做法是交叉熵加 Dice 损失组合,或者用带权重的交叉熵。
import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth=1e-6): super().__init__() self.smooth = smooth def forward(self, logits, targets): probs = F.softmax(logits, dim=1) targets_onehot = F.one_hot(targets, num_classes=logits.shape[1]) targets_onehot = targets_onehot.permute(0, 3, 1, 2).float() intersection = (probs * targets_onehot).sum(dim=(0, 2, 3)) union = probs.sum(dim=(0, 2, 3)) + targets_onehot.sum(dim=(0, 2, 3)) dice = (2 * intersection + self.smooth) / (union + self.smooth) return 1 - dice.mean() class CombinedLoss(nn.Module): def __init__(self, weight_ce=0.5, weight_dice=0.5): super().__init__() self.ce = nn.CrossEntropyLoss() self.dice = DiceLoss() self.weight_ce = weight_ce self.weight_dice = weight_dice def forward(self, logits, targets): return self.weight_ce * self.ce(logits, targets) + self.weight_dice * self.dice(logits, targets)逻辑说明:DiceLoss 对每个类别单独算 Dice 系数再平均,能缓解小器官被大器官淹没的问题。CombinedLoss 把交叉熵和 Dice 按权重相加,weight_ce和weight_dice默认各 0.5,如果小器官分割效果差可以把 Dice 权重提到 0.6 到 0.7。参数上smooth防止除零,num_classes要和模型输出通道一致。
4. 训练与验证:从 show 脚本到指标监控的完整链路
4.1 用 show 脚本先做可视化验证
摘要提到“运行 show 脚本即可查看 gt 在 images 上的掩膜结果”,这一步千万别跳过。在写训练代码之前,先用 show 脚本把图像和 mask 叠在一起看几例,确认标签对齐、类别颜色正确、没有整体偏移。常见做法是随机抽 5 到 10 张,把 mask 以半透明红色叠在灰度图上。
import numpy as np import matplotlib.pyplot as plt from PIL import Image from pathlib import Path def show_overlay(img_path, mask_path, alpha=0.4): img = np.array(Image.open(img_path).convert("RGB")) mask = np.array(Image.open(mask_path)) # 把 mask 中非零区域涂红 overlay = img.copy() overlay[mask > 0] = [255, 0, 0] blended = (img * (1 - alpha) + overlay * alpha).astype(np.uint8) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img) axes[0].set_title("原图") axes[1].imshow(mask, cmap="jet") axes[1].set_title("mask") axes[2].imshow(blended) axes[2].set_title("叠加") for ax in axes: ax.axis("off") plt.show() img_dir = Path("data/train/images") mask_dir = Path("data/train/masks") for name in sorted([f.stem for f in img_dir.glob("*.jpg")])[:3]: show_overlay(img_dir / f"{name}.jpg", mask_dir / f"{name}.png")这段代码把原图、mask 和叠加结果并排显示。如果叠加后发现 mask 整体偏移或翻转,说明图像和 mask 在预处理时没有同步,需要检查 show 脚本或自己的读取逻辑。参数上alpha控制叠加透明度,0.4 左右比较清楚。
4.2 训练循环与验证指标
训练循环本身是标准写法,但验证指标要选对。腹部器官分割常用 Dice 系数和 IoU,按类别算再平均。下面是一个简化的训练加验证框架。
import torch from torch.utils.data import DataLoader from tqdm import tqdm def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss = 0 for images, masks in tqdm(loader, desc="train"): images, masks = images.to(device), masks.to(device) optimizer.zero_grad() logits = model(images) loss = criterion(logits, masks) loss.backward() optimizer.step() total_loss += loss.item() return total_loss / len(loader) @torch.no_grad() def validate(model, loader, criterion, device, num_classes=9): model.eval() total_loss = 0 dice_sum = torch.zeros(num_classes).to(device) count = torch.zeros(num_classes).to(device) for images, masks in tqdm(loader, desc="val"): images, masks = images.to(device), masks.to(device) logits = model(images) loss = criterion(logits, masks) total_loss += loss.item() preds = logits.argmax(dim=1) for c in range(num_classes): pred_c = (preds == c) mask_c = (masks == c) inter = (pred_c & mask_c).sum().float() union = pred_c.sum().float() + mask_c.sum().float() if union > 0: dice_sum[c] += 2 * inter / (union + 1e-6) count[c] += 1 dice_per_class = dice_sum / count.clamp(min=1) return total_loss / len(loader), dice_per_class逻辑说明:训练循环里标准的前向、反向、优化三步。验证时按类别算 Dice,count记录每个类别在验证集中出现的批次数,避免某些类别没出现时除零。参数上num_classes要和模型输出一致,device根据是否有 GPU 设置。如果某些类别 Dice 一直很低,回去检查该类的 mask 像素值是否被正确映射。
4.3 学习率与优化器的选择
医学图像分割里 Adam 和 SGD 都有人用,Adam 收敛快但最终精度可能略低,SGD 加动量调好了泛化更好。我一般先用 Adam,学习率 1e-4,配合余弦退火。
import torch.optim as optim from torch.optim.lr_scheduler import CosineAnnealingLR model = model.to(device) optimizer = optim.Adam(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6) criterion = CombinedLoss(weight_ce=0.5, weight_dice=0.5).to(device) for epoch in range(50): train_loss = train_one_epoch(model, train_loader, optimizer, criterion, device) val_loss, dice_per_class = validate(model, val_loader, criterion, device) scheduler.step() print(f"epoch {epoch}, train_loss {train_loss:.4f}, val_loss {val_loss:.4f}") print("dice per class:", dice_per_class.cpu().numpy().round(3))参数说明:lr=1e-4是 Adam 的常用起点,如果 loss 震荡可以降到 5e-5;weight_decay=1e-5轻微正则化;T_max=50和总 epoch 数一致,eta_min=1e-6是学习率下限。每轮打印各类 Dice,观察小器官是否在提升。
5. 避坑与排查:这套数据最容易翻车的五个地方
5.1 现象:mask 像素值不连续,类别对不上
原因:classes 文件里定义的类别 id 和 mask 实际像素值不一致,或者 mask 经过了调色板压缩。解决:先跑 2.2 节的唯一值统计脚本,把实际像素值列出来,再和 classes 文件逐行对照。如果发现 mask 是调色板模式,用Image.open(mask_path).convert("L")转成灰度再读。
5.2 现象:训练 loss 正常但验证 Dice 极低
原因:训练集和验证集的图像分布差异大,或者验证集 mask 读取时用了错误的插值方式。解决:检查验证集 transform 里有没有误加随机增强,mask 的 resize 必须用最近邻插值。albumentations 里A.Resize对 mask 默认就是最近邻,但如果自己写 PIL resize,要指定Image.NEAREST。
5.3 现象:某些器官 Dice 始终为 0
原因:该器官在验证集中出现次数极少,或者 mask 中该类的像素值被映射到了背景。解决:统计验证集里每个类别的像素占比,如果某类占比低于 0.1%,考虑在损失里给它更高权重,或者接受该指标波动大。同时确认 mask 映射表没有把该类归到 0。
5.4 现象:显存溢出,batch size 上不去
原因:512×512 的图像加 9 类输出,如果模型解码器通道数大,显存占用高。解决:先把 batch size 降到 2 或 4,用梯度累积模拟大 batch;或者把图像随机裁剪到 384×384 训练,验证时再恢复 512。混合精度训练也能省不少显存。
5.5 现象:show 脚本显示的 mask 和训练时读到的 mask 不一致
原因:show 脚本可能用了convert("P")或直接读调色板,而训练时用了convert("L"),两者像素值不同。解决:统一读取方式,训练和可视化都用同一种模式。我一般统一用np.array(Image.open(path))不加 convert,先看原始像素值再决定。
6. 进阶技巧:用病例分组交叉验证压榨这份数据的泛化信息
这份数据已经分好了训练和验证,但如果你想更严谨地评估模型,可以自己按 case 做分组交叉验证。具体做法是把所有 case 编号收集起来,随机分成 5 折,每折里用 4 折的 case 做训练、1 折做验证。这样能避免同一病例的切片同时出现在训练和验证里,指标更接近真实泛化能力。
import re import numpy as np from pathlib import Path from sklearn.model_selection import GroupKFold all_imgs = sorted(Path("data/train/images").glob("*.jpg")) + \ sorted(Path("data/val/images").glob("*.jpg")) names = [f.stem for f in all_imgs] groups = [re.match(r"(case\d+)", n).group(1) for n in names] groups = np.array(groups) gkf = GroupKFold(n_splits=5) for fold, (train_idx, val_idx) in enumerate(gkf.split(names, groups=groups)): train_names = [names[i] for i in train_idx] val_names = [names[i] for i in val_idx] print(f"fold {fold}: train {len(train_names)}, val {len(val_names)}, " f"train cases {len(set(groups[train_idx]))}, val cases {len(set(groups[val_idx]))}")这段代码用GroupKFold按 case 分组,保证同一病例的切片不会跨折。参数上n_splits=5可以改成 3 或 10,取决于数据量和计算资源。跑完五折后把每折的 Dice 平均,得到的指标比单次划分更可信。
另一个技巧是测试时增强(TTA)。对验证集图像做水平翻转、小角度旋转,分别预测后再把概率图平均,通常能涨 1 到 2 个 Dice 点。实现上就是在 validate 函数里对每张图跑多次前向,把 softmax 后的概率累加再取 argmax。注意 TTA 只适合几何变换,对比度变换不要加,否则概率分布会偏。
@torch.no_grad() def validate_tta(model, loader, device, num_classes=9): model.eval() dice_sum = torch.zeros(num_classes).to(device) count = torch.zeros(num_classes).to(device) for images, masks in loader: images, masks = images.to(device), masks.to(device) probs = torch.zeros_like(model(images)) # 原始 probs += torch.softmax(model(images), dim=1) # 水平翻转 probs += torch.softmax(model(torch.flip(images, dims=[3])), dim=1).flip(dims=[3]) # 小角度旋转可以用 kornia 或手动实现,这里省略 preds = probs.argmax(dim=1) for c in range(num_classes): pred_c = (preds == c) mask_c = (masks == c) inter = (pred_c & mask_c).sum().float() union = pred_c.sum().float() + mask_c.sum().float() if union > 0: dice_sum[c] += 2 * inter / (union + 1e-6) count[c] += 1 return dice_sum / count.clamp(min=1)逻辑说明:TTA 把原始预测和翻转后的预测概率相加,翻转后的预测要再翻回来才能对齐。参数上只加了水平翻转,旋转和缩放可以按需加,但每加一种 TTA 推理时间就翻倍。我一般只在最终评估时用 TTA,训练过程中不用。
从那以后我每次拿到新的医学分割数据,都强制先跑一遍文件配对检查和 mask 唯一值统计,再动手写模型。这两个脚本花不了五分钟,但能省掉后面几小时的排查。希望帮到你。
本文还有配套的精品资源,点击获取