☰
超声乳腺良性分割数据集实战:从数据检查到模型训练避坑指南
2026/10/14 13:04:15 网站建设 项目流程

简介:超声乳腺良性图像分割数据集,面向医学影像分析、深度学习分割方向的研究者与学生,适用于训练和评估二分类分割模型(背景与良性乳腺区域)。数据划分明确,训练集约300张原始超声图像及对应mask标签,测试集约130张,并附带classes类别文件,便于直接开展模型训练与指标验证。压缩包共877个文件,以875张png图像为主,另含1个类别说明txt与1个可视化脚本py,整体体积约88.01MB。可视化脚本可随机抽取一张图像,并生成原始图、GT标签图及GT叠加在原图上的效果图,方便快速检查标签质量与分割效果。目前已有962人学习下载,适合刚入门医学图像分割或需要标准超声乳腺数据集的读者,可减少数据收集与预处理成本,聚焦于模型设计与实验比较。

1. 超声乳腺良性分割数据集:拿到手先确认三件事

做医学图像分割的人拿到一个新数据集,第一反应不应该是直接丢进模型训练,而是先回答三个问题:GT到底长什么样、类别是怎么定义的、训练集和测试集是否真的对齐。这个超声乳腺良性图像分割数据集提供的就是一套「背景 + 良性乳腺」的二类分割训练/测试集,约 430 张超声图像和对应 mask,外加一个快速可视化脚本。很多人在小数据集上翻车,不是模型不行,而是没先看 GT 就把数据喂进去了。适合的场景很明确:你要做超声影像的病灶分割验证、跑二类分割基线、或者给学生做入门实战,这套数据够用了。

2. 拆开目录看结构:images、masks 与可视化脚本的配合方式

拿到数据集先别急着训练,把目录结构和文件对应关系搞清楚,后面能省大量排查时间。

2.1 目录结构与命名规律

数据集的划分已经替你做好了:训练集约 300 张,测试集约 130 张。每一张图像都对应一张同名的 mask 文件,文件命名形如benign (117).png,这类带括号编号的命名很常见,但有个隐患:括号和空格在 Linux shell 里需要转义,批量处理脚本稍不注意就会漏文件,这是后话。

以训练集为例,目录结构大致长这样:

train/ ├── images/ │ ├── benign (1).png │ ├── benign (2).png │ └── ... └── masks/ ├── benign (1).png ├── benign (2).png └── ...

测试集结构一致,只是数量不同。classes文件里定义了类别顺序,一般第一类是背景,第二类是良性乳腺。这个顺序直接决定训练时的通道排列,读代码时先确认是否class 0 = background。

2.2 用 Python 快速核对图像与 mask 的完整性

我拿到数据集的第一件事永远是写一个完整性检查脚本,确认图像和 mask 数量一致、尺寸一致、mask 是否为二值。常见做法是列出两张表的文件名做差集,再随机抽样检查尺寸。

import os from PIL import Image train_img_dir = "train/images" train_mask_dir = "train/masks" imgs = sorted(os.listdir(train_img_dir)) masks = sorted(os.listdir(train_mask_dir)) # 核对数量 print(f"images: {len(imgs)}, masks: {len(masks)}") # 找出没有对应 mask 的图像 img_names = {os.path.splitext(f)[0] for f in imgs} mask_names = {os.path.splitext(f)[0] for f in masks} print("缺 mask 的图像:", img_names - mask_names) print("缺 image 的 mask:", mask_names - img_names) # 抽查尺寸是否一致 for f in imgs[:3]: img = Image.open(os.path.join(train_img_dir, f)) m = Image.open(os.path.join(train_mask_dir, f)) print(f, img.size, m.size, img.size == m.size)

这段脚本解决两个问题:一是配对是否完整,二是尺寸是否对齐。很多数据集下载后文件不齐或者尺寸被二次压缩过,不检查就训练,模型学到的东西可能是错位的。

有一点需要说明:mask 虽然是 PNG 格式,但不一定严格只有 0 和 255 两种值。可能带抗锯齿边缘、可能是 16 位灰度、也可能标签是 0 和 1。训练前最好把 mask 统一重映射一次,确保类别索引符合预期。

import numpy as np mask = np.array(Image.open(mask_path).convert("L")) print("mask 取值:", np.unique(mask)) # 如果出现多值但逻辑上是二值,做一次重映射 if len(np.unique(mask)) > 2: mask_bin = (mask > 127).astype(np.uint8) Image.fromarray(mask_bin * 255).save("remapped_mask.png")

