本文重点介绍使用 Three.js 和 Tensorflow.js 实现实时人脸网格点云所需的步骤。 它假设你之前了解异步 javascript 和 Three.js 基础知识,因此不会涵盖基础知识。
该项目的源代码可以在此 Git 存储库中找到。 在阅读本文时查看该代码将会很有帮助,因为将跳过一些基本的实现步骤。
本文还将以面向对象的方式实现该项目,其中包含大量抽象,因此对 Typescript 中的类有基本的了解是一个优势。
推荐:用 NSDT编辑器 快速搭建可编程3D场景
由于本教程的目标是渲染人脸点云,因此我们需要从设置三个 js 场景开始。
设置场景所需的数据和方法封装在 sceneSetUp.ts 中名为 ThreeSetUp 的工厂类中。 该类负责创建所有必要的场景对象,如渲染器、相机和场景。 它还启动画布元素的调整大小处理程序。 该类具有以下公共方法:
getSetUp(){
return {
camera: this.camera,
scene: this.scene,
renderer : this.renderer,
sizes: this.sizes,
}
}
applyOrbitControls(){
const controls = new OrbitControls(
this.camera, this.renderer.domElement!
)
controls.enableDamping = true
return ()=> controls.update();
}
我们的主要实现类 FacePointCloud 将启动 ThreeSetUP 类并调用这两个方法来获取设置元素并应用轨道控制。
为了使我们能够获得面部网格跟踪信息,我们需要一个像素输入来提供给面部网格跟踪器。 在这种情况下,我们将使用设备网络摄像头来生成此类输入。 我们还将使用 HTML 视频元素(不将其添加到 Dom)从网络摄像头读取媒体流,并以我们的代码可以交互的方式加载它。
在此步骤之后,我们将设置一个 HTML canvas 元素(也无需添加到 Dom)并向其渲染视频输出。 这使我们还可以选择从画布生成三个 Js 纹理并将其用作材质(我们不会在本教程中实现这一点)。 我们将使用 canvas 元素作为 FaceMeshTracker 的输入。
为了处理从网络摄像头读取媒体流并将其加载到视频 HTML 元素,我们将创建一个名为 WebcamVideo 的类。 此类将处理创建 HTML 视频元素并调用导航器 api 来加载获取用户权限并从设备的网络摄像头加载信息。
在启动此类时,将调用私有 init 方法,该方法具有以下代码:
private init(){
navigator.mediaDevices.getUserMedia(this.videoConstraints)
.then((mediaStream)=>{
this.videoTarget.srcObject = mediaStream
this.videoTarget.onloadedmetadata = () => this.onLoadMetadata()
}
).catch(function (err) {
alert(err.name + ': ' + err.message)
}
)
}
此方法调用导航器对象的 mediaDevices 属性上的 getUserMedia 方法。 此方法将视频约束(也称为视频设置)作为参数并返回一个承诺。 此承诺解析为包含来自网络摄像头的视频数据的 mediaStream 对象。 在 Promise 的解析回调中,我们将视频元素的源设置为返回的 mediaStream。
在promise解析回调中,我们还在视频元素上添加了一个loadedmetadata事件监听器。 该监听器的回调会触发对象的 onLoadMetaData 方法并设置以下副作用:
private onLoadMetadata(){
this.videoTarget.setAttribute('autoplay', 'true')
this.videoTarget.setAttribute('playsinline', 'true')
this.videoTarget.play()
this.onReceivingData()
}
此时,我们有一个 WebcamVideo 对象,它负责创建包含实时网络摄像头数据的视频元素。 下一步是将视频输出绘制在画布对象上。
为此,我们将创建一个使用 WebcamVideo 类的特定 WebcamCanvas 类。 此类将创建 WebcamVideo 类的实例,并使用它通过画布上下文方法的drawImage()将视频的输出绘制到画布上。 这将在 updateFromWebcam 方法上实现。
updateFromWebCam(){
this.canvasCtx.drawImage(
this.webcamVideo.videoTarget,
0,
0,
this.canvas.width,
this.canvas.height
)
}
我们必须在渲染循环中不断调用此函数,以不断使用视频的当前帧更新画布。
此时,我们已将像素输入准备为显示网络摄像头的画布元素。
创建人脸网格检测器并生成检测数据是本教程的主要部分。 这将实现 Tensorflow.js 人脸特征点检测模型。
npm add @tensorflow/tfjs-core, @tensorflow/tfjs-converter
npm add @tensorflow/tfjs-backend-webgl
npm add @tensorflow-models/face-detection
npm add @tensorflow-models/face-landmarks-detection
安装所有相关包后,我们将创建一个处理以下内容的类:
我们创建了一个名为faceLandmark.ts 的文件来实现该类。 文件顶部的导入是:
import '@mediapipe/face_mesh'
import '@tensorflow/tfjs-core'
import '@tensorflow/tfjs-backend-webgl'
import * as faceLandmarksDetection from '@tensorflow-models/face-landmarks-detection'
运行和创建检测器对象将需要这些模块。
我们创建 FaceMeshDetectorClass,如下所示:
export default class FaceMeshDetector {
detectorConfig: Config;
model: faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh;
detector: faceLandmarksDetection.FaceLandmarksDetector | null;
constructor(){
this.model = faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh;
this.detectorConfig = {
runtime: 'mediapipe',
refineLandmarks: true,
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh',
}
this.detector = null;
}
private getDetector(){
const detector = faceLandmarksDetection.createDetector(
this.model,
this.detectorConfig as faceLandmarksDetection.MediaPipeFaceMeshMediaPipeModelConfig
);
return detector;
}
async loadDetector(){
this.detector = await this.getDetector()
}
async detectFace(source: faceLandmarksDetection.FaceLandmarksDetectorInput){
const data = await this.detector!.estimateFaces(source)
const keypoints = (data as FaceLandmark[])[0]?.keypoints
if(keypoints) return keypoints;
return [];
}
}
这个类中的主要方法是 getDetector,它调用我们从 Tensorflow.js 导入的 FaceLandMarksDetection 上的 createDetector 方法。 然后 createDetector 采用我们在构造函数中引入的模型:
this.model = faceLandmarksDetection.SupportedModels.MediaPipeFaceMesh;
以及指定检测器参数的检测配置对象:
this.detectorConfig = {
runtime: 'mediapipe',
refineLandmarks: true,
solutionPath: 'https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh',
}
检测函数将返回一个承诺,该承诺将解析为检测器对象。 然后在 loadDetector 公共异步方法中使用私有 getDetector 函数,该方法将类上的 this. detector 属性设置为检测器。
FaceMeshDetector 类还实现了一个公共的 detectorFace 方法:
async detectFace(source){
const data = await this.detector!.estimateFaces(source)
const keypoints = (data as FaceLandmark[])[0]?.keypoints
if(keypoints) return keypoints;
return [];
}
该方法采用一个源参数,即像素输入。 我们将在这里使用上面的画布元素作为跟踪源。 该函数将被如下调用:
faceMeshDetector.detectFace(this.webcamCanvas.canvas)
此方法调用检测器上的estimateFaces方法,如果此方法在网络摄像头输出中检测到人脸,它将返回一个包含检测数据的对象的数组。 该对象有一个称为关键点的属性,它包含模型在面部检测到的 478 个点中每个点的对象数组。 每个对象都有 x、y 和 z 属性,其中包括画布中点的坐标。 例子:
[
{
box: {
xMin: 304.6476503248806,
xMax: 502.5079975897382,
yMin: 102.16298762367356,
yMax: 349.035215984403,
width: 197.86034726485758,
height: 246.87222836072945
},
keypoints: [
{x: 406.53152857172876, y: 256.8054528661723, z: 10.2, name:
"lips"},
{x: 406.544237446397, y: 230.06933367750395, z: 8},
...
],
}
]
值得注意的是,这些点作为画布空间中的坐标返回,这意味着参考点、x: 0 和 y: 0 点位于画布的左上角。 稍后当我们必须将坐标转换为 Three.js 场景空间(其参考点位于场景中心)时,这将是相关的。
此时,我们有了像素输入源,以及面部网格检测器,它将为我们提供检测到的点。 现在,我们可以转到 Three.js 部分!
为了在 Three.js 中生成面部网格,我们必须从检测器加载面部网格点,然后将它们用作 Three js Points 对象的位置属性。 为了使三个 js 面部网格反映视频中的运动(实时反应),每当我们创建的检测器发生面部检测变化时,我们都必须更新此位置属性。
为了实现这一点,我们将创建另一个名为 PointCloud 的工厂类,它将创建一个空的 Points 对象,以及一个可用于更新此点对象的属性(例如位置属性)的公共方法。 这个类看起来像这样:
export default class PointCloud {
bufferGeometry: THREE.BufferGeometry;
material: THREE.PointsMaterial;
cloud: THREE.Points;
constructor() {
this.bufferGeometry = new THREE.BufferGeometry();
this.material = new THREE.PointsMaterial({
color: 0x888888,
size: 0.0151,
sizeAttenuation: true,
});
this.cloud = new THREE.Points(this.bufferGeometry, this.material);
}
updateProperty(attribute: THREE.BufferAttribute, name: string){
this.bufferGeometry.setAttribute(
name,
attribute
);
this.bufferGeometry.attributes[name].needsUpdate = true;
}
}
此类启动空的 BufferGrometry,它是点的材质以及消耗两者的点对象。 将此点对象添加到场景中不会改变任何内容,因为几何体没有任何位置属性,换句话说,没有顶点。
PointCloud 类还公开 updateProperty 方法,该方法接受缓冲区属性和属性名称。 然后,它将调用 bufferGeometry setAttribute 方法并将 needUpdate 属性设置为 true。 这将允许 Three.js 在下一个 requestAnimationFrame 迭代中反映 bufferAttribute 的更改。
我们将使用此 updateProperty 方法根据从 Tensorflow.js 检测器接收到的点来更改点云的形状。
现在,我们还准备好点云来接收新的位置数据。 所以,是时候把所有的东西都绑在一起了!
为了将所有内容结合在一起,我们将创建一个实现类来调用使所有内容正常工作所需的类、方法和步骤。 这个类称为 FacePointCloud。 在构造函数中,它将实例化以下类:
constructor() {
this.threeSetUp = new ThreeSetUp()
this.setUpElements = this.threeSetUp.getSetUp()
this.webcamCanvas = new WebcamCanvas();
this.faceMeshDetector = new faceLandMark()
this.pointCloud = new PointCloud()
}
这个类还有一个名为bindFaceDataToPointCloud的方法,它执行我们逻辑的主要部分,即获取检测器提供的数据,将其转换为Three.js可以理解的形式,从中创建一个Three.js缓冲区属性并使用 它来更新点云。
async bindFaceDataToPointCloud(){
const keypoints = await
this.faceMeshDetector.detectFace(this.webcamCanvas.canvas)
const flatData = flattenFacialLandMarkArray(keypoints)
const facePositions = createBufferAttribute(flatData)
this.pointCloud.updateProperty(facePositions, 'position')
}
因此,我们将画布像素源传递给 detectorFace 方法,然后在实用程序函数 flattenFacialLandMarkArray 中对返回的数据执行操作。 这非常重要,因为有两个问题:
正如我们上面提到的,人脸检测模型中的点将以以下形式返回:
keypoints: [
{x: 0.542, y: 0.967, z: 0.037},
...
]
而 buffer 属性期望数据/数字具有以下形状:
number[] or [0.542, 0.967, 0.037, .....]
数据源(画布)之间的坐标系差异,画布的坐标系如下所示:
因此,考虑到这两个选项,我们实现了 flattenFacialLandMarkArray 函数来解决这些问题。 该函数的代码如下所示:
function flattenFacialLandMarkArray(data: vector[]){
let array: number[] = [];
data.forEach((el)=>{
el.x = mapRangetoRange(500 / videoAspectRatio, el.x,
screenRange.height) - 1
el.y = mapRangetoRange(500 / videoAspectRatio, el.y,
screenRange.height, true)+1
el.z = (el.z / 100 * -1) + 0.5;
array = [
...array,
...Object.values(el),
]
})
return array.filter((el)=> typeof el === 'number');
flattenFacialLandMarkArray 函数获取我们从人脸检测器接收到的关键点输入,并将它们展开到一个数组中,以数字 [] 形式而不是对象 [] 形式。 在将数字传递到新的输出数组之前,它通过 mapRangetoRange 函数将它们从画布坐标系映射到 Three.js 坐标系。 该函数如下所示:
function mapRangetoRange(from: number, point: number, range: range, invert: boolean = false): number{
let pointMagnitude: number = point/from;
if(invert) pointMagnitude = 1-pointMagnitude;
const targetMagnitude = range.to - range.from;
const pointInRange = targetMagnitude * pointMagnitude +
range.from;
return pointInRange
}
我们现在可以创建初始化函数和动画循环。 这是在 FacePointCloud 类的 initWork 方法中实现的,如下所示:
async initWork() {
const { camera, scene, renderer } = this.setUpElements
camera.position.z = 3
camera.position.y = 1
camera.lookAt(0,0,0)
const orbitControlsUpdate = this.threeSetUp.applyOrbitControls()
const gridHelper = new THREE.GridHelper(10, 10)
scene.add(gridHelper)
scene.add(this.pointCloud.cloud)
await this.faceMeshDetector.loadDetector()
const animate = () => {
requestAnimationFrame(animate)
if (this.webcamCanvas.receivingStreem){
this.bindFaceDataToPointCloud()
}
this.webcamCanvas.updateFromWebCam()
orbitControlsUpdate()
renderer.render(scene, camera)
}
animate()
}
我们可以看到这个 init 函数如何将所有内容联系在一起,它获取 Three.js 设置元素并设置相机,向场景和点云添加 gridHelper。
然后,它将检测器加载到faceLandMark 类上,并开始设置我们的动画函数。 在此动画函数中,我们首先检查 WebcamCanvas 元素是否正在接收来自网络摄像头的流,然后调用 bindFaceDataToPointCloud 方法,该方法在内部调用检测面部函数并将数据转换为 bufferAttribute 并更新点云位置属性。
原文链接:Three.js实时构建人脸点云 — BimAnt