简介:面向医学图像分割研究与深度学习开发的甲状腺结节超声图像数据集,汇集超过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(parents=True, exist_ok=True) mask_dir.mkdir(parents=True, exist_ok=True) 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_limit=2.0, tile_grid=(8, 8)): img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE) clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_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, interpolation=cv2.INTER_LINEAR) mask_resized = cv2.resize(mask, target_size, interpolation=cv2.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(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.2), A.RandomScale(scale_limit=0.1, p=0.3), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.3), ]) def apply_augment(img, mask): augmented = train_transform(image=img, mask=mask) 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, transform=None): 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, smooth=1.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(dim=1) union = preds.sum(dim=1) + targets.sum(dim=1) 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, threshold=0.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, threshold=0.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.png,Windows下能跑,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章介绍的BCE+Dice损失,就能在验证集上拿到还不错的Dice。跑通之后,再考虑换DeepLabV3+、SegFormer这类更强网络做提升。要注意的是,ImageNet预训练权重对超声图像的作用不如对自然图像那么明显,因为超声灰度图像的低级特征分布和自然图像差异太大,第一层卷积核学到的东西很多要重新适配。如果训练资源紧张,先不加载预训练权重,直接在数据上从零训练U-Net,有时候效果并不差。
验证方法上也有一条经验:不要只在512x512的resize尺寸上评估。训练时resize是为了统一输入,但实际临床超声图像分辨率各不相同。训练结束后,把模型输出的分割结果上采样回原始图像尺寸,再做一次Dice评估,这才是真实使用场景下的性能。如果上采样后指标掉得厉害,说明模型的边界表达能力不足,需要回去调整损失函数权重或加深解码器。
最后一件事是可视化。分割模型的结果不能只看数字指标,建议在验证集上挑十几张图,把原始超声图、真实掩码、预测掩码叠加成三列对比图。超声结节边缘本身模糊,Dice高不一定代表边界贴合;看可视化图比看指标更容易发现系统性偏差,比如模型总是把甲状腺包膜当作结节、或者总是漏掉低回声区域的边缘毛刺。从那次以后,我每次下载这类医学图像分割数据集,都会强制走一遍“先查classes.txt、再统计mask像素、再扫描文件大小写、再逐图算指标”的流程,磨刀不误砍柴工。这套流程花不了十五分钟,但能避免在最基础的地方浪费一两天时间,希望帮到你。
本文还有配套的精品资源,点击获取