简介:项目基于UNet完成视网膜血管分割,提供完整工程实现,适合医学图像处理初学者、算法工程师及科研人员,可直接用于学习或作为分割任务的基础项目。项目覆盖数据预处理、编码器-解码器网络搭建、损失函数与优化器配置、训练、测试及结果可视化全流程,包含预训练权重、模型结构文件和多个测试配置;其中源码按功能拆分为图像预处理、补丁提取、训练、预测等独立脚本,便于二次开发。资源共47个文件,以Python脚本和PNG结果图像为主,辅以h5权重、json配置文件、txt说明与md文档,压缩包约21.85MB,已有236人学习下载。通过阅读代码可深入理解U-Net跳跃连接与多尺度特征融合原理,掌握DRIVE/STARE视网膜数据集的采样策略与评估指标;附带大量可视化结果能直观对比原图、掩膜与模型预测,便于定位调优方向。对于开展医疗图像分割研究或落地类似像素级识别任务,具有良好的参考和复用价值。
1. 分割算法遇上UNet:视网膜血管分割为何难,又为何能成
视网膜血管分割是医学图像分析里公认的“硬骨头”:血管在眼底图像上与背景对比度低、粗细跨度大、分支结构复杂,还常伴随病变区域的遮挡和噪声干扰。传统分割靠手工特征和形态学操作,遇到病变图像很容易断裂或误检。UNet这种对称的编码器-解码器架构,用跳跃连接把浅层的边缘细节和深层的语义信息拼在一起,正好应对“既要全局结构、又要局部细节”的矛盾。这篇博文要拆的就是一个完整可运行的UNet视网膜血管分割项目:从DRIVE数据集的预处理、patch抽取,到模型构建、训练调参,再到预测评估和结果可视化,整套源码都能跑通。无论你是想复现结果、转做医疗图像分割,还是需要一份能直接改造的深度学习工程基线,这个项目都值得花时间吃透。下面直接按工程实现顺序展开。
2. 数据预处理:DRIVE图像标准化与Patch抽取策略
2.1 为什么不能直接把整张眼底图扔进UNet
DRIVE数据集里的眼底图像尺寸通常是565×584,直接用原始分辨率训练UNet会带来两个问题。第一,GPU显存限制:标准的UNet编码器深度为4时,特征图逐层减半,但底层特征图仍然有较大空间尺寸,batch size稍微调大就爆显存。第二,血管像素在整图中占比只有10%左右,类别极度不平衡,用全局损失训练时模型容易偏向背景。常见做法是先把图像切成固定大小的patch,比如48×48或64×64,这样既能控制显存开销,又能在每个patch里获得更均衡的正负样本比例。这个项目里的extract_patches.py干的就是这件事。
2.2 预处理脚本中的关键操作
先看pre_processing.py里的典型步骤。眼底图通常是彩色RGB,但血管分割只需要灰度信息,而且不同设备采集的图像光照不均,必须做标准化。我一般会保留绿色通道,因为视网膜血管在绿色通道下对比度最高。
import cv2 import numpy as np def preprocess_image(image_path): # 读取RGB眼底图 img = cv2.imread(image_path) # 分离通道,绿色通道血管对比度最高 green = img[:, :, 1] # 自适应直方图均衡化,增强局部对比度 clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) enhanced = clahe.apply(green) # 转换为浮点并归一化到[0,1] enhanced = enhanced.astype(np.float32) / 255.0 # 零均值标准化 mean = np.mean(enhanced) std = np.std(enhanced) img_norm = (enhanced - mean) / (std + 1e-8) return img_norm这段代码的要点在于:先取绿色通道,再用CLAHE做局部对比度增强,最后做z-score标准化。clipLimit控制对比度增强的强度,太大容易放大噪声,眼底图一般取1.5到2.0;tileGridSize决定局部区域大小,眼底结构均匀,8×8是经验值。标准化里的1e-8是防分母为零的保护项。这一步输出的img_norm就是后续patch抽取和模型输入的图像。
2.3 训练数据Patch的采样与标签对齐
extract_patches.py负责从预处理好的图像和对应标注中切patch。这里要特别小心训练集和验证集的切分方式:DRIVE官方把图像分为训练集20张、测试集20张,但本项目还允许你按比例划分或使用STARE数据集。切patch时要注意步长策略,步长小于patch尺寸会产生重叠,相当于数据增强,能抑制预测时的棋盘效应。
python extract_patches.py --image_dir ./DRIVE/training/images \ --label_dir ./DRIVE/training/1st_manual \ --output_dir ./data/patches \ --patch_size 64 \ --stride 8 \ --augment 1参数含义如下:patch_size为64,表示每张训练样本是64×64;stride取8,意味着相邻patch有56像素重叠,从一张565×584的图能抽出约5000个patch;augment设为1时,会对patch做旋转90度、水平翻转等随机变换,把训练样本量放大4倍。注意label_dir指向的是1st_manual,即标注文件夹,切图时图和标注必须用完全相同的坐标裁切,保证像素级对齐。我的经验是,如果血管细分支多,patch大小不要超过64,否则局部感受野里血管占比太小,模型容易忽略细血管。
下表整理了预处理阶段最影响结果的几组参数,建议按这个范围调整:
| 参数 | 推荐值 | 作用 | 调整方向 |
|---|---|---|---|
| patch_size | 48–64 | 控制模型输入局部区域大小 | 细血管多时调小,粗血管多时调大 |
| stride | 8–16 | 决定样本重叠度和数量 | 越小样本越多,但训练变慢 |
| clipLimit | 1.5–2.0 | CLAHE对比度强度 | 图像灰蒙时调大,但噪声也增大 |
| 增强方式 | 旋转+翻转 | 扩充样本,提升旋转鲁棒性 | 可增加弹性变形,但会加重CPU负担 |
预处理完成后,数据就变成了(N, 64, 64, 1)的numpy数组,标签是对应的(N, 64, 64, 1)二值mask。这个项目的prepare_datasets_DRIVE.py会把这些数组打包成H5或npy格式,供训练脚本直接读取。建议你在跑训练之前,先画一个patch和对应标签的对比图,确认血管形态清晰、标签没有偏移。
3. UNet模型构建与训练参数配置
3.1 UNet的编码器-解码器设计与跳跃连接的作用
UNet的核心思路是:编码器(收缩路径)逐层下采样,卷积核数量翻倍,提取从细节到语义的特征;解码器(扩张路径)逐层上采样,逐步恢复空间分辨率;中间用跳跃连接把编码器每一层的特征图直接拼到解码器对应层上。这种设计让解码器在学习全局语义的同时,还能拿到编码器保存的边缘轮廓信息。视网膜血管分割中,细小的毛细血管几乎全靠高分辨率的浅层特征,如果去掉跳跃连接,细血管的分割结果就像断掉的虚线。
我用Keras实现过一个精简版UNet,结构和本项目源码里的retinaNN_training.py基本一致。下面给出核心的卷积块和下采样/上采样层:
import tensorflow as tf from tensorflow.keras import layers, models def conv_block(x, n_filters): x = layers.Conv2D(n_filters, 3, padding='same', activation='relu')(x) x = layers.Conv2D(n_filters, 3, padding='same', activation='relu')(x) return x def unet(input_shape=(64, 64, 1)): inputs = layers.Input(input_shape) # 编码器 c1 = conv_block(inputs, 64) p1 = layers.MaxPooling2D(2)(c1) c2 = conv_block(p1, 128) p2 = layers.MaxPooling2D(2)(c2) c3 = conv_block(p2, 256) p3 = layers.MaxPooling2D(2)(c3) c4 = conv_block(p3, 512) # 瓶颈 c5 = conv_block(c4, 1024) # 解码器 u4 = layers.UpSampling2D(2)(c5) u4 = layers.concatenate([u4, c4]) c6 = conv_block(u4, 512) u3 = layers.UpSampling2D(2)(c6) u3 = layers.concatenate([u3, c3]) c7 = conv_block(u3, 256) u2 = layers.UpSampling2D(2)(c7) u2 = layers.concatenate([u2, c2]) c8 = conv_block(u2, 128) u1 = layers.UpSampling2D(2)(c8) u1 = layers.concatenate([u1, c1]) c9 = conv_block(u1, 64) outputs = layers.Conv2D(1, 1, activation='sigmoid')(c9) return models.Model(inputs, outputs)注意我在上采样时用了UpSampling2D加concatenate,而不是转置卷积。原因是转置卷积容易产生棋盘伪影,而双线性上采样是固定操作,训练更稳定,适合视网膜这种纹理细腻的图。跳跃连接是concatenate实现的,把编码器的64、128、256、512通道特征与解码器对应层拼接,通道数直接相加,所以解码器第一层卷积的输入通道数变成了1024+512=1536。如果你改用Add相加,参数量会减少,但会丢失部分位置信息,血管分割场景不推荐。
3.2 configuration.txt中的关键训练参数
项目里有一个configuration.txt,作用是把训练参数集中管理,避免每次改代码。典型内容如下:
input_channels = 1 batch_size = 16 epochs = 150 learning_rate = 1e-4 train_data = ./data/patches/train.h5 valid_data = ./data/patches/valid.h5 model_output = ./outputs/model_weights.h5 loss = binary_crossentropy optimizer = adam参数解析逻辑不复杂,但有两个值值得重点解释。learning_rate这里用的是固定1e-4。UNet在视网膜血管分割上建议初始学习率不超过1e-4,因为Adam的默认步长偏保守,再大的学习率容易让二值分割的损失震荡。batch_size设16是权衡显存和梯度稳定性的结果,在64×64输入下,16的batch大约占用6GB显存,如果显卡只有4GB就改成8。
训练脚本的调用方式很简单:
python run_training.py --config configuration.txtrun_training.py内部会读取配置文件、加载H5数据、调用retinaNN_training.py里的train函数。训练时每个epoch都会做一次验证,保存验证IoU最高的权重为best_weights.h5,同时定期保存last_weights.h5用于断点续训。这里有一个容易踩的坑:项目中test_architecture.json保存的是模型结构,加载预测时,必须保证Keras/TensorFlow版本与训练时一致,否则结构文件解析会报未知层。建议用model.to_json()保存结构,但加载时不要依赖custom_objects。
3.3 损失函数的选择与类别不平衡处理
视网膜血管分割是典型的二分类像素预测,但血管和背景像素比例悬殊。直接用binary_crossentropy往往让模型偏向预测背景,因为即使全部预测为背景,准确率也有90%。第二个常见方案是Dice loss,本质上优化的是区域重叠程度,对不平衡更鲁棒。本项目默认使用二元交叉熵,但我在实际复现时通常改为Dice loss和交叉熵的组合:
def combined_loss(y_true, y_pred): # 二元交叉熵 bce = tf.keras.losses.binary_crossentropy(y_true, y_pred) # Dice损失 smooth = 1.0 y_true_f = tf.reshape(y_true, [-1]) y_pred_f = tf.reshape(y_pred, [-1]) intersection = tf.reduce_sum(y_true_f * y_pred_f) dice = (2.0 * intersection + smooth) / \ (tf.reduce_sum(y_true_f) + tf.reduce_sum(y_pred_f) + smooth) return tf.reduce_mean(bce) + (1.0 - dice)这里把交叉熵和Dice损失简单相加,两者都是越小越好。加smooth是为了防止分子分母同时为零导致数值错误。Dice损失能迫使模型聚焦血管区域,但它的梯度在预测完全错误时反而可能不平稳,所以和交叉熵配合使用最稳妥。如果你想进阶,可以把权重改为0.6 * bce + 0.4 * dice,在KEras里用keras.losses的自定义函数直接替换配置项里的loss即可。
下表是训练阶段需要重点关注的指标和对应状态检查:
| 指标 | 正常表现 | 异常表现与对策 |
|---|---|---|
| train loss | 稳定下降,150 epoch后低于0.3 | 震荡不降,降低学习率至5e-5 |
| valid IoU | 持续上升,最终高于0.70 | 不升反降,加数据增强或增大patch重叠 |
| valid loss | 与train loss差距小于0.1 | 差距大说明过拟合,早停或加dropout |
| 血管预测图 | 粗细连续、分支完整 | 断开或断裂,降低stride重新切patch |
训练时长方面,64×64输入、64个基础通道数,在RTX 3060上150个epoch大约需要2小时。如果你没有GPU,建议先把epoch降到30,确认整个流程能跑通再上全量训练。
4. 测试预测流程与分割效果评估
4.1 从训练好的权重到完整预测图
训练完成后,retinaNN_predict.py负责加载权重和模型结构,对测试集逐张预测。由于训练时是patch级输入,预测时也要用相同的策略:把整张图切成patch,逐patch预测,再拼接回原图。这里的关键是重叠patch的拼接策略。如果stride小于patch_size,每个像素会得到多次预测,最终取平均再阈值化。项目中的run_testing.py已经实现了这个流程。
下面这段伪代码展示了预测和拼接的核心逻辑:
import numpy as np def predict_full_image(model, image, patch_size=64, stride=16): h, w = image.shape[:2] # 记录每个像素的预测累加值 pred_map = np.zeros((h, w), dtype=np.float32) count_map = np.zeros((h, w), dtype=np.float32) # 滑窗预测 for y in range(0, h - patch_size + 1, stride): for x in range(0, w - patch_size + 1, stride): patch = image[y:y+patch_size, x:x+patch_size] patch_input = patch[np.newaxis, ..., np.newaxis] pred = model.predict(patch_input, verbose=0)[0, ..., 0] pred_map[y:y+patch_size, x:x+patch_size] += pred count_map[y:y+patch_size, x:x+patch_size] += 1.0 # 把边界未覆盖区域补一次 pred_map /= (count_map + 1e-8) return pred_map注意count_map初始化为0,每个patch预测结果累加后,用出现的次数取平均。越靠近patch中心的像素被预测次数越多(因为重叠更多),所以拼接结果越平滑。预测完成后,使用常见阈值0.5把连续概率转成二值mask。如果你想更精细,可以用Otsu算法自动确定阈值,针对不同图像自适应调整。这样得到的im0319.png之类的预测图,和原图、金标准一起并列输出,就是项目中的test_Original_GroundTruth_Prediction*.png。
4.2 评估指标:ROC、Precision-recall与IoU
项目根目录下有ROC.png、Precision_recall.png、performances.txt,这些都是评估产物。视网膜血管分割的评估不能只看准确率,因为背景占绝大多数。业界常用三个指标:ROC曲线下的面积AUC、Precision-recall曲线下的面积AP、以及IoU。其中IoU更适合衡量血管区域的定位精度,计算公式为:
def compute_iou(y_true, y_pred): intersection = np.logical_and(y_true, y_pred).sum() union = np.logical_or(y_true, y_pred).sum() return intersection / (union + 1e-8)计算ROC和PR曲线时,要遍历不同的阈值(比如0.1到0.9),计算每个阈值下的TPR、FPR、Precision、Recall,然后绘制曲线。项目里performances.txt记录的就是最终指标数值。以DRIVE数据集官方基准,UNet的AUC通常能达到0.97以上,IoU在0.65到0.75之间。如果低于这个区间,先检查预处理是否破坏了血管边缘,再检查训练是否收敛。
一个实用的评估脚本如下:
python run_testing.py --model_weights outputs/best_weights.h5 \ --test_dir ./DRIVE/test/images \ --label_dir ./DRIVE/test/1st_manual \ --output_dir ./results \ --threshold 0.5脚本会输出每张图的IoU、AUC,并在results目录下生成可视化的对比图。threshold参数建议设0.5,但如果你发现预测的血管过粗,说明阈值偏低,适当提高到0.6;血管过细有断裂,则降到0.4。另外,项目里all_groundTruths.png和all_predictions.png是把所有测试图像的ground truth和预测拼成一张大图,方便横向对比整体质量。这种可视化对排查某张图特别差的case很有帮助,能直观看出是某些分支没检测到还是噪声点过多。
4.3 手动检查预测图的常见缺陷模式
即使AUC很高,预测图也可能有肉眼可见的问题。我遇到过两类典型缺陷。第一类是“树杈断裂”,细血管的预测概率在0.3到0.5之间,阈值一卡就断。解决办法不是改阈值,而是回到预处理阶段把clipLimit调低一点,减少对比度增强对细血管的腐蚀效应。第二类是“背景青苔”,切割边缘出现密密麻麻的小噪点。这通常是因为patch预测时重叠不足,有棋盘伪影,把stride从16降到8就能显著改善。如果还是不行,在后处理阶段加一步开运算,用3×3的核去除面积小于10像素的连通域。
5. 进阶技巧:类别加权训练与形态学后处理
5.1 通过像素权重强化细血管学习
UNet在视网膜血管分割上的天花板往往不在架构,而在损失函数和采样策略。细血管只占整图的1%左右,即使Dice loss也会因为大量背景像素的“稀释效应”而忽略它们。一个有效的技巧是给像素级损失加上距离权重。具体做法是:计算每个像素到最近血管像素的距离,距离越远权重越低,距离近的权重高。这样模型会重点关注血管边缘和细分支。实现上可以用scipy.ndimage.distance_transform_edt:
from scipy.ndimage import distance_transform_edt def weight_map(label): # label: 0背景 1血管 dist_bg = distance_transform_edt(label == 0) # 血管像素权重设为2,背景权重按距离衰减 w = np.where(label == 1, 2.0, 0.5 + dist_bg * 0.1) return w训练时把weight_map和loss相乘:loss = binary_crossentropy(y_true, y_pred) * w。注意需要在每个batch里实时生成权重,因为每个patch的血管分布不同。这个技巧通常能让AUC提升0.01到0.02,细血管的连续性明显变好。如果你不想改损失,也可以用在线难例挖掘,在训练时保留loss最高的像素做反向传播,但实现复杂度更高。
5.2 形态学后处理消除残噪和断裂
预测得到的二值mask还需要经过一轮形态学操作才能达到“高质量”标准。这个项目虽然没强制要求后处理,但实际复现时,我一般加两步:先做闭运算连接细小的断裂,再做开运算去除孤立噪点。用OpenCV一行即可:
import cv2 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) closed = cv2.morphologyEx(binary_mask, cv2.MORPH_CLOSE, kernel, iterations=1) opened = cv2.morphologyEx(closed, cv2.MORPH_OPEN, kernel, iterations=1)椭圆核比矩形核更贴合血管的圆润截面。iterations=1即可,多了会让细血管被抹掉。闭运算能连接距离在2像素以内的断裂分支,开运算能去掉面积小于9像素的噪声点。如果你对血管连通性要求很高,还可以提取最大连通域作为最终血管区域,但这种方法对严重病变图像不太好,因为病灶区域的异常血管也是有效信号。
5.3 验证改进是否有效的基准方法
做任何改进,都要有一套可对比的评估流程。我会固定同一批测试集,先跑一次原始配置,记录AUC、IoU、PR曲线。改进后重新训练并评估,至少重复两次取平均值。对比时除了看数值,还要逐张看图。人体视觉对血管连接性的敏感度远高于AUC,所以一定留意断裂但AUC没掉的情况。这个项目的performances.txt会保留每次运行的指标,你可以按日期归档,方便回溯。如果你想尝试跳跃连接的改进,建议先在c4这一层(最深层跳跃连接)切入,把它替换成注意力门控模块,但要注意这会增加训练时间,且收益未必超过后处理。
最后分享一个时间成本极低的调参技巧:训练早期(前10个epoch)就观察验证集的IoU趋势,如果前5个epoch的IoU低于0.2,说明学习率太高或数据预处理有bug,立即停止调整参数,不要等完整训练完再后悔。用这个习惯,整个项目的调优周期能从三天压缩到半天。
本文还有配套的精品资源,点击获取