眼底图像视杯视盘分割实战:从数据预处理到CDR计算全解析
简介这是一份基于Python的眼底图像视杯视盘分割项目源码面向医学图像处理、计算机视觉方向的课程设计、期末大作业与入门实践。项目围绕眼底图像视杯、视盘自动分割这一核心任务展开提供完整可运行的高分方案经导师指导并获得97分适合需要参考整体架构或直接改编使用的学生。压缩包内共198个文件以156个Python脚本为主体承担模型结构搭建、训练与推理等核心逻辑另有39个pyc编译文件与3个Markdown说明文档整体仅451KB目录精炼易读。内容覆盖眼底图像预处理与数据增强、基于DeepLab思路的分割网络、注意力机制组装模块以及简明说明文档便于对照代码梳理视杯视盘分割流程。已有418人学习浏览可作为课程设计、期末项目复现及进一步改进的起点。1. 眼底图像视杯视盘分割源码到手之后先别急着解压拿到“基于python的眼底图像视杯视盘分割项目源码高分项目.zip”这类文件默认它是毕设或课程设计产物。标题背后其实是一套标准医学图像分割流程用 python 读入眼底彩照通过深度学习模型生成视盘optic disc和视杯optic cup的像素级掩码再计算杯盘比CDR辅助青光眼筛查。这类项目真正的门槛不在网络结构多新而在数据流的每一步是否自洽标签二值化、ROI 裁剪、损失函数配比、后处理顺序以及评估口径任何一环错位都会让最终指标变得不可信。本篇文章按“数据 → 模型 → 后处理 → 评估 → 交付”这条线把源码拆开讲新手能按参数照做熟手能直接看到哪些地方值得替换、哪些地方是成本黑洞。适合正在做医学图像课设、准备相关方向毕设答辩以及想把分割项目从“能跑”推到“能交付”的人。2. 先把数据捋顺眼底图像预处理与 ROI 裁剪的三个关键参数任何分割项目的成败一半由数据决定。眼底图像和自然图像最大的区别在于光照不均匀、角膜反光、血管纹理密集而且医生关心的视盘视杯只占整张图的一小部分。所以拿到源码后的第一件事不是看模型而是把预处理链路逐行过一遍。2.1 标签读法把灰度掩码变成模型能用的 0/1 张量大多数眼底数据集给的是 PNG 格式的灰度掩码前景标记为 255背景为 0。但在工程实现里模型输出层通常接 Sigmoid用 0~1 的浮点数做监督如果直接把 255 输入损失函数数值范围与输出不匹配BCE 会算出一个偏高但完全没意义的损失。更隐蔽的是有些标注工具生成的掩码带抗锯齿边缘像素是 127 或 200 之类的中间灰度直接astype(bool)会把半透明边缘丢掉。import cv2 import numpy as np def load_fundus_sample(img_path, disc_path, cup_path): # 眼底彩照用彩色图读入 img cv2.imread(img_path, cv2.IMREAD_COLOR) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 视盘和视杯掩码都是灰度图 disc cv2.imread(disc_path, cv2.IMREAD_GRAYSCALE) cup cv2.imread(cup_path, cv2.IMREAD_GRAYSCALE) # 抗锯齿边界用 127 做分界前景统变为 1 disc (disc 127).astype(np.uint8) cup (cup 127).astype(np.uint8) return img, disc, cup这里 127比 0稳原因是边缘抗锯齿像素不该被当成完整前景。读入后马上验证np.unique确认掩码只有 0 和 1再进入后续流程。很多源码在这步偷懒直接读原始值训练最终表现就是损失下降但验证集 Dice 上不去——这是典型的数据类型问题不是模型问题。2.2 ROI 裁剪以视盘质心为中心外扩 1.2 倍视盘直径整张眼底图的分辨率通常在千万像素级别直接输入网络既消耗显存又把大量无关区域交给模型。视盘和视杯在图像中央偏外侧周边是视网膜血管和暗角这些区域对分割任务是纯噪声。常见做法是先通过视盘掩码算出质心和半径再裁一个正方形区域。def crop_roi(img, disc_mask, pad_ratio1.2): ys, xs np.where(disc_mask 0) cy int((ys.min() ys.max()) / 2) cx int((xs.min() xs.max()) / 2) r max(ys.max() - ys.min(), xs.max() - xs.min()) // 2 r int(r * pad_ratio) h, w img.shape[:2] x1, y1 max(cx - r, 0), max(cy - r, 0) x2, y2 min(cx r, w), min(cy r, h) return img[y1:y2, x1:x2], disc_mask[y1:y2, x1:x2]pad_ratio是这里最值得调的参数。取 1.0 时 ROI 刚好贴着视盘边缘视杯一旦略大就会被切出去取 1.5 时背景占比过高模型容易学会“中间是盘、四周是背景”的偷懒模式。我一般取 1.2既能保留视盘外围一圈过渡区又不引入太多血管干扰。裁剪后如果 ROI 不是正方形后续 resize 会变形建议在裁剪时强制按2*r的边长取正方形超出边界部分用边界像素反射填充。2.3 CLAHE 与归一化眼底图像的对比度分布极不均匀眼底图像受拍摄设备影响常见中间亮四周暗的渐晕或者局部反光过曝。直接用全局均值和方差做归一化只能解决整体亮度偏移解决不了局部对比度不足。CLAHE限制对比度自适应直方图均衡化是眼底图像预处理里性价比最高的一步。def apply_clahe(roi_rgb, clip_limit2.0, tile8): lab cv2.cvtColor(roi_rgb, cv2.COLOR_RGB2LAB) clahe cv2.createCLAHE(clipLimitclip_limit, tileGridSize(tile, tile)) lab[:, :, 0] clahe.apply(lab[:, :, 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)clipLimit控制对比度拉伸的强度2.0 是一个保守又不失效的起点。调太大比如 5.0 以上会把血管和反光边缘一起拉伸成伪边界模型学到的纹理和医生眼里的结构完全不是一回事。另一个关键是只在 L 通道上做AB 通道保持原样否则颜色信息会被破坏。做完 CLAHE 再除以 255 或按 ImageNet 均值方差归一化。如果模型是从零训练直接用(x - 127.5) / 127.5就够预训练权重才需要匹配原归一化参数。2.4 数据增强视杯视盘分割的增强要克制分割任务里增强是把双刃剑。随机旋转、水平垂直翻转、轻微亮度抖动对眼底图是安全的但强随机裁剪、大尺度缩放、弹性形变会破坏视盘与视杯的相对位置关系。杯盘比是一个解剖结构比例过度形变等于篡改标注。推荐用 albumentations 同时变换图像和两张掩码。import albumentations as A transform A.Compose([ A.RandomRotate90(p0.5), A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.2), ], additional_targets{ disc_mask: mask, cup_mask: mask })additional_targets这个参数是 albumentations 的同步关键忘记配置会导致图像翻了而掩码没翻训练过程不报错但指标永远异常。旋转角度限制在 90 度倍数以内避免视盘被旋转成非生理角度。增强的目标是提升泛化不是制造新样本所以幅度必须克制。3. 模型与训练U-Net 选型、损失函数和优化器怎么设预处理做完接下来是模型选型和训练策略。这一章回答两个问题为什么这类项目大多用 U-Net损失函数和训练超参怎么设才能在有限数据下不翻车。3.1 为什么选 U-Net 而不是 DeepLabV3 或 Transformer眼底图像公开数据集规模普遍不大去掉标注质量差的样本后能用于训练的往往只有几百到一千张。这个数据量撑不起 Transformer 这类需要大数据预训练的结构DeepLabV3 虽然多尺度能力更强但参数多、收敛慢在小型数据集上容易过拟合。U-Net 的跳连接结构天然适合这个任务下采样路径捕捉全局上下文上采样路径把边缘细节逐级恢复而视盘视杯的分割恰恰依赖边界清晰度。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue), nn.Conv2d(out_channels, out_channels, 3, padding1, biasFalse), nn.BatchNorm2d(out_channels), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x)padding1保证卷积不改变特征图尺寸biasFalse是因为后面接 BatchNorm偏置会被归一化抵消。U-Net 的编码器逐层下采样四次感受野覆盖整个 ROI解码器通过跳连接把浅层边缘信息拼回来。对两个任务杯、盘共用同一个编码器是常见做法输出层接两个通道的 Sigmoid比训练两个独立模型更省显存还能让编码器同时学到盘和杯的共性特征。3.2 损失函数BCE Dice视杯小目标为什么不能只用 BCE视杯在 ROI 里占比很小有些样本可能只有几个百分点。普通 BCE 会把绝大多数梯度分配给背景模型很快学会“全预测为背景”也能拿到低损失但 Dice 完全无意义。所以这类项目几乎不会只用 BCE而是配一个 Dice 损失强行拉高前景重叠率。def dice_loss(pred, target, smooth1e-6): pred pred.contiguous().view(pred.size(0), -1) target target.contiguous().view(target.size(0), -1) intersection (pred * target).sum(dim1) dice (2.0 * intersection smooth) / ( pred.sum(dim1) target.sum(dim1) smooth ) return 1.0 - dice.mean()smooth防止分子分母都为 0 时除零。dice_loss返回的是1 - dice所以越小代表重叠越高。实际训练里我会把总损失设为0.5 * BCE 0.5 * Dice其中 Dice 分别对杯、盘两个通道计算再取平均。如果发现视杯漏检严重可以把 Dice 权重提到 0.7或者换成 Tversky Loss 调整假阳和假阴的惩罚比例但不要同时堆 Focal、Boundary、Hausdorff 三四个损失调参成本远大于收益。3.3 完整训练管线batch size、学习率、早停、验证划分训练循环本身不复杂坑在超参和验证策略。输入统一 resize 到 512×512batch size 4优化器 Adam初始学习率 1e-4。每轮验证算视盘和视杯的平均 Dice保存最优权重连续 10 轮不提升就早停。配置项推荐值说明输入尺寸512×512保留边界细节显存不够可降到 448batch size4~8ROI 裁剪后显存压力小尽量不低于 4优化器Adam收敛稳定比 SGD 好调初始学习率1e-4过大会振荡过小收敛太慢调度策略ReduceLROnPlateau验证损失平台期减半早停patience10以验证 Dice 为基准损失权重0.5 BCE 0.5 Dice杯盘分别算 Dice 再平均for epoch in range(max_epochs): model.train() for imgs, discs, cups in train_loader: pred_disc, pred_cup model(imgs) loss_disc 0.5 * bce(pred_disc, discs) dice_loss(pred_disc, discs) loss_cup 0.5 * bce(pred_cup, cups) dice_loss(pred_cup, cups) loss loss_disc loss_cup optimizer.zero_grad() loss.backward() optimizer.step() val_dice evaluate(model, val_loader) if val_dice best_dice: torch.save(model.state_dict(), best_model.pth) best_dice val_dice验证集划分要按眼底图像 ID 分不能让同一只眼的两个裁剪图同时出现在训练集和验证集。医学图像数据往往同一病人有多张图随机划分会造成信息泄漏验证指标虚高。训练过程中定期保存叠加可视化图比只看数值更能发现问题。4. 从分割图到杯盘比后处理与评估指标的实现细节模型输出的是概率图不是最终结果。后处理的质量直接决定交付时别人对项目的第一印象这一章讲清楚四步后处理和三个评估口径。4.1 后处理流程阈值、最大连通域、孔洞填充、形态学闭运算第一步是阈值化0.5 是默认起点如果视杯边界偏大可以上调到 0.55。第二步是保留最大连通域这一步能去掉血管方向上误检的小碎片。第三步是孔洞填充杯内常有血管穿行导致预测出现空洞。第四步是形态学闭运算平滑边界。def postprocess_mask(prob, thr0.5): m (prob thr).astype(np.uint8) # 只保留最大连通域清掉血管误检碎片 num, labels, stats, _ cv2.connectedComponentsWithStats(m, 8) if num 1: largest 1 int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) m (labels largest).astype(np.uint8) # 孔洞填充让视杯内部连续 contours, _ cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) filled np.zeros_like(m) cv2.drawContours(filled, contours, -1, 1, -1) # 闭运算平滑边界锯齿 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) return cv2.morphologyEx(filled, cv2.MORPH_CLOSE, kernel)这几个操作用到的都是 OpenCV 内置函数没有额外学习成本。kernel 尺寸 5×5 在 512×512 分辨率下合适如果输入分辨率较高可以适当增大到 7×7。闭运算对边界形态影响很小但对最终视觉观感提升很大。4.2 评估指标Dice、IoU、CDR一个都不能少指标计算方式用途Dice2P∩TIoUP∩TCDR杯面积 / 盘面积与临床相关的核心指标def compute_cdr(cup_mask, disc_mask): cup_area int((cup_mask 0).sum()) disc_area int((disc_mask 0).sum()) if disc_area 0: return 0.0 return cup_area / disc_areaCDR 是青光眼筛查中医生实际会看的指标也是高分项目评审时最容易被追问的地方。要注意面积比和垂直径比是两种口径同一只眼算出来数值有差异报告里必须写清楚用的是哪种。如果你用面积比就要在文档里注明如果临床标准用的是垂直径比需要先拟合椭圆再取椭圆长轴比值不要混着用。4.3 验证时的评估口径回到原始分辨率上算指标在 512×512 的裁剪图上算 Dice和回到原始分辨率的标签上算结果可能差出几个点。原因在于 resize 操作改变了边界像素分布小目标分割对空间分辨率尤其敏感。正确的做法是先把预测 mask 用最近邻插值 resize 回原图尺寸再和原图标签对齐计算。from skimage.transform import resize pred_orig resize(pred_mask, (orig_h, orig_w), order0, anti_aliasingFalse) dice_val compute_dice(pred_orig, orig_label)order0是最近邻插值保持 mask 的离散属性anti_aliasingFalse防止边缘被滤波抹糊。报告中写 Dice 数值时必须带上分辨率、阈值和是否裁剪这三个条件否则别人复现出来的数字很难对齐。5. 避坑指南视杯视盘分割复现中最常见的五个翻车点这部分是我改源码时最常遇到的五个问题每个都按“现象 → 原因 → 解决”梳理条条都有血泪经验在里面。5.1 损失一路下降Dice 就是上不去现象训练损失从 1.2 降到 0.3验证集 Dice 却卡在 0.2 左右不动。原因八成是标签格式问题。掩码被直接当 0/255 二值图参与计算Transformer 分支学习到一个几乎恒定的输出或者是掩码文件路径配对错误视盘标签当成了视杯标签。解决在训练脚本里加数据自检打印每张掩码的 unique 值和像素分布可视化三张图确认盘、杯、原图的对应关系。这类问题发生后往往会返工整套训练流程值得在开始前花半小时排查。5.2 视杯跑到视盘外面去现象单看 Dice 数值挺高但把两张掩码叠在原图上发现视杯区域明显超出视盘边界解剖结构完全不合理。原因两个输出通道各自独立优化模型没有学到“杯包含于盘”的解剖先验。解决训练时给视杯通道加一个掩码约束把视盘预测结果当作视杯的注意力图cup_pred cup_pred * disc_pred.detach()后处理阶段再做一次cup_mask cup_mask disc_mask的硬性约束。经验是把结构约束当作损失函数的一部分来优化效果比单纯靠模型自己学习要稳定。这类问题在 CDR 指标上会暴露得很明显杯盘比异常偏大一眼就能看出错。5.3 显存不够batch size 只能设为 1现象开始训练 10 秒就 OOM被迫 batch size 设为 1训练极不稳定。原因整张眼底图直接输入网络分辨率动辄两千万像素显存再大也扛不住。解决先做 ROI 裁剪再做 resize把输入控制在 512×512。如果显存还是不够就降低 batch size 到 2 配合梯度累积。很多人忽略梯度累积只是在 batch size 上硬扛。accumulation_steps 4 if step % accumulation_steps 0: optimizer.step() optimizer.zero_grad()5.4 翻转增强后 mask 跟着出错现象训练集可视化里出现大量全黑或全白的掩码模型预测开始出现诡异条纹。原因图像翻转了但掩码没有同步翻转很可能是手动实现翻转时忘了对 label 做同样操作或者用了复杂的 transform 组合但没对齐。解决用 albumentations 统一管理通过additional_targets把两张 mask 一并传入。自己写 dataset 时对图像和掩码使用同一个随机种子执行几何变换。5.5 CDR 数值异常偏高或偏低现象Dice 和 IoU 都正常但算出的 CDR 明显不合理偏大或偏小。原因杯盘分割的边界误差在各自区域面积上被放大或预处理中 CLAHE 参数过大造成伪边界偏移。解决把模型输出的概率图叠加回原图做人工抽查观察杯盘边界是否贴住解剖结构。如果杯边界整体外扩把阈值从 0.5 调到 0.6如果盘边界内缩检查 ROI 裁剪是否切到了视盘边缘。CDR 是对边界误差极敏感的指标它也是检验整个链路是否可靠的最有效指标。6. 把源码交付成一个能复现的工具推理批处理与 CDR 报告源码能跑通训练只是第一步真正有交付价值的是离线推理工具和可读的结果报告。很多“高分项目”最终展示的就是这一步。6.1 用 argparse 封装推理脚本训练和推理的解耦非常重要。训练脚本里揉进数据加载、模型定义和可视化会让别人很难实际使用。我一般会单独写一个推理脚本输入一张眼底图输出视盘掩码、视杯掩码和概率图。import argparse import cv2 import torch def inference(model_path, img_path, output_dir): img cv2.imread(img_path) roi, _ crop_roi(img, disc_mask_placeholder) roi_resized cv2.resize(roi, (512, 512)) tensor torch.tensor(roi_resized / 255.0).permute(2, 0, 1).unsqueeze(0) with torch.no_grad(): pred_disc, pred_cup model(tensor) cv2.imwrite(f{output_dir}/disc_pred.png, pred_disc * 255) cv2.imwrite(f{output_dir}/cup_pred.png, pred_cup * 255) if __name__ __main__: parser argparse.ArgumentParser() parser.add_argument(--model, requiredTrue, typestr) parser.add_argument(--input, requiredTrue, typestr) parser.add_argument(--out, defaultoutput, typestr) args parser.parse_args() inference(args.model, args.input, args.out)这个脚本的好处是别人不需要读你的训练代码也能直接用模型。提取出来的概率图保留原始尺度后续要调阈值、做形态学处理都不需要重跑推理。6.2 输出批处理结果到 CSV单张推理补齐后需要批量处理整个目录并生成 CSV 报告包含文件名、视盘面积、视杯面积和 CDR 值这是项目落地中最常见的交付物。import pandas as pd rows [] for img_path in sorted(image_paths): disc_mask, cup_mask run_inference(img_path) cdr compute_cdr(cup_mask, disc_mask) rows.append([img_path, int(disc_mask.sum()), int(cup_mask.sum()), cdr]) df pd.DataFrame(rows, columns[image, disc_area, cup_area, cdr]) df.to_csv(report.csv, indexFalse)CSV 文件名里带上病人或图像 ID方便回查原始数据。如果结果要用于进一步统计分析再合并一份带 Dice 验证集结果的评估表这样别人拿到报告时既有算法性能又有临床指标。6.3 验证这个方法真的可用三个小动作第一把训练集和验证集比例改成 8:2 后重新训练一轮确认指标没有大幅波动排除运气因素。第二在固定一张图上把阈值 0.40、0.50、0.60 各跑一遍记录 CDR 变化范围确认结果在合理区间。第三拿 3 张没参与训练的外部眼底图做盲测把预测掩码叠加在原图上找懂行的人看一眼边界是否可信。我早期做这类项目有一个血泪教训拿到源码急着一键训练结果标签格式和 ROI 裁剪没对齐跑出来的报告自己都不信。后来养成习惯先把数据浏览一遍把标签可视化把输出叠加回原图再做训练与评估。现在我把这个习惯写成脚本每次动手先跑“数据体检”。这个检查花不了多长时间换来的却是后面不返工希望帮到你。本文还有配套的精品资源点击获取