Melexis MLX90640 · 32×24 远红外热成像阵列 · I2C

MLX90640ESF-BAA / BAB 热成像阵列模块
接线与 ESP32 实测

BAA 和 BAB 是同一颗芯片的两个镜头版本,像素规格完全一样(32×24,768点), 区别只在视场角:BAA 110°×75° 广角,BAB 55°×35° 窄角。 本资料两个型号一起讲解原理,实物接线测试用 BAB,开发板是 ESP32D 2.8寸屏(CYD)。模块板上已经带好 LDO 稳压和 I2C 上拉电阻, 只需 VIN/GND/SCL/SDA 四根线;真正的难点不在接线,而在于 原始数据必须经过片内 EEPROM 校准系数补偿运算才能变成温度—— 这一点在第 6 节详细拆开讲,已实机验证通过

更新于 2026-08-01

  • 阵列32×24(768点)
  • 接口I2C
  • 地址0x33
  • 刷新率0.5–64Hz
  • BAA 视场角110°×75°
  • BAB 视场角55°×35°
  • 芯片供电2.9–3.6V
  • 模块供电3.3–5V(板载LDO)
  • 封装TO-39
  • 实测型号BAB
MLX90640 热成像模块实物图,黑色小板,金属罐封装传感器,四个焊盘VIN/GND/SDA/SCL,附赠一条排针
模块实物:黑色小板 + 金属罐封装传感器,四个焊盘。
1

这颗传感器是什么

普通摄像头拍的是可见光反射,MLX90640 感应的是物体自身发出的远红外热辐射, 输出的不是一张照片,而是一张 32×24 的温度矩阵——768 个格子,每个格子一个 独立的摄氏温度读数。它不需要环境光,纯黑暗里一样能"看见"温度分布。

口播比喻

可以把它想成一张 32 列 × 24 行的电子表格,每个单元格填的不是文字,是一个温度数字。 屏幕上看到的"热图",其实就是把这张表格里的数字按大小映射成颜色画出来的。

它适合做低成本热成像入门、人体存在检测、热源定位、设备发热观察——但不是 高清热像仪,分辨率有限,看不清电路板级别的小元件发热。

2

BAA / BAB 怎么选

两颗芯片像素规格完全相同,都是 32×24=768 点,区别不是清晰度, 而是同样 768 个点被分布到多大的视场角(FOV)里。

MLX90640ESF-BAA

110° × 75° 广角

看得更宽,适合房间范围热分布、人体存在检测、近距离大范围观察。 ⚠ 本资料仅整理参数,未实物验证

MLX90640ESF-BAB

55° × 35° 窄角

画面更集中,适合桌面测试、局部热源(一杯热水、一只手掌)。 ✓ 本项目实物接线测试用这颗,已验证

项目BAABAB
视场角 FOV110° × 75°55° × 35°
像素规格相同,32×24(768点)
接口/地址/刷新率完全相同,代码不用改
适合场景房间级、人体存在检测桌面局部热源、集中观察
本项目验证状态⚠ 仅资料整理✓ 实物接线测试通过
3

关键参数表

参数标注了来源;精度这类容易被简化成"一个数字"的指标,官方原文其实是一张随温度区间 变化的曲线图,本表已经在备注里说明,不直接当成绝对值使用。

参数数值 / 说明备注
像素阵列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
NETD0.1K RMS @ 1Hz多方资料一致
测温精度约 ±1℃ ~ ±2℃(区间而非定值)⚠ 官方是精度曲线图,正式方案请查datasheet
目标测温范围-40℃ ~ +300℃多方资料一致
环境工作温度-40℃ ~ +85℃多方资料一致
帧结构每帧2个subpage,各384点交错见第5节
这张表不是最终定稿

带 ⚠ 的行是整理时没能逐字核对官方 PDF 原文的地方(多个二手资料源交叉核对)。 正式写进量产方案前,请人工下载 Melexis 官方 datasheet 核对一遍,尤其是精度曲线图和 EEPROM 写入速度这两条。

