IoT šŸ“– 16 min read

TinyML Gesture Recognition on an Arduino Nano 33 BLE Sense or ESP32

This project trains a small neural network to tell three movements apart (a circle drawn in the air, a side-to-side shake, and holding still) from accelerometer data, then runs it on the microcontroller with no network connection. Edge Impulse handles data collection, feature extraction and training in the browser. You can follow it with an Arduino Nano 33 BLE Sense, whose accelerometer is on the board, or with a classic ESP32 and an MPU6050 breakout, which needs four wires and a little more setup.

Choose a board

Both routes use the same Edge Impulse project steps. They differ in how data reaches Edge Impulse and how the model gets back onto the board.

Nano 33 BLE Sense (original or Rev2) ESP32 + MPU6050
Chip and RAMnRF52840 at 64 MHz, 256 KB SRAM, 1 MB flash (Arduino)520 KB internal SRAM (ESP-IDF memory types)
AccelerometerBuilt in, no wiringMPU6050 breakout on I2C
Data collectionEdge Impulse firmware, sampled from StudioYour own sketch plus the Edge Impulse data forwarder
Running the modelReady-made example in the generated librarySketch below, on ESP32 Arduino core 2.0.4

The Nano is the shorter path because Edge Impulse fully supports it (board guide). The two Nano revisions use different motion sensors, so you'll need to know which one you have: Rev2 boards have NANO 33 BLE SENSE REV2 printed on the underside.

A model learns the sensor it was trained on. If you build both versions, use a separate Edge Impulse project for each board.

Install the tools

  • An Edge Impulse account. The Developer plan is aimed at individual developers and prototyping; the pricing page lists its current limits.
  • Python 3 and Node.js 16 or later. Edge Impulse recommends Node.js 22. On Windows, accept the installer's offer to add the tools for native modules.
  • Arduino CLI, which the Nano's firmware flash script uses, and Arduino IDE 2 for building the final sketch.
  • On Linux, GNU Screen (sudo apt install screen on Debian and Ubuntu).

Then install the Edge Impulse CLI, as its installation page shows:

PowerShell / Bash
npm install -g edge-impulse-cli

Check: in PowerShell, Get-Command edge-impulse-daemon, edge-impulse-data-forwarder lists both commands. On Linux or macOS, command -v edge-impulse-daemon edge-impulse-data-forwarder prints two paths. arduino-cli version should also work.

If PowerShell answers with "The execution of scripts is disabled on this system", it is blocking the script wrappers npm creates. Edge Impulse's troubleshooting fixes this by changing PowerShell's execution policy. Set-ExecutionPolicy -Scope CurrentUser RemoteSigned limits the change to your own account and still blocks unsigned scripts downloaded from the internet.

In Edge Impulse Studio, create a new project for this board before moving on.

Connect a Nano 33 BLE Sense

Skip to the ESP32 section if that's your board.

  1. Connect the board with a micro-USB data cable and press RESET twice quickly. The LED starts pulsing to show the bootloader is running.
  2. Download the Edge Impulse firmware for the Nano 33 BLE Sense and unzip it. The same firmware works on both revisions.
  3. Run the flash script for your system: flash_windows.bat, flash_mac.command or flash_linux.sh. When it finishes, press RESET once.
  4. Run edge-impulse-daemon, log in, and pick the project you created. Add --clean later if you want to switch the board to another project.

Check: the board appears on the project's Devices page with a green dot. Recent versions of Chrome and Edge can also connect to the board from Studio directly, without the daemon.

If the flash script fails, double-press RESET again so the LED pulses, then retry. Make sure the cable carries data. Continue at Collect training data.

Connect an ESP32 and MPU6050

Terminal window with the commands from this section
Install core 2.0.4, the version Edge Impulse tests, and the MPU6050 library.
Wiring diagram: MPU6050 VCC to 3V3, GND to GND, SCL to GPIO22, SDA to GPIO21 and AD0 to GND for address 0x68.
The same I2C pins as the weather station. AD0 to GND sets the address to 0x68; tie it to 3V3 instead for 0x69.

