农作物病虫害识别分类实战:从数据增强到模型部署全解析
简介:面向机器学习、农业信息化及图像识别方向的初学者与竞赛备赛者,提供一份完整的农作物病虫害识别分类项目。项目涵盖数据预处理、特征工程、模型训练与评估环节,以AI Challenger植物病害识别赛题为背景,利用Inception-v3等模型展开实验,适合作为图像分类实战项目的入门参考。压缩包共12个文件,主要有2个Python脚本,分别实现模型训练与结果绘图;7张PNG/JPG图片展示训练集与验证集样本、Inception-v3结构以及训练/验证曲线等可视化结果;另有JSON测试结果、示例图片和Markdown使用说明,整体仅687KB,轻量便于快速学习和复用。目前已有579人学习,资源内完整保留赛题方案中的数据集划分、模型验证与参数调优思路,配合可视化图表可帮助读者快速理解算法效果与实现细节。
1. 一个 zip 装下的农作物病虫害识别分类项目,到底应该怎么读
拿到“基于 python 的农作物病虫害识别分类项目源码+数据集+使用说明.zip”这类压缩包,多数人的第一反应是解压、装环境、跑 train.py,然后盯着 accuracy 数字发半小时呆。这个思路没错,但很容易卡在“数据集格式看不懂”“训练脚本报错后不知道该改哪里”两个地方。这个项目的本质是一条完整的业务链路:图像数据如何组织、模型如何训练、类别不均衡怎么处理、训练好的权重如何变成可调用的分类接口。如果你正打算做智慧农业方向的项目,或者手里有一批作物叶片图像想做病害分类,这篇就顺着这条链路把每一步的命令、参数和取舍讲透。新手照着能跑通,有经验的人也能在数据增强、阈值调整和部署细节里找到可复用的操作。
2. 数据集是骨架:识别分类项目的目录设计与图像增强
2.1 先读懂数据集目录的约定
这种项目的数据集最常见的组织方式是 ImageFolder 风格,也就是“根目录 / 类别名 / 图片文件”。你解压后大概率会看到这样的结构:
crop_disease/
├── train/
│ ├── tomato_early_blight/
│ │ ├── 001.jpg
│ │ └── 002.jpg
│ ├── tomato_healthy/
│ └── potato_late_blight/
├── valid/
│ └── ...
└── test/
└── ...
train、valid、test 三段划分直接对应训练、调参、最终评估三个阶段,标准比例是 7:1:2 或 8:1:1,具体看总样本量。每个子目录名就是分类标签,torchvision 的 datasets.ImageFolder 可以直接按这个目录结构读取,不需要额外写 CSV 映射。这里我认为是整套源码里最值得先看的地方,因为后面的训练代码几乎都默认这个目录约定。
from torchvision import datasets, transforms
data_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])
])
train_dataset = datasets.ImageFolder(root='crop_disease/train', transform=data_transform)
valid_dataset = datasets.ImageFolder(root='crop_disease/valid', transform=data_transform)
print(train_dataset.classes) # 类别名列表
print(train_dataset.class_to_idx) # 类别到索引的映射
最关键的是 class_to_idx ,它记录了标签在模型输出向量里的位置。比如训练时 tomato_early_blight 对应索引 0,那推理时输出向量的第 0 个值就是该病害的概率。这个映射必须在使用说明里写清楚,或者你保存模型时一并存成 JSON 文件,否则换台机器跑推理时很容易出现“预测结果对不上名字”的问题。我一般会建议作者把 class_to_idx 和模型权重放在同一个输出目录下。
2.2 农作物图像增强的 4 个常用操作与参数选择
病虫害识别分类项目里,数据增强不是可选项,而是必须项。农田环境里叶片拍摄角度、光照、遮挡情况千差万别,而典型数据集每个类别往往只有一千到几千张图。不做增强,模型会把“阳光直射”这种背景特征当成病害特征,泛化能力很差。常见做法是在训练集上做增强,验证集和测试集只做缩放和归一化。
train_transform = transforms.Compose([
transforms.RandomResizedCrop(224, scale=(0.7, 1.0)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomRotation(degrees=15),
transforms.ColorJitter(brightness=0.2, contrast=0.2, saturation=0.2),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406],
std=[0.229, 0.224, 0.225])
])
参数说明:
-
RandomResizedCrop(224, scale=(0.7, 1.0))表示随机裁剪原图的 70% 到 100% 区域并缩放到 224×224,模拟不同拍摄距离。scale 下限不宜低于 0.5,否则裁剪区域里可能只剩叶片局部纹理,把正常叶片的叶脉误当成病害特征。 -
RandomHorizontalFlip(p=0.5)做水平翻转,左右对称的叶片结构完全适用于翻转增强。 -
RandomRotation(degrees=15)旋转角度限制在 15 度以内。农作物叶片不像自然场景物体那样有明确朝向,但角度太大会让边缘产生黑色填充区域,反而引入噪声。 -
ColorJitter三个值都设 0.2,应对早晚光照色温差异。取值超过 0.3 后,病斑的褐黄色会失真,模型容易学到颜色伪影。
还有一个容易被忽略的细节:验证集和测试集不要加任何随机增强,只用 Resize(256) 加 CenterCrop(224) ,保证每次评估结果可复现。源码里如果训练和验证用同一套增强逻辑,这会在训练早期造成 val_loss 锯齿状抖动,这是第一个要排查的地方。
2.3 类别不均衡必须单独处理
农作物病害数据集的天然特点是长尾分布:健康叶片样本量远大于某种早期病害,而“早疫病”和“晚疫病”在视觉上又极为相似。直接拿原始分布训练,模型会偏向把不确定的样本预测为样本量大的类别,整体 accuracy 看着不低,实际对少数类毫无区分能力。处理手段有两个,按优先级排列。
from torch.utils.data import WeightedRandomSampler
# 统计每个类别的样本数
class_counts = [len(train_dataset.targets) for _ in set(train_dataset.targets)]
# 或直接用列表推导式统计
class_counts = [train_dataset.targets.count(i) for i in range(len(train_dataset.classes))]
# 每个样本的采样权重 = 样本所在类别的样本数的倒数
sample_weights = [1.0 / class_counts[t] for t in train_dataset.targets]
sampler = WeightedRandomSampler(weights=sample_weights, num_samples=len(sample_weights), replacement=True)
train_loader = DataLoader(train_dataset, batch_size=32, sampler=sampler, num_workers=4)
逻辑说明: WeightedRandomSampler 的思路不是直接把小类样本复制几份,而是让每个 epoch 里每个样本被抽到的概率和它所属类别的大小成反比。小类样本在同一个 epoch 里可能被抽到多次,大类样本可能一次都不被抽到,整体梯度更新方向对少数类更友好。参数 replacement=True 表示可重复抽样,这是必须的,否则后面数量少的类别先被抽完,epoch 就没法完整迭代。配合上采样,通常还会在损失函数里加类别权重,双管齐下。
import torch.nn as nn
weights = torch.tensor([1.0 / c for c in class_counts], dtype=torch.float32)
criterion = nn.CrossEntropyLoss(weight=weights)
这里有一个实际经验:类别权重不要直接用样本数倒数,而是用 (1.0 / count) ** 0.5 这种压缩后的值,压低权重倍数,防止模型在小类上过拟合、在几百张图里反复记忆噪声。当某个类别样本数只有另一个类别的 1/20 时,权重差距过大尤其明显。
3. 训练实现:从预训练权重到病虫害分类器的最短代码路径
3.1 选 ResNet50 还是 EfficientNet?
病虫害识别分类项目里,绝大多数依赖 PyTorch 实现,模型首选是 ResNet50 或 EfficientNet-B0。原因很直接:这类任务是细粒度图像分类,病害差异往往只是一个褐色斑点或一片失绿区域,需要足够深的网络提取局部纹理特征,而不是只靠整体形状来分。ResNet50 的残差结构在多轮训练里不容易梯度消失,在医疗图像、农业图像这些数据量不足以支撑 Swin Transformer 从头训练的场景中是默认选择。
import torchvision.models as models
model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1)
num_features = model.fc.in_features
model.fc = nn.Linear(num_features, num_classes)
# 只微调最后一层和最后两个 Block
for name, param in model.named_parameters():
if not name.startswith('layer4') and not name.startswith('fc'):
param.requires_grad = False
参数说明: weights= 用 torchvision 2.0 之后推荐的方式加载预训练权重,比 pretrained=True 更明确。把 fc 层替换成输出维度等于本任务类别数的线性层后, num_features 取的是原全连接层的输入维度,ResNet50 是 2048。冻结前几层的策略适合数据集量在几千张的场景,保留 ImageNet 上学习到的边缘、纹理基础特征,只微调高层语义,训练速度和收敛稳定性都有明显改善。如果数据集超过 1 万张,就不要冻结了,全部参数一起训练效果更好。
3.2 完整训练脚本的核心骨架
训练脚本建议使用 PyTorch 训练循环加 tqdm 进度条,配合 TensorBoard 或 wandb 记录曲线。下面给出的是不依赖额外框架也能直接跑通的核心结构:
import torch
import torch.optim as optim
from torch.utils.data import DataLoader
from tqdm import tqdm
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
model = model.to(device)
optimizer = optim.AdamW(model.parameters(), lr=1e-4, weight_decay=1e-3)
scheduler = optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30)
criterion = nn.CrossEntropyLoss(weight=weights.to(device))
best_acc = 0.0
for epoch in range(30):
model.train()
running_loss = 0.0
for inputs, labels in tqdm(train_loader, desc=f'Epoch {epoch+1}'):
inputs, labels = inputs.to(device), labels.to(device)
optimizer.zero_grad()
outputs = model(inputs)
loss = criterion(outputs, labels)
loss.backward()
optimizer.step()
running_loss += loss.item()
# 每个 epoch 后验证一次
model.eval()
val_correct = 0
val_total = 0
with torch.no_grad():
for inputs, labels in valid_loader:
inputs, labels = inputs.to(device), labels.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
val_correct += (preds == labels).sum().item()
val_total += labels.size(0)
val_acc = val_correct / val_total
print(f'Epoch {epoch+1} | loss={running_loss/len(train_loader):.4f} | val_acc={val_acc:.4f}')
if val_acc > best_acc:
best_acc = val_acc
torch.save({
'model_state_dict': model.state_dict(),
'class_to_idx': train_dataset.class_to_idx,
'num_classes': num_classes
}, 'best_model.pth')
scheduler.step()
逻辑说明:优化器选用 AdamW 而不是普通 Adam ,因为权重衰减在 AdamW 里的实现方式是把衰减从梯度计算中解耦,正则化效果更干净,不容易掉点。初始学习率取 1e-4 ,这是微调任务的常见起点,比从头训练的 1e-3 低一个数量级,因为预训练权重已经在一个好的局部最优附近,学习率太大会破坏已有特征。 CosineAnnealingLR 让学习率从初始值按余弦曲线退火到接近 0,可以避免后期在极小值附近来回震荡。
参数 T_max=30 是余弦退火的周期长度,等于总训练轮数。这里有一个必须注意的地方: scheduler.step() 要放在每个 epoch 结束后调用,并且验证逻辑要放在学习率更新之前。如果顺序颠倒,你验证时用的已经是下一轮的学习率,日志会和实际不符。
3.3 用使用说明里的训练命令做一次完整复现
这类 zip 包的使用说明里一般都会给出训练和推理的命令。习惯上训练命令长这样:
# 训练 30 轮,指定数据根目录、初始学习率和 batch size
python train.py --data_root crop_disease --epochs 30 --batch_size 32 --lr 1e-4 --out_dir output/
# 如果显存不足,把 batch_size 降为 16 并同步调低学习率到 5e-5
python train.py --data_root crop_disease --epochs 30 --batch_size 16 --lr 5e-5 --out_dir output/
train.py 里通常还会有一个 --model_name 参数,用来在 resnet50 、 efficientnet_b0 、 mobilenet_v3_large 之间切换。 out_dir 参数控制模型输出位置,里面会保存 best_model.pth 、 last_model.pth 和 class_to_idx.json 三个文件。运行前建议先用 --fast_test 之类的参数加载 20 张图跑一个 batch 验证前向传播没问题,再进入完整训练,避免等半小时后才发现类别数对不上。如果 --fast_test 输出的 loss 没有任何变化,重点检查数据加载路径下图片能否正常读取,以及标签映射和类别数是否匹配。
4. 评估、调参与推理:把分类准确率从 91% 推到 95% 的实操手法
4.1 不要只看准确率,这三个指标才是病害分类的体检报告
农作物病虫害识别分类项目里,最常见的评价误区是只看总体 accuracy。遇到前面说的样本不均衡,总体准确率虚高是常态。一个病害样本只占总样本的 5%,你全预测成健康类别准确率也有 95%。对农业场景来说,漏掉早疫病比误报健康叶片严重得多,所以需要 Precision、Recall、F1-score 一起看。
from sklearn.metrics import classification_report, confusion_matrix
import numpy as np
all_preds = []
all_labels = []
model.eval()
with torch.no_grad():
for inputs, labels in valid_loader:
inputs = inputs.to(device)
outputs = model(inputs)
_, preds = torch.max(outputs, 1)
all_preds.extend(preds.cpu().numpy())
all_labels.extend(labels.numpy())
all_preds = np.array(all_preds)
all_labels = np.array(all_labels)
print(classification_report(all_labels, all_preds, target_names=valid_dataset.classes))
运行这段代码后,重点看两个方面:一是每个类别的 Recall,病害类别在视野里却没被模型认出来,说明该类别的特征没有学透,先加增强再考虑加数据;二是混淆矩阵里哪些类别互相混淆,比如“晚疫病”和“早疫病”经常被搞混,这两种病害在叶片上的病斑形态和颜色本来就有交叉。如果这两个类别的混淆比例超过 15%,单靠调参很难根治,通常需要合并相似类别重新定义标签体系,或收集更典型的区分性样本。
4.2 三个必调超参数和它们的调整边界
训练完第一轮 baseline 后,用验证集的真实表现做带着目标感的调参,一个又一个试,不加状态记录是不行的。以下三个参数是从多处经验里沉淀下来的调整优先级:
| 参数 | 默认值 | 调整方向 | 调整边界 |
|---|---|---|---|
| 学习率 | 1e-4 | val_acc 连续 5 轮不涨时减半 | 下限 1e-6,低于此值训练几乎不动 |
| batch_size | 32 | 显存允许前提下尽量大 | 每张卡建议 16~64,大于 128 时需同步调大学习率 |
| 图像分辨率 | 224 | 有 GPU 余量时换 320 | 上限 384,再大收益极小且显存翻倍 |
学习率是最优先调整的。微调场景下,第 8 到第 12 轮时 loss 容易进入平台期,把学习率降到 5e-5 再用低学习率跑 10 轮,比重新开一轮训练更有效。batch size 在作物病虫害这种静态图像场景里影响没那么敏感,但 batch 太小时 BatchNorm 的统计量不稳定,验证准确率会上下跳动超过两个百分点,这是很多人误以为模型没收敛的原因。010 分辨率从 224 提到 320,对细小病斑的识别有明显帮助,代价是显存占用提升约一倍。
4.3 推理阶段后处理:用置信度阈值减少误报
训练好坏是一回事,推理时用纯 argmax 还是用阈值过滤,结果完全不一样。农业场景中,一张健康叶片被判成病害,农户会多打一次药;一张带病叶片被判成健康,直接错过防治窗口。后者代价远高于前者,所以推理时一般不用裸 argmax,而是设置一个阈值,低于阈值时返回“疑似/需人工确认”。
import torch.nn.functional as F
def predict_single(image_tensor, model, class_names, threshold=0.6):
model.eval()
with torch.no_grad():
logits = model(image_tensor.unsqueeze(0))
prob = F.softmax(logits, dim=1)
max_prob, max_idx = torch.max(prob, dim=1)
if max_prob.item() < threshold:
return "uncertain", max_prob.item()
return class_names[max_idx.item()], max_prob.item()
这里一个在实际项目中很实用的参数是 threshold 的取值。它会遍历验证集,每取一个候选值就计算对应的精确率和召回率,取 F1 最高点作为最终阈值。注意默认 threshold=0.6 不适合所有类别,因为模型对某些形态不明显的早期病害本就只给出 0.45 左右的置信度,一刀切会把这些样本全部拒掉。实际使用时,建议按类别分别记录置信度分布,对每个类别单独定阈值,或者维持一个全局阈值但把 "uncertain" 档再做一次人工复核,而不是直接抛掉。
5. 让识别分类走出 ipynb:模型导出、API 封装与嵌入式部署技巧
5.1 用 TorchScript 导出模型并做一次完整推理验证
训练好的 best_model.pth 依赖 Python 和 torchvision 里的模型定义代码,换个环境想要复现,要先装 torch、torchvision,版本对不上就报错。常见做法是用 TorchScript 把模型和权重打包成一个文件。
from torch import jit
model.eval()
example_input = torch.randn(1, 3, 224, 224)
scripted_model = jit.trace(model, example_input)
scripted_model.save('crop_disease_scripted.pt')
导出后建议在纯推理环境里做一次冒烟测试:加载 crop_disease_scripted.pt ,输入一张训练时没见过的叶片图片,确认输出和 PyTorch 原始模型一致。使用说明里如果提供了 test 脚本,就是用这个导出文件做端到端验证的。冒烟测试主要能提前暴露两个问题:一是模型里如果包含 nn.Dropout ,trace 时会记录训练状态,需要在 model.eval() 之后再做 trace;二是输入归一化的 mean 和 std 必须和训练时完全一致,否则输出概率分布会偏移,这类错误通常要到验证集上算指标才能发现。
5.2 用 FastAPI 把分类模型包成 HTTP 接口
农业现场最常见的落地形态不是写 Python 脚本跑推理,而是把模型做成一个 HTTP 接口,让前端小程序或后台服务调用。FastAPI 是在这种轻量级推理服务场景里主流的选择,异步支持好,自带 OpenAPI 文档,不需要额外配置。
from fastapi import FastAPI, UploadFile, File
from PIL import Image
import io, torchvision.transforms as transforms
app = FastAPI()
model = jit.load('crop_disease_scripted.pt')
model.eval()
@app.post("/predict")
async def predict(file: UploadFile = File(...)):
img_bytes = await file.read()
img = Image.open(io.BytesIO(img_bytes)).convert('RGB')
img = transform(img).unsqueeze(0)
with torch.no_grad():
output = model(img)
prob = torch.softmax(output, dim=1)
idx = torch.argmax(prob, dim=1).item()
confidence = prob[0][idx].item()
return {"class": class_names[idx], "confidence": round(confidence, 4)}
这段接口代码里最容易出错的细节是 Image.open 后必须调用 .convert('RGB') 。用户上传的图片可能是带透明通道的 PNG,或者灰度 JPEG,不做 RGB 转换,通道数不匹配在模型推理时会直接报维度错误。调用方式如下:
# 用 curl 测试接口是否正常
curl -X POST -F "file=@leaf_test.jpg" http://127.0.0.1:8000/predict
一个实际建议:接口部署时同步记录一次调用耗时,如果单次推理超过 200 毫秒,检查是否有其他进程抢占 GPU、batch size 是否过大、输入图片是否被异常放大到几 MB。排查问题时最有效的技巧是打印出该图片的尺寸、缩放时间、模型推理时间三段耗时,一目了然。
5.3 让模型跑在边缘设备上的三个省内存技巧
使用说明如果包含嵌入式或移动端部署说明,一般是把这套识别分类模型压缩到能在 Jetson Nano、树莓派或 RK3588 之类的设备上实时运行。核心技巧有三个,每个都是对应一类硬件瓶颈。
第一个技巧是输入尺寸从 224 降到 192。对摄像头拍摄的连续帧,相邻帧之间信息高度重合,模型分辨率在 192 以上时精度损失通常在 0.5% 以内,但推理耗时能减少约 30%。测试时对比 224 和 192 两组输入在同一验证集上的差距,如果指标差超过 1 个百分点,就放弃这个优化。
第二个技巧是量化。 torch.quantization 可以把模型权重从 FP32 压缩到 INT8,模型文件体积缩小到约四分之一,在支持 INT8 加速的硬件上推理速度提升明显。注意量化前要在校准数据集上跑一遍统计激活值范围,校准集应包含各类别的代表性样本,否则量化后某个类别可能出现断崖式掉点。
from torch.quantization import quantize_dynamic
quantized_model = quantize_dynamic(
model, {torch.nn.Linear}, dtype=torch.qint8
)
torch.jit.save(torch.jit.script(quantized_model), 'model_int8.pt')
这里用的 quantize_dynamic 只量化 Linear 层,主要针对模型的分类头部。如果模型主干是 Transformer 结构,Linear 占比足够高,收益明显;如果主干是纯卷积,Linear 层占比低,量化收益有限,需要参考 QAT(量化感知训练)的更重方案。
第三个技巧是模型剪枝。实际操作中是把 ResNet50 倒数第二个 Block 的通道数减半,然后微调 5 轮恢复精度。比完整训练快很多,适合反复试场景。核心思想是病害图像的高层特征维度远低于 ImageNet 的 1000 类,2048 维的特征表达对这个任务来说是过剩的,压缩到 512 维通常不影响分类精度,却能在边缘设备上减少可观的显存占用和计算量。做完这三步后,模型通常能从初始的 90MB 降到 20MB 以内,帧率从不到 15 提升到 30 上下。验证技巧很简单 :用同一段田间拍摄的视频分别跑优化前和优化后的模型,统计准确率和帧率,达标后再拆代码。
更多推荐
所有评论(0)