基于PyTorch ResNet50的眼疾分类项目实战:从数据预处理到模型部署 简介基于pytorchResNet50的眼部疾病图片分类源码是一份面向深度学习初学者和高校课程设计场景的完整实验项目。它利用OCT图像实现多类眼部疾病自动分类不仅内置ResNet18/34/50与VGG16/19五种主流网络结构还在测试集上达到90%以上准确率同时提供了3D-ResNet的尝试对比便于理解不同模型的性能差异和参数量影响。资源包共10个文件包括7个Python脚本、2个Markdown说明文档和1个gitignore配置文件。Python脚本覆盖数据预处理、训练集划分、模型构建、评估指标计算和推理分类等核心环节Markdown文档则补充环境准备与使用指引整体仅24KB轻量、结构清晰无需大型依赖即可快速上手。目前已有422人学习浏览下载后可直接参考代码逻辑、复现实验结果也可在此基础之上扩展网络或改进训练策略作为期末大作业或课程设计的高分蓝本。1. 为什么一个 ResNet50 眼疾分类项目能当“高分项目”一个基于 PyTorch ResNet50 的眼部疾病图片分类项目摆在简历或 GitHub 上最容易被问到的问题不是“准确率多少”而是“你的模型为什么能分出这些病”。眼科影像分类与通用物体识别不同样本量小、类间差异细微、病灶位置分散单纯把 ResNet50 从 torchvision 里 load 下来再 fine-tune往往只能得到一个“看起来能跑”的 demo距离一个经得起追问的项目还差数据组织、训练策略、评估可视化和部署落地这四块。本文会顺着一个可复现的 PyTorch ResNet50 眼部疾病分类项目的完整路径展开把数据准备、训练脚本、诊断指标、可视化解释和导出部署逐一落地。适合正在做课程设计、毕业设计或简历项目并且希望在答辩或面试时把原理和代码都讲清楚的人。整个方案不需要多卡集群一张 8GB 显存的消费级显卡就能跑核心是让你拿到任何一份眼疾影像数据集或 CSV 标签后能在半小时内启动训练并得到可解释、可汇报的结果。2. 数据是这个项目的隐形扣分项先把 OCT 图像组织成 PyTorch 能吃的 Dataset2.1 眼疾分类的数据不像 CIFAR标签往往在 CSV 里大多数公开的眼部疾病数据集比如常见 OCT 分类数据、眼底彩照数据集不是按文件夹分好类的而是给一个包含image_path、diagnosis或label列的 CSV 文件。即便你找到的是已经按目录组织好的数据也建议先转成 CSV原因有三个第一CSV 可以直接记录训练集/验证集划分避免 DataLoader 因文件夹顺序产生偏差第二后续做类别权重、数据增强、重复采样时CSV 比目录名更好操作第三项目答辩时评审大概率会问“你的数据是怎么划分的”CSV 能直观展示 train/val 的分布。推荐的目录结构是eye_disease_project/ ├── data/ │ ├── train.csv │ ├── val.csv │ └── images/ │ ├── OCTID_1001.png │ ├── OCTID_1002.png │ └── ... ├── src/ │ ├── dataset.py │ ├── train.py │ ├── evaluate.py │ └── export_onnx.py └── checkpoints/train.csv长这样image_path,label images/OCTID_1001.png,CNV images/OCTID_1002.png,DME images/OCTID_1003.png,DRUSEN images/OCTID_1004.png,NORMAL标签建议直接用字符串不要一开始就编码成 0/1/2/3因为字符串在划分数据集和输出分类报告时可读性更高。在dataset.py里再做label_map {cls: idx for idx, cls in enumerate(sorted(set(labels)))}的映射这样新增类别时不用改预处理代码。2.2 用 torch.utils.data.Dataset 封装 CSV 路径和标签先看完整的dataset.py实现这是一个在 PyTorch 里做眼疾分类最常用的 Dataset 写法兼容 OCT 和眼底彩照只需要改image_path前缀和transformimport os import pandas as pd from PIL import Image from torch.utils.data import Dataset from torchvision import transforms class EyeDiseaseDataset(Dataset): def __init__(self, csv_path, root_dirdata, transformNone, is_trainTrue): self.df pd.read_csv(csv_path) self.root_dir root_dir self.transform transform # 建立字符串标签到整数索引的映射 self.label_map {cls: idx for idx, cls in enumerate( sorted(self.df[label].unique()))} self.classes list(self.label_map.keys()) def __len__(self): return len(self.df) def __getitem__(self, idx): row self.df.iloc[idx] img_path os.path.join(self.root_dir, row[image_path]) # 统一转成 RGB避免灰度图/四通道 PNG 导致 tensor 维度对不上 image Image.open(img_path).convert(RGB) label self.label_map[row[label]] if self.transform: image self.transform(image) return image, label # 训练集和验证集用不同的 transform验证集不做随机增强 train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomRotation(degrees10), transforms.ColorJitter(brightness0.15, contrast0.15), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])逻辑说明__getitem__里做的三件事分别是拼路径、读图、映射标签。这里最容易出的 bug 是路径拼接有些数据集 CSV 里已经带了data/images/前缀那就不要再拼root_dir建议在root_dir参数上传一个空字符串并在读取 CSV 前先自行检查前两行路径是否存在。另一个关键点是ColorJitter的幅度不能太大眼科 OCT 图像本身是灰度结构图过度调亮度和对比度会让原本就细微的脉络膜、视网膜层间结构失真brightness0.15已经是上限。2.3 数据校验与一键划分训练前先跑这个脚本接手一个未知来源的眼疾数据集时不要急着训练。先用一个快速脚本确认图片可读、类别分布合理、没有损坏文件。下面这段 bashPython 混合的方式是团队项目里常见的“数据体检”步骤# 检查图片数量和是否有损坏文件 python - EOF import os from PIL import Image from collections import Counter image_dir data/images label_csv data/train.csv broken [] count 0 for fname in os.listdir(image_dir): if fname.lower().endswith((.png, .jpg, .jpeg)): count 1 try: Image.open(os.path.join(image_dir, fname)).load() except Exception as e: broken.append(fname) print(total images:, count) print(broken images:, len(broken)) for f in broken[:10]: print(f) EOF# 单独检查标签分布作为类别权重的依据 import pandas as pd df pd.read_csv(data/train.csv) print(df[label].value_counts())这段脚本的意义不只是检查它会直接告诉你三个信息图片数量是否与 CSV 行数一致、标签是否严重不均衡比如 NORMAL 有 2000 张DME 只有 200 张、有没有破损图片会在 DataLoader 中途抛异常。眼疾数据集中常见的问题是部分 OCT 图像是.tif格式而PIL默认可能不支持读取这时需要在环境中安装pillow并确认它编译时带了 TIFF 支持或者统一用 OpenCV 的cv2.imread转存为 PNG。3. ResNet50 在 PyTorch 里的正确打开方式预训练权重、替换分类头与训练脚本3.1 为什么 ResNet50 是这个任务的合适骨架ResNet50 在眼疾分类项目里成为默认选择不是因为它在 ImageNet 上刷分最高而是它恰好卡在“模型容量够用、显存占用可控、预训练权重易得”这三者的平衡点。ResNet18 的 5 个 stage 只有 512 维输出对 OCT 图像里微小的渗出物或玻璃膜疣特征表达能力偏弱ResNet101 和 ResNet152 则会在 batch size 取 32 时直接吃掉 12GB 以上显存而且医疗影像数据集往往只有几千张深层模型在小样本上更容易过拟合。ResNet50 的 bottleneck 结构1x1 降维 3x3 卷积 1x1 升维在保持参数量的同时加深了网络层数预训练权重在torchvision里一行代码即可加载这些都是实际训练时最省心的点。从 ResNet50 的提出论文来看残差连接解决的是深层网络退化问题。在眼疾分类语境下这意味着模型能把“病灶特征层”和“正常组织结构层”的信息跨层传递不至于因为网络加深而丢失边缘纹理。实际使用中启用 ImageNet 预训练权重可以在 3000 张左右的小数据集上把收敛 epoch 从 60 压到 25 上下且最终准确率通常比随机初始化高 8~12 个百分点。3.2 train.py 核心代码加载 ResNet50 并替换全连接分类头下面是训练脚本里最关键的部分完整度足够直接运行但参数你应当根据实际数据量调整import torch import torch.nn as nn import torch.optim as optim from torch.utils.data import DataLoader from torchvision import models from dataset import EyeDiseaseDataset, train_transform, val_transform device torch.device(cuda if torch.cuda.is_available() else cpu) num_classes 4 # 根据实际标签数量修改 # 加载 ImageNet 预训练权重 weights models.ResNet50_Weights.IMAGENET1K_V1 model models.resnet50(weightsweights) # 替换最后一层全连接输出维度改为分类数 in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(p0.3), nn.Linear(in_features, 256), nn.ReLU(inplaceTrue), nn.Dropout(p0.2), nn.Linear(256, num_classes) ) model model.to(device) # 冻结前三个 stage只训练 stage4 和分类头小数据集防过拟合 for name, param in model.named_parameters(): if layer4 not in name and fc not in name: param.requires_grad False train_dataset EyeDiseaseDataset(data/train.csv, transformtrain_transform, is_trainTrue) val_dataset EyeDiseaseDataset(data/val.csv, transformval_transform, is_trainFalse) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4, pin_memoryTrue) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4, pin_memoryTrue) criterion nn.CrossEntropyLoss(label_smoothing0.1) optimizer optim.AdamW(filter(lambda p: p.requires_grad, model.parameters()), lr3e-4, weight_decay1e-4) scheduler optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max30) for epoch in range(30): model.train() running_loss 0.0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() # 验证 model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) total labels.size(0) correct (preds labels).sum().item() val_acc 100.0 * correct / total print(fEpoch [{epoch1}/30] Loss: {running_loss/len(train_loader):.4f} fVal Acc: {val_acc:.2f}%) scheduler.step()参数说明label_smoothing0.1是对抗医疗影像标签噪声的常用手段如果某个 OCT 样本本身处于疾病早期标注者可能犹豫不决平滑标签让模型输出概率不过度自信最后保存模型时泛化性更好。冻结前三个 stage 是本方案在小数据上稳定收敛的关键因为 ImageNet 预训练的 layer1~layer3 已经能提供通用边缘和纹理特征眼疾病灶往往只激活 layer4 的语义抽象如果你数据量超过 10000 张可以把requires_grad改成所有层都为 True并降低 lr 到 1e-4 做全量微调。3.3 训练超参数速查表参数建议值说明输入尺寸224x224ResNet50 的默认输入与预训练权重一致batch size328GB 显存/ 6412GB 以上显存不够时优先降到 16 而不是调小图片优化器AdamW相比 Adam 能更好处理 weight decay 与梯度解耦初始学习率3e-4冻结前 3 层/ 1e-4全量微调如果 loss 震荡降到 1e-4权重衰减1e-4眼疾数据量小过拟合远比欠拟合常见学习率调度CosineAnnealingLRT_maxepochs收敛更平滑比 StepLR 少调 gamma 的麻烦损失函数CrossEntropyLoss label_smoothing0.1类别极度不平衡时改用带 weight 的版本Epoch30冻结/ 50全量微调用验证集早停保存最佳而非最后一个 epoch3.4 训练时最容易被追问的三个坑第一个坑是显存不足。8GB 显卡跑 ResNet50 batch size 64 一定会 OOM但不要为了塞进 batch 而把输入图缩到 160x160这会直接降低病灶识别率。常见做法是 batch size 降到 16并配合梯度累积模拟 64 的 batchaccumulation_steps 4 optimizer.zero_grad() for i, (images, labels) in enumerate(train_loader): images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) / accumulation_steps loss.backward() if (i 1) % accumulation_steps 0: optimizer.step() optimizer.zero_grad()第二个坑是类别不平衡。如果 CSV 里 NORMAL 占 60%DME 只占 10%直接训练会让模型把所有样本都判成 NORMAL。除了常见的WeightedRandomSampler更稳妥的办法是在损失函数里传入类别权重import torch.nn.functional as F class_weights torch.tensor([1.0, 2.5, 2.5, 1.2]).to(device) # 根据频次倒数归一化 criterion nn.CrossEntropyLoss(weightclass_weights, label_smoothing0.05)第三个坑是 PyTorch 版本之间的 AMP 接口差异。旧代码里常见的torch.cuda.amp.autocast()在 PyTorch 2.x 已推荐写成torch.autocast(device_typecuda, dtypetorch.float16)如果你跑的是组合安装包比如 python 3.10 pytorch 2.8.0 cuda 12.1使用旧写法虽然仍兼容但会有 deprecation 警告。为避免答辩时被问到“为什么有 warning”建议直接用新 API。4. 让模型“讲理”用指标、混淆矩阵和 Grad-CAM 支撑你的高分结论4.1 眼疾分类的指标不能只看 accuracy在类别不平衡的眼疾数据上95% 的 accuracy 可能只是因为 NORMAL 占绝大多数。答辩或面试时能被追问深度的指标是宏平均 F1、加权 F1、AUC 以及每类的 sensitivity / specificity。下面这段 evaluate.py 直接输出这些指标并保存到 CSVimport torch import numpy as np import pandas as pd from sklearn.metrics import (confusion_matrix, classification_report, roc_auc_score) from dataset import EyeDiseaseDataset, val_transform from torch.utils.data import DataLoader import torch.nn.functional as F # 加载模型后 model.eval() all_preds [] all_labels [] all_probs [] with torch.no_grad(): for images, labels in val_loader: images images.to(device) outputs model(images) probs F.softmax(outputs, dim1) all_probs.append(probs.cpu().numpy()) all_labels.extend(labels.numpy()) all_preds.extend(torch.argmax(outputs, dim1).cpu().numpy()) y_true np.array(all_labels) y_pred np.array(all_preds) y_prob np.vstack(all_probs) cm confusion_matrix(y_true, y_pred) print(Confusion Matrix:\n, cm) # 多分类 AUC 使用 one-vs-rest 宏平均 auc_macro roc_auc_score(y_true, y_prob, multi_classovr, averagemacro) print(fMacro AUC: {auc_macro:.4f}) report classification_report(y_true, y_pred, target_namestrain_dataset.classes, output_dictTrue, zero_division0) df_report pd.DataFrame(report).transpose() df_report.to_csv(evaluation_report.csv, indexTrue)逻辑说明classification_report输出了每一类的 precision、recall、f1-score这三项直接对应医学诊断里“漏诊”和“误诊”的含义——recall 低表示这类病很多被漏掉了precision 低表示正常眼被误判为病变。AUC 多分类采用的是 one-vs-rest 宏平均它衡量的是模型在不同类别之间排序能力不受分类阈值影响。如果发现某个类别的 precision 和 recall 差距过大说明你的模型在该类上偏向保守或激进需要回到数据处理层面看是不是样本太少或增强过度。4.2 混淆矩阵的可视化与解读用 matplotlib 将混淆矩阵画出来能直接放进答辩 PPT。建议用归一化后的混淆矩阵避免被大类别数字干扰判断import matplotlib.pyplot as plt cm_normalized cm.astype(float) / cm.sum(axis1, keepdimsTrue) fig, ax plt.subplots(figsize(8, 6)) im ax.imshow(cm_normalized, cmapBlues) ax.set_xticks(range(len(train_dataset.classes))) ax.set_yticks(range(len(train_dataset.classes))) ax.set_xticklabels(train_dataset.classes, rotation45) ax.set_yticklabels(train_dataset.classes) for i in range(len(train_dataset.classes)): for j in range(len(train_dataset.classes)): ax.text(j, i, f{cm_normalized[i, j]:.2f}, hacenter, vacenter, colorblack) plt.xlabel(Predicted) plt.ylabel(True) plt.title(Normalized Confusion Matrix) plt.tight_layout() plt.savefig(confusion_matrix.png, dpi150)图中对角线越接近 1 越好。如果某一行有明显横向分布说明该类被系统性误判为另一类这在眼疾里通常是两个疾病在 OCT 影像上形态相似比如 DME 和 DRUSEN 都有视网膜下积液的特征这时应该考虑增加该类别的样本量或针对病灶区域做局部增强。4.3 Grad-CAM回答“模型根据什么判断这是 DME”医疗影像分类项目里模型的可解释性比准确率更能拉开项目档次。Grad-CAM 通过计算类别得分对最后一个卷积层特征图的梯度得到每个空间位置的注意力权重从而定位模型关注的区域。不需要手写完整的 hook 逻辑直接使用pytorch-grad-cam能省下不少代码量pip install grad-camfrom pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image from PIL import Image import numpy as np import torchvision.transforms as transforms # 定位到 resnet50 的 layer4[-1] 作为目标层 target_layers [model.layer4[-1]] cam GradCAM(modelmodel, target_layerstarget_layers, use_cudatorch.cuda.is_available()) # 预处理单张图 img Image.open(data/images/OCTID_1001.png).convert(RGB) transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(img).unsqueeze(0).to(device) # 指定要解释的类别索引比如 1 对应 DME target_category 1 grayscale_cam cam(input_tensorinput_tensor, target_categorytarget_category) grayscale_cam grayscale_cam[0, :] visualization show_cam_on_image( np.array(img.resize((224, 224))) / 255.0, grayscale_cam, use_rgbTrue )解释时的要点ResNet50 最后一个 stage 输出的特征图是 7x7 分辨率224 输入经过 5 次下采样Grad-CAM 生成的热力图会先上采样回原图大小。如果热力图高亮区域集中在视网膜层间位置而不是背景说明模型学到了真实的病理位置如果高亮区域散布在图像边缘或整体泛红说明模型在走捷径比如根据扫描仪的水印或周边伪影分类这时必须检查数据清洗是否彻底。5. 部署与验收把模型导出 ONNX准备好一份经得起检查的交付物5.1 用 ONNX 导出 ResNet50摆脱 PyTorch 环境依赖一个经常被问到的场景是“你的模型怎么给别人用”。答辩现场未必有 GPU也没有完整的 PyTorch 环境此时把模型导出为 ONNX配上onnxruntime 推理是整个项目完整性的体现。导出脚本如下import torch from torchvision import models model models.resnet50(weightsNone) model.fc torch.nn.Linear(model.fc.in_features, 4) checkpoint torch.load(checkpoints/best_model.pth, map_locationcpu) model.load_state_dict(checkpoint[model_state_dict]) model.eval() # 构造与训练时一致的输入 dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, resnet50_eye.onnx, opset_version12, input_names[input], output_names[logits], dynamic_axes{input: {0: batch_size}, logits: {0: batch_size}} ) print(ONNX export complete.)参数说明opset_version12是一个兼容性和算子支持都比较稳妥的版本更高的 opset比如 17在某些老设备上可能不支持。dynamic_axes把 batch 维度设为动态这样导出后既可以单张推理也可以批量处理。注意导出前必须调用model.eval()否则 dropout 和 batchnorm 的统计量会使用训练模式导致同一张图在 PyTorch 和 ONNX 里输出不一致。5.2 单文件推理脚本输入图片路径输出类别和置信度在onnxruntime环境里推理代码只有 20 行左右适合作为交付物的一部分import onnxruntime as ort from PIL import Image import numpy as np import torchvision.transforms as transforms classes [CNV, DME, DRUSEN, NORMAL] def predict_image(model_path, image_path): sess ort.InferenceSession(model_path, providers[CPUExecutionProvider]) img Image.open(image_path).convert(RGB) transform transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) input_tensor transform(img).unsqueeze(0).numpy() outputs sess.run(None, {input: input_tensor}) logits outputs[0] probs np.exp(logits) / np.sum(np.exp(logits), axis1, keepdimsTrue) pred_idx int(np.argmax(probs[0])) return classes[pred_idx], float(np.max(probs[0])) cls, conf predict_image(resnet50_eye.onnx, data/images/OCTID_1001.png) print(fPrediction: {cls}, Confidence: {conf:.4f})这段代码值得注意的地方是 softmax 实现写成了显式的 numpy 公式而非scipy.special.softmax目的就是减少依赖。考虑实际交付时可以把这一块包进 Flask 或 FastAPI 做 HTTP 接口但项目源码里保留这个纯函数版本在答辩现场演示更方便。5.3 提交前的自查清单打分和面试官最烦的不是模型效果差而是“跑不起来”和“说不清”。对照下面清单逐项确认requirements.txt锁定核心版本包括 torch、torchvision、pandas、onnxruntime、grad-cam注明 Python 版本建议 3.9~3.11。检查README.md是否包含数据目录说明和训练命令。训练命令写清楚python src/train.py --epochs 30 --batch_size 32 --lr 3e-4 --freeze_layers 3确认随机种子已固定保证复现结果一致。模型最佳权重和最后 5 个 epoch 的权重分别保存方便对比过拟合程度。如果时间允许再补一个小实验比较 ResNet50 随机初始化与 ImageNet 预训练在验证集上的指标差异这通常是答辩中加分最多的对比实验。最后提醒把 Grad-CAM 输出图和混淆矩阵图放进项目根目录的results/文件夹提交时一并打包这样对方解压 zip 后能第一眼看到项目的完整交付物不需要先跑代码才知道项目做了什么。本文还有配套的精品资源点击获取