浏览器端AI推理实战:TensorFlow.js与Three.js实现摄像头3D交互
发布时间:2026/10/1 19:37:48来源:尧图网络
简介这份资源是一个基于TensorFlow.js与Three.js的网页摄像头交互式创意演示项目面向具备一定前端与机器学习基础的开发者用于学习浏览器端深度学习模型与三维渲染的结合应用。项目集成PoseNet人体姿态识别、FaceMesh面部特征点检测与BodyPix人体分割三类模型通过摄像头实时捕捉图像数据再由Three.js渲染出姿态跟随、表情映射与手势交互等三维效果。压缩包共147个文件约432KB以JavaScript脚本、Vue组件、着色器文件fs/vs、样式表与图片素材为主另含少量模型文件与说明文档目录结构清晰便于按模块阅读源码与调试。目前已有64人学习下载。读者可从中获取完整的模型加载与交互逻辑实现、着色器特效参考以及前后端整合思路适合作为课程设计、毕业设计或创意交互原型的实践参考。1. 浏览器里跑三个模型这套 TensorFlow.js Three.js 方案到底能做什么打开一个网页摄像头一开屏幕上的人体骨架跟着你动、脸上 468 个特征点实时贴合、背景被干净地抠掉换成 3D 场景——这套基于 TensorFlow.js 和 Three.js 的网页摄像头交互式创意演示项目核心就是把 PoseNet、FaceMesh、BodyPix 三个模型塞进浏览器再用 Three.js 把识别结果渲染成可交互的 3D 内容。它不需要后端 GPU不依赖云服务一台带摄像头的笔记本打开 Chrome 就能跑。适合谁做互动装置的前端工程师、想给展厅或直播间加体感交互的开发者、以及想入门浏览器端 AI 推理的技术爱好者。你拿到的是一个可复现的完整链路模型加载、摄像头采集、推理调度、3D 渲染、性能调优每一环都有明确的参数和坑。2. 三个模型各管什么PoseNet、FaceMesh、BodyPix 的分工与选型2.1 PoseNet 的 17 个关键点与单人或多人模式PoseNet 输出的是人体 17 个关键点坐标包括鼻子、左右眼、左右耳、左右肩、左右肘、左右腕、左右髋、左右膝、左右踝。每个点带 x、y 坐标和置信度分数。它有两种模式SinglePerson 和 MultiPerson。SinglePerson 速度快、只返回一个人适合单人互动MultiPerson 能检测多个人但每帧推理时间翻倍且关键点归属需要自己做聚类。选型建议很直接如果你的场景是一个人站在摄像头前做动作用 SinglePerson推理耗时能控制在 15ms 以内中端笔记本。如果是多人同框的展厅互动MultiPerson 的耗时可能到 40ms 以上帧率会掉到 20fps 以下这时候要么降分辨率要么接受卡顿。// PoseNet 初始化指定架构和输出节点 const net await posenet.load({ architecture: MobileNetV1, // 轻量适合浏览器 outputStride: 16, // 输出步长16 比 8 快但精度略低 inputResolution: { width: 640, height: 480 }, multiplier: 0.75 // 通道缩放0.75 是速度与精度的平衡点 });architecture选 MobileNetV1 而不是 ResNet50因为 ResNet50 在浏览器里加载就要十几秒推理更慢。outputStride设 16 时输出特征图是输入的 1/16关键点定位精度会降一点但速度明显提升设 8 精度高但耗时增加约 40%。multiplier控制 MobileNet 的通道数0.75 是常用折中0.5 更快但手腕、脚踝这类小关节容易丢。2.2 FaceMesh 的 468 个特征点与三角面片输出FaceMesh 返回 468 个 3D 特征点覆盖面部轮廓、眉毛、眼睛、鼻子、嘴唇、脸颊。每个点有 x、y、z 坐标z 是相对深度。它还输出一套三角面片索引triangles可以直接用来构建 Three.js 的网格。468 个点听起来多但 FaceMesh 的模型本身很轻在浏览器里单帧推理约 10-20ms。关键参数是maxFaces和refineLandmarks。maxFaces默认 1设成 2 以上会成倍增加耗时。refineLandmarks开启后会额外计算虹膜周围的点精度更高但速度下降约 30%。如果你要做眼神追踪或瞳孔定位这个参数必须开如果只是做面部轮廓驱动关掉更划算。const faceMesh await facemesh.load({ maxFaces: 1, refineLandmarks: false, // 不做虹膜追踪就关掉 detectionConfidence: 0.8 // 置信度阈值低于此值不输出 });detectionConfidence设 0.8 意味着模型对检测结果有 80% 以上把握才输出。设太低会引入抖动设太高会频繁丢帧。实测 0.75-0.85 是稳定区间。2.3 BodyPix 的分割粒度与输出格式选择BodyPix 做的是人体分割输出每个像素属于人体的概率。它有两种输出segmentation返回二值掩码人/背景partSegmentation返回 24 个身体部位的分割左臂、右腿、躯干等。partSegmentation更耗性能但能做更精细的交互比如只让左臂触发某个效果。const bodyPix await bodyPix.load({ architecture: MobileNetV1, outputStride: 16, multiplier: 0.75, quantBytes: 2 // 量化字节数2 比 4 省一半内存精度损失可接受 });quantBytes控制模型权重的量化精度。4 字节最精确但内存占用大2 字节在浏览器里更实用1 字节会明显掉精度。分割结果是一张与输入同尺寸的掩码图你可以把它作为纹理传给 Three.js 的着色器实现背景替换或人体描边。三个模型可以同时跑但要注意总耗时。PoseNet 15ms FaceMesh 15ms BodyPix 25ms 55ms加上渲染和摄像头采集一帧约 70-80ms帧率在 12-15fps。如果只跑其中一两个能稳定在 30fps 以上。我的建议是先确定核心交互需要哪个模型其他模型按需降频调用比如每 3 帧跑一次。3. 从摄像头到 3D 场景完整链路的搭建步骤3.1 摄像头采集与视频帧的预处理第一步是拿到摄像头视频流。用navigator.mediaDevices.getUserMedia请求权限把流绑定到video元素。注意视频分辨率不要设太高640x480 足够模型推理1280x720 会让推理耗时翻倍。const video document.getElementById(video); const stream await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: user }, audio: false }); video.srcObject stream; await video.play();facingMode: user表示前置摄像头。如果是后置摄像头做体感改成environment。audio: false必须写否则某些浏览器会同时请求麦克风权限用户会看到两个权限弹窗体验很差。视频就绪后每一帧推理前需要把视频帧画到 canvas 上再取 ImageData或者直接用tf.browser.fromPixels(video)转成 Tensor。后者更简洁但要注意视频尺寸和模型输入尺寸的匹配。// 每帧推理循环 async function detectFrame() { const pose await net.estimateSinglePose(video, { flipHorizontal: true // 前置摄像头需要镜像 }); const faces await faceMesh.estimateFaces(video, { flipHorizontal: true }); // ... 处理结果 requestAnimationFrame(detectFrame); }flipHorizontal: true对前置摄像头很关键否则你抬右手屏幕上的骨架抬左手交互会反。后置摄像头则设为 false。3.2 把关键点映射到 Three.js 坐标系PoseNet 输出的坐标是视频像素坐标0-640, 0-480Three.js 用的是归一化设备坐标-1 到 1或世界坐标。映射关系是function toThreeCoords(point, videoWidth, videoHeight) { return { x: (point.x / videoWidth) * 2 - 1, y: -(point.y / videoHeight) * 2 1, // Y 轴翻转 z: point.score // 用置信度当深度或固定值 }; }Y 轴要翻转是因为视频坐标原点在左上角Three.js 原点在左下角。z可以用置信度映射也可以固定为 0取决于你的场景。如果要做 3D 骨架建议把 z 设为 0然后在 Three.js 里用正交相机这样骨架和视频画面能精确对齐。对于 FaceMesh 的 468 个点映射方式相同但要注意 z 坐标是相对深度范围大约在 -0.1 到 0.1 之间需要乘以一个缩放系数才能和 x、y 在同一量级。const facePoints faces[0].scaledMesh.map(p ({ x: (p[0] / videoWidth) * 2 - 1, y: -(p[1] / videoHeight) * 2 1, z: p[2] * 5 // 深度放大 5 倍让面部立体感更明显 }));3.3 用 Three.js 渲染骨架、面部网格与分割掩码骨架用THREE.Line或THREE.Points渲染。PoseNet 的 17 个点之间有固定的连接关系比如鼻子连左眼、左肩连左肘你可以预定义一组边每帧更新顶点位置。const skeletonEdges [ [0, 1], [0, 2], [1, 3], [2, 4], // 鼻子-眼-耳 [5, 6], [5, 7], [7, 9], [6, 8], [8, 10], // 肩-肘-腕 [5, 11], [6, 12], [11, 12], // 肩-髋 [11, 13], [13, 15], [12, 14], [14, 16] // 髋-膝-踝 ]; const geometry new THREE.BufferGeometry(); const positions new Float32Array(skeletonEdges.length * 2 * 3); geometry.setAttribute(position, new THREE.BufferAttribute(positions, 3)); const lines new THREE.LineSegments(geometry, new THREE.LineBasicMaterial({ color: 0x00ff00 })); scene.add(lines);每帧根据关键点坐标更新positions数组然后设geometry.attributes.position.needsUpdate true。面部网格用THREE.BufferGeometry加THREE.Mesh。FaceMesh 返回的triangles是索引数组直接塞给setIndex顶点位置每帧更新。const faceGeometry new THREE.BufferGeometry(); faceGeometry.setAttribute(position, new THREE.BufferAttribute(new Float32Array(468 * 3), 3)); faceGeometry.setIndex(faces[0].triangles.flat()); const faceMesh3D new THREE.Mesh(faceGeometry, new THREE.MeshBasicMaterial({ color: 0x00aaff, wireframe: true, transparent: true, opacity: 0.6 })); scene.add(faceMesh3D);分割掩码作为纹理贴到一个全屏平面上或者用着色器把掩码区域替换成其他内容。最简单的方式是把 BodyPix 输出的掩码画到 canvas再作为THREE.CanvasTexture传给材质。const maskCanvas document.createElement(canvas); maskCanvas.width 640; maskCanvas.height 480; const maskCtx maskCanvas.getContext(2d); const maskTexture new THREE.CanvasTexture(maskCanvas); // 每帧更新 const segmentation await bodyPix.segment(video); bodyPix.toMask(segmentation).then(mask { maskCtx.putImageData(mask, 0, 0); maskTexture.needsUpdate true; });3.4 渲染循环与帧率控制三个模型同时跑必须控制调用频率。我的做法是PoseNet 每帧跑FaceMesh 每 2 帧跑一次BodyPix 每 3 帧跑一次。这样总耗时能压到 30ms 左右帧率稳定在 30fps。let frameCount 0; async function renderLoop() { frameCount; const pose await net.estimateSinglePose(video, { flipHorizontal: true }); updateSkeleton(pose); if (frameCount % 2 0) { const faces await faceMesh.estimateFaces(video, { flipHorizontal: true }); updateFaceMesh(faces); } if (frameCount % 3 0) { const seg await bodyPix.segment(video); updateMask(seg); } renderer.render(scene, camera); requestAnimationFrame(renderLoop); }requestAnimationFrame的回调频率跟显示器刷新率一致通常是 60Hz。但模型推理是异步的如果推理耗时超过 16ms帧率就会掉。用frameCount取模来降频是最简单的策略不需要复杂的调度器。4. 性能调优与避坑三个模型同时跑不翻车的经验4.1 模型加载慢与内存泄漏的排查现象页面打开后白屏十几秒或者跑几分钟后浏览器标签页崩溃。原因三个模型加起来约 20-30MB首次加载要从 CDN 下载。内存泄漏通常是因为每帧创建了新的 Tensor 但没有 dispose。解决模型加载加 loading 提示用tf.ready()确认后端就绪。每帧推理完的 Tensor 必须手动释放const tensor tf.browser.fromPixels(video); const result await net.estimateSinglePose(tensor); tensor.dispose(); // 必须释放BodyPix 的segment返回的对象里包含 Tensor也要 dispose。如果不释放几分钟内内存就能涨到几百 MB。4.2 关键点抖动与置信度过滤现象骨架点不停跳动即使人站着不动。原因模型每帧独立推理没有时序平滑。低置信度的点坐标噪声很大。解决加置信度阈值过滤低于 0.5 的点不渲染。再加一个简单的滑动平均const history {}; function smoothPoint(key, point, alpha 0.3) { if (!history[key]) history[key] { x: point.x, y: point.y }; history[key].x history[key].x * (1 - alpha) point.x * alpha; history[key].y history[key].y * (1 - alpha) point.y * alpha; return history[key]; }alpha越小越平滑但延迟越大。0.3 是响应速度和稳定性的平衡点。4.3 摄像头镜像与坐标翻转的坑现象前置摄像头下你抬右手屏幕上的骨架抬左手。原因视频画面默认是镜像的像照镜子但模型输出的坐标是基于原始画面的。解决推理时设flipHorizontal: true或者在映射坐标时手动翻转 x 轴。两者选一不要同时做否则会翻回去。4.4 多模型同时推理的 GPU 内存竞争现象三个模型同时跑时帧率骤降或者 WebGL 上下文丢失。原因TensorFlow.js 的 WebGL 后端和 Three.js 共用 GPU 内存。三个模型同时推理会占满显存。解决用tf.setBackend(webgl)明确后端然后限制每个模型的inputResolution。PoseNet 和 BodyPix 的输入分辨率降到 320x240FaceMesh 保持 640x480因为面部需要更高精度。另外BodyPix 的quantBytes设 2 而不是 4能省一半显存。4.5 移动端浏览器兼容性与降级策略现象在手机上打开模型加载失败或帧率极低。原因移动端 GPU 性能有限且部分浏览器不支持 WebGL 2.0。解决检测tf.getBackend()如果是webgl但帧率低于 15fps自动降级到只跑 PoseNet。或者用tf.setBackend(cpu)虽然慢但兼容性最好。另外iOS Safari 需要用户手势触发才能播放视频加一个「开始」按钮。5. 进阶玩法用分割掩码做 3D 场景交互与一个验证技巧5.1 把 BodyPix 掩码变成 Three.js 的深度遮罩BodyPix 的分割掩码不只是用来抠背景。你可以把它作为深度图让 3D 物体只在人体区域显示或者根据人体轮廓做粒子效果。具体做法把掩码的灰度值映射到 0-1作为着色器的alpha通道。// 片元着色器 uniform sampler2D maskTexture; varying vec2 vUv; void main() { vec4 mask texture2D(maskTexture, vUv); float alpha mask.r; // 人体区域为 1背景为 0 gl_FragColor vec4(1.0, 0.5, 0.0, alpha * 0.8); }这样 3D 物体只在人体区域可见人一动物体跟着显现互动感很强。5.2 用 FaceMesh 的 z 坐标驱动 3D 模型表情FaceMesh 的 468 个点里有嘴唇和眉毛的点。你可以计算嘴唇上下点的距离映射到 3D 模型的嘴部骨骼计算眉毛和眼睛的距离映射到眉毛骨骼。这样不用任何面部捕捉设备光靠摄像头就能驱动一个 3D 角色做表情。// 嘴唇开合度上唇和下唇的距离 const upperLip facePoints[13]; const lowerLip facePoints[14]; const mouthOpen Math.abs(upperLip.y - lowerLip.y) * 10; // 映射到 3D 模型的 morphTarget character.morphTargetInfluences[0] Math.min(mouthOpen, 1);morphTargetInfluences是 Three.js 的形变目标权重0 是闭嘴1 是张嘴。乘以 10 是经验系数根据你的模型尺度调整。5.3 一个验证技巧用静态图片测试模型输出调试时不要每次都开摄像头。准备一张包含人脸和全身的静态图片用tf.browser.fromPixels(img)代替视频帧跑一次推理把关键点画到 canvas 上。这样能快速验证坐标映射是否正确不用反复调整摄像头角度。const img document.getElementById(testImage); const tensor tf.browser.fromPixels(img); const pose await net.estimateSinglePose(tensor); // 把 pose.keypoints 画到 canvas 上对比如果静态图片的关键点位置正确但摄像头画面不对那问题一定在镜像或坐标映射上不在模型本身。5.4 我踩过的最大的坑忘了 dispose 导致页面崩溃最后说一个血泪教训。我第一版做完演示了五分钟浏览器标签页直接崩溃。查了半天以为是 GPU 内存不够后来发现是每帧创建的 Tensor 没释放。TensorFlow.js 的 Tensor 是手动管理内存的不像 JavaScript 对象有垃圾回收。一个 640x480 的 RGB Tensor 约 3.6MB每秒 30 帧就是 108MB几分钟就能把内存吃满。从那以后我养成了一个习惯只要调用了tf.browser.fromPixels或任何返回 Tensor 的 API下一行就写 dispose。这个习惯帮我省了无数个调试的夜晚。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网