YOLOX+ByteTrack部署指南:从ONNX导出到OpenCV实时跟踪
2026/9/23 1:29:22 网站建设 项目流程

简介:OpenCV与ONNXRuntime部署YOLOX+ByteTrack目标跟踪的资源包,面向希望落地目标检测与多目标跟踪的C++/Python开发者,提供一套从模型推理到跟踪关联的完整参考实现。包内共53个文件,包括14个Python源码、12个C++源码、10个头文件与若干说明文档,压缩包整体约2.76MB,并附带Eigen库便于依赖处理。方案以YOLOX完成目标检测,ByteTrack结合IoU匹配与卡尔曼滤波实现稳定在线跟踪,ONNXRuntime保障高效推理;C++与Python双版本覆盖模型加载、输入预处理、推理输出和后处理等关键环节,开发者可直接替换模型或调整参数适配自身场景,也可借此学习ONNX模型的转换与集成方法。随附3份md和3份txt说明,清晰讲明安装依赖、运行示例与代码结构。已有109人学习下载,适合研究OpenCV、ONNXRuntime及多目标跟踪技术的初中级开发者参考学习。

1. 把 YOLOX 检测交给 ByteTrack 之前,最难的不是模型而是数据流

一段视频流里要稳定追踪同一批人、车、物,最常见的组合就是 YOLOX 做检测、ByteTrack 做跨帧关联。把这两个算法接到 OpenCV 的帧循环上,再用 ONNXRuntime 跑张量计算,真正的复杂度不在网络精度,而在数据怎么流转:从 BGR 帧到 letterbox 输入图,从推理输出到解码后的检测框,从检测框到 track_id,每一步的参数错一个,结果就会从“偶尔跳 ID”变成“框全乱飘”。这套方案适合视觉应用开发者和算法工程化人员,拿到手之后先别急着跑 demo,把预处理、解码、关联三段的数据形状理清楚,后面所有问题都能定位。

2. 部署前先确认 YOLOX 的 ONNX 输出格式,这一步决定后端所有代码

2.1 YOLOX 解耦检测头导出后到底输出什么

YOLOX 的检测头是解耦的,训练时回归分支和分类分支分开输出,但在导出 ONNX 时官方脚本会做一个拼接操作,把三个尺度的特征图展开后合并成一个张量。默认导出得到的输出形状是[1, 8400, 85],其中 8400 是三个 stride(8、16、32)上的 anchor 总数,85 是cx, cy, w, h, obj_conf加 80 个类别分数。

这个格式和 YOLOv5 的导出结果很相似,但处理上有区别:YOLOX 的坐标是中心点形式,而且官方导出脚本已经把 stride 乘回去了,也就是说输出张量里的坐标已经是模型输入尺寸(比如 640×640)下的像素值,不再需要你手动乘 stride。很多人部署时报“框位置偏到角落”或者“NMS 出来全是重复框”,基本都是把坐标又乘了一次,或者把中心点坐标当成了左上角坐标。

另一个容易踩的点是 anchor 数量。如果用 P6 模型,比如 YOLOX-L 的 P6 版本,stride 会多一个 64,对应的输出形状会变成[1, 8500, 85],四种 stride 分别产生 6400、1600、400、100 个 anchor。代码里如果把 anchor 总数写死成 8400,P6 模型一跑就会崩溃或者输出全部错位,排查时第一件要做的事就是确认模型的输出维度。

2.2 用官方导出脚本生成验证用的 ONNX 文件

拿到 PyTorch 权重之后,用 YOLOX 仓库自带的导出脚本就能把模型转成 ONNX:

python tools/export_onnx.py \ -n yolox-s \ -c weights/yolox_s.pth \ --output-name yolox_s.onnx \ --opset 11

参数含义:-n指定模型变体,可选yolox-syolox-myolox-lyolox-x-c指向权重文件路径;--output-name控制输出文件名;--opset指定 ONNX 算子集版本,一般建议用 11,ONNXRuntime 对 opset 11 的覆盖最稳定。

导出完成后,不要直接拿去写推理,先用onnxruntime加载一下,打印输入输出信息:

