尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于CNN的农作物病虫害图像识别实战:从数据集到部署

基于CNN的农作物病虫害图像识别实战:从数据集到部署 简介图像分类是计算机视觉中最基础也最广泛的应用方向之一其核心在于让机器自动从像素中学习可区分不同类别的特征。传统方法依赖人工设计的特征描述子但在光照复杂、背景多变的自然场景下鲁棒性不足。卷积神经网络CNN通过层级化特征提取从边缘纹理到语义模式逐层抽象在农业植保领域展现出强大的实用价值尤其适合病虫害识别这类小样本、多类别、真实环境复杂的目标。本文以水稻稻瘟病、稻飞虱等农作物病虫害识别为例系统梳理基于PyTorch的完整技术路线涵盖数据集清洗与增强、轻量CNN模型构建、ResNet18迁移学习训练、评估指标解读以及CPU环境下的推理优化。同时分享训练调参、过拟合抑制和类别不平衡处理等工程经验帮助读者快速落地一套可用的图像分类系统。无论是入门深度学习还是开展实际植保项目这套方法都值得参考。 做农业植保的朋友半年前找到我说他们那边水稻田的稻瘟病和稻飞虱识别全靠老技术员肉眼判断一个县就两三个懂行的根本忙不过来问我能不能用AI做个自动识别。这个场景我太熟悉了——病虫害识别听着是个细分方向其实落地价值非常大。一块田几千亩地靠人一张张叶子去看效率太低而且病害初期特征不明显年轻植保员经验不够很容易误判。用神经网络做图像分类把这个过程自动化是目前农业AI落地最成熟的方向之一。这篇文章就把完整的项目做法写出来从数据集准备、CNN模型构建、训练调参到推理部署和踩坑记录所有Python代码都是可以直接跑的文档说明的整理思路也会带上。适合想入行或者刚开始做图像分类项目的人参考尤其是那种数据集不大、类别多、还要真落地的场景。1. 项目整体设计思路1.1 为什么选神经网络做病虫害识别首先要明白一个问题传统的图像识别怎么做的以前的做法是手工设计特征——颜色直方图、纹理特征、形态特征然后接SVM或者随机森林。这套路子也不是不能用但一遇到真实田间环境就崩了光照变化、叶片遮挡、背景复杂、不同生育期同一种病害长相完全不同。手工特征根本扛不住这种多样性。神经网络特别是卷积神经网络CNN强在哪它可以自动从原始像素学习特征。浅层学边缘纹理中层学形状结构深层学语义级别的病斑模式。最关键的是你不需要告诉它“稻瘟病的病斑是梭形、边缘褐色、中间灰白”它自己会从数据里归纳。这在复杂的田间场景下鲁棒性远超传统方法。这个项目最终选择的方案是基于PyTorch实现一个轻量CNN分类器识别番茄、水稻等作物常见病虫害同时保留对预训练模型ResNet18的迁移学习接口。既满足快速落地的需求又给后续提升精度留了空间。1.2 整体架构怎么搭整个项目我分成三层来设计数据层采集或下载原始图片清洗后做数据增强再按类别划分训练集、验证集、测试集。模型层定义网络结构训练模型保存最佳权重。应用层加载权重对输入图片推理输出类别和置信度对接告警或报表系统。我建议你在动手写代码前一定先把这个架构在脑子里过一遍。很多新手上来就写模型数据集随便拿个文件夹一放最后训练出来的模型根本没法用——问题往往不在模型本身是数据层面就没做好。1.3 技术选型考量Python生态下做图像分类无非是PyTorch和TensorFlow二选一。我最终选了PyTorch理由很实际代码风格贴近Python原生习惯调试方便出错信息好懂torchvision里预训练模型下载方便迁移学习几行代码搞定社区资料多遇到问题基本都能搜到答案。如果你主要用TensorFlow思路完全通用后面我也会标注出对应的关键差异点。2. 数据集准备与增强处理2.1 数据集从哪里来一个比较现实的问题病虫害图片数据集不像通用物体识别那样好找。常用的公开数据集有PlantVillage最经典的植物病害数据集涵盖14种作物、38个类别共5万多张图AI Challenger 2018农业病虫害数据集国内团队做的包含农作物病害、害虫等国家农业科学数据中心等平台可以找到一些专项数据但说实话公开数据集和实际田间情况差距很大。PlantVillage的图大多是实验室单叶背景叶子摆得整整齐齐、光线均匀。你拿到真实的稻田里拍一张图光照、泥土、杂草、虫体叠加在一起模型会蒙。所以我的建议是公开数据集用于预训练或起步落地一定要采集真实场景数据哪怕数量少点也管用。我当时的做法是拿了1000多张朋友那边植保站攒的真实田间照片加上公开数据集最终做成20个类别、每类400到800张的有效数据集。别嫌数据少后面数据增强加上迁移学习这个体量完全够跑出一个能用的模型。2.2 数据清洗与初筛这个环节最容易被新手跳过。下载下来的数据没经过检查直接扔进训练集结果模型训练的时候loss死活降不下去一看数据里有大量错误标注和重复图片。清洗要做三件事人工抽检每个类别的图片把明显标注错误的剔掉删除完全相同或亮度异常的图片比如纯黑、纯白、过曝的统一图片尺寸和格式训练前全部Resize到224x224有些人觉得人工抽检费时间实际上这步省不了。5000张图可能就花你半天但可以让后续训练少走好多弯路。2.3 数据集划分脚本划分数据集前一定要先把数据打乱很多人漏了这一步训练集和验证集分布不一致验证指标就会失真。我用Python写了个简单的划分脚本import os import shutil import random raw_dir raw_data train_dir dataset/train val_dir dataset/val test_dir dataset/test split_ratio [0.8, 0.1, 0.1] # 收集所有图片路径 all_images [] for class_name in os.listdir(raw_dir): class_path os.path.join(raw_dir, class_name) if not os.path.isdir(class_path): continue for img_name in os.listdir(class_path): if img_name.lower().endswith((.jpg, .jpeg, .png)): all_images.append((class_name, os.path.join(class_path, img_name))) random.seed(42) random.shuffle(all_images) # 按比例划分并复制 n_train int(len(all_images) * split_ratio[0]) n_val int(len(all_images) * split_ratio[1]) for i, (class_name, img_path) in enumerate(all_images): if i n_train: target os.path.join(train_dir, class_name) elif i n_train n_val: target os.path.join(val_dir, class_name) else: target os.path.join(test_dir, class_name) os.makedirs(target, exist_okTrue) shutil.copy(img_path, os.path.join(target, os.path.basename(img_path))) print(fTotal: {len(all_images)}, Train: {n_train}, Val: {n_val}, Test: {len(all_images) - n_train - n_val})这个脚本的逻辑很简单扫描所有图片随机打乱按比例复制到三个目录。关键点在random.shuffle没有这行你的数据划分就会有问题。另外建议固定随机种子保证每次运行划分结果一致这样可以复现实验结果。2.4 数据增强策略真实场景下同一片叶子的病害照片换个角度、换个光照像素差异巨大。数据增强本质上是在有限的数据里模拟更多的拍摄变化让模型学会“不管怎么拍病斑特征都在”。我当时用的增强组合from torchvision import transforms train_transform transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(p0.5), transforms.RandomVerticalFlip(p0.3), transforms.RandomRotation(degrees30), transforms.ColorJitter(brightness0.3, contrast0.3, saturation0.3, hue0.1), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_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]) ])几个参数的经验值说一下。RandomResizedCrop的scale设成0.7到1.0不要小于0.5裁剪太多会把病斑切掉。旋转角度30度够用了田间照片很少出现完全倒置的情况。ColorJitter的亮度、对比度扰动0.3左右模拟早晚不同光照。注意验证集和测试集不要做随机增强只做Resize和标准化否则你看到的验证指标是“增强后”的指标和真实部署环境不一致。3. CNN模型构建与训练3.1 从零搭建还是用预训练模型这个项目里我两条路都走了给你对比一下。从零搭一个小型CNN比如3个卷积块训练速度快、模型文件小、部署容易但精度上限比较低数据少的情况下容易过拟合。适合做原型验证或者嵌入式设备部署。用预训练模型ResNet18或ResNet50做迁移学习训练时间稍长但精度高很多。尤其当你数据量只有几百张时预训练模型已经在ImageNet上学到了大量通用视觉特征你只需要微调它适配病虫害的独特纹理。我的建议是先跑通小CNN保证流程没问题再用迁移学习出最终模型。下面两套代码都给你。3.2 轻量CNN模型结构import torch import torch.nn as nn class PestoNet(nn.Module): def __init__(self, num_classes20): super(PestoNet, self).__init__() self.features nn.Sequential( # Block 1 nn.Conv2d(3, 32, kernel_size3, padding1), nn.BatchNorm2d(32), nn.ReLU(inplaceTrue), nn.Conv2d(32, 32, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 2 nn.Conv2d(32, 64, kernel_size3, padding1), nn.BatchNorm2d(64), nn.ReLU(inplaceTrue), nn.Conv2d(64, 64, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), # Block 3 nn.Conv2d(64, 128, kernel_size3, padding1), nn.BatchNorm2d(128), nn.ReLU(inplaceTrue), nn.Conv2d(128, 128, kernel_size3, padding1), nn.ReLU(inplaceTrue), nn.MaxPool2d(kernel_size2, stride2), ) self.classifier nn.Sequential( nn.AdaptiveAvgPool2d((1, 1)), nn.Flatten(), nn.Dropout(0.5), nn.Linear(128, 256), nn.ReLU(inplaceTrue), nn.Dropout(0.3), nn.Linear(256, num_classes) ) def forward(self, x): x self.features(x) x self.classifier(x) return x几个设计细节解释一下。每个卷积后面都接BatchNorm这个太关键了。不加BatchNorm的话网络深层梯度容易爆炸或者消失训练根本稳不下来。池化用MaxPooling保留最显著的特征对病斑这种纹理信息更友好。分类器部分加Dropout0.5的比例这是最简单有效的防过拟合手段。用AdaptiveAvgPool2d(1,1)把特征图压缩成固定尺寸不管输入图片多大都能进全连接层。3.3 迁移学习训练脚本用ResNet18做迁移学习的完整训练代码如下import torch import torch.nn as nn import torch.optim as optim from torchvision import models from torch.utils.data import DataLoader, Dataset from PIL import Image import os class ImageFolder(Dataset): def __init__(self, root, transformNone): self.samples [] self.classes sorted(os.listdir(root)) self.class_to_idx {cls: i for i, cls in enumerate(self.classes)} for cls in self.classes: cls_dir os.path.join(root, cls) for img_name in os.listdir(cls_dir): if img_name.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, img_name), self.class_to_idx[cls])) self.transform transform def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label self.samples[idx] image Image.open(path).convert(RGB) if self.transform: image self.transform(image) return image, label num_classes 20 model models.resnet18(pretrainedTrue) # 替换最后一层全连接 num_features model.fc.in_features model.fc nn.Linear(num_features, num_classes) device torch.device(cuda if torch.cuda.is_available() else cpu) model model.to(device) # 冻结前几层只训练后面的层 # 这样既利用了预训练特征又不会因为数据少把学到的通用特征搞坏 for name, param in model.named_parameters(): if layer4 in name or fc in name: param.requires_grad True else: param.requires_grad False criterion nn.CrossEntropyLoss() optimizer optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr0.001) scheduler optim.lr_scheduler.ReduceLROnPlateau(optimizer, modemin, factor0.5, patience3) train_dataset ImageFolder(dataset/train, train_transform) val_dataset ImageFolder(dataset/val, val_transform) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) best_val_acc 0.0 epochs 30 for epoch in range(epochs): # 训练 model.train() running_loss 0.0 correct 0 total 0 for images, labels in train_loader: images, labels images.to(device), labels.to(device) optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() running_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() train_loss running_loss / total train_acc correct / total # 验证 model.eval() val_loss 0.0 val_correct 0 val_total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) loss criterion(outputs, labels) val_loss loss.item() * images.size(0) _, predicted torch.max(outputs, 1) val_total labels.size(0) val_correct (predicted labels).sum().item() val_loss val_loss / val_total val_acc val_correct / val_total print(fEpoch {epoch1}/{epochs} | Train Loss: {train_loss:.4f} | Train Acc: {train_acc:.4f} | Val Loss: {val_loss:.4f} | Val Acc: {val_acc:.4f}) scheduler.step(val_loss) # 保存最佳模型 if val_acc best_val_acc: best_val_acc val_acc torch.save(model.state_dict(), best_model.pth) print(f - Saved best model, Val Acc: {val_acc:.4f})这段代码有几个点特别提一下。我用了冻结部分层策略只训练layer4和fc。数据量小的情况下全量微调很容易过拟合。学习率用0.001这是Adam类优化器比较稳妥的起点。如果你想追求更好效果可以先用0.001跑20个epoch再降到0.0001跑10个epoch。训练和验证都记得切换model.train()和model.eval()Dropout和BatchNorm在这两种模式下的行为完全不同忘记切是新手最常见的问题。3.4 损失函数和优化器怎么选分类任务损失函数基本就是CrossEntropyLoss它是LogSoftmax和NLLLoss的组合输出的就是每个类别的logits反向传播梯度比较稳。除非你遇到严重的类别不平衡否则不需要上Focal Loss。如果你的数据确实不平衡比如稻飞虱图片特别多某种罕见病害图片只有100张可以给损失函数加权重class_counts [600, 400, 100] # 每个类别的样本数 weights 1.0 / torch.tensor(class_counts, dtypetorch.float) weights weights / weights.sum() * len(class_counts) # 归一化 criterion nn.CrossEntropyLoss(weightweights.to(device))优化器我用Adam而不是SGD原因是Adam对学习率不敏感调参成本低适合快速出结果。如果你追求极限精度后期可以切回SGD加Momentum配上余弦退火学习率通常能再提升一两个点。但前提是数据质量和数量已经到位否则这个提升可以忽略。4. 模型评估与预测部署4.1 评估指标不能只看准确率准确率是最直观的指标但它会骗人。假设20个类别中有一类占了一半数据模型把所有图片都预测成那一类准确率也有50%。对于植保场景漏掉一类病和误报一类病代价完全不同。所以除了准确率至少要看每个类别的精确率Precision、召回率Recall和F1值。我写了个评估脚本from sklearn.metrics import classification_report, confusion_matrix import numpy as np def evaluate_model(model, dataloader, class_names, device): model.eval() all_preds [] all_labels [] with torch.no_grad(): for images, labels in dataloader: images images.to(device) outputs model(images) _, predicted torch.max(outputs, 1) all_preds.extend(predicted.cpu().numpy()) all_labels.extend(labels.numpy()) print(Classification Report:) print(classification_report(all_labels, all_preds, target_namesclass_names, digits4)) cm confusion_matrix(all_labels, all_preds) return all_preds, all_labels, cm跑完之后你会发现真正拉开差距的往往是那几个容易混淆的类别。比如稻瘟病和稻胡麻斑病病斑都是褐色小点人眼都容易看错模型出错也正常。这时候需要做的是合并类别或者增加更细粒度的特征输入比如用更大分辨率输入局部区域。4.2 单张图片预测脚本部署阶段最常用的是单张图片的推理接口。下面是完整的预测代码import torch from torchvision import transforms from PIL import Image import json def load_model(model_path, num_classes20, devicecpu): model models.resnet18(pretrainedFalse) num_features model.fc.in_features model.fc nn.Linear(num_features, num_classes) model.load_state_dict(torch.load(model_path, map_locationdevice)) model.to(device) model.eval() return model def predict_image(model, image_path, class_names, devicecpu): 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]) ]) image Image.open(image_path).convert(RGB) input_tensor transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs model(input_tensor) probabilities torch.softmax(outputs, dim1) top_prob, top_class torch.topk(probabilities, k3) results [] for i in range(top_prob.size(1)): cls_idx top_class[0][i].item() results.append({ class: class_names[cls_idx], probability: top_prob[0][i].item() }) return results if __name__ __main__: class_names [Tomato_Bacterial_Spot, Tomato_Early_Blight, Rice_Blast, Rice_Leaf_Roller] model load_model(best_model.pth, num_classeslen(class_names)) result predict_image(model, test_images/001.jpg, class_names, devicecuda) for r in result: print(fClass: {r[class]}, Confidence: {r[probability]:.4f})实际部署的时候置信度输出的处理也很关键。我给预测结果加了置信度阈值低于0.6的界面显示“无法确认请上传更清晰的图片”。这个逻辑能大幅减少误报比模型本身提升精度还管用。你想想如果模型只有50%的把握说这是稻瘟病你直接让植保员去喷药这个责任谁都担不起。所以宁可说不知道不要瞎说。4.3 批量推理和结果导出植保站实际使用的时候往往不是一张张传图而是拿着相机在田里拍了一圈回来几十上百张图要批量处理。我加了个批量推理模式支持读取文件夹内所有图片结果输出到CSVimport csv import os def batch_predict(model, image_dir, class_names, output_csv, devicecpu): model.eval() results [] for img_name in sorted(os.listdir(image_dir)): img_path os.path.join(image_dir, img_name) preds predict_image(model, img_path, class_names, device) top preds[0] results.append([img_name, top[class], round(top[probability], 4)]) with open(output_csv, w, newline, encodingutf-8) as f: writer csv.writer(f) writer.writerow([image, predicted_class, confidence]) writer.writerows(results) print(fDone! {len(results)} images processed, result saved to {output_csv})CSV可以直接导入Excel给植保人员看他们不需要关心模型内部怎么实现的只需要知道哪块田发现了什么问题有多少張图检测出了病虫害。5. 常见问题与排查技巧实录5.1 训练Loss不下降这是遇到最多的一个坑。检查顺序很重要数据是不是错的。随便挑几张图看一下是不是类别和图片对不上数据增强是不是太激进了。Normalize的mean和std是不是写错增强过头会导致模型看不到原始特征学习率是不是太大或者太小。太大Loss容易震荡太小模型根本不动。我一般先用0.001试最后一层全连接的输出维度是不是和类别数一致。这个错误特别隐蔽你定义模型时传的num_classes和数据集实际类别数不一致跑起来就会报错或者训练崩5.2 过拟合严重训练准确率98%验证准确率只有70%教科书级别的过拟合。我当时的解决方案按优先级排列增大数据增强的强度尤其是随机裁剪和颜色扰动增加Dropout比例从0.3提到0.5减少模型容量把ResNet50换成ResNet18加大正则化给优化器加weight_decay1e-4数据实在不够就去采集别硬扛5.3 类别不平衡怎么处理植保数据特别容易不平衡因为常见病害好采集罕见病害一年碰不到几次。处理方法前面提过加权损失这是最省事的。进阶做法包括对样本少的类别做更多数据增强用Focal Loss它会让模型更关注难分类的样本采集数据时专门针对样本少的类别补拍5.4 推理速度太慢如果你的部署环境没有GPU用CPU跑ResNet50一张图要1到2秒体验很糟糕。几个提速方案模型换轻量网络MobileNetV3或者EfficientNet-Lite精度损失1到2个点速度提升3倍以上输入图片从224降到160很多病害特征在160分辨率下依然能认用TensorRT或者ONNX Runtime做推理优化CPU上也能有不错的提升我当时在朋友那台没有独显的办公电脑上部署最后用的是MobileNetV3-Small加160分辨率加ONNX Runtime单张图片推理时间从1.5秒降到了200毫秒左右完全够用。5.5 容易混淆的类别怎么优化不同病害外观相似导致的混淆不是单纯加大数据量就能解决的。我在实际项目中试过两个有效的方法用更大的输入分辨率224改成336模型能看到更细节的纹理混淆率明显下降代价是训练时间和显存占用上升。对混淆严重的类别做二次分类比如先判断是否是稻瘟病再细分为哪一型。相当于在主干模型之外加了个小模型解决特定问题。6. 文档说明怎么整理6.1 README要包含哪些内容项目标题里写着“python源码文档说明”文档说明这半边其实非常重要但恰恰是很多人不重视的部分。我分享下我的文档整理习惯。一份好的项目README至少要有这些板块项目简介一句话说明这个项目是干什么的环境依赖Python版本、PyTorch版本、其他依赖包必须写清楚最好给requirements.txt数据集结构说明目录怎么组织每个文件夹放什么训练方法怎么开始训练参数在哪里修改训练多久评估和预测怎么评估、怎么对单张图片预测常见问题把踩过的坑和解决方法写进去我见过太多开源项目代码写得很漂亮但README只有一句话“深度学习病虫害识别”别人拿到手上根本跑不起来。文档的本质是让人能复现你的工作这个价值不亚于代码本身。6.2 实验日志记录训练过程中我建了个最简单的实验记录表格每跑一次实验就记一行日期模型输入尺寸数据增强学习率训练准确率验证准确率备注04-12ResNet18224基础增强0.0010.9640.871首次全量微调04-13ResNet18224增强裁剪0.0010.9580.893只微调layer404-15MobileNetV3160增强0.0010.9410.879加速部署版本有了这个表每次调试都有了依据不会出现“我好像之前跑出来过更好的结果但忘记怎么复现”的尴尬。最后再分享一个我从这个项目里得到的真实体会做AI项目模型精度其实只占一半另一半在数据和工程细节。数据清洗做好了模型怎么跑都不会太差文档写清楚了你的代码才能真正被用起来。还有不要迷信公开数据集上的高准确率真实场景下的表现才是一切。把整个流程从数据到部署完整走一遍你收获的东西远比“又跑通了一个模型”多得多。这个项目后面还可以继续扩展把单分类升级成多标签一片叶子可能同时有病虫害加目标检测定位病斑位置或者做一个前后端分离的小程序让植保员在手机上拍照就能用。方向很多但地基就是这套分类流程。希望对你有帮助有问题欢迎交流。本文还有配套的精品资源点击获取
返回列表