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
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-thermal-cameraand give the wrapper a size. The image fills it, cropped to cover. - 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-palette | iron (default), rainbow, or white for white-hot greyscale. |
data-min | Temperature at the cold end of the scale. Default 14. |
data-max | Temperature at the hot end. Default 38. |
data-unit | C (default) or F. |
data-hud | false hides the crosshair, the scale and the label. |
data-autoplay | The crosshair drifts around by itself while nobody is pointing. Handy on phones. |
<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><figure data-thermal-camera data-palette="iron" style="height:400px">
<img src="photo.jpg" alt="A cat asleep by a mug">
</figure>