
简介基于Unet的眼底血管分割项目完整覆盖切片数据集、训练代码与推理脚本适合深度学习入门者及医学图像处理学习者。包内共216个文件包括182张PNG样本图、8个Python脚本负责多尺度训练、指标计算与自动推理、14个pyc编译文件、模型权重pth及配置说明等压缩包仅153.92MB便于快速下载。模型训练10个epoch即达到全局像素准确率0.95、mIoU 0.67代码支持随机缩放、自动读取mask灰度值并动态设定输出通道学习率采用cos衰减训练日志中可查看各类别IoU、Recall、Precision等曲线。推理时只需将图像放入inference目录运行predict脚本无需额外参数README对小白友好。目前已有269人学习适合以此作为Unet分割实战与调优的起点。1. 眼底血管分割不是跑通一个Unet就完事数据切片和结果文件才是门槛拿到一套基于Unet的眼底血管分割工程里面配好了切片数据集、完整代码和训练结果文件看上去是躺赢。但真跑起来你会发现模型本身不是瓶颈翻车大多发生在标签对齐、损失函数和结果文件解读上。Unet在工程目录里常写成Unet论文里写作U-Net从2015年进入医学图像分割领域几乎是视网膜血管这类细长目标分割的默认起点。它做的事情很直白逐像素判断一个点是血管还是背景本质是语义分割里的二分类问题。适合谁读刚拿到这套资料打算跑通的初学者以及手里有结果文件、想继续改进的从业者。这套方案值不值得投入我的判断是值得但要把力气花在数据切片和后处理上而不是一上来就换更花哨的网络结构。2. U-Net为什么是眼底血管分割的默认选项编码器、解码器与跳跃连接的取舍U-Net能在这个任务里成为默认选项不是因为结构花哨而是它的编码器-解码器结构正好契合「语义要深、细节要全」这对矛盾需求。很多初学者以为网络越深分割越准实际在血管这类细长目标上更关键的是特征分辨率能不能保住。U-Net不是黑匣子它每一个模块对应的都是可解释的图像处理直觉。下面把它拆开看再给一份能直接跑的最小实现。2.1 编码器四次下采样语义够深细血管靠什么留下来编码器做的事是用卷积加池化逐级把特征图变小、通道变多每一次2x2最大池化都会丢掉一半的空间分辨率。以眼底公开数据集DRIVE为例原始图像尺寸是565x584经过四次下采样之后特征图只剩约35x36。在这个尺度上1到3像素宽的血管末梢在数值上已经不存在了。所以编码器不是在「看血管」而是在构建「哪里存在血管结构」的语义响应。这个阶段如果只看输出确实会丢细节但它换来的是更大的感受野最后一层每个点都能看到原图上很大一片区域模型才能判断一根细线是血管还是噪声纹理。下采样丢细节不是bug是设计代价由后面的解码器来补。U-Net之所以比普通全卷积网络更适合眼底血管关键就在它做了对称的解码器。解码器把低分辨率的语义特征一步步上采样回原图尺寸每一次上采样都通过跳跃连接拿到编码器对应层的细节特征。语义信息和空间信息在解码器里逐级融合细血管才能在恢复分辨率的过程中被重新「画」出来。2.2 跳跃连接空间细节的定向回传通道跳跃连接是U-Net和FCN、SegNet这类早期分割网络最本质的差别。它做的事很简单把编码器每一层下采样之前的特征图沿通道维度拼接给对应的解码器层。注意是拼接不是相加通道数会翻倍。编码器早期层比如enc1、enc2通道少、分辨率高保留了血管边缘、末梢和分叉点的细节深层特征有更强的语义但空间位置模糊。跳跃连接的本质是把「细节」和「语义」在解码器里直接拼起来让解码器不必凭空想象血管边缘在哪。具体到眼底血管最细的末梢血管对比度很低如果只靠深层特征上采样这些细线会被平均掉而跳跃连接把原始位置的梯度信号直接引回来末梢就保住了。我把三种常见分割网络的连接方式放在一起对比方便理解为什么大家最终都回到U-Net网络跨层连接方式细节恢复能力在细血管分割上的表现FCN跳跃加和add一般血管边缘容易糊末梢丢失多SegNet索引上采样无跨层特征中等结构保留可以低对比度细血管易断U-Net通道拼接concat强末梢血管保留最好长期是基线顺带说一句YOLO系做的是带检测框的实例分割和眼底血管这种逐像素语义分割二分类是两条技术路线拿检测的思路来套分割任务容易走偏。2.3 最小可运行的U-Net定义PyTorch代码与三处参数改动下面这份代码是标准的U-Net实现带BatchNorm输入输出都是单通道直接对应眼底血管分割的二分类任务。import torch import torch.nn as nn class DoubleConv(nn.Module): 两次 3x3 卷积 BN ReLUU-Net 的最小重复单元 def __init__(self, in_ch, out_ch): super().__init__() self.conv nn.Sequential( nn.Conv2d(in_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue), nn.Conv2d(out_ch, out_ch, 3, padding1), nn.BatchNorm2d(out_ch), nn.ReLU(inplaceTrue) ) def forward(self, x): return self.conv(x) class UNet(nn.Module): def __init__(self, in_ch1, out_ch1, base64): super().__init__() # 编码器通道逐层翻倍分辨率逐层减半 self.enc1 DoubleConv(in_ch, base) self.enc2 DoubleConv(base, base * 2) self.enc3 DoubleConv(base * 2, base * 4) self.enc4 DoubleConv(base * 4, base * 8) self.pool nn.MaxPool2d(2) self.bottom DoubleConv(base * 8, base * 16) # 解码器转置卷积上采样再与编码器特征拼接 self.up4 nn.ConvTranspose2d(base * 16, base * 8, 2, stride2) self.dec4 DoubleConv(base * 16, base * 8) self.up3 nn.ConvTranspose2d(base * 8, base * 4, 2, stride2) self.dec3 DoubleConv(base * 8, base * 4) self.up2 nn.ConvTranspose2d(base * 4, base * 2, 2, stride2) self.dec2 DoubleConv(base * 4, base * 2) self.up1 nn.ConvTranspose2d(base * 2, base, 2, stride2) self.dec1 DoubleConv(base * 2, base) self.out nn.Conv2d(base, out_ch, 1) def forward(self, x): e1 self.enc1(x) # 64通道原尺寸 e2 self.enc2(self.pool(e1)) # 128通道1/2尺寸 e3 self.enc3(self.pool(e2)) # 256通道1/4尺寸 e4 self.enc4(self.pool(e3)) # 512通道1/8尺寸 b self.bottom(self.pool(e4)) # 1024通道1/16尺寸 # 解码器每一层都把上采样结果和对应编码器特征拼接 d4 self.dec4(torch.cat([self.up4(b), e4], dim1)) d3 self.dec3(torch.cat([self.up3(d4), e3], dim1)) d2 self.dec2(torch.cat([self.up2(d3), e2], dim1)) d1 self.dec1(torch.cat([self.up1(d2), e1], dim1)) return self.out(d1)逻辑说明forward里每经过一次池化分辨率减半、通道翻倍直到bottom层变成1/16尺寸、1024通道解码器用ConvTranspose2d做2倍上采样再和编码器对应层拼接通道数翻倍后由DoubleConv融合压缩。参数说明base是基础通道数64是经典配置显存吃紧时可以降到48或32末梢血管会有少量损失但整体可接受in_ch是输入通道数眼底图如果预处理成单通道灰度图就填1若保留三通道彩色就填3out_ch填1对应背景和血管的二分类输出。这份实现加了BatchNorm原版U-Net没有但在眼底这种对比度差异大的数据上加BN收敛更稳是个值得保留的改动。3. 切片好的数据集这样接进训练目录检查、图像预处理与加载器首轮跑通数据集已经切片好省了最耗时的一步。但切片好的数据不代表可以直接开训三个地方不检查后面所有指标都是自欺欺人。这一章按我实际拿到一套陌生数据集时的检查顺序来写每一步都能直接照做。3.1 拿到切片数据集先查三样目录对应关系、尺寸一致性、标签对齐第一件事不是看代码是盘目录。切片数据集最常见的组织方式是一个图像目录加一个标签目录文件名一一对应。用sorted()排序加载时只要有一个文件名对不上整个训练集全部错位模型会把背景学成血管。我都会写一个10行的脚本把两个目录的文件名列出来做差集检查确保数量和名称完全一致。第二件事是检查所有切片的尺寸是否一致。切片过程如果用了滑窗最后一行或最后一列的patch尺寸可能和其他patch不同。模型里的卷积对尺寸不敏感但不同尺寸的图混在一个batch里DataLoader会直接报错即使你用了collate_fn强行padding也会引入边缘噪声。常见的做法是写个循环遍历一遍统计所有图像尺寸发现异常尺寸直接删掉或用反射填充补齐。第三件事是标签对齐抽查。血管标注通常是白色前景、黑色背景但有些数据的mask里混入了手工标注时的杂线或注释残留。加载前随机抽20张图把原图和mask用半透明方式叠加显示肉眼扫一遍确认血管和标注对得上。这一步换到其他二分类医学分割数据集比如息肉分割数据集上流程完全一样只是把眼底图换成内镜图。这一步不需要任何模型知识但它是整个项目里性价比最高的检查。目录或标签错位导致的错误会在后面损失函数和指标上表现出各种奇怪症状排查起来远比现在花两分钟核对目录痛苦。3.2 眼底图像预处理要点绿色通道、归一化与增强的取舍眼底血管分割的输入是眼底相机拍到的彩色图像。很多人直接转灰度图输入但标准加权灰度图里血管和背景的对比度会被红色通道拉低。实际经验是绿色通道里血管和背景的灰度差最大红色通道饱和度过高蓝色通道噪声最大。所以预处理第一步就是拆通道取绿色通道作为输入这一步带来的提升比换任何网络结构都明显。拆完通道之后做归一化。常见做法有两种简单除以255或者用全数据集统计的均值和标准差做标准化。我一般用后者因为不同眼底相机拍摄的亮度差异很大除以255之后不同批次的图亮度分布仍然不一致模型要额外花精力去适应亮度变化。统计全数据集的mean和std训练时减均值除标准差预测时用同一组参数效果会更稳。数据增强方面血管的走向是任意的随机翻转和随机旋转90度对模型学习是友好的不会产生语义错误亮度扰动和轻微对比度扰动可以做但范围要小幅度大了会把本来就低对比度的末梢血管直接抹掉。弹性形变在息肉分割这类任务里常用但在血管分割上要慎用它会让血管形态变形模型学到的血管粗细和连续性都偏离真实分布。3.3 数据加载器一个能跑通训练循环的Dataset实现import os import cv2 import numpy as np import torch from torch.utils.data import Dataset class VesselDataset(Dataset): def __init__(self, img_dir, mask_dir, patch_size128, augmentTrue): self.img_paths sorted([os.path.join(img_dir, f) for f in os.listdir(img_dir)]) self.mask_paths sorted([os.path.join(mask_dir, f) for f in os.listdir(mask_dir)]) self.patch_size patch_size self.augment augment def __len__(self): return len(self.img_paths) def __getitem__(self, idx): # 读取眼底图和标注统一转成单通道 img cv2.imread(self.img_paths[idx], cv2.IMREAD_GRAYSCALE) mask cv2.imread(self.mask_paths[idx], cv2.IMREAD_GRAYSCALE) # 如果数据集中还有整图随机裁剪成 patch if img.shape[0] self.patch_size: y np.random.randint(0, img.shape[0] - self.patch_size) x np.random.randint(0, img.shape[1] - self.patch_size) img img[y:y self.patch_size, x:x self.patch_size] mask mask[y:y self.patch_size, x:x self.patch_size] # 归一化到 [0, 1]标注按阈值二值化 img img.astype(np.float32) / 255.0 mask (mask 127).astype(np.float32) if self.augment: if np.random.rand() 0.5: img cv2.flip(img, 1) mask cv2.flip(mask, 1) if np.random.rand() 0.5: img cv2.flip(img, 0) mask cv2.flip(mask, 0) # 转成 (C, H, W) 的张量 img torch.from_numpy(img.astype(np.float32)).unsqueeze(0) mask torch.from_numpy(mask.astype(np.float32)).unsqueeze(0) return img, mask逻辑说明Dataset先按文件名排序建立图像和标注的路径列表每次取样本时读图、转灰度、归一化如果是整图就随机裁剪成patch。增强阶段只做了随机水平翻转和垂直翻转这类翻转不改变血管的语义是血管分割里增强的上限附近。参数说明patch_size设128时一张565x584的DRIVE眼底图能裁出约20个训练样本显存占用和细节保留都比较平衡如果降到64可以加大batch size但末梢血管的上下文信息会变少。augment在验证集和测试集必须设为False否则指标每次跑都不一样。注意这里归一化用的是简单除法如果你在3.2里统计了全数据集的均值和标准差把这一行替换成减均值除标准差即可。4. 完整训练循环落一遍损失函数、超参设置与checkpoint的解读数据流跑通之后训练本身的坑集中在两个地方损失函数怎么选以及结果文件里到底存了什么、存得够不够。这一章把训练循环完整落一遍每个参数都给出推荐范围和原因。4.1 损失函数选型BCEWithLogitsLoss、Dice Loss与组合损失眼底血管分割里血管像素通常只占百分之十出头背景占了绝对多数。直接拿普通BCE训练模型很容易陷入「全预测成背景也能拿到90%以上准确率」的假象血管全部漏检。第一个解法是BCEWithLogitsLoss里设置pos_weight按背景和前景比例给血管像素加权。这个方案简单有效但对权重的设置很敏感权重小了漏检权重大了假阳性一堆。第二个解法是Dice Loss它直接优化分割结果和标注的重叠度天然不惧类别不平衡。但Dice Loss在训练初期有梯度不稳定问题当预测概率图接近全0或全1时Dice的梯度接近于0模型学得很慢。实际工程里最稳的是组合损失把BCE和Dice按权重加在一起import torch import torch.nn as nn class BCEDiceLoss(nn.Module): BCE 负责像素级收敛Dice 负责整体重叠度 def __init__(self, weight_bce0.5, weight_dice0.5): super().__init__() self.bce nn.BCEWithLogitsLoss() self.weight_bce weight_bce self.weight_dice weight_dice def forward(self, logits, targets): # logits 是未过 sigmoid 的原始输出 probs torch.sigmoid(logits) # 加 1 是平滑项防止除零 dice 1 - (2 * (probs * targets).sum() 1) / (probs.sum() targets.sum() 1) return self.weight_bce * self.bce(logits, targets) self.weight_dice * dice逻辑说明BCEWithLogitsLoss内部已经包含了sigmoid所以forward里直接用logits算BCE而Dice Loss需要先sigmoid转成概率再计算。两个损失相加后反向传播模型既要逐像素收敛又要保证整体分割区域和标注重叠度高。参数说明weight_bce和weight_dice的默认值0.5/0.5在大多数情况下表现良好。如果你的数据集血管占比更低可以把weight_dice调到0.6到0.7让Dice主导训练反之背景噪声多就把weight_bce调高。我在代码里用了分子分母各加1的平滑项避免某个batch里恰好没有血管像素时报错。4.2 超参设置patch size、batch size、学习率与早停阈值很多人拿到训练代码第一件事就是改epoch数量实际上最值得花时间的是下面这张表里的四个参数。参数推荐范围说明patch size64 ~ 128越小越省显存但会丢失血管上下文batch size8 ~ 32以8G显存为例patch128时batch8起步学习率1e-4 ~ 3e-4Adam优化器超过5e-4容易震荡早停patience15 ~ 20医学分割训练时长波动大给足耐心patch size和batch size是联动的。128x128的patch配合batch size 16显存占用大约在6到8G之间这是大多数单卡能跑通的配置。如果显存不够优先减小patch size而不是batch size因为batch太小会放大BatchNorm的统计噪声导致训练不稳定实在要减batch建议把BatchNorm换成GroupNorm。学习率方面Adam优化器配合1e-4到3e-4是U-Net训练的安全区间。5e-4以上经常会看到loss曲线前期下降很快中期开始抖动最后在某个局部震荡下不去。如果loss已经下降但Dice长期不涨不要反复调学习率先检查是不是预处理或者标签对齐出了问题。早停的patience建议设到15到20轮。眼底血管分割的训练曲线有明显的不稳定期中间可能出现Dice连续好几轮不涨甚至小幅回退但后面又突然涨上去的情况。patience设太小会在接近最优解之前就停掉。4.3 训练主循环与checkpoint解读训练主循环本身不复杂复杂的是怎么把结果文件存得够用。下面是训练和保存checkpoint的核心逻辑def train_model(model, train_loader, val_loader, criterion, optimizer, epochs120, devicecuda): best_dice 0 for epoch in range(epochs): model.train() total_loss 0 for imgs, masks in train_loader: imgs, masks imgs.to(device), masks.to(device) optimizer.zero_grad() logits model(imgs) loss criterion(logits, masks) loss.backward() optimizer.step() total_loss loss.item() model.eval() val_dice compute_dice(model, val_loader, device) # 每轮都存 checkpoint覆盖式保存 best追加式保存最近一轮 torch.save({ epoch: epoch, model_state_dict: model.state_dict(), optimizer_state_dict: optimizer.state_dict(), best_dice: val_dice, loss: total_loss / len(train_loader), }, fcheckpoint_last.pth) if val_dice best_dice: best_dice val_dice torch.save(model.state_dict(), best_model.pth) print(fepoch {epoch}: new best dice {best_dice:.4f})逻辑说明每个epoch先训练再验证验证集上的Dice作为模型优劣的唯一标准。每轮都保存一份checkpoint_last.pth里面带上了optimizer_state_dict和epoch信息这意味着训练中断后能直接恢复继续跑不浪费前面几十个小时。只有验证Dice刷新时才覆盖保存best_model.pth这个文件就是结果文件里最重要的资产。参数说明optimizer_state_dict是后悔药没有它中断恢复时必须重新设置学习率、动量等状态训练行为会跟原计划偏离。best_dice字段记录的是验证集上的最优指标如果你拿到的结果文件里这个值和模型效果对不上大概率是训练和验证的预处理流程不一致导致。拿到训练结果文件后你可能会看到best_model.pth、checkpoint_last.pth和一堆预测图片。这时候第一件事是用下面几行代码检查checkpoint内容是否完整import torch ckpt torch.load(best_model.pth, map_locationcpu) # 如果是完整 checkpoint这里是 dict如果只有 state_dict说明保存时没带附加信息 print(ckpt.keys()) model.load_state_dict(ckpt[model_state_dict]) print(best dice:, ckpt[best_dice])这里有个容易忽略的点结果文件里的预测图最好是保存模型输出的概率图而不是二值图。概率图是浮点数据可以自己换不同阈值重新二值化也可以用来复算AUC指标如果只保存了0和255的PNGAUC就没法复算了。这点我在下一章还会提到它是很多人在评测时翻车的根源。5. 训练与推理中的5个高频翻车点现象、原因、解法Unet使用时最常被忽略的注意事项其实和模型结构没关系反而全在数据流和评测习惯里。下面五条是我在眼底血管分割上反复遇到过的真实问题全部按「现象、原因、解决」的结构来写。5.1 现象验证集Dice不低但预测图全是椒盐噪点训练结束后看验证指标Dice有0.9以上打开预测结果图却发现血管区域里布满了孤立的白点血管边缘也都是毛刺状的噪声。原因是训练时把二值标签当成0到1的浮点数来算损失而预测概率图在血管边缘附近本来就是介于0到1的过渡值阈值0.5附近的像素稍微有一点扰动就会翻转成噪声点。再加上数据增强里的亮度扰动幅度过大模型学会了对亮度敏感而不是对血管结构敏感。解决分两步第一步把亮度扰动幅度调小甚至先关掉增强对比一次第二步在推理后加形态学后处理开运算去除孤立噪点闭运算接回断裂边缘。后处理的具体参数在第6章里给出。5.2 现象训练Loss下降正常血管却比标注细一整圈模型输出的血管整体形态是对的但所有血管都比标注细末梢血管几乎全部消失分割结果看起来像血管的「骨架」而不是血管本身。原因是血管像素占比太低即使加了Dice Loss背景像素仍然主导梯度。模型发现输出细一点的血管能避免在背景区域产生假阳性而细血管末梢丢失对Dice的惩罚远小于背景大片误判带来的损失于是它选择了「保守策略」。解决方法是给Dice Loss加上一个针对末梢血管的权重倾斜或者在预处理阶段做血管增强比如用形态学顶帽变换提升细血管对比度。更直接的做法是降低损失函数里BCE的权重让Dice主导训练我会把weight_bce调到0.3、weight_dice调到0.7。5.3 现象切片把血管切断拼接回大图后血管整体断裂数据集虽然是切片好的但推理时你想在整张眼底图上出结果把patch逐个预测再拼回原图发现血管在切片边界处大量断裂看起来像一张被撕碎的网。原因是切片时没有设置重叠区域。滑窗切patch如果步长等于patch大小相邻patch之间没有任何重叠血管刚好在边界处被切断时模型只能看到半根血管无法判断它是否应该延伸到隔壁patch。解决方法是推理时用重叠滑窗patch大小128步长设64相邻patch有一半重叠区域。拼接时对重叠区域做加权平均中间的像素权重高、边缘权重低这样既能消除接缝伪影又能让血管跨patch保持连续。如果你手里的切片数据集已经固定了滑窗步长建议确认一下是否带重叠没有的话推理环节必须自己补上。5.4 现象固定patch下显存不够调小batch后训练彻底不稳定训练到中途发现显存溢出直接把batch size从16改到4结果loss曲线开始剧烈震荡Dice怎么都上不去。原因是BatchNorm在batch size太小时统计不稳定。BatchNorm在训练时用的是当前batch内样本的均值和方差batch只有4时每批次统计量的波动很大模型参数也跟着来回跳。这不是U-Net本身的问题而是训练配置不匹配。解决办法有三个优先级优先减小patch size然后考虑梯度累积也就是accumulate_gradients4每4个小batch攒够梯度再更新一次效果等价于大batch如果一定要小batch把网络里的BatchNorm2d替换成GroupNorm这样每个样本独立计算归一化统计量不受batch大小影响。这三个方案里梯度累积是最不需要改模型的方案。5.5 现象换了一批测试图指标断崖式下跌训练集和验证集上Dice都在0.9以上换成另一批眼底相机拍摄的图片指标直接掉到0.8以下。原因是不同眼底相机的成像颜色、亮度范围和分辨率不一致而你的预处理参数是在训练集上统计出来的用同一套均值和标准差去归一化这不批新数据分布对不上。另一个常见原因是测试图的尺寸没有先缩放到训练时的patch能整除的尺寸直接resize或padding方式不对导致图像边缘区域特征分布异常。解决方法是保存训练时的预处理参数到配置文件推理时强制加载同一组均值和标准差测试输入尺寸统一用反射padding到patch的整数倍避免边缘信息丢失。更稳妥的做法是在预处理阶段做一次CLAHE增强局部对比度让不同相机拍摄的图像在对比度层面更接近。6. 把结果文件用出价值形态学后处理、指标自检与Unet的改进入口训练跑完、best_model.pth拿到手这只是开始。模型的原始输出是一张概率图直接阈值化往往带着大量孤立点和小块噪声而后处理能让结果文件里的模型在同样权重下获得更高的实际指标。6.1 两步形态学后处理连通域过滤与孔洞填充import cv2 import numpy as np def post_process(prob_map, thr0.5, min_area50): 概率图 - 二值图 - 形态学清理 - 去小连通域 binary (prob_map thr).astype(np.uint8) kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3)) # 开运算先腐蚀后膨胀去掉孤立噪点 cleaned cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel, iterations1) # 闭运算先膨胀后腐蚀接回细血管的断裂处 cleaned cv2.morphologyEx(cleaned, cv2.MORPH_CLOSE, kernel, iterations1) # 过滤面积小于 min_area 的连通域这些通常是假阳性 n, labels, stats, _ cv2.connectedComponentsWithStats(cleaned, connectivity8) for i in range(1, n): if stats[i, cv2.CC_STAT_AREA] min_area: cleaned[labels i] 0 return cleaned逻辑说明开运算去掉预测图里的孤立白点闭运算把因为阈值化而断裂的细血管重新接上最后用连通域分析把面积小于min_area的小块直接删除。这三个操作对血管分割的效果非常明显尤其是假阳性控制。参数说明thr是二值化阈值通常取0.5如果模型输出偏保守可以降到0.4偏激进则升到0.6。min_area设50对DRIVE这类数据基本安全设太大会把末梢血管当噪声删掉设太小则过滤了个寂寞建议在验证集上先扫一遍观察不同阈值对Dice的影响再定。6.2 复算指标不看logger亲手算一次Dice和AUC训练日志里的指标只能说明训练过程正常不能证明结果文件真的能打。我养成的习惯是拿到最优权重之后在测试集上自己复算一遍Dice和AUC。from sklearn.metrics import roc_auc_score def dice_score(pred, gt): pred (pred 0.5).astype(np.float32) gt (gt 0.5).astype(np.float32) return (2 * (pred * gt).sum() 1e-6) / (pred.sum() gt.sum() 1e-6) def compute_auc(prob, gt): # prob 是概率图不能是二值图gt 是标注 return roc_auc_score(gt.ravel(), prob.ravel())逻辑说明Dice要求输入是二值图所以内部做了阈值化AUC要求输入是连续概率所以必须加载你保存的概率图。这两个指标一个衡量区域重叠度一个衡量概率排序质量结合来看才能判断模型是「真的会分割」还是「只是概率排序碰巧对了」。参数说明dice_score里加1e-6是防除零compute_auc要求gt展平后的数据正好等于0和1两种取值如果你的标注是0和255先除以255。如果logger里Dice是0.94复算只有0.89不要急着怀疑代码先对比训练和测试时的预处理是否完全一致包括归一化参数、图像尺寸、增强开关这类不一致是评测结果对不上的最大来源。最后说一句Unet模型改进的事。很多人拿到结果文件后的第一反应是想换更强的网络但实际上在眼底血管分割这类任务上改进跳跃连接比换主干更划算——在跳跃连接的编码器侧加一个简单的attention gate或者给多个解码器层加深监督损失效果通常比直接换大模型明显而且不需要重新切片数据集、重新调数据加载器。我自己跑过几次结论是数据切片、预处理和后处理这些外围环节的收益远大于换网络结构。这也是为什么这套方案值得投入Unet提供了稳定的基线而真正拉开差距的部分恰好是每个人都能自己动手改的部分。希望帮到你。本文还有配套的精品资源点击获取