Close Menu
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
Facebook X (Twitter) Instagram
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us
Facebook X (Twitter) Instagram Pinterest YouTube LinkedIn
How To Electronics
  • Articles
    • Learn Electronics
    • Product Review
    • Tech Articles
  • Electronics Circuits
    • 555 Timer Projects
    • Op-Amp Circuits
    • Power Electronics
  • Microcontrollers
    • Arduino Projects
    • STM32 Projects
    • AMB82-Mini IoT AI Camera
    • BLE Projects
  • IoT Projects
    • ESP8266 Projects
    • ESP32 Projects
    • ESP32 MicroPython
    • ESP32-CAM Projects
    • LoRa/LoRaWAN Projects
  • Raspberry Pi
    • Raspberry Pi Projects
    • Raspberry Pi Pico Projects
    • Raspberry Pi Pico W Projects
  • Electronics Calculator
How To Electronics
Home » DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display
ESP32 Projects IoT Projects

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

Mamtaz AlamBy Mamtaz AlamUpdated:May 10, 202611 Mins Read
Share Facebook Twitter LinkedIn Telegram Reddit WhatsApp
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display
Share
Facebook Twitter LinkedIn Pinterest Email Reddit Telegram WhatsApp

Overview

In this project, we will develop an ESP32 Thermal Imaging Camera using the MLX90640 IR Image Array Temperature Sensor. The MLX90640 is a far-infrared thermal sensor with a 32 × 24 pixel array, providing 768 individual temperature measurement points for thermal image generation.

Compared to lower-resolution thermal sensors such as the AMG8833, which has only an 8 × 8 pixel array with 64 thermal detectors, the MLX90640 offers significantly better thermal image detail. Its higher pixel density allows the camera to detect heat patterns from objects, electronic components, human hands, faces, and surrounding temperature variations more clearly.

The project uses an ESP32 module as the main controller and the MLX90640 sensor as the thermal imaging element.  The thermal data is processed as a 32 × 24 infrared temperature matrix and converted into a smooth thermal heatmap for visualization. The web interface includes enhanced thermal image processing features such as bilinear interpolation, color palette mapping, frame smoothing, contrast enhancement, and hot/cold temperature markers. It also displays useful real-time values, including minimum temperature, maximum temperature, average temperature, and center temperature.

This DIY ESP32 MLX90640 Thermal Imaging Camera is useful for heat detection, electronics troubleshooting, human presence detection, temperature visualization, thermal monitoring, and IoT-based infrared imaging applications.


MLX90640 32×24 IR Array Temperature Sensor

MLX90640 IC

The MLX90640 from Melexis is a small size, non-contact, and low cost far-infrared thermal sensor array that integrates 768 (32×24) thermal sensors into a compact and standard 4-lead TO39 package.

This sensor is capable of capturing detailed thermal images by measuring the infrared radiation emitted by objects in its field of view, translating it into temperature readings ranging from -40°C to 300°C.

Different MLX90640 Breakout Boards
Different MLX90640 Breakout Boards

The sensor achieves a high degree of accuracy, maintaining approximately ±1°C across its operational range. Furthermore, the MLX90640 includes additional functionalities like an ambient temperature sensor and a supply voltage sensor, enhancing its precision and reliability. Data from the IR sensors, as well as the ambient and supply voltage measurements, are stored in internal RAM and can be accessed via an I2C interface.



The MLX90640 thermal camera features a 32×24 array, totaling 768 individual far-infrared pixels. Each pixel captures temperature data, allowing for detailed thermal imaging and accurate temperature measurements across the sensor’s field of view.

The MLX90640 is not only functional but also user-friendly, tailored for hobbyists and professionals alike. It supports both 3.3V and 5V operating voltages and communicates through a configurable I2C interface that can reach data rates up to 1MHz. This sensor is compatible with platforms like Arduino, Raspberry Pi, or STM32.

The sensor’s capability to adjust the frame rate from 0.5 to 64Hz allows users to fine-tune its performance based on specific application requirements, whether it’s tracking fast-moving objects or conducting detailed thermal evaluations over a slower period. Refer to MLX90640 Datasheet for more information about this Thermal Camera.

Features and Benefits

  • Small size, low cost 32×24 pixels IR array
  • Easy to integrate
  • Industry standard four lead TO39 package
  • Factory calibrated
  • Noise Equivalent Temperature Difference (NETD): 0.1K RMS @1Hz refresh rate
  • I2C compatible digital interface
  • Programmable refresh rate 0.5Hz…64Hz
  • 3.3V supply voltage
  • Current consumption is less than 23mA
  • 2 FOV options – 55°x35° and 110°x75°
  • Operating temperature -40°C ÷ 85°C
  • Target temperature -40°C ÷ 300°C
  • Complies with RoHS regulations

Check previous MLX90640 Thermal Camera projects and tutorials:
1. DIY Thermal Camera with MLX90640 & Raspberry Pi
2. Thermal Fever Detector with MLX90640 & OpenCV




ESP32 MLX90640 Thermal Camera Circuit Design & Schematic

Let’s walk through the hardware design of the ESP32 MLX90640 Thermal Camera in detail.

ESP32 AS7265x Colorimeter True Color Analyzer
Fig: Schematic for ESP32 MLX90640 Thermal Camera

The board is powered through a USB Type-C port. If battery operation is required, charging can be handled by a Li-ion charging IC such as the BQ24092D (or similar). It supports CC/CV charging, pre-conditioning, and auto cut-off. A small LED indicates charging status. A JST-PH connector is used to connect a 3.7 V Li-ion/LiPo battery. A slide switch is added to turn the system on or off.

The regulated 3.3 V supply for the ESP32 and the MLX90640 sensor is provided by a buck-boost converter like the TPS63020. It ensures a stable 3.3 V output across the entire battery voltage range (3.0–4.2 V). This keeps the ESP32 and the Thermal Sensor powered reliably even at low battery levels.

Programming is done through the PROG header or the onboard USB interface using an FTDI adapter (depending on your ESP32 module). Two transistors, Q1 and Q2, handle the auto-reset and auto-boot sequence. This means no buttons are needed during code upload.

Extra GPIO pins are broken out for sensors and modules. IO21 (SDA) and IO22 (SCL) provide the I²C interface for connecting the MLX90640 IR Thermal Image Array Temperature Sensor, which is the core of the Thermal Camera system.




PCB Designing & Gerber Files

The schematic of the ESP32 MLX90640 Thermal Camera was created in EasyEDA, and from there it was converted into a compact PCB layout.

Fig: PCB Layout of ESP32 MLX90640 Thermal Camera

