简介基于Python的眼底图像视杯视盘分割项目定位为医学图像处理方向的高分课程设计与毕业设计源码包含血管、视盘、视杯的分割与可视化支持杯盘比vCDR计算、血管分支特征提取以及早产儿视网膜病变分类、青光眼分级等39类眼科疾病分类任务。资源共18个文件涵盖Python源码、ONNX推理模型、Markdown文档说明、PNG/JPG效果截图等压缩包仅9.22MB结构紧凑且便于按模块对照学习。已有345人学习下载代码经过完整测试并附带详细注释README文档与截图演示可辅助快速上手下载后若有运行疑问还可联系作者远程教学。项目既适合计算机、人工智能等专业学生作为课设或毕设参考也适合在此基础上扩展功能、开展相关课题研究。1. 基于 Python 的眼底图像视杯视盘分割课程设计选题里的高分路径基于 Python 的眼底图像视杯视盘分割是医学图像处理里少数数据公开、指标客观、演示效果好的课程设计方向。视盘是眼底照片中亮度最高的椭圆区域视杯是视盘中央更亮的凹陷两者分割完成后计算杯盘比CDR直接对应青光眼筛查的临床指标。任务以 U-Net 为基线配合 DRISHTI-GS 公开数据集预处理、建模、训练、评估全链路都能在一个课程设计周期内跑通。相比通用图像分类分割的中间结果——原图、真值掩膜、预测叠加图——天然适合截图演示答辩讲点充足一百张级别的数据量也让训练时间可控普通笔记本就能完成。2. 视杯视盘分割的技术选型传统方法为什么被 U-Net 替代2.1 阈值分割与形态学方法的三个致命伤第一个直觉方案是视盘最亮全局阈值就能提出来。实际操作会碰到三个问题眼底图像受光照和病变干扰严重出血点与硬性渗出在灰度直方图上和视盘边缘重叠固定阈值难以可靠分割视杯边缘没有明显的梯度突变神经视网膜边缘在部分样本中和背景接近同灰度形态学开闭运算只能消噪对结构性边界模糊无效每张图都要单独调阈值违背工程化原则。传统方法在 DRISHTI-GS 这类包含病变视野的公开集上视盘 Dice 通常落在 0.82-0.88 区间视杯更低拿这个成绩去答辩没有竞争力。2.2 编码器-解码器结构与感受野的取舍U-Net 成为医学分割事实标准的原因在于它同时保住了语义和细节两头。编码器四次下采样把 512x512 输入压到 32x32 特征图每次池化后感受野翻倍最后一层卷积能看到视杯周边大范围的视网膜纹理依据上下文推断杯缘位置解码器逐级上采样恢复分辨率跳跃连接把编码器对应层的边界细节拼回特征图。对视杯这种边界模糊的结构纯分类网络输出分辨率太低插值回去边缘全糊滑动窗口 patch 方法又缺少全局上下文。课程设计不需要冒险上 Attention 变体先把 U-Net 基准跑通拿到 Dice 数据再决定要不要做改进点。2.3 公开数据集选择与掩膜格式数据集规模特点适用场景DRISHTI-GS101 张50 训练 / 51 测试含视盘、视杯专家标注课程设计首选划分固定RIM-ONE多个子版本总量 150图像质量分级交叉验证或扩充训练集DRISHTI-GS 的掩膜是灰度 PNG前景 255、背景 0读取后必须二值化为 0/1。一个容易踩的坑部分版本掩膜是 RGB PNG直接用 IMREAD_GRAYSCALE 读会把三通道加权平均边缘出现灰色过渡带。选择数据集前先确认有没有视杯标注很多公开集只有视盘单独分割视盘和完整任务的分量完全不同。2.4 评价指标Dice、IoU 与 CDR 误差分割任务最容易犯的评估错误是只报 Accuracy。背景像素占比超过 95%模型全部预测背景也能拿 90% 以上的 Accuracy这个数字没有任何说服力。答辩要给的指标是 Dice、IoU 和 CDR 误差import numpy as np def dice_coef(pred, target, smooth1e-6): # pred 是概率图先二值化再统计 pred (pred 0.5).astype(np.float32).flatten() target target.astype(np.float32).flatten() inter np.sum(pred * target) return (2.0 * inter smooth) / (np.sum(pred) np.sum(target) smooth) def iou_coef(pred, target): pred (pred 0.5).astype(np.float32).flatten() target target.astype(np.float32).flatten() inter np.sum(pred * target) union np.sum(pred) np.sum(target) - inter return (inter 1e-6) / (union 1e-6)测试阶段用 0.5 硬阈值二值化再计算和训练阶段用的软 Dicesigmoid 概率直接参与计算是两套口径报告里要分别标注。flatten 后向量化统计避免了 Python 循环512x512 的掩膜批量计算也不会有性能问题。3. 眼底图像预处理与数据集加载Python 代码逐个拆解3.1 通道选择保留 RGB 还是只取绿色通道眼底图像预处理里流传很广的说法是绿通道对比度最高只取绿通道。实际做下来我建议保留三通道输入。第一U-Net 首层卷积本身会学习通道权重人为裁掉红蓝通道等于提前丢信息第二答辩时保留原始通道、让网络自己学比人工降维更好解释。如果时间充裕可以把通道选择做成消融实验写进文档说明但这不是必须项。掩膜读取有个容易被忽略的细节——彩色 PNG 掩膜直接灰度读会得到加权平均结果边缘发灰阈值化后出现锯齿。正确做法是彩色读入取单通道再阈值import cv2 import numpy as np def load_mask(path, threshold127): # 彩色读入后取单通道避免灰度加权导致边缘发灰 mask cv2.imread(path, cv2.IMREAD_COLOR) channel mask[:, :, 0] return (channel threshold).astype(np.uint8)3.2 CLAHE 参数clipLimit 与 tileGridSize 怎么设CLAHE对比度受限自适应直方图均衡是眼底图预处理的标准操作。它把图像分块各自做直方图均衡再用 clipLimit 限制对比度放大倍数防止噪声被同步放大。直接在 BGR 空间对三个通道分别处理会产生颜色畸变常见做法是转到 LAB 色彩空间只处理 L 亮度通道def clahe_preprocess(image, clip_limit2.0, tile(8, 8)): # LAB 空间只增强亮度通道避免颜色失真 lab cv2.cvtColor(image, cv2.COLOR_BGR2LAB) clahe cv2.createCLAHE(clipLimitclip_limit, tileGridSizetile) lab[:, :, 0] clahe.apply(lab[:, :, 0]) image cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) image image.astype(np.float32) / 255.0 mean image.mean(axis(0, 1), keepdimsTrue) std image.std(axis(0, 1), keepdimsTrue) 1e-8 return (image - mean) / std参数推荐范围影响clipLimit1.5 - 2.5过小等同于没增强过大把血管噪声一起放大tileGridSize8x8512 输入分块越小局部对比越强块边界伪影也越明显归一化放在 CLAHE 之后均值和标准差按每个通道单独统计。clipLimit 超过 4.0 时视杯内部的小血管会被错误增强成类似边界的结构直接影响训练。CLAHE 做完不做归一化也能喂网络但批间数据分布不一致会让 BatchNorm 的统计量漂移后期 loss 出现锯齿所以归一化一定要保留在预处理里。3.3 数据集类与增强策略albumentations 是医学分割项目里最省心的增强库同一个 Compose 同时处理图像和掩膜随机参数天然对齐不写同步逻辑from torch.utils.data import Dataset import albumentations as A import torch class FundusDataset(Dataset): def __init__(self, paths, mask_paths, size512, trainingTrue): self.paths paths self.mask_paths mask_paths self.size size self.training training self.aug A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit25, p0.8), A.RandomBrightnessContrast(p0.3), ]) def __len__(self): return len(self.paths) def __getitem__(self, idx): img cv2.imread(self.paths[idx]) mask load_mask(self.mask_paths[idx]) img cv2.resize(img, (self.size, self.size)) # 掩膜必须用最近邻插值双线性会生成灰色边缘 mask cv2.resize(mask, (self.size, self.size), interpolationcv2.INTER_NEAREST) if self.training: aug self.aug(imageimg, maskmask) img, mask aug[image], aug[mask] img clahe_preprocess(img) img_t torch.from_numpy(img).permute(2, 0, 1).float() mask_t torch.from_numpy(mask).unsqueeze(0).float() return img_t, mask_t掩膜 resize 用双线性插值是新手最常犯的错误模型学到的边界是糊的测试阈值化后直接出现假阳性。验证集实例化时要把 training 设为 False增强只作用在训练阶段否则验证集 Dice 每次都随随机变换抖动best_dice 对比就失真了。增强里 Rotate 和 RandomBrightnessContrast 对杯缘学习尤其重要眼底图拍摄角度和亮度差异本来就大。4. U-Net 源码实现与训练参数调优从结构到损失函数4.1 U-Net 每个模块在做什么U-Net 结构可以拆成四段理解。编码器由四个 DoubleConv MaxPool 组成每过一次池化特征图分辨率减半、通道数翻倍空间细节逐渐让位给语义信息bridge 是编码器和解码器的连接层通道数达到峰值解码器用双线性上采样恢复分辨率核心操作是把上采样结果和编码器对应层在通道维度拼接浅层边缘细节和深层语义在同一组卷积里融合最后 1x1 卷积把特征压缩成单通道 logits。512x512 输入经过四次下采样是 32x32如果没有跳跃连接视盘边缘这种高频信息在下采样过程中会丢得差不多。4.2 轻量 U-Net显存 6GB 也能跑下面这份源码把基础通道数设为 32参数量约 800 万512x512 输入、batch size 4 时显存占用在 6GB 级别。显存不够就把 base 降到 16Dice 损失通常在 0.01 以内import torch import torch.nn as nn class DoubleConv(nn.Module): 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_ch3, out_ch1, base32): super().__init__() self.enc1 DoubleConv(in_ch, base) self.enc2 DoubleConv(base, base * 2) self.enc3 DoubleConv(base * 2, base * 4) self.enc4 DoubleConv(base * 4, base * 8) self.pool nn.MaxPool2d(2) self.bridge DoubleConv(base * 8, base * 16) self.up nn.Upsample(scale_factor2, modebilinear, align_cornersFalse) # 解码层输入通道 上采样通道 跳跃连接通道 self.dec4 DoubleConv(base * 16 base * 8, base * 8) self.dec3 DoubleConv(base * 8 base * 4, base * 4) self.dec2 DoubleConv(base * 4 base * 2, base * 2) self.dec1 DoubleConv(base * 2 base, base) self.head nn.Conv2d(base, out_ch, 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.bridge(self.pool(e4)) d4 self.dec4(torch.cat([self.up(b), e4], dim1)) d3 self.dec3(torch.cat([self.up(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up(d2), e2], dim1)) d1 self.dec1(torch.cat([self.up(d2), e1], dim1)) return self.head(d1)这里用 bilinear 上采样而不是转置卷积原因是转置卷积多一组可学习参数在百张图片级别的小数据集上更容易过拟合bilinear 没有参数训练过程更平顺。解码层的通道拼接必须和编码器顺序严格对应写错维度会在 forward 阶段直接报错这是 U-Net 源码最常见的错误来源。4.3 损失函数BCE Dice 的组合逻辑视杯掩膜通常只占整张图的 3%-8%前景占比过小单独用 BCE 会让模型倾向全部预测背景。Dice Loss 直接优化目标指标但训练初期梯度不平滑对误分类的惩罚过重。组合写法取两者长处def combined_loss(logits, target): # BCE 提供平滑梯度Dice 直接对齐评估指标 bce nn.functional.binary_cross_entropy_with_logits( logits, target) probs torch.sigmoid(logits) inter (probs * target).sum(dim(1, 2, 3)) denom probs.sum(dim(1, 2, 3)) target.sum(dim(1, 2, 3)) dice 1.0 - (2.0 * inter 1.0) / (denom 1.0) return bce dice.mean()按样本维度分别算 Dice 再求均值比把整个 batch 摊开算更合理。如果 batch 里某张图的视杯特别小全局摊开的计算方式会把这个样本的误差稀释掉模型对难样本的拟合能力变差。4.4 训练参数表照着设就能收敛参数推荐值调整依据输入尺寸512x512低于 384 时视杯边界信息不足batch size46GB 显存常规值显存溢出就降到 2优化器AdamWlr3e-4权重衰减 1e-4正则效果优于 Adam学习率策略CosineAnnealingT_maxepoch固定 lr 后期会在最优值附近震荡epoch10050 张训练图100 epoch 足够收敛保存策略验证集 Dice 最高的权重最后一个 epoch 往往已经过拟合4.5 训练主循环与模型保存model UNet().cuda() optimizer torch.optim.AdamW(model.parameters(), lr3e-4) scheduler torch.optim.lr_scheduler.CosineAnnealingLR( optimizer, T_max100) best_dice 0.0 for epoch in range(100): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda() optimizer.zero_grad() loss combined_loss(model(img), mask) loss.backward() optimizer.step() # 每步更新权重 scheduler.step() model.eval() with torch.no_grad(): val_logits model(val_images.cuda()) val_dice dice_coef(torch.sigmoid(val_logits).cpu().numpy(), val_masks.numpy()) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), unet_best.pth)验证集要固定为同一批样本不要每个 epoch 随机重采样否则 best_dice 之间的比较没有意义。DRISHTI-GS 官方 51 张测试图可以直接作为验证集与训练集分开目录存放。提示依赖版本建议 Python 3.8、PyTorch 1.12、OpenCV 4.5。先用 CPU 跑一个 epoch 确认数据管线没有问题再切换 GPU能省去大半环境排查时间。5. 答辩加分项掩膜转 CDR 计算与可视化验证5.1 三行代码把分割结果变成临床指标评审看 Dice 只能判断分得好不好看不出医学意义。视杯视盘分割的最终价值在杯盘比 CDR——视杯直径与视盘直径的比值临床上常以 0.5 作为青光眼筛查参考阈值。对预测掩膜做最小外接圆拟合CDR 就出来了import cv2 def cdr_from_mask(cup_mask, disc_mask): def outer_diameter(mask): contours, _ cv2.findContours( mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if not contours: return 0.0 main max(contours, keycv2.contourArea) # 取最大轮廓抗假阳性 (_, radius) cv2.minEnclosingCircle(main) return radius * 2.0 cup_d outer_diameter(cup_mask) disc_d outer_diameter(disc_mask) return cup_d / disc_d if disc_d 0 else 0.0掩膜是二值图findContours 找外轮廓后取面积最大的一个即使分割结果里带一两块小假阳性也不会污染直径计算。在测试集 51 张图上逐张计算 CDR和真值掩膜算出的 CDR 做差得出平均绝对误差这个数字放进文档说明比贴十张 Dice 表格更有临床说服力。5.2 叠加可视化一图看懂错在哪单独贴分割结果评审只能看到分出来了。把预测掩膜半透明叠加到原图上视杯边缘是越界还是缺角、视盘边界是否贴合一眼就能定位。我一般把原图、真值掩膜、预测掩膜、叠加图拼成四联图批量输出测试集前 10 张选作常规展示再单独挑 Dice 最低的 5 张作为错例分析在文档说明里写一段失败原因——光照过暗、病变遮挡、视杯边界对比度过低。数据完整且诚实比全报高分的表格更容易让评审信任。本文还有配套的精品资源点击获取
