☰
超声腹部多器官图像分割实战:从数据集预处理到nnU-Net训练与评估
2026/10/5 2:56:39 网站建设 项目流程

简介:面向医学超声图像多器官分割任务,构建腹部多器官标注数据集,覆盖肝脏、肾脏、胰腺、胆囊、脾脏、血管、肾上腺、骨骼等类别,适合深度学习与医学影像处理研究者用于模型训练、算法验证与性能评估。数据已做对比度拉伸、尺寸统一缩放、像素映射等预处理,使图像灰度与尺度趋于一致;图像与标签均为PNG格式,便于直接加载训练。压缩包共一千八百五十五个文件,其中一千八百五十三张为PNG图像及对应标签,另含一份说明文档与一个Python脚本;整体约四十三点五八兆字节,目录结构清晰,便于快速下载与本地部署。目前已有七百一十四人学习使用。配套Python脚本可辅助数据预览、标签查看与格式整理,掩膜中不同类别独立编码,可支撑肝脏、肾脏、脾脏等多目标语义分割,也便于不同分割网络的对比与复现。

1. 超声腹部多器官图像分割数据集:为什么说它是“最难啃的医学图像分割”之一

很多人以为超声图像分割只是“拿 U-Net 在单器官上跑一下”,真上手才发现,肝脏和肾脏的边界在 B 超图像里根本不是一条线,而是噪声颗粒组成的灰度渐变。这个超声腹部多器官图像分割数据集把肝脏、肾脏、胆囊、脾脏和血管放在同一张图里标注,目标就是逼你把多类分割当成一个完整系统来处理,而不是单器官玩具。它解决的是三件事:可复现的多器官联合测评、小标注量下的模型迁移、以及超声设备辅助诊断功能的落地验证。适合医疗影像算法工程师、做通用图像分割想找真实场景的研究生,以及负责超声设备功能开发的团队。

拿到这种数据集之后,直接写个 U-Net 开跑通常走不通。因为数据格式、标签编码、训练策略和自然图像分割差别很大。下面的内容按我实际处理的流程写:先拆数据结构,再做预处理和加载器,然后跑通 nnU-Net,最后把评价指标和可视化做扎实。每一章都会给出可以照着改的代码和参数。

2. 数据集内部结构拆解:从文件格式到标签映射,先搞清器官之间的遮挡关系

2.1 文件组织形式:PNG、DICOM 和 NIfTI 各是什么角色

一个完整的超声腹部多器官图像分割数据集,拿到手里通常不是单一格式。最常见的是三类文件:直接从超声设备导出的 DICOM 序列、研究团队预处理好的 PNG/JPEG 逐帧图像和对应 mask、以及面向分割框架的 NIfTI 文件。DICOM 信息最全,包含探头类型、增益、深度和患者信息,但不能直接喂给深度学习框架;PNG 帧和 mask 最直观,但容易丢失像素间距和设备参数;NIfTI 是医学分割工具链的标准格式,nnU-Net 这类框架直接支持,缺点是需要额外转换步骤。

先别急着训练,第一步是把数据集目录结构看清楚。我一般会在项目根目录执行下面这条命令:

# 只显示前 50 个文件,先看整体命名规律,不做任何修改 find . -maxdepth 3 -type f | sort | head -50

如果输出里出现P001_frame010.png和P001_frame010_mask.png这种成对结构,说明数据已经完成逐帧抽取,图像和标签一一对应。如果看到一堆.dcm,就需要用 SimpleITK 或 pydicom 自己抽帧。如果看到.nii.gz,说明已经过重采样和标签编码,可以直接往下走。

超声腹部多器官数据集的标注方式和自然图像数据集差异很大。以 coco2017 数据集结构为例,COCO 的 80 类实例用多边形标注,每张图里同一个物体可以有多个实例,而超声多器官分割更接近语义分割,每个像素只能属于一个器官,同一类器官出现多块时(比如左右肾、多条血管分支)也共用同一个标签值。这一点决定了后续损失函数和后处理逻辑,不能用目标检测的 mask 思路去套。

