欢迎您访问 最编程 本站为您分享编程语言代码,编程技术文章!
您现在的位置是: 首页

tinyml-ml-tf-merge-2

最编程 2024-03-31 11:11:25
...

Tinyml:TensorFlow Lite 深度学习(三)

原文:Tinyml: Machine Learning with Tensorflow Lite

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:唤醒词检测:构建一个应用程序

TinyML 可能是一个新现象,但它最广泛的应用可能已经在你的家中、你的汽车中,甚至在你的口袋里工作。你能猜到它是什么吗?

过去几年见证了数字助手的崛起。这些产品提供了一个旨在提供即时访问信息而无需屏幕或键盘的语音用户界面(UI)。在谷歌助手、苹果的 Siri 和亚马逊的 Alexa 之间,这些数字助手几乎无处不在。几乎每部手机都内置了某种变体,从旗舰型号到专为新兴市场设计的语音优先设备。它们也存在于智能音箱、计算机和车辆中。

在大多数情况下,语音识别、自然语言处理和生成用户查询响应的繁重工作是在云端完成的,运行大型 ML 模型的强大服务器上。当用户提出问题时,它会作为音频流发送到服务器。服务器会弄清楚这意味着什么,查找所需的任何信息,并发送适当的响应。

但助手吸引人的部分是它们总是在线的,随时准备帮助你。通过说“嘿,谷歌”或“Alexa”,你可以唤醒你的助手并告诉它你需要什么,而无需按下按钮。这意味着它们必须时刻倾听你的声音,无论你是坐在客厅里,驾驶在高速公路上,还是在户外手持手机。

尽管在服务器上进行语音识别很容易,但将设备捕获的音频不间断地发送到数据中心是不可行的。从隐私的角度来看,将每秒捕获的音频发送到远程服务器将是一场灾难。即使这样做没问题,也需要大量的带宽,并且会在几小时内耗尽移动数据套餐。此外,网络通信会消耗能量,不间断地发送数据流将迅速耗尽设备的电池。更重要的是,每个请求都要经过服务器来回传输,助手会感觉迟钝和反应迟缓。

助手实际上只需要在唤醒词之后立即跟随的音频(例如“嘿,谷歌”)。如果我们能够在不发送数据的情况下检测到这个词,但在听到它时开始流式传输呢?我们将保护用户隐私,节省电池寿命和带宽,并在不等待网络的情况下唤醒助手。

这就是 TinyML 的用武之地。我们可以训练一个微小的模型来监听唤醒词,并在低功耗芯片上运行它。如果我们将其嵌入到手机中,它可以一直监听唤醒词。当它听到魔法词时,它会通知手机的操作系统(OS),后者可以开始捕获音频并将其发送到服务器。

唤醒词检测是 TinyML 的完美应用。它非常适合提供隐私、效率、速度和离线推理。这种方法,即一个微小、高效的模型“唤醒”一个更大、更耗资源的模型,被称为级联

在本章中,我们将探讨如何使用预训练的语音检测模型,利用微型微控制器提供始终开启的唤醒词检测。在第八章中,我们将探讨模型是如何训练的,以及如何创建我们自己的模型。

我们要构建什么

我们将构建一个嵌入式应用程序,使用一个 18 KB 的模型,该模型在语音命令数据集上进行训练,用于分类口头音频。该模型经过训练,可以识别“是”和“否”这两个词,还能够区分未知词和沉默或背景噪音。

我们的应用程序将通过麦克风监听周围环境,并在检测到一个词时通过点亮 LED 或在屏幕上显示数据来指示。理解这段代码将使你能够通过语音命令控制任何电子项目。

注意

与第五章一样,此应用程序的源代码可在TensorFlow GitHub 存储库中找到。

我们将按照第五章的类似模式,先浏览测试,然后是应用代码,接着是使样本在各种设备上工作的逻辑。

我们提供了将应用程序部署到以下设备的说明:

  • Arduino Nano 33 BLE Sense

  • SparkFun Edge

  • ST Microelectronics STM32F746G Discovery kit

注意

TensorFlow Lite 定期添加对新设备的支持,所以如果您想要使用的设备不在此列表中,请查看示例的README.md。如果您在按照这些步骤操作时遇到问题,也可以在那里查看更新的部署说明。

这是一个比“hello world”示例复杂得多的应用程序,所以让我们从浏览其结构开始。

应用程序架构

在前几章中,您已经了解到机器学习应用程序执行以下一系列操作:

  1. 获取输入

  2. 预处理输入以提取适合输入模型的特征

  3. 对处理后的输入进行推断运行

  4. 后处理模型的输出以理解它

  5. 使用生成的信息来实现一些事情

“hello world”示例以非常直接的方式遵循了这些步骤。它接受一个由简单计数器生成的单个浮点数作为输入。其输出是另一个浮点数,我们直接用来控制可视输出。

由于以下原因,我们的唤醒词应用程序将更加复杂:

  • 它将音频数据作为输入。正如您将看到的,这需要在输入模型之前进行大量处理。

  • 其模型是一个分类器,输出类别概率。我们需要解析并理解这个输出。

  • 它旨在持续进行推断,对实时数据进行处理。我们需要编写代码来理解一系列推断。

  • 该模型更大更复杂。我们将推动我们的硬件极限。

