简介:面向医学图像分割与MRI影像分析需求,提供一套大脑肿瘤分割数据集,聚焦人脑MRI背景下的Tumor区域,图像分辨率为416×416,并配有类别标签和可视化脚本。该数据集采用2分割设置,仅区分背景与肿瘤区域,背景简洁、前景标注良好,适合学习分割模型和验证算法。数据按训练集、测试集划分,训练集含1632张图像及对应mask,测试集含240张图像及对应mask,可覆盖常见的训练与评估流程。整份资源共2000个文件,以PNG和JPG图片为主,另含1个Python可视化脚本,7z压缩后约48.17MB。PNG多为mask掩膜,JPG为原始MRI图像,脚本可随机抽取样本展示原图、GT掩膜及GT在原图上的叠加效果,便于直观检查标注质量。目前已有1879人学习/浏览,尤其适合初学者、医学影像算法工程师使用,也可作为标准数据集用于肿瘤分割研究。
1. 医学图像分割数据集最容易被忽略的,恰恰是2分割本身的标签设计
拿到一份“大脑肿瘤分割”数据集时,多数人第一反应是找个U-Net马上开训,等损失降到不错了才发现可视化结果歪得离谱:有的slide全是背景,有的把脑室当成肿瘤。问题通常不在网络结构,而在数据准备——医学图像分割数据集的“2分割”不是简单的前景=1、背景=0,它涉及原始标签从多子结构到二值mask的合并规则、类别标签的数值约定,以及能否用可视化代码把每个npz或nii文件真的看一遍。这篇文章就把这条链路补齐:先讲清2分割的语义和目录组织,再给出标签读取、重编码和可视化的可运行Python代码,最后把可视化变成训练前体检工具,适用于刚接触脑肿瘤分割的算法工程师,也适合需要复现baseline的科研人员。
2. 大脑肿瘤分割数据集的目录组织与2分类标签的真实语义
2.1 “2分割”不等于只有两个类别,而是“前景合并”后的像素级二分类
医学图像分割中“2分割”最常见的含义是:每个像素/体素只属于0或1,0是背景,1是肿瘤前景。但公开的大脑肿瘤分割数据集(例如面向多类分割任务的MRI数据)原始标签往往不止0和1,而是把肿瘤内部划分成坏死区、增强区、水肿区等多个子区域。因此拿到数据集时,必须确认原始mask文件里的数值分布,再决定是直接使用还是把它们合并为单个前景。
从模型角度看,二值与多类之间不是简单的阈值关系。脑肿瘤2分割中,前景内部不再做区分,网络只需学习“整肿瘤区域与正常脑组织的边界”。这个边界在MRI各模态上的表现差异很大:FLAIR对水肿敏感,T1ce对增强区敏感,所以训练2分割模型时通常把多个模态叠加成多通道输入,而不是只取T2或FLAIR单序列。
实际处理中,我会先做一个探测:用np.unique扫一遍所有mask,确认标注值到底是{0,1}、{0,1,2,3}还是{0,255}。这一步决定了后续所有二值化代码怎么写,也是个人工作流里始终放在目录检查之前的操作。
2.2 一个可直接复用的数据目录结构:images、masks、labels.json
很多下载到的脑肿瘤分割数据集并不会按统一标准打包,常见的可靠组织方式是把影像和掩码分开存放,并附带一份JSON描述类别标签。下面这个目录结构是我在本地复现2分割任务时常用的起点:
brain_tumor_2seg/ ├── images/ │ ├── MRI_001.nii.gz │ ├── MRI_002.nii.gz │ └── ... ├── masks/ │ ├── MRI_001.nii.gz │ ├── MRI_002.nii.gz │ └── ... ├── labels.json └── README.md这份结构里,images和masks按相同的文件名前缀一一对应。labels.json用于记录医学图像分割数据集最关键的类别标签映射,内容大致如下:
{ "name": "brain_tumor_2seg", "modality": ["T1", "T1ce", "T2", "FLAIR"], "labels": { "0": "background", "1": "whole_tumor" }, "original_label_codes": [0, 1, 2, 4], "merge_rule": "necrosis_and_enhancing_and_edema_merged_to_1" }original_label_codes记录了原始标签中可能出现的值,merge_rule说明这些子结构如何合并成1。参数的含义不是死规定,而是让后来的人不必重新翻数据集说明。
关于文件命名,我建议统一用数字序号或病例ID,不要带空格和中文。MRI_001.nii.gz这种纯ASCII命名可以避免跨平台路径解析问题,也方便在shell里用通配符批量处理。
2.3 NIfTI与二维PNG两种存储形态的取舍
同一个脑肿瘤分割数据集可能同时提供.nii.gz三维体数据和二维切片图。img格式的体积小但读取门槛高,适合做全卷积训练和三维后处理;二维PNG则便于快速查看、兼容目标检测框架,却会丢失体素间距和跨切片空间关系。
下表对比了我在实际项目里的选择依据:
| 存储形态 | 典型后缀 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 三维NIfTI | .nii.gz | 保留体素间距、方向信息,适合3D U-Net | 需要用nibabel/SimpleITK读取,调试成本稍高 | 3D分割模型的训练和评估 |
| 二维PNG切片 | .png | 可直接用PIL/OpenCV查看,方便快速筛错 | 丢失第三维和间距信息,标签易被压缩重采样 | 快速预览、2D模型、文档插图 |
若数据集中同时存在两种形态,我一般以NIfTI为准,在数据预处理阶段再导出对应的PNG。不要直接修改手动标注后的原始mask,否则方向或取值搞坏后很难追溯。
3. 用nibabel读取脑肿瘤分割mask,并完成二值重编码
3.1 读取MRI与Mask:先看shape、affine和dtype
写可视化代码前先把体数据正确读进来。医学图像处理里最常见的库是nibabel,它能处理NIfTI格式的header和affine。一个基础读取函数是这样:
import nibabel as nib import numpy as np def load_volume(path): """读取NIfTI文件并返回numpy数组、变换矩阵和header信息""" img = nib.load(str(path)) data = np.asarray(img.dataobj) # 避免一次性拷贝全部数据,惰性读取 affine = img.affine # 体素坐标到解剖坐标的变换矩阵 header = img.header # 包含spacing、scl_slope等信息 return data, affine, headerimg.dataobj实际上是一个代理对象,np.asarray会按需加载。这里不直接调用get_fdata(),是因为对于mask这种uint8或uint16数据,get_fdata()会把数据类型转成float64,浪费内存,还可能在后续二值化时引入浮点误差。用np.asarray(img.dataobj)能保留原始整数类型。
affine是4×4矩阵,它决定了体素在真实空间的坐标。仅仅做2分割可视化时可以不深入affine细节,但如果你需要把多个模态的MRI堆叠成立方体,就要先检查它们的affine是否一致。不一致的输入经过reslice后才能叠加,否则会得到错位的训练数据。
读取完成后必需的检查包括:
data, affine, header = load_volume("images/MRI_001.nii.gz") mask, _, _ = load_volume("masks/MRI_001.nii.gz") print("image shape:", data.shape, "dtype:", data.dtype) print("mask shape:", mask.shape, "dtype:", mask.dtype) print("voxel spacing:", header.get_zooms()) print("label unique:", np.unique(mask))header.get_zooms()返回每个维度的体素间距,常见脑肿瘤MRI为(1.0, 1.0, 1.0)或(0.5, 0.5, 1.0)。如果图像shape和mask shape不一致,直接套用同一坐标切片会错位,必须先利用affine做重采样。
3.2 把多标签掩码重编码成标准的0/1二分割
即使是标明“2分割”的数据集,原始mask也可能保留多类标签,只是文档里说“使用前请合并”。我通常用一个显式函数完成合并,而不是直接在训练管线里改mask值,主要为了让预处理流程可复现。
def merge_to_binary(mask, foreground_labels=(1, 2, 4)): """ 把mask中指定的前景类别合并为1,其他区域设为0。 mask: HxWxD 或 HxW 的整数数组 foreground_labels: 需要视为前景的原始标签值 """ binary = np.zeros(mask.shape, dtype=np.uint8) for label in foreground_labels: binary[mask == label] = 1 return binary为什么用np.zeros而不是mask拷贝后直接修改?因为原始mask可能包含背景0、前景1、增强区4、坏死区3等,如果不把不需要的类别置零,binary[mask==label]之外会残留其他前景值。先初始化全零数组,再按白名单填充,能够保证输出只有0和1。
参数foreground_labels要根据实际数据集调整。有的公开数据集把“水肿”标为2、“增强”标为4、“坏死”标为1,合并规则是(1, 2, 4);但另一个任务可能只有(1,)。我会把merge_rule写进labels.json,避免几个月后自己都忘记为什么要合并这几个标签。
3.3 类别占比检查:0和1的比例严重失衡时要先处理
2分割数据的典型问题是前景体素占比极低。脑肿瘤相比全脑体积很小,背景经常占99%以上。因此准备好binary mask后,应该统计每个样本的前景占比,而不仅是全局平均值。
def class_ratio(mask): total = mask.size foreground = int((mask > 0).sum()) return background, foreground, foreground / total当某个样本的前景占比低于0.1%时,这个样本对训练的贡献会非常有限,还可能让网络偏向“全预测为背景”。常见做法是把这类样本单独放到一个hard_samples.txt里,先用np.percentile找出占比分布,再决定是全部保留还是过滤。类别标签本身不一定要做像素级重加权,但知道极端的占比分布能帮助你确定损失函数里要不要加pos_weight。
提示:如果一个mask文件里只有0和1,就没必要调用merge_to_binary;但如果
np.unique(mask)出现255或很大的值,需要小心。255往往来自手工标注工具导出的索引色标签,直接作为前景会让loss爆掉,必须先除以255或重映射。
4. 可视化代码:从脑肿瘤分割样本的三轴切片到批量导出PNG
4.1 一张图显示轴状面、冠状面、矢状面
医学图像分割可视化代码的第一步是看三轴视图,因为一个NIfTI体数据是三维的,单看一眼中轴切片很难确认肿瘤在空间中的位置。我常用的最小实现是:
import matplotlib.pyplot as plt def plot_orthogonal(data, mask, axis_slices=None): """显示体数据三个维度的横断面,mask以红色叠加""" if axis_slices is None: z, y, x = np.array(data.shape) // 2 axis_slices = {'z': z, 'y': y, 'x': x} fig, axes = plt.subplots(1, 3, figsize=(12, 4)) for ax, axis_name, idx in zip(axes, ['z', 'y', 'x'], [axis_slices['z'], axis_slices['y'], axis_slices['x']]): if axis_name == 'z': image_slice = data[idx, :, :] mask_slice = mask[idx, :, :] elif axis_name == 'y': image_slice = data[:, idx, :] mask_slice = mask[:, idx, :] else: image_slice = data[:, :, idx] mask_slice = mask[:, :, idx] ax.imshow(image_slice, cmap='gray') ax.imshow(mask_slice, cmap='Reds', alpha=0.4) ax.set_title(f"{axis_name}={idx}") ax.axis('off') plt.tight_layout() return fig代码中的cmap='Reds'表示mask用半透明的红色叠加,alpha=0.4让mask既清晰又不遮挡底层MRI。这样做的理由很简单:灰度图像用gray,彩色mask用Reds或autumn,人眼能很快把边界和背景区分开。axis='off'只保留图像,适合放入论文或汇报材料。
三个切面的选择并不复杂,data.shape通常是(z, y, x)即(深度, 高度, 宽度),但不同数据集也可能存储为(x, y, z)。为了稳妥,可以用nibabel的affine来判断坐标轴方向,或者干脆把三个维度都打印出来,再根据mask形状做不同切面。
4.2 叠加肿瘤轮廓:边缘比半透明更好用
半透明叠加适合看区域重叠,但在肿瘤边界不清时效果一般。另一个更专业的方法是只看边缘轮廓,用skimage.segmentation.find_boundaries配合contour绘制:
from skimage.segmentation import find_boundaries boundary = find_boundaries(mask_slice, mode='inner') ax.imshow(image_slice, cmap='gray') ax.contour(boundary, levels=[0.5], colors='red', linewidths=0.8)find_boundaries会返回一个布尔数组,标记前景内部边缘的体素。用contour画线时levels=[0.5]是为了把布尔数组中的True区域轮廓提取出来。采用轮廓线而不是面填充,当多个小肿瘤同时出现在一个切片时,图形不会糊成一团。
此外,很多脑肿瘤MRI并不是普通CT值,直接用imshow会看到整图偏黑或偏白,因为MRI数据的灰度范围没有被正确归一化。下面一节会专门处理这个问题。
4.3 批量导出PNG:窗宽窗位、归一化与三视图布局
当样本量上百个时,逐个打开图像窗口很不现实。我会写一个把三轴视图批量保存为PNG的脚本,并在保存前完成与医学图像显示一致的窗宽窗位调整。MRI虽然没有CT那么强的窗宽概念,但T1、T2、FLAIR各模态灰度范围差异很大,通常采用百分位截断。
def normalize_slice(slice_2d, lower=0.5, upper=99.5): """对切片做百分位归一化,去除极亮噪声影响""" low_val = np.percentile(slice_2d, lower) high_val = np.percentile(slice_2d, upper) clipped = np.clip(slice_2d, low_val, high_val) normalized = (clipped - low_val) / (high_val - low_val + 1e-8) return normalizedlower=0.5, upper=99.5的含义是切掉最小0.5%和最大0.5%的像素,再把剩余范围拉伸到0-1。1e-8是防止全图为常数时除零。对于T1ce这类增强序列,这个归一化往往能让肿瘤边缘明显很多。
批量导出时,我通常生成一张包含原图、归一化图、mask轮廓的三图组合:
import os from pathlib import Path def export_slice_png(data, mask, save_path, axis='z', idx=None): if idx is None: idx = data.shape[0] // 2 if axis == 'z' else data.shape[1] // 2 if axis == 'z': img_slice, msk_slice = data[idx], mask[idx] elif axis == 'y': img_slice, msk_slice = data[:, idx], mask[:, idx] else: img_slice, msk_slice = data[:, :, idx], mask[:, :, idx] fig, axes = plt.subplots(1, 3, figsize=(15, 4)) axes[0].imshow(img_slice, cmap='gray'); axes[0].set_title("raw") axes[1].imshow(normalize_slice(img_slice), cmap='gray'); axes[1].set_title("normalized") boundary = find_boundaries(msk_slice, mode='outer') axes[2].imshow(normalize_slice(img_slice), cmap='gray') axes[2].contour(boundary, levels=[0.5], colors='red', linewidths=1.0) axes[2].set_title("contour") for ax in axes: ax.axis('off') os.makedirs(os.path.dirname(save_path), exist_ok=True) fig.savefig(save_path, dpi=150, bbox_inches='tight') plt.close(fig)参数mode='outer'会把前景内部和外部边界都框出来,适合快速检视。这里plt.close(fig)很重要,如果在一个大批次循环里不关图,内存里会累积大量Figure对象,导致程序跑一半爆掉。
| 参数 | 值 | 作用 |
|---|---|---|
| axis | z/y/x | 选择沿哪个坐标轴切slice |
| idx | 整数 | 指定切片索引,默认取中心 |
| dpi | 150 | 控制PNG清晰度,论文插图可调为300 |
| bbox_inches | tight | 裁掉多余留白,图片更紧凑 |
批量导出后,还要配合一个“可视化代码”最常用的技巧:给输出的PNG按样本名和切片索引命名,例如MRI_001_z_48.png。这样当某个切片mask异常时,能直接定位到原始体数据的z=48那一层,无需再打开3D体数据翻找。
5. 把可视化当训练前体检:连通域清洗与最小Dataset
5.1 用连通域分析剔除微小噪声标签
二值化后的mask里偶尔会存在若干个像素的孤立噪声,这类噪声大多来自标注抖动或后处理失误。对2分割模型来说,一个3×3像素的孤立前景会对边界损失造成明显干扰。常见的做法是保留最大连通域,或剔除体素量少于阈值的小区域:
from scipy import ndimage def clean_mask_connected_components(mask, min_volume=20): """ 保留体积大于min_volume的连通域,去掉微小噪点。 mask: (z, y, x) 二值数组 """ labeled, num_features = ndimage.label(mask) if num_features == 0: return mask.copy() sizes = ndimage.sum(mask, labeled, range(1, num_features + 1)) keep_labels = np.where(sizes >= min_volume)[0] + 1 # 重建mask,只保留keep_labels中的连通域 cleaned = np.isin(labeled, keep_labels).astype(np.uint8) return cleanedndimage.label默认按6邻域或26邻域连接,对于3D掩码自动使用26邻域,因此能在三维空间直接聚合肿瘤区域。ndimage.sum(mask, labeled, ...)会返回每个连通域的体素总数。min_volume=20表示小于20个体素的区域都丢掉;如果你只是切片2D训练,也可以改成2D连接并设min_volume=5。
检查清洗前后效果最直观的方法是生成一个对比图:左边是原始mask轮廓,右边是clean_mask_connected_components后的轮廓,肉眼确认没有把真正的肿瘤小块误删。
5.2 一个最小可用的PyTorch Dataset:返回切片和对应二值mask
把上面的清洗和归一化整合到训练管线里,最简单的2D Dataset如下:
import torch from torch.utils.data import Dataset class BrainTumorSliceDataset(Dataset): def __init__(self, image_paths, mask_paths, axis=0): self.image_paths = image_paths self.mask_paths = mask_paths self.axis = axis def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image, affine, header = load_volume(self.image_paths[idx]) mask, _, _ = load_volume(self.mask_paths[idx]) binary = merge_to_binary(mask, foreground_labels=(1, 2, 4)) binary = clean_mask_connected_components(binary, min_volume=20) # 沿指定轴取中段切片,作为2D输入 slice_idx = image.shape[self.axis] // 2 img_slice = np.take(image, slice_idx, axis=self.axis) msk_slice = np.take(binary, slice_idx, axis=self.axis) img_slice = normalize_slice(img_slice) img_tensor = torch.from_numpy(img_slice).unsqueeze(0).float() msk_tensor = torch.from_numpy(msk_slice).float() return img_tensor, msk_tensor这个Dataset刻意只取一个中间切片,方便快速验证训练管线,替换到完整2D分割时再把所有切片加入。注意np.take相比img[:, idx]的好处是axis可以写成参数,且不改变数组维度。torch.unsqueeze(0)补上通道维度,因为模型输入期望(C, H, W)。
5.3 进阶验证:按前景占比排序抽样,用可视化代码找出bad case
一个我每次训练前都会做的动作,是把数据集按前景占比排序,抽样40个样本生成可视化网格。占比最低的样本最可能导致“网络全预测为背景”,占比最高的样本则可能暴露标签过分割问题。
def sample_by_ratio(dataset, n=40): ratios = [] for i in range(len(dataset)): img, mask = dataset[i] ratios.append(mask.sum().item()) idx_sorted = np.argsort(ratios) idx_sorted = idx_sorted[:n//2].tolist() + idx_sorted[-n//2:].tolist() return idx_sorted把返回的索引对应到可视化代码里批量画网格图,检查肿瘤边界是否与MRI影像对齐。有没有把正常脑组织包进前景、有没有左右脑翻转、有没有mask比image多出一圈白边,这些问题在网格图里一眼就能看出来。把这些检查放进训练前置流程,能至少省下二到三个调试epoch的时间。
本文还有配套的精品资源,点击获取