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测试病例数量,可为 020
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,进入指纹提取与计划生成阶段。

nnU-Net 数据集目录结构与命名映射 Dataset001_Liver/ imagesTr/ labelsTr/ imagesTs/ dataset.json 训练图像(多通道) LIVER_001_0000.nii.gz → 通道 0(CT) LIVER_001_0001.nii.gz → 通道 1(如 T2) 训练标签(单文件,无通道后缀) LIVER_001.nii.gz → 像素值 0/1/2 表示类别 测试图像(无标签) LIVER_132_0000.nii.gz → 仅用于推理 dataset.json 关键字段 channel_names : {"0": "CT"} labels : {"background":0, "liver":1} numTraining : 131 numTest : 20 file_ending : ".nii.gz"