Wire the MPU6050

The wiring uses the same I2C pins as the ESP32 BME280 weather station. You can leave the BME280 connected, since it uses a different address.

  • VCC to 3V3, GND to GND
  • SCL to GPIO22, SDA to GPIO21
  • AD0 to GND, which sets the address to 0x68 (tie it to 3V3 instead for 0x69)

Check: the I2C scan sketch from the weather station guide prints Device found at 0x68.

Install the core version Edge Impulse tests

Edge Impulse tests its ESP32 Arduino examples with Arduino-ESP32 core 2.0.4. On the 3.x cores, the generated library can fail to compile with either all initializer clauses should be designated or none of them should be (Edge Impulse Arduino deployment docs). Install 2.0.4 for this project. In Boards Manager, pick version 2.0.4 of esp32 by Espressif Systems from the version list, or use the CLI:

PowerShell / Bash
arduino-cli core install esp32:esp32@2.0.4 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
arduino-cli lib install "Adafruit MPU6050@2.2.9"

Only one version of a core is installed at a time, so this replaces any newer ESP32 core. The cleanup section shows how to switch back. In the IDE, install Adafruit MPU6050 2.2.9 from Library Manager and accept its dependencies.

Stream accelerometer data

The data forwarder reads one line per sample over serial at 115200 baud, with the axes separated by commas. This sketch sends 50 samples a second in m/s². The accelerometer range and filter settings matter: the sketch that runs the model must use exactly the same ones.

MPU6050Forwarder.ino
#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

constexpr int SDA_PIN = 21;
constexpr int SCL_PIN = 22;
constexpr uint8_t MPU_ADDR = 0x68;  // 0x69 if AD0 is tied to 3V3
constexpr float SAMPLE_HZ = 50.0F;  // pass the same value to --frequency
constexpr unsigned long INTERVAL_US = (unsigned long)(1000000.0F / SAMPLE_HZ);

Adafruit_MPU6050 mpu;
unsigned long lastSampleUs = 0;

void setup() {
  Serial.begin(115200);
  delay(500);
  Wire.begin(SDA_PIN, SCL_PIN);

  if (!mpu.begin(MPU_ADDR, &Wire)) {
    Serial.println("MPU6050 not found");
    while (true) {
      delay(1000);
    }
  }

  // Keep these two settings identical in the classification sketch.
  mpu.setAccelerometerRange(MPU6050_RANGE_4_G);
  mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);  // below half of 50 Hz

  lastSampleUs = micros();
}

void loop() {
  if (micros() - lastSampleUs < INTERVAL_US) {
    return;
  }
  lastSampleUs += INTERVAL_US;

  sensors_event_t accel, gyro, temp;
  mpu.getEvent(&accel, &gyro, &temp);

  // One reading per line, comma-separated, in m/s^2
  Serial.printf("%.3f,%.3f,%.3f\n",
                accel.acceleration.x, accel.acceleration.y, accel.acceleration.z);
}

Select ESP32 Dev Module and upload. From the CLI, after arduino-cli sketch new MPU6050Forwarder and pasting the code in (use your own port):

PowerShell / Bash
arduino-cli compile --fqbn esp32:esp32:esp32 MPU6050Forwarder
arduino-cli upload -p COM5 --fqbn esp32:esp32:esp32 MPU6050Forwarder

Check: in the Serial Monitor at 115200 baud, a board lying flat prints lines like 0.120,-0.310,9.790, with gravity, about 9.8 m/s², on whichever axis points up. Tilt it and the value moves to another axis. Close the Serial Monitor afterwards, because the forwarder needs the port.

Start the data forwarder

PowerShell / Bash
edge-impulse-data-forwarder --frequency 50

Log in, choose your project, name the three axes accX, accY, accZ when asked, and give the device a name. Leave it running while you collect data. If you change the sample rate or axis count in the sketch later, run it once with --clean to reset the stored configuration.

Check: the device shows up on the project's Devices page.

Collect training data

