在 ESP32 上运行AI模型

本文说明了如何将模型加载到 MCU 上是可能的,并展示了一个相当简单的应用程序。

在 ESP32 上运行AI模型

上个月的一篇文章讲述了 STM32"蓝色药丸"微控制器板如何被用来"捕获"数据,这些数据随后被用于在 Jupyter 笔记本中运行的 Python TensorFlow 过程训练机器学习模型。最初的希望是该模型可以在 STM32 本身上运行,以对"新数据"进行"预测"或"推断"。相反,更小但更强大的 ESP32-C6 小板运行应用程序。训练数据是在蓝色药丸上收集的,在 Jupyter 笔记本中训练的,然后部署到 ESP32。

1、ML 捕获项目

让我们简要回顾一下"捕获"项目。它的主要目标是将一个非常小的发展板利用为自定义外设来获取数据。它的设计初衷是便宜、容易且快速构建,但仍然足够可靠地完成任务。虽然笨重,带有屏幕抓取和 PC 连接,但电路允许收集数据。

收集的数据是"敲门模式"。两个来自流行文化的敲门序列被反复作为样本。一个是明显的"刮和剃发"敲门。另一个来自电视节目"Doctor Who":两组四个等间距的敲门。为了区分敲击模式,敲击之间的时间间隔被用作标准毫秒间隔,而不是来自特定微控制器定时器的计时。幸运的是,对于本文描述的项目,时间段已经被保存为标准毫秒间隔。

2、ML 推断项目

这种标准化使得在不同的微控制器(ESP32-C6)上识别敲门模式成为可能。如下图所示,这个 ESP32-C6 位于名为"Xiao"的微小开发板上。有整个这样的 ESP32 小系统系列,就像 STM32 一样,都是 32 位。然而,与那个 STM32F103C8T6 系统不同,这个系统具有 512KB SRAM 和 4MB Flash。它的时钟速度是 F103 的两倍以上。顺便提一下,请不要得出 STM 芯片性能不足的印象。F103 只是 STM 处理器大家族中的一款大型芯片。STM 生产的某些产品实际上非常适合 Tiny Machine Learning 应用。选择 ESP 是因为它之前已被购买,足够强大,而且价格便宜,大约 12 美元。它们可以从亚马逊或其他 Seeed Studio 经销商处购买。

ESP32C6,LED 指示灯和振动传感器的 ML 推断设置。在背景中,可以看到用于收集数据的 STM32F103C8T6"蓝色药丸"

3、一些背景

正如本系列其他地方所讨论的,与桌面、笔记本电脑甚至现代智能手机相比,微控制器相对功耗低且速度慢。然而,在最近几十年中,它们在消耗更少电流的同时 steadily 变得越来越强大。它们占用空间很小,为其预期用途自给自足(通常不需要外部内存;通常不需要硬盘来加载程序;通常不连接到显示器)。它们经常捆绑片上"外设"用于专用信号和通信。这种 ESP32-C6 设置专门打包了大量附加功能,可以用极低的电流运行。这种低电流特性在这里没有得到利用,但在之前的文章  中,它在未使用时被进入睡眠状态。作为关于 MCU 变得多么强大的比较参考,考虑大约在 2000 年,一台合理的强大桌面的 CPU 将与此设备一样强大。我们在 2000 年左右完成了很多工作。

随着这些微型奇迹在最近几十年中变得如此强大,已经有可能将一些 AI 任务卸载到它们身上。MCU 一直参与物联网(物联网/边缘计算),成为操作的本地大脑。通过传感器数据收集和致动操作,MCU 要么将数据卸载回一些决策系统,要么从连接的网络接收指令。然而,随着更多地了解了物联网,人们意识到这会带来问题。一方面,数据被来回推送到某个决策系统可能存在时间滞后。另一方面,它可能成为安全问题,传输大量数据。也许更糟糕的是,使用更多通信消耗更多能量,而且一些部署环境并没有提供太多。

因此,在最近几年,更多关注点集中在允许 MCU 在边缘运行自己的模型上。想象一下这个典型场景:边缘设备具有用于异常检测的模型。当触发时,它做出关闭设备以避免损坏的决定 — 只是传达已采取此类行动。

