diff --git a/examples/vermont-cog-comparison/src/tile-loaders.ts b/examples/vermont-cog-comparison/src/tile-loaders.ts index dccf3b88..b0073d6d 100644 --- a/examples/vermont-cog-comparison/src/tile-loaders.ts +++ b/examples/vermont-cog-comparison/src/tile-loaders.ts @@ -16,10 +16,10 @@ export type TileTextureData = { /** * Tile loader for 3- or 4-band pixel-interleaved COGs. * - * Fetches the source tile, asserts pixel-interleaved layout, expands - * 3-band data to RGBA via `addAlphaChannel` (WebGL2 has no rgb-only 8-bit - * texture format), and uploads as `rgba8unorm`. For 4-band data the - * source-side alpha (NIR) is overridden by the shader pipeline (see + * Fetches the source tile and expands 3-band data to RGBA via + * `addAlphaChannel` (WebGL2 has no rgb-only 8-bit texture format), which also + * interleaves a band-separate tile, then uploads as `rgba8unorm`. For 4-band + * data the source-side alpha (NIR) is overridden by the shader pipeline (see * `SetAlpha1`). */ export async function getTileDataRGBA( @@ -28,11 +28,7 @@ export async function getTileDataRGBA( ): Promise { const { device, x, y, signal } = options; const tile = await image.fetchTile(x, y, { signal, boundless: false }); - const array = addAlphaChannel(tile.array); - if (array.layout === "band-separate") { - throw new Error("Vermont COGs are expected to be pixel-interleaved"); - } - const { width, height, data } = array; + const { width, height, data } = addAlphaChannel(tile.array); const texture = device.createTexture({ data, format: "rgba8unorm", diff --git a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts index 1f128373..d4d610fd 100644 --- a/packages/deck.gl-geotiff/src/geotiff/geotiff.ts +++ b/packages/deck.gl-geotiff/src/geotiff/geotiff.ts @@ -4,25 +4,25 @@ import type { ConcurrencyLimiter, Priority, RasterArray, + RasterArrayPixelInterleaved, } from "@developmentseed/geotiff"; -import { GeoTIFF } from "@developmentseed/geotiff"; +import { GeoTIFF, toPixelInterleaved } from "@developmentseed/geotiff"; import type { Converter } from "proj4"; /** * Add an alpha channel to an RGB image array. * * Only supports input arrays with 3 (RGB) or 4 (RGBA) channels. If the input is - * already RGBA, it is returned unchanged. + * already RGBA, it is returned unchanged. Band-separate input is interleaved + * first, so the result is always pixel-interleaved. */ -export function addAlphaChannel(rgbImage: RasterArray): RasterArray { +export function addAlphaChannel( + rgbImage: RasterArray, +): RasterArrayPixelInterleaved { const { height, width } = rgbImage; if (rgbImage.layout === "band-separate") { - // This should be pretty easy to do by just returning an additional array of - // 255s - // But not sure if we'll want to do that, because it's fine to upload 3 - // separate textures. - throw new Error("Band-separate images not yet implemented."); + return addAlphaChannel(toPixelInterleaved(rgbImage)); } if (rgbImage.data.length === height * width * 4) { diff --git a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts index c6fcea0c..f7f19939 100644 --- a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts +++ b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts @@ -12,7 +12,7 @@ import { WhiteIsZero, } from "@developmentseed/deck.gl-raster/gpu-modules"; import type { GeoTIFF, Overview } from "@developmentseed/geotiff"; -import { parseColormap } from "@developmentseed/geotiff"; +import { parseColormap, toPixelInterleaved } from "@developmentseed/geotiff"; import type { Device, SamplerProps, Texture } from "@luma.gl/core"; import type { GetTileDataOptions } from "../cog-layer.js"; import { addAlphaChannel } from "./geotiff.js"; @@ -161,16 +161,22 @@ function createUnormPipeline( let numSamples = samplesPerPixel; + // A PlanarConfiguration=2 image decodes to one array per band, which can't be + // uploaded as a single texture. Interleave it so the rest of this function - + // and the shader pipeline built above - sees the same thing it would for a + // pixel-interleaved image. Uploading one texture per band and sampling a + // sampler2DArray would avoid this copy, but needs the render pipeline to know + // how many textures a tile has; see #159. + if (array.layout === "band-separate") { + array = toPixelInterleaved(array); + } + if (samplesPerPixel === 3) { // WebGL2 doesn't have an RGB-only texture format; it requires RGBA. array = addAlphaChannel(array); numSamples = 4; } - if (array.layout === "band-separate") { - throw new Error("Band-separate images not yet implemented."); - } - const textureFormat = inferTextureFormat( // Add one sample for added alpha channel numSamples, diff --git a/packages/deck.gl-geotiff/tests/geotiff.test.ts b/packages/deck.gl-geotiff/tests/geotiff.test.ts new file mode 100644 index 00000000..539feaf7 --- /dev/null +++ b/packages/deck.gl-geotiff/tests/geotiff.test.ts @@ -0,0 +1,70 @@ +import type { + RasterArray, + RasterArrayBandSeparate, +} from "@developmentseed/geotiff"; +import { describe, expect, it } from "vitest"; +import { addAlphaChannel } from "../src/geotiff/geotiff.js"; + +/** A band-separate raster holding one array per band, two pixels wide. */ +function bandSeparate(bands: number[][]): RasterArrayBandSeparate { + return { + layout: "band-separate", + count: bands.length, + width: bands[0]!.length, + height: 1, + mask: null, + bands: bands.map((band) => new Uint8Array(band)), + } as RasterArrayBandSeparate; +} + +describe("addAlphaChannel", () => { + it("pads a pixel-interleaved RGB array", () => { + const rgb = { + layout: "pixel-interleaved", + count: 3, + width: 2, + height: 1, + mask: null, + data: new Uint8Array([1, 2, 3, 4, 5, 6]), + } as unknown as RasterArray; + + const rgba = addAlphaChannel(rgb); + + expect(rgba.count).toEqual(4); + expect(Array.from((rgba as any).data)).toEqual([ + 1, 2, 3, 255, 4, 5, 6, 255, + ]); + }); + + it("interleaves a band-separate RGB array before padding it", () => { + const rgba = addAlphaChannel( + bandSeparate([ + [1, 4], + [2, 5], + [3, 6], + ]), + ); + + expect(rgba.layout).toEqual("pixel-interleaved"); + expect(rgba.count).toEqual(4); + expect(Array.from((rgba as any).data)).toEqual([ + 1, 2, 3, 255, 4, 5, 6, 255, + ]); + }); + + // Already four channels, so there is nothing to pad - only to interleave + it("interleaves a band-separate RGBA array unchanged", () => { + const rgba = addAlphaChannel( + bandSeparate([ + [1, 5], + [2, 6], + [3, 7], + [0, 255], + ]), + ); + + expect(rgba.layout).toEqual("pixel-interleaved"); + expect(rgba.count).toEqual(4); + expect(Array.from((rgba as any).data)).toEqual([1, 2, 3, 0, 5, 6, 7, 255]); + }); +}); diff --git a/packages/deck.gl-geotiff/tests/render-pipeline.test.ts b/packages/deck.gl-geotiff/tests/render-pipeline.test.ts index 7c48b8ab..28fd5745 100644 --- a/packages/deck.gl-geotiff/tests/render-pipeline.test.ts +++ b/packages/deck.gl-geotiff/tests/render-pipeline.test.ts @@ -53,3 +53,69 @@ describe("RGB with mask", async () => { expect(renderPipeline[1]?.module.name).toEqual("mask-texture"); }); }); + +describe("band-separate tiles", () => { + /** + * A PlanarConfiguration=2 tile, as `fetchTile` decodes one: a separate array + * per band. Handed to `getTileData` directly rather than read from a fixture, + * because the only band-separate fixture is int8, which + * `inferRenderPipeline` does not build a pipeline for. + */ + function bandSeparateTile(bandCount: number) { + return { + array: { + layout: "band-separate" as const, + count: bandCount, + width: 2, + height: 1, + mask: null, + // Band b holds [b, b + bandCount], so an interleaved result reads + // 0, 1, .., bandCount - 1, bandCount, .. + bands: Array.from( + { length: bandCount }, + (_, b) => new Uint8Array([b, b + bandCount]), + ), + }, + }; + } + + async function _getTileData(geotiff: GeoTIFF, bandCount: number) { + const { getTileData } = inferRenderPipeline(geotiff, MOCK_DEVICE as any); + const image = { fetchTile: async () => bandSeparateTile(bandCount) }; + return await getTileData( + image as any, + { + device: MOCK_DEVICE, + x: 0, + y: 0, + } as any, + ); + } + + it("interleaves an RGBA tile into a single texture", async () => { + const geotiff = await loadGeoTIFF("cog_uint8_rgba", "rasterio"); + + const { texture, width, height } = await _getTileData(geotiff, 4); + + expect((texture as any).format).toEqual("rgba8unorm"); + expect(Array.from((texture as any).data)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]); + expect(width).toEqual(2); + expect(height).toEqual(1); + }); + + // WebGL2 has no RGB-only texture format, so a 3-band tile is interleaved and + // then padded to RGBA, same as a pixel-interleaved one + it("interleaves and pads an RGB tile", async () => { + const geotiff = await loadGeoTIFF( + "uint8_rgb_deflate_block64_cog", + "rasterio", + ); + + const { texture } = await _getTileData(geotiff, 3); + + expect((texture as any).format).toEqual("rgba8unorm"); + expect(Array.from((texture as any).data)).toEqual([ + 0, 1, 2, 255, 3, 4, 5, 255, + ]); + }); +}); diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index 8f2b7a1f..43418068 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -1,10 +1,17 @@ export type { + PackBandsToRGBAOptions, RasterArray, RasterArrayBandSeparate, RasterArrayBase, RasterArrayPixelInterleaved, RasterTypedArray, } from "./array.js"; +export { + packBandsToRGBA, + reorderBands, + toBandSeparate, + toPixelInterleaved, +} from "./array.js"; export type { AssembleTilesOptions } from "./assemble.js"; export { assembleTiles } from "./assemble.js"; export { parseColormap } from "./colormap.js";