简介:本资源是面向医学图像分割初学者与算法工程师的高质量2D脾脏分割数据集,源自公开3D脾脏分割数据,在冠状面与矢状面两个解剖视角下精细抽取并严格筛选(剔除前景占比低于0.1%的样本),专用于训练和验证轻量级2D分割模型,如U-Net、SegNet等,适用于课程实验、Kaggle式入门项目及模型baseline构建。资源共1664个文件,含1663张PNG格式的原始图像与对应掩膜(coronal平面60对、sagittal平面771对),以及1个即用型Python可视化脚本——无需修改参数,运行即可随机加载样本,同步展示原图、真值掩膜及叠加蒙版效果,并自动保存结果图,显著降低数据探查门槛。压缩包仅23.45MB,结构清晰、标注精准、前景区域丰富,已获461人学习下载,是兼顾专业性与易用性的医学影像入门级实践资源。
1. 从3D医学影像里“切片”出高质量2D脾脏分割数据:为什么冠状面只有60张、矢状面却有771张?
你手头有一套3D脾脏CT扫描数据,想训练一个轻量级2D分割模型快速验证算法——但直接拿原始体数据喂U-Net?显存炸、训练慢、泛化差。这时候,有人把3D体积数据沿解剖平面“切”成二维切片,并人工筛掉无效区域(比如脾脏只占图像0.05%的薄层),留下冠状面60张 + 矢状面771张带精确标注的2D样本。这不是简单截图,而是按临床解剖逻辑抽帧:冠状面切得少,因为脾脏在该方向跨度小;矢状面切得多,因脾脏前后径长、信息更密集。数据集共34MB,开箱即用,含完整目录结构(images/masks)、二值标注(0=背景,1=脾脏)、以及一个零配置可视化脚本——运行即弹窗显示原图+GT+叠加蒙版,连路径都不用改。适合刚入坑医学图像分割的新手练手,也适合老手做baseline对比或数据增强前的数据探查。别被“34MB”骗了,这尺寸背后是严格的质量过滤:剔除所有前景像素占比<0.001的切片,确保每张图都有足够脾脏纹理可学。
2. 数据结构与加载逻辑:如何用PyTorch DataLoader正确读取coronal/sagittal双平面数据
2.1 目录结构解析:为什么必须区分coronal和sagittal两个子集
项目明确将数据分为coronal和sagittal两个独立子集,而非混放。这不是为了文件管理方便,而是解剖学约束:冠状面(frontal plane)垂直于人体长轴,显示左右对称结构;矢状面(sagittal plane)平行于中线,呈现前后层次。脾脏在矢状面上的投影面积更大、形态变化更丰富(从脾门到脾尖渐变),因此切片数量(771)远超冠状面(60)。若强行合并加载,会破坏数据的空间分布特性,导致模型混淆解剖维度。实际工程中,我通常为二者分别构建Dataset类,再用ConcatDataset组合——这样既能保留平面特异性,又支持统一训练。
# dataset_loader.py import os from torch.utils.data import Dataset, DataLoader from PIL import Image import numpy as np class Spleen2DSliceDataset(Dataset): def __init__(self, root_dir: str, plane: str = "coronal", transform=None): """ :param root_dir: 数据集根目录,如 './spleen_2d/' :param plane: 'coronal' 或 'sagittal' :param transform: 图像预处理pipeline(如ToTensor) """ self.plane = plane self.image_dir = os.path.join(root_dir, plane, "images") self.mask_dir = os.path.join(root_dir, plane, "masks") # 确保images和masks文件名严格一一对应 self.image_files = sorted([f for f in os.listdir(self.image_dir) if f.lower().endswith(('.png', '.jpg'))]) self.mask_files = sorted([f for f in os.listdir(self.mask_dir) if f.lower().endswith(('.png', '.jpg'))]) # 校验配对完整性 assert len(self.image_files) == len(self.mask_files), \ f"Plane {plane}: image/mask count mismatch ({len(self.image_files)} vs {len(self.mask_files)})" for img, mask in zip(self.image_files, self.mask_files): assert img.split('.')[0] == mask.split('.')[0], \ f"Filename mismatch at {img} / {mask}" def __len__(self): return len(self.image_files) def __getitem__(self, idx): img_path = os.path.join(self.image_dir, self.image_files[idx]) mask_path = os.path.join(self.mask_dir, self.mask_files[idx]) image = Image.open(img_path).convert("RGB") # 统一转RGB,兼容多数预训练模型 mask = Image.open(mask_path).convert("L") # 灰度图,像素值0/1 if self.transform: image = self.transform(image) mask = self.transform(mask) # 注意:mask需用相同transform,但避免RandomRotation等破坏标签 return image, mask.squeeze(0) # 去掉channel维度,返回 [C,H,W], [H,W]提示:
mask.convert("L")是关键。原始mask虽为二值,但可能以P模式(调色板)存储,直接转tensor会出错。convert("L")强制转为灰度,确保像素值为0-255整数,后续torch.tensor(mask)才能正确映射为0/1。
2.2 构建双平面DataLoader:按比例采样避免矢状面主导训练
矢状面771张 vs 冠状面60张,若直接合并,模型93%的batch都看到矢状面切片,严重偏置。常见做法是分层采样:先为每个plane构建独立DataLoader,再用WeightedRandomSampler按反比权重分配——让coronal样本被采样概率提升12.85倍(771/60≈12.85),使两个平面在epoch内出现频次接近。
# train_dataloader.py from torch.utils.data import WeightedRandomSampler coronal_ds = Spleen2DSliceDataset("./spleen_2d/", plane="coronal", transform=train_transform) sagittal_ds = Spleen2DSliceDataset("./spleen_2d/", plane="sagittal", transform=train_transform) # 计算采样权重:coronal权重 = 1/60, sagittal权重 = 1/771 → 归一化后coronal权重≈0.93 weights = [1/len(coronal_ds)] * len(coronal_ds) + [1/len(sagittal_ds)] * len(sagittal_ds) sampler = WeightedRandomSampler(weights, num_samples=len(weights), replacement=True) # 合并数据集 combined_ds = torch.utils.data.ConcatDataset([coronal_ds, sagittal_ds]) train_loader = DataLoader(combined_ds, batch_size=16, sampler=sampler, num_workers=4)2.3 可视化脚本深度拆解:为什么它能“零配置”运行?
摘要提到“代码无需更改,直接运行”,这依赖于脚本对目录结构的强约定。其核心逻辑是:
- 自动遍历当前目录下所有
*/images/子目录; - 对每个子目录,匹配同名
*/masks/; - 随机选一张图片,读取image/mask,用OpenCV叠加蒙版(绿色半透明);
- 保存三图拼接结果到
./viz_output/。
# viz_script.py (精简版) import os import random import cv2 import numpy as np from pathlib import Path def find_image_mask_pairs(): """自动发现所有plane子目录下的images/masks配对""" pairs = [] for plane_dir in Path(".").glob("*/"): if not plane_dir.is_dir() or plane_dir.name in ["viz_output", "__pycache__"]: continue images_dir = plane_dir / "images" masks_dir = plane_dir / "masks" if images_dir.exists() and masks_dir.exists(): img_files = list(images_dir.glob("*.png")) + list(images_dir.glob("*.jpg")) for img_path in img_files: mask_path = masks_dir / img_path.name if mask_path.exists(): pairs.append((img_path, mask_path)) return pairs if __name__ == "__main__": pairs = find_image_mask_pairs() if not pairs: raise FileNotFoundError("No image-mask pairs found! Check directory structure.") img_path, mask_path = random.choice(pairs) image = cv2.imread(str(img_path)) mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) # 叠加蒙版:mask为1处画绿色,alpha=0.4 overlay = image.copy() overlay[mask == 1] = [0, 255, 0] # BGR顺序 blended = cv2.addWeighted(image, 0.6, overlay, 0.4, 0) # 拼接三图:原图 | GT | 叠加图 gt_colored = cv2.cvtColor(mask * 255, cv2.COLOR_GRAY2BGR) # 0/1→0/255 viz = np.hstack([image, gt_colored, blended]) output_dir = Path("viz_output") output_dir.mkdir(exist_ok=True) cv2.imwrite(str(output_dir / f"viz_{img_path.stem}.png"), viz) print(f"Visualization saved to {output_dir / f'viz_{img_path.stem}.png'}")注意:此脚本不依赖任何外部配置文件,靠目录名隐式识别plane类型。若你重命名了
coronal为frontal,脚本仍能工作——只要images和masks子目录存在且同级。
3. 数据质量验证:如何用统计指标确认“标注效果极好”是否属实
3.1 前景占比分布分析:验证“剔除<0.001区域”的实际效果
摘要强调“去除了前景区域不足0.001的数据”,但需实证。我们遍历所有mask,计算每个mask中像素值为1的比例,绘制直方图:
import matplotlib.pyplot as plt from tqdm import tqdm def analyze_foreground_ratio(root_dir: str): ratios = [] for plane in ["coronal", "sagittal"]: mask_dir = Path(root_dir) / plane / "masks" for mask_path in tqdm(list(mask_dir.glob("*.png")), desc=f"Analyzing {plane}"): mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) ratio = np.sum(mask == 1) / mask.size ratios.append(ratio) plt.hist(ratios, bins=50, alpha=0.7, label="All masks") plt.axvline(0.001, color='r', linestyle='--', label="Threshold (0.001)") plt.xlabel("Foreground pixel ratio") plt.ylabel("Count") plt.legend() plt.title("Foreground ratio distribution across all slices") plt.show() print(f"Min ratio: {min(ratios):.6f}, Max: {max(ratios):.4f}, Mean: {np.mean(ratios):.4f}") analyze_foreground_ratio("./spleen_2d/")预期结果:直方图左边界应紧贴0.001红线,无低于该值的点——证明过滤生效。若发现大量0.001~0.01的样本,说明脾脏在这些切片中确实很薄,但仍有学习价值;若峰值集中在0.1~0.3,则说明数据富含典型脾脏形态。
3.2 标注一致性检查:用Dice系数量化GT间相似度
单张图标注质量难判断,但多张图间若存在系统性偏差(如某操作员总把脾脏边缘标粗),可通过计算相邻切片mask的Dice相似度来探测。理想情况下,同一3D体积中相邻矢状面切片的脾脏形状应高度相似(Dice > 0.85)。
def dice_coefficient(mask1, mask2): intersection = np.sum(mask1 & mask2) union = np.sum(mask1) + np.sum(mask2) return 2. * intersection / (union + 1e-8) # 对sagittal平面计算相邻切片Dice sagittal_masks = sorted(Path("./spleen_2d/sagittal/masks").glob("*.png")) dice_scores = [] for i in range(len(sagittal_masks)-1): m1 = cv2.imread(str(sagittal_masks[i]), cv2.IMREAD_GRAYSCALE) > 0 m2 = cv2.imread(str(sagittal_masks[i+1]), cv2.IMREAD_GRAYSCALE) > 0 dice_scores.append(dice_coefficient(m1, m2)) print(f"Sagittal adjacent Dice: mean={np.mean(dice_scores):.3f}, std={np.std(dice_scores):.3f}") # 合格数据集应输出:mean≈0.82~0.92,std<0.13.3 解剖合理性验证:用最小外接矩形(Bounding Box)检查脾脏位置
脾脏位于左上腹,其mask的最小外接矩形应满足:
- y坐标(行)集中在图像上半部(y_min < height/3)
- x坐标(列)偏左(x_max < width*0.6)
若大量mask的bbox落在右下角,可能是标注错误或数据裁剪异常。
def check_bbox_anatomy(root_dir: str): invalid_count = 0 total = 0 for plane in ["coronal", "sagittal"]: mask_dir = Path(root_dir) / plane / "masks" for mask_path in mask_dir.glob("*.png"): mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) if np.sum(mask) == 0: continue # 跳过全黑mask(理论上已过滤,但防万一) total += 1 # 计算bbox coords = np.where(mask > 0) y_min, y_max = coords[0].min(), coords[0].max() x_min, x_max = coords[1].min(), coords[1].max() h, w = mask.shape # 解剖规则:脾脏应在图像上半部且偏左 if y_max > h/2 or x_max > w*0.7: invalid_count += 1 print(f"Anatomically suspicious masks: {invalid_count}/{total} ({invalid_count/total*100:.1f}%)") check_bbox_anatomy("./spleen_2d/")血泪经验:我在早期项目中曾发现某批数据因DICOM窗宽窗位预处理错误,导致脾脏在CT上显示为低密度区,标注员误标为“无脾脏”,结果bbox全空。这个检查能在训练前揪出此类硬伤。
4. 模型训练实战:用nnUNet风格预处理适配2D U-Net
4.1 输入尺寸归一化:为什么固定为512×512而非原始尺寸?
原始图片尺寸未在摘要中说明,但医学图像常为512×512或更高。若保持原尺寸,不同切片分辨率不一(如coronal切片可能被插值放大),影响模型收敛。统一缩放到512×512是平衡显存与细节的黄金选择:
- 小于512的图像用
cv2.INTER_CUBIC插值放大,避免锯齿; - 大于512的用
cv2.INTER_AREA缩小,保留结构; - mask必须用
cv2.INTER_NEAREST,防止0/1像素被插值污染。
def resize_to_512(image: np.ndarray, mask: np.ndarray) -> tuple: h, w = image.shape[:2] if h == 512 and w == 512: return image, mask # 图像缩放 if h < 512 or w < 512: image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_CUBIC) mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST) else: image = cv2.resize(image, (512, 512), interpolation=cv2.INTER_AREA) mask = cv2.resize(mask, (512, 512), interpolation=cv2.INTER_NEAREST) return image, mask4.2 数据增强策略:针对医学图像的保守增强组合
医学图像增强需克制——过度旋转/弹性变形会扭曲解剖结构。推荐组合:
RandomHorizontalFlip(p=0.5):脾脏左右不对称,但水平翻转在2D切片中可接受;RandomAffine(degrees=0, translate=(0.1, 0.1), scale=(0.95, 1.05)):微平移+微缩放,模拟定位误差;ColorJitter(brightness=0.1, contrast=0.1):仅调亮度/对比度,不碰饱和度(CT无色彩);- 禁用:RandomRotation(破坏解剖朝向)、ElasticTransform(CT纹理不适用)。
from torchvision import transforms train_transform = transforms.Compose([ transforms.ToTensor(), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomAffine( degrees=0, translate=(0.1, 0.1), scale=(0.95, 1.05), interpolation=transforms.InterpolationMode.BILINEAR ), transforms.ColorJitter(brightness=0.1, contrast=0.1), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) # ImageNet均值 ])4.3 损失函数选择:Dice Loss + BCE Loss的加权组合
二值分割任务中,单纯BCE Loss对前景稀疏场景(如脾脏占图10%)敏感度低。Dice Loss直接优化交并比,但梯度不稳定。加权组合(Dice:0.5 + BCE:0.5)是业界标配:
import torch import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, weight_bce=0.5): super().__init__() self.weight_bce = weight_bce def forward(self, logits, targets): # BCE部分 bce_loss = F.binary_cross_entropy_with_logits(logits, targets.float(), reduction='mean') # Dice部分 probs = torch.sigmoid(logits) intersection = (probs * targets).sum() dice = (2. * intersection + 1e-8) / (probs.sum() + targets.sum() + 1e-8) dice_loss = 1 - dice return self.weight_bce * bce_loss + (1 - self.weight_bce) * dice_loss criterion = DiceBCELoss(weight_bce=0.5)5. 避坑指南:训练时踩过的5个真实坑及解决方案
5.1 现象:训练loss下降但验证Dice不升反降
原因:模型过拟合coronal平面(仅60张),在sagittal上泛化差。验证集若随机采样,大概率抽到sagittal样本,导致Dice骤降。
解决:验证集必须按plane分层采样,确保coronal和sagittal各占50%。用SubsetRandomSampler指定索引,而非全局shuffle。
5.2 现象:mask叠加可视化时出现灰色斑点,非纯绿
原因:原始mask为8位PNG,像素值是0/255,但cv2.imread默认读为uint8,mask == 1永远为False(因值为255)。
解决:读mask后强制二值化:mask = (cv2.imread(str(mask_path), 0) > 128).astype(np.uint8)。
5.3 现象:DataLoader报错OSError: broken data stream when reading image file
原因:部分PNG文件损坏(常见于解压不完整或传输中断),尤其spleen_18_154.png这类编号连续的文件易成片损坏。
解决:在Dataset__getitem__中加健壮读取:
try: image = Image.open(img_path).convert("RGB") except Exception as e: print(f"Corrupted image {img_path}, skipping...") return self.__getitem__((idx + 1) % len(self)) # 递归重试5.4 现象:训练初期loss为nan
原因:DiceBCELoss中分母为0(某batch内targets全0),1e-8补偿不足。
解决:在loss计算中加batch级保护:
# Dice计算前 if targets.sum() == 0 and probs.sum() == 0: dice = torch.tensor(1.0) # 完全匹配 elif targets.sum() == 0 or probs.sum() == 0: dice = torch.tensor(0.0) # 完全不匹配 else: intersection = (probs * targets).sum() dice = (2. * intersection) / (probs.sum() + targets.sum())5.5 现象:可视化脚本保存的图片全黑
原因:OpenCV的cv2.imwrite要求输入为uint8,但blended数组是float64(因addWeighted输出浮点)。
解决:保存前转换:cv2.imwrite(..., blended.astype(np.uint8))。
6. 进阶技巧:用Grad-CAM定位模型“看哪里”,验证是否聚焦脾脏解剖区
6.1 Grad-CAM实现:无需修改模型结构的热力图生成
Grad-CAM通过反向传播最后一层卷积的梯度,加权求和特征图,生成类激活图。对2D U-Net,我们取decoder最后输出的特征(假设为64通道),计算其对脾脏类别(logits[:,1])的梯度:
import torch import torch.nn.functional as F class GradCAM: def __init__(self, model, target_layer): self.model = model self.target_layer = target_layer self.gradients = None self.features = None self.target_layer.register_forward_hook(self._save_features) self.target_layer.register_backward_hook(self._save_gradients) def _save_features(self, module, input, output): self.features = output def _save_gradients(self, module, grad_in, grad_out): self.gradients = grad_out[0] def __call__(self, input_tensor, target_class=None): self.model.eval() input_tensor = input_tensor.requires_grad_(True) output = self.model(input_tensor) # [1,2,H,W] for binary segmentation if target_class is None: target_class = 1 # spleen class # 取脾脏通道的logits,全局平均池化作为分类得分 score = output[:, target_class].mean() self.model.zero_grad() score.backward() gradients = self.gradients features = self.features weights = torch.mean(gradients, dim=(2, 3), keepdim=True) # [1,C,1,1] cam = torch.sum(weights * features, dim=1, keepdim=True) # [1,1,H,W] cam = F.relu(cam) cam = F.interpolate(cam, size=input_tensor.shape[2:], mode='bilinear') # 上采样到原图 cam = cam - cam.min() cam = cam / (cam.max() + 1e-8) return cam.squeeze().cpu().detach().numpy() # 使用示例 model = UNet2D(in_channels=3, num_classes=2) # 假设已加载权重 grad_cam = GradCAM(model, model.decoder[-1]) # 取decoder最后一层 # 加载一张sagittal切片 image, mask = sagittal_ds[0] # [3,512,512], [512,512] image_batch = image.unsqueeze(0) # [1,3,512,512] cam_map = grad_cam(image_batch) # [512,512] plt.imshow(image.permute(1,2,0).cpu().numpy()) plt.imshow(cam_map, cmap='jet', alpha=0.4) plt.title("Grad-CAM: Model attention on spleen") plt.show()6.2 解剖可信度评估表:用热力图验证模型是否“懂医学”
| 评估维度 | 合格标准 | 不合格表现 | 检查方法 |
|---|---|---|---|
| 空间定位 | 热力图峰值在脾脏解剖区(左肋缘下) | 峰值在胃/胰腺/左肾区域 | 叠加CT解剖图比对 |
| 形态覆盖 | 热力图覆盖脾脏整体轮廓,非局部碎片 | 热力图呈多个孤立斑点,未连通 | 计算热力图连通域数量(应≤3) |
| 边缘锐度 | 热力图边界与mask边缘基本重合 | 热力图明显溢出mask边界(假阳性关注) | 计算热力图与mask的IoU(应>0.6) |
| 平面特异性 | coronal切片热力图呈椭圆横向分布;sagittal呈纵向条带 | 两平面热力图形态无差异 | 分别统计主轴方向(PCA) |
6.3 一个硬核习惯:每次训练前必跑的3行验证脚本
我从不直接开训。在train.py最开头插入这三行,它会在加载数据后立即打印关键统计,5秒内告诉你数据有没有硬伤:
# 在DataLoader创建后立即执行 sample_batch = next(iter(train_loader)) print(f"Batch shape: {sample_batch[0].shape}") # 应为 [B,3,512,512] print(f"Mask unique values: {torch.unique(sample_batch[1])}") # 应为 tensor([0, 1]) print(f"Foreground ratio in batch: {sample_batch[1].float().mean():.4f}") # 应在0.05~0.3之间如果unique values出现2或-1,说明mask有非法值;如果foreground ratio恒为0,说明路径错了或mask全黑。这三行省去80%的debug时间——毕竟,模型不会说谎,但数据会撒谎。从那以后我每次新建实验,都强制走一遍这个检查,哪怕只是临时调试。希望帮到你。
本文还有配套的精品资源,点击获取