Running a Machine Learning Model in ESP32 using micromlgen - TinyML

Recently, I was working on my final-year thesis, which proposes an ecosystem for precision farming using IoT, environmental data, and embedded machine learning & Edge computing. The main idea is simple but powerful: create a low-cost platform for farmers where they can analyze their own fields, decide which crops to cultivate, and determine the right fertilizers — all without relying on expensive cloud infrastructure. Farmers can still send data to the cloud for long-term storage and deeper analysis, but the system is designed to work off-grid, remain sustainable, and minimize recurring costs.

The key to keeping costs down was processing data directly on the device. This meant integrating embedded machine learning so that the collected environmental data could be processed onboard. Farmers could get instant results without an internet connection. To demonstrate this concept, I needed to build a portable sensor module with onboard ML capabilities. That’s when my exploration of embedded machine learning began.

This is the idea, basically

For the hardware, my primary choice was the ESP32. It’s IoT-friendly, with both BLE and Wi-Fi built in, and it packs a surprising amount of processing power thanks to its dual-core Tensilica Xtensa LX7 RISC SoC. With 4MB of flash and 520KB of SRAM, it’s more than capable for many ML inference tasks. And most importantly, It is cheap. You can get a generic ESP32 module at around 3 USD or 450 BDT. For this price range, this microcontroller SoC is packed with a lot of features. For an embedded device, that’s plenty of room to get started.

But then came the big question: how do I actually run a machine learning model on such a tiny device? At first, this felt overwhelming. Unlike on a laptop where you can just load a model into Python, microcontrollers have strict memory limits and can’t handle big frameworks. So I started exploring the available approaches to embedded ML.


The Ways Around Embedded Machine Learning & TinyML

My friend Navid collected the dataset and worked on the paper, so the hardware and programming responsibilities were mine. To implement a model using his dataset, I had to search around some ways. After some google bing and chatGPT, I was able to find the perfect way into embedded machine learning and Tiny ML

So at this point you might get confused "what is the diff within TinyML and EmbeddedML?"

TinyML is basically targeted for ultra low power microcontrollers like Arduino or ESP32 and STM32. These microcontrollers uses power under 1W and also, have limited resources like SRAM and Flash memory. And EmbeddedML is machine learning for embedded systems like Raspberry Pi or Jetson Nano. They are a lot more powerful that microcontrollers.

