Guest User

Untitled

a guest
Jun 3rd, 2026
14
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
  1. // Size the canvas to fill available width while never exceeding available height.
  2. // Sets canvas to 1px wide first so siblings report their natural height, then
  3. // calculates how much vertical space the canvas can actually occupy.
  4. function fitCanvas() {
  5.   canvas.style.width = '1px';
  6.  
  7.   const canvasRect = canvas.getBoundingClientRect();
  8.   const parent = canvas.parentElement;
  9.   const paddingBottom = parseFloat(getComputedStyle(parent).paddingBottom) || 0;
  10.  
  11.   let belowCanvas = 0, foundCanvas = false;
  12.   for (const el of parent.children) {
  13.     if (el === canvas) { foundCanvas = true; continue; }
  14.     if (foundCanvas) belowCanvas += el.getBoundingClientRect().height;
  15.   }
  16.  
  17.   const availH = window.innerHeight - canvasRect.top - belowCanvas - paddingBottom - 4;
  18.   const availW = parent.clientWidth;
  19.   const w = Math.min(availW, Math.max(0, availH) * (640 / 480));
  20.   canvas.style.width = Math.max(64, Math.round(w)) + 'px';
  21. }
  22. window.addEventListener('resize', fitCanvas);
  23.  
  24. // Histogram canvas auto-resize
  25. function resizeHistogramCanvas() {
  26.   const hc = document.getElementById('histogramCanvas');
  27.   hc.width = hc.parentElement.offsetWidth;
  28.   hc.height = 100;
  29.   updateHistogram();
  30. }
  31. window.addEventListener('resize', resizeHistogramCanvas);
  32. window.addEventListener('DOMContentLoaded', resizeHistogramCanvas);
  33.  
  34. const canvas = document.getElementById('c');
  35. const ctx = canvas.getContext('2d');
  36. const imgData = ctx.createImageData(640, 480);
  37. const imgData32 = new Uint32Array(imgData.data.buffer);
  38.  
  39. let reader = null;
  40. let streaming = false;
  41. let frameData = new Uint16Array(307200);
  42. let hotPixel = { x: -1, y: -1, value: -Infinity };
  43. let lastFrameTime = Date.now();
  44. let restartTimer = null;
  45.  
  46. // Packed RGBA palette LUT with Uint32 view for single-write per pixel
  47. const paletteLUT = new Uint8ClampedArray(65536 * 4);
  48. const paletteLUT32 = new Uint32Array(paletteLUT.buffer);
  49. let lastMin = null, lastMax = null, lastRepeat = null, lastPalette = null, lastInvert = null;
  50.  
  51. // Processing buffers - pre-allocated to avoid per-frame GC pressure
  52. const sharpenedBuffer = new Uint16Array(307200);
  53. const statsHist       = new Int32Array(65536);
  54. const histBins        = new Int32Array(2500);
  55.  
  56. // Reference frame
  57. let refFrame = null;                              // Uint16Array when captured, null otherwise
  58. const refApplyBuffer = new Uint16Array(307200);
  59.  
  60. // Dark frame - captures sensor fixed-pattern noise, subtracted before all other processing
  61. let darkFrame = null;
  62. let darkMean  = 0;
  63. const darkBuffer = new Uint16Array(307200);
  64.  
  65. // Frame averaging - dedicated ring buffer, independent of NR
  66. const AVG_MAX    = 16;
  67. const avgRingBuf = new Uint16Array(AVG_MAX * 307200);
  68. const avgAccm    = new Uint32Array(307200);
  69. const avgOutput  = new Uint16Array(307200);
  70. let   avgIdx = 0, avgCount = 0;
  71.  
  72. // Noise reduction buffers - all pre-allocated
  73. const noiseBuffer  = new Uint16Array(307200);     // output of any NR pass
  74. const gaussTmp     = new Uint16Array(307200);     // horizontal-pass temp for Gaussian
  75. const medVals      = new Uint16Array(25);         // neighbor scratch for median sort
  76.  
  77. // Temporal averaging: ring buffer of up to 8 frames
  78. const TEMPORAL_MAX = 8;
  79. const temporalBuf  = new Uint16Array(TEMPORAL_MAX * 307200);
  80. const temporalAccm = new Uint32Array(307200);
  81. let   tIdx = 0, tCount = 0;
  82.  
  83. // Bilateral: precomputed spatial weights + range LUT (rebuilt when sigmaR changes)
  84. const bilRangeLUT = new Float32Array(65536);
  85. let   lastBilSigmaR = -1;
  86.  
  87. function buildBilSpatial(radius, sigmaS) {
  88.   const n = (radius * 2 + 1) ** 2;
  89.   const w = new Float32Array(n);
  90.   const inv = 1 / (2 * sigmaS * sigmaS);
  91.   let i = 0;
  92.   for (let dy = -radius; dy <= radius; dy++)
  93.     for (let dx = -radius; dx <= radius; dx++)
  94.       w[i++] = Math.exp(-(dx*dx + dy*dy) * inv);
  95.   return w;
  96. }
  97. const bilSpatial3 = buildBilSpatial(1, 1.0);
  98. const bilSpatial5 = buildBilSpatial(2, 1.5);
  99.  
  100. // CLAHE
  101. const CLAHE_BINS     = 256;
  102. const MAX_CLAHE_TILES = 16 * 16;                 // supports up to 16x16 tile grid
  103. const claheTileHist  = new Int32Array(MAX_CLAHE_TILES * CLAHE_BINS);
  104. const claheTileCDF   = new Float32Array(MAX_CLAHE_TILES * CLAHE_BINS);
  105. const claheBuffer    = new Uint16Array(307200);
  106. // Per-pixel tile index/weight maps - precomputed once per tile-count change
  107. const claheTX0 = new Uint8Array(640), claheTX1 = new Uint8Array(640);
  108. const claheFX  = new Float32Array(640);
  109. const claheTY0 = new Uint8Array(480), claheTY1 = new Uint8Array(480);
  110. const claheFY  = new Float32Array(480);
  111. let lastClaheNX = 0, lastClaheNY = 0;
  112.  
  113. // Cached palette preview ImageData
  114. let palImgData = null, palImgData32 = null;
  115.  
  116. const url = '';
  117.  
  118. // Encode/decode Uint16Array to/from base64 for localStorage persistence
  119. function arrayToB64(arr) {
  120.   const bytes = new Uint8Array(arr.buffer);
  121.   const chunk = 0x8000;
  122.   let s = '';
  123.   for (let i = 0; i < bytes.length; i += chunk)
  124.     s += String.fromCharCode(...bytes.subarray(i, i + chunk));
  125.   return btoa(s);
  126. }
  127. function b64ToUint16Array(s) {
  128.   const bin = atob(s);
  129.   const bytes = new Uint8Array(bin.length);
  130.   for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
  131.   return new Uint16Array(bytes.buffer);
  132. }
  133.  
  134. // Cached DOM refs
  135. const elOffset         = document.getElementById('offset');
  136. const elRange          = document.getElementById('range');
  137. const elSharpness      = document.getElementById('sharpness');
  138. const elInvert         = document.getElementById('invertPalette');
  139. const elPalette        = document.getElementById('palette');
  140. const elPaletteRepeat  = document.getElementById('paletteRepeat');
  141. const elAutoCheck      = document.getElementById('autoCheck');
  142. const elHotTrack       = document.getElementById('hotTrack');
  143. const elAutoRangeExtra  = document.getElementById('autoRangeExtra');
  144. const elAutoRangeOffset = document.getElementById('autoRangeOffset');
  145. const elShowHistogram  = document.getElementById('showHistogram');
  146. const elHistSection    = document.getElementById('histogramSection');
  147. const elStats          = document.getElementById('stats');
  148. const elOffsetVal      = document.getElementById('offsetVal');
  149. const elRangeVal       = document.getElementById('rangeVal');
  150. const elHistCanvas     = document.getElementById('histogramCanvas');
  151. const elPalCanvas      = document.getElementById('paletteCanvas');
  152. const elClaheEnable    = document.getElementById('claheEnable');
  153. const elClaheClip      = document.getElementById('claheClip');
  154. const elClaheTiles     = document.getElementById('claheTiles');
  155. const elRefMode        = document.getElementById('refMode');
  156. const elRefStrength    = document.getElementById('refStrength');
  157. const elRefStatus      = document.getElementById('refStatus');
  158. const elNoiseEnable    = document.getElementById('noiseEnable');
  159. const elNoiseAlgo      = document.getElementById('noiseAlgo');
  160. const elNoiseStrength  = document.getElementById('noiseStrength');
  161. const elAvgEnable      = document.getElementById('avgEnable');
  162. const elAvgFrames      = document.getElementById('avgFrames');
  163. const elDarkEnable     = document.getElementById('darkEnable');
  164. const elDarkStatus     = document.getElementById('darkStatus');
  165.  
  166.  
  167. const palettes = {
  168.   gray:   (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[i,i,i];},
  169.   iron:   (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;
  170.     if(i<64)return[0,0,i*4];if(i<128)return[(i-64)*4,0,255];
  171.     if(i<192)return[255,(i-128)*4,255-(i-128)*4];return[255,255,(i-192)*4];
  172.   },
  173.   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];},
  174.   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)];},
  175.   cool:   (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[i,255-i,255];},
  176.   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];},
  177.   viridis:(v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[68+n*187,1+n*204,84+n*171];},
  178.   plasma: (v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[12+n*243,7+n*76,134+n*121];},
  179.   magma:  (v,mn,mx)=>{const n=(v-mn)/(mx-mn),i=n*255;return[Math.max(0,i*2-100),0,i];},
  180.   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)];},
  181.   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];},
  182.   ice:    (v,mn,mx)=>{const n=(v-mn)/(mx-mn);return[200-n*200,220-n*100,255];},
  183.   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];},
  184.   wavelength: (v,mn,mx) => {
  185.     const n = (v - mn) / (mx - mn);
  186.     const lmbd = 380 + n * 260;
  187.     let r=0, g=0, b=0;
  188.     if (lmbd >= 380 && lmbd < 440) { r = -(lmbd - 440) / 60; g = 0; b = 1; }
  189.     else if (lmbd < 490)            { r = 0; g = (lmbd - 440) / 50; b = 1; }
  190.     else if (lmbd < 510)            { r = 0; g = 1; b = -(lmbd - 510) / 20; }
  191.     else if (lmbd < 580)            { r = (lmbd - 510) / 70; g = 1; b = 0; }
  192.     else if (lmbd <= 645)           { r = 1; g = -(lmbd - 645) / 65; b = 0; }
  193.     let f = 1;
  194.     if (lmbd < 420) f = 0.3 + 0.7 * (lmbd - 380) / 40;
  195.     else if (lmbd > 700) f = 0.3 + 0.7 * (750 - lmbd) / 50;
  196.     return [r * f * 255, g * f * 255, b * f * 255];
  197.   }
  198. };
  199.  
  200.  
  201. function generatePaletteLUT(minVal, maxVal, repeat, palName, invert) {
  202.   if (lastMin === minVal && lastMax === maxVal && lastRepeat === repeat &&
  203.       lastPalette === palName && lastInvert === invert) return;
  204.   lastMin = minVal; lastMax = maxVal; lastRepeat = repeat;
  205.   lastPalette = palName; lastInvert = invert;
  206.  
  207.   const pal = palettes[palName];
  208.   for (let v = 0; v < 65536; v++) {
  209.     let n = (v - minVal) / (maxVal - minVal);
  210.     if (n < 0) n = 0; else if (n > 1) n = 1;
  211.     n = (n * repeat) % 2;
  212.     if (n > 1) n = 2 - n;
  213.     if (invert) n = 1 - n;
  214.     const val = minVal + n * (maxVal - minVal);
  215.     const [r, g, b] = pal(val, minVal, maxVal);
  216.     const base = v * 4;
  217.     paletteLUT[base] = r; paletteLUT[base+1] = g;
  218.     paletteLUT[base+2] = b; paletteLUT[base+3] = 255;
  219.   }
  220. }
  221.  
  222.  
  223. // Inlined Laplacian - pre-allocated output, no per-frame allocation
  224. function sharpenFunc(src, width, height, sharpness) {
  225.   const alpha = sharpness / 100, beta = 1 - alpha;
  226.   sharpenedBuffer.set(src.subarray(0, width));
  227.   sharpenedBuffer.set(src.subarray((height - 1) * width), (height - 1) * width);
  228.   for (let y = 1; y < height - 1; y++) {
  229.     const row = y * width;
  230.     sharpenedBuffer[row] = src[row];
  231.     sharpenedBuffer[row + width - 1] = src[row + width - 1];
  232.     for (let x = 1; x < width - 1; x++) {
  233.       const idx = row + x;
  234.       const sum = 5*src[idx] - src[idx-width] - src[idx+width] - src[idx-1] - src[idx+1];
  235.       const b = src[idx] * beta + sum * alpha;
  236.       sharpenedBuffer[idx] = b < 0 ? 0 : b > 65535 ? 65535 : b;
  237.     }
  238.   }
  239.   return sharpenedBuffer;
  240. }
  241.  
  242.  
  243. // Reference frame operations.
  244. // All modes use the current display offset as the neutral center so that results
  245. // stay inside the visible display range (the data lives near offset, not 0 or 32768).
  246. // Strength 0-100 blends from passthrough (0) to full effect (100).
  247. function applyRefFrame(src, ref, mode, strength) {
  248.   const str = strength / 100;
  249.   const center = parseInt(elOffset.value);
  250.   for (let i = 0; i < 307200; i++) {
  251.     let v;
  252.     const r = ref[i] || 1;
  253.     if (mode === 'sub') {
  254.       // Show difference from reference, centered at display offset.
  255.       // Neutral (src==ref) -> center. Hotter than ref -> above center.
  256.       v = src[i] + (center - r) * str;
  257.     } else if (mode === 'add') {
  258.       // Overlay reference deviations from neutral onto current frame.
  259.       // Ref hot spot (ref > center) adds warmth; ref cold spot subtracts.
  260.       v = src[i] + (r - center) * str;
  261.     } else {
  262.       v = src[i] + (r - center) * str;
  263.     }
  264.     refApplyBuffer[i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
  265.   }
  266.   return refApplyBuffer;
  267. }
  268.  
  269.  
  270. // Dark frame: subtract fixed-pattern sensor noise, re-center at dark_mean so
  271. // output stays in the same value range as the raw sensor data.
  272. // result = live - dark + dark_mean
  273. // Pixels that exactly match the dark frame come out at dark_mean (neutral).
  274. function applyDarkFrame(src) {
  275.   for (let i = 0; i < 307200; i++) {
  276.     const v = src[i] - darkFrame[i] + darkMean;
  277.     darkBuffer[i] = v < 0 ? 0 : v > 65535 ? 65535 : v;
  278.   }
  279.   return darkBuffer;
  280. }
  281.  
  282.  
  283. // Frame averaging: ring buffer of raw frames, averaged into avgOutput
  284. function applyFrameAvg(src, nFrames) {
  285.   nFrames = Math.min(AVG_MAX, nFrames);
  286.   avgRingBuf.set(src, avgIdx * 307200);
  287.   avgIdx = (avgIdx + 1) % nFrames;
  288.   if (avgCount < nFrames) avgCount++;
  289.   const cnt = avgCount;
  290.   avgAccm.fill(0);
  291.   for (let f = 0; f < cnt; f++) {
  292.     const base = ((avgIdx - 1 - f + nFrames) % nFrames) * 307200;
  293.     for (let i = 0; i < 307200; i++) avgAccm[i] += avgRingBuf[base + i];
  294.   }
  295.   const inv = 1 / cnt;
  296.   for (let i = 0; i < 307200; i++) avgOutput[i] = avgAccm[i] * inv;
  297.   return avgOutput;
  298. }
  299.  
  300.  
  301. // --- Noise reduction ---
  302.  
  303. function nrTemporal(src, nFrames) {
  304.   nFrames = Math.max(2, Math.min(TEMPORAL_MAX, nFrames));
  305.   // Write current frame into ring buffer slot, then advance index
  306.   temporalBuf.set(src, tIdx * 307200);
  307.   tIdx = (tIdx + 1) % nFrames;
  308.   if (tCount < nFrames) tCount++;
  309.   const cnt = tCount;
  310.   temporalAccm.fill(0);
  311.   for (let f = 0; f < cnt; f++) {
  312.     const base = ((tIdx - 1 - f + nFrames) % nFrames) * 307200;
  313.     for (let i = 0; i < 307200; i++) temporalAccm[i] += temporalBuf[base + i];
  314.   }
  315.   const inv = 1 / cnt;
  316.   for (let i = 0; i < 307200; i++) noiseBuffer[i] = temporalAccm[i] * inv;
  317.   return noiseBuffer;
  318. }
  319.  
  320. // Separable Gaussian: horizontal then vertical pass, kernel size from radius
  321. function nrGaussian(src, strength) {
  322.   const radius = strength <= 2 ? 1 : strength <= 5 ? 2 : 3;
  323.   const kernels = [[1,2,1],[1,4,6,4,1],[1,6,15,20,15,6,1]];
  324.   const k = kernels[radius - 1];
  325.   const ksum = k.reduce((a,b) => a+b, 0);
  326.   const W = 640, H = 480;
  327.   // Horizontal pass into gaussTmp
  328.   for (let y = 0; y < H; y++) {
  329.     const row = y * W;
  330.     for (let x = 0; x < W; x++) {
  331.       let sum = 0;
  332.       for (let t = 0; t < k.length; t++) {
  333.         const xi = x + t - radius;
  334.         sum += src[row + (xi < 0 ? 0 : xi >= W ? W-1 : xi)] * k[t];
  335.       }
  336.       gaussTmp[row + x] = sum / ksum;
  337.     }
  338.   }
  339.   // Vertical pass into noiseBuffer
  340.   for (let y = 0; y < H; y++) {
  341.     const row = y * W;
  342.     for (let x = 0; x < W; x++) {
  343.       let sum = 0;
  344.       for (let t = 0; t < k.length; t++) {
  345.         const yi = y + t - radius;
  346.         sum += gaussTmp[(yi < 0 ? 0 : yi >= H ? H-1 : yi) * W + x] * k[t];
  347.       }
  348.       noiseBuffer[row + x] = sum / ksum;
  349.     }
  350.   }
  351.   return noiseBuffer;
  352. }
  353.  
  354. // Median filter using insertion sort (fast for small kernels)
  355. function nrMedian(src, strength) {
  356.   const radius = strength >= 5 ? 2 : 1;
  357.   const W = 640, H = 480;
  358.   const n = (radius*2+1) ** 2;
  359.   const mid = n >> 1;
  360.   for (let y = 0; y < H; y++) {
  361.     const row = y * W;
  362.     for (let x = 0; x < W; x++) {
  363.       let cnt = 0;
  364.       for (let dy = -radius; dy <= radius; dy++) {
  365.         const yi = (y+dy < 0 ? 0 : y+dy >= H ? H-1 : y+dy) * W;
  366.         for (let dx = -radius; dx <= radius; dx++) {
  367.           const xi = x+dx < 0 ? 0 : x+dx >= W ? W-1 : x+dx;
  368.           medVals[cnt++] = src[yi + xi];
  369.         }
  370.       }
  371.       // Insertion sort
  372.       for (let i = 1; i < n; i++) {
  373.         const v = medVals[i]; let j = i - 1;
  374.         while (j >= 0 && medVals[j] > v) { medVals[j+1] = medVals[j]; j--; }
  375.         medVals[j+1] = v;
  376.       }
  377.       noiseBuffer[row + x] = medVals[mid];
  378.     }
  379.   }
  380.   return noiseBuffer;
  381. }
  382.  
  383. // Bilateral: edge-preserving blur using precomputed spatial weights and range LUT.
  384. // Range LUT is rebuilt only when sigmaR changes (sigmaR = strength * 8).
  385. function nrBilateral(src, strength) {
  386.   const sigmaR = Math.max(4, strength * 8);
  387.   if (sigmaR !== lastBilSigmaR) {
  388.     lastBilSigmaR = sigmaR;
  389.     const inv = 1 / (2 * sigmaR * sigmaR);
  390.     for (let d = 0; d < 65536; d++) bilRangeLUT[d] = Math.exp(-d * d * inv);
  391.   }
  392.   const radius = strength >= 5 ? 2 : 1;
  393.   const sw = radius === 1 ? bilSpatial3 : bilSpatial5;
  394.   const W = 640, H = 480;
  395.   for (let y = 0; y < H; y++) {
  396.     const row = y * W;
  397.     for (let x = 0; x < W; x++) {
  398.       const c = src[row + x];
  399.       let wsum = 0, vsum = 0, si = 0;
  400.       for (let dy = -radius; dy <= radius; dy++) {
  401.         const yi = (y+dy < 0 ? 0 : y+dy >= H ? H-1 : y+dy) * W;
  402.         for (let dx = -radius; dx <= radius; dx++) {
  403.           const v = src[yi + (x+dx < 0 ? 0 : x+dx >= W ? W-1 : x+dx)];
  404.           const diff = v > c ? v - c : c - v;
  405.           const w = sw[si++] * bilRangeLUT[diff];
  406.           wsum += w; vsum += v * w;
  407.         }
  408.       }
  409.       noiseBuffer[row + x] = vsum / wsum;
  410.     }
  411.   }
  412.   return noiseBuffer;
  413. }
  414.  
  415. function applyNoise(src, algo, strength) {
  416.   if (algo === 'temporal')  return nrTemporal(src, strength);
  417.   if (algo === 'gauss')     return nrGaussian(src, strength);
  418.   if (algo === 'median')    return nrMedian(src, strength);
  419.   return nrBilateral(src, strength);
  420. }
  421.  
  422.  
  423. // Recompute per-pixel tile index/weight maps when tile count changes
  424. function ensureCLAHETileMap(nx, ny) {
  425.   if (nx === lastClaheNX && ny === lastClaheNY) return;
  426.   lastClaheNX = nx; lastClaheNY = ny;
  427.   const tw = 640 / nx, th = 480 / ny;
  428.   for (let x = 0; x < 640; x++) {
  429.     const tx = (x + 0.5) / tw - 0.5;
  430.     const tx0 = Math.max(0, Math.min(nx - 1, Math.floor(tx)));
  431.     claheTX0[x] = tx0;
  432.     claheTX1[x] = Math.min(nx - 1, tx0 + 1);
  433.     claheFX[x]  = Math.max(0, tx - tx0);
  434.   }
  435.   for (let y = 0; y < 480; y++) {
  436.     const ty = (y + 0.5) / th - 0.5;
  437.     const ty0 = Math.max(0, Math.min(ny - 1, Math.floor(ty)));
  438.     claheTY0[y] = ty0;
  439.     claheTY1[y] = Math.min(ny - 1, ty0 + 1);
  440.     claheFY[y]  = Math.max(0, ty - ty0);
  441.   }
  442. }
  443.  
  444. // Contrast Limited Adaptive Histogram Equalization.
  445. // Operates over the display range [minVal, maxVal] using CLAHE_BINS bins.
  446. // clipLimit: clip threshold as a multiple of the uniform histogram height.
  447. // Result is remapped back to [minVal, maxVal] for normal LUT colorization.
  448. function applyCLAHE(src, width, height, clipLimit, nx, ny, minVal, maxVal) {
  449.   ensureCLAHETileMap(nx, ny);
  450.   const tw = width / nx, th = height / ny;
  451.   const valRange = maxVal - minVal || 1;
  452.   const tobin = (CLAHE_BINS - 1) / valRange;
  453.  
  454.   // --- Step 1: Per-tile histogram, clip, and CDF ---
  455.   claheTileHist.fill(0);
  456.   for (let ty = 0; ty < ny; ty++) {
  457.     const y0 = Math.round(ty * th), y1 = Math.round((ty + 1) * th);
  458.     for (let tx = 0; tx < nx; tx++) {
  459.       const x0 = Math.round(tx * tw), x1 = Math.round((tx + 1) * tw);
  460.       const base = (ty * nx + tx) * CLAHE_BINS;
  461.       let pixels = 0;
  462.       for (let y = y0; y < y1; y++) {
  463.         const row = y * width;
  464.         for (let x = x0; x < x1; x++) {
  465.           let bin = Math.round((src[row + x] - minVal) * tobin);
  466.           if (bin < 0) bin = 0; else if (bin >= CLAHE_BINS) bin = CLAHE_BINS - 1;
  467.           claheTileHist[base + bin]++;
  468.           pixels++;
  469.         }
  470.       }
  471.       // Clip excess and redistribute uniformly across all bins
  472.       const clipThresh = Math.max(1, clipLimit * pixels / CLAHE_BINS);
  473.       let excess = 0;
  474.       for (let b = 0; b < CLAHE_BINS; b++) {
  475.         const h = claheTileHist[base + b];
  476.         if (h > clipThresh) { excess += h - clipThresh; claheTileHist[base + b] = clipThresh; }
  477.       }
  478.       const add = excess / CLAHE_BINS;
  479.       // Build normalized CDF into claheTileCDF
  480.       let cum = 0;
  481.       for (let b = 0; b < CLAHE_BINS; b++) {
  482.         cum += claheTileHist[base + b] + add;
  483.         claheTileCDF[base + b] = cum;
  484.       }
  485.       const cdfMax = claheTileCDF[base + CLAHE_BINS - 1];
  486.       if (cdfMax > 0) {
  487.         for (let b = 0; b < CLAHE_BINS; b++) claheTileCDF[base + b] /= cdfMax;
  488.       }
  489.     }
  490.   }
  491.  
  492.   // --- Step 2: Bilinear interpolation of 4 surrounding tile CDFs per pixel ---
  493.   for (let y = 0; y < height; y++) {
  494.     const ty0 = claheTY0[y], ty1 = claheTY1[y], yf = claheFY[y], y1f = 1 - yf;
  495.     const row = y * width;
  496.     for (let x = 0; x < width; x++) {
  497.       let bin = Math.round((src[row + x] - minVal) * tobin);
  498.       if (bin < 0) bin = 0; else if (bin >= CLAHE_BINS) bin = CLAHE_BINS - 1;
  499.       const tx0 = claheTX0[x], tx1 = claheTX1[x], xf = claheFX[x];
  500.       const b00 = claheTileCDF[(ty0 * nx + tx0) * CLAHE_BINS + bin];
  501.       const b10 = claheTileCDF[(ty0 * nx + tx1) * CLAHE_BINS + bin];
  502.       const b01 = claheTileCDF[(ty1 * nx + tx0) * CLAHE_BINS + bin];
  503.       const b11 = claheTileCDF[(ty1 * nx + tx1) * CLAHE_BINS + bin];
  504.       const mapped = b00*(1-xf)*y1f + b10*xf*y1f + b01*(1-xf)*yf + b11*xf*yf;
  505.       claheBuffer[row + x] = Math.round(minVal + mapped * valRange);
  506.     }
  507.   }
  508.   return claheBuffer;
  509. }
  510.  
  511.  
  512. function updateImage() {
  513.   const offset    = parseInt(elOffset.value);
  514.   const range     = parseInt(elRange.value);
  515.   const sharpness = parseInt(elSharpness.value);
  516.   const invertPalette = elInvert.checked;
  517.  
  518.   const pal = palettes[elPalette.value];
  519.   if (!pal) { console.error('Invalid palette selected'); return; }
  520.  
  521.   const min16 = offset - range / 2;
  522.   const max16 = offset + range / 2;
  523.  
  524.   // Pipeline: dark frame -> frame avg -> noise reduction -> ref frame -> sharpen -> CLAHE -> LUT
  525.   let src = frameData;
  526.  
  527.   if (elDarkEnable.checked && darkFrame) {
  528.     src = applyDarkFrame(src);
  529.   }
  530.  
  531.   if (elAvgEnable.checked) {
  532.     src = applyFrameAvg(src, parseInt(elAvgFrames.value));
  533.   }
  534.  
  535.   if (elNoiseEnable.checked) {
  536.     src = applyNoise(src, elNoiseAlgo.value, parseInt(elNoiseStrength.value));
  537.   }
  538.  
  539.   const refMode = elRefMode.value;
  540.   if (refFrame && refMode !== 'off') {
  541.     src = applyRefFrame(src, refFrame, refMode, parseInt(elRefStrength.value));
  542.   }
  543.  
  544.   if (sharpness >= 1) {
  545.     src = sharpenFunc(src, 640, 480, sharpness);
  546.   }
  547.  
  548.   if (elClaheEnable.checked) {
  549.     src = applyCLAHE(src, 640, 480, parseFloat(elClaheClip.value),
  550.                      parseInt(elClaheTiles.value), parseInt(elClaheTiles.value),
  551.                      min16, max16);
  552.   }
  553.  
  554.   generatePaletteLUT(min16, max16, parseInt(elPaletteRepeat.value), elPalette.value, invertPalette);
  555.  
  556.   for (let i = 0; i < 307200; i++) {
  557.     imgData32[i] = paletteLUT32[src[i]];
  558.   }
  559.  
  560.   ctx.putImageData(imgData, 0, 0);
  561.  
  562.   if (elHotTrack.checked && hotPixel.x >= 0) {
  563.     const scaleX = canvas.width / 640;
  564.     const scaleY = canvas.height / 480;
  565.     const x = hotPixel.x * scaleX;
  566.     const y = hotPixel.y * scaleY;
  567.  
  568.     ctx.strokeStyle = 'red';
  569.     ctx.lineWidth = 2;
  570.     ctx.beginPath();
  571.     ctx.moveTo(x-10, y); ctx.lineTo(x+10, y);
  572.     ctx.moveTo(x, y-10); ctx.lineTo(x, y+10);
  573.     ctx.stroke();
  574.  
  575.     ctx.fillStyle = 'blue';
  576.     ctx.beginPath();
  577.     ctx.arc(x, y, 3, 0, 2*Math.PI);
  578.     ctx.fill();
  579.  
  580.     ctx.fillStyle = '#333';
  581.     ctx.fillRect(x+10, y+8, 36, 16);
  582.     ctx.fillStyle = 'white';
  583.     ctx.font = '14px monospace';
  584.     ctx.fillText(`${hotPixel.value}`, x+12, y+20);
  585.   }
  586.  
  587.   updateHistogram();
  588. }
  589.  
  590.  
  591. function updateHistogram() {
  592.   if (!elShowHistogram.checked) return;
  593.  
  594.   const hCtx = elHistCanvas.getContext('2d');
  595.   hCtx.clearRect(0, 0, elHistCanvas.width, elHistCanvas.height);
  596.  
  597.   const offset2 = parseInt(elOffset.value);
  598.   const range   = parseInt(elRange.value);
  599.   const minVal  = offset2 - range / 2;
  600.   const maxVal  = offset2 + range / 2;
  601.   if (range <= 0) return;
  602.  
  603.   // Re-bucket from statsHist (already computed by getStats) - avoids re-scanning 307,200 pixels
  604.   const binCount = range;
  605.   const bins = histBins.subarray(0, binCount);
  606.   bins.fill(0);
  607.   const vMin = Math.max(0, Math.ceil(minVal));
  608.   const vMax = Math.min(65535, Math.floor(maxVal));
  609.   const scale = (binCount - 1) / range;
  610.   for (let v = vMin; v <= vMax; v++) {
  611.     const b = Math.floor((v - minVal) * scale);
  612.     if (b >= 0 && b < binCount) bins[b] += statsHist[v];
  613.   }
  614.  
  615.   let maxCount = 0;
  616.   for (let b = 0; b < binCount; b++) { if (bins[b] > maxCount) maxCount = bins[b]; }
  617.   if (maxCount === 0) return;
  618.  
  619.   const maxLog = Math.log10(maxCount + 1);
  620.  
  621.   hCtx.strokeStyle = 'white';
  622.   hCtx.lineWidth = 1;
  623.   hCtx.beginPath();
  624.   for (let x = 0; x < elHistCanvas.width; x++) {
  625.     const bi   = x * (binCount - 1) / (elHistCanvas.width - 1);
  626.     const left = Math.floor(bi);
  627.     const frac = bi - left;
  628.     const bv   = bins[left] * (1 - frac) + bins[Math.min(binCount-1, left+1)] * frac;
  629.     const lc   = bv > 0 ? Math.log10(bv + 1) : 0;
  630.     const y    = elHistCanvas.height - (lc / maxLog) * (elHistCanvas.height - 20);
  631.     x === 0 ? hCtx.moveTo(x, y) : hCtx.lineTo(x, y);
  632.   }
  633.   hCtx.stroke();
  634.  
  635.   const offset = parseInt(elOffset.value);
  636.   if (offset >= minVal && offset <= maxVal) {
  637.     const ox = ((offset - minVal) / range) * elHistCanvas.width;
  638.     hCtx.strokeStyle = 'red'; hCtx.lineWidth = 2;
  639.     hCtx.beginPath(); hCtx.moveTo(ox, 0); hCtx.lineTo(ox, elHistCanvas.height); hCtx.stroke();
  640.   }
  641.  
  642.   hCtx.fillStyle = '#fff';
  643.   hCtx.font = '16px monospace';
  644.   hCtx.fillText(`${minVal}`, 5, elHistCanvas.height - 5);
  645.   hCtx.fillText(`${maxVal}`, elHistCanvas.width - 60, elHistCanvas.height - 5);
  646.   hCtx.fillText('Log Scale', 5, 15);
  647.  
  648.   if (elPalCanvas) {
  649.     const pctx = elPalCanvas.getContext('2d');
  650.     const w = elPalCanvas.width, h = elPalCanvas.height;
  651.     if (!palImgData || palImgData.width !== w || palImgData.height !== h) {
  652.       palImgData = pctx.createImageData(w, h);
  653.       palImgData32 = new Uint32Array(palImgData.data.buffer);
  654.     }
  655.     const inv = elInvert.checked;
  656.     for (let x = 0; x < w; x++) {
  657.       const xx  = inv ? w - 1 - x : x;
  658.       const val = minVal + (xx / (w - 1)) * (maxVal - minVal);
  659.       const li  = val < 0 ? 0 : val > 65535 ? 65535 : Math.round(val);
  660.       const pk  = paletteLUT32[li] || 0xFF000000;
  661.       for (let y = 0; y < h; y++) palImgData32[y * w + x] = pk;
  662.     }
  663.     pctx.putImageData(palImgData, 0, 0);
  664.   }
  665. }
  666.  
  667.  
  668. // Single pass: min/max/sum + statsHist + optional hot-pixel tracking.
  669. // statsHist is reused by updateHistogram to avoid a second full scan.
  670. function getStats() {
  671.   let min = Infinity, max = -Infinity, sum = 0;
  672.   let hotMax = -Infinity, hotIdx = -1;
  673.   const trackHot = elHotTrack.checked;
  674.  
  675.   statsHist.fill(0);
  676.   for (let i = 0; i < frameData.length; i++) {
  677.     const val = frameData[i];
  678.     if (val < min) min = val;
  679.     if (val > max) max = val;
  680.     sum += val;
  681.     statsHist[val]++;
  682.     if (trackHot && val > hotMax) { hotMax = val; hotIdx = i; }
  683.   }
  684.  
  685.   if (trackHot && hotIdx >= 0) {
  686.     hotPixel.x = hotIdx % 640;
  687.     hotPixel.y = Math.floor(hotIdx / 640);
  688.     hotPixel.value = hotMax;
  689.   }
  690.  
  691.   const margin = parseInt(elAutoRangeExtra.value);
  692.   const total  = frameData.length;
  693.   const p0001  = Math.floor(total * 0.0001);
  694.   const p9999  = Math.floor(total * 0.9999);
  695.  
  696.   let count = 0, min_p = min;
  697.   for (let v = 0; v < 65536; v++) {
  698.     count += statsHist[v];
  699.     if (count > p0001) { min_p = v; break; }
  700.   }
  701.   count = 0;
  702.   let max_p = max;
  703.   for (let v = 65535; v >= 0; v--) {
  704.     count += statsHist[v];
  705.     if (count > total - p9999) { max_p = v; break; }
  706.   }
  707.  
  708.   return {
  709.     min: min_p - margin, max: max_p + margin,
  710.     avg: Math.round(sum / frameData.length),
  711.     absolute_min: min, absolute_max: max
  712.   };
  713. }
  714.  
  715.  
  716. // Accepts pre-computed stats to avoid calling getStats() twice per frame
  717. function autoRange(stats) {
  718.   if (!stats) stats = getStats();
  719.   const aoOffset = parseInt(elAutoRangeOffset.value);
  720.   elOffset.value = Math.round((stats.min + stats.max) / 2) + aoOffset;
  721.   elRange.value  = stats.max - stats.min;
  722.   elOffsetVal.textContent = elOffset.value;
  723.   elRangeVal.textContent  = elRange.value;
  724.   updateImage();
  725. }
  726.  
  727.  
  728. async function startStream() {
  729.   let frames = 0, fps = 0;
  730.   setInterval(() => { fps = frames; frames = 0; }, 1000);
  731.   streaming = true;
  732.   let first = true;
  733.   document.getElementById('stream').textContent = 'Stop';
  734.  
  735.   const resp = await fetch(`/cgi-bin/proxy.cgi/stream?Type=RAW&Source=Raw&Frames=100000000`);
  736.   reader = resp.body.getReader();
  737.   let buffer = new Uint8Array(0);
  738.   let skipNext = true;
  739.  
  740.   if (!restartTimer) {
  741.     restartTimer = setInterval(() => {
  742.       if (Date.now() - lastFrameTime > 500) {
  743.         console.warn("Stream stalled. Restarting...");
  744.         stopStream(); startStream();
  745.       }
  746.     }, 200);
  747.   }
  748.  
  749.   while (streaming) {
  750.     const { done, value } = await reader.read();
  751.     if (done) break;
  752.     lastFrameTime = Date.now();
  753.  
  754.     const tmp = new Uint8Array(buffer.length + value.length);
  755.     tmp.set(buffer); tmp.set(value, buffer.length);
  756.     buffer = tmp;
  757.  
  758.     while (buffer.length >= 0x96014 + 4) {
  759.       let found = false;
  760.       for (let i = 0; i < buffer.length - 0x96014 - 3; i++) {
  761.         if (buffer[i]==0x66 && buffer[i+1]==0xDD && buffer[i+2]==0xDD && buffer[i+3]==0x66) {
  762.           const nextPos = i + 0x96014;
  763.           if (nextPos+3 < buffer.length &&
  764.               buffer[nextPos]==0x66 && buffer[nextPos+1]==0xDD &&
  765.               buffer[nextPos+2]==0xDD && buffer[nextPos+3]==0x66) {
  766.  
  767.             if (i+0x20 < buffer.length && buffer[i+0x18]==0x44 && buffer[i+0x19]==0x4C) {
  768.               buffer = buffer.slice(i + 0x224); found = true; break;
  769.             }
  770.             if (skipNext) {
  771.               skipNext = false; buffer = buffer.slice(i + 0x96014); found = true; break;
  772.             }
  773.  
  774.             const low  = buffer.slice(i + 0x14,    i + 0x4B014);
  775.             const high = buffer.slice(i + 0x4B014, i + 0x96014);
  776.  
  777.             for (let j = 0; j < 307200; j += 8) {
  778.               frameData[j]   = (high[j]   << 8) | low[j];
  779.               frameData[j+1] = (high[j+1] << 8) | low[j+1];
  780.               frameData[j+2] = (high[j+2] << 8) | low[j+2];
  781.               frameData[j+3] = (high[j+3] << 8) | low[j+3];
  782.               frameData[j+4] = (high[j+4] << 8) | low[j+4];
  783.               frameData[j+5] = (high[j+5] << 8) | low[j+5];
  784.               frameData[j+6] = (high[j+6] << 8) | low[j+6];
  785.               frameData[j+7] = (high[j+7] << 8) | low[j+7];
  786.             }
  787.  
  788.             frames++;
  789.  
  790.             // Single pass: stats + hot tracking + statsHist (reused by histogram)
  791.             const stats = getStats();
  792.             elStats.textContent =
  793.               `Min: ${stats.min} | Avg: ${stats.avg} | Max: ${stats.max} | FPS: ${fps}`;
  794.  
  795.             if (elAutoCheck.checked) autoRange(stats);
  796.             updateImage();
  797.             if (first) { first = false; autoRange(stats); }
  798.  
  799.             buffer = buffer.slice(i + 0x96014);
  800.             found = true; break;
  801.           }
  802.         }
  803.       }
  804.       if (!found) break;
  805.     }
  806.   }
  807. }
  808.  
  809.  
  810. function stopStream() {
  811.   streaming = false;
  812.   document.getElementById('stream').textContent = 'Start';
  813.   if (reader) { try { reader.cancel(); } catch (e) {} }
  814. }
  815.  
  816.  
  817. document.getElementById('stream').onclick = () => {
  818.   if (streaming) { streaming = false; reader?.cancel(); document.getElementById('stream').textContent = 'Start'; }
  819.   else startStream();
  820. };
  821.  
  822. document.getElementById('autoRange').onclick = () => { autoRange(); };
  823.  
  824. async function sendFFCCommand() {
  825.   try { await fetch(`/cgi-bin/dmcmd?Command=${encodeURIComponent('KBD,C')}`, { timeout: 5000 }); } catch (e) {}
  826. }
  827. document.getElementById('doFFC').onclick = () => { sendFFCCommand(); };
  828.  
  829. document.getElementById('save').onclick = () => {
  830.   canvas.toBlob(blob => {
  831.     const a = document.createElement('a');
  832.     a.href = URL.createObjectURL(blob);
  833.     a.download = 'thermal.png';
  834.     a.click();
  835.   });
  836. };
  837.  
  838. document.getElementById('captureDark').onclick = () => {
  839.   if (!darkFrame) darkFrame = new Uint16Array(307200);
  840.   darkFrame.set(frameData);
  841.   let sum = 0;
  842.   for (let i = 0; i < 307200; i++) sum += darkFrame[i];
  843.   darkMean = Math.round(sum / 307200);
  844.   elDarkStatus.textContent = `Dark captured (mean: ${darkMean})`;
  845.   elDarkStatus.style.color = '#8f8';
  846.   localStorage.setItem('thermal_darkFrame', arrayToB64(darkFrame));
  847.   localStorage.setItem('thermal_darkMean', darkMean);
  848.   updateImage();
  849. };
  850.  
  851. document.getElementById('clearDark').onclick = () => {
  852.   darkFrame = null;
  853.   elDarkStatus.textContent = 'No dark frame';
  854.   elDarkStatus.style.color = '#888';
  855.   localStorage.removeItem('thermal_darkFrame');
  856.   localStorage.removeItem('thermal_darkMean');
  857.   updateImage();
  858. };
  859.  
  860. document.getElementById('captureRef').onclick = () => {
  861.   if (!refFrame) refFrame = new Uint16Array(307200);
  862.   refFrame.set(frameData);
  863.   elRefStatus.textContent = 'Ref captured';
  864.   elRefStatus.style.color = '#8f8';
  865.   localStorage.setItem('thermal_refFrame', arrayToB64(refFrame));
  866.   updateImage();
  867. };
  868.  
  869. document.getElementById('clearRef').onclick = () => {
  870.   refFrame = null;
  871.   elRefStatus.textContent = 'No ref';
  872.   elRefStatus.style.color = '#888';
  873.   localStorage.removeItem('thermal_refFrame');
  874.   updateImage();
  875. };
  876.  
  877. elShowHistogram.addEventListener('change', () => {
  878.   elHistSection.style.display = elShowHistogram.checked ? '' : 'none';
  879.   if (elShowHistogram.checked) updateHistogram();
  880.   fitCanvas();
  881. });
  882.  
  883. // Sliders and selects that trigger updateImage + localStorage
  884. ['offset', 'range', 'palette', 'sharpness', 'autoRangeExtra', 'autoRangeOffset',
  885.  'claheClip', 'claheTiles', 'refStrength', 'refMode',
  886.  'noiseStrength', 'avgFrames'].forEach(id => {
  887.   const el = document.getElementById(id);
  888.   el.oninput = () => {
  889.     const valEl = document.getElementById(id + 'Val');
  890.     if (valEl) valEl.textContent = el.value;
  891.     localStorage.setItem('thermal_' + id, el.value);
  892.     updateImage();
  893.   };
  894. });
  895.  
  896. // Noise algo select: reset temporal buffer on change so stale frames don't bleed in
  897. elNoiseAlgo.addEventListener('change', () => {
  898.   tIdx = 0; tCount = 0;
  899.   localStorage.setItem('thermal_noiseAlgo', elNoiseAlgo.value);
  900.   updateImage();
  901. });
  902.  
  903. // Checkboxes
  904. ['claheEnable', 'showHistogram', 'noiseEnable', 'avgEnable', 'darkEnable'].forEach(id => {
  905.   document.getElementById(id).addEventListener('change', () => {
  906.     if (id === 'noiseEnable') { tIdx = 0; tCount = 0; }
  907.     if (id === 'avgEnable')   { avgIdx = 0; avgCount = 0; }
  908.     localStorage.setItem('thermal_' + id, document.getElementById(id).checked ? '1' : '0');
  909.     updateImage();
  910.   });
  911. });
  912.  
  913.  
  914. window.addEventListener('DOMContentLoaded', () => {
  915.   // Restore slider/select values
  916.   ['offset', 'range', 'palette', 'sharpness', 'autoRangeExtra', 'autoRangeOffset',
  917.    'claheClip', 'claheTiles', 'refStrength', 'refMode',
  918.    'noiseStrength', 'noiseAlgo', 'avgFrames'].forEach(id => {
  919.     const val = localStorage.getItem('thermal_' + id);
  920.     if (val !== null) {
  921.       document.getElementById(id).value = val;
  922.       const valEl = document.getElementById(id + 'Val');
  923.       if (valEl) valEl.textContent = val;
  924.     }
  925.   });
  926.   // Restore checkboxes
  927.   ['claheEnable', 'showHistogram', 'noiseEnable', 'avgEnable', 'darkEnable'].forEach(id => {
  928.     const val = localStorage.getItem('thermal_' + id);
  929.     if (val !== null) document.getElementById(id).checked = val === '1';
  930.   });
  931.   elHistSection.style.display = elShowHistogram.checked ? '' : 'none';
  932.  
  933.   // Restore dark frame
  934.   const savedDark = localStorage.getItem('thermal_darkFrame');
  935.   if (savedDark) {
  936.     try {
  937.       darkFrame = b64ToUint16Array(savedDark);
  938.       darkMean = parseInt(localStorage.getItem('thermal_darkMean')) || 0;
  939.       elDarkStatus.textContent = `Dark captured (mean: ${darkMean})`;
  940.       elDarkStatus.style.color = '#8f8';
  941.     } catch (e) {
  942.       localStorage.removeItem('thermal_darkFrame');
  943.       localStorage.removeItem('thermal_darkMean');
  944.     }
  945.   }
  946.  
  947.   // Restore reference frame
  948.   const savedRef = localStorage.getItem('thermal_refFrame');
  949.   if (savedRef) {
  950.     try {
  951.       refFrame = b64ToUint16Array(savedRef);
  952.       elRefStatus.textContent = 'Ref captured';
  953.       elRefStatus.style.color = '#8f8';
  954.     } catch (e) {
  955.       localStorage.removeItem('thermal_refFrame');
  956.     }
  957.   }
  958.  
  959.   requestAnimationFrame(fitCanvas);
  960.   startStream();
  961.   updateImage();
  962. });
  963.  
  964.  
  965. function updateOffsetRangeState() {
  966.   const auto = elAutoCheck.checked;
  967.   elOffset.disabled = auto; elRange.disabled = auto;
  968.   elOffsetVal.style.opacity = auto ? 0.5 : 1;
  969.   elRangeVal.style.opacity  = auto ? 0.5 : 1;
  970. }
  971. document.getElementById('autoCheck').addEventListener('change', updateOffsetRangeState);
  972. window.addEventListener('DOMContentLoaded', updateOffsetRangeState);
  973.  
Add Comment
Please, Sign In to add comment