提示:如果数据集里同时出现mask和label两种目录,优先以带明确标签文档的label为准。有的版本会把前景做成 255,背景做成 0,这种 0/255 的二值标记得先转换成连续整数标签,否则神经网络的输出层会直接把所有像素预测成同一类。

2.2 器官标签映射与类别不平衡:肝脏最大、血管最小

多器官分割的标签映射是训练前必须确定的事。典型的超声腹部多器官数据集会按器官编号,比如 0 背景、1 肝脏、2 肾脏、3 胆囊、4 脾脏、5 血管。下面是一张我常用的标签核查表:

标签值结构形态特点训练时的典型问题
0背景阴影、噪声、腹壁容易和肝实质混淆
1肝脏大片低回声区和胆囊、血管边界模糊
2肾脏左右各一,包膜强回声与脾脏灰度重叠
3胆囊无回声暗区体积小,易被背景吞掉
4脾脏新月形与左肾、胰腺灰度接近
5血管树状暗带多连通域,Dice 指标极不稳定

这张表的核心意义是提前认识到类别不平衡。肝脏在大多数切片里能占到 30% 以上像素,而血管可能不到 5%,胆囊更是在呼吸与探头压力下时有时无。用标准交叉熵训练,小器官的梯度会被大器官稀释,最终得到肝脏不错、血管几乎为零的“偏科模型”。

所以,预处理和训练前就要决定损失函数方向。最常见做法是 Dice Loss 加权或 TopK Loss。如果用的是 nnU-Net,它会自动计算标签频率并调节损失权重,不需要手写;如果你写自己的 U-Net,就得在 DataLoader 里统计每个类别的像素占比,把背景权重压低,把血管和胆囊权重抬高。手写加权时注意不要给权重设上限,有些切片的血管只有几十个像素,权重过大反而会让模型把噪声当血管。

2.3 数据划分:按患者分组而不是按帧

超声数据和自然图像另一个明显不同是:同一患者往往有几十帧连续图像,相邻帧几乎一样。如果按文件随机划分训练集和验证集,同一患者的相似帧会同时出现在两边,验证集 Dice 虚高,还会让模型“记住”特定患者的纹理。正确的做法是按患者 ID 分组,再把整个患者的所有帧分到同一个 split 里。

下面是我常用的按患者划分脚本。假设目录名形如PATIENT001_FRAME012,前缀就是患者 ID:

import os import shutil from collections import defaultdict # 假设原始所有文件在 raw/ 目录下 cases = sorted(os.listdir("raw")) patient_map = defaultdict(list) for c in cases: # 目录名或文件名前缀,例如 PATIENT001_FRAME012 patient_id = c.split("_")[0] patient_map[patient_id].append(c) patients = sorted(patient_map.keys()) n = len(patients) # 按患者划分:70% 训练,15% 验证,15% 测试 train_patients = patients[: int(n * 0.7)] val_patients = patients[int(n * 0.7): int(n * 0.85)] test_patients = patients[int(n * 0.85):] for split_name, patient_list in [ ("train", train_patients), ("val", val_patients), ("test", test_patients), ]: for p in patient_list: for case_name in patient_map[p]: os.makedirs(f"split/{split_name}", exist_ok=True) shutil.copy(f"raw/{case_name}", f"split/{split_name}/{case_name}")

这里的核心参数是划分比例。70/15/15 是通用默认值,如果总患者数少于 20,建议直接用 5 折交叉验证,别把 15% 的验证集削得太薄。划分完成后,验证集和测试集在整个调参期间都不能碰,尤其是测试集要留到最后跑。

注意:有的数据集里一个患者会对应多次检查记录,单靠目录名前缀不足以区分。更稳妥的做法是读取元数据里的patient_id和study_id,按study_id分组。如果数据集只给了帧号没给患者 ID,可以先用聚类把连续帧按时间窗切分,再按切片结果分组。

3. 把原始数据变成模型输入:切片、归一化和超声专用增强

3.1 从体素到二维切片:三种常见方案

超声腹部多器官数据集的原始形态可能是二维 B 超视频帧,也可能是三维探头扫出来的体数据。三维数据直接训练 3D U-Net 显存压力大,而且标注通常只在少数帧上做过,所以工程上最稳的做法是先转成二维切片。三种方案各有适用场景。

