ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

PyTorch实战:玉米叶病害视频识别与连续帧稳定方案

PyTorch实战:玉米叶病害视频识别与连续帧稳定方案

1. 项目背景与需求拆解

做农业视觉识别这个方向,我是从去年才开始接触的。之前一直做的是通用目标检测和图像分类,总觉得农业场景“不就是分类嘛,套个模型就完事了”。真等到我接到这个玉米叶病害识别的项目,才发现里面坑多得很,尤其是从“单张图像识别”到“图像连续识别”再到“视频识别”,这三级跳跃,每一级都会让你重新审视之前写过的代码。

先说这个项目到底要做什么。需求来自一个小型农业科技团队,他们做的是田间智能监测设备,需要部署一套算法,能识别玉米叶片上的常见病害,比如大斑病、小斑病、锈病、灰斑病、北方炭疽病这些。硬件端是一路固定摄像头,在田里循环巡扫,偶尔还有无人机拍的视频片段拿回来做离线分析。所以算法至少要支持两种输入形态:一种是单张图片或者图片序列,另一种是视频流或者视频文件。输出是每一帧或者每一段视频里的病害类别、置信度,最好还能统计一下发病区域占比,方便后续生成植保报告。

我当时的第一个反应是:这不就是图像分类加视频抽帧嘛,torchvision 里拉一个预训练的 ResNet,微调一下,分类准确率怎么也能到 90% 以上。但真正做起来才发现,分类模型在单张清晰图片上表现不错,放到连续帧上就开始“抖动”——同一片叶子这一帧是健康的,下一帧就变成了病斑,再下一帧又变回来了。这种不稳定在视频场景里非常致命,因为你没法跟农户解释“为什么机器一会儿说有病一会儿说没病”。

所以项目真正的难点不是“训练一个分类器”,而是“如何让分类器在时间和空间维度上保持稳定和连续”。这个认知是整个项目最核心的转折点,也决定了后面我所有的技术选型。

适合看这篇文章的,我猜应该是这几类人:正在做农业视觉项目的开发者,想从单图分类往视频识别方向转型的算法工程师,以及接了类似“田间监测”需求但是不知道从哪下手的团队。如果你是纯新手,也没关系,我会把从数据准备到模型训练到视频推理的完整链路都讲清楚,包括很多我在实践中踩过的坑,尽量让你少走弯路。

2. 整体技术方案设计

2.1 从单图分类到连续识别的三层结构

我把整个项目拆成了三个层次,每一层解决一类问题:

第一层是单帧图像分类。这是基础能力,输入是一张玉米叶片的图像,输出是病害类别。这个层面用的是经典的 CNN 图像分类架构,PyTorch 生态里成熟的预训练模型直接拿来用,重点是数据质量和类别的区分度。

第二层是图像连续识别。这个“连续”指的是同一片区域、不同时间点拍摄的一组图像,或者摄像头在一个固定位置持续采集的画面序列。单帧分类结果容易抖动,所以这一层要做的是在时间序列维度上对结果进行平滑和融合,常用的手段有滑动窗口投票、EMA 指数平滑、基于置信度的自适应融合。

第三层是视频识别。视频本质上就是连续帧的集合,但视频有它的特殊性:相邻帧之间的冗余度极高,如果每一帧都跑一次模型,计算浪费大,而且容易受运动模糊、光照突变影响。所以视频层面要考虑抽帧策略、关键帧选择、多帧特征聚合,甚至可以考虑轻量级时序模型来建模帧间关系。

这三层不是独立的,是层层递进的关系。我在实际项目里的做法是:先做好第一层的单帧分类精度,在此基础上做第二层的时间平滑,最后才上视频抽帧和推理管线的优化。如果一上来就想着端到端的视频识别模型,调试起来会非常痛苦,因为很难判断到底是哪里出了问题。

2.2 为什么选择 PyTorch 而不是其他框架

这个项目选 PyTorch,说实话没有什么悬念。原因有三点:

第一,生态成熟度。torchvision 里预训练模型非常丰富,ResNet、EfficientNet、MobileNet 系列都有现成的权重,而且针对不同精度的需求有清晰的选型梯度,这可以让我把精力集中在数据和策略上,而不是自己去复现网络结构。

第二,动态图的调试便利性。视频识别场景下经常要处理不定长的输入、需要灵活控制中间特征,PyTorch 的动态图机制在这种场景下非常舒服。比如我需要拿到模型倒数第二层的特征做多帧融合,只需要注册一个 forward hook 就能实现,不用改网络结构。

