IoT et IA

TinyML sur ESP32 : entraîner, déployer et publier les résultats via MQTT

Un parcours TinyML ESP32 complet : entraîner un classificateur de vibrations en Python, le convertir en modèle TensorFlow Lite int8, l'exécuter sur un ESP32 avec esp-tflite-micro et publier les résultats de classification vers un broker MQTT.

Couverture du tutoriel TinyML ESP32 montrant une carte ESP32 qui exécute un petit réseau de neurones et envoie les résultats de classification via MQTT
Essayez-le sur votre propre broker MQTTObtenez un broker MQTT privé et managé avec l’offre Free et partez d’exemples de code prêts à l’emploi.
Essayer gratuitement Exemples de code Aucune carte bancaire requise

Le TinyML sur ESP32 permet à un microcontrôleur Wi-Fi bon marché de classer lui-même des données de capteurs et de ne transmettre que la réponse. Dans ce tutoriel, vous allez entraîner un petit classificateur de vibrations en Python, le quantifier en int8, l'exécuter sur un ESP32 avec le portage de TensorFlow Lite Micro d'Espressif et publier chaque classification via MQTT vers broker.connectmq.com, le serveur MQTT public gratuit réservé aux tests. Tout fonctionne sur une simple carte ESP32, car le firmware simule le signal du capteur jusqu'à ce que vous branchiez un vrai accéléromètre.

Ce que vous allez construire

L'appareil échantillonne une fenêtre de vibrations de 256 points à 1 kHz, calcule trois caractéristiques (valeur efficace, facteur de crête et taux de passage par zéro), exécute un minuscule réseau de neurones et classe la machine en idle, normal ou fault. Il publie un résultat JSON à chaque changement de classe, plus un heartbeat toutes les 30 secondes, et maintient un statut online/offline en retained avec un Last Will.

Élément Choix
Carte N'importe quelle carte de développement ESP32 ou ESP32-S3
Framework ESP-IDF 5.x
Runtime d'inférence Composant espressif/esp-tflite-micro
Entraînement Python 3, TensorFlow 2.x, NumPy
Client MQTT esp-mqtt d'ESP-IDF
Broker broker.connectmq.com:1883 pour les tests

Pourquoi ESP-IDF plutôt que l'Arduino IDE ? Espressif maintient esp-tflite-micro en phase avec la version amont actuelle de TensorFlow Lite Micro, avec des noyaux optimisés pour l'ESP32-S3. Plusieurs portages Arduino sont en retard sur l'API amont, ce qui est une source fréquente d'erreurs de compilation. Si le TinyML est nouveau pour vous, parcourez d'abord ce qu'est le TinyML.

Étape 1 : entraîner et quantifier le modèle en Python

Créez un dossier tinyml_mqtt pour le projet ESP-IDF avec un sous-dossier main, et placez ce script d'entraînement à côté. Il génère des fenêtres de vibrations synthétiques étiquetées, entraîne un petit réseau dense, le convertit en modèle TensorFlow Lite entièrement int8 et écrit main/model_data.cc et main/model_data.h, y compris les constantes de normalisation des caractéristiques dont le firmware a besoin.

pip install tensorflow numpy
# train_model.py
import numpy as np
import tensorflow as tf

FS, N = 1000, 256
LABELS = ["idle", "normal", "fault"]
rng = np.random.default_rng(42)


def gen(cls):
    t = np.arange(N) / FS
    if cls == 0:                                   # idle: sensor noise only
        return 0.05 * rng.standard_normal(N)
    x = rng.uniform(0.8, 1.2) * np.sin(2 * np.pi * 50 * t + rng.uniform(0, 2 * np.pi))
    x += 0.1 * rng.standard_normal(N)
    if cls == 2:                                   # fault: periodic impacts
        idx = rng.choice(N, size=rng.integers(4, 10), replace=False)
        x[idx] += rng.uniform(2, 3, idx.size) * rng.choice([-1, 1], idx.size)
    return x


def features(x):
    rms = np.sqrt(np.mean(x ** 2))
    crest = np.max(np.abs(x)) / (rms + 1e-6)
    zcr = np.mean((x[1:] < 0) != (x[:-1] < 0))
    return np.array([rms, crest, zcr], dtype=np.float32)


y = rng.integers(0, 3, 6000)
X = np.stack([features(gen(c)) for c in y])
mean, std = X.mean(axis=0), X.std(axis=0)
Xn = ((X - mean) / std).astype(np.float32)

