小程序集成MoveNet:动作识别从选型到性能优化实战

发布时间:2026/10/24 3:57:36
小程序集成MoveNet:动作识别从选型到性能优化实战

动作识别这件事,几年前还是实验室里的稀罕玩意,现在已经被压进了手机摄像头和浏览器里。我最近把谷歌的 MoveNet 模型接进了一个小程序里做人体关键点检测,从选型到跑通再到优化帧率,踩了不少坑,也攒了一些可以直接抄作业的经验。这篇文章就围绕“小程序 + MoveNet 动作识别”这条线,把核心思路、模型细节、实操步骤和排查技巧一次讲透。

先说清楚这是什么:MoveNet 是一个轻量级的人体姿态估计模型,能从图像里定位出人体的 17 个关键点(鼻子、眼睛、肩膀、手肘、手腕、髋、膝、踝等),输出每个点的坐标和置信度。它有两个版本,Lightning 主打速度,Thunder 主打精度。把它放进小程序里,就能做健身动作计数、体态纠正、互动游戏、跌倒检测这类应用。适合谁看?有一定前端基础、想在小程序里落地 AI 能力的开发者,以及想了解端侧姿态估计整体链路的同学。哪怕你之前没碰过模型部署,跟着思路走也能明白每一步在干什么。

1. 整体方案设计与选型思路拆解

1.1 为什么是 MoveNet 而不是别的姿态模型

做端侧动作识别,模型选型基本围绕三个指标打架:精度、速度、体积。市面上常见的姿态估计模型里,OpenPose 精度高但模型大、推理慢,压根不适合塞进小程序;MediaPipe 的 BlazePose 也不错,但它的生态绑定较深;MoveNet 的优势在于它是专门为端侧实时推理设计的,Lightning 版本在移动端能做到 30 FPS 以上,模型体积只有几 MB 级别,这对小程序这种对包体和内存都敏感的环境非常关键。

我当时的判断逻辑是这样的:小程序运行环境本质是一个受限的 JS 容器,能调用的算力有限,WebGL 和 WebAssembly 是主要的加速手段。MoveNet 官方提供了 TensorFlow.js 版本,可以直接在浏览器/小程序环境里跑,省去了自己转换模型的麻烦。这一点直接决定了选型——能用现成 TFJS 模型,就不要自己去折腾 ONNX 转 CoreML 再转各种格式,链路越长坑越多。

Lightning 和 Thunder 怎么选?我的建议是先上 Lightning 把链路跑通,等业务逻辑稳定了再评估要不要换 Thunder。Lightning 的输入分辨率是 192x192,Thunder 是 256x256,后者精度更好但推理耗时明显更高。对于动作计数这类场景,关键点稍微抖一点其实不影响计数逻辑,Lightning 完全够用;如果是体态纠正这种对关节角度敏感的,才值得上 Thunder。

1.2 小程序端跑模型的整体架构

整个链路可以拆成四层:采集层、预处理层、推理层、后处理层。采集层负责从摄像头拿到视频帧,小程序里用camera组件配合CameraContext的onCameraFrame回调拿帧数据;预处理层把帧转成模型需要的张量格式,包括缩放、归一化;推理层调用 TFJS 加载的 MoveNet 模型做前向计算;后处理层把输出的关键点做平滑、阈值过滤,再交给业务逻辑。

这里有个关键决策:推理放在哪。小程序有主线程和逻辑层,如果把推理直接放主线程,帧率一高就会卡 UI。我的做法是把推理逻辑尽量做成异步,利用 TFJS 的 WebGL 后端把计算压到 GPU 上,主线程只负责调度。实测下来,Lightning 在中等配置手机上单帧推理能压到 20ms 以内,配合跳帧策略(比如每两帧处理一次),整体体验是流畅的。

提示:小程序对包体积有硬限制,TFJS 加上模型文件很容易超标。务必用分包加载,把模型和推理代码放进独立分包,主包只留入口。

1.3 动作识别与姿态估计的区别要先想明白

