<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Software Thermal Camera</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: #121212;
color: #ffffff;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 20px;
}
h1 {
font-size: 1.8rem;
margin-bottom: 12px;
color: #00ffcc;
text-transform: uppercase;
letter-spacing: 1px;
text-align: center;
}
.controls {
margin-bottom: 15px;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
justify-content: center;
}
button, select {
background: #222;
color: #fff;
border: 1px solid #00ffcc;
padding: 8px 16px;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s ease;
}
button:hover, select:hover {
background: #00ffcc;
color: #000;
}
.container {
display: flex;
background: #1e1e1e;
padding: 15px;
border-radius: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.6);
border: 1px solid #333;
max-width: 100%;
overflow-x: auto;
}
canvas {
border-radius: 4px;
cursor: crosshair;
max-width: 100%;
height: auto;
}
.info-panel {
margin-top: 15px;
font-size: 0.9rem;
color: #aaa;
text-align: center;
}
/* Hidden video element used as source */
#videoSource {
display: none;
}
</style>
</head>
<body>
<h1>Software Thermal Camera</h1>
<div class="controls">
<label for="paletteSelect">Palette:</label>
<select id="paletteSelect">
<option value="jet">JET (Classic Thermal)</option>
<option value="inferno">INFERNO</option>
<option value="ironbow">IRONBOW</option>
<option value="grayscale">GRAYSCALE</option>
</select>
<button id="switchCamBtn">Flip Camera</button>
<button id="resetLockBtn">Reset Lock Probe</button>
</div>
<div class="container">
<canvas id="thermalCanvas" width="780" height="480"></canvas>
</div>
<div class="info-panel">
<p><strong>Instructions:</strong> Hover over the video feed to sample temperature. <strong>Click</strong> to lock a probe point.</p>
</div>
<video id="videoSource" autoplay playsinline></video>
<script>
const video = document.getElementById('videoSource');
const canvas = document.getElementById('thermalCanvas');
const ctx = canvas.getContext('2d');
const paletteSelect = document.getElementById('paletteSelect');
const resetLockBtn = document.getElementById('resetLockBtn');
const switchCamBtn = document.getElementById('switchCamBtn');
// Render Dimensions
const VIEW_WIDTH = 640;
const VIEW_HEIGHT = 480;
const BAR_WIDTH = 30;
const PANEL_WIDTH = 140;
// Temperature Scale Range Config (°C)
const TEMP_MIN = 20.0;
const TEMP_MAX = 40.0;
// States
let mousePos = { x: -1, y: -1 };
let lockedPos = null;
let currentFacingMode = "environment"; // Defaults to rear/back camera
let activeStream = null;
// --- Color Palettes (256 Look-Up Table Entries) ---
function generateLUT(paletteName) {
const lut = new Uint8ClampedArray(256 * 3);
for (let i = 0; i < 256; i++) {
let v = i / 255;
let r = 0, g = 0, b = 0;
if (paletteName === 'jet') {
r = Math.min(255, Math.max(0, Math.floor(255 * (1.5 - Math.abs(v * 4 - 3)))));
g = Math.min(255, Math.max(0, Math.floor(255 * (1.5 - Math.abs(v * 4 - 2)))));
b = Math.min(255, Math.max(0, Math.floor(255 * (1.5 - Math.abs(v * 4 - 1)))));
} else if (paletteName === 'inferno') {
r = Math.floor(255 * Math.pow(v, 0.7));
g = Math.floor(255 * Math.pow(v, 2.0));
b = Math.floor(255 * Math.min(1, Math.max(0, Math.sin(v * Math.PI * 1.5))));
} else if (paletteName === 'ironbow') {
r = Math.floor(255 * Math.min(1, v * 1.5));
g = Math.floor(255 * Math.pow(v, 2));
b = Math.floor(255 * Math.max(0, 1 - v * 2));
} else {
r = g = b = i; // Grayscale
}
lut[i * 3] = r;
lut[i * 3 + 1] = g;
lut[i * 3 + 2] = b;
}
return lut;
}
let currentLUT = generateLUT('jet');
// Event Listeners
paletteSelect.addEventListener('change', (e) => {
currentLUT = generateLUT(e.target.value);
});
resetLockBtn.addEventListener('click', () => {
lockedPos = null;
});
switchCamBtn.addEventListener('click', () => {
currentFacingMode = (currentFacingMode === "environment") ? "user" : "environment";
initCamera();
});
// Mouse Tracking
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
mousePos.x = Math.floor((e.clientX - rect.left) * (canvas.width / rect.width));
mousePos.y = Math.floor((e.clientY - rect.top) * (canvas.height / rect.height));
});
canvas.addEventListener('mouseleave', () => {
mousePos = { x: -1, y: -1 };
});
canvas.addEventListener('click', () => {
if (mousePos.x >= 0 && mousePos.x < VIEW_WIDTH && mousePos.y >= 0 && mousePos.y < VIEW_HEIGHT) {
lockedPos = { x: mousePos.x, y: mousePos.y };
}
});
// Webcam Initialization
async function initCamera() {
if (activeStream) {
activeStream.getTracks().forEach(track => track.stop());
}
try {
activeStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: currentFacingMode },
width: { ideal: VIEW_WIDTH },
height: { ideal: VIEW_HEIGHT }
},
audio: false
});
video.srcObject = activeStream;
video.play();
} catch (err) {
console.warn("Camera Mode Fallback:", err);
try {
activeStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
video.srcObject = activeStream;
video.play();
} catch (fallbackErr) {
alert("Camera Access Failed: " + fallbackErr.message);
}
}
}
// Offscreen Canvas to extract video frame data
const offscreenCanvas = document.createElement('canvas');
offscreenCanvas.width = VIEW_WIDTH;
offscreenCanvas.height = VIEW_HEIGHT;
const offCtx = offscreenCanvas.getContext('2d');
function processFrame() {
if (video.readyState === video.HAVE_ENOUGH_DATA) {
// 1. Capture raw frame
offCtx.drawImage(video, 0, 0, VIEW_WIDTH, VIEW_HEIGHT);
const frameData = offCtx.getImageData(0, 0, VIEW_WIDTH, VIEW_HEIGHT);
const pixels = frameData.data;
// Create heatmap pixel buffer
const thermalImgData = ctx.createImageData(VIEW_WIDTH, VIEW_HEIGHT);
const thermalPixels = thermalImgData.data;
// Intensity buffer for mouse probe sampling
const luminanceMap = new Uint8Array(VIEW_WIDTH * VIEW_HEIGHT);
// 2. Grayscale conversion + Contrast Boost + Heatmap Mapping
for (let i = 0; i < pixels.length; i += 4) {
let gray = Math.floor(0.299 * pixels[i] + 0.587 * pixels[i + 1] + 0.114 * pixels[i + 2]);
// Contrast enhancement
gray = Math.min(255, Math.max(0, Math.floor((gray - 30) * (255 / 195))));
luminanceMap[i / 4] = gray;
// Apply Color LUT
thermalPixels[i] = currentLUT[gray * 3]; // Red
thermalPixels[i + 1] = currentLUT[gray * 3 + 1]; // Green
thermalPixels[i + 2] = currentLUT[gray * 3 + 2]; // Blue
thermalPixels[i + 3] = 255; // Alpha
}
// Render Heatmap View
ctx.putImageData(thermalImgData, 0, 0);
// 3. Render Side Colorbar Panel
drawColorbarPanel();
// 4. Render Active Probe / Reticle
const activeProbe = lockedPos || (mousePos.x >= 0 && mousePos.x < VIEW_WIDTH ? mousePos : null);
if (activeProbe) {
const px = activeProbe.x;
const py = activeProbe.y;
const idx = py * VIEW_WIDTH + px;
const intensity = luminanceMap[idx];
// Linear Temperature Mapping
const tempC = TEMP_MIN + (intensity / 255.0) * (TEMP_MAX - TEMP_MIN);
// Crosshair Marker
ctx.strokeStyle = lockedPos ? '#00ff00' : '#ffffff';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(px - 10, py); ctx.lineTo(px + 10, py);
ctx.moveTo(px, py - 10); ctx.lineTo(px, py + 10);
ctx.stroke();
// Temperature Text Box
const label = `${tempC.toFixed(1)} °C`;
ctx.font = 'bold 14px sans-serif';
const textWidth = ctx.measureText(label).width;
const boxX = Math.min(px + 12, VIEW_WIDTH - textWidth - 15);
const boxY = Math.max(py - 25, 20);
ctx.fillStyle = 'rgba(0, 0, 0, 0.8)';
ctx.fillRect(boxX, boxY - 14, textWidth + 10, 20);
ctx.fillStyle = lockedPos ? '#00ff00' : '#ffff00';
ctx.fillText(label, boxX + 5, boxY);
}
}
requestAnimationFrame(processFrame);
}
function drawColorbarPanel() {
const startX = VIEW_WIDTH + 15;
// Side Panel Background
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(VIEW_WIDTH, 0, PANEL_WIDTH, VIEW_HEIGHT);
// Vertical Gradient Scale Bar
const barGradient = ctx.createLinearGradient(0, 0, 0, VIEW_HEIGHT);
for (let i = 0; i <= 10; i++) {
const ratio = i / 10;
const lutIdx = Math.floor((1 - ratio) * 255);
const r = currentLUT[lutIdx * 3];
const g = currentLUT[lutIdx * 3 + 1];
const b = currentLUT[lutIdx * 3 + 2];
barGradient.addColorStop(ratio, `rgb(${r},${g},${b})`);
}
ctx.fillStyle = barGradient;
ctx.fillRect(startX, 0, BAR_WIDTH, VIEW_HEIGHT);
// Outer Outline for Color Bar
ctx.strokeStyle = '#ffffff';
ctx.lineWidth = 1;
ctx.strokeRect(startX, 0, BAR_WIDTH, VIEW_HEIGHT);
// Temperature Scale Ticks & Text
const ticks = 6;
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 13px sans-serif';
ctx.textAlign = 'left';
for (let i = 0; i < ticks; i++) {
const y = (VIEW_HEIGHT / (ticks - 1)) * i;
const tempVal = TEMP_MAX - ((TEMP_MAX - TEMP_MIN) / (ticks - 1)) * i;
// Draw tick mark INSIDE the color bar so it can never be mistaken for a minus sign
ctx.strokeStyle = '#ffffff';
ctx.beginPath();
ctx.moveTo(startX + BAR_WIDTH - 6, y);
ctx.lineTo(startX + BAR_WIDTH, y);
ctx.stroke();
// Display clean text with proper spacing to the right
const clampY = Math.max(14, Math.min(VIEW_HEIGHT - 6, y + 4));
ctx.fillText(`${tempVal.toFixed(0)} °C`, startX + BAR_WIDTH + 12, clampY);
}
}
// Initialize and Start Frame Loop
initCamera();
requestAnimationFrame(processFrame);
</script>
</body>
</html>
Error code
ERRW:ST1.0
Were you logged in?
Yes
Your username (if logged in)
No response
Your HTML
Your JavaScript
'-'Your CSS
'_'