import onnxruntime as ort sess = ort.InferenceSession("yolox_s.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)

这里打印出来的 shapes 是你后面写代码的依据。如果输出是[1, 8400, 85],解码段就按单张量处理;如果你用的是自己魔改过的导出流程,输出变成多个张量,那解码逻辑就要分开处理每个 head 的输出,不要按 85 列一次性取完。

2.3 输出形状、opset 和动态轴的适配关系

不同版本 YOLOX 代码的导出行为可能有细微差别,最常见的是输出列顺序不同。有的仓库把输出拼成cx, cy, w, h, obj_conf, cls_score...,有的会拼成cx, cy, w, h, cls_score..., obj_conf。前者在解码时取分类分数用[:, 5:],后者要取[:, 5:85]再单独拿最后一列。这种差异在代码里只差一个索引,但结果完全不对,建议在拿到模型后先用一张已知结果的图跑一次,把输出前几行打印出来人工确认。

opset 版本的影响也不可忽视。opset 9 到 11 对大多数部署场景没区别,但如果你后面要做量化或者用到较新的算子的自定义实现,建议导出时保持 opset 11 或 12。ONNXRuntime 1.10 以后对 opset 12 的支持已经很完善,太高反而可能遇到 provider 不支持的情况。

模型类型输出形状anchor 总数说明
YOLOX-S/M/L/X(P5)[1, 8400, 85]8400最常用,stride 8/16/32
YOLOX P6 模型[1, 8500, 85]8500stride 8/16/32/64
动态 batch 导出[None, 8400, 85]8400第一维可变,代码里要读实际值

3. 用 OpenCV 读帧、ONNXRuntime 推理、落到 ByteTrack 关联的最小 Python 链路

3.1 letterbox 预处理和坐标还原的对应关系

YOLOX 训练时的输入是正方形图片,推理时不能直接把任意尺寸的帧塞进去,需要做 letterbox 缩放。OpenCV 里这一步用cv2.resize配合手动算 pad 就能完成,不需要额外依赖:

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)) dw = (new_shape[1] - new_w) / 2 dh = (new_shape[0] - new_h) / 2 resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) return cv2.copyMakeBorder(resized, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color), r, dw, dh

后面返回的rdwdh是坐标还原的钥匙。推理得到的检测框是在 640×640 的 letterbox 图上的坐标,要还原回原始帧,需要先减去 padding 再除以缩放比例:

# 假设 box 是 [cx, cy, w, h],当前是模型输入尺寸下的坐标 cx = (cx - dw) / r cy = (cy - dh) / r w = w / r h = h / r

还原时最容易出错的是 padding 的计算方式。上面代码里dwdhcopyMakeBorder时分别分配给了左右和上下,还原时用的是同一个dwdh。如果你用了别的 letterbox 实现,比如把 padding 直接放在单侧,那还原时的偏移量要跟着改,否则框会整体偏移。

3.2 ONNXRuntime 会话初始化与推理调用写法

import cv2 import numpy as np import onnxruntime as ort so = ort.SessionOptions() so.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL so.intra_op_num_threads = 4 sess = ort.InferenceSession( "yolox_s.onnx", sess_options=so, providers=["CUDAExecutionProvider", "CPUExecutionProvider"], ) # 输入预处理:BGR -> RGB -> float32 -> /255 -> CHW -> NCHW frame = cv2.imread("frame.jpg") blob, r, dw, dh = letterbox(frame, (640, 640)) blob = cv2.cvtColor(blob, cv2.COLOR_BGR2RGB) blob = blob.astype(np.float32) / 255.0 blob = np.transpose(blob, (2, 0, 1))[None] # [1, 3, 640, 640]

providers列表的顺序决定了 ONNXRuntime 尝试加载的执行后端。CUDAExecutionProvider排在前面表示优先用 GPU,如果 CUDA 环境不可用,会回退到 CPU 而不是报错。intra_op_num_threads控制单个算子内部的线程数,CPU 推理时这个值对速度影响很大,设成 CPU 物理核心数的一半到三分之二通常效果最好,设多了反而会因为线程切换变慢。

推理和输出解析的部分:

outputs = sess.run(None, {sess.get_inputs()[0].name: blob}) pred = outputs[0][0] # [8400, 85] # 过滤低置信度框 conf = pred[:, 4:5] * pred[:, 5:].max(axis=1, keepdims=True) cls_id = pred[:, 5:].argmax(axis=1) valid = conf.squeeze() > 0.5 boxes = pred[valid, :4] scores = conf[valid].squeeze() cls_ids = cls_id[valid]

置信度计算用的是目标置信度乘类别最大分数,这样比单独用obj_conf或类别分数更接近训练时的正样本定义。阈值 0.5 是检测阶段的初始过滤,后面的 ByteTrack 还会再做一次高低分区分,所以这里放宽一点没关系。

3.3 decode 之后如何组装 ByteTrack 需要的输入格式