第三,部署链路相对友好。虽然 PyTorch 的部署不如某些专用框架轻量,但是通过 TorchScript 或者 ONNX 导出,可以很方便地转到 C++ 推理环境,或者在服务端用 TorchServe 跑。对于这个项目的规模——一路摄像头、一台边缘盒子——PyTorch 的部署成本完全在可控范围内。

我自己的原则是:项目在研究和原型验证阶段,果断选 PyTorch;如果未来要大规模上嵌入式设备(比如无人机机载芯片),再做模型压缩和转换,但是训练和验证仍然在 PyTorch 里完成。

2.3 模型选型的取舍逻辑

模型选型上,我实际上是做了一个“多方案并行”的计划,而不是只选一个:

方案网络定位预期精度单帧耗时(CPU)
Baseline AResNet50高精度主模型,用于离线分析最高约 80ms
Baseline BEfficientNet-B3精度和速度的相对均衡次高约 50ms
轻量方案MobileNetV3-Large边缘盒子实时推理可接受约 20ms

为什么不直接用 ResNet50 一路走到底?因为视频场景对实时性有要求。边缘盒子的算力有限,如果每帧推理耗时超过 100ms,摄像头巡航的时候画面就会发卡,用户体验非常差。所以我的策略是:离线分析用 EfficientNet-B3 或者 ResNet50 拿精度,在线实时巡田用 MobileNetV3,两套模型共享同一套训练数据和预处理代码,只是网络结构不同。

还有一个比较重要的点是:这个任务是“细粒度病害识别”,不同病害在叶片上的早期症状其实很像,这对模型提取局部纹理特征的能力要求很高。所以我对比过 ResNet50 和 EfficientNet 之后,最终把离线模型固定在了 EfficientNet-B3,因为它的复合缩放策略在保持参数量的同时,对局部特征的表达更细腻。MobileNetV3 在轻量化模型里面算是对细粒度特征保留得比较好的,但也只能作为实时兜底方案,精度损失大约在 3 到 5 个百分点,这个需要在实际项目中权衡。

3. 数据准备与增强策略详解

3.1 数据集获取与清洗

先说一个很多新手容易忽略的问题:数据集不是“下载下来就能用”的。

我们当时的初始数据来自两个渠道:一部分是从公开的农业病害图像库中爬取整理,另一部分是团队自己在试验田里拍摄的。混合数据有一个很大的问题,就是分布不一致。公开数据集里的图像很多是在受控条件下拍的,背景干净、光照均匀,而田间实拍的照片有泥土背景、有杂草遮挡、有光线阴影,甚至还有露珠反光。模型在公开数据集上训练得很好,一到实拍场景准确率直线下降,这是典型的 domain gap 问题。

所以我花了不少时间做数据清洗,具体操作如下:

  1. 剔除模糊图片。用 Laplacian 算子计算图像方差,低于阈值的直接丢弃。
  2. 剔除标注可疑的图片。有一些公开数据集的标签本身就是错的,尤其是早期症状图片,很容易被误标。我的做法是随机抽样 200 张,让植保专家帮忙复查一遍,发现错误率超过 5% 就直接不用这个来源的数据。
  3. 统一图像尺寸。玉米叶片病害识别对局部细节要求高,图像分辨率低的话很难区分早期病斑,所以我统一把输入分辨率设置为 384x384(EfficientNet 家族常用分辨率),太低的话会丢失细纹特征。

最终留下来的有效数据大约是 12000 张,分 6 个类别:健康、大斑病、小斑病、锈病、灰斑病、北方炭疽病。类别分布并不均衡,这个后面再说。

数据集的目录结构我沿用 ImageNet 风格,简单直接,方便 torchvision 的 Dataset 直接读取:

data/ train/ healthy/ northern_leaf_blight/ gray_leaf_spot/ common_rust/ small_spot/ northern_corn_blight/ val/ healthy/ ...

3.2 数据增强:病害识别的专属配方

数据增强是农业视觉项目里性价比最高的一步,但必须“有感而发”地设计,不能无脑套用。

田间拍摄的图像有几个天然特点:光照变化剧烈、叶片姿态随意、有遮挡和阴影。针对这些特点,我的增强策略分成了三个层次:

基础几何增强:随机水平翻转、随机裁剪、随机旋转。这些操作能提升模型对叶片姿态的鲁棒性。注意这里不建议做垂直翻转,因为玉米叶片在田间的朝向是有物理意义的,垂直翻转会引入不真实的样本。

