☰
深度学习实战:水稻叶部病害识别从数据到部署全攻略
2026/10/4 22:49:33 网站建设 项目流程

简介:图像识别技术正从通用场景走向细分农业领域,其中卷积神经网络通过提取纹理、颜色等特征,让计算机具备像植保专家一样的病害判断能力。水稻叶部病害识别作为典型落地场景,依赖高质量图像数据与合理的迁移学习策略,在PyTorch框架下完成模型训练与调优。实际工程中还需解决类别不均衡、数据泄露、边缘设备部署等问题。本文结合完整项目实践,系统梳理水稻叶部病害识别的数据采集、增强、模型选型、训练技巧、评估指标与ONNX导出流程,并借助Grad-CAM可视化验证模型关注区域,为农业AI落地提供可复用的技术路径。

1. 稻叶上那点斑,深度学习模型到底能认出几分

傍晚的田埂上,一个人举着手机蹲在稻丛边,对着叶片来回找角度按快门。这不是摄影爱好者在采风,而是植保站的常规操作:把带病叶片拍下来,拿回电脑前让人一张张看。而“基于深度学习的水稻叶部病害识别方法研究”这个标题,本质上就是把“人眼看病斑”这件事交给卷积神经网络去做——拍一张稻叶照片,输入模型,输出“稻瘟病/白叶枯病/胡麻斑病”这样的结论。它解决的是基层植保人手不足、老专家经验不可复制、靠肉眼巡检效率低的现实问题。适合三类人看:想把识别模型从论文落到田间的算法工程师、要给农户做拍照识病小程序的农业物联网团队、以及被毕业论文选题按在键盘上的农学研究生。这篇笔记不聊论文怎么写,只讲这套方法从头到尾怎么做通、参数怎么设、以及哪些环节会让你一晚上白干。

2. 数据比模型更值钱:水稻叶部病害图像从哪里来、怎么喂给网络

2.1 病害数据集的三种来源:公开集、田间自采、实验室补拍

做“基于深度学习的水稻叶部病害识别方法”,第一个绕不开的问题是:图片从哪来。我见过不少人一上来就搭ResNet,结果训练到第三天发现验证集准确率卡在78%上不去,回头一查是数据只有八百张、其中三类病还是同一个角度拍的。数据来源常见有三种,每种都有自己的脾气。

公开数据集是起步最省事的做法。水稻病害方向比较常用的公开集有水稻病害识别数据集(Rice Disease Dataset)、IPM Images这类带标签的农业图像库,里面稻瘟病、纹枯病、白叶枯病的图片基本都有。但公开集的毛病是“场景太干净”——很多图是近距离特写、单一背景、光线均匀,模型在这种图上练出来,一到水田实拍就露馅。

田间自采是让模型“接地气”的关键。用手机在上午9点到11点、下午3点到5点这两个时段拍,光线斜射,叶片反光少。每片病叶至少拍3个角度,同时把健康叶、虫咬叶、机械损伤叶也拍进去。这里有个从业者很容易忽略的点:水稻叶部病害识别不只是“判断有没有病”,还得“判断是不是这种病”,而虫咬和机械伤的纹理在卷积网络眼里有时和病斑非常像。我在做数据采集时一般会专门建一个“other_damage”类,把非病害叶片归进去,宁可多分一类,也不让模型把杂物硬塞进病害类别。

实验室补拍是给数据做“兜底”。田间采不到某些病害的典型症状期,就去种植温室里人工接菌,等发病后用白纸板背景补拍,光线不够就上两盏LED平板灯,左右45度角打光,避免叶片油亮反光形成高光斑。这块的成本不高,但能让少数类样本的完备度明显提升。三种来源的比例上,我一般控制在公开集:田间自采:实验室补拍约3:5:2,田间自采要占大头,否则模型很难适应真实场景。

2.2 数据不平衡与标注一致性:决定模型上限的两个前置问题

