4. Arduino Nano 33 BLE Sense上的运动识别

任务目标

在Arduino Nano 33 BLE Sense上实现基于IMU(惯性测量单元)数据的实时运动识别,能够区分静止、走路、跑步三种状态。工作流程为:采集IMU数据 → 在PC端训练LSTM模型 → 将模型转换为TensorFlow Lite格式 → 部署到开发板进行实时推理。

环境与依赖

  • 硬件:Arduino Nano 33 BLE Sense(板载LSM9DS1 IMU)
  • PC端:Python 3.8+,安装 tensorflow==2.13.*、numpy、pandas、scikit-learn、tensorflow-lite(用于模型转换)
  • Arduino端:Arduino IDE 1.8.19+,安装 Arduino_LSM9DS1 库、Arduino_TensorFlowLite 库(版本≥2.4.0)

实现方案

整体分为三个环节:数据采集(Arduino端)、模型训练与转换(PC端)、部署推理(Arduino端)。本案例提供完整可运行的代码,并限定在以下范围:使用6轴IMU数据(加速度计3轴+陀螺仪3轴),每帧采集30个采样点(窗口大小30),采样频率约50Hz,即每个窗口代表约0.6秒的运动数据。模型输入为30×6的时序特征,输出为3类动作概率。

第一步:Arduino数据采集固件

将以下代码烧录到开发板,通过串口以CSV格式输出IMU原始数据。每行包含6个浮点数(ax, ay, az, gx, gy, gz),波特率115200。采集时分别保持静止、走路、跑步各3分钟,保存为三个CSV文件用于训练。

#include <Arduino_LSM9DS1.h>

const int WINDOW_SIZE = 30;
float ax, ay, az, gx, gy, gz;

void setup() {
  Serial.begin(115200);
  while (!Serial);
  if (!IMU.begin()) {
    Serial.println("IMU init failed!");
    while (1);
  }
  Serial.println("ax,ay,az,gx,gy,gz");
}

void loop() {
  for (int i = 0; i < WINDOW_SIZE; i++) {
    if (IMU.accelerationAvailable() && IMU.gyroscopeAvailable()) {
      IMU.readAcceleration(ax, ay, az);
      IMU.readGyroscope(gx, gy, gz);
      Serial.print(ax); Serial.print(",");
      Serial.print(ay); Serial.print(",");
      Serial.print(az); Serial.print(",");
      Serial.print(gx); Serial.print(",");
      Serial.print(gy); Serial.print(",");
      Serial.println(gz);
      delay(20);  // 约50Hz采样
    }
  }
  // 每个窗口后发送空行作为分隔
  Serial.println();
}

第二步:PC端LSTM训练与模型转换

将采集的三个CSV文件(static.csv, walk.csv, run.csv)放在与脚本相同目录下。运行以下Python脚本完成数据预处理、LSTM训练、模型转换为TensorFlow Lite格式,并输出C语言头文件供Arduino使用。

import numpy as np
import pandas as pd
from sklearn.model_selection import train_test_split
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import LSTM, Dense, Dropout
from tensorflow.keras.utils import to_categorical
import tensorflow as tf

WINDOW = 30
STEP = 15  # 窗口滑动步长

def load_data(filename, label):
    df = pd.read_csv(filename)
    data = df.values.astype(np.float32)
    windows = []
    for i in range(0, len(data) - WINDOW, STEP):
        windows.append(data[i:i+WINDOW])
    return np.array(windows), np.full(len(windows), label)

X_static, y_static = load_data('static.csv', 0)
X_walk, y_walk = load_data('walk.csv', 1)
X_run, y_run = load_data('run.csv', 2)

X = np.concatenate([X_static, X_walk, X_run], axis=0)
y = np.concatenate([y_static, y_walk, y_run], axis=0)

# 归一化(使用全局统计量)
mean = X.mean(axis=(0,1), keepdims=True)
std = X.std(axis=(0,1), keepdims=True) + 1e-6
X = (X - mean) / std

X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
y_train = to_categorical(y_train, num_classes=3)
y_test_cat = to_categorical(y_test, num_classes=3)

# LSTM模型
model = Sequential([
    LSTM(32, input_shape=(WINDOW, 6), return_sequences=False),
    Dropout(0.3),
    Dense(16, activation='relu'),
    Dense(3, activation='softmax')
])
model.compile(optimizer='adam', loss='categorical_crossentropy', metrics=['accuracy'])
model.fit(X_train, y_train, epochs=20, batch_size=32, validation_split=0.1, verbose=1)

# 评估
loss, acc = model.evaluate(X_test, y_test_cat, verbose=0)
print(f"测试准确率: {acc:.3f}")

# 转换为TFLite并生成C头文件
converter = tf.lite.TFLiteConverter.from_keras_model(model)
tflite_model = converter.convert()

# 保存为头文件(使用xxd格式)
with open('model.tflite', 'wb') as f:
    f.write(tflite_model)

