MATLAB实现UNet肺结节检测:从模型搭建到训练部署全指南
2026/9/16 12:43:19 网站建设 项目流程

简介:一套基于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 的完整步骤

搭建思路是:用convolution2dLayermaxPooling2dLayer搭编码器,用transposedConv2dLayerdltranspconv做上采样,用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 可以缓解协变量偏移,让训练更稳定。Padding='same'保证特征图尺寸在卷积后不变,避免边缘信息过早丢失。

接着补解码器和跳跃连接,用addLayersconnectLayers把图拼完整。跳跃连接的本质是把编码器同一 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 到可训练的 imageDatastore

3.1 数据格式与预处理管线

肺结节公开数据集通常提供两种格式:DICOM 系列文件和已提取的 PNG 切片加标注掩膜。DICOM 文件需要用dicominfodicomread读取,但整批读很慢,我一般先把 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 数据增强:randomPatchExtractionDatastoreimageDataAugmenter

医学图像数据量通常不大,几百张切片训练 UNet 很容易过拟合。MATLAB 提供了imageDataAugmenter做在线增强:随机旋转、缩放、平移、翻转,这些操作在每次 epoch 读取数据时实时执行,不额外占用磁盘空间。

augmenter = imageDataAugmenter(... 'RandRotation', [-10 10], ... 'RandScale', [0.9 1.1], ... 'RandXTranslation', [-10 10], ... 'RandYTranslation', [-10 10], ... 'RandXReflection', true);

注意不要用RandXReflection做水平翻转以外的强增强,比如随机裁剪到很小区域,可能会把小结节裁掉一半,反而降低标注质量。对分割任务来说,增强后的标签和图像必须同步变换,MATLAB 的imageDataAugmenterrandomPatchExtractionDatastore会自动处理这层对应关系,这也是我选择用内置 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 训练过程监控:看准确率没意义,看 Dice

MATLAB 的 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-patchSize+1 for x = 1:stride:w-patchSize+1 patch = img(y:y+patchSize-1, x:x+patchSize-1, :); patch = dlarray(single(patch), 'SSCB'); prob = predict(net, patch); prob = extractdata(prob); probMap(y:y+patchSize-1, x:x+patchSize-1) = ... probMap(y:y+patchSize-1, x:x+patchSize-1) + prob(:, :, 2, 1); countMap(y:y+patchSize-1, x:x+patchSize-1) = ... countMap(y:y+patchSize-1, x:x+patchSize-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方法就行。常见做法是加 scSE(Concurrent 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 检查一遍,这个小步骤能帮你避开绝大多数模型实际不可用的问题。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询