从关键点到角度信号:姿态估计运动计数引擎的工程化设计与实现

发布时间:2026/10/17 12:05:34
从关键点到角度信号:姿态估计运动计数引擎的工程化设计与实现 这个开源项目做到现在系列第二篇终于可以聊聊整个项目里最技术向的部分了。第一篇发了之后不少朋友在后台问我姿态估计模型接好了骨架也能实时显示但真正卡住他们的反而是计数怎么数得准——这确实是一道分水岭。模型输出的关键点准确率只是入场券真正决定一个运动计数App能不能用的是后续的特征提取、计数逻辑和工程化细节。这篇文章就把我踩过的坑和最终采用的方案完整摊开覆盖角度特征构建、状态机计数引擎、移动端实时优化、边界情况处理和精度验证给正在做类似项目的同学一个可以直接参考的路线。1. 先搞明白运动计数难的不是检测是判定一次完整动作1.1 逐帧分类思路为什么走不通很多人刚开始做运动计数第一反应都是把每一帧图像丢进分类模型识别出当前在做深蹲还是俯卧撑然后根据动作类别的切换来加次数。这个思路听起来顺理成章但实际跑起来问题非常明显。第一动作的中间态在分类模型眼里是模糊的。比如俯卧撑做到一半身体处于半下降状态模型在俯卧撑和平板支撑两个类别之间反复横跳类别标签本身就带着抖动。第二即使模型连续几十帧都稳定输出俯卧撑你还是没法判断这算一次还是十次——逐帧分类只告诉你正在发生什么不告诉你完整发生了一次什么。第三如果试图用类别从非俯卧撑变成俯卧撑就加一来计数那么任何一帧因遮挡、模糊导致类别翻转都会立刻产生误计。我最初在这个思路上浪费了不少时间后来才意识到问题本质重复性运动的本质是周期性信号计数这件事本质上是在做相位检测而不是在做人脸识别那种每帧独立判断的静态分类任务。1.2 把动作看作一个周期信号拿最典型的俯卧撑来说人体可以抽象成一套由关键点连接的骨架。做一次标准俯卧撑肘关节角度会大致经历这样的变化从手臂伸直时的大约170到180度随着身体下降逐渐减小到90度左右再随着推起恢复到接近180度。深蹲则看膝关节角度从站直时的大约170度下蹲到90度上下再回到170度。引体向上看肘关节角度开合跳看肩关节和膝关节角度的同步展开与收回。你会发现所有重复性动作都可以投影到一个或多个关节角度的时间序列上呈现出一个相对规则的下降—到达波谷—上升—到达波峰的周期波形。计数问题因此转化成了一个更清晰的问题如何在角度曲线这个连续信号里稳健地识别出完整的周期轮回。1.3 角度信号方案相比逐帧分类的收益把计数建立在角度信号上而不是建立在逐帧分类标签上带来的收益很实际。首先它只依赖关键点坐标不依赖背景、颜色和场景其次计数逻辑和姿态识别模型完全解耦后面想换一个更快的模型计数代码一行都不用改最后调试体验好太多——把角度曲线画出来肉眼看曲线形状就能判断阈值设得合不合理。这些优势在做真实产品时远比花哨重要。2. 关键点坐标不会直接用构建稳定角度特征2.1 先处理置信度低置信度点不参与计算姿态估计模型输出的每个关键点通常带着一个归一化坐标和对应的置信度分数。置信度是后续一切特征计算的地基不能上来直接算角度。我在项目里定的处理规则比较简单置信度低于0.5的关键点不参与角度计算低于0.3的关键点对应的整帧直接标记为低质量帧这一帧完全不进入计数状态机。原因是一个错误的关键点坐标可能会产生一个偏离正常范围四五十度的异常角度状态机一旦被这种假数据驱动很可能直接误迁移后果比这一帧不算严重得多。与其给后面填坑不如在入口就拦住。2.2 关节角度的计算公式与实现为什么用角度而不是直接用关键点坐标这个点在第一版代码里我吃过亏。关键点坐标是像素坐标会随着相机距离、画面分辨率变化而整体缩放同一套逻辑换个机型或者换个摆放位置就失效。而关节角度反映的是骨骼段之间的几何关系天然和距离、分辨率无关。计算三个关键点构成的夹角本质上是高中数学里的向量夹角问题。假设关键点A、B、C其中B是关节顶点比如计算肘角则A为肩、B为肘、C为腕计算向量BA和BC的夹角import math import numpy as np def compute_angle(a: np.ndarray, b: np.ndarray, c: np.ndarray) - float: # b 是关节顶点 v1 a - b v2 c - b norm1 np.linalg.norm(v1) norm2 np.linalg.norm(v2) if norm1 1e-6 or norm2 1e-6: return float(nan) cos_theta np.dot(v1, v2) / (norm1 * norm2) cos_theta max(-1.0, min(1.0, cos_theta)) # 防数值越界 return math.degrees(math.acos(cos_theta))有一个细节要提醒计算出来的余弦值理论上不会超过[-1, 1]但浮点精度偶尔会导致越界让acos返回nan所以必须做一次clip。这个便宜但不做排查起来很头疼。2.3 不同动作对应哪些主特征角度不同动作需要关注的角度不一样选错了特征整个状态机就无从谈起。我在项目里总结了一张选型表可以作为参考动作主特征角度关键点组合备选角度俯卧撑肘关节角肩-肘-腕躯干与上臂夹角深蹲膝关节角髋-膝-踝髋关节角引体向上肘关节角肩-肘-腕肩部抬升高度仰卧起坐躯干大腿夹角肩-髋-膝腰椎弯曲角开合跳肩关节角膝关节角肩-肘-躯干中线 / 髋-膝-踝双臂与躯干夹角选择主特征的原则有三个角度变化幅度要大、受遮挡影响要小、语义清晰容易设定阈值。举个例子俯卧撑用肘角就很合适因为肩、肘、腕三个点即使身体压低也很少被完全遮住但如果用肩关节角肩-肘-躯干身体压得越低肩肘连线与躯干越接近重合角度本身还容易受骨盆侧倾影响稳定性差一截。2.4 平滑与异常值处理不过度、分策略角度原始序列通常带噪声要做平滑但不能做死。我建议主特征角度用一阶低通滤波公式很简单smoothed alpha * raw_angle (1 - alpha) * smoothedalpha取0.3到0.4比较合适。alpha太大等于没滤波alpha太小会引入明显滞后。我这里一开始把alpha设成0.15结果测试时发现动作已经做了两个计数才加上第一个就是因为平滑系数过小导致角度曲线被压扁延迟。异常值处理要用跳变剔除而非强滤波如果当前帧角度和上一帧平滑值的差超过40度大概率是检测跳点或者关键点错位这时直接用上一帧的平滑值填充。不要对所有曲线做太重的滤波因为运动本身是高速且大摆幅的信号滤波过重会把真实波峰压低让计数判断失真。3. 用状态机表示动作周期计数引擎的完整设计3.1 为什么低于阈值就加一会出大问题直接对角度设定阈值、低于某值就加一次的做法是我见过最多人踩的坑。你可以做个简单实验俯卧撑撑在最低点不动时肘关节角度会一直稳定在90度附近如果代码按角度小于100度就加一来计那么用户在最低点停两秒次数就会直接加几十次。这本质上是把连续的电平信号当成脉冲信号来用了而正确做法是检测一次由高位到低位再回到高位的完整轮回。3.2 双阈值迟滞状态机我实现的状态机有两个状态EXTENDED代表动作未发生的起始状态比如俯卧撑手臂伸直FOLDED代表动作已完成的折叠状态比如俯卧撑手臂弯曲到最低。状态迁移规则是在EXTENDED状态下只有角度持续低于弯曲判定阈值才进入FOLDED在FOLDED状态下只有角度持续高于伸直判定阈值才回到EXTENDED并且在这个回归瞬间次数加一。两个阈值之间留出的缓冲区间就是迟滞带。比如俯卧撑底部肘角大约90度顶部大约170度我把弯曲判定阈值定在110度伸直判定阈值定在160度中间120到160度是迟滞区。模型输出的角度在边界附近抖动时因为要跨过整段迟滞区才会触发状态迁移就不会产生反复计数。这跟单片机按键消抖是一个道理。核心逻辑的伪代码如下class RepCounter: def __init__(self, bend_threshold: float, extend_threshold: float, confirm_frames: int 3): self.bend_threshold bend_threshold self.extend_threshold extend_threshold self.confirm_frames confirm_frames self.state EXTENDED self.reps 0 self._frames 0 def update(self, angle: float) - int: if self.state EXTENDED: if angle self.bend_threshold: self._frames 1 if self._frames self.confirm_frames: self.state FOLDED self._frames 0 else: self._frames 0 elif self.state FOLDED: if angle self.extend_threshold: self._frames 1 if self._frames self.confirm_frames: self.state EXTENDED self.reps 1 self._frames 0 else: self._frames 0 return self.reps3.3 连续帧确认状态不随单帧迁移单帧跳变的问题很隐蔽但也更要命。姿态估计偶尔会突然给某个关键点一个离谱坐标比如膝关节瞬间跑到画面外再拉回来角度曲线会出现一个很窄的尖峰。如果不加确认机制这个尖峰就足以让状态机误迁移一次计数器就会无中生有多一次。我给状态迁移加了连续N帧确认的机制N取3到5。实际测试下来2帧窗口还是有轻微误计5帧又会让快速动作漏计暴增最终取了3到4帧这个平衡点。以30fps的相机帧率算3帧确认带来的延迟只有0.1秒对运动App完全可接受。3.4 掉帧与推理滞后时的状态保持手机端跑模型不是每一帧都能准时出结果。偶尔掉帧、模型推理偶发超时都会造成时间序列上的空洞。这里有一个很反直觉的要点状态机必须由帧输入驱动而不是由墙钟时间驱动。也就是说状态机只在收到有效的角度帧时才执行update某段时间没有新帧状态就原地保持而不是按时间流逝自动推进。一开始我把状态机写成了每30毫秒查一次最新角度结果一掉帧角度曲线在时间轴上被拉长状态机在错误的时间点切换计数乱了套。改成帧驱动之后掉帧只是少了几次状态判断不会产生错误的迁移。4. 移动端实时跑起来的优化细节从12fps到30fps4.1 先认清端到端管线的真实耗时不少同学只看模型单帧推理时间觉得20毫秒一帧够快。但真实管线远不止模型本身相机帧YUV转RGB、图像缩放与裁剪、模型推理、关键点后处理、坐标映射回原图、状态机更新、UI叠加绘制每一步都是耗时点。我在一台主流中端安卓机上做过一次拆解初始版本的数据很能说明问题环节耗时毫秒说明相机帧格式转换约30用CPU逐像素转YUV到RGB图像缩放与裁剪约10目标区域resize到模型输入尺寸模型推理约20轻量姿态模型640x640输入关键点后处理约5解码输出张量生成关键点列表坐标映射与UI绘制约8叠加骨架与角度信息合计约73实际帧率只能到13fps左右4.2 保持输入尺度稳定不要直接把整帧塞给模型直接拿整个相机画面送到模型里人只占画面的一小部分模型输入只有640x640相当于把整个人缩到百来个像素区域里关键点坐标的误差会非常大。正确思路是先做人形检测拿到人形包围盒再裁剪出这个人形区域并扩展成正方形最后缩放到模型输入尺寸。这样做模型输入的人占比明显提高关键点精度提升非常直观同一台手机上直接把整帧送进去肘角波动有差不多正负8度改成先裁剪再推理后波动降到了正负3度以内。做人形检测可以直接复用姿态模型自带的检测器成本低、效果好不必额外接一路检测模型。4.3 用池化与消息队列消除卡顿源另一个重要优化是内存复用。每帧都new数组、new对象在小内存手机上会频繁触发GC表现为帧率周期性掉到个位数。我把相机的帧数据、关键点存储、角度特征都改成对象池管理相机回调线程从池里取一个空闲帧对象填入数据后塞给推理队列推理线程处理完把结果丢给UI队列再把帧对象归还池子。线程模型也很简单整体是相机回调线程 → 帧队列 → 推理线程 → 结果队列 → UI线程的单向流水线。相机回调只做入队不在回调里跑模型UI线程只消费最终结果不在UI线程做推理。这个改动配合前面的内存池整条管线从最初的13fps稳定到了28到30fps。4.4 旋转坐标映射竖屏下的隐蔽坑手机默认竖屏但相机传感器输出的原始帧通常是横向的。如果不做坐标旋转映射直接把模型输出的关键点坐标画到预览层上骨架和真实人体会明显错位怎么调都别扭。模型输出的归一化坐标是相对于送入模型的裁剪图的要还原到屏幕UI坐标需要按顺序做两步变换先把归一化坐标还原到裁剪图像素坐标再应用旋转矩阵把裁剪图坐标映射到原始相机图像坐标最后映射到屏幕预览坐标。旋转角度由相机旋转角决定。代码里建议把所有变换封装成图像坐标系到屏幕坐标系的单一函数配一组已知坐标的单元测试。这个坑我调了差不多一晚上才彻底解决提醒大家别省这一步。5. 边界情况和计数准确性验证方法5.1 遮挡和关键点置信度骤降的容错真实运动场景里遮挡是常态。俯卧撑身体压到最低点的时候前视角度下肘关节常常和躯干重叠肩、肘、腕的可见度都会下降深蹲蹲到底时膝盖关键点容易被大腿遮挡。处理策略分三层第一低置信度点不参与角度计算第二连续少量帧丢失时用前后帧线性插值补充第三如果关键点连续丢失超过0.5秒直接把状态机重置为初始态避免用假数据驱动状态迁移。最后这条很关键如果某次动作做到一半身体出了画面又回来而状态机被保持在了中间态恢复后同一个动作就会被错误地计成两次。主动重置状态虽然会丢掉还没完成的那半次动作但换来的是整体计数准确率提升。5.2 左右侧关键点怎么取舍关于左右侧最常见的错误是左右两侧关键点平均一下。开合跳这种两侧对称动作平均没问题但深蹲、俯卧撑这类动作相机视角下通常是侧身靠近镜头一侧的关键点置信度高另一侧可能被遮挡平均出来的角度反而更差。稳妥做法是选择每帧置信度更高的一侧来计算角度但要注意左右侧交替切换会造成角度曲线的跳变。真测一轮深蹲就会发现身体转向或手部遮挡变化时主侧做了几次切换角度在正确的曲线附近抖出了尖刺。如果不想在这块花太多时间先固定取左侧把用户朝向统一约定为正对镜头这是成本最低的版本。后续再做成可配置项。5.3 离线视频回放与计数一致性测试判断计数准不准不能靠拍脑袋。我的做法是实测用真实手机录一段固定机位的运动视频把算法实时处理时叠加的关键点、角度曲线和状态机状态一起录进去另一组同学人工观看原始视频数出真实次数作为ground truth最后逐段对比算法输出和人工标注。这里直接分享一组实测数据动作测试次数完全一致少计多计主要误差原因俯卧撑504631半程动作幅度不足未触发阈值深蹲504811蹲得过快有效帧不足开合跳504442手臂未完全伸展角度没到阈值引体向上302721底部悬挂停留时间过长引发抖动少计多数集中在动作幅度不够标准、动作速度过快导致有效帧太少多计则来自半程起伏被当成了完整周期。这些数据反过来指导了阈值设计也让校准动作这件事从可选项变成了必选项。5.4 用一次校准动作生成自适应阈值固定阈值只能适配平均人换个高矮胖瘦的人测试就会真正体会到什么叫参数失效。我给开源版本加了校准动作流程用户先做一次标准动作系统记录该动作角度曲线的最大值和最小值然后按比例生成两个状态阈值。通用做法是记录波峰和波谷设波峰为high_peak波谷为low_valley动作方向不同要注意区分先降还是先升那么结束态确认阈值取谷值附近靠中间的0.3位置起始态确认阈值取峰值附近靠中间的0.7位置。公式化表达就是bend_threshold low_valley (high_peak - low_valley) * 0.3 extend_threshold low_valley (high_peak - low_valley) * 0.7这个比例是我多次测试后的经验值迟滞带足够宽又能容忍一定程度的动作不标准。校准动作做完同一套代码逻辑直接适用于深蹲、俯卧撑、引体向上等不同动作因为每个动作只是换了不同的角度特征来源和一组校准后的阈值计数引擎本身不用改。6. 开源重构与后续方向我踩过的坑都在这里6.1 早期Demo的三个致命问题讲到工程化必须先总结早期Demo的失败经验。第一个致命问题是在相机回调线程里直接跑模型推理主线程被模型卡住帧率掉到个位数状态机的数据流被搅得七零八落。第二个问题是阈值写死在代码里我自己测试很顺手但朋友拿去测了一轮深蹲一个都没计上。第三个问题是直接用像素坐标当特征换了分辨率换了镜头位置逻辑完全失效。这三个问题本质上都指向同一件事代码分层不够可配置性和可复用性太差。6.2 最终模块划分与接口设计重构后我把项目按数据流方向拆成四层数据源层负责提供图像帧姿态提取层负责从图像得到关键点列表特征层负责把关键点列表转成稳定角度计数引擎只消费角度值和校准参数输出层负责UI叠加和语音播报。接口很好理解姿态提取层长这样class PoseEstimator: def process(self, image_rgb) - PoseFrame: # 返回包含关键点列表和置信度的结果对象 pass计数引擎长这样class RepCounter: def update(self, angle: float) - int: # 输入当前帧主特征角度返回最新总次数 pass每层之间只依赖接口不关心上游怎么实现。这样做的直接收益是单测计数引擎时我只需要喂一串预先生成的角度序列连手机都不需要就能把边界情况测完。如果有一天想从某套姿态模型换成另一套模型只需要替换PoseEstimator实现类。6.3 关于后续技术路线的一点倾向开源版本发布后好几位朋友建议我直接上LSTM或者Transformer来学习计数。我的个人倾向是状态机逻辑不能丢因为它可解释、可调试、可在任意设备上稳定复现数据驱动模型更适合做辅助修正比如判断当前动作类型、识别异常帧而不是直接替代计数主逻辑。运动计数这个场景里为什么错了比错了多少更重要哪天用户说计数不对你能立刻打开角度曲线图指出来哪一次动作没到位这比黑盒模型给你一个预测要踏实得多。下一步我准备把动作模板改成纯配置文件用户在界面上自定义动作要观察哪个角度、阈值按什么方式校准同时补上动作质量评分不只是告诉用户做了多少个还告诉他这次俯卧撑有没有做到位幅度是不是太浅——这比单纯堆次数更有产品价值。最后说点实际的体会我把这个项目跑通的过程中最大的感受是姿态估计模型本身的准确度只是入场券真正决定一个运动计数App能不能用的是后面的角度特征和状态机设计。很多朋友看完核心代码觉得不过是个if else但正是这些if else写不好就会陷进多计、漏计、来回跳数的泥潭。建议正在做类似项目的同学不要一上来就追最新的模型、最新的框架先把自己的计数逻辑用最笨的办法做稳定遇到一个边界情况就补一个处理稳定了再谈优化。这一篇先到这里下一篇准备重点写动作模板配置化和质量评估模块的实现有想法的朋友欢迎一起交流。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询