简介:这份资源面向计算机、电子信息工程、数学等专业的大学生及医学影像处理初学者,提供一套基于Matlab的V-Net神经网络完整实现,用于从3D医学图像中完成肺肿瘤语义分割,可服务于课程设计、期末大作业与毕业设计等场景。压缩包共12个文件,约2.1MB,以xml配置与关系文件、png和jpeg图像素材为主,配合Matlab脚本与说明文档,构成可直接运行的程序结构。代码采用参数化编程,网络层结构、训练参数均可按需调整,注释清晰,便于理解编码器与解码器对称设计、跳跃连接、卷积与反卷积等关键环节。附赠案例数据可直接运行,涵盖数据归一化、Dice损失与Adam优化器选择、过拟合缓解及Dice系数评估等流程,兼容Matlab2014、2019a与2024a版本。目前已有58人学习,适合希望快速上手3D医学图像分割实践、对照代码复现实验并迁移到自身课题的读者。
1. 从一份 Matlab 代码包说起:V-Net 在 3D 肺肿瘤分割里到底解决什么问题
拿到「如何创建和训练 V-Net 神经网络,并从 3D 医学图像中对肺肿瘤进行语义分割 Matlab 代码.rar」这个标题时,多数人的第一反应是去找一份能跑的代码,但真正卡住落地的是三件事:3D 数据怎么读进来、V-Net 和普通 2D 语义分割网络差在哪、Matlab 里怎么把训练循环搭起来。肺肿瘤在 CT 里是一个三维连通体,单张切片上看它可能只占几十个像素,相邻切片之间却存在明显的体积连续性,用 2D 卷积逐层预测再堆叠,边界处经常出现「切片跳变」,这就是 V-Net 这类 3D 全卷积结构存在的理由。它面向的是有 Matlab 基础、手上有 nii 或 nii.gz 格式 CT 数据、想把肺肿瘤语义分割跑通并评估 Dice 的从业者,不是只想知道网络结构图的人。下面按数据准备、网络搭建、训练、评估、避坑、进阶的顺序,把这条链路拆成能照着复现的步骤。
2. V-Net 结构与 Matlab 落地选型:为什么不用 2D U-Net 硬堆
2.1 V-Net 的核心设计与 3D 语义分割的匹配点
V-Net 最早是为前列腺 MRI 体积分割提出的,结构上属于编码器-解码器全卷积网络,和 U-Net 同源,但有三处针对三维数据的改动值得注意。第一,卷积核全部是 3D 卷积,输入张量形状是[H, W, D, C],Matlab 里对应dlarray的'SSCB'或'SSSCB'格式,通道维在最后。第二,下采样用步长为 2 的 3D 卷积代替最大池化,这样梯度能直接回传到降采样路径,对体积数据的边界更友好。第三,解码器侧使用残差连接,把编码器对应层的特征加回解码器,缓解 3D 网络层数一深就难训的问题。
肺肿瘤分割里,肿瘤体积小、与周围血管和胸膜粘连,2D 网络在单切片上容易把血管断面误判成肿瘤。3D 卷积能同时看到上下切片,把「这个亮块在相邻层是否连续」作为判据,误检会明显下降。代价是显存和内存占用按深度线性增长,一个128×128×64的 patch,单精度浮点就要约 4MB,batch 一上去就爆。所以实际落地时不会整卷输入,而是随机裁 patch 训练、滑窗推理。
Matlab 从 R2019b 起提供trainNetwork和自定义训练循环两套接口。V-Net 这种带残差相加、非标准上采样的结构,用dlnetwork加自定义循环更灵活,trainingOptions那套封装反而不好塞自定义损失。选型上我一般建议:数据量小于 50 例、想快速验证,用dlnetwork+ 自定义循环;数据量大、要做多卡,再考虑trainNetwork配合layerGraph。
2.2 数据准备:把 nii 转成 Matlab 能吃的 4D 数组
医学图像常见格式是 NIfTI,Matlab 没有内置读取函数,常见做法是用niftiread(R2017b 之后 Image Processing Toolbox 提供)或第三方Tools for NIfTI。读进来后要做三件事:强度归一化、方向对齐、标签二值化。
% 读取 CT 与标签,假设已用 niftiread 得到 vol 和 lbl vol = niftiread('case001_ct.nii.gz'); % 尺寸 [H, W, D],int16 lbl = niftiread('case001_seg.nii.gz'); % 0/1 标签 % 1) 肺窗截断:CT 值 HU 范围,肺窗约 [-1000, 400] vol = double(vol); vol(vol < -1000) = -1000; vol(vol > 400) = 400; % 2) 归一化到 [0,1],按全局而非单例,避免不同病例尺度漂移 vol = (vol + 1000) / 1400; % 3) 标签二值化并转 single lbl = single(lbl > 0); % 4) 转成 dlarray,格式 SSSCB(H,W,D,C,B) vol = dlarray(reshape(vol, [size(vol,1), size(vol,2), size(vol,3), 1, 1]), 'SSSCB'); lbl = dlarray(reshape(lbl, [size(lbl,1), size(lbl,2), size(lbl,3), 1, 1]), 'SSSCB');这段代码的关键参数是窗宽窗位。肺窗[-1000, 400]是肺实质和肿瘤的常见显示范围,如果肿瘤靠近纵隔,可以放宽到[-1000, 600]。归一化用全局固定范围而不是按病例 min-max,是因为推理时新病例的 min-max 不可预知,固定范围能让训练和推理一致。标签转single是为了和网络输出做数值运算,logical在自定义损失里容易报类型错误。
提示:
niftiread读出的方向可能和标签不一致,务必用niftiinfo检查Affine矩阵,方向错了会出现「预测结果整体翻转」这种玄学现象。
2.3 用 dlnetwork 搭一个最小可训的 V-Net
下面是一个精简版 V-Net 编码器-解码器,层数按肺肿瘤 patch 规模裁剪,完整版可以按需加深。
function net = buildVNet(inputSize) % inputSize: [H W D C],例如 [128 128 64 1] layers = [ image3dInputLayer(inputSize, 'Normalization', 'none', 'Name', 'in') % ---- 编码器 ---- conv3dLayer([5 5 5], 16, 'Stride', 1, 'Padding', 'same', 'Name', 'e1c') batchNormalizationLayer('Name', 'e1b') reluLayer('Name', 'e1r') conv3dLayer([2 2 2], 32, 'Stride', 2, 'Padding', 'same', 'Name', 'e2c') batchNormalizationLayer('Name', 'e2b') reluLayer('Name', 'e2r') conv3dLayer([2 2 2], 64, 'Stride', 2, 'Padding', 'same', 'Name', 'e3c') batchNormalizationLayer('Name', 'e3b') reluLayer('Name', 'e3r') % ---- 解码器 ---- transposedConv3dLayer([2 2 2], 32, 'Stride', 2, 'Cropping', 'same', 'Name', 'd2t') reluLayer('Name', 'd2r') transposedConv3dLayer([2 2 2], 16, 'Stride', 2, 'Cropping', 'same', 'Name', 'd1t') reluLayer('Name', 'd1r') conv3dLayer([1 1 1], 1, 'Name', 'out') sigmoidLayer('Name', 'sigm') ]; net = dlnetwork(layerGraph(layers)); endconv3dLayer的Stride为 2 时承担下采样,Padding设'same'保证尺寸可预测。transposedConv3dLayer的Cropping设'same'是为了让上采样后尺寸和编码器对应层对齐,否则残差相加会因尺寸不匹配报错。输出层用sigmoid是因为肺肿瘤分割是二分类,如果做多类(肿瘤、血管、背景)就换成softmax并调整输出通道数。
注意:
dlnetwork不接受image3dInputLayer之外的输入层做自定义循环,如果要用trainNetwork,把image3dInputLayer换成sequenceInputLayer会破坏 3D 结构,别混用。
3. 训练循环与损失函数:Dice 怎么在 Matlab 里写对
3.1 自定义训练循环的骨架
Matlab 自定义循环的核心是dlfeval+dlgradient,配合adamupdate。下面是一个可运行的最小骨架。
numEpochs = 100; learnRate = 1e-3; trailingAvg = []; trailingAvgSq = []; net = buildVNet([128 128 64 1]); for epoch = 1:numEpochs shuffleIdx = randperm(numel(trainList)); for i = 1:numel(shuffleIdx) [X, Y] = loadPatch(trainList(shuffleIdx(i)), [128 128 64]); [loss, grads] = dlfeval(@modelLoss, net, X, Y); [net, trailingAvg, trailingAvgSq] = adamupdate(net, grads, ... trailingAvg, trailingAvgSq, i, learnRate); end fprintf('Epoch %d, loss %.4f\n', epoch, double(gather(extractdata(loss)))); end function [loss, grads] = modelLoss(net, X, Y) Ypred = forward(net, X); loss = diceLoss(Ypred, Y); grads = dlgradient(loss, net.Learnables); endloadPatch负责从整卷里随机裁128×128×64的 patch,正样本 patch 和负样本 patch 按 1:1 采样,否则肺肿瘤像素占比可能不到 1%,网络会直接全预测背景。adamupdate的第四个参数是迭代计数,传i而不是epoch,学习率衰减才按步数走。
3.2 Dice 损失与边界加权
Dice 系数对类别不平衡比交叉熵鲁棒,但纯 Dice 在训练初期梯度不稳定,常见做法是 Dice + 二元交叉熵加权。
function loss = diceLoss(Ypred, Ytrue) epsVal = 1e-6; Ypred = squeeze(Ypred); Ytrue = squeeze(Ytrue); intersection = sum(Ypred .* Ytrue, 'all'); union = sum(Ypred, 'all') + sum(Ytrue, 'all'); dice = (2 * intersection + epsVal) / (union + epsVal); loss = 1 - dice; endepsVal防止分母为零,squeeze去掉通道和 batch 维,sum(..., 'all')是 R2018b 之后的写法。如果要做边界加权,可以在Ytrue上用imerode和imdilate生成边界带,对边界像素的损失乘 2 到 5 倍。肺肿瘤边界模糊,加权后 Dice 通常能涨 1 到 2 个百分点,但权重超过 5 容易过拟合边界噪声。
提示:Dice 损失在 batch 很小时方差大,batch 小于 4 建议改用 Tversky 损失,把假阳性和假阴性分开调。
3.3 学习率、patch 尺寸与 batch 的联动
这三个参数是联动的,不能单独调。patch 从64×64×32加到128×128×64,显存占用约 8 倍,batch 只能从 8 降到 1,此时学习率要相应降到 1e-4 量级,否则梯度噪声会让 loss 震荡。我一般按这个顺序定:先定显存能承受的最大 patch,再定 batch 为 2 到 4,最后用 lr finder 找初始学习率。Matlab 里没有现成 lr finder,可以手动跑 10 个 epoch,学习率从 1e-5 指数增到 1e-2,画 loss 曲线找下降最陡的点。
4. 推理、后处理与 Dice 评估:滑窗怎么拼回整卷
4.1 滑窗推理与重叠融合
整卷尺寸往往超过显存,推理要滑窗。窗口大小和训练 patch 一致,步长取窗口的 1/2 到 1/4,重叠区用高斯权重融合,避免拼接缝。
function volPred = slidingWindowInfer(net, vol, winSize, stride) [H, W, D] = size(vol); volPred = zeros(H, W, D, 'single'); weightMap = zeros(H, W, D, 'single'); gauss = gaussian3d(winSize); % 自定义 3D 高斯核 for h = 1:stride(1):H-winSize(1)+1 for w = 1:stride(2):W-winSize(2)+1 for d = 1:stride(3):D-winSize(3)+1 patch = vol(h:h+winSize(1)-1, w:w+winSize(2)-1, d:d+winSize(3)-1); X = dlarray(reshape(patch, [winSize, 1, 1]), 'SSSCB'); Yp = extractdata(forward(net, X)); Yp = squeeze(Yp); volPred(h:h+winSize(1)-1, w:w+winSize(2)-1, d:d+winSize(3)-1) = ... volPred(h:h+winSize(1)-1, w:w+winSize(2)-1, d:d+winSize(3)-1) + Yp .* gauss; weightMap(h:h+winSize(1)-1, w:w+winSize(2)-1, d:d+winSize(3)-1) = ... weightMap(h:h+winSize(1)-1, w:w+winSize(2)-1, d:d+winSize(3)-1) + gauss; end end end volPred = volPred ./ max(weightMap, 1e-6); endstride取winSize/2是精度和速度的折中,取winSize/4精度略升但推理时间翻倍。高斯核让窗口中心权重高、边缘权重低,拼接处不会出现硬边。max(weightMap, 1e-6)防止边缘未覆盖区域除零。
4.2 连通域后处理与 Dice 计算
网络输出是概率图,阈值 0.5 二值化后,用bwconncomp去掉小于 50 体素的孤立块,这些多半是血管断面误检。
bw = volPred > 0.5; cc = bwconncomp(bw, 26); % 26 邻域,3D 连通 numPix = cellfun(@numel, cc.PixelIdxList); bw(:, :, :) = false; for k = find(numPix >= 50)' bw(cc.PixelIdxList{k}) = true; end % Dice 计算 inter = sum(bw(:) & gtVol(:)); dice = 2 * inter / (sum(bw(:)) + sum(gtVol(:)));26邻域比6邻域更宽松,肺肿瘤形状不规则,用 26 能保住细长突起。阈值 50 体素是经验值,按体素间距换算,如果层厚 1mm、层内 0.7mm,50 体素约 25 立方毫米,小于这个的孤立块基本是噪声。
注意:Dice 要在原始分辨率上算,不要在下采样后的标签上算,否则数值会虚高。
5. 避坑与排查:3D 分割训练里最常见的 5 个翻车现场
现象一:loss 一直停在 0.69 附近不降。原因通常是标签全为背景,或者正样本 patch 采样比例太低。解决:打印每个 batch 的sum(Ytrue, 'all'),如果长期为 0,检查loadPatch里正样本索引是否写错,把正负比例调到 1:1 或 1:2。
现象二:训练 loss 降但验证 Dice 不涨。多半是过拟合,肺肿瘤数据量小,网络参数量却按自然图像规模设。解决:把编码器通道数从 16/32/64 降到 8/16/32,加dropoutLayer(0.3)在编码器末端,数据增强加随机旋转 ±15 度和弹性形变。
现象三:推理结果整体偏移或翻转。这是 NIfTI 方向没对齐的血泪经验。niftiread读出的体数据方向由头信息决定,训练和推理用了不同方向就会翻车。解决:统一用niftiinfo读Affine,用imrotate3或permute对齐到同一方向,再送网络。
现象四:显存够但训练报 out of memory。Matlab 的dlarray默认保留计算图,dlfeval里如果忘了extractdata或循环里累积了变量,内存会持续涨。解决:每个 batch 结束清clear grads Ypred,验证阶段用dlarray但包在noGrad语义下(Matlab 里用forward而非dlfeval)。
现象五:Dice 在 0.6 到 0.7 卡住上不去。肺肿瘤边界模糊,纯 Dice 对边界不敏感。解决:加边界加权损失,或者把输出层从sigmoid换成softmax做三分类(背景、肿瘤核心、边界),再合并后处理,通常能到 0.75 以上。
6. 进阶技巧:用 5 折交叉验证和 TTA 把 Dice 再抬一档
数据量小的时候,单次划分的验证 Dice 波动能到 0.05,靠一次结果判断模型好坏不靠谱。我一般做 5 折交叉验证,每折训练一个模型,推理时对同一病例做 8 次测试时增强(TTA):原始、三个轴翻转、两个对角翻转,概率图平均后再二值化。这套组合在肺肿瘤数据上通常比单模型单次推理高 2 到 4 个百分点。
% TTA:8 种变换的概率图平均 augList = {@(x) x, @(x) flip(x,1), @(x) flip(x,2), @(x) flip(x,3), ... @(x) flip(flip(x,1),2), @(x) flip(flip(x,1),3), ... @(x) flip(flip(x,2),3), @(x) flip(flip(flip(x,1),2),3)}; probSum = zeros(size(vol)); for a = 1:numel(augList) volAug = augList{a}(vol); probAug = slidingWindowInfer(net, volAug, [128 128 64], [64 64 32]); probSum = probSum + augList{a}(probAug); % 逆变换后累加 end probAvg = probSum / numel(augList);逆变换要和正变换一致,翻转是对合运算,再翻一次就还原。TTA 的代价是推理时间乘 8,如果临床场景要求实时,可以只保留原始加三个轴翻转这 4 种,精度损失约 0.5 个百分点,时间省一半。
验证方法上,除了 Dice,我还会看 HD95(95% 豪斯多夫距离),它对边界离群点敏感,能暴露 Dice 掩盖的边界崩坏。Matlab 没有内置 HD95,可以用bwdist算距离变换再取分位数。参数上,TTA 的权重我一般均等,如果某一变换明显更差(比如深度轴翻转在层厚大的数据上),可以把它权重降到 0.5。
最后说个习惯:每次改网络结构或损失,先跑 10 个 epoch 看 loss 曲线形状,形状不对就别浪费算力跑满。肺肿瘤分割这方向,数据质量比网络结构重要,标注边界差 2 个像素,Dice 就能差 0.1,拿到数据先花时间核对标签,比调参划算。希望帮到你。
本文还有配套的精品资源,点击获取