ByteTrack 的update接口只接收一个detections列表,每个检测对象至少要包含tlwh(左上角坐标加宽高)和score两个字段。从前面还原好的cx, cy, w, h转成tlwh就是一次坐标变换:

class Detection: def __init__(self, tlwh, score, cls_id): self.tlwh = tlwh self.score = score self.cls_id = cls_id detections = [] for i in range(len(scores)): cx, cy, w, h = boxes[i] x1 = cx - w / 2 y1 = cy - h / 2 detections.append(Detection([x1, y1, w, h], scores[i], cls_ids[i])) tracker = BYTETracker(frame_rate=30) tracks = tracker.update(detections, frame_id)

frame_rate参数会影响卡尔曼滤波中速度的初始化,视频帧率在 25 到 30 之间差别不大,但如果你的视频源是 15 帧或者 60 帧,建议传实际帧率。update内部会处理检测框与已有轨迹的关联、新轨迹的创建、丢失轨迹的年龄管理,返回的tracks列表里每个 track 带有track_idtlwh,直接画到帧上就是完整的跟踪可视化。

4. C++ 版本的 OpenCV + ONNXRuntime + ByteTrack 部署要点

4.1 用 VS Code 配置 C/C++ 环境并链接 ONNXRuntime 动态库

C++ 版本的最大门槛是环境配置。onnxruntime 学习资料大多是 Python 的,到 C++ 这里大家几乎都卡在动态库链接。最常见的坑是:头文件能找到,但链接时报cannot open file "onnxruntime.lib"或者运行时提示找不到 DLL。

Windows 上推荐用 CMake 管理依赖,不是在 VS Code 里手动配置c_cpp_properties.json去指定 include 路径就能解决一切,链接环节必须让 CMake 知道库文件在哪:

cmake_minimum_required(VERSION 3.10) project(yolox_tracker) find_package(OpenCV REQUIRED) set(ONNX_RUNTIME_DIR "D:/deps/onnxruntime-win-x64-1.16.3") include_directories(${OpenCV_INCLUDE_DIRS}) include_directories(${ONNX_RUNTIME_DIR}/include) link_directories(${ONNX_RUNTIME_DIR}/lib) add_executable(tracker src/main.cpp) target_link_libraries(tracker ${OpenCV_LIBS} onnxruntime)

target_link_libraries里写的onnxruntime会自动匹配onnxruntime.liblibonnxruntime.so,不需要手写完整库名。编译产物运行时,Windows 下要把onnxruntime.dll复制到 exe 所在目录,或者把lib目录加进系统 PATH,否则弹窗报错说找不到模块。这个问题和vscode配置c/c++环境的基础教程里常见的动态库缺失是一回事,本质是运行时搜索路径的问题,不是代码写错。

4.2 C++ 推理代码结构与内存管理

ONNXRuntime 的 C++ API 比 Python 繁琐,但结构是一致的。初始化时需要注意:Ort::Env每个进程只创建一个,多个Session可以共享同一个Env。Session 创建时如果路径类型不对,Windows 下必须用宽字符版本:

#include <onnxruntime_cxx_api.h> #include <opencv2/opencv.hpp> #include <vector> int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "yolox_tracker"); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel(GraphOptimizationLevel::ORT_ENABLE_ALL); #ifdef _WIN32 Ort::Session session(env, L"yolox_s.onnx", opts); #else Ort::Session session(env, "yolox_s.onnx", opts); #endif auto allocator = Ort::AllocatorWithDefaultOptions(); auto input_name = session.GetInputNameAllocated(0, allocator); auto output_name = session.GetOutputNameAllocated(0, allocator); std::vector<const char*> input_names = {input_name.get()}; std::vector<const char*> output_names = {output_name.get()}; // 输入张量构造 std::vector<int64_t> in_shape = {1, 3, 640, 640}; std::vector<float> in_data(1 * 3 * 640 * 640); Ort::MemoryInfo mem_info = Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value in_tensor = Ort::Value::CreateTensor<float>( mem_info, in_data.data(), in_data.size(), in_shape.data(), in_shape.size()); auto outputs = session.Run(Ort::RunOptions{nullptr}, input_names.data(), &in_tensor, 1, output_names.data(), 1); float* out_ptr = outputs[0].GetTensorMutableData<float>(); // out_ptr 的内容就是 [1, 8400, 85],按行解析即可 }

GetInputNameAllocated返回的是智能指针,input_names向量里存的是指针副本,使用时必须保证input_name这个临时对象还活着,也就是不能把它放在一个函数里返回后到外面再用。Session 的Run每次会分配新的输出张量,如果追求极致性能,可以提前准备好输出 buffer,但大部分场景下Run的开销不在张量分配,而在算子执行本身。

