漂移菌 发表于 3 天前

ESP32-C5系列连载04-老黄的金箍棒项目终于不用烂尾了

本帖最后由 漂移菌 于 2025-10-23 12:52 编辑

书接上文,中间利用python代码的修改,将ESP32-C5上链接的MPU6050 的数据采集并通过串口传给了树莓派5, 通过树莓派5 采集并将数据写入对应的文件。
下面让我来为你详细讲解一下如何在树莓派上用 arduino-cli 构建 ESP32-C5 + MPU6050 获取设备姿态信息的完整流程。
这里我会尝试使用 MPU6050 内置的 DMP(Digital Motion Processor) 功能,直接输出四元数,再通过官方库函数转换为欧拉角(Yaw、Pitch、Roll),这种方式精度高且计算量小。

硬件连接主要去看上一篇帖子哈。
在树莓派上我部署了arduino-cli的环境,编译非常丝滑,也不用鼠标点点点,如果还不记得怎么用。可以尝试参考我下面的方法:

# 安装 arduino-cli
curl -fsSL https://raw.githubusercontent.com/arduino/arduino-cli/master/install.sh | sh

# 添加到环境变量
echo 'export PATH=$PATH:$HOME/bin' >> ~/.bashrc
source ~/.bashrc

# 初始化配置
arduino-cli config init
arduino-cli core update-index

# 安装 ESP32 开发板支持
arduino-cli core install esp32:esp32在config init 部分,需要修改一下你的配置哦。不然使用的编译器和编译环境可能认不出你的esp32-C5

pi@rpi16g:~/max7219_test $ cat ~/.arduino15/arduino-cli.yaml
board_manager:
    additional_urls: ['https://github.com/espressif/arduino-esp32/releases/download/3.3.0-alpha1/package_esp32_dev_index_cn.json']
network:
    connection_timeout: 1800s
然后记得安装一下下面两个库:

[*]I2Cdevlib(用于 I2C 通信)
[*]MPU6050(用于 MPU6050 控制)
步骤如下:
cd ~/Arduino/libraries
git clone https://github.com/jrowberg/i2cdevlib
mv i2cdevlib/Arduino/I2Cdev .
mv i2cdevlib/Arduino/MPU6050 .然后创建一个Arduino Sketch 项目
arduino-cli sketch new esp32c5-mpu6050
cd ~/esp32c5-mpu6050 创建主文件 esp32c5-mpu6050.ino,内容如下:
#include <Arduino.h>
#include "I2Cdev.h"
#include "MPU6050_6Axis_MotionApps20.h"
#include "Wire.h"

#define INTERRUPT_PIN2
#define SDA_PIN 9
#define SCL_PIN 10
#define COB_PIN 15

MPU6050 mpu;

volatile bool mpuInterrupt = false;
bool dmpReady = false;
uint8_t mpuIntStatus;
uint8_t devStatus;
uint16_t packetSize;
uint8_t fifoBuffer;

Quaternion q;
VectorFloat gravity;
float ypr;//

void IRAM_ATTR dmpDataReady() {
      mpuInterrupt = true;
}

void setup() {
      Serial.begin(9600);
      Wire.begin(SDA_PIN, SCL_PIN);
      Wire.setClock(100000);

      mpu.initialize();
      if (!mpu.testConnection()) {
      Serial.println("MPU6050连接失败!");
                while (1);
      }

      devStatus = mpu.dmpInitialize();
      if (devStatus != 0) {
                Serial.printf("DMP初始化失败,状态码: %d\n", devStatus);
                while (1);
      }

      mpu.setXGyroOffset(220);
      mpu.setYGyroOffset(76);
      mpu.setZGyroOffset(-85);
      mpu.setZAccelOffset(1788);

      mpu.CalibrateAccel(6);
      mpu.CalibrateGyro(6);
      mpu.setDMPEnabled(true);

      pinMode(INTERRUPT_PIN, INPUT);
      attachInterrupt(digitalPinToInterrupt(INTERRUPT_PIN), dmpDataReady, RISING);
      mpuIntStatus = mpu.getIntStatus();
      dmpReady = true;
      packetSize = mpu.dmpGetFIFOPacketSize();

      pinMode(COB_PIN, OUTPUT);
      digitalWrite(COB_PIN, HIGH);
      Serial.println("ESP32-C5 + MPU6050 + COB Light 初始化完成!");
      digitalWrite(COB_PIN, LOW);
      delay(3);
      digitalWrite(COB_PIN, HIGH);
}

