基于CNN的农作物病虫害识别:从数据增强到Flask部署的完整Python工程 简介面向计算机专业毕业生、课程设计学生及算法学习者提供基于深度学习卷积神经网络的农作物病虫害识别检测系统完整源码与运行说明。项目聚焦农业生产中的病虫害识别难题覆盖图像预处理、模型训练、评估与部署全流程帮助读者直观掌握CNN在图像分类任务中的工程实践。压缩包共56个文件包含Jupyter Notebook训练脚本、Python源码、训练好的模型权重pkl、Docker及云平台部署配置、Web可视化页面等整体约88MB。目前已有111人学习下载。资料附详细运行说明并以ResNet50、VGG16及PyTorch、TensorFlow等多框架对照实验呈现便于迁移复用既适合作为高分毕业设计展示也可作为图像识别入门到实战的理想素材。1. 农田里的 CNN这套病虫害识别源码解决的不只是分类问题农作物病虫害识别难点不在“分类”而在“现场”。一张照片可能有光照不均、叶片重叠、病斑和虫害共存且各类别样本数往往悬殊。基于深度学习卷积神经网络的这套 Python 源码把从数据预处理到模型训练再到 Web 推理的完整链路都做成了可运行工程多个框架的 NotebookKeras、TensorFlow、PyTorch、Fastai加上 Flask 服务和部署配置适合做毕业设计、课程设计也适合想快速验证 CNN 在实际图像分类上效果的人。源码里值得看的不是某个网络本身而是它怎么组织实验、怎么把模型暴露成 HTTP 接口以及不同骨干网络在同一份数据下的表现差异。对于手头有少量标注图像、想尽快跑通一个基线系统的开发者来说这份代码的目录结构就是一个可复用的项目模板。2. 数据处理与增强让卷积神经网络在小样本病害图像上不过拟合2.1 为什么预处理直接决定训练能不能收敛所有使用 ImageNet 预训练模型的代码第一件事就是把输入缩放到模型要求的尺寸然后按mean[0.485, 0.456, 0.406]、std[0.229, 0.224, 0.225]做标准化。原因在于预训练卷积层学到的是一套特定的像素分布如果直接喂原始图像第一层卷积的输出容易落在非线性区间的边缘导致训练初期 loss 震荡。这个项目里多个 notebook 都遵循同样的预处理链路说明作者在组织数据时已经把“预训练权重 标准化”当作默认约束。数据集的多样性在项目说明里被明确强调。不同病虫害类别、不同生长阶段、不同拍摄角度都要覆盖模型才不至于“记住”了叶片纹理而没学到病斑形状。预处理阶段先做图像大小标准化再降噪再做数据增强。降噪在农业场景里主要去除传感器噪点和压缩伪影常见做法是高斯模糊或中值滤波但需要注意卷积神经网络对轻微噪声本身有鲁棒性过度降噪反而会抹掉病斑边缘所以实际项目中降噪通常不是单独一步而是放在缩放之后、归一化之前并且只在灰度通道上做轻处理。下面是一套在多数农作物病害数据集上能直接用的增强配置Keras 和 PyTorch 都有对应实现。注意这些参数不是拍脑袋定的而是围绕田间拍摄环境里的光照变化、叶片朝向和病斑尺度来的具体到每个类别还要看增强后的图像是否保留病斑关键特征。# TensorFlow / Keras 版本 from tensorflow.keras.preprocessing.image import ImageDataGenerator datagen ImageDataGenerator( rotation_range20, # 随机旋转 ±20° width_shift_range0.1, # 水平平移 10% height_shift_range0.1, # 垂直平移 10% shear_range0.1, # 错切变换强度 zoom_range0.2, # 随机缩放 0.8~1.2 horizontal_flipTrue, # 水平翻转 vertical_flipFalse, # 叶片有朝向性谨慎开启 fill_modenearest, # 填充新像素的方式 brightness_range[0.8, 1.2],# 亮度扰动模拟阴天/强光 preprocessing_functionNone # 可以挂自定义归一化 )# PyTorch 版本 import torch from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.8, 1.0)), transforms.RandomRotation(20), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])参数说明rotation_range20只做小角度旋转因为农作物叶片在图像里通常有自然朝向旋转过大可能把“垂叶病”变成“正常叶”zoom_range0.2模拟不同拍摄距离brightness_range和ColorJitter解决田间光照不均匀的问题但这部分增强强度不宜过高否则会引入颜色失真让模型误把叶绿素含量变化当成病害特征vertical_flipFalse是有意为之。很多叶片病害在正反面表现不同比如锈病孢子堆集中在背面若随意翻转模型会学到“反正都一样”导致真实场景下对叶片正反面无区分能力。增强参数推荐区间典型问题rotation_range15~20°过大会让叶片朝向失真zoom_range0.1~0.2过大会裁到背景ColorJitter / brightness0.1~0.2过强会改变病斑颜色vertical_flip关闭正反面病害差异大时不能开对于样本量少的类别不要盲目把增强倍数拉到几十倍。常见做法是先用RandomResizedCrop配合水平翻转观察训练集和验证集 loss 的差距。如果验证集 loss 一开始就高于训练集先检查是否数据泄露比如同一株植物的多张图片同时出现在训练和验证集里再考虑增强。2.3 数据集划分要按“地块”而不是按“图片”一个容易踩的坑用train_test_split随机划分图像会把同一块农田、同一时期拍摄的相似图片同时分进训练集和验证集导致验证准确率虚高。这套源码的 README 和 notebook 里建议按图像目录或者按采集批次划分保证验证集里的病害形态是模型没见过的。实际操作时可以在数据加载阶段读文件路径提取路径中的field_id或date字段做分层抽样。import pandas as pd from sklearn.model_selection import GroupShuffleSplit df pd.DataFrame(columns[path, label, field_id]) # ... 从数据集目录构造 DataFrame ... split GroupShuffleSplit(n_splits1, test_size0.2, random_state42) train_idx, val_idx next(split.split(df, groupsdf[field_id])) train_df, val_df df.iloc[train_idx], df.iloc[val_idx]逻辑说明GroupShuffleSplit会把同一field_id的所有图片都放到同一边防止验证集“偷看”同一个采集环境下的背景纹理。test_size0.2是常见比例如果总样本不足 5000建议只从中划出 10% 做验证留出更多训练数据。同时random_state42固定随机种子保证实验可复现这在写课程设计报告时尤其有用。3. 骨干网络选型VGG16、ResNet50、DenseNet121 在同一批数据上的对照思路3.1 为什么一个项目里会有五种框架的实现这个项目的根目录里放着Plant_Disease_VGG16.ipynb、Plant_Disease_VGG19.ipynb、Plant_Disease_RESNET50.ipynb、Plant_Disease_DenseNet121.ipynb再加上 TensorFlow、PyTorch、Keras、Fastai 四个框架的独立 notebook。乍一看像是重复造轮子实际上这是做毕业设计或课程设计时最稳妥的对照实验结构固定同一个数据集只改变骨干网络就能画出模型复杂度、训练时间和准确率之间的关系。对于病虫害识别这种细粒度图像分类任务VGG 这类纯卷积堆叠模型能提供很稳定的基线而 ResNet 和 DenseNet 的优势在于梯度流动和特征复用。选择哪个取决于手头的 GPU 显存和可接受的推理延迟。VGG16 有 1.38 亿参数ResNet50 有 2500 万左右DenseNet121 约 800 万参数量的差异直接决定了训练速度和过拟合风险。这里说的参数量是 ImageNet 分类头的数字换成自己的病虫害分类层后会有小幅变化但量级关系不变。3.2 用 PyTorch 微调预训练模型的标准写法在像 PlantVillage 这类公开数据集上一般不需要从零训练而是加载 ImageNet 预训练权重冻结前几层只训练最后的分类层和部分残差块。下面以 PyTorch 为例import torchvision.models as models import torch.nn as nn def build_resnet50(num_classes, freeze_bnTrue): model models.resnet50(pretrainedTrue) # 替换最后一层全连接 in_features model.fc.in_features model.fc nn.Sequential( nn.Dropout(0.3), nn.Linear(in_features, num_classes) ) if freeze_bn: # 冻结 BatchNorm 的统计量防止小 batch 下抖动 for module in model.modules(): if isinstance(module, nn.BatchNorm2d): module.eval() return model model build_resnet50(num_classes10)代码说明pretrainedTrue会加载在 ImageNet 上训练好的权重。model.fc.in_features取出原全连接层的输入维度再换成带 Dropout 的新分类头。freeze_bnTrue是关键冻结 BatchNorm 的运行均值和方差让它们在微调过程中不随当前 batch 更新否则当 batch size 较小时BN 统计量会剧烈波动模型在验证集上的表现时好时坏。Fastai 版本会更“暴力”一点直接cnn_learner(dls, resnet50, metricsaccuracy)适合快速看结果但要调参时还得理解底层 PyTorch 逻辑。Keras 版本可以用applications.ResNet50(weightsimagenet, include_topFalse, input_shape(224,224,3))然后加全局池化和 Dense 层。TensorFlow 版本几乎一样因为 Keras 就是它的高层 API。3.3 从参数表看不同骨干网络的取舍下面是我在这类项目里用来决策的对照表数据基于 ImageNet 预训练模型的公开指标实际训练时要结合自己的数据量看网络参数量输入尺寸特点适合的数据量VGG161.38 亿224x224结构简单容易过拟合大样本5万/类VGG191.43 亿224x224比 VGG16 多三层收益有限同 VGG16ResNet502500 万224x224残差连接训练稳定中等样本DenseNet121800 万224x224特征复用参数效率高中小样本参数说明VGG 系列参数集中在全连接层占 90% 以上其前几层卷积感受野较小适合捕捉叶片上的细微纹理但对计算资源要求高。ResNet50 通过残差块让梯度能直接回传到浅层所以即便层数加深微调时也更容易收敛。DenseNet121 的密集连接会把每一层的输出都送给后面所有层特征复用能力强在样本量有限时往往比 ResNet 更稳但显存占用会随网络深度增加。实际使用这套源码时建议先跑Plant_Disease_VGG16.ipynb得到一个基线准确率再用Plant_Disease_ResNet50.ipynb和Plant_Disease_DenseNet121.ipynb做对比。如果训练集只有几千张DenseNet121 大概率是最终选择如果数据达到数万张且你有 8GB 以上显存ResNet50 的推理速度优势会更明显。3.4 训练超参epoch、学习率与 Early Stopping源码里的多个 notebook 都展示了同一个策略先用小的学习率1e-4 或 1e-3微调全部层如果验证 loss 不再下降就降低学习率继续训练。卷积神经网络的 epoch 设置不是越多越好。有个简单的观察方法每个 epoch 结束后打印训练/验证准确率和 loss当训练 loss 持续下降而验证 loss 连续 5 个 epoch 不降就触发 early stopping并保存验证 loss 最低的模型权重。from torch.optim.lr_scheduler import ReduceLROnPlateau from early_stopping_pytorch import EarlyStopping early_stopping EarlyStopping(patience5, verboseTrue) scheduler ReduceLROnPlateau(optimizer, modemin, factor0.1, patience3) for epoch in range(50): train_one_epoch(model, train_loader, optimizer, criterion) val_loss, val_acc validate(model, val_loader, criterion) scheduler.step(val_loss) early_stopping(val_loss, model) if early_stopping.early_stop: print(fEpoch {epoch}: early stop) break这里的factor0.1表示验证 loss 连续 3 个 epoch 不降时学习率缩小为原来的十分之一patience5允许验证 loss 连续 5 个 epoch 不改善才停止。这两个参数组合起来能在 20~30 个 epoch 内把模型训练到收敛而不是机械地跑满 50 个 epoch。写论文时这个 early stopping 的触发点也可以作为实验记录的一部分能直接体现你对训练过程的理解。4. 从 Notebook 到 Web 服务Flask 推理接口与 Docker 打包4.1 为什么需要单独写一个 server.py训练只是开始。毕业设计要展示系统课程设计要交一个能运行的 demo这时候把模型封装成 Web 服务比写一堆 print 更有说服力。这个项目的目录里有一个清晰的app结构server.py放在其中职责只有两个加载训练好的权重、处理上传的图片并返回识别结果。不要在这里放训练逻辑训练和推理的依赖不同混在一起会带来不必要的内存占用和部署体积。Flask 是最适合做这种单模型推理服务的轻量框架。不用像 FastAPI 那样额外处理 Pydantic 响应模型也不需要一个完整的 Django 应用来承载分类接口。如果你的并发量只有几十 QPSFlask 加上waitress或gunicorn完全够用。4.2 一个能用的 Flask 推理接口长什么样下面是一个典型的server.py骨架它假设你已经在本地用 PyTorch 训练并导出了model_state_dict.pthimport io from flask import Flask, request, jsonify from PIL import Image import torch import torchvision.transforms as transforms from model import build_resnet50 app Flask(__name__) model None class_names [Healthy, Leaf Rust, Powdery Mildew] # 换成你的类别 def load_model(): global model model build_resnet50(num_classeslen(class_names)) state torch.load(model_state_dict.pth, map_locationcpu) model.load_state_dict(state[model]) model.eval() def preprocess_image(image_bytes): img Image.open(io.BytesIO(image_bytes)).convert(RGB) tfm transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) return tfm(img).unsqueeze(0) app.route(/predict, methods[POST]) def predict(): if image not in request.files: return jsonify({error: no image file}), 400 img_bytes request.files[image].read() tensor preprocess_image(img_bytes) with torch.no_grad(): logits model(tensor) prob torch.softmax(logits, dim1).squeeze() topk_idx torch.argsort(prob, descendingTrue)[:3] result { predictions: [ {class: class_names[i], score: float(prob[i])} for i in topk_idx ] } return jsonify(result) if __name__ __main__: load_model() app.run(host0.0.0.0, port5000)逻辑说明load_model()在服务启动时加载一次权重避免每个请求都重新读磁盘。preprocess_image把上传的字节流转成 PIL Image再走和训练时完全相同的Resize ToTensor Normalize流程这一点必须和训练脚本严格一致否则模型看到的输入分布和训练时不同识别结果会变得很差。torch.no_grad()关闭梯度计算能减少显存占用并提高推理速度。返回的 Top-3 结果比只返回一个类别更有用前端可以显示“可能是叶锈病置信度 0.82”而不是生硬地给出一个标签。4.3 Dockerfile 里的依赖管理这个项目提供了一个Dockerfile和.dockerignore说明作者已经考虑过环境迁移问题。常见的做法是先用 Python 3.9 或 3.10 镜像再装 PyTorch CPU 版本避免 GPU 镜像体积过大。# 推理阶段不需要 GPU用 CPU 版本可以减小镜像体积 FROM python:3.9-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple COPY app/ ./app/ EXPOSE 5000 CMD [gunicorn, --bind, 0.0.0.0:5000, --workers, 2, app.server:app]参数说明python:3.9-slim基础镜像体积小但缺少编译依赖如果requirements.txt里有一些需要源码编译的包可能得换成python:3.9或者加入 build-essential。--workers 2对单模型实例来说是合适的因为每个 worker 都会加载一份模型到内存开太多 worker 容易把内存打满。app.server:app表示导入app/server.py里的 Flask 实例app。项目里有 Dockerfile 但没有 docker-compose 的话运行镜像的命令通常是docker build -t plant-disease-api . docker run -p 5000:5000 -v $(pwd)/weights:/app/weights plant-disease-api把模型权重目录通过-v挂载进去避免每次重建镜像都要把几百 MB 的权重文件打进去。这一步能让迭代速度快很多。4.4 接口验证与坑位启动服务后用 curl 测试是最直接的验证方式同时也能确认返回的 JSON 结构是否符合前端预期省得前端同学拿到一个结构不明的接口还要到处问。下面这条命令会把本地 test 目录下的叶片图片作为 multipart 请求发到接口curl -X POST -F imagetest/leaf_rust.jpg http://localhost:5000/predict如果返回的 JSON 里包含predictions字段说明接口通了。常见的失败场景有三种一是图片过大导致请求超时Flask 默认不会限制上传大小但 gunicorn 有超时设置二是preprocess_image里用了训练时没有的变换比如多了一个RandomHorizontalFlip()导致推理结果不稳定三是类别顺序不一致训练时 class_names 的索引和模型输出的索引必须一一对应否则准确率再高返回的名字也是错的。这里最容易出的问题是把torch.load的权重文件放在server.py同级目录却在 Docker 里忘了挂载权重目录于是跑起来直接报FileNotFoundError。现象可能原因处理方式服务启动即崩溃权重路径不对检查 docker run -v 挂载目录返回类别名称错乱类别索引与模型输出不一致保存模型时同时保存 class_names.json推理耗时 1sCPU 推理未关闭梯度加入 torch.no_grad()并 model.eval()上传大图报 413nginx/gunicorn 限制在入口处压缩图片或调大限制5. 混淆矩阵、类别权重与云部署把准确率真正压到生产环境5.1 用混淆矩阵定位“哪些病被认错了”分类准确率会掩盖很多问题尤其是病虫害数据里类别不均衡时。比如健康叶片占 60%模型全猜健康也能有 60% 准确率这时混淆矩阵比准确率重要得多。在 notebook 末尾加一段验证代码from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds, all_labels [], [] for images, labels in val_loader: with torch.no_grad(): outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) cm confusion_matrix(all_labels, all_preds) print(classification_report(all_labels, all_preds, target_namesclass_names))classification_report会输出每个类别的 precision、recall、f1-score。如果某个病害类别的 recall 明显偏低说明模型把它认成了别的类。下一步针对性地给这个类别加样本、加增强或者修改类别权重比盲目调学习率有效得多。5.2 类别权重不增加数据也能平衡训练项目的数据集如果不做额外采集可以在训练代码里给损失函数加上类别权重。PyTorch 的做法是用WeightedRandomSampler来控制每个 batch 的采样比例from torch.utils.data import WeightedRandomSampler class_counts torch.bincount(train_labels) class_weights 1.0 / class_counts.float() sample_weights class_weights[train_labels] sampler WeightedRandomSampler(sample_weights, num_sampleslen(sample_weights), replacementTrue) train_loader DataLoader(train_set, batch_size32, samplersampler)replacementTrue表示允许同一个样本在一个 epoch 里被多次采样这对样本少的类别很有效。注意这个操作要在训练前统计好所有训练标签不要用验证集做权重。5.3 云端部署的性价比选择项目里有aws_deployment.md和gcp_deployment.md说明原作者至少部署过两条云路径。以我的经验这类 CPU 推理服务用 2 核 4GB 的云主机就够了模型用 DenseNet121 或 ResNet50 的 CPU 版本单张图片的推理时间可以控制在 200~500ms 之间。如果只是演示没必要上 GPU 实例成本差十倍以上。部署后别忘了做一件事把模型权重的哈希值和训练数据的版本记录下来方便复现。在模型文件导出的同时把类别 ID 到中文名的映射一并写进配置能省掉上线后最无聊的联调也方便以后换成新模型时直接 diff 输出格式。很多线上事故都不是模型算错了而是后端的类别表没跟上训练脚本。本文还有配套的精品资源点击获取