十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MATLAB实现UNet肺结节检测:从模型搭建到训练部署全指南

MATLAB实现UNet肺结节检测:从模型搭建到训练部署全指南 简介一套基于MATLAB与UNet的肺结节检测实战资源面向医学图像处理、深度学习方向的在校生与工程师旨在帮助读者从零跑通语义分割任务理解编码器-解码器结构以及肺部CT影像的预处理、训练与预测流程。资源共2000个文件其中1995张png格式的肺部CT切片与标注图构成完整训练/测试样本集另含4个m脚本分别实现主程序、UNet网络定义、测试和结果保存1个txt文件用于辅助说明整体约195MB便于下载、离线复现与二次修改。包体组成清晰目录结构简洁便于按脚本功能快速定位。已有227人学习下载不少学习者将其用于课程设计、毕业设计或算法验证。通过运行代码可直接获得肺结节分割结果与可视化输出既能帮助快速掌握UNet原理也能在此基础上调整网络结构与超参数迁移到其他医学影像分割场景是一份兼顾教学与实战的参考资料。1. 为什么在 MATLAB 里做 UNet 肺结节检测把 UNet 用在肺结节检测上本质是把「找到疑似病灶」转成「对 CT 切片做像素级分割」。拿到一张肺部 CT 的横断面切片模型要为每一个像素输出一个标签是结节还是背景。与目标检测画边界框不同分割能保留结节的轮廓信息方便后续算体积、看形态这对临床随访和良恶性判断都有直接价值。MATLAB 做这件事的路径比想象中顺——Deep Learning Toolbox 里提供了完整的层定义、训练选项和推理函数配合 Image Processing Toolbox预处理、增强和后处理能在一个环境里闭环不需要把数据导到 Python 再导回来。这篇我按自己实际跑通的路子把 UNet 的结构拆解、数据准备、模型搭建、训练调参与落地验证一次讲透。适合刚接触医学图像分割的工程师也适合手里有 CT 数据但不想切语言栈的研究人员。2. UNet 结构在肺结节分割里的角色以及 MATLAB 里的两种搭法2.1 为什么编码器-解码器结构适合「找小结节」肺结节在 CT 切片里通常表现为小范围的、与周围组织灰度接近的类圆形区域。有些结节直径只有几毫米对比度又低普通分类网络很难定位如果只做目标检测MS COCO 那套 Anchor 机制在医学影像上也不好调。UNet 的设计恰好解决这两个痛点。UNet 的编码器通过逐层下采样缩小特征图的空间尺寸同时增加通道数。这一步让网络在越来越大的感受野里提取语义信息——大结节靠上下文小结节靠浅层纹理。解码器再逐步上采样恢复空间分辨率把编码器各层学到的特征图放大回去。关键是跳跃连接Skip Connection解码器每个 stage 都会拼接编码器同层输出的特征图让网络在恢复细节时直接引用浅层的高分辨率特征。这样最终输出的分割图边缘更锐利小目标的召回率也更高。在 MATLAB 里实现 UNet 有两条路一条是用unetLayers直接拿预置函数另一条是用layerGraph手动拼。unetLayers适合快速验证改不了内部模块手工搭虽然代码量大但能控制每个卷积的卷积核个数、是否带 BatchNorm、Dropout 加在哪也更方便后续做改进实验。我下面用 layerGraph 搭一个适合 512x512 CT 切片的 UNet输入单通道输出二分类前景/背景。2.2 用 layerGraph 手工搭建 UNet 的完整步骤搭建思路是用convolution2dLayer和maxPooling2dLayer搭编码器用transposedConv2dLayer或dltranspconv做上采样用depthConcatenationLayer做跳跃连接。注意 MATLAB 的transposedConv2dLayer在 R2021a 之后可用老版本用convolution2dLayer配合resize2dLayer也可以但效果和效率都不如转置卷积。inputSize [512 512 1]; numClasses 2; layers [ imageInputLayer(inputSize, Name, input, Normalization, none) % 编码器 Stage 1 convolution2dLayer(3, 64, Padding, same, Name, enc1_conv1) batchNormalizationLayer(Name, enc1_bn1) reluLayer(Name, enc1_relu1) convolution2dLayer(3, 64, Padding, same, Name, enc1_conv2) batchNormalizationLayer(Name, enc1_bn2) reluLayer(Name, enc1_relu2) maxPooling2dLayer(2, Stride, 2, Name, enc1_pool) % 编码器 Stage 2 convolution2dLayer(3, 128, Padding, same, Name, enc2_conv1) batchNormalizationLayer(Name, enc2_bn1) reluLayer(Name, enc2_relu1) convolution2dLayer(3, 128, Padding, same, Name, enc2_conv2) batchNormalizationLayer(Name, enc2_bn2) reluLayer(Name, enc2_relu2) maxPooling2dLayer(2, Stride, 2, Name, enc2_pool) % 编码器 Stage 3 convolution2dLayer(3, 256, Padding, same, Name, enc3_conv1) batchNormalizationLayer(Name, enc3_bn1) reluLayer(Name, enc3_relu1) convolution2dLayer(3, 256, Padding, same, Name, enc3_conv2) batchNormalizationLayer(Name, enc3_bn2) reluLayer(Name, enc3_relu2) maxPooling2dLayer(2, Stride, 2, Name, enc3_pool) % 编码器 Stage 4 convolution2dLayer(3, 512, Padding, same, Name, enc4_conv1) batchNormalizationLayer(Name, enc4_bn1) reluLayer(Name, enc4_relu1) convolution2dLayer(3, 512, Padding, same, Name, enc4_conv2) batchNormalizationLayer(Name, enc4_bn2) reluLayer(Name, enc4_relu2) ]; lgraph layerGraph(layers);这里把编码器前四层搭好。注意每个卷积后都接了 BatchNorm 和 ReLU原因是医学影像的灰度分布在不同设备间差异很大BatchNorm 可以缓解协变量偏移让训练更稳定。Paddingsame保证特征图尺寸在卷积后不变避免边缘信息过早丢失。接着补解码器和跳跃连接用addLayers和connectLayers把图拼完整。跳跃连接的本质是把编码器同一 stage 的输出接到解码器对应 stage 的输入上这一步在 MATLAB 里通过给层定义不同 Name 来实现连接关系。% 解码器 Stage 4 - 上采样到 Stage 3 尺寸 dec4_up transposedConv2dLayer(2, 256, Stride, 2, Name, dec4_up); dec4_conv1 convolution2dLayer(3, 256, Padding, same, Name, dec4_conv1); dec4_bn1 batchNormalizationLayer(Name, dec4_bn1); dec4_relu1 reluLayer(Name, dec4_relu1); dec4_conv2 convolution2dLayer(3, 256, Padding, same, Name, dec4_conv2); dec4_bn2 batchNormalizationLayer(Name, dec4_bn2); dec4_relu2 reluLayer(Name, dec4_relu2); % 解码器 Stage 3 - 上采样到 Stage 2 尺寸 dec3_up transposedConv2dLayer(2, 128, Stride, 2, Name, dec3_up); dec3_conv1 convolution2dLayer(3, 128, Padding, same, Name, dec3_conv1); dec3_bn1 batchNormalizationLayer(Name, dec3_bn1); dec3_relu1 reluLayer(Name, dec3_relu1); dec3_conv2 convolution2dLayer(3, 128, Padding, same, Name, dec3_conv2); dec3_bn2 batchNormalizationLayer(Name, dec3_bn2); dec3_relu2 reluLayer(Name, dec3_relu2); % 解码器 Stage 2 - 上采样到输入尺寸 dec2_up transposedConv2dLayer(2, 64, Stride, 2, Name, dec2_up); dec2_conv1 convolution2dLayer(3, 64, Padding, same, Name, dec2_conv1); dec2_bn1 batchNormalizationLayer(Name, dec2_bn1); dec2_relu1 reluLayer(Name, dec2_relu1); dec2_conv2 convolution2dLayer(3, 64, Padding, same, Name, dec2_conv2); dec2_bn2 batchNormalizationLayer(Name, dec2_bn2); dec2_relu2 reluLayer(Name, dec2_relu2); % 输出层 final_conv convolution2dLayer(1, numClasses, Name, final_conv); softmax softmaxLayer(Name, softmax); pixelClass dicePixelClassificationLayer(Name, output); dec_layers [ dec4_up dec4_conv1, dec4_bn1, dec4_relu1, dec4_conv2, dec4_bn2, dec4_relu2 dec3_up dec3_conv1, dec3_bn1, dec3_relu1, dec3_conv2, dec3_bn2, dec3_relu2 dec2_up dec2_conv1, dec2_bn1, dec2_relu1, dec2_conv2, dec2_bn2, dec2_relu2 final_conv softmax pixelClass ]; lgraph addLayers(lgraph, dec_layers);解码器的通道数比原版 UNet 少一半是刻意压的。512 输入下如果每层都用 1024 通道显存会直接爆掉。实际训练时我先把编码器 Stage 1 从 64 改成 32 试过精度掉得不多但显存占用少了约 40%。如果跑 256x256 输入通道数可以保持 64 起步。连接跳跃连接时注意 MATLAB 的connectLayers第一个参数是源层名第二个是目标层名。这里要把编码器每个池化之前的输出接到解码器对应 stage 的输入上。如果名字写错analyzeNetwork会直接报图不连通这也是新手最容易卡住的地方。lgraph connectLayers(lgraph, enc1_relu2, dec2_up/in); lgraph connectLayers(lgraph, enc2_relu2, dec3_up/in); lgraph connectLayers(lgraph, enc3_relu2, dec4_up/in);为什么是dec2_up/in因为 transposedConv2dLayer 的输入端口默认就叫in。如果不写端口名直接连到层名也可以。接完以后用analyzeNetwork(lgraph)检查一遍MATLAB 会画出网络结构图并标出哪一层尺寸不匹配。这一步必须做肉眼排查 20 层网络太容易漏。2.3 损失函数的选择Dice Loss 比交叉熵稳肺结节分割里背景像素远多于前景像素典型场景下结节只占整张切片的 1% 到 5%。如果直接用交叉熵损失模型会把所有像素预测成背景因为这样 loss 也很低。dicePixelClassificationLayer计算的是 Dice 系数损失公式为Loss 1 - (2 * |X ∩ Y|) / (|X| |Y|)X 是预测的前景区域Y 是标注的真实区域。这个损失不关心像素总数只关心重叠程度天然对类别不平衡不敏感。MATLAB 里直接指定这个层做输出层就行不需要自己写损失函数。如果数据里结节特别小或者一张切片里同时有大结节和微小结节Dice 损失训练时会比较震荡。我一般会在训练初期把学习率调低一点或者先用交叉熵预训练 20 个 epoch再用 Dice 微调。MATLAB 里交叉熵对应pixelClassificationLayer切换成本很低。3. MATLAB 里的数据准备从原始 CT 到可训练的 imageDatastore3.1 数据格式与预处理管线肺结节公开数据集通常提供两种格式DICOM 系列文件和已提取的 PNG 切片加标注掩膜。DICOM 文件需要用dicominfo和dicomread读取但整批读很慢我一般先把 DICOM 批量转成 PNG 缓存到本地训练时直接读 PNG。转格式时可以顺手做三件事裁剪到固定尺寸、归一化、保存掩膜。常用的公开数据集中结节掩膜是黑白图白色像素是结节区域。要注意有些数据集标注的是整个肺结节有些标注的是结节的核心区域这对训练标签影响很大。拿到数据后先抽几张图把掩膜以半透明方式叠在原图上检查一遍确认标注风格再开始训练。function [img, mask] loadNodulePair(imgPath, maskPath) img imread(imgPath); mask imread(maskPath); % 转灰度 if size(img, 3) 3 img rgb2gray(img); end if size(mask, 3) 3 mask rgb2gray(mask); end % 归一化到 [0 1] img double(img) / 255; mask double(mask 128); % 二值化 % 缩放到 512x512双线性插值 img imresize(img, [512 512], bilinear); mask imresize(mask, [512 512], nearest); % 掩膜用最近邻 end掩膜的缩放必须用nearest不能用双线性插值。双线性会在结节边界产生介于 0 和 1 之间的过渡值导致训练时标签出现第三类像素影响分割精度。这是分割任务里最常见也最隐蔽的错误。3.2 用 imageDatastore 和 pixelLabelDatastore 管理数据MATLAB 的imageDatastore负责读图像pixelLabelDatastore负责读像素级标签。两类数据要用同一个随机种子做 shuffle保证图像和掩膜一一对应。可以用shuffle前先rng(0)固定随机种子或者干脆把图像和掩膜文件按相同命名规则放在两个目录里靠文件名匹配保证对应。imgDir fullfile(dataRoot, images); maskDir fullfile(dataRoot, masks); imds imageDatastore(imgDir, FileExtensions, .png); pxds pixelLabelDatastore(maskDir, [background, nodule], [0 1]); % 划分训练/验证 rng(42); numFiles numel(imds.Files); idx randperm(numFiles); trainIdx idx(1:round(numFiles*0.8)); valIdx idx(round(numFiles*0.8)1:end); imdsTrain subset(imds, trainIdx); pxdsTrain subset(pxds, trainIdx); imdsVal subset(imds, valIdx); pxdsVal subset(pxds, valIdx);pixelLabelDatastore的第二个参数是类别名第三个是类别对应的像素值。这里 0 是背景1 是结节。训练前用preview看一下 mask 读取是否正确再统计一下结节像素在所有像素中的占比。3.3 数据增强randomPatchExtractionDatastore与imageDataAugmenter医学图像数据量通常不大几百张切片训练 UNet 很容易过拟合。MATLAB 提供了imageDataAugmenter做在线增强随机旋转、缩放、平移、翻转这些操作在每次 epoch 读取数据时实时执行不额外占用磁盘空间。augmenter imageDataAugmenter(... RandRotation, [-10 10], ... RandScale, [0.9 1.1], ... RandXTranslation, [-10 10], ... RandYTranslation, [-10 10], ... RandXReflection, true);注意不要用RandXReflection做水平翻转以外的强增强比如随机裁剪到很小区域可能会把小结节裁掉一半反而降低标注质量。对分割任务来说增强后的标签和图像必须同步变换MATLAB 的imageDataAugmenter和randomPatchExtractionDatastore会自动处理这层对应关系这也是我选择用内置 datastore 而不是自己写数据加载函数的主要原因。4. 训练配置与调参从能跑到跑好的关键参数4.1 训练选项怎么设初始学习率、MiniBatchSize 和验证频率UNet 训练和分类网络不一样学习率一般要更小。Adam 优化器下初始学习率 1e-4 到 3e-4 比较稳SGD 的话用 1e-3 配 Momentum 0.9。我建议直接上 Adam省去手动调 Momentum 和 L2 正则的麻烦。options trainingOptions(adam, ... InitialLearnRate, 2e-4, ... MaxEpochs, 50, ... MiniBatchSize, 4, ... ValidationData, {imdsVal, pxdsVal}, ... ValidationFrequency, 10, ... Shuffle, every-epoch, ... Plots, training-progress, ... Verbose, true, ... LearnRateSchedule, piecewise, ... LearnRateDropPeriod, 20, ... LearnRateDropFactor, 0.5, ... L2Regularization, 1e-5);MiniBatchSize 是显存的直接决定因素。512x512 输入、编码器第一层 64 通道的 UNet在 11G 显存如 RTX 2080Ti上 MiniBatchSize 最多设 4 到 6。如果你显存不够优先把输入尺寸降到 512 以下比如 384 或 256而不是缩小 batch size 到 1因为 BatchNorm 在 batch 太小的时候统计量不稳定训练会抖动。4.2 训练过程监控看准确率没意义看 DiceMATLAB 的 training-progress 图会实时显示 loss 和验证准确率。对分割任务来说准确率 99% 以上根本不能说明问题——因为背景占了绝大多数像素。要验证模型真的学到了结节特征在训练选项里添加自定义评估函数或者在训练结束后单独用验证集算 Dice 系数和 IoU。% 训练结束后在验证集上评估 net trainedNet; pxdsResults semanticseg(imdsVal, net, MiniBatchSize, 2); metrics evaluateSemanticSegmentation(pxdsResults, pxdsVal); disp(metrics.ClassMetrics);evaluateSemanticSegmentation会输出每个类别的 IoU、Dice、BF 分数。重点看 nodule 类的 Dice 是否在 0.6 以上。如果只有 0.3说明模型没有真正学到结节特征需要回头看数据预处理、损失函数或者模型容量。4.3 显存不足时的降级方案梯度累积与 patch 训练512x512 输入直接训练对显存压力很大。常见做法是裁剪成 256x256 的 patch 来训练但这会丢失结节的上下文信息——大结节可能横跨多个 patch小 patch 看不到完整轮廓。用randomPatchExtractionDatastore可以解决一部分问题它会在每张图上随机取 patch同时保证 patch 内至少包含一个前景像素这样既能控制显存又能确保训练时看到正样本。patchds randomPatchExtractionDatastore(imdsTrain, pxdsTrain, [256 256], ... PatchesPerImage, 8, ... DataAugmentation, augmenter);PatchesPerImage设为 8相当于一张 512 原图产生 8 个 256 patch变相扩大了数据集。训练时 MiniBatchSize 可以提高到 8 到 12显存占用反而比直接跑 512 更小。代价是 patch 之间没有重叠模型看不到结节全貌推理时需要用滑动窗口预测并拼接结果这会在第 5 章详细讲。5. 推理、后处理与部署把分割结果变成临床可用的输出5.1 滑动窗口推理与拼接策略如果训练用了 patch推理时不能把整张 512 图直接 feed 进网络需要做滑动窗口。窗口大小与训练 patch 一致步长设置为窗口大小的三分之一到二分之一保证相邻窗口有重叠拼接时用加权平均消除边界效应。function probMap predictWithSlidingWindow(net, img, patchSize, stride) [h, w, ~] size(img); probMap zeros(h, w, single); countMap zeros(h, w, single); for y 1:stride:h-patchSize1 for x 1:stride:w-patchSize1 patch img(y:ypatchSize-1, x:xpatchSize-1, :); patch dlarray(single(patch), SSCB); prob predict(net, patch); prob extractdata(prob); probMap(y:ypatchSize-1, x:xpatchSize-1) ... probMap(y:ypatchSize-1, x:xpatchSize-1) prob(:, :, 2, 1); countMap(y:ypatchSize-1, x:xpatchSize-1) ... countMap(y:ypatchSize-1, x:xpatchSize-1) 1; end end probMap probMap ./ max(countMap, eps); end注意 MATLAB 的predict函数在 R2021a 之后直接接受普通数组不需要手动dlarray。上面保留dlarray是为了兼容 R2020b 及更早版本。拼接后把概率大于 0.5 的像素标为前景再用medfilt2或形态学开运算去掉孤立的假阳性点。5.2 后处理连通域分析与体积估算给每个检测到的结节标记独立编号用bwconncomp找连通域过滤掉面积过小的区域。肺结节的最小临床意义直径通常在 3mm 左右按 CT 像素间距换算成像素面积后设阈值。cc bwconncomp(maskBinary); stats regionprops(cc, Area, Centroid, BoundingBox); minArea 20; % 小于 20 像素的区域直接删除 validIdx [stats.Area] minArea; numNodules sum(validIdx);如果 DICOM 里有像素间距信息PixelSpacing算结节直径就有临床价值。面积乘以像素间距的平方再换算成等效直径。这个阶段可以顺手把每个结节的中心坐标、边界框、面积、等效直径写进 CSV输出成结构化报告。5.3 导出与部署ONNX 与代码生成MATLAB 训练好的网络可以用exportONNXNetwork导出到 ONNX 格式放到 ONNX Runtime 里做推理也可以在 MATLAB 里用codegen生成 CUDA 代码部署到边缘设备。R2023a 以后的版本还支持直接加载 ONNX 模型做迁移学习前置模型来自 PyTorch 的话也能在 MATLAB 里继续训练。这个互操作性让 MATLAB 在原型验证阶段很有优势——先用 MATLAB 快速跑通 pipeline后面要上生产再换语言栈分割模型本身不绑定平台。6. 多类别扩展、注意力机制与模型瘦身6.1 从二分类到多分类肺结节、血管、胸膜增厚临床数据里标注往往不止结节一类可能还有血管、胸膜增厚、钙化灶等。把pixelLabelDatastore的类别列表改一下把numClasses改成语义类别数网络输出层不用动损失函数会自动兼容多分类。要注意的是多分类时如果类别数超过 4建议每类单独建一个 datastore或者用combine手动管理避免 MATLAB 在内存里一次加载全部类别的标注。6.2 给 UNet 加注意力模块用 MATLAB 自定义层MATLAB 自 R2021a 起支持layer基类来写自定义层可以插入 attention block 到跳跃连接处。这个操作不复杂定义一个继承自nnet.layer.Layer的类实现predict方法就行。常见做法是加 scSEConcurrent Spatial and Channel Squeeze Excitation模块提升结节区域的特征响应。在 MATLAB 里写这类自定义层要注意自定义层的predict方法要避免使用for循环尽量用向量化操作否则训练会慢 5 到 10 倍。6.3 模型轻量化与显存优化如果要在低显存设备上推理把编码器的通道数从 64/128/256/512 减半解码器同步减少。另一个更有效的优化是使用深度可分离卷积替换标准卷积参数减少 6 到 8 倍精度下降通常在 1% 到 3% 以内。MATLAB 没有内置深度可分离卷积层需要用groupedConvolution2dLayer配合逐点卷积手动搭建。在推理阶段把网络转换成dlnetwork后调用resetState可以减少计算图中的中间变量缓存显存占用能进一步下降。实测 512 输入下推理时的峰值显存可以压到 2GB 以内这个量级已经可以在部分嵌入式设备上跑通。最后提醒一点不管是用预置unetLayers还是手工 layerGraph验证模型是否有效永远要看结节区域的 Dice 和 IoU而不是整图的准确率。跑完训练后抽几张带有不同大小结节5mm 以下、5-10mm、10mm 以上的验证图把预测掩膜叠加在原图上保存成 PNG 检查一遍这个小步骤能帮你避开绝大多数模型实际不可用的问题。本文还有配套的精品资源点击获取
返回列表