简介:这套深度学习数据集聚焦眼睛及虹膜区域的上睑下垂疾病分类与分割,适用于医学图像处理、计算机视觉方向的研究者与开发者,可帮助快速搭建医疗影像分析实验。所有图像来自真实采集并自行标注,类别涵盖轻度、中度、重度、正常四类,数据集按类别分目录存放,每张原始图像均配有对应的分割标注文件,既可直接训练分类模型,也能展开眼睛、虹膜结构的精细分割研究。资源包共1568个文件,主体为786张JPG原图与782个JSON标注文件,压缩包仅约92MB,目录结构清晰,图像与标注一一对应,便于直接加载使用。目前已有267人学习下载,数据集同时覆盖分类与分割两条研究路径,省去自行采集、筛选和标注的繁琐环节,为模型训练、调参对比和指标验证提供了现成素材,适合课程设计、论文实验或项目预研等场景。
1. 上睑下垂分类与分割:这个眼睛图像数据集要解决的两类任务
眼科门诊里,医生拿到一张眼睑特写照片,需要在几秒内判断患者是否属于上睑下垂,并粗略评估睑缘遮盖角膜的程度。这个判断本身依赖经验,而深度学习模型可以辅助完成初筛和量化测量——前提是有一批质量可靠的眼睛图像数据集。标题里的「上睑下垂疾病分类、分割」实际上定义了两种不同的监督任务:分类告诉模型「这张图有没有病、属于哪个等级」,分割告诉模型「病变区域和关键解剖结构在像素级位于哪里」。两类任务共享同一批原始图像,但需要完全不同的标注。这篇博文围绕这类数据集展开:它的构成和格式、预处理与增强、双任务模型训练、评估与落地时容易踩的坑。适合正在做医学图像分析、需要从零构建或调用类似数据集的工程师。
2. 数据集成因:分类标签与分割掩膜的规范、结构和质量控制
2.1 分类任务和分割任务分别需要什么样的标签
上睑下垂的分类标签通常是序数型(ordinal)的,常见做法是分成正常、轻度、中度、重度四个等级,或者按临床量化指标 MRD-1(边缘反射距离)的数值区间切分。由于等级之间存在连续性,分类模型输出的概率分布比单一硬标签更有用——你可以从 softmax 分布里看出模型在「轻度和中度」之间的犹豫程度。分割任务的标签则是像素级掩膜,需要标注的区域一般包括上眼睑边缘、睑裂区域和虹膜暴露区域。虹膜暴露程度是判断上睑下垂严重度的关键视觉线索,所以掩膜标注通常以虹膜轮廓为基准。
这两类标签的关系值得注意:分割掩膜不是分类标签的「附带产物」,而是分类判断的可解释依据。一个常见做法是把分割结果作为中间特征,计算虹膜暴露比例或睑裂高度,再映射到分类等级。这比直接端到端分类更接近临床推理路径,也方便医生理解模型为什么给出某个结论。
2.1.1 同源数据、两种标注的协调策略
同源图像做双重标注时,需要协调两个标注任务的关系。一般流程是:先让标注医生完成分割掩膜,再基于分割结果填写分类标签——这样分类标签的边界会更一致,因为「轻度」和「中度」的判定标准(虹膜暴露比例)已经通过掩膜量化了。反过来做容易出问题:先标分类等级再画掩膜,标注者会被分类标签锚定,画出符合分类预期而非真实边界的掩膜。
2.2 数据集目录结构与文件组织方式
图像数据集的目录结构直接影响训练代码的复杂度。我一般会按任务拆分子目录,而不是把分类和分割混合在同一个遍历逻辑里:
ptosis_dataset/ ├── images/ │ ├── train/ │ │ ├── ptosis_001.jpg │ │ ├── ptosis_002.jpg │ │ └── ... │ ├── val/ │ └── test/ ├── masks/ │ ├── train/ │ │ ├── ptosis_001_mask.png │ │ └── ... │ ├── val/ │ └── test/ ├── labels/ │ ├── train.csv │ ├── val.csv │ └── test.csv └── metadata.json掩膜文件用 PNG 格式存储,8-bit 单通道,像素值为 0(背景)、1(虹膜)、2(眼睑边缘)。PNG 是无损压缩,不像 JPEG 那样在边缘产生伪影——医学分割任务里这种伪影会影响 Dice 指标的准确性。CSV 里每一行对应一张图像,包含patient_id(患者编号)、image_path、mask_path、severity(分类标签)、mrd1_mm(如果有临床测量值)字段。
patient_id这个字段不是可有可无的。数据划分必须按患者进行,不能按图像随机切分——同一患者的左右眼图像高度相似,如果左眼在训练集、右眼在测试集,评估结果会虚高。这在后续章节会展开讲。
2.3 标注质量控制的三个层
2.3.1 标注者间一致性
上睑下垂的边界判定存在主观性:睑缘位置、虹膜上缘的弧线在低对比度图像上并不总是清晰。常见做法是让两位眼科医生独立标注同一批图像,计算分割掩膜的 Dice 一致性系数和分类标签的 Cohen's Kappa。Kappa 值低于 0.8 的样本需要第三方仲裁重新标注。分割任务里,标注者间 Dice 达到 0.85 以上通常认为标注规范足够稳定。
2.3.2 排除标准
以下几类图像需要从数据集中剔除:眼睑严重闭合导致虹膜被完全遮挡的图像(能拍到头但无法判断虹膜边界)、佩戴美瞳或强反光导致虹膜纹理不可辨识的图像、图像经过美颜滤镜处理的(边缘被虚化,像素级标注没有意义)。这些排除标准在数据采集阶段就要记录,避免后期花大量时间筛选。
2.3.3 边界细化
如果目标是精确分割上睑缘边缘,所有人都要遵守同一个约定:标注的是「实际睑缘线」而非「可见皮肤褶皱」。这两者在重度上睑下垂患者身上差距很大。标注工具里放大到 200% 再逐点打点是最慢但最稳的方式,高级点的做法是用 SAM(Segment Anything Model)预标注后让医生修正——眼科医生普遍认为修 SAM 的 mask 比从零画快得多,但要用低灰度阈值避免 SAM 把虹膜纹理也吞进去。
提示:拿到任何公开的眼睛图像数据集,先跑一段数据探索脚本统计掩膜像素分布。如果掩膜值不是 0/1/2 三值而是带有中间灰度,说明文件被压缩或标注工具保存了抗锯齿边缘,需要重新进行阈值化处理。3. 预处理与数据增强:从原始眼部图像到模型输入的标准化流程
3.1 尺寸标准化与图像归一化
眼科图像的原始分辨率差异很大:裂隙灯显微镜拍摄的图像可能高达 2048×1536,手机拍摄的眼部特写则只有 720×1080。分割任务通常统一到 512×512,这个尺寸是 U-Net 系模型的常用默认选择——4 次下采样后特征图是 32×32,保留了足够空间细节,又不会让显存压力过大。分类任务可以接受更小的 224×224 输入,因为 ImageNet 预训练模型的权重就是以这个尺寸设计的。
归一化不能简单套用 ImageNet 的 mean/std。医学图像(尤其是裂隙灯图像)的亮度分布与自然图像差异明显,直接用 [0.485, 0.456, 0.406] 这套参数往往导致对比度偏低。我一般会先对训练集的每个通道统计均值方差,然后做标准化:
import cv2 import numpy as np from albumentations import Compose, CLAHE, Resize # 统计训练集各通道的均值和标准差(一次性脚本) means, stds = [], [] for path in train_image_paths: img = cv2.imread(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = img.astype(np.float32) / 255.0 means.append(img.reshape(-1, 3).mean(axis=0)) stds.append(img.reshape(-1, 3).std(axis=0)) mean = np.mean(means, axis=0) std = np.mean(stds, axis=0) print("RGB mean:", mean) # 例:0.412, 0.388, 0.402 print("RGB std:", std) # 例:0.214, 0.203, 0.198这里把图像从 BGR 转成 RGB 是因为 PyTorch 预训练模型的权重是按 RGB 顺序加载的。统计训练集而非整个数据集的均值和标准差,避免测试集信息污染训练过程——虽然影响很小,但严谨的 benchmark 会这样做。
注意:统计均值时把像素值缩放到 0~1 再算,这与后续用 imagenet-stats 还是 dataset-stats 的用法保持一致。如果你直接用 0~255 的整数算均值,得到的 mean 要除以 255 后输入模型。
3.2 针对眼部图像的增强策略
通用增强(随机翻转、裁剪、亮度抖动)够用,但不充分。上睑下垂图像有几个特定问题需要针对性增强。
3.2.1 对比度受限自适应直方图均衡化
裂隙灯图像的中心亮区与边缘暗区对比度差异大。CLAHE 在局部区域做直方图均衡化,能提升虹膜纹理与眼睑边缘的可见度——这是处理这类图像最值得优先尝试的预处理步骤:
import cv2 def apply_clahe(img, clip_limit=2.0, grid_size=(8, 8)): """ 对亮度通道做 CLAHE,保留色彩信息。 clip_limit 越大对比度提升越明显,过大会带来噪声放大。 """ lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) l_channel, a_channel, b_channel = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=grid_size) l_channel_clahe = clahe.apply(l_channel) lab_clahe = cv2.merge((l_channel_clahe, a_channel, b_channel)) return cv2.cvtColor(lab_clahe, cv2.COLOR_LAB2RGB)参数含义:clip_limit限制了对比度放大的上限,2.0 是保守值;grid_size决定局部块大小,对于包含大面积虹膜纹理的图像,8×8 比 4×4 更不容易产生块状伪影。注意 CLAHE 只对 LAB 空间的 L(明度)通道做,保留 AB 彩色通道,避免颜色偏移——这对后续分类判断「眼睑是否发红」这类线索很关键。
3.2.2 模拟拍摄条件扰动的增强
裂隙灯拍摄时,患者头部微小移动会产生模糊和几何畸变。在增强阶段加入高斯模糊和轻微透视变换,可以提高模型对采集设备差异的鲁棒性。但注意不要对分割掩膜做相同强度的模糊——掩膜是二值语义标签,模糊会破坏边缘的硬边界。正确的做法是让图像和掩膜共享几何变换(旋转、缩放、翻转),但跳过像素级变换(模糊、噪声、CLAHE):
from albumentations import ( Compose, HorizontalFlip, ShiftScaleRotate, RandomBrightnessContrast, GaussianBlur, OpticalDistortion ) train_transform = Compose([ HorizontalFlip(p=0.5), # 左右眼镜像,天然对称 ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.7), # 小角度旋转 + 轻微缩放 RandomBrightnessContrast(brightness_limit=0.2, contrast_limit=0.2, p=0.5), GaussianBlur(blur_limit=(3, 5), p=0.3), # 模拟失焦 CLAHE(clip_limit=(1, 3), tile_grid_size=(8, 8), p=0.5), ]) train_transform_mask = Compose([ HorizontalFlip(p=0.5), ShiftScaleRotate(shift_limit=0.05, scale_limit=0.1, rotate_limit=15, p=0.7), ])这两个Compose对象必须使用固定的随机种子同步调用:先给图像做第一个 transform,再把同一个 seed 传入第二个 transform 处理掩膜。用albumentations的ReplayCompose或者给Compose传入additional_targets参数都能实现这种同步,后一种更简洁——直接在additional_targets里声明mask目标,图像和掩膜共享所有几何增强参数。
rotate_limit=15这个值不能更大。眼睑和虹膜的解剖结构有固定朝向,旋转太多会让模型学到错误的几何先验——真实拍摄时患者头部倾斜很少超过 15 度。另外,不建议使用ElasticTransform(弹性形变),因为眼睑组织虽然有柔韧性但不会产生随机的局部扭曲,弹性形变反而会让分割边缘失去临床意义。
3.2.3 为什么不做随机裁剪
通用目标检测任务里随机裁剪是标配,但上睑下垂分割任务里,裁剪位置如果偏离了虹膜中心,掩膜和图像的对应关系虽然不变,模型的注意力会被引导到错误区域。我通常只在「先检测出眼睛区域再裁剪」的流水线里用固定比例的中心裁剪,而不是随机裁剪。如果数据集里眼睛并非始终居中,优先用目标检测框定位后再裁剪到统一尺寸。
3.3 类不平衡的采样层处理
上睑下垂数据集中,正常样本通常远多于中度和重度样本。这种分布反映真实门诊比例,但直接训练会让模型偏向预测「正常」。
处理方式分两层。第一层是采样器层面:
from torch.utils.data import WeightedRandomSampler # 假设 labels 是长度为 N 的分类标签数组,classes 是类名列表 class_counts = np.bincount(labels, minlength=len(classes)) class_weights = 1.0 / class_counts sample_weights = class_weights[labels] sampler = WeightedRandomSampler( weights=sample_weights, num_samples=len(sample_weights), replacement=True )这段逻辑的核心是给少数类样本更高的采样概率。replacement=True表示放回抽样,少数类会在每个 epoch 中重复出现。num_samples设置为样本总数,保证每个 epoch 的迭代步数与均匀采样一致,方便与学习率调度器配合。
第二层是损失函数层面的权重。两个层面可以同时使用,但要注意:采样器已经改变了有效类别分布,损失函数里再加权重会双重放大少数类的梯度。我倾向于在采样器修正分布后,损失函数只加一个很小的类别权重(0.5 到 0.8 之间),主要用于缓解残余偏差。
4. 分类与分割双任务训练:网络选型、损失函数与评估指标
4.1 双头网络结构的选择
两种主流方案:一种是两个独立模型分别做分类和分割,另一种是共享 encoder 的双头模型。小数据集上用两个独立模型更稳——先训练分割模型,再用分割结果提取特征去辅助分类,这样即使分割效果不完美,分类模型也不会被连带拖累。数据集足够大或需要端到端优化时,双头模型是更经济的选择。
4.1.1 Encoder 的选型
分割任务推荐 U-Net 结构,其中 encoder 直接用 EfficientNet 或者 ResNet 的预训练权重(PyTorch 生态下用segmentation_models_pytorch这个库很省事,但你要理解它背后的原理,不能只当黑盒)。在医学小数据集上,ResNet34 比 ResNet50 更合适——深度更浅、参数量更少,不容易在小数据上过拟合。分类任务单独用 EfficientNet-B3 即可。
4.1.2 双头模型的话,梯度怎么回传
双头模型一个常见的坑是分类头与分割头的梯度量级差距很大。分割头的损失通常在 0.5~1.0 这个尺度,分类头的交叉熵损失则在 0.1~1.5 之间,但梯度范数可能差一个数量级。解决方式是在两个头之间使用梯度缩放:
# 双头模型前向传播与加权损失的简化示意 class PtosisModel(nn.Module): def __init__(self, n_classes=4, n_mask_classes=3): super().__init__() self.encoder = timm.create_model("efficientnet_b3", pretrained=True, features_only=True) self.seg_head = nn.Sequential( nn.Conv2d(64, 32, 3, padding=1), nn.ReLU(), nn.Conv2d(32, n_mask_classes, 1) ) self.cls_head = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(64, n_classes) ) def forward(self, x, return_mask=True): # features_only=True 时取最后一层特征,64 通道 feat = self.encoder(x)[-1] seg_logits = self.seg_head(feat) # 上采样到原始分辨率 cls_logits = self.cls_head(feat) if return_mask: seg_logits = F.interpolate(seg_logits, size=x.shape[2:], mode="bilinear", align_corners=False) return cls_logits, seg_logits注意timm的features_only=True返回多尺度特征列表,这里只取最后一层。双任务的核心是让 encoder 学到既对纹理敏感(分割)又对全局结构敏感(分类)的表征。实际训练时对两个 head 的损失加权,分割损失权重设为 0.7、分类损失 0.3 是常见起点,分割任务难度更高、能提供更强的梯度信号。
4.2 损失函数组合与训练关键参数
分类头用带标签平滑的交叉熵。上睑下垂分级是序数任务,预测 1 和 2 的差异比预测 0 和 3 的差异小,标签平滑可以软化硬标签,让模型更少「过度自信」。分割头用 Dice Loss 和交叉熵的组合:
提示:分割头的损失不要全程用纯 Dice Loss。Dice Loss 在小目标分割(虹膜区域的边缘区域)上梯度不稳定,容易震荡。Dice + CE 的组合(Dice 权重 0.6、CE 权重 0.4)收敛更平稳。训练参数参考下表(这批参数在 1000 到 5000 张图像规模的眼科数据集上表现稳定):
| 参数 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 512 × 512 | 分割任务默认,分类任务可降到 256 |
| Batch size | 8 | 512 尺寸 + U-Net 结构下 16GB 显存的极限 |
| 优化器 | AdamW | weight decay 设 1e-4,比 Adam 更稳 |
| 学习率 | 1e-4(主模型)/ 1e-5(分类头) | encoder 共享时不区分 head,双头模型可给分类头更小 lr |
| 调度器 | CosineAnnealingLR | T_max 设为 epoch 数 |
| Epochs | 60 | 医学图像小数据集 commonly 用早停,不需要很多 epoch |
| 类别权重(分类) | [0.5, 0.8, 1.0, 1.5] | 对应正常/轻/中/重,少数类权重更大 |
| Dice 权重 vs CE 权重 | 0.6 / 0.4 | 分割损失内部的子权重 |
数据增强的强度要随着训练进度衰减。前 20 个 epoch 用强增强(旋转 15 度、亮度扰动 ±20%),后 20 个 epoch 逐渐减弱到旋转 5 度、亮度扰动 ±10%。原因:强增强在训练初期提供正则化,但在后期会阻挠模型收敛到更精确的边界——增强带来的噪声使模型无法精修边缘。这个「增强退火」技巧在分割任务里效果显著,很多框架没有内置,需要手动实现。
4.3 评估指标体系与阈值选择
分类任务的评估不能只看 Accuracy。上睑下垂数据集中正常样本占比可能超过 70%,一个全预测「正常」的模型就能拿到 0.7 的 Accuracy,但这毫无临床价值。需要同时报告:
- Sensitivity(敏感性/召回率):所有真实患者中被检出的比例
- Specificity(特异性):所有正常者中被正确判为正常的比例
- AUC:不依赖阈值的排序能力
- Macro-F1:各类别 F1 的算术平均,对少数类更敏感
分割任务用 Dice 和 IoU。注意两者的数值关系:IoU = Dice / (2 - Dice),Dice 永远不小于 IoU。例如 Dice 0.85 对应 IoU 约 0.74。报告指标时两个都列出,不必二选一。
4.3.1 阈值选择与校准
多分类阈值的默认做法是取概率最大的类别,但这在序数任务里有问题。两种更符合实用场景的方式:第一,使用 Youden 指数(J = Sensitivity + Specificity - 1)在每个类别上寻找最优阈值;第二,因为类别有序,直接对概率做加权累加得到「严重度得分」,按医生的就诊决策阈值划分。第二种方式更贴近临床——医生不关心「度」的边界,关心的是「要不要手术」,而手术标准通常与严重度得分相关。
5. 从分割结果到可解释特征、跨数据集的迁移验证
5.1 把分割掩膜变成定量特征
分类任务知道「有没有病」还远远不够。上睑下垂的临床决策依赖量化指标,比如虹膜暴露比例(眼睑遮盖了多少角膜)和睑裂高度。分割掩膜可以直接计算这些特征,这正是分割任务与分类任务形成闭环的地方:
import numpy as np def compute_iridial_exposure(mask): """ mask: shape (H, W),1 代表虹膜,2 代表眼睑覆盖区 """ iris_area = np.sum(mask == 1) eyelid_area = np.sum(mask == 2) total_area = iris_area + eyelid_area return iris_area / max(total_area, 1) # 暴露比例,值越小表示遮盖越严重这个暴露比例可以当作一个生物学上的连续特征与分类 logits 拼接,也可以单独用来做决策。有了连续特征,模型不再只输出一个离散等级,而是能回答「这个患者的 MRD 大约是多少毫米」——前提是你有一批带有 MRD 临床测量值的标注。
5.2 标注不一致的排错方法
训练时 loss 降不下去,第一步先检查标注,而不是调模型。把训练集的掩膜和原始图像叠加可视化,检查以下典型问题:
- 左右眼标注是否统一(有的标注者可能把虹膜和眼睑像素值互换)
- 重度上睑下垂样本中,虹膜区域被大面积遮盖时,标注者是否仍然标出了不完整的虹膜边界
- 图像方向未统一时,模型需要额外学习「左眼右眼镜像」这一与疾病无关的变换
操作方法是用 matplotlib 把每个样本的图、掩膜、预测结果并排拼成一张大图,按 loss 降序排列,直接看 loss 最高的那批样本的标注质量。常常会发现某个标注员在某个时间段标注的样本有明显边界偏移——这是标注疲劳的典型表现。
5.3 跨数据集迁移时的验证策略
上睑下垂数据集之间拍摄设备、光线条件和患者群体差异可能很大。如果用公开数据集训练、在自己的临床数据上微调,需要先做一次层冻结实验:冻结 encoder 的前几层,只微调后几层和分割头,对比全量微调的效果。医学小数据集的共识是「用 ImageNet 做初始化、用同领域数据微调高维语义层」,因为底层边缘纹理特征已经由 ImageNet 学好了,同领域数据更适合微调语义层面。层冻结实验最直接的做法是设置requires_grad=False并记录验证集 Dice 的变化曲线,如果冻结层数增加到某一层之后 Dice 断崖式下跌,说明数据域差异集中在那几层,后续可以针对性微调。
细节上,训练完成后对分割掩膜做一次后处理:去掉小于阈值的连通区域(消除噪声)、对掩膜边缘做一次形态学闭运算(闭合细微空洞),这两个操作能让 Dice 指标提升 0.01~0.03。先处理再提交测试集,不要为了数据好看在评估阶段就做清洗——一行的差异都值得抠,因为模型上线后面对的就是这样不干净的预测结果。
本文还有配套的精品资源,点击获取