ARTICLE DETAIL

资讯详情

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

Python眼底图像视杯视盘分割实战:从源码复现到CDR计算

Python眼底图像视杯视盘分割实战:从源码复现到CDR计算 简介这是一份面向计算机视觉与医学图像处理方向的Python项目源码聚焦眼底图像中视杯与视盘的自动分割任务适合作为课程设计、期末大作业或入门医学图像分割的实战参考。压缩包共198个文件以156个py源码文件为核心辅以39个pyc编译缓存与3个md说明文档整体约451KB体量轻便便于快速部署与二次开发。项目已通过导师指导并获得97分评价代码结构完整、可直接运行无需额外修改。内容涵盖DeepLab系列分割网络、注意力模块组装、数据增强与变换等关键环节能帮助读者理解视盘视杯分割的完整流程与模型搭建思路。目前已有418人学习关注适合需要一份可运行、有评分背书的医学图像分割项目作为参考的读者。1. 眼底图像视杯视盘分割从一份 Python 源码能跑出什么眼底彩照里那两个同心圆——视盘和视杯——是青光眼筛查绕不开的结构指标。杯盘比CDR每变化 0.1对应的临床风险分层就可能跳一档所以自动分割视杯视盘这件事在眼科 AI 里属于地基级任务。这份基于 Python 的眼底图像视杯视盘分割项目源码解决的就是从一张原始眼底图到视杯、视盘二值掩膜的完整链路读图、预处理、模型推理、后处理、指标计算。它适合三类人正在做医学图像分割课程设计的学生、想快速搭一个眼底分割 baseline 的算法工程师、以及需要把 CDR 计算自动化落地的眼科方向开发者。下面我按自己复现这类项目的顺序把环境、数据、模型、训练、排错、进阶一条条拆开讲能直接抄的部分我都给命令和参数。2. 环境搭建与数据准备让源码先跑起来2.1 Python 环境与依赖安装的稳妥路径这类项目最常见的翻车点不是模型而是环境。源码里通常依赖 OpenCV、NumPy、PyTorch 或 TensorFlow、scikit-image、Pillow 这几件套版本错一个就可能报undefined symbol或者cv2导入失败。我的习惯是先建独立虚拟环境再按先框架后工具的顺序装。# 建虚拟环境Python 3.8~3.10 对医学图像库兼容性最好 python -m venv venv # Windows 激活 venv\Scripts\activate # Linux / macOS 激活 source venv/bin/activate # 先装深度学习框架以 PyTorch 为例按自己 CUDA 版本去官网选命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cu118 # 再装图像与科学计算依赖 pip install opencv-python numpy scikit-image pillow matplotlib pandas tqdm逻辑说明先装框架是因为 torch 会锁定一部分底层依赖版本后装 OpenCV 等库时 pip 会自动做兼容取舍反过来先装 OpenCV 再装 torch容易出现 NumPy 版本被降级导致np.float之类旧 API 报错。参数上--index-url指向的 CUDA 版本必须和本机驱动匹配用nvidia-smi看右上角 CUDA Version驱动版本低于框架要求就改用 CPU 版先跑通流程。如果你用 conda把pip install换成conda install也行但医学图像库在 conda 里更新慢我一般混用。提示装完先跑一句python -c import cv2, torch; print(cv2.__version__, torch.__version__)两个版本都能打印出来再往下走能省掉后面一半的玄学报错。2.2 眼底数据集的结构与读取方式视杯视盘分割常用的公开数据是 REFUGE、Drishti-GS、RIM-ONE 这几套源码一般会约定一个目录结构。不管它默认读哪里你都要先确认三件事图像是 RGB 还是灰度、掩膜是单通道还是三通道、视杯和视盘是分开两个文件夹还是用像素值 1/2 区分。这三点决定了后面 dataloader 怎么写。import os import cv2 import numpy as np def load_sample(img_path, mask_path): # 眼底图统一读成 RGB避免灰度图被误当三通道 img cv2.imread(img_path, cv2.IMREAD_COLOR) img cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 掩膜读灰度保留原始标签值 mask cv2.imread(mask_path, cv2.IMREAD_GRAYSCALE) # 归一化到 [0,1]float32 是分割任务的标准输入 img img.astype(np.float32) / 255.0 return img, mask # 打印一张样本的尺寸和标签分布确认数据没读错 img, mask load_sample(data/images/001.png, data/masks/001.png) print(img shape:, img.shape, mask unique:, np.unique(mask))逻辑说明IMREAD_COLOR强制三通道是因为很多眼底图存的是伪彩色或带黑边的 RGB直接按灰度读会丢信息掩膜用IMREAD_GRAYSCALE是为了拿到原始标签值如果标签里视盘是 1、视杯是 2灰度读法能原样保留用彩色读法反而会被插值污染。参数上/255.0是常规归一化如果你的模型用了 ImageNet 预训练权重要改成按均值和标准差标准化。np.unique(mask)打印出来的值域是判断标签编码方式最直接的办法看到[0 1 2]就说明是三类编码看到[0 255]就得先做阈值二值化。2.3 数据增强与尺寸统一眼底图分辨率差异很大直接缩放到固定尺寸会让小视杯变形。常见做法是先做感兴趣区域裁剪把黑边去掉再统一 resize 到 512×512 或 256×256。增强方面水平翻转、随机旋转、亮度对比度扰动是安全牌垂直翻转要慎用因为视盘在解剖上有固定的鼻侧/颞侧方位翻过头会让模型学到错误的空间先验。import albumentations as A train_transform A.Compose([ A.HorizontalFlip(p0.5), A.RandomRotate90(p0.5), A.RandomBrightnessContrast(p0.3), A.Resize(512, 512), ]) # 注意图像用双线性插值掩膜必须用最近邻否则标签会被插出中间值 aug train_transform(imageimg, maskmask)逻辑说明Resize放在增强链末尾保证所有几何变换在原始尺度上做完再统一尺寸。掩膜插值方式是这里最容易踩的坑用默认的双线性会让 0/1 标签之间出现 0.5 这种脏值训练时 loss 直接发散。参数上p是触发概率医学图像数据量小增强概率可以给高一点但旋转角度别超过 180 度否则视盘方位先验就废了。3. 视杯视盘分割模型选型、结构与训练参数3.1 U-Net 系结构为什么是这类项目的默认解视杯视盘分割本质是密集预测目标区域边界模糊、面积占比小U-Net 的编码器-解码器加跳跃连接结构天然适合编码器抓语义解码器恢复分辨率跳跃连接把浅层边缘信息直接送到深层正好补上视杯边界这种细节。源码里如果用的是 U-Net 或它的变体Attention U-Net、U-Net、ResU-Net都是这个思路。选型理由很实在数据量通常只有几百张Transformer 类大模型容易过拟合U-Net 参数量可控、训练稳定、显存友好是性价比最高的 baseline。import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_ch, out_ch): super().__init__() self.block 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.block(x) class UNet(nn.Module): def __init__(self, in_ch3, out_ch3): super().__init__() # 编码器逐级下采样通道翻倍 self.enc1 DoubleConv(in_ch, 64) self.enc2 DoubleConv(64, 128) self.pool nn.MaxPool2d(2) # 解码器上采样后与跳跃连接拼接 self.up1 nn.ConvTranspose2d(128, 64, 2, stride2) self.dec1 DoubleConv(128, 64) self.out nn.Conv2d(64, out_ch, 1) def forward(self, x): e1 self.enc1(x) e2 self.enc2(self.pool(e1)) d1 self.up1(e2) d1 torch.cat([d1, e1], dim1) # 跳跃连接 return self.out(self.dec1(d1))逻辑说明out_ch3对应背景、视盘、视杯三类如果你只做视盘二分类就改成 1 并配 sigmoid。ConvTranspose2d做上采样比直接插值多一组可学习参数边界恢复更准但容易产生棋盘伪影介意的话换成nn.Upsample加普通卷积。参数上padding1保证卷积后尺寸不变stride2的下采样和上采样必须成对否则torch.cat时尺寸对不上会直接报错。3.2 损失函数与类别不平衡的处理视杯在整张图里可能只占百分之几的像素纯交叉熵会让模型倾向于全预测背景。常见组合是 Dice Loss 加交叉熵Dice 直接优化重叠度对前景少的情况更友好。class DiceCELoss(nn.Module): def __init__(self, weight_ce0.5): super().__init__() self.weight_ce weight_ce self.ce nn.CrossEntropyLoss() def forward(self, pred, target): # pred: [B, C, H, W], target: [B, H, W] ce_loss self.ce(pred, target) # 对每个类别算 Dice 再平均 probs torch.softmax(pred, dim1) target_onehot torch.nn.functional.one_hot(target, pred.shape[1]) target_onehot target_onehot.permute(0, 3, 1, 2).float() dims (0, 2, 3) inter (probs * target_onehot).sum(dims) union probs.sum(dims) target_onehot.sum(dims) dice_loss 1 - (2 * inter 1e-6) / (union 1e-6) return self.weight_ce * ce_loss (1 - self.weight_ce) * dice_loss.mean()逻辑说明one_hot把标签转成和预测同形状才能逐类算 Dice。1e-6是平滑项防止某类完全没出现时除零。参数上weight_ce控制两项权重前景极小时可以调到 0.3让 Dice 主导如果训练初期 loss 震荡厉害先把权重调回 0.5 稳定一下再动。3.3 训练循环与关键超参训练部分源码一般会给一个 train.py你要盯的是学习率、batch size、优化器和早停策略。医学图像数据小batch size 给 4 到 8 就够学习率 1e-4 起步配 Adam配合余弦退火。from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR model UNet(in_ch3, out_ch3).cuda() optimizer Adam(model.parameters(), lr1e-4, weight_decay1e-5) scheduler CosineAnnealingLR(optimizer, T_max50) criterion DiceCELoss(weight_ce0.5).cuda() for epoch in range(50): model.train() for img, mask in train_loader: img, mask img.cuda(), mask.cuda().long() optimizer.zero_grad() pred model(img) loss criterion(pred, mask) loss.backward() optimizer.step() scheduler.step() print(fepoch {epoch}, lr {scheduler.get_last_lr()[0]:.6f})逻辑说明mask.long()是交叉熵的硬性要求标签必须是整型。weight_decay1e-5是轻量正则数据少时能压一点过拟合。参数上T_max设成总 epoch 数余弦退火会在这个周期内把学习率降到接近 0如果你发现验证集 loss 早早回升把T_max调小或者加早停别硬跑满。4. 推理、后处理与指标计算把掩膜变成 CDR4.1 推理脚本与掩膜输出训练完要能对单张图出结果推理脚本的核心是加载权重、前向、取 argmax、还原尺寸。import torch import cv2 import numpy as np def predict(model, img_path, ckpt_path, size512): model.load_state_dict(torch.load(ckpt_path, map_locationcuda)) model.eval() img cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB) h, w img.shape[:2] inp cv2.resize(img, (size, size)).astype(np.float32) / 255.0 inp torch.from_numpy(inp).permute(2, 0, 1).unsqueeze(0).cuda() with torch.no_grad(): pred model(inp).argmax(dim1).squeeze().cpu().numpy() # 还原到原图尺寸最近邻避免标签被插值 pred cv2.resize(pred.astype(np.uint8), (w, h), interpolationcv2.INTER_NEAREST) return pred逻辑说明argmax(dim1)把每个像素归到概率最大的类得到 0/1/2 的标签图。还原尺寸必须用INTER_NEAREST理由和增强时一样标签不能插值。参数上size要和训练时一致训练用 512 推理用 256 会让边界明显变糊。4.2 后处理连通域与形态学清理模型输出常有零星误检用连通域保留最大区域、再用形态学开闭运算平滑边界是标准操作。def postprocess(pred, cup_label2, disc_label1): result np.zeros_like(pred) for label in [disc_label, cup_label]: binary (pred label).astype(np.uint8) # 只保留最大连通域去掉孤立噪点 num, labels cv2.connectedComponents(binary) if num 1: sizes [(labels i).sum() for i in range(1, num)] binary (labels (np.argmax(sizes) 1)).astype(np.uint8) # 闭运算填小孔开运算去毛刺 kernel cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7)) binary cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) binary cv2.morphologyEx(binary, cv2.MORPH_OPEN, kernel) result[binary 1] label return result逻辑说明视杯一定在视盘内部后处理时可以加一条约束——把视盘区域外的视杯像素清掉能修掉不少越界误检。参数上(7,7)的椭圆核适合 512 尺度图更小就缩到(3,3)核太大会把细窄的视杯边缘吃掉。4.3 CDR 计算与分割指标杯盘比是垂直杯盘比vCDR用视杯和视盘的垂直直径之比。分割质量本身用 Dice 和 IoU 衡量。指标计算方式参考阈值Dice2×交集/(预测真值)视盘 0.90视杯 0.80IoU交集/并集视盘 0.85视杯 0.70vCDR视杯垂直径/视盘垂直径临床参考非模型指标def vertical_cdr(cup_mask, disc_mask): # 取掩膜的行范围算垂直直径 cup_rows np.where(cup_mask.any(axis1))[0] disc_rows np.where(disc_mask.any(axis1))[0] cup_d cup_rows[-1] - cup_rows[0] 1 disc_d disc_rows[-1] - disc_rows[0] 1 return cup_d / disc_d逻辑说明np.where(...any(axis1))拿到有前景的行索引首尾差就是垂直直径。参数上如果掩膜有孤立噪点行范围会被拉大所以必须先做 4.2 的连通域清理再算 CDR否则数值会虚高。5. 避坑与排查复现这类源码最容易栽的五个地方5.1 掩膜标签值读错导致训练全崩现象训练 loss 一直不降预测出来全是背景或者全是一类。原因掩膜是调色板 PNGIMREAD_GRAYSCALE读出来是 0/1/2 之外的调色板索引值或者视杯视盘用了 128/255 这种非连续编码。解决先np.unique(mask)看真实值域再写映射表把标签重映射到 0/1/2别假设源码里的编码和你手上的数据一致。5.2 图像和掩膜尺寸不匹配现象cv2.resize或torch.cat报尺寸错误或者掩膜和图像错位。原因原始数据里图像和掩膜分辨率不同或者掩膜带黑边没裁。解决读入时断言img.shape[:2] mask.shape[:2]不一致就统一按图像尺寸 resize 掩膜且掩膜用最近邻。5.3 显存溢出与 batch size 的取舍现象CUDA out of memory。原因512×512 三通道输入加 U-Net 全分辨率特征图显存吃得很凶。解决先把 batch size 降到 2 或 1再考虑把输入降到 256也可以用梯度累积模拟大 batch累积步数设 4 相当于 batch 4 的效果显存只按 1 算。5.4 验证集指标虚高现象Dice 0.95 但可视化一看边界糊成一片。原因视盘面积大Dice 对大面积目标天然友好视杯这种小目标被平均掉了。解决视杯和视盘分开算 Dice重点看视杯再加边界距离指标如 Hausdorff 距离辅助判断别只看一个总数。5.5 推理结果和训练可视化对不上现象训练时验证图挺准单独跑推理脚本就拉胯。原因推理时的预处理和训练时不一致比如训练做了标准化推理没做或者 resize 插值方式不同。解决把预处理封装成一个函数训练和推理共用同一份代码杜绝两套逻辑。6. 进阶技巧让这份源码从能跑到好用跑通只是起点真正决定这份源码值不值得投入的是它能不能稳定产出可信的 CDR。我一般会做三件事。第一件是加测试时增强TTA对同一张图做水平翻转和轻微旋转把多次预测的概率图平均后再 argmax视杯边界通常能稳 1 到 2 个 Dice 点代价是推理时间翻几倍筛查场景可以接受。第二件是把后处理的视杯约束写死——视杯必须落在视盘内越界像素直接归到视盘这一条能修掉大部分视杯跑到视盘外面的离谱结果。def enforce_cup_in_disc(pred, cup_label2, disc_label1): disc (pred disc_label) cup (pred cup_label) # 视杯越界部分并入视盘 pred[cup ~disc] disc_label return pred第三件是做一个最小验证集回归测试固定 20 张图每次改完模型或后处理都跑一遍记录视杯 Dice 和平均 vCDR数值波动超过阈值就回滚。这个习惯救过我很多次医学图像项目最怕的就是改了一行看起来无关的代码指标悄悄掉了。参数上TTA 的旋转角度控制在 ±10 度以内再大边界就对不齐了回归测试的阈值我一般设 Dice 波动 0.01、vCDR 波动 0.02。最后说个血泪经验这类分割项目数据质量比模型结构重要得多。我见过太多人花两周换网络结构指标纹丝不动结果回头把几张标注明显偏移的掩膜修掉Dice 直接涨了 5 个点。拿到源码先别急着改模型把数据过一遍看看掩膜边界和图像对不对得上这一步的投入产出比高得离谱。希望帮到你。本文还有配套的精品资源点击获取
返回列表