model = tf.keras.Sequential([
    tf.keras.Input(shape=(3,)),
    tf.keras.layers.Dense(16, activation="relu"),
    tf.keras.layers.Dense(3, activation="softmax"),
])
model.compile(optimizer="adam", loss="sparse_categorical_crossentropy", metrics=["accuracy"])
model.fit(Xn, y, epochs=30, batch_size=32, validation_split=0.2, verbose=2)


def representative_data():
    for i in range(300):
        yield [Xn[i:i + 1]]


converter = tf.lite.TFLiteConverter.from_keras_model(model)
converter.optimizations = [tf.lite.Optimize.DEFAULT]
converter.representative_dataset = representative_data
converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8]
converter.inference_input_type = tf.int8
converter.inference_output_type = tf.int8
tflite_model = converter.convert()
print("model size:", len(tflite_model), "bytes")

hex_bytes = ", ".join(f"0x{b:02x}" for b in tflite_model)
with open("tinyml_mqtt/main/model_data.cc", "w") as f:
    f.write('#include "model_data.h"\n\n')
    f.write(f"alignas(16) const unsigned char g_model[] = {{{hex_bytes}}};\n")
    f.write(f"const unsigned int g_model_len = {len(tflite_model)};\n")

with open("tinyml_mqtt/main/model_data.h", "w") as f:
    f.write("#pragma once\n\n")
    f.write("extern const unsigned char g_model[];\nextern const unsigned int g_model_len;\n\n")
    f.write("constexpr float kFeatMean[3] = {%s};\n" % ", ".join(f"{v:.6f}f" for v in mean))
    f.write("constexpr float kFeatStd[3] = {%s};\n" % ", ".join(f"{v:.6f}f" for v in std))
    f.write('constexpr const char *kLabels[3] = {"idle", "normal", "fault"};\n')
print("wrote main/model_data.cc and main/model_data.h")

Le modèle comporte deux couches FULLY_CONNECTED et un SOFTMAX, et la version int8 ne pèse que quelques kilo-octets. La précision de validation devrait être quasi parfaite, car les classes synthétiques sont bien séparées. Avec des données réelles, attendez-vous à passer l'essentiel de votre temps sur la collecte de données et les caractéristiques, pas sur le réseau.

Pourquoi une quantification entièrement int8

Les paramètres du convertisseur ci-dessus produisent un modèle dont les poids, les activations, l'entrée et la sortie sont tous des entiers 8 bits. C'est important sur l'ESP32 pour trois raisons : le modèle fait environ un quart de la taille de la version flottante, les noyaux optimisés d'esp-tflite-micro sont écrits pour l'int8, et l'arène nécessaire aux tenseurs intermédiaires diminue aussi. En contrepartie, vous devez vous-même convertir les entrées dans le domaine entier, à l'aide de l'échelle et du point zéro stockés dans le tenseur d'entrée, puis reconvertir les sorties. C'est exactement ce que fait le firmware ci-dessous.

Le jeu de données représentatif est ce que le convertisseur utilise pour choisir ces échelles. S'il ne couvre pas toute la plage des entrées réelles, les valeurs hors plage sont écrêtées et la précision chute sur l'appareil, même si elle semblait parfaite en Python. Lorsque vous passerez à des données de capteurs réelles, fournissez à representative_data() de vrais vecteurs de caractéristiques normalisés issus de chaque classe.

Étape 2 : créer le projet ESP-IDF

Structure du projet :

tinyml_mqtt/
  CMakeLists.txt
  sdkconfig.defaults
  main/
    CMakeLists.txt
    main.cc
    model_data.cc   (generated)
    model_data.h    (generated)

Le CMakeLists.txt de premier niveau. Il intègre l'assistant Wi-Fi des exemples d'Espressif pour garder le tutoriel concis :

cmake_minimum_required(VERSION 3.16)
set(EXTRA_COMPONENT_DIRS $ENV{IDF_PATH}/examples/common_components/protocol_examples_common)
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(tinyml_mqtt)

main/CMakeLists.txt :

idf_component_register(SRCS "main.cc" "model_data.cc" INCLUDE_DIRS ".")

sdkconfig.defaults avec votre réseau Wi-Fi :

CONFIG_EXAMPLE_CONNECT_WIFI=y
CONFIG_EXAMPLE_WIFI_SSID="your-ssid"
CONFIG_EXAMPLE_WIFI_PASSWORD="your-password"

Ajoutez ensuite le runtime d'inférence depuis l'ESP Component Registry, à partir du dossier du projet :

idf.py set-target esp32s3      # or esp32
idf.py add-dependency "espressif/esp-tflite-micro"

