news 2026/10/6 12:43:24

深度学习医学影像分割实战:U-Net与V-Net的2D/2.5D/3D实现全解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
深度学习医学影像分割实战:U-Net与V-Net的2D/2.5D/3D实现全解析

简介:基于深度学习的医学影像图像分割Python工程代码,面向医学影像分析初学者、课程设计或需要复现U-Net系列分割实验的研究者,提供一套从数据准备到模型训练、预测与查看结果的完整可运行流程。工程包含19个Python脚本,压缩包整体仅35KB,脚本按功能划分为数据预处理、2.5D/3D模型构建、训练、预测、后处理以及NII文件可视化等模块,目录层级清晰,便于按需取用。目前已有552人学习下载,热度较好。代码中不仅包含基础U-Net,还提供unet_3d、v_net等变体,读者可据此对比不同网络在MRI分割上的表现;同时给出训练集含10%验证的数据划分说明,并提供批量分割脚本,可对测试集和待预测图像生成结果并保存,方便直观评估。资源附有环境依赖与运行顺序说明,适合快速搭建环境、跑通全流程,也可作为医学影像深度学习入门或课程项目复现的轻量实用代码库。

1. 基于深度学习的医学影像图像分割:这个 MRI 分割项目包里到底装着什么

做医学影像图像分割的人大概都经历过这种场景:手头有一批 MRI 的 .nii 文件,标注已就绪,但训练入口在哪、预处理要不要做、.nii 怎么读都是拦路虎。这个项目就是干这个的——它把 U-Net 和 V-Net 在 2D、2.5D、3D 三种维度下的实现、训练、预测、后处理全部串成一条可运行链路。data/train放训练集,data/rest放测试集,data/test放待预测数据,三次独立预测结果也保留在目录里,方便做稳定性对比,这个细节我很喜欢。适合刚入门医学影像分割的学生、要做课程设计的本科生、以及想快速验证分割方案、不想从零搭网络的工程师。下面我从文件结构开始拆,一步步说清楚怎么跑、怎么调参、踩过哪些坑。

2. 文件结构与网络选型:U-Net、V-Net 和 2.5D 方案怎么选

2.1 项目文件布局:训练链路核心文件与摘要映射

解压MRI-Segmentation-main.rar之后,目录大致是这样:

unet/ ├── data/ │ ├── train/ # 训练数据,其中 10% 作为验证集 │ ├── rest/ # 测试数据,含 predict/predict1/predict11 三次运行结果 │ └── test/ # 课程设计要求预测的数据 ├── two_half_d/ # 2.5D 方案脚本目录 ├── u_net_3d/ # 3D U-Net 相关实现 ├── create_train_data_25d.py ├── generator_25d.py ├── train_25d.py ├── preprocess_25d.py ├── network/ │ ├── unet_3d_nn.py │ ├── v_net_25d.py │ ├── unet_25d.py │ └── vnet_3d_nn.py ├── public_lib/ │ ├── utils.py │ ├── loss_function.py │ ├── pathvariable.py │ └── dataIO.py ├── predict.py # 对 data/test/image 分割 ├── predict_rest.py # 对 data/rest/image 分割 ├── train.py ├── preprocess.py ├── generator.py ├── postprocess.py ├── create_train_data.py └── environment.txt

这个项目给我印象最深的是代码按阶段拆得很细,每个脚本只负责一件事,没有把预处理、训练、预测全塞进一个文件里。工程上这是好事——你可以单独替换某一段而不用动其他部分。比如你想换一种归一化策略,只改create_train_data.py就够;想换损失函数,只动public_lib/loss_function.py。

不过要注意,摘要里写的运行方式是 "python3 name.py",并列举了data.py、unet_model.py、see.py这些名字,实际压缩包里不是完全这么叫的。我整理了一张对应关系表,照着这个找文件就不会懵:

摘要里的名称实际代码文件职责
data.pycreate_train_data.py + preprocess.py + generator.py数据准备、预处理、在线增强
unet_model.pynetwork/unet_25d.py、network/unet_3d_nn.pyU-Net 模型定义
train.pytrain.py / train_25d.py训练主流程
predict.pypredict.py / predict_rest.py对 test、rest 数据分割
see.py查看 .nii 的工具脚本(基于 dataIO 封装)输入路径,预览文件

two_half_d和u_net_3d是两个独立的方案目录,里面装的是 2.5D 和 3D 训练的专用脚本,比如create_train_data_25d.py、train_25d.py。它们和根目录下主流程脚本形成对照,作者等于把三条技术路线都铺开了,这对做课程设计的人特别友好——你可以直接对比 2D、2.5D、3D 在同一批数据上的效果,不用自己写三份代码。

