简介:一套基于Python+Django与卷积神经网络CNN的糖尿病视网膜病变识别系统毕业设计项目,面向软件工程、人工智能、电子信息等计算机相关专业的在校学生、教师或初级开发者,解决医学图像分类任务从模型训练到Web端部署的完整落地问题。压缩包共含2000个文件,大小约28.1MB,其中1416个py文件覆盖CNN模型搭建、数据预处理与Django后端逻辑,156个html、271个js及102个css等构成前端交互界面,45个txt及模型、说明文档提供使用指引与训练成果,整体目录层次清晰。目前已有152人学习/浏览该项目。源码经运行验证且获导师指导认可,答辩评审分达95分;配套说明文档与数据资料可帮助理解糖尿病视网膜病变识别的实现思路,也便于在此基础上扩展功能、替换数据集或调整网络结构,能直接支撑毕业设计、课程设计及项目初期演示等场景。
1. 从一张眼底照片到 DR 分级,这套系统替你做了什么
糖尿病视网膜病变(DR)是全球范围内导致成年人失明的主要眼病之一。临床上,医生通过眼底彩照观察微血管瘤、出血点、渗出物等病灶,再按照国际分级标准把它分成 0 到 4 级。这个流程高度依赖阅片经验和耐心,基层眼科门诊一天几十张片子看下来,漏诊率并不低。这套毕业设计资源把整个流程做成了三个可以交付的产物:一份基于 Python 的 CNN 分类模型、一个基于 Django 的 Web 识别系统,以及从数据处理到部署说明的完整文档。换言之,你拿到的不只是能跑的代码,而是一条从"上传眼底图"到"输出 DR 等级与置信度"的完整链路。它适合三类人:做图像分类毕业设计的学生、想学 Django 如何承载机器学习模型的开发者,以及想快速改造成其他医学图像分类任务的人。
2. CNN 模型与眼底图像的特征提取链路
2.1 为什么 CNN 能识别视网膜病灶
眼底图像里的病灶是典型的局部纹理差异:微血管瘤表现为暗红小点,出血区是形态不规则的暗色团块,硬性渗出是黄白色亮斑。传统方案用形态学算子提取这些特征再交给 SVM,特征工程成本高,换一台相机拍出来的图分布一变,准确率立刻下滑。CNN 的优势在于把"特征设计"这件事交给卷积核:浅层卷积核响应边缘、色块、圆斑,深层卷积核把这些局部模式组合成更抽象的病征组合;池化又让网络对病灶位置不敏感,适应"病灶可能出现在眼底任何象限"的实际情况。
这一类任务里常用的基础结构是先堆叠卷积块,每个卷积块包含卷积、批归一化和池化,最后用全局平均池化接全连接层输出分类概率。批归一化在这里效果明显:眼底图像受拍摄设备影响,亮度与色差变化大,BN 能压低层间分布偏移,让训练收敛速度明显加快。
2.2 图像归一化与数据增强
2.2.1 尺寸与通道处理
模型输入固定为 224×224×3,但原始眼底照片通常接近 2000×3000 甚至更大,直接缩放会丢失细小的微血管瘤特征,所以缩放策略比普通分类任务更讲究。合理的做法是先用 OpenCV 读图,裁剪掉四周黑色边框,保留视盘和血管弓区域,再缩放到模型输入尺寸。
import cv2 import numpy as np def preprocess(path, target_size=(224, 224)): # 读入 BGR 图像后转为 RGB,保证通道顺序与训练一致 img = cv2.imread(path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # 去掉眼底照片常见的黑边,避免黑色区域干扰归一化统计 gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) _, mask = cv2.threshold(gray, 5, 255, cv2.THRESH_BINARY) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) if contours: x, y, w, h = cv2.boundingRect(contours[0]) img = img[y:y + h, x:x + w] # INTER_AREA 对缩小图像更平滑,能保留更多病灶边缘信息 img = cv2.resize(img, target_size, interpolation=cv2.INTER_AREA) img = img.astype(np.float32) / 255.0 # ImageNet 统计均值与标准差,配合预训练权重使用时不要改 mean = np.array([0.485, 0.456, 0.406], dtype=np.float32) std = np.array([0.229, 0.224, 0.225], dtype=np.float32) img = (img - mean) / std return img几个需要留意的参数。target_size决定模型输入分辨率,224 是速度和精度的折中;想把微血管瘤看得更清楚可以提到 299,但显存占用和推理耗时都会上升。cv2.threshold的阈值 5 是为了去掉视野外的纯黑像素,而不是去掉低照度的病灶区域。缩放插值选INTER_AREA,因为它对缩小图像做像素区域平均,比INTER_LINEAR更不容易产生高频伪影。最后一步按 ImageNet 统计做标准化,如果后续要加载预训练权重,这一步的均值方差必须保持一致,否则第一层卷积的输入分布就是错的。
2.2.2 与图像增强配合的 CLAHE 对比度增强
DR 数据集的对比度差异主要来自采集设备,同一个病变在不同品牌相机下拍出来的亮度与对比度可能差很远。图像增强 CNN 算法里常用 CLAHE 来压平这种差异,它的特点是只增强局部区域的对比度,不会像全局直方图均衡那样把噪声一起放大。
def clahe_pipeline(img): # 输入为 RGB、float32、0~1 范围的图像 img = (img * 255.0).astype(np.uint8) lab = cv2.cvtColor(img, cv2.COLOR_RGB2LAB) clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) # 只对 L 亮度通道做均衡,保留 a/b 颜色通道的原始色偏 lab[:, :, 0] = clahe.apply(lab[:, :, 0]) img = cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) return img.astype(np.float32) / 255.0clipLimit=2.0控制对比度放大的上限,值调大画面会更锐利,但也会让噪声更明显;眼底出血点和渗出区域本身偏暗或偏亮,噪声被放大后容易掩盖小型微血管瘤。tileGridSize=(8, 8)表示把图像切成 8×8 个小块分别做直方图均衡,块越小局部增强越强,也越容易出现块状伪影。这里常见的误用是对整张图做cv2.equalizeHist,眼底暗区会立刻过曝,病灶细节反而丢失。
2.3 模型结构与训练配置
这个资源里的基础模型是一个自建的轻量 CNN,没有直接套 VGG16 或 ResNet50,好处是参数少,CPU 上也能完成推理,适合毕业设计的演示环境。结构上采用卷积块叠加的方式,核心配置如下:
from tensorflow.keras import layers, models def build_cnn(input_shape=(224, 224, 3), num_classes=5): model = models.Sequential([ # 第一层需要显式传入输入形状,后续层自动推导 layers.Conv2D(32, (3, 3), activation='relu', padding='same', input_shape=input_shape), layers.BatchNormalization(), layers.MaxPooling2D((2, 2)), layers.Conv2D(64, (3, 3), activation='relu', padding='same'), layers.BatchNormalization(), layers.MaxPooling2D((2, 2)), layers.Conv2D(128, (3, 3), activation='relu', padding='same'), layers.BatchNormalization(), layers.GlobalAveragePooling2D(), layers.Dropout(0.5), layers.Dense(num_classes, activation='softmax') ]) return modelpadding='same'保证卷积不改变特征图尺寸,让池化层按 2 的倍数降采样。GlobalAveragePooling2D替代 Flatten 加全连接,直接把每个通道压成一个值,参数数量大幅下降,同时让网络对输入尺寸有更好的适应能力。Dropout(0.5)放在分类头前,防止全连接层把训练集的阅片习惯背下来。
训练参数是这类任务里最值得花时间调的环节,下面这份配置在多数 DR 数据集上都能直接落地:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| 优化器 | Adam | 初始学习率 1e-3,收敛速度比 SGD 明显更快 |
| 学习率调度 | ReduceLROnPlateau | patience=3,验证损失不降时学习率乘 0.5 |
| 损失函数 | categorical_crossentropy | DR 五分类任务默认选择 |
| 类别权重 | class_weight | 0 级样本通常占一半以上,按类别频率反比配置 |
| 批量大小 | 32 | 显存不够时降到 16,但注意 BN 在小 batch 下统计有偏差 |
| Epochs | 30 | 配合早停,不设死上限 |
2.4 损失函数与评估指标
严格说,DR 识别在竞赛里的标准评估指标是 Quadratic Weighted Kappa(QWK),它惩罚"离真实等级差太多"的预测。比如把 0 级判成 1 级,扣分远小于把 0 级判成 4 级。这个指标很贴合医学场景:漏掉一个早期病变和把一个正常眼判成重度,代价完全不同。
Keras 内置没有 QWK,需要自己实现。常见的写法是:
from sklearn.metrics import cohen_kappa_score def qwk(y_true, y_pred): # y_pred 是模型输出的 argmax 结果,类别取 0~4 return cohen_kappa_score(y_true, y_pred, weights='quadratic')cohen_kappa_score的weights='quadratic'就是竞赛评分标准,取值范围从 -1 到 1。DR 数据集中 QWK 超过 0.8 已经算很强的模型。训练时可以用 accuracy 观察收敛,但最终答辩和对比实验要优先以 QWK 为准。
3. Django 把训练好的 CNN 模型搬进浏览器
3.1 MVT 请求链路与模型加载策略
Django 的项目壳在这里只承担一件事:接收上传的眼底照片,把图片喂给 CNN 模型,再把预测结果渲染到页面。整个链路是浏览器 POST 表单到urls.py路由,路由分发给 views 里的视图函数,视图操作 ORM 模型存储记录,最后把上下文交给模板渲染。
模型加载策略是这块最需要关注的点。如果把load_model写在视图函数内部,每来一个请求都会把整个权重从头读一遍,一张眼底图预测要等几秒钟,页面长时间白屏。合理的做法是模块级单例加载,进程启动时只加载一次:
# model_loader.py from tensorflow.keras.models import load_model as keras_load from django.conf import settings _model = None def get_model(): global _model if _model is None: _model = keras_load(settings.MODEL_PATH) return _model_model是模块级全局变量,第一次调用时加载权重,之后复用同一份模型对象。注意 Django 开发服务器默认开启 autoreload,代码修改后会自动重启进程,模型会被重新加载一次。h5 文件比较大时,每次保存代码后等几秒属于正常现象,不必担心。
3.2 上传接口与预测视图
核心视图代码一般长这样:
import os from django.shortcuts import render from django.conf import settings from .models import PredictionRecord from .ml.preprocess import preprocess from .ml.model_loader import get_model def predict_view(request): if request.method == "POST": image_file = request.FILES.get("image") if not image_file: return render(request, "predict/error.html", {"msg": "请上传眼底照片"}) # 用 chunks() 逐块写盘,避免大图一次性读入内存 save_path = os.path.join(settings.MEDIA_ROOT, image_file.name) with open(save_path, "wb+") as f: for chunk in image_file.chunks(): f.write(chunk) model = get_model() img = preprocess(save_path) probs = model.predict(img[None, ...])[0] pred_label = int(probs.argmax()) confidence = float(probs[pred_label]) record = PredictionRecord.objects.create( image=image_file.name, label=pred_label, confidence=confidence ) return render(request, "predict/result.html", {"record": record}) return render(request, "predict/upload.html")几个容易被忽略的参数细节。image_file.chunks()是 Django 对上传文件的迭代器,默认每块 64KB,逐块写盘比直接read()整个文件更省内存,眼底原图动辄 10MB,这一步不能省。model.predict(img[None, ...])是在输入张量上加一维 batch 维度,CNN 要求输入是(batch, height, width, channels),漏掉会直接抛维度错误。probs.argmax()拿到 0 到 4 的类别索引,float(probs[pred_label])转成置信度,模板里可以直接渲染成百分比。
PredictionRecord表是识别记录的落库位置,字段设计如下:
| 字段 | 类型 | 用途 |
|---|---|---|
| image | CharField | 保存上传眼底照片的文件名或路径 |
| label | IntegerField | 0 到 4 的 DR 分级结果 |
| confidence | FloatField | 对应类别的 softmax 概率 |
| created_at | DateTimeField | 自动记录识别时间 |
3.3 路由、模板与静态资源组织
urls.py 里只需要一条路由:
from django.urls import path from . import views urlpatterns = [ path("", views.predict_view, name="predict"), ]模板里最关键的坑在表单的编码类型。上传文件必须显式声明enctype="multipart/form-data",否则request.FILES永远是空的:
<form method="post" enctype="multipart/form-data"> {% csrf_token %} <input type="file" name="image" accept="image/*" /> <button type="submit">开始识别</button> </form>accept="image/*"只是浏览器端的筛选,后端仍然要判断文件类型和大小,因为可以直接用工具构造请求绕过前端限制。常见做法是在视图里检查content_type并限制image_file.size不超过 20MB。
这个资源的后端还带了一套后台管理界面,里面引用了table.css、date-picker.css、transfer.css等组件样式。这类样式表通常是从通用后台模板里整体复制进来的,实际用到的可能只有其中几类,改项目时可以把没引用的 CSS 删掉,减少页面加载时间。
3.4 依赖清单与启动命令
项目的依赖集中在requirements.txt,从用途上可以拆成 Web 框架、深度学习框架和图像处理三部分:
Django>=4.2 tensorflow>=2.10 opencv-python>=4.8 numpy pillow启动流程如下:
pip install -r requirements.txt python manage.py makemigrations predict python manage.py migrate python manage.py runserver 0.0.0.0:8000makemigrations和migrate是为了把PredictionRecord表同步到 SQLite 数据库。第一次跑项目的人经常卡在pip install tensorflow下载困难的问题上,可以先确认 Python 版本再选对应 wheel。python manage.py runserver默认只监听本机 127.0.0.1,局域网里其他机器要访问时,0.0.0.0:8000这个参数不能漏。
4. 数据准备、模型训练与一次完整记录
4.1 数据集目录与类别分布
这个资源的全部数据资料里,眼底图像数据预先按照 DR 分级分好了目录,典型的组织方式如下:
data/ train/ 0_no_dr/ 1_mild/ 2_moderate/ 3_severe/ 4_proliferative/ val/ 0_no_dr/ ... test/ 0_no_dr/ ...训练集和验证集按患者维度切分,而不是按图片维度切分。这一点在 DR 场景里非常关键:同一个病人的左右眼图像高度相似,如果一张进训练集、另一张进验证集,验证指标会虚高,真到新患者身上准确率明显下滑。切分时优先按病人的文件夹 ID 做分组随机,而不是直接对图片路径做train_test_split。
4.2 训练脚本的数据流
数据流上,常见的实现是ImageDataGenerator配合flow_from_directory,一行代码自动完成标签读取、数据增强、批量打包:
from tensorflow.keras.preprocessing.image import ImageDataGenerator train_gen = ImageDataGenerator( rotation_range=15, width_shift_range=0.1, height_shift_range=0.1, zoom_range=0.1, horizontal_flip=True, brightness_range=(0.8, 1.2), preprocessing_function=clahe_pipeline ) train_flow = train_gen.flow_from_directory( "data/train", target_size=(224, 224), batch_size=32, class_mode="categorical", shuffle=True )rotation_range=15表示在正负 15 度范围内随机旋转。眼底图像不像自然图像有严格的"上下"概念,旋转增强不会产生语义错误。width_shift_range和height_shift_range负责平移,模拟视盘不在正中心的情况;brightness_range=(0.8, 1.2)把亮度随机乘 0.8 到 1.2 倍,对应不同相机设备的曝光差异。preprocessing_function=clahe_pipeline是把 CLAHE 增强挂到数据流里,训练集和验证集都需要走同一套预处理,但验证集不应加随机旋转和翻转,否则指标不稳定。
验证集使用单独的ImageDataGenerator(rescale=1.0/255),保证每个 epoch 看到的验证样本完全一致,loss 曲线才能真实反映模型状态。
4.3 训练参数与验证指标
训练过程中的回调配置决定了模型能不能在最佳点停下来:
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau callbacks = [ EarlyStopping(monitor="val_loss", patience=8, restore_best_weights=True), ModelCheckpoint("best_model.h5", monitor="val_qwk", mode="max", save_best_only=True), ReduceLROnPlateau(monitor="val_loss", factor=0.5, patience=3, min_lr=1e-6) ]EarlyStopping的patience=8意味着连续 8 个 epoch 验证损失不下降就停止,restore_best_weights=True保证恢复到验证集最优的那一轮权重。ModelCheckpoint按val_qwk保存最佳模型,这里要配合 2.4 节自定义的 QWK 指标注册到 Keras 里。ReduceLROnPlateau在验证损失停滞 3 轮后把学习率减半,让训练后期可以更精细地逼近最优解。
评估阶段除了 accuracy,还要看每个类别的 precision 和 recall。DR 数据集有个典型现象:0 级样本占比超过 60%,模型全预测成 0 级也能拿到接近 0.6 的准确率,但这个模型没有任何临床价值。下面这个表是这个任务里常用的指标关注点:
| 指标 | 关注原因 | 达到什么值算可用 |
|---|---|---|
| QWK | 与阅片医生分级的一致性 | 高于 0.75 |
| 0 级召回率 | 避免正常眼被误判为病变 | 高于 0.90 |
| 4 级召回率 | 增殖期病变漏诊后果严重 | 高于 0.80 |
| 平均准确率 | 整体判断能力参照 | 高于 0.78 |
4.4 训练日志分析与一次完整记录
实际训练时输出会逐 epoch 滚动,下面是一段典型的收尾日志:
Epoch 12/30 170/170 [==============================] - 38s 224ms/step - loss: 0.4319 - acc: 0.8420 - val_loss: 0.5201 - val_acc: 0.8010 Epoch 13/30 170/170 [==============================] - 38s 224ms/step - loss: 0.4251 - acc: 0.8460 - val_loss: 0.5588 - val_acc: 0.7950第 12 轮之后训练损失还在下降,但验证损失从 0.52 涨到 0.55,这是典型的过拟合信号,说明模型开始记住训练集中特定眼底照片的拍摄噪声。此时ReduceLROnPlateau会把学习率从 1e-3 降到 5e-4,如果接下来几个 epoch 验证损失仍然回升,EarlyStopping在 patience 期满后自动停在最佳权重。
另外一个容易误导人的细节是训练日志里acc偏高、val_acc偏低。DR 五分类中相邻类别的图像差异很小,模型预测成相邻等级也属于"可接受错误",accuracy 把所有错误一视同仁,所以会感觉比人工阅片的准确率低很多。这也是为什么最终对比实验一律以 QWK 为准,而不是拿 accuracy 去和医生的阅片准确率直接比较。
5. 让识别系统更像一个可交付的产品
5.1 从开发服务器到生产部署
runserver只适合开发调试,它单进程且没有并发优化。更实用的方案是 gunicorn 作为 WSGI 进程管理器:
pip install gunicorn gunicorn project_name.wsgi:application -b 0.0.0.0:8000 --workers 3 --timeout 120--workers 3的取值一般按 CPU 核心数加 1 来配,CNN 推理是 CPU 密集型任务,worker 超过核心数反而会因为频繁切换上下文而变慢。--timeout 120要调大,因为模型首次加载和一张大图的预处理可能超过默认的 30 秒。如果前面挂了 nginx,注意把上传文件大小限制同步调大,nginx 默认client_max_body_size 1m,眼底原图经常超过这个值,直接导致上传失败。用宝塔面板部署 Django 的底层逻辑也是配置 Python 虚拟环境和这条 gunicorn 启动命令,只是把操作挪到了网页上。
5.2 推理延迟测量与模型压缩
模型能跑只是第一步,答辩和演示时最常见的问题是页面转圈太久。建议在预测视图里包一层计时逻辑:
import time start = time.time() probs = model.predict(img[None, ...])[0] elapsed = time.time() - start print(f"single inference: {elapsed:.3f}s")单张推理超过 1 秒就需要考虑压缩。优先检查是否真的需要 224 以上的输入尺寸,如果训练时用的是 224,推理时强行改成 299 反而会因为尺寸分布不一致导致准确率下降。更实用的方向是替换网络主干,把自建 CNN 换成 MobileNetV3 或 EfficientNet-Lite,同一批数据下推理时间通常能缩一半以上,准确率损失在 2 个百分点以内。这个资源里自带的模型在 CPU 上推理一张图大约在 0.3 到 0.6 秒之间,属于可接受的演示范围。
5.3 数据泄露防范与可视化验证
如果要在这个项目基础上做优化实验,最值得补的一课是划分数据集时按患者分组。用 scikit-learn 的GroupShuffleSplit可以一次完成:
from sklearn.model_selection import GroupShuffleSplit gss = GroupShuffleSplit(n_splits=1, test_size=0.2, random_state=42) for train_idx, val_idx in gss.split(images, labels, groups=patient_ids): train_images, val_images = images[train_idx], images[val_idx]groups=patient_ids会保证同一个病人的所有图像只出现在训练集或只出现在验证集,避免左右眼图像跨集合泄露。做完这一步再对比原来的切分方式,通常会发现 QWK 下降 0.05 到 0.1,这才是模型在新患者身上的真实水平。最后,答辩展示时可以结合 Grad-CAM 热力图看模型关注了哪些区域,如果热力图集中在黄斑或视盘位置而不是出血斑块,说明模型学到的是拍摄设备的光线模式,需要回到数据层做更彻底的颜色归一化。
本文还有配套的精品资源,点击获取