简介:本资源是一个面向Web前端开发者与AI应用实践者的网页摄像头交互式创意演示项目,聚焦于浏览器端实时人体理解与3D可视化融合,解决传统网页交互缺乏深度感知与沉浸感的问题。压缩包共147个文件,含39个核心JavaScript逻辑文件(模型加载、数据流处理)、29个Vue组件(UI交互与状态管理)、21个GLSL着色器文件(如PostEffectBlur.fs、Face.fs等,用于Three.js后处理与面部/姿态特效渲染),以及PNG、OBJ、MTL等资源文件,整体仅432KB,轻量易部署。已有64人学习下载,适合中高级前端工程师、计算机视觉初学者及创意编程爱好者。读者可直接运行获得完整可交互Demo:基于TensorFlow.js实时调用PoseNet(人体关键点)、FaceMesh(468面部特征点)与BodyPix(像素级人像分割)三大模型,并通过Three.js实现姿态驱动3D模型、面部表情映射、背景虚化与光效渲染等效果;项目结构清晰,sketch-webcam-master目录已封装标准化接入流程与模块化渲染管线。 打开摄像头,屏幕上不再是那个略显疲惫的自己,而是一个由光点和连线组成的3D火柴人,你抬手他也抬手,你转身他也转身;换个模式,你的脸变成了一张半透明的多边形网格面具;再换个模式,画面里的你化作数千个彩色粒子,在三维空间里缓缓漂浮。这些看似Magic的效果,核心就是TensorFlow.js和Three.js这对组合:前者负责在浏览器里跑机器学习模型,让人能“看懂”摄像头画面;后者负责把识别结果变成实时3D交互体验。PoseNet做人体姿态识别、FaceMesh做面部特征点检测、BodyPix做人体分割——这篇文章我会完整拆解这三个模型如何接入TensorFlow.js、如何把输出映射到Three.js的3D场景里,以及整个项目从零搭建时最容易踩的坑和性能优化思路。
这个项目非常适合对前端创意编程、Web端AI应用感兴趣的人,哪怕你之前没怎么接触过机器学习和3D渲染,跟着思路走一遍也会很清楚:它不需要买GPU、不需要训练模型,用的全是官方预训练模型,浏览器打开就能跑。唯一的门槛是你得有点JavaScript基础,并且愿意动手改代码验证效果。
1. 为什么是 TensorFlow.js + Three.js:在浏览器里让摄像头“说话”
这个组合不是凭空冒出来的,它解决的问题非常具体:过去做摄像头交互,要么用OpenCV加Python写本地服务,要么把画面传云端做AI推理。前者部署麻烦,后者有延迟和隐私顾虑。TensorFlow.js把模型推理完全放到了浏览器端,数据不出本机,PoseNet一类的模型在WebGL加速下能做到实时推理;而Three.js则是浏览器里最成熟的3D渲染方案,负责把推理结果“画出来”。
选择这两个库还有一个很实际的理由:它们都极其活跃,社区资料多,出了问题几乎都能搜到解法。TensorFlow.js官方维护了PoseNet、FaceMesh、BodyPix的模型包,虽然有的已经进入维护模式,文档上写着deprecated,但跑起来依然稳定。而Three.js从底层WebGL封装到各种辅助库都很完善,不需要自己写GLSL着色器就能做出不错的效果。
有些朋友会问,为什么不直接用MediaPipe的JS包代替TensorFlow.js?MediaPipe确实更新、性能也更好,但从项目标题和实际工程的角度看,TensorFlow.js的模型API更统一,加载方式简单,适合快速出原型。如果你之后想换模型、调参数、做多人识别,直接改一改配置就行。
这个项目本质上的技术链条是这样的:
- getUserMedia获取摄像头视频流
- 每帧把画面绘制到画布或直接转成Tensor
- TensorFlow.js运行模型,输出关键点坐标或分割掩码
- 坐标经过处理和映射,变成Three.js场景中物体的位置、旋转或形变
- 渲染循环持续读摄像头、跑推理、更新3D场景,形成实时交互
听起来不复杂,但真正落地时你会遇到分辨率取舍、镜像翻转、帧率控制、模型叠加等一堆“文档里没有”的细节。接下来的内容,就是我实际跑通一遍之后的完整记录。
2. 起步第一件事:摄像头流、画布镜像与坐标映射
很多人拿到这个项目,第一反应是直接往上堆模型,结果发现画面要么是反的,要么坐标对不上。所有问题几乎都出在“画面从摄像头到模型再到3D场景”这条链路的坐标体系没有对齐。所以我建议从一开始就把这一步做对。
2.1 获取视频流和绘制帧
获取摄像头视频流的代码所有人都会写:
const video = document.createElement('video'); video.width = 640; video.height = 480; navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480, facingMode: 'user' } }).then(stream => { video.srcObject = stream; video.play(); });这里有两个容易被忽略的点:第一,视频分辨率不要贪高。PoseNet和BodyPix对输入分辨率敏感,但推理速度也跟输入分辨率直接相关。640x480基本是实时性的甜点区间,1280x720也可以跑,但三模型叠加的时候帧率会明显下降。第二,实际识别用的画布分辨率最好和视频分辨率一致,不要拿一个静止的canvas反复drawImage,那样会产生额外的坐标换算。
绘制到画布的代码是这样的:
const canvas = document.createElement('canvas'); canvas.width = 640; canvas.height = 480; const ctx = canvas.getContext('2d'); function drawVideoFrame() { // 这里做水平镜像,让画面像照镜子一样自然 ctx.save(); ctx.translate(canvas.width, 0); ctx.scale(-1, 1); ctx.drawImage(video, 0, 0, canvas.width, canvas.height); ctx.restore(); }注意这一步的镜像处理。前置摄像头的原始画面是反的,很多演示项目直接不处理,结果关键点坐标和用户看到的画面方向不一致。你举起左手,画面里的模型举右手,体验非常奇怪。绘制时做一次水平镜像,让视频画面、模型输入、3D渲染三个坐标系统一,是省心做法。
2.2 坐标映射的核心公式
PoseNet和FaceMesh输出的坐标是像素坐标(FaceMesh是归一化坐标,细节后面会说到),它们的原点在图片左上角,x轴向右,y轴向下。而Three.js的世界坐标原点在场景中心,x轴向右,y轴向上,z轴向屏幕外。把一个2D的像素点放到3D场景里,需要做三层变换:
function mapTo3D(px, py, planeWidth, planeHeight, z) { const x = (px / planeWidth - 0.5) * 2; const y = -(py / planeHeight - 0.5) * 2; return new THREE.Vector3(x, y, z); }这个公式的本质是:先把像素坐标归一化到[0, 1],再映射到[-1, 1]区间,为什么用[-1, 1]?因为Three.js里默认视口和正交相机可以直接用这个区间定位,省了很多换算。y取负号是因为屏幕坐标和Three.js坐标的y方向相反。
我用这个方式做PoseNet的火柴人时,把z值固定在0.5的位置,整个3D骨骼就浮在画面前方。你不需要真的做深度估计,这个固定z值带来的“平面叠加”效果已经足够好了。如果你想让人物有前后纵深,可以后续用“肩膀到髋部的像素距离”反推一个近似深度,这个后面讲扩展时再说。
2.3 模型输出到3D场景的两种模式
坐标映射基本有两种接线方式。一种是直接把识别结果转成Three.js对象的position/rotation,比如检测到手腕就往手腕位置放一个小球;另一种是把一整组关键点作为几何体的顶点数据,整体更新Geometry的position属性,比如用FaceMesh的468个点动态驱动一张人脸的三角形网格。
第二种方式的效果更惊艳,但实现复杂一些,需要你把顶点数组和模型的拓扑结构(indices)对应起来。FaceMesh的顶点顺序是有意义的,每个三角形索引可以通过包里的faces数组拿到。后续我会把接线代码展开。
3. 三大模型逐个接入:PoseNet、FaceMesh、BodyPix 各自的联动姿势
这一章是核心。三个模型虽然都是TensorFlow.js体系,但输出格式、加载方式、和Three.js联动的思路完全不同。我会按模型一个一个拆,给出最简可用的接入方式,再讲清楚它们到底适合做什么效果。
3.1 PoseNet:从17个关键点到3D骨骼火柴人
PoseNet输出的关键是points数组,每组包含x、y坐标和score置信度。默认配置下是17个关键点,按COCO标注顺序排列:鼻子、左右眼、左右耳、左右肩、左右肘、左右腕、左右髋、左右膝、左右踝。
官方加载模型的方式是这样的:
import * as posenet from '@tensorflow-models/posenet'; const net = await posenet.load({ architecture: 'MobileNetV1', outputStride: 16, inputResolution: 320, multiplier: 0.75, });参数这里说下我的习惯:MobileNetV1架构在浏览器里最快,足够做实时交互;ResNet50准确率高但推理时间翻几倍,实时性很难保证。inputResolution决定神经网络输入图片的尺寸,320x320是比较平衡的选择,如果设备性能好可以试401。multiplier是网络宽度系数,0.5-0.75已经很快,精度损失肉眼几乎看不出来。
每帧推理和画骨骼的典型代码,我把它简化成一个结构清晰的过程:
const pose = await net.estimateSinglePose(canvas, { flipHorizontal: false, // 因为我们已经在绘制时做了镜像,这里不要重复翻转 decodingMethod: 'single-person' }); // 在Three.js中动态更新一个LineSegments const positions = []; const connections = [ ['leftShoulder', 'leftElbow'], ['leftElbow', 'leftWrist'], ['rightShoulder', 'rightElbow'], ['rightElbow', 'rightWrist'], ['leftShoulder', 'rightShoulder'], ['leftShoulder', 'leftHip'], ['rightShoulder', 'rightHip'], ['leftHip', 'rightHip'], ['leftHip', 'leftKnee'], ['leftKnee', 'leftAnkle'], ['rightHip', 'rightKnee'], ['rightKnee', 'rightAnkle'] ]; for (const [a, b] of connections) { const pa = pose.keypoints.find(k => k.part === a); const pb = pose.keypoints.find(k => k.part === b); if (pa.score < 0.3 || pb.score < 0.3) { // 置信度不够就拉远,相当于隐藏该条线 positions.push(-10, -10, -10, -10, -10, -10); } else { const va = mapTo3D(pa.position.x, pa.position.y, 640, 480, 0.5); const vb = mapTo3D(pb.position.x, pb.position.y, 640, 480, 0.5); positions.push(va.x, va.y, va.z, vb.x, vb.y, vb.z); } }我把连线逻辑里的一个小细节提出来单独说:置信度过滤。PoseNet在一个人完全被遮挡、或者手部快速挥动时,置信度会骤降。如果不过滤,那些错误的关键点会让骨骼线条疯狂抖动,看起来非常廉价。加一个0.3的阈值,低于阈值的就把线段顶点拉到屏幕外(比如远离坐标系的-10位置),实际效果就是那条线“消失”了。0.3是我测试下来比较合适的值,太低会有明显抖动,太高会导致线条频繁闪烁。
Three.js侧,用一个动态更新BufferGeometry的LineSegments来承载这套数据:
const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); const material = new THREE.LineBasicMaterial({ color: 0x00ffff, linewidth: 1 }); const lineSegments = new THREE.LineSegments(geometry, material); scene.add(lineSegments);注意linewidth在WebGL里大多数浏览器会当作1处理,你设置大于1的数值往往不生效。想要粗线条效果,需要换用THREE.Line2或者把每段骨骼替换成圆柱体Mesh。用圆柱体替代线条的缺点是计算量上升、代码变复杂,我建议先做线条版,跑通后再按需升级。
3.2 FaceMesh:468个点驱动3D面具
FaceMesh的接入方式和PoseNet差异很大。它输出的不是像素坐标,而是归一化坐标(0到1的范围),使用前必须乘上画面宽高。另一个关键是它返回的是三维坐标数组(x、y、z),但z轴的尺度是“相对深度估计”,和x/y的比例不一致,如果你直接用原始的z值,面具会显得极端拉伸。
我是这样处理的:
import * as faceLandmarksDetection from '@tensorflow-models/face-landmarks-detection'; const model = faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh; const detector = await faceLandmarksDetection.createDetector(model, { runtime: 'tfjs', refineLandmarks: true, maxFaces: 1, }); // 每帧检测 const faces = await detector.estimateFaces(canvas); if (faces.length > 0) { const landmarks = faces[0].keypoints; // 468个 {x, y, z} // 把归一化坐标转成像素坐标 const points = landmarks.map(p => ({ x: p.x * 640, y: p.y * 480, z: p.z * 80, // 手动缩放z,让它和x/y量级一致 })); }这里手动把z乘以80,是我实测后的经验值。FaceMesh的z值绝对值非常小,直接放进Three.js,面部网格会变成一个几乎贴在平面上的纸片。乘以80到120(取决于相机距人脸远近)后,鼻子、眼睛、嘴唇的立体感就出来了。这个系数没有标准答案,你应该根据自己想要的效果调整。
接下来是“惊艳效果”的实现:把468个关键点直接作为BufferGeometry的顶点,用FaceMesh提供的三角形索引来构建网格。face-landmarks-detection包导出了TRIANGULATION数组,但你自己解析那个数组比较麻烦。通常情况下,如果你想从landmarks构建geometry的index属性,可以使用包内部输出的faces字段,或者使用完整的三角形索引列表。这里我提供一个思路:把顶点positions和三角形indices合并成Three.js的Mesh,并用一个半透明材质来渲染:
const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3)); geometry.setIndex(indices); geometry.computeVertexNormals(); const material = new THREE.MeshBasicMaterial({ color: 0xffffff, wireframe: true, // 线框模式最直观 transparent: true, opacity: 0.7, }); const mesh = new THREE.Mesh(geometry, material); scene.add(mesh);每帧更新顶点的时候,注意别重新new BufferAttribute,直接更新原数组:
const posAttr = geometry.attributes.position; const arr = posAttr.array; for (let i = 0; i < points.length; i++) { const v = mapTo3D(scaleFactor, points[i].x, points[i].y, points[i].z); arr[i * 3] = v.x; arr[i * 3 + 1] = v.y; arr[i * 3 + 2] = v.z; } posAttr.needsUpdate = true;设置needsUpdate = true 是Three.js里对已有geometry进行动态更新的关键,漏了这行,你看到的画面会一直是第一帧的形状。这个坑我已经见过不止一个人踩了。
FaceMesh的“refineLandmarks: true”参数也值得说。它表示使用更精细的二次模型,把468个点进一步优化,尤其对轮廓和眼唇边缘的处理更好。缺点是多一个推理步骤,帧率有小幅下降。如果你只做面具,推荐开着;如果要做低延迟的眼神交互,可以关掉。
3.3 BodyPix:像素级人体分割与粒子效果
BodyPix和前面两个模型完全不是一个路子。PoseNet和FaceMesh输出的是稀疏的关键点,BodyPix输出的是密集的像素级掩码。它可以告诉你:画面中哪些像素属于“人”的轮廓,甚至能区分出24个身体部位。
加载和推理代码:
import * as bodyPix from '@tensorflow-models/body-pix'; const net = await bodyPix.load({ architecture: 'MobileNetV1', outputStride: 16, multiplier: 0.5, quantBytes: 2, }); const segmentation = await net.segmentPerson(canvas, { flipHorizontal: false, internalResolution: 0.5, // 内部推理分辨率,越低越快 segmentationThreshold: 0.5, });segmentPerson返回的segmentation里,最重要的字段是data,它是一个Uint8Array,长度等于internalResolution下的像素总数,每个值是0或1。1代表属于人体,0代表背景。还有一个width和height表示这个掩码图的分辨率。
拿到掩码之后,最直观的Three.js联动方式是把整个画面变成一个粒子系统。思路是:把视频帧的每个像素映射成一个粒子点,粒子的颜色取视频帧对应的像素颜色,粒子的尺寸或者可见性则取决于该像素是否属于人体。这样你能看到一个由成千上万个小圆点组成的“人体”,背景粒子则被隐藏或虚化。
这个效果的关键代码逻辑是:
// 假设你在场景中创建了一个包含 width*height 个粒子的Points const positions = geometry.attributes.position.array; const colors = geometry.attributes.color.array; for (let y = 0; y < maskHeight; y++) { for (let x = 0; x < maskWidth; x++) { const idx = y * maskWidth + x; const isPerson = segmentation.data[idx] === 1; const px = (x / maskWidth) * 2 - 1; const py = -(y / maskHeight) * 2 + 1; // 从视频帧 canvas 上采样颜色 const imageData = ctx.getImageData(x * step, y * step, 1, 1).data; positions[idx * 3] = px; positions[idx * 3 + 1] = py; positions[idx * 3 + 2] = isPerson ? 0 : -5; // 背景粒子推到远处 colors[idx * 3] = imageData[0] / 255; colors[idx * 3 + 1] = imageData[1] / 255; colors[idx * 3 + 2] = imageData[2] / 255; } }getImageData每一帧调用会拖慢性能。更高效的做法是先把整个视频帧绘制到一个离屏canvas,然后用getImageData一次性拿到整帧像素数组,再按采样步长填充粒子颜色。
粒子数量的控制也很关键。如果你按640x480全分辨率生成粒子,那就是30万个粒子,Three.js的Points渲染倒是撑得住,但CPU侧每帧更新数组的开销非常大。我建议把粒子网格降采样到160x120,也就是约2万个粒子,视觉上依然是密集的“人体星云”,性能却完全没压力。
BodyPix还有segmentPersonParts方法可以分部位着色——手臂一个颜色、腿一个颜色、脸一个颜色。如果你想做“人体解剖图”那样的效果,可以考虑它。但要注意,partSegmentation返回的data里每个值不是0/1,而是部位ID(0到23),你需要建一张部位ID到颜色的映射表。
4. 性能优化:帧率、分辨率和推理节奏的控制策略
三模型同时在一个页面里跑,直接让你体验什么叫“PPT交互”。我在第一次把PoseNet、FaceMesh、BodyPix全部打开时,帧率掉到7fps,整个页面卡得像慢动作。经过一轮优化后,至少能稳定在25fps以上。几个关键手段,效果立竿见影。
4.1 跳帧推理:最有效的优化
大多数摄像头是30fps,但模型推理没法做到每帧都跑,或者跑完了也没必要每帧更新位置。我采用“推理频率控制”的策略:渲染循环保持60fps,但模型推理每隔2到4帧执行一次,3D场景继续用上次的推理结果渲染。
具体实现是用一个计数器:
let frameCount = 0; const INFERENCE_INTERVAL = 3; // 每3帧推理一次 function animate() { requestAnimationFrame(animate); frameCount++; if (frameCount % INFERENCE_INTERVAL === 0) { runPoseNetInference(); // 其他模型也在这里跑 } updateThreeSceneFromLatestResults(); renderer.render(scene, camera); }这个“间隔跳帧”的收益非常大,因为TF.js的前向推理是同步计算,会阻塞JS主线程。跳掉2/3的帧,等于把主线程的时间让给了Three.js渲染。代价是动作会有轻微的延迟感,大概是100毫秒以内的滞后,在交互演示场景完全可以接受。
如果想更精细一点,可以给不同模型分配不同间隔。比如PoseNet每3帧一次,FaceMesh每2帧一次,BodyPix每5帧一次。这样能削平CPU峰值。
4.2 输入分辨率与内部推理分辨率
模型输入分辨率直接影响推理速度。前面PoseNet我把inputResolution设成了320,比摄像头原始分辨率小了一半,这是刻意的。神经网络对输入的细节要求没有你想的那么高,320分辨率的PoseNet识别一个完整人体已经足够了。
BodyPix的内部推理分辨率比较特殊,segmentPerson里有个internalResolution参数,0.5表示把输入图缩到一半,输出掩码的分辨率也跟着降一半。对于粒子效果来说,掩码不需要高清,它只是用来判断“这个像素是不是人”,0.25甚至都能用。分辨率越低,像素块感越强,但这也是某种“风格”。
4.3 WebGL后端和WASM后端怎么选
TensorFlow.js在支持WebGL的浏览器上默认用WebGL后端。WebGL后端通过GPU加速卷积计算,实时推理基本靠它。但有个反直觉的情况:在部分低端设备或Windows系统上,WebGL模式下某些算子反而很慢,而WASM(WebAssembly)后端用CPU多线程计算,虽然在数学运算上通常比GPU慢,但是它的启动开销小,而且更稳定。
如果项目跑起来明显卡顿,可以试着强制指定WASM后端:
await tf.setBackend('wasm'); await tf.ready();我记得WASM后端还需要单独加载wasm二进制文件,用npm安装时通常会自动处理。实际对比效果:在苹果芯片的Mac上WebGL表现更好,在普通Windows笔记本上WASM有时候更稳。我的建议是默认用WebGL,出问题再切WASM做A/B对比。
4.4 显存和内存释放
TensorFlow.js每次推理返回的Tensor如果忘记dispose,内存会被占满。尤其BodyPix输出的掩码数据量大,跑几百帧之后页面会越来越卡,最后直接崩溃。在写代码时,对显式创建的Tensor要记得调用tensor.dispose(),对模型返回的结果也一样:
const segmentation = await net.segmentPerson(canvas, options); // 用完后立即释放 segmentation.data = null; // 如果用的是Tensor,调用 tensor.dispose()不过segmentPerson返回的不是Tensor,而是处理过的对象,内部已经释放了部分资源。PoseNet的estimateSinglePose返回的也是普通对象。真正需要小心的是,如果你自己用tf.browser.fromPixels创建了Tensor,那么一定要在推理后调用dispose。不然一帧泄漏一个Tensor,几百帧后浏览器的内存就爆了。
5. 这类项目最常见的坑:从 WebGL 上下文到标签页节流
跑通一个演示项目并调试到流畅状态,中间一定有各种诡异问题。下面这几个坑我基本都踩过,按“踩坑率”排个序,排名靠后的可能更隐蔽,但往往一卡就是半天。
5.1 多WebGL上下文的冲突
TensorFlow.js会创建自己私有的WebGL上下文,Three.js也会创建渲染上下文。一般情况下各自用各自的没问题,但在同一页面里,浏览器对WebGL上下文的总数有限制(通常是8到16个,因浏览器而异)。如果你开了多个页面、或者反复加载模型,旧的上下文没有释放,新上下文创建失败,整个页面黑屏。
一个常见冲突场景是:你定义了一个全局video,然后又创建了一个隐藏canvas给TF.js用,Three.js再创建一个webgl-canvas。三个上下文同时活跃,在某些显卡驱动下有概率直接报“Too many active WebGL contexts”错误。这时候最简单的排查办法:关了其他标签页,刷新页面,如果恢复正常,基本上就是上下文溢出。
更系统的做法是尽量共享一个上下文。Three.js的WebGLRenderer在创建时可以把context传进去,TF.js也支持指定自己的gl上下文。但把两个库接到同一个context会比较绕,而且要处理SharedArrayBuffer等问题,对于休闲项目来说性价比不高。我的实际建议是:保持Three.js自己的canvas渲染3D,摄像头帧画到隐藏的2D canvas,TF.js用这个2D canvas创建输入Tensor。这样2D canvas和3D canvas互不干扰,TF.js内部用自己的WebGL上下文,虽不是同一个gl,但它们在各自的canvas上操作,不会互相破坏状态。
5.2 镜像反转翻车
前面我特意说了镜像:如果绘制视频时做了scale(-1,1),画面上的人和手势是镜像的,但你直接把这个canvas交给模型推理,模型的输出坐标也是以这个canvas为基准的,坐标体系是一致的,这是正常情况。
真正容易翻车的是“我既想显示视频流,又想显示3D叠加”——视频流用css或canvas做了镜像,但3D场景没有镜像。结果就是:你的左手对应的3D火柴人的右手。要解决其实也很简单,让Three.js的scene在渲染时也做一个横向翻转,或者直接把3D对象整体scale.x设为-1:
group.scale.x = -1;这样做之后的实际效果是,视频里你抬起右手,3D火柴人的右手也对应着抬起。镜像问题在做交互演示时非常影响观感,务必写在项目注释里。
5.3 浏览器后台标签页节流
几乎每个做摄像头交互的人都会遇到:切到其他标签页几分钟,再切回来发现画面卡住或者模型完全失灵。原因有两个:一是浏览器为了省电会暂停后台页面的requestAnimationFrame;二是getUserMedia的视频流在标签页不可见时,浏览器可能会降低视频帧的产生频率。
从后台切回来之后,requestAnimationFrame恢复正常,但Three.js场景可能还停留在切走之前的状态,TF.js的推理结果也过期了。这时候需要立刻基于最新一帧视频重新做一次推理,把3D状态刷新。我的做法是监听visibilitychange事件:
document.addEventListener('visibilitychange', () => { if (!document.hidden) { video.play(); frameCount = 0; // 重置推理计数器 runAllInferenceImmediately(); // 立即跑一次完整推理 } });实测下来这个处理能解决大部分“回到页面后画面瘫痪”的问题。还有个小细节:如果视频流用video.play()的promise被拒绝(因为页面切到后台期间浏览器暂停了流),需要catch一下,否则控制台会一直刷Unhandled Promise Rejection。
5.4 多个模型同时加载的顺序问题
PoseNet、FaceMesh、BodyPix三个模型一起load,在低端设备上可能要等好几秒。如果加载时有任何模型包里的wasm(比如TF.js自动加载的二进制文件)发起请求,页面在加载完成前最好给足反馈,不然用户会以为页面挂了。
我的处理方式是做一个“模型管理器”,把加载流程改成串行+进度条:
async function loadAllModels(onProgress) { const tasks = [ ['PoseNet', () => posenet.load(config)], ['FaceMesh', () => faceLandmarksDetection.createDetector(...)], ['BodyPix', () => bodyPix.load(config)], ]; for (let i = 0; i < tasks.length; i++) { onProgress(tasks[i][0], (i + 1) / tasks.length); await tasks[i][1](); } }有人会想并行加载三个模型,现实是TF.js在加载时可能会同时启动多个wasm下载,导致网络竞争和内存压力。串行加载虽然慢一点,但更稳。
6. 项目结构参考与后续扩展:从一个演示到一个可玩的作品
如果你不是只想要一段贴到页面上就能跑的Demo,而是想把这个当成一个持续迭代的创意项目,那代码结构从一开始就不能太乱。我分享一下这个项目最终稳定下来时用的模块划分,以及我自己在跑通之后又做了哪些有趣的扩展方向。
6.1 建议的目录与模块划分
我习惯把所有视觉生成相关的代码拆成独立的模块,而不是在一个入口文件里堆所有逻辑。实际结构大致是这样的:
src/ main.js // 入口:初始化摄像头、启动渲染循环 models/ poseModel.js // PoseNet的加载、推理、结果缓存 faceModel.js // FaceMesh的加载、推理、结果缓存 bodyModel.js // BodyPix的加载、推理、结果缓存 three/ sceneManager.js // 场景、相机、渲染器的创建 poseVisualizer.js // 把关键点画成线条/小球 faceVisualizer.js // 把468点画成网格面具 particleVisualizer.js // 基于分割掩码的粒子人体 utils/ coordinates.js // mapTo3D等坐标系转换函数 video.js // 视频流获取、canvas绘制三个visualizer各自对外暴露一个update(data)方法,负责把新推理数据喂给对应的Three.js对象。main.js里的渲染循环只管从模型模块拿“最新结果”,然后逐个调用visualizer的update。这样改动一套视觉表现不会影响模型推理,反过来换模型也不动渲染逻辑。
6.2 在Three.js里升级视觉效果:水面、闪电这类Shader变化
当项目基础跑通后,你大概率不会满足于朴素的线框和粒子。Three.js最强大的地方在于,只要你愿意写GLSL,所有视觉都能玩出花。
比如PoseNet的火柴人线条,默认是LineBasicMaterial纯色线框,看起来有点廉价。如果想做得更有质感,可以用Line2实现可变宽度的发光线条;或者加一道“闪电”效果:把骨骼骨骼点之间,通过shader渲染成一段带有随机锯齿状的电流。三点之间每两个点生成一个折线,再通过时间噪声让折线的每个顶点在垂直于骨骼的方向上抖动,呈现出电流闪烁的效果。
水面的效果也可以和摄像头结合:BodyPix分割出人体之后,把人体区域作为“水面波纹扰动源”,人体边缘的粒子当作涟漪源头,让粒子围绕边缘做放射状起伏。用Three.js写一个自定义顶点shader,在顶点着色器里根据距离中心的远近和时间值计算扰动偏移。这种效果跟Three.js官方的water案例不一样,自己控制shader才有交互性。
这些方向对GLSL的要求直接上了一个台阶,但如果只是想看看更炫的过渡效果,也有取巧方案:用ShaderMaterial叠加一个后处理pass,比如在粒子颜色里插值一些动态色带、加一个简单的Bloom辉光后期。Three.js的EffectComposer配合UnrealBloomPass,几行代码就能让整个画面多一层“电影感”。
6.3 识别精度、稳定性和使用体验的后续目标
从Demo期到作品期,你在意的变量会从“能跑吗”变成“跑得稳吗”和“用户用起来爽吗”。几个我仍在持续迭代的方向供参考:
一个是多人追踪。当前PoseNet用estimateSinglePose默认只能识别单个主体,如果想多人,需要用estimateMultiplePoses,并考虑如何把多条骨骼映射到Three.js中做分组显示。多人模式对CPU的要求高很多,需要把输入分辨率降到240甚至更小,同时接受帧率下滑。
另一个是把识别结果做平滑。模型每帧输出的关键点会有一点随机抖动,直接使用坐标看起来像手持摄影机没开稳定器。可以用指数平滑或者基于卡尔曼滤波的简易一阶算法,对每个关键点坐标做低通滤波:
smoothedX = prevX * 0.7 + currentX * 0.3;系数0.3可以理解成“新数据占的权重”,权重越低越平滑,但延迟越高。实际调的过程中我一般从0.3起步,看抖动和延迟的平衡再调。对FaceMesh这种微小面部动作,平滑尤其重要,不然面具边缘会一直轻微闪烁。
最后,所有东西跑通之后,别忘了做一次性能基准测试。建议跑一个“摄像头+单模型+渲染”的最小组合,记录帧率和推理耗时,然后逐步叠加其他功能,这样才能定位瓶颈到底出在模型、渲染还是代码逻辑上。我在做这个项目时,就发现BodyPix的推理耗时比PoseNet和FaceMesh加起来还高,之后所有涉及多模型叠加的场景,BodyPix的优先级都是最低的。
7. 结尾:一点个人体会
这类“浏览器里的实时交互”项目,最迷人的地方不在于某个技术点有多前沿,而在于那种“从数据到视觉”的即时反馈链条。PoseNet把人体变成一串坐标,Three.js再把坐标变成屏幕上活生生的影像,这种转化的爽感是纯后端AI或纯3D渲染都很难单独带来的。我强烈建议你拿到代码后,先别急着调参数,把自己放进画面里动一动,感受一下延迟和视觉反馈的配合,然后再去改代码。你会很快发现自己最想优化的那个点,可能就是别人学不走的独门技巧。
本文还有配套的精品资源,点击获取