数据量凑够了,真正的坑在水面下。水稻叶部病害类别天然不平衡:稻瘟病发生面积大、图片容易拍到,往往占数据集的一半以上;而稻曲病、胡麻斑病这类要么发生不规律、要么症状不明显,样本量常常只有几十张。深度学习模型在极端不平衡下会把大头类学得很好,对小类直接摆烂——表现为少数类的召回率不到40%,而这种表现在混淆矩阵里一目了然。

解决不平衡,优先级是:采集 > 重采样 > 损失函数。优先回田间补拍小类样本,哪怕只多二十张真实图片,也比用复制粘贴造出一百张强。补不到再考虑在训练时做类别加权采样,让每个epoch里小类被抽到的概率更高。更进一步的可以换损失函数,常见做法是把交叉熵换成Focal Loss,让模型把注意力放到难分的少样本上。不过这招要在模型已经能正常收敛之后再上,一上来就换损失函数,反而经常把训练搞得不稳定。

标注一致性是同样要命但更隐蔽的问题。水稻病害早期的病斑形态区分度很低,稻瘟病急性型病斑是“灰绿色水渍状”,白叶枯病是“枯黄色条斑”,但在图片上如果只有手指肚大一块,标注员A和标注员B的判断可能完全不同。我的做法是:写一页纸的标注规范,配上每个病害的典型图和非典型图各5张,灌给每个标注的人看完再动手;每标200张抽查30张,发现分歧超过10%就停下来重新对齐标准。这个环节花半天时间,省的是后面整个训练周期的返工成本。

2.3 在PyTorch里搭一个带增强的Dataset:代码与参数

数据准备好了,下一步是把图片文件夹变成PyTorch能吃的Dataset。这里我直接用torchvision的ImageFolder加上transforms来组织,训练集和验证集的增强策略分开写,这是一个关键取舍:训练集需要足够强的扰动让模型见多识广,验证集则要尽量保持原始形态,增强太激进会污染评估指标。

import torch from torchvision import datasets, transforms from torch.utils.data import DataLoader, WeightedRandomSampler # 训练集增强:旋转、翻转、亮度扰动、透视变化一起上 train_transforms = transforms.Compose([ transforms.RandomResizedCrop(size=224, scale=(0.6, 1.0)), transforms.RandomHorizontalFlip(p=0.5), transforms.RandomVerticalFlip(p=0.3), transforms.RandomRotation(degrees=15), transforms.ColorJitter(brightness=0.3, contrast=0.3, saturation=0.2), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) # 验证集只做缩放和中心裁剪,不做任何随机扰动 val_transforms = 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]) ]) train_dataset = datasets.ImageFolder('./data/train', transform=train_transforms) val_dataset = datasets.ImageFolder('./data/val', transform=val_transforms) # 类别加权采样:样本少的病害类别,每个epoch多抽几次 label_list = [s[1] for s in train_dataset.samples] class_counts = torch.bincount(torch.tensor(label_list)).float() class_weights = 1.0 / class_counts sample_weights = class_weights[torch.tensor(label_list)] 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, pin_memory=True) val_loader = DataLoader(val_dataset, batch_size=32, shuffle=False, num_workers=4, pin_memory=True)

这里有一个关键参数值得单独说:RandomResizedCrop的scale范围。病害识别的病斑常以像素尺度出现在叶片上,如果scale设得太低比如(0.2, 1.0),增强后病斑被放大到看不清纹理,模型会把“模糊”当成特征来学。水稻叶片病害建议scale保持(0.6, 1.0),让裁剪后的图仍然保留足够清晰的病斑形态。ColorJitter的幅度也别太大,叶片颜色是诊断的重要依据之一,调得太狠会让稻瘟病的“灰绿色”变成“枯黄色”,反而制造噪声。WeightedRandomSampler里的replacement=True保证小类样本在采样时允许重复抽取,否则类别权重就起不到增采样效果。

