☰
医学图像分割入门:Synapse多器官数据集预处理与2D UNet训练全攻略
2026/10/2 3:25:31 网站建设 项目流程

简介:面向医学图像分割研究与深度学习实践,这份腹部Synapse多器官分割数据集提供约1200张腹部图像与对应标签,覆盖背景、主动脉、肝、胰腺、胃、双肾等8个类别器官分割任务,可用于训练和验证语义分割模型,也适合医学影像方向学生与算法工程师作为入门到进阶的实验数据。资源包整体37.75MB,共2000个文件:1280个PNG为图像与掩膜,718个JPG为预览图,1个classes文本说明类别,1个Python可视化脚本可随机抽取图片,将原始图像、GT标签图及GT叠加原图的效果保存至当前目录,便于直观检查分割结果。数据集已划分训练集与测试集:训练集约1000张图像及对应mask,测试集约250张,目录结构直观清晰,下载后可按images与masks路径直接开展对比实验。目前已有1461人学习浏览,作者还整理了医学图像分割网络介绍专栏,便于进一步理解模型选型与调优思路。

1. 为什么医学图像分割入门,总绕不开腹部 Synapse 多器官数据集

做医学图像分割的人,多半经历过这种时刻:模型结构照着论文搭好了,却在数据上卡了一周——格式不对、标签对不上、类别分布离谱。腹部 Synapse 多器官图像分割数据集之所以被反复拿来当基准,就是因为它把“多器官、小器官、类别不均衡、三维 CT 数据怎么喂给模型”这些典型问题一次性凑齐了。常见版本包含肝脏、脾脏、胰腺、双肾等八个腹部器官,并附带与图像配套的分割标签,数据规模约 1200 张切片,足够跑通一条完整的数据处理到训练评估链路,又不至于大到让人失去耐心。

这篇文章从数据读取、预处理、训练到验收按真实落地顺序讲,适合两类人:一是刚接触医学图像分割、想找一个带标签的公开数据集练手的新手;二是已经在做 CT 分割,想快速把 Synapse 跑成内部基线的工程师。你会看到具体的代码、参数和翻车点,而不是一句“下载后开始训练”。

2. 先看清 Synapse 数据长什么样:NIfTI 读取、标签校验与预处理三件套

拿到这份数据集,第一件事不是建模型,而是把数据形态查清楚。Synapse 这类腹部 CT 数据集绝大多数以 NIfTI 格式分发,一个病例通常是一个三维 CT 卷加一个同尺寸的分割卷。标题里提到的“约 1200 张数据和标签”,在实操中更接近“切片级”口径:把约 30 例 CT 卷沿轴向切成 2D 切片,剔除没有目标器官的背景切片后,得到约 1200 张带标签的二维图像。你要先决定按 3D 卷做,还是按 2D 切片做,这个决定会影响后续所有代码。

2.1 病例为单位还是切片为单位:先统一数据视角

我一般建议第一次跑通时用 2D 切片视角。原因很朴素:2D 模型显存压力小,调试快,数据集里的“约 1200 张”也正好对应切片数量。Synapse 的每张 CT 切片是单通道灰度图,像素值是 CT 值(Hounsfield Unit),不是普通的 0 到 255 自然图像;标签图则是每个像素一个整数,0 表示背景,1 到 8 分别对应非背景器官。

当你打算把整卷切片全部导出时,要注意轴向切片的数量在不同病例之间差异很大。有的病例腹部扫描范围广,切片多;有的范围窄,切片少。如果直接按切片数量进行随机划分,同一个病例的相邻切片很容易同时出现在训练集和验证集里,造成数据泄漏。后面避坑章节会专门展开,但这里先记住:数据视角统一成切片后,划分还是要回到“病例”维度。

2.2 用 Python 读 NIfTI 并检查标签