Record three classes, one label each:

  • circle: draw circles in the air with the board.
  • shake: shake it side to side.
  • idle: hold it still or leave it on the desk. Without this class the model would have to call every moment either a circle or a shake.

In Studio, open Data acquisition and under Record new data:

  1. Select your device.
  2. Nano: choose Built-in accelerometer at 62.5Hz, the setting Edge Impulse's motion recognition tutorial uses. ESP32: the sensor axes and the 50 Hz rate come from the forwarder configuration.
  3. Enter the label and a sample length of 10000 ms.
  4. Click Start sampling and keep moving for the whole 10 seconds. Edge Impulse cuts each recording into short windows, so continuous movement matters.

Repeat until each class has around three minutes of data. Vary speed, grip and orientation, and if you can, have another person record a few samples. Keep some samples of every class in the test set, which Data acquisition lists separately, so you can test the model on recordings it never trained on.

Check: open a sample. You should see three lines, one per axis, that look different for each class.

Design the impulse

An impulse is Edge Impulse's name for the pipeline from raw data to a prediction. On Create impulse:

Diagram of the Edge Impulse flow: collect three classes in 10-second samples, design the impulse with 2,000 ms windows and an 80 ms step, train for 30 cycles, then deploy as an Arduino library.
The Edge Impulse flow with this guide's settings: record, cut into overlapping two-second windows, train, then build an Arduino library.
  1. Set the window size to 2000 ms and the window increase to 80 ms. Each prediction looks at two seconds of motion, and windows overlap so training gets many examples from each recording.
  2. Add the Spectral Analysis processing block, which pulls frequency and power features out of repetitive motion.
  3. Add the Classification learning block, then click Save impulse.
  4. Open Spectral features, click Save parameters, then Generate features.

Check: the feature explorer plots every window. Points from the same class should form clusters. If circle and shake points are mixed together, record more varied data before training.

Train and test the model

  1. Open the classifier page (NN Classifier), set the number of training cycles to 30, and click Start training.
  2. Read the accuracy and the confusion matrix. The matrix shows which classes the model mixes up.
  3. Open Live classification, record a new 10-second sample while doing one gesture, and check the result.

Very high accuracy isn't automatically good. Edge Impulse warns that 100% can mean the network is overfitting, matching the training data closely but doing badly on new movement. If live results are poor, the motion recognition tutorial lists the usual causes:

  • Too little data.
  • New movement that doesn't look like the training data. Add those recordings to the training set.
  • Too few training cycles.
  • Overfitting. Lower the learning rate or add more data.
  • A network that doesn't suit the data. Try changing the number of layers and neurons.

Pick int8 or float32

Microcontrollers have little RAM, so models are usually quantized: weights stored as 8-bit integers instead of 32-bit floats, which takes a quarter of the space and lets the chip use integer maths. How much accuracy that costs depends on the model, so measure it instead of assuming.

On the Deployment page, Edge Impulse lets you choose between Quantized (int8) and Unoptimized (float32) and shows estimated latency, flash and RAM for each. It also offers the EON Compiler, which aims to cut RAM and flash use without changing accuracy (deployment docs).

Check: click Run model testing and compare the int8 and float32 results on your test set. If int8 is close, use it. If it falls well short, use float32, as long as the estimated RAM fits your board.

Build the Arduino library

Editor window showing GestureClassifier.ino (below the _inferencing.h include) from the section Build the Arduino library
The top of the sketch: MPU6050 libraries and the I2C pins, 21 and 22.
  1. On Deployment, search for and select Arduino library, then click Build. A .zip file downloads.
  2. In Arduino IDE, choose Sketch → Include Library → Add .ZIP Library and select the file.
  3. Restart the IDE if the new examples don't show up under File → Examples → (your project name)_inferencing. Edge Impulse's docs also show this menu entry as "Your project name - Edge Impulse".

Run it on the Nano

  1. Install Arduino Mbed OS Nano Boards in Boards Manager (arduino-cli core install arduino:mbed_nano does the same), then select Arduino Nano 33 BLE and the board's port.
  2. Open File → Examples → (your project name)_inferencing, then the accelerometer example under nano_ble33_sense for the original board or nano_ble33_sense_rev2 for Rev2. The two use different sensors, so the wrong one won't read motion data.
  3. Upload, then open the Serial Monitor at 115200 baud.