因此,本文说明了如何将模型加载到 MCU 上是可能的,并展示了一个相当简单的应用程序。该实现使用价格低于 2 美元的振动传感器来检测敲门。MCU 记录连续敲门之间的间隔,将完整序列输入到"输入张量",运行推断(要求预测),并从"输出张量"中获取数据。此输出将是针对一种敲击模式的 1 或针对其他模式的 0。考虑到这些模式在长度上的差异(这仅通过计算敲击次数就可以完成),这本来可以通过间隔测量来实现。就像其他一些示例(正弦波;计算两个数字的总和)一样,这个示例可能不需要机器学习。但是,该模型实际上正在使用中。正在做出预测。而且,准确性足以令人满意,即使只有 100 个样本。

请注意,此模型的所有训练都是在 Jupyter 笔记本中完成的。一旦模型在设备上运行,该模型就用于将其传入的数据分类为不同的敲击模式。

4、将模型加载到设备上

在将此模型从简单的 Python 程序转换为 MCU 应用程序时,需要考虑几件事。TensorFlow Lite Micro 专用于非常低功耗环境。请记住,像 ChatGPT 这样的许多 AI 应用程序将在整个服务器场运行。尽管对于其微小封装来说很强大,但它仍然只是一个系统,而且没有太多余地。Micro 版本用 C++ 编写,这对于我们甚至在 STM32F103C8T6(作为补充,经验丰富的 C++ 程序员可能会在 F103 上成功运行模型)来说没问题。但这意味着我们必须从 Python 笔记本转移到 IDE。这需要更复杂的开发周期,在周期中,我们使用特殊程序构建输出,并将其烧录到 ESP32-C6 的 Flash 中。我们将从本文开始将其称为"ESP"、"ESP32"或其全名。

模型从 Python 到 MCU 的转换需要转储文件、转换它,然后使用实用程序将其转换为 C 代码。

model.save("c:/Users/lesfo/OneDrive/Documents/STM32/knock_data/knock_model.keras")
converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()
open("c:/Users/lesfo/OneDrive/Documents/STM32/knock_data/knock_model.tflite", "wb").write(tflite_model)

上面显示了转储代码。首先将模型保存为 keras 文件。然后创建转换器,运行转换器,最后将模型写回 tflite 文件。

tflite 文件仍然不合适。有一个在 Linux 中可用的实用程序(这里使用的是 WSL,但它可能存在于 git bash 环境中)称为"xxd"。以下是如何运行它的:

xxd -i knock_model.tflite > knock_model.h

这将生成一个 C 头文件。经过一些修改,C 头可以作为内存中的模型源使用。

#ifndef KNOCK_MODEL_H_
#define KNOCK_MODEL_H_

#ifdef __cplusplus
extern "C" {
#endif

// 此文件由以下命令生成:
// xxd -i knock_model.tflite > knock_model.h

unsigned char knock_model_tflite[] = {
   0x1c, 0x00, 0x00, 0x00, 0x54, 0x46, 0x4c, 0x33, 0x14, 0x00, 0x20, 0x00,
   0x1c, 0x00, 0x00, 0x18, 0x00, 0x14, 0x00, 0x10, 0x00, 0x0c, 0x00, 0x00,
   0x08, 0x00, 0x04, 0x00, 0x14, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x00,
   0x98, 0x00, 0x00, 0x00, 0xf0, 0x00, 0x00, 0xb8, 0x05, 0x00, 0x00,
   0xc8, 0x05, 0x00, 0x00, 0x28, 0x0a, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00,
   0x01, 0x00, 0x00, 0x10, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x00, 0x00,
   0x10, 0x00, 0x0c, 0x00, 0x08, 0x00, 0x04, 0x00, 0x0a, 0x00, 0x00, 0x00,
   0x0c, 0x00, 0x00, 0x00, 0x1c, 0x00, 0x00, 0x00, 0x3c, 0x00, 0x00, 0x00,
   0x0f, 0x00, 0x00, 0x00, 0x73, 0x65, 0x72, 0x76, 0x69, 0x6e, 0x67, 0x5f,
   0xf4, 0xff, 0xff, 0x19, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x19,
   0x0c, 0x00, 0x00, 0x00, 0x0b, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00,
   0x0c, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x09
};
unsigned int knock_model_tflite_len = 2700;
#ifdef __cplusplus
}
#endif
#endif