The board is designed with mostly SMD components to keep the size small. Resistors and capacitors are in the 0603 package, while the main ICs, such as the TPS63020 buck-boost converter and the BQ24092D battery charger, are also in SMD form.

Fig: Front Side of PCB

All of the critical components, including the ESP32-WROOM module, are placed on the front side of the PCB, which makes assembly easier and more reliable.

Fig: Backside of PCB

Here are the link of files that you can download for PCB manufacturing and PCB assembly services.

  1. Download: Schematic PDF
  2. Download: Bill of Materials (BOM)
  3. Download: Pick & Place File
  4. Download: Gerber File




PCB Ordering Online & Assembly

The Gerber file for this ESP32 MLX90640 Thermal Camera is provided above. You can download the Gerber file and place an order with a PCB manufacturer like AIVON for as low as $1 for a PCB Prototype.

To order the PCB, visit the AIVON Official Website and upload the Gerber file using the Quote Now option. You can then choose your required parameters, such as Material Type, Dimensions, Quantity, Thickness, and Solder Mask Color.

AIVON is making PCB prototyping and assembly more affordable for new users by offering $1 PCB Prototype and $35 PCB Assembly with Shipping fee $30 OFF on your first PCB order. With this promotion, you can enjoy free shipping on your first order and affordable assembly service for your project.

Here is the promotion link: AIVON PCB/PCBA Promotion Offer

Once all the details are filled in, select your country and shipping method. After confirming everything, you can place the order and wait for your boards to arrive.




Assembly & Testing the ESP32 MLX90640 PCB Board

After about a week, the finished PCBs arrived from AIVON. The quality was excellent, with smooth solder pads and precise silkscreen printing—perfect for assembling the ESP32 MLX90640 IoT board.

Assembly begins with soldering all the SMD components on the front side. The design is compact, so only the front contains the ESP32 module, buck-boost converter, charger IC, and supporting resistors and capacitors.

In the first prototype there were a few minor value adjustments for capacitors and resistors, but these have been corrected in the final Gerber release, so you can assemble the latest version without any issues.

Once assembled, connect a 3.7 V Li-ion/LiPo battery to the JST connector. Programming the ESP32 is done through the PROG header with an FTDI adapter. Thanks to the automatic reset circuitry, there’s no need to press boot or reset buttons—the upload process is completely automatic.

For charging, simply plug in a USB Type-C cable. A red LED will indicate charging, and you can verify it by checking the battery voltage with a multimeter.

IoT Battery Monitoring System ESP32

The voltage should gradually increase during charging, confirming that the system is working properly.

The most important part of this project is installing the MLX90640 Thermal Image Array Temperature Sensor. The board includes a 4-pin connector for connecting any I2C sensor or module.

DIY ESP32 MLX90640 IR Thermal Camera

Connect the VCC, GND, SDA, and SCL pins of the MLX90640 sensor to the board’s 3.3V, GND, SDA, and SCL pins respectively. Since the thermal camera is a movable part, it is better to use jumper wires and connect it to the through-hole connector. Alternatively, you can solder the sensor wires directly to the board for a more permanent connection.




Source Code/Program for ESP32 MLX90640 Thermal Web Camera

Now let’s move to the coding part. We will develop an Arduino C++ code to interface the MLX90640 IR Image Array Temperature Sensor with the ESP32 and build our ESP32 Thermal Camera with Webserver.

For this project, we have created our own MLX90640 Arduino library to make the code easier to organize and use with the ESP32. This custom library contains the required MLX90640 API and I2C driver files needed to read thermal frame data from the sensor. You can download the library from the link below and add it to your Arduino libraries folder:

Download: MLX90640 Custom Library

This library allows the ESP32 to read the complete 32 × 24 infrared temperature pixel array from the sensor through the I2C interface. Each frame contains 768 temperature values, which are processed by the ESP32 to generate a real-time thermal image.

After initializing the sensor, the code reads the thermal frame data, calculates the minimum, maximum, average, and center temperature values, and sends this data to the webpage.

The ESP32 also hosts a real-time webserver using its built-in Wi-Fi capability. The webpage displays the live thermal image as a smooth heatmap in the browser. To make the thermal view more detailed and visually clear, the web interface uses interpolation, color mapping, frame smoothing, and enhanced contrast. This allows the low-resolution 32 × 24 MLX90640 data to appear as a much smoother and more useful thermal image on a smartphone or computer browser.

The code for this project is divided into two main files: thermal_image.ino and webpage.h. The thermal_image.ino file contains the main ESP32 program, including Wi-Fi setup, MLX90640 initialization, thermal data reading, and webserver handling. The webpage.h file contains all the HTML, CSS, and JavaScript code used to create the live web view.

thermal_image.ino File

Here is the main.ino code for the ESP32 MLX90640 Thermal Camera project.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
/*
  ESP32-WROOM-32 + MLX90640 Enhanced Web Thermal Camera
 
  Only two sketch files are normally edited:
    thermal_image/thermal_image.ino   -> ESP32, Wi-Fi, web server, MLX90640 read logic
    thermal_image/webpage.h           -> Browser UI and thermal image rendering
 
  MLX90640 library stays in:
    libraries/MLX90640/
 
  Hardware wiring:
    ESP32 GPIO21 -> MLX90640 SDA
    ESP32 GPIO22 -> MLX90640 SCL
    ESP32 3V3    -> MLX90640 VIN / 3V3
    ESP32 GND    -> MLX90640 GND
 
  Browser:
    Connect to ESP32 Wi-Fi AP: MLX90640-Camera
    Password: 12345678
    Open: http://192.168.4.1
*/
 
#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <WebServer.h>
 
#include <MLX90640_I2C_Driver.h>
#include <MLX90640_API.h>
 
#include "webpage.h"
 
// ---------------- ESP32 I2C pins ----------------
#define I2C_SDA 21
#define I2C_SCL 22
 
// ---------------- MLX90640 settings ----------------
static const uint8_t MLX90640_ADDR = 0x33;
static const float EMISSIVITY = 0.95f;
static const float TA_SHIFT = 8.0f;
 
// Faster MLX90640 settings:
// Refresh-rate register values used by the Melexis API:
// 0x00=0.5Hz, 0x01=1Hz, 0x02=2Hz, 0x03=4Hz,
// 0x04=8Hz, 0x05=16Hz, 0x06=32Hz, 0x07=64Hz
// Recommended default: 0x04 = 8Hz. Try 0x05 only if wiring is short and stable.
static const uint8_t MLX_REFRESH_RATE = 0x04;  // 8 Hz
 
