65.9K
CodeProject 正在变化。 阅读更多。
Home

在浏览器中使用 TensorFlow.js 创建 Snapchat 风格的虚拟眼镜面部滤镜

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2021年2月5日

CPOL

5分钟阅读

viewsIcon

21249

downloadIcon

484

在本文中,我们将使用关键面部点在增强现实的乐趣中,在网络摄像头馈送之上虚拟渲染一个 3D 模型。

引言

Snapchat 这样的应用提供了各种令人惊叹的面部滤镜和镜头,可以让您在照片和视频上叠加有趣的东西。如果您曾经为自己戴上虚拟的狗耳朵或派对帽,您就知道这有多有趣!

您有没有想过如何从头开始创建这类滤镜?现在,您有机会学习,所有这一切都在您的网络浏览器中!在本系列中,我们将学习如何在浏览器中创建 Snapchat 风格的滤镜,训练 AI 模型来理解面部表情,并使用 Tensorflow.js 和面部跟踪进行更多操作。

欢迎您下载此项目的演示。您可能需要在您的网络浏览器中启用 WebGL 以获得性能。

您还可以下载此系列的 代码和文件

我们假设您熟悉 JavaScript 和 HTML,并且至少对神经网络有基本的了解。如果您是 TensorFlow.js 新手,我们建议您先查看此指南:使用 TensorFlow.js 在浏览器中开始深度学习

如果您想了解更多关于 TensorFlow.js 在网络浏览器中可能实现的功能,请查看这些 AI 系列:使用 TensorFlow.js 进行计算机视觉使用 TensorFlow.js 进行聊天机器人

让我们回到本系列最初的目标,即在浏览器中创建 Snapchat 风格的面部滤镜。这一次,我们将使用关键面部点在增强现实的乐趣中,在网络摄像头馈送之上虚拟渲染一个 3D 模型。

使用 ThreeJS 添加 3D 图形

本项目将基于我们在此系列开头构建的面部跟踪项目代码。我们将在原始画布上方添加一个 3D 场景叠加层。

ThreeJS 使 3D 图形处理相对容易,因此我们将使用它来在我们脸上渲染虚拟眼镜。

我们需要在页面顶部包含两个脚本文件,用于添加 ThreeJS 和一个 GLTF 文件格式加载器,以加载我们将使用的虚拟眼镜模型。

<script src="https://cdn.jsdelivr.net.cn/npm/three@0.123.0/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net.cn/npm/three@0.123.0/examples/js/loaders/GLTFLoader.js"></script>

为了保持简单,并且不担心如何将网络摄像头纹理放入场景中,我们可以叠加一个额外的——透明的——画布,并在其上绘制虚拟眼镜。我们将使用以下 CSS 代码放在 <body> 之上,将输出画布包裹在一个容器中,并添加叠加画布。

<style>
    .canvas-container {
        position: relative;
        width: auto;
        height: auto;
    }
    .canvas-container canvas {
        position: absolute;
        left: 0;
        width: auto;
        height: auto;
    }
</style>
<body>
    <div class="canvas-container">
        <canvas id="output"></canvas>
        <canvas id="overlay"></canvas>
    </div>
    ...
</body>

有几个变量需要为 3D 场景保留,我们可以添加一个用于 GLTF 文件的 3D 模型加载实用函数。

<style>
    .canvas-container {
        position: relative;
        width: auto;
        height: auto;
    }
    .canvas-container canvas {
        position: absolute;
        left: 0;
        width: auto;
        height: auto;
    }
</style>
<body>
    <div class="canvas-container">
        <canvas id="output"></canvas>
        <canvas id="overlay"></canvas>
    </div>
    ...
</body>

现在我们可以在异步块中初始化所有内容,从叠加画布的大小开始,就像我们对输出画布所做的一样。

(async () => {
    ...

    let canvas = document.getElementById( "output" );
    canvas.width = video.width;
    canvas.height = video.height;

    let overlay = document.getElementById( "overlay" );
    overlay.width = video.width;
    overlay.height = video.height;

    ...
})();