像任何 C/C++ 头文件一样,这只需要被导入。使用唯一性定义块是为了避免如果这在多个不同头文件中被使用时发生冲突。另一个 ifdef-wrapper 是为了允许使用 C 或 C++。

下面使用了该模型的设置代码:

// 这个函数的名称对于 Arduino 兼容性很重要。
void setup() {
   if (setup_io_pins() != ESP_OK) {
     MicroPrintf("Failed to setup IO pins.\n");
     return;
   }

   // 将模型映射到可用的数据结构中。这不涉及任何
   // 复制或解析,它是一个非常轻量级的操作。
   model = tflite::GetModel(knock_model_tflite);
   if (model->version() != TFLITE_SCHEMA_VERSION) {
     MicroPrintf("提供的模型模式版本 %d 不等于支持的版本 "
                 "版本 %d.", model->version(), TFLITE_SCHEMA_VERSION);
     return;

   }

   // 构建解释器以使用该模型运行推断。
   static tflite::MicroOpResolver<6> resolver;
     if (resolver.AddDequantize() != kTfLiteOk) {
       return;
     }
     if (resolver.AddFullyConnected() != kTfLiteOk) {
       return;
     }
     if (resolver.AddQuantize() != kTfLiteOk) {
       return;
     }
     if (resolver.AddRelu() != kTfLiteOk) {
       return;
     }
     if (resolver.AddReshape() != kTfLiteOk) {
       return;
     }
     if (resolver.AddSoftmax() != kTfLiteOk) {
       return;
     }

   // 构建解释器以使用该模型运行推断。
   static tflite::MicroInterpreter static_interpreter(
       model, resolver, tensor_arena, kTensorArenaSize);
   interpreter = &static_interpreter;

   // 从 tensor_arena 为模型的张量分配内存。
   TfLiteStatus allocate_status = interpreter->AllocateTensors();
   if (allocate_status != kTfLiteOk) {
     MicroPrintf("AllocateTensors() failed");
     return;
   }

}

我们在这里看到了 ESP 是如何被营销的。Arduino 代码使用 "setup()" 和 "loop()" 方法。这类似于那些程序员熟悉的"模板方法"模式。为了系统自身方便,一些操作通过名称方便的方法(setup() 和 loop())执行,之后运行用户代码。这并非 Arduino 项目,但如果给予适当关注,它可能是。当然,肯定有大量在 Arduino 兼容设备上运行 TinyML / TensorFlow Lite Micro 的项目。本项目主要采用了 ESP32 约定,但 Arduino 的兼容性可能有所帮助。

你可以在上面看到获取模型、建立操作解析器、创建解释器和张量内存分配的步骤。完成后,可以使用下面的方法进行预测:

static void set_input(const uint16_t* input_data, int length)
{
     TfLiteTensor* input = interpreter->input(0);
     // 未经归一化传递原始值(匹配更新的训练方法)
     for (int i = 1; i < 8 && i < length; i++)
     {
         input->data.f[i-1] = (float)input_data[i]; // 直接转换为 float
     }
     input->data.f[7] = (float)length-1; // 将长度设置为第 8 个输入
     // 如果我们有不到 7 个间隔,则用零填充
     for (int i = length; i < 7; i++)
     {
         input->data.f[i] = 0.0f; // 用零填充
     }
     printf("input type = %d\n", input->type);
     MicroPrintf("%f %f %f %f %f %f",
          input->data.f[0], input->data.f[1], input->data.f[2], input->data.f[3],
          input->data.f[4], input->data.f[5], input->data.f[6], input->data.f[7]);
}

static void run_inference(void)
{
     TfLiteStatus invoke_status = interpreter->Invoke();
     if (invoke_status != kTfLiteOk) {
       MicroPrintf("Invoke failed with: %d\n",
                           invoke_status);
       return;
     }
}

// 返回最高得分预测的索引
static int get_prediction(void)
{
     TfLiteTensor* output = interpreter->output(0);

     int best_index = 0;
     float best_score = output->data.f[0];

     for (int i = 1; i < output->dims->data[1]; i++)
     {
         float score = output->data.f[i];
         if (score > best_score)
         {
             best_score = score;
             best_index = i;
         }
     }

     return best_index;
}

