☰
585张眼底图训练指南:血管分割与病灶标注的医学图像实战
2026/10/9 19:50:58 网站建设 项目流程

简介:这份资源是面向糖尿病视网膜病变(DR)检测两阶段AI流程第一阶段的分段数据集,适合医学图像处理、眼底病灶识别方向的研究者与算法工程师使用。数据由RetinoMix-5、视网膜血管分段综合分析、IDRiD与MAPLES-DR四个公开数据集整合而成,共585张视网膜眼底图像,覆盖血管掩膜与七类病灶标注,包括微动脉瘤、出血、硬渗出物、软渗出物、视盘、新生血管及棉花斑点,其中部分病灶类型仅来自单一数据源,便于按需筛选训练与验证样本。压缩包为7z格式,内含2000个文件,以1591个png掩膜与408个tif图像为主,另附1个json说明文件,整体约644.8MB,目录结构清晰,便于按数据集与病灶类型组织读取。目前已有28人学习下载,适合用于血管分割、病灶检测模型的预训练、对比实验与流程验证,能帮助读者快速搭建DR筛查第一阶段的训练数据基础。

1. 585 张眼底图能训出什么:从血管分割到病灶标注的落地判断

手里只有 585 张 JPG 眼底图,第一反应往往是「这点数据够干嘛」。但如果你做过视网膜血管分割或病灶检测,就会知道这个量级恰好卡在一个微妙的位置:它不足以从零训一个通用大模型,却足够在预训练权重上做一次像样的微调,或者验证一套标注、增强、评估流程能不能跑通。视网膜眼底血管和病灶分段数据集(585 张图像)JPG 这类资源,核心价值不在「大」,而在「全」——血管和病灶两类目标同时存在,意味着你可以在一份数据上同时练分割和检测两条链路,省掉跨数据集对齐标注格式的麻烦。

适合谁用?一是想入门医学图像分割的开发者,血管这种细长、低对比度、拓扑敏感的目标,比常规自然图像分割更能暴露模型和损失函数的问题;二是做眼底筛查相关原型验证的团队,585 张足够跑通从数据清洗到指标评估的闭环,判断某个 backbone 或损失组合值不值得上更大规模的数据。不适合谁?指望直接训出可上临床的模型的人,这个量级只能做方法验证,泛化性必须靠外部数据补。

2. 拿到 JPG 先别急着训:数据体检与标注格式对齐

2.1 为什么 JPG 格式决定了你的预处理上限

这批数据是 JPG,不是 PNG 或 TIFF。JPG 是有损压缩,眼底图里血管边缘和微动脉瘤这类细小病灶,恰恰落在高频细节区域,压缩伪影会直接吃掉边缘梯度。我一般拿到 JPG 医学数据的第一件事不是写 dataloader,而是做一次压缩质量体检:看图像块效应是否明显、血管最细处是否已经糊成一条灰带。

常见做法是用 OpenCV 读进来后转灰度,算一下拉普拉斯方差,数值越低说明边缘越糊。如果整批数据方差普遍偏低,说明压缩比较激进,这时候要么在增强里加锐化补偿,要么直接放弃对最细血管的分割精度预期,把评估重点放在主干血管和明显病灶上。

import cv2 import numpy as np import os def check_sharpness(img_dir): """遍历目录,统计每张图的拉普拉斯方差,判断JPG压缩对边缘的影响""" results = [] for fname in os.listdir(img_dir): if not fname.lower().endswith(('.jpg', '.jpeg')): continue path = os.path.join(img_dir, fname) img = cv2.imread(path, cv2.IMREAD_GRAYSCALE) if img is None: continue # 拉普拉斯方差:值越低边缘越模糊 lap_var = cv2.Laplacian(img, cv2.CV_64F).var() results.append((fname, lap_var, img.shape)) results.sort(key=lambda x: x[1]) return results # 用法:打印最模糊的10张,人工复核是否血管已经不可辨 res = check_sharpness('./fundus_images') for name, var, shape in res[:10]: print(f"{name} sharpness={var:.1f} size={shape}")

这段代码的逻辑是先转灰度再做拉普拉斯卷积,方差反映整体边缘能量。参数上cv2.CV_64F是为了保留负值,避免取绝对值前丢信息。阈值没有绝对标准,我的经验是方差低于 50 的图要单独拎出来看,低于 20 的基本可以判定血管细节已经不可靠。注意这里只做筛查不做自动剔除,因为有些图整体偏暗但血管其实清晰,得人工过一遍。

2.2 血管标注和病灶标注的格式差异怎么统一