Étape 3 : le firmware avec inférence et MQTT

main/main.cc configure le Wi-Fi et MQTT, charge le modèle dans une arène de tenseurs statique et exécute la boucle échantillonnage, caractéristiques, inférence et publication. Remplacez TOPIC_BASE et le client ID par des valeurs uniques avant de flasher.

#include <algorithm>
#include <cmath>
#include <cstdio>

#include "esp_event.h"
#include "esp_log.h"
#include "esp_netif.h"
#include "esp_random.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "mqtt_client.h"
#include "nvs_flash.h"
#include "protocol_examples_common.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "tensorflow/lite/micro/micro_mutable_op_resolver.h"
#include "tensorflow/lite/schema/schema_generated.h"

#include "model_data.h"

#define TOPIC_BASE "connectmq/demo/esp32-7c41a9/motor1"   // make this unique
#define CLIENT_ID  "esp32-tinyml-7c41a9"                  // make this unique

static const char *TAG = "tinyml";
static constexpr int kN = 256;
static constexpr float kFs = 1000.0f;
static constexpr int kArenaSize = 8 * 1024;
alignas(16) static uint8_t tensor_arena[kArenaSize];

static esp_mqtt_client_handle_t mqtt = nullptr;
static volatile bool mqtt_ready = false;

static void mqtt_event(void *arg, esp_event_base_t base, int32_t id, void *data) {
  if (id == MQTT_EVENT_CONNECTED) {
    mqtt_ready = true;
    esp_mqtt_client_publish(mqtt, TOPIC_BASE "/status", "online", 0, 1, 1);
    ESP_LOGI(TAG, "MQTT connected");
  } else if (id == MQTT_EVENT_DISCONNECTED) {
    mqtt_ready = false;
    ESP_LOGW(TAG, "MQTT disconnected");
  }
}

static void start_mqtt() {
  esp_mqtt_client_config_t cfg = {};
  cfg.broker.address.uri = "mqtt://broker.connectmq.com:1883";
  cfg.credentials.client_id = CLIENT_ID;
  cfg.session.last_will.topic = TOPIC_BASE "/status";
  cfg.session.last_will.msg = "offline";
  cfg.session.last_will.qos = 1;
  cfg.session.last_will.retain = 1;
  mqtt = esp_mqtt_client_init(&cfg);
  esp_mqtt_client_register_event(mqtt, MQTT_EVENT_ANY, mqtt_event, nullptr);
  esp_mqtt_client_start(mqtt);
}

// ---- simulated sensor: replace with real accelerometer samples ----
static float uniform(float a, float b) { return a + (b - a) * (esp_random() / 4294967296.0f); }
static float randn() {
  float u1 = (esp_random() + 1.0f) / 4294967297.0f;
  float u2 = esp_random() / 4294967296.0f;
  return sqrtf(-2.0f * logf(u1)) * cosf(2.0f * M_PI * u2);
}
static void read_window(int scenario, float *x) {
  float amp = uniform(0.8f, 1.2f), phase = uniform(0.0f, 2.0f * M_PI);
  for (int i = 0; i < kN; i++) {
    x[i] = scenario == 0 ? 0.05f * randn()
                         : amp * sinf(2.0f * M_PI * 50.0f * i / kFs + phase) + 0.1f * randn();
  }
  if (scenario == 2) {
    int impacts = 4 + esp_random() % 6;
    for (int k = 0; k < impacts; k++) {
      int idx = esp_random() % kN;
      x[idx] += uniform(2.0f, 3.0f) * ((esp_random() & 1) ? 1.0f : -1.0f);
    }
  }
}

static void compute_features(const float *x, float *f) {
  float sumsq = 0, peak = 0;
  int crossings = 0;
  for (int i = 0; i < kN; i++) {
    sumsq += x[i] * x[i];
    peak = std::max(peak, fabsf(x[i]));
    if (i > 0 && ((x[i] < 0) != (x[i - 1] < 0))) crossings++;
  }
  float rms = sqrtf(sumsq / kN);
  f[0] = rms;
  f[1] = peak / (rms + 1e-6f);
  f[2] = (float)crossings / (kN - 1);
}