很多人一上来就说“我要做动作识别”,其实姿态估计和动作识别是两回事。MoveNet 给你的是每一帧的 17 个关键点坐标,这叫姿态估计;而“这个人在做深蹲还是俯卧撑”属于动作分类,需要在关键点序列上再叠一层时序模型或规则判断。我这次的项目里,动作分类是用规则做的——比如通过髋、膝、踝的角度变化判断深蹲的下蹲和起身,通过手腕相对肩膀的高度判断举手。规则法的好处是零训练成本、可解释性强,坏处是泛化差,换个动作就得重新写规则。

如果你要做的是多动作、复杂动作的识别,那就得在 MoveNet 输出的关键点序列上再训练一个轻量时序分类器(比如基于滑动窗口的 LSTM 或简单的全连接网络)。但这一步会显著增加复杂度和包体,建议先把姿态估计跑稳,再考虑往上叠。这个取舍我在项目初期纠结了很久,最后选择规则法,因为业务只需要识别三四个固定动作,规则完全够用,还省了一大块模型体积。

2. 核心细节解析与实操要点

2.1 MoveNet 的输入输出到底长什么样

先把模型的输入输出吃透,后面所有代码都是围绕它转的。MoveNet 的输入是一个形状为[1, 192, 192, 3]的张量(Lightning),也就是单张 192x192 的 RGB 图像,像素值归一化到 0 到 1 之间。注意是 NHWC 格式,不是很多 CV 模型习惯的 NCHW,这个格式搞错会导致输出完全乱掉。

输出是一个形状为[1, 1, 17, 3]的张量。17 是关键点数量,每个点三个值:y 坐标、x 坐标、置信度。这里有个特别容易踩的坑——输出顺序是 y 在前 x 在后,而且坐标是归一化到 0 到 1 的相对值,需要乘以原图宽高才能还原到像素坐标。我第一次接的时候按 x,y 顺序取,结果画出来的骨架整个是歪的,排查了半天才发现是顺序问题。

17 个关键点的索引顺序是固定的:0 鼻子、1 左眼、2 右眼、3 左耳、4 右耳、5 左肩、6 右肩、7 左肘、8 右肘、9 左腕、10 右腕、11 左髋、12 右髋、13 左膝、14 右膝、15 左踝、16 右踝。这个顺序必须记牢,画骨架连线、算关节角度全靠它。

2.2 预处理:从摄像头帧到模型张量

小程序拿到的摄像头帧是 ArrayBuffer 格式的 RGBA 数据,要转成模型能吃的张量,中间要做几步转换。第一步是把 RGBA 转成 RGB,去掉 alpha 通道;第二步是按比例缩放到 192x192,这里要注意保持宽高比,直接拉伸会导致关键点位置偏移;第三步是归一化,把 0-255 的像素值除以 255。

缩放这一步我建议用 canvas 来做,把帧数据画到离屏 canvas 上再getImageData,比纯 JS 逐像素处理快得多。具体做法是创建一个 192x192 的离屏 canvas,用drawImage把原始帧按比例绘制进去,空白部分补黑边。这样既保证了宽高比,又利用了浏览器的图像缩放优化。

// 预处理核心逻辑示意 const offscreen = wx.createOffscreenCanvas({ type: '2d', width: 192, height: 192 }); const ctx = offscreen.getContext('2d'); // 计算保持宽高比的绘制区域 const scale = Math.min(192 / frameWidth, 192 / frameHeight); const drawW = frameWidth * scale; const drawH = frameHeight * scale; const offsetX = (192 - drawW) / 2; const offsetY = (192 - drawH) / 2; ctx.fillStyle = '#000'; ctx.fillRect(0, 0, 192, 192); ctx.drawImage(sourceImage, offsetX, offsetY, drawW, drawH); const imageData = ctx.getImageData(0, 0, 192, 192);

拿到 imageData 之后,用tf.browser.fromPixels转成张量,再div(255)归一化,最后expandDims(0)加上 batch 维度。这一套下来就是模型的标准输入。

注意:补黑边的偏移量一定要记录下来,后处理还原坐标时要把这个偏移和缩放比例反向算回去,否则关键点会整体偏移。这是新手最容易忽略的一步。

2.3 后处理:关键点平滑与置信度过滤

