Jetson Nano部署YOLOv8:TensorRT加速车辆与摩托车跟踪计数实战
2026/9/10 5:46:08 网站建设 项目流程

简介:面向边缘计算与智能视频分析开发者,这份项目包演示了在Jetson-Nano上借助TensorRT部署YOLOv8,实现车辆和摩托车的实时检测、跟踪与计数。它直面边缘设备算力有限与实时性要求高的矛盾,适合需要将深度学习模型落地到嵌入式平台的算法工程师、竞赛选手或研究人员。压缩包共35个文件、206.24MB,以C++源码(h/cpp/hpp)、Python脚本、Markdown/TXT文档、yaml配置及mp4/avi演示视频为主。源码工程覆盖训练、模型导出、推理与跟踪计数模块,文档说明环境准备和部署步骤,视频则方便先看效果再动手。资源按train、detect、track_count等模块组织,从数据准备、模型训练到TensorRT转换和边缘端部署均有完整流程;同时提供C++与Python两种调用方式,便于对照学习。目前已有390人学习下载,对于想快速跑通YOLOv8边缘端部署全流程的开发者,是一份实践性很强的参考。

1. 在 Jetson-Nano 上推 YOLOv8 的真实瓶颈:TensorRT 不是可选项

把 YOLOv8 检测模型塞进 Jetson Nano 时,第一个打击往往来自帧率。在 Nano 的 Maxwell GPU 上直接跑 PyTorch 推理,一个 640x640 的 YOLOv8n 都很难稳定到 15 FPS,更不用说在视频流里同时做车辆和摩托车的跟踪计数。TensorRT 在这里不是加速选项,而是让项目能跑起来的必要条件:通过层融合、精度校准和 kernel 自动调优,把模型变成针对当前 GPU 高度特化的执行计划,推理延迟能压到 PyTorch 的 1/3 甚至更低。

这个项目正好落在边缘计算里最常见的场景:用固定摄像头统计双向车流中的车辆和摩托车数量。适合两类人——一类是要在资源受限设备上交付算法的工程师,想知道 ONNX 导出到 engine 构建之间有哪些坑;另一类是刚接触 YOLOv8 部署的学生,需要一份能直接复现的完整流程。下面从模型转换讲起,最后落到 C++ 和 Python 两套代码怎么选。

2. 模型准备:从 YOLOv8 权重到 TensorRT 引擎的完整链路

在接触 main.py 之前,先把离线的模型转换跑通。这一步决定了后续推理的准确率和实时性,也是项目里最容易返工的地方。整个链路是 best.pt → best.onnx → best.engine。

2.1 环境版本匹配与依赖安装

Jetson Nano 的 JetPack 镜像已经带了 CUDA、cuDNN 和 TensorRT。安装 Python 侧依赖时,最大的原则是不要用 pip 任意升级系统 TensorRT。JetPack 4.6 系列自带 TensorRT 8.2,对应的 PyTorch 需要从 NVIDIA 提供的预编译 wheel 安装,而不是 PyPI 上的最新版。先确认基础环境:

python3 -c "import tensorrt; print(tensorrt.__version__)" nvcc --version

如果 TensorRT Python 包没有装,可以在 JetPack 的 apt 源里安装:

sudo apt install python3-libnvinfer-dev

然后安装训练与导出的依赖。这里有一个容易踩的坑:ultralytics 最新版对 torchvision 的版本有硬性要求,在 Nano 的老 Python 环境里经常装不上。建议把 PyTorch 版本固定到 1.10 或 1.11,再配合对应的 torchvision。项目里的requirements.txt如果直接写,可以用如下命令安装:

pip3 install ultralytics==8.0.0 onnx onnxruntime

onnxruntime不是导出的必需品,但用来在 PC 上先验证 ONNX 的输入输出 shape,能节省大量在 Jetson 上反复构建 engine 的时间。参数说明:

  • ultralytics负责加载权重、导出 ONNX、训练和验证;
  • 固定大版本,避免 API 变动导致export_onnx.py里的调用方式失效。

我一般会在 PC 上把导出和验证做了,再把 ONNX 拷到 Nano 上构建 engine。Nano 的 CPU 构建 engine 很慢,一个 YOLOv8n 可能要十几分钟。