Think of it like layers: Machine Learning ⟶ Embedded ML (for embedded devices) ⟶ TinyML (for ultra-low-power microcontrollers

Firstly, I was stuck with the TensorFlowLite for Microcontroller or TFLM Framework. That required building and training my model, converting the model into a tflite format file.TFLite format is basically the model itself, just the extra dependencies are stripped out, values are quantiezed to make it lighter. After that, the TFLite model have to converted in to C array, to embed the model into the ESP32 firmware. The ESP32 can't directly read tflite format file from SD Card. And will require a lot of memory. So all this process is necessery to make the ESP32 able to read the model data.

This process was fine in theory, but in practice I faced many problems and was really frustrated.. Then my friend Muntakim gave me some articles and blogs to read about Embedded Machine Learning in ESP32.  Then my concept about TinyML got more clear. Here are the links to the blogs,

These three articles have enough informations to get started with TinyML. That's what I did. As my target was to take soil sensor measurement data and Classify the Fertilizer recommendation towards the user, I had already thought of what I had to do next.

And yes, I've uploaded all the codes in this blog to my github, you can check that out.

Preparing The Dataset & The Model

As I've the dataset now, I can start training this and prepare it for the ESP32 microcontroller. Before this hardware and embedded ML experiment, we already had our trained our model to be tested on our computers. Changing all the parameters, analyzing and cleaning the data, we came to a point that, Random Forest would be the best algorithm to train our ML model. But here comes some question in my mind. A simple decision tree based model would work just fine, but for accuracy we are using Random Forest based model. My question was, Random Forest means average of so multiple decision trees. So the result can be averaged and reduce the chance of getting biased. So comes the cost. A Decision Tree Model required less memory, less processing and also takes less time, depending on the processing load and the computation power. As a small model, our laptops had no problem with running the model, but here's the catch,will the model be able to run on ESP32 small memory? 

We're going to use a python package called micromlgen. This package is used for converting our model data into a C++ code. The code is basically a if/else based desicion tree based on the main model. This conversion of the model is generating pure C++ code, that gets compiled to machine code for ESP32 or other microcontroller. And running this code will requires a fixed memory size, instead of dynamic memory allocation that we get on our computer done by our lovely operating systems. ESP32 does not have this freedom. He had to run using his limited resources. So micromlgen comes to the help.

So without any further a do, I'll just jump right into the process. 

The Environment

Firstly, I created a Python virtual environment (venv) to do all my embeddedML works. I always do this practice 'casue sometimes some packages collide in the global environment and mess up with the python installation. So a venv contains everything in a container, no way to mess up big.
In the virtual environment, I've installed these below python packages.
pip install numpy pandas scikit-learn micromlgen 

Here, each package is necessery for different part of our working process. numpy is used for mathmetical operations in python, like arrays, matrices, preprocessing inputs and doing vectorized operations. And then comes pandas, it is used for data manipulation and analysis. Our dataset is in an CSV format file. To read and process that, we are using pandas. scikit-learn is used for training our models. Scikit-learn contains ML Algorithms and other tools to split dataset, scaling data nad evaluating trained models and many more. And the last one is micromlgen, which converts our scikit-learn models into pure C code, so that they can run in microcontrollers.


Preparing My Dataset

My dataset is already processed. You can have a look in my dataset here. It's from Kaggle. This dataset contains over 10000 rows of datas. This dataset containes columns like Temperature, Humidity, Moisture, NPK levels, Soil Type, Srop Type and Fertilizer name. I downloaded the CSV dataset file to my virtual environment for further process. The dataset structure is similar to this,
feature1,feature2,...,featureN,label
value11,value12,...,value1N,class1
value21,value22,...,value2N,class2
Here is a little snippet from the dataset.


In my dataset, there are two types of data. Temperature, humidity, npk measurements and moisture, these are numeric type data. and other features, are indeed String type or catagorical data.

Writing The Script, preparing the model

Here below is the script that I used to process data, train and export the model. I'm explaining the code to you. 

# train_model.py
import pandas as pd
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestClassifier  # Use Random Forest
from sklearn.preprocessing import LabelEncoder
from micromlgen import port

# Load dataset
df = pd.read_csv("Fertilizer_Prediction.csv")

# Strip whitespaces from column names
df.columns = df.columns.str.strip()

# Encode categorical columns
label_encoders = {}
categorical_columns = ['Soil Type', 'Crop Type', 'Fertilizer Name']

for col in categorical_columns:
    le = LabelEncoder()
    df[col] = le.fit_transform(df[col])
    label_encoders[col] = le

# Prepare features and target
X = df.drop(columns=['Fertilizer Name']).values
y = df['Fertilizer Name'].values

# Split dataset
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)

# Train using Random Forest (better accuracy)
model = RandomForestClassifier(n_estimators=10, max_depth=5, random_state=42)
model.fit(X_train, y_train)

# Evaluate accuracy
print("Accuracy:", model.score(X_test, y_test))

# Try exporting using micromlgen (note: micromlgen only supports DecisionTreeClassifier)
try:
    c_code = port(model)
    with open("fertilizer_model_micromlgen.h", "w") as f:
        f.write(c_code)
except Exception as e:
    print("Export error:", e)
    print("Note: micromlgen supports only DecisionTreeClassifier. Use Decision Tree for microcontroller inference.")

# Print encoded indexes
print("\nIndex Mapping for Soil Type:")
for idx, label in enumerate(label_encoders['Soil Type'].classes_):
    print(f"{idx}: {label}")

print("\nIndex Mapping for Crop Type:")
for idx, label in enumerate(label_encoders['Crop Type'].classes_):
    print(f"{idx}: {label}")

print("\nIndex Mapping for Fertilizer Name:")
for idx, label in enumerate(label_encoders['Fertilizer Name'].classes_):
    print(f"{idx}: {label}")

Before explaining, let me tell some more about the limitations that we are going to face in TinyML implementation in ESP32. There are strings in the dataset. And ESP32 is really weak to process string operations, and if we use class names directly in model training, the model will be larger. One way to compensate with that is label encoding. Using  this technique, we can convert categorical datas to numerical data, but those data won't get processed like numerical data in the model. 

Also, micromlgen doesn't support RandomForestClassifier because it is too heavy for microcontrollers. When you export a RandomForestClassifier using micromlgen, it doesn’t generate a compact mathematical formula—it literally unrolls the ensemble of decision trees into C code. Each tree is represented as a long series of if/else statements, and the code keeps a “vote counter” across all trees. The final prediction is whichever class gets the most votes. 