血管分割通常是二值 mask,一条连续曲线;病灶标注可能是多边形、也可能是逐像素分类,还可能是检测框。585 张里如果两类标注格式不一致,直接混着训会出问题。我一般先做一次标注格式盘点,把每张图对应的标注文件读出来,看是 PNG mask、JSON polygon 还是 COCO 格式。

统一策略取决于你的主任务。如果主任务是血管分割,病灶标注就转成额外的 mask 通道,用多通道输出;如果主任务是病灶检测,血管 mask 可以降采样成辅助分支做多任务学习。关键是别让两种标注在同一个 loss 里互相干扰,常见做法是给血管和病灶分配不同的 loss 权重,血管用 Dice 类拓扑敏感损失,病灶用交叉熵或 Focal Loss。

标注类型常见存储格式转 mask 方式注意事项
血管二值PNG 单通道直接读,阈值 128注意 0/255 还是 0/1
病灶多边形JSON / XML用 shapely 或 cv2.fillPoly 填充多边形自交要处理
病灶检测框CSV / COCO转成矩形 mask 或单独检测头框和分割别混用同一指标
多类病灶多通道 PNG每类一个通道通道顺序必须固定

盘点完格式,下一步是确认图像和标注的文件名能不能对上。我踩过的坑是标注文件名带后缀差异,比如图像是case_001.jpg,标注是case_001_mask.png,批量匹配时用split('.')[0]会出错。稳妥做法是用os.path.splitext取主名再拼。

3. 从 585 张里榨出训练量:增强、划分与损失函数选型

3.1 眼底图增强不能照搬自然图像那一套

自然图像增强里常用的随机裁剪、颜色抖动、水平翻转,放到眼底图上要打折扣。眼底图是圆形视野,四周有黑色边框,随机裁剪很容易把视野边缘裁掉,导致模型学到一堆黑边。颜色抖动更危险,眼底图的颜色和病灶类型强相关,比如出血点偏红、渗出偏黄,你把色调一抖,标签就错了。

我一般用的增强组合是:随机旋转(0 到 360 度,眼底图旋转不变性成立)、水平翻转、轻微的亮度和对比度调整(幅度控制在 10% 以内)、以及弹性形变(模拟不同设备的几何畸变)。裁剪只用中心裁剪或先做圆形视野 mask 再裁。弹性形变对血管分割特别有用,因为血管拓扑在轻微形变下保持不变,能逼模型学结构而不是记位置。

import albumentations as A import cv2 # 眼底图专用增强管线 train_transform = A.Compose([ A.HorizontalFlip(p=0.5), A.RandomRotate90(p=0.5), A.Rotate(limit=180, p=0.7, border_mode=cv2.BORDER_CONSTANT), # 亮度对比度小幅调整,避免破坏病灶颜色语义 A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.5), # 弹性形变模拟几何畸变,对血管拓扑友好 A.ElasticTransform(alpha=1, sigma=50, p=0.3), A.Resize(512, 512), ]) # 注意:mask 也要跟着变换,albumentations 会自动同步 # 如果 mask 是多通道,确保每个通道都传进去

参数上ElasticTransform的alpha控制形变强度,sigma控制平滑度,alpha 太大血管会断,我一般不超过 1.5。Rotate的border_mode用常量填充,填 0 就行,因为眼底图外围本来就是黑的。这里没有用RandomCrop,就是因为裁剪对圆形视野不友好。

3.2 585 张怎么划分才不虚高

585 张如果随机按 8:1:1 划分,很容易出现同一只眼、同一患者的图同时出现在训练和验证集里,指标会虚高。眼底图通常双眼成对,左右眼血管结构高度相似。如果数据里有患者 ID 或眼别信息,必须按患者划分,而不是按图像划分。

没有患者 ID 怎么办?我的做法是用图像相似度做聚类,把高度相似的图聚成一簇,再按簇划分。简单点可以用感知哈希或 SSIM 做近邻,复杂点用预训练模型提特征再聚类。585 张的量级,感知哈希足够快。

import imagehash from PIL import Image from sklearn.cluster import DBSCAN import numpy as np def cluster_by_similarity(img_paths, hash_size=8, eps=5): """用感知哈希聚类,避免相似图跨集泄漏""" hashes = [] for p in img_paths: img = Image.open(p).convert('L') h = imagehash.phash(img, hash_size=hash_size) hashes.append(h) # 转成向量算汉明距离 vecs = np.array([h.hash.flatten() for h in hashes]) clustering = DBSCAN(eps=eps, min_samples=1, metric='manhattan').fit(vecs) return clustering.labels_ # 按簇划分,而不是按图划分 # 同一簇的图必须整体进训练或整体进验证

hash_size越大对细节越敏感,8 是常用值。eps是汉明距离阈值,越小簇越细。聚类完按簇随机分配,保证相似图不跨集。这一步多花十分钟,能避免后面指标虚高十几个点的翻车。

