ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

脑瘤MR图像二值分割实战:从TIF数据预处理到UNet训练

脑瘤MR图像二值分割实战:从TIF数据预处理到UNet训练 简介大脑磁共振脑瘤图像分割数据集面向医学图像处理、深度学习与计算机视觉研究人员专用于二值图像分割任务。数据已按标准监督学习格式划分训练集包含1099对原始脑瘤图像与对应分割掩膜测试集包含274对图像与掩膜目录结构清晰采用tif格式存储可直接输入常用分割模型进行训练与验证省去自行收集、清洗和格式转换环节。整个压缩包共2000个文件除1999个tif图像样本外还附带一个Python可视化脚本可随机抽取样本并自动生成原图、真值掩膜及两者叠加效果的三联图便于直观核对标注质量。资源包大小130.8MB已有395人学习浏览。对于脑肿瘤分割入门、模型对比以及医学影像课堂教学这套数据都提供了低门槛的现成实验基础可帮助快速跑通分割流程。1. 医学图像分割的第一道坎一份能直接训练的二值分割数据初学者接触医学图像分割时最容易在预处理上耗尽热情。第一次拿到TCGA编号的脑瘤MR数据时我犯了一个几乎所有转做医学影像的人都会犯的错——对着网上下载的脑瘤数据集直接建了个UNet开训结果十几个epoch后预测图要么全黑要么把整片脑组织圈成肿瘤。回头排查才发现问题压根不在网络结构而在数据本身的组织方式、mask的像素约定和训练验证的划分逻辑。这份大脑磁共振脑瘤图像分割数据集恰好帮我避开了这条弯路训练集1099张原图加1099张对应mask测试集274组总计不到300MB不依赖任何私有格式imges和masks两个目录摆得清清楚楚。对正在做医学图像分割入门、二值分割任务复现或者毕业设计验证的人来说它是少有的拿过来就能直接跑通流程的干净数据。2. 解剖这份脑瘤分割数据imges/masks结构、TCGA命名与二值mask约定2.1 imges与masks双目录和YOLO“图片标签”的本质区别先看目录骨架这是最容易被忽略但其实最关键的环节。数据集的存放方式是典型的医学分割风格训练集下面分两个平级目录imges里放原始MR影像masks里放对应的标注掩膜。文件名一一对应比如TCGA_HT_8113_19930809_18.tif在imges里出现一次在masks里也出现一次前缀完全一致只是所在目录不同。这种“同名不同目录”的组织方式和YOLO检测数据集里“一张jpg对应一个txt”的标注方式有很大差别对比项分割数据这份脑瘤集检测数据YOLO风格原图后缀.tif.jpg/.png标注内容与图同尺寸的mask图像归一化坐标的txt文本标注通道单通道二值或灰度多行框坐标读取方式图像直接打开解析文本再换算这个区别决定了后面的数据加载逻辑完全不同。分割任务里mask本身是一张图它的宽高必须和原图严格一致否则叠加显示时会错位。拿到这个数据集后我习惯先写一段脚本统计两件事一是imges与masks两个目录的文件名集合是否完全一致二是随机抽几张图对比原图和mask的尺寸。前者防止文件名错位后者防止某个mask因为标注工具导出问题出现尺寸漂移。顺序上后补的mask永远比原图容易出差错所以校验要以masks目录为主。2.2 文件名拆解TCGA编号里能挖出哪些信息文件名长得很有辨识度TCGA_HT_8113_19930809_18.tif、TCGA_DU_5871_19941206_20.tif。TCGA是公开肿瘤基因组图谱项目这类编号在医学公开数据里非常常见是很多脑瘤、肺癌、肾癌影像数据的标准前缀。按TCGA的命名习惯第一段TCGA是项目标识第二段是数据来源中心的缩写第三段是匿名的患者编号后面的数字串一般是扫描日期或样本接收日期最后一个数字往往是该患者的切片序号或序列编号。比如用TCGA_DU_6401_19831001_37.tif来拆可以大致认为这位患者编号6401某次扫描日期在1983年10月01日序号37表示同一次检查里的第37个切片或第37个序列。这种信息对训练划分非常有用同一个患者编号下往往有多张切片如果不做分组处理随机划分时同一个患者的切片可能同时出现在训练集和测试集里造成验证分数虚高。这一点我会在第5章详细展开现在先记住这个结论——文件名不是随手起的它带着重要的分组信息。2.3 二值segmentation与VOC/COCO实例分割的差异再看任务定义。标题里“二值图像分割任务”这几个字决定了模型输出层的设计每个像素只有两个类别前景和背景。前景是脑瘤区域背景是其他所有组织。这和VOC、COCO那类带几十个类别的实例分割不一样二值分割不需要区分肿瘤的亚型不需要识别水肿还是坏死只回答“这个像素是不是肿瘤”这一个问题。对模型而言这意味着输出通道数直接用1——一个sigmoid输出就能表达概率图。我在最初设计网络时纠结过要不要输出2个通道做softmax后来发现二值任务里1通道sigmoid更省显存收敛也更快。训练时mask的标签值只要0和1背景为0肿瘤区域为1。但实际从公开数据集拿到的mask经常出现255和0的组合或者偶尔混进一些灰度过渡像素。所以预处理里必须有一行强制二值化的操作。还有一个容易被忽略的点MR影像和自然图像不一样它本质上是单通道灰度图即使以.png或.jpg存储也常常是灰度模式。这份数据集用的是tif后缀tif这个容器既能装8位灰度也能装16位深度图读取时不能默认它和普通png一样只有0到255这点留到第5章专门说。3. 先跑通show.py三视图快速验证1099张图与mask是否对齐3.1 运行前先看这两个路径变量项目里附带了一个show.py脚本功能是随机从数据集中抽一张图把原始影像、GT真实标注、GT蒙在原图上的效果三张图并排显示并保存到当前目录。拿到手的第一件事不要急着python show.py先打开脚本看开头几行确认两个路径变量指向哪里。我见过太多人直接运行然后报FileNotFoundError原因就是脚本里的相对路径和你解压数据集的目录层级不一致。我一般会在脚本同级目录下建这样的相对结构./show.py ./brain_tumor_dataset/ train/ imges/ masks/ test/ imges/ masks/如果数据集目录名不同先做一次软链接或者改脚本里的路径字符串。这里不建议动数据集内的文件给数据集留一份原封不动的副本是个好习惯后面校验数据完整性时能省掉很多麻烦。3.2 show.py输出的三张图分别看什么脚本运行完成后当前目录会出现一张三联图从左到右分别是原图、GT mask、原图上蒙着半透明GT的效果。这三张图不是用来好看的每张图都必须回答一个具体问题。第一张原图看成像模态和组织对比度。大脑磁共振图像里脑脊液、灰质、白质各有不同的亮度特征肿瘤区域通常表现为局部灰度异常或边界不清。如果原图里肿瘤区域和周围组织在肉眼上根本无法分辨就要怀疑图像是不是被错误归一化了这种图直接喂给网络会加大分割难度。第二张GT mask看标注覆盖区域的位置和大小。二值mask显示为黑底白斑白色区域就是肿瘤。重点看白色区域和原图上肿瘤位置是否对得上有的数据集标注的是肿瘤核心有的标注的是整个病灶包含水肿两种标注难度差异很大。这张图决定了模型的损失函数上界——标注画得粗糙模型再怎么调参也学不出精细边界。第三张叠加图看空间对齐是否精确。肿瘤区域应该准确地贴在原图病灶上如果白斑漂移了半个脑回说明原图和mask存在空间错位这类数据千万不能用错位样本的Dice最多只能到0.7左右而且很难排查。3.3 从单张随机到批量自检show.py每次随机抽一张运气不好可能抽到一张相对简单的切片。我的习惯是把脚本改造成批量自检模式新增一个参数指定抽查数量随机抽出比如20张每张保存一个三视图然后快速翻阅。# show_batch.py 核心改动思路 import random from pathlib import Path import numpy as np import matplotlib.pyplot as plt from PIL import Image img_dir Path(./brain_tumor_dataset/train/imges) mask_dir Path(./brain_tumor_dataset/train/masks) ids sorted([p.stem for p in img_dir.glob(*.tif)]) sample_ids random.sample(ids, kmin(20, len(ids))) for i, stem in enumerate(sample_ids): img np.array(Image.open(img_dir / f{stem}.tif).convert(L)) mask np.array(Image.open(mask_dir / f{stem}.tif).convert(L)) # 强制二值化防止mask里混入灰度过渡值 mask (mask 127).astype(np.uint8) overlay np.dstack([img, img, img]).astype(np.float32) overlay[..., 1] mask.astype(np.float32) * 80 # 绿色通道叠加 overlay np.clip(overlay, 0, 255).astype(np.uint8) fig, ax plt.subplots(1, 3, figsize(15, 5)) ax[0].imshow(img, cmapgray); ax[0].set_title(origin) ax[1].imshow(mask, cmapgray); ax[1].set_title(mask) ax[2].imshow(overlay); ax[2].set_title(overlay) for a in ax: a.axis(off) plt.savefig(fcheck_{i}_{stem}.png, bbox_inchestight, dpi150) plt.close()这段代码里的关键参数有两个。第一个是mask 127这个阈值它把mask里所有大于127的像素视为前景避免标注边缘的抗锯齿灰度值干扰标签第二个是overlay[..., 1] 80在绿色通道叠加了80的亮度增量让肿瘤区域在叠加图上呈现明显的黄绿色肉眼扫一眼就能看出位置对不对。之所以选择批量模式而不是单张随机是因为随机抽20张基本能覆盖数据集中不同位置的切片远比单张更有代表性。4. 把数据集接进UNetTIF读取、Dice损失与训练流水线4.1 自定义Dataset把TIF读成统一尺寸的单通道医学图像分割最常用的基线网络是UNet这个选择基于两个原因一是它在少量数据上也能学到不错的特征二是它的跳连结构对微小病灶的定位有天然优势。早年用BP神经网络做图像分割的人会有体会全连接结构对像素空间关系几乎无能为力而UNet的多尺度特征融合正好补上了这个短板。这份脑瘤数据集接UNet需要先写一个标准的Dataset类。import torch import numpy as np from pathlib import Path from PIL import Image from torch.utils.data import Dataset class BrainTumorDataset(Dataset): def __init__(self, img_dir, mask_dir, target_size(256, 256)): self.img_dir Path(img_dir) self.mask_dir Path(mask_dir) self.target_size target_size self.ids sorted([p.stem for p in self.img_dir.glob(*.tif)]) def __len__(self): return len(self.ids) def __getitem__(self, idx): stem self.ids[idx] img Image.open(self.img_dir / f{stem}.tif).convert(L) mask Image.open(self.mask_dir / f{stem}.tif).convert(L) img img.resize(self.target_size, Image.BILINEAR) mask mask.resize(self.target_size, Image.NEAREST) # 用最近邻保持标签边界 img_arr np.array(img).astype(np.float32) / 255.0 mask_arr (np.array(mask) 127).astype(np.float32) img_tensor torch.from_numpy(img_arr).unsqueeze(0) mask_tensor torch.from_numpy(mask_arr).unsqueeze(0) return img_tensor, mask_tensor, stem这里的三个细节值得展开。第一convert(L)把图像统一转成8位灰度避免某些tif以RGBA模式读出四通道导致维度不匹配。第二resize时原图用BILINEAR插值而mask必须用NEAREST线性插值会在标签边界制造出0到1之间的过渡像素值这些值会在计算Dice时干扰正确率判断。第三stem作为第三个返回值保留样本名训练结束后做错误分析时能精确定位到具体是哪张切片预测失败。归一化直接除以255是因为这里统一转成了8位图如果遇到16位深度的tif这个操作会异常对应处理放在第5章避坑里。4.2 Dice损失处理肿瘤像素占比不足5%的主损失脑瘤分割和很多医学分割任务一样存在严重的类别不平衡。一张512乘512的图像里有几十万像素肿瘤区域可能只占几千到一万像素比例时常低于5%。如果用普通的交叉熵损失网络很容易学成“全部预测为背景”因为这样的错误率只有不到5%。这是我第一次训练时踩过的最深的坑。解决思路是用Dice损失作为主损失它的计算方式天然关注预测区域与真实区域的重叠度不受类别比例影响。Dice损失我从Dice系数推导而来Dice系数等于两倍交集除以并集数值越接近1越好损失就是1减Dice系数。import torch import torch.nn as nn class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, pred, target): # pred: [B, 1, H, W] 未经sigmoid的logits # target: [B, 1, H, W] 取值0或1 pred torch.sigmoid(pred).view(pred.size(0), -1) target target.view(target.size(0), -1) intersection (pred * target).sum(dim1) denominator pred.sum(dim1) target.sum(dim1) dice (2.0 * intersection self.smooth) / (denominator self.smooth) return 1.0 - dice.mean()smooth这个参数用来防止分子分母同时为0导致除零错误数值取1e-6即可不要取太大太大会让损失值在训练后期产生明显偏置。需要留意的是输入pred是模型的原始logits没有经过sigmoid所以forward内部先做了一次torch.sigmoid(pred)把输出压缩到0到1之间。target在Dataset里已经做过了阈值二值化所以这里直接使用。实际训练中我会把DiceLoss和Binary Cross Entropy按0.7比0.3加权组合使用——纯Dice损失训练初期梯度不稳定加上一点BCE能让训练更平稳这个比例可以按自己的实验结果微调。4.3 训练循环与验证指标Dice与IOU一起算训练循环本身不复杂核心是固定好设备和损失函数。验证阶段除了Dice还要算IOUDice和IOU都在衡量重叠度但数值口径不同Dice会把小目标的分值拉高一些IOU则更严格。写报告时两个都放免得只报Dice被审稿人或导师质疑。def calc_metrics(pred_mask, true_mask): # pred_mask和true_mask都是numpy数组0/1二值 inter (pred_mask * true_mask).sum() union (pred_mask true_mask).sum() - inter dice (2.0 * inter 1e-6) / (pred_mask.sum() true_mask.sum() 1e-6) iou (inter 1e-6) / (union 1e-6) return dice, iou验证的时候模型的logits输出先过sigmoid再按0.5阈值转成二值预测。阈值取0.5是默认做法医学图像分割里如果更看重召回率会把阈值降到0.3甚至0.2。但阈值降低会导致预测区域膨胀如果mask标注得本来就偏大再降阈值等于双重膨胀Dice反而会掉。所以先固定0.5跑通流程再根据后续的错误分析决定是否调阈值。训练过程中的学习率Adam优化器我习惯从1e-4起步batch size按显存选择8或者4因为数据是单通道灰度图显存压力比RGB数据小很多。5. 避坑TIF位深、mask像素值、样本失衡与同患者串扰5.1 读出来的图全是黑的16位TIF的位深问题现象用matplotlib的imread或者OpenCV的imread直接读tif文件显示出来的图像几乎全黑只有个别亮点或者用PIL读取后转numpy数组发现最大值远大于255。原因tif格式支持8位、16位甚至32位深度。MR影像原始导出经常保存为16位无符号整型像素值范围是0到65535直接按8位显示时大部分像素集中在数值的低区间看起来就是一片黑。PIL虽然能打开但如果不做类型转换后续归一化除255会得到错误结果。解决读取后先检查数组的dtype。如果是uint16需要先转成浮点再按65535做归一化或者右移8位降成8位深度再走常规流程。from PIL import Image import numpy as np img Image.open(TCGA_DU_6401_19831001_37.tif) print(img.mode) # 先看modeL是8位灰度I;16是16位灰度 arr np.array(img) if arr.dtype np.uint16: arr (arr / 65535.0 * 255.0).astype(np.uint8) elif arr.dtype np.uint8: pass else: arr (arr - arr.min()) / (arr.max() - arr.min() 1e-6) * 255.0这段代码在训练管线启动前跑一遍如果发现样本中混有16位图直接统一转成8位。如果数据集里8位和16位图混着来转完8位后还要注意同一张图是否出现整体亮度偏暗这时候可以加一个基于百分位的对比度拉伸比如把1%到99%的分位数映射到0到255效果比直接归一化稳定。5.2 mask像素值不是0/1二值化之前先统计标签值现象加载mask后打印它的像素值集合发现里面有0、1、2甚至255某些mask还有抗锯齿边缘产生的中间灰度值。直接用这些值算Dice指标在0.8左右徘徊上不去预测图的形状看着对但数值就是不对。原因不同的标注工具导出mask时对前景和背景的约定不一样。有的用0和255有的用0和1有的在边缘做了平滑处理产生了1到254之间的过渡像素。网络上流传的数据集经多手转存mask格式早就被改乱了。解决训练之前绝对不要假设mask只有两种值先做一次像素值统计。from collections import Counter def inspect_mask(mask_path): img np.array(Image.open(mask_path).convert(L)) cnt Counter(img.flatten().tolist()) top_vals cnt.most_common(5) print(f{mask_path}: {top_vals}) return top_vals跑完统计后统一做二值化mask_arr (np.array(mask) 127).astype(np.uint8)阈值取127是为了把0到255的尺度划分成两半如果有少数mask整体偏暗或者整体偏亮这个阈值可能需要下调或上调。我建议把统计脚本固化进项目的预处理流程里每次换数据集都强制跑一遍不要漏掉。5.3 loss在降但预测全黑类别不平衡的常见翻车现象训练前几个epoch的loss值明显下降到验证阶段把预测图保存出来发现整张图全黑或者只有零星几点白色。查看验证集Dice数字可能在0.1以下但训练集的Dice却不错。原因肿瘤区域像素占比过低模型学到了“全预测为背景”这种简单解。交叉熵损失里背景类贡献了95%以上的梯度前景类梯度被稀释网络根本学不到肿瘤特征。解决换用第4章给出的DiceLoss或者交叉熵和Dice按比例混合。如果DiceLoss之后仍然全黑优先检查sigmoid输出分布打印一批预测图的像素均值——如果所有预测值都在0.1以下梯度极有可能还在被背景主导这时可以给前景类在BCE里手动加权重。另外一个有效做法是训练时对图像做随机裁剪只裁包含肿瘤区域的块变相提升前景像素占比。这个trick在小肿瘤数据上非常有效缺点是模型推理时要滑窗处理工程上稍麻烦。5.4 验证集Dice虚高同一患者的切片同时进了训练和验证现象模型在测试集上Dice达到0.9以上换成外部数据或者换一批患者后性能骤降到0.6附近。回头检查发现测试集里有很多切片和训练集来自同一个患者编号只是切片位置不同。原因这就是我在第2章提到的文件名分组问题。TCGA编号里第三段是患者编号同一个患者的多个切片在影像学上高度相似相当于考试时把同一道题的不同小题同时分给了学生和阅卷老师分数自然虚高。解决按患者编号分组划分训练验证集合强制同一个患者的所有切片只能落在训练集或验证集中装便不能跨集出现。import random from pathlib import Path import re img_dir Path(./brain_tumor_dataset/train/imges) ids sorted([p.stem for p in img_dir.glob(*.tif)]) patient_map {} for stem in ids: parts stem.split(_) patient_id _.join(parts[:3]) # 取TCGA_XXX_编号三段作患者ID patient_map.setdefault(patient_id, []).append(stem) patients list(patient_map.keys()) random.seed(42) random.shuffle(patients) split_idx int(len(patients) * 0.85) train_patients set(patients[:split_idx]) val_patients set(patients[split_idx:]) train_ids [s for p in train_patients for s in patient_map[p]] val_ids [s for p in val_patients for s in patient_map[p]] print(len(train_ids), len(val_ids))这段里的parts[:3]取TCGA、来源缩写、患者编号三段拼起来作为患者唯一标识。如果你拿到的数据命名格式不完全一样需要先打印几个文件名手工确认分隔位置。按患者切分的代价是训练验证比例没法精确控制为8比2因为每个患者的切片数不同这是正常的模型最终泛化能力比数据集划分的比例更重要。5.5 show.py报FileNotFoundError脚本路径写死了现象按项目说明运行python show.py立刻报错找不到某个tif文件或者在当前目录找不到生成的图片。原因脚本内部把路径写成了相对当前工作目录的位置。如果你在项目根目录下执行而数据集文件夹放在另一个名字的目录里路径就断了。另一个常见情况是脚本生成的图片名没有带输出目录前缀运行后图片被写到了别的盘符的默认目录里看起来像没生成。解决运行前先花一分钟读脚本头部找到路径相关的赋值行。常见做法是改成从环境变量或命令行参数读取路径但原项目脚本不一定这么写。我通常的做法是用os.chdir切到数据集所在目录再执行或者在脚本里把两个目录变量改成绝对路径。改完路径后顺手在脚本里加两行打印输出一下读取到的样本数和目标图片的绝对保存路径避免出错后还得在磁盘里翻找。6. 验证分割质量的三个动作宁可多看一张图也不多信一个指标模型训练完验证工作不能只停留在打印那一个Dice数字上。我的习惯是固定做三个动作每个动作都从不同角度审查模型输出。第一个动作是算指标Dice和IOU一个都不能少第二个动作是画预测叠加图把原图、GT、模型预测并排摆出来第三个动作是抽查失败样例把Dice最低的十张图挑出来看。三个动作跑完模型是否可用心里才有底。import torch import numpy as np import matplotlib.pyplot as plt from PIL import Image model.eval() with torch.no_grad(): pred_logits model(img_tensor.cuda()) pred_mask (torch.sigmoid(pred_logits).cpu().numpy()[0, 0] 0.5).astype(np.uint8) fig, ax plt.subplots(1, 3, figsize(15, 5)) ax[0].imshow(img_arr, cmapgray); ax[0].set_title(origin) ax[1].imshow(mask_arr, cmapgray); ax[1].set_title(GT) ax[2].imshow(pred_mask, cmapgray); ax[2].set_title(pred) plt.savefig(fpred_{stem}.png, bbox_inchestight)第一眼先看预测区域在不在原图上肿瘤对应的位置位置对不对比形状像不像更重要。很多模型的失败是系统性偏移比如总是低估肿瘤的上边界这种错误在Dice上只表现为零点零几的差距但医生看片时一眼就发现不对。第二眼看边界粗糙度医学分割中边界锯齿和孔洞是常见问题如果预测图内部有大片空洞就需要在损失函数里加一项平滑惩罚或者训练后做形态学闭运算。第三眼对比GT与预测的面积差如果模型系统性高估肿瘤面积可能是训练数据里mask标注本身就偏大这时候调整阈值不如回头检查数据集标注质量。从那次虚高Dice翻车之后我给自己定了一条规矩每换一个新数据集第一件事永远是跑一遍第3章的批量可视化脚本第二件事是统计像素分布和患者分组这两件事没做完绝不开训练。这套流程看似繁琐但替我省下过至少三次从头重训的时间成本。数据集的坑藏在文件名和像素值里指标不会告诉你问题在哪图会。希望这份数据和这套验证习惯能帮你少走一段弯路。本文还有配套的精品资源点击获取
返回列表