颜色与光照增强:随机亮度调整、对比度调整、色调抖动,这能模拟一天中不同时间的光照变化。还有一个非常重要的操作是随机 gamma 校正,因为田间经常出现逆光或者树影斑驳的情况,gamma 扰动可以模拟这种亮度非线性变化。

高级增强:CutMix 和 MixUp。病害识别的难点在于病斑的纹理细节,CutMix 可以让模型学会在复杂的背景下定位病斑区域,而不是仅仅依赖整体图像外观。我实际测试下来,使用 CutMix 之后,模型在实拍视频中的误检率降低了约 8%。

我用的增强是分两套的:训练时用全套增强,验证和推理时只用中心裁剪和归一化。很多新手会犯的一个错误是推理时也带上随机增强,这会导致测试结果不稳定。

3.3 类别不均衡的处理

前面提到类别不均衡,实际情况是:健康叶片最多,锈病次之,北方炭疽病最少,可能只有健康的五分之一。如果不处理,模型会对样本多的类别过拟合,对样本少的类别几乎不学习。

我的方案是组合拳:

  1. 重采样:对样本少的类别做 oversampling,在采样器里设置类别权重,让每个 batch 的类别分布尽量均衡。
  2. 损失函数加权:在 CrossEntropyLoss 里传入 class weights,权值用类别样本数的倒数做归一化,再开根号平滑一下,避免权重差异过大导致训练震荡。
  3. 对少数类做更强的增强:北方炭疽病样本少,我就对它额外做随机遮挡模拟和局部放大增强,相当于“无中生有”出更多有效样本。

这三步做完之后,北方炭疽病的 F1 score 从 0.62 提升到了 0.81,效果非常明显。

4. 基于 PyTorch 的模型搭建与训练

4.1 核心训练代码结构

我不喜欢写动不动几百行的单文件训练脚本,项目一复杂根本没法维护。所以我把代码组织成了几个模块:

  • dataset.py:负责数据加载、增强、采样
  • model.py:负责模型构建,包括 backbone 选择和分类头设计
  • train.py:训练主流程,包含 logger、checkpoint、early stopping
  • infer.py:单帧推理和视频推理入口

这里给一个模型构建的核心代码示例,我用的是 EfficientNet-B3 作为 backbone,然后在最后加了一个自定义的分类头。

import torch import torch.nn as nn from torchvision.models import efficientnet_b3, EfficientNet_B3_Weights class CornLeafDiseaseModel(nn.Module): def __init__(self, num_classes=6, dropout_rate=0.3): super().__init__() self.backbone = efficientnet_b3(weights=EfficientNet_B3_Weights.IMAGENET1K_V1) in_features = self.backbone.classifier[1].in_features # 替换原来的分类头 self.backbone.classifier = nn.Sequential( nn.Dropout(p=dropout_rate, inplace=True), nn.Linear(in_features, 512), nn.ReLU(inplace=True), nn.Dropout(p=dropout_rate), nn.Linear(512, num_classes) ) def forward(self, x): return self.backbone(x)

为什么要把原本的单个 Linear 换成“Linear-ReLU-Dropout-Linear”这个结构?因为病害分类的类别之间差异很细,需要更高维的中间特征空间来刻画,而且加了一层 Dropout 能显著减少过拟合。我试过直接换分类头,验证集准确率低了大概 1.5 个百分点,别小看这 1.5 个点,在细粒度分类任务里已经算很大差距了。

训练参数方面,我有一组比较稳定的配置:

optimizer = torch.optim.AdamW(model.parameters(), lr=1e-4, weight_decay=5e-4) scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=30) criterion = nn.CrossEntropyLoss(weight=class_weights.to(device)) batch_size = 16 epochs = 40

初始学习率为什么用 1e-4 而不是常见的 1e-3?因为预训练模型的权重已经经过大规模数据预热,如果学习率太大,很容易破坏底层已经学习好的特征。我采取的方式是先用 1e-4 训练 10 个 epoch 让分类头收敛,然后把整个模型的学习率降到 5e-5 继续微调,这个策略在迁移学习里叫做“分阶段微调”,效果比整体用一个统一学习率要好。

还有一个容易被忽视的地方是冻结 backbone 的问题。很多教程建议刚微调时冻结 backbone 只训练分类头,我试过,在数据量只有一万多张的情况下,冻结和全量微调的差距并不大,但全量微调后模型对实拍环境的适应能力明显更好。所以我建议如果算力允许,直接全量微调,但注意要加一个较小的 weight_decay 防止过拟合。

4.2 训练过程中的性能优化技巧

训练过程中我遇到几个非常磨人的性能问题,这里一并分享:

