☰
从NIfTI到nnU-Net:脊椎MRI 3D分割数据预处理与训练实战
2026/10/4 1:31:43 网站建设 项目流程

简介:面向医学图像分割和深度学习研究者,这套3D MRI人体脊椎分割数据集采用NIfTI格式存储,聚焦脊柱SPINE与MRI影像分割任务,适用于训练、验证和测试语义分割模型,解决脊柱结构自动化标注中缺少标准数据集的问题。压缩包共222个文件,其中220个nii格式的3D影像数据按原始MRI与对应分割标签成对组织,另附dataset.json配置文件和txt说明文档,整体大小约374.58MB,目录结构简洁,便于按编号索引。目前已有299人学习/下载。数据集完整性较高,既包含原始三维核磁共振影像,也提供对应脊椎标注,可直接接入nnU-Net等主流框架的数据加载流程;dataset.json与txt说明文件可辅助核对样本划分和模态信息,帮助快速搭建训练环境,免去大批量格式转换与命名整理工作,适合医学影像方向的学生、研究人员和算法工程师作为基准数据集,用于脊柱分割模型的复现与对比实验。

1. 一份能直接喂给 3D 分割模型的脊椎 MRI 数据:它到底长什么样

做脊椎 MRI 分割的同行应该都有体会:算法结构反而是最简单的部分,真正卡住三天进度的往往是数据本身的格式和标注,尤其是 nii.gz 这类 3D 医学影像数据,处理不当会在第一步就翻车。这套脊椎 SPINE 分割数据集正是冲着这个痛点来的——它按 nnU-Net 的标准格式组织,每个样本包含一对 MRI 原图与分割标签,图像文件统一带_0000后缀,直接能作为 3D 分割模型的输入。适合谁:正在做椎骨分割、3D MRI 语义分割、或者拿着 nnU-Net 练手的开发者和科研人员。不足之处也要提前说清:样本量不大,做好数据增强和交叉验证,会比盲目堆网络结构更能出效果。

2. 拆开 nii.gz 看数据:NIfTI 头信息、affine 与 dataset.json 的关键字段

2.1 NIfTI 格式到底存了什么:header、voxel 与 affine

拿到压缩包解压之后,你会看到dataset.json、133.nii、133_0000.nii这样的文件组合。这里先说明一件事:你下载后看到的文件后缀可能是.nii,也可能是.nii.gz,两种本质上是一种东西——.nii.gz就是.nii做了 gzip 压缩,加载代码几乎不用改。判断它是不是有效的 NIfTI,看文件前几字节的 magic 字段即可,不是看后缀名。

NIfTI 文件内部不是简单的像素数组,它有一个固定长度的 header(348 字节),里面存了维度信息、体素尺寸(pixdim)、数据类型、以及从体素坐标映射到世界坐标的 affine 变换矩阵。我在第一次处理这套数据时,直接用nibabel.load()读出数据后丢给模型训练,结果预测出来的 mask 和原图完全错位,最后发现是忽略了affine里的翻转信息。MRI 扫描的方向约定很多,同一个对象的矢状位和冠状位数据,数组排布方向完全不同,不读 affine 直接 reshape 是一定要出事的。

NIfTI 的数组形状是(i, j, k),对应三维体素索引;而医学影像里我们常说的 x、y、z 坐标,是拿这个索引乘以 affine 矩阵得到的。所以处理 3D 分割数据的第一原则:图像数组的形状必须和它自带的 affine 绑定在一起解释,单独看任何一个都没有意义。这套脊椎数据里的每一对*.nii和*_0000.nii,标注和原图理论上应该共享一套 affine 坐标系,但实践里我每次都逐一核对,因为标注制作软件重导出一遍就容易弄丢方向信息。

2.2 dataset.json 与文件命名:nnU-Net 的数据契约

dataset.json是这套数据能不能被 nnU-Net 直接识别的地基。以 3D MRI 分割任务为例,nnU-Net 对数据集的约定非常明确:图像文件的命名是案例ID_模态号.nii.gz,标签文件的命名是案例ID.nii.gz,两者放在不同子目录,且 labelsTr 下没有_0000后缀。所以你可以看133_0000.nii和133.nii的区别——前者是输入图像,后者是专家标注的分割 mask,模态号0000代表这是单模态数据。如果以后你拿到多模态 MRI(T1、T2 两序列),就需要case_0000.nii.gz和case_0001.nii.gz两个输入文件了。