2.2 三种网络架构的定位差异:2D、2.5D、3D 怎么选

选择网络架构是医学影像分割里最核心的决策。2D U-Net 把每一层切片当独立图像处理,输入形状是 (B, C, H, W),理解成本最低、显存占用最小,但完全丢掉片间连续性。遇到层厚较大、目标在相邻层间形态突变的情况,2D 的结果经常在层间出现锯齿状边界。

2.5D 的思路是取当前切片并各带上下一层,拼成 3 通道或 5 通道的输入(我一般取上一层+当前层+下一层)。这样网络虽然还是二维卷积,却能通过通道维度感知到层间局部上下文。这个方案在 MRI 分割里很实用——很多数据集的层间距比平面内分辨率大,3D 卷积在层间学到有效特征很费力,而 2.5D 用很小的代价就把三维信息送进了网络。

3D U-Net / 3D V-Net 直接以 (B, C, D, H, W) 体素卷积,信息利用最充分,但显存占用随深度线性增长,训练时间也最长。当整个数据集只有几十个 case 时,3D 网络很容易过拟合。这个项目同时实现三套,相当于给不同数据条件留了三条路:

方案输入形状显存压力空间上下文适合场景
2D U-Net(B, C, H, W)低层内快速验证、大数据量
2.5D U-Net/V-Net(B, 3~5, H, W)中层间局部层间距大、显存有限
3D U-Net/V-Net(B, C, D, H, W)高全局体积高分辨率厚数据、追求精度

我实际跑这个项目时,默认从 2.5D 入手,因为它显存成本比 3D 低一个数量级,又比纯 2D 稳。先跑通链路,再根据数据厚度决定要不要切到 3D。如果数据只有几十层且各向异性明显,2.5D 往往是效果和资源最平衡的选项。

3. 数据准备与训练流程:从 .nii 原始数据到分割模型

3.1 环境安装与启动方式:先跑通链路

cd /path/to/unet pip3 install -r environment.txt python3 create_train_data.py python3 train.py python3 predict.py

逻辑说明:按顺序跑下来就是完整链路。cd到项目根目录是为了让相对路径生效,这个项目把数据集路径写在public_lib/pathvariable.py里,全部基于当前目录解析;如果不在根目录运行,第一行就会报 FileNotFoundError,这是初学者最容易踩的一步。

参数说明:安装依赖建议加--user或用虚拟环境,避免污染系统 Python;GPU 机器需要先确认 torch 版本与 CUDA 匹配,常见做法是运行python3 -c "import torch; print(torch.cuda.is_available())",打印 True 说明显存可用。如果机器上同时有 Python 2 环境,务必用pip3和python3,否则依赖装错环境后 import 报错会浪费很多时间。

提示:先跑 create_train_data.py 再跑 train.py,顺序不能反。create_train_data 输出的中间文件是 train.py 的输入,漏跑一步在后面会直接读不到数据。

3.2 create_train_data.py 与数据预处理

create_train_data.py主要负责把 .nii 读进来、归一化、按比例切分训练集和验证集。下面是核心逻辑的示意代码,和项目实现对齐:

# create_train_data.py 核心逻辑示意 import numpy as np from public_lib.dataIO import load_nii nii_files = load_file_list("data/train/image") # 读入所有 nii 路径 images = [] for nii_file in nii_files: img, affine = load_nii(nii_file) # (D, H, W) img = img.astype(np.float32) # 归一化:MRI 灰度值范围大,先 clip 再线性映射 lo, hi = np.percentile(img, 1), np.percentile(img, 99) img = np.clip(img, lo, hi) img = (img - lo) / (hi - lo + 1e-8) images.append(img) # 10% 做验证集 split = int(len(images) * 0.9) train_list, val_list = images[:split], images[split:] np.savez("data/train_ready.npz", train=train_list, val=val_list)

逻辑说明:clip 到 1%~99% 分位是为了去掉 MRI 里极端高亮的噪声点。这种数据的灰度值经常有少数体素异常偏大,直接用 max-min 归一化会被个别异常值带偏,所以我一般用分位数截断而不是全范围截断。split 用 0.9 表示预留 10% 验证集,这是项目作者预设的比例。

参数说明:如果你的数据来自不同扫描仪或不同序列,灰度基线会差很多,可以在归一化前加偏置场校正步骤。如果数据集很小并切各类别极不均衡,可以把 split 调到 0.85 让训练数据多一些,代价是验证集波动变大——小数据集上 10% 的验证集可能只有几例,Dice 曲线看起来会很抖。这时候不是你模型不行,是验证集太小,要有这个心理准备。