第一个是数据加载瓶颈。刚开始训练时 GPU 利用率经常只有 40%,那个 384x384 的输入分辨率加上复杂的增强策略,导致 CPU 数据预处理成了瓶颈。解决方案是:

  • num_workers从默认的 2 调到 8
  • persistent_workers=True,避免每个 epoch 都重新创建进程
  • 把图像解码方式从 PIL 换成cv2.imread,速度提升约 3 倍
  • 使用prefetch_factor=4增加每个 worker 的预取数量

第二个是混合精度训练。我的显卡支持 AMP,直接用了torch.cuda.amp.autocast(),训练速度提升了约 40%,而且精度几乎没有损失。对于 ResNet 和 EfficientNet 这类结构,AMP 是非常安全的加速手段。

第三个是混合精度下的 BN 层问题,这个坑比较隐蔽。EfficientNet 里的 BN 层在 AMP 模式下如果处理不当,会出现训练和推理统计量不一致的问题,表现为验证集上 Loss 突然上涨。我的解决办法是在torch.cuda.amp.GradScaler的标准流程基础上,把 model 放到 device 后显式调用model.eval()再跑验证集,确保 BN 用的是 running stats 而不是 batch stats。

训练过程的监控我用的比较朴素,就是记录 train loss、val loss、val acc、每个类别的 F1 score,每 5 个 epoch 存一次 checkpoint。早期停止策略是 val loss 连续 8 个 epoch 不下降就停止。

4.3 训练结果的评估标准

病害识别不是只看准确率就行的。因为类别不均衡存在,准确率会有很大的欺骗性——如果健康叶片占 40%,模型全预测成健康也有 40% 的准确率。所以我重点看的是每个类别的 Precision、Recall 和 F1 score,以及整体的 Macro-F1。

训练结束后,我的 EfficientNet-B3 模型在验证集上的 Macro-F1 是 0.93,单类表现如下:

类别PrecisionRecallF1 Score
健康0.960.980.97
大斑病0.920.900.91
小斑病0.890.870.88
锈病0.940.960.95
灰斑病0.910.900.90
北方炭疽病0.820.800.81

少数类北方炭疽病还是最弱的,这个受限于样本量。但如果用到连续识别和视频识别,通过多帧投票机制,最终输出的稳定性会显著高于单帧结果,后面我会细说。

5. 图像连续识别:解决多帧抖动问题

5.1 为什么单帧识别结果会“抖动”

模型训练好了之后,我立刻拿了一段田间的视频去测试,结果发现问题严重:同一片叶子在连续 30 帧里,模型预测结果在“健康-锈病-健康-锈病”之间反复横跳。即使单帧准确率已经有 93%,到视频上这个抖动依然非常明显。

这个抖动的原因要分开说:

一是图像本身的差异。摄像头在巡航时角度在变,光照在变,同一片叶子的成像效果差异很大。某些角度下病斑清晰可见,某些角度下病斑被叶片反光覆盖,模型就会“误判”成健康。

二是模型本身的预测不确定性。对于接近分类边界的样本,输出概率分布不会呈现明显的单峰形态,在相邻帧之间概率分布会波动,最终 argmax 结果随之跳变。

第三个原因是运动模糊。视频帧之间存在运动模糊是常态,尤其是无人机视频,模糊帧的预测结果很不可靠。如果直接把模糊帧的结果加入最终判断,噪音就会被放大。

我意识到,如果要做“图像连续识别”,必须要设计一个机制来吸收这种帧间波动,而不是单纯地“逐帧分类”。

5.2 滑动窗口时间平滑方案

我采用的核心方案是“滑动窗口 + 加权投票”。

具体思路是这样的:维护一个长度为 N(我试下来 N=9 或者 N=15 效果都不错)的滑动窗口,窗口内保存每一帧的 softmax 输出向量。当新的一帧进来时,把窗口里所有帧的概率向量做加权平均,然后对加权平均后的向量取 argmax 作为当前输出。

关键点是“加权”而不是“平均”。我给不同帧分配权重时有几个原则:

  • 越新的帧权重越大,因为最新的帧最接近当前时刻的实际情况
  • 置信度低的帧权重小,防止模糊帧干扰结果
  • 连续多帧高置信度预测的类别,会积累优势,避免被一帧异常结果带偏

这个加权滑动平均用代码实现非常简单:

