使用 TensorFlow.js 构建 Emoji 寻宝游戏
2018 年 10 月 10 日
由来自 Google 品牌工作室的 Jacques Bruwer、JK Kafalas 和 Shuhei Iitsuka 发布

在这篇文章中,我们将讨论实验性游戏 Emoji 寻宝 的内部机制。我们将向您展示我们如何使用 TensorFlow 训练用于对象识别的自定义模型,以及我们如何使用 TensorFlow.js 在 Web 前端使用该模型。我们还将讨论在使用浏览器 API 进行相机访问和文本转语音时遇到的一些挑战和解决方法。该游戏的全部代码是开源的,可以在 Github 上获得。

Emoji 寻宝游戏简介

Emoji scavenger hunt
Emoji 寻宝是一款有趣的实验性游戏,游戏中会显示一个 Emoji,您需要在规定的时间内找到现实世界中的对应物体,并将手机摄像头对准它,以延长您的时间。随着您在现实世界中玩游戏并找到 Emoji,之后显示的 Emoji 的难度会增加。从您可能手边拥有的物体开始,比如鞋子、书或您自己的手 :),然后逐渐过渡到香蕉、蜡烛甚至滑板车等物体。

我们的目标是在一个有趣且互动的方式中展示机器学习技术的应用。

训练用于对象识别的模型

Emoji 寻宝游戏的核心功能是识别您的摄像头所看到的物体,并将它与游戏中要求您找到的物体(Emoji)进行匹配。但是,摄像头如何知道它所看到的是什么?我们需要一个可以帮助识别物体的模型。最初,我们开始使用名为 MobileNet 的预训练模型。该模型轻量级,并针对移动设备进行了优化,但其中的物体过于具体,不适合我们的游戏。例如,识别了“金毛猎犬”等犬种,但没有针对“犬”的通用物体类别。我们意识到,训练我们自己的自定义图像识别模型才是正确的做法。

这就是迁移学习可以派上用场的地方。迁移学习是一种技术,通过为另一个目标任务操纵机器学习模型,重新使用针对特定任务训练的机器学习模型。我们通过使用与 Tensorflow 重新训练教程 中描述的类似过程,基于 MobileNet 重新训练模型,构建了自己的自定义模型。我们添加了一个全连接层,它将默认的输出 logits 映射到我们想要的 Emoji 物体,例如“手”和“键盘”等。我们列出了大约 400 个物体以供识别,并为每个物体收集了 100-1000 张图像作为训练数据。添加的全连接层通过结合来自 MobileNet 输出层的 1000 个信号来推断这 400 个物体。
我们的图像识别模型的架构。我们训练了添加到预训练 MobileNet 的一个全连接层。
训练脚本在 TensorFlow Github 代码库 中提供。我们已将训练过程编译为 Dockerfile,以便您可以通过指向自己的图像数据集来训练自己的模型。

我们运行了脚本,将训练图像数据馈送到模型中。为了简化我们的训练过程,我们在 Google Cloud Platform 上构建了整个管道。所有训练数据都存储在 Cloud Storage 上的一个存储桶中,并且在检测到存储桶中的任何更改时,就会启动 Compute Engine 上的一个 GPU 实例,方法是在 Cloud Functions 中设置 Cloud Storage 触发器。GPU 实例以 TensorFlow SavedModel 格式输出结果重新训练的模型,并将其保存到 Cloud Storage 上的另一个存储桶中。
模型训练的数据管道

我们如何与 TensorFlow.js 集成

完成上述模型训练部分中的步骤后,我们最终得到了用于对象识别的 TensorFlow SavedModel。为了通过 TensorFlow.js 在浏览器中访问和使用该模型,我们使用了 TensorFlow.js 转换器 将此 SavedModel 转换为 TensorFlow.js 可以加载的格式。

识别物体的行为可以分为两个子任务。首先,从相机获取像素,其次,将该图像数据发送到 TensorFlow.js,以根据我们之前训练的模型预测它认为可能是的东西。

相机和模型设置

在我们可以开始预测物体之前,我们需要确保相机(通过 MediaDevices.getUserMedia)已准备好显示内容,以及我们的机器学习模型已加载并已准备好开始预测。我们使用以下代码段来启动两者,并在我们可以开始预测之前执行一些任务设置。
Promise.all([
  this.emojiScavengerMobileNet.load().then(() => this.warmUpModel()),
  camera.setupCamera().then((value: CameraDimentions) => {
    camera.setupVideoDimensions(value[0], value[1]);
  }),
]).then(values => {
  // Both the camera and model are loaded, we can start predicting
  this.predict();
}).catch(error => {
  // Some errors occurred and we need to handle them
});
相机设置和模型加载都将在成功完成时通过 Promise 解决。您会注意到,模型加载完毕后,我们调用 this.warmUpModel()。此函数只是通过传入零来执行预测调用,以便编译程序并将权重上传到 GPU,以便在我们需要传入实际数据以进行预测时,模型将已准备好。这有助于使初始预测感觉快速。

