简介:基于Python的肝脏CT图像分割与三维重建项目,面向医学图像处理、计算机视觉等方向的在校生与开发者,适用于毕业设计、课程设计及期末大作业等场景,也适合作为入门进阶和二次开发的基础。资源包含完整源码与预训练模型,共124个文件,以42个Python脚本为核心,配合42张结果图像、模型权重tar包、配置说明txt/xml及Markdown文档等,整体约105.2MB,目录结构清晰,便于按模块阅读与调试。已有180人浏览学习。项目在医学影像分割基础上完成三维重建,覆盖数据预处理、模型训练、分割评估与可视化展示等环节,并包含说明文档和演示图片,可帮助理解肝脏区域提取与立体呈现的技术流程。源码经本地运行测试无误,答辩平均分达97.5分,具有较高的学习借鉴价值,可在此基础上进行二次开发与功能扩展。
1. 基于Python的肝脏CT图像分割及三维重建:织进临床流水线之前先看清三件事
拿到一个「基于Python的肝脏CT图像分割及三维重建源码+模型」的zip压缩包,你大概率是想找一条能直接处理医学CT影像的自动化流水线:喂进去一个DICOM序列,吐出来肝脏掩膜和三维模型。传统方式里,医生在每一层CT切片上手动画肝脏轮廓,一个病例要耗掉半天;这份源码的价值是把人工流程压缩成几分钟的Python推理,直接服务术前规划的肝体积测量、肿瘤定位和切除路径设计。适合算法工程师、影像研究生和医院影像技术团队。但拿到zip先别急着解压运行,先确认三件事:模型是在什么CT数据上训练的、Python和深度学习框架版本是否对口、输出格式能否接上你下游的PACS或手术计划流程。
2. 从CT切片到肝脏掩膜:预处理、网络结构和源码里的数据流
2.1 DICOM读取与HU值归一化:分割前两步最关键
任何一套肝脏CT分割流程,第一步都绕不开DICOM。CT设备落盘的文件不适合直接喂给深度学习模型:像素值没有物理意义,切片顺序依赖文件名且可能乱序,体素间距藏在metadata里。源码包的价值,就是把这些脏活整理成几个可复用的函数,比如load_dicom_series:
import os import pydicom import numpy as np def load_dicom_series(folder): """读取一个DICOM序列文件夹,返回(z, h, w)的HU数组和体素间距。""" slices = [] for name in os.listdir(folder): path = os.path.join(folder, name) try: ds = pydicom.dcmread(path) if hasattr(ds, 'ImagePositionPatient'): slices.append(ds) except pydicom.errors.InvalidDicomError: continue # 按扫描床的物理坐标排序,而不是按文件名 slices.sort(key=lambda s: float(s.ImagePositionPatient[2])) vols = [] for s in slices: slope = float(getattr(s, 'RescaleSlope', 1.0)) intercept = float(getattr(s, 'RescaleIntercept', 0.0)) hu = s.pixel_array.astype(np.float32) * slope + intercept vols.append(hu) vol = np.stack(vols, axis=0) spacing = ( float(slices[0].PixelSpacing[0]), float(slices[0].PixelSpacing[1]), float(slices[0].SliceThickness), ) return vol, spacing这里有两个细节值得抠。ImagePositionPatient[2]是切片在病人坐标系里的z坐标,用它排序能避免“10.dcm排在2.dcm前面”的字符串字典序陷阱;pixel_array拿到的是设备原始像素值,必须乘以RescaleSlope再加上RescaleIntercept,才是有物理单位的HU值。整个函数返回一个numpy三维数组和一个spacing三元组,三维重建和体积计算在后面都要依赖它们。
HU值域里的肝脏大约在40~120,而空气是-1000,骨骼能到几百甚至上千。把肝脏从背景里拉出来,常见做法是设定窗宽窗位做裁剪,把无关的骨和空气压成纯白纯黑,再归一化到网络输入区间:
def hu_to_normalized(volume, low=-200, high=200): """裁剪到软组织窗口并归一化到[0, 1],适配分割网络输入。""" clipped = np.clip(volume, low, high) norm = (clipped - low) / (high - low) return norm.astype(np.float32)注意,如果跳过裁剪直接对整个体数据做min-max归一化,整卷的极值会被骨和空气主导,肝脏的灰度区间被压缩到非常窄的范围,模型看到的是一大片难以区分的灰色。这是预处理环节最常见的翻车点,后面第五章会专门做排查。
2.2 2D还是3D:U-Net选型与显存精度的权衡
源码包里的分割模型,最常见的是2D或3D的U-Net。这个结构在医学分割里的地位很稳:编码器逐层下采样提取多尺度特征,解码器逐步上采样恢复分辨率,跳跃连接把编码器同尺度的细节特征拼回解码器。肝脏边界本身比较模糊,尤其靠近胆囊和脾脏的区域,跳跃连接让网络在输出时能参考浅层的边缘信息,不会只依赖高层语义。
| 网络形态 | 输入方式 | 显存占用 | 边界精度 | 典型场景 |
|---|---|---|---|---|
| 2D U-Net | 逐切片 | 低,4~6GB可用 | 中等,切片间不连续 | 显存有限、数据量小 |
| 3D U-Net | 体素patch | 高 | 好,利用z轴上下文 | 训练数据充足、追求精度 |
| nnU-Net | 自适应 | 很高 | 强,接近通用基线 | 需要标杆结果、有时间 |
源码包预训练权重一般以2D U-Net为主。原因也现实:推理时逐层过一遍,普通游戏显卡就能跑,不需要专业卡。肝脏在相邻切片上的形态变化平缓,2D模型配合切片级后处理,DICE普遍能做到0.90上下,对绝大多数医院实际病例够用。
如果你要上3D U-Net,不要直接把整卷喂进去,显存会瞬间爆掉。常规做法是裁成64×256×256的patch,一次推理一个patch,重叠区域用高斯权重融合消除接缝。这个切换会引入patch边界伪影,处理起来比想象中麻烦,训练时间也成倍增加,没有明确精度收益时我一般不动这个念头。
2.3 数据流拆解:从切片堆到肝脏掩膜的完整链路
把完整流水线拆开看,标准路径就是:DICOM读取 -> HU值裁剪与归一化 -> 网络逐层推理 -> argmax取前景类 -> 取最大连通域 -> 保存掩膜。网络输出的是每个像素属于肝脏和背景的概率,argmax之后是0/1二值图,但这张图里常有一些噪点,比如脾脏边缘、胃壁的一部分被模型误判。
肝脏在解剖上是连续的单个器官,所以常见做法是取最大连通域作为兜底后处理:
from scipy import ndimage def keep_largest_component(mask): """去掉零散误检区域,只保留最大的连通分量。""" labels, num = ndimage.label(mask) if num == 0: return mask sizes = ndimage.sum(mask, labels, range(1, num + 1)) largest = int(np.argmax(sizes)) + 1 return (labels == largest).astype(np.uint8)注意,这个函数对「脾脏和肝脏连成一片」的误检是无效的,因为解剖上脾脏贴近肝脏,前后切片连起来后形态学去不掉。真正的病根是预训练数据和你自己数据之间的灰度分布差异,后面微调章节会讲怎么处理。所以后处理只是兜底,不是根治方案。
拿到源码包后的正确姿势是先把这几个最小单元各测一遍:load_dicom_series输出vol.shape和spacing,确认维度顺序是(z, h, w);keep_largest_component输入两个连通域的示例数组,确认输出保留了大的那个。先跑通最小单元再上完整推理,踩坑成本会低很多。
3. 本地跑通这套Python源码:从环境到DICE评估
3.1 搭建Python环境:版本敏感的就这几个包
解压源码包后,头一件容易劝退的事是装环境。肝脏CT分割的依赖不算复杂,但版本要卡住:Python 3.8/3.9、PyTorch、pydicom、nibabel、scikit-image、scipy。深度学习框架版本不匹配,加载预训练权重时经常直接报Unexpected key或者shape mismatch。
conda create -n liver python=3.9 -y conda activate liver pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install pydicom nibabel scikit-image scipy matplotlibPyTorch那行的index-url取决于本机显卡的CUDA版本,如果拿不准就先跑nvidia-smi看驱动支持的CUDA号。只是验证流程的话,装CPU版也能跑,就是速度慢几倍。pydicom负责DICOM解析,nibabel负责写nii.gz,skimage里marching_cubes做三维表面重建,scipy被连通域后处理用到。后面三维显示和STL导出还需要VTK和trimesh,建议一起装上:
pip install vtk trimesh这套依赖装好之后,源码包里其他脚本基本不用动。项目如果要求更老的Python 3.7,pydicom和skimage的版本要往下兼容,这时优先用conda而不是pip,避免依赖冲突。
3.2 推理主程序与参数:window、model、device逐个说
源码包的推理入口一般是个infer.py或者predict.py,核心逻辑是读DICOM、过模型、存掩膜。参数设计上通常会用argparse暴露几个关键开关,方便换数据和换权重:
import argparse import torch import numpy as np import nibabel as nib from model import UNet2D # 源码包里的网络定义 from preprocess import load_dicom_series, hu_to_normalized from postprocess import keep_largest_component parser = argparse.ArgumentParser(description='肝脏CT分割推理') parser.add_argument('--input', '-i', required=True, help='DICOM序列文件夹路径') parser.add_argument('--output', '-o', default='liver_mask.nii.gz') parser.add_argument('--model', '-m', default='weights/liver_seg.pth') parser.add_argument('--device', default='cuda', choices=['cuda', 'cpu']) parser.add_argument('--window', nargs=2, type=float, default=[-200, 200]) args = parser.parse_args() vol, spacing = load_dicom_series(args.input) norm_vol = hu_to_normalized(vol, tuple(args.window)) net = UNet2D(in_channels=1, num_classes=2) net.load_state_dict(torch.load(args.model, map_location=args.device)) net.to(args.device).eval() mask = np.zeros(vol.shape[:2], dtype=np.uint8) * 0 mask = np.zeros(vol.shape[:3], dtype=np.uint8) with torch.no_grad(): for z in range(norm_vol.shape[0]): x = torch.from_numpy(norm_vol[z]).unsqueeze(0).unsqueeze(0).to(args.device) logits = net(x) # (1, 2, H, W) pred = logits.argmax(dim=1).squeeze(0).cpu().numpy() mask[z] = keep_largest_component(pred.astype(np.uint8)) nib.save(nib.Nifti1Image(mask.astype(np.int16), affine=np.eye(4)), args.output) print(f'分割完成,掩膜保存到 {args.output},spacing={spacing}')这段代码先读DICOM,做HU裁剪归一化,再逐切片过模型。逐切片循环里只保留了一个batch,显存占用很低,4GB显卡也能跑。输出mask的shape和原CT一致,这样后面三维重建时能把每个体素对应回真实坐标。
几个参数值得单独解释。window默认[-200, 200],这是标准腹部软组织窗,平扫和增强CT都适用;如果你的数据以平扫为主,可以把下界提到-100,上界降到150,让肝脏对比度更突出。model参数默认指向weights/liver_seg.pth,换权重时要确认state_dict里的层名和模型定义一致。device选cuda时如果显存不足,程序会直接OOM,后面第五章有专门排查。
提示:输出文件用nibabel写了单位阵affine,掩膜在体素空间是对的,但不含真实病人坐标。如果要把分割结果叠加回原始DICOM或和血管模型配准,需要从DICOM头构造真实的affine矩阵,这一步务必对着源码包的读入函数确认。
3.3 用DICE和切片叠图验收分割结果
推理跑通之后,第一件事不是急着做三维重建,而是验收分割质量。源码包里如果有配套的测试集和标注,直接算DICE就是最客观的指标:
def dice_score(gt_path, pred_path): """计算二值掩膜的DICE系数,输入是nii.gz文件路径。""" gt = nib.load(gt_path).get_fdata() > 0 pred = nib.load(pred_path).get_fdata() > 0 inter = np.logical_and(gt, pred).sum() return 2 * inter / (gt.sum() + pred.sum() + 1e-6)DICE超过0.9说明分割基本可用,0.85到0.9之间需要检查是哪层切片掉的点,通常集中在肝脏顶部和底部。源码包不带标注的话,就做目视对比,把预测轮廓画在CT原图上:
import matplotlib.pyplot as plt ct = vol / vol.max() pred = nib.load('liver_mask.nii.gz').get_fdata() z = pred.shape[0] // 2 plt.figure(figsize=(12, 5)) plt.subplot(1, 2, 1) plt.imshow(ct[z], cmap='gray') plt.subplot(1, 2, 2) plt.imshow(ct[z], cmap='gray') plt.imshow(pred[z] > 0, alpha=0.5, cmap='Reds') plt.show()红色半透明区域和灰度CT叠在一起,能直接看出边界是外扩还是内缩,比单看DICE数字更能定位问题。目视检查时重点关注横隔膜附近、脾脏邻接区、肠道挤压肝下缘这三处,出错率最高。
4. 从掩膜到三维模型:网格导出与体绘制的两条路线
4.1 marching cubes把掩膜变成STL:spacing参数决定模型不干瘪
分割掩膜是一个三维体素数组,要变成能拖进手术规划软件的三维模型,首先要做的就是等值面提取,最常用的算法是marching cubes。skimage里直接实现了,调用很省事:
from skimage import measure import trimesh verts, faces, normals, _ = measure.marching_cubes( mask.astype(bool), level=0.5, spacing=spacing, ) mesh = trimesh.Trimesh( vertices=verts, faces=faces, vertex_normals=normals, ) mesh.export('liver.stl') print(f'顶点数 {len(verts)},三角面片数 {len(faces)}')这个函数看起来简单,最关键的坑是spacing参数。掩膜里每个体素是一个单位立方体,如果不传spacing,提取的表面在z方向会被拉伸,肝脏看起来像被压扁又拉长的畸形体。spacing必须和读DICOM时拿到的Z轴间隔对应,层厚1.5mm的数据就填1.5,层厚5mm的老数据填5,否则模型的长宽比是错的。
STL导出后可以直接用3D Slicer或MeshLab打开。如果表面呈阶梯状,是因为原始CT层厚太大,z方向信息密度不足。解决方法是先对掩膜做三线性插值重采样到各向同性间距(比如1mm×1mm×1mm),再跑marching cubes,表面会光滑得多,代价是体素数量膨胀,处理时间变长。
4.2 VTK体绘制:保留灰度信息的三维显示
网格导出适合做物理模型打印和定量测量,但医生看三维图像时更习惯带灰度信息的体绘制,能把血管和肝脏密度差异一并显示出来。VTK是医学影像三维显示的事实标准库,一个最小体绘制管线长这样:
import vtk reader = vtk.vtkNIFTIImageReader() reader.SetFileName('liver_mask.nii.gz') reader.Update() cast = vtk.vtkImageCast() cast.SetInputConnection(reader.GetOutputPort()) cast.SetOutputScalarTypeToUnsignedChar() cast.Update() mapper = vtk.vtkGPUVolumeRayCastMapper() mapper.SetInputConnection(cast.GetOutputPort()) color = vtk.vtkColorTransferFunction() color.AddRGBPoint(0, 0, 0, 0) color.AddRGBPoint(1, 0.9, 0.6, 0.3) opacity = vtk.vtkPiecewiseFunction() opacity.AddPoint(0, 0.0) opacity.AddPoint(1, 0.6) volume = vtk.vtkVolume() volume.SetMapper(mapper) volume.GetProperty().SetColor(color) volume.GetProperty().SetScalarOpacity(opacity) renderer = vtk.vtkRenderer() renderer.AddVolume(volume) renderer.SetBackground(0.1, 0.1, 0.1) window = vtk.vtkRenderWindow() window.AddRenderer(renderer) window.SetSize(800, 800) interactor = vtk.vtkRenderWindowInteractor() interactor.SetRenderWindow(window) interactor.Start()GPUVolumeRayCastMapper如果在本机报错,通常是不支持GPU光线投射,把类名换成vtkFixedPointVolumeRayCastMapper就能回落到CPU渲染,慢一点但保证能出图。这里的色彩函数里背景设为透明,肝脏标成淡黄褐色,临床看习惯的腹部窗就是这种色调。
网格和体渲染这两条路线不冲突,实际交付时常两个都要:STL给工程师做切割模拟,体渲染给临床医生看形态关系。VTK里也可以同时加载CT原始图像和分割掩膜,用半透明度让掩膜浮在灰度图上,观察分割有没有越界。
4.3 体积计算与最大径测量:把三维模型用进术前规划
三维模型最大的临床价值是定量。肝切除前最关心两件事:全肝体积、术后剩余肝体积。两者比值是评估术后肝衰竭风险的核心指标,这个数用Python算起来很快:
vox_mm3 = spacing[0] * spacing[1] * spacing[2] total_volume_cm3 = np.sum(mask == 1) * vox_mm3 / 1000.0 print(f'肝脏体积: {total_volume_cm3:.1f} cm3')单位换算这条很多人写错,spacing单位是毫米,体素体积是立方毫米,除以1000才是立方厘米,再除以1000才是升。肝脏体积通常在1200到1800立方厘米之间,算出来只有几十的基本是单位错了。
最大径测量更简单直观,在三维模型上取两个标记点算欧氏距离。VTK里可以用vtkDistanceWidget交互取点,或者直接从网格顶点坐标里暴力算两个极值点。但要记住最大径受呼吸运动影响,评估时选用门静脉期的CT数据,这个期相肝脏轮廓最清楚,测量误差最小。
5. 避坑与排查:数据读入到三维重建的5个翻车现场
5.1 数据读入与预处理:两个最隐蔽的错误
坑1:三维模型在z方向出现分层错位。
现象:分割出来的掩膜在连续几层切片上位置突然跳变,三维重建后肝脏表面出现明显的台阶状断裂,看起来像上下两段被切开又错开。
原因:DICOM读取时用了字符串文件名排序,数字10排在2前面,切片顺序整体错乱。个别源码包用InstanceNumber排序也不稳,扫描仪在多期增强扫描时可能复用实例号,只有ImagePositionPatient的z坐标是物理上最可靠的排序依据。
解决:读入逻辑统一按ImagePositionPatient[2]排序,如果个别文件缺这个标签,退回用InstanceNumber,再不行用AcquisitionNumber。排序后打印前三个和后三个切片的z坐标,确认单调递增再往下走,这步检查十秒钟,能省半小时。
坑2:模型输出整卷全黑或全白。
现象:推理脚本跑完,DICE直接是0,另存出来的mask文件里全是0,或者全是1。
原因:预处理跳过RescaleSlope和RescaleIntercept,直接用pixel_array里的原始整数做min-max归一化。骨骼的原始像素值可能到4000,空气却是0,整个灰度范围被拉得很开,肝脏的实际灰度区间被压缩到网络几乎无法响应。
解决:任何归一化之前必须先把原始像素转成HU值,再裁剪到[-200, 200]软组织窗。转HU这一步在load_dicom_series里完成,不要在infer.py里重新写一遍,保持单一出口避免版本漂移。
5.2 模型推理:显存爆了和预测全黑不是一回事
坑3:推理到中途CUDA out of memory。
现象:前几层切片还好,跑到几十层时直接报torch.cuda.OutOfMemoryError,程序中断。
原因:2D推理本身占用不高,但如果输入切片没有resize,还是512×512或更大分辨率,模型中间特征图叠加起来很容易吃满6GB显存。
解决:有两个路线。一是把切片先缩放到256×256再推理,输出用小尺寸掩膜做双线性插值回原分辨率,损失一点边界细节,换来显存占用减半。二是用fp16半精度推理,PyTorch的torch.autocast包装一下推理循环就能生效,显存再降一截。两个一起用基本能在4GB卡上跑通。
坑4:训练集DICE很高,换一家医院的数据掉到0.6。
现象:预训练模型在自己验证集上DICE 0.93,拿到实际临床数据后掉到0.6甚至更低,而且错的地方很集中:脾脏被当成肝脏、肝尾叶缺失。
原因:CT数据的灰度分布受扫描厂家、管电流、增强期相影响,预训练模型如果在单中心数据上训练,换中心的活就不可用了。这个域差异是医学影像落地的老难题,不是玄学,是分布本身不同。
解决:先不要急着重新训练。抽出5个新病例统计HU直方图,和训练集的均值方差对比,如果整体偏移明显,用直方图匹配把新数据对齐到训练集分布,往往就能把DICE拉回0.8以上。这是成本最低的解法,也是我接到类似项目最先试的一步。
5.3 三维重建阶段:等值面提取不到怎么办
坑5:marching cubes报No surface found,或者导出空STL文件。
现象:掩膜文件明明有值,但marching_cubes函数报错,或者生成的STL用查看器打开是空的。
原因:二值掩膜以整数0/1存储,marching cubes默认在level=0.5处提取等值面,理论上没问题,但如果掩膜数组里只有0和255,中间没有过0.5的像素值,等值面就断掉了。还有一种可能是输入mask根本没有前景,np.unique后全是0。
解决:传参前统一转成bool类型,即mask.astype(bool),level保持0.5;同时先跑np.unique(mask)确认前景类存在。排查顺序是:确认mask有值 -> 确认bool转换 -> 确认spacing参数长度是3,按这个顺序查能定位九成问题。STL没有顶点、三角形计数为0的文件,多半就是等值面断层,不是软件读取问题。
6. 进阶:用少量标注微调模型,把DICE从0.6拉回0.85
域差异不会因为你做了直方图匹配就彻底消失,所以真正要落地这个Python方案时,建议配一套微调流程。第一步是标注,我用ITK-SNAP直接在nii.gz格式的CT体数据上逐层画肝脏,画完保存为同一空间下的label nii.gz;数量不用贪多,8到15例不同体型和不同增强期相的数据,就能让预训练权重产生明显适应,前提是你只微调轻量网络,不重新训整个模型。
第二步是冻结浅层、只解冻高层。浅层卷积学到的是边缘、纹理这类跨数据集通用的特征,真正敏感的分布差异集中在深层语义特征。
from model import UNet2D import torch net = UNet2D(in_channels=1, num_classes=2) net.load_state_dict(torch.load('weights/liver_seg.pth', map_location='cpu')) for name, param in net.named_parameters(): if name.startswith('encoder.stage1'): param.requires_grad = False # 浅层特征在各数据集里通用,冻结它 optimizer = torch.optim.AdamW( filter(lambda p: p.requires_grad, net.parameters()), lr=1e-4, weight_decay=1e-5, )学习率从头训练的十分之一到百分之一,用1e-4起步,每5个epoch在验证集上算一次DICE,连续3轮不升就减半。数据增强做随机旋转±15度、随机弹性形变和HU抖动,能扛住标注样本不够的过拟合。
我自己的习惯是不急着微调:先把预训练模型跑完所有待处理数据,把翻车病例按病灶类型分开,看是肝硬化导致的低密度结节漏检,还是增强晚期边缘模糊,然后决定是补数据还是调后处理规则。统计出真正的系统性偏差再微调,整个迭代周期最短,也最容易让临床同事接受这套自动化流程。希望这些经验能帮你在肝脏CT分割和三维重建这条路上少走弯路,希望帮到你。
本文还有配套的精品资源,点击获取