2.4 训练集/验证集/测试集划分:按植株不按图片

数据切分是许多人最容易犯低级错误的地方。如果同一株稻叶的多个角度的照片被随机分到了训练集和验证集,那么验证结果会虚高——模型在某张照片上学到的背景纹理,会在另一张同株的照片上“作弊”。这种泄露在深度学习里叫数据泄露,会让你的模型在实验室指标上好看得离谱,下地一测就露馅。

我一般会按“植株”划分而不是“图片”划分:采集时每株水稻给一个编号,文件名前缀带上,比如plant_023_leaf_2_diseased.jpg,切分时保证同一个植株编号的所有图片全部进同一个集合。这样验证集和测试集反映的是“没见过的新植株”,而不是“同一个体换了个姿势”,评估才有参考价值。划分比例上,6:2:2是稳妥的底限,数据量少于一千张时,训练集比例可以提到7成,测试集仍然留足2成不动。

3. 模型选型与训练:一个能跑通水稻叶部病害识别的PyTorch最小闭环

3.1 病害识别选什么网络:从ResNet到EfficientNet的取舍

在基于深度学习做水稻叶部病害识别这个方向上,模型选型有个很实际的约束:既要在田里能用,又别把实验室机器烧穿。病斑识别本质上是细粒度图像识别——类间差异小(稻瘟病和胡麻斑病的斑点是颜色和形状的双重近似),这对网络的纹理提取能力要求比普通分类高。

ResNet50是这个任务里最稳的起手式。残差结构在数据量不大的时候不容易退化,torchvision里带预训练权重,加载方便,参数量2500万在单张消费级GPU上能跑。比它更深的ResNet101收益有限,病害识别的瓶颈往往在数据层面而非模型容量,堆深度不如把数据搞干净。EfficientNet系列里B3到B4是性价比区间,同样的准确率下参数量小一半,适合后面要往树莓派、Jetson这类边缘设备上部署的场景。MobileNetV3则属于“牺牲一点准确率换部署顺畅”,如果目标是做小程序端的拍照识病,可以直接从它开始。VGG系列不建议碰——参数量大、训练慢,在这个任务里没有任何优势。

结构选型之外,一个被低估的决定是输入分辨率。ImageNet标准的224x224在处理病斑时经常不够用,稻叶上绿豆大小的初期病斑缩到224分辨率下只剩十几个像素。如果显存允许,把输入调成320或384,病斑纹理细节能明显保留。我在做这个方向时,两个分辨率都跑过对比,320输入相比224的top-1准确率普遍能高出2到3个百分点,代价只是训练时间增加约40%。

3.2 迁移学习:用ImageNet预训练权重给病害模型“打地基”

水稻叶部病害识别有个天然痛点:专业数据量少,哪怕全部收集齐也到不了ImageNet量级。从头训练一个ResNet50需要几百万张图才能收敛出有效的底层特征,而病害数据撑死几千张,唯一靠谱的路线是迁移学习——把模型在ImageNet上已经学好的边缘、纹理、颜色等底层特征“搬过来”,只微调高层语义部分。

加载预训练权重的姿势有两种。第一种是直接torchvision官方的weights接口,省事。第二种是自己先在一个大而杂的植物病害数据集上预训练,再在稻叶数据上微调,效果通常更好,因为中间域的图像分布更接近目标域,但实现成本高,前期要有足够数据。对大多数项目,直接用ImageNet权重就已经能给出相当好的起点。关键是训练策略要与迁移学习匹配:预训练模型已经具备了很强的底层特征提取能力,如果从头就用大学习率,会把预训练权重冲得稀碎,那还不如不加载。后面3.3节的训练脚本里采用了冻结骨干、分段学习率的方案,这是迁移学习落地时最常用的稳健选择。

3.3 完整训练脚本与关键参数:batch size、学习率与数据增强强度