void loop() {
      if (!dmpReady) return;

      uint16_t fifoCount = mpu.getFIFOCount();

      if (!mpuInterrupt && fifoCount < packetSize) return;

      mpuInterrupt = false;
      mpuIntStatus = mpu.getIntStatus();
      fifoCount = mpu.getFIFOCount();

      if((mpuIntStatus & 0x10) || fifoCount == 1024){
                mpu.resetFIFO();
                Serial.println("DAMN, FIFO特喵的溢出了!");
                return;
      }

      if (mpuIntStatus & 0x02) {
                while (fifoCount < packetSize) fifoCount = mpu.getFIFOCount();
                mpu.getFIFOBytes(fifoBuffer, packetSize);
                fifoCount -= packetSize;
                mpu.dmpGetQuaternion(&q, fifoBuffer);
                mpu.dmpGetGravity(&gravity, &q);
                mpu.dmpGetYawPitchRoll(ypr, &q, &gravity);
            /*
                Serial.print("YAW: ");
                Serial.print(ypr * 180 /M_PI);
                Serial.print("\tPITCH: ");
                Serial.print(ypr * 180 /M_PI);
                if ((ypr * 180 /M_PI) > 50.0){
                   digitalWrite(COB_PIN, LOW);}
                else {
                   digitalWrite(COB_PIN, HIGH);
                }
                Serial.print("\tROLL: ");
                Serial.println(ypr * 180 /M_PI);
                */
                Serial.printf("IMU:%.2f,%.2f,%.2f\n",(ypr*180/M_PI),(ypr*180/M_PI),(ypr*180/M_PI));
      }
}

编译并上传代码到esp32-C5:
# 编译
arduino-cli compile --fqbn esp32:esp32:esp32:CDCOnBoot=cdcesp32c5-mpu6050

# 上传(替换为你的串口)
arduino-cli upload -p /dev/ttyACM0--fqbn esp32:esp32:esp32:CDCOnBoot=cdcesp32c5-mpu6050打开串口监视器看看是否有数据输出:
arduino-cli monitor -p /dev/ttyACM0 -c baudrate=115200
如果一切正确,可以看到类似下面的输出:
IMU:-12.34, 45.33, -3.21

到这里,ESP32-C5 的数据采集并通过串口输出就好了,接下来我们到树莓派上去搭建数据采集的环境。
在树莓派上把「yaw/pitch/roll 序列」变成「画圈 / 画×」的 AI 分类器,整套流程可以塞进 4 步就能完成。
1. 数据采集和打标签(要在树莓派主机上完成)
操作思路: ESP32 ---》 串口 ---》 树莓派USB串口读取
2. 写个简单的采集脚本, 每类动作记录1条 .csv 文件,然后全部记录下来后再机器学习。
采集脚本如下:
pi@rpi16g:~/esp32_TinyML_MPU6050 $ cat collect_data.py
# 功能:通过MPU6050采集数据集,目前采集4组,circle是划圆,cross是划十字,左右就是向左砍和向右砍
# 目标;帮老黄的金箍棒灯效采集数据
# Editor: 漂移菌
# 串口数据来自接入的ESP32

import serial
import time
import csv
import os


GESTURES = ["circle", "cross", "left", "right"]
SAMPLES_PER_GESTURE = 30 # 每种手势采集30条
SAMPLE_DURATION = 2.0    # 每条样本2秒
SAMPLE_RATE = 50         # 50HZ
TOTAL_READS = int(SAMPLE_RATE * SAMPLE_DURATION)

ser = serial.Serial("/dev/ttyUSB0", 9600, timeout=1)
time.sleep(2)

def collect_gesture(gesture_name, index):
    filename = f"{gesture_name}/{gesture_name}_{index:03d}.csv"
    print(f"准备采集: {filename}, 请在2秒内完成动作...")
    time.sleep(2)
    data = []
    try:
      while len(data) < TOTAL_READS:
            line = ser.readline().decode('utf-8', errors='ignore').strip()
            if line:
                print(line)
                parts = line.split(',')
                if len(parts) == 3:
                  try:
                        yaw, pitch, roll = map(float, parts)
                        data.append()
                        print(f"✅\r已采集: {len(data)}条数据", end='\n')
                  except ValueError:
                        print("跳过坏数据:", line)

      print(f"✅ 采集完成:{filename}, 共{len(data)}条数据")
    except KeyboardInterrupt:
      print(f"✅ 手动结束采集:{filename}, 共{len(data)}条数据")
    with open(filename, 'w', newline='') as f:
      writer = csv.writer(f)
      writer.writerow(["yaw", "pitch", "roll"])
      writer.writerows(data)