extern "C" void app_main(void) {
  ESP_ERROR_CHECK(nvs_flash_init());
  ESP_ERROR_CHECK(esp_netif_init());
  ESP_ERROR_CHECK(esp_event_loop_create_default());
  ESP_ERROR_CHECK(example_connect());
  start_mqtt();

  const tflite::Model *model = tflite::GetModel(g_model);
  if (model->version() != TFLITE_SCHEMA_VERSION) {
    ESP_LOGE(TAG, "schema mismatch");
    return;
  }
  static tflite::MicroMutableOpResolver<4> resolver;
  resolver.AddFullyConnected();
  resolver.AddSoftmax();
  resolver.AddQuantize();
  resolver.AddDequantize();

  static tflite::MicroInterpreter interpreter(model, resolver, tensor_arena, kArenaSize);
  if (interpreter.AllocateTensors() != kTfLiteOk) {
    ESP_LOGE(TAG, "AllocateTensors failed: increase kArenaSize");
    return;
  }
  ESP_LOGI(TAG, "arena used: %u bytes", (unsigned)interpreter.arena_used_bytes());
  TfLiteTensor *in = interpreter.input(0);
  TfLiteTensor *out = interpreter.output(0);

  static float window[kN];
  int last_label = -1;
  int64_t last_sent_us = 0;

  for (int n = 0;; n++) {
    int scenario = (n / 10) % 3;          // demo: change machine state every 10 windows
    read_window(scenario, window);

    float feat[3];
    compute_features(window, feat);
    for (int i = 0; i < 3; i++) {
      float norm = (feat[i] - kFeatMean[i]) / kFeatStd[i];
      int q = (int)lroundf(norm / in->params.scale) + in->params.zero_point;
      in->data.int8[i] = (int8_t)std::min(127, std::max(-128, q));
    }

    int64_t t0 = esp_timer_get_time();
    if (interpreter.Invoke() != kTfLiteOk) {
      ESP_LOGE(TAG, "Invoke failed");
      continue;
    }
    int infer_us = (int)(esp_timer_get_time() - t0);

    int best = 0;
    float best_p = -1;
    for (int i = 0; i < 3; i++) {
      float p = (out->data.int8[i] - out->params.zero_point) * out->params.scale;
      if (p > best_p) { best_p = p; best = i; }
    }
    ESP_LOGI(TAG, "%s p=%.2f (%d us)", kLabels[best], best_p, infer_us);

    int64_t now = esp_timer_get_time();
    if (mqtt_ready && (best != last_label || now - last_sent_us > 30000000)) {
      char payload[128];
      snprintf(payload, sizeof(payload),
               "{\"label\":\"%s\",\"p\":%.2f,\"rms\":%.3f,\"us\":%d,\"model\":\"vib-mlp-v1\"}",
               kLabels[best], best_p, feat[0], infer_us);
      esp_mqtt_client_publish(mqtt, TOPIC_BASE "/inference", payload, 0, 1, 0);
      last_label = best;
      last_sent_us = now;
    }
    vTaskDelay(pdMS_TO_TICKS(500));
  }
}

Compilez, flashez et ouvrez le moniteur :

python train_model.py
cd tinyml_mqtt
idf.py build flash monitor

Étape 4 : regarder arriver les résultats

Depuis n'importe quel ordinateur, abonnez-vous à votre préfixe :

mosquitto_sub -h broker.connectmq.com -p 1883 -i tinyml-viewer-31 \
  -t 'connectmq/demo/esp32-7c41a9/#' -v

Vous verrez le statut online en retained, puis un message d'inférence à chaque changement d'état de la machine simulée, plus un heartbeat toutes les 30 secondes. Le firmware évalue deux fenêtres par seconde mais ne publie qu'une poignée de messages par minute, et c'est tout l'intérêt du TinyML : calculer localement, communiquer avec parcimonie. Le serveur MQTT public gratuit autorise jusqu'à 10 messages par seconde et par client, ce qui laisse une large marge à cette conception.

Concevoir les topics et le payload

Le tutoriel utilise deux topics sous un même préfixe d'appareil : status, en retained et adossé à un Last Will, et inference, qui transporte les résultats. Cette séparation permet à un tableau de bord d'afficher instantanément l'état de l'appareil tandis que les résultats circulent sous forme d'événements. À mesure que le parc grandit, enrichissez la hiérarchie de contexte, par exemple plant1/utilities/compressors/comp-04/inference, pour qu'un abonné puisse sélectionner une machine, une ligne ou le site entier avec des wildcards.

Gardez le payload court mais autodescriptif. Le champ model vous indique quel firmware a produit un résultat après une mise à jour à distance, p permet aux abonnés d'appliquer leur propre seuil de confiance, et us révèle une hausse du temps d'inférence après un changement de modèle. Si vous devez ajuster le comportement à distance, abonnez l'appareil à un topic cmd pour des réglages comme l'intervalle de heartbeat et validez chaque valeur avant de l'appliquer. Sur un broker ConnectMQ privé, vous pouvez suivre les deux topics dans l'explorateur de topics en direct et définir une règle d'alerte à seuil sur des champs numériques pendant vos tests.

