C++版SORT多目标跟踪算法:原理、实现与调参指南

发布时间:2026/10/22 14:57:14
C++版SORT多目标跟踪算法:原理、实现与调参指南 简介资源基于C实现的SORT多目标跟踪算法封装面向具备一定C与计算机视觉基础的学习者可用于毕业设计、课程设计、工程实训或初期项目开发在检测器之后无缝接入即可获得实时多目标跟踪能力。算法被封装为可复用类支持直接衔接现有检测模块并集成了匈牙利匹配、卡尔曼滤波跟踪等核心逻辑降低二次开发门槛。压缩包共21个文件以cpp源文件与h头文件为主体辅以txt说明文档、markdown指南以及演示gif便于读者对照代码理解实现细节并直观查看跟踪效果包体整体约49.91MB。目前已有114人学习浏览。资源内置PETS、ETH、KITTI、TUD等公开测试序列目录结构清晰配合CMake构建脚本可直接编译运行适合希望深入研究SORT原理或快速移植算法到自身项目中的学习者和开发者。1. 基于C版本的SORT算法检测器后面那个实时多目标跟踪类目标检测跑完一帧输出一堆框但框上没有名字。视频里同一个目标在第5帧和第30帧各出现一次没有算法把它们关联起来就没有ID也没法统计路径。要解决这个实时多目标跟踪问题最简单的方案之一就是SORT。这份基于C版本的SORT算法工程把跟踪逻辑完整搬到C核心封装成一个Sort类能不加改动地挂在常见检测器后面每帧喂进检测框吐出的就是带稳定ID的轨迹框。它适合两类人。一类是做毕设、课程设计或工程实训需要一个能实时运行的跟踪模块另一类是已经跑通检测器、想在输出端做人数统计、轨迹记录或ID归属的开发者。SORT的优点是计算量小、接入成本低缺点是ID稳定性有限这个后文会专门讲。2. 卡尔曼预测与匈牙利关联SORT的两步核心在代码里怎么落地2.1 从检测框到轨迹每帧只做三步SORT的全称是Simple Online and Realtime Tracking。Simple对应它的核心思想不用外观特征不用ReID只用位置和尺寸做关联Online表示每帧只利用当前及过去的信息不等待未来帧Realtime是结果在普通CPU上处理几百帧每秒都很轻松真正吃性能的往往是前面的检测器。整个算法的循环可以收成一个update函数。拆开之后一帧的处理只有三步先用卡尔曼滤波把已有的每条轨迹外推到当前帧得到每个目标的预测框再把预测框和这帧的检测框两两算IOU送入匈牙利算法做全局最优匹配最后按匹配结果分三类处理——匹配上的轨迹用检测框更新状态没匹配上的检测框作为新轨迹候选没匹配上的轨迹则计一次丢失。// 伪代码Sort::update 的主流程 for (auto trk : trackers) trk.predict(); // 1. 预测 cost buildCostMatrix(pred_boxes, det_boxes, iou_thr); // 2. 代价矩阵 matched hungarianSolve(cost); // 3. 全局匹配 updateMatched(matched, detections); createNewTracks(unmatched_detections); deleteLostTracks(max_age);这段伪代码里buildCostMatrix和hungarianSolve分别对应工程里的Sort.cpp与Hungarian.cpp。前者负责把IOU转换成关联代价后者负责在代价矩阵上求解最优匹配。有一点需要强调匹配是一对一全局最优不是每个检测框独立找最大IOU。贪心做法会让两条轨迹同时去抢同一个检测框另一个目标被漏掉匈牙利算法通过代价矩阵一次性安排所有轨迹和检测框保证整帧的匹配代价总和最小代价是求解时间略高。2.2 KalmanTracker匀速模型的状态与更新KalmanTracker是单条轨迹的滤波器。SORT原版里它的状态向量一般是7维目标中心坐标x、y框面积s宽高比r以及三个对应的速度分量vx、vy、vs。面积和宽高比代替宽高本身是因为面积随目标远近变化更平滑而宽高比能区分行人和车辆这类形状差异。// 状态向量x, y, s, r, vx, vy, varea // x,y 为中心坐标s 为框面积r 为宽高比后三个是速度 kf.statePre.atfloat(0) box.cx; // 中心 x kf.statePre.atfloat(1) box.cy; // 中心 y kf.statePre.atfloat(2) box.w * box.h; // 面积 s kf.statePre.atfloat(3) box.w / box.h; // 宽高比 r kf.statePre.atfloat(4) 0.f; // vx初始为 0 kf.statePre.atfloat(5) 0.f; // vy kf.statePre.atfloat(6) 0.f; // varea常见做法是直接用OpenCV的KalmanFilter类来实现这个滤波器predict方法做匀速外推update方法用检测框的测量值修正状态。测量向量是4维的x、y、s、r速度项不会被直接观测而是通过状态转移矩阵隐式估计。这里有个容易忽略的设计宽高比r一般只在初始化和测量更新时起作用预测阶段会保持相对稳定避免预测框在形状上突然拉长或压扁。对车辆这类刚性目标这个假设效果很好对密集行人预测框之间容易出现重叠进而引发关联错乱这一点到第5章再展开。SORT选择不引入外观特征换来的是模块极简。卡尔曼管时间域上的运动平滑匈牙利管空间域上的匹配最优两者结合能跑出相当可用的实时跟踪代价是目标一旦被遮挡或停止仅靠几何关系很难保持身份所以后面会看到ID跳变几乎无法完全避免。2.3 HungarianIOU距离构成的代价矩阵检测框和预测框的“匹配度”用什么定义SORT里就是IOU。IOU越接近1两个框越像同一个目标代价定义为1-IOU。IOU低于某个阈值时直接写一个大数禁止关联防止把相隔很远的框硬配成一对。不用欧式距离的原因也很实际IOU天然对目标尺度做了归一化远近大小都能比较欧式距离还要额外处理不同分辨率、不同目标尺寸的权重问题。// 代价矩阵构建trackPredict[i] 与 detBox[j] 的关联代价 for (int i 0; i track_count; i) { for (int j 0; j det_count; j) { float iou calcIOU(trackPredict[i], detBox[j]); if (iou iou_threshold) { cost[i][j] BIG_NUMBER; // 表示禁止关联 } else { cost[i][j] 1.0f - iou; // IOU 越大代价越小 } } }匈牙利算法的输出是一个匹配关系数组每条轨迹对应一个检测框下标未匹配的用-1表示。Hungarian.cpp里实现的就是这个求解器核心逻辑是不断找增广路径来调整匹配直到无法改进为止。对于几百个目标规模求解耗时可以忽略真正要注意的是代价矩阵不一定是方阵。轨迹数少于检测框数时多余的检测框自然成为新轨迹候选轨迹数多时多余的轨迹进入丢失状态。传参时两个维度不要写反否则匹配结果会错位。这里还有个细节代价等于1减去IOU意味着IOU0.3时代价是0.7IOU0.9时代价是0.1。匈牙利算法找的是总代价最小所以它天然会优先把IOU高的检测框分配给对应轨迹但这不代表低IOU的轨迹一定会被放弃全局最优偶尔会让某个轨迹让出一个较低IOU换取另一个轨迹成功关联这种权衡是贪心匹配做不到的。2.4 生命周期hit/lost计数与轨迹删除新检测框会立刻建一条轨迹但不会立刻对外输出。SORT要求轨迹连续命中min_hits帧后才发布目的是过滤单帧误检和抖动框多数工程默认值是3。每帧匹配成功则hit加1、lost归零匹配失败则lost加1lost超过max_age后轨迹被彻底删除。if (matched) { hit; lost 0; } else { lost; if (lost max_age) { removeTrack(id); // 连续 max_age 帧没匹配上就删除 } } if (hit min_hits) { publishTrack(id); // 达到最小命中帧数才对外输出 }这个生命周期逻辑是整套算法里最容易被低估的部分。很多人发现目标被遮挡一两帧后ID就丢第一反应是卡尔曼滤波不够好其实多半是max_age设置太紧。原版默认max_age1意味着目标只要丢一帧就会被杀换来的是极低的计算延迟和干净的轨迹列表代价是抗遮挡、抗漏检能力非常弱。真实视频里目标被临时挡住是常态接入检测器时通常需要把这个值放大到5甚至10具体怎么权衡放到第4章讲。3. 编译与接入把Sort类加到检测器后的完整流程3.1 CMakeLists依赖、源文件与编译命令工程根目录放着一个CMakeLists.txt负责把四个核心源文件编成一个可执行程序。我在这类C工程里看到的结构通常是这样cmake_minimum_required(VERSION 3.10) project(sort_cpp) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(OpenCV REQUIRED) add_executable(sort_cpp main.cpp Sort.cpp KalmanTracker.cpp Hungarian.cpp ) target_link_libraries(sort_cpp ${OpenCV_LIBS})C11对SORT来说完全够用卡尔曼滤波、匈牙利匹配、向量容器都不需要特别新的语法。OpenCV在这里主要承担两件事KalmanTracker里的KalmanFilter类以及main.cpp里读图、画框、写gif这些IO操作。如果你的环境没有OpenCV需要自己实现一个矩阵版卡尔曼滤波工程量不大但容易出错不建议一上来就脱离OpenCV。编译命令很常规cd sort_cpp-master mkdir -p build cd build cmake .. make -j4如果cmake阶段找不到OpenCV先检查是否安装、路径是否在环境变量里如果链接阶段报undefined reference优先检查CMakeLists里的源文件列表Hungarian.cpp或KalmanTracker.cpp漏掉是这类工程最常见的编译事故。3.2 Sort类对外暴露的接口就是update这个工程最大的价值是把整个跟踪器收敛成了一个类。Sort.h里会声明构造函数和update方法接口大致是下面这个样子class Sort { public: // iou_threshold关联阈值max_age轨迹最大丢失帧数min_hits输出前最小命中帧数 Sort(float iou_threshold 0.3f, int max_age 1, int min_hits 3); // 每帧调用一次输入该帧所有检测框返回带 ID 的轨迹框 std::vectorTrackBox update(const std::vectorDetectBox detections); private: float iou_threshold_; int max_age_; int min_hits_; std::vectorKalmanTracker trackers_; };所谓“无缝添加”指的就是集成点只有一个update接口。检测器那边每帧输出若干框整理成DetectBox的vector传进来返回值里携带轨迹id、框坐标和命中帧数。你不需要关心内部卡尔曼矩阵怎么转、匈牙利怎么求解那些状态都封装在trackers_里。接入自己检测器时代码大概长这样// 假设 detector 是已有的检测器 std::vectorDetectBox dets; for (auto obj : detector.detect(frame)) { DetectBox d; d.x obj.x; d.y obj.y; d.w obj.w; d.h obj.h; d.score obj.score; dets.push_back(d); } Sort tracker(0.3f, 3, 3); // 低帧率场景把 max_age 调大 std::vectorTrackBox tracks tracker.update(dets); for (auto t : tracks) { drawBox(frame, t.x, t.y, t.w, t.h, t.id); // 用 id 决定颜色 }有一个低级但常见的错误把Sort实例放在循环体内部每帧重新构造。这样一来卡尔曼状态、hit计数、lost计数全部清零跟踪效果直接退化成逐帧独立匹配ID每帧都不一样。tracker必须定义在循环外面只在程序启动时创建一次。3.3 main.cpp的调用循环读检测结果吐轨迹存gif工程里的main.cpp是一个完整可运行的demo做的事情就是上面这段逻辑的循环版本打开某个测试序列逐帧载入检测结果调用Sort.update把带id的框画在图上最后把结果写成debug/sort.gif。拿到手先打开sort.gif能直观判断跟踪是否跑通。Sort tracker(0.3f, 1, 3); for (int frame_id first; frame_id last; frame_id) { std::vectorDetectBox dets loadDetectionFile(frame_id); if (dets.empty()) continue; std::vectorTrackBox tracks tracker.update(dets); for (const auto t : tracks) { drawTrack(frame, t.x, t.y, t.w, t.h, t.id); } }loadDetectionFile是读取检测框的辅助函数负责按帧号把检测结果装进vector你接入自己项目时通常把这一行换成检测器输出的转换逻辑。需要注意tracker实例只能有一个且必须放循环外检测框为空的那一帧也要调用update让轨迹正常走lost计数跳过调用会导致算法内部时间戳错乱。3.4 换数据序列时的三个检查点工程内附带了多组公开MOT测试序列覆盖行人密集、车辆道路、十字路口等不同场景目标密度、遮挡程度和相机运动方式差别很大。换序列的步骤很简单改main.cpp里的输入路径。但有几个检查点必须确认。第一检测框坐标是像素坐标还是归一化坐标。不同检测器输出差异大SORT内部默认按像素坐标处理喂进去之前必须转成统一坐标系。第二输入帧率与max_age的匹配。30fps下max_age1意味着目标丢一帧就没了10fps下max_age1意味着只容忍0.1秒的丢失两者完全不同。第三检测框是否已经过NMS。SORT不负责去重同一个目标重复出几个框会让多条轨迹争抢ID跳变几乎无法避免。这些检查点同样适用于自己采集的视频。接入检测器时先在一小段数据上把路径、坐标和帧率确认清楚再跑完整序列能省下大量排错时间。4. 框格式与阈值边界怎么喂数据、怎么调参数、怎么收ID4.1 DetectBox入格式统一四种常见表示检测器输出的框形式多种多样最常见是左上角x、y加宽w、高h也有左上右下两个角点、中心点加宽高、以及归一化中心与尺度。SORT内部要把框转成“中心x、中心y、面积s、宽高比r”供卡尔曼使用所以接入阶段做一层格式转换是必须的。struct DetectBox { float x, y, w, h; // 左上角 x,y宽 w高 h float score; // 置信度供调用方过滤 }; // 转成卡尔曼需要的表示 TrackBox toKfBox(const DetectBox d) { TrackBox k; k.cx d.x d.w / 2.f; k.cy d.y d.h / 2.f; k.s d.w * d.h; k.r d.w / d.h; return k; }score字段在这里只是附带信息SORT本身不消费它。建议在调用update之前先按score做一次过滤把低置信度框扔掉。低置信度框往往是误检喂进去就会多出大量短命轨迹既增加计算量也会干扰正常匹配。具体置信度阈值取决于检测器常见是0.3到0.5之间宁缺毋滥。4.2 三个默认参数与常见场景的设定值构造函数里的三个参数是这套算法最主要的调参对象改好它们大部分跟踪问题都能解决。参数默认值作用调整方向iou_threshold0.3预测框与检测框IOU低于此值不关联密集场景下调到0.15-0.25稀疏场景可保持0.3-0.4max_age1轨迹连续lost帧数超过则删除遮挡多或帧率低时调到5-10min_hits3新轨迹连续命中帧数达到才输出误检多时加到5-8追求实时反馈时减到1-2max_age调大后目标短暂出画或漏检时ID能保持住代价是轨迹假死时间变长同一个位置可能同时存在旧轨迹和新轨迹目标重现时两条轨迹竞争ID容易乱。min_hits调大能显著减少一闪而过的噪声轨迹但会让新目标的首个ID出现延迟几帧下游做实时统计时要容忍这段滞后。4.3 阈值调整的边界效应过了某个点就翻车很多人把iou_threshold理解成“重合度要求”以为调越高越严格越准确实际相反。IOU阈值是关联下限调高意味着两个框必须高度重合才允许匹配。检测框本身有抖动预测框又有误差阈值超过0.5后大量合法匹配会被拒之门外跟踪结果表现为ID疯狂跳变。阈值调低也有问题。到0.05以下时两个不同目标只要边缘稍微碰一下代价矩阵里的不可关联标记就失效它们会被配成同一条轨迹行人在密集场景里轨迹线会互相串。我一般先放在0.3跑一遍基线观察两个指标ID跳变频率和轨迹存活时长。前者高说明关联过严后者短说明max_age太紧。调参时一次只动一个参数记录结果再改第二个不要同时动两个否则出了问题都说不清是谁引起的。还有一个小坑代价矩阵中禁止关联用的大数不能取INT_MAX。匈牙利算法内部要做加法运算INT_MAX加一次就溢出成负数匹配结果直接乱套。常见做法是填一个固定的较大数比如1e6既不会被选入最优解又不会溢出。4.4 实时性边界目标数量与匈牙利开销SORT的实时性优势有前提目标数量不能太多。匈牙利算法的复杂度是O(n^3)n是轨迹数和检测框数的较大值。几十个目标时完全无感到几百个目标时一帧的匹配耗时开始能感知到上千个目标时就会成为瓶颈。目标是数量庞大的鸟群、人群、车流时纯SORT方案会吃力。此时可以考虑做几步削减按置信度过滤检测框把图像分成区域分别跑多个跟踪器或者限制单帧最多参与匹配的轨迹和检测框数量。SORT的设计哲学是“够快就好”不是“上限要多高”了解自己的场景规模比盲目堆算力更有用。5. 避坑与排查编译、关联、轨迹断裂的常见问题5.1 现象同一目标的ID半路跳变现象视频里第20帧还是ID 3第24帧变成ID 12过一会儿又变回3附近。原因检测器在帧间输出不稳定目标框有闪烁加上IOU阈值偏严预测框和检测框的重合度偶尔低于阈值轨迹被判丢失目标下次出现时作为新轨迹重新建档于是换ID。解决先确认检测器输出本身是否抖动对同一帧重复跑几次看框是否稳定。检测稳定后把iou_threshold从0.3调低到0.2-0.25再把max_age从1调到5-8给短暂断开的轨迹留重连机会。这两个动作能解决大部分ID跳变问题。如果检测器本身一直抖动跟踪器只能缓解不能根治。5.2 现象目标站住不动几秒后ID消失现象一个人从画面左边走进来站在路边不动检测框一直存在但ID在静止几秒后消失了。原因SORT的卡尔曼用的是匀速模型目标静止后速度观测趋近于零但卡尔曼会把速度项保留一段时间预测框继续按惯性缓慢漂移。此时检测器如果偶尔漏几帧轨迹丢失等检测框再回来时和预测框的IOU已经太大关联失败目标只能作为新轨迹重建。解决对站定场景把max_age放大到10帧以上让预测框有时间等检测框回来。如果检测框全程稳定ID还是丢问题就出在卡尔曼预测偏移需要改KalmanTracker.cpp里的状态转移矩阵让连续未更新时速度逐步衰减到零。后者属于对跟踪器的定制修改动手前建议先确认漏检原因别一上来就调矩阵。5.3 现象画面里全是1到2帧的短命轨迹现象每帧都冒出几个新ID画面上方框不断出现又消失真正的目标ID反而不明显。原因min_hits被设成了0或1。新轨迹建立后第一帧就对外输出单帧误检、检测框抖动、影子框都会立即以正式ID的形式出现。SORT默认min_hits3是有道理的两帧以内的轨迹大概率是噪声。解决把min_hits设回3如果场景误检特别多比如墙壁上的投影被反复检测min_hits可以加到5以上。代价是目标从出现到获得ID之间有约几帧的延迟这个延迟在统计场景里可以接受。注意min_hits只影响对外发布不影响内部卡尔曼更新所以调大不会让跟踪精度下降只是让ID出现得晚一点。5.4 现象CMake链接阶段报undefined reference现象编译能通过链接时报错错误信息集中在KalmanTracker或Hungarian相关符号上。原因add_executable里的源文件列表漏掉了对应.cpp文件。这个工程拆成Sort、KalmanTracker、Hungarian、main四个模块任何一块没参与编译链接时都会暴露。另一种情况是OpenCV库没链接成功KalmanFilter和图像IO相关符号全部报未定义。解决先检查CMakeLists.txt中add_executable的源文件列表是否完整再把find_package和target_link_libraries两行确认一遍。排查顺序永远是先看源文件列表再确认OpenCV路径不要一上来就怀疑代码写错。链接错误不是代码错误是构建配置错误。5.5 现象轨迹框跟着检测框疯狂抖动现象ID没有跳变但画出的轨迹框在帧间左右晃动像是没有滤波的裸框。原因SORT对检测框是强力跟随的卡尔曼滤波在参数设计上给了测量值很大的权重检测框的抖动会直接透传到输出端。跟踪器本身不是降噪器它的目标是跟踪检测框不是修复检测框。解决先确认抖动来自检测器。如果检测器输出本身不稳换检测器比调跟踪器更有效。如果检测器暂时不方便换可以在输出端补一层平滑下一章给出一个简单的EMA方案。需要注意加平滑会让轨迹响应变慢高速运动目标会有明显滞后属于用延迟换稳定。6. 进阶给输出轨迹加EMA平滑同时验证ID稳定时长6.1 每个ID一份EMA缓冲在Sort类外面包一层平滑器按ID维护指数移动平均对框的中心坐标做平滑面积和宽高可以保持原样视觉上先解决最明显的中心抖动。struct SmoothedBox { float cx, cy; }; std::unordered_mapint, SmoothedBox smooth_map; TrackBox smoothTrack(const TrackBox t, float alpha 0.35f) { auto it smooth_map.find(t.id); if (it smooth_map.end()) { smooth_map[t.id] { t.cx, t.cy }; // 首次出现直接用当前值 } else { SmoothedBox s it-second; s.cx alpha * t.cx (1 - alpha) * s.cx; // 指数移动平均 s.cy alpha * t.cy (1 - alpha) * s.cy; } t.cx smooth_map[t.id].cx; t.cy smooth_map[t.id].cy; return t; }alpha越大越相信当前帧轨迹更跟手但更抖alpha越小画面越稳定但目标转向时会有明显拖尾。alpha0.35是行人漫步场景里比较折中的值车辆高速场景可以提高到0.5静止场景降到0.2也没问题。注意当一个ID被删除后smooth_map里要同步清理避免map无限增长。6.2 验证ID稳定性数一数每条轨迹活了多久平滑改完要验证不能只看感觉。常用做法是输出轨迹日志统计每条ID首次出现帧号和最后出现帧号帧数差就是ID存活时长。把存活不足10帧的ID数量、ID切换次数记录下来作为调参前后的对比指标。./sort_cpp | awk {print $1} | sort | uniq -c如果日志第一列是轨迹id这条命令能统计每个ID出现的帧数一眼看出短命轨迹有没有被压下去。对比改参数前后的统计结果ID的中位存活帧数明显变长画面抖动主观感受变轻才算真正调到位。SORT这类在线跟踪器没有回头修正的机会ID一旦断了就是断了。我从那以后接到跟踪需求都会强制先跑一遍轨迹日志统计确认问题到底在检测器还是关联参数而不是凭肉眼反复看视频猜测。希望这个工作流对你有帮助。本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询