贫民版魔杖:如何用4块钱的 mpu6050 复刻赛博魔杖的手势识别

2,703 阅读7分钟

前言

最近不是有个超火的魔杖项目吗,虽说 up 他本身是干自媒体的,其中可能有剪辑成分,但是大多数地方是可以实现的。

今天就不说前端,简单用 arduino 来复现一下其中的手势识别

截屏2024-06-25 21.24.22.png

前置准备

材料清单

  • 能编写 arduino 的开发板(我这里选用的是 esp323c,supermini 开发板,尺寸 2cm x 3cm,8块钱)
  • mpu6050 陀螺仪(3块钱)
  • 4根杜邦线(1块一大把)

硬件连线参考

  • 开发板 3.3v -- mpu6050 VCC
  • 开发板 GND -- mpu6050 GND
  • 开发板 3口 -- mpu6050 SCL
  • 开发板 4口 -- mpu6050 SDA

21719324164_.pic.jpg

软件清单

效果演示

截屏2024-06-25 21.49.00.png

整体流程

  1. 安装 arduino,连接到开发板,跑个 blink 示例确认开发板正常,调通开发板串口的数据显示
  2. 连接开发板与6050,读取到6050的六轴数据
  3. 利用读取到的数据写入一些样本
  4. 对照样本来识别手势

手势识别详解

本质分析

我们只有 mpu6050 这个模块,他提供了三轴的加速度,我们需要利用这一点来进行分析。

我们来举个例子,比如说 ”Z“ 这个字,我们需要向右挥向左挥再向右挥,虽然我们拿不到具体的行进路线有钱升级模块倒也不是不行

但是如果去高频率记录这个动作过程中一系列的加速度点,我们同样也可以根据大致的加速度数组来判断出它到底属于哪种手势。比方说 ”Z“ 和 ”O“,其行进路线的加速度点数组差距会极为明显。

那么我们的问题就可以转化为:

  1. 怎么高频记录加速度点?
  2. 如何进行手势数组之间的比较?(如果知道了哪个手势数组跟你当前的手势数组接近,那岂不是就能知道你当前是哪个手势了)

对于问题一,我们可以只在开始识别的时候进行极小 delay 的循环保证快速记录点位,其余时间则保持较高的 delay 循环来保证续航。

对于问题二,就需要用到一点点数学知识了。首先,我们从 6050 获取的数据会处理成这样

// ”z“的行进加速度数组
arrZ = [[x, y, z], [x2, y2, z2], ...]
// ”o“的行进加速度数组
arrO = [[x, y, z], [x2, y2, z2], ...]

我们且说怎么比较两个点之间的差距。说起一个有三轴坐标的点,不知道你有没有回忆起高中的一点知识:向量。可以很容易发现,这个加速度数组的每一项,其实都是一个向量。

截屏2024-06-26 14.22.50.png

所以两个点的差距这个问题,又被转化为了数学问题:如何比较两个向量的差距

向量比较

关于这个问题,答案并不是统一的,在不同的视角得到的相似性也并不一样,可以搜到很多种公式,具体可以参考这篇文章:blog.csdn.net/Losteng/art…

本文介绍两种较为常用的比较方法:欧式距离余弦距离

欧式距离

截屏2024-06-26 14.25.43.png

float euclideanDistance3D(float point1[3], float point2[3]) {
  float diffX = point1[0] - point2[0];
  float diffY = point1[1] - point2[1];
  float diffZ = point1[2] - point2[2];
  return sqrt(diffX * diffX + diffY * diffY + diffZ * diffZ);
}

余弦距离

截屏2024-06-26 14.27.27.png

// 计算向量的点积
float dotProduct(float vec1[3], float vec2[3]) {
    float dot = 0.0;
    for (int i = 0; i < 3; ++i) dot += vec1[i] * vec2[i];
    return dot;
}
// 计算向量的范数(即向量的长度)
float norm(float vec[3]) {
    float sum = 0.0;
    for (int i = 0; i < 3; ++i) sum += vec[i] * vec[i];
    return std::sqrt(sum);
}
// 计算余弦距离
float cosineDistance(float point1[3], float point2[3]) {
    float dot_product = dotProduct(point1, point2);
    float magnitude1 = norm(point1);
    float magnitude2 = norm(point2);
    return 1.0f - (dot_product / (magnitude1 * magnitude2));
}

