眼底图像三任务深度学习实战:血管分割、中心凹检测与DR分级

发布时间:2026/9/24 21:50:36
眼底图像三任务深度学习实战:血管分割、中心凹检测与DR分级
简介这是一套基于Python深度学习的智能眼底影像分析源码项目面向医学图像处理、计算机视觉方向的在校生与研发人员涵盖中心凹检测定位、眼底血管分割和糖尿病视网膜病变分级三大核心任务适合用于毕业设计、课程设计或科研复现。压缩包约4.82MB共153个文件其中包含54个Python源码、42个YAML和11个YML配置文件并配有Dockerfile、Shell脚本、Notebook教程、说明文档及演示动图等便于快速搭建环境、理解模型结构与训练流程。项目预留了较为清晰的目录划分打开后可对照代码和配置文件逐步运行降低上手门槛。目前已有158人学习下载对于希望基于真实任务进行二次开发或作为课题基座的读者来说是一份值得参考的完整素材。1. 眼底疾病诊断的三个任务为什么必须一起做从原始眼底图到诊断结果的数据流拿到这个压缩包很多人第一反应是先把中心凹检测、血管分割、糖尿病视网膜病变分级三套代码分别跑通。我建议反过来先花半小时把三个任务的输入输出对齐再碰任何网络。因为中心凹坐标决定了黄斑区ROI应该剪在哪血管分割结果直接暴露出血点、硬性渗出和血管迂曲DR分级要是脱离这两个几何信息只能靠全图特征去硬猜指标天花板非常低。这个项目用一个Python深度学习的CNN框架把一张眼底彩照映射成三层输出——像素级血管掩膜、解剖坐标点、疾病等级覆盖了从眼底图像到辅助诊断的核心链路适合正在入门医学图像、手头只有一张图的同学也适合想搞懂多任务怎么组织的一线算法工程师。2. 数据准备阶段把眼底图、血管掩膜、中心凹坐标和DR标签整理到同一套坐标系里2.1 环境组合PyTorch版本、CUDA与opencv的匹配关系常见做法是用 PyTorch 作为主框架配合 torchvision 里的预训练模型做编码器。安装时最容易出错的是 torch 和 CUDA 的版本不匹配新手在 vscode 里配 python 环境时习惯直接pip install torch结果 CPU 版跑得极慢训练一个 epoch 要一小时。先确认显卡驱动支持的 CUDA 版本再装对应轮子。# 建议在 conda 里建独立环境避免污染系统 python conda create -n retina python3.10 -y conda activate retina # 下面这行按自己机器的 CUDA 版本选我这边是 cu118 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 pip install opencv-python numpy pandas scikit-learn tqdm albumentations matplotlib逻辑上先执行nvidia-smi看驱动版本再决定上面的 cu 后缀。torchvision 版本会约束后端可用预训练权重如果下载权重卡住可以在代码里设torch.hub.set_dir()指到本地缓存目录。opencv 装 4.x 即可骨架无要求。albumentations 是可选项但眼底图像增强阶段它比 torchvision.transforms 更顺手因为能同步处理掩膜和关键点。2.2 图像预处理黑边裁剪、CLAHE与绿色通道选取眼底彩照周边经常有一圈黑色无信息区域不裁掉会干扰归一化统计量。常规流程是找到灰度图里像素值大于阈值的区域取最小外接矩形裁剪然后缩放到统一尺寸。尺寸选 512 或 640 都可以显存不够就 512。我一般会用 640因为中心凹和微动脉瘤这种小目标在低分辨率下容易丢。然后是通道选择。眼底图三通道里绿色通道对比度最高血管与背景的灰度差最明显红色通道容易过曝蓝色通道信息量低。所以血管分割的输入通常取绿色通道或者做灰度化后加 CLAHE。import cv2 import numpy as np def load_fundus(path, target_size640, use_green_channelTrue): img cv2.imread(path) # BGR gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 黑边裁剪取非零区域外接矩形避免全图归一化被黑色背景拉偏 mask gray 10 coords cv2.findNonZero(mask.astype(np.uint8)) x, y, w, h cv2.boundingRect(coords) img img[y:yh, x:xw] if use_green_channel: img_g img[:, :, 1] else: img_g cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # CLAHE限制对比度自适应直方图均衡clipLimit 调大会增强噪声 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) img_g clahe.apply(img_g) img_rgb cv2.resize(img, (target_size, target_size)) img_g cv2.resize(img_g, (target_size, target_size)) # 输出灰度单通道作为分割输入RGB作为分级输入 return img_rgb, img_g这段代码的关键不在 CLAHE 本身而在先裁剪再缩放。黑边导致裁剪后坐标原点变了后面所有标签血管掩膜、中心凹坐标必须用同一套变换。clipLimit在眼底图上取 1.52.5 之间比较稳超过 3 会把背景噪声一起拉起来。血管分割我全程用绿色通道分级用 RGB两者输入不同不能在一个预处理函数里混着输出。2.3 标签导入坐标缩放、掩膜重采样与类别分布统计血管掩膜是 PNG 单通道中心凹标签通常是 CSV 里的 x、y 像素坐标DR 等级是0/1/2/3/4的整数标签。这三类标签的变换规则不一样掩膜跟着图像一起缩放或裁剪坐标点要乘缩放比再减偏移量DR 等级是图级别的标签跟几何变换无关。import pandas as pd from PIL import Image def load_labels(img_path, csv_path, target_size640): img cv2.imread(img_path) h, w img.shape[:2] df pd.read_csv(csv_path) row df[df[image] img_path.split(/)[-1]].iloc[0] fx, fy row[fovea_x], row[fovea_y] # 几何对齐必须用和图像完全相同的黑边裁剪参数 gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mask gray 10 coords cv2.findNonZero(mask.astype(np.uint8)) x, y, w_crop, h_crop cv2.boundingRect(coords) fx, fy fx - x, fy - y # 坐标缩放新坐标 原坐标 * target_size / 原尺寸 fx fx * target_size / w_crop fy fy * target_size / h_crop return np.array([fx, fy], dtypenp.float32), int(row[dr_level])这里的坑是黑边裁切的坐标偏移很容易漏。很多人缩放掩膜时直接给cv2.resize忘了先做相同坐标偏移导致分割结果整体平移几个像素。血管分割图是 0 和 255 的 PNG读入后要除以 255 转成浮点掩膜计算 BCE Loss 时才会收敛。DR 等级要用int()转成 04 的索引不能直接拿浮点标签交给 CrossEntropyLoss。类别统计要单独打印一份眼底 DR 数据集里 0 级往往占 60% 以上后面第 4 章会专门处理这个问题。2.4 训练集/验证集划分的隐藏约束划分数据时要保证同一个病人的多张图只在一边。眼底数据集如果没给病人 ID至少按眼睛左右眼做分组一只眼的图不能同时出现在训练和验证集里否则模型学到的是这只眼的颜色特征而不是病变模式验证指标虚高。常规做法是拿图像文件名里的前缀当分组键用sklearn.model_selection.GroupKFold做划分。3. 血管分割与中心凹检测定位共享编码器用热图回归取代坐标回归3.1 为什么中心凹要检测成热图而不是回归坐标点中心凹是黄斑中心一个小小的凹陷在眼底彩照上就是一个暗红色区域周围没有锐利边缘。直接拿全连接层回归 x、y 两个数模型很容易被视盘误导因为视盘是个亮黄色大椭圆特征太强了。常见做法是把坐标点渲染成高斯热图让网络输出一个概率密度图中心凹位置就是热图峰值。这里把分割和检测放在同一个模型里有充分理由两个任务共享编码器血管分支需要低层纹理信息中心凹热图分支需要中高层语义信息两个监督信号互相补充尤其是血管走向能帮模型定位黄斑中心。训练时两侧损失相加权重配比很关键。import torch import torch.nn as nn import torch.nn.functional as F class SegDetectHead(nn.Module): def __init__(self, in_channels512): super().__init__() # 血管分割分支输出单通道 logits self.seg_head nn.Sequential( nn.Conv2d(in_channels, 128, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(128, 1, 1) ) # 中心凹热图分支结构更浅防止过拟合 self.fov_head nn.Sequential( nn.Conv2d(in_channels, 64, 3, padding1), nn.ReLU(inplaceTrue), nn.Conv2d(64, 1, 1) ) def forward(self, x): seg_logit self.seg_head(x) heatmap torch.sigmoid(self.fov_head(x)) return seg_logit, heatmap def fovea_gaussian_heatmap(coord, size160, sigma8): 把中心凹坐标渲染成高斯热图与模型输出尺寸一致 x torch.arange(size, dtypetorch.float32) y torch.arange(size, dtypetorch.float32) yy, xx torch.meshgrid(y, x, indexingij) fx, fy coord heatmap torch.exp(-((xx - fx) ** 2 (yy - fy) ** 2) / (2 * sigma ** 2)) return heatmap注意热图尺寸。如果模型下采样 4 倍输入 640 对应热图 160坐标也要除以 4 再渲染。sigma取 610 个像素比较合适太大峰值周围一片糊定位精度变差太小网络很难稳定收敛。热图分支输出后套 sigmoid配合 MSE Loss 回归到[0, 1]。训练阶段坐标参与热图渲染推理阶段直接取argmax得到像素坐标再乘下采样倍数还原。3.2 U-Net主体结构ResNet-34编码器加两层解码器血管分割最稳的骨架还是 U-Net 系。常见的做法是拿 torchvision 里预训练的 ResNet-34 当编码器省去自己设计骨架和加载权重的麻烦只需处理维度对齐。import torchvision.models as models class FundusNet(nn.Module): def __init__(self, backboneresnet34, pretrainedTrue): super().__init__() enc models.resnet34(weightsmodels.ResNet34_Weights.IMAGENET1K_V1) if pretrained else models.resnet34() self.enc1 nn.Sequential(enc.conv1, enc.bn1, enc.relu) # 1/2 self.enc2 nn.Sequential(enc.maxpool, enc.layer1) # 1/4 self.enc3 enc.layer2 # 1/8 self.enc4 enc.layer3 # 1/16 self.enc5 enc.layer4 # 1/32 # 解码器逐级上采样拼接编码器特征 self.dec4 self._decoder_block(512, 256) self.dec3 self._decoder_block(256, 128) self.dec2 self._decoder_block(128, 64) self.dec1 self._decoder_block(64, 64) self.head SegDetectHead(64) def _decoder_block(self, in_ch, out_ch): return nn.Sequential( nn.ConvTranspose2d(in_ch, out_ch, kernel_size2, stride2), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.ReLU(inplaceTrue) ) def forward(self, x): e1, e2, e3, e4, e5 self.enc1(x), self.enc2(x), self.enc3(x), self.enc4(x), self.enc5(x) d4 self.dec4(e5) d4 torch.cat([d4, e4], dim1) ... return seg_logit, heatmap参数说明这里解码器只用了四层没有一路回到原尺寸最后在 1/4 分辨率输出。血管是细长结构在 1/4 分辨率下最细的毛细血管会丢但完整分辨率训练显存压力大一般取 1/4 加后处理插值。dec4拼接时要注意通道数ResNet-34 的 layer3 输出是 256 通道layer4 是 512拼接前必须调整。你要是显存够大可以把最后两层输出接一个深度监督让 1/8 分辨率也能算损失训练更稳。3.3 训练设置DiceBCE组合损失与训练曲线判断血管分割的像素级分类里血管像素只占全图的 10% 左右单用 BCE 损失会让模型倾向预测背景。常见做法是 BCE 加 Dice Loss让模型在像素准确率和区域重叠率之间取平衡。中心凹热图用 MSE 就够了它的监督信号本来就稀疏。def dice_loss(pred, target, smooth1.0): pred torch.sigmoid(pred) pred_flat pred.reshape(pred.size(0), -1) target_flat target.reshape(target.size(0), -1) intersection (pred_flat * target_flat).sum(dim1) return 1 - (2.0 * intersection smooth) / (pred_flat.sum(dim1) target_flat.sum(dim1) smooth) # 训练循环内 seg_logit, heatmap model(batch_img) seg_loss F.binary_cross_entropy_with_logits(seg_logit, seg_mask) 0.5 * dice_loss(seg_logit, seg_mask) fov_loss F.mse_loss(heatmap, fov_target) total_loss seg_loss fov_loss训练时观察两组曲线分割 Dice 应该在前 20 个 epoch 内从 0.3 爬到 0.7 以上如果一直在 0.5 以下震荡先检查掩膜有没有同步裁剪再检查数据增强是否把血管旋转到了不自然的角度。热图损失通常在第 10 个 epoch 后降到 0.01 以下此时预测峰值位置基本落在半径 10 像素范围内。合理的 batch size 是 812学习率 1e-4AdamW 优化器配 cosine 衰减。眼底图是自然图像迁移过来的预训练 backbone 用 ImageNet 权重在多数数据集上比随机初始化快 30% 以上的收敛速度。4. 糖尿病视网膜病变分级从分割结果里取特征还是单独训一个分类模型4.1 分级标准与数据分布五个等级与 Quadratic Weighted Kappa 评估指标DR 分级是临床上的五级任务从 0 到 4无病变、轻度非增殖期、中度非增殖期、重度非增殖期、增殖期。各级别在公开数据集里的分布极不均衡0 级占一半以上3 级和 4 级加起来经常不到 15%。直接拿准确率当指标毫无意义预测全 0 都能拿 60% 以上的准确率。常用评估指标是加权二次 KappaQuadratic Weighted Kappa它惩罚的是错得离谱——把 4 级预测成 0 级比把 2 级预测成 1 级扣分狠得多。sklearn 里cohen_kappa_score(weightsquadratic)可以直接算。训练集上如果 Kappa 刚到 0.5模型基本等于看颜色猜级别0.8 以上才具备参考价值。4.2 分级网络选型EfficientNet还是ResNet分级任务输入是整张眼底图病灶分布在全图各个位置需要较大的感受野还要能捕捉微动脉瘤这种细小目标。常见做法是拿 EfficientNet-B3 或 B4 做分类骨架B4 性能略高但显存占用明显上涨GTX 1080Ti 以下的卡用 B3 更合理。把最后全连接层换成五分类输出输入尺寸 384配合 ImageNet 预训练权重微调。import torchvision.models as models def build_dr_classifier(num_classes5, archefficientnet_b3): if arch efficientnet_b3: weights models.EfficientNet_B3_Weights.IMAGENET1K_V1 model models.efficientnet_b3(weightsweights) in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) else: weights models.ResNet50_Weights.IMAGENET1K_V1 model models.resnet50(weightsweights) model.fc nn.Linear(model.fc.in_features, num_classes) return model分类主干和解剖模块的输入差异值得明确血管分割和中心凹用的绿色通道灰度图分级用的增强后的 RGB 图两者差异已经大到不能共享输入所以分级模型单独训练是对的。特征提取阶段只微调最后两层卷积前面冻结能大幅减少训练时间显存也降一半。4.3 类别不均衡处理过采样与加权CE的实际取舍DR 分级里类别不均衡是最大的噪声来源。有两种常用策略第一种是加权交叉熵给少样本类别更高的损失权重第二种是过采样按类别补齐训练数据。我的经验是过采样比加权更可靠加权 CE 在类别权重超过 5 倍时训练容易震荡过采样则没有这个问题。from torch.utils.data import WeightedRandomSampler def make_sampler(labels): class_counts np.bincount(labels, minlength5) class_weights 1.0 / (class_counts.astype(np.float32) 1e-6) sample_weights class_weights[labels] sampler WeightedRandomSampler(sample_weights, num_sampleslen(labels), replacementTrue) return sampler过采样让每个 epoch 里 4 级样本出现的次数和被网络看到的机会大幅提升配合标签平滑label smoothing0.1能防止模型对少样本类别过拟合。训练过程要同时监控分类准确率和 Kappa两者不冲突时再决定是否早停。4.4 把前面两个任务的输出接进来ROI裁剪与多任务串联单独训一个分类网络能拿到不错的基线但要达到更好的指标常规做法是把中心凹检测的结果接进来检测到中心凹后在它周围裁剪一个固定大小的 ROI这个 ROI 包含黄斑区——硬性渗出和出血最容易聚集的位置。把这个 ROI 缩放后作为分类网络的第二路输入或者直接替掉全图输入模型就有了该往哪看的先验。def crop_roi_around_fovea(rgb_img, fovea_coord, roi_size256): fx, fy fovea_coord x0 int(np.clip(fx - roi_size // 2, 0, rgb_img.shape[1] - roi_size)) y0 int(np.clip(fy - roi_size // 2, 0, rgb_img.shape[0] - roi_size)) roi rgb_img[y0:y0roi_size, x0:x0roi_size] return cv2.resize(roi, (224, 224))这里的roi_size选择有几个讲究。眼底图标准化缩放到 640 之后黄斑区病变区域大约占图幅的 30%40%256 像素的 ROI 在这个尺度下既能覆盖黄斑附近渗出带又不会吞进过多无关区域。如果中心凹检测坐标偏移超过 30 像素ROI 就会切偏分级精度反而下降所以先确保中心凹检测质量再谈分级。5. 复现这套方案最容易翻车的五个坑坐标方向、增强同步、标签噪声与类别失衡5.1 中心凹坐标与视盘混淆现象热图训练收敛但推理时每次峰值都落在视盘中心。中心凹在解剖位置上是黄斑中心视盘则在鼻侧两者距离大约在 1.5 个视盘直径以上不是同一个点。原因视盘是眼底图亮度最高的区域边缘锐利CNN 偷懒学到了找一个亮斑中心。解决给训练数据加几何先验在损失上叠加峰值与视盘中心距离必须大于 80 像素的约束更简单的做法是训练时把视盘中心坐标作为额外一维输入让中心凹分支学习相对视盘的位置。5.2 翻转增强没有同步坐标标签现象训练损失正常下降验证损失在第 30 个 epoch 突然飙升。原因随机翻转只翻了图像没翻坐标模型看到的是一条矛盾标签流过拟合得莫名其妙。解决所有几何增强必须定义成同时作用于图像、掩膜、关键点的复合操作。albumentations 里有HorizontalFlip可以在keypoints参数里同步翻转但要注意它默认翻转的是关键点在图像内的位置不需要你自己加变换。5.3 混合数据集直接联合训练造成的假指标现象在 DRIVE 和 STARE 两个公开血管分割数据集上合在一起训练验证 Dice 能到 0.82但换一个来源的图就掉到 0.6。原因不同设备的眼底图在色调、分辨率和病变分布上差异很大混合后模型学到的是设备指纹而不是血管结构。解决混合前先做 each-image 归一化或者干脆分开训练做集成。更稳妥的做法是在缩放阶段把两套数据的所有图统一到同一个尺寸和通道范围再做数据均衡采样。5.4 DR 分级类别权重过大导致训练震荡现象用加权 CE 训练分级模型loss 一直在 1.0 附近下不去Kappa 卡在 0.5 以下。原因4 类样本太少权重给到 8 倍以上梯度被少数样本主导。解决换用过采样或 Focal Loss。Focal Loss 对易分类样本降权对难分类样本保持梯度在不均衡场景下更稳妥。调参时gamma2.0、alpha0.25起步不要一大把往上加。5.5 BatchNorm 在小 batch 下失效现象batch size 设 2训练曲线像过山车验证指标上蹿下跳。原因BatchNorm 依赖 batch 内统计量batch 太小均值方差估计不稳。解决分配合理显存至少把 batch 顶到 8实在显存不够换 GroupNorm 或者把模型前几层 BN 冻结用 ImageNet 预训练统计量做推理。6. 把三套模型串成一条推理管线从原图到诊断报告的单次前向前几章讲的是三个独立模型的训练最后要做的是把它们组装成一条端到端推理链路。推理阶段不像训练那样每步都要算梯度GPU 显存压力小可以放更大的输入。def infer_pipeline(img_rgb, img_gray, seg_model, cls_model, target_size640, roi_size256): seg_model.eval(); cls_model.eval() with torch.no_grad(): # 第一步血管分割 中心凹检测拿到几何结果 seg_logit, fov_heatmap seg_model(img_gray.unsqueeze(0).cuda()) seg_mask torch.sigmoid(seg_logit[0, 0]).cpu().numpy() fov_mask fov_heatmap[0, 0].cpu().numpy() fy, fx np.unravel_index(np.argmax(fov_mask), fov_mask.shape) fx, fy fx * 4, fy * 4 # 还原到640分辨率 # 第二步用中心凹坐标裁剪ROI单独喂给DR分级网络 roi crop_roi_around_fovea(img_rgb, (fx, fy), roi_size) roi_tensor normalize(roi).unsqueeze(0).cuda() logits cls_model(roi_tensor) level torch.argmax(logits, dim1).item() # 第三步血管面积密度是一个可解释的参考指标 vascular_density float((seg_mask 0.5).mean()) return fx, fy, level, round(vascular_density, 4)这套流程里血管分割和中心凹检测共享一次前向ROI 裁剪后只多了一次分类前向单张图在 1080Ti 上推理时间可以控制在百毫秒内。中心凹坐标的还原倍数要跟训练时下采样倍数保持一致缩放回 640 后才能拿去裁 ROI。更进阶的验证方法画一张带标注的对比图把血管掩膜以半透明红色叠在 RGB 原图上中心凹位置画一个十字标记DR 等级打印在左上角保存成 PNG 给医生或同行看。这样能直接暴露几何错误——如果十字标在了视盘上说明中心凹分支还需要回炉。如果 ROI 切出来的区域里渗出点肉眼可见但模型仍判成 0 级就说明分级网络容量不够该从 EfficientNet-B3 换 B5 或引入注意力模块了。这些检查做完再谈调参否则刷 Kappa 也是在错误的前提上打转。按我的习惯每次换数据集第一件事不是训练而是做一次完整的前向推理检查几何输出。几何错了分级做得再花哨也白搭。中心凹定位这个任务虽然看着简单但它决定整条管线能走多远值得在前面多花心思。希望这些边界和参数能帮到你少走几趟弯路祝跑通顺利。本文还有配套的精品资源点击获取