TinyML Arduino Posture Classifier/ Sida 2 av 4
Data Collection and Feature Extraction
Collecting 9-axis IMU data from the Arduino, extracting statistical features with a sliding window, and applying EMA normalization for posture classification.
IMU Sampling
The LSM9DS1 sensor is read in imu_features.cpp. We sample all three sensors together:
void updateIMU() {
float ax, ay, az, gx, gy, gz, mx, my, mz;
if (IMU.accelerationAvailable() && IMU.gyroscopeAvailable() &&
IMU.magneticFieldAvailable()) {
IMU.readAcceleration(ax, ay, az);
IMU.readGyroscope(gx, gy, gz);
IMU.readMagneticField(mx, my, mz);
// Store in circular buffers
accelX[sampleIndex] = ax;
accelY[sampleIndex] = ay;
accelZ[sampleIndex] = az;
gyroX[sampleIndex] = gx;
gyroY[sampleIndex] = gy;
gyroZ[sampleIndex] = gz;
magX[sampleIndex] = mx;
magY[sampleIndex] = my;
magZ[sampleIndex] = mz;
sampleIndex++;
if (sampleIndex >= WINDOW_SIZE) {
sampleIndex = 0;
windowFilled = true;
}
}
}
The window size is 128 samples:
#define WINDOW_SIZE 128
Each axis has its own circular buffer (9 buffers total), storing 128 floats each. That’s 9 × 128 × 4 = 4,608 bytes for the raw IMU data.
Feature Extraction
Once the window is full, we compute mean and standard deviation for each of the 9 axes:
FeatureVector computeFeatures() {
FeatureVector fv;
uint8_t count = windowFilled ? WINDOW_SIZE : sampleIndex;
uint8_t idx = 0;
// Accelerometer X/Y/Z
computeMeanStd(accelX, count, mean, stddev);
fv.features[idx++] = mean;
fv.features[idx++] = stddev;
// ... same for Y and Z
// Gyroscope X/Y/Z
computeMeanStd(gyroX, count, mean, stddev);
fv.features[idx++] = mean;
fv.features[idx++] = stddev;
// ... same for Y and Z
// Magnetometer X/Y/Z
computeMeanStd(magX, count, mean, stddev);
fv.features[idx++] = mean;
fv.features[idx++] = stddev;
// ... same for Y and Z
return fv;
}
This gives 18 features total: 9 axes × 2 statistics (mean + std).
The feature vector structure:
struct FeatureVector {
float features[NUM_FEATURES]; // NUM_FEATURES = 18
};
EMA Normalization
Sensor values vary between devices and drift over time. We normalize using Exponential Moving Average:
#define EMA_ALPHA 0.005
static float EMAs[NUM_FEATURES] = {0.0};
void updateEMA(FeatureVector vector) {
for (size_t fi = 0; fi < NUM_FEATURES; fi++) {
EMAs[fi] = (1 - EMA_ALPHA) * EMAs[fi] + EMA_ALPHA * vector.features[fi];
}
}
void normalizeVector(FeatureVector &vector) {
for (size_t fi = 0; fi < NUM_FEATURES; fi++) {
vector.features[fi] -= EMAs[fi];
}
}
The EMA tracks a running average of each feature. Normalization subtracts this average, centering the data around zero. The low alpha (0.005) means the EMA adapts slowly, providing stable normalization without being thrown off by individual samples.
The EMA values are persisted to KVStore so they survive power cycles:
void persistEMA(void) {
setKVPersistedEMA(EMAs);
}
Collecting Training Data
During training, we collect a batch of 8 feature vectors:
void collectBuffer(FeatureVector (&featureBuffer)[BATCH_SIZE], uint16_t *nSamples) {
*nSamples = 0;
while (*nSamples < BATCH_SIZE) {
updateIMU();
if (windowFilled && sampleIndex == 0) {
FeatureVector fv = computeFeatures();
updateEMA(fv);
featureBuffer[(*nSamples)++] = fv;
}
}
}
Each feature vector in the batch gets an EMA update, then the entire batch is normalized together:
void normalizeBuffer(FeatureVector (&featureBuffer)[BATCH_SIZE], uint16_t nSamples) {
for (size_t wi = 0; wi < nSamples; wi++) {
for (size_t fi = 0; fi < NUM_FEATURES; fi++) {
featureBuffer[wi].features[fi] -= EMAs[fi];
}
}
}
The Training Flow
In engine.cpp, the CMD_TRAIN command orchestrates the full flow:
case CMD_TRAIN: {
Coms.send("[CMD=TRAIN]: INIT");
// Collect 8 feature vectors
collectBuffer(featureBuffer, &nSamples);
Coms.send("[CMD=TRAIN]: COLLECTED");
// Get labels from user (one per sample)
if (!Coms.getLabel(labelBuffer, nSamples)) {
Coms.send("Bad input");
nSamples = 0;
break;
}
// Normalize all features
Coms.send("[CMD=TRAIN]: NORMALIZATION");
normalizeBuffer(featureBuffer, nSamples);
// Train output layer
Coms.send("[CMD=TRAIN]: GRADIENT DESCENT");
trainOutputLayer(featureBuffer, labelBuffer, nSamples, LEARNING_RATE);
// Print predictions on training data
Coms.send("[CMD=TRAIN]: PRINTING PREDICTIONS:");
while (nSamples--) {
updateReservoir(featureBuffer[i++]);
uint8_t prediction = predict();
Coms.send(String(prediction));
}
Coms.send("[CMD=TRAIN]: DONE");
break;
}
The user provides labels (0=sitting, 1=standing, 2=moving) through the serial or BLE interface, one per sample in the batch.