4.3 ByteTrack 在 C++ 里最容易写错的两处

C++ 实现 ByteTrack 和 Python 版逻辑完全一致,但有两个地方是 C++ 版独有的坑。第一是轨迹列表的迭代器失效问题。update过程中要对tracked_strackslost_stracksremoved_stracks三个列表做增删,如果边遍历边删除元素,迭代器会失效。建议做法是先用索引遍历,收集要删除的 track_id,循环结束后统一移除:

std::vector<int> to_remove; for (size_t i = 0; i < stracks.size(); ++i) { if (stracks[i].state == TrackState::Removed) { to_remove.push_back(i); } } for (auto it = to_remove.rbegin(); it != to_remove.rend(); ++it) { stracks.erase(stracks.begin() + *it); }

第二是卡尔曼滤波的协方差矩阵在 C++ 里容易初始化错。ByteTrack 里用的是 8 维状态量,协方差初始值的数量级会影响跟踪收敛速度。常见错误是把std::vector<float>当二维数组用,读到越界数据后协方差变成非正定矩阵,匈牙利匹配时cv::solve直接崩溃。正确做法是用std::array<std::array<float, 8>, 8>cv::Matx<float, 8, 8>这类定长容器,避免手动管理二维数据的内存布局。

对比项Python 实现C++ 实现
依赖加载pip 安装 onnxruntime手动下载动态库和头文件
Session 创建一行代码需管理 Env、SessionOptions、路径编码
跟踪器容器list 操作简单注意迭代器失效和内存布局
调试便利性打印堆栈清晰需要 gdb 或 VS 调试器

5. 从跑通到稳定运行:用 ID Switch 统计和参数顺序调优

5.1 用相邻帧 IoU 估算跟踪质量

一个跟踪系统跑起来之后,最直接的验证指标是 ID Switch,也就是同一个目标在跟踪过程中 track_id 发生跳变。严格计算 ID Switch 需要 ground truth,但工程上可以先做一个近似统计:如果同一个 track_id 在相邻两帧的框完全不重叠,大概率是发生了 ID 切换或者轨迹被错误继承。

def estimate_id_switches(frame_results): # frame_results: list,每个元素是 [(track_id, x1, y1, x2, y2), ...] switches = 0 for prev, cur in zip(frame_results[:-1], frame_results[1:]): cur_map = {tid: box for tid, box in cur} for tid, box in prev: if tid not in cur_map: continue iou = compute_iou(box, cur_map[tid]) if iou < 0.2: switches += 1 return switches

代码逻辑是逐个 ID 对比相邻帧的框,IoU 低于 0.2 就算一次跳变。这个值对快速移动目标偏保守,如果目标本身快速运动导致相邻帧重叠少,可以降到 0.1。先跑通再调参的顺序是:先确认检测阈值下 ID Switch 的数量级,再动 ByteTrack 的关联参数。

5.2 参数调整顺序和常见现象的对应关系

ByteTrack 核心参数有四个:track_thresh决定哪些检测框进入第一轮高置信度匹配,high_thresh控制新轨迹的激活条件,match_thresh是 IoU 关联阈值,track_buffer决定目标丢失后保留多久。调参顺序建议从track_thresh开始,先保证检测不过滤太多真目标,再看match_threshtrack_buffer的组合。

现象优先调参数调整方向
目标频繁丢 IDmatch_thresh调小,允许更低 IoU 关联
丢失后长时间不恢复track_buffer调大,增加保留帧数
一个目标多个 ID 并存high_thresh调大,避免低分框反复建新轨迹
轨迹串到其他目标上match_thresh调大,关联更严格

调参之后看两个指标:ID Switch 数量是否下降,以及轨迹的平均存活帧数是否上升。很多时候 ID Switch 低了但轨迹存活帧数也低了,这说明是“不关联”策略在起作用,不是真的跟踪变好。这两个指标要放在一起看,不能单看一个。实际项目里还有一种推荐做法:给 track 加一个lost_at字段,记录轨迹进入丢失状态的帧号,恢复时统计丢失时长。如果丢失时长超过track_buffer的一半且恢复后 IoU 很低,说明这个轨迹是靠宽 buffer 硬接回去的,下次时间碰撞会比卡 buffer 更稳定。这个“用丢失时长的中位数来预判下一次遮挡”的小技巧,在密集人群场景下比单纯调match_thresh有效,因为它直接反映当前场景的目标遮挡规律。

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

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

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

立即咨询