ARTICLE DETAIL

资讯详情

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

纯CPU部署RTMPose:C++与OnnxRuntime实现实时姿态估计实战

纯CPU部署RTMPose:C++与OnnxRuntime实现实时姿态估计实战 简介面向在 Windows 平台上使用 C 完成姿态估计模型本地化部署的开发者这份资源围绕 RTMPose 提供了不借助 mmdeploy 的完整工程示例。项目已经基于 OnnxRuntime CPU SDK 将 RTMDetnano 与 RTMPose 串联起来并封装了一个带跳帧检测的 RTMPoseTrack 类因此在 CPU 上也能实时进行二维姿态估计配合工程注释和结构划分适合算法工程化、边缘终端落地以及姿态跟踪场景的进阶学习。压缩包内共有 308 个文件其中 197 个 hpp、75 个 h、5 个 cpp 构成主要源码2 个 onnx 为推理模型dll 与 lib 提供 OpenCV 和 OnnxRuntime 的运行依赖还有 sln 工程文件与 md、txt 使用说明整体约 170.21MB便于直接打开编译和按目录检索。目前已有 265 人学习下载。通过这份工程可以系统了解 RTMDetnano 目标检测与 RTMPose 关键点估计的 C 调用流程掌握模型输入输出预处理、推理结果解析、跳帧跟踪逻辑等关键环节也能为后续改用 TensorRT 部署或替换自有模型提供可复用的代码基底。1. 为什么弃用mmdeployRTMPose在C侧的真实部署成本RTMPose是典型的两段式top-down关键点方案先用RTMDetnano把人检测出来再对每个检测框做姿态估计。拿到模型之后很多人第一反应是上mmdeploy但mmdeploy把PyTorch、CUDA、TensorRT的版本关系绑得非常死在Windows上光是把依赖编过一遍就得花掉一整天而且一旦模型里有个自定义算子整个导出流程就得重来。这份源码的价值在于绕开mmdeploy直接用OnnxRuntime的C API加载RTMDetnano和RTMPose的ONNX模型在纯CPU上跑完检测、裁剪、关键点推理整条链路还封装了一个带跳帧检测的RTMPoseTrack类来压低CPU开销。它适合两类人一类是手里只有ONNX模型、不想被部署框架绑架的C工程师另一类是要把姿态估计以库的形式嵌进现有OpenCV桌面软件、只关心接口和耗时的业务开发者。先给结论只要CPU是近五年的主流型号640x640检测加256x192姿态推理整帧总耗时可以控制在100ms附近后续所有调优都围绕这个基线展开。2. 依赖就得自己管onnxruntime.dll与opencv_world的版本搭配Windows下做图像推理最烦的不是写代码而是让一堆DLL在运行时互不打架。这份工程把依赖直接用文件摆了出来省去自己编译OpenCV的时间。但反过来你也得知道这些文件各自的定位否则换一台机器部署时运行时弹出的0xc000007b会让人一头雾水。2.1 源码包里的DLL清单与分工先看包内与运行时直接相关的文件文件作用部署注意点onnxruntime.dllOnnxRuntime推理引擎的C ABI实现必须和onnxruntime_cpu_model_base.cpp里include的头文件版本一致opencv_world3410.dllOpenCV 3.4.10聚合库Release构建避免拆分出opencv_core、opencv_imgproc等一堆小DLLopencv_world3410d.dll同上Debug构建调试时用发布包不要带opencv_ffmpeg3410_64.dllOpenCV VideoIO的FFmpeg插件第一次读mp4或rtsp流时被动态加载这里有两个高频坑。第一个是Release和Debug混用用Debug配置编译时链接的是opencv_world3410d.dll运行时如果只拷了opencv_world3410.dll启动直接报找不到后者反过来发布时把Debug版DLL带出去exe能跑但速度慢一截而且Debug版OpenCV依赖的VC运行时和Release不一致目标机器没装对应Redistributable就会在运行时崩溃。第二个是位数匹配64位进程必须用x64的onnxruntime.dll和opencv_world3410.dll32位进程用x86版本。混用后的典型症状是进程能创建窗口但第一次调用cv::imread时弹出0xc000007b。提示opencv_world3410.dll启动时不会主动加载opencv_ffmpeg3410_64.dll只有第一次使用cv::VideoCapture读视频或流时才按DLL搜索顺序去查找。视频打不开先查它是否在exe同级目录其次再查OpenCV的FFmpeg插件本身是否被安全软件拦截。2.2 工程目录与公共基类的职责工程文件在项目里的组织方式直接决定后面换平台时改动的范围。常见做法是把第三方库头文件和DLL都放在工程内deploy/ ├── main.cpp ├── rtmdet_onnxruntime.cpp ├── rtmpose_onnxruntime.cpp ├── rtmpose_tracker_onnxruntime.cpp ├── onnxruntime_cpu_model_base.cpp ├── onnxruntime/ │ ├── include/onnxruntime_cxx_api.h │ └── lib/onnxruntime.dll ├── opencv/ │ ├── include/opencv2/ │ └── x64/vc15/lib/opencv_world3410.lib └── models/ ├── rtmdet-nano.onnx └── rtmpose.onnxonnxruntime_cpu_model_base.cpp在工程里承担公共基类的角色。它封装了Ort::Env、Ort::Session创建、输入输出节点名获取、Tensor构建和释放上层RTMDetOnnxruntime和RTMPoseOnnxruntime都继承它避免每个模型把Session初始化代码重写一遍。这个抽象有个附带好处后续要换TensorRT后端时基类里把执行提供程序从CPU换成TensorRT业务代码完全不用动。链接时也只需要opencv_world3410.lib一个导入库运行时依赖收敛成一个dll模块管理成本比分开链接opencv_core、opencv_imgproc等模块低很多。2.3 OnnxRuntime会话初始化的骨架OnnxRuntime的C API在1.x之后保持稳定核心就是Ort::Env、Ort::SessionOptions和Ort::Session三个对象#include onnxruntime_cxx_api.h // Env建议作为全局或成员持有不要每次推理都创建 Ort::Env env(ORT_LOGGING_LEVEL_WARNING, rtmpose_deploy); Ort::SessionOptions options; options.SetIntraOpNumThreads(4); // 算子内并行线程数 options.SetInterOpNumThreads(1); // 算子间并行两模型串联时设为1 options.SetGraphOptimizationLevel( GraphOptimizationLevel::ORT_ENABLE_ALL); Ort::Session session(env, Lmodels/rtmdet-nano.onnx, options); // 获取输入输出节点名后续Run时依赖 Ort::AllocatorWithDefaultOptions allocator; std::string input_name session.GetInputNameAllocated(0, allocator).get(); std::string output_name session.GetOutputNameAllocated(0, allocator).get();SetIntraOpNumThreads控制单个算子内部线程数RTMDetnano主干较轻设成物理核数的一半是个合理起点SetInterOpNumThreads控制算子之间的并行检测和姿态两个模型是串行调用算子间并行带来的收益有限设1能避免过度创建线程造成的上下文切换。SetGraphOptimizationLevel(ORT_ENABLE_ALL)会做算子融合与常量折叠吞吐提升明显代价是模型首次加载时间变长几十到几百毫秒都算正常别当成死循环。加载完模型之后接下来要处理的是把一帧图像从OpenCV的Mat变成OnnxRuntime能吃的Tensor这一步涉及内存连续性和通道顺序稍不注意就会得到全零置信度输出所以放在下一章和检测输出一起讲。3. RTMDetnano的输出怎么在C里还原成检测框RTMDetnano是anchor-free检测模型导出成ONNX后输出层一般已经把多个stride的特征图拼接好并完成distance2bbox解码。拿到手的是两个张量boxes形状为(1, 8400, 4)scores形状为(1, 8400, 80)。8400来自三种stride的anchor总数80x80加40x40加20x2080对应COCO类别数。要做的事就是预处理、解析、NMS再把坐标映射回原图。3.1 输入预处理letterbox和归一化RTMDetnano在mmdet训练时用640x640输入推理也固定成这个尺寸避免动态shape带来的额外开销。直接resize会破坏长宽比用letterbox保持比例在较短边补灰边cv::Mat letterbox(const cv::Mat src, cv::Mat pad, int target_size 640) { float scale std::min(target_size * 1.0f / src.cols, target_size * 1.0f / src.rows); int new_w std::round(src.cols * scale); int new_h std::round(src.rows * scale); cv::Mat resized; cv::resize(src, resized, cv::Size(new_w, new_h)); int pad_w target_size - new_w; // 右侧补充宽度 int pad_h target_size - new_h; // 下方补充高度 cv::copyMakeBorder(resized, pad, 0, pad_h, 0, pad_w, cv::BORDER_CONSTANT, cv::Scalar(114, 114, 114)); // 把后续要用到的几何参数放在Mat前三个通道里带回 pad.atfloat(0) scale; pad.atfloat(1) pad_w; pad.atfloat(2) pad_h; return pad; }copyMakeBorder的填充值114是mmdet默认的pad_val不要改成0否则检测框在靠近图像边缘时会产生系统性偏移。RTMDet在MMDetection里的归一化参数是mean为(123.675, 116.28, 103.53)std为(58.395, 57.12, 57.375)但ONNX导出时这些常量往往已经固化到第一个节点里C侧直接除以255即可。判断方法是打开onnx文件看第一个Conv节点的输入如果看到三个一维常量就说明归一化已在图内完成重复减均值会导致置信度普遍偏低。注意OpenCV Mat默认BGRONNX模型输入通常是RGB填tensor前先cvtColor转通道顺序否则关键点整体错位且很难排查。3.2 输出张量解析与阈值过滤推理用session.Run一次拿到两个输出然后按形状直接读内存。ONNX的输出内存是连续float数组按行主序排列std::vectorOrt::Value outputs session.Run( Ort::RunOptions{nullptr}, input_names.data(), input_tensor, 1, output_names.data(), 2); const float* box_data outputs[0].GetTensorDatafloat(); const float* score_data outputs[1].GetTensorDatafloat(); const int num_anchors 8400; const int num_classes 80; std::vectorcv::Rect boxes; std::vectorfloat confidences; std::vectorint class_ids; for (int i 0; i num_anchors; i) { float max_score 0.0f; int cls_id -1; for (int c 0; c num_classes; c) { float s score_data[i * num_classes c]; if (s max_score) { max_score s; cls_id c; } } if (max_score 0.5f) continue; // 置信度阈值低于0.5直接丢弃 const float* b box_data i * 4; cv::Rect r((int)b[0], (int)b[1], (int)(b[2] - b[0]), (int)(b[3] - b[1])); if ((r cv::Rect(0, 0, 640, 640)) ! r) continue; // 越界丢弃 boxes.push_back(r); confidences.push_back(max_score); class_ids.push_back(cls_id); }这段代码的关键在两点一是先在8400个anchor里对每个anchor求最大类别分数过滤后候选框可能只剩十几个后续NMS的平方级复杂度就可以忽略二是对每个anchor只保留一个最佳类别对姿态任务够用因为只需要person这一类但代码保留class_ids便于以后扩展成检测其它物体。score阈值0.5在单人场景合适密集人群降到0.35否则漏检率会明显上升。3.3 NMS合并与letterbox坐标还原候选框之间可能有重叠用cv::dnn::NMSBoxes做非极大值抑制然后按letterbox的scale、pad_w、pad_h映射回原图坐标std::vectorint keep_indices; cv::dnn::NMSBoxes(boxes, confidences, 0.5f, 0.45f, keep_indices); float scale pad.atfloat(0); float pad_w pad.atfloat(1); float pad_h pad.atfloat(2); std::vectorcv::Rect final_boxes; for (int idx : keep_indices) { cv::Rect r boxes[idx]; cv::Rect mapped( (int)((r.x - pad_w) / scale), (int)((r.y - pad_h) / scale), (int)(r.width / scale), (int)(r.height / scale)); final_boxes.push_back(mapped); }cv::dnn::NMSBoxes的后两个参数分别是score阈值和IoU阈值score阈值0.5与前面过滤保持一致IoU阈值0.45表示两个框重叠超过45%时保留分数高的那个。映射回原图时x要减去pad_wy要减去pad_h因为letterbox的pad加在右侧和下方映射时对应减法。scale做除法前转成float整数除法会把坐标误差放大到好几个像素直接影响下一步姿态估计的裁剪质量。如果发现同一个目标输出两个几乎重合的框多半不是NMS失效而是把letterbox pad错误地重复添加到坐标里导致同一个目标在两个scale下各过了一次NMS。反过来如果姿态结果在目标边缘来回抖要检查的就是scale和pad的存取顺序工程里把它存在Mat前三个float里读取顺序写错不会崩但结果会错得非常隐蔽。3.4 输出节点形状确认模型输入shape输出node名称示例输出shape含义RTMDet-nano(1,3,640,640)output0: boxes(1,8400,4)xyxy格式RTMDet-nano同上output1: scores(1,8400,80)类别分数RTMPose(1,3,192,256)keypoints(1,17,2)或simcc_x/y两个分支这个表里的node名称不是固定值不同导出脚本命名不同运行时以GetInputNameAllocated和GetOutputNameAllocated返回的为准。我自己踩过的坑是照着教程里的名称硬编码换一个导出的onnx就报输入名找不到所以代码里统一动态读取节点名反而省事。4. RTMPose的SimCC头解析与跳帧追踪类检测模型输出的是人框姿态模型则要在框内精确回归17个关键点。RTMPose和传统heatmap模型不同它用SimCC方案把连续坐标建模成两条离散分类向量。理解这个设计C侧解析输出才能写对否则容易把argmax结果直接用丢掉亚像素精度。4.1 top-down流程先检测后裁剪RTMPose输入通常是192x256宽x高。训练时以检测框中心为基准把框按比例扩大后裁剪组成一个包含完整人体的图像块再resize到模型输入尺寸。扩大系数常见做法是1.2到1.5太小会把手脚切掉太大会让背景干扰关键点定位cv::Mat crop_person(const cv::Mat frame, const cv::Rect det, cv::Mat M, int model_w 192, int model_h 256) { int cx det.x det.width / 2; int cy det.y det.height / 2; int cw (int)(det.width * 1.4f); int ch (int)(det.height * 1.4f); cv::Rect crop_rect(cx - cw / 2, cy - ch / 2, cw, ch); crop_rect cv::Rect(0, 0, frame.cols, frame.rows); cv::Mat cropped frame(crop_rect).clone(); cv::resize(cropped, cropped, cv::Size(model_w, model_h)); // 构造原图到裁剪图的仿射矩阵推理后把关键点映射回去 M cv::getRotationMatrix2D(cv::Point2f(cx, cy), 0, 1.0); M.atdouble(0, 2) model_w / 2.0 - cx; M.atdouble(1, 2) model_h / 2.0 - cy; return cropped; }用getRotationMatrix2D构造的是平移矩阵旋转角为0。推理得到的关键点坐标乘以cv::invertAffineTransform(M)得到的逆矩阵即可映射回原图。注意crop_rect和frame取交集后如果目标在画面边缘crop区域比预期小resize会产生轻微形变边缘场景下的关节点误差会变大业务上可接受要求高时先判断crop_rect是否完整不完整就把缺失区域用cv::copyMakeBorder填边缘像素。4.2 SimCC分类头转坐标的亚像素解码RTMPose导出ONNX时有两种常见形态一是把SimCC解码也导出输出直接是(1,17,2)的坐标张量二是只导出分类头输出simcc_x和simcc_y两个张量。第一种直接取坐标即可第二种需要解析void parse_simcc(const float* x_data, const float* y_data, int num_points, int split, float scale_x, float scale_y, std::vectorcv::Point2f kpts) { for (int k 0; k num_points; k) { const float* xr x_data k * split; const float* yr y_data k * split; int mx 0, my 0; for (int i 1; i split; i) { if (xr[i] xr[mx]) mx i; if (yr[i] yr[my]) my i; } // 亚像素插值以峰值相邻三点拟合抛物线顶点 float fx mx; if (mx 0 mx split - 1) { float denom xr[mx] * 2.0f - xr[mx-1] - xr[mx1]; if (std::abs(denom) 1e-6f) fx 0.5f * (xr[mx1] - xr[mx-1]) / denom; } float fy my; if (my 0 my split - 1) { float denom yr[my] * 2.0f - yr[my-1] - yr[my1]; if (std::abs(denom) 1e-6f) fy 0.5f * (yr[my1] - yr[my-1]) / denom; } kpts[k] cv::Point2f(fx * scale_x, fy * scale_y); } }argmax直接取整会带来约1像素的量化误差对鼻尖、手腕这类点1像素偏差会让PCK指标掉好几个点。上面代码用峰值和左右相邻点做二次抛物线插值获得亚像素位置这是mmpose里SimCC解码的标准做法。split对应分类向量长度通常就是特征图宽或高scale_x和scale_y等于模型输入尺寸除以分类长度常见值是1.2和1.28左右不同版本差异不小直接打印simcc_x节点的shape最稳。4.3 RTMPoseTrack类的跳帧检测实现朴素做法是每帧都跑检测加姿态CPU下检测30ms加姿态40ms看起来不多算上imread、resize、归一化就会掉到10fps。RTMPoseTrack的思路是每N帧跑一次检测中间帧直接复用上一轮的人框只跑姿态估计。人体在相邻帧位移有限这个策略在监控和交互场景里非常有效class RTMPoseTrack { public: RTMPoseTrack(const std::string det_path, const std::string pose_path, int interval 4) : detector_(det_path), pose_(pose_path), interval_(interval), frame_id_(0) {} void run(const cv::Mat frame, std::vectorPoseResult out) { bool do_detect (frame_id_ % interval_ 0); if (do_detect) { detector_.infer(frame, boxes_, 0.5f); if (boxes_.empty()) { frame_id_ 0; // 连续漏检则强制下一帧补检测 return; } } out.clear(); for (const auto box : boxes_) { PoseResult pr; pose_.infer(frame, box, pr); out.push_back(pr); } frame_id_; } private: RTMDetOnnxruntime detector_; RTMPoseOnnxruntime pose_; std::vectorcv::Rect boxes_; int interval_; int frame_id_; };interval_设4意味着每5帧才跑一次检测检测模型的开销摊到5帧上总耗时约等于只跑姿态的耗时。单人跟随场景interval可以到6多人且频繁交叉遮挡时降到2到3。boxes_为空时必须把frame_id_归零否则空框会持续到下一轮检测周期画面里有人却一直没有姿态输出。类内两个模型对象在单线程里串行调用没有线程安全问题如果后续要并行化需要注意同一个Ort::Session的Run不是线程安全的必须各自持有独立Session实例。关键点序号部位关键点序号部位0鼻9左腕1左眼10右腕2右眼11左髋3左耳12右髋4右耳13左膝5左肩14右膝6右肩15左踝7左肘16右踝8右肘COCO 17点的顺序从0到16如上表。这个顺序在mmpose数据增强里已经固化公开模型导出的onnx遵循同样顺序但自训练模型建议打印一次推理结果人工核对错一个序号整条骨骼线都会交叉。5. TensorRT迁移前的性能基线验证换TensorRT之前要知道当前CPU方案的数字基线在哪里否则TensorRT跑完不知道是快是慢也不知道瓶颈在哪个环节。这章给出计时、调参和精度验证三个可操作步骤。5.1 单帧耗时统计与瓶颈定位main.cpp主循环里用QueryPerformanceCounter统计阶段耗时LARGE_INTEGER freq, t0, t1, t2; QueryPerformanceFrequency(freq); std::vectorPoseResult poses; QueryPerformanceCounter(t0); detector_.infer(frame, boxes, 0.5f); QueryPerformanceCounter(t1); for (const auto b : boxes) pose_.infer(frame, b, poses); QueryPerformanceCounter(t2); double det_ms (t1.QuadPart - t0.QuadPart) * 1000.0 / freq.QuadPart; double pose_ms (t2.QuadPart - t1.QuadPart) * 1000.0 / freq.QuadPart; std::printf(det%.1fms pose%.1fms total%.1fms fps%.1f\n, det_ms, pose_ms, det_ms pose_ms, 1000.0 / (det_ms pose_ms));至少跑200帧取平均值且前5帧要丢弃OnnxRuntime的线程池初始化和算子融合都发生在前几帧算进去会虚高一倍。如果检测加姿态总耗时超过150ms优先降姿态输入分辨率256x192降到192x160关键点精度损失通常小于1像素耗时下降20%-30%。这个对比数据就是TensorRT迁移后第一个要复现的指标。5.2 跳帧参数与检测阈值的联动调整跳帧数调大后姿态框滞后变明显此时适当降低检测阈值让检测在每N帧执行时更激进地召回人框弥补间隔期的漏检场景跳帧interval检测阈值det_thrIoU实测CPU总耗时参考单人坐姿、固定镜头60.450.4050-70ms单人走动、跟拍抖动30.400.4560-90ms多人交叉、固定视野20.350.45110-150ms参数联调的原则是先固定interval调det_thr到不漏框再看总耗时决定要不要加大interval。interval过大时即便降低det_thr快速运动下的姿态也会钉在旧框上关键点整体偏移稳定但肢体方向抖动。定量判断方法是在测试视频里统计相邻帧同一关节的像素位移若位移集中在某个固定方向且数值超过框宽度的15%说明跳帧超标必须降一档。5.3 轻量回归验证无标注的PCK自检在没有标注数据的业务现场精度回归验证可以用一个简化办法让一个人站在画面中央保持静止跑一段10秒的追踪统计每个关节点相对时间均值位置的偏差。正常结果应是关节标准差在2像素以内如果某些关节标准差超过3像素要么是检测框在抖要么是SimCC插值逻辑写错这时先查scale系数和坐标映射代码再考虑调det_thr。把这段验证逻辑写成一个独立函数每次调整模型、换onnx或者准备换TensorRT之前跑一遍输出的标准差数据就是一份回归记录能直接对比出迁移前后的精度差。本文还有配套的精品资源点击获取
返回列表