在推理时,我们使用输入张量输入数据,然后使用解释器运行推断。一旦解释器返回,我们可以使用输出张量获取回预测。分数映射到选择 id。代码上面通过假设第 0 个选择是最好的开始,然后检查其他选择是否有更好的分数。最佳分数是模型认为最可能是正确答案的分数。

5、如何使用

与许多本系列中的其他微控制器项目不同,该项目大量利用日志输出。由于它使用 LED 来指示选择,该项目仅需电源供应,但如果连接到 PC 时方便提供"监视"操作,则由 ESP 代码提供 LED 指示。视频链接下方展示了该项目的实际操作。

C:\...\ESP32Projects\knock_classifier>idf.py -p COM6 monitor
Executing action: monitor
Running idf_monitor in directory C:\...\ESP32Projects\knock_classifier
Executing "C:\python_env\idf5.3_py3.11_env\Scripts\python.exe C:\tools\Espressif\frameworks\esp-idf-v5.3.1\tools/idf_monitor.py -p COM6 -b 115200 --toolchain-prefix riscv32-esp-elf --target esp32c6 --revision 0 --decode-panic backtrace C:\Users\lesfo\gitfiles\ESP32Projects\knock_classifier\build\knock_classifier.elf --force-color -m 'C:\tools\Espressif\python_env\idf5.3_py3.11_env\Scripts\python.exe' 'C:\tools\Espressif\frameworks\esp-idf-v5.3.1\tools\idf.py' '-p' 'COM6'"...
--- Warning: GDB cannot open serial ports accessed as COMx
--- Using \\.\COM6 instead...
--- esp-idf-monitor 1.5.0 on \\.\COM6 115200
--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H
ESP-ROM:esp32c6-20220919
Build:Sep 19 2022
rst:0x15 (USB_UART_HPSYS),boot:0x1e (SPI_FAST_FLASH_BOOT)
Saved PC:0x408037de
--- 0x408037de: rv_utils_wait_for_intr at C://esp-idf-v5.3.1/components/riscv/include/riscv/rv_utils.h:55
(inlined by) esp_cpu_wait_for_intr at C:/tools/Espressif/frameworks/esp-idf-v5.3.1/components/esp_hw_support/cpu.c:62
SPIWP:0xee
mode:DIO, clock div:2
load:0x40875720,len:0x1974
load:0x4086c110,len:0xfe4
load:0x4086e610,len:0x2f4
entry 0x4086c11a
I (23) boot: ESP-IDF v5.3.1-dirty 2nd stage bootloader
I (23) boot: compile time Jan 12 2026 21:50:25
I (24) boot: chip revision: v0.1
I (26) qio_mode: Enabling default flash chip QIO
I (32) boot.esp32c6: SPI Speed      : 80MHz
I (36) boot.esp32c6: SPI Mode       : QIO
I (41) boot.esp32c6: SPI Flash Size : 2MB
I (46) boot: Enabling RNG early entropy source...
I (51) boot: Partition Table:
...
I (287) coexist: coexist rom version 5b8dcfa
I (292) main_task: Started on CPU0
I (292) main_task: Calling app_main()
进入捕获模式
Enabling interrupt to sensor GPIO
收到捕获结束信号
退出捕获模式
分析间隔...
Captured intervals: 1532, 338, 274, 154, 358, 623, 382, 0
input type = 1
338.000000 274.000000 154.000000 358.000000 623.000000 382.000000 0.000000 6.000000
Predicted: 0

进入捕获模式
Enabling interrupt to sensor GPIO
收到捕获结束信号
退出捕获模式
分析间隔...
Captured intervals: 876, 226, 228, 262, 790, 250, 248, 255
input type = 1
226.000000 228.000000 262.000000 790.000000 250.000000 248.000000 255.000000 7.000000
Predicted: 0

以上是来自 IDF "monitor"命令的会话。在 Windows 上,只需运行:

> idf.py -P comN monitor

在你的项目目录中,将"N"替换为你的 ESP 连接的 Windows COM 端口,它将开始监控。类似的命令应该适用于 Linux。你可以通过这样的代码添加自己的输出:

     printf("input type = %d\n", input->type);
     MicroPrintf("%f %f %f %f %f %f",
          input->data.f[0], input->data.f[1], input->data.f[2], input->data.f[3],
          input->data.f[4], input->data.f[5], input->data.f[6], input->data.f[7]);

