ARTICLE DETAIL

资讯详情

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

甲状腺结节超声图像语义分割数据集与U-Net训练实战

甲状腺结节超声图像语义分割数据集与U-Net训练实战 简介面向医学影像分析初学者、科研人员及算法工程师提供甲状腺结节区域图像语义分割数据集包含约3500张超声等医学图像及对应像素级标签共背景与甲状腺结节两个类别已按约2800张训练、600张验证划分各含images图片目录与masks模板目录结构清晰可直接用于训练和评估Unet、SwinUnet、TransUnet等分割模型。压缩包共2000个文件以jpg图像为主1999张另含1个Python可视化脚本整体约167MB便于快速下载与解压使用。已有105人学习。除完整标注外还提供可视化脚本可随机抽取一张图像将原始图片、GT标签及GT蒙版叠加效果同屏展示并保存方便核对标注质量、排查数据问题配合作者博客中的医学图像分割网络及改进专栏可系统开展分割实验、算法对比与论文复现适合医学影像分析入门及进阶实践也可为相关课题与工程落地提供数据支撑。1. 医学图像分割数据集甲状腺结节区域语义分割解决的是边界问题甲状腺结节的超声影像常常是灰蒙蒙一片里隐约有个低回声区边界比医生肉眼看到的还模糊。你手里这份「医学图像分割数据集甲状腺结节区域图像语义分割数据集约3500张数据和标签」要做的事情只有一件让模型逐像素判断每个点是背景还是结节把结节的轮廓从原图里完整抠出来。这个数据规模不大不小正好能跑通一个语义分割模型也能作为超声智能诊断里很有价值的基线数据。适合医学图像分割方向的算法工程师、研究生也适合刚想把语义分割流程落到真实医疗图像上的团队。2. 甲状腺结节语义分割数据集先把标签格式和目录结构读懂2.1 语义分割和实例分割、目标检测的区别结节场景为什么不用YOLO有的同事一听到「分割」就想到YOLO。其实语义分割和实例分割是两回事YOLO26里的实例分割输出的是「每个独立实例的mask」会把两张B超图里挨得很近的结节分别编号而语义分割输出的是和原图一样大小的类别图所有结节都算前景像素不做个体区分。对甲状腺结节来说医生要的是边界轮廓的精确勾画不是「第几个结节」所以用语义分割就够而且比实例分割更容易训练、指标也更直接。目标检测则只给一个矩形框或旋转框无法描述结节的不规则形态。下面这张表是我经常贴在团队白板上的对比。任务类型输出典型模型结节场景的适配度目标检测边界框Faster R-CNN、YOLO只能定位不能勾轮廓实例分割每个实例的maskMask R-CNN、YOLO系列区分个体训练成本高语义分割整图逐像素类别U-Net、DeepLabV3直接输出轮廓区域最匹配所以不管你手里的数据是哪来的先把任务定成语义分割。这个选择不是「最新」的问题是「够用且稳」的问题。甲状腺结节的自然边界本身就模糊标注员画线时也会有主观差异语义分割模型对边界像素做逐像素分类比硬抠每个实例更符合临床逻辑。2.2 3500张数据和标签的目录组织images与masks怎么对应拿到一份约3500张的图像和标签组合第一件事是梳理目录。最常见的交付结构是这样thyroid_dataset/ ├── images/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... ├── masks/ │ ├── case_0001.png │ ├── case_0002.png │ └── ... └── meta.jsonimages 是原始超声图可能是灰度png或dicom转出的8位图masks 是标注掩码文件名和图像一一对应。mask存储有两种坑要当场确认背景是0、结节是255或者背景是0、结节是1。前者符合人眼直观后者符合训练框架的类别索引。很多开源代码里np.array(mask) / 255.0一把梭如果mask里结节本来等于1除完255就变成0.0039训练几乎不收敛。如果数据不是这种现成目录而是COCO JSON或VOC XML需要先做一个语义分割数据集制作转换把多边形标注画回全黑画布生成mask。这里要留意标注是轮廓还是区域COCO JSON里的segmentation可能是多边形点序列也可能是RLE编码。多边形必须通过cv2.fillPoly填充RLE直接解码即可。顺序不能反我见过好几份数据用cv2.polylines只画了线结果标签宽一个像素Dice上不去。meta.json 一般记录图像尺寸、来源设备、结节类型等。不要只盯着images和masks把meta里和图像采集相关的字段读一遍尤其是像素尺寸后面切patch、改输入分辨率时要用。2.3 标签质量检查训练前先做像素级对齐与类别统计3500张看着不多靠人眼一张张看是不现实的。我一般会先跑一个自动化检查脚本把三件事一次做完核对mask文件名存在性、检查图像与mask尺寸是否一致、检查mask像素取值是否只有预设类别。import os import numpy as np from PIL import Image data_root thyroid_dataset image_dir os.path.join(data_root, images) mask_dir os.path.join(data_root, masks) expected_values {0, 255} # 根据实际数据集改成 {0,1} problems [] for name in sorted(os.listdir(image_dir)): img_path os.path.join(image_dir, name) mask_path os.path.join(mask_dir, name) if not os.path.exists(mask_path): problems.append((name, mask missing)) continue img Image.open(img_path) mask Image.open(mask_path) if img.size ! mask.size: problems.append((name, fsize mismatch: {img.size} vs {mask.size})) continue arr np.array(mask) actual set(np.unique(arr).tolist()) if not actual.issubset(expected_values): problems.append((name, funexpected values: {actual - expected_values})) print(checked:, len(os.listdir(image_dir))) print(problems:, len(problems)) for p in problems[:100]: print(p)逻辑说明脚本以图像文件名为基准去找同名mask。如果数据集里mask文件名加了_mask后缀把mask_path的拼接改成name.replace(.png, _mask.png)即可。像素值检查里出现0和255之外的数值大概率是标注软件做了抗锯齿或者导出时误把伪彩色图直接保存。这类图如果不处理交叉熵会多出一个类别模型可能在边界上产生「第三类」幻觉。检查通过之后再做一次统计计算每张mask里前景像素占比。甲状腺结节在整图里通常只占很小一块很多图甚至不到1%。这个数字直接决定要不要换损失函数我后面在样本不平衡那节再展开。这里你先记下结论如果3000多张图里有相当一部分前景占比低于5%常规纯交叉熵几乎必翻车。3. 用3500张甲状腺结节数据训练U-Net数据加载与最小训练配置3.1 U-Net是基线为什么不是DeepLabV3也不是YOLO在超声甲状腺结节这个任务上我默认第一个语义分割模型就是U-Net。原因不是它「炫」而是它结构对称、参数量适中、3500张数据能喂饱它。U-Net的编码器下采样四次解码器再逐层上采样融合跳层连接对小目标保留能力强。DeepLabV3用ASPP扩大感受野对多尺度目标更友好但训练起来对学习率和增强策略更敏感同样是第一次跑U-Net翻车概率明显更低。有人会问YOLO不是也支持实例分割吗能不能直接拿来做YOLO的强项是局部检测加实例分割输出逻辑是「框里有什么物体再抠出每个物体」对边界平滑度、亚像素精度并不擅长。如果真想处理数据集用于YOLO训练得把语义分割mask转成多边形实例标注这一转就会丢失边界精度。而甲状腺结节语义分割要求的是区域级像素分类所以还是回到专门的语义分割模型比较稳。3.2 数据加载器图像与mask必须同步读取和同步变换用PyTorch搭加载器时最重要的原则图像和mask走同一条数据增强流水线且mask只能做几何变换、不能做改变像素值的颜色变换。下面这个类是典型写法import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import albumentations as A class ThyroidSegDataset(Dataset): def __init__(self, image_dir, mask_dir, file_names, transformNone): self.image_dir image_dir self.mask_dir mask_dir self.file_names file_names self.transform transform def __len__(self): return len(self.file_names) def __getitem__(self, idx): name self.file_names[idx] image np.array(Image.open(f{self.image_dir}/{name}).convert(L)) mask np.array(Image.open(f{self.mask_dir}/{name}).convert(L)) mask (mask 127).astype(np.uint8) # 255 - 1 if self.transform is not None: aug self.transform(imageimage, maskmask) image aug[image] mask aug[mask] image torch.from_numpy(image).float().unsqueeze(0) / 255.0 mask torch.from_numpy(mask).long() return image, mask参数说明convert(L)确保超声图和mask都是单通道避免png带alpha通道时读取成四通道。mask (mask 127)把255硬编码转成1这是兼容「0/255型标签」最快的方式但前面如果确认数据集就是0/1标签这行可以去掉。unsqueeze(0)把 [H, W] 变成 [1, H, W] 作为灰度单通道输入。最后 mask 是long()供CrossEntropyLoss使用如果用BCEWithLogitsLoss则要保留float并加一个通道维度。3.3 最小训练配置DiceLoss组合、AdamW、batch size和输入尺寸加载器就绪之后训练脚本不用写得花哨配置稳定就行。下面这个训练循环是我常用的最小模板import torch from torch.nn import BCEWithLogitsLoss from torch.utils.data import DataLoader # 模型U-Net二分类输出1通道概率图 model UNet(in_channels1, num_classes1).cuda() optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max120) def dice_loss(pred, mask): # pred和mask都是0~1范围 smooth 1.0 pred torch.sigmoid(pred) intersection (pred * mask).sum() return 1 - (2 * intersection smooth) / (pred.sum() mask.sum() smooth) bce BCEWithLogitsLoss() for epoch in range(120): model.train() for image, mask in DataLoader(dataset, batch_size8, shuffleTrue, num_workers4): image, mask image.cuda(), mask.cuda() pred model(image) # [B,1,H,W] loss 0.5 * bce(pred, mask.unsqueeze(1).float()) 0.5 * dice_loss(pred, mask.unsqueeze(1).float()) optimizer.zero_grad() loss.backward() optimizer.step() scheduler.step()关键参数我习惯这么定输入分辨率512×512batch size 812GB以上显存刚好跑得动如果显存吃紧把batch降到4或输入切成384。学习率1e-4对U-Net很稳用1e-3容易在头几十步就把loss推到NaN。损失函数用0.5×BCE加0.5×Dice组合比单用交叉熵收敛更快这是因为结节前景占比太小BCE会倾向把所有像素预测成背景Dice则直接惩罚前景区域的覆盖率。AdamW的weight_decay设1e-5就够太大反而让分割边界的细节被正则掉。4. 预处理与数据增强让3500张图发挥更大的分割性能4.1 超声图像的预处理归一化、窗宽窗位与输入尺寸B超图像和自然图像不一样没有RGB三通道它是灰度强度图而且不同设备之间的亮度分布差异很大。常见预处理三条转灰度、min-max归一化、统一尺寸。不要直接套 ImageNet 的 mean/std那是针对自然图像统计的用在超声上会压缩低回声区的对比度。我一般会把图像先裁剪掉两侧的黑色无信号区再缩放到512×512。如果原图是dicom格式还要考虑窗宽窗位超声图像显示的亮度范围来自设备的增益曲线直接读dicom原始像素值经常会过暗或过曝。简单做法是截取5%和95%分位之间的像素做clip再做min-max归一化这样能消除大部分采集差异。判断预处理有没有做过头就看归一化后图像里结节区域和周围腺体组织的对比度是否比原图更明显如果变得更平说明clip范围取大了。4.2 数据增强翻转、旋转、弹性形变以及mask插值必须nearest数据增强对3500张小数据集是决定性的。我不建议直接只做水平翻转甲状腺结节的位置和形态没有固定朝向旋转和弹性形变更有效。albumentations配置如下import albumentations as A import cv2 train_transform A.Compose([ A.RandomResizedCrop(size(512, 512), scale(0.8, 1.0), ratio(0.9, 1.1)), A.Rotate(limit30, border_modecv2.BORDER_CONSTANT, value0, mask_value0), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.2), A.ElasticTransform(alpha60, sigma4, alpha_affine20, p0.3), A.RandomBrightnessContrast(p0.2), ])逻辑说明RandomResizedCrop让模型看到不同比例的目标提升对结节大小的鲁棒性Rotate的value0表示旋转后空区域填0mask_value0表示mask空区域填背景两者必须保持一致。ElasticTransform是超声分割的利器因为B超图像本身就有声学伪影导致的微小形变代价是过强的形变会改变解剖结构alpha不能太大。最后一项只改图像亮度、不改mask这是正确的颜色增强不会改变标签。这里最容易翻车的插值问题给mask做resize或rotate时如果用了线性插值或三次插值会在边界产生小数灰度值训练时又得用阈值去切切不好就把边界搞成锯齿。albumentations的几何变换会自动对mask用nearest插值但如果你自己写cv2.resize(mask, ...)必须显式指定interpolationcv2.INTER_NEAREST。4.3 样本不平衡前景占比不足5%时的损失函数选择统计完3500张mask的前景占比后我见过不少图的前景只有2%~3%。这时候交叉熵会让模型快速收敛到「全部预测为背景」因为准确率也能到97%。两个有效手段一是前面已经用的Dice损失Dice指出「你的预测区域和真实区域重合度」二是Focal Loss它让困难像素边界、小目标贡献更多梯度。Focal Loss不用记完整公式它就是在交叉熵基础上乘一个调制系数(1-p)^gamma。gamma通常取2。当模型对某个像素预测得很自信但错了这个系数会放大它的惩罚当模型已经预测对了惩罚自动降低。对甲状腺结节来说Focal Loss配合Dice是个强组合但要注意gamma太大容易让训练不稳我从gamma1开始反而不行再调2。样本不平衡还影响验证集采样。按文件索引随机划分时如果某些mask全是空白背景模型什么都不预测Dice也会很高。所以我一般会在划分时先过滤掉前景占比小于0.1%的样本把它们放到「人工复核」列表避免污染训练和验证。5. 甲状腺结节分割训练中的5个高频问题踩坑与排查5.1 loss在下降预测mask却全黑现象训练了三五十个epochloss看起来在降但随便拿一张验证图预测输出图几乎全黑偶尔有一个小点。原因最常见的是标签读取时没有把255转成1交叉熵把255当成一个独立类别。模型为了压loss把255和0之间的区分当成了主要矛盾对「真实类别索引1」没有梯度或者DiceLoss里pred.sum() mask.sum()出现数值下溢导致DS始终为负。解决在数据加载器里加入一个断言打印标签的最大值和类别数。把mask (mask 127).astype(np.uint8)提前到所有增强之前并确认DiceLoss的smooth不少于1.0。出现全黑还有一种隐蔽原因模型最后一层用了Softmax后取argmax但输出是单通道时应该用Sigmoid再阈值0.5argmax会永远取0。5.2 训练mIoU高验证集直接崩现象训练集mIoU达到0.85验证集只有0.3且验证loss回升。原因一是验证集也用了一样的随机增强比如RandomResizedCrop每次裁剪位置不同模型永远看不到整张图二是划分数据时用了默认随机种子但mask含空图导致训练验证分布不一致。解决验证集只做固定Resize和归一化任何随机变换都不要进验证流水线划分数据时先在完整图级别固定随机种子再把前景占比极低的样本单独筛掉。我通常会打印两个集合的平均前景占比差异超过一个数量级就说明划分有问题。5.3 Dice很高但边界毛刺严重现象mIoU和Dice都到0.85以上把预测mask叠在原图上看整体位置很准边界却像锯齿有些还越过甲状腺包膜一两毫米。原因交叉熵一类的逐像素损失对每个像素独立计算不关心相邻像素一致性模型虽然把大部分像素分对了但没有学到「平滑轮廓」的先验。解决在损失函数里加一个梯度一致性项或者用专门的边界损失。简单做法是把Dice损失和带拉普拉斯滤波的边界损失组合更工程化的做法是训练后做形态学闭运算。对医学图像一两毫米的边界误差在临床上可能就是手术切除范围的分界线所以我会把「边界平滑度」单独拉出来看而不只看Dice。5.4 batch size 8显存还是OOM现象输入512×512batch size 8一张Titan很快就OOM。原因U-Net在512输入下显存开销非常大尤其编码器第一层通道数从64开始。很多人为了大batch把输入减小到256结果结节在256分辨率下只有几十个像素边界全被抹掉。解决最优先的是开混合精度torch.cuda.amp显存能省30%~40%其次batch size降到4配合梯度累积凑成有效batch 32。不到万不得已不要降输入尺寸因为甲状腺结节的边界信息在高分辨率下才能保留。5.5 部分mask与原图错位模型学偏现象训练过程正常但预测的区域总是偏向图像一侧比如结节实际在右侧预测掩码集中在左侧边缘。原因数据集交付时少数图像在采集或导出时发生了坐标偏移图像和mask并不严格对齐。这种错位样本比例如果超过5%模型就会学习到一个「目标在右侧」的偏置。解决做一次自动配准评估。对每张图提取原图中的高亮区域和mask区域计算质心坐标打印质心偏离超过一定像素的样本。有问题的样本可以直接排除或做刚性配准。我会把这个检查放在第2.3节的脚本里一起跑质心偏移阈值通常设20像素超过就人工复核。6. 验证阶段必须算准的两个指标Dice和mIoU的代码实现6.1 指标对照表Dice、mIoU、PA分别回答什么问题指标回答的问题结节场景的参考价值Dice预测区域与真实区域的重叠率最常用直接对标临床覆盖度mIoU交并比逐类别平均模型对比时更严格PA像素准确率背景占比高时基本无参考价值6.2 Dice和mIoU的PyTorch实现def dice_coef(pred_mask, true_mask, eps1e-6): pred_mask (pred_mask 0.5).float() inter (pred_mask * true_mask).sum() return (2 * inter eps) / (pred_mask.sum() true_mask.sum() eps) def miou(pred_mask, true_mask, num_classes2, eps1e-6): pred_mask (pred_mask 0.5).long() ious [] for c in range(num_classes): inter ((pred_mask c) (true_mask c)).sum() union ((pred_mask c) | (true_mask c)).sum() ious.append(inter.float() / (union.float() eps)) return torch.mean(torch.tensor(ious))逻辑说明Dice和mIoU都要在预测值二值化之后计算不能在sigmoid输出上直接算否则会严重虚高。eps是为了防止分母为0尤其当某张图全部是背景时前景类iou分母为0直接跳过会显得整体指标偏高加一个小常数更稳妥。6.3 模型体检把预测mask叠在原图上看边界指标之外我会在验证集上输出20张Dice最差的样本把预测mask半透明叠加在原图上。对超声图像最常出现的临床问题是把甲状腺旁边的气管阴影误判为结节。只看平均Dice不会发现这个问题但叠加图一眼就能看出来。最后再强调一个习惯千万不要只留训练最好的checkpoint要在验证集上选Dice和mIoU综合最好的那个。我做这类项目的最大教训就是「训练loss最低的模型不一定边界最好」边界表现必须靠可视化和指标一起判断。希望帮到你。本文还有配套的精品资源点击获取
返回列表