简介:一套基于U-Net的眼底血管分割项目包,面向医学图像处理初学者与算法开发人员,解决眼底血管二分割任务从数据准备到训练推理的完整流程。压缩包共216个文件,以182张切片PNG图像为主,另含8个Python脚本、5个XML配置、3个TXT说明、1个训练权重PTH等,整体约154MB,目录组织清晰可直接上手。项目仅训练10个epochs,全局像素准确率达0.95,mIoU为0.67,若增加训练轮次性能还可提升。代码支持多尺度随机缩放训练,utils中compute_gray函数自动统计mask灰度值并配置输出通道,便于扩展多分割项目;学习率采用cos衰减,训练日志与损失、IoU曲线均保存在run_results中,可查看每类指标。推理时只需把图像放入inference目录并运行predict脚本,无需手动调参。目前已有269人学习下载,适合希望快速复现眼底分割实验或基于U-Net做二次开发的读者。
1. 为什么眼底血管分割绕不开 U-Net
眼底血管分割是医学图像分割里一个非常"卡脖子"的基础任务。血管细长、分叉多,背景占比极大,一张图中血管可能只占 5%~10%。直接用普通 FCN 或 DeepLab 分割,血管很容易断裂,尤其在细小毛细血管区域。U-Net 通过 encoder-decoder 加跳跃连接,把 encoder 的底层高分辨率特征传给 decoder,避免细节丢失,几乎成了血管分割的默认基线。这个项目把切片好的眼底血管数据集、完整训练代码和训练结果文件打包在一起,只训练 10 个 epoch,全局像素准确度达到 0.95,miou 为 0.67。对想复现完整训练流程、研究医学分割的人来说,是个能直接跑通的起点。
2. 数据准备与多尺度训练:切片、灰度映射与 channel 自适应
2.1 数据集结构与目录摆放
项目的数据集是眼底血管二分割,所以标签里只有背景和血管两个类别。数据已经按固定结构切好了,通常是这样:
dataset/ ├── images/ │ ├── 61.png │ ├── 63.png │ ├── 65.png │ ├── 69.png │ ├── 70.png │ ├── 74.png │ ├── 79.png │ └── 84.png ├── masks/ │ ├── 61.png │ └── ... └── inference/images 放原始眼底图像,masks 放对应的手动标注二值图。uint8 像素值一般只有 0 和 255,分别代表背景和血管。inference 目录用于推理,把待分割图放进去,运行 predict 脚本就会自动读取。
这种目录结构对新手很友好。训练脚本 train 会默认读取这两个文件夹,不需要手动输入路径。如果你的数据没有切片,或者想加入自定义数据集,只要把原始大图和对应 mask 切成小块,摆成同样的目录结构即可。切片的好处是内存占用可控,训练时不会因为单张眼底图尺寸太大而爆显存。下表列出几个目录的关键作用:
| 目录/文件 | 作用 | 常见问题 |
|---|---|---|
| images/ | 训练原始眼底图像 | 保证图像尺寸一致或使用比例缩放 |
| masks/ | 二值标签,背景0,血管255 | 标签必须和图像一一同名 |
| classes.txt | compute_gray 生成的灰度值列表 | 多类别时自动更新 |
| inference/ | 推理输入目录 | 直接放待分割图,脚本自动处理 |
2.2 compute_gray:把 mask 灰度值解析成类别并自动设置输出通道
在写分割代码时,最烦的不是网络结构,而是"到底有几个类别"。眼底血管分割虽然是二分类,但很多人习惯把血管标注成 255,导致 label 像素值不连续。这个项目里的 utils 模块提供了一个 compute_gray 函数,作用是扫描一个样本的 mask,把出现过的灰度值提取出来,按顺序保存到 txt 文件,同时根据灰度值的种类数量自动给 U-Net 的输出 channel 赋值。这样做的好处是:换一个多分割数据集,不需要改网络定义,只需要重新跑一次 compute_gray,输出 channel 会自动变成类别数。
下面是一段常见的 compute_gray 实现逻辑,核心是扫描 mask 像素值并去重:
import numpy as np from PIL import Image from pathlib import Path def compute_gray(mask_dir, output_txt="classes.txt"): class_values = set() for mask_path in sorted(Path(mask_dir).glob("*.png")): mask = np.array(Image.open(mask_path).convert("L")) # 去掉全零背景,只保留实际出现的灰度值 unique_vals = np.unique(mask) class_values.update(unique_vals.tolist()) # 排序写入txt,保证类别顺序稳定 with open(output_txt, "w") as f: for val in sorted(class_values): f.write(str(val) + "\n") return len(class_values) class_num = compute_gray("dataset/masks", "classes.txt") print("检测到类别灰度值个数:", class_num)这段代码的逻辑说明:首先用 glob 遍历全部 mask,用 PIL 读取为灰度数组;np.unique 取出每个 mask 中出现的像素值,加入全局集合;最后排序写入 txt,并返回类别数量。类别数就是输出 channel 数。常见做法是在训练脚本开头调用一次 compute_gray,然后把返回值传给一个负责创建 U-Net 模型的工厂函数。如果你是自己写网络,注意最后输出的卷积层 channel 要等于这个 num_classes,否则前向传播算出来的 loss 维度对不上,训练会直接报错。
2.3 多尺度随机缩放:训练时数据增强,推理时不用
train 脚本里有一个很关键的操作:在加载图像后,将数据随机缩放到设定尺寸的 0.5~1.5 倍之间。这一步是典型的多尺度训练增强。眼底血管在不同图中的粗细和视场变化很大,单尺度训练容易让模型记住固定尺寸下的纹理,遇到更大或更小的血管尺度时失效。随机缩放让模型看到丰富的尺度分布,相当于免费扩充数据集。实现上,通常会设定一个基准尺寸,然后生成一个 0.5~1.5 区间的随机系数,对 image 和 mask 同步缩放,之后再随机裁剪或 resize 回基准尺寸。
这里有一个必须注意的坑:缩放时 image 和 mask 一定要用同一份随机系数,并且插值方式要区分开。图像可以用双线性插值,mask 是标签,如果也用双线性插值会生成介于 0 和 255 之间的"假像素",训练时可能被当作新类别。常见做法是 mask 用最近邻插值,保持像素值严格为 0 或 255。
3. U-Net 结构与训练脚本:从跳跃连接到 COS 学习率衰减
3.1 U-Net 的 encoder-decoder 与跳跃连接细节
U-Net 的网络结构分为编码器、解码器和跳跃连接三部分。编码器由若干卷积块和池化层组成,每经过一个池化层,特征图尺寸减半,通道数翻倍。解码器则把最底层的特征图上采样回原始尺寸,每次上采样后与对应编码器层的特征图拼接,再用卷积融合。这个拼接操作是 U-Net 的经典操作,它让上采样过程中既能获得高层语义信息,又能保留低层边界细节。眼底血管的管径差异很大,主干血管需要语义信息来保持连续性,毛细血管则需要高分辨率边缘信息来还原轮廓,跳跃连接正好同时满足这两点。
在写代码时,我一般会用函数式方式来构建:
import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, 3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x)这个 DoubleConv 是 U-Net 的基本卷积块,每个卷积后接 BatchNorm 和 ReLU,能够稳定训练。注意 padding=1 是为了保持卷积后分辨率不变,方便跳跃连接时直接与解码器特征图拼接。训练时如果显存不够,可以把 BatchNorm 换成 GroupNorm,或者减少初始通道数。
3.2 train 脚本的核心流程与关键参数
train 脚本会自动完成数据加载、多尺度缩放、模型构建和训练循环。整个流程大概是:
- 调用 compute_gray 生成 classes.txt,得到类别数;
- 加载数据集,对每张图做随机缩放和标准化;
- 构建 U-Net 模型,输出 channel 等于类别数;
- 设置损失函数、优化器、学习率调度器;
- 在每个 epoch 里训练,并在验证集上计算 loss 和 iou;
- 把曲线和日志保存到 run_results 目录。
学习率采用 cos 衰减(余弦退火)。即学习率在刚开始时较大,之后按照余弦函数逐渐下降到接近 0。这种衰减方式比固定学习率更平滑,有利于后期收敛,避免在 loss 平台期震荡。下面是训练循环中学习率调度的典型用法:
from torch.optim import lr_scheduler optimizer = torch.optim.Adam(model.parameters(), lr=1e-4) scheduler = lr_scheduler.CosineAnnealingLR( optimizer, T_max=epochs, eta_min=1e-6 ) for epoch in range(epochs): train_one_epoch(model, train_loader, optimizer) val_loss, val_miou = evaluate(model, val_loader) scheduler.step() logger.log(epoch, val_loss, val_miou, optimizer.param_groups[0]['lr'])Cos 衰减的 T_max 设为总的训练轮次,eta_min 是最低学习率。代码里每轮scheduler.step()会更新一次学习率。如果训练中途停了,重启时需要重建 optimizer 和 scheduler 的状态,否则学习率会从初始值重新走一遍,导致结果不可复现。
3.3 训练日志:什么信息值得看
项目在 run_results 目录下保存了四类内容:损失曲线、iou 曲线、训练日志、最佳权重。训练日志里不仅记录了每个 epoch 的全局 loss 和 iou,还记录了每个类别的 iou、recall、precision 以及全局像素准确度。全局像素准确度很容易被背景主导,所以单独看类别指标才能发现真实问题。
查看日志时,我一般用 grep 过滤关键行:
grep -E "epoch|miou|recall|precision" run_results/train.log | tail -20也可以直接用 matplotlib 读 logger 生成的 json,画在同一张图上观察 loss 和 miou 的走势。如果 loss 已经平稳但 miou 还在上升,说明可以继续训练;如果 precision 高、recall 低,说明血管被漏检得多,考虑加长训练或调整类别权重。
这里再给一个参数参考表,对应训练脚本中常见的设置:
| 参数名 | 常见值 | 说明 |
|---|---|---|
| batch_size | 4~8 | 显存小就降低,或打开梯度累积 |
| epochs | 10~50 | 本项目10轮已可用,加大会继续提升 |
| optimizer | Adam | 初始学习率常取1e-4 |
| scheduler | CosineAnnealingLR | T_max等于epochs |
| input_size | 如512×512 | 需要可被下采样整除 |
4. 从 0.95 准确率到 0.67 miou:指标解读与结果文件分析
4.1 为什么像素准确度会"虚高"
训练结果里,全局像素点准确度为 0.95,miou 为 0.67。很多人第一次看到这两个数字会觉得差距很大,其实这不奇怪。眼底图像里背景像素占绝对多数,假设一张图 95% 是背景,5% 是血管,即使模型把所有像素都预测为背景,全局像素准确率也有 0.95。所以 0.95 的准确度并不能说明分割质量好,miou 才更能反映真实分割能力。miou 0.67 对 10 个 epoch 的 Unet 来说是个正常的水平,说明血管区域和背景区域的重合度已经相当高。想要达到临床可用水平,通常需要把 miou 推到 0.75 以上。
为什么只有 10 个 epoch 也能有 0.67?一方面因为眼底血管分割任务是二分类,背景干扰相对有规律;另一方面是多尺度训练增强了模型对血管尺度变化的适应能力。但 0.67 也意味着血管边界处的预测并不精确,很多细支血管可能被漏掉。
4.2 从 run_results 里读取曲线和日志
训练脚本会把训练集和测试集的 loss、iou 曲线用 matplotlib 绘制并保存到图片文件里。同时保存的还有训练日志和最佳权重。日志里每一个 epoch 都会记录训练和验证的 loss、全局准确率、每个类别的 iou/recall/precision。这种多类别指标在二分割场景中,通常看类别 1(血管)就够了。
你可以用下面这段 Python 脚本把日志里的数值解析出来,快速画出趋势:
import matplotlib.pyplot as plt with open("run_results/train.log") as f: lines = f.readlines() epochs = [] miou_list = [] for line in lines: if "miou:" in line: # 假设日志格式: epoch=10, loss=0.123, miou=0.67, ... parts = line.strip().split(",") epoch = int(parts[0].split("=")[1]) miou = float(parts[2].split("=")[1]) epochs.append(epoch) miou_list.append(miou) plt.plot(epochs, miou_list, marker="o") plt.xlabel("epoch") plt.ylabel("miou") plt.savefig("miou_curve_from_log.png", dpi=150)这段代码的作用是从日志中提取 miou 序列并出图。注意这里只是示例,真实日志格式以项目里 README 为准。如果你的日志用了 json 或 csv 格式,直接把解析部分换成 csv.reader 或 json.load 即可。我的经验是,最好在训练脚本里同时输出 json 格式的日志,这样下游分析代码不用频繁改。
4.3 epochs 加大后性能还能继续涨,训练多久合适
项目作者在说明里提到,"训练 epoch 加大的话,性能还会更加优越"。这从日志曲线可以看出来:如果第 10 轮结束时 miou 还没有进入平台期,说明模型欠拟合,继续训练会继续涨。具体涨到多少,取决于数据集规模和复杂度。对于切片后的眼底血管数据集,我一般先把 epoch 设为 50,然后观察是否在 20 轮附近出现平台。如果 30 轮还在缓慢上升,就继续加到 100。多类别分割中还会配合 Early Stopping,以验证集 miou 为准。
这里给出一个"是否继续训练"的判断表,可以作为参考:
| 现象 | 判断 | 操作 |
|---|---|---|
| 训练loss下降,验证miou持续上升 | 欠拟合 | 增大epoch,或提高学习率 |
| 训练loss低,验证miou不再上升 | 过拟合 | 增大数据增强,降低模型宽度 |
| 验证miou震荡大 | 学习率偏高或batch太小 | 降低初始学习率,增大batch |
| 类别1 recall 长期低于0.5 | 血管样本不足 | 增加loss权重,或补充hard negative |
5. 推理脚本与参数调优技巧:从 predict 到 TTA
5.1 predict 脚本的工作方式
推理时不需要设参数,把待推理的眼底图像放到 inference 目录下,直接运行 predict 脚本即可。predict 内部做了三件事:加载最优权重、对输入图像做与训练时一致的前处理(resize、归一化、标准化)、把模型输出的 logits 通过 argmax 或阈值转换成二值 mask。二分割时,最后一个卷积层输出两个 channel,分别表示背景和血管的 logits,然后逐像素比较哪个通道得分高,最终生成掩膜并保存成 png。
简化后的推理代码大概是这样:
import torch from PIL import Image import numpy as np model.load_state_dict(torch.load("run_results/best_weight.pth")) model.eval() img = Image.open("inference/test.png").convert("RGB") # resize到训练时的基准尺寸 img_resized = img.resize((512, 512)) x = torch.from_numpy( np.array(img_resized) / 255.0 ).float().permute(2, 0, 1).unsqueeze(0) with torch.no_grad(): logits = model(x) # shape [1, 2, 512, 512] mask = torch.argmax(logits, dim=1)[0].numpy() result = Image.fromarray((mask * 255).astype(np.uint8)) result.save("inference/test_mask.png")这里的关键是前处理必须和训练完全一致,包括尺寸、归一化方式、通道顺序。训练时如果用了随机缩放,推理时是不需要做的,只要固定 resize 到同一尺寸就好。
5.2 输出全黑或通道错乱的排错路径
跑 predict 最常见的问题有两个。一是输出全黑:先检查归一化因子,如果用 0-255 训练,推理也全用 0-255;如果用 0-1,则都要除以 255。二是提示 weights 维度不匹配:多半是 compute_gray 生成的 classes.txt 丢失或顺序改变,建议在训练前重新生成一次并固定种子。还有一个小坑:inference 目录里如果有脚本不支持的图像格式,predict 脚本可能会崩溃,统一用 png 最稳。
5.3 让分割效果再上一个台阶的实用技巧
做完基础推理后,可以打开测试时增强(TTA)。常见做法是:把输入图像水平翻转一次,预测两次,把两张概率图取平均再选 argmax。这个技巧对血管这种非轴对称结构也有稳定作用。另一个更实用的技巧是使用针对血管结构的 post-processing:对预测的 mask 做一次形态学闭运算,可以补上薄管上的断裂;再剔除面积小于预定阈值的连通域,能去掉玻璃体出血等产生的孤立点。
这两行能直接改善视觉结果:
python -c " import cv2 import numpy as np m = cv2.imread('inference/test_mask.png', 0) m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, np.ones((3,3), np.uint8)) cv2.imwrite('inference/test_mask_closed.png', m) "闭运算的核大小不用太大,3×3 即可,核太大会把相邻的血管粘连起来。把 TTA 和闭运算配合起来,在原模型不训练的情况下,miou 通常能提升 2~3 个点。
本文还有配套的精品资源,点击获取