ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

超声乳腺病灶分割数据集实战:从预处理到U-Net训练与避坑

超声乳腺病灶分割数据集实战:从预处理到U-Net训练与避坑 简介一套面向医学影像分割任务的数据集聚焦超声乳腺良性区域分割适用于计算机视觉与医学图像处理方向的学习者、研究者既可作为入门分割模型训练与验证的基础数据也适合用来做算法对比与效果评估。压缩包内共877个文件其中875个PNG格式文件图像与掩码、1个txt类别文件和1个Python可视化脚本整体大小约88.01MB目前已有958人下载学习。数据包含训练集与测试集训练集约300张图像及对应mask测试集约130张按images与masks目录组织可直接接入常见分割流程数据采用背景、良性乳腺两类标注classes文件提供类别说明。可视化脚本可随机抽取一张原始图、GT图及两者叠加效果并保存便于快速直观检查标注质量与模型输出。整体来看数据集与可视化脚本共同为乳腺超声图像分割相关的算法复现、论文实验或课程设计提供了扎实的数据与工具支撑适合入门至中高级实践者使用。1. 医学图像分割数据集一份超声乳腺良性病灶数据到底能帮你做什么拿到一份带训练集、测试集和标签的超声乳腺良性图像分割数据集第一件事不是急着跑模型而是先搞清楚它和自然图像分割数据有什么不同。B超图像噪声大、边界模糊良性病灶与周围组织灰度接近分割任务比纯检测更依赖像素级标注质量。接下来我会从数据集结构、标签格式、训练/测试划分讲到最常用的 U-Net 落地脚本再列出真实使用中踩过的五个坑最后给一套可以复现的验证套路。这套流程适合刚入手医学图像分割的研究生、算法工程师也适合想拿公开数据集验证预处理和训练管线的从业者。2. 超声乳腺良性分割数据集的目录与标签拿到手先做三件事拿到一个医学图像分割数据集尤其是超声影像数据直接开训是翻车高发区。超声图像不像CT或MRI那样有标准DICOM头信息公开数据集的目录命名、掩码格式甚至像素值定义都可能不同。我的习惯是先做三件事确认训练集和测试集是否完全分开确认标签是掩码还是多边形坐标确认图像和标签能否一一对应。这三件事决定了后面的预处理代码怎么写也决定了评测分数是否可信。2.1 训练集和测试集为什么必须分开数据划分的底层逻辑分割模型的目标是学会“看”病灶区域而不是“认出”某张特定图片。如果训练集和测试集存在重叠模型在测试时直接匹配文件名或像素模板Dice会虚高到不真实。对于超声乳腺数据集这个问题尤其隐蔽同一患者的图像可能连文件名都不同但病灶区域来自同一次扫描背景纹理几乎一样。按文件名查重只是第一道防线按患者ID查重才是关键。如果数据集没有提供患者ID也可以通过文件名中的case编号或病人编号推断。下面这段脚本会扫描训练集和测试集的图像文件名输出重合列表from pathlib import Path train_img_dir Path(data/train/images) test_img_dir Path(data/test/images) train_names {p.name for p in train_img_dir.glob(*.png)} test_names {p.name for p in test_img_dir.glob(*.png)} overlap train_names test_names print(train count:, len(train_names)) print(test count:, len(test_names)) print(overlap count:, len(overlap)) for name in sorted(overlap)[:10]: print(overlap:, name)这段代码用set求交集train_names和test_names分别是两个目录下的文件集合。输出重合数量后即使没有重合也要再按文件名前缀分组检查比如文件名是case_001_frame_01.png那么所有case_001开头的图像应该只出现在一个集合里。如果同一病例的帧散落在两边就要手动调整划分或者改用后面第5章会讲的按患者分折交叉验证。再补一段按患者ID检查的代码import re def patient_id(filename): match re.match(r(case[_-]?\d), filename) return match.group(1) if match else filename train_patients {patient_id(name) for name in train_names} test_patients {patient_id(name) for name in test_names} print(patients in both sets:, train_patients test_patients)这里用正则case[_-]?\d提取病例号。实际命名可能不是这个模式需要先print几个文件名看一眼再写正则。注意如果提取不出来宁可手动生成一个patient_id映射表也不要跳过这一步。对医学图像分割数据集而言按患者划分比按图像划分更能反映模型在真实新病人上的表现。2.2 标签格式识别掩码是 PNG、JSON 还是 NIfTI决定预处理怎么写标题里的“标签”两个字看着简单落地时差异很大。超声乳腺分割数据集常见三种标签格式二值PNG掩码、JSON多边形坐标、NIfTI体数据。PNG掩码可以直接用OpenCV/PIL读取JSON需要把边界点填充成掩码NIfTI需要处理放射学坐标方向。拿到数据集后先用一段脚本统计标签目录的扩展名避免用错解析器。from pathlib import Path from collections import Counter label_dir Path(data/train/labels) ext_counter Counter(p.suffix.lower() for p in label_dir.iterdir() if p.is_file()) print(ext_counter) for p in sorted(label_dir.glob(*))[:5]: print(p.name)这段脚本用Counter统计标签目录里所有文件后缀能立刻看出是.png还是.npy还是.nii.gz。注意.nii.gz的实际文件名后缀是.gz如果直接统计.suffix会得到.gz所以最好用p.name.endswith((.nii.gz,))或先用.with_suffix()再判断。打印前5个文件名称是为了确认命名规律给后面的文件名对齐做准备。如果标签是PNG下一步检查掩码的像素值。很多数据集的背景是0前景是255但也有用1表示前景的。训练时如果直接把255当成类别索引会把一个前景像素拆成两个类别。读掩码后先打印唯一值import cv2 import numpy as np mask cv2.imread(data/train/labels/case_001.png, cv2.IMREAD_GRAYSCALE) print(mask shape:, mask.shape) print(unique values:, np.unique(mask))这里用cv2.imread的IMREAD_GRAYSCALE强制按灰度读避免三通道干扰。如果输出只有[0 255]训练时要除255再当成背景/前景如果输出是[0 1]直接转long类型即可。如果还有中间值如128说明掩码可能带边界标注或类别权重要回到数据集说明确认。超声乳腺良性分割一般只需要背景和病灶两类出现128时要小心处理。如果标签是JSON常见结构是{filename: ..., regions: [{shape_attributes: {points: [...]}}]}。这种情况不能直接送进模型要先做一次离线转换把多边形填充成掩码import json import cv2 import numpy as np with open(annotation.json) as f: ann json.load(f) mask np.zeros((height, width), dtypenp.uint8) for region in ann.get(regions, []): points region[shape_attributes][points] pts np.array(points, dtypenp.int32).reshape(-1, 1, 2) cv2.fillPoly(mask, [pts], 255)这段代码用cv2.fillPoly把多边形顶点填充成白色掩码。height和width必须与原始B超图像一致通常可以根据图像尺寸获取。转换后的掩码要和原始图像放在同一个目录层级方便Dataset类读取。2.3 目录组织检查清单用一段脚本验证文件名、尺寸和类别在写训练脚本前我会先用一个检查脚本把所有潜在问题都暴露出来。你需要确认四点图像和标签文件一一对应、图像和标签尺寸一致、掩码类别只有前景和背景、训练集和测试集没有文件重合。下面这段脚本会遍历图像目录对每张图找到同名标签并检查 shapefrom pathlib import Path import cv2 import numpy as np img_dir Path(data/train/images) mask_dir Path(data/train/labels) img_files sorted(img_dir.glob(*.png)) mask_files sorted(mask_dir.glob(*.png)) print(images:, len(img_files), masks:, len(mask_files)) for img_path in img_files: mask_path mask_dir / img_path.name if not mask_path.exists(): print(missing mask:, img_path.name) continue img cv2.imread(str(img_path), cv2.IMREAD_GRAYSCALE) mask cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) if img.shape ! mask.shape: print(shape mismatch:, img_path.name, img.shape, mask.shape)这组代码用同名配对方式检查img_files按字典序排序后再通过mask_dir / img_path.name构造掩码路径。这样不会因为排序方式不同出现错位。如果打印出missing mask或shape mismatch需要先用离线脚本修复数据而不是在训练时靠报错去猜。检查结果可以用下面这张表记录检查项期望值失败动作图像数量与掩码数量完全一致补齐缺失文件或重命名图像和掩码尺寸每个样本 pair 相等先统一裁剪/缩放再入 train掩码唯一值0 和 255 或 0 和 1训练前归一化到类别索引训练/测试文件交集无按患者ID重新划分这张表是我在实际处理B超数据集时的检查模板。超声乳腺图像四周通常有大片黑色背景如果发现图像尺寸不统一不要盲目resize先用固定比例中心裁剪去掉黑边再用cv2.resize统一。掩码尺寸不一样时更危险说明标注和原图可能不是从同一个预处理流程出来的需要回到原始数据确认。3. 用这份数据集跑通 U-Net预处理、训练脚本与参数选择把目录和标签检查做完就可以进入正题了。医学图像分割数据集最常见的落地路径是用 U-Net 结构训练自己的数据集。这里我会给一个最小可用流程灰度图预处理、图像与标签对齐、训练循环、曲线解读。超声乳腺良性病灶本身边界偏模糊模型不需要特别复杂先跑通基线比换大模型更重要。3.1 从 B 超图像到模型输入灰度图归一化与裁剪超声图像通常不是标准照片而是灰度强度图。直接套用 ImageNet 的三通道均值和标准差没有意义常见做法是对整张图做最大最小值归一化或者除以255。更稳的做法是先统计数据集的灰度分布把均值附近的范围映射到0-1而不是让个别高亮伪影主导整个输入。另一个重要的预处理是裁剪。B超图像四周经常有设备信息、刻度尺、纯黑边框这些区域不参与诊断却会让模型在前期把注意力放在背景结构上。我一般用阈值法先找到超声扇形区域再做归一化和缩放。下面这段函数展示了一个可复用的预处理def preprocess_image(image_path, target_size(256, 256)): img cv2.imread(image_path, cv2.IMREAD_GRAYSCALE) img img.astype(np.float32) # 去掉四周黑色背景 non_bg img 5 coords np.argwhere(non_bg) y0, x0 coords.min(axis0) y1, x1 coords.max(axis0) 1 img img[y0:y1, x0:x1] # 归一化到 0~1 img (img - img.min()) / (img.max() - img.min() 1e-6) img cv2.resize(img, target_size, interpolationcv2.INTER_CUBIC) return imgnon_bg img 5的5是灰度阈值用来区分超声回声区和纯黑背景。如果图像整体很暗可以降到2如果包含强噪声点可以提到10。np.argwhere得到所有非背景像素坐标取最小和最大值得到裁剪框。归一化时用img.max() - img.min()做分母加1e-6防止全黑图除零。最后 resize 到256x256这是一个速度和精度平衡的尺寸如果显存够大用512x512能让边界更清楚。标签掩码要执行同样的裁剪和resize但插值方法必须改成最近邻否则会引入不属于任何一类的中间灰度值def preprocess_mask(mask_path, target_size(256, 256)): mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 使用同一个裁剪框这里简化为直接 resize mask cv2.resize(mask, target_size, interpolationcv2.INTER_NEAREST) mask (mask 127).astype(np.int64) return mask注意这里的裁剪框要和图像预处理完全一致。实际代码里我通常把裁剪坐标从preprocess_image返回再传给preprocess_mask避免各自算一遍导致错位。3.2 标签读取与数据对齐防止图像和掩码错位的检查点很多刚开始用医学图像分割数据集的人会在 Dataset 类里用os.listdir分别读图像和掩码再按下标访问。这是最容易错位的写法因为两个目录的排序结果可能不一样。更稳的做法是只遍历图像目录然后用图像的文件名拼接掩码路径。下面是一个标准 PyTorch Dataset 写法from torch.utils.data import Dataset from pathlib import Path import cv2 import torch class UltrasoundDataset(Dataset): def __init__(self, image_dir, mask_dir, target_size(256, 256)): self.image_paths sorted(Path(image_dir).glob(*.png)) self.mask_dir Path(mask_dir) self.target_size target_size for p in self.image_paths: assert (self.mask_dir / p.name).exists(), fmissing {p.name} def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image_path self.image_paths[idx] mask_path self.mask_dir / image_path.name image preprocess_image(str(image_path), self.target_size) mask preprocess_mask(str(mask_path), self.target_size) image_tensor torch.from_numpy(image).unsqueeze(0).float() mask_tensor torch.from_numpy(mask).long() return image_tensor, mask_tensorself.image_paths使用sorted(Path.glob(*.png))掩码路径直接用同名拼接。assert在初始化时快速失败如果缺掩码会立刻报错而不是训练到中间才发现。torch.from_numpy(image).unsqueeze(0)给灰度图补上通道维得到(1, H, W)。掩码转成long是 PyTorch 交叉熵损失要求的整数标签类型。3.3 一个最小可运行的 U-Net 训练脚本PyTorch模型结构建议先用经典 U-Net编码器三层、解码器三层通道数从32开始。下面这段训练循环聚焦在数据加载、损失函数和优化器配置上import torch from torch.utils.data import DataLoader from unet_model import UNet device torch.device(cuda if torch.cuda.is_available() else cpu) train_dataset UltrasoundDataset(data/train/images, data/train/labels) train_loader DataLoader(train_dataset, batch_size8, shuffleTrue, num_workers2) model UNet(in_channels1, out_channels2).to(device) optimizer torch.optim.Adam(model.parameters(), lr1e-4) loss_fn torch.nn.CrossEntropyLoss() for epoch in range(50): model.train() running_loss 0.0 for images, masks in train_loader: images, masks images.to(device), masks.to(device) output model(images) loss loss_fn(output, masks) optimizer.zero_grad() loss.backward() optimizer.step() running_loss loss.item() avg_loss running_loss / len(train_loader) print(fepoch {epoch 1:02d} loss {avg_loss:.4f})UNet(in_channels1, out_channels2)里in_channels1对应灰度超声图out_channels2对应背景和病灶两个类别。CrossEntropyLoss的输出要求是(N, C, H, W)掩码是(N, H, W)正好匹配。batch_size8是在256x256输入、显存8GB左右的经验值如果出现OOM先降到4或2。lr1e-4对医学图像小数据集比默认的1e-3更稳避免刚开始几步就把分割权重带偏。如果掩码只有0和255需要在preprocess_mask里做(mask 127)再转LongTensor如果已经是0和1也要确认没有浮点噪声。类别索引从0开始CrossEntropyLoss会自动忽略索引为负的值所以不要给掩码加背景掩蔽以满足形状要求。注意unet_model模块可以用你自己实现的 U-Net 替换关键是输入通道为1、输出通道为2且输出尺寸与输入一致。3.4 训练曲线解读Dice 不涨、Loss 震荡时先调什么训练跑起来之后不能只看loss。医学图像分割更常用Dice系数来衡量因为良性和背景之间像素极度不平衡时交叉熵loss可能一直很低但Dice也不涨。下面这段代码在验证集上计算Dicedef dice_score(pred, target): pred (pred 0).float() # 预测属于病灶类的概率最大 intersection (pred * target).sum() return (2.0 * intersection 1e-6) / (pred.sum() target.sum() 1e-6)pred是模型输出的类别索引这里把预测病灶像素和二值掩码逐像素相乘求和。加1e-6是为了处理两张全空掩码时除零的问题。如果训练loss下降但Dice在0.1左右徘徊优先检查两个方向一是数据增强是否过强比如随机旋转90度把超声扇形方向打乱二是掩码是否在resize时被最近邻插值破坏尤其是病灶区域小的时候。如果loss来回震荡把学习率从1e-4降到3e-5同时把batch size调小通常能让曲线稳定下来。还有一种常见情况是验证loss下降但Dice不降这时需要看模型是否把所有像素都预测为背景。打印预测图的唯一值如果只有0没有1说明类别不平衡已经把训练推到了平凡解。可以改用Dice Loss或Focal Loss先把背景类权重降低。4. 数据集使用避坑五个让分割模型翻车的真实场景医学图像分割数据集的坑不在网络结构而在数据管道。很多问题看起来像玄学最后定位都是读取、划分或预处理不一致。下面五条按“现象、原因、解决”展开方便照着排查。4.1 灰度图像被当成三通道输入U-Net 输入通道数设成3现象模型能跑通loss也在下降但验证集上预测结果基本是全黑或全图。 原因cv2.imread默认会按三通道读图代码又没显式指定IMREAD_GRAYSCALE于是灰度B超图变成三通道的重复矩阵。模型第一层in_channels设成3确实能训练但学到的三通道信息完全冗余边界特征被削弱。 解决统一在数据读取时加cv2.IMREAD_GRAYSCALE并打印img.shape确认输出是(H, W)而不是(H, W, 3)。模型第一层in_channels1如果要用预训练模型迁移也建议把权重在输入层做均值折叠而不是简单地把图复制三通道。img cv2.imread(data/train/images/case_001.png) print(img.shape) # 如果是 (512, 512, 3)说明没走灰度读取这段检查会立刻暴露问题。灰度B超图被读成三通道后三个通道完全相同模型等于在重复特征上做卷积参数量和计算量都白白增加。4.2 掩码前后景比例悬殊损失函数被背景主导现象训练准确率很高但Dice只有0.2分割结果覆盖了一整片灰度接近的区域。 原因良性病灶在超声图像里通常只占几万像素背景占了几十万像素。交叉熵损失会把绝大多数梯度花在背景上模型学到的就是把大块暗区判为背景偶尔碰中一个病灶像素就能把准确率拉高。 解决训练损失改成Dice Loss或Focal Loss。Dice Loss直接优化区域重叠对类别不平衡更稳定。如果数据集里病灶区域太小还可以在预处理阶段按包含前景的裁剪框切patch让每个训练样本里前景占比不低于某个阈值。def dice_loss(pred, target): pred torch.softmax(pred, dim1)[:, 1] smooth 1.0 intersection (pred * target).sum() return 1 - (2 * intersection smooth) / (pred.sum() target.sum() smooth)这个dice_loss直接取模型输出中病灶类别的概率与二值掩码计算重叠。smooth防止除零也避免训练初期梯度过大。替换CrossEntropyLoss后模型会更关注少数类像素。4.3 训练集和测试集来自同一病例指标虚高现象训练时Dice到0.95测试时换一组数据就掉到0.5。 原因数据集划分是按文件名随机分的但同一患者的多个超声帧出现在两个集合里。因为同一患者的设备参数、探头角度、病灶形态高度相似模型记住的是这个病人的背景纹理而不是通用病灶特征。 解决拿到数据先做患者级分组同一患者的所有图像只能出现在训练集或测试集不能跨集合。如果标题的数据集没有提供患者ID也可以用文件名中的case编号提取。更稳妥的做法是用第5章的按患者分折交叉验证反复评估模型稳定性。train_patients {patient_id(p.name) for p in train_names} test_patients {patient_id(p.name) for p in test_names} shared train_patients test_patients if shared: print(患者级泄露:, shared)这段代码把2.1里的患者检查逻辑明确放在训练前执行。出现共享患者时宁可减少训练集规模也不能保留跨集合的病例。4.4 裁剪黑边导致标签坐标没同步现象预测掩码在图像边缘出现固定偏移评测时边界区域出现一排假正或假负。 原因预处理函数对图像先裁剪再resize但标签掩码在另一段代码里直接resize没有同步裁剪框。图像缩放到目标尺寸时失去了一部分边缘掩码却保留了完整尺寸两者自然错位。 解决图像和掩码必须共用同一个裁剪框。把preprocess_image里计算出的(y0, y1, x0, x1)返回给preprocess_mask掩码先裁剪再resize。掩码的resize插值统一用INTER_NEAREST配合代码块里的assert检查尺寸一致。def crop_resize_pair(image, mask, threshold5): non_bg image threshold coords np.argwhere(non_bg) y0, x0 coords.min(axis0) y1, x1 coords.max(axis0) 1 image_crop cv2.resize(image[y0:y1, x0:x1], (256, 256), interpolationcv2.INTER_CUBIC) mask_crop cv2.resize(mask[y0:y1, x0:x1], (256, 256), interpolationcv2.INTER_NEAREST) return image_crop, mask_cropcrop_resize_pair把图像和掩码的裁剪框统一到同一个y0,x0,y1,x1掩码用INTER_NEAREST不会产生新灰度值。这个函数应作为训练管线的唯一入口避免两处各写一套。4.5 测试时没有做与训练时相同的预处理现象离线验证Dice还行部署到新环境后指标明显下降。 原因训练脚本里的预处理写在train.py测试脚本里重新写了一份归一化顺序、裁剪阈值或resize尺寸有细微差别。超声图像的灰度范围很敏感差一个阈值就可能让病灶边界改变。 解决把预处理函数抽成独立模块训练和推理都从同一个模块导入。然后在固定一张图像上跑训练管线和推理管线比较输出数组是否完全一致。我习惯加一段单元测试比较同一个输入在两次调用后的numpy array是否相等。arr1 preprocess_image(data/train/images/case_001.png) arr2 preprocess_image(data/train/images/case_001.png) assert np.array_equal(arr1, arr2)这段测试只能验证函数自身确定性更关键的是确认推理脚本没有重新编写一份“看起来差不多”的预处理。如果训练和推理的灰度阈值、目标尺寸、插值方式不一致测试集上的微小偏差会在边界像素上被放大。5. 在单独一次划分之外交叉验证与分割指标的人工复核技巧5.1 用按患者分组的交叉验证替代单次随机划分如果数据集包含训练集和测试集直接用固定划分训练即可。但超声乳腺数据规模通常不大单次划分对随机种子太敏感。我会在大致确认固定划分之外额外做一次五折按患者交叉验证用下面这段代码生成折标签from sklearn.model_selection import KFold import numpy as np patient_ids np.array(sorted({patient_id(p.name) for p in image_paths})) kf KFold(n_splits5, shuffleTrue, random_state42) for fold, (train_idx, val_idx) in enumerate(kf.split(patient_ids)): train_patients patient_ids[train_idx] val_patients patient_ids[val_idx] print(fold, len(train_patients), len(val_patients))KFold按患者ID列表分折而不是按图像分折。这样每一折的验证集都来自模型没见过的患者评估结果更接近真实新数据上的效果。random_state42固定随机种子保证每次实验可比较。5.2 把预测掩码和原始 B 超图像叠起来做人工抽检Dice和IoU只是数值不能告诉你模型在哪个解剖位置犯错。我会在每轮验证后随机抽5张测试图像把预测掩码和真实掩码分别叠加在原始B超图上转成彩色对比图保存。如果模型总把低回声区域整片当成病灶数值上看不出来但可视化会非常明显。overlay cv2.cvtColor(img_bgr, cv2.COLOR_GRAY2BGR) overlay[pred_mask 1] (0, 0, 255) # 红为预测 overlay[true_mask 1] (0, 255, 0) # 绿为真实 cv2.imwrite(fcheck_fold{fold}.png, overlay)红色表示预测为病灶、绿色表示真实标注红绿重叠会变成黄。看到大片红色区域时说明模型把灰度相近的腺体组织误判成了病灶需要回到训练数据里检查是否有类似的错误标注。这个人工抽检习惯帮我校准了很多看似合理、实则结构不完整的模型。我自己的教训是第一次跑这份数据时只看了Dice觉得0.87很不错抽检才发现模型把探头阴影也划进了病灶。后来把可视化抽检加进训练循环问题立刻暴露。希望帮到你。本文还有配套的精品资源点击获取
返回列表