dataset.json内容一般不长,几十行而已,核心字段集中在通道定义、标签字典、训练样本数和文件后缀。一个典型的例子我放在下面,但是注意:你拿到手的 dataset.json 以实际文件为准,我见过不少数据集里 labels 字典的键名写错,或者类别数和你预期不一致,训练前必须先打开确认。

{ "channel_names": { "0": "MRI" }, "labels": { "background": 0, "spine": 1 }, "numTraining": 5, "file_ending": ".nii.gz", "overwrite_image_reader_writer": "SimpleITKIO" }

逻辑说明:channel_names定义输入通道,0 号通道是 MRI;labels是语义类别映射,background 是 0,椎骨区域是 1;numTraining是训练样本数;file_ending声明文件后缀。参数说明:如果你的数据里椎骨按节段拆成了多个标签(比如 T1 到 L5 每个椎体一个编号),那labels里会多出"T1": 1, "T2": 2这样的项,你需要在预处理时决定是合并成二分类还是保留多分类。

2.3 拿到手先做的一件事:全量 sanity check

不管项目描述写得有多清楚,我拿到任何新数据集的第一个动作永远是跑一遍全量检查,而不是直接进训练。这一步能挡掉大半的坑。我会写一个小脚本,遍历每个案例,检查三件事:图像和标签的 shape 是否一致、spacing(体素尺寸)是否一致、非零体素占比是否正常。

import numpy as np import nibabel as nib from pathlib import Path base = Path("./Spine_dataset") image_dir = base / "imagesTr" label_dir = base / "labelsTr" for img_path in sorted(image_dir.glob("*.nii*")): case_id = img_path.name.split("_")[0] label_path = list(label_dir.glob(f"{case_id}.nii*")) if not label_path: print(f"[ERROR] {case_id}: label not found") continue img_nii = nib.load(str(img_path)) lab_nii = nib.load(str(label_path[0])) img_data = img_nii.get_fdata() lab_data = lab_nii.get_fdata() print(f"{case_id}: img_shape={img_data.shape} spacing={img_nii.header.get_zooms()} " f"lab_shape={lab_data.shape} lab_unique={np.unique(lab_data)} " f"lab_ratio={np.mean(lab_data > 0):.4f}")

这个脚本的逻辑说明:get_zooms()拿到的是每个维度的体素间距,比如(1.0, 1.0, 3.0)表示层厚 3mm 的扫描;lab_unique打印标签值,如果出现小数说明标注被转成了 float,这是后续计算的隐患;lab_ratio是前景占比,脊椎在 MRI 体数据里通常占比很低,如果打印出来超过 30%,那你得怀疑标注是不是被二值化成了整图。第一次跑这套数据时,我就发现个别案例 spacing 和其他样本不一致,直接 resample 到统一分辨率解决了,否则模型训练时 batch 都无法对齐。

3. 从 nii.gz 到训练集:用 SimpleITK 完成加载、预处理与数据增强

3.1 MRI 特有的预处理流程:偏置场校正与逐例归一化

和 CT 不同,MRI 没有绝对的灰度标定,同一个扫描序列在不同设备、不同线圈下灰度分布差异很大。更麻烦的是偏置场(bias field)效应,图像中间亮边缘暗,这种低频不均匀性会直接影响分割模型对边界的判断。所以处理这套脊椎 MRI 数据,我不会跳过偏置场校正这一步。工具首选 N4ITK,它拿一个前景 mask 估计偏置场然后除掉,对脊椎这种解剖结构相对居中的数据很友好,尤其是 3T 设备扫出来的图像,校正前后分割结果能差几个点的 Dice。

处理 MRI 数据还容易踩的坑是归一化方式。很多人习惯直接(data - min) / (max - min),这对 CT 的 HU 值尚可,对 MRI 完全不行——个别高亮噪声点会压掉整个动态范围。我一般用百分位裁剪后做 z-score,具体做法是取 0.5% 到 99.5% 分位数做截断,然后算均值和方差做标准化。这套数据集只有 5 例,逐例归一化比全局归一化更稳,因为每个 case 的灰度分布可能差异很大,全局统计算出来的均值和标准差会被某一个样本带偏。

import SimpleITK as sitk import numpy as np def n4_bias_correct(image_path): img = sitk.ReadImage(image_path) mask = sitk.OtsuThreshold(img, 0, 1, 200) corrector = sitk.N4BiasFieldCorrectionImageFilter() corrected = corrector.Execute(img, mask) return corrected def normalize_mri(data): data = data.astype(np.float32) lo = np.percentile(data, 0.5) hi = np.percentile(data, 99.5) clipped = np.clip(data, lo, hi) mean = clipped.mean() std = clipped.std() return (clipped - mean) / (std + 1e-6)

