简介:基于YOLOv5与ByteTrack的实时视频分析系统完整工程包,面向智能监控、自动驾驶及边缘计算开发者,解决嵌入式平台多目标检测与跟踪的高效部署问题。压缩包共58个文件,以C++头文件与源文件为主,涵盖h/cpp/cc等代码约40余个,另有rknn模型、动态库so、构建脚本sh、说明文档docx/md/txt等,包体仅8.13MB,结构精简。系统采用三线程并行处理设计,在RK3588平台可达到60FPS流畅运行,目前已有120人学习下载。在智能监控中能实时识别并跟踪行人、车辆等目标,在自动驾驶中可辅助感知路况与障碍物。附赠资料包含部署指南、使用手册及案例说明,源码目录中包含完整的YOLOv5处理流程和ByteTrack跟踪实现,开发者可直接参考核心模块进行二次开发,也可借助构建脚本快速复现RK3588部署环境,有效缩短项目落地周期。
1. 三线程流水线与 RKNN 推理链的取舍
最初看这套 yolov5_bytetrack_rknn 工程时,真正吸引我的不是 YOLOv5 的 mAP,也不是 ByteTrack 的匹配机制,而是 main.cc 里那种把解码、推理、跟踪拆成三条流水线、各自递进的设计。在 RK3588 上跑目标检测的方案很多,但多数止步于单帧推理跑通,一旦接上 ByteTrack 做多目标跟踪,帧率立刻被拖动。根本原因在于跟踪器是状态敏感的——上一帧的卡尔曼预测和当前帧的观测更新必须严格按帧序执行,任何一帧阻塞都会打乱时间轴。这套系统用 rknnPool 把 NPU 推理、图像预处理、ByteTrack 更新解耦成三线程并行,让 YOLOv5 的检测输出像流水线一样持续喂给跟踪器,最终在 RK3588 上实现端到端 60FPS,这个数字包含了跟踪耗时,不是模型纯推理的帧率。对于要在嵌入式平台落地实时视频分析的工程师,这套工程的线程组织和 RKNN 调用方式值得逐个模块过一遍。
2. YOLOv5 与 ByteTrack 的算法形态与系统设计边界
2.1 从 YOLOv5 输出到 ByteTrack 输入的“两段式解码”
YOLOv5 在 coco_80_labels_list.txt 对应的模型上输出三个尺度的 feature map,分别是 80x80、40x40、20x20,每个特征点带 85 维向量——4 个框坐标、1 个 objectness、80 个类别得分。RKNN 推理拿到的是这些原始张量,不能直接把坐标交给 ByteTrack。postprocess.cc的职责,就是先把这些张量按 stride 换算回原图坐标,再做 NMS,生成跟踪器真正需要的检测框、得分和类别信息。
// postprocess.cc 片段 for (int i = 0; i < grid_h * grid_w * num_anchors; ++i) { float obj_conf = output[85 * i + 4]; if (obj_conf < obj_threshold) continue; int class_id = 0; float max_cls = 0.0f; for (int c = 0; c < num_classes; ++c) { if (output[85 * i + 5 + c] > max_cls) { max_cls = output[85 * i + 5 + c]; class_id = c; } } float score = obj_conf * max_cls; if (score < conf_threshold) continue; float cx = (output[85 * i] + dx) * stride; float cy = (output[85 * i + 1] + dy) * stride; float w = output[85 * i + 2] * stride; float h = output[85 * i + 3] * stride; // 框坐标、score、class_id 压入 candidates }这是一个典型的 YOLOv5 解码循环。obj_conf表示框里有目标的置信度,max_cls是类别概率最大值,二者相乘才是最终 score。cx、cy、w、h 必须乘以 stride 才能得到原图坐标系下的像素值。新手常见错误是让obj_threshold和conf_threshold取同一个值,导致大量低质量框进入后续流程。实际应该把obj_threshold放低到 0.25 左右,conf_threshold提到 0.4 以上,让后处理在把数据交给 ByteTrack 之前就开始过滤,减少无效输入。这里 NMS 按类别分组做,不同类别间不互相抑制,同位置的行人和车辆各自保留。
2.2 ByteTrack 的匹配策略:高置信度与低置信度两轮匹配
ByteTrack 的核心思想是:一帧里高于 0.9 和低于 0.5 的检测框都可能携带有效信息,关键是区分对待。高置信度框参与第一轮 IoU 匹配;低置信度框保留下来,在第二轮用 IoU 与尚未匹配的跟踪轨迹做二次关联,从而恢复被遮挡或短暂丢失的目标。只匹配高分帧的方案会让漏检目标直接断链,ByteTrack 的价值就在这个“第二落点”。
BYTETracker.cpp中维护的 STrack 列表就是这套逻辑的实现载体。涉及几个关键参数,直接决定跟踪行为:
| 参数 | 常见值 | 作用 |
|---|---|---|
| track_buffer | 30 | 目标在匹配失败后保持活跃的帧数 |
| track_thresh | 0.5 | 进入第一轮匹配的检测框阈值 |
| high_thresh | 0.6 | 激活新轨迹所需的置信度阈值 |
| match_thresh | 0.8 | 第一轮 IoU 匹配的相似度阈值 |
参数之间是联动的。track_buffer 调大,遮挡恢复能力增强,但 ID switch 次数也会上升;match_thresh 取 0.8 在车载前视场景比较合适,如果画面里目标间距近、互相遮挡频繁,可以降到 0.7。智能监控场景下目标密度高,我一般把 track_thresh 降到 0.4,减少漏报优先,代价是 ID 编号抖动范围变大。lapjv.cpp是关联核心,用 Jonker-Volgenant 算法求解线性指派问题,相比早期匈牙利算法的 O(n3) 实现,LAPJV 在 100x100 代价矩阵上明显更快,这正是每帧处理数百个检测框时需要的效率。
2.3 算法选型边界:为什么 ByteTrack 而不是 DeepSORT
ByteTrack 不依赖 ReID 分支,跟踪过程完全不提取外观特征,这是设计上的关键取舍。DeepSORT 需要在检测之外再跑一个 ReID 网络提取行人或车辆的外观向量,在 RK3588 这种嵌入式平台上意味着 NPU 资源被额外占用,而这个工程的 NPU 时间已经被 YOLOv5 的 INT8 推理吃掉了大半。跟踪器不做外观匹配,天然避免了三线程中的“外观特征线程”与非对齐的交叉依赖。自动驾驶场景中目标形变大、视角变化剧烈,ReID 特征往往不可靠,而 ByteTrack 的坐标轨迹约束在这种极端外观变化下依然稳定。
这套算法组合的适用边界也清晰:
- 固定视角智能监控:YOLOv5 检测准确度足够,ByteTrack 能应对遮挡恢复,是最佳性价比
- 移动车载摄像头:track_buffer 需要调到 45 帧以上,否则短暂遮挡后目标 ID 会丢
- 密集人群或车流:检测框之间 IoU 竞争激烈,match_thresh 需要下调到 0.7 附近
3. RKNN 模型转换与 C++ 部署的接线细节
3.1 rknn-toolkit2 将 YOLOv5 转为 RK3588 可用的推理模型
RK3588 的 NPU 不读 PyTorch 权重,从训练好的 YOLOv5 到板端运行,中间必须经过 ONNX 再转 RKNN。rknn-toolkit2(支持 RK3588 的版本)是做这步转换的标准工具,它有很多细节直接决定板端精度和运行性能。关键在于rknn.config的参数设置:
# rknn_convert.py 核心流程 from rknn.api import RKNN rknn = RKNN() # 配置目标平台为 RK3588,int8 量化 rknn.config( mean_values=[[0, 0, 0]], std_values=[[255, 255, 255]], target_platform='rk3588', quantized_dtype='int8', quantized_algorithm='normal' ) # 从 ONNX 导入 rknn.load_onnx(model='yolov5s.onnx') # 使用校准数据集做 int8 量化 rknn.build(do_quantization=True, dataset='dataset.txt') # 导出板端运行时需要的 .rknn 文件 rknn.export_rknn('yolov5s_rk3588.rknn') rknn.release()mean_values和std_values决定图像送入 NPU 前的归一化方式。写成[0,0,0]和[255,255,255]表示输入像素直接除以 255,映射到 0 到 1 区间,这与 C++ 端preprocess.cc中的处理必须严格对应。常见踩坑点是 dataset.txt 里校准图数量不足,少于 100 张会导致 int8 量化时激活值分布估计不准,实测 mAP 掉 3 到 5 个点。我会准备 200 张覆盖不同光照、不同目标密度的图片做校准集,量化效果稳定很多。转换完成后,模型文件、librknnrt.so运行时库、rknn_api.h头文件三者要版本匹配,否则板端加载模型时会直接报错。
3.2 CMake 构建与 NPU 库的链接关系
build-linux_RK3588.sh在板子上执行 cmake 构建,CMakeLists.txt要把 NPU 运行时库和板端硬件加速库都链接进来。这个工程的 CMake 配置有几点值得注意:一是链接rknn_api和rknnrt,这是 RKNN SDK 在板端运行的两个关键库;二是要链接 OpenCV 用于视频解码和帧处理;三是编译参数需要匹配 RK3588 的 ARMv8.2 架构。
cmake_minimum_required(VERSION 3.10) project(yolov5_bytetrack_rknn) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_FLAGS "-O3 -march=armv8.2-a+fp16 -ffast-math") include_directories(${CMAKE_SOURCE_DIR}/include) link_directories(${CMAKE_SOURCE_DIR}/lib) add_executable(yolov5_bytetrack_rknn src/main.cc src/preprocess.cc src/postprocess.cc src/rkYolov5s.cc src/rknn_utils.cc src/STrack.cpp src/BYTETracker.cpp src/kalmanFilter.cpp src/lapjv.cpp ) target_link_libraries(yolov5_bytetrack_rknn rknn_api rknnrt pthread opencv_world )-march=armv8.2-a+fp16让 RK3588 的 Cortex-A76 核心能用上 FP16 指令,对后处理中的浮点运算有加速效果。-ffast-math能拉开约 10% 的计算性能,但会改变浮点运算的舍入方式,对视觉应用影响不大。这里要注意include和lib路径的对应关系,工程里的librknnrt.so是板端运行时依赖,缺了它程序启动时就会报cannot open shared object file。
3.3 预处理接入 RGA 与 DRM 的硬件路径
preprocess.cc做的事不只是 resize。RK3588 的 RGA 硬件模块支持图像缩放、格式转换和旋转,用 RGA 替代 CPU 做 NV12 到 RGB 的转换,能省下大量 CPU 周期。drm_func.h处理 DRM 显存的分配与映射,让图像数据直接从显存进入 RGA,跳过 CPU 拷贝。
// preprocess.cc 中通过 RGA 完成缩放与 NV12 转换 rga_info_t src, dst; memset(&src, 0, sizeof(src)); memset(&dst, 0, sizeof(dst)); src.fd = input_fd; // 视频解码得到的 DMA-BUF fd src.rect = {0, 0, src_w, src_h}; dst.fd = reserve_fd; dst.rect = {0, 0, model_w, model_h}; // 触发 RGA blit,硬件完成 YUV→RGB + 缩放 rga_set_rect(&src.rect, 0, 0, src_w, src_h, src_w, src_h, RK_FORMAT_YCbCr_420_SP); rga_set_rect(&dst.rect, 0, 0, model_w, model_h, model_w, model_h, RK_FORMAT_RGB_888); int ret = rga_blit(&src, &dst, NULL); // letterbox pad 到 640×640 int x_offset = (model_w - resized_w) / 2; int y_offset = (model_h - resized_h) / 2;这段代码里,RK_FORMAT_YCbCr_420_SP是 NV12 像素格式,RK_FORMAT_RGB_888是模型输入格式,RGA 硬件一步完成转换和缩放。关键坑在于rga_set_rect的 stride 参数必须满足 16 字节对齐,否则图像会产生横向偏移。这个偏移在视觉上不容易察觉,但会让检测框整体右移或左移,跟踪结果出现系统性偏差。如果你发现 YOLOv5 检测的框位置总是偏的,先查 RGA 的 stride 配置,这比调跟踪器参数有效得多。
4. ByteTrack 状态机与 rknnPool 线程池的集成思路
4.1 rknnPool 的三线程调度机制
ThreadPool.hpp与rknnPool.hpp的组合是这套系统最核心的工程实现。rknnPool 内部维护三个逻辑阶段:主线程负责从视频流解码图像并推入输入队列;NPU 推理线程从队列取帧执行 rknn_run;推理完成后结果进入输出队列,另一个线程取结果做后处理并交给 ByteTrack。三个阶段严格按 FIFO 顺序推进,保证每帧数据的时序一致。
// rknnPool.hpp 的接口抽象 template<typename T_IN, typename T_OUT> class rknnPool { public: rknnPool(int thread_num, std::string model_path); ~rknnPool(); int push(T_IN input); // 主线程塞入待推理数据 T_OUT get(); // 主线程取出推理结果 private: ThreadPool* pool; std::queue<std::future<T_OUT>> results; };push只负责把帧放入队列,get阻塞获取最老的已完成推理帧结果。这样设计的目的有两个:一是主线程永远不直接等 NPU,而是把等待时间藏在 get 调用里;二是 FIFO 顺序保证了 ByteTrack 按帧序消费检测结果,卡尔曼滤波的前向预测不会乱序。
实际在 main.cc 里调用时,逻辑上保持在一条主循环里,但实际耗时被分摊到三个线程:
// main.cc 主循环 while (capture.read(frame)) { int ret = rknnPool->push(frame); // 主线程只做推送 auto det = rknnPool->get(); // 取之前某一帧的结果 // 后处理产生的 detections 交给 ByteTrack std::vector<STrack> output_stracks; byte_tracker->update(det, frame_id++); byte_tracker->get_tracked(output_stracks); }4.2 卡尔曼滤波与 LAPJV 匹配的工程实现
kalmanFilter.cpp里的滤波模型为每个跟踪轨迹维护一个 8 维状态向量:x、y、宽高比 a、高度 h,以及对应的速度分量。其中宽高比 a 在行人和车辆目标上相对稳定,即使目标在画面中发生透视变化,短期预测依然可靠。速度分量是隐状态,通过观测帧进行更新,不直接暴露给外部。
// kalmanFilter.cpp 中的状态预测核心 void KalmanFilter::predict() { _state = _transition_matrix * _state; _covariance = _transition_matrix * _covariance * _transition_matrix.transpose() + _process_noise; }_transition_matrix是标准匀速运动模型,这里没有加速度项,因为在视频帧间隔只有 16ms 的情况下,匀加速度对位置预测的改善远不如噪声项带来的不确定性。采用简单模型反而让滤波在目标突然减速或转向时不会产生过大的预测偏差。LAPJV 求解在BYTETracker.cpp中通过代价矩阵完成,代价定义为1 - IoU,匹配结果中代价小于match_thresh的对子才被接受为有效关联。
4.3 ByteTrack 状态管理与多线程安全的边界
STrack 的 state 字段维护了 New、Tracked、Lost、Removed 四种状态。每帧跟踪循环开始前,先清理上一帧的标志位,再根据本轮匹配结果迁移状态。多线程并发下,这里的线程安全边界要特别小心。
线程安全的边界要明确:YOLOv5 的检测推理可以在 worker 线程并发执行,但 ByteTrack 的 update 调用必须集中在一个线程顺序执行。原因是 STrack 内部有卡尔曼协方差矩阵和轨迹列表,这些状态依赖帧序,一旦并发写会破坏时序。正确做法是让主线程持有椎一跟踪上下文,后处理线程只把检测结果推入 std::queue,主线程从队列取数据再调用 ByteTrack 的 update。这样一来,三线程中的推理和后处理是流水线并行,而跟踪状态机则是单线程顺序推进,既保证了帧率,又保住了状态一致性。
5. 60FPS 调优与 RK3588 平台特定优化
5.1 用 DRM 与 RGA 彻底绕开 CPU 拷贝
RK3588 性能瓶颈往往不在 NPU,而在视频帧从解码器到模型输入的数据搬移。正确通道是视频解码输出 DMA-BUF,RGA 直接从 DMA-BUF 读取并缩放,完全不走 CPU 内存。
// DRM 显存获取 + RGA 硬件缩放 int drm_fd = drm_open(); int input_gem_fd = drm_prime_handle_to_fd(drm_fd, gem_handle, DRM_CLOEXEC, &dma_fd); rga_info_t src = {0}; src.fd = dma_fd; // 让 RGA 直接读显存 int ret = rga_blit(&src, &dst, NULL); // 硬件完成缩放/格式转换如果代码里出现 memcpy 或 cvtColor,说明走了 CPU 拷贝路径。一次 1920x1080 到 640x640 的 YUV 转 RGB,CPU 做要 8ms,RGA 只需要 0.9ms——这个差距在 60FPS 目标下就是 8 到 10 帧的差距。判断当前是否走了硬件路径,看 CPU 占用率:如果总 CPU 占用超过 200%,基本可以断定预处理在用 CPU 做像素操作。
performance.sh里有 RK3588 的 CPU 调频配置,这是另一个容易忽略的环节。RK3588 的默认调频策略在 NPU 负载高时会让 CPU 频率波动,影响后处理耗时。跑长时间性能测试前,先把 performance.sh 执行一遍:
# performance.sh 中的核心操作 for cpu in /sys/devices/system/cpu/cpu[0-7]/cpufreq do governor=$(cat $cpu/scaling_governor) if [ "$governor" != "performance" ]; then echo performance > $cpu/scaling_governor fi done将 A76 核心固定到最高频率,后处理的耗时波动会明显减少。但要注意:固定频率后整板功耗升高,长时间运行需要监测温度,RK3588 温度超过 85 度时会触发降频,帧率反而掉下去。
5.2 用 rknn_query 验证真实瓶颈
运行构建好的可执行文件时,输入参数可以指定模型路径和输入分辨率:
./yolov5_bytetrack_rknn yolov5s_rk3588.rknn 640 640 demo.mp4 3这个命令的最后两个参数分别指定推理线程数和输入分辨率。运行后观察三段时间指标:
| 指标 | 合理区间 | 说明 |
|---|---|---|
| preprocess_time | < 1ms | RGA 无 CPU 拷贝时的正常表现 |
| inference_time | 8-15ms | 单帧 NPU 推理时长 |
| postprocess_time | 0.5-2ms | 包含 NMS 与坐标解码 |
如果 inference_time 超过 15ms,用rknn_query查 NPU 核心分配。RK3588 有 3 个 NPU 核心,但 640x640 输入下开满 3 核并不总是更好——核间同步开销会在小分辨率模型上抵消并行收益,实测 2 个核是甜点。
5.3 跟踪参数在嵌入式上的快速收敛步骤
我建议的调参顺序:
- 把
track_thresh与后处理的conf_threshold对齐,保证高质量检测都进入第一轮匹配 - 观察 ID switch 率,目标稳定但 ID 频繁变化时,把
match_thresh从 0.8 提到 0.85 - 目标遮挡后丢失,
track_buffer从 30 调到 45,但注意轨迹保留时间增加会占用匹配耗时 - 出现鬼影轨迹时,
high_thresh从 0.6 提到 0.7,抑制低置信度框的轨迹激活 - 最后跑 performance.sh 固定频率,实测端到端帧率,用结果的剩余余量决定是否再加一路视频流
这个顺序在智能监控和车载前视场景都适用。先保障检测质量,再调跟踪匹配,最后看并行效率——三线程流水线的帧率上限,取决于你能否把每一环的耗时压到 16ms 以内。RK3588 上的 60FPS 不难触及,难的是让 YOLOv5 的检测框和 ByteTrack 的轨迹 ID 在持续高帧率下保持一致。
本文还有配套的精品资源,点击获取