第一种是单帧训练,每张超声图独立进网络。实现最简单,但会丢失呼吸运动带来的器官位置变化信息。第二种是相邻三帧叠成三通道输入,网络依然用 2D U-Net,但输入形状变成[3, H, W],输出仍是一张 mask。这种做法能利用一点帧间连续性,又不需要 3D 网络,是超声多器官分割最常见的折中方案。第三种是把整段视频输入时序模型,比如 U-Net 加 ConvLSTM,效果好但数据量和训练成本都高,一般数据少于 100 个患者时不需要上。

我建议先把单帧或三帧方案跑通,再考虑时序。下面是一个典型的三帧堆叠代码:

import numpy as np def stack_three_frames(frame_paths): # frame_paths 是三个相邻帧的路径,按时间顺序排列 frames = [] for p in frame_paths: img = np.load(p) # 假设已预处理成 float32 的 npy frames.append(img) # 结果形状: (3, H, W),对应模型输入通道 stacked = np.stack(frames, axis=0) return stacked

这里的参数是“相邻帧”的跨度。探头静止时连续两帧差异很小,可以每隔 2 帧取一帧,减少冗余;如果呼吸运动明显,取原始连续帧反而能学到器官位移。这类参数没有万能值,训练时先看验证集表现再调。

3.2 归一化和重采样:别用普通 min-max 直接铺

超声图像是单通道灰度,但不同设备的动态范围差别很大。有的图像像素集中在 0 到 50,有的集中在 50 到 200,如果直接做 min-max 归一化,低动态范围的图像会被放大噪声,高动态范围图又可能把高回声区域全截断。我一般用百分位裁剪代替极端值归一化。

下面是一段配合 SimpleITK 的预处理代码,既做归一化,又重采样到各向同性:

import numpy as np import SimpleITK as sitk def normalize_ultrasound(img_array, low_perc=1, high_perc=99): # 用百分位裁剪抑制超声图像里的极端亮斑和声影 lo = np.percentile(img_array, low_perc) hi = np.percentile(img_array, high_perc) clipped = np.clip(img_array, lo, hi) clipped = (clipped - clipped.min()) / (clipped.max() - clipped.min() + 1e-6) return clipped def resample_to_isotropic(img, spacing, target_spacing=1.0): # 保持图像和 mask 使用相同的重采样规则,mask 插值用最近邻 original_spacing = np.array(spacing, dtype=float) target_spacing = np.array([target_spacing] * len(original_spacing), dtype=float) scale = original_spacing / target_spacing new_size = np.ceil(np.array(img.GetSize()) * scale).astype(int) resampler = sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing.tolist()) resampler.SetSize(new_size.tolist()) # 图像用线性插值,mask 需要改成 sitk.sitkNearestNeighbor resampler.SetInterpolator(sitk.sitkLinear) return resampler.Execute(img)

两个关键点。第一,low_perc和high_perc在超声上取 1 和 99 比较稳,如果数据里大量切片有强回声伪影,可以放宽到 5 和 95;第二,重采样到各向同性只对带像素间距的 NIfTI 或 DICOM 有意义,纯 PNG 数据集没有间距信息,跳过重采样即可。

归一化之后要记得把均值、方差或百分位参数存下来。推理时用训练集同样的参数处理新图,否则设备亮度一变,模型输出就可能漂移。

3.3 数据增强:亮度扰动、散斑模拟和弹性形变

超声多器官分割的数据增强不能照搬自然图像的随机裁剪和色彩抖动。你要模拟的是不同探头频率、增益、深度下同一器官的灰度变化。最常用的三样是 Gamma 对比度扰动、乘性散斑噪声、弹性形变。

下面用 imgaug 实现一个增强序列:

