ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

眼底图像分割实战:Python+ONNX计算杯盘比并可视化的完整方案

眼底图像分割实战:Python+ONNX计算杯盘比并可视化的完整方案 简介这是一套基于Python的眼底图像视杯视盘分割项目源码包面向计算机、人工智能、医学影像等专业的课程设计或毕业设计场景。项目以血管、视盘、视杯的分割与可视化为主线支持计算杯盘比(vCDR)、提取血管分支处特征并可在眼底图像上显示分支点等关键位置、以列表呈现血管特征清单方便医生点击查看对应点特征同时集成早产儿视网膜病变分类、青光眼分级等39类眼科疾病分类模型覆盖眼底图像分析的主要环节。代码经测试运行成功答辩评审平均分达96分包含详细注释、文档说明和截图演示下载后打开说明文档即可快速上手。压缩包共18个文件主要有Python源码、PNG/JPG演示图、2个ONNX推理模型、Markdown文档等整体约9.22MB目录清晰便于按模块查阅。已有348人学习下载适合基础薄弱者参考学习也可在此基础上二次开发拓展至更多眼底图像分析任务。1. 眼底图像分割该下什么资源一个能算杯盘比的 Python 方案做医学图像分割课题的人最怕的不是模型看不明白而是分割完了拿不出一套能写进答辩 PPT 的产出。这套基于 Python 的眼底图像分割项目正好把这条链路补完整了它同时输出血管、视盘、视杯三个分割结果并且在这之上继续计算杯盘比 vCDR、血管分支点这类眼科临床特征再用 QTJS 客户端把特征可视化出来。也就是说它不是一个只停在“出一张 mask”的演示项目而是从图像一路走到了医生可以直接看的临床指标。适合课程设计、毕设初期立项参考也适合想搞懂医学图像分割落地流程的人。项目说明里注明所有脚本提交前都跑通过这类源码包最怕的就是打开跑不起来至少这一点可以先放心。2. 源码结构先拆一遍fias-main 里的模型、数据与文档布局在动手跑之前先把整个包拆开看一眼。很多人拿到压缩包直接找 main.py其实这种多模块项目第一个要读的是两样东西README.md 和 docs 目录。前者告诉你怎么跑后者告诉你能跑到什么程度。fias-main 是整个工程的主目录压缩包里同时带了模型权重、onnx 推理文件、四套公开眼底数据集和一个竞赛说明文件下面分三块来看。2.1 目录先过一遍哪是代码、哪是权重、哪是结果下载解压之后你会看到下面这些目录和文件目录/文件作用使用建议model/模型权重目录训练好的权重文件在这里onnx导出的 ONNX 推理模型没有 GPU 也能用 onnxruntime 跑segmentation/分割相关代码训练和推理脚本所在地fundus/眼底图像样本拿来试跑的原始图results/结果输出目录分割可视化、特征结果会写到这里stare/、drive/、chase/、hrf/四套公开眼底数据集训练和评测用docs/项目文档配合 README 一起看Competition.md竞赛需求说明讲清楚“为什么要做这些功能”QTJS.png客户端界面截图先看长什么样再决定要不要研究前端我第一次打开这类包的习惯是先 README再 Competition最后才碰代码。README 讲运行方式Competition 讲需求来源。这套项目的需求明显来自一个眼底图像竞赛要求的不只是分割还要在分割之后算血管分支特征、杯盘比和疾病分类搞清楚需求再回来读代码很多设计就能对上了。2.2 ONNX 是推理边界没有训练环境也能跑分割包里出现 onnx 文件对运行来说是个好消息。ONNX 是模型推理的通用格式它把 PyTorch 或 TensorFlow 训练出的权重导出成了一套与训练框架解耦的计算图。你在自己电脑上不需要装深度学习框架只要 onnxruntime 就能加载推理。而且 ONNX 模型输入输出形状是固定的拿过来先打印一下接口比瞎猜预处理快得多。import onnxruntime as ort sess ort.InferenceSession(model.onnx) for inp in sess.get_inputs(): print(f输入名: {inp.name}, 形状: {inp.shape}, 类型: {inp.type}) for out in sess.get_outputs(): print(f输出名: {out.name}, 形状: {out.shape}, 类型: {out.type})这段代码的作用是把 onnx 模型的计算图接口亮出来。打印结果直接决定预处理怎么写如果输入形状是 [1, 3, 512, 512]说明模型吃的是 NCHW 的 RGB 三通道图你要把读进来的 BGR 转成 RGB 再变成 channel-first如果输出是 [1, 3, 512, 512]说明输出有三个通道按项目描述对应血管、视盘、视杯。注意有些 onnx 的输出形状是动态的比如 [1, 3, None, None]说明它支持任意尺寸输入但为了分割稳定我一般还是固定到一个尺寸再推理。2.3 四套公开数据集的分工DRIVE、STARE、CHASE 与 HRF项目把四个数据集目录一起放了进来这个配置在医学图像分割里很常见因为它们各有各的用途。DRIVEDigital Retinal Images for Vessel Extraction是血管分割最常用的 benchmark40 张眼底图带手工标注的血管掩膜STARE 是早期经典数据集20 张图除了血管还有病变标注CHASE 是儿童眼底数据集血管标注比较细HRF 是高分辨率眼底图包含健康、糖尿病视网膜病变、青光眼三类跟项目里的青光眼分级需求正好对应。从用途上看DRIVE 和 STARE 适合做血管分割的训练和评测HRF 适合做青光眼相关验证CHASE 则可以用来测模型在不同拍摄条件下的泛化能力。如果打算在源码基础上重新训练这四个目录可以直接切分成训练集和测试集如果只做复现直接拿 onnx 模型跑 fundus 目录里的样本图看输出就足够了。3. 把 ONNX 分割模型跑起来Python 环境、推理脚本与输出解读3.1 环境准备依赖清单与版本坑这个项目的运行环境其实很轻因为推理只依赖 onnxruntime 和图像处理那套库。我一般会在 conda 里单独建一个 Python 3.9 或 3.10 的环境避免跟其他项目打架。依赖也就这几样pip install numpy opencv-python onnxruntime scikit-image pillowonnxruntime 负责加载 onnx 模型opencv-python 负责读写和缩放图像scikit-image 用于骨架化和后续的血管细化pillow 在某些脚本里处理图像。如果后面要跑客户端需要再装 PySide6 或 PyQt但第一步先不用装等分割推理通了再碰界面。版本上要注意 onnxruntime 别一上来就装最新主力版有些早几年导出的模型 opset 版本偏旧新版本不识别会导致加载失败真遇到这种报错把 onnxruntime 降到 1.14 左右再试。3.2 单张眼底图推理从图像到三通道掩膜准备一张眼底图写一个推理函数把读取、resize、通道转换、推理、拿结果这五步走通。import cv2 import numpy as np import onnxruntime as ort def preprocess(img_path, target_size): img cv2.imread(img_path) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img cv2.resize(img, target_size, interpolationcv2.INTER_LINEAR) img img.astype(np.float32) / 255.0 img np.transpose(img, (2, 0, 1)) # HWC - CHW return np.expand_dims(img, axis0).astype(np.float32) sess ort.InferenceSession(model.onnx) input_shape sess.get_inputs()[0].shape target (input_shape[3], input_shape[2]) # 注意 onnx 里是 NCHW x preprocess(fundus/001.png, target) out sess.run(None, {sess.get_inputs()[0].name: x})[0] print(原始输出形状:, out.shape) mask np.squeeze(out, 0) # 去掉 batch 维 mask np.transpose(mask, (1, 2, 0)) # 转成 HWC方便逐通道处理关键点有两个。一是 resize 用 INTER_LINEAR不要用 INTER_NEAREST血管这类细长结构用最近邻插值缩放容易出现锯齿甚至断线。二是 onnx 模型的输入形状是 [N, C, H, W]所以 target_size 要从 shape[2] 和 shape[3] 取不能想当然写 512x512。实际跑的时候如果打印出来有 None说明是动态维度那就自己指定一个模型可接受的尺寸比如 512 或 1024。提示拿到的 onnx 如果是动态形状先把输入固定到一个尺寸再推理。动态输入虽然灵活但推理速度和结果稳定性都不如固定尺寸。3.3 输出通道解读血管、视盘、视杯怎么落到红绿蓝拿到 mask 之后先别急着存图先确认三件事输出是概率图还是 logits是类别索引还是多通道概率。判断方法很简单打印 mask 的值域值在 0 到 1 之间是概率出现负数和大于 1 的正数是 logits只有 {0, 1, 2} 这样的整数是类别索引。# 如果是 logits先过 sigmoid prob 1.0 / (1.0 np.exp(-mask)) # 可视化红绿蓝三通道分别对应血管、视盘、视杯 overlay np.zeros((mask.shape[0], mask.shape[1], 3), dtypenp.uint8) overlay[..., 0] ((prob[..., 0] 0.5) * 255).astype(np.uint8) # 红色血管 overlay[..., 1] ((prob[..., 1] 0.5) * 255).astype(np.uint8) # 绿色视盘 overlay[..., 2] ((prob[..., 2] 0.5) * 255).astype(np.uint8) # 蓝色视杯 cv2.imwrite(results/overlay.png, overlay)关于通道顺序项目说明写得很清楚红是血管、绿是视盘、蓝是视杯可视化时直接按这个顺序拼三通道就行。不少人在这一步翻车是因为拿到一个单通道类别图却按三通道切片结果什么都显示不出来。从 onnx 输出形状就能提前判断输出是 [1, 3, H, W] 就按三通道处理输出是 [1, 1, H, W] 就是单通道类别图需要自己在代码里做颜色映射。sigmoid 这一步也容易漏模型输出如果是 logits不过 sigmoid 直接拿原始值比较阈值血管细线很容易全部丢光。3.4 分类任务的输出设计ROP、青光眼分级与 39 类项目需求里还有疾病分类这一块包括早产儿视网膜病变ROP分类、青光眼分级、39 类眼病分类。理解这部分的关键是搞清楚各自的输出形式。ROP 分类在国际标准里通常按区域、分期、附加病变描述落地到模型上最常见是预测一个严重程度等级青光眼分级跟杯盘比直接挂钩靠 vCDR 阈值或视盘形态特征分级39 类多分类则是标准的 softmax 输出。如果项目在分割 onnx 之外还提供了独立的分类模型推理代码长这样import numpy as np def softmax(x): e np.exp(x - np.max(x)) return e / e.sum() sess_cls ort.InferenceSession(classifier.onnx) cls_out sess_cls.run(None, {input: x})[0] probs softmax(cls_out[0]) top3 np.argsort(probs)[::-1][:3] for idx in top3: print(f类别 {idx}: {probs[idx]:.4f})这段代码假设分类模型是独立的 onnx 文件且输入是图像。如果项目里只提供了一个分割 onnx分类逻辑很可能是在分割结果基础上提取特征再接分类头属于模型内部的分支结构。具体是哪种看 docs 目录里的模型说明最保险。我拿到这类项目的第一反应是先跑通分割再碰分类因为分割是一切特征计算的地基分割不对后面的杯盘比和分支点全部失真。4. 血管特征、杯盘比与疾病分类从掩膜到客户端 JSON 契约分割跑通只是把地基打了这个项目的价值在后面的特征计算和分类。4.1 vCDR 杯盘比竖直直径怎么量才稳杯盘比是青光眼筛查里非常高频的临床指标计算方式是视杯竖直直径除以视盘竖直直径。最省事的做法是拿二值掩膜直接算高度差但这条思路在实际分割结果上经常不稳因为掩膜边缘的噪声点会把高度拉长。更稳的流程是先取最大连通域再对视盘区域约束视杯最后量竖直直径。import cv2 import numpy as np def largest_component(mask): mask (mask 0).astype(np.uint8) n, labels, stats, _ cv2.connectedComponentsWithStats(mask, 8) if n 1: return mask largest 1 np.argmax(stats[1:, cv2.CC_STAT_AREA]) return (labels largest).astype(np.uint8) def vertical_diameter(mask): mask largest_component(mask) rows np.any(mask 0, axis1) ys np.where(rows)[0] if len(ys) 0: return 0 return int(ys[-1] - ys[0] 1) cup (prob[..., 2] 0.5).astype(np.uint8) disc (prob[..., 1] 0.5).astype(np.uint8) cup (cup disc).astype(np.uint8) # 视杯必须约束在视盘内 vcdr vertical_diameter(cup) / (vertical_diameter(disc) 1e-6) print(vCDR:, round(vcdr, 3))这里最值得抄的是一行cup disc。分割模型如果训练得不够充分视杯掩膜经常会在视盘边界外多出一块高亮区域直接量出来的竖直直径会偏大vCDR 甚至可能超过 1。做一次交集运算把视杯约束在视盘范围内数值层面至少不会出现反常识结果。我一般还会在输出前加防御式判断如果 vCDR 大于 1说明掩膜仍有问题回退到只算最大连通域的版本再查原因。4.2 血管分支点检测细化、邻域判定与特征汇总血管分支点是眼底图像里的关键结构医生看一张眼底图会先找血管分叉处有没有异常。算法上的典型路线是先把血管二值掩膜做形态学闭运算补洞再骨架化最后在骨架图上用邻域卷积数分支。from skimage.morphology import skeletonize from scipy.ndimage import convolve def find_branch_points(vessel_mask): v vessel_mask.astype(np.uint8) v cv2.morphologyEx(v, cv2.MORPH_CLOSE, np.ones((3, 3), np.uint8)) sk skeletonize(v) kernel np.ones((3, 3)) kernel[1, 1] 0 neighbor_count convolve(sk.astype(np.uint8), kernel, modeconstant, cval0) branch (sk 0) (neighbor_count 3) return sk, branch sk, branch find_branch_points((prob[..., 0] 0.5).astype(np.uint8)) ys, xs np.where(branch) print(分支点数量:, len(ys))骨架化的原理是把粗血管带细化成单像素宽的中心线细化之后的分叉处在 8 邻域里会同时连接三条以上的血管所以邻域计数大于等于 3 的像素就是分支点。这个阈值的取舍是个经验活取 3 会把交叉点也包含进来取 4 只保留标准的四连通交叉。实际项目里我一般先用 3 扫一遍看分支点密度再决定要不要调调的时候只动这个数字骨架化部分不用改。分支点本身只是一个坐标但客户端要求“点击查看对应点特征”所以后端要把每个点扩成一条记录。常见特征是分支点坐标、血管宽度、分支角度。血管宽度可以在细化前的掩膜上沿法线方向量一段距离分支角度则需要从骨架图上追踪三段分支方向再算夹角。这些细节源码里未必全部实现但作为特征清单的数据结构先按这个思路组织一定不会错。4.3 疾病分类输出与客户端可视化标签、JSON 与点击交互项目里有一个 QTJS.png 截图说明客户端是 Qt 和 JavaScript 配合做出来的。常见搭法是后端 Python 把计算结果整理成 JSON通过 Qt 的 WebEngine 通道交给前端 JS前端负责在 Canvas 上画叠加层。青光眼分级可以直接把 vCDR 映射到等级标签上def grade_glaucoma(vcdr): if vcdr 0.3: return 0 # 正常 elif vcdr 0.5: return 1 # 可疑 else: return 2 # 高风险这个阈值是常见参考值具体取值要以 docs 里的要求为准。ROP 分级思路类似模型输出严重度等级再映射到口袋里能显示的中文标签。39 类疾病分类则是模型 softmax 输出后取置信度最高的几个类别作为辅助信息一并传到客户端。后端最终要组织的数据结构大概是这样的feature_list [] for xi, yi in zip(xs.tolist()[:200], ys.tolist()[:200]): feature_list.append({ x: int(xi), y: int(yi), branch_count: 3, avg_width_px: 4.2, angle_deg: 73.5 }) payload { vcdr: round(vcdr, 3), glaucoma_grade: grade_glaucoma(vcdr), branch_points: feature_list, image_path: fundus/001.png, class_top3: [{label: 类别A, prob: 0.93}] } import json print(json.dumps(payload, ensure_asciiFalse, indent2))这份 JSON 是前后端之间的契约。前端拿到之后先在眼底图上画红绿蓝三通道叠加层再把 branch_points 里的坐标画成可点击的圆点点击后弹窗显示该点的血管宽度和角度。后端在传分支点时我习惯做抽样300 个点全画出去视觉上会糊成一片取前 200 个或者按坐标均匀采样都行。整理好这份 JSON客户端的交互逻辑就完成了一大半因为你要展示的数据全部在这一个结构里。最后谈一下疾病分类的整体组织。ROP、青光眼分级和 39 类可以共用同一个分割特征底座先提取血管、视盘、视杯的区域特征再灌进分类头。竞赛类项目常见的做法是做成多任务头分割和分类共享编码器这时候就只需要一个 onnx 就能同时出分割、杯盘比和分类结果。但多任务头对训练数据要求高如果数据集不够均衡分类准确率会拖后腿。跑这套源码的时候如果你的需求只是做课程设计演示分类阈值怎么设定的说清楚就行不用追求 SOTA。5. 避坑指南跑眼底分割与分类最常踩的 5 个坑这里集中写我在复现类似项目时踩过的坑每一条都按现象、原因、解决三步说清楚。先说两个环境层的翻车现场。5.1 环境与推理层onnx 加载失败和尺寸错位坑 1onnxruntime 加载模型时报 opset 不支持。现象ort.InferenceSession(model.onnx)这行直接抛错提示某个算子不识别或者版本太旧。这种错在早几年的训练代码导出的模型上非常常见。原因模型导出的 opset 版本和当前 onnxruntime 支持范围不匹配一般是训练框架版本偏旧导出的算子是旧格式新版 runtime 反而把它标记成了不识别。解决先把 onnxruntime 升级到最新版试一次不行就降级到 1.14 左右这个版本对早中期导出的模型兼容性最好。再不行用 onnxsim 对模型做简化把冗余节点清掉通常能解决算子兼容问题。命令是pip install onnxsim onnxsim model.onnx model_sim.onnx跑完后加载 model_sim.onnx 试试。坑 2预处理尺寸写死导致 shape mismatch。现象输入模型时报错说期望 [1, 3, 1024, 1024]实际喂进去的是 [1, 3, 512, 512]模型直接拒收。原因没有打印模型输入形状凭感觉写了统一 resize 尺寸。很多眼底图原图分辨率在 2048 以上模型训练时可能裁剪成 1024也可能缩到 512不打印根本猜不准。解决第一件事永远是用 2.2 节的脚本打印 input.shape一切预处理以打印结果为准。模型期望 1024 就 resize 到 1024输出掩膜再 resize 回原图尺寸做后续计算。这个 resize 回原图也容易漏掩膜缩过一次后直接拿去和原图叠加位置全对不上。5.2 分割质量层血管断裂和视杯外溢坑 3血管分割结果断成虚线分支点数量骤减。现象overlay 图里的血管主干是断的分支点只有零星几个跟原图血管形态完全对不上。原因图像被直接压成正方形后血管宽度只剩 1 到 2 像素再用固定 0.5 阈值一卡细血管全部丢光。眼底图本身就不是正方形强行 resize 等于把血管压变形。解决推理前用保持纵横比的 resize 加 padding别直接压成正方形二值化用 Otsu 相对阈值而不是固定 0.5后处理加一个 3x3 闭运算把邻近的断点连上。做完这三步血管连续性通常会有肉眼可见的提升。坑 4视杯跑到视盘外杯盘比算出来超过 1。现象vCDR 输出 1.2明显违反临床常识。这种反常识数值拿去答辩老师一眼就能看出来后处理有问题。原因分割掩膜在视盘边缘多出一块视杯噪声区域竖直直径被拉长。视杯本身是视盘内部的凹陷区域它的掩膜理论上不应该超出视盘边界但模型输出的边缘经常带毛刺。解决计算前对视杯掩膜做cup disc交集约束再取最大连通域。这两步做完大多数异常值都能消掉最后再加一道if vcdr 1: vcdr 1的防御判断。注意防御判断只能兜底真正修好还是要靠前面的约束。5.3 客户端与验证层界面空白和特征列表不刷新坑 5QTJS 界面能打开但图上一个标记都没有。现象界面加载了Canvas 上没红绿蓝叠加层分支点列表也是空的整块区域跟白屏一样。原因后端特征计算抛了异常或者 QWebChannel 通道没注册成功数据根本没推到前端。前端代码在等数据后端数据没来页面就永远停留在初始化状态。解决先在纯命令行环境跑后端脚本确认 JSON 能完整打印再检查 Qt 侧的 channel 注册对象名和前端 JS 里调用名是否一致。客户端的问题九成出在后端数据没就绪而不是前端绘制代码有 bug。所以排查顺序一定是先跑后端脚本看输出再开客户端不要一上来就翻前端代码。扩展一个经验如果界面能出叠加层但分支点列表为空优先怀疑分支点检测那步的邻域阈值设太高了把 3 改成 2 或者把骨架化的输入先做一次闭运算通常能找回一大半点。6. 进阶验证拿你自己的眼底图检验分割与指标6.1 批量验证脚本Dice 与分支点数量双指标只有定性看 overlay 是不够的验证分割有没有修好最直接的办法是跟金标准算 Dice。DRIVE 数据集自带血管标注刚好能用来做这个验证。def dice(a, b): a (a 0.5).astype(np.uint8) b (b 0.5).astype(np.uint8) inter np.sum(a b) return 2.0 * inter / (np.sum(a) np.sum(b) 1e-6) # 对 DRIVE 测试集逐张推理算血管 Dice results {} for idx in range(1, 21): img fdrive/test/{idx:02d}_test.tif gt fdrive/test/{idx:02d}_manual1.gif prob run_inference(img) results[idx] dice(prob[..., 0], load_gt(gt)) print(平均血管 Dice:, np.mean(list(results.values())))没有金标准的图也有一个粗检办法统计分支点数量。同一模型同一类眼底图分支点数量应该在相近的量级如果某张图的分支点数量比其他图少一个数量级基本可以断定预处理或者拍摄条件出了问题。这个指标比肉眼看得准。6.2 换数据源前必过的四件套检查换任何一批新的眼底图之前我强制自己先过四件套检查图像尺寸和模型输入是否匹配、通道顺序是 RGB 还是 BGR、归一化方式是否和训练时一致、标签顺序是否按血管/视盘/视杯排列。这四件事任何一件不对输出都会像个黑匣子一样看起来是 mask实际位置和原图对不上。分割管线稳定之后再做指标验证不然所有数字都是假的。我自己就吃过一次亏拿一张手机翻拍的眼底截图直接喂进模型输出血管全断视盘区域多了一整片高亮噪声。那次之后我给自己立了规矩换数据源必须先过四件套再谈调参和指标。这套项目本身已经把 ONNX 权重和四套公开数据集都备齐了如果你正在做医学图像分割方向的课程设计直接拿它当底子跑通再换自己的数据比自己从零搭环境省事得多。希望这个检查习惯能帮你也少走几步弯路。本文还有配套的精品资源点击获取
返回列表