在浏览器里做人体姿态估计,前几年基本还停留在调用远端API或者后端跑Python推理的阶段。今天这篇换个路子,咱们纯前端、纯JavaScript,直接调用MediaPipe的BlazePose模型,配合GHUM人体参数模型拿到的3D关键点,再用TensorFlow.js把整个推理链路在浏览器里跑起来。整套方案落地之后,摄像头对准人,页面上直接能看到一个带景深的3D人体骨架在转动,延迟低到可以实时交互。这篇文章适合对姿态检测有基础了解、想在Web端做落地验证或者做交互原型的开发者,我会把原理、选型、代码、踩坑一条龙讲清楚。
1. 核心概念拆解:BlazePose、GHUM、TensorFlow.js到底各管哪一段
1.1 BlazePose不是普通的关键点检测模型
很多人一听到姿态检测,第一反应就是OpenPose或者PoseNet。BlazePose从定位上就和它们不太一样,它是谷歌为移动端和浏览器实时推理专门设计的轻量级模型,普通人拿普通笔记本摄像头跑都能到30帧以上。
BlazePose的核心设计值得一说:它采用两阶段的检测-跟踪架构。第一阶段是人体检测器,找到画面里的人框;第二阶段才对框内的人做关键点回归。这么做的好处非常直接——关键点回归网络不需要在整张图上做滑窗扫描,计算量大幅下降。而跟踪机制进一步做了帧间复用,上一帧的人框位置经过光流和对齐后直接作为下一帧的候选框,省掉一大半重复检测的开销。
关键点数量上,BlazePose输出33个身体关键点,比COCO的17点多了将近一倍。多出来的点集中在面部轮廓、手掌、脚踝这些部位,尤其是面部和手部的关键点,为后续的表情捕捉、手势识别留了接口。这里要注意,多出来的点并非简单标注,而是跟GHUM模型一一对应,这也是它能够输出真正3D坐标的前提。
1.2 GHUM模型在3D关键点生成中的角色
GHUM是微软研究院提出的人体参数化模型,全称是Generative Human Model。它的特点是可微分、可学习,本身就是一个神经网络生成器,输入姿态参数和形状参数,输出人体网格顶点和关键点位置。
BlazePose的3D标注数据,官方给出的方案是把GHUM模型拟合到二维关键点数据上,用模型生成的三维关键点作为监督信号。也就是说,BlazePose训练时看到的3D标注不是手工标注的,而是GHUM这个参数化人体模型生成的。
用GHUM生成标注有一个很好的副产品:坐标系是对齐的。所有3D关键点使用统一的以髋部中心为原点的局部坐标系,X轴朝画面右侧,Y轴朝上,Z轴朝向观察者方向(也就是指向屏幕外,越接近摄像头Z值越大)。这就让模型学到的3D关键点天然带上了模型的先验约束,关节角度、肢体长度的合理性都有保证。
有了这层关系,我们在前端拿到33个3D关键点之后,不用再去绞尽脑汁换算坐标系或者做尺度归一化,直接使用GHUM坐标系的约定来处理就对了。髋部中心在数组索引里是第24个关键点(Index 24),这一点在后面的姿态角计算里会反复用到。
1.3 TensorFlow.js在链路里的定位
TensorFlow.js在这个方案里的角色比较微妙。它并不是唯一跑推理的引擎——MediaPipe的Tasks API默认自带基于WASM和WebGL的推理后端。那我们为什么还要扯上TensorFlow.js?
原因有两层。
第一层,MediaPipe底层依赖TFLite格式的模型,而TFLite Runtime的Web端实现跟TensorFlow.js共享大量基础设施。你在浏览器里用MediaPipe加载一个.task文件,本质上是TFLite模型在WebAssembly/WebGL后端上执行,这跟TensorFlow.js跑TFLite模型的机制是一脉相承的。
第二层,也是更实际的意义:当你需要把BlazePose的输出接入自定义神经网络,比如用关键点序列做动作分类、用3D坐标做异常检测,你就必须在TensorFlow.js里写模型推理了。MediaPipe只管输出关键点,而后续所有基于关键点的“智能化”处理,几乎都离不开TensorFlow.js或者ONNX Runtime Web这类工具。
所以我的理解是:MediaPipe负责把关键点“抠”出来,TensorFlow.js负责干活。前端处理的整个闭环,就是这两者配合完成的。
2. 技术选型:为什么不用Python服务器方案
2.1 纯前端方案和传统服务器的核心差异
早期做姿态检测,常规套路是前端把视频帧上传到服务器,Python端用OpenCV加MediaPipe处理,再把关键点JSON返回给前端。这一套逻辑简单,但有几个痛点很难绕过去。
首先是延迟。一帧图像从编码、上传、推理到返回,至少需要80到120毫秒的往返时间,这还建立在服务器性能充沛的前提下。对于实时交互应用,比如体感游戏、健身计数、手势控制,这个延迟是会直接让用户觉得“不跟手”的。
其次是服务器成本。视频帧率30帧每秒,每一帧都上传,带宽和GPU的消耗是很可观的。如果并发用户一多,服务器就得不断扩容,成本压力比开发压力大得多。
纯前端方案的优势就在这里体现出来了。模型文件一次加载后缓存在浏览器里,之后的每一帧推理都在本地完成。整个推理过程零网络请求,延迟只取决于本机算力,帧率稳定后体感上是即时响应的,尤其适合做面向普通用户的Web应用。
2.2 MediaPipe Tasks API和旧版JavaScript解决方案的取舍
MediaPipe以前在浏览器端的方案需要手工搭配模型文件和算子库,配置过程非常容易踩坑,版本之间兼容性问题也多,甚至不同浏览器表现不一致的情况都有。我早期用旧方案做项目的时候,光是解决wasm加载失败就折腾了好几天。
现在官方主推MediaPipe Tasks Vision API,整个体验好了不止一个档次。这个API把模型加载、预处理、推理、后处理封装成了一个高层接口,我们只需要提供模型地址和配置项,PoseLandmarker对象就自动处理了所有细节。
Tasks API带来一个特别实际的好处:模型和运行时完全解耦。你可以把.tflite格式的模型文件放到自己的CDN上,或者干脆用官方托管的地址,换模型不需要改任何逻辑代码。这跟直接在GitHub仓库里找现成代码库相比,可维护性强太多。
所以我的建议是:新项目一律使用Tasks Vision API。如果你在网上翻到教你用旧版的教程,除非是维护老项目,否则可以直接跳过。
3. 实操:纯前端3D姿态检测的完整实现
3.1 搭建最小工程并加载PoseLandmarker
工程方面不用复杂框架,我直接用Vite打底建一个原生JavaScript项目,能省去不少配置时间。
参考资料:初始化项目后,安装依赖就一个包需要处理:
npm init -y npm install @mediapipe/tasks-vision npm install three # 3D可视化会用然后就是一个HTML文件加一个JavaScript入口文件就能撑起整个应用。页面里需要有视频元素用来承载摄像头画面,一个Canvas用来叠加2D骨架,另一个Canvas用来给Three.js画3D骨架。
加载模型的核心代码长这样:
import { FilesetResolver, PoseLandmarker } from "@mediapipe/tasks-vision"; const vision = await FilesetResolver.forVisionTasks( "https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@latest/wasm" ); const poseLandmarker = await PoseLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: "https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_lite/float16/1/pose_landmarker_lite.task", delegate: "GPU" }, runningMode: "VIDEO", numPoses: 1, minPoseDetectionConfidence: 0.5, minPosePresenceConfidence: 0.5, minTrackingConfidence: 0.5 });这里有两个参数值得单独解释。第一个是delegate字段,MEDIAPIPE官方在浏览器端支持CPU和GPU两种委托,默认建议GPU。GPU的WebGL后端推理速度通常比CPU快一到两倍,但GPU的精度普遍比CPU低一点,关键点在画面里大量遮挡的时候偶尔会出现跳变。实测下来我的建议是,演示项目用GPU没问题,如果做动作识别这种对关键点稳定性要求高的场景,CPU反而更稳。
第二个是modelAssetPath,我上面用的是官方托管地址里的lite版本。如果追求更高精度,同一路径下还有full版本,代价是加载体积和推理时间都翻倍。做Web端交互优先选lite,因为抖动和延迟对体验的伤害远大于那么一点精度。
3.2 打开摄像头并进入实时检测循环
摄像头捕获直接用浏览器原生方法:
const video = document.getElementById("video"); const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: "user" } }); video.srcObject = stream; await video.play();注意,PoseLandmarker的runningMode选择的是VIDEO模式,对应的检测接口是detectForVideo,它要求传入一个带时间戳的VideoFrame对象。跟detect(针对静态图像)不同,VIDEO模式内部会利用帧间跟踪信息,让关键点的连续性提升很多。
检测循环用requestAnimationFrame驱动,每次取视频当前帧做推理:
function loop() { if (video.readyState >= 2) { const now = performance.now(); const results = poseLandmarker.detectForVideo(video, now); draw2DSkeleton(results); draw3DSkeleton(results); } requestAnimationFrame(loop); } loop();这里有一个特别容易翻车的点:detectForVideo的第二个参数时间戳必须是递增的,否则内部跟踪逻辑会重置。很多人直接用Date.now()转毫秒,结果发现模型一会有一会没有,就是时间戳抖动导致的。用performance.now()就不会有这个问题,它天然单调递增。
拿到了results之后,我们关心的结构在results.landmarks里。这是一个数组,每个元素是33个关键点的列表(因为设了numPoses: 1,数组长度为1)。每个关键点对象有x、y、z、visibility四个属性,前三个是归一化坐标,范围0到1,visibility是可见性置信度。
3.3 把关键点画成2D骨架并计算关节角度
在把3D可视化之前,先画2D骨架能帮我们快速验证检测链路是不是通的。绘制每个关键点很简单,但连线才是骨架的精髓。MediaPipe官方给了一个连接关系表:比如点11是左肩,点13是左手肘,点15是左腕,它们之间的连线就是手臂。
我项目中用的核心连接关系简化版:
const CONNECTIONS = [ [11, 12], [11, 13], [13, 15], // 左臂 [12, 14], [14, 16], // 右臂 [11, 23], [12, 24], // 躯干 [23, 24], // 髋部 [23, 25], [25, 27], // 左腿 [24, 26], [26, 28] // 右腿 ];画线的时候,别忘了缩放:
function draw2DSkeleton(results) { const ctx = canvas2d.getContext("2d"); ctx.clearRect(0, 0, canvas2d.width, canvas2d.height); const landmarks = results.landmarks[0]; if (!landmarks) return; CONNECTIONS.forEach(([i, j]) => { const a = landmarks[i]; const b = landmarks[j]; ctx.beginPath(); ctx.moveTo(a.x * canvas2d.width, a.y * canvas2d.height); ctx.lineTo(b.x * canvas2d.width, b.y * canvas2d.height); ctx.stroke(); }); landmarks.forEach((p) => { ctx.beginPath(); ctx.arc(p.x * canvas2d.width, p.y * canvas2d.height, 4, 0, Math.PI * 2); ctx.fillStyle = "#00ffcc"; ctx.fill(); }); }这个阶段如果骨架能跟着人动,说明链路已经通了,下一步才是重头戏——3D可视化。
顺便讲讲关节角度计算,因为姿态检测最终一定会落到“这个人手臂抬了多少度”“膝盖弯曲是否标准”这类问题上。角度计算的原理是三维向量夹角,用点积公式:
function angle3D(a, b, c) { const v1 = { x: a.x - b.x, y: a.y - b.y, z: a.z - b.z }; const v2 = { x: c.x - b.x, y: c.y - b.y, z: c.z - b.z }; const dot = v1.x * v2.x + v1.y * v2.y + v1.z * v2.z; const len1 = Math.hypot(v1.x, v1.y, v1.z); const len2 = Math.hypot(v2.x, v2.y, v2.z); return Math.acos(dot / (len1 * len2)) * 180 / Math.PI; }以肘关节为例,b点是肘部(关键点13),a点是肩膀(关键点11),c点是手腕(关键点15),三者的三维坐标代入这个函数,就能算出肘关节的弯曲角度。这就是后面做深蹲计数、开合跳判定的基础。
这里有一个我对新手的建议:做动作识别前先花时间校准动作。不同人的肢干比例不一样,直接用关键点坐标判断动作幅度有时候会误判。比如浅蹲和深蹲的区分,基于髋关节和膝关节的角度阈值来做判断,比基于像素位移可靠得多。
3.4 用Three.js绘制3D骨架
3D可视化的思路是把关键点的归一化坐标映射到Three.js的三维空间里,然后用线段把关键点连起来。这里有个细节需要注意:2D画布坐标系是原点在左上角,Y轴向下;而3D渲染里我们通常希望Y轴向上,所以渲染前要做一次坐标翻转。
我的做法是写一个简单的转换函数:
function landmarksToVector3(lm, scale = 3) { return new THREE.Vector3( (lm.x - 0.5) * scale, (0.5 - lm.y) * scale, lm.z * scale ); }把归一化坐标以0.5为中心平移到原点,然后把Y轴方向翻转,Z轴直接放大使用。scale可以根据场景大小调,我这里取3表示半米范围,基本能容纳一个成人。
然后用三种Three.js元素来呈现骨架:关键点用小SphereGeometry球体,连接线用BufferGeometry的线段,为了好看我还会给左右肢体用不同颜色。
const scene = new THREE.Scene(); const camera = new THREE.PerspectiveCamera(50, canvas3d.clientWidth / canvas3d.clientHeight, 0.1, 100); camera.position.set(0, 0, 5); const renderer = new THREE.WebGLRenderer({ canvas: canvas3d, antialias: true }); renderer.setSize(canvas3d.clientWidth, canvas3d.clientHeight); const group = new THREE.Group(); scene.add(group); function update3DSkeleton(results) { // 清空旧骨架 while (group.children.length) group.remove(group.children[0]); const landmarks = results.landmarks[0]; if (!landmarks) return; // 绘制关键点 landmarks.forEach((lm) => { const sphere = new THREE.Mesh( new THREE.SphereGeometry(0.05, 8, 8), new THREE.MeshBasicMaterial({ color: 0x00ffcc }) ); sphere.position.copy(landmarksToVector3(lm)); group.add(sphere); }); // 绘制连接线 const points = CONNECTIONS.map(([i, j]) => { return [ landmarksToVector3(landmarks[i]), landmarksToVector3(landmarks[j]) ]; }); points.forEach(([a, b]) => { const geometry = new THREE.BufferGeometry().setFromPoints([a, b]); const line = new THREE.Line(geometry, new THREE.LineBasicMaterial({ color: 0xffaa00 })); group.add(line); }); }渲染循环里别忘了调用renderer.render,这样整个3D骨架就能实时转动了。再加上OrbitControls插件,还可以在页面上拖拽旋转视角,从不同方向观察姿态。这对做动作评估特别有用,因为正面看不清的角度,转到侧面一眼就能看出来问题。
4. 常见问题与排查技巧实录
4.1 模型加载慢或者加载失败
模型文件本身有5MB左右,托管在Google的存储空间上。国内部分地区访问这个地址偶尔会超时或者速度很慢,这是很多人碰到的第一个坎。
我的解决方案是把模型下载下来放在自己服务器或者CDN上。MediaPipe允许你传入任何可访问的URL作为modelAssetPath,所以完全可以把.task文件部署到自己的静态资源目录里。需要注意两点:一是要配置正确的CORS头,否则浏览器跨域请求会被拦截;二是尽量用HTTPS,因为getUserMedia在非安全上下文下会被禁用。
排查的时候先看Network面板,确认.task文件是否返回200。如果返回200并且Content-Length符合预期但加载还是失败,多半是CORS配置问题,在CORS响应头里加入Access-Control-Allow-Origin: *基本就能解决。
4.2 帧率低和丢帧问题
帧率低的原因通常有三类。第一类是视频流分辨率太高,把640x480的画面直接喂进去就行,画面细节对姿态估计的帮助有限,但计算量会成倍增加。第二类是同时跑了2D绘制和3D渲染,Three.js的渲染其实也吃性能,可以考虑降低canvas3d的分辨率或者降低渲染帧率,比如每两帧推理一次、每帧都渲染。
第三类是我踩过的一个坑:在GPU delegate模式下,移动端的某些GPU驱动对WebGL的支持有兼容性问题,导致推理速度反而比CPU慢。遇到这个情况就切换delegate到CPU,两行代码的事,但很多人卡了很久没方向。
4.3 关键点抖动怎么平滑
姿态估计输出的关键点帧与帧之间有抖动是很正常的,尤其是手和脚这些运动幅度大的部位。直接拿原始坐标去做角度计算,角度值会跳来跳去,根本没法用。
目前我在项目里用的是一阶指数移动平均(EMA)做轻量平滑。原理很简单:当前帧的坐标由上一帧的坐标和当前帧的原始值按权重混合得到。
const smooth = {}; function smoothLandmark(idx, raw, alpha = 0.4) { if (!smooth[idx]) { smooth[idx] = { x: raw.x, y: raw.y, z: raw.z, visibility: raw.visibility }; } else { smooth[idx].x += alpha * (raw.x - smooth[idx].x); smooth[idx].y += alpha * (raw.y - smooth[idx].y); smooth[idx].z += alpha * (raw.z - smooth[idx].z); } return smooth[idx]; }alpha取0.3到0.5之间比较合适。alpha太小响应慢,动作反射弧太长;alpha太大又起不到平滑作用。对于需要实时反馈的场景,我试下来0.35左右是手感最好的。
如果对平滑质量要求更高,可以考虑One Euro Filter这种更专业的自适应低通滤波器,它对低频运动保留细节、对高频抖动抑制明显,效果比EMA好一个档次,但实现复杂度也高不少。
4.4 画面里多个人只检测到一个
PoseLandmarker默认可以设置numPoses大于1来检测多人姿态。但要注意两点:首先numPoses增大会让推理时间增加,因为模型需要在画面里寻找多个目标;其次人数的跟踪逻辑在浏览器端还是有瓶颈,实测达到3人以上,帧率就会明显下降。
实际项目里如果是固定场景、固定机位,我更建议把区域裁切好,一次只处理一个人,精度和帧率都能保住。
5. 应用落地与自定义模型的扩展思考
5.1 从Demo到实用产品需要跨过哪些坎
3D姿态检测在网页里跑通之后,能玩的方向非常多。我自己做过或者见过做得比较好的几个方向:
健身动作计数是门槛最低的落地场景。拿到关键点之后计算关节角度,用角度的时间序列做波峰检测或者状态机切换,就能实现深蹲、俯卧撑、引体向上的自动计数。再加上一个动作标准度评分,利用角度偏差或者关键点轨迹跟标准模板进行比对,整个产品的体验就立起来了。
康复训练评估是另一个很有价值的方向。传统康复训练需要治疗师全程陪护,通过网页姿态检测可以做一个居家康复辅助工具,给出关节活动度报告、左右侧对称性分析,还能记录一段时间内的恢复趋势。
体感交互类应用也值得提一下。这一点在展厅、游戏行业已经有成熟实践,用身体控制屏幕上的人物或者菜单,本质上就是把手柄摇杆映射成髋关节或手腕的位移。
5.2 Model Maker能不能自定义姿态模型
很多人搜到MediaPipe Model Maker,第一反应是能不能用它训练自己的姿态检测模型。这里我直接说结论:目前Model Maker的官方支持范围是图像分类、目标检测、文本分类、文本问答和推荐器,并不包含姿态估计模型的自定义训练。
那想要自定义姿态模型怎么办?目前业内比较现实的路径有三条。
第一条是训练一个关键点之后的分类器,而不是训练姿态模型本身。也就是说,用BlazePose输出关键点坐标作为特征,喂给一个你自己用TensorFlow.js或TensorFlow训练的小型MLP或者LSTM网络,用来识别自定义动作。这条路非常推荐,因为特征维度只有33乘3等于99维,数据量不需要很大,普通笔记本电脑就能训练。我做过一个“摔倒检测”的分类器,就是基于关键点速度、加速度和角度特征训练出来的,精度很理想。
第二条是知识蒸馏。如果你想让自己定义的姿态检测模型直接跑在浏览器上,可以用TensorFlow训练一个大模型,再用蒸馏技术把知识迁移到一个小模型,最后通过TensorFlow.js的Converter把模型转成Web格式。这个路线技术含量高不少,优点是完全脱离MediaPipe的桎梏,模型可以完全为自己定制。
第三条是找现成的高精度姿态模型然后微调,比如COCO-WholeBody数据集上训练过的模型,在整脸和手部关键点的覆盖度上更有优势。
这里我的个人建议是:绝大多数业务场景,直接用BlazePose输出的关键点就已经够了。姿态估计只是上游信号,真正的产品竞争力在关键点之后的理解和应用层。把精力花在动作语义建模和交互体验上,收益远大于从头训练一个姿态模型。
5.3 模型量化与部署优化方向
如果将来要做大规模用户侧的部署,模型体积和推理速度都需要进一步优化。MediaPipe的PoseLandmarker本身提供了不同的量化版本,我有次测试时在移动端用int8量化版本,模型体积能压到2MB以内,推理速度提升了30%以上,精度损失在可接受范围内。
TensorFlow.js也有自己的优化手段:开启WebGL后端、使用模型转换工具做图优化、避免在每一帧推理时重复创建对象,都是能实打实看到性能提升的操作。
另外一个容易被忽略的点是缓存策略。模型文件是静态的,完全可以设置HTTP缓存,让二次访问直接走本地缓存,加载时间从秒级降到毫秒级。配合Service Worker做离线缓存,整个应用甚至可以做到断网可用。
前端姿态检测这条路走到现在,已经没有明显的技术瓶颈了。模型在浏览器里跑,摄像头直接访问,关键点实时输出,3D渲染即时呈现,整条链路都是通的。剩下的事情,就是看我们怎么把这个能力用出花来了。
最后分享一个小经验:我在实际开发里发现,无论检测效果多么丝滑,永远不要忘了给用户一个“检测失败”的兜底提示。光线太暗、人离镜头太远、身体被遮挡,这些情况都会让关键点的可信度下降。与其硬着头皮给出错误的姿态数据,不如在界面里结合每个关键点的visibility字段做一个置信度指示。这个细节看着不起眼,但对产品的专业感提升立竿见影。