模型原始输出是抖的,尤其是 Lightning 版本,单帧之间的关键点会有几个像素的跳动。直接拿原始坐标画骨架,视觉上会“抽搐”。解决办法是做时序平滑,我用的是指数移动平均(EMA):当前帧的坐标 = 上一帧平滑值 × 0.7 + 当前帧原始值 × 0.3。系数 0.7 是我试出来的,太大跟手性差,太小平滑不够,0.6 到 0.8 之间都可以按场景调。

置信度过滤也很关键。MoveNet 每个点都带一个 0 到 1 的置信度,低于阈值(我设的 0.3)的点视为不可信,画骨架时直接跳过。比如人只露出上半身,下半身的关键点置信度会很低,这时候如果硬画腿的连线,就会出现莫名其妙的线条。过滤掉低置信度点,骨架看起来就干净多了。

还有一个细节是坐标还原。模型输出是归一化坐标,要先乘回 192x192,再减去补黑边的偏移,最后除以缩放比例还原到原始帧坐标。这一串变换我封装成了一个函数,每次后处理都走它,避免手算出错。

2.4 跳帧策略与性能平衡

小程序里如果每帧都跑推理,中低端机很容易掉帧。我的策略是跳帧:摄像头回调频率可能是 30 FPS,但推理只跑 15 FPS,中间帧用上一帧的平滑结果顶上。这样视觉上几乎看不出差别,但算力省了一半。

跳帧的间隔不是拍脑袋定的,要看单帧推理耗时。我的做法是在启动时跑几次预热推理,测出平均耗时,然后动态决定跳帧数:如果单帧超过 33ms,就两帧处理一次;超过 50ms,就三帧处理一次。这个自适应逻辑让同一套代码在不同机型上都能跑得比较稳。