因为很多复杂性是由我们将要使用的模型造成的,让我们多了解一些关于它的信息。

介绍我们的模型

正如我们之前提到的,本章中使用的模型经过训练,可以识别“yes”和“no”这两个词,同时也能够区分未知词和沉默或背景噪音。

该模型是在一个名为Speech Commands dataset的数据集上进行训练的。该数据集包含 65,000 个 30 个短单词的一秒长话语,是在线众包的。

尽管数据集包含 30 个不同的单词,但模型只训练来区分四个类别:单词“yes”和“no”,“未知”单词(指数据集中的其他 28 个单词)以及沉默。

该模型每次接受一秒钟的数据。它输出四个概率分数,分别对应这四个类别,预测数据代表其中一个类别的可能性有多大。

然而,该模型不接受原始音频样本数据。相反,它使用频谱图,这是由频率信息片段组成的二维数组,每个片段来自不同的时间窗口。

图 7-1 是从一个说“yes”的一秒音频片段生成的频谱图的可视表示。图 7-2 展示了同样的内容,但是是“no”这个词。

'yes'的频谱图

图 7-1. “yes”的频谱图

'no'的频谱图

图 7-2. “no”的频谱图

通过在预处理过程中隔离频率信息,我们让模型的生活变得更轻松。在训练过程中,它不需要学习如何解释原始音频数据;相反,它可以使用一个更高层次的抽象来处理最有用的信息。

我们将在本章后面看到如何生成频谱图。现在,我们只需要知道模型将频谱图作为输入。因为频谱图是一个二维数组,我们将其作为 2D 张量输入到模型中。

有一种神经网络架构专门设计用于处理多维张量,其中信息包含在相邻值组之间的关系中。它被称为卷积神经网络(CNN)。

这种类型数据最常见的例子是图像,其中一组相邻的像素可能代表一个形状、图案或纹理。在训练过程中,CNN 能够识别这些特征并学习它们代表什么。

它可以学习简单图像特征(如线条或边缘)如何组合成更复杂的特征(如眼睛或耳朵),以及这些特征如何组合形成输入图像,比如人脸的照片。这意味着 CNN 可以学会区分不同类别的输入图像,比如区分人的照片和狗的照片。

尽管它们通常应用于图像,即像素的 2D 网格,但 CNN 可以与任何多维向量输入一起使用。事实证明,它们非常适合处理频谱图数据。

在第八章中,我们将看看这个模型是如何训练的。在那之前,让我们回到讨论我们应用程序的架构。

所有的组件

如前所述,我们的唤醒词应用程序比“hello world”示例更复杂。图 7-3 显示了组成它的组件。

我们唤醒词应用程序的组件图

图 7-3。我们唤醒词应用程序的组件

让我们来研究每个部分的功能:

主循环

与“hello world”示例一样,我们的应用程序在一个连续循环中运行。所有后续的过程都包含在其中,并且它们会持续执行,尽可能快地运行,即每秒多次。

音频提供者

音频提供者从麦克风捕获原始音频数据。由于不同设备捕获音频的方法各不相同,这个组件可以被覆盖和定制。

特征提供者

特征提供者将原始音频数据转换为我们模型所需的频谱图格式。它作为主循环的一部分以滚动方式提供,为解释器提供一系列重叠的一秒窗口。

TF Lite 解释器

解释器运行 TensorFlow Lite 模型,将输入的频谱图转换为一组概率。

模型

模型包含在数据数组中,并由解释器运行。该数组位于tiny_conv_micro_features_model_data.cc中。

命令识别器

由于推断每秒运行多次,RecognizeCommands类聚合结果并确定是否平均听到了一个已知的单词。

命令响应器

如果听到了一个命令,命令响应器将使用设备的输出功能让用户知道。根据设备的不同,这可能意味着闪烁 LED 或在 LCD 显示器上显示数据。它可以被覆盖以适应不同的设备类型。

GitHub 上的示例文件包含了每个组件的测试。我们将逐步学习它们是如何工作的。

测试过程

就像第五章中一样,我们可以使用测试来了解应用程序的工作原理。我们已经涵盖了很多 C++和 TensorFlow Lite 的基础知识,因此不需要解释每一行代码。相反,让我们专注于每个测试的最重要部分,并解释发生了什么。

我们将探讨以下测试,您可以在GitHub 存储库中找到:

micro_speech_test.cc

展示如何对频谱图数据进行推断并解释结果

audio_provider_test.cc

展示如何使用音频提供程序

feature_provider_mock_test.cc

展示如何使用特征提供程序,使用模拟(虚假)音频提供程序的实现来传递虚假数据

recognize_commands_test.cc

展示如何解释模型的输出以决定是否找到了命令

command_responder_test.cc

展示如何调用命令响应器以触发输出

示例中还有许多其他测试,但是探索这些测试将使我们了解关键的移动部分。

基本流程

测试micro_speech_test.cc遵循我们从“hello world”示例中熟悉的基本流程:加载模型,设置解释器并分配张量。

