Python肝脏CT图像分割与三维重建:开源源码模型实战全解析
简介一份基于Python的肝脏CT图像分割及三维重建项目源码及模型面向医学影像处理方向的毕业设计、课程设计及期末大作业适合具备一定Python基础、希望深入图像分割与三维可视化技术的学生和从业者。压缩包共124个文件以Python脚本、PNG图像、模型归档tar、TXT/MD说明文档为主辅以pyc、XML、GIF、VTK等类型覆盖数据预处理、模型训练、分割推理与三维重建展示链路整体大小约105.2MB。资源内置可直接运行的源码与模型功能经本地验证答辩评审平均分达97.5分内容包含训练日志、演示动画、说明文档和示例切片图像便于快速复现实验并对照理解关键流程。目前已累计180人学习下载项目结构清晰、注释完整既适合入门进阶也利于在此基础上二次开发是医学图像处理类项目的高质量参考资料。1. 基于python的肝脏CT图像分割及三维重建源码模型开源包里到底有什么值得打开吗在医学图像处理里肝脏CT图像分割和三维重建经常被当成同一个问题实际上它们是两套独立工程前者解决“哪些体素是肝”后者解决“这些体素拼起来像不像肝”。标题这套基于python的肝脏CT图像分割及三维重建源码模型理想情况是一次给足两段——已经训练好的分割权重、能直接跑的推理脚本、以及把掩膜转成STL网格的重建脚本。它适合三类人毕业设计需要完整pipeline的研究生想评估肝脏体积、肝段占比的算法工程师被临床同事追着要三维模型的医技科室人员。先说结论这套东西大概率能跑通但真正让你花时间的不是模型推理而是数据预处理和坐标对齐区间的处理。2. 跑通最小推理链路Python环境、目录结构、一条inference命令2.1 解压后先做的三件事认目录、看权重后缀、统一数据格式拿到压缩包第一件事不是装环境而是把目录结构摸清楚。这类项目常见的目录组织方式如下liver_ct_seg/ ├── checkpoints/ │ ├── liver_seg_99.pth │ └── liver_seg_99.onnx ├── data/ │ ├── case001.nii │ └── label001.nii ├── src/ │ ├── models/ │ │ └── unet3d.py │ ├── preprocess.py │ ├── inference.py │ └── rebuild3d.py ├── requirements.txt └── README.md你可以先用系统自带的tree命令在终端里看一遍unzip liver_seg_rebuild.zip -d liver_ct_seg cd liver_ct_seg tree -L 2这一步能帮你确认两件事。第一权重文件是.pth/.pt还是.onnx这直接决定你要不要复刻网络结构。PyTorch 权重必须配合src/models/下的类定义才能加载如果包里给了 ONNX 版本优先用 ONNX因为它不依赖训练时的 Python 类只认输入输出张量跨版本环境也不容易翻车。第二看requirements.txt里有没有pydicom、nibabel、simpleitk、vtk、scikit-image这些医学影像和网格处理库缺哪个后面补哪个。数据格式方面肝脏CT数据常见两种格式NIfTI.nii/.nii.gz和 MetaImage.mhd.raw。NIfTI 文件自带affine仿射矩阵记录体素坐标到世界坐标的映射关系MHD 的几何信息写在.mhd头的Offset、ElementSpacing字段里。我一般会统一用 NIfTI 做标准输入因为后面三维重建要拿affine做坐标还原比手动解析 MHD 头省事得多。环境配置上用 conda 新建一个独立环境是成本最低的做法conda create -n liver_ct python3.10 -y conda activate liver_ct # CUDA 11.8 对应 cu118先看 nvidia-smi 确认驱动版本再装 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install nibabel pydicom simpleitk scikit-image vtk natsort这里的逻辑是PyTorch 的 CUDA 版本必须和驱动支持的最高版本匹配而不是越新越好。如果机器只有 CPU把torch那行换成pip install torch torchvision推理会慢一些但结果不变。医学图像库nibabel负责 NIfTI 读写pydicom处理 DICOM 序列vtk是后面做三维重建平滑和导出的主力scikit-image则提供marching_cubes等值面提取函数。2.2 最小推理把 case001.nii 变成 case001_mask.nii 的 Python 脚本环境就绪后跑通一个最小推理脚本是验证整套源码和权重是否匹配的最快路径。下面这段脚本几乎可以套用到大多数同类项目上import nibabel as nib import numpy as np import torch from src.models.unet3d import UNet3D # ---------- 1. 读取CT序列 ---------- img nib.load(data/case001.nii) data img.get_fdata().astype(np.float32) affine img.affine # 三维重建时要用它还原世界坐标 # ---------- 2. HU截断与归一化 ---------- # 肝脏CT常用窗宽窗位约在[-200, 250]HU区间 lower, upper -200.0, 250.0 data np.clip(data, lower, upper) data (data - lower) / (upper - lower) # 线性缩放到[0,1] # ---------- 3. 构造5D输入张量 ---------- # PyTorch 3D卷积输入格式为 B C D H W input_tensor torch.from_numpy(data).unsqueeze(0).unsqueeze(0).float() # ---------- 4. 加载权重并推理 ---------- device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet3D(in_channels1, out_channels1, base_channels16) model.load_state_dict(torch.load(checkpoints/liver_seg_99.pth, map_locationdevice)) model.to(device).eval() with torch.no_grad(): logits model(input_tensor.to(device)) prob torch.sigmoid(logits).cpu().numpy() # ---------- 5. 阈值化并保存 ---------- mask (prob 0.5).astype(np.uint8) # 沿用原图affine输出保证mask与CT在同一个坐标系 out nib.Nifti1Image(mask, affineaffine) nib.save(out, data/case001_mask.nii) print(fmask shape: {mask.shape}, voxel values: 0/1)这段脚本里有几个地方值得解释。读取部分用get_fdata()拿到的数据顺序是(D, H, W)对应 NIfTI 的 i、j、k 轴不要擅自转置否则后面重建出的肝脏方向是乱的。HU 截断范围我写的是[-200, 250]这个区间覆盖了肝脏实质通常 40~70 HU、血管和低密度病灶如果你手头数据增强过或做过对比度调整需要重新统计直方图再定范围。阈值取 0.5 是二分类默认值但实际网络输出的概率分布往往偏保守分割掩膜的边缘容易偏细后面可以考虑把阈值改成 0.3~0.4 再对比一次。还有一个很多人忽略的点torch.load在 PyTorch 2.0 之后默认weights_onlyTrue老项目权重如果包含自定义类对象会报WeightsUnpickler错误。遇到这种情况把加载行改成torch.load(path, map_locationdevice, weights_onlyFalse)这是源码兼容性问题不是模型坏了。2.3 推理输出全黑或全白先别怀疑模型三个自检位置跑完最小脚本后最常见的情况是掩膜全黑所有像素都是0或者全白都是1。这时候先别急着怪权重按下面三个位置检查基本能定位问题。第一数据出现 NaN 或 Inf。CT 图像如果某层扫描范围没盖住病人边缘会有 -1024 HU 背景直接归一化没问题但如果原始数据里混入了 NaN即使只有一个体素经过卷积也会扩散到全图。检查方法是用np.isnan(data).sum()统计一下有 NaN 就把这一层用中值填充。第二通道数不匹配。很多旧代码的模型输入是三通道用的是“当前切片 上一张 下一张”堆叠成 3 个通道来捕捉层间信息。如果你直接把单张切片塞进去模型预测出来的概率会接近随机。解决办法不是改网络而是把D维的相邻切片沿通道维拼接构造B 3 D H W的输入。判断源码到底是哪种输入方式去src/models/里看第一层卷积的in_channels是 1 还是 3。第三数据归一化方向反了。有的项目习惯做减均值除方差有的是 0-1 线性归一化。如果模型训练用的是(x - mean) / std你手动用了(x - min) / (max - min)特征分布对不上输出全黑其实是模型在表达“这不是我见过的输入”。自检方法是取一条训练集数据看一眼它的mean和std按同样的统计量重新归一化。3. 肝脏分割源码怎么读模型选型逻辑与训练阶段三个必调参数3.1 为什么肝脏分割默认用 U-Net/V-Net 而不是 Transformer 结构打开这类项目的源码模型文件里出现的几乎都是 U-Net、V-Net 或其变体这个选型有明确原因。肝脏在 CT 里的边界虽然模糊但它属于“解剖结构相对固定、形态差异主要通过邻近器官挤压体现”的器官U-Net 的编码器-解码器结构加上跳跃连接能把浅层的高分辨率边界特征和深层的语义特征融合起来在标注样本只有几十例的医学场景下远比大模型实用。三维 V-Net 和二维 U-Net 的选择则是图像数据本身的形状决定的。CT 序列的层内分辨率通常是 512×512 或 768×768层间间距往往是 1~5 mm直接把整卷数据当 3D 输入显存消耗会立刻吃满。实际项目里常见做法是把 CT 重采样到各向同性体素比如 1mm×1mm×1mm然后用固定尺寸的 patch 切块训练比如 128×128×64。patch 尺寸是 2 的幂方便下采样z 轴取短一点是为了缓解层间插值带来的信息稀释。如果你手里的源码只支持 2D 切片训练也不必急着换 3D 网络在 2D 模型预测完逐层 mask 之后用形态学闭运算沿 z 轴做个连通域约束效果也能接受。还有一类源码会用双阶段结构第一阶段分割整肝第二阶段在整肝 ROI 内分割肿瘤或血管。这种设计比单阶段直接分三类背景/肝脏/病灶更容易训因为肝脏和病灶的类别不平衡差太多整肝区域内的病灶占比可能只有 2~5%单模型很难兼顾。3.2 三个必调参数损失函数、输入块尺寸、前景背景采样比如果源码附带的模型在你的数据上表现一般优先调整三个参数。第一个是损失函数。医学分割项目里DiceLoss几乎成了标配因为它直接优化目标指标 Dice 系数对类别不平衡不敏感。但纯 DiceLoss 在训练初期梯度不够平稳容易让网络跑到局部最优。我见过多数源码会写DiceLoss CrossEntropyLoss的组合默认权重是 0.5 和 0.5。如果你发现预测的肝脏边缘太毛糙把 Dice 权重提到 0.7CE 降到 0.3边界通常会收紧一圈。第二个是输入块尺寸。patch_size决定网络一次能看到的上下文范围。肝脏占整个腹部的比例不小patch 太小比如 64×64×32会让网络只看到局部纹理把脾脏和肾脏误判为肝脏patch 太大又吃显存。常规折中是在训练时用RandomCrop从肝脏 ROI 周围裁剪 160×160×48 或 128×128×64 大小的块同时让每个 batch 里至少有一半 patch 包含肝脏前景避免整批都是背景。第三个是前景背景采样比。肝脏在腹部 CT 里占 20%~30% 的面积但加上空气背景和腹腔其他组织全局占比可能只有 10%。如果训练时完全随机采样模型会把大量参数花在学习“背景长什么样”上。解决方式是实现一个在线采样器先对训练集的标签做概率图标签内有肝脏的像素采样概率设为 1背景设为 0.1每次裁剪按这个概率图采样。这个改动通常能直接提升 2~3 个点的 Dice。# 在线采样器核心逻辑根据标签密度决定裁剪中心 import numpy as np def sample_crop_center(prob_map, patch_size, rng): 根据前景概率图采样裁剪中心 d, h, w prob_map.shape pd, ph, pw patch_size flat prob_map.flatten() if flat.sum() 1e-6: # 全是背景就随机采样 cd rng.randint(pd // 2, d - pd // 2) ch rng.randint(ph // 2, h - ph // 2) cw rng.randint(pw // 2, w - pw // 2) return cd, ch, cw # 按概率采样一个体素坐标 idx rng.choice(flat.size, pflat / flat.sum()) cd, ch, cw np.unravel_index(idx, prob_map.shape) # 约束中心不越界 cd min(max(cd, pd // 2), d - pd // 2 - 1) ch min(max(ch, ph // 2), h - ph // 2 - 1) cw min(max(cw, pw // 2), w - pw // 2 - 1) return cd, ch, cw这段代码逻辑是把标签下采样成概率图每个体素的取值代表它是肝脏中心的可能性用np.random.choice按这个概率分布采样一个点作为裁剪中心然后约束到边界内。这样每个 batch 里包含肝脏的 patch 比例能稳定维持在 50% 以上不需要手动调num_workers里的随机种子。3.3 从推理回到训练数据划分与 Dice 指标怎么算当你决定用自己的数据微调源码里的权重时最怕的是数据划分方式不对结果训练集指标很好、验证集一团糟。肝脏CT分割项目我建议按“病人级别”切分而不是按“切片级别”切分。原因是同一病人的相邻 CT 切片高度相似如果同一个病人的切片同时出现在训练和验证集里验证指标会虚高模型实际泛化能力远没那么好。划分比例上如果数据量少于 30 例用五折交叉验证多于 30 例按 7:2:1 分训练、验证、测试。注意测试集只允许用一次不要在调参过程中反复碰它。验证阶段的 Dice 计算也有讲究。平时用torchmetrics.Dice或者sigmoid 阈值之后直接算都行但要注意边界惩罚。对肝脏这种边界模糊的器官我一般会在计算 Dice 之前把预测 mask 和标签 mask 分别腐蚀 2 个像素只比较肝脏实质内部的吻合度这个指标叫“骨架 Dice”也行“边界放宽 Dice”也行——它能更真实反映肿瘤或血管区域对分割质量的拖累。下面的代码是 Dice 的朴素实现配合scipy.ndimage.binary_erosion就能完成腐蚀操作from scipy.ndimage import binary_erosion def dice_score(pred, label, erosion_iterations0): if erosion_iterations 0: pred binary_erosion(pred, iterationserosion_iterations) label binary_erosion(label, iterationserosion_iterations) inter (pred label).sum() total pred.sum() label.sum() return (2.0 * inter) / (total 1e-6)4. 三维重建源码拆解marching cubes、网格平滑、坐标对齐一个都不能少4.1 最小重建用 scikit-image 把 0/1 掩膜变成三角网格拿到分割 mask 之后进入三维重建环节。这类源码里最核心的算法是 marching cubes移动立方体它的作用是把体素化的 0/1 数据转换成三角形网格。scikit-image 提供了measure.marching_cubes函数用起来只占几行代码但参数细节决定了网格质量。import numpy as np from skimage import measure mask nib.load(data/case001_mask.nii).get_fdata() # mask 是 0/1 组成的体素块1 代表肝脏 # level0.5 表示在体素值跨过0.5的地方生成等值面 verts, faces, normals, values measure.marching_cubes( mask, # 输入体素数据 level0.5, # 等值面阈值 spacing(1.0, 1.0, 1.0), # 体素间距单位mm gradient_directionascent, methodlewiner, # 推荐用lewiner比lorensen更稳定 ) print(fvertices: {verts.shape}, faces: {faces.shape})这段代码里spacing是最容易出错的参数。如果你直接把(1.0, 1.0, 1.0)写进去而原始数据体素间距是(5.0, 0.7, 0.7)重建出来的肝脏会在 z 方向被拉长五倍。正确做法是从 NIfTI 头文件里读zooms字段img.header.get_zooms()返回一个(d, h, w)的体素尺寸元组。注意 NIfTI 默认轴序是 i/j/k和数组 shape 的(D, H, W)对应别读反。level0.5也是经过斟酌的。mask 二值化后0 到 1 的跳变是突变的marching cubes 在 0.5 处生成面片能最好地保持原体积轮廓。如果你把输入换成网络输出的概率图而不是硬阈值 masklevel建议设在 0.3~0.5 之间低阈值会得到更大但更粗糙的肝脏高阈值会得到更保守更小的肝脏这部分属于后处理调参没有绝对标准。4.2 网格平滑与抽稀先做拉普拉斯平滑还是先减面顺序不能反直接 marching cubes 出来的网格有两个问题一是表面呈现明显“阶梯状”因为体素分辨率有限二是三角面片数量特别多一个 512×512×300 的肝脏 mask 很容易生成上百万个三角面片放进任何三维软件都会卡。源码里处理这两步的顺序非常关键先平滑再减面是常见做法也有人先减面再平滑我自己的经验是先做一次轻量平滑再做减面最后再做一次平滑质量最稳。原因是直接对原始稠密网格减面减面算法会保留血管和胆管等小结构的细节但这些细节在医学打印场景其实不需要先平滑一遍把高频噪声去掉减面时能保留更光滑的大曲面。VTK 提供了完整的平滑和减面管线import vtk def smooth_decimate(poly_data, target_reduction0.9): # ---------- 拉普拉斯平滑 ---------- smoother vtk.vtkSmoothPolyDataFilter() smoother.SetInputData(poly_data) smoother.SetNumberOfIterations(30) # 迭代次数 smoother.SetRelaxationFactor(0.1) # 松弛系数太大容易塌陷 smoother.FeatureEdgeSmoothingOff() # 关闭则保留边缘细节 smoother.BoundarySmoothingOff() # 边界不平滑保持轮廓 smoother.Update() # ---------- 减面 ---------- decimate vtk.vtkDecimatePro() decimate.SetInputConnection(smoother.GetOutputPort()) decimate.SetTargetReduction(target_reduction) # 0.9表示删掉90%面片 decimate.PreserveTopologyOn() # 保持拓扑结构避免破洞 decimate.Update() # ---------- 第二次平滑细节恢复 ---------- smoother2 vtk.vtkSmoothPolyDataFilter() smoother2.SetInputConnection(decimate.GetOutputPort()) smoother2.SetNumberOfIterations(10) smoother2.SetRelaxationFactor(0.05) smoother2.Update() return smoother2.GetOutput()两个最容易翻车的参数SetTargetReduction减面比例和SetRelaxationFactor松弛系数。target_reduction设到 0.95 以上时肝脏表面的小血管突起会被抹平成大弧面如果目标只是给术前讨论用可以接受但要用来做肝段划分就太大了建议控制在 0.85~0.9。松弛系数超过 0.5 会导致表面像融化的蜡烛一样塌陷尤其是肝脏边缘薄的地方直接消失。前面说的“先平滑再减面”顺序就是因为平滑能压低噪声让减面算法更专注在大尺度形态上。4.3 STL 导出与坐标还原方向矩阵、原点、体素间距三件套网格生成完最后一步是导出 STL 或 OBJ 文件。这里面最大的坑不是导出函数本身而是坐标变换。很多人把 mask 的体素坐标直接当世界坐标导出结果模型拿到 3D 打印软件里发现是歪的或者左右肝叶反了。正确做法是用原始 NIfTI 的affine矩阵把体素坐标投影到世界坐标。NIfTI 的 affine 是一个 4×4 矩阵包含原点、方向余弦和体素间距它把(i, j, k, 1)映射到(x, y, z, 1)。marching cubes 输出的是(i, j, k)索引坐标需要先用体素间距把它变成物理毫米再用 affine 旋转方向。VTK 里处理这个最简单的方式是给网格整体施加一个刚体变换def apply_affine_to_mesh(poly_data, affine): transform vtk.vtkTransform() # affine 的 3x3 旋转部分 第四列的平移部分 matrix vtk.vtkMatrix4x4() for i in range(4): for j in range(4): matrix.SetElement(i, j, affine[i][j]) transform.SetMatrix(matrix) transform_filter vtk.vtkTransformPolyDataFilter() transform_filter.SetInputData(poly_data) transform_filter.SetTransform(transform) transform_filter.Update() return transform_filter.GetOutput()导出 STL 时用vtkSTLWriter设置SetFileName后直接写。写完以后在 MeshLab 或 Paraview 里打开先用“Box”工具量一下肝脏最大径看看是否和影像报告里的肝脏尺寸接近。如果 CT 报告写肝脏上下径约 15 cm你导出的模型测出来 75 cm那大概率是体素间距没有被 affine 乘进去——三维重建最典型的黑匣子问题。5. 肝脏CT分割与三维重建的5个踩坑记录现象、原因和解决步骤5.1 分割结果把脾脏和胃壁一起包进肝脏现象推理后的 mask 在腹部的左上区域连成一大片脾脏和胃壁被标成肝脏。原因这类训练样本不足的项目里脾脏密度和肝脏实质在 CT 值上的分布很接近如果训练数据里缺少脾脏紧贴肝脏的样本模型会把“左上腹实性脏器”当成肝脏的语义特征。另一个推手是窗宽窗位截断后归一化时把脾脏和肝脏的差异进一步拉平。解决先给预测结果加连通域过滤。肝脏是腹部最大且连通的实性器官保留体素最多的那个 3D 连通域删掉其他零散小区域。用scipy.ndimage.label即可from scipy import ndimage labels, num ndimage.label(mask) sizes ndimage.sum(mask, labels, range(1, num 1)) keep labels (np.argmax(sizes) 1) mask_filtered np.where(keep, 1, 0)如果过滤后脾脏仍然和肝脏粘连在同一个连通域说明两者没有间隙需要改训练策略。建议在预处理时对标注数据做“肝脏边界膨胀 周边器官标签”的双通道输出让网络学习边界约束。5.2 窗宽窗位写死成脑部参数肝脏边缘被截断现象分割出来的肝脏体积明显偏小肝左叶外侧段缺了一大块右叶下缘也内缩。原因推理脚本里np.clip(data, -20, 100)用的窗宽窗位是脑组织的肝脏在 40~70 HU高密度血管和钙化灶到了 200 HU一旦把上界截到 100肝脏内部对比度严重不足边缘细节丢失。解决重新统计数据集的 CT 值直方图。针对肝脏用-200 ~ 250 HU是通用安全范围但更严谨的做法是画出 20 例数据的直方图找肝脏区域的主峰和尾部截止点。另外读 DICOM 时要注意RescaleIntercept和RescaleSlope部分设备存储的是原始像素值需要hu pixel * slope intercept才能换算成真实的 HU 值。脚本里漏掉这一步所有截断和归一化都建立在错的数值坐标系上。5.3 层厚与层间距不匹配重建表面出现台阶状伪影现象三维模型表面沿某一方向出现一层一层的“台阶”尤其在肝脏下缘和右叶边缘像地层剖面。原因CT 序列的层间距如果远大于层内像素间距比如像素间距 0.7mm但层厚 3mm体素就是高度各向异性的。marching cubes 的球形结构在这种数据上会沿低分辨率轴生成锯齿。解决在分割之前先做重采样用SimpleITK把整个序列重采样到近似各向同性例如 1mm×1mm×1mmimport SimpleITK as sitk def resample_to_isotropic(image, target_spacing(1.0, 1.0, 1.0)): original_spacing image.GetSpacing() original_size image.GetSize() target_spacing list(target_spacing) target_size [ int(round(original_size[0] * original_spacing[0] / target_spacing[0])), int(round(original_size[1] * original_spacing[1] / target_spacing[1])), int(round(original_size[2] * original_spacing[2] / target_spacing[2])), ] resampler sitk.ResampleImageFilter() resampler.SetOutputSpacing(target_spacing) resampler.SetSize(target_size) resampler.SetInterpolator(sitk.sitkLinear) resampler.SetOutputDirection(image.GetDirection()) resampler.SetOutputOrigin(image.GetOrigin()) return resampler.Execute(image)重采样介入的时机要在分割之前而不是在得到 mask 之后。对 mask 做最近邻插值没错但对原始 CT 做线性插值重采样能平滑层间的阶梯效应让分割网络看到更连续的组织边界。5.4 导出 STL 是镜像的左右肝叶对调现象在患者 CT 上肝脏右叶在屏幕左侧放射科视角导出的 STL 里右叶变到右侧左右颠倒。原因NIfTI 的 affine 方向矩阵里通常包含负的行列式值说明数据映射到世界坐标时经过了镜像操作。如果重建脚本没读 affine直接把 mask 数组的轴序当成物理空间得到的就是镜像模型。DICOM 转 NIfTI 的工具不同方向处理方式也不同这也是为什么不同来源的数据跑同一套重建脚本结果方向不同。解决导出前检查 affine 的旋转部分affine[:3, :3]的行列式如果行列式为负说明坐标轴方向是镜像的。最直接的解决办法是导出时把模型的i轴反向翻转verts[:, 0] -verts[:, 0]然后重新检查左右肝叶位置。更稳妥的办法是保存模型的时候同时输出一个坐标说明文件明确 X 轴对应患者身体的哪一侧打印或阅图时人工确认。5.5 推理阶段显存不足不换卡怎么跑完整卷现象在 2080 Ti 或 3060 这种 8~12 GB 显存的卡上整卷 512×512×300 喂进去直接CUDA out of memory。原因3D 模型显存消耗随输入体积线性增长300 层切片整体输入是大部分小型卡无法承受的。解决方案是把 3D 推理改成滑窗叠加推理。把整卷切成多个有重叠的块比如 128×128×64重叠 32 个体素分块预测后把概率图拼接回去。拼接时重叠区取每个模型预测概率的平均值而不是简单二值化后取交集否则边界会有明显的接缝。def sliding_window_infer(model, volume, patch_size(128, 128, 64), overlap32, devicecuda): stride tuple(s - o for s, o in zip(patch_size, [overlap]*3)) d, h, w volume.shape result np.zeros((d, h, w), dtypenp.float32) count np.zeros((d, h, w), dtypenp.float32) model.eval() for zd in range(0, d, stride[0]): for yh in range(0, h, stride[1]): for xw in range(0, w, stride[2]): zd_e min(zd patch_size[0], d) yh_e min(yh patch_size[1], h) xw_e min(xw patch_size[2], w) patch volume[zd:zd_e, yh:yh_e, xw:xw_e] # 边界补零 if patch.shape[0] patch_size[0] or patch.shape[1] patch_size[1] or patch.shape[2] patch_size[2]: tmp np.zeros(patch_size, dtypenp.float32) tmp[:patch.shape[0], :patch.shape[1], :patch.shape[2]] patch patch tmp inp torch.from_numpy(patch).unsqueeze(0).unsqueeze(0).float().to(device) with torch.no_grad(): prob torch.sigmoid(model(inp)).cpu().numpy()[0, 0] # 取实际有效区域 prob prob[:min(patch_size[0], d-zd), :min(patch_size[1], h-yh), :min(patch_size[2], w-xw)] result[zd:zd_e, yh:yh_e, xw:xw_e] prob count[zd:zd_e, yh:yh_e, xw:xw_e] 1.0 return result / np.maximum(count, 1e-6)滑窗的overlap参数是平衡速度和质量的关键。重叠 0 块拼接处容易出现条带重叠 32 个体素基本能消除接缝但推理时间会增加到原来的 3~4 倍。低显存环境下把输入重采样到 384×384 再滑窗是副作用最小的方案。6. 重建结果怎么验收体积计算、切片对照、网格自交检查重建完成不等于交付完成至少要过三关验证。第一个验证是体积一致性在原始 CT 上肝脏体积可以用 mask 体素数乘以体素体积直接算标准做法是把 mask 和原始 CT 放在同一个体素坐标系下统计肝脏体素数再乘以三轴 spacing 之积voxel_volume np.prod(zooms) # zooms来自img.header.get_zooms() liver_volume_ml mask.sum() * voxel_volume / 1000.0 # mm3转ml正常成人肝脏体积在 1100~1500 ml 之间如果你算出 3000 ml 或者 300 ml先回查 affine 是不是重复乘了体素间距。重建出的 mesh 在 MeshLab 里用Measure - Volume重新算一次结果和体素法差 5% 以内视为合格超过 10% 说明减面或平滑把体积改太多了。第二个验证是切片对照。在原始 CT 轴位图上抽取横断面、冠状位和矢状位三张切片把分割 mask 以半透明红色叠加到 CT 上重点观察肝门静脉分叉平面和肝右叶下缘。切片对照能发现方位错误、边界侵蚀和漏斗状凹陷。这一步不要省我见过很多次三维模型看着光滑漂亮但横断面上一看胆囊窝被分割丢了。第三个验证是网格自交检查尤其当你把模型用于 3D 打印或有限元仿真时。VTK 里有vtkTriangleFilter配合vtkCleanPolyData可以剔除重复点而自相交面片需要专门的检查工具MeshLab 的Filters - Cleaning and Repairing - Check self-intersections是常用操作。通常自交多发生在肝门区血管密集的位置解决方法是局部平滑或重新阈值分割。最后一件事是把验证结果固化成一条 shell 命令或者脚本每次推理完自动输出体积、Dice、边界检查三项报告这样所有成员验收的标准是同一个而不是“肉眼看差不多”。我自己在项目收尾阶段会固定做一遍滑窗推理 各向同性重采样 连通域过滤三步流程宁可慢十分钟也不接受带着伪影的模型进交付。每台机器的 CUDA 版本、PyTorch 编译版本和模型权重序列化格式都有细微差异所以任何一步翻车都要回到那一层去检查输入数据本身而不是急着改代码。希望帮到你。本文还有配套的精品资源点击获取