如何使用FaceAPI、React Hooks和TypeScript向你的Twilio视频应用程序添加过滤器(详细教程)

197 阅读12分钟

本博客最初Héctor Zelaya在此发表

你有没有尝试过在社交媒体应用程序中给自己的脸部加一个滤镜?也许你在自拍或视频聊天中加入了一顶有趣的帽子,一副很酷的眼镜,或者一对猫耳朵。

如果你使用过这些过滤器,你可能想知道该技术是如何工作的。这些应用程序利用人脸检测软件来检测你的照片或视频输入中的人脸,并将图像放在人脸的特定部分上。

本教程将告诉你如何使用人脸检测来为你的视频会议应用程序添加过滤器。这个视频会议应用程序是用TypeScript编写的,并使用Twilio可编程视频ReactReact HooksFaceAPI

人脸识别技术一般用于确定(检测)图像或视频中是否存在人脸,评估(分析)人脸的细节,并试图验证身份(认证/验证)。

在本教程中,我们将只使用检测功能,但在使用面部识别技术时,一定要注意道德和隐私问题。

如果你要发布一个使用面部识别软件的应用程序,一定要包括一个询问用户是否同意的功能,并让他们决定是否允许使用面部识别软件。

关于面部识别和其他形式的人工智能的道德使用的更多信息,请参见以下链接

先决条件

运行本教程,你将需要以下东西

  • 一个Twilio账户(如果你使用这个链接创建了一个账户,当你升级账户时,你将获得10美元的积分)。
  • NPM 6
  • Node.js 14
  • 指南针
  • 终端仿真器应用
  • 可选的代码编辑器

设置你的项目

对于这个教程,你不需要自己建造所有的元素。一个基本的视频聊天库已经有了,所以第一步是复制GitHub上的这个库。启动一个终端窗口,导航到你想保存项目的位置,并使用以下步骤复制它

cd your/favorite/path
git clone https://github.com/agilityfeat/twilio-filters-tutorial.git && cd twilio-filters-tutorial

这个资源库由两个主要的文件夹组成:final_和_start。_final_文件夹包含应用程序的完成版本,所以你可以立即看到应用程序的运行。最后一个文件夹包含应用程序的完成版,因此你可以立即看到它的运行情况,而_开始_文件夹是为那些想一步步建立它的人准备的。它只包含视频会议功能,不包含任何过滤或人脸检测代码。

该应用程序的整体结构是基于这里的另一篇文章中描述的结构。该应用程序是用TypeScript编写的,并利用React功能组件的React钩子。

首先,我们需要设置我们的Twilio凭证。在同一文件夹中复制_start/.env.example_文件,并将其重命名为_.env_。打开任何代码编辑器,分别为TWILIO_ACCOUNT_SID,TWILIO_API_KEYTWILIO_API_SECRET输入数值。

你的账户SID可以在Twilio控制台找到,API****密钥/私钥对可以在控制台的API密钥部分生成。

下一步是安装所需的依赖项。回到你的终端窗口,从你项目的根文件夹中运行以下命令。

# install dependencies
cd start
npm install

# then run the application
npm start

如果你想立即看到你正在工作的内容,你可以在_最后的_文件夹中进行前面的步骤。

FaceAPI基础知识

本教程的特点是不仅要添加滤镜,而且要实现人脸检测。这使得基于人脸应用过滤器成为可能,就像社交媒体应用程序功能经常发生的那样。

这是由一个名为FaceAPI的人脸识别工具实现的。

FaceAPITensorFlow上运行。它被部署在浏览器和Node.js上,目的是提供使用AI检测、描述和识别人脸的能力。

安装FaceAPI

face-api,可以使用npm安装,启动第二个终端窗口,导航到你的复制仓库的根文件夹。安装依赖项的方法如下

cd path/to/project/twilio-filters-tutorial
npm install @vladmandic/face-api@1.1.5

如何使用人脸检测功能

现在你已经安装了FaceAPI,你可能想开始使用它。在这之前,请查看下面的代码以了解如何在你的项目中使用FaceAPI。在本教程中,我们将只使用检测输入源中人脸位置的能力。

为了实现人脸检测功能,我们首先使用faceapi.nets,加载所需的模型。下面这行代码就可以了。

await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');

然后,我们使用faceapi.detectAllFaces()函数来检测输入源中的所有面孔,如图像或HTML元素(视频)。

