红外小目标检测:YOLO+动态规划多帧融合实战
红外图像的目标检测和可见光完全是两码事。我第一次把训练好的YOLO权重直接扔到红外视频上结果有点怀疑人生白天拍得清清楚楚的行人在热像仪画面里不是对比度太低就是边缘糊成一团漏检率一下子飙到三成以上。后来在一套“动态规划做多帧融合”的思路上折腾了一段时间把YOLO单帧检测结果按时间维度重新组织效果才慢慢稳下来。这篇案例想讲的就是这套方案怎么落地以及过程中踩过的那些坑。这套方案的核心思路其实很简单YOLO负责“看一帧”动态规划负责“串一段”。红外场景里目标往往对比度低、尺寸小、信噪比差单帧检测经常出现漏检或者置信度跳变但目标在连续帧里的运动通常是平滑的。把连续几帧的检测结果放到一张时空图里用动态规划求一条得分最高的路径就能把单帧漏掉的目标通过前后帧的信息“补”回来。这就是所谓的“先检测、后融合”不需要改动YOLO本身的结构对已有工程线的侵入非常小。这篇内容适合正在做红外安防巡检、工业测温、夜间监控这类任务的开发者尤其是已经跑通了YOLO检测但苦于红外小目标漏检、检测结果不稳定的人。只需要有基本的Python和PyTorch使用经验就能把文中的思路落到自己的项目里。1. 为什么YOLO在红外图像上会“失灵”1.1 红外图像和可见光图像的根本区别先说点背景。可见光图像是物体反射环境光形成的边缘清晰、纹理丰富、颜色信息量大。红外图像则是物体自身热辐射的强度分布本质上是温度场的可视化有几个非常明显的特征单通道灰度图没有颜色信息YOLO预训练模型的RGB统计特征基本失效。边缘是“温度梯度”而不是“反射率梯度”很多在可见光下很清楚的轮廓在红外下是模糊的渐变带。动态范围窄目标与背景的温差可能只有零点几度到几度反映到像素上就是很低的对比度。噪声模型不同红外探测器有固定图案噪声、非均匀性噪声图像质量比可见光差一个量级。这些差异直接导致一个问题直接用ImageNet或COCO上预训练的YOLO权重跑红外图浅层卷积核提取到的颜色、纹理特征都用不上相当于让一个习惯看彩色照片的人去观察一张全是噪声的热力图。所以很多项目里常见的操作是把灰度图复制成三通道喂进去虽然能跑但效果远不是最优的。更靠谱的做法是迁移学习保留YOLO backbone的前几层做通用的边缘和形状特征提取后面针对红外域做针对性微调。1.2 单帧检测的三大短板即便做了域适配单帧YOLO在红外场景里依然有几个绕不开的问题。第一是小目标漏检。红外场景里大量目标是典型的“小目标”无人机、远处的行人、输电线路上的发热器件在640×640输入下往往只占几个到十几个像素。YOLO的下采样倍数比较高小目标的特征经过多层pooling后几乎被抹平检测头根本找不到它。第二是置信度不稳定。同一个目标前一帧对比度高一点检测置信度0.85下一帧正好飘过一片热杂波置信度直接掉到0.2。单帧检测的置信度波动让很多依赖固定阈值的后处理逻辑变得不可靠。第三是背景伪目标干扰。红外图像里经常有类似于“热源边缘”这种高响应区域比如建筑轮廓、设备发热外壳、太阳照射的不均匀地面这些区域在单帧检测中很容易被当成候选框输出而且置信度还不低。这三个问题靠调YOLO本身的超参数解决起来很吃力。因为它们是“单帧信息不足”造成的不是网络结构不好。这时候引入时序信息用一个多帧融合模块来修正单帧结果是更直接的思路。1.3 方案定位YOLO做“逐帧感知”动态规划做“序列决策”这套方案的整体架构可以拆成两个环节逐帧检测YOLO对每一帧红外图像独立输出一组候选框每个候选框包含位置、尺寸和置信度。序列融合取连续N帧的候选框集合把目标跟踪问题建模成“从第1帧到第N帧选一条最优路径”的问题用动态规划求解输出经过修正的检测结果。这两个环节是解耦的。也就是说你可以用任意版本的YOLO做第一步不需要为这套方案去改网络结构也不影响原有模型的迭代。动态规划模块只看检测结果不看原始图像这样它就可以独立于检测器做单元测试和参数调优。我推荐把动态规划模块放在检测器和业务逻辑之间作为一个“时序后处理”层。它的输入是检测框序列输出是筛选好的目标轨迹。这样业务侧拿到的不是一帧一帧孤立的框而是“某个目标从第几帧出现一直走到第几帧”的完整轨迹下游做报警、统计、抓拍都会方便很多。2. 动态规划在这里到底解决什么问题2.1 把多帧检测看成最优路径问题先建立一个直觉。假设你有8帧红外图像每帧检测出来若干个候选框其中有些框是真实的运动目标有些是噪声产生的伪框。现在的问题变成在这8帧的这么多框里选出一条或几条路径让这条路径上的框看起来像同一个目标在连续运动。为什么这能解决漏检因为真实目标的路径有一个重要特征前后帧位置变化符合运动规律而且每一帧的检测置信度即使有波动总体上不会太低。而伪目标不一样它的位置往往是随机跳动的或者只在某一两帧出现无法形成一条连续、平滑的路径。动态规划就是在所有可能的路径中找一条“综合得分”最高的这条路径对应的就是最可信的目标轨迹。这里面有个生活化的类比你去一个大型仓库找一个人单看某一秒的监控画面并不能确定哪个身影是他但把前后10秒的画面串起来那个从A货架匀速走到B货架、中间没有瞬移也没有消失的身影基本就是目标。YOLO相当于“某一秒画面里的身影候选人”动态规划相当于“串起最连续身影”的决策过程。2.2 状态、得分、转移的数学建模把问题形式化。设共有T帧参与融合第t帧有m_t个候选框我们用state (t, i)表示第t帧的第i个候选框。定义三个核心量单帧得分 s(t, i)表示候选框i本身的可信程度。最简单的做法直接取YOLO的置信度也可以叠加红外图像的灰度对比度、目标尺寸先验等做成综合得分。转移得分 g(i→j)表示第t帧的候选框i与第t1帧的候选框j属于同一目标的概率。这个量通常由位置距离、尺寸变化、运动方向一致性共同决定。累计得分 dp[t][j]从第1帧到第t帧最终停在候选框j的最大累计得分。状态转移方程写成dp[t][j] max_i ( dp[t-1][i] g(i→j) ) s(t, j)这个式子的含义很直白要想到达第t帧的框j必须先从前一帧的某个框i走过来这条路的得分等于走到框i的最佳累计得分dp[t-1][i]加上从i到j的转移得分最后再加上框j自身的单帧得分。取所有i中的最大值就是到框j为止的最优路径得分。有了这个递推式从第1帧开始逐帧向后计算每一帧每个框都记录“上一帧的最优前驱是哪个框”到了最后一帧再沿着前驱指针回溯就能得到一整条最优轨迹。这就是动态规划的核心过程也是它和简单的帧间匹配最大的区别它不是在相邻两帧之间做局部决策而是在整个N帧范围内找全局最优的路径组合。2.3 为什么不用卡尔曼滤波或简单的IOU匹配在工程上确实有很多人会用IOU匹配加卡尔曼滤波来做多帧关联比如各种tracking框架。这里想说明一下为什么我在这个案例里选了动态规划而不是它们。卡尔曼滤波的核心是“预测更新”它假设目标运动模型基本已知而且每一帧都有一个确定的测量值来更新状态。但在红外小目标场景里单帧检测经常整帧都没有测量值——目标漏检了。卡尔曼滤波一旦连续几帧没有测量值状态的不确定性就会指数增长轨迹也就断了。你得额外写很多逻辑来处理“消失再出现”的情况工程复杂度一点也不低。简单的IOU匹配的问题更直接它只看相邻两帧的框重叠面积对运动目标来说如果帧率不够高、目标移动较快前后帧的框可能根本没有重叠IOU是0目标直接断链。而且IOU匹配是局部贪心的每一帧都做一次最优匹配但不保证整个序列的路径是最优的。动态规划的优势在于它天然支持“跳过漏检帧”。即使某一帧没有检测到某个目标只要前后帧的位置差异在允许范围内转移得分仍然可以保持一个不算太低的值路径不会断。它找的是“全局最优”的轨迹而不是每一帧局部的“最优匹配”。当然代价是计算量更大后面我会专门讲怎么控制这个复杂度。3. 完整实现从数据准备到结果输出3.1 单帧YOLO检测器怎么适配红外域说完了思想开始聊落地。第一步是把YOLO检测器调成“红外友好”的状态。我用的方案是以骨干网络在COCO上的预训练权重作为初始化然后在红外数据集上做迁移学习。有几个实操要点红外图像是单通道读入后直接复制成三通道会浪费一部分网络表达能力。更好的方式是修改第一层卷积的输入通道数把权重对灰度通道做平均初始化然后单独fine-tune第一层。这个改动对PyTorch来说很简单只需要改模型定义的conv1参数用预训练权重三个通道的均值去初始化这一个通道的卷积核。输入分辨率尽量不低于640×640。红外小目标本来就小分辨率再低检测头就真的什么也看不见了。数据增强里对比度扰动和噪声扰动要加大幅度。红外图像的信噪比变化比可见光大得多训练时多模拟一些低对比度的退化情况模型在真实场景里才不会掉链子。在标注策略上红外图像上的目标轮廓不清晰建议用矩形框做“紧致标注”而不是“贴边标注”。目标是热源区域的核心团块不要框进大片背景不然检测器会被背景热响应带偏。3.2 动态规划轨迹融合的代码骨架这里给出一个可以直接改的Python实现骨架。假设输入是detections它是一个列表每个元素对应一帧元素内容是一个列表每一项是 (cx, cy, w, h, score)。import numpy as np def build_dp_tracks(detections, max_dist50.0, max_hw_ratio2.0, score_bias1.0, lambda_motion0.6, min_track_len3): T len(detections) all_dp [] all_back [] for t in range(T): boxes detections[t] if t 0: all_dp.append([(boxes[i][4], -1) for i in range(len(boxes))]) else: prev_boxes detections[t - 1] dp_t [] back_t [] for j, box in enumerate(boxes): best_val -float(inf) best_prev -1 for i, pbox in enumerate(prev_boxes): dx box[0] - pbox[0] dy box[1] - pbox[1] dist np.hypot(dx, dy) if dist max_dist: continue w_ratio max(box[2], pbox[2]) / max(1e-6, min(box[2], pbox[2])) if w_ratio max_hw_ratio: continue motion_score 1.0 - dist / max_dist trans_score lambda_motion * motion_score total all_dp[t - 1][i] trans_score if total best_val: best_val total best_prev i if best_prev 0: dp_t.append(best_val score_bias * box[4]) else: dp_t.append(score_bias * box[4]) back_t.append(best_prev) all_dp.append(dp_t) all_back.append(back_t) return all_dp, all_back def backtrack_tracks(all_dp, all_back, min_track_len3): T len(all_dp) used set() tracks [] for t in range(T - 1, 0, -1): for j in range(len(all_dp[t])): if (t, j) in used: continue # 回溯一条轨迹 track [] cur_j j cur_t t while cur_t 0 and cur_j 0: track.append((cur_t, cur_j)) used.add((cur_t, cur_j)) prev_j all_back[cur_t][cur_j] cur_j prev_j cur_t - 1 if len(track) min_track_len: tracks.append(track[::-1]) return tracks这段代码的逻辑不复杂。build_dp_tracks函数做了正向递推对每一帧的每个候选框只找前一帧在max_dist范围内且尺寸变化不过大的框作为前驱累计得分最高的成为当前最优。backtrack_tracks函数从最后一帧往前回溯把长度小于min_track_len的短线轨迹过滤掉只保留持续出现的真实目标。这里有两个细节值得注意。第一转移得分只考虑位置距离实际项目中可以加入方向一致性约束——计算目标在前几帧的速度方向如果候选框相对上帧的位置方向和历史速度方向夹角太大转移得分直接减半。第二对每帧候选框数量要做上限控制我习惯保留置信度最高的前40个框参与DP防止状态空间爆炸。3.3 关键参数怎么定参数是这套方案里最容易“差一点就废了”的部分我给一组实测下来比较可靠的起始值并解释为什么。帧缓存T8到10帧。太长目标已经走出搜索区域路径中断太短动态规划的优势发挥不出来漏检帧还没被补上就已经滑出窗口。固定摄像头监控推荐8帧无人机巡航场景推荐6帧。最大距离max_dist建议用“目标在相邻帧间的最大可能位移”来标定。例如帧率25fps、目标移动速度2像素/帧那max_dist取50像素留出余量。这个值设太大会把不相关的伪框连成轨迹设太小快速目标会断链。转移权重lambda_motion0.5到0.7之间。lambda_motion越大路径就越偏向于“位置连续性好”的目标对单帧置信度的依赖降低。但调太高的话一些缓慢移动的热噪声伪目标也会被当成稳定轨迹因为它们的连续性和真实目标一样好只是置信度低。所以这个值不是越大越好我建议用0.6起步再根据漏检和误检的平衡日志微调。最长轨迹中允许的“跳帧”数动态规划的优势就是能跳过漏检帧但跳多了会误连。一般允许连续两帧漏检还能续上再多就要断。这些参数不要试图一次调到位。我的习惯是先固定max_dist和min_track_len只调lambda_motion和置信度阈值找到一个效果区间再把min_track_len从3调到5看误检率的变化。每次只动一个参数否则出了问题你根本不知道是哪个步骤引入的。3.4 输出修正与延迟处理动态规划输出的是一条轨迹但业务系统要的是“这一帧哪里有目标”所以要把轨迹映射回每一帧。具体做法是对每一条轨迹把每一帧对应的候选框单独抽出来作为这一帧修正后的检测结果。如果某个轨迹在某帧里没有候选框漏检跳帧用前后帧的框做线性插值补一个框出来。插值补出来的框置信度要打一个折扣我用的是前后帧置信度的平均值再乘0.8表示“这个框是推测出来的可靠度略低”。延迟是另一个要注意的问题。动态规划需要等整个窗口N帧的检测结果全部到位后才能回溯输出因此处理这一帧会有N-1帧的延迟。对于实时告警应用8帧延迟在25fps下约280ms多数场景能接受。但如果要求低延迟可以把输出策略改成“每来一帧先输出上一帧的检测结果track已经确定的部分”也就是滑窗大小为N、输出滞后1帧实际效果上只会损失极小一部分轨迹信息延迟却大大降低。4. 实际效果一组可复现的对比数据4.1 测试集构建与指标为了验证这套方案到底有多大提升我在一个红外序列测试集上做了对比实验。测试集包含3000帧红外视频场景包括固定摄像头监控、输电线路无人机巡检、夜晚园区安防三类目标以行人、车辆、小型无人机为主。所有帧都做了人工标注标注目标的最小尺寸约12×12像素最大约80×80像素。评价指标用两个召回率Rec和平均每帧误检数FPPI。召回率衡量的是“真实目标有多少被检测出来”误检数衡量的是“有多少假目标被判成真”这两个指标在实际工程里需要一起看只追召回率会把误检率拉到不能用的程度。4.2 单帧 vs 融合的对比结果基线方案是迁移学习后的YOLO单帧检测置信度阈值0.4。融合方案是YOLO单帧检测加动态规划轨迹融合转移权重lambda_motion0.6min_track_len3。结果整理如下指标单帧YOLOYOLO DP融合召回率行人0.620.81召回率小型无人机0.430.67召回率车辆0.710.80FPPI每帧误检数0.350.28目标轨迹中断次数无法统计下降约55%数据说明几件事。召回率提升最明显的是小型无人机从0.43升到0.67这个类别的目标尺寸小、对比度低正是单帧检测最容易漏的情况而动态规划恰好能从时序上把漏检的帧补回来。行人也有明显提升因为行人运动相对缓慢DP路径的连续性更容易被满足。车辆本身的信噪比高单帧已经检测得很好提升幅度是最小的。误检数从0.35降到0.28说明动态规划不只是“补漏检”它还能通过轨迹连续性过滤一部分单帧伪目标。那些在单帧里置信度很高、但只出现一帧就消失的伪框在轨迹回溯时因为长度不足被过滤掉了。代价是误检数不会降得更多原因是有一些持续存在的热源边缘比如长时间不动的设备外壳也会形成稳定伪轨迹这个单靠动态规划很难消除需要额外的场景先验。4.3 运行效率与实时性评估动态规划的计算量主要取决于帧缓存T和每帧候选框数M。最坏情况下每帧要计算所有框到前一帧所有框的转移得分复杂度是O(T × M²)。实际工程里M被限制在40以内转移计算里带有max_dist和尺寸比两个快速筛选条件所以真正要计算完整得分转移的框对数远小于1600。在普通CPU上8帧窗口的DP处理时间小于5毫秒几乎可以忽略。而YOLO本身在GPU上的单帧推理时间是十几毫秒的量级所以整个系统依然是GPU推理占大头动态规划模块不会拖慢整体流程。如果候选框数量很大可以先用距离粗筛把候选前驱数量降下来或者把逐帧匹配过程用空间索引加速。我在一个极端场景里测过一帧同时出现200多个候选框DP耗时升到47毫秒这时候就必须对候选框做置信度截断或者按区域分块处理了。5. 高频踩坑与排查实录5.1 检测框太多导致DP状态爆炸这是第一个遇到的问题。某个场景里背景热源边缘产生大量候选框每帧检测框数经常超过150个DP的计算时间从不到5毫秒直接飙到40毫秒以上实时性崩了。排查思路很简单先看候选框的置信度分布。结果发现大量伪框置信度都在0.25到0.4之间而真实目标的置信度普遍高于0.5。我把参与DP的框做了两层过滤先按单帧置信度排序只保留前40个框再把置信度低于0.15的框直接丢弃。这样M从150降到40以内DP耗时降到3毫秒左右召回率的损失小于1个百分点。如果对召回率要求极高可以把M上限放宽到60同时按空间区域做分块DP。5.2 轨迹错连背景热源边缘带来的伪轨迹动态规划模块运行一段时间后发现一些固定热源边缘的候选框被连成很长的稳定轨迹。比如设备外壳有一条垂直加热带每帧都会产生连续的框虽然每个框的置信度只有0.2到0.35但因为位置完全不变连续性强转移得分反而非常高。这个问题靠调lambda_motion解决不了因为伪轨迹的连续性和真实目标一样好。我加了两个约束后基本消除一是规定轨迹必须包含至少一个置信度高于0.5的候选框让纯低置信度路径直接不合法二是增加目标运动方差检查真实目标即使缓慢移动其位置序列的方差也大于完全静止的伪目标。如果轨迹位置方差低于一个极小值就说明它是静止背景响应需要丢弃。5.3 小目标在深层特征里直接消失YOLO输出层对小目标不敏感的另一个表现是很多尺寸小于16×16的目标检测头输出的置信度长期在0.2以下即使真实存在也很难通过阈值筛选。这个问题发生在YOLO侧DP也救不回来因为输入DP的候选框本身就丢了。我从两个方向处理一是给模型加上P2检测层让网络在原图1/4分辨率输出检测结果小目标的特征在更浅层、更高分辨率的特征图上还保留着二是对原始图像做切片推理把大图切成若干小块分别检测再拼接结果。这两种方法会带来推理时间增加但小目标的召回率提升非常明显。DP融合模块的工作仍然有效只是它的输入质量变好了。5.4 多帧延迟和工程部署的取舍在实时视频流场景里动态规划的N帧缓存会造成输出延迟。实际项目中我遇到过一次客户要求前端看到的目标框必须实时贴上去不接受近300毫秒的延迟。解决办法是分两路输出。一路走“低延迟模式”每来一帧就把上一帧的检测结果直接输出不等待未来帧另一路走“高质量模式”用完整的N帧DP轨迹做统计分析和归档。低延迟模式下目标框会比融合结果少一些但位置准确美观性足够高质量模式下目标框会平滑很多用于事后分析非常合适。这个折中方案客户很满意也让我认识到不是所有场景都需要让DP参与实时输出它更擅长做“离线修正”和“动作级统计”。5.5 参数速查表把文中的参数和适配场景整理成一张表方便直接对照使用。参数推荐值调节方向帧缓存T6~10帧目标运动越快取值越小最大距离max_dist相邻帧最大位移×2留有余量过大则误连转移权重lambda_motion0.5~0.7单帧置信度不稳时调高候选框上限M40~60计算量大时下调最小轨迹长度min_track_len3~5误检高时上调置信度阈值0.15~0.3伪框多时上调插值框置信度折扣0.8插值框可靠度不高最后说一点实际体会。这套方案真正考验人的不是动态规划本身而是怎么跟单帧检测器的缺陷做配合。我调试过程中最有价值的一步是把YOLO的检测结果和DP融合结果可视化对比着看逐帧播放视频找出每一处“DP漏掉但YOLO检测到了”和“DP补回但YOLO没检测到”的情况。每个差异背后都对应一个参数问题或者特征工程问题解决问题比盲目调参有效得多。红外目标检测没有万能配方但“单帧检测器做感知、时序模块做决策”这个思路在大多数低信噪比场景里都能带来稳定提升值得你在自己的项目里试一次。