☰
C++实现SORT多目标跟踪:基于卡尔曼滤波与匈牙利匹配的实时轨迹关联
2026/10/22 9:44:13 网站建设 项目流程

简介:基于C++的SORT多目标跟踪算法实现,面向计算机视觉与目标跟踪方向的开发者、毕设学生或工程实践者。该资源将SORT算法封装成独立类,可无缝衔接在检测器后实时运行,尤其适合在YOLO等检测结果上添加跟踪模块,适用于课程设计、毕业设计或初期项目搭建。

压缩包共21个文件,以12个txt配置与说明文件、4个cpp源码文件、3个h头文件为主,另含1个gif演示动画和1个md说明文档,整体约49.91MB,文件组织清晰、便于按需调用。已有114人学习浏览。

资源内含完整的C++工程文件,涵盖匈牙利匹配算法、卡尔曼滤波跟踪器、Sort核心类及main演示程序,并附带PETS09-S2L1、ETH-Bahnhof、KITTI-13等多个公开数据集,可以直接运行对比不同场景下的跟踪效果。gif演示动画能直观展示算法输出,README与多份txt文件则说明编译方式、数据配置与调用流程,方便在自有检测器上快速集成和二次开发。

1. 先说清楚这个标题到底在做什么

检测器只负责回答“这一帧里哪里有目标”,它不关心上一个瞬间那个框是不是同一个目标。实时多目标跟踪要做的,就是把检测器给的一堆漂移框,按照时间和空间上的连续性串成一条条带ID的轨迹。基于C++版本的SORT算法,就是基于检测结果做在线跟踪的最轻量方案——不训练、不提取外观特征,只靠卡尔曼滤波做运动预测,加匈牙利匹配做框的关联,就能在CPU上跑出远高于检测器本身的帧率。如果你手里的检测器已经稳定出框,却苦于拿不到稳定的目标ID,本文就是给你看的。这篇文章会按“为什么要选SORT、怎么设计接口、核心代码怎么写、参数怎么调、踩了哪些坑”一路讲到底。

2. 为什么是SORT而不是DeepSORT:从算法选型到C++落地的理由

2.1 SORT的“online实时”特性决定了它适合接在检测器后面

多目标跟踪有两个大方向,一种是offline的,需要拿到整段视频才能做全局优化,另一种是online的,只依赖当前帧和历史轨迹。SORT属于后者,它的全称是Simple Online and Realtime Tracking,三个词把特征都写在名字里了。它不需要未来帧的信息,每一帧的处理是一个纯粹的“预测—匹配—更新”三步走,这个结构决定了它天然适合作为检测器的后置模块。

与之相对的是DeepSORT和各类外观重识别方案。DeepSORT在SORT基础上加了一个ReID网络提取外观特征,用来在目标遮挡、交叉时减少ID切换。听起来更聪明,但代价是引入一个额外的深度模型推理,CPU上的耗时和内存占用都会明显上涨。如果你的场景是单类目标、运动轨迹相对简单,比如车辆跟踪、行人跟踪,SORT已经能覆盖掉大部分需求。

我一般会这么判断:硬件资源紧张、检测器已经占了大部分算力、目标没有剧烈的外观变化时,直接用SORT;目标密集且频繁交叉遮挡时,才考虑换特征重识别这一套。用C++写SORT还有一个现实原因——很多检测器部署都是用C++推理框架完成的,后处理出来的检测结果本身就是C++对象,跟踪模块用同一语言接入的成本最低,不用做进程间通信,也不用序列化传输。

2.2 C++实现的三个硬理由:延迟、内存、生命周期

可能有人会问,Python里调一个开源的SORT实现,再通过pybind11接到C++检测器上,不是也能跑?这种方案在原型验证阶段没什么问题,但到了量产落地,三个问题会接连冒出来。

第一个是延迟。Python的GIL全局锁会拖累多线程调度,而且跟踪模块每帧都要和Python运行时交换数据,即便单帧跟踪本身的逻辑只有零点几毫秒,加上类型转换和引用计数开销,也会平白多出几毫秒。在实时视频流里,这几毫秒可能直接导致丢帧。

第二个是内存。Python对象的内存布局和C++不一致,检测框列表每帧都要重新构造列表对象、解析Numpy数组,时间一长内存碎片化问题就会出现。C++的std::vector和固定尺寸数组可以预分配,长时间跑下来内存曲线是平的。

