585张眼底图训练指南:血管分割与病灶标注的医学图像实战

发布时间:2026/10/9 19:52:08
585张眼底图训练指南:血管分割与病灶标注的医学图像实战
简介这份资源是面向糖尿病视网膜病变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(keylambda 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(p0.5), A.RandomRotate90(p0.5), A.Rotate(limit180, p0.7, border_modecv2.BORDER_CONSTANT), # 亮度对比度小幅调整避免破坏病灶颜色语义 A.RandomBrightnessContrast(brightness_limit0.1, contrast_limit0.1, p0.5), # 弹性形变模拟几何畸变对血管拓扑友好 A.ElasticTransform(alpha1, sigma50, p0.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_size8, eps5): 用感知哈希聚类避免相似图跨集泄漏 hashes [] for p in img_paths: img Image.open(p).convert(L) h imagehash.phash(img, hash_sizehash_size) hashes.append(h) # 转成向量算汉明距离 vecs np.array([h.hash.flatten() for h in hashes]) clustering DBSCAN(epseps, min_samples1, metricmanhattan).fit(vecs) return clustering.labels_ # 按簇划分而不是按图划分 # 同一簇的图必须整体进训练或整体进验证hash_size越大对细节越敏感8 是常用值。eps是汉明距离阈值越小簇越细。聚类完按簇随机分配保证相似图不跨集。这一步多花十分钟能避免后面指标虚高十几个点的翻车。3.3 血管和病灶的损失函数不能共用一套血管分割的核心难点是细长结构正负样本极度不平衡普通交叉熵会让模型直接全预测背景。常用组合是 Dice Loss 加 BCEDice 管重叠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漏检代价高于误检我一般先跑一版 DiceBCE 做 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, encoderresnet34, num_lesion_classes1): super().__init__() # 血管分支 self.vessel_model Unet( encoder_nameencoder, encoder_weightsimagenet, in_channels3, classes1, ) # 病灶分支复用同一个编码器 self.lesion_head nn.Sequential( nn.Conv2d(64, 32, 3, padding1), nn.ReLU(inplaceTrue), 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, sizex.shape[2:], modebilinear, align_cornersFalse ) return vessel_out, lesion_out这里encoder_weightsimagenet是关键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 张图如果切 patchbatch 可能只有 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_size3): 按血管粗细分档算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, iterations1) # 残差是细血管 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 大得多。我在这上面翻过车模型改了一周不如把泄漏的验证集重新划一次。希望帮到你。本文还有配套的精品资源点击获取