简介医学图像分割是辅助诊断的重要环节。胰腺病变图像分割数据集面向初学者与算法研究人员提供约260张图像及一一对应标签覆盖背景与病变区域两个类别适用于语义分割模型训练、效果评估及课程设计。资源包共533个文件以PNG格式为主共531张图像另有1个说明文件和1个Python可视化脚本压缩包整体22.58MB目录按images和masks分明便于快速加载与评估。目前已有605人浏览/学习。配套脚本可随机抽取一张图像将原始图、Ground Truth及GT在原图上的蒙版叠加结果展示保存方便直观检查标注质量训练集约210张、测试集约50张的划分为对比实验提供了便利适合复现经典分割网络或调试预处理流程。1. 医学图像分割数据集先别急着跑训练先摸清这套胰腺数据的脾气医学图像分割数据集到手后最忌讳的事情就是直接把训练代码拉起来跑。我拆过不少公开数据集几乎每次都要在预处理上花掉半天时间胰腺病变这套数据也不例外。它的体量很克制——训练集两百来张、测试集五十张左右每张原图配一张同名mask做的是背景加病变区域的二分类分割。这个规模放在深度学习里不算大所以正确打开方式是把它当成跑通流程、验证模型、做对比实验的基准资源。适合想熟悉医学分割完整链路、或者拿U-Net等网络做基线验证的从业者。这篇文章我从目录结构、标签格式、训练参数到踩坑记录按实际拆包顺序讲清楚。2. 数据组织方式先把目录树和标签语义摸清楚2.1 原始目录和命名规律拿到压缩包后先说目录结构。数据集默认分为train和test两大部分pancreatic-lesion-dataset/ ├── train/ │ ├── images/ │ │ ├── 8_50.png │ │ ├── 8_51.png │ │ └── ... │ └── masks/ │ ├── 8_50.png │ ├── 8_51.png │ └── ... └── test/ ├── images/ │ └── ... └── masks/ └── ...images 目录放的是原始医学图像masks 目录放着对应的分割标签。文件名前缀相同只用8_50、8_51、18_24、18_25这类编号区分。这种命名规律对代码非常友好读取配对时只需遍历 images 目录再用文件名去 masks 目录中找同名文件就行不需要额外维护标注文件。我一般会先写一段统计代码确认每张图片都有对应mask并检查图像尺寸和通道数是否统一。这一步价值很大能避免训练中途因为某张图尺寸不一致直接崩溃。2.2 类别语义和像素值分布整个数据集是两类分割背景归为0病变区域归为1。但这种说法值得细抠二分类在实现上可以做成单通道输出配合 Sigmoid也可以做成双通道输出配合 Softmax。从当前资源结构来看mask 更像是 0 和 255 或 0 和 1 的单通道灰度图。建议拿到数据后先跑一段统计脚本直接观察像素分布from PIL import Image import numpy as np import os mask_dir train/masks mask_files os.listdir(mask_dir) value_set set() resolutions set() for mf in mask_files: if not mf.endswith(.png): continue m np.array(Image.open(os.path.join(mask_dir, mf))) value_set.update(np.unique(m).tolist()) resolutions.add(m.shape) print(像素取值:, sorted(value_set)) print(分辨率种类:, resolutions)逻辑说明这里对全部mask做像素级扫描确认两个信息。像素取值决定后续是否需要二值化分辨率种类决定要不要加 Resize。参数上不预设任何阈值让数据自己说话。常见结果有几类一类是纯 0/255 的组合一类是 0/1 的组合偶尔会遇到带噪点的情况。如果是 0/255训练时最好除以 255 转成 0/1不然损失函数可能计算出意想不到的梯度方向。2.3 数据集规模的实际意义两百多张训练图做医学图像分割属于中小规模直接用随机初始化训练U-Net效果大概率不理想。常见做法是加载在 ImageNet 或同模态数据上预训练的编码器权重或者使用轻量增强策略来控制过拟合。这个现象后面章节会具体讲。测试集只有五十张得出的评估指标波动会比较大所以论文里的指标只能当作参考复现时不要指望指标一字不差。3. 数据加载与预处理原始图和mask喂进网络前的关键转换3.1 统一尺寸与归一化原始图像分辨率不一定统一这在真实医学数据集中非常普遍。直接原尺寸输入网络会导致 batch 内张量形状不一致。通常做法是训练阶段先 Resize 到固定尺寸比如256x256或512x512医学分割场景里尺寸选择会影响显存占用也影响病灶细节的保留程度。from torch.utils.data import Dataset from PIL import Image import torchvision.transforms as T class LesionDataset(Dataset): def __init__(self, image_dir, mask_dir, size(256, 256), augmentFalse): self.image_paths sorted(glob.glob(os.path.join(image_dir, *.png))) self.mask_paths sorted(glob.glob(os.path.join(mask_dir, *.png))) self.size size self.augment augment def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img Image.open(self.image_paths[idx]).convert(RGB) mask Image.open(self.mask_paths[idx]).convert(L) img img.resize(self.size, Image.BILINEAR) mask mask.resize(self.size, Image.NEAREST) img T.ToTensor()(img) mask torch.from_numpy(np.array(mask)).float() / 255.0 if self.augment: img, mask self.apply_augment(img, mask) return img, mask.unsqueeze(0)逻辑说明mask.resize必须使用最近邻插值分割标签是离散类别双线性插值会引入介于 0 和 1 之间的过渡像素导致标签失真。归一化这里直接除 255把取值压到 0 到 1 区间。mask.unsqueeze(0)把形状从(H, W)扩展成(1, H, W)匹配网络输出的通道结构。3.2 数据增强策略样本量不大增强策略需要谨慎选择。常见做法是随机水平翻转、随机旋转、轻微对比度调整。弹性形变对医学图像分割有争议因为会改变解剖结构形态胰腺这类器官如果增强太激进模型学到的是变形后的错误分布。def apply_augment(self, img, mask): if torch.rand(1) 0.5: img torch.flip(img, dims[2]) mask torch.flip(mask, dims[2]) # 随机旋转90度 k torch.randint(0, 4, (1,)).item() img torch.rot90(img, k, dims[1, 2]) mask torch.rot90(mask, k, dims[1, 2]) return img, mask图片和mask必须以完全相同的随机参数操作否则标签错位。翻转和旋转90度这类操作能保持标签语义尺寸不变实现起来也足够稳住训练。3.3 加载流程的验证方法搭建数据管道后先做一次前向检查。拿一个batch打印张量形状、数值范围并确认mask中同时存在0和1这一步能解决很多后续训练报错。dataset LesionDataset(train/images, train/masks) img, mask dataset[0] print(img.shape, mask.shape, img.dtype, mask.dtype) print(pixel range:, img.min().item(), img.max().item()) print(mask unique:, torch.unique(mask).tolist())跑完这段若mask unique只有[0.0]或[1.0]说明类别不平衡很严重或者读取逻辑有误。正常情况应看到[0.0, 1.0]大概率数值还会有介于两者之间的浮点噪声可以在损失函数里加一个取整操作。4. 训练与评估U-Net做医学分割的完整流程和参数设定4.1 模型结构选型医学图像分割领域U-Net是默认基线。常见实现方式是基于PyTorch搭建一个轻量版本编码器四层下采样解码器四层上采样底层用跳跃连接。针对这套胰腺数据集输入单张三通道图输出单通道概率图Sigmoid后和mask计算损失。import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_c, out_c): super().__init__() self.conv1 nn.Conv2d(in_c, out_c, 3, padding1) self.bn1 nn.BatchNorm2d(out_c) self.conv2 nn.Conv2d(out_c, out_c, 3, padding1) self.bn2 nn.BatchNorm2d(out_c) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.relu(self.bn2(self.conv2(x))) return x class UNet(nn.Module): def __init__(self, in_channels3, out_channels1, base32): super().__init__() self.base base self.enc1 ConvBlock(in_channels, base) self.enc2 ConvBlock(base, base*2) self.enc3 ConvBlock(base*2, base*4) self.enc4 ConvBlock(base*4, base*8) self.pool nn.MaxPool2d(2) self.up3 nn.ConvTranspose2d(base*8, base*4, 2, stride2) self.dec3 ConvBlock(base*8, base*4) self.up2 nn.ConvTranspose2d(base*4, base*2, 2, stride2) self.dec2 ConvBlock(base*4, base*2) self.up1 nn.ConvTranspose2d(base*2, base, 2, stride2) self.dec1 ConvBlock(base*2, base) self.out nn.Conv2d(base, out_channels, 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)) d3 self.up3(e4) d3 self.dec3(torch.cat([d3, e3], dim1)) d2 self.up2(d3) d2 self.dec2(torch.cat([d2, e2], dim1)) d1 self.up1(d2) d1 self.dec1(torch.cat([d1, e1], dim1)) return self.out(d1)逻辑说明基础通道数base32医学小数据集中够用显存占用也更友好。编码器部分逐层降分辨率增加通道数解码器通过转置卷积恢复分辨率并与编码器特征拼接。输出通道为1配合Sigmoid实现像素级二分类。4.2 损失函数和评估指标二分类分割中BCE Loss是默认选择但正负样本比例悬殊时容易出现全背景预测。行业中常见做法是把 Dice Loss 或 Focal Loss 与 BCE 组合。对于这套胰腺病灶数据病灶区域偏小我推荐 Mixed Loss即 BCE 与 Dice Loss 各占一半权重。import torch import torch.nn.functional as F from torch import nn class MixedLoss(nn.Module): def __init__(self, bce_weight0.5, smooth1.0): super().__init__() self.bce_weight bce_weight self.smooth smooth def forward(self, pred, target): bce F.binary_cross_entropy_with_logits(pred, target) pred_probs torch.sigmoid(pred) # 展平后计算Dice Loss pred_flat pred_probs.contiguous().view(-1) target_flat target.contiguous().view(-1) intersection (pred_flat * target_flat).sum() dice (2.0 * intersection self.smooth) / (pred_flat.sum() target_flat.sum() self.smooth) dice_loss 1.0 - dice return self.bce_weight * bce (1 - self.bce_weight) * dice_loss逻辑说明smooth1.0是平滑项防止分子分母都是0时计算出NaN。bce_weight用来控制两个损失的比重熵损失负责像素级梯度Dice Loss负责缓解类别不平衡。针对这类小病灶数据集我会更倾向于bce_weight0.3让Dice主导全局形状优化。评估指标使用 Dice 系数和 IoU 就足够统计时需要注意只看前景像素。背景占比极大如果直接全图计算指标可能出现虚高让人误以为结果很好。def dice_iou(pred_probs, target, threshold0.5): pred (pred_probs threshold).float() intersection (pred * target).sum() union pred.sum() target.sum() - intersection dice (2 * intersection 1e-6) / (pred.sum() target.sum() 1e-6) iou intersection / (union 1e-6) return dice.item(), iou.item()测试集上预测时输出Sigmoid概率以0.5为阈值转成二值图。如果后期发现病灶边缘锯齿感强可以微调阈值但不要轻易修改测试逻辑否则指标无法横向对比。4.3 训练超参设定两百多张图的训练规模batch大小给 4 到 8 即可。我常用的组合是Adam优化器、初始学习率1e-4、每10个epoch观察验证集Dice。当Dice不再上升时把学习率下调5倍。from torch.utils.data import DataLoader dataset LesionDataset(train/images, train/masks, augmentTrue) loader DataLoader(dataset, batch_size8, shuffleTrue, num_workers4) model UNet(base32) optimizer torch.optim.Adam(model.parameters(), lr1e-4) criterion MixedLoss(bce_weight0.3) epochs 80 for epoch in range(epochs): model.train() total_loss 0.0 for img, mask in loader: optimizer.zero_grad() pred model(img) loss criterion(pred, mask) loss.backward() optimizer.step() total_loss loss.item() * img.size(0) avg_loss total_loss / len(dataset) if epoch % 10 0: print(fepoch {epoch:03d} | loss {avg_loss:.4f})num_workers4在个人电脑上比较合理显存不够时优先降batch而不是降分辨率因为分辨率直接影响小病灶的检测能力。80个epoch在2080Ti或类似级别的显卡上大概能在一小时内完成跑之前准备一个固定的验证集不用实时打乱。5. 避坑指南胰腺分割数据集上手时的六个常见问题5.1 mask是RGB三通道直接当单通道读导致维度错误现象加载mask时直接用Image.open().convert(RGB)训练到一半维度对不上。原因部分标注工具导出的png是RGB三通道即使视觉上看起来是黑白的实际数组形状是(H, W, 3)。解决在数据集中统一用convert(L)转灰度。如果是第一次读取数据先用np.array(Image.open(mask_path))检查shape不要用视觉颜色判断通道数。5.2 mask像素值是0和255没归一化导致损失函数爆炸现象训练loss出现nan或训练集Dice忽高忽低不稳定。原因0/255的mask直接输入损失函数和网络输出的0到1概率范围不在同一尺度梯度计算异常。解决读取mask后除以255确认torch.unique(mask)输出接近0和1。如果还有介于两者之间的灰阶先做阈值处理比如大于127设成1否则为0。5.3 图片和mask尺寸不一致DataLoader报错现象RuntimeError: The size of tensor a (512) must match the size of tensor b (480)。原因原始图像和标注文件不一定同一分辨率可能在采集或标注过程中经过了裁剪。解决在__getitem__中强制resize同一尺寸。mask不要用cv2.resize的默认双线性插值而是用最近邻插值让标签保持离散整数。5.4 数据量少模型过拟合严重现象训练集Dice接近0.95测试集Dice只有0.5左右割裂感明显。原因两百多张图直接训练深层网络模型容量远大于数据容量记忆了噪声特征。解决增加数据增强强度首要加水平翻转和旋转。如果效果还不够把U-Net的基础通道数从32降到16缩小模型容量。另一个实用技巧是用ImageNet预训练权重初始化编码器在中小规模数据上能明显提高收敛质量。5.5 病灶区域过小输出几乎全黑现象预测图全是背景Dice直接为0。原因背景像素远大于前景模型发现预测全背景就能让损失降得很低。BCE Loss在这种情况下容易被背景像素主导网络偷懒。解决切换到前面介绍的MixedLossDice Loss对前景敏感同时把bce_weight调低让Dice起主要优化作用。还可以在训练时对mask做一次采样检查统计前景像素占比做一个类别权重补偿。5.6 测试时用了训练时的BatchNorm统计量现象单独加载模型推理一张测试图边缘区域预测效果明显差于训练时验证集的表现。原因BatchNorm在train和eval模式下行为不同。推理时没有用全局统计量而是用了当前batch的统计量导致分布漂移。解决测试时调用model.eval()把BatchNorm切换到累计均值和方差模式。预测小尺寸输入时优先保证最短边在128像素以上否则特征图尺寸过小语义信息损失严重。6. 再进一步把内置可视化脚本改成批量对比工具这份数据集附带的可视化脚本逻辑是把一张原图、GT mask、GT在原图上的蒙板效果拼接展示方便人眼检查标签质量。连续操作几张图后发现洗label用单张脚本效率偏低我习惯把它改成批量执行模式自动扫整个测试集一次性检查所有mask与图像的边缘对齐情况。改造思路并不复杂外层套一个目录遍历内层保持原脚本的图像叠加逻辑。叠加的原理是把mask转换成半透明红色通道盖在原图上边界的对齐关系一眼就能识别label画歪了没有。import cv2 import numpy as np import os def overlay_mask(image_path, mask_path, alpha0.4, save_pathNone): img cv2.imread(image_path) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) mask_binary (mask 127).astype(np.uint8) overlay img.copy() overlay[mask_binary 1] (0, 0, 255) result cv2.addWeighted(img, 1 - alpha, overlay, alpha, 0) if save_path: cv2.imwrite(save_path, result) return result test_images_dir test/images test_masks_dir test/masks save_root visual_check for name in sorted(os.listdir(test_images_dir)): if not name.endswith(.png): continue img_path os.path.join(test_images_dir, name) mask_path os.path.join(test_masks_dir, name) save_path os.path.join(save_root, name) overlay_mask(img_path, mask_path, save_pathsave_path) print(processed, name)逻辑说明alpha0.4控制红色蒙板的透明度数值越大颜色越重。mask127这一步很重要某些mask导出来有抗锯齿灰度边缘直接当0/1用会生成一圈又淡又碎的半透明蒙板影响人工判断。保存路径可以另建目录不要覆盖原图。这个视觉检查习惯帮我规避过不少问题印象最深的一次就是发现测试集里有两张图的mask和原图没有对应上大概率是原始数据整理时挪错了文件。如果直接跑训练这两张图会让测试指标出现无法解释的异常。从那以后我每次拿到新数据集都强制走一遍批量可视化流程先确认标签质量再进训练迭代。希望帮到你。本文还有配套的精品资源点击获取