然而,有一个显著的区别。在“hello world”示例中,我们使用AllOpsResolver来引入可能需要运行模型的所有深度学习操作。这是一种可靠的方法,但是它是浪费的,因为给定模型可能并不使用所有数十个可用操作。当部署到设备时,这些不必要的操作将占用宝贵的内存,因此最好只包含我们需要的操作。

为此,我们首先在测试文件的顶部定义我们的模型将需要的操作:

namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_FULLY_CONNECTED();
TfLiteRegistration* Register_SOFTMAX();
}  // namespace micro
}  // namespace ops
}  // namespace tflite

接下来,我们设置日志记录并加载我们的模型,正常进行:

// Set up logging.
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = &micro_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
const tflite::Model* model =
    ::tflite::GetModel(g_tiny_conv_micro_features_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
  error_reporter->Report(
      "Model provided is schema version %d not equal "
      "to supported version %d.\n",
      model->version(), TFLITE_SCHEMA_VERSION);
}

加载模型后,我们声明一个MicroMutableOpResolver并使用其方法AddBuiltin()来添加我们之前列出的操作:

tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
    tflite::BuiltinOperator_DEPTHWISE_CONV_2D,
    tflite::ops::micro::Register_DEPTHWISE_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
    tflite::BuiltinOperator_FULLY_CONNECTED,
    tflite::ops::micro::Register_FULLY_CONNECTED());
micro_mutable_op_resolver.AddBuiltin(tflite::BuiltinOperator_SOFTMAX,
                                      tflite::ops::micro::Register_SOFTMAX());

您可能想知道我们如何知道为给定模型包含哪些操作。一种方法是尝试使用MicroMutableOpResolver运行模型,但完全不调用AddBuiltin()。推断将失败,并且随附的错误消息将告诉我们缺少哪些操作需要添加。

注意

MicroMutableOpResolvertensorflow/lite/micro/micro_mutable_op_resolver.h中定义,您需要将其添加到您的include语句中。

设置好MicroMutableOpResolver后,我们就像往常一样继续,设置解释器及其工作内存:

// Create an area of memory to use for input, output, and intermediate arrays.
const int tensor_arena_size = 10 * 1024;
uint8_t tensor_arena[tensor_arena_size];
// Build an interpreter to run the model with.
tflite::MicroInterpreter interpreter(model, micro_mutable_op_resolver, tensor_arena,
                                     tensor_arena_size, error_reporter);
interpreter.AllocateTensors();

在我们的“hello world”应用程序中,我们仅为tensor_arena分配了 2 * 1,024 字节的空间,因为模型非常小。我们的语音模型要大得多,它处理更复杂的输入和输出,因此需要更多空间(10 1,024)。这是通过试错确定的。

接下来,我们检查输入张量的大小。但是,这次有点不同:

