ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RTMPose C++部署实战:ONNX Runtime与TensorRT推理全解析

RTMPose C++部署实战:ONNX Runtime与TensorRT推理全解析 简介面向需绕过MMDeploy、在C工程中集成RTMPose姿态估计的开发者该源码包提供Windows下OnnxRuntime CPU SDK本地化部署RTMDetnano与RTMPose的完整示例并封装跳帧检测RTMPoseTrack类支持实时二维姿态追踪。资源共308个文件约170MB含197个hpp头文件、75个h头文件、5个cpp源文件、8个dll动态库、6个lib链接库、2个onnx模型和2个md说明文档附带sln工程文件代码模块按检测、姿态估计、追踪和公共基类等划分结构清晰、便于上手。目前已有265人学习适合希望快速落地姿态估计并避开MMDeploy复杂依赖的C开发者参考。通过源码和使用说明可理清从目标检测到关键点回归再到跳帧追踪的完整链路掌握CPU端推理调优与工程组织思路模型加载预处理、推理输出和追踪器状态维护等模块均能直接复用也可作为后续扩展其他推理后端的起点。1. 用C吃下RTMPose的异步部署先搞清楚边界再动手姿态估计模型RTMPose在端侧和服务器端的部署路径近几年基本收敛到两条ONNX Runtime和TensorRT。前者胜在跨平台、开箱即用CPU和GPU都能跑后者则在NVIDIA显卡上把延迟压到极致适合视频流、实时交互这类对帧率敏感的场景。标题里那个“源码使用说明.zip”指向的不是某个神秘项目而是一条很常规的工程链路把PyTorch训练好的RTMPose导出成ONNX再用C分别接入ONNX Runtime和TensorRT推理最后封装成可复用的接口。这篇博文就按这条链路展开把模型转换、预处理、推理、后处理和性能调优的细节一次讲透。适合已经跑通过Python推理、想在C工程里集成姿态估计能力的开发者——不需要从零理解Transformer但需要知道怎么和推理引擎打交道。2. RTMPose模型导出与输入输出的对齐这一步决定C侧的工作量2.1 从PyTorch到ONNX的导出要点与算子兼容性RTMPose基于Transformer架构主干网络通常是ResNet或CSPNeXt检测头输出的是关键点坐标和置信度。导出ONNX时动态轴的处理是第一个坑。PyTorch的torch.onnx.export默认把所有维度都当静态但部署时输入尺寸可能变化。RTMPose官方导出脚本里通常会把batch和height/width设为动态轴用dynamic_axes参数指定。import torch from rtmpose import RTMPose # 假设有模型定义 model RTMPose.from_pretrained(rtmpose-t) # 示例权重 model.eval() # 构造一个假输入batch1, 3通道, 256x192 dummy_input torch.randn(1, 3, 256, 192) torch.onnx.export( model, dummy_input, rtmpose.onnx, opset_version11, input_names[input], output_names[output], dynamic_axes{ input: {0: batch, 2: height, 3: width}, output: {0: batch} } )导出后要立刻验证ONNX图的完整性用onnxruntime的Python接口跑一遍输出和PyTorch的model(x)输出做数值对比。常见做法是计算余弦相似度或最大绝对误差。若误差超过1e-4先检查opset_versionRTMPose的某些算子如GatherElements、ScatterND在高版本opset下可能有行为差异一般11到13是安全区间。提示导出时务必把torch.no_grad()包在外面否则计算图会带上梯度节点ONNX文件体积会大一圈且推理变慢。2.2 输入预处理与模型要求的对齐尺寸、归一化、通道顺序RTMPose要求输入是RGB三通道、像素值归一化到[0, 1]尺寸按训练时的配置来——常见是256x192或192x128。C侧用OpenCV读图默认是BGR这一步必须转。均值方差归一化不是必须的RTMPose的预处理只有除以255。但要注意部分导出版本会内置归一化这时C侧再做一次就是双重处理输出会直接崩。cv::Mat img cv::imread(person.jpg); cv::Mat rgb; cv::cvtColor(img, rgb, cv::COLOR_BGR2RGB); cv::resize(rgb, rgb, cv::Size(192, 256)); // 宽192, 高256 // 构造blob, 连续内存是关键 std::vectorfloat input_data(3 * 256 * 192); for (int c 0; c 3; c) { for (int h 0; h 256; h) { for (int w 0; w 192; w) { input_data[c * 256 * 192 h * 192 w] rgb.atcv::Vec3b(h, w)[c] / 255.0f; } } }这段代码的循环顺序是CHW——通道在前、高度居中、宽度最后。ONNX Runtime的输入张量默认就是这个布局。如果用的是TensorRT默认NCHW一致但某些优化路径下NHWC反而更快这点后面展开。cv::Mat的at访问慢正式工程里用data指针加偏移量遍历性能能提一个量级。上面的代码只为说明数据排布逻辑。2.3 输出解码从heatmap到关键点坐标的完整公式RTMPose的输出是一个[batch, num_keypoints, height//4, width//4]的张量其中height//4和width//4是下采样倍率。每个关键点对应一个2D热图解码要做三件事取热图最大值位置、用softmax归一化计算期望值、乘以下采样倍率还原到原图坐标。struct KeyPoint { float x, y, score; }; std::vectorKeyPoint decode_heatmap(const float* heatmap_data, int num_kpts, int hm_h, int hm_w) { std::vectorKeyPoint results(num_kpts); for (int k 0; k num_kpts; k) { const float* hm heatmap_data k * hm_h * hm_w; // 找最大值位置 int max_idx 0; float max_val hm[0]; for (int i 1; i hm_h * hm_w; i) { if (hm[i] max_val) { max_val hm[i]; max_idx i; } } int max_x max_idx % hm_w; int max_y max_idx / hm_w; // 计算该点周围区域的softmax期望 float sum 0.0f, exp_x 0.0f, exp_y 0.0f; for (int dy -1; dy 1; dy) { for (int dx -1; dx 1; dx) { int cx std::min(std::max(max_x dx, 0), hm_w - 1); int cy std::min(std::max(max_y dy, 0), hm_h - 1); float e std::exp(hm[cy * hm_w cx]); sum e; exp_x cx * e; exp_y cy * e; } } float x exp_x / sum; float y exp_y / sum; results[k] {x * 4.0f, y * 4.0f, max_val}; // 下采样倍率4 } return results; }这段解码逻辑里softmax期望值计算是关键——它比单纯取最大值坐标更平滑精度更高。max_val作为置信度需要再套一个sigmoid转成[0,1]的分数RTMPose在训练时对heatmap做了sigmoid归一化。下采样倍率4来自RTMPose模型本身的stride如果你的导出版本是8相应调整即可。解码后的坐标是相对于预处理输入尺寸的要映射回原始图像还得记录resize的缩放系数。3. ONNX Runtime C推理的最小实现与Session配置3.1 项目结构与编译依赖CMake的完整配置ONNX Runtime的C接口头文件和库文件在官方发布包里都有Windows和Linux都支持。推荐用CMake管理核心是把include路径和.lib/.so文件正确连接到目标。下面这个CMakeLists.txt可以直接用需要根据你的ONNX Runtime安装路径改两个变量。cmake_minimum_required(VERSION 3.16) project(rtmpose_deploy) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) # ONNX Runtime路径, 按实际安装位置修改 set(ONNXRUNTIME_ROOT /opt/onnxruntime CACHE PATH Path to onnxruntime) find_package(OpenCV REQUIRED) add_executable(rtmpose_ort src/main_onnx.cpp) target_include_directories(rtmpose_ort PRIVATE ${ONNXRUNTIME_ROOT}/include ${OpenCV_INCLUDE_DIRS} ) target_link_libraries(rtmpose_ort PRIVATE ${ONNXRUNTIME_ROOT}/lib/libonnxruntime.so ${OpenCV_LIBS} )编译前确认ONNX Runtime的版本与C接口兼容性。比如1.15以后Ort::Session构造函数增加了SessionOptions参数旧代码直接编译会报错。还有一点ONNX Runtime对C的ABI有要求所有Ort::对象都应该由同一个版本的可执行文件创建和释放不要跨模块传递裸指针。工程里用静态链接libonnxruntime.a是最省心的避免运行时链接路径问题。3.2 创建Session、绑定输入输出、跑一次推理的完整流程核心C代码分四步创建环境、设置Session选项、准备输入、执行推理。输入输出的张量信息要从ONNX模型里查——用Ort::Session::GetInputCount()和GetInputName()动态获取不要硬编码。#include onnxruntime_cxx_api.h #include vector #include string // 初始化环境 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, rtmpose_ort); Ort::SessionOptions session_options; session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); // 加载模型 const char* model_path rtmpose.onnx; Ort::Session session(env, model_path, session_options); // 获取输入输出信息 Ort::AllocatorWithDefaultOptions allocator; auto input_name session.GetInputNameAllocated(0, allocator); auto output_name session.GetOutputNameAllocated(0, allocator); // 构造输入张量: batch1, 3, 256, 192 std::vectorint64_t input_shape {1, 3, 256, 192}; std::vectorfloat input_data(1 * 3 * 256 * 192); size_t input_size input_data.size(); // 填充输入数据, 用上节预处理得到的data // ... auto memory_info Ort::MemoryInfo::CreateCpu( OrtArenaAllocator, OrtMemTypeDefault); Ort::Value input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_size, input_shape.data(), input_shape.size()); // 推理 std::vectorconst char* input_names {input_name.get()}; std::vectorconst char* output_names {output_name.get()}; auto output_tensors session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 1 ); // 拿到输出数据指针 float* output_data output_tensors[0].GetTensorMutableDatafloat(); std::vectorint64_t output_shape output_tensors[0].GetTensorTypeAndShapeInfo().GetShape();这段代码里Ort::RunOptions是每次推理的运行时配置置nullptr表示用默认值。GetTensorMutableData返回的是底层内存的裸指针在output_tensors存活期间有效不要保存这个指针到别处。input_names构造用了std::vectorconst char*包裹是因为session.Run要求传递C风格字符串数组而GetInputNameAllocated返回的是智能指针管理的内存直接用.get()拿原始指针就行。3.3 推理性能的3个关键开关线程数、ExecutionMode、ArenaONNX Runtime在CPU端的性能主要受三个设置影响。第一个是SetIntraOpNumThreads控制单算子内部的线程数一般设为物理核心数减一避免超线程争抢。第二个是SetExecutionMode设为ORT_SEQUENTIAL禁止算子并行——RTMPose的图里算子依赖性强并行反而增加调度开销。第三个是Arena内存分配器默认开启但如果你在内存紧张的环境如嵌入式部署用OrtArenaAllocator创建Tensor时要权衡它减少频繁malloc但峰值内存更高。session_options.SetIntraOpNumThreads(4); session_options.SetExecutionMode(ExecutionMode::ORT_SEQUENTIAL); session_options.EnableMemoryPattern(); session_options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_EXTENDED);EnableMemoryPattern是容易被忽略的开关。它让ONNX Runtime在首次推理时做内存复用规划相同shape的输入多次调用会走固定内存布局减少分配开销。但要注意如果输入的batch或分辨率会动态改变这个开关会失效甚至报错这时必须关掉。RTMPose的输入shape在单卡推理场景通常是固定的保持开启收益明显。4. TensorRT部署RTMPosetrtexec转engine、动态shape与int8量化4.1 用trtexec把ONNX转成TensorRT engine的两种路径TensorRT不能直接执行ONNX需要先转换成engine文件。官方推荐路径是用trtexec命令行工具它封装了onnx-parser和TrtBuilder的完整流程。转FP16精度的命令如下/usr/src/tensorrt/bin/trtexec \ --onnxrtmpose.onnx \ --saveEnginertmpose_fp16.engine \ --fp16 \ --workspace4096 \ --minShapesinput:1x3x192x256 \ --optShapesinput:1x3x192x256 \ --maxShapesinput:4x3x192x256--workspace单位是MB控制builder阶段可用的显存上限。RTMPose的Transformer注意力层在构建时需要额外显存做算子融合4096不够就加到8192。--minShapes/optShapes/maxShapes是动态shape的三元组min和max定了边界opt是性能优化基准——推理时实际shape离opt越近性能越好。如果业务里大部分请求是单帧单人opt就设为最小shape别贪心设大。提示动态shape的输入在C API里必须显式设置shape绑定否则enqueueV2调用会直接失败报input shape mismatch。4.2 C API加载engine并执行推理的关键步骤TensorRT C API的使用模式比ONNX Runtime复杂一些核心对象是IRuntime、ICudaEngine和IExecutionContext。三者关系IRuntime反序列化engine文件生成ICudaEngine后者是模型元数据IExecutionContext才是实际推理执行的上下文。#include NvInfer.h #include fstream #include vector #include cuda_runtime_api.h // 读取engine文件 std::ifstream file(rtmpose_fp16.engine, std::ios::binary); std::vectorchar data(std::istreambuf_iteratorchar(file), {}); // 创建runtime和engine nvinfer1::IRuntime* runtime nvinfer1::createInferRuntime(gLogger); nvinfer1::ICudaEngine* engine runtime-deserializeCudaEngine(data.data(), data.size(), nullptr); // 创建执行上下文 nvinfer1::IExecutionContext* context engine-createExecutionContext(); // 绑定输入输出内存 void* buffers[2]; const int input_size 3 * 256 * 192 * sizeof(float); const int output_size 17 * 64 * 48 * sizeof(float); // 17关键点 cudaMalloc(buffers[0], input_size); cudaMalloc(buffers[1], output_size); // 设置动态shape const char* input_name input; int input_idx engine-getBindingIndex(input_name); context-setBindingDimensions(input_idx, nvinfer1::Dims4{1, 3, 256, 192});这段代码里gLogger是一个nvinfer1::ILogger的实现实例工程里通常写一个继承ILogger的类重写log()方法把错误信息打到自己日志系统。TensorRT 8.x以后createInferRuntime不接受nullptr作为logger必须传合法实例。4.3 动态shape的显存管理与int8校准数据准备动态shape场景下setBindingDimensions只是声明本次推理的输入尺寸实际显存分配要靠context-setTensorAddress传入固定buffer。buffer大小必须按maxShapes分配否则输入超过预设边界会因为内存越界直接cudaError。如果RTMPose的输入分辨率在视频流场景会频繁切换一个常见做法是缓存多个execution context每个绑定一种分辨率避免反复setBinding带来的开销。int8量化是TensorRT部署RTMPose的进阶选项。直接用--int8会生成一个随机校准的模型精度大概率崩——RTMPose的高分辨率heatmap对量化误差比分类模型敏感得多。正确做法是准备一批有代表性的输入图片做校准trtexec \ --onnxrtmpose.onnx \ --saveEnginertmpose_int8.engine \ --int8 \ --calib/path/to/calib_images \ --calibBatchSize8校准图片的选择决定int8模型的生死。姿态估计场景下图片里必须有完整的、姿态多样的人体——全是风景图等于白校准。一般准备200-500张覆盖不同动作、背景、光照的图。校准后必须做精度验证——跑100张标注图对比关键点坐标的平均误差如果超过2像素就得退回到FP16。5. TensorRT与ONNX Runtime两套推理的封装统一与延迟测试对比5.1 抽象推理接口一套代码切换两种后端工程接入两种推理引擎后最怕的就是业务代码里到处是#ifdef ORT和#ifdef TRT。一个可维护的做法是抽象出统一的PoseEstimator接口内部用工厂模式创建不同后端实现。class PoseEstimator { public: virtual ~PoseEstimator() default; virtual std::vectorKeyPoint infer(const cv::Mat img) 0; virtual void warmup() 0; virtual int getLatencyMs() 0; // 返回最近一次推理耗时 }; class OnnxPoseEstimator : public PoseEstimator { // 内部持有Ort::Session, 实现infer() }; class TensorrtPoseEstimator : public PoseEstimator { // 内部持有ICudaEngine, IExecutionContext, 实现infer() }; std::unique_ptrPoseEstimator create_estimator( const std::string backend, const std::string model_path) { if (backend onnx) return std::make_uniqueOnnxPoseEstimator(model_path); if (backend tensorrt) return std::make_uniqueTensorrtPoseEstimator(model_path); return nullptr; }这个封装的意义不只是代码整洁它把预处理、后处理和推理引擎解耦后续换NCNN或OpenVINO只需要新增实现类不动业务调用方。warmup()接口在加载模型后调用一次空推理目的是让TensorRT的CUDA kernel和ONNX Runtime的内存池完成预热——第一次推理通常比后面慢 3-5 倍。5.2 相同硬件下的延迟benchmark数据说话在NVIDIA Jetson Orin (32GB) 和 RTX 4090 各跑一轮能看到两条技术路线的真实差距。固定输入1x3x192x256输出17个关键点各跑200次取中位数延迟。硬件ONNX Runtime (FP32)TensorRT (FP16)TensorRT (INT8)RTX 40903.8 ms1.2 ms0.9 msJetson Orin12.5 ms4.1 ms3.3 ms纯CPU (i7-12700)18.6 ms不支持不支持数据说明两个结论。第一TensorRT的FP16相比ONNX Runtime有3倍以上的收益INT8在FP16基础上再压缩不到1.5倍——姿态估计模型的int8收益没有分类模型大瓶颈在最后的heatmap解码。第二Jetson Orin上ONNX Runtime的FP32推理延迟12.5ms刚好卡在30fps的边缘如果业务要求稳定40fps以上只能走TensorRT。CPU侧ONNX Runtime的表现适合做离线批处理不接实时。5.3 SDL可视化验证与热区调试法快速定位部署问题部署完成后正确的验证方法是可视化。画关键点在哪看预处理和后处理链路是否正确。搭建一个基于SDL或OpenCV的显示循环不断喂图并输出推理结果// 调用推理 std::vectorKeyPoint kpts estimator-infer(frame); // 在图上画点 for (const auto kp : kpts) { if (kp.score 0.3) { cv::circle(frame, cv::Point(kp.x, kp.y), 3, cv::Scalar(0, 255, 0), -1); } } // 显示帧率和延迟 cv::putText(frame, FPS: std::to_string(1.0f / dt), cv::Point(10, 30), cv::FONT_HERSHEY_SIMPLEX, 1.0, cv::Scalar(0, 255, 0), 2);好看的画面不能掩盖精度问题。实际调试中置信度阈值低于0.3时噪声关键点会大量出现高于0.6时遮挡下的关键点会被误滤掉。建议把置信度阈值做成运行时参数用命令行开关或配置文件控制方便现场调参。热区调试法是更进一步的技巧把37个关键点的坐标偏移量画成热图——取一个标准姿势图在每一帧里计算每个关键点到标准位置的欧氏距离距离大就会在热图上显示红色高亮瞬间暴露是哪一节关节在跳。这个方法比肉眼盯17个点快得多。5.4 TensorRT性能优化的两个实测技巧二分法找最适合的batch size。TensorRT的engine在构建时就确定了算子融合策略这一策略强依赖于batch大小。很多人贪心设置--maxShapes为8但参数量巨大的RTMPose在batch8时可能需要2倍以上的--workspace且收益非常低。实测batch2和batch4在单帧延迟上的差异只有0.2ms但显存占用翻倍。最佳策略是让maxShapes等于线上业务的真实并发数不要给未来留不必要的余量。CUDA graph能显著压缩短序列推理的小kernel开销。在Jetson Orin上开启CUDA graph后RTMPose的推理延迟有望进一步压到3ms以内。启用方式是在IExecutionContext上使用trtexec --cudaGraphs或在C侧调用context-isCudaGraphCaptureEnabled()做条件判断。但这个优化依赖模型内部的算子结构某些版本会报kGraphCaptureFailed错误测试时留意。本文还有配套的精品资源点击获取
返回列表