4

模块引脚与硬件

本次用的是模块板(黑色小板+金属罐传感器),不是裸芯片。正面丝印从左到右:

  • VIN模块供电输入。板上有LDO稳压,3.3V–5V都能接。
  • GND地,所有接线必须共地。
  • SCLI2C时钟线,板上已有上拉电阻,不用外加。
  • SDAI2C数据线,板上已有上拉电阻,不用外加。
板载元件:LDO + 上拉都做好了

跟裸芯片小板不同,这块模块已经把稳压和I2C上拉电阻做在板上了,所以接线只需要四根线, 不要再额外并联上拉电阻,多个上拉并联后阻值过小反而可能让总线不稳定。

5

帧结构:subpage 与棋盘交错模式

这是很多教程会跳过、但理解了会少走很多弯路的一点:MLX90640 并不是一次性读出全部 768个像素,它每次只输出半帧(384点),完整的一张768点画面需要读两次,官方把这 两次半帧叫做两个 subpage

完整一帧(32×24) = subpage 0(棋盘格一半) + subpage 1(另一半)
每个小方块代表一个像素。左边是完整32×24画面;右边拆成两次读取的subpage—— 棋盘交错模式下,subpage 0 只覆盖深色格子,subpage 1 只覆盖浅色格子,两次读完拼在一起才是完整帧。
模式交错方式精度
棋盘模式(chess mode)像棋盘格一样交错取点出厂标定用这个,精度更高,官方推荐,本项目实测用这个
行交错模式(TV mode)按行交替(像老式电视隔行扫描)精度不如棋盘模式,不建议使用
对开发的实际影响

调用一次官方库的 GetFrameData 类接口,拿到的是一个subpage, 要拼出完整768点画面得调用两次并合并。如果发现"画面好像一半是新的一半是旧的", 先确认是不是只处理了一个subpage就拿去显示了。

6

为什么原始数据不是温度

这是MLX90640和SHT30、SCD41这类"读出来就是结果"的数字传感器最大的不同: MLX90640 读出来的原始ADC值不能直接当摄氏度用,必须先做一次补偿运算。

  1. 出厂一次性写入:每颗芯片出厂时,Melexis会把这颗芯片自己的校准系数 (每个像素的offset/gain等)烧录进片内EEPROM。
  2. 开机读一次EEPROM:主控启动时把这一大块校准数据整体读出来, 解析成一个校准参数结构体,只需要做一次,不用每帧重复。
  3. 每帧做补偿运算:拿到某一帧的原始ADC值后,结合当前环境温度、 供电电压和上一步的校准参数,做一次浮点运算,才能算出这768个点各自的真实摄氏度。
最容易踩的坑

如果直接把原始ADC值当温度显示,画面会是一堆没有意义的固定模式/异常数值—— 这不是传感器坏了,是漏掉了EEPROM校准这一步。官方驱动库已经把这套运算封装好, 正常使用不需要自己重新推导公式,但一定要按顺序调用:先解析校准参数(一次), 再逐帧调用换算函数。

这也是为什么MLX90640项目通常推荐ESP32这类算力更强的主控——每帧768点的浮点补偿运算, 对内存/算力紧张的入门单片机会比较吃力。

7

ESP32D 2.8寸屏(CYD)接线

这块开发板俗称"CYD",板上有一个 CN1 四针扩展口,本系列其它项目 (BMM150、SCD41)已经把它的I2C引脚约定为 IO22=SCL、IO27=SDA 并实测通过, 本项目沿用同一约定,跨项目保持一致。

⚠ 最容易踩的坑:IO21 是背光脚,不能接SDA

CYD板上 GPIO21 已经被屏幕背光 TFT_BL 占用,如果把SDA接到IO21, 会跟背光信号打架。这块板的I2C应该用 CN1 扩展口的 IO22/IO27, 不是随便挑两个空脚。