第三个是生命周期。检测器和跟踪模块在Python绑定层里,对象的生命周期管理要么绕、要么脆,稍不留神就是悬垂引用。C++里tracker的生命周期跟着检测器走,构造和析构时机完全可控,服务重启、模型热切换时都不容易翻车。

从工程角度看,C++版本最大的价值不是“性能碾压Python”,而是和检测器的部署形态融为一体。你不需要再维护一套独立于主工程之外的小进程,也不需要面对版本升级时绑定层失效的手足无措。

2.3 模块间的数据契约:Detection结构体与Track输出

把SORT接在检测器后面,“无缝”两个字的关键在于数据契约。检测器输出的信息通常包括目标框坐标、置信度,有时候还有类别。跟踪模块需要消费的正好是前两个。这里最稳妥的做法是单独定义一个轻量结构体,不依赖检测器内部的数据类型,这样换检测器时跟踪模块一行都不用改。

下面这组结构体我每次做新项目都会优先搬进去:

// 检测器输出,喂给跟踪器 struct Detection { float x, y, w, h; // 框的左上角坐标和宽高,单位与图像像素一致 float score; // 检测置信度,范围 [0, 1] int class_id; // 类别编号,用来区分不同目标类别 }; // 跟踪器输出,消费方直接使用 struct Track { int id; // 轨迹ID,全局唯一 float x, y, w, h; // 当前帧预测框位置 float score; // 关联检测框的置信度 int lost_count; // 连续未命中的帧数,用于判断轨迹是否要删除 };

这里有一个容易忽略的设计点:class_id要保留而不是丢弃。如果场景里同时有行人和车辆,SORT不能把一辆车和一个人关联到同一条轨迹上。常见做法是跟踪模块内部按类别分别维护各自的轨迹列表和卡尔曼滤波器,匹配时只对同类别的检测和轨迹做关联。

输出Track里保留score也很有用。关联成功时可以直接继承当前检测的置信度,这样下游的计数或报警逻辑不需要再做一次框类别的归档。lost_count则是SORT里面管理轨迹生死的重要字段,它记录目标连续多少帧没有被匹配上,超过阈值轨迹就会被回收。

3. 把SORT做成一个可复用的C++模块:接口设计与核心数据结构

3.1 Tracker类的对外接口:FeedFrame是唯一入口

设计一个能被不同检测器反复套用的模块,接口要薄,内部状态要厚。我常用的对外接口只有三个方法:构造、喂一帧检测结果、读取当前轨迹。所有中间状态,包括卡尔曼滤波器、轨迹列表、匹配代价矩阵,都藏在类内部,调用方完全不需要知道细节。

class SortTracker { public: explicit SortTracker(float iou_threshold = 0.3, int max_lost = 3, float score_threshold = 0.5); void feedFrame(const std::vector<Detection>& detections, float timestamp); const std::vector<Track>& getTracks() const; void reset(); };

逻辑说明:feedFrame接收检测器当前帧输出的全部检测框,内部按类别分组后,依次执行预测、匹配、更新、创建和删除轨迹,最后把当前帧的有效轨迹存到内部成员变量里。getTracks返回上一帧更新完成后的轨迹,时刻保持只读语义。reset用于清空所有内部状态,比如视频源切换或场景发生变化时调用。

参数说明:iou_threshold是关联匹配的IOU门槛,低于这个值的轨迹和检测框不会配对;max_lost控制轨迹在多长时间内可以容忍漏检;score_threshold用于过滤掉置信度太低的检测框。这三个值在跟踪效果调优时最常动,具体怎么选后面有一章专门讲。

跨类别冲突是实际项目里最先遇到的设计问题。如果不分组,两个不同类别的目标挨得很近,IOU匹配时可能发生交叉配对,之后两个ID就缠在一起了。在feedFrame内部按class_id分桶是最简单的解法。

3.2 卡尔曼滤波的状态设计与预测更新:从公式到Eigen代码

SORT里的卡尔曼滤波是标准的线性恒速模型。每个目标的状态向量是八维的:[x, y, s, r, x_dot, y_dot, s_dot],其中x和y是框中心点坐标,s是框面积,r是宽高比,后三项是对应的速度分量。这里有一个行业约定,r即宽高比视为恒定值,状态转移时不更新它,这是基于行人或车辆在短时间帧内姿态变化不大的假设。