2.2 导出 ONNX:C2f 结构与动态轴的坑

YOLOv8 的 backbone 使用了 C2f 结构,它把输入分成两路,经过多个 Bottleneck 后拼接,再走一层卷积。在 PyTorch 里看起来是几个模块嵌套,导出 ONNX 后会被展开成一串算子,TensorRT 解析时看到的是多个卷积和拼接。这意味着导出的 ONNX 不会像网络结构图那样保留清晰的 C2f 边界,调参时要接受这一点。

项目里的export_onnx.py核心逻辑一般是这样:

from ultralytics import YOLO model = YOLO("runs/train/exp/weights/best.pt") # 换成训练好的权重 model.export( format="onnx", imgsz=640, dynamic=False, simplify=True, opset=12, half=False, )

参数说明:

参数说明
imgsz640必须和训练尺寸一致,Nano 上不建议 1280 输入
dynamicFalseJetson 场景固定 batch=1 最省心
simplifyTrue清理多余 reshape 和 transpose,减少 TRT 报错
opset12TensorRT 8.x 兼容性最好的档位
halfFalse半精度放到 TensorRT 构建阶段做

导出后会得到 best.onnx。用 onnxruntime 检查一下输出 shape:

import onnxruntime as ort sess = ort.InferenceSession("best.onnx") for inp in sess.get_inputs(): print(inp.name, inp.shape) for out in sess.get_outputs(): print(out.name, out.shape)

车辆和摩托车两类时,输出一般是[1, 6, 8400],其中 6 = 4 个坐标 + 2 个类别概率,8400 是 640x640 下三个尺度特征图铺平后的 anchor 数量。如果输出是[1, 8400, 6],后处理逻辑要对应调整,这是 YOLOv8 不同导出方式最常见的差异点。

2.3 用 trtexec 或 Python API 构建 FP16 engine

构建 engine 有两种方式,项目文档prepare_jetson.md里通常会提供命令。推荐先用 trtexec 做一次构建,它能直接输出每个算子的耗时,方便确认瓶颈。

cd /usr/src/tensorrt/bin sudo ./trtexec \ --onnx=/home/nano/traffic_count/best.onnx \ --saveEngine=/home/nano/traffic_count/best.engine \ --fp16 \ --workspace=2048

如果 TensorRT 版本新一点(8.5 以后),--workspace已经被--memPoolSize取代:

sudo ./trtexec \ --onnx=best.onnx \ --saveEngine=best.engine \ --fp16 \ --memPoolSize=workspace:2048

参数说明:

  • --fp16开启半精度,Nano 的 GPU 对 FP16 有硬件加速,这是延迟下降的主要来源;
  • --workspace--memPoolSize限制构建时的显存申请,Nano 只有 4GB 内存共享,给 2GB 比较稳妥;
  • 如果不想用命令行,也可以在 Python 里用tensorrt.Builder构建,源码的src目录里可能已经有封装。

构建成功后,用下面的代码验证 engine 能正常加载:

import tensorrt as trt logger = trt.Logger(trt.Logger.WARNING) with open("best.engine", "rb") as f: runtime = trt.Runtime(logger) engine = runtime.deserialize_cuda_engine(f.read()) print("engine loaded:", engine.name)

这里值得留意的是,engine 包含了网络结构和权重,部署时不需要再带 ONNX 和训练权重。几个文件的大小差异很大:best.pt 通常在几十 MB,best.engine 往往比 ONNX 稍大一点,但拷贝到设备后只能用 TensorRT 加载,不能用 PyTorch 加载。

3. 车辆/摩托车跟踪计数的实现逻辑:从检测框到过线统计

模型推理只是拿到了一帧的检测框,要完成计数,必须把前后帧的框关联成轨迹,再和一条预先画好的虚拟线比较位置。这套逻辑集中在track_count目录下,是项目的核心。

3.1 从 TensorRT 输出到检测框的后处理

YOLOv8 的输出是每个 anchor 的原始预测值,需要经过 sigmoid、解码、NMS 三步才能变成可用的矩形框。在 Nano 上 IOU 和置信度阈值设置很关键,阈值太高容易漏掉远处的小目标,阈值太低会产生大量碎片框,给后面的跟踪器带来压力。

