diff --git a/README.md b/README.md index 1778842..048ec68 100644 --- a/README.md +++ b/README.md @@ -4,26 +4,26 @@ Inspect high-bit-depth, floating-point, scientific, and camera image files direc Supports TIFF, EXR, NPY/NPZ, PNG, JPEG, WebP, AVIF, HDR, JXL, TGA, BMP, ICO, PPM, PFM, PBM and PGM. -The viewer supports 8-bit and 16-bit integer images as well as 16-bit and 32-bit floating-point images. You can inspect exact pixel values, normalize image data to custom ranges, adjust gamma and brightness, compare images, and export the current visualization as PNG. +The viewer supports 8-bit and 16-bit integer images as well as 16-bit and 32-bit floating-point images. You can inspect exact pixel values, normalize image data to custom ranges, adjust gamma and brightness, compare images, and export the current visualization as PNG. Uses rust for decoding several formats and the gpu for rendering to provide the fastest possible extension. ![tiff-visualizer](https://github.com/kleinicke/tiff-visualizer/releases/download/v1.0.0/TiffVisualizerVSCode.gif) - ## Supported Sample Types +## Supported Sample Types - -| Format | uint8 | uint16 | float16 | float32 | Notes | -|---|---:|---:|---:|---:|---| -| TIFF | Yes | Yes | Yes | Yes | Decoded by a Rust/WASM decoder by default (uint8/16/32, int, float16/32/64); geotiff.js is a fallback for 24-bit grayscale mode and TIFF variants the Rust decoder can't handle | -| EXR | No | No | Yes | Yes | HDR floating-point format | -| NPY/NPZ | Yes | Yes | Yes | Yes | Also supports float64 and int8/16/32/64, uint32/64 | -| PFM | No | No | No | Yes | Portable Float Map | -| HDR | No | No | No | Yes | Radiance RGBE, decoded to float32 | -| PNG | Yes | Yes | No | No | Palette PNGs become 8-bit RGBA | -| PPM/PGM/PBM | Yes | Yes | No | No | PBM is 1-bit, shown as 8-bit | -| JPEG/WebP/AVIF/BMP/ICO/TGA/JXL | Yes | No | No | No | Decoded as 8-bit image data in the extension | +| Format | uint8 | uint16 | float16 | float32 | Notes | +| ------------------------------ | ----: | -----: | ------: | ------: | --------------------------------------------------------------------------- | +| TIFF | Yes | Yes | Yes | Yes | Decoded by a Rust/WASM decoder by default (uint8/16/32, int, float16/32/64) | +| EXR | No | No | Yes | Yes | HDR floating-point format | +| NPY/NPZ | Yes | Yes | Yes | Yes | Also supports float64 and int8/16/32/64, uint32/64 | +| PFM | No | No | No | Yes | Portable Float Map | +| HDR | No | No | No | Yes | Radiance RGBE, decoded to float32 | +| PNG | Yes | Yes | No | No | Palette PNGs become 8-bit RGBA | +| PPM/PGM/PBM | Yes | Yes | No | No | PBM is 1-bit, shown as 8-bit | +| JPEG/WebP/AVIF/BMP/ICO/TGA/JXL | Yes | No | No | No | Decoded as 8-bit image data in the extension | ## Features +- **Fast and versatile TIFF Support**: Fast TIFF decoding using ![Rust](https://github.com/image-rs/image-tiff). Opens high-bit-depth, floating-point, multi-channel, and compressed TIFF files. - **Advanced TIFF Support**: Opens high-bit-depth, floating-point, multi-channel, and compressed TIFF files. Fast TIFF loading via Rust/WebAssembly, with geotiff.js fallback for compatibility. - **Scientific Image Inspection**: Inspect uint8, uint16, float16, and float32 image data in grayscale, RGB, and RGBA images. - **Interactive Pixel Values**: Hover over any pixel to see its exact value in the status bar. For multi-channel images, all channel values are displayed. @@ -32,8 +32,8 @@ The viewer supports 8-bit and 16-bit integer images as well as 16-bit and 32-bit - **Histogram View**: Show a histogram overlay to inspect the current image distribution while tuning the visualization. - **Image Collections**: Group related images in one preview and quickly move between them without opening a tab for every file. Add individual images, folders, paths, or wildcard matches from the command palette and editor context menu. ![collection](https://github.com/kleinicke/tiff-visualizer/releases/download/v1.0.0/Collection.gif) -- **Layers View**: Open one or more images in a dedicated Layers window for compositing and visual comparison. -Easily get the difference between two images or apply a mask onto one. This layer view allows dedicated compositions between multiple images. +- **Layers View**: Open one or more images in a dedicated Layers window for compositing and visual comparison. + Easily get the difference between two images or apply a mask onto one. This layer view allows dedicated compositions between multiple images. - **NaN Color**: Choose how NaN values are displayed. - **Session-Wide Settings**: A single VS Code window keeps visualization settings across opened images. - **Export and Copy**: Export the current visualization as PNG, copy the image, or copy image zoom level to the clipboard to paste onto other image. diff --git a/media/decode-worker.js b/media/decode-worker.js index 07c2941..2fcb880 100644 --- a/media/decode-worker.js +++ b/media/decode-worker.js @@ -28,7 +28,7 @@ import './parse-exr.js'; import * as WorkerGeoTIFF from './geotiff.min.js'; import UPNG from './upng.min.js'; import parseHdr from 'parse-hdr'; -import initTiffWasm, { decode_tiff } from './wasm/tiff-wasm.js'; +import initTiffWasm, { decode_exr_fast, decode_hdr_fast, decode_png16_fast, decode_tiff, decode_tiff_fast } from './wasm/tiff-wasm.js'; import { NpyProcessor } from './modules/npy-processor.js'; import { PfmProcessor } from './modules/pfm-processor.js'; import { PpmProcessor } from './modules/ppm-processor.js'; @@ -94,12 +94,43 @@ function decodeTiffWasm(buffer) { if (!tiffWasmReady) { throw new Error('TIFF WASM decoder not initialized'); } - const result = decode_tiff(new Uint8Array(buffer)); + const timings = []; + let phaseStart = performance.now(); + const result = typeof decode_tiff_fast === 'function' + ? decode_tiff_fast(new Uint8Array(buffer)) + : decode_tiff(new Uint8Array(buffer)); + let now = performance.now(); + timings.push({ name: 'decode-wasm-rust', durationMs: now - phaseStart }); + if (Number.isFinite(result.timing_metadata_ms)) { + timings.push({ name: 'decode-rust-metadata', durationMs: result.timing_metadata_ms }); + } + if (Number.isFinite(result.timing_decode_ms)) { + timings.push({ name: 'decode-rust-read-image', durationMs: result.timing_decode_ms }); + } + if (Number.isFinite(result.timing_convert_ms)) { + timings.push({ name: 'decode-rust-convert-pack', durationMs: result.timing_convert_ms }); + } + if (Number.isFinite(result.timing_stats_ms)) { + timings.push({ name: 'decode-rust-stats', durationMs: result.timing_stats_ms }); + } + if (Number.isFinite(result.timing_pack_ms)) { + timings.push({ name: 'decode-rust-pack', durationMs: result.timing_pack_ms }); + } + if (result.direct_decode) { + timings.push({ name: 'decode-rust-direct', durationMs: 1 }); + } + + phaseStart = now; const width = result.width; const height = result.height; const channels = result.channels; - const data = new Float32Array(result.get_data_as_f32()); + const data = typeof result.take_data_as_f32 === 'function' + ? result.take_data_as_f32() + : result.get_data_as_f32(); + now = performance.now(); + timings.push({ name: 'decode-wasm-to-f32', durationMs: now - phaseStart }); + phaseStart = now; /** @type {Float32Array[]} */ const rasters = []; if (channels === 1) { @@ -114,6 +145,8 @@ function decodeTiffWasm(buffer) { rasters.push(channel); } } + now = performance.now(); + timings.push({ name: 'decode-wasm-deinterleave', durationMs: now - phaseStart }); return { width, @@ -125,11 +158,20 @@ function decodeTiffWasm(buffer) { predictor: result.predictor, photometricInterpretation: result.photometric_interpretation, planarConfiguration: result.planar_configuration, + rowsPerStrip: result.rows_per_strip, + stripCount: result.strip_count, + stripByteCountTotal: Number(result.strip_byte_count_total || 0), + stripByteCountMax: Number(result.strip_byte_count_max || 0), + tileWidth: result.tile_width, + tileLength: result.tile_length, + tileCount: result.tile_count, + directDecode: result.direct_decode, data, rasters, min: result.min_value, max: result.max_value, decodedWith: 'wasm (worker)', + decodeTimings: timings, }; } @@ -140,8 +182,17 @@ function decodeTiffWasm(buffer) { * @param {string} wasmError */ async function decodeTiffGeotiff(buffer, wasmError) { + const timings = []; + let phaseStart = performance.now(); const tiff = await WorkerGeoTIFF.fromArrayBuffer(buffer); + let now = performance.now(); + timings.push({ name: 'decode-geotiff-open', durationMs: now - phaseStart }); + + phaseStart = now; const image = await tiff.getImage(); + now = performance.now(); + timings.push({ name: 'decode-geotiff-ifd', durationMs: now - phaseStart }); + const width = image.getWidth(); const height = image.getHeight(); const samplesPerPixel = image.getSamplesPerPixel(); @@ -149,7 +200,10 @@ async function decodeTiffGeotiff(buffer, wasmError) { const rawSampleFormat = image.getSampleFormat(); const bitsPerSample = Array.isArray(rawBitsPerSample) ? rawBitsPerSample[0] : rawBitsPerSample; const sampleFormat = Array.isArray(rawSampleFormat) ? rawSampleFormat[0] : rawSampleFormat; + phaseStart = performance.now(); const decodedRasters = await image.readRasters(); + now = performance.now(); + timings.push({ name: 'decode-geotiff-rasters', durationMs: now - phaseStart }); const fileDirectory = image.fileDirectory || {}; /** @type {Float32Array} */ @@ -157,9 +211,13 @@ async function decodeTiffGeotiff(buffer, wasmError) { /** @type {Float32Array[]|any[]} */ let rasters; if (samplesPerPixel === 1) { + phaseStart = performance.now(); data = new Float32Array(decodedRasters[0]); rasters = [data]; + now = performance.now(); + timings.push({ name: 'decode-geotiff-copy', durationMs: now - phaseStart }); } else { + phaseStart = performance.now(); const pixelCount = width * height; data = new Float32Array(pixelCount * samplesPerPixel); for (let i = 0; i < pixelCount; i++) { @@ -168,6 +226,8 @@ async function decodeTiffGeotiff(buffer, wasmError) { } } rasters = decodedRasters; + now = performance.now(); + timings.push({ name: 'decode-geotiff-interleave', durationMs: now - phaseStart }); } return { @@ -184,6 +244,7 @@ async function decodeTiffGeotiff(buffer, wasmError) { rasters, decodedWith: 'geotiff.js (worker)', wasmFallbackReason: wasmError, + decodeTimings: timings, }; } @@ -204,6 +265,243 @@ async function decodeTiff(buffer) { } } +/** + * Decode an EXR with the Rust/WASM decoder, returning a parse-exr-compatible + * shape for the existing EXR processor. + * @param {ArrayBuffer} buffer + */ +function decodeExrWasm(buffer) { + if (!tiffWasmReady || typeof decode_exr_fast !== 'function') { + throw new Error('EXR WASM decoder not initialized'); + } + const timings = []; + let phaseStart = performance.now(); + const result = decode_exr_fast(new Uint8Array(buffer)); + let now = performance.now(); + timings.push({ name: 'decode-exr-rust', durationMs: now - phaseStart }); + if (Number.isFinite(result.timing_read_ms)) { + timings.push({ name: 'decode-exr-read-image', durationMs: result.timing_read_ms }); + } + if (Number.isFinite(result.timing_pack_ms)) { + timings.push({ name: 'decode-exr-pack', durationMs: result.timing_pack_ms }); + } + + phaseStart = now; + const data = result.take_data_as_f32(); + now = performance.now(); + timings.push({ name: 'decode-exr-to-f32', durationMs: now - phaseStart }); + + const channelNames = String(result.channel_names_csv || '').split(',').filter(Boolean); + const displayedChannels = String(result.displayed_channels_csv || '').split(',').filter(Boolean); + return { + width: result.width, + height: result.height, + data, + format: result.format, + type: result.data_type, + channelNames, + displayedChannels, + shape: [result.width, result.height], + flipY: false, + decodedWith: 'rust-exr-wasm (worker)', + decodeTimings: timings, + }; +} + +/** @param {ArrayBuffer} buffer */ +function decodeExrParseExr(buffer, wasmError = '') { + const phaseStart = performance.now(); + // FloatType (1015): decoded Float32Array values, matching exr-processor. + // @ts-ignore — parseExr is attached to the (shimmed) window by parse-exr.js + const result = globalThis.parseExr(buffer, 1015); + result.decodedWith = 'parse-exr.js (worker)'; + result.flipY = true; + if (wasmError) { + result.wasmFallbackReason = wasmError; + } + result.decodeTimings = [{ name: 'decode-exr-parse-exr', durationMs: performance.now() - phaseStart }]; + return result; +} + +/** @param {ArrayBuffer} buffer */ +async function decodeExr(buffer) { + if (tiffWasmInitPromise) { + await withTimeout(tiffWasmInitPromise, TIFF_WASM_INIT_TIMEOUT_MS, 'WASM init wait timed out') + .catch(error => console.warn('[DecodeWorker]', error)); + } + try { + return decodeExrWasm(buffer); + } catch (error) { + const message = String((error instanceof Error ? error.message : error) || 'WASM EXR decode failed'); + console.warn('[DecodeWorker] EXR WASM decode failed, using parse-exr in worker:', message); + return decodeExrParseExr(buffer, message); + } +} + +/** @param {ArrayBuffer} buffer */ +function decodeHdrWasm(buffer) { + if (!tiffWasmReady || typeof decode_hdr_fast !== 'function') { + throw new Error('HDR WASM decoder not initialized'); + } + const timings = []; + let phaseStart = performance.now(); + const result = decode_hdr_fast(new Uint8Array(buffer)); + let now = performance.now(); + timings.push({ name: 'decode-hdr-rust', durationMs: now - phaseStart }); + + phaseStart = now; + const data = result.take_data_as_f32(); + const metadata = result.take_metadata_as_f64(); + now = performance.now(); + timings.push({ name: 'decode-hdr-transfer-f32', durationMs: now - phaseStart }); + const [ + width = 0, + height = 0, + exposure = 1, + gamma = 1, + timingHeader = NaN, + timingDecode = NaN, + timingConvert = NaN, + ] = metadata; + if (Number.isFinite(timingHeader)) { + timings.push({ name: 'decode-hdr-header', durationMs: timingHeader }); + } + if (Number.isFinite(timingDecode)) { + timings.push({ name: 'decode-hdr-rle', durationMs: timingDecode }); + } + if (Number.isFinite(timingConvert)) { + timings.push({ name: 'decode-hdr-to-f32', durationMs: timingConvert }); + } + return { + shape: [width, height], + exposure, + gamma, + data, + decodedWith: 'rust-hdr-wasm (worker)', + decodeTimings: timings, + }; +} + +/** + * @param {ArrayBuffer} buffer + * @param {string} [wasmError] + */ +function decodeHdrParseHdr(buffer, wasmError = '') { + const phaseStart = performance.now(); + const result = parseHdr(buffer); + result.decodedWith = 'parse-hdr (worker)'; + if (wasmError) { + result.wasmFallbackReason = wasmError; + } + result.decodeTimings = [{ name: 'decode-hdr-parse-hdr', durationMs: performance.now() - phaseStart }]; + return result; +} + +/** @param {ArrayBuffer} buffer */ +async function decodeHdr(buffer) { + if (tiffWasmInitPromise) { + await withTimeout(tiffWasmInitPromise, TIFF_WASM_INIT_TIMEOUT_MS, 'WASM init wait timed out') + .catch(error => console.warn('[DecodeWorker]', error)); + } + try { + return decodeHdrWasm(buffer); + } catch (error) { + const message = String((error instanceof Error ? error.message : error) || 'WASM HDR decode failed'); + console.warn('[DecodeWorker] HDR WASM decode failed, using parse-hdr in worker:', message); + return decodeHdrParseHdr(buffer, message); + } +} + +/** @param {ArrayBuffer} buffer */ +function decodePng16Wasm(buffer) { + if (!tiffWasmReady || typeof decode_png16_fast !== 'function') { + throw new Error('PNG WASM decoder not initialized'); + } + const timings = []; + let phaseStart = performance.now(); + const result = decode_png16_fast(new Uint8Array(buffer)); + let now = performance.now(); + timings.push({ name: 'decode-png16-rust', durationMs: now - phaseStart }); + if (Number.isFinite(result.timing_read_info_ms)) { + timings.push({ name: 'decode-png16-rust-info', durationMs: result.timing_read_info_ms }); + } + if (Number.isFinite(result.timing_decode_ms)) { + timings.push({ name: 'decode-png16-rust-frame', durationMs: result.timing_decode_ms }); + } + if (Number.isFinite(result.timing_convert_ms)) { + timings.push({ name: 'decode-png16-rust-to-u16', durationMs: result.timing_convert_ms }); + } + + phaseStart = now; + const decodedData = result.take_data_as_u16(); + now = performance.now(); + timings.push({ name: 'decode-png16-rust-transfer-u16', durationMs: now - phaseStart }); + return { + width: result.width, + height: result.height, + depth: result.bit_depth, + ctype: result.color_type, + decodedData, + decodedWith: 'rust-png-wasm (worker)', + decodeTimings: timings + }; +} + +/** @param {ArrayBuffer} buffer */ +function decodePng16Upng(buffer, wasmError = '') { + const timings = []; + let phaseStart = performance.now(); + const png = UPNG.decode(buffer); + let now = performance.now(); + timings.push({ name: 'decode-png16-upng', durationMs: now - phaseStart }); + if (png.depth === 16 && png.data) { + phaseStart = now; + const uint8Data = new Uint8Array(png.data); + const uint16Data = new Uint16Array(uint8Data.length / 2); + let src = 0; + for (let i = 0; i < uint16Data.length; i++, src += 2) { + uint16Data[i] = (uint8Data[src] << 8) | uint8Data[src + 1]; + } + png.decodedData = uint16Data; + png.data = null; + now = performance.now(); + timings.push({ name: 'decode-png16-byte-swap', durationMs: now - phaseStart }); + } + png.decodeTimings = timings; + png.decodedWith = 'upng-js (worker)'; + if (wasmError) { + png.wasmFallbackReason = wasmError; + } + return png; +} + +/** @param {ArrayBuffer} buffer */ +async function decodePng16(buffer) { + if (tiffWasmInitPromise) { + await withTimeout(tiffWasmInitPromise, TIFF_WASM_INIT_TIMEOUT_MS, 'WASM init wait timed out') + .catch(error => console.warn('[DecodeWorker]', error)); + } + try { + return decodePng16Wasm(buffer); + } catch (error) { + const message = String((error instanceof Error ? error.message : error) || 'WASM PNG decode failed'); + console.warn('[DecodeWorker] PNG WASM decode failed, using UPNG in worker:', message); + return decodePng16Upng(buffer, message); + } +} + +/** @param {ArrayBuffer} buffer */ +function decodePpmWorker(buffer) { + const start = performance.now(); + const result = ppmParser._parsePpm(buffer); + const parseTimings = Array.isArray(result.decodeTimings) ? result.decodeTimings : []; + result.decodeTimings = [ + { name: 'decode-ppm-parse', durationMs: performance.now() - start }, + ...parseTimings + ]; + return result; +} + /** * @param {string} format * @param {ArrayBuffer} buffer @@ -213,9 +511,7 @@ async function decodeFormat(format, buffer) { case 'tiff': return decodeTiff(buffer); case 'exr': - // FloatType (1015): decoded Float32Array values, matching exr-processor. - // @ts-ignore — parseExr is attached to the (shimmed) window by parse-exr.js - return globalThis.parseExr(buffer, 1015); + return decodeExr(buffer); case 'npy': { const view = new DataView(buffer); // NPZ (ZIP) signature 0x04034b50 @@ -225,13 +521,13 @@ async function decodeFormat(format, buffer) { return npyParser._parseNpy(buffer); } case 'pfm': - return pfmParser._parsePfm(buffer); + return pfmParser._parsePfm(buffer, { topDown: true }); case 'ppm': - return ppmParser._parsePpm(buffer); + return decodePpmWorker(buffer); case 'png16': - return UPNG.decode(buffer); + return decodePng16(buffer); case 'hdr': - return parseHdr(buffer); + return decodeHdr(buffer); default: throw new Error(`Unknown decode format: ${format}`); } diff --git a/media/imagePreview.css b/media/imagePreview.css index 3ada778..be5583e 100644 --- a/media/imagePreview.css +++ b/media/imagePreview.css @@ -374,8 +374,10 @@ body img { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); display: flex; flex-direction: column; - min-width: 320px; - max-width: 500px; + width: 326px; + min-width: 326px; + max-width: 326px; + box-sizing: border-box; user-select: none; } @@ -441,21 +443,83 @@ body img { display: block; margin: 12px; border-radius: 3px; + width: 300px; + height: 150px; + flex: 0 0 auto; + box-sizing: border-box; +} + +.histogram-labels { + width: 300px; + margin: 0 12px 8px; + box-sizing: border-box; + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +.histogram-labels span { + display: inline-block; + max-width: 48%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.histogram-labels span:last-child { + text-align: right; } .histogram-stats { display: flex; - justify-content: space-around; + flex-direction: column; + justify-content: center; padding: 8px 12px; font-size: 11px; - gap: 8px; + line-height: 1.35; + gap: 4px; border-top: 1px solid var(--vscode-panel-border, #3c3c3c); background: var(--vscode-sideBarSectionHeader-background, #252526); border-radius: 0 0 6px 6px; + width: 100%; + min-height: 50px; + box-sizing: border-box; + overflow: hidden; + font-family: var(--vscode-editor-font-family, 'Consolas', 'Monaco', monospace); + font-variant-numeric: tabular-nums; + font-feature-settings: "tnum"; +} + +.histogram-stat-line { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-width: 0; +} + +.histogram-stat-item { + flex: 1 1 0; + text-align: center; +} + +.histogram-stat-item:first-child { + text-align: left; +} + +.histogram-stat-item:last-child { + text-align: right; +} + +.histogram-stat-nan { + color: #ffcc00; + text-align: center; } .histogram-stats span { white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; } /* Light theme adjustments for histogram */ diff --git a/media/imagePreview.js b/media/imagePreview.js index d55edd2..2644c2d 100644 --- a/media/imagePreview.js +++ b/media/imagePreview.js @@ -29,7 +29,7 @@ import { LayersPanel } from './modules/layers-panel.js'; */ (function () { /** - * @typedef {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedMasks: boolean, changedStructure: boolean}} SettingsChanges + * @typedef {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedStructure: boolean}} SettingsChanges * @typedef {{relativeX: number, relativeY: number, sourceWidth: number, sourceHeight: number, scale: number|string}} CopiedPosition * @typedef {{colormapName: string, minValue: number, maxValue: number, inverted: boolean, logarithmic: boolean}} ColormapConversionState * @typedef {{width?: number, height?: number, samplesPerPixel?: number, bitsPerSample?: number, sampleFormat?: number, formatType?: string, [key: string]: any}} FormatInfo @@ -41,6 +41,8 @@ import { LayersPanel } from './modules/layers-panel.js'; // Format info tracking for context menu /** @type {FormatInfo|null} */ let currentFormatInfo = null; + /** @type {{time: number, generation: number, formatType: string}|null} */ + let lastFormatInfoPost = null; // Wrap vscode.postMessage to track formatInfo const vscode = { @@ -49,6 +51,11 @@ import { LayersPanel } from './modules/layers-panel.js'; // Track formatInfo when it's sent if (message.type === 'formatInfo' && message.value) { currentFormatInfo = message.value; + lastFormatInfoPost = { + time: performance.now(), + generation: _loadGeneration, + formatType: String(message.value.formatType || '') + }; } return originalVscode.postMessage(message); }, @@ -93,10 +100,18 @@ import { LayersPanel } from './modules/layers-panel.js'; mouseHandler.setRawProcessor(rawProcessor); mouseHandler.setExrProcessor(exrProcessor); + function disposeWebglRenderers() { + for (const p of allProcessors) { + if (p?._webglRenderer && typeof p._webglRenderer.dispose === 'function') { + p._webglRenderer.dispose(); + } + } + } + // Layer compositing (GIMP-style) — manager holds the stack, panel is the UI. const layerManager = new LayerManager(); const layersPanel = new LayersPanel(layerManager, { - onChange: () => { scheduleRecomposite(); scheduleSaveState(); }, + onChange: (options = {}) => { scheduleRecomposite(options.interactive ? 180 : 0); scheduleSaveState(); }, onPersist: () => { scheduleSaveState(); }, onAddLayer: () => { vscode.postMessage({ type: 'executeCommand', command: 'tiffVisualizer.addLayer' }); }, onVisibilityChange: (visible) => { @@ -104,6 +119,7 @@ import { LayersPanel } from './modules/layers-panel.js'; // Tell the extension so it can track layer mode (and block collection ops). vscode.postMessage({ type: 'layerModeChanged', active: visible }); if (visible) { + syncBaseLayer(); recompositeLayers(); } else { // Restore the normal single-image render. @@ -165,6 +181,12 @@ import { LayersPanel } from './modules/layers-panel.js'; let currentLoadFormat = ''; /** @type {{engine: string, durationMs: number}|null} */ let currentLoadDecodeInfo = null; + /** @type {number|null} */ + let _deferredHistogramTimer = null; + /** @type {{resourceUri: string, format: string, raw: any}|null} */ + let _previousDecodedImageCache = null; + /** @type {{resourceUri: string, format: string, raw: any}|null} */ + let _restoreDecodedImageCandidate = null; function formatDecodeInfo() { return currentLoadDecodeInfo @@ -323,31 +345,8 @@ import { LayersPanel } from './modules/layers-panel.js'; // Load image based on file extension const src = settings.src ?? ''; - if (resourceUri.toLowerCase().endsWith('.tif') || resourceUri.toLowerCase().endsWith('.tiff')) { - handleTiff(src); - } else if (resourceUri.toLowerCase().endsWith('.exr')) { - handleExr(src); - } else if (resourceUri.toLowerCase().endsWith('.pfm')) { - handlePfm(src); - } else if (resourceUri.toLowerCase().endsWith('.ppm') || resourceUri.toLowerCase().endsWith('.pgm') || resourceUri.toLowerCase().endsWith('.pbm')) { - handlePpm(src); - } else if (resourceUri.toLowerCase().endsWith('.png') || resourceUri.toLowerCase().endsWith('.jpg') || resourceUri.toLowerCase().endsWith('.jpeg')) { - handlePng(src); - } else if (resourceUri.toLowerCase().endsWith('.npy') || resourceUri.toLowerCase().endsWith('.npz')) { - handleNpy(src); - } else if (resourceUri.toLowerCase().endsWith('.hdr')) { - handleHdr(src); - } else if (resourceUri.toLowerCase().endsWith('.tga')) { - handleTga(src); - } else if (resourceUri.toLowerCase().match(/\.(webp|avif|bmp|ico)$/)) { - handleWebImage(src); - } else if (resourceUri.toLowerCase().endsWith('.jxl')) { - handleJxl(src); - } else if (isRawExtension(resourceUri.toLowerCase())) { - handleRaw(src); - } else { - image.src = src; - } + beginDirectLoadTrace('open', resourceUri); + loadImageByType(src, resourceUri, _loadGeneration); // Restore comparison state if we have peer images if (peerImageUris.length > 0) { @@ -405,6 +404,7 @@ import { LayersPanel } from './modules/layers-panel.js'; imageElement = null; primaryImageData = null; peerImageData = null; + disposeWebglRenderers(); // Reset each processor's initial-load flag so the reload re-sends // formatInfo (refreshing currentFormatInfo and per-format settings). @@ -448,31 +448,8 @@ import { LayersPanel } from './modules/layers-panel.js'; // Load image based on file extension const reloadSrc = settings.src ?? ''; - if (resourceUri.toLowerCase().endsWith('.tif') || resourceUri.toLowerCase().endsWith('.tiff')) { - handleTiff(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.exr')) { - handleExr(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.pfm')) { - handlePfm(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.ppm') || resourceUri.toLowerCase().endsWith('.pgm') || resourceUri.toLowerCase().endsWith('.pbm')) { - handlePpm(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.png') || resourceUri.toLowerCase().endsWith('.jpg') || resourceUri.toLowerCase().endsWith('.jpeg')) { - handlePng(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.npy') || resourceUri.toLowerCase().endsWith('.npz')) { - handleNpy(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.hdr')) { - handleHdr(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.tga')) { - handleTga(reloadSrc); - } else if (resourceUri.toLowerCase().match(/\.(webp|avif|bmp|ico)$/)) { - handleWebImage(reloadSrc); - } else if (resourceUri.toLowerCase().endsWith('.jxl')) { - handleJxl(reloadSrc); - } else if (isRawExtension(resourceUri.toLowerCase())) { - handleRaw(reloadSrc); - } else { - image.src = reloadSrc; - } + beginDirectLoadTrace('reload', resourceUri); + loadImageByType(reloadSrc, resourceUri, _loadGeneration); } /** @@ -504,6 +481,19 @@ import { LayersPanel } from './modules/layers-panel.js'; logToOutput(message); }); + /** + * Start a detailed trace for a direct image load. Collection switches and + * layer adds have their own labels; this covers initial open and reload. + * @param {string} action + * @param {string} resourceUri + */ + function beginDirectLoadTrace(action, resourceUri) { + let name = resourceUri || 'image'; + try { name = decodeURIComponent(name.split('/').pop() || name); } + catch { name = name.split('/').pop() || name; } + PerfTrace.begin(`${action} ${name}`); + } + /** * Helper to render ImageData to canvas using createImageBitmap for performance * @param {ImageData} imageData @@ -540,6 +530,62 @@ import { LayersPanel } from './modules/layers-panel.js'; PerfTrace.mark('canvas-upload'); } + /** + * A canvas that has a WebGL context can never acquire a 2D context. When a + * later operation needs CPU ImageData rendering, replace it with a fresh + * canvas of the same size and styling. + * @returns {CanvasRenderingContext2D|null} + */ + function ensure2dCanvasContext() { + if (!canvas) { return null; } + let ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { return ctx; } + + const replacement = document.createElement('canvas'); + replacement.width = canvas.width; + replacement.height = canvas.height; + replacement.className = canvas.className; + replacement.style.cssText = canvas.style.cssText; + if (imageElement === canvas && canvas.parentElement) { + canvas.replaceWith(replacement); + } + canvas = replacement; + imageElement = replacement; + zoomController.setCanvas(canvas); + zoomController.setImageElement(imageElement); + mouseHandler.setImageElement(imageElement); + mouseHandler.addMouseListeners(imageElement); + ctx = canvas.getContext('2d', { willReadFrequently: true }); + return ctx; + } + + /** + * Read the displayed canvas pixels from either a 2D or WebGL2-backed canvas. + * WebGL readPixels is bottom-left origin, so rows are flipped into ImageData. + * @param {HTMLCanvasElement} sourceCanvas + * @returns {ImageData|null} + */ + function readDisplayedCanvasImageData(sourceCanvas) { + const ctx = sourceCanvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { + return ctx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height); + } + const gl = sourceCanvas.getContext('webgl2'); + if (!gl) { return null; } + const width = sourceCanvas.width; + const height = sourceCanvas.height; + const bottomUp = new Uint8Array(width * height * 4); + gl.readPixels(0, 0, width, height, gl.RGBA, gl.UNSIGNED_BYTE, bottomUp); + const topDown = new Uint8ClampedArray(bottomUp.length); + const rowBytes = width * 4; + for (let y = 0; y < height; y++) { + const src = (height - 1 - y) * rowBytes; + const dst = y * rowBytes; + topDown.set(bottomUp.subarray(src, src + rowBytes), dst); + } + return new ImageData(topDown, width, height); + } + /** * Setup image loading handlers */ @@ -619,9 +665,10 @@ import { LayersPanel } from './modules/layers-panel.js'; primaryImageData = result.imageData; imageElement = canvas; - // Draw the processed image data to canvas - const ctx = canvas.getContext('2d'); - if (ctx) { + // Deferred TIFF renders must not create a 2D context here; doing so + // would prevent the later WebGL2 render path from using this canvas. + const ctx = tiffProcessor._pendingRenderData ? null : canvas.getContext('2d'); + if (ctx && primaryImageData) { await renderImageDataToCanvas(primaryImageData, ctx); } @@ -666,9 +713,10 @@ import { LayersPanel } from './modules/layers-panel.js'; primaryImageData = result.imageData; imageElement = canvas; - // Draw the processed image data to canvas - const ctx = canvas.getContext('2d'); - if (ctx) { + // Deferred float renders may use WebGL2, so don't create a 2D + // context for their placeholder canvases. + const ctx = exrProcessor._pendingRenderData ? null : canvas.getContext('2d'); + if (ctx && primaryImageData) { await renderImageDataToCanvas(primaryImageData, ctx); } @@ -703,8 +751,8 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = result.canvas; primaryImageData = result.imageData; imageElement = canvas; - const ctx = canvas.getContext('2d'); - if (ctx) { + const ctx = pfmProcessor._pendingRenderData ? null : canvas.getContext('2d'); + if (ctx && primaryImageData) { await renderImageDataToCanvas(primaryImageData, ctx); } hasLoadedImage = true; @@ -737,8 +785,8 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = result.canvas; primaryImageData = result.imageData; imageElement = canvas; - const ctx = canvas.getContext('2d'); - if (ctx) { + const ctx = ppmProcessor._pendingRenderData ? null : canvas.getContext('2d'); + if (ctx && primaryImageData) { await renderImageDataToCanvas(primaryImageData, ctx); } hasLoadedImage = true; @@ -771,7 +819,7 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = result.canvas; primaryImageData = result.imageData; imageElement = result.displayElement || canvas; - const ctx = canvas.getContext('2d'); + const ctx = pngProcessor._pendingRenderData ? null : canvas.getContext('2d'); if (ctx && primaryImageData && !result.canvasAlreadyRendered) { await renderImageDataToCanvas(primaryImageData, ctx); } @@ -805,8 +853,8 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = result.canvas; primaryImageData = result.imageData; imageElement = canvas; - const ctx = canvas.getContext('2d'); - if (ctx) { + const ctx = npyProcessor._pendingRenderData ? null : canvas.getContext('2d'); + if (ctx && primaryImageData) { await renderImageDataToCanvas(primaryImageData, ctx); } hasLoadedImage = true; @@ -835,7 +883,7 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = result.canvas; primaryImageData = result.imageData; imageElement = canvas; - const ctx = canvas.getContext('2d'); + const ctx = hdrProcessor._pendingRenderData ? null : canvas.getContext('2d'); if (ctx) { await renderImageDataToCanvas(primaryImageData, ctx); } @@ -1033,6 +1081,7 @@ import { LayersPanel } from './modules/layers-panel.js'; } mouseHandler.addMouseListeners(imageElement); + PerfTrace.mark('finalize-dom'); // Note: Histogram visibility is restored via restoreHistogramState message // when webview becomes active (sent from ImagePreview.sendHistogramState) @@ -1040,10 +1089,17 @@ import { LayersPanel } from './modules/layers-panel.js'; // Update histogram if visible updateHistogramData(); - // Keep the layer stack's base in sync with the loaded image. - syncBaseLayer(); + // Keep the layer stack's base in sync only when the layer system needs it. + // Browser-native images without raw buffers otherwise force a full-canvas readback. + if (shouldSyncBaseLayer()) { + syncBaseLayer(); + PerfTrace.mark('layers-sync'); + } else { + PerfTrace.detail('layers-sync-skipped', 0); + } // Restore a saved layer stack after a webview reload (once the base exists). maybeRestoreLayers(); + PerfTrace.mark('layers-restore'); // In a dedicated Layers window, open the panel automatically on first load // and ask the extension for any images to stack on top (e.g. a collection). if (settingsManager.settings.surfaceMode === 'layers' && !_layerSurfaceShown) { @@ -1055,6 +1111,7 @@ import { LayersPanel } from './modules/layers-panel.js'; } if (layerManager.active && layerManager.hasExtraLayers()) { recompositeLayers(); + PerfTrace.mark('layers-recomposite'); } // Close the switch trace once the final pixels are shown. With a deferred @@ -1125,10 +1182,9 @@ import { LayersPanel } from './modules/layers-panel.js'; */ function baseFromCanvas(name, uri) { if (!canvas) { return null; } - const ctx = canvas.getContext('2d'); - if (!ctx) { return null; } const w = canvas.width, h = canvas.height; - const img = ctx.getImageData(0, 0, w, h); + const img = readDisplayedCanvasImageData(canvas); + if (!img) { return null; } const data = new Float32Array(img.data.length); for (let i = 0; i < img.data.length; i++) { data[i] = img.data[i]; } return { data, width: w, height: h, channels: 4, isFloat: false, typeMax: 255, name, uri }; @@ -1149,6 +1205,7 @@ import { LayersPanel } from './modules/layers-panel.js'; case 'PPM/PGM': return lastRawToLayer(ppmProcessor._lastRaw, { isFloat: false, typeMax: (ppmProcessor._lastRaw && ppmProcessor._lastRaw.maxval) || 255 }, name, uri) || baseFromCanvas(name, uri); case 'PNG/JPEG': return lastRawToLayer(pngProcessor._lastRaw, { isFloat: false, typeMax: (pngProcessor._lastRaw && pngProcessor._lastRaw.maxValue) || 255 }, name, uri) || baseFromCanvas(name, uri); case 'NPY/NPZ': return lastRawToLayer(npyProcessor._lastRaw, npyTypeInfo(npyProcessor._lastRaw && npyProcessor._lastRaw.dtype), name, uri) || baseFromCanvas(name, uri); + case 'HDR': return lastRawToLayer(hdrProcessor._lastRaw, { isFloat: true, typeMax: 1.0 }, name, uri) || baseFromCanvas(name, uri); default: return baseFromCanvas(name, uri); } } @@ -1228,6 +1285,14 @@ import { LayersPanel } from './modules/layers-panel.js'; } /** (Re)synchronize the base layer with the current primary image. */ + function shouldSyncBaseLayer() { + return layerManager.active || + layersPanel.isVisible() || + !!_pendingLayerRestore || + settingsManager.settings.surfaceMode === 'layers' || + layerManager.hasExtraLayers(); + } + function syncBaseLayer() { const base = deriveBaseLayer(); if (!base) { return; } @@ -1263,19 +1328,35 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas.width = imageData.width; canvas.height = imageData.height; } - const ctx = canvas.getContext('2d'); + const ctx = ensure2dCanvasContext(); if (ctx) { - renderImageDataToCanvas(imageData, ctx); + void renderImageDataToCanvas(imageData, ctx); primaryImageData = imageData; updateHistogramData(); } return true; } - // Coalesce rapid recomposite requests (e.g. dragging the opacity slider) into - // at most one composite per animation frame so the UI stays responsive. + // Coalesce rapid recomposite requests. Interactive drags get a short trailing + // debounce because a full large-layer composite can take hundreds of ms or + // seconds, and running one for every slider event makes the slider itself lag. let _recompositeScheduled = false; - function scheduleRecomposite() { + /** @type {ReturnType|null} */ + let _interactiveRecompositeTimer = null; + /** @param {number} [delayMs] */ + function scheduleRecomposite(delayMs = 0) { + if (delayMs > 0) { + if (_interactiveRecompositeTimer) { clearTimeout(_interactiveRecompositeTimer); } + _interactiveRecompositeTimer = setTimeout(() => { + _interactiveRecompositeTimer = null; + scheduleRecomposite(0); + }, delayMs); + return; + } + if (_interactiveRecompositeTimer) { + clearTimeout(_interactiveRecompositeTimer); + _interactiveRecompositeTimer = null; + } if (_recompositeScheduled) { return; } _recompositeScheduled = true; requestAnimationFrame(() => { @@ -1454,20 +1535,36 @@ import { LayersPanel } from './modules/layers-panel.js'; break; case 'addLayerImages': { + const images = message.images || []; + const imageLabel = `${images.length} image${images.length === 1 ? '' : 's'}`; + PerfTrace.begin(`add-layer ${imageLabel}`, { conciseLabel: `Layer add (${imageLabel}) completed` }); syncBaseLayer(); - layersPanel.show(); + PerfTrace.mark('layers-base-sync'); + const wasLayerActive = layerManager.active; + layersPanel.show({ notify: false }); + if (!wasLayerActive) { + layerManager.active = true; + vscode.postMessage({ type: 'layerModeChanged', active: true }); + } + PerfTrace.mark('layers-panel-show'); let addedLayers = 0; - for (const im of (message.images || [])) { + for (const im of images) { const layer = await decodeLayer(im.src, im.resourceUri); + PerfTrace.mark('layer-decode'); if (layer) { layerManager.addLayer(layer); addedLayers++; } } if (addedLayers > 0) { layersPanel.refresh(); + PerfTrace.mark('layers-panel-refresh'); recompositeLayers(); + PerfTrace.mark('layers-recomposite-submit'); scheduleSaveState(); + PerfTrace.mark('layers-state-save-scheduled'); } else { vscode.postMessage({ type: 'show-error', message: 'Could not load the selected image(s) as layers.' }); + PerfTrace.mark('layers-add-failed'); } + PerfTrace.end(); break; } @@ -1509,9 +1606,12 @@ import { LayersPanel } from './modules/layers-panel.js'; break; case 'updateSettings': + const updateMessageStart = performance.now(); // Handle real-time settings updates const oldResourceUri = settingsManager.settings.resourceUri; + const updateApplyStart = performance.now(); const changes = settingsManager.updateSettings(message.settings); + const updateApplyDuration = performance.now() - updateApplyStart; const newResourceUri = settingsManager.settings.resourceUri; const updateReason = message.reason || (message.isInitialRender ? 'initial-render' : 'unspecified'); @@ -1520,7 +1620,9 @@ import { LayersPanel } from './modules/layers-panel.js'; // initial-settings response while that canvas is still in flight. if (message.isInitialRender && currentLoadFormat === 'TIFF' && !canvas) { const waitingGeneration = _loadGeneration; + const canvasWaitStart = performance.now(); await _tiffCanvasReadyPromise; + PerfTrace.detail('await-settings-tiff-canvas-wait', performance.now() - canvasWaitStart); if (waitingGeneration !== _loadGeneration) { break; } @@ -1530,26 +1632,64 @@ import { LayersPanel } from './modules/layers-panel.js'; if (message.isInitialRender && canvas) { // Time between formatInfo going out and per-format settings // coming back — extension-host latency, not main-thread work. + if (lastFormatInfoPost && lastFormatInfoPost.generation === _loadGeneration) { + PerfTrace.detail('await-settings-roundtrip', updateMessageStart - lastFormatInfoPost.time); + } + PerfTrace.detail('settings-apply', updateApplyDuration); PerfTrace.mark('await-settings'); // Trigger deferred rendering for the appropriate processor let deferredImageData = null; let deferredCanvasAlreadyRendered = false; if (tiffProcessor._pendingRenderData) { - deferredImageData = await tiffProcessor.performDeferredRender(); + deferredImageData = await tiffProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = tiffProcessor._lastRenderUsedWebGL === true; } else if (npyProcessor._pendingRenderData) { - deferredImageData = npyProcessor.performDeferredRender(); + deferredImageData = npyProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = npyProcessor._lastRenderUsedWebGL === true; } else if (pngProcessor._pendingRenderData) { - deferredImageData = pngProcessor.performDeferredRender(); - deferredCanvasAlreadyRendered = pngProcessor._lastRenderReusedOriginalImageData === true; + deferredImageData = pngProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = pngProcessor._lastRenderReusedOriginalImageData === true || pngProcessor._lastRenderUsedWebGL === true; } else if (ppmProcessor._pendingRenderData) { - deferredImageData = ppmProcessor.performDeferredRender(); + deferredImageData = ppmProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = ppmProcessor._lastRenderUsedWebGL === true; } else if (pfmProcessor._pendingRenderData) { - deferredImageData = pfmProcessor.performDeferredRender(); + deferredImageData = pfmProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = pfmProcessor._lastRenderUsedWebGL === true; } else if (exrProcessor._pendingRenderData) { - deferredImageData = exrProcessor.updateSettings(settingsManager.settings); + deferredImageData = exrProcessor.updateSettings(settingsManager.settings, { + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = exrProcessor._lastRenderUsedWebGL === true; } else if (hdrProcessor._pendingRenderData) { - deferredImageData = hdrProcessor.performDeferredRender(); + deferredImageData = hdrProcessor.performDeferredRender({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); + deferredCanvasAlreadyRendered = hdrProcessor._lastRenderUsedWebGL === true; } else if (tgaProcessor._pendingRenderData) { deferredImageData = tgaProcessor.performDeferredRender(); } else if (webImageProcessor._pendingRenderData) { @@ -1561,12 +1701,15 @@ import { LayersPanel } from './modules/layers-panel.js'; } if (deferredImageData) { - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (ctx) { - if (!deferredCanvasAlreadyRendered) { + if (deferredCanvasAlreadyRendered) { + PerfTrace.mark('canvas-upload-skipped'); + primaryImageData = deferredImageData; + } else { + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (ctx) { await renderImageDataToCanvas(deferredImageData, ctx); + primaryImageData = deferredImageData; } - primaryImageData = deferredImageData; } // Canvas now has real pixels — swap out old canvas and finalize @@ -1633,12 +1776,6 @@ import { LayersPanel } from './modules/layers-panel.js'; extensionLoadStartTime = message.timestamp; break; - case 'mask-filter-settings': - // Handle mask filter settings updates - const maskChanges = settingsManager.updateSettings(message.settings); - updateImageWithNewSettings(maskChanges); - break; - case 'updateImageCollectionOverlay': updateImageCollectionOverlay(message.data); break; @@ -1776,7 +1913,6 @@ import { LayersPanel } from './modules/layers-panel.js'; changed: true, changedKeys: ['displayColormap'], parametersOnly: true, - changedMasks: false, changedStructure: false }); } @@ -1795,13 +1931,54 @@ import { LayersPanel } from './modules/layers-panel.js'; return; } try { + if (_deferredHistogramTimer !== null) { + clearTimeout(_deferredHistogramTimer); + _deferredHistogramTimer = null; + } if (pngProcessor && pngProcessor.hasLazyNativeReadback()) { + const sampledImageData = pngProcessor.getLazyNativeHistogramImageData(1_000_000); + if (sampledImageData) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.update(sampledImageData, { settings: settingsManager.settings, sampleStep: 1 }); + return; + } const imageData = pngProcessor.renderPngWithSettings(); if (imageData) { primaryImageData = imageData; } } + if (tiffProcessor.rawTiffData && tiffProcessor._lastRenderHistogram) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.updateFromPrecomputed(tiffProcessor._lastRenderHistogram); + PerfTrace.mark('histogram-from-render'); + return; + } + if (exrProcessor.rawExrData && exrProcessor._lastRenderHistogram) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.updateFromPrecomputed(exrProcessor._lastRenderHistogram); + PerfTrace.mark('histogram-from-render'); + return; + } + if (npyProcessor._lastRaw && npyProcessor._lastRenderHistogram) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.updateFromPrecomputed(npyProcessor._lastRenderHistogram); + PerfTrace.mark('histogram-from-render'); + return; + } + if (pfmProcessor._lastRaw && pfmProcessor._lastRenderHistogram) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.updateFromPrecomputed(pfmProcessor._lastRenderHistogram); + PerfTrace.mark('histogram-from-render'); + return; + } + if (hdrProcessor._lastRaw && hdrProcessor._lastRenderHistogram) { + PerfTrace.mark('histogram-prepare'); + histogramOverlay.updateFromPrecomputed(hdrProcessor._lastRenderHistogram); + PerfTrace.mark('histogram-from-render'); + return; + } + const settings = settingsManager.settings; - /** @type {object} */ + /** @type {any} */ let histogramOptions = { settings: settings }; @@ -1878,6 +2055,19 @@ import { LayersPanel } from './modules/layers-panel.js'; const { width, height, data, channels } = pfmProcessor._lastRaw; const stats = pfmProcessor._cachedStats || null; + histogramOptions = { + ...histogramOptions, + rawData: data, + channels: channels, + isFloat: true, + typeMax: 1.0, + stats: stats + }; + } else if (hdrProcessor && hdrProcessor._lastRaw) { + // HDR raw data (float RGBA from parse-hdr; alpha is ignored by histogram stats) + const { width, height, data, channels } = hdrProcessor._lastRaw; + const stats = hdrProcessor._cachedStats || null; + histogramOptions = { ...histogramOptions, rawData: data, @@ -1914,10 +2104,38 @@ import { LayersPanel } from './modules/layers-panel.js'; }; } + PerfTrace.mark('histogram-prepare'); + // Get canvas image data as fallback - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) return; - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + let imageData = null; + if (!histogramOptions.rawData && !histogramOptions.planarData) { + imageData = readDisplayedCanvasImageData(canvas); + if (!imageData) return; + PerfTrace.mark('histogram-canvas-readback'); + } + + const rawPixelCount = histogramOptions.planarData + ? (histogramOptions.planarData[0]?.length || 0) + : (histogramOptions.rawData ? Math.floor(histogramOptions.rawData.length / (histogramOptions.channels || 1)) : 0); + const largeRawHistogram = rawPixelCount > 4_000_000 && (histogramOptions.rawData || histogramOptions.planarData); + if (largeRawHistogram) { + const sampleStep = Math.max(2, Math.ceil(rawPixelCount / 1_000_000)); + histogramOverlay.update(imageData, { ...histogramOptions, sampleStep }); + const generation = _loadGeneration; + const runExact = () => { + _deferredHistogramTimer = null; + if (generation !== _loadGeneration || !histogramOverlay.getVisibility()) { return; } + try { + const exactStart = performance.now(); + histogramOverlay.update(imageData, histogramOptions); + console.log(`[Histogram] Deferred exact update took ${(performance.now() - exactStart).toFixed(1)}ms`); + } catch (error) { + console.error('Error updating exact histogram:', error); + } + }; + _deferredHistogramTimer = window.setTimeout(runExact, 250); + return; + } // Update histogram overlay histogramOverlay.update(imageData, histogramOptions); @@ -1942,6 +2160,7 @@ import { LayersPanel } from './modules/layers-panel.js'; if (webImageProcessor) webImageProcessor._lastRaw = null; if (jxlProcessor) jxlProcessor._lastRaw = null; if (rawProcessor) rawProcessor._lastRaw = null; + disposeWebglRenderers(); } /** @@ -1978,15 +2197,14 @@ import { LayersPanel } from './modules/layers-panel.js'; } try { - const ctx = canvas.getContext('2d', { willReadFrequently: true }); - if (!ctx) { + const imageData = readDisplayedCanvasImageData(canvas); + if (!imageData) { console.error('Could not get canvas context'); return; } // 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; @@ -2128,12 +2346,7 @@ import { LayersPanel } from './modules/layers-panel.js'; // Default to full update if no change info provided if (!changes) { - changes = { changed: true, changedKeys: ['unspecified'], parametersOnly: false, changedMasks: false, changedStructure: false }; - } - - // If masks changed, clear the mask cache - if (changes.changedMasks && tiffProcessor._maskCache) { - tiffProcessor.clearMaskCache(); + changes = { changed: true, changedKeys: ['unspecified'], parametersOnly: false, changedStructure: false }; } // For TIFF images, optimize based on what changed @@ -2146,15 +2359,26 @@ import { LayersPanel } from './modules/layers-panel.js'; const newImageData = await tiffProcessor.renderTiffWithSettingsFast( tiffProcessor.rawTiffData.image, tiffProcessor.rawTiffData.rasters, - true // skipMasks flag + true, // skipMasks flag + { + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + } ); // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx && newImageData) { - await renderImageDataToCanvas(newImageData, ctx); + if (tiffProcessor._lastRenderUsedWebGL && newImageData) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx && newImageData) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } return; } @@ -2162,16 +2386,27 @@ import { LayersPanel } from './modules/layers-panel.js'; // Fallback to full re-render for structural changes or mask changes const newImageData = await tiffProcessor.renderTiffWithSettings( tiffProcessor.rawTiffData.image, - tiffProcessor.rawTiffData.rasters + tiffProcessor.rawTiffData.rasters, + { + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + } ); // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx && newImageData) { - console.log('✅ CANVAS UPDATE (TIFF slow path): Applying new ImageData to canvas'); - await renderImageDataToCanvas(newImageData, ctx); + if (tiffProcessor._lastRenderUsedWebGL && newImageData) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx && newImageData) { + console.log('✅ CANVAS UPDATE (TIFF slow path): Applying new ImageData to canvas'); + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } console.log('✨ Slow path complete, returning'); return; // Don't fall through to other processors @@ -2190,16 +2425,26 @@ import { LayersPanel } from './modules/layers-panel.js'; console.log('📄 Processing EXR update'); try { // Re-render the EXR with current settings - const newImageData = exrProcessor.updateSettings(settingsManager.settings); + const newImageData = exrProcessor.updateSettings(settingsManager.settings, { + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx) { - console.log('✅ CANVAS UPDATE (EXR): Applying new ImageData to canvas'); - await renderImageDataToCanvas(newImageData, ctx); + if (exrProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + console.log('✅ CANVAS UPDATE (EXR): Applying new ImageData to canvas'); + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } } } catch (error) { @@ -2211,16 +2456,26 @@ import { LayersPanel } from './modules/layers-panel.js'; if (primaryImageData && ppmProcessor && ppmProcessor._lastRaw) { try { // Re-render the PGM with current settings - const newImageData = ppmProcessor.renderPgmWithSettings(); + const newImageData = ppmProcessor.renderPgmWithSettings({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx) { - await renderImageDataToCanvas(newImageData, ctx); + if (ppmProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; - swapImageElementToCanvas(); updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + swapImageElementToCanvas(); + updateHistogramData(); + } } } } catch (error) { @@ -2233,15 +2488,25 @@ import { LayersPanel } from './modules/layers-panel.js'; if (primaryImageData && pfmProcessor && pfmProcessor._lastRaw) { try { // Re-render the PFM with current settings - const newImageData = pfmProcessor.renderPfmWithSettings(); + const newImageData = pfmProcessor.renderPfmWithSettings({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx) { - await renderImageDataToCanvas(newImageData, ctx); + if (pfmProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } } } catch (error) { @@ -2254,15 +2519,25 @@ import { LayersPanel } from './modules/layers-panel.js'; if (primaryImageData && npyProcessor && npyProcessor._lastRaw) { try { // Re-render the NPY with current settings - const newImageData = npyProcessor.renderNpyWithSettings(); + const newImageData = npyProcessor.renderNpyWithSettings({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx) { - await renderImageDataToCanvas(newImageData, ctx); + if (npyProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } } } catch (error) { @@ -2275,16 +2550,26 @@ import { LayersPanel } from './modules/layers-panel.js'; if (pngProcessor && (pngProcessor._lastRaw || pngProcessor.hasLazyNativeReadback())) { try { // Re-render the PNG with current settings - const newImageData = pngProcessor.renderPngWithSettings(); + const newImageData = pngProcessor.renderPngWithSettings({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { // Update the canvas with new image data - const ctx = canvas.getContext('2d'); - if (ctx) { - await renderImageDataToCanvas(newImageData, ctx); + if (pngProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; - swapImageElementToCanvas(); updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + swapImageElementToCanvas(); + updateHistogramData(); + } } } } catch (error) { @@ -2296,13 +2581,23 @@ import { LayersPanel } from './modules/layers-panel.js'; // For HDR images, re-render with new settings if (primaryImageData && hdrProcessor && hdrProcessor._lastRaw) { try { - const newImageData = hdrProcessor.renderHdrWithSettings(); + const newImageData = hdrProcessor.renderHdrWithSettings({ + collectHistogram: histogramOverlay.getVisibility(), + targetCanvas: canvas, + placeholderImageData: primaryImageData + }); if (newImageData) { - const ctx = canvas.getContext('2d'); - if (ctx) { - await renderImageDataToCanvas(newImageData, ctx); + if (hdrProcessor._lastRenderUsedWebGL) { + PerfTrace.mark('canvas-upload-skipped'); primaryImageData = newImageData; updateHistogramData(); + } else { + const ctx = ensure2dCanvasContext(); + if (ctx) { + await renderImageDataToCanvas(newImageData, ctx); + primaryImageData = newImageData; + updateHistogramData(); + } } } } catch (error) { @@ -2904,6 +3199,194 @@ import { LayersPanel } from './modules/layers-panel.js'; } } + function cacheCurrentDecodedImage() { + const resourceUri = settingsManager.settings.resourceUri; + if (!resourceUri || !hasLoadedImage) { return; } + const lower = resourceUri.toLowerCase(); + let entry = null; + if ((lower.endsWith('.tif') || lower.endsWith('.tiff')) && tiffProcessor.rawTiffData) { + entry = { + resourceUri, + format: 'tiff', + raw: { + tiffData: tiffProcessor.rawTiffData, + lastStatistics: tiffProcessor._lastStatistics, + lastStatisticsRgb24Mode: tiffProcessor._lastStatisticsRgb24Mode, + convertedFloatData: tiffProcessor._convertedFloatData, + formatInfo: currentFormatInfo ? { ...currentFormatInfo } : null + } + }; + } else if (lower.endsWith('.exr') && exrProcessor.rawExrData) { + entry = { resourceUri, format: 'exr', raw: exrProcessor.rawExrData }; + } else if ((lower.endsWith('.npy') || lower.endsWith('.npz')) && npyProcessor._lastRaw) { + entry = { resourceUri, format: 'npy', raw: npyProcessor._lastRaw }; + } else if (lower.endsWith('.pfm') && pfmProcessor._lastRaw) { + entry = { resourceUri, format: 'pfm', raw: pfmProcessor._lastRaw }; + } else if ((lower.endsWith('.ppm') || lower.endsWith('.pgm') || lower.endsWith('.pbm')) && ppmProcessor._lastRaw) { + entry = { resourceUri, format: 'ppm', raw: ppmProcessor._lastRaw }; + } else if (lower.endsWith('.png') && pngProcessor._lastRaw && pngProcessor._lastRaw.bitDepth > 8) { + entry = { resourceUri, format: 'png', raw: pngProcessor._lastRaw }; + } else if (lower.endsWith('.hdr') && hdrProcessor._lastRaw) { + entry = { resourceUri, format: 'hdr', raw: hdrProcessor._lastRaw }; + } + _previousDecodedImageCache = entry; + } + + /** + * @param {number} width + * @param {number} height + */ + function installCachedPlaceholder(width, height) { + canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvas.classList.add('scale-to-fit'); + primaryImageData = new ImageData(width, height); + imageElement = canvas; + hasLoadedImage = true; + PerfTrace.mark('decoded-cache-hit'); + } + + /** @param {any} raw */ + function postCachedExrFormatInfo(raw) { + vscode.postMessage({ + type: 'formatInfo', + value: { + width: raw.width, + height: raw.height, + channels: raw.channels, + samplesPerPixel: raw.channels, + dataType: raw.type === 1016 ? 'float16' : 'float32', + isHdr: true, + formatLabel: 'EXR', + formatType: 'exr-float', + isInitialLoad: true, + channelNames: raw.channelNames || [], + displayedChannels: raw.displayedChannels || raw.channelNames || [] + } + }); + } + + /** @param {string} resourceUri */ + function tryRestoreDecodedImageFromCache(resourceUri) { + const cache = _restoreDecodedImageCandidate; + if (!cache || cache.resourceUri !== resourceUri) { return false; } + const raw = cache.raw; + currentLoadDecodeInfo = null; + switch (cache.format) { + case 'tiff': { + const tiffData = raw.tiffData; + const image = tiffData?.image; + const rasters = tiffData?.rasters; + if (!image || !rasters) { return false; } + currentLoadFormat = 'TIFF'; + currentLoadDecodeInfo = { engine: 'decoded-cache', durationMs: 0 }; + tiffProcessor.rawTiffData = tiffData; + tiffProcessor._lastStatistics = raw.lastStatistics || null; + tiffProcessor._lastStatisticsRgb24Mode = raw.lastStatisticsRgb24Mode === true; + tiffProcessor._convertedFloatData = raw.convertedFloatData || null; + tiffProcessor._lastRenderHistogram = null; + tiffProcessor._lastRenderUsedWebGL = false; + tiffProcessor._isInitialLoad = true; + tiffProcessor._pendingRenderData = { image, rasters }; + installCachedPlaceholder(image.getWidth(), image.getHeight()); + const sampleFormat = image.getSampleFormat?.(); + const bitsPerSample = image.getBitsPerSample?.(); + const samplesPerPixel = image.getSamplesPerPixel?.(); + const sampleFormatValue = Array.isArray(sampleFormat) ? sampleFormat[0] : sampleFormat; + vscode.postMessage({ + type: 'formatInfo', + value: { + width: image.getWidth(), + height: image.getHeight(), + sampleFormat, + samplesPerPixel, + bitsPerSample, + planarConfig: tiffData.ifd?.t284 ?? 1, + formatType: sampleFormatValue === 3 ? 'tiff-float' : 'tiff-int', + ...(raw.formatInfo || {}), + isInitialLoad: true, + decodedWith: 'decoded-cache' + } + }); + return true; + } + case 'exr': + currentLoadFormat = 'EXR'; + exrProcessor.rawExrData = raw; + exrProcessor._cachedStats = undefined; + exrProcessor._isInitialLoad = true; + exrProcessor._pendingRenderData = { + width: raw.width, + height: raw.height, + data: raw.data, + channels: raw.channels, + type: raw.type, + format: raw.format + }; + installCachedPlaceholder(raw.width, raw.height); + postCachedExrFormatInfo(raw); + return true; + case 'npy': + currentLoadFormat = 'NPY/NPZ'; + npyProcessor._lastRaw = raw; + npyProcessor._cachedStats = undefined; + npyProcessor._cachedStatsRgb24Mode = false; + npyProcessor._isInitialLoad = true; + npyProcessor._pendingRenderData = { data: raw.data, width: raw.width, height: raw.height }; + installCachedPlaceholder(raw.width, raw.height); + npyProcessor._postFormatInfo(raw.width, raw.height, 'NPY'); + return true; + case 'pfm': + currentLoadFormat = 'PFM'; + pfmProcessor._lastRaw = raw; + pfmProcessor._cachedStats = undefined; + pfmProcessor._isInitialLoad = true; + pfmProcessor._pendingRenderData = { displayData: raw.data, width: raw.width, height: raw.height, channels: raw.channels }; + installCachedPlaceholder(raw.width, raw.height); + pfmProcessor._postFormatInfo(raw.width, raw.height, raw.channels, 'PFM'); + return true; + case 'ppm': + currentLoadFormat = 'PPM/PGM'; + ppmProcessor._lastRaw = raw; + ppmProcessor._cachedStats = undefined; + ppmProcessor._cachedStatsRgb24Mode = false; + ppmProcessor._isInitialLoad = true; + ppmProcessor._pendingRenderData = { + displayData: raw.data, + width: raw.width, + height: raw.height, + maxval: raw.maxval, + channels: raw.channels + }; + installCachedPlaceholder(raw.width, raw.height); + ppmProcessor._postFormatInfo(raw.width, raw.height, raw.channels, raw.format || 'PPM/PGM', raw.maxval); + return true; + case 'png': + currentLoadFormat = 'PNG/JPEG'; + pngProcessor._lastRaw = raw; + pngProcessor._cachedStats = undefined; + pngProcessor._cachedStatsRgb24Mode = false; + pngProcessor._isInitialLoad = true; + pngProcessor._pendingRenderData = true; + installCachedPlaceholder(raw.width, raw.height); + pngProcessor._postFormatInfo(raw.width, raw.height, raw.channels, raw.bitDepth, 'PNG'); + return true; + case 'hdr': + currentLoadFormat = 'HDR'; + hdrProcessor._lastRaw = raw; + hdrProcessor._cachedStats = undefined; + hdrProcessor._cachedWebglRgb = null; + hdrProcessor._isInitialLoad = true; + hdrProcessor._pendingRenderData = { data: raw.data, width: raw.width, height: raw.height, renderChannels: raw.channels }; + installCachedPlaceholder(raw.width, raw.height); + hdrProcessor._postFormatInfo(raw.width, raw.height, 3, 'HDR'); + return true; + default: + return false; + } + } + /** * Switch to a new image in the collection (legacy - for fallback) * @param {string} uri @@ -2916,7 +3399,11 @@ import { LayersPanel } from './modules/layers-panel.js'; // Trace where this switch spends its time; the summary is logged from // finalizeImageSetup once the final pixels are on screen. - PerfTrace.begin(`switch ${resourceUri.split('/').pop()}`); + let switchName = resourceUri.split('/').pop() || 'image'; + try { switchName = decodeURIComponent(switchName); } catch { /* keep encoded name */ } + PerfTrace.begin(`switch ${switchName}`, { conciseLabel: `Collection switch ${switchName} completed` }); + _restoreDecodedImageCandidate = _previousDecodedImageCache; + cacheCurrentDecodedImage(); // Abort the previous in-flight load: cancels its network fetch and lets // the processors stop before decoding, instead of the superseded load @@ -2950,6 +3437,7 @@ import { LayersPanel } from './modules/layers-panel.js'; canvas = null; imageElement = null; primaryImageData = null; + disposeWebglRenderers(); // Reset each processor's initial-load flag so they re-send formatInfo and // trigger the extension to apply the correct per-format settings for the @@ -3006,6 +3494,9 @@ import { LayersPanel } from './modules/layers-panel.js'; if (gen !== _loadGeneration) { return; } PerfTrace.mark('paint-yield'); const lower = resourceUri.toLowerCase(); + if (tryRestoreDecodedImageFromCache(resourceUri)) { + return; + } if (lower.endsWith('.tif') || lower.endsWith('.tiff')) { handleTiff(uri, gen); } else if (lower.endsWith('.exr')) { diff --git a/media/modules/decode-worker-client.js b/media/modules/decode-worker-client.js index 2754421..c8f9b8f 100644 --- a/media/modules/decode-worker-client.js +++ b/media/modules/decode-worker-client.js @@ -240,6 +240,31 @@ export class DecodeWorkerClient { this._startPromise = null; } + /** + * Fetch a source as bytes with consistent performance breakdown for + * worker-decoded formats. + * @param {string} src + * @param {AbortSignal|undefined} signal + * @param {string} format + * @returns {Promise} + */ + static async fetchArrayBuffer(src, signal, format) { + const responseStart = performance.now(); + const response = await fetch(src, { signal }); + PerfTrace.detail(`fetch-${format}-response`, performance.now() - responseStart); + const readStart = performance.now(); + const buffer = await response.arrayBuffer(); + const readDuration = performance.now() - readStart; + PerfTrace.detail(`fetch-${format}-arrayBuffer`, readDuration); + const megabytes = buffer.byteLength / (1024 * 1024); + PerfTrace.note(`fetch-${format}-bytes`, `${megabytes.toFixed(1)}MB`); + if (readDuration > 0) { + PerfTrace.note(`fetch-${format}-arrayBuffer-rate`, `${(megabytes / (readDuration / 1000)).toFixed(0)}MB/s`); + } + PerfTrace.mark(`fetch(${format})`); + return buffer; + } + /** * Decode `buffer` via the worker when possible, falling back to * `parseLocal` on the main thread. The buffer may have been transferred @@ -253,13 +278,34 @@ export class DecodeWorkerClient { * @param {(buffer: ArrayBuffer) => any} parseLocal */ static async decodeWithFallback(client, format, buffer, src, signal, parseLocal) { + const workerStart = performance.now(); const response = client ? await client.decode(format, buffer) : null; + const workerDuration = performance.now() - workerStart; if (signal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } if (response?.ok) { - // Spans the caller's fetch too (it runs just before this helper). - PerfTrace.mark(`fetch+decode-worker(${format})`); + PerfTrace.mark(`decode-worker(${format})`); + if (Array.isArray(response.result?.decodeTimings)) { + let measuredWorkerTime = 0; + let topLevelDecodeTime = 0; + for (const timing of response.result.decodeTimings) { + const durationMs = Number(timing?.durationMs); + if (!Number.isFinite(durationMs)) { continue; } + const name = String(timing.name || `${format}-decode-detail`); + measuredWorkerTime += durationMs; + if ( + name === `decode-${format}-rust` || + name === `decode-${format}-parse-exr` || + name === `decode-${format}-upng` || + name === `decode-${format}-parse` + ) { + topLevelDecodeTime += durationMs; + } + PerfTrace.detail(name, durationMs); + } + PerfTrace.detail(`decode-${format}-worker-transfer+overhead`, workerDuration - (topLevelDecodeTime || measuredWorkerTime)); + } return response.result; } if (response) { @@ -271,7 +317,7 @@ export class DecodeWorkerClient { localBuffer = await refetched.arrayBuffer(); } const result = await parseLocal(localBuffer); - PerfTrace.mark(`fetch+decode-local(${format})`); + PerfTrace.mark(`decode-local(${format})`); return result; } } diff --git a/media/modules/exr-processor.js b/media/modules/exr-processor.js index 8c7b2ab..d1b3cde 100644 --- a/media/modules/exr-processor.js +++ b/media/modules/exr-processor.js @@ -2,6 +2,7 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ @@ -34,6 +35,9 @@ export class ExrProcessor { this._pendingRenderData = null; // Store data waiting for format-specific settings this._isInitialLoad = true; // Track if this is the first render this._cachedStats = undefined; // Cache for min/max stats (only used in stats mode) + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ @@ -80,8 +84,7 @@ export class ExrProcessor { throw new Error('parseExr library not loaded. Make sure parse-exr is included.'); } - const response = await fetch(src, { signal: loadSignal }); - const buffer = await response.arrayBuffer(); + const buffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'exr'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Invalidate stats cache for new image @@ -96,19 +99,25 @@ export class ExrProcessor { this.decodeWorker, 'exr', buffer, src, loadSignal, // @ts-ignore (b) => parseExr(b, FloatType)); + if (exrResult.wasmFallbackReason) { + console.warn('[ExrProcessor] Rust EXR decoder fell back to parse-exr:', exrResult.wasmFallbackReason); + } const { width, height, data, format, type, channelNames, displayedChannels } = exrResult; + const flipY = exrResult.flipY !== false; // Determine channels based on format // RGBAFormat = 1023, RedFormat = 1028 let channels; - if (format === 1023) { // RGBA + const pixelCount = width * height; + if (Array.isArray(displayedChannels) && displayedChannels.length > 0 && data.length === pixelCount * displayedChannels.length) { + channels = displayedChannels.length; + } else if (format === 1023) { // RGBA channels = 4; } else if (format === 1028) { // Red (grayscale) channels = 1; } else { // Fallback: try to detect from data length - const pixelCount = width * height; const totalValues = data.length; channels = totalValues / pixelCount; } @@ -127,7 +136,8 @@ export class ExrProcessor { type, // 1015 = Float32, 1016 = HalfFloat format, isFloat: true, // EXR is always floating point - channelNames: channelNames || [] // Original EXR channel names + channelNames: channelNames || [], // Original EXR channel names + flipY }; // Send format information to VS Code BEFORE rendering @@ -181,18 +191,20 @@ export class ExrProcessor { * @param {ImageSettings} settings - Current rendering settings * @returns {ImageData} - Rendered image data */ - renderExrToCanvas(settings) { + renderExrToCanvas(settings, renderOptions = {}) { + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; if (!this.rawExrData) { throw new Error('No EXR data loaded'); } - const { width, height, data, channels } = this.rawExrData; + const { width, height, data, channels, flipY } = this.rawExrData; const isGammaMode = settings.normalization?.gammaMode || false; // Calculate stats if needed (for auto-normalize or just to have them) /** @type {{min: number, max: number} | undefined} */ let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { stats = ImageStatsCalculator.calculateFloatStats(data, width, height, channels); this._cachedStats = stats; @@ -211,11 +223,38 @@ export class ExrProcessor { const nanColor = this._getNanColor(settings); + if (renderOptions.targetCanvas && this._webglRenderer.canRender({ + data, + width, + height, + channels, + isFloat: true, + settings, + collectHistogram: renderOptions.collectHistogram === true + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data, + width, + height, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : 1, + typeMax: 1.0, + settings, + nanColor, + channels, + flipY + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } + // Create options object const options = { nanColor: nanColor, - // EXR data is typically bottom-up, so we need to flip it for display - flipY: true + flipY, + collectHistogram: renderOptions.collectHistogram === true }; const imageData = ImageRenderer.render( @@ -228,6 +267,7 @@ export class ExrProcessor { settings, options ); + this._lastRenderHistogram = options.renderHistogramResult || null; return imageData; } @@ -244,8 +284,7 @@ export class ExrProcessor { const { width, height, data, channels } = this.rawExrData; if (x < 0 || x >= width || y < 0 || y >= height) return null; - // Apply Y-flip to match rendering (EXR uses bottom-left origin, canvas uses top-left) - const flippedY = height - 1 - y; + const flippedY = this.rawExrData.flipY ? height - 1 - y : y; const dataIndex = (flippedY * width + x) * channels; const values = []; for (let i = 0; i < channels; i++) { @@ -259,11 +298,10 @@ export class ExrProcessor { * @param {ImageSettings} settings - New settings * @returns {ImageData|null} - Updated image data */ - updateSettings(settings) { + updateSettings(settings, renderOptions = {}) { if (this._pendingRenderData && this._isInitialLoad) { // First render after initial load - use pending data - const { width, height } = this._pendingRenderData; - const imageData = this.renderExrToCanvas(settings); + const imageData = this.renderExrToCanvas(settings, renderOptions); this._isInitialLoad = false; this._pendingRenderData = null; diff --git a/media/modules/hdr-processor.js b/media/modules/hdr-processor.js index bc7b4dd..2659309 100644 --- a/media/modules/hdr-processor.js +++ b/media/modules/hdr-processor.js @@ -2,6 +2,8 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; +import { PerfTrace } from './perf-trace.js'; import parseHdr from 'parse-hdr'; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ @@ -30,6 +32,11 @@ export class HdrProcessor { this._isInitialLoad = true; /** @type {{min:number,max:number}|undefined} */ this._cachedStats = undefined; + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; + /** @type {{source: Float32Array, data: Float32Array}|null} */ + this._cachedWebglRgb = null; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ @@ -39,8 +46,7 @@ export class HdrProcessor { /** @param {string} src */ async processHdr(src) { const loadSignal = this.loadSignal; - const response = await fetch(src, { signal: loadSignal }); - const buffer = await response.arrayBuffer(); + const buffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'hdr'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // parse-hdr returns { shape:[width,height], exposure, gamma, data:Float32Array } @@ -60,7 +66,8 @@ export class HdrProcessor { const renderChannels = 4; this._cachedStats = undefined; - this._lastRaw = { width, height, data, channels }; + this._cachedWebglRgb = null; + this._lastRaw = { width, height, data, channels: renderChannels }; const canvas = document.createElement('canvas'); canvas.width = width; @@ -87,12 +94,14 @@ export class HdrProcessor { * @param {number} renderChannels - actual data stride (4 from parse-hdr) * @returns {ImageData} */ - _toImageDataFloat(data, width, height, renderChannels) { + _toImageDataFloat(data, width, height, renderChannels, renderOptions = {}) { + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; const settings = this.settingsManager.settings; const isGammaMode = settings.normalization?.gammaMode || false; let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { // Calculate stats over RGB channels only (ignore alpha at index 3) stats = ImageStatsCalculator.calculateFloatStats(data, width, height, renderChannels); this._cachedStats = stats; @@ -100,8 +109,43 @@ export class HdrProcessor { this.vscode.postMessage({ type: 'stats', value: stats }); } } + const nanColor = this._getNanColor(settings); + // HDR arrives as RGBA floats, while the WebGL renderer needs RGB. + // On large images the extra pack + RGB32F upload has measured slower + // than the CPU path, so keep HDR on CPU until a no-copy GPU path exists. + const webglData = null; + if (webglData && this._webglRenderer.canRender({ + data: webglData, + width, + height, + channels: 3, + isFloat: true, + settings + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data: webglData, + width, + height, + channels: 3, + isFloat: true, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : 1, + typeMax: 1.0, + settings, + nanColor + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } - return ImageRenderer.render( + const options = { + nanColor, + collectHistogram: renderOptions.collectHistogram === true + }; + + const imageData = ImageRenderer.render( data, width, height, @@ -109,8 +153,38 @@ export class HdrProcessor { true, // isFloat stats || { min: 0, max: 1 }, settings, - { nanColor: this._getNanColor(settings) } + options ); + this._lastRenderHistogram = options.renderHistogramResult || null; + return imageData; + } + + /** + * parse-hdr returns RGBA with alpha fixed at 1.0. The renderer only needs RGB, + * and uploading RGB avoids a large, useless alpha channel texture. + * @param {Float32Array} data + * @param {number} width + * @param {number} height + * @param {number} renderChannels + * @returns {Float32Array|null} + */ + _getWebglRgbData(data, width, height, renderChannels) { + if (renderChannels === 3) return data; + if (renderChannels !== 4) return null; + if (this._cachedWebglRgb?.source === data) { + return this._cachedWebglRgb.data; + } + const start = performance.now(); + const pixels = width * height; + const rgb = new Float32Array(pixels * 3); + for (let src = 0, dst = 0; dst < rgb.length; src += 4, dst += 3) { + rgb[dst] = data[src]; + rgb[dst + 1] = data[src + 1]; + rgb[dst + 2] = data[src + 2]; + } + this._cachedWebglRgb = { source: data, data: rgb }; + PerfTrace.detail('hdr-pack-rgb', performance.now() - start); + return rgb; } /** @@ -179,12 +253,12 @@ export class HdrProcessor { } /** @returns {ImageData|null} */ - performDeferredRender() { + performDeferredRender(renderOptions = {}) { if (!this._pendingRenderData) return null; const { data, width, height, renderChannels } = this._pendingRenderData; this._pendingRenderData = null; this._isInitialLoad = false; - const imageData = this._toImageDataFloat(data, width, height, renderChannels); + const imageData = this._toImageDataFloat(data, width, height, renderChannels, renderOptions); if (this.vscode) { this.vscode.postMessage({ type: 'refresh-status' }); } @@ -192,9 +266,9 @@ export class HdrProcessor { } /** @returns {ImageData|null} */ - renderHdrWithSettings() { + renderHdrWithSettings(renderOptions = {}) { if (!this._lastRaw) return null; const { width, height, data } = this._lastRaw; - return this._toImageDataFloat(data, width, height, 4); + return this._toImageDataFloat(data, width, height, 4, renderOptions); } } diff --git a/media/modules/histogram-overlay.js b/media/modules/histogram-overlay.js index e2072a5..b5c4a98 100644 --- a/media/modules/histogram-overlay.js +++ b/media/modules/histogram-overlay.js @@ -1,11 +1,13 @@ // @ts-check "use strict"; +import { PerfTrace } from './perf-trace.js'; + /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ /** @typedef {{postMessage: (msg: any) => any}} VsCodeApi */ /** @typedef {{r: Uint32Array, g: Uint32Array, b: Uint32Array, luminance: Uint32Array, nanCount: number, stats: {r: {minBin:number,maxBin:number,meanBin:number,total:number}, g: {minBin:number,maxBin:number,meanBin:number,total:number}, b: {minBin:number,maxBin:number,meanBin:number,total:number}, luminance: {minBin:number,maxBin:number,meanBin:number,total:number}}}} HistogramData */ -/** @typedef {{rawData?: ArrayLike|null, planarData?: ArrayLike[]|null, channels?: number, settings?: ImageSettings, isFloat?: boolean, typeMax?: number, stats?: {min:number,max:number}|null, lut?: Uint8Array|null}} HistogramOptions */ +/** @typedef {{rawData?: ArrayLike|null, planarData?: ArrayLike[]|null, channels?: number, settings?: ImageSettings, isFloat?: boolean, typeMax?: number, stats?: {min:number,max:number}|null, lut?: Uint8Array|null, sampleStep?: number}} HistogramOptions */ /** * Histogram Overlay Module @@ -104,10 +106,8 @@ export class HistogramOverlay { labels.className = 'histogram-labels'; labels.style.display = 'flex'; labels.style.justifyContent = 'space-between'; - labels.style.padding = '0 5px'; labels.style.fontSize = '10px'; labels.style.color = '#cccccc'; - labels.style.marginTop = '2px'; this.minLabel = document.createElement('span'); this.minLabel.textContent = '0'; @@ -431,6 +431,7 @@ export class HistogramOverlay { if (!imageData && !options.rawData && !options.planarData) return null; const startTime = performance.now(); + const sampleStep = Math.max(1, Math.floor(options.sampleStep || 1)); // Use TypedArrays for bins (much faster than regular arrays) const histR = new Uint32Array(256); @@ -489,9 +490,11 @@ export class HistogramOverlay { if (useIntegerLUT && !lut) { lut = this.generateTransformLUT(settings, intTypeMax); + PerfTrace.mark('histogram-lut'); } else if (useFloatLUT && !lut) { // For float, use 16-bit quantization LUT (same as image rendering) lut = this.generateFloatLUT(settings); + PerfTrace.mark('histogram-lut'); } // Non-null assertion: lut is only accessed inside useIntegerLUT/useFloatLUT branches above const safeLut = /** @type {Uint8Array} */ (lut); @@ -505,7 +508,47 @@ export class HistogramOverlay { const channels = options.channels || 3; const isGrayscale = channels === 1 || (planarData && planarData.length === 1); - if (planarData) { + if (isGrayscale) { + const grayData = planarData ? planarData[0] : rawData; + if (grayData) { + const len = grayData.length; + if (useIntegerLUT) { + for (let i = 0; i < len; i += sampleStep) { + const value = grayData[i]; + if (!Number.isFinite(value)) { nanCount++; continue; } + histR[safeLut[Math.max(0, Math.min(intTypeMax, value | 0))]]++; + if (value < origMinR) origMinR = value; + if (value > origMaxR) origMaxR = value; + origSumR += value; + origCountR++; + } + } else if (useFloatLUT) { + for (let i = 0; i < len; i += sampleStep) { + const value = grayData[i]; + if (!Number.isFinite(value)) { nanCount++; continue; } + const lutIdx = Math.max(0, Math.min(65535, ((value - normMin) * floatToLutScale) | 0)); + histR[safeLut[lutIdx]]++; + if (value < origMinR) origMinR = value; + if (value > origMaxR) origMaxR = value; + origSumR += value; + origCountR++; + } + } else { + for (let i = 0; i < len; i += sampleStep) { + const value = grayData[i]; + if (!Number.isFinite(value)) { nanCount++; continue; } + const bin = Math.max(0, Math.min(255, ((value - normMin) * invRange * 255) | 0)); + histR[bin]++; + if (value < origMinR) origMinR = value; + if (value > origMaxR) origMaxR = value; + origSumR += value; + origCountR++; + } + } + histG.set(histR); + histB.set(histR); + } + } else if (planarData) { const len = planarData[0].length; const rCh = planarData[0]; const gCh = planarData.length > 1 ? planarData[1] : rCh; @@ -513,7 +556,7 @@ export class HistogramOverlay { if (useIntegerLUT) { // Fast path: integer data with LUT - process ALL pixels - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += sampleStep) { const rv = rCh[i], gv = gCh[i], bv = bCh[i]; if (!Number.isFinite(rv) || !Number.isFinite(gv) || !Number.isFinite(bv)) { nanCount++; continue; } @@ -535,7 +578,7 @@ export class HistogramOverlay { } } else if (useFloatLUT) { // Float data with LUT - quantize to 16-bit and lookup - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += sampleStep) { const rv = rCh[i], gv = gCh[i], bv = bCh[i]; if (!Number.isFinite(rv) || !Number.isFinite(gv) || !Number.isFinite(bv)) { nanCount++; continue; } @@ -562,7 +605,7 @@ export class HistogramOverlay { } } else { // Non-gamma mode (no transformation needed) - process ALL pixels - for (let i = 0; i < len; i++) { + for (let i = 0; i < len; i += sampleStep) { const rv = rCh[i], gv = gCh[i], bv = bCh[i]; if (!Number.isFinite(rv) || !Number.isFinite(gv) || !Number.isFinite(bv)) { nanCount++; continue; } @@ -593,7 +636,7 @@ export class HistogramOverlay { if (useIntegerLUT) { // Fast path with LUT - process ALL pixels - for (let i = 0; i < len; i += channels) { + for (let i = 0; i < len; i += channels * sampleStep) { const rv = rawData[i]; const gv = channels > 1 ? rawData[i + 1] : rv; const bv = channels > 2 ? rawData[i + 2] : rv; @@ -619,7 +662,7 @@ export class HistogramOverlay { } } else if (useFloatLUT) { // Float data with LUT - quantize and lookup - for (let i = 0; i < len; i += channels) { + for (let i = 0; i < len; i += channels * sampleStep) { const rv = rawData[i]; const gv = channels > 1 ? rawData[i + 1] : rv; const bv = channels > 2 ? rawData[i + 2] : rv; @@ -649,7 +692,7 @@ export class HistogramOverlay { } } else { // Non-gamma mode - just normalize to bins - for (let i = 0; i < len; i += channels) { + for (let i = 0; i < len; i += channels * sampleStep) { const rv = rawData[i]; const gv = channels > 1 ? rawData[i + 1] : rv; const bv = channels > 2 ? rawData[i + 2] : rv; @@ -694,7 +737,7 @@ export class HistogramOverlay { const len = data.length; // Process ALL pixels - for (let i = 0; i < len; i += 4) { + for (let i = 0; i < len; i += 4 * sampleStep) { if (data[i + 3] === 0) continue; const rv = data[i], gv = data[i + 1], bv = data[i + 2]; histR[rv]++; histG[gv]++; histB[bv]++; @@ -711,6 +754,8 @@ export class HistogramOverlay { } } + PerfTrace.mark(sampleStep > 1 ? 'histogram-sampled-scan' : 'histogram-scan'); + // Calculate bin-based stats const calculateBinStats = (/** @type {Uint32Array} */ hist) => { let minBin = 0, maxBin = 255, sum = 0, count = 0; @@ -742,7 +787,8 @@ export class HistogramOverlay { } } - console.log(`[Histogram] ${(performance.now() - startTime).toFixed(1)}ms (${totalPixels} pixels)`); + console.log(`[Histogram] ${(performance.now() - startTime).toFixed(1)}ms (${totalPixels} pixels${sampleStep > 1 ? `, sampled every ${sampleStep}` : ''})`); + PerfTrace.mark('histogram-stats'); return { r: histR, g: histG, b: histB, luminance: histLum, @@ -768,6 +814,23 @@ export class HistogramOverlay { } } + /** + * Use a histogram computed during image rendering. + * @param {{histogramData: HistogramData, originalStats?: any, valueRange?: {min:number,max:number,isFloat:boolean}}} precomputed + */ + updateFromPrecomputed(precomputed) { + this.histogramData = precomputed.histogramData; + if (precomputed.originalStats) { + this.originalStats = precomputed.originalStats; + } + if (precomputed.valueRange) { + this.valueRange = precomputed.valueRange; + } + if (this.isVisible) { + this.render(); + } + } + /** * Render the histogram to canvas */ @@ -928,6 +991,7 @@ export class HistogramOverlay { // Update stats display this.updateStatsDisplay(); + PerfTrace.mark('histogram-draw'); } /** @@ -964,54 +1028,60 @@ export class HistogramOverlay { return Math.round(value).toString(); }; + const createCell = (/** @type {string} */ text, /** @type {string} */ className = 'histogram-stat-item') => { + const span = document.createElement('span'); + span.textContent = text; + if (className) span.className = className; + return span; + }; + + const createLine = (/** @type {string[]} */ values, /** @type {string[]} */ colors = []) => { + const row = document.createElement('div'); + row.className = 'histogram-stat-line'; + values.forEach((value, index) => { + const cell = createCell(value); + if (colors[index]) cell.style.color = colors[index]; + row.appendChild(cell); + }); + return row; + }; + + const createNanLine = () => { + const row = document.createElement('div'); + row.className = 'histogram-stat-line histogram-stat-nan'; + const cell = createCell(`NaN/Inf: ${this.histogramData ? this.histogramData.nanCount.toLocaleString() : '0'}`, 'histogram-stat-item histogram-stat-nan'); + row.appendChild(cell); + if (!this.histogramData || this.histogramData.nanCount <= 0) { + row.style.visibility = 'hidden'; + } + return row; + }; + // For grayscale images, show single channel stats, otherwise show RGB if (isGrayscale && origStats) { const s = origStats.r; - - const createSpan = (/** @type {string} */ text) => { - const span = document.createElement('span'); - span.textContent = text; - return span; - }; - - statsEl.appendChild(createSpan(`Min: ${formatStat(s.min)}`)); - statsEl.appendChild(createSpan(`Max: ${formatStat(s.max)}`)); - statsEl.appendChild(createSpan(`Mean: ${formatStat(s.mean)}`)); + statsEl.appendChild(createLine([ + `Min: ${formatStat(s.min)}`, + `Max: ${formatStat(s.max)}`, + `Mean: ${formatStat(s.mean)}` + ])); } else if (origStats) { // Show RGB stats in original values - const createStatSpan = (/** @type {string} */ label, /** @type {{min:number,max:number,mean:number}} */ stat, /** @type {string} */ color) => { - const span = document.createElement('span'); - span.style.color = color; - span.textContent = `${label}: ${formatStat(stat.min)}-${formatStat(stat.max)} (μ=${formatStat(stat.mean)})`; - return span; - }; - - statsEl.appendChild(createStatSpan('R', origStats.r, '#ff6666')); - statsEl.appendChild(createStatSpan('G', origStats.g, '#66ff66')); - statsEl.appendChild(createStatSpan('B', origStats.b, '#6666ff')); + statsEl.appendChild(createLine([ + `R: ${formatStat(origStats.r.min)}-${formatStat(origStats.r.max)} μ=${formatStat(origStats.r.mean)}`, + `G: ${formatStat(origStats.g.min)}-${formatStat(origStats.g.max)} μ=${formatStat(origStats.g.mean)}`, + `B: ${formatStat(origStats.b.min)}-${formatStat(origStats.b.max)} μ=${formatStat(origStats.b.mean)}` + ], ['#ff6666', '#66ff66', '#6666ff'])); } else { // Fallback to bin-based stats (when no raw data available) const stats = this.histogramData.stats; - const createStatSpan = (/** @type {string} */ label, /** @type {{minBin:number,maxBin:number}} */ stat, /** @type {string} */ color) => { - const span = document.createElement('span'); - span.style.color = color; - span.textContent = `${label}: ${stat.minBin}-${stat.maxBin}`; - return span; - }; - - statsEl.appendChild(createStatSpan('R', stats.r, '#ff6666')); - statsEl.appendChild(createStatSpan('G', stats.g, '#66ff66')); - statsEl.appendChild(createStatSpan('B', stats.b, '#6666ff')); - } - - // Show NaN count if present - if (this.histogramData.nanCount > 0) { - const nanSpan = document.createElement('span'); - nanSpan.style.color = '#ffcc00'; - nanSpan.style.marginLeft = '10px'; - nanSpan.textContent = `NaN/Inf: ${this.histogramData.nanCount.toLocaleString()}`; - statsEl.appendChild(nanSpan); + statsEl.appendChild(createLine([ + `R: ${stats.r.minBin}-${stats.r.maxBin} μ=${formatStat(stats.r.meanBin)}`, + `G: ${stats.g.minBin}-${stats.g.maxBin} μ=${formatStat(stats.g.meanBin)}`, + `B: ${stats.b.minBin}-${stats.b.maxBin} μ=${formatStat(stats.b.meanBin)}` + ], ['#ff6666', '#66ff66', '#6666ff'])); } + statsEl.appendChild(createNanLine()); // Update picker background and position const bgColor = getComputedStyle(document.body).getPropertyValue('--vscode-editor-background') || '#1e1e1e'; @@ -1032,23 +1102,10 @@ export class HistogramOverlay { statsEl.style.backgroundColor = isDarkTheme ? 'rgba(0, 0, 0, 0.8)' : 'rgba(255, 255, 255, 0.8)'; statsEl.style.color = isDarkTheme ? '#ffffff' : '#000000'; - // Smart positioning - if (!this.overlay) return; - const overlayRect = this.overlay.getBoundingClientRect(); - const windowWidth = window.innerWidth; - - // If close to right edge, move to left - if (overlayRect.right > windowWidth - 200) { // Increased threshold - statsEl.style.left = 'auto'; - statsEl.style.right = '100%'; - statsEl.style.marginRight = '10px'; - statsEl.style.marginLeft = '0'; - } else { - statsEl.style.right = 'auto'; - statsEl.style.left = '100%'; - statsEl.style.marginLeft = '10px'; - statsEl.style.marginRight = '0'; - } + statsEl.style.left = 'auto'; + statsEl.style.right = 'auto'; + statsEl.style.marginLeft = '0'; + statsEl.style.marginRight = '0'; // Update min/max labels at bottom of histogram this.updateRangeLabels(); diff --git a/media/modules/layer-compositor.js b/media/modules/layer-compositor.js index ed5960e..0b23262 100644 --- a/media/modules/layer-compositor.js +++ b/media/modules/layer-compositor.js @@ -167,6 +167,122 @@ function sampleLayer(layer, lx, ly, outChannels, out) { } } +/** + * Fast path for the overwhelmingly common interactive case: display-style + * normal blending. Avoids per-pixel scratch arrays and per-channel dispatch. + * @param {Layer} layer + * @param {Float32Array} data + * @param {Uint8Array} covered + * @param {number} outChannels + * @param {number} canvasWidth + * @param {number} xStart + * @param {number} yStart + * @param {number} xEnd + * @param {number} yEnd + * @param {number} offsetX + * @param {number} offsetY + * @param {number} opacity + * @param {number} coveredCount + * @returns {number} + */ +function compositeNormalLayerFast(layer, data, covered, outChannels, canvasWidth, xStart, yStart, xEnd, yEnd, offsetX, offsetY, opacity, coveredCount) { + const srcData = layer.data; + const layerChannels = layer.channels; + const opaque = opacity >= 1; + + if (outChannels === 1 && layerChannels === 1) { + for (let y = yStart; y < yEnd; y++) { + let pixel = y * canvasWidth + xStart; + let si = (y - offsetY) * layer.width + (xStart - offsetX); + for (let x = xStart; x < xEnd; x++, pixel++, si++) { + const s = srcData[si]; + if (!covered[pixel]) { + data[pixel] = s; + covered[pixel] = 1; + coveredCount++; + } else if (Number.isFinite(s)) { + const below = data[pixel]; + data[pixel] = opaque || !Number.isFinite(below) + ? s + : below + (s - below) * opacity; + } + } + } + return coveredCount; + } + + if (outChannels === 3 && layerChannels === 1) { + for (let y = yStart; y < yEnd; y++) { + let pixel = y * canvasWidth + xStart; + let di = pixel * 3; + let si = (y - offsetY) * layer.width + (xStart - offsetX); + for (let x = xStart; x < xEnd; x++, pixel++, di += 3, si++) { + const s = srcData[si]; + if (!covered[pixel]) { + data[di] = s; + data[di + 1] = s; + data[di + 2] = s; + covered[pixel] = 1; + coveredCount++; + } else if (Number.isFinite(s)) { + if (opaque) { + data[di] = s; + data[di + 1] = s; + data[di + 2] = s; + } else { + const b0 = data[di]; + const b1 = data[di + 1]; + const b2 = data[di + 2]; + data[di] = Number.isFinite(b0) ? b0 + (s - b0) * opacity : s; + data[di + 1] = Number.isFinite(b1) ? b1 + (s - b1) * opacity : s; + data[di + 2] = Number.isFinite(b2) ? b2 + (s - b2) * opacity : s; + } + } + } + } + return coveredCount; + } + + if (outChannels === 3 && layerChannels >= 3) { + for (let y = yStart; y < yEnd; y++) { + let pixel = y * canvasWidth + xStart; + let di = pixel * 3; + let si = ((y - offsetY) * layer.width + (xStart - offsetX)) * layerChannels; + for (let x = xStart; x < xEnd; x++, pixel++, di += 3, si += layerChannels) { + const s0 = srcData[si]; + const s1 = srcData[si + 1]; + const s2 = srcData[si + 2]; + if (!covered[pixel]) { + data[di] = s0; + data[di + 1] = s1; + data[di + 2] = s2; + covered[pixel] = 1; + coveredCount++; + } else if (opaque) { + if (Number.isFinite(s0)) data[di] = s0; + if (Number.isFinite(s1)) data[di + 1] = s1; + if (Number.isFinite(s2)) data[di + 2] = s2; + } else { + if (Number.isFinite(s0)) { + const b = data[di]; + data[di] = Number.isFinite(b) ? b + (s0 - b) * opacity : s0; + } + if (Number.isFinite(s1)) { + const b = data[di + 1]; + data[di + 1] = Number.isFinite(b) ? b + (s1 - b) * opacity : s1; + } + if (Number.isFinite(s2)) { + const b = data[di + 2]; + data[di + 2] = Number.isFinite(b) ? b + (s2 - b) * opacity : s2; + } + } + } + } + } + + return coveredCount; +} + /** * Composite an ordered layer stack (index 0 = bottom / background) into a single * float buffer of the given canvas size. @@ -202,6 +318,11 @@ export function composite(layers, canvasWidth, canvasHeight) { const xEnd = Math.min(canvasWidth, offsetX + layer.width); const yEnd = Math.min(canvasHeight, offsetY + layer.height); + if (!arithmetic && !isMask && (outChannels === 1 || outChannels === 3)) { + coveredCount = compositeNormalLayerFast(layer, data, covered, outChannels, canvasWidth, xStart, yStart, xEnd, yEnd, offsetX, offsetY, opacity, coveredCount); + continue; + } + for (let y = yStart; y < yEnd; y++) { const ly = y - offsetY; for (let x = xStart; x < xEnd; x++) { diff --git a/media/modules/layer-manager.js b/media/modules/layer-manager.js index b963a89..49da083 100644 --- a/media/modules/layer-manager.js +++ b/media/modules/layer-manager.js @@ -12,6 +12,7 @@ import { composite, centeredOffset, BLEND_MODES } from './layer-compositor.js'; import { ImageRenderer } from './normalization-helper.js'; +import { PerfTrace } from './perf-trace.js'; /** * @typedef {Object} LayerInput @@ -147,10 +148,13 @@ export class LayerManager { * @returns {ImageData|null} */ renderToImageData(settings, options = {}) { + const compositeStart = performance.now(); const c = this.getComposite(); if (!c) { return null; } + PerfTrace.detail('layer-composite', performance.now() - compositeStart); this._lastComposite = c; // cache for pixel inspection - return ImageRenderer.render( + const renderStart = performance.now(); + const imageData = ImageRenderer.render( c.data, c.width, c.height, @@ -160,6 +164,8 @@ export class LayerManager { settings, { nanColor: options.nanColor, typeMax: c.typeMax } ); + PerfTrace.detail('layer-render-total', performance.now() - renderStart); + return imageData; } /** diff --git a/media/modules/layers-panel.js b/media/modules/layers-panel.js index 2127c77..b1d4adf 100644 --- a/media/modules/layers-panel.js +++ b/media/modules/layers-panel.js @@ -12,7 +12,7 @@ import { BLEND_MODES, MASK_CONDITIONS } from './layer-compositor.js'; export class LayersPanel { /** * @param {import('./layer-manager.js').LayerManager} manager - * @param {{ onChange: () => void, onVisibilityChange?: (visible: boolean) => void, onPersist?: () => void, onAddLayer?: () => void }} callbacks + * @param {{ onChange: (options?: {interactive?: boolean}) => void, onVisibilityChange?: (visible: boolean) => void, onPersist?: () => void, onAddLayer?: () => void }} callbacks * @param {{ closable?: boolean }} [options] */ constructor(manager, callbacks, options = {}) { @@ -34,9 +34,22 @@ export class LayersPanel { this.minimizeBtn = null; /** id of the layer currently armed for drag-to-move, or null */ this.movingLayerId = null; + /** @type {string|null} id of the layer that needs a second remove click */ + this._pendingRemoveId = null; + /** @type {ReturnType|null} */ + this._pendingRemoveTimer = null; this.collapsed = false; } + _clearPendingRemove(refresh = false) { + if (this._pendingRemoveTimer) { + clearTimeout(this._pendingRemoveTimer); + this._pendingRemoveTimer = null; + } + this._pendingRemoveId = null; + if (refresh) { this.refresh(); } + } + /** Build the panel DOM (once) and attach it to the document body. */ mount() { if (this.root) { return; } @@ -99,11 +112,15 @@ export class LayersPanel { if (this.isVisible()) { this.hide(); } else { this.show(); } } - show() { + /** @param {{notify?: boolean}} [options] */ + show(options = {}) { + const wasVisible = this.isVisible(); this.mount(); this.root?.removeAttribute('hidden'); this.refresh(); - this.onVisibilityChange?.(true); + if (!wasVisible && options.notify !== false) { + this.onVisibilityChange?.(true); + } } hide() { @@ -229,8 +246,14 @@ export class LayersPanel { opacity.disabled = layer.blendMode === 'mask'; opacity.addEventListener('input', () => { this.manager.updateLayer(id, { opacity: Number(opacity.value) / 100 }); - this.onChange(); + this.onChange({ interactive: true }); + }); + opacity.addEventListener('change', () => { + this.manager.updateLayer(id, { opacity: Number(opacity.value) / 100 }); + this.onChange({ interactive: true }); + opacity.blur(); }); + opacity.addEventListener('pointerup', () => opacity.blur()); controls.appendChild(opacity); row.appendChild(controls); @@ -299,9 +322,23 @@ export class LayersPanel { const remove = document.createElement('button'); remove.className = 'layers-btn layer-remove'; - remove.textContent = '🗑'; - remove.title = 'Remove layer'; + const pendingRemove = this._pendingRemoveId === id; + remove.textContent = pendingRemove ? 'again' : '🗑'; + remove.title = pendingRemove ? 'Click again to remove this layer' : 'Remove layer'; + remove.classList.toggle('pending', pendingRemove); remove.addEventListener('click', () => { + if (this._pendingRemoveId !== id) { + this._clearPendingRemove(false); + this._pendingRemoveId = id; + remove.textContent = 'again'; + remove.title = 'Click again to remove this layer'; + remove.classList.add('pending'); + this._pendingRemoveTimer = setTimeout(() => { + this._clearPendingRemove(true); + }, 1600); + return; + } + this._clearPendingRemove(false); if (this.movingLayerId === id) { this.movingLayerId = null; } this.manager.removeLayer(id); this.refresh(); diff --git a/media/modules/normalization-helper.js b/media/modules/normalization-helper.js index 7cf4fc3..4a7f7f3 100644 --- a/media/modules/normalization-helper.js +++ b/media/modules/normalization-helper.js @@ -7,7 +7,7 @@ import { getColormapLut } from './colormaps.js'; /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ /** - * @typedef {{nanColor?: {r:number,g:number,b:number}, flipY?: boolean, typeMax?: number, rgbAs24BitGrayscale?: boolean, planarData?: any}} RenderOptions + * @typedef {{nanColor?: {r:number,g:number,b:number}, flipY?: boolean, typeMax?: number, rgbAs24BitGrayscale?: boolean, planarData?: any, collectHistogram?: boolean, renderHistogramResult?: any}} RenderOptions */ /** @@ -55,6 +55,16 @@ export class NormalizationHelper { return { min: normMin, max: normMax }; } + /** + * Whether rendering needs exact data min/max for the current settings. + * Manual range and gamma-mode renders can use their configured/full range + * without scanning the image first. + * @param {ImageSettings} settings + */ + static needsStats(settings) { + return !settings.normalization?.gammaMode && settings.normalization?.autoNormalize !== false; + } + /** * Apply gamma and brightness corrections to a normalized value (0-1). * @param {number} normalized - Value in range 0-1 @@ -211,14 +221,26 @@ export class ImageStatsCalculator { let maxVal = -Infinity; const len = width * height; - for (let i = 0; i < len; i++) { - for (let c = 0; c < Math.min(channels, 3); c++) { - const val = data[i * channels + c]; - if (Number.isFinite(val)) { + if (channels === 1) { + for (let i = 0; i < len; i++) { + const val = data[i]; + if (val === val && val !== Infinity && val !== -Infinity) { if (val < minVal) minVal = val; if (val > maxVal) maxVal = val; } } + } else { + const scanChannels = Math.min(channels, 3); + for (let i = 0; i < len; i++) { + const base = i * channels; + for (let c = 0; c < scanChannels; c++) { + const val = data[base + c]; + if (val === val && val !== Infinity && val !== -Infinity) { + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } + } + } } console.log(`[Stats] Float stats calculation took ${(performance.now() - perfStart).toFixed(2)}ms`); @@ -246,10 +268,18 @@ export class ImageStatsCalculator { if (val24 < minVal) minVal = val24; if (val24 > maxVal) maxVal = val24; } + } else if (channels === 1) { + for (let i = 0; i < len; i++) { + const val = data[i]; + if (val < minVal) minVal = val; + if (val > maxVal) maxVal = val; + } } else { + const scanChannels = Math.min(channels, 3); for (let i = 0; i < len; i++) { - for (let c = 0; c < Math.min(channels, 3); c++) { - const val = data[i * channels + c]; + const base = i * channels; + for (let c = 0; c < scanChannels; c++) { + const val = data[base + c]; if (val < minVal) minVal = val; if (val > maxVal) maxVal = val; } @@ -266,6 +296,59 @@ export class ImageStatsCalculator { * Handles normalization, gamma/brightness correction, and NaN handling. */ export class ImageRenderer { + /** + * Build the histogram payload consumed by HistogramOverlay from a histogram + * collected during a render pass. + * @private + * @param {Uint32Array} hist + * @param {number} nanCount + * @param {{min:number,max:number,isFloat:boolean}} valueRange + * @param {{min:number,max:number,sum:number,count:number}} original + * @returns {any} + */ + static _finishSingleChannelRenderHistogram(hist, nanCount, valueRange, original) { + const histG = new Uint32Array(hist); + const histB = new Uint32Array(hist); + const histLum = new Uint32Array(hist); + const calculateBinStats = (/** @type {Uint32Array} */ h) => { + let minBin = 0, maxBin = 255, sum = 0, count = 0; + for (let i = 0; i < 256; i++) { + if (h[i] > 0) { + if (count === 0) minBin = i; + maxBin = i; + sum += i * h[i]; + count += h[i]; + } + } + return { minBin, maxBin, meanBin: count > 0 ? sum / count : 0, total: count }; + }; + const binStats = calculateBinStats(hist); + const mean = original.count > 0 ? original.sum / original.count : 0; + const min = original.count > 0 ? original.min : 0; + const max = original.count > 0 ? original.max : 0; + return { + histogramData: { + r: hist, + g: histG, + b: histB, + luminance: histLum, + nanCount, + stats: { + r: binStats, + g: binStats, + b: binStats, + luminance: binStats + } + }, + originalStats: { + r: { min, max, mean, total: original.count }, + g: { min, max, mean, total: original.count }, + b: { min, max, mean, total: original.count } + }, + valueRange + }; + } + /** * Render typed array to ImageData with normalization and corrections. * @param {Uint8Array|Uint16Array|Float32Array|ArrayLike} data - Image data @@ -453,6 +536,51 @@ export class ImageRenderer { const nanColor = options.nanColor || { r: 255, g: 0, b: 255 }; // Magenta default const range = max - min; const invRange = range > 0 ? 1.0 / range : 0; + const collectHistogram = options.collectHistogram === true && channels === 1; + const renderHist = collectHistogram ? new Uint32Array(256) : null; + let renderHistNanCount = 0; + const renderOriginal = { min: Infinity, max: -Infinity, sum: 0, count: 0 }; + + if (channels === 1) { + for (let i = 0; i < width * height; i++) { + const p = i * 4; + const value = data[i]; + if (!Number.isFinite(value)) { + out[p] = nanColor.r; + out[p + 1] = nanColor.g; + out[p + 2] = nanColor.b; + out[p + 3] = 255; + if (renderHist) { renderHistNanCount++; } + continue; + } + + const normalized = (value - min) * invRange; + const intensity = Math.round(Math.max(0, Math.min(1, normalized)) * 255); + out[p] = intensity; + out[p + 1] = intensity; + out[p + 2] = intensity; + out[p + 3] = 255; + + if (renderHist) { + renderHist[intensity]++; + if (value < renderOriginal.min) renderOriginal.min = value; + if (value > renderOriginal.max) renderOriginal.max = value; + renderOriginal.sum += value; + renderOriginal.count++; + } + } + + if (renderHist) { + options.renderHistogramResult = this._finishSingleChannelRenderHistogram( + renderHist, + renderHistNanCount, + { min, max, isFloat: true }, + renderOriginal + ); + } + + return new ImageData(out, width, height); + } for (let i = 0; i < width * height; i++) { let r = 0, g = 0, b = 0; @@ -463,10 +591,18 @@ export class ImageRenderer { r = nanColor.r; g = nanColor.g; b = nanColor.b; + renderHistNanCount++; } else { const normalized = (value - min) * invRange; const intensity = Math.round(Math.max(0, Math.min(1, normalized)) * 255); r = g = b = intensity; + if (renderHist) { + renderHist[intensity]++; + if (value < renderOriginal.min) renderOriginal.min = value; + if (value > renderOriginal.max) renderOriginal.max = value; + renderOriginal.sum += value; + renderOriginal.count++; + } } } else if (channels === 3) { const idx = i * 3; @@ -515,6 +651,15 @@ export class ImageRenderer { out[p + 3] = 255; } + if (renderHist) { + options.renderHistogramResult = this._finishSingleChannelRenderHistogram( + renderHist, + renderHistNanCount, + { min, max, isFloat: true }, + renderOriginal + ); + } + return new ImageData(out, width, height); } @@ -551,6 +696,51 @@ export class ImageRenderer { const lut = NormalizationHelper.generateLut(lutSettings, 16, 65535, 0, 65535); const vRange = vMax - vMin; const invVRange = vRange > 0 ? 65535 / vRange : 0; + const collectHistogram = options.collectHistogram === true && channels === 1; + const renderHist = collectHistogram ? new Uint32Array(256) : null; + let renderHistNanCount = 0; + const renderOriginal = { min: Infinity, max: -Infinity, sum: 0, count: 0 }; + + if (channels === 1) { + for (let i = 0; i < width * height; i++) { + const p = i * 4; + const value = data[i]; + if (!Number.isFinite(value)) { + out[p] = nanColor.r; + out[p + 1] = nanColor.g; + out[p + 2] = nanColor.b; + out[p + 3] = 255; + if (renderHist) { renderHistNanCount++; } + continue; + } + + const lutIdx = Math.round(Math.max(0, Math.min(65535, (value - vMin) * invVRange))); + const intensity = lut[lutIdx]; + out[p] = intensity; + out[p + 1] = intensity; + out[p + 2] = intensity; + out[p + 3] = 255; + + if (renderHist) { + renderHist[intensity]++; + if (value < renderOriginal.min) renderOriginal.min = value; + if (value > renderOriginal.max) renderOriginal.max = value; + renderOriginal.sum += value; + renderOriginal.count++; + } + } + + if (renderHist) { + options.renderHistogramResult = this._finishSingleChannelRenderHistogram( + renderHist, + renderHistNanCount, + { min, max, isFloat: true }, + renderOriginal + ); + } + + return new ImageData(out, width, height); + } for (let i = 0; i < width * height; i++) { let r = 0, g = 0, b = 0; @@ -561,9 +751,17 @@ export class ImageRenderer { r = nanColor.r; g = nanColor.g; b = nanColor.b; + renderHistNanCount++; } else { const lutIdx = Math.round(Math.max(0, Math.min(65535, (value - vMin) * invVRange))); r = g = b = lut[lutIdx]; + if (renderHist) { + renderHist[r]++; + if (value < renderOriginal.min) renderOriginal.min = value; + if (value > renderOriginal.max) renderOriginal.max = value; + renderOriginal.sum += value; + renderOriginal.count++; + } } } else if (channels === 3) { const idx = i * 3; @@ -618,6 +816,15 @@ export class ImageRenderer { out[p + 3] = 255; } + if (renderHist) { + options.renderHistogramResult = this._finishSingleChannelRenderHistogram( + renderHist, + renderHistNanCount, + { min, max, isFloat: true }, + renderOriginal + ); + } + return new ImageData(out, width, height); } diff --git a/media/modules/npy-processor.js b/media/modules/npy-processor.js index 662b15b..6af59a2 100644 --- a/media/modules/npy-processor.js +++ b/media/modules/npy-processor.js @@ -2,6 +2,7 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ @@ -51,6 +52,9 @@ export class NpyProcessor { /** @type {{min: number, max: number} | undefined} */ this._cachedStats = undefined; // Cache for min/max stats (only used in stats mode) this._cachedStatsRgb24Mode = false; // Track whether cached stats were computed in rgb24 mode + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ @@ -64,8 +68,7 @@ export class NpyProcessor { this._cachedStats = undefined; this._cachedStatsRgb24Mode = false; - const response = await fetch(src, { signal: loadSignal }); - const buffer = await response.arrayBuffer(); + const buffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'npy'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Parse in the decode worker when available, locally otherwise. @@ -240,7 +243,9 @@ export class NpyProcessor { * @param {number} width * @param {number} height */ - _toImageDataFloat(data, width, height) { + _toImageDataFloat(data, width, height, renderOptions = {}) { + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; const channels = this._lastRaw?.channels || 1; const settings = this.settingsManager.settings; const rgbAs24BitMode = (settings.rgbAs24BitGrayscale ?? false) && channels === 3; @@ -255,7 +260,7 @@ export class NpyProcessor { } let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { if (isFloat) { stats = ImageStatsCalculator.calculateFloatStats(data, width, height, channels); } else { @@ -278,16 +283,44 @@ export class NpyProcessor { else if (dtype.includes('2')) typeMax = 65535; else if (dtype.includes('4')) typeMax = 4294967295; // 32-bit } + const effectiveTypeMax = typeMax ?? 1.0; + + if (renderOptions.targetCanvas && this._webglRenderer.canRender({ + data, + width, + height, + channels, + isFloat, + settings, + collectHistogram: renderOptions.collectHistogram === true + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data, + width, + height, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : effectiveTypeMax, + typeMax: effectiveTypeMax, + settings, + nanColor, + channels + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } // Create options object const options = { nanColor: nanColor, rgbAs24BitGrayscale: rgbAs24BitMode, flipY: false, // NPY is usually top-down - typeMax: typeMax + typeMax: typeMax, + collectHistogram: renderOptions.collectHistogram === true }; - return ImageRenderer.render( + const imageData = ImageRenderer.render( data, width, height, @@ -297,6 +330,8 @@ export class NpyProcessor { settings, options ); + this._lastRenderHistogram = options.renderHistogramResult || null; + return imageData; } @@ -304,10 +339,10 @@ export class NpyProcessor { * Re-render NPY with current settings (for real-time updates) * @returns {ImageData | null} */ - renderNpyWithSettings() { + renderNpyWithSettings(renderOptions = {}) { if (!this._lastRaw) return null; const { width, height, data } = this._lastRaw; - return this._toImageDataFloat(data, width, height); + return this._toImageDataFloat(data, width, height, renderOptions); } /** @@ -325,6 +360,12 @@ export class NpyProcessor { const settings = this.settingsManager.settings; const rgbAs24BitMode = (settings.rgbAs24BitGrayscale ?? false) && channels === 3; const normalizedFloatMode = settings.normalizedFloatMode; + const formatNumber = (/** @type {number} */ n) => { + if (Number.isNaN(n)) return 'NaN'; + if (n === Infinity) return 'Inf'; + if (n === -Infinity) return '-Inf'; + return parseFloat(n.toFixed(6)).toString(); + }; if (rgbAs24BitMode) { // RGB as 24-bit grayscale: show combined value @@ -344,14 +385,7 @@ export class NpyProcessor { const r = data[srcIdx + 0]; const g = data[srcIdx + 1]; const b = data[srcIdx + 2]; - if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) { - const formatNumber = (/** @type {number} */ n) => { - // Use fixed decimal notation to avoid scientific notation - // Show up to 6 decimal places, but remove trailing zeros - return parseFloat(n.toFixed(6)).toString(); - }; - return `${formatNumber(r)} ${formatNumber(g)} ${formatNumber(b)}`; - } + return `${formatNumber(r)} ${formatNumber(g)} ${formatNumber(b)}`; } else if (channels === 4) { // RGBA data - return space-separated values with α: prefix for alpha const srcIdx = pixelIdx * 4; @@ -359,37 +393,23 @@ export class NpyProcessor { const g = data[srcIdx + 1]; const b = data[srcIdx + 2]; const a = data[srcIdx + 3]; - if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b) && Number.isFinite(a)) { - const formatNumber = (/** @type {number} */ n) => { - // Use fixed decimal notation to avoid scientific notation - // Show up to 6 decimal places, but remove trailing zeros - return parseFloat(n.toFixed(6)).toString(); - }; - return `${formatNumber(r)} ${formatNumber(g)} ${formatNumber(b)} α:${formatNumber(a)}`; - } + return `${formatNumber(r)} ${formatNumber(g)} ${formatNumber(b)} α:${formatNumber(a)}`; } else { // Grayscale data const value = data[pixelIdx]; - if (Number.isFinite(value)) { - const formatNumber = (/** @type {number} */ n) => { - // Use fixed decimal notation to avoid scientific notation - // Show up to 6 decimal places, but remove trailing zeros - return parseFloat(n.toFixed(6)).toString(); - }; - // Check if normalized float mode is enabled for uint images - if (normalizedFloatMode && dtype && !dtype.includes('f')) { - // Convert uint to normalized float (0-1) - let maxValue = 255; - if (dtype.includes('u2') || dtype.includes('i2')) { - maxValue = dtype.includes('u') ? 65535 : 32767; - } else if (dtype.includes('u4') || dtype.includes('i4')) { - maxValue = dtype.includes('u') ? 4294967295 : 2147483647; - } - const normalized = value / maxValue; - return formatNumber(normalized); + // Check if normalized float mode is enabled for uint images + if (normalizedFloatMode && dtype && !dtype.includes('f') && Number.isFinite(value)) { + // Convert uint to normalized float (0-1) + let maxValue = 255; + if (dtype.includes('u2') || dtype.includes('i2')) { + maxValue = dtype.includes('u') ? 65535 : 32767; + } else if (dtype.includes('u4') || dtype.includes('i4')) { + maxValue = dtype.includes('u') ? 4294967295 : 2147483647; } - return formatNumber(value); + const normalized = value / maxValue; + return formatNumber(normalized); } + return formatNumber(value); } return ''; } @@ -465,7 +485,7 @@ export class NpyProcessor { * Called when format-specific settings have been applied * @returns {ImageData|null} - The rendered image data, or null if no pending render */ - performDeferredRender() { + performDeferredRender(renderOptions = {}) { if (!this._pendingRenderData) { return null; } @@ -475,7 +495,7 @@ export class NpyProcessor { this._isInitialLoad = false; // Now render with the correct format-specific settings - const imageData = this._toImageDataFloat(data, width, height); + const imageData = this._toImageDataFloat(data, width, height, renderOptions); // Force status refresh so normalization UI appears this.vscode.postMessage({ type: 'refresh-status' }); @@ -490,9 +510,18 @@ export class NpyProcessor { */ _getNanColor(settings) { if (settings.nanColor) { - // Handle hex string if (typeof settings.nanColor === 'string') { + if (settings.nanColor === 'fuchsia') { + return { r: 255, g: 0, b: 255 }; + } + if (settings.nanColor === 'black') { + return { r: 0, g: 0, b: 0 }; + } + // Handle explicit hex string. const hex = settings.nanColor.replace('#', ''); + if (!/^[0-9a-fA-F]{6}$/.test(hex)) { + return { r: 0, g: 0, b: 0 }; + } return { r: parseInt(hex.substring(0, 2), 16), g: parseInt(hex.substring(2, 4), 16), @@ -502,7 +531,6 @@ export class NpyProcessor { // Handle object return settings.nanColor; } - return { r: 255, g: 0, b: 0 }; // Default red + return { r: 0, g: 0, b: 0 }; } } - diff --git a/media/modules/perf-trace.js b/media/modules/perf-trace.js index 87e8dca..9e4b267 100644 --- a/media/modules/perf-trace.js +++ b/media/modules/perf-trace.js @@ -1,6 +1,11 @@ // @ts-check "use strict"; +// Code-only diagnostic flag. Keep disabled for normal builds so users see the +// concise [Perf] load summaries without the full per-phase trace. Temporarily +// enable this while profiling image loading, rendering, or collection switches. +const DETAILED_PERF_TRACING = false; + /** * Lightweight phase timer for diagnosing where an image switch spends time. * @@ -13,13 +18,17 @@ * un-instrumented work between two marks is attributed to the later mark — * nothing is hidden from the total. * + * detail(name, durationMs) appends a measured sub-phase without advancing the + * main timeline. Use it for timings collected inside workers or libraries + * after the parent wall-clock phase has already been marked. + * * Output is a single line per traced load, e.g.: * [PerfTrace] switch img_004.tif: paint-yield 18ms | fetch 12ms | * decode-worker 85ms | raster-copy 41ms | stats 33ms | interleave 58ms | * render 122ms | canvas-upload 9ms | finalize 6ms | total 384ms */ export class PerfTrace { - /** @type {{label: string, start: number, last: number, phases: string[]}|null} */ + /** @type {{label: string, start: number, last: number, phases: string[], detailed: boolean, conciseLabel: string}|null} */ static _active = null; /** @type {(message: string) => void} */ @@ -34,10 +43,22 @@ export class PerfTrace { if (fn) { PerfTrace._log = fn; } } - /** @param {string} label */ - static begin(label) { + /** + * @param {string} label + * @param {{conciseLabel?: string}} [options] + */ + static begin(label, options = {}) { + const conciseLabel = String(options.conciseLabel || ''); + if (!DETAILED_PERF_TRACING && !conciseLabel) { return; } const now = performance.now(); - PerfTrace._active = { label, start: now, last: now, phases: [] }; + PerfTrace._active = { + label, + start: now, + last: now, + phases: [], + detailed: DETAILED_PERF_TRACING, + conciseLabel, + }; } /** @@ -46,19 +67,47 @@ export class PerfTrace { */ static mark(name) { const trace = PerfTrace._active; - if (!trace) { return; } + if (!trace?.detailed) { return; } const now = performance.now(); trace.phases.push(`${name} ${(now - trace.last).toFixed(0)}ms`); trace.last = now; } + /** + * Append an externally measured detail without changing the active timer. + * No-op when no trace is active. + * @param {string} name + * @param {number} durationMs + */ + static detail(name, durationMs) { + const trace = PerfTrace._active; + if (!trace?.detailed || !Number.isFinite(durationMs)) { return; } + trace.phases.push(`${name} ${Math.max(0, durationMs).toFixed(0)}ms`); + } + + /** + * Append a non-duration measurement such as bytes or throughput. + * No-op when no trace is active. + * @param {string} name + * @param {string|number} value + */ + static note(name, value) { + const trace = PerfTrace._active; + if (!trace?.detailed) { return; } + trace.phases.push(`${name} ${value}`); + } + /** Log the summary line and deactivate. No-op when no trace is active. */ static end() { const trace = PerfTrace._active; if (!trace) { return; } PerfTrace._active = null; const total = (performance.now() - trace.start).toFixed(0); - PerfTrace._log(`[PerfTrace] ${trace.label}: ${trace.phases.join(' | ')} | total ${total}ms`); + if (trace.detailed) { + PerfTrace._log(`[PerfTrace] ${trace.label}: ${trace.phases.join(' | ')} | total ${total}ms`); + } else if (trace.conciseLabel) { + PerfTrace._log(`[Perf] ${trace.conciseLabel} in ${total}ms`); + } } /** Drop the active trace without logging (e.g. load failed or superseded). */ diff --git a/media/modules/pfm-processor.js b/media/modules/pfm-processor.js index dde49ba..ee13923 100644 --- a/media/modules/pfm-processor.js +++ b/media/modules/pfm-processor.js @@ -2,6 +2,7 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {import('./settings-manager.js').ImageSettings} ImageSettings */ @@ -24,6 +25,9 @@ export class PfmProcessor { this._isInitialLoad = true; // Track if this is the first render /** @type {{min: number, max: number} | undefined} */ this._cachedStats = undefined; // Cache for min/max stats (only used in stats mode) + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ @@ -33,17 +37,12 @@ export class PfmProcessor { /** @param {string} src */ async processPfm(src) { const loadSignal = this.loadSignal; - const response = await fetch(src, { signal: loadSignal }); - const buffer = await response.arrayBuffer(); + const buffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'pfm'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Parse in the decode worker when available, locally otherwise. const { width, height, channels, data } = await DecodeWorkerClient.decodeWithFallback( - this.decodeWorker, 'pfm', buffer, src, loadSignal, (b) => this._parsePfm(b)); - // Keep color data for RGB PFM files - let displayData = data; - - // PFM format stores rows from bottom to top, so we need to flip vertically - displayData = this._flipImageVertically(displayData, width, height, channels); + this.decodeWorker, 'pfm', buffer, src, loadSignal, (b) => this._parsePfm(b, { topDown: true })); + const displayData = data; // Invalidate stats cache for new image this._cachedStats = undefined; @@ -71,34 +70,66 @@ export class PfmProcessor { } /** @param {ArrayBuffer} arrayBuffer */ - _parsePfm(arrayBuffer) { - const text = new TextDecoder('ascii').decode(arrayBuffer); - // Read header lines - const lines = text.split(/\n/); - let idx = 0; - while (idx < lines.length && lines[idx].trim() === '') idx++; - const type = lines[idx++].trim(); + _parsePfm(arrayBuffer, options = {}) { + const bytes = new Uint8Array(arrayBuffer); + let offset = 0; + const readLine = () => { + while (offset < bytes.length && (bytes[offset] === 10 || bytes[offset] === 13)) { offset++; } + const start = offset; + while (offset < bytes.length && bytes[offset] !== 10 && bytes[offset] !== 13) { offset++; } + let end = offset; + while (end > start && (bytes[end - 1] === 32 || bytes[end - 1] === 9)) { end--; } + const line = String.fromCharCode(...bytes.subarray(start, end)).trim(); + while (offset < bytes.length && (bytes[offset] === 10 || bytes[offset] === 13)) { offset++; } + return line; + }; + let type = readLine(); + while (type === '') { type = readLine(); } if (type !== 'PF' && type !== 'Pf') throw new Error('Invalid PFM magic'); - // Skip comments - while (idx < lines.length && lines[idx].trim().startsWith('#')) idx++; - const dims = lines[idx++].trim().split(/\s+/).map(n => parseInt(n, 10)); + let dimsLine = readLine(); + while (dimsLine.startsWith('#') || dimsLine === '') { dimsLine = readLine(); } + const dims = dimsLine.split(/\s+/).map(n => parseInt(n, 10)); const width = dims[0]; const height = dims[1]; - const scale = parseFloat(lines[idx++].trim()); + let scaleLine = readLine(); + while (scaleLine.startsWith('#') || scaleLine === '') { scaleLine = readLine(); } + const scale = parseFloat(scaleLine); const littleEndian = scale < 0; const channels = type === 'PF' ? 3 : 1; - // Find start byte offset of pixel data - const headerUpTo = lines.slice(0, idx).join('\n') + '\n'; - const headerBytes = new TextEncoder().encode(headerUpTo).length; - const bytesPerPixel = 4 * channels; - const dv = new DataView(arrayBuffer, headerBytes); + const valuesPerRow = width * channels; const pixels = width * height; const out = new Float32Array(pixels * channels); - let o = 0; - for (let i = 0; i < pixels; i++) { - for (let c = 0; c < channels; c++) { - const v = dv.getFloat32((i * channels + c) * 4, littleEndian); - out[o++] = v; + const topDown = options.topDown === true; + const nativeLittleEndian = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1; + const canViewDirectly = littleEndian === nativeLittleEndian && (offset % 4) === 0 && offset + out.byteLength <= arrayBuffer.byteLength; + + if (canViewDirectly) { + const source = new Float32Array(arrayBuffer, offset, out.length); + if (topDown) { + for (let y = 0; y < height; y++) { + const srcStart = (height - 1 - y) * valuesPerRow; + out.set(source.subarray(srcStart, srcStart + valuesPerRow), y * valuesPerRow); + } + } else { + out.set(source); + } + return { width, height, channels, data: out }; + } + + const dv = new DataView(arrayBuffer, offset); + if (topDown) { + for (let y = 0; y < height; y++) { + const srcRow = height - 1 - y; + let srcByte = srcRow * valuesPerRow * 4; + let dst = y * valuesPerRow; + for (let x = 0; x < valuesPerRow; x++) { + out[dst++] = dv.getFloat32(srcByte, littleEndian); + srcByte += 4; + } + } + } else { + for (let i = 0; i < out.length; i++) { + out[i] = dv.getFloat32(i * 4, littleEndian); } } return { width, height, channels, data: out }; @@ -110,14 +141,16 @@ export class PfmProcessor { * @param {number} height * @param {number} [channels] */ - _toImageDataFloat(data, width, height, channels = 1) { + _toImageDataFloat(data, width, height, channels = 1, renderOptions = {}) { + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; const settings = this.settingsManager.settings; const isGammaMode = settings.normalization?.gammaMode || false; // Calculate stats if needed (for auto-normalize or just to have them) /** @type {{min: number, max: number} | undefined} */ let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { stats = ImageStatsCalculator.calculateFloatStats(data, width, height, channels); this._cachedStats = stats; @@ -126,8 +159,39 @@ export class PfmProcessor { } } + const nanColor = this._getNanColor(settings); + if (renderOptions.targetCanvas && this._webglRenderer.canRender({ + data, + width, + height, + channels, + isFloat: true, + settings, + collectHistogram: renderOptions.collectHistogram === true + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data, + width, + height, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : 1, + typeMax: 1.0, + settings, + nanColor, + channels + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } + // Use centralized ImageRenderer - return ImageRenderer.render( + const options = { + nanColor, + collectHistogram: renderOptions.collectHistogram === true + }; + const imageData = ImageRenderer.render( data, width, height, @@ -135,8 +199,10 @@ export class PfmProcessor { true, // isFloat (float32) stats || { min: 0, max: 1 }, settings, - { nanColor: this._getNanColor(settings) } + options ); + this._lastRenderHistogram = options.renderHistogramResult || null; + return imageData; } /** @@ -223,7 +289,7 @@ export class PfmProcessor { * Called when format-specific settings have been applied * @returns {ImageData|null} - The rendered image data, or null if no pending render */ - performDeferredRender() { + performDeferredRender(renderOptions = {}) { if (!this._pendingRenderData) { return null; } @@ -233,7 +299,7 @@ export class PfmProcessor { this._isInitialLoad = false; // Now render with the correct format-specific settings - const imageData = this._toImageDataFloat(displayData, width, height, channels); + const imageData = this._toImageDataFloat(displayData, width, height, channels, renderOptions); // Force status refresh this.vscode.postMessage({ type: 'refresh-status' }); @@ -245,10 +311,10 @@ export class PfmProcessor { * Re-render PFM with current settings (for real-time updates) * @returns {ImageData | null} */ - renderPfmWithSettings() { + renderPfmWithSettings(renderOptions = {}) { if (!this._lastRaw) return null; const { width, height, data, channels } = this._lastRaw; - return this._toImageDataFloat(data, width, height, channels); + return this._toImageDataFloat(data, width, height, channels, renderOptions); } /** @@ -295,4 +361,3 @@ export class PfmProcessor { return flipped; } } - diff --git a/media/modules/png-processor.js b/media/modules/png-processor.js index fd0210d..4234ed4 100644 --- a/media/modules/png-processor.js +++ b/media/modules/png-processor.js @@ -2,6 +2,8 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; +import { PerfTrace } from './perf-trace.js'; /** * @typedef {Object} RawImageData @@ -37,6 +39,8 @@ export class PngProcessor { this._cachedStats = undefined; this._cachedStatsRgb24Mode = false; this._lastRenderReusedOriginalImageData = false; + this._lastRenderUsedWebGL = false; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {{image: HTMLImageElement, canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, tempCanvas?: HTMLCanvasElement, tempCtx?: CanvasRenderingContext2D | null, width: number, height: number, format: string} | null} */ this._lazyNativeReadback = null; /** @type {AbortSignal|undefined} */ @@ -66,8 +70,7 @@ export class PngProcessor { this._cachedStats = undefined; this._cachedStatsRgb24Mode = false; - const response = await fetch(src, { signal: loadSignal }); - const arrayBuffer = await response.arrayBuffer(); + const arrayBuffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'png'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Quick bit depth detection from PNG IHDR chunk (just reads byte 24) @@ -84,6 +87,9 @@ export class PngProcessor { this.decodeWorker, 'png16', arrayBuffer, src, loadSignal, // @ts-ignore (b) => UPNG.decode(b)); + if (png.wasmFallbackReason) { + console.warn('[PngProcessor] Rust PNG decoder fell back to UPNG:', png.wasmFallbackReason); + } /* png = { @@ -130,19 +136,22 @@ export class PngProcessor { } else { // Use raw data - may be uint8 or uint16! if (pngBitDepth === 16) { - // PNG stores uint16 in big-endian format, need to swap bytes - const uint8Data = new Uint8Array(png.data); - const uint16Data = new Uint16Array(uint8Data.length / 2); - - // Swap bytes from big-endian to little-endian - for (let i = 0; i < uint16Data.length; i++) { - const byteIdx = i * 2; - const highByte = uint8Data[byteIdx]; // MSB (big-endian) - const lowByte = uint8Data[byteIdx + 1]; // LSB - uint16Data[i] = (highByte << 8) | lowByte; + if (png.decodedData instanceof Uint16Array) { + rawData = png.decodedData; + } else { + const swapStart = performance.now(); + // PNG stores uint16 in big-endian format, need to swap bytes + const uint8Data = new Uint8Array(png.data); + const uint16Data = new Uint16Array(uint8Data.length / 2); + + // Swap bytes from big-endian to little-endian + let src = 0; + for (let i = 0; i < uint16Data.length; i++, src += 2) { + uint16Data[i] = (uint8Data[src] << 8) | uint8Data[src + 1]; + } + PerfTrace.detail('png16-main-byte-swap', performance.now() - swapStart); + rawData = uint16Data; } - - rawData = uint16Data; } else { rawData = new Uint8Array(png.data); } @@ -273,9 +282,10 @@ export class PngProcessor { * Render raw image data to ImageData with gamma/brightness corrections * @returns {ImageData} */ - _renderToImageData() { + _renderToImageData(renderOptions = {}) { if (!this._lastRaw) return new ImageData(1, 1); this._lastRenderReusedOriginalImageData = false; + this._lastRenderUsedWebGL = false; const { width, height, data, channels, bitDepth, maxValue, originalImageData } = this._lastRaw; const settings = this.settingsManager.settings; @@ -303,7 +313,7 @@ export class PngProcessor { } /** @type {{min:number,max:number}|undefined} */ let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { stats = ImageStatsCalculator.calculateIntegerStats(/** @type {any} */ (data), width, height, channels, rgbAs24BitMode); this._cachedStats = stats; this._cachedStatsRgb24Mode = rgbAs24BitMode; @@ -314,14 +324,40 @@ export class PngProcessor { // For gamma mode, provide dummy stats (renderer uses full type range) if (isGammaMode && !stats) { - stats = { min: 0, max: maxValue }; + stats = { min: 0, max: rgbAs24BitMode ? 16777215 : maxValue }; } // Create options object const options = { rgbAs24BitGrayscale: settings.rgbAs24BitGrayscale && channels >= 3, - typeMax: maxValue + typeMax: rgbAs24BitMode ? 16777215 : maxValue }; + const typeMax = rgbAs24BitMode ? 16777215 : maxValue; + if (renderOptions.targetCanvas && this._webglRenderer.canRender({ + data, + width, + height, + channels, + isFloat: false, + settings + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data: /** @type {Uint16Array} */ (data), + width, + height, + channels, + isFloat: false, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : typeMax, + typeMax, + settings, + nanColor: { r: 0, g: 0, b: 0 } + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } return ImageRenderer.render( data, @@ -360,9 +396,26 @@ export class PngProcessor { return imageData; } - renderPngWithSettings() { + /** @param {number} [maxPixels] @returns {ImageData|null} */ + getLazyNativeHistogramImageData(maxPixels = 1_000_000) { + if (!this._lazyNativeReadback) return null; + const { image, width, height } = this._lazyNativeReadback; + const pixelCount = width * height; + const scale = pixelCount > maxPixels ? Math.sqrt(maxPixels / pixelCount) : 1; + const sampleWidth = Math.max(1, Math.round(width * scale)); + const sampleHeight = Math.max(1, Math.round(height * scale)); + const sampleCanvas = document.createElement('canvas'); + sampleCanvas.width = sampleWidth; + sampleCanvas.height = sampleHeight; + const sampleCtx = sampleCanvas.getContext('2d', { willReadFrequently: true }); + if (!sampleCtx) return null; + sampleCtx.drawImage(image, 0, 0, sampleWidth, sampleHeight); + return sampleCtx.getImageData(0, 0, sampleWidth, sampleHeight); + } + + renderPngWithSettings(renderOptions = {}) { if (!this._lastRaw && !this._ensureLazyNativeImageData()) return null; - return this._renderToImageData(); + return this._renderToImageData(renderOptions); } /** @@ -508,7 +561,7 @@ export class PngProcessor { * Called when format-specific settings have been applied * @returns {ImageData|null} - The rendered image data, or null if no pending render */ - performDeferredRender() { + performDeferredRender(renderOptions = {}) { if (!this._pendingRenderData || !this._lastRaw) { return null; } @@ -516,8 +569,9 @@ export class PngProcessor { this._pendingRenderData = null; this._isInitialLoad = false; + PerfTrace.mark('png-deferred-render-start'); // Render with the correct format-specific settings - const imageData = this._renderToImageData(); + const imageData = this._renderToImageData(renderOptions); // Force status refresh this.vscode.postMessage({ type: 'refresh-status' }); diff --git a/media/modules/ppm-processor.js b/media/modules/ppm-processor.js index b618886..2a7f322 100644 --- a/media/modules/ppm-processor.js +++ b/media/modules/ppm-processor.js @@ -2,6 +2,10 @@ "use strict"; import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { DecodeWorkerClient } from './decode-worker-client.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; +import { PerfTrace } from './perf-trace.js'; + +const IS_LITTLE_ENDIAN = new Uint8Array(new Uint16Array([1]).buffer)[0] === 1; /** @typedef {import('./settings-manager.js').SettingsManager} SettingsManager */ /** @typedef {{postMessage: (msg: any) => any}} VsCodeApi */ @@ -25,6 +29,8 @@ export class PpmProcessor { /** @type {{min:number,max:number}|undefined} */ this._cachedStats = undefined; // Cache for min/max stats (only used in stats mode) this._cachedStatsRgb24Mode = false; // Track whether cached stats were computed in rgb24 mode + this._lastRenderUsedWebGL = false; + this._webglRenderer = new WebGL2FloatRenderer(); /** @type {AbortSignal|undefined} */ this.loadSignal = undefined; // Set before each load; aborts the fetch when a newer image switch supersedes it /** @type {DecodeWorkerClient|null} */ @@ -34,8 +40,7 @@ export class PpmProcessor { /** @param {string} src */ async processPpm(src) { const loadSignal = this.loadSignal; - const response = await fetch(src, { signal: loadSignal }); - const buffer = await response.arrayBuffer(); + const buffer = await DecodeWorkerClient.fetchArrayBuffer(src, loadSignal, 'ppm'); if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } // Parse in the decode worker when available, locally otherwise. const { width, height, channels, data, maxval, format } = await DecodeWorkerClient.decodeWithFallback( @@ -51,7 +56,7 @@ export class PpmProcessor { this._cachedStats = undefined; this._cachedStatsRgb24Mode = false; - this._lastRaw = { width, height, data: displayData, maxval, channels }; + this._lastRaw = { width, height, data: displayData, maxval, channels, format }; const canvas = document.createElement('canvas'); canvas.width = width; @@ -75,6 +80,9 @@ export class PpmProcessor { /** @param {ArrayBuffer} arrayBuffer */ _parsePpm(arrayBuffer) { + const parserStart = performance.now(); + /** @type {{name:string,durationMs:number}[]} */ + const decodeTimings = []; const uint8Array = new Uint8Array(arrayBuffer); let offset = 0; @@ -172,8 +180,11 @@ export class PpmProcessor { // Determine data type based on maxval const use16bit = !isPbm && maxval > 255; const DataType = use16bit ? Uint16Array : Uint8Array; - const data = new DataType(totalValues); + /** @type {Uint8Array|Uint16Array} */ + let data = new DataType(totalValues); + decodeTimings.push({ name: 'decode-ppm-header', durationMs: performance.now() - parserStart }); + const rasterStart = performance.now(); if (isPbm && isAscii) { // PBM ASCII format (P1) - read 0s and 1s for (let i = 0; i < totalValues; i++) { @@ -225,6 +236,7 @@ export class PpmProcessor { data[i] = value; } } else { + const binarySetupStart = performance.now(); // Binary format (P5/P6) // PPM spec: after maxval, there is exactly ONE whitespace character (usually newline), // then the binary data starts immediately @@ -242,22 +254,46 @@ export class PpmProcessor { if (offset + expectedBytes > uint8Array.length) { throw new Error('Insufficient data for binary PPM/PGM'); } + decodeTimings.push({ name: 'decode-ppm-binary-setup', durationMs: performance.now() - binarySetupStart }); + const binaryCopyStart = performance.now(); if (use16bit) { - // 16-bit values (big-endian as per PPM spec) - const dataView = new DataView(arrayBuffer, offset); - for (let i = 0; i < totalValues; i++) { - data[i] = dataView.getUint16(i * 2, false); // false = big-endian + // 16-bit values are big-endian in NetPBM. Prefer a Uint16Array + // view plus in-place byte swap over byte-by-byte reconstruction. + const isAligned = offset % 2 === 0; + const rasterBuffer = isAligned ? arrayBuffer : arrayBuffer.slice(offset, offset + expectedBytes); + const rasterOffset = isAligned ? offset : 0; + data = new Uint16Array(rasterBuffer, rasterOffset, totalValues); + if (IS_LITTLE_ENDIAN) { + for (let i = 0; i < totalValues; i++) { + const value = data[i]; + data[i] = ((value & 0xff) << 8) | (value >>> 8); + } + decodeTimings.push({ + name: isAligned ? 'decode-ppm-byte-swap-u16-inplace' : 'decode-ppm-byte-swap-u16-slice', + durationMs: performance.now() - binaryCopyStart + }); + } else { + decodeTimings.push({ + name: isAligned ? 'decode-ppm-raster-view-u16' : 'decode-ppm-raster-slice-u16', + durationMs: performance.now() - binaryCopyStart + }); } } else { - // 8-bit values - for (let i = 0; i < totalValues; i++) { - data[i] = uint8Array[offset + i]; - } + // 8-bit binary raster can be used as a direct view. + data = uint8Array.subarray(offset, offset + expectedBytes); + decodeTimings.push({ + name: 'decode-ppm-raster-view-u8', + durationMs: performance.now() - binaryCopyStart + }); } } + decodeTimings.push({ name: 'decode-ppm-raster-total', durationMs: performance.now() - rasterStart }); + for (const timing of decodeTimings) { + PerfTrace.detail(timing.name, timing.durationMs); + } - return { width, height, channels, data, maxval, format }; + return { width, height, channels, data, maxval, format, decodeTimings }; } @@ -269,7 +305,8 @@ export class PpmProcessor { * @param {number} maxval * @param {number} [channels] */ - _toImageDataWithNormalization(data, width, height, maxval, channels = 1) { + _toImageDataWithNormalization(data, width, height, maxval, channels = 1, renderOptions = {}) { + this._lastRenderUsedWebGL = false; const settings = this.settingsManager.settings; const rgbAs24BitMode = (settings.rgbAs24BitGrayscale ?? false) && channels === 3; const isGammaMode = settings.normalization?.gammaMode || false; @@ -281,7 +318,7 @@ export class PpmProcessor { // Calculate stats if needed let stats = this._cachedStats; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { if (rgbAs24BitMode) { // For 24-bit mode, compute stats from combined 24-bit values let min = Infinity; @@ -325,6 +362,32 @@ export class PpmProcessor { rgbAs24BitGrayscale: rgbAs24BitMode, typeMax: rgbAs24BitMode ? 16777215 : maxval }; + const typeMax = rgbAs24BitMode ? 16777215 : maxval; + if (renderOptions.targetCanvas && this._webglRenderer.canRender({ + data, + width, + height, + channels, + isFloat: false, + settings + })) { + const rendered = this._webglRenderer.render(renderOptions.targetCanvas, { + data, + width, + height, + channels, + isFloat: false, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : typeMax, + typeMax, + settings, + nanColor: { r: 0, g: 0, b: 0 } + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } return ImageRenderer.render( data, @@ -342,10 +405,10 @@ export class PpmProcessor { /** * Re-render PPM/PGM with current settings (for real-time updates) */ - renderPgmWithSettings() { + renderPgmWithSettings(renderOptions = {}) { if (!this._lastRaw) return null; const { width, height, data, maxval, channels } = this._lastRaw; - return this._toImageDataWithNormalization(data, width, height, maxval, channels); + return this._toImageDataWithNormalization(data, width, height, maxval, channels, renderOptions); } /** @@ -463,7 +526,7 @@ export class PpmProcessor { * Perform deferred rendering using stored data and current settings * @returns {ImageData|null} Rendered image data or null */ - performDeferredRender() { + performDeferredRender(renderOptions = {}) { if (!this._pendingRenderData) { return null; } @@ -472,8 +535,9 @@ export class PpmProcessor { this._pendingRenderData = null; this._isInitialLoad = false; + PerfTrace.mark('ppm-deferred-render-start'); // Now render with the correct format-specific settings - const imageData = this._toImageDataWithNormalization(displayData, width, height, maxval, channels); + const imageData = this._toImageDataWithNormalization(displayData, width, height, maxval, channels, renderOptions); // Force status refresh this.vscode.postMessage({ type: 'refresh-status' }); diff --git a/media/modules/settings-manager.js b/media/modules/settings-manager.js index f957a70..a55f855 100644 --- a/media/modules/settings-manager.js +++ b/media/modules/settings-manager.js @@ -20,26 +20,18 @@ * @property {number} offset */ -/** - * @typedef {Object} MaskFilterSettings - * @property {string} maskUri - * @property {number} threshold - * @property {boolean} filterHigher - * @property {boolean} enabled - */ - /** * @typedef {Object} ImageSettings * @property {NormalizationSettings} [normalization] * @property {GammaSettings} [gamma] * @property {BrightnessSettings} [brightness] - * @property {MaskFilterSettings[]} [maskFilters] * @property {string} [nanColor] * @property {string} [displayColormap] * @property {boolean} [rgbAs24BitGrayscale] * @property {number} [scale24BitFactor] * @property {boolean} [normalizedFloatMode] * @property {boolean} [colorPickerShowModified] + * @property {boolean} [gpuAcceleration] * @property {boolean} [plyVisualizerInstalled] * @property {string} [resourceUri] * @property {string} [src] @@ -116,19 +108,18 @@ export class SettingsManager { /** * Update settings from new data (for real-time updates) * @param {ImageSettings} newSettings - New settings object - * @returns {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedMasks: boolean, changedStructure: boolean}} - What changed + * @returns {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedStructure: boolean}} - What changed */ updateSettings(newSettings) { if (!newSettings) { - return { changed: false, changedKeys: [], parametersOnly: false, changedMasks: false, changedStructure: false }; + return { changed: false, changedKeys: [], parametersOnly: false, changedStructure: false }; } // Track what changed - const changes = /** @type {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedMasks: boolean, changedStructure: boolean}} */ ({ + const changes = /** @type {{changed: boolean, changedKeys: string[], parametersOnly: boolean, changedStructure: boolean}} */ ({ changed: false, changedKeys: [], parametersOnly: false, - changedMasks: false, changedStructure: false }); @@ -149,8 +140,6 @@ export class SettingsManager { oldSettings.normalization?.gammaMode !== newSettings.normalization.gammaMode; // Check structural changes - const masksChanged = newSettings.maskFilters !== undefined && - JSON.stringify(oldSettings.maskFilters || []) !== JSON.stringify(newSettings.maskFilters); const rgbModeChanged = newSettings.rgbAs24BitGrayscale !== undefined && oldSettings.rgbAs24BitGrayscale !== newSettings.rgbAs24BitGrayscale; const scaleModeChanged = newSettings.scale24BitFactor !== undefined && @@ -172,7 +161,6 @@ export class SettingsManager { [normRangeChanged, 'normalization.range'], [normAutoChanged, 'normalization.auto'], [normGammaModeChanged, 'normalization.gammaMode'], - [masksChanged, 'maskFilters'], [rgbModeChanged, 'rgbAs24BitGrayscale'], [scaleModeChanged, 'scale24BitFactor'], [floatModeChanged, 'normalizedFloatMode'], @@ -183,11 +171,6 @@ export class SettingsManager { changes.changedKeys = changedFields.filter(([changed]) => changed).map(([, key]) => key); changes.changed = changes.changedKeys.length > 0; - // Determine change type - if (masksChanged) { - changes.changedMasks = true; - } - if (rgbModeChanged || scaleModeChanged || floatModeChanged) { changes.changedStructure = true; } @@ -195,11 +178,10 @@ 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 || displayColormapChanged || colorPickerModeChanged) && - !masksChanged && !rgbModeChanged && !scaleModeChanged && !floatModeChanged)) { + !rgbModeChanged && !scaleModeChanged && !floatModeChanged)) { changes.parametersOnly = true; - } else if (changes.changedStructure || changes.changedMasks) { + } else if (changes.changedStructure) { console.log('⚠️ Structural changes detected:', { - masksChanged, rgbModeChanged, scaleModeChanged, floatModeChanged, @@ -220,9 +202,6 @@ export class SettingsManager { brightness: newSettings.brightness ? { ...newSettings.brightness } : this._settings.brightness, - maskFilters: newSettings.maskFilters - ? [...newSettings.maskFilters] - : this._settings.maskFilters, }; return changes; diff --git a/media/modules/tiff-processor.js b/media/modules/tiff-processor.js index 655702b..9109fa8 100644 --- a/media/modules/tiff-processor.js +++ b/media/modules/tiff-processor.js @@ -3,6 +3,7 @@ import { NormalizationHelper, ImageRenderer, ImageStatsCalculator } from './normalization-helper.js'; import { TiffWasmProcessor } from './tiff-wasm-wrapper.js'; import { PerfTrace } from './perf-trace.js'; +import { WebGL2FloatRenderer } from './webgl2-float-renderer.js'; /** * @typedef {Object} GeoTIFFGlobal @@ -34,11 +35,12 @@ export class TiffProcessor { this.rawTiffData = null; this._pendingRenderData = null; // Store data waiting for format-specific settings this._isInitialLoad = true; // Track if this is the first render - this._maskCache = new Map(); // Cache loaded mask images by URI this._lastImageData = null; // Store the last rendered image data for fast parameter updates /** @type {{min:number,max:number}|null} */ this._lastStatistics = null; // Cache min/max statistics this._lastStatisticsRgb24Mode = false; // Track whether cached stats were computed in rgb24 mode + this._lastRenderHistogram = null; // Histogram computed during render when requested + this._lastRenderUsedWebGL = false; // True when the latest render drew directly to the canvas /** @type {{ floatData: Float32Array, width?: number, height?: number, min?: number, max?: number } | null} */ this._convertedFloatData = null; // Cache converted float data for analysis /** @type {AbortSignal|undefined} */ @@ -48,6 +50,7 @@ export class TiffProcessor { // WASM decoder this._wasmProcessor = new TiffWasmProcessor(); + this._webglRenderer = new WebGL2FloatRenderer(); this._wasmAvailable = false; this._wasmProcessor.init().then(available => { this._wasmAvailable = available; @@ -91,6 +94,59 @@ export class TiffProcessor { } } + /** + * @param {any} source + * @returns {{rowsPerStrip?: number, stripCount?: number, stripByteCountTotal?: number, stripByteCountMax?: number, tileWidth?: number, tileLength?: number, tileCount?: number, directDecode?: boolean}} + */ + _getTiffLayoutInfo(source) { + if (!source) { return {}; } + if (source.fileDirectory) { + const fd = source.fileDirectory; + const byteCounts = (Array.isArray(fd.StripByteCounts) || ArrayBuffer.isView(fd.StripByteCounts)) ? fd.StripByteCounts : []; + const tileCounts = (Array.isArray(fd.TileByteCounts) || ArrayBuffer.isView(fd.TileByteCounts)) ? fd.TileByteCounts : []; + let stripByteCountTotal = 0; + let stripByteCountMax = 0; + for (const value of byteCounts) { + const numeric = Number(value || 0); + stripByteCountTotal += numeric; + if (numeric > stripByteCountMax) { stripByteCountMax = numeric; } + } + return { + rowsPerStrip: fd.RowsPerStrip, + stripCount: byteCounts.length || undefined, + stripByteCountTotal: byteCounts.length ? stripByteCountTotal : undefined, + stripByteCountMax: byteCounts.length ? stripByteCountMax : undefined, + tileWidth: fd.TileWidth, + tileLength: fd.TileLength, + tileCount: tileCounts.length || undefined + }; + } + return { + rowsPerStrip: source.rowsPerStrip, + stripCount: source.stripCount, + stripByteCountTotal: source.stripByteCountTotal, + stripByteCountMax: source.stripByteCountMax, + tileWidth: source.tileWidth, + tileLength: source.tileLength, + tileCount: source.tileCount, + directDecode: source.directDecode + }; + } + + /** @param {{rowsPerStrip?: number, stripCount?: number, stripByteCountTotal?: number, stripByteCountMax?: number, tileWidth?: number, tileLength?: number, tileCount?: number, directDecode?: boolean}} layout */ + _logTiffLayout(layout) { + const parts = []; + if (layout.rowsPerStrip) { parts.push(`rows/strip=${layout.rowsPerStrip}`); } + if (layout.stripCount) { parts.push(`strips=${layout.stripCount}`); } + if (layout.stripByteCountMax) { parts.push(`maxStripBytes=${layout.stripByteCountMax}`); } + if (layout.tileCount) { parts.push(`tiles=${layout.tileCount}`); } + if (layout.tileWidth && layout.tileLength) { parts.push(`tile=${layout.tileWidth}x${layout.tileLength}`); } + if (layout.directDecode) { parts.push('direct-uncompressed-path=yes'); } + if (parts.length) { + console.log(`[TiffProcessor] TIFF layout: ${parts.join(', ')}`); + } + } + /** * Process TIFF file from URL * @param {string} src - TIFF file URL @@ -98,12 +154,23 @@ export class TiffProcessor { */ async processTiff(src) { const startTime = performance.now(); + this._lastRenderHistogram = null; const loadSignal = this.loadSignal; /** @type {{engine: string, durationMs: number}|null} */ let decodeInfo = null; try { + const responseStart = performance.now(); const response = await fetch(src, { signal: loadSignal }); + PerfTrace.detail('fetch-tiff-response', performance.now() - responseStart); + const readStart = performance.now(); const buffer = await response.arrayBuffer(); + const readDuration = performance.now() - readStart; + PerfTrace.detail('fetch-tiff-arrayBuffer', readDuration); + const megabytes = buffer.byteLength / (1024 * 1024); + PerfTrace.note('fetch-tiff-bytes', `${megabytes.toFixed(1)}MB`); + if (readDuration > 0) { + PerfTrace.note('fetch-tiff-arrayBuffer-rate', `${(megabytes / (readDuration / 1000)).toFixed(0)}MB/s`); + } if (loadSignal?.aborted) { throw new DOMException('Load superseded', 'AbortError'); } const fetchTime = performance.now() - startTime; console.log(`[TiffProcessor] Fetch time: ${fetchTime.toFixed(2)}ms`); @@ -153,6 +220,16 @@ export class TiffProcessor { }); } PerfTrace.mark(decodedWith.startsWith('geotiff.js') ? 'decode-geotiff-worker' : 'decode-wasm-worker'); + if (Array.isArray(wasmResult.decodeTimings)) { + let measuredWorkerTime = 0; + for (const timing of wasmResult.decodeTimings) { + const durationMs = Number(timing?.durationMs); + if (!Number.isFinite(durationMs)) { continue; } + measuredWorkerTime += durationMs; + PerfTrace.detail(String(timing.name || 'decode-worker-detail'), durationMs); + } + PerfTrace.detail('decode-worker-transfer+overhead', decodeInfo.durationMs - measuredWorkerTime); + } } else { workerTiffFailed = true; localBuffer = (workerResponse?.buffer && workerResponse.buffer.byteLength > 0) ? workerResponse.buffer : null; @@ -222,7 +299,9 @@ export class TiffProcessor { const predictor = wasmResult.predictor; const photometricInterpretation = wasmResult.photometricInterpretation; const planarConfig = wasmResult.planarConfiguration; + const layoutInfo = this._getTiffLayoutInfo(wasmResult); console.log(`[TiffProcessor] Using metadata from WASM: compression=${compression}, predictor=${predictor}`); + this._logTiffLayout(layoutInfo); // Store TIFF data for pixel inspection and re-rendering // Create a minimal image-like object for compatibility @@ -247,6 +326,10 @@ export class TiffProcessor { }, data: data }; + if (Number.isFinite(wasmResult.min) && Number.isFinite(wasmResult.max)) { + this._lastStatistics = { min: wasmResult.min, max: wasmResult.max }; + this._lastStatisticsRgb24Mode = false; + } // Send format information to VS Code if (this.vscode && this._isInitialLoad) { @@ -266,6 +349,7 @@ export class TiffProcessor { planarConfig, samplesPerPixel, bitsPerSample, + ...layoutInfo, formatType, isInitialLoad: true, decodedWith: wasmResult.decodedWith || 'wasm' @@ -315,6 +399,8 @@ export class TiffProcessor { const predictor = fileDir.Predictor; const photometricInterpretation = fileDir.PhotometricInterpretation; const planarConfig = fileDir.PlanarConfiguration; + const layoutInfo = this._getTiffLayoutInfo(image); + this._logTiffLayout(layoutInfo); const canvas = document.createElement('canvas'); canvas.width = width; @@ -391,6 +477,7 @@ export class TiffProcessor { planarConfig: planarConfig, samplesPerPixel: image.getSamplesPerPixel(), bitsPerSample: image.getBitsPerSample(), + ...layoutInfo, formatType: formatType, // For per-format settings isInitialLoad: true, // Signal that this is the first load decodedWith: use24BitMode ? 'geotiff.js (24-bit mode)' : 'geotiff.js' @@ -419,39 +506,12 @@ export class TiffProcessor { * @param {*} rasters - Raster data * @returns {Promise} */ - async renderTiffWithSettings(image, rasters) { - // Create copies of rasters to avoid modifying the original data - const rastersCopy = []; - for (let i = 0; i < rasters.length; i++) { - rastersCopy.push(new Float32Array(rasters[i])); - } - PerfTrace.mark('raster-copy'); - - // Apply mask filtering if enabled + async renderTiffWithSettings(image, rasters, renderOptions = {}) { + this._lastRenderHistogram = null; + this._lastRenderUsedWebGL = false; const settings = this.settingsManager.settings; - if (settings.maskFilters && settings.maskFilters.length > 0) { - try { - // Apply all enabled masks in sequence - for (const maskFilter of settings.maskFilters) { - if (maskFilter.enabled && maskFilter.maskUri) { - const maskData = await this.loadMaskImage(maskFilter.maskUri); - // Apply mask filter to each band - for (let band = 0; band < rastersCopy.length; band++) { - const filteredData = this.applyMaskFilter( - rastersCopy[band], - maskData, - maskFilter.threshold, - maskFilter.filterHigher - ); - rastersCopy[band] = filteredData; - } - } - } - } catch (error) { - console.error('Error applying mask filters:', error); - // Continue without mask filtering if there's an error - } - } + const rastersCopy = rasters; + PerfTrace.mark('raster-copy-skipped'); const width = image.getWidth(); const height = image.getHeight(); @@ -487,7 +547,7 @@ export class TiffProcessor { let stats = this._lastStatistics; const isGammaMode = settings.normalization?.gammaMode || false; - if (!stats && !isGammaMode) { + if (!stats && NormalizationHelper.needsStats(settings)) { if (isFloat) { // Use centralized float stats calculator // We need to interleave data for the calculator if it's planar @@ -515,28 +575,28 @@ export class TiffProcessor { // Use the first 3 channels to determine the image stats if (settings.rgbAs24BitGrayscale && rastersCopy.length >= 3) { // Calculate min/max of combined 24-bit values + const r0 = rastersCopy[0]; + const r1 = rastersCopy[1]; + const r2 = rastersCopy[2]; for (let j = 0; j < rastersCopy[0].length; j++) { - const values = []; - for (let i = 0; i < 3; i++) { - const value = rastersCopy[i][j]; - if (!isNaN(value) && isFinite(value)) { - values.push(Math.round(Math.max(0, Math.min(255, value)))); - } else { - values.push(0); - } - } - const combined24bit = (values[0] << 16) | (values[1] << 8) | values[2]; - min = Math.min(min, combined24bit); - max = Math.max(max, combined24bit); + const rv = r0[j], gv = r1[j], bv = r2[j]; + const r = (rv === rv && rv !== Infinity && rv !== -Infinity) ? Math.round(Math.max(0, Math.min(255, rv))) : 0; + const g = (gv === gv && gv !== Infinity && gv !== -Infinity) ? Math.round(Math.max(0, Math.min(255, gv))) : 0; + const b = (bv === bv && bv !== Infinity && bv !== -Infinity) ? Math.round(Math.max(0, Math.min(255, bv))) : 0; + const combined24bit = (r << 16) | (g << 8) | b; + if (combined24bit < min) min = combined24bit; + if (combined24bit > max) max = combined24bit; } } else { // Normal mode: use individual channel values - for (let i = 0; i < Math.min(rastersCopy.length, 3); i++) { - for (let j = 0; j < rastersCopy[i].length; j++) { - const value = rastersCopy[i][j]; - if (!isNaN(value) && isFinite(value)) { - min = Math.min(min, value); - max = Math.max(max, value); + const scanChannels = Math.min(rastersCopy.length, 3); + for (let i = 0; i < scanChannels; i++) { + const raster = rastersCopy[i]; + for (let j = 0; j < raster.length; j++) { + const value = raster[j]; + if (value === value && value !== Infinity && value !== -Infinity) { + if (value < min) min = value; + if (value > max) max = value; } } } @@ -550,23 +610,26 @@ export class TiffProcessor { if (settings.rgbAs24BitGrayscale && rastersCopy.length >= 3) { // Same 24-bit logic + const r0 = rastersCopy[0]; + const r1 = rastersCopy[1]; + const r2 = rastersCopy[2]; for (let j = 0; j < rastersCopy[0].length; j++) { - const values = []; - for (let i = 0; i < 3; i++) { - const value = rastersCopy[i][j]; - values.push(Math.round(Math.max(0, Math.min(255, value)))); - } - const combined24bit = (values[0] << 16) | (values[1] << 8) | values[2]; - min = Math.min(min, combined24bit); - max = Math.max(max, combined24bit); + const r = Math.round(Math.max(0, Math.min(255, r0[j]))); + const g = Math.round(Math.max(0, Math.min(255, r1[j]))); + const b = Math.round(Math.max(0, Math.min(255, r2[j]))); + const combined24bit = (r << 16) | (g << 8) | b; + if (combined24bit < min) min = combined24bit; + if (combined24bit > max) max = combined24bit; } } else { - for (let i = 0; i < Math.min(rastersCopy.length, 3); i++) { - for (let j = 0; j < rastersCopy[i].length; j++) { - const value = rastersCopy[i][j]; - if (!isNaN(value) && isFinite(value)) { - min = Math.min(min, value); - max = Math.max(max, value); + const scanChannels = Math.min(rastersCopy.length, 3); + for (let i = 0; i < scanChannels; i++) { + const raster = rastersCopy[i]; + for (let j = 0; j < raster.length; j++) { + const value = raster[j]; + if (value === value && value !== Infinity && value !== -Infinity) { + if (value < min) min = value; + if (value > max) max = value; } } } @@ -593,34 +656,76 @@ export class TiffProcessor { let interleavedData; const len = width * height; - - if (isFloat) { - interleavedData = new Float32Array(len * channels); - } else if (bitsPerSample === 16) { - interleavedData = new Uint16Array(len * channels); + const storedData = this.rawTiffData?.data; + const canUseStoredInterleaved = + storedData && + storedData.length === len * channels && + (isFloat || + (bitsPerSample === 16 && storedData instanceof Uint16Array) || + (bitsPerSample !== 16 && (storedData instanceof Uint8Array || storedData instanceof Uint8ClampedArray))); + + if (canUseStoredInterleaved) { + interleavedData = storedData; + PerfTrace.mark('interleave-skipped'); } else { - interleavedData = new Uint8Array(len * channels); - } + if (isFloat) { + interleavedData = new Float32Array(len * channels); + } else if (bitsPerSample === 16) { + interleavedData = new Uint16Array(len * channels); + } else { + interleavedData = new Uint8Array(len * channels); + } - // Interleave - if (channels === 1) { - interleavedData.set(rastersCopy[0]); - } else { - for (let i = 0; i < len; i++) { - for (let c = 0; c < channels; c++) { - interleavedData[i * channels + c] = rastersCopy[c][i]; + // Interleave + if (channels === 1) { + interleavedData.set(rastersCopy[0]); + } else { + for (let i = 0; i < len; i++) { + for (let c = 0; c < channels; c++) { + interleavedData[i * channels + c] = rastersCopy[c][i]; + } } } + PerfTrace.mark('interleave'); } - PerfTrace.mark('interleave'); // Create options object const options = { nanColor: nanColor, - rgbAs24BitGrayscale: settings.rgbAs24BitGrayscale + rgbAs24BitGrayscale: settings.rgbAs24BitGrayscale, + collectHistogram: renderOptions.collectHistogram === true }; - return ImageRenderer.render( + const targetCanvas = renderOptions.targetCanvas; + const typeMax = isFloat ? 1.0 : (bitsPerSample === 16 ? 65535 : 255); + if (targetCanvas && this._webglRenderer.canRender({ + data: interleavedData, + width, + height, + channels, + isFloat, + settings, + collectHistogram: renderOptions.collectHistogram === true + })) { + const rendered = this._webglRenderer.render(targetCanvas, { + data: /** @type {Float32Array} */ (interleavedData), + width, + height, + min: (stats && Number.isFinite(stats.min)) ? stats.min : 0, + max: (stats && Number.isFinite(stats.max)) ? stats.max : typeMax, + typeMax, + settings, + nanColor, + channels + }); + if (rendered) { + this._lastRenderUsedWebGL = true; + this._lastRenderHistogram = null; + return renderOptions.placeholderImageData || new ImageData(width, height); + } + } + + const imageData = ImageRenderer.render( interleavedData, width, height, @@ -630,93 +735,23 @@ export class TiffProcessor { settings, options ); + this._lastRenderHistogram = options.renderHistogramResult || null; + return imageData; } /** - * Fast render TIFF data with current settings (skips mask loading and uses cached statistics) + * Fast render TIFF data with current settings. * @param {*} image - GeoTIFF image object * @param {*} rasters - Raster data - * @param {boolean} _skipMasks - Whether to skip mask filtering * @returns {Promise} */ - async renderTiffWithSettingsFast(/** @type {any} */ image, /** @type {any} */ rasters, _skipMasks = true) { + async renderTiffWithSettingsFast(/** @type {any} */ image, /** @type {any} */ rasters, renderOptions = {}) { // Redirect to main render method for now to ensure correctness and use centralized ImageRenderer - // TODO: Re-implement optimization for skipMasks if needed - return this.renderTiffWithSettings(image, rasters); - } - - async renderTiff(/** @type {any} */ image, /** @type {any} */ rasters) { - return this.renderTiffWithSettings(image, rasters); - } - - /** - * Load mask image for filtering - * @param {string} maskSrc - Mask TIFF file URL - * @returns {Promise} - */ - async loadMaskImage(maskSrc) { - // Check cache first - if (this._maskCache.has(maskSrc)) { - return this._maskCache.get(maskSrc); - } - - try { - const response = await fetch(maskSrc); - const buffer = await response.arrayBuffer(); - const tiff = await GeoTIFF.fromArrayBuffer(buffer); - const image = await tiff.getImage(); - const rasters = await image.readRasters(); - - // Return the first band as a Float32Array - const maskData = new Float32Array(rasters[0]); - - // Cache the mask data - this._maskCache.set(maskSrc, maskData); - - return maskData; - } catch (error) { - console.error('Error loading mask image:', error); - throw error; - } - } - - /** - * Clear the mask cache (call when mask URIs change) - */ - clearMaskCache() { - this._maskCache.clear(); + return this.renderTiffWithSettings(image, rasters, renderOptions); } - /** - * Apply mask filtering to image data - * @param {Float32Array} imageData - Original image data - * @param {Float32Array} maskData - Mask data - * @param {number} threshold - Threshold value - * @param {boolean} filterHigher - Whether to filter higher or lower values - * @returns {Float32Array} - Filtered image data - */ - applyMaskFilter(imageData, maskData, threshold, filterHigher) { - const filteredData = new Float32Array(imageData.length); - - for (let i = 0; i < imageData.length; i++) { - const maskValue = maskData[i]; - const imageValue = imageData[i]; - - let shouldFilter = false; - if (filterHigher) { - shouldFilter = maskValue > threshold; - } else { - shouldFilter = maskValue < threshold; - } - - if (shouldFilter) { - filteredData[i] = NaN; - } else { - filteredData[i] = imageValue; - } - } - - return filteredData; + async renderTiff(/** @type {any} */ image, /** @type {any} */ rasters, renderOptions = {}) { + return this.renderTiffWithSettings(image, rasters, renderOptions); } /** @@ -819,7 +854,7 @@ export class TiffProcessor { * Called when format-specific settings have been applied * @returns {Promise} - The rendered image data, or null if no pending render */ - async performDeferredRender() { + async performDeferredRender(renderOptions = {}) { const perfStart = performance.now(); if (!this._pendingRenderData) { return null; @@ -830,7 +865,7 @@ export class TiffProcessor { this._isInitialLoad = false; // Now render with the correct format-specific settings - const imageData = await this.renderTiff(image, rasters); + const imageData = await this.renderTiff(image, rasters, renderOptions); console.log(`[TiffProcessor] Deferred render took ${(performance.now() - perfStart).toFixed(2)}ms`); return imageData; } diff --git a/media/modules/tiff-wasm-wrapper.js b/media/modules/tiff-wasm-wrapper.js index a3f801d..34a5b2c 100644 --- a/media/modules/tiff-wasm-wrapper.js +++ b/media/modules/tiff-wasm-wrapper.js @@ -58,6 +58,14 @@ async function initWasm() { * @property {number} predictor - TIFF predictor value * @property {number} photometricInterpretation - TIFF photometric interpretation * @property {number} planarConfiguration - TIFF planar configuration + * @property {number} [rowsPerStrip] + * @property {number} [stripCount] + * @property {number} [stripByteCountTotal] + * @property {number} [stripByteCountMax] + * @property {number} [tileWidth] + * @property {number} [tileLength] + * @property {number} [tileCount] + * @property {boolean} [directDecode] * @property {Float32Array} data - Pixel data as floats * @property {number} min - Minimum value * @property {number} max - Maximum value @@ -112,6 +120,14 @@ export class TiffWasmProcessor { predictor: result.predictor, photometricInterpretation: result.photometric_interpretation, planarConfiguration: result.planar_configuration, + rowsPerStrip: result.rows_per_strip, + stripCount: result.strip_count, + stripByteCountTotal: Number(result.strip_byte_count_total || 0), + stripByteCountMax: Number(result.strip_byte_count_max || 0), + tileWidth: result.tile_width, + tileLength: result.tile_length, + tileCount: result.tile_count, + directDecode: result.direct_decode, data: new Float32Array(result.get_data_as_f32()), min: result.min_value, max: result.max_value diff --git a/media/modules/webgl2-float-renderer.js b/media/modules/webgl2-float-renderer.js new file mode 100644 index 0000000..2d19564 --- /dev/null +++ b/media/modules/webgl2-float-renderer.js @@ -0,0 +1,521 @@ +// @ts-check +"use strict"; + +import { NormalizationHelper } from './normalization-helper.js'; +import { PerfTrace } from './perf-trace.js'; +import { getColormapLut } from './colormaps.js'; + +/** @type {Map} */ +const validatedUploadPixelsByFormat = new Map(); +/** @type {Set} */ +const validatedColormapUploads = new Set(); + +/** + * Small WebGL2 renderer for the hot scientific-image paths: + * scalar/float RGB/uint16 data rendered directly to the visible canvas. + * + * It is intentionally narrow. Unsupported settings should fall back to the CPU + * ImageRenderer path rather than growing hidden behavior differences. + */ +export class WebGL2FloatRenderer { + constructor() { + /** @type {HTMLCanvasElement|null} */ + this.canvas = null; + /** @type {WebGL2RenderingContext|null} */ + this.gl = null; + /** @type {WebGLProgram|null} */ + this.program = null; + /** @type {WebGLTexture|null} */ + this.texture = null; + /** @type {WebGLTexture|null} */ + this.dummyFloatTexture = null; + /** @type {WebGLTexture|null} */ + this.dummyUintTexture = null; + /** @type {WebGLTexture|null} */ + this.colormapTexture = null; + /** @type {WebGLVertexArrayObject|null} */ + this.vao = null; + /** @type {Float32Array|Uint16Array|null} */ + this.textureData = null; + this.textureFormat = ''; + this.colormapName = ''; + this.textureWidth = 0; + this.textureHeight = 0; + this.failed = false; + this.rgb32fFailed = false; + this.uintTextureFailed = false; + } + + /** + * @param {{data: ArrayLike, width: number, height: number, channels: number, isFloat: boolean, settings: any, collectHistogram?: boolean}} params + */ + canRender(params) { + if (params.settings?.gpuAcceleration === false) { return false; } + if (this.failed) { return false; } + if (!ArrayBuffer.isView(params.data)) { return false; } + const wantsRgb24 = params.settings?.rgbAs24BitGrayscale && params.channels === 3; + const wantsScalar = params.channels === 1; + const wantsColor = !wantsRgb24 && (params.channels === 3 || params.channels === 4); + if (params.isFloat) { + if (params.data.BYTES_PER_ELEMENT !== 4) { return false; } + if (wantsRgb24 && this.rgb32fFailed) { return false; } + if (!wantsScalar && !wantsRgb24 && !wantsColor) { return false; } + } else { + if (this.uintTextureFailed || !(params.data instanceof Uint16Array)) { return false; } + if (!wantsScalar && !wantsRgb24 && !wantsColor) { return false; } + if (params.settings?.displayColormap && params.settings.displayColormap !== 'none' && !wantsScalar) { return false; } + } + return typeof WebGL2RenderingContext !== 'undefined'; + } + + /** + * @param {HTMLCanvasElement} canvas + * @param {{data: Float32Array|Uint16Array, width: number, height: number, channels?: number, isFloat?: boolean, min: number, max: number, typeMax: number, settings: any, nanColor: {r:number,g:number,b:number}, flipY?: boolean}} params + * @returns {boolean} true when WebGL rendered the canvas + */ + render(canvas, params) { + try { + const setupStart = performance.now(); + if (!this._ensureContext(canvas)) { return false; } + PerfTrace.mark('webgl-context-setup'); + PerfTrace.detail('webgl-context-setup-detail', performance.now() - setupStart); + const gl = /** @type {WebGL2RenderingContext} */ (this.gl); + const maxTextureSize = /** @type {number} */ (gl.getParameter(gl.MAX_TEXTURE_SIZE)); + if (params.width > maxTextureSize || params.height > maxTextureSize) { + console.warn(`[WebGL2FloatRenderer] Image ${params.width}x${params.height} exceeds max texture size ${maxTextureSize}; using CPU renderer`); + return false; + } + if (canvas.width !== params.width || canvas.height !== params.height) { + canvas.width = params.width; + canvas.height = params.height; + } + + const textureFormat = this._getTextureFormat(params); + this._uploadTextureIfNeeded(params.data, params.width, params.height, textureFormat); + this._uploadColormapIfNeeded(params.settings?.displayColormap || 'none'); + this._draw(params); + PerfTrace.mark('render-webgl'); + return true; + } catch (error) { + const textureFormat = this._getTextureFormat(params); + if (textureFormat.endsWith('ui')) { + this.uintTextureFailed = true; + console.warn('[WebGL2FloatRenderer] Disabled uint16 GPU path after render failure:', error); + return false; + } + if (params.settings?.rgbAs24BitGrayscale && params.channels === 3) { + this.rgb32fFailed = true; + console.warn('[WebGL2FloatRenderer] Disabled RGB24 GPU path after render failure:', error); + return false; + } + this.failed = true; + console.warn('[WebGL2FloatRenderer] Disabled after render failure:', error); + return false; + } + } + + dispose() { + const gl = this.gl; + if (gl) { + if (this.texture) { gl.deleteTexture(this.texture); } + if (this.dummyFloatTexture) { gl.deleteTexture(this.dummyFloatTexture); } + if (this.dummyUintTexture) { gl.deleteTexture(this.dummyUintTexture); } + if (this.colormapTexture) { gl.deleteTexture(this.colormapTexture); } + if (this.program) { gl.deleteProgram(this.program); } + if (this.vao) { gl.deleteVertexArray(this.vao); } + } + this.canvas = null; + this.gl = null; + this.program = null; + this.texture = null; + this.dummyFloatTexture = null; + this.dummyUintTexture = null; + this.colormapTexture = null; + this.vao = null; + this.textureData = null; + this.textureFormat = ''; + this.colormapName = ''; + this.textureWidth = 0; + this.textureHeight = 0; + } + + /** @param {HTMLCanvasElement} canvas */ + _ensureContext(canvas) { + if (this.gl && this.canvas === canvas && this.program && this.texture && this.vao) { + return true; + } + this.dispose(); + const gl = canvas.getContext('webgl2', { + alpha: false, + antialias: false, + depth: false, + stencil: false, + preserveDrawingBuffer: true, + premultipliedAlpha: false + }); + if (!gl) { + this.failed = true; + return false; + } + + const program = this._createProgram(gl); + const vao = gl.createVertexArray(); + const texture = gl.createTexture(); + const dummyFloatTexture = gl.createTexture(); + const dummyUintTexture = gl.createTexture(); + const colormapTexture = gl.createTexture(); + if (!program || !vao || !texture || !dummyFloatTexture || !dummyUintTexture || !colormapTexture) { + this.failed = true; + return false; + } + + gl.bindVertexArray(vao); + const vertexBuffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([ + -1, -1, 0, 1, + 1, -1, 1, 1, + -1, 1, 0, 0, + 1, 1, 1, 0 + ]), gl.STATIC_DRAW); + const stride = 4 * 4; + const posLoc = gl.getAttribLocation(program, 'a_position'); + const texLoc = gl.getAttribLocation(program, 'a_texCoord'); + gl.enableVertexAttribArray(posLoc); + gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, stride, 0); + gl.enableVertexAttribArray(texLoc); + gl.vertexAttribPointer(texLoc, 2, gl.FLOAT, false, stride, 2 * 4); + + gl.bindTexture(gl.TEXTURE_2D, texture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + gl.bindTexture(gl.TEXTURE_2D, dummyFloatTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, 1, 1, 0, gl.RED, gl.FLOAT, new Float32Array([0])); + + gl.bindTexture(gl.TEXTURE_2D, dummyUintTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16UI, 1, 1, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, new Uint16Array([0])); + + gl.bindTexture(gl.TEXTURE_2D, colormapTexture); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + + this.canvas = canvas; + this.gl = gl; + this.program = program; + this.texture = texture; + this.dummyFloatTexture = dummyFloatTexture; + this.dummyUintTexture = dummyUintTexture; + this.colormapTexture = colormapTexture; + this.vao = vao; + return true; + } + + /** + * @param {Float32Array|Uint16Array} data + * @param {number} width + * @param {number} height + * @param {'r32f'|'rgb32f'|'rgba32f'|'r16ui'|'rgb16ui'|'rgba16ui'} textureFormat + */ + _uploadTextureIfNeeded(data, width, height, textureFormat) { + const gl = /** @type {WebGL2RenderingContext} */ (this.gl); + if (this.textureData === data && this.textureWidth === width && this.textureHeight === height && this.textureFormat === textureFormat) { + PerfTrace.detail('webgl-texture-upload-skipped', 0); + return; + } + const start = performance.now(); + const setupStart = performance.now(); + gl.bindTexture(gl.TEXTURE_2D, this.texture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + PerfTrace.detail('webgl-texture-upload-setup', performance.now() - setupStart); + const uploadStart = performance.now(); + switch (textureFormat) { + case 'rgb32f': + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB32F, width, height, 0, gl.RGB, gl.FLOAT, data); + break; + case 'rgba32f': + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA32F, width, height, 0, gl.RGBA, gl.FLOAT, data); + break; + case 'r16ui': + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R16UI, width, height, 0, gl.RED_INTEGER, gl.UNSIGNED_SHORT, data); + break; + case 'rgb16ui': + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB16UI, width, height, 0, gl.RGB_INTEGER, gl.UNSIGNED_SHORT, data); + break; + case 'rgba16ui': + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16UI, width, height, 0, gl.RGBA_INTEGER, gl.UNSIGNED_SHORT, data); + break; + default: + gl.texImage2D(gl.TEXTURE_2D, 0, gl.R32F, width, height, 0, gl.RED, gl.FLOAT, data); + } + PerfTrace.detail(`webgl-texImage2D-${textureFormat}`, performance.now() - uploadStart); + const pixels = width * height; + const validatedPixels = validatedUploadPixelsByFormat.get(textureFormat) || 0; + if (pixels > validatedPixels) { + const errorStart = performance.now(); + const error = gl.getError(); + PerfTrace.detail('webgl-texture-upload-error-check', performance.now() - errorStart); + if (error !== gl.NO_ERROR) { + throw new Error(`Float texture upload failed: WebGL error ${error}`); + } + validatedUploadPixelsByFormat.set(textureFormat, pixels); + } else { + PerfTrace.detail('webgl-texture-upload-error-check-skipped', 0); + } + this.textureData = data; + this.textureWidth = width; + this.textureHeight = height; + this.textureFormat = textureFormat; + PerfTrace.mark('webgl-texture-upload'); + console.log(`[WebGL2] Float texture upload took ${(performance.now() - start).toFixed(2)}ms`); + } + + /** @param {{channels?: number, isFloat?: boolean, settings?: any}} params */ + _getTextureFormat(params) { + const channels = params.channels || 1; + const wantsRgb24 = params.settings?.rgbAs24BitGrayscale && channels === 3; + if (params.isFloat !== false) { + if (wantsRgb24 || channels === 3) { return 'rgb32f'; } + if (channels === 4) { return 'rgba32f'; } + return 'r32f'; + } + if (wantsRgb24 || channels === 3) { return 'rgb16ui'; } + if (channels === 4) { return 'rgba16ui'; } + return 'r16ui'; + } + + /** @param {string} colormapName */ + _uploadColormapIfNeeded(colormapName) { + if (!colormapName || colormapName === 'none') { + this.colormapName = 'none'; + return; + } + if (this.colormapName === colormapName) { return; } + const start = performance.now(); + const lut = getColormapLut(colormapName); + if (!lut) { + this.colormapName = 'none'; + return; + } + const gl = /** @type {WebGL2RenderingContext} */ (this.gl); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.colormapTexture); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGB8, 256, 1, 0, gl.RGB, gl.UNSIGNED_BYTE, lut); + if (!validatedColormapUploads.has(colormapName)) { + const error = gl.getError(); + if (error !== gl.NO_ERROR) { + throw new Error(`Colormap texture upload failed: WebGL error ${error}`); + } + validatedColormapUploads.add(colormapName); + } + this.colormapName = colormapName; + PerfTrace.detail('webgl-colormap-upload', performance.now() - start); + } + + /** @param {{min:number,max:number,typeMax:number,settings:any,nanColor:{r:number,g:number,b:number},channels?:number}} params */ + _draw(params) { + const gl = /** @type {WebGL2RenderingContext} */ (this.gl); + const program = /** @type {WebGLProgram} */ (this.program); + const settings = params.settings || {}; + const textureFormat = this.textureFormat; + const isUintTexture = textureFormat.endsWith('ui'); + const isGammaMode = settings.normalization?.gammaMode || false; + const isIdentity = NormalizationHelper.isIdentityTransformation(settings); + const stats = Number.isFinite(params.min) && Number.isFinite(params.max) + ? { min: params.min, max: params.max } + : null; + let displayMin; + let displayMax; + let gammaExponent = 1.0; + if (isGammaMode && isIdentity) { + displayMin = 0; + displayMax = params.typeMax; + } else { + const range = NormalizationHelper.getNormalizationRange(settings, stats, params.typeMax, true); + displayMin = range.min; + displayMax = range.max; + } + if (isGammaMode && !isIdentity) { + const range = NormalizationHelper.getEffectiveVisualizationRange(settings, 0, params.typeMax); + displayMin = range.min; + displayMax = range.max; + const gammaIn = settings.gamma?.in ?? 1.0; + const gammaOut = settings.gamma?.out ?? 1.0; + gammaExponent = gammaIn / gammaOut; + } + const range = displayMax - displayMin; + + const stateStart = performance.now(); + gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); + gl.useProgram(program); + gl.bindVertexArray(this.vao); + gl.activeTexture(gl.TEXTURE0); + gl.bindTexture(gl.TEXTURE_2D, isUintTexture ? this.dummyFloatTexture : this.texture); + gl.uniform1i(gl.getUniformLocation(program, 'u_dataFloat'), 0); + gl.activeTexture(gl.TEXTURE1); + gl.bindTexture(gl.TEXTURE_2D, this.colormapTexture); + gl.uniform1i(gl.getUniformLocation(program, 'u_colormap'), 1); + gl.activeTexture(gl.TEXTURE2); + gl.bindTexture(gl.TEXTURE_2D, isUintTexture ? this.texture : this.dummyUintTexture); + gl.uniform1i(gl.getUniformLocation(program, 'u_dataUint'), 2); + gl.uniform1f(gl.getUniformLocation(program, 'u_min'), displayMin); + gl.uniform1f(gl.getUniformLocation(program, 'u_invRange'), range > 0 ? 1.0 / range : 0.0); + gl.uniform1f(gl.getUniformLocation(program, 'u_gammaExponent'), gammaExponent); + gl.uniform1i(gl.getUniformLocation(program, 'u_flipY'), params.flipY ? 1 : 0); + const channels = params.channels || 1; + const renderMode = settings.rgbAs24BitGrayscale && channels === 3 + ? (isUintTexture ? 4 : 2) + : (channels >= 3 ? (isUintTexture ? 5 : 3) : (isUintTexture ? 1 : 0)); + gl.uniform1i(gl.getUniformLocation(program, 'u_renderMode'), renderMode); + gl.uniform1i(gl.getUniformLocation(program, 'u_useColormap'), !!settings.displayColormap && settings.displayColormap !== 'none' ? 1 : 0); + gl.uniform1f(gl.getUniformLocation(program, 'u_rgb24ChannelDivisor'), params.typeMax > 255 ? 257.0 : 1.0); + gl.uniform3f( + gl.getUniformLocation(program, 'u_nanColor'), + params.nanColor.r / 255, + params.nanColor.g / 255, + params.nanColor.b / 255 + ); + PerfTrace.detail('webgl-draw-state', performance.now() - stateStart); + const drawStart = performance.now(); + gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); + PerfTrace.detail('webgl-draw-submit', performance.now() - drawStart); + const flushStart = performance.now(); + gl.flush(); + PerfTrace.detail('webgl-flush', performance.now() - flushStart); + } + + /** + * @param {WebGL2RenderingContext} gl + * @returns {WebGLProgram|null} + */ + _createProgram(gl) { + const vertex = this._compileShader(gl, gl.VERTEX_SHADER, `#version 300 es +in vec2 a_position; +in vec2 a_texCoord; +out vec2 v_texCoord; +void main() { + v_texCoord = a_texCoord; + gl_Position = vec4(a_position, 0.0, 1.0); +}`); + const fragment = this._compileShader(gl, gl.FRAGMENT_SHADER, `#version 300 es +precision highp float; +precision highp sampler2D; +precision highp usampler2D; +uniform sampler2D u_dataFloat; +uniform usampler2D u_dataUint; +uniform sampler2D u_colormap; +uniform float u_min; +uniform float u_invRange; +uniform float u_gammaExponent; +uniform float u_rgb24ChannelDivisor; +uniform bool u_flipY; +uniform int u_renderMode; +uniform bool u_useColormap; +uniform vec3 u_nanColor; +in vec2 v_texCoord; +out vec4 outColor; +float applyGamma(float value) { + float normalized = clamp((value - u_min) * u_invRange, 0.0, 1.0); + if (abs(u_gammaExponent - 1.0) > 0.0001) { + normalized = pow(normalized, u_gammaExponent); + } + return normalized; +} +void main() { + vec2 texCoord = u_flipY ? vec2(v_texCoord.x, 1.0 - v_texCoord.y) : v_texCoord; + if (u_renderMode == 2 || u_renderMode == 4) { + vec3 sampleRgb = u_renderMode == 2 + ? texture(u_dataFloat, texCoord).rgb + : vec3(texture(u_dataUint, texCoord).rgb); + if (isnan(sampleRgb.r) || isinf(sampleRgb.r) || isnan(sampleRgb.g) || isinf(sampleRgb.g) || isnan(sampleRgb.b) || isinf(sampleRgb.b)) { + outColor = vec4(u_nanColor, 1.0); + return; + } + float r8 = floor(clamp(sampleRgb.r / u_rgb24ChannelDivisor, 0.0, 255.0) + 0.5); + float g8 = floor(clamp(sampleRgb.g / u_rgb24ChannelDivisor, 0.0, 255.0) + 0.5); + float b8 = floor(clamp(sampleRgb.b / u_rgb24ChannelDivisor, 0.0, 255.0) + 0.5); + float value24 = r8 * 65536.0 + g8 * 256.0 + b8; + float gray = applyGamma(value24); + outColor = vec4(vec3(gray), 1.0); + return; + } + if (u_renderMode == 3 || u_renderMode == 5) { + vec3 sampleRgb = u_renderMode == 3 + ? texture(u_dataFloat, texCoord).rgb + : vec3(texture(u_dataUint, texCoord).rgb); + if (isnan(sampleRgb.r) || isinf(sampleRgb.r) || isnan(sampleRgb.g) || isinf(sampleRgb.g) || isnan(sampleRgb.b) || isinf(sampleRgb.b)) { + outColor = vec4(u_nanColor, 1.0); + return; + } + outColor = vec4( + applyGamma(sampleRgb.r), + applyGamma(sampleRgb.g), + applyGamma(sampleRgb.b), + 1.0 + ); + return; + } + float value = u_renderMode == 1 + ? float(texture(u_dataUint, texCoord).r) + : texture(u_dataFloat, texCoord).r; + if (u_renderMode == 0) { + vec4 sampleValue = texture(u_dataFloat, texCoord); + if (isnan(sampleValue.r) || isinf(sampleValue.r) || isnan(sampleValue.g) || isinf(sampleValue.g) || isnan(sampleValue.b) || isinf(sampleValue.b)) { + outColor = vec4(u_nanColor, 1.0); + return; + } + } + float normalized = applyGamma(value); + if (u_useColormap) { + outColor = vec4(texture(u_colormap, vec2(normalized, 0.5)).rgb, 1.0); + return; + } + outColor = vec4(vec3(normalized), 1.0); +}`); + if (!vertex || !fragment) { return null; } + const program = gl.createProgram(); + if (!program) { return null; } + gl.attachShader(program, vertex); + gl.attachShader(program, fragment); + gl.linkProgram(program); + gl.deleteShader(vertex); + gl.deleteShader(fragment); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { + console.warn('[WebGL2FloatRenderer] Program link failed:', gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + return null; + } + return program; + } + + /** + * @param {WebGL2RenderingContext} gl + * @param {number} type + * @param {string} source + */ + _compileShader(gl, type, source) { + const shader = gl.createShader(type); + if (!shader) { return null; } + gl.shaderSource(shader, source); + gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { + console.warn('[WebGL2FloatRenderer] Shader compile failed:', gl.getShaderInfoLog(shader)); + gl.deleteShader(shader); + return null; + } + return shader; + } +} diff --git a/media/wasm/tiff-wasm.js b/media/wasm/tiff-wasm.js index 56feed3..163878d 100644 --- a/media/wasm/tiff-wasm.js +++ b/media/wasm/tiff-wasm.js @@ -124,6 +124,67 @@ function takeFromExternrefTable0(idx) { wasm.__externref_table_dealloc(idx); return value; } +/** + * @param {Uint8Array} data + * @returns {PngResult} + */ +export function decode_png16_fast(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.decode_png16_fast(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return PngResult.__wrap(ret[0]); +} + +/** + * @param {Uint8Array} data + * @returns {HdrResult} + */ +export function decode_hdr_fast(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.decode_hdr_fast(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return HdrResult.__wrap(ret[0]); +} + +/** + * Decode a TIFF file without eagerly computing min/max statistics. + * + * The webview render path computes stats lazily when a non-gamma mode needs + * them. Skipping eager stats saves a full pass over large float TIFFs during + * the common gamma-mode initial load. + * @param {Uint8Array} data + * @returns {TiffResult} + */ +export function decode_tiff_fast(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.decode_tiff_fast(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return TiffResult.__wrap(ret[0]); +} + +/** + * @param {Uint8Array} data + * @returns {ExrResult} + */ +export function decode_exr_fast(data) { + const ptr0 = passArray8ToWasm0(data, wasm.__wbindgen_malloc); + const len0 = WASM_VECTOR_LEN; + const ret = wasm.decode_exr_fast(ptr0, len0); + if (ret[2]) { + throw takeFromExternrefTable0(ret[1]); + } + return ExrResult.__wrap(ret[0]); +} + /** * Decode a TIFF file from an ArrayBuffer * Returns TiffResult with image data and metadata @@ -140,6 +201,303 @@ export function decode_tiff(data) { return TiffResult.__wrap(ret[0]); } +let cachedFloat64ArrayMemory0 = null; + +function getFloat64ArrayMemory0() { + if (cachedFloat64ArrayMemory0 === null || cachedFloat64ArrayMemory0.byteLength === 0) { + cachedFloat64ArrayMemory0 = new Float64Array(wasm.memory.buffer); + } + return cachedFloat64ArrayMemory0; +} + +function getArrayF64FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getFloat64ArrayMemory0().subarray(ptr / 8, ptr / 8 + len); +} + +let cachedUint16ArrayMemory0 = null; + +function getUint16ArrayMemory0() { + if (cachedUint16ArrayMemory0 === null || cachedUint16ArrayMemory0.byteLength === 0) { + cachedUint16ArrayMemory0 = new Uint16Array(wasm.memory.buffer); + } + return cachedUint16ArrayMemory0; +} + +function getArrayU16FromWasm0(ptr, len) { + ptr = ptr >>> 0; + return getUint16ArrayMemory0().subarray(ptr / 2, ptr / 2 + len); +} + +const ExrResultFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_exrresult_free(ptr >>> 0, 1)); + +export class ExrResult { + + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(ExrResult.prototype); + obj.__wbg_ptr = ptr; + ExrResultFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + ExrResultFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_exrresult_free(ptr, 0); + } + /** + * @returns {number} + */ + get timing_pack_ms() { + const ret = wasm.exrresult_timing_pack_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_read_ms() { + const ret = wasm.exrresult_timing_read_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_total_ms() { + const ret = wasm.exrresult_timing_total_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {Float32Array} + */ + take_data_as_f32() { + const ret = wasm.exrresult_take_data_as_f32(this.__wbg_ptr); + var v1 = getArrayF32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v1; + } + /** + * @returns {string} + */ + get channel_names_csv() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.exrresult_channel_names_csv(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + /** + * @returns {string} + */ + get displayed_channels_csv() { + let deferred1_0; + let deferred1_1; + try { + const ret = wasm.exrresult_displayed_channels_csv(this.__wbg_ptr); + deferred1_0 = ret[0]; + deferred1_1 = ret[1]; + return getStringFromWasm0(ret[0], ret[1]); + } finally { + wasm.__wbindgen_free(deferred1_0, deferred1_1, 1); + } + } + /** + * @returns {number} + */ + get width() { + const ret = wasm.exrresult_width(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get format() { + const ret = wasm.exrresult_format(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get height() { + const ret = wasm.exrresult_height(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get channels() { + const ret = wasm.exrresult_channels(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get data_type() { + const ret = wasm.exrresult_data_type(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) ExrResult.prototype[Symbol.dispose] = ExrResult.prototype.free; + +const HdrResultFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_hdrresult_free(ptr >>> 0, 1)); + +export class HdrResult { + + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(HdrResult.prototype); + obj.__wbg_ptr = ptr; + HdrResultFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + HdrResultFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_hdrresult_free(ptr, 0); + } + /** + * @returns {Float32Array} + */ + take_data_as_f32() { + const ret = wasm.hdrresult_take_data_as_f32(this.__wbg_ptr); + var v1 = getArrayF32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v1; + } + /** + * @returns {Float64Array} + */ + take_metadata_as_f64() { + const ret = wasm.hdrresult_take_metadata_as_f64(this.__wbg_ptr); + var v1 = getArrayF64FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 8, 8); + return v1; + } +} +if (Symbol.dispose) HdrResult.prototype[Symbol.dispose] = HdrResult.prototype.free; + +const PngResultFinalization = (typeof FinalizationRegistry === 'undefined') + ? { register: () => {}, unregister: () => {} } + : new FinalizationRegistry(ptr => wasm.__wbg_pngresult_free(ptr >>> 0, 1)); + +export class PngResult { + + static __wrap(ptr) { + ptr = ptr >>> 0; + const obj = Object.create(PngResult.prototype); + obj.__wbg_ptr = ptr; + PngResultFinalization.register(obj, obj.__wbg_ptr, obj); + return obj; + } + + __destroy_into_raw() { + const ptr = this.__wbg_ptr; + this.__wbg_ptr = 0; + PngResultFinalization.unregister(this); + return ptr; + } + + free() { + const ptr = this.__destroy_into_raw(); + wasm.__wbg_pngresult_free(ptr, 0); + } + /** + * @returns {number} + */ + get color_type() { + const ret = wasm.pngresult_color_type(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get timing_total_ms() { + const ret = wasm.pngresult_timing_total_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {Uint16Array} + */ + take_data_as_u16() { + const ret = wasm.pngresult_take_data_as_u16(this.__wbg_ptr); + var v1 = getArrayU16FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 2, 2); + return v1; + } + /** + * @returns {number} + */ + get timing_decode_ms() { + const ret = wasm.exrresult_timing_pack_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_convert_ms() { + const ret = wasm.exrresult_timing_total_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_read_info_ms() { + const ret = wasm.exrresult_timing_read_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get width() { + const ret = wasm.exrresult_channels(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get height() { + const ret = wasm.exrresult_format(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get channels() { + const ret = wasm.exrresult_data_type(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get bit_depth() { + const ret = wasm.pngresult_bit_depth(this.__wbg_ptr); + return ret >>> 0; + } +} +if (Symbol.dispose) PngResult.prototype[Symbol.dispose] = PngResult.prototype.free; + const TiffResultFinalization = (typeof FinalizationRegistry === 'undefined') ? { register: () => {}, unregister: () => {} } : new FinalizationRegistry(ptr => wasm.__wbg_tiffresult_free(ptr >>> 0, 1)); @@ -167,6 +525,20 @@ export class TiffResult { const ptr = this.__destroy_into_raw(); wasm.__wbg_tiffresult_free(ptr, 0); } + /** + * @returns {number} + */ + get tile_count() { + const ret = wasm.tiffresult_tile_count(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get tile_width() { + const ret = wasm.tiffresult_tile_width(this.__wbg_ptr); + return ret >>> 0; + } /** * @returns {number} */ @@ -174,6 +546,27 @@ export class TiffResult { const ret = wasm.tiffresult_compression(this.__wbg_ptr); return ret >>> 0; } + /** + * @returns {number} + */ + get strip_count() { + const ret = wasm.tiffresult_strip_count(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get tile_length() { + const ret = wasm.tiffresult_tile_length(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {boolean} + */ + get direct_decode() { + const ret = wasm.tiffresult_direct_decode(this.__wbg_ptr); + return ret !== 0; + } /** * @returns {number} */ @@ -191,6 +584,20 @@ export class TiffResult { wasm.__wbindgen_free(ret[0], ret[1] * 1, 1); return v1; } + /** + * @returns {number} + */ + get rows_per_strip() { + const ret = wasm.tiffresult_rows_per_strip(this.__wbg_ptr); + return ret >>> 0; + } + /** + * @returns {number} + */ + get timing_pack_ms() { + const ret = wasm.tiffresult_timing_pack_ms(this.__wbg_ptr); + return ret; + } /** * @returns {number} */ @@ -208,6 +615,45 @@ export class TiffResult { wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); return v1; } + /** + * @returns {number} + */ + get timing_stats_ms() { + const ret = wasm.tiffresult_timing_stats_ms(this.__wbg_ptr); + return ret; + } + /** + * Move float data out of the result when possible. This avoids cloning the + * decoded f32 vector before wasm-bindgen copies it into JS-owned memory. + * @returns {Float32Array} + */ + take_data_as_f32() { + const ret = wasm.tiffresult_take_data_as_f32(this.__wbg_ptr); + var v1 = getArrayF32FromWasm0(ret[0], ret[1]).slice(); + wasm.__wbindgen_free(ret[0], ret[1] * 4, 4); + return v1; + } + /** + * @returns {number} + */ + get timing_decode_ms() { + const ret = wasm.tiffresult_timing_decode_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_convert_ms() { + const ret = wasm.tiffresult_timing_convert_ms(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get timing_metadata_ms() { + const ret = wasm.tiffresult_timing_metadata_ms(this.__wbg_ptr); + return ret; + } /** * @returns {number} */ @@ -215,6 +661,20 @@ export class TiffResult { const ret = wasm.tiffresult_planar_configuration(this.__wbg_ptr); return ret >>> 0; } + /** + * @returns {number} + */ + get strip_byte_count_max() { + const ret = wasm.tiffresult_strip_byte_count_max(this.__wbg_ptr); + return ret; + } + /** + * @returns {number} + */ + get strip_byte_count_total() { + const ret = wasm.tiffresult_strip_byte_count_total(this.__wbg_ptr); + return ret; + } /** * @returns {number} */ @@ -247,14 +707,14 @@ export class TiffResult { * @returns {number} */ get max_value() { - const ret = wasm.tiffresult_max_value(this.__wbg_ptr); + const ret = wasm.pngresult_timing_total_ms(this.__wbg_ptr); return ret; } /** * @returns {number} */ get min_value() { - const ret = wasm.tiffresult_min_value(this.__wbg_ptr); + const ret = wasm.exrresult_timing_total_ms(this.__wbg_ptr); return ret; } /** @@ -361,6 +821,8 @@ function __wbg_finalize_init(instance, module) { __wbg_init.__wbindgen_wasm_module = module; cachedDataViewMemory0 = null; cachedFloat32ArrayMemory0 = null; + cachedFloat64ArrayMemory0 = null; + cachedUint16ArrayMemory0 = null; cachedUint8ArrayMemory0 = null; diff --git a/media/wasm/tiff-wasm.wasm b/media/wasm/tiff-wasm.wasm index 23f3312..4bfbaed 100644 Binary files a/media/wasm/tiff-wasm.wasm and b/media/wasm/tiff-wasm.wasm differ diff --git a/package.json b/package.json index 6c5978a..3d104a2 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "tiff-visualizer", "displayName": "Scientific Image Visualizer", "description": "Analyze float and int TIFF images, also works with exr, npy, png, jpg, ppm, pfm and pgm images.", - "version": "1.7.2", + "version": "1.8.0", "publisher": "kleinicke", "icon": "icon.png", "license": "MIT", @@ -35,7 +35,7 @@ "image", "viewer", "visualizer", - "geotiff", + "wasm", "float", "scientific", "raster", @@ -258,6 +258,16 @@ "group": "comparison@1" } ] + }, + "configuration": { + "title": "TIFF Visualizer", + "properties": { + "tiffVisualizer.gpuAcceleration": { + "type": "boolean", + "default": true, + "description": "Use GPU acceleration via WebGL2 for supported image rendering paths. Disable this if a GPU/driver causes incorrect output or slower rendering." + } + } } }, "scripts": { diff --git a/src/imagePreview/imagePreview.ts b/src/imagePreview/imagePreview.ts index dd0c068..5708d76 100644 --- a/src/imagePreview/imagePreview.ts +++ b/src/imagePreview/imagePreview.ts @@ -14,13 +14,12 @@ import { ColorPickerModeStatusBarEntry } from './colorPickerModeStatusBarEntry'; import { MessageRouter } from './messageHandlers'; import type { IImagePreviewManager } from './types'; import type { ImageSettings } from './appStateManager'; -import type { MaskFilterSettings } from './imageSettings'; -// Extended settings for webview (includes per-image mask filters and nanColor) +// Extended settings for webview (includes preview-local display settings). interface WebviewImageSettings extends ImageSettings { - maskFilters: MaskFilterSettings[]; nanColor: 'black' | 'fuchsia'; colorPickerShowModified: boolean; + gpuAcceleration: boolean; } export class ImagePreview extends MediaPreview { @@ -148,8 +147,8 @@ export class ImagePreview extends MediaPreview { scale24BitFactor: this._manager.appStateManager.imageSettings.scale24BitFactor, normalizedFloatMode: this._manager.appStateManager.imageSettings.normalizedFloatMode, colorPickerShowModified: this._manager.settingsManager.getColorPickerShowModified(), - maskFilters: [], - nanColor: this._manager.settingsManager.getNanColor() + nanColor: this._manager.settingsManager.getNanColor(), + gpuAcceleration: this.getGpuAccelerationEnabled() }; // Send to webview once @@ -157,8 +156,7 @@ export class ImagePreview extends MediaPreview { this.updateStatusBar(); }; - // Subscribe to both managers but use single update function - // Per-image settings (maskFilters) always apply to this preview + // Subscribe to both managers but use single update function. this._register(this._manager.settingsManager.onDidChangeSettings(() => updateSettings('preview-settings-changed'))); // Per-format settings (normalization, gamma, brightness) only apply if format matches this._register(this._manager.appStateManager.onDidChangeSettings(() => { @@ -167,6 +165,11 @@ export class ImagePreview extends MediaPreview { updateSettings('format-settings-changed'); } })); + this._register(vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('tiffVisualizer.gpuAcceleration')) { + updateSettings('configuration-changed'); + } + })); this._register(webviewEditor.onDidDispose(() => { if (this.previewState === PreviewState.Active) { @@ -298,9 +301,6 @@ export class ImagePreview extends MediaPreview { } private sendCurrentSettings() { - // Get current settings from both managers - const maskFilters = this._manager.settingsManager.getMaskFilterSettings(this.resource.toString()); - // Create full settings object const webviewSettings = { normalization: this._manager.appStateManager.imageSettings.normalization, @@ -309,15 +309,21 @@ export class ImagePreview extends MediaPreview { rgbAs24BitGrayscale: this._manager.appStateManager.imageSettings.rgbAs24BitGrayscale, scale24BitFactor: this._manager.appStateManager.imageSettings.scale24BitFactor, normalizedFloatMode: this._manager.appStateManager.imageSettings.normalizedFloatMode, - maskFilters: maskFilters, nanColor: this._manager.settingsManager.getNanColor(), - colorPickerShowModified: this._manager.settingsManager.getColorPickerShowModified() + colorPickerShowModified: this._manager.settingsManager.getColorPickerShowModified(), + gpuAcceleration: this.getGpuAccelerationEnabled() }; // Send to webview this.sendSettingsUpdate(webviewSettings, 'preview-refresh'); } + private getGpuAccelerationEnabled(): boolean { + return vscode.workspace + .getConfiguration('tiffVisualizer') + .get('gpuAcceleration', true); + } + public setImageSize(size: string): void { this._imageSize = size; } @@ -681,12 +687,6 @@ export class ImagePreview extends MediaPreview { private sendSettingsUpdate(settings: WebviewImageSettings, reason: string): void { // Send to both Active and Visible previews (for multi-preview support) if (this.previewState === PreviewState.Active || this.previewState === PreviewState.Visible) { - // Convert mask URIs to webview-safe URIs if they exist - const webviewSafeMasks = (settings.maskFilters || []).map(mask => ({ - ...mask, - maskUri: this._webviewEditor.webview.asWebviewUri(vscode.Uri.parse(mask.maskUri)).toString() - })); - // Use the currently displayed image URI (not always the original resource when // using the image collection). This prevents settings updates from triggering // a full reload when the webview is showing a different image in the collection. @@ -696,7 +696,6 @@ export class ImagePreview extends MediaPreview { const uri = this._webviewEditor.webview.asWebviewUri(currentResource); const webviewSafeSettings = { ...settings, - maskFilters: webviewSafeMasks, resourceUri: currentResource.toString(), src: uri.toString() }; @@ -784,8 +783,7 @@ export class ImagePreview extends MediaPreview { // Merge settings from both managers: // - normalization, gamma, brightness, rgbAs24BitGrayscale, scale24BitFactor, normalizedFloatMode from appStateManager (per-format) - // - maskFilters from settingsManager (per-image) - const maskFilters = this._manager.settingsManager.getMaskFilterSettings(this.resource.toString()); + // - nan color / color-picker mode from preview settings const settings = { normalization: this._manager.appStateManager.imageSettings.normalization, gamma: this._manager.appStateManager.imageSettings.gamma, @@ -793,10 +791,10 @@ export class ImagePreview extends MediaPreview { rgbAs24BitGrayscale: this._manager.appStateManager.imageSettings.rgbAs24BitGrayscale, scale24BitFactor: this._manager.appStateManager.imageSettings.scale24BitFactor, normalizedFloatMode: this._manager.appStateManager.imageSettings.normalizedFloatMode, - maskFilters: maskFilters, nanColor: this._manager.settingsManager.getNanColor(), colorPickerShowModified: this._manager.settingsManager.getColorPickerShowModified(), - plyVisualizerInstalled: !!vscode.extensions.getExtension('kleinicke.ply-visualizer') + plyVisualizerInstalled: !!vscode.extensions.getExtension('kleinicke.ply-visualizer'), + gpuAcceleration: this.getGpuAccelerationEnabled() }; const nonce = getNonce(); @@ -806,16 +804,9 @@ export class ImagePreview extends MediaPreview { const workspaceUri = vscode.workspace.getWorkspaceFolder(this.resource)?.uri ?? this.resource; const folderUri = this._webviewEditor.webview.asWebviewUri(workspaceUri); - // Convert mask URIs to webview-safe URIs if they exist - const webviewSafeMasks = settings.maskFilters.map(mask => ({ - ...mask, - maskUri: this._webviewEditor.webview.asWebviewUri(vscode.Uri.parse(mask.maskUri)).toString() - })); - // Extend settings with required properties for JavaScript const extendedSettings = { ...settings, - maskFilters: webviewSafeMasks, resourceUri: this.resource.toString(), src: uri.toString(), folder: folderUri.toString(), diff --git a/src/imagePreview/imageSettings.ts b/src/imagePreview/imageSettings.ts index 0e544da..88f95a9 100644 --- a/src/imagePreview/imageSettings.ts +++ b/src/imagePreview/imageSettings.ts @@ -1,20 +1,10 @@ import * as vscode from 'vscode'; import type { ImageStats } from './appStateManager'; -export interface MaskFilterSettings { - maskUri: string; - threshold: number; - filterHigher: boolean; - enabled: boolean; -} - export class ImageSettingsManager { private readonly _onDidChangeSettings = new vscode.EventEmitter(); public readonly onDidChangeSettings = this._onDidChangeSettings.event; - // Store mask filter settings per image URI - private _perImageMaskFilters = new Map(); - private _imageStats: ImageStats | undefined; private _comparisonBaseUri: vscode.Uri | undefined; private _nanColor: 'black' | 'fuchsia' = 'black'; @@ -28,42 +18,6 @@ export class ImageSettingsManager { return this._comparisonBaseUri; } - - public addMaskFilter(imageUri: string, mask: MaskFilterSettings): void { - const arr = this._perImageMaskFilters.get(imageUri) || []; - arr.push(mask); - this._perImageMaskFilters.set(imageUri, arr); - this._fireSettingsChanged(); - } - - public updateMaskFilter(imageUri: string, index: number, mask: Partial): void { - const arr = this._perImageMaskFilters.get(imageUri); - if (arr && arr[index]) { - arr[index] = { ...arr[index], ...mask }; - this._fireSettingsChanged(); - } - } - - public removeMaskFilter(imageUri: string, index: number): void { - const arr = this._perImageMaskFilters.get(imageUri); - if (arr && arr[index]) { - arr.splice(index, 1); - this._fireSettingsChanged(); - } - } - - public setMaskFilterEnabled(imageUri: string, index: number, enabled: boolean): void { - const arr = this._perImageMaskFilters.get(imageUri); - if (arr && arr[index]) { - arr[index].enabled = enabled; - this._fireSettingsChanged(); - } - } - - public getMaskFilterSettings(imageUri: string): MaskFilterSettings[] { - return this._perImageMaskFilters.get(imageUri) || []; - } - public toggleNanColor(): void { this._nanColor = this._nanColor === 'black' ? 'fuchsia' : 'black'; this._fireSettingsChanged(); @@ -101,4 +55,4 @@ export class ImageSettingsManager { public dispose(): void { this._onDidChangeSettings.dispose(); } -} \ No newline at end of file +} diff --git a/src/imagePreview/layersStatusBarEntry.ts b/src/imagePreview/layersStatusBarEntry.ts index ddbe6f0..231b85d 100644 --- a/src/imagePreview/layersStatusBarEntry.ts +++ b/src/imagePreview/layersStatusBarEntry.ts @@ -5,7 +5,7 @@ const LAYERS_COMMAND_ID = 'tiffVisualizer.toggleLayers'; /** * Status bar button that opens the Layers panel for image compositing. - * Replaces the former mask-filter button. + * Opens the layer stack and masking controls. */ export class LayersStatusBarEntry extends PreviewStatusBarEntry { constructor() { @@ -13,7 +13,7 @@ export class LayersStatusBarEntry extends PreviewStatusBarEntry { 'tiffVisualizer.layers', 'Layers', vscode.StatusBarAlignment.Right, - 97 // Same slot the mask filter button used (next to binary size) + 97 // Next to binary size ); this.entry.command = LAYERS_COMMAND_ID; this.entry.text = '$(layers) Layers'; diff --git a/src/imagePreview/messageHandlers.ts b/src/imagePreview/messageHandlers.ts index 26dc617..83c482c 100644 --- a/src/imagePreview/messageHandlers.ts +++ b/src/imagePreview/messageHandlers.ts @@ -27,7 +27,6 @@ export class MessageRouter { this.handlers.set('ready', new ReadyMessageHandler()); this.handlers.set('didExportAsPng', new ExportPngMessageHandler()); this.handlers.set('get-initial-data', new InitialDataMessageHandler()); - this.handlers.set('mask-filter-request', new MaskFilterRequestMessageHandler()); this.handlers.set('refresh-status', new RefreshStatusMessageHandler()); this.handlers.set('zoomStateResponse', new ZoomStateResponseMessageHandler()); this.handlers.set('comparisonStateResponse', new ComparisonStateResponseMessageHandler()); @@ -183,19 +182,6 @@ class InitialDataMessageHandler implements MessageHandler { } } -class MaskFilterRequestMessageHandler implements MessageHandler { - handle(message: any, preview: ImagePreview): void { - if (preview.isPreviewActive()) { - const imageUri = preview.resource.toString(); - const maskSettings = preview.getManager().settingsManager.getMaskFilterSettings(imageUri); - preview.getWebview().postMessage({ - type: 'mask-filter-settings', - settings: maskSettings - }); - } - } -} - class RefreshStatusMessageHandler implements MessageHandler { handle(message: any, preview: ImagePreview): void { preview.updateStatusBar(); diff --git a/src/imagePreview/sizeStatusBarEntry.ts b/src/imagePreview/sizeStatusBarEntry.ts index 8a62d8c..3445acc 100644 --- a/src/imagePreview/sizeStatusBarEntry.ts +++ b/src/imagePreview/sizeStatusBarEntry.ts @@ -17,6 +17,14 @@ interface FormatInfo { channels?: number; // Alternative to samplesPerPixel channelNames?: string[]; // All channel names in file (e.g., for EXR: ['R', 'G', 'B', 'A'] or ['ViewLayer.Combined.R', ...]) displayedChannels?: string[]; // Channels actually being displayed (subset of channelNames for multi-layer EXR) + rowsPerStrip?: number; + stripCount?: number; + stripByteCountTotal?: number; + stripByteCountMax?: number; + tileWidth?: number; + tileLength?: number; + tileCount?: number; + directDecode?: boolean; } export class SizeStatusBarEntry extends PreviewStatusBarEntry { @@ -120,6 +128,8 @@ export class SizeStatusBarEntry extends PreviewStatusBarEntry { if (formatInfo.planarConfig !== undefined) { info += `**Planar Config:** ${formatInfo.planarConfig === 1 ? 'Chunky' : 'Planar'}\n\n`; } + + info += this.getTiffLayoutMarkdown(formatInfo); } // Add color picker mode info @@ -200,6 +210,8 @@ export class SizeStatusBarEntry extends PreviewStatusBarEntry { if (info.planarConfig !== undefined) { tooltip.appendMarkdown(`**Planar Config:** ${info.planarConfig === 1 ? 'Chunky' : 'Planar'}\n\n`); } + + tooltip.appendMarkdown(this.getTiffLayoutMarkdown(info)); } // Add color picker mode info @@ -267,4 +279,49 @@ export class SizeStatusBarEntry extends PreviewStatusBarEntry { }; return photometricMap[photometric] || `Unknown (${photometric})`; } + + private getTiffLayoutMarkdown(info: FormatInfo): string { + let text = ''; + if (info.rowsPerStrip !== undefined || info.stripCount !== undefined || info.tileCount !== undefined) { + text += '**TIFF Storage:** '; + const parts: string[] = []; + if (info.stripCount !== undefined) { + parts.push(`${info.stripCount} strip${info.stripCount === 1 ? '' : 's'}`); + } + if (info.rowsPerStrip !== undefined) { + parts.push(`${info.rowsPerStrip} rows/strip`); + } + if (info.stripByteCountMax !== undefined) { + parts.push(`max strip ${this.formatBytes(info.stripByteCountMax)}`); + } + if (info.tileCount !== undefined && info.tileCount > 0) { + parts.push(`${info.tileCount} tile${info.tileCount === 1 ? '' : 's'}`); + } + if (info.tileWidth && info.tileLength) { + parts.push(`${info.tileWidth}×${info.tileLength} tile size`); + } + text += `${parts.join(', ')}\n\n`; + } + if (info.directDecode) { + text += '**Decode Path:** Direct uncompressed strip parser\n\n'; + } + return text; + } + + private formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) { + return 'unknown'; + } + if (bytes < 1024) { + return `${bytes} B`; + } + const units = ['KB', 'MB', 'GB']; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex++; + } + return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`; + } } diff --git a/test/behavioral.test.ts b/test/behavioral.test.ts index e6b7d57..7f7931b 100644 --- a/test/behavioral.test.ts +++ b/test/behavioral.test.ts @@ -313,40 +313,7 @@ suite('TIFF Visualizer Behavioral Tests', () => { }); }); - suite('6. Mask Filter Tests', () => { - test('Mask filter settings are properly configured', () => { - console.log('Testing mask filter settings configuration'); - - const { ImageSettingsManager } = require('../src/imagePreview/imageSettings'); - const settingsManager = new ImageSettingsManager(); - - // Test default mask filter settings - const defaultSettings = settingsManager.getMaskFilterSettings(); - assert.strictEqual(defaultSettings.enabled, false, 'Default should be disabled'); - assert.strictEqual(defaultSettings.threshold, 0.5, 'Default threshold should be 0.5'); - assert.strictEqual(defaultSettings.filterHigher, true, 'Default should filter higher values'); - - console.log('✅ Default mask filter settings are correct'); - }); - - test('Mask filter settings can be updated', () => { - console.log('Testing mask filter settings updates'); - - const { ImageSettingsManager } = require('../src/imagePreview/imageSettings'); - const settingsManager = new ImageSettingsManager(); - - // Test updating mask filter settings - settingsManager.setMaskFilter(true, 'file:///test/mask.tif', 0.8, false); - - const updatedSettings = settingsManager.getMaskFilterSettings(); - assert.strictEqual(updatedSettings.enabled, true, 'Should be enabled'); - assert.strictEqual(updatedSettings.maskUri, 'file:///test/mask.tif', 'Mask URI should be set'); - assert.strictEqual(updatedSettings.threshold, 0.8, 'Threshold should be updated'); - assert.strictEqual(updatedSettings.filterHigher, false, 'Should filter lower values'); - - console.log('✅ Mask filter settings can be updated correctly'); - }); - + suite('6. Layers Tests', () => { test('Layers status bar entry works correctly', () => { console.log('Testing layers status bar entry'); @@ -365,35 +332,5 @@ suite('TIFF Visualizer Behavioral Tests', () => { console.log('✅ Layers status bar entry works correctly'); }); - - // Test multiple mask filter functionality - test('Multiple mask filter management works correctly', () => { - const manager = new AppStateManager(); - const imageUri = 'file:///test/image.tif'; - - // Test that the manager can handle multiple mask filters - // Note: Actual mask filter management is handled by ImageSettingsManager - // This test verifies the AppStateManager integration - - // Test UI state management for mask filters - manager.setImageSize('100x100'); - assert.strictEqual(manager.uiState.imageSize, '100x100'); - - // Test settings management - manager.updateNormalization(0.1, 0.9); - assert.strictEqual(manager.imageSettings.normalization.min, 0.1); - assert.strictEqual(manager.imageSettings.normalization.max, 0.9); - - // Test gamma settings - manager.updateGamma(1.5, 2.5); - assert.strictEqual(manager.imageSettings.gamma.in, 1.5); - assert.strictEqual(manager.imageSettings.gamma.out, 2.5); - - // Test brightness settings - manager.updateBrightness(0.2); - assert.strictEqual(manager.imageSettings.brightness.offset, 0.2); - - console.log('✅ Multiple mask filter integration works correctly'); - }); }); -}); \ No newline at end of file +}); diff --git a/test/playwright/offline-extension-test.spec.ts b/test/playwright/offline-extension-test.spec.ts index 2334779..9c6dfe7 100644 --- a/test/playwright/offline-extension-test.spec.ts +++ b/test/playwright/offline-extension-test.spec.ts @@ -126,7 +126,6 @@ test.describe('Offline TIFF Visualizer Extension Tests', () => { 'tiffVisualizer.setBrightness', 'tiffVisualizer.selectForCompare', 'tiffVisualizer.compareWithSelected', - 'tiffVisualizer.filterByMask', 'tiffVisualizer.toggleNanColor' ]; @@ -213,4 +212,4 @@ test.describe('Offline TIFF Visualizer Extension Tests', () => { console.log(''); console.log('✅ Offline extension tests completed successfully'); }); -}); \ No newline at end of file +}); diff --git a/test/simple-behavior-test.js b/test/simple-behavior-test.js index d4efee2..516c4ee 100644 --- a/test/simple-behavior-test.js +++ b/test/simple-behavior-test.js @@ -144,39 +144,6 @@ class TestAppStateManager { this._imageSettings.nanColor = this._imageSettings.nanColor === 'black' ? 'fuchsia' : 'black'; } - setMaskFilter(imageUri, enabled, maskUri, threshold, filterHigher) { - // Mock implementation for testing - if (!this._perImageMaskFilters) { - this._perImageMaskFilters = new Map(); - } - - const currentSettings = this._perImageMaskFilters.get(imageUri) || { - enabled: false, - maskUri: undefined, - threshold: 0.5, - filterHigher: true - }; - - if (enabled !== undefined) currentSettings.enabled = enabled; - if (maskUri !== undefined) currentSettings.maskUri = maskUri; - if (threshold !== undefined) currentSettings.threshold = threshold; - if (filterHigher !== undefined) currentSettings.filterHigher = filterHigher; - - this._perImageMaskFilters.set(imageUri, currentSettings); - } - - getMaskFilterSettings(imageUri) { - if (!this._perImageMaskFilters) { - return this._imageSettings.maskFilter; - } - - const perImageSettings = this._perImageMaskFilters.get(imageUri); - if (perImageSettings) { - return perImageSettings; - } - return this._imageSettings.maskFilter; - } - dispose() { // Mock dispose } @@ -267,34 +234,6 @@ function testAppStateManager() { assert.strictEqual(finalColor, 'black', 'Final color should be black again'); console.log(' ✅ NaN color toggle works correctly'); - // Test per-image mask filter settings - console.log(' 🎭 Testing per-image mask filter settings...'); - const imageUri1 = 'file:///path/to/image1.tif'; - const imageUri2 = 'file:///path/to/image2.tif'; - - // Set mask filter for first image - manager.setMaskFilter(imageUri1, true, 'file:///path/to/mask1.tif', 0.3, true); - const settings1 = manager.getMaskFilterSettings(imageUri1); - assert.strictEqual(settings1.enabled, true, 'Mask filter should be enabled for image1'); - assert.strictEqual(settings1.maskUri, 'file:///path/to/mask1.tif', 'Mask URI should be set for image1'); - assert.strictEqual(settings1.threshold, 0.3, 'Threshold should be set for image1'); - assert.strictEqual(settings1.filterHigher, true, 'Filter direction should be set for image1'); - - // Set different mask filter for second image - manager.setMaskFilter(imageUri2, true, 'file:///path/to/mask2.tif', 0.7, false); - const settings2 = manager.getMaskFilterSettings(imageUri2); - assert.strictEqual(settings2.enabled, true, 'Mask filter should be enabled for image2'); - assert.strictEqual(settings2.maskUri, 'file:///path/to/mask2.tif', 'Mask URI should be set for image2'); - assert.strictEqual(settings2.threshold, 0.7, 'Threshold should be set for image2'); - assert.strictEqual(settings2.filterHigher, false, 'Filter direction should be set for image2'); - - // Verify settings are independent - const settings1Again = manager.getMaskFilterSettings(imageUri1); - assert.strictEqual(settings1Again.maskUri, 'file:///path/to/mask1.tif', 'Image1 settings should be preserved'); - assert.strictEqual(settings1Again.threshold, 0.3, 'Image1 threshold should be preserved'); - - console.log(' ✅ Per-image mask filter settings work correctly'); - // Cleanup manager.dispose(); cleanManager.dispose(); @@ -355,7 +294,6 @@ async function runAllTests() { console.log(' ✅ UI state management works'); console.log(' ✅ Image stats tracking works'); console.log(' ✅ NaN color toggle works'); - console.log(' ✅ Per-image mask filter settings work'); console.log(' ✅ Test image files are available'); console.log('\n🚀 Extension refactoring Phase 1 is stable and ready!'); console.log('\n🔍 Key Behaviors Verified:'); @@ -373,4 +311,4 @@ async function runAllTests() { } } -runAllTests(); \ No newline at end of file +runAllTests(); diff --git a/wasm/tiff-decoder/Cargo.lock b/wasm/tiff-decoder/Cargo.lock index 47fdfff..2a5aadd 100644 --- a/wasm/tiff-decoder/Cargo.lock +++ b/wasm/tiff-decoder/Cargo.lock @@ -8,6 +8,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bumpalo" version = "3.15.4" @@ -63,6 +75,20 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "smallvec", + "zune-inflate", +] + [[package]] name = "fax" version = "0.2.6" @@ -83,6 +109,15 @@ dependencies = [ "syn", ] +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "flate2" version = "1.1.5" @@ -130,6 +165,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "minicov" version = "0.3.7" @@ -156,6 +197,19 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -219,6 +273,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + [[package]] name = "syn" version = "2.0.56" @@ -250,8 +310,10 @@ name = "tiff-wasm" version = "0.1.0" dependencies = [ "console_error_panic_hook", + "exr", "hayro-ccitt", "js-sys", + "png", "ruzstd", "tiff", "wasm-bindgen", @@ -441,6 +503,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.5.15" diff --git a/wasm/tiff-decoder/Cargo.toml b/wasm/tiff-decoder/Cargo.toml index a882226..8241702 100644 --- a/wasm/tiff-decoder/Cargo.toml +++ b/wasm/tiff-decoder/Cargo.toml @@ -14,6 +14,8 @@ default = ["console_error_panic_hook"] [dependencies] wasm-bindgen = "0.2" tiff = { version = "0.11.3", features = ["webp"] } +exr = { version = "1.74", default-features = false } +png = { version = "0.17", default-features = false } ruzstd = "0.8" zune-jpeg = "0.5" hayro-ccitt = "0.3" diff --git a/wasm/tiff-decoder/src/lib.rs b/wasm/tiff-decoder/src/lib.rs index 41b84a9..3d97ecb 100644 --- a/wasm/tiff-decoder/src/lib.rs +++ b/wasm/tiff-decoder/src/lib.rs @@ -6,6 +6,8 @@ use wasm_bindgen::prelude::*; use std::io::Cursor; +use std::mem; +use exr::prelude::FlatSamples; use tiff::decoder::{Decoder, DecodingResult}; #[cfg(feature = "console_error_panic_hook")] @@ -24,11 +26,168 @@ pub struct TiffResult { predictor: u32, photometric_interpretation: u32, planar_configuration: u32, + rows_per_strip: u32, + strip_count: u32, + strip_byte_count_total: u64, + strip_byte_count_max: u64, + tile_width: u32, + tile_length: u32, + tile_count: u32, + direct_decode: bool, // Data stored as bytes, interpreted based on sample_format data: Vec, + // Float representation used by the webview render pipeline. For float TIFFs + // this avoids converting decoded f32 pixels to bytes and back again. + data_f32: Vec, // Computed statistics min_value: f64, max_value: f64, + timing_metadata_ms: f64, + timing_decode_ms: f64, + timing_convert_ms: f64, + timing_stats_ms: f64, + timing_pack_ms: f64, +} + +#[wasm_bindgen] +pub struct ExrResult { + width: u32, + height: u32, + channels: u32, + data_f32: Vec, + channel_names_csv: String, + displayed_channels_csv: String, + format: u32, + data_type: u32, + timing_read_ms: f64, + timing_pack_ms: f64, + timing_total_ms: f64, +} + +#[wasm_bindgen] +pub struct PngResult { + width: u32, + height: u32, + channels: u32, + bit_depth: u32, + color_type: u32, + data_u16: Vec, + timing_read_info_ms: f64, + timing_decode_ms: f64, + timing_convert_ms: f64, + timing_total_ms: f64, +} + +#[wasm_bindgen] +pub struct HdrResult { + data_f32: Vec, + metadata_f64: Vec, +} + +#[wasm_bindgen] +impl HdrResult { + #[wasm_bindgen] + pub fn take_data_as_f32(&mut self) -> Vec { + mem::take(&mut self.data_f32) + } + + #[wasm_bindgen] + pub fn take_metadata_as_f64(&mut self) -> Vec { + mem::take(&mut self.metadata_f64) + } +} + +#[wasm_bindgen] +impl PngResult { + #[wasm_bindgen(getter)] + pub fn width(&self) -> u32 { self.width } + + #[wasm_bindgen(getter)] + pub fn height(&self) -> u32 { self.height } + + #[wasm_bindgen(getter)] + pub fn channels(&self) -> u32 { self.channels } + + #[wasm_bindgen(getter)] + pub fn bit_depth(&self) -> u32 { self.bit_depth } + + #[wasm_bindgen(getter)] + pub fn color_type(&self) -> u32 { self.color_type } + + #[wasm_bindgen(getter)] + pub fn timing_read_info_ms(&self) -> f64 { self.timing_read_info_ms } + + #[wasm_bindgen(getter)] + pub fn timing_decode_ms(&self) -> f64 { self.timing_decode_ms } + + #[wasm_bindgen(getter)] + pub fn timing_convert_ms(&self) -> f64 { self.timing_convert_ms } + + #[wasm_bindgen(getter)] + pub fn timing_total_ms(&self) -> f64 { self.timing_total_ms } + + #[wasm_bindgen] + pub fn take_data_as_u16(&mut self) -> Vec { + mem::take(&mut self.data_u16) + } +} + +#[wasm_bindgen] +impl ExrResult { + #[wasm_bindgen(getter)] + pub fn width(&self) -> u32 { + self.width + } + + #[wasm_bindgen(getter)] + pub fn height(&self) -> u32 { + self.height + } + + #[wasm_bindgen(getter)] + pub fn channels(&self) -> u32 { + self.channels + } + + #[wasm_bindgen(getter)] + pub fn channel_names_csv(&self) -> String { + self.channel_names_csv.clone() + } + + #[wasm_bindgen(getter)] + pub fn displayed_channels_csv(&self) -> String { + self.displayed_channels_csv.clone() + } + + #[wasm_bindgen(getter)] + pub fn format(&self) -> u32 { + self.format + } + + #[wasm_bindgen(getter)] + pub fn data_type(&self) -> u32 { + self.data_type + } + + #[wasm_bindgen(getter)] + pub fn timing_read_ms(&self) -> f64 { + self.timing_read_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_pack_ms(&self) -> f64 { + self.timing_pack_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_total_ms(&self) -> f64 { + self.timing_total_ms + } + + #[wasm_bindgen] + pub fn take_data_as_f32(&mut self) -> Vec { + mem::take(&mut self.data_f32) + } } #[wasm_bindgen] @@ -68,6 +227,31 @@ impl TiffResult { self.max_value } + #[wasm_bindgen(getter)] + pub fn timing_metadata_ms(&self) -> f64 { + self.timing_metadata_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_decode_ms(&self) -> f64 { + self.timing_decode_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_convert_ms(&self) -> f64 { + self.timing_convert_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_stats_ms(&self) -> f64 { + self.timing_stats_ms + } + + #[wasm_bindgen(getter)] + pub fn timing_pack_ms(&self) -> f64 { + self.timing_pack_ms + } + #[wasm_bindgen(getter)] pub fn compression(&self) -> u32 { self.compression @@ -88,15 +272,66 @@ impl TiffResult { self.planar_configuration } + #[wasm_bindgen(getter)] + pub fn rows_per_strip(&self) -> u32 { + self.rows_per_strip + } + + #[wasm_bindgen(getter)] + pub fn strip_count(&self) -> u32 { + self.strip_count + } + + #[wasm_bindgen(getter)] + pub fn strip_byte_count_total(&self) -> f64 { + self.strip_byte_count_total as f64 + } + + #[wasm_bindgen(getter)] + pub fn strip_byte_count_max(&self) -> f64 { + self.strip_byte_count_max as f64 + } + + #[wasm_bindgen(getter)] + pub fn tile_width(&self) -> u32 { + self.tile_width + } + + #[wasm_bindgen(getter)] + pub fn tile_length(&self) -> u32 { + self.tile_length + } + + #[wasm_bindgen(getter)] + pub fn tile_count(&self) -> u32 { + self.tile_count + } + + #[wasm_bindgen(getter)] + pub fn direct_decode(&self) -> bool { + self.direct_decode + } + /// Get raw data as bytes (for transferring to JS) #[wasm_bindgen] pub fn get_data_bytes(&self) -> Vec { + if self.data.is_empty() && !self.data_f32.is_empty() { + let mut bytes = Vec::with_capacity(self.data_f32.len() * 4); + for &value in &self.data_f32 { + bytes.extend_from_slice(&value.to_le_bytes()); + } + return bytes; + } self.data.clone() } /// Get data as Float32Array (most common for visualization) #[wasm_bindgen] pub fn get_data_as_f32(&self) -> Vec { + if !self.data_f32.is_empty() { + return self.data_f32.clone(); + } + match self.sample_format { 3 => { // Already float32 @@ -123,12 +358,514 @@ impl TiffResult { _ => vec![], } } + + /// Move float data out of the result when possible. This avoids cloning the + /// decoded f32 vector before wasm-bindgen copies it into JS-owned memory. + #[wasm_bindgen] + pub fn take_data_as_f32(&mut self) -> Vec { + if !self.data_f32.is_empty() { + return mem::take(&mut self.data_f32); + } + self.get_data_as_f32() + } } /// Decode a TIFF file from an ArrayBuffer /// Returns TiffResult with image data and metadata #[wasm_bindgen] pub fn decode_tiff(data: &[u8]) -> Result { + decode_tiff_impl(data, true) +} + +/// Decode a TIFF file without eagerly computing min/max statistics. +/// +/// The webview render path computes stats lazily when a non-gamma mode needs +/// them. Skipping eager stats saves a full pass over large float TIFFs during +/// the common gamma-mode initial load. +#[wasm_bindgen] +pub fn decode_tiff_fast(data: &[u8]) -> Result { + decode_tiff_impl(data, false) +} + +#[wasm_bindgen] +pub fn decode_exr_fast(data: &[u8]) -> Result { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + decode_exr_impl(data) +} + +#[wasm_bindgen] +pub fn decode_png16_fast(data: &[u8]) -> Result { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + decode_png16_impl(data) +} + +fn decode_png16_impl(data: &[u8]) -> Result { + let start_time = js_sys::Date::now(); + let cursor = Cursor::new(data); + let mut limits = png::Limits::default(); + limits.bytes = 512 * 1024 * 1024; + let decoder = png::Decoder::new_with_limits(cursor, limits); + let mut reader = decoder.read_info() + .map_err(|e| JsValue::from_str(&format!("Failed to read PNG info: {}", e)))?; + let read_info_time = js_sys::Date::now() - start_time; + + let decode_start = js_sys::Date::now(); + let mut raw = vec![0u8; reader.output_buffer_size()]; + let info = reader.next_frame(&mut raw) + .map_err(|e| JsValue::from_str(&format!("Failed to decode PNG frame: {}", e)))?; + raw.truncate(info.buffer_size()); + let decode_time = js_sys::Date::now() - decode_start; + + if info.bit_depth != png::BitDepth::Sixteen { + return Err(JsValue::from_str("Rust PNG fast path only supports 16-bit PNG output")); + } + let channels = match info.color_type { + png::ColorType::Grayscale => 1, + png::ColorType::Rgb => 3, + png::ColorType::GrayscaleAlpha => 2, + png::ColorType::Rgba => 4, + png::ColorType::Indexed => return Err(JsValue::from_str("Rust PNG fast path does not support indexed 16-bit PNG")), + }; + + let expected_values = (info.width as usize) + .checked_mul(info.height as usize) + .and_then(|v| v.checked_mul(channels as usize)) + .ok_or_else(|| JsValue::from_str("PNG dimensions overflow"))?; + if raw.len() < expected_values * 2 { + return Err(JsValue::from_str("PNG decoded byte count is smaller than expected")); + } + + let convert_start = js_sys::Date::now(); + let mut values: Vec = Vec::with_capacity(expected_values); + let src_ptr = raw.as_ptr(); + let dst = values.as_mut_ptr(); + for i in 0..expected_values { + // SAFETY: `raw` was checked to contain at least `expected_values * 2` + // bytes, and `values` has capacity for every output sample. + unsafe { + let be = (src_ptr.add(i * 2) as *const u16).read_unaligned(); + dst.add(i).write(u16::from_be(be)); + } + } + unsafe { + values.set_len(expected_values); + } + let convert_time = js_sys::Date::now() - convert_start; + let total_time = js_sys::Date::now() - start_time; + + Ok(PngResult { + width: info.width, + height: info.height, + channels, + bit_depth: 16, + color_type: png_color_type_to_u32(info.color_type), + data_u16: values, + timing_read_info_ms: read_info_time, + timing_decode_ms: decode_time, + timing_convert_ms: convert_time, + timing_total_ms: total_time, + }) +} + +fn png_color_type_to_u32(color_type: png::ColorType) -> u32 { + match color_type { + png::ColorType::Grayscale => 0, + png::ColorType::Rgb => 2, + png::ColorType::Indexed => 3, + png::ColorType::GrayscaleAlpha => 4, + png::ColorType::Rgba => 6, + } +} + +#[wasm_bindgen] +pub fn decode_hdr_fast(data: &[u8]) -> Result { + #[cfg(feature = "console_error_panic_hook")] + console_error_panic_hook::set_once(); + + decode_hdr_impl(data) +} + +fn decode_hdr_impl(data: &[u8]) -> Result { + let start_time = js_sys::Date::now(); + let mut offset = 0usize; + let mut width = 0usize; + let mut height = 0usize; + let mut exposure = 1.0f32; + let mut gamma = 1.0f32; + let mut rle = false; + + for _ in 0..128 { + let line_start = offset; + while offset < data.len() && data[offset] != b'\n' { + offset += 1; + } + if offset >= data.len() { + return Err(JsValue::from_str("HDR header ended before resolution line")); + } + let line_bytes = &data[line_start..offset]; + offset += 1; + let line = std::str::from_utf8(line_bytes) + .map_err(|_| JsValue::from_str("HDR header is not UTF-8"))? + .trim(); + if line == "FORMAT=32-bit_rle_rgbe" { + rle = true; + } else if let Some(value) = line.strip_prefix("EXPOSURE=") { + exposure = value.trim().parse::().unwrap_or(1.0); + } else if let Some(value) = line.strip_prefix("GAMMA=") { + gamma = value.trim().parse::().unwrap_or(1.0); + } else if line.starts_with("-Y ") && line.contains(" +X ") { + let mut parts = line.split_whitespace(); + if parts.next() == Some("-Y") { + height = parts.next() + .ok_or_else(|| JsValue::from_str("Missing HDR height"))? + .parse::() + .map_err(|_| JsValue::from_str("Invalid HDR height"))?; + if parts.next() != Some("+X") { + return Err(JsValue::from_str("Unsupported HDR orientation")); + } + width = parts.next() + .ok_or_else(|| JsValue::from_str("Missing HDR width"))? + .parse::() + .map_err(|_| JsValue::from_str("Invalid HDR width"))?; + break; + } + } + } + + let header_time = js_sys::Date::now() - start_time; + if width == 0 || height == 0 { + return Err(JsValue::from_str("HDR resolution line not found")); + } + if !rle { + return Err(JsValue::from_str("Only FORMAT=32-bit_rle_rgbe HDR files are supported")); + } + if width > 0x7fff { + return Err(JsValue::from_str("HDR scanline is too wide for RLE")); + } + + let pixel_count = width.checked_mul(height) + .ok_or_else(|| JsValue::from_str("HDR dimensions overflow"))?; + let mut scanline = vec![0u8; width * 4]; + let mut output = vec![0f32; pixel_count * 4]; + let mut scales = [0f32; 256]; + for e in 1..256 { + scales[e] = 2f32.powi(e as i32 - 128) / 255.0; + } + let mut rle_time = 0.0; + let mut convert_time = 0.0; + + for y in 0..height { + let rle_start = js_sys::Date::now(); + if offset + 4 > data.len() { + return Err(JsValue::from_str("Unexpected EOF in HDR scanline header")); + } + let b0 = data[offset]; + let b1 = data[offset + 1]; + let b2 = data[offset + 2]; + let b3 = data[offset + 3]; + offset += 4; + if b0 != 2 || b1 != 2 || (b2 & 0x80) != 0 { + return Err(JsValue::from_str("HDR file is not new-style RLE encoded")); + } + let scanline_width = ((b2 as usize) << 8) | b3 as usize; + if scanline_width != width { + return Err(JsValue::from_str("HDR scanline width mismatch")); + } + for channel in 0..4 { + let mut ptr = channel * width; + let end = ptr + width; + while ptr < end { + if offset + 2 > data.len() { + return Err(JsValue::from_str("Unexpected EOF in HDR RLE data")); + } + let count_byte = data[offset]; + let value = data[offset + 1]; + offset += 2; + if count_byte > 128 { + let count = (count_byte - 128) as usize; + if count == 0 || ptr + count > end { + return Err(JsValue::from_str("Bad HDR RLE run")); + } + scanline[ptr..ptr + count].fill(value); + ptr += count; + } else { + let count = count_byte as usize; + if count == 0 || ptr + count > end { + return Err(JsValue::from_str("Bad HDR RLE literal")); + } + scanline[ptr] = value; + ptr += 1; + if count > 1 { + let remaining = count - 1; + if offset + remaining > data.len() { + return Err(JsValue::from_str("Unexpected EOF in HDR literal")); + } + scanline[ptr..ptr + remaining].copy_from_slice(&data[offset..offset + remaining]); + ptr += remaining; + offset += remaining; + } + } + } + } + rle_time += js_sys::Date::now() - rle_start; + + let convert_start = js_sys::Date::now(); + let row_offset = y * width * 4; + for x in 0..width { + let e = scanline[x + width * 3] as usize; + let out = row_offset + x * 4; + if e == 0 { + output[out] = 0.0; + output[out + 1] = 0.0; + output[out + 2] = 0.0; + } else { + let scale = scales[e]; + output[out] = scanline[x] as f32 * scale; + output[out + 1] = scanline[x + width] as f32 * scale; + output[out + 2] = scanline[x + width * 2] as f32 * scale; + } + output[out + 3] = 1.0; + } + convert_time += js_sys::Date::now() - convert_start; + } + + Ok(HdrResult { + data_f32: output, + metadata_f64: vec![ + width as f64, + height as f64, + exposure as f64, + gamma as f64, + header_time, + rle_time, + convert_time, + js_sys::Date::now() - start_time, + ], + }) +} + +fn decode_exr_impl(data: &[u8]) -> Result { + use exr::prelude::*; + + let start_time = js_sys::Date::now(); + let cursor = Cursor::new(data); + let image = read() + .no_deep_data() + .largest_resolution_level() + .all_channels() + .first_valid_layer() + .all_attributes() + .from_buffered(cursor) + .map_err(|e| JsValue::from_str(&format!("Failed to decode EXR: {}", e)))?; + let read_time = js_sys::Date::now() - start_time; + let pack_start = js_sys::Date::now(); + + let layer = image.layer_data; + let width = layer.size.0; + let height = layer.size.1; + if width == 0 || height == 0 { + return Err(JsValue::from_str("EXR has empty dimensions")); + } + + let mut channels = layer.channel_data.list; + if channels.is_empty() { + return Err(JsValue::from_str("EXR has no flat channels")); + } + + let pixel_count = width + .checked_mul(height) + .ok_or_else(|| JsValue::from_str("EXR dimensions overflow"))?; + let channel_names: Vec = channels.iter().map(|channel| channel.name.to_string()).collect(); + let selection = select_exr_display_channels(&channel_names); + if selection.source_indices.is_empty() { + return Err(JsValue::from_str("EXR has no displayable channels")); + } + + for &index in selection.source_indices.iter().flatten() { + let channel = &channels[index]; + if channel.sampling.0 != 1 || channel.sampling.1 != 1 { + return Err(JsValue::from_str("Subsampled EXR channels are not supported by the Rust fast path")); + } + if channel.sample_data.len() < pixel_count { + return Err(JsValue::from_str("EXR channel sample count is smaller than the image dimensions")); + } + } + + let output_channels = selection.source_indices.len(); + let interleaved = if output_channels == 1 { + let source_index = selection.source_indices[0] + .ok_or_else(|| JsValue::from_str("EXR grayscale selection unexpectedly has no source channel"))?; + let samples = mem::replace(&mut channels[source_index].sample_data, FlatSamples::F32(Vec::new())); + exr_samples_into_f32_vec(samples, pixel_count) + } else { + let mut interleaved = vec![0.0f32; pixel_count * output_channels]; + for (out_channel, source_index) in selection.source_indices.iter().enumerate() { + if let Some(source_index) = source_index { + copy_exr_channel_to_interleaved( + &channels[*source_index].sample_data, + &mut interleaved, + out_channel, + output_channels, + pixel_count, + ); + } else { + fill_exr_interleaved_channel(&mut interleaved, out_channel, output_channels, pixel_count, 1.0); + } + } + interleaved + }; + + let format = if output_channels == 1 { 1028 } else { 1023 }; + let pack_time = js_sys::Date::now() - pack_start; + let total_time = js_sys::Date::now() - start_time; + + Ok(ExrResult { + width: width as u32, + height: height as u32, + channels: output_channels as u32, + data_f32: interleaved, + channel_names_csv: channel_names.join(","), + displayed_channels_csv: selection.displayed_names.join(","), + format, + data_type: 1015, + timing_read_ms: read_time, + timing_pack_ms: pack_time, + timing_total_ms: total_time, + }) +} + +struct ExrChannelSelection { + source_indices: Vec>, + displayed_names: Vec, +} + +fn select_exr_display_channels(channel_names: &[String]) -> ExrChannelSelection { + let mut y = None; + let mut r = None; + let mut g = None; + let mut b = None; + let mut a = None; + + for (index, name) in channel_names.iter().enumerate() { + let base = exr_base_channel_name(name); + match base { + "Y" => y.get_or_insert(index), + "R" => r.get_or_insert(index), + "G" => g.get_or_insert(index), + "B" => b.get_or_insert(index), + "A" => a.get_or_insert(index), + "Z" | "z" | "depth" | "Depth" | "DEPTH" => y.get_or_insert(index), + _ if channel_names.len() == 1 => y.get_or_insert(index), + _ => continue, + }; + } + + if let (Some(r), Some(g), Some(b)) = (r, g, b) { + let mut source_indices = vec![Some(r), Some(g), Some(b)]; + let mut displayed_names = vec![ + channel_names[r].clone(), + channel_names[g].clone(), + channel_names[b].clone(), + ]; + if let Some(a) = a { + source_indices.push(Some(a)); + displayed_names.push(channel_names[a].clone()); + } else { + source_indices.push(None); + } + return ExrChannelSelection { source_indices, displayed_names }; + } + + if let Some(index) = y { + return ExrChannelSelection { + source_indices: vec![Some(index)], + displayed_names: vec![channel_names[index].clone()], + }; + } + + for (index, name) in channel_names.iter().enumerate() { + let base = exr_base_channel_name(name); + if base == "R" || base == "G" || base == "B" { + return ExrChannelSelection { + source_indices: vec![Some(index)], + displayed_names: vec![channel_names[index].clone()], + }; + } + } + + ExrChannelSelection { source_indices: Vec::new(), displayed_names: Vec::new() } +} + +fn exr_base_channel_name(name: &str) -> &str { + name.rsplit('.').next().unwrap_or(name) +} + +fn copy_exr_channel_to_interleaved( + samples: &FlatSamples, + out: &mut [f32], + out_channel: usize, + output_channels: usize, + pixel_count: usize, +) { + match samples { + FlatSamples::F16(values) => { + for i in 0..pixel_count { + out[i * output_channels + out_channel] = values[i].to_f32(); + } + } + FlatSamples::F32(values) => { + for i in 0..pixel_count { + out[i * output_channels + out_channel] = values[i]; + } + } + FlatSamples::U32(values) => { + for i in 0..pixel_count { + out[i * output_channels + out_channel] = values[i] as f32; + } + } + } +} + +fn exr_samples_into_f32_vec(samples: FlatSamples, pixel_count: usize) -> Vec { + match samples { + FlatSamples::F16(values) => { + let mut out = Vec::with_capacity(pixel_count); + for value in values.into_iter().take(pixel_count) { + out.push(value.to_f32()); + } + out + } + FlatSamples::F32(mut values) => { + values.truncate(pixel_count); + values + } + FlatSamples::U32(values) => { + let mut out = Vec::with_capacity(pixel_count); + for value in values.into_iter().take(pixel_count) { + out.push(value as f32); + } + out + } + } +} + +fn fill_exr_interleaved_channel( + out: &mut [f32], + out_channel: usize, + output_channels: usize, + pixel_count: usize, + value: f32, +) { + for i in 0..pixel_count { + out[i * output_channels + out_channel] = value; + } +} + +fn decode_tiff_impl(data: &[u8], compute_stats: bool) -> Result { #[cfg(feature = "console_error_panic_hook")] console_error_panic_hook::set_once(); @@ -193,6 +930,17 @@ pub fn decode_tiff(data: &[u8]) -> Result { let planar_configuration = decoder.get_tag_u32(tiff::tags::Tag::PlanarConfiguration) .unwrap_or(1); + let rows_per_strip = decoder.get_tag_u32(tiff::tags::Tag::RowsPerStrip).unwrap_or(height); + let strip_byte_counts = decoder.get_tag_u64_vec(tiff::tags::Tag::StripByteCounts).unwrap_or_default(); + let strip_count = strip_byte_counts.len() as u32; + let strip_byte_count_total = strip_byte_counts.iter().copied().sum::(); + let strip_byte_count_max = strip_byte_counts.iter().copied().max().unwrap_or(0); + let tile_width = decoder.get_tag_u32(tiff::tags::Tag::TileWidth).unwrap_or(0); + let tile_length = decoder.get_tag_u32(tiff::tags::Tag::TileLength).unwrap_or(0); + let tile_count = decoder.get_tag_u64_vec(tiff::tags::Tag::TileByteCounts) + .map(|counts| counts.len() as u32) + .unwrap_or(0); + // CCITT fax compressions: 2 (Modified Huffman), 3 (Group 3 / T.4) and // 4 (Group 4 / T.6). The tiff crate only decodes Group 4, so route all of // them through hayro-ccitt, which understands the TIFF encoding options. @@ -228,8 +976,22 @@ pub fn decode_tiff(data: &[u8]) -> Result { // the WASM build needs no C toolchain. The decompressed strips are rebuilt // into an uncompressed TIFF and handed back to the tiff crate, which still // performs predictor un-application and type/endianness handling. + let mut direct_decode = false; let decode_result = if compression == 50000 { decode_zstd(data, &mut decoder)? + } else if let Some(result) = try_decode_uncompressed_strips( + data, + &mut decoder, + width, + height, + channels, + bits_per_sample, + compression, + predictor, + planar_configuration, + )? { + direct_decode = true; + result } else { decoder.read_image() .map_err(|e| JsValue::from_str(&format!("Failed to decode image: {}", e)))? @@ -237,101 +999,209 @@ pub fn decode_tiff(data: &[u8]) -> Result { let decompress_time = js_sys::Date::now() - decode_start; let convert_start = js_sys::Date::now(); + let mut stats_time = 0.0; + let mut pack_time = 0.0; // Determine sample format and convert data to bytes - let (data_bytes, sample_format, min_val, max_val) = match decode_result { + let (data_bytes, data_f32, sample_format, min_val, max_val) = match decode_result { DecodingResult::U8(data) => { if bits_per_sample == 1 { // Uncompressed (or LZW/PackBits/Deflate) bilevel images are // returned as MSB-first packed bits with each row padded to a // byte boundary. Expand to one byte per pixel so they render // like any other 8-bit grayscale image. + let pack_start = js_sys::Date::now(); let expanded = unpack_bilevel(&data, width, height, photometric_interpretation); bits_per_sample = 8; - let (min, max) = compute_stats_u8(&expanded); - (expanded, 1u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_u8(&expanded); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + (expanded, Vec::new(), 1u32, min, max) } else { - let (min, max) = compute_stats_u8(&data); - (data, 1u32, min as f64, max as f64) + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_u8(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + (data, Vec::new(), 1u32, min, max) } } DecodingResult::U16(data) => { - let (min, max) = compute_stats_u16(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_u16(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; // SIMD-optimized byte conversion + let pack_start = js_sys::Date::now(); let bytes = convert_u16_to_bytes_simd(&data); - (bytes, 1u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 1u32, min, max) } DecodingResult::U32(data) => { - let (min, max) = compute_stats_u32(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_u32(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let bytes: Vec = data.iter() .flat_map(|&v| v.to_le_bytes()) .collect(); - (bytes, 1u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 1u32, min, max) } DecodingResult::U64(data) => { - let (min, max) = compute_stats_u64(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_u64(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let bytes: Vec = data.iter() .flat_map(|&v| v.to_le_bytes()) .collect(); - (bytes, 1u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 1u32, min, max) } DecodingResult::I8(data) => { - let (min, max) = compute_stats_i8(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_i8(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let ubytes: Vec = data.iter().map(|&v| v as u8).collect(); - (ubytes, 2u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (ubytes, Vec::new(), 2u32, min, max) } DecodingResult::I16(data) => { - let (min, max) = compute_stats_i16(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_i16(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let bytes: Vec = data.iter() .flat_map(|&v| v.to_le_bytes()) .collect(); - (bytes, 2u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 2u32, min, max) } DecodingResult::I32(data) => { - let (min, max) = compute_stats_i32(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_i32(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let bytes: Vec = data.iter() .flat_map(|&v| v.to_le_bytes()) .collect(); - (bytes, 2u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 2u32, min, max) } DecodingResult::I64(data) => { - let (min, max) = compute_stats_i64(&data); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_i64(&data); + stats_time += js_sys::Date::now() - stats_start; + (stats.0 as f64, stats.1 as f64) + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); let bytes: Vec = data.iter() .flat_map(|&v| v.to_le_bytes()) .collect(); - (bytes, 2u32, min as f64, max as f64) + pack_time += js_sys::Date::now() - pack_start; + (bytes, Vec::new(), 2u32, min, max) } DecodingResult::F32(data) => { - let (min, max) = compute_stats_f32(&data); - // SIMD-optimized byte conversion - let bytes = convert_f32_to_bytes_simd(&data); - (bytes, 3u32, min as f64, max as f64) + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_f32(&data); + stats_time += js_sys::Date::now() - stats_start; + stats + } else { + (f64::NAN, f64::NAN) + }; + (Vec::new(), data, 3u32, min, max) } DecodingResult::F64(data) => { - let (min, max) = compute_stats_f64(&data); - // Convert to f32 for consistency and pre-allocate - let mut bytes = Vec::with_capacity(data.len() * 4); + let (min, max) = if compute_stats { + let stats_start = js_sys::Date::now(); + let stats = compute_stats_f64(&data); + stats_time += js_sys::Date::now() - stats_start; + stats + } else { + (f64::NAN, f64::NAN) + }; + let pack_start = js_sys::Date::now(); + let mut values = Vec::with_capacity(data.len()); for &val in &data { - bytes.extend_from_slice(&(val as f32).to_le_bytes()); + values.push(val as f32); } - (bytes, 3u32, min, max) + pack_time += js_sys::Date::now() - pack_start; + (Vec::new(), values, 3u32, min, max) } DecodingResult::F16(data) => { // Convert f16 to f32 for processing and pre-allocate - let mut bytes = Vec::with_capacity(data.len() * 4); + let pack_start = js_sys::Date::now(); + let mut values = Vec::with_capacity(data.len()); let mut min_val = f32::INFINITY; let mut max_val = f32::NEG_INFINITY; - - for &val in &data { - let f32_val = val.to_f32(); - if f32_val < min_val { min_val = f32_val; } - if f32_val > max_val { max_val = f32_val; } - bytes.extend_from_slice(&f32_val.to_le_bytes()); + + if compute_stats { + for &val in &data { + let f32_val = val.to_f32(); + if f32_val < min_val { min_val = f32_val; } + if f32_val > max_val { max_val = f32_val; } + values.push(f32_val); + } + } else { + for &val in &data { + values.push(val.to_f32()); + } } - (bytes, 3u32, min_val as f64, max_val as f64) + pack_time += js_sys::Date::now() - pack_start; + let min = if compute_stats { min_val as f64 } else { f64::NAN }; + let max = if compute_stats { max_val as f64 } else { f64::NAN }; + (Vec::new(), values, 3u32, min, max) } }; + let convert_time = js_sys::Date::now() - convert_start; + let total_time = js_sys::Date::now() - start_time; + let metadata_time = total_time - decompress_time - convert_time; + let result = Ok(TiffResult { width, height, @@ -342,15 +1212,25 @@ pub fn decode_tiff(data: &[u8]) -> Result { predictor, photometric_interpretation, planar_configuration, + rows_per_strip, + strip_count, + strip_byte_count_total, + strip_byte_count_max, + tile_width, + tile_length, + tile_count, + direct_decode, data: data_bytes, + data_f32, min_value: min_val, max_value: max_val, + timing_metadata_ms: metadata_time, + timing_decode_ms: decompress_time, + timing_convert_ms: convert_time, + timing_stats_ms: stats_time, + timing_pack_ms: pack_time, }); - - let convert_time = js_sys::Date::now() - convert_start; - let total_time = js_sys::Date::now() - start_time; - let metadata_time = total_time - decompress_time - convert_time; - + web_sys::console::log_1(&format!( "[Rust] Total: {:.2}ms (metadata: {:.2}ms, decompress: {:.2}ms, convert: {:.2}ms)", total_time, metadata_time, decompress_time, convert_time @@ -359,6 +1239,173 @@ pub fn decode_tiff(data: &[u8]) -> Result { result } +fn tiff_is_little_endian(data: &[u8]) -> Option { + match data.get(0..4)? { + b"II*\0" | b"II+\0" => Some(true), + b"MM\0*" | b"MM\0+" => Some(false), + _ => None, + } +} + +fn try_decode_uncompressed_strips( + data: &[u8], + decoder: &mut Decoder>, + width: u32, + height: u32, + channels: u32, + bits_per_sample: u32, + compression: u32, + predictor: u32, + planar_configuration: u32, +) -> Result, JsValue> { + use tiff::tags::Tag; + + if compression != 1 || predictor != 1 || planar_configuration != 1 { + return Ok(None); + } + if bits_per_sample == 0 || bits_per_sample % 8 != 0 { + return Ok(None); + } + if decoder.get_tag_u64_vec(Tag::TileOffsets).is_ok() { + return Ok(None); + } + + let little_endian = match tiff_is_little_endian(data) { + Some(value) => value, + None => return Ok(None), + }; + let offsets = match decoder.get_tag_u64_vec(Tag::StripOffsets) { + Ok(value) if !value.is_empty() => value, + _ => return Ok(None), + }; + let counts = match decoder.get_tag_u64_vec(Tag::StripByteCounts) { + Ok(value) if value.len() == offsets.len() => value, + _ => return Ok(None), + }; + + let sample_format = decoder.get_tag_u64_vec(Tag::SampleFormat) + .ok() + .and_then(|values| values.first().copied()) + .unwrap_or(1) as u32; + let sample_count = (width as usize) + .checked_mul(height as usize) + .and_then(|v| v.checked_mul(channels as usize)) + .ok_or_else(|| JsValue::from_str("Direct TIFF decode: image dimensions overflow"))?; + let bytes_per_sample = (bits_per_sample / 8) as usize; + let expected_bytes = sample_count + .checked_mul(bytes_per_sample) + .ok_or_else(|| JsValue::from_str("Direct TIFF decode: raster byte count overflow"))?; + + let total_available = counts.iter().try_fold(0usize, |acc, &count| { + acc.checked_add(count as usize) + }).ok_or_else(|| JsValue::from_str("Direct TIFF decode: strip byte count overflow"))?; + if total_available < expected_bytes { + return Ok(None); + } + + let mut raster = Vec::with_capacity(expected_bytes); + for (&offset, &count) in offsets.iter().zip(counts.iter()) { + if raster.len() >= expected_bytes { + break; + } + let start = offset as usize; + let count_usize = count as usize; + let end = match start.checked_add(count_usize) { + Some(value) => value, + None => return Ok(None), + }; + if end > data.len() { + return Ok(None); + } + let remaining = expected_bytes - raster.len(); + let take = remaining.min(count_usize); + raster.extend_from_slice(&data[start..start + take]); + } + if raster.len() != expected_bytes { + return Ok(None); + } + + let result = match (sample_format, bits_per_sample) { + (1, 8) => DecodingResult::U8(raster), + (1, 16) => { + let values = raster.chunks_exact(2) + .map(|b| { + if little_endian { + u16::from_le_bytes([b[0], b[1]]) + } else { + u16::from_be_bytes([b[0], b[1]]) + } + }) + .collect(); + DecodingResult::U16(values) + } + (1, 32) => { + let values = raster.chunks_exact(4) + .map(|b| { + if little_endian { + u32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + u32::from_be_bytes([b[0], b[1], b[2], b[3]]) + } + }) + .collect(); + DecodingResult::U32(values) + } + (2, 8) => DecodingResult::I8(raster.into_iter().map(|v| v as i8).collect()), + (2, 16) => { + let values = raster.chunks_exact(2) + .map(|b| { + if little_endian { + i16::from_le_bytes([b[0], b[1]]) + } else { + i16::from_be_bytes([b[0], b[1]]) + } + }) + .collect(); + DecodingResult::I16(values) + } + (2, 32) => { + let values = raster.chunks_exact(4) + .map(|b| { + if little_endian { + i32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + i32::from_be_bytes([b[0], b[1], b[2], b[3]]) + } + }) + .collect(); + DecodingResult::I32(values) + } + (3, 32) => { + let values = raster.chunks_exact(4) + .map(|b| { + if little_endian { + f32::from_le_bytes([b[0], b[1], b[2], b[3]]) + } else { + f32::from_be_bytes([b[0], b[1], b[2], b[3]]) + } + }) + .collect(); + DecodingResult::F32(values) + } + (3, 64) => { + let values = raster.chunks_exact(8) + .map(|b| { + if little_endian { + f64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + } else { + f64::from_be_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]]) + } + }) + .collect(); + DecodingResult::F64(values) + } + _ => return Ok(None), + }; + + Ok(Some(result)) +} + /// Decode a ZSTD-compressed TIFF (compression 50000) using the pure-Rust /// ruzstd crate. We decompress each strip, concatenate the raster (still /// predictor-encoded), rebuild it as a single-strip *uncompressed* TIFF that @@ -485,7 +1532,7 @@ fn build_uncompressed_tiff( buf.extend_from_slice(&u32b(ifd_offset)); buf.extend_from_slice(&u16b(N_TAGS)); - let mut put = |buf: &mut Vec, tag: u16, typ: u16, count: u32, valfield: [u8; 4]| { + let put = |buf: &mut Vec, tag: u16, typ: u16, count: u32, valfield: [u8; 4]| { buf.extend_from_slice(&u16b(tag)); buf.extend_from_slice(&u16b(typ)); buf.extend_from_slice(&u32b(count)); @@ -590,9 +1637,23 @@ fn decode_jpeg_ycbcr( // Data is now decoded RGB (or grayscale); report it as such. photometric_interpretation: if channels == 3 { 2 } else { 1 }, planar_configuration: 1, + rows_per_strip: decoder.get_tag_u32(Tag::RowsPerStrip).unwrap_or(height), + strip_count: counts.len() as u32, + strip_byte_count_total: counts.iter().copied().sum::(), + strip_byte_count_max: counts.iter().copied().max().unwrap_or(0), + tile_width: 0, + tile_length: 0, + tile_count: 0, + direct_decode: false, data: rgb, + data_f32: Vec::new(), min_value: min as f64, max_value: max as f64, + timing_metadata_ms: 0.0, + timing_decode_ms: 0.0, + timing_convert_ms: 0.0, + timing_stats_ms: 0.0, + timing_pack_ms: 0.0, }) } @@ -693,6 +1754,13 @@ fn decode_palette(data: &[u8], width: u32, height: u32) -> Result = match d.read_image() .map_err(|e| JsValue::from_str(&format!("Palette: index decode failed: {}", e)))? @@ -725,9 +1793,23 @@ fn decode_palette(data: &[u8], width: u32, height: u32) -> Result(), + strip_byte_count_max: strip_byte_counts.iter().copied().max().unwrap_or(0), + tile_width, + tile_length, + tile_count, + direct_decode: false, data: rgb, + data_f32: Vec::new(), min_value: min as f64, max_value: max as f64, + timing_metadata_ms: 0.0, + timing_decode_ms: 0.0, + timing_convert_ms: 0.0, + timing_stats_ms: 0.0, + timing_pack_ms: 0.0, }) } @@ -864,44 +1946,28 @@ fn decode_ccitt( predictor, photometric_interpretation, planar_configuration, + rows_per_strip, + strip_count: counts.len() as u32, + strip_byte_count_total: counts.iter().copied().sum::(), + strip_byte_count_max: counts.iter().copied().max().unwrap_or(0), + tile_width: 0, + tile_length: 0, + tile_count: 0, + direct_decode: false, data: pixels, + data_f32: Vec::new(), min_value: min as f64, max_value: max as f64, + timing_metadata_ms: 0.0, + timing_decode_ms: 0.0, + timing_convert_ms: 0.0, + timing_stats_ms: 0.0, + timing_pack_ms: 0.0, }) } // SIMD-optimized conversion functions -/// Convert f32 slice to little-endian bytes using SIMD -#[inline] -fn convert_f32_to_bytes_simd(data: &[f32]) -> Vec { - use wide::*; - - let mut bytes = Vec::with_capacity(data.len() * 4); - - // Process 4 f32s at a time (128-bit SIMD) - let chunks = data.chunks_exact(4); - let remainder = chunks.remainder(); - - for chunk in chunks { - // Load 4 f32 values into SIMD register - let simd = f32x4::new([chunk[0], chunk[1], chunk[2], chunk[3]]); - - // Convert each to bytes and append - let arr = simd.to_array(); - for val in arr { - bytes.extend_from_slice(&val.to_le_bytes()); - } - } - - // Handle remaining values (0-3) with scalar code - for &val in remainder { - bytes.extend_from_slice(&val.to_le_bytes()); - } - - bytes -} - /// Convert u16 slice to little-endian bytes using SIMD #[inline] fn convert_u16_to_bytes_simd(data: &[u16]) -> Vec {