代码逻辑说明:n4_bias_correct里 Otsu 阈值生成一个粗略的前景 mask,N4 只需要在组织区域内估计偏置场,背景区域会干扰估计;normalize_mri返回的是 float32 数据,后面喂给网络前不用再转。参数说明:Otsu 的200是直方图 bin 数,对大多数 MRI 体数据够用;如果图像分辨率很高可以提高到 256。z-score 里的1e-6防止平坦区域除零,强烈建议保留。

3.2 用 SimpleITK 加载并可视化切片:先看到再训练

预处理跑完,我会做一件事:把每个病例的中间层切片组合成一张图,用 Matplotlib 存到磁盘上人工过一遍。这一步看似原始,但能发现很多代码层面看不出的问题——比如标注画到了椎管里、图像和标签轴向相反、某个病例有明显伪影。可视化代码并不复杂,关键是选取有代表性的切面,矢状面(sagittal)对脊椎结构最直观。

import matplotlib.pyplot as plt def save_visual_check(case_id, data, label, out_dir="./check"): # 取中间切片的矢状面:shape 为 (z, y, x),选 y 方向中间层 mid_y = data.shape[1] // 2 slice_img = np.rot90(data[:, mid_y, :]) slice_lab = np.rot90(label[:, mid_y, :]) fig, axes = plt.subplots(1, 2, figsize=(12, 6)) axes[0].imshow(slice_img, cmap="gray") axes[1].imshow(slice_img, cmap="gray") axes[1].contour(slice_lab, levels=[0.5], colors="red", linewidths=0.8) axes[0].set_title("MRI") axes[1].set_title("Overlay") plt.tight_layout() plt.savefig(f"{out_dir}/{case_id}_check.png", dpi=150) plt.close()

逻辑说明:np.rot90是为了让切片显示成常规的矢状面方向,避免头朝下;label 叠加用contour画轮廓而不是直接imshow(label),这样能看到标注边界和图像解剖结构的贴合程度。参数说明:levels=[0.5]只画二值边界的轮廓线,如果标签是多分类,你需要改成遍历每个类别值。实际使用中,我会把 5 个案例如法炮制,拼成一张大图快速扫一遍,比逐个打开 ITK-SNAP 高效得多。

3.3 面向分割的数据增强:小样本量下必须做重

5 例训练数据是这个项目的硬约束,也是我反复强调的点。这种情况下数据增强不是可选项,而是直接影响模型能不能收敛到合理精度的关键。我常用的是monai的RandAffined和RandGibbsNoise,前者做空间变换,后者给图像加随机纹理扰动,模拟不同扫描设备之间的差异。注意空间增强和强度增强必须只作用于图像,不能动标签;而仿射变换作用于图像和标签时,插值方式必须区分——图像用线性插值,标签用最近邻插值,否则标签边缘会被插出 0.5 这样的模糊值。

import monai.transforms as mt train_transform = mt.Compose([ mt.LoadImageD(keys=["image", "label"], image_only=True), mt.EnsureChannelFirstD(keys=["image", "label"]), mt.RandAffined( keys=["image", "label"], prob=0.8, rotate_range=(0.15, 0.15, 0.15), scale_range=(0.1, 0.1, 0.1), mode=("bilinear", "nearest"), padding_mode="border" ), mt.RandGibbsNoiseD(keys=["image"], prob=0.3, alpha=(0.1, 0.4)), mt.RandGaussianSmoothD(keys=["image"], prob=0.2, sigma=(0.5, 1.0)), mt.ScaleIntensityRangeD(keys=["image"], a_min=-3.0, a_max=3.0, b_min=0.0, b_max=1.0, clip=True) ])

参数说明:rotate_range单位是弧度,0.15 约 8.6 度,脊椎结构细长,旋转角度不宜太大,旋转过猛会让椎体形状失真;scale_range0.1 是 10% 缩放,控制在这个范围不会导致椎骨相对位置严重畸形;关键在mode参数,("bilinear", "nearest")的顺序必须和keys一一对应——图像用双线性插值保留灰度平滑度,标签用最近邻保持整数值,这条我在生产环境中踩过,忘了改插值方式导致标签出现 1.3 之类的数值,Dice 计算直接报错。padding_mode="border"是让旋转时边缘补成像边界值,比补 0 更温和,因为 MRI 背景接近黑,补 0 会让噪声方差被拉大。