import numpy as np def decode_outputs(pred, conf_thres=0.4, num_classes=2): # pred 形状可能是 (1, 6, 8400),先改为 (8400, 6) pred = pred.transpose(0, 2, 1).squeeze(0) # (8400, 6) boxes = pred[:, :4] scores = pred[:, 4:] cls_scores = scores.max(axis=1) cls_ids = scores.argmax(axis=1) mask = cls_scores > conf_thres boxes = boxes[mask] cls_ids = cls_ids[mask] cls_scores = cls_scores[mask] return boxes, cls_scores, cls_ids

说明:

  • 这里假设 YOLOv8 输出坐标是中心点加宽高,shape 是(1, 6, 8400),所以先转置到(8400, 6)
  • 类别分数取 max 而不是直接索引,是因为输出可能包含多个类别,车辆和摩托车要分开统计;
  • conf_thres参数后处理阶段可以单独调整,但最终要以跟踪计数结果为准,建议先用 0.4 跑一遍测试视频。

坐标解码部分,如果导出的 ONNX 已经包含解码逻辑,上面拿到的boxes就是原图坐标。如果拿到的还是归一化状态,需要乘以输入图片尺寸。识别这一点的方法是随机取一个框,打印坐标看是否大于 1。

NMS 可以用torchvision.ops.nms,在 Nano 上处理 8400 个候选框时有一定 CPU 开销。更快的办法是用 TensorRT 的 EfficientNMS 插件把 NMS 放进 engine,但那样会把输出格式改了,后处理也要跟着改,这个放到性能调优部分再展开。

3.2 跟踪器选择:IOU/卡尔曼/ByteTrack 的取舍

项目没有用复杂的特征重识别模型,因为 Nano 的算力预算不允许。常见做法是轻量卡尔曼滤波加匈牙利匹配,也就是 ByteTrack 的基本思路。检测框在前后帧的移动用卡尔曼预测,匹配代价用 IoU 和中心点距离的加权值。

class TrackState: def __init__(self, box, cls_id, track_id): self.bbox = box # x,y,w,h self.cls_id = cls_id self.track_id = track_id self.hit_streak = 0 def iou_cost(prev_box, cur_box): # 计算两个框的 IoU,返回 1 - IoU 作为匹配代价 ...

参数层面的选择:

  • IOU 匹配适合车辆这种刚体目标,前后帧位移不大;
  • 摩托车和车辆重叠时会互相遮挡,可以放宽置信度阈值,用低分框进行二次匹配,这是 ByteTrack 的关键改进;
  • 对帧率较低的视频(低于 10FPS),IOU 容易断轨迹,需要把匹配半径调大。

在 Nano 上,我一般会把跟踪器设计成每帧最多跟踪 50 个目标,超过就丢弃得分最低的,避免在大量车辆进入画面时 CPU 占用过高。调整参数时可以参考下面这组初始值:

参数推荐值说明
conf_thres0.4太高漏检,太低碎片框多
iou_thres0.45NMS 抑制重合框
max_track50防止 CPU 过载
match_thres0.6匈牙利匹配阈值,越小越容易分裂轨迹

3.3 虚拟线计数实现:draw_line 与计数逻辑

计数的核心是一根线。utils/draw_line.py的作用是让人用鼠标在视频流上点两个点,然后把线段坐标保存到配置文件里。到 main 里,判断目标是否穿越这条线。

def cross_line(prev_pt, cur_pt, line_p1, line_p2): # 向量叉积符号变化表示跨线 x1, y1 = line_p1 x2, y2 = line_p2 prev_s = (x2 - x1) * (prev_pt[1] - y1) - (y2 - y1) * (prev_pt[0] - x1) cur_s = (x2 - x1) * (cur_pt[1] - y1) - (y2 - y1) * (cur_pt[0] - x1) return (prev_s > 0 and cur_s < 0) or (prev_s < 0 and cur_s > 0)

