简介:这份资源是一个基于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-480),Three.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 的输入分辨率降到 320x240,FaceMesh 保持 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。这个习惯帮我省了无数个调试的夜晚。希望帮到你。
本文还有配套的精品资源,点击获取