• 正文
  • 相关推荐
申请入驻 产业图谱

深度学习实战-基于U-Net视网膜血管图像分割模型

06/17 10:54
385
加入交流群
扫码加入
获取工程师必备礼包
参与热点资讯讨论

 

1.项目背景

在现代眼科学的数字化诊断进程中,视网膜血管的形态学特征——如血管的粗细、分支角度、扭曲程度以及动静脉比值——是筛查糖尿病视网膜病变、高血压性视网膜病变以及青光眼等致盲性疾病的关键临床指标。然而,眼底影像通常伴随着光照不均、视盘干扰以及病理渗出物等复杂的背景噪声,传统的临床评估高度依赖资深医师的手工勾勒,这不仅耗时耗力,且在处理大批量防盲筛查任务时存在较强的主观偏差。实现视网膜血管的自动化、高精度分割,已成为构建智能化眼科诊断系统、实现早发现与早治疗的技术瓶颈。

本项目依托于经典且强大的 U-Net 语义分割架构,致力于在像素层面攻克视网膜微细血管提取的难题。U-Net 独特的“对称式”结构通过编码器捕获眼底影像的全局病理语义,并利用关键的跳跃连接(Skip Connections)将浅层的高清纹理细节直接补偿给解码器,从而在大幅下采样的同时,极大地降低了毛细血管等微小目标的特征流失。

本实战不仅涵盖了从 Kaggle 专业医学数据集的自动化加载、基于 albumentations 的强力数据增强,到深度卷积网络的逐层搭建与训练监控的全过程,更通过多维度的 IoU 性能评估与推理可视化,验证了深度学习在处理极度不平衡医疗影像任务中的优越性。这不仅是一场关于算法精度演进的实操,更是为未来构建低成本、标准化的眼病自动筛查范式提供了切实可行的工程路径。

2.数据集介绍

本实验数据集来源于Kaggle,该数据集是推进医学图像分析领域发展和提升视网膜血管疾病诊断水平的宝贵资源。本数据集包含大量视网膜眼底图像,并经过精心标注,用于血管分割。准确的血管分割是眼科领域的一项关键任务,有助于早期发现和治疗各种视网膜疾病,例如糖尿病视网膜病变和黄斑变性。

主要特点:

图像尺寸:数据集中的图像尺寸各异,从 XXX 像素到 XXX 像素不等,模拟了真实世界中视网膜图像的多样性。
标注:每张图像都提供了相应的像素级标注,采用二值掩码格式。血管像素标记为 1,背景像素标记为 0。
病理变异:该数据集涵盖了一系列视网膜疾病,包括不同的血管宽度、分支模式和异常情况,使其适用于评估分割模型的鲁棒性
应用案例:医学图像分析、计算机视觉和人工智能领域的研究人员和从业人员会发现该数据集在以下几个应用场景中具有极高的价值:

算法开发:利用该数据集训练和测试创新的分割算法,借助精确的标注信息获得准确可靠的结果。
疾病检测:创建能够辅助早期检测视网膜病变的模型,从而有助于及时进行医疗干预。
教育:该数据集可用于教育目的,帮助学生和专业人士理解视网膜血管结构的复杂性。
评估指标:性能评估主要包括将分割结果与真实标注进行比较,以衡量分割精度。常用的指标,例如交并比 (IoU)、Dice 系数和像素级准确率,可用于量化模型的性能。

3.技术工具

Python版本:3.9

代码编辑器:jupyter notebook

4.实验过程

4.1导入数据

在视网膜血管分割这类对边缘极其敏感的任务中,数据预处理与增强策略直接决定了模型对细小微血管的捕捉上限。本阶段我们首先集成了 TensorFlow/Keras 深度学习框架与专业的影像增强库 albumentations,后者在处理医学影像的几何变换(如翻转、旋转、随机缩放)时能够确保图像与掩码(Mask)的同步性,这对于像素对齐至关重要。我们设计的 load_data 流水线不仅实现了眼底原图与血管金标准(Gold Standard)的自动化路径配对,还通过 load_image 函数完成了尺寸归一化与通道校准。针对医疗样本通常存在的稀缺性问题,我们在加载过程中嵌入了实时增强机制,通过模拟不同的拍摄角度与尺度变化,强迫网络学习血管的拓扑结构而非位置死记,为后续 U-Net 网络解析复杂的视网膜神经网络奠定了坚实的工程基石。