这就产生了一个单一的对象,我们从中得到了诸如X坐标、Y坐标和整个面部区域的宽度等属性。

const results = await faceapi.detectAllFaces(localVideoRef.current);

这些信息和HTML<canvas>元素,以及window.requestAnimationFrame`函数,我们可以画出自定义的媒体元素来配合你的脸。在社交媒体应用程序中经常看到的过滤器正是使用这种机制。

加载FaceAPI模型

现在你知道了使用FaceAPI进行人脸检测的基本知识,你可以继续设置你的应用程序了。

包含FaceAPI模型的文件夹已经被添加到_开始_文件夹的_公共_文件夹中。请参考以下内容来更新_start/src/App.tsx_文件。

// start/src/App.tsx
...
import { connect, Room as RoomType } from 'twilio-video';
import * as faceapi from '@vladmandic/face-api';
...
function App() {
  ...
  return (
    ...
          <button
            ...
            onClick={async () => {
                ...
                const room = await connect(data.accessToken, {
                  name: 'cool-room',
                  audio: true,
                  video: { width: 640, height: 480 }
                });

                await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');
                setRoom(room);

                ...
  )
}
...

有了这段代码,模型将在应用程序启动时被加载。从现在开始,让我们把重点放在操作流所需的代码上。

使用HTML画布元素操纵流

我们现在已经能够识别输入流中的所有面孔。我们现在将使用<canvas>元素,以编程方式描述我们想要在面孔上面显示的项目。由于这是一个用React开发的应用程序,并且使用了功能组件,我们需要考虑如何在渲染后对DOM进行操作。

对于这些任务,你可以使用钩子,具体而言,useEffect钩子。这很可靠,可以用来代替类组件的旧的生命周期方法,componentDidMount

我们还需要找出一种方法来保持DOM信息,以便我们能够以编程方式操作画布元素并调用window.requestAnimationFrame。这超出了标准React渲染的范围,所以再次使用钩子是有意义的。在这种情况下,使用useRef钩子是最佳选择。

现在让我们打开_start/src/Track.tsx_文件,添加一些引用。由于Track组件用于音频和视频轨道,我们将为两者添加HTML元素。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  let divRef = useRef<HTMLDivElement>(null);
  // adding additional refs
  let canvasRef = useRef<HTMLCanvasElement>(null);
  let localAudioRef = useRef<HTMLAudioElement | null>(null);
  let localVideoRef = useRef<HTMLVideoElement | null>(null);
  let requestRef = useRef<number>();

  useEffect(() => {
    // refactoring a bit
    if (props.track) {
      divRef.current?.classList.add(props.track.kind);
      switch (props.track.kind) {
        case 'audio':
          localAudioRef.current = props.track.attach();
          break;
        case 'video':
          localVideoRef.current = props.track.attach();
          break;
      }
    }
  }, []);

  return (
    <div className="track" ref={divRef}>
      {props.track.kind === 'audio' &&
        <audio autoPlay={true} ref={localAudioRef} />
      }
      {props.track.kind === 'video' &&
        <>
          <video autoPlay={true} ref={localVideoRef} />
          <canvas width="640" height="480" ref={canvasRef} />
        </>
      }
    </div>
  );

现在我们可以添加所有的FaceAPI和画布元素。首先,导入face-api库。 在现有的useEffect钩子上增加一个叫drawFilter的内部函数。

// start/src/Track.tsx

import * as faceapi from '@vladmandic/face-api';

function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    function drawFilter() {
      let ctx = canvasRef.current?.getContext('2d');
      let image = new Image();
      image.src = 'sunglasses.png';

      async function step() {
        const results = await faceapi.detectAllFaces(localVideoRef.current);
        ctx?.drawImage(localVideoRef.current!, 0, 0);
        // eslint-disable-next-line array-callback-return
        results.map((result) => {
          ctx?.drawImage(
            image,
            result.box.x + 15,
            result.box.y + 30,
            result.box.width,
            result.box.width * (image.height / image.width)
          );
        });
        requestRef.current = requestAnimationFrame(step);
      }

      requestRef.current = requestAnimationFrame(step);
    }

   ...
  }, [])
  ... 
}
...

然后我们设置drawFilter,作为我们开始播放视频元素时的一个监听器。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      ...
      case 'video':
          localVideoRef.current = props.track.attach();
          localVideoRef.current?.addEventListener('playing', drawFilter);
          break;
       ...
    }
  }
}
...

由于我们在window.requestAnimationFrame之外还增加了一个监听器,我们需要把事情整理一下,防止内存泄漏。

如果你使用React Functional Component,你不能像使用Class Component那样使用componentWillUnmount生命周期方法。

钩子在这里也很有用。 useEffect 钩子返回一个函数,可以代替componentWillUnmount方法来更新Track组件,如下所示。

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      divRef.current?.classList.add(props.track.kind);
      switch (props.track.kind) {
        case 'audio':
          localAudioRef.current = props.track.attach();
          break;
        case 'video':
          localVideoRef.current = props.track.attach();
          localVideoRef.current?.addEventListener('playing', drawFilter);
          break;
      }
    }

    return () => {
      if (props.track && props.track.kind === 'video') {
        localVideoRef.current?.removeEventListener('playing', drawFilter);
        cancelAnimationFrame(requestRef.current!);
      }
    }
  }, []);

  ...
}

现在让我们看看该应用程序的运行情况。 通过运行npm start,启动应用程序,等待浏览器加载,并在输入栏出现时输入你的名字。[点击**'加入房间**'按钮,进入视频房间。几秒钟后,你应该看到以下屏幕

video with filter on face

不错的效果!

选择一个过滤器

在这个阶段,你可以在本地应用一个名为Sunglasses的硬编码过滤器到你的Twilio视频轨道。然而,过滤器之所以如此受欢迎,是因为有很多选择,用户可以使用他们喜欢的任何过滤器。在本教程中,我们不会通过步骤来增加大量的选择,但我们将允许应用程序的用户在两个不同的过滤器之间进行选择。为了简化我们的工作,我们将使用另一个图像创建一个新的过滤器,用于与之前相同类型的过滤器。

在_start/src_下创建一个新文件,命名为_FilterMenu.tsx。_在该文件中加入以下代码。

// start/src/FilterMenu.tsx
import React from 'react';

function FilterMenu(props: { changeFilter: (filter: string) => void }) {
  const filters = ['Sunglasses', 'CoolerSunglasses'];

  return (
    <div className="filterMenu">
      {
        filters.map(filter => 
          <div className={`icon icon-${filter}`} 
            onClick={() => props.changeFilter(filter)}>
              {filter}
          </div>  
        )
      }
    </div>
  );
}

export default FilterMenu;

这里我们定义了两个过滤器,SunglassesCoolerSunglasses。我们将这些过滤器渲染成一个列表,启动changeFilter处理程序,并将其作为一个属性传递给组件。

将新创建的过滤器添加到_start/src/Participant.tsx_文件。将组件的状态设置为你选择的过滤器。这将确保如果用户选择了一个不同的过滤器,用户界面将被再次渲染以反映这一变化。

// start/src/Participant.tsx
...
import FilterMenu from './FilterMenu';

function Participant(props: { localParticipant: boolean, participant: LocalParticipant | RemoteParticipant }) {
  ...
  const [tracks, setTracks] = useState(nonNullTracks);
  const [filter, setFilter] = useState('Sunglasses');
  ...
  return (
    <div className="participant" id={props.participant.identity}>
      <div className="identity">{props.participant.identity}</div>
      {
        props.localParticipant
        ? <FilterMenu changeFilter={(filter) => {
            setFilter(filter);
          }} />
        : ''
      }

      {
        tracks.map((track) =>
          <Track key={track!.name} track={(track as VideoTrack | AudioTrack)} filter={filter} />)
      }
    </div>
  )
}

filter属性已被添加到Track组件中。由于我们将发送额外的参数,我们将在Track中更新财产属性类型,如下所示

// start/src/Track.tsx
...
function Track(props: { track: AudioTrack | VideoTrack, filter: string }) {

然后将Track组件中的一行替换为以下内容

// replace this
image.src = 'sunglasses.png';

// with this
image.src = props.filter === 'Sunglasses' ? 'sunglasses.png' : 'sunglasses-style.png';

你就快成功了--你现在可以在两个过滤器之间进行切换了!现在只剩下一件事要做。默认情况下,useEffect,每次渲染时都会运行,但有些时候这是不可取的。为了防止这种情况,除了匿名函数之外,你还可以将一个空数组作为第二个参数传给useEffect。这将确保useEffect块中的代码只被执行一次。

你也可以使用这个数组来跳过执行useEffect钩子,除非某些属性已经被改变。由于我们在这里改变了一个过滤器,我们需要重新运行钩子,并在发生变化时更新Track

要做到这一点,将props.filter的值添加到一个空数组中,如下所示。

// change this
}, []);

// to this
}, [props.filter]);

让我们回到浏览器,查看一下视频应用。点击过滤器名称并切换过滤器。现在看起来酷多了!

sunglass - hector

到目前为止,我们所做的一切都发生在当地。因此,我们需要一种方法,让其他参与者知道用户选择了什么过滤器,以便在每一端都能应用它。为此,你可以使用Twilio DataTrack API。它允许你发送任意数据,如过滤信息,给其他参与者。

发送过滤信息

要发送过滤信息,你首先需要设置一个DataTrack通道。创建一个新的LocalDataTrack实例,并使用publishTrack()方法将其发布到房间。

打开_start/src/App.tsx_文件,输入以下代码

...
import { connect, Room as RoomType, LocalDataTrack } from 'twilio-video';
...
function App() {
  ...
         const room = await connect(data.accessToken, {
           name: 'cool-room',
           audio: true,
           video: { width: 640, height: 480 }
         });

         const localDataTrack = new LocalDataTrack();
         await room.localParticipant.publishTrack(localDataTrack);
         await faceapi.nets.ssdMobilenetv1.loadFromUri('/model');
}

确保所有用户已将数据轨道添加到他们的本地轨道列表中。你需要使用一个数据跟踪,并在每次过滤器信息发生变化时发送该信息。它还将接收视频通话中所有参与者的过滤信息,并根据需要更新。

这种行为都发生在文件_start/src/Participant.tsx_中。打开这个文件并输入以下代码

// start/src/Participant.tsx
...
import { LocalParticipant, RemoteParticipant, LocalTrackPublication, RemoteTrackPublication, VideoTrack, AudioTrack, LocalDataTrack, DataTrack } from 'twilio-video';
...
function Participant(props: { localParticipant: boolean, participant: LocalParticipant | RemoteParticipant }) {
  ...
  useEffect(() => {
    if (!props.localParticipant) {
      ...
      // here the user adds the data track to the list of local tracks
      props.participant.on('trackPublished', track => {
        setTracks(prevState => ([...prevState, track]));
      });
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    ...
      {
        props.localParticipant
        ? <FilterMenu changeFilter={(filter) => {
            // when the user changes the filter, notify all other users
            // retrieve the dataTrack from the list of tracks
            const dataTrack = tracks.find(track => track!.kind === 'data') as LocalDataTrack;
            // send filter information
            dataTrack!.send(filter);
            setFilter(filter);
          }} />
        : ''
      }

      {
        tracks.map((track) =>
          <Track key={track!.name} track={(track as VideoTrack | AudioTrack | DataTrack)} filter={filter} setFilter={setFilter}/>)
      }
    ...
  )
}

你有一个新的属性,被发送到Track组件。这就是突变函数setFilter。你现在可以通过DataTrack发送过滤信息。然后我们可以监听消息,并根据需要在视频通话的每一端更新过滤器。使用以下代码来更新_start/src/Track.tsx_文件。

// start/src/Track.tsx
...
import { AudioTrack, VideoTrack, DataTrack } from 'twilio-video';
...
function Track(props: { track: AudioTrack | VideoTrack | DataTrack, filter: string, setFilter: (filter: string) => void }) {
  ...
  useEffect(() => {
    ...
    if (props.track) {
      ...
      switch (props.track.kind) {
        ...
        case 'data':
          // when receiving a message, update the filter
          props.track.on('message', props.setFilter);
          break;
      }
    }
    ...
  }, [props.filter]);
   
}
...

你现在已经完成了。你可以运行该应用程序并应用过滤器来定制外观和感觉。

filtering - comple

_隐藏的备忘录参考_你们中的一些人可能已经注意到一个巧合的是,丹尼尔是我的中间名!这是我的名字。

摘要

由于FaceAPI和TensorFlow等强大的工具,现在很容易将人脸检测添加到你的网络应用中。当与HTML Canvas、React和React钩子等最好的网络构建块结合使用时,你可以开发配备最新功能的复杂应用程序。所有这些都要感谢Twilio可编程视频和DataTrack API。

你可以在我们的Github资源库中找到完整版本的代码。如果你喜欢,你也可以在Twitter上关注我。

赫克托是一名来自萨尔瓦多的计算机系统工程师。当他不在电脑前的时候,他喜欢玩音乐、打电子游戏和与他所爱的人在一起。