// Get information about the memory area to use for the model's input.
TfLiteTensor* input = interpreter.input(0);
// Make sure the input has the properties we expect.
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
TF_LITE_MICRO_EXPECT_EQ(4, input->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(49, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(40, input->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, input->type);

因为我们的输入是频谱图,所以输入张量具有更多维度——总共四个。第一维只是包含单个元素的包装器。第二和第三代表我们的频谱图的“行”和“列”,恰好有 49 行和 40 列。输入张量的第四个、最内部的维度,大小为 1,保存频谱图的每个单独的“像素”。我们稍后将更详细地查看频谱图的结构。

接下来,我们获取一个“yes”样本频谱图,存储在常量g_yes_micro_f2e59fea_nohash_1_data中。该常量在文件micro_features/yes_micro_features_data.cc中定义,该文件被此测试包含。频谱图存在为 1D 数组,我们只需迭代它将其复制到输入张量中:

// Copy a spectrogram created from a .wav audio file of someone saying "Yes"
// into the memory area used for the input.
const uint8_t* yes_features_data = g_yes_micro_f2e59fea_nohash_1_data;
for (int i = 0; i < input->bytes; ++i) {
  input->data.uint8[i] = yes_features_data[i];
}

在输入被分配之后,我们运行推断并检查输出张量的大小和形状:

// Run the model on this input and make sure it succeeds.
TfLiteStatus invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
  error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);

// Get the output from the model, and make sure it's the expected size and
// type.
TfLiteTensor* output = interpreter.output(0);
TF_LITE_MICRO_EXPECT_EQ(2, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(4, output->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, output->type);

我们的输出有两个维度。第一个只是一个包装器。第二个有四个元素。这是保存每个四个类别(静音、未知、“是”和“否”)匹配概率的结构。

接下来的代码块检查概率是否符合预期。输出张量的每个元素始终代表一个特定的类别,因此我们知道要检查每个类别的哪个索引。这个顺序在训练期间定义:

// There are four possible classes in the output, each with a score.
const int kSilenceIndex = 0;
const int kUnknownIndex = 1;
const int kYesIndex = 2;
const int kNoIndex = 3;

// Make sure that the expected "Yes" score is higher than the other classes.
uint8_t silence_score = output->data.uint8[kSilenceIndex];
uint8_t unknown_score = output->data.uint8[kUnknownIndex];
uint8_t yes_score = output->data.uint8[kYesIndex];
uint8_t no_score = output->data.uint8[kNoIndex];
TF_LITE_MICRO_EXPECT_GT(yes_score, silence_score);
TF_LITE_MICRO_EXPECT_GT(yes_score, unknown_score);
TF_LITE_MICRO_EXPECT_GT(yes_score, no_score);

我们传入了一个“是”频谱图,因此我们期望变量yes_score包含的概率高于silence_scoreunknown_scoreno_score

当我们对“是”满意时,我们用“否”频谱图做同样的事情。首先,我们复制一个输入并运行推断:

// Now test with a different input, from a recording of "No".
const uint8_t* no_features_data = g_no_micro_f9643d42_nohash_4_data;
for (int i = 0; i < input->bytes; ++i) {
  input->data.uint8[i] = no_features_data[i];
}
// Run the model on this "No" input.
invoke_status = interpreter.Invoke();
if (invoke_status != kTfLiteOk) {
  error_reporter->Report("Invoke failed\n");
}
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);

推断完成后,我们确认“no”获得了最高分数:

// Make sure that the expected "No" score is higher than the other classes.
silence_score = output->data.uint8[kSilenceIndex];
unknown_score = output->data.uint8[kUnknownIndex];
yes_score = output->data.uint8[kYesIndex];
no_score = output->data.uint8[kNoIndex];
TF_LITE_MICRO_EXPECT_GT(no_score, silence_score);
TF_LITE_MICRO_EXPECT_GT(no_score, unknown_score);
TF_LITE_MICRO_EXPECT_GT(no_score, yes_score);

我们完成了!

要运行此测试,请从 TensorFlow 存储库的根目录发出以下命令:

make -f tensorflow/lite/micro/tools/make/Makefile \
  test_micro_speech_test

接下来,让我们看看所有音频数据的来源:音频提供程序。

音频提供程序

音频提供程序是将设备的麦克风硬件连接到我们的代码的部分。每个设备都有不同的机制来捕获音频。因此,audio_provider.h为请求音频数据定义了一个接口,开发人员可以为他们想要支持的任何平台编写自己的实现。

提示

示例包括 Arduino、STM32F746G、SparkFun Edge 和 macOS 的音频提供程序实现。如果您希望此示例支持新设备,可以阅读现有的实现以了解如何实现。

音频提供程序的核心部分是一个名为GetAudioSamples()的函数,在audio_provider.h中定义。它看起来像这样:

TfLiteStatus GetAudioSamples(tflite::ErrorReporter* error_reporter,
                             int start_ms, int duration_ms,
                             int* audio_samples_size, int16_t** audio_samples);

audio_provider.h中所述,该函数应返回一个 16 位脉冲编码调制(PCM)音频数据数组。这是数字音频的一种非常常见的格式。

该函数被调用时带有一个ErrorReporter实例、一个开始时间(start_ms)、一个持续时间(duration_ms)和两个指针。

这些指针是GetAudioSamples()提供数据的机制。调用者声明适当类型的变量,然后在调用函数时将指针传递给它们。在函数的实现内部,指针被解引用,并设置变量的值。

第一个指针audio_samples_size将接收音频数据中 16 位样本的总数。第二个指针audio_samples将接收一个包含音频数据本身的数组。

通过查看测试,我们可以看到这一点。audio_provider_test.cc中有两个测试,但我们只需要查看第一个来学习如何使用音频提供程序:

TF_LITE_MICRO_TEST(TestAudioProvider) {
  tflite::MicroErrorReporter micro_error_reporter;
  tflite::ErrorReporter* error_reporter = &micro_error_reporter;

  int audio_samples_size = 0;
  int16_t* audio_samples = nullptr;
  TfLiteStatus get_status =
      GetAudioSamples(error_reporter, 0, kFeatureSliceDurationMs,
                      &audio_samples_size, &audio_samples);
  TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, get_status);
  TF_LITE_MICRO_EXPECT_LE(audio_samples_size, kMaxAudioSampleSize);
  TF_LITE_MICRO_EXPECT_NE(audio_samples, nullptr);

  // Make sure we can read all of the returned memory locations.
  int total = 0;
  for (int i = 0; i < audio_samples_size; ++i) {
    total += audio_samples[i];
  }
}

测试展示了如何使用一些值和一些指针调用GetAudioSamples()。测试确认在调用函数后指针被正确赋值。

注意

您会注意到一些常量的使用,kFeatureSliceDurationMskMaxAudioSampleSize。这些是在模型训练时选择的值,您可以在micro_features/micro_model_settings.h中找到它们。

audio_provider.cc的默认实现只返回一个空数组。为了证明它的大小是正确的,测试只是简单地循环遍历它以获取预期数量的样本。

除了GetAudioSamples()之外,音频提供程序还包含一个名为LatestAudioTimestamp()的函数。这个函数旨在返回音频数据最后捕获的时间,以毫秒为单位。特征提供程序需要这些信息来确定要获取哪些音频数据。

要运行音频提供程序测试,请使用以下命令:

make -f tensorflow/lite/micro/tools/make/Makefile \
  test_audio_provider_test

音频提供程序被特征提供程序用作新鲜音频样本的来源,所以让我们接着看一下。

特征提供程序