if __name__ == "__main__":
    for gesture in GESTURES:
      os.makedirs(gesture, exist_ok=True)
      for i in range(1, SAMPLES_PER_GESTURE+1):
            input(f"\n按下回车开始采集{gesture}第{i}条...")
            collect_gesture(gesture, i)
然后根据提示执行,分别进行画圆圈,打×, 左挥,右砍, 采集到足够的数据会在当前目录下看到类似这样的几个文件夹:
pi@rpi16g:~/esp32_TinyML_MPU6050 $ tree data
data
├── circle
│   ├── circle_001.csv
│   ├── circle_002.csv
│   ├── circle_003.csv
│   ├── circle_004.csv
│   ├── circle_005.csv
│   ├── circle_006.csv
│   ├── circle_007.csv
│   ├── circle_008.csv
│   ├── circle_009.csv
│   ├── circle_010.csv
│   ├── circle_011.csv
│   ├── circle_012.csv
│   ├── circle_013.csv
│   ├── circle_014.csv
│   ├── circle_015.csv
│   ├── circle_016.csv
│   ├── circle_017.csv
│   ├── circle_018.csv
│   ├── circle_019.csv
│   ├── circle_020.csv
│   ├── circle_021.csv
│   ├── circle_022.csv
│   ├── circle_023.csv
│   ├── circle_024.csv
│   ├── circle_025.csv
│   ├── circle_026.csv
│   ├── circle_027.csv
│   ├── circle_028.csv
│   ├── circle_029.csv
│   └── circle_030.csv
├── cross
│   ├── cross_001.csv
│   ├── cross_002.csv
│   ├── cross_003.csv
│   ├── cross_004.csv
│   ├── cross_005.csv
│   ├── cross_006.csv
│   ├── cross_007.csv
│   ├── cross_008.csv
│   ├── cross_009.csv
│   ├── cross_010.csv
│   ├── cross_011.csv
│   ├── cross_012.csv
│   ├── cross_013.csv
│   ├── cross_014.csv
│   ├── cross_015.csv
│   ├── cross_016.csv
│   ├── cross_017.csv
│   ├── cross_018.csv
│   ├── cross_019.csv
│   ├── cross_020.csv
│   ├── cross_021.csv
│   ├── cross_022.csv
│   ├── cross_023.csv
│   ├── cross_024.csv
│   ├── cross_025.csv
│   ├── cross_026.csv
│   ├── cross_027.csv
│   ├── cross_028.csv
│   ├── cross_029.csv
│   └── cross_030.csv
├── left
│   ├── left_001.csv
│   ├── left_002.csv
│   ├── left_003.csv
│   ├── left_004.csv
│   ├── left_005.csv
│   ├── left_006.csv
│   ├── left_007.csv
│   ├── left_008.csv
│   ├── left_009.csv
│   ├── left_010.csv
│   ├── left_011.csv
│   ├── left_012.csv
│   ├── left_013.csv
│   ├── left_014.csv
│   ├── left_015.csv
│   ├── left_016.csv
│   ├── left_017.csv
│   ├── left_018.csv
│   ├── left_019.csv
│   ├── left_020.csv
│   ├── left_021.csv
│   ├── left_022.csv
│   ├── left_023.csv
│   ├── left_024.csv
│   ├── left_025.csv
│   ├── left_026.csv
│   ├── left_027.csv
│   ├── left_028.csv
│   ├── left_029.csv
│   └── left_030.csv
└── right
    ├── right_001.csv
    ├── right_002.csv
    ├── right_003.csv
    ├── right_004.csv
    ├── right_005.csv
    ├── right_006.csv
    ├── right_007.csv
    ├── right_008.csv
    ├── right_009.csv
    ├── right_010.csv
    ├── right_011.csv
    ├── right_012.csv
    ├── right_013.csv
    ├── right_014.csv
    ├── right_015.csv
    ├── right_016.csv
    ├── right_017.csv
    ├── right_018.csv
    ├── right_019.csv
    ├── right_020.csv
    ├── right_021.csv
    ├── right_022.csv
    ├── right_023.csv
    ├── right_024.csv
    ├── right_025.csv
    ├── right_026.csv
    ├── right_027.csv
    ├── right_028.csv
    ├── right_029.csv
    └── right_030.csv