用Eigen来写矩阵运算比较省心。Eigen是C++里常用的线性代数库,矩阵运算的代码表达和公式几乎一一对应。

#include <Eigen/Dense> class KalmanFilter { public: KalmanFilter() { // 状态转移矩阵 F:线性恒速模型,dt 固定为 1 帧 F_.setIdentity(); F_(0, 4) = 1; F_(1, 5) = 1; F_(2, 6) = 1; // 观测矩阵 H:能直接观测到 x, y, s, r 四个量 H_.setZero(); H_(0, 0) = 1; H_(1, 1) = 1; H_(2, 2) = 1; H_(3, 3) = 1; // 过程噪声协方差 Q,表示状态转移模型本身的不确定性 Q_.setIdentity(); Q_(0, 0) = 0.01; Q_(1, 1) = 0.01; Q_(2, 2) = 0.01; Q_(3, 3) = 0.01; Q_(4, 4) = 0.01; Q_(5, 5) = 0.01; Q_(6, 6) = 0.01; // 测量噪声协方差 R,表示检测框本身的噪声水平 R_.setIdentity(); R_(0, 0) = 0.1; R_(1, 1) = 0.1; R_(2, 2) = 0.1; R_(3, 3) = 0.1; } void predict() { x_ = F_ * x_; P_ = F_ * P_ * F_.transpose() + Q_; } void update(const Eigen::Vector4f& measurement) { Eigen::Matrix<float, 4, 8> K = P_ * H_.transpose() * (H_ * P_ * H_.transpose() + R_).inverse(); x_ = x_ + K * (measurement - H_ * x_); P_ = P_ - K * H_ * P_; } // 状态向量 x_ 和协方差 P_ 作为成员变量,8 维状态、8x8 协方差 private: Eigen::Matrix<float, 8, 8> F_; Eigen::Matrix<float, 4, 8> H_; Eigen::Matrix<float, 8, 8> Q_; Eigen::Matrix<float, 4, 4> R_; Eigen::Matrix<float, 8, 1> x_; Eigen::Matrix<float, 8, 8> P_; };

逻辑说明:predict在每一帧开始时对已有的轨迹外推位置,update在匹配到检测框后用测量值矫正预测结果。注意这里dt固定为1帧,如果检测器有抽帧策略,dt要按实际帧间隔动态调整,否则预测位置会系统性偏差。

参数说明:Q_的四组对角值(位置、面积、宽高比、速度)代表“模型有多不靠谱”,R_的对角值代表“检测框有多不靠谱”。Q太小会让跟踪显得迟钝,环境一变就拉不回来;Q太大会让预测框乱飘,帧间抖动明显。实际调参也常是一手调Q一手调R,后面单独展开细说。

创建新轨迹时,x_的前四维直接用检测框值初始化,后四维速度分量初始化为0,P_初始化时对角线取较大值比如10,向滤波器表达“初始速度不确定”。目标轨迹丢失达到阈值后,直接销毁滤波器对象即可,不需要额外处理。

3.3 匈牙利匹配与IOU代价矩阵:关联这一步怎么写

SORT的匹配核心是构建一个代价矩阵,然后调用匈牙利算法求全局最优配对。代价用的是轨迹预测框和检测框的交并比(IOU)。SORT的匹配逻辑比IOU本身更好用的地方在于:它是一个全局优化过程,不是简单的贪心匹配,目标密集时不会出现一个框被重复占位的问题。

#include <vector> #include <algorithm> // 计算两个框的 IOU,输入为标准 xywh 格式,左上角坐标 + 宽高 float computeIOU(const float* box1, const float* box2) { float x1 = std::max(box1[0], box2[0]); float y1 = std::max(box1[1], box2[1]); float x2 = std::min(box1[0] + box1[2], box2[0] + box2[2]); float y2 = std::min(box1[1] + box1[3], box2[1] + box2[3]); float inter = std::max(0.0f, x2 - x1) * std::max(0.0f, y2 - y1); float area1 = box1[2] * box1[3]; float area2 = box2[2] * box2[3]; float uni = area1 + area2 - inter; return uni > 0 ? inter / uni : 0.0f; } // 构建代价矩阵并调用匈牙利匹配(核心入口) void associateDetectionsToTracks( const std::vector<Detection>& detections, const std::vector<Track>& tracks, float iou_threshold, // 低于该 IOU 的配对视为无效 std::vector<int>& matches, // 输出:匹配上的检测框下标 std::vector<int>& unmatched_dets, // 输出:未匹配检测 std::vector<int>& unmatched_trks) { // 输出:未匹配轨迹 // 代价矩阵的行对应轨迹,列对应检测,用 IOU 取倒数作为代价 // 未超过阈值的配对,直接标记为不可匹配 // 匈牙利算法求出最小代价匹配 }

