ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

农作物病害识别田间翻车?从数据到部署的避坑指南

农作物病害识别田间翻车?从数据到部署的避坑指南

简介:这份资源是面向深度学习与人工智能方向的课程设计、毕业设计参考项目,聚焦农作物病害图像识别这一计算机视觉典型应用。项目围绕卷积神经网络等模型,对小麦、水稻、玉米、番茄、棉花、大豆、油菜等作物的病害样本进行训练与分类,帮助学习者理解从数据采集、模型训练到验证测试的完整流程,并掌握图像处理与模式识别相关知识。压缩包共455个文件,约119.76MB,包含93个Java源码、118个XML配置、95张PNG与6张JPG图像样本、76个CSV数据集,以及38个SO库、12个JAR依赖和Gradle构建脚本,兼顾算法实现、数据组织与工程构建。已有76人学习下载,适合需要完整项目结构、可运行代码与数据集来开展课题实践的学生参考。

1. 农作物病害识别为什么总在田间翻车:从一张玉米叶片的误判说起

你拿手机对着玉米叶片拍一张照,模型信心满满地告诉你这是「健康」,可三天后那块地全染了病。这不是段子,是很多农作物病害识别项目上线后的真实写照。基于深度学习的农作物病害识别,核心就是用卷积神经网络对叶片图像做分类或检测,输出病害类别和置信度。它要解决的是植保环节里「人眼看不准、专家不够用、巡田来不及」的问题,适合想把手里的图像分类技术落到农业场景的开发者、做智慧农业方案的工程师,以及需要快速筛查病害的种植管理者。但实验室里 99% 的准确率,到了田间往往掉到 70% 以下,原因不在模型本身,而在数据、光照、背景和采集方式。这一篇就顺着这个 zip 标题背后的技术链路,把选型、训练、部署和踩坑一次讲透。

2. 数据集怎么选怎么洗:别让背景抢了叶片的戏

农作物病害识别的第一道坎不是模型,是数据。公开数据集里最常见的是 PlantVillage 这类实验室条件下拍摄的叶片图,背景干净、光照均匀、单片叶子居中。拿它训练出来的模型,在田间复杂背景下几乎必然翻车。所以选数据集时,第一件事是判断它的采集条件和你实际要部署的场景是否一致。

2.1 公开数据集与自采数据的取舍

常见做法是先用公开数据集做预训练或基线验证,再用自采数据做微调。公开数据集类别全、标注规范,适合快速跑通流程;自采数据贴近真实场景,但标注成本高。我一般会按 7:3 的比例混合,公开数据占大头做特征学习,自采数据做场景适配。如果自采数据能到每类 300 张以上,可以逐步提高自采比例。

数据来源优点缺点建议用途
公开实验室数据集标注规范、类别全背景单一、光照理想预训练、基线对比
田间自采数据贴近真实场景标注贵、类别不均微调、验证
网络爬取图像数量大噪声高、版权风险仅作补充,需人工清洗

2.2 用脚本做数据清洗与增强

拿到原始图像后,先做去重、去模糊、去非叶片图。下面这段 Python 脚本做三件事:计算图像清晰度(拉普拉斯方差)、检查尺寸一致性、按类别统计数量。

import cv2 import os import numpy as np from collections import defaultdict def check_quality(img_path, blur_threshold=100): """返回图像是否清晰、尺寸是否合规""" img = cv2.imread(img_path) if img is None: return False, "read_fail", None gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 拉普拉斯方差越小越模糊 blur_score = cv2.Laplacian(gray, cv2.CV_64F).var() h, w = img.shape[:2] if blur_score < blur_threshold: return False, "blur", blur_score if h < 224 or w < 224: return False, "too_small", (h, w) return True, "ok", blur_score def scan_dataset(root_dir): stats = defaultdict(int) bad_files = [] for cls in os.listdir(root_dir): cls_dir = os.path.join(root_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): fpath = os.path.join(cls_dir, fname) ok, reason, info = check_quality(fpath) if ok: stats[cls] += 1 else: bad_files.append((fpath, reason, info)) return stats, bad_files if __name__ == "__main__": stats, bad = scan_dataset("./data/raw") for cls, cnt in stats.items(): print(f"{cls}: {cnt} 张可用") print(f"异常文件 {len(bad)} 个")

这段脚本的关键参数是blur_threshold,默认 100 适合大多数叶片图,如果图像普遍偏暗或纹理少,可以降到 60。scan_dataset返回的bad_files里记录了每张图被剔除的原因,方便回溯。清洗完之后,增强策略要克制:随机旋转、水平翻转、亮度微调可以,但不要用大幅裁剪和强色彩抖动,否则会把病斑特征破坏掉。

2.3 类别不平衡的处理顺序