# --- 1. 导入核心工程库:涵盖数据分析、模型构建与性能度量 ---import pandas as pdimport numpy as npimport matplotlib.pyplot as plt# 采用深色绘图背景,更清晰地观察血管细微分支plt.style.use('dark_background')import osimport math# 数据处理与进度监控from sklearn.model_selection import train_test_splitimport cv2from tqdm import tqdm # 深度学习框架组件import tensorflow as tffrom tensorflow.keras import backend as Kimport keras from keras.models import Modelfrom keras.layers import Input,Conv2D,MaxPool2D,Conv2DTranspose,Resizing,Concatenate,Activationfrom keras.optimizers import Adamfrom keras.callbacks import EarlyStopping,ModelCheckpointimport albumentations as Afrom albumentations.core.composition import OneOffrom tensorflow.keras.metrics import *import warningswarnings.filterwarnings("ignore")# --- 2. 定义增强策略:针对眼底图像特征进行几何变换 ---augmentation = A.Compose([    A.HorizontalFlip(p=0.5),           # 随机水平翻转,模拟左右眼镜像    A.Rotate(limit=30, p=0.3),         # 随机小角度旋转,模拟拍摄倾斜    A.RandomScale(scale_limit=0.2, p=0.3), # 随机缩放,应对不同视野大小    A.Resize(512, 512)                 # 统一重采样至 512x512])# --- 3. 定义单张影像加载与归一化逻辑 ---def load_image(path, size, mask=False):    """    加载影像并进行通道预处理与灰度归一化    """    image = cv2.imread(path)    image = cv2.resize(image, (size, size))    if mask:        # 掩码采用灰度读取,代表血管的真值分布        image = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)    else:        # 原始眼底图转换为 RGB 格式        image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)        # 像素值归一化至 [0, 1] 空间,加速梯度下降收敛    image = image / 255.0    return image# --- 4. 构建自动化数据加载流水线 ---def load_data(root_path, size, augment=False):    """    遍历目录结构,实现原图与掩码的精准匹配及批处理加载    """    images, masks = [], []    image_paths, mask_paths = [], []    # 递归检索文件夹,自动区分 Image 与 Mask 路径    for folder in os.listdir(root_path):        folder_path = os.path.join(root_path, folder)        if not os.path.isdir(folder_path):            continue        is_mask = folder.lower() == "mask"        for img in os.listdir(folder_path):            image_path = os.path.join(folder_path, img)            if is_mask:                mask_paths.append(image_path)            else:                image_paths.append(image_path)    # 路径排序,确保每一个眼底样本都对应其正确的血管标签    image_paths.sort()    mask_paths.sort()    # 利用 tqdm 监控大规模数据读取进度    for img_path, mask_path in tqdm(zip(image_paths, mask_paths), total=len(image_paths), desc="加载并执行实时增强"):        img = load_image(img_path, size, mask=False)        mask = load_image(mask_path, size, mask=True)        # 训练集执行数据增强,提升模型的泛化边界        if augment:            augmented = augmentation(image=img, mask=mask)            img = augmented['image']            mask = augmented['mask']        images.append(img)        masks.append(mask)    return np.array(images), np.array(masks)# --- 5. 执行数据加载:完成训练集与测试集的初始化 ---X, y = load_data("/kaggle/input/retina-blood-vessel/Data/train", 512, augment=True)X_test, y_test = load_data("/kaggle/input/retina-blood-vessel/Data/test", 512)

4.2数据可视化

为了确保训练集中的每一张眼底图都精准对应其血管分布标签,我们编写了 show_images_and_masks 函数。该函数采用了多行多列的矩阵布局,将原始 RGB 影像与灰度掩码成对排列。在深色背景的衬托下,我们可以看到血管在 Mask 中呈现为明亮的白色细线,而背景则被完全抑制为黑色。通过观察随机抽取的 12 组样本,可以确认数据增强后的影像依然保持了极佳的对齐性,且血管分支的细节在 512 像素的分辨率下得到了充分保留。这种“所见即所得”的核验方式,能够有效排除因路径错位导致的训练失效,为模型在微小血管上的定位精度提供了第一道质量防线。

