1. 火灾检测为什么值得在 YOLOv8 上动结构
做视频类烟火识别这行的都知道,火灾检测这个题目最尴尬的地方在于:它看起来是个标准的目标检测任务,网上随手拉个 YOLOv8 的预训练权重,标几百张图,跑两轮就能在验证集上刷出 0.9 的 mAP。可一旦真的接到现场视频流上,误报能多到让人把告警按钮关掉。我见过太多项目就卡在这一步——模型指标好看,工程上不可用。
这篇是系列的第一篇,主要讲清楚为什么要在 YOLOv8 的基础上引入 AIFIRepBN 这个组合模块,这个组合解决的是哪一类具体问题,以及怎么把它合理地塞进网络里而不把训练搞崩。后面几篇会分别讲训练调参、量化部署和小目标切片的工程实现。适合已经跑通过原生 YOLOv8 训练流程、准备在真实场景里做落地改进的人看,纯新手建议先把 YOLOv8 的 backbone、neck、head 三段结构摸一遍再往下读,不然后面讲特征图层级会有点吃力。
1.1 三个绕不开的老问题
火灾视觉检测的难点从来不是"火是什么颜色",而是三件事同时发生。第一是早期目标极小:真正有预警价值的时刻,往往是一缕十几像素宽的青烟,或者远处一个几像素的亮点,等你看到成片的火焰,那已经是处置阶段不是预警阶段了。第二是形态漂移严重:火焰在燃烧过程中形状变化幅度远超普通物体,一秒钟前还是个点,几秒后就是一片不规则的带状区域,烟雾更是半透明、边界模糊、随气流变形,标注框本身都有很大的不确定性。第三是类火干扰密集:夕阳、路灯、车灯、红色车辆、金属反光、焊接火花、烤箱内部、秋天的红叶,这些东西在 RGB 空间里的颜色分布和火焰高度重叠。
这三件事叠在一起,导致一个很典型的现象:模型在验证集上 mAP50 能到 0.93,投到现场后每天几千条误报,运维人员第三天就把告警关了。你去看那些误报截图,会发现模型框住的都是孤立的高亮红色或橙色区域——它学到的是"颜色像火",而不是"场景里有火"。这个差距,靠调阈值、加数据是补不回来的,本质上是模型缺少判断上下文的能力。
1.2 原版 YOLOv8 在火灾场景下的具体短板
原生 YOLOv8 的结构是 backbone 出 P3、P4、P5 三个尺度,P5 走 SPPF 之后进 PAN-FPN,自顶向下传语义、自底向上传位置,最后三个解耦头分别出预测。这个设计对 COCO 那类目标足够,但在火灾场景下有两处明显的结构性缺口。
一处是感受野的传递方式。PAN-FPN 的信息融合是靠卷积堆叠和上采样实现的,卷积的感受野增长是线性的,P5 上面的信息要传到 P3,中间隔着好几层卷积,实际有效感受野远小于理论值。这意味着 P5 位置(20×20 网格)上的模型虽然能看到更大的区域,但它看到的"上下文"并没有被显式地整合成一个全局判断,只是被动地往下传。对于"这个亮斑周围有没有扩散状烟雾""这个高亮区是不是孤立在城市夜景里"这类需要全局判断的问题,卷积结构天然吃力。
另一处是归一化层的统计稳定性。YOLOv8 全程用 BN。BN 的好处是梯度平滑、收敛快,但它的统计量依赖 batch 分布。火灾数据有个特点:同一个 batch 里可能同时混着白天暴晒的高亮图和夜间火光的低照度图,亮度分布差异极大,batch 统计量的方差会剧烈波动,训练后期 loss 抖得厉害。更麻烦的是推理阶段,如果用 batch=1 逐帧跑视频流,BN 用的是训练时攒下来的 running 统计量,一旦现场光照和训练集有偏移,BN 的输出分布就会整体偏移,表现为"换个场地准确率掉一半"。
AIFIRepBN 这个组合,就是针对这两处缺口设计的:AIFI 负责在最高层特征上做一次真正的全局自注意力,把上下文显式建模出来;RepBN 负责把归一化的统计稳定性问题按训练态和推理态分开处理。
2. AIFIRepBN 拆开看:一个全局注意力配一个双归一化
这个名字是两块的拼接,AIFI 加 RepBN,不要被它唬住,拆开看都很直接。我在项目里的用法是:把 backbone 输出的最高层特征送进一个 Transformer 编码块,块内的两个归一化层用 RepBN 替换掉原本的 LayerNorm。整块结构本身没有新发明,价值在于组合方式选得对不对、位置放得准不准。
2.1 AIFI 到底做了什么
AIFI 的原型出自 RT-DETR 系列检测器的混合编码器,全称是"基于注意力的内在特征交互"。它的核心判断非常朴素:多尺度特征里,只有最高层、最低分辨率的那张图值得做全局自注意力,其他层直接走卷积融合就行。这个判断背后是算力账,不是理论账。
算一下就明白了。输入 640×640 时,P5 的步长是 32,特征图是 20×20,展平成序列是 400 个 token,自注意力矩阵是 400×400,一共 16 万个元素,fp32 下 640KB,完全无所谓。如果把同样的操作放到 P3 上,步长 8,特征图 80×80,序列长度 6400,注意力矩阵 6400×6400 就是 4096 万个元素,fp32 下 164MB,这还是单头,8 头乘上去 1.3GB 直接爆显存。输入提到 960 的话,P3 变成 120×120 即 14400 个 token,注意力矩阵 2.07 亿个元素,fp32 约 829MB,8 头 6.6GB,任何一张消费级卡都不用想了。而 960 输入下的 P5 是 30×30 即 900 个 token,900×900 只有 81 万个元素,3.24MB,依然随意。
注意:这三个数量级的差距,就是"为什么只加在最深层"的全部理由。我见过有人图省事把注意力模块统一加到三个尺度上,训练到第二个 epoch 就 OOM,还以为是数据加载的问题。
AIFI 块内部是一个标准的 post-norm 编码层:多头自注意力 → 残差 → 归一化 → FFN → 残差 → 归一化。输入是 [B, C, H, W],先 permute 成 [B, H×W, C] 的序列,加上二维正弦位置编码,然后 Q 和 K 都带位置编码、V 不带,这是标准做法。它带来的能力是:P5 上每一个位置都能直接"看到"整张图的所有位置,从而学到空间上的长程关系。对火灾场景来说,这个能力对应的是"这块高亮区域和周围的语义是否协调"——孤零零一个红色斑块在黑色背景里,是路灯;红色斑块下面连着灰白色的扩散区域,是初期火情。
2.2 RepBN 的并行结构和重参数化
RepBN 要解决的问题是 LayerNorm 和 BatchNorm 各自的偏科。LN 按样本归一化,不依赖 batch,推理稳定,但训练时梯度不如 BN 平滑,收敛慢;BN 反过来,训练快但推理吃 batch 统计。RepBN 的思路是把两者并行起来,训练时同时用,推理时收成一个。
训练态的形式很简洁:
y = LN(x) + η · BN(x)其中 η 是一个可学习的标量,初值一般设 0 或者 0.1。设 0 的话,相当于从纯 LN 起步,训练过程中模型自己决定要不要引入 BN 的统计;也可以从 BN 起步、第二阶段再切到 RepBN,这样过渡更平滑,我后面训练策略那节会讲。
推理态为什么能收成一层,推一下就清楚了。推理时 BN 的均值 μ 和方差 σ 是训练时攒下的常量,所以 BN(x) 退化成逐通道的仿射变换a ⊙ x + b,其中a = γ_b / σ,b = β_b - a ⊙ μ。代回去:
y = LN(x) + η·a ⊙ x + η·bη·a ⊙ x这一项是输入 x 的逐通道缩放,如果前一层是卷积或线性层,可以直接折进它的权重里;η·b是个常量,折进偏置。折完之后,剩下的就只是一个纯 LN,推理时零额外参数、零额外延迟。这就是"重参数化"三个字的实际含义——训练时多分支,推理时合单路。
提示:折叠成立的前提是前一层输出只服务于这一个归一化层。如果中间夹了残差相加,折叠就得拆开分别处理,实现会复杂不少。
我在实际部署时的处理比较务实:没有做折叠。原因是 LN + BN + 一次加法全是元素级运算,实测在 1080p 视频流上对 FPS 的影响不到 2%,为了省这点性能去写折叠逻辑、还要处理残差分支的边界情况,不划算。如果是要往边缘设备上压,那再考虑折叠,或者干脆把 η 固定为 0 退化成纯 LN,精度损失通常在一个点以内。
顺带说一个容易忽略的细节:这里的 BN 用的是BatchNorm1d,输入布局是 [B, C, N],统计量跨 batch 和序列长度 N 计算,效果上等价于视觉里的 BN 跨 (B, H, W) 统计;而 LN 是沿通道维 C 归一化。两者统计维度正交,这才是"互补"的物理来源,不是随便凑的。
2.3 两者拼在一起为什么对火灾场景对味
AIFI 和 RepBN 单独拿出来都不新鲜,凑在一起对火灾场景特别合适,是因为它们刚好各自补上了对方的短板。
AIFI 这种全局自注意力在训练时比较"娇气",对归一化方式和学习率都敏感,序列长度一变,LN 的统计分布跟着变,收敛曲线容易抖。配上 RepBN 之后,BN 分支提供了跨 batch 的统计平滑,相当于给注意力层加了个稳定器,前期收敛明显更稳。我做过对照,同样 150 epoch,纯 LN 的版本在第 20 到 40 epoch 之间有一段明显的 loss 平台期,换成 RepBN 之后这段基本消失。
反过来,RepBN 的 BN 分支在小 batch 下统计不准,但 AIFI 恰好只在 P5 上工作,序列长度在 640 输入下是 400、960 下是 900,BatchNorm1d的统计是在这个 N 维度上做的,即使 batch size 只有 4,统计量也有 1600 到 3600 个样本点,足够稳。这个组合在小 batch 训练时特别舒服,而火灾数据因为分辨率高、标注框密集,batch 普遍开不大,正好对上。
还有一个隐性好处是推理阶段的。视频流推理基本都是 batch=1 逐帧跑,纯 BN 的模型在跨场地时会因为 running 统计量偏移而整体漂移。RepBN 推理时主体是 LN,样本独立,不受 batch 影响,换个场地光照变了,输出分布不会整体偏。这一点在实地部署时的体感差异,比验证集上那零点几个点的 mAP 重要得多。
3. 把模块塞进 YOLOv8 的完整改造
结构讲清楚了,接下来是工程落地。这块容易出问题的地方不在模块本身,而在接入方式——位置编码的尺寸依赖、YAML 里的层索引顺延、预训练权重的加载匹配,任何一处搞错都会表现为"训练不收敛",然后你就会花两天时间怀疑模块设计有问题。
3.1 模块代码实现
先写 RepBN。注意输入布局是 [B, N, C],BN 需要 permute 到 [B, C, N]。
import math import torch import torch.nn as nn class RepBN(nn.Module): """LN 与 BN 并行的可重参数化归一化,输入布局 [B, N, C]""" def __init__(self, num_features, eps=1e-5, momentum=0.1, eta_init=0.0): super().__init__() self.ln = nn.LayerNorm(num_features, eps=eps) self.bn = nn.BatchNorm1d(num_features, eps=eps, momentum=momentum, affine=True) self.eta = nn.Parameter(torch.tensor(float(eta_init))) def forward(self, x): y_ln = self.ln(x) y_bn = self.bn(x.transpose(1, 2)).transpose(1, 2) return y_ln + self.eta * y_bn然后是位置编码。这里有个必须提前想清楚的问题:正弦位置编码的维度是固定的,标准实现是 64 组频率,正弦余弦各一份,横纵各一份,加起来正好 256 维。也就是说这个编码只适配通道数 256 的特征。而 YOLOv8 的 P5 通道数随模型规模变化,n 和 s 是 256,m 是 512,l 和 x 是 1024。直接用会维度对不上。
解决办法是在 AIFI 块前后各加一个 1×1 卷积做通道投影,块内固定用 256 维工作。这也顺手把计算量压下来了,因为 l/x 规模的 P5 是 1024 通道,直接在上面做注意力成本高得多。
def build_2d_sincos_pos(h, w, device, dtype, temperature=10000.0): """生成 [1, h*w, 256] 的二维正弦位置编码""" y, x = torch.meshgrid( torch.arange(h, device=device, dtype=dtype), torch.arange(w, device=device, dtype=dtype), indexing="ij", ) eps = 1e-6 y = y / (h - 1 + eps) * 2 - 1 x = x / (w - 1 + eps) * 2 - 1 omega = torch.arange(64, device=device, dtype=dtype) / 64 omega = 1.0 / (temperature ** omega) out_y = y.flatten()[:, None] * omega[None, :] out_x = x.flatten()[:, None] * omega[None, :] pos = torch.cat([out_x.sin(), out_x.cos(), out_y.sin(), out_y.cos()], dim=1) return pos.unsqueeze(0)最后是主模块:
class AIFIRepBN(nn.Module): """YOLOv8 最高层特征的全局重编码块""" def __init__(self, c1, dim=256, num_heads=8, hidden_ratio=2.0, dropout=0.0, eta_init=0.0): super().__init__() assert dim % num_heads == 0 self.dim = dim self.proj_in = nn.Conv2d(c1, dim, 1) if c1 != dim else nn.Identity() self.proj_out = nn.Conv2d(dim, c1, 1) if c1 != dim else nn.Identity() self.attn = nn.MultiheadAttention(dim, num_heads, dropout=dropout, batch_first=True) hidden = int(dim * hidden_ratio) self.ffn = nn.Sequential( nn.Linear(dim, hidden), nn.GELU(), nn.Dropout(dropout), nn.Linear(hidden, dim), ) self.norm1 = RepBN(dim, eta_init=eta_init) self.norm2 = RepBN(dim, eta_init=eta_init) self.drop = nn.Dropout(dropout) def forward(self, x): x = self.proj_in(x) b, c, h, w = x.shape seq = x.flatten(2).transpose(1, 2) # [B, N, C] pos = build_2d_sincos_pos(h, w, seq.device, seq.dtype) q = k = seq + pos attn_out, _ = self.attn(q, k, seq, need_weights=False) seq = self.norm1(seq + self.drop(attn_out)) seq = self.norm2(seq + self.drop(self.ffn(seq))) return self.proj_out(seq.transpose(1, 2).reshape(b, c, h, w))need_weights=False这个参数别省,默认值是 True,会额外算一遍完整的注意力权重矩阵并返回,既慢又占显存,而我们根本用不到。
3.2 YAML 配置与插入位置的取舍
插入位置我试过三种,结论是只放在 SPPF 之后、进 neck 之前这一处最划算。候选位置有三个:SPPF 前、SPPF 后、neck 的 P5 融合节点后。SPPF 前的话,那块还有大量空间信息没被压缩,序列长度一样但语义不够抽象,注意力学到的多是纹理关系;neck 融合后的话,P5 已经和 P4 上采样的特征混过一遍,位置信息被稀释了。SPPF 之后是最干净的——特征语义最强、分辨率最低、位置信息还没被污染。
以 yolov8n 为例,backbone 末尾改成这样:
backbone: # ... 前面若干层省略 - [-1, 1, Conv, [1024, 3, 2]] # 索引 7,P4 -> P5 下采样 - [-1, 3, C2f, [1024, True]] # 索引 8 - [-1, 1, SPPF, [1024, 5]] # 索引 9 - [-1, 1, AIFIRepBN, [1024, 256, 8, 2.0, 0.0, 0.0]] # 索引 10,新增这里的1024是 yaml 里的名义通道,实际会被 width 系数缩放,n 版本缩完就是 256,proj_in自动退化成 Identity,不产生额外参数。
新增一层之后有个必须改的地方:head 部分引用 P5 的那一层,from索引要跟着往后顺延一位。原来是from: [9],现在得写from: [10]。忘了改的症状很有意思——不报错,但模型会去引用 SPPF 的输出而不是全局重编码之后的特征,等于你的模块白加了,训练照样能收敛,指标也不会崩,就是没有提升。我第一版就是这么踩的,排查了半天以为是模块没用。
注册模块时在parse_model里加一条分支即可:
elif m is AIFIRepBN: c2 = ch[f] args = [c2, *args]参数顺序要和__init__的签名对上,c1由框架按上一层输出通道自动填,其余从 yaml 里读。
3.3 位置编码与多尺度输入的兼容坑
这个坑值得单独拎出来说。YOLOv8 默认开multi_scale,训练过程中每若干个 iteration 会随机换一次输入尺寸,常见做法是在 640 的 0.5 到 1.5 倍之间抖动。正弦位置编码是按当前特征图的 h、w 现算的,尺寸一变,编码内容就整体变了一次。
这本身不是 bug,因为每次都是重新生成的,和当次输入对得上。但问题在于:模型会同时见到 640、800、960 等多种尺寸下的位置编码,位置的"物理尺度"含义不一致,P5 上相邻两个 token 在 640 输入下相隔 32 像素,在 960 输入下还是 32 像素,这点是一致的;但归一化后的坐标范围在不同 h、w 下对应的实际视野不一样。实测下来,开着 multi_scale 训练时,加了位置编码的版本收敛较慢,且在某个尺寸上的表现会明显好于其他尺寸,泛化反而变差。
我的处理是训练时把multi_scale关掉,固定一个分辨率。火灾数据本身场景单一,多尺度增强带来的收益本来就不大,而 mosaic 和随机缩放已经在做尺寸多样性了。关掉之后收敛曲线明显更顺。如果确实要用多尺度,另一个选择是把正弦编码换成一个可学习的nn.Parameter位置表,前向时按输入尺寸做双线性插值,代价是位置精度略损,但兼容性好很多。
还有一点:build_2d_sincos_pos里对 h-1 和 w-1 做了归一化,如果特征图退化成 1×1(极小输入加上很深的网络),分母会变成 eps 的近似,除出来是 0 附近的一堆数,位置信息就没了。正常 640 输入不会出现这情况,但如果有人为了提速把输入压到 320,P5 只剩 10×10,虽然不会退化但分辨率确实太粗,这时候更该考虑改检测头配置而不是硬上。
4. 数据口径与训练参数怎么定
结构改完只是第一步,火灾数据的口径和训练策略对最终效果的影响,说实话比模块本身还大。我见过模块设计得很精巧、结果因为标注框把稀薄烟雾全框进去了导致回归分支学崩的例子。
4.1 类别体系与标注规则
类别我建议就定两类:fire和smoke。有人会想加flame、smoke_light、smoke_dense这种细分,理由是希望模型区分严重程度。这个想法在低分辨率监控画面上很难成立——同一团烟雾在不同帧里密度差异极大,标注人员自己都标不一致,细分标签带来的只会是噪声。两类足够覆盖告警需求,严重程度交给后处理(比如检测框面积占比加持续时间)判断更靠谱。
标注规则里有三条是必须写进规范文档的,不然不同人标出来的东西没法混。
第一条是烟雾框的边界怎么定。烟雾是半透明的,边缘无限延伸,你让十个人标同一张图,框的大小能差一倍。我的规则是只框"能被判定为烟雾主体、颜色浓度明显高于背景"的部分,扩散到几乎透明的边缘不框。这条规则的副作用是框普遍偏小,但一致性高,对模型来说是好事——回归目标稳定比覆盖完整更重要。
第二条是火和烟重叠时的处理。燃烧初期火焰在烟雾内部是很常见的,这种情况允许两个框大量重叠,都标。不要因为重叠度高就合并,也不要只标一个。YOLOv8 的 TaskAlignedAssigner 对重叠目标有一定处理能力,但前提是标签本身不能自相矛盾。
第三条是负样本怎么放。这一条最容易被忽略。训练集里必须混入 5% 到 10% 的纯背景图,专门挑那种容易误报的:黄昏天空、路灯密集的街道、红色外墙建筑、焊接作业画面、汽车尾灯。这些图对应的标签文件留空。Ultralytics 支持空标签,会被当作负样本参与分类损失计算。混入比例不要太高,超过 20% 会拖慢正样本的收敛,5% 到 10% 是我试出来的比较舒服的区间。
4.2 超参配置与训练阶段
配置大概长这样,以 yolov8n 加 AIFIRepBN、单卡 24G 显存为例:
model: yolov8n-aifirepbn.yaml data: fire_smoke.yaml epochs: 150 batch: 16 imgsz: 960 optimizer: SGD lr0: 0.01 lrf: 0.01 momentum: 0.937 weight_decay: 0.0005 warmup_epochs: 3.0 warmup_momentum: 0.8 close_mosaic: 20 multi_scale: false rect: false patience: 40几个参数值得解释一下原因。
imgsz选 960 而不是默认的 640,是因为早期火点在 640 下经过 32 倍下采样后,P5 上的响应几乎没有,P3 上也就剩几个像素。960 输入下 P3 是 120×120,同样大小的目标能占到 6 到 8 个像素,可学性明显提升。代价是 P5 的序列长度从 400 涨到 900,注意力开销涨了约五倍,但绝对值仍然很小,显存主要吃在 P3 的高分辨率特征图上,跟模块关系不大。
close_mosaic: 20的意思是最后 20 个 epoch 关掉 mosaic 增强。这一条在火灾任务上比在通用检测任务上更关键。mosaic 会把四张图拼在一起,火焰和烟雾被切碎重组,拼出来的图案在物理上是不可能存在的——比如一朵烟从画面正中间凭空断掉,或者一团火被贴到另一个场景的天空上。模型在前期能靠这个学到尺度不变性,但后期需要收敛到真实的形态分布上,不关的话最终 mAP 会掉一两个点,误报也会增多。
学习率用 SGD 加 0.01 的初始值,配合 3 个 epoch 的 warmup。加模块之后不要急着换 AdamW,SGD 在这个结构上收敛虽然慢一点,但最终精度通常更高,而 warmup 能避免注意力层刚初始化时被大梯度冲坏。
训练阶段我习惯切成两段,如果eta_init设为 0 的话:前 10 个 epoch 把eta冻住(requires_grad=False),只训 LN 分支和注意力主体,让模型先找到一个合理的表示;从第 11 个 epoch 开始解冻eta,让 BN 分支慢慢介入。这么切的原因是注意力层初始随机,前期梯度大,如果 BN 分支同时参与,两个统计维度的梯度叠加容易让eta快速涨到一个不合适的值,之后再难降回去。解冻之后eta一般会稳定在 0.2 到 0.5 之间,很少超过 1。
4.3 预训练权重怎么接
改结构之后,yolov8n.pt直接 load 会报一堆 key 不匹配,这是正常现象,别慌。Ultralytics 的model.load()默认是宽松匹配,能对上的就加载,对不上的随机初始化,同时打印一串 missing keys。
要检查的是哪些 key 对上了。backbone 的 Conv、C2f、SPPF,neck 的 C2f 和上采样,head 的三个解耦头,这些应该全部加载成功。AIFIRepBN 里的attn.in_proj_weight、ffn.*、norm*.*应该全部在 missing 列表里,这是预期的。如果发现有 backbone 的 key 出现在 missing 里,说明 yaml 的层索引改错了,多半是新增层导致后面的层号全变了,得整体核对一遍。
还有一个可选操作是部分冻结。数据集小于 5000 张时,我建议前 20 个 epoch 冻结 backbone 的前三段(P3、P4 对应的部分),只训后段和 AIFIRepBN,学习率调小到 0.001。原因是新增的注意力模块参数量不小,在中小数据集上从头训很容易过拟合,先让它在一个已经学好的特征基础上适配,收敛更稳。数据量上万之后就不用冻了,直接全量训。
5. 实测中踩到的坑和排查清单
下面这些是我在这个结构上反复调试过程中攒下来的,大部分在文档里查不到,因为都是组合使用才暴露出来的问题。
5.1 训练阶段的典型异常
症状一:第一个 epoch 就 NaN,loss 直接变 inf。
八成是 RepBN 里的 BN 遇到了极端值。检查两处:一是eta_init是不是设得太大,从 0.5 起步的话,BN 分支的初始