YOLOX+ByteTrack目标跟踪实战:ONNXRuntime推理与C++/Python部署全解析

发布时间:2026/10/11 22:33:59
YOLOX+ByteTrack目标跟踪实战:ONNXRuntime推理与C++/Python部署全解析
简介这是一套基于OpenCV与ONNXRuntime部署YOLOX检测和ByteTrack跟踪的完整工程适合有一定深度学习与视觉开发基础的读者用来快速搭建目标检测加多目标跟踪流程。资源共53个文件约2.76MB包含14个Python源码、12个C源码、10个头文件以及3个说明文档其中py、cpp与h文件分别覆盖模型加载、预处理、推理和后处理txt与md则提供环境配置与使用说明。目前已有110人学习内容按Python与C双版本组织并附带ONNX模型方便对比两种语言在相同任务上的实现差异。通过学习可以掌握ONNXRuntime推理流程、YOLOX检测结果解析、ByteTrack的IoU匹配与卡尔曼滤波跟踪思路也可作为在嵌入式或实时系统上进行视觉任务开发时的参考工程。1. 目标跟踪不是目标检测的下一步为什么 YOLOXByteTrack 值得装进你的工程很多做图像处理项目的人走到一定阶段都会卡在同一步检测已经能出框了但视频里的目标没有 ID框在相邻帧之间对不上号。YOLOX 在检测精度和实时性之间平衡得很好ByteTrack 又不像 DeepSORT 那样依赖单独的 ReID 特征网络两个模型一前一后几乎是最容易落地到 C/Python 工程里的跟踪组合。这份资源把 ONNXRuntime 推理、YOLOX 检测、ByteTrack 跟踪、C 和 Python 两套源码打包在一起你不用再从模型导出、依赖拼装、参数调优开始折腾。适合要做视频监控、车流统计或者把算法搬上边缘设备的工程师。接下来按环境、模型、推理、避坑、移植验证的顺序把每个关键位置讲透。2. 环境与依赖ONNXRuntime 为什么是比 DNN 模块更好用的推理后端2.1 为什么不用 OpenCV DNN 而要单独引入 ONNXRuntime很多人会问OpenCV 自己不是有 dnn 模块能读 ONNX 吗为什么这个项目还要单独把 onnxruntime 打成依赖。我早期在 Windows 上用 OpenCV DNN 跑 YOLOX 时踩过一次很深的坑模型加载成功了但输出节点解析出来全是空后来才发现是 dnn 模块对某个自定义算子的解释和 ONNXRuntime 不一致。从此我养成了一个习惯只要模型是 ONNX 并且对速度有要求就优先用 ONNXRuntime 做推理OpenCV 只负责视频读写、图像预处理和绘制。这个包也是同样的分工OpenCV 做图像侧ONNXRuntime 做模型侧。选择 ONNXRuntime 还有一层原因它在 CPU、GPU、嵌入式设备上都能用同一套接口。Python 里改一行 providers 参数就能切换到不同的执行提供程序C 里对应的就是 OrtSessionOptionsAppendExecutionProvider 这类调用。模型文件不变代码结构不变对需要跨平台交付的项目来说维护成本会低很多。另一个实际好处是 ONNXRuntime 对动态输入形状的模型支持得比 OpenCV DNN 好而 OpenCV 的 dnn 模块处理动态 shape 时经常要靠环境去猜这也是很多项目最终放弃 DNN 模块的原因。2.2 压缩包内容与目录结构解压之后重点看这几个东西。目录/文件作用使用方式code-3/主代码Python 与 C 各放一边按 README 指引进入对应目录eigen-3.3.9.zipEigen 3.3.9 头文件库解压后加入 C include 路径onnxruntime/推理库的 include、lib 与动态库C 链接Python 侧按需安装README.md运行步骤、参数说明先通读一遍requirements.txtPython 依赖清单用 pip 安装这个包把依赖直接放在压缩包里的做法对内网开发很友好。我见过不少项目 README 写“自行安装 OpenCV”结果同事装出来的版本五花八门代码里用的函数在新版本里已经改名了。作者把 Eigen、ONNXRuntime 都锁好版本带进来实际是在帮你固定一套可复现的环境。所以拿到包之后第一件事不是去下载最新版依赖而是先按包内的版本跑通再考虑升级。我一般会把 onnxruntime/ 和 eigen-3.3.9.zip 解压到工程的 third_party 目录而不是散落在桌面或下载文件夹里。C 工程对相对路径很敏感你很难保证换一台机器后绝对路径还存在。把依赖收进工程目录是让项目能跟着代码走的起码习惯。2.3 Python 侧安装与版本冲突处理Python 侧安装原本不应该有太多花样但实际翻车率不低。最常见的问题是机器上已经装过 opencv-python 或 onnxruntime版本冲突后 import 时报错。建议的安装流程python -m venv venv # Windows: venv\Scripts\activate # Linux/macOS: source venv/bin/activate pip install -r requirements.txt python -c import onnxruntime, cv2; print(onnxruntime.__version__, cv2.__version__)requirements.txt 一般会把你要用的 numpy、opencv-python、onnxruntime 版本写在里面。有一个容易被忽略的细节requirements.txt 是为 Python 3.8 到 3.10 准备的如果你现在用的是 Python 3.12 或更新版本onnxruntime 的 wheel 可能不匹配。遇到这种情况不要硬装改用 conda 建一个 3.9 的环境最省事。如果是为了 GPU 推理把 onnxruntime 换成 onnxruntime-gpu但两者不能同时存在于同一个环境。我的经验是先执行pip uninstall onnxruntime onnxruntime-gpu -y再装你需要的那个避免遇到 ImportError 之外的诡异崩溃。2.4 C 侧依赖与 CMake 配置C 侧会相对繁琐一点。先说 Eigen这个包带了 eigen-3.3.9.zip 老版本是因为部分 C 依赖在构建时引用了特定版本的 Eigen 头文件。新版本不保证 ABI 兼容所以我建议严格使用包内版本。cmake_minimum_required(VERSION 3.15) project(yolox_bytetrack CXX) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(EIGEN3_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/third_party/eigen-3.3.9) set(ORT_INCLUDE_DIR ${PROJECT_SOURCE_DIR}/third_party/onnxruntime/include) set(ORT_LIB_DIR ${PROJECT_SOURCE_DIR}/third_party/onnxruntime/lib) include_directories(${EIGEN3_INCLUDE_DIR} ${ORT_INCLUDE_DIR}) link_directories(${ORT_LIB_DIR}) find_package(OpenCV REQUIRED) add_executable(yolox_bytetrack src/main.cpp) target_link_libraries(yolox_bytetrack PRIVATE ${OpenCV_LIBS} onnxruntime)这段 CMake 的逻辑是OpenCV 用 find_package 自动寻找ONNXRuntime 和 Eigen 手动指定路径。原因在于 ONNXRuntime 官方没有提供好用的 CMake config 文件手动指定反而更可控。编译之前记得把 onnxruntime.dll 复制到最终 exe 旁边否则运行时会在加载阶段直接报“找不到 onnxruntime.dll”。Windows 下还会要求安装 Microsoft Visual C 2015-2022 Redistributable (x64)否则程序可能在启动瞬间秒退这个在出现 0xc000007b 错误码时尤其明显。2.5 先跑通最小检测再上跟踪我拿到任何目标跟踪包都不会直接跑完整 demo。先做一次纯检测验证确认模型、依赖、后处理链路是通的再叠加 ByteTrack否则出现问题时你很难定位是检测的错还是跟踪的错。常见入口命令类似python demo.py --source test.mp4 --output out.mp4如果 README 提供了纯检测脚本就优先跑那个。验证标准很简单画面上应该能看到多个不同类别目标被框住框的位置和真实物体基本贴合不会有大量错位或空白帧。只要这一步通了环境的大坑基本就排掉了。3. YOLOX 的 ONNX 模型解析输入输出、letterbox 与坐标还原3.1 模型输入输出参数先看模型再写代码。用 onnxruntime 跑一个最简单的推理之前建议先打印一下模型的输入输出结构因为你从网上下载的 ONNX 可能是不同版本导出的输入输出命名不一定一样。用一个小脚本可以省很多事import onnxruntime as ort sess ort.InferenceSession(yolox.onnx, providers[CPUExecutionProvider]) for inp in sess.get_inputs(): print(input:, inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print(output:, out.name, out.shape, out.type)从常见导出来看YOLOX 输入是 [1,3,640,640]顺序是 NCHWRGB 图像像素值归一化到 0 到 1。输出通常合并成一个张量shape 是 (1, 8400, 85)。8400 这个数字来自三个检测层640 输入下 stride 8 的特征图是 80×806400stride 16 是 40×401600stride 32 是 20×20400三层加起来正好 8400。每个候选框有 85 维cx、cy、w、h、objectness、80 个类别的置信度。这里要注意一个版本差异有些导出脚本会在模型里把 sigmoid 一起做掉有些不会。如果你在后处理里又做了一次 sigmoid分数会变得很奇怪如果不做又可能得到负值。我的习惯是先不管公式直接打印几帧输出统计值看看数值范围是 0 到 1 还是负的再决定要不要加 sigmoid。这个判断比看文档可靠。3.2 letterbox 预处理YOLOX 训练时用的是等比例缩放加灰边填充推理时也必须保持一致。直接把宽屏视频压缩到 640×640会破坏宽高比特别是小目标性能下降很可观。letterbox 的代码看起来短但后处理时要用到它记录的两个东西缩放系数和填充偏移。def letterbox(img, new_shape(640, 640), color(114, 114, 114)): h, w img.shape[:2] r min(new_shape[0] / h, new_shape[1] / w) new_w, new_h int(round(w * r)), int(round(h * r)) img cv2.resize(img, (new_w, new_h), interpolationcv2.INTER_LINEAR) dw (new_shape[1] - new_w) / 2 dh (new_shape[0] - new_h) / 2 top, bottom int(round(dh - 0.1)), int(round(dh 0.1)) left, right int(round(dw - 0.1)), int(round(dw 0.1)) img cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, valuecolor) return img, r, (left, top)这段代码返回三个东西填充后的图、缩放比例 r、左上角填充偏移 (left, top)。后续做坐标还原时要同时用到它们。注意 top 和 left 这里是相对填充后图像坐标系的偏移不是除以 r 之后的偏移。很多人在这一步直接把 (left, top) 减掉实际上先减偏移再除 r 才对。值得多说一句的是 0.1 这个数。它是从 YOLO 系列代码里一直保留的习惯作用是避免浮点除不净时多出一个像素的边。如果不用它某些奇数尺寸的帧会导致 box 偏移一到两像素肉眼不易察觉但跟踪时 IoU 计算会被放大影响。3.3 后处理与 NMS模型输出是候选框不是最终结果这一点要反复提醒。后处理主要做四件事解码坐标、算置信度、按阈值过滤、NMS 去重。YOLOX 输出里的坐标是 cx、cy、w、h对应输入图像坐标空间。还原代码大致如下def postprocess(preds, ratio, pad_left, pad_top, conf_thre0.3, nms_thre0.45): preds preds[0] # (8400, 85) boxes preds[:, :4].copy() scores preds[:, 4] * preds[:, 5:].max(axis1) class_ids np.argmax(preds[:, 5:], axis1) keep scores conf_thre boxes, scores, class_ids boxes[keep], scores[keep], class_ids[keep] x1 (boxes[:, 0] - boxes[:, 2] / 2 - pad_left) / ratio y1 (boxes[:, 1] - boxes[:, 3] / 2 - pad_top) / ratio x2 (boxes[:, 0] boxes[:, 2] / 2 - pad_left) / ratio y2 (boxes[:, 1] boxes[:, 3] / 2 - pad_top) / ratio boxes np.stack([x1, y1, x2, y2], axis1) nms_idx cv2.dnn.NMSBoxes( boxes.tolist(), scores.tolist(), conf_thre, nms_thre) if len(nms_idx) 0: return [], [], [] nms_idx np.array(nms_idx).reshape(-1).tolist() return boxes[nms_idx], scores[nms_idx], class_ids[nms_idx]置信度是 objectness 乘以类别最大分数这一步不能省。有的实现会把 objectness 忽略只用类别分数这样会把大量背景框放进来跟踪器的“低分框匹配”逻辑会被噪声彻底带偏。cv2.dnn.NMSBoxes 在 OpenCV 4.5 之后返回类型不太稳定有的是 list有的是 tuple还有的会返回空。我一般会先包一层np.array(nms_idx).reshape(-1).tolist()避免在下一行做索引时报错。3.4 session 复用与推理循环ONNXRuntime 的 InferenceSession 创建时会加载模型、分配内存这个操作很重。一个常见错误是把 session 写在视频循环里一帧创建一个 session帧率直接掉到个位数。正确做法是循环外只创建一次循环内只调用 run。sess ort.InferenceSession(yolox.onnx, providers[CPUExecutionProvider]) while cap.isOpened(): ret, frame cap.read() if not ret: break img, r, (left, top) letterbox(frame) blob img[:, :, ::-1].transpose(2, 0, 1)[None] / 255.0 outputs sess.run(None, {sess.get_inputs()[0].name: blob})[0] dets postprocess(outputs, r, left, top)这里img[:, :, ::-1]是把 BGR 转成 RGB 的常见做法transpose(2,0,1)把 HWC 转成 CHW。除以 255 的目的是匹配训练时的归一化。如果模型导出时已经把归一化算子放进去了这里再除一遍就会出现所有 score 都偏低的现象。怎么判断还是那句先打印输出值范围。4. 把检测器接上 ByteTracktracker 的调用方式与参数调优4.1 ByteTrack 为什么不需要 ReID到了跟踪这一段很多新手会先想到 DeepSORT然后被 ReID 特征网络劝退。ByteTrack 的设计思路是反过来的它不区分“这是个什么人”只关心“这个框和上一帧的哪个框最像”。这里的“像”用一个简单的 IoU 或中心点距离就能衡量再配上卡尔曼滤波来预测下一帧位置。ByteTrack 的关键创新是保留低分框。传统做法里置信度低于阈值的框会被直接丢掉但这些低分框往往就是被遮挡或模糊的目标。ByteTrack 把它们收集起来在第一轮没有匹配上的轨迹里继续做匹配从而保住被短暂遮挡的目标 ID。这个策略在行人遮挡严重的监控场景里效果相当明显而且实现成本很低。4.2 在视频循环里集成 tracker开始写集成代码。要注意 tracker 的实例化位置它应该在循环外创建一次不能每帧 new。否则卡尔曼滤波的累积状态全被重置ID 会每帧从头编号。tracker BYTETracker(args, frame_rate30) while cap.isOpened(): ret, frame cap.read() if not ret: break img, r, (pad_left, pad_top) letterbox(frame) blob img[:, :, ::-1].transpose(2, 0, 1)[None] / 255.0 outputs sess.run(None, {sess.get_inputs()[0].name: blob})[0] dets postprocess(outputs, r, pad_left, pad_top) online_tlwhs, online_ids tracker.update(dets, frame.shape[:2]) for tlwh, track_id in zip(online_tlwhs, online_ids): x, y, w, h [int(v) for v in tlwh] cv2.rectangle(frame, (x, y), (x w, y h), (0, 255, 0), 2) cv2.putText(frame, fID:{track_id}, (x, y - 8), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2)tracker.update(dets, frame.shape[:2]) 的输入格式很关键。ByteTrack 原版实现里接收的 dets 通常是 [x1, y1, x2, y2, score] 的二维数组但因为不同 fork 的接口有差异有的接收 tlwh 格式。你打开项目里的 byte_tracker.py 看一眼 update 函数的 parse 部分就知道该传什么。拿不准的时候先用固定视频跑一次看报错信息里的尺寸提示比猜接口靠谱。我要提醒检测框坐标混用是跟踪里最常见的隐性 bug。如果后处理返回的是 xyxytracker 内部按 tlwh 解析矩形就会完全错误。为了统一常用做法是在调用 update 之前统一转成项目要求的格式并在转换处打印几帧验证。4.3 ByteTrack 参数调优byte_tracker 里有一批和效果直接相关的参数。以 ByteTrack 原版默认值为基准参数常见默认值调整方向track_thresh0.5高分框/低分框的分界调低让更多目标参与匹配match_thresh0.8匹配阈值调低匹配更宽松ID 切换也会更频繁frame_rate30需要和输入视频帧率一致max_time_lost30轨迹丢失帧数调大让长时间被遮挡的目标不消失实际调的时候我建议一次只动一个参数。跟踪效果很难量化同时动两个参数你无法判断是谁导致的 ID 跳变。先调 track_thresh观察低分框第二阶段匹配是否把遮挡后的目标找了回来再调 match_thresh观察是否出现同一目标两个 ID。还有一个会被忽略的地方frame_rate 不是随意填的。ByteTrack 用 frame_rate 计算一个轨迹在丢失多少帧后被判定结束。如果你视频是 25 帧参数填 30max_time_lost30 实际表示约一秒而不是你想要的一秒多一点。确定视频帧率最准的办法是用 OpenCV 读cap.get(cv2.CAP_PROP_FPS)不要靠眼睛猜。4.4 保存结果与可视化写到视频时注意输出尺寸和 fourcc 编码。常见组合是 mp4v 加 .mp4这个组合跨平台支持相对好。出现保存文件 0KB 或打不开时先检查这两项fourcc cv2.VideoWriter_fourcc(*mp4v) writer cv2.VideoWriter(out.mp4, fourcc, fps, (int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))))fps 用cap.get(cv2.CAP_PROP_FPS)不要直接写死 30除非你验证过。尺寸要和写入帧完全一致否则 writer 会写不进任何内容。另一个坑是最后忘了writer.release()导致文件没有正常收尾。把这行放在循环外面用 try/finally 包住养成习惯。可视化时 OpenCV 的 rectangle 和 putText 只接受 int 坐标。如果你把后处理里的 float 坐标直接传进去Python 会抛 TypeError 之类的报错。所以代码里我把 tlwh 四个值都用 int() 转了一遍这个小动作能省很多运行时的意外中断。5. 避坑排查从编译失败到跟踪跳 ID 的五个实际问题5.1 Eigen 头文件缺失或版本被顶掉现象C 编译时找不到eigen3/Eigen/Core或者在解开 Eigen 后运行出现矩阵相关的断言失败。原因包内自带的是 eigen-3.3.9.zip但有些系统或之前的工程里已经装过更高版本的 Eigeninclude 搜索顺序把新版本排到了前面或者 zip 根本没有解压。解决把 eigen-3.3.9 解压后统一放到 third_party/eigen-3.3.9在 CMakeLists 中手动指定并放在其他 include 路径之前。如果编译机之前安装过 Eigen直接在 CMakeLists.txt 里用 include_directories 的 BEFORE 参数强制优先使用包内版本include_directories(BEFORE ${PROJECT_SOURCE_DIR}/third_party/eigen-3.3.9)这样做的好处是同一份代码在不同机器上构建时使用的 Eigen 完全一致不会再出现“我本机能跑到服务器就崩”的经典问题。5.2 导入 onnxruntime 直接报 ModuleNotFoundError现象运行 Python demo 时提示ModuleNotFoundError: No module named onnxruntime或者能导入但在 InferenceSession 创建时崩掉。原因暂时未激活虚拟环境或者当前 Python 版本过新pip 装了不匹配的 wheel还有可能是同一环境里同时存在 onnxruntime 和 onnxruntime-gpu导入顺序导致冲突。解决回到项目目录激活虚拟环境然后统一重装先pip uninstall onnxruntime onnxruntime-gpu -y再按 requirements.txt 安装。如果 Python 是 3.11 及以上优先用 conda 建一个 3.9 的环境onnxruntime 对这个版本支持最稳。我见过的大多数翻车案例都发生在过新的 Python 版本上。5.3 检测框整体偏移现象检测框能框住目标但框整体偏左上或偏右下且偏移量随着目标位置变化。原因letterbox 的填充偏移在坐标还原时被忽略或写反。常见写法是只除 r 不剪偏移或者减偏移用的坐标是 (dw, dh) 而不是实际填充值。解决用一张固定尺寸的图像做验证在图上手动画一条参考线跑完检测后看框的偏移方向。如果偏左上说明忘了减填充偏移如果偏右下多半是符号写反。还原公式固定为x1 (cx - w / 2 - pad_left) / ratio y1 (cy - h / 2 - pad_top) / ratio把 pad_left 和 pad_top 都打印出来对照 letterbox 里的 left、top 值是否一致。这个方法比盯着画面猜快得多。5.4 目标 ID 频繁跳变现象同一行人走过画面ID 从 1 跳到 3 又跳回 1或者一个目标身上同时出现两个框。原因track_thresh 过低导致背景被当成目标match_thresh 设置不当导致匹配过松还有一些情况是 tracker 被错误地创建在循环里。解决先检查 tracker 实例化位置确保它在循环之外。再把 track_thresh 从 0.5 上调到 0.6过滤掉模糊背景框如果目标在遮挡后 ID 容易丢那就把 max_time_lost 调大到 60 试试。要判断是哪个参数造成的最佳方式是固定同一段测试视频记录每次调整前后的 ID 数而不是肉眼盯着画面。5.5 C 版运行时秒退或找不到模型现象编译成功但双击 exe 闪退或者控制台提示找不到 yolox.onnx / onnxruntime.dll。原因onnxruntime.dll 没有复制到 exe 目录模型路径是相对路径exe 所在目录和工程目录不一致也有可能是缺少 Microsoft Visual C 2015-2022 Redistributable。解决在 CMake 里加一段自动复制 dll 的脚本或者手动把 onnxruntime.dll 放到输出目录。模型路径用绝对路径先验证一次流程确认能跑之后再改回相对路径。如果闪退时错误码是 0xc000007b先确认编译配置是 x64且所有依赖库都是 x64 版本这个组合最容易在 Debug 模式下编译、Release 模式下运行时踩到。6. 用逐帧日志验证 C 移植结果Python 版跑通只是第一步。如果你最终要交付的是 C 服务最担心的是移植后检测框对不上。我的验证办法是把 C 和 Python 放在同一段视频上跑然后逐帧打印跟踪结果。字段格式保持一致printf(%d,%d,%.2f,%.2f,%.2f,%.2f\n, frame_id, track_id, x1, y1, w, h);两边各跑一遍输出到两个文件。比对时不用逐像素对齐重点看三类数据每帧检测框数量是否一致高置信度目标的 track_id 是否稳定同一目标在两个输出里的坐标差是否在可接受范围内。允许 1 到 2 像素的偏差那属于两次后处理浮点计算的正常误差超过 5 像素就需要回去检查 letterbox 的还原逻辑。可以用一个小脚本做对比把按 frame_id 聚合后的结果拉出来手动检查即可。常见做法是打印 100 帧里的 ID 总数和平均框数如果在某个场景下 C 少了几个框优先查 NMS 的返回类型解析因为 OpenCV 在不同版本里 NMSBoxes 的返回值结构有过变动。C 侧我一般会写std::vectorint indices; cv::dnn::NMSBoxes(boxes, scores, conf_thre, nms_thre, indices);这一步在两边的表现经常不一致值得最优先排查。性能方面如果 CPU 推理总是不够快先确认两个点一是 ONNX 是否固定了 640×640 输入动态维度会让 ONNXRuntime 每帧重新做内存规划二是会话选项里是否开启了线程数配置。C 里设置方式是这样Ort::SessionOptions session_options; session_options.SetIntraOpNumThreads(4); session_options.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, model_path.c_str(), session_options);SetIntraOpNumThreads 控制单次推理内部线程数一般和部署机器的物理核数一致并不是设得越大越快线程过多时线程切换开销反而会拖慢推理。ORT_ENABLE_ALL 让 ONNXRuntime 做完整的图优化这个选项对定长输入的模型收益明显。从那以后我每次做 ONNX 工程化时都强制走一遍固定流程先固定输入尺寸再打印输入输出形状再用一张测试图验证 letterbox 映射确认准确后才接 ByteTrack。这套习惯帮我省掉的调试时间比任何工具都多。希望也能帮到你。本文还有配套的精品资源点击获取