特征提供者将从音频提供者获取的原始音频转换为可以输入到我们模型中的频谱图。它在主循环中被调用。

其接口在feature_provider.h中定义,如下所示:

class FeatureProvider {
 public:
  // Create the provider, and bind it to an area of memory. This memory should
  // remain accessible for the lifetime of the provider object, since subsequent
  // calls will fill it with feature data. The provider does no memory
  // management of this data.
  FeatureProvider(int feature_size, uint8_t* feature_data);
  ~FeatureProvider();

  // Fills the feature data with information from audio inputs, and returns how
  // many feature slices were updated.
  TfLiteStatus PopulateFeatureData(tflite::ErrorReporter* error_reporter,
                                   int32_t last_time_in_ms, int32_t time_in_ms,
                                   int* how_many_new_slices);

 private:
  int feature_size_;
  uint8_t* feature_data_;
  // Make sure we don't try to use cached information if this is the first call
  // into the provider.
  bool is_first_run_;
};

要查看它的使用方式,我们可以看一下feature_provider_mock_test.cc中的测试。

为了使特征提供者能够处理音频数据,这些测试使用了一个特殊的假版本音频提供者,称为模拟,它被设置为提供音频数据。它在audio_provider_mock.cc中定义。

注意

在测试的构建说明中,模拟音频提供者被真实的东西替代,您可以在Makefile.inc中的FEATURE_PROVIDER_MOCK_TEST_SRCS下找到。

文件feature_provider_mock_test.cc包含两个测试。这是第一个:

TF_LITE_MICRO_TEST(TestFeatureProviderMockYes) {
  tflite::MicroErrorReporter micro_error_reporter;
  tflite::ErrorReporter* error_reporter = &micro_error_reporter;

  uint8_t feature_data[kFeatureElementCount];
  FeatureProvider feature_provider(kFeatureElementCount, feature_data);

  int how_many_new_slices = 0;
  TfLiteStatus populate_status = feature_provider.PopulateFeatureData(
      error_reporter, /* last_time_in_ms= */ 0, /* time_in_ms= */ 970,
      &how_many_new_slices);
  TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, populate_status);
  TF_LITE_MICRO_EXPECT_EQ(kFeatureSliceCount, how_many_new_slices);

  for (int i = 0; i < kFeatureElementCount; ++i) {
    TF_LITE_MICRO_EXPECT_EQ(g_yes_micro_f2e59fea_nohash_1_data[i],
                            feature_data[i]);
  }
}

要创建一个FeatureProvider,我们调用它的构造函数,传入feature_sizefeature_data参数:

FeatureProvider feature_provider(kFeatureElementCount, feature_data);

第一个参数指示频谱图中应该有多少总数据元素。第二个参数是一个指向我们希望用频谱图数据填充的数组的指针。

频谱图中的元素数量是在模型训练时决定的,并在micro_features/micro_model_settings.h中定义为kFeatureElementCount

为了获取过去一秒钟的音频特征,会调用feature_provider.PopulateFeatureData()

TfLiteStatus populate_status = feature_provider.PopulateFeatureData(
      error_reporter, /* last_time_in_ms= */ 0, /* time_in_ms= */ 970,
      &how_many_new_slices);

我们提供一个ErrorReporter实例,一个表示上次调用此方法的时间的整数(last_time_in_ms),当前时间(time_in_ms)以及一个指向整数的指针,该指针将更新为我们接收到多少个新的特征切片how_many_new_slices)。切片只是频谱图的一行,代表一段时间。

因为我们总是想要最后一秒钟的音频,特征提供者将比较上次调用时的时间(last_time_in_ms)和当前时间(time_in_ms),从那段时间内捕获的音频创建频谱数据,然后更新feature_data数组以添加任何额外的切片并删除超过一秒钟的旧切片。

PopulateFeatureData()运行时,它将从模拟音频提供者请求音频。模拟将提供代表“yes”的音频,特征提供者将处理它并提供结果。

在调用PopulateFeatureData()之后,我们检查其结果是否符合预期。我们将生成的数据与由模拟音频提供者提供的“yes”输入的已知频谱图进行比较:

TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, populate_status);
TF_LITE_MICRO_EXPECT_EQ(kFeatureSliceCount, how_many_new_slices);
for (int i = 0; i < kFeatureElementCount; ++i) {
  TF_LITE_MICRO_EXPECT_EQ(g_yes_micro_f2e59fea_nohash_1_data[i],
                          feature_data[i]);
}

模拟音频提供者可以根据传入的开始和结束时间提供“yes”或“no”的音频。feature_provider_mock_test.cc中的第二个测试与第一个测试完全相同,但表示“no”的音频。

运行测试时,请使用以下命令:

make -f tensorflow/lite/micro/tools/make/Makefile \
  test_feature_provider_mock_test

特征提供者如何将音频转换为频谱图

特征提供者在feature_provider.cc中实现。让我们来看看它是如何工作的。

正如我们讨论过的,它的工作是填充一个代表一秒钟音频的频谱图的数组。它被设计为在循环中调用,因此为了避免不必要的工作,它只会为现在和上次调用之间的时间生成新的特征。如果它在不到一秒钟之前被调用,它将保留一些先前的输出并仅生成缺失的部分。

