尧图精选

YOLOv8+ByteTrack+TensorRT:C++实时多目标跟踪部署实战

🕒 发布时间:2026/10/2 18:21:13 📁 来源:尧图网络
简介一套面向实际部署的YOLOv8ByteTrack目标跟踪C工程支持在Jetson系列嵌入式设备以及常规Linux x86_64服务器上编译运行适合有一定深度学习推理经验、希望在项目中快速接入高性能目标跟踪能力的开发者。压缩包总计64个文件体积约25.16MB代码以头文件、C源文件和CUDA内核文件为主分别27、14、5个配合CMake构建脚本、TensorRT插件、Python权重转换脚本、Markdown说明文档以及图片/视频演示素材兼顾可读性与工程完整性。已有163人学习下载。实现上参考tensorrtx完成pth到engine的转换将YOLOv8推理封装为动态链接库并与ByteTrack解耦针对跟踪场景对NMS和conf_thres做了调整可过滤指定类别作者还比较了CUDA与CPU后处理最终保留CPU版本以简化依赖、方便移植。代码采用模块化解耦设计既可作为独立演示项目也方便将检测与跟踪模块集成到现有系统显著缩短部署验证周期。1. 这是干什么的YOLOv8 检测、ByteTrack 跟踪与 TensorRT 加速一套能直接抄的 C 部署链路拿到标题里这个项目 zip等于拿到一套“实时多目标跟踪”的完整 C 工程骨架YOLOv8 出检测框ByteTrack 给连续帧里的同一个目标分配稳定 IDTensorRT 把模型编译成 GPU 可执行的 engine摆脱 Python 运行时和 PyTorch 依赖。它解决的是“检测能跑但跟不住目标”和“Python 推理太慢没法上实时”这两个典型问题。适合已经在 YOLOv8 上训练过自己的数据集、现在想在边缘设备或工控机上做客流统计、车辆计数、越线报警的开发者也适合想理解检测与跟踪如何衔接、TensorRT 工程如何组织的初学者直接拿去做二次开发。2. 先把链路拆开检测、跟踪、推理在每帧里各干哪些活2.1 选择 ByteTrack 而不是 DeepSORT省掉一个特征模型的部署成本标题里把 YOLOv8 和 ByteTrack 放在一起不是随便配对。经典 DeepSORT 需要在检测之外再跑一个 ReID 特征提取模型用来判断“这个框和那个框是不是同一个人”。放到 C 工程里等于多维护一份 ONNX、多跑一次推理、多调一个特征距离阈值部署体积和耗时都上去了。ByteTrack 的思路更便宜只用检测框本身。它把检测结果按置信度分成两批先用高置信度框和已有轨迹做一轮 IoU 匹配再用低置信度框去捞那些没匹配上的轨迹。刚被遮挡或者很模糊的目标检测置信度低但框位置往往还在ByteTrack 靠第二轮匹配把人留住。对行人、车辆这类近似刚体目标效果足够接近 DeepSORT但 C 实现里只需要卡尔曼滤波、匈牙利匹配和 IoU 计算不需要额外特征网络。在边缘设备上少一个模型就是少一块显存、少一段延迟这是选它的最大理由。2.2 一帧视频到一串跟踪结果的完整数据流从预处理、推理到 tracker update整个链路按顺序可以分成六段理解这段流程后看任何 C 检测跟踪工程都不会迷路第一段是取帧。本地视频和摄像头取流都可以关键是控制读取频率不要让取流线程把 CPU 占满。第二段是预处理。OpenCV 读进来的是 BGR 的 HWC 排布网络要的是 NCHW 的 float 张量所以要先做 letterbox 等比缩放、BGR 转 RGB、除以 255 归一化、再把 HWC 展开成 CHW。第三段是 TensorRT 推理输入张量是 1x3x640x640输出张量常见是 1x84x8400。第四段是后处理把这 8400 个候选位置解码成框过滤低置信度做 NMS 去重。第五段是跟踪把所有保留下来的框交给 ByteTrack 的 update 函数得到带 track_id 的结果。第六段是输出画框、计数、写日志。这里最容易被看漏的是 letterbox。很多工程直接 resize 到 640x640目标被拉宽拉扁检测框要么偏大要么漏检。正确做法是记录缩放比例 ratio 和填充像素 pad后处理还原坐标时再算回去。第六段输出时所有坐标都应该回到原图尺度而不是停留在 640x640 坐标系。2.3 张量排布与设备内存边界C 里最容易出问题的地方C 版和 Python 版最大差异不在语言而在内存管理。Python 里 numpy 转 torch 张量顺手就做了C 里 OpenCV 的 cv::Mat 是 HWC 排布TensorRT 要的是 NCHW中间少了任何一步推理结果都是错的。输入张量的内存布局必须严格按 NCHW 排。比如 1x3x640x640内存里先是第一通道的 640x640 个 float再是第二通道最后是第三通道。OpenCV 的 Mat 数据是 BGR 交织存储直接 memcpy 进去等于把通道全混在一起。常见做法是写一个 CUDA kernel 在 GPU 上完成 BGR 转 RGB、归一化和 HWC 转 NCHW这样只需要一次 HostToDevice 拷贝。如果图省事在 CPU 上做每帧多花 3 到 5 毫秒帧率立刻掉下来。输出张量同样要搞清楚排布。YOLOv8 导出的 ONNX 输出通常是 [1, 84, 8400]84 4 个框坐标加 80 类得分8400 是三个尺度特征图上的候选总数。看代码时优先确认这一行很多“推理结果全乱”的翻车都是把 8400 误解成 6400 或者把通道顺序读反造成的。2.4 TensorRT 到底快在哪不是只有低精度还有算子融合TensorRT 的加速不是简单把 PyTorch 换成 C 接口就能解释的。它会把网络转换成一张计算图做三件核心优化。第一是算子融合把卷积、偏置、激活合并成一个大 kernel减少 kernel 启动次数。第二是内存复用推理过程中的中间 tensor 直接复用一块显存池省掉反复 malloc 和 free 的开销。第三才是低精度推理FP16 和 INT8 能减少显存带宽消耗对边缘设备尤其明显。这也是为什么标题要把 C 和 TensorRT 绑在一起。C 侧可以直接调用 TensorRT 的 C API规避 Python 解释器开销还能精细控制 CUDA stream 和显存池。整个链路里帧率瓶颈经常不在 GPU 计算而在 CPU 和 GPU 之间的数据搬运。后面第 3 章先讲模型怎么转再给一个最小 C 推理骨架把这条链路从命令到代码完整打通。3. 模型转换与 C 推理从 pt 到 engine 的四步与三个易错点3.1 先用官方导出脚本拿到 ONNX参数与版本注意事项拿到项目之后第一步是把 YOLOv8 的 pt 权重转成 ONNX。常见做法是直接用 ultralytics 仓库里的 export.py省去手写网络结构cd ultralytics python export.py \ --weights yolov8n.pt \ --include onnx \ --opset 12 \ --imgsz 640导出脚本会把网络结构和权重固化到 ONNX 里。这里有两个参数值得注意。opset 12 是 TensorRT 兼容性比较好的版本号太低表达不了部分算子的语义太高则依赖较新的 ONNX 解析器。imgsz 必须和后续 TensorRT 构建时的输入尺寸一致否则 engine 建出来后输入输出尺寸对不上C 侧又要改代码。如果训练过自己的数据集把 yolov8n.pt 换成你训练产出的 best.pt 即可导出过程不变。导出后建议先拿 onnxruntime 跑一遍确认输出 shape 是否符合预期。常见输出名是 output0shape 是 [1, 84, 8400]如果你的类别数不是 80第二个维度会变成 4 类别数。这个数字要记牢后面 C 后处理代码里的常量全靠它。3.2 trtexec 建 engine 的最小命令动态 shape 的 min/opt/max 怎么填ONNX 只是中间格式TensorRT 需要把 ONNX 编译成当前 GPU 架构下的 engine。最快的方式是用官方自带的 trtexec 命令行工具trtexec \ --onnxyolov8n.onnx \ --saveEngineyolov8n.engine \ --fp16 \ --minShapesimages:1x3x640x640 \ --optShapesimages:1x3x640x640 \ --maxShapesimages:4x3x640x640这段命令做了三件事加载 ONNX按 FP16 精度优化网络把可变的 batch 维度写成 1 到 4 的范围。minShapes 和 maxShapes 是动态 shape 的上限和下限optShapes 是 TensorRT 重点优化的尺寸。部署时实际发的是 1 路视频就把 opt 也填成 1性能最优。如果将来要接 4 路视频流才需要把 maxShapes 放宽到 4。需要注意trtexec 只会保存 engine不会保存构建日志。如果构建失败建议加上 --verbose 参数重新跑一次看具体是在哪个算子报错。另一个血泪经验是TensorRT engine 和 GPU 架构强绑定在本机编译的 engine 换到另一块显卡上经常直接加载失败。正确做法是在每台部署设备上分别执行一次构建或者把 trtexec 命令写进部署脚本不要跨设备拷贝 .engine 文件。3.3 C 读 engine 并执行推理一份最小骨架代码拿到 engine 后C 侧要做的事情很直接读文件、反序列化、建 context、执行推理。下面是一份最小加载骨架// tensorrt_engine_loader.cpp #include NvInfer.h #include fstream #include memory #include vector #include iostream class Logger : public nvinfer1::ILogger { void log(Severity severity, const char* msg) noexcept override { if (severity Severity::kWARNING) std::cerr msg std::endl; } }; std::shared_ptrnvinfer1::ICudaEngine loadEngine( const std::string enginePath) { std::ifstream file(enginePath, std::ios::binary); file.seekg(0, std::ios::end); size_t size file.tellg(); file.seekg(0, std::ios::beg); std::vectorchar blob(size); file.read(blob.data(), size); file.close(); static Logger gLogger; auto runtime std::shared_ptrnvinfer1::IRuntime( nvinfer1::createInferRuntime(gLogger)); auto engine std::shared_ptrnvinfer1::ICudaEngine( runtime-deserializeCudaEngine(blob.data(), size)); return engine; }这段代码有几个要点。createInferRuntime 和 deserializeCudaEngine 是 TensorRT 的 C API和 Python 版的 TRT 完全不同不要混。engine 本质上是一段针对当前 GPU 编译好的二进制读取时必须一次性读入内存不能边读边解析。Logger 必须实现否则反序列化时某些警告信息会直接丢失。推理部分骨架如下// tensorrt_inference.cpp auto engine loadEngine(yolov8n.engine); auto context engine-createExecutionContext(); void* buffers[2]; cudaMalloc(buffers[0], 1 * 3 * 640 * 640 * sizeof(float)); cudaMalloc(buffers[1], 1 * 84 * 8400 * sizeof(float)); cudaStream_t stream; cudaStreamCreate(stream); // 假设 hostInput 和 hostOutput 已经准备好 cudaMemcpyAsync(buffers[0], hostInput, 1 * 3 * 640 * 640 * sizeof(float), cudaMemcpyHostToDevice, stream); context-setTensorAddress(images, buffers[0]); context-setTensorAddress(output0, buffers[1]); context-enqueueV3(stream); cudaMemcpyAsync(hostOutput, buffers[1], 1 * 84 * 8400 * sizeof(float), cudaMemcpyDeviceToHost, stream); cudaStreamSynchronize(stream);这份代码使用的是新版 TensorRT 的 IO 方式setTensorAddress 配合 enqueueV3。如果你的项目基于旧版 TensorRT常见写法是 context-enqueueV2(buffers, batchSize, stream)。两个 API 不能混用看代码时先确认头文件中引用的 TensorRT 版本。这里最容易被忽略的部分是 host 与 device 的内存布局通道数、高度、宽度任何一个不对推理结果都是随机数级别的错误。3.4 后处理从 [1,84,8400] 还原成坐标框计算公式与 letterbox 还原拿到原始输出后不能直接画框必须先把输出解码成标准坐标。YOLOv8 去掉 NMS 的输出布局通常是84 个通道里的前 4 个是 cx、cy、w、h后面 80 个通道是类别概率。8400 个候选位置按三个尺度排列前面的 6400 对应 stride 8中间 1600 对应 stride 16后面 400 对应 stride 32。// yolo_decode_sample.cpp // 输入 output: float*长度 1 * 84 * 8400 // 按输出排布读取第 i 个候选的 4 个坐标 const int numAnchors 8400; float cx output[i]; float cy output[i numAnchors]; float w output[i numAnchors * 2]; float h output[i numAnchors * 3]; // 找到该候选属于哪个 stride int gridSize 80; // 640 / 8 int stride 8; if (i 6400 1600) { gridSize 20; stride 32; } else if (i 6400) { gridSize 40; stride 16; } // 反算到输入图像坐标系 float x1 (cx - w / 2) * stride; float y1 (cy - h / 2) * stride; float boxW w * stride; float boxH h * stride;这段代码把候选索引映射到 stride再把网络输出的归一化宽高还原成输入尺寸的像素坐标。做完这一步还要按 letterbox 的参数还原到原图坐标否则画出来的框整体偏移。假设原图宽为 origWletterbox 后的缩放比例为 ratio左侧填充为 padX顶部填充为 padY还原公式就是float originalX1 (x1 - padX) / ratio; float originalY1 (y1 - padY) / ratio;这里的 padX 和 padY 必须在预处理时保存下来很多工程喜欢在预处理函数里临时算完就丢结果后处理时没有还原参数画出来的框自然对不齐。我的习惯是把 ratio 和 pad 放进一个结构体和帧数据一起传给后处理函数保证预处理和后处理用的是同一组数值。4. 把 ByteTrack 接进工程参数怎么调、状态怎么维护、目标 ID 怎么稳4.1 两次匹配的更新流程高置信度先行、低置信度兜底ByteTrack 的核心逻辑不是一堆公式而是一个很务实的策略先处理有把握的检测再用没把握的检测去捞快丢的轨迹。每帧进入 update 后第一步把当前帧检测框按置信度分成 high 和 low 两组。第二步用 high 组检测框和当前活跃轨迹做匈牙利匹配代价函数是 IoU。匹配成功的检测框直接更新对应轨迹的卡尔曼状态。第三步没匹配上的轨迹和 low 组检测框再做一轮 IoU 匹配这一轮的目标是找回那些刚被短暂遮挡的目标它们的检测置信度低但位置可能仍然有效。最后还活着的轨迹如果连续 track_buffer 帧都没匹配上就从轨迹池里移除。这种“两轮匹配”的结构意味着不能把检测结果直接当跟踪结果输出。输出轨迹必须经过匹配、激活、丢失、确认整个过程。项目中跟踪效果差第一嫌疑往往是 tracker 压根没跑只是把检测框转发出去第二嫌疑是 tracker 每帧新建轨迹池从未积累过历史状态。4.2 先定义好 C 数据结构检测框、轨迹状态与卡尔曼量在 C 工程里写跟踪器最忌一边写算法一边现场定义结构体。代码会越来越乱调试时根本没有抓手。建议先把两个结构体固定下来// tracker_types.hpp struct DetectionBox { float x1, y1, x2, y2; float score; int clsId; }; struct TrackState { int trackId -1; int frameId 0; bool activated false; bool lost false; int missedFrames 0; float mean[8]; // cx, cy, w, h, vx, vy, vw, vh float covariance[64]; };DetectionBox 可以同时复用做后处理输出和 tracker 输入避免在中间层再拷贝一份数据。TrackState 里的 mean 数组存的是卡尔曼滤波的 8 维状态量前 4 维是位置和宽高后 4 维是对应速度。ByteTrack 默认假设目标做匀速运动所以状态量里必须保留速度信息。有一个很容易踩的坑一个跟踪器实例和多个跟踪器实例的区别。ByteTrack 官方实现是全局一个 tracker 池不区分类别。如果项目里既跟踪人又跟踪车建议按类别分别创建 tracker 实例否则猫的轨迹被狗抢走ID 会乱到没法看。按类别拆分之后track_id 需要加类别偏移量保证画框时不同类别不会出现相同的 ID。4.3 track_buffer、match_threshold、det_thresh三个必调参数标题里既然点名 ByteTrack说明跟踪效果主要靠下面这张参数表调出来。项目交付时最常改的就是这三个值。参数常见设置作用调参方向det_thresh0.5区分高置信度和低置信度检测调高可减少误检但会漏掉严重遮挡目标match_threshold0.8判断两个框是否为同一目标的 IoU 阈值调高容易断轨调低容易串 IDtrack_buffer30轨迹允许丢失的最大帧数调大能扛住长时间遮挡但会延迟目标消失判断这三个参数相互牵连不要单独调一个。比如视频里行人频繁互相遮挡det_thresh 从 0.5 降到 0.4让低置信度检测更容易参与第二轮匹配同时 track_buffer 从 30 提到 50保证遮挡期间轨迹不被清理。反过来如果场景里大量误检框乱飘先把 det_thresh 提到 0.6再观察 match_threshold 是否需要放松。一个实用的调参流程固定 match_threshold 在 0.8跑一段 1 分钟视频记录 ID Switch 次数和丢失时长然后每 0.1 步进 det_thresh重复测试最后微调 track_buffer。每次只改一个变量否则出了效果都不知道是哪个参数换来的这种翻车我已经经历过很多次。4.4 把 tracker 放进推理线程每帧只 update 一次C 工程里最常见的跟踪器错误是线程模型没想清楚。有人把 tracker 放在推理线程里每帧调用又把 tracker 的 update 放在可视化线程里再调一次结果轨迹池状态被两个线程同时改ID 跳动得跟随机数一样。正确做法是让 tracker 跟随推理循环一帧推理完成、后处理拿到检测框后立刻调用 update。可视化线程只读取跟踪结果不做修改。代码结构可以是这样// pipeline_loop.cpp while (capture.read(frame)) { preprocess(frame, hostInput); inferOnce(engine, hostInput, hostOutput); decodeOutput(hostOutput, detections); auto tracks tracker.update(detections, frameId); drawTracks(frame, tracks); frameId; }这里的 frameId 要特别注意。不要用 capture 的当前帧数因为 seek 回退或视频暂停会让帧号倒退tracker 的卡尔曼状态会被时序回拨搞乱。更好的做法是使用系统时间戳或自增计数器保证 update 接收的帧号单调递增。很多半路项目“跟踪一会儿就集体消失”查到最后都发现是帧号没有单调递增导致轨迹池被反复重置。5. 性能与部署排查帧率上不去、ID 乱跳、显存报错的常见原因5.1 帧率上不去找瓶颈在预处理还是后处理现象TensorRT 推理看起来很快但整体帧率还是只有十几帧。原因推理网络本身不是瓶颈瓶颈往往在预处理和 NMS。预处理如果用 CPU 版 cv::resize 加 cv::split每帧要多花 4 到 6 毫秒。NMS 如果写在单线程循环里候选框一多CPU 占用直接拉满。解决把 letterbox、BGR 转 RGB、归一化、HWC 转 NCHW 合并进一个 CUDA kernel在 GPU 上完成。NMS 可以先用置信度阈值把候选框压到几十个再在 CPU 上跑普通 NMS压不住就上 TensorRT 自带的 EfficientNMS 插件。排查顺序建议是先关掉 NMS 和画框看推理本身耗时再把预处理和后处理逐个加回来哪一步加上去帧率骤降瓶颈就是它。5.2 目标 ID 频繁切换多录一段遮挡场景再排查现象行人一被遮挡再出现track_id 就变统计人数时会多算。原因最常见的是 det_thresh 太高。目标被遮挡时检测置信度跌破阈值低置信度框没有进入第二轮匹配轨迹直接丢失重新出现后只能创建新轨迹ID 自然就变了。另一个常见原因是 match_threshold 设得太大IoU 稍微偏一点就觉得不是同一个目标。解决先录一段包含典型遮挡行为的视频固定测试素材不要换着视频肉眼评估。然后按上一章说的流程把 det_thresh 降一档同时看 match_threshold 是否太紧。如果 ID 还是闪跳检查 tracker 的 missedFrames 是否在匹配成功后没有清零或者卡尔曼状态在更新时把宽高更新成了负值。负宽高的轨迹一旦接入后续匹配IoU 计算会直接失效。5.3 显存分配失败检查每帧是否在偷偷 malloc现象程序跑几十秒后突然报 cudaErrorMemoryAllocation。原因绝大部分情况是每帧推理时重复调用了 cudaMalloc显存碎片越堆越多最后申请不到连续块。还有一种是同时开了多个 CUDA stream 和多个 context每个 context 都要持有若干层中间 buffer显存消耗成倍上涨。解决把输入输出 buffer 分配在推理循环之外整个生命周期只分配一次。context 也尽量只创建一个不要在每帧里 createExecutionContext。如果必须并发处理多路视频流用 cudaStream 做并发而不是创建多个 context显存占用会小很多。5.4 engine 换一台机器就加载失败TensorRT 不是一次编译到处跑现象开发机上生成的 engine 拷贝到另一台 GPU 机器上反序列化直接报错连错误信息都看不懂。原因engine 是二进制计划文件和 GPU 架构、TensorRT 版本、CUDA 版本绑定。在不同代显卡之间几乎必然失效即使都是同品牌同系列TensorRT 版本不一致也可能反序列化失败。解决在部署机上重新跑一次 trtexec 构建命令不要试图跨机器拷贝 engine。如果部署机没有 trtexec可以把构建逻辑写进 C 程序里运行时检测到 engine 不存在或加载失败时自动用同一套 ONNX 现场构建。项目交付时建议把 ONNX 和构建脚本一起带上engine 当作缓存产物而不是交付物。5.5 跟踪框位置整体偏移letterbox 比例没有还原现象检测框大小基本准但位置整体偏左上或偏右下跟踪轨迹也跟着歪。原因预处理用了 letterbox后处理还原坐标时用的是直接 resize 的比例没有扣除填充区域。或者反过来预处理直接拉伸了图像后处理却按 letterbox 补边还原两边对不上。解决在预处理时把缩放比例 ratio、左右填充 padX、上下填充 padY 存到上下文里后处理统一走还原函数。不要在两处各写各的公式统一封装成 restoreBoxToOriginal 函数输入是网络坐标系下的 x1y1x2y2输出是原图坐标。排查时打印一组中间数值对比检测框中心是否落在画面正确位置比盯着画出来的框猜快很多。6. 用跟踪结果做点正事MOT 指标、越线与停留统计6.1 用 MOTA、IDF1 把调参效果量化肉眼判断“跟得还行”不叫验证。建议把跟踪结果导出成 MOT 格式的文本文件每行是一个有效轨迹框1, -1, 1, 706, 219, 22, 44, 0.92, -1, -1, -1这行字段依次是帧号、标注 ID、track_id、框左上角 x、y、宽、高、置信度、剩余三个占位。导出后用 MOTChallenge 的评测脚本算 MOTA、IDF1 和 ID Switch。调参前后各跑一次用这三个数字说话。MOTA 反映整体匹配质量IDF1 反映 ID 保持能力ID Switch 专门反映 ID 跳变次数。一次调参如果 IDF1 涨了 3 个点但 MOTA 没降说明方向对了。6.2 越线与停留时间统计把跟踪结果转成业务指标跟踪 ID 稳定后业务统计顺理成章。最常见的两个需求是越线计数和停留时长。越线只需跟踪每个 track_id 的框中心点累计值当中心点跨过预设线段且该 ID 第一次跨线时计数加一。停留时长维护一个 track_id 到首次出现帧号的 map当前帧减首现帧就是停留帧数。我的习惯是在工程里保留一套完整日志输出把每一帧的 track_id、框坐标、置信度都打印到文件。线上效果出问题时靠这套日志回放现场比在黑匣子里猜参数靠谱。这套 YOLOv8 加 ByteTrack 加 TensorRT 的组合做完正确性和性能两层验证后基本可以在真实场景里站稳脚。希望这一套调度思路能帮你在自己的项目里少走几步弯路。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联 返回资讯列表 →