# --- 1. 定义多样本配对可视化函数 ---def show_images_and_masks(images, masks, num_samples=8):      """    以矩阵排列方式同时展示原始眼底图与对应的血管分割掩码    """    # 确保请求的样本数不超过数据集实际规模    num_samples = min(num_samples, len(images), len(masks))    # 配置绘图布局:每行显示 4 对(共 8 列)    cols = 2     pairs_per_row = 4      rows = math.ceil(num_samples / pairs_per_row)    # 创建画布,设置适合大批量样本展示的尺寸    fig, axes = plt.subplots(rows, pairs_per_row * cols, figsize=(pairs_per_row * 5, rows * 3.5))    # 处理单行显示的特殊情况,保持 axes 索引的一致性    if rows == 1:        axes = np.expand_dims(axes, 0)     fig.suptitle("视网膜血管分割:原始影像与金标准对比", fontsize=18, color='white')    for i in range(num_samples):        # 提取当前样本对        image = images[i]        mask = masks[i]                # 计算当前子图在矩阵中的行列索引        row = i // pairs_per_row        col_pair = (i % pairs_per_row) * 2              # 绘制原始眼底照片        ax_img = axes[row, col_pair]        ax_img.imshow(image)        ax_img.set_title("原始影像 (Image)", fontsize=12)        ax_img.axis("off")             # 绘制对应的血管掩码        ax_mask = axes[row, col_pair + 1]        cmap = 'gray' if len(mask.shape) == 2 else None        ax_mask.imshow(mask, cmap=cmap)        ax_mask.set_title("血管掩码 (Mask)", fontsize=12)        ax_mask.axis("off")    # 清理多余的空白子图槽位    total_slots = rows * pairs_per_row    for i in range(num_samples, total_slots):        row = i // pairs_per_row        col_pair = (i % pairs_per_row) * 2        axes[row, col_pair].axis("off")        axes[row, col_pair + 1].axis("off")    # 优化布局,防止标题重叠    plt.tight_layout()    plt.subplots_adjust(top=0.88)    plt.show()# --- 2. 随机抽取 12 组样本进行视觉合规性核验 ---show_images_and_masks(X, y, num_samples=12)

4.3构建模型

在正式构建网络前,我们首先对掩码数据执行了 np.expand_dims 操作,为其增加通道维度以匹配 Keras 的张量输入规范。核心架构函数 unet_model 严格遵循了收缩路径(Encoder)与扩张路径(Decoder)的平衡。编码器模块通过连续的 3 x 3 卷积与 ReLU 激活提取深层病理特征,而解码器模块则引入了关键的跳跃连接(Skip Connections)——利用 Concatenate 将编码阶段保留的原始高清细节直接“喂”给上采样层。这种设计有效缓解了深层网络在下采样过程中的信息流失,确保模型在最终输出阶段能够精准定位每一处血管分叉。最后,通过一个 1 x 1 卷积层配合 Sigmoid 激活函数,我们将多维特征压缩为单通道的概率图,实现了从复杂影像到二值化掩码的像素级映射。

# --- 1. 维度对齐:为掩码数据增加通道维度 (H, W) -> (H, W, 1) ---y = np.expand_dims(y, -1)y_test = np.expand_dims(y_test, -1)# --- 2. 定义编码器模块:收缩路径,提取深层抽象特征 ---def encoder_block(inputs, num_filters):    """    包含双层卷积与最大池化,用于捕捉图像的语义信息    """    x = Conv2D(num_filters, 3, padding='same')(inputs)    x = Activation('relu')(x)        x = Conv2D(num_filters, 3, padding='same')(x)    x = Activation('relu')(x)    # 空间维度减半,通道数翻倍,深度提取病灶特征    x = MaxPool2D(pool_size=(2, 2))(x)       return x# --- 3. 定义解码器模块:扩张路径,恢复空间分辨率 ---def decoder_block(inputs, skip_features, num_filters):    """    通过转置卷积与跳跃连接,将深层语义与浅层细节融合    """    # 上采样:将特征图尺寸放大 2 倍    x = Conv2DTranspose(num_filters, (2, 2), strides=2, padding='same')(inputs)    # 鲁棒性处理:确保跳跃连接的特征图尺寸与上采样后的张量完全对齐    skip_features = Resizing(x.shape[1], x.shape[2])(skip_features)    # 核心步骤:特征拼接,弥补下采样丢失的空间坐标信息    x = Concatenate()([x, skip_features])    # 拼接后再次卷积,平滑特征过渡    x = Conv2D(num_filters, 3, padding='same')(x)    x = Activation('relu')(x)    x = Conv2D(num_filters, 3, padding='same')(x)    x = Activation('relu')(x)    return x# --- 4. 封装 U-Net 完整模型 ---def unet_model(input_shape=(256, 256, 3), num_classes=1):    inputs = tf.keras.layers.Input(shape=input_shape)        # --- 编码器阶段 (Contracting Path) ---    s1 = encoder_block(inputs, 64)    s2 = encoder_block(s1, 128)    s3 = encoder_block(s2, 256)    s4 = encoder_block(s3, 512)        # --- 瓶颈层 (Bottleneck) ---    # 位于 U 型底部的最深层,捕捉全局视野下的最抽象特征    b1 = Conv2D(1024, 3, padding='same')(s4)    b1 = Activation('relu')(b1)    b1 = Conv2D(1024, 3, padding='same')(b1)    b1 = Activation('relu')(b1)        # --- 解码器阶段 (Expansive Path) ---    d1 = decoder_block(b1, s4, 512)    d2 = decoder_block(d1, s3, 256)    d3 = decoder_block(d2, s2, 128)    d4 = decoder_block(d3, s1, 64)        # --- 输出层 ---    # 采用 Sigmoid 激活,输出每个像素属于血管的概率值 [0, 1]    outputs = Conv2D(num_classes, 1, padding='same', activation='sigmoid')(d4)        model = Model(inputs=inputs, outputs=outputs, name='U-Net_Retina')    return model# --- 5. 实例化并审查模型参数 ---# 针对视网膜影像,我们采用 512x512 的高分辨率输入以保留微血管model = unet_model(input_shape=(512, 512, 3), num_classes=1)model.summary()