逻辑说明:matches返回检测框和轨迹的配对关系,unmatched_dets用于创建新轨迹,unmatched_trks用于累计漏检帧数。IOU取值在0到1之间,作为增益要最大化;匈牙利算法习惯上处理最小化问题,所以常见的做法是取1减去IOU,或者是直接取负IOU做最小化。

matches里存储的是下标索引,不是拷贝的框坐标,匹配完成后需要按照索引去读取检测框来更新卡尔曼滤波器。这样设计是避免检测框数据在匹配和更新之间被二次复制,长时间跑大分辨率视频时可以省下不少内存带宽。匈牙利算法本身不复杂,如果不想自己实现,可以使用合适的开源实现,但要注意代价矩阵维度变化时的边界处理。

这里的匹配顺序还有一个业界通用的小技巧:先匹配已经存在的轨迹,后处理新检测,匹配完之后把unmatched_dets全部初始化为新轨迹,unmatched_trks的lost_count加1,超过max_lost就回收。

4. 参数调整与工程化:影响跟踪质量的关键旋钮

4.1 IOU匹配阈值与置信度阈值怎么配合

iou_threshold设得太大,目标稍有遮挡轨迹就断,ID频繁切换;设得太小,两个距离很近的目标容易发生错误关联。经验值从0.3起步,然后根据画面中目标的密集程度微调也算是个常见做法。

score_threshold要跟着检测器走。有些检测器训练时把置信度阈值压得很低,输出大量低分框;有些则默认只吐高分框。跟踪模块里的score_threshold是在检测器输出基础上做的二次过滤,目的是把“脏数据”挡在卡尔曼滤波外面。如果发现轨迹抖动频繁,先别急着怀疑卡尔曼参数,看看这个阈值是不是太低了。

这两个值要配合起来看:目标短暂被遮挡时,检测框分数往往也骤降,如果score_threshold设得太高,遮挡期间的检测直接被丢弃,lost_count一路涨到阈值,轨迹就没了。保守的做法是score_threshold比检测器推荐值低0.1到0.2,让遮挡期间的低质量检测仍然有机会维持匹配。

4.2 卡尔曼过程噪声与测量噪声的调参思路

Q和R的比值直接决定滤波器相信预测还是相信测量。我的习惯是先把R固定下来,用一小段人工标注的轨迹数据去衡量检测框的抖动幅度,然后主要调Q。如果跟踪框在高速运动时跟不上目标,通常是Q的位移分量设得太小;如果跟踪框在目标静止时还飘来飘去,通常是Q的位移分量设得太大。

一个实用的验证方法是做“预测框可视化”。把卡尔曼预测的框和检测器的框同时画到视频上,观察它们的位置偏差。偏差在1到2个像素之间是正常的,偏差到5个像素以上就要检查坐标系是否统一或Q是不是失配。

Q和R的取值没有绝对标准,因为它们依赖于检测器本身的稳定性和目标运动的平均速度,同一个角落的参数换一个场景可能需要重新标定。每次修改参数后,要跑至少2000帧的数据来评估,不要只看几十帧就下结论。

4.3 抽帧与丢帧策略对跟踪稳定性的影响

实时系统中经常出现检测器跑不满30帧、需要隔帧送测的情况。SORT的恒速模型以帧为单位做状态转移,抽帧会让目标的位移突变,导致IOU关联失效。如果视频源是30帧宽,而检测器只能跑15帧,有两个常用的策略。

一种是在feedFrame里传真实时间戳,卡尔曼状态转移时用dt乘以速度分量,相当于把相邻帧的时间差算进预测里。另一种是保持帧频假设不变,但把iou_threshold适当调低,让位置偏差大一点的配对仍然有概率成立。前者是根治,后者是缓解。

我常见的落地组合是:跟踪模块接收时间戳做可变帧间隔预测,同时在检测器下游做一个缓存队列,保证跟踪算法的输入始终是最新的检测而不是乱序的检测。这套组合在车辆和行人场景都验证过,跟踪稳定性比固定dt=1的提升非常明显。

