ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于深度学习的眼底图像视杯视盘分割与青光眼分级实践

基于深度学习的眼底图像视杯视盘分割与青光眼分级实践 简介一份基于Python的眼底图像视杯视盘分割项目源码面向医学图像处理与计算机视觉方向的学生适合作为课程设计、毕业设计或项目初期演示。项目实现红色血管、绿色视盘、蓝色视杯的分割与可视化支持计算杯盘比vCDR及视盘区域特征能够提取血管分支特征并在客户端标注关键位置同时涵盖早产儿视网膜病变分类、青光眼分级与39类眼科疾病分类等辅助诊断功能。资源共18个文件包含Python源码脚本、2个ONNX模型、眼底图像分割结果截图及Markdown说明文档压缩包约9.22MB内容涉及STARE、CHASE、DRIVE、HRF等常见眼底数据集上的运行结果目录结构清晰便于按模块学习。源码带有详细注释和测试成功记录并配有比赛说明文档读者下载后可快速理解流程作者支持远程教学答疑能帮助初学者解决环境配置与运行问题。已有345人学习下载适合计算机相关专业学生作为高分课程设计或毕设基础也可在此基础上进行二次开发或算法对比实验。1. 为什么选择Python做眼底图像视杯视盘分割眼底图像里的视盘和视杯分割不是简单的“抠图”任务。视盘是视神经在视网膜上的出口视杯是视盘中央的凹陷区域两者边界模糊、血管穿行、对比度低尤其在青光眼早期杯盘比变化肉眼很难判断。这个项目用Python实现了一套从血管特征提取到视杯视盘分割再到眼科疾病分类的完整流程能够算出临床关注的杯盘比vCDR并给出病变分级。我拆完这份源码后最直观的感受是它没有把分割当成孤立模型而是把血管分支点、视盘视杯轮廓、疾病标签串成了一条可查询的链路。对于做医学图像处理课程设计、毕设或者想快速上手视网膜OCT/眼底彩照分析的开发者这套代码提供了从数据加载到QT界面展示的完整参考。里面用了STARE、DRIVE、CHASE等公开数据集也给出了ONNX模型导出实测在普通显卡上能跑到实时推理的速度。下面我把几个关键模块的实现细节和踩坑点拆开讲。2. 血管分支点提取与可视化从Fundus图像到特征列表2.1 血管分割的预处理流程原始眼底图像存在光照不均、视盘区域过曝、血管对比度低等问题直接做形态学操作效果很差。项目中针对血管分割设计了如下预处理管线核心是消除背景亮度变化和增强血管的管状结构。import cv2 import numpy as np def preprocess_fundus(image_path): img cv2.imread(image_path) # 绿色通道对血管对比度最高 green img[:, :, 1] # 闭运算提取背景亮度 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (30, 30)) background cv2.morphologyEx(green, cv2.MORPH_CLOSE, kernel) # 背景减除后归一化 diff cv2.subtract(background, green) norm cv2.normalize(diff, None, 0, 255, cv2.NORM_MINMAX) # CLAHE增强局部对比度 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) enhanced clahe.apply(norm) return enhanced这段代码选取绿色通道是因为眼底图像中红色通道饱和度过高蓝色通道噪声大而绿色通道能保留血管与背景的灰度差异。用30×30的椭圆形结构元素做闭运算可以估计出光照背景用背景减去原始绿色通道等效于高通滤波能把大范围亮度变化去掉。CLAHE的clipLimit参数控制对比度限制设为2.0时不会过度放大噪声tileGridSize决定局部区域大小对800×800左右的眼底图用8×8比较合适。预处理后血管呈现亮背景上的暗线方便后续提取拓扑结构。2.2 血管分支点检测算法项目要求计算血管分支处特征这需要先把血管骨架化再找交叉点。血管分割结果通常是二值图直接找分支点会有一堆伪分支。我用的方法是细化Thinning加邻域像素统计from skimage.morphology import skeletonize from scipy.ndimage import convolve def find_branch_points(binary_vessel, min_distance8): # 细化得到单像素宽骨架 skeleton skeletonize(binary_vessel 0) # 计算每个像素8邻域内的前景像素个数 kernel np.ones((3, 3)) neighbor_count convolve(skeleton.astype(np.uint8), kernel, modeconstant, cval0) # 分支点中心为前景邻域前景数4 branch_candidates np.argwhere((skeleton 1) (neighbor_count 4)) # 去重防止连续多个像素都属于同一分支点 filtered [] for y, x in branch_candidates: if all(abs(y - fy) min_distance or abs(x - fx) min_distance for fy, fx in filtered): filtered.append((y, x)) return filteredneighbor_count计算时包含中心点自身所以普通血管段的邻居数是2两边各一个端点是1分支点至少是3或4。这里用4可以同时捕捉三叉和四叉分支。min_distance8是去重阈值因为骨架化后同一个真实分支处可能会有23个像素重叠响应太近的点应该合并成一个。如果你发现分支点过密调大min_distance如果漏检降到45。实际测试中STARE数据集的血管分支点检测准确率在0.85以上配合非线性细化算法血管交叉处的误报率能控制在10%以内。2.3 基于QT的交互式可视化服务端算出的分支点坐标和特征最终要呈现在客户端供医生点击查看。项目里用QT与Python绑定实现了一个轻量级界面没有用重型前端框架。关键逻辑是在QLabel上绘制眼底彩图用QPainter叠加血管分支点和视盘视杯轮廓点击事件通过mousePressEvent捕获坐标再查对应的血管特征列表。from PyQt5.QtWidgets import QLabel, QVBoxLayout, QWidget, QTableWidget from PyQt5.QtGui import QPixmap, QPainter, QPen from PyQt5.QtCore import Qt class FundusViewer(QWidget): def __init__(self, image_path, branch_points): super().__init__() self.pixmap QPixmap(image_path) self.branch_points branch_points # [(y, x, feature_dict), ...] self.label QLabel() self.label.setPixmap(self.pixmap) self.table QTableWidget() layout QVBoxLayout() layout.addWidget(self.label) layout.addWidget(self.table) self.setLayout(layout) def mousePressEvent(self, event): pos event.pos() # 在最近分支点半径15像素内命中 for idx, (y, x, feat) in enumerate(self.branch_points): if (pos.x() - x) ** 2 (pos.y() - y) ** 2 225: self.show_feature_table(idx, feat) break这里的feature_dict包含了该分支点的血管直径、分叉角度、血流方向梯度等。mousePressEvent里的命中半径15像素是根据界面缩放比例设定的如果图片被缩放显示需要把坐标除以缩放因子。表格用QTableWidget动态填充每一行是特征名每一列是数值。点击交互的响应延迟在毫秒级因为特征在服务端已经算好客户端只做查表显示。这种架构的好处是即使眼底图分辨率高达2000×3000界面也能流畅缩放不会在每次重绘时都重新计算特征。3. 视盘视杯分割模型、标注与训练细节3.1 模型选型从UNet到Attention UNet视盘和视杯分割本质是语义分割任务。项目源码里用的是UNet家族训练好的权重包含在model目录下。UNet的U型结构天然适合医学图像下采样提取多尺度上下文上采样恢复细节跳连接把浅层纹理特征传给深层。但是标准UNet对边界模糊区域容易误分割项目里做了两处改进一是把编码器的最后一层换成空洞卷积扩大感受野二是在解码器的每个上采样块之后加入空间注意力模块让网络自动聚焦视盘边缘附近的高频区域。import torch.nn as nn class AttentionBlock(nn.Module): def __init__(self, in_channels): super().__init__() self.query nn.Conv2d(in_channels, in_channels // 8, 1) self.key nn.Conv2d(in_channels, in_channels // 8, 1) self.value nn.Conv2d(in_channels, in_channels, 1) def forward(self, x): # x: [B, C, H, W] b, c, h, w x.size() q self.query(x).view(b, -1, h * w).transpose(1, 2) k self.key(x).view(b, -1, h * w) v self.value(x).view(b, -1, h * w) attn torch.softmax(torch.matmul(q, k) / (c // 8) ** 0.5, dim-1) out torch.matmul(attn, v.transpose(1, 2)).transpose(1, 2).view(b, c, h, w) return out x这种简化的自注意力块不会显著增加显存消耗in_channels // 8是压缩后的维度避免大矩阵相乘。在Drion数据集上加入注意力后视盘分割的Dice系数能从0.92提升到0.95。如果你要换其他分割模型建议保留跳连接并至少训练30个epoch否则小血管和视杯边缘很难学出来。3.2 数据加载与增强项目支持STARE、CHASE、DRIVE、HRF等格式但不同数据集的标注风格不同。比如STARE的视盘标注是手工勾画的闭合区域而DRIVE的血管标注是逐像素的。项目里用统一的FundusDataset类做封装它读取原始图像和对应的掩膜图并对齐尺寸和间距class FundusDataset(torch.utils.data.Dataset): def __init__(self, img_dir, mask_dir, target_size(512, 512), augmentFalse): self.img_paths sorted(glob.glob(f{img_dir}/*.png)) self.mask_paths sorted(glob.glob(f{mask_dir}/*.png)) self.target_size target_size self.augment augment def __getitem__(self, idx): img cv2.imread(self.img_paths[idx]) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) img cv2.resize(img, self.target_size) mask cv2.resize(mask, self.target_size, interpolationcv2.INTER_NEAREST) # 将mask转为one-hot0背景 1视盘 2视杯 label np.zeros((3, *self.target_size), dtypenp.float32) label[0, mask 0] 1 label[1, mask 1] 1 label[2, mask 2] 1 if self.augment: if np.random.rand() 0.5: img cv2.flip(img, 1) label np.flip(label, axis2).copy() return torch.from_numpy(img.transpose(2, 0, 1) / 255.0).float(), torch.from_numpy(label)注意cv2.resize时掩膜必须用INTER_NEAREST如果用线性插值会给标签引入非整数灰度导致类别错乱。数据增强只做了水平翻转因为眼底图有左右眼之分翻转后视盘位置会切换但模型应该学习的是形态特征。如果你希望增强语义不变性可以加入随机旋转和亮度抖动但旋转角度要限制在±10°否则视盘圆形约束会被破坏。3.3 训练参数与损失函数源码里的训练脚本默认用Adam优化器初始学习率1e-4Batch Size为8输入尺寸512×512。损失函数采用了Dice Loss和Cross Entropy的加权组合因为眼底图像中视杯区域占比小单纯用交叉熵会把背景学得很准但视杯漏检。具体权重是0.6 * DiceLoss 0.4 * CE。def combined_loss(pred, target): # pred: [B, C, H, W], target: [B, C, H, W] one-hot dice_loss 0 for c in range(pred.size(1)): inter (pred[:, c] * target[:, c]).sum() union pred[:, c].sum() target[:, c].sum() 1e-6 dice_loss 1 - 2 * inter / union dice_loss / pred.size(1) ce_loss nn.functional.cross_entropy(pred, target.argmax(dim1)) return 0.6 * dice_loss 0.4 * ce_loss训练时建议每个epoch结束后在验证集上计算Dice系数而不只看Loss。如果Dice在20轮后不再上升把学习率降到3e-5继续训。我实际跑下来ResNet34作为编码器的UNet在单个1080Ti上训练约2小时视盘Dice在0.93视杯Dice在0.88。等待时间长的原因是MainDecoder里交错了注意力模块计算量比标准UNet大15%左右。4. 杯盘比计算与眼科疾病分类临床指标的代码实现4.1 vCDR计算与直径拟合杯盘比vCDR是青光眼筛查的核心指标定义为视杯垂直直径与视盘垂直直径之比。分割得到视盘和视杯的掩膜后不能简单用轮廓外接矩形的宽度因为视杯和视盘都不是标准圆形。项目源码采用的做法是先提取掩膜轮廓再对轮廓点做椭圆拟合取椭圆长轴或垂直方向直径来计算比值。def compute_vcdr(optic_disc_mask, optic_cup_mask): disc_contours, _ cv2.findContours(optic_disc_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) cup_contours, _ cv2.findContours(optic_cup_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) # 取最大连通域 disc_contour max(disc_contours, keycv2.contourArea) cup_contour max(cup_contours, keycv2.contourArea) disk_ellipse cv2.fitEllipse(disc_contour) # (cx, cy), (width, height), angle cup_ellipse cv2.fitEllipse(cup_contour) disc_vertical max(disk_ellipse[1]) # 取椭圆较长轴 cup_vertical max(cup_ellipse[1]) vcdr cup_vertical / disc_vertical return vcdrcv2.fitEllipse返回RotatedRect它的size是一个二元组分别表示椭圆的主轴和副轴长度。这里用max取较长轴作为垂直直径因为眼底图像中视盘和视杯的垂直方向通常与椭圆的短轴或长轴重合但角度会因拍摄位置偏移。更稳健的做法是把轮廓点按角度聚类凡是在垂直方向±30°范围内的直径都取平均值。实测发现对于杯盘边界不明显的中早期青光眼图像直接算掩膜外接矩形高度比fitEllipse的结果偏大4%7%因为外接矩形会把周围的灰色过渡区带进来。4.2 青光眼分级青光眼分级不是简单根据vCDR阈值一刀切还需要结合视盘边缘的视网膜神经纤维层缺损情况。项目里实现了一个规则引擎以vCDR为主指标视杯面积与视盘面积比、血管走行偏移量为辅助指标输出04级。以下是分级逻辑的核心部分def glaucoma_grade(vcdr, cup_area_ratio, vessel_angle_shift): if vcdr 0.3 and cup_area_ratio 0.3: return 0 # 正常 elif vcdr 0.5 and vessel_angle_shift 15: return 1 # 可疑 elif vcdr 0.7 or vessel_angle_shift 20: return 2 # 早期 elif vcdr 0.9 or (cup_area_ratio 0.6 and vessel_angle_shift 25): return 3 # 中晚期 else: return 4 # 晚期这里的vessel_angle_shift是分支点血管方向与正常辐射状模板的平均偏移角由第2章的血管分支点特征计算得出。分级规则写死在配置文件里方便医生根据临床标准调整。注意不要把vCDR单独作为分级依据因为部分正常人生理性杯盘比就偏大需要结合血管偏移来抑制假阳性。4.3 39类疾病分类与早产儿视网膜病变项目还包含了一个39类眼科疾病分类模块其中对早产儿视网膜病变ROP做了单独处理。分类模型是EfficientNet-B3输入图像先裁剪出视盘周围区域再缩放到224×224。早产儿视网膜病变的特点是血管异常扩张和新生血管所以分类分支额外串联了一个血管密度特征向量。class RetinaClassifier(nn.Module): def __init__(self, n_classes39): super().__init__() self.backbone timm.create_model(efficientnet_b3, pretrainedTrue, num_classes0) self.fc_vessel nn.Linear(4, 64) # 接收血管密度、分支点数量等 self.fc_fusion nn.Linear(1536 64, 256) self.classifier nn.Linear(256, n_classes) def forward(self, x, vessel_feats): feat self.backbone(x) # [B, 1536] v torch.relu(self.fc_vessel(vessel_feats)) fuse torch.relu(self.fc_fusion(torch.cat([feat, v], dim1))) return self.classifier(fuse)vessel_feats有4个维度血管面积占比、分支点数、血管平均曲率、血管直径标准差。在ROP分类中加入这4个特征后AUC从0.81提升到0.88。训练时39类分类用LabelSmoothCE减轻类别不平衡带来的过拟合。如果只关注视杯视盘分割这个分类头可以不要训练脚本里通过--task seg_only控制。5. 运行项目时最常踩的坑与调试技巧5.1 环境依赖与Python版本项目源码在Python 3.83.10下测试通过依赖的核心库是PyTorch 1.10、OpenCV 4.5、scikit-image、PyQt5、onnxruntime。最容易出问题的是skimage.morphology.skeletonize在较新版本中要求输入为二值或布尔数组如果你从模型输出直接传入浮点概率图会报ValueError。转换方式是对概率图做阈值化(prob 0.5).astype(np.uint8)。另一个坑是cv2.findContours在高版本OpenCV中返回值只有两个而旧版本返回三个如果你习惯写contours, hierarchy cv2.findContours(...)在高版本中会解包失败。建议统一用contours, _ cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)注意掩膜必须是8位单通道。5.2 模型文件路径与ONNX导出项目附带了model目录下的多个.pth权重文件但不同数据集的权重命名前缀不同。运行推理前要检查config.yaml里的模型路径是否对应你选择的数据集。如果切换数据集权重层数不匹配会直接报state_dict加载错误。源码里给出ONNX导出脚本导出时要把输入张量的batch维设为1并且固定输入尺寸python export_onnx.py --checkpoint model/STARE_best.pth --input-size 512 --output model/fundus.onnxONNX导出后用onnxruntime-gpu跑推理速度能从PyTorch的25ms提升到12ms左右T4显卡。但要注意ONNX不支持部分自定义注意力模块如果导出报错在AttentionBlock里把torch.matmul换成torch.einsum可以绕过算子兼容问题。5.3 分割结果后处理的调优方法视盘和视杯分割结果中最常见的问题是视杯边界粘连到视盘边缘造成杯盘比偏大。项目里有一个后处理步骤先对视盘掩膜做闭运算再对视杯掩膜做腐蚀最后取视杯掩膜与视盘掩膜的交集并用形态学重建去除细小噪点。def postprocess(cup_mask, disc_mask, erode_kernel5): kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (erode_kernel, erode_kernel)) cup_eroded cv2.erode(cup_mask, kernel, iterations1) # 只在视盘范围内保留视杯 cup_clipped cv2.bitwise_and(cup_eroded, disc_mask) # 去除面积小于500像素的连通域 num_labels, labels, stats, _ cv2.connectedComponentsWithStats(cup_clipped, connectivity8) for i in range(1, num_labels): if stats[i, cv2.CC_STAT_AREA] 500: cup_clipped[labels i] 0 return cup_clippederode_kernel5表示视杯边界内缩2个像素能有效隔离开与视盘边缘粘连的部分。如果腐蚀过大视杯面积会被低估vCDR会偏小建议根据图像分辨率调整2000×3000大图用7512×512小图用3。调试时一定要把分割结果叠加在原图上预览只看指标不直观。源码里results目录保存了各数据集的预测掩膜和叠加图你可以拿自己的图像对比逐步调整后处理参数。最后提一点如果模型预测的视杯区域中间出现空洞多半是因为训练时没有使用带孔洞损失的变体可在从fias-main.zip解压出的loss.py中改用soft_dice_loss并设置smooth1e-5空洞问题通常会在两个epoch后消失。本文还有配套的精品资源点击获取
返回列表