田间数据往往健康叶片多、病害叶片少。处理顺序建议是:先统计每类数量,差距在 3 倍以内的用加权损失,差距超过 5 倍的再考虑过采样或合成。不要一上来就 SMOTE,图像合成容易产生不真实的病斑纹理,反而干扰训练。

3. 模型选型与训练:轻量骨干网络比大模型更实用

在田间部署场景里,模型不是越大越好。你最终要跑在手机、边缘盒子或者无人机上,推理速度和内存占用是硬约束。所以选型的第一原则是:在满足精度要求的前提下,参数量和计算量越小越好。

3.1 骨干网络对比与选择依据

常见的选择有 MobileNetV3、EfficientNet-Lite、ShuffleNetV2 和 ResNet50。前三个是轻量级,ResNet50 精度高但太重。我一般先用 MobileNetV3-Small 跑基线,如果精度不够再换 MobileNetV3-Large 或 EfficientNet-Lite-B0。

骨干网络参数量适合场景注意事项
MobileNetV3-Small约 2.5M手机端、实时精度上限有限
MobileNetV3-Large约 5.4M边缘设备速度与精度平衡
EfficientNet-Lite-B0约 4.7M边缘设备输入分辨率敏感
ResNet50约 25M服务器端不适合端侧

3.2 用迁移学习跑通训练流程

下面这段代码用 PyTorch 加载预训练的 MobileNetV3,替换分类头,冻结骨干前几层做微调。输入尺寸设为 224,批次 32,初始学习率 1e-3。

import torch import torch.nn as nn from torchvision import models, transforms from torch.utils.data import DataLoader from torchvision.datasets import ImageFolder # 数据增强:训练集用轻度增强,验证集只做归一化 train_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness=0.2, contrast=0.2), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) val_tf = transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]) ]) train_ds = ImageFolder("./data/train", transform=train_tf) val_ds = ImageFolder("./data/val", transform=val_tf) train_loader = DataLoader(train_ds, batch_size=32, shuffle=True, num_workers=4) val_loader = DataLoader(val_ds, batch_size=32, shuffle=False, num_workers=4) # 加载预训练模型,替换最后一层 model = models.mobilenet_v3_small(pretrained=True) num_classes = len(train_ds.classes) model.classifier[3] = nn.Linear(model.classifier[3].in_features, num_classes) # 冻结特征提取部分的前几层 for name, param in model.features.named_parameters(): if int(name.split(".")[0]) < 4: param.requires_grad = False device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model = model.to(device) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) for epoch in range(20): model.train() for imgs, labels in train_loader: imgs, labels = imgs.to(device), labels.to(device) optimizer.zero_grad() loss = criterion(model(imgs), labels) loss.backward() optimizer.step() # 验证阶段 model.eval() correct, total = 0, 0 with torch.no_grad(): for imgs, labels in val_loader: imgs, labels = imgs.to(device), labels.to(device) preds = model(imgs).argmax(dim=1) correct += (preds == labels).sum().item() total += labels.size(0) print(f"Epoch {epoch+1}, Val Acc: {correct/total:.4f}")

冻结层数int(name.split(".")[0]) < 4表示冻结 features 模块前 4 个 block,这个数字可以根据数据量调整:数据少就多冻,数据多就少冻。学习率 1e-3 适合微调,如果 loss 震荡明显,降到 3e-4。批次 32 在显存 8G 以内都能跑,显存不够就降到 16。

3.3 训练过程中该盯哪些指标

不要只看准确率。类别不平衡时,准确率会被多数类拉高。重点看每类的召回率和 F1,尤其是病害类的召回率——漏判一个病叶的代价远大于误判。如果某个病害类召回率低于 0.8,优先补该类数据或调高它的损失权重。

4. 部署到端侧:模型转换与推理速度优化

训练完的模型不能直接扔到手机上。PyTorch 模型动辄几十兆,推理也慢。端侧部署的标准路径是:PyTorch → ONNX → 目标框架(如 NCNN、TFLite、MNN)。这一步的坑比训练还多。

4.1 导出 ONNX 并检查算子兼容性

import torch from model_def import build_model # 假设模型定义在单独文件 model = build_model(num_classes=10) model.load_state_dict(torch.load("best.pth", map_location="cpu")) model.eval() dummy = torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy, "crop_disease.onnx", input_names=["input"], output_names=["output"], opset_version=11, # 11 兼容性最好 dynamic_axes={"input": {0: "batch"}, "output": {0: "batch"}} )

opset_version=11是端侧框架支持最广的版本,不要盲目用 13 或 17。dynamic_axes让模型支持变批次,但有些端侧框架对动态维度支持不好,如果只做单张推理,可以去掉这个参数。

4.2 量化与推理速度实测

