简介本资源为面向医学图像分割任务的Liver肝脏癌症2D数据集适合从事肝脏及肿瘤分割研究的学生、算法工程师与科研人员使用。原始数据为Liver3d的nii.gz文件已沿x轴切分并剔除前景区域不足0.05的切片共提取8千余张图像mask像素分为0背景、1肝脏、2癌症三类可直接用于训练与评估分割模型。压缩包共约2000个文件以1998张png图像为主另含1个py可视化脚本与1个json配置文件整体约937.6MB训练集含6227张图像及对应mask测试集含2668张图像及对应mask目录按images与masks分列结构清晰。附带的可视化脚本可随机抽取一张图片展示原始图像、GT图像及GT在原图上的蒙板效果并保存至当前目录便于快速核验标注质量。目前已有877人学习下载适合需要现成2D肝脏癌症分割数据与可视化工具的研究者参考使用。1. 肝脏癌症 2D 分割数据集从拿到手到跑通第一个基线肝脏肿瘤分割是医学图像分割里最典型的入门场景之一也是很多算法工程师从自然图像转向医学领域的第一个真实项目。你拿到的这份 Liver 肝脏癌症数据集已经把 3D 体数据切成了一张张 2D 切片并且预先划分好了训练集和测试集省掉了最耗时的数据整理环节。它解决的核心问题是让你不用从 DICOM 原始序列开始清洗直接进入模型训练和评估。适合谁适合刚接触医学图像分割、想快速验证 U-Net 系列模型、或者需要一份带标注的肝脏肿瘤数据做对比实验的从业者。但别高兴太早2D 切片划分背后藏着切片间泄漏、类别极度不平衡、标注边界模糊这三个坑后面会逐个拆。2. 先搞懂这份 2D 肝脏数据到底长什么样2.1 从 3D 体数据到 2D 切片的转换逻辑肝脏 CT 原始数据是三维体数据维度通常是 512×512×NN 是切片数一般 100 到 300 层不等。把 3D 切成 2D本质是沿轴向逐层导出每层变成一张 512×512 的灰度图对应的标注 mask 也是同样尺寸的二值或三值图。常见做法是保留 HU 值在 [-200, 300] 区间的窗宽窗位这个范围能覆盖肝脏实质和肿瘤的密度差异。如果你拿到的已经是 PNG 或 JPG说明已经做过窗宽窗位映射和归一化直接读就行如果是 npy 或 npz那大概率保留了原始 HU 值需要自己再做一次窗宽窗位截断。这里有个容易翻车的地方不同设备的 CT 值范围不一致直接按固定窗宽窗位处理可能把肿瘤区域压成一片黑。我一般会先统计训练集所有切片的 HU 直方图确认肝脏区域的峰值位置再决定截断区间。如果数据已经是 8 位图像这一步跳过但要做像素值归一化到 [0,1]。2.2 训练集/测试集划分方式与潜在泄漏风险标题说划分了训练集和测试集但没说是按切片随机划分还是按病人划分。这两种方式差别巨大。按切片随机划分同一个病人的不同切片可能同时出现在训练集和测试集里由于相邻切片高度相似模型在测试集上的指标会虚高这就是典型的切片间泄漏。按病人划分才是正确做法保证同一个病人的所有切片只出现在一个集合里。拿到数据后第一件事就是检查划分文件。如果目录结构是 train/images、train/masks、test/images、test/masks你需要确认每个子目录里的文件名是否包含病人 ID。常见命名格式是patientID_sliceIndex.png用正则提取病人 ID统计训练集和测试集的病人 ID 是否有交集。有交集就说明存在泄漏需要重新按病人划分。没有病人 ID 的文件名只能按切片顺序做间隔划分比如每 5 张取 1 张做测试尽量降低相邻切片相似度带来的影响。import os import re from collections import defaultdict def extract_patient_id(filename): # 常见格式: 001_23.png 或 patient001_slice023.png match re.match(r(\d)[_-], filename) return match.group(1) if match else None def check_leakage(train_dir, test_dir): train_patients set() test_patients set() for f in os.listdir(train_dir): pid extract_patient_id(f) if pid: train_patients.add(pid) for f in os.listdir(test_dir): pid extract_patient_id(f) if pid: test_patients.add(pid) overlap train_patients test_patients print(f训练集病人数: {len(train_patients)}) print(f测试集病人数: {len(test_patients)}) print(f重叠病人数: {len(overlap)}) if overlap: print(f警告: 存在切片间泄漏, 重叠病人ID: {overlap}) return overlap check_leakage(data/train/images, data/test/images)这段代码的逻辑很直接从文件名提取病人 ID分别统计训练集和测试集的病人集合求交集。参数说明train_dir和test_dir分别指向训练集和测试集的图像目录如果你的 mask 目录命名规则不同需要同步修改。如果输出重叠病人数大于 0说明这份数据的划分方式有问题后续所有指标都不可信必须重新划分。2.3 标注类别与像素分布肝脏和肿瘤的样本极度不平衡肝脏癌症分割通常有两个前景类别肝脏实质和肿瘤。有些数据集还会标注血管但这份标题只提了肝脏癌症大概率是背景、肝脏、肿瘤三类。肿瘤区域在整张切片里的像素占比通常不到 5%有些切片甚至只有几十个像素。这种极端不平衡会让模型倾向于全部预测为背景或肝脏肿瘤的召回率极低。处理方式有三种一是损失函数用 Dice Loss 加交叉熵的组合Dice Loss 对前景区域敏感能缓解不平衡二是采样时对含肿瘤的切片做过采样让每个 batch 里至少有一张含肿瘤的切片三是后处理时降低肿瘤类别的阈值比如从 0.5 降到 0.3牺牲一点精确率换召回率。我一般会先统计训练集里含肿瘤切片的比例如果低于 20%就同时用 Dice Loss 和过采样。import numpy as np from PIL import Image def analyze_class_distribution(mask_dir): total_pixels 0 liver_pixels 0 tumor_pixels 0 tumor_slices 0 total_slices 0 for f in os.listdir(mask_dir): mask np.array(Image.open(os.path.join(mask_dir, f))) total_slices 1 total_pixels mask.size liver_pixels np.sum(mask 1) tumor_pixels np.sum(mask 2) if np.sum(mask 2) 0: tumor_slices 1 print(f总切片数: {total_slices}) print(f含肿瘤切片数: {tumor_slices}, 占比: {tumor_slices/total_slices:.2%}) print(f肝脏像素占比: {liver_pixels/total_pixels:.2%}) print(f肿瘤像素占比: {tumor_pixels/total_pixels:.2%}) analyze_class_distribution(data/train/masks)这段代码遍历所有 mask统计肝脏和肿瘤的像素占比以及含肿瘤切片的比例。参数说明mask_dir指向训练集 mask 目录假设 mask 是单通道 8 位图像素值 0 为背景、1 为肝脏、2 为肿瘤。如果你的 mask 是多通道 one-hot 格式需要先做 argmax 转成单通道。输出结果里肿瘤像素占比低于 1% 是常态含肿瘤切片占比低于 20% 就要考虑过采样。3. 用 PyTorch 搭一个能跑通的 2D 分割基线3.1 数据集类封装读取、增强与归一化PyTorch 的 Dataset 类需要实现__len__和__getitem__。读取时用 PIL 或 OpenCV 都行PIL 更轻量。增强方面医学图像分割常用的有随机水平翻转、随机旋转 ±15 度、随机缩放 0.9 到 1.1 倍、弹性形变。注意图像和 mask 必须做完全相同的几何变换否则标注会错位。归一化用 ImageNet 的均值和方差就行虽然医学图像和自然图像分布不同但实践中影响不大也可以用训练集自身的均值和方差。import torch from torch.utils.data import Dataset import numpy as np from PIL import Image import torchvision.transforms as T import random class LiverDataset(Dataset): def __init__(self, image_dir, mask_dir, image_size256, augmentFalse): self.image_dir image_dir self.mask_dir mask_dir self.image_size image_size self.augment augment self.filenames sorted(os.listdir(image_dir)) self.normalize T.Normalize(mean[0.485], std[0.229]) def __len__(self): return len(self.filenames) def __getitem__(self, idx): fname self.filenames[idx] image Image.open(os.path.join(self.image_dir, fname)).convert(L) mask Image.open(os.path.join(self.mask_dir, fname)).convert(L) image image.resize((self.image_size, self.image_size), Image.BILINEAR) mask mask.resize((self.image_size, self.image_size), Image.NEAREST) image np.array(image, dtypenp.float32) / 255.0 mask np.array(mask, dtypenp.int64) if self.augment: if random.random() 0.5: image np.fliplr(image).copy() mask np.fliplr(mask).copy() angle random.uniform(-15, 15) image Image.fromarray((image * 255).astype(np.uint8)) mask Image.fromarray(mask.astype(np.uint8)) image image.rotate(angle, resampleImage.BILINEAR, fillcolor0) mask mask.rotate(angle, resampleImage.NEAREST, fillcolor0) image np.array(image, dtypenp.float32) / 255.0 mask np.array(mask, dtypenp.int64) image torch.from_numpy(image).unsqueeze(0) image self.normalize(image) mask torch.from_numpy(mask) return image, mask逻辑说明读取灰度图统一 resize 到 256×256图像用双线性插值mask 用最近邻插值避免产生中间值。增强部分先做水平翻转再做随机旋转旋转时图像填充 0mask 也填充 0。参数说明image_size默认 256显存不够可以降到 128augment训练时设 True验证和测试时设 False。注意旋转后 mask 的像素值可能因为插值产生非整数用 NEAREST 可以避免但旋转角度不是 90 的整数倍时边缘会有锯齿这是正常现象。3.2 U-Net 模型定义与输出通道设置U-Net 是医学图像分割的标配编码器逐层下采样提取特征解码器逐层上采样恢复分辨率跳跃连接把编码器的浅层特征拼到解码器对应层。输出通道数等于类别数背景、肝脏、肿瘤就是 3。损失函数用交叉熵加 Dice Loss交叉熵负责像素级分类Dice Loss 负责区域重叠度。import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch1, num_classes3): super().__init__() self.enc1 DoubleConv(in_ch, 64) self.enc2 DoubleConv(64, 128) self.enc3 DoubleConv(128, 256) self.enc4 DoubleConv(256, 512) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(512, 1024) self.up4 nn.ConvTranspose2d(1024, 512, 2, stride2) self.dec4 DoubleConv(1024, 512) self.up3 nn.ConvTranspose2d(512, 256, 2, stride2) self.dec3 DoubleConv(512, 256) self.up2 nn.ConvTranspose2d(256, 128, 2, stride2) self.dec2 DoubleConv(256, 128) self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明编码器四次下采样瓶颈层通道 1024解码器四次上采样每次上采样后与编码器对应层拼接。参数说明in_ch1因为输入是灰度图num_classes3对应背景、肝脏、肿瘤。如果显存不够可以把每层通道数减半或者把输入尺寸降到 128。注意拼接时通道数要匹配up4输出 512 通道e4也是 512 通道拼起来 1024 通道正好是dec4的输入。3.3 训练循环与 Dice 指标监控训练循环里每个 epoch 跑完训练集后跑验证集记录损失和 Dice 系数。Dice 系数只对前景类别计算背景不参与。优化器用 Adam学习率 1e-3每 20 个 epoch 衰减一半。batch size 根据显存调整256×256 输入下 8 到 16 都行。def dice_coeff(pred, target, num_classes3): pred torch.argmax(pred, dim1) dice_sum 0 for cls in range(1, num_classes): pred_cls (pred cls).float() target_cls (target cls).float() intersection (pred_cls * target_cls).sum() union pred_cls.sum() target_cls.sum() if union 0: dice_sum (2 * intersection / union).item() return dice_sum / (num_classes - 1) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for images, masks in loader: images, masks images.to(device), masks.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, masks) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader)逻辑说明dice_coeff对每个前景类别分别计算 Dice 再取平均train_one_epoch是标准训练循环。参数说明criterion用nn.CrossEntropyLoss加 Dice Loss 的组合device根据是否有 GPU 选择。注意torch.argmax返回的类别索引要和 mask 的像素值对应如果 mask 像素值是 0、1、2那类别索引也是 0、1、2不需要额外映射。4. 避坑与排查肝脏 2D 分割最常见的 5 个翻车点4.1 现象训练 loss 正常下降但验证 Dice 始终低于 0.3原因切片间泄漏导致训练集和测试集分布不一致或者测试集里含肿瘤切片比例远低于训练集。解决先检查病人 ID 是否有重叠有重叠就重新按病人划分。如果没有重叠统计测试集含肿瘤切片比例如果低于 10%说明测试集里大部分是空切片Dice 被拉低是正常的需要单独看含肿瘤切片的 Dice。4.2 现象模型把所有像素预测为肝脏肿瘤 Dice 为 0原因肿瘤像素占比太低交叉熵损失被背景和肝脏主导模型学不到肿瘤特征。解决损失函数换成 Dice Loss 为主交叉熵权重降到 0.1。同时对含肿瘤切片过采样每个 batch 里保证至少 2 张含肿瘤切片。后处理时把肿瘤阈值从 0.5 降到 0.3。4.3 现象增强后 mask 出现灰度值 128 等中间值原因旋转或缩放时 mask 用了双线性插值导致类别边界产生过渡值。解决mask 的所有几何变换必须用最近邻插值Image.NEAREST或cv2.INTER_NEAREST。如果已经产生了中间值用np.round或阈值截断恢复成整数类别。4.4 现象显存溢出batch size 只能设 2原因U-Net 在 256×256 输入下参数量约 31M加上中间特征图显存占用不小。解决把输入尺寸降到 128×128或者把 U-Net 每层通道数减半或者用混合精度训练torch.cuda.amp。混合精度能省 30% 到 40% 显存对 Dice 影响很小。4.5 现象测试集评估时 Dice 波动很大每次跑结果不一样原因测试集样本量太小或者没有固定随机种子。解决固定torch.manual_seed、np.random.seed、random.seed测试时关闭增强和 dropout。如果测试集只有几十张切片Dice 波动大是正常的建议做 5 折交叉验证取平均。5. 把 2D 切片用出 3D 效果相邻切片拼接与后处理技巧2D 分割最大的问题是丢失了切片间的上下文信息同一个病人在相邻切片上的预测可能不连续肿瘤区域出现空洞或孤立噪点。一个实用技巧是推理时把相邻 3 张切片拼成 3 通道输入模型改成in_ch3这样每个像素都能看到上下文的肝脏和肿瘤形态。训练时同样用 3 通道数据加载器里对每个索引取idx-1、idx、idx1三张切片边界索引用复制填充。这个改动能让肿瘤 Dice 提升 3 到 5 个百分点代价是显存增加不多因为输入通道从 1 变 3 只影响第一层卷积。另一个技巧是后处理连通域分析。模型输出的肿瘤 mask 往往有零星小噪点用scipy.ndimage.label做连通域标记只保留面积最大的前 2 个连通域其余置为背景。这个操作对精确率提升明显召回率几乎不变。如果肿瘤本身是多发性的保留前 3 到 5 个连通域。from scipy import ndimage import numpy as np def postprocess_tumor(mask, min_area50, keep_topk2): tumor_mask (mask 2).astype(np.uint8) labeled, num ndimage.label(tumor_mask) if num 0: return mask areas ndimage.sum(tumor_mask, labeled, range(1, num 1)) sorted_idx np.argsort(areas)[::-1] keep_labels sorted_idx[:keep_topk] 1 keep_mask np.isin(labeled, keep_labels) small_mask (labeled 0) (~keep_mask) mask[small_mask] 1 return mask逻辑说明先提取肿瘤二值 mask做连通域标记按面积降序保留前keep_topk个连通域其余小连通域归为肝脏。参数说明min_area是最小面积阈值小于这个面积的连通域直接忽略keep_topk是保留的连通域数量单发肿瘤设 1多发设 3 到 5。注意这个后处理只对肿瘤类别做肝脏类别不动。验证方法上我习惯在测试集上同时跑原始输出和后处理输出对比 Dice 和 Hausdorff 距离。Hausdorff 距离对边界敏感能反映后处理是否把肿瘤边界削得太狠。如果 Hausdorff 距离变大超过 10 个像素说明后处理过度需要放宽min_area或增加keep_topk。最后说个血泪经验医学图像分割的评估指标一定要看多个维度Dice 高不代表临床可用边界模糊、小肿瘤漏检、切片间不连续都是 Dice 看不出来的问题。我一般会随机抽 10 个测试病例把预测 mask 和真实 mask 叠加成 RGB 图肉眼过一遍比看指标管用。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?