So in our training script, I've set n_estimators = 10, means the forest will contain 10 decision tree. Each tree can grow up to 5 levels deep (prevents overfitting & keeps model small). The parameters are actually up to you, how you get the best results. These are my configuration. And normally, what everyone would do, I also did, split the dataset and fed unseen data into the model to test the accuracy before training. 

After all that training, we are going to export the model in a C Code using micromlgen. We've already learned about the restrictions of micromlgen and RandomForest. But when I pass a RandomForest, micromlgen tries to unroll it into a big if/else tree, representing all trees + their voting logic.  So you might end up like me. 

721 Lines of just decision logic, and this is just the header file for the model.

I end up seeing a large header file with Many if (...) return classX; else ... statements, each tree’s decision logic and some vote aggregation code, which is basically majority voting across trees.

In the python script, you might notice an error handler. 
If micromlgen couldn’t handle the RandomForest, it would throw an error. But in my case, it didn’t error — instead it successfully produced code (just very big and less efficient). Here's the thing, if we are running inference (running the model for output) more frequently in a certain time, it might not be that efficient, will require a lot of power and ESP32 or any MCU might get a stress. But in my case, I'm not planning to run inference more and more often, so I thing that will be fine for my purpose.

Why print all the Indexes? 

At the end of the code, the script is printing all the index mapping for categorical data which got input through the dataset. Becasue we are converting all the categorical data into numerical placeholders, so that we can reduce string processing on the edge and also keep the model size smaller. And we need to keep that indexing somewhere noted as we are going to need these indexes for the ESP32 code.


Enough chitchat, let's run the python code to train our model and get the C Header file generated using this command. I've saved my main python script file as main.py, so...
py main.py
After running the script, it'll take some time, will train our model, show us the accuracy, print out the indexes and generate a C Header file containing the model code.

Here is the generated C Header file containing the converted code



Writing the Code for ESP32

Now we have the Machine Learning model in C code that we can use in microcontroller. For the code writing, buildng and uploading, I'm going to use platform.io
I'm using platform.io because it builds fast, the interface is simple and packed with features and robust. Yes, you can also use Arduino IDE, it'll be much simpler if you do so. But the thing is, if you are changing parameters frequently for testing, you will surely get frustrated in Arduino IDE. Let me tell you why. 
When we press Build in Arduino IDE, the arduino IDE builds the whole code again and again from scratch. If you already have uploaded code to esp32 using arduino IDE, then you already know how slow and painful it is. Though the process is simple. 

But on the other hand, platform.io builds code into seperate parts. So that, when you change certain line of code, only that part gets updated, so it can build faster. Also you can directly search for library using header file name, add boards and monitor multiple serial ports (requires another plugin to be installed in vscode)

Before starting, you have to install this arduino library,
So, I've created a project in platform.io to start with the code of ESP32. And started to code. Here below is the sketch that I've wrote that is able to take commands and parameters from serial monitor to run inference.

#include <Arduino.h>
#include "fertilizer_model_micromlgen_RandomForest.h"

// Fertilizer index → name
const char *fertilizerNames[] = {
    "10-26-26",
    "14-35-14",
    "17-17-17",
    "20-20",
    "28-28",
    "DAP",
    "Urea"
};

// Soil Type name → index mapping (same as in training)
const char *soilTypes[] = {
    "Black",    // 0
    "Clayey",    // 1
    "Loamy",    // 2
    "Red",      // 3
    "Sandy"    // 4
};

int selectedSoilType = -1;
bool soilTypeSet = false;

void askSoilType() {
  Serial.println("Select Soil Type by index:");
  for (int i = 0; i < sizeof(soilTypes) / sizeof(soilTypes[0]); i++) {
    Serial.print(i);
    Serial.print(" → ");
    Serial.println(soilTypes[i]);
  }
  Serial.print("Enter soil type index: ");
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  askSoilType();
}

String inputString = "";
int stage = 0;
float N, P, K;

