ARTICLE DETAIL

资讯详情

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

甲状腺结节超声图像分割数据集实战:从预处理到模型训练避坑

甲状腺结节超声图像分割数据集实战:从预处理到模型训练避坑 简介面向医学图像分割研究与深度学习开发的甲状腺结节超声图像数据集汇集超过600张影像及对应分割标签适用于训练、验证和评估语义分割模型服务于甲状腺结节自动检测与边界勾画任务。文件总数1277个以1275张PNG格式的超声原图和标注结果图为主另附txt标签说明文件与Python辅助脚本压缩包仅25.13MB便于快速下载与本地迁移。数据在发布前已完成对比度拉伸、尺寸归一化等图像增广处理并按训练集与验证集完成预划分可直接用于模型训练与交叉验证省去自行清洗与整理的繁琐环节。目前已有700人浏览学习。借助该资源读者可获得一套可直接投入使用的医学图像分割数据基础同时参考其预处理流程、标签组织方式与classes文件标注格则为甲状腺超声影像的自动化分析研究提供便利有助于快速验证分割算法、加速课题起步。1. 医学图像分割数据集600超声甲状腺结节标注图下载后先想清楚怎么用做医学图像分割的人手里最缺的不是模型而是能直接跑通训练流程的干净数据。这份甲状腺结节超声图像分割数据集包含超过600张B超图像和对应的分割标签预划分好了训练集和验证集图像还做了对比度拉伸、resize等预处理。它的直接价值是让研究者和新手绕开“收集超声数据、找医生标注”这个最耗时的环节把精力放在分割模型对比、训练流程搭建和论文实验上。适合做医学图像分割课题的学生、做超声影像辅助诊断算法验证的工程师以及想快速跑一个语义分割baseline的人。需要提醒的是它不是临床诊断级别的标注数据别拿来做医疗决策但做算法研究、课程设计和对比实验完全够用。2. 认识数据集结构从PNG文件名到classes.txt的对应关系2.1 超声图像与分割掩码的存储约定这类数据集通常按“图像 同名掩码”的方式组织。原始物料里能看到 result.png、247.PNG、42.PNG、221.PNG、395.PNG 这样的文件命名比较散大小写混用、序号不连续说明这批图像是从超声采集设备导出的原始截图中整理出来的。下载解压后不要直接往训练代码里塞先把目录归拢成统一结构。我一般先建这样的目录结构thyroid_ultrasound/ ├── images/ # 输入超声图像 ├── masks/ # 分割标签PNG ├── classes.txt # 类别列表 ├── train.txt # 训练集文件名 └── val.txt # 验证集文件名如果你的压缩包里没有 images 和 masks 两个子目录就用下面的脚本把散落的PNG分别归位。判断依据是图像是常规超声灰度图掩码是单通道、大部分区域为黑色、结节点为白色或高亮。import os import shutil from pathlib import Path src_dir Path(解压目录) img_dir Path(thyroid_ultrasound/images) mask_dir Path(thyroid_ultrasound/masks) img_dir.mkdir(parentsTrue, exist_okTrue) mask_dir.mkdir(parentsTrue, exist_okTrue) for f in src_dir.glob(*.PNG): # 文件名包含mask/label/gt的归为掩码否则按图像处理 if any(k in f.stem.lower() for k in (mask, label, gt, seg)): shutil.copy(f, mask_dir / (f.stem .png)) else: shutil.copy(f, img_dir / (f.stem .png))这段脚本的关键是“按文件名关键词区分图像和掩码”。但要注意很多超声分割数据集的掩码文件名和图像文件名完全相同只是放在不同目录。如果解压后所有PNG都混在一起先看几个文件的尺寸图像通常几百KB、掩码通常很小且几乎全黑。后缀我统一转成了小写.png避免在Linux环境里出现大小写不匹配的路径问题——这种问题看似小实际能让你在数据加载阶段反复翻车。2.2 classes.txt为什么写着Pneumothorax但标的是甲状腺结节下载后第一件事不是跑训练是打开 classes.txt 看一眼。cat classes.txt这份数据集的摘要里特意提到标签类别写的是“Pneumothorax”但结合项目名称“甲状腺结节超声图像”以及输出的分割目标正确的类别应当是甲状腺结节。Pneumothorax是气胸和甲状腺结节完全是两个领域这大概率是从其他数据源复制时遗留的类别名。处理方式很简单两个选择要么把 classes.txt 内容改成 thyroid_nodule要么在代码里忽略这个文件、直接用 “thyroid_nodule” 作为类别名。我建议后者因为改文件会留下记录万一以后要追溯原始数据类别名还保留着问题痕迹。同时要检查掩码的像素值范围确认前景值到底是0/1还是0/255。写个快速统计脚本import numpy as np from PIL import Image from pathlib import Path mask_dir Path(thyroid_ultrasound/masks) vals set() for f in list(mask_dir.glob(*.png))[:20]: arr np.array(Image.open(f)) vals.update(np.unique(arr).tolist()) print(f.name, arr.shape, sorted(set(arr.ravel()))) print(所有样本中出现过的像素值:, sorted(vals))这段代码只读取前20张就够目的不是全量统计而是快速确认掩码的编码方式。输出里如果出现[0, 255]那训练时就要先做一次 arr (arr 127) 把掩码转为0/1如果出现[0, 1]就直接用。很多分割库默认标签是0/1整数喂进去255会算出一个特别难看的Dice而且你一时很难想到是标签值范围的问题。2.3 训练集与验证集划分检查摘要里说“本数据集已经预划分了训练集和验证集”但具体划分方式要看 train.txt 和 val.txt 是否存在。如果不存在需要根据文件名自制划分脚本。先检查是否有重叠样本是最稳的做法sort train.txt train_sorted.txt sort val.txt val_sorted.txt comm -12 train_sorted.txt val_sorted.txtcomm 命令的第三列如果没有任何输出说明两个集合没有重叠。两三百行的文本文件用 comm 几毫秒就能查完。没有重叠是最基本的要求但实际项目里我遇到过好几份公开数据集train和val因为文件名大小写不同导致同一个样本被同时放进两个集合所以这一步不要跳过。顺便统计一下两份列表的文件数with open(train.txt) as f: train_names [line.strip() for line in f if line.strip()] with open(val.txt) as f: val_names [line.strip() for line in f if line.strip()] print(train: %d, val: %d % (len(train_names), len(val_names))) print(总样本: %d % (len(train_names) len(val_names)))训练集和验证集的比例不用太纠结常见的是8:2或9:1。600张图片的场景下验证集落在60到150张之间都算正常。比较重要的是保证同一病人或同一采集批次的图像不要既出现在训练集又出现在验证集——超声数据常有多张连续截图的“近亲”图像如果划分时没按病例ID分组评估指标会比真实水平偏高。这份数据集的划分是否考虑到这一点从文件名看不出来但做实验时要有这个意识。3. 预处理链路对比度拉伸、resize与标签同步的工程复现3.1 超声图像为什么必须做对比度拉伸超声图像和自然图像有很大区别。B超图像是灰度图软组织之间的灰度对比度很低加上超声波在组织界面产生的散斑噪声结节和周围组织的边界经常是模糊的。原始图像直接送进网络卷积核很难学到“低对比度下的边界信息”。对比度拉伸的本质是把灰度直方图拉开让结节的暗区或亮区和周围组织的灰度差变大。数据本身已经做了对比度拉伸和resize但复现它的预处理链路仍然有实际意义。第一训练时如果要换输入尺寸需要清楚原图是多大、怎么缩放的第二做推理时要对测试图像走同样的预处理否则模型会被灰度分布偏移带偏。常用方案有两种线性对比度拉伸和CLAHE。线性拉伸是把像素值从某个区间线性映射到0-255数学上最简单CLAHE限制对比度自适应直方图均衡把图像分块做直方图均衡并限制增益倍数能避免整张图亮度不均的问题。超声图像因为深度不同、近场远场衰减不均用全局线性拉伸往往会把深部噪声一起拉亮所以我在实际项目中更倾向CLAHE。3.2 对比度拉伸的参数实践下面这段代码是标准CLAHE处理流程和这份数据集描述里的“对比度拉伸”目标一致import cv2 import numpy as np def clahe_preprocess(img_path, clip_limit2.0, tile_grid(8, 8)): img cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) clahe cv2.createCLAHE(clipLimitclip_limit, tileGridSizetile_grid) img clahe.apply(img) return img # 用法示例 img clahe_preprocess(thyroid_ultrasound/images/247.PNG) cv2.imwrite(preprocessed/247.png, img)clipLimit 控制对比度增强的强度值越大增强越明显。超声图像里的散斑噪声比较多clipLimit超过3.0时噪声会被过度增强结节边界是清楚了但网络也会把伪影当特征。我一般先用2.0起步看结果不合适再调到2.5或3.0。tileGridSize 是分块大小(8,8)对512x512的图是比较常规的选择如果你的输入分辨率只有256x256(8,8)会让每个块只有32像素统计直方图时会不够稳可以改成(4,4)。要注意的是这份数据集在发布前已经做过预处理如果下载下来的图像看起来已经是“增强过的状态”再套一次CLAHE会导致双重拉伸极端情况下会出现灰度饱和。建议先用上一节的统计脚本看一下图像的灰度分布如果直方图已经接近均匀分布就跳过CLAHE直接做归一化。3.3 resize图像与mask必须用不同插值方式预处理里第二项是resize。常见的目标尺寸是256x256或512x512具体要看数据集原始尺寸。先扫描所有图像的尺寸确认是否统一from PIL import Image from collections import Counter from pathlib import Path counter Counter() for f in Path(thyroid_ultrasound/images).glob(*.png): im Image.open(f) counter[(im.width, im.height)] 1 print(counter.most_common(10))如果输出里尺寸很多说明原始图像不是统一尺寸需要做resize如果所有图已经是512x512之类的统一尺寸resize这一步其实已经做过你只需要在数据加载时保持网络输入和它一致。统一尺寸时图像和掩码的resize方式不能相同。图像用线性插值或双三次插值都行掩码必须用最近邻插值。原因很容易理解最近邻第二类插值会把像素压缩到最近的标签值保持0和1的边界锐利如果用线性插值去缩放掩码掩码边界会出现0.5、0.7这类中间值在损失函数里这些值会被当作“半目标”导致网络学到错误边缘。import cv2 from pathlib import Path target_size (512, 512) for img_path in Path(thyroid_ultrasound/images).glob(*.png): mask_path Path(thyroid_ultrasound/masks) / img_path.name img cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) mask cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) img_resized cv2.resize(img, target_size, interpolationcv2.INTER_LINEAR) mask_resized cv2.resize(mask, target_size, interpolationcv2.INTER_NEAREST) # 掩码二值化防止出现中间像素 _, mask_bin cv2.threshold(mask_resized, 127, 1, cv2.THRESH_BINARY) cv2.imwrite(str(img_path.parent / img_path.stem _resized.png), img_resized) cv2.imwrite(str(mask_path.parent / mask_path.stem _resized.png), mask_bin * 255)参数说明INTER_LINEAR是双线性插值适合灰度超声图像保留灰度平滑过渡INTER_NEAREST是最近邻插值只取最近像素的标签值不会产生新像素值。阈值127转二值这一步可选但我建议尽量做因为原mask如果就是0/255转成0/1能让训练时少踩类型坑。最后写盘时乘以255是为了方便肉眼检查训练时再除以255或者直接用0/1。3.4 增广图像变换与掩码必须走同一套几何变换预处理做完进入训练阶段还要做随机增广。但数据集本身已经做过“对比度拉伸、resize等图像增广”这里的“增广”在发布语境里更像是数据增强预处理而不是训练时的在线增广。训练时仍然可以再做随机翻转、旋转、缩放但要特别注意图像和掩码的同步。用OpenCV手写增广极其容易漏掉掩码建议直接用albumentations。它对图像和掩码的变换是完全同步的传一个mask参数就自动做了同样几何变换import albumentations as A import cv2 import numpy as np train_transform A.Compose([ A.RandomRotate90(p0.5), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.2), A.RandomScale(scale_limit0.1, p0.3), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.3), ]) def apply_augment(img, mask): augmented train_transform(imageimg, maskmask) return augmented[image], augmented[mask]代码里每个变换都对图像和mask同时生效。RandomRotate90对结节方向没有物理意义超声扫查时患者体位不同结节方向完全随机所以旋转增广是合理的。RandomScale会有padding问题albumentations默认会填充边界但填充值会参与卷积如果图像边缘出现黑边模型可能会学到“边缘就是背景”的偏置。scale_limit控制在0.1以内问题不大。对比度拉伸和resize这类固定预处理应该放在加载阶段做不进在线增广。否则每轮epoch图像灰度都在变化模型学到的东西就不稳定。这是数据和增广的一个基本分层固定处理放前面随机增广放数据加载器里。4. 把数据送进模型训练PyTorch加载器、损失函数与评估指标4.1 自定义Dataset类与掩码读取拿到数据后第一步要写一个标准的PyTorch Dataset。这个数据集不是COCO那种JSON标注格式也不是VOC那种XML格式而是最简单的“图像同名单通道mask”。这种格式的好处是加载逻辑透明坏处是很多新手会把mask当三通道RGB读进去导致后续损失函数计算时形状对不上。我习惯把图像读取为RGB三通道因为后面要用ImageNet预训练权重需要三通道输入mask读取为单通道灰度转换成0/1整数import torch from torch.utils.data import Dataset from PIL import Image import os class ThyroidUltrasoundDataset(Dataset): def __init__(self, image_dir, mask_dir, file_list, transformNone): with open(file_list, r) as f: self.samples [line.strip() for line in f if line.strip()] self.image_dir image_dir self.mask_dir mask_dir self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): name self.samples[idx] img_path os.path.join(self.image_dir, name) mask_path os.path.join(self.mask_dir, name) image Image.open(img_path).convert(RGB) mask Image.open(mask_path).convert(L) mask (np.array(mask) 127).astype(np.float32) mask torch.from_numpy(mask).unsqueeze(0) if self.transform: image self.transform(image) return image, masktransform这里我故意只用了torchvision的标准化不做几何增广因为几何增广要同时处理mask放在Dataset里写会比较绕。如果要用albumentations就把它用在PIL转numpy之后最后再转tensor。参数说明mask的shape是(1, H, W)喂给模型输出shape是(B, 1, H, W)用BCEWithLogitsLoss时不需要额外调整如果你的网络输出是多通道logits需要改成(B, num_classes, H, W)。4.2 损失函数BCEWithLogitsLoss与Dice结合超声结节分割里最经典的损失组合是BCE加Dice。原因是BCE对每个像素独立计算收敛稳定但对小目标不够敏感Dice直接优化分割区域的重叠度对类别不平衡有天然鲁棒性。甲状腺结节在超声图像里可能只占很小一块有时甚至不到全图面积的5%只用BCE会让模型倾向于预测“全是背景”所以必须用Dice或Focal Loss拉住它。Dice损失的核心实现是计算预测概率图和真实mask之间的重叠度import torch import torch.nn as nn import torch.nn.functional as F class DiceLoss(nn.Module): def __init__(self, smooth1.0): super().__init__() self.smooth smooth def forward(self, logits, targets): preds torch.sigmoid(logits) preds preds.reshape(preds.size(0), -1) targets targets.reshape(targets.size(0), -1) intersection (preds * targets).sum(dim1) union preds.sum(dim1) targets.sum(dim1) dice (2.0 * intersection self.smooth) / (union self.smooth) return 1.0 - dice.mean()smooth参数是平滑项防止前景区域为空时除以0。在超声数据集里部分图像可能没有结节或结节极小所以smooth必须要设。我一般设1.0小火慢炖如果你发现loss曲线早期抖动太大可以设2.0。训练时把BCE和Dice加起来用bce nn.BCEWithLogitsLoss() dice DiceLoss() # 每个step的loss loss bce(logits, targets) dice(logits, targets)这个组合里BCE在训练早期提供稳定的梯度信号Dice在后期帮模型收紧边界。超声图像边界模糊Dice比重可以调大一些我常用的是0.5BCE 1.0Dice。如果结节目标特别小可以进一步把Dice部分换成Tversky Loss给假阳性或假阴性不同的权重但那是后话了。4.3 Dice与IoU评估指标计算验证阶段不建议直接用损失值因为Dice损失经过sigmoid概率加权和最终二值分割结果不完全等价。标准做法是先对预测概率做阈值处理把概率图变成0/1掩码再和真实mask计算Dice和IoU。IoU的常见误用是把所有图像累加成一张全局混淆矩阵算一个IoU。对小目标分割来说全局混淆矩阵会被背景像素比例“稀释”一张图漏掉结节、另一张大面积预测正确全局IoU可能看起来还像样但实际模型不行。正确做法是逐图计算再求平均def dice_coefficient(pred, target, threshold0.5): pred_bin (pred threshold).float() intersection (pred_bin * target).sum() return (2.0 * intersection) / (pred_bin.sum() target.sum() 1e-8) def iou_score(pred, target, threshold0.5): pred_bin (pred threshold).float() intersection (pred_bin * target).sum() union pred_bin.sum() target.sum() - intersection return intersection / (union 1e-8)阈值默认0.5对超声结节这种目标来说足够如果希望提高敏感性可以在验证集上扫描阈值看0.3到0.7之间的Dice变化再选最优阈值用于测试。逐图平均的指标稳定性更好更接近医生逐张超声阅片的主观评价。这也是医学图像分割常见的一个坑用全局指标会把性能虚高一半以上。5. 避坑指南甲状腺结节超声数据集的六个翻车点5.1 classes.txt里类别名是Pneumothorax现象用现成训练框架读取classes.txt得到的类别列表是气胸Pneumothorax和甲状腺结节完全不搭边。如果框架用类别名做输出层模型会输出一个和语义完全无关的类别。原因这份数据集的classes.txt有问题可能沿用了其他胸部影像数据集的标签文件没有随图像内容更新。解决直接忽略classes.txt里的文本内容在代码里写死类别为 thyroid_nodule。如果用MMSegmentation之类框架需要把data配置里的classes改为一个元素列表不能用原文件。5.2 图像文件名大小写和扩展名不对齐现象images目录里有247.PNG但train.txt里写的是247.pngWindows下能跑Linux下FileNotFoundError。原因超声采集设备的导出文件名后缀为大写PNG而划分脚本或标注脚本统一改成了小写png。Windows文件系统不区分大小写掩盖了问题换到服务器就暴露。解决写数据加载时对文件存在性做一次归一化处理。要么统一用脚本把扩展名改成小写要么在Dataset里加一个回退逻辑先找小写不存在再找大写。5.3 掩码像素值不是0/1现象训练loss正常下降但验证集中Dice全部为0或者loss在某个值附近震荡不收敛。原因mask像素值是0和255网络输出的sigmoid概率范围是0到1两者不在一个量纲上。计算Dice时预测值永远无法和255重合所以指标一直很差。解决加载时强制做 (np.array(mask) 127).astype(np.float32)。多花一行代码能省半天的debug时间。5.4 resize后mask边缘出现灰色过渡带现象把mask用双线性插值resize后和原图叠加检查发现结节边界有一圈灰色半透明像素。原因线性插值在mask边界处计算了背景和前景的加权平均生成了0.5之类的中间值。这类中间值在二值交叉熵损失里不明确属于前景还是背景而且会在上采样时造成边界模糊。解决mask的resize强制用INTER_NEAREST并在写盘之前做一次阈值转二值。图像用双三次或双线性mask用最近邻这条规则要写成注释贴在你项目里。5.5 训练集和验证集存在跨病人重叠现象训练Dice很高验证Dice也高但换一批外部超声数据测试就崩盘。原因超声图像多为连续视频帧抽取同一病人可能出现在多个文件中。划分时如果只按文件名随机切分同一病人的两张相邻截图会被同时分到训练集和验证集相当于验证集泄漏了训练信息。解决如果数据文件名里有病人编号按病人编号分组划分如果信息不清晰至少要把训练和验证按文件名的某种哈希特征分开。这批数据集是否已经按此处理无法从文件名直接判定建议自己再验证一下重复模式。5.6 图像已经被增强过又做一次对比度拉伸现象模型训练曲线一开始正常后来越来越差或者验证集表现和训练集差距悬殊。原因数据发布时已经做了对比度拉伸和resize你在训练pipeline里又套了CLAHE或直方图均衡相当于对同一张图做两次非线性灰度变换超声图像里的噪声被二次放大。解决先检查图像灰度直方图。如果直方图已经比较舒展、不是挤在低灰度区域就不要主动再做CLAHE。预处理要遵循“原图是什么状态就按什么状态进模型”不要机械地叠加所有步骤。6. 落地技巧从baseline到可靠的分割结果拿到这份600多张的数据集我建议先跑一个最简单的U-Net baseline而不是直接上最新的大模型。超声图像结构较一致、结节目标形态相对简单一个轻量U-Net配合第4章介绍的BCEDice损失就能在验证集上拿到还不错的Dice。跑通之后再考虑换DeepLabV3、SegFormer这类更强网络做提升。要注意的是ImageNet预训练权重对超声图像的作用不如对自然图像那么明显因为超声灰度图像的低级特征分布和自然图像差异太大第一层卷积核学到的东西很多要重新适配。如果训练资源紧张先不加载预训练权重直接在数据上从零训练U-Net有时候效果并不差。验证方法上也有一条经验不要只在512x512的resize尺寸上评估。训练时resize是为了统一输入但实际临床超声图像分辨率各不相同。训练结束后把模型输出的分割结果上采样回原始图像尺寸再做一次Dice评估这才是真实使用场景下的性能。如果上采样后指标掉得厉害说明模型的边界表达能力不足需要回去调整损失函数权重或加深解码器。最后一件事是可视化。分割模型的结果不能只看数字指标建议在验证集上挑十几张图把原始超声图、真实掩码、预测掩码叠加成三列对比图。超声结节边缘本身模糊Dice高不一定代表边界贴合看可视化图比看指标更容易发现系统性偏差比如模型总是把甲状腺包膜当作结节、或者总是漏掉低回声区域的边缘毛刺。从那次以后我每次下载这类医学图像分割数据集都会强制走一遍“先查classes.txt、再统计mask像素、再扫描文件大小写、再逐图算指标”的流程磨刀不误砍柴工。这套流程花不了十五分钟但能避免在最基础的地方浪费一两天时间希望帮到你。本文还有配套的精品资源点击获取
返回列表