diff --git a/packages/deck.gl-geotiff/package.json b/packages/deck.gl-geotiff/package.json index 3864302b..757a8eb4 100644 --- a/packages/deck.gl-geotiff/package.json +++ b/packages/deck.gl-geotiff/package.json @@ -48,7 +48,7 @@ "dependencies": { "@developmentseed/deck.gl-raster": "workspace:^", "@developmentseed/raster-reproject": "workspace:^", - "geotiff": "2.1.3", + "geotiff": "2.1.4-beta.1", "proj4": "^2.20.2" }, "peerDependencies": { diff --git a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts index f3650eca..f3e98abc 100644 --- a/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts +++ b/packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts @@ -5,13 +5,14 @@ import { CreateTexture, cieLabToRGB, FilterNoDataVal, + RescaleSnormToUnorm, YCbCrToRGB, } from "@developmentseed/deck.gl-raster/gpu-modules"; import type { Device, SamplerProps, Texture } from "@luma.gl/core"; import type { GeoTIFFImage, TypedArrayWithDimensions } from "geotiff"; import type { COGLayerProps, GetTileDataOptions } from "../cog-layer"; import { addAlphaChannel, parseColormap, parseGDALNoData } from "./geotiff"; -import { inferTextureFormat } from "./texture"; +import { inferTextureFormat, verifyIdenticalBitsPerSample } from "./texture"; import type { ImageFileDirectory } from "./types"; import { PhotometricInterpretationT } from "./types"; @@ -54,6 +55,9 @@ export function inferRenderPipeline( // Unsigned integers case 1: return createUnormPipeline(ifd, device); + // Signed integers + case 2: + return createSignedIntegerPipeline(ifd, device); } throw new Error( @@ -92,8 +96,117 @@ function createUnormPipeline( // Add NoData filtering if GDAL_NODATA is defined const noDataVal = parseGDALNoData(GDAL_NODATA); if (noDataVal !== null) { - // Since values are 0-1 for unorm textures, - const noDataScaled = noDataVal / 255.0; + const numBits = verifyIdenticalBitsPerSample(BitsPerSample); + const noDataScaled = transformUnsignedIntegerNodataValue( + noDataVal, + numBits, + ); + renderPipeline.push({ + module: FilterNoDataVal, + props: { value: noDataScaled }, + }); + } + + const toRGBModule = photometricInterpretationToRGB( + PhotometricInterpretation, + device, + ColorMap, + ); + if (toRGBModule) { + renderPipeline.push(toRGBModule); + } + + // For palette images, use nearest-neighbor sampling + const samplerOptions: SamplerProps = + PhotometricInterpretation === PhotometricInterpretationT.Palette + ? { + magFilter: "nearest", + minFilter: "nearest", + } + : { + magFilter: "linear", + minFilter: "linear", + }; + + const getTileData: COGLayerProps["getTileData"] = async ( + image: GeoTIFFImage, + options: GetTileDataOptions, + ) => { + const { device } = options; + const mergedOptions = { + ...options, + interleave: true, + }; + + let data: TypedArrayWithDimensions | ImageData = (await image.readRasters( + mergedOptions, + )) as TypedArrayWithDimensions; + let numSamples = SamplesPerPixel; + + if (SamplesPerPixel === 3) { + // WebGL2 doesn't have an RGB-only texture format; it requires RGBA. + data = addAlphaChannel(data); + numSamples = 4; + } + + const textureFormat = inferTextureFormat( + // Add one sample for added alpha channel + numSamples, + BitsPerSample, + SampleFormat, + ); + const texture = device.createTexture({ + data, + format: textureFormat, + width: data.width, + height: data.height, + sampler: samplerOptions, + }); + + return { + texture, + height: data.height, + width: data.width, + }; + }; + const renderTile: COGLayerProps["renderTile"] = ( + tileData: TextureDataT, + ): RasterModule[] => { + return renderPipeline.map((m, _i) => resolveModule(m, tileData)); + }; + + return { getTileData, renderTile }; +} + +function createSignedIntegerPipeline( + ifd: ImageFileDirectory, + device: Device, +): { + getTileData: COGLayerProps["getTileData"]; + renderTile: COGLayerProps["renderTile"]; +} { + const { + BitsPerSample, + ColorMap, + GDAL_NODATA, + PhotometricInterpretation, + SampleFormat, + SamplesPerPixel, + } = ifd; + + const renderPipeline: UnresolvedRasterModule[] = [ + { + module: CreateTexture, + props: { + textureName: (data: TextureDataT) => data.texture, + }, + }, + ]; + + const noDataVal = parseGDALNoData(GDAL_NODATA); + if (noDataVal !== null) { + const numBits = verifyIdenticalBitsPerSample(BitsPerSample); + const noDataScaled = transformSignedIntegerNodataValue(noDataVal, numBits); renderPipeline.push({ module: FilterNoDataVal, @@ -101,6 +214,11 @@ function createUnormPipeline( }); } + // Rescale -1 to 1 to 0 to 1 + renderPipeline.push({ + module: RescaleSnormToUnorm, + }); + const toRGBModule = photometricInterpretationToRGB( PhotometricInterpretation, device, @@ -172,6 +290,48 @@ function createUnormPipeline( return { getTileData, renderTile }; } +/** + * Rescale nodata values by the maximum possible value for the given bit depth. + * + * This is because we use unorm textures, where integer values are mapped to + * 0-1. + */ +function transformUnsignedIntegerNodataValue( + nodata: number, + bits: number, +): number { + const max = (1 << bits) - 1; + return nodata / max; +} + +/** + * Rescale signed integer nodata values by the maximum possible value for the + * given bit depth. + * + * According to ChatGPT: + * + * SNORM uses a symmetric divisor, not asymmetric scaling, because: + * - GPUs want a simple, vectorizable conversion + * - The integer range is asymmetric, but the float range is symmetric + * - The extra negative value (-128) is treated as a sentinel that also maps to -1.0 + * + * This is why we only divide by the positive max value. + */ +function transformSignedIntegerNodataValue( + nodata: number, + bits: number, +): number { + const max = (1 << (bits - 1)) - 1; + const min = -(1 << (bits - 1)); + + if (nodata === min) { + // SNORM special case: minimum maps exactly to -1.0 + return -1.0; + } + + return nodata / max; +} + function photometricInterpretationToRGB( PhotometricInterpretation: number, device: Device, diff --git a/packages/deck.gl-geotiff/src/geotiff/texture.ts b/packages/deck.gl-geotiff/src/geotiff/texture.ts index 2c881c35..21aa6f5d 100644 --- a/packages/deck.gl-geotiff/src/geotiff/texture.ts +++ b/packages/deck.gl-geotiff/src/geotiff/texture.ts @@ -70,7 +70,9 @@ function verifySamplesPerPixel(samplesPerPixel: number): ChannelCount { ); } -function verifyIdenticalBitsPerSample(bitsPerSample: Uint16Array): BitWidth { +export function verifyIdenticalBitsPerSample( + bitsPerSample: Uint16Array, +): BitWidth { // bitsPerSamples is non-empty const first = bitsPerSample[0]!; diff --git a/packages/deck.gl-raster/src/gpu-modules/index.ts b/packages/deck.gl-raster/src/gpu-modules/index.ts index 43958dc6..d0413dfd 100644 --- a/packages/deck.gl-raster/src/gpu-modules/index.ts +++ b/packages/deck.gl-raster/src/gpu-modules/index.ts @@ -2,4 +2,5 @@ export { CMYKToRGB, cieLabToRGB, YCbCrToRGB } from "./color"; export { Colormap } from "./colormap"; export { CreateTexture } from "./create-texture"; export { FilterNoDataVal } from "./filter-nodata"; +export { RescaleSnormToUnorm } from "./rescale-snorm-to-unorm"; export type { RasterModule } from "./types"; diff --git a/packages/deck.gl-raster/src/gpu-modules/rescale-snorm-to-unorm.ts b/packages/deck.gl-raster/src/gpu-modules/rescale-snorm-to-unorm.ts new file mode 100644 index 00000000..b5958f53 --- /dev/null +++ b/packages/deck.gl-raster/src/gpu-modules/rescale-snorm-to-unorm.ts @@ -0,0 +1,21 @@ +import type { ShaderModule } from "@luma.gl/shadertools"; + +const shader = /* glsl */ ` + vec4 rescaleSnormToUnorm(vec4 value) { + return (value + 1.0) / 2.0; + } +`; + +/** + * A shader module that injects a unorm texture and uses a sampler2D to assign + * to a color. + */ +export const RescaleSnormToUnorm = { + name: "rescale-snorm-to-unorm", + inject: { + "fs:#decl": shader, + "fs:DECKGL_FILTER_COLOR": /* glsl */ ` + color = rescaleSnormToUnorm(color); + `, + }, +} as const satisfies ShaderModule; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ffb56bc4..575248ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -187,8 +187,8 @@ importers: specifier: 9.2.5 version: 9.2.5 geotiff: - specifier: 2.1.3 - version: 2.1.3 + specifier: 2.1.4-beta.1 + version: 2.1.4-beta.1 proj4: specifier: ^2.20.2 version: 2.20.2 @@ -1458,6 +1458,10 @@ packages: resolution: {integrity: sha512-PT6uoF5a1+kbC3tHmZSUsLHBp2QJlHasxxxxPW47QIY1VBKpFB+FcDvX+MxER6UzgLQZ0xDzJ9s48B9JbOCTqA==} engines: {node: '>=10.19'} + geotiff@2.1.4-beta.1: + resolution: {integrity: sha512-3cW7V2XjnBWDJ8Gj/vCidvl3xiaN5c3az1Tfym9MBFq0LI3pi37YYWt9YxOjXF7njO135iKRGTJcbbsTiPdk2Q==} + engines: {node: '>=10.19'} + get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} @@ -2255,6 +2259,9 @@ packages: zstddec@0.1.0: resolution: {integrity: sha512-w2NTI8+3l3eeltKAdK8QpiLo/flRAr2p8AGeakfMZOXBxOg9HIu4LVDxBi81sYgVhFhdJjv1OrB5ssI8uFPoLg==} + zstddec@0.2.0: + resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==} + snapshots: '@acemir/cssom@0.9.30': {} @@ -3473,6 +3480,17 @@ snapshots: xml-utils: 1.10.2 zstddec: 0.1.0 + geotiff@2.1.4-beta.1: + dependencies: + '@petamoriken/float16': 3.9.3 + lerc: 3.0.0 + pako: 2.1.0 + parse-headers: 2.0.6 + quick-lru: 6.1.2 + web-worker: 1.5.0 + xml-utils: 1.10.2 + zstddec: 0.2.0 + get-stream@6.0.1: {} get-value@2.0.6: {} @@ -4213,3 +4231,5 @@ snapshots: optional: true zstddec@0.1.0: {} + + zstddec@0.2.0: {}