简介:面向毕业设计或科研实践的农作物病虫害识别检测系统完整工程包,以卷积神经网络为核心,提供从图像数据集收集与预处理、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(root="data/train", transform=train_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(weight=class_weight)。这里的class_weight可以简单取总样本数除以每个类别的样本数再做归一化。
错标问题比不平衡更隐蔽。植物叶片病害相似度高,同一个叶子可能同时感染多种病,标注员把早疫病标成晚疫病很常见。我的做法是训练一个基线模型之后,专门筛出验证集里置信度最高但预测错误的图片,人工看一遍。这类图往往暴露的是标签错误而不是模型缺陷。别在项目一开始就花大把时间做复杂增强,先把标注噪音降下来,收益更大。
2.3 针对叶片病斑的增强组合:旋转、裁剪、颜色扰动
农作物病害识别对光照和拍摄角度极其敏感。同一片叶子,清晨逆光和中午顶光拍出来,颜色分布差很多。数据增强的目标不是把图片变得花哨,而是模拟这些自然波动。常见组合如下:
from torchvision import transforms train_trans = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.2), transforms.RandomRotation(degrees=25), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.3, hue=0.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]) ])参数解释:
| 增强策略 | 本项目推荐参数 | 该参数解决的问题 |
|---|---|---|
| RandomResizedCrop | scale=(0.6, 1.0) | 模拟不同距离拍摄,强迫模型只依赖病斑局部特征 |
| RandomRotation | 25 度 | 叶片朝向不定,旋转超过 30 度会把细长病斑形状切坏 |
| ColorJitter | brightness/contrast/saturation 0.3 | 田间光照变化,hue 只给 0.05,避免叶片颜色失真 |
| RandomVerticalFlip | 0.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, arch="resnet18"): if arch == "resnet18": model = models.resnet18(weights=models.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(weights=models.MobileNet_V2_Weights.IMAGENET1K_V1) in_features = model.classifier[1].in_features model.classifier[1] = nn.Linear(in_features, num_classes) else: raise ValueError(f"Unsupported arch: {arch}") return model代码说明:models.resnet18(weights=...)会加载在 ImageNet 上预训练过的一整套权重。model.fc是 ResNet 最后一层全连接,分类数量从原来的一千类改成你的病虫害类别数;MobileNetV2 对应的分类头在model.classifier[1]。改完这两行,输出张量的形状就从(batch, 1000)变成(batch, num_classes)。
选型考虑就两点:效果和推理速度。模型参数量和预期表现大致如下:
| 模型 | 参数量 | 输入尺寸 | CPU 单张推理耗时(大约) | 适用场景 |
|---|---|---|---|---|
| ResNet18 | 约 11.7M | 224×224 | 30–50ms | 实验室基线、服务器推理 |
| MobileNetV2 | 约 3.5M | 224×224 | 15–25ms | 移动端、边缘设备 |
| EfficientNet-B0 | 约 5.3M | 224×224 | 20–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_classes=3) 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(dim=1) == labels).sum().item() total += labels.size(0) return total_loss / total, correct / total model = build_model(num_classes=3).to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.AdamW(model.parameters(), lr=3e-4, weight_decay=5e-4) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4)代码说明:criterion用CrossEntropyLoss,它内部自带 Softmax,不需要在全连接层之后再手动加一层。AdamW是 PyTorch 里带权重衰减的 Adam,它对迁移学习场景更友好,weight_decay=5e-4用来抑制过拟合。DataLoader里的num_workers=4让 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(dim=1) == 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 或 64 | GPU 显存 8G 以下用 32 |
| epoch | 20–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.ReduceLROnPlateau:
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau( optimizer, mode="min", factor=0.3, patience=4 ) # 每个 epoch 结束后 scheduler.step(val_loss)mode="min"是盯住验证集 loss,希望它变小;patience=4表示连续 4 个 epoch 没有降低才降学习率;factor=0.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(dim=1).cpu() all_preds.extend(preds.tolist()) all_labels.extend(labels.tolist()) print(classification_report(all_labels, all_preds, target_names=train_ds.classes)) print(confusion_matrix(all_labels, all_preds))classification_report会输出每个类别的精确率、召回率和 F1 值。看表时重点关注“相似病害”之间互相污染的情况:比如早疫病被预测成晚疫病,说明模型学到的是发病后期的共同特征而没有学到两者的差异。此时两个做法:一是检查这些类别的训练图像是否病斑面积过大,导致局部特征被全局枯黄掩盖;二是把输入分辨率提高到 256 或 384,让病斑细节更清晰。
另一个常用技巧是给CrossEntropyLoss加标签平滑:
criterion = nn.CrossEntropyLoss(label_smoothing=0.1)标签平滑让模型不必把正确类的输出概率推到 1,留出一点容错空间。在相似病害多、标注噪音明显的农作物流数据上,它通常比标准交叉熵涨 1–2 个百分点,而且能明显改善验证集的置信度分布。
5. 病害检测落地:从全图识别到定位,再到交付自检
5.1 全图分类测不准局部病斑:先过一下滑窗检测
整图分类在单叶片的特写图上效果很好,但真实场景往往是整株作物照片,叶片交叠、光照不均,病斑只占图像的一小块。这时候直接用ResNet18分类,模型会看到大量绿色背景,预测结果偏向健康类。常见做法是退回到滑动窗口:把原图切成若干小块,每个小块独立进分类模型,再按得分叠加出热图。
import torch import numpy as np def slide_predict(model, image, crop_size=224, stride=112, device="cpu"): model.eval() h, w = image.shape[:2] heatmap = np.zeros((h // stride + 1, w // stride + 1), dtype=np.float32) for y in range(0, h - crop_size + 1, stride): for x in range(0, w - crop_size + 1, stride): crop = image[y:y+crop_size, x:x+crop_size] crop_tensor = val_trans(crop).unsqueeze(0).to(device) with torch.no_grad(): prob = torch.softmax(model(crop_tensor), dim=1) disease_prob = prob[0, target_class].item() heatmap[y // stride, x // stride] = disease_prob return heatmapstride控制滑窗重叠度:stride=112表示两个窗口之间有一半重叠,能缓解边缘处病斑被截断的问题,但推理时间翻倍。用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, device="cpu"): image = Image.open(image_path).convert("RGB") tensor = val_trans(image).unsqueeze(0).to(device) with torch.no_grad(): probs = torch.softmax(model(tensor), dim=1).squeeze(0) topk_conf, topk_idx = torch.topk(probs, k=min(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; m=models.resnet18(weights=None); 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 就直接把device="cpu"写死,别让训练脚本在无 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(dim=1)] 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 章的混淆矩阵里找原因。
最后一个值得一提的收尾技巧,是记录“最易混淆类别对”。把验证集里所有预测错误的样本按真实类别和预测类别分组,统计出数量最多的组合,比如“玉米大斑病被识别成玉米小斑病”。这个组合比总准确率更能指导下一次迭代:你只需要去收集这两个类别的边缘样本,或者专门对这两个类做特征可视化。一个能说清楚“为什么错、错在哪里、下一步加什么数据”的演示,远比一个只报准确率的训练结果更有说服力。
本文还有配套的精品资源,点击获取