改进YOLOv5烟叶病害检测:标注训练、CBAM与CIoU调优 简介这是一份面向计算机视觉学习者、农业智能化方向研究者与课程设计或毕业设计参与者的PPT报告聚焦基于改进YOLOv5的烟叶病害检测系统回应传统病害诊断不及时、识别不准的问题。整包仅含1个pptx文件大小约10.34MB内容以项目汇报结构组织便于课堂展示、方案答辩与技术交流。报告围绕烟叶病害的经济影响、嵌套式YOLOv5识别思路、数据集采集与labelimg标注、环境搭建、训练与识别效果、系统整合及后续研究方向展开并涉及特征融合、激活函数与损失函数等改进线索。已有3134人学习可作为目标检测落地农业场景的案例参考帮助读者理解从数据准备到系统集成的完整链路并获取可借鉴的实验设计与汇报框架。1. 这套资源讲什么、能解决什么问题改进 YOLOv5 做烟叶病害检测这个题目在计算机视觉与深度学习的课程设计里出现频率很高交上去的材料里算法只占一半另一半是能不能把数据、训练、指标、演示串成一条完整线。这份基于改进 YOLOv5 的烟叶病害检测系统 PPT把病害背景、labelimg 标注、YOLOv5 环境搭建、训练与识别效果以及优化器、激活函数、损失函数、网络结构四处可改点放在同一条叙述线上正好对上“这是什么、怎么用、坑在哪”三类问题。它适合要交课程设计或答辩的学生也适合做叶片质检方案、想判断这套改进值不值得复现的工程师——PPT 里的骨架能直接用缺的是把骨架跑起来的参数和排错细节后面几章就补这块。2. 三个可替换的改进点优化器、激活函数与损失函数怎么选PPT 里把“改进 YOLOv5”拆成了几条相对独立的改动线这个拆法很务实优化器、激活函数、损失函数、网络结构每一处都能单独做消融也都能单独写进答辩稿。很多人在这里翻车不是改不动代码是四处一起改最后指标涨了 2 个点说不清是哪一处贡献的评审一问就露底。合理的节奏是先固定数据与划分再一处一处替换每次只动一个变量把 mAP0.5 和 mAP0.5:0.95 都记下来。章节里给的参数是常见起点不是唯一解真正要盯的是训练曲线和验证集预测图。2.1 优化器不是二选一按数据规模切 Adam 与 SGDYOLOv5 的作者在代码里同时留了 Adam 和 SGD 两套预设并且给两套都配了匹配的超参PPT 里那句“小数据集用 Adam大数据集回到 SGD”是经验判断不是定论。我一般这样落地自建烟叶数据集通常几千张、几千个框属于小样本先用 Adam 起步学习率按 1e-3 量级给不要直接套 SGD 的 0.01否则前几个 epoch 的 loss 会呈锯齿状等数据量上到几万张、类别分布更均衡时再切 SGD收敛更稳最终精度往往也更耐看。# 小样本起步Adam学习率压到 1e-3lrf 控制余弦退火的下限 python train.py \ --img 640 --batch 16 --epochs 150 \ --data data/tobacco_leaf.yaml \ --weights yolov5s.pt \ --optimizer Adam --lr0 0.001 --lrf 0.01 \ --cache --workers 4 \ --project runs/train --name leaf_adam参数逐个说清楚--optimizer决定超参模板选 Adam 时--lr0必须同步下调--lrf是终止学习率相对初始值的比例0.01 表示退火到 1e-5--cache把解码后的图像缓存进内存几千张小图能明显缩短每个 epoch 的取数时间图多到几十 GB 就别开--workers在 Linux 上给到 4 到 8Windows 上先给 0 或 2后面避坑章节会展开。切换 SGD 时我只改两处# 数据量上来后切 SGD动量与权重衰减按预设走学习率回到 0.01 量级 python train.py \ --img 640 --batch 32 --epochs 300 \ --data data/tobacco_leaf.yaml \ --weights runs/train/leaf_adam/weights/best.pt \ --optimizer SGD --lr0 0.01 --momentum 0.937 --weight-decay 0.0005从 Adam 训出来的权重接 SGD 继续训是我比较推荐的做法Adam 阶段快速把 loss 压下去SGD 阶段做精修两边的好处都吃到。要注意衔接时 batch 从 16 提到 32、学习率从 1e-3 提到 1e-2如果曲线在衔接处抖得厉害先用 5e-3 过渡 20 个 epoch 再提。判断该不该切的信号很直接Adam 训到 100 epoch 后验证集 mAP 连续 30 个 epoch 在 0.5 个点内横盘就可以考虑换优化器或换数据增强而不是继续加 epoch。2.2 激活函数替换Mish 换进 YOLOv5 的真实代价PPT 里对比得挺清楚YOLOv5 中间层用 Leaky-ReLU检测头最后一层用 SigmoidYOLOv4 用的是 Mish。于是很自然的想法是把中间层的 Leaky-ReLU 全换成 Mish指望精度涨一点。这条思路可行但代价要提前算Mish 比 Leaky-ReLU 多一次指数和对数运算显存占用和单 epoch 时间都会上去小数据集上的收益常常被噪声吃掉——我在一个两千多张叶片的数据集上试过mAP0.5 涨了 0.4 个点单 epoch 时间多了将近两成答辩时这个交换比并不好看最后又退回了 Leaky-ReLU。真要换代码层面不要图省事直接调nn.Mish原因在导出后面如果要做成可演示的推理脚本甚至 ONNX 模型某些算子集会卡在激活函数上。写一个显式实现放进models/common.py再在models/yolo.py的parse_model里注册路径最顺# models/common.py 追加显式实现 Mish便于替换与导出 import torch import torch.nn as nn import torch.nn.functional as F class Mish(nn.Module): x * tanh(softplus(x))参数量为 0仅替换非线性 def __init__(self, inplaceFalse): super().__init__() self.inplace inplace def forward(self, x): return x * torch.tanh(F.softplus(x))写完之后不要全局替换先只替换 backbone 里较深的两到三个 C3 块检测头最后一层的 Sigmoid 绝对不动——它负责输出置信度和类别的概率换成 Mish 会把输出值域撑到负数训练直接崩。替换方式是在自定义 yaml 里把对应层的参数位改成新激活名或者在Conv类里把act参数化# models/common.py 的 Conv 模块把激活函数做成可传参默认仍是 SiLU/Leaky-ReLU class Conv(nn.Module): default_act nn.SiLU() # 保持原默认行为避免影响其他配置 def __init__(self, c1, c2, k1, s1, pNone, g1, actTrue): super().__init__() self.conv nn.Conv2d(c1, c2, k, s, autopad(k, p), groupsg, biasFalse) self.bn nn.BatchNorm2d(c2) self.act self.default_act if act is True else (act if isinstance(act, nn.Module) else nn.Identity())改完跑两组一组原版一组替换其余超参完全一致看 mAP0.5:0.95 和每 epoch 秒数两列。如果精度没涨、时间涨了就别写进最终版本如果涨了但幅度在 1 个点以内论文里要老实写“提升有限代价是约两成训练时间”比硬吹更经得起追问。2.3 边界框损失GIoU 换 CIoU 的收益与验证方法YOLO 系列的损失由目标置信度、类别概率、边界框三部分构成PPT 里指出 YOLOv5 用 GIoU LossYOLOv4 用 CIOU LossCIoU 收敛更快。这个结论在烟叶这种细长、密集、互相遮挡的目标上值得单独验一次叶片重叠多GIoU 在框不相交时只退化到最小闭包框的约束方向信息不够前期收敛确实会慢一些。YOLOv5 的bbox_iou已经内置了 GIoU/DIoU/CIoU 分支不用手写公式改的是损失计算那一侧的调用参数# utils/metrics.py 中 bbox_iou 的关键分支节选函数签名以你手上版本为准 def bbox_iou(box1, box2, xywhTrue, GIoUFalse, DIoUFalse, CIoUFalse, eps1e-7): # ... 坐标转换略 ... iou inter / (area1 area2 - inter eps) if CIoU or DIoU or GIoU: cw torch.max(b1_x2, b2_x2) - torch.min(b1_x1, b2_x1) ch torch.max(b1_y2, b2_y2) - torch.min(b1_y1, b2_y1) if CIoU or DIoU: c2 cw ** 2 ch ** 2 eps rho2 ((b2_x1 b2_x2 - b1_x1 - b1_x2) ** 2 (b2_y1 b2_y2 - b1_y1 - b1_y2) ** 2) / 4 if CIoU: # 长宽比一致性项v 越接近 0 说明两框形状越接近 v (4 / torch.pi ** 2) * torch.pow( torch.atan(b2_w / (b2_h eps)) - torch.atan(b1_w / (b1_h eps)), 2) with torch.no_grad(): alpha v / (v - iou (1 eps)) return iou - (rho2 / c2 v * alpha) return iou - rho2 / c2 return iou - (c2 - inter) / c2 return iou逻辑说明rho2是两框中心点距离的平方c2是最小闭包框对角线平方CIoU 在 DIoU 基础上多了v * alpha这一项惩罚长宽比差异。参数上唯一要动的是调用处的开关比如把ComputeLoss里的bbox_iou(pbox, tbox[i], CIoUTrue)从 GIoU 改成 CIoU其余不要动。改完看两个东西前 30 个 epoch 的box_loss下降斜率以及验证集里长条形叶片比如整片枯叶的框贴合度。如果 box_loss 下降更快但 mAP 反而掉了多半是分类分支欠拟合这时候先回去调学习率别急着再改损失。3. 改网络结构给 YOLOv5 加 CBAM 与轻量 bottleneck 的四步落地结构改动是 PPT 里最容易讲、也最容易做砸的部分。原文给的四步是先在models/common.py实现 bottleneck 和 PW_Conv再改models/yolo.py里读取模型配置文件的parse_model函数然后在 import 引用处补上新模块名最后基于yolov5s.yaml写自己的配置文件。这四步顺序是对的坑主要集中在第二步——通道数没对齐、参数位被误当成通道数、yaml 缩进错一行报错信息全都指向“某个模块实例化失败”看不出根因。3.1 在 common.py 补模块PWConv、bottleneck 与 CBAM先写基础件。PW_Conv 是逐点卷积加 BN 加激活bottleneck 用 MobileNetV2 的倒残差结构先 1x1 升维再 3x3 深度可分离卷积再 1x1 降维最后残差相加。这套结构在叶片这种纹理重复度高的图像上比较省参数塞进 backbone 末尾不会明显拖慢推理。CBAM 则分通道注意力和空间注意力两段通道数不变只做加权。# models/common.py 追加PWConv / 倒残差 bottleneck / CBAM import torch import torch.nn as nn def autopad(k, pNone): 保持卷积前后尺寸一致的 padding 计算 return k // 2 if p is None else p class PWConv(nn.Module): 1x1 逐点卷积 BN 激活MobileNetV2 风格的基础块 def __init__(self, c1, c2, actnn.ReLU6): super().__init__() self.conv nn.Conv2d(c1, c2, 1, 1, 0, biasFalse) self.bn nn.BatchNorm2d(c2) self.act act(inplaceTrue) def forward(self, x): return self.act(self.bn(self.conv(x))) class BottleneckMob(nn.Module): 倒残差升维 - 深度可分离 - 降维通道一致时走残差 def __init__(self, c1, c2, e4, shortcutTrue): super().__init__() c_ int(c1 * e) self.pw1 PWConv(c1, c_) self.dw nn.Sequential( nn.Conv2d(c_, c_, 3, 1, 1, groupsc_, biasFalse), nn.BatchNorm2d(c_), nn.ReLU6(inplaceTrue), ) self.pw2 PWConv(c_, c2, actnn.Identity) # 降维后不加激活接残差 self.add shortcut and c1 c2 def forward(self, x): y self.pw2(self.dw(self.pw1(x))) return x y if self.add else y class ChannelAttention(nn.Module): def __init__(self, c, r16): super().__init__() self.avg nn.AdaptiveAvgPool2d(1) self.max nn.AdaptiveMaxPool2d(1) self.fc nn.Sequential( nn.Conv2d(c, max(c // r, 4), 1, biasFalse), nn.ReLU(inplaceTrue), nn.Conv2d(max(c // r, 4), c, 1, biasFalse), ) self.sig nn.Sigmoid() def forward(self, x): w self.sig(self.fc(self.avg(x)) self.fc(self.max(x))) return x * w class CBAM(nn.Module): 通道 空间注意力输出通道数必须等于输入通道数 def __init__(self, c1, c2None, k7): super().__init__() c2 c1 if c2 is None else c2 assert c1 c2, fCBAM 不改变通道数收到 c1{c1}, c2{c2} self.ca ChannelAttention(c1) self.sa nn.Conv2d(2, 1, k, 1, autopad(k), biasFalse) def forward(self, x): x self.ca(x) a torch.cat([x.mean(1, keepdimTrue), x.max(1, keepdimTrue)[0]], dim1) return x * torch.sigmoid(self.sa(a))要点在各处注释里再强调两个BottleneckMob的第二个 PWConv 不加激活是为了让残差相加前的输出分布更接近输入ChannelAttention里的降维比例r用了一个下限 4是为了防止小通道层比如 32 通道的浅层压到 2 甚至 1那样通道注意力基本等于常数。CBAM的 assert 不要删它是后面排错时最快能定位通道错位的哨兵。3.2 yolo.py 的 parse_model 注册与通道对齐parse_model是根据 yaml 里的模块名字符串反查全局命名空间来实例化的所以第一个动作是把新类导进models/yolo.py的命名空间。如果yolo.py顶部已经是from models.common import *新加的类自动可见如果是显式 import 列表就得手动补上PWConv, BottleneckMob, CBAM三个名字。这一步漏了报错是NameError: name CBAM is not defined最容易发现。第二个动作是处理参数位。parse_model的约定是yaml 里一层写成[-1, 1, 模块名, [参数...]]实例化时会把上一层输出通道c1自动塞到参数最前面所以[参数...]的第一位往往被当成c2。CBAM 不改变通道yaml 里就应该写空列表[]让c1单独进构造函数走c2None的默认分支如果写了[1024]就变成CBAM(c1, 1024)一旦前层通道不是 1024assert 立刻触发。# models/yolo.py 的 parse_model 内节选保留原有逻辑只在需要处插入 # 1) 确认新模块在命名空间里 from models.common import * # 或在显式 import 列表里补 PWConv, BottleneckMob, CBAM # 2) 通道不变量模块输出通道沿用上一层不做 c2 覆盖 no_c2_change {CBAM, ChannelAttention} for i, (f, n, m, args) in enumerate(d[backbone] d[head]): m getattr(torch.nn, m[3:]) if nn. in m else globals()[m] for j, a in enumerate(args): if isinstance(a, str): args[j] eval(a) if a in {True, False, None} else a if m in no_c2_change: c2 ch[f] # 强制通道不变避免 yaml 里手写错 args [c2] # CBAM 只吃一个位置参数 else: c2 args[0] if args else ch[f] m_ m(*args) if m in no_c2_change else m(ch[f], *args) t str(m)[8:-2].replace(__main__., ) np_ sum(x.numel() for x in m_.parameters()) m_.i, m_.f, m_.type, m_.np i, f, t, np_ save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x ! -1) layers.append(m_) ch.append(c2)这段是简化后的骨架实际项目里你手上的parse_model可能还有分组卷积、重复次数n的处理改动时只加no_c2_change这一个分支别重写整个循环。参数说明c2 ch[f]让输出通道严格等于上一层args [c2]保证CBAM(c1)的签名对得上。改完先跑一次 dry run不训练只建模型能省掉大量等训练启动的时间# 只建模型不训练验证 yaml 能不能被正确解析 python models/yolo.py --cfg models/yolov5s-cbam.yaml输出里会打印每一层的from / n / params / module / arguments逐个核对插入 CBAM 的那一层arguments应该是[-1, 1, 1, [512]]这种形态通道数和上一层对得上参数量为 0 左右CBAM 只有很少的卷积参数。对不上就回头改 yaml不要带着错误进训练。3.3 自定义 yaml、权重加载与嵌套式两级推理配置文件不要从零写基于yolov5s.yaml复制一份只在目标位置插层。CBAM 的插入位置我一般选 backbone 最后两个 C3 之后和 neck 的上采样汇合处浅层插入收益小、还容易拖慢。片段示意# models/yolov5s-cbam.yaml只列改动位置其余层保持原样 backbone: # [[from, repeat, module, args], ...] - [-1, 1, Conv, [512, 3, 2]] # P3 之后的降采样 - [-1, 3, C3, [512, False]] - [-1, 1, CBAM, []] # 空列表通道沿用上一层不能写 [512] - [-1, 1, Conv, [1024, 3, 2]] - [-1, 3, C3, [1024, False]] - [-1, 1, SPPF, [1024, 5]] - [-1, 1, BottleneckMob, [1024, 1024]] # 轻量残差块压一压参数量训练时把--cfg指过去--weights仍加载原始的yolov5s.pt。新增层没有预训练权重日志里会出现一批Unmatched keys这属于正常现象要警惕的是另一种backbone 里原有的层名大面积缺失说明你在 yaml 里挪动了层序预训练权重整体错位这时候要么改用--weights 从头训要么把插入位置换回去。# 加载原版预训练权重新增层随机初始化其余层照常迁移 python train.py \ --cfg models/yolov5s-cbam.yaml \ --weights yolov5s.pt \ --data data/tobacco_leaf.yaml \ --img 640 --batch 16 --epochs 200 \ --optimizer Adam --lr0 0.001 --lrf 0.01PPT 里提到的“嵌套式 YOLOv5”落到工程上一般是两级第一级用整叶检测框住单片叶子第二级把裁切块送进另一个模型做病害细粒度检测或分类。这种拆法对密集小病斑特别有效因为第二级的输入被放大了。实现上不用改训练代码推理侧拼一下就行import cv2 import numpy as np def nested_detect(img, leaf_model, disease_model, conf0.35, pad0.05, size640): 第一级定位整叶第二级在放大后的裁切块上检测病斑坐标回贴原图 h, w img.shape[:2] leaf_res leaf_model(img, sizesize)[0] results [] for *xyxy, c, s in leaf_res.xyxy[0].tolist(): if s conf: continue x1, y1, x2, y2 map(int, xyxy) p int(max(x2 - x1, y2 - y1) * pad) # 裁切留边避免病斑贴边被截断 cx1, cy1 max(0, x1 - p), max(0, y1 - p) cx2, cy2 min(w, x2 p), min(h, y2 p) crop img[cy1:cy2, cx1:cx2] if crop.size 0: continue dis_res disease_model(crop, sizesize)[0] for *dxyxy, dc, ds in dis_res.xyxy[0].tolist(): if ds conf: continue dx1, dy1, dx2, dy2 dxyxy # 裁切坐标 偏移量 原图坐标这一步漏了框会全部挤在左上角 results.append({ leaf_box: [x1, y1, x2, y2], disease_box: [dx1 cx1, dy1 cy1, dx2 cx1, dy2 cy1], cls: int(dc), score: float(ds), }) return results参数上的两个经验值pad取 0.05 到 0.1太小会把贴在叶缘的病斑切掉太大又会让第二级重新引入背景干扰conf两级可以不一样第一级放宽到 0.25 保证不漏叶第二级收到 0.35 到 0.4 压误检。坐标回贴那行代码是这类两级方案最常见的错误点忘了加cx1/cy1偏移画出来的框全挤在左上角。4. 烟叶数据集流水线labelimg 标注、xml 校验与分组划分PPT 里“基于迭代思想的数据集采集、标注、训练”这句话落地难度比算法改动高得多。烟叶病害的类别边界本身就模糊褐斑、赤星、黑胫在早期肉眼都难分标注一致性差模型上限就被锁死了。我在一个模拟项目里吃过亏——三个人标同一批图类名一个有下划线一个没有训练时data.yaml的 names 和 txt 里的索引错位模型把两种病当成一种mAP 看着还行混淆矩阵一片糊。4.1 采集与类别设计迭代式补采怎么定停止条件采集端先定三件事拍什么、怎么拍、标几类。拍什么按病害类型分健康叶也要留足样本否则模型会把“没病”学成“没检测到”怎么拍尽量覆盖真实场景的光照和背景田间自然光、室内补光、白底、黑底各来一部分别全是干干净净的实验室图那样训出来的模型一到现场就崩标几类不要贪多先把三到五类高频病害做扎实稀有类样本不足 100 个框的先合并成“其他病害”否则这类别的 AP 永远是 0还把整体 mAP 拖下去。迭代式补采的关键是定停止条件不然会陷入“再标一点”的无底洞。我一般用两轮第一轮训完看验证集的漏检图把漏检最多的两个场景比如逆光、叶片重叠各补 150 到 200 张第二轮训完如果 mAP0.5 的提升小于 1.5 个点且漏检图里没有新的系统性场景就停止补采转去做标注质量清洗。这个判据比“标到一万张”实在因为后期涨不动的往往不是数据量是标注噪声和类别定义。4.2 labelimg 标注规范与 xml 批量校验脚本labelimg 的用法不复杂真正要立规矩的是四件事。一是类名统一用英文小写下划线比如brown_spot、blank_shank不要中文、不要空格、不要大小写混用二是标注格式选 Pascal VOC产出 xml后面转 YOLO txt 一条命令的事三是框要贴紧病斑外缘不要把整片叶子框进去当病斑四是每标完一批就另存目录别在一个文件夹里反复覆盖出了问题没有后悔药。标完必须跑校验别指望人眼。下面这个脚本查四类问题空标注文件、未知类名、越界框、零面积框顺带统计每个类别的框数用来判断类别不均衡。import glob import xml.etree.ElementTree as ET from collections import Counter # 与 data.yaml 的 names 顺序保持一致顺序错了训练必然错位 NAMES [brown_spot, blank_shank, wildfire, healthy] cnt Counter() bad [] for f in glob.glob(datasets/leaf/labels_xml/*.xml): root ET.parse(f).getroot() size root.find(size) W, H int(size.find(width).text), int(size.find(height).text) objs root.findall(object) if not objs: bad.append((f, empty_xml)) # 空文件会被当成负样本谨慎保留 for o in objs: name (o.find(name).text or ).strip() cnt[name] 1 if name not in NAMES: bad.append((f, funknown_class:{name})) b o.find(bndbox) x1, y1, x2, y2 [float(b.find(k).text) for k in (xmin, ymin, xmax, ymax)] if x2 x1 or y2 y1: bad.append((f, zero_area)) if x1 0 or y1 0 or x2 W or y2 H: bad.append((f, out_of_range)) print(类别分布:, dict(cnt)) print(问题样本数:, len(bad)) for item in bad[:20]: print(item)逻辑说明遍历所有 xml用size里的宽高做边界判断bndbox的四个值统一转 float 再比较避免整数截断误判NAMES必须和data.yaml完全一致包括顺序这是后面避坑章节第一条的根源。跑完看两列类别分布里最多的类和最少的类差 10 倍以上就得考虑过采样或者加类别权重问题样本数不为 0先清完再进训练别带着脏数据跑几十个 epoch。4.3 分组划分、data.yaml 与首轮训练命令划分数据集最容易犯的错是随机按文件切。同一片叶子拍了好几个角度、同一个上午拍了几十张随机切会把它们分到训练集和验证集两边验证指标虚高得离谱换个新批次立刻掉十几个点。正确做法是按“叶片编号”或“拍摄批次”分组同组只进一个集合import glob import os import random from collections import defaultdict random.seed(42) files glob.glob(datasets/leaf/images/*.jpg) groups defaultdict(list) for f in files: # 文件名约定leafID_角度_序号.jpg按 leafID 分组避免同叶泄漏 gid os.path.basename(f).split(_)[0] groups[gid].append(f) gids list(groups.keys()) random.shuffle(gids) n_val max(1, int(len(gids) * 0.2)) val_ids, train_ids set(gids[:n_val]), set(gids[n_val:]) for split, ids in ((train, train_ids), (val, val_ids)): os.makedirs(fdatasets/leaf/{split}/images, exist_okTrue) os.makedirs(fdatasets/leaf/{split}/labels, exist_okTrue) for gid in ids: for f in groups[gid]: stem os.path.splitext(os.path.basename(f))[0] os.system(fcp {f} datasets/leaf/{split}/images/) os.system(fcp datasets/leaf/labels/{stem}.txt datasets/leaf/{split}/labels/) print(train 组数:, len(train_ids), val 组数:, len(val_ids))参数说明random.seed(42)保证划分可复现答辩时别人问起能当场重跑出同一份划分验证集比例 0.2 是几千张量级的常用值样本少于 500 张时按组切到 0.25 更稳cp那两行换成shutil.copy更跨平台。划分完写data.yaml# data/tobacco_leaf.yaml path: ../datasets/leaf train: train/images val: val/images nc: 4 names: [brown_spot, blank_shank, wildfire, healthy]首轮训练不要一上来就 300 epoch先用小 fig 数探路确认 loss 在降、验证图有框# 探路轮30 epoch 看曲线形状确认数据管线和标签没错位 python train.py \ --img 640 --batch 16 --epochs 30 \ --data data/tobacco_leaf.yaml \ --cfg models/yolov5s-cbam.yaml --weights yolov5s.pt \ --optimizer Adam --lr0 0.001 \ --workers 4 --project runs/train --name smoke_test跑完看三样results.png里 box_loss 和 cls_loss 是否单调下行、val_batch0_pred.jpg里预测框有没有落在叶片上、混淆矩阵对角线是不是明显。三样都正常再拉长到 200 到 300 epoch别把时间浪费在错的管线上。5. 避坑与排查五条记录每条都给现象、原因和解决下面这五条不是在 PPT 里能看到的是这类项目真跑起来几乎必撞的问题。按“现象 → 原因 → 解决”写遇到对应现象可以直接对号入座。5.1 训练侧类别错位、显存与 dataloader、权重不匹配第一条训练 loss 正常下降但验证集几乎不出框。现象是val_batch0_pred.jpg上一片空白或者所有框都标成同一个类。原因基本是data.yaml的names顺序和标注时用的索引不一致——labelimg 导出 YOLO 格式时按类名首次出现顺序生成索引你后来在 yaml 里调整了顺序索引就整体错位了。解决办法先跑 4.2 的校验脚本把NAMES和 yaml 的names逐字对比包括顺序再抽查几个 txt第一列的数字对应的类名是否和图上目标一致。确认后重新生成一遍标签不要手动改 txt 补救越改越乱。第二条训练到第 2 个 epoch 卡住不动GPU 利用率掉到 0。现象是进度条停住、日志不再刷新、显存占着但不计算。原因多半是--workers开太大数据加载进程和主进程死锁Windows 上尤其常见因为多进程启动方式和 Linux 不同。解决办法先把--workers设为 0 跑通确认是加载器问题再逐步加到 2、4 找稳定值。同时把--cache ram换成--cache disk内存不够时缓存整个数据集会触发交换表现也是卡死。顺手检查图片有没有损坏文件cv2.imread返回 None 的图会让某个 worker 静默退出。第三条加载预训练权重后精度反而比从头训还差。现象是日志里Unmatched keys一大片训练前 20 个 epoch 的 loss 比随机初始化还高。原因是改了 backbone 层序预训练权重整体错位卷积层对上了但语义层对不上等于给了一个错误的起点。解决办法对比日志里的Transferred xxx/xxx items正常改结构应该是只缺新增的那几层如果缺失数量接近总层数说明层序被动了把插入位置换到网络末端或者干脆--weights 从头训用更多 epoch 补回来。改结构时优先“末尾追加”少在中间插层这是最省事的习惯。5.2 数据与评估侧划分泄漏与指标虚高、锚框与分辨率不匹配第四条验证集 mAP0.5 到 0.95换个批次实测掉到 0.6 以下。现象是训练报告漂亮现场演示翻车。原因是划分泄漏同一片叶子的多个角度、同一批次的相似光照图被随机分到了训练和验证两边模型记住了叶子而不是病害。解决办法是按叶片编号或拍摄批次分组划分用 4.3 的脚本重切重切后指标通常会掉 5 到 10 个点这是挤掉水分后的真实水平别慌掉下来才说明评估可信。再加一步留一个完全没参与训练的批次做最终测试只看它一次不要拿它调参。第五条小病斑漏检严重大叶片检测正常。现象是整叶检测没问题早期病斑一平方厘米以下的几乎全漏。原因有两个一是 YOLOv5 配置文件里预设的 anchor 是按 640x640 输入尺寸算的而烟叶图像长宽比往往很扁缩放后有效分辨率被压缩二是 P3 特征图的下采样步长是 8小于 8 像素的目标在特征图上就没了。解决办法分两层先提高输入尺寸到 960 或 1280代价是显存翻倍batch 要相应减半再考虑 3.3 里的两级嵌套把叶片裁出来放大再检测等效分辨率提升比单纯加 imgsz 更划算。如果还想再进一步用 k-means 在自己的标注框上重聚类一组 anchor替换 yaml 里的预设值这个改动小、见效直接。6. 12 分钟讲完这份 PPT指标表、演示脚本和一个复盘习惯这份 PPT 的结构是按“背景 → 方法 → 实验 → 展望”排的直接照着念大概 20 分钟评审席会走神。我一般压到 12 分钟背景 1 分半讲清病害识别的时效性和人工诊断的局限别铺陈产业数据方法与改进点 4 分钟只讲你真正做了消融的那两处优化器和损失函数各一页其他一笔带过数据集与标注 2 分钟重点放类别定义和分组划分这是最能体现工程严谨度的地方实验 3 分钟一页表格加两张图演示 1 分钟剩下半分钟留给结论。指标表不要堆四列就够每列都要能解释配置mAP0.5mAP0.5:0.95单张推理耗时 (ms)基线 YOLOv5sGIoU0.8420.56111.3 CIoU0.8510.57811.4 CBAMbackbone 末端0.8630.58912.1 两级嵌套推理0.8790.60724.6表格里的数字换成你自己跑出来的重点是最后一列的代价要如实写两级嵌套精度最高但耗时翻倍这就给了你一个可以主动讨论的边界——产线节拍要求 20 ms 以内时该怎么取舍。评审最爱问的正是这种取舍你先把答案放在表里等于把问题引到自己熟悉的地方。演示脚本提前写成固定的几行现场不要即兴敲# 演示用固定权重、固定输入、固定置信度保证每次输出一致 python detect.py \ --weights runs/train/leaf_final/weights/best.pt \ --source demo/leaf_demo \ --img 640 --conf 0.35 --iou 0.45 \ --save-txt --project runs/demo --name final--conf和--iou写死是血泪经验现场换一张图默认阈值下框多得糊成一片评审第一印象就废了。提前在演示图集上把这两个值调到手感和精度都顺眼再固化进命令。最后一个习惯我每次训练启动前都强制走一遍先跑 30 epoch 的探路轮把results.png、val_batch0_pred.jpg、混淆矩阵三张图并排看一遍确认标签没串、框没跑偏、类别没塌再拉长训练。这个动作花不到十分钟能省掉一晚上的无效算力。从那以后我每次改完 yaml 或重切数据集都不会跳过这一轮因为翻车从来不是发生在模型上而是发生在那几行没人检查的配置里。希望帮到你。本文还有配套的精品资源点击获取