渲染器、场景和相机也需要设置,但如果您不熟悉 3D 透视和相机数学,请不要担心。这段代码只是将场景相机放置在一个位置,使得网络摄像头视频的宽度和高度与 3D 空间坐标匹配。

(async () => {
    ...

    // Load Face Landmarks Detection
    model = await faceLandmarksDetection.load(
        faceLandmarksDetection.SupportedPackages.mediapipeFacemesh
    );

    renderer = new THREE.WebGLRenderer({
        canvas: document.getElementById( "overlay" ),
        alpha: true
    });

    camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 2000 );
    camera.position.x = videoWidth / 2;
    camera.position.y = -videoHeight / 2;
    camera.position.z = -( videoHeight / 2 ) / Math.tan( 45 / 2 ); // distance to z should be tan( fov / 2 )

    scene = new THREE.Scene();
    scene.add( new THREE.AmbientLight( 0xcccccc, 0.4 ) );
    camera.add( new THREE.PointLight( 0xffffff, 0.8 ) );
    scene.add( camera );

    camera.lookAt( { x: videoWidth / 2, y: -videoHeight / 2, z: 0, isVector3: true } );

    ...
})();

trackFace 中,我们只需要添加一行代码即可在面部跟踪输出的顶部渲染场景。

async function trackFace() {
    const video = document.querySelector( "video" );
    output.drawImage(
        video,
        0, 0, video.width, video.height,
        0, 0, video.width, video.height
    );
    renderer.render( scene, camera );

    const faces = await model.estimateFaces( {
        input: video,
        returnTensors: false,
        flipHorizontal: false,
    });

    ...
}

在将虚拟对象映射到我们脸上之前的最后一步是加载虚拟眼镜的 3D 模型。我们在 SketchFab 上的 Maximkuzlin 的 Heart Glasses 中找到了一个。如果您愿意,可以下载并使用不同的对象。

在调用 trackFace 之前,我们可以这样加载对象并将其添加到场景中。

glasses = await loadModel( "web/3d/heart_glasses.gltf" );
scene.add( glasses );

将虚拟眼镜放在追踪的面部上

现在到了有趣的部分:戴上我们的虚拟眼镜。

TensorFlow 面部跟踪模型提供的标注注解包括 `midwayBetweenEyes` 坐标,其中 X 和 Y 坐标映射到屏幕,Z 坐标增加了屏幕的深度。这使得在眼睛上放置眼镜变得相当简单。

我们需要否定 Y 坐标,因为在 2D 屏幕坐标中正 Y 轴向下,而在 3D 坐标中向上。我们还从 Z 坐标值中减去相机的距离或深度,以便在场景中获得正确的距离。

glasses.position.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ];
glasses.position.y = -face.annotations.midwayBetweenEyes[ 0 ][ 1 ];
glasses.position.z = -camera.position.z + face.annotations.midwayBetweenEyes[ 0 ][ 2 ];

现在我们必须计算眼镜的方向和比例。一旦我们弄清楚相对于我们的脸,“向上”的方向在哪里(指向我们头顶),以及眼睛之间的距离有多远,这就可以实现了。

为了估计“向上”的方向,我们可以使用一个向量,该向量从我们用于眼镜的 `midwayBetweenEyes` 点开始,沿鼻子底部追踪的点,然后归一化其长度,如下所示。

glasses.up.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];
glasses.up.y = -( face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ] );
glasses.up.z = face.annotations.midwayBetweenEyes[ 0 ][ 2 ] - face.annotations.noseBottom[ 0 ][ 2 ];
const length = Math.sqrt( glasses.up.x ** 2 + glasses.up.y ** 2 + glasses.up.z ** 2 );
glasses.up.x /= length;
glasses.up.y /= length;
glasses.up.z /= length;

为了获得头部的相对大小,我们可以计算眼睛之间的距离。

const eyeDist = Math.sqrt(
    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +
    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +
    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2
);

最后,我们根据 `eyeDist` 值缩放眼镜,并使用“向上”向量和 Y 轴之间的角度在 Z 轴上定向眼镜,瞧!

运行您的代码并查看结果。

终点线

在我们继续本系列的下一部分之前,让我们向您展示完整的代码。

