简介面向医学影像分析与深度学习算法研究人员这是一套已划分好训练/验证集的肾脏肿瘤语义分割数据集共约2800张影像及对应像素级标签标注类别涵盖背景、肾脏和肿瘤三类。包内含约2000个文件以PNG图像和Mask为主另有类别说明文本与图像分割可视化脚本方便随机抽取原图、GT及叠加效果图快速核对标签质量整体压缩包约88.6MB便于下载与存储。目前已有175人学习使用适合入门与进阶的医学图像分割项目。该数据集还配套医学图像分割网络及Unet、SwinUnet、TransUnet等改进模型的专栏文章可帮助读者在数据准备基础上进一步开展模型训练与对比实验。1. 肾脏肿瘤语义分割数据集约 2800 张数据和标签下载之后先别急着训练拿到一个医学图像分割数据集时最常见的误区是直接套用网络训练结果发现验证集 Dice 低得离谱回头查才发现数据格式、类别编码或者划分方式早就埋了雷。肾脏肿瘤语义分割数据集约 2800 张数据和标签这类标注资源核心价值是帮你在不用重新请医生标注的前提下把“数据准备 模型迭代”这条链路跑通。这里的 2800 张在多数打包里有明确区分——是二维切片还是三维体数据会直接影响预处理策略和显存规划。适合的读者很明确做医学影像研究的同学、想快速验证语义分割算法效果的工程师以及正在为辅助诊断原型搭基座的团队。这篇文章我按自己拿到类似数据后的完整处理流程来讲从体检、转换、训练参数一路讲到评估脚本。2. 数据体检拿到数据包后先做三类检查别急着开训2.1 看目录结构和文件格式nii / nrrd / mha / png 的读取差异医学图像分割数据集的打包方式五花八门有的发 NIfTI.nii.gz有的发 MHA 或 NRRD偷懒一点的可能直接发切片 PNG。虽然都是“图像 标签”但读取库和维度语义完全不同。常见做法是先写一个目录扫描脚本弄清楚文件清单、格式和数量是否匹配。import os import glob from collections import Counter data_root ./kidney_tumor_dataset image_paths sorted(glob.glob(os.path.join(data_root, images, *))) label_paths sorted(glob.glob(os.path.join(data_root, labels, *))) # 统计扩展名分布 print(Images:, Counter(os.path.splitext(p)[1] for p in image_paths)) print(Labels:, Counter(os.path.splitext(p)[1] for p in label_paths)) # 核对文件数是否一致 print(fimages: {len(image_paths)}, labels: {len(label_paths)}) assert len(image_paths) len(label_paths), 图像和标签数量不一致这段脚本先把数据根目录下的 images 和 labels 文件夹扫一遍确认扩展名和数量。数量不一致是最常见的首坑标签缺失、重复文件、大小写不一致都会导致这里直接报错。参数说明glob.glob 返回无序列表sorted 保证图像和标签按同一套文件名对齐如果文件名前缀一致后续按 basename 配对时用 Path(p).stem 提取主名即可。读矩阵时根据扩展名选库nii.gz 用 nibabelmha/nrrd 用 SimpleITKpng 用 Pillow。维度上要特别注意nibabel 读出来的 shape 是 (x, y, z)SimpleITK 的 numpy 转换后是 (z, y, x)方向搞反了模型根本学不动这是最容易翻车的小细节。2.2 统计标签编码和体素分布肿瘤占比决定损失函数怎么选语义分割数据集最怕标签不干净。有的标注 0 是背景、1 是肾脏、2 是肿瘤有的只有二值背景/肿瘤还有的干脆用 0/255 表示必须在训练前重映射成连续的类别索引。最稳的方法是统计每个标签文件的唯一值和体素占比一次性把整个数据集的分布摸清楚。import numpy as np import nibabel as nib from collections import defaultdict value_counter defaultdict(int) total_voxels 0 for lp in label_paths: nii nib.load(lp) label nii.get_fdata() unique, counts np.unique(label, return_countsTrue) for v, c in zip(unique, counts): value_counter[int(v)] c total_voxels label.size print(类别编码 - 全局体素数 / 占比) for v in sorted(value_counter.keys()): print(f{v}: {value_counter[v]} ({value_counter[v] / total_voxels:.4%}))这段代码会输出全数据集各类别的体素占比。肾脏肿瘤分割里最常见的情况是背景占比超过 90%肿瘤可能只有 0.5% 到 2%——这个数字会直接告诉你该用加权 Dice Loss 还是 focal loss以及要不要做 patch 采样。参数说明get_fdata() 返回 float64 数组如果标签是 uint8 的 PNG 路径用 np.asarray(PIL.Image.open(...)) 代替np.unique 同时解决“标签是否有跳号”和“是否有负样本文件”两个问题。2.3 可视化抽检人和模型都需要先看几眼数据分布统计完还要肉眼验证。机器能读到标签≠标签和图像对得上我一般会随机抽 10 到 20 个病例把中间层的切片画成“原图 标签叠加图”存成一张大图快速扫一遍。import matplotlib.pyplot as plt def visualize_pair(image_path, label_path, save_path, slice_ratio0.5): img nib.load(image_path).get_fdata() lab nib.load(label_path).get_fdata() z int(img.shape[-1] * slice_ratio) fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(img[:, :, z], cmapgray) axes[0].set_title(Image) axes[1].imshow(lab[:, :, z], cmapgray) axes[1].set_title(Label) axes[2].imshow(img[:, :, z], cmapgray) axes[2].imshow(lab[:, :, z], alpha0.4, cmapjet) axes[2].set_title(Overlay) plt.savefig(save_path, dpi120, bbox_inchestight) plt.close()注意这里我取了一个固定 z 轴切片slice_ratio 取 0.5 就是看肾脏中部的横截面这个位置通常肿瘤最明显。抽检时重点看三件事图像里能不能看到病灶、标签是否贴着器官边缘、标签有没有出现“半个肾被标成肿瘤”之类的明显错误。这类错误统计指标看不出来只有人眼过一遍才能发现。如果某个病例标签完全空白直接丢进黑名单别让它污染训练集。3. 把原始数据转成模型能吃的格式重采样、切片划分与格式选型3.1 重采样到各向同性分辨率用 SimpleITK 一次搞定医学影像的 spacing每个体素代表的物理尺寸在不同病人身上不一样。肾脏 CT 扫描的层厚可能是 1mm 到 5mm如果直接输入网络模型会把“层厚”当成一种特征学进去导致泛化变差。常见做法是统一重采样到各向同性比如把 x/y/z 都变成 1mm。import SimpleITK as sitk def resample_to_isotropic(image_path, label_path, target_spacing(1.0, 1.0, 1.0)): img sitk.ReadImage(image_path) lab sitk.ReadImage(label_path) original_spacing img.GetSpacing() original_size img.GetSize() new_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetOutputDirection(img.GetDirection()) resampler.SetOutputOrigin(img.GetOrigin()) resampler.SetInterpolator(sitk.sitkLinear) # 图像用线性插值 img_resampled resampler.Execute(img) resampler.SetInterpolator(sitk.sitkNearestNeighbor) # 标签用最近邻 lab_resampled resampler.Execute(lab) return img_resampled, lab_resampled这段代码的核心是 ResampleImageFilter。图像用 sitkLinear 保持灰度过渡平滑标签必须换成 sitkNearestNeighbor否则肿瘤边缘会被插值出 1.4、2.7 这种非整数标签训练时直接报错或静默错分。参数说明new_size 由原尺寸乘原 spacing 除以目标 spacing 得到保证物理覆盖范围不变如果目标 spacing 设得太小比如 0.5mm体素数量会膨胀 8 倍显存压力剧增肾脏分割通常 1mm 够用。3.2 切 2D 切片并按患者划分防止数据泄露很多朋友用 2D 网络做医学分割图省事直接把所有切片打散随机划分。这是比较危险的做法同一个病人的几十张切片高度相似训练验证重叠后指标虚高换个医院的数据直接崩。正确做法是按患者 ID 分组再用 GroupShuffleSplit 或者手动按病人切分。import numpy as np def split_by_patient(patient_ids, train_ratio0.7, val_ratio0.15, seed42): unique_patients sorted(set(patient_ids)) rng np.random.RandomState(seed) rng.shuffle(unique_patients) n len(unique_patients) n_train int(n * train_ratio) n_val int(n * val_ratio) train_patients set(unique_patients[:n_train]) val_patients set(unique_patients[n_train:n_train n_val]) test_patients set(unique_patients[n_train n_val:]) train_indices [i for i, pid in enumerate(patient_ids) if pid in train_patients] val_indices [i for i, pid in enumerate(patient_ids) if pid in val_patients] test_indices [i for i, pid in enumerate(patient_ids) if pid in test_patients] return train_indices, val_indices, test_indices这里 patient_ids 是从文件名里提取的患者编号比如 “KITS_001_slice_023” 中的 KITS_001。参数说明seed 固定为 42 是为了复现实验train/val/test 比例按 7:1.5:1.5 是常见设置数据量小的时候可以把测试集压到 10%。验证集的作用是调参测试集只能最后碰一次这个纪律必须守住。3.3 格式选型nnU-Net、2D 切片 npy 还是 YOLO-seg预处理完的数据需要选一个落地格式这个选择取决于你的后续工具链。我遇到过三种主流做法各有适用场景格式适用场景优缺点nnU-Net 原始格式imagesTr / labelsTr正式实验、发表对比自动处理重采样和归一化但配置黑匣子较重2D 切片 npy 掩码 npy本地快速迭代模型加载快、调试直观没有格式转换成本COCO JSON / YOLO-seg TXT用 YOLOv8 做实例分割检测部署友好但需要把掩码转多边形小病灶有精度损失做肾脏肿瘤语义分割时我一般会同时保留 npy 切片和 nnU-Net 两种格式npy 用于写自定义网络nnU-Net 用于跑对照组。如果团队已经在用目标检测管线也可以转成 YOLO 格式微调 YOLOv8-seg但要接受多边形近似带来的边界误差——这跟模型能力无关是标注表示方式本身的差。4. 跑语义分割基线从 UNet 到 nnU-Net 的参数建议4.1 2D UNet 起步输入尺寸、归一化与 batch size 怎么设当前主流的语义分割模型里2D UNet 依然是医学图像分割的首选基线比直接上 Transformer 稳得多。FCN 语义分割把全卷积结构带到这一领域后UNet 用跳跃连接改善了边缘恢复至今仍是性价比最高的起点。先把输入尺寸和归一化定下来。# train.py 关键配置片段 config { input_size: (256, 256), batch_size: 16, lr: 3e-4, epochs: 200, loss: dice_ce, normalization: zscore, augmentation: [flip, rotate_10, scale_0.1], } # 归一化按每个病例的体素统计量做 z-score def zscore_normalize(img): mean img.mean() std img.std() 1e-8 return (img - mean) / std为什么 input_size 从 512x512 降到 256x256因为肾脏和病灶的解剖结构相对居中过大分辨率收益有限显存开销却翻四倍。如果发现小肿瘤丢失严重再把输入加到 384 或 512配合 patch sampling 更有效。batch size 在 16 到 32 之间常见显存不够时先降 batch保持学习率不变观察 loss 曲线是否震荡。归一化用 z-score 是医学影像的通用做法因为它对不同扫描设备的绝对灰度差异不敏感比 min-max 稳。4.2 损失函数Dice Loss 加 CrossEntropy 的组合逻辑类别不平衡是肾脏肿瘤分割的头号敌人。肿瘤体素占比经常低于 2%用纯交叉熵训练模型把所有像素预测成背景也能拿到 98% 的准确率。常见做法是把 Dice Loss 和 CrossEntropy 按权重相加让梯度在“类别平衡”和“像素级置信度”两个方向上都有信号。import torch import torch.nn.functional as F def dice_ce_loss(pred, target, alpha0.5, smooth1.0): pred: (N, C, H, W) logits target: (N, H, W) long tensor ce F.cross_entropy(pred, target) pred_soft F.softmax(pred, dim1) n, c, h, w pred_soft.shape pred_flat pred_soft.permute(0, 2, 3, 1).reshape(-1, c) target_flat F.one_hot(target.view(-1), num_classesc).float() dice 0.0 for cls in range(c): intersection (pred_flat[:, cls] * target_flat[:, cls]).sum() union pred_flat[:, cls].sum() target_flat[:, cls].sum() dice (2 * intersection smooth) / (union smooth) dice / c return alpha * ce (1 - alpha) * (1 - dice)参数说明alpha 取 0.5 表示两项等权肿瘤占比极低时可以调到 alpha0.3让 Dice 主导梯度。smooth 参数防除零取 1.0 足够。有个 ArXiv 上的经典做法是把每个类的 Dice 分开算再平均防止背景类把肿瘤类的指标稀释掉——上面的循环就是这个思路相比直接用全局 Dice 对少数类更敏感。4.3 nnU-Net 自动配置什么时候信默认值什么时候必须改如果你不想手调参数我建议直接用 nnU-Net 跑一套基线。它内部会自己分析数据集的中位体素间距、期望 patch size 和 batch size这套自动规划总体很强。但在肾脏肿瘤场景里有两个点一定要手动确认。第一个是nnUNet_plans里选的 patch size 是否覆盖了肾脏的物理尺寸。默认逻辑偏向于覆盖整个器官但如果肿瘤非常大且边界模糊patch 太小会让上下文不够。第二个是它的类别权重是自动从数据分布算出来的但如果你做的是三维训练而肿瘤体素占比极低建议显式把标签里的肿瘤类权重往上调。经验值是肿瘤类权重设成背景的 5 到 10 倍验证集 Dice 可以涨 5 个点以上。nnU-Net 也有它的黑匣子部分表现在推理时镜面推断策略在腹部数据上很可靠可一旦遇到术后肾脏变形严重的病例多跑一次 TTA 反而更稳。5. 医学图像分割避坑肾脏肿瘤训练里 5 个高频翻车现场5.1 翻车一标签文件全部读到 0训练 loss 直接降到 0现象训练第一个 epoch loss 就极低验证集 Dice 完全不涨。原因标签文件被读取成 0 值或者类别值被误读。一种常见情况是 PNG 格式的标签是 uint8 且只有 0 和 1但代码里用 sitk.ReadImage 读出来带方向信息维度顺序转置后变成全黑另一种是标签压缩包解压失败生成了同一张空白占位图。解决写一个独立性检查脚本跑完np.unique后统计全数据集每个文件中 0 的比例把全零文件单独挑出来打印文件名重点看这些文件的来源和格式。另外读 PNG 标签务必加np.asarray(PIL.Image.open(p))医学专用库反而不一定适合普通图片格式。5.2 翻车二图像和标签尺寸对不上训练时报 shape 错误现象DataLoader 报RuntimeError: The size of tensor a (512) must match the size of tensor b (384)。原因重采样后图像和标签用了不同插值方式或读进了不同 spacing 的文件没有统一处理。解决如上文写的重采样函数图像用线性插值、标签用最近邻并把标签的类型强制转成 int16 再做 one-hot。处理完每个病例都做一次assert img.shape label.shape一旦不一致立刻抛异常别让脏数据逃过早期检查。5.3 翻车三肿瘤类 Dice 一直为 0模型只预测背景现象平均 Dice 看着有 0.9但单独打印每类指标发现肿瘤是 0。原因类别不平衡 损失函数不给少数类足够梯度。肿瘤体素占比小于 1% 时模型很容易陷入“全预测背景”的局部最优分布于背景的交叉熵损失极小化方向过多。解决先用第 2.2 节的统计代码确认占比低于 3% 就要上加权 Dice 或者 focal loss。我通常的做法是损失函数换成 4.2 的dice_ce_loss并把采样策略改成“每 batch 至少包含 25% 含肿瘤切片”这样网络每个 step 都能看到正样本而不是靠运气撞上。5.4 翻车四切片级随机划分导致数据泄露验证指标虚高现象训练集 Dice 0.85验证集 0.82测试集只有 0.6换一批病人后崩得更厉害。原因同一个病人的相邻切片被分到 train 和 test网络和验证集高度相似泛化能力被高估。解决按患者 ID 用 3.2 的脚本分组保证同一个病人的数据不进不同集合。如果你拿到的数据文件名里没有患者编号就用文件名前缀或者路径中公共部分先聚类再手动打标这一步值得额外花半小时做。5.5 翻车五验证时只看平均 Dice忽略小肿瘤漏检现象报告写了 Dice 0.87但医生说“这个肿瘤你完全没有检测到”。原因体积大的病例把平均指标拉高了小肿瘤的漏检被掩盖。解决评估脚本按 case 输出每个类别的 Dice并额外加一个“肿瘤类体积小于 5 毫升”的分组统计。小肿瘤是临床重点能不能检测到比平均指标重要得多。下一章我会给出一个可以直接跑的分组评估脚本。6. 验证不只看平均 Dice按病例和按类别的评估脚本最后这节我给一个可复制的评估脚本它会对每个病例分别输出背景、肾脏、肿瘤三类的 Dice并按肿瘤体积分组合并报告。这个脚本是血泪经验换来的——我最初做类似项目时只打印全体 Dice 均值模型在几个大肿瘤上表现很好就以为达标后来医生反馈小病灶漏检率很高加了分组统计才发现问题。def evaluate_per_case(pred_paths, gt_paths, class_names(background, kidney, tumor)): results [] for pred_p, gt_p in zip(pred_paths, gt_paths): pred np.load(pred_p) gt np.load(gt_p) dice_scores {} tumor_voxels 0 for idx, name in enumerate(class_names): p (pred idx) g (gt idx) inter (p g).sum() dice (2 * inter 1e-6) / (p.sum() g.sum() 1e-6) dice_scores[name] dice if name tumor: tumor_voxels g.sum() results.append({ case: pred_p.split(/)[-1], **dice_scores, tumor_voxels: tumor_voxels, }) # 按肿瘤体积分两组 small [r for r in results if r[tumor_voxels] 500] large [r for r in results if r[tumor_voxels] 500] print(fSmall tumor cases (500v): {len(small)}, Large: {len(large)}) for group, name in [(small, small), (large, large)]: avg {c: np.mean([r[c] for r in group]) for c in class_names} print(f[{name}] .join(f{c}: {avg[c]:.4f} for c in class_names))这个脚本的关键是“按肿瘤体积分组”阈值 500 个体素大约对应 1mm 等方分辨率下的 0.5 毫升你可以按临床需求调整。注意上面用的inter是按类计算交集如果希望输出 HD95表面距离还得调medpy.metric.binary.hd95需要额外输入 spacing 参数原理上是对边界距离的统计和 Dice 互补。我的习惯是每个实验跑完先看这个脚本的输出再决定要不要调损失或者采样策略而不是只看 tensorboard 上的标量曲线。如果你准备长期做这个方向建议把评估脚本固定成独立的evaluate.py每次改模型只动训练代码不动评估代码。这套工作流跑顺了后续换器官、换模态也只是换数据和预处理的问题。希望这些踩坑记录能帮你少走几段弯路。本文还有配套的精品资源点击获取