MLX90640接ESP32D 2.8寸屏(CYD)接线图:GND对GND,VIN对CN1的3V3,SCL对IO22,SDA对IO27
四根线一一对应,不交叉。已实机验证:接上后串口能读到真实序列号,屏幕正常显示热成像画面。
MLX90640 模块ESP32D 2.8寸屏(CN1)说明
VIN3V3CN1上的3V3,模块自带LDO
GNDGND共地
SCLIO22与BMM150/SCD41项目同一组引脚
SDAIO27不是IO21——IO21是背光TFT_BL
同一组引脚只能接一个模块

CN1的IO22/IO27是通用GPIO,跟BMM150、SCD41等项目共用同一组接口, 换项目前记得先拔掉旧模块,这是正常现象不是故障。

8

软件与驱动选择

层级库/仓库说明
官方底层驱动melexis/mlx90640-libraryC语言,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读取、单帧温度数组这几步跑通、 从串口打印验证数据合理,再接入屏幕显示,最后才调刷新率和插值效果——顺序反过来会很难排查是哪一层出的问题。

9

完整固件源码 + 在线烧录

✓ 已实机验证通过(2026-08-01)

接上真实 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%,零编译报错。

实机测试中发现并修复的两处问题(v1→v2)

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、已实机验证通过的版本。

下载源码包

ESP32 触摸UI固件(PlatformIO工程)

main.ino + platformio.ini + README,已实机验证通过,共432行。

下载

源码在线预览: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 &center, 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;
}

在线烧录

直接在浏览器里烧进ESP32,不用装Arduino IDE

上面这份代码已经编译好、合并成单文件固件包,点开 网页烧录器 用电脑端 Chrome/Edge 连上ESP32就能烧,不用装任何软件。

10

常见问题与避坑

  • I2C扫描不到0x33:先查VIN/GND有没有接反或没插到底;确认IO22/IO27没被别的已插模块占用。
  • 画面全黑/全是异常固定值:多半是漏了EEPROM校准这一步,把原始ADC值直接当温度显示了。
  • 画面卡顿:先把刷新率降到4Hz验证流程通顺,再逐步提高;只刷新热图区域而非整屏重绘。
  • 翻页触摸不灵敏:确认用的是v2固件(FreeRTOS任务把传感器读取搬到独立核心),v1版会因为 getFrame()阻塞主循环导致触摸检测跟不上,详见第9节。
  • 温度看着不准:小目标没填满视场会被背景平均;金属反光面会干扰红外测温; 环境温度剧烈变化时读数有滞后。
  • BAA和BAB烧同一份程序,画面范围看着不一样:正常现象,两者视场角不同,属于设计特性不是bug。
11

应用场景

低成本热成像入门、人体存在检测、设备/电路发热粗略定位、教学演示热分布原理。 画面分辨率有限、帧率受限,但用来理解"红外阵列怎么读数、怎么做校准补偿"这套思路, 学习价值很高——这也是这个模块最大的价值所在,不是替代专业热像仪。

人体存在检测 热源定位 教学演示 低成本热分布
12

参考资料

链接用途
Melexis 官方产品页产品概览
Melexis 官方 Datasheet完整规格(本次未能在线解析全文,建议人工核对)
melexis/mlx90640-library官方底层驱动
Adafruit_MLX90640Arduino/ESP32封装库,本项目实测版本1.1.2
Adafruit 使用教程接线与代码参考
本页信息来源

像素规格、I2C地址、EEPROM校准原理、subpage机制来自 Melexis 官方资料与多方公开资料交叉核对; 接线、固件、串口验证结果和实拍照片均来自本店实机测试; 精度曲线和EEPROM写入速度等标 ⚠ 的条目尚未逐字核对官方PDF原文,请以最终datasheet为准。

← 返回传感器分类 返回首页 进入淘宝店铺 →