说明:

  • 叉积的符号代表点在直线的哪一侧,前后两帧符号相反说明这条轨迹穿过了线;
  • 要统计双向车流,可以检查是prev_s > 0cur_s < 0还是相反方向,然后分别累加到up_countdown_count
  • 车头方向会先经过车身中心点,中心点跨线即可计数,不需要整个框越过。

需要给同一个 track_id 只计一次数,否则车辆在线附近来回抖动会导致多次计数。代码里一般维护一个counted_ids集合,或者给每个轨迹打一个counted=True的标记。对于摩托车和车辆分方向计数,结构可以设计成:

counts = { "vehicle": {"up": 0, "down": 0}, "motorcycle": {"up": 0, "down": 0}, }

帧数足够多以后,跟踪器偶尔会丢失 ID,这会让同一种目标被重复计数。解决思路是:检测到轨迹跨越时,再取该轨迹最近 5 帧的类别众数作为最终类别,而不是只看当前帧的检测结果,这样能减少类别跳变带来的误计。

4. 项目源码结构与双语言部署:Python 快速验证 vs C++ 生产落地

拿到项目压缩包后,不要急着跑 main.py。先理清文件之间的关系,不然会被src目录、track_count目录混合的 Python/C++ 代码绕晕。这个仓库其实包含了两套部署方案:Python 用于快速验证,C++ 用于正式运行。

4.1 源码文件地图

项目根目录解压后结构大致如下:

train/ # 训练相关 export_onnx.py # PyTorch 权重转 ONNX main.py # Python 推理 + 跟踪计数 data.yaml # 训练数据配置(类别名) requirements.txt # Python 依赖 src/ # Python 后处理/跟踪工具 test.mp4 test_1.mp4 test_2.mp4 # 测试视频 video_presentation.avi # 效果展示视频 utils/ draw_line.py # 在视频上画虚拟线并导出坐标 docs/ prepare_jetson.md # Jetson 部署步骤 train.md # 训练步骤 detect/ # C++ 端模型加载与推理 track_count/ # C++ 跟踪计数主模块 include/ src/ CMakeLists.txt

关键点:

  • detecttrack_count是 C++ 工程的输入输出,一般把前者封装成库,后者链接它;
  • src是 Python 版的工具集;
  • video_presentation.avi用于给客户或验收演示,不用来做性能测试;
  • data.yaml记录了nc(类别数)和类别名,后处理里num_classes必须和它一致。

main.pytrack_count/src/main.cpp分别对应两套入口,输入都是视频和 engine 文件,输出是叠加检测线、计数结果标注后的视频。

4.2 Python main.py 推理流程

Python 版本适合先跑通效果。整体流程:加载 engine → 读取视频 → 预处理 → 推理 → 后处理 → 跟踪 → 计数 → 画框 → 写视频。加载 engine 的代码可以直接参考项目src目录下的封装:

import tensorrt as trt import numpy as np import cv2 class TrtEngine: def __init__(self, engine_path): logger = trt.Logger(trt.Logger.WARNING) runtime = trt.Runtime(logger) with open(engine_path, "rb") as f: self.engine = runtime.deserialize_cuda_engine(f.read()) self.context = self.engine.create_execution_context() self.bindings = [] self.outputs = [] def infer(self, input_blob): # 这里省略 buffer 申请和 copy 细节 ...

推理时一个常见的坑是输入图片的预处理:YOLOv8 要求 BGR 转 RGB、除以 255、再转成 NCHW float,很多人在这一步直接把 OpenCV 的 BGR 图喂进去,导致检测结果完全错乱。正确的做法是:

def preprocess(img, size=640): img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) img = cv2.resize(img, (size, size)) img = img.astype(np.float32) / 255.0 img = np.transpose(img, (2, 0, 1)) # HWC -> CHW return np.ascontiguousarray(img[None])

这里的np.ascontiguousarray是很多初学 TensorRT 的人忽略的细节。PyTorch 和 numpy 默认可能不是连续内存,而 CUDA 的 memcpy 要求输入输出 buffer 是连续的。

4.3 C++ main.cpp + CMakeLists 编译要点