Uploading this sketch replaces the Edge Impulse data-collection firmware. Flash it again with the script from earlier if you want to record more data.

Run it on the ESP32

The library's header is named after your project. To get that name right, start from the generated static_buffer example:

  1. Open File → Examples → (your project name)_inferencing → static_buffer → static_buffer and use File → Save As to save a copy named GestureClassifier.
  2. In the copy, keep the line that includes ..._inferencing.h, delete everything else, and paste the code below underneath it.
  3. Select ESP32 Dev Module on core 2.0.4 and upload. The first build compiles the whole Edge Impulse SDK, so it takes longer than a normal sketch.
GestureClassifier.ino (below the _inferencing.h include)
#include <Wire.h>
#include <Adafruit_MPU6050.h>
#include <Adafruit_Sensor.h>

constexpr int SDA_PIN = 21;
constexpr int SCL_PIN = 22;
constexpr uint8_t MPU_ADDR = 0x68;
// Sample at the rate the model was trained with.
constexpr unsigned long INTERVAL_US =
    (unsigned long)(1000000.0F / EI_CLASSIFIER_FREQUENCY);

Adafruit_MPU6050 mpu;
static float features[EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE];
size_t featureIx = 0;
unsigned long lastSampleUs = 0;

void setup() {
  Serial.begin(115200);
  delay(500);
  Wire.begin(SDA_PIN, SCL_PIN);

  if (!mpu.begin(MPU_ADDR, &Wire)) {
    Serial.println("MPU6050 not found");
    while (true) {
      delay(1000);
    }
  }
  if (EI_CLASSIFIER_RAW_SAMPLES_PER_FRAME != 3) {
    Serial.println("This sketch expects a model trained on 3 accelerometer axes");
    while (true) {
      delay(1000);
    }
  }

  // Same settings as MPU6050Forwarder.ino
  mpu.setAccelerometerRange(MPU6050_RANGE_4_G);
  mpu.setFilterBandwidth(MPU6050_BAND_21_HZ);

  lastSampleUs = micros();
}

void loop() {
  if (micros() - lastSampleUs < INTERVAL_US) {
    return;
  }
  lastSampleUs += INTERVAL_US;

  sensors_event_t accel, gyro, temp;
  mpu.getEvent(&accel, &gyro, &temp);
  features[featureIx++] = accel.acceleration.x;
  features[featureIx++] = accel.acceleration.y;
  features[featureIx++] = accel.acceleration.z;

  if (featureIx < EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE) {
    return;  // keep filling the two-second window
  }
  featureIx = 0;

  signal_t signal;
  int err = numpy::signal_from_buffer(features, EI_CLASSIFIER_DSP_INPUT_FRAME_SIZE, &signal);
  if (err != 0) {
    Serial.printf("signal_from_buffer failed (%d)\n", err);
    return;
  }

  ei_impulse_result_t result = { 0 };
  EI_IMPULSE_ERROR res = run_classifier(&signal, &result, false);
  if (res != EI_IMPULSE_OK) {
    Serial.printf("run_classifier failed (%d)\n", (int)res);
    return;
  }

  Serial.printf("DSP %d ms, classification %d ms\n",
                result.timing.dsp, result.timing.classification);
  for (size_t ix = 0; ix < EI_CLASSIFIER_LABEL_COUNT; ix++) {
    Serial.printf("  %s: %.3f\n",
                  result.classification[ix].label, result.classification[ix].value);
  }

  lastSampleUs = micros();  // start the next window after classification finishes
}

The sketch fills a two-second buffer at the model's sample rate, classifies it, then starts a new window. The data forwarder's Arduino classification example in the Edge Impulse docs works the same way.

Test on the board

Open the Serial Monitor at 115200 baud. Each result shows how long feature extraction (DSP) and classification took, then a score between 0 and 1 for each label. The highest score is the model's answer.