下面这份脚本是我做水稻叶部病害识别时最常用的一套配置:ResNet50骨架,加载ImageNet预训练权重,先冻结骨干只训练分类头,等分类头收敛后再解冻整个网络用小学习率微调。这套两阶段策略在数据量处于1000到5000张区间时,比全程全参数训练稳定得多。

import torch import torch.nn as nn import torch.optim as optim from torchvision import models from tqdm import tqdm # 加载预训练ResNet50,把最后一层改成自己的类别数 model = models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) num_classes = 6 # 稻瘟病、白叶枯病、纹枯病、胡麻斑病、稻曲病、其他损伤 model.fc = nn.Linear(model.fc.in_features, num_classes) device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model = model.to(device) # 阶段一:冻结backbone,只训练新加的fc层 for param in model.parameters(): param.requires_grad = False for param in model.fc.parameters(): param.requires_grad = True criterion = nn.CrossEntropyLoss() # fc层是随机初始化的,学习率可以给大一点 optimizer = optim.Adam(filter(lambda p: p.requires_grad, model.parameters()), lr=1e-3) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=10, gamma=0.5) # 前10个epoch只训练分类头 for epoch in range(10): model.train() for inputs, labels in tqdm(train_loader): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() print(f'Stage1 Epoch {epoch+1}, Loss: {loss.item():.4f}') # 阶段二:解冻backbone,全参数微调,学习率放低 for param in model.parameters(): param.requires_grad = True # 全参数训练时学习率降到1e-4,避免破坏预训练权重 optimizer = optim.Adam(model.parameters(), lr=1e-4) scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=15, gamma=0.5) for epoch in range(30): model.train() for inputs, labels in tqdm(train_loader): inputs, labels = inputs.to(device), labels.to(device) optimizer.zero_grad() outputs = model(inputs) loss = criterion(outputs, labels) loss.backward() optimizer.step() scheduler.step() print(f'Stage2 Epoch {epoch+1}, Loss: {loss.item():.4f}') torch.save(model.state_dict(), 'rice_disease_resnet50.pth')

batch size的选择要同时看显存和数据规模。32在ResNet50+224输入下是消费级显卡的安全值,24G显存的卡可以提到64,但batch size过大会让BatchNorm的统计量更平滑,收敛虽稳但容易陷入尖锐极小值,泛化反而下降。数据量只有一两千张时,batch size 16到32足够,不要盲目加大。学习率的设置原则是“迁移学习永远从保守开始”——阶段1给1e-3是因为fc层是全新的,需要大步子快速找到方向;阶段2降到1e-4是因为预训练权重已经足够好,只需要微调,步子太大就把之前学好的特征全部覆盖掉了。StepLR的step_size=10和gamma=0.5表示每10个epoch学习率减半,这是图像分类任务里最简单也最不容易出错的调度方式,换CosineAnnealing效果可能更好,但参数更敏感。训练结束时保存的是state_dict,后面转ONNX、做推理都要用到这个文件。

3.4 训练过程的中间检查:loss曲线与top-1准确率的正确看法

训练跑起来后,别急着等30个epoch跑完再看结果。每一两个epoch就要把验证集上的top-1准确率和loss打印出来,我习惯在训练循环里再套一个验证循环,每个epoch结束都算一次验证集指标。判断训练是否健康,有三个信号要看。

第一个信号是训练loss与验证loss的差。两者差距小,说明模型还没过拟合;差距拉大且训练loss继续下降,说明模型开始死记硬背训练集了,这时应该增强数据扰动、加大dropout,或者提前停。第二个信号是阶段切换点的准确率跳变。从阶段1切到阶段2时,因为分类头换成了全参数微调,验证准确率通常会有一次抖动,这是正常现象,如果抖动超过10个百分点且回不来,说明学习率给大了。第三个信号是每个类别的recall,而不是整体的accuracy。水稻叶部病害数据不平衡,整体accuracy会被大头类主导,模型可能在稻瘟病上有95%的recall,白叶枯病却只有30%。只看总体指标会让你误以为模型已经能用了。

