Files
gods-eye-view/src/layers/weather/infraredImage.js
T
Mazeyar Moeini FeizabadiandClaude Fable 5.2 0b5ae6386e perf(weather): apply the infrared cloud transfer from a lookup table, in place
The Satellite clouds transfer ran a power curve per pixel and wrote the
result back from a full-size copy. On the 4096x2048 images the 3D Tiles
host draws, that held the page for about 150 ms per image. The scale
depends only on the brightest channel, so it is now worked out once per
byte value and applied to the canvas pixels in place. Output bytes are
unchanged.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-13 14:42:57 +01:00

173 lines
5.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readResponseBytesCapped } from '../../sources/httpBody.js';
import { msg } from '../../i18n/index.js';
import {
weatherImageUrl,
weatherPixelSize,
drawWeatherPixels,
closeWeatherPixels,
} from './source.js';
import { applyInfraredAlpha } from './infraredAlpha.js';
export const MAX_MOSAIC_BYTES = 4 * 1024 * 1024;
/** Apply the display transfer once to a decoded image; `flipY` draws a
* bottom-up image back upright. */
export function processInfraredImage(
image,
mode,
createCanvas,
{ flipY = false } = {},
) {
const canvas = createCanvas();
canvas.width = image.width;
canvas.height = image.height;
const context = canvas.getContext('2d');
if (flipY) context.setTransform(1, 0, 0, -1, 0, image.height);
context.drawImage(image, 0, 0);
if (flipY) context.setTransform(1, 0, 0, 1, 0, 0);
applyInfraredTransfer(context, canvas, mode);
return canvas;
}
function applyInfraredTransfer(context, canvas, mode) {
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
// 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);
}
/** Draw pixels a frame source supplied into a new canvas, with the display
* transfer for infrared products, and release them. They must be `size`. */
function drawSuppliedImage(pixels, product, mode, size, signal, createCanvas) {
try {
signal.throwIfAborted();
const { width, height } = weatherPixelSize(pixels);
if (width !== size.width || height !== size.height)
throw new Error('Invalid weather image dimensions');
const texture = createCanvas();
texture.width = width;
texture.height = height;
const context = texture.getContext('2d');
drawWeatherPixels(context, pixels);
if (product === 'clouds' || product === 'clouds-regional')
applyInfraredTransfer(context, texture, mode);
return texture;
} finally {
closeWeatherPixels(pixels);
}
}
/** Decode locally, releasing the object URL on success, failure or cancellation. */
export function decodeInfraredImage(blob, signal) {
signal.throwIfAborted();
if (typeof globalThis.createImageBitmap === 'function')
return globalThis.createImageBitmap(blob);
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(blob);
const cleanup = () => {
signal.removeEventListener('abort', abort);
image.onload = image.onerror = null;
URL.revokeObjectURL(url);
};
const abort = () => {
cleanup();
image.src = '';
reject(signal.reason);
};
image.onload = () => {
cleanup();
resolve(image);
};
image.onerror = () => {
cleanup();
reject(new Error(msg('Infrared decode failed')));
};
signal.addEventListener('abort', abort, { once: true });
image.src = url;
});
}
/** Fetch one capped whole-extent frame, or a `bbox` detail window of it, and draw
* it into a canvas. Infrared frames get the display transfer; a canvas also keeps
* Cesium's texture row order, which an ImageBitmap upload would not. The decoded
* image is always released. With a `frameSource` that has `getImage`, the host
* supplies the decoded pixels instead and nothing is fetched or decoded here. */
export async function acquireWeatherImage(
product,
time,
{
signal,
mode,
size,
bbox = null,
maxBytes,
createCanvas,
fetchImpl,
decodeImage = decodeInfraredImage,
now = () => performance.now(),
onFetched = () => {},
frameSource = null,
},
) {
if (frameSource?.getImage) {
signal.throwIfAborted();
const started = now();
const pixels = await frameSource.getImage({
product,
time,
size,
bbox,
signal,
});
onFetched();
const texture = drawSuppliedImage(
pixels,
product,
mode,
size,
signal,
createCanvas,
);
return { texture, decodeMs: now() - started };
}
const response = await fetchImpl(weatherImageUrl(product, time, size, bbox), {
signal,
});
if (!response.ok) throw new Error(`Weather HTTP ${response.status}`);
const bytes = await readResponseBytesCapped(response, maxBytes);
signal.throwIfAborted();
onFetched();
const started = now();
const image = await decodeImage(
new Blob([bytes], { type: 'image/png' }),
signal,
);
try {
signal.throwIfAborted();
if (image.width !== size.width || image.height !== size.height)
throw new Error('Invalid weather image dimensions');
let texture;
if (product === 'clouds' || product === 'clouds-regional')
texture = processInfraredImage(image, mode, createCanvas);
else {
texture = createCanvas();
texture.width = image.width;
texture.height = image.height;
texture.getContext('2d').drawImage(image, 0, 0);
}
return { texture, decodeMs: now() - started };
} finally {
image.close?.();
}
}
/** Acquire one capped global mosaic before adding any imagery layer. */
export function acquireInfraredMosaic(time, options) {
return acquireWeatherImage('clouds', time, {
...options,
size: { width: 2048, height: 1024 },
maxBytes: MAX_MOSAIC_BYTES,
});
}