diff --git a/src/layers/weather/infraredAlpha.js b/src/layers/weather/infraredAlpha.js index f42bda603..287e985e0 100644 --- a/src/layers/weather/infraredAlpha.js +++ b/src/layers/weather/infraredAlpha.js @@ -1,22 +1,29 @@ export const INFRARED_ALPHA_T0 = 0.4; export const INFRARED_ALPHA_T1 = 0.7; -/** Return display pixels without changing RGB or the source RGBA bytes. +function infraredAmount(strength) { + return strength === 'full' + ? 0 + : strength === 'filtered' || !Number.isFinite(strength) + ? 1 + : Math.max(0, Math.min(100, strength)) / 100; +} + +/** Scale each pixel's alpha in place; RGB is left as it is. The scale depends + * only on the brightest channel, so it is worked out once for each of the 256 + * byte values instead of once per pixel: a 4096×2048 image has 8.4 million + * pixels and the power curve per pixel held the page for over 100 ms. The + * table holds the same numbers the per-pixel formula produced, so the output + * bytes are unchanged. * `strength` is 0–100: zero is the full image and 100 is the cloud key. */ -export function infraredAlpha(rgba, strength = 'filtered') { - const pixels = new Uint8ClampedArray(rgba); - const amount = - strength === 'full' - ? 0 - : strength === 'filtered' || !Number.isFinite(strength) - ? 1 - : Math.max(0, Math.min(100, strength)) / 100; +export function applyInfraredAlpha(pixels, strength = 'filtered') { + const amount = infraredAmount(strength); if (amount === 0) return pixels; - for (let i = 0; i < pixels.length; i += 4) { + const scale = new Float64Array(256); + for (let value = 0; value < 256; value++) { // Match Cesium's czm_srgbToLinear conversion in draped imagery. - const linearMax = - (Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2; + const linearMax = (value / 255) ** 2.2; const t = Math.max( 0, Math.min( @@ -26,7 +33,21 @@ export function infraredAlpha(rgba, strength = 'filtered') { ), ); const filtered = t * t * (3 - 2 * t); - pixels[i + 3] = rgba[i + 3] * (1 - amount + filtered * amount); + scale[value] = 1 - amount + filtered * amount; + } + for (let i = 0; i < pixels.length; i += 4) { + const r = pixels[i]; + const g = pixels[i + 1]; + const b = pixels[i + 2]; + const max = r > g ? (r > b ? r : b) : g > b ? g : b; + pixels[i + 3] = pixels[i + 3] * scale[max]; } return pixels; } + +/** Return display pixels without changing RGB or the source RGBA bytes. + * `strength` is 0–100: zero is the full image and 100 is the cloud key. + */ +export function infraredAlpha(rgba, strength = 'filtered') { + return applyInfraredAlpha(new Uint8ClampedArray(rgba), strength); +} diff --git a/src/layers/weather/infraredAlpha.test.mjs b/src/layers/weather/infraredAlpha.test.mjs index 950271a9d..d2c20cc6e 100644 --- a/src/layers/weather/infraredAlpha.test.mjs +++ b/src/layers/weather/infraredAlpha.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { + applyInfraredAlpha, infraredAlpha, INFRARED_ALPHA_T0, INFRARED_ALPHA_T1, @@ -61,3 +62,43 @@ test('full infrared preserves every channel including partial and zero alpha', ( ]); assert.deepEqual(infraredAlpha(pixels, 'full'), pixels); }); + +test('the lookup table gives the per-pixel curve byte for byte, in place', () => { + // The transfer as it was written per pixel, before the table. + const perPixel = (rgba, amount) => { + const out = new Uint8ClampedArray(rgba); + for (let i = 0; i < out.length; i += 4) { + const linearMax = + (Math.max(rgba[i], rgba[i + 1], rgba[i + 2]) / 255) ** 2.2; + const t = Math.max( + 0, + Math.min( + 1, + (linearMax - INFRARED_ALPHA_T0) / + (INFRARED_ALPHA_T1 - INFRARED_ALPHA_T0), + ), + ); + out[i + 3] = rgba[i + 3] * (1 - amount + t * t * (3 - 2 * t) * amount); + } + return out; + }; + // Every brightest-channel value in each channel, against a spread of alphas. + const bytes = []; + for (let value = 0; value < 256; value++) + for (const alpha of [0, 1, 37, 128, 200, 254, 255]) { + bytes.push(value, 0, 0, alpha); + bytes.push(value >> 1, value, 0, alpha); + bytes.push(0, value >> 2, value, alpha); + } + const source = new Uint8ClampedArray(bytes); + for (const strength of [1, 33, 50, 99, 100, 'filtered', Number.NaN]) { + const amount = Number.isFinite(strength) ? strength / 100 : 1; + const pixels = source.slice(); + assert.equal(applyInfraredAlpha(pixels, strength), pixels); + assert.deepEqual(pixels, perPixel(source, amount), `strength ${strength}`); + } + for (const strength of [0, 'full']) { + const pixels = source.slice(); + assert.deepEqual(applyInfraredAlpha(pixels, strength), source); + } +}); diff --git a/src/layers/weather/infraredImage.js b/src/layers/weather/infraredImage.js index de57ab3b9..3a017171b 100644 --- a/src/layers/weather/infraredImage.js +++ b/src/layers/weather/infraredImage.js @@ -6,7 +6,7 @@ import { drawWeatherPixels, closeWeatherPixels, } from './source.js'; -import { infraredAlpha } from './infraredAlpha.js'; +import { applyInfraredAlpha } from './infraredAlpha.js'; export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024; @@ -31,7 +31,8 @@ export function processInfraredImage( function applyInfraredTransfer(context, canvas, mode) { const pixels = context.getImageData(0, 0, canvas.width, canvas.height); - pixels.data.set(infraredAlpha(pixels.data, mode)); + // In place: a copy of a 4096×2048 image is 33 MB, made and written back. + applyInfraredAlpha(pixels.data, mode); context.putImageData(pixels, 0, 0); } diff --git a/src/layers/weather/infraredImage.test.mjs b/src/layers/weather/infraredImage.test.mjs index fb06607e4..a3fe08308 100644 --- a/src/layers/weather/infraredImage.test.mjs +++ b/src/layers/weather/infraredImage.test.mjs @@ -26,6 +26,28 @@ function canvas() { return output; } +test('the transfer is applied to the canvas pixels in place, with no copy written back', () => { + let read = null; + const output = {}; + output.getContext = () => ({ + drawImage() {}, + getImageData: () => { + read = { data: new Uint8ClampedArray([194, 0, 0, 200]) }; + // A second full-size buffer copied over the first is what this guards. + read.data.set = () => { + throw new Error('the canvas pixels were overwritten from a copy'); + }; + return read; + }, + putImageData: (pixels) => { + output.pixels = pixels; + }, + }); + processInfraredImage({ width: 1, height: 1 }, 'filtered', () => output); + assert.equal(output.pixels, read); + assert.deepEqual([...read.data], [194, 0, 0, 98]); +}); + test('decoded pixels are processed once on a same-sized canvas', () => { const image = { width: 1, height: 1 }; const output = processInfraredImage(image, 'filtered', canvas);