将图像数据发送到 TensorFlow.js

以下代码段(已删除注释)是我们的预测函数调用,它从相机获取数据,将其解析为正确的图像大小,将其发送到基于 TensorFlow.js 的 MobileNet,并使用生成的识别物体来查看我们是否找到了我们的 Emoji。
async predict() {
  if (this.isRunning) {
    const result = tfc.tidy(() => {

      const pixels = tfc.fromPixels(camera.videoElement);
      const centerHeight = pixels.shape[0] / 2;
      const beginHeight = centerHeight - (VIDEO_PIXELS / 2);
      const centerWidth = pixels.shape[1] / 2;
      const beginWidth = centerWidth - (VIDEO_PIXELS / 2);
      const pixelsCropped =
            pixels.slice([beginHeight, beginWidth, 0],
                         [VIDEO_PIXELS, VIDEO_PIXELS, 3]);

      return this.emojiScavengerMobileNet.predict(pixelsCropped);
    });

    const topK =
        await this.emojiScavengerMobileNet.getTopKClasses(result, 10);

    this.checkEmojiMatch(topK[0].label, topK[1].label);
  }
  requestAnimationFrame(() => this.predict());
}
让我们更详细地了解一下这个代码段。我们将整个预测代码逻辑包装在 requestAnimationFrame 调用中,以确保浏览器在执行屏幕绘制更新时以最有效的方式执行此逻辑。我们只在游戏处于运行状态时执行预测逻辑。这样,我们可以确保在执行屏幕动画(如结束和获胜屏幕)时,我们不会运行任何 GPU 密集型预测代码。

另一个微小但意义重大的性能改进是将我们的 TensorFlow.js 逻辑包装在对 tf.tidy() 的调用中。这将确保在该逻辑执行期间创建的所有 TensorFlow.js 张量将在之后被清理,确保更好的长期运行性能。请参阅 https://js.tensorflow.org/api/latest/#tidy

我们的预测逻辑的核心与从相机提取图像以发送到 TensorFlow.js 相关。我们不是简单地获取整个相机图像并将其发送,而是从相机中心的屏幕上切出一部分并将其发送到 TensorFlow.js。在我们的游戏中,我们使用的是大小为 224 像素 x 224 像素的参考图像训练了我们的模型。将与我们的参考训练数据尺寸相同的图像发送到 TensorFlow.js 将确保更好的预测性能。我们的相机元素(只是一个 HTML 视频元素)不是 224 像素的原因是,我们希望确保为用户提供全屏体验,这意味着使用 CSS 使相机元素扩展到屏幕的 100%。

以下参考图像显示了将发送到 TensorFlow.js 的左上角的切片。这在实时版本中没有实现为可见,只是在此处作为参考进行显示。
然后,模型使用该图像数据生成最可能的 10 个项目的列表。您会注意到我们获取了前 2 个值并将其传递给 checkEmojiMatch 以确定我们是否找到了匹配项。我们选择使用前 2 个匹配项而不是最上面的项目,仅仅是因为这使游戏更有趣,并允许我们根据我们的模型在匹配方面具有一定的灵活性。拥有过于准确和严格的模型会导致用户在物体无法识别时感到沮丧。
在上面的图像示例中,您可以看到我们当前的任务是找到一个“键盘”Emoji。在这个示例中,我们还显示了一些调试信息,这样您就可以看到模型根据输入图像预测的所有 10 个可能的项目。“键盘”和“手”是前两个匹配项,它们都在图像中,而“手”的可能性略大。即使“键盘”是第二个检测到的位置,游戏也检测到匹配,因为我们使用前两个匹配项进行检查。

使用文本转语音为我们的模型赋予声音