# 生成C数组头文件
import subprocess
subprocess.run(['xxd', '-i', 'model.tflite', 'model_data.h'], check=True)
print("模型已保存为 model_data.h")

运行后得到 model_data.h 文件,其中包含 model_tflite 数组和长度变量。记录终端输出的测试准确率作为模型性能参考。

第三步:Arduino部署与实时推理

将生成的 model_data.h 放入Arduino项目文件夹。以下代码实现:每采集30个采样点(约0.6秒)执行一次推理,通过串口输出识别结果(0=静止,1=走路,2=跑步)。

#include <Arduino_LSM9DS1.h>
#include <TensorFlowLite.h>
#include "tensorflow/lite/micro/all_ops_resolver.h"
#include "tensorflow/lite/micro/micro_interpreter.h"
#include "model_data.h"

const int WINDOW = 30;
const int NUM_FEATURES = 6;
const int NUM_CLASSES = 3;

float imu_data[WINDOW][NUM_FEATURES];
int data_index = 0;

// 归一化参数(需与训练时一致)
const float MEAN[NUM_FEATURES] = {0.0f, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f};
const float STD[NUM_FEATURES] = {1.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f};

namespace {
  const tflite::Model* model = nullptr;
  tflite::MicroInterpreter* interpreter = nullptr;
  TfLiteTensor* input_tensor = nullptr;
  TfLiteTensor* output_tensor = nullptr;
  constexpr int kTensorArenaSize = 8 * 1024;
  uint8_t tensor_arena[kTensorArenaSize];
}

void setup() {
  Serial.begin(115200);
  while (!Serial);
  if (!IMU.begin()) {
    Serial.println("IMU init failed!");
    while (1);
  }

  // 加载模型
  model = tflite::GetModel(model_tflite);
  static tflite::AllOpsResolver resolver;
  static tflite::MicroInterpreter static_interpreter(model, resolver, tensor_arena, kTensorArenaSize);
  interpreter = &static_interpreter;
  interpreter->AllocateTensors();
  input_tensor = interpreter->input(0);
  output_tensor = interpreter->output(0);
  Serial.println("Model loaded.");
}

void loop() {
  float ax, ay, az, gx, gy, gz;
  if (IMU.accelerationAvailable() && IMU.gyroscopeAvailable()) {
    IMU.readAcceleration(ax, ay, az);
    IMU.readGyroscope(gx, gy, gz);
    imu_data[data_index][0] = ax;
    imu_data[data_index][1] = ay;
    imu_data[data_index][2] = az;
    imu_data[data_index][3] = gx;
    imu_data[data_index][4] = gy;
    imu_data[data_index][5] = gz;
    data_index++;
    delay(20);

    if (data_index >= WINDOW) {
      // 填充输入张量并归一化
      for (int i = 0; i < WINDOW; i++) {
        for (int j = 0; j < NUM_FEATURES; j++) {
          input_tensor->data.f[i * NUM_FEATURES + j] = 
            (imu_data[i][j] - MEAN[j]) / STD[j];
        }
      }
      // 推理
      interpreter->Invoke();
      int pred = 0;
      float max_prob = output_tensor->data.f[0];
      for (int c = 1; c < NUM_CLASSES; c++) {
        if (output_tensor->data.f[c] > max_prob) {
          max_prob = output_tensor->data.f[c];
          pred = c;
        }
      }
      const char* labels[] = {"静止", "走路", "跑步"};
      Serial.print("识别结果: ");
      Serial.println(labels[pred]);
      data_index = 0;
    }
  }
}
重要:Arduino代码中的 MEAN 和 STD 数组必须替换为Python训练脚本中实际计算出的全局均值与标准差(在脚本中打印 mean.flatten() 和 std.flatten() 获取)。否则推理结果将严重失真。

验证方法与通过标准

  1. 数据采集验证:烧录第一步代码后,打开串口监视器,应看到6列浮点数持续输出。静止时加速度计数值接近(0, 0, ±1g),走路/跑步时波形有明显波动。
  2. 训练验证:Python脚本运行结束后,终端输出测试集准确率。当三个类别数据量各≥500个窗口时,准确率应≥0.90。若低于此值,增加采集数据量或训练轮次。
  3. 部署验证:烧录部署代码后,打开串口监视器(波特率115200)。依次进行以下动作,每个动作持续5秒以上,观察输出:
    • 保持静止 → 应输出“静止”
    • 原地匀速走路 → 应输出“走路”
    • 原地跑步 → 应输出“跑步”
    每个动作重复3次,若至少2次识别正确,则判定部署通过。

常见问题

  • 编译错误(内存不足):若提示 region 'RAM' overflowed,将 kTensorArenaSize 从8192减小到6144,或使用 tflite::MicroMutableOpResolver<10> 替代 AllOpsResolver 以减少内存占用。
  • 推理结果始终为同一类:检查归一化参数是否正确填入,以及IMU数据是否正常更新(可在填充前打印原始值调试)。
  • 串口输出乱码:确认Arduino IDE串口监视器波特率设置为115200,且开发板供电稳定(建议USB直连电脑)。