读取 NIfTI 我习惯用 nibabel,读分割卷时顺带打印标签的唯一值,这是第一道防线。很多版本的数据集在预处理时会把某些器官缺失的标签置成 0,或者把背景标成 255,不检查直接训练会出大问题。

import nibabel as nib import numpy as np def load_synapse_case(ct_path, seg_path): """读取一个病例的 CT 和分割标签,返回 numpy 数组。""" ct_img = nib.load(ct_path) seg_img = nib.load(seg_path) ct = ct_img.get_fdata().astype(np.float32) seg = seg_img.get_fdata().astype(np.uint8) # 打印关键信息,确认方向、尺寸和标签值分布 print("CT shape:", ct.shape, "seg shape:", seg.shape) print("CT spacing:", ct_img.header.get_zooms()) print("seg unique values:", np.unique(seg)) return ct, seg

这段代码里最值得注意的不是读取本身,而是seg的唯一值。医学图像分割数据集翻车多半不在模型,而在标签值的约定不统一:有的版本背景是 0,器官从 1 开始;有的版本背景是 255,器官从 1 开始;还有的版本标签顺序和论文里不一样。打印唯一值后,你可以立即确认数据集实际包含几个类别,再决定网络输出通道数。

另外,nibabel 的get_fdata()返回的数组轴序是(i, j, k),对应 NIfTI 文件里的(x, y, z),其中z是切片方向。如果你从ct[:, :, z]取一张轴向切片,看到的是侧视图而不是横断面,那就是轴序理解错了。处理 Synapse 这类腹部 CT,常规做法是直接按ct[z, :, :]或ct[:, :, z]中的一种固定下来,并在预处理阶段统一。

2.3 重采样、窗宽窗位和切片导出

CT 扫描的层厚在不同病例之间不一致,如果保持原始 spacing 直接训练,模型会把薄层和厚层当成两种模态。常见做法是把所有病例重采样到各向同性 spacing,比如 1.0×1.0×1.0 mm。重采样时 CT 用线性插值,标签必须用最近邻插值,这是不能妥协的。