做重映射时注意阈值选择,127 是常见做法,但对边界模糊的超声图像,如果 mask 边缘有淡淡的过渡带,直接用 127 切可能会丢掉细小的边缘区域。

2.3 自带可视化脚本:三张图直接看清数据质量

数据集附带了一个可视化脚本,作用是从数据中随机抽一张图,把原始图、GT mask、GT 在原图上的半透明蒙板并排展示并保存。这个脚本虽然简单,但价值很高,它强制你在训练前「看」一眼数据长什么样。

import matplotlib.pyplot as plt from PIL import Image import numpy as np def visualize(img_path, mask_path, save_path="visual.png"): img = np.array(Image.open(img_path).convert("RGB")) mask = np.array(Image.open(mask_path).convert("L")) overlay = img.copy() overlay[mask > 127] = (overlay[mask > 127] * 0.5 + [255, 0, 0] * 0.5).astype(np.uint8) fig, axes = plt.subplots(1, 3, figsize=(15, 5)) axes[0].imshow(img) axes[0].set_title("Original") axes[1].imshow(mask, cmap="gray") axes[1].set_title("GT") axes[2].imshow(overlay) axes[2].set_title("Overlay") plt.savefig(save_path, bbox_inches="tight")

这里overlay[mask > 127]的作用是找到 mask 里的前景区域,然后把原图像素和红色做 0.5 加权融合。这个阈值和上面重映射的阈值保持一致,不然可能出现蒙板区域和 GT 对不上的视觉误差。

用这个脚本跑完后,建议重点看几个指标:病灶边界是否清晰、mask 是否包含了一些不属于病灶的杂散区域、图像本身有没有标注框或文字干扰。超声图像的噪声本来就重,如果 mask 边界切得太碎,后续训练的收敛速度会明显变慢。

潜在的坑在于:可视化脚本里如果直接用mask > 0判断前景,而 mask 里存在灰度值非 0 的背景噪声(压缩伪影偶尔会引入),蒙板会把噪声区域也标红,看起来像标注错误。我一般会在可视化之前做一次二值化,确保显示效果和实际训练输入一致。

3. 训练一套二类分割模型:从数据加载到评估指标

数据没问题之后,接下来就是把数据送进模型。这个数据集只有二类(背景和良性乳腺),分割任务本身不复杂,但配置层面有几个关键决定会影响结果:数据加载方式、Loss 组合、评估指标选择。

3.1 写一个 Dataset 类完成图像与 mask 配对

PyTorch 写分割训练,第一步是自定义 Dataset。重点在于图像和 mask 的预处理一定要同步:图像做归一化、resize、数据增强时,mask 必须跟着做同样的几何变换,而且 mask 在增强时不能做像素值插值,否则类别会变花。

import torch from torch.utils.data import Dataset from PIL import Image import numpy as np import albumentations as A class BreastDataset(Dataset): def __init__(self, img_dir, mask_dir, size=(256, 256), augment=False): self.img_dir = img_dir self.mask_dir = mask_dir self.size = size self.augment = augment self.files = [f for f in sorted(os.listdir(img_dir)) if f.endswith(".png")] self.aug_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomBrightnessContrast(p=0.2), A.RandomScale(scale_limit=0.05, p=0.2), ]) self.base_transform = A.Compose([ A.Resize(height=size[0], width=size[1]), ]) def __len__(self): return len(self.files) def __getitem__(self, idx): img_path = os.path.join(self.img_dir, self.files[idx]) mask_path = os.path.join(self.mask_dir, self.files[idx]) img = np.array(Image.open(img_path).convert("RGB")) mask = np.array(Image.open(mask_path).convert("L")) # mask 二值化并转为 0/1 标签 mask = (mask > 127).astype(np.uint8) if self.augment: augmented = self.aug_transform(image=img, mask=mask) else: augmented = self.base_transform(image=img, mask=mask) img = augmented["image"].astype(np.float32) / 255.0 mask = augmented["mask"].astype(np.int64) # HWC -> CHW img = torch.from_numpy(img).permute(2, 0, 1) mask = torch.from_numpy(mask) return img, mask

这段代码有几个值得注意的设计决定。

