From 60fb0590b82d467e10a7a88d33767497d2ad4939 Mon Sep 17 00:00:00 2001 From: Kyle Barron Date: Wed, 4 Mar 2026 16:48:20 -0500 Subject: [PATCH] feat: Return ImageBitmap in canvas decoding --- .../src/geotiff/render-pipeline.ts | 8 +++ packages/geotiff/src/array.ts | 41 ++++++------- packages/geotiff/src/codecs/canvas.ts | 39 ++----------- packages/geotiff/src/decode.ts | 58 ++++++++++++++++++- packages/geotiff/src/fetch.ts | 23 +++++++- packages/geotiff/src/pool/wrapper.ts | 5 ++ 6 files changed, 116 insertions(+), 58 deletions(-) diff --git a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts index ddb67a2b..de29a3ea 100644 --- a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts +++ b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts @@ -146,6 +146,14 @@ function createUnormPipeline( throw new Error("Band-separate images not yet implemented."); } + if (array.layout === "image-bitmap") { + return { + texture: array.data, + height: array.height, + width: array.width, + }; + } + const textureFormat = inferTextureFormat( // Add one sample for added alpha channel numSamples, diff --git a/packages/geotiff/src/array.ts b/packages/geotiff/src/array.ts index c7f0e573..0f9d8928 100644 --- a/packages/geotiff/src/array.ts +++ b/packages/geotiff/src/array.ts @@ -1,5 +1,10 @@ import type { Affine } from "@developmentseed/affine"; import type { ProjJson } from "./crs.js"; +import type { + DecodedBandInterleaved, + DecodedPixelInterleaved, + DecodedPixels, +} from "./decode.js"; /** Typed arrays supported for raster sample storage. */ export type RasterTypedArray = @@ -44,30 +49,13 @@ type RasterArrayBase = { }; /** Raster stored in one typed array per band (band-major / planar). */ -export type BandRasterArray = RasterArrayBase & { - layout: "band-separate"; - /** - * One typed array per band, each length = width * height. - * - * This is the preferred representation when uploading one texture per band. - */ - bands: RasterTypedArray[]; -}; +export type BandRasterArray = RasterArrayBase & DecodedBandInterleaved; /** Raster stored in one pixel-interleaved typed array. */ -export type PixelRasterArray = RasterArrayBase & { - layout: "pixel-interleaved"; - /** - * Pixel-interleaved raster data: - * [p00_band0, p00_band1, ..., p01_band0, ...] - * - * Length = width * height * count. - */ - data: RasterTypedArray; -}; +export type PixelRasterArray = RasterArrayBase & DecodedPixelInterleaved; /** Decoded raster data from a GeoTIFF region. */ -export type RasterArray = BandRasterArray | PixelRasterArray; +export type RasterArray = RasterArrayBase & DecodedPixels; /** Options for packing band data to a 4-channel pixel-interleaved array. */ export type PackBandsToRGBAOptions = { @@ -87,6 +75,10 @@ export function toBandSeparate(array: RasterArray): BandRasterArray { return array; } + if (array.layout === "image-bitmap") { + throw new Error("Not implemented; should probably remove this helper fn"); + } + const sampleCount = array.width * array.height; const bands: RasterTypedArray[] = new Array(array.count); const Ctor = array.data.constructor as new ( @@ -128,6 +120,10 @@ export function toPixelInterleaved( return array; } + if (array.layout === "image-bitmap") { + throw new Error("Not implemented; should probably remove this helper fn"); + } + const Ctor = ( array.layout === "pixel-interleaved" ? array.data.constructor @@ -257,6 +253,11 @@ function validateRasterShape(array: RasterArray): void { return; } + if (array.layout === "image-bitmap") { + // Validated in ImageBitmap construction + return; + } + const expectedDataLength = sampleCount * array.count; if (array.data.length !== expectedDataLength) { throw new Error( diff --git a/packages/geotiff/src/codecs/canvas.ts b/packages/geotiff/src/codecs/canvas.ts index 69d1b9b0..a90aae99 100644 --- a/packages/geotiff/src/codecs/canvas.ts +++ b/packages/geotiff/src/codecs/canvas.ts @@ -1,41 +1,14 @@ import type { DecodedPixels, DecoderMetadata } from "../decode.js"; -// TODO: in the future, have an API that returns an ImageBitmap directly from -// the decoder, to avoid copying pixel data from GPU -> CPU memory -// Then deck.gl could use the ImageBitmap directly as a texture source without -// copying again from CPU -> GPU memory -// https://github.com/developmentseed/deck.gl-raster/issues/228 export async function decode( bytes: ArrayBuffer, metadata: DecoderMetadata, ): Promise { const blob = new Blob([bytes]); - const imageBitmap = await createImageBitmap(blob); - - const canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); - const ctx = canvas.getContext("2d")!; - ctx.drawImage(imageBitmap, 0, 0); - imageBitmap.close(); - - const { width, height } = canvas; - const imageData = ctx.getImageData(0, 0, width, height); - const rgba = imageData.data; - - const samplesPerPixel = metadata.samplesPerPixel; - if (samplesPerPixel === 4) { - return { layout: "pixel-interleaved", data: rgba }; - } - - if (samplesPerPixel === 3) { - const pixelCount = width * height; - const rgb = new Uint8ClampedArray(pixelCount * 3); - for (let i = 0, j = 0; i < rgb.length; i += 3, j += 4) { - rgb[i] = rgba[j]!; - rgb[i + 1] = rgba[j + 1]!; - rgb[i + 2] = rgba[j + 2]!; - } - return { layout: "pixel-interleaved", data: rgb }; - } - - throw new Error(`Unsupported SamplesPerPixel for JPEG: ${samplesPerPixel}`); + const { clippedWidth, clippedHeight, width, height } = metadata; + const needsClip = clippedWidth !== width || clippedHeight !== height; + const imageBitmap = needsClip + ? await createImageBitmap(blob, 0, 0, clippedWidth, clippedHeight) + : await createImageBitmap(blob); + return { layout: "image-bitmap", data: imageBitmap }; } diff --git a/packages/geotiff/src/decode.ts b/packages/geotiff/src/decode.ts index 8420e307..8ab2be8a 100644 --- a/packages/geotiff/src/decode.ts +++ b/packages/geotiff/src/decode.ts @@ -4,18 +4,72 @@ import type { RasterTypedArray } from "./array.js"; import { decode as decodeViaCanvas } from "./codecs/canvas.js"; import { applyPredictor } from "./codecs/predictor.js"; +/** Raster data stored in an ImageBitmap. + * + * This is a common result type for image codecs like JPEG or WebP that are + * decoded via a canvas. + */ +export type DecodedImageBitmap = { + layout: "image-bitmap"; + /** A pixel-interleaved ImageBitmap. */ + data: ImageBitmap; +}; + +/** Raster stored in one pixel-interleaved typed array. */ +export type DecodedPixelInterleaved = { + layout: "pixel-interleaved"; + /** + * Pixel-interleaved raster data: + * [p00_band0, p00_band1, ..., p01_band0, ...] + * + * Length = width * height * count. + */ + data: RasterTypedArray; +}; + +/** Raster stored in one typed array per band (band-major / planar). */ +export type DecodedBandInterleaved = { + layout: "band-separate"; + /** + * One typed array per band, each length = width * height. + * + * This is the preferred representation when uploading one texture per band. + */ + bands: RasterTypedArray[]; +}; + /** The result of a decoding process */ export type DecodedPixels = - | { layout: "pixel-interleaved"; data: RasterTypedArray } - | { layout: "band-separate"; bands: RasterTypedArray[] }; + | DecodedImageBitmap + | DecodedPixelInterleaved + | DecodedBandInterleaved; /** Metadata from the TIFF IFD, passed to decoders that need it. */ export type DecoderMetadata = { sampleFormat: SampleFormat; bitsPerSample: number; samplesPerPixel: number; + /** Full encoded tile width in pixels. */ width: number; + /** Full encoded tile height in pixels. */ height: number; + /** + * Clipped width for edge tiles when boundless=false. + * + * Equals `width` for interior tiles. + * + * Image-bitmap decoders use this to produce a pre-clipped bitmap. + */ + clippedWidth: number; + /** + * + * Clipped height for edge tiles when boundless=false. + * + * Equals `height` for interior tiles. + * + * Image-bitmap decoders use this to produce a pre-clipped bitmap. + */ + clippedHeight: number; predictor: Predictor; planarConfiguration: PlanarConfiguration; }; diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 0edbed5c..f24c9509 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -73,22 +73,33 @@ export async function fetchTile( const samplesPerPixel = self.image.value(TiffTag.SamplesPerPixel) ?? 1; + const { width: clippedWidth, height: clippedHeight } = + self.image.getTileBounds(x, y); + const clip = + boundless === false && + (clippedWidth !== self.tileWidth || clippedHeight !== self.tileHeight); + const decoderMetadata = { sampleFormat, bitsPerSample, samplesPerPixel, width: self.tileWidth, height: self.tileHeight, + clippedWidth: clip ? clippedWidth : self.tileWidth, + clippedHeight: clip ? clippedHeight : self.tileHeight, predictor, planarConfiguration, }; const decodedPixels = await decodeTile(tile, decoderMetadata, pool); + const outWidth = clip ? clippedWidth : self.tileWidth; + const outHeight = clip ? clippedHeight : self.tileHeight; + const array: RasterArray = { ...decodedPixels, count: samplesPerPixel, - height: self.tileHeight, - width: self.tileWidth, + height: outHeight, + width: outWidth, mask: null, transform: tileTransform, crs: self.crs, @@ -98,7 +109,7 @@ export async function fetchTile( return { x, y, - array: boundless === false ? clipToImageBounds(self, x, y, array) : array, + array: clip ? clipToImageBounds(self, x, y, array) : array, }; } @@ -324,6 +335,12 @@ function clipToImageBounds( return array; } + if (array.layout === "image-bitmap") { + // We pre-clip the bitmap during decoding in `canvas.ts`, so this should + // never happen + return array; + } + if (array.layout === "pixel-interleaved") { const { count, data } = array; const Ctor = data.constructor as new (n: number) => typeof data; diff --git a/packages/geotiff/src/pool/wrapper.ts b/packages/geotiff/src/pool/wrapper.ts index 993cb3ff..24360f15 100644 --- a/packages/geotiff/src/pool/wrapper.ts +++ b/packages/geotiff/src/pool/wrapper.ts @@ -25,9 +25,14 @@ export type WorkerErrorResponse = { /** Collect the transferable ArrayBuffers from a DecodedPixels. */ export function collectTransferables(pixels: DecodedPixels): Transferable[] { + if (pixels.layout === "image-bitmap") { + return [pixels.data]; + } + if (pixels.layout === "pixel-interleaved") { return [pixels.data.buffer]; } + return pixels.bands.map((b) => b.buffer); }