简介在Windows平台使用C本地化部署RTMPose的源码包提供不使用mmdeploy、基于OnnxRuntime CPU SDK的RTMDet-nano与RTMPose完整部署示例。项目实现了目标检测与关键点估计串联流程并封装了带跳帧机制的RTMPoseTrack类可在CPU上实时进行二维人体姿态估计同时通过跳帧策略有效降低计算负担代码结构模块化适合需要将RTMPose集成到自研C项目、或希望绕开mmdeploy复杂依赖的算法工程人员参考后续也可扩展至TensorRT部署。资源共308个文件以197个hpp、75个h头文件及5个cpp源文件为主同时附带2个onnx模型、6个lib库与8个dll运行库并包含OpenCV和ONNX Runtime相关动态库压缩包整体约170MB内含使用说明文档和Visual Studio工程配置涵盖检测、姿态估计与跟踪调用示例可帮助快速搭建环境并运行示例。目前已有265人学习下载适合具备一定C与深度学习部署基础的读者作为实战参考。1. 为什么RTMPose的C部署常比预想多一层“输出解析”工作最近围绕RTMPose的讨论明显变多手上存着RTMPose ONNX或者TensorRT源码包的人也不少但“在C里能跑”和“在业务里能用”之间还隔着一段输出解析。RTMPose部署链路本身不复杂拿到onnx模型、用onnx runtime或TensorRT加载、送入一张裁剪好的人物图推理、拿结果。难点在于RTMPose的输出不是大多数人熟悉的17张heatmap而是一组SimCC坐标分类向量。坐标解码方式不对即便推理速度再快关键点也会抖动甚至完全错位。这里按模型输出协议、ONNX Runtime接入、TensorRT引擎构建、精度验证四个阶段展开不依赖某个具体开源仓库的目录结构适合手头有模型文件、但缺一套C调用方案的工程师直接对照落地。2. RTMPose导出与输入输出协议onnx和TensorRT共用的部署前提用C接推理引擎之前先把模型输出协议固定下来。Top-down姿态估计链路是“检测器给框 - 按框裁剪 - 姿态模型推理”RTMPose只处理后一段因此工程里要自己处理检测框坐标与模型输入尺寸之间的缩放。无论后面用ONNX Runtime还是TensorRT姿态模型的输入输出协议是一致的先把这两个问题核对完后边不会反复返工。2.1 RTMPose为什么不用heatmap而用SimCC坐标分类RTMPose在COCO关键点任务上使用SimCCSimCC: Simple Coordinate Classification作为训练目标它把关键点横坐标和纵坐标拆成两个一维分类问题。以COCO 17个关键点为例推理输出通常是两个浮点张量一个对应x方向分类一个对应y方向分类常见shape为[1, 17, num_bins_x]和[1, 17, num_bins_y]。num_bins与模型的SimCC split配置相关常见导出脚本会把num_bins设置成输入宽高例如输入192x256时输出[1, 17, 192]与[1, 17, 256]。与heatmap相比SimCC避免了对特征图做反卷积上采样计算量更小且分类目标对量化更友好这是RTMPose能在CPU和嵌入式设备上跑出现实帧率的原因之一。但对部署者来说这意味着后处理不能直接套用“找最大响应点”的旧逻辑要做一次softmax和加权坐标计算。如果某个开源项目里直接把输出当作heatmap取argmax结果通常不准确尤其在遮挡和低分辨率输入下会很明显。2.2 从pt转onnxtorch.onnx与mmdeploy各有适用场景导出RTMPose的onnx有两条常见路径一条直接在PyTorch工程里用torch.onnx.export适合模型结构已经被剥离成推理子图的情况另一条用OpenMMLab的mmdeploy工具链它会自动处理数据预处理、输出重排和部分后处理融合。这两种方式本质都在做“pt转onnx”差别在于对前处理和后处理的打包程度。# 方式Atorch.onnx.export 导出需要先加载训练权重并切到 eval 模式 python export_onnx.py \ --config rtmpose-m.py \ --checkpoint rtmpose-m.pth \ --input-h 256 --input-w 192 \ --opset 13 \ --output rtmpose-m.onnx # 方式Bmmdeploy 提供的 deploy.py会一并处理预处理算子 python tools/deploy.py \ configs/rtmpose/rtmpose-s_onnxruntime.py \ /path/to/rtmpose-s.pth \ demo.jpg \ --work-dir deploy_out # 导出后建议顺手用 onnxsim 做一次算子合并 python -m onnxsim rtmpose-m.onnx rtmpose-m-sim.onnx方式B生成的文件常带端到端预处理可能把Normalize、Resize都固化成图内算子方便快速验证但图结构更复杂后续转TensorRT时反而可能多出一些子图融合的坑。我一般优先用方式A导出裸模型前处理和坐标缩放全部放到C侧统一管理这样ONNX和TensorRT两套后端共用同一份预处理代码。导出方式图内是否带预处理转TensorRT难度适用场景torch.onnx.export不带较低可以直接trtexec需要精细控制前处理流程mmdeploy部署工具可能带取决于是否含动态Resize快速验证、端到端demoonnxsim二次简化保持原图结构降低算子粒度更规整两种后端通吃推荐做2.3 导出后第一件事核对输入输出名称和动态维度拿到onnx文件后先用一行命令确认输入输出张量的名称和维度不要直接看导出的代码。很多RTMPose repo在导出时把输出变量命名为simcc_x、simcc_y但也有命名为output1、output2的情况。推理代码里写死的字符串必须和这里完全一致。python -c import onnx; monnx.load(rtmpose-m-simcc.onnx); \ print(input:, [(i.name, [d.dim_value or d.dim_param for d in i.type.tensor_type.shape.dim]) for i in m.graph.input]); \ print(output:, [(o.name, [d.dim_value or d.dim_param for d in o.type.tensor_type.shape.dim]) for o in m.graph.output])输出示例input: [(input, [1, 3, 256, 192])] output: [(simcc_x, [1, 17, 192]), (simcc_y, [1, 17, 256])]3. 用C接ONNX Runtime跑RTMPose从Session配置到SimCC解码ONNX Runtime是先把C整条链路跑通的最佳选择因为它的CPU和GPU构建都能对同一份onnx文件生效。这里给出最小可用的C推理流程重点放在Session配置、输入内存布局和SimCC解码上。3.1 依赖和Session配置C侧依赖onnxruntime C头文件和动态库OpenCV用于读图和resize。ONNX Runtime提供onnxruntime_cxx_api.h链接时加-lonnxruntime。Session配置里有两个参数值得注意SetIntraOpNumThreads控制单次推理内部算子并行线程数CPU部署时一般设成物理核数的一半到全部SetGraphOptimizationLevel建议直接开ORT_ENABLE_ALLRTMPose这种以卷积和矩阵运算为主的图能从图优化里拿到稳定的速度提升。配置项常用值说明SetIntraOpNumThreads4或8CPU推理时与核数匹配过高反而增加调度开销SetGraphOptimizationLevelORT_ENABLE_ALL合并ConvBN等结构无副作用SetExecutionModeORT_SEQUENTIAL单模型推理时保持默认即可SetInterOpNumThreads1只有图内多个并行子图时才需要调3.2 C推理主流程与内存布局#include onnxruntime_cxx_api.h #include opencv2/opencv.hpp #include array #include iostream #include vector int main() { Ort::Env env(ORT_LOGGING_LEVEL_WARNING, rtmpose); Ort::SessionOptions opts; opts.SetIntraOpNumThreads(4); opts.SetGraphOptimizationLevel(ORT_ENABLE_ALL); Ort::Session session(env, rtmpose-m-simcc.onnx, opts); Ort::AllocatorWithDefaultOptions allocator; auto in_name session.GetInputNameAllocated(0, allocator); auto x_out_name session.GetOutputNameAllocated(0, allocator); auto y_out_name session.GetOutputNameAllocated(1, allocator); constexpr int INPUT_H 256, INPUT_W 192; cv::Mat img cv::imread(person.jpg); cv::Mat resized; cv::resize(img, resized, cv::Size(INPUT_W, INPUT_H)); resized.convertTo(resized, CV_32FC3, 1.0 / 255.0); // ONNX Runtime 需要 NCHW 连续内存 std::vectorfloat input_data(1 * 3 * INPUT_H * INPUT_W); for (int y 0; y INPUT_H; y) { for (int x 0; x INPUT_W; x) { cv::Vec3f p resized.atcv::Vec3f(y, x); input_data[y * INPUT_W x] p[2]; // R input_data[INPUT_H * INPUT_W y * INPUT_W x] p[1]; // G input_data[2 * INPUT_H * INPUT_W y * INPUT_W x] p[0]; // B } } std::arrayint64_t, 4 input_shape{1, 3, INPUT_H, INPUT_W}; auto memory_info Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault); auto input_tensor Ort::Value::CreateTensorfloat( memory_info, input_data.data(), input_data.size(), input_shape.data(), input_shape.size()); const char* input_names[] {in_name.get()}; const char* output_names[] {x_out_name.get(), y_out_name.get()}; auto outputs session.Run(Ort::RunOptions{nullptr}, input_names, input_tensor, 1, output_names, 2); auto x_shape outputs[0].GetTensorTypeAndShapeInfo().GetShape(); auto y_shape outputs[1].GetTensorTypeAndShapeInfo().GetShape(); const float* x_logits outputs[0].GetTensorDatafloat(); const float* y_logits outputs[1].GetTensorDatafloat(); std::cout x: x_shape[0] x_shape[1] x_shape[2] \n; std::cout y: y_shape[0] y_shape[1] y_shape[2] \n; return 0; }这段代码把图片从HWC转成CHW并按RGB顺序写入input_data。注意RTMPose训练时一般用RGB输入直接把BGR数据喂进去会让精度明显下降。输出只取了两个维度信息先确认x_shape[2]是否等于192、y_shape[2]是否等于256如果和期望不符说明导出的模型带动态维度需要在Session里显式绑定输入shape。3.3 SimCC解码把分类向量转成关键点坐标解码核心是softmax加期望坐标。对每个关键点把x方向的num_bins个logits做softmax再按分类索引求加权平均y方向同样处理。这里给出一个通用实现scale_x和scale_y需要按模型训练时的SimCC split比例设置。#include algorithm #include cmath struct Keypoint { float x, y; }; void simcc_decode(const float* x_logits, const float* y_logits, int num_keypoints, int num_bins_x, int num_bins_y, float scale_x, float scale_y, std::vectorKeypoint kpts) { kpts.resize(num_keypoints); for (int k 0; k num_keypoints; k) { const float* x_row x_logits k * num_bins_x; const float* y_row y_logits k * num_bins_y; float x_max *std::max_element(x_row, x_row num_bins_x); float sum_x 0.f, expect_x 0.f; for (int i 0; i num_bins_x; i) { float e std::exp(x_row[i] - x_max); sum_x e; expect_x e * i; } float y_max *std::max_element(y_row, y_row num_bins_y); float sum_y 0.f, expect_y 0.f; for (int i 0; i num_bins_y; i) { float e std::exp(y_row[i] - y_max); sum_y e; expect_y e * i; } kpts[k] {expect_x / sum_x * scale_x, expect_y / sum_y * scale_y}; } }scale_x与scale_y的取值原则是如果num_bins等于模型输入宽度那么scale_x1.0解码结果直接是192宽坐标系下的坐标后续再乘“原图宽 / 输入宽”映射回检测框。如果导出的onnx里num_bins不等同输入宽高例如采用2倍split则scale_x input_w / num_bins_x否则坐标会整体缩小一半。遇到不确定的模型先拿一张图上Python端decode结果与C端对比再定缩放系数不要拍脑袋。3.4 ONNX Runtime、ncnn与TensorRT的选型场景常见替换选项是ncnn和TensorRT但三者的边界不一样。ncnn在手机和ARM Linux上更轻更适合移动端App直接调用但算子支持和精度与onnx runtime的兼容性需要逐模型验证。ONNX Runtime最大的优势是CPU和GPU一套代码切换GPU端CUDA EP性能足够应付多数实时业务。TensorRT则适合NVIDIA Jetson、Orin和服务器GPU上追求极致延迟的场景。做C部署时建议先以ONNX Runtime为基准把后处理跑通再把同一份onnx交给TensorRT这样精度问题能快速定位是模型解码还是引擎转换造成的。4. 从onnx到TensorRTRTMPose引擎构建与C推理中的动态Shape问题TensorRT部署RTMPose的性能收益主要在FP16和INT8。先明确一点TensorRT engine文件与CUDA、TensorRT版本强绑定换机器或换版本必须重新生成。工厂里经常遇到“在Orin上降TensorRT版本后engine失效”的情况这不是代码问题而是序列化engine本身不可跨版本。4.1 引擎构建方式离线trtexec优先常见做法是用trtexec离线生成engineC运行时只做反序列化。原因很简单TensorRT引擎构建会做层融合和kernel自动调优耗时从几十秒到几分钟不等不应该放到每次启动的业务进程里。只有在需要根据运行时输入尺寸动态改变模型结构时才考虑在代码里调用buildSerializedNetwork。trtexec \ --onnxrtmpose-m-simcc.onnx \ --saveEnginertmpose-m-fp16.engine \ --fp16 \ --minShapesinput:1x3x256x192 \ --optShapesinput:1x3x256x192 \ --maxShapesinput:8x3x256x192 \ --workspace2048如果部署场景固定单人、固定裁剪尺寸建议直接把所有shape设成一致让TensorRT做更多静态优化。RTMPose在多人场景下需要把检测框裁剪后resize到统一输入常见做法是保持输入尺寸固定为256x192只在batch维上放宽到4或8这样既支持多路摄像头又能保留大部分静态优化收益。参数作用RTMPose建议值--fp16启用FP16推理无精度异常时推荐开启--int8启用INT8需要校准数据仅在精度验证通过后使用--minShapes动态输入最小shapeinput:1x3x256x192--optShapes最优shape影响kernel选择input:1x3x256x192--maxShapes最大shape决定显存预留按实际batch需求设置--workspace构建时可用的显存上限2GB足够RTMPose使用4.2 C反序列化engine并执行推理#include fstream #include vector #include NvInfer.h #include cuda_runtime_api.h class TrtLogger : public nvinfer1::ILogger { public: void log(Severity severity, const char* msg) noexcept override { if (severity Severity::kWARNING) { fprintf(stderr, [TRT] %s\n, msg); } } } gLogger; std::vectorchar loadFile(const char* path) { std::ifstream f(path, std::ios::binary); f.seekg(0, std::ios::end); size_t size f.tellg(); std::vectorchar data(size); f.seekg(0); f.read(data.data(), size); return data; } void infer() { auto blob loadFile(rtmpose-m-fp16.engine); nvinfer1::IRuntime* runtime nvinfer1::createInferRuntime(gLogger); nvinfer1::ICudaEngine* engine runtime-deserializeCudaEngine(blob.data(), blob.size()); nvinfer1::IExecutionContext* ctx engine-createExecutionContext(); ctx-setBindingDimensions(0, nvinfer1::Dims4{1, 3, 256, 192}); // 按bindings顺序分配显存0输入1、2输出 void* bindings[3]; cudaMalloc(bindings[0], 1 * 3 * 256 * 192 * sizeof(float)); cudaMalloc(bindings[1], 1 * 17 * 192 * sizeof(float)); cudaMalloc(bindings[2], 1 * 17 * 256 * sizeof(float)); // 假设cpu_input_data已经完成归一化和CHW cudaMemcpy(bindings[0], cpu_input_data, 1 * 3 * 256 * 192 * sizeof(float), cudaMemcpyHostToDevice); ctx-enqueueV2(bindings, nullptr, nullptr); cudaMemcpy(cpu_x_out, bindings[1], 1 * 17 * 192 * sizeof(float), cudaMemcpyDeviceToHost); cudaMemcpy(cpu_y_out, bindings[2], 1 * 17 * 256 * sizeof(float), cudaMemcpyDeviceToHost); }代码里bindings[0]、bindings[1]、bindings[2]的顺序必须和onnx输入输出列表一致不能假设输出一定按“x在前、y在后”。更稳妥的方式是用engine-getBindingName(i)打印所有binding名再按名字绑定数据指针。TensorRT较新版本推荐enqueueV3但enqueueV2仍是存量工程里最常见形式兼容性也更好。4.3 INT8量化与校准集RTMPose转INT8时最容易翻车的是校准集不能用纯噪声图或非人体图像。TensorRT做INT8量化时会把每个激活值的分布统计出来如果校准集里没有人形关键点输出的分布统计会严重偏离真实场景。常见做法是从测试集抽出100到500张包含不同姿态的人物图放在一个目录下做校准。trtexec \ --onnxrtmpose-m-simcc.onnx \ --saveEnginertmpose-m-int8.engine \ --int8 \ --calib./rtmpose_calibration_cache.bin \ --fp16--fp16与--int8同时使用时TensorRT会先按FP16量化反推INT8 scale部分层保留FP16精度。RTMPose在INT8下17个关键点的坐标误差通常能控制在1到3个像素内但如果业务对关键点稳定性要求很高我建议优先用FP16保留INT8作为后续优化选项。实际测试中FP16相比FP32的延迟收益已经非常明显INT8带来的额外收益要看显存瓶颈是否突出。5. 部署收尾用输出比对法快速定位解码与引擎问题ONNX Runtime版本和TensorRT引擎都跑通之后最值得做的一步是“三方输出比对”以Python端PyTorch输出为基准把同一张测试图的输入和输出分别落盘再和C的ONNX Runtime输出、TensorRT输出逐一对比这样能快速区分是模型转换问题、坐标解码问题还是推理引擎精度问题。先写一个简单的Python脚本把输入、输出保存成二进制文件python dump_rtmpose_io.py \ --image demo.jpg \ --onnx rtmpose-m-simcc.onnx \ --out io.bin然后C端读取io.bin里的原始输入推理后把输出与Python端的x_logits、y_logits做逐元素对比。判断阈值建议按后端区分对比项允许误差说明Python浮点 vs ONNX Runtime FP321e-4正常应在这个范围内ONNX Runtime FP32 vs TensorRT FP321e-3TensorRT有kernel重排TensorRT FP32 vs TensorRT FP161e-2坐标误差一般小于1像素三个最容易踩的坑要单独检查第一不要对输出直接取argmax当坐标SimCC必须做softmax加权直接argmax会在低置信时产生像素级抖动第二检测框坐标到模型输入的缩放要在C侧统一算常见错误是用原始图宽高去缩放模型输出坐标导致关键点偏移几个像素第三TensorRT动态shape设置后每次enqueueV2前确认setBindingDimensions被调用否则多batch场景下显存越界很难排查。最后还有一个很实用的验证技巧把同一个检测框同时送入ONNX Runtime和TensorRT比较两个后端的17个关键点坐标如果只有部分关键点偏差大重点查对应关键点的num_bins通道数是否被硬件访存截断如果全部偏差均匀再考虑FP16误差范围。RTMPose在FP16下对绝大多数姿态场景是安全的只有当输入分辨率降到128x96且目标人体很小的时候FP16关键点抖动才会明显那时把输入切回256x192往往比换回FP32更有效。本文还有配套的精品资源点击获取