void loop() {
  if (Serial.available()) {
    char c = Serial.read();

    if (c == '\n' || c == '\r') {
      inputString.trim();

      if (!soilTypeSet) {
        selectedSoilType = inputString.toInt();
        if (selectedSoilType >= 0 && selectedSoilType < (sizeof(soilTypes) / sizeof(soilTypes[0]))) {
          soilTypeSet = true;
          Serial.print("Selected Soil Type: ");
          Serial.println(soilTypes[selectedSoilType]);
          Serial.println("Enter Nitrogen value (N): ");
        } else {
          Serial.println("Invalid index. Try again.");
          askSoilType();
        }
      } else {
        switch (stage) {
          case 0:
            N = inputString.toFloat();
            Serial.println("Enter Phosphorous value (P): ");
            stage++;
            break;
          case 1:
            P = inputString.toFloat();
            Serial.println("Enter Potassium value (K): ");
            stage++;
            break;
          case 2:
            K = inputString.toFloat();

            // All inputs are collected, now predict
            Serial.println("Predicting fertilizer...");

            // Set default/sample values
            float temperature = 30;
            float humidity = 50;
            float moisture = 40;
            int cropType = 1;  // Example crop (should match label-encoded index from training)

            float input[] = {
              temperature,
              humidity,
              moisture,
              (float)selectedSoilType,
              (float)cropType,
              N,
              K,
              P
            };

            int prediction = model.predict(input);

            Serial.print("Predicted Fertilizer Index: ");
            Serial.println(prediction);

            if (prediction >= 0 && prediction < sizeof(fertilizerNames) / sizeof(fertilizerNames[0])) {
              Serial.print("Predicted Fertilizer Name: ");
              Serial.println(fertilizerNames[prediction]);
            } else {
              Serial.println("Prediction out of range.");
            }

            // Reset for next run
            stage = 0;
            soilTypeSet = false;
            Serial.println("\n--- Restarting ---");
            askSoilType();
            break;
        }
      }

      inputString = "";  // clear buffer`
    } else {
      inputString += c;
    }
  }
}

Here in the code, you can see that I've included the C header file that includes the decision tree code in C. And I've also mapped all the fertilizer names and soil types with their relevant indexes that we found on the Training Python Script (mentioned earlier). We are doing this because we don't want to input assigned numbers as parameters, that will be so painful. And I've also hardcoded some parameters that are not being input through the terminal like the temperature, humidity and moisture, but the values are relevantly ideal. So here, we're only taking NPK values as user defined input.

At this stage, I'm creating an array with all these values, and called the inference using   model.predict(input); function. 

Why it's saying model not defined or declared?

When you first time try to compile or build the code, you might get an error saying "object not defined: model" It's because the header file containing the model data doesn't have a line of code. You have to add this below line of code at the bottom of your C header file code.

    Eloquent::ML::Port::RandomForest model;


Our code is pretty much done. As you can observe, the code is simple and streightforward. After that, I've worked further on this thing, and I've integrated a NPK 5-in-1 sensor and took the data to pass through the machine learning model for inference. 

To run the inference, I've to attach the ESP32 to one of the USB port on my pc and open up serial monitor. Then in the serial monitor, I can pass the user defined parameters to the ESP32 for the inference. 
Here is an example of the inference on the ESP32

Select Soil Type by index:
0 → Black
1 → Clayey
2 → Loamy
3 → Red
4 → Sandy
Enter soil type index: 2

Selected Soil Type: Loamy
Enter Nitrogen value (N): 
45

Enter Phosphorous value (P): 
30

Enter Potassium value (K): 
25

Predicting fertilizer...
Predicted Fertilizer Index: 6
Predicted Fertilizer Name: Urea

--- Restarting ---
Select Soil Type by index:
0 → Black
1 → Clayey
2 → Loamy
3 → Red
4 → Sandy
Enter soil type index:


I've learnt a lot of deal while doing this project. And I'll share more if I continue to work more with micromlgen and TinyML. And be sure about that, I'll do more. 

In the end, this journey taught me that running machine learning on microcontrollers is not about pushing the boundaries of raw computational power, but about creativity, optimization, and finding the balance between limitations and possibilities. 

By combining low-cost hardware like the ESP32 with TinyML techniques, it becomes possible to bring AI directly to the field — literally. 
For farmers, that means actionable insights without internet dependency; for developers, it’s a reminder that innovation doesn’t always need big servers or expensive infrastructure. This is just the beginning — as I refine my system and integrate real sensors, I hope to move closer to a future where precision farming is affordable, accessible, and sustainable for everyone.

Post a Comment

Previous Post Next Post