// 0x00=16-bit, 0x01=17-bit, 0x02=18-bit, 0x03=19-bit
// 18-bit gives good quality. For more speed/noise tradeoff, try 0x01.
static const uint8_t MLX_RESOLUTION = 0x02;    // 18-bit ADC resolution
 
// MLX90640 supports fast I2C. 800 kHz is a good ESP32 balance.
// If unstable, change this to 400000UL. If very stable, you can try 1000000UL.
static const uint32_t I2C_CLOCK_HZ = 800000UL;
 
// Target browser/sensor update period. MLX90640_GetFrameData waits until data is ready,
// so the real speed is still limited by the sensor refresh rate.
static const uint32_t FRAME_INTERVAL_MS = 110;
 
// ---------------- Wi-Fi mode ----------------
// 1 = ESP32 creates its own Wi-Fi network.
// 0 = ESP32 connects to your router/hotspot using WIFI_SSID/WIFI_PASS.
#define USE_AP_MODE 1
 
#if USE_AP_MODE
const char* AP_SSID = "MLX90640-Camera";
const char* AP_PASS = "12345678";  // minimum 8 characters for ESP32 AP mode
#else
const char* WIFI_SSID = "YOUR_WIFI_NAME";
const char* WIFI_PASS = "YOUR_WIFI_PASSWORD";
#endif
 
WebServer server(80);
 
paramsMLX90640 mlxParams;
float thermalPixels[32 * 24];
uint16_t mlxFrame[834];
 
float minTemp = 0.0f;
float maxTemp = 0.0f;
float avgTemp = 0.0f;
float centerTemp = 0.0f;
float ambientTemp = 0.0f;
 
uint16_t minIndex = 0;
uint16_t maxIndex = 0;
uint8_t minX = 0;
uint8_t minY = 0;
uint8_t maxX = 0;
uint8_t maxY = 0;
 
bool sensorReady = false;
bool frameValid = false;
uint32_t lastFrameRequestMs = 0;
uint32_t lastFrameCompleteMs = 0;
uint32_t frameCounter = 0;
uint32_t lastReadDurationMs = 0;
float measuredFps = 0.0f;
String lastError = "Starting";
 
bool isMLXConnected()
{
  Wire.beginTransmission(MLX90640_ADDR);
  return Wire.endTransmission() == 0;
}
 
void calculateStats()
{
  minTemp = thermalPixels[0];
  maxTemp = thermalPixels[0];
  avgTemp = 0.0f;
  minIndex = 0;
  maxIndex = 0;
 
  for (uint16_t i = 0; i < 768; i++)
  {
    const float t = thermalPixels[i];
    avgTemp += t;
 
    if (t < minTemp)
    {
      minTemp = t;
      minIndex = i;
    }
 
    if (t > maxTemp)
    {
      maxTemp = t;
      maxIndex = i;
    }
  }
 
  avgTemp /= 768.0f;
 
  minX = minIndex % 32;
  minY = minIndex / 32;
  maxX = maxIndex % 32;
  maxY = maxIndex / 32;
 
  // Four center pixels around the middle of the 32x24 thermal array.
  centerTemp = (thermalPixels[367] + thermalPixels[368] + thermalPixels[399] + thermalPixels[400]) / 4.0f;
}
 
bool readMLX90640Frame()
{
  if (!sensorReady)
  {
    lastError = "MLX90640 not initialized";
    return false;
  }
 
  const uint32_t startMs = millis();
 
  // MLX90640 uses two subpages. Reading twice gives one complete 32x24 image.
  for (uint8_t subpage = 0; subpage < 2; subpage++)
  {
    int status = MLX90640_GetFrameData(MLX90640_ADDR, mlxFrame);
    if (status < 0)
    {
      lastError = "MLX90640_GetFrameData error: " + String(status);
      frameValid = false;
      return false;
    }
 
    ambientTemp = MLX90640_GetTa(mlxFrame, &mlxParams);
    const float reflectedTemp = ambientTemp - TA_SHIFT;
    MLX90640_CalculateTo(mlxFrame, &mlxParams, EMISSIVITY, reflectedTemp, thermalPixels);
  }
 
  calculateStats();
 
  const uint32_t nowMs = millis();
  lastReadDurationMs = nowMs - startMs;
 
  if (lastFrameCompleteMs > 0)
  {
    const uint32_t dt = nowMs - lastFrameCompleteMs;
    if (dt > 0)
    {
      const float instantFps = 1000.0f / (float)dt;
      measuredFps = (measuredFps <= 0.01f) ? instantFps : (0.75f * measuredFps + 0.25f * instantFps);
    }
  }
 
  lastFrameCompleteMs = nowMs;
  frameCounter++;
  frameValid = true;
  lastError = "OK";
  return true;
}
 
String makeJsonData()
{
  if (!frameValid)
  {
    String err = "{\"ok\":false,\"error\":\"";
    err += lastError;
    err += "\"}";
    return err;
  }
 
  String json;
  json.reserve(10500);
  json += "{\"ok\":true,";
  json += "\"frame\":" + String(frameCounter) + ",";
  json += "\"min\":" + String(minTemp, 2) + ",";
  json += "\"max\":" + String(maxTemp, 2) + ",";
  json += "\"avg\":" + String(avgTemp, 2) + ",";
  json += "\"center\":" + String(centerTemp, 2) + ",";
  json += "\"ambient\":" + String(ambientTemp, 2) + ",";
  json += "\"fps\":" + String(measuredFps, 2) + ",";
  json += "\"readMs\":" + String(lastReadDurationMs) + ",";
  json += "\"minX\":" + String(minX) + ",";
  json += "\"minY\":" + String(minY) + ",";
  json += "\"maxX\":" + String(maxX) + ",";
  json += "\"maxY\":" + String(maxY) + ",";
  json += "\"pixels\":[";
 
  for (uint16_t i = 0; i < 768; i++)
  {
    if (i) json += ',';
    json += String(thermalPixels[i], 2);
  }
 
  json += "]}";
  return json;
}
 
String makeCsvData()
{
  String csv;
  csv.reserve(7200);
 
  for (uint8_t row = 0; row < 24; row++)
  {
    for (uint8_t col = 0; col < 32; col++)
    {
      if (col) csv += ',';
      csv += String(thermalPixels[row * 32 + col], 2);
    }
    csv += '\n';
  }
 
  return csv;
}
 