C++ 版本部署时,main.cpp的结构是:读 engine 文件 → 创建IRuntimeIExecutionContext→ 为输入输出分配 GPU 显存 → 循环读取帧。TensorRT 的 C++ API 使用起来比 Python 版本繁琐,但显存控制更明确,适合 24 小时运行的监控服务。

项目的track_count/CMakeLists.txt需要同时找到 TensorRT、CUDA、OpenCV:

cmake_minimum_required(VERSION 3.10) project(track_count) set(CMAKE_CXX_STANDARD 14) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(CUDA REQUIRED) find_package(OpenCV REQUIRED) # Jetson 上 TensorRT 头文件和库位于系统路径 set(TENSORRT_INCLUDE_DIR /usr/include/aarch64-linux-gnu) set(TENSORRT_LIB_DIR /usr/lib/aarch64-linux-gnu) add_executable(track_count main.cpp) target_include_directories(track_count PRIVATE ${TENSORRT_INCLUDE_DIR} ${CUDA_INCLUDE_DIRS} ${OpenCV_INCLUDE_DIRS} ) target_link_libraries(track_count PRIVATE nvinfer nvinfer_plugin cudart ${OpenCV_LIBS} ) set_target_properties(track_count PROPERTIES CUDA_STANDARD 14 CUDA_STANDARD_REQUIRED ON )

说明:

  • TensorRT 的库名是nvinfer而不是tensorrt,编译时拼错是最常见的 CMake 报错;
  • nvparsersnvonnxparser用于解析模型,如果只加载 engine 文件,链接nvinfer就够了;
  • OpenCV 在 Jetson 上可能编译成libopencv_*系列,find_package(OpenCV REQUIRED)一般能正确找到。

编译命令:

mkdir build && cd build cmake .. make -j4 ./track_count ../test.mp4 ../best.engine

这里-j4是 Nano 上比较稳定的并行度,j8容易因为内存不足导致编译器被杀。

Python 版和 C++ 版的定位差别,可以参考下面的对照:

对比项Python 版C++ 版
启动速度慢,加载 numpy/trt 较重快,直接加载 engine
内存占用高,多一层 Python runtime低,显存可控
开发速度快,适合调算法参数慢,适合固定流程
适用场景实验验证、演示长时间运行、轻量交付

5. 性能调优与测试:帧率、准确率与常见问题的定位

完成了部署,接下来要回答两个问题:这套系统在 Nano 上到底能跑多快,结果到底准不准。测试的意义不只是为了汇报数据,更是为了在后续调整阈值时有个参照。

5.1 用 test.mp4 做基准测试与结果解读

项目自带的 test.mp4、test_1.mp4、test_2.mp4 是不同的监控场景。测试时要固定模型输入尺寸、FP16 开关、后处理阈值,否则数据之间不可比。我一般用下面的命令跑一个固定 300 帧的循环:

time python3 main.py \ --video test.mp4 \ --engine best.engine \ --num-class 2 \ --conf 0.4 \ --iou 0.45 \ --count 300

统计结果时,关注preprocessinferencepostprocess三个阶段的耗时。在 Nano 上,一个 YOLOv8n FP16 engine,常见结果如下表:

模型/精度输入尺寸推理耗时全流程 FPS
YOLOv8n + FP16640x64025~35 ms20~28
YOLOv8s + FP16640x64055~75 ms10~15
YOLOv8n + FP32640x64060~80 ms8~12

表里的 FP32 数据是用 trtexec 不加--fp16构建得到的,实际项目里 FP32 意义不大,因为 Nano 的 CUDA 核心本来不多,FP16 能减少一半显存带宽压力。

如果实测帧率远低于表中数据,先看两件事:

  1. sudo jetson_clocks有没有执行,默认电源模式会锁在低频率;
  2. 视频解码有没有成为瓶颈,test.mp4如果是 1080P/30FPS,用 OpenCV 的VideoCapture解码会占掉一个 CPU 核心。

tegrastats可以同时观察 CPU/GPU 频率和内存占用:

sudo tegrastats

重点关注EMC_FREQGR3D_FREQ,两个指标都要接近最高频率才能说明硬件跑满了。

5.2 优化手段:动态 batch、多 stream 和 GPU NMS

优化顺序是:先保证检测是唯一瓶颈,再考虑后端加速。

