
简介这是一份医学图像分割数据集面向从事超声影像分析、甲状腺结节检测的研究人员与算法开发者用于训练和评估分割模型。数据集内含超过600例超声图像及对应分割标签经对比度拉伸、resize等图像增广后形成1277个文件其中以PNG图像为主1275张另附classes.txt类别说明与Python脚本整体压缩包约25.13MB。数据已预先划分训练集与验证集可直接用于模型训练和指标验证测试集则由使用者自行划分以保证评估过程的公正性。目前已有700人学习下载适合作为甲状腺结节超声分割研究的入门或基准数据集。借助这份资源研究者可省去数据收集与预处理环节专注于分割算法设计与实验对比快速开展相关课题。1. 600张超声图做甲状腺结节分割这个数据集到底处在什么位置如果你手里刚好有超过600张超声图像和对应的结节分割标签那你已经站在医学图像分割最舒服的一条起跑线上。医学图像分割里最难的不是模型而是带标注的数据甲状腺结节分割这类任务数据规模不需要像自然影像那样动辄几万张600张带标签的超声图足够把U-Net一类的分割流程完整跑通并看到可用的结果。这套数据解决的正是“想做甲状腺超声AI但缺标注数据”的起步问题图像是B超任务是给结节画出边界标签已经对齐到像素级。适合两类读者一是刚入门分割、想找一个“不算太小也不至于复杂到跑不动”的数据练手二是已经在做超声影像处理、想用这个小样本验证增强策略和后处理流程的人。先说清楚这个规模下不会存在“下载即SOTA”但它是验证工程流程和踩坑经验的好样本。2. 拿到数据集先读标签超声结节分割的标注形态与格式坑很多人在600多张图上翻车不是模型问题而是第一步就读错了标签。超声图像分割数据集的标签存储方式不统一直接决定你的 DataLoader 怎么写。先别急着训练把格式摸清能省掉后面一大半排障时间。2.1 常见的两种存储方式掩码图与JSON多边形超声甲状腺结节分割数据集最常见的标签组织方式是两种。第一种是“原图 单通道掩码图”。掩码图和原图分辨率一致背景是0结节区域是255或1通常就是images/和labels/两个目录。这种格式最省事PIL.Image.open()直接读不需要额外解析。第二种是“原图 JSON 多边形坐标”。每个图像文件对应一个.json文件里面记录一组或多组轮廓坐标点训练前需要把多边形栅格化成掩码。这种格式更接近医生的标注习惯因为超声科医生本来就是用轨迹球在屏幕上勾轮廓而不是一个像素一个像素地刷。判断方式很简单进目录后看文件后缀。如果看到大量同名 PNG一个灰度、一个全黑但结节区域是白色就是掩码格式如果看到.json或.xml就是轮廓格式。JSON 和 XML 只是外壳不同核心都是坐标点解析方法改一下就行。别小看这个环节我见过有人把 mask 当成原图喂给模型模型等于在学“把全黑图预测成全零矩阵”白白跑了几十个 epoch 才发现。掩码图用起来直接JSON 反而有一个必须确定的参数轮廓内是否填满。有的标注文件只存了闭合多边形的顶点有的在语义上表示“这条线围住的区域都是结节”。用ImageDraw.polygon栅格化时outline1, fill1两个参数必须写清楚只画线不填充出来的 mask 就是一条空心环损失函数会在环上反复震荡。2.2 用Python把标签读出来并和原图叠一次无论格式是什么先写一小段读取代码把原图和标签叠在一起用肉眼确认配对正确再做后续。下面这段代码我每次拿到新分割数据集都会先跑一次。import numpy as np from PIL import Image, ImageDraw # 方式A标签是单通道PNG掩码 image np.array(Image.open(sample.png).convert(L)).astype(np.float32) mask np.array(Image.open(sample_mask.png).convert(L)) mask (mask 127).astype(uint8) # 兼容255/128/1多种取值统一成0/1 # 方式B标签是JSON多边形坐标点 import json with open(sample.json, encodingutf-8) as f: ann json.load(f) points ann[points] # 常见key是points也可能是regions/shapes/polygon polygon [(p[x], p[y]) if isinstance(p, dict) else tuple(p) for p in points] mask_from_poly Image.new(L, (image.shape[1], image.shape[0]), 0) ImageDraw.Draw(mask_from_poly).polygon(polygon, outline1, fill1) mask_from_poly np.array(mask_from_poly, dtypeuint8)代码逻辑分两块掩码图读取时用convert(L)强制转到灰度避免原图是 RGB 三通道、mask 是四通道 PNG 时读进来 shape 对不上阈值判断用 127是为了兼容标签值为 255 和 1 两种写法统一成 0/1 之后后面计算 Dice 才不会出现“一个 mask 最大值是255另一个最大值是1损失函数算出个怪异值”的尴尬。JSON 分支里isinstance(p, dict)是为了兼容两种坐标存储风格一种存[x, y]列表一种存{x: .., y: ..}字典。outline1, fill1是把闭合多边形内部全部填充成 1。如果 JSON 里有多个结节必须循环画出所有 polygon再合并到同一张 mask 上漏掉一个就少一个正样本区域。之后做叠加图import matplotlib.pyplot as plt plt.figure(figsize(8, 4)) plt.subplot(121); plt.imshow(image, cmapgray); plt.title(B-mode) plt.subplot(122); plt.imshow(image, cmapgray) plt.imshow(mask, cmapjet, alpha0.4); plt.title(overlay) plt.savefig(check_overlay.png, dpi150)叠加图里如果看到 mask 边界和结节轮廓明显错位先查是不是原图和 mask 分辨率不一致。有些数据集在发布前把原图缩放过但 mask 还是原始尺寸np.array(Image.open(...))的 shape 对不上时模型训练第一轮就会报广播错误。2.3 标注质量的初次体检边界模糊与漏标600 张图里最影响后期 Dice 的不是网络结构是标签本身。第一次全量叠图做完后我会做三件事。第一看 mask 是否刚好盖住结节。超声图像里甲状腺结节的边界本身模糊医生标注时通常按“低回声区边界”勾但不同标注者掌握的尺度不一样有人标得紧只包含实性部分有人标得松把周边低回声晕圈也带进去。这种差异直接决定你能达到的 Dice 上限。如果标签之间本身相差 3-5 个像素模型不可能在测试集上做到 Dice 0.9 以上。第二找漏标。有些图像上结节非常明显但对应 mask 是全黑。常见原因是数据整理时某张掩码文件丢失或配对错位。处理方式是先把这些样本筛出来看而不是让模型强行学“有结节但标签为空”的矛盾样本。漏标样本放进训练集相当于给模型发错误信号放进验证集则会把你的评估指标拉低。第三看尺寸分布。600 多张里如果大多数是微小结节少数是大结节损失函数会被大结节主导小结节的边界永远学不好。先把每张图的 mask 面积记录下来生成一个metadata.csv后面按尺寸分组评估时直接用。超声原图还有两个常见坑顺便说掉。一是分辨率不统一同一个数据集里可能有 512×512、800×600 甚至带设备厂商水印的大图标签如果没跟着一起 resize后面就要在 DataLoader 里做大量对齐。二是灰度深度有的图保存成 16bit直接读出来数值范围是 0-65535不归一化直接训练模型一开始就会因为梯度爆炸学不动。统一转成 8bitimage image / image.max() * 255或者用skimage.exposure.rescale_intensity做百分位截断。下面是一份我整理标签时习惯用的落地目录你不需要照抄但保持这个文件结构能让后面所有脚本少写很多路径判断。路径内容作用images/超声原图 PNG训练输入labels/对应分割掩码 PNG监督信号metadata.csv文件名、mask面积、结节尺寸、设备来源分层评估与筛选split/train.txt训练集文件名划分集合split/val.txt验证集文件名调参与早停split/test.txt测试集文件名最终评估3. 处理数据集用于分割训练从600张超声图到可训练样本标签读进来只是第一步。超声图像的特点是噪声重、灰度分布漂移、结节占比小如果不做针对性处理直接把 600 张图塞进网络大概率训练出来的模型只能在原图上“背答案”。3.1 超声图像预处理要点归一化、去噪、增强超声与自然图像、CT 的灰度语义完全不同。CT 有标准的亨氏单位不同设备扫描同一组织灰度基本一致超声的灰度取决于探头频率、增益、深度和操作者的扫查角度同一结节在不同机器上可能一个显示为低回声、一个显示为等回声。所以归一化不要用自然图像里那套“整个数据集统一减均值除标准差”超声图更适合每张单独做百分位截断。我常用的做法是把每张原图先 clip 到 1% 和 99.5% 分位数之间再做线性拉伸到 0-1最后按单张图减均值除标准差。注意这里的均值和标准差是按图算不要跨图统计。如果某张图整体偏暗跨图归一化会把它的对比度压没结节直接消失在背景里。去噪方面超声斑点噪声是乘性噪声中值滤波是性价比最高的选择。核大小选 3 或 5不要选 7 以上——中值滤波核越大结节边缘被磨得越圆和标签轮廓的偏差越大。双边滤波理论上能保边但在 600 张的数据规模下它对最终 Dice 的提升通常不超过 0.01反而让预处理时间翻倍。我一般把核为 3 的中值滤波放在训练样本读取之后、数据增强之前当作固定预处理而不是离线把整批图全部滤一遍存成新图。增强环节有一个甲状腺特有的取舍水平翻转要慎用。甲状腺的左叶和右叶在超声图像中是镜像结构如果做二值分割翻转后标签也跟着翻转模型本身能学但如果你打算在测试阶段用 TTA翻转就要慎用。测试时翻转原图得到预测再翻转回来做平均这个操作对非对称器官有时会引入错误的边界定位。我的习惯是训练时只用旋转、缩放、亮度和弹性形变测试时不用翻转类 TTA。增强强度也要匹配小数据集。旋转角度 ±15°缩放 0.9-1.1亮度扰动 ±20%弹性形变 sigma 取 3。强度再大超声图像里的解剖结构会失真模型学到的是“扭曲的纹理”而不是结节边界。3.2 ROI裁剪与patch采样小数据集下提高训练效率600 张原图如果直接缩放到 512×512 输入GPU 显存占用高而且一旦结节在图中占比小下采样会把结节细节直接抹平。常见做法是先用标签的 bbox 裁剪一个以结节为中心的 ROI再统一 padding 到固定尺寸。这样相当于在计算资源和显存都不变的情况下把有效分辨率放大了。import numpy as np def crop_to_nodule(image, mask, crop_size192, margin16): 按标签裁剪ROI并padding到crop_size返回(image, mask)均为(HWcrop_size) ys, xs np.where(mask 0) if len(ys) 0: return None, None # 全黑标签先跳过单独人工复核 y_min, y_max ys.min(), ys.max() x_min, x_max xs.min(), xs.max() # 在bbox外扩margin并限制在图像范围内 y_min max(y_min - margin, 0) y_max min(y_max margin, image.shape[0]) x_min max(x_min - margin, 0) x_max min(x_max margin, image.shape[1]) crop image[y_min:y_max, x_min:x_max] crop_mask mask[y_min:y_max, x_min:x_max] h, w crop.shape[:2] padded np.zeros((crop_size, crop_size), dtypenp.float32) padded_mask np.zeros((crop_size, crop_size), dtypenp.uint8) # 居中padding不足部分补零 y_off (crop_size - h) // 2 x_off (crop_size - w) // 2 padded[y_off:y_off h, x_off:x_off w] crop padded_mask[y_off:y_off h, x_off:x_off w] crop_mask return padded, padded_mask这个函数的逻辑很直接np.where(mask 0)拿到所有前景像素坐标求出最小外接矩形外扩margin16是为了让模型看到结节周围的甲状腺组织而不只是“贴着边界的局部图”。crop_size192是显存和分辨率之间的折中如果结节特别小可以设 256但要相应减小 batch size。补零 padding 不是最优方案因为超声图像的背景噪声区域和纯黑区域差别很大模型可能学会“凡是黑色区域都不是结节”。更稳的办法是在 padding 时用图像边缘像素值填充而不是 0。实现上可以在 crop 外圈先做镜像填充或者把padded初始化为image[0, 0]这类背景灰度值。还要注意一个工程细节训练时不能只从 mask 非零区域裁剪。如果所有训练样本都是以结节为中心的近景测试时模型面对整张图的上下文会不知所措。我会保留约 15% 的样本不做 ROI 裁剪直接 resize 成 192×192 输入让模型同时见过“结节占满画面”和“结节只占一小块”两种分布。3.3 划分训练/验证/测试集按病例还是按图600 张图看起来不少但如果同一个病人的多张图同时出现在训练集和验证集验证指标的参考价值会大打折扣。第一步是看目录或元数据里有没有patient_id之类的字段。有的话一定要按病人 ID 分组划分同一个病人的所有图只能进同一个集合。否则模型可能不是在学习“什么是结节”而是在记忆“这位病人的皮肤纹理和背景噪声”换到新病人身上立刻失效。如果数据集没有提供病例 ID只能用文件名随机划分但我建议心里有数这种划分下的验证集 Dice 会偏乐观最终报告里要写清楚“未按病人分组的局限性”。划分比例我习惯用 8:1:1也就是约 480 张训练、60 张验证、60 张测试。60 张验证图在分割任务里只够看趋势单次验证集的 Dice 波动可能达到 0.03-0.05。应对办法是两个一是每个 epoch 都在验证集上算 Dice但只保留最高点对应的权重二是训练结束后用 5 个不同随机种子跑五折交叉验证把平均 Dice 和标准差写进结论。测试集要完全锁起来不做任何早停决策只允许在最终评估时碰一次。划分还有一个容易被忽略的顺序问题数据增强必须发生在划分之后。如果先做增强再划分同一张原图的不同增强版本可能同时进入训练集和验证集相当于验证集被“污染”了测试指标会被高估。标准做法是先按文件名写三个 txtDataLoader 读取时再按集合名选择增强管线。4. 用U-Net在600张图上训练甲状腺结节分割最小可跑通流程与关键参数处理完数据接下来就是训练。这个章节我给出一套最小可跑通的 U-Net 训练流程以及每个关键参数的选择依据。照着这个流程走即使数据只有 600 张也能在一个小时左右看到明确的分割效果。4.1 U-Net为什么是超声结节分割的默认选择在 600 张规模下U-Net 几乎是第一选择。原因不是它分数最高而是它的归纳偏置最适合小数据编码器逐层下采样提取语义解码器通过跳跃连接恢复细节参数规模比 Swin-UNet、TransUNet 这类 Transformer 分割模型小一个量级在几十万像素的超声图上不容易过拟合。相比自然图像分割里常用的 DeepLabV3U-Net 在医学边界任务上的优势是它保留了多尺度的细节信息浅层特征可以直接传到解码器所以对结节这种边界模糊的目标更友好。结构上我用 4 层编码器每层两个 Conv3x3 BN ReLU通道数从 32 开始逐层加倍解码器用双线性上采样而不是转置卷积。后者参数多而且容易在少量数据下产生棋盘格伪影。如果你的环境里有现成分割库直接调用基础模块时注意一个最容易错的地方预训练模型第一个卷积层的in_channels3超声图是单通道要改成 1。三通道图复制三次喂进去也可以但显存翻倍收益不大。4.2 最小PyTorch训练脚本DiceLoss AdamW训练目标我用 Dice Loss 和 BCE 的组合Dice Loss 0.5 * BCE。纯 BCE 在结节前景占比小的情况下会偏向背景模型学到最后输出一张全黑图因为全黑也能把 BCE 压到很低纯 Dice Loss 对极小结节的梯度不稳定训练容易震荡。两者组合是医学分割里最稳的做法。import torch import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): pred是未经过sigmoid的logitstarget取值0/1 pred torch.sigmoid(pred) pred pred.contiguous().view(pred.size(0), -1) target target.contiguous().view(target.size(0), -1).float() intersection (pred * target).sum(dim1) dice (2.0 * intersection smooth) / ( pred.sum(dim1) target.sum(dim1) smooth ) return 1.0 - dice.mean() for epoch in range(epochs): model.train() for images, masks in train_loader: images images.cuda() masks masks.cuda().float() optimizer.zero_grad() logits model(images) loss dice_loss(logits, masks) 0.5 * F.binary_cross_entropy_with_logits( logits, masks ) loss.backward() optimizer.step() # epoch结束存一次验证指标保留最高Dice权重 val_dice evaluate(model, val_loader) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), best_unet.pt)这段脚本的关键参数有三个。第一smooth1.0是平滑系数作用是避免标签和预测全零时除零在 600 张数据规模下不要设太大否则 Dice 的梯度被压平训练变慢。第二优化器用 AdamW初始学习率 3e-4weight_decay1e-4。小数据集上 AdamW 的权重衰减能明显抑制过拟合SGD 反而不好收敛。第三epochs 设 200但配 early stoppingpatience 30。早停看验证集 Dice连续 30 个 epoch 不上升就停不是为了精确是为了防止最后几轮过拟合导致权重变差。推理阶段要注意模型输出的是 logits不是概率。保存预测图时先torch.sigmoid()再转 numpy阈值取 0.5。如果验证集上预测边界明显比标签大一圈阈值可以抬到 0.6-0.7这也是后处理的一部分。4.3 从头训练和迁移学习怎么选600 张属于典型小样本理论上迁移学习应该有效但超声图像和 ImageNet 的视觉特征差异很大预训练权重不一定带来收益。我一般会同时跑两组对比。第一组是 U-Net encoder 用 ImageNet 预训练的 ResNet34输入改单通道第一层卷积权重在通道维取平均。第二组是完全随机初始化从头训练。两组用完全相同的预处理和数据增强。经验结果是如果结节边界在图像里比较清晰、标注也一致从头训练和迁移学习的最终 Dice 差距通常在 0.02 以内如果数据里有较多低质量图或标注噪声迁移学习会更稳一些。迁移学习有一种用法更容易踩坑把 encoder 冻结只训练 decoder。这在超声分割上通常效果不好因为超声图像的低层特征和自然图像差别太大冻结 encoder 等于让模型用一个“看不懂超声纹理”的编码器。正确做法是全部参数一起微调学习率可以比从头训练设低一点比如 1e-4。5. 甲状腺结节分割数据集训练中的避坑记录5个值得写进排障手册的问题这一章写我自己在这类数据集上踩过的坑。每一条都是“现象 → 原因 → 解决”的格式照着排查比反复调参有效。5.1 斑点噪声把模型带偏现象训练 loss 正常下降验证集 Dice 也不难看但把所有预测图叠出来看结节边界外面散布着大量小碎块像撒了一把盐。原因超声斑点噪声在图像上表现为黑白相间的细颗粒模型把很多噪声点当成了结节边缘。解决训练预处理加 3×3 中值滤波预测阶段对概率图做阈值后用scipy.ndimage.binary_opening去掉面积小于 20 像素的连通域。如果碎块出现在结节内部改用闭运算把空洞补上。不要为了去噪把滤波核加到 7×7边界会被磨掉一圈Dice 一样上不去。5.2 标签边界不一致导致Dice卡在0.6现象训练集 Dice 能到 0.92验证集始终在 0.6-0.7 之间波动而且预测的轮廓整体比标签小一圈或者大一圈。原因不同标注者对“结节边界”的尺度掌握不一样有的把低回声晕圈算进去有的只标实性部分模型只能学一个折中边界。解决不要在硬标签上硬扛可以给标签边界做 soft label 平滑。具体做法是对二值 mask 做距离变换边界附近 3-5 个像素范围的标签值从 1 渐变到 0让模型对边界的不确定性有感知。损失函数不用改但梯度更稳。另一个做法是统一做一次形态学腐蚀或膨胀让所有标注边界对齐到同一尺度。这样处理后验证集 Dice 通常能回升到 0.75 以上。5.3 小数据集过拟合和验证集波动现象训练集 Dice 一路涨到 0.95验证集到了第 80 个 epoch 开始掉头向下或者验证集 Dice 在两个 epoch 之间从 0.78 跌到 0.65 再弹回。原因600 张的规模下模型很容易把训练图的纹理、设备水印、背景固定图案一起记住。解决把数据增强强度调大亮度扰动 ±20%、缩放 0.9-1.1、弹性形变 sigma3做早停时只看验证集 Dice不看了训练 loss最后用五折交叉验证的平均指标代替单次留出集指标。千万不要用“最后一个 epoch 的权重”做最终评估那是小数据集上最便宜也最贵的教训。5.4 超声设备差异导致的地域偏差现象在自己的留出集上效果不错换一批来源不同的 B 超图就明显变差边界出现整体偏移。原因超声没有标准灰度单位不同设备、探头频率、增益、深度设置会改变结节显示效果。600 张如果都来自同一台机器模型的泛化边界就很窄。解决预处理阶段不要用固定灰度窗位每张图按百分位归一化如果知道设备型号在验证时按设备分组算 Dice看看是哪一类图在拉后腿。最直接的做法是从新设备补标 30-50 张用它们做一次微调。这比在模型里加域自适应模块更省事也更容易落地。5.5 结节尺寸差异大单一Dice不敏感现象总体 Dice 有 0.80但按结节长径分组后长径小于 5mm 的微小结节 Dice 只有 0.2几乎全漏大于 20mm 的结节都分割得很好。原因Dice 是面积比大结节在分母中权重大漏掉一个小结节对整体 loss 影响很小模型优化时自然优先保住容易的大目标。解决损失函数换成 Tversky Loss 或 Focal Dice让假阴性得到更高惩罚评估时按结节面积分组算分层 DICE不要只看总体一个数推理时如果原图分辨率足够用滑窗 patch 预测代替整图 resize。对甲状腺结节随访来说微小结节的变化往往才是临床关心的重点这个分层指标比总 Dice 更重要。6. 最后一步用效果图和分层指标判断模型是否真的能用6.1 画一组预测叠加图肉眼先于指标指标可以骗人图像不会。每次训练结束我会把验证集全部预测图存成三列并排图原图、标签、预测叠加。用 sigmoid 输出得到概率再做二值化代码很短但必须规范import matplotlib.pyplot as plt import numpy as np def dump_prediction(image, gt, prob, idx, save_dir): prob是sigmoid输出值域0-1 pred (prob 0.5).astype(uint8) fig, axes plt.subplots(1, 3, figsize(12, 4)) axes[0].imshow(image, cmapgray) axes[0].set_title(B-mode) axes[1].imshow(gt, cmapgray) axes[1].set_title(Ground Truth) axes[2].imshow(image, cmapgray) axes[2].imshow(pred, cmapReds, alpha0.5) axes[2].set_title(Prediction) fig.savefig(f{save_dir}/{idx:04d}.png, dpi150) plt.close(fig)这段代码没有做任何后处理就是为了看模型的原始输出。如果看到预测比标签大一圈优先怀疑标注尺度不一致而不是模型架构如果看到预测边界整齐但位置偏移优先怀疑配准和裁剪问题。保存成 150 dpi 的 PNG放到一个单独的目录里按 epoch 命名就能直观看到训练过程是怎么演化的。6.2 按结节尺寸分组的Dice与体积误差表小数据集的评估不能只给一个平均 Dice。我最终会生成一张按结节尺寸分组的表格式大概是这样的结节尺寸分组样本数Dice(阈值0.5)HD95(mm)体积误差(%)5mm120.443.2185-10mm280.721.8-610-20mm150.811.2420mm50.871.02HD95 可以用medpy.metric.binary.hd95计算体积误差直接用预测 mask 的像素数量除以标签像素数量再减 1。Dice 高只能说明两个区域重叠面积大边界外扩一圈时 Dice 可能依然好看但体积误差会暴露问题。对甲状腺结节的随访场景体积变化是医生做判断的重要参考所以这个表比总 Dice 更有临床意义。我的习惯是任何一个分割模型上线前总 Dice 和分组表必须放在一起看。如果某个分组样本数太少、标签又边界模糊我宁可在结论里写“该组不确定”也不要让一个漂亮的平均 DICE 把问题盖过去。这个小数据集方案做到最后真正值钱的不是模型权重而是你对自己数据边界的那份清楚认知。希望帮到你。本文还有配套的精品资源点击获取