void setupWebServer()
{
  server.on("/", HTTP_GET, []()
  {
    server.send_P(200, "text/html", WEBPAGE_HTML);
  });
 
  server.on("/data", HTTP_GET, []()
  {
    server.sendHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
    server.sendHeader("Pragma", "no-cache");
    server.send(200, "application/json", makeJsonData());
  });
 
  server.on("/csv", HTTP_GET, []()
  {
    server.sendHeader("Cache-Control", "no-store");
    server.sendHeader("Content-Disposition", "attachment; filename=mlx90640_frame.csv");
    server.send(200, "text/csv", makeCsvData());
  });
 
  server.on("/health", HTTP_GET, []()
  {
    String health = "ESP32 MLX90640 Enhanced Web Thermal Camera\n";
    health += "Sensor ready: " + String(sensorReady ? "yes" : "no") + "\n";
    health += "Frame valid: " + String(frameValid ? "yes" : "no") + "\n";
    health += "Frame count: " + String(frameCounter) + "\n";
    health += "Measured FPS: " + String(measuredFps, 2) + "\n";
    health += "Read duration ms: " + String(lastReadDurationMs) + "\n";
    health += "I2C clock Hz: " + String(I2C_CLOCK_HZ) + "\n";
    health += "Refresh setting: 0x" + String(MLX_REFRESH_RATE, HEX) + "\n";
    health += "Resolution setting: 0x" + String(MLX_RESOLUTION, HEX) + "\n";
    health += "Last error: " + lastError + "\n";
    server.send(200, "text/plain", health);
  });
 
  server.onNotFound([]()
  {
    server.send(404, "text/plain", "Not found");
  });
 
  server.begin();
}
 
bool initializeMLX90640()
{
  if (!isMLXConnected())
  {
    lastError = "MLX90640 not detected at I2C address 0x33. Check SDA=21, SCL=22, 3.3V, GND.";
    return false;
  }
 
  uint16_t eeMLX90640[832];
  int status = MLX90640_DumpEE(MLX90640_ADDR, eeMLX90640);
  if (status != 0)
  {
    lastError = "MLX90640_DumpEE failed: " + String(status);
    return false;
  }
 
  status = MLX90640_ExtractParameters(eeMLX90640, &mlxParams);
  if (status != 0)
  {
    lastError = "MLX90640_ExtractParameters failed: " + String(status);
    return false;
  }
 
  status = MLX90640_SetRefreshRate(MLX90640_ADDR, MLX_REFRESH_RATE);
  if (status != 0)
  {
    lastError = "SetRefreshRate failed: " + String(status);
    return false;
  }
 
  status = MLX90640_SetResolution(MLX90640_ADDR, MLX_RESOLUTION);
  if (status != 0)
  {
    lastError = "SetResolution failed: " + String(status);
    return false;
  }
 
  status = MLX90640_SetChessMode(MLX90640_ADDR);
  if (status != 0)
  {
    lastError = "SetChessMode failed: " + String(status);
    return false;
  }
 
  sensorReady = true;
  lastError = "OK";
  return true;
}
 
void setupWiFi()
{
  WiFi.setSleep(false);  // lower latency for live streaming
 
#if USE_AP_MODE
  WiFi.mode(WIFI_AP);
  WiFi.softAP(AP_SSID, AP_PASS, 1, false, 4);
 
  Serial.println();
  Serial.println("Wi-Fi AP started");
  Serial.print("SSID: ");
  Serial.println(AP_SSID);
  Serial.print("Password: ");
  Serial.println(AP_PASS);
  Serial.print("Open browser: http://");
  Serial.println(WiFi.softAPIP());
#else
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  Serial.print("Connecting to Wi-Fi");
 
  while (WiFi.status() != WL_CONNECTED)
  {
    delay(250);
    Serial.print('.');
  }
 
  Serial.println();
  Serial.print("Open browser: http://");
  Serial.println(WiFi.localIP());
#endif
}
 
void setup()
{
  setCpuFrequencyMhz(240);
 
  Serial.begin(115200);
  delay(800);
 
  Serial.println();
  Serial.println("ESP32 MLX90640 Enhanced Web Thermal Camera");
 
  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(I2C_CLOCK_HZ);
  Wire.setTimeOut(1000);
 
  Serial.print("I2C SDA: GPIO");
  Serial.println(I2C_SDA);
  Serial.print("I2C SCL: GPIO");
  Serial.println(I2C_SCL);
  Serial.print("I2C clock: ");
  Serial.println(I2C_CLOCK_HZ);
  Serial.println("MLX90640 address: 0x33");
 
  setupWiFi();
  setupWebServer();
 
  if (initializeMLX90640())
  {
    Serial.println("MLX90640 initialized successfully");
    readMLX90640Frame();
  }
  else
  {
    Serial.print("MLX90640 initialization failed: ");
    Serial.println(lastError);
  }
}
 
void loop()
{
  server.handleClient();
 
  const uint32_t now = millis();
  if (now - lastFrameRequestMs >= FRAME_INTERVAL_MS)
  {
    lastFrameRequestMs = now;
    readMLX90640Frame();
  }
}



webpage.h File

Here is the webpage file for webserver, which combines html, CSS, JavaScript and other files. Create a webpage.h file in the same Arduino editor window and paste the following code there.

C++
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
#ifndef WEBPAGE_H
#define WEBPAGE_H
 
#include <Arduino.h>
 