5 directories, 120 files
五个分类目录和120个文件,这些数据信息单独如下:

然后就可以编写代码来训练了。
目前你已经有:

[*]✅ ESP32 + MPU6050 采集的 yaw/pitch/roll 数据(CSV 格式)
[*]✅ 树莓派5(性能足够)
目标就是在树莓派5 本地训练一个TinyML级别的模型了。哈哈

用于识别:

[*]画圈(circle)
[*]打叉(cross)
[*]左挥(left)
[*]右挥(right)
最终的solution如下:
✅ 方案:用 scikit-learn 训练轻量模型 → 导出为 C数组 → ESP32 上推理


步骤工具说明
1. 数据加载pandas读取 CSV(yaw/pitch/roll)
2. 特征提取scipy / scikit-learn提取时域特征(均值、方差、能量、峰值)
3. 标签编码sklearn.preprocessing四类:circle/cross/left/right
4. 模型训练RandomForestClassifier 或 SVM轻量、快速、适合树莓派
5. 模型导出micromlgen 或 sklearn-porter导出为 C 代码
6. ESP32 推理C 语言直接运行模型,控制 RGB


树莓派先要安装依赖:
sudo apt update
sudo apt install python3-pip
pip3 install pandas scikit-learn scipy micromlgen然后就可以编写一个训练脚本,例如我这里是train_local.py
import os
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestClassifier
from sklearn.model_selection import train_test_split
from sklearn.metrics import classification_report
from micromlgen import port# 转换成c代码
import pickle


# 0. 先做个函数提取特征
def extract_features(df):
    return [
            df['yaw'].mean(), df['yaw'].std(), df['yaw'].max(), df['yaw'].min(),
            df['pitch'].mean(), df['pitch'].std(), df['pitch'].max(), df['pitch'].min(),
            df['roll'].mean(), df['roll'].std(), df['roll'].max(), df['roll'].min(),]

# 1. 尝试加载所有的CSV文件.
def load_data(folder):
    X, y = [], []
    label = os.path.basename(folder)
    for file in os.listdir(folder):
      if file.endswith('.csv'):
            df = pd.read_csv(os.path.join(folder, file))
            # 提取时域特征
            features = extract_features(df)
            X.append(features)
            y.append(label)
    return X, y


# 2. 加载所有类别
X_all, y_all = [], []

for gesture in ['circle', 'cross', 'left', 'right']:
    X, y = load_data(f'data/{gesture}')
    X_all.extend(X)
    y_all.extend(y)

X_all = np.array(X_all)
print(f"X_all.shape = {X_all.shape}")

# 3. 训练模型
X_train, X_test, y_train, y_test = train_test_split(X_all, y_all, test_size=0.2)
clf = RandomForestClassifier(n_estimators=30, max_depth=10)
clf.fit(X_train, y_train)

with open("my_esp32-gesture-RFmodel.pkl", 'wb') as f:
    pickle.dump(clf, f)

# 4. 评估模型
print(classification_report(y_test, clf.predict(X_test)))
print('-'* 50)
print(f"训练集准确率: {clf.score(X_all, y_all):.2f}")

# 5. 导出一个C头文件,后面烧录到esp32

with open('model.h', 'w') as f:
    f.write(port(clf))

print(f"已经生成model.h头文件")

这时候执行这个脚本就会很快训练出一个非常精准的模型,然后还会得到一个model.h的头文件,这个需要放到arduino项目里
然后继续下面的步骤:

[*]把 model.h 放到 Arduino 项目里
[*]每次采集 2 秒数据(100 条),提取同样的 12 个特征
[*]调用 predict(features) → 返回类别索引
[*]根据类别控制 RGB 灯
然后安装到金箍棒里面,我目前还在打印和调试,未完,待续吧~更新比较慢。但是不会断。哈哈

那个训练的过程我发布到B站了,链接如下:
https://www.bilibili.com/video/BV1gvsizaEqm/?vd_source=bdff96c7413b9836f4a9c717176dc2da
原谅我没有语音解说哈~

页: [1]
查看完整版本: ESP32-C5系列连载04-老黄的金箍棒项目终于不用烂尾了