Passer à des données de capteurs réelles

Remplacez read_window() par des échantillons provenant d'un accéléromètre comme un MPU6050 ou un ADXL345, échantillonnés à fréquence fixe avec un timer matériel ou la FIFO du capteur. Réentraînez ensuite sur des fenêtres enregistrées : journalisez de vraies fenêtres via MQTT ou la liaison série pour chaque état de la machine, étiquetez-les et injectez-les dans le même code de caractéristiques et d'entraînement. Gardez un calcul des caractéristiques strictement identique en Python et en C++, y compris la définition du passage par zéro, sinon la précision sur l'appareil ne correspondra pas à celle de l'entraînement.

Pour la production, passez à TLS et aux identifiants : utilisez l'URI mqtts://broker.connectmq.com:8883 avec l'hôte de votre broker ConnectMQ, définissez cfg.credentials.username, cfg.credentials.authentication.password et le client ID attribué, et fournissez le certificat de l'autorité de certification (le bundle de certificats d'ESP-IDF gère Let's Encrypt). Le tutoriel ESP32 MQTT détaille la configuration TLS.

Dépannage du TinyML sur ESP32

Problème Cause Solution
AllocateTensors failed Arène trop petite Augmenter kArenaSize, puis réduire à arena_used_bytes() plus une marge
Didn't find op for builtin opcode Opérateur non enregistré L'ajouter au resolver et augmenter le paramètre de template
Prédit toujours la même classe Caractéristiques non normalisées comme à l'entraînement Vérifier kFeatMean/kFeatStd et la formule de quantification de l'entrée
Erreurs de compilation dans les en-têtes TFLM Versions mélangées ou portage Arduino Utiliser esp-tflite-micro depuis le registre de composants avec ESP-IDF 5.x
MQTT ne se connecte jamais Identifiants Wi-Fi ou client ID en double Vérifier le journal du moniteur et rendre le client ID unique

Étapes suivantes

Comparez ce pipeline construit à la main avec le flux de travail Edge Impulse MQTT, qui automatise la collecte de données et la conception des caractéristiques, ou explorez le runtime lui-même avec la prise en main de TensorFlow Lite Micro. Le guide IA et IoT montre la place d'appareils comme celui-ci dans un système AIoT plus vaste.

Démarrer avec ConnectMQ

Votre nœud TinyML n'est utile que si des systèmes reçoivent ses résultats. ConnectMQ est un broker MQTT managé avec TLS, des identifiants générés et un explorateur de topics en direct pour suivre les messages d'inférence en temps réel. Créez un broker avec l'offre gratuite Serverless sur app.connectmq.com, sans carte bancaire, et dirigez-y le firmware.

Questions fréquentes

Quel ESP32 choisir pour le TinyML ?

L'ESP32-S3 est le choix habituel, car ses instructions vectorielles accélèrent les noyaux de réseaux de neurones d'esp-tflite-micro et de nombreuses cartes intègrent de la PSRAM. L'ESP32 d'origine convient parfaitement aux petits modèles comme celui de ce tutoriel.

Peut-on faire du TinyML sur ESP32 avec l'Arduino IDE ?

Oui, via des bibliothèques Arduino qui embarquent TensorFlow Lite Micro ou des exports Edge Impulse. Vérifiez que la version de la bibliothèque correspond à l'API TFLM actuelle, car les anciens portages utilisent des constructeurs et des en-têtes différents.

Quelle taille peut atteindre un modèle TinyML sur ESP32 ?

Cela dépend de la flash pour les poids et de la RAM pour l'arène de tenseurs. Les petits modèles denses et convolutifs tiennent facilement en mémoire interne ; les modèles de vision plus grands nécessitent généralement un ESP32-S3 avec PSRAM.

Pourquoi ne publier que sur changement ?

L'inférence s'exécute plusieurs fois par seconde, mais les systèmes en aval ne s'intéressent qu'aux changements d'état. Publier sur changement avec un heartbeat économise l'énergie radio et la bande passante, et vous maintient dans les limites de débit du broker.

Articles liés

Tous les articles →

Votre broker MQTT privé, à quelques clics

Créez un compte, récupérez les identifiants de votre broker et connectez votre premier appareil — aucun serveur à installer, mettre à jour ou dimensionner.