const char WEBPAGE_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>ESP32 MLX90640 Enhanced Thermal Camera</title>
  <style>
    :root {
      color-scheme: dark;
      --bg0: #05070d;
      --bg1: #0b1220;
      --card: rgba(255,255,255,0.075);
      --card2: rgba(0,0,0,0.30);
      --line: rgba(255,255,255,0.14);
      --text: #f5f7fb;
      --muted: #aeb9ca;
      --good: #7bf5c6;
      --warn: #ffd166;
    }
 
    * { box-sizing: border-box; }
 
    body {
      margin: 0;
      font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif;
      color: var(--text);
      background:
        radial-gradient(circle at top left, rgba(55, 91, 158, 0.42), transparent 38%),
        radial-gradient(circle at top right, rgba(187, 72, 121, 0.22), transparent 32%),
        linear-gradient(135deg, var(--bg1), var(--bg0));
      min-height: 100vh;
    }
 
    .wrap {
      width: min(1180px, 100%);
      margin: 0 auto;
      padding: 18px;
    }
 
    .top {
      display: flex;
      align-items: flex-end;
      justify-content: space-between;
      gap: 16px;
      flex-wrap: wrap;
      margin-bottom: 14px;
    }
 
    h1 {
      font-size: clamp(24px, 4vw, 42px);
      line-height: 1.05;
      margin: 8px 0 5px;
      letter-spacing: -0.045em;
    }
 
    .sub {
      color: var(--muted);
      font-size: 14px;
    }
 
    .pill {
      display: inline-flex;
      align-items: center;
      gap: 8px;
      border: 1px solid var(--line);
      background: rgba(255,255,255,0.08);
      border-radius: 999px;
      padding: 9px 12px;
      color: #dce6f7;
      font-size: 13px;
      white-space: nowrap;
    }
 
    .dot {
      width: 9px;
      height: 9px;
      border-radius: 999px;
      background: var(--warn);
      box-shadow: 0 0 16px var(--warn);
    }
 
    .dot.ok {
      background: var(--good);
      box-shadow: 0 0 16px var(--good);
    }
 
    .grid {
      display: grid;
      grid-template-columns: minmax(0, 1fr) 330px;
      gap: 16px;
      align-items: start;
    }
 
    .panel {
      background: var(--card);
      border: 1px solid var(--line);
      border-radius: 24px;
      padding: 14px;
      box-shadow: 0 24px 80px rgba(0, 0, 0, 0.38);
      backdrop-filter: blur(14px);
    }
 
    .cameraShell {
      position: relative;
      width: 100%;
      max-width: 900px;
      margin: 0 auto;
      border-radius: 20px;
      overflow: hidden;
      background: #000;
      border: 1px solid rgba(255,255,255,0.18);
    }
 
    canvas {
      width: 100%;
      aspect-ratio: 4 / 3;
      display: block;
      image-rendering: auto;
      background: #000;
    }
 
    .hud {
      position: absolute;
      left: 12px;
      top: 12px;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      pointer-events: none;
    }
 
    .hud span {
      background: rgba(0,0,0,0.48);
      border: 1px solid rgba(255,255,255,0.18);
      border-radius: 999px;
      padding: 6px 9px;
      font-size: 12px;
      color: #edf3ff;
      backdrop-filter: blur(8px);
    }
 
    .legend {
      display: grid;
      grid-template-columns: auto 1fr auto;
      gap: 10px;
      align-items: center;
      margin: 12px auto 0;
      max-width: 900px;
      color: var(--muted);
      font-size: 12px;
    }
 
    .bar {
      height: 16px;
      border-radius: 999px;
      border: 1px solid rgba(255, 255, 255, 0.22);
      background: linear-gradient(90deg,
        #080017,
        #240b55,
        #8d164f,
        #e03b32,
        #ff9a1f,
        #fff36d,
        #ffffff
      );
    }
 
    .stats {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 10px;
      margin-top: 14px;
    }
 
    .box {
      background: var(--card2);
      border-radius: 16px;
      padding: 11px 10px;
      border: 1px solid rgba(255, 255, 255, 0.10);
      min-height: 76px;
    }
 
    .label {
      display: block;
      color: var(--muted);
      font-size: 12px;
      margin-bottom: 5px;
    }
 
    .value {
      display: block;
      font-size: clamp(18px, 3vw, 25px);
      font-weight: 800;
      letter-spacing: -0.03em;
    }
 
    .small {
      color: #9eabba;
      font-size: 12px;
      margin-top: 4px;
    }
 
    .controls {
      display: grid;
      gap: 12px;
    }
 
    .controlTitle {
      font-weight: 800;
      font-size: 16px;
      margin: 2px 0 0;
    }
 
    .control {
      background: var(--card2);
      border: 1px solid rgba(255,255,255,0.10);
      border-radius: 16px;
      padding: 12px;
      text-align: left;
    }
 
    .control label {
      display: flex;
      justify-content: space-between;
      gap: 12px;
      color: #dce5f3;
      font-size: 13px;
      margin-bottom: 8px;
    }
 
    .control label strong {
      color: #ffffff;
    }
 
    select, button, input[type="range"] {
      width: 100%;
    }
 
    select, button {
      background: rgba(255,255,255,0.10);
      color: #fff;
      border: 1px solid rgba(255,255,255,0.16);
      border-radius: 12px;
      padding: 10px 11px;
      font-weight: 700;
      outline: none;
    }
 
    option { background: #101827; }
 
    button {
      cursor: pointer;
      transition: 0.15s ease;
    }
 
    button:hover {
      background: rgba(255,255,255,0.18);
    }
 
    .buttonRow {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 8px;
    }
 
    .toggleLine {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      color: #dce5f3;
      font-size: 13px;
    }
 
    .toggleLine input { transform: scale(1.15); }
 
    .status {
      margin-top: 12px;
      color: var(--muted);
      font-size: 13px;
      min-height: 20px;
      text-align: center;
    }
 
    @media (max-width: 980px) {
      .grid { grid-template-columns: 1fr; }
      .controls { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    }
 
    @media (max-width: 650px) {
      .wrap { padding: 10px; }
      .panel { padding: 10px; border-radius: 20px; }
      .stats { grid-template-columns: repeat(2, 1fr); }
      .controls { grid-template-columns: 1fr; }
      .top { align-items: flex-start; }
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="top">
      <div>
        <h1>MLX90640 Thermal Camera</h1>
        <div class="sub">ESP32-WROOM live thermal stream • 32 × 24 sensor • browser-enhanced image</div>
      </div>
      <div class="pill"><span class="dot" id="liveDot"></span><span id="liveText">Connecting...</span></div>
    </div>
 
    <div class="grid">
      <div class="panel">
        <div class="cameraShell">
          <canvas id="thermal" width="512" height="384"></canvas>
          <div class="hud">
            <span id="hudRange">Range --</span>
            <span id="hudCursor">Move over image</span>
          </div>
        </div>
 
        <div class="legend">
          <span id="legendMin">-- C</span>
          <div class="bar" id="legendBar"></div>
          <span id="legendMax">-- C</span>
        </div>
 
        <div class="stats">
          <div class="box"><span class="label">Center</span><span class="value" id="center">--</span><div class="small">middle 4 pixels</div></div>
          <div class="box"><span class="label">Minimum</span><span class="value" id="min">--</span><div class="small" id="minPos">--</div></div>
          <div class="box"><span class="label">Maximum</span><span class="value" id="max">--</span><div class="small" id="maxPos">--</div></div>
          <div class="box"><span class="label">Average</span><span class="value" id="avg">--</span><div class="small">all 768 pixels</div></div>
          <div class="box"><span class="label">Ambient</span><span class="value" id="ambient">--</span><div class="small">sensor estimate</div></div>
          <div class="box"><span class="label">Speed</span><span class="value" id="fps">--</span><div class="small" id="readMs">--</div></div>
        </div>
 
        <div class="status" id="status">Starting web display...</div>
      </div>
 
      <div class="panel controls">
        <div class="controlTitle">Image Controls</div>
 
        <div class="control">
          <label><strong>Palette</strong><span id="paletteName">Iron</span></label>
          <select id="palette" onchange="setPalette(this.value)">
            <option value="iron" selected>Iron / Thermal</option>
            <option value="rainbow">Rainbow</option>
            <option value="arctic">Arctic</option>
            <option value="gray">Gray</option>
          </select>
        </div>
 
        <div class="control">
          <label><strong>Interpolation</strong><span id="interpName">Smooth</span></label>
          <div class="buttonRow">
            <button onclick="setInterpolation(true)" id="smoothBtn">Smooth</button>
            <button onclick="setInterpolation(false)" id="pixelBtn">Pixel</button>
          </div>
        </div>
 
        <div class="control">
          <label><strong>Detail boost</strong><span id="detailVal">0.35</span></label>
          <input id="detail" type="range" min="0" max="1.2" value="0.35" step="0.05" oninput="setDetail(this.value)">
          <div class="small">Adds light edge/detail enhancement in the browser.</div>
        </div>
 
        <div class="control">
          <label><strong>Frame smoothing</strong><span id="smoothVal">0.35</span></label>
          <input id="temporal" type="range" min="0" max="0.85" value="0.35" step="0.05" oninput="setTemporal(this.value)">
          <div class="small">Higher value reacts faster. Lower value looks steadier.</div>
        </div>
 
        <div class="control">
          <div class="toggleLine">
            <span><strong>Enhanced contrast</strong><br><span class="small">Uses 3% to 97% range for more visible detail.</span></span>
            <input id="contrast" type="checkbox" checked onchange="redrawLast()">
          </div>
        </div>
 
        <div class="control">
          <div class="toggleLine">
            <span><strong>Show hot/cold markers</strong><br><span class="small">Draws max and min crosshairs.</span></span>
            <input id="markers" type="checkbox" checked onchange="redrawLast()">
          </div>
        </div>
 
        <div class="control">
          <button onclick="downloadCsv()">Download Current Frame CSV</button>
        </div>
      </div>
    </div>
  </div>
 
<script>
const SENSOR_W = 32;
const SENSOR_H = 24;
const OUT_W = 512;
const OUT_H = 384;
const FETCH_DELAY_MS = 85;
 
const canvas = document.getElementById('thermal');
canvas.width = OUT_W;
canvas.height = OUT_H;
const ctx = canvas.getContext('2d', { alpha: false });
const imageData = ctx.createImageData(OUT_W, OUT_H);
 
let palette = 'iron';
let useSmoothInterpolation = true;
let detailBoost = 0.35;
let temporalAmount = 0.35;
let temporalPixels = null;
let lastData = null;
let isFetching = false;
 
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function lerp(a, b, t) { return a + (b - a) * t; }
function fmtC(v) { return Number(v).toFixed(2) + ' C'; }
 
function setPalette(v) {
  palette = v;
  document.getElementById('paletteName').textContent = document.getElementById('palette').selectedOptions[0].textContent;
  updateLegendGradient();
  redrawLast();
}
 
function setInterpolation(v) {
  useSmoothInterpolation = v;
  document.getElementById('interpName').textContent = v ? 'Smooth' : 'Pixel';
  document.getElementById('smoothBtn').style.opacity = v ? '1' : '0.55';
  document.getElementById('pixelBtn').style.opacity = v ? '0.55' : '1';
  redrawLast();
}
 
function setDetail(v) {
  detailBoost = Number(v);
  document.getElementById('detailVal').textContent = detailBoost.toFixed(2);
  redrawLast();
}
 
function setTemporal(v) {
  temporalAmount = Number(v);
  document.getElementById('smoothVal').textContent = temporalAmount.toFixed(2);
}
 
function getPixel(pixels, x, y) {
  x = clamp(x, 0, SENSOR_W - 1);
  y = clamp(y, 0, SENSOR_H - 1);
  return pixels[y * SENSOR_W + x];
}
 
function sampleBilinear(pixels, x, y) {
  const x0 = Math.floor(x);
  const y0 = Math.floor(y);
  const x1 = Math.min(x0 + 1, SENSOR_W - 1);
  const y1 = Math.min(y0 + 1, SENSOR_H - 1);
  const fx = x - x0;
  const fy = y - y0;
 
  const p00 = getPixel(pixels, x0, y0);
  const p10 = getPixel(pixels, x1, y0);
  const p01 = getPixel(pixels, x0, y1);
  const p11 = getPixel(pixels, x1, y1);
 
  return lerp(lerp(p00, p10, fx), lerp(p01, p11, fx), fy);
}
 
function applyTemporalSmoothing(newPixels) {
  if (!temporalPixels || temporalPixels.length !== newPixels.length) {
    temporalPixels = newPixels.slice();
    return temporalPixels;
  }
 
  const alpha = clamp(temporalAmount, 0, 1);
  for (let i = 0; i < newPixels.length; i++) {
    temporalPixels[i] = temporalPixels[i] + alpha * (newPixels[i] - temporalPixels[i]);
  }
  return temporalPixels;
}
 
function enhanceDetails(pixels) {
  if (detailBoost <= 0.001) return pixels;
  const out = new Array(pixels.length);
 
  for (let y = 0; y < SENSOR_H; y++) {
    for (let x = 0; x < SENSOR_W; x++) {
      const i = y * SENSOR_W + x;
      const c = pixels[i];
      const n = (
        getPixel(pixels, x - 1, y) +
        getPixel(pixels, x + 1, y) +
        getPixel(pixels, x, y - 1) +
        getPixel(pixels, x, y + 1)
      ) * 0.25;
      out[i] = c + detailBoost * (c - n);
    }
  }
  return out;
}
 
function percentile(values, p) {
  const sorted = values.slice().sort((a, b) => a - b);
  const idx = clamp(Math.round((sorted.length - 1) * p), 0, sorted.length - 1);
  return sorted[idx];
}
 
function getDisplayRange(pixels, rawMin, rawMax) {
  if (document.getElementById('contrast').checked) {
    let lo = percentile(pixels, 0.03);
    let hi = percentile(pixels, 0.97);
    if (hi - lo < 0.5) {
      lo = rawMin;
      hi = rawMax;
    }
    return [lo, hi];
  }
  return [rawMin, rawMax];
}
 
function colorStopsForPalette(name) {
  if (name === 'gray') {
    return [
      [0.00, [0,0,0]], [1.00, [255,255,255]]
    ];
  }
  if (name === 'arctic') {
    return [
      [0.00, [1,10,35]], [0.25, [0,80,160]], [0.50, [0,210,255]],
      [0.75, [170,255,230]], [1.00, [255,255,255]]
    ];
  }
  if (name === 'rainbow') {
    return [
      [0.00, [35,0,80]], [0.16, [0,0,255]], [0.33, [0,180,255]],
      [0.50, [0,255,80]], [0.66, [255,255,0]], [0.83, [255,90,0]], [1.00, [255,255,255]]
    ];
  }
  return [
    [0.00, [0,0,12]], [0.18, [34,6,75]], [0.36, [132,20,78]],
    [0.55, [224,58,48]], [0.72, [255,151,31]], [0.88, [255,243,105]], [1.00, [255,255,255]]
  ];
}
 
function thermalColor(t) {
  t = clamp(t, 0, 1);
  const stops = colorStopsForPalette(palette);
 
  for (let i = 0; i < stops.length - 1; i++) {
    const a = stops[i];
    const b = stops[i + 1];
    if (t >= a[0] && t <= b[0]) {
      const k = (t - a[0]) / (b[0] - a[0]);
      return [
        lerp(a[1][0], b[1][0], k),
        lerp(a[1][1], b[1][1], k),
        lerp(a[1][2], b[1][2], k)
      ];
    }
  }
  return stops[stops.length - 1][1];
}
 
function updateLegendGradient() {
  const stops = colorStopsForPalette(palette)
    .map(s => `rgb(${s[1][0]},${s[1][1]},${s[1][2]}) ${Math.round(s[0] * 100)}%`)
    .join(',');
  document.getElementById('legendBar').style.background = `linear-gradient(90deg, ${stops})`;
}
 
function drawMarker(sensorX, sensorY, label, color) {
  const x = (sensorX / (SENSOR_W - 1)) * OUT_W;
  const y = (sensorY / (SENSOR_H - 1)) * OUT_H;
 
  ctx.save();
  ctx.strokeStyle = color;
  ctx.fillStyle = color;
  ctx.lineWidth = 2;
  ctx.shadowColor = 'rgba(0,0,0,0.85)';
  ctx.shadowBlur = 5;
 
  ctx.beginPath();
  ctx.moveTo(x - 12, y);
  ctx.lineTo(x + 12, y);
  ctx.moveTo(x, y - 12);
  ctx.lineTo(x, y + 12);
  ctx.stroke();
 
  ctx.beginPath();
  ctx.arc(x, y, 5, 0, Math.PI * 2);
  ctx.stroke();
 
  ctx.font = 'bold 13px system-ui, sans-serif';
  ctx.fillText(label, clamp(x + 9, 4, OUT_W - 90), clamp(y - 9, 14, OUT_H - 4));
  ctx.restore();
}
 
function drawThermal(data) {
  const rawPixels = data.pixels;
  let pixels = applyTemporalSmoothing(rawPixels);
  pixels = enhanceDetails(pixels);
 
  const range = getDisplayRange(pixels, data.min, data.max);
  const displayMin = range[0];
  const displayMax = range[1];
  const span = Math.max(0.25, displayMax - displayMin);
 
  for (let py = 0; py < OUT_H; py++) {
    const sySmooth = (py / (OUT_H - 1)) * (SENSOR_H - 1);
    const syPixel = Math.floor((py / OUT_H) * SENSOR_H);
 
    for (let px = 0; px < OUT_W; px++) {
      const sxSmooth = (px / (OUT_W - 1)) * (SENSOR_W - 1);
      const sxPixel = Math.floor((px / OUT_W) * SENSOR_W);
 
      const temp = useSmoothInterpolation
        ? sampleBilinear(pixels, sxSmooth, sySmooth)
        : getPixel(pixels, sxPixel, syPixel);
 
      const c = thermalColor((temp - displayMin) / span);
      const o = (py * OUT_W + px) * 4;
      imageData.data[o + 0] = c[0];
      imageData.data[o + 1] = c[1];
      imageData.data[o + 2] = c[2];
      imageData.data[o + 3] = 255;
    }
  }
 
  ctx.putImageData(imageData, 0, 0);
 
  if (document.getElementById('markers').checked) {
    drawMarker(data.maxX, data.maxY, 'HOT ' + fmtC(data.max), '#ffffff');
    drawMarker(data.minX, data.minY, 'COLD ' + fmtC(data.min), '#62d9ff');
  }
 
  document.getElementById('legendMin').textContent = displayMin.toFixed(1) + ' C';
  document.getElementById('legendMax').textContent = displayMax.toFixed(1) + ' C';
  document.getElementById('hudRange').textContent = 'Display range ' + displayMin.toFixed(1) + '–' + displayMax.toFixed(1) + ' C';
}
 
function redrawLast() {
  if (lastData) drawThermal(lastData);
}
 
function updateNumbers(data) {
  document.getElementById('center').textContent = fmtC(data.center);
  document.getElementById('min').textContent = fmtC(data.min);
  document.getElementById('max').textContent = fmtC(data.max);
  document.getElementById('avg').textContent = fmtC(data.avg);
  document.getElementById('ambient').textContent = fmtC(data.ambient);
  document.getElementById('fps').textContent = data.fps.toFixed(1) + ' fps';
  document.getElementById('readMs').textContent = data.readMs + ' ms read time';
  document.getElementById('minPos').textContent = 'x=' + data.minX + ', y=' + data.minY;
  document.getElementById('maxPos').textContent = 'x=' + data.maxX + ', y=' + data.maxY;
  document.getElementById('status').textContent = 'Frame ' + data.frame + ' • updated ' + new Date().toLocaleTimeString();
  document.getElementById('liveDot').classList.add('ok');
  document.getElementById('liveText').textContent = 'Live';
}
 
async function updateFrame() {
  if (isFetching) return;
  isFetching = true;
 
  try {
    const res = await fetch('/data?ts=' + Date.now(), { cache: 'no-store' });
    const data = await res.json();
 
    if (!data.ok) {
      document.getElementById('status').textContent = data.error || 'Sensor not ready';
      document.getElementById('liveDot').classList.remove('ok');
      document.getElementById('liveText').textContent = 'Sensor error';
      return;
    }
 
    lastData = data;
    drawThermal(data);
    updateNumbers(data);
  } catch (e) {
    document.getElementById('status').textContent = 'Web update error: ' + e;
    document.getElementById('liveDot').classList.remove('ok');
    document.getElementById('liveText').textContent = 'Disconnected';
  } finally {
    isFetching = false;
    setTimeout(updateFrame, FETCH_DELAY_MS);
  }
}
 
canvas.addEventListener('mousemove', (ev) => {
  if (!lastData) return;
  const rect = canvas.getBoundingClientRect();
  const x = clamp((ev.clientX - rect.left) / rect.width, 0, 0.9999);
  const y = clamp((ev.clientY - rect.top) / rect.height, 0, 0.9999);
  const sx = Math.floor(x * SENSOR_W);
  const sy = Math.floor(y * SENSOR_H);
  const temp = getPixel(lastData.pixels, sx, sy);
  document.getElementById('hudCursor').textContent = 'x=' + sx + ' y=' + sy + ' • ' + fmtC(temp);
});
 
canvas.addEventListener('mouseleave', () => {
  document.getElementById('hudCursor').textContent = 'Move over image';
});
 
function downloadCsv() {
  window.location.href = '/csv?ts=' + Date.now();
}
 
setInterpolation(true);
setPalette('iron');
updateFrame();
</script>
</body>
</html>
)rawliteral";
 
#endif




Testing DIY Thermal Camera with Live Web Display

Once the hardware assembly is complete, it’s time to upload the code.

To upload the code, connect your FTDI module directly to the PROG header on the PCB. In the Arduino IDE, go to Tools → Board and select ESP32 Dev Module. Then choose the correct COM port for your FTDI adapter.

Finally, click the Upload button. The code will be uploaded to the ESP32 board. After uploading, power the device and wait for the ESP32 Wi-Fi network to appear.

Connect your phone or computer to the ESP32 Wi-Fi network:

1
2
Wi-Fi: MLX90640-Camera
Password: 12345678

Then open a browser and go to:

1
192.168.4.1

The webpage will display a live thermal image from the MLX90640 thermal camera. The heatmap shows temperature differences in front of the sensor. Warmer objects, such as your hand or face, will appear in brighter/hotter colors, while cooler background areas will appear in darker/cooler colors.

To test the sensor, move your hand slowly in front of the camera and observe the thermal image changing in real time on the webpage. You can also stand in front of the camera to see your body heat displayed as a thermal image. The webpage also shows useful temperature values such as minimum temperature, maximum temperature, average temperature, and center temperature.

You can use the webpage controls to change the color palette, enable smooth view, view hot/cold markers, and adjust the thermal image display. For the best result, point the sensor toward a clear object with a temperature difference from the background.


Video Tutorial & Guide

DIY Portable Thermal Imaging Camera with ESP32 & MLX90640
Watch this video on YouTube.

Share. Facebook Twitter Pinterest LinkedIn Tumblr Email Reddit Telegram WhatsApp
Previous ArticleIoT Activity Tracker with ESP32 & Accelerometer/Gyroscope
Next Article IoT Based PM & Air Quality Monitoring System using ESP32

Related Posts

IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

Updated:May 2, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

Updated:April 27, 20261K
High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

Updated:April 27, 20262K
DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

Updated:February 1, 20261K
Flight Black-Box Motion Recorder using ESP32 & BMI160

Flight Black-Box Motion Recorder System using ESP32 & BMI160

Updated:December 7, 20252K
Add A Comment

CommentsCancel reply

Latest Posts
IoT Based PM & Air Quality Monitoring System using ESP32

IoT Based PM & Air Quality Monitoring System using ESP32

May 31, 2026
DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

DIY ESP32 MLX90640 IR Thermal Camera with Live Web Display

May 10, 2026
IoT Activity Tracker with ESP32 & Accelerometer Gyroscope

IoT Activity Tracker with ESP32 & Accelerometer/Gyroscope

May 2, 2026
A Guide to Sourcing Obsolete ICs for Vintage Projects

Beyond AliExpress: A Guide to Sourcing Obsolete ICs for Vintage Projects

April 21, 2026

ESP32 IoT Vehicle Motion Analyzer with MPU6050 & LIS3MDL

April 27, 2026
Building a Smart Sensor Node with a BLE Microcontroller

Building a Smart Sensor Node with a BLE Microcontroller

February 26, 2026
High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

High-Accuracy Pitch, Roll, Yaw with ESP32 & BNO08x IMU

April 27, 2026
DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

DIY Colorimeter using AS7265x Spectroscopy Sensor & ESP32

February 1, 2026
Top Posts & Pages
  • IoT Based PM & Air Quality Monitoring System using ESP32
    IoT Based PM & Air Quality Monitoring System using ESP32
  • 12V DC to 220V AC Inverter Circuit & PCB
    12V DC to 220V AC Inverter Circuit & PCB
  • IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
    IoT AC Energy Meter with PZEM-004T & ESP32 WebServer
  • Buck Converter: Basics, Working, Design & Application
    Buck Converter: Basics, Working, Design & Application
  • ESP32 CAN Bus Tutorial | Interfacing MCP2515 CAN Module with ESP32
    ESP32 CAN Bus Tutorial | Interfacing MCP2515 CAN Module with ESP32
  • ECG Graph Monitoring with AD8232 ECG Sensor & Arduino
    ECG Graph Monitoring with AD8232 ECG Sensor & Arduino
  • L293D Dual H-Bridge Motor Driver IC Pins, Circuit, Working
    L293D Dual H-Bridge Motor Driver IC Pins, Circuit, Working
  • LD2410 Sensor with ESP32 - Human Presence Detection
    LD2410 Sensor with ESP32 - Human Presence Detection
Categories
  • Arduino Projects (197)
  • Articles (60)
    • Learn Electronics (19)
    • Product Review (15)
    • Tech Articles (28)
  • Electronics Circuits (46)
    • 555 Timer Projects (21)
    • Op-Amp Circuits (7)
    • Power Electronics (13)
  • IoT Projects (204)
    • ESP32 MicroPython (7)
    • ESP32 Projects (81)
    • ESP32-CAM Projects (15)
    • ESP8266 Projects (76)
    • LoRa/LoRaWAN Projects (22)
  • Microcontrollers (38)
    • AMB82-Mini IoT AI Camera (4)
    • BLE Projects (18)
    • STM32 Projects (19)
  • Raspberry Pi (93)
    • Raspberry Pi Pico Projects (57)
    • Raspberry Pi Pico W Projects (12)
    • Raspberry Pi Projects (24)
Follow Us
  • Facebook
  • Twitter
  • Pinterest
  • Instagram
  • YouTube
About Us

“‘How to Electronics’ is a vibrant community for electronics enthusiasts and professionals. We deliver latest insights in areas such as Embedded Systems, Power Electronics, AI, IoT, and Robotics. Our goal is to stimulate innovation and provide practical solutions for students, organizations, and industries. Join us to transform learning into a joyful journey of discovery and innovation.

Copyright © How To Electronics. All rights reserved.
  • About Us
  • Disclaimer
  • Privacy Policy
  • Contact Us
  • Advertise With Us

Type above and press Enter to search. Press Esc to cancel.

Ad Blocker Enabled!
Ad Blocker Enabled!
Looks like you're using an ad blocker. Please allow ads on our site. We rely on advertising to help fund our site.