简介:这份资源面向医学图像分割方向的研究者、算法工程师与相关专业学生,提供超声波背景下的甲状腺结节分割数据集,可用于训练与评估语义分割模型,解决结节区域自动提取与边界识别问题。包内共2000个文件,以1999张png图像为主,涵盖原始超声图与对应mask标签,另有1个Python可视化脚本;压缩包约333.25MB,采用7z格式。数据按训练集与测试集划分:训练集含5831张图片及5831个对应mask,测试集含1457张图片及1457个对应mask,分辨率覆盖400至1000,mask为前景255的二值图像,便于直观观察结节区域。可视化脚本可随机抽取一张图片,将原始图像、GT图像以及GT在原图上的蒙板结果一并展示并保存至当前目录,方便快速核验标注质量与分割效果。目前已有406人学习,适合需要真实超声数据开展结节分割实验、模型对比与结果可视化的读者。
1. 甲状腺结节超声分割数据集:5831 张训练图能跑出什么结果
超声甲状腺结节分割这个方向,真正卡住大多数人的从来不是模型结构,而是数据。公开渠道能拿到的甲状腺超声数据要么只有分类标签没有像素级 mask,要么图像数量少到连一次像样的消融实验都撑不起来。这个数据集给的是 400–1000 分辨率下的甲状腺结节分割样本,训练集 5831 对图像与 mask,测试集 1457 对,mask 统一为前景 255 的二值图,结节区域一目了然。它适合三类人:想入门医学图像分割但找不到合规数据的学生、需要验证 U-Net 类结构在超声场景下表现的算法工程师、以及做甲状腺辅助诊断产品原型时需要快速搭 baseline 的开发者。超声图像特有的斑点噪声、低对比度、结节边界模糊,让它比自然图像分割更考验预处理和后处理,这也是它值得单独拿出来跑一遍的原因。
2. 数据集结构与格式解析:从目录树到 mask 编码
2.1 目录组织与文件命名规律
拿到压缩包解压后,你会看到训练集和测试集各自独立成目录,每个目录下再分 images 和 masks 两个子目录。images 里是原始超声截图,masks 里是对应的二值标注图。文件名采用数字加下划线的形式,比如 2284_1.png、1365_2.png、2301_2.png、375_1.png、2331_1.png、2337_2.png、2337_1.png、2301_1.png、1191_2.png、2251_1.png 这一组,下划线前的数字是病例或采集批次编号,下划线后的数字是同组内的帧序号。这种命名方式意味着同一个病例可能有多帧图像,做训练集/验证集划分时如果按文件名随机切分,同一病例的不同帧可能同时出现在训练和验证里,造成数据泄漏。常见做法是按病例编号分组后再划分,保证验证集里的病例在训练集中从未出现过。
images 和 masks 的文件名是一一对应的,这一点在写 DataLoader 时可以直接用文件名匹配,不需要额外的映射表。但要注意,部分超声设备导出的 PNG 带有 alpha 通道或调色板信息,直接用 PIL 读取后转 numpy 可能得到四通道数组,训练时会报维度不匹配。稳妥的做法是在 Dataset 类里统一做一次 convert('L') 或 convert('RGB'),把通道数固定下来。
2.2 mask 的像素值分布与二值化处理
mask 是前景为 255 的二值图像,背景为 0。这个设计对可视化很友好,但在训练时有一个容易翻车的点:如果你用交叉熵损失,标签必须是 0/1 的 long 类型;如果你用 Dice Loss 或 BCE,标签需要是 0/1 的 float 类型。直接从 PNG 读进来是 0/255 的 uint8,不做归一化就送进损失函数,梯度会直接炸掉。我一般会在 Dataset 的__getitem__里做一步mask = (mask > 127).astype(np.float32),把 255 映射到 1.0,0 保持 0.0,这样无论后面接哪种损失都不会出问题。
另外,超声图像本身是灰度的,但有些采集设备会存成三通道的伪彩色 PNG。如果你的 images 读出来是三通道而 mask 是单通道,在 DataLoader 里 collate 的时候会因为形状不一致报错。解决办法是在 Dataset 里对 image 做convert('L')再np.expand_dims成单通道,或者统一转成三通道,取决于你用的骨干网络是否接受灰度输入。
2.3 分辨率差异对训练的影响
数据集覆盖 400–1000 分辨率,这意味着图像尺寸并不统一。直接送进网络之前必须做 resize。这里有一个选择:是统一 resize 到固定尺寸(比如 256×256 或 512×512),还是按 batch 内最大尺寸做 padding。前者实现简单,但会改变结节的形状比例,对于边界本来就模糊的超声图像,过度缩放可能让小结节丢失细节。后者保留原始比例,但需要自定义 collate_fn,且 batch 内尺寸差异大时 padding 区域会浪费显存。
我一般会先把所有图像短边缩放到 512,长边按比例缩放后中心裁剪到 512×512。这样既保留了结节的相对大小,又保证了输入尺寸一致。如果你的显存有限,可以降到 256×256,但建议先在小分辨率上跑通流程,再逐步放大验证效果差异。
3. 从零跑通分割训练:DataLoader、损失函数与评估指标
3.1 构建 Dataset 与 DataLoader
下面这段代码是一个可以直接抄的 Dataset 实现,假设你的目录结构是train/images和train/masks,测试集同理。关键点在于文件名匹配、通道统一和 mask 二值化。
import os import numpy as np from PIL import Image from torch.utils.data import Dataset, DataLoader import torch class ThyroidDataset(Dataset): def __init__(self, img_dir, mask_dir, img_size=512, transform=None): self.img_dir = img_dir self.mask_dir = mask_dir self.img_size = img_size self.transform = transform # 只保留 images 目录下存在的文件名,避免 masks 里有孤儿文件 self.names = sorted([ f for f in os.listdir(img_dir) if f.lower().endswith(('.png', '.jpg', '.jpeg')) ]) def __len__(self): return len(self.names) def __getitem__(self, idx): name = self.names[idx] img_path = os.path.join(self.img_dir, name) mask_path = os.path.join(self.mask_dir, name) # 统一转灰度,避免三通道/四通道不一致 img = Image.open(img_path).convert('L') mask = Image.open(mask_path).convert('L') # resize 到固定尺寸,这里用双线性插值处理图像,最近邻处理 mask img = img.resize((self.img_size, self.img_size), Image.BILINEAR) mask = mask.resize((self.img_size, self.img_size), Image.NEAREST) img = np.array(img, dtype=np.float32) / 255.0 mask = (np.array(mask, dtype=np.float32) > 127).astype(np.float32) # 增加通道维度: (H, W) -> (1, H, W) img = np.expand_dims(img, axis=0) mask = np.expand_dims(mask, axis=0) if self.transform: img, mask = self.transform(img, mask) return torch.from_numpy(img), torch.from_numpy(mask) # 使用示例 train_ds = ThyroidDataset('train/images', 'train/masks', img_size=512) train_loader = DataLoader(train_ds, batch_size=8, shuffle=True, num_workers=4)逻辑说明:convert('L')强制灰度,避免通道数不一致;mask 用NEAREST插值是因为最近邻不会产生中间灰度值,保证二值性;> 127把 255 映射到 1.0,同时兼容可能存在的抗锯齿边缘。参数方面,img_size建议从 256 起步,确认流程跑通后再升到 512;batch_size在 8GB 显存下 512 分辨率建议设为 4 或 8,配合梯度累积也能达到大 batch 的效果。
3.2 损失函数选择与组合策略
超声分割里结节区域通常只占图像的 5%–15%,背景占绝对多数。如果只用交叉熵,模型会倾向于全部预测为背景,准确率看起来很高但 Dice 接近 0。常见做法是 Dice Loss 和 BCE 按权重组合,比如loss = 0.5 * BCE + 0.5 * Dice。Dice Loss 直接优化重叠区域,对类别不平衡不敏感;BCE 提供稳定的梯度信号,避免训练初期 Dice 梯度消失。
import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): def __init__(self, weight_bce=0.5, weight_dice=0.5): super().__init__() self.weight_bce = weight_bce self.weight_dice = weight_dice self.bce = nn.BCEWithLogitsLoss() def forward(self, logits, targets): # logits 是未经过 sigmoid 的输出 bce_loss = self.bce(logits, targets) probs = torch.sigmoid(logits) # 计算 Dice,加平滑项防止除零 intersection = (probs * targets).sum(dim=(2, 3)) union = probs.sum(dim=(2, 3)) + targets.sum(dim=(2, 3)) dice = (2. * intersection + 1e-6) / (union + 1e-6) dice_loss = 1 - dice.mean() return self.weight_bce * bce_loss + self.weight_dice * dice_loss参数说明:weight_bce和weight_dice可以根据验证集 Dice 调整,如果 Dice 上升缓慢就加大 dice 权重,如果训练不稳定就加大 bce 权重。平滑项1e-6是防止空 mask 导致除零,虽然这个数据集里每张图都有结节,但 batch 内某张图的预测可能全为背景,所以保留平滑项是必要的。
3.3 评估指标与验证集划分
评估不能只看准确率。医学分割里常用 Dice 系数和 IoU。Dice 对小结节更敏感,IoU 对大区域更稳定。建议两个都记录,以 Dice 为主。验证集划分要按病例编号分组,不能随机按文件切。如果你拿到的文件名里下划线前的数字就是病例号,可以这样分组:
import re from sklearn.model_selection import GroupKFold def get_case_id(filename): # 提取下划线前的数字作为病例编号 match = re.match(r'(\d+)_', filename) return match.group(1) if match else filename names = train_ds.names groups = [get_case_id(n) for n in names] # 用 GroupKFold 保证同一病例不出现在训练和验证中 gkf = GroupKFold(n_splits=5) for train_idx, val_idx in gkf.split(names, groups=groups): train_subset = torch.utils.data.Subset(train_ds, train_idx) val_subset = torch.utils.data.Subset(train_ds, val_idx) break # 这里只演示第一折逻辑说明:GroupKFold按 groups 分组,同一病例的所有帧只会出现在训练或验证其中一边。参数n_splits=5表示五折交叉验证,实际跑的时候可以先固定一折做快速迭代,最后再跑完整交叉验证。
4. 可视化脚本拆解:原图、GT 与叠加蒙板怎么生成
4.1 随机采样与三图拼接逻辑
数据集自带一个可视化脚本,随机抽一张图,展示原始图像、GT 二值图和 GT 在原图上的蒙板叠加,并保存到当前目录。这个脚本看起来简单,但有几个细节决定了输出是否直观。第一,原图和 mask 必须用同一个文件名读取,否则会出现图文不对应。第二,叠加蒙板时颜色要选对比度高的,比如红色或绿色,透明度控制在 0.3–0.5,太透明看不清边界,太不透明遮住原图纹理。第三,保存时建议把三张图横向拼接成一张大图,方便对比。
import os import random import numpy as np from PIL import Image import matplotlib.pyplot as plt def visualize_one(img_dir, mask_dir, save_path='vis_result.png'): names = [f for f in os.listdir(img_dir) if f.lower().endswith('.png')] name = random.choice(names) img = np.array(Image.open(os.path.join(img_dir, name)).convert('L')) mask = np.array(Image.open(os.path.join(mask_dir, name)).convert('L')) mask_bin = (mask > 127).astype(np.uint8) # 创建 RGB 叠加图 overlay = np.stack([img] * 3, axis=-1) overlay[mask_bin == 1] = [255, 0, 0] # 结节区域标红 overlay = (0.6 * np.stack([img] * 3, axis=-1) + 0.4 * overlay).astype(np.uint8) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img, cmap='gray') axes[0].set_title('Original') axes[1].imshow(mask_bin, cmap='gray') axes[1].set_title('GT Mask') axes[2].imshow(overlay) axes[2].set_title('Overlay') for ax in axes: ax.axis('off') plt.tight_layout() plt.savefig(save_path, dpi=150) plt.close() print(f'Saved to {save_path}, sample: {name}') visualize_one('train/images', 'train/masks')逻辑说明:overlay[mask_bin == 1] = [255, 0, 0]把结节区域染红,再用 0.6 原图加 0.4 叠加图做混合,既能看到结节位置又不丢失超声纹理。参数dpi=150保证保存的图片足够清晰,figsize=(15, 5)让三张图并排时不至于太挤。
4.2 批量可视化与训练过程监控
单张随机可视化适合快速检查数据质量,但训练过程中你更需要看模型预测和 GT 的对比。我一般会在验证阶段每跑完一个 epoch,抽 4 张验证图,把原图、GT、预测二值图、预测叠加图排成 4×4 的网格保存下来。这样能直观看到模型是在欠拟合还是过拟合,也能发现某些特定形态的结节始终分割不好。
def visualize_batch(model, dataset, device, save_path='val_vis.png', num_samples=4): model.eval() indices = random.sample(range(len(dataset)), num_samples) fig, axes = plt.subplots(num_samples, 4, figsize=(16, 4 * num_samples)) with torch.no_grad(): for i, idx in enumerate(indices): img, mask = dataset[idx] img_tensor = img.unsqueeze(0).to(device) pred = torch.sigmoid(model(img_tensor)).cpu().squeeze().numpy() pred_bin = (pred > 0.5).astype(np.uint8) img_np = img.squeeze().numpy() mask_np = mask.squeeze().numpy() overlay = np.stack([img_np] * 3, axis=-1) overlay[pred_bin == 1] = [0, 255, 0] axes[i][0].imshow(img_np, cmap='gray') axes[i][1].imshow(mask_np, cmap='gray') axes[i][2].imshow(pred_bin, cmap='gray') axes[i][3].imshow(overlay) for j in range(4): axes[i][j].axis('off') plt.tight_layout() plt.savefig(save_path, dpi=120) plt.close()参数说明:pred > 0.5是默认阈值,实际部署时可以根据验证集 Dice 曲线选最优阈值,通常在 0.4–0.6 之间。num_samples=4是显存和可读性的折中,太多图会导致单张图太小看不清边界。
5. 避坑与排查:超声分割里最容易翻车的五个点
5.1 现象:训练 loss 正常下降但 Dice 始终在 0.1 以下
原因通常是 mask 没有做二值化归一化,255 直接送进 BCE 导致梯度异常,或者图像和 mask 在 DataLoader 里没有对齐,模型学到的是随机映射。解决方法是检查__getitem__里 mask 是否做了> 127的映射,以及 image 和 mask 是否来自同一个文件名。可以在 Dataset 里加一行断言assert img.shape == mask.shape,提前暴露形状不匹配。
5.2 现象:验证集 Dice 很高但测试集一跑就崩
这是典型的数据泄漏。同一病例的多帧图像被随机分到了训练和验证两边,模型记住了这个病例的纹理特征而不是结节本身的形态。解决办法是按病例编号分组划分,用 GroupKFold 或者手动按文件名前缀切分。检查方法是打印训练集和验证集的病例编号集合,确认交集为空。
5.3 现象:显存溢出,batch_size 降到 1 还是 OOM
超声图像分辨率到 1000 时,即使 batch_size=1,U-Net 类结构的中间特征图也会占大量显存。常见做法是先把图像 resize 到 256 或 384 跑通流程,再用梯度累积模拟大 batch。另外检查是否有不必要的张量保留在计算图中,比如在验证阶段忘记加torch.no_grad()。如果用的是 PyTorch,可以用torch.cuda.empty_cache()清理缓存,但根本解决办法还是降低输入分辨率或换更轻量的骨干网络。
5.4 现象:mask 边缘出现锯齿或断裂
这通常是 resize 时对 mask 用了双线性插值,导致二值边界产生了中间灰度值,再经过> 127阈值化后边界变得不规则。解决办法是 mask 的 resize 必须用Image.NEAREST,图像可以用Image.BILINEAR。另外,如果原始 mask 本身就有锯齿,可以在后处理阶段用形态学开闭运算平滑边界,但要注意不要过度平滑导致小结节被抹掉。
5.5 现象:可视化脚本保存的图片全黑或全白
检查plt.imshow的 cmap 参数和归一化范围。如果图像数组是 0–255 的 uint8,imshow默认会按 0–255 显示,但如果你在 Dataset 里已经除了 255,可视化时又没乘回来,就会显示为全黑。解决办法是在可视化函数里统一用原始 0–255 数据,或者在imshow里加vmin=0, vmax=1。另外,保存图片时如果用了plt.savefig但之前调用了plt.close(),会保存空图,顺序不能反。
6. 进阶技巧:用测试集做阈值搜索与模型集成
跑通 baseline 之后,真正拉开差距的往往不是换更复杂的网络,而是后处理阈值和模型集成。这个数据集给了 1457 张测试图,足够你做一次阈值搜索。具体做法是:在验证集上跑一遍模型,保存所有预测的概率图,然后从 0.3 到 0.7 以 0.05 为步长遍历阈值,计算每个阈值下的 Dice,选最高的那个作为最终阈值。注意阈值要在验证集上选,不能在测试集上选,否则就是另一种形式的数据泄漏。
import numpy as np def search_threshold(model, val_loader, device): model.eval() all_probs = [] all_targets = [] with torch.no_grad(): for imgs, masks in val_loader: imgs = imgs.to(device) probs = torch.sigmoid(model(imgs)).cpu().numpy() all_probs.append(probs) all_targets.append(masks.numpy()) all_probs = np.concatenate(all_probs, axis=0) all_targets = np.concatenate(all_targets, axis=0) best_thresh, best_dice = 0.5, 0.0 for thresh in np.arange(0.3, 0.71, 0.05): preds = (all_probs > thresh).astype(np.float32) intersection = (preds * all_targets).sum() union = preds.sum() + all_targets.sum() dice = (2. * intersection + 1e-6) / (union + 1e-6) if dice > best_dice: best_dice = dice best_thresh = thresh print(f'Best threshold: {best_thresh:.2f}, Dice: {best_dice:.4f}') return best_thresh参数说明:np.arange(0.3, 0.71, 0.05)覆盖了常见的概率阈值范围,如果你的模型输出普遍偏高或偏低,可以扩大搜索范围。1e-6平滑项和训练时保持一致,保证可比性。
模型集成方面,最简单的做法是训练 3–5 个不同随机种子的 U-Net,推理时对概率图取平均,再统一阈值化。集成通常能把 Dice 提升 1–3 个百分点,代价是推理时间线性增加。如果部署环境对延迟敏感,可以只集成两个差异最大的模型,比如一个用 Dice+BCE 训练,另一个用 Focal Loss 训练,两者的错误模式不同,互补性更强。
还有一个容易被忽略的点:测试集的 1457 张图里可能包含训练集中从未出现过的结节形态或采集设备。如果你的模型在测试集上某些子集表现特别差,可以把测试集按图像亮度或对比度做聚类,看看是不是某些低质量图像拉低了整体指标。针对这些子集做定向增强,比如加高斯噪声、调整 gamma,往往比盲目加深网络更有效。
从那以后我每次拿到新的医学分割数据集,都会先跑一遍病例分组检查、mask 二值化检查和阈值搜索,这三步走完再谈模型结构。希望帮到你。
本文还有配套的精品资源,点击获取