简介:面向边缘计算与智能视频分析开发者,这份项目包演示了在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 onnxruntimeonnxruntime不是导出的必需品,但用来在 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, )参数说明:
| 参数 | 值 | 说明 |
|---|---|---|
| imgsz | 640 | 必须和训练尺寸一致,Nano 上不建议 1280 输入 |
| dynamic | False | Jetson 场景固定 batch=1 最省心 |
| simplify | True | 清理多余 reshape 和 transpose,减少 TRT 报错 |
| opset | 12 | TensorRT 8.x 兼容性最好的档位 |
| half | False | 半精度放到 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_thres | 0.4 | 太高漏检,太低碎片框多 |
| iou_thres | 0.45 | NMS 抑制重合框 |
| max_track | 50 | 防止 CPU 过载 |
| match_thres | 0.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 > 0到cur_s < 0还是相反方向,然后分别累加到up_count和down_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关键点:
detect和track_count是 C++ 工程的输入输出,一般把前者封装成库,后者链接它;src是 Python 版的工具集;video_presentation.avi用于给客户或验收演示,不用来做性能测试;data.yaml记录了nc(类别数)和类别名,后处理里num_classes必须和它一致。
main.py和track_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 文件 → 创建IRuntime和IExecutionContext→ 为输入输出分配 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 报错; nvparsers和nvonnxparser用于解析模型,如果只加载 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统计结果时,关注preprocess、inference、postprocess三个阶段的耗时。在 Nano 上,一个 YOLOv8n FP16 engine,常见结果如下表:
| 模型/精度 | 输入尺寸 | 推理耗时 | 全流程 FPS |
|---|---|---|---|
| YOLOv8n + FP16 | 640x640 | 25~35 ms | 20~28 |
| YOLOv8s + FP16 | 640x640 | 55~75 ms | 10~15 |
| YOLOv8n + FP32 | 640x640 | 60~80 ms | 8~12 |
表里的 FP32 数据是用 trtexec 不加--fp16构建得到的,实际项目里 FP32 意义不大,因为 Nano 的 CUDA 核心本来不多,FP16 能减少一半显存带宽压力。
如果实测帧率远低于表中数据,先看两件事:
sudo jetson_clocks有没有执行,默认电源模式会锁在低频率;- 视频解码有没有成为瓶颈,
test.mp4如果是 1080P/30FPS,用 OpenCV 的VideoCapture解码会占掉一个 CPU 核心。
用tegrastats可以同时观察 CPU/GPU 频率和内存占用:
sudo tegrastats重点关注EMC_FREQ和GR3D_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:4x3x640x640NMS 放在 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_clocks | tegrastats 看 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))这段脚本里有两个容易出问题的点。第一,preprocess用cv2.resize会把原图直接拉伸成 640x640,而不是 letterbox。严格来说 YOLOv8 推理时要在保持宽高比的基础上填充灰度边,否则同一个目标在两种模型下的坐标天然对不上。理想的做法是让 PyTorch 的predict也走完全相同的 letterbox。第二,TensorRT 输出的坐标如果对应输入分辨率,需要按640 / img.shape[1]之类比例映射回原图,才能和pt_out的坐标比较。
如果确认坐标映射没问题,再对比类别。车辆和摩托车在样本不平衡时容易混淆,先看 NMS 之前的类别分数分布,再决定是调conf_thres还是补充训练数据。这个验证脚本不要放到生产代码里,只留在开发目录;当test.mp4上的计数结果异常时,优先用这个脚本排除模型转换层的问题,而不是去怀疑跟踪器的方向判断。
本文还有配套的精品资源,点击获取