Check: start a gesture before a new window begins and keep it going for the full two seconds. Changing movement halfway through a window mixes the two and gives unreliable scores. Leaving the board still should give a high idle score.

If the board's results don't match what Live classification showed in Studio, test the model on its own: Edge Impulse's static buffer procedure has you paste a sample's raw features from Live classification into the static_buffer example. If that output matches Studio, the model is fine and the difference is in how the sketch reads the sensor.

Troubleshooting

  • either all initializer clauses should be designated or none of them should be (ESP32). You're compiling on a 3.x core. Install core 2.0.4 as described above.
  • Multiple libraries were found for .... Each Edge Impulse build is a new library version. Delete the older copies from Documents\Arduino\libraries on Windows, ~/Documents/Arduino/libraries/ on macOS, or ~/Arduino/libraries on Linux (Arduino IDE 2 defaults; File → Preferences → Sketchbook location shows yours).
  • The forwarder or daemon can't open the port. Only one program can use the serial port at a time. Close the IDE's Serial Monitor and any other terminal on that port.
  • The forwarder reports the wrong frequency or axis count. Run edge-impulse-data-forwarder --clean, then start it again with --frequency 50.
  • Nano firmware flashing fails. Put the board back into bootloader mode with a quick double press on RESET and retry with a data cable. On Linux, make sure GNU Screen is installed.
  • MPU6050 not found. Run the I2C scan. No device means a wiring problem; 0x69 means AD0 is high, so change MPU_ADDR in both sketches.
  • Good results in Studio, poor results on the ESP32. Check that the range, filter and sample rate in GestureClassifier.ino match the forwarder sketch. The model only knows data captured that way.

What to plan around

  • RAM sets the ceiling. The model's weights go in flash, but feature extraction and inference need working memory too. The Deployment page's RAM estimate is the number to compare with your board.
  • Data variety matters more than training time. A model trained on one person's way of moving may not work for someone else. Recording more people and grips helps more than adding training cycles.
  • Test after quantizing. The int8 model is a different model. Run model testing on the version you deploy.
  • Treat sensor settings as part of the model. Changing the accelerometer, its range, its filter or the sample rate means collecting new data and training again.

Going further

  • Sound instead of motion. The Nano 33 BLE Sense also has a microphone. Edge Impulse's sound recognition tutorial follows the same collect, design, train and deploy flow with audio processing blocks. Use the microphone examples in the generated library to run it.
  • Unknown movements. The motion recognition tutorial adds an anomaly detection block that flags movement unlike anything in the training data, instead of forcing it into one of the classes.
  • Training outside Edge Impulse. To bring your own TensorFlow model, convert it with the LiteRT post-training integer quantization workflow and run it with TensorFlow Lite Micro, or Espressif's esp-tflite-micro component on ESP32. Their hello_world examples show the pattern: register the operations the model uses with tflite::MicroMutableOpResolver, give tflite::MicroInterpreter a tensor arena, and confirm AllocateTensors() returns kTfLiteOk before calling Invoke().

Cleanup

  • Stop the data forwarder or daemon with Ctrl+C.
  • Delete old ..._inferencing folders from your Arduino libraries folder (paths in troubleshooting) once you've finished with them.
  • If you installed ESP32 core 2.0.4 for this project, switch back to the current stable core for other projects with arduino-cli core install esp32:esp32@3.3.12 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json, or by picking 3.3.12 in Boards Manager.
  • Remove the Edge Impulse CLI with npm uninstall -g edge-impulse-cli if you no longer need it.
  • Your recordings stay in the Edge Impulse project until you delete them or the project.

Related guides

  • ESP32 BME280 Weather Station covers the ESP32 board setup, the I2C scan used above, and logging serial output to a file with arduino-cli.
  • WLED Smart LED Controller runs WLED on an ESP32 and accepts commands over its JSON API, which makes it a visible output for gestures if you send recognised results to it from another device.
  • Raspberry Pi Setup Guide sets up a small Linux machine that can collect results from several boards.