ARTICLE DETAIL

资讯详情

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

医学图像分割入门:Synapse多器官数据集预处理与2D UNet训练全攻略

医学图像分割入门:Synapse多器官数据集预处理与2D UNet训练全攻略 简介面向医学图像分割研究与深度学习实践这份腹部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, upper225): 腹部软组织窗截断后归一化到 [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 模型。Structurally3D 模型要考虑 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, interpolationcv2.INTER_LINEAR) label cv2.resize(label, self.size, interpolationcv2.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_weight0.5): Dice 与交叉熵的混合损失。 ce F.cross_entropy(logits, targets) probs F.softmax(logits, dim1) # (B, C, H, W) targets_onehot F.one_hot(targets, num_classesprobs.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第一版跑通用后处理再试 512batch 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 开启shuffleTrue每个 epoch 打乱一次。import torch from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet(in_channels1, num_classes9).to(device) # 0背景 8器官 optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max100) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers4) 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(fepoch {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. 验收不止看 lossDice 与病例级评估的正确算法很多人在 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同样要分别指定order1对应 CT、order0对应标签。预处理脚本里把这条规则写成注释比靠记忆更可靠。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 多器官图像分割数据集的经验能帮到你。数据集的价值在于用它把流程跑通、把坑踩明白而不是只看那个最终数字。本文还有配套的精品资源点击获取
返回列表