4. 评估与落地部署:识别方法能不能用,看这三个环节

4.1 别只看accuracy:用混淆矩阵揪出“稻瘟病 vs 胡麻斑病”这类高频混淆

模型练完了,第一件事不是欢呼准确率,而是把混淆矩阵打出来看。水稻叶部病害的类间相似度非常高,稻瘟病的慢性型病斑和胡麻斑病都是褐色带黄晕的椭圆斑,区别只在病斑大小和边缘颜色饱和度,这类组合在数据不足的情况下几乎必然互相“串味”。

import numpy as np import matplotlib.pyplot as plt from sklearn.metrics import confusion_matrix, ConfusionMatrixDisplay model.eval() all_preds = [] all_labels = [] with torch.no_grad(): for inputs, labels in val_loader: inputs = inputs.to(device) outputs = model(inputs) _, preds = torch.max(outputs, 1) all_preds.extend(preds.cpu().numpy()) all_labels.extend(labels.numpy()) # 类别顺序要和训练集的folder名一致 class_names = ['稻瘟病', '白叶枯病', '纹枯病', '胡麻斑病', '稻曲病', '其他损伤'] cm = confusion_matrix(all_labels, all_preds) disp = ConfusionMatrixDisplay(cm, display_labels=class_names) plt.figure(figsize=(8, 8)) disp.plot(cmap='Blues', ax=plt.gca()) plt.savefig('confusion_matrix.png', dpi=150)

看混淆矩阵时有几个具体的观察点。第一,主对角线上的数值是否每个类别都足够高,如果有某一行几乎全部分散到其他列,说明这个类别的特征没有被模型学会。第二,非对角线上的最高值出现在哪两类之间,这决定了你有没有必要增加针对性数据。第三,背景类“其他损伤”是否频繁被预测成病害——如果频繁,说明模型把“非病斑纹理”和“病斑”混淆了,需要回去补充更多健康叶和虫咬叶样本。混淆矩阵分析完之后再决定是补充数据还是调整模型,不要一上来就动网络结构。

4.2 推理延迟与模型尺寸:边缘设备上跑深度学习模型的选型依据

识别方法要落地,模型不能只在服务器上跑。农业场景里最常见的部署环境是:农户拿手机拍照上传、服务端返回结果;或者田间的摄像头节点做实时推理。前者延迟容忍度高一些,后者对单帧推理时间有硬要求。这里有个参数选择上的现实逻辑——模型参数量和推理延迟不是一回事,MobileNet参数量小,但在某些边缘设备上没有针对性的算子和加速库支持,实际延迟反而不如ResNet50配TensorRT优化后的表现。

判断一个模型能不能上边缘设备,看四个指标:参数量、单帧推理时间、模型文件大小、内存占用。常见部署板上这个表格可以当参考,具体数据会因为硬件平台不同而有出入,但量级不会差太远:

模型参数量224x224推理延迟(Jetson Nano, FP16)是否适合边缘
ResNet5025.6M约35ms适合
MobileNetV3-Large5.4M约12ms适合
EfficientNet-B419.3M约40ms一般,需要量化
VGG16138.4M约120ms不合适

如果目标是在摄像头节点上做连续推理,MobileNetV3经过量化后能跑到30fps,基本满足实时监测。如果是“拍照上传”模式,选准确率更高的模型更划算,ResNet50的延迟完全在接受范围内。我一般会在项目立项时先问清楚部署形态,再回来定模型,顺序反了会导致返工。

4.3 把PyTorch模型导出ONNX并离线校验

