简介面向青光眼研究与人工智能辅助诊断的眼底图像分割数据集整合了牛津大学等公开标注来源适合医学图像分析开发者、眼科算法工程师及科研人员使用用于训练视盘、视杯及病变区域的分割与分类模型。压缩包共包含两千个文件其中一千零二十个为 PNG 眼底图像九百七十九个为 JPG 图像另附一个 Python 预处理脚本便于查看和转换影像格式整体大小七十七点二四兆字节。已有二百三十七人学习下载。数据覆盖不同年龄、性别和族裔的病例提供血管结构及多种病变标签可支撑多任务学习与模型泛化能力验证。借助配套脚本可快速完成数据预览、清洗和加载为开发自动分割工具、辅助早期青光眼筛查提供高质量训练基础。1. 青光眼眼底成像分割数据为什么眼科医生最缺的不是算法而是干净数据集青光眼是全球首位不可逆致盲眼病早期筛查的关键指标之一就是视盘与视杯的几何关系也就是临床常说的杯盘比CDR。眼底成像分割数据正是用来训练语义分割模型、从眼底彩照或OCT中把视盘Optical Disc、视杯Optic Cup甚至视网膜血管、出血区域逐像素标出来的数据集。我接触这个方向快五年最深的感触是模型架构从来不是瓶颈真正决定筛查系统能不能落地的是数据——标注准不准、边界糊不糊、设备差异控没控住。这篇文章会把青光眼眼底成像分割数据从任务定义、数据集整理、模型训练到临床指标验证完整拆一遍适合想入眼科AI方向的工程师也适合手头有一批眼底图却不知道怎么下刀的团队。2. 先搞懂分割目标视盘、视杯与杯盘比这套指标怎么来的2.1 为什么分割目标不是“病灶”而是视盘视杯一般医学影像分割做的是肿瘤、出血、渗出这类“病灶区域”但青光眼眼底成像分割数据里第一优先级永远不是病灶而是视盘与视杯两个结构。原因在于青光眼的诊断逻辑视神经纤维受损后视杯会相对视盘扩大杯盘比垂直径线上的杯径/盘径就成了判断损伤程度的硬指标。临床上医生手画的杯盘比误差本来就大AI分割的价值就是把这条测量流水线自动化。视觉上视盘是眼底最亮的椭圆区域视杯在其内部颜色更浅、边界更模糊。最麻烦的是视杯边界在正常人眼和早中期青光眼之间几乎没有明确分界对比度极低。这也是为什么语义分割模型在这类数据上看起来“指标很高但临床上不敢用”——Dice刷到0.9不难但杯盘比差0.1就可能把正常眼判成疑似青光眼。2.2 公开数据集的标注格式与层级REFUGE、ORIGA、RIM-ONE 到底差在哪做这个方向绕不开几个公开数据集REFUGE、ORIGA、RIM-ONE、DRISHTI-GS。它们虽然都是“青光眼眼底成像分割数据”但格式差异非常大。REFUGE 来自ISBI 2018挑战赛图分辨率是2124×2056标注是单通道PNG掩膜背景为0、视盘为1、视杯为2同时提供400张训练图和400张测试图还有两位专家的独立标注可以拿来评估标注者一致性。ORIGA 来自新加坡眼科研究所650张图分辨率与REFUGE接近标注的边界存成多边形坐标文件而不是掩膜。RIM-ONE 每个样本由多张立体图像组成标注是XML格式适合做三维重建和体积测量。这里要推理一下格式对训练的影响REFUGE 这种“背景-视盘-视杯”三分类单通道掩膜训练时可以直接做交叉熵或Dice LossORIGA 的多边形标注必须自己栅格化成掩膜而栅格化的采样密度会直接影响边界平滑度RIM-ONE 的XML格式适合做杯盘体积比但转换成二维掩膜时会丢失部分高度信息。我的建议是如果只想做二维分割和CDR测量首选REFUGE如果想研究标注不确定性用ORIGA想往视盘三维形态度量走的才考虑RIM-ONE。2.3 标签表达方式决定损失函数多类别掩膜与“软标签”的坑常见的标签表达有两种。第一种是单通道多类别掩膜像素值0/1/2分别代表背景、视盘、视杯。此时视杯区域同时也是视盘区域很多新手会把这两类当成互斥目标导致损失函数直接算错。第二种是每个类别独立成一张二值掩膜视盘一张、视杯一张两张掩膜在杯区重叠。后者训练时更稳损失函数也可以对两类分开计算Dice再平均。需要特别提醒的是“软标签”问题。公开数据集里同一个样本有多个标注者比如REFUGE的测试集就有两个专家的独立标注。有些团队会把两位专家的标注做平均得到概率图当软标签训练这种做法的收益被高估了。多数情况下软标签会让模型输出概率更平滑、杯盘比预测方差更小但会牺牲峰值性能。我一般会在基线阶段先练习二值硬标签等所有流程打通后再对比软标签收益而不是一上来就搞复杂方案。3. 把眼底图整理成可训练数据集裁剪、预处理与增强三板斧3.1 目录结构先约定一套不会后悔的命名规则拿到原始图像和标注后第一步不是写模型而是先把目录结构固定下来。我常用的结构是raw/ 放原始图像masks/ 放统一后的掩膜annotations/ 放原始多边形或XMLprocessed/ 放裁剪和重采样后的训练输入。文件名建议保留数据集原始ID不要另起炉灶否则后面做错误分析时根本对照不回去。data/ raw/ # 原始眼底图jpg/png masks/ # 统一为单通道掩膜0背景 1视盘 2视杯 annotations/ # 原始多边形标注如有 processed/ images/ # 裁剪重采样后的512x512或1024x1024图 labels/ # 对应掩膜 splits/ # train.txt / val.txt / test.txt这里有个容易被忽略的点mask 的像素值类型一定要统一。很多标注工具导出的是8位灰度PNG有些导出的是调色板PNG直接读进来像素值可能是0、255而不是0、1、2。我在读数据时一定会先跑一次像素直方图确认类别数再进训练管线。3.2 预处理ROI裁剪、重采样与归一化眼底图原始分辨率通常在2000像素以上直接整图训练既慢又容易让视盘区域在缩放到224像素后丢失细节。常见做法是先根据原始标注或模型检测框把视盘ROI裁出来再缩放到统一尺寸。import cv2 import numpy as np def crop_roi(image, mask, margin_ratio0.25): 以视盘标注为基准裁剪ROImargin_ratio控制外扩比例。 image: [H, W, 3] BGR眼底图 mask: [H, W] 单通道掩膜0背景 1视盘 2视杯 # 取视盘区域像素值1的区域就是视盘 disc_mask (mask 1).astype(np.uint8) ys, xs np.where(disc_mask 0) if len(ys) 0: raise ValueError(未找到视盘区域检查掩膜是否为空或通道顺序) x_min, x_max xs.min(), xs.max() y_min, y_max ys.min(), ys.max() w, h x_max - x_min, y_max - y_min # 外扩防抠图时把视杯边缘裁掉 x_min max(0, int(x_min - margin_ratio * w)) x_max min(image.shape[1], int(x_max margin_ratio * w)) y_min max(0, int(y_min - margin_ratio * h)) y_max min(image.shape[0], int(y_max margin_ratio * h)) image_crop image[y_min:y_max, x_min:x_max] mask_crop mask[y_min:y_max, x_min:x_max] return image_crop, mask_crop def resize_to(image, mask, target_size512): 双线性插值图像最近邻插值掩膜避免类别被平均掉。 image_resized cv2.resize(image, (target_size, target_size), interpolationcv2.INTER_LINEAR) mask_resized cv2.resize(mask, (target_size, target_size), interpolationcv2.INTER_NEAREST) return image_resized, mask_resized def normalize(image): 眼底图常用归一化减均值除标准差或直接除以255。 注意彩色眼底图的三通道均值差异很大逐通道归一化效果好于整体算。 image image.astype(np.float32) / 255.0 mean np.array([0.485, 0.456, 0.406], dtypenp.float32) std np.array([0.229, 0.224, 0.225], dtypenp.float32) return (image - mean) / std这段代码里最值得琢磨的是margin_ratio0.25这个参数。视盘ROI裁得太大背景占比高模型容易把背景学得过死裁得太小视杯边界信息缺失。我做过一组对比外扩0.1时杯盘比误差偏大因为裁剪把杯边的一部分裁掉了外扩0.5时训练变慢且Dice没明显提升。0.2到0.3是甜点区间不同数据集可以快速扫一遍验证。掩膜resize必须用INTER_NEAREST这是红线。用双线性插值会把0/1/2变成0.4/1.2这种小数再拿去算Dice时全乱套。另一个容易被忽视的是归一化很多眼底图有强烈的光照不均匀整图除255后再减均值并不够建议先做灰度直方图均衡化的数据增强兜底这放在下一节讲。3.3 数据增强不要对眼底图用 90 度旋转和随机缩放眼底图方向和结构是受解剖约束的视盘总在颞侧血管走向也有规律。经验上水平翻转是安全且有效的因为左右眼镜像后结构依然合理垂直翻转要慎用虽然视盘上下方向改变在解剖上未必完全错误但会让杯盘比的垂直径测量变得不稳定90度或任意角度旋转则几乎不可用因为这会让视盘被切出ROI之外。我常用的增强组合是水平翻转概率0.5、小角度旋转±10度以内配合旋转后裁剪、亮度对比度微调gamma 0.8到1.2、高斯模糊或锐化。随机缩放要小心视盘尺寸本身在不同设备间差异就很大再叠加随机缩放会让模型对“视盘到底多大”失去概念而杯盘比恰好是对尺度敏感的指标。import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit10, border_mode0, value0, p0.5), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.3), A.GaussNoise(var_limit(10.0, 50.0), p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ], additional_targets{mask: mask}) # 掩膜也要做同样的transform但要保证用最邻近插值 # albumentations 里 Rotate 的 mask_interpolation 默认是1线性必须手动设为0 train_transform_with_mask A.Compose([ A.HorizontalFlip(p0.5), A.Rotate(limit10, border_mode0, value0, p0.5, mask_interpolation0), A.RandomBrightnessContrast(brightness_limit0.2, contrast_limit0.2, p0.3), A.GaussNoise(var_limit(10.0, 50.0), p0.3), A.Normalize(mean(0.485, 0.456, 0.406), std(0.229, 0.224, 0.225)), ToTensorV2(), ], additional_targets{mask: mask})一个很容易翻车的细节albumentations 的Rotate默认对掩膜做线性插值这跟前面说的INTER_NEAREST原则违背结果就是掩膜边界出现毛刺和重影。解决方法是显式传mask_interpolation0如果用的是老版本库没有这个参数就换成A.ShiftScaleRotate并检查版本文档。另外眼底图的黑色背景非视网膜区域在旋转后会露出黑边border_mode0填充0值会把背景值拉低干扰归一化更稳的做法是让ROI裁剪居中让眼底图本身占据整个正方形再配合最小旋转角度。4. 用 U-Net 在本地跑通最小训练命令、评估与 CDR 计算4.1 最小训练脚本从 Dataset 到 loss 一次跑通模型选择上分割眼底视盘视杯最常见、最稳的骨干是 U-Net。没有明显出血或新生血管这类小目标时不需要上更重的架构。我用的是一个带ResNet34编码器的U-Net变体显存占用小单卡3060就能跑512分辨率。下面是去掉工程封装后的最小训练脚本目标是让新手在单机单卡上先让代码跑起来。import torch import torch.nn as nn from torch.utils.data import Dataset, DataLoader from torchvision.models import resnet34 import segmentation_models_pytorch as smp class FundusDataset(Dataset): def __init__(self, img_paths, mask_paths, transformNone): self.img_paths img_paths self.mask_paths mask_paths self.transform transform def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 读图和掩膜掩膜像素值 0/1/2 image cv2.imread(self.img_paths[idx]) # [H, W, 3] mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # [H, W] if self.transform: transformed self.transform(imageimage, maskmask) image_t transformed[image] mask_t transformed[mask].long() return image_t, mask_t class SoftDiceLoss(nn.Module): 多类别Dice Loss对每个类别单独算Dice后取平均。 def __init__(self, n_classes3): super().__init__() self.n_classes n_classes def forward(self, logits, targets): # logits: [B, C, H, W], targets: [B, H, W] probs torch.softmax(logits, dim1) # [B, C, H, W] dice 0.0 for c in range(self.n_classes): p probs[:, c] t (targets c).float() intersection (p * t).sum(dim(1, 2)) union p.sum(dim(1, 2)) t.sum(dim(1, 2)) dice (2 * intersection / (union 1e-6)).mean() return 1.0 - dice / self.n_classes model smp.Unet( encoder_nameresnet34, encoder_weightsimagenet, in_channels3, classes3, ) criterion SoftDiceLoss(n_classes3) optimizer torch.optim.AdamW(model.parameters(), lr1e-4, weight_decay1e-5) for epoch in range(50): model.train() for images, masks in train_loader: images, masks images.cuda(), masks.cuda() logits model(images) loss criterion(logits, masks) optimizer.zero_grad() loss.backward() optimizer.step() print(fepoch {epoch} loss {loss.item():.4f})这个脚本里SoftDiceLoss是对3个类别分别算Dice再取平均这样视杯这类小目标不会被视盘和背景淹没。很多人会用CrossEntropyLoss但眼底图里背景像素占比极高CE会倾向于把大面积背景预测正确而忽略边界Dice Loss天然对类别不均衡更稳健这也是我把它作为默认损失函数的原因。训练一个完整实验的常规参数是输入512×512batch size 8到16视显存而定AdamW学习率1e-4跑了约50个epoch。不要一开始就上CosineAnnealing先用固定学习率跑出基线确认数据流水线没有问题后再调学习率策略。4.2 评价指标Dice 之外还要看杯盘比误差很多团队在测试集上只报mIoU或Dice这在青光眼分割数据上是不够的。一个常见现象是模型在整图Dice上达到0.93看起来很漂亮但算出来的杯盘比和专家标注差了0.2。这是因为杯盘比只依赖视盘边缘与视杯边缘的垂直径交点几个像素的边界偏移就会放大成数值误差。import numpy as np def compute_cdr(mask): 由分割掩膜计算杯盘比垂直径比。 mask: [H, W]像素值 1视盘 2视杯 返回估计的CDR值 disc (mask 1).astype(np.uint8) cup (mask 2).astype(np.uint8) def _vertical_extent(binary): ys, _ np.where(binary 0) if len(ys) 0: return 0 return ys.max() - ys.min() disc_height _vertical_extent(disc) cup_height _vertical_extent(cup) if disc_height 0: return 0.0 return cup_height / disc_height # 评估时对每个样本分别计算再求平均绝对误差 # 临床上 CDR 0.8 往往提示重度青光眼误差容忍度通常在 0.05 以内_vertical_extent用掩膜在垂直方向上的最大跨度来近似角膜垂直径这是大多数论文里的做法但要注意杯口边界偏低时垂直跨度会把整个杯底算进去导致CDR偏大。更严谨的做法是从视盘中心作垂直扫描线沿扫描线找杯缘的上下交点。这个差异在视杯开口不对称的病例里会非常明显建议评估阶段做两版对比。4.3 参数调整经验值学习率、batch size 与类别权重先说学习率用ImageNet预训练的ResNet编码器时1e-4到3e-4是稳定区间超过1e-3基本一两步就会loss飞掉。Decoder部分可以单独设5e-4但为简单起见统一用1e-4最稳。batch size 8以下时建议把num_workers调高到4以上否则CPU读图会成为瓶颈。类别权重上我见过把CrossEntropyLoss的权重设成[0.1, 1.0, 5.0]背景、视盘、视杯的做法配合Dice Loss混用。但实验下来纯Dice Loss在大多数据集上已经足够。如果视杯特别小早期病例可以在Dice里对杯类加权dice 0.3 * disc_dice 0.7 * cup_dice效果比手动调CE权重更容易控制。最后训练到30个epoch左右一定要存一次中间权重后面做伪标签或者坏样本分析时能回来对照这也是我给所有跑医学影像分割的团队的第一条建议。5. 青光眼眼底分割数据的避坑指南5 个最常见的翻车现场5.1 视杯边界高光把掩膜推成锯齿和空洞现象分割出来的视杯掩膜边界像锯齿且视杯内部偶尔出现空洞Dice在验证集上忽高忽低。原因眼底彩照里视杯底部常有强反光形成局部高亮区域这些区域的纹理信息几乎丢失模型在边界处的置信度非常低。还有一个典型因素部分公开数据集的标注本身就是沿着高光边缘画的导致学习目标本身就不平滑。解决一是在增强阶段对高光区域做gamma校正将gamma0.8的映射施加在图像亮度通道上让暗部细节突出来二是对掩膜做一次轻量的形态学闭运算cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)消除标注里的细小孔洞但这只能作为后处理不能替代模型学习。真正的解决还是要靠多中心数据把高光形态学丰富。5.2 不同眼底相机来源导致性能暴跌现象在公开数据集上Dice有0.91换到合作医院自己拍的眼底图后掉到0.82杯盘比误差直接翻倍。原因眼底相机的视野角、照明色温、图像压缩率都不同。比如有些设备是45度视野有些是30度造成同样的视盘在图像里的大小差出一大截。模型学到的“视盘大小”在跨设备时失效。解决数据层面建议按设备来源划分训练集和测试集而不是把所有图混在一起随机分割否则性能虚高。另外在预处理阶段记录每张图的拍摄设备元数据视场角、分辨率训练时可以用视场角信息把视盘物理尺寸估计出来再归一化。如果拿不到元数据至少要把图像分辨率与ROI裁剪的外扩比例联动实现在不同输入尺度下的稳定测量。5.3 训练集与测试集划分不当导致数据泄漏现象验证集Dice很高但发布模型后回测真实病例效果一般。很多人会误以为是过拟合但更常见的原因是数据划分泄漏。原因最常见的是来自同一受试者的左右眼图像同时出现在训练和测试集里。青光眼患者左右眼的视盘形态高度相关模型在测试时相当于“见过这个人”。这在公开数据集里尤其容易发生因为数据集发布时未必做了受试者级划分。解决划分数据集时必须以受试者ID为粒度哪怕图是成对出现的也要保证同一个人的所有图只进一侧。如果数据集没提供受试者ID尽量按拍摄时间戳粗分。另一个泄漏点是预处理阶段把全数据集的均值和标准差算完后再做归一化这会把全局统计信息透传给测试集正确做法是在训练集上算好归一化参数再固定下来。5.4 半自动标注工具的“智能”反而引入系统性误标现象用标注工具画视盘时快了很多但训练出来的模型在某个特定方向上总把视盘想外扩一圈和手动标注的模型相比系统性地偏移。原因很多标注工具的魔棒/活动轮廓算法在眼底图上会沿着高亮边缘收缩产生的标注比人工描线更“圆润”且工具版本更新后行为还可能变。如果用过这些预标注功能数据集中会混入工具偏置。解决拿到任何数据集后先随机抽20张图把标注和原始图叠在一起人工看一遍重点关注视盘下缘和视杯鼻侧边界。如果发现大量掩膜边缘像“水彩笔涂出来”的就要怀疑工具自动吸附。解决办法是对这些掩膜做Sobel梯度分析统计边界点的梯度方向分布和人工标注做对比分布差异过大的批次直接重标。5.5 Dice 很高但杯盘比不准的“指标幻觉”现象模型Dice达到0.94验证集上CDR平均绝对误差只有0.06可上线试运行时临床医生抱怨读数偏大。原因CDR是一个比值指标对边界偏移极其敏感。如果模型在视盘边缘向内偏1像素视杯边缘向外偏1像素两者叠加会让杯盘比被放大好几个百分点。Dice对这类小偏移不敏感所以两个指标会脱节。解决把“CDR平均绝对误差”作为主要评估指标Dice只做辅助参考。评估时除了平均误差还要看Bland-Altman图——以专家CDR为横轴、模型与专家的差值为纵轴观察误差是否随CDR增大而变大。如果CDR在0.8以上时误差明显扩大说明视杯边界在晚期病例上分割不稳定这时候要在损失函数里加大杯类权重或者针对高CDR样本做困难样本挖掘。6. 进阶从掩膜到临床报告——CDR 验证、伪标签与部署习惯拿到一个效果满意的分割模型后下一步不是直接上线而是把掩膜转成临床可解释的结果。我养成的习惯是每次评估除了输出整图Dice还单独输出视杯边界上的像素级误差热图。做法是把预测掩膜和专家标注的边界提取出来计算每个边界像素到另一条边界的最小距离距离超过3像素的点标红。这个热图可以直接给临床医生看比一堆数字更有说服力也能快速定位模型在哪个解剖区域系统性偏置。伪标签是这个领域特别值得投入的方向。眼底图像标注贵但未标注的图多。用当前模型对大规模未标注眼底图做预测再用规则筛选出可靠样本——筛选条件包括CDR预测值与模型置信度联合判断置信度取softmax概率在杯边界的平均值。我一般只收置信度大于0.9且两个类别概率都清晰的样本把它们加入训练集后重训通常能把杯盘比误差再压低10%。伪标签对青光眼数据特别有效原因是视盘视杯结构共性强模型对大部分健康眼的预测已经很稳定。部署上最常见的错误是把模型当黑匣子直接接临床系统。我在实际项目中最后保留的是一条“后处理管线”前向推理产出三分类掩膜先用连通域分析剔除面积异常的小区域再根据解剖先验限定视杯中心必须在视盘中心附近距离差超过视盘半径的30%就告警最后计算CDR。这样做不是提升指标而是挡住极少数离谱输出。对青光眼眼底成像分割数据这个方向我的真实建议是不要贪多模型先把视盘视杯分割这一件事做到杯盘比误差稳定在0.05以内再考虑加血管分割或病变检测。把数据管线、标注质量评估、跨设备泛化验证这三件事做扎实比换十个网络结构都有用。前几天我还在为一个高光病例调gamma参数回头看这正是这个方向的常态——数据和边界问题永远比模型结构问题多。希望这篇笔记能帮你在自己的眼底数据上少走几步弯路。本文还有配套的精品资源点击获取
