基于Python的农作物病虫害识别分类实战:数据集与ResNet迁移学习

发布时间:2026/10/9 1:09:18
基于Python的农作物病虫害识别分类实战:数据集与ResNet迁移学习
简介基于Python的农作物病虫害识别分类项目完整交付包主要面向计算机专业毕业设计、课程设计与期末大作业场景适合需要完整可运行实战项目的学生与学习者。项目涵盖数据加载、模型构建、训练验证等核心环节并附带数据集与使用说明经过严格调试下载后即可直接运行。压缩包共96个文件主体为76个Python脚本与15个编译后的pyc文件另有2个数据集zip包、2个训练日志txt及1个说明文档整体大小约25.09MB目录结构清晰便于按功能模块查阅。已有128人学习使用。通过该项目可系统掌握农作物病虫害图像识别从数据预处理、模型搭建到训练评估的完整流程源码中集成多种主流网络结构便于扩展与二次开发是一份既能支撑毕设答辩又能提升实际工程能力的优质参考资料。1. 一份“基于python的农作物病虫害识别分类项目源码数据集使用说明.zip”值不值得解压你大概率是从某个资源站或网盘链接拿到这个压缩包的标题里同时带了源码、数据集和使用说明看起来是一条龙服务。我直接说结论这类包多数是课程设计、毕业设计或竞赛 baseline 的产物下载下来能不能跑通取决于你能不能接受“代码骨架齐全、细节需要自己补”这个现实。解压前你先想清楚自己要什么——如果是想交作业或快速出一版识别结果它能省你两三天搭框架的时间如果指望解压后直接训练出 97% 准确率的模型那多半要失望。整个链路拆开看其实就四件事把数据集按类别整理好、写一个能批量喂图的 Dataset 类、选一个预训练模型做微调、再用混淆矩阵和单张推理把模型行为说清楚。这套流程不挑具体框架PyTorch 用得最多TensorFlow 的 Keras 也能做下文以 PyTorch 为例。我后面会把每个环节的参数设置和踩坑点都写出来你拿到任何一份同类 zip都可以按这个路径来验收和改造。2. 先摸清这份 zip 的底细目录结构、数据集划分与 Dataset 封装2.1 解压后第一件事对照“使用说明”核对目录而不是直接跑 train.py我拿到任何代码包第一动作永远是看目录和 README 或 PDF 说明而不是直接运行。原因很简单这类项目压缩包往往来自不同人整理有的依赖特定文件夹命名有的把训练集和验证集放在同一个根目录下代码里却写着两套路径直接运行会立刻抛 FileNotFoundError。常见目录结构长这样project/ ├── train.py ├── predict.py ├── utils.py ├── requirements.txt ├── data/ │ ├── train/ │ │ ├── tomato_early_blight/ │ │ ├── tomato_leaf_mold/ │ │ └── tomato_healthy/ │ └── val/ │ ├── tomato_early_blight/ │ └── ... └── checkpoints/注意看 train 和 val 是不是都按类别分子目录。PyTorch 的torchvision.datasets.ImageFolder要求图片必须放在“根目录/类别名/图片.jpg”这种三层结构里如果你的压缩包里所有图片都堆在一个文件夹、靠 CSV 标类别那后面想用 ImageFolder 就得先写脚本重排。另外一个细节是类别目录名早期枯萎病这种写法配英文名有的包会用中文目录名这在 Windows 上也能跑但如果你之后要转 ONNX 或部署到 Linux 上中文路径容易引发编码问题我一般提前改成拼音或英文。核对完目录还要确认一件事requirements.txt里的依赖版本。这类源码包最常见的版本坑是 PyTorch 版本。比如torchvision.models里很多模型的预训练参数接口在 0.13 之后改过名pretrainedTrue换成了weightsResNet18_Weights.IMAGENET1K_V1你用旧代码配新环境就会报TypeError: pretrained() got an unexpected keyword argument。遇到这种情况不要急着降级把代码里的写法改成新接口即可下文模型章节我会给对应写法。2.2 不要迷信源码里现成的 Dataset自己封装一个加载器更可控很多 zip 里附带的 Dataset 类是照数据集论文写的针对的是某个私有数据集的读取逻辑。你要是直接套用自己的图片数据大概率会撞上尺寸不一致、灰度图和 RGB 图混在一起、图片后缀大小写不统一这类问题。我一般不会去改原作者的 Dataset而是新建一个custom_dataset.py从零写一个通用版本。# custom_dataset.py import torch from torch.utils.data import Dataset from PIL import Image import os class CropDiseaseDataset(Dataset): 通用的农作物病害图片分类数据集。 目录结构要求: root/class_name/*.jpg def __init__(self, root_dir, transformNone): self.root_dir root_dir self.transform transform self.classes sorted(os.listdir(root_dir)) self.class_to_idx {cls: idx for idx, cls in enumerate(self.classes)} self.samples [] for cls in self.classes: cls_dir os.path.join(root_dir, cls) if not os.path.isdir(cls_dir): continue for fname in os.listdir(cls_dir): if fname.lower().endswith((.jpg, .jpeg, .png)): self.samples.append((os.path.join(cls_dir, fname), self.class_to_idx[cls])) 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这段代码有四个关键点。第一sorted(os.listdir(root_dir))保证类别顺序在所有机器上一致因为class_to_idx的映射在训练和推理时必须一模一样顺序错乱会让模型预测结果完全对不上。第二convert(RGB)把灰度图强制转成三通道避免一个数据集里混着灰度图和彩色图导致训练报维度错误。第三文件后缀判断用了.lower()防止压缩包里出现.JPG和.jpg混用的情况。第四__len__返回的是所有图片总数如果你的数据集有几万张图但内存不够可以在这里做路径缓存读图只发生在__getitem__这一步。写完这个之后别急着跑训练用一小段代码先验证加载器是否正常from torchvision import transforms from torch.utils.data import DataLoader 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]) ]) dataset CropDiseaseDataset(data/train, transformtransform) print(f样本总数: {len(dataset)}, 类别数: {len(dataset.classes)}) loader DataLoader(dataset, batch_size8, shuffleTrue, num_workers0) images, labels next(iter(loader)) print(f单批次图像张量形状: {images.shape}, 标签: {labels})这一段是调试 Dataset 最快的方式。num_workers0在 Windows 上更稳因为 Windows 下num_workers0时 DataLoader 需要在if __name__ __main__里运行否则多进程会递归加载主模块报错。确认张量形状是[8, 3, 224, 224]、标签是一维整数以后再进训练环节。2.3 训练集验证集别混在一起写一个带随机种子和样本数统计的划分脚本这类 zip 里最常见的一个坑是数据集的 train 和 val 文件夹里内容雷同或者是压缩包只给了全部图片划分脚本缺失。原作者可能提交时把划分逻辑写进了自己电脑的临时脚本里打包时漏了。这时候最稳妥的做法是自己重写一个划分脚本常见做法是sklearn.model_selection.train_test_split但要注意按类别分层不然随机划分可能让某些小类别在验证集里一个样本都没有。# split_data.py import os import random import shutil from collections import Counter random.seed(42) source_dir data/all_images # 所有按类别分好子文件夹的图片根目录 target_dir data/split train_ratio 0.8 # 训练集比例 val_ratio 0.1 # 验证集比例剩余0.1为测试集 for class_name in os.listdir(source_dir): class_path os.path.join(source_dir, class_name) if not os.path.isdir(class_path): continue images [f for f in os.listdir(class_path) if f.lower().endswith((.jpg, .jpeg, .png))] random.shuffle(images) n_train int(len(images) * train_ratio) n_val int(len(images) * val_ratio) for split_name, split_list in zip( [train, val, test], [images[:n_train], images[n_train:n_train n_val], images[n_train n_val:]] ): dest_dir os.path.join(target_dir, split_name, class_name) os.makedirs(dest_dir, exist_okTrue) for img_name in split_list: shutil.copy2(os.path.join(class_path, img_name), os.path.join(dest_dir, img_name)) print(f{class_name}: 总样本 {len(images)} f训练 {n_train}验证 {n_val}测试 {len(images) - n_train - n_val})这个脚本里的random.seed(42)是重点。固定随机种子能保证你每次划分结果一致提交论文或实验结果时别人能复现你的数据分布。shutil.copy2比shutil.copy多保留文件元数据对大图片集来说能省一点复制时间。另外我建议打印每个类别的样本数统计因为后续类别不平衡处理完全依赖这份统计信息。3. 识别分类模型选型与训练为什么默认用预训练 ResNet18微调参数怎么设3.1 从零训练在几百张图上基本是浪费 GPU迁移学习的逻辑农作物病虫害识别分类的数据集规模一般是多少公开数据集像 PlantVillage 有 5 万多张图但你下载的压缩包里面常见的其实是某几个类别的子集多的几千张少的几百张。在这个量级下从零训练一个深层卷积网络几乎不可能收敛到实用精度——图像分类任务动辄需要百万级数据来拟合随机初始化的深层网络而病虫害特征病斑纹理、叶片颜色变化又强依赖局部纹理细节小数据集上很容易直接过拟合。迁移学习的做法是加载一个在 ImageNet 上训练好的模型权重把最后一层全连接换成你自己的类别数然后冻结前面大部分层、只微调最后几层或者以较小的学习率微调全部层。ImageNet 上学习到的纹理、边缘、形状特征对叶片病斑识别是很好的初始化这比随机初始化良好了太多。我一般选 ResNet18 而不是 ResNet50 或更深模型原因有二虫病数据集类别之间差异有时候非常细微比如早疫病和晚疫病但 ResNet50 在小数据集上微调更容易过拟合且训练速度慢一倍以上ResNet18 的 1100 万参数量在 CPU 上也能勉强推理后续转部署更现实。如果你发现 ResNet18 准确率不够先加数据增强和数据量而不是直接上更深网络。3.2 端到端的训练脚本权重加载、学习率、优化器与混合精度在拿到 zip 里自带训练脚本的基础上我通常会重写一个更简洁的版本避免原脚本里夹杂无关功能。下面这个脚本是完整可用的主干覆盖了权重下载、数据加载、训练循环和模型保存。# train.py import torch import torch.nn as nn import torchvision from torchvision import models, transforms, datasets from torch.utils.data import DataLoader import os device torch.device(cuda if torch.cuda.is_available() else cpu) num_classes 5 # 改成你的实际类别数 batch_size 32 epochs 30 lr 1e-4 # 微调阶段学习率不宜大 weight_decay 1e-4 transform_train transforms.Compose([ transforms.Resize((256, 256)), transforms.RandomResizedCrop(224, scale(0.7, 1.0)), transforms.RandomHorizontalFlip(), transforms.RandomRotation(15), transforms.ColorJitter(brightness0.2, contrast0.2, saturation0.2), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) transform_val 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(data/split/train, transformtransform_train) val_dataset datasets.ImageFolder(data/split/val, transformtransform_val) train_loader DataLoader(train_dataset, batch_sizebatch_size, shuffleTrue, num_workers4) val_loader DataLoader(val_dataset, batch_sizebatch_size, shuffleFalse, num_workers4) model models.resnet18(weightsmodels.ResNet18_Weights.IMAGENET1K_V1) model.fc nn.Linear(model.fc.in_features, num_classes) model model.to(device) criterion nn.CrossEntropyLoss() optimizer torch.optim.AdamW(model.parameters(), lrlr, weight_decayweight_decay) scheduler torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_maxepochs) for epoch in range(epochs): model.train() running_loss 0.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) model.eval() correct 0 total 0 with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, predicted torch.max(outputs, 1) total labels.size(0) correct (predicted labels).sum().item() val_acc 100.0 * correct / total scheduler.step() print(fEpoch [{epoch1}/{epochs}] Loss: {running_loss/len(train_dataset):.4f} fVal Acc: {val_acc:.2f}%) if val_acc best_acc: best_acc val_acc torch.save(model.state_dict(), checkpoints/best_model.pth)训练脚本里有几个参数值得单独说。lr1e-4是微调阶段的安全起点如果你只冻结前几层只训练后面的层可以调到5e-4让收敛更快但全量微调时1e-4起步更稳。RandomResizedCrop(224, scale(0.7, 1.0))与RandomRotation(15)是病虫害识别里最有效的两个增强前者模拟叶片在不同视角下的尺度变化后者模拟叶片摆放角度随机ColorJitter幅度注意不要太大病害颜色本身是判据颜色抖动过大会让模型学偏。CosineAnnealingLR配合 AdamW 是图像分类微调的常规组合它让学习率沿余弦曲线下降到接近零比固定学习率在最后几个 epoch 更稳定直接看验证集准确率曲线不容易出现在最后阶段震荡。另外要注意model.fc nn.Linear(model.fc.in_features, num_classes)这一行。如果你想换成models.resnet50这一行的model.fc照样适用因为 ResNet 系列最后一层都叫fc但如果你换成 EfficientNet 或 ViT分类头属性名就变了。我把model.state_dict()单独保存而不是保存整个模型是为了之后换设备或做推理时可以用load_state_dict灵活加载也不会把优化器状态带上导致文件过大。3.3 类别数改了、图片尺寸也改了两个最容易被忽略的预处理对齐问题训练脚本里很容易翻车的一个点原 zip 里的代码可能是针对 10 类的你换成 5 类只改了num_classes但忽略了model.fc nn.Linear(model.fc.in_features, num_classes)之前预训练权重和类别数的匹配。这行代码本身没问题但如果你从网上下到的是已经被原作者改成某个特定类别数的权重文件而不是官方 ImageNet 权重那加载时就会报size mismatch for fc.weight之类的错误。另一个被忽视的点是图片尺寸对齐。训练时你用Resize((256, 256))再RandomResizedCrop(224)验证时用Resize((224, 224))这没问题。但如果原 zip 里的脚本写的是Resize((299, 299))那是 Inception 系列的标准输入你的模型是 ResNet18输入尺寸是 224两者对不上模型推理效果会明显下降。预处理里的Normalize用的 ImageNet 均值标准差mean[0.485, 0.456, 0.406]是固定值除非你用自己数据集算出的均值方差否则不要改动。4. 把训练得到的模型说清楚混淆矩阵、单张推理与置信度阈值4.1 验证集准确率会骗人用混淆矩阵看哪两个类别在互相打架训练结束后90% 以上的情况你只会得到一个best_model.pth和一句话“验证集准确率 95%”。但这个数字掩盖了太多信息你的模型可能对某个类别准确率是 99%对另一个只有 60%也可能两类病害长相接近模型总是在这两个类之间摇摆。混淆矩阵是唯一能快速暴露这种问题的工具。# evaluate.py import torch import numpy as np import matplotlib.pyplot as plt import seaborn as sns from torchvision import models, transforms, datasets from torch.utils.data import DataLoader device torch.device(cuda if torch.cuda.is_available() else cpu) num_classes 5 class_names [early_blight, late_blight, healthy, leaf_mold, mosaic_virus] model models.resnet18(weightsNone) model.fc torch.nn.Linear(model.fc.in_features, num_classes) model.load_state_dict(torch.load(checkpoints/best_model.pth, map_locationdevice)) model.to(device) model.eval() transform_val transforms.Compose([ transforms.Resize((224, 224)), transforms.ToTensor(), transforms.Normalize(mean[0.485, 0.456, 0.406], std[0.229, 0.224, 0.225]) ]) val_dataset datasets.ImageFolder(data/split/val, transformtransform_val) val_loader DataLoader(val_dataset, batch_size32, shuffleFalse, num_workers4) all_preds [] all_labels [] with torch.no_grad(): for images, labels in val_loader: images, labels images.to(device), labels.to(device) outputs model(images) _, preds torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.cpu().numpy()) cm np.zeros((num_classes, num_classes), dtypeint) for true_label, pred_label in zip(all_labels, all_preds): cm[true_label, pred_label] 1 plt.figure(figsize(8, 6)) sns.heatmap(cm, annotTrue, fmtd, xticklabelsclass_names, yticklabelsclass_names, cmapBlues) plt.xlabel(Predicted) plt.ylabel(True) plt.savefig(confusion_matrix.png, dpi150)代码里的zip(all_labels, all_preds)直接构造混淆矩阵没有用sklearn.metrics.confusion_matrix是因为后者返回的矩阵标签排序顺序有时和你的class_names顺序不一致手写循环更可控。看混淆矩阵时重点看对角线之外的非零值集中在哪两个类之间。如果early_blight经常被预测为late_blight说明这两类在纹理和颜色上本来就很接近不是模型坏了是数据本身的类间相似度高。应对手段有两个一是收集更多这两类的样本二是往后端换更强的骨干网络。混淆矩阵的另一个作用是确认验证集是否平滑——如果某行全为零说明这个类别在验证集里一个样本都没有回到第 2 章的划分脚本去查。4.2 写一个单张推理脚本加载模型之外还要对齐预处理参数每次做推理前都打开训练脚本找预处理代码再复制一份这种做法很容易出错。我通常把预处理单独拆成get_transform()函数放在一个utils.py里训练和推理共用。单张推理脚本重点不在加载模型而在把一张任意尺寸的本地图片变换成和训练时完全一致的输入分布。# predict.py import torch from torchvision import models, transforms from PIL import Image device torch.device(cuda if torch.cuda.is_available() else cpu) class_names [early_blight, late_blight, healthy, leaf_mold, mosaic_virus] 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]) ]) model models.resnet18(weightsNone) model.fc torch.nn.Linear(model.fc.in_features, len(class_names)) model.load_state_dict(torch.load(checkpoints/best_model.pth, map_locationdevice)) model.to(device) model.eval() image_path test_data/sample_001.jpg image Image.open(image_path).convert(RGB) input_tensor transform(image).unsqueeze(0).to(device) with torch.no_grad(): outputs model(input_tensor) probs torch.softmax(outputs, dim1).cpu().numpy()[0] top_indices probs.argsort()[::-1] for rank, idx in enumerate(top_indices[:3]): print(fTop{rank1}: {class_names[idx]} ({probs[idx]*100:.2f}%)) conf_threshold 0.7 if probs[top_indices[0]] conf_threshold: print(置信度过低建议人工复核或重新拍摄更清晰的叶片图像)这段代码里最容易出错的是unsqueeze(0)。单张图片经过 transform 后的 shape 是[3, 224, 224]但模型期望的输入是[N, 3, 224, 224]unsqueeze(0)就是在 batch 维度上补一个 1。如果你拿到的是原 zip 里自带的推理脚本这个细节往往被原作者写成了.unsqueeze_(0)或直接input_tensor transform(image)前者是原地操作容易和后面的变量引用纠缠后者直接报错。4.3 置信度阈值不是固定 0.5什么场景调高什么场景应该调低识别分类落地的核心问题不是“模型认不认得出来”而是“模型不确定时该怎么办”。农田环境拍的叶片图片往往有泥点、遮挡、光照不均模型硬着头皮给个预测预测错了就是一次误报。我一般设两个阈值低阈值 0.5用来筛掉完全无意义的结果高阈值 0.7 到 0.8超过这个值才认为是可靠结果。介于两者之间的输出直接标记为“疑似”交给人工确认。调高阈值的场景是和农技站联动做防治决策——宁可漏报也不误导打药调低阈值的场景是早期病害筛查——先把可疑叶片挑出来后面反正有人工复核环节漏掉一株晚疫病比多跑一趟的代价大得多。这里没有绝对正确的数阈值的选择本质上是“漏报成本”和“误报成本”之间的权衡你要做的是在代码里预留一个conf_threshold变量让使用说明里写明怎么调而不是把阈值写死在条件判断里。5. 常见问题排查5 类翻车情形与处理办法5.1 图片路径或格式导致训练中断现象训练脚本跑第一个 epoch 时报UnidentifiedImageError或OSError: image file is truncated程序直接退出。原因压缩包里混入了非图片文件常见的有.db缓存文件、Mac 系统生成的.DS_Store、微信传输产生的.txt说明文件还有损坏的 JPEG 图片。Image.open碰到这些文件会抛异常而 DataLoader 的默认collate_fn不会帮你跳过坏样本。解决在数据集类里捕获读取异常并跳过该样本同时用PIL.ImageFile.LOAD_TRUNCATED_IMAGES True容忍部分截断图片但要记录跳过数量方便后续排查。from PIL import ImageFile ImageFile.LOAD_TRUNCATED_IMAGES True try: image Image.open(path).convert(RGB) except Exception as e: print(f跳过损坏图片: {path}, 错误: {e}) return self.__getitem__((idx 1) % len(self.samples)) # 跳至下一张5.2 训练集准确率高但验证集准确率低现象训练集准确率 99%验证集只有 70% 上下损失曲线在验证集上逐渐回升。原因这是典型的过拟合。小数据集上模型记忆了训练集的纹理噪声而不是学病害的共性特征。另一个隐蔽原因是验证集划分时没固定随机种子每次运行划分不同导致你拿不同分布的验证集自欺欺人。解决先确认划分脚本的random.seed固定。然后增强正则化把weight_decay从1e-4提到1e-3在RandomResizedCrop里把scale下限从 0.7 降到 0.5增加RandomHorizontalFlip之外再叠加RandomVerticalFlip。如果还不行那就是数据量确实太少优先考虑用旋转和裁剪做离线扩充而不是换更深模型。5.3 验证集准确率很高但测试集表现崩盘现象训练时验证集稳定在 95%但你自己留出的测试集只有 60%或者是实地拍摄的图片效果极差。原因验证集和训练集来自同一个文件夹的随机划分两张图可能是在同一株叶片上连续拍的背景、光线几乎一致。模型实际学的是“这张图看起来像训练集的那张”而不是“这种病斑长这样”。这就是为什么我坚持要划出独立的 test 集且保证同一植株的图片不会同时出现在训练集和测试集——这个统计在划分脚本里很难自动做需要在整理数据集时按拍摄编号检查。解决重新整理数据集按“植株个体”而不是“单张图片”划分。如果压缩包里数据集本身没有拍摄信息退而求其次的做法是去网上爬一些真实田间场景的病叶图来测至少能暴露模型在背景复杂的图片上的真实水平。5.4 GPU 显存溢出 OOM现象CUDA out of memory程序终止。原因多数情况不是显存真的不够而是你开的 batch size 太大。ResNet18 在 224x224 输入下batch size 32 占用约 3-4GB 显存很多入门级显卡只有 6GB还同时开了num_workers4给图像加载进程分配额外显存。解决把batch_size降到 16 或 8num_workers降到 2。如果显存仍然不够改用混合精度训练PyTorch 的torch.cuda.amp.autocast配合GradScaler能让显存占用减少近一半。我这里补一句新版本 PyTorch 里torch.cuda.amp仍然能用如果你的环境是 PyTorch 2.x写法有更新但思路一致先找官方文档确认版本再参考网上混精代码混合精度的常见配置依赖你的显卡对 FP16 的支持程度这也算一个坑。5.5 训练过程中 loss 变成 NaN现象训练跑着跑着 loss 变成 nan验证准确率也掉到无法理解的程度。原因学习率过大导致梯度爆炸或者数据里有异常值比如全黑图片、纯白图片让 loss 计算不稳定。农作物病害数据里经常出现对着土地拍的废片归一化之后全是同一个像素值网络输出会异常。解决先调低学习率到1e-5验证是不是学习率问题然后检查数据集中是否有异常图片写个脚本把所有图片的均值像素统计一遍把均值接近 0 或接近 255 的图拎出来删掉。最后在损失计算处加一个torch.isnan(loss)的判断一旦出现就停止避免带着 NaN 继续跑浪费时间。很多人遇到 NaN 第一反应是降低学习率其实先排查数据更高效。6. 进阶把分类脚本升级成大图识别、可视化解释与模型部署前五章解决的是“单张叶片图分类”但这离真实使用还差一步。农技人员拍的照片通常不是一片叶子而是一整株作物或一大片田地的局部你要识别的是图里某一块叶片的病斑。我常用的做法是滑窗裁剪用一个 224x224 的窗口在整张大图上按步长滑动每个窗口分别过模型汇总所有窗口的预测结果以置信度最高且超过阈值的窗口作为最终判定。步长选窗口宽度的一半重叠区域多一些避免病斑刚好跨在窗口边界上被拆成两半。另一个建议是加 GRAD-CAM 可视化把模型判定“有晚疫病”的依据画成热力图叠加到原图上。这一步不是锦上添花而是给使用的人一个核验依据热力图若落在褐色的病斑区域说明模型学对了若落在叶片边缘或背景土壤上说明模型在偷懒靠背景特征找答案这种模型在真实田间的泛化能力一定差。这不是玄学是我们在项目验收里必做的环节省掉的话很难向农户或导师解释“为什么机器说这是稻瘟病”。至于部署如果你的目标是让模型在田间平板上跑建议把best_model.pth转成 ONNX 再做量化。PyTorch 的torch.onnx.export转 ONNX 很简单但有两件事必须处理输入输出名要显式指定避免后续推理时按位置猜动态轴要注明 batch 维度可变否则每次只能推理一个 batch。转完 ONNX 后再用 OpenVINO 或 ONNX Runtime 加速通常能在不损失太多精度的情况下把推理时间压缩到原来的一半以下。最后说一个我自己踩过最深的教训有一年我拿到的病害数据集里健康叶片全是晴天拍的病害叶片全是阴天拍的模型最终不看病斑靠识别天气来分类测试准确率虚高。从那以后我每做完一个分类模型都会手动检查模型在某些极端输入——比如纯色背景、反转颜色、大块遮挡图像上的行为如果模型在这些图上仍然给出高置信度说明它学到的是背景特征而不是病害特征这类模型在原 zip 代码的基础上再怎么调参都没用需要回到数据采集环节补样本。这个习惯帮我挡住了后面不止一次“看起来很美、落地就废”的模型也希望能帮到你。本文还有配套的精品资源点击获取