Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/deck.gl-geotiff/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
166 changes: 163 additions & 3 deletions packages/deck.gl-geotiff/src/geotiff/render-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -92,15 +96,129 @@ 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<TextureDataT>["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<TextureDataT>["renderTile"] = (
tileData: TextureDataT,
): RasterModule[] => {
return renderPipeline.map((m, _i) => resolveModule(m, tileData));
};

return { getTileData, renderTile };
}

function createSignedIntegerPipeline(
ifd: ImageFileDirectory,
device: Device,
): {
getTileData: COGLayerProps<TextureDataT>["getTileData"];
renderTile: COGLayerProps<TextureDataT>["renderTile"];
} {
const {
BitsPerSample,
ColorMap,
GDAL_NODATA,
PhotometricInterpretation,
SampleFormat,
SamplesPerPixel,
} = ifd;

const renderPipeline: UnresolvedRasterModule<TextureDataT>[] = [
{
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,
props: { value: noDataScaled },
});
}

// Rescale -1 to 1 to 0 to 1
renderPipeline.push({
module: RescaleSnormToUnorm,
});

const toRGBModule = photometricInterpretationToRGB(
PhotometricInterpretation,
device,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion packages/deck.gl-geotiff/src/geotiff/texture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]!;

Expand Down
1 change: 1 addition & 0 deletions packages/deck.gl-raster/src/gpu-modules/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
21 changes: 21 additions & 0 deletions packages/deck.gl-raster/src/gpu-modules/rescale-snorm-to-unorm.ts
Original file line number Diff line number Diff line change
@@ -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;
24 changes: 22 additions & 2 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.