Want to build your own TinyML application? This is a detailed approach to getting started with TensorFlow Lite for Microcontrollers!
Story
Introduction
In the summer of 2022 I was selected to participate in the Google Summer of Code (GSoC) under TensorFlow. While trying to build my firstĀ project,Ā I quickly realized that there werenāt many good beginner practical guides for using TensorFlow Lite for Microcontrollers. For this blog I hope to somewhat remedy that by introducing TFLite for Micro and how you can get started on your journey with the tool. As I plan to improve this blog or create more content in the future, please leave your feedback in the comments! Happy Hacking!
The tutorial in a nutshell
1. Decide on an idea
2. Decide on the components
3. Collect data and train a MachineĀ LearningĀ model
4. BuildĀ theĀ applicationĀ code
5.Ā Conclusion
Before we go ahead, itās good to learn some definitions.
1.Ā TensorFlow:Ā TensorFlow is a free, open-source software library for machine learning and artificial intelligence. It works well for backend and PC inference.
2.Ā TensorFlow Lite:Ā TensorFlow Lite is a set of tools that enables on-device machine learning by helping developers run their models on mobile, embedded, and edge devices.
3.Ā TensorFlow Lite Micro/TinyML:Ā TensorFlow Lite for Microcontrollers is a library designed to run machine learning models on microcontrollers and other devices with only a few kilobytes of memory. It doesnāt require operating system support, standard C or C++ libraries, or dynamic memory allocation.
1. Decide on an idea
Firstly, youāll need to come up with an idea for a project. Say you want to build your own smart shoes that can track your daily activity or a smart water bottle that tracks how much water you have had throughout the day and automatically notifies you to drink more water.
Once you come up with an idea, try to answer this question: Is ML really needed here?
2. Decide on the components
Once you have a clear idea for your project, itās time to figure out how to execute it. Say you want to build a smart glove that can detect complex gestures. Youāll either need flex sensors and an accelerometer or a camera.
This tutorial will demonstrate a simple gesture detector using an ESP32 microcontroller. The sensor of choice that Iām going to use is an MPU6050. It consists of a three-axis accelerometer and a three-axis gyroscope. The data from the accelerometer will be used to detect a simple shoot gesture.
3. Collect data and train a Machine Learning model
3.a Installing the Adafruit_MPU6050 library
First you will need the Arduino IDE installed. Next, under theĀ ToolsĀ section, click theĀ Manage Libraries, search forĀ Adafruit MPU6050, select theĀ Adafruit MPU6050Ā library and clickĀ Install
3.b Wiring
- ConnectĀ board VCCĀ toĀ ESP323V3
- ConnectĀ board GNDĀ toĀ ESP32GND
- ConnectĀ board SCLĀ toĀ ESP32SCL (GPIO 22)
- ConnectĀ board SDAĀ toĀ ESP32SDA(GPIO 21)
3.c Basics of theĀ Adafruit_MPU6050 library
The Adafruit_MPU6050 library is a library that allows you to interface with the MPU6050 sensor via a microcontrollerās i2c bus. The basics.ino file logs the accelerometer and gyroscope data onto the serial monitor.
//basics.ino
/* including header files */
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <Wire.h>
/* instantiates an object of the 'Adafruit_MPU6050' class */
Adafruit_MPU6050 mpu;
void setup(void)
{
Serial.begin(115200);
while (!Serial)
{
delay(10);
}
/* Try to initialize! */
if (!mpu.begin())
{
Serial.println("Failed to find MPU6050 chip");
while (1)
{
delay(10);
}
}
mpu.setAccelerometerRange(MPU6050_RANGE_16_G);
mpu.setGyroRange(MPU6050_RANGE_250_DEG);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
Serial.println("");
delay(100);
}
void loop()
{
/* Get new sensor events with the readings */
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
/* Print out the accelerometer values */
Serial.print("AccelX:");
Serial.print(a.acceleration.x);
Serial.print(",");
Serial.print("AccelY:");
Serial.print(a.acceleration.y);
Serial.print(",");
Serial.print("AccelZ:");
Serial.print(a.acceleration.z);
Serial.print(", ");
/* Print out the gyroscope values */
Serial.print("GyroX:");
Serial.print(g.gyro.x);
Serial.print(",");
Serial.print("GyroY:");
Serial.print(g.gyro.y);
Serial.print(",");
Serial.print("GyroZ:");
Serial.print(g.gyro.z);
Serial.println("");
delay(10);
}
3.d ChoosingĀ a threshold value
We only need the accelerometer data to train the model and also, we donāt want inference to run all the time. Open up the serial plotter and use the below code to figure out a threshold value above which the inference will be called.
TheĀ acc_visualize.inoĀ file visualizes the accelerometer data from the MPU6050 sensor. Trying out the gestures and figuring out the threshold is done via this (weāll be figuring out the threshold value through trial and error).
/* acc_visualize.ino */
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <Wire.h>
/* instantiates an object of the Adafruit_MPU6050 class */
Adafruit_MPU6050 mpu;
void setup(void) {
Serial.begin(115200);
while (!Serial) {
delay(10);
}
/* Try to initialize mpu! */
if (!mpu.begin()) {
Serial.println("Failed to find MPU6050 chip");
while (1) {
delay(10);
}
}
mpu.setAccelerometerRange(MPU6050_RANGE_16_G);
mpu.setGyroRange(MPU6050_RANGE_250_DEG);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
Serial.println("");
delay(100);
}
void loop() {
/* Get new sensor events with the readings */
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
/* Print out the values */
Serial.print("AccelX:");
Serial.print(a.acceleration.x);
Serial.print(",");
Serial.print("AccelY:");
Serial.print(a.acceleration.y);
Serial.print(",");
Serial.print("AccelZ:");
Serial.println(a.acceleration.z);
delay(10);
}
3.e Logging dataĀ forĀ theĀ MLĀ model
Now that you have a threshold value to work with letās start logging the data
TheĀ log_data.inoĀ file handles the logging of data on the serial monitor. Some notes about this file are:
1. TheĀ calibrate_mpuĀ function runs in the setup part of the code and it takes in 10 different readings to average them
2. TheĀ detect_motionĀ function reads one set of reading from the MPU, and if the sum of the absolute values of the accelerations is greater than a threshold value, it triggers the read_data function
3. The value forĀ READINGS_PER_SAMPLEĀ is also found using trial and error. It varies for different gestures.
4. The read_data function logsĀ READINGS_PER_SAMPLEĀ sets of accelerometer data to be used for training purposes.
//log_data.ino
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <Wire.h>
#define THRESHOLD 20
#define READINGS_PER_SAMPLE 40
Adafruit_MPU6050 mpu;
int count =1;
float x_initial, y_initial, z_initial;
void setup() {
Serial.begin(115200);
while (!Serial)
delay(10);
Serial.println("Adafruit MPU6050 test!");
/* Try to initialize! */
if (!mpu.begin(0x69)) {
/* Serial.println("Failed to find MPU6050 chip"); */
while (1) {
delay(10);
}
}
/* Serial.println("MPU6050 Found!"); */
mpu.setAccelerometerRange(MPU6050_RANGE_8_G);
mpu.setGyroRange(MPU6050_RANGE_500_DEG);
mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);
delay(100);
calibrate_mpu();
}
void loop() {
detect_motion();
}
void read_data(){
for(int i =0;i<40;i++){
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
Serial.print(a.acceleration.x );
Serial.print(",");
Serial.print(a.acceleration.y );
Serial.print(",");
Serial.println(a.acceleration.z);
delay(10);
}
Serial.println("");
Serial.println("--------");
Serial.println(count);
Serial.println("--------");
count++;
}
void calibrate_mpu(){
float totX, totY, totZ;
sensors_event_t a, g, temp;
for (int i = 0; i < 10; i++) {
mpu.getEvent(&a, &g, &temp);
totX = totX + a.acceleration.x;
totY = totY + a.acceleration.y;
totZ = totZ + a.acceleration.z;
}
x_initial = totX / 10;
y_initial = totY / 10;
z_initial = totZ / 10;
Serial.println("Calibrated");
}
void detect_motion(){
sensors_event_t a, g, temp;
mpu.getEvent(&a, &g, &temp);
if( abs(a.acceleration.x - x_initial) +abs(a.acceleration.y - y_initial) + abs(a.acceleration.z - z_initial) > 20){
read_data();
}
else{
delay(5);
}
}
Once you get data from the serial monitor, copy-paste them into aĀ .csv
Ā file
Youāll need to create two files:
- Shoot.csv: Accelerometer data of the shoot gesture
- Noshoot.csv: Accelerometer data of random gestures
3.f training the model
Open up thisĀ colab notebook,Ā run the cells andĀ download the model.h file from the sidebar.
The model architecture:
Experimented with different hyperparameters like the number of hidden layers, units, dropout, activation functions, and batch size. I also tried different optimizers, loss functions and finally settled on this.
I didnāt use a CNN as the fully connected layers gave good enough accuracy.
from tensorflow.keras import regularizers
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(10, activation='relu')) # relu is used for performance
model.add(tf.keras.layers.Dense(10, activation='relu',kernel_regularizer= regularizers.L1(l1=1e-4)))
model.add(tf.keras.layers.Dense(NUM_GESTURES, activation = 'softmax')) # softmax is used, because we only expect one gesture to occur per input
model.compile(optimizer=tf.keras.optimizers.Adam(), loss='mse', metrics=['mae'])
history = model.fit(inputs_train, outputs_train, epochs=300, batch_size=1, validation_data=(inputs_validate, outputs_validate))
The output of the model is of a 2D tensor:Ā [[probability of class 1, probability of class 2]]
Where,
- Class 1: Unknown gesture
- Class 2: Shoot gesture
4. Build the application code
4.a Installing the TensorFlow_ESP32Ā library
Under theĀ ToolsĀ section, click theĀ Manage Libraries, search forĀ TensorFlowLite_ESP32, and select theĀ TensorFlowLite_ESP32Ā library and clickĀ Install
4.b Adding model.h file
Under theĀ SketchĀ section, ClickĀ ShowSketchFolderĀ and add the model.h file in this folder.
4.c Header Files
#include <TensorFlowLite_ESP32.h>
This library allows you to run machine learning models locally on your ESP32.
#include "tensorflow/lite/experimental/micro/micro_error_reporter.h"
This line imports the class that can log errors and output to help with debugging.
#include "tensorflow/lite/experimental/micro/micro_interpreter.h"
This line imports theĀ TensorFlow Lite for MicrocontrollersĀ interpreter, which will run our model.
#include "tensorflow/lite/experimental/micro/kernels/all_ops_resolver.h"
The line imports the class that allows the interpreter to load all the operations available to TensorFlow Lite Micro.
#include "tensorflow/lite/experimental/micro/micro_mutable_op_resolver.h"
This line imports the class that allows the interpreter to load only the necessary operations used by our model.
A quick comparison between the resolvers:
Micro Ops Resolver
/* Code */
static tflite::MicroMutableOpResolver micro_mutable_op_resolver;
micro_mutable_op_resolver.AddBuiltin(
tflite::BuiltinOperator_FULLY_CONNECTED,
tflite::ops::micro::Register_FULLY_CONNECTED());
/* Info about sketch size */
Sketch uses 290794 bytes (22%) of program storage space. Maximum is 1310720 bytes.
Global variables use 90392 bytes (27%) of dynamic memory, leaving 237288 bytes for local variables. Maximum is 327680 bytes.
All Ops Resolver
/* Code */
static tflite::ops::micro::AllOpsResolver resolver;
/* Info about sketch size */
Sketch uses 376026 bytes (28%) of program storage space. Maximum is 1310720 bytes.
Global variables use 91752 bytes (28%) of dynamic memory, leaving 235928 bytes for local variables. Maximum is 327680 bytes.
The difference between the resolvers is that AllOps Resolver, by default, contains all the operations available to TFLite Micro. In contrast, we must register the operations necessary while using the Micro Ops Resolver. The benefit of using a MicroOpsResolver is reduced sketch size compared to the AllOpsResolver.
#include "tensorflow/lite/experimental/micro/kernels/micro_ops.h"
This line imports the class that contains all the operations needed by the resolver.
#include "tensorflow/lite/schema/schema_generated.h"
Line of code imports the schema that defines the structure of TensorFlow Lite FlatBuffer data, used to make sense of the model data in sine_model_data.h
#include "tensorflow/lite/version.h"
This line of code imports the current version number of the schema, so we can check that the model was defined with a compatible version.
#include "model.h"
This line of code allows our main model to access the model.h file. The IDE view is attached below. Iāll explain it better in the below sections.
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>
#include <Wire.h>
These lines of code import the āAdafruit_MPU6050ā library into the project. I chose this library for the tutorial as, from my experience, this library is very understandable.
The Checklist:
a. Variables
- Declare Pointers
- Declare Tensor_arena array
b. Setup Function
- Set up Error Reporter
- Map the ML model with Get_Model() function
- Compare TF Lite versions
- Create an Op Resolver
- Build an interpreter
- Allocate an area of working memory using Allocate_tensors()
- Assign input and output tensors
c. Loop Function
- Load data using the input tensors
- Invoke inference using the interpreter
- Access output tensors
Source: How to get started with TensorFlow Lite for Microcontrollers