资讯动态

基于姿态估计与双阈值状态机的运动计数实现解析

发布时间:2026/10/17 10:32:55 来源:尧图企业网站定制
1. 计数问题本质从连续姿态到离散次数1.1 为什么我不直接训练一个“计数器模型”上一篇我们完成了姿态估计的接入拿到了人体关键点的实时坐标流。很多朋友看到骨架点在屏幕上跟着人动第一反应是“计数应该很简单吧数一下弯腰几次、抬手几次就是”。真正上手你会发现计数这事比想象中麻烦得多同一动作不同人做幅度不一样同一个人做十次速度不一样中间还可能停顿、抖一下、走神。如果直接拿原始坐标去判断要么漏计要么误计到你怀疑人生。我一开始也想过走“端到端”路线比如给模型加一个输出分支直接回归计数结果。但试了一圈就放弃了。原因很简单这种方案对训练数据的覆盖要求极高你得把各种体型、各种动作幅度、各种速度都喂进去否则换个环境就崩而且计数本质是时序决策问题单帧图像很难定义“这一帧该不该算一次”。与其让模型硬学不如把人体的运动规律拆解出来姿态估计只管“人在哪里、关节怎么摆”计数逻辑交给确定性的状态判定。这也符合大多数开源运动计数项目的通用做法——关键点检测负责感知阈值状态机负责决策。1.2 把动作看作一条波形曲线任何周期性运动比如深蹲、俯卧撑、开合跳、抬腿都有一个共同特征关节角度或位置会随时间呈近似周期性变化。以深蹲为例髋关节角度或膝关节角度会在一个较大的范围里起伏站立时角度接近180度蹲到底时降到90度甚至更小。如果你把时间作为横轴、角度作为纵轴画一条曲线它就像一条正弦波。计数的本质就是在这条波形上数“波峰和波谷的成对出现”。最简单的办法是设两个阈值角度大于某个值算“高位”小于某个值算“低位”从高位进入低位再从低位回到高位算一次。但这里有个非常关键的坑真实传感器的数据不像教科书那么干净人下蹲的过程中膝盖角度会抖动如果只在单一阈值附近徘徊计数就会来回跳。所以我在项目里改用了双阈值加状态锁定的方案这也是我在运动计数板块里最想分享的一条经验。从工程上讲姿态估计的任务刚刚好它不负责判断次数只负责把关键点坐标给我我拿到坐标后算关节夹角、判断相位、推进状态这些是纯计算逻辑快且可控。把“感知”和“决策”拆开后续换模型、换动作、调阈值都更灵活这也是开源项目容易维护的主要原因。2. 我选的计数方案关键点夹角加双阈值状态机2.1 选哪些关键点、怎么算夹角整个计数的核心输入是关节夹角。夹角选得准计数就成功了一半。拿深蹲举例我计算的是髋-膝-踝三点的夹角髋关节和膝关节连成一条线膝关节和踝关节连成一条线两条线在膝关节处形成的夹角就是膝关节弯曲度。站立时接近180度下蹲过程中逐渐减小蹲到底通常在70到90度之间。为什么用膝关节夹角而不是髋关节因为髋关节在画面里很容易受遮挡而且当人做半蹲或浅蹲时髋关节角度变化不明显。膝关节更直观也更接近动作的实际幅度。如果你想识别的是“是否蹲到位”那就必须把角度阈值和动作标准关联起来——我习惯用一个可配置参数来控制判定严格程度阈值放宽适合普通用户收紧一点适合标准训练。夹角计算要用三点的坐标做向量点积和叉积代码在Kotlin里长这样fun angleBetween3Points( a: Landmark, // 髋 b: Landmark, // 膝 c: Landmark // 踝 ): Float { val abX a.x - b.x val abY a.y - b.y val cbX c.x - b.x val cbY c.y - b.y val dot abX * cbX abY * cbY val det abX * cbY - abY * cbX return Math.toDegrees(atan2(det, dot)).toFloat() }这里有个细节Landmark坐标如果直接用原始坐标可能在画面边缘发生轻微跳变所以我在算角度前加了一个轻量平滑核心思路是保留上一帧角度当前帧只占一定比例更新smoothedAngle 0.7f * smoothedAngle 0.3f * rawAngle0.7和0.3是我实测下来比较稳的系数。系数权重太偏向新值就平滑不了抖动太偏向旧值动作快了会跟不上响应会有明显的迟滞感。这一行看似简单但如果你跳过了它后面你会被误计折磨到抓狂。2.2 双阈值和滞回区间是怎么设计的假设深蹲的膝关节角度在180度站直和80度蹲到底之间变化。你设一个阈值100度角度小于100度判定为“蹲下”大于100度判定为“站起”。看起来没问题但实际运行中角度会在100度附近抖动。尤其是快速下蹲后反弹可能连续几次穿越阈值一次动作被计成三次。解决办法很简单设置两个阈值一个低阈值用于判定“到位”一个高阈值用于判定“复位”。比如低阈值100度高阈值140度。只有当角度低于100度时才算进入低位状态然后必须等角度再升到140度以上才认为完成一次完整循环。这中间留了40度的缓冲带即使角度在100度到140度之间抖动也不会触发重复计数。用代码描述出来就是这个状态机的核心enum class SquatState { Waiting, Counted } class SquatCounter( private val lowTh: Float 100f, // 下蹲判定阈值 private val highTh: Float 140f // 复位判定阈值 ) { private var state SquatState.Waiting var count 0 private set fun update(angle: Float) { when (state) { SquatState.Waiting - { if (angle lowTh) { // 进入蹲下状态等待复位 state SquatState.Counted } } SquatState.Counted - { if (angle highTh) { // 完成一次循环 count state SquatState.Waiting } } } } }严格来说大状态机还应该有一个“起始状态”和“结束状态”确保你从动作中间开始记录时不会漏计。比如用户打开App时人已经处于半蹲状态直接套上面的逻辑就会先把半蹲判定成一次完整动作。我后来把状态扩展成了三态初始态等待开始→ 动作进行态检测到第一次达标→ 复位完成态计数并回到等待这样即使从任意画面进入也不会在刚开始那段产生错误计数。这个状态机的思路和平时写UI交互里的防抖、节流本质是一样的——都是通过对边界条件加缓冲、加状态锁定来过滤噪声。2.3 角度计算的方向陷阱算夹角时还有一个极易踩的坑向量方向。我用的是髋-膝-踝三点求膝关节内角也就是从髋指向膝的向量和从踝指向膝的向量之间的夹角。如果你把顺序弄反了算出来的角度完全不对——比如某段代码里用了膝-髋-踝算的是髋关节角画面里乍看不出来但计数阈值怎么调都调不对。我在刚开始写躯干角度时就在这翻过车排查了整整一个晚上最后把每个关节角的实时数值打印到日志里对比真实姿态才发现是向量端点取错了。建议你在调试阶段把关键点坐标和计算出的角度实时渲染在预览画面上旁边标注角度数值人动一下就能看出角度随动作的趋势对不对。这一步能帮你省掉大量的“为什么计数不准”的排查时间。3. 多动作适配与UI反馈不是每个动作都靠同一组阈值3.1 动作库的抽象设计不同动作对应不同的关键点和角度定义。深蹲看膝关节俯卧撑看肘关节开合跳看肩关节和手腕位置高抬腿看髋关节与膝关节的配合。如果每个动作单独写一套逻辑代码会变得又臭又难维护。我选择做一个动作接口每种动作实现同一个回调interface ExerciseCounter { fun update(landmarks: ListLandmark): Int? }update返回null表示当前帧没有完成新的一次计数返回非空则表示本次更新正好跨过了一次完整循环。这样界面层只要拿到返回次数直接刷新数字即可每种动作自己管好自己的阈值和状态。开合跳比较特殊它不是单关节角度变化而是手脚位置同时变化。我处理方式是同时看手腕相对肩膀的高度以及双脚间距相对骨盆宽度的比例两个条件都满足才判断为一次动作。这种“多条件同时判定”在计数里很常见不是只看一个角度而是人物多个关键点协同判断。如果只看手腕你举手时手臂晃动会被误计只看腿部走路时也会误触发。两个条件互相佐证准确率会高很多。每种动作的阈值设置我在代码里做成了配置文件支持热加载。这样发布新版本不用重新打包用户也可以根据自己的动作幅度微调阈值。开源项目里很多使用者并不都是做标准深蹲的尤其是初学者下蹲不到底阈值卡死了体验会非常差。把这些参数暴露出来反而是最实用的设计。3.2 UI反馈次数显示和动作指导UI这一层容易被低估但它直接影响用户是否愿意坚持用。我做的反馈分三层实时次数、阶段性提示、异常提醒。实时次数就是主页面上最大的那个数字每次计数加一数字旁边带一个轻微的缩放松散动画给用户“这次动作被收到了”的确认感。姿态估计天然有几百毫秒延迟如果用户做完一个动作完全看不到任何即时反馈很快就会认为自己白做了。这个动画不需要复杂一个缩放加透明度变化就够了。阶段性提示是另一种反馈当连续几次动作幅度都不达标时画面顶部会出现“角度过小试着蹲得更低一些”的提示。我是通过滑动窗口统计最近5次动作的最小角度低于阈值就提示。这相当于给用户一个辅助训练教练的角色而不是一个干巴巴的计数工具。异常提醒解决的是另一个问题用户不在画面内或者侧身面对镜头导致关键点不可见。我会检查关键点的置信度如果髋、膝、踝任一关键点置信度连续20帧低于0.5就停止计数并在画面上提示“请面对镜头确保全身入画”。这里要特别强调置信度检查必须在计数逻辑之前做否则人在画面边缘时坐标坐标很容易漂移计数会突然跳一下。3.3 反馈节奏和计数的异步关系姿态估计推理是异步的大概率跑在单独线程UI刷新需要保证流畅不能因为推理卡顿连点击都没反应。所以我的架构是相机帧采集线程只管送帧给推理引擎推理结果通过Handler或协程发回主线程更新UI。计数逻辑放在推理线程里执行只把最终结果传给界面。这样即使推理速度波动UI的触摸响应始终是顺畅的。这里我想提醒一点不要为了省事把计数逻辑和姿态估计结果串行塞到同一帧循环里。如果推理一帧需要50毫秒计数逻辑里再加个平滑滤波、算多个角度单帧就可能逼近60毫秒直接掉到20帧以下。计数逻辑很轻但架构上的解耦带来的稳定性收益远大于那几行代码的复杂度。4. 性能优化与真机实操让计数App跑得更稳更省电4.1 模型选型和输入分辨率运动计数对实时性要求高模型不能一味选精度高的。以我们团队在某款中低端测试机上的实测为例TFLite姿态模型在输入分辨率256x256时单帧推理约35毫秒算上前后处理整体帧率能稳定在25帧以上把输入分辨率提到512x512后精度确实有提升但推理时间直接翻倍中等偏下的设备会明显卡顿。务实的选择是保持256x256输入关键点输出精度已经足够。计数逻辑用的是关节角度角度本身对关键点的亚像素偏差没那么敏感。深蹲时膝关节点偏几个像素角度误差大概在2到5度完全在双阈值缓冲范围内。如果你真的需要更高精度建议优先检查模型对特定动作的识别率而不是盲目推高输入分辨率。推理引擎的线程数也需要调。MediaPipe Task API里可以设置CPU delegate的线程数。有些开发者习惯直接把线程数拉满但姿态模型在推理时不是线性扩展的线程太多反而会因为调度开销拖慢速度。我实测在四核设备上设2到3个线程效果最好。具体数字不同机型有差异但大部分项目的默认值都能直接用不用过度调优。4.2 渲染和坐标映射的坑姿态估计返回的关键点坐标是归一化的0到1之间映射到相机预览画面上时要注意镜像和裁剪。前置摄像头默认会有镜像效果如果你把关键点直接画到预览上你会发现骨架像照镜子一样是反的用户会产生“左右反了”的错乱感。解决方案是画骨架时对X坐标做一次镜像处理val flippedX 1f - landmark.x还有一个“取景框”问题关键点是相对整个画面归一化的但画面裁剪后骨架可能超出预览显示区域。我在调试时不只画骨架还把取景范围的可视边界显示出来确保用户知道“人必须在这个框里才有效”。当用户偏离取景框时坐标仍然有输出但角度判断已经不可信这时候自动暂停计数更合理。4.3 发热和耗电的控制长时间运行姿态模型手机的发热和耗电是绕不开的。用户在App里做完一组动作往往要持续十到二十分钟中低端机如果满载跑发热掉电会非常明显。我采取的方案是摄像头帧率和推理帧率分离。相机采集可以保持30帧但推理不必每帧都跑。我通过丢帧策略控制推理频率比如每两帧或每三帧才送一次推理。计数用的角度变化是连续而且缓慢的一般20到25帧每秒的推理采样已经足够每帧推理反而浪费电。丢帧后模型稳定运行的功耗能下降不少用户在长时间训练时不容易抱怨手机发烫。那段时间我收到过不少反馈说“用十分钟手机就烫手”后来加了这层控制后体感温度明显下降。这里也顺带说一句姿态估计在后台没有训练场景时不应该持续运行。如果用户切到后台最好直接释放模型实例而不是让它继续在后台空转消耗资源。5. 常见问题与排查技巧实录5.1 计数偏多或偏少计数偏多最常见的原因是阈值缓冲带太小。比如你把高阈值设成120、低阈值设成110只有10度的缓冲人只要稍微停留在临界附近或者动作速度不均匀就容易来回穿越。我通常建议高阈值和低阈值相差至少30度。以深蹲为例站直180、蹲到底80的话低阈值设100、高阈值设140比较合理。计数偏少往往是动作幅度没达到阈值。比如用户做浅蹲膝盖最大弯曲只到120度而你的低阈值设成100度那永远触发不了。解决方案是把阈值从固定值改成相对值先记录用户在一段时间内角度的最大值和最小值按比例取中间区间来判定。比如自动校准后将角度范围中的前20%作为高位区间后20%作为低位区间。这样不同体型、不同动作幅度的人都能适应。5.2 侧身和遮挡问题侧身时人体左右关键点会重合模型输出的关键点经常左右互换。比如本来左手的关键点跑到右边去了。姿态模型通常通过归一化坐标和置信度来做左右判定但仍可能出错。我处理的方法是运动计数动作尽量只依赖单侧关键点比如深蹲只算右侧髋-膝-踝。左右互换时动作逻辑仍能大体保持不会像要求左右手分别判定的动作那样直接崩坏。遮挡则在俯卧撑场景里高发身体压到最低点时手肘角度趋近为零但手肘关键点容易被躯干遮挡。我加了一个规则如果手肘置信度很低但肩和腕的置信度依然可靠就用手腕相对肩膀的位置变化来判断动作周期。这相当于给动作计数加了一个降级策略主传感器失效时用备选传感器顶替。5.3 测试时看起来正常一到真机就失灵这种情况八成是线程和生命周期的问题。开发机上性能强后台各种任务不打架真机上资源紧张一旦相机线程和推理线程同时抢CPU结果队列就会积压。我用下来最有效的手段是给推理线程设一个超时监控如果某一帧推理超过80毫秒直接丢弃该帧不再等待结果而界面始终按上一次有效结果刷新。这样线程再怎么拥堵UI也不会越积越卡。另外Android系统对后台相机的限制比较多App切到后台再回来Camera可能已经释放掉了姿态模型实例也可能失效。我在项目里统一用“onPause时释放、onResume时重建”的策略绝不在后台访问模型和相机。这一条如果你没处理好很容易出现“来回切换几次App就闪退”的反馈。5.4 缓存机型的适配问题不同机型的相机预览尺寸差异很大有的支持1080p有的只有720p。归一化坐标虽然和预览尺寸无关但在做取景框提示时不同屏幕比例会导致骨架显示偏移。我在代码里根据预览实际的裁剪区域做了修正把预览矩形和取景矩形统一到同一坐标系再画骨架。这样一来无论屏幕是全面屏还是传统16比9提示框里的骨架位置都是准的。6. 开源项目的维护心得和下一步规划写完计数逻辑、UI和性能优化之后项目基本可以跑起来用了。但我必须说开源项目的价值不只在于功能完整更在于别人能读懂、能改、能顺手扩展。所以我最后再做了一件事把计数逻辑里所有阈值参数全部集中到一个配置类里并写了注释说明每个参数对应哪个动作、调大或调小会产生什么影响。几个使用者提了PR给动作库新加了波比跳和其他几个动作靠的就是这套配置能看懂、改得动。我个人在几次实际迭代里体会最深的一点是计数应用的核心不在模型精度而在状态机的鲁棒性。模型把关键点定位出来的准确率哪怕稍微低一点只要状态机设计得有容错计数结果依然可以稳定反过来模型再准状态机没有防抖动、没有阈值缓冲用户就一定会遇到“做了10个却显示12个”的尴尬。后续我打算继续做的是给每个动作加入“动作标准度评分”把每次循环的角度极值记录下来与标准动作区间对比输出一个训练质量的参考值。同时把计数结果和某个实操日志打通让用户可以回顾当天每个动作的总次数和平均幅度变化。这些数据能在运动反馈上形成闭环让一个计数工具慢慢变得更像一个训练助理。这个系列如果继续往下写我会重点分享动作标准度评分的设计思路还有低端机上姿态模型量化压缩的一些实测数据。如果你们在接入自己的运动项目时遇到了类似问题欢迎照着这套结构去套先算角度再设双阈值然后加状态锁定最后再考虑性能优化。这套流程一旦熟了不只是深蹲和开合跳任何周期性动作都能快速做出一个靠谱的计数功能。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