在我们的代码中,每个频谱图都表示为一个二维数组,有 40 列和 49 行,其中每一行代表一个 30 毫秒(ms)的音频样本,分成 43 个频率桶。

为了创建每一行,我们通过快速傅立叶变换(FFT)算法运行 30 毫秒的音频输入。这种技术分析了样本中音频的频率分布,并创建了一个由 256 个频率桶组成的数组,每个值从 0 到 255。这些被平均成六组,留下 43 个桶。

执行此操作的代码位于文件micro_features/micro_features_generator.cc中,并由特征提供程序调用。

为了构建整个二维数组,我们将在 49 个连续的 30 毫秒音频片段上运行 FFT 的结果组合在一起,每个片段与上一个片段重叠 10 毫秒。图 7-4 展示了这是如何发生的。

您可以看到 30 毫秒的样本窗口每次向前移动 20 毫秒,直到覆盖完整的一秒样本。生成的频谱图准备传递到我们的模型中。

我们可以在feature_provider.cc中了解这个过程是如何发生的。首先,它根据上次调用PopulateFeatureData()的时间确定实际需要生成哪些片段:

// Quantize the time into steps as long as each window stride, so we can
// figure out which audio data we need to fetch.
const int last_step = (last_time_in_ms / kFeatureSliceStrideMs);
const int current_step = (time_in_ms / kFeatureSliceStrideMs);

int slices_needed = current_step - last_step;

正在处理的音频样本的图表

图 7-4。正在处理的音频样本的图表

如果它以前没有运行过,或者它在一秒钟前运行过,它将生成最大数量的片段:

if (is_first_run_) {
  TfLiteStatus init_status = InitializeMicroFeatures(error_reporter);
  if (init_status != kTfLiteOk) {
    return init_status;
  }
  is_first_run_ = false;
  slices_needed = kFeatureSliceCount;
}
if (slices_needed > kFeatureSliceCount) {
  slices_needed = kFeatureSliceCount;
}
*how_many_new_slices = slices_needed;

生成的数字被写入how_many_new_slices

接下来,它计算应保留多少现有片段,并将数组中的数据移动以为任何新片段腾出空间:

const int slices_to_keep = kFeatureSliceCount - slices_needed;
const int slices_to_drop = kFeatureSliceCount - slices_to_keep;
// If we can avoid recalculating some slices, just move the existing data
// up in the spectrogram, to perform something like this:
// last time = 80ms          current time = 120ms
// +-----------+             +-----------+
// | data@20ms |         --> | data@60ms |
// +-----------+       --    +-----------+
// | data@40ms |     --  --> | data@80ms |
// +-----------+   --  --    +-----------+
// | data@60ms | --  --      |  <empty>  |
// +-----------+   --        +-----------+
// | data@80ms | --          |  <empty>  |
// +-----------+             +-----------+
if (slices_to_keep > 0) {
  for (int dest_slice = 0; dest_slice < slices_to_keep; ++dest_slice) {
    uint8_t* dest_slice_data =
        feature_data_ + (dest_slice * kFeatureSliceSize);
    const int src_slice = dest_slice + slices_to_drop;
    const uint8_t* src_slice_data =
        feature_data_ + (src_slice * kFeatureSliceSize);
    for (int i = 0; i < kFeatureSliceSize; ++i) {
      dest_slice_data[i] = src_slice_data[i];
    }
  }
}
注意

如果您是经验丰富的 C++作者,您可能会想知道为什么我们不使用标准库来做诸如数据复制之类的事情。原因是我们试图避免不必要的依赖关系,以保持我们的二进制文件大小较小。因为嵌入式平台的内存非常有限,较小的应用程序二进制文件意味着我们有空间容纳更大更准确的深度学习模型。

在移动数据之后,它开始一个循环,每次迭代一次,它都需要一个新的片段。在这个循环中,它首先使用GetAudioSamples()从音频提供程序请求该片段的音频:

for (int new_slice = slices_to_keep; new_slice < kFeatureSliceCount;
     ++new_slice) {
  const int new_step = (current_step - kFeatureSliceCount + 1) + new_slice;
  const int32_t slice_start_ms = (new_step * kFeatureSliceStrideMs);
  int16_t* audio_samples = nullptr;
  int audio_samples_size = 0;
  GetAudioSamples(error_reporter, slice_start_ms, kFeatureSliceDurationMs,
                  &audio_samples_size, &audio_samples);
  if (audio_samples_size < kMaxAudioSampleSize) {
    error_reporter->Report("Audio data size %d too small, want %d",
                           audio_samples_size, kMaxAudioSampleSize);
    return kTfLiteError;
  }

要完成循环迭代,它将数据传递给GenerateMicroFeatures(),在micro_features/micro_features_generator.h中定义。这是执行 FFT 并返回音频频率信息的函数。

它还传递了一个指针new_slice_data,指向新数据应写入的内存位置:

  uint8_t* new_slice_data = feature_data_ + (new_slice * kFeatureSliceSize);
  size_t num_samples_read;
  TfLiteStatus generate_status = GenerateMicroFeatures(
      error_reporter, audio_samples, audio_samples_size, kFeatureSliceSize,
      new_slice_data, &num_samples_read);
  if (generate_status != kTfLiteOk) {
    return generate_status;
  }
}