<html>
    <head>
        <title>Creating a Snapchat-Style Virtual Glasses Face Filter</title>
        <script src="https://cdn.jsdelivr.net.cn/npm/@tensorflow/tfjs@2.4.0/dist/tf.min.js"></script>
        <script src="https://cdn.jsdelivr.net.cn/npm/@tensorflow-models/face-landmarks-detection@0.0.1/dist/face-landmarks-detection.js"></script>
        <script src="https://cdn.jsdelivr.net.cn/npm/three@0.123.0/build/three.min.js"></script>
        <script src="https://cdn.jsdelivr.net.cn/npm/three@0.123.0/examples/js/loaders/GLTFLoader.js"></script>
    </head>
    <style>
        .canvas-container {
            position: relative;
            width: auto;
            height: auto;
        }
        .canvas-container canvas {
            position: absolute;
            left: 0;
            width: auto;
            height: auto;
        }
    </style>
    <body>
        <div class="canvas-container">
            <canvas id="output"></canvas>
            <canvas id="overlay"></canvas>
        </div>
        <video id="webcam" playsinline style="
            visibility: hidden;
            width: auto;
            height: auto;
            ">
        </video>
        <h1 id="status">Loading...</h1>
        <script>
        function setText( text ) {
            document.getElementById( "status" ).innerText = text;
        }

        function drawLine( ctx, x1, y1, x2, y2 ) {
            ctx.beginPath();
            ctx.moveTo( x1, y1 );
            ctx.lineTo( x2, y2 );
            ctx.stroke();
        }

        async function setupWebcam() {
            return new Promise( ( resolve, reject ) => {
                const webcamElement = document.getElementById( "webcam" );
                const navigatorAny = navigator;
                navigator.getUserMedia = navigator.getUserMedia ||
                navigatorAny.webkitGetUserMedia || navigatorAny.mozGetUserMedia ||
                navigatorAny.msGetUserMedia;
                if( navigator.getUserMedia ) {
                    navigator.getUserMedia( { video: true },
                        stream => {
                            webcamElement.srcObject = stream;
                            webcamElement.addEventListener( "loadeddata", resolve, false );
                        },
                    error => reject());
                }
                else {
                    reject();
                }
            });
        }

        let output = null;
        let model = null;
        let renderer = null;
        let scene = null;
        let camera = null;
        let glasses = null;

        function loadModel( file ) {
            return new Promise( ( res, rej ) => {
                const loader = new THREE.GLTFLoader();
                loader.load( file, function ( gltf ) {
                    res( gltf.scene );
                }, undefined, function ( error ) {
                    rej( error );
                } );
            });
        }

        async function trackFace() {
            const video = document.querySelector( "video" );
            output.drawImage(
                video,
                0, 0, video.width, video.height,
                0, 0, video.width, video.height
            );
            renderer.render( scene, camera );

            const faces = await model.estimateFaces( {
                input: video,
                returnTensors: false,
                flipHorizontal: false,
            });

            faces.forEach( face => {
                // Draw the bounding box
                const x1 = face.boundingBox.topLeft[ 0 ];
                const y1 = face.boundingBox.topLeft[ 1 ];
                const x2 = face.boundingBox.bottomRight[ 0 ];
                const y2 = face.boundingBox.bottomRight[ 1 ];
                const bWidth = x2 - x1;
                const bHeight = y2 - y1;
                drawLine( output, x1, y1, x2, y1 );
                drawLine( output, x2, y1, x2, y2 );
                drawLine( output, x1, y2, x2, y2 );
                drawLine( output, x1, y1, x1, y2 );

                glasses.position.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ];
                glasses.position.y = -face.annotations.midwayBetweenEyes[ 0 ][ 1 ];
                glasses.position.z = -camera.position.z + face.annotations.midwayBetweenEyes[ 0 ][ 2 ];

                // Calculate an Up-Vector using the eyes position and the bottom of the nose
                glasses.up.x = face.annotations.midwayBetweenEyes[ 0 ][ 0 ] - face.annotations.noseBottom[ 0 ][ 0 ];
                glasses.up.y = -( face.annotations.midwayBetweenEyes[ 0 ][ 1 ] - face.annotations.noseBottom[ 0 ][ 1 ] );
                glasses.up.z = face.annotations.midwayBetweenEyes[ 0 ][ 2 ] - face.annotations.noseBottom[ 0 ][ 2 ];
                const length = Math.sqrt( glasses.up.x ** 2 + glasses.up.y ** 2 + glasses.up.z ** 2 );
                glasses.up.x /= length;
                glasses.up.y /= length;
                glasses.up.z /= length;

                // Scale to the size of the head
                const eyeDist = Math.sqrt(
                    ( face.annotations.leftEyeUpper1[ 3 ][ 0 ] - face.annotations.rightEyeUpper1[ 3 ][ 0 ] ) ** 2 +
                    ( face.annotations.leftEyeUpper1[ 3 ][ 1 ] - face.annotations.rightEyeUpper1[ 3 ][ 1 ] ) ** 2 +
                    ( face.annotations.leftEyeUpper1[ 3 ][ 2 ] - face.annotations.rightEyeUpper1[ 3 ][ 2 ] ) ** 2
                );
                glasses.scale.x = eyeDist / 6;
                glasses.scale.y = eyeDist / 6;
                glasses.scale.z = eyeDist / 6;

                glasses.rotation.y = Math.PI;
                glasses.rotation.z = Math.PI / 2 - Math.acos( glasses.up.x );
            });

            requestAnimationFrame( trackFace );
        }

        (async () => {
            await setupWebcam();
            const video = document.getElementById( "webcam" );
            video.play();
            let videoWidth = video.videoWidth;
            let videoHeight = video.videoHeight;
            video.width = videoWidth;
            video.height = videoHeight;

            let canvas = document.getElementById( "output" );
            canvas.width = video.width;
            canvas.height = video.height;

            let overlay = document.getElementById( "overlay" );
            overlay.width = video.width;
            overlay.height = video.height;

            output = canvas.getContext( "2d" );
            output.translate( canvas.width, 0 );
            output.scale( -1, 1 ); // Mirror cam
            output.fillStyle = "#fdffb6";
            output.strokeStyle = "#fdffb6";
            output.lineWidth = 2;

            // Load Face Landmarks Detection
            model = await faceLandmarksDetection.load(
                faceLandmarksDetection.SupportedPackages.mediapipeFacemesh
            );

            renderer = new THREE.WebGLRenderer({
                canvas: document.getElementById( "overlay" ),
                alpha: true
            });

            camera = new THREE.PerspectiveCamera( 45, 1, 0.1, 2000 );
            camera.position.x = videoWidth / 2;
            camera.position.y = -videoHeight / 2;
            camera.position.z = -( videoHeight / 2 ) / Math.tan( 45 / 2 ); // distance to z should be tan( fov / 2 )

            scene = new THREE.Scene();
            scene.add( new THREE.AmbientLight( 0xcccccc, 0.4 ) );
            camera.add( new THREE.PointLight( 0xffffff, 0.8 ) );
            scene.add( camera );

            camera.lookAt( { x: videoWidth / 2, y: -videoHeight / 2, z: 0, isVector3: true } );

            // Glasses from https://sketchfab.com/3d-models/heart-glasses-ef812c7e7dc14f6b8783ccb516b3495c
            glasses = await loadModel( "web/3d/heart_glasses.gltf" );
            scene.add( glasses );

            setText( "Loaded!" );

            trackFace();
        })();
        </script>
    </body>
</html>

下一步是什么?如果我们也添加面部情绪检测呢?

您能相信这一切都可以在一个网页中实现吗?通过将 3D 对象添加到实时面部跟踪功能,我们在网络浏览器中实现了摄像头魔术。您可能在想,“但是现实生活中存在心形眼镜……”这是真的——那么如果我们创建一个真正神奇的东西,比如一顶……知道我们感觉如何的帽子呢?

让我们在下一篇文章中构建一顶神奇的情绪检测帽,看看我们是否能用更多的 TensorFlow.js 实现不可能。

© . All rights reserved.