ARTICLE DETAIL

资讯详情

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

超声甲状腺结节分割实战:从数据集读取到U-Net训练与评估

超声甲状腺结节分割实战:从数据集读取到U-Net训练与评估 简介这份资源面向医学图像处理方向的研究者、算法工程师及深度学习学习者提供超声波背景下的甲状腺结节分割数据集可用于训练与评估语义分割模型解决医学影像中结节区域自动提取的典型问题。包内共约2000个文件以1999张png图像和1个py脚本为主压缩包大小约333MB其中png涵盖原始超声图像与对应mask标签py为图像分割可视化脚本。数据集按训练集与测试集划分训练集含5831张图片及5831个对应mask测试集含1457张图片及1457个对应mask分辨率覆盖400至1000mask为前景255的二值图像便于直观观察结节轮廓。可视化脚本可随机抽取一张图片将原始图像、GT图像及GT在原图上的蒙板效果一并展示并保存至当前目录方便快速核验标注质量与模型输出。目前已有405人学习下载适合需要真实医学超声数据开展分割实验、复现论文或搭建基线模型的读者使用。1. 超声甲状腺结节分割数据集为什么它比自然图像数据集更难伺候拿到一个超声背景下的甲状腺结节分割数据集第一反应往往是“不就是 U-Net 跑一遍的事”。真上手才发现超声图像和 COCO、VOC 那类自然图像完全不是一个物种。超声成像靠的是回波信号天生带斑点噪声、声影、后方回声增强结节边界经常和周围腺体组织糊在一起灰度对比度低到肉眼都要反复确认。更麻烦的是不同设备、不同增益设置下同一类结节的纹理分布能差出一大截训练集和测试集如果来自不同机器模型泛化会直接翻车。这个数据集要解决的核心问题就一个在强噪声、弱边界、样本量通常不大的条件下把甲状腺结节区域从超声背景里干净地抠出来。它适合两类人——一类是想入门医学图像分割、拿一个真实临床场景练手的算法工程师另一类是做甲状腺辅助诊断、需要分割结果去算结节面积、纵横比、边缘规则的从业者。训练集和测试集分开给出意味着你可以直接做端到端的训练与评估不用自己再划分子集。接下来我会按“数据长什么样 → 怎么读进来 → 怎么训 → 坑在哪 → 怎么验证”的顺序把这条链路走一遍。2. 超声甲状腺结节数据集的结构与读取从文件夹到张量2.1 先搞清楚数据集的目录约定和标注格式医学分割数据集常见的组织方式有两种一种是图像和掩码分文件夹存放文件名一一对应另一种是图像和掩码同目录、靠后缀区分。超声甲状腺结节数据集通常采用前者结构大致如下dataset/ ├── train/ │ ├── images/ │ │ ├── patient001_0.png │ │ └── ... │ └── masks/ │ ├── patient001_0.png │ └── ... └── test/ ├── images/ └── masks/掩码是单通道灰度图结节区域为 255背景为 0。这里第一个容易踩的点是有些数据集掩码里结节是 1 而不是 255直接拿去算损失函数不会报错但可视化时一片黑你会以为标注丢了。读进来第一件事就是打印掩码的唯一值。import numpy as np from PIL import Image mask np.array(Image.open(dataset/train/masks/patient001_0.png)) print(unique values:, np.unique(mask)) print(shape:, mask.shape, dtype:, mask.dtype)逻辑说明np.unique能立刻告诉你掩码里到底有几个灰度级。如果只有[0, 1]后面做二值化时阈值要设成 0而不是 127。参数上Image.open默认按原模式读取灰度图读出来是L模式转成 numpy 后是uint8不需要额外做色彩空间转换。2.2 用 Dataset 和 DataLoader 把超声图像喂进网络PyTorch 生态下最稳的做法是自定义Dataset把图像和掩码同步做几何变换。超声图像分割里随机水平翻转、随机旋转、随机缩放是安全且有效的增强但颜色抖动要慎用因为超声的灰度分布本身携带组织信息乱调亮度对比度可能把结节特征抹掉。import torch from torch.utils.data import Dataset, DataLoader from torchvision import transforms import os from PIL import Image class ThyroidDataset(Dataset): def __init__(self, root, splittrain, img_size256): self.img_dir os.path.join(root, split, images) self.mask_dir os.path.join(root, split, masks) self.names sorted(os.listdir(self.img_dir)) self.img_size img_size # 图像和掩码用相同的几何变换参数 self.img_tf transforms.Compose([ transforms.Resize((img_size, img_size)), transforms.ToTensor(), transforms.Normalize(mean[0.5], std[0.5]), ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name self.names[idx] img Image.open(os.path.join(self.img_dir, name)).convert(L) mask Image.open(os.path.join(self.mask_dir, name)).convert(L) img self.img_tf(img) mask transforms.Resize((self.img_size, self.img_size))( transforms.ToTensor()(mask) ) mask (mask 0.5).float() # 二值化兼容 0/1 和 0/255 两种标注 return img, mask loader DataLoader(ThyroidDataset(dataset), batch_size8, shuffleTrue, num_workers2) imgs, masks next(iter(loader)) print(imgs.shape, masks.shape) # torch.Size([8, 1, 256, 256]) 两次逻辑说明convert(L)强制单通道避免某些 PNG 带 alpha 通道导致维度对不上。Normalize用mean0.5, std0.5把灰度压到[-1, 1]这是医学图像里比较通用的做法比 ImageNet 的三通道均值更合适。掩码用 0.5而不是 0是因为ToTensor()会把 255 映射成 1.0、1 映射成约 0.0039阈值取中间值能同时兼容两种标注习惯。参数上img_size设 256 是超声分割的常见起点原始图像如果更大直接缩到 256 会丢小结节细节可以改成 512 但显存要翻倍。batch_size8在 8GB 显存上跑 U-Net 级别模型比较稳显存富裕可以加到 16。num_workers在 Windows 上如果报错就设 0这是老问题了。提示读数据阶段一定先跑一遍next(iter(loader))看形状和数值范围别等训练跑了两小时才发现掩码全是零。3. 从 U-Net 到损失函数超声结节分割的训练配置怎么定3.1 模型选型为什么 U-Net 仍然是这个任务的基线超声甲状腺结节分割的公开研究里U-Net 及其变体占了绝大多数。原因很直接医学分割样本量小结节形态差异大U-Net 的编码器-解码器加跳跃连接结构能在少量数据上快速收敛跳跃连接把浅层的高分辨率边界信息直接送到解码端对弱边界场景特别关键。相比之下Transformer 类模型如 Swin-UNet 在数据量充足时上限更高但几百张超声图往往喂不饱容易过拟合。我一般会先用标准 U-Net 跑一个基线确认数据管线和损失函数没问题再考虑换注意力模块或残差结构。基线跑不通就上复杂模型等于给自己挖坑。下面是一个可以直接用的 U-Net 定义通道数按超声单通道输入调整。import torch.nn as nn import torch class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.net nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), ) def forward(self, x): return self.net(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base32): super().__init__() self.down1 DoubleConv(in_ch, base) self.down2 DoubleConv(base, base * 2) self.down3 DoubleConv(base * 2, base * 4) self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(base * 4, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.conv3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.conv2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.conv1 DoubleConv(base * 2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): d1 self.down1(x) d2 self.down2(self.pool(d1)) d3 self.down3(self.pool(d2)) b self.bottleneck(self.pool(d3)) u3 self.conv3(torch.cat([self.up3(b), d3], dim1)) u2 self.conv2(torch.cat([self.up2(u3), d2], dim1)) u1 self.conv1(torch.cat([self.up1(u2), d1], dim1)) return self.out(u1)逻辑说明base32控制模型宽度超声数据量小的时候 32 够用数据上千张可以提到 64。BatchNorm2d在小 batch 下统计量不稳如果batch_size只能开到 2 或 4换成InstanceNorm2d或GroupNorm更稳。输出层不加 sigmoid因为后面损失函数用BCEWithLogitsLoss它内部自带 sigmoid加两次会导致数值不稳定。3.2 损失函数组合Dice 和 BCE 怎么配比超声结节分割最头疼的是正负样本极度不平衡——结节区域往往只占整张图的百分之几到十几。单用 BCE 会让模型倾向于全预测背景因为那样损失也很低。Dice Loss 直接优化重叠度对不平衡不敏感但训练初期梯度不稳。常见做法是两者加权相加。import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, dice_weight0.5): super().__init__() self.dice_weight dice_weight def forward(self, logits, targets): bce F.binary_cross_entropy_with_logits(logits, targets) probs torch.sigmoid(logits) probs probs.view(probs.size(0), -1) targets targets.view(targets.size(0), -1) intersection (probs * targets).sum(dim1) dice (2 * intersection 1e-6) / (probs.sum(dim1) targets.sum(dim1) 1e-6) dice_loss 1 - dice.mean() return bce self.dice_weight * dice_loss逻辑说明1e-6是平滑项防止某张图完全没有结节时除零。dice_weight0.5是起点如果训练几个 epoch 后 Dice 卡在 0.6 以下可以提到 0.8 甚至 1.0让模型更关注重叠区域。反过来如果 Dice 震荡厉害降到 0.3 让 BCE 主导稳定梯度。这个参数没有万能值跟数据集的结节占比强相关结节越小越要加大 Dice 权重。优化器用Adam学习率1e-3起步配合ReduceLROnPlateau在验证 Dice 不升时减半。训练 epoch 数看数据量几百张图通常 50 到 100 个 epoch 就收敛了早停耐心值设 10 到 15。注意验证集一定要从训练集里划出来不要用测试集调参否则最后报的指标是虚高的。4. 训练过程中最容易翻车的五个地方4.1 掩码和图像没对齐Dice 死活上不去现象训练 loss 正常下降但验证 Dice 一直在 0.5 左右晃可视化发现预测区域整体偏移了几个像素。原因图像和掩码分别做了Resize如果两者原始尺寸不一致或者用了不同的插值方式缩放后像素位置就对不上了。超声图像和掩码尺寸不一致的情况在手工标注数据集里很常见。解决读进来先断言img.size mask.size不一致就报错。几何变换必须用同一组参数最稳的方式是把图像和掩码拼成多通道数组一起变换或者用 albumentations 这类支持同步变换的库。别用两套独立的transforms.Compose各做各的。4.2 全预测背景Dice 为 0 但 loss 看着不高现象训练几个 epoch 后模型输出全黑Dice 接近 0但 BCE loss 只有 0.1 左右看起来还挺“好”。原因结节占比太低模型发现全预测背景就能拿到很低的 BCE直接躺平。这是正负样本不平衡的典型表现。解决加大 Dice 权重或者用 Focal Loss 替代 BCE。另一个立竿见影的办法是在采样时做前景过采样让每个 batch 里至少有一半图像包含结节。还可以用pos_weight参数给 BCE 的正样本加权值设成负正样本比例的倒数。4.3 不同设备来源的测试集指标暴跌现象训练集和验证集 Dice 能到 0.85测试集只有 0.6 出头。原因训练集和测试集来自不同超声设备或不同增益设置图像灰度分布、斑点噪声模式差异大模型学到了训练设备特有的纹理而不是结节本身的形状。解决训练时加强灰度增强比如随机 Gamma 校正、随机对比度调整让模型对灰度变化不敏感。如果测试集完全不可见考虑在训练集上做风格归一化或者用直方图匹配把测试集往训练集的分布上靠。这个坑没有银弹数据来源越杂泛化越难。4.4 显存溢出batch size 只能开到 1现象CUDA out of memory把batch_size降到 1 才能跑但 BatchNorm 在 batch1 时统计量完全失效训练不稳定。原因输入尺寸设太大或者模型通道数太宽。超声图像缩到 256 后U-Net base32 在 8GB 显存上 batch8 是没问题的爆显存通常是尺寸设了 512 还用了 base64。解决优先降输入尺寸而不是降 batch size256 对结节分割通常够用。如果必须用大尺寸把 BatchNorm 换成 GroupNorm它对 batch size 不敏感。还可以用混合精度训练torch.cuda.amp能省将近一半显存。4.5 测试集评估时忘了把输出二值化现象测试时算 Dice 只有 0.3但训练时验证 Dice 有 0.8代码看起来一模一样。原因训练时损失函数内部做了 sigmoid验证时手动加了 sigmoid 再二值化。测试时直接拿 logits 去算 Dice数值范围是负无穷到正无穷和 0/1 的掩码完全对不上。解决评估流程统一封装成一个函数输入 logits内部做 sigmoid、阈值 0.5 二值化再算指标。训练、验证、测试都调同一个函数别各写各的。这个坑我踩过不止一次血泪经验就是评估代码只留一份。5. 评估指标与结果验证Dice 之外还要看什么5.1 Dice、IoU、敏感度和特异度怎么一起看Dice 是分割任务最常用的指标但它对小目标不敏感。一个结节只占图像 5%全预测背景 Dice 是 0但预测对了 80% 的结节区域 Dice 就能到 0.8 以上。所以必须配合敏感度召回率和特异度一起看。指标含义超声结节分割的参考阈值Dice预测与真值的重叠度测试集 0.75 算可用IoU交并比比 Dice 更严格测试集 0.60敏感度真结节被检出的比例 0.80漏检代价高特异度背景被正确排除的比例 0.95误检太多没法用HD9595% 豪斯多夫距离 10 像素边界贴合度敏感度和特异度是一对矛盾阈值调低敏感度升、特异度降。临床上漏检结节比误检严重所以阈值可以适当往 0.4 压牺牲一点特异度换敏感度。HD95 衡量的是边界最差的那 5% 像素的距离对超声这种边界模糊的场景HD95 比 Dice 更能反映边界质量。5.2 用测试集跑一遍完整评估并保存可视化结果评估代码要固定随机种子、固定阈值保证每次跑出来数字一致。下面这段把测试集遍历一遍算指标并存预测叠加图。import numpy as np import torch from PIL import Image def evaluate(model, loader, device, threshold0.5): model.eval() dice_list, iou_list, sens_list, spec_list [], [], [], [] with torch.no_grad(): for imgs, masks in loader: imgs imgs.to(device) logits model(imgs) probs torch.sigmoid(logits) preds (probs threshold).float() for p, m in zip(preds, masks): p p.view(-1).cpu().numpy() m m.view(-1).cpu().numpy() tp (p * m).sum() fp (p * (1 - m)).sum() fn ((1 - p) * m).sum() tn ((1 - p) * (1 - m)).sum() dice (2 * tp 1e-6) / (2 * tp fp fn 1e-6) iou (tp 1e-6) / (tp fp fn 1e-6) sens (tp 1e-6) / (tp fn 1e-6) spec (tn 1e-6) / (tn fp 1e-6) dice_list.append(dice) iou_list.append(iou) sens_list.append(sens) spec_list.append(spec) return { dice: np.mean(dice_list), iou: np.mean(iou_list), sensitivity: np.mean(sens_list), specificity: np.mean(spec_list), } # 用法 model UNet().to(cuda) model.load_state_dict(torch.load(best_unet.pth)) metrics evaluate(model, test_loader, cuda) print(metrics)逻辑说明threshold0.5是默认值如果敏感度偏低就降到 0.4 再跑一遍看指标变化。tp/fp/fn/tn在像素级别统计每张图单独算再取平均避免大图主导指标。1e-6防止空掩码或空预测导致除零。这个函数不依赖任何第三方指标库复制就能用。参数上best_unet.pth是验证集 Dice 最高时保存的权重不要用最后一个 epoch 的超声数据小最后几个 epoch 往往已经过拟合了。测试时model.eval()和torch.no_grad()都要加前者固定 BatchNorm 统计量后者省显存。提示把预测结果叠加到原图上存成 PNG肉眼过一遍。指标再高如果预测区域明显跑到结节外面说明评估代码有 bug。5.3 一个容易被忽略的验证习惯每次改完数据增强、损失权重或模型结构固定用同一个测试集跑评估把指标记到表格里。我习惯在项目根目录放一个results.csv每行记录日期、改动内容、Dice、IoU、敏感度、特异度。跑了几十组实验后回头看哪些改动真正有用一目了然。超声分割这个方向玄学调参的空间不大大部分提升来自数据质量和损失函数配比把实验记录做好比盲目试参数高效得多。最后一章说个具体技巧如果测试集 Dice 和验证集差距超过 0.1先别急着改模型把测试集图像和训练集图像各抽 20 张拼成对比图看灰度分布。十有八九是设备差异导致的分布偏移这时候在训练时加随机 Gamma 校正比换模型管用。我自己做超声分割的习惯是任何模型改动之前先确认数据管线没有对齐问题和分布问题这两个坑排掉U-Net 基线就能给你一个能用的结果。希望帮到你。本文还有配套的精品资源点击获取
返回列表