去年我在做一个健身动作比对的原型项目,需求一句话:用户站在摄像头前,屏幕里的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 | 高(帧传输+推理+回传) | 高,要维护服务端 | 视频流经过服务器 | 延迟难压,带宽成本高 |
| 原生App(Android/iOS) | 低 | 中,双端都要写 | 本地处理 | 跨平台开发成本高 |
| 浏览器 + MediaPipe任务API + TF.js后端 | 低 | 极低,静态托管即可 | 摄像头数据不出本机 | 浏览器兼容性要处理 |
实际体验下来,纯前端方案的优势不是"少写两行代码",而是把架构彻底简化:不需要WebSocket、不需要推流、不需要服务器GPU,一个静态页面加模型文件就能跑。摄像头数据从getUserMedia拿到之后,直接进模型推理,数据从头到尾不出本机,这对很多注重隐私的产品形态特别关键。
1.3 BlazePose、GHUM、TensorFlow.js各自扮演什么角色
这个标题里的三个关键词,严格说对应的是三件不同层面的事:
- BlazePose:MediaPipe团队提出的轻量级姿态估计模型,负责从图像中回归人体关键点。它的特点是快,移动端都能跑实时推理,同时输出2D和3D两套关键点。
- GHUM:一个参数化人体模型。MediaPipe用GHUM的人体拓扑和解剖学约束来定义BlazePose的3D关键点回归头,让模型"凭空"推断出的深度信息符合人体运动的物理规律。
- TensorFlow.js(TF.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/tfjs@mediapipe/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-vision@0.10.14/wasm" );如果使用CDN路径,注意版本号要和npm包一致。如果项目部署在内网或离线环境,需要把wasm目录拷贝到自己的静态服务器,然后路径改成相对路径。
第三个坑:跨域。
如果你的前端页面是http://localhost:8080,模型文件放在同一个静态服务下,没有跨域问题。但如果模型放在OSS、CDN或者独立文件服务上,必须确认目标服务返回了正确的CORS头。浏览器控制台里看到Failed to fetch、Origin is null这类报错,八成是跨域。
3.3 runningMode怎么选:IMAGE、VIDEO还是LIVE_STREAM
PoseLandmarker支持三种运行模式,选错了会直接影响你的代码结构和性能:
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-vision@0.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与worldLandmarks
LIVE_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.js,Canvas 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的rejection:
video.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 lost | GPU 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做虚拟形象驱动、配合手势识别做多模态交互,都只是水到渠成的事。