简介:一套面向医学图像分割学习与实战的DRIVE眼底血管分割项目,整合了UNet、UNet++、UNet3+三种主流网络,支持自由切换;模型已训练完成,采用余弦退火学习率与AdamW优化器,验证集Dice约0.8。项目附带基于QT的推理界面,上传血管图像即可直接查看分割结果,适合入门至进阶的医学影像开发者。资源共137个文件,涵盖60张jpg与48张png图像(训练集/验证集可视化与训练曲线)、7个Python脚本(网络定义、训练与推理)、4个pth权重文件以及json/txt配置与评估日志,压缩包整体约356MB。已有255人浏览学习。通过该项目可快速掌握眼底血管分割的数据组织、网络搭建、指标评估与可视化部署全流程,并可在此基础上进行算法改进与功能扩展。
1. 从“跑一个UNet”到能交付的眼底血管分割:这个项目到底做了什么
同事把一个训练好的眼底血管分割项目丢给我时说:“跑一个UNet网络而已,简单。”真拿到手才发现,模型权重确实在,但离“能用”还差一个QT推理界面,还差一套能让自己和别人都信服的预测流程。这个基于UNet、UNet++、UNet3+实现的DRIVE眼底血管分割项目,正好把这条链补齐了:训练完成只是起点,交付时还得有可视化界面、可靠推理、部署环境。它适合两类人:一类是刚把手搓UNet换成三模型横向对比的学生,另一类是需要在本地演示或实验室读片场景里把结果给医生、老师看的开发者。标题里的那一串名字不是堆参数,而是告诉你这里能切换三个模型,不是只有一个网络。
2. DRIVE眼底血管数据与三个分割模型的选型逻辑
2.1 DRIVE数据集:这种“跑一个UNet”的第一步是读对图
眼底血管分割最常用的公开数据集就是DRIVE,全称Digital Retinal Images for Vessel Extraction。整个数据集一共40张眼底彩照,训练集20张、测试集20张,图像分辨率是565x584。血管标注由两位专家先后完成,通常把第一位专家的标注当作gold standard,第二位专家的标注留作计算观察者间差异。这个数据量放到今天的深度学习里算很小的,所以“训练完成”不等于“泛化完成”,选哪个模型、怎么防过拟合,直接决定QT推理界面里点开图片后看到的是血管还是噪点。
DRIVE原本没有独立的验证集,标准做法是在20张训练图里再切出2到3张做验证。这个点很多人上手时忽略:直接把20张训练到底,最后测试集指标要么虚高,要么细血管漏检。UNet类模型本来就是数据饥饿型,DRIVE这么小的语料,数据增强、K-Fold或者用预训练encoder都是必须考虑的。我踩过的坑是:DRIVE图像的绿色通道对比度最好,通常做法是先把RGB转成灰度,或者直接把绿色通道提出来再归一化。上来就送三通道,训练会慢,分割结果也更容易把背景纹理当成血管。
有一个容易被忽略的细节:DRIVE原图自带一圈黑色背景,训练时直接resize到512x512,黑边会被压进图像里。如果训练时没做中心裁剪、也没做ROI掩膜,推理阶段就必须用同样的resize方式,否则模型看到的内容和训练时不一致。我一般会在项目里固定一个配置文件:
dataset: name: DRIVE image_size: [512, 512] use_channel: green # green / gray / rgb normalize: [0.5, 0.5] # mean, std,按训练时的值写死 validate_split: 0.1 # 从训练集切10%做验证这个配置文件的作用不是给人看的,是给后面QT推理界面做预处理用的。很多项目只保存网络权重,不保存这些参数,过三个月再打开,自己都忘了当时是用单通道还是三通道训练。
2.2 UNet、UNet++、UNet3+:结构差异和各自适合的血管场景
UNet是最经典的编码-解码对称结构,核心是跳跃连接把下采样丢掉的细节接回来。对DRIVE这种细血管分割,UNet的问题是:连续下采样四到五次,很多小血管的边缘信息在深层语义和高分辨率浅层之间存在语义gap,跳跃连接只做简单concat,尺度没对齐就融合不干净。常见表现是粗血管割得不错,末梢毛细血管出现断裂或模糊。
UNet++(Nested UNet)针对这个gap,在跳跃连接中间加入密集嵌套卷积块,等于在每一层之间做了多个中间监督。它确实能多召回一些细血管,但参数多、训练慢。在DRIVE这种只有20张训练集的小样本任务上,如果只跑几十个epoch,很容易把背景纹理一起学进来,分割图的噪点会明显增多。用UNet++时我一般会在每个epoch结束后单独算一次Dice并保存分数最高的权重,而不是保存最后一个epoch。
UNet3+把跨尺度连接铺到encoder和decoder的每一层,并引入深度监督。它的优势是同时保留浅层位置信息和深层语义信息,对小目标、边界模糊的血管更稳。代价是显存消耗比前两者都大,如果机器只有4G显存,加载UNet3+推理时经常会OOM,更别说在QT界面里同时加载三个模型。
三者在DRIVE上的差距不会是天翻地覆。多数已发表工作的Dice在0.81到0.84这个量级,谁的预处理做得更细致,往往比换模型影响更大。项目里把三个模型都集成进来,核心价值不是刷分,而是让同一个输入图像能在同一个界面上对比输出,方便快速定位问题。我做对比实验时,会固定相同的loss、相同输入尺寸、相同数据增强方式,再切换网络结构。否则你分不清提升来自模型本身还是来自多改了半个预处理函数。
| 模型 | 核心改进 | 细血管偏好 | 显存占用 | 适合场景 |
|---|---|---|---|---|
| UNet | 编码-解码 + 跳跃连接 | 中等 | 低 | 快速验证、小显存部署 |
| UNet++ | 跳跃连接加入密集卷积 | 高 | 中 | 追求细血管召回 |
| UNet3+ | 跨尺度全连接 + 深度监督 | 高且稳定 | 高 | 精度优先、离线或服务端推理 |
2.3 三模型同项目:对比实验的接口设计
为了让QT推理界面里能“一键换模型”,常见做法是统一输入输出接口。我会把三个模型都包装成同一个类:forward(x)返回logits,model_type存字符串。不要各自写一套预处理、一套输出解码,那样界面里就是接不完的if-else,改一个阈值要翻三个文件。
落地时的目录结构可以参考这个:
weights/ unet_drive.pth unetpp_drive.pth unet3plus_drive.pth configs/ train_config.yaml inference/ model_wrapper.py preprocess.py postprocess.py ui/ main_window.py worker.py这个结构不复杂,但很关键。训练完成的pth文件只是网络状态,要在QT里复用,必须把“图像怎么预处理、掩膜怎么后处理、输入尺寸多少”一起打包。很多人只拷一个权重文件,换台电脑就翻车,就是因为这些附属参数没跟上。以PyTorch为例,统一包装逻辑大概是这样:
class Segmentor: def __init__(self, model_type: str, weight_path: str, device="cuda"): self.model_type = model_type if model_type == "unet": self.net = UNet(in_channels=1, out_channels=1) elif model_type == "unetpp": self.net = UNetPP(in_channels=1, out_channels=1) elif model_type == "unet3plus": self.net = UNet3Plus(in_channels=1, out_channels=1) self.net.load_state_dict(torch.load(weight_path, map_location=device)) self.net.to(device).eval()这里我统一用了单通道输入,也就是预处理时已经把眼底图转成灰度。三个网络的前向输出都是一个batch的logits,维度是(1, 1, H, W),后面接相同的sigmoid和阈值后处理。这样界面端只需要关心model_type字符串,不需要关心网络内部是嵌套UNet还是跨尺度UNet。参数说明:device推荐固定为"cuda:0"或"cpu",不要在构造函数里自动判断,自动判断有时会把QT所在机器上的非GPU设备误选。
3. 推理流程:把训练好的权重变成可靠的分割结果
3.1 保持一致:预处理参数和训练时不能有半点偏差
推理界面里最容易出问题的不是网络结构,而是预处理和训练时不“对齐”。常见情况是训练时用绿色通道、归一化到[0,1],推理脚本却用三通道、归一化到[-1,1],分割结果立刻变成一团噪声。解决思路很简单:把训练阶段记录的预处理参数写到一个配置里,推理时读同一份配置。
DRIVE原图是565x584,训练时一般resize到512x512。如果训练用了随机裁剪,推理阶段就不要裁剪,直接resize。如果训练用了水平翻转做数据增强,推理阶段可以用镜像TTA(test-time augmentation)把预测结果求平均,但不做也不影响基本使用。真正要命的是在QT里读取图像后用的插值方式变了:训练时用cv2.INTER_AREA缩小,推理时却用了默认的INTER_LINEAR。
我一般会单独写一个预处理函数,和训练端共用:
def preprocess_drive(image_bgr, cfg): if cfg["use_channel"] == "green": gray = image_bgr[:, :, 1] # OpenCV BGR通道,绿色是第二通道 elif cfg["use_channel"] == "gray": gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY) else: gray = image_bgr resized = cv2.resize(gray, tuple(cfg["image_size"]), interpolation=cv2.INTER_AREA) tensor = torch.from_numpy(resized.astype(np.float32) / 255.0) tensor = (tensor - cfg["normalize"][0]) / cfg["normalize"][1] return tensor.unsqueeze(0).unsqueeze(0)注意,这里normalize是我训练时统计出来的均值或自定义均值,不是随便用ImageNet的。很多眼底血管模型会直接除以255而不是做Z-Score归一化,两种写法都行,但必须和训练时保持一致。把cfg字段设计成字典后,QT界面里读取一张新图,只需要从同一个配置里load一次,不会出现位置参数错乱。
3.2 最小推理脚本:加载权重、前向推断、后处理
不管QT界面怎么写,核心推理脚本必须独立可运行。我先会在命令行验证一遍,再接界面。以下是可跑通的最小推理代码:
import cv2 import numpy as np import torch from model_wrapper import Segmentor def load_image(path, input_size=512): img = cv2.imread(path, cv2.IMREAD_COLOR) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray = cv2.resize(gray, (input_size, input_size), interpolation=cv2.INTER_AREA) tensor = torch.from_numpy(gray.astype(np.float32) / 255.0) return tensor.unsqueeze(0).unsqueeze(0) # (1, 1, 512, 512) def predict(seg, path): x = load_image(path) with torch.no_grad(): logit = seg.net(x) prob = torch.sigmoid(logit) mask = (prob.squeeze().cpu().numpy() > 0.5).astype(np.uint8) * 255 return cv2.resize(mask, (565, 584), interpolation=cv2.INTER_NEAREST) seg = Segmentor("unet", "weights/unet_drive.pth", device="cuda") mask = predict(seg, "test_images/01_test.tif") cv2.imwrite("output_mask.png", mask)这段代码的关键点有三个。第一,网络一定要切成eval模式,在Segmentor构造函数里已经执行了self.net.eval(),如果漏掉,BatchNorm在推理时会继续用batch统计量,结果会出现网格状伪影。第二,阈值0.5是默认值,实际可以按验证集调,DRIVE上很多模型的最佳阈值在0.45到0.6之间。第三,mask还原到原图565x584时要用最近邻插值,不能用线性插值,否则二值边界会被插成灰色过渡带。
3.3 分割结果怎么看:Dice、ACC、AUC?
QT界面上展示的血管图和ground truth不能直接用肉眼看就拍板,还是需要量化。常用指标是Dice系数、Accuracy和AUC。Dice适合评估目标区域的像素重叠,Accuracy容易被背景主导,因为眼底图像大部分是背景,即使血管全错,Accuracy也可能高达95%。AUC则更关注排序能力,阈值移动时曲线变化。
计算Dice和AUC可以直接用一段小脚本:
from sklearn.metrics import roc_auc_score def dice_coef(pred_mask, true_mask): pred_flat = pred_mask.flatten().astype(bool) true_flat = true_mask.flatten().astype(bool) inter = np.logical_and(pred_flat, true_flat).sum() return 2 * inter / (pred_flat.sum() + true_flat.sum() + 1e-6) auc = roc_auc_score(true_mask.flatten() > 0, prob.flatten())注意true_mask是flow,因为测试标注里用255表示血管、0表示背景,读取后要先除以255或比较大于0;推理输出的prob是sigmoid后的概率,不要直接和255的掩膜比较。这里的1e-6只是防止除零,不影响数值。如果Dice低于0.75,我的经验是先怀疑预处理、标注对齐或阈值,而不是立刻改网络结构。
4. QT推理界面:从脚本到可视化工具的集成
4.1 界面工作流:选图、推理、展示、保存
QT推理界面的价值,是把上面那段命令行脚本变成任何会用电脑的人都能操作的工具。一个完整的工作流是:打开图片 → 在下拉框选择模型 → 点击推理 → 左侧显示原图,右侧显示分割掩膜 → 点击保存按钮把结果写回磁盘。流程看起来简单,但有个隐藏问题:深度学习推理不能直接放在QT按钮的槽函数里。
为什么?因为UNet、UNet++、UNet3+的前向推断在CPU上可能需要几秒,在GPU上也要几百毫秒。如果推理逻辑直接写在on_click_infer()里,整个界面会冻结,按钮按下去后窗口无法拖动、无法关闭,客户会直接认为是程序崩溃。解决办法是把推理放到QThread里。界面设计上,我一般会先用Qt Designer画好主界面,然后转换成py文件,再在生成的代码里接业务逻辑。纯代码写界面也行,但QT Designer改布局更直观。
4.2 用PyQt5实现一个最小可视化推理界面
以下是一个最小可复现的例子,使用PyQt5,主界面包含一个图片显示区、一个按钮、一个下拉框:
from PyQt5.QtWidgets import ( QMainWindow, QLabel, QPushButton, QFileDialog, QComboBox, QWidget, QVBoxLayout ) from PyQt5.QtGui import QPixmap, QImage from PyQt5.QtCore import Qt import cv2 import numpy as np class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("DRIVE眼底血管分割") self.image_label = QLabel("请选择图片") self.image_label.setFixedSize(360, 360) self.model_box = QComboBox() self.model_box.addItems(["unet", "unetpp", "unet3plus"]) self.infer_btn = QPushButton("开始推理") self.save_btn = QPushButton("保存掩膜") self.infer_btn.clicked.connect(self.on_infer) layout = QVBoxLayout() layout.addWidget(self.image_label) layout.addWidget(self.model_box) layout.addWidget(self.infer_btn) layout.addWidget(self.save_btn) container = QWidget() container.setLayout(layout) self.setCentralWidget(container) def on_infer(self): path, _ = QFileDialog.getOpenFileName(self, "选择眼底图") if not path: return # 这里先占位,后面在QThread中替换 pixmap = QPixmap(path) self.image_label.setPixmap(pixmap.scaled( self.image_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation))这个界面不包含真实推理,只是先验证文件选择、图片显示和按钮事件能跑通。再把预处理和后处理填充进来时,要特别注意QPixmap与OpenCV的通道顺序转换。OpenCV读到的是BGR,QPixmap需要RGB,先cv2.cvtColor转一下再显示,否则血管颜色和医生看到的不一致。显示掩膜时建议把二值mask映射成伪彩图,例如血管标记为红色,黑背景保留原图灰度,这样在界面上不会和原图混淆。
4.3 用QThread隔离深度学习推理,避免界面卡死
把推理放到QThread是QT集成深度学习模型的正常操作。我用一个Worker对象承载Segmentor,推理完成后通过signal把结果传回主线程。这样模型权重、网络运行都在子线程里,主界面可以正常响应鼠标点击和关闭事件。
from PyQt5.QtCore import QThread, pyqtSignal class InferWorker(QThread): finished = pyqtSignal(np.ndarray) # 传回mask error = pyqtSignal(str) def __init__(self, segmentor, image_path, cfg): super().__init__() self.segmentor = segmentor self.image_path = image_path self.cfg = cfg def run(self): try: mask = run_inference(self.segmentor, self.image_path, self.cfg) self.finished.emit(mask) except Exception as exc: self.error.emit(str(exc))这里需要解释一个常见误区:有人把Segmentor放在主线程创建,然后让QThread去调用,这在PyQt里通常会报“wrapped C/C++ object has been deleted”或者运行不稳定。正确做法是在QThread的run方法内部创建模型对象,或者把模型移动到该线程。我的习惯是每次切换模型时重新创建Segmentor,这样三个模型不会被同时载入显存。界面响应速度可以这样感知:点击推理后按钮文字变成“推理中”,线程结束后再恢复成“开始推理”。
5. 避坑:眼底分割和QT推理的五个血泪经验
5.1 训练好的模型换台电脑效果变差:先查预处理,别先骂模型
现象:同一个训练好的pth权重文件,在自己的电脑上推理效果正常,换到另一台机器后同一张眼底图分割结果变成全黑或者噪声。 原因:最常见的不是模型坏了,而是OpenCV、PIL或Python环境之间传递图像时通道顺序变了。比如一个人用cv2.imread读图默认BGR,另一个人用PIL.Image.open读图是RGB,转numpy后没调通道,直接送进网络,分布瞬间不一样。另一个常见原因是缩放插值不同。cv2.resize默认INTER_LINEAR,如果有人训练时用INTER_AREA,推理时用了默认插值,细血管位置会产生半像素偏移。 解决:把预处理封装成固定函数,并在函数里强制写清通道顺序、插值方式、dtype和归一化除数。不要相信任何“默认”。
5.2 QT版本混乱:cannot mix incompatible Qt library
现象:双击编译好的程序,启动时直接弹窗fatal: cannot mix incompatible Qt library (version ex50601) with this library,程序根本不进入主窗口。 原因:机器上装了多个Qt版本,比如系统里有Qt 5.12,项目里用PyQt5 5.15,程序运行时把两个库混杂加载了。最常见的是PATH环境变量同时指向多个Qt目录,或者Qt Creator自带Qt版本又与项目QTDIR冲突。 解决:先卸载或清理无关Qt安装,只保留一个Qt 5.15.2。在启动脚本里固定LD_LIBRARY_PATH指向唯一库目录。如果是PyQt5开发,不要手动把C:\Qt\bin塞进PATH,Qt运行库由PyQt5的包管理即可。这样报错会少很多。
5.3 Qt平台插件找不到:qpa plugin could not find linuxfb
现象:在Linux板卡或没有桌面显示服务的环境运行QT程序,报错qt.qpa.plugin: could not find the Qt platform plugin "linuxfb"。 原因:Qt程序启动时需要平台插件,比如xcb、wayland、linuxfb。程序发布包没有把Qt/platforms目录打包进去,或者Qt安装时未包含linuxfb插件。尤其在ARM板卡、树莓派交叉编译的场景中常见。 解决:如果是x86 Linux桌面,应设置export QT_QPA_PLATFORM=xcb;如果是嵌入式板卡,确认安装包带linuxfb插件。打包时复制Qt安装目录下的plugins/platforms到exe同级目录:
cp -r /path/to/Qt/plugins/platforms ./release/ export QT_QPA_PLATFORM=linuxfb ./DriveSeg也可以修改代码,在main.py开头用QApplication.addLibraryPath指定插件绝对路径。优先保证开发机上能正常显示,再去做嵌入式发布。
5.4 二值掩膜显示成全黑或全白:显示映射不对
现象:网络输出的mask在终端里打印是0和1,但保存成图片全黑;或者用QPixmap显示时整片白色,完全看不到血管结构。 原因:掩膜是0和1的numpy数组,直接imwrite会把1当作像素值1,保存出来的图几乎是黑的。如果通过QImage显示,灰度图默认会按0-255映射,1被显示成黑色,255被显示成白色,而mask里可能没有0和255以外的值。 解决:显示和保存前先统一转换为0-255的uint8:
if mask.max() <= 1.0: mask = (mask * 255).astype(np.uint8)如果是概率图而不是二值图,就先把范围映射到0-255再做显示。这一步放在postprocess函数末尾,无论界面还是离线脚本都用同一个转换逻辑。
5.5 小显存电脑“显存不足”:全尺寸batch推理翻车
现象:命令行脚本单张推理正常,但QT界面推理时偶尔报torch.cuda.OutOfMemoryError。 原因:界面程序里可能创建了多个模型实例。比如下拉框切换一次就new一个Segmentor,旧对象没有被释放,显存里堆了多份网络权重。尤其是UNet3+,显存需求大,多两个实例立刻爆显存。 解决:管理模型实例生命周期,只保留当前选中模型,切换时del旧模型并torch.cuda.empty_cache()。如果显存只有4G,尽量用CPU推理或半精度推理。半精度写法是:
with torch.no_grad(): logit = self.net(x.half())注意这需要模型权重也转换成half,否则会报dtype不匹配。还可以配上滑窗预测,把大图切块再拼回来,但DRIVE原图只有512x512,一般不需要,除非你要处理更高分辨率的眼底相机原图。
6. 把三个模型做成可切换按钮:一次加载、随时换权重
最后给你一个实用技巧:在QT推理界面里用懒加载管理三个模型。不要启动时把所有权重加载进显存,而是第一次选中某个模型时才加载,切换后再清理。这样既保留了“三模型对比”的卖点,又不会让低配电脑一启动就卡死。
模型管理器可以做成这样:
class ModelManager: def __init__(self): self._models = {} def get(self, name): if name not in self._models: self._models[name] = Segmentor(name, f"weights/{name}_drive.pth") return self._models[name] def clear(self, keep=None): for name in list(self._models.keys()): if name != keep: del self._models[name] torch.cuda.empty_cache()逻辑说明:切换模型按钮时,先调用clear(keep=new_name),保留当前需要的模型,删掉其他,释放显存。这样不会出现“界面比脚本还吃内存”的尴尬。验证这个界面是否靠谱,我有一个习惯:拿训练集之外的一张DRIVE测试图,先跑一遍离线脚本保存A结果,再跑QT界面保存B结果,然后对比两个mask文件的MD5。只要两张mask像素差别大于0.1%,就说明界面里的预处理和离线脚本没有对齐,我会直接修预处理而不是去调模型。
现在我还会在权重目录里放一个train_config.yaml,记录训练时使用的输入尺寸、通道、阈值和后处理方式。不要嫌麻烦,三个月后重新打开这个项目时,你会感谢当时的自己。希望帮到你。
本文还有配套的精品资源,点击获取