ESP32红外热成像手势识别:从温度矩阵到0-9分类实战
2026/9/13 19:43:40 网站建设 项目流程

简介:这是一份面向嵌入式开发者和物联网爱好者的ESP32手势识别项目资料,聚焦于利用红外热成像阵列传感器实时捕获手部温度分布并解析动作含义。内容覆盖ESP32的GPIO/ADC配置、底层外设驱动、C/C++应用层开发、FreeRTOS任务调度,以及红外传感器数据读取与图像预处理流程。在识别算法方面,资料梳理了灰度化、噪声滤波、直方图均衡化、模板匹配和轻量级CNN等思路,帮助读者了解如何根据微控制器资源限制选择合适的计算方案,同时也可作为高校课程设计或电子设计竞赛的参考方案。

压缩包为zip格式,大小约19.73MB,适合有一定单片机基础、正尝试进入嵌入式视觉或传感器融合领域的开发者参考。目前已有87人次浏览学习,说明其内容在相关技术社区中有一定参考价值。读者可以从中完整了解从传感器连接到手势判别的系统架构,掌握各功能模块的划分、调试路径与常见问题排查思路,从而将这套方法迁移到自己的智能交互或IoT项目中。

1. 为什么在ESP32上做红外热成像手势识别,而不是摄像头

摄像头方案一遇到暗光、逆光、窗帘拉上就显得很脆弱,而红外热成像阵列传感器根本不在乎光线,它输出的是一幅“温度矩阵”:手是 31~35℃,背景是 20~28℃,温差天然把目标从背景里剥出来。用 ESP32 通过 I2C 读这颗传感器,再用 C/C++ 做背景建模和分类,完全可以实现“0到9手势识别”,不需要跑 OpenCV,也不需要外挂 NPU。

很多人以为热成像只能用来测体温,其实 32x24 的低分辨率矩阵对静态手势识别刚好够用:数字 0、1、2、3、4、5 这类形状,核心信息在轮廓和分布,不在纹理。下面这套方案适合想把手势识别做成一个独立模块的人:硬件只留 ESP32 + 传感器,代码能直接烧进 Arduino 或 ESP-IDF 工程,模型小到可以用 C 数组固化在 Flash 里。

2. 红外热成像阵列传感器选型与ESP32的I2C接线

2.1 三种常用红外热成像阵列传感器参数对比

先别急着写代码,传感器选错,后面所有算法都要返工。市面上能直接买到的阵列传感器主要有三款:

型号分辨率FOV刷新率接口适合场景
AMG88338x860°10HzI2C靠近检测、左右挥手
MLX9064116x1255° 或 110°0.5~64HzI2C中等复杂度手势
MLX9064032x2455° 或 110°0.5~64HzI2C0到9手势识别

如果只做“手掌靠近”或“左右挥”,AMG8833 完全够用,成本最低,I2C 量也小。但做 0 到 9 这类数字手势,8x8 的像素太少了,数字 6 和 0、1 和 7 在热像上几乎长一个样。我一般直接上 MLX90640,32x24 的横向 32 个像素能保留出手指之间的温度凹陷,分类器才有可分性。

2.2 ESP32的I2C引脚和供电,别接错3.3V和5V

MLX90640 是 3.3V 器件,ESP32 开发板的 VIN 或 5V 引脚不能直接接给传感器,否则通电瞬间就可能烧掉内部 LDO。标准的接法是这样:

MLX90640ESP32 开发板
VDD3.3V
GNDGND
SDAGPIO21
SCLGPIO22

注意 ESP32-S3 等新板子的 I2C 默认引脚不一定还是 21/22,很多模组把 SDA/SCL 放在 GPIO8/GPIO9,买板子时先看丝印和原理图。Wire.begin(21, 22)里的引脚号是 ESP32 做 I2C 时的自定义引脚,不是固定的硬件引脚,所以你也可以改成其他 GPIO,但不要选 GPIO12,它是 strapping pin,上电时有下拉电阻,容易造成 I2C 电平异常。

传感器功耗不高,运行电流在 12mA 左右,直接用开发板的 3.3V LDO 没问题。I2C 上拉电阻在多数开发板上已经存在,但如果用杜邦线把传感器拉到 20cm 以上,最好在 SDA/SCL 上各并联一个 4.7k 欧姆上拉电阻,否则高温环境下波形沿变缓,会出现隔几分钟丢一帧的现象。

2.3 用C++读一帧768点温度矩阵