不管是部署到服务端还是边缘设备,把PyTorch训练好的模型转成ONNX都是必经之路。ONNX作为中间表示,可以再转成TensorRT的engine、OpenVINO的IR格式,或者直接用ONNX Runtime跑。这里有一个高频翻车点:模型在PyTorch里推得好好的,转成ONNX后输出对不上,常见的原因是预处理不一致或动态维度没配置好。

import torch import onnx import onnxruntime as ort from torchvision import models # 重新加载训练好的模型,注意要加.eval() model = models.resnet50(weights=None) model.fc = torch.nn.Linear(model.fc.in_features, 6) model.load_state_dict(torch.load('rice_disease_resnet50.pth')) model.eval() # 构造一个固定的输入尺寸,转ONNX时这里定义输入维度 dummy_input = torch.randn(1, 3, 320, 320) torch.onnx.export( model, dummy_input, 'rice_disease_model.onnx', opset_version=12, input_names=['input'], output_names=['output'], dynamic_axes={'input': {0: 'batch_size'}, 'output': {0: 'batch_size'}} ) # 用onnxruntime做离线校验,对比两种框架的输出差异 ort_session = ort.InferenceSession('rice_disease_model.onnx') test_tensor = torch.randn(4, 3, 320, 320) with torch.no_grad(): torch_out = model(test_tensor).numpy() ort_out = ort_session.run(['output'], {'input': test_tensor.numpy()})[0] diff = (torch_out - ort_out).max() print(f'PyTorch与ONNX输出最大差异: {diff:.6f}')

dynamic_axes这段配置很关键。如果你不声明batch维度为动态,导出的模型就固定成batch size=1,部署时一旦凑不够刚好1张图就要做填充。实际推理时单张图最常见,所以只把batch维度动态化就够了。opset_version选择12是保守选择,新版ONNX Runtime都支持,而太高的版本号反而可能在旧设备上缺失算子。跑完这段脚本后,打印出的最大差异一般应该在1e-5这个量级,如果差异达到1e-2,先查预处理——训练时用Normalize、推理时忘了用同样的均值和标准差,这是最典型的“训练好、部署歪”的原因。

4.4 误判率与阈值调整:识别方法进入实际应用的最后一公里

模型输出的是每个类别的概率,但“概率大于多少算确实属于这个病”,是由阈值策略决定的,这个决策直接影响实际使用体验。水稻叶部病害识别里,漏诊和误诊的代价不一样:漏诊稻瘟病意味着农户错过防治窗口期,损失大;误诊意味着农户喷了不该喷的药,花冤枉钱。两种错误的代价不对称,就不能简单把阈值定在0.5。

我的习惯是针对每个类别单独定阈值——用测试集跑出每个类的概率分布,然后逐一尝试阈值,选一个让“重误代价最小”的点。如果重点是防漏诊,就把该类的阈值压低比如0.35,只要模型有35%的把握就报警;如果重点是防误诊,阈值拉到0.7以上,宁可少报也不空报。类的阈值适合写进一个配置文件里,部署时读入,不用改代码。最后再留一个“不明确类别”的兜底输出:概率都达不到阈值时,返回“症状不明显,建议上传高清图片或咨询当地植保站”,这个交互设计比直接甩一个错误结论要讨喜得多。

5. 水稻叶部病害识别常见问题与避坑:从采集到部署的5个坑

5.1 训练loss不降、验证集准确率长期纹丝不动

现象:训练跑了几百个step,loss一直在2.0附近打转,验证集准确率在40%以下纹丝不动。

原因:这种症状八成是学习率设置问题。水稻叶部病害识别用的是迁移学习,很多人直接把整个模型的学习率设成1e-2甚至1e-1,预训练权重被大步长冲击后迅速“失忆”。还有一种可能是模型输入尺寸不对,比如预训练权重要求224输入,你喂的是64x64缩略图,网络在几乎无纹理可辨的图上自然学不到特征。

