ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

YOLOX+ByteTrack部署指南:从ONNX导出到OpenCV实时跟踪

YOLOX+ByteTrack部署指南:从ONNX导出到OpenCV实时跟踪 简介OpenCV与ONNXRuntime部署YOLOXByteTrack目标跟踪的资源包面向希望落地目标检测与多目标跟踪的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 是三个 stride8、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 总数写死成 8400P6 模型一跑就会崩溃或者输出全部错位排查时第一件要做的事就是确认模型的输出维度。2.2 用官方导出脚本生成验证用的 ONNX 文件拿到 PyTorch 权重之后用 YOLOX 仓库自带的导出脚本就能把模型转成 ONNXpython tools/export_onnx.py \ -n yolox-s \ -c weights/yolox_s.pth \ --output-name yolox_s.onnx \ --opset 11参数含义-n指定模型变体可选yolox-s、yolox-m、yolox-l、yolox-x-c指向权重文件路径--output-name控制输出文件名--opset指定 ONNX 算子集版本一般建议用 11ONNXRuntime 对 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/XP5[1, 8400, 85]8400最常用stride 8/16/32YOLOX 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), interpolationcv2.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, valuecolor), r, dw, dh后面返回的r、dw、dh是坐标还原的钥匙。推理得到的检测框是在 640×640 的 letterbox 图上的坐标要还原回原始帧需要先减去 padding 再除以缩放比例# 假设 box 是 [cx, cy, w, h]当前是模型输入尺寸下的坐标 cx (cx - dw) / r cy (cy - dh) / r w w / r h h / r还原时最容易出错的是 padding 的计算方式。上面代码里dw和dh在copyMakeBorder时分别分配给了左右和上下还原时用的是同一个dw和dh。如果你用了别的 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_optionsso, 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(axis1, keepdimsTrue) cls_id pred[:, 5:].argmax(axis1) 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_rate30) tracks tracker.update(detections, frame_id)frame_rate参数会影响卡尔曼滤波中速度的初始化视频帧率在 25 到 30 之间差别不大但如果你的视频源是 15 帧或者 60 帧建议传实际帧率。update内部会处理检测框与已有轨迹的关联、新轨迹的创建、丢失轨迹的年龄管理返回的tracks列表里每个 track 带有track_id和tlwh直接画到帧上就是完整的跟踪可视化。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.lib或libonnxruntime.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, Lyolox_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::vectorconst char* input_names {input_name.get()}; std::vectorconst char* output_names {output_name.get()}; // 输入张量构造 std::vectorint64_t in_shape {1, 3, 640, 640}; std::vectorfloat in_data(1 * 3 * 640 * 640); Ort::MemoryInfo mem_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value in_tensor Ort::Value::CreateTensorfloat( 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].GetTensorMutableDatafloat(); // out_ptr 的内容就是 [1, 8400, 85]按行解析即可 }GetInputNameAllocated返回的是智能指针input_names向量里存的是指针副本使用时必须保证input_name这个临时对象还活着也就是不能把它放在一个函数里返回后到外面再用。Session 的Run每次会分配新的输出张量如果追求极致性能可以提前准备好输出 buffer但大部分场景下Run的开销不在张量分配而在算子执行本身。4.3 ByteTrack 在 C 里最容易写错的两处C 实现 ByteTrack 和 Python 版逻辑完全一致但有两个地方是 C 版独有的坑。第一是轨迹列表的迭代器失效问题。update过程中要对tracked_stracks、lost_stracks、removed_stracks三个列表做增删如果边遍历边删除元素迭代器会失效。建议做法是先用索引遍历收集要删除的 track_id循环结束后统一移除std::vectorint 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::vectorfloat当二维数组用读到越界数据后协方差变成非正定矩阵匈牙利匹配时cv::solve直接崩溃。正确做法是用std::arraystd::arrayfloat, 8, 8或cv::Matxfloat, 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_thresh和track_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有效因为它直接反映当前场景的目标遮挡规律。本文还有配套的精品资源点击获取
返回列表