import SimpleITK as sitk import numpy as np def resample_to_isotropic(ct_path, seg_path, target_spacing=(1.0, 1.0, 1.0)): """将 CT 与标签统一重采样到目标 spacing。""" ct = sitk.ReadImage(ct_path) seg = sitk.ReadImage(seg_path) original_spacing = ct.GetSpacing() original_size = ct.GetSize() new_size = [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler = sitk.ResampleImageFilter() resampler.SetSize(new_size) resampler.SetOutputSpacing(target_spacing) resampler.SetOutputDirection(ct.GetDirection()) resampler.SetOutputOrigin(ct.GetOrigin()) # CT 用线性插值,标签用最近邻 resampler.SetInterpolator(sitk.sitkLinear) ct_resampled = resampler.Execute(ct) resampler.SetInterpolator(sitk.sitkNearestNeighbor) seg_resampled = resampler.Execute(seg) # SimpleITK 的 GetArrayFromImage 返回的是 (z, y, x),和 nibabel 相反 ct_arr = sitk.GetArrayFromImage(ct_resampled).astype(np.float32) seg_arr = sitk.GetArrayFromImage(seg_resampled).astype(np.uint8) return ct_arr, seg_arr

代码里注释已经标注了容易踩坑的点:GetArrayFromImage返回的数组是(z, y, x)轴序,ct_arr[z]才是轴向切片。很多人在这里不统一,后面训练时图像旋转了 90 度都不自知。

重采样之后是窗宽窗位。腹部 CT 观察软组织,常用窗位约 40 HU、窗宽约 300 HU,也就是把数值截断到[-75, 225]附近。这个范围不是拍脑袋定的,它对应肝脏、胰腺、肾脏与周围脂肪、肠管的灰度对比度。

def window_and_normalize(ct, lower=-75, upper=225): """腹部软组织窗:截断后归一化到 [0, 1]。""" ct = np.clip(ct, lower, upper).astype(np.float32) ct = (ct - lower) / (upper - lower) return ct

预处理三件套做完后,下一步就是把三维卷按ct_arr[z]和seg_arr[z]导出成 2D 切片。导出时建议顺带剔除全背景切片,既能减少训练时的无效计算,也能避免模型被背景类主导。

Synapse 数据集预处理参数可以参考下面的默认值,按自己显卡显存调整:

参数推荐值说明
重采样 spacing1.0×1.0×1.0 mm各向同性,不同病例对齐
CT 窗位 / 窗宽窗位 40,窗宽 300截断到 -75 到 225 HU
标签插值sitkNearestNeighbor绝不使用线性插值
切片方向轴向(z 轴)腹部 CT 常规观察方向
无关切片处理剔除全背景切片避免类别严重失衡

3. 用 2D UNet 跑通最小分割管线:从 Dataset 到训练循环

预处理做完,你会得到一批 2D 灰度切片和对应的标签切片。这一章的目标是跑通一条能出指标的最小训练管线,模型用 2D UNet 就够,不要一上来就上 3D 模型。Structurally,3D 模型要考虑 patch 采样、显存占用和标签稀疏性,调试复杂度高好几倍;而 2D 版本能把数据链路、损失函数和评估代码先验证正确。

3.1 Dataset 封装的两个关键选择

封装 Dataset 时,两个细节决定训练是否正常:一是缩放切片时标签必须用最近邻插值,二是标签要转成long类型直接喂给交叉熵损失,不要做成 one-hot。很多人把标签转成 one-hot 后和 Dice 损失配合出错,其实 PyTorch 的交叉熵支持直接传整数标签。

import cv2 import torch from torch.utils.data import Dataset class Synapse2DDataset(Dataset): def __init__(self, image_list, label_list, size=(256, 256)): self.images = image_list # list[np.ndarray], 每个 shape 为 (H, W) self.labels = label_list # list[np.ndarray], 每个 shape 为 (H, W) self.size = size def __len__(self): return len(self.images) def __getitem__(self, idx): img = self.images[idx] label = self.labels[idx] # 图像用线性插值,标签必须用最近邻插值 img = cv2.resize(img, self.size, interpolation=cv2.INTER_LINEAR) label = cv2.resize(label, self.size, interpolation=cv2.INTER_NEAREST) img_t = torch.from_numpy(img).float().unsqueeze(0) # (1, H, W) label_t = torch.from_numpy(label).long() # (H, W) return img_t, label_t

这段代码里,cv2.resize对图像和标签分别指定不同的插值方式,原因在于线性插值会在标签边缘产生“混合类别”。比如一个像素原本是肝脏,插值后变成 2.5,转成整数后就成了另一个器官,边界会变得模糊甚至错位。近邻插值虽然会带来轻微锯齿,但能保证类别值不变,这是分割任务里更高优先级的约束。

Dataset 的输入分辨率建议先定为 256×256。Synapse 原始 CT 切片通常是 512×512,直接训练对显存容量要求高,而且许多器官在 512 尺度下才有清晰边界,但第一版跑通没必要追求 512。256×256 下 batch size 可以给到 8 到 16,显存占用大约在 6 到 12 GB,多数训练卡都能接受。

3.2 训练参数怎么给:一张参数表说清楚

训练 Synapse 这类多器官分割任务,损失函数一般用 Dice 损失和交叉熵损失的加权组合。单用交叉熵,小器官如胆囊、食管会被大器官肝脏、脾脏淹没;单用 Dice 损失,早期梯度又不够平滑。常见做法是二者各取 0.5 权重。

import torch.nn.functional as F def dice_ce_loss(logits, targets, dice_weight=0.5): """Dice 与交叉熵的混合损失。""" ce = F.cross_entropy(logits, targets) probs = F.softmax(logits, dim=1) # (B, C, H, W) targets_onehot = F.one_hot(targets, num_classes=probs.shape[1]).permute(0, 3, 1, 2).float() smooth = 1.0 intersection = (probs * targets_onehot).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets_onehot.sum(dim=(2, 3)) dice = (2.0 * intersection + smooth) / (union + smooth) dice_loss = 1.0 - dice.mean() return dice_weight * dice_loss + (1.0 - dice_weight) * ce

这里dice_loss是对所有类别求平均,包含背景类。Synapse 这类数据集背景占比往往很高,如果觉得小器官梯度仍不足,可以把背景类从 Dice 平均中剔除,或者对背景的交叉熵权重降到 0.5 以下。两种做法都能缓解类别不均衡,但不要同时把权重压得太低,否则背景区域会大量误判成器官。

超参数推荐值说明
输入尺寸256×256第一版跑通用,后处理再试 512
batch size8 到 16显存不够就降到 4,并用梯度累积
优化器AdamW比 SGD 好调,weight_decay 给 1e-5
学习率1e-4配合多项式衰减或余弦退火
损失权重Dice 0.5 + CE 0.5小器官效果差时调高 Dice 权重
epoch80 到 120以验证集 Dice 不再上升为准

3.3 训练循环骨架:先跑通再调优

训练循环本身没有特殊之处,关键是显存监控和早停。Synapse 的切片之间存在连续性,如果按切片顺序遍历,同一个 epoch 里相邻 batch 的图像内容高度相似,收敛会变慢。建议 DataLoader 开启shuffle=True,每个 epoch 打乱一次。

import torch from torch.utils.data import DataLoader device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = UNet(in_channels=1, num_classes=9).to(device) # 0背景 + 8器官 optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=100) train_loader = DataLoader(train_dataset, batch_size=8, shuffle=True, num_workers=4) for epoch in range(100): model.train() running_loss = 0.0 for images, labels in train_loader: images = images.to(device) labels = labels.to(device) logits = model(images) loss = dice_ce_loss(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() running_loss += loss.item() scheduler.step() print(f"epoch {epoch:03d} loss {running_loss / len(train_loader):.4f}")

模型输出通道数是 9,而不是 8,因为类别 0 是背景。num_classes写错是新手最常犯的错误,有人写成 8,训练时交叉熵直接报 shape 不匹配。

另外,验证要在每个 epoch 结束后做,不能用训练集的 loss 下降作为收敛判断。Synapse 切片之间存在大量相似背景,训练 loss 会在第 20 个 epoch 左右降到很低,但小器官的 Dice 可能还在爬升。正确做法是保存验证集 Dice 最高的 checkpoint,而不是最后一个 epoch 的权重。

4. 验收不止看 loss:Dice 与病例级评估的正确算法

很多人在 Synapse 上训练完,看训练 loss 不高就觉得自己成功了。实际上分割任务真正有效的指标是 Dice、HD95 这类空间重叠和边界距离指标。Dice 要按每个器官单独计算再取平均,简单的全局像素正确率在这种类别不均衡的数据集里没有任何参考价值。

4.1 计算每个器官的 Dice:跳过硬编码标签

Synapse 标签的类别顺序在不同来源版本里有差异,计算指标时最好不要按“第 1 类固定是肝脏”这种记忆来写死。更稳妥的做法是先从标签里取唯一值,再对每个存在的类别逐一计算 Dice。

import numpy as np def compute_volume_dice(pred_vol, gt_vol): """三维级别计算每个器官的 Dice,返回各类别 Dice 列表。""" pred_vol = pred_vol.astype(np.uint8) gt_vol = gt_vol.astype(np.uint8) classes = [c for c in np.unique(gt_vol) if c != 0] # 跳过背景 dices = [] for c in classes: pred_mask = (pred_vol == c) gt_mask = (gt_vol == c) intersection = np.logical_and(pred_mask, gt_mask).sum() denominator = pred_mask.sum() + gt_mask.sum() if denominator == 0: continue # 该器官在当前病例中不存在,跳过 dice = (2.0 * intersection + 1e-6) / (denominator + 1e-6) dices.append(dice) return dices

代码里有两个关键判断。第一是classes从标签里动态获取,不依赖固定的器官编号;第二是denominator == 0时直接跳过。如果某个器官在验证集中没有出现,而你又按全部类别分母计算,它的 Dice 会变成 0,整体指标会被明显拉低,这个结果并不是模型真实能力的体现。

计算 Dice 时还有一个常见的细节:医学图像分割里通常用三维整体计算,而不是先把每个 2D 切片的 Dice 算出来再平均。三维整体计算更贴近临床关心的“器官体积是否完整分割出来”,而 2D 平均会受到切片数量的影响,切片多的病例会在平均中占据更大权重。

4.2 按切片还是按病例评估:两者差别很大

验证时如果按切片计算 Dice,再对所有切片取平均,会隐含一个假设:每张切片的重要性相同。但 Synapse 数据集的切片分布并不均匀,有的病例只有 40 张有效切片,有的有 60 张,切片级的平均结果天然偏向扫描范围广的病例。

评估口径计算方式特点
切片级每张 2D 切片算 Dice,再取平均受切片数量影响,偏向扫描范围大的病例
病例级每个病例的三维卷整体算 Dice,再取平均每个病例平等,更接近临床评价
类别级每个器官在所有病例上分别算 Dice,再平均能看出哪个器官最难分割

我建议在 Synapse 上以病例级 Dice 作为主指标,因为它更接近临床使用的“一个病人的器官分割得好不好”视角。你可以在验证循环里把每个病例的预测和标签都收集起来,最后统一计算病例级 Dice,而不是在每个 batch 里算完就平均。

4.3 Synapse 的合理验收基线

跑通基线时,不要一开始就追求指标多高。2D UNet 在 Synapse 上训练后,病例级平均 Dice 大致落在 0.7 到 0.8 区间,视预处理、输入分辨率和损失函数权重不同会有明显波动。更值得关注的是按器官拆开的 Dice,你会发现肝脏和脾脏的 Dice 能到 0.9 以上,而胆囊、食管、胰腺可能只有 0.5 到 0.7。

这个现象本身是 Synapse 数据集的价值所在:它不是那种所有类别都很容易刷高的数据集。胰腺周围脂肪多、边界不清晰,食管在切片上往往只占几个像素,胆囊体积小且形状多变。如果你的模型在所有器官上 Dice 都差不多,反而要怀疑标签有没有对齐。

5. Synapse 数据集使用避坑:4 个常见的翻车点

5.1 标签值并不是想当然的 1 到 8

现象:训练 loss 能降,但验证 Dice 始终在 0.2 左右徘徊,看预测图发现器官位置完全错乱。

原因:拿到数据集后没有打印标签唯一值,想当然地认为标签从 1 到 8 对应固定的器官顺序。不同渠道分发的 Synapse 版本可能在预处理时做过重映射,有的把背景设为 255,有的器官编号顺序与论文不一致。

解决:第一个代码块就要加np.unique(seg)输出。确认标签值后,再检查每个类别在三维切片里的空间位置是否大致对应预期器官。哪怕花掉半天时间做这一步,也比训练三天后发现标签错位划算。

5.2 按切片随机划分导致指标虚高

现象:训练和验证的切片混合在一起,验证 Dice 很高,但把模型放到未参与训练的完整病例上预测,效果明显变差。

原因:同一个病例的相邻轴向切片内容高度相似,随机划分时,验证集里的切片很可能与训练集切片来自同一个病例,模型相当于见过了“邻居”。

解决:划分时以病例 ID 为单位,先把病例分成训练 / 验证 / 测试三组,再把每组内部导出切片。不要直接对全部 1200 张切片做随机划分。这一步最好在预处理脚本里就完成,而不是训练时才分。

5.3 小器官被大器官淹没,Dice 结果两极分化

现象:肝脏、脾脏 Dice 接近 0.9,胆囊、食管、胰腺 Dice 却只有 0.4 左右。

原因:Synapse 的标签中,肝脏和脾脏的体素数量远大于胆囊和食管。交叉熵损失被大器官主导,模型学会了优先把资源分配给容易分割的大区域。

解决:把 Dice 损失的权重从 0.5 提到 0.7,并训练时额外挑选包含胰腺或胆囊的切片做重复采样。还有一个立竿见影的技巧:在损失计算里把背景类的 Dice 排除,让模型不需要为“预测对背景”获得梯度,把注意力集中到器官边界上。

5.4 重采样插值方式污染标签边界

现象:训练收敛正常,但预测结果里器官边缘总出现一层宽度为 1 到 2 像素的错误类别,尤其在小器官周围明显。

原因:预处理阶段对标签用了线性插值,或者用nibabel重采样时没有区分 CT 与标签的插值方式。线性插值会让标签边缘像素变成非整数,再取整时就会把边界像素归到相邻类别。

解决:标签重采样必须使用最近邻插值,这在第 2 章的代码里已经强调过。如果你用的是scipy.ndimage.zoom,同样要分别指定order=1对应 CT、order=0对应标签。预处理脚本里把这条规则写成注释,比靠记忆更可靠。

6. 把 Synapse 用成基准:消融、后处理和一个值得养成的习惯

数据集跑通一遍之后,Synapse 更大的价值是当“试验场”。它包含多个器官、多个难度层级,任何改动都能在相对固定的口径下比较,所以值得在上面做规范的消融实验。

6.1 固定评估口径再做消融

做消融实验时,唯一允许变化的变量是你的网络结构或损失函数,其他一切都要锁死。具体来说:同一份预处理后的切片、同一个病例划分文件、同一个评估脚本、同一组随机种子。我通常会把病例划分保存成一个 JSON 文件,里面记录每个病例属于训练还是验证,这样后续所有实验都用同一个划分,不会因为重新划分导致指标波动。

6.2 两行后处理提升小器官分割

对胰腺、十二指肠这类形状不规则的器官,预测结果中常常出现零散的假阳性区域。常见做法是保留最大连通域,去掉孤立的小块。

from scipy import ndimage import numpy as np def keep_largest_component(mask): """保留掩膜中的最大连通域。""" labeled, num = ndimage.label(mask) if num == 0: return mask sizes = ndimage.sum(mask, labeled, range(1, num + 1)) largest_label = np.argmax(sizes) + 1 return (labeled == largest_label).astype(np.uint8)

用这个函数时要注意:左右肾是分离的两个器官,但标签里它们是两个类别,每类只包含一个肾,所以每类保留最大连通域是安全的。如果某个类别天然包含多个连通区域,盲目保留最大域反而会删除真正有用的部分。后处理不能无脑加,要结合具体器官形态判断。

6.3 把数据链路固化成脚本,这比调模型更值

我自己做 Synapse 这类数据集时有一个固定的习惯:把预处理、训练、评估拆成三个独立脚本,每个脚本输出中间结果,不互相耦合。预处理脚本输出标准化后的切片和标签;训练脚本只依赖切片文件;评估脚本独立读取预测结果和标签计算指标。这样后续换 nnU-Net、换 3D 模型时,只需要替换训练脚本,预处理和评估可以原样复用。

这个习惯在后面的项目里帮了我很多次。医学图像分割数据集不像自然图像数据集那么规范,每次拿到新数据,都要重新经历格式确认、标签检查和预处理调试。把数据链路固化下来,模型怎么换都只是换中间一环;如果数据和训练代码耦合在一起,每一次升级模型都要重走一遍弯路。

希望这篇围绕腹部 Synapse 多器官图像分割数据集的经验能帮到你。数据集的价值在于用它把流程跑通、把坑踩明白,而不是只看那个最终数字。

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

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

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

立即咨询