简介:这是一份基于Python的眼底图像视杯视盘分割项目源码,面向医学图像处理、计算机视觉方向的课程设计、期末大作业与入门实践。项目围绕眼底图像视杯、视盘自动分割这一核心任务展开,提供完整可运行的高分方案,经导师指导并获得97分,适合需要参考整体架构或直接改编使用的学生。压缩包内共198个文件,以156个Python脚本为主体,承担模型结构搭建、训练与推理等核心逻辑;另有39个pyc编译文件与3个Markdown说明文档,整体仅451KB,目录精炼易读。内容覆盖眼底图像预处理与数据增强、基于DeepLab思路的分割网络、注意力机制组装模块以及简明说明文档,便于对照代码梳理视杯视盘分割流程。已有418人学习浏览,可作为课程设计、期末项目复现及进一步改进的起点。
1. 眼底图像视杯视盘分割源码到手之后,先别急着解压
拿到“基于python的眼底图像视杯视盘分割项目源码(高分项目).zip”这类文件,默认它是毕设或课程设计产物。标题背后其实是一套标准医学图像分割流程:用 python 读入眼底彩照,通过深度学习模型生成视盘(optic disc)和视杯(optic cup)的像素级掩码,再计算杯盘比(CDR),辅助青光眼筛查。这类项目真正的门槛不在网络结构多新,而在数据流的每一步是否自洽:标签二值化、ROI 裁剪、损失函数配比、后处理顺序以及评估口径,任何一环错位都会让最终指标变得不可信。本篇文章按“数据 → 模型 → 后处理 → 评估 → 交付”这条线把源码拆开讲,新手能按参数照做,熟手能直接看到哪些地方值得替换、哪些地方是成本黑洞。适合正在做医学图像课设、准备相关方向毕设答辩以及想把分割项目从“能跑”推到“能交付”的人。
2. 先把数据捋顺:眼底图像预处理与 ROI 裁剪的三个关键参数
任何分割项目的成败,一半由数据决定。眼底图像和自然图像最大的区别在于光照不均匀、角膜反光、血管纹理密集,而且医生关心的视盘视杯只占整张图的一小部分。所以拿到源码后的第一件事不是看模型,而是把预处理链路逐行过一遍。
2.1 标签读法:把灰度掩码变成模型能用的 0/1 张量
大多数眼底数据集给的是 PNG 格式的灰度掩码,前景标记为 255,背景为 0。但在工程实现里,模型输出层通常接 Sigmoid,用 0~1 的浮点数做监督;如果直接把 255 输入损失函数,数值范围与输出不匹配,BCE 会算出一个偏高但完全没意义的损失。更隐蔽的是,有些标注工具生成的掩码带抗锯齿,边缘像素是 127 或 200 之类的中间灰度,直接astype(bool)会把半透明边缘丢掉。
import cv2 import numpy as np def load_fundus_sample(img_path, disc_path, cup_path): # 眼底彩照用彩色图读入 img = cv2.imread(img_path, cv2.IMREAD_COLOR) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 视盘和视杯掩码都是灰度图 disc = cv2.imread(disc_path, cv2.IMREAD_GRAYSCALE) cup = cv2.imread(cup_path, cv2.IMREAD_GRAYSCALE) # 抗锯齿边界用 127 做分界,前景统变为 1 disc = (disc > 127).astype(np.uint8) cup = (cup > 127).astype(np.uint8) return img, disc, cup这里> 127比> 0稳,原因是边缘抗锯齿像素不该被当成完整前景。读入后马上验证np.unique,确认掩码只有 0 和 1,再进入后续流程。很多源码在这步偷懒直接读原始值训练,最终表现就是损失下降但验证集 Dice 上不去——这是典型的数据类型问题,不是模型问题。
2.2 ROI 裁剪:以视盘质心为中心,外扩 1.2 倍视盘直径
整张眼底图的分辨率通常在千万像素级别,直接输入网络既消耗显存,又把大量无关区域交给模型。视盘和视杯在图像中央偏外侧,周边是视网膜血管和暗角,这些区域对分割任务是纯噪声。常见做法是先通过视盘掩码算出质心和半径,再裁一个正方形区域。
def crop_roi(img, disc_mask, pad_ratio=1.2): ys, xs = np.where(disc_mask > 0) cy = int((ys.min() + ys.max()) / 2) cx = int((xs.min() + xs.max()) / 2) r = max(ys.max() - ys.min(), xs.max() - xs.min()) // 2 r = int(r * pad_ratio) h, w = img.shape[:2] x1, y1 = max(cx - r, 0), max(cy - r, 0) x2, y2 = min(cx + r, w), min(cy + r, h) return img[y1:y2, x1:x2], disc_mask[y1:y2, x1:x2]pad_ratio是这里最值得调的参数。取 1.0 时 ROI 刚好贴着视盘边缘,视杯一旦略大就会被切出去;取 1.5 时背景占比过高,模型容易学会“中间是盘、四周是背景”的偷懒模式。我一般取 1.2,既能保留视盘外围一圈过渡区,又不引入太多血管干扰。裁剪后如果 ROI 不是正方形,后续 resize 会变形,建议在裁剪时强制按2*r的边长取正方形,超出边界部分用边界像素反射填充。
2.3 CLAHE 与归一化:眼底图像的对比度分布极不均匀
眼底图像受拍摄设备影响,常见中间亮四周暗的渐晕,或者局部反光过曝。直接用全局均值和方差做归一化只能解决整体亮度偏移,解决不了局部对比度不足。CLAHE(限制对比度自适应直方图均衡化)是眼底图像预处理里性价比最高的一步。
def apply_clahe(roi_rgb, clip_limit=2.0, tile=8): lab = cv2.cvtColor(roi_rgb, cv2.COLOR_RGB2LAB) clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=(tile, tile)) lab[:, :, 0] = clahe.apply(lab[:, :, 0]) return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB)clipLimit控制对比度拉伸的强度,2.0 是一个保守又不失效的起点。调太大(比如 5.0 以上)会把血管和反光边缘一起拉伸成伪边界,模型学到的纹理和医生眼里的结构完全不是一回事。另一个关键是只在 L 通道上做,AB 通道保持原样,否则颜色信息会被破坏。做完 CLAHE 再除以 255 或按 ImageNet 均值方差归一化。如果模型是从零训练,直接用(x - 127.5) / 127.5就够,预训练权重才需要匹配原归一化参数。
2.4 数据增强:视杯视盘分割的增强要克制
分割任务里增强是把双刃剑。随机旋转、水平垂直翻转、轻微亮度抖动对眼底图是安全的;但强随机裁剪、大尺度缩放、弹性形变会破坏视盘与视杯的相对位置关系。杯盘比是一个解剖结构比例,过度形变等于篡改标注。推荐用 albumentations 同时变换图像和两张掩码。
import albumentations as A transform = A.Compose([ A.RandomRotate90(p=0.5), A.HorizontalFlip(p=0.5), A.VerticalFlip(p=0.5), A.RandomBrightnessContrast(brightness_limit=0.1, contrast_limit=0.1, p=0.2), ], additional_targets={ 'disc_mask': 'mask', 'cup_mask': 'mask' })additional_targets这个参数是 albumentations 的同步关键,忘记配置会导致图像翻了而掩码没翻,训练过程不报错但指标永远异常。旋转角度限制在 90 度倍数以内,避免视盘被旋转成非生理角度。增强的目标是提升泛化,不是制造新样本,所以幅度必须克制。
3. 模型与训练:U-Net 选型、损失函数和优化器怎么设
预处理做完,接下来是模型选型和训练策略。这一章回答两个问题:为什么这类项目大多用 U-Net;损失函数和训练超参怎么设才能在有限数据下不翻车。
3.1 为什么选 U-Net 而不是 DeepLabV3+ 或 Transformer
眼底图像公开数据集规模普遍不大,去掉标注质量差的样本后,能用于训练的往往只有几百到一千张。这个数据量撑不起 Transformer 这类需要大数据预训练的结构,DeepLabV3+ 虽然多尺度能力更强,但参数多、收敛慢,在小型数据集上容易过拟合。U-Net 的跳连接结构天然适合这个任务:下采样路径捕捉全局上下文,上采样路径把边缘细节逐级恢复,而视盘视杯的分割恰恰依赖边界清晰度。
import torch import torch.nn as nn class DoubleConv(nn.Module): def __init__(self, in_channels, out_channels): super().__init__() self.conv = nn.Sequential( nn.Conv2d(in_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True), nn.Conv2d(out_channels, out_channels, 3, padding=1, bias=False), nn.BatchNorm2d(out_channels), nn.ReLU(inplace=True) ) def forward(self, x): return self.conv(x)padding=1保证卷积不改变特征图尺寸,bias=False是因为后面接 BatchNorm,偏置会被归一化抵消。U-Net 的编码器逐层下采样四次,感受野覆盖整个 ROI;解码器通过跳连接把浅层边缘信息拼回来。对两个任务(杯、盘)共用同一个编码器是常见做法,输出层接两个通道的 Sigmoid,比训练两个独立模型更省显存,还能让编码器同时学到盘和杯的共性特征。
3.2 损失函数:BCE + Dice,视杯小目标为什么不能只用 BCE
视杯在 ROI 里占比很小,有些样本可能只有几个百分点。普通 BCE 会把绝大多数梯度分配给背景,模型很快学会“全预测为背景”也能拿到低损失,但 Dice 完全无意义。所以这类项目几乎不会只用 BCE,而是配一个 Dice 损失强行拉高前景重叠率。
def dice_loss(pred, target, smooth=1e-6): pred = pred.contiguous().view(pred.size(0), -1) target = target.contiguous().view(target.size(0), -1) intersection = (pred * target).sum(dim=1) dice = (2.0 * intersection + smooth) / ( pred.sum(dim=1) + target.sum(dim=1) + smooth ) return 1.0 - dice.mean()smooth防止分子分母都为 0 时除零。dice_loss返回的是1 - dice,所以越小代表重叠越高。实际训练里我会把总损失设为0.5 * BCE + 0.5 * Dice,其中 Dice 分别对杯、盘两个通道计算再取平均。如果发现视杯漏检严重,可以把 Dice 权重提到 0.7,或者换成 Tversky Loss 调整假阳和假阴的惩罚比例,但不要同时堆 Focal、Boundary、Hausdorff 三四个损失,调参成本远大于收益。
3.3 完整训练管线:batch size、学习率、早停、验证划分
训练循环本身不复杂,坑在超参和验证策略。输入统一 resize 到 512×512,batch size 4,优化器 Adam,初始学习率 1e-4。每轮验证算视盘和视杯的平均 Dice,保存最优权重,连续 10 轮不提升就早停。
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 512×512 | 保留边界细节,显存不够可降到 448 |
| batch size | 4~8 | ROI 裁剪后显存压力小,尽量不低于 4 |
| 优化器 | Adam | 收敛稳定,比 SGD 好调 |
| 初始学习率 | 1e-4 | 过大会振荡,过小收敛太慢 |
| 调度策略 | ReduceLROnPlateau | 验证损失平台期减半 |
| 早停 | patience=10 | 以验证 Dice 为基准 |
| 损失权重 | 0.5 BCE + 0.5 Dice | 杯盘分别算 Dice 再平均 |
for epoch in range(max_epochs): model.train() for imgs, discs, cups in train_loader: pred_disc, pred_cup = model(imgs) loss_disc = 0.5 * bce(pred_disc, discs) + dice_loss(pred_disc, discs) loss_cup = 0.5 * bce(pred_cup, cups) + dice_loss(pred_cup, cups) loss = loss_disc + loss_cup optimizer.zero_grad() loss.backward() optimizer.step() val_dice = evaluate(model, val_loader) if val_dice > best_dice: torch.save(model.state_dict(), "best_model.pth") best_dice = val_dice验证集划分要按眼底图像 ID 分,不能让同一只眼的两个裁剪图同时出现在训练集和验证集。医学图像数据往往同一病人有多张图,随机划分会造成信息泄漏,验证指标虚高。训练过程中定期保存叠加可视化图,比只看数值更能发现问题。
4. 从分割图到杯盘比:后处理与评估指标的实现细节
模型输出的是概率图,不是最终结果。后处理的质量直接决定交付时别人对项目的第一印象,这一章讲清楚四步后处理和三个评估口径。
4.1 后处理流程:阈值、最大连通域、孔洞填充、形态学闭运算
第一步是阈值化,0.5 是默认起点;如果视杯边界偏大,可以上调到 0.55。第二步是保留最大连通域,这一步能去掉血管方向上误检的小碎片。第三步是孔洞填充,杯内常有血管穿行导致预测出现空洞。第四步是形态学闭运算平滑边界。
def postprocess_mask(prob, thr=0.5): m = (prob > thr).astype(np.uint8) # 只保留最大连通域,清掉血管误检碎片 num, labels, stats, _ = cv2.connectedComponentsWithStats(m, 8) if num > 1: largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA])) m = (labels == largest).astype(np.uint8) # 孔洞填充,让视杯内部连续 contours, _ = cv2.findContours(m, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) filled = np.zeros_like(m) cv2.drawContours(filled, contours, -1, 1, -1) # 闭运算平滑边界锯齿 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) return cv2.morphologyEx(filled, cv2.MORPH_CLOSE, kernel)这几个操作用到的都是 OpenCV 内置函数,没有额外学习成本。kernel 尺寸 5×5 在 512×512 分辨率下合适,如果输入分辨率较高可以适当增大到 7×7。闭运算对边界形态影响很小,但对最终视觉观感提升很大。
4.2 评估指标:Dice、IoU、CDR,一个都不能少
| 指标 | 计算方式 | 用途 |
|---|---|---|
| Dice | 2 | P∩T |
| IoU | P∩T | |
| CDR | 杯面积 / 盘面积 | 与临床相关的核心指标 |
def compute_cdr(cup_mask, disc_mask): cup_area = int((cup_mask > 0).sum()) disc_area = int((disc_mask > 0).sum()) if disc_area == 0: return 0.0 return cup_area / disc_areaCDR 是青光眼筛查中医生实际会看的指标,也是高分项目评审时最容易被追问的地方。要注意面积比和垂直径比是两种口径,同一只眼算出来数值有差异,报告里必须写清楚用的是哪种。如果你用面积比,就要在文档里注明;如果临床标准用的是垂直径比,需要先拟合椭圆再取椭圆长轴比值,不要混着用。
4.3 验证时的评估口径:回到原始分辨率上算指标
在 512×512 的裁剪图上算 Dice,和回到原始分辨率的标签上算,结果可能差出几个点。原因在于 resize 操作改变了边界像素分布,小目标分割对空间分辨率尤其敏感。正确的做法是先把预测 mask 用最近邻插值 resize 回原图尺寸,再和原图标签对齐计算。
from skimage.transform import resize pred_orig = resize(pred_mask, (orig_h, orig_w), order=0, anti_aliasing=False) dice_val = compute_dice(pred_orig, orig_label)order=0是最近邻插值,保持 mask 的离散属性;anti_aliasing=False防止边缘被滤波抹糊。报告中写 Dice 数值时必须带上分辨率、阈值和是否裁剪这三个条件,否则别人复现出来的数字很难对齐。
5. 避坑指南:视杯视盘分割复现中最常见的五个翻车点
这部分是我改源码时最常遇到的五个问题,每个都按“现象 → 原因 → 解决”梳理,条条都有血泪经验在里面。
5.1 损失一路下降,Dice 就是上不去
现象:训练损失从 1.2 降到 0.3,验证集 Dice 却卡在 0.2 左右不动。
原因:八成是标签格式问题。掩码被直接当 0/255 二值图参与计算,Transformer 分支学习到一个几乎恒定的输出,或者是掩码文件路径配对错误,视盘标签当成了视杯标签。
解决:在训练脚本里加数据自检,打印每张掩码的 unique 值和像素分布;可视化三张图确认盘、杯、原图的对应关系。这类问题发生后往往会返工整套训练流程,值得在开始前花半小时排查。
5.2 视杯跑到视盘外面去
现象:单看 Dice 数值挺高,但把两张掩码叠在原图上,发现视杯区域明显超出视盘边界,解剖结构完全不合理。
原因:两个输出通道各自独立优化,模型没有学到“杯包含于盘”的解剖先验。
解决:训练时给视杯通道加一个掩码约束,把视盘预测结果当作视杯的注意力图:
cup_pred = cup_pred * disc_pred.detach()后处理阶段再做一次cup_mask = cup_mask & disc_mask的硬性约束。经验是把结构约束当作损失函数的一部分来优化,效果比单纯靠模型自己学习要稳定。这类问题在 CDR 指标上会暴露得很明显:杯盘比异常偏大,一眼就能看出错。
5.3 显存不够,batch size 只能设为 1
现象:开始训练 10 秒就 OOM,被迫 batch size 设为 1,训练极不稳定。
原因:整张眼底图直接输入网络,分辨率动辄两千万像素,显存再大也扛不住。
解决:先做 ROI 裁剪再做 resize,把输入控制在 512×512。如果显存还是不够,就降低 batch size 到 2 配合梯度累积。很多人忽略梯度累积,只是在 batch size 上硬扛。
accumulation_steps = 4 if step % accumulation_steps == 0: optimizer.step() optimizer.zero_grad()5.4 翻转增强后 mask 跟着出错
现象:训练集可视化里出现大量全黑或全白的掩码,模型预测开始出现诡异条纹。
原因:图像翻转了但掩码没有同步翻转,很可能是手动实现翻转时忘了对 label 做同样操作,或者用了复杂的 transform 组合但没对齐。
解决:用 albumentations 统一管理,通过additional_targets把两张 mask 一并传入。自己写 dataset 时,对图像和掩码使用同一个随机种子执行几何变换。
5.5 CDR 数值异常偏高或偏低
现象:Dice 和 IoU 都正常,但算出的 CDR 明显不合理,偏大或偏小。
原因:杯盘分割的边界误差在各自区域面积上被放大,或预处理中 CLAHE 参数过大造成伪边界偏移。
解决:把模型输出的概率图叠加回原图做人工抽查,观察杯盘边界是否贴住解剖结构。如果杯边界整体外扩,把阈值从 0.5 调到 0.6;如果盘边界内缩,检查 ROI 裁剪是否切到了视盘边缘。CDR 是对边界误差极敏感的指标,它也是检验整个链路是否可靠的最有效指标。
6. 把源码交付成一个能复现的工具:推理批处理与 CDR 报告
源码能跑通训练只是第一步,真正有交付价值的是离线推理工具和可读的结果报告。很多“高分项目”最终展示的就是这一步。
6.1 用 argparse 封装推理脚本
训练和推理的解耦非常重要。训练脚本里揉进数据加载、模型定义和可视化,会让别人很难实际使用。我一般会单独写一个推理脚本,输入一张眼底图,输出视盘掩码、视杯掩码和概率图。
import argparse import cv2 import torch def inference(model_path, img_path, output_dir): img = cv2.imread(img_path) roi, _ = crop_roi(img, disc_mask_placeholder) roi_resized = cv2.resize(roi, (512, 512)) tensor = torch.tensor(roi_resized / 255.0).permute(2, 0, 1).unsqueeze(0) with torch.no_grad(): pred_disc, pred_cup = model(tensor) cv2.imwrite(f"{output_dir}/disc_pred.png", pred_disc * 255) cv2.imwrite(f"{output_dir}/cup_pred.png", pred_cup * 255) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--model", required=True, type=str) parser.add_argument("--input", required=True, type=str) parser.add_argument("--out", default="output", type=str) args = parser.parse_args() inference(args.model, args.input, args.out)这个脚本的好处是别人不需要读你的训练代码也能直接用模型。提取出来的概率图保留原始尺度,后续要调阈值、做形态学处理都不需要重跑推理。
6.2 输出批处理结果到 CSV
单张推理补齐后,需要批量处理整个目录并生成 CSV 报告,包含文件名、视盘面积、视杯面积和 CDR 值,这是项目落地中最常见的交付物。
import pandas as pd rows = [] for img_path in sorted(image_paths): disc_mask, cup_mask = run_inference(img_path) cdr = compute_cdr(cup_mask, disc_mask) rows.append([img_path, int(disc_mask.sum()), int(cup_mask.sum()), cdr]) df = pd.DataFrame(rows, columns=["image", "disc_area", "cup_area", "cdr"]) df.to_csv("report.csv", index=False)CSV 文件名里带上病人或图像 ID,方便回查原始数据。如果结果要用于进一步统计分析,再合并一份带 Dice 验证集结果的评估表,这样别人拿到报告时既有算法性能又有临床指标。
6.3 验证这个方法真的可用:三个小动作
第一,把训练集和验证集比例改成 8:2 后重新训练一轮,确认指标没有大幅波动,排除运气因素。第二,在固定一张图上把阈值 0.40、0.50、0.60 各跑一遍,记录 CDR 变化范围,确认结果在合理区间。第三,拿 3 张没参与训练的外部眼底图做盲测,把预测掩码叠加在原图上,找懂行的人看一眼边界是否可信。
我早期做这类项目有一个血泪教训:拿到源码急着一键训练,结果标签格式和 ROI 裁剪没对齐,跑出来的报告自己都不信。后来养成习惯,先把数据浏览一遍,把标签可视化,把输出叠加回原图,再做训练与评估。现在我把这个习惯写成脚本,每次动手先跑“数据体检”。这个检查花不了多长时间,换来的却是后面不返工,希望帮到你。
本文还有配套的精品资源,点击获取