ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Unet眼底血管分割实战:从数据切片到训练避坑

Unet眼底血管分割实战:从数据切片到训练避坑 简介基于U-Net的眼底血管分割完整项目面向医学图像处理与深度学习入门者提供从数据集到训练、推理、结果分析的一站式流程。整套7z压缩包内共216个文件包体约153.92MB其中182个PNG图像用于训练与预测展示8个Python脚本及配套pyc文件实现多尺度训练、IoU/损失曲线绘制与一键推理pth权重文件保存了训练10个epochs的最优模型txt文本记录类别权重与训练日志readme可指导环境配置与自定义数据接入。目前已有269人学习下载。项目在10个epochs下即达到全局像素准确率0.95、mIoU 0.67若加大epoch性能可进一步提升代码采用cos学习率衰减自动为Unet输出通道数适配二分割utils模块会把mask灰度值写入txt还可在日志中查看各类别IoU、recall、precision。将待推理图片放入inference目录并运行predict脚本即可无需手动设参适合快速复现和二次开发。1. 从一张眼底照片里找血管Unet 为什么成了这个任务的默认答案眼底血管分割不是一个新问题但直到 Unet 出现之前它都处在一种“能做但不实用”的状态。传统方法靠滤波器和形态学操作提取血管光照不均、病灶遮挡、毛细血管太细这三座大山压下来分割结果总是断的。Unet 在 2015 年被提出后医学图像分割几乎被它统一了——这不是因为它结构多花哨而是它把“位置信息”和“语义信息”焊在了一起这在血管这种极度依赖局部细节又需要全局上下文的任务里是致命的优势。这套方案里包含切片好的数据集、完整代码和训练结果文件本质上是一个可以直接复现的整链路从原始眼底图到像素级血管掩膜Unet 在其中承担的不仅是特征提取更是一个可训练的空间滤波器。适合谁用做医学影像分析的初学者、需要快速搭一套分割基线的研究生以及想在自己的数据集上验证 Unet 效果的算法工程师。本文不绕弯子直接拆网络结构、数据切片、训练参数和踩坑记录让你从零把这条链路跑通。2. Unet 的骨架与眼底血管任务的特殊性为什么 skip connection 在这里是刚需2.1 编码器-解码器结构血管分割需要多大感受野又需要多细的细节Unet 的核心架构是一个对称的 U 形左侧编码器逐层下采样压缩空间分辨率右侧解码器逐层上采样恢复分辨率。编码器每降一次分辨率特征图的通道数翻倍这意味着网络在高层能看到更大的视野——对于眼底血管来说大血管的走向、视盘附近的血管弧度、病变区域的遮挡关系都需要这种全局信息才能判断。但光有全局不够。血管分割的难点在于微细血管的宽度只有 1 到 3 个像素如果单纯走“编码-解码”的瓶颈结构细节在逐层池化中早就磨没了。就像你先把一张照片缩小成缩略图再放大回去边缘信息是回不来的。下采样是信息损失的过程而上采样是信息重建的过程重建质量决定了毛细血管的连续性。这里就是 Unet 与普通全卷积网络最本质的差别skip connection。编码器每一层的特征图不但在本层继续往下走还会通过横向连接直接拼接到解码器对应层上。这相当于解码器在恢复细节时直接把原始分辨率下的边缘特征拿过来用不需要从模糊的高层特征里“猜”。对于血管这种对边界极其敏感的结构skip connection 不是锦上添花而是刚需。2.2 眼底血管的类不均衡问题血管像素占比只有 10% 左右我的经验里第一次跑血管分割最容易翻车的地方不是网络结构而是损失函数。眼底图像里血管区域通常只占整幅图像的 8% 到 12%剩下的全是背景。如果你直接拿普通的交叉熵损失去训练网络会发现“全部预测为背景”就能拿到 90% 以上的准确率训练过程看起来 loss 在降实际分割结果是一片黑。这是语义分割里经典的类不均衡问题在血管任务上尤其极端。解决办法有两个方向一是用加权交叉熵给前景像素更高权重二是直接用 Dice Loss 或 Focal Loss。我在实际项目中倾向 Dice Loss因为它直接优化的是分割结果的重叠率比加权交叉熵更贴近最终目标。但纯 Dice Loss 在小目标上收敛不稳定常见做法是 Dice Loss 和交叉熵按一定比例叠加比如0.5 * dice_loss 0.5 * bce_loss。另一个需要注意的点是数据增强策略。眼底图像有固定的生理结构视盘通常在图像一侧血管从视盘向外辐射。做水平翻转和垂直翻转时语义不会改变但旋转角度需要谨慎——眼底图像没有绝对的“上方”概念但旋转超过 90 度会对血管的走向分布产生影响。我一般只用小角度旋转、翻转和弹性形变的组合不做 90 度整数倍旋转。2.3 Unet 的经典变体取舍plain Unet 还是 Unet这个项目标题直接写的是“Unet”没有加或其他后缀说明目标是用最基础的结构跑通任务。但你在复现时会面临一个选择是照搬原始论文的 plain Unet还是用有密集跳跃连接的 Unet。从训练资源的维度看plain Unet 的参数量在 3100 万左右取决于编码器深度Unet 因为密集连接会额外增加约 20% 到 30% 的参数量。如果你的 GPU 显存有限或者数据量本身不大plain Unet 反而更容易训练。Unet 的优势体现在多尺度特征融合上它在皮肤病变分割和肺结节分割上比 plain Unet 有稳定提升但在眼底血管上优势并不绝对。我的建议是先跑通 plain Unet把数据、训练、评估这条链路摸清。等你有了基线结果再考虑改成 Unet 或者加注意力模块比如 Attention Unet做对比实验。直接上复杂模型而不理解基础结构的边界后面排查问题会非常痛苦。3. 切片好的数据集怎么用从原始眼底图到训练样本的完整转换3.1 理解切片数据的组织方式训练集、验证集、标签的对应关系这个方案的核心资产之一是“切片好的数据集”。所谓切片在眼底血管分割里通常指两种操作一是把原始大图切成若干小 patch因为整张眼底图动辄 500×500 甚至更高分辨率直接整图训练对显存要求极高二是按比例划分训练集和验证集确保评估时用的是模型没见过的数据。拿到切片后的数据集先别急着训练。我一般会做一个完整性检查确认每个训练样本都有对应的标签图且文件名能对应上。切片数据的目录结构通常是这样的dataset/ ├── train/ │ ├── images/ │ │ ├── 01_patch_0.png │ │ ├── 01_patch_1.png │ └── masks/ │ ├── 01_patch_0.png │ └── 01_patch_1.png ├── val/ │ ├── images/ │ └── masks/先确认标签图的数值范围。眼底血管分割的标签图应该是二值图只有 0背景和 255血管两种像素值但有些数据集会用 0 和 1。训练时要在代码里统一转换比如mask mask // 255否则 loss 计算会出问题。这一步看起来简单但格式不统一导致的训练崩溃是最常见的前期事故。3.2 训练集与测试集不能有病人重叠这是数据切片的铁律这是切片数据使用时最容易踩的坑。如果原始数据来自多张眼底图比如 DRIVE 数据集有 40 张图你不能把所有图像混合后随机切 patch 再划分训练测试——同一个病人的多个 patch 可能同时出现在训练集和测试集里这会导致模型在测试时“见过”部分病灶信息评估指标虚高。正确的做法是先按图像划分病人集合再从每个病人的图像里切 patch。例如有 40 张原始图按 70/30 比例划分训练和测试即先选 28 张图的 patch 进训练集剩余 12 张图的 patch 进测试集。切片时训练集和测试集分开切不共享任何像素。如果你的数据集已经有明确的划分说明就严格按照说明来。3.3 自己写切片脚本patch 大小、重叠率与切片数量的三角关系如果数据集给的是原始眼底图而不是切片你需要自己写切片脚本。patch 大小的选择直接影响训练效果和显存占用。patch 越大模型看到的空间范围越大上下文信息越足但显存占用跟随平方级增长patch 越小样本数越多切片数量越多但可能截断血管的连续性。以 DRIVE 数据集的 565×584 原始图为例常见配置是切 256×256 或 128×128 的 patch。256 的 patch 在 8GB 显存上配 batch size 4 基本是上限。下面是一个标准的重叠切片脚本import cv2 import numpy as np import os def sliding_window_crop(image, mask, patch_size256, stride128): 对单张眼底图和对应标签做滑窗裁剪 stride 小于 patch_size 时产生重叠增加样本量并保留边界连续性 h, w image.shape[:2] patches_img, patches_mask [], [] for y in range(0, h - patch_size 1, stride): for x in range(0, w - patch_size 1, stride): img_patch image[y:y patch_size, x:x patch_size] mask_patch mask[y:y patch_size, x:x patch_size] patches_img.append(img_patch) patches_mask.append(mask_patch) # 如果边缘剩余区域不足 patch_size做右下角补齐采样 if (h - patch_size) % stride ! 0 or (w - patch_size) % stride ! 0: for y in [h - patch_size]: for x in [w - patch_size]: if y 0 and x 0: patches_img.append(image[y:y patch_size, x:x patch_size]) patches_mask.append(mask[y:y patch_size, x:x patch_size]) return patches_img, patches_mask # 使用示例 image cv2.imread(raw/01_training.png) # BGR顺序读取 mask cv2.imread(raw/01_manual1.png, cv2.IMREAD_GRAYSCALE) # 标签以灰度图读取 patches_img, patches_mask sliding_window_crop(image, mask, patch_size256, stride128) os.makedirs(train/images, exist_okTrue) os.makedirs(train/masks, exist_okTrue) for i, (img_p, mask_p) in enumerate(zip(patches_img, patches_mask)): cv2.imwrite(ftrain/images/01_patch_{i}.png, img_p) cv2.imwrite(ftrain/masks/01_patch_{i}.png, mask_p)这个脚本的关键参数是stride。stride 等于 patch_size 时不重叠样本量最小stride 设为 patch_size 的一半时样本量约为不重叠的 4 倍且相邻 patch 有 50% 的重叠区域这能有效缓解血管在 patch 边界被截断的问题。我的实践是如果原始图只有 20 到 40 张必须用重叠切片扩样本如果原始图超过 100 张不重叠切片配合数据增强也够用。4. 完整代码怎么跑通从 DataLoader 到训练主循环的每个细节4.1 自定义 Dataset加载切片图像与掩膜实时做数据增强拿到切片数据后第一步是写一个 PyTorch 的 Dataset 类负责把图像和标签成对加载。这里要特别注意一个实际问题眼底图和标签的读取方式不同。原图是 RGB 三通道用cv2.imread默认读成 BGR要做颜色通道转换标签是单通道灰度图读取时要用cv2.IMREAD_GRAYSCALE并且把像素值归一化到 0-1 区间。数据增强应该在 Dataset 里做而不是提前做离线增强。离线增强会把数据集物理体积膨胀好几倍而且无法在训练中随机变化。在线增强每次 epoch 产生不同的增强结果相当于模型看到的是“无限”数据集。学术界的标准做法是原图上做随机翻转、旋转和弹性形变但标签图做完全相同的变换——必须用同一个随机种子否则图像和标签错位训练会直接崩掉。import torch from torch.utils.data import Dataset import cv2 import numpy as np import albumentations as A class VesselDataset(Dataset): 眼底血管分割数据集加载切片后的图像与对应的二值标签 def __init__(self, image_paths, mask_paths, augmentFalse): self.image_paths image_paths self.mask_paths mask_paths self.augment augment self.transform A.Compose([ A.HorizontalFlip(p0.5), A.VerticalFlip(p0.5), A.RandomBrightnessContrast(p0.3), A.ElasticTransform(alpha1, sigma50, alpha_affine50, p0.2), ]) def __len__(self): return len(self.image_paths) def __getitem__(self, idx): image cv2.imread(self.image_paths[idx]) image cv2.cvtColor(image, cv2.COLOR_BGR2RGB) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 标签像素值统一归一到 0/1二值化防止出现中间值 mask (mask 127).astype(np.float32) if self.augment: augmented self.transform(imageimage, maskmask) image augmented[image] mask augmented[mask] # HWC - CHW像素值归一到 [0, 1] image image.transpose(2, 0, 1).astype(np.float32) / 255.0 mask mask[np.newaxis, :, :] return torch.from_numpy(image), torch.from_numpy(mask)这里用 albumentations 库做增强它最方便的地方是自动保证 image 和 mask 应用完全相同的随机变换不用手动同步随机种子。ElasticTransform是医学图像分割里很常用的一种增强模拟血管的形变对泛化性有帮助但注意弹性形变强度不要太大alpha 超过 2 会让细血管扭曲得过于夸张。4.2 定义 Unet 模型用现成实现还是自己搭实际上经验丰富的从业者都不会自己从头写 Unet因为网上已经有大量经过验证的实现。但这个项目标题强调“完整代码”意味着代码包里的 Unet 结构就是你要用的模型不必自己重写。你需要做的事情是理解这个模型的输入输出维度以及如何加载预训练权重。阅读代码包里的模型定义时重点关注几个地方输入图像的通道数眼底图是 3 通道、第一层卷积的 kernel size、下采样次数通常 4 次。确认模型接受 256×256 的输入后把模型实例化并打印参数量import torch from models.unet import UNet # 假设代码包里的模型文件 device torch.device(cuda if torch.cuda.is_available() else cpu) model UNet(in_channels3, out_channels1, init_features32).to(device) model torch.nn.DataParallel(model) # 单机多卡时开启 total_params sum(p.numel() for p in model.parameters()) trainable_params sum(p.numel() for p in model.parameters() if p.requires_grad) print(fTotal params: {total_params / 1e6:.2f}M, Trainable: {trainable_params / 1e6:.2f}M)init_features32表示编码器第一层的初始通道数每下采样一次翻倍32 → 64 → 128 → 256 → 512。这个值越大模型容量越大但小数据集上容易过拟合。如果数据集切片后只有几百张 patch32 是安全的起点如果数据量上千可以尝试 64。4.3 损失函数与训练主循环Dice Loss 实现与学习率调度训练主循环是整个链路的核心。损失函数我推荐 Dice Loss 与二元交叉熵的组合。Dice Loss 的实现有几个细节需要在 batch 维度和空间维度上同时求交集与并集分母加上平滑项防止除零用torch.sigmoid把 logits 转成概率后计算。import torch import torch.nn as nn import torch.nn.functional as F class DiceBCELoss(nn.Module): Dice Loss 与 BCE 的组合损失兼顾像素级精度与区域重叠率 def __init__(self, smooth1e-6): super().__init__() self.smooth smooth self.bce nn.BCEWithLogitsLoss() def forward(self, logits, targets): probs torch.sigmoid(logits) # 展平到 batch 维度计算 Dice probs_flat probs.view(probs.size(0), -1) targets_flat targets.view(targets.size(0), -1) intersection (probs_flat * targets_flat).sum(dim1) union probs_flat.sum(dim1) targets_flat.sum(dim1) dice (2.0 * intersection self.smooth) / (union self.smooth) dice_loss 1 - dice.mean() bce_loss self.bce(logits, targets) return 0.5 * dice_loss 0.5 * bce_loss训练主循环里有一个常见错误总是忘记在验证时把模型切到eval()模式。这会导致 BatchNorm 在推理时继续用当前 batch 的均值方差验证指标忽高忽低误以为模型训练不稳定。另外训练时保存最优模型不只是看验证集 loss还要同时看 F1 分数——loss 最低的 epoch 不一定分割效果最好因为在类不均衡任务上loss 曲线的分辨力不够。def train_one_epoch(model, dataloader, optimizer, criterion, device): model.train() total_loss 0.0 for images, masks in dataloader: images, masks images.to(device), masks.to(device) optimizer.zero_grad() logits model(images) loss criterion(logits, masks) loss.backward() optimizer.step() total_loss loss.item() return total_loss / len(dataloader) def validate(model, dataloader, criterion, device): model.eval() # eval模式关闭DropoutBatchNorm用全局统计量 total_loss 0.0 fp_counter 0 # 统计全预测为背景的坏模型 with torch.no_grad(): for images, masks in dataloader: images, masks images.to(device), masks.to(device) logits model(images) loss criterion(logits, masks) total_loss loss.item() preds (torch.sigmoid(logits) 0.5).float() if preds.sum() 0: fp_counter 1 return total_loss / len(dataloader), fp_counter上面的验证函数里我加了一个preds.sum() 0的计数——如果验证集上模型把所有 patch 都预测为背景说明发生了类不均衡崩溃每个 epoch 都会产生一个空预测。这比只看 loss 更早发现问题。4.4 训练参数选择学习率、batch size 与 epoch 数量怎么定训练参数是新手最容易用“默认值”带过但实际上影响非常大的环节。我基于多个眼底血管数据集的训练经验给出以下推荐起点参数推荐值说明优化器Adamβ10.9, β20.999适合医学图像分割这类非凸优化初始学习率1e-4不可大于 1e-3否则 Dice Loss 早期会震荡batch size4-8由显存决定低于 4 时 BatchNorm 不稳定学习率调度ReduceLROnPlateau验证 loss 连续 5 epoch 不降则 lr 减半epoch 数80-120配合早停patience 设为 15输入尺寸256×256平衡上下文信息与显存占用优化器选用 Adam 是因为它在医学图像分割任务上几乎不需要调整动量参数就能收敛得很好。但 Adam 有一个已知缺点后期收敛不够精细。经验做法是用 Adam 训练前 60 个 epoch然后切换成带 momentum 的 SGD 做微调学习率降到 1e-5。这个切换能带来 F1 分数 0.01 到 0.02 的提升属于性价比极高的调参技巧。学习率调度我有一个血泪教训不要只看训练 loss 来降学习率训练 loss 下降不代表验证 loss 也下降。如果训练 loss 持续下降而验证 loss 停在原地不动是过拟合信号此时应该降低学习率而不是继续训练。我一般同时监控训练和验证的 loss 曲线触发条件只认验证 loss。5. 训练结果文件怎么解从指标到可视化以及 4 个避坑记录5.1 结果文件里有什么权重、日志、预测图与指标表训练完成后的结果文件通常包含以下几类内容你需要知道每个文件怎么用、看什么模型权重best_model.pth是验证集 F1 最优的权重last_model.pth是最后一个 epoch 的权重。推理时用best_model.pth续训时用last_model.pth。训练日志通常是.txt或.csv格式每个 epoch 记录训练 loss、验证 loss、F1、IOU 等指标。用来画 loss 曲线观察收敛趋势。预测图验证集上的分割结果对比标签图检查视觉效果这个是最终裁决者。指标汇总F1、IOU、AUC、准确率等。看 F1 和 IOU 为主Acc 和 Dice 为辅。加载权重做推理的代码框架如下model.load_state_dict(torch.load(checkpoints/best_model.pth, map_locationdevice)) model.eval() # 单张图推理 with torch.no_grad(): input_tensor preprocess_image(test/01_test.png) # 归一化转CHW加batch维度 logits model(input_tensor.to(device)) probs torch.sigmoid(logits) pred (probs 0.5).float().cpu().numpy().squeeze() # 保存预测结果 cv2.imwrite(results/01_pred.png, (pred * 255).astype(np.uint8))阈值 0.5 是默认值但血管分割的最终预测目检时你会发现阈值可以调。如果模型输出偏保守血管被低估把阈值降到 0.4 或多加一个形态学闭运算会得到更连续的血管如果背景噪声很多阈值升到 0.6。阈值的选择属于后处理调优不改模型不动训练只影响预测二值化。5.2 为什么验证集 F1 高但目检效果差切片边界断裂与阈值问题这是最迷惑人的一个现象指标表上 F1 到了 0.80 以上看起来不错但把预测图拼回完整原始图像时血管在 patch 拼接边界处出现明显断裂。原因在于窗口切片时血管跨越了 patch 边界模型在 patch 边缘的预测置信度天然偏低——它看到的上下文不完整。解决这个问题的标准做法是重叠推理推理时不是从原始图左上角不重叠地切 patch而是用带 stride 的滑动窗口切 patch得到多份重叠的预测概率图然后在重叠区域取均值。这个策略能有效消除拼接缝代价是推理时间翻倍。class 里推理如下def predict_full_image(model, image, patch_size256, stride128, devicecuda): model.eval() h, w image.shape[:2] prob_map np.zeros((h, w, 1), dtypenp.float32) count_map np.zeros((h, w, 1), dtypenp.float32) for y in range(0, h, stride): for x in range(0, w, stride): # 处理边缘越界 y_start min(y, h - patch_size) x_start min(x, w - patch_size) patch image[y_start:y_startpatch_size, x_start:x_startpatch_size] # 预处理并推理 patch_tensor torch.from_numpy(patch.transpose(2,0,1) / 255.0).unsqueeze(0).float().to(device) with torch.no_grad(): prob torch.sigmoid(model(patch_tensor)).cpu().numpy()[0, 0] prob_map[y_start:y_startpatch_size, x_start:x_startpatch_size] prob[..., np.newaxis] count_map[y_start:y_startpatch_size, x_start:x_startpatch_size] 1 prob_map / np.maximum(count_map, 1) return prob_mapcount_map防止除零每个像素点可能被多个 patch 覆盖除以出现次数等于取平均值。5.3 避坑记录 1训练集和验证集随机打乱却忘了先按病人划分现象训练过程一切正常验证集 loss 下降得异常快F1 高达 0.9 以上但把模型拿到新的眼底图上推理效果直线下降。原因数据划分时没有按病人分组同一个病人不同位置的 patch 被同时分进了训练集和验证集。模型在验证时偷看了同一个病人的图像分布指标虚高泛化能力被高估。解决检查数据划分逻辑以原始图像 ID 为单位划分训练验证保证验证集中的所有 patch 都来自训练集中没有出现过的原始图像。如果你的切片脚本是对每张原始图独立切片的那就需要先划分原始图再切 patch。5.4 避坑记录 2BatchNorm 在 batch size 为 1 时训练不收敛现象显存不足把 batch size 降到 1 后训练 loss 震荡剧烈验证 loss 完全下不去。原因BatchNorm 在一个 batch 只有 1 张图时均值和方差统计量来自单样本噪声极大导致模型训练不稳定。眼底血管分割的显存压力主要来自高分辨率输入不是 batch 大小本身。解决把输入 patch 从 256×256 降到 192×192 或 160×160保住 batch size 为 4 以上或者去掉 BatchNorm 换成 InstanceNorm。前者更简单效果也更稳定。5.5 避坑记录 3标签图保存为 JPEG 导致血管边界糊成灰色现象训练时 loss 能降但预测结果里血管边缘有一圈灰色晕影指标 F1 卡在 0.7 左右上不去。原因标签图被保存成了 JPEG 格式压缩过程引入伪影。血管分割的标签是二值的但 JPEG 压缩会让边界变成过度的灰色值虽然(mask 127)能强行二值化但边界处原本精确的像素位置已经发生了偏移。解决检查数据集文件后缀确保掩膜是 PNG 或 BMP 等无损格式。如果已经买了这份数据集且部分标签是 JPEG用cv2.imread读入后做 Otsu 二值化再逐张看效果。5.6 避坑记录 4训练到一半显存溢出OOM现象训练在某个 epoch 中途突然报CUDA out of memory重启训练后稳定一段时间又复发。原因PyTorch 的显存分配不是随 epoch 线性增长的。中途出现 OOM 常见原因是数据加载时的预处理在 CPU 端用了过多内存管理操作或者某个 batch 中图包含的血管密度高导致中间特征图激活值剧增。另外验证阶段如果忘了关梯度验证时也占用同样显存。解决训练和验证共用 GPU 时验证用with torch.no_grad():包裹如果显存还是不够开启gradient_accumulation_steps每个 batch 的梯度累积 2 步再更新一次等效于增大了 batch size 但显存占用不涨scaler.scale(loss).backward() if (step 1) % 2 0: # 每2步更新一次 scaler.step(optimizer) scaler.update() optimizer.zero_grad()这里用了混合精度训练torch.cuda.amp显存占用能减少约 40%。项目代码里如果没启用 AMP手动加上是一个性价比极高的优化手段。6. 进阶验证习惯把预测图叠加到原图上目检比任何指标都诚实训练出了一个看起来指标不错的模型先别急着生成指标表。我有一条坚持了多年的习惯把预测的血管轮廓叠加到原始眼底图上逐张看。自动指标有盲区——F1 综合反映整体重叠率但看不出血管的拓扑连续性。一个模型的 F1 数值不错可能只是大血管分割得好细血管全部断掉。叠加目检能在 10 秒内抓住这种问题。叠加可视化用 OpenCV 很简单预测图二值化后找出血管轮廓在原图上用绿色描边。def overlay_prediction(original_image, pred_mask, alpha0.5, color(0, 255, 0)): 把预测血管区域以半透明绿色叠加到原图上 overlay original_image.copy() mask_bool pred_mask 0.5 overlay[mask_bool] (overlay[mask_bool] * alpha np.array(color) * (1 - alpha)) return overlay # 目检三种典型情况 # 1. 大血管完整、细血管断裂 → 需要降低阈值或加强数据增强 # 2. 血管连续但背景噪点多 → 需要提高阈值或加后处理 # 3. 视盘边界被误判为血管 → 需要检查训练数据的标签正确性另一个被严重低估的习惯是看每个病例里全部 slice 或 patch 的预测结果拼接图。切片训练最大的代价是丢失全图上下文。如果你训练和推理都发生在 patch 级别你永远看不到完整血管树的形态。我会在验证阶段对每个验证集病例做整图推理把所有 patch 预测拼回去叠加到原始图上连续观察。用上述方式审查全部验证集结果后回到指标表里找一个与目检判断一致的指标。如果你觉得血管边缘太平滑去检查 Dice 是不是被大血管主导了如果你觉得毛细血管被漏掉不少去检查阈值是不是要下探。指标数值是裁判但它只能告诉你分数不能告诉你哪里踢得差叠加可视化才能让你看到失分点。这个项目的地基是 Unet 这个老牌结构但真正拉开效果差距的从来不是结构本身而是对数据切片边界、类不均衡、评估方法这三个环节的处理。我做过的分割项目里凡是效果不如预期的几乎都能在这三个方向上找到原因。建议你把代码跑通的第一次结果当作基线然后针对上面三个环节逐一做对比实验每次只改一个变量。这种控制变量法虽然古老但在深度学习里依旧是最可靠的调参思路。希望这些从数据切片到训练排错的细节能帮到你祝你的眼底血管分割跑出干净、连续、可信的结果。本文还有配套的精品资源点击获取
返回列表