在每个片段完成这个过程之后,我们有了整整一秒的最新频谱图。

提示

生成 FFT 的函数是GenerateMicroFeatures()。如果您感兴趣,您可以在micro_features/micro_features_generator.cc中阅读其定义。

如果您正在构建自己的应用程序并使用频谱图,您可以直接重用此代码。在训练模型时,您需要使用相同的代码将数据预处理为频谱图。

一旦我们有了频谱图,我们就可以使用模型对其进行推理。发生这种情况后,我们需要解释结果。这项任务属于我们接下来要探讨的类RecognizeCommands

命令识别器

在我们的模型输出了一组概率,表明在音频的最后一秒中说出了一个已知的单词之后,RecognizeCommands类的工作就是确定这是否表示成功的检测。

这似乎很简单:如果给定类别中的概率超过某个阈值,那么该单词已被说出。然而,在现实世界中,事情变得有点复杂。

正如我们之前建立的,我们每秒运行多个推理,每个推理在一秒钟的数据窗口上运行。这意味着我们将在任何给定单词上多次运行推理,在多个窗口上。

在图 7-5 中,您可以看到单词“noted”被说出的波形,周围有一个代表被捕获的一秒窗口的框。

我们的窗口中捕获到的单词“noted”

图 7-5。在我们的窗口中捕获到的单词“noted”

我们的模型经过训练,可以检测到“no”一词,并且它知道“noted”一词不是同一回事。如果我们在这一秒钟的窗口上进行推理,它将(希望)输出“no”一词的低概率。但是,如果窗口稍早出现在音频流中,如图 7-6 中所示,会发生什么呢?

我们窗口中捕获到的“noted”一词的部分

图 7-6。在我们的窗口中捕获到“noted”一词的部分

在这种情况下,“noted”一词的唯一部分出现在窗口中的是它的第一个音节。因为“noted”的第一个音节听起来像“no”,所以模型很可能会将其解释为“no”的概率很高。

这个问题,以及其他问题,意味着我们不能依赖单个推理来告诉我们一个单词是否被说出。这就是RecognizeCommands的作用所在!

识别器计算每个单词在过去几次推理中的平均分数,并决定是否高到足以被视为检测。为了做到这一点,我们将每个推理结果传递给它。

你可以在recognize_commands.h中看到它的接口,这里部分重现:

class RecognizeCommands {
 public:
  explicit RecognizeCommands(tflite::ErrorReporter* error_reporter,
                             int32_t average_window_duration_ms = 1000,
                             uint8_t detection_threshold = 200,
                             int32_t suppression_ms = 1500,
                             int32_t minimum_count = 3);

  // Call this with the results of running a model on sample data.
  TfLiteStatus ProcessLatestResults(const TfLiteTensor* latest_results,
                                    const int32_t current_time_ms,
                                    const char** found_command, uint8_t* score,
                                    bool* is_new_command);

RecognizeCommands被定义,以及一个构造函数,为一些默认值进行了定义:

