news 2026/9/12 23:44:45

U-Net眼底血管分割实战:数据、训练与推理全流程解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
U-Net眼底血管分割实战:数据、训练与推理全流程解析

简介:一套基于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.txtcompute_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 脚本会自动完成数据加载、多尺度缩放、模型构建和训练循环。整个流程大概是:

  1. 调用 compute_gray 生成 classes.txt,得到类别数;
  2. 加载数据集,对每张图做随机缩放和标准化;
  3. 构建 U-Net 模型,输出 channel 等于类别数;
  4. 设置损失函数、优化器、学习率调度器;
  5. 在每个 epoch 里训练,并在验证集上计算 loss 和 iou;
  6. 把曲线和日志保存到 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_size4~8显存小就降低,或打开梯度累积
epochs10~50本项目10轮已可用,加大会继续提升
optimizerAdam初始学习率常取1e-4
schedulerCosineAnnealingLRT_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 个点。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/12 23:43:49

Windows 的 A 卡/I 卡用户如何为 RVC 安装并启用 DirectML 依赖?

Windows 的 A 卡/I 卡用户如何为 RVC 安装并启用 DirectML 依赖&#xff1f; 【免费下载链接】Retrieval-based-Voice-Conversion-WebUI Easily train a good VC model with voice data < 10 mins! 项目地址: https://gitcode.com/GitHub_Trending/re/Retrieval-based-Voi…

作者头像 李华
网站建设 2026/9/12 23:42:39

打造高质量技术博文:从规范输入素材开始

我需要先拿到你的输入内容&#xff0c;才能按规范输出博文。请按以下格式提供原始素材&#xff1a;项目标题: [标题] 项目正文: [比较零散、不完整的原始描述] 关键词: [关键词1, 关键词2, ...] 摘要描述: [一句话简介]你这次只贴了要求&#xff0c;没有给我具体的标题、正文和…

作者头像 李华
网站建设 2026/9/12 23:40:21

告别Postman!15款接口测试工具分类盘点与选型指南

最近好几个做测试和开发的朋友问我&#xff1a;接口测试到底该用什么工具&#xff1f;我第一反应是&#xff0c;你八成在用 Postman 吧&#xff1f;对方点头。然后下一句就是&#xff0c;那除了 Postman 还有别的吗&#xff1f;这问题问得特别好。不是 Postman 不好&#xff0c…

作者头像 李华
网站建设 2026/9/12 23:39:47

Seata XA模式全解析:分布式事务强一致性的原理与实战

搞分布式事务&#xff0c;尤其是刚接触 Seata 的时候&#xff0c;不少人第一眼看到的就是 AT 模式&#xff0c;因为资料多、案例也多。但真到了金融、订单、库存这类对数据一致性要求极高的场景&#xff0c;我反而会建议你先看看 XA 模式。这个模式在 Seata 里常被说成是“天生…

作者头像 李华