解决:把初始学习率降到1e-4量级,阶段式的两段训练策略优先采用。检查输入分辨率是否在224以上,病斑这种细粒度特征需要足够的像素支撑。如果还没改善,打印几个batch的图片看一眼是不是预处理后图像发黑——Normalize的mean和std用错会导致输入分布整体偏移,模型看到的图和训练时的截然不同。

5.2 准确率虚高却下地失效

现象:实验室测试集上准确率95%,拿到田里拍新图识别准确率掉到60%。

原因:最典型的成因是数据泄露。同一个植株的多角度照片被随机切分到了训练集和验证集,等于考试时偷看了答案。另一个原因是训练数据“太干净”了,都是中午顶光拍摄的典型病斑,而实际农田里清晨的露水反光、正午的高光斑、傍晚的阴影睡在叶片上,特征分布完全变了。

解决:按植株编号切分数据集而非按图片切分。训练图里刻意混入不同光照条件、不同拍摄距离、甚至有点虚焦的图,让模型见过“脏场景”。如果条件允许,用另一个完全独立采集的测试集做盲测,这个测试集连你自己都不要反复去看。

5.3 稻瘟病和胡麻斑病永远互相弄混

现象:混淆矩阵里,稻瘟病的预测结果有30%落到了胡麻斑病上,补充了数据也没有明显改善。

原因:这两种病害在早期症状确实高度相似,但更底层的因素是标注本身的边界不清晰。不同标注员会把同一张病斑图一个标成稻瘟病、一个标成胡麻斑病,模型学到的就是一套自相矛盾的标准,怎么练都练不对。

解决:回到标注环节做“一致性复核”。把两类的边界案例单独挑出来,请植保专家给出判定依据的明确描述,落到标注规范里。如果专家也无法区分部分图片,果断把这类图挪到“不确定/其他损伤”类,不要硬塞进某一边。

5.4 memory error:显存不足导致训练中断

现象:训练进行到几个epoch后显存占用突然飙升,报CUDA out of memory,重启后重跑还是挂在同一个位置。

原因:最常见的是DataLoader的num_workers开太高,每个worker多份数据预加载;还有增强过程中有些操作会保存计算图,导致显存峰值远高于单个batch的理论占用。训练中途跑到验证集阶段时显示存不足,往往是验证集batch size没调、分辨率太大。

解决:先看模型本身占用的显存,把batch size降到16并调低num_workers到2做测试性运行。检查图里是否有超大尺寸的原始图片没有resize就送入网络——ImageFolder默认不预处理尺寸,如果某张图是4000x3000的原始照片,那一张图就顶得上32张224图的内存。标准做法是给train和val的transforms都加上Resize,上面2.3节的代码里已经体现。

5.5 模型部署到手机上输出概率全是差不多的值

现象:ONNX模型集成到小程序后,每次推理输出的概率都像0.2、0.3这样的平均分布,没有哪个类别明显突出。

原因:这是在预处理环节把图片通道排列搞错了。PyTorch训练时用RGB顺序(通过Normalize前的ToTensor决定),而手机端用OpenCV读图默认是BGR顺序。BGR直接喂给训练时用RGB的模型,颜色特征全乱了,模型只能输出模糊的概率。

解决:在手机端或服务端做一次通道翻转,cv2.cvtColor(img, cv2.COLOR_BGR2RGB)。同时检查Normalize的mean和std是否和训练时一致,有的部署框架要求输入是0到1的float格式,和训练时的Tensor格式保持一致,有的则是0到255的uint8,这个不匹配也会让输出概率变得不可理喻。

6. 用Grad-CAM验证深度学习模型到底在看哪一片叶子

上面说了那么多参数和踩坑,最后留一个我认为最值的验收入门动作:用Grad-CAM把模型的注意力画出来,亲眼看看它到底“看”的是病斑还是背景。这一步是“识别方法研究”和“识别方法可用”的分水岭——如果模型的注意力热力图集中在叶片边缘、水珠反光这些地方,说明它学的不是病害特征,而是场景伪迹。

