资讯动态

基于Python与U-Net的眼底图像视杯视盘分割实战

发布时间:2026/10/1 12:12:59 来源:尧图企业网站定制
简介基于Python的眼底图像视杯视盘分割项目面向计算机、人工智能等专业课程设计或毕业设计场景提供完整源码、文档说明与截图演示。项目实现了视杯、视盘及血管的自动分割与可视化支持杯盘比vCDR计算、血管分支特征提取并包含早产儿视网膜病变分类、青光眼分级及39类眼科疾病辅助识别功能代码均测试通过且附有详细注释适合医学图像处理方向的学习与二次开发。压缩包共18个文件以PNG/JPG截图、ONNX分割模型、Python脚本和Markdown文档为主总大小约9.22MB结构清晰便于快速查看项目效果与运行逻辑。目前已有348人学习下载对于想要完成高分课设或入门眼底图像分析的同学这份资源可帮助理解分割模型调用、特征计算与界面可视化等关键环节也可在原有基础上修改拓展。1. 这个课程设计题目到底在做什么一张眼底图两个目标区域当你在课程设计选题列表里看到“基于python的眼底图像视杯视盘分割”时第一反应多半是“这是不是太难了”。实际上这个题目的完成难度被两个因素放大了一是医学图像的听感给人距离感二是很多人一上来就想着要复现论文里的SOTA网络。真实的课程设计要求没那么高——你要做的是把眼底图像里视盘和视杯这两个解剖结构用算法圈出来并且让人一眼看出圈得准不准顺带把精度指标写清楚。这个任务在深度学习时代已经被U-Net这类分割网络解决得很成熟Python生态里有现成的工具链支撑数据也有公开集可用。做完它的核心价值在于你能走通一条“图像预处理→模型训练→指标评估→结果可视化”的完整链路这比很多花哨的课题更能体现工程能力。适合计算机、生物医学工程、电子信息方向需要一份能讲清楚原理、能现场跑通、能拿高分的课程设计或毕业设计的人。我会从方案选型、数据准备、模型结构、训练调参到最后的文档包装把这条链路拆成可复现的步骤和参数再列出这个题目最常见的几个翻车点。整个过程不依赖实验室的特殊条件有一张普通NVIDIA显卡甚至用CPU也能跑通一个缩水版就能完成。2. 视杯和视盘是什么任务拆解与方案选型的理由2.1 分割结果怎么用杯盘比是青光眼筛查的关键指标视盘在眼底彩色照片里是那个颜色偏亮、近似椭圆形的区域它是视神经纤维汇聚离开眼球的出口。视杯则是视盘中心颜色更亮、更凹陷的部分。临床上医生会测量视杯直径与视盘直径的比值也就是杯盘比CDR。正常人的这个比值一般小于0.5如果持续大于0.6~0.7就是青光眼的典型风险信号。所以这个分割任务的输出并不是“画个圈好看”而是要为CDR计算服务。这决定了你的技术方案必须有边界精度——视杯边界哪怕偏了几个像素CDR的数值就会明显变化。这也是为什么不能用简单的阈值法眼底照片的亮度分布极不均匀视杯边缘和周围组织的对比度经常低到肉眼都难以分辨。2.2 为什么直接用深度学习传统方法在低对比度边界上无能为力课程设计里最常见的偷懒方案是先用灰度化、Canny边缘检测、霍夫圆检测来找视盘。这套打法在对比度好的图像上能画出视盘的大致轮廓但遇到视杯就彻底失灵——视杯的特征不是“边缘梯度变化明显”而是“颜色和纹理的相对差异”有时两者对比度非常低。常见做法是用分割网络直接对每个像素分类输入是RGB眼底图输出是和输入同尺寸的概率图代表该像素属于视杯或视盘的概率。U-Net是这个任务里最稳妥、最常被验证的选择。它的优势在于编码器-解码器结构编码器逐层下采样提取语义特征解码器逐层上采样恢复空间分辨率同时用跳跃连接把浅层细节传给深层。眼底图像里视盘边界虽然模糊但位置相对固定大小相对一致这种“全局位置靠语义、局部边界靠细节”的任务正好是U-Net的强项。2.3 Python技术栈怎么选PyTorch是当前课程设计的主流选择这个项目标题明确锁定了Python。具体到深度学习框架我的选择是PyTorch而不是TensorFlow原因有三个第一PyTorch的调试体验更直观训练到一半出问题print一个tensor的shape就能定位这对课程设计阶段非常重要第二网上能找到的U-Net参考代码大部分是PyTorch写的遇到问题检索成本低第三它和NumPy的互操作很自然便于你用OpenCV做预处理、用Matplotlib做可视化。环境搭建时有一个要注意的点不要一上来就装最新版Python。PyTorch对Python版本有版本兼容窗口我一般建议Python 3.8~3.10之间选一个配合PyTorch 1.13~2.x。如果你已经装了3.11以上碰到某些依赖包编译失败的概率会明显上升。安装顺序是先装CUDA版PyTorch再装OpenCV、NumPy、Matplotlib、scikit-learn最后装Jupyter Notebook或VSCode的Python插件。建议用conda分配独立环境避免把系统Python搞乱——这个坑我在多个项目里见过尤其是之前装过其他深度学习项目的人。3. 数据集准备与预处理分割效果的上限在数据这一层就决定了3.1 公开数据集怎么选RIM-ONE、DORIGA、Drishti-GS各自的特点课程设计不建议自己采集眼底图像公开数据集足够用。这个领域有几个常见的公开数据源数据集图像规模有无视杯视盘标注适合课程设计的原因RIM-ONE有多个版本一百至几百张不等有且区分左右眼文件组织清晰标注是独立maskORIGA约650张有规模相对大类别均衡论文常用Drishti-GS约100张有专家多次标注正常与青光眼都有可做对比分析选哪个取决于你机器能跑多快。RIM-ONE的数据量小CPU训练也能在合理时间内跑完如果你有NVIDIA显卡建议直接用ORIGA数据量更充足指标结果更有说服力。要注意的是不同数据集的标注格式不完全一样。有的mask是整块区域用白色填充的二值图有的则只是轮廓线或灰度值区分不同结构。拿到数据后第一件事是写个脚本把原图和mask叠在一起可视化确认视盘和视杯在mask里分别对应什么像素值。这个步骤到此为止我还没见过有人绕过它不出问题的你的代码再正确数据理解错了后面全白做。3.2 预处理流水线实现ROI裁剪、CLAHE、归一化一步不少预处理的目标有三个减小计算量、缓解类别不平衡、让模型更容易学到对比度特征。眼底原图通常是2448×2050像素左右的大图直接塞进网络不但显存不够而且背景区域占了大部分模型会严重偏向预测背景。所以第一步是裁剪出视盘所在的ROI区域。import cv2 import numpy as np def preprocess(image_path, mask_path, roi_ratio0.6, target_size(512, 512)): # 读取彩色眼底图和灰度mask image cv2.imread(image_path) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) h, w image.shape[:2] # 取以图像中心为中心、长宽各占roi_ratio的矩形区域 # 眼底相机拍摄时视盘通常位于图像中心偏上/偏外侧中心裁剪是常见做法 y0 int(h * (1 - roi_ratio) / 2) y1 int(h * (1 roi_ratio) / 2) x0 int(w * (1 - roi_ratio) / 2) x1 int(w * (1 roi_ratio) / 2) image image[y0:y1, x0:x1] mask mask[y0:y1, x0:x1] # mask统一为0/1先用阈值把背景和目标分开再转float32 mask (mask 127).astype(np.float32) # CLAHE对比度受限自适应直方图均衡化增强视杯/视盘与周边的对比度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) for c in range(3): image[:, :, c] clahe.apply(image[:, :, c]) # 缩放到统一尺寸mask必须用最近邻插值防止产生非0/1的中间值 image cv2.resize(image, target_size) mask cv2.resize(mask, target_size, interpolationcv2.INTER_NEAREST) # 图像归一化到[0,1]保持float32 image image.astype(np.float32) / 255.0 return image, mask这段代码里的两个核心参数需要注意。roi_ratio0.6通常能覆盖视盘及周边足够多的血管参考区域但如果你用的数据集视盘位置偏离中心直接套用会切掉目标要先做一次全局可视化确认所有图的视盘都在中心区域内。target_size(512, 512)是一个通用选择U-Net下采样4次后特征图是32×32信息容量足够如果显存紧张可以降到384或256但指标会小幅下降。mask的resize插值方式必须是INTER_NEAREST这是容易忽略的关键点。3.3 数据增强图像和mask必须走同一套变换眼底图像的数据增强不能乱来。水平翻转可以用因为左右眼的视盘结构本来就是镜像对称的垂直翻转需要谨慎因为拍摄时眼球位置决定了图像上下方向有解剖学含义翻转会破坏先验。最稳妥的是用随机旋转±15度以内、水平翻转、小范围的缩放和位移以及轻微的亮度对比度扰动。import random def train_transform(image, mask): # 随机水平翻转 if random.random() 0.5: image image[:, ::-1, :] mask mask[:, ::-1] # 随机旋转±15度 angle random.uniform(-15, 15) h, w mask.shape M cv2.getRotationMatrix2D((w // 2, h // 2), angle, 1.0) image cv2.warpAffine(image, M, (w, h), flagscv2.INTER_LINEAR) mask cv2.warpAffine(mask, M, (w, h), flagscv2.INTER_NEAREST) return image, mask核心原则是图像和mask必须用完全相同的变换参数否则训练时模型会看到“错位的标签”损失函数永远降不下去。warpAffine里图像用INTER_LINEAR保平滑mask用INTER_NEAREST保语义不变。增强后的数据在训练过程中实时生成不要提前离线存成文件这样既能无限生成新样本又不占磁盘空间。4. 训练一个U-Net从模型结构到关键参数调优4.1 一个能在课程设计里直接用U-Net实现主体结构与跳跃连接这里给出一份不依赖任何第三方分割库的PyTorch实现结构是经典的四层U-Net。输入是3通道眼底图输出是1通道logits经过Sigmoid后得到前景概率图。import torch import torch.nn as nn class DoubleConv(nn.Module): 两个卷积BNReLU组成的模块U-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) class UNet(nn.Module): def __init__(self, in_channels3, out_channels1, base64): super().__init__() self.enc1 DoubleConv(in_channels, base) # 64 self.enc2 DoubleConv(base, base * 2) # 128 self.enc3 DoubleConv(base * 2, base * 4) # 256 self.enc4 DoubleConv(base * 4, base * 8) # 512 self.pool nn.MaxPool2d(2) self.bottleneck DoubleConv(base * 8, base * 16) # 1024 self.up4 nn.ConvTranspose2d(base * 16, base * 8, 2, stride2) self.dec4 DoubleConv(base * 16, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.dec3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.dec2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.dec1 DoubleConv(base * 2, base) self.out nn.Conv2d(base, out_channels, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) e3 self.enc3(self.pool(e2)) e4 self.enc4(self.pool(e3)) b self.bottleneck(self.pool(e4)) d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)代码里几个关键点base64表示第一层卷积的输出通道数通道数每下采样一次翻倍直到瓶颈层1024。torch.cat是U-Net的精髓把下采样路径上相同尺寸的浅层特征拼接到深层特征上这样解码器既能利用高层语义又能利用浅层的细致边缘信息。ConvTranspose2d负责上采样kernel size为2、stride为2正好是特征图尺寸翻倍。如果你显存吃紧把base改成32能显著降低内存占用代价是分割精度会下降几个点。4.2 损失函数为什么不能只用BCEDice Loss怎么加眼底图像里视盘区域占整张图的比例很小ROI裁剪后大概占15%~25%视杯就更小通常只有5%~10%。直接用二值交叉熵BCE模型会倾向于把所有像素都预测为背景因为这样loss已经很低了。常见做法是BCE和Dice Loss组合使用。def dice_loss(pred, target, smooth1.0): pred是经过sigmoid的概率图target是0/1的mask pred pred.contiguous().view(-1) target target.contiguous().view(-1) intersection (pred * target).sum() return 1 - (2.0 * intersection smooth) / (pred.sum() target.sum() smooth) def combined_loss(pred_logits, target): bce nn.BCEWithLogitsLoss()(pred_logits, target) pred_prob torch.sigmoid(pred_logits) dice dice_loss(pred_prob, target) return bce dice关注一下combined_loss的设计它接收的是未经过Sigmoid的logits因为BCEWithLogitsLoss在内部做了Sigmoid和BCE的结合数值上比分开算更稳定。Dice Loss需要的是概率值所以这里单独对logits做一次torch.sigmoid。权重比例直接相加即可不需要刻意调权重二者的loss量级是接近的。如果你只想分割视盘一个目标这套组合可以覆盖大部分课程设计场景。4.3 训练参数配置学习率、Batch Size、Epochs、模型保存def train_one_epoch(model, loader, optimizer, device): model.train() total_loss 0.0 for imgs, masks in loader: imgs imgs.to(device) # (B, 3, 512, 512) masks masks.to(device) # (B, 1, 512, 512) preds model(imgs) loss combined_loss(preds, masks) optimizer.zero_grad() loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(loader) def validate(model, loader, device): 验证集上计算平均Dice用于挑选best model model.eval() dice_sum 0.0 with torch.no_grad(): for imgs, masks in loader: imgs imgs.to(device) masks masks.to(device) preds torch.sigmoid(model(imgs)) 0.5 # 阈值0.5转成二值 preds preds.float() dice_sum dice_loss(preds, masks).item() # 这里dice_loss返回的是1-dice return 1 - dice_sum / len(loader)训练超参数我建议的起点是batch_size8512×512输入时8已经是大多数显存的稳定值、learning_rate1e-4、epochs100、优化器AdamWweight_decay1e-5。学习率调度用ReduceLROnPlateau如果验证Dice连续5个epoch不涨就把学习率乘0.5。每个epoch末尾跑一次验证保存验证Dice最高的模型权重这就是你的“后悔药”——训练到后期过拟合了也不用怕随时可以回退到之前的最佳状态。5. 评估指标与避坑排查结果能不能上论文就看这五个细节分割任务的评估不能只看loss。课程设计答辩时老师一定会问“你的分割精度是多少”你至少要能报出两个指标Dice系数DSC和交并比IOU。def dice_coef(pred_mask, true_mask, eps1e-7): pred_mask和true_mask都是0/1的numpy数组 intersection np.sum(pred_mask * true_mask) return (2.0 * intersection eps) / (np.sum(pred_mask) np.sum(true_mask) eps) def iou_score(pred_mask, true_mask, eps1e-7): intersection np.sum(pred_mask * true_mask) union np.sum(pred_mask) np.sum(true_mask) - intersection return (intersection eps) / (union eps)Dice衡量的是预测和真实区域的重叠程度IOU更严格对边界像素的差异惩罚更大。用这两个指标配合可视化结果一起展示说服力足够。下面列的是我从这个题目里踩过和见过别人踩的坑按“现象→原因→解决”写清楚。5.1 避坑记录1mask直接除以255训练出来的结果全黑现象训练loss下降但分割结果全预测背景Dice接近0。原因数据集的mask不是0和255的二值图而是0和1的灰度图或者反过来是0和255但你在代码里除以了255后得到0和1的浮点值。还有的mask用1和2表示视盘和视杯直接阈值就丢了一个类别。解决拿到数据集后不要急着开训练先写三行代码打印mask的像素值集合np.unique(mask)再可视化叠加到原图上确认。统一转0/1时先mask 127做阈值再.astype(np.float32)。5.2 避坑记录2CUDA out of memory在第三个epoch准时出现现象训练到一半进程崩溃报错CUDA out of memory。这跟代码本身无关纯粹是显存不够。原因显存占用主要来自三部分——网络参数、梯度、中间激活值。batch_size16加上512×512输入在8GB显存卡上很容易超。解决把batch_size降到4或8如果还不够把输入尺寸改成384×384还不行就用梯度累积每两步反传一次效果上等价于batch_size翻倍。以上三个方案按优先级尝试不建议换更小的模型U-Net缩太多会影响精度。5.3 避坑记录3训练轮数够了但视杯和视盘的边界马赛克状锯齿严重现象分割结果整体区域重合度不错但边界像是被压缩过的低分辨率图视觉上很粗糙。原因下采样次数太多或者解码器上采样时没有把深层和浅层特征充分融合。四层下采样的U-Net在512输入下最深层只有32×32如果目标边界精细细节就丢了。解决确认输入的target_size不是256256对眼底分割来说偏小或者调整数据增强里的旋转角度让模型见过更多不同姿态的边界形态。还有一个技巧是训练后在预测时做水平翻转TTA测试时增强把原图和翻转图的预测结果平均一下边界通常会更平滑。5.4 避坑记录4验证Dice在0.85左右就上不去了卡死现象训练到中期验证指标平稳不再提升调整学习率也没有明显效果。原因要么是光照差异大的样本学不够要么是数据量太小导致过拟合到训练集的特征上。解决先用可视化检查预测错误的样本集中在哪种图像上——偏暗、偏模糊、还是视盘在边缘位置的。针对暗图可以在预处理里加入自适应直方图均衡化的变体如果数据量少于100张可以加入更激进的数据增强或者在训练时对前景区域做随机裁块放大训练。5.5 避坑记录5换数据集后指标崩掉且无法复现现象在RIM-ONE上训练好的权重直接拿ORIGA的数据测试Dice从0.88掉到0.5以下。原因不同眼底相机的色彩响应不同图像分辨率也不同训练集和测试集的数据分布偏移明显。这就是典型的域差异问题课程设计里很容易被忽视。解决如果课程设计本质上只需要在单一数据集上验收那“训练集和测试集要同分布”这件事必须在文档里写清楚。如果想让结果更扎实可以做一个简单的跨数据集验证用ORIGA训练RIM-ONE测试把指标差距写到说明文档里并分析差距来源。这在答辩时反而是一个亮点说明你理解模型的泛化边界。6. 把课程设计做出高分品格文档结构、代码注释与截图演示的规范6.1 注释怎么写不逐行加注释在类、函数和关键逻辑处给说明课程设计的源码注释最忌两种极端一种是一行注释都没有另一种是每一行都写“把x赋给y”这种废话。老师看注释看的是你能不能用自然语言讲清楚设计意图。class FundusDataset(Dataset): 眼底图像数据集封装。 读取图片和mask对执行统一的预处理和数据增强。 返回(image, mask)两者形状分别为(3, H, W)和(1, H, W)。 def __init__(self, image_paths, mask_paths, transformNone): self.image_paths image_paths self.mask_paths mask_paths self.transform transform def __getitem__(self, idx): image, mask preprocess(self.image_paths[idx], self.mask_paths[idx]) # 注意mask需要增加通道维度(H, W) - (1, H, W) mask mask[np.newaxis, ...] return image.transpose(2, 0, 1), mask类级别的docstring写清楚“这个类是什么、输入输出是什么”函数里的注释只在该有判断的地方写“为什么”。上面的mask[np.newaxis, ...]这行非常关键初学者最容易在这里漏掉维度那一句注释就能帮你拿到印象分。6.2 文档说明怎么组织六个章节固定图表比代码更有说服力课程设计文档不需要花哨但结构要完整。常见做法是按下面这个顺序组织章节必须包含的内容项目背景与意义青光眼筛查背景杯盘比概念方案设计为什么选U-Net整体流程图数据集与预处理数据来源、图像规格、预处理可视化实验结果与分析Dice/IOU指标表格、典型结果图运行说明环境版本、目录结构、运行命令总结与改进方向遇到的问题、可以继续做的优化这里有一个容易被忽略的细节运行说明一定要写清楚Python和依赖库的版本比如Python 3.9 PyTorch 1.13 CUDA 11.7。老师如果真去复现版本对不上报错成果质量在细节上就打了折扣。6.3 截图演示至少准备四张图叠图比裸mask更直观答辩现场的演示截图至少要有四张原图、视盘标注mask、模型预测mask、原图与预测结果叠加图。叠加图最有说服力因为你能直接看到预测边界和真实解剖结构的位置关系。import matplotlib.pyplot as plt def show_overlay(image, pred_mask, true_mask, save_path): 原图和预测mask叠加可视化 fig, axes plt.subplots(1, 3, figsize(15, 5)) axes[0].imshow(image); axes[0].set_title(Original) axes[1].imshow(image); axes[1].imshow(pred_mask, alpha0.5) # 半透明叠加 axes[1].set_title(Prediction Overlay) axes[2].imshow(image); axes[2].imshow(true_mask, alpha0.5) axes[2].set_title(Ground Truth Overlay) plt.savefig(save_path, dpi150, bbox_inchestight)这里的核心技巧是把mask用alpha0.5半透明叠加在原图上而不是单独显示一张黑底白块。因为单独看mask非专业人士很难对应到眼底图像的结构位置老师可能看不懂你的分割质量。6.4 一个短时间内能拉开差距的进阶点如果基础部分都做完了想给答辩加一个亮点可以尝试把视盘和视杯作为一个两类分割任务同时输出。具体做法是把out_channels从1改成2一个通道学视盘、一个通道学视杯损失函数对两个通道分别算Dice Loss后相加。这样你的模型学到的不再是孤立的两个结构而是它们之间的相对位置关系——这是一种从“分割”走向“结构感知”的转变。训练前把同一样本的视盘map和视杯map拆成两个通道叠在一起即可。加上这个改动你的项目说明文档里就能多写一小节“多任务结构化输出”这也比单纯调大U-Net的层数更能体现设计感。顺便说一个我的个人习惯不管项目时间多紧我都会在训练完成后花半小时做一次失败样本分析把预测最差的5张图打印出来找出它们的共性是偏暗、偏糊还是视盘偏小。这个习惯让我在答辩时每次都能说出“这个方案的下一个改进点在哪里”而不是等老师来问。希望这个思路帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