第一,mask > 127的二值化放在增广之前,用 127 做阈值是因为这个数据集里 mask 大概率是 0/255 的二值图,但做一次归一化更保险。如果你发现有的 mask 值是 1 而不是 255,这个判断也成立。

第二,增广用的是 albumentations 而不是 torchvision,原因很简单:torchvision 做同步 mask 变换需要手动写,albumentations 原生支持 image + mask 同步变换,代码量少很多。

第三,RandomScale的scale_limit=0.05设得很小,因为超声图像里的病灶边缘本来就是模糊的,大幅缩放会改变病灶相对尺寸,影响分割准确率。我一般对医学影像只开小比例缩放。

第四,最后把 mask 转成int64,这是 PyTorch CrossEntropyLoss 的输入要求。如果你用 DiceLoss 自定义实现,要注意类型转换。

3.2 Loss 选择:BCE + Dice 组合是稳的

二类分割的 Loss 选项很多,但在这个只有约 400 张训练图像的小数据集上,我倾向于用 BCE + Dice 的组合。原因有两点:一是背景区域往往远大于病灶区域,BCE 会偏向把像素预测为背景,Dice 能缓解这种类别不平衡;二是超声图像中「良性」区域的占比波动很大,Dice 天然关注区域重叠率而不是像素级准确率。

import torch.nn.functional as F import torch def dice_loss(pred, target, smooth=1.0): pred = torch.sigmoid(pred) pred_flat = pred.view(-1) target_flat = target.view(-1).float() intersection = (pred_flat * target_flat).sum() return 1 - (2.0 * intersection + smooth) / (pred_flat.sum() + target_flat.sum() + smooth) def combined_loss(pred, target): bce = F.binary_cross_entropy_with_logits(pred, target.float()) dice = dice_loss(pred, target) return bce + dice

这里pred是网络输出的 logits 而不是概率,BCE 函数内部会处理 sigmoid。smooth=1.0是常见做法,防止分子分母同时为 0,但实际使用时如果数据里的病灶区域特别小,smooth 可以提升到 10.0,避免 loss 在某些样本上震荡过大。

一个容易踩的细节:如果网络输出是两个通道并用 CrossEntropyLoss,那么target是单通道标签图;如果网络输出单通道并用 BCE,target也必须是单通道 float。两者混用是最常见的报错原因。

我一般不会单独用 Dice Loss,因为在小样本训练时 Dice Loss 的梯度更新比较剧烈,早期训练 loss 值下降快但后期容易在个位数附近转圈。BCE 提供像素级的稳定梯度,Dice 提供区域级的修正信号,加起来比较平衡。

3.3 训练配置参考:小数据集从 50 轮起跑

这个数据集规模不大,不需要特别大的模型。输入尺寸建议用 256×256 或 512×512。512 保留了更多超声纹理细节,但显存占用翻倍,训练速度也慢。我一般先跑 256 看基线,再上 512 精调。

from torch.utils.data import DataLoader train_dataset = BreastDataset("train/images", "train/masks", size=(256, 256), augment=True) test_dataset = BreastDataset("test/images", "test/masks", size=(256, 256), augment=False) train_loader = DataLoader(train_dataset, batch_size=16, shuffle=True, num_workers=4) test_loader = DataLoader(test_dataset, batch_size=16, shuffle=False, num_workers=4) # 模型、优化器、调度器示例 model = torchvision.models.segmentation.deeplabv3_resnet50(weights=None, num_classes=2) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-5) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=50, eta_min=1e-6)

batch_size=16在 256×256 输入下对常见 8GB 显存是安全的,如果显存不够降到 8。学习率 1e-4 是微调预训练模型的常见起点,如果你用的是在 ImageNet 上预训练过的 backbone,1e-4 偏保守但稳定,跑完 50 轮基本能收敛。

CosineAnnealingLR配合 50 个 epoch 的效果比较稳定,前 10 轮大学习率快速收敛,后 10 轮小学习率做精细调整。但如果训练后期发现 loss 曲线有明显的波动,可以把eta_min从 1e-6 改成 1e-5,减少学习率过低导致的跳变。

另一种常见选择是用 U-Net 结构,配合预训练的 encoder,比如smp.Unet(encoder_name="resnet34", encoder_weights="imagenet", classes=2)。这个数据集本身是从超声设备采集的,与 ImageNet 的域差异很大,预训练权重不是必须的,但能加速前几轮的收敛。如果你不想引入额外的依赖库,手写一个浅层 U-Net 也行,数据量不大,网络太深反而容易过拟合。

