3. nnU-Net数据集结构与转换
nnU-Net 的所有训练、推理与交叉验证流程,都建立在一个约定俗成的数据集目录之上。它不依赖数据库,也不依赖额外的索引文件,而是通过“目录名 + 文件命名 + dataset.json”三件事来定位每一例影像和标签。理解这套结构,是后续做格式转换、跑通训练、排查“找不到文件”类报错的前提。
一、DatasetXXX_Name 命名规范
nnU-Net 要求数据集根目录名必须匹配 Dataset<三位数字>_<名称> 的形式,例如 Dataset001_Liver、Dataset027_Pancreas。其中数字部分称为 Dataset ID,是 nnU-Net 内部识别数据集的唯一键;下划线后的名称是给人看的可读标识,可以包含字母、数字和下划线,但不要使用空格、中文或特殊符号,避免路径解析和跨平台问题。
Dataset ID 一旦确定,就会出现在训练输出目录中,例如 nnUNet_results/Dataset001_Liver/nnUNetTrainer__nnUNetPlans__3d_fullres/。因此建议在项目早期就规划好 ID 分配,避免后期重命名导致已训练模型、预处理缓存与数据集对不上。常见做法是按课题顺序编号,如 001 肝脏、002 胰腺、003 心脏,同一课题的多个变体(如不同标注版本)用不同 ID 区分,而不是在同一 ID 下混放。
二、imagesTr / imagesTs / labelsTr 的组织方式
在 Dataset001_Liver/ 下,标准结构包含三个子目录:
imagesTr/:训练图像,每例一个文件,文件名形如LIVER_001_0000.nii.gz。labelsTr/:训练标签,与 imagesTr 中的病例一一对应,文件名形如LIVER_001.nii.gz,不带通道后缀。imagesTs/:测试图像,只放图像、不放标签,命名规则与 imagesTr 相同,用于推理或提交评测。
这里最关键的是 通道后缀 _0000。nnU-Net 把多模态影像视为“同一病例的多个通道”:CT 单模态时只有 _0000;若同一病例同时有 T1、T2、DWI 三路 MRI,则分别命名为 _0000、_0001、_0002。所有病例的通道顺序必须一致,且通道数要与 dataset.json 中的 channel_names 完全对应。标签文件则始终是单文件,像素值直接表示类别索引(0 为背景,1、2、3… 为前景类别),不要对标签做 one-hot 编码。
LIVER_001_0000.nii.gz,或在 imagesTr 中混入没有 _0000 后缀的文件。nnU-Net 会按后缀解析通道,命名不一致会直接导致“找不到对应标签”或通道数校验失败。三、dataset.json 字段含义
dataset.json 位于数据集根目录,是 nnU-Net 读取数据集的“说明书”。它至少包含以下字段:
| 字段 | 含义 | 示例 |
|---|---|---|
channel_names | 通道索引到模态名的映射,键为 "0"、"1"… | {"0": "CT"} |
labels | 类别名到像素值的映射,必须包含 "background": 0 | {"background": 0, "liver": 1, "tumor": 2} |
numTraining | 训练病例数量,需与 imagesTr 中病例数一致 | 131 |
numTest | 测试病例数量,可为 0 | 20 |
file_ending | 文件扩展名,通常为 ".nii.gz" | ".nii.gz" |
tensorImageSize | 旧版字段,部分版本仍读取,可写 "3D" | "3D" |
其中 labels 的数值必须与标签文件中的实际像素值严格一致。例如标签里肿瘤区域像素值为 2,而 dataset.json 写成 "tumor": 3,训练时该类别就会被当作背景或错位,表现为某些类别 Dice 恒为 0。此外,类别值建议从 1 开始连续编号,不要跳号,也不要使用 255 这类大值,以免与预处理中的强度归一化或重采样产生冲突。
四、从原始数据到 nnU-Net 格式的转换
实际项目里,原始数据往往来自不同医院,命名混乱、格式各异。转换脚本通常做四件事:读取原始图像与标注、统一重采样到同一空间(可选)、按规范重命名并复制到目标目录、统计数量并写出 dataset.json。下面是一个可参考的转换骨架:
import os, json, shutil, numpy as np, nibabel as nib
SRC_IMG = "raw/images"
SRC_LAB = "raw/labels"
DST = "nnUNet_raw/Dataset001_Liver"
os.makedirs(f"{DST}/imagesTr", exist_ok=True)
os.makedirs(f"{DST}/labelsTr", exist_ok=True)
os.makedirs(f"{DST}/imagesTs", exist_ok=True)
cases = sorted(os.listdir(SRC_IMG))
n_train = 0
for i, case in enumerate(cases):
img = nib.load(os.path.join(SRC_IMG, case))
lab = nib.load(os.path.join(SRC_LAB, case))
# 统一标签像素值:确保前景从 1 开始
lab_data = np.asanyarray(lab.dataobj).astype(np.uint8)
lab_data[lab_data > 0] = 1 # 示例:二分类,前景统一为 1
lab_new = nib.Nifti1Image(lab_data, lab.affine, lab.header)
if i < len(cases) - 20: # 前 N-20 例作训练
name = f"LIVER_{i+1:03d}"
nib.save(img, f"{DST}/imagesTr/{name}_0000.nii.gz")
nib.save(lab_new, f"{DST}/labelsTr/{name}.nii.gz")
n_train += 1
else:
name = f"LIVER_{i+1:03d}"
nib.save(img, f"{DST}/imagesTs/{name}_0000.nii.gz")
meta = {
"channel_names": {"0": "CT"},
"labels": {"background": 0, "liver": 1},
"numTraining": n_train,
"numTest": len(cases) - n_train,
"file_ending": ".nii.gz",
"tensorImageSize": "3D"
}
with open(f"{DST}/dataset.json", "w") as f:
json.dump(meta, f, indent=4)
print("done:", n_train, "train,", len(cases)-n_train, "test")
转换完成后,建议做一次自检:imagesTr 与 labelsTr 的病例名集合是否完全一致;每个图像是否有对应的 _0000 后缀;标签像素值是否只包含 dataset.json 中声明的类别;numTraining 是否等于实际文件数。可以用一条命令快速核对:
ls nnUNet_raw/Dataset001_Liver/imagesTr | sed 's/_0000.nii.gz//' | sort > /tmp/img.txt
ls nnUNet_raw/Dataset001_Liver/labelsTr | sed 's/.nii.gz//' | sort > /tmp/lab.txt
diff /tmp/img.txt /tmp/lab.txt && echo "OK"
若输出为空并打印 OK,说明图像与标签一一对应;若有差异,diff 会列出缺失或多余的病例名,据此回到转换脚本修正。完成这一步后,nnU-Net 才能顺利执行 nnUNetv2_plan_and_preprocess,进入指纹提取与计划生成阶段。