ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

医学图像分割实战:甲状腺结节超声数据集与Unet训练全流程

医学图像分割实战:甲状腺结节超声数据集与Unet训练全流程 简介面向甲状腺结节区域语义分割任务的医学图像数据集专供医学影像分析研究者、算法工程师及相关专业学生使用可支撑U-Net、SwinUNet、TransUNet等分割模型的训练与效果验证。数据按背景与甲状腺结节两类划分包含约3500张图像及对应像素级标签训练集约2800张、验证集约600张均已放入独立images与masks目录配合classes文件可直接开展训练和评估。资源包共2000个文件以jpg格式的原图与mask为主另含1个Python可视化脚本可随机抽取样本自动绘制原图、GT以及GT在原图上的蒙版结果便于快速检查标注质量、定位错标样本。压缩后整体约167.09MB目前已有105人学习适合作为甲状腺结节分割、医学图像预处理以及不同分割网络对比实验的基准数据集。1. 甲状腺结节分割数据集不是所有医图数据都能直接喂进网络做医学图像分割的人最容易踩的第一个坑是数据集和标签都在但标签到底是怎么编码的、训练集验证集怎么分、mask为什么进网络就全黑全凭猜。这个甲状腺结节区域语义分割数据集把该准备的都准备了——约 3500 张超声图像和对应的标注图已经按 2800 张左右训练集、600 张左右验证集划分好2 个类别背景和甲状腺结节像素值标签参考 classes 文件另外还配了一个可视化脚本随机抽一张图就能同时看到原图、GT、GT 蒙版叠加图。对刚入门医学图像分割的新手来说它意味着“数据整理”这一步几乎不用做直接把精力放在网络训练和调参上对已经跑过几个模型的老手来说它是验证 Unet、SwinUnet 这类分割网络时一个干净的起点。一句话这份资源解决的是医图分割里最磨人的数据准备问题。2. 数据集结构与标签体系看懂 2 类别分割的编码规则2.1 目录结构train/val 划分已经替你完成了一件琐事医图分割数据集里最容易乱的不是网络代码而是文件组织方式。有些公开数据集把所有图片丢在一个文件夹里还得自己写脚本按文件名前缀分训练集和验证集有些数据集的 mask 和 image 不重名对齐要花半天。这份数据集的结构很直观打开之后大致是data_root/ ├── train/ │ ├── images/ # 训练原图jpg │ └── masks/ # 训练标签png ├── val/ │ ├── images/ # 验证原图jpg │ └── masks/ # 验证标签png └── classes.txt # 类别与像素值的对应说明训练集 images 和 masks 一一对应验证集同理。拿到手第一件事我一般会跑一遍文件数量统计确认两边数量是否对得上也顺便看一下文件命名规律# 统计 images 和 masks 数量并检查首尾文件名 find train/images -type f | wc -l find train/masks -type f | wc -l find val/images -type f | wc -l find val/masks -type f | wc -l ls train/images | head -5 ls train/masks | head -5这段命令的意义很简单先确认数量一致。只要 images 和 masks 数量不相等后面所有训练都是白跑轻则漏样本重则 index 错位导致标签和图对不上。实际踩坑时我发现有些数据集 jpg 和 png 数量差一两张就是因为原图为 RGB 三通道图片、标注图里有几张损坏肉眼根本看不出来。所以我把“数量对齐”当作不可跳过的第一步。2.2 标签编码背景和结节在像素层面怎么区分语义分割本质上是对图像里每个像素做分类。2 类别分割的意思是每个像素只能属于背景或者甲状腺结节标签图是一个单通道的整数图而不是三通道的彩色图。和做目标检测的框标注不同这里的标签是像素级的掩膜读进网络时要保持整数类型不能当普通图像做归一化。classes.txt 的作用就是告诉使用者背景的像素值是多少结节的像素值是多少。我拿到数据集后习惯用一小段代码把 mask 的像素值分布打出来from PIL import Image import numpy as np mask_path train/masks/0386.jpg.replace(.jpg, .png) # 按实际文件名调整 mask np.array(Image.open(mask_path)) print(mask 尺寸:, mask.shape) print(像素值分布:, np.unique(mask))这段代码做的事情是用 PIL 读入 mask转成 numpy 数组然后打印尺寸和所有出现过的像素值。输出通常会类似mask 尺寸: (H, W)和像素值分布: [0 255]也可能是[0 1]具体以 classes.txt 为准。逻辑上背景像素值为 0结节像素值为 255 或 1这两种约定都很常见关键是后面训练时要把 255 统一映射成类别下标否则交叉熵损失函数会认为你有 256 个类别。提示千万别用 OpenCV 的cv2.imread直接读 png 标签它默认读成三通道 BGR会把类别图变成彩色图。用 PIL 读再转 numpy 是最稳的。2.3 样本不平衡甲状腺结节通常很小mask 面积占比要先统计甲状腺结节分割一个很现实的特性是结节在整幅超声图像里往往只占很小一块区域。这就带来语义分割里典型的样本不平衡问题——如果背景像素占了 98%模型从头到尾预测全背景准确率都可能超过 95%但实际一点用都没有。我拿到任何分割数据集第一件事不是急着写训练脚本而是统计每张 mask 里目标区域的像素占比。最简单的方式如下import numpy as np from PIL import Image import glob mask_list glob.glob(train/masks/*.png) ratios [] for mask_path in mask_list: mask np.array(Image.open(mask_path)) # 假设结节像素值不为 0具体以 classes.txt 为准 target_pixels np.sum(mask 0) total_pixels mask.shape[0] * mask.shape[1] ratios.append(target_pixels / total_pixels) ratios np.array(ratios) print(f平均占比: {ratios.mean():.4f}) print(f最小占比: {ratios.min():.4f}, 最大占比: {ratios.max():.4f})参数说明mask 0是把你认为的目标区域筛选出来如果你的 classes.txt 里结节像素值是 255那就直接mask 255如果结节是 1就用mask 1。统计结果如果平均占比低于 0.1说明这是一个典型的小目标分割任务后面损失函数就不能只依赖交叉熵否则训练过程会被背景像素主导dice 曲线会很长时间在低位徘徊。3. 可视化脚本先行让标签在实际训练前“眼见为实”3.1 自带可视化脚本的价值省掉自己写叠加图的步骤这份数据集配套了一个可视化脚本功能是从数据里随机抽一张图把原始图片、GT 图像、以及 GT 蒙版叠加在原图上的效果一并保存到当前目录。看起来是个小功能但对医图分割来说这一步极其重要。我自己的习惯是任何新的分割数据集到手绝不直接开训而是先随机看 10 张到 20 张图。看什么看标签边界是否贴合超声图像里的结节轮廓、标注是不是存在明显偏移、mask 和原图的尺寸是否完全一致。很多数据集你只看文件名和文件夹觉得没问题一叠加就原形毕露有的 mask 整体往右下偏移了几个像素有的标注区域边界明显是粗糙的矩形而不是沿结节边缘画的有的 mask 尺寸被 resize 过和原图不是同一分辨率。这些问题只有在叠加图里才能一眼发现。有的公开数据集不附可视化脚本你自己得写几十行 matplotlib 代码。这份资源直接给了一个省下的是最琐碎的体力活。3.2 跑通脚本并理解叠加原理拿到可视化脚本后直接在项目根目录运行即可。具体命令取决于脚本名一般类似python visualize.py运行后当前目录下会多出三张图命名大致是origin.jpg、gt.jpg、overlay.jpg分别对应原始超声图、GT 掩膜图、GT 蒙版叠加图。如果你手上这份数据的脚本命名不同打开看一眼输出逻辑就知道怎么调。为了不把它当黑匣子用我下面给一份等价的叠加逻辑 Python 代码方便你理解脚本到底做了什么也方便你后续改成同时叠加多张图import numpy as np from PIL import Image image Image.open(train/images/0386.jpg).convert(RGB) mask Image.open(train/masks/0386.png).convert(L) image_np np.array(image) mask_np np.array(mask) # 生成一个半透明的红色蒙版只覆盖目标区域 overlay image_np.copy() overlay[..., 0] np.where(mask_np 0, 255, overlay[..., 0]) # 红色通道 overlay[..., 1] np.where(mask_np 0, 0, overlay[..., 1]) # 绿色通道 overlay[..., 2] np.where(mask_np 0, 0, overlay[..., 2]) # 蓝色通道 # 透明度混合alpha 控制蒙版透明度0.5 是比较常见的取值 alpha 0.5 blended (overlay * alpha image_np * (1 - alpha)).astype(np.uint8) Image.fromarray(blended).save(overlay_preview.png)逻辑说明先把原图转成 RGB 数组把 mask 转成灰度数组并保持单通道。然后对原图的红色通道做条件赋值——凡是 mask 大于 0 的位置红色通道直接拉到 255绿色和蓝色通道压到 0这样目标区域就会呈现红色。最后用alpha做一次加权混合让红色半透明地盖在原图上而不至于完全遮住底下的超声纹理。参数说明alpha0.5是通用做法适合大多数场景。如果标签边界特别细想看清楚边界和原图的贴合程度我会把 alpha 调到 0.3蒙版更透明如果想看整体覆盖范围alpha 调到 0.7 更直观。mask_np 0这个条件依赖你的像素值约定如果 classes.txt 里结节像素值是 255 0没问题如果背景是 0、结节是 1同样没问题但如果你要区分多个前景类别就得改成mask_np 1这样的精确匹配否则会把所有非背景都当成同一个目标。3.3 原图是 jpg 而 mask 是 png格式不一致的隐患超声原图大多是医院系统导出的 jpg或者是采集设备保存的 jpgmask 则几乎统一用 png。原因很简单jpg 是有损压缩存储边界信息会出现伪影而语义分割恰恰对边界敏感png 是无损的每个像素的值都能精确还原。所以这份数据集的 mask 用 png是正确的处理方式。但我见过不少翻车案例有人训练脚本里写Image.open(mask_path)忘了转格式或者用 OpenCV 的imread读 png 时还加了一路cv2.COLOR_BGR2GRAY结果类别像素值被改动。还有一个比较隐蔽的问题jpg 原图是(H, W, 3)mask 是(H, W)在数据加载时如果不注意就会在torch.stack或np.concatenate时报维度错误。注意批量训练前用脚本统一检查 mask 是否全部为单通道、尺寸是否一致。这一步检查 10 秒不到能避免后面几千次迭代里出现“loss 变成 NaN”或者“验证集 dice 一直为 0”这种让人查半天的问题。4. 把数据集送进 Unet训练流程与参数落地4.1 为什么用 Unet 做基线医图分割的常规选型逻辑拿到这份甲状腺结节数据集后第一个要跑通的模型我建议选 Unet 而不是直接上 SwinUnet 或者 TransUnet。原因有两层。第一Unet 的结构天然适合医学图像分割。它用编码器逐级下采样来扩大感受野、提取多尺度特征再用解码器逐步恢复分辨率最关键的是引入了编码器和解码器之间的跳跃连接把浅层细节信息直接拼到深层特征上。甲状腺结节在超声图像里边界往往模糊、和周围组织灰度接近浅层的边缘信息和深层的语义信息都很重要Unet 的跳跃连接正好两者兼顾。第二基线模型的作用是验证数据集没问题。你直接上一个 Transformer 结构训练时间更长、超参数更多一旦结果不理想你很难判断是数据的问题还是模型的问题。先在 Unet 上跑通一轮dice 有一个合理基线再换改进结构才有意义。配套公开专栏里就有 Unet、SwinUnet、TransUnet 的改进参考但那是后面的事不是第一步。4.2 数据加载与预处理三个原则把数据集写进一个标准 PyTorch Dataset有几点必须注意。下面给一份我自己常用的加载代码import os from PIL import Image import torch from torch.utils.data import Dataset from torchvision import transforms class ThyroidDataset(Dataset): def __init__(self, images_dir, masks_dir, img_size(512, 512)): self.images_dir images_dir self.masks_dir masks_dir self.img_size img_size self.image_files sorted(os.listdir(images_dir)) def __len__(self): return len(self.image_files) def __getitem__(self, idx): img_name self.image_files[idx] img_path os.path.join(self.images_dir, img_name) mask_name os.path.splitext(img_name)[0] .png mask_path os.path.join(self.masks_dir, mask_name) # 原图转 RGBmask 转灰度单通道 image Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) # 同一套 resize 参数保证空间对齐 image image.resize(self.img_size, Image.BILINEAR) mask mask.resize(self.img_size, Image.NEAREST) image transforms.ToTensor()(image) mask torch.from_numpy(np.array(mask)).long() return image, mask逻辑说明__getitem__里最关键的是同一对图、mask 使用完全相同的 resize 尺寸否则训练时标签和原图错位。原图用双线性插值因为要平滑地缩放像素值mask 必须用最近邻插值因为插值算法不能改变类别标签的整数值——如果对 mask 也用双线性插值原本是 0 和 255 的边界会被插成 127 之类的中间值类别编码就被污染了。数据增强部分我这里没有写因为很多人第一次跑时翻转增强没同步到 mask 上导致训练过程中标签和图像方向不一致。如果要加翻转正确做法是用同一个随机种子同时处理 image 和 mask或者直接用 torchvision 的v2系列增强接口。第一次训练我建议先不加增强跑通流程后再逐步加上。4.3 损失函数与评估指标交叉熵只是起点Dice 才是医图的标尺甲状腺结节这类小目标任务纯交叉熵会有比较明显的短板。假设一张图 98% 都是背景模型只需要把每个像素都预测成背景交叉熵 loss 就能很低但实际一个结节都没分割出来。所以医图分割领域更常看 Dice loss 或者交叉熵 Dice 的混合损失。我通常先跑一个纯交叉熵的基线然后把验证集指标换成 Dice 和 IoU。评估函数的实现并不复杂def dice_coefficient(pred, target, smooth1e-6): pred (pred 0.5).float() intersection (pred * target).sum() return (2.0 * intersection smooth) / (pred.sum() target.sum() smooth) def iou_score(pred, target, smooth1e-6): pred (pred 0.5).float() intersection (pred * target).sum() union pred.sum() target.sum() - intersection return (intersection smooth) / (union smooth)逻辑说明pred是模型输出的概率图先做二值化 0.5表示预测为结节target是标签图里面结节像素为 1。Dice 的分子是二倍交集分母是预测区域面积加真实区域面积IoU 的分母是并集面积。加smooth是为了防止预测和标签都是空时除零。参数说明smooth一般取1e-6到1e-3。对于小目标分割我习惯取1e-6因为它对微小差异更敏感能暴露模型“其实只分割出了几个像素”这种问题如果你发现训练过程中 dice 抖动剧烈可以适当加大 smooth这只是数值稳定手段不改变指标语义。pred 0.5这种硬阈值适合大多数场景但如果结节过小模型输出的概率整体偏低我会在验证后处理阶段用 0.3 或者 0.4 再测一轮看哪一阈值下 IoU 更高。这里必须强调一句验证集只看 loss 是远远不够的。由于类别严重不平衡交叉熵 loss 下降曲线会给人一种“模型在变好”的错觉而 dice 和 IoU 才是真正反映分割质量的指标。每轮训练结束把训练集 dice、验证集 dice、验证集 IoU 三个数字打出来比盯 loss 曲线可靠得多。5. 避坑指南医学图像分割数据集的五个高频翻车现场数据本身划分好了、附了 classes.txt 和可视化脚本并不代表后续训练顺风顺水。下面这五个坑是我在做医图分割时反复遇到、也看到身边人反复踩的按“现象 → 原因 → 解决”写清楚。5.1 标签图读出来是全黑的训练 loss 完全不动现象训练循环正常跑但 loss 非常低或者完全不下降可视化预测结果时发现模型把所有像素都预测成背景验证集 dice 为 0。原因mask 读取方式不对。很多人用cv2.imread读 png它返回三通道 BGR 数组再转灰度时如果用的参数不对或者直接参考了 jpg 的读取逻辑类别像素值会被压缩。更常见的是 mask 本身没问题但代码里把 mask 归一化到了 0 到 1标签变成了 0.0039 这样的浮点值交叉熵收到非整数标签直接困惑。解决回到 2.2 节的方法用 PIL 读取 mask 并打印np.unique确认像素值确实是 classes.txt 里约定的那几个整数。然后在使用torch.from_numpy(...).long()时注意不要在前面做任何标准化操作。标签是整数标签不是图像不需要除以 255。5.2 验证集 dice 接近 0.95但预测图上一片空白现象验证集 dice 数字很好看但把预测结果可视化出来发现模型输出的整张图全黑一个结节都没画出来。原因dice 公式在样本极度不平衡时会出现虚高。如果真实标签里 99% 是背景模型预测全背景交集集中在背景区域dice 很容易算出来一个很高的值因为 dice 的分子是整个图像的交集像素而不是只统计目标区域。解决不要单独看 dice把 IoU 也一并打印出来同时在可视化时把预测蒙版叠加到原图上亲眼确认。更稳妥的做法是除了全局 dice额外计算“只针对结节这一类的 dice”背景类不参与目标评估。这个小改动往往会让“看似完美的模型”现出原形。我有一次就被这种虚高骗了整整一周后来养成了每次训练结束必出叠加图的习惯。5.3 叠加可视化显示 GT 和原图明显不对齐现象用自带可视化脚本或者自己写的叠加代码看图发现红色蒙版整体偏移或者蒙版边界明显和超声图像里的结节轮廓不在同一位置。原因最常见的情况是 mask 的尺寸和原图不一致。原图可能是 512×512mask 被某一步 resize 成了 480×480叠加时直接按数组索引赋值就会造成偏移。另外mask 如果是三通道彩色图但没有先转成灰度就做条件赋值叠加效果也会看起来错位错色。解决先打印image.shape和mask.shape确认两者严格一致。如果不一致统一 resize 到相同尺寸mask 用最近邻插值。再次强调可视化脚本跑出来只是第一步真正训练时 Dataset 里也要保证尺寸一致最好在__getitem__里加一行断言尺寸不匹配直接报错别让错误悄悄传进训练循环。5.4 一训练就 OOMbatch size 降到 2 还是爆显存现象模型和数据都加载好后一启动训练CUDA out of memory怎么调 batch size 都没用。原因超声原图往往分辨率不低有人直接用原图分辨率训练比如 1024×1024显存很容易被占满。再加上 Unet 这种编码器-解码器结构中间特征图本身就大又叠加上跳跃连接显存消耗是实际图像尺寸的数倍。解决先把输入尺寸统一到 512×512这个分辨率对甲状腺结节分割通常够了因为结节在超声图像里的相对尺寸并不算小。batch size 从 2 或者 4 开始逐步往上加不需要一上来就追求大批量。还有一个不牺牲速度的做法是把验证循环也放进torch.no_grad()上下文并用model.eval()否则保留梯度图同样会吃掉大量显存。5.5 增强一做训练指标波动剧烈连验证集结果都忽高忽低现象加入随机翻转、随机旋转等数据增强后训练 loss 忽上忽下dice 曲线像过山车甚至出现“模型训练到一半突然退化”的假象。原因绝大多数情况是增强操作只作用到了原图没有同步作用到 mask。比如原图做了水平翻转mask 没翻那每次迭代模型都在学习一个错位的标签等于训练数据全是噪声。另一种可能旋转角度过大标签边界被最近邻插值搞得支离破碎小目标被严重破坏。解决给原图做增强的 transform必须给 mask 做完全一致的操作。用同一个随机种子生成一组参数或者直接用支持图像和掩膜同步处理的数据增强库比如 imgaug、albumentations。旋转角度建议限制在 ±15 度以内太大会把超声图像的纹理特征都旋转乱同时把结节这种小目标搅碎。6. 固定种子复验法训练后怎么不靠“玄学”判断模型水平很多人训练结束后只看一个测试集 dice 数字然后就开始调参。我的做法是在训练完成后固定随机种子从验证集里挑几张有代表性的图重新走一遍前向推理把原始图、GT、预测蒙版三张图并排保存下来亲眼确认边界和整体空间分布。6.1 五张图递进式验证流程下面是一段可以直接用的推理验证代码import numpy as np import torch from PIL import Image import torchvision.transforms as transforms def validate_one_sample(model, img_path, mask_path, device, img_size(512, 512), save_pathresult.png): model.eval() # 读原图和 mask统一尺寸 image Image.open(img_path).convert(RGB).resize(img_size, Image.BILINEAR) mask Image.open(mask_path).convert(L).resize(img_size, Image.NEAREST) # 预处理原图并前向推理 transform transforms.Compose([ transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(image).unsqueeze(0).to(device) with torch.no_grad(): output model(input_tensor) prob torch.sigmoid(output)[0, 0].cpu().numpy() pred (prob 0.5).astype(np.uint8) * 255 gt np.array(mask) # 三图横向拼接原图、GT、预测 image_np np.array(image) pred_rgb np.stack([pred, pred, pred], axis-1).astype(np.uint8) combined np.hstack([image_np, pred_rgb, np.stack([gt, gt, gt], axis-1).astype(np.uint8)]) Image.fromarray(combined).save(save_path) print(f已保存: {save_path})逻辑说明这一小段代码把所有关键环节串起来了——固定图片尺寸、标准化、单张前向推理、sigmoid 转概率、阈值二值化、GT 转成三通道方便并排显示。保存的三联图左边是原图中间是模型预测右边是 GT。任何分割质量问题在这张图里都能直接看到预测区域缺失、多余噪声、边界错位。参数说明Normalize的均值和标准差用的是 ImageNet 标准值。如果你的训练脚本里用了不同的均值标准差这里必须改成同样的参数否则推理结果和训练时看到的指标会严重不一致。prob 0.5是硬阈值如果你训练时发现模型输出概率整体偏低可以改成 0.3 再试一轮看预测区域是否更贴近 GT 边界。6.2 检查三件事边界、小目标、孤立点拿到这张三联图只看三个地方。第一预测边界是否平滑地贴合 GT 边界。如果预测边界参差不齐说明模型对边缘的感知不够可以尝试加深解码器或者换更精细的损失。第二小结节有没有被漏掉。甲状腺结节有时候是一串小病灶模型容易只分割出最大的一块。第三预测图里有没有孤立的噪点。超声图像里噪声多模型如果过拟合了纹理特征会把一些暗区也识别成结节这时要在后处理里做最小连通域过滤只保留面积大于某个阈值的区域。我用这个流程复验过不少模型最典型的结论是测试集整体 dice 高不代表单张小目标结节图效果就好抽样细看之后往往会发现模型的真实水平比数字低一截。从那以后我每次训练完都强制走一遍“固定种子、抽样五张、三联图留档”这个流程不做这步就不算训练完成。也建议你从这份数据集开始就养成这个习惯分阶段把验证图留好比任何指标都更能告诉你模型到底行不行。希望帮到你。本文还有配套的精品资源点击获取
返回列表