如上所述,还有三个 LED 和一个按钮。琥珀色 LED 显示何时收到敲门输入(太容易或太轻的敲门是很好的指标),其他 LED 表示模型做出的选择。蓝色是"刮和剃发",绿色是"两组四连拍"。视频链接下方展示了该项目在操作中。

6、软件

像大多数或所有微控制器项目一样,有两个完全不同的开发环境。一个是微控制器本身,需要编码加上部署(将代码闪存到板上)。另一个是工作站,你可以交叉编译到微控制器。这通常会生成一个格式如".elf"的二进制文件。

本项目充分利用了 ESP 的中断处理、任务和 FreeRTOS 中的定时器队列等功能。FreeRTOS 包含在 ESP-IDF 框架中。

7、组件

要构建此项目,你需要以下内容。除非另有说明,这些组件可以在大多数电子爱好者套件中找到:

  • 扩展板(这里链接了两个用于隔离振动传感器,但可能不是必需的) 导线(dupont 电缆即可)
  • 三个 100Ω、220Ω 或 330Ω 电阻用于 LED* 三个 LED
  • 瞬时开关
  • 振动传感器。

下方的是 SW-420。这些可以成套购买,价格约为 6.50 美元。下方的一个是包含在传感器套件中的 Xiao ESP32-C6。可能有其他也能良好工作的 ESP32 变体。此代码可能需要大量内存,所以请记住这一点。该一个可能需要约 12.00 美元,当然可以用于许多其他项目中。

8、接线

如下图所示,该电路需要几根连接到 Xiao 板的导线。下方的接线描述假设板是如上图所示(USB 连接器在右侧)。

  • 一个电阻器有一条腿连接到左下引脚,另一条腿连接到下长引脚(anode)腿。
  • 一条电阻器有一条腿连接到左下引脚,另一条腿连接到第二个左下引脚(anode)腿。
  • 一条电阻器有一条腿连接到第四个左下引脚,另一条腿连接到下长引脚(anode)腿。
  • 三个 LED 的较短(cathode)腿每条都必须连接到地。
  • 三个 LED 的较长(anode)腿连接到地。
  • 按钮的输入连接到第三个右上引脚,其输出连接到地。当按下时,高电压出现在通往 Xiao 引脚的输出线上。
  • 振动传感器的最左侧引脚是输出,按方向居中。其中心是地,其最右侧引脚是电源。未显示:电源必须通过导线或排插电缆连接到面包板电源。
  • 按钮的输出也是电源,应该连接到面包板电源。

电路其余部分如下所述:

  • 该电阻器是作为"下拉"配置的,以防止 LED 瞬时烧毁。
  • 按钮作为"上拉"配置。
  • 来自振动传感器(左侧,按图像中的方向)的输出进入通往 Xiao 引脚的输入。
  • 来自 Xiao(右侧,朝向图像中左侧)的输出进入通往 Xiao 引脚的输入。

电路中显示的额外收缩导线贴向左突出是次级电源输入。经过一些仔细的焊接,可以将可充电电池组连接到这些设备之一。这是可选的,此处仅供参考。

为了参考,这里包含了引脚图。

没有图片

9、讨论

该项目最初是作为在更小的设备上获取模型的尝试,但提出了一些挑战。一方面,STM32F103C8T6 不是 TensorFlow Lite 的"原生"设备。它本应是通用设置。其他 STM32 有更多关于此主题的文档。内存也有点过于受限,导致一些不寻常的步骤。

在使用时的一个挑战是关于是否"量化"或如何进行。在这种情况下,量化并不是必需的。正确地操作它需要一些努力,并且必须在训练代码和部署代码之间保持大量一致性。如果原来就是较小的设备,量化可能会节省大量内存。ChatGPT 在接受提示代码时绝对值得提醒这个项目中的细节可能会被遗漏。与 Claude Code 一样,它有助于将这些细节汇总在一起。总是,重要的是要意识到细节。

物联网应用使用摄像头和进行手势识别的入门项目。本项目要简单一些,可能使用更少的内存,并且可以用更便宜的组件完成。这是一个简单的应用程序,但使用的是真实模型。


原文链接: Who's Knocking? Your Microcontroller Can Tell

汇智网翻译整理,转载请标明出处