ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

病虫害识别系统落地:从数据到部署的工程实践与避坑指南

病虫害识别系统落地:从数据到部署的工程实践与避坑指南 简介这是一套面向农业信息化与图像处理学习者的病虫害识别系统源码基于MATLAB实现通过叶片图像自动判别植物病虫害程度帮助农业工作者快速诊断作物健康状况。资源包共95个文件以84张jpg样本图片、10个m脚本和1个mat数据文件为主压缩包约7.62MB样本按正常、轻微、中等、严重灾害分级存放脚本覆盖主流程调用、模型训练、颜色空间转换与特征匹配等环节。项目完整呈现了图像预处理、纹理与颜色特征提取、分类器训练及测试评估的技术链路涉及灰度化、直方图均衡、LBP、GLCM、SVM等常见方法并配有中等灾害示例图便于调试验证。目前已有4904人学习下载适合希望将机器学习落地农业场景、系统练习MATLAB图像处理与分类建模的读者参考借鉴。1. 病虫害识别系统从拍一张叶子到给出防治建议中间要过几道工程坎田里发现叶片上出现不规则褐斑拍照上传几秒后返回“早疫病置信度 0.91建议喷施代森锰锌”。这个链路听起来简单但真正把它做成一个可用的病虫害识别系统中间要跨过数据采集、标注质量、模型选型、端侧部署、置信度校准五道坎。我见过太多团队在实验室里跑出 98% 的准确率拉到田间地头一测连 60% 都保不住。原因往往不是模型不行而是训练数据里的叶片全是干净背景、单一光照、单一角度模型学到的是“背景特征”而不是“病斑特征”。这套系统适合三类人想给种植户做轻量工具的开发者、需要把识别能力嵌入现有农业管理平台的工程师、以及想用公开数据集快速验证方案可行性的算法同学。下面按“数据怎么准备、模型怎么选、服务怎么搭、坑怎么避”的顺序把一条能复现的路径讲清楚。2. 数据准备公开数据集怎么选、怎么清洗、怎么划分才算靠谱2.1 先搞清楚你的识别任务属于哪一类病虫害识别看起来是一个分类问题但实际落地时会拆成两种任务。第一种是单叶病害分类一张图里只有一片叶子背景干净输出是“这张叶子得了什么病”。第二种是田间场景检测一张图里有植株、土壤、杂草、多片叶子需要先定位病斑区域再分类。两者的数据要求和模型选型完全不同。新手最容易犯的错是拿 PlantVillage 这类干净背景数据集训练一个分类模型然后直接部署到田间拍照场景结果就是前面说的“实验室 98%、田间 60%”。如果你要做的是“拍一片叶子识别病害”PlantVillage 够用如果你要做的是“拍一株作物识别病害”必须用带标注框的田间数据集或者自己标注。常见做法是先用公开数据集跑通流程再用自己采集的 200500 张田间图做微调。公开数据集里PlantVillage 包含 14 种作物、26 种病害、约 5 万张干净背景叶片图适合做分类基线。PlantDoc 包含 13 种作物、27 类病害、约 2500 张田间场景图带边界框标注适合做检测和域适应验证。还有一个常被忽略的点健康叶片样本必须占一定比例。很多团队只收集病叶模型没见过健康叶上线后把健康叶也判成病害农户直接就不信了。我一般建议健康样本至少占 15%20%。2.2 用脚本做数据清洗和划分三个必须检查的边界拿到数据集后不要直接开训。下面这段脚本做三件事检查每类样本数量、剔除尺寸过小的图、按类别分层划分训练/验证/测试集。import os import shutil import random from PIL import Image from collections import defaultdict # 参数说明 # data_dir原始数据根目录每个子文件夹是一个类别 # output_dir划分后输出目录 # min_size最小边长小于此值的图直接丢弃 # split_ratio训练/验证/测试比例 data_dir ./plant_disease_raw output_dir ./plant_disease_split min_size 224 split_ratio (0.7, 0.15, 0.15) random.seed(42) class_counts defaultdict(int) skipped [] for cls in os.listdir(data_dir): cls_dir os.path.join(data_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): fpath os.path.join(cls_dir, fname) try: img Image.open(fpath) w, h img.size if w min_size or h min_size: skipped.append((fpath, too_small)) continue class_counts[cls] 1 except Exception as e: skipped.append((fpath, str(e))) print(各类样本数, dict(class_counts)) print(跳过文件数, len(skipped)) # 分层划分 for cls in class_counts: cls_dir os.path.join(data_dir, cls) files [f for f in os.listdir(cls_dir) if os.path.isfile(os.path.join(cls_dir, f))] random.shuffle(files) n len(files) n_train int(n * split_ratio[0]) n_val int(n * split_ratio[1]) splits { train: files[:n_train], val: files[n_train:n_train n_val], test: files[n_train n_val:] } for split, flist in splits.items(): dst os.path.join(output_dir, split, cls) os.makedirs(dst, exist_okTrue) for f in flist: shutil.copy(os.path.join(cls_dir, f), os.path.join(dst, f))这段脚本的逻辑很直白先遍历所有类别目录用 PIL 打开每张图检查尺寸太小的直接跳过并记录原因然后按类别分别打乱、按比例切分、复制到对应目录。random.seed(42)保证每次划分结果一致方便复现。min_size224是因为后面用的模型输入一般是 224×224原图小于这个尺寸会被强行放大细节丢失严重。划分比例 7:1.5:1.5 是分类任务的常见做法如果某类样本少于 100 张建议改成 8:1:1否则验证集太小指标波动大。注意划分前一定要检查类别是否平衡。如果某类只有几十张而另一类有几千张直接训练会导致模型偏向多数类。常见做法是对少数类做数据增强旋转、翻转、色彩抖动或者用加权采样。2.3 数据增强不是越多越好田间场景的三个有效增强数据增强在病虫害识别里特别重要因为田间光照、角度、遮挡变化很大。但不是所有增强都有效。我实测下来对田间场景最有效的三种增强是随机旋转±30 度、随机亮度对比度调整±20%、随机遮挡模拟叶片重叠。而像水平翻转这种对叶片病害分类有帮助但对“病斑在叶尖还是叶基”这种位置敏感的任务反而有害。用 torchvision 或 albumentations 都可以下面给一个 albumentations 的配置示例import albumentations as A from albumentations.pytorch import ToTensorV2 train_transform A.Compose([ A.RandomRotate90(p0.5), A.Rotate(limit30, p0.7), A.RandomBrightnessContrast( brightness_limit0.2, contrast_limit0.2, p0.7 ), A.CoarseDropout( max_holes8, max_height32, max_width32, p0.3 ), A.Resize(224, 224), A.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ), ToTensorV2() ])RandomRotate90做 90 度整数倍旋转Rotate做小角度旋转两者叠加覆盖角度变化。RandomBrightnessContrast模拟不同光照条件。CoarseDropout随机挖掉一些矩形区域强迫模型不依赖单一局部特征对遮挡场景有帮助。Normalize用的 ImageNet 均值方差如果你从零训练可以用数据集自身统计值但用预训练权重就必须保持一致。3. 模型选型与训练从 ResNet 到 EfficientNet哪个更适合端侧3.1 分类基线ResNet50 还是 EfficientNet-B0如果任务是单叶病害分类ResNet50 和 EfficientNet-B0 是两个最常被拿来对比的基线。ResNet50 参数量约 25MEfficientNet-B0 约 5.3M。在 PlantVillage 上两者都能轻松跑到 95% 以上的准确率但 EfficientNet-B0 的推理速度快 23 倍模型文件小 4 倍左右。如果你打算把模型部署到手机或边缘设备上EfficientNet-B0 是更务实的选择。如果服务器端推理、追求极致精度可以上 EfficientNet-B4 或 ConvNeXt-Tiny。我一般会先用 EfficientNet-B0 跑一个基线看混淆矩阵里哪些类容易混再决定要不要换更大模型或加注意力模块。训练时有一个关键参数经常被忽略学习率调度。用预训练权重时主干网络的学习率应该比分类头小一个数量级。比如分类头用 1e-3主干用 1e-4。如果统一用 1e-3预训练权重会被快速破坏效果反而比从零训练还差。下面是一个用 PyTorch 写的训练循环核心片段import torch import torch.nn as nn from torch.optim import Adam from torch.optim.lr_scheduler import CosineAnnealingLR from torchvision.models import efficientnet_b0, EfficientNet_B0_Weights # 加载预训练模型 weights EfficientNet_B0_Weights.IMAGENET1K_V1 model efficientnet_b0(weightsweights) num_classes 26 # 根据你的数据集类别数修改 model.classifier[1] nn.Linear(model.classifier[1].in_features, num_classes) # 分层学习率主干小分类头大 backbone_params [] head_params [] for name, param in model.named_parameters(): if classifier in name: head_params.append(param) else: backbone_params.append(param) optimizer Adam([ {params: backbone_params, lr: 1e-4}, {params: head_params, lr: 1e-3} ]) # 余弦退火调度T_max 设为总 epoch 数 scheduler CosineAnnealingLR(optimizer, T_max30) criterion nn.CrossEntropyLoss(label_smoothing0.1) # 训练循环骨架 for epoch in range(30): model.train() for images, labels in train_loader: images, labels images.cuda(), labels.cuda() optimizer.zero_grad() outputs model(images) loss criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() # 每个 epoch 后在验证集上评估保存最优权重label_smoothing0.1是防止模型对训练标签过度自信对提升泛化有帮助尤其在标注有噪声的情况下。CosineAnnealingLR让学习率从初始值平滑降到接近零比阶梯下降更稳定。T_max设成总 epoch 数如果中途早停学习率不会降到最低点这个细节很多人不注意。3.2 检测方案YOLOv8 做病斑定位的标注和训练要点如果你要做的是田间场景检测YOLOv8 是目前工程落地最顺手的选择。标注格式用 YOLO 格式每张图对应一个 txt 文件每行是类别id 中心x 中心y 宽 高坐标都归一化到 01。标注时有一个血泪经验病斑边界框不要标得太紧。太紧的框会让模型学到病斑内部纹理但田间病斑边缘模糊推理时框会偏小甚至漏检。我一般建议框比肉眼看到的病斑大 10%15%给模型一点容错空间。训练命令用 ultralytics 一行就能跑yolo detect train \ dataplant_disease.yaml \ modelyolov8s.pt \ epochs100 \ imgsz640 \ batch16 \ lr00.01 \ patience20 \ device0data指向数据集配置文件里面写清楚训练/验证路径和类别名。modelyolov8s.pt用预训练权重小模型在数据量不大时更稳。imgsz640是检测任务常用输入尺寸如果病斑很小可以提到 1280但显存和速度会受影响。patience20表示 20 个 epoch 验证指标不提升就早停省时间。训练完后用yolo detect val看 mAP50 和 mAP50-95前者看“能不能找到”后者看“框得准不准”。3.3 置信度校准为什么模型说 0.95 你也不能全信模型输出的 softmax 概率往往偏高也就是“过度自信”。一个在测试集上准确率 85% 的模型可能会对很多错误样本输出 0.9 以上的置信度。直接拿这个置信度给农户看会出问题。常见做法是做温度缩放Temperature Scaling在验证集上拟合一个温度参数 T推理时用softmax(logits / T)替代softmax(logits)。T 大于 1 会让概率分布更平滑降低过度自信。实现很简单import torch import torch.nn.functional as F def calibrate_temperature(logits, labels, devicecuda): 在验证集上搜索最优温度 T logits logits.to(device) labels labels.to(device) best_T 1.0 best_nll float(inf) for T in [0.5, 0.8, 1.0, 1.2, 1.5, 2.0, 3.0]: nll F.cross_entropy(logits / T, labels).item() if nll best_nll: best_nll nll best_T T return best_T # 推理时使用 # calibrated_probs F.softmax(logits / best_T, dim1)这段代码在验证集上遍历一组候选温度值选交叉熵损失最小的那个。推理时把 logits 除以 T 再做 softmax。校准后模型说 0.9 的时候实际准确率会更接近 90%。这个步骤在农业场景里特别重要因为农户会根据置信度决定要不要打药过度自信会导致误判和浪费。4. 服务化与端侧部署从 FastAPI 到 ONNX 的落地路径4.1 用 FastAPI 搭一个最小可用推理服务模型训练完只是第一步要让别人能用得包成 HTTP 接口。FastAPI 是目前 Python 生态里最省事的方案自带异步和文档。下面是一个最小推理服务from fastapi import FastAPI, UploadFile, File from PIL import Image import torch import torch.nn.functional as F import io from torchvision import transforms app FastAPI() model torch.load(./best_model.pth, map_locationcpu) model.eval() class_names [早疫病, 晚疫病, 健康, 叶霉病] # 按实际类别替换 best_T 1.5 # 校准温度从验证集得到 preprocess transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize( mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225] ) ]) app.post(/predict) async def predict(file: UploadFile File(...)): img_bytes await file.read() img Image.open(io.BytesIO(img_bytes)).convert(RGB) tensor preprocess(img).unsqueeze(0) with torch.no_grad(): logits model(tensor) probs F.softmax(logits / best_T, dim1) conf, idx probs.max(dim1) return { class: class_names[idx.item()], confidence: round(conf.item(), 4), all_probs: { class_names[i]: round(probs[0][i].item(), 4) for i in range(len(class_names)) } }启动命令uvicorn main:app --host 0.0.0.0 --port 8000访问/docs就能看到自动生成的接口文档。preprocess里的 Resize 256 CenterCrop 224 是标准 ImageNet 推理流程和训练时的验证集处理保持一致。best_T从校准步骤得到不要写死 1.0。返回结果里带上all_probs是为了让前端可以展示“可能性分布”农户看到“早疫病 0.85、晚疫病 0.10”会比只看到一个标签更放心。4.2 导出 ONNX 并在端侧跑三个必须对齐的参数如果要把模型放到手机或边缘设备上ONNX 是最通用的中间格式。导出时最容易翻车的地方是输入尺寸、归一化参数、输出节点名。下面是一个导出脚本import torch import torch.onnx model torch.load(./best_model.pth, map_locationcpu) model.eval() dummy_input torch.randn(1, 3, 224, 224) torch.onnx.export( model, dummy_input, plant_disease.onnx, input_names[input], output_names[output], dynamic_axes{ input: {0: batch_size}, output: {0: batch_size} }, opset_version12 )opset_version12兼容性最好大部分推理引擎都支持。dynamic_axes让 batch 维度可变方便批量推理。导出后一定要用 onnxruntime 跑一遍和 PyTorch 输出对比误差在 1e-4 以内才算对齐。端侧推理时预处理必须和训练时完全一致同样的 resize 方式、同样的均值方差、同样的通道顺序RGB 不是 BGR。我见过有人用 OpenCV 读图默认 BGR忘了转 RGB结果模型输出全乱排查了半天。提示如果端侧设备支持 NNAPI 或 CoreML可以进一步把 ONNX 转成对应格式推理速度还能再提升。但转换后一定要做数值对齐测试不要假设转换无损。4.3 批量推理和超时处理服务上线前必须压测单张推理快不代表服务能扛住并发。上线前至少做两件事一是用ab或wrk压测看 QPS 和 P99 延迟二是给推理加超时和降级。如果模型推理超过 2 秒还没返回前端应该显示“网络繁忙请重试”而不是一直转圈。FastAPI 里可以用asyncio.wait_for包一层import asyncio app.post(/predict) async def predict(file: UploadFile File(...)): try: result await asyncio.wait_for( run_inference(file), timeout2.0 ) return result except asyncio.TimeoutError: return {error: 推理超时请重试}run_inference是实际推理函数可以放到线程池里跑避免阻塞事件循环。超时时间根据你的硬件和业务容忍度调整田间网络差的时候 2 秒可能不够可以放宽到 35 秒。5. 避坑与排查五个让病虫害识别系统翻车的真实原因5.1 现象训练准确率 99%上线后农户说“全是错的”原因训练集和线上数据分布不一致。训练集是干净背景、单一光照的实验室图线上是田间自然光、复杂背景、多角度拍摄。模型学到的是背景特征而不是病斑特征。解决用田间数据做微调至少 200 张以上训练时加入强数据增强模拟光照和角度变化上线后持续收集误判样本每月迭代一次。5.2 现象模型把健康叶判成病害置信度还很高原因健康样本太少模型没见过足够的负样本。或者标注时把“疑似病斑”也标成了病害导致决策边界偏移。解决健康样本占比提到 20% 以上标注规范里明确“疑似但不确定”的样本单独一类或剔除推理时加一个置信度阈值低于阈值返回“不确定建议人工复核”。5.3 现象ONNX 推理结果和 PyTorch 不一致原因预处理不一致。最常见的是 OpenCV 读图默认 BGR而训练时用的是 RGB或者 resize 的插值方式不同PyTorch 默认 bilinearOpenCV 默认 bilinear 但实现有差异或者归一化均值方差写错。解决端侧预处理代码逐行对照训练代码用同一张图分别跑 PyTorch 和 ONNX打印预处理后的 tensor 前几个值对比确认通道顺序和归一化参数完全一致。5.4 现象服务跑一段时间后内存持续上涨原因每次推理都加载模型或创建新 tensor 没释放或者用torch.load在请求里重复加载。另一个常见原因是图片解码后没关闭PIL 的 Image 对象在某些情况下会持有内存。解决模型在服务启动时加载一次全局复用推理用torch.no_grad()包住图片处理完显式img.close()用gc.collect()定期回收虽然不优雅但有效。5.5 现象某些类别总是分不清比如早疫病和晚疫病原因这两个病在早期症状上确实很像人眼都难分模型更难。如果训练集里这两类样本标注边界模糊模型就会学混。解决先确认标注一致性找农业专家复核如果确实难分考虑合并成“疫病类”粗分类或者加一个“需实验室检测”的兜底类别也可以引入多模态信息比如结合天气数据湿度高更可能是晚疫病做后处理。6. 进阶技巧用 Grad-CAM 做可解释性让农户看到模型在看哪里病虫害识别系统要让人信服光给一个标签不够。农户会问“你凭什么说是早疫病”。这时候 Grad-CAM 就派上用场了它能把模型注意力热力图叠加在原图上显示模型是根据叶片上哪块区域做的判断。如果热力图集中在病斑上说明模型学到了正确特征如果集中在背景或叶柄上说明模型走偏了需要重新训练。实现 Grad-CAM 不需要改模型结构用pytorch-grad-cam库几行就能跑from pytorch_grad_cam import GradCAM from pytorch_grad_cam.utils.image import show_cam_on_image import numpy as np import cv2 # model 是你的分类模型target_layers 选最后一个卷积阶段 target_layers [model.features[-1]] cam GradCAM(modelmodel, target_layerstarget_layers) # 输入 tensor 和原始 RGB 图0-1 浮点 grayscale_cam cam(input_tensortensor, targetsNone) grayscale_cam grayscale_cam[0, :] # 叠加到原图 rgb_img cv2.resize(rgb_img, (224, 224)) visualization show_cam_on_image( rgb_img.astype(np.float32) / 255.0, grayscale_cam, use_rgbTrue ) cv2.imwrite(cam_output.jpg, visualization[:, :, ::-1])target_layers选最后一个卷积阶段太浅的层感受野小热力图会碎太深的层分辨率低热力图会糊。targetsNone表示用模型预测的最高分类别作为目标也可以指定某个类别看模型对那个类别的注意力。生成的cam_output.jpg可以直接返回给前端农户看到热力图集中在病斑上信任度会明显提升。我自己的习惯是每次模型迭代后随机抽 20 张验证集图片跑一遍 Grad-CAM人工扫一眼热力图有没有集中在背景上。如果发现某类样本的热力图总是偏说明这类数据有问题优先补这类样本。这个习惯帮我提前发现过好几次数据标注错误比只看准确率曲线有用得多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

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