这里先用 Arduino 生态最常用的库验证,库的底层就是 C++,后面要搬到 ESP-IDF 也容易。在 Arduino IDE 的库管理器里搜索并安装Adafruit MLX90640,然后写这一段:

#include <Wire.h> #include <Adafruit_MLX90640.h> Adafruit_MLX90640 mlx; float frame[32 * 24]; void setup() { Serial.begin(115200); Wire.begin(21, 22); // SDA=GPIO21, SCL=GPIO22 Wire.setClock(400000); // I2C 时钟 400kHz if (!mlx.begin(0x33, &Wire)) { Serial.println("MLX90640 not found"); while (1) delay(100); } mlx.setMode(MLX90640_CHESS); mlx.setResolution(MLX90640_ADC_16BIT); mlx.setRefreshRate(MLX90640_8_HZ); } void loop() { if (mlx.getFrame(frame) == 0) { for (int i = 0; i < 32 * 24; i++) { Serial.printf("%6.2f%c", frame[i], (i % 32 == 31) ? '\n' : ' '); } } delay(100); }

这段代码里几个参数是关键:

  • Wire.begin(21, 22):指定 ESP32 的 I2C 引脚,换成你的实际接线。
  • mlx.begin(0x33, &Wire):MLX90640 默认 I2C 地址是 0x33,如果改了传感器的地址位,这里要跟着改。
  • MLX90640_CHESS:设置读出模式。如果一帧图像里出现横条纹或棋盘格噪声,把它换成MLX90640_INTERLEAVED再试。
  • MLX90640_ADC_16BIT:ADC 精度越高,分辨率越好,但刷新率会受限制,实际用 16bit 就够。
  • MLX90640_8_HZ:8Hz 刷新率适合手部动作,太慢会丢过程,太快则背景和 I2C 总线压力都大。

getFrame返回的frame数组已经是摄氏温度值,比如 26.4 表示 26.4℃,不需要再乘系数。第一次跑通后建议用串口绘图器或上位机把 32x24 的二维数组画成热图,确认传感器是不是正对着手背。如果画面里手的顶部被切掉,可能是 FOV 和距离不匹配,MLX90640 的 55° 视场在 30cm 距离上大约能覆盖 31cm x 23cm,手放太近就超出画面了。

3. 手势识别核心:背景建模、手部分割与特征提取

拿到原始温度矩阵之后,下一步不是直接训练,而是先把手掌从背景里抠出来。手势识别算法里最容易翻车的就是背景漂移:空调出风、阳光晒到的桌面、传感器自身发热,都会让同一个像素的温度随时间变化。如果只做一次静态背景采集,早上能用的阈值,下午就废了。

3.1 背景更新需要“留一手”

我常用的做法是慢速背景建模,参考帧只更新温差小的像素。这样手长时间停在画面里,也不会被吸收进背景。

#define PIXEL_NUM 768 static float bg[PIXEL_NUM]; static bool bgReady = false; void updateBackground(float *frame) { if (!bgReady) { memcpy(bg, frame, sizeof(bg)); bgReady = true; return; } for (int i = 0; i < PIXEL_NUM; i++) { float diff = frame[i] - bg[i]; // 只有背景像素才更新,避免手部长时间不动后被写成新背景 if (fabsf(diff) < 1.5f) { bg[i] = 0.95f * bg[i] + 0.05f * frame[i]; } } } void frameDiff(float *frame, float *fg) { for (int i = 0; i < PIXEL_NUM; i++) { float d = frame[i] - bg[i]; // 下限滤掉传感器噪声,上限滤掉热杯/空调等高温源 fg[i] = (d > 1.8f && d < 15.0f) ? d : 0.0f; } }

0.95f是背景更新系数,相当于每次只拿 5% 的新像素去修正背景。在 8Hz 刷新率下,背景时间常数大约是 20 帧,也就是 2.5 秒。太大会导致背景跟不上室内温度变化,太小则会导致手停下 1 秒后变成背景。

阈值 1.8~15℃ 也不是随便写的。MLX90640 在 16bit 模式下的随机噪声大约 ±0.1~0.3℃,1.8℃ 能滤掉噪声,又能保留手和空气的温差。上限 15℃ 是为了避开电热杯、灯泡这些比手更热的物体;如果你所在环境里没有这类高温源,可以把上限去掉,只保留下限。

3.2 用连通域过滤小噪点,保留最大手掌区域

阈值分割之后会出现零星亮点,可能是传感器噪声或远处热源。我一般会再用一次“最大连通域”过滤,只保留面积最大的一块区域。这里用四邻域种子填充就能跑:

