简介:面向医学影像分析与深度学习算法研究人员,这是一套已划分好训练/验证集的肾脏肿瘤语义分割数据集,共约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(f"images: {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 用 nibabel,mha/nrrd 用 SimpleITK,png 用 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_counts=True) 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_ratio=0.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], cmap="gray") axes[0].set_title("Image") axes[1].imshow(lab[:, :, z], cmap="gray") axes[1].set_title("Label") axes[2].imshow(img[:, :, z], cmap="gray") axes[2].imshow(lab[:, :, z], alpha=0.4, cmap="jet") axes[2].set_title("Overlay") plt.savefig(save_path, dpi=120, bbox_inches="tight") 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_ratio=0.7, val_ratio=0.15, seed=42): 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, alpha=0.5, smooth=1.0): """ pred: (N, C, H, W) logits target: (N, H, W) long tensor """ ce = F.cross_entropy(pred, target) pred_soft = F.softmax(pred, dim=1) 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_classes=c).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 表示两项等权,肿瘤占比极低时可以调到 alpha=0.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(f"Small 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,每次改模型只动训练代码不动评估代码。这套工作流跑顺了,后续换器官、换模态也只是换数据和预处理的问题。希望这些踩坑记录能帮你少走几段弯路。
本文还有配套的精品资源,点击获取