
简介基于Python的肝脏CT图像分割与三维重建项目面向医学影像处理、计算机视觉方向的在校生与开发者适用于毕业设计、课程设计及期末大作业。压缩包共124个文件约105.2MB以42个Python脚本为核心覆盖图像预处理、模型训练与三维重建全流程另含42张结果示意图、15个编译后的pyc文件、5个模型数据tar包、VTK三维模型文件及说明文档便于对照阅读与复现。资源已通过本地上线运行、功能测试答辩平均分达97.5分当前已有180人学习下载。从入门调参到替换模型、扩展解剖结构可视化均可基于源码二次开发是兼具学习与实战价值的医学图像入门项目。1. 基于Python的肝脏CT分割与三维重建跑通一个源码包之前先看清整条链路基于Python的肝脏CT图像分割及三维重建听起来是“源码模型”两个词就说完的事真跑起来是一条完整的医学影像流水线DICOM读入、肝脏分割、三维网格生成。我见过不少人拿到压缩包第一反应是找main.py直接跑结果不是缺依赖就是权重路径不对或者分割出的“肝脏”把脾脏也包了进去——坑不在算法在工程链路。它解决的问题很具体把CT序列里肝脏的每一层切出来再把掩膜堆成可旋转、可测体积、可导出OBJ/STL的三维模型。适合刚入门Python的医学影像从业者、影像组学研究生以及想验证分割效果的技术人员。我的建议是别把它当黑匣子跑通就算完。把DICOM读取、预处理、分割、重建、评估拆开每段单独验证参数才不会变成玄学。下文按这条链路讲。2. 把链路拆开选型HU值窗口、U-Net与marching cubes的取舍动手写代码之前先回答三个问题肝脏在CT里靠什么和周围组织区分分割用什么网络掩膜用什么算法变成网格这三个答案决定了后面的脚本结构和绝大多数参数。我见过太多人跳过这一步最后在“为什么别人能跑通我跑不通”里反复折腾。2.1 肝脏在CT里长什么样HU值、窗宽窗位和边界模糊的根源CT图像里每个像素存的不是灰度而是亨氏单位HU它以水的吸收系数为0、空气为-1000。肝脏平扫大约在40到60HU增强门脉期因为造影剂充盈肝实质普遍升到100到130HU而脾脏、胰腺、右肾皮质和它的范围高度重叠——这是肝脏分割难的根本原因不是对比度不够是重叠太多边界区域的灰度差异很小。放射科医生阅片时靠“窗宽窗位”来压制不关心的组织。腹部平扫常用窗宽400、窗位40显示范围从-160到240专门看肝脏时会收窄到窗宽150、窗位30突出肝实质和病灶的差别。深度学习模型不需要人眼那样的窗宽窗位但输入仍然要做裁剪和归一化常见做法是把HU裁剪到[-100, 200]再做线性归一化。裁剪范围不是随便给的下限要去掉空气和大部分脂肪上限要保留增强后的肝实质和骨骼对比。这个边界模糊还有解剖层面的原因肝左叶贴近心脏膈肌运动带来呼吸伪影肝与脾、胃壁之间的薄膜在CT上经常显示不出来分割网络很容易把边界“涨”出去。门脉期注射造影剂后约60到70秒扫描是肝分割的首选相位这个时期肝实质强化均匀与脾脏的差异相对稳定。源码包里如果注明“需要门脉期序列”你要先确认自己的数据是增强还是平扫拿平扫喂给门脉期训出来的权重分割质量会明显下降。组织典型HU范围门脉期对分割的影响空气/肺-1000 ~ -500预处理中应被clip掉脂肪-100 ~ -50与肝脏边界清晰常用作负样本脾脏90 ~ 130与肝灰度重叠是假阳性主要来源肝脏100 ~ 130目标区域骨骼300裁剪后成为最高值不属于分割目标提示预处理里对HU的裁剪范围必须和训练脚本保持一致。这是模型输出全零之外最常见的一个坑。2.2 分割模型选型为什么U-Net是默认答案nnU-Net什么时候更值得医学影像分割的默认答案就是U-Net几乎不需要争论。它由编码器和解码器组成编码器不断下采样把全局上下文压缩成特征解码器逐步上采样恢复分辨率中间的skip connection把浅层的边缘纹理直接拼到深层特征上让弱边界的小目标恢复得更好。肝脏在CT里边界弱、形状随呼吸变化U-Net的这三点正好对症。选2D还是3D看体数据的各向异性。如果原始CT是层厚5mm、层内分辨率0.7mmz轴的间距远大于x/y直接用3D卷积会浪费大量显存去学没什么信息的片间关系这种数据用2D U-Net逐层分割再把2D结果堆成3D掩膜是常见做法。如果层厚已经是1mm的各向同性数据3D U-Net的片间连贯性优势才值得用显存去换。损失函数上肝脏分割常用Dice loss加BCE的组合Dice loss直接优化预测与金标准的重叠率对前景占比小的切片更公平BCE保证预测极差时梯度不会消失。拿到别人训好的权重时先看两处配置输入尺寸和归一化方式。输入是全图resize还是patch裁剪归一化是min-max还是mean/std这两处和预处理脚本对不上后面全是黑匣子一样的玄学问题。nnU-Net是当你有多余算力时的升级项它会自动配置预处理、网络拓扑、patch大小和训练策略在公开的LiTS肝脏数据集上经常刷到接近人类标注的水平。但对于一个“源码模型”压缩包U-Net已经足以跑通并交付初版结果。原则是先跑通基准再讨论换网络而不是第一天就在两个框架之间摇摆。2.3 三维重建选型marching cubes与VTK管线为什么3D高斯重建不适合CT分割输出是0/1的体素掩膜把它变成三角网格经典算法是marching cubes。思路很朴素把每个体素当作一个小立方体8个顶点各自判断在等值面内侧还是外侧256种顶点状态归纳成15种基本拓扑再用插值求出三角形的顶点位置。对二值掩膜等值面level取0.5就是恰好穿过体素中心的那个表面。最近热门的3D高斯三维重建和NeRF本质是从多视角照片恢复连续表面需要RGB图像和相机位姿适合拍物体、拍场景。CT本身就是三维体数据不存在“多视角拍摄”问题直接对体素跑marching cubes又快又准。流行词要放在合适的场景里医学体数据重建别去赶这个时髦。落地工具通常两条路。轻量路线是scikit-image的marching_cubes加trimesh适合在notebook里验证和快速出图交付级路线是VTK的完整管线vtkMarchingCubes提取表面vtkWindowedSincPolyDataFilter平滑vtkDecimatePro减面最后重算法线导出OBJ或STL。平滑和减面的顺序不能反先平滑后减面能保住大体形态反过来会让边缘锯齿被保留下来。选型定完下一章就落到第一个分割掩膜上。3. 从Python安装到第一个分割掩膜最小可复现流程3.1 Python安装与环境依赖先锁版本再谈跑通Python安装这一步本身不难难的是版本组合。这类源码包依赖SimpleITK、VTK、PyTorch和scikit-image它们对Python版本各有要求。我的建议是用Python 3.8到3.10之间太新的版本如3.12、3.13SimpleITK和旧版VTK的二进制wheel不一定跟得上装不上就卡在最前面。先建一个干净的虚拟环境别把依赖装进系统Python这是所有后悔药里的第一颗。# 创建并激活虚拟环境 python -m venv .venv source .venv/bin/activate # Windows 下是 .venv\Scripts\activate # 按顺序安装先医学影像与数值计算再深度学习最后几何处理 pip install SimpleITK2.2.1 numpy1.24.3 scipy1.10.1 # torch 的安装命令按你的 CUDA 版本在 pytorch.org 上选对应行 pip install torch2.0.1 pip install scikit-image0.21.0 vtk9.2.6 trimesh4.0.9逻辑说明SimpleITK负责DICOM序列读取和重采样numpy和scipy做数组运算与连通域处理torch加载模型权重scikit-image提供marching_cubes接口vtk做交付级网格处理trimesh负责OBJ导出和体积计算。用虚拟环境隔离后这台机器上的依赖问题不会污染其他项目换机器时也能用requirements.txt快速复现。参数说明torch装CPU版还是GPU版取决于你有没有英伟达显卡没有就装CPU版否则会报找不到CUDA runtime。版本号不要随手升级scikit-image 0.21的marching_cubes接口稳定新版对返回值做过调整trimesh升到4.x后部分平滑接口参数变了。锁版本能省下大量翻车时间尤其当你看的是别人半年前写的代码。3.2 预处理重采样、窗宽窗位与归一化的参数设置预处理是整个流程里最值得花时间的地方。一个5mm层厚的序列直接喂给模型即使网络再好重建出来也是千层饼clip范围不对模型看见的数值分布和训练时完全两样。下面这段脚本是这类源码包里最常见的预处理逻辑也建议你作为自检基准import SimpleITK as sitk import numpy as np def load_and_preprocess(series_path, target_spacing(1.0, 1.0, 1.0), clip_range(-100, 200)): # 1) 读取整个DICOM目录为一个3D体数据 reader sitk.ImageSeriesReader() reader.SetFileNames(reader.GetGDCMSeriesFileNames(series_path)) img reader.Execute() # 2) 重采样到各向同性体素避免z方向拉伸成千层饼 resampler sitk.ResampleImageFilter() resampler.SetReferenceImage(img) resampler.SetOutputSpacing(target_spacing) # (dz, dy, dx) orig_size np.array(img.GetSize()) orig_spacing np.array(img.GetSpacing()) new_size (orig_size * (orig_spacing / np.array(target_spacing))).astype(int) resampler.SetSize(new_size.tolist()) resampler.SetInterpolator(sitk.sitkLinear) img resampler.Execute(img) # 3) 转numpy裁剪HU范围并归一化到[0,1] arr sitk.GetArrayFromImage(img) # 形状 (Z, Y, X) arr np.clip(arr, clip_range[0], clip_range[1]) arr (arr - clip_range[0]) / (clip_range[1] - clip_range[0]) return arr.astype(np.float32), img逻辑说明ImageSeriesReader会自动按切片位置排序把几百张二维CT拼成三维体数据这一步是很多新手卡住的地方——直接读单张PNG是拼不出正确的三维结构的。ResampleImageFilter把层厚5mm的序列重采样成1mm各向同性让后续marching cubes拿到的体素近似立方体。numpy数组形状是(Z,Y,X)z在最外层这个顺序后面和spacing参数严格对应。参数说明target_spacing(1,1,1)是内存和质量的折中0.5mm各向同性会让体素数量变成8倍内存和显存可能吃不住。clip_range(-100,200)覆盖肝实质在门脉期的典型范围同时清掉了空气、脂肪和高密度的骨。如果你的模型权重用的是别的范围比如(-200,300)或mean/std归一化这里必须改——这是分割结果好坏的第一决定因素也是源码包内置脚本里最容易被忽略的行。3.3 模型推理与后处理把网络输出变成干净的肝脏掩膜预处理完成后就是推理。模型输入通常是(B,1,H,W)或(B,1,Z,H,W)输出是一个概率图需要经过sigmoid和阈值才能变成掩膜。大部分源码包里的模型权重是2D U-Net下面这段按逐层推理加三维连通域后处理来写import torch import numpy as np import SimpleITK as sitk def predict_clean_mask(arr, model, devicecuda, threshold0.5): # arr 来自预处理形状 (Z, H, W)值域 [0,1] model.eval().to(device) slices torch.from_numpy(arr[:, None, :, :]).to(device) # (Z,1,H,W) with torch.no_grad(): probs torch.sigmoid(model(slices)).cpu().numpy() # 注意切层很多时建议改成for循环逐层推理避免一次batch太大爆显存 mask (probs[:, 0] threshold).astype(np.uint8) # 三维连通域分析只保留最大目标去掉脾脏、胃壁等假阳性 itk_mask sitk.GetImageFromArray(mask) cc sitk.ConnectedComponent(itk_mask) # 26邻域连通 relabel sitk.RelabelComponent(cc, sortByObjectSizeTrue) largest (relabel 1).astype(np.uint8) # 1号是最大连通域 return largest逻辑说明用sigmoid把网络原始logits变成0到1的概率threshold取0.5是把概率图二值化的默认方式。sitk.ConnectedComponent在三维空间里做26邻域连通分析把每一层单独看时连不上、堆起来却是一个整体的区域合并成同一个目标RelabelComponent按体积排序后最大连通域编号是1那一大片把脾脏包进来的假阳性直接被去掉这是后处理里最有效的一刀。参数说明threshold默认0.5想减少假阳性可以提到0.6到0.7代价是肝边缘的薄层会被切掉一点想保住边缘可以把阈值降到0.4以下但碎片会变多。调阈值的最快方法是用matplotlib把概率图叠在原始CT切面上目测挑中间层和肝顶、肝底各看几层比看统计数字直观得多。如果模型是3D的输入要改成(B,1,Z,H,W)的patch用滑窗推理并做overlap融合显存不够时优先把patch从96降到64。4. 三维重建落地marching cubes参数、VTK管线与OBJ导出4.1 marching cubes的三个关键参数level、平滑迭代与减面比例掩膜跑marching cubes是最直接的一步但skimage和VTK两套路线在参数上的取舍不一样。先用skimage加trimesh讲轻量路线你在验证阶段会反复用到它import numpy as np from skimage import measure import trimesh def mask_to_mesh(mask, spacing(1.0, 1.0, 1.0), level0.5, smooth_iter50, decimate_ratio0.7): # mask 形状 (Z, Y, X)spacing 是 (dz, dy, dx)单位 mm verts, faces, _, _ measure.marching_cubes( mask.astype(np.float32), levellevel, spacingspacing) mesh trimesh.Trimesh(verticesverts, facesfaces) mesh mesh.smoothed(iterationssmooth_iter) # 去体素阶梯 if decimate_ratio 0: target int(len(mesh.faces) * decimate_ratio) # 保留比例 mesh mesh.simplify_quadric_decimation(target) return mesh逻辑说明marching_cubes的spacing必须和体数据的体素尺寸一致z轴用层距y和x用DICOM里的PixelSpacing。如果不传spacing默认按1:1:1处理5mm层厚的数据重建出来就是被压扁的肝脏模型。trimesh的smoothed做的是保形平滑能在降低阶梯感的同时尽量保留肝裂和静脉切面的凹陷。参数说明level对二值掩膜固定取0.5不需要调smooth_iter在30到80之间够用太大会把这些凹陷磨平decimate_ratio是保留比例0.7表示删掉30%的面。很多新手以为面越多越好实际一个肝脏掩膜直接跑marching cubes可能有几百万个三角面预览和配准都卡减到十几万面在视觉上几乎没有损失。要提醒的是不同trimesh版本对smoothed的参数名有细微差异你用的版本如果报TypeError去查一下当前版本的签名别在这上面死磕。注意要算体积必须在减面之前的掩膜上算不要拿减面后的三角网格积分误差会到5%以上。4.2 用VTK渲染并导出OBJ/STL坐标系、spacing和单位别搞错skimage路线适合验证交付给临床或3D打印我一般用VTK因为它有更成熟的平滑和减面算法结果更接近工业级。难点在数据格式转换numpy的轴顺序和VTK不一样先转哪一个、再翻哪一个错了模型就翻转。import vtk import numpy as np def mask_to_vtk(mask_np, spacing_mm, out_fileliver.obj): # mask_np 形状 (Z, Y, X)VTK 的 ImageData 顺序是 (X, Y, Z) data np.transpose(mask_np, (2, 1, 0)).astype(np.uint8) vtk_data vtk.vtkImageData() vtk_data.SetDimensions(*data.shape) # (X, Y, Z) vtk_data.SetSpacing(*spacing_mm) # (dx, dy, dz) importer vtk.vtkImageImport() importer.SetImportVoidPointer(data.tobytes()) importer.SetDataScalarTypeToUnsignedChar() importer.SetNumberOfScalarComponents(1) importer.SetDataExtent(0, data.shape[0]-1, 0, data.shape[1]-1, 0, data.shape[2]-1) importer.Update() mc vtk.vtkMarchingCubes() mc.SetInputData(importer.GetOutput()) mc.SetValue(0, 0.5) # 等值面取0.5 smoother vtk.vtkWindowedSincPolyDataFilter() smoother.SetInputConnection(mc.GetOutputPort()) smoother.SetNumberOfIterations(15) smoother.SetPassBand(0.001) smoother.NormalizeCoordinatesOn() decimate vtk.vtkDecimatePro() decimate.SetInputConnection(smoother.GetOutputPort()) decimate.SetTargetReduction(0.7) # 删掉70%的面 decimate.PreserveTopologyOn() writer vtk.vtkOBJWriter() # 需要STL就换vtkSTLWriter writer.SetFileName(out_file) writer.SetInputConnection(decimate.GetOutputPort()) writer.Write()逻辑说明np.transpose把(Z,Y,X)变成(X,Y,Z)这是numpy和VTK之间最容易出错的一步。vtkImageImport把裸字节交给VTK必须同时声明数据类型、通道数和数据范围否则内存视图错乱出来的模型是噪声。vtkWindowedSincPolyDataFilter是窗口正弦平滑相比普通拉普拉斯平滑不容易让模型越磨越小适合医学表面。vtkDecimatePro在保持拓扑的前提下减面PreserveTopologyOn防止表面出现洞。参数说明SetSpacing的单位是毫米这是后面3D打印和测体积的地基如果把spacing设成1而真实层厚是5mm导出的模型就是扁的。平滑迭代15次和PassBand 0.001是VTK社区里常见的保守组合想更光滑可以把迭代加到30但肝裂等凹沟会变钝。目标减面率0.7对三百万面的模型能压到九十多万面预览足够如果只做网页展示0.85也行但细节最丰富的血管切面会被磨掉。5. 避坑与排查分割翻车与重建变形的5个常见原因这一章写的是我反复筛过的经验。每个问题按“现象→原因→解决”来写你遇到时可以按图索骥。5.1 分割结果把脾脏或胃壁一起包了进来现象模型输出的肝脏掩膜明显偏大左叶方向拖着一大块灰度接近的组织看起来像“肝和脾脏连体”。原因门脉期肝脏和脾脏的HU范围重叠严重预处理没做窗宽窗位裁剪或者裁剪范围跟训练脚本不一致网络就会把脾脏也判为肝脏。后处理如果漏了最大连通域这一步散落的假阳性还会连成片问题更难定位。解决先确认预处理和训练脚本一致再把阈值从0.5提到0.6走一遍连通域最后用形态学开运算先腐蚀后膨胀断开肝脏和脾脏之间的细连接。开运算的kernel用半径2到3个voxel的球太大会把肝边缘也磨掉。5.2 模型输出全零或全一跟输入毫无关系现象无论喂什么CT序列输出的概率图都是一整块黑或者一整块白没有任何解剖结构。原因八成是归一化不匹配。训练时用mean/std归一化你用的是min-max数值域完全不同其次是权重加载失败load_state_dict时某些键名对不上等于权重根本没进来还有一种是模型在train模式下推理dropout还在工作。解决加载权重时打印missing_keys和unexpected_keys确保模型真正吃进了权重。推理前固定三件事model.eval()、torch.no_grad()、输入数组的值域和训练数据一致。建议写一个自检把一张输入图可视化再把概率图叠加到原图上一眼就能看出网络在看什么。5.3 CUDA out of memory12G显存也不够现象3D模型一跑就爆显存batch设成1也爆。原因3D卷积的中间激活量是逐层累积的patch太小没有临床意义patch太大显存顶不住很多人忽略了3D输入的Batch维度同样占显存batch为1时中间特征图照样吃掉几GB。解决patch从96³降到64³或48³滑窗推理加25% overlapoverlap部分取平均而不是直接覆盖还不行就换成2D U-Net逐层推理这是吃显存最少、最稳的退路。不要在每个batch后调torch.cuda.empty_cache()频繁清理会严重拖慢推理速度。5.4 重建出来的模型像千层饼一层一层台阶现象OBJ在Meshlab里打开表面横向沟槽清晰可见像是把切片一张张叠出来的。原因原始数据层厚5mm且没重采样或者marching_cubes没传spacing默认1:1:1抹掉了真实的层距信息。解决回到预处理重采样到1mm各向同性marching_cubes的spacing参数必须与实际体素一致。如果数据已经是1mm各向同性还出台阶把平滑迭代加上去VTK路线用WindowedSinc迭代30次。做完之后在软件里转一圈确认台阶真的消失再进入下一步。5.5 导出的模型被压扁、翻转或上下颠倒现象肝脏模型左右反了或者高宽比例明显不对丢进3D打印切片软件里没法用。原因numpy的(Z,Y,X)和VTK的(X,Y,Z)之间少了一次transposespacing的单位和轴序没对上有的扫描重建时z轴是反向的方向余弦信息没有被沿用。解决导出前把transpose和spacing写清楚spacing元组顺序固定为(dx,dy,dz)并打印一个真实病例的掩膜做方向自检。如果模型左右反了在VTK模型上做一次关于Y轴的镜像变换上下颠倒则翻转Z轴。改完把模型和一张轴位CT切面叠加看一次别只凭数字判断。6. 验证与交付用Dice和体积量化结果交付前检查三件事6.1 Dice系数与体积计算给分割结果一个量化指标分割模型做得对不对不能靠“看起来像不像”要有量化指标。Dice系数是医学影像分割里最常用的重叠指标肝脏分割的常见水平在0.90到0.95之间低于0.85基本可以判定预处理或权重有问题。import numpy as np def dice(pred_mask, gt_mask): inter np.logical_and(pred_mask 0, gt_mask 0).sum() denom (pred_mask 0).sum() (gt_mask 0).sum() return 2.0 * inter / (denom 1e-6) def liver_volume(mask, spacing(1.0, 1.0, 1.0)): voxel_mm3 spacing[0] * spacing[1] * spacing[2] return (mask 0).sum() * voxel_mm3 / 1000.0 # cm³即 ml逻辑说明Dice把预测和标签的交集乘2除以两者面积之和完全重叠是1完全不重叠是0。体积计算直接在掩膜上做用spacing算出单个体素体积再乘体素数单位从mm³换成cm³后就是临床常说的毫升。成人肝脏体积通常在1200到1500毫升超出这个范围太多先怀疑预处理或阈值。参数说明只靠Dice不够它会被大面积重叠掩盖局部漏分割。建议加一个HD95即Hausdorff距离的95分位它专门抓“边界上漏了一坨”这种稀疏大误差。验证集至少要10个病例有金标准标注的优先对比公开的LiTS数据集没有金标准就拿人工修正过的病例当参考。6.2 交付给临床之前的三件检查第一件确认坐标系和单位。OBJ和STL文件的顶点坐标单位是毫米导出时spacing必须写对给3D打印或手术规划软件之前用一个边长已知的体模验证尺度别让医生拿着放大或缩小的模型做规划。第二件切面复核。用matplotlib把分割掩膜叠加到原始CT的轴向、冠状和矢状三个切面上肝静脉汇合处和肝顶是漏分割的高发区重点看这几层。第三件可复现记录。把输入序列ID、预处理参数、模型版本、阈值、平滑次数和减面率写成一个txt和输出文件放在同一个目录。我的习惯是每个病例跑完都写一条这样的记录看起来简陋但临床追问时只有它能救你。曾经有个病例三个月后被要求复查结果我靠当时记下的阈值和spacing一秒钟复现了当时的模型而没有记录的那一版数据最后只能重新跑一遍这种后悔药一个txt加几个数字就够了。如果你正要拿这个源码包做自己的肝分割验证把DICOM读取、预处理、推理、重建、评估这一段链路完整走一遍参数逐个试、结果逐个看比收集多少技巧都管用。希望帮到你。本文还有配套的精品资源点击获取