小程序集成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 已经把模型这端做得很好了,剩下的就是把工程细节抠到位。如果你也在做类似的事,建议先把单帧推理跑通、把关键点画对,再一步步往上叠平滑、跳帧、动作规则,别一上来就想着全链路,那样排查问题会非常痛苦。