评估阶段用 IoU 和 Dice 作为主要指标,配合每轮的验证集计算,这样能看出模型的真实效果和 loss 值之间的关系是否吻合。

def compute_metrics(pred_mask, true_mask): pred_mask = (pred_mask > 0.5).astype(np.uint8) true_mask = (true_mask > 0.5).astype(np.uint8) intersection = np.logical_and(pred_mask, true_mask).sum() union = np.logical_or(pred_mask, true_mask).sum() iou = intersection / (union + 1e-6) dice = 2 * intersection / (pred_mask.sum() + true_mask.sum() + 1e-6) return iou, dice

这个1e-6是防止零除的常规手段,但在评测时如果某个样本完全没有病灶区域并出现了iou = 0/1e-6的情况,最好把该样本单独拎出来看看 mask 是否真的为空,这往往是数据质量问题而不是模型问题。

4. 避坑清单:图像配对、mask 阈值与评估口径中的翻车点

训练集与测试集一起给,表面上看是个省心的事情,但实际操作中我遇到过不止一次数据本身没问题、训练代码跑飞的情况,原因往往是细节处理不到位。这里列出几条具体的踩坑记录,都是在这个数据集上大概率会遇到的问题。

4.1 图像与 mask 错位:排序方式导致配对错误

现象:训练过程中 loss 能正常下降,但验证集 Dice 始终在 0.5 以下,随机抽样可视化发现原图里的病灶区域和 mask 完全不重叠。

原因:文件命名是benign (117).png这种带括号和空格的格式,如果代码里用sorted()对images和masks分别排序后再配对,某些情况下两个目录的排序结果不一致(比如一个目录里文件名包含隐藏文件,或者系统排序规则不同),导致第 n 张图和第 n 张 mask 不是同一张图。

解决:不要依赖两个目录的排序一致性,而是基于「同名配对」的原则读取文件。每次拿到一张图,就通过它的文件名去另一个目录里找对应 mask,找不到就报错。

for f in sorted(os.listdir(img_dir)): mask_path = os.path.join(mask_dir, f) if not os.path.exists(mask_path): raise FileNotFoundError(f"mask not found: {mask_path}")

这个做法把错位风险从源头消除了。从那以后我写分割训练代码,一律先跑一遍配对检查,再开始训练。

4.2 mask 阈值错误:把背景噪声当成了前景标签

现象:训练后模型的输出在整张图上都是一片糊的,看起来像在乱画,Dice 不升反降。

原因:有些 mask 图片虽然肉眼看着是二值,但实际像素值不是严格的 0 和 255,可能是 0 和 254,或者在病灶内部有少量灰度值只有几十的「脏点」。如果你用mask == 255来判定前景,这些脏点会被归为背景;如果你用mask > 0判定前景,噪声点会被当成病灶,模型的监督信号就是错的。

解决:统一走「归一化重映射」流程,先判断唯一值数量,不属于严格二值的就做阈值切分,并抽样查看切分后的 mask 是否和原始 mask 视觉一致。

vals = np.unique(mask) if len(vals) != 2: mask = (mask > 127).astype(np.uint8) else: mask = (mask == vals.max()).astype(np.uint8) # 取最大值作为前景

这里判断vals.max()作为前景而不是固定 255,原因是有部分 mask 的前景值是 254,如果写死 255 会丢一圈边界像素。

4.3 数据增强把 mask 弄成了非二值

现象:训练集 loss 正常下降,验证集指标也不错,但把预测结果可视化后发现,预测的病灶区域总是比 GT 小一圈,尤其是边缘位置。

原因:在 mask 上做了随机旋转或缩放时,如果使用cv2.warpAffine或torchvision.transforms.RandomRotation但没指定interpolation=Image.NEAREST,默认的插值算法会把 mask 边缘变成灰色过渡区。这些灰色像素介于 0 和 255 之间,二值化后边缘收缩,等效于标签被「腐蚀」了。

解决:mask 的几何变换必须用最近邻插值,同时避免对 mask 做像素级增强(比如亮度、对比度调整)。如果你用 albumentations,它内部会自动处理 mask 的插值方式,但如果你手写往左多次变换,务必显式指定interpolation=cv2.INTER_NEAREST。

