← Filters & retro

Thermal Camera

Show an image the way a heat camera sees it. Brightness becomes temperature, smoothed into soft blobs and painted in false colour, deep blue and purple for cold through red and orange to white-hot, with a gentle shimmer. A crosshair reads out the temperature under the pointer, beside a colour scale.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap an image in an element with data-thermal-camera and give the wrapper a size. The image fills it, cropped to cover.
  3. Use an image from your own site (or an inline SVG): the script has to read its pixels. Bright, warm-coloured areas read as hot.

Options

Add any of these attributes to the same element.

data-paletteiron (default), rainbow, or white for white-hot greyscale.
data-minTemperature at the cold end of the scale. Default 14.
data-maxTemperature at the hot end. Default 38.
data-unitC (default) or F.
data-hudfalse hides the crosshair, the scale and the label.
data-autoplayThe crosshair drifts around by itself while nobody is pointing. Handy on phones.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Thermal Camera: shows an image the way a heat camera would. Brightness
    // becomes temperature, smoothed into soft blobs and painted in false colour
    // (deep blue and purple for cold, through red and orange to white-hot), with
    // a gentle shimmer. A crosshair reads out the temperature under the pointer
    // and a colour scale sits down the side.
    // Usage: <figure data-thermal-camera><img src="photo.jpg" alt="…"></figure>
    // The image (an <img> on the same site, or an inline <svg>) fills the box,
    // cropped to cover, and stays in the page for screen readers.
    // Optional: data-palette ("iron", the default, "rainbow" or "white" for
    // white-hot greyscale), data-min / data-max (temperatures at the two ends
    // of the scale, default 14 and 38), data-unit ("C", the default, or "F"),
    // data-hud="false" (no crosshair or scale), data-autoplay (the crosshair
    // drifts around by itself while nobody is pointing).
    var DEFAULTS = { palette: 'iron', min: 14, max: 38, unit: 'C', maxSize: 260 };
    var PALETTES = {
      iron: [[0, '#05001a'], [0.18, '#2a0a7a'], [0.36, '#8e0d9c'], [0.55, '#e0302a'], [0.72, '#ff8a00'], [0.88, '#ffe23a'], [1, '#ffffff']],
      rainbow: [[0, '#10006b'], [0.2, '#0050ff'], [0.4, '#00e0ff'], [0.55, '#20ff60'], [0.7, '#ffff00'], [0.85, '#ff6000'], [1, '#ff0030']],
      white: [[0, '#000000'], [1, '#ffffff']]
    };

    var css =
      '.thc{position:relative;overflow:hidden;background:#05001a}' +
      '.thc-src{opacity:0}' +
      '.thc-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    // Turn the source (<img> or inline <svg>) into something drawImage can use.
    function loadSource(node, done) {
      if (node.tagName.toLowerCase() === 'svg') {
        var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
        var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
        var k = 800 / Math.max(vw, vh);
        var clone = node.cloneNode(true);
        clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        clone.setAttribute('width', Math.round(vw * k));
        clone.setAttribute('height', Math.round(vh * k));
        var im = new Image();
        im.onload = function () { done(im, im.width, im.height); };
        im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
      } else if (node.complete && node.naturalWidth) {
        done(node, node.naturalWidth, node.naturalHeight);
      } else {
        node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
      }
    }

    function cover(ctx, img, iw, ih, w, h) {
      var s = Math.max(w / iw, h / ih);
      ctx.drawImage(img, (w - iw * s) / 2, (h - ih * s) / 2, iw * s, ih * s);
    }

    function hex(c) {
      var n = parseInt(c.slice(1), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }

    // A 256-step colour table from a list of [position, colour] stops.
    function makeLut(stops) {
      var lut = new Uint8ClampedArray(256 * 3);
      for (var v = 0; v < 256; v++) {
        var q = v / 255, i = 0;
        while (i < stops.length - 2 && q > stops[i + 1][0]) i++;
        var a = stops[i], b = stops[i + 1], f = Math.max(0, Math.min(1, (q - a[0]) / (b[0] - a[0])));
        var ca = hex(a[1]), cb = hex(b[1]);
        for (var k = 0; k < 3; k++) lut[v * 3 + k] = ca[k] + (cb[k] - ca[k]) * f;
      }
      return lut;
    }

    // Box blur a float field a few times: heat spreads, so edges go soft.
    function blur(src, w, h, r, passes) {
      var a = src, b = new Float32Array(src.length), x, y, s, n;
      for (var p = 0; p < passes; p++) {
        for (y = 0; y < h; y++) {
          for (x = 0; x < w; x++) {
            s = 0; n = 0;
            for (var dx = -r; dx <= r; dx++) { var xx = x + dx; if (xx >= 0 && xx < w) { s += a[y * w + xx]; n++; } }
            b[y * w + x] = s / n;
          }
        }
        for (y = 0; y < h; y++) {
          for (x = 0; x < w; x++) {
            s = 0; n = 0;
            for (var dy = -r; dy <= r; dy++) { var yy = y + dy; if (yy >= 0 && yy < h) { s += b[yy * w + x]; n++; } }
            a[y * w + x] = s / n;
          }
        }
      }
      return a;
    }

    function setup(el, img, iw, ih) {
      var d = el.dataset;
      var stops = PALETTES[d.palette] || PALETTES[DEFAULTS.palette];
      var lut = makeLut(stops);
      var tMin = d.min !== undefined ? +d.min : DEFAULTS.min;
      var tMax = d.max !== undefined ? +d.max : DEFAULTS.max;
      var unit = (d.unit || DEFAULTS.unit).toUpperCase() === 'F' ? 'F' : 'C';
      var showHud = d.hud !== 'false';
      var autoplay = d.autoplay !== undefined && d.autoplay !== 'false';
      var canvas = document.createElement('canvas');
      canvas.className = 'thc-canvas';
      canvas.setAttribute('aria-hidden', 'true');
      el.appendChild(canvas);
      var ctx = canvas.getContext('2d');
      var work = document.createElement('canvas');
      var wctx = work.getContext('2d', { willReadFrequently: true });
      var pw = 0, ph = 0, heat = null, out = null, w = 0, h = 0, dpr = 1;

      function build() {
        dpr = Math.min(2, window.devicePixelRatio || 1);
        w = el.clientWidth;
        h = el.clientHeight;
        if (!w || !h) return false;
        canvas.width = Math.round(w * dpr);
        canvas.height = Math.round(h * dpr);
        var s = Math.min(1, DEFAULTS.maxSize / Math.max(w, h));
        pw = Math.max(1, Math.round(w * s));
        ph = Math.max(1, Math.round(h * s));
        work.width = pw;
        work.height = ph;
        cover(wctx, img, iw, ih, pw, ph);
        var p;
        try { p = wctx.getImageData(0, 0, pw, ph).data; }
        catch (err) { canvas.remove(); el.querySelector('.thc-src').classList.remove('thc-src'); return false; }
        var n = pw * ph, lum = new Float32Array(n), lo = 1, hi = 0, i;
        for (i = 0; i < n; i++) {
          // Warm colours read a little hotter than cool ones of the same brightness.
          var r = p[i * 4], g = p[i * 4 + 1], b = p[i * 4 + 2];
          lum[i] = (0.42 * r + 0.45 * g + 0.13 * b) / 255 + (r - b) / 255 * 0.12;
        }
        heat = blur(lum, pw, ph, Math.max(1, Math.round(pw / 120)), 2);
        for (i = 0; i < n; i++) { if (heat[i] < lo) lo = heat[i]; if (heat[i] > hi) hi = heat[i]; }
        var span = Math.max(0.05, hi - lo);
        for (i = 0; i < n; i++) heat[i] = 0.04 + 0.94 * (heat[i] - lo) / span;
        out = wctx.createImageData(pw, ph);
        for (i = 0; i < n; i++) out.data[i * 4 + 3] = 255;
        return true;
      }

      function heatAt(x, y) {
        var px = Math.max(0, Math.min(pw - 1, (x / w * pw) | 0)), py = Math.max(0, Math.min(ph - 1, (y / h * ph) | 0));
        return heat[py * pw + px];
      }

      function temp(v) {
        var c = tMin + (tMax - tMin) * v;
        return (unit === 'F' ? c * 9 / 5 + 32 : c).toFixed(1) + '°' + unit;
      }

      var t = 0, cross = { x: 0, y: 0, k: 0 };
      function draw() {
        // The shimmer: two slow waves of extra warmth drifting across the field.
        var o = out.data, n = pw * ph, a = t / 1400, b = t / 2300;
        for (var y = 0; y < ph; y++) {
          var wy = Math.sin(y * 0.09 + a) * 0.5;
          for (var x = 0; x < pw; x++) {
            var i = y * pw + x;
            var v = heat[i] + 0.035 * (Math.sin(x * 0.07 + wy + b) + Math.sin((x + y) * 0.05 - a * 1.3)) * (0.4 + heat[i]);
            var j = Math.max(0, Math.min(255, (v * 255) | 0)) * 3;
            o[i * 4] = lut[j]; o[i * 4 + 1] = lut[j + 1]; o[i * 4 + 2] = lut[j + 2];
          }
        }
        wctx.putImageData(out, 0, 0);
        ctx.setTransform(1, 0, 0, 1, 0, 0);
        ctx.imageSmoothingEnabled = true;
        ctx.imageSmoothingQuality = 'high';
        ctx.drawImage(work, 0, 0, canvas.width, canvas.height);
        if (!showHud) return;
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        var fs = Math.max(10, Math.min(13, w / 36)), pad = Math.max(10, Math.min(18, w * 0.035));
        ctx.font = '700 ' + fs + 'px ui-monospace, Menlo, Consolas, monospace';
        ctx.textBaseline = 'middle';
        // The colour scale down the right-hand side, hottest at the top.
        var bh = Math.min(h - pad * 2 - fs * 3, 150), bw = Math.max(6, fs * 0.6);
        var bx = w - pad - bw, by = (h - bh) / 2;
        var grad = ctx.createLinearGradient(0, by + bh, 0, by);
        stops.forEach(function (s) { grad.addColorStop(s[0], s[1]); });
        ctx.fillStyle = grad;
        ctx.fillRect(bx, by, bw, bh);
        ctx.strokeStyle = 'rgba(255,255,255,.7)';
        ctx.lineWidth = 1;
        ctx.strokeRect(bx + 0.5, by + 0.5, bw - 1, bh - 1);
        ctx.fillStyle = '#fff';
        ctx.shadowColor = 'rgba(0,0,0,.7)';
        ctx.shadowBlur = 4;
        ctx.textAlign = 'right';
        ctx.fillText(temp(1).replace('.0', ''), w - pad, by - fs * 0.9);
        ctx.fillText(temp(0).replace('.0', ''), w - pad, by + bh + fs * 0.9);
        // The crosshair and its reading.
        if (cross.k > 0.02) {
          ctx.globalAlpha = Math.min(1, cross.k);
          var cx = cross.x, cy = cross.y, r = Math.max(9, Math.min(14, w / 40));
          ctx.strokeStyle = '#fff';
          ctx.lineWidth = 1.5;
          ctx.beginPath();
          ctx.moveTo(cx - r * 1.8, cy); ctx.lineTo(cx - r * 0.6, cy);
          ctx.moveTo(cx + r * 0.6, cy); ctx.lineTo(cx + r * 1.8, cy);
          ctx.moveTo(cx, cy - r * 1.8); ctx.lineTo(cx, cy - r * 0.6);
          ctx.moveTo(cx, cy + r * 0.6); ctx.lineTo(cx, cy + r * 1.8);
          ctx.stroke();
          ctx.beginPath();
          ctx.arc(cx, cy, r, 0, Math.PI * 2);
          ctx.stroke();
          var label = temp(heatAt(cx, cy));
          ctx.textAlign = cx > w - 110 ? 'right' : 'left';
          var lx = cx + (ctx.textAlign === 'right' ? -r * 1.4 : r * 1.4);
          ctx.fillText(label, lx, Math.max(fs, cy - r * 1.6));
          ctx.globalAlpha = 1;
        }
        ctx.shadowBlur = 0;
        ctx.textAlign = 'left';
        ctx.fillText('● ' + (unit === 'F' ? 'IR °F' : 'IR °C'), pad, pad + fs * 0.5);
      }

      if (!build()) return;
      cross.x = w * 0.5; cross.y = h * 0.5;
      if (window.ResizeObserver) {
        var lw = el.clientWidth, lh = el.clientHeight;
        new ResizeObserver(function () {
          if (el.clientWidth === lw && el.clientHeight === lh) return;
          lw = el.clientWidth; lh = el.clientHeight;
          if (build()) draw();
        }).observe(el);
      }
      if (reduced()) { cross.k = autoplay ? 1 : 0; draw(); return; }
      draw();

      var real = null, lastReal = -1e9;
      function move(e) {
        var r = el.getBoundingClientRect();
        real = { x: e.clientX - r.left, y: e.clientY - r.top };
        lastReal = t;
      }
      el.addEventListener('pointermove', move);
      el.addEventListener('pointerdown', move);
      el.addEventListener('pointerleave', function () { real = null; lastReal = t; });
      el.addEventListener('pointerup', function (e) { if (e.pointerType !== 'mouse') { real = null; lastReal = t; } });

      // About 20 frames a second: heat moves slowly.
      var last = 0, acc = 0;
      var timer = anime.createTimer({
        loop: true, duration: 1000, autoplay: false,
        onUpdate: function () {
          var now = performance.now();
          var dt = last ? Math.min(60, now - last) : 16;
          last = now;
          t += dt;
          var target = null;
          if (real) target = real;
          else if (autoplay && t - lastReal > 2000) {
            // Wander between the hot and cold parts of the picture.
            target = { x: w * (0.45 + 0.33 * Math.sin(t / 2100)), y: h * (0.5 + 0.28 * Math.sin(t / 1500 + 0.8)) };
          }
          var e = 1 - Math.pow(0.85, dt / 16);
          if (target) {
            if (cross.k < 0.02) { cross.x = target.x; cross.y = target.y; }
            cross.x += (target.x - cross.x) * e;
            cross.y += (target.y - cross.y) * e;
          }
          cross.k += ((target ? 1 : 0) - cross.k) * e * 0.6;
          acc += dt;
          if (acc < 48) return;
          acc = 0;
          draw();
        }
      });

      new IntersectionObserver(function (entries) {
        if (entries[0].isIntersecting) { last = 0; timer.play(); }
        else timer.pause();
      }, { threshold: 0.1 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-thermal-camera]').forEach(function (el) {
        if (el._thc) return;
        var src = el.querySelector('img, svg');
        if (!src) return;
        el._thc = true;
        el.classList.add('thc');
        src.classList.add('thc-src');
        loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<figure data-thermal-camera data-palette="iron" style="height:400px">
  <img src="photo.jpg" alt="A cat asleep by a mug">
</figure>