5. 接入检测器后的避坑清单:SORT实战中常见的几个问题

5.1 检测框坐标系不统一导致预测位置偏差

现象:跟踪框的实际位置和检测框错位,误差随目标移动方向变化,高速运动时尤其明显。

原因:检测器输出的是归一化坐标,或者返回的是中心点坐标加宽高,而跟踪器内部按左上角加宽高或像素坐标处理,导致卡尔曼滤波的输入和输出不在一个坐标系下。

解决:在feedFrame入口统一做一次坐标转换,一律转换成像素坐标系下的左上角加宽高,再进入卡尔曼滤波。转换代码单独拆开,不要散落在多处,方便后续适配不同检测器。

5.2 目标短暂从画面消失后重新出现时ID变化

现象:目标离开画面几帧后回来,系统给它分配了一个新ID,导致下游统计数量虚高。

原因:SORT没有外观记忆,纯靠位置关联。目标重新出现的位置和消失时的预测位置相距较远,IOU低于阈值,轨迹过期后被删除。

解决:提高max_lost的值;在目标可能反复出现的区域(比如画面边界)调低iou_threshold;如果需要更强的记忆能力,可以后续加一个轻量的特征缓存,但这就不是SORT了。

5.3 低置信度检测框干扰匹配

现象:画面里出现大量抖动的小框,跟踪器跟着这些框乱跳,真实目标的轨迹反而被挤掉。

原因:检测器输出的低置信度框没有过滤干净,匈牙利匹配时占用掉了真实目标的匹配资源。

解决:把score_threshold调高,或者在进入匹配前对检测框做一次NMS聚合,保留每个区域得分最高的框。这里需要特别注意,不能只调匹配阈值,因为低分框和高分框的IOU可能很高,匹配时会把高分轨迹拉走。

5.4 卡尔曼滤波在目标机动时跟踪滞后

现象:目标突然加速或转弯,跟踪框明显落后于检测框,帧间存在可感知的延迟。

原因:恒速模型假设运功在两个帧之间是线性匀速的,目标机动时会偏离预测。

解决:适当调大过程噪声Q,给预测增加不确定性,让滤波器更愿意相信当前的测量值。如果目标动作模式再复杂,可以换成带转向角速度的运动模型,但那样需要扩展状态向量,实现复杂度会上升不少。

5.5 多线程场景下Tracker状态竞争

现象:检测器在多个线程同时产出检测结果,调用feedFrame后跟踪ID出现错乱或程序偶发崩溃。

原因:Tracker内部的卡尔曼状态和轨迹列表不是线程安全的,多个线程同时写入导致状态互相污染。

解决:保持feedFrame单线程调用,检测线程只把结果投递到队列里,由唯一工作线程消费并调用Tracker。这个设计要在一开始就定好,加锁只能缓解,不能根治并发访问带来的逻辑错乱。

6. 进阶:用最少代价判断SORT是否适合你的场景

判断SORT适不适合自己的场景,动手前先记住前面提到的六个字:不训练、纯运动。如果目标的外观高度相似而运动模式复杂,SORT的表现就会明显下降。

如果决定试SORT,落地验证时可以按这个顺序来:第一,用一段10到30秒的连续视频,目标数控制在3到5个,把原始检测结果和加了SORT的结果并排对比,观察ID是否稳定;第二,统计一次典型目标的ID切换次数,用这组数字给团队一个可以讨论的量化基线;第三,用标签工具生成一小段真值轨迹,算一下MOTA和ID switch指标,这样同一个优化方向的前后版本才有比较意义。

调试时有一个很实用的习惯,写一个把每帧轨迹ID写进CSV的旁路输出。这样脚本可以在不渲染视频的情况下快速统计不同参数组合下的ID切换次数。你会发现调参效率提升一大截,不用每次改一个参数都重新看一遍视频。

我个人的习惯是每到一个新场景,先把三个核心参数打印到日志里,连着跑两个小时,再结合曲线图做微调。SORT的机动性限制也确实存在——自动化密集场景下超过三成比例的ID切换,都不是靠调参能救回来的。如果验证后发现这个瓶颈确实卡在运动模型的假设上,那就需要转向带外观特征的重识别方案了。在此之前,SORT始终是最省钱、最值得先试的那一步棋,希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询