这颗传感器是什么
普通摄像头拍的是可见光反射,MLX90640 感应的是物体自身发出的远红外热辐射, 输出的不是一张照片,而是一张 32×24 的温度矩阵——768 个格子,每个格子一个 独立的摄氏温度读数。它不需要环境光,纯黑暗里一样能"看见"温度分布。
可以把它想成一张 32 列 × 24 行的电子表格,每个单元格填的不是文字,是一个温度数字。 屏幕上看到的"热图",其实就是把这张表格里的数字按大小映射成颜色画出来的。
它适合做低成本热成像入门、人体存在检测、热源定位、设备发热观察——但不是 高清热像仪,分辨率有限,看不清电路板级别的小元件发热。
BAA / BAB 怎么选
两颗芯片像素规格完全相同,都是 32×24=768 点,区别不是清晰度, 而是同样 768 个点被分布到多大的视场角(FOV)里。
110° × 75° 广角
看得更宽,适合房间范围热分布、人体存在检测、近距离大范围观察。 ⚠ 本资料仅整理参数,未实物验证。
55° × 35° 窄角
画面更集中,适合桌面测试、局部热源(一杯热水、一只手掌)。 ✓ 本项目实物接线测试用这颗,已验证。
| 项目 | BAA | BAB |
|---|---|---|
| 视场角 FOV | 110° × 75° | 55° × 35° |
| 像素规格 | 相同,32×24(768点) | |
| 接口/地址/刷新率 | 完全相同,代码不用改 | |
| 适合场景 | 房间级、人体存在检测 | 桌面局部热源、集中观察 |
| 本项目验证状态 | ⚠ 仅资料整理 | ✓ 实物接线测试通过 |
关键参数表
参数标注了来源;精度这类容易被简化成"一个数字"的指标,官方原文其实是一张随温度区间 变化的曲线图,本表已经在备注里说明,不直接当成绝对值使用。
| 参数 | 数值 / 说明 | 备注 |
|---|---|---|
| 像素阵列 | 32 × 24(768点) | 多方资料一致 |
| 芯片封装 | TO-39,4引脚金属罐 | DigiKey 型号页 |
| 芯片供电 | 2.9V – 3.6V(典型3.3V) | DigiKey / utmel |
| 芯片工作电流 | 典型 < 23mA | ⚠ 待与官方数值表逐条核对 |
| 模块供电 | 3.3V – 5V(板载LDO) | 同类模块常见规格,本模块未单独核实 |
| I2C 默认地址 | 0x33 | 多方资料一致,本机实测确认 |
| I2C 时钟 | 通信最高约1MHz;写EEPROM建议≤400kHz | ⚠ 400kHz建议未逐字核对原文 |
| 可编程刷新率 | 0.5 – 64Hz(8档) | 多方资料一致,实测用4Hz |
| NETD | 0.1K RMS @ 1Hz | 多方资料一致 |
| 测温精度 | 约 ±1℃ ~ ±2℃(区间而非定值) | ⚠ 官方是精度曲线图,正式方案请查datasheet |
| 目标测温范围 | -40℃ ~ +300℃ | 多方资料一致 |
| 环境工作温度 | -40℃ ~ +85℃ | 多方资料一致 |
| 帧结构 | 每帧2个subpage,各384点交错 | 见第5节 |
带 ⚠ 的行是整理时没能逐字核对官方 PDF 原文的地方(多个二手资料源交叉核对)。 正式写进量产方案前,请人工下载 Melexis 官方 datasheet 核对一遍,尤其是精度曲线图和 EEPROM 写入速度这两条。
模块引脚与硬件
本次用的是模块板(黑色小板+金属罐传感器),不是裸芯片。正面丝印从左到右:
- VIN模块供电输入。板上有LDO稳压,3.3V–5V都能接。
- GND地,所有接线必须共地。
- SCLI2C时钟线,板上已有上拉电阻,不用外加。
- SDAI2C数据线,板上已有上拉电阻,不用外加。
跟裸芯片小板不同,这块模块已经把稳压和I2C上拉电阻做在板上了,所以接线只需要四根线, 不要再额外并联上拉电阻,多个上拉并联后阻值过小反而可能让总线不稳定。
帧结构:subpage 与棋盘交错模式
这是很多教程会跳过、但理解了会少走很多弯路的一点:MLX90640 并不是一次性读出全部 768个像素,它每次只输出半帧(384点),完整的一张768点画面需要读两次,官方把这 两次半帧叫做两个 subpage。
| 模式 | 交错方式 | 精度 |
|---|---|---|
| 棋盘模式(chess mode) | 像棋盘格一样交错取点 | 出厂标定用这个,精度更高,官方推荐,本项目实测用这个 |
| 行交错模式(TV mode) | 按行交替(像老式电视隔行扫描) | 精度不如棋盘模式,不建议使用 |
调用一次官方库的 GetFrameData 类接口,拿到的是一个subpage,
要拼出完整768点画面得调用两次并合并。如果发现"画面好像一半是新的一半是旧的",
先确认是不是只处理了一个subpage就拿去显示了。
为什么原始数据不是温度
这是MLX90640和SHT30、SCD41这类"读出来就是结果"的数字传感器最大的不同: MLX90640 读出来的原始ADC值不能直接当摄氏度用,必须先做一次补偿运算。
- 出厂一次性写入:每颗芯片出厂时,Melexis会把这颗芯片自己的校准系数 (每个像素的offset/gain等)烧录进片内EEPROM。
- 开机读一次EEPROM:主控启动时把这一大块校准数据整体读出来, 解析成一个校准参数结构体,只需要做一次,不用每帧重复。
- 每帧做补偿运算:拿到某一帧的原始ADC值后,结合当前环境温度、 供电电压和上一步的校准参数,做一次浮点运算,才能算出这768个点各自的真实摄氏度。
如果直接把原始ADC值当温度显示,画面会是一堆没有意义的固定模式/异常数值—— 这不是传感器坏了,是漏掉了EEPROM校准这一步。官方驱动库已经把这套运算封装好, 正常使用不需要自己重新推导公式,但一定要按顺序调用:先解析校准参数(一次), 再逐帧调用换算函数。
这也是为什么MLX90640项目通常推荐ESP32这类算力更强的主控——每帧768点的浮点补偿运算, 对内存/算力紧张的入门单片机会比较吃力。
ESP32D 2.8寸屏(CYD)接线
这块开发板俗称"CYD",板上有一个 CN1 四针扩展口,本系列其它项目
(BMM150、SCD41)已经把它的I2C引脚约定为 IO22=SCL、IO27=SDA 并实测通过,
本项目沿用同一约定,跨项目保持一致。
CYD板上 GPIO21 已经被屏幕背光 TFT_BL 占用,如果把SDA接到IO21,
会跟背光信号打架。这块板的I2C应该用 CN1 扩展口的 IO22/IO27,
不是随便挑两个空脚。
| MLX90640 模块 | ESP32D 2.8寸屏(CN1) | 说明 |
|---|---|---|
| VIN | 3V3 | CN1上的3V3,模块自带LDO |
| GND | GND | 共地 |
| SCL | IO22 | 与BMM150/SCD41项目同一组引脚 |
| SDA | IO27 | 不是IO21——IO21是背光TFT_BL |
CN1的IO22/IO27是通用GPIO,跟BMM150、SCD41等项目共用同一组接口, 换项目前记得先拔掉旧模块,这是正常现象不是故障。
软件与驱动选择
| 层级 | 库/仓库 | 说明 |
|---|---|---|
| 官方底层驱动 | melexis/mlx90640-library | C语言,Apache-2.0协议,是所有平台移植的源头 |
| Arduino/ESP32封装 | Adafruit_MLX90640(Arduino库管理器可搜到,本项目实测版本1.1.2) | 对官方驱动做Arduino风格封装,安装更省事 |
| 屏幕显示 | TFT_eSPI + U8g2_for_TFT_eSPI(本项目实测组合) | ESP32D 2.8寸屏(ST7789驱动)常用选择,U8g2负责中文点阵字体 |
先用Adafruit_MLX90640库把I2C地址扫描、EEPROM读取、单帧温度数组这几步跑通、 从串口打印验证数据合理,再接入屏幕显示,最后才调刷新率和插值效果——顺序反过来会很难排查是哪一层出的问题。
完整固件源码 + 在线烧录
接上真实 MLX90640ESF-BAB + ESP32D 2.8寸屏:串口读到真实芯片序列号
110A6767190,屏幕正确显示热成像画面(手掌靠近能看到清楚的热区变化:
白/粉色热区+青色冷背景)。编译环境:espressif32@6.13.0 +
arduino-esp32 2.0.17(固定核心版本绕开3.x在部分环境下链接
esp_bt_controller_mem_release的已知问题,跟SCD41/0911同一个坑同一个解法),
RAM 13.0%、Flash 27.6%,零编译报错。
1. 翻页触摸不灵敏:根因是 mlx.getFrame() 是阻塞调用,直接放在
loop() 里会让触摸检测跟着卡顿。改成用 FreeRTOS 任务把传感器读取挪到
ESP32 另一个核(core 0)单独跑,主循环(core 1)专心处理触摸。修复后录了真实串口日志验证,
4次点按全部100%正确识别:
down 75859ms → up 76079ms(held 220ms) → THERMAL→STATS down 78574ms → up 78774ms(held 200ms) → STATS→INFO down 81891ms → up 81951ms(held 60ms) → INFO→ABOUT down 84592ms → up 84652ms(held 60ms) → ABOUT→THERMAL
2. 热图偏小、颜色对比不清楚:v1只有5个调色板锚点, 冷端的紫色偏蓝紫,容易把偏热的手误读成"看不清的蓝色"。v2换成7锚点经典热像仪配色 (黑→深蓝→品红→红→橙→黄→白),热图从192×144放大到256×192,实拍效果手掌清楚可辨 (白/粉色热区+青色冷背景)。下面的下载/源码是v2、已实机验证通过的版本。
下载源码包
源码在线预览:main.ino
直接在下面框里滚动查看,不用下载;页面结构、触摸交互、字体处理都沿用本系列 SCD41/0911等项目已验证过的写法,只有传感器数据层是本项目新写的。
/*
ESP32-2432S028R(2.8寸黄屏CYD) + MLX90640 热成像阵列 触摸UI演示(中文版)v2
接线:模块 VIN->3V3,GND->GND,SDA->IO27,SCL->IO22
(跟 0911 ATECC608A / BMM150 / DPS310 / SCD41 项目同一路 CN1 I2C排针)
⚠ 别把SDA接到GPIO21——CYD板上21是屏幕背光(TFT_BL=21),接SDA会跟背光打架。
屏幕/触摸引脚沿用本系列已验证过的配置(TFT_eSPI + U8g2_for_TFT_eSPI中文点阵字体)。
✓ 已实机烧录验证(2026-08-01):真实读到传感器序列号、屏幕正确显示热成像画面
(手掌靠近能看到画面变化)。这一版是根据实机测试反馈的两个问题改的:
1. "翻页点不了/不够灵敏":根因是v1版把 mlx.getFrame() 直接放在 loop() 里调用——
这个调用是阻塞的,内部要等两个subpage都读到才返回,在4Hz刷新率下每次可能
卡住大几十到几百毫秒。loop()里触摸检测代码排在它后面,阻塞期间发生的点按
完全检测不到,只有阻塞结束那一瞬间去看引脚状态,手指多半已经松开了。
v2把MLX90640的读取搬到FreeRTOS的独立任务(sensorTask),钉在core 0上跑,
跟主循环(loop(),在core 1)用互斥锁交换数据;主循环不再被I2C等待卡住,
触摸检测能一直按20ms间隔跑,翻页恢复正常灵敏度。
2. "热图太小、手是蓝色看不清":v1的调色板只有黑->紫->红->橙->白5个锚点,
冷端的"紫"(90,0,120)偏蓝紫色,跟真正想要的"冷=蓝、热=黄白"对比不够强烈,
容易把偏暖的手误读成"看不清的蓝紫色"。v2换成7锚点的经典热像仪配色
(黑->深蓝->品红->红->橙->黄->白),冷热两端对比更清楚;同时把热图从
192x144放大到256x192(HM_CELL从6提到8),标题栏改窄、底部统计行和翻页
提示合并成一行,腾出空间让热图尽量占满屏幕。
⚠ 本项目实际焊的是 MLX90640ESF-BAB(55°x35°窄角),BAA(110°x75°广角)只在
资料页整理规格,没有实物验证。BAA/BAB 的差别是镜头视场角,芯片本身没法通过
I2C读出FOV是多少,MODULE_VARIANT_NAME 只是给"传感器信息"页显示用的编译期
说明文字,换成BAA模块时改这一行就行,代码逻辑完全不用动。
交互:
- 单击:翻页(热成像画面 -> 数值统计 -> 传感器信息 -> 原理说明 -> 回热成像画面)
- 长按2秒:重新初始化传感器(软复位,等效重启整块板子)
*/
#include <SPI.h>
#include <TFT_eSPI.h>
#include <U8g2_for_TFT_eSPI.h>
#include <Wire.h>
#include <Adafruit_MLX90640.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#define I2C_SDA 27
#define I2C_SCL 22
#define TOUCH_CS 33
#define TOUCH_IRQ 36
#define HOLD_MS 2000UL
#define SCREEN_W 320
#define SCREEN_H 240
#define COL_BG TFT_BLACK
#define COL_TEXT TFT_WHITE
#define COL_DIM TFT_DARKGREY
#define COL_OK TFT_GREEN
#define COL_WARN TFT_RED
#define COL_ACCENT TFT_YELLOW
#define COL_CYAN TFT_CYAN
// 本店实际焊的模块型号,只影响信息页显示文字,不影响任何通信逻辑
#define MODULE_VARIANT_NAME "MLX90640ESF-BAB (55x35 窄角,本项目实测型号)"
TFT_eSPI tft = TFT_eSPI();
U8g2_for_TFT_eSPI u8f;
Adafruit_MLX90640 mlx;
// ---------------- 热成像共享数据 ----------------
// sensorTask(core 0)写、loop()(core 1)读,用互斥锁保护,避免主循环被I2C等待卡住。
static SemaphoreHandle_t frameMutex;
static float frame[32 * 24];
static bool haveFrame = false;
static float frameMin = 0, frameMax = 0, frameCenter = 0, frameAmbient = 0;
static float fps = 0;
static uint32_t frameVersion = 0; // 每成功拿到一帧+1,主循环靠这个判断"要不要重画"
static uint8_t sensorFailStreak = 0; // 连续失败次数,用来判定"传感器掉线"
// 热图显示区:8px一格,32x24 -> 256x192,标题栏改窄、底部统计+提示合并成一行,
// 尽量把屏幕让给热图本身(实机反馈v1版192x144"感觉太小")。
#define HM_CELL 8
#define HM_W (32 * HM_CELL) // 256
#define HM_H (24 * HM_CELL) // 192
#define HM_X ((SCREEN_W - HM_W) / 2) // 32
#define HM_Y 26
// ---------------- 应用状态 ----------------
enum AppState {
STATE_DETECT,
STATE_ERROR,
STATE_THERMAL, // 热成像画面(主页)
STATE_STATS, // 数值统计:最低/最高/中心/环境温度、FPS
STATE_INFO, // 传感器信息
STATE_ABOUT, // 原理说明:subpage/EEPROM校准
};
static AppState state = STATE_DETECT;
// ---------------- 伪彩色(经典热像仪配色 ironbow v2) ----------------
// 7个锚点:黑 -> 深蓝 -> 品红 -> 红 -> 橙 -> 黄 -> 白,冷端明确是蓝、热端明确是黄白,
// 比v1版(黑->紫->红->橙->白,紫偏蓝紫容易跟"暖但不够热"的目标混在一起看不清)
// 对比度更清楚——手掌这类27~34℃目标应该在橙黄段,跟背景的蓝/品红拉开差距。
static uint16_t heatColor(float t) {
if (t < 0) t = 0;
if (t > 1) t = 1;
static const uint8_t stops[7][3] = {
{0, 0, 0}, {20, 0, 110}, {140, 0, 130}, {230, 20, 20},
{250, 140, 0}, {255, 215, 0}, {255, 255, 255}
};
float pos = t * 6.0f; // 0..6之间,对应7个锚点
int idx = (int)pos;
if (idx > 5) idx = 5;
float frac = pos - idx;
uint8_t r = stops[idx][0] + (uint8_t)((stops[idx + 1][0] - stops[idx][0]) * frac);
uint8_t g = stops[idx][1] + (uint8_t)((stops[idx + 1][1] - stops[idx][1]) * frac);
uint8_t b = stops[idx][2] + (uint8_t)((stops[idx + 1][2] - stops[idx][2]) * frac);
return tft.color565(r, g, b);
}
// ---------------- 触摸:只用IRQ判断"有没有摁下",不做坐标映射 ----------------
static void touchInit() {
pinMode(TOUCH_CS, OUTPUT);
digitalWrite(TOUCH_CS, LOW);
pinMode(TOUCH_IRQ, INPUT);
}
static bool isTouchedNow() {
return digitalRead(TOUCH_IRQ) == LOW;
}
// ---------------- 绘制小工具 ----------------
static int cursorY;
static void resetCursor(int y) { cursorY = y; }
static void setPrimaryFont() { u8f.setFont(u8g2_font_wqy16_t_gb2312); } // 16px
static void setSecondaryFont() { u8f.setFont(u8g2_font_wqy12_t_gb2312); } // 12px
static void drawHeader(const char *title, uint16_t color) {
tft.fillScreen(COL_BG);
u8f.setForegroundColor(color);
setPrimaryFont();
u8f.drawUTF8(12, 26, title);
tft.drawFastHLine(0, 34, SCREEN_W, COL_DIM);
resetCursor(56);
}
// 热成像页专用的窄标题栏(12px字体+更靠上的分割线),比drawHeader省出~18px高度给热图
static void drawThermalHeader(const char *title, uint16_t color) {
tft.fillScreen(COL_BG);
u8f.setForegroundColor(color);
setSecondaryFont();
u8f.drawUTF8(8, 15, title);
tft.drawFastHLine(0, 21, SCREEN_W, COL_DIM);
}
// tier: 2=正文(16px),1=小字提示(12px)
static void printLine(const String &text, uint16_t color, uint8_t tier = 2) {
u8f.setForegroundColor(color);
if (tier >= 2) setPrimaryFont(); else setSecondaryFont();
u8f.drawUTF8(12, cursorY, text.c_str());
cursorY += (tier >= 2) ? 24 : 18;
}
static void printGap(int px) { cursorY += px; }
static void drawFooterHint(const char *text) {
u8f.setForegroundColor(COL_DIM);
setSecondaryFont();
u8f.drawUTF8(12, SCREEN_H - 8, text);
}
static void drawProgressBar(uint32_t elapsedMs) {
const int x = 12, y = SCREEN_H - 30, w = SCREEN_W - 24, h = 10;
uint32_t clamped = elapsedMs > HOLD_MS ? HOLD_MS : elapsedMs;
int fillW = (int)((uint32_t)w * clamped / HOLD_MS);
tft.fillRect(x, y, w, h, 0x2104);
if (fillW > 0) tft.fillRect(x, y, fillW, h, COL_ACCENT);
tft.drawRect(x, y, w, h, COL_DIM);
}
// 从共享数据里取一份快照,尽量少占用互斥锁的时间(拷贝完立刻释放锁,再慢慢画)
static bool snapshotFrame(float *outFrame, float &lo, float &hi, float ¢er, float &amb, float &fpsOut) {
bool ok;
xSemaphoreTake(frameMutex, portMAX_DELAY);
ok = haveFrame;
if (ok) {
memcpy(outFrame, frame, sizeof(frame));
lo = frameMin; hi = frameMax; center = frameCenter; amb = frameAmbient; fpsOut = fps;
}
xSemaphoreGive(frameMutex);
return ok;
}
// ---------------- 各页面 ----------------
static void renderDetectPage() {
drawHeader("检测中...", COL_TEXT);
printLine("正在查找 MLX90640", COL_TEXT);
}
static void renderErrorPage() {
drawHeader("未找到传感器", COL_WARN);
printLine("请检查接线:", COL_TEXT);
printLine("SDA=IO27 SCL=IO22", COL_TEXT);
printLine("VIN=3.3V 地址=0x33", COL_TEXT);
printGap(6);
printLine("模块自带LDO+上拉,不用外加", COL_DIM, 1);
drawFooterHint("长按2秒重新扫描");
}
/* 热成像主页:窄标题栏 + 256x192热图(尽量占满屏幕) + 底部一行统计+提示 */
static void renderThermalStatic() {
drawThermalHeader("热成像画面", COL_CYAN);
}
static void renderThermalValues() {
static float localFrame[32 * 24];
float lo, hi, center, amb, fpsNow;
if (!snapshotFrame(localFrame, lo, hi, center, amb, fpsNow)) {
u8f.setForegroundColor(COL_DIM);
setPrimaryFont();
u8f.drawUTF8(HM_X, HM_Y + HM_H / 2, "等待第一帧数据...");
return;
}
float span = hi - lo;
if (span < 2.0f) span = 2.0f;
for (uint8_t r = 0; r < 24; r++) {
for (uint8_t c = 0; c < 32; c++) {
float t = localFrame[r * 32 + c];
float norm = (t - lo) / span;
uint16_t col = heatColor(norm);
tft.fillRect(HM_X + c * HM_CELL, HM_Y + r * HM_CELL, HM_CELL, HM_CELL, col);
}
}
char buf[80];
tft.fillRect(0, HM_Y + HM_H + 2, SCREEN_W, 16, COL_BG);
u8f.setForegroundColor(COL_TEXT);
setSecondaryFont();
snprintf(buf, sizeof(buf), "%.0f~%.0f℃ 中心%.1f℃ 轻触翻页·长按扫描", lo, hi, center);
u8f.drawUTF8(6, HM_Y + HM_H + 14, buf);
}
static void renderStatsPage() {
drawHeader("数值统计", COL_CYAN);
static float localFrame[32 * 24];
float lo, hi, center, amb, fpsNow;
if (!snapshotFrame(localFrame, lo, hi, center, amb, fpsNow)) {
printLine("等待第一帧数据...", COL_DIM);
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
return;
}
char buf[48];
printLine("最低温度", COL_DIM, 1);
snprintf(buf, sizeof(buf), "%.1f C", lo);
tft.setTextColor(COL_CYAN, COL_BG);
tft.drawString(buf, 12, cursorY, 4);
cursorY += 34;
printLine("最高温度", COL_DIM, 1);
snprintf(buf, sizeof(buf), "%.1f C", hi);
tft.setTextColor(COL_WARN, COL_BG);
tft.drawString(buf, 12, cursorY, 4);
cursorY += 34;
char line[64];
snprintf(line, sizeof(line), "中心点 %.1f C 环境 %.1f C %.1f FPS",
center, amb, fpsNow);
u8f.setForegroundColor(COL_TEXT);
setSecondaryFont();
u8f.drawUTF8(12, cursorY + 6, line);
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
}
static void renderInfoPage() {
drawHeader("传感器信息", COL_OK);
printLine("型号:" + String(MODULE_VARIANT_NAME), COL_TEXT, 1);
char sn[24];
snprintf(sn, sizeof(sn), "%04X%04X%04X",
mlx.serialNumber[0], mlx.serialNumber[1], mlx.serialNumber[2]);
printLine("序列号:" + String(sn), COL_TEXT, 1);
printGap(4);
printLine("地址 0x33 阵列 32x24(768点)", COL_TEXT, 1);
printLine("模式 棋盘交错(chess) 刷新率 4Hz", COL_TEXT, 1);
printGap(4);
printLine("BAA(110x75广角)本页仅作参数说明", COL_DIM, 1);
printLine("本机实测型号是BAB(55x35窄角)", COL_DIM, 1);
drawFooterHint("轻触:翻页 长按2秒:重新扫描");
}
static void renderAboutPage() {
drawHeader("画面是怎么算出来的?", COL_ACCENT);
printLine("传感器每次只读半帧(384点),", COL_TEXT, 1);
printLine("交错两次(subpage)才拼成一整", COL_TEXT, 1);
printLine("张768点画面,用的是精度更高", COL_TEXT, 1);
printLine("的棋盘交错模式,不是行交错。", COL_TEXT, 1);
printGap(6);
printLine("原始数据不能直接当温度用:", COL_ACCENT, 1);
printLine("开机先读一次芯片EEPROM里出厂", COL_TEXT, 1);
printLine("校准系数,每帧再结合环境温度", COL_TEXT, 1);
printLine("做一次补偿运算,才是真实摄氏", COL_TEXT, 1);
printLine("度——这一步库已经封装好了。", COL_TEXT, 1);
drawFooterHint("轻触:回热成像画面 长按2秒:重新扫描");
}
static void renderCurrentState() {
switch (state) {
case STATE_DETECT: renderDetectPage(); break;
case STATE_ERROR: renderErrorPage(); break;
case STATE_THERMAL: renderThermalStatic(); renderThermalValues(); break;
case STATE_STATS: renderStatsPage(); break;
case STATE_INFO: renderInfoPage(); break;
case STATE_ABOUT: renderAboutPage(); break;
}
}
// ---------------- 触摸事件 -> 状态机 ----------------
static const char *stateName(AppState s) {
switch (s) {
case STATE_DETECT: return "DETECT";
case STATE_ERROR: return "ERROR";
case STATE_THERMAL: return "THERMAL";
case STATE_STATS: return "STATS";
case STATE_INFO: return "INFO";
case STATE_ABOUT: return "ABOUT";
}
return "?";
}
static void onShortTap() {
AppState before = state;
switch (state) {
case STATE_THERMAL: state = STATE_STATS; renderCurrentState(); break;
case STATE_STATS: state = STATE_INFO; renderCurrentState(); break;
case STATE_INFO: state = STATE_ABOUT; renderCurrentState(); break;
case STATE_ABOUT: state = STATE_THERMAL; renderCurrentState(); break;
default: break; // 检测中/错误页只能长按重试
}
Serial.printf("[touch] short tap: %s -> %s\n", stateName(before), stateName(state));
}
static void onLongPressComplete() {
ESP.restart();
}
// ---------------- 传感器初始化(只在setup()里跑一次,跑完才建task) ----------------
static bool sensorInit() {
if (!mlx.begin(MLX90640_I2CADDR_DEFAULT, &Wire)) return false;
mlx.setMode(MLX90640_CHESS); // 棋盘交错,精度优于行交错
mlx.setResolution(MLX90640_ADC_18BIT);
mlx.setRefreshRate(MLX90640_4_HZ); // 先从4Hz开始,稳定后再考虑调高
return true;
}
// ---------------- 传感器任务(core 0):只有这个任务碰Wire/I2C ----------------
// mlx.getFrame()是阻塞调用,放在独立任务里跑,不会影响core 1上loop()的触摸检测。
static void sensorTaskFunc(void *pv) {
static float localFrame[32 * 24];
static uint32_t lastFrameMs = 0;
for (;;) {
if (mlx.getFrame(localFrame) == 0) {
float lo = localFrame[0], hi = localFrame[0];
for (uint16_t i = 1; i < 32 * 24; i++) {
if (localFrame[i] < lo) lo = localFrame[i];
if (localFrame[i] > hi) hi = localFrame[i];
}
float center = localFrame[12 * 32 + 16];
float amb = mlx.getTa(false);
uint32_t now = millis();
float fpsNow = fps;
if (lastFrameMs != 0) {
uint32_t dt = now - lastFrameMs;
if (dt > 0) fpsNow = 1000.0f / (float)dt;
}
lastFrameMs = now;
xSemaphoreTake(frameMutex, portMAX_DELAY);
memcpy(frame, localFrame, sizeof(frame));
frameMin = lo; frameMax = hi; frameCenter = center; frameAmbient = amb;
fps = fpsNow;
haveFrame = true;
sensorFailStreak = 0;
frameVersion++;
xSemaphoreGive(frameMutex);
} else {
xSemaphoreTake(frameMutex, portMAX_DELAY);
if (sensorFailStreak < 250) sensorFailStreak++;
xSemaphoreGive(frameMutex);
}
vTaskDelay(pdMS_TO_TICKS(5)); // 让出一点CPU,getFrame()本身已经会等到数据就绪
}
}
void setup() {
Serial.begin(115200);
delay(300);
tft.init();
tft.setRotation(1);
tft.fillScreen(COL_BG);
u8f.begin(tft);
u8f.setFontMode(1); // 透明背景
u8f.setFontDirection(0);
touchInit();
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setClock(400000); // 官方支持读帧最高约1MHz,400kHz是稳妥的起点
renderDetectPage();
if (!sensorInit()) {
Serial.println("MLX90640 not found");
state = STATE_ERROR;
renderCurrentState();
return;
}
Serial.print("Serial number: ");
Serial.print(mlx.serialNumber[0], HEX);
Serial.print(mlx.serialNumber[1], HEX);
Serial.println(mlx.serialNumber[2], HEX);
frameMutex = xSemaphoreCreateMutex();
xTaskCreatePinnedToCore(sensorTaskFunc, "mlxTask", 4096, NULL, 1, NULL, 0);
state = STATE_THERMAL;
renderCurrentState();
}
void loop() {
static uint32_t lastCheck = 0;
static uint32_t lastPoll = 0;
static bool wasDown = false;
static uint32_t pressStartMs = 0;
static bool longFired = false;
static uint32_t lastActionMs = 0;
static uint32_t lastSeenVersion = 0;
uint32_t now = millis();
// 只做"要不要重画/要不要判定掉线"的轻量检查,真正读传感器的慢操作在sensorTask里,
// 不占用这里的时间,所以下面的触摸检测能一直按20ms节奏跑,不会被I2C等待卡住。
if (state != STATE_ERROR && state != STATE_DETECT && now - lastCheck >= 100) {
lastCheck = now;
uint32_t ver;
uint8_t fails;
xSemaphoreTake(frameMutex, portMAX_DELAY);
ver = frameVersion;
fails = sensorFailStreak;
xSemaphoreGive(frameMutex);
if (fails >= 12) {
state = STATE_ERROR;
renderCurrentState();
} else if (ver != lastSeenVersion) {
lastSeenVersion = ver;
if (state == STATE_THERMAL) renderThermalValues();
}
}
if (now - lastPoll < 20) return;
lastPoll = now;
bool down = isTouchedNow();
if (down && !wasDown) {
pressStartMs = now;
longFired = false;
Serial.printf("[touch] down at %lu ms\n", (unsigned long)now);
}
if (down) {
uint32_t held = now - pressStartMs;
if (held > 300) drawProgressBar(held);
if (!longFired && held >= HOLD_MS) {
longFired = true;
lastActionMs = now;
Serial.println("[touch] long press -> restart");
onLongPressComplete();
}
}
if (!down && wasDown) {
uint32_t held = now - pressStartMs;
Serial.printf("[touch] up at %lu ms, held=%lu ms\n", (unsigned long)now, (unsigned long)held);
if (!longFired && (now - lastActionMs > 250)) {
lastActionMs = now;
onShortTap();
} else if (!longFired) {
renderCurrentState();
}
}
wasDown = down;
}
在线烧录
上面这份代码已经编译好、合并成单文件固件包,点开 网页烧录器 用电脑端 Chrome/Edge 连上ESP32就能烧,不用装任何软件。
常见问题与避坑
- I2C扫描不到0x33:先查VIN/GND有没有接反或没插到底;确认IO22/IO27没被别的已插模块占用。
- 画面全黑/全是异常固定值:多半是漏了EEPROM校准这一步,把原始ADC值直接当温度显示了。
- 画面卡顿:先把刷新率降到4Hz验证流程通顺,再逐步提高;只刷新热图区域而非整屏重绘。
- 翻页触摸不灵敏:确认用的是v2固件(FreeRTOS任务把传感器读取搬到独立核心),v1版会因为
getFrame()阻塞主循环导致触摸检测跟不上,详见第9节。 - 温度看着不准:小目标没填满视场会被背景平均;金属反光面会干扰红外测温; 环境温度剧烈变化时读数有滞后。
- BAA和BAB烧同一份程序,画面范围看着不一样:正常现象,两者视场角不同,属于设计特性不是bug。
应用场景
低成本热成像入门、人体存在检测、设备/电路发热粗略定位、教学演示热分布原理。 画面分辨率有限、帧率受限,但用来理解"红外阵列怎么读数、怎么做校准补偿"这套思路, 学习价值很高——这也是这个模块最大的价值所在,不是替代专业热像仪。
参考资料
| 链接 | 用途 |
|---|---|
| Melexis 官方产品页 | 产品概览 |
| Melexis 官方 Datasheet | 完整规格(本次未能在线解析全文,建议人工核对) |
| melexis/mlx90640-library | 官方底层驱动 |
| Adafruit_MLX90640 | Arduino/ESP32封装库,本项目实测版本1.1.2 |
| Adafruit 使用教程 | 接线与代码参考 |
像素规格、I2C地址、EEPROM校准原理、subpage机制来自 Melexis 官方资料与多方公开资料交叉核对; 接线、固件、串口验证结果和实拍照片均来自本店实机测试; 精度曲线和EEPROM写入速度等标 ⚠ 的条目尚未逐字核对官方PDF原文,请以最终datasheet为准。