  • 平均窗口的长度(average_window_duration_ms

  • 作为检测的最低平均分数(detection_threshold

  • 在识别第二个命令之前听到命令后等待的时间量(suppression_ms

  • 在窗口中需要的最小推理数量,以便结果计数(3

该类有一个方法,ProcessLatestResults()。它接受一个指向包含模型输出的TfLiteTensor的指针(latest_results),并且必须在当前时间(current_time_ms)下调用。

此外,它还接受三个指针用于输出。首先,它给出了任何被检测到的单词的名称(found_command)。它还提供了命令的平均分数(score)以及命令是新的还是在一定时间内的先前推理中已经听到过(is_new_command)。

对多次推理结果进行平均是处理时间序列数据时的一种有用且常见的技术。在接下来的几页中,我们将逐步介绍recognize_commands.cc中的代码,并了解一些它的工作原理。你不需要理解每一行,但了解一些可能对你自己的项目有帮助的工具是有益的。

首先,我们确保输入张量的形状和类型是正确的:

TfLiteStatus RecognizeCommands::ProcessLatestResults(
    const TfLiteTensor* latest_results, const int32_t current_time_ms,
    const char** found_command, uint8_t* score, bool* is_new_command) {
  if ((latest_results->dims->size != 2) ||
      (latest_results->dims->data[0] != 1) ||
      (latest_results->dims->data[1] != kCategoryCount)) {
    error_reporter_->Report(
        "The results for recognition should contain %d elements, but there are "
        "%d in an %d-dimensional shape",
        kCategoryCount, latest_results->dims->data[1],
        latest_results->dims->size);
    return kTfLiteError;
  }

  if (latest_results->type != kTfLiteUInt8) {
    error_reporter_->Report(
        "The results for recognition should be uint8 elements, but are %d",
        latest_results->type);
    return kTfLiteError;
  }

接下来,我们检查current_time_ms以验证它是否在我们的平均窗口中最近的结果之后:

if ((!previous_results_.empty()) &&
    (current_time_ms < previous_results_.front().time_)) {
  error_reporter_->Report(
      "Results must be fed in increasing time order, but received a "
      "timestamp of %d that was earlier than the previous one of %d",
      current_time_ms, previous_results_.front().time_);
  return kTfLiteError;
}

之后,我们将最新的结果添加到我们将要进行平均的结果列表中:

// Add the latest results to the head of the queue.
previous_results_.push_back({current_time_ms, latest_results->data.uint8});
// Prune any earlier results that are too old for the averaging window.
const int64_t time_limit = current_time_ms - average_window_duration_ms_;
while ((!previous_results_.empty()) &&
       previous_results_.front().time_ < time_limit) {
  previous_results_.pop_front();

如果我们的平均窗口中的结果少于最小数量(由minimum_count_定义,默认为3),我们无法提供有效的平均值。在这种情况下,我们将输出指针设置为指示found_command是最近的*命令,分数为 0,并且该命令不是新的:

// If there are too few results, assume the result will be unreliable and
// bail.
const int64_t how_many_results = previous_results_.size();
const int64_t earliest_time = previous_results_.front().time_;
const int64_t samples_duration = current_time_ms - earliest_time;
if ((how_many_results < minimum_count_) ||
    (samples_duration < (average_window_duration_ms_ / 4))) {
  *found_command = previous_top_label_;
  *score = 0;
  *is_new_command = false;
  return kTfLiteOk;
}

否则,我们继续通过平均窗口中的所有分数:

// Calculate the average score across all the results in the window.
int32_t average_scores[kCategoryCount];
for (int offset = 0; offset < previous_results_.size(); ++offset) {
  PreviousResultsQueue::Result previous_result =
      previous_results_.from_front(offset);
  const uint8_t* scores = previous_result.scores_;
  for (int i = 0; i < kCategoryCount; ++i) {
    if (offset == 0) {
      average_scores[i] = scores[i];
    } else {
      average_scores[i] += scores[i];
    }
  }
}
for (int i = 0; i < kCategoryCount; ++i) {
  average_scores[i] /= how_many_results;
}

现在我们有足够的信息来确定哪个类别是我们的赢家。建立这一点是一个简单的过程:

// Find the current highest scoring category.
int current_top_index = 0;
int32_t current_top_score = 0;
for (int i = 0; i < kCategoryCount; ++i) {
  if (average_scores[i] > current_top_score) {
    current_top_score = average_scores[i];
    current_top_index = i;
  }
}
const char* current_top_label = kCategoryLabels[current_top_index];

最后一部分逻辑确定结果是否是有效检测。为了做到这一点,它确保其分数高于检测阈值(默认为 200),并且它没有在上次有效检测之后发生得太快,这可能是一个错误结果的指示:

// If we've recently had another label trigger, assume one that occurs too
// soon afterwards is a bad result.
int64_t time_since_last_top;
if ((previous_top_label_ == kCategoryLabels[0]) ||
    (previous_top_label_time_ == std::numeric_limits<int32_t>::min())) {
  time_since_last_top = std::numeric_limits<int32_t>::max();
} else {
  time_since_last_top = current_time_ms - previous_top_label_time_;
}
if ((current_top_score > detection_threshold_) &&
    ((current_top_label != previous_top_label_) ||
     (time_since_last_top > suppression_ms_))) {
  previous_top_label_ = current_top_label;
  previous_top_label_time_ = current_time_ms;
  *is_new_command = true;
} else {
  *is_new_command = false;
}
*found_command = current_top_label;
*score = current_top_score;

如果结果有效,is_new_command被设置为true。这是调用者可以用来确定一个单词是否真正被检测到。

测试(在recognize_commands_test.cc中)对存储在平均窗口中的各种不同输入和结果进行了测试。

让我们走一遍RecognizeCommandsTestBasic中的一个测试,演示了如何使用RecognizeCommands。首先,我们只是创建了该类的一个实例:

TF_LITE_MICRO_TEST(RecognizeCommandsTestBasic) {
  tflite::MicroErrorReporter micro_error_reporter;
  tflite::ErrorReporter* error_reporter = &micro_error_reporter;

  RecognizeCommands recognize_commands(error_reporter);

接下来,我们创建一个包含一些虚假推理结果的张量,这将由ProcessLatestResults()使用来决定是否听到了命令:

TfLiteTensor results = tflite::testing::CreateQuantizedTensor(
    {255, 0, 0, 0}, tflite::testing::IntArrayFromInitializer({2, 1, 4}),
    "input_tensor", 0.0f, 128.0f);

然后,我们设置一些变量,这些变量将被ProcessLatestResults()的输出设置:

const char* found_command;
uint8_t score;
bool is_new_command;

最后,我们调用ProcessLatestResults(),提供这些变量的指针以及包含结果的张量。我们断言该函数将返回kTfLiteOk,表示输入已成功处理:

TF_LITE_MICRO_EXPECT_EQ(
    kTfLiteOk, recognize_commands.ProcessLatestResults(
                   &results, 0, &found_command, &score, &is_new_command));

文件中的其他测试执行了一些更详尽的检查,以确保函数的正确执行。您可以阅读它们以了解更多信息。

要运行所有测试,请