Tinyml:TensorFlow Lite 深度学习(四)
原文:Tinyml: Machine Learning with Tensorflow Lite
译者:飞龙
第八章:唤醒词检测:训练模型
在第七章中,我们围绕一个训练有能力识别“是”和“否”的模型构建了一个应用程序。在本章中,我们将训练一个新模型,可以识别不同的单词。
我们的应用代码相当通用。它只是捕获和处理音频,将其输入到 TensorFlow Lite 模型中,并根据输出执行某些操作。它大多数情况下不关心模型正在寻找哪些单词。这意味着如果我们训练一个新模型,我们可以直接将其放入我们的应用程序中,它应该立即运行。
在训练新模型时,我们需要考虑以下事项:
输入
新模型必须在与我们的应用代码相同形状和格式的输入数据上进行训练,具有相同的预处理。
输出
新模型的输出必须采用相同的格式:每个类别一个概率张量。
训练数据
无论我们选择哪些新单词,我们都需要很多人说这些单词的录音,这样我们才能训练我们的新模型。
优化
模型必须经过优化,以在内存有限的微控制器上高效运行。
幸运的是,我们现有的模型是使用由 TensorFlow 团队发布的公开可用脚本进行训练的,我们可以使用这个脚本来训练一个新模型。我们还可以访问一个免费的口语音频数据集,可以用作训练数据。
在下一节中,我们将详细介绍使用此脚本训练模型的过程。然后,在“在我们的项目中使用模型”中,我们将把新模型整合到我们现有的应用程序代码中。之后,在“模型的工作原理”中,您将了解模型的实际工作原理。最后,在“使用您自己的数据进行训练”中,您将看到如何使用您自己的数据集训练模型。
训练我们的新模型
我们正在使用的模型是使用 TensorFlow Simple Audio Recognition脚本进行训练的,这是一个示例脚本,旨在演示如何使用 TensorFlow 构建和训练用于音频识别的模型。
该脚本使训练音频识别模型变得非常容易。除其他事项外,它还允许我们执行以下操作:
-
下载一个包含 20 个口语单词的音频数据集。
-
选择要训练模型的单词子集。
-
指定在音频上使用哪种类型的预处理。
-
选择几种不同类型的模型架构。
-
使用量化将模型优化为微控制器。
当我们运行脚本时,它会下载数据集,训练模型,并输出代表训练模型的文件。然后我们使用其他工具将此文件转换为适合 TensorFlow Lite 的正确形式。
注意
模型作者通常会创建这些类型的训练脚本。这使他们能够轻松地尝试不同变体的模型架构和超参数,并与他人分享他们的工作。
运行训练脚本的最简单方法是在 Colaboratory(Colab)笔记本中进行,我们将在下一节中进行。
在 Colab 中训练
Google Colab 是一个很好的训练模型的地方。它提供了云中强大的计算资源,并且设置了我们可以用来监视训练过程的工具。而且它完全免费。
在本节中,我们将使用 Colab 笔记本来训练我们的新模型。我们使用的笔记本可以在 TensorFlow 存储库中找到。
打开笔记本并单击“在 Google Colab 中运行”按钮,如图 8-1 所示。
图 8-1。在 Google Colab 中运行按钮
提示
截至目前,GitHub 存在一个错误,导致在显示 Jupyter 笔记本时出现间歇性错误消息。如果尝试访问笔记本时看到消息“抱歉,出了点问题。重新加载?”,请按照“构建我们的模型”中的说明操作。
本笔记本将指导我们完成训练模型的过程。它将按照以下步骤进行:
-
配置参数
-
安装正确的依赖项
-
使用称为 TensorBoard 的工具监视训练
-
运行训练脚本
-
将训练输出转换为我们可以使用的模型
启用 GPU 训练
在第四章中,我们在少量数据上训练了一个非常简单的模型。我们现在正在训练的模型要复杂得多,具有更大的数据集,并且需要更长时间来训练。在一台普通的现代计算机 CPU 上,训练它需要三到四个小时。
为了缩短训练模型所需的时间,我们可以使用一种称为GPU 加速的东西。GPU,即图形处理单元。它是一种旨在帮助计算机快速处理图像数据的硬件部件,使其能够流畅地渲染用户界面和视频游戏等内容。大多数计算机都有一个。
图像处理涉及并行运行许多任务,训练深度学习网络也是如此。这意味着可以使用 GPU 硬件加速深度学习训练。通常情况下,使用 GPU 运行训练比使用 CPU 快 5 到 10 倍是很常见的。
我们训练过程中需要的音频预处理意味着我们不会看到如此巨大的加速,但我们的模型在 GPU 上仍然会训练得更快 - 大约需要一到两个小时。
幸运的是,Colab 支持通过 GPU 进行训练。默认情况下未启用,但很容易打开。要这样做,请转到 Colab 的运行时菜单,然后单击“更改运行时类型”,如图 8-2 所示。
图 8-2. 在 Colab 中的“更改运行时类型”选项
选择此选项后,将打开图 8-3 中显示的“笔记本设置”框。
图 8-3. “笔记本设置”框
从“硬件加速器”下拉列表中选择 GPU,如图 8-4 所示,然后单击保存。
图 8-4. “硬件加速器”下拉列表
Colab 现在将在具有 GPU 的后端计算机(称为运行时)上运行其 Python。
下一步是配置笔记本,以包含我们想要训练的单词。
配置训练
训练脚本通过一系列命令行标志进行配置,这些标志控制从模型架构到将被训练分类的单词等所有内容。
为了更容易运行脚本,笔记本的第一个单元格将一些重要值存储在环境变量中。当运行这些脚本时,这些值将被替换为脚本的命令行标志。
第一个是WANTED_WORDS,允许我们选择要训练模型的单词:
os.environ["WANTED_WORDS"] = "yes,no"
默认情况下,选定的单词是“yes”和“no”,但我们可以提供以下单词的任何组合,这些单词都出现在我们的数据集中:
-
常见命令:yes、no、up、down、left、right、on、off、stop、go、backward、forward、follow、learn
-
数字零到九:zero、one、two、three、four、five、six、seven、eight、nine
-
随机单词:bed、bird、cat、dog、happy、house、Marvin、Sheila、tree、wow
要选择单词,我们只需将它们包含在逗号分隔的列表中。让我们选择单词“on”和“off”来训练我们的新模型:
os.environ["WANTED_WORDS"] = "on,off"
在训练模型时,未包含在列表中的任何单词将在模型训练时归为“未知”类别。
注意
在这里选择超过两个单词是可以的;我们只需要稍微调整应用代码。我们提供了在“在我们的项目中使用模型”中执行此操作的说明。
还要注意TRAINING_STEPS和LEARNING_RATE变量:
os.environ["TRAINING_STEPS"]="15000,3000"
os.environ["LEARNING_RATE"]="0.001,0.0001"
在第三章中,我们了解到模型的权重和偏差会逐渐调整,以便随着时间的推移,模型的输出越来越接近所期望的值。TRAINING_STEPS指的是训练数据批次通过网络运行的次数,以及其权重和偏差的更新次数。LEARNING_RATE设置调整速率。
使用高学习率,权重和偏差在每次迭代中调整更多,意味着收敛速度快。然而,这些大幅跳跃意味着更难以达到理想值,因为我们可能会一直跳过它们。使用较低的学习率,跳跃较小。需要更多步骤才能收敛,但最终结果可能更好。对于给定模型的最佳学习率是通过试错确定的。
在上述变量中,训练步骤和学习率被定义为逗号分隔的列表,定义了每个训练阶段的学习率。根据我们刚刚查看的值,模型将进行 15,000 步的训练,学习率为 0.001,然后进行 3,000 步的训练,学习率为 0.0001。总步数将为 18,000。
这意味着我们将使用高学习率进行一系列迭代,使网络快速收敛。然后我们将使用低学习率进行较少的迭代,微调权重和偏差。
现在,我们将保持这些值不变,但知道它们是什么是很好的。运行单元格。您将看到以下输出打印:
Training these words: on,off
Training steps in each stage: 15000,3000
Learning rate in each stage: 0.001,0.0001
Total number of training steps: 18000
这提供了我们的模型将如何训练的摘要。
安装依赖项
接下来,我们获取一些运行脚本所必需的依赖项。
运行下面的两个单元格来执行以下操作:
-
安装包含训练所需操作的特定版本的 TensorFlow
pip软件包。 -
克隆 TensorFlow GitHub 存储库的相应版本,以便我们可以访问训练脚本。
加载 TensorBoard
为了监视训练过程,我们使用TensorBoard。这是一个用户界面,可以向我们显示图表、统计数据和其他关于训练进展的见解。
当训练完成时,它将看起来像图 8-5 中的截图。您将在本章后面了解所有这些图表的含义。
图 8-5。训练完成后的 TensorBoard 截图
运行下一个单元格以加载 TensorBoard。它将出现在 Colab 中,但在我们开始训练之前不会显示任何有趣的内容。
开始训练
以下单元格运行开始训练的脚本。您可以看到它有很多命令行参数:
!python tensorflow/tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words=${WANTED_WORDS} --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --verbosity=WARN --how_many_training_steps=${TRAINING_STEPS} \
--learning_rate=${LEARNING_RATE} --summaries_dir=/content/retrain_logs \
--data_dir=/content/speech_dataset --train_dir=/content/speech_commands_train
其中一些,如--wanted_words=${WANTED_WORDS},使用我们之前定义的环境变量来配置我们正在创建的模型。其他设置脚本的输出,例如--train_dir=/content/speech_commands_train,定义了训练模型将保存的位置。
保持参数不变,运行单元格。您将开始看到一些输出流过。在下载语音命令数据集时,它将暂停一段时间:
>> Downloading speech_commands_v0.02.tar.gz 18.1%
完成后,会出现更多输出。可能会有一些警告,只要单元格继续运行,您可以忽略它们。此时,您应该向上滚动到 TensorBoard,希望它看起来像图 8-6。如果您看不到任何图表,请单击 SCALARS 选项卡。
图 8-6。训练开始时的 TensorBoard 截图
万岁!这意味着训练已经开始。您刚刚运行的单元将继续执行,训练将需要最多两个小时才能完成。该单元将不会输出更多日志,但有关训练运行的数据将出现在 TensorBoard 中。
您可以看到 TensorBoard 显示了两个图形,“准确度”和“交叉熵”,如图 8-7 所示。两个图形都显示了 x 轴上的当前步骤。“准确度”图显示了模型在 y 轴上的准确度,这表明它能够正确检测单词的时间有多少。“交叉熵”图显示了模型的损失,量化了模型预测与正确值之间的差距。

图 8-7。"准确度"和"交叉熵"图
注意
交叉熵是衡量机器学习模型损失的常见方法,用于执行分类,目标是预测输入属于哪个类别。
图形上的锯齿线对应于训练数据集上的性能,而直线反映了验证数据集上的性能。验证定期进行,因此图上的验证数据点较少。
新数据将随着时间的推移出现在图形中,但要显示它,您需要调整它们的比例以适应。您可以通过单击每个图形下面的最右边的按钮来实现这一点,如图 8-8 所示。

图 8-8。单击此按钮以调整图形的比例,以适应所有可用数据
您还可以单击图 8-9 中显示的按钮,使每个图形变大。

图 8-9。单击此按钮以放大图形
除了图形外,TensorBoard 还可以显示输入传入模型。单击 IMAGES 选项卡,显示类似于图 8-10 的视图。这是在训练期间输入到模型中的频谱图的示例。

图 8-10。TensorBoard 的 IMAGES 选项卡
等待训练完成
训练模型将需要一到两个小时,所以我们现在的工作是耐心等待。幸运的是,我们有 TensorBoard 漂亮的图形来娱乐我们。
随着训练的进行,您会注意到指标在一定范围内跳动。这是正常的,但它使图形看起来模糊且难以阅读。为了更容易看到训练的进展,我们可以使用 TensorFlow 的平滑功能。
图 8-11 显示了应用默认平滑度的图形;请注意它们有多模糊。

图 8-11。默认平滑度的训练图
通过调整图 8-12 中显示的平滑滑块,我们可以增加平滑度,使趋势更加明显。

图 8-12。TensorBoard 的平滑滑块
图 8-13 显示了具有更高平滑度级别的相同图形。原始数据以较浅的颜色可见,在下面。

