简介:一份基于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 python=3.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_channels=1, out_channels=1, base_channels=16) model.load_state_dict(torch.load("checkpoints/liver_seg_99.pth", map_location=device)) 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, affine=affine) nib.save(out, "data/case001_mask.nii") print(f"mask 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_only=True,老项目权重如果包含自定义类对象,会报WeightsUnpickler错误。遇到这种情况,把加载行改成torch.load(path, map_location=device, weights_only=False),这是源码兼容性问题,不是模型坏了。
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.7,CE 降到 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, p=flat / 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_iterations=0): if erosion_iterations > 0: pred = binary_erosion(pred, iterations=erosion_iterations) label = binary_erosion(label, iterations=erosion_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 代表肝脏 # level=0.5 表示在体素值跨过0.5的地方生成等值面 verts, faces, normals, values = measure.marching_cubes( mask, # 输入体素数据 level=0.5, # 等值面阈值 spacing=(1.0, 1.0, 1.0), # 体素间距,单位mm gradient_direction='ascent', method='lewiner', # 推荐用lewiner,比lorensen更稳定 ) print(f"vertices: {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)对应,别读反。
level=0.5也是经过斟酌的。mask 二值化后,0 到 1 的跳变是突变的,marching cubes 在 0.5 处生成面片能最好地保持原体积轮廓。如果你把输入换成网络输出的概率图而不是硬阈值 mask,level建议设在 0.3~0.5 之间,低阈值会得到更大但更粗糙的肝脏,高阈值会得到更保守更小的肝脏,这部分属于后处理调参,没有绝对标准。
4.2 网格平滑与抽稀:先做拉普拉斯平滑还是先减面,顺序不能反
直接 marching cubes 出来的网格有两个问题,一是表面呈现明显“阶梯状”,因为体素分辨率有限;二是三角面片数量特别多,一个 512×512×300 的肝脏 mask 很容易生成上百万个三角面片,放进任何三维软件都会卡。
源码里处理这两步的顺序非常关键:先平滑再减面是常见做法,也有人先减面再平滑,我自己的经验是先做一次轻量平滑,再做减面,最后再做一次平滑,质量最稳。原因是直接对原始稠密网格减面,减面算法会保留血管和胆管等小结构的细节,但这些细节在医学打印场景其实不需要;先平滑一遍,把高频噪声去掉,减面时能保留更光滑的大曲面。
VTK 提供了完整的平滑和减面管线:
import vtk def smooth_decimate(poly_data, target_reduction=0.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×1mm:
import 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), overlap=32, device="cuda"): stride = tuple(s - o for s, o in zip(patch_size, [overlap]*3)) d, h, w = volume.shape result = np.zeros((d, h, w), dtype=np.float32) count = np.zeros((d, h, w), dtype=np.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, dtype=np.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 编译版本和模型权重序列化格式都有细微差异,所以任何一步翻车都要回到那一层去检查输入数据本身,而不是急着改代码。希望帮到你。
本文还有配套的精品资源,点击获取