深度学习农作物病虫害识别检测实战:从CNN分类到目标定位
简介面向毕业设计或科研实践的农作物病虫害识别检测系统完整工程包以卷积神经网络为核心提供从图像数据集收集与预处理、CNN特征提取、模型训练与评估到应用部署的端到端实现。资源共61个文件包含9个基于不同框架的训练/推理Notebook覆盖TensorFlow、Keras、PyTorch、Fastai等、Python后端脚本与Flask页面组件、Dockerfile及YAML部署配置、约88MB农作物图片数据集和Markdown说明文档目录结构清晰便于直接运行与二次开发。项目内置多种经典网络模型VGG16/VGG19、ResNet50、DenseNet121的对比实验并给出准确率、召回率、精确率等评估思路以及本地、AWS、GCP等部署指南可用于快速搭建病虫害识别基线、完成课程设计或毕业设计也可为迁移学习与模型调优提供参考。已有335人学习下载适合需要系统实践深度学习图像分类流程的开发者。1. 农作物病虫害识别项目最大的坑不在网络而在你打开压缩包之后的第一个动作你从压缩包里解出“基于深度学习卷积神经网络的农作物病虫害识别检测系统”第一反应是找model.py还是train.py跑通之前最值得先想明白的是这个项目到底要解决“是什么病”还是要解决“病斑在哪”。很多作物病害会在叶片上形成局部斑点刚拿到时叶片整体还是绿色单靠整图分类很容易漏检。你要交付的是一套能对着真实田块照片给出结论的系统哪怕结论只是“玉米锈病概率 0.82发病区域大致在左下角”。这篇博客不会去复述某个看不见的源码包而是把这类项目最常见的工程路径拆开讲数据目录怎么组织、卷积神经网络怎么选、训练参数怎么设、把整图分类升级到检测定位需要补什么以及跑完怎么验证结果。新手照步骤能出数字老手能对照检查自己的训练流程哪里埋了隐患。适合当毕设框架也适合在企业实验环境里快速搭一个病害识别的 MVP。2. 数据集组织让深度卷积神经网络从第一行代码就稳定2.1 用ImageFolder组织目录别在__init__里手工维护标签绝大多数 PyTorch 分类任务都能用torchvision.datasets.ImageFolder直接读取目录型数据。它把每个子目录当作一个类别文件名和文件内容不需要再写一份 CSV 映射。对应的目录结构推荐长成这样data/ train/ tomato_healthy/ IMG_001.jpg IMG_002.jpg tomato_early_blight/ IMG_010.jpg tomato_late_blight/ IMG_020.jpg val/ tomato_healthy/ tomato_early_blight/ tomato_late_blight/ test/ unlabeled/读取代码只有几行from torchvision import datasets, transforms train_trans transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), ]) train_ds datasets.ImageFolder(rootdata/train, transformtrain_trans) print(train_ds.class_to_idx) # 输出示例{tomato_healthy: 0, tomato_early_blight: 1, tomato_late_blight: 2}这里class_to_idx是按目录名字母序生成的索引。它的价值在于你不需要关心每张图的标签文件叫什么目录名就是标签。代价也很明显目录名一旦拼错整个类就被静默丢弃或错配。所以代码跑起来后的第一件事是打印这个映射表和你的病害清单核对一遍。验证集的目录结构和训练集保持一致。很多人喜欢在脚本里用random_split从训练集切出验证集短平快但真实田块照片往往同一时间采集、背景相似随机切分会让验证集指标虚高。按地块、按拍摄批次切分才是模拟真实场景的做法。如果源码里只给了一个dataset/根目录建议你手动重排成上面的结构后续所有数据处理流程都会简单很多。2.2 类别不平衡和错标问题先查样本数再谈增强病虫害数据集的不平衡不是小事。常见场景是健康叶片拍了几千张某些发病早期的叶片只有一两百张。模型为了压低整体 loss会学成“永远猜健康叶片”验证准确率看起来不错但每个病害类别的召回率惨不忍睹。跑训练之前先做一次统计import os from collections import Counter def count_images(root): counter Counter() for sub_dir in sorted(os.listdir(root)): full_dir os.path.join(root, sub_dir) if not os.path.isdir(full_dir): continue count 0 for _, _, files in os.walk(full_dir): count len([f for f in files if f.lower().endswith((.jpg, .jpeg, .png))]) counter[sub_dir] count return counter print(count_images(data/train))看到每个类别数量之后三个处理方向按优先级排第一补齐真实样本哪怕每个类别只多几十张也比盲目复制好第二训练时用WeightedRandomSampler让少数类在每个 epoch 里出现的次数多于多数类第三损失函数换成torch.nn.CrossEntropyLoss(weightclass_weight)。这里的class_weight可以简单取总样本数除以每个类别的样本数再做归一化。错标问题比不平衡更隐蔽。植物叶片病害相似度高同一个叶子可能同时感染多种病标注员把早疫病标成晚疫病很常见。我的做法是训练一个基线模型之后专门筛出验证集里置信度最高但预测错误的图片人工看一遍。这类图往往暴露的是标签错误而不是模型缺陷。别在项目一开始就花大把时间做复杂增强先把标注噪音降下来收益更大。2.3 针对叶片病斑的增强组合旋转、裁剪、颜色扰动农作物病害识别对光照和拍摄角度极其敏感。同一片叶子清晨逆光和中午顶光拍出来颜色分布差很多。数据增强的目标不是把图片变得花哨而是模拟这些自然波动。常见组合如下from torchvision import transforms train_trans transforms.Compose([ transforms.RandomResizedCrop(224, scale(0.6, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.2), transforms.RandomRotation(degrees25), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3, hue0.05), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_trans transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ])参数解释增强策略本项目推荐参数该参数解决的问题RandomResizedCropscale(0.6, 1.0)模拟不同距离拍摄强迫模型只依赖病斑局部特征RandomRotation25 度叶片朝向不定旋转超过 30 度会把细长病斑形状切坏ColorJitterbrightness/contrast/saturation 0.3田间光照变化hue 只给 0.05避免叶片颜色失真RandomVerticalFlip0.2垂直翻转频率比水平低一点贴合叶片自然朝向验证集增强只保留 Resize 和 Normalize不做随机裁剪。这里最容易犯的错是把训练增强直接应用到验证集导致验证指标抖动很大。Normalize 的均值和标准差用的是 ImageNet 的统计数据因为后面要用在 ImageNet 上预训练过的卷积神经网络保持一致才能让迁移学习真正生效。如果你用源码包里的网络从头训练可以换成按自己数据集统计的均值和标准差但多数场景下沿用 ImageNet 数值损失不大。3. 模型选型与网络搭建CNN 结构、迁移学习与 PyTorch 实现3.1 识别和检测对应的卷积神经网络结构不一样先厘清概念。标题里的“识别检测”通常包含两个层次一是图像分类输入一张叶子图输出病害类别二是目标检测输入一张完整植株照片输出病斑位置和边界框。分类用卷积神经网络加最后的全连接分类头就够了检测则要把分类特征图映射成“物体在哪 物体是什么”典型结构是 YOLO、SSD 这类带检测头的网络。带检测头的模型不是从零设计出来的。最常见的落地路径是先用分类网络确认“能不能分对”再升级到检测网络解决“位置在哪”。如果压缩包里的源码只是一个纯分类器而你答辩时需要用检测框展示结果不要慌这是标准升级路径。本章先讲分类基线怎么搭第 5 章再讲怎么把分类网络接到定位任务上。3.2 用预训练 ResNet18 做基线再尝试 MobileNetV2病虫害图像数据量通常只有几千张到几万张从头训练一个深层卷积神经网络很容易过拟合。用 ImageNet 预训练权重做初始化相当于把模型对形状、边缘、纹理的通用理解迁移过来再在作物叶片上做微调。ResNet18 是稳妥的基线它比 ResNet50 轻对叶片这种纹理丰富但结构相对简单的图像表达能力足够。import torch.nn as nn import torchvision.models as models def build_model(num_classes, archresnet18): if arch resnet18: model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) in_features model.fc.in_features model.fc nn.Linear(in_features, num_classes) elif arch mobilenet_v2: model models.mobilenet_v2(weightsmodels.MobileNet_V2_Weights.IMAGENET1K_V1) in_features model.classifier[1].in_features model.classifier[1] nn.Linear(in_features, num_classes) else: raise ValueError(fUnsupported arch: {arch}) return model代码说明models.resnet18(weights...)会加载在 ImageNet 上预训练过的一整套权重。model.fc是 ResNet 最后一层全连接分类数量从原来的一千类改成你的病虫害类别数MobileNetV2 对应的分类头在model.classifier[1]。改完这两行输出张量的形状就从(batch, 1000)变成(batch, num_classes)。选型考虑就两点效果和推理速度。模型参数量和预期表现大致如下模型参数量输入尺寸CPU 单张推理耗时大约适用场景ResNet18约 11.7M224×22430–50ms实验室基线、服务器推理MobileNetV2约 3.5M224×22415–25ms移动端、边缘设备EfficientNet-B0约 5.3M224×22420–35ms精度与速度均衡不要一上来就上 ResNet101 或 RegNet。病斑识别依赖的是局部纹理不是超大感受野。如果你发现 ResNet18 训练后验证准确率在几个相似病害之间反复跳动优先检查数据增强和数据量而不是换更大的模型。3.3 冻结特征层还是全量微调看你的数据量决定数据量少的时候一种常见做法是把除了最后分类头之外的所有参数冻结只训练新加的Linear层。这样稳定的原因是预训练权重不会被强噪声样本破坏收敛也很快。代码实现如下for name, param in model.named_parameters(): if name.startswith(fc): param.requires_grad True else: param.requires_grad Falsenamed_parameters()会返回每层参数的名字和值ResNet18 的最后一层名字前缀是fc。冻结之后反向传播只更新这一层前向传播仍然经过整个网络。这样训练几个 epoch 就能得到可用的分类结果适合快速验证标签和数据是否正常。如果你的数据量达到每类 2000 张以上直接全量微调效果更好。此时把requires_grad全部设为True但学习率要比正常从头训练低一个数量级通常设置在1e-4到5e-4。更大的数据集允许模型把低层特征也针对叶片纹理做调整比如叶脉走向、病斑边缘形状这比通用物体特征更贴合任务。无论选哪种方式训练之前先打印模型结构确认最后一层输出是(batch, num_classes)再用一个 dummy 张量过一遍import torch model build_model(num_classes3) dummy torch.randn(4, 3, 224, 224) out model(dummy) print(out.shape) # torch.Size([4, 3])这个自检步骤能过滤掉绝大多数“改错分类头导致维度对不上”的运行时错误比把训练跑到一半在 loss 计算处崩溃划算得多。维度确认无误后再进入训练环节。4. 训练循环、学习率与评估可复现的基准线4.1 最小可跑训练脚本数据加载、优化器、保存权重训练脚本的设计目标不是炫技而是让同一份代码在任何机器上都能产出稳定指标。下面这段代码把一次训练过程压缩到最小但完整的程度import torch import torch.nn as nn from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) def train_one_epoch(model, loader, optimizer, criterion, device): model.train() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() total_loss loss.item() * images.size(0) correct (outputs.argmax(dim1) labels).sum().item() total labels.size(0) return total_loss / total, correct / total model build_model(num_classes3).to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lr3e-4, weight_decay5e-4) train_loader DataLoader(train_ds, batch_size32, shuffleTrue, num_workers4)代码说明criterion用CrossEntropyLoss它内部自带 Softmax不需要在全连接层之后再手动加一层。AdamW是 PyTorch 里带权重衰减的 Adam它对迁移学习场景更友好weight_decay5e-4用来抑制过拟合。DataLoader里的num_workers4让 CPU 提前加载下一批图片避免 GPU 空转如果你的机器内存不大设成 2 或 0 更安全。训练主循环要记录验证集表现而不是只在训练集上打印 loss。每轮训练结束后切换到验证模式torch.no_grad() def evaluate(model, loader, criterion, device): model.eval() total_loss, correct, total 0.0, 0, 0 for images, labels in loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) total_loss loss.item() * images.size(0) correct (outputs.argmax(dim1) labels).sum().item() total labels.size(0) return total_loss / total, correct / totalmodel.eval()会关闭 Dropout 和 BatchNorm 的批次统计更新。这是个容易漏的动作忘了切换的话验证集每批的 BatchNorm 统计都不一样指标会上下跳动。评估时不计算梯度用torch.no_grad()包裹既能省显存也避免意外触发反向传播。4.2 学习率、epoch 和数据加载参数怎么设置超参数没有一个万能答案但有一组能覆盖多数植物病害数据集的合理起始值。下表是我通常采用的一组配置可以根据实际情况调整超参数推荐区间设置依据输入尺寸224×224与 ImageNet 预训练模型对齐批大小32 或 64GPU 显存 8G 以下用 32epoch20–50超过 30 轮不涨就停初始学习率3e-4全量微调 / 1e-3只训分类头AdamW 家族的常用量级权重衰减5e-4抑制过拟合学习率策略CosineAnnealingLR 或 ReduceLROnPlateau后期精细下降早停耐心值5–8 个 epoch连续多个 epoch 验证 loss 不降即回调学习率是这里最敏感的参数。用1e-3在预训练模型上全量微调早期 loss 可能下降很快但后期很难收敛到局部最优用1e-5则训练速度极慢。实践中我会先跑 10 个 epoch观察前 3 个 epoch 的验证准确率变化如果验证 loss 在第一轮就暴涨说明学习率过大如果 5 轮之后准确率还在接近随机水平说明学习率过小。余弦退火对稳定收尾效果好但调度器容易掩盖“模型其实还没到最优”的信号。更直观的做法是torch.optim.lr_scheduler.ReduceLROnPlateauscheduler torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, modemin, factor0.3, patience4 ) # 每个 epoch 结束后 scheduler.step(val_loss)modemin是盯住验证集 loss希望它变小patience4表示连续 4 个 epoch 没有降低才降学习率factor0.3表示下调为原来的 30%。这样比固定步长下调更贴合训练曲线的真实状态。提示如果验证集 loss 连续 3 个 epoch 不降但准确率还稳先查一下验证集里是否有标注错误不要急着调学习率。4.3 用混淆矩阵找相似病害而不是盯着总准确率总准确率在类别不平衡时极具欺骗性。假设健康叶片占 90%模型全猜健康也能拿到 90% 准确率但这个系统毫无价值。所以要输出混淆矩阵按行看每个类别的召回率。from sklearn.metrics import confusion_matrix, classification_report import numpy as np all_preds, all_labels [], [] model.eval() for images, labels in val_loader: with torch.no_grad(): preds model(images.to(device)).argmax(dim1).cpu() all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(classification_report(all_labels, all_preds, target_namestrain_ds.classes)) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的精确率、召回率和 F1 值。看表时重点关注“相似病害”之间互相污染的情况比如早疫病被预测成晚疫病说明模型学到的是发病后期的共同特征而没有学到两者的差异。此时两个做法一是检查这些类别的训练图像是否病斑面积过大导致局部特征被全局枯黄掩盖二是把输入分辨率提高到 256 或 384让病斑细节更清晰。另一个常用技巧是给CrossEntropyLoss加标签平滑criterion nn.CrossEntropyLoss(label_smoothing0.1)标签平滑让模型不必把正确类的输出概率推到 1留出一点容错空间。在相似病害多、标注噪音明显的农作物流数据上它通常比标准交叉熵涨 1–2 个百分点而且能明显改善验证集的置信度分布。5. 病害检测落地从全图识别到定位再到交付自检5.1 全图分类测不准局部病斑先过一下滑窗检测整图分类在单叶片的特写图上效果很好但真实场景往往是整株作物照片叶片交叠、光照不均病斑只占图像的一小块。这时候直接用ResNet18分类模型会看到大量绿色背景预测结果偏向健康类。常见做法是退回到滑动窗口把原图切成若干小块每个小块独立进分类模型再按得分叠加出热图。import torch import numpy as np def slide_predict(model, image, crop_size224, stride112, devicecpu): model.eval() h, w image.shape[:2] heatmap np.zeros((h // stride 1, w // stride 1), dtypenp.float32) for y in range(0, h - crop_size 1, stride): for x in range(0, w - crop_size 1, stride): crop image[y:ycrop_size, x:xcrop_size] crop_tensor val_trans(crop).unsqueeze(0).to(device) with torch.no_grad(): prob torch.softmax(model(crop_tensor), dim1) disease_prob prob[0, target_class].item() heatmap[y // stride, x // stride] disease_prob return heatmapstride控制滑窗重叠度stride112表示两个窗口之间有一半重叠能缓解边缘处病斑被截断的问题但推理时间翻倍。用torch.softmax取目标病害类别的概率填进热图。拿到热图后转成 OpenCV 的applyColorMap叠加在原图上就能画出病斑集中区域的大致位置。这种方法不需要训练检测网络毕设演示完全够用缺点是慢、框不精细。如果源码包里已经用了 YOLO 或 Faster R-CNN训练脚本里基本会包含锚框配置和 NMS 参数。你要做的是只去调整model.train()阶段的输入尺寸和类别数不要把检测头当成一个黑盒直接套用。检测模型的两个必调参数是置信度阈值和 IoU 阈值置信度低则框多而杂IoU 阈值高则重叠框多。先跑一组验证集统计每张图的平均框数目标是一张图不超过 3 个框。5.2 把分类模型整理成可直接运行预测的 Python 脚本毕设交付时最有说服力的是一个predict.py输入图片路径输出类别、置信度和热力图。脚本大致接口如下python predict.py \ --image samples/tomato_leaf_01.jpg \ --weights best_model.pt \ --labels labels.txt \ --output runs/pred_01.jpg预测脚本内部做了三件事读图、预处理、输出。读图用PIL.Image.open同时转成 RGB因为训练时所有通道顺序都是 RGB预处理复用训练时的验证阶段变换输出时打印每一个类别的 Top-5 概率def predict_image(model, image_path, labels, devicecpu): image Image.open(image_path).convert(RGB) tensor val_trans(image).unsqueeze(0).to(device) with torch.no_grad(): probs torch.softmax(model(tensor), dim1).squeeze(0) topk_conf, topk_idx torch.topk(probs, kmin(5, len(labels))) for conf, idx in zip(topk_conf, topk_idx): print(f{labels[idx]}: {conf.item() * 100:.2f}%) return probs打印 Top-5 而不是只打印最高类别能暴露模型的“犹豫”。如果前两名概率分别是 0.43 和 0.42系统实际不可用只是硬着头皮选了一个。此时预测脚本要提示使用者换输入图像或补样本。5.3 提交前必查的三条命令与数据自查示例拿到解压后的 Python 源码我的习惯是先投入 20 分钟做环境验证而不是直接训练。依赖安装完成之后按顺序检查python -c import torch, torchvision, PIL, sklearn; print(deps ok) python -c from torchvision import models; mmodels.resnet18(weightsNone); print(m.fc.in_features) python train.py --data data/train --arch resnet18 --epochs 1 --batch-size 4第一条检查依赖是否装全第二条确认模型能实例化第三条用 1 个 epoch 和极小批大小跑通完整流程。第三条跑出来后再改成正式训练参数。很多毕设项目里环境问题都出在 PyTorch 和 CUDA 版本不匹配上torch.cuda.is_available()返回值要单独确认没 GPU 就直接把devicecpu写死别让训练脚本在无 GPU 机器上抛错。交付前数据自查最容易忽略两个地方一是test目录里有没有和train目录重叠的图片哪怕文件名不同同一张图的不同尺寸裁剪也会让验证指标失真二是labels.txt的顺序是否和class_to_idx一致。判断方法很简单随机从每类里抽 3–5 张图走一遍predict.py人工核对目录名、标签文本和图像内容是否对得上。也别小看这个问题不少系统在训练时用 A 顺序预测时读另一个文件结果所有类别错位准确率直接掉到随机水平。5.4 把热力图和最易混类对作为答辩的技术亮点与其在那解释准确率 97%不如展示一张带 Grad-CAM 热力图的真实预测结果。Grad-CAM 并不复杂它在反向传播时只保留最后一个卷积层的梯度用梯度加权特征图得到模型注意力区域。这个可视化能让答辩老师直观看到模型是被病斑吸引了还是被叶片边缘的阴影带偏了。实现时可以直接取模型内部某个features模块的钩子记录输出这里给一个最精简的示范def grad_cam(model, tensor, target_layer): gradients {} activations {} def save_activation(module, input, output): activations[value] output def save_gradient(module, grad_input, grad_output): gradients[value] grad_output[0] handle_act target_layer.register_forward_hook(save_activation) handle_grad target_layer.register_full_backward_hook(save_gradient) output model(tensor) class_score output[0, output.argmax(dim1)] model.zero_grad() class_score.backward() handle_act.remove() handle_grad.remove() act activations[value].squeeze(0) # [C, H, W] grad gradients[value].squeeze(0) # [C, H, W] weights grad.mean(dim(1, 2)) # 对每个通道求平均 cam torch.einsum(c,chw-hw, weights, act).detach().cpu() cam torch.relu(cam) return camregister_forward_hook拿到指定层的输出register_full_backward_hook拿到关于输出的梯度。把梯度的空间维平均得到每个通道的权重再和激活图加权求和最后用relu丢掉负值区域。运行后把cam上采样到原图尺寸叠加成半透明热图。如果热图中心落在叶片边缘或土块上说明模型学偏了这时要回到数据增强和第 4 章的混淆矩阵里找原因。最后一个值得一提的收尾技巧是记录“最易混淆类别对”。把验证集里所有预测错误的样本按真实类别和预测类别分组统计出数量最多的组合比如“玉米大斑病被识别成玉米小斑病”。这个组合比总准确率更能指导下一次迭代你只需要去收集这两个类别的边缘样本或者专门对这两个类做特征可视化。一个能说清楚“为什么错、错在哪里、下一步加什么数据”的演示远比一个只报准确率的训练结果更有说服力。本文还有配套的精品资源点击获取