3.3 血管和病灶的损失函数不能共用一套

血管分割的核心难点是细长结构,正负样本极度不平衡,普通交叉熵会让模型直接全预测背景。常用组合是 Dice Loss 加 BCE,Dice 管重叠,BCE 管像素级分类。但 Dice 对细小血管的梯度不稳定,可以再加一个 clDice 或拓扑损失,专门约束连通性。

病灶分割如果是不规则区域,Focal Loss 更合适,能压易分样本的权重。如果病灶很小,比如微动脉瘤只有几个像素,还要考虑在损失里给正样本加权,或者用 Tversky Loss 调整 FP/FN 的惩罚比例。

任务推荐损失关键参数适用场景
血管分割Dice + BCEDice 权重 0.5 到 0.7细长结构,类别不平衡
血管拓扑clDice迭代次数 3 到 5连通性要求高
病灶分割Focal Lossgamma 1.5 到 2小目标,易分样本多
病灶分割Tversky Lossalpha 0.3, beta 0.7漏检代价高于误检

我一般先跑一版 Dice+BCE 做 baseline,看血管断裂情况,如果断裂多就加 clDice。病灶那边先看正样本占比,低于 1% 直接上 Focal,别犹豫。

4. 训练链路搭起来:backbone、输入尺寸与评估指标

4.1 backbone 选型:别一上来就上大模型

585 张图,参数量超过 50M 的 backbone 基本会过拟合。我一般从 U-Net 加 ResNet34 编码器起步,或者直接用 nnU-Net 的默认配置。如果要做多任务,可以共享编码器,血管和病灶各接一个解码器。想用 Transformer 类模型,也得选轻量的,比如 SegFormer 的 B0 或 B1,并且必须加载预训练权重。

输入尺寸上,眼底图原始分辨率可能 2000 以上,直接 resize 到 512 会丢细小血管。常见做法是切 patch 训练,推理时再拼回去。512 的 patch 对血管够用,对微动脉瘤可能还是丢,那就得用 1024 或者多尺度。

import torch import torch.nn as nn from segmentation_models_pytorch import Unet # 双解码器多任务:共享编码器,血管和病灶各一个头 class MultiTaskFundus(nn.Module): def __init__(self, encoder='resnet34', num_lesion_classes=1): super().__init__() # 血管分支 self.vessel_model = Unet( encoder_name=encoder, encoder_weights='imagenet', in_channels=3, classes=1, ) # 病灶分支复用同一个编码器 self.lesion_head = nn.Sequential( nn.Conv2d(64, 32, 3, padding=1), nn.ReLU(inplace=True), nn.Conv2d(32, num_lesion_classes, 1), ) def forward(self, x): # 提取编码器特征 features = self.vessel_model.encoder(x) # 血管解码 vessel_out = self.vessel_model.decoder(features) vessel_out = self.vessel_model.segmentation_head(vessel_out) # 病灶用浅层特征,保留细节 lesion_out = self.lesion_head(features[0]) # 上采样到输入尺寸 lesion_out = nn.functional.interpolate( lesion_out, size=x.shape[2:], mode='bilinear', align_corners=False ) return vessel_out, lesion_out

这里encoder_weights='imagenet'是关键,585 张从零训编码器基本没戏。病灶头用features[0]浅层特征,是因为病灶边界依赖细节,深层特征太抽象。实际用的时候要注意显存,双解码器比单任务多吃不少,batch size 可能要降到 4 或 2。

4.2 评估指标别只看 Dice

血管分割只看 Dice 会骗人。一条血管断成两截,Dice 可能还有 0.7,但临床上这条血管已经不可用。所以血管评估必须加连通性指标,比如 clDice 或者连通域数量差异。病灶评估除了 Dice,还要看检测率,尤其是小病灶的召回。

我一般会同时记录:Dice、IoU、灵敏度、特异度、以及血管的连通域数量差。连通域数量差就是预测和真值的连通域个数之差,差越大说明断裂或粘连越严重。这个指标不用额外库,用cv2.connectedComponents就能算。

import cv2 import numpy as np def connectivity_diff(pred_mask, gt_mask): """计算预测和真值的连通域数量差,反映血管断裂/粘连""" pred_bin = (pred_mask > 0.5).astype(np.uint8) gt_bin = (gt_mask > 0.5).astype(np.uint8) n_pred, _ = cv2.connectedComponents(pred_bin) n_gt, _ = cv2.connectedComponents(gt_bin) # 减去背景的1 return abs((n_pred - 1) - (n_gt - 1)) # 用法:在验证循环里累计 # 连通域差突然变大,说明模型开始断血管,该调损失或加拓扑约束了

