去年在做一个低帧率视频下的行人追踪项目,输入源只有10FPS左右的网络摄像头画面。一开始我天真地以为,把YOLOv8检测的置信度阈值调低一点、把跟踪器的匹配阈值放宽一点就能搞定,结果ID依旧频繁跳变——一个行人走到画面中间,转个身就变成了“新目标”;再拐个弯,又换了一个ID。翻来覆去排查,问题根本不在检测器精度,而是帧率。帧率一旦降下来,“显示上是连续运动、实际上却是离散采样”这件事,会让基于边界框匹配的追踪器全线崩溃。
后来我把光流法(Optical Flow)引入到追踪管线里,用稀疏Lucas-Kanade光流跟踪目标内部的特征点,在检测帧之间不断传播边界框,再拿传播结果去和下一帧检测框做关联融合。这个组合把IDSW(ID Switch)直接砍掉了近七成,MOTA也涨了一截。这篇文章就把我在这套方案里的完整思路、代码做法和踩坑记录整理出来,给同样被低帧率视频困扰的人一个可以直接参考的落地样板。
1. 低帧率不是“少检测几帧”那么简单:问题模型与失效机制
1.1 帧间隔拉大后,追踪器为什么会“掉链子”
所有基于检测的追踪器(DeepSORT、ByteTrack、BoT-SORT等都算)核心都依赖一个东西:把当前帧的检测框和上一帧的轨迹预测框做匹配,最常用的相似度度量就是IoU。IoU对帧率极其敏感。
假设一个目标的边界框尺寸是100×100像素,目标在图像平面上以每秒300像素的速度运动。当帧率是30FPS时,相邻帧位移只有10像素,两帧IoU大概有0.82,匹配毫无压力。但帧率降到10FPS,位移变成30像素,IoU掉到0.55左右;如果帧率只有5FPS,位移直接到60像素,IoU就只剩下0.25上下。IoU低于0.3时,无论ByteTrack还是DeepSORT,关联分数都会跌破阈值,追踪器就认为这个框和原有轨迹对不上,于是新建一个ID。
除了IoU,运动模型也在低帧率下失效。DeepSORT和BoT-SORT内部都带卡尔曼滤波,用匀速或匀加速模型外推下一帧位置。高帧率下相邻帧间隔极短,目标在这几十毫秒里的运动近似直线,线性外推很准;但低帧率意味着相隔100毫秒甚至200毫秒,目标完全可能在期间完成一次转身、急停或者变向,线性模型预测出来的位置和真实位置相差很远。预测框不准,匹配自然不稳。
1.2 检测器在低帧率下的隐性退化
很多人以为降低帧率只是追踪器吃亏,检测器每一帧还在干活,应该没问题。实际不是这样。
低帧率视频往往伴随着运动模糊加剧。同一个摄像头,曝光时间不变,帧率降低后目标在单帧内移动的像素距离更大,拖影更明显,YOLOv8对模糊目标的特征提取能力会明显下降。还有一个容易被忽略的点:低帧率视频在采集时常常伴随抽帧,也就是把高帧率视频中的若干帧直接丢弃。这个操作会让目标的关键姿态变化被“吞掉”,比如人弯腰捡东西的动作,在高帧率下能拍到完整过程,抽帧后只留下“站着”和“蹲着”两帧,中间没有过渡。检测器检测出来框的形状会突变,追踪算法拿一个突然矮一半的框和一个突然高回去的框做匹配,很容易判定它们不是同一个目标。
1.3 为什么单靠YOLOv8不够:需要一个“运动先验”
YOLOv8本质上是观测器,它回答的是“这一帧画面里目标在哪”,它不回答“目标从上一帧运动到这一帧经历了什么路径”。追踪器要做的恰恰是把这种瞬时观测连接成连续轨迹。在高帧率下,帧与帧之间目标运动很小,你甚至可以偷懒用“上帧检测框直接当作下帧预测框”。但在低帧率下,观测之间出现了巨大的“真空期”,你必须填补这个运动估计。
这时候有两个选项:一是依赖卡尔曼滤波等参数化运动模型,二是用光流法做像素级运动估计。前者参数少、快,但模型过于简单;后者能直接测量目标每个特征点的实际位移,提供更丰富的运动先验。这也是我最终选光流法辅助追踪的根本原因——我们需要的是一个能插在相邻检测时刻中间的、足够可靠的“运动插值器”。
2. 光流法这一刀切在哪里才不伤手:原理、假设与选型
2.1 光流的两个核心假设,以及低帧率下怎么补救
光流法估计的是图像中每个像素点在两帧之间的运动向量。几乎所有经典光流算法都建立在两个假设上:亮度恒定(目标像素点在运动中亮度不变)和短时运动足够小(两帧之间点的位移不超过一定范围)。
低帧率直接打脸第二个假设。位移大了,局部窗口内的像素匹配就失效。补救的手段也很直接:用图像金字塔。OpenCV里的calcOpticalFlowPyrLK就是金字塔LK光流实现,先在低分辨率层估计大位移,再逐层细化到原分辨率。相当于先用“缩小的图”看全局粗运动,再用原图抠细节。对低帧率场景,金字塔层数直接决定你能补偿多大的位移,这个参数后面详谈。
2.2 稀疏光流与稠密光流:选型逻辑
光流按计算密度分成两类:稀疏光流(Lucas-Kanade法为代表)只跟踪被选中的特征点,稠密光流(Farneback、RAFT等)对每个像素都计算运动向量。对目标追踪这个任务,我强烈推荐稀疏光流。
原因有三个。第一,追踪的主体是目标内部有区分度的角点/纹理点,不需要背景每个像素都算。第二,稀疏光流计算量低很多,一个目标跟踪几十个点,在CPU上都能跑实时,而稠密光流在嵌入式设备上常常要卡几帧。第三,稀疏光流的结果容易做鲁棒处理。你可以分别跟踪多个特征点,然后取位移中位数,抵消个别点跟丢或跟错的影响。
Farneback稠密光流也不是没用,它适合需要整个运动场的场景(比如背景全局运动估计),但纯粹为目标追踪服务的话,稀疏LK更划算。两个算法的比较可以参考下面这张表:
| 维度 | 稀疏Lucas-Kanade | 稠密Farneback |
|---|---|---|
| 计算开销 | 低,只算特征点 | 高,全图像素都要算 |
| 适用对象 | 单个/多个目标的特征点跟随 | 全局运动场分析、背景补偿 |
| 对位移大的容忍度 | 依赖金字塔层数,可调高 | 一般,过大位移容易糊 |
| 鲁棒后处理 | 方便,逐点结果可做统计 | 不直观,需要后续滤波 |
| 在追踪管线里的角色 | 目标框传播 | 相机运动补偿 |
2.3 LK光流关键参数怎么定
calcOpticalFlowPyrLK的参数不多,但每个都直接影响低帧率下的表现。我按经验值给一个起点,你先跑一轮再看效果。
winSize:搜索窗口大小。窗口太小,大位移跟不住;窗口太大,匹配范围过宽容易找错点。常见取值21×21到31×31之间。如果目标本身很小(比如远处行人只有30×50像素),窗口可以缩到11×11或15×15。maxLevel:金字塔层数。这就是应对低帧率大位移的核心参数。10FPS视频目标位移30~50像素时,maxLevel=2-3比较合适;5FPS甚至更低,建议maxLevel=3-4。criteria:迭代终止条件,通常设为(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_COUNT, 30, 0.01)。迭代次数太少,位移收敛不好;太多浪费时间,30次足够了。minEigThreshold:最小特征值阈值。低于这个阈值的点会被认为是“弱纹理点”而丢弃,避免跟丢。默认1e-4偏低,在低纹理区域内希望采更多点时,可以降到1e-5甚至更小。
一句话总结:低帧率场景的调参优先级是“金字塔层数优先,窗口其次”,位移越大越要增加金字塔层数,而不是盲目放大窗口。
3. 完整实施:用光流给YOLOv8追踪“续命”的双路管线
3.1 总体设计
这套方案的核心思路是:不要让YOLOv8每帧都做检测。检测帧之间,用光流作为一个轻量级的“运动插值器”,把上一帧的边界框传播到当前帧。等到下一个检测帧来了,再用检测框对传播框做关联和融合。
这样做有两个好处。一是低帧率视频的检测质量本来就不稳定,与其强制每帧都依赖检测框,不如在检测帧之间插入一个更连续的估计;二是光流传播的代价远低于一次YOLO推理,算力紧张时你可以把检测频率进一步降低,用光流“扛住”中间帧。
整个管线的流程描述如下:
- 第0帧做YOLO检测,初始化轨迹,并在每个目标框内采样特征点。
- 进入中间帧,不做检测,只用金字塔LK光流跟踪特征点,根据特征点位移计算目标框的新位置。
- 到达下一个检测帧,YOLO检测出新框,同时用光流传播得到预测框。两者做关联匹配。
- 关联成功的目标,用检测框校准光流点,继续传播;关联失败的目标按遮挡/丢失状态处理。
3.2 边界框传播的具体实现
光流传框的逻辑说起来简单,做起来有几个细节。先看核心代码骨架。
import cv2 import numpy as np def propagate_bbox_by_flow(prev_gray, cur_gray, bbox, points): """ 用稀疏光流传播目标框。 bbox: [x1, y1, x2, y2] points: 目标框内的特征点,Nx2 返回新的bbox,以及有效光流点 """ if points is None or len(points) < 2: return bbox, None # 上一帧特征点要求是float32 prev_pts = points.astype(np.float32).reshape(-1, 1, 2) cur_pts, status, _ = cv2.calcOpticalFlowPyrLK( prev_gray, cur_gray, prev_pts, None, winSize=(21, 21), maxLevel=3, criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01) ) cur_pts = cur_pts.reshape(-1, 2) status = status.reshape(-1) good_prev = points[status == 1] good_cur = cur_pts[status == 1] if len(good_cur) < 2: return bbox, None # 中位位移,抗异常点 dx = np.median(good_cur[:, 0] - good_prev[:, 0]) dy = np.median(good_cur[:, 1] - good_prev[:, 1]) new_bbox = [ bbox[0] + dx, bbox[1] + dy, bbox[2] + dx, bbox[3] + dy, ] # 尺度变化估计:用点对间距离比的中位数 if len(good_cur) >= 4: dist_prev = np.linalg.norm(good_prev[:, None, :] - good_prev[None, :, :], axis=2) dist_cur = np.linalg.norm(good_cur[:, None, :] - good_cur[None, :, :], axis=2) mask = dist_prev > 5 ratio = (dist_cur[mask] / (dist_prev[mask] + 1e-6)) ratio = ratio[np.isfinite(ratio)] if len(ratio) > 0: scale = np.median(ratio) scale = np.clip(scale, 0.7, 1.3) w = new_bbox[2] - new_bbox[0] h = new_bbox[3] - new_bbox[1] cx = (new_bbox[0] + new_bbox[2]) / 2 cy = (new_bbox[1] + new_bbox[3]) / 2 new_bbox = [ cx - scale * w / 2, cy - scale * h / 2, cx + scale * w / 2, cy + scale * h / 2, ] return new_bbox, good_cur几个值得展开的细节:
为什么用中位数而不是均值。光流点里偶尔会混入背景点或者匹配错误的大偏移点,均值会被这些离群值拉偏。中位数对离群值的鲁棒性高一个量级。在目标内部移动不统一时(比如人的手臂摆动和躯干运动方向不一致),中位数也能反映主体的整体位移。
尺度估计要加clip限制。单纯用特征点距离比中位数估计目标缩放很容易被少数点对干扰,一次跳跃可能从0.8跳到1.5。把尺度限制在0.7到1.3这个范围内,能避免框突然放大缩小。对行人追踪,实际帧间尺度变化不会那么大。
3.3 检测帧的关联与融合策略
每次检测帧来临时,光流传播框和YOLO检测框需要做关联。我这里采用的策略和ByteTrack类似,但多了一个候选框来源:光流传播框。
具体做法是:把传播框当作一个“低质量检测框”参与关联。先对高置信度检测框做IoU匹配,再把未匹配的传播框和低置信度检测框做第二轮匹配,最后剩下的未匹配轨迹进入挂起状态。
代码层面的关联部分简化如下:
def associate(tracks_propagated, detections, sigma_iou=0.4): # 用IoU算代价矩阵,匈牙利匹配 # 第一轮:高置信度检测框 + 传播框 matches, unmatched_tracks, unmatched_dets = \ iou_associate(tracks_propagated, detections, threshold=sigma_iou) # 传播框和检测框匹配上之后,融合位置: # 权重建议检测框更大,因为光流传播帧数越多,累积漂移越严重 for track_id, idx, det_id in matches: fused_bbox = detections[det_id] * 0.7 + tracks_propagated[track_id].bbox * 0.3 tracks_propagated[track_id].bbox = fused_bbox # 用检测框中心附近的特征点重新采样光流点 tracks_propagated[track_id].resample_points()为什么融合权重是检测框更大?因为光流传播是一个递推过程,每传播一帧,误差都有累积的可能性。检测框虽然在低帧率下也会抖动,但它是独立的观测,相当于一次“绝对定位”。融合的意义是让轨迹位置既不要太跳(检测框噪声),也不要累积漂移(光流传播),所以检测框权重高一些。
3.4 目标被遮挡或被截断时的回退逻辑
低帧率视频里,目标遮挡会变得非常棘手。遮挡发生得快,恢复也快,追踪器经常反应不过来。光流在这里能帮上忙,是因为遮挡前跟踪到的特征点,在遮挡瞬间会有两种表现:点跟丢了,或者点跳到遮挡物上。
我在管线里专门设了一个置信度状态机。当目标框内光流幸存点比例低于30%时,把轨迹状态置为lost。lost状态下,不再用传播框参与关联,而是继续用光流点跟踪一小段时间(比如5帧),如果后续检测帧里又出现能匹配上传播框的高置信度检测框,就把这个轨迹“复活”。
这里有一个容易做错的地方:目标被完全遮挡时,特征点跟丢速度和遮挡物有关。如果遮挡物是墙体,点会直接消失;如果遮挡物是另一个行人,点可能跳到那人身上,位移中位数可能会突变。所以我额外加了一个位移合理性判断,一旦当前帧点位移中位数超过上一帧位移中位数的3倍以上,就判定为“疑似点跳变”,立刻停止用这些点做框传播,转为等待检测结果。
下面这张表是我实际用的状态处置策略:
| 情况 | 判断条件 | 处置方式 |
|---|---|---|
| 正常跟踪 | 光流内点率高,位移连续 | 传播框 + 检测框融合 |
| 部分遮挡 | 内点率30%-70%,位移偏大 | 降低融合权重,以检测框为主 |
| 严重遮挡 | 内点率低于30% | 轨迹挂起,保留光流点继续试探5帧 |
| 点跳变 | 点位位移中位数骤升3倍以上 | 直接丢弃光流结果,等待检测框 |
| 目标出画面 | 传播框越界或面积小于阈值 | 终结轨迹并清理特征点 |
这套状态机让整个追踪器在真实摄像头数据里稳了很多,尤其是行人短暂被立柱挡住、两人交错的场景。
4. 实测对比:低帧率场景下追踪效果的前后差异
4.1 实验设置
验证这套方案时,我用了一个模拟项目X的低帧率视频集,故意把原始30FPS视频抽帧到10FPS和5FPS,模拟真实低帧率链路。检测器用YOLOv8n,输入分辨率640,检测间隔D分别设为1(即每帧都检测)、2和3。D=1相当于不使用光流传帧,只做低帧率下的纯检测追踪,作为对照基线;D=2表示每隔一帧检测一次,中间帧全部靠光流传播;D=3是把检测频率压到最低,中间连续两帧靠光流撑住。
需要说明的是,D=2、D=3模式中,追踪器的输出帧率仍然是视频帧率,只是检测器本身偷懒了。这也是这套方案在生产环境里真正的价值:追踪输出不降帧率,但YOLO推理次数大幅减少。
4.2 量化指标的改善
在测试集上,我对比了D=1(纯检测追踪)和D=2/D=3(光流辅助)的表现。为了公平,D=1模式里追踪器的关联阈值经过专门的调优,已经在10FPS下尽可能减少了IDSW;D=2/D=3则使用本文上面描述的光流辅助管线。
| 场景 | 模式 | MOTA | IDF1 | IDSW | YOLO推理次数 |
|---|---|---|---|---|---|
| 10FPS | D=1 纯检测追踪 | 62.1 | 55.4 | 86 | 100% |
| 10FPS | D=2 光流辅助 | 68.8 | 63.9 | 29 | 50% |
| 10FPS | D=3 光流辅助 | 66.2 | 61.0 | 38 | 33% |
| 5FPS | D=1 纯检测追踪 | 43.6 | 38.2 | 142 | 100% |
| 5FPS | D=3 光流辅助 | 54.7 | 51.3 | 66 | 33% |
最直观的结论有两条。第一,光流辅助在10FPS下把IDSW从86降到29,IDF1涨了8.5个百分点,D=2模式甚至比每帧检测还多用一半算力。第二,D=3在5FPS场景下表现依然不错,IDSW只略高于10FPS的D=1基线,但YOLO推理次数只有三成。这说明光流传播确实充当了一个不错的“轻量级追踪主力”,检测器只是用来定期校准。
4.3 直观案例:之前ID跳变、现在线框稳定
数字之外,实际视频效果更能说明问题。
在10FPS抽帧样本里,有两个行人在画面中间错身而过。D=1基线模式下,两个ID在交错后完全互换了一次,后面重新分配ID,跟踪线框在目标身上跳来跳去。换成光流辅助的D=2模式后,交错过程里两个目标的特征点依然各自跟随目标主体,传播框在交叉瞬间会稍微贴近但不会合并,检测帧到来后再把位置拉回到真实框上。肉眼上看,线框抖动变小,ID一直稳定到目标消失。
这就是我一直强调的点:低帧率追踪最大的痛点不是“框不准”,而是“ID不稳定”。在监控场景里,ID一旦跳变,下游的轨迹分析、人流统计、绊线检测全部乱套。
5. 工程落地中容易翻车的细节清单(基于项目里的踩坑记录)
5.1 光流点管理:什么时候重采样
光流点不是初始化一次就万事大吉的。目标变形、自我遮挡、光照变化,都会让最初采样的点逐渐丢失。所以每次检测帧校准完成后,要重新在融合后的框内采样特征点。但不是无脑全部重采,那样会丢掉光流连续性的优势。我的做法是:保留上一帧跟踪成功的点,然后如果当前特征点数量少于目标框面积对应点数的30%时,再补充采样。
另外特征点数量要控制。目标框过大时,goodFeaturesToTrack会返回几百个点,浪费算力;目标框很小时,点太少又不稳定。我一般限制每个目标最多120个点,最少5个点。小于5个就放弃光流传播,直接用上一帧位置。
5.2 相机自身运动带来的干扰
很多低帧率视频来自固定摄像头,但固定不代表完全静止,风、振动、云台微调都会让背景运动。如果光流点里混入了背景点,位移中位数会被背景带动,目标框就会跟着背景“漂移”。
处理这个问题有两个思路。一个是采样点的时候尽量选目标框内部远离边缘的点,减少边缘背景点的混入;另一个是做强背景运动估计,用视频角落处的背景点拟合一个全局运动模型,然后把目标光流位移减去全局分量。前者简单,后者更可靠但复杂。我的经验是:摄像头非常固定的场景,第一种足够;摄像头有轻微抖动的场景,建议至少先看看光流矢量场的整体直方图,再用全局位移修正。
5.3 算力预算与检测间隔的动态调节
固定D值虽然简单,但不够聪明。目标运动慢时可以把检测间隔拉大,目标运动快时需要让检测器更勤快一点。
我最后在项目里用的是一种简单自适应策略:每帧统计光流位移中位数,当连续3帧位移中位数都小于5像素时,把D上限从2调到3;当位移中位数大于25像素时,强制把下一帧设为检测帧。这样做的理论依据是:位移小时,传播误差累积慢,可以省检测;位移大时,单个光流传播误差相对变大,需要尽快用检测框校准。
光流的单帧成本其实很低。用YOLOv8n在640分辨率下推理一次大概30-50ms,而一次稀疏LK传播追踪50个目标、每个目标几十个点,在i5级别的CPU上也只要5-8ms。也就是说,检测间隔每拉大一倍,就相当于省了一半以上的推理时间,光流自身的开销几乎可以忽略。
5.4 参数调试经验总结
最后聊点调试心得。
第一,低帧率追踪如果表现不好,先别怀疑网络,先看光流点颜色对不对。把光流点的轨迹可视化出来,如果点的运动方向和实际画面里目标的运动方向明显不一致,优先调整maxLevel和winSize,这两个参数几乎决定一切。
第二,调minEigThreshold时不要贪多。低阈值能采到更多点,但也会采进很多纹理不明确的点,跟丢率反而更高。目标纹理丰富时用默认值就好,目标本身纹理弱(纯色衣服、白车)再考虑降低。
第三,检测帧融合时不要只信检测框。低帧率下检测框本身也会抖动,你如果用官方默认的conf=0.25,经常会看到框忽大忽小。我建议检测框在参与融合前先做一次轻量级平滑,比如对宽高做指数移动平均。
第四,多目标场景下,每个目标的光流计算应该独立做。有人图省事把全图的点扔给calcOpticalFlowPyrLK一次性算,结果不同目标之间的点会互相干扰,尤其在两个目标靠近时,点会跳来跳去。按目标分别调用函数,虽然多几毫秒,但稳定性好得多。
根据我个人经验,这套检测+稀疏光流双路追踪方案,不是要替代纯检测追踪,而是给低帧率约束下的追踪问题提供一条很实用的逃生通道。它最大的优点是把“追踪连续性”和“检测成本”解耦:你用更低频率的检测,换来基本不降级的追踪质量,这在边缘设备、带宽受限的摄像头链路上尤其值得一试。最近我还想顺着这个方向看看能不能把光流传播置信度直接喂给下游融合模块,让业务层知道当前轨迹的“可信度”,而不是只知道一个框的坐标。如果有人也在做类似方向,欢迎一起交流。