import collections import numpy as np class TemporalSmoother: def __init__(self, window_size=9, alpha=0.8): self.window_size = window_size self.alpha = alpha self.queue = collections.deque(maxlen=window_size) def reset(self): self.queue.clear() def predict(self, new_softmax): # new_softmax: shape (num_classes,), 模型输出的概率向量 self.queue.append(new_softmax) # 时间衰减权重:离当前帧越远权重越小 weights = np.array([self.alpha ** (self.window_size - 1 - i) for i in range(len(self.queue))]) weights = weights / weights.sum() # 置信度加权:低置信度帧的贡献进一步降低 confidences = np.max(self.queue, axis=1) weights = weights * confidences weights = weights / weights.sum() avg_prob = np.zeros_like(new_softmax) for i, entry in enumerate(self.queue): avg_prob += weights[i] * entry final_label = int(np.argmax(avg_prob)) final_conf = float(avg_prob[final_label]) return final_label, final_conf

这个平滑器用起来有几个注意点:

  • 每次切换镜头或者开始处理新的视频片段时,一定要调用reset(),否则上一段视频的帧还留在窗口里,新视频的前几帧结果会被污染。
  • alpha 的取值决定了“记忆长度”。alpha 越大,模型越稳定,但对突变的响应越慢。我调试下来,芯片上实时场景用 0.7 到 0.8 之间的值比较合适,太快则没效果,太慢则当机器从一株病株移到健康植株时,要等接近 2 秒才能正确切换标签。
  • 这个平滑器的输出不是“分类概率”,而是“平滑后的概率”,所以不要把它直接当作置信度展示给用户。

用了平滑器之后,同一段测试视频的标签跳变从“30 帧跳 7 次”降到了“30 帧跳 0 次”,效果立竿见影。

5.3 基于置信度的自适应策略

窗口平滑能解决大部分问题,但还有一类特殊情况:当模型持续低置信度时,比如镜头对焦不准、被遮挡、或者画面中根本没有玉米叶片,无脑平滑反而会把错误信息保留在窗口里。所以我加了一个“低置信度遮蔽”机制:如果连续 K 帧的最大概率都低于阈值(比如 0.6),就直接判定为“无法识别”,并输出一个“不明确”类别,同时清空窗口等待新的有效帧。

这里有一个细节:阈值不能设太高。因为我观察到,病害早期的病斑面积小,模型本来置信度就不高,如果阈值设 0.8 以上,早期病害信号容易被过滤掉,这不符合农业诊断的需求。最后我用了 0.55 作为判断阈值,既能在模糊帧时有效触发遮挡,又不会丢掉早期病害弱信号。

6. 视频识别:抽帧策略与推理管线

6.1 视频抽帧的三个关键策略

视频识别的第一个问题是“要不要每一帧都推理”。

我的答案是最优情况下不要。视频帧的冗余度太高了,相邻两帧之间的内容差异往往不到 5%。逐帧推理不仅浪费算力,还会因为运动模糊导致结果抖动加剧。

我采用的抽帧策略是三种模式按需切换:

均匀抽帧:每 N 帧取 1 帧推理。适用于摄像头固定方位匀速巡扫的场景,计算量可控。默认 N=3,即每秒 10 帧的快慢节奏下实际推理 3 到 4 帧。

运动检测抽帧:通过相邻帧差的像素变化判断画面是否有明显运动,运动剧烈时提高抽帧频率,画面静止时降低。这个策略对“摄像头在巡航过程中路过一整片病斑”的场景非常有效,可以在运动剧烈时密集采样,确保不遗漏。

关键帧提取:这个不是简单的抽帧,而是结合了图像质量评估。每帧计算 Laplacian 方差作为清晰度指标,低于阈值的帧直接丢弃,防止模糊帧进入平滑窗口。我还加了简单的色彩饱和度判断,逆光过曝的帧也直接丢弃。

为了清晰说明不同抽帧策略的适用场景,我做了一个简单的对比:

策略适用场景优劣势
均匀抽帧固定摄像头巡航实现简单、算力可控,但对突变响应慢
运动检测抽帧无人机航拍、手持拍摄能捕捉动态变化,但对运动检测参数敏感
关键帧提取光照变化大、抖动严重的场景有效排除退化帧,但需要额外的图像质量评估开销

实际项目中我用的是一套组合方案:进入新视频流后先用关键帧提取过滤掉模糊帧,再用运动检测决定抽帧密度,最后把抽出来的帧送入平滑器做时间维度的融合。

6.2 视频推理管线完整实现

视频推理管线的核心是“生产-消费”模型。读取视频的线程(生产者)和推理的线程(消费者)互相独立,中间用一个有界队列解耦。这样即使单帧推理偶尔发生阻塞,视频读取也不会丢帧。