let lastInferTime = 0; let frameCount = 0; function onFrame(frame) { frameCount++; const now = Date.now(); const interval = now - lastInferTime; // 根据上次推理耗时动态决定是否跳过 const skipThreshold = interval > 50 ? 3 : interval > 33 ? 2 : 1; if (frameCount % skipThreshold !== 0) return; lastInferTime = now; runInference(frame); }

3. 实操过程与核心环节实现

3.1 环境搭建与依赖引入

小程序里用 TFJS,核心依赖是@tensorflow/tfjs-core、@tensorflow/tfjs-converter和@tensorflow/tfjs-backend-webgl。注意不要引@tensorflow/tfjs这个全家桶,它会把所有后端都打进来,包体积直接爆炸。按需引入这三个就够了,webgl 后端负责 GPU 加速,converter 负责加载模型。

模型文件我放在分包里,用tf.loadGraphModel加载。MoveNet 的 TFJS 模型是 graph model 格式,不是 layers model,加载方式别搞错。加载是异步的,建议在小程序启动时就预加载,别等到用户点开摄像头才开始,否则首帧会卡好几秒。

import * as tf from '@tensorflow/tfjs-core'; import '@tensorflow/tfjs-backend-webgl'; import { loadGraphModel } from '@tensorflow/tfjs-converter'; async function initModel() { await tf.setBackend('webgl'); await tf.ready(); const model = await loadGraphModel('/subpackage/models/movenet_lightning/model.json'); return model; }

这里有个坑:小程序的 webgl 后端在某些低端机上初始化会失败,需要做降级处理,回退到 cpu 后端。虽然 cpu 慢很多,但至少能跑起来,不至于白屏。

3.2 摄像头帧采集与格式转换

小程序拿摄像头帧用CameraContext.onCameraFrame,回调里给的是{ data, width, height },data 是 RGBA 的 ArrayBuffer。要把它转成 canvas 能用的图像,得先构造一个Uint8ClampedArray,再通过ImageData塞进 canvas。

const cameraCtx = wx.createCameraContext(); const listener = cameraCtx.onCameraFrame((frame) => { const { data, width, height } = frame; const clamped = new Uint8ClampedArray(data); const imageData = new ImageData(clamped, width, height); // 后续绘制到离屏 canvas 做预处理 processFrame(imageData, width, height); }); listener.start();

注意onCameraFrame的回调频率和摄像头设置有关,可以在camera组件上设frame-size为 small 来降分辨率,减少数据量。分辨率不是越高越好,192 的模型输入用 480p 的帧完全够,用 1080p 纯属浪费带宽和内存。

3.3 推理执行与结果解析

推理这一步就是把预处理好的张量喂给模型,拿输出再解析。核心代码不长,但每一步都要小心。

async function runInference(imageData) { const tensor = tf.tidy(() => { const img = tf.browser.fromPixels(imageData); const resized = tf.image.resizeBilinear(img, [192, 192]); const normalized = resized.div(255.0); return normalized.expandDims(0); }); const output = await model.executeAsync(tensor); const keypoints = await output.data(); tensor.dispose(); output.dispose(); return parseKeypoints(keypoints); }

tf.tidy用来自动回收中间张量,避免内存泄漏——小程序环境内存本来就紧张,不回收的话跑几分钟就崩。executeAsync比同步的execute更适合小程序,不会阻塞主线程。输出拿到后是扁平化的 Float32Array,长度 51(17 个点 × 3 个值),按 y、x、score 的顺序解析。

解析完还要做坐标还原和平滑,前面 2.3 节讲过了,这里不重复。还原后的关键点数组直接交给业务层,画骨架或者算角度都行。

3.4 动作判定规则的落地

以深蹲计数为例,说下规则怎么落地。核心是算膝盖角度:用髋、膝、踝三个点的坐标,通过向量点积算夹角。下蹲时膝盖角度会从接近 180 度降到 90 度左右,起身再回到 180 度。计数逻辑就是检测角度从大于 160 度降到小于 100 度再回到大于 160 度,算一次完整动作。

function calcAngle(a, b, c) { const ab = { x: a.x - b.x, y: a.y - b.y }; const cb = { x: c.x - b.x, y: c.y - b.y }; const dot = ab.x * cb.x + ab.y * cb.y; const magAB = Math.hypot(ab.x, ab.y); const magCB = Math.hypot(cb.x, cb.y); const cos = dot / (magAB * magCB); return Math.acos(Math.max(-1, Math.min(1, cos))) * 180 / Math.PI; }

角度算出来会有抖动,所以判定要加状态机:当前处于“站立”状态时,角度跌破 100 度才切到“下蹲”;处于“下蹲”状态时,角度回升超过 160 度才切回“站立”并计数加一。这个状态机避免了角度在阈值附近抖动导致的重复计数。阈值 100 和 160 是我根据实际测试调的,不同人身高体型有差异,可以做成可配置项。

提示:算角度前一定要确认三个关键点的置信度都高于阈值,否则角度算出来是错的。我遇到过用户侧身时一侧膝盖被遮挡,置信度低,角度乱跳导致误计数,加了置信度校验后就稳了。

4. 常见问题与排查技巧实录

4.1 关键点错位与骨架歪斜

这是最高频的问题,表现是画出来的骨架整体偏移或者镜像翻转。排查顺序是这样的:先确认输出解析顺序是不是 y、x、score,这个错了整个就歪;再确认坐标还原时有没有减去补黑边的偏移;最后确认摄像头帧是不是前置摄像头需要镜像。前置摄像头默认是镜像的,如果不做水平翻转,用户会看到自己的骨架左右反了。

我踩过最隐蔽的一个坑是 canvas 的drawImage在部分机型上对 ImageData 的处理有偏差,导致缩放后的图像和预期差几个像素。解决办法是缩放后重新getImageData读一次,确保拿到的是实际绘制结果,而不是假设它按预期绘制了。

4.2 帧率低与内存增长

帧率低通常是三个原因:推理没走 GPU、每帧都创建新张量没回收、预处理用了纯 JS 逐像素循环。第一个查后端是不是 webgl,第二个查有没有用tf.tidy或手动dispose,第三个把预处理换成 canvas 绘制。

内存增长更隐蔽,表现是跑几分钟后小程序闪退。根因基本都是张量没释放。我的经验是:凡是tf.开头的创建操作,都要么包在tidy里,要么手动 dispose。尤其是executeAsync返回的输出张量,很多人忘了释放,跑一会儿内存就爆了。可以在开发阶段加个定时打印tf.memory().numTensors,数字持续增长就说明有泄漏。

4.3 模型加载失败与分包配置

模型加载失败最常见的原因是路径不对或者分包没配对。小程序的分包要求模型文件放在分包目录下,主包引用分包资源要走正确的相对路径。另外模型文件(model.json 和权重分片)要一起放进去,少一个就加载失败。

还有一个坑是模型文件大小超限。单个分包有大小限制,模型加上 TFJS 依赖很容易逼近上限。我的做法是只保留 Lightning 模型,Thunder 按需动态下载(如果业务真的需要),并且把权重分片做量化压缩,能省不少体积。

问题现象可能原因排查动作解决方式
骨架整体偏移坐标解析顺序错检查是否按 y,x,score 解析修正解析顺序
骨架左右反了前置摄像头镜像确认是否做了水平翻转加镜像处理
关键点抖动严重无时序平滑检查后处理有无 EMA加指数移动平均
帧率低于 15 FPS未走 GPU 或未跳帧查后端类型和跳帧逻辑切 webgl 后端并跳帧
运行几分钟闪退张量未释放打印 numTensors 观察用 tidy 或手动 dispose
模型加载报错路径或分包配置错检查文件路径和分包声明修正路径并确认分包

4.4 光照与遮挡导致的识别不稳

MoveNet 对光照和遮挡比较敏感。逆光环境下关键点置信度会整体下降,侧身时被遮挡的一侧肢体容易丢点。这些是模型本身的局限,靠代码只能缓解不能根治。我的缓解手段有三个:一是置信度阈值不要设太高,0.3 左右比较平衡;二是对丢失的点做短时预测,用上一帧的位置顶替,超过一定帧数还没恢复就标记为丢失;三是引导用户正对摄像头、保证光照均匀,从采集端减少问题。

实际项目里我还加了一个“识别质量”提示,当连续多帧有效关键点少于 10 个时,在界面上提示用户调整位置或光线。这个小功能显著降低了用户“怎么没反应”的困惑,体验提升很明显。

5. 性能优化与工程化收尾

5.1 模型量化与包体压缩

如果包体实在压不下来,可以考虑对模型做量化。TFJS 支持把权重从 float32 量化到 int8 或 float16,体积能降一半到四分之三,精度损失在可接受范围内。量化后的模型加载和推理都更快,对小程序这种环境很友好。不过量化要用官方工具离线做,做完替换模型文件即可,代码不用改。

包体压缩的另一招是剔除没用到的算子。TFJS converter 加载模型时会注册一堆算子,如果模型只用了其中一部分,可以通过自定义构建来裁剪。这一步比较进阶,收益也有限,建议先把分包和量化做好,再考虑这个。

5.2 首帧体验与预加载策略

用户点开摄像头到看到骨架,中间有模型加载和预热的时间,这段空白期体验很差。我的做法是页面一进入就后台预加载模型,同时显示一个加载动画;模型 ready 后再启动摄像头。预热阶段跑两三次空推理,把 GPU 的 shader 编译开销提前消化掉,这样正式推理的第一帧就不会卡。

预加载还可以和分包下载结合:把模型分包设为进入页面时预下载,用户还在看引导页的时候模型已经在后台下好了,点开始就能直接用。这个细节对留存的影响比想象中大,首帧卡三秒和秒开,用户感受完全是两回事。

5.3 多动作扩展的架构预留

虽然当前只做了三四个动作,但架构上要给扩展留口子。我的做法是把动作判定抽象成独立的“规则模块”,每个动作一个模块,统一接口:输入关键点序列,输出动作状态和计数。新增动作只需加一个模块并注册,不用改主流程。这样后续要加开合跳、高抬腿,工作量就是写一个规则文件的事。

关键点序列的缓存也要设计好。我维护了一个固定长度的环形缓冲区,存最近 N 帧的平滑后关键点,动作判定模块按需读取。缓冲区长度按动作周期定,深蹲一个周期大概一两秒,30 FPS 下存 60 帧足够。这个设计让时序相关的判定逻辑写起来很顺手,也方便后续接时序模型。

我在实际项目里最大的体会是:端侧 AI 的难点从来不在模型本身,而在模型和运行环境之间的那层“胶水”——预处理、后处理、内存管理、性能调度,这些琐碎的东西才决定最终体验。MoveNet 已经把模型这端做得很好了,剩下的就是把工程细节抠到位。如果你也在做类似的事,建议先把单帧推理跑通、把关键点画对,再一步步往上叠平滑、跳帧、动作规则,别一上来就想着全链路,那样排查问题会非常痛苦。