作为游戏的有趣补充,我们实现了 SpeechSynthesis API,以便在您四处寻找 Emoji 时,大声朗读模型“认为”它正在看到的每件事。在 Android 上的 Chrome 中,这最终通过以下代码变得非常容易实现
speak(msg: string) {
  if (this.topItemGuess) {
    if ('speechSynthesis' in window) {
      let msgSpeak = new SpeechSynthesisUtterance();
      msgSpeak.voice = this.sleuthVoice['activeVoice'];

      msgSpeak.text = msg;
      speechSynthesis.speak(msgSpeak);
    }
  }
}
这个 API 在 Android 上能够很好地实时运行,但 iOS 将所有 SpeechSynthesis 调用限制为与用户操作(例如点击事件)直接相关的调用,因此我们需要为该平台寻找替代解决方案。我们已经熟悉 iOS 的要求,即将音频播放事件绑定到用户操作,我们通过在用户最初点击“播放”按钮时开始播放游戏中的其他声音,然后立即暂停所有这些音频文件来处理这些声音。最终,我们制作了一个音频精灵,其中包含所有“成功”语音行(例如,“嘿,你找到了一瓶啤酒”)。这种方法的缺点是,随着需要包含的对话增多,这个音频精灵文件会变得非常大。

我们尝试过的一种方法是将音频精灵进一步拆分成前缀(“嘿,你找到了一,”“那是什么”)和后缀(“啤酒,”“香蕉”等),但我们发现 iOS 在播放一个音频文件的片段,暂停,移动播放头,然后播放同一文件的另一个片段之间,会添加一个不可避免的 1 秒延迟。前缀和后缀之间的间隔非常长,听起来很突兀,而且我们经常发现语音会远远滞后于实际的游戏玩法。我们仍在探索其他在 iOS 上改进语音的选项。

以下是我们的播放音频文件的函数,其中包含额外的代码来处理通过开始和停止时间戳播放音频精灵的片段
playAudio(audio: string, loop = false, startTime = 0,
    endTime:number = undefined) {
  let audioElement = this.audioSources[audio];
  if (loop) {
    audioElement.loop = true;
  }
  if (!this.audioIsPlaying(audio)) {
    audioElement.currentTime = startTime;
    let playPromise = audioElement.play();
    if (endTime !== undefined) {
      const timeUpdate = (e: Event) => {
        if (audioElement.currentTime >= endTime) {
          audioElement.pause();
          audioElement.removeEventListener('timeupdate', timeUpdate);
        }
      };
      audioElement.addEventListener('timeupdate', timeUpdate);
    }
    if (playPromise !== undefined) {
      playPromise.catch(error => {
        console.log('Error in playAudio: ' + error);
      });
    }
  }
}

使用 getUserMedia 访问相机的冒险

Emoji Scavenger Hunt 严重依赖于能够通过浏览器中的 Javascript 访问摄像头。我们使用浏览器中的 MediaDevices.getUserMedia API 来访问摄像头。此 API 并非在所有浏览器中都受支持,但大多数主流浏览器的最新版本都有很好的支持.

要通过此 API 访问摄像头,我们使用以下代码片段
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
  const stream = await navigator.mediaDevices.getUserMedia({
    'audio': false,
    'video': {facingMode: 'environment'}
  });
  (window).stream = stream;
  this.videoElement.srcObject = stream;
}
此 API 通过传递配置对象并指定 facingMode,提供了一种访问前后置摄像头的途径。

无法通过 UIWebViews 访问

在测试期间,我们意识到 Apple 不支持从任何基于 UIWebView 的 webkit 浏览器中使用 getUserMedia API,这意味着任何在 iOS 上实现自己的浏览器的应用程序(例如第三方推特客户端或 iOS 上的 Chrome)将无法访问摄像头。

为了解决这个问题,我们检测到这些情况下摄像头的初始化失败,并提示用户在原生 Safari 浏览器中打开体验。

最终想法和鸣谢

通过这个实验,我们想要创建一个有趣且令人愉快的游戏,利用当今浏览器中提供的惊人的机器学习技术。这只是一个开始,我们很乐意看到你使用TensorFlow.jsTensorFlow.js 转换器 的强大功能构建的东西。如前所述,我们的代码可在Github 上获取,请使用它来启动你自己的想法。

我们要感谢 Takashi Kawashima、Daniel Smilkov、Nikhil Thorat 和 Ping Yu 在我们构建这个实验过程中的帮助。
下一篇文章
A look at how we built the Emoji Scavenger Hunt using TensorFlow.js

由 Google 品牌工作室的 Jacques Bruwer、JK Kafalas 和 Shuhei Iitsuka 发布

在这篇文章中,我们将讨论实验性游戏Emoji Scavenger Hunt 的内部机制。我们将向你展示我们如何使用 TensorFlow 训练自定义模型以进行物体识别,以及如何使用 TensorFlow.js 在 Web 前端使用该模型。我们还将介绍在使用 br... 时遇到的一些挑战和解决方法。