☰
青光眼眼底图像分割实战:从视盘视杯标注到杯盘比计算
2026/9/26 2:50:51 网站建设 项目流程

简介:面向青光眼研究与人工智能辅助诊断的眼底图像分割数据集,整合了牛津大学等公开标注来源,适合医学图像分析开发者、眼科算法工程师及科研人员使用,用于训练视盘、视杯及病变区域的分割与分类模型。压缩包共包含两千个文件,其中一千零二十个为 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 Loss;ORIGA 的多边形标注必须自己栅格化成掩膜,而栅格化的采样密度会直接影响边界平滑度;RIM-ONE 的XML格式适合做杯盘体积比,但转换成二维掩膜时会丢失部分高度信息。我的建议是:如果只想做二维分割和CDR测量,首选REFUGE;如果想研究标注不确定性,用ORIGA;想往视盘三维形态度量走的,才考虑RIM-ONE。

2.3 标签表达方式决定损失函数:多类别掩膜与“软标签”的坑

常见的标签表达有两种。第一种是单通道多类别掩膜,像素值0/1/2分别代表背景、视盘、视杯。此时视杯区域同时也是视盘区域,很多新手会把这两类当成互斥目标,导致损失函数直接算错。第二种是每个类别独立成一张二值掩膜,视盘一张、视杯一张,两张掩膜在杯区重叠。后者训练时更稳,损失函数也可以对两类分开计算Dice再平均。

需要特别提醒的是“软标签”问题。公开数据集里同一个样本有多个标注者,比如REFUGE的测试集就有两个专家的独立标注。有些团队会把两位专家的标注做平均得到概率图当软标签训练,这种做法的收益被高估了。多数情况下软标签会让模型输出概率更平滑、杯盘比预测方差更小,但会牺牲峰值性能。我一般会在基线阶段先练习二值硬标签,等所有流程打通后再对比软标签收益,而不是一上来就搞复杂方案。

3. 把眼底图整理成可训练数据集:裁剪、预处理与增强三板斧

3.1 目录结构:先约定一套不会后悔的命名规则

拿到原始图像和标注后,第一步不是写模型,而是先把目录结构固定下来。我常用的结构是:raw/ 放原始图像,masks/ 放统一后的掩膜,annotations/ 放原始多边形或XML,processed/ 放裁剪和重采样后的训练输入。文件名建议保留数据集原始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_ratio=0.25): """以视盘标注为基准裁剪ROI,margin_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_size=512): """双线性插值图像,最近邻插值掩膜,避免类别被平均掉。""" image_resized = cv2.resize(image, (target_size, target_size), interpolation=cv2.INTER_LINEAR) mask_resized = cv2.resize(mask, (target_size, target_size), interpolation=cv2.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], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) return (image - mean) / std

这段代码里最值得琢磨的是margin_ratio=0.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(p=0.5), A.Rotate(limit=10, border_mode=0, value=0, p=0.5), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.GaussNoise(var_limit=(10.0, 50.0), p=0.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(p=0.5), A.Rotate(limit=10, border_mode=0, value=0, p=0.5, mask_interpolation=0), A.RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.3), A.GaussNoise(var_limit=(10.0, 50.0), p=0.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_interpolation=0,如果用的是老版本库没有这个参数,就换成A.ShiftScaleRotate并检查版本文档。另外眼底图的黑色背景(非视网膜区域)在旋转后会露出黑边,border_mode=0填充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, transform=None): 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(image=image, mask=mask) 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_classes=3): 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, dim=1) # [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_name='resnet34', encoder_weights='imagenet', in_channels=3, classes=3, ) criterion = SoftDiceLoss(n_classes=3) optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-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(f"epoch {epoch} loss {loss.item():.4f}")

这个脚本里,SoftDiceLoss是对3个类别分别算Dice再取平均,这样视杯这类小目标不会被视盘和背景淹没。很多人会用CrossEntropyLoss,但眼底图里背景像素占比极高,CE会倾向于把大面积背景预测正确而忽略边界;Dice Loss天然对类别不均衡更稳健,这也是我把它作为默认损失函数的原因。

训练一个完整实验的常规参数是:输入512×512,batch 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校正,将gamma=0.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参数,回头看这正是这个方向的常态——数据和边界问题永远比模型结构问题多。希望这篇笔记能帮你在自己的眼底数据上少走几步弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询