我的推理管线大概是这样的:

  1. 打开视频流(本地文件或者摄像头 RTSP 地址均可)
  2. 读取线程逐帧读取,送入预处理器
  3. 预处理器做尺寸缩放、归一化、清晰度评估
  4. 抽帧策略决定这一帧是否送入推理队列
  5. 推理线程从队列取帧,跑模型前向,得到 softmax 向量
  6. 把 softmax 送入时间平滑器
  7. 输出层把平滑结果转成标签,并叠加到原视频帧上做可视化

在代码上,我用了一个线程安全的双向队列,防止生产者和消费者之间出现竞态。核心代码片段如下:

import threading import queue import cv2 import torch from torchvision import transforms class VideoInferenceEngine: def __init__(self, model, device, smoother, input_size=384): self.model = model.to(device).eval() self.device = device self.smoother = smoother self.frame_queue = queue.Queue(maxsize=16) self.stop_flag = False self.transform = transforms.Compose([ transforms.ToPILImage(), transforms.Resize((input_size, input_size)), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) ]) def read_worker(self, video_path): cap = cv2.VideoCapture(video_path) frame_id = 0 while not self.stop_flag: ret, frame = cap.read() if not ret: break self.frame_queue.put((frame_id, frame)) frame_id += 1 cap.release() self.frame_queue.put((None, None)) # 结束哨兵 def inference_worker(self, output_callback): while not self.stop_flag: frame_id, frame = self.frame_queue.get() if frame is None: break # 抽帧:每3帧推理一次 if frame_id % 3 != 0: continue # 清晰度评估 gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) laplacian_var = cv2.Laplacian(gray, cv2.CV_64F).var() if laplacian_var < 50: # 模糊帧直接跳过 continue tensor = self.transform(frame).unsqueeze(0).to(self.device) with torch.no_grad(): logits = self.model(tensor) probs = torch.softmax(logits, dim=1).cpu().numpy()[0] label, conf = self.smoother.predict(probs) output_callback(frame_id, frame, label, conf)

这个管线的几个设计要点:

RTSP 流和本地文件的读取方式不一样,RTSP 需要设置cv2.CAP_PROP_BUFFERSIZE来降低延迟,本地文件则不需要。如果做实时识别,还要在read_worker里加上超时机制,防止读流阻塞导致线程假死。

队列大小设 16 就够了。如果队列太大,推理速度跟不上视频读取速度时,延迟会不断累积,实时性就崩了。队列满的时候直接丢弃旧帧,保证新帧优先。这个策略叫“丢帧保新”,对实时监控场景非常重要。

模糊帧阈值 50 是我在测试视频上调出来的经验值,如果你换一个分辨率或者拍摄设备,这个值要重新标定。比较靠谱的做法是拿 20 张清晰图片和 20 张模糊图片算 Laplacian 方差,取两者的分界点作为阈值。

6.3 多帧特征融合的进阶方案

滑动窗口投票用起来简单有效,但如果只停留在 softmax 级别融合,还有一个问题:它没有利用帧间共享的特征信息。比如某一片叶子在连续多帧里出现,理论上模型对这些帧提取的特征有很强的互补性,简单投票浪费了这种互补性。

所以我又做了一个进阶版本:多帧特征融合。具体做法是:

  • 不是只取最后一层分类概率,而是在模型倒数第二层(那个 512 维的特征向量)注册 forward hook,把特征向量取出来
  • 连续多帧的特征向量在时间维度上做注意力加权平均,得到融合特征
  • 融合特征再接一个分类头输出最终类别

这样做的好处是,特征的表达能力远强于概率向量,融合时信息损失更少。不过代价也很明显:需要同时保留多帧的特征而不是只保留概率,显存占用会上升,而且模型不能简单地只用torch.no_grad跑完就丢。

在效果上,特征融合比概率投票的稳定性进一步提升。我拿一段包含 12 株玉米的视频做测试,概率投票方案下有 1 株出现标签抖动,特征融合方案则完全稳定。如果你的算力宽裕,推荐优先考虑特征融合方案。

下面是特征融合的关键代码:

import torch import torch.nn.functional as F class FeatureFusionRecognizer: def __init__(self, model, fusion_len=5): self.model = model self.fusion_len = fusion_len self.feature_buffer = [] # 保存特征向量 self.fc = None # 这里需要额外一个分类头,可以训练或者复用原模型分类头 def extract_feature(self, tensor): # 注册 hook 获取倒数第二层特征 features = [] def hook_fn(module, input, output): features.append(output.detach().cpu()) hook = self.model.backbone.classifier[1].register_forward_hook(hook_fn) with torch.no_grad(): _ = self.model(tensor) hook.remove() return features[0] def predict_fused(self, feature): if len(self.feature_buffer) >= self.fusion_len: self.feature_buffer.pop(0) self.feature_buffer.append(feature) # 简单的时间平均 + L2 归一化 stacked = torch.stack(self.feature_buffer, dim=0) # (T, D) fused = stacked.mean(dim=0) fused = F.normalize(fused, dim=-1) # 用分类头预测 logits = self.fc(fused) return torch.softmax(logits, dim=-1)