4. 把 5 例数据训练出能用的模型:nnU-Net 适配与训练参数落地

4.1 5 例数据的划分策略:不做固定训练/验证集,改做 5 折交叉验证

5 个样本的常规做法八成会翻车:你划分 3 训练 1 验证 1 测试,验证集和测试集各只有一个样本,一次随机的坏运气就让你误判整个方案的可行性。我处理这种规模的医学数据,策略很固定:做 5 折交叉验证,每折只用 4 例训练、1 例验证,五折平均结果作为最终汇报指标。等于每个样本都会轮流做一次验证,比固定划分要稳得多。

代价是训练时间变成了 5 倍。但对这种体量的 3D 分割任务来说,一折训练在单张 RTX 4090 上耗时不会太久,时间成本可以接受。如果你在卡资源受限的环境,至少也要做 3 折,比 1 折固定划分有意义得多。折叠划分时注意文件按 case_id 排序后轮流分配,不要让同一个病人的多个扫描进到同一折——这套数据每个 case 只有一个扫描,暂时没有这个问题,但如果你后续扩充数据,这个原则要守住。

4.2 适配 nnU-Net 的目录结构与 dataset.json:从零整理一份可用数据

nnU-Net 对数据集目录结构有硬性要求,直接平铺文件不认。我把这套数据整理成标准结构时会跑一个重排脚本,同时也做了后缀兼容——因为下载后文件可能是.nii也可能是.nii.gz。

Spine_dataset/ ├── dataset.json ├── imagesTr/ │ ├── 1_0000.nii.gz │ ├── 15_0000.nii.gz │ ├── 99_0000.nii.gz │ ├── 109_0000.nii.gz │ └── 133_0000.nii.gz └── labelsTr/ ├── 1.nii.gz ├── 15.nii.gz ├── 99.nii.gz ├── 109.nii.gz └── 133.nii.gz

注意 imagesTr 里文件名是caseID_0000,labelsTr 里是caseID,没有_0000。如果你的原始文件名不是这个规律,用下面这段脚本批量重排:

import shutil from pathlib import Path src_dir = Path("./raw") dst_img = Path("./Spine_dataset/imagesTr") dst_lab = Path("./Spine_dataset/labelsTr") dst_img.mkdir(parents=True, exist_ok=True) dst_lab.mkdir(parents=True, exist_ok=True) for f in sorted(src_dir.glob("*.nii*")): suffix = f.suffix if f.suffix == ".nii" else ".nii.gz" name = f.name.replace(suffix, "") if "_0000" in name: case_id = name.replace("_0000", "") shutil.copy(f, dst_img / f"{case_id}_0000{suffix}") else: shutil.copy(f, dst_lab / f"{name}{suffix}")

逻辑说明:脚本先判断文件后缀,再按文件名里是否含_0000决定拷贝到图像目录还是标签目录。参数说明:路径变量按实际解压位置改;我建议用copy而不是move,保留原始文件作为备份,毕竟重命名操作不可逆,万一脚本写错了还能从原始目录捡回来。

dataset.json放到Spine_dataset/根目录后,还需要确认里面的labels定义和你手里的标注一致。方法是第 2.3 节那段 sanity check 脚本,跑一遍打印lab_unique看看。如果标注是 0/1 两个值,那 json 里的字典就是 background/spine;如果是 0-24 这样的多类别,就要逐个对应椎体节段,这一步省不得。

4.3 nnU-Net 训练参数落地:plan 阶段与关键超参

目录整理完,下一步是让 nnU-Net 跑plan_and_preprocess。这一步会自动分析每个 case 的 spacing 和体素尺寸,规划出合适的下采样倍数和 patch size。对这一套 5 例数据,我建议在命令里指定--verify_dataset_integrity,让框架先做一个全量检查,比你自己写的 sanity check 更严格,能在预处理开始前拦截 90% 的数据格式问题。计划阶段跑完后,打印出来的planner日志里重点看两个指标:patch_size和batch_size。

我在实际训练里会把 nnU-Net 默认配置改两三处。第一处是num_epochs,5 例数据训练几百轮意义不大,模型很快会记住训练样本;我习惯设 200 epoch 以内,更多依赖跨折指标来评估。第二处是batch_size,这套数据单例体积不大,plan 阶段如果给的建议是 2,可以放心加到 4,前提是显存够用,颈椎 MRI 单例通常在 128×256×256 附近,patch size 会取中间区域,batch 4 在 24G 显存下能吃得住。