generator.py的作用是训练时按 batch 动态读取切片并做随机翻转、旋转等在线增强,避免把所有增强结果一次性写进内存把磁盘撑爆。这是医学影像数据量小时的常规操作——在线增强等于变相扩容,而且每个 epoch 看到的样本都不一样,能有效抑制过拟合。

3.3 train.py 训练参数与模型行为观察

train.py负责加载数据集、初始化模型、迭代训练、保存最优权重。下面这些参数是训练时最需要盯的:

epochs = 100 # 小数据集建议 100 起步,看曲线再决定加不加 batch_size = 8 # 2D/2.5D 可接受;3D 要降到 2 或 1 learning_rate = 1e-4 # 医学影像数据量小,1e-3 太大,1e-4 稳妥 optimizer = torch.optim.Adam(model.parameters(), lr=learning_rate) criterion = loss_function.dice_loss # 见 public_lib/loss_function.py

逻辑说明:医学影像分割正负样本严重失衡,目标区域经常只占整个体积的百分之几,直接用交叉熵会让网络倾向于输出全背景。Dice Loss 优化的是预测与标注的体素重叠程度,天然对类别不均衡问题不敏感,这是它在这个场景下被选为主损失函数的原因。

参数说明:学习率配 1e-4 是经验值,如果 loss 震荡明显就降到 5e-5。batch_size 受显存约束,2.5D 用 8 没问题,但如果你把输入分辨率设得非常大(比如 512×512×3),先减半再观察显存余量。训练里另一个关键点是按验证集 Dice 保存最优权重:

# train.py 中保存最优权重的常见写法 best_dice = 0.0 for epoch in range(epochs): train_loss = run_epoch(model, train_loader, optimizer) val_dice = evaluate(model, val_loader) if val_dice > best_dice: best_dice = val_dice torch.save(model.state_dict(), "checkpoints/best.pth") print(f"epoch={epoch} loss={train_loss:.4f} val_dice={val_dice:.4f}")

逻辑说明:按验证集 Dice 决定是否保存当前权重,而不是按训练 loss。训练 loss 只反映拟合程度,验证集 Dice 才说明泛化能力——拿训练 loss 最小的一版权重去预测,往往不是最好的。

参数说明:epochs 不要盲目跑满。我一般先观察 5 个 epoch,如果 val_dice 没有上升趋势,问题大概率出在数据准备环节,而不是训练时间不够。检查路径通常是:先看 create_train_data 的输出有没有问题,再看生成器有没有把数据对齐。

4. 预测与后处理实战:predict.py 输出约定与结果修复

4.1 predict.py 与 predict_rest.py 的输出约定

predict.py针对data/test/image预测,结果写到data/test/predict;predict_rest.py针对data/rest/image,结果写到data/rest/predict。核心逻辑是这样:

# predict.py 核心循环(示意) import torch from network.unet_25d import UNet25D from public_lib.dataIO import load_nii, save_nii model = UNet25D(in_channels=3, out_channels=1) model.load_state_dict(torch.load("checkpoints/best.pth", map_location="cpu")) model.eval() # 切到推理模式,关掉 BN/Dropout 的随机性 for case in test_cases: img, affine = load_nii(case) # (D, H, W) pred = predict_case(model, img) # 概率图 seg = (pred > 0.5).astype(np.uint8) # 二值化阈值,可调整 save_nii(seg, affine, output_path)

逻辑说明:model.eval()那行是最容易漏的,漏了预测结果会产生随机抖动。二值化阈值取 0.5 是常规默认,实际应用中如果医生反馈分割边界偏小,把阈值降到 0.3~0.4;如果偏大,提到 0.6 以上。阈值不是玄学,它直接控制着查全率和查准率的取舍。

参数说明:predict_rest.py的数据流和 predict.py 相同,只是输入换成data/rest/image,输出换成data/rest/predict。这个项目把data/rest/predict、predict1、predict11三组运行结果都保留下来,这就是标准的三次重复测试——同一模型多跑几遍,看分割结果在体素级别稳不稳定。

4.2 后处理:连通域分析、阈值调整与结果修复

网络预测出的原始 mask 有噪声是常态,postprocess.py负责去掉零散假阳性:

# postprocess.py 后处理逻辑(示意) from scipy import ndimage labeled, num_features = ndimage.label(binary_pred > 0) if num_features > 0: volumes = ndimage.sum(binary_pred > 0, labeled, range(1, num_features + 1)) # 只保留最大连通域,或滤掉小于 min_volume 的区域 max_label = volumes.argmax() + 1 binary_pred = (labeled == max_label).astype(np.uint8)

