Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // Size the canvas to fill available width while never exceeding available height.
- // Sets canvas to 1px wide first so siblings report their natural height, then
- // calculates how much vertical space the canvas can actually occupy.
- function fitCanvas() {
- canvas.style.width = '1px';
- const canvasRect = canvas.getBoundingClientRect();
- const parent = canvas.parentElement;
- const paddingBottom = parseFloat(getComputedStyle(parent).paddingBottom) || 0;
- let belowCanvas = 0, foundCanvas = false;
- for (const el of parent.children) {
- if (el === canvas) { foundCanvas = true; continue; }
- if (foundCanvas) belowCanvas += el.getBoundingClientRect().height;
- }
- const availH = window.innerHeight - canvasRect.top - belowCanvas - paddingBottom - 4;
- const availW = parent.clientWidth;
- const w = Math.min(availW, Math.max(0, availH) * (640 / 480));
- canvas.style.width = Math.max(64, Math.round(w)) + 'px';
- }
- window.addEventListener('resize', fitCanvas);
- // Histogram canvas auto-resize
- function resizeHistogramCanvas() {
- const hc = document.getElementById('histogramCanvas');
- hc.width = hc.parentElement.offsetWidth;
- hc.height = 100;
- updateHistogram();
- }
- window.addEventListener('resize', resizeHistogramCanvas);
- window.addEventListener('DOMContentLoaded', resizeHistogramCanvas);
- const canvas = document.getElementById('c');
- const ctx = canvas.getContext('2d');
- const imgData = ctx.createImageData(640, 480);
- const imgData32 = new Uint32Array(imgData.data.buffer);
- let reader = null;
- let streaming = false;
- let frameData = new Uint16Array(307200);
- let hotPixel = { x: -1, y: -1, value: -Infinity };
- let lastFrameTime = Date.now();
- let restartTimer = null;
- // Packed RGBA palette LUT with Uint32 view for single-write per pixel
- const paletteLUT = new Uint8ClampedArray(65536 * 4);
- const paletteLUT32 = new Uint32Array(paletteLUT.buffer);
- let lastMin = null, lastMax = null, lastRepeat = null, lastPalette = null, lastInvert = null;
- // Processing buffers - pre-allocated to avoid per-frame GC pressure
- const sharpenedBuffer = new Uint16Array(307200);
- const statsHist = new Int32Array(65536);
- const histBins = new Int32Array(2500);
- // Reference frame
- let refFrame = null; // Uint16Array when captured, null otherwise
- const refApplyBuffer = new Uint16Array(307200);
- // Dark frame - captures sensor fixed-pattern noise, subtracted before all other processing
- let darkFrame = null;
- let darkMean = 0;
- const darkBuffer = new Uint16Array(307200);
- // Frame averaging - dedicated ring buffer, independent of NR
- const AVG_MAX = 16;
- const avgRingBuf = new Uint16Array(AVG_MAX * 307200);
- const avgAccm = new Uint32Array(307200);
- const avgOutput = new Uint16Array(307200);
- let avgIdx = 0, avgCount = 0;
- // Noise reduction buffers - all pre-allocated
- const noiseBuffer = new Uint16Array(307200); // output of any NR pass
- const gaussTmp = new Uint16Array(307200); // horizontal-pass temp for Gaussian
- const medVals = new Uint16Array(25); // neighbor scratch for median sort
- // Temporal averaging: ring buffer of up to 8 frames
- const TEMPORAL_MAX = 8;
- const temporalBuf = new Uint16Array(TEMPORAL_MAX * 307200);
- const temporalAccm = new Uint32Array(307200);
- let tIdx = 0, tCount = 0;
- // Bilateral: precomputed spatial weights + range LUT (rebuilt when sigmaR changes)
- const bilRangeLUT = new Float32Array(65536);
- let lastBilSigmaR = -1;
- function buildBilSpatial(radius, sigmaS) {
- const n = (radius * 2 + 1) ** 2;
- const w = new Float32Array(n);
- const inv = 1 / (2 * sigmaS * sigmaS);
- let i = 0;
- for (let dy = -radius; dy <= radius; dy++)
- for (let dx = -radius; dx <= radius; dx++)
- w[i++] = Math.exp(-(dx*dx + dy*dy) * inv);
- return w;
- }
- const bilSpatial3 = buildBilSpatial(1, 1.0);
- const bilSpatial5 = buildBilSpatial(2, 1.5);
- // CLAHE
- const CLAHE_BINS = 256;
- const MAX_CLAHE_TILES = 16 * 16; // supports up to 16x16 tile grid
- const claheTileHist = new Int32Array(MAX_CLAHE_TILES * CLAHE_BINS);
- const claheTileCDF = new Float32Array(MAX_CLAHE_TILES * CLAHE_BINS);
- const claheBuffer = new Uint16Array(307200);
- // Per-pixel tile index/weight maps - precomputed once per tile-count change
- const claheTX0 = new Uint8Array(640), claheTX1 = new Uint8Array(640);
- const claheFX = new Float32Array(640);
- const claheTY0 = new Uint8Array(480), claheTY1 = new Uint8Array(480);
- const claheFY = new Float32Array(480);
- let lastClaheNX = 0, lastClaheNY = 0;
- // Cached palette preview ImageData
- let palImgData = null, palImgData32 = null;
- const url = '';
- // Encode/decode Uint16Array to/from base64 for localStorage persistence
- function arrayToB64(arr) {
- const bytes = new Uint8Array(arr.buffer);
- const chunk = 0x8000;
- let s = '';
- for (let i = 0; i < bytes.length; i += chunk)
- s += String.fromCharCode(...bytes.subarray(i, i + chunk));
- return btoa(s);
- }
- function b64ToUint16Array(s) {
- const bin = atob(s);
- const bytes = new Uint8Array(bin.length);
- for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
- return new Uint16Array(bytes.buffer);
- }
- // Cached DOM refs
- const elOffset = document.getElementById('offset');
- const elRange = document.getElementById('range');
- const elSharpness = document.getElementById('sharpness');
- const elInvert = document.getElementById('invertPalette');
- const elPalette = document.getElementById('palette');
- const elPaletteRepeat = document.getElementById('paletteRepeat');
- const elAutoCheck = document.getElementById('autoCheck');
- const elHotTrack = document.getElementById('hotTrack');
- const elAutoRangeExtra = document.getElementById('autoRangeExtra');
- const elAutoRangeOffset = document.getElementById('autoRangeOffset');
- const elShowHistogram = document.getElementById('showHistogram');
- const elHistSection = document.getElementById('histogramSection');
- const elStats = document.getElementById('stats');
- const elOffsetVal = document.getElementById('offsetVal');
- const elRangeVal = document.getElementById('rangeVal');
- const elHistCanvas = document.getElementById('histogramCanvas');
- const elPalCanvas = document.getElementById('paletteCanvas');
- const elClaheEnable = document.getElementById('claheEnable');
- const elClaheClip = document.getElementById('claheClip');
- const elClaheTiles = document.getElementById('claheTiles');
- const elRefMode = document.getElementById('refMode');
- const elRefStrength = document.getElementById('refStrength');
- const elRefStatus = document.getElementById('refStatus');
- const elNoiseEnable = document.getElementById('noiseEnable');
- const elNoiseAlgo = document.getElementById('noiseAlgo');
- const elNoiseStrength = document.getElementById('noiseStrength');
- const elAvgEnable = document.getElementById('avgEnable');
- const elAvgFrames = document.getElementById('avgFrames');
- const elDarkEnable = document.getElementById('darkEnable');
- const elDarkStatus = document.getElementById('darkStatus');
- const palettes = {
- gray: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[i,i,i];},
- iron: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;
- if(i<64)return[0,0,i*4];if(i<128)return[(i-64)*4,0,255];
- if(i<192)return[255,(i-128)*4,255-(i-128)*4];return[255,255,(i-192)*4];
- },
- fire: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.min(i*2,255),Math.min(Math.max(i-128,0)*4,255),0];},
- hot: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.min(255,i*2),Math.max(0,i*2-255),Math.max(0,i*4-510)];},
- cool: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[i,255-i,255];},
- rainbow:(v,mn,mx)=>{const n=(v-mn)/(mx-mn),r=Math.sin(n*Math.PI*2+Math.PI)*127+128,g=Math.sin(n*Math.PI*2+Math.PI/3+Math.PI)*127+128,b=Math.sin(n*Math.PI*2+2*Math.PI/3+Math.PI)*127+128;return[r,g,b];},
- viridis:(v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[68+n*187,1+n*204,84+n*171];},
- plasma: (v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[12+n*243,7+n*76,134+n*121];},
- magma: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.max(0,i*2-100),0,i];},
- inferno:(v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.min(255,i*2),i*0.5,Math.max(0,255-i)];},
- turbo: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return i<128?[0,i*2,255-i*2]:[(i-128)*2,255-(i-128)*2,0];},
- ice: (v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[200-n*200,220-n*100,255];},
- lava: (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.min(i*3,255),Math.max((i-170)*4,0),0];},
- wavelength: (v,mn,mx) => {
- const n = (v - mn) / (mx - mn);
- const lmbd = 380 + n * 260;
- let r=0, g=0, b=0;
- if (lmbd >= 380 && lmbd < 440) { r = -(lmbd - 440) / 60; g = 0; b = 1; }
- else if (lmbd < 490) { r = 0; g = (lmbd - 440) / 50; b = 1; }
- else if (lmbd < 510) { r = 0; g = 1; b = -(lmbd - 510) / 20; }
- else if (lmbd < 580) { r = (lmbd - 510) / 70; g = 1; b = 0; }
- else if (lmbd <= 645) { r = 1; g = -(lmbd - 645) / 65; b = 0; }
- let f = 1;
- if (lmbd < 420) f = 0.3 + 0.7 * (lmbd - 380) / 40;
- else if (lmbd > 700) f = 0.3 + 0.7 * (750 - lmbd) / 50;
- return [r * f * 255, g * f * 255, b * f * 255];
- }
- };
- function generatePaletteLUT(minVal, maxVal, repeat, palName, invert) {
- if (lastMin === minVal && lastMax === maxVal && lastRepeat === repeat &&
- lastPalette === palName && lastInvert === invert) return;
- lastMin = minVal; lastMax = maxVal; lastRepeat = repeat;
- lastPalette = palName; lastInvert = invert;
- const pal = palettes[palName];
- for (let v = 0; v < 65536; v++) {
- let n = (v - minVal) / (maxVal - minVal);
- if (n < 0) n = 0; else if (n > 1) n = 1;
- n = (n * repeat) % 2;
- if (n > 1) n = 2 - n;
- if (invert) n = 1 - n;
- const val = minVal + n * (maxVal - minVal);
- const [r, g, b] = pal(val, minVal, maxVal);
- const base = v * 4;
- paletteLUT[base] = r; paletteLUT[base+1] = g;
- paletteLUT[base+2] = b; paletteLUT[base+3] = 255;
- }
- }
- // Inlined Laplacian - pre-allocated output, no per-frame allocation
- function sharpenFunc(src, width, height, sharpness) {
- const alpha = sharpness / 100, beta = 1 - alpha;
- sharpenedBuffer.set(src.subarray(0, width));
- sharpenedBuffer.set(src.subarray((height - 1) * width), (height - 1) * width);
- for (let y = 1; y < height - 1; y++) {
- const row = y * width;
- sharpenedBuffer[row] = src[row];
- sharpenedBuffer[row + width - 1] = src[row + width - 1];
- for (let x = 1; x < width - 1; x++) {
- const idx = row + x;
- const sum = 5*src[idx] - src[idx-width] - src[idx+width] - src[idx-1] - src[idx+1];
- const b = src[idx] * beta + sum * alpha;
- sharpenedBuffer[idx] = b < 0 ? 0 : b > 65535 ? 65535 : b;
- }
- }
- return sharpenedBuffer;
- }
- // Reference frame operations.
- // All modes use the current display offset as the neutral center so that results
- // stay inside the visible display range (the data lives near offset, not 0 or 32768).
- // Strength 0-100 blends from passthrough (0) to full effect (100).
- function applyRefFrame(src, ref, mode, strength) {
- const str = strength / 100;
- const center = parseInt(elOffset.value);
- for (let i = 0; i < 307200; i++) {
- let v;
- const r = ref[i] || 1;
- if (mode === 'sub') {
- // Show difference from reference, centered at display offset.
- // Neutral (src==ref) -> center. Hotter than ref -> above center.
- v = src[i] + (center - r) * str;
- } else if (mode === 'add') {
- // Overlay reference deviations from neutral onto current frame.
- // Ref hot spot (ref > center) adds warmth; ref cold spot subtracts.
- v = src[i] + (r - center) * str;
- } else {
- v = src[i] + (r - center) * str;
- }
- refApplyBuffer[i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
- }
- return refApplyBuffer;
- }
- // Dark frame: subtract fixed-pattern sensor noise, re-center at dark_mean so
- // output stays in the same value range as the raw sensor data.
- // result = live - dark + dark_mean
- // Pixels that exactly match the dark frame come out at dark_mean (neutral).
- function applyDarkFrame(src) {
- for (let i = 0; i < 307200; i++) {
- const v = src[i] - darkFrame[i] + darkMean;
- darkBuffer[i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
- }
- return darkBuffer;
- }
- // Frame averaging: ring buffer of raw frames, averaged into avgOutput
- function applyFrameAvg(src, nFrames) {
- nFrames = Math.min(AVG_MAX, nFrames);
- avgRingBuf.set(src, avgIdx * 307200);
- avgIdx = (avgIdx + 1) % nFrames;
- if (avgCount < nFrames) avgCount++;
- const cnt = avgCount;
- avgAccm.fill(0);
- for (let f = 0; f < cnt; f++) {
- const base = ((avgIdx - 1 - f + nFrames) % nFrames) * 307200;
- for (let i = 0; i < 307200; i++) avgAccm[i] += avgRingBuf[base + i];
- }
- const inv = 1 / cnt;
- for (let i = 0; i < 307200; i++) avgOutput[i] = avgAccm[i] * inv;
- return avgOutput;
- }
- // --- Noise reduction ---
- function nrTemporal(src, nFrames) {
- nFrames = Math.max(2, Math.min(TEMPORAL_MAX, nFrames));
- // Write current frame into ring buffer slot, then advance index
- temporalBuf.set(src, tIdx * 307200);
- tIdx = (tIdx + 1) % nFrames;
- if (tCount < nFrames) tCount++;
- const cnt = tCount;
- temporalAccm.fill(0);
- for (let f = 0; f < cnt; f++) {
- const base = ((tIdx - 1 - f + nFrames) % nFrames) * 307200;
- for (let i = 0; i < 307200; i++) temporalAccm[i] += temporalBuf[base + i];
- }
- const inv = 1 / cnt;
- for (let i = 0; i < 307200; i++) noiseBuffer[i] = temporalAccm[i] * inv;
- return noiseBuffer;
- }
- // Separable Gaussian: horizontal then vertical pass, kernel size from radius
- function nrGaussian(src, strength) {
- const radius = strength <= 2 ? 1 : strength <= 5 ? 2 : 3;
- const kernels = [[1,2,1],[1,4,6,4,1],[1,6,15,20,15,6,1]];
- const k = kernels[radius - 1];
- const ksum = k.reduce((a,b) => a+b, 0);
- const W = 640, H = 480;
- // Horizontal pass into gaussTmp
- for (let y = 0; y < H; y++) {
- const row = y * W;
- for (let x = 0; x < W; x++) {
- let sum = 0;
- for (let t = 0; t < k.length; t++) {
- const xi = x + t - radius;
- sum += src[row + (xi < 0 ? 0 : xi >= W ? W-1 : xi)] * k[t];
- }
- gaussTmp[row + x] = sum / ksum;
- }
- }
- // Vertical pass into noiseBuffer
- for (let y = 0; y < H; y++) {
- const row = y * W;
- for (let x = 0; x < W; x++) {
- let sum = 0;
- for (let t = 0; t < k.length; t++) {
- const yi = y + t - radius;
- sum += gaussTmp[(yi < 0 ? 0 : yi >= H ? H-1 : yi) * W + x] * k[t];
- }
- noiseBuffer[row + x] = sum / ksum;
- }
- }
- return noiseBuffer;
- }
- // Median filter using insertion sort (fast for small kernels)
- function nrMedian(src, strength) {
- const radius = strength >= 5 ? 2 : 1;
- const W = 640, H = 480;
- const n = (radius*2+1) ** 2;
- const mid = n >> 1;
- for (let y = 0; y < H; y++) {
- const row = y * W;
- for (let x = 0; x < W; x++) {
- let cnt = 0;
- for (let dy = -radius; dy <= radius; dy++) {
- const yi = (y+dy < 0 ? 0 : y+dy >= H ? H-1 : y+dy) * W;
- for (let dx = -radius; dx <= radius; dx++) {
- const xi = x+dx < 0 ? 0 : x+dx >= W ? W-1 : x+dx;
- medVals[cnt++] = src[yi + xi];
- }
- }
- // Insertion sort
- for (let i = 1; i < n; i++) {
- const v = medVals[i]; let j = i - 1;
- while (j >= 0 && medVals[j] > v) { medVals[j+1] = medVals[j]; j--; }
- medVals[j+1] = v;
- }
- noiseBuffer[row + x] = medVals[mid];
- }
- }
- return noiseBuffer;
- }
- // Bilateral: edge-preserving blur using precomputed spatial weights and range LUT.
- // Range LUT is rebuilt only when sigmaR changes (sigmaR = strength * 8).
- function nrBilateral(src, strength) {
- const sigmaR = Math.max(4, strength * 8);
- if (sigmaR !== lastBilSigmaR) {
- lastBilSigmaR = sigmaR;
- const inv = 1 / (2 * sigmaR * sigmaR);
- for (let d = 0; d < 65536; d++) bilRangeLUT[d] = Math.exp(-d * d * inv);
- }
- const radius = strength >= 5 ? 2 : 1;
- const sw = radius === 1 ? bilSpatial3 : bilSpatial5;
- const W = 640, H = 480;
- for (let y = 0; y < H; y++) {
- const row = y * W;
- for (let x = 0; x < W; x++) {
- const c = src[row + x];
- let wsum = 0, vsum = 0, si = 0;
- for (let dy = -radius; dy <= radius; dy++) {
- const yi = (y+dy < 0 ? 0 : y+dy >= H ? H-1 : y+dy) * W;
- for (let dx = -radius; dx <= radius; dx++) {
- const v = src[yi + (x+dx < 0 ? 0 : x+dx >= W ? W-1 : x+dx)];
- const diff = v > c ? v - c : c - v;
- const w = sw[si++] * bilRangeLUT[diff];
- wsum += w; vsum += v * w;
- }
- }
- noiseBuffer[row + x] = vsum / wsum;
- }
- }
- return noiseBuffer;
- }
- function applyNoise(src, algo, strength) {
- if (algo === 'temporal') return nrTemporal(src, strength);
- if (algo === 'gauss') return nrGaussian(src, strength);
- if (algo === 'median') return nrMedian(src, strength);
- return nrBilateral(src, strength);
- }
- // Recompute per-pixel tile index/weight maps when tile count changes
- function ensureCLAHETileMap(nx, ny) {
- if (nx === lastClaheNX && ny === lastClaheNY) return;
- lastClaheNX = nx; lastClaheNY = ny;
- const tw = 640 / nx, th = 480 / ny;
- for (let x = 0; x < 640; x++) {
- const tx = (x + 0.5) / tw - 0.5;
- const tx0 = Math.max(0, Math.min(nx - 1, Math.floor(tx)));
- claheTX0[x] = tx0;
- claheTX1[x] = Math.min(nx - 1, tx0 + 1);
- claheFX[x] = Math.max(0, tx - tx0);
- }
- for (let y = 0; y < 480; y++) {
- const ty = (y + 0.5) / th - 0.5;
- const ty0 = Math.max(0, Math.min(ny - 1, Math.floor(ty)));
- claheTY0[y] = ty0;
- claheTY1[y] = Math.min(ny - 1, ty0 + 1);
- claheFY[y] = Math.max(0, ty - ty0);
- }
- }
- // Contrast Limited Adaptive Histogram Equalization.
- // Operates over the display range [minVal, maxVal] using CLAHE_BINS bins.
- // clipLimit: clip threshold as a multiple of the uniform histogram height.
- // Result is remapped back to [minVal, maxVal] for normal LUT colorization.
- function applyCLAHE(src, width, height, clipLimit, nx, ny, minVal, maxVal) {
- ensureCLAHETileMap(nx, ny);
- const tw = width / nx, th = height / ny;
- const valRange = maxVal - minVal || 1;
- const tobin = (CLAHE_BINS - 1) / valRange;
- // --- Step 1: Per-tile histogram, clip, and CDF ---
- claheTileHist.fill(0);
- for (let ty = 0; ty < ny; ty++) {
- const y0 = Math.round(ty * th), y1 = Math.round((ty + 1) * th);
- for (let tx = 0; tx < nx; tx++) {
- const x0 = Math.round(tx * tw), x1 = Math.round((tx + 1) * tw);
- const base = (ty * nx + tx) * CLAHE_BINS;
- let pixels = 0;
- for (let y = y0; y < y1; y++) {
- const row = y * width;
- for (let x = x0; x < x1; x++) {
- let bin = Math.round((src[row + x] - minVal) * tobin);
- if (bin < 0) bin = 0; else if (bin >= CLAHE_BINS) bin = CLAHE_BINS - 1;
- claheTileHist[base + bin]++;
- pixels++;
- }
- }
- // Clip excess and redistribute uniformly across all bins
- const clipThresh = Math.max(1, clipLimit * pixels / CLAHE_BINS);
- let excess = 0;
- for (let b = 0; b < CLAHE_BINS; b++) {
- const h = claheTileHist[base + b];
- if (h > clipThresh) { excess += h - clipThresh; claheTileHist[base + b] = clipThresh; }
- }
- const add = excess / CLAHE_BINS;
- // Build normalized CDF into claheTileCDF
- let cum = 0;
- for (let b = 0; b < CLAHE_BINS; b++) {
- cum += claheTileHist[base + b] + add;
- claheTileCDF[base + b] = cum;
- }
- const cdfMax = claheTileCDF[base + CLAHE_BINS - 1];
- if (cdfMax > 0) {
- for (let b = 0; b < CLAHE_BINS; b++) claheTileCDF[base + b] /= cdfMax;
- }
- }
- }
- // --- Step 2: Bilinear interpolation of 4 surrounding tile CDFs per pixel ---
- for (let y = 0; y < height; y++) {
- const ty0 = claheTY0[y], ty1 = claheTY1[y], yf = claheFY[y], y1f = 1 - yf;
- const row = y * width;
- for (let x = 0; x < width; x++) {
- let bin = Math.round((src[row + x] - minVal) * tobin);
- if (bin < 0) bin = 0; else if (bin >= CLAHE_BINS) bin = CLAHE_BINS - 1;
- const tx0 = claheTX0[x], tx1 = claheTX1[x], xf = claheFX[x];
- const b00 = claheTileCDF[(ty0 * nx + tx0) * CLAHE_BINS + bin];
- const b10 = claheTileCDF[(ty0 * nx + tx1) * CLAHE_BINS + bin];
- const b01 = claheTileCDF[(ty1 * nx + tx0) * CLAHE_BINS + bin];
- const b11 = claheTileCDF[(ty1 * nx + tx1) * CLAHE_BINS + bin];
- const mapped = b00*(1-xf)*y1f + b10*xf*y1f + b01*(1-xf)*yf + b11*xf*yf;
- claheBuffer[row + x] = Math.round(minVal + mapped * valRange);
- }
- }
- return claheBuffer;
- }
- function updateImage() {
- const offset = parseInt(elOffset.value);
- const range = parseInt(elRange.value);
- const sharpness = parseInt(elSharpness.value);
- const invertPalette = elInvert.checked;
- const pal = palettes[elPalette.value];
- if (!pal) { console.error('Invalid palette selected'); return; }
- const min16 = offset - range / 2;
- const max16 = offset + range / 2;
- // Pipeline: dark frame -> frame avg -> noise reduction -> ref frame -> sharpen -> CLAHE -> LUT
- let src = frameData;
- if (elDarkEnable.checked && darkFrame) {
- src = applyDarkFrame(src);
- }
- if (elAvgEnable.checked) {
- src = applyFrameAvg(src, parseInt(elAvgFrames.value));
- }
- if (elNoiseEnable.checked) {
- src = applyNoise(src, elNoiseAlgo.value, parseInt(elNoiseStrength.value));
- }
- const refMode = elRefMode.value;
- if (refFrame && refMode !== 'off') {
- src = applyRefFrame(src, refFrame, refMode, parseInt(elRefStrength.value));
- }
- if (sharpness >= 1) {
- src = sharpenFunc(src, 640, 480, sharpness);
- }
- if (elClaheEnable.checked) {
- src = applyCLAHE(src, 640, 480, parseFloat(elClaheClip.value),
- parseInt(elClaheTiles.value), parseInt(elClaheTiles.value),
- min16, max16);
- }
- generatePaletteLUT(min16, max16, parseInt(elPaletteRepeat.value), elPalette.value, invertPalette);
- for (let i = 0; i < 307200; i++) {
- imgData32[i] = paletteLUT32[src[i]];
- }
- ctx.putImageData(imgData, 0, 0);
- if (elHotTrack.checked && hotPixel.x >= 0) {
- const scaleX = canvas.width / 640;
- const scaleY = canvas.height / 480;
- const x = hotPixel.x * scaleX;
- const y = hotPixel.y * scaleY;
- ctx.strokeStyle = 'red';
- ctx.lineWidth = 2;
- ctx.beginPath();
- ctx.moveTo(x-10, y); ctx.lineTo(x+10, y);
- ctx.moveTo(x, y-10); ctx.lineTo(x, y+10);
- ctx.stroke();
- ctx.fillStyle = 'blue';
- ctx.beginPath();
- ctx.arc(x, y, 3, 0, 2*Math.PI);
- ctx.fill();
- ctx.fillStyle = '#333';
- ctx.fillRect(x+10, y+8, 36, 16);
- ctx.fillStyle = 'white';
- ctx.font = '14px monospace';
- ctx.fillText(`${hotPixel.value}`, x+12, y+20);
- }
- updateHistogram();
- }
- function updateHistogram() {
- if (!elShowHistogram.checked) return;
- const hCtx = elHistCanvas.getContext('2d');
- hCtx.clearRect(0, 0, elHistCanvas.width, elHistCanvas.height);
- const offset2 = parseInt(elOffset.value);
- const range = parseInt(elRange.value);
- const minVal = offset2 - range / 2;
- const maxVal = offset2 + range / 2;
- if (range <= 0) return;
- // Re-bucket from statsHist (already computed by getStats) - avoids re-scanning 307,200 pixels
- const binCount = range;
- const bins = histBins.subarray(0, binCount);
- bins.fill(0);
- const vMin = Math.max(0, Math.ceil(minVal));
- const vMax = Math.min(65535, Math.floor(maxVal));
- const scale = (binCount - 1) / range;
- for (let v = vMin; v <= vMax; v++) {
- const b = Math.floor((v - minVal) * scale);
- if (b >= 0 && b < binCount) bins[b] += statsHist[v];
- }
- let maxCount = 0;
- for (let b = 0; b < binCount; b++) { if (bins[b] > maxCount) maxCount = bins[b]; }
- if (maxCount === 0) return;
- const maxLog = Math.log10(maxCount + 1);
- hCtx.strokeStyle = 'white';
- hCtx.lineWidth = 1;
- hCtx.beginPath();
- for (let x = 0; x < elHistCanvas.width; x++) {
- const bi = x * (binCount - 1) / (elHistCanvas.width - 1);
- const left = Math.floor(bi);
- const frac = bi - left;
- const bv = bins[left] * (1 - frac) + bins[Math.min(binCount-1, left+1)] * frac;
- const lc = bv > 0 ? Math.log10(bv + 1) : 0;
- const y = elHistCanvas.height - (lc / maxLog) * (elHistCanvas.height - 20);
- x === 0 ? hCtx.moveTo(x, y) : hCtx.lineTo(x, y);
- }
- hCtx.stroke();
- const offset = parseInt(elOffset.value);
- if (offset >= minVal && offset <= maxVal) {
- const ox = ((offset - minVal) / range) * elHistCanvas.width;
- hCtx.strokeStyle = 'red'; hCtx.lineWidth = 2;
- hCtx.beginPath(); hCtx.moveTo(ox, 0); hCtx.lineTo(ox, elHistCanvas.height); hCtx.stroke();
- }
- hCtx.fillStyle = '#fff';
- hCtx.font = '16px monospace';
- hCtx.fillText(`${minVal}`, 5, elHistCanvas.height - 5);
- hCtx.fillText(`${maxVal}`, elHistCanvas.width - 60, elHistCanvas.height - 5);
- hCtx.fillText('Log Scale', 5, 15);
- if (elPalCanvas) {
- const pctx = elPalCanvas.getContext('2d');
- const w = elPalCanvas.width, h = elPalCanvas.height;
- if (!palImgData || palImgData.width !== w || palImgData.height !== h) {
- palImgData = pctx.createImageData(w, h);
- palImgData32 = new Uint32Array(palImgData.data.buffer);
- }
- const inv = elInvert.checked;
- for (let x = 0; x < w; x++) {
- const xx = inv ? w - 1 - x : x;
- const val = minVal + (xx / (w - 1)) * (maxVal - minVal);
- const li = val < 0 ? 0 : val > 65535 ? 65535 : Math.round(val);
- const pk = paletteLUT32[li] || 0xFF000000;
- for (let y = 0; y < h; y++) palImgData32[y * w + x] = pk;
- }
- pctx.putImageData(palImgData, 0, 0);
- }
- }
- // Single pass: min/max/sum + statsHist + optional hot-pixel tracking.
- // statsHist is reused by updateHistogram to avoid a second full scan.
- function getStats() {
- let min = Infinity, max = -Infinity, sum = 0;
- let hotMax = -Infinity, hotIdx = -1;
- const trackHot = elHotTrack.checked;
- statsHist.fill(0);
- for (let i = 0; i < frameData.length; i++) {
- const val = frameData[i];
- if (val < min) min = val;
- if (val > max) max = val;
- sum += val;
- statsHist[val]++;
- if (trackHot && val > hotMax) { hotMax = val; hotIdx = i; }
- }
- if (trackHot && hotIdx >= 0) {
- hotPixel.x = hotIdx % 640;
- hotPixel.y = Math.floor(hotIdx / 640);
- hotPixel.value = hotMax;
- }
- const margin = parseInt(elAutoRangeExtra.value);
- const total = frameData.length;
- const p0001 = Math.floor(total * 0.0001);
- const p9999 = Math.floor(total * 0.9999);
- let count = 0, min_p = min;
- for (let v = 0; v < 65536; v++) {
- count += statsHist[v];
- if (count > p0001) { min_p = v; break; }
- }
- count = 0;
- let max_p = max;
- for (let v = 65535; v >= 0; v--) {
- count += statsHist[v];
- if (count > total - p9999) { max_p = v; break; }
- }
- return {
- min: min_p - margin, max: max_p + margin,
- avg: Math.round(sum / frameData.length),
- absolute_min: min, absolute_max: max
- };
- }
- // Accepts pre-computed stats to avoid calling getStats() twice per frame
- function autoRange(stats) {
- if (!stats) stats = getStats();
- const aoOffset = parseInt(elAutoRangeOffset.value);
- elOffset.value = Math.round((stats.min + stats.max) / 2) + aoOffset;
- elRange.value = stats.max - stats.min;
- elOffsetVal.textContent = elOffset.value;
- elRangeVal.textContent = elRange.value;
- updateImage();
- }
- async function startStream() {
- let frames = 0, fps = 0;
- setInterval(() => { fps = frames; frames = 0; }, 1000);
- streaming = true;
- let first = true;
- document.getElementById('stream').textContent = 'Stop';
- const resp = await fetch(`/cgi-bin/proxy.cgi/stream?Type=RAW&Source=Raw&Frames=100000000`);
- reader = resp.body.getReader();
- let buffer = new Uint8Array(0);
- let skipNext = true;
- if (!restartTimer) {
- restartTimer = setInterval(() => {
- if (Date.now() - lastFrameTime > 500) {
- console.warn("Stream stalled. Restarting...");
- stopStream(); startStream();
- }
- }, 200);
- }
- while (streaming) {
- const { done, value } = await reader.read();
- if (done) break;
- lastFrameTime = Date.now();
- const tmp = new Uint8Array(buffer.length + value.length);
- tmp.set(buffer); tmp.set(value, buffer.length);
- buffer = tmp;
- while (buffer.length >= 0x96014 + 4) {
- let found = false;
- for (let i = 0; i < buffer.length - 0x96014 - 3; i++) {
- if (buffer[i]==0x66 && buffer[i+1]==0xDD && buffer[i+2]==0xDD && buffer[i+3]==0x66) {
- const nextPos = i + 0x96014;
- if (nextPos+3 < buffer.length &&
- buffer[nextPos]==0x66 && buffer[nextPos+1]==0xDD &&
- buffer[nextPos+2]==0xDD && buffer[nextPos+3]==0x66) {
- if (i+0x20 < buffer.length && buffer[i+0x18]==0x44 && buffer[i+0x19]==0x4C) {
- buffer = buffer.slice(i + 0x224); found = true; break;
- }
- if (skipNext) {
- skipNext = false; buffer = buffer.slice(i + 0x96014); found = true; break;
- }
- const low = buffer.slice(i + 0x14, i + 0x4B014);
- const high = buffer.slice(i + 0x4B014, i + 0x96014);
- for (let j = 0; j < 307200; j += 8) {
- frameData[j] = (high[j] << 8) | low[j];
- frameData[j+1] = (high[j+1] << 8) | low[j+1];
- frameData[j+2] = (high[j+2] << 8) | low[j+2];
- frameData[j+3] = (high[j+3] << 8) | low[j+3];
- frameData[j+4] = (high[j+4] << 8) | low[j+4];
- frameData[j+5] = (high[j+5] << 8) | low[j+5];
- frameData[j+6] = (high[j+6] << 8) | low[j+6];
- frameData[j+7] = (high[j+7] << 8) | low[j+7];
- }
- frames++;
- // Single pass: stats + hot tracking + statsHist (reused by histogram)
- const stats = getStats();
- elStats.textContent =
- `Min: ${stats.min} | Avg: ${stats.avg} | Max: ${stats.max} | FPS: ${fps}`;
- if (elAutoCheck.checked) autoRange(stats);
- updateImage();
- if (first) { first = false; autoRange(stats); }
- buffer = buffer.slice(i + 0x96014);
- found = true; break;
- }
- }
- }
- if (!found) break;
- }
- }
- }
- function stopStream() {
- streaming = false;
- document.getElementById('stream').textContent = 'Start';
- if (reader) { try { reader.cancel(); } catch (e) {} }
- }
- document.getElementById('stream').onclick = () => {
- if (streaming) { streaming = false; reader?.cancel(); document.getElementById('stream').textContent = 'Start'; }
- else startStream();
- };
- document.getElementById('autoRange').onclick = () => { autoRange(); };
- async function sendFFCCommand() {
- try { await fetch(`/cgi-bin/dmcmd?Command=${encodeURIComponent('KBD,C')}`, { timeout: 5000 }); } catch (e) {}
- }
- document.getElementById('doFFC').onclick = () => { sendFFCCommand(); };
- document.getElementById('save').onclick = () => {
- canvas.toBlob(blob => {
- const a = document.createElement('a');
- a.href = URL.createObjectURL(blob);
- a.download = 'thermal.png';
- a.click();
- });
- };
- document.getElementById('captureDark').onclick = () => {
- if (!darkFrame) darkFrame = new Uint16Array(307200);
- darkFrame.set(frameData);
- let sum = 0;
- for (let i = 0; i < 307200; i++) sum += darkFrame[i];
- darkMean = Math.round(sum / 307200);
- elDarkStatus.textContent = `Dark captured (mean: ${darkMean})`;
- elDarkStatus.style.color = '#8f8';
- localStorage.setItem('thermal_darkFrame', arrayToB64(darkFrame));
- localStorage.setItem('thermal_darkMean', darkMean);
- updateImage();
- };
- document.getElementById('clearDark').onclick = () => {
- darkFrame = null;
- elDarkStatus.textContent = 'No dark frame';
- elDarkStatus.style.color = '#888';
- localStorage.removeItem('thermal_darkFrame');
- localStorage.removeItem('thermal_darkMean');
- updateImage();
- };
- document.getElementById('captureRef').onclick = () => {
- if (!refFrame) refFrame = new Uint16Array(307200);
- refFrame.set(frameData);
- elRefStatus.textContent = 'Ref captured';
- elRefStatus.style.color = '#8f8';
- localStorage.setItem('thermal_refFrame', arrayToB64(refFrame));
- updateImage();
- };
- document.getElementById('clearRef').onclick = () => {
- refFrame = null;
- elRefStatus.textContent = 'No ref';
- elRefStatus.style.color = '#888';
- localStorage.removeItem('thermal_refFrame');
- updateImage();
- };
- elShowHistogram.addEventListener('change', () => {
- elHistSection.style.display = elShowHistogram.checked ? '' : 'none';
- if (elShowHistogram.checked) updateHistogram();
- fitCanvas();
- });
- // Sliders and selects that trigger updateImage + localStorage
- ['offset', 'range', 'palette', 'sharpness', 'autoRangeExtra', 'autoRangeOffset',
- 'claheClip', 'claheTiles', 'refStrength', 'refMode',
- 'noiseStrength', 'avgFrames'].forEach(id => {
- const el = document.getElementById(id);
- el.oninput = () => {
- const valEl = document.getElementById(id + 'Val');
- if (valEl) valEl.textContent = el.value;
- localStorage.setItem('thermal_' + id, el.value);
- updateImage();
- };
- });
- // Noise algo select: reset temporal buffer on change so stale frames don't bleed in
- elNoiseAlgo.addEventListener('change', () => {
- tIdx = 0; tCount = 0;
- localStorage.setItem('thermal_noiseAlgo', elNoiseAlgo.value);
- updateImage();
- });
- // Checkboxes
- ['claheEnable', 'showHistogram', 'noiseEnable', 'avgEnable', 'darkEnable'].forEach(id => {
- document.getElementById(id).addEventListener('change', () => {
- if (id === 'noiseEnable') { tIdx = 0; tCount = 0; }
- if (id === 'avgEnable') { avgIdx = 0; avgCount = 0; }
- localStorage.setItem('thermal_' + id, document.getElementById(id).checked ? '1' : '0');
- updateImage();
- });
- });
- window.addEventListener('DOMContentLoaded', () => {
- // Restore slider/select values
- ['offset', 'range', 'palette', 'sharpness', 'autoRangeExtra', 'autoRangeOffset',
- 'claheClip', 'claheTiles', 'refStrength', 'refMode',
- 'noiseStrength', 'noiseAlgo', 'avgFrames'].forEach(id => {
- const val = localStorage.getItem('thermal_' + id);
- if (val !== null) {
- document.getElementById(id).value = val;
- const valEl = document.getElementById(id + 'Val');
- if (valEl) valEl.textContent = val;
- }
- });
- // Restore checkboxes
- ['claheEnable', 'showHistogram', 'noiseEnable', 'avgEnable', 'darkEnable'].forEach(id => {
- const val = localStorage.getItem('thermal_' + id);
- if (val !== null) document.getElementById(id).checked = val === '1';
- });
- elHistSection.style.display = elShowHistogram.checked ? '' : 'none';
- // Restore dark frame
- const savedDark = localStorage.getItem('thermal_darkFrame');
- if (savedDark) {
- try {
- darkFrame = b64ToUint16Array(savedDark);
- darkMean = parseInt(localStorage.getItem('thermal_darkMean')) || 0;
- elDarkStatus.textContent = `Dark captured (mean: ${darkMean})`;
- elDarkStatus.style.color = '#8f8';
- } catch (e) {
- localStorage.removeItem('thermal_darkFrame');
- localStorage.removeItem('thermal_darkMean');
- }
- }
- // Restore reference frame
- const savedRef = localStorage.getItem('thermal_refFrame');
- if (savedRef) {
- try {
- refFrame = b64ToUint16Array(savedRef);
- elRefStatus.textContent = 'Ref captured';
- elRefStatus.style.color = '#8f8';
- } catch (e) {
- localStorage.removeItem('thermal_refFrame');
- }
- }
- requestAnimationFrame(fitCanvas);
- startStream();
- updateImage();
- });
- function updateOffsetRangeState() {
- const auto = elAutoCheck.checked;
- elOffset.disabled = auto; elRange.disabled = auto;
- elOffsetVal.style.opacity = auto ? 0.5 : 1;
- elRangeVal.style.opacity = auto ? 0.5 : 1;
- }
- document.getElementById('autoCheck').addEventListener('change', updateOffsetRangeState);
- window.addEventListener('DOMContentLoaded', updateOffsetRangeState);
Add Comment
Please, Sign In to add comment