尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

浏览器端3D姿态检测实战:MediaPipe BlazePose + TensorFlow.js 从零搭建

发布时间:2026/9/24 22:30:04

资讯中心
01
ARTICLE

浏览器端3D姿态检测实战:MediaPipe BlazePose + TensorFlow.js 从零搭建

浏览器端3D姿态检测实战:MediaPipe BlazePose + TensorFlow.js 从零搭建
去年我在做一个健身动作比对的原型项目需求一句话用户站在摄像头前屏幕里的3D角色要跟着动作走。最开始我习惯性想上Python后端跑MediaPipe或者OpenPose再推流给前端真动手才发现延迟、部署、隐私全是坑。后来换成 MediaPipe BlazePose GHUM 模型推理全程放在浏览器里的 TensorFlow.js 生态中完成一套纯前端 3D 姿态检测管线就此跑通。这篇文章就是我在这个项目里的完整记录从模型原理到环境配置从推理代码到Canvas渲染再到调参和踩坑适合正在做体感交互、虚拟形象驱动、健身计数或者单纯想在浏览器里玩3D姿态检测的人参考。1. 为什么是浏览器里的3D姿态检测一点项目背景1.1 需求场景从一段摄像头画面到三维骨架所谓3D姿态检测就是给定一帧视频画面算法输出人体关键点的三维坐标每个关节点不仅有x、y画面里的位置还有z深度方向上的相对位置。有了这组带深度的坐标你就可以让人物模型跟着用户做深蹲、抬手、转身或者做动作角度分析。我当时的需求可以拆成三层第一层实时检测延迟尽量低于100ms否则动作跟手感觉差。第二层要3D数据不能只是2D骨架。用户侧身对着摄像头时左右手不能叠在一起。第三层部署简单。不想在后端跑一个常驻模型服务也不想给用户装任何东西。这三点叠加基本把技术选型逼到了前端方向。1.2 方案对比Python后端、原生App、还是纯前端在定方案之前我把几条路线都过了一遍这里列个直观对比方案延迟部署复杂度隐私关键问题Python HTTP服务 OpenPose/MediaPipe高帧传输推理回传高要维护服务端视频流经过服务器延迟难压带宽成本高原生AppAndroid/iOS低中双端都要写本地处理跨平台开发成本高浏览器 MediaPipe任务API TF.js后端低极低静态托管即可摄像头数据不出本机浏览器兼容性要处理实际体验下来纯前端方案的优势不是少写两行代码而是把架构彻底简化不需要WebSocket、不需要推流、不需要服务器GPU一个静态页面加模型文件就能跑。摄像头数据从getUserMedia拿到之后直接进模型推理数据从头到尾不出本机这对很多注重隐私的产品形态特别关键。1.3 BlazePose、GHUM、TensorFlow.js各自扮演什么角色这个标题里的三个关键词严格说对应的是三件不同层面的事BlazePoseMediaPipe团队提出的轻量级姿态估计模型负责从图像中回归人体关键点。它的特点是快移动端都能跑实时推理同时输出2D和3D两套关键点。GHUM一个参数化人体模型。MediaPipe用GHUM的人体拓扑和解剖学约束来定义BlazePose的3D关键点回归头让模型凭空推断出的深度信息符合人体运动的物理规律。TensorFlow.jsTF.js浏览器端的机器学习基础设施。它负责提供张量计算能力并根据当前设备自动选择WebGL/WASM/CPU后端来加速。MediaPipe任务API内部封装了TFLite runtime而TF.js是前端ML生态里你绕不开的底层依赖。一句话概括BlazePose是大脑GHUM是骨架规则TF.js是发动机。2. 看懂输出才能用好模型33个关键点与两套坐标系2.1 BlazePose比COCO 17点多出来的16个点在哪传统姿态估计常用COCO数据集定义的17个关键点鼻子、双眼、双肩、双肘、双腕、双髋、双膝、双踝这些。BlazePose直接扩展到33个关键点多出来的点集中在面部轮廓和手脚部位关键点编号说明面部中心0-10鼻子、眼睛内外角、耳朵、嘴角肩肘腕11-16左右肩、左右肘、左右腕手指17-22小指、食指、拇指的根部位置髋膝踝23-28左右髋、左右膝、左右踝脚部29-32左右脚跟、左右脚掌前段这16个多出来的点不是凑数。手指的根部位置能让你判断握拳还是张手脚掌点能区分脚尖朝向面部点可以用来配合后续的人脸网格做头部姿态估算。做动作细节分析时这16个点的价值极大。2.2 GHUM先验单目摄像头为什么能估算出深度单目摄像头没有深度传感器从一张2D图像里恢复3D坐标本质是病态问题同一个2D位置可能对应无数种3D姿态。那BlazePose怎么给出z值靠的就是GHUM这类参数化人体模型提供的先验。GHUM是通过大量高精度人体扫描数据训练出来的生成模型它知道真实人体各个关节在三维空间里的相对位置关系、骨骼长度比例、关节活动范围。BlazePose在训练时用GHUM生成的3D关键点作为监督信号让网络学会从2D图像推理出最符合人体解剖学规律的3D骨架。所以——注意这个z不是测量值是模型根据图像内容和人体先验猜出来的估值。它适合做姿态角、动作比对但拿去做精确的物理距离测量会翻车。2.3 landmarks vs worldLandmarks什么时候用哪一套MediaPipe的输出里有两份关键点数据很多新手会搞混输出坐标系单位原点适用场景landmarks图像坐标像素z为相对深度图像左上角画2D骨架、叠加AR标签、判断画面位置worldLandmarks世界坐标米归一化近似髋部中心计算关节角度、驱动3D模型、动作比对实际项目中两套数据我都在用画面叠加线框用landmarks因为它直接对应像素位置。计算肘关节角度、膝关节角度用worldLandmarks因为它的坐标与摄像头视角无关侧身时不会出现左右手畸变。3. 环境搭建与模型加载踩过坑才知道的细节3.1 依赖选型mediapipe/tasks-vision 与 tensorflow/tfjs 的分工安装依赖很简单两个npm包npm install mediapipe/tasks-vision npm install tensorflow/tfjsmediapipe/tasks-vision是MediaPipe官方任务API封装了Pose Landmarker模型和推理逻辑是我们要直接调用的入口。tensorflow/tfjs则是把张量运算、WebGL后端这些底层能力接到浏览器里。如果你的项目不想直接跟MediaPipe任务API打交道TF.js生态也有tensorflow-models/pose-detection这个库内部同样集成了BlazePose模型。两条路线选一条即可我推荐官方任务API更新节奏更稳、文档更全。装完依赖后你会在node_modules/mediapipe/tasks-vision/wasm里看到一堆.wasm文件这些文件负责模型推理的底层实现后续加载时要特别注意路径。3.2 模型文件、WASM、跨域加载阶段的三个定时炸弹模型加载有三个隐藏坑我逐一踩过这里直接说结论第一个坑模型文件路径。Pose Landmarker的模型文件是.task格式。官方提供了三个精度档位模型体积速度精度pose_landmarker_lite.task约5MB最快中pose_landmarker_full.task约7MB中高pose_landmarker_heavy.task约11MB慢最高把模型文件放到前端项目的public目录或者静态资源目录然后通过modelAssetPath指定相对路径。不要一开始就追求heavy原型阶段用full足够最后按实际帧率再升档。第二个坑WASM文件的位置。import { FilesetResolver, PoseLandmarker } from mediapipe/tasks-vision; const vision await FilesetResolver.forVisionTasks( // 这里要能正确访问到wasm文件 https://cdn.jsdelivr.net/npm/mediapipe/tasks-vision0.10.14/wasm );如果使用CDN路径注意版本号要和npm包一致。如果项目部署在内网或离线环境需要把wasm目录拷贝到自己的静态服务器然后路径改成相对路径。第三个坑跨域。如果你的前端页面是http://localhost:8080模型文件放在同一个静态服务下没有跨域问题。但如果模型放在OSS、CDN或者独立文件服务上必须确认目标服务返回了正确的CORS头。浏览器控制台里看到Failed to fetch、Origin is null这类报错八成是跨域。3.3 runningMode怎么选IMAGE、VIDEO还是LIVE_STREAMPoseLandmarker支持三种运行模式选错了会直接影响你的代码结构和性能IMAGE单张图片推理适合上传图片做分析的场景。VIDEO逐帧同步推理每帧需要传入一个时间戳推理完返回结果。适合需要拿到完整结果再做后续处理的场景。LIVE_STREAM异步回调模式模型内部会排队处理帧检测完成通过回调函数返回结果。适合实时摄像头场景。我的经验是做实时交互直接用LIVE_STREAM因为detectForVideo是同步阻塞的如果一帧推理超过40ms会直接把渲染主线程卡死导致视频画面抖动。LIVE_STREAM模式内部做了异步调度体验好得多。4. 核心推理链路从摄像头到33个3D关键点的完整代码4.1 初始化PoseLandmarker并打开摄像头先初始化PoseLandmarker实例。这里我把runningMode设为LIVE_STREAM并准备一个回调函数接收姿态结果import { FilesetResolver, PoseLandmarker } from mediapipe/tasks-vision; let poseLandmarker; let lastVideoTime -1; async function initPoseLandmarker() { const vision await FilesetResolver.forVisionTasks( https://cdn.jsdelivr.net/npm/mediapipe/tasks-vision0.10.14/wasm ); poseLandmarker await PoseLandmarker.createFromOptions(vision, { baseOptions: { modelAssetPath: ./pose_landmarker_full.task, delegate: GPU }, runningMode: LIVE_STREAM, numPoses: 1, minPoseDetectionConfidence: 0.5, minPosePresenceConfidence: 0.5, minTrackingConfidence: 0.5 }); }numPoses设置为1如果场景里有多个人并且你都要跟踪可以调高但推理耗时会增加。三个置信度参数用于过滤低质量检测我建议保持在0.5附近太高会导致短暂遮挡时关键点丢失太低会出现大量抖动。摄像头初始化是老套路async function initCamera() { const video document.getElementById(webcam); video.width 640; video.height 480; const stream await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: user } }); video.srcObject stream; await video.play(); }分辨率不一定越高越好模型内部会把输入图像缩放到固定尺寸你给再高的分辨率模型看到的归一化后图像差别不大反而增加带宽和绘制压力。640x480是个甜点值。4.2 逐帧检测并解析landmarks与worldLandmarksLIVE_STREAM模式下检测结果通过回调返回function predictLoop() { if (poseLandmarker video.readyState 2) { const now performance.now(); if (video.currentTime ! lastVideoTime) { lastVideoTime video.currentTime; poseLandmarker.detectForVideo(video, now, (result) { handlePoseResult(result); }); } } requestAnimationFrame(predictLoop); }注意两个细节detectForVideo的第二个参数是时间戳必须单调递增直接用performance.now()不会有大问题但要注意不要传一个固定值否则结果不会更新。用video.currentTime判断是否处理过当前帧避免在视频帧没有变化时重复推理。回调里的result包含三个数据字段function handlePoseResult(result) { if (!result.landmarks || result.landmarks.length 0) { return; // 画面里没有人 } const landmarks result.landmarks[0]; // 33个点像素坐标 const worldLandmarks result.worldLandmarks[0]; // 33个点近似米制坐标 const segmentationMask result.segmentationMasks?.[0]; // 可选人像分割 // 把关键点存到共享缓冲区供渲染层读取 currentLandmarks landmarks; currentWorldLandmarks worldLandmarks; }拿到worldLandmarks之后就可以计算关节角度了。一个实用的例子是计算右肘夹角function angleBetween(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; } // 右肩12、右肘14、右腕16 const elbowAngle angleBetween( worldLandmarks[12], worldLandmarks[14], worldLandmarks[16] );4.3 性能要点复用结果对象、控制推理频率LIVE_STREAM回调返回的result对象在下一帧推理完成之前是稳定的但我还是强烈建议把需要的数据拷贝到自己的变量里而不是存引用。原因是后续帧推理结束后底层可能复用同一块内存引用指向的数据会被覆盖。另外不要试图每帧都更新整个骨架数组。我的做法是维护一个Float32Array作为关键点缓冲区回调里只把坐标填进去渲染层按固定帧率读取。这样既避免了对象频繁创建导致的GC压力也让渲染和推理解耦。5. 把3D骨架画出来Canvas上的投影与深度渲染5.1 为什么直接画worldLandmarks看起来是平的拿到worldLandmarks后第一个直觉是把x、y直接当作Canvas坐标画出来结果发现骨架是平的完全没有3D感觉。原因很简单worldLandmarks是三维坐标而Canvas是一个二维平面。你把三维点直接投影到二维默认用的是正交投影相当于从正前方看深度信息完全没法体现。要让用户体验到这是3D必须做两件事给骨架加一个绕Y轴的旋转让用户看到人体的侧面和背面。用透视投影替代正交投影近大远小产生空间纵深感。5.2 手动实现透视投影与旋转矩阵实现3D渲染不一定要引入Three.jsCanvas 2D配合一点数学就能画出很好的效果。核心是两个函数旋转和投影。function rotateY(p, angle) { const cos Math.cos(angle); const sin Math.sin(angle); return { x: p.x * cos p.z * sin, y: p.y, z: -p.x * sin p.z * cos }; } function project(p, fov, width, height) { const f width / (2 * Math.tan(fov / 2)); const scale f / (p.z 4); // 4是摄像机到场景的距离 return { x: width / 2 p.x * scale, y: height / 2 - p.y * scale, depth: p.z }; }rotateY让骨架绕Y轴旋转angle随时间变化就会产生人在转圈的效果。project实现透视投影fov是视场角常用值约60度约1.047弧度。4的含义是把整个人体放到离摄像机4米远的地方太小会让骨架溢出屏幕太大会让骨架显得很小。这里有个坐标系细节worldLandmarks的Y轴是向上为正而Canvas的Y轴是向下为正所以投影时用height / 2 - p.y * scale让骨架立起来。5.3 连线、深度排序与一个可复用的渲染函数BlazePose的33个关键点连接关系官方文档没有直接给你数组需要自己组织。我整理了一份常用连接表const BONES [ [0, 1], [1, 2], [2, 3], [0, 4], [4, 5], [5, 6], [0, 7], [0, 8], // 面部 [9, 10], // 嘴 [11, 13], [13, 15], [15, 17], [15, 19], [15, 21], // 左臂 [12, 14], [14, 16], [16, 18], [16, 20], [16, 22], // 右臂 [11, 23], [12, 24], [23, 24], // 躯干 [23, 25], [25, 27], [27, 29], [29, 31], // 左腿 [24, 26], [26, 28], [28, 30], [30, 32] // 右腿 ];注意编号是0-based第11个是左肩不是有些文章里写的7和8这里以MediaPipe官方文档为准。画线的时候有个特别重要的细节深度排序。如果只是按固定顺序画线当骨架转到侧面时右手臂的线可能画在躯干前面看起来像穿透了。解决方法是先计算每条线两个端点的平均z值按z从小到大排序先画离摄像机远的再画离摄像机近的function renderSkeleton(ctx, landmarks, angle, width, height) { const projected landmarks.map((p) { const r rotateY(p, angle); return project(r, 1.047, width, height); }); const boneList BONES.map(([i, j]) { const a projected[i]; const b projected[j]; return { a, b, depth: (a.depth b.depth) / 2 }; }).sort((x, y) y.depth - x.depth); // 先画远的 ctx.clearRect(0, 0, width, height); ctx.strokeStyle #00ff88; ctx.lineWidth 3; ctx.lineCap round; for (const { a, b } of boneList) { ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } }每次渲染前调用renderSkeleton(ctx, currentWorldLandmarks, rotationAngle, width, height)就能看到一具旋转的3D骨架。这个渲染函数不依赖任何第三方库逻辑清晰也方便后续扩展成不同骨骼粗细、不同颜色的风格。6. 实测性能调参与诡异Bug排查6.1 GPU delegate、WASM后端与分辨率取舍推理性能的第一个变量是delegate。初始化时delegate: GPU会让模型优先走WebGL后端把算子放到GPU上执行。实测在桌面Chrome上GPU delegate比默认的CPU/WASM快3到5倍。但GPU delegate不是万能的它在设备兼容性上会出问题部分安卓机的WebGL驱动对某些算子的支持不完整推理会静默失败或者直接不返回结果。如果遇到这种情况可以改成delegate: CPU此时MediaPipe任务API会退回到WASM实现性能和GPU差距明显但稳定性显著提高。我的调参策略是分两步先以帧率达标为目标确认delegate: GPU能跑通如果目标平台尤其是老旧移动设备上GPU表现不稳定再降级到CPU。用WebGL的WEBGL_debug_renderer_info扩展读一下实际渲染设备名称能帮你快速判断设备GPU能力。6.2 内存泄漏与掉帧detectForVideo的隐藏开销LIVE_STREAM模式跑一段时间后如果发现帧率逐渐下降大概率是内存泄漏。常见来源有三个渲染层Canvas越画越大没有在执行ctx.clearRect之前设置canvas.width canvas.width重置画布导致Canvas缓冲区不断膨胀。回调里创建了大量临时对象比如每帧都JSON.stringify(result)或console.log关键点数组浏览器控制台的日志缓冲会吃掉大量内存。忽略了segmentationMasks如果你不需要人像分割最好在模型选项里关闭相关输出或者不去访问result.segmentationMasks。PoseLandmarker默认会为每一帧计算分割掩码这个掩码是一张和原图同尺寸的浮点矩阵一帧几MB开着不用纯属浪费。如果你的业务不需要分割精确到头发丝级别可以在初始化选项里检查是否有控制分割输出的字段。实测中把这个输出避开之后长时间运行的帧率稳定性明显改善。6.3 移动端兼容Safari的autoplay与WebGL限制移动端Safari和安卓WebView有几个老生常谈但极其常见的坑摄像头打不开iOS的getUserMedia要求你的页面使用HTTPS或localhost并且video标签必须加上playsinline属性否则会在全屏播放器里打开拿不到视频帧。WebGL上下文限制iOS Safari对WebGL上下文数量有限制如果页面上多个组件各自创建WebGL上下文后创建的会拿不到。MediaPipe和TF.js默认会尝试创建WebGL上下文如果你又用Three.js渲染可能撞上这个限制。解决方法是让MediaPipe与渲染共用同一个WebGL上下文或者把渲染改为Canvas 2D像我在第5章做的那样。自动播放策略video.play()需要在用户手势事件中调用或者在页面加载后立即调用。Safari会拦截没有用户交互的自动播放一定要在play()后面捕获Promise的rejectionvideo.play().catch((err) { console.warn(自动播放被拦截请点击页面后重试, err); });6.4 高频报错对照表最后整理一份我在开发中反复遇到的报错和对应解法遇事不决先查表报错现象可能原因解决方案Failed to fetch加载模型文件模型路径错误或跨域检查modelAssetPath与静态资源路径确认服务返回CORS头Cannot read properties of undefined (reading x)result.landmarks为空数组先判断landmarks.length 0没检测到人体直接跳过GL context lostGPU delegate在部分设备上崩溃切换delegate: CPU或监听webglcontextlost事件做恢复The provided timestamp does not match时间戳没有单调递增用performance.now()并确保每帧传入时间戳变大帧率掉到个位数后台可能运行了多个检测实例检查是否每次进入页面都创建了新的PoseLandmarker旧的没关移动端摄像头黑屏playsinline没设置或非HTTPS环境补上playsinline部署到HTTPS环境骨架左右反了摄像头镜像画面没处理在Canvas绘制时做水平翻转或手动交换左右关键点索引7. 从能看见到能理解MediaPipe Model Maker自定义动作识别7.1 Model Maker到底自定义了什么3D姿态检测解决的是骨架在哪的问题但很多业务场景需要的是你在做什么动作。比如健身应用要知道用户当前是在做深蹲还是弓步瑜伽应用要识别树式还是下犬式。这个场景就需要自定义分类模型。MediaPipe Model Maker是Google提供的一套迁移学习工具它的工作方式不是从头训练一个姿态检测网络而是在BlazePose已经输出的关键点特征之上训练一个轻量分类器。你可以把它理解成BlazePose负责把每一帧图像浓缩成33个点的坐标Model Maker负责从这33个点的空间关系里学到深蹲和弓步的区别。所以自定义的准确定义是自定义人体姿态分类器而不是自定义姿态检测器本身。7.2 基于BlazePose特征的分类器训练流程Model Maker的训练流程在Python侧完成大致分四步第一步采集数据。对每个动作录制若干段视频用BlazePose把关键点导出成CSV或JSON。为了让分类器泛化要注意覆盖不同体型、不同角度、不同距离每类动作至少几百帧。第二步构造特征。原始33个关键点的绝对坐标不适合直接做分类因为站的位置一变坐标就全变了。业界标准的做法是计算关节角度特征和相对位置特征例如左肘角度、右膝角度、髋部中心到手部的相对向量等。每一帧压缩成一组几十维的特征向量。第三步训练分类器。在Model Maker里可以用k近邻kNN或者一个小型神经网络来拟合这些特征。kNN最简单几十行代码就能搞定适合原型验证神经网络精度更高但需要更多数据防过拟合。# 伪代码示意实际API以当前官方文档为准 from mediapipe.model_maker import gesture_recognizer train_data gesture_recognizer.Dataset.from_folder(train_data) model gesture_recognizer.GestureRecognizer.create(train_data) model.export(pose_classifier.task)第四步导出并集成。训练好的分类器导出为.task模型。前端JS侧用mediapipe/tasks-vision里的GestureRecognizer加载这个文件再把BlazePose输出的特征喂给它就能得到动作类别。7.3 什么时候该上自定义什么时候用现成骨架就够了不是所有场景都需要自定义分类器我总结了一个判断标准动作有明确的空间特征比如左手抬过头顶用现成骨架数据在JS里算一个角度阈值就能解决不需要训练。动作是一个连续过程比如从站立到下蹲再到起身中间有大量中间姿态阈值法会很难写。这时候可以考虑用**动态时间规整DTW**配合关键点序列做比对也不一定要训练。动作类别多、差异微妙比如瑜伽的多种体式外观相似但发力点不同阈值和DTW都难以覆盖这时候上Model Maker自定义分类器才划算。从我实际项目的经验看80%的基础互动动作靠现成骨架加几何计算就能搞定。自定义模型带来的收益确实明显但数据采集和标注的成本也不低建议先把骨架应用跑通再决定要不要上分类器。最后说一点我自己的体会。把整套链路跑通之后再做类似项目会先想清楚一个问题3D姿态检测给出的坐标是有先验的估计不是测量值。围绕这个认知去设计交互——多利用关节角度、相对位移这类几何特征少依赖绝对坐标和绝对尺度——整个项目会顺畅很多。BlazePose GHUM TF.js这套组合目前依然是浏览器端3D姿态检测里综合体验最好的方案跑通基础管线之后后面接Three.js做虚拟形象驱动、配合手势识别做多模态交互都只是水到渠成的事。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。