ARTICLE DETAIL

资讯详情

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

基于U-Net的眼底图像视杯视盘分割实战:从数据准备到模型训练

基于U-Net的眼底图像视杯视盘分割实战:从数据准备到模型训练 简介基于 Python 的眼底图像视杯视盘分割项目属于医学图像处理方向的完整课程设计/毕业设计示例适合计算机、人工智能、电子信息等专业学生作为实践项目参考。项目实现了眼底图像中红色血管、绿色视盘、蓝色视杯的自动分割与可视化可计算杯盘比vCDR与血管分支点特征并以可视化列表形式呈现特征便于医生交互查看关键位置同时扩展出早产儿视网膜病变分类、青光眼分级以及 39 类眼科疾病分类等多种任务功能结构完整可操作性强。压缩包共 18 个文件约 9.22MB包含程序源码、两个 ONNX 模型文件、7 张运行结果截图、5 张眼底样图以及 README 说明文档等目录划分清晰模型与代码分离便于快速定位与学习。代码经过调试运行成功注释详细并附有项目说明与截图演示目前已有 348 人学习浏览。下载后依据 README 即可复现分割与分类流程也可在此工程基础上调整模型参数或增加特征指标快速改造为适合毕业设计或竞赛展示的完整方案。1. 眼底图像视杯视盘分割从一张眼底照片到青光眼量化指标拿到一张彩色眼底照片让Python程序自动把视盘和视杯的边界画出来再算出杯盘比CDR这就是眼底图像视杯视盘分割要解决的事。眼科医生筛查青光眼时杯盘比超过0.5就会警惕这个数值直接由分割结果计算得到。它非常适合做成课程设计任务边界清晰、公开数据集多、结果可视化直观更重要的是传统图像分割算法在这种弱边界场景下容易翻车换成基于深度学习的U-Net后指标提升明显天然能写出有说服力的对比分析。下文按“数据准备—模型搭建—训练避坑—验收交付”的顺序把这个项目完整拆开讲。2. 视杯和视盘为什么难分割解剖机制与U-Net选型2.1 视盘与视杯的解剖特征分割目标不是一个简单的圆先明确要分割的两种结构。视盘是视神经在视网膜上的出口在眼底照片里通常呈淡红或淡黄色圆形区域直径占整个眼底图像的四分之一到五分之一具体比例受拍摄视野影响。视杯是视盘中央的凹陷区域由于光线反射较弱颜色更浅、更白。视杯的边界永远在视盘内部所以一张正确标注的眼底图像里视杯区域一定是视盘区域的子集。分割时输出两类掩码一个是整个视盘一个是视盘中心的小圆两个掩码之间的环状区域就是神经视网膜边缘青光眼患者的这个环会变薄。这个解剖关系直接影响程序输出设计。你不能把视杯当独立类别随便分割否则可能出现视杯跑到视盘外这种解剖学上不可能的预测。工程上通常让模型同时输出两个通道通道0负责视盘通道1负责视杯训练时天然吸收“杯子在盘里”的空间约束或者在后处理中用视盘掩码裁掉视杯的多余部分。尽管解剖结构清晰但眼底图像本身噪声很大。照明不均匀会让同一张图左右亮度差异明显黄斑和视盘周围的亮斑会干扰定位血管从视盘中心穿出切断视杯的连续边界。视盘和视杯之间不是一条清晰的边缘线而是一个灰度渐进带。所以做这个分割真正学到的是“边界在哪里”的语义判断而不是简单的像素颜色分类。早期青光眼患者的视杯边界尤其模糊灰度过渡带跨越几十个像素多位专家标注的结果也会有5%到10%的边界差异这意味着模型Dice达到0.9以上后再追指标已经没有医学意义课程设计里不必纠结最后一两个百分点。2.2 传统图像分割算法为什么在这里集体翻车很多课程设计的第一个版本都会从阈值分割开始因为“视盘明显比背景亮”。如果只用几张干净图测试Otsu阈值确实能把视盘大致圈出来。但换到有反光、血管密集的青光眼图像阈值就失灵要么把视杯视盘连成一片要么把黄斑边缘也圈进来。传统算法最大的问题是参数依赖图像亮度分布而眼底图像来自不同相机、不同光源无法用一个固定阈值处理。形态学方法更复杂比如用区域生长对视盘做分割需要选种子点和相似度阈值。视杯内部的苍白区域和视盘边缘灰度差异不小区域生长很容易在中途停住。分水岭算法对梯度图非常敏感血管阴影会切出无数小区域。主动轮廓模型Snake能对圆形边界拟合但初始化轮廓必须靠近真实边界不同人眼的视盘位置差异很大初始化不好就收敛到血管上。如果非要用传统方法做主线有个相对可用的做法先估计视盘中心然后在极坐标下展开边界检测用Canny找边缘效果稍好一些但遇到血管遮挡仍然不稳定。这类方法作为课程设计的“对照组”很有价值作为主方案则不够可靠。我拿到这类题目时建议先花两三天跑一遍传统方法作为对比然后把主线换成基于深度学习的图像分割。这样做既能在文档里写出“传统方法局限性”的真实实验数据又能让最终Dice指标足够好看高分课程设计一般都需要这种对照组。2.3 U-Net为什么适合眼底图像从编码器到跳跃连接U-Net最初就是为医学图像分割设计的其结构非常适合视杯视盘这种“小目标、弱边界”场景。网络分两部分编码器用卷积和池化不断下采样把输入从512乘512压缩到32乘32特征图包含“哪个区域是视盘”的高层语义解码器把特征图逐层上采样回512乘512恢复空间细节。如果没有跳跃连接解码器只能看到模糊的语义图恢复出的边界会很圆润但不精确跳跃连接把编码器每层的边缘特征直接拼到解码器对应层让模型在恢复位置时同时看到原始细节。选择U-Net的另一层考虑是数据效率。眼底图像分割的公开数据集规模普遍不大几百张标注已经算充裕。Vision Transformer这类模型在超大数据集上更强在几百张图的小样本场景下反而容易过拟合训练也更难调。U-Net基础版本参数量适中用GPU训练几十个epoch就能得到Dice 0.85以上的模型CPU上也能完成推理非常契合课程设计的时间预算。最后说输出设计。常见有两种方案第一种训练一个网络输出两个通道同时预测视盘和视杯第二种分两个独立网络分别分割。课程设计用双通道更好因为视盘和视杯特征高度相关共享编码器能降低训练成本文档里也更好解释。数据加载时视盘、视杯真值必须与图像做同一套增强否则两个任务学不到对齐信息。3. 数据准备公开数据集选型、掩码解析与预处理脚本3.1 公开数据集怎么选RIM-ONE、DRISHTI-GS与REFUGE的差异环境准备是一个基础坑。PyTorch 1.12以上即可Python 3.8到3.10都能跑。有同学卡在python安装numpy库的方法上其实一行 pip install numpy 就能解决别在镜像源上纠结太久。接着就要选数据集。视杯视盘分割没有官方统一数据集常见选择是三个公开眼底图像集。RIM-ONE最常用作课程设计因为它按青光眼严重程度分类包含不同等级的样本标注是手工勾画的视盘、视杯边界。DRISHTI-GS提供了训练集和测试集的明确划分还包含多位专家的标注适合用测试集做客观评估。REFUGE的规模更大除了视盘视杯分割标注还附带青光眼标签适合做进阶实验。选择时可以参考下表数据集标注内容适用阶段RIM-ONE视盘、视杯掩码初版验证、小样本交叉验证DRISHTI-GS视盘视杯掩码加专家标注标准训练/测试划分REFUGE视盘视杯掩码加病变标签数据量充足、深度学习主实验选数据集时先看掩码定义。不同数据集对“视盘”边界定义略有差异有的包含筛板有的只包含凹陷区域。课程设计不要求统一但文档必须写明用的是哪一个版本测试时不能把多个数据集混在一起评估。3.2 掩码解析与ROI裁剪把标注文件变成模型能读的张量数据集的标注文件常见形式是两张图一张眼底原图一张掩码图。掩码是单通道PNG像素值代表不同语义区域但具体数值没有标准有的用0、128、255有的用0、1、2。写解析脚本前先用代码统计掩码的像素值分布再按实际值映射千万不要凭直觉硬编码。每份代码的注释要写明“像素值以数据集说明为准”。下面这段函数将原始掩码解析成视盘、视杯两个二值数组并统一缩放到训练尺寸。import numpy as np import cv2 from pathlib import Path def parse_masks(mask_path, size(512, 512)): 把原始掩码解析成视盘、视杯两个二值数组 假设掩码中: 0背景, 128视杯, 255视盘(包含视杯) 不同数据集需要先统计再按实际值调整 mask cv2.imread(str(mask_path), cv2.IMREAD_UNCHANGED) if mask.ndim 3: mask mask[..., 0] # 有的标注三通道内容相同只取第一个通道 disc (mask 0).astype(np.uint8) # 所有非背景都在视盘内 cup (mask 128).astype(np.uint8) # 视杯是标号为128的像素 disc cv2.resize(disc, size, interpolationcv2.INTER_NEAREST) cup cv2.resize(cup, size, interpolationcv2.INTER_NEAREST) return disc.astype(np.uint8), cup.astype(np.uint8)注意第7行如果掩码是三通道彩色图直接读取会得到H乘W乘3数组需要手动取一个通道。很多同学卡在“为什么mask读出来不是二值数组”就是这里没处理。第12行的像素值映射是数据相关逻辑换成DRISHTI-GS可能要改成视杯等于某个特定值务必先统计验证。解析完之后不要直接整图训练。眼底原图通常是2048乘1536如果直接缩放整图视盘会被压成几十个像素的小点模型学不到边界细节。常见做法是先做ROI裁剪用掩码质心作圆心切一个包含视盘加一定边距的正方形再缩放到512乘512。def crop_roi(img, mask, margin1.3): 以掩码质心为中心裁剪固定比例ROI ys, xs np.where(mask 0) cx, cy int(xs.mean()), int(ys.mean()) h int((ys.max() - ys.min()) * margin) w int((xs.max() - xs.min()) * margin) side max(h, w) // 2 # 处理边界越界 x0, x1 max(cx - side, 0), min(cx side, img.shape[1]) y0, y1 max(cy - side, 0), min(cy side, img.shape[0]) roi_img img[y0:y1, x0:x1] roi_mask mask[y0:y1, x0:x1] return roi_img, roi_maskmargin参数很敏感。1.2到1.5之间推荐太小会把视杯边缘截掉太大让背景占比升高恶化类别不平衡。裁剪后统一用INTER_NEAREST做缩放因为mask不能有灰度过渡值双线性插值会在二值边界产生0.5这种中间值后续计算Dice时会出现错乱。3.3 CLAHE、归一化与数据增强让模型在不同眼底照相机下都不翻车眼底图像的亮度分布受相机影响极大同一张图也会出现半边暗半边亮。直接归一化到0到1还不够常见做法是先做CLAHE对比度受限自适应直方图均衡增强局部对比度。这个预处理函数要供训练和推理共用。def preprocess(img): CLAHE增强加归一化训练和推理必须使用同一套 img_rgb cv2.cvtColor(img, cv2.COLOR_BGR2RGB) lab cv2.cvtColor(img_rgb, cv2.COLOR_RGB2LAB) l, a, b cv2.split(lab) clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) l clahe.apply(l) lab cv2.merge([l, a, b]) img_out cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) / 255.0 return img_out.astype(np.float32)CLAHE只对亮度通道做不会改变色调clipLimit越大对比度越强眼底图像经验值2.0到3.0。做完CLAHE再除以255让网络输入落在0到1。最容易犯的错是推理时忘了做同一套预处理导致训练输入分布和推理输入分布不一致指标凭空掉几个点。数据增强要克制。旋转、翻转、缩放对目标分割都有效但眼底图像有解剖方向垂直翻转会让视盘位置变得不合理解剖学一般只做水平翻转。随机旋转角度控制在15度以内过度旋转会给模型引入虚假的方向特征下面是增强函数。def augment(img, disc, cup): 训练时随机水平翻转和旋转mask使用最近邻插值 if np.random.rand() 0.5: img img[:, ::-1].copy() disc disc[:, ::-1].copy() cup cup[:, ::-1].copy() angle np.random.uniform(-15, 15) h, w img.shape[:2] M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) img cv2.warpAffine(img, M, (w, h), flagscv2.INTER_LINEAR) disc cv2.warpAffine(disc, M, (w, h), flagscv2.INTER_NEAREST) cup cv2.warpAffine(cup, M, (w, h), flagscv2.INTER_NEAREST) return img, disc, cup图像用双线性插值保持平滑mask用最近邻插值保证语义标签不被污染。如果两个mask的旋转矩阵和原图不一致后面对齐时会出现一层像素的偏移。4. 用PyTorch实现U-Net模型、损失函数与训练循环4.1 U-Net模型定义双卷积块与跳跃连接把编码器和解码器写清楚后整个网络结构并不复杂。先定义一个双卷积模块它是U-Net的基本重复块。import torch import torch.nn as nn class DoubleConv(nn.Module): 两次卷积加BN加ReLUU-Net的基本重复块 def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x)BatchNorm在训练和推理时的行为不同保存模型后加载时要用model.eval()切换到推理模式否则BN会继续用当前batch的统计量做估计影响分割边界。下面是U-Net主体。输入3通道眼底图输出2通道logits分别对应视盘和视杯。class UNet(nn.Module): def __init__(self, in_ch3, base_ch64, out_ch2): super().__init__() self.enc1 DoubleConv(in_ch, base_ch) self.pool1 nn.MaxPool2d(2) self.enc2 DoubleConv(base_ch, base_ch * 2) self.pool2 nn.MaxPool2d(2) self.enc3 DoubleConv(base_ch * 2, base_ch * 4) self.pool3 nn.MaxPool2d(2) self.enc4 DoubleConv(base_ch * 4, base_ch * 8) self.pool4 nn.MaxPool2d(2) self.bridge DoubleConv(base_ch * 8, base_ch * 16) # 转置卷积上采样把特征图尺寸还原 self.up1 nn.ConvTranspose2d(base_ch * 16, base_ch * 8, 2, stride2) self.dec1 DoubleConv(base_ch * 16, base_ch * 8) self.up2 nn.ConvTranspose2d(base_ch * 8, base_ch * 4, 2, stride2) self.dec2 DoubleConv(base_ch * 8, base_ch * 4) self.up3 nn.ConvTranspose2d(base_ch * 4, base_ch * 2, 2, stride2) self.dec3 DoubleConv(base_ch * 4, base_ch * 2) self.up4 nn.ConvTranspose2d(base_ch * 2, base_ch, 2, stride2) self.dec4 DoubleConv(base_ch * 2, base_ch) self.out nn.Conv2d(base_ch, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool1(e1)) e3 self.enc3(self.pool2(e2)) e4 self.enc4(self.pool3(e3)) b self.bridge(self.pool4(e4)) d1 self.dec1(torch.cat([self.up1(b), e4], dim1)) d2 self.dec2(torch.cat([self.up2(d1), e3], dim1)) d3 self.dec3(torch.cat([self.up3(d2), e2], dim1)) d4 self.dec4(torch.cat([self.up4(d3), e1], dim1)) return self.out(d4)in_ch是输入图片通道数眼底图为3out_ch2分别输出视盘、视杯的logitbase_ch64是初始通道数。如果显存不够把base_ch改成32Dice一般只差0.5到1个百分点。解码器的torch.cat是在通道维拼接所以上采样后的通道数必须等于对应编码器的输出通道数这个对应关系最容易写错。4.2 Dice Loss与评估指标类别不平衡下的损失函数选择视盘在512乘512图像里通常只占5%像素视杯占比更小。直接用BCE Loss会出现“全预测为背景”也能得到很小的loss。Dice Loss直接优化分割重叠度对小目标更友好但它和BCE混合使用会更可靠。下面是课程设计里常用的双通道损失实现。def dice_bce_loss(pred, target, smooth1.0): 单个通道的Dice和BCE混合损失 pred_prob torch.sigmoid(pred) bce nn.functional.binary_cross_entropy(pred_prob, target) inter (pred_prob * target).sum() dice 1 - (2.0 * inter smooth) / (pred_prob.sum() target.sum() smooth) return bce dice class DiscCupLoss(nn.Module): 视盘加视杯双通道损失两通道独立计算后相加 def __init__(self, cup_weight1.5): super().__init__() self.cup_weight cup_weight def forward(self, pred, disc_gt, cup_gt): # pred: (B,2,H,W)两个通道分别对应视盘、视杯 disc_pred pred[:, 0].squeeze(1) cup_pred pred[:, 1].squeeze(1) loss_disc dice_bce_loss(disc_pred, disc_gt) loss_cup dice_bce_loss(cup_pred, cup_gt) return loss_disc self.cup_weight * loss_cupcup_weight设成1.5是为了放大视杯的梯度贡献防止模型把注意力全放在视盘上。smooth参数控制在1.0即可太小会放大梯度波动。这个损失函数在验证时也能用但课程设计文档里建议单独算Dice和IOU作为最终指标避免损失函数和评估指标混淆。评估指标的计算函数如下输入是预测掩码和真值掩码。def seg_metrics(pred_mask, gt_mask): pred_mask和gt_mask都是bool或0/1数组 pred_mask pred_mask 0 gt_mask gt_mask 0 inter np.logical_and(pred_mask, gt_mask).sum() union np.logical_or(pred_mask, gt_mask).sum() iou inter / (union 1e-6) dice 2 * inter / (pred_mask.sum() gt_mask.sum() 1e-6) return dice, iou视杯的Dice通常比视盘低10个百分点左右因为视杯小且边界模糊。如果视杯Dice达到0.85以上视盘Dice达到0.92以上这个模型就可以作为课程设计的主交付成果了。4.3 训练循环与可视化日志、断点检查和分割结果叠加训练主体部分要留出验证集每个epoch记录验证Dice保存最优权重。下面是训练一个epoch的框架代码。def train_one_epoch(model, loader, criterion, optimizer, device): model.train() total_loss 0 for imgs, disc, cup in loader: imgs imgs.to(device) disc_gt disc.unsqueeze(1).float().to(device) cup_gt cup.unsqueeze(1).float().to(device) pred model(imgs) loss criterion(pred, disc_gt, cup_gt) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() * imgs.size(0) return total_loss / len(loader.dataset)imgs是已经预处理过的float张量disc和cup是0/1的uint8张量unsqueeze(1)把H乘W变成1乘H乘W让模型输出和真值的形状对齐。训练配置建议优化器AdamW学习率3e-4weight_decay1e-4batch_size在base_ch64时设8显存不足就降到4epoch设为80到100每5个epoch用验证集算一次Dice学习率调度用ReduceLROnPlateaupatience10。保存最优模型并做可视化是课程设计演示的关键一环。把预测概率图和真值叠加到原图上建议做一个四联图原图、真值掩码、预测掩码、误差叠加图。保存到输出目录后续写文档和做答辩PPT直接取用。torch.save(model.state_dict(), best_unet.pth) def visualize_result(img, disc_pred, cup_pred, disc_gt, cup_gt, save_path): draw img.copy() draw[disc_pred 0] (0, 255, 0) # 绿色标注视盘预测 draw[cup_pred 0] (0, 0, 255) # 红色标注视杯预测 cv2.imwrite(save_path, draw[:, :, ::-1])可视化函数里的颜色叠加要分通道处理眼底图是三通道彩色图直接用布尔索引给RGB赋值即可。保存时要从RGB转回BGR否则画出来颜色会偏蓝。5. 常见问题排查掩码错位、全零预测和跨数据集过拟合5.1 视盘分割出来了视杯却整片消失现象训练的模型在验证集上视盘Dice有0.9视杯Dice却只有0.3甚至全黑。原因视杯区域小且边界模糊Dice损失对视杯的梯度贡献太小模型把注意力全放在视盘上。解决单独加大视杯损失的权重把4.2节中的cup_weight从1.5调到2.0到3.0或者分两步训练先训视盘再冻结编码器单独微调视杯输出头。实践中发现第二个方法对课程设计的代码量要求更高但文档里能体现工程能力。5.2 预测掩码错位视觉效果叠加不上现象预测边界和真实边界形状一致但整体偏移几个像素Dice比预期低。原因数据加载时对图像做了随机裁剪或缩放但mask没有做相同的仿射变换或者resize时mask用了双线性插值产生灰度过渡值。解决图像和mask共用同一个变换函数mask的resize和warp固定用INTER_NEAREST检查增强函数里是否对图像和mask做了完全相同的翻转和旋转。这个操作看似小事实际影响Dice 1到2个百分点。5.3 全部预测成背景输出一片黑现象训练loss不下降所有预测掩码全为0。原因极端类别不平衡下BCE损失被背景主导模型直接输出背景Dice损失在目标区域预测为空时分母为0梯度波动大也会使模型停在“全0”的鞍点。解决改用BCE加Dice混合损失bce_weight调到0.5再检查数据归一化CLAHE之后必须除以255否则输入幅度过大导致梯度爆炸模型直接学死。训练第一个epoch结束后就做一次验证可视化发现全黑立刻停不用等几十个epoch。5.4 训练中后期验证集Dice突然掉到0现象前面Dice接近0.9某个epoch后验证集Dice突然归零或者loss变成NaN。原因学习率过大会在后期震荡BatchNorm在batch size过小时方差波动大导致数值溢出也可能是某一批数据里出现空白mask把损失直接带崩。解决学习率调度换成ReduceLROnPlateau而不是固定值数据加载时过滤掉mask全部为空的样本损失函数里的smooth参数不要设成0保留1.0让数值更安全。5.5 换一个测试集就崩跨数据集Dice直接掉一半现象在RIM-ONE上Dice很好换到REFUGE测试集Dice直接崩到0.5。原因两个数据集的拍摄设备、分辨率、亮度补偿不同更重要的是标注边界定义可能不同。RIM-ONE的视盘可能包含巩膜环REFUGE的视盘只标注神经边缘。解决先在文档中明确“分割目标定义”。跨数据集评估时把两组标注统一到同一约定例如把视盘定义为“mask大于0”但CLAHE和归一化参数必须完全一致。很多同学只在一个数据集上跑了实验就下结论答辩时被问跨数据集表现就容易卡住。6. 高分课程设计的验收技巧后处理、可视化与文档编排6.1 后处理让预测掩码更干净神经网络输出的概率图直接取0.5阈值总会有零散小区域和空洞。简单可靠的后处理是形态学开闭运算加连通域筛选。视盘是单一连通区域只保留最大连通域视杯应该在视盘内部裁掉超出视盘的部分。这个后处理在文档里写清楚能体现工程意识。def postprocess(disc_prob, cup_prob): disc (disc_prob 0.5).astype(np.uint8) cup (cup_prob 0.5).astype(np.uint8) n, labels, stats, _ cv2.connectedComponentsWithStats(disc, 8) if n 1: disc (labels (np.argmax(stats[1:, 4]) 1)).astype(np.uint8) cup cv2.bitwise_and(cup, disc) # 视杯必须在视盘内 return disc, cup6.2 从源码到文档验收时怎么展示文档结构建议按需求分析、数据描述、算法设计、实验对比、结论五段走。算法对比部分不要只给一个U-Net至少加一行传统阈值分割的Dice作为对照组让“深度学习方法有效”有数据支撑。截图演示不能只给一张分割结果建议做一个四联图原图、真值、预测、两者误差。展示至少5个案例包括正常眼、青光眼、边界模糊的样本。我第一次做这个课题时把大量时间花在调网络结构最后发现数据预处理、mask对齐和后处理这些小坑才是决定Dice的关键。后来迭代任何图像分割项目都先用一条最小的数据流跑通再逐步加模块这样定位问题会快很多。希望这篇笔记能帮到你在课程设计里少走几个弯路。本文还有配套的精品资源点击获取
返回列表