这个方案需要额外准备一个分类头,可以用合成数据做训练,也可以直接用原模型分类头的第一层权重来初始化,然后在验证集上微调几轮。

7. 常见问题与排坑实录

7.1 过拟合与数据增强悖论

第一次训练时,我的验证集准确率只有 88%,训练集准确率却有 99%,明显过拟合了。当时我加了一堆数据增强,结果发现验证集准确率反而更低了。

排查了半天,发现问题出在验证集的预处理上。我有一套增强函数,但训练时用的是transform_train,验证时用的transform_val,两者的归一化参数不一致——训练集用的是 ImageNet 的均值方差,验证集不小心用了数据集自己的均值方差。这种不一致会导致输入数据分布偏移,模型在验证集上表现极差。

这个坑让我意识到:验证集和测试集的预处理必须和训练集的“推理分支”完全一致。后来我把预处理逻辑标准化成了一个函数,统一输出 tensor,只在训练阶段额外追加随机增强,验证和推理阶段走同一路线。

7.2 类别不均衡导致模型偏向

之前提到北方炭疽病样本少,如果不做任何处理,模型会大面积误判成健康。除了前面说的采样器和类别权重,还有一个简单但有效的策略:对少数类做“局部病灶复制”。具体做法是把患病叶片图像中病斑区域裁剪出来,用随机位置粘贴到健康叶片上,人为构建新的患病样本。这个方法看似粗暴,但在农业病害识别任务上效果还不错,因为它模拟了真实的发病形态。

需要注意一点:粘贴病斑时要做透明度混合或者边缘羽化,避免出现生硬的矩形拼接线,否则模型会学到“有矩形边缘=患病”这种错误模式。

7.3 视频推理线程卡死的排查

这个问题让我印象很深。部署到客户环境后,模型运行大约 6 小时,视频推理就会出现画面不更新的情况,CPU 占用率却还很高。

查了很久才发现是 RTSP 视频流断开了,cap.read()返回 False 后我的读取线程没有退出,而是在 waitKey 那种阻塞逻辑里白白空转。后来我加了重连机制:如果cap.read()连续失败超过 3 次,就重新VideoCapture(rtsp_url),同时把时间平滑器的窗口也reset(),避免重连后的第一帧结果被旧的平滑状态污染。

还有个跟线程相关的问题是 PyTorch 推理线程和视频读取线程共用同一个 CUDA context,在极少数情况下会遇到 queue 里积累了太多帧导致显存被打满。我的解决方式是限定队列长度和推理频率,并且在推理线程里定期调用torch.cuda.empty_cache()来释放不再使用的显存块,实测下来显存占用波动值大幅减小。

7.4 多类病害同时发生时怎么办

这是个非常现实的问题。一片玉米叶子上可能同时有大斑病和锈病,但分类模型是单标签的,只能输出一个类别。我在项目里用了一个折中方案:把分类模型作为第一级筛选,如果发现预测类别为病害,再额外跑一个轻量级的病斑分割模型,把病斑区域框出来,统计不同病害的占比。这个分割模型用的是 U-Net 的简化版,只在病斑级别标注上训练,精度不需要非常高,能区分出大斑病区域和锈病区域就行。

这个方案在演示时很加分,因为农技人员不关心“是什么病”,更关心“病有多严重、需要怎么处理”。只输出一个类别标签,实际使用价值非常有限。

7.5 常见问题速查表

问题现象可能原因解决方案
训练集 acc 高,验证集 acc 低过拟合 / 预处理不一致统一预处理函数,增加增强强度,加大 weight_decay
帧间预测结果反复跳动单帧置信度低,时间平滑缺失加入滑动窗口加权投票,设置低置信度遮蔽
视频推理延迟越来越高队列积压,消费速度跟不上生产速度减小队列大小,丢旧帧保新帧,降低抽帧频率
模糊帧导致误判运动模糊、失焦引入 Laplacian 方差判断,低于阈值直接丢帧
摄像头断流后程序假死read 阻塞没有超时加重连机制,连续失败后重建 VideoCapture
新视频前几帧结果异常平滑器窗口里有旧视频的残留切换视频时主动 reset 平滑器
少数类识别差类别不均衡使用加权损失、重采样、病灶复制增强

