
简介一套基于C语言实现的多类别目标跟踪系统面向计算机视觉开发者和算法工程师主要解决视频流中行人、车辆等目标的实时检测与连续跟踪问题。系统深度融合YOLOX模型、ONNX推理和ByteTrack算法优化并兼容OpenCV可在智能监控、自动驾驶等高实时性场景中稳定运行应对遮挡、快速移动等复杂情况。压缩包共含14个文件以5个C源文件与5个头文件构成核心实现覆盖卡尔曼滤波、数据关联、跟踪管理等关键模块另附2个txt说明文档、docx附赠资源和md说明文档整体约50KB层次清晰、易于按需检索。目前已有38人学习下载。通过这份资源读者可以系统理解多类别跟踪的完整流程掌握YOLOX与ONNX的集成方式以及ByteTrack的工程化写法获得一套可复用、可改造的C语言多目标跟踪代码基础便于后续在监控或车载平台上做二次开发与算法研究。1. 把 YOLOX 检测器接进 ByteTrack 骨架这套 C 实现的多目标跟踪工程包到底能不能用在智能监控和自动驾驶场景里视频流中的多目标检测与跟踪难的不只是“检出目标”还要解决“同一个目标换了一帧还是不是同一个人”这个身份问题。这套以 C 语言实现的工程包把 YOLOX 模型的 ONNX 推理输出直接接到 ByteTrack 跟踪器上用 OpenCV 完成取帧、预处理与可视化整条链路在纯 C/C 环境下就能跑起来不依赖 Python 运行时。工程里对 ByteTrack 的二次关联做了参数化裁剪支持多类别标签模型文件也可以替换成自定义数据集训练出的 YOLOX。对要在 Jetson、x86 工控机上做智能监控或自动驾驶感知的团队来说它是个能直接当起步骨架用的下载资源。2. 工程结构与推理链路ONNX Runtime 初始化、预处理与每帧调用2.1 系统拆解检测器与跟踪器是解耦的两层这套工程给我的第一印象是检测和跟踪的边界切得很干净。YOLOX 检测器只负责输出一组“带类别标签的框”ByteTrack 只负责跨帧关联两者之间通过一个 Detection 结构体传递数据。这样设计的好处很实际你可以不换跟踪器单独把 YOLOX 换成其他 ONNX 模型也可以保留 YOLOX把 ByteTrack 的匹配策略换成自己的。数据流大致是读取视频帧 → letterbox 缩放 → BGR/RGB 转换与归一化 → ONNX Runtime 推理 → 解码出检测框 → NMS 去除重复框 → ByteTrack 更新轨迹 → 在原图上画框。其中前面三步属于“图像侧”中间两步属于“模型侧”最后两步属于“跟踪侧”。调试的时候必须分清楚问题出在哪一层不然很容易出现“检测没问题但跟踪乱跳”的情况最后全归咎于算法。2.2 ONNX Runtime 的 C 接口初始化会话、优化级别与线程数ONNX Runtime 的 C API 在工程里通常封装成一个引擎类。初始化时最需要注意的是输入输出张量的名字不能硬编码我见过不少工程在模型重新导出后直接崩掉就是因为输入名从 “images” 变成了 “input.1”。下面是一个最小可用的初始化骨架// engine.h —— YOLOX 推理引擎的最小初始化 #include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include memory #include string class YoloxEngine { public: void init(const char *model_path, int threads 4) { // 会话环境日志级别设为 WARNING跑起来干净一点 env_ std::make_uniqueOrt::Env(ORT_LOGGING_LEVEL_WARNING, mot_engine); Ort::SessionOptions opts; opts.SetGraphOptimizationLevel(ORT_ENABLE_ALL); opts.SetIntraOpNumThreads(threads); session_ std::make_uniqueOrt::Session(*env_, model_path, opts); // 输入输出名从模型里动态读取不要硬编码 allocator_ std::make_uniqueOrt::AllocatorWithDefaultOptions(); input_name_ session_-GetInputNameAllocated(0, *allocator_).get(); output_name_ session_-GetOutputNameAllocated(0, *allocator_).get(); } private: std::unique_ptrOrt::Env env_; std::unique_ptrOrt::Session session_; std::unique_ptrOrt::AllocatorWithDefaultOptions allocator_; std::string input_name_, output_name_; };代码里做了三件事创建推理环境、配置会话、读取输入输出名。SetGraphOptimizationLevel(ORT_ENABLE_ALL) 会做图优化包括算子融合对 CPU 推理帧率影响很明显。SetIntraOpNumThreads 控制单次推理内部的并行线程数在 Jetson 这种小核心设备上不是越大越好一般 4 线程就够了开太多反而在帧间隔里造成调度抖动。提示如果你用的是 GPU 版 ONNX Runtime还需要额外调用 AppendExecutionProvider_CUDA并把 Session 的 intra_op 线程数调低否则 CPU 线程和 GPU 拷贝线程会互相抢资源。2.3 每帧预处理letterbox、blobFromImage 与张量拷贝视频流场景里每帧都要做缩放和归一化这块是性能瓶颈之一也是最容易翻车的地方。常见做法是用 OpenCV 的 letterbox 保持宽高比把图缩放到 640×640剩余区域用灰色填充。需要特别注意网络的输入是 RGB 还是 BGR训练时填的是 0 还是 114这些必须和导出模型时的预处理保持一致否则检测框位置会整体漂移。// infer.cpp —— 单帧推理与预处理 cv::Mat frame; // 原图BGR 顺序 cap.read(frame); cv::Mat letterboxed; float ratio std::min(640.0f / frame.cols, 640.0f / frame.rows); int new_w (int)(frame.cols * ratio), new_h (int)(frame.rows * ratio); cv::resize(frame, letterboxed, cv::Size(new_w, new_h)); cv::copyMakeBorder(letterboxed, letterboxed, 0, 640 - new_h, 0, 640 - new_w, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); // OpenCV 的 blobFromImage 输出 NCHW注意参数含义 cv::Mat blob cv::dnn::blobFromImage(letterboxed, 1.0f / 255.0f, cv::Size(640, 640), cv::Scalar(0, 0, 0), true, false); std::vectorint64_t shape {1, 3, 640, 640}; std::vectorfloat input_data(1 * 3 * 640 * 640); memcpy(input_data.data(), blob.ptrfloat(0), input_data.size() * sizeof(float)); auto input_tensor Ort::Value::CreateTensorfloat( Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault), input_data.data(), input_data.size(), shape.data(), shape.size()); auto outputs session_-Run(Ort::RunOptions{nullptr}, input_name_, input_tensor, 1, output_name_, 1);这里有个非常隐蔽的坑blobFromImage 中间的 true 表示把 BGR 转成 RGB如果你在导出 ONNX 模型时预处理已经做过转换这里就别再转一次。填充色 Scalar(114,114,114) 要和 letterbox 的 fill 值一致否则图像边缘会有一圈异常亮度的像素导致边缘误检。memcpy 之前还要确认 Mat 是连续内存一般情况下 blobFromImage 输出是连续的但保险起见可以用 blob.isContinuous() 检查一下。主循环结构比较简单每帧做一次推理把检测结果交给跟踪器while (cap.isOpened()) { cap.read(frame); auto dets engine.infer(frame); // 得到 NMS 后的检测框 auto tracks tracker.update(dets, frame_id); draw_tracks(frame, tracks); // 可视化层 frame_id; }检测和跟踪在这里彻底分离后续替换模型时只需要改 engine 内部的输入输出解析tracker 完全不用动。这就是解耦设计在维护时的价值。3. YOLOX 输出解码与后处理从 8400 个锚点到多类别检测框3.1 看懂 YOLOX 的输出张量结构YOLOX 是 anchor-free 检测器不像 YOLOv5 那样有 3 组 anchor 先验。它把输入图切成多个尺度的网格每个网格位置预测一个中心点坐标cx, cy、宽高w, h、objectness 分数和类别分数。以 640×640 输入为例输出张量通常是 (1, 8400, 5类别数)比如 COCO 80 类就是 85 维自定义多类别数据集则是 5N。特别要注意的是YOLOX 的 Decoupled Head 把 objectness 和类别分数分成两路输出解码时两者都要过 sigmoid综合分数用 obj * cls。如果导出模型时把 sigmoid 也导出进去了代码里再套一层 sigmoid 就会让所有分数趋向 0 或 1检测结果几乎必废。3.2 解码与分数过滤sigmoid、中心点转 xyxy解码的核心逻辑是遍历 8400 个锚点先算 objectness再按类别算综合分。下面的实现会比较“贴近训练时导出”的做法即模型输出的是原始 logits后处理还得自己补 sigmoid// decode.cpp —— YOLOX 模型输出解码 static float sigmoid(float x) { return 1.0f / (1.0f std::exp(-x)); } void decode_yolox(const float *raw, int num_anchors, int num_classes, float obj_thresh, std::vectorDetection out) { out.clear(); int stride 5 num_classes; for (int i 0; i num_anchors; i) { const float *p raw i * stride; float obj sigmoid(p[4]); if (obj obj_thresh) continue; // 先砍掉多数无效框 // YOLOX 输出的是中心点 宽高要转成 xyxy float cx p[0], cy p[1], w p[2], h p[3]; float x1 cx - w / 2, y1 cy - h / 2; float x2 cx w / 2, y2 cy h / 2; for (int c 0; c num_classes; c) { float cls sigmoid(p[5 c]); float score obj * cls; // 综合分数阈值一般设 0.25~0.5 if (score 0.25f) { out.push_back({x1, y1, x2, y2, score, c}); } } } }参数方面obj_thresh 控制的是“这个位置有没有目标”一般设 0.3 左右score 阈值控制的是“这个目标属于某类别的可信度”太低会出现大量重叠候选太高会把小目标漏掉。多类别工程里类别间容易互相压分建议先跑一段自己场景的视频统计各类别的分数分布再定阈值不要照抄 COCO 的 0.3。3.3 按类别做 NMS多类别检测框去重解码出来的候选框数量不少同一个目标在不同锚点位置会输出多个框NMS 就是用来消重的。多类别场景不能把所有类别的框混在一起做 NMS否则“一个行人和他旁边的自行车”可能因为框重叠被误删一个。标准做法是每个类别单独排序、单独做 NMS// nms.cpp —— 按类别分组做 NMS void nms_per_class(std::vectorDetection cands, float nms_thresh, std::vectorDetection keep) { std::sort(cands.begin(), cands.end(), [](const Detection a, const Detection b){ return a.score b.score; }); std::vectorbool suppressed(cands.size(), false); for (size_t i 0; i cands.size(); i) { if (suppressed[i]) continue; keep.push_back(cands[i]); for (size_t j i 1; j cands.size(); j) { if (suppressed[j]) continue; if (cands[i].cls cands[j].cls iou(cands[i].box, cands[j].box) nms_thresh) { suppressed[j] true; } } } }注意 NMS 的类别比较必须放在 IoU 判断之前用if (cands[i].cls cands[j].cls iou(...))而不是只比 IoU。我见过有人为了省事把所有框一起 NMS结果车和行人重叠时行人直接被吞掉跟踪计数直接少一半。3.4 letterbox 坐标回映射最后的逆变换解码出的框坐标是在 640×640 的 letterbox 图上的要转回原图必须记录之前的 ratio 和 pad。这个步骤漏掉的工程症状是“检测框整体向右下方偏了固定距离”放大追到视频里会特别明显。这里给一个安全的回映射写法// 把 letterbox 坐标恢复到原图坐标 float pad_x 0, pad_y 0; float ratio_x (float)frame.cols / (640 - pad_x); // 实际工程里按 letterbox 参数算 float ratio_y (float)frame.rows / (640 - pad_y); float orig_x1 x1_letterbox / ratio_x; float orig_y1 y1_letterbox / ratio_y; float orig_x2 x2_letterbox / ratio_x; float orig_y2 y2_letterbox / ratio_y;常见做法是把 ratio、pad_x、pad_y 存在一个结构体里和每帧检测结果一起返回。如果输入图宽高不一样ratio 必须按宽高分别算不能只取一个公共缩放系数否则非正方形画面上框的偏移程度会不一致。建议在工程里加一个单元测试拿一张固定图验证回映射后的框和标注框完全重合。4. ByteTrack 二次关联与参数调优高置信度框先匹配低置信度框兜底4.1 第一阶段高置信度检测框与已有轨迹做 IoU 匹配ByteTrack 的核心思路非常朴素不提取外观特征不训练 Re-ID 模型只用目标框的 IoU 做关联。它之所以能打是因为作者发现检测器输出里高置信度框的质量足够好IoU 匹配在多数视频序列上已经能取得不错的效果。实现上第一阶段把得分高于 det_thresh 的检测框和当前所有未丢失的轨迹做代价矩阵计算。代价定义为1 - IoU(track.box, det.box)再用匈牙利算法求解最小匹配。标准参数里 match_thresh 取 0.8意思是代价大于 0.8即 IoU 小于 0.2就不允许匹配直接判为未匹配// 第一阶段高置信度匹配 std::vectorstd::pairint, int match_high( const std::vectorTrack tracks, const std::vectorDetection high_dets, float match_thresh) { int n tracks.size(), m high_dets.size(); std::vectorstd::vectorfloat cost(n, std::vectorfloat(m, 1.0f)); for (int i 0; i n; i) for (int j 0; j m; j) cost[i][j] 1.0f - iou(tracks[i].rect, high_dets[j].rect); // 匈牙利算法求解最小代价匹配返回对应索引 std::vectorstd::pairint, int matches; hungarian(cost, match_thresh, matches); // 工程内已实现 return matches; }4.2 第二阶段低置信度框补匹配与新轨迹激活第一轮结束后还有两类东西剩下来未匹配的低分检测框以及未匹配的轨迹。ByteTrack 的第二轮就把它们再做一次 IoU 匹配目的是接住“目标被短暂遮挡后又出现”的情况。由于第二轮用的检测框分数较低误检风险更大通常会把 IoU 阈值提得更严只接受 IoU 明显高的情况。// 第二阶段低置信度补匹配 void match_recover(std::vectorTrack unmatched_tracks, std::vectorDetection low_dets, std::vectorMatch recovered) { for (auto t : unmatched_tracks) { float best_iou 0.0f; int best_det -1; for (int j 0; j low_dets.size(); j) { float iou_val iou(t.rect, low_dets[j].rect); if (iou_val best_iou) { best_iou iou_val; best_det j; } } // 低置信度框匹配阈值更保守一般大于 0.5 if (best_det 0 best_iou 0.5f) { recovered.push_back({t.id, best_det}); } } }第二阶段的意义在于“缓存住轨迹”让目标被遮挡或短暂出画面后还能恢复同一 ID。ByteTrack 里有个 track_buffer 参数表示轨迹可保留的未匹配帧数超过就删除轨迹。第一次编译跑通时最容易出现的问题就是这里轨迹删太快每个目标被遮挡两帧就换新 ID。关于新轨迹的激活ByteTrack 的思路是检测框连续出现若干帧且分数足够高才激活为新轨迹避免“闪烁框”造成的瞬时僵尸轨迹。min_hits 一般设 3意思是连续三帧匹配上才确认新目标否则只是一条 pending 轨迹。4.3 参数表与调优边界下面这组参数是这套工程里最值得调的部分我按实际效果排了序参数默认值含义调低后果调高后果det_thresh0.6第一阶段检测框分数下限低分误检增多跟踪轨迹变乱小目标被过滤漏检严重match_thresh0.8第一阶段最大代价阈值轨迹更容易断ID 跳变变多两个目标距离近时会粘在一起track_buffer30轨迹最大未匹配帧数遮挡恢复能力弱僵尸轨迹增多计数偏大min_hits3新轨迹激活所需连续帧数目标出现即建轨迹抖动多短时出现的目标永远无法激活nms_thresh0.7检测器 NMS 阈值重叠框多跟踪端匹配紊乱邻近目标被 NMS 误删有一个参数联动陷阱要提醒track_buffer 是按帧算的不是按秒算的。如果你的视频是 5fps30 帧等于 6 秒如果是 30fps30 帧只有 1 秒。同一个参数在不同帧率视频下表现可能天差地别工程里应该根据输入视频 fps 动态折算buffer_frames target_seconds * fps。另外多类别跟踪要留意 ByteTrack 本质是类别无关的它只算 IoU。工程里如果要让“人”和“自行车”不互相抢 ID常见做法是在匹配阶段加一个类别一致性约束只有类别相同的候选对才参与匈牙利匹配。这个约束会增加一些实现量但对智能监控和自动驾驶来说几乎是必须的否则人走到自行车旁边ID 很容易被交换。提示如果不想引入匈牙利算法也可以用贪心替代按 IoU 从大到小排序依次抢占匹配。实际效果在目标密集场景下会差一些但实现简单适合先跑通全流程再做优化。5. 避坑排查预处理错位、坐标未逆映射、ID 跳变与多类别混乱5.1 推理输出全是 0 或 NaN现象检测结果为空或者 score 全部接近 0。用 debugger 看输出张量发现大量 0 值和部分 nan跟踪器自然一条轨迹都建立不起来。原因最常见的问题是把 blobFromImage 对 Mat 的 HWC 转 CHW 和模型输入的 NCHW 搞混。OpenCV 的 Mat 是 HWC 排列ONNX Runtime 输入要求是 CHW如果直接从 Mat 内存拷贝到 vector形状完全错位。其次是 RGB/BGR 重复转换模型在导出前训练时已经做了 BGR→RGB推理时又转了一次特征分布直接被破坏。解决先把单帧图片保存下来和训练时的预处理比对确认通道顺序和归一化方式。memcpy 前检查blob.isContinuous()确保 Mat 内存连续。再不行就做“差分调试”用 Python 端同一模型同一张图跑一次逐张量比对输入数值定位偏差发生在哪一步。5.2 检测框整体偏移letterbox 坐标没有回映射现象框能框到目标但位置整体向右下方偏且越靠近图像边缘偏差越大或者图尺寸变化后偏差程度不一样。原因解码出的坐标是 letterbox 缩放后的坐标直接用这个坐标画框没有减去 pad、没有除以缩放比。视频帧宽高比不等于 1 时偏移尤其明显因为 640×640 的填充区域让坐标原点发生了平移。解决保存 letterbox 时的 ratio、pad_x、pad_y解码后统一做一次逆映射。建议把逆映射放在解码函数内部完成而不是让上层去处理否则每一处使用坐标的地方都要写一遍换算迟早漏一处。5.3 同一目标 ID 频繁跳变或轨迹碎片化现象一个行人走几步后 ID 从 3 跳到 17计数被重复统计目标被电线杆挡了一秒ID 就回不来了重新编号。原因两个参数大概率配合错了。match_thresh 太高帧间框稍微错位就匹配不上track_buffer 太短轨迹几帧没匹配到就被删除。还有一种隐蔽情况NMS 阈值设太高导致同一目标同时出现多个抖动的框跟踪端的 IoU 计算不稳定。解决先把 match_thresh 降到 0.7 左右试一轮同时把 track_buffer 按目标帧率折算成至少 2 秒。如果目标运动速度快、帧间位移大考虑对轨迹做卡尔曼预测用预测位置参与 IoU 计算而不是直接用上一帧原始坐标。5.4 多类别目标 ID 来回切换现象一个目标的 ID 在“人”和“自行车”之间反复横跳或者轨迹 ID 没变但类别标签乱变。原因ByteTrack 的匹配只算 IoU不管类别。人和他骑的自行车重叠度高时两类的框都会参与匹配ID 可能被错误交换同时可视化层如果用每帧最新检测的类别覆盖就会造成标签闪烁。解决在 Track 结构里维护一个类别计数器每条轨迹记录“该 ID 下出现次数最多的类别”输出时用众数类别而不是最新类别。匹配阶段加入类别一致性过滤让不同类别的检测框不参与匹配这一步改起来不复杂但能省掉后面大量的人工核对工作。6. 落地前的最后一公里单帧校准、MOT 指标回放与遮挡恢复工程跑通后的验证我一般分三步单帧校准、序列回放、参数回归。单帧校准是防止“改了一行代码把推理改坏”的兜底手段。做法是固定一张测试图把模型输出 dump 成 CSV任何改动之后重新跑一遍逐字段 diff输出完全一致才算通过./mot_demo --input test.mp4 --output result.csv \ --det-thresh 0.6 --match-thresh 0.8 --class-names coco.namesCSV 里至少要有 frame_id、track_id、class_id、score、x1、y1、x2、y2 这 8 列。序列回放阶段用一段带遮挡、带交叉的监控片段看实际效果重点盯目标被遮挡后恢复的帧数和 ID 是否保持稳定。遮挡恢复这块有个可以落地的技巧在 track_buffer 里加一个“恢复窗口”。当轨迹未匹配帧数超过 buffer 一半但还没到上限时第二阶段匹配的 IoU 阈值适当放宽给被遮挡目标多一次找回机会。这个“半开窗”的设置比硬调参数效果好得多因为它只在最需要恢复的时刻降低匹配门槛不影响平时对误检的抗拒。还有一点是我个人的血泪经验多类别场景下验证时不要只看 MOTA把 IDF1 一起看了。MOTA 高不代表轨迹连贯两个目标交替频繁时 MOTA 可能还行但 IDF1 会很难看。如果 IDF1 掉得厉害优先查第二阶段的低置信度匹配和类别一致性而不是急着改检测阈值别让玄学调参耽误时间。说句实在话这套工程最初跑通时检测器的精度和跟踪器的关联质量都在线。但二次匹配恢复、遮挡存活唤醒这类细节拿到真实视频里回放几条长序列就会发现藏了不少问题。从那以后我每次改完推理或匹配代码都会强制走一遍单帧标定和短序列回放确认输出 diff 为零、ID 不闪变才敢继续往下做。希望帮到你。本文还有配套的精品资源点击获取