比较

欧几里得距离:

  • 适用场景:手势数据比较平滑,噪声较少,特征维度相对较少的情况。
  • 优点:在大多数情况下,欧几里得距离是默认和常用的选择,因为它计算简单,且能提供良好的性能

余弦距离:

  • 适用场景:手势的方向性比幅度更重要的情况,例如手势的形状和方向一致但幅度不同。
  • 优点:对方向性变化敏感,适用于归一化后的手势数据。在需要捕捉方向性变化的手势识别任务中,余弦距离可能会提供更好的效果,特别是在手势形状变化较大但轨迹方向一致的情况下。

在这个项目中,我尝试了这两种方案,最终结论是余弦距离远比欧几里得距离识别率高得多,欧式距离的识别成功率大约在 30%,而余弦距离的识别成功率大约在 80%。所以本次项目最终采用了余弦距离来实现

手势相似度比较

我们回到问题二上:如何进行手势数组之间的比较?

注意到,手势数组是由若干个向量构成的,所以最简单的想法是:

  1. 把需要识别的数组的每一项,都和对照的手势数组的每一项进行比较,计算这两个向量的余弦距离的绝对值
  2. 把这些绝对值累加到一起,就得到了一个值,我称为与此对照手势的偏差值
  3. 最后,我们有许多个对照的手势数组(如提前录入的”Z“手势数组、”O“手势数组),在对每个进行对照完成后,就得到了一个偏差值数组
  4. 偏差值最小的那个,就是最符合我们的手势数组

这么来看是不是思路就清晰了不少。

但是很可惜,这个算法很糟糕。

  • 错位问题:如果对照手势是在100ms后开始挥动的,而你的录入手势是在200ms后开始挥动的,这是不是就出现了延迟,数组之间是不是也错开了。但是代码不知道,它按你的上述算法算,就会出现很大的误差。
  • 长短问题:如果对照手势的其中某一笔只用了20ms,而你录入手势这一笔用了50ms,那么就会造成扭曲问题,数组还是对照不上,依旧会出现误差。

要解决这两个问题,建议还是站在巨人的肩膀上,请出我们的DTW 算法

动态时间规整(Dynamic Time Warping, DTW)是一种用于计算时间序列之间相似性的方法,它能够处理时间上的不对齐和速度变化问题。

有兴趣的同学可以去了解其原理,我这里是让 gpt 直接写了一份可以用的算法出来:

// 计算两个三维时间序列之间的DTW距离
float calculateDTW(float seq1[][3], int n, float seq2[][3], int m) {
  float D[2][m + 1];

  // 初始化矩阵
  for (int j = 0; j <= m; j++) {
    D[0][j] = D[1][j] = 999999;  // 使用一个非常大的数来替代INFINITY
  }
  D[0][0] = 0;

  // 填充距离矩阵
  for (int i = 1; i <= n; i++) {
    for (int j = 1; j <= m; j++) {
      // 这里采用余弦距离来计算差距
      float cost = cosineDistance(seq1[i - 1], seq2[j - 1]);
      D[i % 2][j] = cost + min(min(D[(i - 1) % 2][j], D[i % 2][j - 1]), D[(i - 1) % 2][j - 1]);
    }
  }

  // 返回最优对齐路径的距离
  return D[n % 2][m];
}

至此,最复杂的部分就被攻克了。将代码稍作调整,烧录到程序中,即可进行手势识别了。

其他

由于本人不擅长 cpp 编程,但 arduino 是使用 cpp 进行开发的。所以我项目中的大多数代码都是写成 js ,再让 gpt 转成 cpp 并进行调试的,所以代码可能稀碎,望理解。

源码也比较烂,就不贴出来了,如果有人想要的话我再开源吧。

感谢你的阅读 😉
power by imoo