简介:面向医学图像处理与深度学习初学者的UNet视网膜血管分割完整项目,基于PyTorch框架实现,选用DRIVE公开数据集完成模型训练与测试。项目聚焦眼底图像中血管结构的自动提取,适用于疾病早期筛查及相关科研教学场景。压缩包共包含34个文件,以7个Python源码文件为核心,覆盖数据预处理、模型构建、损失函数、训练与测试等完整流程;20张PNG图片为分割结果可视化展示,便于直观对比效果;另含README说明、附赠文档及数据集压缩包等辅助材料,整体大小36.81MB。资源已有133人学习下载。通过该项目,读者可系统掌握UNet在医学图像分割中的落地方法,获得一套可在本地复现的工程结构,包括数据增强与标准化脚本、训练评估工具链,以及带标注的DRIVE数据集,为开展血管分割实验或论文复现提供了直接可用的参考实现。
1. 视网膜血管分割项目:为什么UNet + PyTorch + DRIVE能成为医学图像分割的入门标配
想跑一个UNet网络做医学图像分割,最经典的起点就是视网膜血管分割。这个项目用PyTorch框架,在DRIVE公开数据集上完成从数据预处理、模型训练到测试评估的完整深度学习流程,还带上了预处理脚本和可视化工具,几乎把医学图像分割的每个环节都覆盖了一遍。很多人觉得UNet难上手,其实难点不在模型本身,而在数据怎么处理、训练怎么调参、结果怎么验证。这篇文章就按我实际跑通这个项目的顺序,把每一步拆开讲清楚,包括参数设置和踩过的坑,让新手能照着复现,让熟手能快速定位问题。
2. 先从数据下手:DRIVE数据集结构与预处理脚本的四个关键步骤
2.1 DRIVE数据集文件构成与标注格式解析
DRIVE(Digital Retinal Images for Vessel Extraction)是视网膜血管分割最常用的公开基准数据集,40张眼底彩照,分成20张训练、20张测试,每张都是565×584像素的RGB图像。标注是手工逐像素画的血管掩码,另外还有一个FOV掩码文件,标明眼底图像的有效区域,测试集还额外提供了第二组人工标注,用来算分割的一致性和AUC。
第一次拿到这份数据时,很多人会直接拿原始图像去喂模型,结果训练半天指标上不去。原因很简单:眼底图像本身存在光照不均、对比度低、血管与背景灰度接近的问题,不做预处理,UNet即使能收敛,分割出的血管也会断断续续。所以项目里配套的数据预处理脚本是关键,不是可有可无的辅助工具。
训练集的文件结构一般是这样的:images目录放原始视网膜图,manual目录放手工标注的血管掩码,mask目录放FOV掩码。测试集多一个manual2目录,用于评估时计算分割一致性和平均精度(AP)。如果拿到手的文件是.zip解压出来的,先确认目录结构匹配,再写脚本读取。
2.2 用Python脚本完成CLAHE增强与归一化
预处理的核心是CLAHE(Contrast Limited Adaptive Histogram Equalization),即限制对比度的自适应直方图均衡化。普通直方图均衡化对整张图做全局拉伸,眼底图像光照不均时容易过曝或欠曝;CLAHE把图像切成小块(默认8×8网格),在每个小块内做均衡化,再限制对比度幅度,避免噪声被过度放大。对血管分割来说,CLAHE能让细小的毛细血管从背景里浮现出来,这是整个预处理里性价比最高的一步。
一个常见的预处理脚本如下:
import cv2 import numpy as np from PIL import Image def preprocess_image(img_path, clip_limit=2.0, tile_grid_size=(8, 8)): # 读取图像并转为灰度 img = cv2.imread(str(img_path)) img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 创建CLAHE对象并应用 clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size) enhanced = clahe.apply(img) # 归一化到[0,1],保持浮点精度 normalized = enhanced.astype(np.float32) / 255.0 return normalized这段代码的逻辑分三步:先转灰度,因为血管分割本质上是单通道任务,彩色信息对UNet的帮助有限反而增加计算量;然后用CLAHE增强对比度,clipLimit控制对比度限制阈值,取2.0是比较稳的起点,调大到4.0会更强地拉伸对比度但噪声也会更明显;最后除以255归一化到[0,1]区间,避免模型输入数值范围过大导致梯度不稳定。注意tileGridSize取(8,8)是OpenCV默认值,对565×584这种小图足够细致,不需要额外调大。
很多人在这一步翻车是因为归一化时机错了。比如先在CLAHE之前做了归一化,再调CLAHE,那clipLimit的语义就变了,数值范围都不同,增强效果完全不对。正确顺序一定是:灰度化 → CLAHE增强 → 归一化。
2.3 标签处理与数据增强:翻转、旋转、裁剪的参数怎么定
标签和图像必须做完全一致的处理。血管掩码是二值的(0和255),模型输出是概率图,我们用BCEWithLogitsLoss时,标签要转成0和1的浮点型。如果标签还是0和255,损失函数算出的值会异常偏大,模型怎么训练都收敛不了。合理的处理方式是:
def load_label(mask_path): mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE) mask = (mask > 127).astype(np.float32) # 二值化并转为0/1 return mask这一步的关键是把255的像素值压成1.0。阈值取127是因为血管标注是纯黑白的,只要大于127就认为是前景。拿到FOV掩码后,也要做同样的二值化,后续计算Dice时可以只统计FOV内部的像素,避免把黑色背景区域计入。
数据增强方面,医学图像样本量少,训练集只有20张,必须用增强来扩充。常见参数组合是:随机水平翻转(概率0.5)、随机垂直翻转(概率0.5)、随机旋转(范围[-10°, 10°])、随机裁剪(输入尺寸256×256)。旋转角度不要太大,视网膜图像里血管的走向有解剖学意义,旋转超过45度会产生大量不真实样本,模型学到的是奇怪的血管形态。裁剪尺寸选256×256是UNet比较常见的输入尺寸,显存占用适中(batch size取8时大约占用6GB左右),而且能保留足够的上下文信息。
这里有个隐含坑:对标签做旋转或翻转时,插值方法要用INTER_NEAREST(最近邻插值),不能用INTER_LINEAR(双线性插值)。因为线性插值会在血管边缘产生中间灰度值(比如0.5),本来二值的标签被污染成灰度图,训练时模型会被这些中间值搞糊涂。图像用线性插值没问题,标签必须用最近邻。
3. 搭建UNet网络:PyTorch实现编码器-解码器与跳跃连接
3.1 UNet架构拆解:编码器、瓶颈、解码器各自承担什么
UNet之所以叫U型网络,是因为它由左边一条收缩路径(编码器)和右边一条扩张路径(解码器)组成,中间通过跳跃连接把同尺度的特征拼接起来。编码器由多个卷积块和下采样组成,每下采样一次,特征图分辨率减半、通道数翻倍,提取的是从细到粗的语义特征;解码器逐步上采样,把低分辨率的语义特征恢复到原始分辨率,同时通过跳跃连接把编码器各层的细节特征拼回来,弥补下采样丢失的边界信息。
这个设计对血管分割特别友好:血管是细长结构,下采样太深会丢掉细血管的轮廓,跳跃连接让解码器能直接访问编码器各层的细节特征,血管边缘就不再是模糊的一团。这也是UNet比普通FCN在医学图像分割上效果好的核心原因——不是模型更深,而是信息传递路径更完整。
PyTorch实现UNet时,一般把编码器和解码器的重复模块抽象成DoubleConv和Down、Up几个类,代码清晰也方便改通道数。一个完整的UNet定义结构如下,不依赖第三方库,只用PyTorch基础模块:
import torch import torch.nn as nn import torch.nn.functional as F class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True), nn.Conv2d(out_ch, out_ch, kernel_size=3, padding=1), nn.BatchNorm2d(out_ch), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x) class Down(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.mpconv = nn.Sequential( nn.MaxPool2d(2), DoubleConv(in_ch, out_ch) ) def forward(self, x): return self.mpconv(x) class Up(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() # 转置卷积上采样,通道减半 self.up = nn.ConvTranspose2d(in_ch, in_ch // 2, kernel_size=2, stride=2) self.conv = DoubleConv(in_ch, out_ch) def forward(self, x1, x2): x1 = self.up(x1) # 跳跃连接:拼接同尺寸的编码器特征 diffY = x2.size()[2] - x1.size()[2] diffX = x2.size()[3] - x1.size()[3] x1 = F.pad(x1, [diffX // 2, diffX - diffX // 2, diffY // 2, diffY - diffY // 2]) x = torch.cat([x2, x1], dim=1) return self.conv(x)重点说明三点。第一,DoubleConv里每个卷积后面跟了BatchNorm2d,这个对血管分割很关键,因为输入图像经过CLAHE后虽然对比度改善了,但不同批次图像的灰度分布还是有差异,BatchNorm能稳定训练。去掉BatchNorm的话,同样学习率下训练波动会明显变大。第二,Down先用MaxPool2d下采样再接DoubleConv,这是UNet原论文的结构,池化能保留平移不变性。第三,Up里用转置卷积做上采样,kernel_size=2, stride=2,输出尺寸刚好翻倍。转置卷积比双线性插值多一组可学习参数,能更精细地恢复血管细节,代价是参数量略增、训练时更容易出现过拟合,所以数据增强在这种结构下更必要。
拼接前有个细节:如果输入尺寸不是2的整数次幂,下采样后x1和x2的尺寸可能不一致,需要用F.pad做对称填充。代码里的diffY和diffX就是干这个的。实际跑DRIVE时,输入图统一裁剪成256×256,不会触发这个分支,但改成384×384或其他不规整尺寸时,这个逻辑能自动处理,省事很多。
完整的UNet主体则按原论文的通道数设定:第一层64通道,之后每次下采样翻倍,直到512:
class UNet(nn.Module): def __init__(self, n_channels=1, n_classes=1): super().__init__() self.inc = DoubleConv(n_channels, 64) self.down1 = Down(64, 128) self.down2 = Down(128, 256) self.down3 = Down(256, 512) self.down4 = Down(512, 512) self.up1 = Up(1024, 256) self.up2 = Up(512, 128) self.up3 = Up(256, 64) self.up4 = Up(128, 64) self.outc = nn.Conv2d(64, n_classes, kernel_size=1) def forward(self, x): x1 = self.inc(x) x2 = self.down1(x1) x3 = self.down2(x2) x4 = self.down3(x3) x5 = self.down4(x4) x = self.up1(x5, x4) x = self.up2(x, x3) x = self.up3(x, x2) x = self.up4(x, x1) return self.outc(x)注意up1的输入通道是1024,因为x5是512通道,拼接了x4的512通道,拼接后是1024。通道数写错是最容易出的编译错误,PyTorch在torch.cat时会直接报维度不匹配,看到“Given groups=1, weight of size…”这类错误时,先检查拼接处的通道数。n_classes=1是因为我们只分割血管这一类前景,输出单通道的概率图,用sigmoid做二分类。如果要扩展成动脉/静脉多分类,把n_classes改成对应数量即可,但训练数据标注也需要对应。
3.2 UNet训练自己的数据集时,输入尺寸和通道数怎么改最稳
换到自己数据集时,很多人有两个直觉错误:一是觉得输入尺寸越大越好,直接把整张原始图丢进去;二是觉得UNet是固定结构,通道数不能改。真实情况是,DRIVE原始图565×584,直接放进GPU训练,batch size只能设为1,UNet在深层特征图上的感受野覆盖不到全局血管分布,效果反而差。我一般做法是统一裁剪到256×256,训练时随机裁剪,测试时中心裁剪,这样batch size能稳定在8-16之间,训练速度和效果都比较均衡。
通道数方面,如果显存不够(比如只有6GB),可以统一把64/128/256/512/512改成32/64/128/256/256,模型参数量会降到原来的1/4左右。代价是特征表达变弱,血管细分支的还原度会差一些。如果显存充裕(12GB以上),可以试着把首层提到96或128,分割精度有小幅提升,但训练时间会增加。项目里默认用64起步,是兼顾速度和精度的折中值,数据量不大的情况下,加大通道数带来的提升很有限,反而容易过拟合。
4. 训练与测试:完整深度学习流程的八个必调参数
4.1 DataLoader配置与训练循环细节
预处理完成、模型定义好后,接下来是把数据送进训练流程。DRIVE训练集只有20张图,按256×256随机裁剪做增强后,理论上每张图可以裁剪出大量patch,所以不用像分类任务那样做多轮epoch才能收敛。项目里常见的配置是batch_size=8、epochs=60左右,配合Adam优化器、学习率1e-3到1e-4的衰减策略。
DataLoader的写法有讲究,num_workers、pin_memory这些参数直接影响训练速度。一个典型的训练脚本片段如下:
from torch.utils.data import Dataset, DataLoader from torchvision import transforms class DRIVEDataset(Dataset): def __init__(self, image_dir, mask_dir, crop_size=256, train=True): self.image_paths = sorted(list(image_dir.glob('*.tif'))) self.mask_paths = sorted(list(mask_dir.glob('*.gif'))) self.crop_size = crop_size self.train = train def __len__(self): return len(self.image_paths) def __getitem__(self, idx): img = preprocess_image(self.image_paths[idx]) # 读图+CLAHE+归一化 mask = load_label(self.mask_paths[idx]) # 读标签+二值化 if self.train: # 随机裁剪到crop_size x = np.random.randint(0, img.shape[1] - self.crop_size) y = np.random.randint(0, img.shape[0] - self.crop_size) img = img[y:y+self.crop_size, x:x+self.crop_size] mask = mask[y:y+self.crop_size, x:x+self.crop_size] # 随机翻转和旋转(代码略,注意标签用最近邻插值) return (torch.from_numpy(img).unsqueeze(0).float(), torch.from_numpy(mask).unsqueeze(0).float()) train_loader = DataLoader( DRIVEDataset(train_img_dir, train_mask_dir, train=True), batch_size=8, shuffle=True, num_workers=4, pin_memory=True )num_workers决定了用几个子进程加载数据,Windows上设0或2比较安全,Linux上可以设4-8。设太高时如果机器CPU核数不够,反而会因为进程切换开销拖慢训练。pin_memory=True把数据放到锁页内存,GPU显存拷贝快一个档次,但会占用更多的物理内存,16GB内存以下不建议开。
训练循环里三个容易忽略的细节:一是每个epoch开始前调用model.train(),验证前调用model.eval(),否则BatchNorm和Dropout的行为在训练和推理时不一致,验证指标会虚高;二是optimizer.zero_grad()一定要在loss.backward()之前做,忘了清梯度的话梯度会累加,loss曲线会莫名其妙震荡;三是学习率调整,项目里常用ReduceLROnPlateau,当验证集Dice连续5个epoch不涨时把学习率降到原来的1/10。
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3) scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode='max', factor=0.5, patience=5 ) criterion = nn.BCEWithLogitsLoss() for epoch in range(60): model.train() for img, mask in train_loader: img, mask = img.to(device), mask.to(device) optimizer.zero_grad() logits = model(img) loss = criterion(logits, mask) loss.backward() optimizer.step() # 验证后调用scheduler.step(val_dice)实现动态学习率mode='max'表示监控指标是越大越好(这里是Dice系数),factor=0.5表示学习率减半,patience=5表示容忍5个epoch不提升才衰减。这套配置对DRIVE这种小数据集非常稳定,一般前10个epoch就会看到Dice从0.3左右爬到0.7以上,30个epoch后趋于平稳。
4.2 损失函数选型:BCEWithLogitsLoss和Dice Loss怎么组合
血管分割是典型的类别不平衡问题,血管像素只占整个图像约10%-12%,背景占了近90%。如果直接用普通的BCELoss,模型会倾向把所有像素预测为背景,因为这样就能拿到约90%的准确率,但血管全没分割出来。BCEWithLogitsLoss自带sigmoid和数值稳定的对数计算,比手写F.sigmoid + F.binary_cross_entropy更安全,这是首选基础损失。
但BCE在极度不平衡时仍然会偏向多数类,所以项目里通常会把它和Dice Loss组合使用。Dice Loss直接优化Dice系数本身,对前景和背景的权重天然相等。组合方式常见有两种:加权求和loss = bce_loss + dice_loss,或者线性组合loss = 0.5 * bce_loss + 0.5 * dice_loss。实践中第一种更直接,不需要调权重,因为量纲上BCE在0.1-1之间,Dice Loss在0-1之间,直接相加即可。以下是Dice Loss的实现:
def dice_loss(logits, targets, smooth=1e-6): probs = torch.sigmoid(logits) # 将拉平成向量,逐像素计算交集 probs = probs.reshape(probs.size(0), -1) targets = targets.reshape(targets.size(0), -1) intersection = (probs * targets).sum(dim=1) total = probs.sum(dim=1) + targets.sum(dim=1) dice = (2.0 * intersection + smooth) / (total + smooth) return 1.0 - dice.mean()smooth加在分子分母上是为了防止除零(当预测和标签都是全零时),取1e-6即可。当模型预测全为背景且标签也是全背景时,dice算出来是1,loss是0,这是合理的。但要注意,如果整批样本里包含了大量纯背景区域(裁剪到图像边缘时可能出现),Dice Loss在训练初期会不稳定,因为纯背景样本的损失天然偏低,模型会更偏向保守预测。这时候把数据增强里的随机裁剪改成裁剪时保证至少20%区域在FOV内,或者过滤掉血管像素占比低于5%的patch,能显著改善训练的稳定性。
4.3 测试流程:加载最佳模型、计算Dice和AUROC
训练完后,测试不是简单跑一遍测试集就完事。DRIVE的测试集有专门的评估协议:用mask目录下的FOV掩码限定评估区域,只统计FOV内部的像素。原因很直接,眼底图像的黑色边框区域既不属于视网膜也不属于血管,如果把这些像素算进去,背景占比更高,Dice系数看起来虚高,但实际分割质量没有提升。
测试脚本的关键部分如下:
def evaluate(model, dataloader, device): model.eval() dices, aucs = [], [] with torch.no_grad(): for img, mask, fov in dataloader: img, mask, fov = img.to(device), mask.to(device), fov.to(device) logits = model(img) probs = torch.sigmoid(logits) # 只保留FOV内的预测 pred = (probs > 0.5).float() * fov mask = mask * fov # 计算Dice inter = (pred * mask).sum() dice = (2 * inter) / (pred.sum() + mask.sum() + 1e-6) dices.append(dice.item()) return np.mean(dices)阈值0.5是默认选择,但对血管分割来说,调低阈值(比如0.4)可以召回更多细血管,调高阈值(0.6)则更精确但会漏掉末端毛细血管。具体阈值取决于用途:如果是辅助医生做形态学测量,精度优先,阈值取高;如果是筛查血管异常,召回率优先,阈值取低。项目里如果提供了prob_maps保存功能,建议把预测概率图存成.npy,后续算阈值曲线、ROC都用得上,不用重新跑一遍推理。
5. 可视化工具与避坑指南:从损失曲线到分割结果
5.1 用Matplotlib和OpenCV完成损失曲线与预测结果可视化
项目里的可视化工具不是附加功能,而是定位训练问题的关键手段。最核心的两个可视化:一是训练过程中的损失曲线和Dice曲线,二是模型在测试集上的预测结果叠加图。损失曲线能告诉你模型是否收敛、是否过拟合;预测结果图能直观看出哪些血管被漏掉了、哪些区域噪声重。
损失曲线的画法很简单,用Matplotlib记录每个epoch的train loss和val loss即可。我一般会记录loss和val_dice两个量,训练完一次性画出来:
import matplotlib.pyplot as plt def plot_curves(history): epochs = range(1, len(history['train_loss']) + 1) fig, ax1 = plt.subplots(figsize=(10, 4)) ax1.plot(epochs, history['train_loss'], label='Train Loss', color='tab:blue') ax1.set_xlabel('Epoch'); ax1.set_ylabel('Loss') ax2 = ax1.twinx() ax2.plot(epochs, history['val_dice'], label='Val Dice', color='tab:orange') ax2.set_ylabel('Dice'); fig.tight_layout() plt.savefig('training_history.png', dpi=150)查看曲线时有一个很实用的判断标准:train loss持续下降但val dice不涨甚至下降,说明过拟合了,需要加强数据增强、增加weight decay,或把学习率调低;train loss和val dice都不动(比如val dice卡在0.6以下),说明模型欠拟合,优先检查学习率是不是太低、BatchNorm是否写进了模型而不是光写了卷积。
预测结果可视化用于定性观察。用OpenCV时注意最终输出合成分割图,血管部分要叠加在原始图上,背景透明,这样能一眼看到漏检区域:
def visualize_prediction(image, prob_map, save_path, threshold=0.5): image = (image.numpy() * 255).astype(np.uint8) mask = (prob_map > threshold).astype(np.uint8) * 255 # 血管掩码转为绿色叠加在灰度图上 overlay = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) overlay[mask > 0] = (0, 255, 0) cv2.imwrite(str(save_path), overlay)prob_map是sigmoid输出的概率图,threshold=0.5是默认分割阈值。如果看到大量细小的绿色散点孤立地分布在背景区,说明模型把噪声当成了血管,通常可以从三个方向找原因:训练标签是否太粗糙、输入图像的CLAHE强度是否过高(噪声被放大)、模型是否在训练集小血管上过拟合了。相反,如果看到连续血管有很多断口,说明模型对细血管的召回不够,考虑调低阈值、增加数据增强中旋转的角度范围,或增大输入分辨率。
5.2 踩坑记录:显存不足、路径含中文、FOV掩码未对齐等五个常见问题
坑一:训练时报“CUDA out of memory”,但代码看起来没毛病。
现象是训练到第一个epoch中途直接抛OOM,试过把batch size从8降到4也没用。原因通常是PyTorch在训练循环里累积了计算图,比如在循环体里不小心把loss.item()写成loss追加到列表,或者把pred和mask都放进了dice的计算,但没有用with torch.no_grad()包住验证部分,导致梯度图始终存在于内存里。解决方法是:把每一轮loss.item()存列表,验证部分用torch.no_grad()包起来,同时用torch.cuda.empty_cache()在epoch之间清理缓存。另外,输入图片如果是整张565×584直接进模型,显存占用比256×256的patch高好几倍,优先裁剪输入。
坑二:模型训练完,验证Dice很高,但测试集效果差很多。
现象是val dice到了0.8,测试环境下dice只有0.4。最常见的原因是验证集和训练集来自同一批patch(或同一张图的不同区域),数据分布高度重叠;另一个隐藏原因是验证时用的预处理与训练不一致,比如训练时做了CLAHE但验证时忘了做,或者mean/std归一化的参数用的是ImageNet的默认值而不是数据集自算的。解决方法是:把测试集的预处理写成独立的函数,和训练流程共用同一份代码,避免手写两遍导致参数不一致;验证时严格从训练集之外取样。
坑三:Windows下数据路径包含中文,报错找不到文件。
现象是Path.glob匹配不到任何文件,或者读取图像时imread返回None。原因很简单,OpenCV的imread不支持中文路径,这是老问题了。解决方法是:项目目录和数据集路径全部用英文,.zip解压时别解压到“桌面/我的文档”这类带中文的路径下。如果非要用中文路径,先os.chdir切换工作目录再处理,或者改用img = Image.open(path)的方式,PIL对中文路径兼容性比OpenCV好。
坑四:FOV掩码和图像尺寸不匹配。
现象是运行时torch.cat报维度错误,或者叠加可视化时图像和掩码对不齐。原因通常是mask目录下的文件尺寸和images目录下的图像尺寸不一致,尤其是从某些渠道下载的数据集被重新压缩过。解决方法是:在预处理脚本里加一行校验,assert img.shape == mask.shape == fov.shape,不满足就直接报错并打印三者的shape。宁可多跑一次校验,也不要在训练到一半时才发现数据对不上。
坑五:标签里血管太细,loss降不下去,Dice上不去。
现象是训练10个epoch后,Dice卡在0.5左右不动,预测结果全是粗大的血管块,细血管完全没了。原因是血管标注是逐像素的手工标注,细血管占的比例极小,BCE和Dice的组合损失在面对细结构时偏向粗血管的召回。解决方法是:把数据增强里加入弹性变形(elastic deformation),让细血管有更多样化的形态;或者对损失函数做修正,给细血管像素更高的权重,比如用Focal Loss替代普通BCE,让模型更关注难分的像素。另一个有效做法是修改损失的权重:loss = 0.3 * bce + 0.7 * dice,让Dice项主导训练方向。
6. 验证模型有没有真的学会分割血管:ROC曲线与三个进阶检查
训练结束后,不能只看Dice系数就收工。Dice对血管粗细分布敏感,可能出现中等程度上可以接受的Dice值,但细血管分割质量很差的情况。项目里要提供最终验证能力,一定要计算ROC曲线和AUC,同时看预测概率图在不同阈值下的表现。
ROC曲线的横轴是假阳性率(FPR),纵轴是真阳性率(TPR),曲线下的面积AUC衡量模型对前景和背景的区分能力。DRIVE测试集因为提供了第二组人工标注(manual2),可以同时给出与第一组标注的AUC——这叫“人类观察者一致性”,用来作为性能上限参考。计算AUC可以用sklearn.metrics.roc_auc_score,但要注意输入是预测概率而不是二值化后的mask,并且需要把FOV外的区域当作忽略项,只统计FOV内的像素:
from sklearn.metrics import roc_auc_score # 把所有测试图像的像素收集起来,仅保留FOV=1的位置 y_true_all, y_prob_all = [], [] for img, mask, fov in test_loader: with torch.no_grad(): prob = torch.sigmoid(model(img)).cpu().numpy() y_true_all.append(mask[fov > 0].numpy()) y_prob_all.append(prob[fov > 0].reshape(-1)) y_true = np.concatenate(y_true_all) y_prob = np.concatenate(y_prob_all) auc = roc_auc_score(y_true, y_prob)AUC大于0.95算合格,DRIVE上UNet的正常水平在0.96-0.98之间。如果AUC低于0.92,问题大概率出在训练阶段而非测试代码,回头检查预处理和损失函数。
进阶检查里,我一般还会做两个额外验证。第一是血管骨骼化(skeletonization),用skimage.morphology.skeletonize对预测的血管掩码做细化,统计骨骼断点数量——断点越多说明分割出的血管连续性越差,Dice可能看起来还行但实际无法用于下游的血管形态分析。第二是分叉点计数,视网膜血管的分叉特征对糖尿病视网膜病变诊断有参考价值,统计预测结果和真值的分叉点数量是否在同一量级。
这是我做这类医学图像分割项目时反复使用的收尾检查流程,每次跑完都会做这三件事:看单张预测叠加图、算AUC、做骨架连续性分析。这么做下来,模型能不能用、能用在哪,心里就有数了。跑UNet和PyTorch组合的视网膜血管分割项目,很多时候不是模型不够好,而是数据预处理和验证细节决定了上限。如果这套流程能帮你少走点弯路,那就最好不过了,希望帮到你。
本文还有配套的精品资源,点击获取