
简介一份面向计算机视觉开发者和深度学习初学者的多人姿态估计CPU运行资源包。基于MoveNet MultiPose Lightning预训练模型提供192×192至736×1280等多种输入分辨率版本使用者可在无GPU环境下以约33FPS速度实现实时多人姿态检测覆盖安防监测、体育分析、康复训练等典型场景。包内共21个文件以OpenVINO模型文件xml结构配置与bin权重参数为主另含Python推理脚本、Jupyter Notebook示例、演示视频及安装说明文档压缩包整体约818.9MB。资源直接调用预训练权重无需训练即可部署适合快速验证算法效果或作为科研、竞赛的基线方案。目前已有5227人学习下载对需要离线运行、CPU友好的多人姿态估计方案的读者具有较高参考价值。1. 视频实时多人姿态估计在 CPU 跑到 FPS33先别急着双击 poose15.zip有没有“不带 GPU 的机器上实时跑多人姿态估计”的现成方案这是我在工位上被问最多的问题。标题里的“视频实时多人姿态估计 cpu_fps33_poose15”按字面拆是四个硬指标纯 CPU 推理、视频流输入、帧率不低于 33、15 点姿态输出。这个组合听起来激进实际是轻量自底向上模型、朴素的峰值分组和 ONNX Runtime CPU 后端共同作用的结果。它适合做边缘盒子、课堂行为分析、安防预筛选这类对成本敏感、又需要多人骨架的场景。这篇不讲广告只讲这个方案为什么成立、怎么在自己机器上复现、以及稳定 33 帧需要躲开哪些坑。如果你手头的机器不在 CPU 天梯图前列也能靠调参把体验拉回实时水平。2. 为什么 CPU 能做到 33自底向上设计、15 点定义和 ONNX 转换2.1 多人姿态估计的两种路线CPU 为什么偏爱自底向上多人姿态估计传统上分两条路线自顶向下和自底向上。自顶向下先在画面中把每个人框出来再对每个框做单人关键点检测自底向上先在全图上预测所有关键点的热图再把属于同一个人的关键点分组。自顶向下的帧率被人数严重拖累画面里三个人单人检测就要跑三遍CPU 上很容易掉到 10 FPS 以下。自底向上只跑一遍全图后续分组计算量很小人数增加对帧率的影响远小于自顶向下。所以能在 CPU 上做到 33 帧以上的多人方案几乎都是自底向上也就是类似 OpenPose 那条路线。自底向上模型对 CPU 友好的另一层原因在于输入形状固定。自顶向下的候选框长宽比每次不一样模型必须容忍动态形状自底向上输入永远是[1,3,256,256]这类固定尺寸卷积、池化、resize 全都能在编译阶段把形状算清楚ONNX Runtime 的图优化吃得最透。这也是为什么同一个模型导出成 ONNX 之后比直接用 PyTorch 跑要快不少。当然自底向上也有代价分组是后处理过程两个人站得很近、肢体互相遮挡时关键点归属容易错乱。这一点会在第 5 章的踩坑记录里展开。这里先记住选型结论CPU 实时多人姿态估计不要走“先检测人再回归骨架”的老路哪怕单点精度更高帧率也撑不住。为了让后处理和模型规模都可控15 点是一个常见的折中。COCO 17 点多出左右耳和左右眼MPII 16 点15 点通常把眼睛耳朵这类易遮挡小目标砍掉保留鼻子、颈部、双肩、双肘、双腕、双髋、双膝、双踝再加一个骨盆中心。砍掉这两个点对姿态判断影响不大但热图通道从 17 降到 15CPU 推理省下的时间是实打实的。下面是一份常见的 15 点索引约定后处理代码都按这个顺序来。如果你从压缩包里拿到不同的点序以模型训练时的标注为准。索引关键点索引关键点0鼻子8右髋1颈部9右膝2右肩10右踝3右肘11左髋4右腕12左膝5左肩13左踝6左肘14骨盆中心7左腕注意这个表里 14 号是骨盆中心它属于虚拟关键点不是哪一块骨头但在多人分组时是重要锚点。一个人只会有一个骨盆中心能避免另一套肢体被错误吸过来。2.2 把训练好的模型转成 ONNX给 CPU 推理让路PyTorch 训练出来的模型直接跑 CPU 不是不行但往往浪费硬件。PyTorch 默认的 CPU 调度偏向训练场景图优化不如推理引擎完整多线程利用率也一般。常见做法是把模型导出成 ONNX再用 ONNX Runtime 的 CPU 执行后端跑。ONNX Runtime 会做算子融合、常量折叠、内存复用还能通过线程数直接控制 CPU 占用。对 FPS 目标来说这一步通常能带来 20% 到 50% 的提升。下面是一个导出 ONNX 的最小脚本。模型结构用占位写法手里的 poose15 如果自带.pt权重结构一般也是类似的自底向上网络import torch model load_pose15_model() # 加载训练好的 15 点模型 model.eval() dummy_input torch.randn(1, 3, 256, 256) # 固定输入分辨率方便 CPU 推理 torch.onnx.export( model, dummy_input, pose15.onnx, input_names[input], output_names[heatmaps, pafs], dynamic_axes{input: {0: batch}}, # 只允许 batch 维可变 opset_version12, )导出时的关键参数有三个。input_names和output_names自己定但导出后要记清楚后面 ONNX Runtime 按名字读取。dynamic_axes只把 batch 维设成动态不要把宽高也设成动态CPU 推理时固定分辨率能让模型内部的张量形状完全静态省掉动态 reshape 和内存分配。opset_version选 11 或 12 比较保守太新的 opset 在旧版 ONNX Runtime 上可能不兼容。导出后先用onnxruntime的InferenceSession加载。它的 CPU 后端通常比 PyTorch 快而且可以设置线程池大小。ONNX Runtime 的 CPU 执行顺序里有两个线程池intra-op 负责算子内部的并行inter-op 负责算子与算子之间的流水。姿态估计模型算子间依赖强inter-op 收益很小主要调的是intra_op_num_threads。这个参数后面会通过--threads透传进去。注意加载模型后不要急着推理先跑一次空输入做预热否则第一次推理会把图优化和内存分配的时间全部算进去导致你误判帧率。模型前向输出一般是两个分支heatmaps 和 pafs。heatmaps 形状[1,15,H,W]每个通道对应一个关键点pafs 形状[1,14,H,W]或通道数翻倍用于关键点之间的亲和力匹配。后处理第一步从 heatmaps 找峰值得到候选关键点第二步用 PAF 做连接。在 CPU 推理中后处理如果写得粗糙会吃掉不少帧预算。第 4 章会给出一个可运行的后处理骨架这里先记住热图峰值提取用 3x3 最大池化来抑制非极大值不要在 Python 里逐个像素比较那会慢到怀疑人生。3. 搭建 CPU 推理环境Python 依赖、解压目录和模型形状核对3.1 安装 CPU 版 PyTorch 与 ONNX Runtime避开 CUDA 全家桶拿到 poose15.zip 这类压缩包第一步是装环境不是双击 demo。这里建议只装 CPU 版 PyTorch不要图省事装带 CUDA 的版本。带 CUDA 的包体积大、依赖 cuDNN在没有 N 卡的机器上会一直提示找不到设备。网上 pytorch 安装教程 CPU 版本一搜一大把核心命令就这几行python -m venv pose15_env source pose15_env/bin/activate pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu pip install onnxruntime pip install opencv-python在 Ubuntu 20.04 上装 CPU 版 PyTorch 的套路和之前搭建 YOLOv8 CPU 环境很像核心是不要把 CUDA 的 index-url 混进来。--index-url指定了 CPU 源torchvision 也会同步装 CPU 版。onnxruntime默认就是 CPU 版x86 台式机用默认包足够ARM 机器再考虑专门的 arm 包。OpenCV 用opencv-python读视频和画骨架不要跟opencv-contrib-python混装两个包会互相覆盖 so 文件出现找不到cv2.VideoCapture的诡异报错。装完可以用一条命令验证后端python -c import torch, onnxruntime; print(torch.__version__, onnxruntime.get_available_providers())正常输出里onnxruntime.get_available_providers()返回[CPUExecutionProvider]。如果你看到 CUDA 字样说明装错源了删掉虚拟环境重装。这个验证一分钟能省半天排查。这里要强调用虚拟环境。OpenCV 和 PyTorch 的依赖经常打架系统 Python 一旦被改乱其他项目也遭殃。venv 隔离是这类压缩包方案最好的后悔药。装完后如果因为某个包依赖又重装回 CUDA 版 PyTorch整个环境就废了建议装好之后不要碰 torch 的重装除非你确定要上 GPU。3.2 解压后先看目录和模型形状不要直接跑 demo压缩包解压后的目录结构常见做法是模型权重、推理脚本、配置和样例视频各占一个目录。poose15.zip 里具体文件不清楚但按同类工程的经验至少会有一个.onnx或.pt权重、一个demo.py或infer.py、一个config.yaml或args.py。先执行下面命令把结构拉出来unzip poose15.zip -d poose15 cd poose15 find . -maxdepth 2 -type f -printf %p %s bytes\n | sort重点看两样东西权重文件后缀是.onnx还是.pt输入尺寸参数写在哪里。.onnx说明作者已经转好了直接跑.pt要按第 2 章的方式自己转一遍或者用 PyTorch 直接加载。输入尺寸通常写在脚本开头的INPUT_SIZE或 config 里它直接决定你能不能到 33 FPS。确定权重后先打印模型的输入输出形状再跑视频。下面这段代码放在脚本最前面import onnxruntime as ort sess ort.InferenceSession(pose15.onnx, providers[CPUExecutionProvider]) for inp in sess.get_inputs(): print(input:, inp.name, inp.shape, inp.type) for out in sess.get_outputs(): print(output:, out.name, out.shape, out.type)如果输出里 heatmaps 第三维不是 15说明这是另一个关键点版本的模型后处理里的索引表要按实际通道改。如果输入是[1,3,256,256]而脚本里 resize 成了 320x240帧率会很难看。看清楚再跑远好过直接撞报错。输入分辨率除了决定 FPS还影响 CPU 天梯图上的参考价值。同样是 33 FPS 的模型在 6 核 12 线程的 CPU 上可能 256 输入正好在 8 核 16 线程的 CPU 上可以开 320。如果手头机器在 CPU 天梯图的中下段先从 224 开始测别默认跑 320。这种细节决定了你是“跑得动”还是“看 ppt”。另一个值得记录的是输入张量的内存布局。ONNX Runtime CPU 推理对 NHWC 和 NCHW 的转换也会耗时如果压缩包自带的是老模型输出是 NCHW而后处理只支持 HWC每一帧都要做一次 transpose。准备一个 256x256 的 blank tensor 测试如果一次 transpose 就要 2 ms对 33 FPS 的预算来说已经不小。解决办法是导出时把输出 layout 调成 NHWC或者在预处理阶段避免反复 transpose。4. 跑通视频推理最小 demo 脚本和四个必调参数4.1 推理循环骨架从视频帧到 15 点骨架这一节给一个能直接抄的推理脚本。它假设压缩包里有一个pose15.onnx和一个视频文件。核心调用关系是读帧、等比缩放、归一化、模型推理、热图峰值提取、PAF 分组、画线显示。后处理写成函数分组逻辑保留最简单版本重点看参数怎么影响 FPS。import cv2 import numpy as np import onnxruntime as ort INPUT_SIZE 256 def preprocess(frame, input_size): h, w frame.shape[:2] scale input_size / max(h, w) nh, nw int(h * scale), int(w * scale) resized cv2.resize(frame, (nw, nh)) canvas np.zeros((input_size, input_size, 3), dtypenp.uint8) canvas[:nh, :nw] resized rgb cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0 rgb (rgb - np.array([0.485, 0.456, 0.406], dtypenp.float32)) / np.array([0.229, 0.224, 0.225], dtypenp.float32) return np.transpose(rgb, (2, 0, 1))[None, ...], scale, nh, nw def extract_peaks(heatmaps, threshold0.3): hmap heatmaps[0] # [15, H, W] peaks [] for k in range(hmap.shape[0]): h hmap[k] h_max cv2.dilate(h, np.ones((3, 3), dtypenp.uint8)) mask (h h_max) (h threshold) coords np.argwhere(mask) scores h[mask] peaks.append(list(zip(coords[:, 1], coords[:, 0], scores))) return peaks sess ort.InferenceSession(pose15.onnx, providers[CPUExecutionProvider]) cap cv2.VideoCapture(input.mp4) cv2.namedWindow(pose15, cv2.WINDOW_NORMAL) while True: ret, frame cap.read() if not ret: break tensor, scale, nh, nw preprocess(frame, INPUT_SIZE) outputs sess.run(None, {input: tensor}) heatmaps, pafs outputs[0], outputs[1] peaks extract_peaks(heatmaps, threshold0.35) # 这里省略 PAF 分组实际把 peaks 按 PAF 亲和力聚类后得到 person 骨架 # groups group_peaks(peaks, pafs) # draw_groups(frame, groups, scale) cv2.imshow(pose15, frame) if cv2.waitKey(1) 0xFF ord(q): breakextract_peaks里用 3x3dilate做局部极大值抑制膨胀后每个局部区域只剩最大点再用h h_max一次拿到所有峰值比 Python 双层循环快一个数量级。threshold影响漏检和误检0.3 左右均衡场景里人小、远降到 0.2但误检会增加。INPUT_SIZE是帧率杠杆256 越小越快小目标会丢320 更准FPS 会掉到 20 出头。标题里的 33 通常就是从 224 或 256 来的。PAF 分组阶段一般先为每个关键点生成候选列表再按 PAF 计算相邻关键点之间的亲和力分数高的优先连接最后按连接树的边数过滤少于 4 个点的不算一个人。这段代码比较长建议先沿用压缩包附带的postprocess.py。如果没有就按 5.3 的阈值调参思路去改不要自己急着从零实现匹配算法。4.2 命令行参数把线程数、分辨率和显示开关暴露出来上面的代码能跑但要调到 33 FPS最好把关键参数放到命令行而不是写死。我一般会给 demo 脚本加这几个参数python demo.py --source input.mp4 --model pose15.onnx \ --input-size 256 --threads 4 --threshold 0.35--input-size控制预处理分辨率常见 224、256、320、384。--threads透传到 ONNX Runtime 的intra_op_num_threads一般设成物理核心数不要设成逻辑线程数。超线程对卷积加速有限设多了反而增加调度切换。--threshold是热图峰值阈值。还有一个容易忽略的--skip-frames参数推理能力跟不上时每 2 到 3 帧推理一次显示时直接复制最近一次骨骼结果。这样即使推理只有 20 FPS画面输出也能稳定在 30 以上代价是骨架轨迹有轻微跳变。使用摄像头时cv2.VideoCapture(0)读到的帧率是 30 或 60但系统调度不一定稳定。技巧是把读帧和推理解耦读帧线程只放最新帧到队列推理线程每次取最新一帧旧帧直接丢。这样视频源卡顿不会慢慢积累延迟始终推理的是最新画面。第 5 章会展开讲这个延迟问题。绘制骨架时要注意坐标还原。模型输出的峰值坐标在 256x256 的 canvas 里画回原图要先把 x、y 除以scale再对齐左上角。如果画面上骨架错位多半是缩放没做对。preprocess里返回的nh, nw是等比缩放后的实际高宽还原后要判断关键点坐标是否落在[0, nw/scale]内越界直接丢弃。这一步不影响 FPS但影响你对方案的第一印象很多人因为画线错位误以为模型不准其实是坐标还原代码写错了。长时间运行后 FPS 下降也是常见现象重点检查 CPU 降频。笔记本尤其明显建议先跑几分钟让机器热起来再读一次 p90 数据。如果 p90 越跑越大大概率是散热兜不住而不是模型变慢。5. 稳定 33 FPS 的五个常见坑线程、分辨率、分组、延迟、归一化5.1 帧率上不去线程数设成了逻辑线程数现象--threads 8在四核八线程 CPU 上跑帧率比默认还低。原因intra-op 线程数超过物理核心后线程切换和缓存竞争吃掉收益。解决先用lscpu看物理核数然后设成物理核数并约束 OpenMPexport OMP_NUM_THREADS4 export OPENBLAS_NUM_THREADS4 python demo.py --threads 4OMP_NUM_THREADS不设的话ONNX Runtime 和 OpenCV 会各自开线程池总线程数可能冲到 30CPU 占用满了但 FPS 没上去。装了 CPU 版 PyTorch 的环境torch.set_num_threads(4)也应该一起设虽然推理主体在 ONNX Runtime前处理里的张量操作可能经过 PyTorch。5.2 改小 input-size 后 FPS 没变ONNX 动态宽高没固定现象输入从 384 改成 256FPS 几乎没变CPU 占用率反而涨了。原因导出 ONNX 时如果把宽高也设成了 dynamic模型内部卷积仍按最大动态范围推断改输入尺寸只影响 resize。解决回到第 2 章的导出脚本dynamic_axes只保留 batch重新导出。如果必须动态分辨率至少保证输入宽高能被模型下采样倍数整除否则会有隐式 padding 浪费算力。一个判断技巧改小输入后 CPU 占用率升高而 FPS 不变基本可以断定模型内部形状没跟着变小。5.3 多人靠近时骨架串线PAF 分组阈值太低现象两个人站在一起骨架交叉成蜘蛛网。原因PAF 分组阶段亲和度阈值太低默认 0.1 时几乎什么都能连上。解决把分组阈值从 0.1 提到 0.3并加一个“最少 4 个关键点才算人”的过滤条件。如果还串把峰值提取阈值也提到 0.4。注意调参顺序先固定峰值阈值再调分组阈值一次只动一个变量。否则出问题你不知道是谁的锅。这里不是玄学自底向上的后处理本来就是一个平衡峰值阈值过高肩、髋这类平坦热图容易漏检分组阈值过高单个人可能被拆成两截。压缩包自带的默认参数往往是针对演示视频调出来的换成你的场景必须重调。5.4 长时间运行延迟越来越大队列积压只处理旧帧现象摄像头跑半小时后画面上的人和真实动作差好几秒。原因读帧线程按 30 FPS 塞帧推理线程处理不过来队列越堆越长处理完的永远是旧帧。解决把队列长度固定为 1放入新帧时清掉旧帧或者读帧循环里发现队列长度大于 1 就直接丢弃当前帧。实时交互场景必须丢帧离线分析录像文件则不能丢同一个压缩包两种目标逻辑别写一起。5.5 一张骨架都检测不到归一化参数和训练时不统一现象官方演示视频正常换成自己的视频后完全检测不到人。原因很多压缩包内部用了自己的 mean/std或者 resize 方式是直接拉伸而不是等比补边输入分布和训练时不一致。解决在预处理代码里检查配置文件确认 mean、std 和 resize 方式。如果包里的 demo 是把整帧拉伸到 256x256而你改成等比补边模型看到的画面完全不同。检查方法是把预处理后的张量保存成图片看内容是否还像正常画面。这一步放在调任何阈值之前。6. 用带预热的测速脚本验证真 FPS把 33 写在纸面上框架自带的 FPS 打印经常混入视频读取和显示时间虚高虚低都不奇怪。我习惯写一个只统计模型推理耗时的脚本验证是不是真的 33。技巧是先跑 3 次空推理预热再计时统计连续 100 帧的推理时间分布import time import numpy as np tensor np.random.randn(1, 3, INPUT_SIZE, INPUT_SIZE).astype(np.float32) for _ in range(3): sess.run(None, {input: tensor}) costs [] for _ in range(100): t0 time.perf_counter() sess.run(None, {input: tensor}) costs.append(time.perf_counter() - t0) costs np.array(costs) print(mean: %.1f ms, p90: %.1f ms, fps: %.1f % ( costs.mean() * 1000, np.percentile(costs, 90) * 1000, 1000 / costs.mean()))p90 比 mean 更能反映卡顿。很多方案平均 FPS 到 33p90 跑到 50 ms拉到实际视频流里就会偶发跳帧。平均耗时小于 30 ms、p90 小于 35 ms才是能安心上线的 33。另一个验证方法是把视频输入换成固定帧率文件对比输出帧数和文件时长差值应在 1% 以内否则说明实时处理跟不上。我现在的习惯是任何压缩包拿到手先跑这个测速脚本再谈优化和部署。很多宣传的 FPS 数字对应的是最小分辨率加最强 CPU你手边的机器可能只有一半性能。花十分钟把真实数据测出来胜过一个下午瞎调参。希望帮到你。本文还有配套的精品资源点击获取