导出 ONNX 后,用 ONNX Runtime 做一次量化前后的速度对比。量化能把模型体积压到原来的四分之一,推理速度提升 2 到 3 倍,精度损失通常在 1% 以内。

import onnxruntime as ort import numpy as np import time # 量化前的推理 sess = ort.InferenceSession("crop_disease.onnx") input_name = sess.get_inputs()[0].name dummy = np.random.randn(1, 3, 224, 224).astype(np.float32) start = time.time() for _ in range(100): sess.run(None, {input_name: dummy}) print(f"FP32 平均耗时: {(time.time()-start)/100*1000:.2f} ms") # 量化后的推理(需先用 onnxruntime.quantization 生成量化模型) sess_q = ort.InferenceSession("crop_disease_quant.onnx") start = time.time() for _ in range(100): sess_q.run(None, {input_name: dummy}) print(f"INT8 平均耗时: {(time.time()-start)/100*1000:.2f} ms")

实测时注意,第一次推理会包含初始化时间,要跳过前几次取平均。如果 INT8 量化后精度掉超过 3%,改用动态量化而不是静态量化,或者只量化部分层。

5. 避坑与排查:田间识别翻车的五个血泪教训

这一章是我自己踩过的坑,每条都按「现象 → 原因 → 解决」写,你对照着排查能省不少时间。

5.1 模型在实验室准、到田间全乱

现象:验证集准确率 95%,拿到田间拍的照片识别率不到 60%。原因:训练数据背景太干净,模型学到了「背景特征」而不是「病斑特征」。解决:在训练集里混入至少 30% 的田间背景图,或者用随机背景替换做增强,逼模型关注叶片本身。

5.2 同一张叶片换个角度就识别错

现象:正面拍能识别,斜着拍就错。原因:训练时叶片都是居中的,模型对旋转和透视变化不鲁棒。解决:增强里加随机旋转(±30 度)和轻微透视变换,但不要用 90 度翻转,那会改变病斑的生物学形态。

5.3 推理速度在手机上慢到不可用

现象:模型在 PC 上跑 20ms,到手机上要 500ms。原因:用了 ResNet50 或者没做量化,手机 CPU 扛不住。解决:换 MobileNetV3-Small,导出 ONNX 后做 INT8 量化,输入分辨率从 224 降到 192 也能提速 30%。

5.4 某些病害类别永远识别不出来

现象:某一种病害的召回率始终为 0。原因:该类样本太少,被多数类淹没了。解决:先确认该类至少有 100 张图,然后调高它的损失权重,或者用 focal loss 替代交叉熵。

5.5 模型文件在端侧加载失败

现象:ONNX 转 NCNN 后加载报错。原因:ONNX 里用了 NCNN 不支持的算子,比如某些自定义激活函数。解决:导出时用 opset 11,避免用高级算子;如果必须用,在转换后用工具检查算子列表,手动替换不支持的层。

6. 把识别准确率再提一截:一个被低估的技巧

前面讲的都是标准流程,最后说一个我实际用下来提升最明显的技巧:在推理阶段做多尺度测试增强。简单说,就是把同一张叶片图缩放到 192、224、256 三个尺寸分别推理,然后把三个 softmax 输出平均,取最大概率的类别。这个操作不需要重新训练,推理耗时增加约 2 倍,但在田间复杂背景下,准确率通常能提升 3 到 5 个百分点。

import cv2 import numpy as np import onnxruntime as ort def multi_scale_infer(sess, img_path, scales=(192, 224, 256)): input_name = sess.get_inputs()[0].name img = cv2.imread(img_path) img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) probs = [] for s in scales: resized = cv2.resize(img, (s, s)).astype(np.float32) / 255.0 mean = np.array([0.485, 0.456, 0.406]) std = np.array([0.229, 0.224, 0.225]) resized = (resized - mean) / std tensor = resized.transpose(2, 0, 1)[None, ...].astype(np.float32) out = sess.run(None, {input_name: tensor})[0] probs.append(out) avg_prob = np.mean(probs, axis=0) return avg_prob.argmax(), avg_prob.max() sess = ort.InferenceSession("crop_disease_quant.onnx") cls, conf = multi_scale_infer(sess, "test_leaf.jpg") print(f"类别: {cls}, 置信度: {conf:.4f}")

注意,多尺度增强对已经过拟合的模型提升有限,它主要解决的是「同一病害在不同拍摄距离下特征尺度不一致」的问题。如果你的验证集本身就是在固定距离拍的,这个技巧收益不大。另外,三个尺度的推理结果要平均 softmax 概率,不要平均 logits,后者容易被极端值带偏。

我自己的习惯是:每次模型上线前,一定拿至少 50 张田间实拍图做一次端到端测试,不看验证集指标,只看实际识别结果。这个习惯帮我拦住了好几次「实验室满分、田间零分」的翻车。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进