检查方法:增强后打印一批 mask 的唯一值,如果出现 0/255 以外的中间值,基本就是插值的问题。

4.4 验证时宏平均与微平均口径不一致

现象:训练集和测试集指标差异很大,有人用脚本测出来测试集 Dice 只有 0.35,但可视化单张图效果明显更好。

原因:评估阶段,有人按「所有像素汇总成一个混淆矩阵」算全局 Dice,有人按「每张图单独算 Dice 再平均」算宏平均。这个数据集里图像之间病灶面积差异很大,小病灶图的 Dice 天然偏低,宏平均会被拉低,而全局平均更受大病灶图像主导,数值偏高,两者没有可比性。

解决:报告指标时固定口径。推荐使用「每张图计算 Dice,然后取平均」作为主指标,同时报告全局 IoU。如果你的方法和别人的结果做对比,先确认对方用的是什么口径,否则数值对不上只会浪费时间排查模型。

常见做法是在代码里把两个口径都打印出来,样本较少的时候直接看每张图的结果列表,能发现个别样本是否异常拉低平均值。

4.5 训练/测试集图像尺寸不统一

现象:train 和 test 目录里图像的尺寸不一致,比如训练集多为 640×480,测试集里有少数 512×512。训练时 resize 成统一尺寸没感觉,但测试时某些图像在 resize 过程中被挤压变形,病灶比例失真,预测结构变差。

原因:数据集来源不是一次采集的,存储时没有强制统一分辨率。

解决:在 Dataset 里做统一的 resize 并记录原始尺寸,推理后在原尺寸上评估。同时写脚本统计所有图像的尺寸分布,发现异常尺寸时单独检查文件,判断是采集问题还是导出问题。

5. 进阶验证:用批量预测与叠加图反向检查数据质量

训练完模型后,不要只看那张可视化的曲线图,我习惯用「反向检查」的思路再走一轮,把模型预测结果和 GT 叠加在一起看,用来发现两类问题:一是 GT 标注不干净,二是模型过拟合了训练集里的某种噪声。

做法很简单:遍历一遍测试集,把每个样本的预测结果连同一张叠加图保存下来,按 Dice 排序生成两个目录——Dice 最高的前 10 个和最低的后 10 个。低分样本不需要急着骂模型,先看叠加图,如果发现 GT 本身画得就很离谱(比如病灶区域漏标了),那这个低分就是数据问题,模型反而是对的。

import matplotlib.pyplot as plt import torch import torch.nn.functional as F model.eval() results = [] with torch.no_grad(): for img, mask in test_loader: pred = model(img)["out"] # 具体写法取决于模型结构 pred = F.interpolate(pred, size=mask.shape[-2:], mode="bilinear", align_corners=False) pred_mask = torch.argmax(pred, dim=1).cpu().numpy() mask = mask.cpu().numpy() for i in range(img.size(0)): iou, dice = compute_metrics(pred_mask[i], mask[i]) results.append((img[i], mask[i], pred_mask[i], dice)) results.sort(key=lambda x: x[3]) low_dice = results[:10] high_dice = results[-10:]

这段代码最后分类汇总,用于输出可视化结果。有一个细节值得注意:做评估时预测结果必须和验证集的形状对齐,如果模型输出的空间尺寸和 mask 不一致(例如 stride 导致下采样),要先插值回去再算指标。插值到 mask 尺寸即可,没必要还原原图尺寸。

跑完这一步,我还会额外统计一下预测结果里病灶面积的最小值和最大值。如果模型把某个样本预测出了一大块额外区域,而这块区域在 GT 里完全不存在,基本可以断定训练集里对应的区域存在类似特征——有时候是一块强回声区域,有时候是超声图像里常见的阴影区。再回训练集里搜相似特征,往往能找到模型「学歪了」的根源。

这个步骤做得越细,越能发现一些非直觉的问题。比如某次我在这个数据集上跑实验,所有低分样本都集中在图像左侧边缘,后来发现这些图像的边缘有一条固定的黑边,GT 里没有标注,但模型把黑边学成了背景特征,叠加图上还能看到黑边上的细小噪声被分割出来。去掉边缘裁剪的预处理后,Dice 提升了四五个点。

从那以后,我每跑完一轮分割实验,多花 20 分钟做一次「低分样本反向检查」,几乎每次都能找到一两处像是数据问题、又像是模型问题的地方。这个习惯也让我的后续实验迭代路径清晰很多,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询