关于类别不均衡:脊椎在 MRI 体数据里占的比例通常只有 2% 到 8%,背景占了绝大多数。这个不用手动做太多干预——nnU-Net 的损失函数里自带类别频率加权,背景权值会自动压低。你要确保的是输出层通道数和dataset.json里labels的类别数一致。如果多分类椎体节段,通道数是节段类别 n 加背景 1;如果二分类整个脊椎区域,通道就是 2。

推理阶段同样有讲究。3D 分割推理用滑窗(sliding window),窗口大小和训练时的 patch size 保持一致,窗口之间要有重叠,重叠率一般取 0.5。重叠区域的预测结果不是直接取平均,而是做高斯加权——中心位置权重高、边缘权重低,这样拼接处不会出现网格状伪影。这个细节决定最终输出的流畅度,尤其是椎骨这种细长结构,边缘处权重突变会造成明显的接缝。

5. 踩过的坑与排查清单:标签错位、OOM、归一化失效的五个实战记录

5.1 图像和标签错位:明明同一根椎骨,预测却偏了两厘米

现象:训练 loss 正常下降,但验证集 Dice 始终低于 0.5,可视化预测结果发现预测的高亮区域和真实的椎骨位置整体偏移,有时甚至上下颠倒。

原因:这套数据里图像和标签的 affine 矩阵不一致。一种情况是标注工具导出时用了不同的 S-form/Q-form 编码,另一种是某一步 resample 只对图像做了,没有对标签应用同一个变换。我不止一次见过同事在预处理阶段 f-string 写错文件名,导致图像读了 A 病例、标签读了 B 病例,模型照样训练但验证时现出原形。

解决:在 sanity check 阶段用nibabel同时打印图像和标签的 affine,直接np.allclose(img.affine, label.affine)逐一对比。如果不一致,核对是哪一维翻转或平移,然后用nibabel的resample_from_to把标签重采样到图像空间。从那以后,我把 affine 一致性检查写进了所有医学影像项目的 CI 流程,每次数据更新都自动跑一遍。

5.2 训练 OOM:显存不足,模型根本起不来的真实原因

现象:PyTorch 报CUDA out of memory,但显存看上去只占了一半,代码一跑 batch 就崩。

原因:3D 卷积的中间激活值占用是惊人的,尤其是 nnU-Net 这种编码器-解码器结构,encoder 最后一层特征图虽然分辨率低,但通道数翻倍,显存占用并不线性。另一个常见原因是 plan 阶段把 patch size 设得过大,比如原始图像 256×256×128,planner 直接把它作为 patch size 采样,5 折交叉验证里模型吃不下。

解决:先把batch_size降到 1,确认能跑通;再把 patch size 逐步减小,观察显存占用曲线。经验值:24G 显存下 3D U-Net 处理(1, 1, 128, 192, 192)的标准 patch 是稳的,超过这个规模需要调小。还有一个偏方是启用梯度累积,batch_size=1 累积 4 步等效于 batch_size=4,但要注意 BatchNorm 在这种模式下表现会变差,最好换成 InstanceNorm。

5.3 归一化失效:预测结果全是一个类,Dice 直接为零

现象:模型训练完,验证集预测输出整张图全是背景,Dice 是 0;或者反过来,整张图全是前景,似乎没有任何判别力。

原因:在使用nibabel.get_fdata()时,标签被读成了 float64,归一化时坐标(-3.0, 3.0)把标签值 0 和 1 的范围压到了很窄的区间,再加上把图像和 label 做了同一个 z-score,标签里的 1 被削弱到接近 0,最终 softmax 输出层无法区分两个类别。本质是数据类型没有保持。

解决:标签数据在送入模型前强制转为torch.long,而且绝不参与单样本归一化。具体做法是在 Dataset 类的__getitem__里单独处理标签,保持 0/1 整数值不变,只有图像走强度归一化。这个坑在医学影像实验室里出现频率极高,排查时直接打印训练样本里的 label 取值分布,一眼就能定位。

5.4 数据增强把标注插坏了:标签里出现 1.3 这种值

现象:训练中 loss 出现 NaN,或者验证 loss 一直抖动不收敛,检查数据 Loader 时发现 label 的取值有 0.5、1.3 这类小数。

