
简介基于 Pytorch 的 3D 图像分割任务完整工程包面向医学图像处理与深度学习开发者以 Luna16 CT 肺结节数据为案例系统呈现数据准备、模型搭建、训练、验证、测试、评估、可视化与后处理的完整代码思路。资源共 92 个文件包含 49 个 Python 脚本、7 个 npy 预处理数据、5 个 xml 配置与 3 个 nii 原始样本另有评估可视化 PNG 图与 CSV 标注信息包体约 61.66MB目录涵盖 preProcess、datasets、models、inference、postProcess 等清晰模块便于按流程查阅。代码提供 UNet3d 与 VNet3d 两种 CNN 结构并给出 bbox 标注生成、数据重采样、patch 抽取、多类别训练、预测掩膜裁剪合并等实用方法。目前已有 543 人学习适合正在入门 3D 分割、希望复现 CT 结节检测完整流程的读者参考。1. 从CSDN系列文章到这包代码LUNA16肺结节3D分割从数据准备到后处理一次性落地如果你的工作是把一批CT数据变成能用于训练的3D样本会发现网上公开的PyTorch分割项目大多止步于模型文件数据准备部分往往被一句话带过。这个压缩包不一样它把从原始NIfTI/NRRD开始的重采样、mask转bbox、新标签CSV生成、patch采样、训练、推理、评估到可视化后处理全部用脚本串联起来案例是复现率很高的LUNA16肺结节CT分割。模型采用UNet3d和VNet3d代码里能看到作者从数据版本到训练策略的迭代痕迹。想拿它做肺结节分割复现或把手里的医学图像推进同一个流程从这套资源开始最合适。2. 数据准备重采样、bbox与patch数据集的三段式落地从项目文件名能直接看出数据准备流程是step1到step3三条脚本step1.generate_resample_image_and_mask.py处理重采样step2.mask2bbox_centerCoor.py把mask转成bbox和中心坐标step3.generateNewBboxLabel_save_csv.py生成新的标注CSV。真正的训练读取靠datasets.py、datasets_patch.py这些数据集类来驱动。这一步走完原始CT才变成能直接喂给UNet3d/VNet3d的patch。2.1 重采样与mask生成统一spacing为什么是第一步LUNA16里不同CT的spacing差异很大XY平面从0.48mm到0.97mm都有Z轴层厚更夸张。3D卷积对体素数敏感同一个结节在A病人里可能是30³体素在B病人里缩到20³模型学的就不是同一个物理尺寸。所以第一步必须把图像和标注mask统一到同一个spacing下。我一般选1mm各向同性既能保留小结节的结构细节又不会让数据体量膨胀太多。import SimpleITK as sitk def resample_image_and_mask(image_path, mask_path, target_spacing(1.0, 1.0, 1.0)): image sitk.ReadImage(image_path) mask sitk.ReadImage(mask_path) original_spacing image.GetSpacing() original_size image.GetSize() # 按spacing比例计算新尺寸保证物理尺寸不变 new_size [ int(round(original_size[i] * original_spacing[i] / target_spacing[i])) for i in range(3) ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(new_size) resampler.SetOutputOrigin(image.GetOrigin()) resampler.SetOutputDirection(image.GetDirection()) # 图像用线性插值保留HU值过渡 resampler.SetInterpolator(sitk.sitkLinear) resampled_image resampler.Execute(image) # mask必须换最近邻插值否则会出现0.3这种灰值标签 resampler.SetInterpolator(sitk.sitkNearestNeighbor) resampled_mask resampler.Execute(mask) return resampled_image, resampled_mask这段代码里最关键的是mask重采样时切换插值器。mask是标签只有0和1线性插值会产生模糊中间值训练时阈值一卡就出现错误标注。图像用线性、mask用最近邻是3D数据准备里最基础的不对称操作。new_size的计算逻辑是按spacing比例缩放原spacing是0.8mm目标1.0mm体素数量会缩到原来的0.8倍但物理尺寸不缩水。重采样后还要做HU值裁剪。LUNA16的CT值范围很大直接喂网络会让训练不稳定。常见做法是裁剪到肺窗范围[-1000, 500]再做z-score归一化def normalize_ct(image_array, lower-1000.0, upper500.0): image_array np.clip(image_array, lower, upper) image_array (image_array - lower) / (upper - lower) return image_array.astype(np.float32)裁剪区间不是拍脑袋定的。空气约-1000HU肺实质约-500HU结节区域一般在-200到200HU之间500HU以上的像素通常是金属伪影或骨骼对结节分割没增益。如果你做的是其他器官这个区间要重新看直方图。项目里preProcess目录下应该还有resize和归一化的配套代码但上面这段是核心逻辑跑通step1后可以对照检查。2.2 从mask到bbox中心坐标、直径与体素坐标的换算LUNA16原始CSV里给的是coordX, coordY, coordZ这是世界坐标单位是毫米不是体素坐标。直接拿它去numpy数组里切片位置一定是错的因为world坐标到体素坐标还隔着origin、spacing和direction三重转换。def world_to_voxel(image, world_coord): 把CSV里的世界坐标转成体素坐标 voxel_index image.TransformPhysicalPointToIndex(list(world_coord)) # 返回顺序是(x, y, z)注意和numpy的(z, y, x)区分 return voxel_indexTransformPhysicalPointToIndex是SimpleITK自带的坐标系变换它内部已经处理了origin和direction。问题在于它返回(x, y, z)而GetArrayFromImage出来的numpy数组是(z, y, x)。写代码时如果直接拿返回值去arr[z, y, x]切片坐标顺序就反了。这个坑在第四章还会详细说。拿到中心坐标后下一步是根据直径生成bbox。LUNA16标注里有diameter_mm字段但结节不是标准球体直接按直径画cube会切掉边缘。项目里step2.mask2bbox_centerCoor.py和step3.generateNewBboxLabel_save_csv.py的处理思路是以中心坐标为核心边长取直径加padding保证结节完整落进bbox再统一裁剪成固定patch大小。def compute_bbox_from_center(voxel_center, diameter_mm, spacing, padding16): # voxel_center是(x, y, z)diameter_mm是结节直径 half [diameter_mm / spacing[i] / 2 padding for i in range(3)] bbox [] for c, h in zip(voxel_center, half): bbox.append(max(0, int(c - h))) bbox.append(int(c h)) # 返回 [x_min, x_max, y_min, y_max, z_min, z_max] return bbox注意diameter_mm / spacing[i]这一步把毫米直径换算成对应轴上的体素数。如果在重采样后做这一步spacing已经是1mm各向同性可以直接用直径体素数如果还在原始分辨率下处理三个轴的spacing不同必须分别换算。padding加多少取决于你的patch size和感受野常见是8到16个体素太小会把结节卡在patch边缘训练时卷积特征学不完整。2.3 PatchDatasetCSV驱动、在线增强与坐标顺序统一datasets.py、datasets_patch.py、datasets_v2.py、datasets_v3.py这些文件在项目里是迭代关系。核心思想都是用CSV驱动每行描述一个样本的图片路径、mask路径、中心点和标签训练时直接按索引读不用每次全图扫描。class PatchDataset(Dataset): def __init__(self, csv_path, patch_size(64, 64, 32), augmentTrue): self.patch_size patch_size self.augment augment self.samples self._load_csv(csv_path) # 按行读img/mask路径和中心点 def __getitem__(self, idx): sample self.samples[idx] img sitk.ReadImage(sample[img_path]) arr sitk.GetArrayFromImage(img) # (D, H, W)即(z, y, x) # center_voxel存的是(x, y, z)取patch时手动调成(z, y, x) cx, cy, cz sample[center_voxel] patch, mask_patch self._crop_patch(arr, (cz, cy, cx)) if self.augment: patch, mask_patch self._augment(patch, mask_patch) return (torch.from_numpy(patch).float().unsqueeze(0), torch.from_numpy(mask_patch).float().unsqueeze(0)) def _crop_patch(self, arr, center): d, h, w self.patch_size cz, cy, cx center z0 max(0, cz - d // 2) y0 max(0, cy - h // 2) x0 max(0, cx - w // 2) patch arr[z0:z0 d, y0:y0 h, x0:x0 w] # 边界不足的地方pad零值 pad_d d - patch.shape[0] pad_h h - patch.shape[1] pad_w w - patch.shape[2] patch np.pad(patch, ((0, pad_d), (0, pad_h), (0, pad_w)), modeconstant) return patch, patch.copy() # 实际代码里这里读mask_patch这里最实用的是把中心坐标顺序在__getitem__里统一掉。我见过太多人栽在这里CSV里存的是(x, y, z)numpy数组是(z, y, x)两套顺序来回切换代码跑起来不报错但训练出的模型在推理时全图错位。这条线我会贯穿整个流程CSV里统一存(x, y, z)进入numpy的瞬间统一转成(z, y, x)出来转成tensor再回到(x, y, z)每个转换点写注释。数据增强我只建议做随机翻转和随机旋转。3D随机旋转对大多数医学任务不友好因为CT的方向性很强左肺右肺翻转后语义已经变了。项目里datasets_v2/v3版本增加过增强策略实测中随机翻转加亮度扰动足够不要一上来就上全套。3. 模型与训练UNet3d和VNet3d的选型、损失函数和训练脚本迭代模型目录里能看到的文件是unet3d.py、unet3d_bn.py、unet3d_bn_activate.py、vnet3d.py训练脚本从train_main_2.py一直排到train_main_6.py另外还有loss.py、global_.py、config.py。把这些文件排开就能看出作者的演进路线模型加BN、加激活、换VNet结构、训练脚本跟着数据版本不断重写。3.1 模型选型UNet3d的后缀变化和VNet3d的残差结构unet3d.py到unet3d_bn.py到unet3d_bn_activate.py的差异核心是在每一层卷积后逐步加入BatchNorm和激活函数。BN对3D医学分割的帮助是实打实的它能加速收敛还能缓解不同病人CT值分布差异带来的训练抖动。unet3d_bn_activate.py里每层卷积后都接BNReLU这是31D分割的常见标准写法。# 一个UNet3D基础block的标准写法对应unet3d_bn_activate.py的结构 import torch.nn as nn class ConvBlock(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv1 nn.Conv3d(in_ch, out_ch, kernel_size3, padding1) self.bn1 nn.BatchNorm3d(out_ch) self.conv2 nn.Conv3d(out_ch, out_ch, kernel_size3, padding1) self.bn2 nn.BatchNorm3d(out_ch) self.relu nn.ReLU(inplaceTrue) def forward(self, x): x self.relu(self.bn1(self.conv1(x))) x self.relu(self.bn2(self.conv2(x))) return xVNet3d和UNet3d的本质区别在于下采样路径用了残差连接。结节分割是小目标背景占比极高VNet能把浅层的空间细节直接跳到深层避免小目标在下采样过程中被背景稀释。项目里同时保留vnet_def.py和vnet3d.py说明作者在不同阶段都试过。我的建议是如果数据量不大几十个CTUNet3d加BN就够网络更小更好调如果拿到完整LUNA16十折数据VNet3d的Dice上限更高。3.2 train_main_3到train_main_6多个训练脚本不是冗余多套train_main_x.py是这个项目最有价值的部分它不是代码冗余而是作者真实迭代过程的留痕。train_main_3/4对应早期数据版本train_main_5.py是对应最终数据集的完整训练入口train_main_6.py继续修正了推理和后处理逻辑train_main_multCls.py是多类别扩展版。看的时候先别急着跑最新版花十分钟diff一下三个版本能看出损失函数、采样策略、学习率调度分别动了哪些地方。# train_main_5.py的核心训练循环结构和思路示意 import torch import torch.optim as optim def train_one_epoch(model, train_loader, optimizer, device): model.train() total_loss 0.0 for imgs, masks in train_loader: imgs imgs.to(device) masks masks.to(device) preds model(imgs) loss dice_bce_loss(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(train_loader)训练侧配套的global_.py和config.py管理全局参数和路径online.py做在线推理辅助。建议训练前把config.py里三个东西确认掉patch_size、epoch数、初始学习率。这套代码适用的是64³级别patch配合单卡训练的策略盲目调大patch_size会直接爆显存。3.3 损失函数Dice Loss和BCE的组合为什么稳loss.py里大概率是Dice Loss和BCE的组合这是结节分割的常规做法。结节在CT里占比极小纯CE会被背景主导Dice Loss直接优化目标指标但Dice Loss的梯度在预测接近0或1时不稳定所以加上BCE做平滑约束代码实现上乘加再平均。def dice_bce_loss(pred, target, bce_weight0.5): bce nn.functional.binary_cross_entropy_with_logits(pred, target) pred_prob torch.sigmoid(pred) smooth 1.0 intersection (pred_prob * target).sum() dice (2.0 * intersection smooth) / (pred_prob.sum() target.sum() smooth) return bce_weight * bce (1 - bce_weight) * (1 - dice)训练日志里那几张图已经把迭代结果暴露了train_loss.png、valid_loss.png、valid_dice.png、lr.png。学习率用的是余弦退火或阶梯下降配合early stopping看valid_dice。训练完先看valid_dice有没有持续上升如果train_loss降但valid_dice不动大概率是采样策略里正负样本失衡。4. 避坑与常见问题坐标、插值与类别不均衡的五个实战坑这部分全部来自把同样流程从LUNA16迁移到其他CT数据的实战每一条都值得在开工前先看完。4.1 重采样后mask边缘出现灰值现象训练loss正常下降但预测出来的分割mask边缘总是糊一层像蒙了雾。原因重采样时图像和mask用了同一个线性插值器。mask是二值标签线性插值会产生0.3、0.7这类中间值网络在模糊标签上训练边界就不可能锐利。解决mask重采样必须用sitk.sitkNearestNeighbor图像保持线性插值两条线分别是两套插值器配置。这是step1里最容易犯但检查起来也最快的坑。4.2 世界坐标转体素坐标偏了一个方向现象训练数据集的bbox中心总是整体偏移要么偏左上要么偏右下肉眼在切片上检查时一眼就能看出来。原因直接把CSV里的coordX, coordY, coordZ当体素坐标用漏了TransformPhysicalPointToIndex这层世界坐标到体素坐标的转换或者把返回的(x, y, z)顺序直接当成numpy的(z, y, x)切片。解决写一个可视化检查脚本把bbox画在原始CT的横断位上逐个样本看中心是否落在结节上。血泪经验是这一步不能省坐标错位的数据集拿去训练跑完整个epoch才发现就是灾难。4.3 模型一直输出全背景valid_dice为零现象训练十几个epoch后prediction全为0Dice指标卡在0。原因patch采样时正负样本比例失衡负样本占比极高网络学到的就是一个“全部预测为背景”的局部最优。解决训练集构建时让正样本patch和负样本patch按1:1或1:2采样。正样本必须要求patch中心落在结节mask内且结节体素数大于某个阈值负样本在非结节区域随机采样把靠近图像边缘和空气区域的样本过滤掉。4.4 推理时一整个CT直接爆显存现象训练正常推理阶段把整个CT输入模型OOM报错。原因训练用了patch推理却想一次跑完整个512×512×200的体积3D网络显存占用随体素数量线性增长没有哪个单卡扛得住。解决推理阶段必须用滑窗。patch size和训练一致stride设为patch的一半或三分之一重叠区域预测取平均值保证边界平滑。4.5 保存的NRRD和原始CT叠加对不上现象后处理阶段把预测结果和原始CT在软件里叠加显示左右翻转或上下错位。原因NIfTI和NRRD的direction矩阵不同部分工具保存预测mask时丢了原始CT的origin和direction信息再读取时就偏了。解决保存mask时显式把原始CT的GetOrigin()、GetDirection()和重采样后的spacing写进NIfTI/NRRD头。CT读取遵循LPS坐标约定横断位翻转多半是direction矩阵丢了z轴负号。5. 推理与评估滑窗预测、patch合并与Dice可视化复盘训练完只是第一步真正决定模型能不能用的是推理流程。项目把推理拆成了inference_main.py、online.py、test.py、evaluate_pred_mask.py、crop_merge_fromNII_one.py和showRes_fromNrrd.py几个脚本。合并预测patch、评估指标、可视化结果每一步都有独立脚本改一个环节不用动其他地方。5.1 online.py和inference_main.py的分工inference_main.py是批量推理入口一次跑完整个测试集online.py是单样本或在线辅助推理适合在开发时快速验证一个CT。两者共用同一套模型和滑窗逻辑区别只是数据读取方式和结果输出路径。5.2 滑窗预测与patch合并把预测结果无损拼回原图3D滑窗推理的代码思路很直接遍历整个体积取patch预测后再放回原位置重叠区域用加权平均。def sliding_window_inference(model, volume, patch_size(64, 64, 32), stride(32, 32, 16)): # volume shape: (D, H, W)这里z在最前面 d, h, w volume.shape pred_accum np.zeros((d, h, w), dtypenp.float32) weight_accum np.zeros((d, h, w), dtypenp.float32) for z in range(0, d - patch_size[0] 1, stride[0]): for y in range(0, h - patch_size[1] 1, stride[1]): for x in range(0, w - patch_size[2] 1, stride[2]): patch volume[z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] patch_tensor torch.from_numpy(patch).float().unsqueeze(0).unsqueeze(0).to(device) with torch.no_grad(): pred model(patch_tensor).sigmoid().cpu().numpy()[0, 0] pred_accum[z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] pred weight_accum[z:z patch_size[0], y:y patch_size[1], x:x patch_size[2]] 1.0 return pred_accum / (weight_accum 1e-6)这段代码的核心是两个accum数组pred_accum累加预测值weight_accum累加每个位置被预测的次数最后相除得到平均。重叠区域用平均而不是丢弃是为了避免patch边界出现网格状伪影。我一般会把重叠率设在50%左右即stride设为patch size一半显存紧张时再降到25%。patch合并后要写回原始坐标空间。crop_merge_fromNII_one.py的设计思路就是先记录推理时用的origin和spacing合并完把结果映射回原图坐标系保存成NIfTI或NRRD。5.3 评估与可视化Dice指标怎么算才不会被骗evaluate_pred_mask.py计算分割指标核心是Dice和IOU。def dice_score(pred_mask, gt_mask, smooth1e-6): pred_mask和gt_mask都是二值numpy数组 pred_bin (pred_mask 0.5).astype(bool) gt_bin gt_mask.astype(bool) intersection (pred_bin gt_bin).sum() return (2.0 * intersection smooth) / (pred_bin.sum() gt_bin.sum() smooth) def iou_score(pred_mask, gt_mask, smooth1e-6): pred_bin (pred_mask 0.5).astype(bool) gt_bin gt_mask.astype(bool) intersection (pred_bin gt_bin).sum() union (pred_bin | gt_bin).sum() return (intersection smooth) / (union smooth)这里有个小细节评估的是patch级指标还是全图级指标。如果只在bbox里的patch上评估Dice天然会偏高要在全图范围评估背景占比一大Dice又会虚低。evaluate_pred_mask.py里建议两种都算patch级Dice用于调参全图级Dice用于汇报。showRes_fromNrrd.py负责把预测mask叠加在原始CT切片上可视化这一步尤其重要Dice高不代表结果对只有肉眼看切片确认位置准确才算数。6. 进阶用法把LUNA16实验换到自己的CT分割任务时改哪里这套代码的价值在复现更在迁移。把自己手里的CT数据换成LUNA16需要动的地方并没有想象中多但每一处都不能漏。第一是config.py和path.py的路径参数。这两个文件存了所有输入输出路径换成自己的数据时把影像目录、标注CSV路径、日志和模型保存路径一次性改对就能少踩一半坑。第二是global_annos.py里的标签读取逻辑。原来的字段是seriesuidcoordX/Y/Zdiameter_mm换成自己的数据时如果标注来源是放疗靶区或医疗PACS导出字段名和坐标系都要逐个确认。第三是datasets.py里的patch size和采样策略。LUNA16训练的64³ patch不一定适合所有任务如果目标器官很大比如肝脏patch_size得上调到128或192同时显存和batch_size要重新平衡。多类别怎么扩datasets_multCls.py和train_main_multCls.py已经给了参考答案。二分类时mask是0/1多分类时mask变成0/1/2/3的整数索引模型输出通道变成类别数损失函数换成带Dice的多类别版本输出层激活换成softmax。改动的关键点是采样阶段确保每个patch里尽可能包含所有类别否则少数类别完全学不到。迁移到自己的数据后我最怕的是漏掉后处理。项目里postProcess目录下的连通域过滤、crop_merge逻辑直接决定最终交付的mask能不能用。我的习惯是每次拿到一批新CT数据先跑一遍step1到step3把重采样结果和bbox可视化出来逐层翻一遍再启动训练。这个习惯帮我在训练前就拦掉了至少三次坐标错位。希望帮到你。本文还有配套的精品资源点击获取