动态 batch 适合多路摄像头场景。把多个画面拼成一个 batch 输入 engine,推理耗时不会等比例增长,但 Nano 的显存只有 4GB,batch=4 时输入分辨率通常得降到 480。具体做法是在导出 ONNX 时设置dynamic=True,然后用 trtexec 指定--minShapes--maxShapes

./trtexec \ --onnx=best.onnx \ --saveEngine=best_b4.engine \ --fp16 \ --minShapes=images:1x3x640x640 \ --optShapes=images:2x3x640x640 \ --maxShapes=images:4x3x640x640

NMS 放在 CPU 上会限制整个流程的下限。把 NMS 换成 TensorRT 的 EfficientNMS 插件后,后处理可以并入 engine,减少一次 D2H 拷贝,但输出格式会变成[1, 1, max_det, 7],第 4~6 列是类别、置信度、坐标,后处理代码需要同步改写。

5.3 常见故障定位

部署阶段最容易遇到的问题,按出现频率整理了一张表:

现象常见原因定位方式
trtexec 构建失败ONNX 算子 opset 太高缩小 opset 到 12,看日志里第一个 unsupported 算子
推理输出全是 0/NaN输入预处理 BGR/RGB 混淆先打印输出统计,再用单张图片对比 PyTorch
帧率上不去没有开 jetson_clockstegrastats 看 GR3D_FREQ
视频卡顿但 GPU 使用率低OpenCV 解码瓶颈换成硬件解码 v4l2src ! nvvidconv
计数重复轨迹丢失后 ID 重置检查跟踪器删除未命中帧的策略
main.py 闪退CUDA context 没有释放在进程退出前调用 del engine,并显式释放 binding

定位用单帧调试脚本最有效,下一章我会说明怎么快速对比 PyTorch 和 TensorRT 的输出。

6. 实用技巧:用一张图片快速验证 TensorRT 引擎输出差异

改后处理或换 TensorRT 版本后,判断有没有破坏模型行为,最直接的方法是把 PyTorch 模型和 TensorRT engine 放在同一个进程里,对同一张图推理,然后对比两类输出。这个脚本在项目调试阶段能省下大量看视频的时间。

核心思路是相同的前处理、相同的置信度阈值,把两者的检测框画在同一张图上,并计算匹配框的 IoU。如果 IoU 平均大于 0.8,说明 engine 转换没有破坏精度;如果接近 0,先检查输入预处理。

import torch import cv2 import numpy as np from ultralytics import YOLO from src.engine import TrtEngine img = cv2.imread("test.jpg") blob = preprocess(img, size=640) # 复用前面定义的函数 # PyTorch 推理 pt_model = YOLO("best.pt") pt_out = pt_model.predict(img, conf=0.4, imgsz=640, verbose=False)[0] # TensorRT 推理 engine = TrtEngine("best.engine") trt_raw = engine.infer(blob) trt_boxes, trt_scores, trt_cls = decode_outputs(trt_raw, conf_thres=0.4) # 把 pt_out 的框转成相同格式,计算 IoU # 这里省略 IoU 函数,直接打印重叠情况 print("PT boxes:", len(pt_out.boxes), "TRT boxes:", len(trt_boxes))

这段脚本里有两个容易出问题的点。第一,preprocesscv2.resize会把原图直接拉伸成 640x640,而不是 letterbox。严格来说 YOLOv8 推理时要在保持宽高比的基础上填充灰度边,否则同一个目标在两种模型下的坐标天然对不上。理想的做法是让 PyTorch 的predict也走完全相同的 letterbox。第二,TensorRT 输出的坐标如果对应输入分辨率,需要按640 / img.shape[1]之类比例映射回原图,才能和pt_out的坐标比较。

如果确认坐标映射没问题,再对比类别。车辆和摩托车在样本不平衡时容易混淆,先看 NMS 之前的类别分数分布,再决定是调conf_thres还是补充训练数据。这个验证脚本不要放到生产代码里,只留在开发目录;当test.mp4上的计数结果异常时,优先用这个脚本排除模型转换层的问题,而不是去怀疑跟踪器的方向判断。

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

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

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

立即咨询