原因:用同一个 transform 处理图像和标签时,随机仿射的插值方式没区分。图像用了双线性插值没问题,但标签如果也用双线性,边缘会被插成带小数的新值。这在分割任务里是致命的,因为交叉熵损失会把 1.3 当一个新的类别处理,或者计算 Dice 时torch.unique()看到一堆奇怪的值直接崩。

解决:凡是对 label 的 transform,统一指定mode="nearest"。在monai的RandAffined里就是传入mode=("bilinear", "nearest"),前面提到了这一点,这里再强调一次是因为我确实在这个问题上丢过一天的训练进度。同时建议在 transform 链的末尾加一个AsDiscrete操作,把 label 强制舍入到最近整数,起到双保险的作用。

5.5 5 例数据过拟合:验证指标虚高,换批数据就崩

现象:交叉验证每折 Dice 都到 0.9 以上,看起来效果惊艳,但把模型拿去推理外部数据时,结果一塌糊涂,几乎全部预测失败。

原因:5 例训练样本太少,nnU-Net 默认的数据增强虽然强,但空间变换范围有限,模型实际上记住了训练集里椎骨的具体形状和位置,而不是学到了通用的解剖结构特征。验证集和训练集来自同一个采集设备、同一批标注标准,指标虚高是必然的。

解决:不要只看交叉验证均值,重点观察五折之间的方差。如果每折 Dice 波动超过 0.05,说明单折训练不稳定。应对方案:一是把数据增强的幅度拉大,尤其是弹性形变和灰度扰动,这招对 MRI 这种灰度分布不固定的模态很有效;二是做模型集成,五折训练出五个模型,推理时预测结果投票取多数,能在不增加训练成本的前提下明显提升稳定性;三是放弃对 0.9 指标的执念,小样本医学分割的合理期望值应该放在 0.8 附近,达到这个水平并有稳定的跨折表现,模型已经具备参考价值。

6. 验证分割结果:从 Dice 计算到推理细节的三个检查习惯

分割模型的验证不是打印一个数字就完事。我习惯按三个层次做检查:先算 Dice 和表面距离,这是量化指标;再可视化切片逐层看,这是定性检查;最后做一次全 volume 的拼接一致性检查,防止滑窗推理在边界处留痕。

Dice 的计算看起来简单,但有几个细节会影响结果。二分类任务直接对预测概率做阈值,阈值取 0.5 是默认值,不算错;多分类则先做argmax再逐类别计算 Dice。下面这段代码我每次做验证都会用,唯一的改动是类别数。

import numpy as np def dice_score(pred, gt, smooth=1e-5): pred = pred > 0.5 gt = gt > 0.5 inter = 2 * np.sum(pred & gt) total = np.sum(pred) + np.sum(gt) return (inter + smooth) / (total + smooth)

逻辑说明:预测和标签都二值化后再求交叠数,smooth是为防止两个都是空时除零,实际场景里如果某例数据没有前景,dice 定义为 1 还是 0 需要事先约定。参数说明:阈值 0.5 只适用于二分类 sigmoid 输出的场景,如果你用 softmax 输出,不要先做 argmax 再套这个函数,而是直接对目标类别的概率通道做 dice,否则会丢失概率信息。

表面距离我常用surface_distance库里的compute_surface_distances,指标看 95% Hausdorff Distance。Dice 对内部空洞不敏感,但对病灶边缘毛刺反映不充分,HD95 能补上这部分评价。对脊椎分割这个任务,HD95 的合理期望是 3mm 以内,超过这个值说明边界预测粗糙,大概率是 resample 步骤丢失了解剖细节。

推理拼接一致性检查是很多人忽略的。滑窗推理时窗口移动步长如果设置不当,相邻窗口的重叠区域可能预测出不一致的边界。我在推理代码里加了一个断言:预测完一个案例后,检查每个方向上窗口起点步长是否整除 patch 尺寸,不整除就报错。因为不整除意味着边缘处留出了没有窗口覆盖的死区,最后的拼接结果在这些位置会出现明显的分界线。

从第一次在 5 例脊椎数据上跑出 0.85 的 Dice,到后来在影像组学项目里用这套预处理管线,我养成了一个习惯:任何新数据进来,先花十分钟做 sanity check、再花五分钟看一眼矢状面切片,最后才允许模型训练。这个流程帮我挡掉的标签错位、方向翻转、类型错乱不计其数。如果你也准备用这套数据训练分割模型,建议把这三个检查写进你的启动脚本,而不是等损失函数飞出 NaN 再去查,那样浪费的时间远远超过这几分钟。希望帮到你。

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

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

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

立即咨询