Grad-CAM的原理是利用最后一个卷积层的梯度,算出特征图每个通道对最终类别判断的贡献权重,再把加权后的特征图放大到输入图片尺寸。在PyTorch里实现一个最简版本,只依赖torch自带的功能就能完成。

import torch import cv2 import numpy as np # 假设model已加载,输入一张tensor格式的测试图 def grad_cam(model, input_tensor, target_class=None): model.eval() # 注册hook拿最后一个卷积层的输出特征图 target_layer = model.layer4[-1] feature_map = None gradients = None def forward_hook(module, input, output): nonlocal feature_map feature_map = output.detach() def backward_hook(module, grad_input, grad_output): nonlocal gradients gradients = grad_output[0].detach() forward_handle = target_layer.register_forward_hook(forward_hook) backward_handle = target_layer.register_full_backward_hook(backward_hook) output = model(input_tensor.unsqueeze(0)) if target_class is None: target_class = torch.argmax(output, dim=1) model.zero_grad() one_hot = torch.zeros_like(output) one_hot[0, target_class] = 1 output.backward(gradient=one_hot) # 对特征图按通道求权重并加权求和 weights = torch.mean(gradients, dim=(2, 3), keepdim=True) cam = torch.relu((weights * feature_map).sum(dim=1, keepdim=True)) cam = cam.squeeze().cpu().numpy() cam = (cam - cam.min()) / (cam.max() - cam.min() + 1e-8) forward_handle.remove() backward_handle.remove() return cam # 与原图做叠加可视化 img = cv2.imread('test_leaf.jpg') img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) cam = grad_cam(model, test_tensor, target_class=pred_class) cam_resized = cv2.resize(cam, (img.shape[1], img.shape[0])) heatmap = np.uint8(255 * cam_resized) heatmap = cv2.applyColorMap(heatmap, cv2.COLORMAP_JET) overlay = cv2.addWeighted(heatmap, 0.5, cv2.cvtColor(img, cv2.COLOR_RGB2BGR), 0.5, 0) cv2.imwrite('gradcam_result.jpg', overlay)

这个脚本的用法是:找5到10张验证集里的典型病叶图,每张都跑一遍Grad-CAM,然后看图。我最在意三类图的结果。第一类,正确预测的样本,热力图应该落在病斑区域,且只落在病斑区域。第二类,错误预测的样本,热力图一般会指向叶片边缘、叶尖枯黄或非病斑的褐色区域,说明模型在靠“看起来像病”的整体颜色判断,而不是靠病斑形态。第三类,训练集中数量最多的类别,它的热力图如果能集中在病灶,说明类别不平衡还没把模型压垮;如果热力图上颜色一片乱散,说明模型学到的是统计规律而非诊断逻辑。

做Grad-CAM这件事的成本很低,每次训练完花半小时跑一遍,能直接决定这个模型是调到部署环节还是回炉重造。我做这个方向有一个习惯,每轮训练结束后第一件事不是看最终准确率,而是选一张典型的稻瘟病图和一张错误的样本图,把Grad-CAM热力图贴在训练日志第一页。有一次模型在测试集上看起来一切正常,热力图却显示它把某张图的叶鞘高光区域当成了病斑——原始数据集里这类“高光+病斑”组合图片太多,模型学的其实是“亮斑”的特征。那次之后我把采集规范改成了“每个病斑必须在无直射光的条件下补拍一张”,模型的假阳性明显回落。

如果你正打算投入水稻叶部病害识别这个方向,我建议你做完整套流程后认真留一份“模型注意力记录”——哪个类别热力图稳定、哪个类别热力图飘忽不定,半年后回来翻会非常有用。另一种做法是把热力图作为答辩或项目验收的交付物,效果比贴一张混淆矩阵更能说明模型质量。希望这个验证思路能帮到你,也祝你的模型在下一茬稻子抽穗前顺利下地。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询