void floodFill(const uint8_t *src, uint8_t *out, int w, int h, int sx, int sy, int *area) { int stack[768]; int top = 0; stack[top++] = sy * w + sx; *area = 0; while (top > 0) { int idx = stack[--top]; if (src[idx] == 0 || out[idx]) continue; out[idx] = 1; (*area)++; int x = idx % w; int y = idx / w; if (x > 0) stack[top++] = idx - 1; if (x < w - 1) stack[top++] = idx + 1; if (y > 0) stack[top++] = idx - w; if (y < h - 1) stack[top++] = idx + w; } }

使用方式是从fg的非零像素开始调用floodFill,每填完一块就记录面积,面积最大的那块就是手。src是二值化后的 mask,out是输出 mask。由于矩阵只有 768 个点,局部栈用int stack[768]就够,不会爆栈;你不需要也不应该在 ESP32 上跑 OpenCV 的findContours

3.3 归一化和下采样:从768个浮点压到64个特征

手掌区域提取出来后,如果直接拿 768 个温度值去训练,模型很容易记住“手在传感器左上角”的位置,而不是记住手的形状。正确做法是把手掌放到一个 8x8 的网格里,做一次 max-pooling,再用最大值归一化。

void thermalTo8x8(float *blob, float *features) { float maxVal = 0.0f; // 32x24 分成 8x8 块,每块 4x3 像素 for (int oy = 0; oy < 8; oy++) { for (int ox = 0; ox < 8; ox++) { float maxv = 0.0f; for (int y = oy * 3; y < oy * 3 + 3; y++) { for (int x = ox * 4; x < ox * 4 + 4; x++) { float v = blob[y * 32 + x]; if (v > maxv) maxv = v; } } features[oy * 8 + ox] = maxv; if (maxv > maxVal) maxVal = maxv; } } // 归一化到 0~1,减少手和传感器的距离影响 if (maxVal > 0.01f) { for (int i = 0; i < 64; i++) features[i] /= maxVal; } }

用 max-pooling 而不是 average-pooling,是因为手指在热像上只占 2~3 个像素,均值会把指尖温度拉低,max 能保留“最热”的位置。下面的表是这组参数的经验值,环境温差越大,越要注意上限:

参数推荐值说明
背景更新系数0.95时间常数约20帧,手不会融进背景
前景温差下限1.8℃低于这个值视为传感器噪声
前景温差上限15℃排除热杯、灯具等高温干扰
池化方式max保留指尖和手掌边缘
输出尺寸8x864维特征,适合轻量 MLP

4. 在ESP32上跑轻量分类器:模型选择与前向推理

4.1 先训练后部署的思路

64 维特征向量不需要上卷积网络。0 到 9 手势识别的可分性主要在轮廓占比和手指分布,一个单隐层全连接网络就够了。常见做法是在 PC 上用 Python 或 Edge Impulse 训练手势识别模型,然后把权重导出成 C/C++ 源文件。Edge Impulse 的好处是能直接从串口采集 MLX90640 的 8x8 灰度图,自动生成嵌入式 C++ 推理代码;如果不想依赖平台,自己用 Keras 训练也可以,最后把model->get_weights()转成二维数组。

训练样本每个手势至少录 100 帧,采集时手要有上下左右轻微移动,不能只放正中间。否则模型在部署时会对位置极其敏感,手一偏就分类错误。

4.2 前向推理 C++ 函数

以下是我在 ESP32 上用的最小推理结构,输入 64 个 float,输出 10 个类别的索引:

#include <math.h> #define N_IN 64 #define N_HIDDEN 24 #define N_CLS 10 static const float w1[N_HIDDEN][N_IN] = { /* PC训练后导出 */ }; static const float b1[N_HIDDEN] = { /* 训练后导出 */ }; static const float w2[N_CLS][N_HIDDEN] = { /* 训练后导出 */ }; static const float b2[N_CLS] = { /* 训练后导出 */ }; int predictGesture(float *features) { float hidden[N_HIDDEN]; // 隐层:线性加权 + tanh 激活 for (int h = 0; h < N_HIDDEN; h++) { float sum = b1[h]; for (int i = 0; i < N_IN; i++) { sum += w1[h][i] * features[i]; } hidden[h] = tanhf(sum); } // 输出层:找最大得分,不需要完整 softmax int best = 0; float bestScore = -1e9f; for (int c = 0; c < N_CLS; c++) { float sum = b2[c]; for (int h = 0; h < N_HIDDEN; h++) { sum += w2[c][h] * hidden[h]; } if (sum > bestScore) { bestScore = sum; best = c; } } return best; // 返回 0~9 }

几个细节必须说明:

  • N_HIDDEN取 24 而不是更大的值,是因为 64 维输入相对简单,隐层太大会过拟合,且在 ESP32 上每多一个节点都会增加乘法次数。
  • tanhfsigmoid更容易收敛,且 ESP32 的 libm 自带,不需要额外算法。
  • 权重必须定义为static const,让编译器放进.rodata段,不占用堆栈。如果你把几 KB 的权重数组放在函数内部,ESP32 默认栈只有 8KB,一调用就重启。
  • 输出层没有做 softmax 也能完成分类,因为只需比较大小。如果要输出“置信度”,再补一个 softmax 循环。

4.3 0到9手势识别的参数整定

参数推荐值踩坑说明
隐层节点16~32大于64后准确率不升反降
学习率0.001Adam 默认即可
训练轮数50~100样本少时轮数太多会记住训练集
分类阈值0.6输出概率低于0.6时判为 unknown
刷新率8~16Hz低于4Hz时手势过程会断

实际跑起来最容易混的是 0 和 6、1 和 7。0 和 6 在热像上都是圆形,差别只在右边有没有一个小缺口;如果 8x8 池化后缺口被 max-pooling 抹掉,就会误判。遇到这种情况,可以把 8x8 改成 12x8 的下采样,给横向更多分辨率,同时把N_HIDDEN提高到 32,就能把缺口作为一个特征保留下来。

5. 串口可视化、OTA升级和现场排错

5.1 用串口JSON输出识别结果

调试阶段不要只打印一个数字,最好把分类结果和手部区域一起输出,这样你才知道模型为什么错。我习惯用一行 JSON:

void publishResult(int cls, float prob, int area, float cx, float cy) { Serial.printf( "{\"cls\":%d,\"prob\":%.2f,\"area\":%d,\"cx\":%.2f,\"cy\":%.2f}\r\n", cls, prob, area, cx, cy); }

上位机用 Python 的json.loads()直接解析,或者用 Node-RED 接 MQTT 都可以。area是最大连通域的面积,cx/cy是质心坐标。如果分类错误时 area 明显偏小,说明分割阶段把手指切掉了;如果 cx/cy 抖动很大,说明背景更新太慢,有很多残留噪声被当成前景。

5.2 ESP32 OTA升级:改模型不用拆机

算法调试到现场后,经常会改阈值或换手势定义。每次都用 USB 线插到板子上烧录太麻烦,尤其传感器已经装在结构件里。ESP32 OTA升级是标准解法:

方案适用阶段注意事项
USB 串口烧录开发验证简单直接
ArduinoOTA/IDF OTA同一局域网第一次烧录需要先把手动烧录入口编译进去
HTTP OTA现场远程分区表必须包含 ota_0 和 ota_1 两个 app 分区

我建议开发初期就把ArduinoOTA加进工程,提前把 OTA 流程调通。使用默认分区表时只有一个 app 分区,Update.begin会失败;需要把分区方案换成带ota_0ota_1的布局,固件升级时才会在两个分区之间切换。OTA 成功后别忘了把模型权重放在可写入的存储区,或者单独做一个 model 分区,否则每次升级都要重新烧模型。

5.3 三个现场排错点

如果你的设备在手势识别现场出现下面三种情况,优先按顺序查:

传感器找不到,mlx.begin返回 false。先用i2cdetect类似工具扫描 0x33 地址,再用示波器或万用表量 SDA/SCL 是否有 3.3V 上拉。杜邦线超过 20cm 时,上升沿变差也可能导致偶尔识别不到。

手放进画面 5 秒后,分割区域慢慢消失。这是背景更新太快,0.95f被改得太小了。背景更新系数要保持在 0.9~0.99 之间,同时updateBackground里必须检查fabsf(diff) < 1.5f,否则手部像素会持续污染背景。

分类准确率在实验室 95%,现场掉到 70%。先看串口的areacx/cy,如果手的位置偏到画面边缘,热像轮廓被截断,分类器自然会错。最好在特征归一化前判断area是否小于全画面的 5%,过小就直接输出unknown,不要让模型硬猜。

如果现场遇到分类错乱,优先把BG_ALPHA调回 0.95,再看串口图里手的轮廓有没有断成两截。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询