眼底血管分割数据集实战:从2类标签到可视化全流程

发布时间:2026/10/11 22:03:57
眼底血管分割数据集实战:从2类标签到可视化全流程
简介本资源面向医学图像分割方向的初学者与算法实践者提供一套可直接上手的眼底血管分割数据集与配套工具帮助解决血管提取任务中数据获取与标签制作的门槛问题。数据集基于DRIVE扩充图像分辨率为500至1000训练集含71张原图与71张对应mask测试集含17张原图与17张maskmask为前景255的二值图像便于直观观察血管区域。压缩包共178个文件以177张png图像为主涵盖原图与标签另附1个Python可视化脚本整体约45.88MB采用7z格式打包。该脚本可随机抽取一张图片将原始图像、GT图像以及GT在原图上的蒙板效果一并展示并保存至当前目录方便快速核验数据质量与分割效果。目前已有1264人学习下载适合用于血管分割模型的训练、验证与结果可视化对比也可作为医学图像分割入门练手与实验复现的参考素材。1. 眼底血管分割数据集从 2 类标签到可视化一套能直接跑通的落地路径眼底血管分割是医学图像分割里最经典的入门任务之一也是很多从业者从自然图像分割转向医学领域的第一个真实场景。它要解决的问题很具体给定一张彩色眼底照把视网膜上的血管像素逐点判出来输出一张二值掩膜。血管占整张图的比例通常只有 5% 到 15%属于典型的极端类别不平衡这也是它比普通语义分割更考验工程细节的地方。一套带类别标签和可视化代码的眼底血管分割数据集价值不在于图片数量堆得多大而在于标签质量、目录结构和配套脚本能不能让你当天就把训练跑起来。这篇笔记面向两类人刚拿到数据集不知道从哪下手的新手以及想把它接进自己训练管线、需要看清参数边界的老手。下面按「数据长什么样 → 怎么读进来 → 怎么训 → 坑在哪 → 怎么验证」的顺序讲透。2. 眼底血管分割数据集的结构与标签体系先看清 2 分割到底分的是什么2.1 2 分割标签的物理含义与像素取值标题里的「2 分割」指的是二分类分割不是两个独立类别。标签图是一张单通道灰度图背景像素值为 0血管像素值为 1或 255取决于导出方式。这一点必须先确认因为很多公开数据集导出时用的是 0/255而 PyTorch 的交叉熵损失默认要求类别索引从 0 开始连续如果直接拿 255 当类别号会直接报越界或者静默算错。常见做法是读入标签后先做一次唯一值统计确认实际取值集合再决定是否归一化。我一般会写一个一次性检查脚本把整个标签目录扫一遍输出每张图的唯一值和血管像素占比。这一步花不了两分钟但能省掉后面几小时的玄学调参。import os import numpy as np from PIL import Image label_dir dataset/labels stats [] for name in sorted(os.listdir(label_dir)): if not name.lower().endswith((.png, .tif, .tiff, .bmp)): continue arr np.array(Image.open(os.path.join(label_dir, name))) uniq np.unique(arr) vessel_ratio (arr 0).mean() stats.append((name, uniq.tolist(), round(float(vessel_ratio), 4))) for s in stats[:10]: print(s) ratios [s[2] for s in stats] print(血管占比 min/mean/max:, min(ratios), sum(ratios)/len(ratios), max(ratios))这段代码的逻辑是遍历标签目录对每张图取唯一值集合和前景占比。唯一值告诉你标签是 0/1 还是 0/255前景占比告诉你类别不平衡有多严重。参数上arr 0这个判断对 0/1 和 0/255 都成立所以不用先归一化就能统计。如果发现唯一值里出现了 2、3 这种值说明标签被做过连通域编号需要先二值化再用。2.2 图像与标签的配对命名规则数据集能不能顺利读进来八成取决于配对规则。眼底血管数据集常见的命名有两种一种是图像和标签同名不同后缀比如01_test.tif配01_test.png另一种是图像带_img、标签带_mask后缀。标题里提到「类别标签」说明标签是独立文件而不是内嵌在图像元数据里所以配对逻辑必须写死在一个地方不要在每个脚本里各写一遍。我一般会写一个build_pairs函数返回(image_path, label_path)列表训练、验证、可视化全部复用这一个函数。这样一旦命名规则变了只改一处。import os def build_pairs(image_dir, label_dir, img_ext(.tif, .png, .jpg), lbl_ext(.png, .tif)): pairs [] label_map {} for name in os.listdir(label_dir): stem, ext os.path.splitext(name) if ext.lower() in lbl_ext: label_map[stem] os.path.join(label_dir, name) for name in os.listdir(image_dir): stem, ext os.path.splitext(name) if ext.lower() not in img_ext: continue if stem in label_map: pairs.append((os.path.join(image_dir, name), label_map[stem])) return pairs pairs build_pairs(dataset/images, dataset/labels) print(配对成功:, len(pairs))逻辑说明先建标签的 stem 到路径的字典再遍历图像去匹配。用 stem 匹配而不是全名匹配是为了兼容图像.tif、标签.png这种不同后缀的情况。参数img_ext和lbl_ext按你实际数据改。如果配对数量明显少于图像总数先打印没配上的文件名通常是大小写或者后缀不一致导致的。2.3 分辨率、通道与常见预处理眼底照常见分辨率在 512×512 到 2048×2048 之间通道是 RGB 三通道。血管本身是低对比度的暗红色细线直接送进网络效果一般所以预处理里 CLAHE限制对比度自适应直方图均衡几乎是标配。它作用在绿色通道上效果最好因为血管和背景在绿通道的对比度最高。常见做法是转 LAB 或直接取绿通道做 CLAHE再和原图拼接或者替换绿通道。训练时统一 resize 到 512×512 或 768×768标签用最近邻插值图像用双线性。这里有个容易翻车的点标签 resize 如果用双线性边缘会产生 0.3、0.7 这种中间值二值化阈值没设好就会引入噪声。提示标签 resize 一律用Image.NEAREST图像用Image.BILINEAR这是医学分割里最省心的默认组合。3. 把数据集接进训练管线从 Dataset 到第一个能收敛的模型3.1 写一个带 CLAHE 的 Dataset 类数据集读进来之后下一步是包成torch.utils.data.Dataset。这里的关键不是代码多复杂而是把预处理、增强、标签二值化三件事的边界划清楚。增强只作用在图像和标签同步变换上颜色类增强只作用在图像上标签永远只做几何变换。import cv2 import numpy as np import torch from torch.utils.data import Dataset from PIL import Image class VesselDataset(Dataset): def __init__(self, pairs, size512, trainTrue): self.pairs pairs self.size size self.train train self.clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) def _preprocess(self, img_bgr): green img_bgr[:, :, 1] green self.clahe.apply(green) img_bgr[:, :, 1] green return img_bgr def __getitem__(self, idx): img_path, lbl_path self.pairs[idx] img cv2.imread(img_path, cv2.IMREAD_COLOR) img self._preprocess(img) img cv2.resize(img, (self.size, self.size), interpolationcv2.INTER_LINEAR) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 img (img - img.mean()) / (img.std() 1e-6) lbl np.array(Image.open(lbl_path).convert(L)) lbl cv2.resize(lbl, (self.size, self.size), interpolationcv2.INTER_NEAREST) lbl (lbl 127).astype(np.float32) return torch.from_numpy(img).permute(2, 0, 1), torch.from_numpy(lbl).unsqueeze(0) def __len__(self): return len(self.pairs)逻辑说明_preprocess只对绿通道做 CLAHEclipLimit2.0是控制对比度增强强度的关键参数调太高会把噪声也放大眼底图上一般 2.0 到 3.0 之间。tileGridSize(8,8)决定局部均衡的粒度图小的时候可以调到 4×4。归一化用逐图减均值除标准差比固定 ImageNet 均值更适合医学图像。标签用 127二值化兼容 0/255 和 0/1 两种导出。3.2 损失函数与类别不平衡的处理血管像素占比低直接用 BCE 会让模型倾向于全预测背景因为那样 loss 也很低。常见做法是 BCE 加 Dice 的组合Dice 对前景占比不敏感能把梯度拉回到血管上。权重上我一般 BCE 和 Dice 各占 0.5如果发现召回率上不去把 Dice 权重提到 0.7。import torch import torch.nn as nn import torch.nn.functional as F class BCEDiceLoss(nn.Module): def __init__(self, bce_weight0.5): super().__init__() self.bce_weight bce_weight def forward(self, logits, target): bce F.binary_cross_entropy_with_logits(logits, target) prob torch.sigmoid(logits) inter (prob * target).sum(dim(1, 2, 3)) union prob.sum(dim(1, 2, 3)) target.sum(dim(1, 2, 3)) dice 1 - (2 * inter 1e-6) / (union 1e-6) dice dice.mean() return self.bce_weight * bce (1 - self.bce_weight) * dice逻辑说明binary_cross_entropy_with_logits内部带 sigmoid数值更稳不要在外面再 sigmoid 一次。Dice 按样本算再平均比整批算更稳。1e-6是防止空标签导致除零。参数bce_weight是唯一需要调的0.5 是稳妥起点。3.3 训练循环里必须打印的三个指标训练时只看 loss 会被骗因为 loss 下降可能只是模型学会了全预测背景。必须同时打印前景的 precision、recall 和 Dice。这三个指标里recall 对血管分割最关键临床上漏掉血管比多标几根更严重。def metrics(logits, target, thr0.5): pred (torch.sigmoid(logits) thr).float() tp (pred * target).sum().item() fp (pred * (1 - target)).sum().item() fn ((1 - pred) * target).sum().item() prec tp / (tp fp 1e-6) rec tp / (tp fn 1e-6) dice 2 * tp / (2 * tp fp fn 1e-6) return prec, rec, dice逻辑说明阈值thr默认 0.5但血管分割里 0.5 往往偏低后面验证章节会讲怎么调。这三个指标每个 epoch 在验证集上算一次如果 loss 降但 recall 不涨说明模型在偷懒该加 Dice 权重或者换采样策略了。4. 可视化代码怎么写让标签和预测结果一眼能看出对错4.1 叠加可视化的三种常用画法可视化不是锦上添花是排查数据问题的第一手段。眼底血管分割常用三种画法原图叠红色掩膜、原图和标签并排、原图叠预测和标签的差异图。差异图最有价值绿色画真阳性红色画假阳性蓝色画假阴性一眼就能看出模型是漏检多还是误检多。import cv2 import numpy as np def overlay(img_rgb, mask, color(255, 0, 0), alpha0.4): img img_rgb.copy() layer np.zeros_like(img) layer[mask 0] color return cv2.addWeighted(img, 1 - alpha, layer, alpha, 0) def diff_view(img_rgb, pred, gt): vis img_rgb.copy() tp (pred 0) (gt 0) fp (pred 0) (gt 0) fn (pred 0) (gt 0) vis[tp] (0, 255, 0) vis[fp] (255, 0, 0) vis[fn] (0, 0, 255) return vis逻辑说明overlay用addWeighted做透明叠加alpha控制掩膜透明度0.4 左右既能看清血管又不遮住原图细节。diff_view直接按像素布尔运算上色注意输入 pred 和 gt 都必须是二值。颜色顺序是 RGB如果你用 OpenCV 显示记得转 BGR。4.2 批量出图与命名规范单张看效率太低我一般写一个批量出图脚本每个 epoch 在验证集上抽 8 张拼成 2×4 的网格存下来。命名带上 epoch 和指标比如ep020_dice0.812.png这样翻历史图的时候不用再查日志。import os import numpy as np import cv2 def save_grid(images, save_path, cols4): rows (len(images) cols - 1) // cols h, w images[0].shape[:2] grid np.zeros((rows * h, cols * w, 3), dtypenp.uint8) for i, im in enumerate(images): r, c divmod(i, cols) grid[r*h:(r1)*h, c*w:(c1)*w] im cv2.imwrite(save_path, cv2.cvtColor(grid, cv2.COLOR_RGB2BGR))逻辑说明save_grid把多张同尺寸图拼成网格cols控制列数。注意 OpenCV 写盘要 BGR所以最后转一次。如果图尺寸不一致先统一 resize 再拼。4.3 用可视化反查标签质量问题可视化最大的价值是发现标签本身的错误。眼底血管标签常见问题有三类血管断裂标注时漏了细血管、边缘毛刺标注工具抗锯齿导致、以及视盘区域误标视盘本身是亮区容易被误当成血管。把原图和标签叠加后逐张翻通常翻二三十张就能判断这批标签的整体质量。如果断裂和毛刺比例超过一成建议先做标签清洗再训练否则模型学到的就是噪声。5. 避坑与排查眼底血管分割里最容易翻车的 5 个点5.1 现象训练 loss 正常下降但验证集 Dice 一直卡在 0.6 以下原因最常见的是标签二值化阈值不对。如果标签是 0/255而你在 Dataset 里用了 0之外的条件或者 resize 时用了双线性导致边缘出现中间值模型学到的边界就是模糊的。另一个原因是 CLAHE 的clipLimit调太高把背景噪声也增强了模型把噪声当血管学。解决先跑 2.1 的唯一值统计脚本确认标签取值再把标签 resize 改成INTER_NEARESTclipLimit降到 2.0。如果还不行把 Dice 权重提到 0.7 再看。5.2 现象模型预测结果全是背景recall 接近 0原因类别不平衡太严重加上 BCE 权重过高。血管占比 5% 时全预测背景的 BCE 也能降到很低模型没有动力去学前景。解决把bce_weight降到 0.3 甚至 0.2让 Dice 主导。如果还不行在采样上做文章用WeightedRandomSampler提高含血管多的图被抽到的概率。注意不要直接对像素做重采样那样会破坏空间结构。5.3 现象不同机器上跑出来的 Dice 差好几个点原因预处理里的归一化用了逐图统计而不同批次的图亮度差异大导致同一张图在不同 batch 里归一化结果不一致。另一个隐藏原因是 OpenCV 读图和 PIL 读图的通道顺序、gamma 处理不同。解决固定用同一种读图库我一般统一用 OpenCV 读、最后转 RGB。归一化改成固定均值方差或者把逐图归一化的统计量存下来复用。数据增强里的随机种子也要固定否则对比实验没有意义。5.4 现象可视化叠加图里血管位置整体偏移几个像素原因图像和标签在 resize 时用了不同的插值方式或者原图本身带黑边而标签没有。眼底照经常有圆形视野外的黑色区域如果图像做了裁剪而标签没同步裁就会错位。解决把图像和标签的几何变换写在一个函数里同步执行。裁剪参数从图像算出来后直接作用到标签上。检查方法是把原图和标签叠加看血管边缘是否重合偏移超过 2 像素就要查。5.5 现象推理时单张图很快但批量推理显存爆掉原因推理时没有用torch.no_grad()或者 batch size 设得和训练一样大。分割模型在 1024×1024 输入下显存占用是 512×512 的四倍左右。解决推理包在with torch.no_grad():里batch size 从 1 开始试。如果显存还是不够用滑动窗口切块推理再拼回去重叠区域取平均。切块大小建议 512重叠 64 像素能避免边界伪影。6. 进阶把 Dice 从 0.80 推到 0.85 的几个具体手法第一个手法是阈值搜索。训练完模型后不要直接用 0.5 当阈值在验证集上从 0.3 到 0.7 按 0.05 步长扫一遍取 Dice 最高的那个。眼底血管分割里最优阈值经常落在 0.4 到 0.55 之间光这一步通常能涨 1 到 2 个点。import numpy as np import torch def search_threshold(model, loader, device): model.eval() probs, gts [], [] with torch.no_grad(): for img, gt in loader: img img.to(device) p torch.sigmoid(model(img)).cpu().numpy() probs.append(p) gts.append(gt.numpy()) probs np.concatenate(probs) gts np.concatenate(gts) best (0, 0) for thr in np.arange(0.30, 0.71, 0.05): pred (probs thr).astype(np.float32) tp (pred * gts).sum() fp (pred * (1 - gts)).sum() fn ((1 - pred) * gts).sum() dice 2 * tp / (2 * tp fp fn 1e-6) if dice best[1]: best (thr, dice) return best逻辑说明把所有验证集预测概率和标签缓存下来避免每个阈值都重新推理。np.arange的步长 0.05 够用想更细可以 0.02。返回最优阈值和对应 Dice这个阈值直接用在推理脚本里。第二个手法是测试时增强TTA。对同一张图做水平翻转、垂直翻转各推理一次把概率图翻回来平均。眼底血管近似左右对称水平翻转 TTA 收益最明显通常能再涨 0.5 到 1 个点。代价是推理时间翻倍看你能不能接受。第三个手法是后处理去小连通域。血管是连通的细长结构预测结果里那些面积小于 20 像素的孤立小块基本都是噪声。用cv2.connectedComponents去掉小连通域precision 会明显提升。但要注意别把真正的毛细血管末端也去掉面积阈值从 10 开始试。手法预期涨幅代价适用阶段阈值搜索1~2 点一次验证集推理训练完成后必做水平翻转 TTA0.5~1 点推理时间翻倍追求极限指标去小连通域0.5~1 点可能损失细血管看 precision 是否偏低换更强 backbone2~4 点显存和训练时间数据量足够时最后说个我自己的习惯每次改完预处理或者损失函数先只跑 5 个 epoch看验证集 recall 的走势涨了就继续平了就回头查数据。眼底血管分割这活儿八成的收益来自把数据和标签搞干净模型结构反而是最后才动的东西。希望帮到你。本文还有配套的精品资源点击获取