简介:这是一份基于机器学习和深度学习的眼底图像病变分类项目资料,面向医学影像分析初学者、眼科AI方向研究人员以及需要快速搭建分类实验的开发者,主要解决眼底图像正常与异常、可分级与不可分级等病变类别的自动识别问题。压缩包共46个文件,容量约459KB,其中包含28个Python脚本,用于模型训练、推理、预处理、激活图叠加等环节;4个npy文件保存训练/验证索引数据,另有标签校验目录与Shell脚本辅助标签校验和快速运行。项目采用CNN等算法,覆盖DIARETDB1、DRIVE等公开数据集的处理流程,并给出TensorFlow/PyTorch环境搭建与Docker复现说明。已有344人浏览学习,适合边读代码边对照环境说明进行复现,也可参考其模型训练与数据增强思路迁移至其他医学图像分析任务。
1. 别急着上 CNN,先搞清楚这个问题真正难在哪
把 MNIST 那套深度学习流程原样搬到眼底彩照上,十有八九会在验证集上翻车。眼底图像病变分类这个标题看起来像是一个打包好的课程设计资源,实际内容却不是「跑通一个网络」这么简单:输入是眼底相机拍出的彩色照片,输出是病种标签或者病变分级,比如糖尿病视网膜病变(DR)的 0 到 4 期、青光眼、黄斑水肿等。这类任务有三个特点:样本量通常只有几千张、类别之间极度不平衡、不同拍摄设备的亮度和色温差异很大。真正决定交付质量的反而是数据集怎么切分、怎么预处理、传统机器学习基线能不能站起来,以及深度学习模型在类别不平衡下怎么调参。环境搭建说明也从来不是装个 PyTorch 就完事,而是要把数据路径、版本依赖和随机种子都固定下来。这篇文章就沿着「数据 → 机器学习 → 深度学习 → 验证」这条路径,把一套能复现、能写进报告的做法讲清楚。
2. 眼底图像数据集:目录规范、预处理与类不平衡要先于模型解决
标题里「内含数据集」这几个字,意味着拿到压缩包之后的第一件事不是解压就训练,而是把数据从「一堆图片」整理成「一份可复现的数据集物件」。这个环节做得越细,后面换模型、调参数、做对比实验时就越省事。
2.1 公开数据集、标签定义与「内含数据集」的差别
大多数以「内含数据集」形式打包的资源,图片来源并不是拍摄机构直接提供的原始数据,而是从公开竞赛和公开数据整理而来。整理质量的差异很大:有的保留了完整的标签 CSV,有的把标签写在了文件名里,还有的混入了重复图或标注噪声。拿到数据后先确认两件事:标签有几种取值,各种取值分别有多少张;同一患者的双眼图像是否可能同时出现在训练集和验证集里。
眼底图像领域常见的公开数据集有下面这些,它们的任务定义差异很大,拿到「内含数据集」时可以先对照着看标签格式更接近哪一种。
| 数据集 | 图像规模 | 标签类型 | 常见用途 |
|---|---|---|---|
| APTOS 2019 | 训练集三千余张 | DR 0-4 五级分类 | 单眼五分类 |
| ODIR | 约 8000 张 | 8 个疾病标签,多标签 | 多标签分类 |
| IDRiD | 数百张,高清 | 病变分割 + 分级 | 分割与分级联合 |
| DRIVE | 40 张 | 血管二分类掩膜 | 血管分割 |
如果「内含数据集」的标签是类似0,1,2,3,4的整数,大概率就是 DR 分级任务;如果标签是N, D, G, C, A, H, M, O这样的字母,那就是 ODIR 风格的多标签分类。这两个任务前者用CrossEntropyLoss,后者要用BCEWithLogitsLoss,模型最后一层的输出维度也不一样,动手前先确认清楚。
2.2 环境搭建与目录初始化
环境搭建说明一般以 README 形式附在包里,但里面的步骤往往只覆盖「安装依赖」,不会告诉你目录怎么组织。我一般会建一个和深度学习框架无关的目录骨架,让数据、源码、权重各归其位,避免训练到一半发现项目里到处都是脚本副本。
project/ ├── data/ │ ├── raw/ # 原始图片,解压后不再改动 │ ├── labels.csv # 文件名和标签的映射 │ ├── train.txt # 划分后的训练列表 │ └── val.txt # 划分后的验证列表 ├── src/ │ ├── preprocess.py │ ├── dataset.py │ └── train.py ├── weights/ # 按时间戳保存模型 └── requirements.txt这一步看起来和模型无关,但train.txt和val.txt是后续所有实验的基准线。同一批图片,每次训练前重新划分,得到的结果就没法横向比较了。环境搭建方面,优先用 conda 创建独立环境,再按顺序安装:PyTorch 和 torchvision 放在最前面,因为它们决定 CUDA 版本;然后是 opencv-python、scikit-learn、pandas 这类依赖。
提示:先确认 CUDA 版本再装 torchvision,
pip install torch torchvision默认装的是 CPU 版还是 GPU 版取决于运行平台,装完立即用一个小张量验证 CUDA 是否可用,不要等训练报错才发现环境不对。
2.3 图像预处理:去黑边、尺寸统一与 CLAHE 照度校正
眼底相机拍出的图片是圆形视场,四角是纯黑背景。如果不处理这些黑边,模型会把「黑色像素占比」学成特征——某台设备光圈调大一点,黑边变少了,模型输出可能就变了。常见的做法是先裁掉黑边,再做照度归一化。
import cv2 import numpy as np def crop_black_border(img: np.ndarray) -> np.ndarray: gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) _, thresh = cv2.threshold(gray, 5, 255, cv2.THRESH_BINARY) coords = cv2.findNonZero(thresh) x, y, w, h = cv2.boundingRect(coords) return img[y:y + h, x:x + w] def apply_clahe(img: np.ndarray, clip: float = 2.0) -> np.ndarray: lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) l, a, b = cv2.split(lab) clahe = cv2.createCLAHE(clipLimit=clip, tileGridSize=(8, 8)) l = clahe.apply(l) return cv2.cvtColor(cv2.merge([l, a, b]), cv2.COLOR_LAB2BGR)crop_black_border先用阈值把亮度低于 5 的像素置为背景,再用boundingRect求出视场的外接矩形,最后裁出有效区域。apply_clahe在 LAB 颜色空间的亮度通道上做对比度限制自适应直方图均衡化,clipLimit=2.0控制对比度放大幅度,太大容易把噪声也拉起来;tileGridSize=(8, 8)意味着亮度统计在 8x8 的局部块中进行,适合眼底图像这种一张图里明暗差异大的情况。注意先裁黑边再做 CLAHE,顺序反了的话 CLAHE 会把黑边区域也做均衡化,浪费计算量还引入伪纹理。
2.4 数据划分与类不平衡统计
预处理写完后的下一步是划分数据,这一步要遵循一个原则:按患者划分,不要按图片划分。同一个患者的左右眼图像高度相似,如果左眼进了训练集,右眼进了验证集,模型相当于提前见过答案。
划分完成后立刻统计每个类别的样本数。DR 五分类数据集的典型分布是0类占一半以上,4类只有几十张。看到这种分布要先决定缓解策略,而不是直接开训。常用的两条路是:在损失函数里按类别频率加权,或者用WeightedRandomSampler对少数类过采样。这两条路后面训练深度学习模型时再展开,这里只需要把类别分布记录下来,写进报告或 README,作为后续实验的对照组信息。
3. 机器学习基线:用手工特征和线性分类器先拿到下限
标题里同时出现「机器学习和深度学习」,最常见的实现策略不是二选一,而是先用机器学习做一个轻量基线,再拿深度学习去冲击更高指标。这符合课程设计和论文对比实验的常见要求,也符合工程直觉。
3.1 为什么这个标题里的「机器学习」通常不是单选题
几千张图像直接训 ResNet,如果不加预训练,效果未必比一个好调过的传统分类器强。原因是眼底图像数据集小、类别边界模糊,纯数据驱动的深度模型在小样本下很容易过拟合,验证集上表现出一种「分类准确但泛化不足」的状态。
机器学习基线的价值有两个。一是提供一个低方差的下限:手工特征固定之后,跑十次结果都差不多,用来判断深度学习模型到底有没有真正学到东西;二是特征可解释:哪种类别最容易被错分,可以回溯到颜色或纹理特征找到原因。很多教材热衷于 MNIST 数据集上展示深度模型的威力,但实际医疗场景里,机器学习分类器仍然是不可替代的对照物。
3.2 特征工程:颜色直方图 + LBP 的最小组合
对于眼底图像,不需要一开始就做复杂的分割和形态学特征。一个容易复现又有区分度的组合是:HSV 颜色直方图描述颜色分布,局部二值模式(LBP)描述纹理。出血点和渗出斑在色相上与正常视网膜差异明显,而 LBP 能捕捉渗出斑边缘的纹理突变。
from sklearn.pipeline import make_pipeline from sklearn.preprocessing import StandardScaler from sklearn.svm import SVC from skimage.feature import local_binary_pattern def extract_features(img): hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) hist = cv2.calcHist([hsv], [0, 1], None, [16, 16], [0, 180, 0, 256]) hist = cv2.normalize(hist, hist).flatten() gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) lbp = local_binary_pattern(gray, 24, 3, method='uniform') lbp_hist, _ = np.histogram(lbp.ravel(), bins=np.arange(0, 27), density=True) return np.concatenate([hist, lbp_hist]) X = np.stack([extract_features(p) for p in image_paths]) clf = make_pipeline(StandardScaler(), SVC(kernel='rbf', C=10, gamma='scale')) clf.fit(X, y)cv2.calcHist将 H 通道的 180 级和 S 通道的 256 级分别量化到 16 个区间,得到 16x16 的联合直方图。联合直方图比单独通道直方图更能刻画「颜色分布」,比如黄白色渗出斑对应 H 通道偏黄、S 通道偏低的组合区域。local_binary_pattern的半径设为 3,邻域点数设为 24,method='uniform'会把模式压缩到 26 个 bin,再用density=True归一化成概率分布。SVC 的C=10控制误分类惩罚,gamma='scale'让 RBF 核的带宽根据特征维度自动调整,避免手动调这个参数。
如果特征提取耗时太长,可以对每张图先缩放到 256x256 再提取特征。颜色直方图对分辨率不敏感,LBP 统计的是局部模式分布,缩放不影响大局。
3.3 直接把 SVM 换成树模型行不行
很多做机器学习分类器的人会把 SVM 换成随机森林或 LightGBM。这个替换在特征维度不高时通常没有明显收益,但在维度上升后会有差异。
| 分类器 | 适合情况 | 注意事项 |
|---|---|---|
| SVM + RBF | 特征维度几百到几千 | 需要标准化,类别不平衡时效果下降明显 |
| 随机森林 | 特征含噪声或缺失 | 对不平衡敏感,需要调class_weight |
| LightGBM | 特征很多且想省时间 | 树模型对类别频率敏感,少数类容易学不到 |
眼底图像手工特征通常在几百维左右,SVM 的边界决策在小样本下更稳。如果坚持用 LightGBM,记得设置class_weight='balanced'或手动指定权重,否则多数类会主导树的切分。机器学习这一步满足「能对比」就够了,不要在这里花太多时间调参,把重心留给深度学习部分。
4. 深度学习主模型:PyTorch 训练流程与关键参数
深度学习部分是整个任务的核心,也是实现「病变分类」高指标的主力,但其中 80% 的效果来自数据策略,模型结构反而没那么重要。使用 PyTorch 深度学习框架时,最稳妥的路线是加载 ImageNet 预训练权重做微调,而不是从零训练。
4.1 选 ResNet 还是 EfficientNet,预训练权重要不要保留
眼底图像虽然和自然图像差异很大,但预训练权重中学到的边缘、纹理、颜色渐变等底层特征仍然有效。对于几千张图的规模,从零训练一个 ResNet 通常学不到足够泛化的特征,微调是必要选项。
模型首选 ResNet-18 或 ResNet-50。ResNet-18 在单卡小显存环境下训练速度快,ResNet-50 精度上限更高但需要更多数据增强配合。EfficientNet-B0 理论效率更好,但需要注意它的classifier结构和 ResNet 不一样,改最后一层时不要直接写model.fc = ...,而是查一下model.classifier的输入维度。
import torch from torch import nn from torch.utils.data import DataLoader from torchvision import models, transforms transform_train = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1) model.fc = nn.Linear(model.fc.in_features, num_classes) criterion = nn.CrossEntropyLoss(weight=class_weights.to(device)) optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=1e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=20)RandomHorizontalFlip和RandomRotation是轻量几何增强,对眼底图像这种结构相对固定的数据足够;ColorJitter模拟不同设备亮度和对比度差异,但幅度不要太大。CrossEntropyLoss中的class_weights是关键参数:把类别频率倒数开根号后再归一化,即参考1 / sqrt(freq),避免少数类权重过大导致模型整体偏向少数类。
4.2 类别不平衡:loss 加权和 WeightedRandomSampler 只选一个
类别不平衡是这个任务最容易出问题的地方。最常用的两种处理方式是损失函数加权和采样器加权,但不要同时开满。
CrossEntropyLoss的weight参数直接改变每个样本对梯度贡献的比例。当少数类权重过大时,每个 epoch 里少数类样本被重复强调,容易过拟合到少数类的个别特征。WeightedRandomSampler通过调整采样概率让每个 epoch 吃到的少数类样本更多,但这会让每个 epoch 里总体样本分布跟真实分布不一致,验证时如果还用原始分布评估,指标会有偏差。
我一般倾向于只用 loss 加权,且权重设置为1 / sqrt(freq)。这个策略比1 / freq平滑,少数类不会被过度放大,多数类也不会被完全压制。
4.3 关键参数表与最小训练命令
训练参数直接照搬别人的配置不一定适合你的数据,但下面这个范围可以从大部分眼底图像数据集上手。
| 参数 | 建议范围 | 说明 |
|---|---|---|
| batch size | 16 到 32 | 显存吃紧时用 8,配合梯度累积到等效 32 |
| 学习率 | 1e-4 到 3e-4 | 微调阶段不要用 1e-3,容易破坏预训练特征 |
| weight decay | 1e-4 到 5e-4 | 控制过拟合,太小没效果,太大会欠拟合 |
| 轮数 | 20 到 40 | 余弦退火下 30 轮左右足够稳定 |
| loss 权重 | 1 / sqrt(freq) | 先试这个,再决定要不要换成采样器 |
训练脚本建议用命令行参数控制这些变量,方便记录每次实验的配置:
python src/train.py \ --data-dir data/ \ --train-txt data/train.txt \ --val-txt data/val.txt \ --backbone resnet18 \ --pretrained 1 \ --batch-size 16 \ --epochs 30 \ --lr 3e-4 \ --weight-decay 1e-4 \ --num-classes 5 \ --seed 42用train.txt和val.txt而不是直接遍历文件夹的原因在于:文件列表是划分结果的唯一事实来源,训练时只需按行读取路径,避免因为文件系统排序差异导致每次训练的数据顺序不一致。--seed 42是我做此类任务时的默认值,种子固定之后,卷积、dropout、数据增强的随机性都可控,方便复现。
4.4 训练曲线怎么读,val loss 不降先查什么
训练过程中记录 train loss 和 val loss,而不是只记录准确率。两类现象需要格外注意:train loss 一直下降但 val loss 在前 5 个 epoch 后就反弹,说明过拟合,优先加大weight_decay,再考虑增强;val loss 整体抖动剧烈,先看 batch size 是不是太小,再看学习率是不是超过 5e-4。
只关注 val accuracy 也会被误导。五分类任务盲猜都有 20% 保底,当类别严重不平衡时,模型只要把所有样本都预测为最常见类别,准确率也能到 50% 以上。所以验证指标至少要看 macro F1 和混淆矩阵。
5. 眼底图像病变分类的验证手段与复现自查
训练完成后,报告或演示里最需要的东西不是一个高 accuracy 数字,而是一套能解释错误、能复现结果的验证流程。模型输出 CSV 或者界面展示之前,先自己看一遍错误样本。
5.1 用错误样本回看替代「准确率崇拜」
把验证集上预测错误的样本按置信度降序导出,置信度越高的错误样本越值得看,因为模型「很有把握地错了」,这种样本通常是标注噪声或类别边界本来就模糊的图。
def dump_wrong_predictions(model, loader, out_csv): model.eval() rows = [] with torch.no_grad(): for imgs, labels, paths in loader: logits = model(imgs) probs = torch.softmax(logits, dim=1) preds = probs.argmax(dim=1) for path, label, pred, prob in zip( paths, labels, preds, probs.max(dim=1).values ): if pred != label: rows.append((path, label.item(), pred.item(), prob.item())) pd.DataFrame(rows, columns=['path', 'true', 'pred', 'prob']).to_csv(out_csv, index=False)这里的dump_wrong_predictions假设loader返回三元组(img, label, path),实际使用时需要自定义 Dataset 的__getitem__返回值。导出后按prob降序排列,重点看每类最容易和哪一类混淆。比如 1 期 DR 和 2 期 DR 互混,其实是标注精细度问题;如果渗出斑和出血点互相混淆,就要检查图像预处理阶段 ColoJitter 是否把颜色差异拉没了。
5.2 复现顺序:种子、数据划分、依赖版本
拿到别人的结果跑不出来,或者自己隔天跑不出同样指标,九成出在这三个环节:数据划分文件有没有变化、随机种子有没有固定、依赖版本有没有锁住。先看data/train.txt是否带上了患者级分组,再看数据增强管线里的随机性是否被种子约束,两者都正常时再对比 torch、torchvision、opencv 的版本差异。环境搭建说明里通常不会提到的一点是:random.seed、np.random.seed、torch.manual_seed要在同一个函数里一起设置,并且DataLoader的shuffle行为还受worker进程随机状态影响,所以除主进程种子外,必要时给每个worker单独设种子。把这些都对齐之后,指标差异才可能是模型本身的问题。
本文还有配套的精品资源,点击获取