8. 部署与性能优化实践

8.1 模型压缩与导出

项目交付时,模型要部署到一台低功耗的边缘设备上,CPU 推理,不能依赖 GPU。EfficientNet-B3 参数量虽然不算特别大,但直接跑 CPU 还是有点吃紧。我做了几步压缩:

第一步是训练 MobileNetV3-Large 作为实时模型。因为数据和预处理代码完全复用,训练成本不高。MobileNetV3 在 CPU 上的推理速度比 EfficientNet-B3 快约 3 倍,精度只掉了约 4 个百分点。

第二步是把模型导出为 TorchScript。直接用torch.jit.trace导出,注意要指定一个和实际输入尺寸一致的 dummy input,然后把 model 设为 eval 模式再去 trace。

dummy_input = torch.randn(1, 3, 384, 384) traced_model = torch.jit.trace(model.eval(), dummy_input) traced_model.save("corn_disease_mobilenetv3.pt")

第三是量化。CPU 部署的终极手段是 INT8 动态量化,不过动态量化对 Conv 层的加速效果有限。我实际测试下来,可靠的做法是:如果推理设备支持 AVX512 指令集,直接用编译过的 PyTorch + 动态量化,单帧耗时大约能降低 30% 到 40%。如果追求极致的加速,就得用静态量化校准或者转 ONNX 走专用推理引擎了。

8.2 实测速度数据参考

我记录了一些在不同设备上的推理耗时数据,方便大家做选型参考:

模型设备输入尺寸单帧耗时
EfficientNet-B3桌面级 CPU384x384约 85ms
EfficientNet-B3中端独立显卡384x384约 12ms
MobileNetV3-Large桌面级 CPU384x384约 25ms
MobileNetV3-Large(量化后)桌面级 CPU384x384约 18ms

注意,这些数字是包含预处理和后处理全链路的时间。如果要做实时视频识别,每一路的预算建议控制在 50ms 以内,这样画面才会流畅。

8.3 边缘场景的省电策略

边缘盒子往往没有独立供电优势,功耗控制很关键。我做了两件事:

一是在无病害出现时降低推理频率。正常情况下 3 帧抽 1 帧,如果连续 20 帧都预测为“健康”,就把采样频率降到 10 帧抽 1 帧;一旦出现病害预测,恢复高频率采样并发出提醒。这个策略在实际使用中能节省大约 35% 的算力消耗。

二是把平滑器窗口的时间同步到采样频率变化上。注意,如果抽帧间隔变了,时间平滑的权重要根据实际帧间隔做调整,否则窗口的“时间覆盖范围”会失真。

9. 项目落地的几点总结

做完这个项目,我最大的体会是:机器学习的模型训练只是整个项目里很小的一部分,真正决定项目能不能落地、客户愿不愿意用,在于稳定性和细节体验。

单帧准确率 93% 听起来挺高,但一落到视频场景就原形毕露。农户不会关心你模型的 MAP 是多少,他只看到屏幕上病害标签乱跳,就会觉得这个系统“不靠谱”。所以那套时间平滑器和低置信度遮蔽机制,看上去没有模型本身“技术含量高”,却是整个系统能不能被接受的关键。

另外,数据永远是农业视觉项目最大的瓶颈。公开数据集和实拍场景的分布差异,比很多人想象中严重得多。我建议如果做类似项目,一定要预留至少两周的时间专门做数据清洗和现场数据采集,这个投入绝对值得。

在技术选型上,我也建议不要过分追求新模型。EfficientNet 也好,MobileNet 也好,都是非常成熟的架构,在这个任务上真正拉开差距的是数据质量和工程策略。把基础的分类、平滑、抽帧、部署链路打磨好了,比频繁换模型靠谱得多。

最后分享一个小技巧:视频推理的可视化输出,不要只画一个类别标签。最好把每一帧的置信度折线统计图画在旁边,滚动展示最近 1 分钟的识别稳定趋势。这样客户一眼就能看出系统是否稳定,而不是盯着一个跳来跳去的数字产生不信任感。

我做完这个项目之后,陆续又接了两个类似需求,一个是水稻稻瘟病识别,一个是小麦条锈病识别。核心架构基本没变,只需要换数据集、换类别名,再重新训练一轮模型。所以这篇文章里写的这套方案,实际上是一个可以复用的农业视觉识别框架,希望对你也有帮助。

RELATED READING

延伸阅读

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