600张超声结节图训练分割模型:从U-Net到验证的完整指南

发布时间:2026/9/24 23:27:48
600张超声结节图训练分割模型:从U-Net到验证的完整指南 简介面向医学影像分析与深度学习研究人员的甲状腺结节超声分割数据集内含超过600张超声图像及配套分割标签适用于训练和验证甲状腺结节边界识别模型也可用于图像增强、对比度拉伸等预处理方法的效果验证。整个压缩包共1277个文件以PNG格式为主1275张含原始图像与标签掩码另有1个类别说明txt和1个Python脚本整体约25MB数据已预先划分为训练集与验证集类别信息可查看classes文件明确便于快速开展实验。该资源已有699人学习数据已经过对比度拉伸、resize等增强处理附带预处理脚本和类别文件使用者无需从头整理数据即可直接用于U-Net、DeepLab等分割网络的训练与评估有效节省前期准备时间。1. 600张超声结节图够不够训练一个能用的分割模型不少团队拿到「医学图像分割数据集基于超声图像的甲状腺结节分割数据集超过600张数据和标签」这类资源后的第一反应是才600张够干什么但实际上在超声图像分割这个方向600张带精细标签的数据已经属于能正经训练模型的门槛级别远比很多论文里用几十张图做验证的情况扎实。甲状腺结节在超声影像里边界模糊、内部回声不均还有各种伪影干扰这600张图如果组织得好足够跑通一条完整的医学图像分割流程甚至做出能写进横向课题结题报告的基线结果。这期就顺着这个标题把整条链路拆开从数据整理、标注格式转换到模型选型、训练参数再到最容易被忽视的验证环节。新手可以照着代码一步步跑通熟手可以重点看第5章的坑和第6章的验证口径。2. 把原始超声图整理成训练集标注格式、划分与清洗2.1 拿到压缩包之后先别急着训练很多人下载完数据集解压看到一堆PNG和JSON就直接开训结果模型收敛了才发现标签对不上图或者验证集里混进了同一患者的连续帧白费一星期算力。这个数据集包含超过600张图像及对应标签第一件事永远是盘点清楚图像是什么格式标签是掩膜mask还是多边形polygon有没有和图像一一对应尺寸是否一致。我常用的一段盘点脚本大致长这样import os import json from PIL import Image from collections import Counter img_dir thyroid_images mask_dir thyroid_masks img_files sorted(os.listdir(img_dir)) mask_files sorted(os.listdir(mask_dir)) print(f图像数量: {len(img_files)}标签数量: {len(mask_files)}) assert len(img_files) len(mask_files), 图像和标签数量不一致 ext_counter Counter([f.split(.)[-1].lower() for f in img_files]) print(f图像格式分布: {ext_counter}) # 抽查前5张图的尺寸 for f in img_files[:5]: with Image.open(os.path.join(img_dir, f)) as im: print(f, im.size, im.mode) # 如果标签是JSON先看结构 with open(os.path.join(mask_dir, mask_files[0]), r) as fp: label_sample json.load(fp) print(type(label_sample), str(label_sample)[:300])这段代码解决三个问题确认文件数量是否匹配避免训练时读到空标签、确认图像格式和尺寸超声图像经常混有DICOM转存的BMP、JPEG尺寸不统一会影响后续深度学习框架的数据加载、确认标签存储结构JSON里可能是多边形坐标也可能是RLE编码这才是后续转换的关键信息。600多张数据看起来不多但如果存在多中心、多设备来源图像的灰度范围可能天差地别。超声图像不像自然图像灰度值本身没有绝对物理意义不同设备扫描深度、增益设置不同整体亮度和对比度差异很大。这个需要在整理阶段记录好后续归一化方式要能扛住这种分布偏移。2.2 将多边形标注转成掩膜一个被低估的工作量公开的甲状腺结节分割数据集中标签格式往往是医生用标注工具画的封闭曲线多边形而不是深度学习直接可用的像素级掩膜。把多边形转成掩膜这一步看似简单实际操作中会踩到坐标系翻转、图像插值导致的边界偏移、掩膜与原始图尺寸不匹配等一堆问题。常见的做法是用OpenCV或PIL把多边形填充成掩膜import numpy as np import cv2 import json from PIL import Image def polygon_to_mask(json_path, img_size): with open(json_path, r) as fp: ann json.load(fp) # 不同数据集字段名不同先探测 if shapes in ann: shapes ann[shapes] polygons [s[points] for s in shapes if s[label] nodule] elif polygon in ann: polygons [ann[polygon]] else: raise ValueError(未知的JSON标注结构请先打印查看字段) mask np.zeros(img_size, dtypenp.uint8) for poly in polygons: pts np.array(poly, dtypenp.int32).reshape(-1, 2) cv2.fillPoly(mask, [pts], 1) return mask mask polygon_to_mask(sample_label.json, (512, 512)) Image.fromarray(mask * 255).save(sample_mask.png)这里有一个需要特别强调的细节img_size必须是原始超声图像的宽高绝对不能是你想当然的某个值。如果JSON里的坐标是基于原图的而你在转换时传错了尺寸生成的掩膜会整体偏移而且这种错误肉眼很难发现——因为甲状腺结节的轮廓在超声图上本来就不规则你很难看出是不是错位了几个像素。对于600多张数据建议转换完成后做一次全量可视化抽查把掩膜以半透明红色叠加在原图上拼接成一张大图人工扫一遍。这个环节能发现至少两类常见问题一类是标注脱靶医生标注时把边界画到了结节外另一类是坐标原点约定不一致有的工具左上角为原点有的左下角。自动脚本检查不出来只能靠人眼。2.3 划分训练/验证/测试集分层与患者级隔离数据划分在医学图像分割里远比在自然图像里敏感。甲状腺超声检查通常一个患者会留存多张静态图如果划分时不做患者级隔离同一个人的多张图可能同时出现在训练集和验证集中导致验证指标虚高。这个问题在600张级别的小数据集上尤其致命因为过拟合本来就快验证集再“脏”整个实验就没有参考意义了。文件命名里通常包含患者ID或检查号靠人工看文件名不现实应当写个小工具自动提取并分组import os import re import random img_files [001_a_left.png, 001_b_right.png, 002_isthmus.png] patient_map {} for f in img_files: match re.match(r(\d), f) if match: pid match.group(1) patient_map.setdefault(pid, []).append(f) patients list(patient_map.keys()) random.seed(42) random.shuffle(patients) train_ratio 0.7 val_ratio 0.15 split_train patients[:int(len(patients) * train_ratio)] split_val patients[int(len(patients) * train_ratio):int(len(patients) * (train_ratio val_ratio))] split_test patients[int(len(patients) * (train_ratio val_ratio)):] train_files [f for p in split_train for f in patient_map[p]] val_files [f for p in split_val for f in patient_map[p]] test_files [f for p in split_test for f in patient_map[p]] print(f训练集图像 {len(train_files)} 张验证集 {len(val_files)} 张测试集 {len(test_files)} 张)患者级划分在超声这种同患者多帧图像高度相似的场景下是必须的。如果数据集的命名没有暴露患者ID那么退而求其次的做法是查文件属性或者检查时间戳——同一个检查批次下的图像大概率来自同一个患者。另一个容易漏掉的细节是划分时的随机种子要固定上面代码中的random.seed(42)否则每次划分结果不同实验结果无法复现写成报告的时候你都没法向导师或甲方交代。3. 选型与方法为什么U-Net仍然是600张图的首选基线3.1 预处理与归一化超声图像的特殊性超声图像和自然图像的预处理有非常明显的差异。自然图像的分割可以不那么在意归一化细节ResNet系列自带BN层扛得住各种输入分布但超声图像灰度分布随设备增益变化极大同一个结节在不同扫描参数下生成的图片灰阶差异巨大归一化做得不好会直接影响模型收敛速度甚至导致训练震荡。处理超声图像的第一步绝对不是直接除以255而是先做一个截断。常见的做法是把图像灰度值从另一个私有的尺度映射到0-255。这里我通常先看直方图再决定截断百分位import numpy as np import cv2 def preprocess_ultrasound(image, low_percentile1, high_percentile99): # image: uint16 或 uint8 灰度图 if image.dtype ! np.uint8: image cv2.normalize(image, None, 0, 255, cv2.NORM_MINMAX) # 直方图截断去掉极亮和极暗的噪声 low_val np.percentile(image, low_percentile) high_val np.percentile(image, high_percentile) image np.clip(image, low_val, high_val) image (image - low_val) / (high_val - low_val 1e-6) * 255.0 return image.astype(np.uint8)百分位截断比单纯的min-max归一化更稳因为超声图像里常常有一小片高亮的探头接触区min-max会被这些极端值带偏。同时如果图像尺寸不是标准正方形比如768×1024不建议直接resize成正方形再送入网络因为这样会改变结节的实际物理尺寸比例影响分割准确度。可以考虑pad到固定尺寸或者用可变形卷积等方案但基线阶段最简单的方式是中心裁剪到网络输入尺寸同时把标签做同样的变换保证空间一致性。3.2 模型选型的取舍为什么不用大型Transformer现在医学图像分割领域论文里最常见的是UNet变体和基于Transformer的模型比如TransUNet、Swin UNet。但对于这个600张的数据集我的意见是先跑通U-Net不要一上来就上大模型。原因非常实际。U-Net的参数量约在3000万级别在600张二维图像上配合数据增强可以勉强训练而不至于严重过拟合而TransUNet这类模型参数量动辄上亿在600张图的小数据集上极其容易过拟合且训练时需要更大的batch size、更长的迭代次数对显存和调参经验的要求都更高。U-Net在医学图像分割中久经考验尤其在超声这种低信噪比的图像上。它的跳跃连接结构能保留高分辨率的边界信息恰好对应对超声图像中结节边缘模糊这一核心痛点。即便将来要换成更强力的模型U-Net的权重也完全可以作为迁移学习的初始化起点。这一点在医学图像分割项目里尤其重要。3.3 基线模型实现从数据加载到训练的完整代码这里给出一段基于PyTorch的U-Net实现目标是在这个数据集上跑通一个可用的基线而不是追求SOTA指标。代码刻意保持简洁方便替换成自己的数据路径。import torch import torch.nn as nn import torch.nn.functional as F class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv 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.conv(x) class UNet(nn.Module): def __init__(self, in_channels1, num_classes1, base_ch64): super().__init__() self.enc1 ConvBlock(in_channels, base_ch) self.enc2 ConvBlock(base_ch, base_ch * 2) self.enc3 ConvBlock(base_ch * 2, base_ch * 4) self.pool nn.MaxPool2d(2) self.center ConvBlock(base_ch * 4, base_ch * 8) self.dec3 ConvBlock(base_ch * 8, base_ch * 4) self.dec2 ConvBlock(base_ch * 4, base_ch * 2) self.dec1 ConvBlock(base_ch * 2, base_ch) self.out nn.Conv2d(base_ch, num_classes, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) c self.center(self.pool(e3)) d3 self.dec3(torch.cat([F.interpolate(c, sizee3.shape[2:]), e3], dim1)) d2 self.dec2(torch.cat([F.interpolate(d3, sizee2.shape[2:]), e2], dim1)) d1 self.dec1(torch.cat([F.interpolate(d2, sizee1.shape[2:]), e1], dim1)) return self.out(d1)base_ch64是U-Net的经典配置。对于600张图的数据集不建议把base_ch加倍到128会增加过拟合风险。in_channels1对应超声灰度图如果数据集中有部分RGB图预处理时统一转灰度再训练。num_classes1对应二分类分割前景背景输出层的sigmoid在损失函数里处理。代码逻辑上池化层逐级降低空间分辨率提取高层语义跳跃连接将浅层边界细节传递给解码器最终输出与输入同尺寸的分割结果。这是U-Net的核心设计应对超声结节的模糊边界非常有效。4. 训练策略与关键参数比别人多跑出2到3个点的Dice4.1 损失函数Dice Loss为什么比CrossEntropy更合适甲状腺结节分割面临类别不平衡问题——结节区域通常只占整幅图像的5%到15%背景占绝大多数。用普通的交叉熵损失会让模型倾向于全部预测为背景因为即使如此准确率也很高但分割结果就失去了临床意义。Dice Loss直接优化目标指标本身对前景占比不敏感。在实际工程中我通常会用Dice Loss和Focal Loss的加权组合既保留了Dice对区域重叠的强调又用Focal Loss处理难分样本尤其是边界模糊的结节区域class DiceLoss(nn.Module): def __init__(self, smooth1e-6): super().__init__() self.smooth smooth def forward(self, pred, target): pred torch.sigmoid(pred) pred_flat pred.view(pred.size(0), -1) target_flat target.view(target.size(0), -1) intersection (pred_flat * target_flat).sum(dim1) dice (2 * intersection self.smooth) / (pred_flat.sum(dim1) target_flat.sum(dim1) self.smooth) return 1 - dice.mean() class FocalLoss(nn.Module): def __init__(self, alpha0.25, gamma2): super().__init__() self.alpha alpha self.gamma gamma def forward(self, pred, target): prob torch.sigmoid(pred) focal -self.alpha * (1 - prob) ** self.gamma * target * torch.log(prob 1e-8) focal -(1 - self.alpha) * prob ** self.gamma * (1 - target) * torch.log(1 - prob 1e-8) return focal.mean()参数配置强烈建议抄作业这是我在多个超声分割任务上调出来的经验值参数推荐值原因损失函数0.6 × DiceLoss 0.4 × FocalLossDice稳全局Focal抓边界优化器AdamWlr3e-4比Adam更稳权重衰减防过拟合batch size8受限于显存但太小影响BN统计训练轮数150 epochs配合早停一般在80轮左右收敛学习率策略CosineAnnealing末期平滑收敛避免振荡4.2 数据增强超声图像不能随便旋转数据增强在600张图的小数据集上几乎是必备的但超声图像增强策略和自然图像有区别。自然图像常用的随机裁剪、水平翻转、颜色抖动可以大胆用但超声图像要谨慎。甲状腺结节本身没有方向性但是颈部解剖结构是有上下概念的大幅旋转会破坏解剖结构的合理性导致模型学到错误的结构先验。我的做法是只用小角度旋转、水平翻转、轻微缩放和弹性形变。弹性形变对医学图像特别友好能模拟超声探头角度变化导致的组织形变帮助模型学到更稳健的边界import albumentations as A train_transform A.Compose([ A.RandomRotate90(p0.5), A.HorizontalFlip(p0.5), A.Affine(scale(0.9, 1.1), translate_percent(-0.1, 0.1), rotate(-15, 15), p0.5), A.ElasticTransform(alpha3, sigma50, alpha_affine3, p0.3), A.RandomBrightnessContrast(brightness_limit0.15, contrast_limit0.15, p0.5), ])alpha3, sigma50是弹性形变的经验参数太大会导致解剖结构变形严重太小起不到正则化作用。rotate(-15, 15)限制在小角度以内避免上下颠倒。4.3 完整训练循环从Dataloader到早停机制训练循环中最容易忽视的环节是验证集上的「最佳模型保存」逻辑。很多人训练完只看最后几个epoch的指标但训练后期模型在验证集上的表现往往已经开始退化。正确的做法是每个epoch结束后计算验证集Dice如果比历史最优值高就保存当前权重训练结束后用保存的最优权重做测试集评估。def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss 0 for images, masks in loader: images, masks images.to(device), masks.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, masks) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) def validate(model, loader, device): model.eval() dice_scores [] with torch.no_grad(): for images, masks in loader: images, masks images.to(device), masks.to(device) logits model(images) pred (torch.sigmoid(logits) 0.5).float() intersection (pred * masks).sum(dim(1, 2, 3)) dice (2 * intersection 1e-6) / (pred.sum(dim(1, 2, 3)) masks.sum(dim(1, 2, 3)) 1e-6) dice_scores.extend(dice.cpu().numpy()) return np.mean(dice_scores)训练时要在每个epoch里同时跑训练和验证把验证集Dice达到最大值时的模型权重保存为best_model.pth。训练结束后测试集只用这个最优模型评估。另外当连续30个epoch验证Dice没有提升时直接提前终止训练节约算力也防止模型在训练集上继续过拟合导致验证指标下滑。5. 避坑甲状腺结节分割最常见的5个现场5.1 标签掩膜与图像尺寸不匹配导致训练直接崩溃现象训练第一个epoch就报size mismatch错误模型输出的特征图尺寸和标签对不上。原因数据集里部分图像经过医生标注工具处理后尺寸被改写了但标注掩膜还是基于原始尺寸生成。最常见的来源是DICOM转PNG时图像被自动裁剪过黑边而标注文件没同步更新。解决在构建Dataloader之前逐个对比图像和掩膜的尺寸不匹配的直接输出文件名单然后统一处理。可以先把所有图像resize到一个固定尺寸比如512×512掩膜做同样的resize但用最近邻插值cv2.INTER_NEAREST而非双线性插值防止标签出现中间值。5.2 灰度值范围异常导致分割结果全是背景现象模型loss不下降预测mask全黑。原因超声图像灰度范围和自然图像不同有的图是16位深度值域0到65535直接除以255之后数值大于1BN层错乱模型不收敛。另一种情况是部分图像有注释文字和标尺白色标尺占了很高的像素比例干扰归一化。解决统一转成8位灰度然后按第3章的百分位截断处理同时观察直方图如果高亮区域是标尺建议在预处理阶段就裁剪掉图像四周的冗余区域只保留超声成像区域。5.3 验证指标很高但模型在临床图像上完全不能用现象验证集Dice达到0.85但平移模型到实际临床场景不同设备、不同医院输入新图像时分割结果明显不对。原因公开数据集通常是单中心数据600张图可能来自同一台设备或同一批标注人员。模型学到的特征高度依赖该设备的灰度分布和探头频率。这属于典型的过拟合到特定数据分布。解决如果目标是做临床级泛化必须做跨域验证——至少留出20%的数据来自不同设备测试时分组统计Dice。如果数据集本身没有跨设备信息那么训练时要加大数据增强尤其是灰度抖动和对比度变化模拟不同设备的成像差异否则只能把这个模型定位为「基线模型」在真实部署前需要额外采集少量数据进行微调。5.4 病灶过小导致Dice波动极大现象一个50×50像素小结节的预测如果偏了几个像素Dice从0.9掉到0.6。原因Dice指标对小目标非常敏感因为分母小几个像素的偏移就占比很大。这个数据集里甲状腺结节的尺寸差异极大小的在32×32像素大的接近256×256。解决评估时除了Dice同时记录结节面积分别统计小目标面积1000像素和常规目标的Dice。训练时可以增加一个辅助損失函数——边界损失让模型更加关注靠近目标边界的区域。这也是为什么我在第4章强调用Focal Loss配合Dice LossFocal能从损失函数层面提升对边界难分样本的关注。5.5 多标注者不一致问题同图不同标签到底学到哪一个现象同一个结节两次标注的边界差别很大模型在某些切片上预测的边界落在两个标注答案之间怎么调参都提不上去。原因超声图像边界不清晰是客观存在的医生A认为边界在高回声边缘医生B认为在暗区中心两个标注都成立。解决仔细查看数据集文件如果标注重合度平均Dice低于0.8说明标注者间差异较大。处理办法两个一是对不同标注取交并比优化形态学腐蚀膨胀做容差处理二是训练时在损失函数中降低边界像素的权重让模型重点学习结节内部和背景区域。对于600张数据还可以把所有标注者标注的掩膜做平均生成一个软标签每个像素值范围0到1模型直接回归软标签这种方式的容错性通常更好。6. 验证与进阶指标怎么算才可信下一步还能往哪走6.1 测试集评估的正确姿势固定最优模型一次评估很多项目在验证集上反复评估选择效果最好的epoch成绩写进报告这是典型的自欺欺人。正确做法是训练完成后用验证集选出的best_model.pth对测试集只评估一次所有指标以这次为准。全程只有训练集在参与优化验证集用于选模型测试集只出现一次。import torch import numpy as np from scipy.ndimage import binary_fill_holes, label def compute_metrics(pred_mask, gt_mask): # Dice intersection (pred_mask * gt_mask).sum() dice (2 * intersection 1e-6) / (pred_mask.sum() gt_mask.sum() 1e-6) # 95% Hausdorff距离用简化版 from scipy.ndimage import distance_transform_edt pred_dt distance_transform_edt(1 - pred_mask) gt_dt distance_transform_edt(1 - gt_mask) hd95 np.percentile(pred_dt[gt_mask 0] gt_dt[pred_mask 0], 95) # 像素精度 iou intersection / ((pred_mask | gt_mask).sum() 1e-6) return {Dice: dice, HD95: hd95, IoU: iou} # 后处理填补孔洞去掉小于100像素的孤立噪点 pred_filled binary_fill_holes(pred_mask 0.5) labeled, num label(pred_filled) if num 0: sizes np.bincount(labeled.ravel()) keep_idx np.argmax(sizes[1:]) 1 pred_clean (labeled keep_idx).astype(np.uint8) else: pred_clean pred_filled.astype(np.uint8)后处理这一步值得多说两句预测结果经常在结节内部出现一个像素宽的小孔洞在结节外部出现零星噪点。用binary_fill_holes填充内部孔洞再只保留面积最大的连通域能有效提升Dice和HD95指标。不要把所有小的连通域全删掉——超声图像里偶尔会有真正的多发结节删除小连通域会让模型在多发结节病例上直接失败。我的习惯是先统计测试集里平均结节数量如果数据集中都是单结节病例再启用最大连通域后处理。6.2 进阶方向与边界600张的定位决定了你还能做什么这个数据集的规模决定了它适合做三件事。第一用做领域内算法对比的公共基准可以在相同训练/测试划分下比较不同分割网络的性能但报告时必须注明通过患者级划分来消除数据泄漏否则你的对比结果在审稿人那里站不住脚。第二用做半监督学习的起点——600张有标签图像可以结合大量无标签超声图像通过一致性正则化或者伪标签的方式进一步提升分割精度这在医疗场景数据获取成本高时是优先选择。第三做超声特有的挑战点分析比如把结节按边界清晰度、内部钙化程度分组对比模型在不同亚组上的性能差异这类工作比单纯刷Dice更有临床导向价值。而对于想用这个数据集直接做甲状腺结节良恶性分类的方案思路会跳到另外一个方向上——分割模型的编码器可以作为特征提取器把结节区域的特征向量接上分类头来区分良恶性这已经突破了纯分割任务的范畴但也是分割基线做扎实之后很自然的下一步延伸。说到底600多张图是让你练手、建基线、形成方法论闭环的良好起点不是终点。从我自己做医疗影像项目的习惯来讲这个数据集适合先跑U-Net基线摸清任务上限再逐步替换成更强的主干网络每一步都留好配置和随机种子做到可复现、可追溯。别指望一上来就得到临床可用的绝对精度能把流程跑通、把指标算明白已经比80%匆忙开训的团队扎实得多。希望帮到你。本文还有配套的精品资源点击获取