In this post, we’ll show how to read the 8×8 temperature array from an Adafruit AMG8833 (Grid-EYE) thermal sensor with an Arduino, stream the data over Serial, and build a simple web page that renders the live temperature map in your browser. You’ll learn:
• How the Arduino sketch initializes and reads the AMG8833 sensor.
• How the HTML/JavaScript page connects via Web Serial, parses incoming frames, and draws the heatmap on a <canvas>.
• A quick tweak to preserve row order so that the top row from the sensor appears at the top of the web viewer.

Section 1: Arduino Sketch Overview
1.1. Libraries & Sensor Setup
#include <Wire.h>
#include <Adafruit_AMG88xx.h>
Adafruit_AMG88xx amg;
We include I²C (Wire.h) and the Adafruit AMG8833 library, then create an amg object.
1.2. Constants & Timing
const uint16_t FPS = 20;
const uint32_t PERIOD = 1000UL / FPS;
uint32_t lastFrame = 0;
We’ll output frames at 20 FPS. The PERIOD in milliseconds controls the loop timing.
1.3. setup()
void setup() {
Serial.begin(115200);
while (!Serial) {}
if (!amg.begin()) {
while (1) {
Serial.println("ERR");
delay(500);
}
}
}
– Start Serial at 115 200 baud.
– Initialize the AMG8833 sensor; if it fails, print “ERR” forever.
1.4. loop() & Reading Pixels
void loop() {
if (millis() - lastFrame < PERIOD) return;
lastFrame = millis();
float pixels[64];
amg.readPixels(pixels);
// Print 8 rows of 8 temperatures:
for (uint8_t row = 0; row < 8; row++) {
for (uint8_t col = 0; col < 8; col++) {
float t = pixels[row * 8 + col];
t = constrain(t, -20.0, 100.0);
Serial.print(t, 1);
if (col < 7) Serial.print('\t');
}
Serial.println();
}
}
– Every PERIOD ms, we call readPixels() into a float[64].
– We loop rows 0→7 and columns 0→7, print each temperature with one decimal place, tab-separated, then newline per row.
– The first printed line is row 0 (sensor’s top row), last is row 7 (bottom).
Section 2: Web Viewer (HTML + JavaScript)
2.1. Layout & Canvas
<canvas id="view" width="64" height="64"
style="image-rendering: pixelated"></canvas>
We reserve a 64×64 canvas and later scale it via CSS, so each sensor pixel becomes a block.
2.2. Connecting via Web Serial
document.getElementById('connect').onclick = async () => {
const port = await navigator.serial.requestPort();
await port.open({ baudRate: 115200 });
readLoop(port);
};
Clicking “Connect” opens the serial port and calls readLoop() to start reading lines.
2.3. Parsing Lines into Frames
async function readLoop(port) {
const reader = port.readable
.pipeThrough(new TextDecoderStream())
.pipeThrough(new TransformStream(new LineBreakTransformer()))
.getReader();
let buf = [];
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value.trim()) continue;
buf.push(value.trim());
if (buf.length === 8) {
parseFrame(buf);
buf = [];
drawThermal();
drawOverlay();
}
}
}
– We decode incoming serial bytes to text, split on \n, and accumulate 8 lines → a full 8×8 frame.
– After 8 lines arrive, we call parseFrame(), then render.
2.4. Preserving Row Order (No Vertical Flip)
By default, some examples flip row 0 to the bottom. To keep row 0 at the top, use:
function parseFrame(lines) {
for (let r = 0; r < 8; r++) {
const drawRow = r; // no flip
const parts = lines[r].split(/\s+/);
for (let c = 0; c < 8; c++) {
temps[drawRow*8 + c] = parseFloat(parts[c]) || 0;
}
}
}
2.5. Drawing the Heatmap
function drawThermal() {
// find frame min/max
// for each canvas pixel yO,xO:
// bilinear-interpolate temps[], map to color
// write into ImageData
vctx.putImageData(img, 0, 0);
}
We interpolate each of the 64 sensor cells up to the canvas resolution (e.g. 64×64, 8×8 blocks), convert temperature to an hsl-based RGB color, then display.
Arduino sketch
#include <Wire.h>
#include <Adafruit_AMG88xx.h>
Adafruit_AMG88xx amg;
const uint32_t BAUD = 115200;
const uint16_t FPS = 20; // desired frames per second
const uint32_t PERIOD = 1000UL / FPS; // frame interval in ms
uint32_t lastFrame = 0;
void setup() {
Serial.begin(BAUD);
while (!Serial) { /* wait for Serial */ }
if (!amg.begin()) {
// sensor not found → blink or halt
while (1) {
Serial.println("ERR");
delay(500);
}
}
}
void loop() {
uint32_t now = millis();
if (now - lastFrame < PERIOD) {
// not yet time for next frame
return;
}
lastFrame = now;
float pixels[64];
amg.readPixels(pixels);
// Print 8 lines of 8 ASCII numbers (one decimal place), tab separated
for (uint8_t row = 0; row < 8; row++) {
for (uint8_t col = 0; col < 8; col++) {
float t = pixels[row * 8 + col];
// clip outliers if you like:
if (t < -20.0) t = -20.0;
if (t > 100.0) t = 100.0;
Serial.print(t, 1); // one decimal place
if (col < 7) Serial.print('\t');
}
Serial.println();
}
// no delay() here → loop() is free until next PERIOD
}
HTML page
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>AMG8833 Thermal Viewer</title> <style> /* — Reset & Base — */ * { margin:0; padding:0; box-sizing:border-box; } html, body { width:100%; height:100%; font-family:"Segoe UI", Roboto, sans-serif; background:#f4f4f6; color:#333; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; } /* — Container — */ #container { display:flex; height:100vh; overflow:hidden; } /* — Sidebar — */ #sidebar { width:180px; padding:16px; background:#fff; box-shadow:2px 0 8px rgba(0,0,0,0.05); display:flex; flex-direction:column; gap:12px; font-size:0.9rem; color:#444; } .cb-row { display:flex; justify-content:space-between; align-items:center; } .cb-label { font-weight:600; color:#555; } #colorbar { position:relative; height:180px; width:24px; margin:0 auto; border-radius:4px; background: linear-gradient( to bottom, #d32f2f 0%, /* red = cold */ #f9a825 25%, #388e3c 50%, #0288d1 75%, #303f9f 100% /* blue = hot */ ); border:1px solid #ddd; } #marker-ring { position:absolute; left:-4px; top:0; width:32px; height:32px; border:2px solid #fff; box-shadow:0 0 4px rgba(0,0,0,0.2); border-radius:50%; pointer-events:none; } /* — Main — */ #main { flex:1; padding:16px; display:flex; flex-direction:column; } #controls { display:flex; align-items:center; gap:12px; margin-bottom:12px; } #controls button, #controls select, #controls input { font:inherit; padding:6px 8px; border:1px solid #ccc; border-radius:4px; background:#fafafa; transition:all .2s; } #controls button:hover, #controls select:hover, #controls input:hover { border-color:#888; } #controls button:active, #controls select:active, #controls input:active { background:#f0f0f0; } #info { height:1.2em; margin-top:8px; color:#555; font-weight:500; } #view-wrapper { position: relative; width: 100%; /* fill available width */ max-width: 80vmin; /* or whatever cap you like */ aspect-ratio: 1 / 1; border:1px solid #ddd; overflow:hidden; background:#000; } #view { display:block; width:100%; height:100%; image-rendering:pixelated; } /* — Markers & Labels — */ .marker { position:absolute; width:20px; height:20px; color:#fff; transform:translate(-50%,-50%); } .marker::before, .marker::after { content:""; position:absolute; background:currentColor; } .marker::before { left:0; top:50%; width:100%; height:2px; transform:translateY(-50%); } .marker::after { left:50%; top:0; width:2px; height:100%; transform:translateX(-50%); } .label { position:absolute; white-space:nowrap; font-size:0.85rem; font-weight:600; color:#fff; text-shadow:0 0 4px rgba(0,0,0,0.5); pointer-events:none; } </style> </head> <body> <div id="container"> <div id="sidebar"> <div class="cb-row"> <span class="cb-label">Max.</span> <span id="cb-max">-- °C</span> </div> <div id="colorbar"><div id="marker-ring"></div></div> <div class="cb-row"> <span class="cb-label">Min.</span> <span id="cb-min">-- °C</span> </div> <div class="cb-row"> <span class="cb-label">Avg.</span> <span id="cb-cur">—</span> </div> <label class="cb-row"> <input type="checkbox" id="toggle-extrema"> Show Extrema </label> </div> <div id="main"> <div id="controls"> <button id="connect">Connect</button> Res: <select id="res-select"> <option value="1">1×</option> <option value="2">2×</option> <option value="4">4×</option> <option value="8" selected>8×</option> <option value="16">16×</option> </select> FPS: <input type="number" id="fps-input" value="10" min="1" max="60" style="width:50px"> </div> <div id="view-wrapper"> <canvas id="view" width="64" height="64"></canvas> </div> <div id="info"></div> </div> </div> <script> const ROWS=8, COLS=8; let SCALE=8, OUT_W=COLS*SCALE, OUT_H=ROWS*SCALE; let mainFPS=10, mainPeriod=1000/mainFPS, lastMain=0; const OVERLAY_FPS=5, OVERLAY_PERIOD=10000/OVERLAY_FPS; let lastOverlay=0, showExtrema=false; const temps=new Float32Array(ROWS*COLS); let hiX=0, hiY=0, loX=0, loY=0, hiT=-Infinity, loT=Infinity; const view=document.getElementById('view'), vctx=view.getContext('2d'), wrapper=document.getElementById('view-wrapper'), cbMax=document.getElementById('cb-max'), cbMin=document.getElementById('cb-min'), cbCur=document.getElementById('cb-cur'), marker=document.getElementById('marker-ring'), colorbar=document.getElementById('colorbar'), infoDiv=document.getElementById('info'); vctx.imageSmoothingEnabled=false; document.getElementById('toggle-extrema') .addEventListener('change', e=> showExtrema=e.target.checked); document.getElementById('res-select') .addEventListener('change', e=>{ SCALE=+e.target.value; OUT_W=COLS*SCALE; OUT_H=ROWS*SCALE; view.width=OUT_W; view.height=OUT_H; }); document.getElementById('fps-input') .addEventListener('input', e=>{ mainFPS=Math.min(60,Math.max(1,+e.target.value)); mainPeriod=1000/mainFPS; }); document.getElementById('connect').onclick=async()=>{ try { const port = await navigator.serial.requestPort(); await port.open({ baudRate:115200 }); readLoop(port); } catch(err){ alert('Serial error: '+err); } }; async function readLoop(port){ const reader=port.readable .pipeThrough(new TextDecoderStream()) .pipeThrough(new TransformStream(new LineBreakTransformer())) .getReader(); let buf=[]; while(true){ const {value,done} = await reader.read(); if(done) break; if(!value.trim()) continue; buf.push(value.trim()); if(buf.length===ROWS){ parseFrame(buf); buf=[]; const now=performance.now(); if(now-lastMain>=mainPeriod){ drawThermal(); lastMain=now; } if(now-lastOverlay>=OVERLAY_PERIOD){ drawOverlay(); lastOverlay=now; } } } } function parseFrame(lines){ for(let r=0; r<8; r++){ const parts = lines[r].split(/\s+/); // Flip the row index vertically: const drawRow = r; for(let c=0; c<8; c++){ temps[drawRow*8 + c] = parseFloat(parts[c]) || 0; } } } function drawThermal(){ let rlo=temps[0], rhi=temps[0]; for(let i=1;i<temps.length;i++){ rlo=Math.min(rlo,temps[i]); rhi=Math.max(rhi,temps[i]); } const dynMin=rlo-1, dynMax=rhi+1; cbMin.textContent=dynMin.toFixed(1)+' °C'; cbMax.textContent=dynMax.toFixed(1)+' °C'; loT= Infinity; hiT=-Infinity; const img=vctx.createImageData(OUT_W,OUT_H); for(let yO=0; yO<OUT_H; yO++){ const y=yO/SCALE, y0=Math.floor(y), y1=Math.min(ROWS-1,y0+1), wy=y-y0; for(let xO=0; xO<OUT_W; xO++){ const x=xO/SCALE, x0=Math.floor(x), x1=Math.min(COLS-1,x0+1), wx=x-x0; const t00=temps[y0*COLS+x0], t10=temps[y0*COLS+x1], t01=temps[y1*COLS+x0], t11=temps[y1*COLS+x1]; const t0=t00*(1-wx)+t10*wx, t1=t01*(1-wx)+t11*wx, t =t0*(1-wy)+t1*wy; if(t<loT){ loT=t; loX=xO; loY=yO; } if(t>hiT){ hiT=t; hiX=xO; hiY=yO; } const [r,g,b]=thermalRGB(t,dynMin,dynMax); const idx=(yO*OUT_W+xO)*4; img.data[idx]=r; img.data[idx+1]=g; img.data[idx+2]=b; img.data[idx+3]=255; } } vctx.putImageData(img,0,0); } function drawOverlay(){ wrapper.querySelectorAll('.marker,.label').forEach(el=>el.remove()); infoDiv.textContent=''; if(!showExtrema) return; infoDiv.textContent=`Hot: ${hiT.toFixed(1)} °C Cold: ${loT.toFixed(1)} °C`; const cW=view.clientWidth, cH=view.clientHeight; const wW=wrapper.clientWidth, wH=wrapper.clientHeight; [[hiX,hiY,hiT],[loX,loY,loT]].forEach(([px,py,temp])=>{ const cx_buf=px+0.5, cy_buf=py+0.5; const cssX=cx_buf*(cW/OUT_W), cssY=cy_buf*(cH/OUT_H); let leftPct=cssX/wW*100, topPct=cssY/wH*100; leftPct=Math.max(2,Math.min(98,leftPct)); topPct=Math.max(2,Math.min(98,topPct)); const anchorX=leftPct>90?'100%':'0%', anchorY=topPct>90?'100%':'0%'; const m=document.createElement('div'); m.className='marker'; m.style.left=leftPct+'%'; m.style.top=topPct+'%'; wrapper.append(m); const l=document.createElement('div'); l.className='label'; l.style.left=leftPct+'%'; l.style.top=topPct+'%'; l.style.transformOrigin=`${anchorX} ${anchorY}`; l.style.transform= `translate(${anchorX==='0%'?'0':'-100%'},${anchorY==='0%'?'0':'-100%'})`; l.textContent=temp.toFixed(1)+'°C'; wrapper.append(l); }); const avg=(hiT+loT)/2, dm=loT-1, dM=hiT+1, norm=(avg-dm)/(dM-dm), barH=colorbar.clientHeight-28; marker.style.top=`${(1-norm)*barH}px`; cbCur.textContent=avg.toFixed(1)+' °C'; } function thermalRGB(t,mn,mx){ let v=(t-mn)/(mx-mn); v=Math.max(0,Math.min(1,v)); const h=(1-v)*240/360; return hslToRgb(h,1,0.5); } function hslToRgb(h,s,l){ let r,g,b; if(s===0) r=g=b=l; else{ const hue2rgb=(p,q,t)=>{ if(t<0) t+=1; if(t>1) t-=1; if(t<1/6) return p+(q-p)*6*t; if(t<1/2) return q; if(t<2/3) return p+(q-p)*(2/3-t)*6; return p; }; const q=l<.5?l*(1+s):l+s-l*s, p=2*l-q; r=hue2rgb(p,q,h+1/3); g=hue2rgb(p,q,h); b=hue2rgb(p,q,h-1/3); } return [Math.round(r*255),Math.round(g*255),Math.round(b*255)]; } class LineBreakTransformer{ constructor(){this.rem=''} transform(chunk,ctrl){ this.rem+=chunk; const parts=this.rem.split('\n'); this.rem=parts.pop(); parts.forEach(l=>ctrl.enqueue(l)); } flush(ctrl){ if(this.rem) ctrl.enqueue(this.rem); } } </script> </body> </html>