简介:面向计算机视觉与算法部署工程师,该项目以RTMPose人体姿态估计算法为例,完整演示如何借助TensorRT在NVIDIA GPU上完成模型导入、优化与实时推理。内容从RTMPose原理、TensorRT层融合与精度校准机制,到模型导出转换、推理代码实现及性能对比,覆盖部署全链路,适合希望提升模型落地效率的开发者参考。压缩包共16个文件、约63.6MB,包含5个C++源文件与4个头文件构成的核心推理工程,以及2个engine序列化模型文件、Visual Studio工程配置(sln/vcxproj)、Python辅助脚本和说明文档,结构清晰便于直接编译调试。目前已有246人学习下载。通过本实战项目,可掌握TensorRT部署RTMPose的完整工程架构,获得可在实际场景中运行的推理代码与模型,并了解精度-速度权衡及常见部署问题的排查方法,为其他姿态估计项目的优化部署提供可复用的思路。
1. 用 TensorRT 部署 RTMPose:算法部署不只是把 ONNX 扔给 trtexec
做直播流人体姿态估计的工程师大都遇到过这种场景:模型在 PyTorch 里跑得很欢,一上线上环境就被帧率要求打脸。要保证 1080p@25fps 下的实时推理,算法部署这一步省不掉。这套工程就是干这个的——用 NVIDIA TensorRT 把 RTMPose 人体姿态估计算法完整部署到 GPU 上:前面挂 RTMDet 检测器出人框,后面接 RTMPose 出 17 个关键点,整个链路用 C++ 工程组织起来,拿到就能编译运行。适合正在做视频监控、体育分析、人机交互的部署工程师,也适合想找一份完整 TensorRT C++ 推理脚手架的人。很多刚接触 TensorRT 的人以为模型转换完就结束了,实际跑起来才发现,预处理、后处理、坐标还原才是真正耗时间的地方。
2. 先看模型再看引擎:RTMPose 选型理由与 TensorRT 优化边界
2.1 为什么 RTMPose 适合工程部署:从 Heatmap 到 SimCC
RTMPose 属于自顶向下(Top-down)的人体姿态估计算法:先检测出画面里的人,再对每个人做关键点回归。它的输出头用的是 SimCC(Simulated Coordinate Classification),不是传统的关键点热图(Heatmap)。传统 Heatmap 方法输出的是一个 H×W 的二维高斯热图,后处理要在这个热图上找峰值,还要做高斯峰值偏移修正,精度和速度两头都受限制。SimCC 的思路是把二维热图拆成 x、y 两个方向的一维分类问题,每个关键点在水平方向和垂直方向各输出一组分类得分,再用 softmax 求期望得到亚像素级坐标。这个改动带来的直接收益是输出特征图尺寸大幅缩小,计算量减了,精度却没有明显掉,反而在 COCO 数据集上超过了不少同体量的 Heatmap 方法。
RTMPose 的模型结构也不是从零设计的。它用 CSPNeXt 作为骨干网络,neck 部分用 PRA(Parallel & Re-param Aggregation)做多尺度特征融合,整体结构和实时检测器 RTMDet 同源。这意味着检测器和姿态估计器可以共用一套预处理约定,部署的时候少踩很多坑。工程里把 RTMDet 和 RTMPose 放在同一个 sln 下,不是随意组合,而是因为这两个模型的导出配置、输入输出格式、前后处理方式都是配套设计的。
下表是我拆 RTMPose 时整理的 Heatmap 与 SimCC 输出对比,能直观看出为什么部署场景更吃 SimCC 这套设计:
| 对比项 | Heatmap | SimCC |
|---|---|---|
| 输出形状 | K×H×W 二维热图 | x、y 两个一维分类向量 |
| 输出尺寸 | 与输入分辨率强相关 | 输入 / stride,通常很小 |
| 坐标后处理 | 找峰值 + 偏移修正 | softmax 期望 |
| 精度损失 | 峰值量化有误差 | 亚像素,误差更小 |
| 部署友好度 | 显存占用高 | 显存和带宽都省 |
2.2 TensorRT 优化手段:层融合、FP16 和动态 shape 的边界
TensorRT 对模型的加速主要来自三个层面:层融合、精度校准、内核自动调优。层融合把 Conv + BN + ReLU 这类相邻算子合并成一个 kernel,减少 kernel 启动开销和显存中间张量的读写;精度校准是让模型在 FP16 或 INT8 下运行,用 Tensor Core 的吞吐换精度上有限的变化;内核自动调优则是 TensorRT 在构建 engine 时对每个算子做 benchmark,选择当前 GPU 上最快的实现。
层融合对 RTMPose 这种以卷积为主的结构收益很明显,因为 CSPNeXt 里大量 1×1 和 3×3 卷积串在一起,融合后 kernel 启动次数能削掉一半以上。但有几个结构是 TensorRT 不太擅长的:动态 shape、非连续内存访问、以及带有大量维度变换的 op。RTMPose 的 SimCC 头在 PyTorch 里是若干矩阵乘和 reshape,导出到 ONNX 后如果解析器处理得不好,会产生一些额外的 Transpose 和 Gather 节点,这些节点在 GPU 上跑不出什么性能,还容易成为延迟黑匣子。所以部署时尽量让输入尺寸固定,不要给 TensorRT 太多动态 shape 的自由度,否则它在优化阶段要跑很多轮 benchmark,生成的 engine 可能还比固定 shape 版本慢。
FP16 是 TensorRT 部署里性价比最高的配置。RTMPose 和 RTMDet 都是纯 CNN,没有对精度特别敏感的算子,开 FP16 基本不掉点。INT8 就需要校准数据集了,如果手头没有和线上分布一致的图像,不建议贸然上 INT8,实测里因为校准集偏差导致关键点偏移的案例不少。我的习惯是:FP16 先跑通,延迟不够再考虑 INT8。
2.3 两级流水线:检测器先出框,姿态网络再定点
Top-down 方案决定了部署链路是两级的:RTMDet 在全图上做检测,输出每个人体框;RTMPose 只处理框内的局部图像,输出关键点。这两级不是独立运行的,框的质量直接影响姿态精度,RTMDet 漏检了人,后面姿态网络再快也白搭。
具体数据流是:原图 → 缩放到 RTMDet 输入尺寸(通常是 640×640)→ 推理 → 后处理得到人体框 → 根据框裁剪原图区域 → 缩放到 RTMPose 输入尺寸(COCO 训练规格是 256×192)→ 推理 → SimCC 解码出关键点 → 关键点坐标从姿态输入图映射回原图。每个环节都有对应的工程代码:rtmdet.cpp 负责检测推理和后处理,rtmpose.cpp 负责姿态推理和解码,inference.h/cpp 封装 TensorRT 引擎的加载和推理,utils.cpp 放图像预处理和坐标换算的工具函数。
两级流水线在性能上的含义是:一帧画面里如果检测到 N 个人,RTMPose 就要跑 N 次,这一步很容易成为瓶颈。工程里可以做的事有两个方向:一是把 N 个人的姿态推理合成一个 batch 跑一次,适合场景里同时出现较多人的情况;二是减少检测框的冗余,比如对同一个人的多个重叠框做 NMS 时把阈值调严一些。后面性能验证部分我会细说这个折算关系。
3. 把工程拆开看:从 ONNX 导出到 C++ 推理链路
3.1 文件清单与模块划分:sln 里每个文件是干什么的
拿到压缩包后先别急着编译,花十分钟把文件结构看清楚。工程是 Visual Studio 解决方案,核心文件在仓库根目录,python/model 目录放的是 Python 侧模型转换相关脚本。文件与职责对应关系如下:
| 文件 | 职责 |
|---|---|
| rtmpose_tensorrt.sln / .vcxproj | VS 工程入口和编译配置 |
| inference.h / inference.cpp | TensorRT 引擎加载、上下文创建、推理执行 |
| rtmdet.h / rtmdet.cpp | RTMDet 检测器推理与后处理 |
| rtmpose.h / rtmpose.cpp | RTMPose 姿态网络推理与 SimCC 解码 |
| utils.h / utils.cpp | 图像预处理、letterbox、坐标换算 |
| main.cpp | 主流程串联,加载模型、读取图像、输出结果 |
模块划分的思路很清楚:inference 层不关心具体模型,只负责加载 engine 和跑张量;rtmdet 和 rtmpose 各自处理自己的前后处理;main 把上下游串起来。这种分层方式最大的好处是可以平替其他模型,比如把 rtmpose.cpp 换成手部姿态模型,主流程和推理封装都不用动。
3.2 导出 ONNX 与构建 engine:trtexec 参数怎么给
RTMPose 和 RTMDet 都是 PyTorch 训练出来的,要进 TensorRT 得先过 ONNX。工程里 python/model 目录下通常放着对应的导出脚本,核心操作是 torch.onnx.export 指定动态轴为 batch 维度,同时把模型的预处理方式冻结进 ONNX。
# 导出检测器 ONNX(工程提供脚本等价于以下步骤) python tools/export_onnx.py \ --config rtmdet-l.py \ --checkpoint rtmdet-l.pth \ --output rtmdet.onnx # 导出姿态模型 ONNX python tools/export_onnx.py \ --config rtmpose-l.py \ --checkpoint rtmpose-l.pth \ --output rtmpose.onnx导出阶段最容易忽略的是输入张量的 dtype。RTMPose 官方 ONNX 输入要求是归一化后的 float 张量,值是 0-1 范围,不是 0-255 的 uint8,也不是没做归一化的 float。导出脚本一般会把 Normalize 层一起写入 ONNX 图里,所以输入直接喂 0-255 的 float 也能出对结果,但这一点不同版本处理不一样,在 C++ 侧读取 ONNX 时一定要确认输入节点的 dtype 和归一化方式,这是后面推理结果全零最常见的来源。
拿到 ONNX 后用 trtexec 构建 engine。建议在 TensorRT 安装目录的 bin 下用命令行执行,Windows 上提前打开 x64 Native Tools Command Prompt for VS:
trtexec --onnx=rtmpose.onnx \ --saveEngine=rtmpose.engine \ --fp16 \ --memPoolSize=1024MiB \ --minShapes=input:1x3x256x192 \ --optShapes=input:1x3x256x192 \ --maxShapes=input:1x3x256x192参数含义:--fp16开启半精度,RTMPose 这种纯 CNN 结构收益明显;--memPoolSize控制 TensorRT 工作区显存上限,老版本参数名是--workspace,写法不同但语义一致;--minShapes/--optShapes/--maxShapes三个放同一个值,是告诉 TensorRT 不需要支持动态 batch,固定 shape 能省掉不少优化时间。如果后面真的需要动态 batch,把三个值改成不同档位,但 engine 构建时间和推理延迟都会有代价。
3.3 推理链路三段代码:加载 engine、预处理、SimCC 解码
工程里 inference.cpp 的核心是反序列化 engine 并创建执行上下文,setBatchSize 可以直接在 context 上设置输入 shape,不必用 setBindingDimensions 反复改,适合固定尺寸场景。
// inference.cpp 中 loadEngine 的核心片段 bool Inference::loadEngine(const std::string& enginePath, int maxBatch) { 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::vector<char> blob(size); file.read(blob.data(), size); // 反序列化 engine,省去重新解析 ONNX 和优化耗时 runtime_ = nvinfer1::createInferRuntime(gLogger); engine_ = runtime_->deserializeCudaEngine(blob.data(), size); context_ = engine_->createExecutionContext(); // 固定 batch=1,绑定输入输出 buffer nvinfer1::Dims4 inputDims{1, 3, 256, 192}; context_->setInputShape("input", inputDims); return context_ != nullptr; }这段代码要注意的是setInputShape在 engine 是固定 shape 时可以不调用,但显式设置一次更保险,防止不同 TensorRT 版本对默认 shape 的处理有差异。blob 数据加载用的是 ifstream 全量读入,对大 engine 文件没问题,但读完后记得 close 文件再走后续逻辑。
rtmpose 的输入尺寸是 256×192,但检测框裁剪出来的区域不一定是这个比例,直接 resize 会把人体拉变形,关键点自然就不准了。正确的是先 letterbox 到目标尺寸,记录比例和填充量:
// utils.cpp:letterbox 缩放工具 cv::Mat resizeKeepRatio(const cv::Mat& src, int targetW, int targetH, float& ratio, int& padW, int& padH) { int h = src.rows, w = src.cols; ratio = std::min((float)targetW / w, (float)targetH / h); int newW = (int)(w * ratio), newH = (int)(h * ratio); padW = (targetW - newW) / 2; padH = (targetH - newH) / 2; cv::Mat dst(targetH, targetW, CV_8UC3, cv::Scalar(114, 114, 114)); cv::Mat roi = dst(cv::Rect(padW, padH, newW, newH)); cv::resize(src, roi, roi.size()); return dst; }letterbox 的填充色用 114 是检测模型训练时的均值填充习惯,和 COCO 预训练权重对齐。姿态模型对填充色不太敏感,但检测框裁剪后背景占比大,填充值用 114 能保持一致性。ratio、padW、padH三个量必须传出去,最后还原关键点坐标时要用。
姿态推理完成后,rtmpose.cpp 里的 SimCC 解码是最后一步。模型的 x 输出分支和 y 输出分支各是一个[1, K, W]和[1, K, H]的得分向量,K 是关键点数,RTMPose 在 COCO 上是 17。
// rtmpose.cpp:SimCC 解码,把分类得分转成坐标 for (int k = 0; k < K; ++k) { const float* xc = x_scores + k * W; // 第 k 个关键点的水平得分 float xMax = *std::max_element(xc, xc + W); float xSum = 0.f, xDenom = 0.f; for (int i = 0; i < W; ++i) { float e = std::exp(xc[i] - xMax); // 减最大值防溢出 xSum += e * i; xDenom += e; } float x = xSum / xDenom * stride; // 期望值乘 stride 映射回输入图 // y 方向同理 }SimCC 解码里的 key point 是 softmax 求期望,不是 argmax 取最大位置。期望值能给出亚像素精度,argmax 只能得到整数位置,对关键点定位来说差一两个像素影响很大。减最大值再做 exp 是标准 softmax 防溢出写法,当分类得分都很大时直接 exp 会上溢成 inf。stride 是模型 head 相对输入图的下采样倍率,RTMPose 输出 W 和输入宽度是 stride 的整数倍关系,这部分在导出 ONNX 后是固定的,运行时从输入 shape 直接推出来即可。
上面三段代码正好对应整个推理链路的三个关键点:engine 反序列化负责让 TensorRT 快速恢复运行环境;letterbox 保证模型输入比例不变形;SimCC 解码把模型输出还原成人体关键点坐标。后处理拿到的是姿态输入图坐标系下的坐标,还需要用这一步的 ratio 和 pad 做一次逆变换才能映射回原图,这个映射逻辑在 utils.cpp 里,公式就是把坐标减 pad 再除以 ratio。
4. 部署避坑:五个让我翻过车的问题
4.1 推理输出全是 0 或 NaN
现象:engine 构建成功,C++ 侧输入图像也正常,但关键点输出全部是 0,或者偶发 NaN。
原因:最常见的是输入张量的归一化方式与训练时不一致。RTMPose 官方 ONNX 输入要求 0-1 范围的 float 张量,很多人直接把 0-255 的 uchar Mat 拷贝进 GPU,模型输出的分类得分全部指向错误位置。另一种情况是 FP16 下某些极端数值溢出,SimCC 头的得分经过 softmax 后本不该有 NaN,但前半段卷积特征里出现 inf 就会带坏后续所有节点。
解决:先做最小化排查。把输入改成全 0 张量或全 0.5 张量推理一次,如果输出还是 NaN,问题在模型本身或 engine 构建参数;如果输出变成了某个固定值,那问题在预处理。确认归一化方式时,看 ONNX 输入节点的 dtype 和生产者的预处理代码,不要靠猜。如果是 FP16 导致的偶发 NaN,先用 FP32 构建一个 engine 对比测试,FP32 正常而 FP16 异常,再考虑对特定层关闭 FP16。
4.2 动态 shape 的 engine 反而更慢
现象:为了让一个模型同时处理不同分辨率输入,构建 engine 时设置了 min/opt/max 三段 shape,结果推理延迟比固定 shape 版本高了 30% 以上。
原因:动态 shape 下 TensorRT 要对多个 shape 做优化,生成的 kernel 是在不同 shape 之间取折中,每个 shape 都不是最优解。而且动态输入维度会引入额外的维度计算节点,GPU 上这些节点不便宜。
解决:工程部署中优先固定输入尺寸。检测器用 640×640,姿态模型用 256×192,这两个尺寸是训练规格,固定住完全够用。如果必须支持多个分辨率,对每个分辨率分别构建一个 engine,运行时按输入切换,而不是用动态 shape 硬扛。这里要多说一句:动态 batch 和动态空间尺寸是两回事,batch 动态化的代价主要在显存分配,尺寸动态化的代价在 kernel 选择,混在一起做容易两头不讨好。
4.3 关键点坐标偏得离谱,框越准点越偏
现象:NMS 之后的检测框准确覆盖了人体区域,但输出的关键点要么集中在图像角落,要么整体偏移一大截。
原因:坐标还原链路断了。姿态网络输出的是姿态输入图坐标系下的坐标,要还原回原图必须经过 letterbox 的逆变换。我见过不少人把 ratio 和 pad 在链路中弄丢,直接拿输出坐标当原图坐标用。还有一种情况是检测框裁剪后没有做 letterbox,而是直接 resize 到 256×192,人物比例被拉伸,关键点跟着变形。
解决:坐标还原公式固定为x_orig = (x_pose - padW) / ratio + box_x,注意这是姿态输入图到原图的完整映射,box_x 是检测框在原图上的起点。每次写完链路先找一张单人图验算:人为标注一个关键点位置,跑完推理看还原坐标是否落在同一点附近。如果偏出几个像素,大概率是 letterbox 填充方向或 pad 计算的正负号问题。
4.4 检测器 CPU 后处理成为性能瓶颈
现象:用 TensorRT 把两个模型的推理延迟都压到几毫秒,但整条链路帧率依然上不去,profiling 显示时间大头在 RTMDet 后处理的 NMS 上。
原因:RTMDet 输出的是大量候选框,默认做法是把输出拷回 CPU 后用 OpenCV 的 NMS 处理。CPU NMS 在候选框数量上千时非常吃力,尤其当画面里人多或者检测阈值调低后,候选框数量翻倍,CPU 直接被打满。
解决:两种路线,第一种是用 TensorRT 的 EfficientNMS plugin 把 NMS 留在 GPU 上,engine 的输出直接就是 NMS 后的框;第二种是工程代码里用 GPU 端排序或并行 NMS 替代单线程循环。工程里 rtmdet.cpp 如果用的是 CPU NMS,优先把 NMS 换成 plugin 方案,这个改动可以把后处理时间从十几毫秒压到 1 毫秒以内。改的时候注意 plugin 的输出格式和原工程后处理的数据结构要对齐。
4.5 Windows 下 DLL 和路径问题:TensorRT 版本不匹配的老朋友
现象:工程在 A 机器上编译运行正常,拷贝到 B 机器后直接崩溃,报错要么是 cudnn64_8.dll 找不到,要么是加载 engine 时提示不兼容。
原因:TensorRT 是强版本绑定的,engine 文件对 TensorRT 版本敏感,用 8.6 构建的 engine 在 8.4 的运行时上一定加载失败。DLL 问题通常是 PATH 环境变量没配好,或者机器上存在多个 TensorRT 版本,系统加载了旧版本的 DLL。
解决:拷贝工程时把nvinfer.dll、cudnn64_*.dll、cublas64_*.dll等依赖全部放进 exe 同目录,而不是依赖系统 PATH。engine 文件在目标机器上用对应版本的 trtexec 重新构建一次,不要跨版本搬运。另外,engine 文件的路径和读取用的文件名不要带中文或特殊字符,Windows 下 ifstream 对 UTF-8 路径的处理经常出幺蛾子。
5. 性能验证与进阶:用 trtexec 数据预估并发路数
5.1 先跑通 perf 再谈优化
拿到构建好的 engine,先别急着写 C++ 代码,用 trtexec 跑一次标准性能测试,拿到基线数据。
trtexec --loadEngine=rtmpose.engine \ --shapes=input:1x3x256x192 \ --fp16关注输出里的Mean latency和Throughput两个字段。我一般把 Mean latency 记为模型单次推理耗时,这个数据是后续所有并发估算法的基础。跑的时候留意一点:trtexec 默认会做多轮 warmup,数据比实际 C++ 调用的延迟更乐观,所以真实工程里要在 Mean latency 基础上加 20%-30% 的余量,否则一上线就露馅。检测器和姿态模型分别跑一次,得到两个延迟基线。
5.2 从单帧延迟推算 T4 上的并发能力
热词检索里常有人问 T4 上用 TensorRT 跑 YOLO 640 分辨率能支持多少路 1080p25 帧流,这个问题的思路往下游延伸一步:RTMDet + RTMPose 的组合在 T4 上能扛多少路。
推算法很简单:一路 1080p25 帧的流,每帧处理预算约 40ms。假设检测器 FP16 在 T4 上单帧耗时约 5ms,姿态模型单人约 2ms,画面里平均有 3 个人,那姿态部分需要 6ms,整帧约 11ms,单纯按算力看一路绰绰有余。但真实瓶颈在拷贝和调度,输入图像从 CPU 到 GPU 的拷贝、多人 batch 的拼接、检测框裁剪出的小图再进姿态模型,这些开销很容易占掉一半预算。按 20ms 实际消耗估算,T4 单卡跑 2 路是稳的,状态好的时候能摸到 3 路。性能这东西有点玄学,不同 TensorRT 版本和驱动对同一模型的影响能差 15%,所以手里有卡就实测,不要只看理论峰值。
进阶技巧有三个方向值得投入。第一个是把整帧的多个检测框拼成一个 batch 喂给姿态模型,batch 推理比循环单张快很多;第二个是让检测器和姿态模型共用同一个 CUDA stream 和显存池,减少上下文切换和显存碎片;第三个是如果精度还能接受,用校准集做 INT8 量化,RTMPose 在 INT8 下通常能把姿态单次推理再压一半,但对人体边缘关键点的定位精度会有轻微影响,上线前一定要用线上数据做回归测试。
从那以后我每次部署姿态模型,都会先做一张原图和推理结果的叠加可视化,把 17 个关键点直接画在图上,肉眼过一遍再谈性能优化。可视化比对坐标还原的 bug 远比调试断点高效,这个习惯帮我避掉了无数次后处理翻车。希望帮到你。
本文还有配套的精品资源,点击获取