图 8-13。增加平滑度的训练图
保持 Colab 运行
为了防止废弃的项目占用资源,如果 Colab 没有被积极使用,它将关闭您的运行时。因为我们的训练需要一段时间,所以我们需要防止这种情况发生。我们需要考虑一些事情。
首先,如果我们没有在与 Colab 浏览器标签进行活动交互,Web 用户界面将与后端运行时断开连接,训练脚本正在执行的地方。几分钟后会发生这种情况,并且会导致您的 TensorBoard 图表停止更新最新的训练指标。如果发生这种情况,无需恐慌—您的训练仍在后台运行。
如果您的运行时已断开连接,您将在 Colab 的用户界面中看到一个重新连接按钮,如图 8-14 所示。点击此按钮以重新连接您的运行时。
图 8-14. Colab 的重新连接按钮
断开连接的运行时并不是什么大问题,但 Colab 的下一个超时需要一些注意。如果您在 90 分钟内不与 Colab 进行交互,您的运行时实例将被回收。这是一个问题:您将丢失所有的训练进度,以及实例中存储的任何数据!
为了避免这种情况发生,您只需要每 90 分钟至少与 Colab 进行一次交互。打开标签页,确保运行时已连接,并查看您美丽的图表。只要在 90 分钟过去之前这样做,连接就会保持打开状态。
警告
即使您关闭了 Colab 标签页,运行时也会在后台继续运行长达 90 分钟。只要在浏览器中打开原始 URL,您就可以重新连接到运行时,并继续之前的操作。
然而,当标签页关闭时,TensorBoard 将消失。如果在重新打开标签页时训练仍在进行,您将无法查看 TensorBoard,直到训练完成。
最后,Colab 运行时的最长寿命为 12 小时。如果您的训练时间超过 12 小时,那就倒霉了—Colab 将在训练完成之前关闭并重置您的实例。如果您的训练可能持续这么长时间,您应该避免使用 Colab,并使用“其他运行脚本的方法”中描述的替代方案之一。幸运的是,训练我们的唤醒词模型不会花费那么长时间。
当您的图表显示了 18000 步的数据时,训练就完成了!现在我们必须运行几个命令来准备我们的模型进行部署。不用担心—这部分要快得多。
冻结图表
正如您在本书中早些时候学到的,训练是一个迭代调整模型权重和偏差的过程,直到它产生有用的预测。训练脚本将这些权重和偏差写入检查点文件。每一百步写入一个检查点。这意味着如果训练在中途失败,可以从最近的检查点重新启动而不会丢失进度。
train.py脚本被调用时带有一个参数,--train_dir,用于指定这些检查点文件将被写入的位置。在我们的 Colab 中,它被设置为*/content/speech_commands_train*。
您可以通过打开 Colab 的左侧面板来查看检查点文件,该面板具有一个文件浏览器。要这样做,请点击图 8-15 中显示的按钮。
图 8-15. 打开 Colab 侧边栏的按钮
在此面板中,点击“文件”选项卡以查看运行时的文件系统。如果您打开*speech_commands_train/*目录,您将看到检查点文件,如图 8-16 所示。每个文件名中的数字表示保存检查点的步骤。
图 8-16. Colab 的文件浏览器显示检查点文件列表
一个 TensorFlow 模型由两个主要部分组成:
-
训练产生的权重和偏差
-
将模型的输入与这些权重和偏差结合起来产生模型的输出的操作图
此时,我们的模型操作在 Python 脚本中定义,并且其训练的权重和偏差在最新的检查点文件中。我们需要将这两者合并为一个具有特定格式的单个模型文件,以便我们可以用来运行推断。创建此模型文件的过程称为冻结——我们正在创建一个具有冻结权重的图的静态表示。
为了冻结我们的模型,我们运行一个脚本。您将在下一个单元格中找到它,在“冻结图”部分。脚本的调用如下:
!python tensorflow/tensorflow/examples/speech_commands/freeze.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words=${WANTED_WORDS} --quantize=1 \
--output_file=/content/tiny_conv.pb \
--start_checkpoint=/content/speech_commands_train/tiny_conv. \
ckpt-${TOTAL_STEPS}
为了指向正确的操作图以冻结的脚本,我们传递了一些与训练中使用的相同参数。我们还传递了最终检查点文件的路径,该文件的文件名以训练步骤的总数结尾。
运行此单元格以冻结图。冻结的图将输出到名为tiny_conv.pb的文件中。
这个文件是完全训练过的 TensorFlow 模型。它可以被 TensorFlow 加载并用于运行推断。这很棒,但它仍然是常规 TensorFlow 使用的格式,而不是 TensorFlow Lite。我们的下一步是将模型转换为 TensorFlow Lite 格式。
转换为 TensorFlow Lite
转换是另一个简单的步骤:我们只需要运行一个命令。现在我们有一个冻结的图文件可以使用,我们将使用toco,TensorFlow Lite 转换器的命令行界面。
在“转换模型”部分,运行第一个单元格:
!toco
--graph_def_file=/content/tiny_conv.pb --output_file= \
/content/tiny_conv.tflite \
--input_shapes=1,49,40,1 --input_arrays=Reshape_2
--output_arrays='labels_softmax' \
--inference_type=QUANTIZED_UINT8 --mean_values=0 --std_dev_values=9.8077
在参数中,我们指定要转换的模型,TensorFlow Lite 模型文件的输出位置,以及一些取决于模型架构的其他值。因为模型在训练期间被量化,我们还提供了一些参数(inference_type,mean_values和std_dev_values),指导转换器如何将其低精度值映射到实数。
您可能想知道为什么input_shape参数在宽度、高度和通道参数之前有一个前导1。这是批处理大小;为了在训练期间提高效率,我们一次发送很多输入,但当我们在实时应用中运行时,我们每次只处理一个样本,这就是为什么批处理大小固定为1。
转换后的模型将被写入tiny_conv.tflite。恭喜!这是一个完全成型的 TensorFlow Lite 模型!
查看这个模型有多小,在下一个单元格中运行以下代码:
import os
model_size = os.path.getsize("/content/tiny_conv.tflite")
print("Model is %d bytes" % model_size)
输出显示模型非常小:模型大小为 18208 字节。
我们的下一步是将这个模型转换为可以部署到微控制器的形式。
创建一个 C 数组
回到“转换为 C 文件”中,我们使用xxd命令将 TensorFlow Lite 模型转换为 C 数组。我们将在下一个单元格中做同样的事情:
# Install xxd if it is not available
!apt-get -qq install xxd
# Save the file as a C source file
!xxd -i /content/tiny_conv.tflite > /content/tiny_conv.cc
# Print the source file
!cat /content/tiny_conv.cc
输出的最后部分将是文件的内容,其中包括一个 C 数组和一个保存其长度的整数,如下所示(您看到的确切值可能略有不同):
unsigned char _content_tiny_conv_tflite[] = {
0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x0e, 0x00, 0x18, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00,
// ...
0x00, 0x09, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x04
};
unsigned int _content_tiny_conv_tflite_len = 18208;
这段代码也被写入一个文件tiny_conv.cc,您可以使用 Colab 的文件浏览器下载。因为您的 Colab 运行时将在 12 小时后到期,现在将此文件下载到您的计算机是一个好主意。
接下来,我们将把这个新训练过的模型与micro_speech项目集成起来,以便我们可以将其部署到一些硬件上。
在我们的项目中使用模型
要使用我们的新模型,我们需要做三件事:
-
在micro_features/tiny_conv_micro_features_model_data.cc中,用我们的新模型替换原始模型数据。
-
在micro_features/micro_model_settings.cc中用我们的新“on”和“off”标签更新标签名称。
-
更新特定设备的command_responder.cc以执行我们对新标签的操作。
替换模型
要替换模型,请在文本编辑器中打开micro_features/tiny_conv_micro_features_model_data.cc。
注意
如果你正在使用 Arduino 示例,该文件将显示为 Arduino IDE 中的一个选项卡。它的名称将是micro_features_tiny_conv_micro_features_model_data.cpp。如果你正在使用 SparkFun Edge,你可以直接在本地的 TensorFlow 存储库副本中编辑文件。如果你正在使用 STM32F746G,你应该在 Mbed 项目目录中编辑文件。
tiny_conv_micro_features_model_data.cc文件包含一个看起来像这样的数组声明:
const unsigned char
g_tiny_conv_micro_features_model_data[] DATA_ALIGN_ATTRIBUTE = {
0x18, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x00, 0x00, 0x0e, 0x00,
0x18, 0x00, 0x04, 0x00, 0x08, 0x00, 0x0c, 0x00, 0x10, 0x00, 0x14, 0x00,
//...
0x00, 0x09, 0x06, 0x00, 0x08, 0x00, 0x07, 0x00, 0x06, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x04};
const int g_tiny_conv_micro_features_model_data_len = 18208;
需要替换数组的内容以及常量g_tiny_conv_micro_features_model_data_len的值,如果已经更改。
为此,打开你在上一节末尾下载的tiny_conv.cc文件。复制并粘贴数组的内容,但不包括定义,到tiny_conv_micro_features_model_data.cc中定义的数组中。确保你正在覆盖数组的内容,但不是它的声明。
在tiny_conv.cc的底部,你会找到_content_tiny_conv_tflite_len,一个变量,其值表示数组的长度。回到tiny_conv_micro_features_model_data.cc,用这个变量的值替换g_tiny_conv_micro_features_model_data_len的值。然后保存文件;你已经完成了更新。
更新标签
接下来,打开micro_features/micro_model_settings.cc。这个文件包含一个类标签的数组:
const char* kCategoryLabels[kCategoryCount] = {
"silence",
"unknown",
"yes",
"no",
};
为了调整我们的新模型,我们可以简单地将“yes”和“no”交换为“on”和“off”。我们按顺序将标签与模型的输出张量元素匹配,因此重要的是按照它们提供给训练脚本的顺序列出这些标签。
以下是预期的代码:
const char* kCategoryLabels[kCategoryCount] = {
"silence",
"unknown",
"on",
"off",
};
如果你训练了一个具有两个以上标签的模型,只需将它们全部添加到列表中。
我们现在已经完成了切换模型的工作。唯一剩下的步骤是更新使用标签的任何输出代码。
更新 command_responder.cc
该项目包含针对 Arduino、SparkFun Edge 和 STM32F746G 的不同设备特定实现的command_responder.cc。我们将在以下部分展示如何更新每个设备。
Arduino
位于arduino/command_responder.cc中的 Arduino 命令响应器在听到“yes”时会点亮 LED 3 秒钟。让我们将其更新为在听到“on”或“off”时点亮 LED。在文件中,找到以下if语句:
// If we heard a "yes", switch on an LED and store the time.
if (found_command[0] == 'y') {
last_yes_time = current_time;
digitalWrite(LED_BUILTIN, HIGH);
}
if语句测试命令的第一个字母是否为“y”,表示“yes”。如果我们将这个“y”改为“o”,LED 将点亮“on”或“off”,因为它们都以“o”开头:
if (found_command[0] == 'o') {
last_yes_time = current_time;
digitalWrite(LED_BUILTIN, HIGH);
}
完成这些代码更改后,部署到你的设备并尝试一下。
SparkFun Edge
位于sparkfun_edge/command_responder.cc中的 SparkFun Edge 命令响应器会根据听到的“yes”或“no”点亮不同的 LED。在文件中,找到以下if语句:
if (found_command[0] == 'y') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
if (found_command[0] == 'n') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
}
if (found_command[0] == 'u') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
}
很容易更新这些,使得“on”和“off”分别点亮不同的 LED:
if (found_command[0] == 'o' && found_command[1] == 'n') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
if (found_command[0] == 'o' && found_command[1] == 'f') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
}
if (found_command[0] == 'u') {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
}
因为这两个命令都以相同的字母开头,我们需要查看它们的第二个字母来消除歧义。现在,当说“on”时,黄色 LED 将点亮,当说“off”时,红色 LED 将点亮。
完成更改后,部署并运行代码,使用与“运行示例”中遵循的相同过程。
STM32F746G
位于disco_f746ng/command_responder.cc中的 STM32F746G 命令响应器会根据听到的命令显示不同的单词。在文件中,找到以下if语句:
if (*found_command == 'y') {
lcd.Clear(0xFF0F9D58);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard yes!", CENTER_MODE);
} else if (*found_command == 'n') {
lcd.Clear(0xFFDB4437);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard no :(", CENTER_MODE);
} else if (*found_command == 'u') {
lcd.Clear(0xFFF4B400);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard unknown", CENTER_MODE);
} else {
lcd.Clear(0xFF4285F4);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard silence", CENTER_MODE);
}
很容易更新以便响应“on”和“off”:
if (found_command[0] == 'o' && found_command[1] == 'n') {
lcd.Clear(0xFF0F9D58);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard on!", CENTER_MODE);
} else if (found_command[0] == 'o' && found_command[1] == 'f') {
lcd.Clear(0xFFDB4437);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard off", CENTER_MODE);
} else if (*found_command == 'u') {
lcd.Clear(0xFFF4B400);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard unknown", CENTER_MODE);
} else {
lcd.Clear(0xFF4285F4);
lcd.DisplayStringAt(0, LINE(5), (uint8_t *)"Heard silence", CENTER_MODE);
}
同样,因为这两个命令都以相同的字母开头,我们需要查看它们的第二个字母来消除歧义。现在我们为每个命令显示适当的文本。
运行脚本的其他方法
如果你无法使用 Colab,有两种其他推荐的训练模型的方法:
-
在一个带有 GPU 的云虚拟机(VM)中
-
在你的本地工作站上
进行基于 GPU 的训练所需的驱动程序仅在 Linux 上可用。没有 Linux,训练将需要大约四个小时。因此,建议使用带有 GPU 的云虚拟机或类似配置的 Linux 工作站。
设置您的虚拟机或工作站超出了本书的范围。但是,我们有一些建议。如果您使用虚拟机,可以启动一个Google Cloud 深度学习虚拟机镜像,该镜像预先配置了所有您进行 GPU 训练所需的依赖项。如果您使用 Linux 工作站,TensorFlow GPU Docker 镜像包含了您所需的一切。
要训练模型,您需要安装 TensorFlow 的夜间版本。要卸载任何现有版本并替换为已确认可用的版本,请使用以下命令:
pip uninstall -y tensorflow tensorflow_estimator
pip install -q tf-estimator-nightly==1.14.0.dev2019072901 \
tf-nightly-gpu==1.15.0.dev20190729
接下来,打开命令行并切换到用于存储代码的目录。使用以下命令克隆 TensorFlow 并打开一个已确认可用的特定提交:
git clone -q https://github.com/tensorflow/tensorflow
git -c advice.detachedHead=false -C tensorflow checkout 17ce384df70
现在您可以运行train.py脚本来训练模型。这将训练一个能识别“是”和“不”的模型,并将检查点文件输出到*/tmp*:
python tensorflow/tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="on,off" --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --verbosity=INFO --how_many_training_steps="15000,3000" \
--learning_rate="0.001,0.0001" --summaries_dir=/tmp/retrain_logs \
--data_dir=/tmp/speech_dataset --train_dir=/tmp/speech_commands_train
训练后,运行以下脚本来冻结模型:
python tensorflow/tensorflow/examples/speech_commands/freeze.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="on,off" --quantize=1 --output_file=/tmp/tiny_conv.pb \
--start_checkpoint=/tmp/speech_commands_train/tiny_conv.ckpt-18000
接下来,将模型转换为 TensorFlow Lite 格式:
toco
--graph_def_file=/tmp/tiny_conv.pb --output_file=/tmp/tiny_conv.tflite \
--input_shapes=1,49,40,1 --input_arrays=Reshape_2 \
--output_arrays='labels_softmax' \
--inference_type=QUANTIZED_UINT8 --mean_values=0 --std_dev_values=9.8077
最后,将文件转换为 C 源文件,以便编译到嵌入式系统中:
xxd -i /tmp/tiny_conv.tflite > /tmp/tiny_conv_micro_features_model_data.cc
模型的工作原理
现在您知道如何训练自己的模型了,让我们探讨一下它是如何工作的。到目前为止,我们将机器学习模型视为黑匣子——我们将训练数据输入其中,最终它会找出如何预测结果。要使用模型并不一定要理解底层发生了什么,但这对于调试问题可能有帮助,而且本身也很有趣。本节将为您提供一些关于模型如何进行预测的见解。
可视化输入
图 8-17 说明了实际输入神经网络的内容。这是一个具有单个通道的 2D 数组,因此我们可以将其可视化为单色图像。我们使用 16 KHz 音频样本数据,那么我们如何从源数据得到这种表示?这个过程是机器学习中所谓的“特征生成”的一个示例,目标是将更难处理的输入格式(在本例中是代表一秒音频的 16,000 个数值)转换为机器学习模型更容易理解的内容。如果您之前研究过深度学习的机器视觉用例,您可能没有遇到这种情况,因为图像通常相对容易让网络接受而无需太多预处理;但在许多其他领域,如音频和自然语言处理,仍然常见在将输入馈入模型之前对其进行转换。
图 8-17。TensorBoard 的 IMAGES 选项卡
为了对我们的模型为什么更容易处理预处理输入有直觉,让我们看一下一些音频录音的原始表示,如图 8-18 到 8-21 所示。
图 8-18。一个人说“是”的音频录音的波形
图 8-19。一个人说“不”的音频录音的波形
图 8-20。一个人说“是”的音频录音的另一个波形
图 8-21。一个人说“不”的音频录音的另一个波形
如果没有标签,你会很难区分哪些波形对应相同的单词。现在看看图 8-22 到 8-25,展示了将相同的一秒录音通过特征生成处理后的结果。
图 8-22。一个人说“是”时的谱图
图 8-23。一个人说“否”时的谱图
图 8-24。一个人说“是”时的另一个谱图
图 8-25。一个人说“否”时的另一个谱图
这些仍然不容易解释,但希望你能看出“是”谱图的形状有点像倒置的 L,而“否”特征显示出不同的形状。我们可以更容易地辨别谱图之间的差异,希望直觉告诉你,对于模型来说做同样的事情更容易。
另一个方面是生成的谱图比样本数据要小得多。每个谱图由 1,960 个数值组成,而波形有 16,000 个。它们是音频数据的摘要,减少了神经网络必须进行的工作量。事实上,一个专门设计的模型,比如DeepMind 的 WaveNet,可以将原始样本数据作为输入,但结果模型往往涉及比我们使用的神经网络加手工设计特征组合更多的计算,因此对于资源受限的环境,如嵌入式系统,我们更喜欢这里使用的方法。
特征生成是如何工作的?
如果你有处理音频的经验,你可能熟悉像梅尔频率倒谱系数(MFCCs)这样的方法。这是一种常见的生成我们正在使用的谱图的方法,但我们的示例实际上使用了一种相关但不同的方法。这是谷歌在生产中使用的相同方法,这意味着它已经得到了很多实际验证,但它还没有在研究文献中发表。在这里,我们大致描述了它的工作原理,但对于详细信息,最好的参考是代码本身。
该过程开始通过为给定时间片段生成傅立叶变换(也称为快速傅立叶变换或 FFT)-在我们的情况下是 30 毫秒的音频数据。这个 FFT 是在使用汉宁窗口过滤的数据上生成的,汉宁窗口是一个钟形函数,减少了 30 毫秒窗口两端样本的影响。傅立叶变换为每个频率产生具有实部和虚部的复数,但我们只关心总能量,因此我们对两个分量的平方求和,然后应用平方根以获得每个频率桶的幅度。
给定N个样本,傅立叶变换提供N/2 个频率的信息。以每秒 16,000 个样本的速率的 30 毫秒需要 480 个样本,因为我们的 FFT 算法需要二的幂输入,所以我们用零填充到 512 个样本,给我们 256 个频率桶。这比我们需要的要大,因此为了缩小它,我们将相邻频率平均到 40 个降采样桶中。然而,这种降采样不是线性的;相反,它使用基于人类感知的梅尔频率刻度,以便更多地为低频率分配权重,从而为它们提供更多的桶,而高频率则合并到更广泛的桶中。图 8-26 展示了该过程的图表。
图 8-26。特征生成过程的图表
这个特征生成器的一个不寻常之处是它包含了一个降噪步骤。这通过保持每个频率桶中的值的运行平均值,然后从当前值中减去这个平均值来实现。其思想是背景噪音随时间保持相对恒定,并显示在特定频率上。通过减去运行平均值,我们有很大机会去除一些噪音的影响,保留我们感兴趣的更快变化的语音。棘手的部分是特征生成器确实保留状态以跟踪每个桶的运行平均值,因此如果您尝试为给定输入重现相同的频谱图输出——就像我们尝试的那样进行测试——您将需要将该状态重置为正确的值。
噪音降低的另一个部分最初让我们感到惊讶的是它对奇数和偶数频率桶使用不同系数。这导致了您可以在最终生成的特征图像中看到的独特的梳齿图案(图 8-22 至 8-25)。最初我们以为这是一个错误,但在与原始实施者交谈后,我们了解到这实际上是有意为之,以帮助性能。在Yuxuan Wang 等人的“用于强健和远场关键词检测的可训练前端”的第 4.3 节中对这种方法进行了详细讨论,该论文还包括了进入此特征生成流程的其他设计决策的背景。我们还通过我们的模型进行了实证测试,去除奇数和偶数桶处理差异确实会显着降低评估的准确性。
然后我们使用每通道幅度归一化(PCAN)自动增益,根据运行平均噪音来增强信号。最后,我们对所有桶值应用对数尺度,以便相对较大的频率不会淹没频谱中较安静的部分——这种归一化有助于后续模型处理这些特征。
这个过程总共重复了 49 次,每次之间以 30 毫秒的窗口向前移动 20 毫秒,以覆盖完整的一秒音频输入数据。这产生了一个 40 个元素宽(每个频率桶一个)和 49 行高(每个时间片一个)的值的 2D 数组。
如果这一切听起来很复杂,不用担心。因为实现它的代码都是开源的,您可以在自己的音频项目中重用它。
理解模型架构
我们正在使用的神经网络模型被定义为一组操作的小图。您可以在create_tiny_conv_model()函数中找到定义它的代码,并且图 8-27 展示了结果的可视化。
该模型由一个卷积层、一个全连接层和最后的 softmax 层组成。在图中,卷积层标记为“DepthwiseConv2D”,但这只是 TensorFlow Lite 转换器的一个怪癖(事实证明,具有单通道输入图像的卷积层也可以表示为深度卷积)。您还会看到一个标记为“Reshape_1”的层,但这只是一个输入占位符,而不是一个真正的操作。
图 8-27。语音识别模型的图形可视化,由Netron 工具提供
卷积层用于在输入图像中发现 2D 模式。每个滤波器是一个值的矩形数组,它作为一个滑动窗口在输入上移动,输出图像表示输入和滤波器在每个点匹配程度。您可以将卷积操作视为在图像上移动一系列矩形滤波器,每个滤波器在每个像素处的结果对应于滤波器与图像中该补丁的相似程度。在我们的情况下,每个滤波器宽 8 像素,高 10 像素,总共有 8 个。图 8-28 到 8-35 显示它们的外观。
图 8-28。第一个滤波器图像
图 8-29。第二个滤波器图像
图 8-30。第三个滤波器图像
图 8-31。第四个滤波器图像
图 8-32。第五个滤波器图像
图 8-33。第六个滤波器图像
图 8-34。第七个滤波器图像
图 8-35。第八个滤波器图像
您可以将这些滤波器中的每一个视为输入图像的一个小补丁。该操作试图将此小补丁与看起来相似的输入图像部分进行匹配。当图像与补丁相似时,高值将被写入输出图像的相应部分。直观地说,每个滤波器都是模型已经学会在训练输入中寻找的模式,以帮助它区分不同类别。
因为我们有八个滤波器,所以将有八个不同的输出图像,每个对应于相应滤波器的匹配值,当它在输入上滑动时。这些滤波器输出实际上被合并为一个具有八个通道的单个输出图像。我们已将步幅设置为两个方向,这意味着每次我们将每个滤波器向前滑动两个像素,而不仅仅是一个像素。因为我们跳过每个其他位置,这意味着我们的输出图像是输入大小的一半。
您可以看到在可视化中,输入图像高 49 像素,宽 40 像素,具有单个通道,这是我们在前一节中讨论的特征频谱图所期望的。因为我们在水平和垂直方向上滑动卷积滤波器时跳过每个其他像素,所以卷积的输出是一半大小,即高 25 像素,宽 20 像素。然而有八个滤波器,所以图像变为八个通道深。
下一个操作是全连接层。这是一种不同的模式匹配过程。与在输入上滑动一个小窗口不同,这里为输入张量中的每个值都有一个权重。结果是指示输入与权重匹配程度的指标,在比较每个值之后。您可以将其视为全局模式匹配,其中您有一个理想的结果,您期望作为输入获得,输出是理想值(保存在权重中)与实际输入之间的接近程度。我们模型中的每个类都有自己的权重,因此“静音”,“未知”,“是”和“否”都有一个理想模式,并生成四个输出值。输入中有 4,000 个值(25 * 20 * 8),因此每个类由 4,000 个权重表示。
最后一层是一个 softmax 层。这有效地增加了最高输出和其最近竞争对手之间的差异,这不会改变它们的相对顺序(从全连接层产生最大值的类仍将保持最高),但有助于产生一个更有用的分数。这个分数通常非正式地被称为“概率”,但严格来说,如果没有更多关于输入数据实际混合的校准,你不能可靠地像那样使用它。例如,如果检测器中有更多的单词,那么像“反对建立教会主义”这样的不常见单词可能不太可能出现,而像“好的”这样的单词可能更有可能出现,但根据训练数据的分布,这可能不会反映在原始分数中。
除了这些主要层外,还有偏差被添加到全连接和卷积层的结果中,以帮助调整它们的输出,并在每个之后使用修正线性单元(ReLU)激活函数。ReLU 只是确保没有输出小于零,将任何负结果设置为零的最小值。这种类型的激活函数是使深度学习变得更加有效的突破之一:它帮助训练过程比网络本来会更快地收敛。
理解模型输出
模型的最终结果是 softmax 层的输出。这是四个数字,分别对应“沉默”,“未知”,“是”和“否”。这些值是每个类别的分数,具有最高分数的类别是模型的预测,分数代表模型对其预测的信心。例如,如果模型输出是[10, 4, 231, 80],它预测第三个类别“是”是最可能的结果,得分为 231。 (我们以它们的量化形式给出这些值,介于 0 和 255 之间,但因为这些只是相对分数,通常不需要将它们转换回它们的实值等价物。)
有一件棘手的事情是,这个结果是基于分析音频的最后一秒。如果我们每秒只运行一次,可能会得到一个话语,一半在上一秒,一半在当前秒。当模型只听到部分单词时,任何模型都不可能很好地识别单词,因此在这种情况下,单词识别会失败。为了克服这个问题,我们需要比每秒运行模型更频繁,以尽可能高的概率在我们的一秒窗口内捕捉到整个单词。实际上,我们发现我们必须每秒运行 10 到 15 次才能取得良好的结果。
如果我们得到这些结果如此迅速,我们如何决定何时得分足够高?我们实现了一个后处理类,它会随着时间平均分数,并仅在短时间内同一个单词的得分高时触发识别。您可以在RecognizeCommands 类中看到这个实现。这个类接收模型的原始结果,然后使用累积和平均算法来确定是否有任何类别已经超过了阈值。然后将这些后处理结果传递给CommandResponder以根据平台的输出能力采取行动。
模型参数都是从训练数据中学习的,但命令识别器使用的算法是手动创建的,所以所有的阈值——比如触发识别所需的得分值,或者需要的正结果时间窗口——都是手动选择的。这意味着不能保证它们是最佳的,所以如果在您自己的应用中看到不佳的结果,您可能希望尝试自己调整它们。
更复杂的语音识别模型通常使用能够接收流数据的模型(如递归神经网络),而不是我们在本章中展示的单层卷积网络。将流式处理嵌入到模型设计中意味着您无需进行后处理即可获得准确的结果,尽管这确实使训练变得更加复杂。
使用您自己的数据进行训练
您要构建的产品很可能不仅需要回答“是”和“否”,因此您需要训练一个对您关心的音频敏感的模型。我们之前使用的训练脚本旨在让您使用自己的数据创建自定义模型。这个过程中最困难的部分通常是收集足够大的数据集,并确保它适用于您的问题。我们在第十六章中讨论了数据收集和清理的一般方法,但本节涵盖了一些您可以训练自己的音频模型的方法。
语音命令数据集
train.py脚本默认下载了 Speech Commands 数据集。这是一个开源集合,包含超过 10 万个一秒钟的 WAV 文件,涵盖了许多不同说话者的各种短单词。它由 Google 分发,但话语是从世界各地的志愿者那里收集的。Aakanksha Chowdhery 等人的“Visual Wake Words Dataset”提供了更多细节。
除了“是”和“否”之外,数据集还包括另外八个命令词(“打开”,“关闭”,“上”,“下”,“左”,“右”,“停止”和“前进”),以及从“零”到“九”的十个数字。每个单词都有几千个示例。还有其他单词,比如“Marvin”,每个单词的示例要少得多。命令词旨在有足够的话语,以便您可以训练一个合理的模型来识别它们。其他单词旨在用于填充“未知”类别,因此模型可以发现当发出未经训练的单词时,而不是将其误认为是一个命令。
由于训练脚本使用了这个数据集,您可以轻松地训练一个模型,结合一些有很多示例的命令词。如果您使用训练集中存在的单词的逗号分隔列表更新--wanted_words参数,并从头开始运行训练,您应该会发现您可以创建一个有用的模型。需要注意的主要事项是,您要限制自己只使用这 10 个命令词和/或数字,否则您将没有足够的示例进行准确训练,并且如果您有超过两个想要的单词,则需要将--silence_percentage和--unknown_percentage值调低。这两个参数控制训练过程中混合了多少无声和未知样本。无声示例实际上并不是完全的沉默;相反,它们是从数据集的background文件夹中的 WAV 文件中随机选择的一秒钟的录制背景噪音片段。未知样本是从训练集中的任何单词中挑选出来的话语,但不在wanted_words列表中。这就是为什么数据集中有一些杂项单词,每个单词的话语相对较少;这让我们有机会认识到很多不同的单词实际上并不是我们正在寻找的单词。这在语音和音频识别中是一个特别的问题,因为我们的产品通常需要在可能从未在训练中遇到的环境中运行。仅在常见英语中就可能出现成千上万个不同的单词,为了有用,模型必须能够忽略那些它没有经过训练的单词。这就是为什么未知类别在实践中如此重要。
以下是使用现有数据集训练不同单词的示例:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="up,down,left,right" --silence_percentage=15 \
--unknown_percentage=15 --quantize=1
在您自己的数据集上训练
训练脚本的默认设置是使用 Speech Commands,但如果您有自己的数据集,可以使用--data_dir参数来使用它。您指向的目录应该像 Speech Commands 一样组织,每个包含一组 WAV 文件的类别都有一个子文件夹。您还需要一个特殊的background子文件夹,其中包含您的应用程序预计会遇到的背景噪音类型的较长的 WAV 录音。如果默认的一秒持续时间对您的用例不起作用,您还需要选择一个识别持续时间,并通过--sample_duration_ms参数指定。然后,您可以使用--wanted_words参数设置要识别的类别。尽管名称如此,这些类别可以是任何类型的音频事件,从玻璃破碎到笑声;只要您有足够的每个类别的 WAV 文件,训练过程应该与语音一样有效。
如果您在根目录*/tmp/my_wavs中有名为glass和laughter*的 WAV 文件夹,这是如何训练您自己的模型的:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--data_url="" --data_dir=/tmp/my_wavs/ --wanted_words="laughter,glass" \
--silence_percentage=25 --unknown_percentage=25 --quantize=1
通常最困难的部分是找到足够的数据。例如,事实证明,真实的玻璃破碎声与我们在电影中听到的声音效果非常不同。这意味着你需要找到现有的录音,或者安排自己录制一些。由于训练过程可能需要每个类别的成千上万个示例,并且它们需要涵盖在真实应用中可能发生的所有变化,这个数据收集过程可能令人沮丧、昂贵且耗时。
对于图像模型,一个常见的解决方案是使用迁移学习,即使用已经在大型公共数据集上训练过的模型,并使用其他数据对不同类别进行微调。这种方法在次要数据集中不需要像从头开始训练那样多的示例,而且通常会产生高准确度的结果。不幸的是,语音模型的迁移学习仍在研究中,但请继续关注。
如何录制您自己的音频
如果您需要捕捉您关心的单词的音频,如果您有一个提示说话者并将结果拆分为标记文件的工具,那将会更容易。Speech Commands 数据集是使用Open Speech Recording app录制的,这是一个托管应用程序,允许用户通过大多数常见的网络浏览器录制话语。作为用户,您将看到一个网页,首先要求您同意被录制,带有默认的谷歌协议,这是可以轻松更改的。同意后,您将被发送到一个具有录音控件的新页面。当您按下录制按钮时,单词将作为提示出现,您说的每个单词的音频将被记录。当所有请求的单词都被记录时,您将被要求将结果提交到服务器。
README 中有在 Google Cloud 上运行的说明,但这是一个用 Python 编写的 Flask 应用程序,因此您应该能够将其移植到其他环境中。如果您使用 Google Cloud,您需要更新app.yaml文件,指向您自己的存储桶,并提供您自己的随机会话密钥(这仅用于哈希,因此可以是任何值)。要自定义记录的单词,您需要编辑客户端 JavaScript中的一些数组:一个用于频繁重复的主要单词,一个用于次要填充词。
记录的文件以 OGG 压缩音频的形式存储在 Google Cloud 存储桶中,但训练需要 WAV 文件,因此您需要将它们转换。而且很可能您的一些录音包含错误,比如人们忘记说单词或说得太轻,因此在可能的情况下自动过滤出这些错误是有帮助的。如果您已经在BUCKET_NAME变量中设置了您的存储桶名称,您可以通过使用以下 bash 命令将文件复制到本地机器开始:
mkdir oggs
gsutil -m cp gs://${BUCKET_NAME}/* oggs/
压缩的 OGG 格式的一个好处是安静或无声的音频会生成非常小的文件,因此一个很好的第一步是删除那些特别小的文件,比如:
find ${BASEDIR}/oggs -iname "*.ogg" -size -5k -delete
我们发现将 OGG 转换为 WAV 的最简单方法是使用FFmpeg 项目,它提供了一个命令行工具。以下是一组命令,可以将一个目录中的所有 OGG 文件转换为我们需要的格式:
mkdir -p ${BASEDIR}/wavs
find ${BASEDIR}/oggs -iname "*.ogg" -print0 | \
xargs -0 basename -s .ogg | \
xargs -I {} ffmpeg -i ${BASEDIR}/oggs/{}.ogg -ar 16000 ${BASEDIR}/wavs/{}.wav
开放语音录制应用程序为每个单词记录超过一秒的音频。这确保了用户的话语被捕捉到,即使他们的时间比我们预期的早或晚一点。训练需要一秒钟的录音,并且最好是单词位于每个录音的中间。我们创建了一个小型开源实用程序,用于查看每个录音随时间的音量,以便正确居中并修剪音频,使其仅为一秒钟。在终端中输入以下命令来使用它:
git clone https://github.com/petewarden/extract_loudest_section \
/tmp/extract_loudest_section_github
pushd /tmp/extract_loudest_section_github
make
popd
mkdir -p ${BASEDIR}/trimmed_wavs
/tmp/extract_loudest_section/gen/bin/extract_loudest_section \
${BASEDIR}'/wavs/*.wav' ${BASEDIR}/trimmed_wavs/
这将为您提供一个格式正确且所需长度的文件夹,但训练过程需要将 WAV 文件按标签组织到子文件夹中。标签编码在每个文件的名称中,因此我们有一个示例 Python 脚本,它使用这些文件名将它们分类到适当的文件夹中。
数据增强
数据增强是另一种有效扩大训练数据并提高准确性的方法。在实践中,这意味着对记录的话语应用音频变换,然后再用于训练。这些变换可以包括改变音量、混入背景噪音,或者轻微修剪片段的开头或结尾。训练脚本默认应用所有这些变换,但您可以使用命令行参数调整它们的使用频率和强度。
警告
这种增强确实有助于使小数据集发挥更大作用,但它不能创造奇迹。如果你应用变换太强烈,可能会使训练输入变形得无法被人识别,这可能导致模型错误地开始触发与预期类别毫不相似的声音。
以下是如何使用其中一些命令行参数来控制增强:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=tiny_conv --window_stride=20 --preprocess=micro \
--wanted_words="yes,no" --silence_percentage=25 --unknown_percentage=25 \
--quantize=1 --background_volume=0.2 --background_frequency=0.7 \
--time_shift_ms=200
模型架构
我们之前训练的“是”/“否”模型旨在小而快速。它只有 18 KB,并且执行一次需要 400,000 次算术运算。为了符合这些约束条件,它牺牲了准确性。如果您正在设计自己的应用程序,您可能希望做出不同的权衡,特别是如果您试图识别超过两个类别。您可以通过修改models.py文件指定自己的模型架构,然后使用--model_architecture参数。您需要编写自己的模型创建函数,例如create_tiny_conv_model0,但要指定您想要的模型中的层。然后,您可以更新create_model0中的if语句,为您的架构命名,并在通过命令行传递架构参数时调用您的新创建函数。您可以查看一些现有的创建函数以获取灵感,包括如何处理辍学。如果您已添加了自己的模型代码,以下是如何调用它的方法:
python tensorflow/examples/speech_commands/train.py \
--model_architecture=my_model_name --window_stride=20 --preprocess=micro \
--wanted_words="yes,no" --silence_percentage=25 \--unknown_percentage=25 \
--quantize=1
总结
识别具有小内存占用的口语是一个棘手的现实世界问题,解决它需要我们与比简单示例更多的组件一起工作。大多数生产机器学习应用程序需要考虑问题,如特征生成、模型架构选择、数据增强、找到最适合的训练数据,以及如何将模型的结果转化为可操作信息。
根据产品的实际需求,需要考虑很多权衡,希望您现在了解一些选项,以便在从训练转向部署时使用。
在下一章中,我们将探讨如何使用不同类型的数据进行推断,尽管这种数据看起来比音频更复杂,但实际上却很容易处理。
第九章:人员检测:构建一个应用程序
如果你问人们哪种感官对他们的日常生活影响最大,很多人会回答视觉。
视觉是一种极其有用的感觉。它使无数自然生物能够在环境中导航,找到食物来源,并避免遇到危险。作为人类,视觉帮助我们认识朋友,解释象征性信息,并理解我们周围的世界,而无需过于接近。
直到最近,视觉的力量并不可用于机器。我们大多数的机器人只是用触摸和接近传感器在世界中探索,通过一系列碰撞获取其结构的知识。一眼之间,一个人可以向你描述一个物体的形状、属性和目的,而无需与之互动。机器人就没有这样的运气。视觉信息只是太混乱、无结构和难以解释了。
随着卷积神经网络的发展,构建能够“看到”的程序变得容易。受到哺乳动物视觉皮层结构的启发,CNN 学会了理解我们的视觉世界,将一个极其复杂的输入过滤成已知模式和形状的地图。这些部分的精确组合可以告诉我们在给定数字图像中存在的实体。
如今,视觉模型被用于许多不同的任务。自动驾驶车辆使用视觉来发现道路上的危险。工厂机器人使用摄像头捕捉有缺陷的零件。研究人员已经训练出可以从医学图像中诊断疾病的模型。而且你的智能手机很有可能在照片中识别出人脸,以确保它们焦点完美。
具有视觉的机器可以帮助改变我们的家庭和城市,自动化以前无法实现的家务。但视觉是一种亲密的感觉。我们大多数人不喜欢自己的行为被记录,或者我们的生活被实时传输到云端,这通常是 ML 推断的地方。
想象一下一个可以通过内置摄像头“看到”的家用电器。它可以是一个可以发现入侵者的安全系统,一个知道自己被遗弃的炉灶,或者一个在房间里没有人时自动关闭的电视。在这些情况下,隐私至关重要。即使没有人观看录像,互联网连接的摄像头嵌入在始终开启的设备中的安全隐患使它们对大多数消费者不吸引人。
但所有这些都随着 TinyML 而改变。想象一下一个智能炉灶,如果长时间不被注意就会关闭它的燃烧器。如果它可以“看到”附近有一个使用微型微控制器的厨师,而没有任何与互联网的连接,我们就可以获得智能设备的所有好处,而不会有任何隐私方面的妥协。
更重要的是,具有视觉功能的微型设备可以进入以前没有敢去的地方。基于微控制器的视觉系统由于其微小的功耗,可以在一个小电池上运行数月甚至数年。这些设备可以在丛林或珊瑚礁中计算濒危动物的数量,而无需在线。
同样的技术使得构建一个视觉传感器作为一个独立的电子组件成为可能。传感器输出 1 表示某个物体在视野中,输出 0 表示不在视野中,但它从不分享摄像头收集的任何图像数据。这种类型的传感器可以嵌入各种产品中,从智能家居系统到个人车辆。你的自行车可以在你后面有车时闪光灯。你的空调可以知道有人在家。而且因为图像数据从未离开独立的传感器,即使产品连接到互联网,也可以保证安全。
本章探讨的应用程序使用一个预训练的人体检测模型,在连接了摄像头的微控制器上运行,以知道何时有人在视野中。在第十章中,您将了解这个模型是如何工作的,以及如何训练自己的模型来检测您想要的内容。
阅读完本章后,您将了解如何在微控制器上处理摄像头数据,以及如何使用视觉模型运行推断并解释输出。您可能会惊讶于这实际上是多么容易!
我们正在构建什么
我们将构建一个嵌入式应用程序,该应用程序使用模型对摄像头捕获的图像进行分类。该模型经过训练,能够识别摄像头输入中是否存在人物。这意味着我们的应用程序将能够检测人物的存在或缺席,并相应地产生输出。
这本质上是我们稍早描述的智能视觉传感器。当检测到人物时,我们的示例代码将点亮 LED 灯—但您可以扩展它以控制各种项目。
注意
与我们在第七章中开发的应用程序一样,您可以在TensorFlow GitHub 存储库中找到此应用程序的源代码。
与之前的章节一样,我们首先浏览测试和应用程序代码,然后是使示例在各种设备上运行的逻辑。
我们提供了将应用程序部署到以下微控制器平台的说明:
注意
TensorFlow Lite 定期添加对新设备的支持,因此如果您想要使用的设备未在此处列出,请查看示例的README.md。如果在按照这些步骤操作时遇到问题,您也可以在那里查找更新的部署说明。
与之前的章节不同,您需要一些额外的硬件来运行这个应用程序。因为这两个开发板都没有集成摄像头,我们建议购买一个摄像头模块。您将在每个设备的部分中找到这些信息。
让我们从了解应用程序的结构开始。它比您想象的要简单得多。
应用程序架构
到目前为止,我们已经确定了嵌入式机器学习应用程序执行以下一系列操作:
-
获取输入。
-
对输入进行预处理,提取适合输入模型的特征。
-
对处理后的输入运行推断。
-
对模型的输出进行后处理以理解其含义。
-
使用得到的信息来实现所需的功能。
在第七章中,我们看到这种方法应用于唤醒词检测,其输入是音频。这一次,我们的输入将是图像数据。这听起来可能更复杂,但实际上比音频更容易处理。
图像数据通常表示为像素值数组。我们将从嵌入式摄像头模块获取图像数据,所有这些模块都以这种格式提供数据。我们的模型也期望其输入是像素值数组。因此,在将数据输入模型之前,我们不需要进行太多的预处理。
鉴于我们不需要进行太多的预处理,我们的应用程序将会相当简单。它从摄像头中获取数据快照,将其输入模型,并确定检测到了哪个输出类。然后以一种简单的方式显示结果。
在我们继续之前,让我们更多地了解一下我们将要使用的模型。
介绍我们的模型
在第七章中,我们了解到卷积神经网络是专门设计用于处理多维张量的神经网络,其中信息包含在相邻值组之间的关系中。它们特别适合处理图像数据。
我们的人体检测模型是一个卷积神经网络,训练于Visual Wake Words 数据集。该数据集包含 115,000 张图像,每张图像都标记了是否包含人体。
该模型大小为 250 KB,比我们的语音模型大得多。除了占用更多内存外,这种额外的大小意味着运行单个推断需要更长的时间。
该模型接受 96×96 像素的灰度图像作为输入。每个图像都以形状为(96, 96, 1)的 3D 张量提供,其中最后一个维度包含一个表示单个像素的 8 位值。该值指定像素的阴影,范围从 0(完全黑色)到 255(完全白色)。
我们的摄像头模块可以以各种分辨率返回图像,因此我们需要确保它们被调整为 96×96 像素。我们还需要将全彩图像转换为灰度图像,以便与模型配合使用。
您可能认为 96×96 像素听起来像是一个很小的分辨率,但它将足以让我们在每个图像中检测到一个人。处理图像的模型通常接受令人惊讶地小的分辨率。增加模型的输入尺寸会带来递减的回报,而网络的复杂性会随着输入规模的增加而大幅增加。因此,即使是最先进的图像分类模型通常也只能处理最大为 320×320 像素的图像。
模型输出两个概率:一个指示输入中是否存在人的概率,另一个指示是否没有人的概率。概率范围从 0 到 255。
我们的人体检测模型使用了MobileNet架构,这是一个为移动手机等设备设计的用于图像分类的经过广泛测试的架构。在第十章中,您将学习如何将该模型适配到微控制器上,并且如何训练您自己的模型。现在,让我们继续探索我们的应用程序是如何工作的。
所有的组件
图 9-1 显示了我们人体检测应用程序的结构。
图 9-1。我们人体检测应用程序的组件
正如我们之前提到的,这比唤醒词应用程序要简单得多,因为我们可以直接将图像数据传递到模型中,无需预处理。
另一个让事情简单的方面是我们不对模型的输出进行平均。我们的唤醒词模型每秒运行多次,因此我们必须对其输出进行平均以获得稳定的结果。我们的人体检测模型更大,推断时间更长。这意味着不需要对其输出进行平均。
代码有五个主要部分:
主循环
与其他示例一样,我们的应用程序在一个连续循环中运行。然而,由于我们的模型更大更复杂,因此推断的运行时间会更长。根据设备的不同,我们可以预期每隔几秒进行一次推断,而不是每秒进行多次推断。
图像提供者
该组件从摄像头捕获图像数据并将其写入输入张量。捕获图像的方法因设备而异,因此该组件可以被覆盖和自定义。
TensorFlow Lite 解释器
解释器运行 TensorFlow Lite 模型,将输入图像转换为一组概率。
模型
该模型作为数据数组包含在内,并由解释器运行。250 KB 的模型太大了,无法提交到 TensorFlow GitHub 存储库。因此,在构建项目时,Makefile 会下载它。如果您想查看,可以自行下载tf_lite_micro_person_data_grayscale.zip。
检测响应器
检测响应器接收模型输出的概率,并使用设备的输出功能来显示它们。我们可以为不同的设备类型进行覆盖。在我们的示例代码中,它将点亮 LED,但您可以扩展它以执行几乎任何操作。
为了了解这些部分如何配合,我们将查看它们的测试。
通过测试
这个应用程序非常简单,因为只有几个测试需要进行。您可以在GitHub 存储库中找到它们:
展示如何对表示单个图像的数组运行推断
展示如何使用图像提供程序捕获图像
展示如何使用检测响应器输出检测结果
让我们从探索person_detection_test.cc开始,看看如何对图像数据运行推断。因为这是我们走过的第三个示例,这段代码应该感觉相当熟悉。您已经在成为嵌入式 ML 开发人员的道路上取得了很大进展!
基本流程
首先是person_detection_test.cc。我们首先引入模型需要的操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_AVERAGE_POOL_2D();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们定义一个适合模型大小的张量区域。通常情况下,这个数字是通过试错确定的:
const int tensor_arena_size = 70 * 1024;
uint8_t tensor_arena[tensor_arena_size];
然后我们进行典型的设置工作,准备解释器运行,包括使用MicroMutableOpResolver注册必要的操作:
// Set up logging.
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_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_person_detect_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);
}
// Pull in only the operation implementations we need.
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_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_AVERAGE_POOL_2D,
tflite::ops::micro::Register_AVERAGE_POOL_2D());
// 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();
我们的下一步是检查输入张量。我们检查它是否具有预期数量的维度,以及其维度是否适当:
// 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(kNumRows, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kNumCols, input->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(kNumChannels, input->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, input->type);
从中我们可以看到,输入技术上是一个 5D 张量。第一个维度只是包含一个元素的包装器。接下来的两个维度表示图像像素的行和列。最后一个维度保存用于表示每个像素的颜色通道的数量。
告诉我们预期维度的常量kNumRows、kNumCols和kNumChannels在model_settings.h中定义。它们看起来像这样:
constexpr int kNumCols = 96;
constexpr int kNumRows = 96;
constexpr int kNumChannels = 1;
如您所见,模型预计接受一个 96×96 像素的位图。图像将是灰度的,每个像素有一个颜色通道。
接下来在代码中,我们使用简单的for循环将测试图像复制到输入张量中:
// Copy an image with a person into the memory area used for the input.
const uint8_t* person_data = g_person_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = person_data[i];
}
存储图像数据的变量g_person_data由person_image_data.h定义。为了避免向存储库添加更多大文件,数据本身会在首次运行测试时作为tf_lite_micro_person_data_grayscale.zip的一部分与模型一起下载。
在我们填充了输入张量之后,我们运行推断。这和以往一样简单:
// 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);
现在我们检查输出张量,确保它具有预期的大小和形状:
TfLiteTensor* output = interpreter.output(0);
TF_LITE_MICRO_EXPECT_EQ(4, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(1, output->dims->data[2]);
TF_LITE_MICRO_EXPECT_EQ(kCategoryCount, output->dims->data[3]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteUInt8, output->type);
模型的输出有四个维度。前三个只是包装器,围绕第四个维度,其中包含模型训练的每个类别的一个元素。
类别的总数作为常量kCategoryCount可用,它位于model_settings.h中,还有一些其他有用的值:
constexpr int kCategoryCount = 3;
constexpr int kPersonIndex = 1;
constexpr int kNotAPersonIndex = 2;
extern const char* kCategoryLabels[kCategoryCount];
正如kCategoryCount所示,输出中有三个类别。第一个恰好是一个未使用的类别,我们可以忽略。“人”类别排在第二位,我们可以从常量kPersonIndex中存储的索引中看到。“不是人”类别排在第三位,其索引由kNotAPersonIndex显示。
还有一个类别标签数组kCategoryLabels,在model_settings.cc中实现:
const char* kCategoryLabels[kCategoryCount] = {
"unused",
"person",
"notperson",
};
接下来的代码块记录“人”和“非人”分数,并断言“人”分数更高——因为我们传入的是一个人的图像:
uint8_t person_score = output->data.uint8[kPersonIndex];
uint8_t no_person_score = output->data.uint8[kNotAPersonIndex];
error_reporter->Report(
"person data. person score: %d, no person score: %d\n", person_score,
no_person_score);
TF_LITE_MICRO_EXPECT_GT(person_score, no_person_score);
由于输出张量的唯一数据内容是表示类别分数的三个uint8值,第一个值未使用,我们可以通过output->data.uint8[kPersonIndex]和output->data.uint8[kNotAPersonIndex]直接访问分数。作为uint8类型,它们的最小值为 0,最大值为 255。
注意
如果“人”和“非人”分数相似,这可能意味着模型对其预测不太有信心。在这种情况下,您可能选择考虑结果不确定。
接下来,我们测试没有人的图像,由g_no_person_data持有:
const uint8_t* no_person_data = g_no_person_data;
for (int i = 0; i < input->bytes; ++i) {
input->data.uint8[i] = no_person_data[i];
}
推理运行后,我们断言“非人”分数更高:
person_score = output->data.uint8[kPersonIndex];
no_person_score = output->data.uint8[kNotAPersonIndex];
error_reporter->Report(
"no person data. person score: %d, no person score: %d\n", person_score,
no_person_score);
TF_LITE_MICRO_EXPECT_GT(no_person_score, person_score);
正如您所看到的,这里没有什么花哨的东西。我们可能正在输入图像而不是标量或频谱图,但推理过程与我们以前看到的类似。
运行测试同样简单。只需从 TensorFlow 存储库的根目录发出以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_person_detection_test
第一次运行测试时,将下载模型和图像数据。如果您想查看已下载的文件,可以在tensorflow/lite/micro/tools/make/downloads/person_model_grayscale中找到它们。
接下来,我们检查图像提供程序的接口。
图像提供程序
图像提供程序负责从摄像头获取数据,并以适合写入模型输入张量的格式返回数据。文件image_provider.h定义了其接口:
TfLiteStatus GetImage(tflite::ErrorReporter* error_reporter, int image_width,
int image_height, int channels, uint8_t* image_data);
由于其实际实现是特定于平台的,因此在person_detection/image_provider.cc中有一个返回虚拟数据的参考实现。
image_provider_test.cc中的测试调用此参考实现以展示其用法。我们的首要任务是创建一个数组来保存图像数据。这发生在以下行中:
uint8_t image_data[kMaxImageSize];
常量kMaxImageSize来自我们的老朋友model_settings.h。
设置了这个数组后,我们可以调用GetImage()函数从摄像头捕获图像:
TfLiteStatus get_status =
GetImage(error_reporter, kNumCols, kNumRows, kNumChannels, image_data);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, get_status);
TF_LITE_MICRO_EXPECT_NE(image_data, nullptr);
我们使用ErrorReporter实例、我们想要的列数、行数和通道数以及指向我们的image_data数组的指针来调用它。该函数将把图像数据写入此数组。我们可以检查函数的返回值来确定捕获过程是否成功;如果有问题,它将设置为kTfLiteError,否则为kTfLiteOk。
最后,测试通过返回的数据以显示所有内存位置都是可读的。即使图像在技术上具有行、列和通道,但实际上数据被展平为一维数组:
uint32_t total = 0;
for (int i = 0; i < kMaxImageSize; ++i) {
total += image_data[i];
}
要运行此测试,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_image_provider_test
我们将在本章后面查看image_provider.cc的特定于设备的实现;现在,让我们看一下检测响应器的接口。
检测响应器
我们的最终测试展示了检测响应器的使用方式。这是负责传达推理结果的代码。其接口在detection_responder.h中定义,测试在detection_responder_test.cc中。
接口非常简单:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score);
我们只需使用“人”和“非人”类别的分数调用它,它将根据情况决定要做什么。
detection_responder.cc中的参考实现只是记录这些值。detection_responder_test.cc中的测试调用该函数几次:
RespondToDetection(error_reporter, 100, 200);
RespondToDetection(error_reporter, 200, 100);
要运行测试并查看输出,请使用以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
test_detection_responder_test
我们已经探索了所有测试和它们所练习的接口。现在让我们走一遍程序本身。
检测人员
应用程序的核心功能位于main_functions.cc中。它们简短而简洁,我们在测试中已经看到了它们的大部分逻辑。
首先,我们引入模型所需的所有操作:
namespace tflite {
namespace ops {
namespace micro {
TfLiteRegistration* Register_DEPTHWISE_CONV_2D();
TfLiteRegistration* Register_CONV_2D();
TfLiteRegistration* Register_AVERAGE_POOL_2D();
} // namespace micro
} // namespace ops
} // namespace tflite
接下来,我们声明一堆变量来保存重要的移动部件:
tflite::ErrorReporter* g_error_reporter = nullptr;
const tflite::Model* g_model = nullptr;
tflite::MicroInterpreter* g_interpreter = nullptr;
TfLiteTensor* g_input = nullptr;
之后,我们为张量操作分配一些工作内存:
constexpr int g_tensor_arena_size = 70 * 1024;
static uint8_t tensor_arena[kTensorArenaSize];
在setup()函数中,在任何其他操作发生之前运行,我们创建一个错误报告器,加载我们的模型,设置一个解释器实例,并获取模型输入张量的引用:
void setup() {
// Set up logging.
static tflite::MicroErrorReporter micro_error_reporter;
g_error_reporter = µ_error_reporter;
// Map the model into a usable data structure. This doesn't involve any
// copying or parsing, it's a very lightweight operation.
g_model = tflite::GetModel(g_person_detect_model_data);
if (g_model->version() != TFLITE_SCHEMA_VERSION) {
g_error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
g_model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// Pull in only the operation implementations we need.
static 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_CONV_2D,
tflite::ops::micro::Register_CONV_2D());
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_AVERAGE_POOL_2D,
tflite::ops::micro::Register_AVERAGE_POOL_2D());
// Build an interpreter to run the model with.
static tflite::MicroInterpreter static_interpreter(
model, micro_mutable_op_resolver, tensor_arena, kTensorArenaSize,
error_reporter);
interpreter = &static_interpreter;
// Allocate memory from the tensor_arena for the model's tensors.
TfLiteStatus allocate_status = interpreter->AllocateTensors();
if (allocate_status != kTfLiteOk) {
error_reporter->Report("AllocateTensors() failed");
return;
}
// Get information about the memory area to use for the model's input.
input = interpreter->input(0);
}
代码的下一部分在程序的主循环中被不断调用。它首先使用图像提供程序获取图像,通过传递一个输入张量的引用,使图像直接写入其中:
void loop() {
// Get image from provider.
if (kTfLiteOk != GetImage(g_error_reporter, kNumCols, kNumRows, kNumChannels,
g_input->data.uint8)) {
g_error_reporter->Report("Image capture failed.");
}
然后运行推理,获取输出张量,并从中读取“人”和“无人”分数。这些分数被传递到检测响应器的RespondToDetection()函数中:
// Run the model on this input and make sure it succeeds.
if (kTfLiteOk != g_interpreter->Invoke()) {
g_error_reporter->Report("Invoke failed.");
}
TfLiteTensor* output = g_interpreter->output(0);
// Process the inference results.
uint8_t person_score = output->data.uint8[kPersonIndex];
uint8_t no_person_score = output->data.uint8[kNotAPersonIndex];
RespondToDetection(g_error_reporter, person_score, no_person_score);
}
在RespondToDetection()完成输出结果后,loop()函数将返回,准备好被程序的主循环再次调用。
循环本身在程序的main()函数中定义,该函数位于main.cc中。它一次调用setup()函数,然后重复调用loop()函数,直到无限循环:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
这就是整个程序!这个例子很棒,因为它表明与复杂的机器学习模型一起工作可以出奇地简单。模型包含了所有的复杂性,我们只需要提供数据给它。
在我们继续之前,您可以在本地运行程序进行尝试。图像提供程序的参考实现只返回虚拟数据,因此您不会得到有意义的识别结果,但至少可以看到代码在运行。
首先,使用以下命令构建程序:
make -f tensorflow/lite/micro/tools/make/Makefile person_detection
构建完成后,您可以使用以下命令运行示例:
tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/ \
person_detection
您会看到程序的输出在屏幕上滚动,直到按下 Ctrl-C 终止它:
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
person score:129 no person score 202
在接下来的部分中,我们将详细介绍特定设备的代码,该代码将捕获摄像头图像并在每个平台上输出结果。我们还展示了如何部署和运行此代码。
部署到微控制器
在这一部分中,我们将代码部署到两个熟悉的设备上:
这次有一个很大的不同:因为这两个设备都没有内置摄像头,我们建议您为您使用的任何设备购买摄像头模块。每个设备都有自己的image_provider.cc实现,它与摄像头模块进行接口,以捕获图像。detection_responder.cc中还有特定于设备的输出代码。
这很简单,所以它将是一个很好的模板,用来创建你自己的基于视觉的 ML 应用程序。
让我们开始探索 Arduino 的实现。
Arduino
作为 Arduino 板,Arduino Nano 33 BLE Sense 可以访问大量兼容的第三方硬件和库的生态系统。我们使用了一个专为与 Arduino 配合使用而设计的第三方摄像头模块,以及一些 Arduino 库,这些库将与我们的摄像头模块进行接口,并理解其输出的数据。
要购买哪种摄像头模块
这个例子使用Arducam Mini 2MP Plus摄像头模块。它很容易连接到 Arduino Nano 33 BLE Sense,并且可以由 Arduino 板的电源供应提供电力。它有一个大镜头,能够捕获高质量的 200 万像素图像 - 尽管我们将使用其内置的图像重缩放功能来获得较小的分辨率。它并不特别节能,但其高质量的图像使其非常适合构建图像捕获应用程序,比如用于记录野生动物。
在 Arduino 上捕获图像
我们通过一些引脚将 Arducam 模块连接到 Arduino 板。为了获取图像数据,我们从 Arduino 板向 Arducam 发送一个命令,指示它捕获图像。Arducam 将执行此操作,将图像存储在其内部数据缓冲区中。然后,我们发送进一步的命令,允许我们从 Arducam 的内部缓冲区中读取图像数据并将其存储在 Arduino 的内存中。为了执行所有这些操作,我们使用官方的 Arducam 库。
Arducam 相机模块具有一颗 200 万像素的图像传感器,分辨率为 1920×1080。我们的人体检测模型的输入尺寸仅为 96×96,因此我们不需要所有这些数据。事实上,Arduino 本身没有足够的内存来容纳一张 200 万像素的图像,其大小将达到几兆字节。
幸运的是,Arducam 硬件具有将输出调整为更小分辨率的能力,即 160×120 像素。我们可以通过在代码中仅保留中心的 96×96 像素来轻松将其裁剪为 96×96。然而,为了复杂化问题,Arducam 的调整大小输出使用了 JPEG,这是一种常见的图像压缩格式。我们的模型需要一个像素数组,而不是一个 JPEG 编码的图像,这意味着我们需要在使用之前解码 Arducam 的输出。我们可以使用一个开源库来实现这一点。
我们的最后任务是将 Arducam 的彩色图像输出转换为灰度,这是我们的人体检测模型所期望的。我们将灰度数据写入我们模型的输入张量。
图像提供程序实现在arduino/image_provider.cc中。我们不会解释其每个细节,因为代码是特定于 Arducam 相机模块的。相反,让我们以高层次的方式来看一下发生了什么。
GetImage()函数是图像提供程序与外部世界的接口。在我们的应用程序主循环中调用它以获取一帧图像数据。第一次调用时,我们需要初始化相机。这通过调用InitCamera()函数来实现,如下所示:
static bool g_is_camera_initialized = false;
if (!g_is_camera_initialized) {
TfLiteStatus init_status = InitCamera(error_reporter);
if (init_status != kTfLiteOk) {
error_reporter->Report("InitCamera failed");
return init_status;
}
g_is_camera_initialized = true;
}
InitCamera()函数在image_provider.cc中进一步定义。我们不会在这里详细介绍它,因为它非常特定于设备,如果您想在自己的代码中使用它,只需复制粘贴即可。它配置 Arduino 的硬件以与 Arducam 通信,然后确认通信正常工作。最后,它指示 Arducam 输出 160×120 像素的 JPEG 图像。
GetImage()函数调用的下一个函数是PerformCapture():
TfLiteStatus capture_status = PerformCapture(error_reporter);
我们也不会详细介绍这个函数。它只是向相机模块发送一个命令,指示其捕获图像并将图像数据存储在其内部缓冲区中。然后,它等待确认图像已被捕获。此时,Arducam 的内部缓冲区中有图像数据,但 Arduino 本身还没有任何图像数据。
接下来我们调用的函数是ReadData():
TfLiteStatus read_data_status = ReadData(error_reporter);
ReadData()函数使用更多的命令从 Arducam 获取图像数据。函数运行后,全局变量jpeg_buffer将填充从相机检索到的 JPEG 编码图像数据。
当我们有 JPEG 编码的图像时,我们的下一步是将其解码为原始图像数据。这发生在DecodeAndProcessImage()函数中:
TfLiteStatus decode_status = DecodeAndProcessImage(
error_reporter, image_width, image_height, image_data);
该函数使用一个名为 JPEGDecoder 的库来解码 JPEG 数据,并直接将其写入模型的输入张量。在此过程中,它裁剪图像,丢弃一些 160×120 的数据,使剩下的只有 96×96 像素,大致位于图像中心。它还将图像的 16 位颜色表示减少到 8 位灰度。
在图像被捕获并存储在输入张量中后,我们准备运行推理。接下来,我们展示模型的输出是如何显示的。
在 Arduino 上响应检测
Arduino Nano 33 BLE Sense 内置了 RGB LED,这是一个包含独立红色、绿色和蓝色 LED 的单一组件,您可以分别控制它们。检测响应器的实现在每次推理运行时闪烁蓝色 LED。当检测到人时,点亮绿色 LED;当未检测到人时,点亮红色 LED。
实现在arduino/detection_responder.cc中。让我们快速浏览一下。
RespondToDetection()函数接受两个分数,一个用于“人”类别,另一个用于“非人”。第一次调用时,它设置蓝色、绿色和黄色 LED 为输出:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score) {
static bool is_initialized = false;
if (!is_initialized) {
pinMode(led_green, OUTPUT);
pinMode(led_blue, OUTPUT);
is_initialized = true;
}
接下来,为了指示推理刚刚完成,我们关闭所有 LED,然后非常简要地闪烁蓝色 LED:
// Note: The RGB LEDs on the Arduino Nano 33 BLE
// Sense are on when the pin is LOW, off when HIGH.
// Switch the person/not person LEDs off
digitalWrite(led_green, HIGH);
digitalWrite(led_red, HIGH);
// Flash the blue LED after every inference.
digitalWrite(led_blue, LOW);
delay(100);
digitalWrite(led_blue, HIGH);
您会注意到,与 Arduino 内置 LED 不同,这些 LED 使用LOW打开,使用HIGH关闭。这只是 LED 连接到板上的方式的一个因素。
接下来,我们根据哪个类别的分数更高来打开和关闭适当的 LED:
// Switch on the green LED when a person is detected,
// the red when no person is detected
if (person_score > no_person_score) {
digitalWrite(led_green, LOW);
digitalWrite(led_red, HIGH);
} else {
digitalWrite(led_green, HIGH);
digitalWrite(led_red, LOW);
}
最后,我们使用error_reporter实例将分数输出到串行端口:
error_reporter->Report("Person score: %d No person score: %d", person_score,
no_person_score);
}
就是这样!函数的核心是一个基本的if语句,您可以轻松使用类似的逻辑来控制其他类型的输出。将如此复杂的视觉输入转换为一个布尔输出“人”或“非人”是非常令人兴奋的事情。
运行示例
运行此示例比我们其他 Arduino 示例更复杂,因为我们需要将 Arducam 连接到 Arduino 板。我们还需要安装和配置与 Arducam 接口并解码其 JPEG 输出的库。但不用担心,这仍然非常简单!
要部署此示例,我们需要以下内容:
-
一个 Arduino Nano 33 BLE Sense 板
-
一个 Arducam Mini 2MP Plus
-
跳线(和可选的面包板)
-
一根 Micro-USB 电缆
-
Arduino IDE
我们的第一个任务是使用跳线连接 Arducam 到 Arduino。这不是一本电子书,所以我们不会详细介绍使用电缆的细节。相反,表 9-1 显示了引脚应该如何连接。每个设备上都标有引脚标签。
表 9-1。Arducam Mini 2MP Plus 到 Arduino Nano 33 BLE Sense 的连接
| Arducam 引脚 | Arduino 引脚 |
|---|---|
| CS | D7(未标记,紧挨 D6 右侧) |
| MOSI | D11 |
| MISO | D12 |
| SCK | D13 |
| GND | GND(任何一个标记为 GND 的引脚都可以) |
| VCC | 3.3 V |
| SDA | A4 |
| SCL | A5 |
设置硬件后,您可以继续安装软件。
提示
建立过程可能会有所变化,所以请查看README.md获取最新说明。
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用。如果您尚未安装该库,请打开 Arduino IDE 并从“工具”菜单中选择“管理库”。在弹出的窗口中,搜索并安装名为Arduino_TensorFlowLite的库。您应该能够使用最新版本,但如果遇到问题,本书测试过的版本是 1.14-ALPHA。
注意
您还可以从*.zip*文件安装库,您可以从 TensorFlow Lite 团队下载或使用 TensorFlow Lite for Microcontrollers Makefile 自动生成。如果您更喜欢后者,请参阅附录 A。
安装完库后,person_detection示例将显示在“文件”菜单下的“示例→Arduino_TensorFlowLite”中,如图 9-2 所示。
图 9-2。示例菜单
点击“person_detection”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件person_detection相当于我们之前介绍的main_functions.cc。
注意
“运行示例”已经解释了 Arduino 示例的结构,所以我们这里不再重复覆盖。
除了 TensorFlow 库,我们还需要安装另外两个库:
-
Arducam 库,以便我们的代码可以与硬件进行交互
-
JPEGDecoder 库,以便我们可以解码 JPEG 编码的图像
Arducam Arduino 库可从GitHub获取。要安装它,请下载或克隆存储库。接下来,将其ArduCAM子目录复制到Arduino/libraries目录中。要找到您机器上的libraries目录,请在 Arduino IDE 的首选项窗口中检查 Sketchbook 位置。
下载库后,您需要编辑其中一个文件,以确保为 Arducam Mini 2MP Plus 进行配置。为此,请打开Arduino/libraries/ArduCAM/memorysaver.h。
您会看到一堆#define语句。确保它们都被注释掉,除了#define OV2640_MINI_2MP_PLUS,如此处所示:
//Step 1: select the hardware platform, only one at a time
//#define OV2640_MINI_2MP
//#define OV3640_MINI_3MP
//#define OV5642_MINI_5MP
//#define OV5642_MINI_5MP_BIT_ROTATION_FIXED
#define OV2640_MINI_2MP_PLUS
//#define OV5642_MINI_5MP_PLUS
//#define OV5640_MINI_5MP_PLUS
保存文件后,您已经完成了 Arducam 库的配置。
提示
示例是使用 Arducam 库的提交#e216049 开发的。如果您在使用库时遇到问题,可以尝试下载这个特定的提交,以确保您使用的是完全相同的代码。
下一步是安装 JPEGDecoder 库。您可以在 Arduino IDE 中完成这个操作。在工具菜单中,选择管理库选项并搜索 JPEGDecoder。您应该安装库的 1.8.0 版本。
安装完库之后,您需要配置它以禁用一些与 Arduino Nano 33 BLE Sense 不兼容的可选组件。打开Arduino/libraries/JPEGDecoder/src/User_Config.h,确保#define LOAD_SD_LIBRARY和#define LOAD_SDFAT_LIBRARY都被注释掉,如文件中的摘录所示:
// Comment out the next #defines if you are not using an SD Card to store
// the JPEGs
// Commenting out the line is NOT essential but will save some FLASH space if
// SD Card access is not needed. Note: use of SdFat is currently untested!
//#define LOAD_SD_LIBRARY // Default SD Card library
//#define LOAD_SDFAT_LIBRARY // Use SdFat library instead, so SD Card SPI can
// be bit bashed
保存文件后,安装库就完成了。现在您已经准备好运行人员检测应用程序了!
首先,通过 USB 将 Arduino 设备插入。确保在工具菜单中从板下拉列表中选择正确的设备类型,如图 9-3 所示。
图 9-3. 板下拉列表
如果您的设备名称不在列表中显示,您需要安装其支持包。要做到这一点,请点击 Boards Manager。在弹出的窗口中搜索您的设备并安装相应支持包的最新版本。
在工具菜单中,还要确保设备的端口在端口下拉列表中被选中,如图 9-4 所示。
图 9-4. 端口下拉列表
最后,在 Arduino 窗口中,点击上传按钮(在图 9-5 中用白色标出)来编译并上传代码到您的 Arduino 设备。
图 9-5. 上传按钮
一旦上传成功完成,程序将运行。
要测试它,首先将设备的摄像头对准明显不是人的东西,或者只是遮住镜头。下次蓝色 LED 闪烁时,设备将从摄像头捕获一帧并开始运行推理。由于我们用于人员检测的视觉模型相对较大,这将需要很长时间的推理——在撰写本文时大约需要 19 秒,尽管自那时起 TensorFlow Lite 可能已经变得更快。
当推断完成时,结果将被翻译为另一个 LED 被点亮。您将相机对准了一个不是人的东西,所以红色 LED 应该点亮。
现在,尝试将设备的相机对准自己!下次蓝色 LED 闪烁时,设备将捕获另一幅图像并开始运行推断。大约 19 秒后,绿色 LED 应该亮起。
请记住,在每次推断之前,图像数据都会被捕获为快照,每当蓝色 LED 闪烁时。在那一刻相机对准的东西将被馈送到模型中。在下一次捕获图像时,相机对准的位置并不重要,当蓝色 LED 再次闪烁时,图像将被捕获。
如果您得到看似不正确的结果,请确保您处于光线良好的环境中。您还应确保相机的方向正确,引脚朝下,以便捕获的图像是正确的方式——该模型没有经过训练以识别颠倒的人。此外,值得记住这是一个微小的模型,它以小尺寸换取准确性。它工作得非常好,但并非 100%准确。
您还可以通过 Arduino 串行监视器查看推断的结果。要做到这一点,请从“工具”菜单中打开串行监视器。您将看到一个详细的日志,显示应用程序运行时发生的情况。还有一个有趣的功能是勾选“显示时间戳”框,这样您就可以看到每个过程需要多长时间:
14:17:50.714 -> Starting capture
14:17:50.714 -> Image captured
14:17:50.784 -> Reading 3080 bytes from ArduCAM
14:17:50.887 -> Finished reading
14:17:50.887 -> Decoding JPEG and converting to greyscale
14:17:51.074 -> Image decoded and processed
14:18:09.710 -> Person score: 246 No person score: 66
从这个日志中,我们可以看到从相机模块捕获和读取图像数据大约需要 170 毫秒,解码 JPEG 并将其转换为灰度需要 180 毫秒,运行推断需要 18.6 秒。
进行自己的更改
现在您已部署了基本应用程序,请尝试玩耍并对代码进行一些更改。只需在 Arduino IDE 中编辑文件并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的几件事:
-
修改检测响应器,使其忽略模糊的输入,即“人”和“无人”得分之间没有太大差异的情况。
-
使用人员检测的结果来控制其他组件,如额外的 LED 或伺服。
-
构建一个智能安全摄像头,通过存储或传输图像来实现,但仅限于包含人物的图像。
SparkFun Edge
SparkFun Edge 板经过优化,以实现低功耗。当与同样高效的相机模块配对时,它是构建视觉应用程序的理想平台,这些应用程序将在电池供电时运行。通过板上的排线适配器轻松插入相机模块。
要购买哪种相机模块
此示例使用 SparkFun 的Himax HM01B0 分支相机模块。它基于一个 320×320 像素的图像传感器,当以每秒 30 帧的速度捕获时,消耗极少的功率:不到 2 mW。
在 SparkFun Edge 上捕获图像
要开始使用 Himax HM01B0 相机模块捕获图像,我们首先必须初始化相机。完成此操作后,我们可以在需要新图像时从相机读取一帧。一帧是一个表示相机当前所看到的内容的字节数组。
使用相机将涉及大量使用 Ambiq Apollo3 SDK 和 HM01B0 驱动程序,后者作为构建过程的一部分下载,位于sparkfun_edge/himax_driver中。
图像提供程序实现在sparkfun_edge/image_provider.cc中。我们不会解释其每个细节,因为代码是针对 SparkFun 板和 Himax 相机模块的。相反,让我们以高层次的方式来看看发生了什么。
GetImage() 函数是图像提供程序与世界的接口。它在我们的应用程序的主循环中被调用以获取一帧图像数据。第一次调用时,我们需要初始化摄像头。这通过调用 InitCamera() 函数来实现,如下所示:
// Capture single frame. Frame pointer passed in to reduce memory usage. This
// allows the input tensor to be used instead of requiring an extra copy.
TfLiteStatus GetImage(tflite::ErrorReporter* error_reporter, int frame_width,
int frame_height, int channels, uint8_t* frame) {
if (!g_is_camera_initialized) {
TfLiteStatus init_status = InitCamera(error_reporter);
if (init_status != kTfLiteOk) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
return init_status;
}
如果 InitCamera() 返回除了 kTfLiteOk 状态之外的任何内容,我们会打开板上的红色 LED(使用 am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED))来指示问题。这对于调试很有帮助。
InitCamera() 函数在 image_provider.cc 中进一步定义。我们不会在这里详细介绍它,因为它非常特定于设备,如果您想在自己的代码中使用它,只需复制粘贴即可。
它调用一堆 Apollo3 SDK 函数来配置微控制器的输入和输出,以便它可以与摄像头模块通信。它还启用了中断,这是摄像头用来发送新图像数据的机制。当这一切设置完成后,它使用摄像头驱动程序打开摄像头,并配置它开始持续捕获图像。
摄像头模块具有自动曝光功能,它会在捕获帧时自动校准曝光设置。为了让它有机会在我们尝试执行推理之前校准,GetImage() 函数的下一部分使用摄像头驱动程序的 hm01b0_blocking_read_oneframe_scaled() 函数捕获几帧图像。我们不对捕获的数据做任何处理;我们只是为了让摄像头模块的自动曝光功能有一些材料可以使用:
// Drop a few frames until auto exposure is calibrated.
for (int i = 0; i < kFramesToInitialize; ++i) {
hm01b0_blocking_read_oneframe_scaled(frame, frame_width, frame_height,
channels);
}
g_is_camera_initialized = true;
}
设置完成后,GetImage() 函数的其余部分非常简单。我们只需调用 hm01b0_blocking_read_oneframe_scaled() 来捕获一幅图像:
hm01b0_blocking_read_oneframe_scaled(frame, frame_width, frame_height,
channels);
当应用程序的主循环中调用 GetImage() 时,frame 变量是指向我们输入张量的指针,因此数据直接由摄像头驱动程序写入到为输入张量分配的内存区域。我们还指定了我们想要的宽度、高度和通道数。
通过这个实现,我们能够从我们的摄像头模块中捕获图像数据。接下来,让我们看看如何响应模型的输出。
在 SparkFun Edge 上响应检测
检测响应器的实现与我们的唤醒词示例的命令响应器非常相似。每次运行推理时,它会切换设备的蓝色 LED。当检测到一个人时,它会点亮绿色 LED,当没有检测到一个人时,它会点亮黄色 LED。
实现在 sparkfun_edge/detection_responder.cc 中。让我们快速浏览一下。
RespondToDetection() 函数接受两个分数,一个用于“人”类别,另一个用于“非人”。第一次调用时,它会为蓝色、绿色和黄色 LED 设置输出:
void RespondToDetection(tflite::ErrorReporter* error_reporter,
uint8_t person_score, uint8_t no_person_score) {
static bool is_initialized = false;
if (!is_initialized) {
// Setup LED's as outputs. Leave red LED alone since that's an error
// indicator for sparkfun_edge in image_provider.
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_BLUE, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_GREEN, g_AM_HAL_GPIO_OUTPUT_12);
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_YELLOW, g_AM_HAL_GPIO_OUTPUT_12);
is_initialized = true;
}
因为该函数每次推理调用一次,所以下面的代码片段会导致它在每次执行推理时切换蓝色 LED 的开关:
// Toggle the blue LED every time an inference is performed.
static int count = 0;
if (++count & 1) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_BLUE);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
}
最后,如果检测到一个人,它会点亮绿色 LED,如果没有检测到一个人,它会点亮蓝色 LED。它还使用 ErrorReporter 实例记录分数:
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
if (person_score > no_person_score) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
} else {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
}
error_reporter->Report("person score:%d no person score %d", person_score,
no_person_score);
就是这样!函数的核心是一个基本的 if 语句,你可以很容易地使用类似的逻辑来控制其他类型的输出。将如此复杂的视觉输入转换为一个布尔输出“人”或“非人”是非常令人兴奋的事情。
运行示例
现在我们已经看到了 SparkFun Edge 实现的工作原理,让我们开始运行它。
提示
由于本书编写时可能已更改构建过程,因此请查看 README.md 获取最新说明。
要构建和部署我们的代码,我们需要以下内容:
-
带有 Himax HM01B0 breakout 的 SparkFun Edge 开发板
-
一个 USB 编程器(我们推荐 SparkFun 串行基础分支,可在micro-B USB和USB-C变种中获得)
-
一根匹配的 USB 电缆
-
Python 3 和一些依赖项
注意
如果您不确定是否安装了正确版本的 Python,请参考“运行示例”中的说明进行检查。
在终端中,克隆 TensorFlow 存储库并切换到其目录:
git clone https://github.com/tensorflow/tensorflow.git
cd tensorflow
接下来,我们将构建二进制文件并运行一些命令,使其准备好下载到设备上。为了避免一些打字,您可以从README.md中复制并粘贴这些命令。
构建二进制文件
以下命令下载所有必需的依赖项,然后为 SparkFun Edge 编译一个二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=sparkfun_edge person_detection_bin
二进制文件被创建为*.bin*文件,位于以下位置:
tensorflow/lite/micro/tools/make/gen/
sparkfun_edge_cortex-m4/bin/person_detection.bin
要检查文件是否存在,可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen \
/sparkfun_edge_cortex-m4/bin/person_detection.bin \
&& echo "Binary was successfully created" || echo "Binary is missing"
当您运行该命令时,您应该看到Binary was successfully created打印到控制台。
如果看到Binary is missing,则构建过程中出现问题。如果是这样,很可能在make命令的输出中有一些线索指出出了什么问题。
对二进制文件进行签名
必须使用加密密钥对二进制文件进行签名,才能部署到设备上。现在让我们运行一些命令,对二进制文件进行签名,以便可以刷写到 SparkFun Edge 上。这里使用的脚本来自 Ambiq SDK,在运行 Makefile 时下载。
输入以下命令设置一些虚拟的加密密钥,供开发使用:
cp tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0 \
/tools/apollo3_scripts/keys_info0.py \
tensorflow/lite/micro/tools/make/downloads/AmbiqSuite-Rel2.0.0 \
/tools/apollo3_scripts/keys_info.py
接下来,运行以下命令创建一个已签名的二进制文件。如果需要,将python3替换为python:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_image_blob.py \
--bin tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/person_detection.bin \
--load-address 0xC000 \
--magic-num 0xCB \
-o main_nonsecure_ota \
--version 0x0
这将创建文件main_nonsecure_ota.bin。现在运行此命令创建文件的最终版本,您可以使用该文件刷写设备,使用下一步中将使用的脚本:
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/create_cust_wireupdate_blob.py \
--load-address 0x20000 \
--bin main_nonsecure_ota.bin \
-i 6 \
-o main_nonsecure_wire \
--options 0x1
现在您应该在运行命令的目录中有一个名为main_nonsecure_wire.bin的文件。这是您将要刷写到设备的文件。
刷写二进制文件
SparkFun Edge 将当前运行的程序存储在其 1 兆字节的闪存中。如果您希望板运行新程序,您需要将其发送到板上,该程序将存储在闪存中,覆盖先前保存的任何程序。
正如我们在本书中早些时候提到的,这个过程被称为刷写。
将编程器连接到板上
要下载新程序到板上,您将使用 SparkFun USB-C 串行基础串行编程器。该设备允许您的计算机通过 USB 与微控制器通信。
将此设备连接到您的板上,执行以下步骤:
-
在 SparkFun Edge 的一侧,找到六针排针。
-
将 SparkFun USB-C 串行基础插入这些引脚,确保每个设备上标记为 BLK 和 GRN 的引脚正确对齐,如图 9-6 所示。
图 9-6. 连接 SparkFun Edge 和 USB-C 串行基础(由 SparkFun 提供)
将编程器连接到计算机
通过 USB 将板连接到计算机。要对板进行编程,您需要找出计算机给设备的名称。最好的方法是在连接设备之前和之后列出所有计算机的设备,然后查看哪个设备是新的。
警告
一些人报告了他们操作系统的默认驱动程序与编程器存在问题,因此我们强烈建议在继续之前安装驱动程序。
在通过 USB 连接设备之前,运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
这应该输出一个附加设备列表,看起来像以下内容:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
现在,将编程器连接到计算机的 USB 端口,并再次运行以下命令:
# macOS:
ls /dev/cu*
# Linux:
ls /dev/tty*
您应该在输出中看到一个额外的项目,如下例所示。您的新项目可能有不同的名称。这个新项目是设备的名称:
/dev/cu.Bluetooth-Incoming-Port
/dev/cu.MALS
/dev/cu.SOC
/dev/cu.wchusbserial-1450
这个名称将用于引用设备。但是,它可能会根据编程器连接的 USB 端口而变化,因此如果您将板子从计算机断开然后重新连接,可能需要再次查找其名称。
提示
一些用户报告列表中出现了两个设备。如果看到两个设备,则要使用的正确设备以“wch”开头;例如,/dev/wchusbserial-14410.
在确定设备名称后,将其放入一个 shell 变量以备后用:
export DEVICENAME=<*your device name here*>
这是在后续过程中运行需要设备名称的命令时可以使用的变量。
运行脚本以刷写您的板子
要刷写板子,您需要将其置于特殊的“引导加载程序”状态,以准备接收新的二进制文件。然后,您将运行一个脚本将二进制文件发送到板子。
首先创建一个环境变量来指定波特率,即数据发送到设备的速度:
export BAUD_RATE=921600
现在将以下命令粘贴到终端中,但不要立即按 Enter!命令中的${DEVICENAME}和${BAUD_RATE}将被替换为您在前面部分设置的值。如有必要,请记得将python3替换为python。
python3 tensorflow/lite/micro/tools/make/downloads/ \
AmbiqSuite-Rel2.0.0/tools/apollo3_scripts/uart_wired_update.py -b \
${BAUD_RATE} ${DEVICENAME} -r 1 -f main_nonsecure_wire.bin -i 6
接下来,您将重置板子到引导加载程序状态并刷写板子。在板子上,找到标有RST和14的按钮,如图 9-7 所示。
执行以下步骤:
-
确保您的板子连接到编程器,并且整个设备通过 USB 连接到计算机。
-
在板子上,按住标有
14的按钮。继续按住它。 -
在仍按住标有
14的按钮的情况下,按下标有RST的按钮重置板子。 -
在计算机上按 Enter 运行脚本。继续按住按钮
14。
图 9-7. SparkFun Edge 的按钮
您现在应该在屏幕上看到类似以下内容:
Connecting with Corvette over serial port /dev/cu.usbserial-1440...
Sending Hello.
Received response for Hello
Received Status
length = 0x58
version = 0x3
Max Storage = 0x4ffa0
Status = 0x2
State = 0x7
AMInfo =
0x1
0xff2da3ff
0x55fff
0x1
0x49f40003
0xffffffff
[...lots more 0xffffffff...]
Sending OTA Descriptor = 0xfe000
Sending Update Command.
number of updates needed = 1
Sending block of size 0x158b0 from 0x0 to 0x158b0
Sending Data Packet of length 8180
Sending Data Packet of length 8180
[...lots more Sending Data Packet of length 8180...]
继续按住按钮14,直到看到Sending Data Packet of length 8180。在看到此信息后可以释放按钮(但如果继续按住也没关系)。
程序将继续在终端上打印行。最终,您会看到类似以下内容:
[...lots more Sending Data Packet of length 8180...]
Sending Data Packet of length 8180
Sending Data Packet of length 6440
Sending Reset Command.
Done.
这表示刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了Sending Reset Command.。如果是,则尽管出现错误,刷写可能已成功。否则,刷写可能失败。尝试再次运行这些步骤(您可以跳过设置环境变量)。
测试程序
首先按下RST按钮,确保程序正在运行。
当程序运行时,蓝色 LED 将交替闪烁,每次推理一次。由于我们用于人员检测的视觉模型相对较大,运行推理需要很长时间——总共约 6 秒。
首先将设备的摄像头对准绝对不是人的东西,或者只是遮住镜头。下一次蓝色 LED 切换时,设备将从摄像头捕获一帧并开始运行推理。大约 6 秒后,推理结果将被转换为另一个 LED 点亮。鉴于您将摄像头对准的不是人,橙色 LED 应该点亮。
现在,尝试将设备的摄像头对准自己。下一次蓝色 LED 切换时,设备将捕获另一帧并开始运行推理。这次,绿色 LED 应该点亮。
请记住,在每次推理之前,图像数据都会被捕获为快照,每当蓝色 LED 切换时。在那一刻摄像头对准的东西将被输入模型。在下一次捕获帧时,摄像头对准的位置并不重要,蓝色 LED 将再次切换。
如果您得到看似不正确的结果,请确保您处于光线良好的环境中。还要记住,这是一个小模型,它以精度换取了小尺寸。它工作得非常好,但并非始终 100%准确。
查看调试数据
该程序将检测结果记录到串行端口。要查看它们,我们可以使用波特率为 115200 监视板的串行端口输出。在 macOS 和 Linux 上,以下命令应该有效:
screen ${DEVICENAME} 115200
您应该最初看到类似以下内容的输出:
Apollo3 Burst Mode is Available
Apollo3 operating in Burst Mode (96MHz)
当板捕获帧并运行推断时,您应该看到它打印调试信息:
Person score: 130 No person score: 204
Person score: 220 No person score: 87
要停止使用screen查看调试输出,请按 Ctrl-A,紧接着按 K 键,然后按 Y 键。
进行您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/examples/person_detection文件夹中找到应用程序的代码。只需编辑并保存,然后重复前面的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事项:
-
修改检测响应器,使其忽略模糊的输入,即“人”和“无人”得分之间没有太大差异的情况。
-
利用人员检测结果来控制其他组件,如额外的 LED 或伺服。
-
构建一个智能安全摄像头,通过存储或传输图像来检测只包含人的图像。
总结
我们在本章中使用的视觉模型是一件了不起的事情。它接受原始且混乱的输入,无需预处理,并为我们提供一个非常简单的输出:是,有人在场,还是,没有人在场。这就是机器学习的魔力:它可以从噪音中过滤信息,留下我们关心的信号。作为开发者,我们可以轻松使用这些信号为用户构建令人惊叹的体验。
在构建机器学习应用程序时,很常见使用像这样的预训练模型,这些模型已经包含执行任务所需的知识。模型大致相当于代码库,封装了特定功能,并且可以在项目之间轻松共享。您经常会发现自己在探索和评估模型,寻找适合您任务的合适模型。
在第十章中,我们将探讨人员检测模型的工作原理。您还将学习如何训练自己的视觉模型来识别不同类型的对象。
在2018 年 YouGov 民意调查中,70%的受访者表示,如果失去视力,他们会最怀念视觉。