逻辑说明:逐体素分类天然会产生碎片,连通域分析是医学影像分割后处理的通用手段。这里用ndimage.label给每个连通域编号,再用ndimage.sum统计体积,把小于阈值的小块滤掉。最常见的设定是保留最大连通域——假设你不是在分割多发病灶,这个操作很管用。

参数说明:min_volume要结合体素分辨率来定。假如体素是各向同性 1mm³,目标是最小病灶直径 5mm,那按 5³=125 体素做下限是合理的。别小看这一步,它经常能把 Dice 从 0.90 拉到 0.93 以上。

边界毛糙时还可以做一次二值形态学闭运算,填掉细缝和小空洞:

from scipy.ndimage import binary_closing binary_pred = binary_closing(binary_pred, structure=np.ones((3, 3, 3)))

注意闭运算的结构元素不要超过 5×5×5,否则会吞掉真实解剖细节。我见过有人用 9×9×9 的结构做闭运算,结果小病灶全被填没了,这个参数是真的要谨慎。

5. 避坑与排查:训练不收敛、显存溢出与路径写死

5.1 训练损失不下降

现象:dice_loss 在前 10 个 epoch 毫无变化,打印出来像一条直线,val_dice 也纹丝不动。

原因:最常见的是学习率偏大导致 loss 震荡,但更隐蔽的是数据预处理没对齐——原始像素值范围是 0~3000,没归一化直接进网络,梯度数值不稳定,网络根本没有有效更新。这种问题在 MRI 数据上特别常见,因为不同序列的灰度范围差异极大。

解决:把 learning_rate 降到 1e-4 以下,并回看 create_train_data.py 的归一化逻辑;在 train.py 训练前打印一个切片的像素统计,确认数值在 0~1 区间。我习惯在数据加载器里加一行断言,值域不对直接报错,省得训练半天才发现数据有问题。

5.2 3D 网络显存溢出

现象:训练第二个 batch 时直接 OOM,进程被系统杀掉,或者报 CUDA out of memory。

原因:3D 卷积的中间特征图比 2D 大一个数量级。2D 下 batch_size=8 没问题,3D 下 batch_size=4 都跑不进去,显存被中间激活值瞬间占满。

解决:先降到 batch_size=2 试跑,看显存占用再逐步加。还不行就把输入统一裁剪成 64×64×64 的 patch,训练时随机裁剪,预测时重叠滑动窗口拼接。这个项目既然给了 2.5D 的完整脚本,日常实验直接用 2.5D 最省事,3D 留给数据厚度确实够大的场景。

5.3 数据增强导致的信息泄露

现象:验证集 Dice 比训练集还高,看起来像捡到宝,仔细一查是数据流出了问题。

原因:如果在整个数据集上先做统一归一化再切分训练/验证,或者增强时用了全数据的统计量,验证集信息已经通过统计量泄露进训练过程,验证指标失去意义。

解决:先切分,再对训练部分单独计算归一化参数;数据增强只作用于训练集。医学影像数据量小,大家习惯"先整体预处理再切分",这个顺序其实是大坑。分割任务里尤其要小心,因为很多预处理是全局的。

5.4 路径写死的翻车现场

现象:代码在自己机器跑得好好的,换个目录或换台机器就报 FileNotFoundError,或者读到了旧数据、旧权重。

原因:public_lib/pathvariable.py里用的是相对路径,但运行脚本时没有先切到项目根目录;或者某个中间文件路径在多个脚本里被反复写死,换环境就找不到。

解决:在程序入口打印os.getcwd(),强制 cd 到项目根目录再运行;路径集中到 pathvariable.py 里改,不要散落在各脚本里改字符串。我这个教训很深,曾经因为路径指到了旧数据目录,用旧的训练集训了一个星期才发现。

5.5 预测结果全是黑的或全白的

现象:分割结果 .nii 打开后全黑,或者全白,看不到任何解剖结构。

原因:大概率是保存时丢了 affine 信息,或者把概率图当二值图保存了。全白通常是阈值设成了 0,网络输出没有经过 sigmoid 就直接比较,所有体素都被判成了正例。

解决:save_nii 时要传入 load_nii 时拿到的 affine;确认网络输出层包含 sigmoid;打开前用查看工具检查保存数组的数值分布,是 0/1 还是 0~1 浮点。如果保存的是概率图,需要做阈值判断后再存。看完第 6 节的查看工具你就知道怎么排查了。

6. 结果验证技巧:用 see.py 检查分割,三次预测取一致性

