Tinyml:TensorFlow Lite 深度学习(二)
原文:Tinyml: Machine Learning with Tensorflow Lite
译者:飞龙
第五章:TinyML 的“Hello World”:构建一个应用程序
模型只是机器学习应用程序的一部分。单独来看,它只是一块信息;它几乎什么都做不了。要使用我们的模型,我们需要将其包装在代码中,为其设置必要的运行环境,提供输入,并使用其输出生成行为。图 5-1 显示了模型在右侧如何适配到基本 TinyML 应用程序中。
在本章中,我们将构建一个嵌入式应用程序,使用我们的正弦模型创建一个微小的灯光秀。我们将设置一个连续循环,将一个x值输入模型,运行推断,并使用结果来开关 LED 灯,或者控制动画,如果我们的设备有 LCD 显示器的话。
这个应用程序已经编写好了。这是一个 C++ 11 程序,其代码旨在展示一个完整的 TinyML 应用程序的最小可能实现,避免任何复杂的逻辑。这种简单性使它成为学习如何使用 TensorFlow Lite for Microcontrollers 的有用工具,因为您可以清楚地看到需要哪些代码,以及很少的其他内容。它也是一个有用的模板。阅读完本章后,您将了解 TensorFlow Lite for Microcontrollers 程序的一般结构,并可以在自己的项目中重用相同的结构。
本章将逐步介绍应用程序代码并解释其工作原理。下一章将提供详细的构建和部署说明,适用于多种设备。如果您不熟悉 C++,不要惊慌。代码相对简单,我们会详细解释一切。到最后,您应该对运行模型所需的所有代码感到满意,甚至可能在学习过程中学到一些 C++知识。
图 5-1。基本 TinyML 应用程序架构
提示
请记住,由于 TensorFlow 是一个积极开发的开源项目,这里打印的代码与在线代码之间可能存在一些细微差异。不用担心,即使有一些代码行发生变化,基本原则仍然保持不变。
测试步骤
在处理应用程序代码之前,编写一些测试通常是一个好主意。测试是演示特定逻辑片段的短代码片段。由于它们由可工作的代码组成,我们可以运行它们来证明代码是否按预期运行。编写完测试后,通常会自动运行测试,以持续验证项目是否仍然按照我们的期望运行,尽管我们可能对其代码进行了任何更改。它们也非常有用,作为如何执行操作的工作示例。
hello_world示例有一个测试,在hello_world_test.cc中定义,加载我们的模型并使用它运行推断,检查其预测是否符合我们的期望。它包含了执行此操作所需的确切代码,没有其他内容,因此这将是学习 TensorFlow Lite for Microcontrollers 的绝佳起点。在本节中,我们将逐步介绍测试,并解释其中的每个部分的作用。阅读完代码后,我们可以运行测试以证明其正确性。
现在让我们逐节来看一下。如果您在电脑上,打开hello_world_test.cc并跟着走可能会有帮助。
包含依赖项
第一部分,在许可证标题下方(指定任何人都可以在Apache 2.0开源许可下使用或共享此代码),如下所示:
#include "tensorflow/lite/micro/examples/hello_world/sine_model_data.h"
#include "tensorflow/lite/micro/kernels/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_error_reporter.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/testing/micro_test.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/version.h"
#include指令是 C++代码指定其依赖的其他代码的一种方式。当使用#include引用代码文件时,它定义的任何逻辑或变量将可供我们使用。在本节中,我们使用#include导入以下项目:
tensorflow/lite/micro/examples/hello_world/sine_model_data.h
我们训练的正弦模型,使用xxd转换并转换为 C++
tensorflow/lite/micro/kernels/all_ops_resolver.h
一个允许解释器加载我们模型使用的操作的类
tensorflow/lite/micro/micro_error_reporter.h
一个可以记录错误并输出以帮助调试的类
- tensorflow/lite/micro/micro_interpreter.h *
将运行我们模型的 TensorFlow Lite for Microcontrollers 解释器
tensorflow/lite/micro/testing/micro_test.h
一个用于编写测试的轻量级框架,允许我们将此文件作为测试运行
tensorflow/lite/schema/schema_generated.h
定义了 TensorFlow Lite FlatBuffer 数据结构的模式,用于理解sine_model_data.h中的模型数据
tensorflow/lite/version.h
模式的当前版本号,以便我们可以检查模型是否使用兼容版本定义
当我们深入代码时,我们将更多地讨论其中一些依赖关系。
注意
按照惯例,设计用于与#include指令一起使用的 C++代码通常编写为两个文件:一个*.cc文件,称为源文件*,以及一个*.h文件,称为头文件*。头文件定义了允许代码连接到程序其他部分的接口。它们包含变量和类声明等内容,但几乎没有逻辑。源文件实现了执行计算和使事情发生的实际逻辑。当我们#include一个依赖项时,我们指定其头文件。例如,我们正在讨论的测试包括micro_interpreter.h。如果我们查看该文件,我们可以看到它定义了一个类,但没有包含太多逻辑。相反,它的逻辑包含在micro_interpreter.cc中。
设置测试
代码的下一部分用于 TensorFlow Lite for Microcontrollers 测试框架。看起来像这样:
TF_LITE_MICRO_TESTS_BEGIN
TF_LITE_MICRO_TEST(LoadModelAndPerformInference) {
在 C++中,您可以定义特殊命名的代码块,可以通过在其他地方包含它们的名称来重用。这些代码块称为宏。这里的两个语句,TF_LITE_MICRO_TESTS_BEGIN和TF_LITE_MICRO_TEST,是宏的名称。它们在文件micro_test.h中定义。
这些宏将我们代码的其余部分包装在必要的装置中,以便通过 TensorFlow Lite for Microcontrollers 测试框架执行。我们不需要担心这是如何工作的;我们只需要知道我们可以使用这些宏作为设置测试的快捷方式。
第二个名为TF_LITE_MICRO_TEST的宏接受一个参数。在这种情况下,传入的参数是LoadModelAndPerformInference。这个参数是测试名称,当运行测试时,它将与测试结果一起输出,以便我们可以看到测试是通过还是失败。
准备记录数据
文件中剩余的代码是我们测试的实际逻辑。让我们看一下第一部分:
// Set up logging
tflite::MicroErrorReporter micro_error_reporter;
tflite::ErrorReporter* error_reporter = µ_error_reporter;
在第一行中,我们定义了一个MicroErrorReporter实例。MicroErrorReporter类在micro_error_reporter.h中定义。它提供了在推理期间记录调试信息的机制。我们将调用它来打印调试信息,而 TensorFlow Lite for Microcontrollers 解释器将使用它来打印遇到的任何错误。
注意
您可能已经注意到每个类型名称之前的tflite::前缀,例如tflite::MicroErrorReporter。这是一个命名空间,只是帮助组织 C++代码的一种方式。TensorFlow Lite 在命名空间tflite下定义了所有有用的内容,这意味着如果另一个库恰好实现了具有相同名称的类,它们不会与 TensorFlow Lite 提供的类发生冲突。
第一个声明看起来很简单,但是第二行看起来有点奇怪,带有*和&字符?为什么我们要声明一个ErrorReporter当我们已经有一个MicroErrorReporter了?
tflite::ErrorReporter* error_reporter = µ_error_reporter;
解释这里发生的事情,我们需要了解一些背景信息。
MicroErrorReporter是ErrorReporter类的一个子类,为 TensorFlow Lite 中这种调试日志机制应该如何工作提供了一个模板。MicroErrorReporter覆盖了ErrorReporter的一个方法,用专门为在微控制器上使用而编写的逻辑替换它。
在前面的代码行中,我们创建了一个名为error_reporter的变量,它的类型是ErrorReporter。它也是一个指针,其声明中使用了*表示。
指针是一种特殊类型的变量,它不是保存一个值,而是保存一个引用,指向内存中的一个值。在 C++中,一个类的指针(比如ErrorReporter)可以指向它的一个子类(比如MicroErrorReporter)的值。
正如我们之前提到的,MicroErrorReporter覆盖了ErrorReporter的一个方法。不详细讨论,覆盖这个方法的过程会导致一些其他方法被隐藏。
为了仍然可以访问ErrorReporter的未覆盖方法,我们需要将我们的MicroErrorReporter实例视为实际上是ErrorReporter。我们通过创建一个ErrorReporter指针并将其指向micro_error_reporter变量来实现这一点。赋值语句中&在micro_error_reporter前面的意思是我们正在分配它的指针,而不是它的值。
哎呀!听起来很复杂。如果你觉得难以理解,不要惊慌;C++可能有点难以掌握。对于我们的目的,我们只需要知道我们应该使用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_sine_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);
return 1;
}
在第一行中,我们将我们的模型数据数组(在文件sine_model_data.h中定义)传递给一个名为GetModel()的方法。这个方法返回一个Model指针,被赋值给一个名为model的变量。正如你可能预料的那样,这个变量代表我们的模型。
类型Model是一个struct,在 C++中与类非常相似。它在schema_generated.h中定义,它保存我们模型的数据并允许我们查询有关它的信息。
一旦model准备好,我们调用一个检索模型版本号的方法:
if (model->version() != TFLITE_SCHEMA_VERSION) {
然后我们将模型的版本号与TFLITE_SCHEMA_VERSION进行比较,这表示我们当前使用的 TensorFlow Lite 库的版本。如果数字匹配,我们的模型是使用兼容版本的 TensorFlow Lite Converter 转换的。检查模型版本是一个好习惯,因为版本不匹配可能导致难以调试的奇怪行为。
注意
在前一行代码中,version()是属于model的一个方法。注意箭头(->)从model指向version()。这是 C++的箭头运算符,当我们想要访问一个对象的成员时使用。如果我们有对象本身(而不仅仅是一个指针),我们将使用点(.)来访问它的成员。
如果版本号不匹配,我们仍然会继续,但我们会使用我们的error_reporter记录一个警告:
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.\n",
model->version(), TFLITE_SCHEMA_VERSION);
我们调用error_reporter的Report()方法来记录这个警告。由于error_reporter也是一个指针,我们使用->运算符来访问Report()。
Report()方法的设计类似于一个常用的 C++方法printf(),用于记录文本。作为它的第一个参数,我们传递一个我们想要记录的字符串。这个字符串包含两个%d格式说明符,它们充当变量在消息记录时插入的占位符。我们传递的下两个参数是模型版本和 TensorFlow Lite 模式版本。这些将按顺序插入到字符串中,以替换%d字符。
注意
Report()方法支持不同的格式说明符,用作不同类型变量的占位符。%d应该用作整数的占位符,%f应该用作浮点数的占位符,%s应该用作字符串的占位符。
创建一个 AllOpsResolver
到目前为止一切顺利!我们的代码可以记录错误,我们已经将模型加载到一个方便的结构中,并检查它是否是兼容的版本。鉴于我们一路上在回顾一些 C++概念,我们进展有点慢,但事情开始变得清晰起来了。接下来,我们创建一个AllOpsResolver的实例:
// This pulls in all the operation implementations we need
tflite::ops::micro::AllOpsResolver resolver;
这个类在all_ops_resolver.h中定义,它允许 TensorFlow Lite for Microcontrollers 解释器访问操作。
在第三章中,您了解到机器学习模型由各种数学运算组成,这些运算按顺序运行,将输入转换为输出。AllOpsResolver类知道 TensorFlow Lite for Microcontrollers 可用的所有操作,并能够将它们提供给解释器。
定义张量区域
我们几乎已经准备好创建一个解释器所需的所有要素。我们需要做的最后一件事是分配一个工作内存区域,我们的模型在运行时将需要这个内存区域:
// Create an area of memory to use for input, output, and intermediate arrays.
// Finding the minimum value for your model may require some trial and error.
const int tensor_arena_size = 2 × 1024;
uint8_t tensor_arena[tensor_arena_size];
正如注释所说,这个内存区域将用于存储模型的输入、输出和中间张量。我们称之为我们的张量区域。在我们的情况下,我们分配了一个大小为 2,048 字节的数组。我们用表达式2 × 1024来指定这一点。
那么,我们的张量区域应该有多大呢?这是一个很好的问题。不幸的是,没有一个简单的答案。不同的模型架构具有不同大小和数量的输入、输出和中间张量,因此很难知道我们需要多少内存。数字不需要精确——我们可以保留比我们需要的更多的内存——但由于微控制器的 RAM 有限,我们应该尽可能保持它小,以便为程序的其余部分留出空间。
我们可以通过试错来完成这个过程。这就是为什么我们将数组大小表示为*n* × 1024:这样可以很容易地通过改变*n*来扩大或缩小数字(保持为 8 的倍数)。要找到正确的数组大小,从一个相对较高的数字开始,以确保它有效。本书示例中使用的最大数字是70 × 1024。然后,减少数字直到您的模型不再运行。最后一个有效的数字就是正确的数字!
创建解释器
现在我们已经声明了tensor_arena,我们准备设置解释器。下面是具体步骤:
// Build an interpreter to run the model with
tflite::MicroInterpreter interpreter(model, resolver, tensor_arena,
tensor_arena_size, error_reporter);
// Allocate memory from the tensor_arena for the model's tensors
interpreter.AllocateTensors();
首先,我们声明一个名为interpreter的MicroInterpreter。这个类是 TensorFlow Lite for Microcontrollers 的核心:一个神奇的代码片段,将在我们提供的数据上执行我们的模型。我们将迄今为止创建的大部分对象传递给它的构造函数,然后调用AllocateTensors()。
在前一节中,我们通过定义一个名为tensor_arena的数组来设置了一个内存区域。AllocateTensors()方法遍历模型定义的所有张量,并为每个张量从tensor_arena中分配内存。在尝试运行推理之前,我们必须调用AllocateTensors(),因为否则推理将失败。
检查输入张量
在我们创建了一个解释器之后,我们需要为我们的模型提供一些输入。为此,我们将我们的输入数据写入模型的输入张量:
// Obtain a pointer to the model's input tensor
TfLiteTensor* input = interpreter.input(0);
要获取输入张量的指针,我们调用解释器的input()方法。由于一个模型可以有多个输入张量,我们需要向input()方法传递一个指定我们想要的张量的索引。在这种情况下,我们的模型只有一个输入张量,所以它的索引是0。
在 TensorFlow Lite 中,张量由TfLiteTensor结构表示,该结构在c_api_internal.h中定义。这个结构提供了一个 API 来与张量进行交互和了解张量。在下一段代码中,我们使用这个功能来验证我们的张量看起来和感觉正确。因为我们将经常使用张量,让我们通过这段代码来熟悉TfLiteTensor结构的工作方式:
// Make sure the input has the properties we expect
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
// The property "dims" tells us the tensor's shape. It has one element for
// each dimension. Our input is a 2D tensor containing 1 element, so "dims"
// should have size 2.
TF_LITE_MICRO_EXPECT_EQ(2, input->dims->size);
// The value of each element gives the length of the corresponding tensor.
// We should expect two single element tensors (one is contained within the
// other).
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
// The input is a 32 bit floating point value
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, input->type);
你会注意到的第一件事是一对宏:TFLITE_MICRO_EXPECT_NE和TFLITE_MICRO_EXPECT_EQ。这些宏是 TensorFlow Lite for Microcontrollers 测试框架的一部分,它们允许我们对变量的值进行断言,证明它们具有某些期望的值。
例如,宏TF_LITE_MICRO_EXPECT_NE旨在断言它所调用的两个变量不相等(因此其名称中的_NE部分表示不相等)。如果变量不相等,代码将继续执行。如果它们相等,将记录一个错误,并标记测试为失败。
我们首先检查的是我们的输入张量是否实际存在。为了做到这一点,我们断言它不等于nullptr,这是一个特殊的 C++值,表示一个指针实际上没有指向任何数据:
TF_LITE_MICRO_EXPECT_NE(nullptr, input);
我们接下来检查的是我们输入张量的形状。如第三章中讨论的,所有张量都有一个形状,这是描述它们维度的一种方式。我们模型的输入是一个标量值(表示一个单个数字)。然而,由于Keras 层接受输入的方式,这个值必须提供在一个包含一个数字的 2D 张量中。对于输入 0,它应该是这样的:
[[0]]
请注意,输入标量 0 被包裹在两个向量中,使其成为一个 2D 张量。
TfLiteTensor结构包含一个dims成员,描述张量的维度。该成员是一个类型为TfLiteIntArray的结构,也在c_api_internal.h中定义。它的size成员表示张量的维度数。由于输入张量应该是 2D 的,我们可以断言size的值为2:
TF_LITE_MICRO_EXPECT_EQ(2, input->dims->size);
我们可以进一步检查dims结构,以确保张量的结构是我们期望的。它的data变量是一个数组,每个维度有一个元素。每个元素是一个表示该维度大小的整数。因为我们期望一个包含每个维度一个元素的 2D 张量,我们可以断言两个维度都包含一个单一元素:
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
我们现在可以确信我们的输入张量具有正确的形状。最后,由于张量可以由各种不同类型的数据组成(比如整数、浮点数和布尔值),我们应该确保我们的输入张量具有正确的类型。
张量结构体的type变量告诉我们张量的数据类型。我们将提供一个 32 位浮点数,由常量kTfLiteFloat32表示,我们可以轻松地断言类型是正确的:
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, input->type);
完美——我们的输入张量现在已经保证是正确的大小和形状,适用于我们的输入数据,这将是一个单个浮点值。我们准备好进行推理了!
在输入上运行推理
要运行推理,我们需要向我们的输入张量添加一个值,然后指示解释器调用模型。之后,我们将检查模型是否成功运行。这是它的样子:
// Provide an input value
input->data.f[0] = 0.;
// Run the model on this input and check that 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);
TensorFlow Lite 的TfLiteTensor结构有一个data变量,我们可以用来设置输入张量的内容。你可以在这里看到它被使用:
input->data.f[0] = 0.;
data变量是一个TfLitePtrUnion——它是一个union,是一种特殊的 C++数据类型,允许您在内存中的同一位置存储不同的数据类型。由于给定张量可以包含多种不同类型的数据(例如浮点数、整数或布尔值),union 是帮助我们存储它的完美类型。
TfLitePtrUnion联合在c_api_internal.h中声明。这是它的样子:
// A union of pointers that points to memory for a given tensor.
typedef union {
int32_t* i32;
int64_t* i64;
float* f;
TfLiteFloat16* f16;
char* raw;
const char* raw_const;
uint8_t* uint8;
bool* b;
int16_t* i16;
TfLiteComplex64* c64;
int8_t* int8;
} TfLitePtrUnion;
您可以看到有一堆成员,每个代表一种特定类型。每个成员都是一个指针,可以指向内存中应存储数据的位置。当我们像之前那样调用interpreter.AllocateTensors()时,适当的指针被设置为指向为张量分配的内存块,以存储其数据。因为每个张量有一个特定的数据类型,所以只有相应类型的指针会被设置。
这意味着为了存储数据,我们可以在TfLitePtrUnion中使用适当的指针。例如,如果我们的张量是kTfLiteFloat32类型,我们将使用data.f。
由于指针指向一块内存块,我们可以在指针名称后使用方括号([])来指示程序在哪里存储数据。在我们的例子中,我们这样做:
input->data.f[0] = 0.;
我们分配的值写为0.,这是0.0的简写。通过指定小数点,我们让 C++编译器清楚地知道这个值应该是一个浮点数,而不是整数。
您可以看到我们将这个值分配给data.f[0]。这意味着我们将其分配为我们分配的内存块中的第一个项目。鉴于只有一个值,这就是我们需要做的。
设置完输入张量后,是时候运行推理了。这是一个一行代码:
TfLiteStatus invoke_status = interpreter.Invoke();
当我们在interpreter上调用Invoke()时,TensorFlow Lite 解释器会运行模型。该模型由数学运算图组成,解释器执行这些运算以将输入数据转换为输出。这个输出存储在模型的输出张量中,我们稍后会深入研究。
Invoke()方法返回一个TfLiteStatus对象,让我们知道推理是否成功或是否有问题。它的值可以是kTfLiteOk或kTfLiteError。我们检查是否有错误,并在有错误时报告:
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed\n");
}
最后,我们断言状态必须是kTfLiteOk,以便我们的测试通过:
TF_LITE_MICRO_EXPECT_EQ(kTfLiteOk, invoke_status);
就这样——推理已经运行!接下来,我们获取输出并确保它看起来不错。
阅读输出
与输入一样,我们模型的输出通过TfLiteTensor访问,获取指向它的指针同样简单:
TfLiteTensor* output = interpreter.output(0);
输出与输入一样,是一个嵌套在 2D 张量中的浮点标量值。为了测试,我们再次检查输出张量的预期大小、维度和类型:
TF_LITE_MICRO_EXPECT_EQ(2, output->dims->size);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[0]);
TF_LITE_MICRO_EXPECT_EQ(1, input->dims->data[1]);
TF_LITE_MICRO_EXPECT_EQ(kTfLiteFloat32, output->type);
是的,一切看起来都很好。现在,我们获取输出值并检查它,确保它符合我们的高标准。首先,我们将其分配给一个float变量:
// Obtain the output value from the tensor
float value = output->data.f[0];
每次运行推理时,输出张量将被新值覆盖。这意味着如果您想在程序中保留一个输出值,同时继续运行推理,您需要从输出张量中复制它,就像我们刚刚做的那样。
接下来,我们使用TF_LITE_MICRO_EXPECT_NEAR来证明该值接近我们期望的值:
// Check that the output value is within 0.05 of the expected value
TF_LITE_MICRO_EXPECT_NEAR(0., value, 0.05);
正如我们之前看到的,TF_LITE_MICRO_EXPECT_NEAR断言其第一个参数和第二个参数之间的差异小于其第三个参数的值。在这个语句中,我们测试输出是否在 0 的数学正弦输入 0 的 0.05 范围内。
注意
我们期望得到一个接近我们想要的数字的原因有两个,但不是一个精确值。第一个原因是我们的模型只是近似真实的正弦值,所以我们知道它不会完全正确。第二个原因是因为计算机上的浮点计算有一个误差范围。误差可能因计算机而异:例如,笔记本电脑的 CPU 可能会产生与 Arduino 稍有不同的结果。通过具有灵活的期望,我们更有可能使我们的测试在任何平台上通过。
如果这个测试通过,情况看起来很好。其余的测试会再次运行推理几次,只是为了进一步证明我们的模型正在工作。要再次运行推理,我们只需要为我们的输入张量分配一个新值,调用 interpreter.Invoke(),并从输出张量中读取输出:
// Run inference on several more values and confirm the expected outputs
input->data.f[0] = 1.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(0.841, value, 0.05);
input->data.f[0] = 3.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(0.141, value, 0.05);
input->data.f[0] = 5.;
interpreter.Invoke();
value = output->data.f[0];
TF_LITE_MICRO_EXPECT_NEAR(-0.959, value, 0.05);
请注意我们如何重复使用相同的 input 和 output 张量指针。因为我们已经有了指针,所以我们不需要再次调用 interpreter.input(0) 或 interpreter.output(0)。
在我们的测试中,我们已经证明了 TensorFlow Lite for Microcontrollers 可以成功加载我们的模型,分配适当的输入和输出张量,运行推理,并返回预期的结果。最后要做的是使用宏指示测试的结束:
}
TF_LITE_MICRO_TESTS_END
有了这些,我们完成了测试。接下来,让我们运行它们!
运行测试
尽管这段代码最终将在微控制器上运行,但我们仍然可以在开发计算机上构建和运行我们的测试。这样做可以更轻松地编写和调试代码。与微控制器相比,个人计算机具有更方便的日志记录工具和代码调试工具,这使得更容易找出任何错误。此外,将代码部署到设备需要时间,因此仅在本地运行代码会更快。
构建嵌入式应用程序(或者说,任何类型的软件)的一个好的工作流程是尽可能多地在可以在普通开发计算机上运行的测试中编写逻辑。总会有一些部分需要实际硬件才能运行,但你在本地测试的越多,你的生活就会变得更容易。
实际上,这意味着我们应该尝试在一组测试中编写预处理输入、使用模型运行推理以及处理任何输出的代码,然后再尝试在设备上使其正常工作。在第七章中,我们将介绍一个比这个示例复杂得多的语音识别应用程序。你将看到我们为其每个组件编写了详细的单元测试。
获取代码
到目前为止,在 Colab 和 GitHub 之间,我们一直在云端进行所有操作。为了运行我们的测试,我们需要将代码下载到我们的开发计算机并进行编译。
为了做到这一切,我们需要以下软件工具:
-
终端仿真器,如 macOS 中的终端
-
一个 bash shell(在 macOS Catalina 之前和大多数 Linux 发行版中是默认的)
-
Git(在 macOS 和大多数 Linux 发行版中默认安装)
-
Make,版本 3.82 或更高版本
在你拥有所有工具之后,打开一个终端并输入以下命令来下载 TensorFlow 源代码,其中包括我们正在使用的示例代码。它将在你运行它的任何位置创建一个包含源代码的目录:
git clone https://github.com/tensorflow/tensorflow.git
接下来,切换到刚刚创建的 tensorflow 目录:
cd tensorflow
太棒了 - 我们现在准备运行一些代码!
使用 Make 运行测试
从我们的工具列表中可以看到,我们使用一个名为Make的程序来运行测试。Make 是一个用于自动化软件构建任务的工具。它自 1976 年以来一直在使用,从计算术语来看几乎是永远的。开发人员使用一种特殊的语言,在名为Makefiles的文件中编写,指示 Make 如何构建和运行代码。TensorFlow Lite for Microcontrollers 在micro/tools/make/Makefile中定义了一个 Makefile;在第十三章中有更多关于它的信息。
要使用 Make 运行我们的测试,我们可以发出以下命令,确保我们是从使用 Git 下载的tensorflow目录的根目录运行。我们首先指定要使用的 Makefile,然后是target,即我们要构建的组件:
make -f tensorflow/lite/micro/tools/make/Makefile test_hello_world_test
Makefile 被设置为为了运行测试,我们提供一个以test_为前缀的目标,后面跟着我们想要构建的组件的名称。在我们的情况下,该组件是*hello_world_test,因此完整的目标名称是test_hello_world_test*。
尝试运行这个命令。您应该开始看到大量输出飞过!首先,将下载一些必要的库和工具。接下来,我们的测试文件以及所有依赖项将被构建。我们的 Makefile 已经指示 C++编译器构建代码并创建一个二进制文件,然后运行它。
您需要等待一段时间才能完成这个过程。当文本停止飞过时,最后几行应该是这样的:
Testing LoadModelAndPerformInference
1/1 tests passed
~~~ALL TESTS PASSED~~~
很好!这个输出显示我们的测试按预期通过了。您可以看到测试的名称LoadModelAndPerformInference,如其源文件顶部所定义。即使它还没有在微控制器上,我们的代码也成功地运行了推断。
要查看测试失败时会发生什么,让我们引入一个错误。打开测试文件hello_world_test.cc。它将位于相对于目录根目录的路径:
tensorflow/lite/micro/examples/hello_world/hello_world_test.cc
为了使测试失败,让我们为模型提供不同的输入。这将导致模型的输出发生变化,因此检查我们输出值的断言将失败。找到以下行:
input->data.f[0] = 0.;
更改分配的值,如下所示:
input->data.f[0] = 1.;
现在保存文件,并使用以下命令再次运行测试(记得要从tensorflow目录的根目录运行):
make -f tensorflow/lite/micro/tools/make/Makefile test_hello_world_test
代码将被重建,测试将运行。您看到的最终输出应该如下所示:
Testing LoadModelAndPerformInference
0.0486171 near value failed at tensorflow/lite/micro/examples/hello_world/\
hello_world_test.cc:94
0/1 tests passed
~~~SOME TESTS FAILED~~~
输出包含有关测试失败原因的一些有用信息,包括失败发生的文件和行号(hello_world_test.cc:94)。如果这是由于真正的错误引起的,这个输出将有助于追踪问题。
项目文件结构
借助我们的测试,您已经学会了如何使用 TensorFlow Lite for Microcontrollers 库在 C++中运行推断。接下来,我们将浏览一个实际应用程序的源代码。
如前所述,我们正在构建的程序由一个连续循环组成,该循环将一个x值输入模型,运行推断,并使用结果生成某种可见输出(如闪烁 LED 的模式),具体取决于平台。
因为应用程序很复杂,涉及多个文件,让我们看看它的结构以及它们如何相互配合。
应用程序的根目录在tensorflow/lite/micro/examples/hello_world中。它包含以下文件:
BUILD
一个列出可以使用应用程序源代码构建的各种内容的文件,包括主应用程序二进制文件和我们之前讨论过的测试。在这一点上,我们不需要太担心它。
Makefile.inc
一个包含有关应用程序内部构建目标信息的 Makefile,包括hello_world_test,这是我们之前运行的测试,以及hello_world,主应用程序二进制文件。它定义了它们的哪些源文件是其中的一部分。
README.md
一个包含构建和运行应用程序说明的自述文件。
constants.h, constants.cc
一对包含各种常量(在程序生命周期中不会改变的变量)的文件,这些常量对于定义程序行为很重要。
create_sine_model.ipynb
在上一章中使用的 Jupyter 笔记本。
hello_world_test.cc
一个使用我们模型运行推断的测试。
main.cc
程序的入口点,在应用部署到设备时首先运行。
main_functions.h, main_functions.cc
一对文件,定义了一个setup()函数,执行我们程序所需的所有初始化,以及一个loop()函数,包含程序的核心逻辑,并设计为在循环中重复调用。这些函数在程序启动时由main.cc调用。
output_handler.h, output_handler.cc
一对文件,定义了一个函数,我们可以用它来显示每次运行推断时的输出。默认实现在output_handler.cc中,将结果打印到屏幕上。我们可以覆盖这个实现,使其在不同设备上执行不同的操作。
output_handler_test.cc
一个证明output_handler.h和output_handler.cc中的代码正常工作的测试。
sine_model_data.h, sine_model_data.cc
一对文件,定义了一个表示我们模型的数据数组,这些数据是在本章的第一部分中使用xxd导出的。
除了这些文件外,目录中还包含以下子目录(可能还有更多):
-
arduino/
-
disco_f76ng/
-
sparkfun_edge/
因为不同的微控制器平台具有不同的功能和 API,我们的项目结构允许我们提供设备特定版本的源文件,如果应用程序为该设备构建,则将使用这些版本而不是默认版本。例如,arduino目录包含了定制版本的main.cc、constants.cc和output_handler.cc,以使应用程序能够与 Arduino 兼容。我们稍后会深入研究这些定制实现。
源代码解析
现在我们知道了应用程序源代码的结构,让我们深入代码。我们将从main_functions.cc开始,这里发生了大部分的魔法,并从那里扩展到其他文件。
注意
这段代码中很多内容在hello_world_test.cc中会看起来很熟悉。如果我们已经涵盖了某些内容,我们不会深入讨论它的工作原理;我们更愿意主要关注您之前没有见过的内容。
从 main_functions.cc 开始
这个文件包含了我们程序的核心逻辑。它从一些熟悉的#include语句和一些新的语句开始:
#include "tensorflow/lite/micro/examples/hello_world/main_functions.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "tensorflow/lite/micro/examples/hello_world/sine_model_data.h"
#include "tensorflow/lite/micro/kernels/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_error_reporter.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/schema/schema_generated.h"
#include "tensorflow/lite/version.h"
我们在hello_world_test.cc中看到了很多这样的内容。新出现的是constants.h和output_handler.h,我们在前面的文件列表中了解到了这些。
文件的下一部分设置了将在main_functions.cc中使用的全局变量:
namespace {
tflite::ErrorReporter* error_reporter = nullptr;
const tflite::Model* model = nullptr;
tflite::MicroInterpreter* interpreter = nullptr;
TfLiteTensor* input = nullptr;
TfLiteTensor* output = nullptr;
int inference_count = 0;
// Create an area of memory to use for input, output, and intermediate arrays.
// Finding the minimum value for your model may require some trial and error.
constexpr int kTensorArenaSize = 2 × 1024;
uint8_t tensor_arena[kTensorArenaSize];
} // namespace
您会注意到这些变量被包裹在一个namespace中。这意味着即使它们可以在main_functions.cc中的任何地方访问,但在项目中的其他文件中是无法访问的。这有助于防止如果两个不同的文件恰好定义了相同名称的变量时出现问题。
所有这些变量应该在测试中看起来很熟悉。我们设置变量来保存所有熟悉的 TensorFlow 对象,以及一个tensor_arena。唯一新的是一个保存inference_count的int,它将跟踪我们的程序执行了多少次推断。
文件的下一部分声明了一个名为setup()的函数。这个函数将在程序首次启动时调用,但之后不会再次调用。我们用它来做所有需要在开始运行推断之前发生的一次性工作。
setup()的第一部分几乎与我们的测试中的相同。我们设置日志记录,加载我们的模型,设置解释器并分配内存:
void setup() {
// Set up logging.
static tflite::MicroErrorReporter micro_error_reporter;
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.
model = tflite::GetModel(g_sine_model_data);
if (model->version() != TFLITE_SCHEMA_VERSION) {
error_reporter->Report(
"Model provided is schema version %d not equal "
"to supported version %d.",
model->version(), TFLITE_SCHEMA_VERSION);
return;
}
// This pulls in all the operation implementations we need.
static tflite::ops::micro::AllOpsResolver resolver;
// Build an interpreter to run the model with.
static tflite::MicroInterpreter static_interpreter(
model, 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;
}
到目前为止都是熟悉的领域。然而,在这一点之后,事情有点不同。首先,我们获取输入张量和输出张量的指针:
// Obtain pointers to the model's input and output tensors.
input = interpreter->input(0);
output = interpreter->output(0);
你可能想知道在运行推断之前我们如何与输出交互。请记住,TfLiteTensor只是一个结构体,它有一个成员data,指向一个已分配用于存储输出的内存区域。即使还没有写入任何输出,结构体及其data成员仍然存在。
最后,为了结束setup()函数,我们将我们的inference_count变量设置为0:
// Keep track of how many inferences we have performed.
inference_count = 0;
}
此时,我们所有的机器学习基础设施都已经设置好并准备就绪。我们拥有运行推断并获得结果所需的所有工具。接下来要定义的是我们的应用逻辑。程序实际上要做什么?
我们的模型经过训练,可以预测从 0 到 2π的任何数字的正弦值,这代表正弦波的完整周期。为了展示我们的模型,我们可以只输入这个范围内的数字,预测它们的正弦值,然后以某种方式输出这些值。我们可以按顺序执行这些操作,以展示模型在整个范围内的工作。这听起来是一个不错的计划!
为了做到这一点,我们需要编写一些在循环中运行的代码。首先,我们声明一个名为loop()的函数,接下来我们将逐步介绍。我们放在这个函数中的代码将被重复运行,一遍又一遍:
void loop() {
首先在我们的loop()函数中,我们必须确定要传递给模型的值(让我们称之为我们的x值)。我们使用两个常量来确定这一点:kXrange,它指定最大可能的x值为 2π,以及kInferencesPerCycle,它定义了我们希望在从 0 到 2π的步骤中执行的推断数量。接下来的几行代码计算x值:
// Calculate an x value to feed into the model. We compare the current
// inference_count to the number of inferences per cycle to determine
// our position within the range of possible x values the model was
// trained on, and use this to calculate a value.
float position = static_cast<float>(inference_count) /
static_cast<float>(kInferencesPerCycle);
float x_val = position * kXrange;
前两行代码只是将inference_count(到目前为止我们已经做的推断次数)除以kInferencesPerCycle,以获得我们在范围内的当前“位置”。下一行将该值乘以kXrange,它代表范围中的最大值(2π)。结果x_val是我们将传递给模型的值。
注意
static_cast<float>()用于将inference_count和kInferencesPerCycle(两者都是整数值)转换为浮点数。我们这样做是为了能够正确执行除法。在 C++中,如果你将两个整数相除,结果将是一个整数;结果的任何小数部分都会被舍弃。因为我们希望我们的x值是一个包含小数部分的浮点数,所以我们需要将被除数转换为浮点数。
我们使用的两个常量,kInferencesPerCycle和kXrange,在文件constants.h和constants.cc中定义。在使用这些常量时,C++的惯例是在常量名称前加上k,这样它们在代码中使用时很容易识别为常量。将常量定义在单独的文件中可能很有用,这样它们可以在需要的任何地方被包含和使用。
我们代码的下一部分应该看起来很熟悉;我们将我们的x值写入模型的输入张量,运行推断,然后从输出张量中获取结果(让我们称之为我们的y值):
// Place our calculated x value in the model's input tensor
input->data.f[0] = x_val;
// Run inference, and report any error
TfLiteStatus invoke_status = interpreter->Invoke();
if (invoke_status != kTfLiteOk) {
error_reporter->Report("Invoke failed on x_val: %f\n",
static_cast<double>(x_val));
return;
}
// Read the predicted y value from the model's output tensor
float y_val = output->data.f[0];
现在我们有了一个正弦值。由于对每个数字运行推断需要一点时间,并且这段代码在循环中运行,我们将随时间生成一系列正弦值。这将非常适合控制一些闪烁的 LED 或动画。我们的下一个任务是以某种方式输出它。
以下一行调用了在output_handler.cc中定义的HandleOutput()函数:
// Output the results. A custom HandleOutput function can be implemented
// for each supported hardware target.
HandleOutput(error_reporter, x_val, y_val);
我们传入我们的x和y值,以及我们的ErrorReporter实例,我们可以用它来记录事情。要查看接下来会发生什么,让我们来探索output_handler.cc。
使用 output_handler.cc 处理输出
文件output_handler.cc定义了我们的HandleOutput()函数。它的实现非常简单:
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
}
这个函数所做的就是使用ErrorReporter实例来记录x和y的值。这只是一个最基本的实现,我们可以用来测试应用程序的基本功能,例如在开发计算机上运行它。
然而,我们的目标是将此应用程序部署到几种不同的微控制器平台上,使用每个平台的专用硬件来显示输出。对于我们计划部署到的每个单独平台,例如 Arduino,我们提供一个自定义替换output_handler.cc,使用平台的 API 来控制输出,例如点亮一些 LED。
如前所述,这些替换文件位于具有每个平台名称的子目录中:arduino/、disco_f76ng/和sparkfun_edge/。我们将稍后深入研究特定于平台的实现。现在,让我们跳回main_functions.cc。
结束 main_functions.cc
我们在loop()函数中做的最后一件事是增加我们的inference_count计数器。如果它已经达到了在kInferencesPerCycle中定义的每个周期的最大推理次数,我们将其重置为 0:
// Increment the inference_counter, and reset it if we have reached
// the total number per cycle
inference_count += 1;
if (inference_count >= kInferencesPerCycle) inference_count = 0;
下一次循环迭代时,这将使我们的x值沿着一步移动或者如果它已经达到范围的末尾,则将其包装回 0。
我们现在已经到达了我们的loop()函数的末尾。每次运行时,都会计算一个新的x值,运行推理,并由HandleOutput()输出结果。如果loop()不断被调用,它将对范围为 0 到 2π的x值进行推理,然后重复。
但是是什么让loop()函数一遍又一遍地运行?答案在main.cc文件中。
理解 main.cc
C++标准规定每个 C++程序都包含一个名为main()的全局函数,该函数将在程序启动时运行。在我们的程序中,这个函数在main.cc文件中定义。这个main()函数的存在是main.cc代表我们程序入口点的原因。每当微控制器启动时,main()中的代码将运行。
文件main.cc非常简短而简洁。首先,它包含了一个main_functions.h的#include语句,这将引入那里定义的setup()和loop()函数:
#include "tensorflow/lite/micro/examples/hello_world/main_functions.h"
接下来,它声明了main()函数本身:
int main(int argc, char* argv[]) {
setup();
while (true) {
loop();
}
}
当main()运行时,它首先调用我们的setup()函数。它只会执行一次。之后,它进入一个while循环,将不断调用loop()函数,一遍又一遍。
这个循环将无限运行。天啊!如果您来自服务器或 Web 编程背景,这可能听起来不是一个好主意。循环将阻塞我们的单个执行线程,并且没有退出程序的方法。
然而,在为微控制器编写软件时,这种无休止的循环实际上是相当常见的。因为没有多任务处理,只有一个应用程序会运行,循环继续进行并不重要。只要微控制器连接到电源,我们就继续进行推理并输出数据。
我们现在已经走完了整个微控制器应用程序。在下一节中,我们将通过在开发计算机上运行应用程序代码来尝试该应用程序代码。
运行我们的应用程序
为了给我们的应用程序代码进行测试运行,我们首先需要构建它。输入以下 Make 命令以为我们的程序创建一个可执行二进制文件:
make -f tensorflow/lite/micro/tools/make/Makefile hello_world
当构建完成后,您可以使用以下命令运行应用程序二进制文件,具体取决于您的操作系统:
# macOS:
tensorflow/lite/micro/tools/make/gen/osx_x86_64/bin/hello_world
# Linux:
tensorflow/lite/micro/tools/make/gen/linux_x86_64/bin/hello_world
# Windows
tensorflow/lite/micro/tools/make/gen/windows_x86_64/bin/hello_world
如果找不到正确的路径,请列出*tensorflow/lite/micro/tools/make/gen/*中的目录。
在运行二进制文件之后,您应该希望看到一堆输出滚动过去,看起来像这样:
x_value: 1.4137159*2¹, y_value: 1.374213*2^-2
x_value: 1.5707957*2¹, y_value: -1.4249528*2^-5
x_value: 1.7278753*2¹, y_value: -1.4295994*2^-2
x_value: 1.8849551*2¹, y_value: -1.2867725*2^-1
x_value: 1.210171*2², y_value: -1.7542461*2^-1
非常令人兴奋!这些是output_handler.cc中HandleOutput()函数写入的日志。每次推理都有一个日志,x_value逐渐增加,直到达到 2π,然后回到 0 并重新开始。
一旦你体验到足够的刺激,你可以按 Ctrl-C 来终止程序。
注意
你会注意到这些数字以二的幂次方的形式输出,比如1.4137159*2¹。这是在微控制器上记录浮点数的高效方式,因为这些设备通常没有浮点运算的硬件支持。
要获得原始值,只需拿出你的计算器:例如,1.4137159*2¹计算结果为2.8274318。如果你感兴趣,打印这些数字的代码在debug_log_numbers.cc中。
总结
我们现在已经确认程序在我们的开发机器上运行正常。在下一章中,我们将让它在一些微控制器上运行!
第六章:TinyML 的“Hello World”:部署到微控制器
现在是时候动手了。在本章的过程中,我们将代码部署到三种不同的设备上:
我们将逐个讨论每个设备的构建和部署过程。
注意
TensorFlow Lite 定期添加对新设备的支持,因此如果您想使用的设备未在此处列出,值得查看示例的README.md。
如果在按照这些步骤时遇到问题,您也可以在那里查看更新的部署说明。
每个设备都有自己独特的输出能力,从一组 LED 到完整的 LCD 显示器,因此示例包含每个设备的HandleOutput()的自定义实现。我们还将逐个讨论这些,并谈谈它们的逻辑如何工作。即使您没有所有这些设备,阅读这段代码也应该很有趣,因此我们强烈建议您查看一下。
什么是微控制器?
根据您的过去经验,您可能不熟悉微控制器如何与其他电子组件交互。因为我们即将开始玩硬件,所以在继续之前介绍一些概念是值得的。
在像 Arduino、SparkFun Edge 或 STM32F746G Discovery kit 这样的微控制器板上,实际的微控制器只是连接到电路板的许多电子组件之一。图 6-1 显示了 SparkFun Edge 上的微控制器。
图 6-1。SparkFun Edge 板上突出显示其微控制器
微控制器使用引脚连接到其所在的电路板。典型的微控制器有数十个引脚,它们有各种用途。一些引脚为微控制器提供电源;其他连接到各种重要组件。一些引脚专门用于由运行在微控制器上的程序输入和输出数字信号。这些被称为GPIO引脚,代表通用输入/输出。它们可以作为输入,确定是否向其施加电压,或作为输出,提供可以为其他组件供电或通信的电流。
GPIO 引脚是数字的。这意味着在输出模式下,它们就像开关,可以完全打开或完全关闭。在输入模式下,它们可以检测由其他组件施加在它们上的电压是高于还是低于某个阈值。
除了 GPIO,一些微控制器还具有模拟输入引脚,可以测量施加在它们上的电压的确切水平。
通过调用特殊函数,运行在微控制器上的程序可以控制特定引脚是输入模式还是输出模式。其他函数用于打开或关闭输出引脚,或读取输入引脚的当前状态。
现在您对微控制器有了更多了解,让我们更仔细地看看我们的第一个设备:Arduino。
Arduino
有各种各样的Arduino板,具有不同的功能。并非所有板都能运行 TensorFlow Lite for Microcontrollers。我们推荐本书使用的板是Arduino Nano 33 BLE Sense。除了与 TensorFlow Lite 兼容外,它还包括麦克风和加速度计(我们将在后面的章节中使用)。我们建议购买带有引脚排针的板,这样可以更容易地连接其他组件而无需焊接。
大多数 Arduino 板都带有内置 LED,这就是我们将用来可视化输出正弦值的内容。图 6-2 显示了一个 Arduino Nano 33 BLE Sense 板,其中突出显示了 LED。
图 6-2。Arduino Nano 33 BLE Sense 板上突出显示的 LED
在 Arduino 上处理输出
因为我们只有一个 LED 可供使用,所以我们需要进行创造性思考。一种选择是根据最近预测的正弦值来改变 LED 的亮度。鉴于该值范围为-1 到 1,我们可以用完全关闭的 LED 表示 0,用完全亮起的 LED 表示-1 和 1,用部分调暗的 LED 表示任何中间值。当程序在循环中运行推断时,LED 将重复地变暗和变亮。
我们可以使用kInferencesPerCycle常量在完整正弦波周期内执行的推断数量。由于一个推断需要一定的时间,调整constants.cc中定义的kInferencesPerCycle将调整 LED 变暗的速度。
在hello_world/arduino/constants.cc中有一个特定于 Arduino 的版本的此文件。该文件与hello_world/constants.cc具有相同的名称,因此在为 Arduino 构建应用程序时将使用它来代替原始实现。
为了调暗我们的内置 LED,我们可以使用一种称为脉宽调制(PWM)的技术。如果我们非常快速地打开和关闭一个输出引脚,那么引脚的输出电压将成为处于关闭和打开状态之间所花时间比率的因素。如果引脚在每种状态中花费的时间占 50%,则其输出电压将是其最大值的 50%。如果它在打开状态花费 75%的时间,关闭状态花费 25%的时间,则其电压将是其最大值的 75%。
PWM 仅在某些 Arduino 设备的某些引脚上可用,但使用起来非常简单:我们只需调用一个设置所需输出电平的函数。
用于 Arduino 输出处理的代码位于hello_world/arduino/output_handler.cc中,该代码用于替代原始文件hello_world/output_handler.cc。
让我们来看一下源代码:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "Arduino.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
首先,我们包含一些头文件。我们的output_handler.h指定了此文件的接口。Arduino.h提供了 Arduino 平台的接口;我们使用它来控制板。因为我们需要访问kInferencesPerCycle,所以我们还包括constants.h。
接下来,我们定义函数并指示它第一次运行时要执行的操作:
// Adjusts brightness of an LED to represent the current y value
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Track whether the function has run at least once
static bool is_initialized = false;
// Do this only once
if (!is_initialized) {
// Set the LED pin to output
pinMode(LED_BUILTIN, OUTPUT);
is_initialized = true;
}
在 C++中,函数内声明为static的变量将在函数的多次运行中保持其值。在这里,我们使用is_initialized变量来跟踪以下if (!is_initialized)块中的代码是否曾经运行过。
初始化块调用 Arduino 的pinMode()函数,该函数指示微控制器给定的引脚应该处于输入模式还是输出模式。在使用引脚之前,这是必要的。该函数使用 Arduino 平台定义的两个常量调用:LED_BUILTIN和OUTPUT。LED_BUILTIN表示连接到板上内置 LED 的引脚,OUTPUT表示输出模式。
将内置 LED 的引脚配置为输出模式后,将is_initialized设置为true,以便此代码块不会再次运行。
接下来,我们计算 LED 的期望亮度:
// Calculate the brightness of the LED such that y=-1 is fully off
// and y=1 is fully on. The LED's brightness can range from 0-255.
int brightness = (int)(127.5f * (y_value + 1));
Arduino 允许我们将 PWM 输出的电平设置为 0 到 255 之间的数字,其中 0 表示完全关闭,255 表示完全打开。我们的y_value是-1 到 1 之间的数字。前面的代码将y_value映射到 0 到 255 的范围,因此当y = -1时,LED 完全关闭,当y = 0时,LED 亮度为一半,当y = 1时,LED 完全亮起。
下一步是实际设置 LED 的亮度:
// Set the brightness of the LED. If the specified pin does not support PWM,
// this will result in the LED being on when y > 127, off otherwise.
analogWrite(LED_BUILTIN, brightness);
Arduino 平台的analogWrite()函数接受一个介于 0 和 255 之间的值的引脚号(我们提供LED_BUILTIN)和我们在前一行中计算的brightness。当调用此函数时,LED 将以该亮度点亮。
注意
不幸的是,在某些型号的 Arduino 板上,内置 LED 连接的引脚不支持 PWM。这意味着我们对analogWrite()的调用不会改变其亮度。相反,如果传递给analogWrite()的值大于 127,则 LED 将打开,如果小于等于 126,则 LED 将关闭。这意味着 LED 将闪烁而不是渐变。虽然不够酷,但仍然展示了我们的正弦波预测。
最后,我们使用ErrorReporter实例记录亮度值:
// Log the current brightness value for display in the Arduino plotter
error_reporter->Report("%d\n", brightness);
在 Arduino 平台上,ErrorReporter被设置为通过串行端口记录数据。串行是微控制器与主机计算机通信的一种非常常见的方式,通常用于调试。这是一种通信协议,其中数据通过开关输出引脚一次一个位来传输。我们可以使用它发送和接收任何内容,从原始二进制数据到文本和数字。
Arduino IDE 包含用于捕获和显示通过串行端口接收的数据的工具。其中一个工具是串行绘图器,可以显示通过串行接收的值的图形。通过从我们的代码输出一系列亮度值,我们将能够看到它们的图形。图 6-3 展示了这一过程。
图 6-3. Arduino IDE 的串行绘图器
我们将在本节后面提供如何使用串行绘图器的说明。
注意
您可能想知道ErrorReporter如何通过 Arduino 的串行接口输出数据。您可以在micro/arduino/debug_log.cc中找到代码实现。它替换了micro/debug_log.cc中的原始实现。就像output_handler.cc被覆盖一样,我们可以通过将它们添加到以平台名称命名的目录中,为 TensorFlow Lite for Microcontrollers 中的任何源文件提供特定于平台的实现。
运行示例
我们的下一个任务是为 Arduino 构建项目并将其部署到设备上。
提示
建议检查README.md以获取最新的指导,因为自本书编写以来构建过程可能已经发生变化。
我们需要的一切如下:
-
支持的 Arduino 板(我们推荐 Arduino Nano 33 BLE Sense)
-
适当的 USB 电缆
-
Arduino IDE(您需要下载并安装此软件才能继续)
本书中的项目作为 TensorFlow Lite Arduino 库中的示例代码可用,您可以通过 Arduino IDE 轻松安装,并从工具菜单中选择管理库。在弹出的窗口中,搜索并安装名为Arduino_TensorFlowLite的库。您应该能够使用最新版本,但如果遇到问题,本书测试过的版本是1.14-ALPHA。
注意
您还可以从*.zip*文件安装库,您可以从 TensorFlow Lite 团队下载,或者使用 TensorFlow Lite for Microcontrollers Makefile 自动生成。如果您更喜欢这样做,请参见附录 A。
安装完库后,hello_world示例将显示在文件菜单下的 Examples→Arduino_TensorFlowLite 中,如图 6-4 所示。
单击“hello_world”加载示例。它将显示为一个新窗口,每个源文件都有一个选项卡。第一个选项卡中的文件hello_world相当于我们之前讨论过的main_functions.cc。
图 6-4. 示例菜单
要运行示例,请通过 USB 连接您的 Arduino 设备。确保在工具菜单中从板下拉列表中选择正确的设备类型,如图 6-5 所示。
图 6-5。板下拉列表
如果您的设备名称未出现在列表中,则需要安装其支持包。要执行此操作,请单击“Boards Manager”。在出现的窗口中,搜索您的设备并安装相应支持包的最新版本。
接下来,请确保在“端口”下拉列表中选择了设备的端口,也在工具菜单中,如图 6-6 所示。
图 6-6。端口下拉列表
最后,在 Arduino 窗口中,单击上传按钮(在图 6-7 中用白色突出显示)来编译并将代码上传到您的 Arduino 设备。
图 6-7。上传按钮,一个右箭头
上传成功完成后,您应该看到 Arduino 板上的 LED 开始淡入淡出或闪烁,具体取决于其连接的引脚是否支持 PWM。
恭喜:您正在设备上运行 ML!
注意
不同型号的 Arduino 板具有不同的硬件,并且将以不同的速度运行推断。如果您的 LED 要么闪烁要么保持完全开启,您可能需要增加每个周期的推断次数。您可以通过arduino_constants.cpp中的kInferencesPerCycle常量来实现这一点。
“进行您自己的更改”向您展示如何编辑示例代码。
您还可以查看绘制在图表上的亮度值。要执行此操作,请在工具菜单中选择 Arduino IDE 的串行绘图器,如图 6-8 所示。
图 6-8。串行绘图器菜单选项
绘图器显示随时间变化的值,如图 6-9 所示。
图 6-9。串行绘图器绘制值
要查看从 Arduino 串行端口接收的原始数据,请从工具菜单中打开串行监视器。您将看到一系列数字飞过,就像图 6-10 中所示。
图 6-10。显示原始数据的串行监视器
进行您自己的更改
现在您已经部署了应用程序,可能会很有趣地玩耍并对代码进行一些更改。您可以在 Arduino IDE 中编辑源文件。保存时,您将被提示在新位置重新保存示例。完成更改后,您可以在 Arduino IDE 中单击上传按钮来构建和部署。
要开始进行更改,您可以尝试以下几个实验:
-
通过调整每个周期的推断次数来使 LED 闪烁速度变慢或变快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,如额外的 LED 或声音发生器。
SparkFun Edge
SparkFun Edge开发板专门设计为在微型设备上进行机器学习实验的平台。它具有功耗高效的 Ambiq Apollo 3 微控制器,带有 Arm Cortex M4 处理器核心。
它具有四个 LED 的一组,如图 6-11 所示。我们使用这些 LED 来直观输出我们的正弦值。
图 6-11。SparkFun Edge 的四个 LED
在 SparkFun Edge 上处理输出
我们可以使用板上的一组 LED 制作一个简单的动画,因为没有什么比blinkenlights更能展示尖端人工智能了。
LED(红色、绿色、蓝色和黄色)在以下顺序中物理排列:
[ R G B Y ]
以下表格表示我们将如何为不同的y值点亮 LED:
| 范围 | 点亮的 LED |
|---|---|
0.75 <= y <= 1 | [ 0 0 1 1 ] |
0 < y < 0.75 | [ 0 0 1 0 ] |
y = 0 | [ 0 0 0 0 ] |
-0.75 < y < 0 | [ 0 1 0 0 ] |
-1 <= y <= 0.75 | [ 1 1 0 0 ] |
每次推断需要一定的时间,所以调整kInferencesPerCycle,在constants.cc中定义,将调整 LED 循环的速度。
图 6-12 显示了程序运行的一个静态图像,来自一个动画*.gif*。
图 6-12。来自 SparkFun Edge LED 动画的静态图像
实现 SparkFun Edge 的输出处理的代码在hello_world/sparkfun_edge/output_handler.cc中,用于替代原始文件hello_world/output_handler.cc。
让我们开始逐步进行:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "am_bsp.h"
首先,我们包含一些头文件。我们的output_handler.h指定了此文件的接口。另一个文件am_bsp.h来自一个叫做Ambiq Apollo3 SDK的东西。Ambiq 是 SparkFun Edge 微控制器的制造商,称为 Apollo3。SDK(软件开发工具包)是一组源文件,定义了可以用来控制微控制器功能的常量和函数。
因为我们计划控制板上的 LED,所以我们需要能够打开和关闭微控制器的引脚。这就是我们使用 SDK 的原因。
注意
当我们最终构建项目时,Makefile 将自动下载 SDK。如果你感兴趣,可以在SparkFun 的网站上阅读更多关于它的信息或下载代码进行探索。
接下来,我们定义HandleOutput()函数,并指示在其第一次运行时要执行的操作:
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// The first time this method runs, set up our LEDs correctly
static bool is_initialized = false;
if (!is_initialized) {
// Set up LEDs as outputs
am_hal_gpio_pinconfig(AM_BSP_GPIO_LED_RED, g_AM_HAL_GPIO_OUTPUT_12);
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);
// Ensure all pins are cleared
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
is_initialized = true;
}
哦,这是很多的设置!我们使用am_hal_gpio_pinconfig()函数,由am_bsp.h提供,来配置连接到板上内置 LED 的引脚,将它们设置为输出模式(由g_AM_HAL_GPIO_OUTPUT_12常量表示)。每个 LED 的引脚号由一个常量表示,比如AM_BSP_GPIO_LED_RED。
然后我们使用am_hal_gpio_output_clear()清除所有输出,以便所有 LED 都关闭。与 Arduino 实现一样,我们使用名为is_initialized的static变量,以确保此块中的代码仅运行一次。接下来,我们确定如果y值为负时应点亮哪些 LED:
// Set the LEDs to represent negative values
if (y_value < 0) {
// Clear unnecessary LEDs
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_GREEN);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
// The blue LED is lit for all negative values
am_hal_gpio_output_set(AM_BSP_GPIO_LED_BLUE);
// The red LED is lit in only some cases
if (y_value <= -0.75) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_RED);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
}
首先,如果y值刚刚变为负数,我们清除用于指示正值的两个 LED。接下来,我们调用am_hal_gpio_output_set()来打开蓝色 LED,如果值为负数,它将始终点亮。最后,如果值小于-0.75,我们打开红色 LED。否则,我们关闭它。
接下来,我们做同样的事情,但是对于y的正值:
// Set the LEDs to represent positive values
} else if (y_value > 0) {
// Clear unnecessary LEDs
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_RED);
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_BLUE);
// The green LED is lit for all positive values
am_hal_gpio_output_set(AM_BSP_GPIO_LED_GREEN);
// The yellow LED is lit in only some cases
if (y_value >= 0.75) {
am_hal_gpio_output_set(AM_BSP_GPIO_LED_YELLOW);
} else {
am_hal_gpio_output_clear(AM_BSP_GPIO_LED_YELLOW);
}
}
LED 就是这样。我们最后要做的是将当前的输出值记录到串口上正在监听的人:
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
注意
我们的ErrorReporter能够通过 SparkFun Edge 的串行接口输出数据,这是由于micro/sparkfun_edge/debug_log.cc的自定义实现取代了mmicro/debug_log.cc中的原始实现。
运行示例
现在我们可以构建示例代码并将其部署到 SparkFun Edge 上。
提示
构建过程可能会有变化,因为这本书写作时,所以请查看README.md获取最新的指导。
要构建和部署我们的代码,我们需要以下内容:
-
一个 SparkFun Edge 板
-
一个 USB 编程器(我们推荐 SparkFun Serial Basic Breakout,可在micro-B USB和USB-C变体中获得)
-
一根匹配的 USB 电缆
-
Python 3 和一些依赖项
首先,打开一个终端,克隆 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 hello_world_bin
注意
二进制文件是包含程序的文件,可以直接由 SparkFun Edge 硬件运行。
二进制文件将被创建为*.bin*文件,位置如下:
tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/hello_world.bin
要检查文件是否存在,您可以使用以下命令:
test -f tensorflow/lite/micro/tools/make/gen/ \
sparkfun_edge_cortex-m4/bin/hello_world.bin \
&& echo "Binary was successfully created" || echo "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/hello_world.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 的引脚正确对齐。
您可以在图 6-13 中看到正确的排列方式。
图 6-13. 连接 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的按钮,如图 6-14 所示。
图 6-14。SparkFun Edge 的按钮
执行以下步骤:
-
确保您的板子连接到编程器,并且整个设备通过 USB 连接到计算机。
-
在板子上,按住标记为
14的按钮。继续按住它。 -
在继续按住标记为
14的按钮的同时,按下标记为RST的按钮重置板子。 -
在计算机上按 Enter 键运行脚本。继续按住按钮
14。
现在您应该在屏幕上看到类似以下内容的东西:
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,直到看到发送数据包长度为 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.
这表明刷写成功。
提示
如果程序输出以错误结束,请检查是否打印了发送复位命令。如果是,则刷写可能成功,尽管有错误。否则,刷写可能失败。尝试再次运行这些步骤(您可以跳过设置环境变量)。
测试程序
现在应该已经将二进制文件部署到设备上。按下标记为RST的按钮重新启动板子。您应该看到设备的四个 LED 按顺序闪烁。干得好!
查看调试数据
在程序运行时,板子会记录调试信息。要查看它,我们可以使用波特率 115200 监视板子的串行端口输出。在 macOS 和 Linux 上,以下命令应该有效:
screen ${DEVICENAME} 115200
您会看到大量的输出飞过!要停止滚动,请按下 Ctrl-A,紧接着按 Esc。然后您可以使用箭头键浏览输出,其中包含对各种x值运行推断的结果:
x_value: 1.1843798*2², y_value: -1.9542645*2^-1
要停止使用screen查看调试输出,请按下 Ctrl-A,紧接着按下 K 键,然后按下 Y 键。
注意
程序screen是一个有用的连接到其他计算机的实用程序程序。在这种情况下,我们使用它来监听 SparkFun Edge 板通过其串行端口记录的数据。如果您使用 Windows,可以尝试使用程序CoolTerm来做同样的事情。
进行您自己的更改
现在您已经部署了基本应用程序,请尝试玩耍并进行一些更改。您可以在tensorflow/lite/micro/examples/hello_world文件夹中找到应用程序的代码。只需编辑并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事情:
-
通过调整每个周期的推断次数使 LED 的闪烁速度变慢或变快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,如额外的 LED 或声音发生器。
ST Microelectronics STM32F746G Discovery Kit
STM32F746G是一个带有相对强大的 Arm Cortex-M7 处理器核心的微控制器开发板。
这块板运行 Arm 的Mbed OS,这是一个专为构建和部署嵌入式应用程序而设计的嵌入式操作系统。这意味着我们可以使用本节中的许多指令来为其他 Mbed 设备构建。
STM32F746G 配备了一个附加的 LCD 屏幕,这将使我们能够构建一个更加复杂的视觉显示。
在 STM32F746G 上处理输出
现在我们有一个整个 LCD 可以玩耍,我们可以绘制一个漂亮的动画。让我们使用屏幕的x轴表示推断的数量,y轴表示我们预测的当前值。
我们将在这个值应该的地方绘制一个点,当我们循环遍历 0 到 2π的输入范围时,它将在屏幕上移动。图 6-15 展示了这个的线框图。
每个推断需要一定的时间,因此调整kInferencesPerCycle,在constants.cc中定义,将调整点的运动速度和平滑度。
图 6-16 显示了程序运行的动画*.gif*的静止画面。
图 6-15。我们将在 LCD 显示屏上绘制的动画
图 6-16 显示了程序运行的动画*.gif*的静止画面。
图 6-16。在 STM32F746G Discovery kit 上运行的代码,带有 LCD 显示屏
实现 STM32F746G 的输出处理的代码位于hello_world/disco_f746ng/output_handler.cc中,该文件用于替代原始文件hello_world/output_handler.cc。
让我们来看一下:
#include "tensorflow/lite/micro/examples/hello_world/output_handler.h"
#include "LCD_DISCO_F746NG.h"
#include "tensorflow/lite/micro/examples/hello_world/constants.h"
首先,我们有一些头文件。我们的output_handler.h指定了这个文件的接口。由开发板制造商提供的LCD_DISCO_F74NG.h声明了我们将用来控制其 LCD 屏幕的接口。我们还包括constants.h,因为我们需要访问kInferencesPerCycle和kXrange。
接下来,我们设置了大量变量。首先是LCD_DISCO_F746NG的一个实例,它在LCD_DISCO_F74NG.h中定义,并提供了我们可以用来控制 LCD 的方法:
// The LCD driver
LCD_DISCO_F746NG lcd;
有关LCD_DISCO_F746NG类的详细信息可在Mbed 网站上找到。
接下来,我们定义一些控制视觉外观和感觉的常量:
// The colors we'll draw
const uint32_t background_color = 0xFFF4B400; // Yellow
const uint32_t foreground_color = 0xFFDB4437; // Red
// The size of the dot we'll draw
const int dot_radius = 10;
颜色以十六进制值提供,如0xFFF4B400。它们的格式为AARRGGBB,其中AA表示 alpha 值(或不透明度,FF表示完全不透明),RR、GG和BB表示红色、绿色和蓝色的量。
提示
通过练习,您可以学会从十六进制值中读取颜色。0xFFF4B400是完全不透明的,有很多红色和一定量的绿色,这使得它成为一个漂亮的橙黄色。
您也可以通过快速的谷歌搜索查找这些值。
然后我们声明了一些变量,定义了动画的形状和大小:
// Size of the drawable area
int width;
int height;
// Midpoint of the y axis
int midpoint;
// Pixels per unit of x_value
int x_increment;
在变量之后,我们定义了HandleOutput()函数,并告诉它在第一次运行时要做什么:
// Animates a dot across the screen to represent the current x and y values
void HandleOutput(tflite::ErrorReporter* error_reporter, float x_value,
float y_value) {
// Track whether the function has run at least once
static bool is_initialized = false;
// Do this only once
if (!is_initialized) {
// Set the background and foreground colors
lcd.Clear(background_color);
lcd.SetTextColor(foreground_color);
// Calculate the drawable area to avoid drawing off the edges
width = lcd.GetXSize() - (dot_radius * 2);
height = lcd.GetYSize() - (dot_radius * 2);
// Calculate the y axis midpoint
midpoint = height / 2;
// Calculate fractional pixels per unit of x_value
x_increment = static_cast<float>(width) / kXrange;
is_initialized = true;
}
里面有很多内容!首先,我们使用属于lcd的方法来设置背景和前景颜色。奇怪命名的lcd.SetTextColor()设置我们绘制的任何东西的颜色,不仅仅是文本:
// Set the background and foreground colors
lcd.Clear(background_color);
lcd.SetTextColor(foreground_color);
接下来,我们计算实际可以绘制到屏幕的部分,以便知道在哪里绘制我们的圆。如果我们搞错了,我们可能会尝试绘制超出屏幕边缘,导致意想不到的结果:
width = lcd.GetXSize() - (dot_radius * 2);
height = lcd.GetYSize() - (dot_radius * 2);
之后,我们确定屏幕中间的位置,我们将在其下方绘制负y值。我们还计算屏幕宽度中表示一个单位x值的像素数。请注意我们如何使用static_cast确保获得浮点结果:
// Calculate the y axis midpoint
midpoint = height / 2;
// Calculate fractional pixels per unit of x_value
x_increment = static_cast<float>(width) / kXrange;
与之前一样,使用名为is_initialized的static变量确保此块中的代码仅运行一次。
初始化完成后,我们可以开始输出。首先,清除任何先前的绘图:
// Clear the previous drawing
lcd.Clear(background_color);
接下来,我们使用x_value来计算我们应该在显示器的x轴上绘制点的位置:
// Calculate x position, ensuring the dot is not partially offscreen,
// which causes artifacts and crashes
int x_pos = dot_radius + static_cast<int>(x_value * x_increment);
然后我们对y值执行相同的操作。这有点复杂,因为我们希望在midpoint上方绘制正值,在下方绘制负值:
// Calculate y position, ensuring the dot is not partially offscreen
int y_pos;
if (y_value >= 0) {
// Since the display's y runs from the top down, invert y_value
y_pos = dot_radius + static_cast<int>(midpoint * (1.f - y_value));
} else {
// For any negative y_value, start drawing from the midpoint
y_pos =
dot_radius + midpoint + static_cast<int>(midpoint * (0.f - y_value));
}
一旦确定了它的位置,我们就可以继续绘制点:
// Draw the dot
lcd.FillCircle(x_pos, y_pos, dot_radius);
最后,我们使用我们的ErrorReporter将x和y值记录到串行端口:
// Log the current X and Y values
error_reporter->Report("x_value: %f, y_value: %f\n", x_value, y_value);
注意
由于自定义实现,ErrorReporter可以通过 STM32F746G 的串行接口输出数据,micro/disco_f746ng/debug_log.cc,取代了micro/debug_log.cc中的原始实现。
运行示例
接下来,让我们构建项目!STM32F746G 运行 Arm 的 Mbed OS,因此我们将使用 Mbed 工具链将我们的应用程序部署到设备上。
提示
建议检查README.md以获取最新说明,因为构建过程可能会有所变化。
在开始之前,我们需要以下内容:
-
一个 STM32F746G Discovery kit 板
-
一个迷你 USB 电缆
-
Arm Mbed CLI(请参阅Mbed 设置指南)
-
Python 3 和
pip
与 Arduino IDE 类似,Mbed 要求源文件以特定方式结构化。 TensorFlow Lite for Microcontrollers Makefile 知道如何为我们做到这一点,并且可以生成适用于 Mbed 的目录。
为了这样做,请运行以下命令:
make -f tensorflow/lite/micro/tools/make/Makefile \
TARGET=mbed TAGS="CMSIS disco_f746ng" generate_hello_world_mbed_project
这将导致创建一个新目录:
tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/ \
hello_world/mbed
该目录包含所有示例的依赖项,以适合 Mbed 构建。
首先,切换到目录,以便您可以在其中运行一些命令:
cd tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/ \
hello_world/mbed
现在,您将使用 Mbed 下载依赖项并构建项目。
要开始,请使用以下命令指定 Mbed 当前目录是 Mbed 项目的根目录:
mbed config root .
接下来,指示 Mbed 下载依赖项并准备构建:
mbed deploy
默认情况下,Mbed 将使用 C++98 构建项目。但是,TensorFlow Lite 需要 C++11。运行以下 Python 片段修改 Mbed 配置文件,以便使用 C++11。您可以直接在命令行中键入或粘贴:
python -c 'import fileinput, glob;
for filename in glob.glob("mbed-os/tools/profiles/*.json"):
for line in fileinput.input(filename, inplace=True):
print(line.replace("\"-std=gnu++98\"","\"-std=c++11\", \"-fpermissive\""))'
最后,运行以下命令进行编译:
mbed compile -m DISCO_F746NG -t GCC_ARM
这应该会在以下路径生成一个二进制文件:
cp ./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin
使用 Mbed 启动的一个好处是,像 STM32F746G 这样的 Mbed 启用板的部署非常简单。要部署,只需将 STM 板插入并将文件复制到其中。在 macOS 上,您可以使用以下命令执行此操作:
cp ./BUILD/DISCO_F746NG/GCC_ARM/mbed.bin /Volumes/DIS_F746NG/
或者,只需在文件浏览器中找到DIS_F746NG卷并将文件拖放过去。复制文件将启动闪存过程。完成后,您应该在设备屏幕上看到动画。
除了这个动画之外,当程序运行时,板上还会记录调试信息。要查看它,请使用波特率为 9600 的串行连接与板建立串行连接。
在 macOS 和 Linux 上,当您发出以下命令时,设备应该会列出:
ls /dev/tty*
它看起来会像下面这样:
/dev/tty.usbmodem1454203
确定设备后,请使用以下命令连接到设备,将</dev/tty.devicename>替换为设备名称,该名称显示在*/dev*中:
screen /<*dev/tty.devicename*> 9600
您会看到很多输出飞过。要停止滚动,请按 Ctrl-A,然后立即按 Esc。然后,您可以使用箭头键浏览输出,其中包含在各种x值上运行推断的结果:
x_value: 1.1843798*2², y_value: -1.9542645*2^-1
要停止使用screen查看调试输出,请按 Ctrl-A,紧接着按 K 键,然后按 Y 键。
进行您自己的更改
现在您已经部署了应用程序,可以尝试玩耍并进行一些更改!您可以在tensorflow/lite/micro/tools/make/gen/mbed_cortex-m4/prj/hello_world/mbed文件夹中找到应用程序的代码。只需编辑并保存,然后重复之前的说明以将修改后的代码部署到设备上。
以下是您可以尝试的一些事情:
-
通过调整每个周期的推理次数来使点移动更慢或更快。
-
修改output_handler.cc以将基于文本的动画记录到串行端口。
-
使用正弦波来控制其他组件,比如 LED 或声音发生器。
总结
在过去的三章中,我们已经经历了训练模型、将其转换为 TensorFlow Lite、围绕其编写应用程序并将其部署到微型设备的完整端到端旅程。在接下来的章节中,我们将探索一些更复杂和令人兴奋的示例,将嵌入式机器学习投入实际应用。
首先,我们将构建一个使用微小的、18 KB 模型识别口头命令的应用程序。