通过 model.summary() 的输出可以观察到,整个网络呈现出完美的对称结构。特别是解码器部分的参数量非常庞大,这正是因为它需要不断整合来自编码器的跳跃连接数据。在医疗影像分割中,这种“慢速下降、精准上升”的策略是应对视网膜血管这类极端细长目标的良药。随着模型架构的就绪,接下来的任务是为其注入“灵魂”——定义专门针对极度不平衡数据的损失函数与评价指标。

4.4训练模型

在模型编译环节,我们采用了 Adam 优化器并配合 Binary Cross-entropy 损失函数。考虑到血管分割任务的特殊性,我们特别引入了 MeanIoU 作为监控指标,它能比单纯的准确率更客观地反映预测区域与真值区域的重叠程度。在回调函数(Callbacks)的设计上,我们构建了两道防线:ModelCheckpoint 负责实时扫描并锁定验证集表现最优的时刻,将其固化为 .keras 模型文件;而 EarlyStopping 则充当了“性能守望者”,如果模型在连续 10 个 Epoch 内无法刷新最佳战绩,则会自动触发停机并回滚至最优状态。这种“小批次、多轮次”的训练策略,配合 0.1 的验证集比例,使得 U-Net 能够在有限的眼底样本中稳健地提取血管纹理。

# --- 1. 模型编译:配置优化器、损失函数与语义分割评价指标 ---model.compile(    optimizer='adam',                # 采用自适应矩估计优化器,确保收敛平稳    loss="binary_crossentropy",      # 针对二分类掩码的像素级交叉熵损失    # MeanIoU 能更真实地反映血管区域的分割质量    metrics=['accuracy', MeanIoU(num_classes=2)])# --- 2. 配置智能监控回调函数 ---# 自动保存验证集表现最优秀的模型权重checkpoint_cb = ModelCheckpoint(    'best_model.keras',    save_best_only=True,             # 仅保留历史表现最好的权重    monitor='val_loss',              # 以验证集损失作为评判标准    mode='min',                      # 目标是最小化损失值(注:原代码中max需根据monitor调整)    verbose=1)# 动态早停:防止过度拟合,节约计算资源early_stopping_cb = EarlyStopping(    monitor='val_loss',    patience=10,                     # 容忍 10 轮无性能提升    mode='min',     restore_best_weights=True,       # 训练结束后自动加载表现最好的权重    verbose=1)# --- 3. 启动模型迭代流程 ---# 在 512x512 高分辨率下,Batch Size 设为 8 以平衡显存压力与梯度更新频率history = model.fit(    X,    y,    validation_split=0.1,            # 划出 10% 的训练数据用于在线验证    epochs=100,                      # 设置最大迭代上限    batch_size=8,    callbacks=[checkpoint_cb, early_stopping_cb],    verbose=1)

4.5模型评估

评估的第一步是审视训练全周期的历史轨迹。我们利用 matplotlib 构建了三联排的可视化图表,分别监控 Loss(损失)、Accuracy(准确率)以及 Mean IoU(平均交并比)。在血管分割中,Mean IoU 是含金量最高的指标,它反映了模型预测的血管区域与医生手工标注区域的重叠程度。理想的状态是训练曲线与验证曲线保持高度同步下降(或上升),且在触发早停机制前达到平稳的平台期。如果发现验证集 IoU 在后期出现剧烈波动,通常预示着模型在尝试过度拟合某些非典型的眼底渗出物或视盘边缘,而我们通过回调函数锁定的“巅峰权重”正是为了规避这一风险。

