简介:这是一份C#环境下利用OpenVINO部署PP-TinyPose人体姿态识别模型的完整源码工程,面向需要把姿态识别能力集成进.NET桌面应用或对模型部署感兴趣的开发者,解决C#侧直接调用深度学习模型进行人体关键点检测的问题。压缩包共113个文件,大小约166.96MB,内含C#源码(cs/csproj/sln)、OpenCVSharp依赖(dll/xml)、PaddlePaddle与ONNX格式模型(pdmodel/pdiparams/onnx)以及演示视频(mp4)等,结构完整,可编译运行。工程基于vs2019与.NET Framework 4.7.2/4.8,使用OpenCVSharp4.8.0处理图像,并预置所需运行库,无需额外安装OpenVINO运行库即可直接运行。目前已有404人学习/下载,配合随包录屏演示,可帮助快速跑通流程并理解PP-TinyPose在C#中的集成思路,便于后续二次开发。
1. C# 跑人体姿态识别,先把 PP-TinyPose 换成 OpenVINO 这趟车
手里有一份 PP-TinyPose 的静态推理模型,想在 C# 上位机里做实时人体姿态识别,常见的 Python + PaddleInference 方案要起独立进程,图像和结果跨进程传,帧率上不去,UI 还容易卡。换 OpenVINO 的动机很直接:PP-TinyPose 是 PaddleDetection 的轻量级单人姿态模型,256x192 输入在 Intel CPU 上单帧推理只要几毫秒;OpenVINO 把模型编译成 IR 格式放进 C# 进程,推理全程不出进程,既绕开 Python 环境,也拿到一份可维护的部署源码。落地顺序就是模型转 ONNX、再转 IR、C# 加载推理三步,下面按这条线逐层拆参数和坑。
2. 模型准备:paddle2onnx 转出 ONNX,再编译成 OpenVINO IR
2.1 PP-TinyPose 导出的模型包里到底有什么
PaddleDetection 里 PP-TinyPose 导出的部署目录一般长这样:model.pdmodel、model.pdiparams、infer_cfg.yml。pdmodel 是网络结构,pdiparams 是权重,yml 记录预处理细节。模型的输入是1x3x256x192的 NCHW 张量,输出是1x17x64x48的 heatmap:17 对应 COCO 17 个关键点,64x48 是下采样四倍之后的空间分辨率。
为什么先确认这个?因为后处理解坐标要拿 stride 反算,预处理要用 yml 里的 mean/std,两者都提前查到,后面 C# 代码不会反复猜。PP-TinyPose 在 256x192 输入下是单人 top-down 姿态模型,一张图一个人,输出 17 个点的 heatmap,而不是带框的检测结果,这个定位决定了后续 C# 端解码和多人逻辑怎么写。
2.2 用 paddle2onnx 把 Paddle 模型转成静态 ONNX
PP-TinyPose 的原始 Paddle 模型不能直接被 OpenVINO 读取,中间格式选 ONNX 最稳。命令如下:
paddle2onnx \ --model_dir ./tinypose_infer \ --model_filename model.pdmodel \ --params_filename model.pdiparams \ --save_file tinypose.onnx \ --opset_version 11 \ --input_shape_dict '{"x":[1,3,256,192]}'--model_filename和--params_filename明确指定两个文件,防止目录下有多个版本文件时工具误取;--input_shape_dict固定输入形状,让 ONNX 成为静态图,C# 侧就不用处理动态维度。如果你的 paddle2onnx 版本不认--input_shape_dict这个参数,直接去掉也能转,但后面读模型时会带回动态性,性能略差。转完检查一下tinypose.onnx的文件大小,PD 模型权重大约 4 到 6 MB,ONNX 应在同一量级。
2.3 ovc/mo 把 ONNX 编成 OpenVINO IR,顺手固定输出节点名
新版本 OpenVINO 推荐ovc,老版本工具链里mo仍然可用。两种写法都给出来:
# 新版本 ovc tinypose.onnx --output_model ./tinypose_ir.xml # 老版本 mo --input_model tinypose.onnx --output_dir ./ir --compress_to_fp16--compress_to_fp16是 mo 的参数,作用是权重压缩到 FP16,CPU 推理速度几乎不变,模型文件体积减半。ovc 默认也做类似处理。生成的两个文件tinypose_ir.xml和tinypose_ir.bin要一起拷贝到部署目录,缺一个都加载不了。输入名一般是x,输出名一般是conv2d_27.tmp_1,但不排除你导出的模型命名不同;把这个名字记下来,C# 侧读写张量全靠它。
2.4 转完先用 Python 脚本核对输入输出,别急着写 C#
转出来的 IR 是否可用,用一段最短的 Python 脚本就能确认:
from openvino import Core core = Core() model = core.read_model("tinypose_ir.xml") for inp in model.inputs: print("input :", inp.any_name, tuple(inp.partial_shape.get_shape())) for out in model.outputs: print("output:", out.any_name, tuple(out.partial_shape.get_shape()))这一步很值得做:C# 绑定读写张量都按名字来,名字或维度写错时错误信息往往很绕。顺手再跑一次推理,给全 0.5 的输入,输出所有 heatmap 的响应值应该接近同一个常数,说明前向正常,而不是到 C# 里才排查。
| 项 | 名称 | 形状 | 说明 |
|---|---|---|---|
| input | x | [1,3,256,192] | NCHW 的 BGR 图像 |
| output | conv2d_27.tmp_1 | [1,17,64,48] | 每通道是一个关键点的 heatmap logits |
3. C# 加载 OpenVINO IR 推理 PP-TinyPose:最小工程骨架
3.1 C# 侧接 OpenVINO:官方绑定与 P/Invoke 怎么选
官方给的是OpenVINO.CSharp.API这类 NuGet 包,封装了 Core、Model、CompiledModel、InferRequest、Tensor 等类型,日常部署最省事。如果项目的 .NET 版本或运行时架构和包发布节奏对不上,另一个稳的方案是直接 P/Invoke 调openvino_c.dll那一组 C API,函数稳定,只是代码量大。我一般先试官方绑定,加载模型够用就不再下来,真遇到绑定版本滞后再考虑 P/Invoke。
工程里只需要一个模型类和一个推理循环,不需要额外引入 ONNX Runtime 或 Paddle Inference 原生运行时,依赖面小很多,这也是 OpenVINO 部署最舒服的地方。
3.2 最小推理骨架:加载 IR、绑定输入输出、跑一次 Infer
using OpenVinoSharp; public class TinyPoseEngine : IDisposable { private Core _core; private CompiledModel _compiled; private InferRequest _request; private Tensor _inputTensor; private Tensor _outputTensor; public TinyPoseEngine(string xmlPath, string device = "CPU") { _core = new Core(); var model = _core.ReadModel(xmlPath); // xml 与 bin 同目录 _compiled = _core.CompileModel(model, device); _request = _compiled.CreateInferRequest(); _inputTensor = _request.Input("x"); // 名字来自 IR,不猜 _outputTensor = _request.Output("conv2d_27.tmp_1"); } }ReadModel只需要 XML 路径,bin 文件在同目录会自动加载。CompileModel的"CPU"指设备名,OpenVINO 会针对当前 CPU 生成优化代码,换机器换 CPU 也不用重新编译模型。注意不同绑定版本对SetData、GetData的方法名和参数有差异,以 NuGet 包自带的 XML 注释为准,上面流程是稳定的。
3.3 Bitmap 预处理:从界面图像到 1x3x256x192 张量
PP-TinyPose 训练时输入固定是 256x192,最常见的做法是直接拉伸到目标尺寸,代码简洁,精度损耗对姿态任务来说可以接受。
private static float[] Preprocess(Bitmap bmp) { const int H = 256, W = 192; using var resized = new Bitmap(bmp, new Size(W, H)); var data = new float[1 * 3 * H * W]; var bmpData = resized.LockBits( new Rectangle(0, 0, W, H), ImageLockMode.ReadOnly, PixelFormat.Format24bppRgb); unsafe { byte* p = (byte*)bmpData.Scan0; for (int y = 0; y < H; y++) { byte* row = p + y * bmpData.Stride; for (int x = 0; x < W; x++) { int baseIdx = y * W + x; for (int c = 0; c < 3; c++) data[c * H * W + baseIdx] = row[x * 3 + c] / 255f; } } } resized.UnlockBits(bmpData); return data; }这段代码把 Bitmap 的 BGR 像素按通道分离成 CHW 连续内存,除以 255 完成归一化。Stride和Width不一定相等,读像素必须按Stride走,否则图像会斜。通道顺序按模型训练约定来,OpenVINO 不关心通道顺序,错的话关键点位置对但置信度分布会乱,颜色通道反了脸色会明显偏青。
3.4 解码 heatmap:argmax 加坐标回投,跑通单帧闭环
public Keypoint[] Infer(Bitmap bmp, float confThr = 0.3f) { float[] input = Preprocess(bmp); _inputTensor.SetData(input); // 绑定版本不同,方法名可能有差异 _request.Infer(); float[] heatmaps = _outputTensor.GetData<float>(); return Decode(heatmaps, bmp.Width, bmp.Height, confThr); } private static Keypoint[] Decode(float[] heatmaps, int origW, int origH, float confThr) { const int C = 17, H = 64, W = 48; // stride = 4 var kpts = new Keypoint[C]; for (int c = 0; c < C; c++) { int offset = c * H * W; int idx = ArgMax(heatmaps, offset, H * W); int hh = idx / W, ww = idx % W; float conf = Sigmoid(heatmaps[offset + idx]); // logits -> 概率 kpts[c] = new Keypoint { X = ww * origW / (float)W, // 直接拉伸下的坐标回投 Y = hh * origH / (float)H, Confidence = conf }; } return kpts; }这里先做单帧单人的最短链路。Sigmoid不能省,导出的 heatmap 是 logits,不做 sigmoid 时置信度分布是负数,0.3 的阈值会过滤掉全部关键点。坐标回投用的是直接拉伸的比例映射,ww * origW / W把 48 宽的 heatmap 映射回原图宽度;如果预处理改成保比例加 pad,这里的换算也要跟着改,下一章专门说。
4. 姿态解码、多人处理与 CPU 推理参数调优
4.1 关键点回原图:拉伸与保比例加 pad 是两套映射
直接拉伸的映射在上一章已经给出,公式是x = hx * origW / W,y 同理。保比例加 pad 的做法也常见:先算缩放比例,再把原图居中贴到 256x192 画布上,剩余部分填 114 灰边。
// 保比例 + pad 的反算 float scale = Math.Min(192f / origW, 256f / origH); int newW = (int)(origW * scale); int newH = (int)(origH * scale); float padX = (192 - newW) / 2f; float padY = (256 - newH) / 2f; // heatmap 上 (hx, hy) 对应原图 float origX = (hx * (newW / 48f) - padX) / scale; float origY = (hy * (newH / 64f) - padY) / scale;两种映射本身没有绝对优劣,保比例更适合目标形变较大的场景,比如摄像头画面里人离镜头很近时,拉伸会让头部比例失真。代价是多一组 pad 参数,解码时容易漏算。要么全程用拉伸,要么全程用 pad,混着用是姿态点飘移最常见的原因之一。
4.2 多人 top-down:先框人,再每人跑一次 PP-TinyPose
PP-TinyPose 输出只有单人 17 点,多人识别依赖上层检测框。常见做法是检测器先出人框,对每个框裁图后输入模型,关键点层面的 NMS 用 OksNMS,框级别用普通 IoU NMS。C# 侧如果不想引入第二个检测模型,可以先用运动前景分割或固定区域框顶替,姿态精度受影响但能演示完整流程。
裁剪框时注意把检测框外扩 10% 到 20%,不然手臂刚出框就被切掉,肘关节和手腕的置信度会断崖式下跌。这个细节在多人场景里比模型精度更影响最终效果。
4.3 CPU 上三个必调推理参数,以及 UI 卡顿的根治办法
OpenVINO 在 CPU 上有一组运行时参数,C# 里通过配置字典传给CompileModel:
var config = new Dictionary<string, string> { ["NUM_STREAMS"] = "1", ["PERFORMANCE_HINT"] = "LATENCY" }; _compiled = _core.CompileModel(model, "CPU", config);| 配置项 | 推荐值 | 什么时候动 |
|---|---|---|
| NUM_STREAMS | 1 | 单路视频流默认 1;多路并发按路数加 |
| PERFORMANCE_HINT | LATENCY / THROUGHPUT | 交互式画面选 LATENCY,后台多路批处理选 THROUGHPUT |
| CPU_THREADS_NUM | 物理核数的一半到全部 | 上位机还要做 UI 时留一半给主线程 |
UI 卡顿的根源是每帧都Invoke回 UI 线程画图。我一般把推理放在后台 Task,结果以帧对象形式整体替换引用,UI 侧用 30ms 的 Timer 拉最新结果画一次,中间丢帧可接受:
private async Task InferenceLoop(CancellationToken token) { while (!token.IsCancellationRequested) { var frame = _camera.GetLatestFrame(); // 无锁拿最近帧 _latestFrame = (frame, _engine.Infer(frame)); // 只换引用 await Task.Delay(1); } }UI 线程只读_latestFrame,不做任何图像缩放和推理运算,画面自然就稳了。推理线程跑满也不影响界面响应,这是 C# 上位机里处理循环采集和画面刷新最实用的解耦方式。
5. 快速验证与三个容易翻车的细节
5.1 用 benchmark_app 先测模型的裸性能
C# 代码里帧率上不去,先别急着优化代码,用 OpenVINO 自带的 benchmark_app 跑一条基线:
benchmark_app -m tinypose_ir.xml -d CPU -api async -hint throughput -niter 300这个命令会输出 Throughput 和 Latency 两个关键指标。如果实测吞吐量远高于你的目标帧率,问题大概率出在预处理或 UI 线程;如果基线上不去,再调NUM_STREAMS和CPU_THREADS_NUM。这一步能省掉很多无效排查。
5.2 三个翻车点,翻车前先查这三个地方
输出不 sigmoid。PP-TinyPose 的 heatmap 输出是 logits,不做 sigmoid 时最大置信度可能是 8 或 -2,阈值怎么设都不对。排查方法是在解码前打印第一通道的最大值,如果明显超出 0~1 范围,就是少了一层激活。
预处理和 infer_cfg.yml 不一致。你导出的 PP-TinyPose 在 yml 里写的是 ImageNet 风格 mean/std 还是直接除以 255,决定了 C# 里的归一化逻辑。用错之后关键点位置基本正确,但置信度整体偏低,这类问题最难发现。转换前打开 yml 确认Normalize字段,C# 侧照抄。
读模型看到动态维度。如果 IR 里某个维度是 -1,说明转换时没固定形状。C# 侧设输入数据时容易报 shape mismatch,最省事的办法是回到第 2 章重新用--input_shape_dict固定形状再转一次。
5.3 用 COCO 骨架连线快速判断姿态合理性
17 个点解出来之后,画线验证比看数字直观得多。COCO 17 点的标准骨架连接关系如下:
(0,1),(0,2),(1,3),(2,4),(5,7),(7,9), (6,8),(8,10),(5,6),(11,12),(5,11),(6,12), (11,13),(13,15),(12,14),(14,16)配一个Graphics.DrawLine循环就能画完整骨架。建议对置信度小于 0.3 的关键点单独画小圆、不做连线,弱响应点连出来会是一堆抖动线段,影响判断。画线时把缩放前的原图直接作为底图,不要画在预处理后的 256x192 上,方便对比姿态和实际画面的对齐程度。
本文还有配套的精品资源,点击获取