这个指标很敏感,训练过程中如果它突然跳高,基本就是损失函数或学习率出了问题,比 Dice 更早报警。

5. 避坑与排查:585 张眼底数据最容易翻车的五个地方

5.1 现象:验证集 Dice 0.85,换一批图直接掉到 0.5

原因:按图像随机划分导致相似图泄漏,验证集和训练集有大量近邻图,指标虚高。解决:按患者或相似度聚类划分,重新跑一遍 baseline,接受真实指标可能低 10 到 15 个点。这一步没有后悔药,越早做越好。

5.2 现象:血管预测结果整体偏移,像蒙了一层

原因:JPG 压缩导致图像和 mask 对齐出现亚像素偏移,或者预处理里 resize 用了不同插值方式。解决:图像和 mask 必须用同一套几何变换,resize 时 mask 用最近邻,图像用双线性。检查方法是对齐后叠加显示,看血管边缘是否重合。

5.3 现象:病灶分割全预测背景,Dice 接近 0

原因:正样本占比极低,交叉熵被背景主导。解决:换 Focal Loss 或 Tversky Loss,同时在采样时对含病灶的 patch 过采样。先确认标注里病灶像素占比,低于 0.5% 就必须做重采样。

5.4 现象:训练 loss 震荡,验证指标忽高忽低

原因:batch size 太小加上 BatchNorm 在小 batch 下统计不稳。585 张图如果切 patch,batch 可能只有 2 到 4。解决:把 BatchNorm 换成 GroupNorm,或者用梯度累积模拟大 batch。GroupNorm 在医学图像小 batch 场景下基本是标配。

5.5 现象:推理时显存爆掉,或者速度慢到没法用

原因:输入分辨率太高,或者多任务模型没做推理优化。解决:推理用滑动窗口切 patch,窗口间重叠 1/4 避免拼接缝。或者把模型转成 ONNX 再用 ONNX Runtime 跑,速度通常能快一倍以上。别在验证阶段就用全图推理,先切 patch 看效果。

6. 把 585 张用到极致:伪标签、外部验证与一个评估技巧

585 张的天花板很明显,想再往上走,伪标签是性价比最高的手段。做法是先用这 585 张训一个 baseline,然后找一批无标注眼底图,用 baseline 推理出伪 mask,挑置信度高的加入训练集。置信度筛选可以用预测概率的熵,熵低的样本伪标签更可靠。注意伪标签别一次性全加,分批加,每加一批重新评估验证集,防止确认偏差累积。

外部验证是另一个必须做的动作。585 张训出来的模型,必须在一个完全独立的数据集上测一次,哪怕只有几十张。没有外部数据,至少要做跨设备或跨人群的留出测试。我一般会留出 10% 做「从未参与任何训练和调参」的测试集,只在最后跑一次,这个数字才是能写进报告的。

一个具体技巧:评估血管分割时,别只看整图 Dice,把血管按粗细分成三档分别算。粗血管 Dice 通常很高,细血管才是拉开差距的地方。分档方法是对真值 mask 做形态学腐蚀,腐蚀掉的就是细血管。这样你能清楚知道模型到底行不行,而不是被一个平均 Dice 糊弄过去。

import cv2 import numpy as np def stratified_vessel_dice(pred, gt, kernel_size=3): """按血管粗细分档算Dice,细血管用腐蚀后的残差""" gt_bin = (gt > 0.5).astype(np.uint8) pred_bin = (pred > 0.5).astype(np.uint8) kernel = np.ones((kernel_size, kernel_size), np.uint8) # 腐蚀得到粗血管 thick = cv2.erode(gt_bin, kernel, iterations=1) # 残差是细血管 thin = gt_bin - thick def dice(a, b): inter = (a & b).sum() return 2 * inter / (a.sum() + b.sum() + 1e-6) return { 'thick_dice': dice(thick, pred_bin & thick), 'thin_dice': dice(thin, pred_bin & thin), 'overall': dice(gt_bin, pred_bin), } # 如果 thin_dice 比 thick_dice 低 0.2 以上,说明模型对细血管不行 # 该加拓扑损失或者提高输入分辨率了

这个分档评估我每次做血管分割都会跑,它比任何单一指标都能说明问题。细血管 Dice 低是常态,但低太多就说明模型没学到细结构,得回去调损失或分辨率。

最后说个习惯:每次拿到新的眼底数据,先花半天做数据体检和划分,别急着写模型。585 张这个量级,数据划分和增强策略对结果的影响,往往比换 backbone 大得多。我在这上面翻过车,模型改了一周不如把泄漏的验证集重新划一次。希望帮到你。

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

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

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

立即咨询