Visualizing AMG8833 Thermal Sensor Data with Arduino and Web-Based Viewer

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>

Leave a Reply