From f5904f363e4f52a7c668a3e276ccf9b256644768 Mon Sep 17 00:00:00 2001 From: florian Date: Tue, 16 Jun 2026 02:01:14 +0200 Subject: [PATCH 1/2] Unify colormaps: render-time apply (pseudocolor) + pipeline-based decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the colormap feature into one unified, non-destructive design with two directions, both flowing through the central ImageRenderer pipeline so they work for every format and for layers. - Add shared media/modules/colormaps.js as the single source of truth: getColormapLut() (cached 256-entry RGB LUT) and rgbToColormapIndex() (cached 32^3 inverse cube for O(N) decode). - Apply ("add color"): new displayColormap render setting, hooked once in ImageRenderer.render() — single-channel grayscale output is remapped through the colormap LUT (NaN pixels preserved). Reversible and non-destructive; works in layers since the compositor uses the same path. - Decode ("remove color"): rewrite the broken canvas-mutating converter. The decoded scalar becomes a first-class single-channel float source that re-renders through ImageRenderer (so normalization/gamma/apply-colormap all work) and feeds the pixel inspector via a new decodedValueProvider. - UX: context menu splits into "Apply Colormap…"/"Remove Colormap" for single-channel images (fixes the missing option on float TIFFs) and "Decode Colormap to Float" for RGB images; add tiffVisualizer.applyColormap command; persist displayColormap across tab switches. - Guard the PNG lazy-native fast path so a requested colormap forces the central pipeline. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 10 +- CLAUDE.md | 9 +- media/imagePreview.js | 215 +++++++------ media/modules/colormap-converter.js | 418 ++++---------------------- media/modules/colormaps.js | 231 ++++++++++++++ media/modules/mouse-handler.js | 17 ++ media/modules/normalization-helper.js | 39 +++ media/modules/png-processor.js | 3 +- media/modules/settings-manager.js | 6 +- package.json | 7 +- src/imagePreview/commands.ts | 46 +++ 11 files changed, 550 insertions(+), 451 deletions(-) create mode 100644 media/modules/colormaps.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 2bf692e..56faadd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,17 @@ # Change Log -## 1.7.0 +## 1.8.0 + +- Add more compression support +- Improve colormap support + +## 1.7.0 (2026-06-14) - Add more file formats: webp, hdr, jxl, tga, bmp and ico files - Renamed extension to Scientific Image Visualizer +- Improving the collection feature to have multiple images next to each other. +- Adding a layer feature allowing to compose multiple images with each other. +- Reworked colormaps into a unified feature: apply a colormap (pseudocolor) to any single-channel image as a non-destructive render setting that also works in layers, and decode colormapped RGB images back to float through the central pipeline. ## 1.6.0 (2026-03-30) diff --git a/CLAUDE.md b/CLAUDE.md index 2c5ea71..265f230 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,7 +87,8 @@ The webview ([media/imagePreview.js](media/imagePreview.js)) uses ES6 modules fo - **ZoomController**: Pan/zoom with mouse/trackpad - **MouseHandler**: Pixel inspection, hover effects - **HistogramOverlay**: Interactive histogram with draggable overlay, linear/sqrt scale toggle, bin hover tooltips, and per-channel display -- **ColormapConverter**: Converts colormap images to float values using various colormaps (viridis, plasma, jet, etc.) +- **Colormaps** ([media/modules/colormaps.js](media/modules/colormaps.js)): Shared source of truth for all colormaps (viridis, plasma, jet, etc.) — `getColormapLut()` (forward 256-entry RGB LUT, used for render-time "apply"/pseudocolor) and `rgbToColormapIndex()` (cached 32³ inverse cube, used for "decode") +- **ColormapConverter** ([media/modules/colormap-converter.js](media/modules/colormap-converter.js)): "Decode" direction — recovers scalar float values from a colormapped RGB image using the shared inverse lookup ### Status Bar Integration Comprehensive status bar system with specialized entries: @@ -144,7 +145,8 @@ All commands registered with `tiffVisualizer.` prefix ([src/imagePreview/command - **Collection**: `browseAndAddToCollection` — adds a file to the active preview's image collection; accepts an optional URI (Explorer context menu) or prompts with a glob/path picker (command palette) - **Comparison**: `selectForCompare`, `compareWithSelected`, `openComparisonPanel` - **Filters**: `filterByMask`, `toggleNanColor` -- **Colormap conversion**: `convertColormapToFloat` - converts colormap images to float values +- **Colormap apply**: `applyColormap` - sets the render-time `displayColormap` (pseudocolor) on single-channel images; non-destructive and applied in `ImageRenderer.render()`, so it also works in layers +- **Colormap decode**: `convertColormapToFloat` - recovers scalar float values from a colormapped RGB image (inverse colormap) - **Reset**: `resetAllSettings` - **Histogram**: `toggleHistogram` (Ctrl+H / Cmd+H) - **Color picker mode**: `toggleColorPickerMode` — switches pixel inspector between original and modified (gamma/exposure) values @@ -294,7 +296,8 @@ media/ │ ├── zoom-controller.js # Pan/zoom functionality │ ├── mouse-handler.js # Pixel inspection │ ├── histogram-overlay.js # Histogram visualization -│ └── colormap-converter.js # Colormap to float conversion +│ ├── colormaps.js # Shared colormap LUTs + inverse lookup (apply & decode) +│ └── colormap-converter.js # Colormap decode (RGB → float, inverse colormap) ├── geotiff.min.js # TIFF processing library (copied from node_modules) ├── parse-exr.js # EXR processing library ├── upng.min.js # PNG decoding diff --git a/media/imagePreview.js b/media/imagePreview.js index 88140ee..d6dd8ec 100644 --- a/media/imagePreview.js +++ b/media/imagePreview.js @@ -17,6 +17,7 @@ import { ZoomController } from './modules/zoom-controller.js'; import { MouseHandler } from './modules/mouse-handler.js'; import { HistogramOverlay } from './modules/histogram-overlay.js'; import { ColormapConverter } from './modules/colormap-converter.js'; +import { ImageRenderer, ImageStatsCalculator } from './modules/normalization-helper.js'; import { DecodeWorkerClient } from './modules/decode-worker-client.js'; import { PerfTrace } from './modules/perf-trace.js'; import { LayerManager } from './modules/layer-manager.js'; @@ -114,6 +115,13 @@ import { LayersPanel } from './modules/layers-panel.js'; // Pixel inspector reads the composite value when compositing is active. mouseHandler.compositeValueProvider = (x, y) => (layerManager.active && layerManager.hasExtraLayers()) ? layerManager.getCompositeValueAt(x, y) : null; + // Pixel inspector reads the decoded scalar when a colormap has been decoded. + mouseHandler.decodedValueProvider = (x, y) => { + if (!decodedColormapSource) { return null; } + const { floatData, width, height } = decodedColormapSource; + if (x < 0 || y < 0 || x >= width || y >= height) { return null; } + return floatData[y * width + x]; + }; /** @type {string|undefined} URI of the image currently used as the base layer. */ let _layerBaseUri; @@ -189,6 +197,13 @@ import { LayersPanel } from './modules/layers-panel.js'; let originalImageData = null; let hasAppliedConversion = false; + // Decoded single-channel float data produced by "Decode Colormap to Float". + // When set, it becomes the active single-image source: it renders through the + // central ImageRenderer pipeline (so normalization/gamma/display-colormap all + // apply) and feeds the pixel inspector. + /** @type {{floatData: Float32Array, width: number, height: number}|null} */ + let decodedColormapSource = null; + // Copied position state (for paste position feature) // Stores position as relative coordinates (0-1) for cross-resolution compatibility /** @type {CopiedPosition|null} */ @@ -202,6 +217,9 @@ import { LayersPanel } from './modules/layers-panel.js'; peerImageUris = persistedState.peerImageUris || []; isShowingPeer = persistedState.isShowingPeer || false; colormapConversionState = persistedState.colormapConversionState || null; + if (persistedState.displayColormap) { + settingsManager.settings.displayColormap = persistedState.displayColormap; + } // Note: Histogram visibility is now managed globally by the extension // and restored via restoreHistogramState message when webview becomes active const savedLayers = persistedState.layers; @@ -238,6 +256,7 @@ import { LayersPanel } from './modules/layers-panel.js'; isShowingPeer: isShowingPeer, currentResourceUri: settingsManager.settings.resourceUri, colormapConversionState: colormapConversionState, + displayColormap: settingsManager.settings.displayColormap, isHistogramVisible: histogramOverlay.getVisibility(), // Include zoom so it isn't erased when the app-level state is written scale: zoomState.scale, @@ -1729,9 +1748,32 @@ import { LayersPanel } from './modules/layers-panel.js'; // Revert to the original image handleRevertToOriginal(); break; + + case 'setDisplayColormap': + // Apply (or clear) a render-time pseudocolor colormap. + await handleSetDisplayColormap(message.colormap || 'none'); + break; } } + /** + * Set the render-time display colormap (pseudocolor) and re-render. Pass + * 'none' to clear it. Applies to single-channel images and to layers, since + * everything renders through ImageRenderer which reads this setting. + * @param {string} colormapName + */ + async function handleSetDisplayColormap(colormapName) { + settingsManager.settings.displayColormap = colormapName; + saveState(); + await updateImageWithNewSettings({ + changed: true, + changedKeys: ['displayColormap'], + parametersOnly: true, + changedMasks: false, + changedStructure: false + }); + } + /** * Update histogram with current image data. * Uses raw image data when available for accurate value representation. @@ -1878,11 +1920,48 @@ import { LayersPanel } from './modules/layers-panel.js'; } /** - * Convert colormap image to float values - * @param {string} colormapName - Name of the colormap to use - * @param {number} minValue - Minimum value to map to - * @param {number} maxValue - Maximum value to map to - * @param {boolean} inverted - Whether to invert the mapping + * Clear the cached raw data of every format processor. Used when the decoded + * colormap scalar takes over as the active single-image source. + */ + function clearAllProcessorRawData() { + tiffProcessor.rawTiffData = null; + if (exrProcessor) exrProcessor.rawExrData = undefined; + if (npyProcessor) npyProcessor._lastRaw = null; + if (ppmProcessor) ppmProcessor._lastRaw = null; + if (pfmProcessor) pfmProcessor._lastRaw = null; + if (pngProcessor) pngProcessor._lastRaw = null; + if (hdrProcessor) hdrProcessor._lastRaw = null; + if (tgaProcessor) tgaProcessor._lastRaw = null; + if (webImageProcessor) webImageProcessor._lastRaw = null; + if (jxlProcessor) jxlProcessor._lastRaw = null; + if (rawProcessor) rawProcessor._lastRaw = null; + } + + /** + * Render the decoded colormap scalar source through the central pipeline, + * honoring the current normalization / gamma / display-colormap settings. + */ + async function renderDecodedColormapSource() { + if (!decodedColormapSource || !canvas) { return; } + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { return; } + const { floatData, width, height } = decodedColormapSource; + const stats = ImageStatsCalculator.calculateFloatStats(floatData, width, height, 1); + const imageData = ImageRenderer.render( + floatData, width, height, 1, true, stats, + settingsManager.settings, { nanColor: getNanColorObj() } + ); + await renderImageDataToCanvas(imageData, ctx); + primaryImageData = imageData; + updateHistogramData(); + } + + /** + * Decode a colormapped image to scalar float values (inverse colormap). + * @param {string} colormapName - Name of the colormap used in the image + * @param {number} minValue - Value mapped to the start of the colormap + * @param {number} maxValue - Value mapped to the end of the colormap + * @param {boolean} inverted - Whether the colormap was applied inverted * @param {boolean} logarithmic - Whether to use logarithmic mapping */ async function handleColormapConversion(colormapName, minValue, maxValue, inverted, logarithmic) { @@ -1898,10 +1977,12 @@ import { LayersPanel } from './modules/layers-panel.js'; return; } - // Get the current image data from canvas + // Read the displayed RGB pixels (the true colormap colors at the + // current display) and invert the colormap to recover scalar values. const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + const width = imageData.width; + const height = imageData.height; - // Convert to float using the colormap const floatData = colormapConverter.convertToFloat( imageData, colormapName, @@ -1911,84 +1992,34 @@ import { LayersPanel } from './modules/layers-panel.js'; logarithmic ); - // Create a new ImageData for the float visualization - // We'll render it as if it's a float TIFF - const width = imageData.width; - const height = imageData.height; - - // Store the float data for display - // Create a temporary processor-like object to handle the float data - const floatImageData = new ImageData(width, height); + // The decoded scalar becomes the active single-image source. Clearing + // the per-processor raw data ensures settings re-renders go through the + // decoded-source path below instead of re-rendering the original image. + decodedColormapSource = { floatData, width, height }; + clearAllProcessorRawData(); - // Enable auto-normalization and set the range + // Switch to a float view of the decoded range. if (settingsManager.settings.normalization) { settingsManager.settings.normalization.autoNormalize = true; settingsManager.settings.normalization.min = minValue; settingsManager.settings.normalization.max = maxValue; } - // Normalize float values to 0-255 for display - for (let i = 0; i < floatData.length; i++) { - const value = floatData[i]; - // Normalize to 0-255 - const normalized = ((value - minValue) / (maxValue - minValue)) * 255; - const clamped = Math.max(0, Math.min(255, normalized)); - - const offset = i * 4; - floatImageData.data[offset] = clamped; // R - floatImageData.data[offset + 1] = clamped; // G - floatImageData.data[offset + 2] = clamped; // B - floatImageData.data[offset + 3] = 255; // A - } - - // Display the converted float image - await renderImageDataToCanvas(floatImageData, ctx); - primaryImageData = floatImageData; - - // Force a visual update by triggering a reflow - // This ensures the canvas changes are actually displayed - if (imageElement === canvas) { - // Canvas is already in DOM, force a repaint - canvas.style.display = 'none'; - canvas.offsetHeight; // Trigger reflow - canvas.style.display = ''; - } + // Render the decoded scalar through the central pipeline so + // normalization / gamma / display-colormap all apply. + await renderDecodedColormapSource(); // Update zoom controller to refresh the display zoomController.updateScale(zoomController.scale || 'fit'); - // Store the float data for pixel inspection - // Store converted float data in a custom property (dynamic property) - // @ts-ignore - Adding dynamic property for converted colormap data - tiffProcessor._convertedFloatData = { - floatData: floatData, - width: width, - height: height, - min: minValue, - max: maxValue - }; - - // Clear the raw processor data to prevent re-rendering from original data - // After colormap conversion, we want to work with the converted float data - tiffProcessor.rawTiffData = null; - if (exrProcessor) exrProcessor.rawExrData = undefined; - if (npyProcessor) npyProcessor._lastRaw = null; - if (ppmProcessor) ppmProcessor._lastRaw = null; - if (pfmProcessor) pfmProcessor._lastRaw = null; - if (pngProcessor) pngProcessor._lastRaw = null; - if (hdrProcessor) hdrProcessor._lastRaw = null; - if (tgaProcessor) tgaProcessor._lastRaw = null; - if (webImageProcessor) webImageProcessor._lastRaw = null; - if (jxlProcessor) jxlProcessor._lastRaw = null; - if (rawProcessor) rawProcessor._lastRaw = null; - // Update settings display vscode.postMessage({ type: 'stats', value: { min: minValue, max: maxValue } }); - // Send format info + // Tell the extension this is now a single-channel float image so the + // float status-bar controls (normalization) appear. sendFormatInfo({ width: width, height: height, @@ -1999,9 +2030,6 @@ import { LayersPanel } from './modules/layers-panel.js'; isInitialLoad: false }); - // Update histogram - updateHistogramData(); - // Save the colormap conversion state for persistence colormapConversionState = { colormapName: colormapName, @@ -2013,7 +2041,7 @@ import { LayersPanel } from './modules/layers-panel.js'; hasAppliedConversion = true; saveState(); - console.log(`Colormap conversion complete: ${colormapName} [${minValue}, ${maxValue}]`); + console.log(`Colormap decode complete: ${colormapName} [${minValue}, ${maxValue}]`); } catch (error) { console.error('Error during colormap conversion:', error); vscode.postMessage({ @@ -2041,20 +2069,10 @@ import { LayersPanel } from './modules/layers-panel.js'; colormapConversionState = null; hasAppliedConversion = false; originalImageData = null; + decodedColormapSource = null; // Clear converted data from processors - tiffProcessor.rawTiffData = null; - if (exrProcessor) exrProcessor.rawExrData = undefined; - if (npyProcessor) npyProcessor._lastRaw = null; - if (ppmProcessor) ppmProcessor._lastRaw = null; - if (pfmProcessor) pfmProcessor._lastRaw = null; - if (pngProcessor) pngProcessor._lastRaw = null; - if (tgaProcessor) tgaProcessor._lastRaw = null; - if (webImageProcessor) webImageProcessor._lastRaw = null; - if (jxlProcessor) jxlProcessor._lastRaw = null; - if (rawProcessor) rawProcessor._lastRaw = null; - // @ts-ignore - tiffProcessor._convertedFloatData = null; + clearAllProcessorRawData(); // Reload the image reloadImage(); @@ -2094,6 +2112,13 @@ import { LayersPanel } from './modules/layers-panel.js'; if (recompositeLayers()) { return; } } + // When a colormap has been decoded to float, that scalar is the active + // source — re-render it (so normalization / gamma / display-colormap apply). + if (decodedColormapSource) { + await renderDecodedColormapSource(); + return; + } + // Default to full update if no change info provided if (!changes) { changes = { changed: true, changedKeys: ['unspecified'], parametersOnly: false, changedMasks: false, changedStructure: false }; @@ -2502,6 +2527,8 @@ import { LayersPanel } from './modules/layers-panel.js'; currentFormatInfo.bitsPerSample === 8 && currentFormatInfo.sampleFormat !== 3; // Not float const isRgbImage = currentFormatInfo && (currentFormatInfo.samplesPerPixel ?? 0) >= 3; + // Single-channel scalar image (or a decoded colormap): can be pseudocolored. + const isSingleChannel = !!currentFormatInfo && (currentFormatInfo.samplesPerPixel ?? 1) <= 1; if (isRgb8BitUint) { menu.appendChild(createSeparator()); @@ -2512,13 +2539,29 @@ import { LayersPanel } from './modules/layers-panel.js'; })); } + // "Apply Colormap" (pseudocolor): map a single-channel scalar to colors. + if (isSingleChannel) { + menu.appendChild(createSeparator()); + + const activeColormap = settingsManager.settings.displayColormap; + const hasColormap = activeColormap && activeColormap !== 'none'; + menu.appendChild(createMenuItem(hasColormap ? `Apply Colormap… (${activeColormap})` : 'Apply Colormap…', () => { + vscode.postMessage({ type: 'executeCommand', command: 'tiffVisualizer.applyColormap' }); + })); + if (hasColormap) { + menu.appendChild(createMenuItem('Remove Colormap', () => { + handleSetDisplayColormap('none'); + })); + } + } + + // "Decode Colormap to Float": recover a scalar from a colormapped RGB image. if (isRgbImage) { if (!isRgb8BitUint) { menu.appendChild(createSeparator()); } - // Add Convert Colormap to Float option (uses command - needs user input) - menu.appendChild(createMenuItem('Convert Colormap to Float', () => { + menu.appendChild(createMenuItem('Decode Colormap to Float', () => { vscode.postMessage({ type: 'executeCommand', command: 'tiffVisualizer.convertColormapToFloat' }); })); } diff --git a/media/modules/colormap-converter.js b/media/modules/colormap-converter.js index 4915ee7..23eed8f 100644 --- a/media/modules/colormap-converter.js +++ b/media/modules/colormap-converter.js @@ -1,391 +1,93 @@ +// @ts-check +"use strict"; + /** * Colormap Converter Module - * Converts colormap images to float values based on selected colormap + * + * "Decode" direction: recover scalar float values from a colormapped RGB image + * (i.e. the inverse of applying a colormap). The forward colormaps and the fast + * inverse RGB->index lookup live in the shared colormaps.js module, so this and + * the render-time "apply" path stay consistent. */ +import { COLORMAP_NAMES, rgbToColormapIndex } from './colormaps.js'; + export class ColormapConverter { constructor() { - // Define common colormaps with 256 color entries (RGB values 0-255) - this.colormaps = this.initializeColormaps(); + /** @type {string[]} */ + this.colormapNames = COLORMAP_NAMES; } /** - * Initialize colormap lookup tables - * Each colormap is an array of 256 [r, g, b] values - * @returns {{[key: string]: number[][]}} + * Decode a colormapped image (RGBA ImageData) to float values. + * @param {ImageData} imageData - The source image data (true colors) + * @param {string} colormapName - Name of the colormap used in the image + * @param {number} minValue - Value mapped to the start of the colormap + * @param {number} maxValue - Value mapped to the end of the colormap + * @param {boolean} [inverted] - Whether the colormap was applied inverted + * @param {boolean} [logarithmic] - Whether to use logarithmic mapping + * @returns {Float32Array} Array of float values (one per pixel) */ - initializeColormaps() { - return { - viridis: this.generateViridis(), - plasma: this.generatePlasma(), - inferno: this.generateInferno(), - magma: this.generateMagma(), - jet: this.generateJet(), - hot: this.generateHot(), - cool: this.generateCool(), - turbo: this.generateTurbo(), - gray: this.generateGray() - }; + convertToFloat(imageData, colormapName, minValue, maxValue, inverted = false, logarithmic = false) { + return this.decodeRgb( + imageData.data, imageData.width, imageData.height, 4, + colormapName, minValue, maxValue, inverted, logarithmic + ); } /** - * Convert a colormap image to float values - * @param {ImageData} imageData - The source image data - * @param {string} colormapName - Name of the colormap to use - * @param {number} minValue - Minimum value to map to - * @param {number} maxValue - Maximum value to map to - * @param {boolean} inverted - Whether to invert the mapping - * @param {boolean} logarithmic - Whether to use logarithmic mapping - * @returns {Float32Array} Array of float values + * Decode interleaved RGB(A) pixel data to float values. + * @param {ArrayLike} rgb - Interleaved pixel data (0-255) + * @param {number} width + * @param {number} height + * @param {number} channels - Number of interleaved channels (3 for RGB, 4 for RGBA) + * @param {string} colormapName + * @param {number} minValue + * @param {number} maxValue + * @param {boolean} [inverted] + * @param {boolean} [logarithmic] + * @returns {Float32Array} */ - convertToFloat(imageData, colormapName, minValue, maxValue, inverted = false, logarithmic = false) { - const colormap = this.colormaps[colormapName]; - if (!colormap) { + decodeRgb(rgb, width, height, channels, colormapName, minValue, maxValue, inverted = false, logarithmic = false) { + if (this.colormapNames.indexOf(colormapName) === -1) { throw new Error(`Unknown colormap: ${colormapName}`); } - const width = imageData.width; - const height = imageData.height; - const data = imageData.data; - const floatData = new Float32Array(width * height); + const count = width * height; + const floatData = new Float32Array(count); - // For each pixel, find the closest colormap entry - for (let i = 0; i < width * height; i++) { - const pixelOffset = i * 4; - const r = data[pixelOffset]; - const g = data[pixelOffset + 1]; - const b = data[pixelOffset + 2]; + const useLog = logarithmic === true; + const logMin = useLog ? Math.log10(Math.max(1e-10, Math.abs(minValue))) : 0; + const logMax = useLog ? Math.log10(Math.max(1e-10, Math.abs(maxValue))) : 0; - // Find closest colormap index - let index = this.findClosestColormapIndex(r, g, b, colormap); + for (let i = 0; i < count; i++) { + const o = i * channels; + const r = rgb[o]; + const g = rgb[o + 1]; + const b = rgb[o + 2]; - // Invert index if requested (255 -> 0, 0 -> 255) - if (inverted) { - index = 255 - index; - } + let index = rgbToColormapIndex(colormapName, r, g, b); + if (index < 0) { index = 0; } + if (inverted) { index = 255 - index; } - // Map index (0-255) to normalized value (0-1) - const normalizedValue = index / 255.0; + const t = index / 255.0; // normalized position along the colormap - // Apply mapping (linear or logarithmic) - let finalValue; - if (logarithmic) { - // Logarithmic mapping - // We need to handle the case where minValue or maxValue could be negative or zero - // For logarithmic mapping to work, we need positive values - const useLogMin = Math.abs(minValue) < 1e-10 ? 1e-10 : Math.abs(minValue); - const useLogMax = Math.abs(maxValue) < 1e-10 ? 1e-10 : Math.abs(maxValue); - - // Map from normalized (0-1) to logarithmic space - const logMin = Math.log10(useLogMin); - const logMax = Math.log10(useLogMax); - const logValue = logMin + normalizedValue * (logMax - logMin); - finalValue = Math.pow(10, logValue); - - // Restore sign if original values were negative + let value; + if (useLog) { + value = Math.pow(10, logMin + t * (logMax - logMin)); if (minValue < 0 && maxValue < 0) { - finalValue = -finalValue; + value = -value; } else if (minValue < 0) { - // Mixed sign range - interpolate sign - finalValue = minValue + normalizedValue * (maxValue - minValue); + // Mixed-sign range: fall back to linear interpolation + value = minValue + t * (maxValue - minValue); } } else { - // Linear mapping - finalValue = minValue + normalizedValue * (maxValue - minValue); + value = minValue + t * (maxValue - minValue); } - floatData[i] = finalValue; + floatData[i] = value; } return floatData; } - - /** - * Find the closest colormap index for a given RGB color - * @param {number} r - Red value (0-255) - * @param {number} g - Green value (0-255) - * @param {number} b - Blue value (0-255) - * @param {number[][]} colormap - Colormap lookup table - * @returns {number} Index of closest color (0-255) - */ - findClosestColormapIndex(r, g, b, colormap) { - let minDistance = Infinity; - let closestIndex = 0; - - for (let i = 0; i < colormap.length; i++) { - const [cr, cg, cb] = colormap[i]; - // Euclidean distance in RGB space - const distance = Math.sqrt( - (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2 - ); - - if (distance < minDistance) { - minDistance = distance; - closestIndex = i; - } - } - - return closestIndex; - } - - // Colormap generation functions - // Each generates 256 RGB values - - generateGray() { - const colormap = []; - for (let i = 0; i < 256; i++) { - colormap.push([i, i, i]); - } - return colormap; - } - - generateJet() { - // Classic jet colormap: blue -> cyan -> green -> yellow -> red - const colormap = []; - for (let i = 0; i < 256; i++) { - const value = i / 255.0; - let r, g, b; - - if (value < 0.125) { - r = 0; - g = 0; - b = 0.5 + value * 4; - } else if (value < 0.375) { - r = 0; - g = (value - 0.125) * 4; - b = 1; - } else if (value < 0.625) { - r = (value - 0.375) * 4; - g = 1; - b = 1 - (value - 0.375) * 4; - } else if (value < 0.875) { - r = 1; - g = 1 - (value - 0.625) * 4; - b = 0; - } else { - r = 1 - (value - 0.875) * 4; - g = 0; - b = 0; - } - - colormap.push([ - Math.round(r * 255), - Math.round(g * 255), - Math.round(b * 255) - ]); - } - return colormap; - } - - generateHot() { - // Hot colormap: black -> red -> orange -> yellow -> white - const colormap = []; - for (let i = 0; i < 256; i++) { - const value = i / 255.0; - let r, g, b; - - if (value < 0.33) { - r = value / 0.33; - g = 0; - b = 0; - } else if (value < 0.66) { - r = 1; - g = (value - 0.33) / 0.33; - b = 0; - } else { - r = 1; - g = 1; - b = (value - 0.66) / 0.34; - } - - colormap.push([ - Math.round(r * 255), - Math.round(g * 255), - Math.round(b * 255) - ]); - } - return colormap; - } - - generateCool() { - // Cool colormap: cyan -> magenta - const colormap = []; - for (let i = 0; i < 256; i++) { - const value = i / 255.0; - colormap.push([ - Math.round(value * 255), - Math.round((1 - value) * 255), - 255 - ]); - } - return colormap; - } - - // Viridis colormap (perceptually uniform) - generateViridis() { - // Simplified viridis - in production, use actual lookup table - const colormap = []; - const viridisData = [ - [0.267004, 0.004874, 0.329415], - [0.282623, 0.140926, 0.457517], - [0.253935, 0.265254, 0.529983], - [0.206756, 0.371758, 0.553117], - [0.163625, 0.471133, 0.558148], - [0.127568, 0.566949, 0.550556], - [0.134692, 0.658636, 0.517649], - [0.266941, 0.748751, 0.440573], - [0.477504, 0.821444, 0.318195], - [0.741388, 0.873449, 0.149561], - [0.993248, 0.906157, 0.143936] - ]; - - // Interpolate to 256 colors - for (let i = 0; i < 256; i++) { - const pos = (i / 255.0) * (viridisData.length - 1); - const idx = Math.floor(pos); - const frac = pos - idx; - - const color1 = viridisData[Math.min(idx, viridisData.length - 1)]; - const color2 = viridisData[Math.min(idx + 1, viridisData.length - 1)]; - - colormap.push([ - Math.round(((color1[0] * (1 - frac) + color2[0] * frac) * 255)), - Math.round(((color1[1] * (1 - frac) + color2[1] * frac) * 255)), - Math.round(((color1[2] * (1 - frac) + color2[2] * frac) * 255)) - ]); - } - return colormap; - } - - // Plasma colormap (perceptually uniform) - generatePlasma() { - const colormap = []; - const plasmaData = [ - [0.050383, 0.029803, 0.527975], - [0.287076, 0.010384, 0.627010], - [0.476230, 0.011158, 0.657865], - [0.647257, 0.125289, 0.593542], - [0.785914, 0.274290, 0.472908], - [0.877850, 0.439704, 0.345067], - [0.936213, 0.605205, 0.231465], - [0.972355, 0.771125, 0.155626], - [0.994617, 0.938336, 0.165141], - [0.987053, 0.991438, 0.749504] - ]; - - for (let i = 0; i < 256; i++) { - const pos = (i / 255.0) * (plasmaData.length - 1); - const idx = Math.floor(pos); - const frac = pos - idx; - - const color1 = plasmaData[Math.min(idx, plasmaData.length - 1)]; - const color2 = plasmaData[Math.min(idx + 1, plasmaData.length - 1)]; - - colormap.push([ - Math.round(((color1[0] * (1 - frac) + color2[0] * frac) * 255)), - Math.round(((color1[1] * (1 - frac) + color2[1] * frac) * 255)), - Math.round(((color1[2] * (1 - frac) + color2[2] * frac) * 255)) - ]); - } - return colormap; - } - - // Inferno colormap (perceptually uniform) - generateInferno() { - const colormap = []; - const infernoData = [ - [0.001462, 0.000466, 0.013866], - [0.094329, 0.042852, 0.225802], - [0.239903, 0.067979, 0.343397], - [0.412470, 0.102815, 0.380271], - [0.591217, 0.155410, 0.347824], - [0.758643, 0.237267, 0.275196], - [0.889650, 0.360829, 0.210001], - [0.969788, 0.514135, 0.186861], - [0.994738, 0.683489, 0.240902], - [0.988362, 0.998364, 0.644924] - ]; - - for (let i = 0; i < 256; i++) { - const pos = (i / 255.0) * (infernoData.length - 1); - const idx = Math.floor(pos); - const frac = pos - idx; - - const color1 = infernoData[Math.min(idx, infernoData.length - 1)]; - const color2 = infernoData[Math.min(idx + 1, infernoData.length - 1)]; - - colormap.push([ - Math.round(((color1[0] * (1 - frac) + color2[0] * frac) * 255)), - Math.round(((color1[1] * (1 - frac) + color2[1] * frac) * 255)), - Math.round(((color1[2] * (1 - frac) + color2[2] * frac) * 255)) - ]); - } - return colormap; - } - - // Magma colormap (perceptually uniform) - generateMagma() { - const colormap = []; - const magmaData = [ - [0.001462, 0.000466, 0.013866], - [0.091904, 0.051667, 0.200303], - [0.234547, 0.090739, 0.348341], - [0.408198, 0.131574, 0.416555], - [0.595732, 0.180653, 0.421399], - [0.776405, 0.266630, 0.373397], - [0.924010, 0.406370, 0.330720], - [0.987622, 0.583041, 0.382914], - [0.996212, 0.771453, 0.543135], - [0.987053, 0.991438, 0.749504] - ]; - - for (let i = 0; i < 256; i++) { - const pos = (i / 255.0) * (magmaData.length - 1); - const idx = Math.floor(pos); - const frac = pos - idx; - - const color1 = magmaData[Math.min(idx, magmaData.length - 1)]; - const color2 = magmaData[Math.min(idx + 1, magmaData.length - 1)]; - - colormap.push([ - Math.round(((color1[0] * (1 - frac) + color2[0] * frac) * 255)), - Math.round(((color1[1] * (1 - frac) + color2[1] * frac) * 255)), - Math.round(((color1[2] * (1 - frac) + color2[2] * frac) * 255)) - ]); - } - return colormap; - } - - // Turbo colormap (improved rainbow) - generateTurbo() { - const colormap = []; - const turboData = [ - [0.18995, 0.07176, 0.23217], - [0.25107, 0.25237, 0.63374], - [0.19659, 0.47276, 0.82300], - [0.12756, 0.66813, 0.82565], - [0.13094, 0.82030, 0.65899], - [0.37408, 0.92478, 0.41642], - [0.66987, 0.95987, 0.19659], - [0.90842, 0.87640, 0.10899], - [0.98999, 0.64450, 0.03932], - [0.93702, 0.25023, 0.01583] - ]; - - for (let i = 0; i < 256; i++) { - const pos = (i / 255.0) * (turboData.length - 1); - const idx = Math.floor(pos); - const frac = pos - idx; - - const color1 = turboData[Math.min(idx, turboData.length - 1)]; - const color2 = turboData[Math.min(idx + 1, turboData.length - 1)]; - - colormap.push([ - Math.round(((color1[0] * (1 - frac) + color2[0] * frac) * 255)), - Math.round(((color1[1] * (1 - frac) + color2[1] * frac) * 255)), - Math.round(((color1[2] * (1 - frac) + color2[2] * frac) * 255)) - ]); - } - return colormap; - } } diff --git a/media/modules/colormaps.js b/media/modules/colormaps.js new file mode 100644 index 0000000..29ab72e --- /dev/null +++ b/media/modules/colormaps.js @@ -0,0 +1,231 @@ +// @ts-check +"use strict"; + +/** + * Shared colormap definitions and lookup tables. + * + * This module is the single source of truth for colormaps used in two directions: + * - "Apply" (pseudocolor): map a single-channel scalar to RGB at render time. + * Used by ImageRenderer in normalization-helper.js. + * - "Decode" (remove color): recover a scalar from a colormapped RGB image. + * Used by ColormapConverter in colormap-converter.js. + * + * Each colormap is generated as 256 [r, g, b] entries (0-255). Forward LUTs and + * the inverse-lookup cube are cached so they are only built once per colormap. + */ + +/** @type {string[]} */ +export const COLORMAP_NAMES = [ + 'viridis', 'plasma', 'inferno', 'magma', 'jet', 'hot', 'cool', 'turbo', 'gray' +]; + +/** Perceptually-uniform colormap control points (matplotlib), interpolated to 256. */ +const CONTROL_POINTS = { + viridis: [ + [0.267004, 0.004874, 0.329415], [0.282623, 0.140926, 0.457517], + [0.253935, 0.265254, 0.529983], [0.206756, 0.371758, 0.553117], + [0.163625, 0.471133, 0.558148], [0.127568, 0.566949, 0.550556], + [0.134692, 0.658636, 0.517649], [0.266941, 0.748751, 0.440573], + [0.477504, 0.821444, 0.318195], [0.741388, 0.873449, 0.149561], + [0.993248, 0.906157, 0.143936] + ], + plasma: [ + [0.050383, 0.029803, 0.527975], [0.287076, 0.010384, 0.627010], + [0.476230, 0.011158, 0.657865], [0.647257, 0.125289, 0.593542], + [0.785914, 0.274290, 0.472908], [0.877850, 0.439704, 0.345067], + [0.936213, 0.605205, 0.231465], [0.972355, 0.771125, 0.155626], + [0.994617, 0.938336, 0.165141], [0.987053, 0.991438, 0.749504] + ], + inferno: [ + [0.001462, 0.000466, 0.013866], [0.094329, 0.042852, 0.225802], + [0.239903, 0.067979, 0.343397], [0.412470, 0.102815, 0.380271], + [0.591217, 0.155410, 0.347824], [0.758643, 0.237267, 0.275196], + [0.889650, 0.360829, 0.210001], [0.969788, 0.514135, 0.186861], + [0.994738, 0.683489, 0.240902], [0.988362, 0.998364, 0.644924] + ], + magma: [ + [0.001462, 0.000466, 0.013866], [0.091904, 0.051667, 0.200303], + [0.234547, 0.090739, 0.348341], [0.408198, 0.131574, 0.416555], + [0.595732, 0.180653, 0.421399], [0.776405, 0.266630, 0.373397], + [0.924010, 0.406370, 0.330720], [0.987622, 0.583041, 0.382914], + [0.996212, 0.771453, 0.543135], [0.987053, 0.991438, 0.749504] + ], + turbo: [ + [0.18995, 0.07176, 0.23217], [0.25107, 0.25237, 0.63374], + [0.19659, 0.47276, 0.82300], [0.12756, 0.66813, 0.82565], + [0.13094, 0.82030, 0.65899], [0.37408, 0.92478, 0.41642], + [0.66987, 0.95987, 0.19659], [0.90842, 0.87640, 0.10899], + [0.98999, 0.64450, 0.03932], [0.93702, 0.25023, 0.01583] + ] +}; + +/** + * Interpolate a control-point colormap to 256 [r,g,b] entries (0-255). + * @param {number[][]} points + * @returns {number[][]} + */ +function interpolateControlPoints(points) { + const out = []; + for (let i = 0; i < 256; i++) { + const pos = (i / 255.0) * (points.length - 1); + const idx = Math.floor(pos); + const frac = pos - idx; + const c1 = points[Math.min(idx, points.length - 1)]; + const c2 = points[Math.min(idx + 1, points.length - 1)]; + out.push([ + Math.round((c1[0] * (1 - frac) + c2[0] * frac) * 255), + Math.round((c1[1] * (1 - frac) + c2[1] * frac) * 255), + Math.round((c1[2] * (1 - frac) + c2[2] * frac) * 255) + ]); + } + return out; +} + +/** @returns {number[][]} */ +function generateGray() { + const out = []; + for (let i = 0; i < 256; i++) { out.push([i, i, i]); } + return out; +} + +/** @returns {number[][]} */ +function generateJet() { + const out = []; + for (let i = 0; i < 256; i++) { + const v = i / 255.0; + let r, g, b; + if (v < 0.125) { r = 0; g = 0; b = 0.5 + v * 4; } + else if (v < 0.375) { r = 0; g = (v - 0.125) * 4; b = 1; } + else if (v < 0.625) { r = (v - 0.375) * 4; g = 1; b = 1 - (v - 0.375) * 4; } + else if (v < 0.875) { r = 1; g = 1 - (v - 0.625) * 4; b = 0; } + else { r = 1 - (v - 0.875) * 4; g = 0; b = 0; } + out.push([Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]); + } + return out; +} + +/** @returns {number[][]} */ +function generateHot() { + const out = []; + for (let i = 0; i < 256; i++) { + const v = i / 255.0; + let r, g, b; + if (v < 0.33) { r = v / 0.33; g = 0; b = 0; } + else if (v < 0.66) { r = 1; g = (v - 0.33) / 0.33; b = 0; } + else { r = 1; g = 1; b = (v - 0.66) / 0.34; } + out.push([Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]); + } + return out; +} + +/** @returns {number[][]} */ +function generateCool() { + const out = []; + for (let i = 0; i < 256; i++) { + const v = i / 255.0; + out.push([Math.round(v * 255), Math.round((1 - v) * 255), 255]); + } + return out; +} + +/** + * Build the 256-entry [r,g,b] table for a colormap. + * @param {string} name + * @returns {number[][] | null} + */ +function buildColormap(name) { + if (CONTROL_POINTS[name]) { return interpolateControlPoints(CONTROL_POINTS[name]); } + switch (name) { + case 'gray': return generateGray(); + case 'jet': return generateJet(); + case 'hot': return generateHot(); + case 'cool': return generateCool(); + default: return null; + } +} + +/** @type {Map} */ +const _lutCache = new Map(); + +/** + * Get a colormap as a flat Uint8Array of length 256*3 (r,g,b interleaved). + * Cached per colormap. Returns null for unknown names. + * @param {string} name + * @returns {Uint8Array | null} + */ +export function getColormapLut(name) { + if (!name || name === 'none') { return null; } + const cached = _lutCache.get(name); + if (cached) { return cached; } + const table = buildColormap(name); + if (!table) { return null; } + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + lut[i * 3] = table[i][0]; + lut[i * 3 + 1] = table[i][1]; + lut[i * 3 + 2] = table[i][2]; + } + _lutCache.set(name, lut); + return lut; +} + +/** @type {Map} */ +const _inverseCubeCache = new Map(); +const CUBE_BITS = 5; // 32 levels per channel +const CUBE_SIZE = 1 << CUBE_BITS; // 32 +const CUBE_SHIFT = 8 - CUBE_BITS; // 3 + +/** + * Build (and cache) a 32x32x32 RGB->index cube for fast inverse colormap lookup. + * Each cube cell holds the colormap index (0-255) whose color is nearest to the + * cell center. This turns decode into an O(N) operation. + * @param {string} name + * @returns {Uint8Array | null} + */ +function getInverseCube(name) { + const lut = getColormapLut(name); + if (!lut) { return null; } + const cached = _inverseCubeCache.get(name); + if (cached) { return cached; } + + const cube = new Uint8Array(CUBE_SIZE * CUBE_SIZE * CUBE_SIZE); + const step = 255 / (CUBE_SIZE - 1); + let ci = 0; + for (let ri = 0; ri < CUBE_SIZE; ri++) { + const r = ri * step; + for (let gi = 0; gi < CUBE_SIZE; gi++) { + const g = gi * step; + for (let bi = 0; bi < CUBE_SIZE; bi++) { + const b = bi * step; + let best = 0, bestDist = Infinity; + for (let k = 0; k < 256; k++) { + const dr = r - lut[k * 3]; + const dg = g - lut[k * 3 + 1]; + const db = b - lut[k * 3 + 2]; + const d = dr * dr + dg * dg + db * db; + if (d < bestDist) { bestDist = d; best = k; } + } + cube[ci++] = best; + } + } + } + _inverseCubeCache.set(name, cube); + return cube; +} + +/** + * Map an RGB color (0-255) to the nearest colormap index (0-255) via the cube. + * @param {string} name + * @param {number} r + * @param {number} g + * @param {number} b + * @returns {number} index 0-255, or -1 if colormap unknown + */ +export function rgbToColormapIndex(name, r, g, b) { + const cube = getInverseCube(name); + if (!cube) { return -1; } + const ri = (r >> CUBE_SHIFT); + const gi = (g >> CUBE_SHIFT); + const bi = (b >> CUBE_SHIFT); + return cube[(ri * CUBE_SIZE + gi) * CUBE_SIZE + bi]; +} diff --git a/media/modules/mouse-handler.js b/media/modules/mouse-handler.js index 854803d..00f98b6 100644 --- a/media/modules/mouse-handler.js +++ b/media/modules/mouse-handler.js @@ -44,6 +44,14 @@ export class MouseHandler { */ this.compositeValueProvider = null; + /** + * Optional provider for the decoded scalar value at a pixel, set when a + * colormapped image has been decoded to float. When set and it returns a + * finite value, the pixel inspector shows that scalar. + * @type {((x:number, y:number) => number|null)|null} + */ + this.decodedValueProvider = null; + // DOM elements this.container = document.body; this.imageElement = null; @@ -230,6 +238,15 @@ export class MouseHandler { } } + // When a colormapped image has been decoded to float, report the decoded + // scalar (works regardless of which colormap is now applied for display). + if (this.decodedValueProvider) { + const decoded = this.decodedValueProvider(x, y); + if (decoded !== null && decoded !== undefined) { + return this._formatCompositeValues([decoded]); + } + } + // Check if we should show modified values // ONLY allow showing modified values if we are in Gamma Mode const isGammaMode = this.settingsManager.settings.normalization && this.settingsManager.settings.normalization.gammaMode; diff --git a/media/modules/normalization-helper.js b/media/modules/normalization-helper.js index 084b078..7cf4fc3 100644 --- a/media/modules/normalization-helper.js +++ b/media/modules/normalization-helper.js @@ -2,6 +2,7 @@ "use strict"; import { PerfTrace } from './perf-trace.js'; +import { getColormapLut } from './colormaps.js'; /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ @@ -281,6 +282,16 @@ export class ImageRenderer { const perfStart = performance.now(); const result = this._renderInternal(data, width, height, channels, isFloat, stats, settings, options); + // Apply a display colormap (pseudocolor) to single-channel images. This is + // a non-destructive render-time step: the grayscale intensity already + // written by the render paths is used as the colormap index. NaN pixels + // (rendered as nanColor) are left untouched. Applied before flipY since + // flipping only reorders rows. Works for every format and for layers, + // because they all funnel through this method. + if (channels === 1 && settings && settings.displayColormap && settings.displayColormap !== 'none') { + this._applyDisplayColormap(result, data, isFloat, settings.displayColormap); + } + if (options.flipY) { const flipped = this._flipY(result); console.log(`[Render] Total render time: ${(performance.now() - perfStart).toFixed(2)}ms (with flip)`); @@ -396,6 +407,34 @@ export class ImageRenderer { return imageData; } + /** + * Remap a freshly-rendered single-channel (grayscale) ImageData through a + * colormap, in place. The grayscale value of each pixel (out[p], 0-255) is + * used as the lookup index. Pixels whose source value is non-finite were + * rendered as nanColor and are skipped so they keep their NaN color. + * @private + * @param {ImageData} imageData + * @param {ArrayLike} data - original single-channel source data + * @param {boolean} isFloat - whether source can contain NaN/Inf + * @param {string} colormapName + */ + static _applyDisplayColormap(imageData, data, isFloat, colormapName) { + const lut = getColormapLut(colormapName); + if (!lut) { return; } + const out = imageData.data; + const n = imageData.width * imageData.height; + for (let i = 0; i < n; i++) { + // Only float sources can hold NaN/Inf; integer sources never do. + if (isFloat && !Number.isFinite(data[i])) { continue; } + const p = i * 4; + const ci = out[p] * 3; // grayscale intensity (r === g === b) as index + out[p] = lut[ci]; + out[p + 1] = lut[ci + 1]; + out[p + 2] = lut[ci + 2]; + // alpha (out[p + 3]) preserved + } + } + /** * Render float32 data directly (no gamma/brightness, just normalization). diff --git a/media/modules/png-processor.js b/media/modules/png-processor.js index 638f706..fd0210d 100644 --- a/media/modules/png-processor.js +++ b/media/modules/png-processor.js @@ -344,7 +344,8 @@ export class PngProcessor { const isGammaMode = settings.normalization?.gammaMode || false; const isIdentity = NormalizationHelper.isIdentityTransformation(settings); const rgbAs24BitMode = settings.rgbAs24BitGrayscale === true; - return isGammaMode && isIdentity && !rgbAs24BitMode; + const hasColormap = !!settings.displayColormap && settings.displayColormap !== 'none'; + return isGammaMode && isIdentity && !rgbAs24BitMode && !hasColormap; } /** @returns {ImageData | null} */ diff --git a/media/modules/settings-manager.js b/media/modules/settings-manager.js index eb3c12a..f957a70 100644 --- a/media/modules/settings-manager.js +++ b/media/modules/settings-manager.js @@ -35,6 +35,7 @@ * @property {BrightnessSettings} [brightness] * @property {MaskFilterSettings[]} [maskFilters] * @property {string} [nanColor] + * @property {string} [displayColormap] * @property {boolean} [rgbAs24BitGrayscale] * @property {number} [scale24BitFactor] * @property {boolean} [normalizedFloatMode] @@ -158,6 +159,8 @@ export class SettingsManager { oldSettings.normalizedFloatMode !== newSettings.normalizedFloatMode; const nanColorChanged = newSettings.nanColor !== undefined && (oldSettings.nanColor ?? 'black') !== newSettings.nanColor; + const displayColormapChanged = newSettings.displayColormap !== undefined && + (oldSettings.displayColormap ?? 'none') !== newSettings.displayColormap; const colorPickerModeChanged = newSettings.colorPickerShowModified !== undefined && oldSettings.colorPickerShowModified !== newSettings.colorPickerShowModified; @@ -174,6 +177,7 @@ export class SettingsManager { [scaleModeChanged, 'scale24BitFactor'], [floatModeChanged, 'normalizedFloatMode'], [nanColorChanged, 'nanColor'], + [displayColormapChanged, 'displayColormap'], [colorPickerModeChanged, 'colorPickerShowModified'], ]; changes.changedKeys = changedFields.filter(([changed]) => changed).map(([, key]) => key); @@ -190,7 +194,7 @@ export class SettingsManager { // If only gamma, brightness, normalization ranges, nanColor, or colorPickerMode changed, it's parameters-only if (changes.changed && - ((gammaChanged || brightnessChanged || normRangeChanged || normAutoChanged || normGammaModeChanged || nanColorChanged || colorPickerModeChanged) && + ((gammaChanged || brightnessChanged || normRangeChanged || normAutoChanged || normGammaModeChanged || nanColorChanged || displayColormapChanged || colorPickerModeChanged) && !masksChanged && !rgbModeChanged && !scaleModeChanged && !floatModeChanged)) { changes.parametersOnly = true; } else if (changes.changedStructure || changes.changedMasks) { diff --git a/package.json b/package.json index 8d9cd83..ac227cd 100644 --- a/package.json +++ b/package.json @@ -177,7 +177,12 @@ }, { "command": "tiffVisualizer.convertColormapToFloat", - "title": "Convert Colormap Image to Float", + "title": "Decode Colormap Image to Float", + "category": "TIFF Visualizer" + }, + { + "command": "tiffVisualizer.applyColormap", + "title": "Apply Colormap (Pseudocolor)", "category": "TIFF Visualizer" }, { diff --git a/src/imagePreview/commands.ts b/src/imagePreview/commands.ts index c13021f..738fad1 100644 --- a/src/imagePreview/commands.ts +++ b/src/imagePreview/commands.ts @@ -1501,6 +1501,52 @@ export function registerImagePreviewCommands( logCommand('convertColormapToFloat', 'error', 'No webview available'); } })); + + disposables.push(vscode.commands.registerCommand('tiffVisualizer.applyColormap', async () => { + logCommand('applyColormap', 'start'); + const activePreview = previewManager.activePreview; + if (!activePreview) { + vscode.window.showErrorMessage('No active image preview found.'); + logCommand('applyColormap', 'error', 'No active preview'); + return; + } + + // Pseudocolor a single-channel image: pick a colormap (or None to clear). + const colormapOptions = [ + { label: 'None', description: 'Show as grayscale (remove colormap)', value: 'none' }, + { label: 'Viridis', description: 'Purple-blue-green-yellow perceptually uniform colormap', value: 'viridis' }, + { label: 'Plasma', description: 'Purple-pink-orange perceptually uniform colormap', value: 'plasma' }, + { label: 'Inferno', description: 'Black-purple-orange-yellow perceptually uniform colormap', value: 'inferno' }, + { label: 'Magma', description: 'Black-purple-pink-yellow perceptually uniform colormap', value: 'magma' }, + { label: 'Jet', description: 'Rainbow colormap (blue-cyan-green-yellow-red)', value: 'jet' }, + { label: 'Hot', description: 'Black-red-orange-yellow-white colormap', value: 'hot' }, + { label: 'Cool', description: 'Cyan-magenta colormap', value: 'cool' }, + { label: 'Turbo', description: 'Improved rainbow colormap', value: 'turbo' }, + { label: 'Gray', description: 'Grayscale colormap', value: 'gray' } + ]; + + const selected = await vscode.window.showQuickPick(colormapOptions, { + placeHolder: 'Select a colormap to apply (pseudocolor)', + title: 'Apply Colormap' + }); + + if (!selected) { + logCommand('applyColormap', 'error', 'User cancelled colormap selection'); + return; + } + + const preview = activePreview as any; + if (preview.getWebview) { + preview.getWebview().postMessage({ + type: 'setDisplayColormap', + colormap: selected.value + }); + logCommand('applyColormap', 'success', selected.value); + } else { + logCommand('applyColormap', 'error', 'No webview available'); + } + })); + disposables.push(vscode.commands.registerCommand('tiffVisualizer.revertToOriginal', async () => { logCommand('revertToOriginal', 'start'); const activePreview = previewManager.activePreview; From 38fdad806d488322bc86af9d90384e172ce7e09a Mon Sep 17 00:00:00 2001 From: florian Date: Wed, 17 Jun 2026 11:36:41 +0200 Subject: [PATCH 2/2] fixes showing all options when reverting color map and correcting auto norm boundary info text --- media/imagePreview.js | 7 +++++++ src/imagePreview/commands.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/media/imagePreview.js b/media/imagePreview.js index d6dd8ec..d55edd2 100644 --- a/media/imagePreview.js +++ b/media/imagePreview.js @@ -406,6 +406,13 @@ import { LayersPanel } from './modules/layers-panel.js'; primaryImageData = null; peerImageData = null; + // Reset each processor's initial-load flag so the reload re-sends + // formatInfo (refreshing currentFormatInfo and per-format settings). + // Without this, reverting a colormap decode would leave the menu/status + // bars showing the decoded single-channel-float format instead of the + // original image's format. + for (const p of allProcessors) { p._isInitialLoad = true; } + // Clear stats in UI to prevent stale values vscode.postMessage({ type: 'stats', value: null }); diff --git a/src/imagePreview/commands.ts b/src/imagePreview/commands.ts index 738fad1..8b82abd 100644 --- a/src/imagePreview/commands.ts +++ b/src/imagePreview/commands.ts @@ -504,6 +504,17 @@ export function registerImagePreviewCommands( const isSingleChannelUint = formatInfo && formatInfo.samplesPerPixel === 1 && formatInfo.sampleFormat !== 3; // Not float const normalizedFloatModeEnabled = previewManager.appStateManager.imageSettings.normalizedFloatMode; + // Gamma mode normalizes to the full type range: 0-1 for float images, but + // 0-typeMax for integer images (e.g. 0-255 for uint8, 0-65535 for uint16). + // Reflect the actual range in the labels so it isn't misleading. + const isFloatImage = !formatInfo || formatInfo.sampleFormat === 3; + const gammaMax = (isFloatImage || normalizedFloatModeEnabled) + ? 1 + : (Math.pow(2, formatInfo.bitsPerSample || 8) - 1); + const gammaRangeLabel = formatInfo + ? `0-${gammaMax}` + : '0-1 (float) or 0-255/0-65535 (integer)'; + // First show a QuickPick with options const options: Array = [ { @@ -520,8 +531,8 @@ export function registerImagePreviewCommands( }, { label: currentConfig.gammaMode ? '$(check) Gamma/Brightness Mode' : '$(square) Gamma/Brightness Mode', - description: 'Normalize to fixed 0-1 range and enable gamma/brightness controls', - detail: 'Always normalize to 0-1 range, then apply gamma and brightness adjustments', + description: `Normalize to fixed ${gammaRangeLabel} range and enable gamma/brightness controls`, + detail: `Always normalize to ${gammaRangeLabel} range, then apply gamma and brightness adjustments`, action: 'gamma' } ];