摘要里提到的see.py是查看 .nii 文件的小工具,核心逻辑其实就是 load_nii 之后转成图像显示。我在这个项目上习惯不用 IDE,直接写一个十几行的加载脚本来看结果:

# 查看单个 .nii 分割结果(对应摘要里的 see.py) import sys import numpy as np from public_lib.dataIO import load_nii img, affine = load_nii(sys.argv[1]) slices = img.shape[0] for i in range(0, slices, max(1, slices // 9)): # 均匀取 9 层预览 print(f"slice {i}: min={img[i].min()} max={img[i].max()} mean={img[i].mean():.2f}")

配合 Dice 一致性检查更直观。这个项目保留了data/rest/predict、predict1、predict11三组运行结果,我会对同一 case 两两算 Dice:

# 三次预测一致性检查 from public_lib.dataIO import load_nii def dice(a, b): return 2 * np.sum((a > 0) & (b > 0)) / (np.sum(a > 0) + np.sum(b > 0) + 1e-8) p1, _ = load_nii("data/rest/predict/case01.nii") p2, _ = load_nii("data/rest/predict1/case01.nii") p3, _ = load_nii("data/rest/predict11/case01.nii") print(f"run1 vs run2: {dice(p1, p2):.4f}") print(f"run1 vs run3: {dice(p1, p3):.4f}") print(f"run2 vs run3: {dice(p2, p3):.4f}")

逻辑说明:三次独立预测的 Dice 一致性是模型稳定性的硬指标。如果两两 Dice 低于 0.95,说明预测对输入扰动敏感,问题多半出在预处理或后处理环节,而不是网络结构本身。再配合分层预览的像素统计,就能快速定位是阈值问题还是连通域参数问题。

参数说明:检查脚本不挑数据,把路径换到 test 目录同样适用。我还习惯把每组结果的总体素数打出来,配合肉眼观察边界是否贴合解剖结构。数值和结构双重验证,比只看 Dice 一个数可靠得多。

有一次我赶时间只跑了一次预测就直接交结果,和同事的算法一对比,边界抖动非常明显,那才意识到预测稳定性也是个必须盯的指标。从那以后我每次做完医学影像分割都强制跑三遍预测,两两 Dice 低于 0.95 就回去查预处理和后处理,这个习惯确实救了不少次。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/6 12:43:20

aarch64 Linux下Eclipse CDT 2021-12 R安装配置与调试实战

简介:这是面向Linux ARM64(aarch64)平台的Eclipse C/C集成开发环境稳定版,于2021年12月发布,专为C/C项目编写、编译、调试与版本协作场景设计,适合从事嵌入式、服务器及云服务领域的中高级开发者使用。压缩…

作者头像 李华
网站建设 2026/10/6 12:43:14

直流电机双闭环调速Simulink仿真:从建模到PI参数整定的完整实操指南

直流电机双闭环调速,是电机控制领域绕不开的一个经典模型。很多朋友在课上学过原理,但一到MATLAB里动手搭仿真就卡壳——要么不知道从哪个模块开始,要么模型搭完一运行就发散,要么波形不对却不知道怎么调。这篇文章我就用一篇完整…

作者头像 李华
网站建设 2026/10/6 12:40:56

上厕所的时间搭好一套系统:AI自动生成实测

标题没夸张,这是一篇技术向实测记录:从一句话需求到系统上线,全程隔了一个课间的长度。 样本:一家连锁餐饮公司,十二家直营店加中央厨房。需求:排产、分店订货、损耗统计三件事管起来。 本文按管线拆解全程…

作者头像 李华
网站建设 2026/10/6 12:40:42

运放偏置电路导致ADC采样不准?从失调电压到温漂的完整排查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/6 12:40:34

Android CLI 支持 AI Agent 通过 Device Streaming 调试云真机

最近 Android CLI 正式支持 Android Device Streaming 这个还是挺实用的,等于是现在远程 Android 可以暴露成了一个标准 ADB endpoint 给 Agent ,然后 CLI 从申请设备到安装、操作、截图、抓日志、抓 trace 都可以留在终端里完成:其实这些能力…

作者头像 李华
网站建设 2026/10/6 12:39:58

揭秘Java程序员逆袭之路:从零到高薪的必经之道

近十年来, 程序员一直是一个非常热的词, 这一职业也是极为抢手的工作类别, 然而程序员的岗位种类繁杂多样, 在此时刻,我们将针对Java程序员的相关信息进行详细介绍, 诚邀各位共同学习知晓。新闻内容:大家可能会问, 究竟是为了什么才去学习Java这门语言的呢?第一个最…

作者头像 李华