眼底影像三任务实战:血管分割、中心凹定位与DR分级
简介智能眼底影像分析资源是一套基于Python深度学习的完整项目源码围绕眼底疾病诊断场景集成中心凹检测定位、视网膜血管分割与糖尿病视网膜病变分级三大任务适合计算机、医学图像处理方向的在校学生、高校教师及公司开发者用于毕业设计、课程设计或科研入门。资源共153个文件压缩包大小4.82MB核心代码以54个Python脚本为主搭配42个YAML与11个YML配置便于快速搭建训练环境同时包含Dockerfile、Shell部署脚本、Jupyter教程、预训练权重及GIF演示动图覆盖从环境配置、模型训练到结果可视化的完整链路。目前已有158人学习下载项目内部按功能模块组织附有说明文档可依据教程直接运行也支持二次开发。遇到环境或代码问题可通过私信获得远程指导适合希望快速跑通基线并深入改进算法的人群。1. 智能眼底影像分析在做什么三个任务、三类标签、一张图拿到这个标题的第一个反应是它把一个眼科医生的工作拆成了三道算法题。血管分割是像素级任务预测每个像素是不是血管中心凹定位是点级任务输出一个坐标糖尿病视网膜病变分级是图像级任务给整张眼底图打一个 0 到 4 的临床等级。三个任务在同一张视网膜彩照上做正好对应深度学习里的分割、定位、分类三种范式。能解决什么问题最常见的落地场景是糖尿病患者群体的眼底筛查。医生看一张眼底图需要一两分钟算法把血管、中心凹、DR 等级同时标出来医生只复核异常区域效率能提不少。适合三类人做医学影像课题的学生想转医学影像方向的算法工程师以及医院信息科里要做辅助诊断原型的团队。整个项目的难度集中在前置数据整理和三个模型的协同单一模型本身并不复杂。2. 数据准备把公开数据集整理成三份能直接喂给模型的目录2.1 公开数据集怎么选DRIVE、APTOS 与中心凹标注从哪来先说数据集。眼底影像分析比较尴尬的地方是公开数据源有但很分散每个任务的格式和标注风格都不一样。这个标题里三个任务对应的主流公开数据集分别是 DRIVE血管分割、APTOS 2019DR 分级以及 Messidor同时带 DR 分级和部分中心凹标注。DRIVE 是最常用的视网膜血管分割基准共 40 张眼底彩照官方划分 20 张训练、20 张测试每张图配了 FOV 掩码和二值血管标注。STARE 和 CHASE_DB1 也是血管分割的补充数据跨数据集泛化验证通常拿它们来测。APTOS 2019 是做 DR 分级最方便的数据集训练集有 3600 多张眼底图每张一个标签0 到 4 对应国际临床分级0 无病变、1 轻度非增殖期、2 中度非增殖期、3 重度非增殖期、4 增殖期。中心凹的公开标注最稀有这是整个项目最容易卡住的地方。常见做法有两条路一是用带中心凹标注的少量数据微调一个定位网络另一个更实用的做法是根本不训定位网络基于解剖先验来定位先找视盘位置再按中心凹与视盘约 2.5 个视盘直径的解剖相对位置推算。我会在第四节展开这个方案它也是做这个项目性价比最高的路线省掉了一个数据最缺的模型。2.2 目录组织与统一预处理FOV 掩码、裁剪与归一化不管算法多复杂数据目录混乱都会摧毁可复现性。我一般按任务分三个根目录每个目录只放原始图和标签预处理脚本单独放一个文件夹避免中间变量混进数据集。retina_project/ ├── data/ │ ├── drive/ │ │ ├── images/ # 原始 .tif 眼底图, 20张训练图 │ │ ├── masks/ # 每张图对应的 FOV 掩码 │ │ ├── labels/ # 血管二值标注 │ ├── aptos/ │ │ ├── train_images/ # APTOS 原始图片 │ │ └── train.csv # id_code 与 diagnosis 列 │ └── raw_annotations/ # 中心凹少量标注或视盘坐标, json 格式 ├── src/ │ ├── preprocess/ │ ├── vessel_seg/ │ ├── fovea_loc/ │ └── dr_classify/ └── configs/目录树的用意是让原始数据、标注、中间产物三个层次不互相污染。raw_annotations放的是自己整理的中心凹或视盘坐标格式统一成 json后续不管改成训练回归网络还是先验定位都不需要再改数据加载的入口。预处理脚本里最关键的是下面这个读取函数它决定了送入模型的张量是不是干净import cv2 import numpy as np def load_drive_pair(image_path, mask_path, label_path, size(512, 512)): image cv2.imread(image_path) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) label cv2.imread(label_path, cv2.IMREAD_GRAYSCALE) image cv2.resize(image, size, interpolationcv2.INTER_AREA) mask cv2.resize(mask, size, interpolationcv2.INTER_NEAREST) label cv2.resize(label, size, interpolationcv2.INTER_NEAREST) image image.astype(np.float32) / 255.0 label (label 127).astype(np.float32) mask (mask 127).astype(np.float32) return image, label, mask这段代码有两点值得注意。第一cv2.imread读进来是 BGR 顺序转了 RGB 再喂给后续网络才不会出现颜色通道颠倒的静默错误这类错误在验证集上不容易暴露换到临床数据上就会找上门。第二分割标签和掩码是二值图缩放时不能用双线性插值必须用INTER_NEAREST否则标签边缘会出现介于 0 和 1 之间的假灰度值被误当成背景参与损失计算。眼底图是圆形 FOV 外围全黑如果不拿掩码排除黑色区域像素均值会被拉低归一化后血管对比度被削弱。DR 分级里黑色边框还会干扰分类网络的注意力所以不少项目会在预处理阶段把 FOV 之外的区域直接裁掉。做法是用cv2.findContours在掩码上找到 FOV 外接圆裁出最小外接正方形再缩放到模型输入尺寸。至此图像、血管标注、FOV 掩码三类数据准备齐全下面三个任务就从这里分流。3. 血管分割用 U-Net 在 DRIVE 上跑通最小可复现闭环3.1 为什么选 U-Net输入分辨率与损失函数怎么定血管分割是典型的二类分割问题背景占绝大多数正样本比例通常不到 15%。这种数据分布下U-Net 是效果和经验都最稳的选择。它在 DRIVE 上的结果经过大量论文验证训练和调试成本都比 Transformer 分割网络低很多脚本跑起来不容易出幺蛾子。输入分辨率一般选 512x512再高效果提升有限、显存开销成倍增长显存小于 8GB 的话可以先降到 384x384 把流程跑通。损失函数不要只用交叉熵。Dice 加 BCE 是医学图像分割常用的组合写法如下import torch import torch.nn as nn import torch.nn.functional as F def dice_loss(pred, target, smooth1.0): pred torch.sigmoid(pred) intersection (pred * target).sum(dim(2, 3)) cardinality pred.sum(dim(2, 3)) target.sum(dim(2, 3)) dice (2.0 * intersection smooth) / (cardinality smooth) return 1.0 - dice.mean() def combined_loss(pred, target): bce F.binary_cross_entropy_with_logits(pred, target) dice dice_loss(pred, target) return bce dicedice_loss里的smooth参数要大于 0防止某个 batch 恰好没有正样本导致除零取 1.0 对大多数眼底图是稳定值过大会让损失对分割错误的梯度变钝。加 BCE 是因为 Dice 对全局相似度敏感却对局部细血管漏检不敏感交叉熵能补上像素级的梯度。3.2 从头训练一个可复现的分割模型训练循环、调度与后处理细节训练环节最需要注意的是数据增广。眼底图有固定的解剖方向视盘通常偏左或偏右水平翻转可以加垂直翻转要谨慎随机旋转控制在 10 度以内旋转过大会让视盘相对位置变形对分割任务影响不大但会给后续中心凹定位的先验造成干扰。优化器直接用 Adam初始学习率 1e-4配 ReduceLROnPlateau 调度器监视验证集 Dice连续 10 个 epoch 不改善就把学习率减半。model UNet(in_channels3, out_channels1, base_ch32) optimizer torch.optim.Adam(model.parameters(), lr1e-4) scheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemax, factor0.5, patience10, verboseTrue ) for epoch in range(100): model.train() for images, labels, masks in train_loader: images, labels, masks images.cuda(), labels.cuda(), masks.cuda() preds model(images) loss combined_loss(preds * masks, labels * masks) optimizer.zero_grad() loss.backward() optimizer.step() dice_val evaluate_dice(model, val_loader) scheduler.step(dice_val) if epoch % 10 0: print(fepoch {epoch}: loss{loss.item():.4f}, val_dice{dice_val:.4f})preds * masks这一乘是关键让 FOV 外的黑色区域不参与误差反向传播。不乘掩码时模型会努力把黑色边框预测成背景这部分区域没有血管也没有标签白白消耗拟合能力收敛会变慢。base_ch32是 U-Net 最常用的起始通道数显存吃紧时降到 16效果差距通常在 0.5 个点以内。推理后处理同样影响最终观感。分割网络输出的是概率图直接 0.5 阈值二值化后常有孤立噪点这是正常现象。先skimage.morphology.remove_small_objects去掉小于 50 像素的连通域再对血管骨架做一次闭运算结果会干净不少。评估指标上Dice 和 AUC 互补。Dice 反映分割重合度AUC 反映阈值鲁棒性两个都看。容易被忽略的是分开统计主干血管和细分支的 Dice主干分割好了细分支没分割整体 Dice 可能只差 0.01但临床想靠血管走形判断病情时差异就放大了。这也是为什么要做多数据集交叉验证第六节会讲。提示如果训练时显存溢出优先把 batch size 降到 8 而不是调低分辨率低分辨率对细血管分割的伤害比小 batch 更明显。我一般先用 8 的 batch size 跑通再逐步往上加。4. 中心凹定位与 DR 分级一个用解剖先验一个用分类网络4.1 中心凹定位视盘偏移加血管约束不训回归网络中心凹定位如果直接训坐标回归网络在公开数据量不足的情况下经常翻车坐标会漂到 FOV 边缘。更稳的方案是利用解剖学先验眼底彩照里视盘是亮度最高、近似圆形的区域而中心凹位于视盘颞侧约 2.5 个视盘直径处。这里的「颞侧」方向取决于左右眼但眼底照相经过标准化的 FOV 矫正后更稳定的做法是沿「视盘中心到 FOV 中心」的连线方向延伸 2.5 个视盘直径。import cv2 import numpy as np def locate_fovea(image, vessel_prob, mask): # 1. 在掩码上找 FOV 外接圆 contours, _ cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) (fov_cx, fov_cy), _ cv2.minEnclosingCircle(contours[0]) # 2. 找视盘: FOV 内灰度最高的区域, 形态学开运算去掉细血管 gray cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) bright cv2.morphologyEx(gray, cv2.MORPH_OPEN, kernel) disk_candidate cv2.minMaxLoc(bright)[3] # 亮度最高点 # 3. 沿视盘到 FOV 中心方向偏移 2.5 个视盘直径 dd estimate_optic_disk_diameter(bright, disk_candidate) dx, dy fov_cx - disk_candidate[0], fov_cy - disk_candidate[1] norm np.hypot(dx, dy) fovea (int(disk_candidate[0] dx / norm * 2.5 * dd), int(disk_candidate[1] dy / norm * 2.5 * dd)) # 4. 约束: 中心凹应落在 FOV 内且不在主干血管上 if not (0 fovea[0] image.shape[1] and 0 fovea[1] image.shape[0]): return None if vessel_prob[fovea[1], fovea[0]] 0.5: return None return foveaestimate_optic_disk_diameter的思路是在视盘候选点周边窗口内用mean 2 * std的阈值提取亮斑连通域取最大亮斑的等效直径。2.5 * dd这个比例在眼底图像分析里被广泛使用不同个体间变异很小比训练回归网络稳定得多。第四步的血管约束是血泪经验黄斑区虽然无血管但中心凹周边的拱环结构在分割图上会有细血管环加上这条约束能防止定位结果压到大血管上。这套方案的核心价值在于可解释性不论医生还是审查者都能看懂定位点是从哪条先验推出来的。对比黑匣子式的坐标回归网络这个优点在临床场景里非常实际。4.2 DR 分级EfficientNet 微调与类别不均衡的 5 个参数DR 分级用分类网络选 EfficientNet-B4 是因为它在 APTOS 上有大量开源基线可以对照计算量比 B5、B6 小准确率差距在 1 个百分点以内。输入先把 512x512 原图裁剪到 FOV再缩放到 256x256灰度归一化直接用 ImageNet 的均值方差不要自己重新统计因为预训练骨干本来就是这么训练的。网络最后接全局平均池化加一层 5 维全连接0 到 4 对应临床分级。类别不均衡是 DR 分级最大的坑。APTOS 训练集里 0 类占了近一半直接交叉熵训练会把模型推向全预测 0 类整体准确率看起来有 50% 以上但 2 类和 3 类完全不可用。我通常用WeightedRandomSampler按类别样本数的倒数采样配合 Focal Lossimport torch.nn as nn class FocalLoss(nn.Module): def __init__(self, gamma2.0, alphaNone): super().__init__() self.gamma gamma self.alpha alpha def forward(self, logits, targets): ce F.cross_entropy(logits, targets, reductionnone) pt torch.exp(-ce) loss (1 - pt) ** self.gamma * ce if self.alpha is not None: alpha_t self.alpha.gather(0, targets).unsqueeze(1) loss loss * alpha_t return loss.mean()gamma2.0是 Focal Loss 论文里验证过的默认值它让轻度和中度 DR 这类边界样本贡献更大梯度。alpha是类别权重一组可用的初始值是[0.1, 0.2, 0.2, 0.25, 0.25]后续在验证集上微调即可不用追求精确对齐样本比例因为采样器已经修正了一次失衡。参数推荐值说明backboneefficientnet-b4精度与算力的平衡点input_size256预处理时先裁 FOV 再缩放samplerweighted按类别样本数倒数采样lossfocal gamma2.0缓解类别不平衡评估指标QWK二次加权 Kappa评估指标不要用 accuracy用二次加权 KappaQuadratic Weighted KappaQWK更贴合临床分级的一致性sklearn.metrics.cohen_kappa_score(weightsquadratic)一行就能算也是 APTOS 竞赛官方排名用的指标。训练脚本的命令行可以这样组织python train_dr.py \ --backbone efficientnet-b4 \ --input_size 256 \ --batch_size 32 \ --lr 3e-4 \ --epochs 60 \ --sampler weighted \ --loss focal \ --data_dir ./data/aptos--sampler weighted和--loss focal是两个独立的开关可以单独关闭以便做消融对照这在写论文时很常用。学习率 3e-4 是迁移学习的常用起点比从头训练的 1e-3 保守因为 ImageNet 预训练权重已经在一个较好的局部最优附近。5. 避坑记录眼底影像项目里最容易翻车的 5 个地方5.1 标签风格不一致DRIVE 标注粗细差异导致跨数据集崩溃现象在 DRIVE 上训练 U-Net验证集 Dice 到 0.82换到 STARE 上一测跌到 0.71一度怀疑代码写错了。原因两个数据集的血管标注粗细差异明显DRIVE 标注更细CHASE 更粗且排除了更多小分支。模型在拟合标注风格而不是解剖结构损失下降只是把风格学得更好跨数据集一测就露馅。解决把所有数据集的标签先做形态学细化skimage.morphology.skeletonize统一到单像素宽度再固定半径膨胀让粗细差异消失。不想改标签的话更省事的做法是不混合训练用 DRIVE 训练、STARE 和 CHASE 只做评估把跨数据集 gap 记录在案也算是一种诚实的泛化指标。5.2 中心凹坐标漂移回归网络学到的只是 FOV 中心现象中心凹回归网络在验证集上平均误差只有 0.6 个视盘直径看起来很乐观但换到一批视角偏斜的眼底图上有 20% 的坐标直接落到 FOV 外面。原因大多数眼底图的中心凹本来就靠近 FOV 中心回归网络学到的是「FOV 中心偏一点」这个捷径而不是真正的中心凹解剖特征。样本量小时坐标回归特别容易退化成这种假学习。解决回到解剖先验方案不做端到端回归。如果觉得先验偏移在个别图上偏差大可以做一个混合方案以先验点为中心、0.8 个视盘直径为半径开窗口在窗口内对血管分割概率图做加权质心取与视盘方向约束最一致的点作为最终定位。5.3 DR 分级的 accuracy 假象0 类占了一半现象模型训练完验证集准确率 0.75ROC 曲线面积也不低但混淆矩阵打出来1 到 4 类几乎被全部分到 0 类。原因APTOS 的 0 类样本占比接近一半用普通交叉熵训练模型只要全猜 0 类就有接近 50% 的准确率。准确率是类别分布敏感的指标在这里基本是虚高。解决训练层面用WeightedRandomSampler加 Focal Loss数据层面额外对 2、3、4 类做随机亮度、对比度、轻微旋转的离线增广。评估层面一定要加上 QWK 和加权 F1我习惯每次训练完同时打印混淆矩阵肉眼看一眼每一行的分布比任何单值指标都可靠。5.4 cv2 和 numpy 版本不匹配的连锁反应现象cv2.imread返回Nonecv2.findContours报返回值数量错误排查半天以为是数据集损坏换个环境又正常。原因opencv-python对 numpy 有强 ABI 依赖两者版本不匹配会在底层二进制层面出问题报错信息往往不指向真实原因。解决用requirements.txt锁版本我常用的组合是opencv-python4.8.x配numpy1.24.x装完先跑一句python -c import cv2, numpy; print(cv2.__version__, numpy.__version__)。另外不要在同一个环境混装opencv-python和opencv-contrib-python两个包互相覆盖会产生各种神秘报错这类坑纯属消磨时间。5.5 加载权重后忘了切换 eval 模式现象训练时验证集 Dice 一直正常保存权重后写推理脚本同样的验证集却输出大量噪声结果完全不可用。原因模型里有 BatchNorm 层推理时如果没切model.eval()BN 会继续用训练时统计的 mini-batch 均值方差而不是累积的全局统计量。dropout 层同理会随机丢弃特征导致结果抖动。解决推理脚本里加载完权重立刻调用model.eval()并且用torch.no_grad()包住前向计算。这是新手最容易忽略、资深工程师也偶尔会踩的细节值得写进代码模板里变成肌肉记忆。6. 进阶用法跨数据集验证与三任务串联推理血管分割模型在原数据集上的表现不能完全代表实际效果医学影像模型最容易在换设备、换拍摄条件后性能滑坡。进阶做法是把跨数据集验证固化成一个脚本DRIVE 训练完直接加载权重在 STARE 和 CHASE_DB1 上输出 Dice 和 AUC不微调。如果跨数据集 Dice 掉超过 0.1就要回头看标签风格问题。这个脚本对最终投入决策的价值比刷高单个数据集指标大得多。三任务串联时我习惯让后面的任务直接读模型内存里的张量而不是各自重新读图。一张 512x512 眼底图进来先血管分割再用分割概率图和原图做中心凹定位同时把同一张图降采样到 256x256 跑 DR 分级。中间结果统一存成 numpy 数组避免反复读写磁盘def analyze_fundus(image_path, vessel_model, dr_model): image, _, mask load_drive_pair(image_path, , , size(512, 512)) with torch.no_grad(): vessel_prob vessel_model(image.unsqueeze(0)).sigmoid()[0, 0] dr_input F.interpolate(image.unsqueeze(0), size(256, 256)) dr_logits dr_model(dr_input)[0] fovea locate_fovea(image, vessel_prob, mask) return { vessel_prob: vessel_prob, fovea: fovea, dr_grade: dr_logits.argmax().item(), dr_probs: dr_logits.softmax(dim0).cpu().numpy() }这个函数的关键是输出设计一次调用拿到全部三个任务的结果下游写报告、做可视化都不用知道内部实现。dr_probs要返回概率分布而不是只返回硬标签因为临床上 2 级和 3 级的边界是模糊的概率比硬分类更有参考价值。可视化时血管概率图用 alpha 通道叠加在原图上中心凹用白色圆圈标出DR 等级写在图像左上角顺序对应医生的阅片逻辑先看有没有病再看病灶位置最后看血管细节。模型权重导出成 ONNX 后CPU 单张图推理可以压到 1 到 2 秒普通门诊电脑就能跑起来。我自己跑这类医学影像项目最大的教训是先把数据和评估指标定义清楚再回头调网络结构。血管分割、中心凹定位、DR 分级三个任务数据规模、标签质量和临床评价标准差异很大用一个统一的处理骨架串起来能省掉大量重复工作。这个方向值得投入的地方在于三件事组合起来就是一个能放进眼底筛查流程的完整原型而不只是单个模型的论文演示。希望这篇拆解能帮你在自己的数据和场景里少走弯路。本文还有配套的精品资源点击获取