# --- 1. 绘制三位一体的训练监控曲线 ---fig, ax = plt.subplots(1, 3, figsize=(15, 4))# 绘制 Loss 曲线:观察模型对像素分类误差的整体收敛情况ax[0].plot(history.epoch, history.history["loss"], label="训练损失 (Train)")ax[0].plot(history.epoch, history.history["val_loss"], label="验证损失 (Val)")ax[0].set_title("损失函数 (Loss)")ax[0].legend()# 绘制 Accuracy 曲线:监控整体像素层面的分类正确率ax[1].plot(history.epoch, history.history["accuracy"], label="训练准确率 (Train)")ax[1].plot(history.epoch, history.history["val_accuracy"], label="验证准确率 (Val)")ax[1].set_title("准确率 (Accuracy)")ax[1].legend()# 绘制 Mean IoU 曲线:这是衡量血管重叠精度的核心硬指标# 注意:Key 名需与模型编译时定义的指标名称一致ax[2].plot(history.epoch, history.history["mean_io_u"], label="训练 IoU (Train)")ax[2].plot(history.epoch, history.history["val_mean_io_u"], label="验证 IoU (Val)")ax[2].set_title("平均交并比 (Mean IoU)")ax[2].legend()fig.suptitle('模型训练全周期指标演进图', fontsize=16)plt.tight_layout()plt.show()

results = model.evaluate(X_test, y_test, verbose=-1)for name, value in zip(["Test Loss","Test Accuracy","Test Mean IOU"], results):    print(f"{name}: {value:.4f}")

4.6模型预测

评估模型在实际应用中的表现,需要将模型预测的概率图进行二值化处理。我们设定了 0.5 作为判定界限:概率高于此值的像素被标记为血管(白色),低于此值的则判定为背景(黑色)。通过 display_predictions 函数,我们将原始眼底图、专家标注的真值(Ground Truth)以及模型生成的预测图(Predicted Mask)进行三位一体的横向对比。这种直观的可视化能让我们立刻发现模型在视盘边缘或黄斑区是否存在误判,以及 U-Net 的跳跃连接是否成功保留了那些末梢微血管的连通性。

# --- 1. 执行全测试集推理 ---# 获取模型对测试集 X_test 的原始概率预测图y_pred = model.predict(X_test)# --- 2. 阈值化处理:将 [0, 1] 的连续概率转化为二值掩码 ---# 设定 0.5 为分类界限,保留高置信度的血管像素y_pred_thresholded = (y_pred > 0.5).astype(np.uint8)# --- 3. 定义三视图对比函数 ---def display_predictions(X_data, y_true, y_pred, index=0):    """    并排显示原始眼底图、医生标注真值与模型预测结果    """    image = X_data[index]    true_mask = y_true[index].squeeze() # 移除通道维度用于显示    pred_mask = y_pred[index].squeeze()    fig, axes = plt.subplots(1, 3, figsize=(15, 5))    # 绘制原始彩色眼底影像    axes[0].imshow(image)    axes[0].set_title("原始眼底影像 (Original)", fontsize=12)    axes[0].axis("off")    # 绘制专家标注的血管金标准    axes[1].imshow(true_mask, cmap='gray')    axes[1].set_title("专家标注真值 (Ground Truth)", fontsize=12)    axes[1].axis("off")    # 绘制 U-Net 预测生成的血管掩码    axes[2].imshow(pred_mask, cmap='gray')    axes[2].set_title("模型预测结果 (Predicted)", fontsize=12)    axes[2].axis("off")    plt.tight_layout()    plt.show()# --- 4. 随机抽取测试样本进行视觉验证 ---# 查看索引为 0 的样本,检查血管末梢的还原度display_predictions(X_test, y_test, y_pred_thresholded, index=0)

5.总结

本实验依托于 Kaggle 提供的专业视网膜眼底影像数据集,通过构建对称式的 U-Net 深度卷积网络,成功实现了对复杂血管拓扑结构的像素级自动化分割。实验结果表明,模型在测试集上达到了 0.8770 的像素准确率,并在最具挑战性的平均交并比(Mean IoU)指标上取得了 0.4734 的成绩。虽然视网膜血管极度稀疏且形态纤细,给分割任务带来了巨大的数据不平衡压力,但 U-Net 凭借其特有的跳跃连接机制,在抑制背景噪声的同时,有效地还原了微细血管的连通性。

这种精准的数字化剥离能力,不仅验证了深度学习在处理高精度医疗影像任务中的优越性,更为糖尿病视网膜病变、黄斑变性等致盲性眼疾的早期辅助诊断提供了关键的技术支撑,展示了 AI 在提升临床筛查效率与诊断一致性方面的巨大潜力。

相关推荐