import imgaug.augmenters as iaa seq = iaa.Sequential([ iaa.GammaContrast((0.8, 1.2)), # 模拟超声增益差异 iaa.Multiply((0.9, 1.1), per_channel=False), # 整体亮度扰动 iaa.ElasticTransformation(alpha=10, sigma=3), # 模拟探头压力导致的形变 iaa.AverageBlur(k=2), # 模拟分辨率下降 ]) # 图像用增强,mask 用同一套几何变换 aug_img, aug_mask = seq( image=image_bgr, segmentation_maps=mask[None, ...], # 需要增加 batch 维度 )

注意ElasticTransformation的alpha和sigma。alpha 太大器官会扭曲到不自然,alpha 太小又没有效果,我通常从alpha=10, sigma=3开始调。mask 在增强后必须是整数标签,千万不要用线性插值把血管标签插成 2.5。imgaug 的 segmentation map 会默认处理最近邻,但如果你自己写增强函数,一定要把 mask 的插值设为nearest。

最后补一个最简单的 PyTorch Dataset 骨架,方便训练循环直接调用:

import torch from torch.utils.data import Dataset class AbdomenUSDataset(Dataset): def __init__(self, image_paths, mask_paths): self.image_paths = image_paths self.mask_paths = mask_paths def __len__(self): return len(self.image_paths) def __getitem__(self, idx): # 提前把预处理结果存成 npy,比在线读 PNG 快很多 img = np.load(self.image_paths[idx]) msk = np.load(self.mask_paths[idx]) # 图像加通道维度,标签保持 long 型 img_t = torch.from_numpy(img).float().unsqueeze(0) msk_t = torch.from_numpy(msk).long() return img_t, msk_t

这里用np.load读数组而不是直接读 PNG,是为了避免每次训练迭代都做一次百分位裁剪和归一化。工程上,第一次预处理把所有图像和 mask 分别转成 npy 文件,训练加载更快,性能也稳定。

4. 用 nnU-Net 训练多器官分割模型:三条命令与关键参数

4.1 为什么 nnU-Net 比手写 U-Net 更适合这个数据集

我见过不少同学拿 yolov8 训练自己的数据集的那套自然图像思路来做超声多器官,第一轮就翻车。YOLO 用检测框表达目标,但胆囊是任意形状的暗区,血管是树状多连通域,检测框连边界都包不住。常规手写 U-Net 不是不行,但要自己调 patch size、归一化方式、损失权重、学习率,光这几项就能烧掉一两周。

nnU-Net 的价值在于自动配置流水线。它内置了“图像分割算法”里被验证过的默认策略:自动估计 patch size、自动选择下采样深度、自动计算类别权重,还会自动对比 2D 和 3D 方案。对于这种标注样本少、器官类别多、超声噪声重的任务,nnU-Net 几乎是开箱即用的最优起点。

4.2 把数据集整理成 nnUNet 格式:目录、命名和 dataset.json

nnU-Net 对数据格式有严格约定,目录名必须叫DatasetXXX_名称,图像命名必须带_0000后缀,标签命名不能带后缀。下面这一段是我常用的转换流程:

# 1. 创建目录,Dataset001 对应编号 1 mkdir -p nnUNet_raw/Dataset001_AbdoUS/{imagesTr,labelsTr,imagesTs} # 2. 把训练集图像复制为 XXX_0000.nii.gz,标签复制为 XXX.nii.gz cd nnUNet_raw/Dataset001_AbdoUS for n in $(ls split/train); do cp split/train/${n}/image.nii.gz imagesTr/${n}_0000.nii.gz cp split/train/${n}/mask.nii.gz labelsTr/${n}.nii.gz done

这里最关键的是命名。${n}_0000.nii.gz的_0000表示第一个模态通道,如果数据集有 B 模式和彩色多普勒两个序列,就要命名成_0000和_0001。超声腹部多器官数据集通常只有单序列灰度,所以只用_0000。

然后生成dataset.json,用命令动态写入训练数量,避免手填出错:

cat > nnUNet_raw/Dataset001_AbdoUS/dataset.json <<EOF { "channel_names": { "0": "US" }, "labels": { "background": 0, "liver": 1, "kidney": 2, "gallbladder": 3, "spleen": 4, "vessel": 5 }, "numTraining": $(ls nnUNet_raw/Dataset001_AbdoUS/imagesTr | wc -l), "file_ending": ".nii.gz" } EOF

numTraining会自动计算成 imagesTr 里的文件数量。pretrained 权重等字段在这里都不需要,nnU-Net 会自己处理。

接下来跑预处理。超声图像是二维帧,所以强制指定 2D 配置:

nnUNetv2_plan_and_preprocess -d 1 --verify_dataset_integrity -c 2d

-d 1对应 Dataset001,-c 2d让 nnU-Net 只在 2D 方案上做规划,--verify_dataset_integrity会检查标签是否从 0 连续、文件命名是否匹配。这一步报错最多的是 labels 没有从 0 开始,或者图像和标签文件名对不上,先修数据再往下走。

4.3 训练参数:fold、GPU、patch size 怎么调

预处理完成后,训练只需要一条命令:

nnUNetv2_train 1 2d 0 --device cuda --npz

参数说明:1是数据集编号,2d是模型配置,0是 fold 编号。--npz表示保存 softmax 概率图,用于推理时的模型集成和不确定性分析,建议加上。数据量少时先跑 fold 0 看趋势,不用五个 fold 全跑。

显存不够时,优先改 patch size,而不是 batch size。nnU-Net 规划好的 patch size 在预处理目录的 plans 文件里,比如nnUNet_preprocessed/Dataset001_AbdoUS/nnUNetPlans_2d.json。手动把 patch size 从512x512降到384x384,重新跑plan_and_preprocess才会生效。BatchNorm 在小 batch size 下表现很不稳定,batch size 不要低于 2。

下面是我常用的参数参考表:

参数推荐值说明
model2d二维 B 超帧优先选 2D
fold0先跑一个 fold 观察曲线
patch_size自动规划显存不足时从 512 降到 384
batch_size2 或 3别降到 1
mixed precision默认开启显存不够时优先关--npz而不是关混合精度

训练过程中不要只看训练 loss,重点看每个类别的验证集 Dice。nnU-Net 会在每个 epoch 结束后计算验证 loss,但默认打印的是平均 Dice,小器官翻车时平均指标依然好看。到第五阶段我会再单独按类别计算指标。

5. 超声多器官分割的 5 个经典踩坑:从标注漏标到血管断裂

5.1 现象:胆囊和肝门静脉区域预测成一团

训练到一半,验证集可视化里最常见的问题是胆囊和肝门静脉被模型连成一片。原因是胆囊的无回声暗区和血管的暗带灰度几乎一样,图像上本来就没有清晰边界,如果标注时只标了血管主干、漏了细分支,模型会倾向于把两个邻近暗区合并成一个连通域。

解决思路有两个层次。第一,用边界惩罚损失,常见做法是给 mask 加一层距离变换,让网络在意器官边界;第二,后处理时按连通域面积过滤血管碎片,把小于一定像素数的连通域从血管预测中剔除。注意不要直接“保留最大连通域”,因为血管分支天生多连,保留最大连通域会把其他分支全删光。

5.2 现象:小器官 Dice 很低但整体 mIoU 还行

很多人在训练日志里看到平均 Dice 0.85 就以为自己成功了,按类别一拆,肝脏 0.93、肾脏 0.82、胆囊 0.61、血管 0.3。这就是典型的类别不平衡。原因是血管和胆囊占像素比例太低,Dice Loss 对整个 batch 求平均时,小器官的梯度被肝脏和背景淹没。

解决方法是按类别加权。nnU-Net 内部已经做了频率加权,但如果你手写 U-Net,就要在损失函数里显式加权。血管的权重通常是肝脏的 3 到 5 倍,但权重太高会把声影和暗区误判为血管,所以我建议先用 nnU-Net 的默认权重跑一版,再手动调小器官权重。评估报告里也要按器官逐列展示 Dice,而不是只给一个平均分。

5.3 现象:验证集 Dice 忽高忽低,同一个模型两次推理差 3 个点

如果同一份验证集每次评估结果波动很大,最有可能是数据划分没做患者隔离。超声视频相邻帧内容相同,随机按帧划分时,验证集里会出现和训练集几乎一模一样的帧,验证 Dice 虚高,而且模型在 dropout 或多线程推理下表现不一致。

解决方法是严格按患者 ID 分组划分,测试时固定窗口中心,打开 TTA 水平翻转取平均。超声腹部图像左右器官分布有一定对称性,TTA 对肝脏和肾脏的分割尤其有效。注意推理时要把 TTA 的随机 seed 固定,否则两次推理还会差零点几个点。

5.4 现象:灰度图像像蒙了一层雾,分割边界漂移

超声设备和采集条件差异会在图像上留下整片灰雾,比如探头频率低、增益过高、患者体脂厚。预处理时如果只做简单 min-max,低回声器官的边界会被灰雾吞掉,导致肝脏边缘漂到腹壁。

解决方法是统一做百分位裁剪,并用 GammaContrast 增强模拟不同的增益曲线。如果数据集里带有 DICOM 原始文件,尽可能用设备导出的灰度标准,不要自己再归一化。我在实际项目里发现,把预处理从 min-max 换成 1% 到 99% 百分位裁剪后,胆囊的 Dice 直接涨了 0.08,这种提升比调网络结构来得更快。

5.5 现象:血管指标每次推理结果飘忽

血管在超声腹部图里是树状结构,分支细、长度长、像素少,逐像素 Dice 对边缘误差非常敏感。同一个模型在相似图像上跑两次,Dice 可能从 0.55 跳到 0.62,而肉眼看到的分割结果几乎没有差别。

解决方法是加一个对薄壁结构更友好的指标:HD95,也就是 95% 豪斯多夫距离。它衡量的是预测边界和真实边界之间的距离,血管这种细长结构用 HD95 评估比 Dice 稳定得多。另外,血管后处理不要使用连通域过滤,因为血管天然多段,可以先用形态学闭运算把断裂的小分支连起来,再做轻微腐蚀去掉孤立点。这个经验我保留了很久,每次做血管分割都会先写一版后处理再训练。

6. 验证脚本怎么写得既快又稳:Dice、HD95 和可视化一条龙

训练结束后的验证脚本,应该同时输出三类信息:按类别分的 Dice、按类别分的 HD95、以及固定几层的预测轮廓叠加图。下面是一个精简但完整的验证脚本:

import numpy as np from scipy.spatial import cKDTree def dice_per_class(pred, gt, class_idx): p = (pred == class_idx).astype(np.uint8) g = (gt == class_idx).astype(np.uint8) return 2.0 * (p & g).sum() / (p.sum() + g.sum() + 1e-6) def hd95_per_class(pred, gt, class_idx, spacing=(1.0, 1.0)): p = np.argwhere((pred == class_idx).astype(np.uint8)) g = np.argwhere((gt == class_idx).astype(np.uint8)) if len(p) == 0 or len(g) == 0: return float("inf") tree_p = cKDTree(p * np.array(spacing)) tree_g = cKDTree(g * np.array(spacing)) d_pg = tree_p.query(g)[0] d_gp = tree_g.query(p)[0] return max(np.percentile(d_pg, 95), np.percentile(d_gp, 95)) # 对每个类别循环调用,生成表格 class_ids = [1, 2, 3, 4, 5] for c in class_ids: d = dice_per_class(pred_mask, gt_mask, c) h = hd95_per_class(pred_mask, gt_mask, c) print(f"class {c}: Dice={d:.4f}, HD95={h:.2f}mm")

spacing参数要换成数据集真实的像素间距,否则 HD95 不是毫米单位。如果是 PNG 数据集没有间距信息,可以把 spacing 设成(1.0, 1.0),此时 HD95 表示像素距离,报告里写明单位即可。可视化我习惯保存成一张大图,左边原图,中间标注 mask,右边预测 mask,然后只抽胆囊和血管最难看的几层查看。

我之前在一批超声数据上把平均 Dice 刷到 0.88 就急着出结果,后来发现血管的 HD95 高达 18mm,几乎等于没分割,而平均指标完全没暴露问题。从那以后,我每次训练结束都会按类别重新计算指标,并固定四个切片做可视化对比,很多隐患在出报告前就暴露了。希望帮到你,祝你这套超声腹部多器官分割数据早日跑出好结果。

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

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

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

立即咨询