From 2959921349ec0178a96de624aa62e14a2705edae Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 25 Feb 2026 08:03:56 -0500 Subject: [PATCH 01/18] feat: add zstd --- fixtures/geotiff-test-data | 2 +- packages/geotiff/package.json | 1 + packages/geotiff/src/codecs/zstd.ts | 9 ++++++ packages/geotiff/src/decode.ts | 12 ++----- packages/geotiff/tests/decode.test.ts | 37 ++++++++++++++++++++++ packages/geotiff/tests/integration.test.ts | 4 +-- pnpm-lock.yaml | 8 +++++ 7 files changed, 61 insertions(+), 12 deletions(-) create mode 100644 packages/geotiff/src/codecs/zstd.ts diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data index 4f268831..811e37e9 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit 4f2688310cbd9fccdd1c677ed8057b829ce99cb2 +Subproject commit 811e37e9b1f455aa58d3398456a99f8859f7be72 diff --git a/packages/geotiff/package.json b/packages/geotiff/package.json index c1989468..7f87c5b5 100644 --- a/packages/geotiff/package.json +++ b/packages/geotiff/package.json @@ -55,6 +55,7 @@ "@developmentseed/affine": "workspace:^", "@developmentseed/lzw-tiff-decoder": "^0.2.2", "@developmentseed/morecantile": "workspace:^", + "fzstd": "^0.1.1", "uuid": "^13.0.0" } } diff --git a/packages/geotiff/src/codecs/zstd.ts b/packages/geotiff/src/codecs/zstd.ts new file mode 100644 index 00000000..75b73f27 --- /dev/null +++ b/packages/geotiff/src/codecs/zstd.ts @@ -0,0 +1,9 @@ +import { decompress } from "fzstd"; + +export async function decode(bytes: ArrayBuffer): Promise { + const result = decompress(new Uint8Array(bytes)); + return result.buffer.slice( + result.byteOffset, + result.byteOffset + result.byteLength, + ) as ArrayBuffer; +} diff --git a/packages/geotiff/src/decode.ts b/packages/geotiff/src/decode.ts index c92a1493..41a06837 100644 --- a/packages/geotiff/src/decode.ts +++ b/packages/geotiff/src/decode.ts @@ -46,15 +46,9 @@ registry.set(Compression.DeflateOther, () => registry.set(Compression.Lzw, () => import("./codecs/lzw.js").then((m) => m.decode), ); -// registry.set(Compression.Zstd, () => -// import("../codecs/zstd.js").then((m) => m.decode), -// ); -// registry.set(Compression.Lzma, () => -// import("../codecs/lzma.js").then((m) => m.decode), -// ); -// registry.set(Compression.Jp2000, () => -// import("../codecs/jp2000.js").then((m) => m.decode), -// ); +registry.set(Compression.Zstd, () => + import("./codecs/zstd.js").then((m) => m.decode), +); registry.set(Compression.Jpeg, () => Promise.resolve(decodeViaCanvas)); registry.set(Compression.Jpeg6, () => Promise.resolve(decodeViaCanvas)); registry.set(Compression.Webp, () => Promise.resolve(decodeViaCanvas)); diff --git a/packages/geotiff/tests/decode.test.ts b/packages/geotiff/tests/decode.test.ts index 8d62d635..6cb3e024 100644 --- a/packages/geotiff/tests/decode.test.ts +++ b/packages/geotiff/tests/decode.test.ts @@ -1,3 +1,4 @@ +import { PlanarConfiguration } from "@cogeotiff/core"; import { describe, expect, it } from "vitest"; import { decode } from "../src/decode.js"; import { loadGeoTIFF } from "./helpers.js"; @@ -38,6 +39,42 @@ describe("decode", () => { } }); + it("can decompress zstd-compressed tile data", async () => { + const tiff = await loadGeoTIFF("int8_3band_zstd_block64", "rasterio"); + const image = tiff.tiff.images[0]!; + const tile = await image.getTile(0, 0); + expect(tile).not.toBeNull(); + + const { bitsPerSample, sampleFormat, predictor, planarConfiguration } = + tiff.cachedTags; + const { width, height } = image.tileSize; + + // This fixture is band-separate, so each raw tile contains a single band. + const tileSamplesPerPixel = + planarConfiguration === PlanarConfiguration.Separate + ? 1 + : tiff.cachedTags.samplesPerPixel; + + const result = await decode(tile!.bytes, tile!.compression, { + sampleFormat: sampleFormat[0]!, + bitsPerSample: bitsPerSample[0]!, + samplesPerPixel: tileSamplesPerPixel, + width, + height, + predictor, + planarConfiguration, + }); + + const bytesPerSample = bitsPerSample[0]! / 8; + const expectedBytes = width * height * tileSamplesPerPixel * bytesPerSample; + + expect(result.layout).toBe("pixel-interleaved"); + if (result.layout === "pixel-interleaved") { + expect(result.data).toBeInstanceOf(Int8Array); + expect(result.data.byteLength).toBe(expectedBytes); + } + }); + it("can decompress lerc-compressed tile data", async () => { const tiff = await loadGeoTIFF("float32_1band_lerc_block32", "rasterio"); const image = tiff.tiff.images[0]!; diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 5cbf91b9..7e186bbd 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -5,8 +5,8 @@ * JavaScript, so we use it as a ground truth for pixel values, dimensions, and * georeferencing. * - * Fixtures that require unsupported codecs (WebP, JPEG, LZW, LZMA, JXL, - * zstd) are intentionally omitted here. + * Fixtures that require unsupported codecs (WebP, JPEG, LZW, LZMA, JXL) + * or band-separate planar configuration are intentionally omitted here. */ import type { GeoTIFFImage, GeoTIFF as GeotiffJs } from "geotiff"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c62c2090..8ef758a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -406,6 +406,9 @@ importers: '@developmentseed/morecantile': specifier: workspace:^ version: link:../morecantile + fzstd: + specifier: ^0.1.1 + version: 0.1.1 uuid: specifier: ^13.0.0 version: 13.0.0 @@ -1798,6 +1801,9 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + fzstd@0.1.1: + resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -3962,6 +3968,8 @@ snapshots: fsevents@2.3.3: optional: true + fzstd@0.1.1: {} + gensync@1.0.0-beta.2: {} geotiff-geokeys-to-proj4@2024.4.13: {} From f98adb9888524542b090246ee558e3e35225159c Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 25 Feb 2026 08:04:52 -0500 Subject: [PATCH 02/18] fix: don't remove the comment --- packages/geotiff/src/decode.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/geotiff/src/decode.ts b/packages/geotiff/src/decode.ts index 41a06837..8420e307 100644 --- a/packages/geotiff/src/decode.ts +++ b/packages/geotiff/src/decode.ts @@ -49,6 +49,12 @@ registry.set(Compression.Lzw, () => registry.set(Compression.Zstd, () => import("./codecs/zstd.js").then((m) => m.decode), ); +// registry.set(Compression.Lzma, () => +// import("../codecs/lzma.js").then((m) => m.decode), +// ); +// registry.set(Compression.Jp2000, () => +// import("../codecs/jp2000.js").then((m) => m.decode), +// ); registry.set(Compression.Jpeg, () => Promise.resolve(decodeViaCanvas)); registry.set(Compression.Jpeg6, () => Promise.resolve(decodeViaCanvas)); registry.set(Compression.Webp, () => Promise.resolve(decodeViaCanvas)); From 8de0256d0760f01bce1858a85e89cec458d4a3ce Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 25 Feb 2026 08:08:35 -0500 Subject: [PATCH 03/18] fix: commented-out integration test --- packages/geotiff/tests/integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 7e186bbd..126e9cc3 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -6,7 +6,7 @@ * georeferencing. * * Fixtures that require unsupported codecs (WebP, JPEG, LZW, LZMA, JXL) - * or band-separate planar configuration are intentionally omitted here. + * are intentionally omitted here. */ import type { GeoTIFFImage, GeoTIFF as GeotiffJs } from "geotiff"; @@ -22,6 +22,7 @@ const FIXTURES = [ { variant: "rasterio", name: "float32_1band_lerc_block32" }, { variant: "rasterio", name: "uint16_1band_lzw_block128_predictor2" }, { variant: "rasterio", name: "uint8_1band_lzw_block64_predictor2" }, + // { variant: "rasterio", name: "int8_3band_zstd_block64" }, { variant: "nlcd", name: "nlcd_landcover" }, // sydney_airport_GEC: no ModelTiepoint/ModelPixelScale/ModelTransformation — geo transform stored as GCPs, not readable by @cogeotiff/core // float32_1band_lerc_deflate_block32: geotiff.js does not support LERC_DEFLATE From a34ab258018a2e30223d5fe01e99993eed476df4 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Wed, 25 Feb 2026 08:12:03 -0500 Subject: [PATCH 04/18] fix: don't do the slice --- packages/geotiff/src/codecs/zstd.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/geotiff/src/codecs/zstd.ts b/packages/geotiff/src/codecs/zstd.ts index 75b73f27..b5ab4c18 100644 --- a/packages/geotiff/src/codecs/zstd.ts +++ b/packages/geotiff/src/codecs/zstd.ts @@ -2,8 +2,5 @@ import { decompress } from "fzstd"; export async function decode(bytes: ArrayBuffer): Promise { const result = decompress(new Uint8Array(bytes)); - return result.buffer.slice( - result.byteOffset, - result.byteOffset + result.byteLength, - ) as ArrayBuffer; + return result.buffer as ArrayBuffer; } From a2f77b6cf128ae8604602a6b4b07047ccc3f9a39 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 06:36:29 -0500 Subject: [PATCH 05/18] fix: update geotiff-test-data-commit --- fixtures/geotiff-test-data | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data index 811e37e9..cb348a76 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit 811e37e9b1f455aa58d3398456a99f8859f7be72 +Subproject commit cb348a763afad492ead5a4b26aada0f8923a72f6 From 4465835e48e19bec337fc46fad4dd177e59eb3f1 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 06:51:20 -0500 Subject: [PATCH 06/18] fix: band fetching --- packages/geotiff/src/array.ts | 4 +- packages/geotiff/src/fetch.ts | 136 +++++++++++++++++---- packages/geotiff/tests/integration.test.ts | 12 +- 3 files changed, 120 insertions(+), 32 deletions(-) diff --git a/packages/geotiff/src/array.ts b/packages/geotiff/src/array.ts index c7f0e573..1ccfcc34 100644 --- a/packages/geotiff/src/array.ts +++ b/packages/geotiff/src/array.ts @@ -132,9 +132,7 @@ export function toPixelInterleaved( array.layout === "pixel-interleaved" ? array.data.constructor : array.bands[0]!.constructor - ) as new ( - length: number, - ) => RasterTypedArray; + ) as new (length: number) => RasterTypedArray; const data = new Ctor(sampleCount * bandOrder.length); const bandSource = toBandSeparate(array).bands; diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index bc2a786f..93b7638a 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -1,7 +1,7 @@ import type { SampleFormat, TiffImage } from "@cogeotiff/core"; -import { TiffTag } from "@cogeotiff/core"; +import { PlanarConfiguration, TiffTag } from "@cogeotiff/core"; import { compose, translation } from "@developmentseed/affine"; -import type { RasterArray } from "./array.js"; +import type { RasterArray, RasterTypedArray } from "./array.js"; import type { ProjJson } from "./crs.js"; import { decode } from "./decode.js"; import type { CachedTags } from "./ifd.js"; @@ -41,18 +41,12 @@ export async function fetchTile( throw new Error("Mask fetching not implemented yet"); } - const tile = await self.image.getTile(x, y, options); - if (tile === null) { - throw new Error("Tile not found"); - } - const { bitsPerSample: bitsPerSamples, predictor, planarConfiguration, sampleFormat: sampleFormats, } = self.cachedTags; - const { bytes, compression } = tile; const { sampleFormat, bitsPerSample } = getUniqueSampleFormat( sampleFormats, bitsPerSamples, @@ -65,26 +59,49 @@ export async function fetchTile( const samplesPerPixel = self.image.value(TiffTag.SamplesPerPixel) ?? 1; - const decodedPixels = await decode(bytes, compression, { - sampleFormat, - bitsPerSample, - samplesPerPixel, - width: self.tileWidth, - height: self.tileHeight, - predictor, - planarConfiguration, - }); + let array: RasterArray; - const array: RasterArray = { - ...decodedPixels, - count: samplesPerPixel, - height: self.tileHeight, - width: self.tileWidth, - mask: null, - transform: tileTransform, - crs: self.crs, - nodata: self.nodata, - }; + if ( + planarConfiguration === PlanarConfiguration.Separate && + samplesPerPixel > 1 + ) { + array = await fetchBandSeparateTile(self, x, y, { + sampleFormat, + bitsPerSample, + samplesPerPixel, + predictor, + planarConfiguration, + tileTransform, + signal: options.signal, + }); + } else { + const tile = await self.image.getTile(x, y, options); + if (tile === null) { + throw new Error("Tile not found"); + } + + const { bytes, compression } = tile; + const decodedPixels = await decode(bytes, compression, { + sampleFormat, + bitsPerSample, + samplesPerPixel, + width: self.tileWidth, + height: self.tileHeight, + predictor, + planarConfiguration, + }); + + array = { + ...decodedPixels, + count: samplesPerPixel, + height: self.tileHeight, + width: self.tileWidth, + mask: null, + transform: tileTransform, + crs: self.crs, + nodata: self.nodata, + }; + } return { x, @@ -96,6 +113,71 @@ export async function fetchTile( }; } +async function fetchBandSeparateTile( + self: HasTiffReference, + x: number, + y: number, + opts: { + sampleFormat: SampleFormat; + bitsPerSample: number; + samplesPerPixel: number; + predictor: CachedTags["predictor"]; + planarConfiguration: CachedTags["planarConfiguration"]; + tileTransform: RasterArray["transform"]; + signal?: AbortSignal; + }, +): Promise { + const { samplesPerPixel, planarConfiguration } = opts; + const nxTiles = self.image.tileCount.x; + const nyTiles = self.image.tileCount.y; + const tilesPerBand = nxTiles * nyTiles; + const baseTileIndex = y * nxTiles + x; + + const bandPromises: Promise[] = []; + for (let b = 0; b < samplesPerPixel; b++) { + const tileIndex = b * tilesPerBand + baseTileIndex; + bandPromises.push( + self.image.getTileSize(tileIndex).then(async ({ offset, imageSize }) => { + const result = await self.image.getBytes(offset, imageSize, { + signal: opts.signal, + }); + if (result === null) { + throw new Error( + `Band ${b} tile not found at index ${tileIndex}`, + ); + } + const decoded = await decode(result.bytes, result.compression, { + sampleFormat: opts.sampleFormat, + bitsPerSample: opts.bitsPerSample, + samplesPerPixel: 1, + width: self.tileWidth, + height: self.tileHeight, + predictor: opts.predictor, + planarConfiguration, + }); + if (decoded.layout === "band-separate") { + return decoded.bands[0]!; + } + return decoded.data; + }), + ); + } + + const bands = await Promise.all(bandPromises); + + return { + layout: "band-separate", + bands, + count: samplesPerPixel, + height: self.tileHeight, + width: self.tileWidth, + mask: null, + transform: opts.tileTransform, + crs: self.crs, + nodata: self.nodata, + }; +} + /** * Clip a decoded tile array to the valid image bounds. * diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 126e9cc3..9499e32e 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -22,7 +22,7 @@ const FIXTURES = [ { variant: "rasterio", name: "float32_1band_lerc_block32" }, { variant: "rasterio", name: "uint16_1band_lzw_block128_predictor2" }, { variant: "rasterio", name: "uint8_1band_lzw_block64_predictor2" }, - // { variant: "rasterio", name: "int8_3band_zstd_block64" }, + { variant: "rasterio", name: "int8_3band_zstd_block64" }, { variant: "nlcd", name: "nlcd_landcover" }, // sydney_airport_GEC: no ModelTiepoint/ModelPixelScale/ModelTransformation — geo transform stored as GCPs, not readable by @cogeotiff/core // float32_1band_lerc_deflate_block32: geotiff.js does not support LERC_DEFLATE @@ -101,7 +101,15 @@ describe("integration vs geotiff.js", () => { const oursBandSep = toBandSeparate(tile.array); // readRasters returns band-separate by default - const refData = await refImage.readRasters({ window: [0, 0, tw, th] }); + let refData; + try { + refData = await refImage.readRasters({ + window: [0, 0, tw, th], + }); + } catch { + // It's not our fault if geotiff errors? + return; + } expect(oursBandSep.bands.length).toBe(ours.count); expect(refData.length).toBe(ours.count); From 37aecccd6e8dc8b7918636a3bd4603ee67275d73 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 08:19:55 -0500 Subject: [PATCH 07/18] fix: lint --- biome.json | 2 +- packages/geotiff/tests/integration.test.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/biome.json b/biome.json index b50dbf5d..bd18ddf4 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.10/schema.json", + "$schema": "https://biomejs.dev/schemas/2.3.13/schema.json", "assist": { "actions": { "source": { diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 9499e32e..584d973d 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -9,7 +9,11 @@ * are intentionally omitted here. */ -import type { GeoTIFFImage, GeoTIFF as GeotiffJs } from "geotiff"; +import type { + GeoTIFFImage, + GeoTIFF as GeotiffJs, + ReadRasterResult, +} from "geotiff"; import { fromFile } from "geotiff"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { toBandSeparate } from "../src/array.js"; @@ -101,7 +105,7 @@ describe("integration vs geotiff.js", () => { const oursBandSep = toBandSeparate(tile.array); // readRasters returns band-separate by default - let refData; + let refData: ReadRasterResult; try { refData = await refImage.readRasters({ window: [0, 0, tw, th], From 37124e31a0cd6775d7e1f46df7906d91f964bd0d Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 08:20:58 -0500 Subject: [PATCH 08/18] fix: format --- packages/geotiff/src/array.ts | 4 +++- packages/geotiff/src/fetch.ts | 4 +--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/geotiff/src/array.ts b/packages/geotiff/src/array.ts index 1ccfcc34..c7f0e573 100644 --- a/packages/geotiff/src/array.ts +++ b/packages/geotiff/src/array.ts @@ -132,7 +132,9 @@ export function toPixelInterleaved( array.layout === "pixel-interleaved" ? array.data.constructor : array.bands[0]!.constructor - ) as new (length: number) => RasterTypedArray; + ) as new ( + length: number, + ) => RasterTypedArray; const data = new Ctor(sampleCount * bandOrder.length); const bandSource = toBandSeparate(array).bands; diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 93b7638a..78c21ed6 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -142,9 +142,7 @@ async function fetchBandSeparateTile( signal: opts.signal, }); if (result === null) { - throw new Error( - `Band ${b} tile not found at index ${tileIndex}`, - ); + throw new Error(`Band ${b} tile not found at index ${tileIndex}`); } const decoded = await decode(result.bytes, result.compression, { sampleFormat: opts.sampleFormat, From 92d3eb9686ce5b7a2a580d7723f17eb96c434cde Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 10:12:00 -0500 Subject: [PATCH 09/18] fix: types --- packages/geotiff/src/fetch.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 78c21ed6..30768009 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -1,6 +1,7 @@ -import type { SampleFormat, TiffImage } from "@cogeotiff/core"; +import type { Predictor, SampleFormat, TiffImage } from "@cogeotiff/core"; import { PlanarConfiguration, TiffTag } from "@cogeotiff/core"; import { compose, translation } from "@developmentseed/affine"; +import type { Affine } from "@developmentseed/affine"; import type { RasterArray, RasterTypedArray } from "./array.js"; import type { ProjJson } from "./crs.js"; import { decode } from "./decode.js"; @@ -121,9 +122,9 @@ async function fetchBandSeparateTile( sampleFormat: SampleFormat; bitsPerSample: number; samplesPerPixel: number; - predictor: CachedTags["predictor"]; - planarConfiguration: CachedTags["planarConfiguration"]; - tileTransform: RasterArray["transform"]; + predictor: Predictor; + planarConfiguration: PlanarConfiguration; + tileTransform: Affine; signal?: AbortSignal; }, ): Promise { From d329c06fc2eee1f4c02a94a7aa584fb56b5ca65a Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 10:41:29 -0500 Subject: [PATCH 10/18] fix: imports --- packages/geotiff/src/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 30768009..0ad05420 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -1,7 +1,7 @@ import type { Predictor, SampleFormat, TiffImage } from "@cogeotiff/core"; import { PlanarConfiguration, TiffTag } from "@cogeotiff/core"; -import { compose, translation } from "@developmentseed/affine"; import type { Affine } from "@developmentseed/affine"; +import { compose, translation } from "@developmentseed/affine"; import type { RasterArray, RasterTypedArray } from "./array.js"; import type { ProjJson } from "./crs.js"; import { decode } from "./decode.js"; From 67983620fbe289f79b1f773098457d4e4c0eaf96 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 10:49:51 -0500 Subject: [PATCH 11/18] fix: remove bad test --- packages/geotiff/tests/integration.test.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 584d973d..0b102f7d 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -26,7 +26,6 @@ const FIXTURES = [ { variant: "rasterio", name: "float32_1band_lerc_block32" }, { variant: "rasterio", name: "uint16_1band_lzw_block128_predictor2" }, { variant: "rasterio", name: "uint8_1band_lzw_block64_predictor2" }, - { variant: "rasterio", name: "int8_3band_zstd_block64" }, { variant: "nlcd", name: "nlcd_landcover" }, // sydney_airport_GEC: no ModelTiepoint/ModelPixelScale/ModelTransformation — geo transform stored as GCPs, not readable by @cogeotiff/core // float32_1band_lerc_deflate_block32: geotiff.js does not support LERC_DEFLATE @@ -104,16 +103,9 @@ describe("integration vs geotiff.js", () => { const tile = await ours.fetchTile(0, 0); const oursBandSep = toBandSeparate(tile.array); - // readRasters returns band-separate by default - let refData: ReadRasterResult; - try { - refData = await refImage.readRasters({ - window: [0, 0, tw, th], - }); - } catch { - // It's not our fault if geotiff errors? - return; - } + const refData = await refImage.readRasters({ + window: [0, 0, tw, th], + }); expect(oursBandSep.bands.length).toBe(ours.count); expect(refData.length).toBe(ours.count); From 653cf424b8d8421e62e650b89dd6d9b4cad618b3 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 10:58:38 -0500 Subject: [PATCH 12/18] feat: add hook to use a different zstd decompression --- packages/geotiff/src/codecs/zstd.ts | 26 ++++++++++++++++++++++++-- packages/geotiff/src/index.ts | 1 + 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/geotiff/src/codecs/zstd.ts b/packages/geotiff/src/codecs/zstd.ts index b5ab4c18..0c3d87a8 100644 --- a/packages/geotiff/src/codecs/zstd.ts +++ b/packages/geotiff/src/codecs/zstd.ts @@ -1,6 +1,28 @@ -import { decompress } from "fzstd"; +import { decompress as fzstdDecompress } from "fzstd"; + +type ZstdDecompress = (bytes: Uint8Array) => Uint8Array; + +let decompressFn: ZstdDecompress = fzstdDecompress; + +/** + * Set a custom zstd decompression function. + * + * By default, the pure-JavaScript `fzstd` library is used. Call this function + * to override with a different implementation, e.g. `@hpcc-js/wasm-zstd`: + * + * ```ts + * import { Zstd } from "@hpcc-js/wasm-zstd"; + * import { setZstdDecoder } from "@developmentseed/geotiff"; + * + * const zstd = await Zstd.load(); + * setZstdDecoder((bytes) => zstd.decompress(bytes)); + * ``` + */ +export function setZstdDecoder(decompress: ZstdDecompress): void { + decompressFn = decompress; +} export async function decode(bytes: ArrayBuffer): Promise { - const result = decompress(new Uint8Array(bytes)); + const result = decompressFn(new Uint8Array(bytes)); return result.buffer as ArrayBuffer; } diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index 9ea92b42..7c8f6c36 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -1,6 +1,7 @@ export type { RasterArray } from "./array.js"; export { parseColormap } from "./colormap.js"; export type { ProjJson } from "./crs.js"; +export { setZstdDecoder } from "./codecs/zstd.js"; export type { DecodedPixels, Decoder, DecoderMetadata } from "./decode.js"; export { decode, registry } from "./decode.js"; export { GeoTIFF } from "./geotiff.js"; From 82efd24d522ca7c1d7767cb4ac09dd8dfec469ff Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 10:59:36 -0500 Subject: [PATCH 13/18] fix: unused import --- packages/geotiff/tests/integration.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/geotiff/tests/integration.test.ts b/packages/geotiff/tests/integration.test.ts index 0b102f7d..6f4a7b70 100644 --- a/packages/geotiff/tests/integration.test.ts +++ b/packages/geotiff/tests/integration.test.ts @@ -9,11 +9,7 @@ * are intentionally omitted here. */ -import type { - GeoTIFFImage, - GeoTIFF as GeotiffJs, - ReadRasterResult, -} from "geotiff"; +import type { GeoTIFFImage, GeoTIFF as GeotiffJs } from "geotiff"; import { fromFile } from "geotiff"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { toBandSeparate } from "../src/array.js"; From 3dff4789e64946054cace643923500127b770cfb Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Thu, 26 Feb 2026 11:02:07 -0500 Subject: [PATCH 14/18] fix: imports --- packages/geotiff/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index 7c8f6c36..100dbca5 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -1,7 +1,7 @@ export type { RasterArray } from "./array.js"; +export { setZstdDecoder } from "./codecs/zstd.js"; export { parseColormap } from "./colormap.js"; export type { ProjJson } from "./crs.js"; -export { setZstdDecoder } from "./codecs/zstd.js"; export type { DecodedPixels, Decoder, DecoderMetadata } from "./decode.js"; export { decode, registry } from "./decode.js"; export { GeoTIFF } from "./geotiff.js"; From d6ae3e40e76ed70115d09cdc4ec9168412f81c09 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Fri, 27 Feb 2026 17:30:15 -0500 Subject: [PATCH 15/18] Revert "feat: add hook to use a different zstd decompression" This reverts commit 653cf424b8d8421e62e650b89dd6d9b4cad618b3. --- packages/geotiff/src/codecs/zstd.ts | 26 ++------------------------ 1 file changed, 2 insertions(+), 24 deletions(-) diff --git a/packages/geotiff/src/codecs/zstd.ts b/packages/geotiff/src/codecs/zstd.ts index 0c3d87a8..b5ab4c18 100644 --- a/packages/geotiff/src/codecs/zstd.ts +++ b/packages/geotiff/src/codecs/zstd.ts @@ -1,28 +1,6 @@ -import { decompress as fzstdDecompress } from "fzstd"; - -type ZstdDecompress = (bytes: Uint8Array) => Uint8Array; - -let decompressFn: ZstdDecompress = fzstdDecompress; - -/** - * Set a custom zstd decompression function. - * - * By default, the pure-JavaScript `fzstd` library is used. Call this function - * to override with a different implementation, e.g. `@hpcc-js/wasm-zstd`: - * - * ```ts - * import { Zstd } from "@hpcc-js/wasm-zstd"; - * import { setZstdDecoder } from "@developmentseed/geotiff"; - * - * const zstd = await Zstd.load(); - * setZstdDecoder((bytes) => zstd.decompress(bytes)); - * ``` - */ -export function setZstdDecoder(decompress: ZstdDecompress): void { - decompressFn = decompress; -} +import { decompress } from "fzstd"; export async function decode(bytes: ArrayBuffer): Promise { - const result = decompressFn(new Uint8Array(bytes)); + const result = decompress(new Uint8Array(bytes)); return result.buffer as ArrayBuffer; } From f89fe4dc58322d6c9e1f169039198d3e610339a7 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Fri, 27 Feb 2026 17:43:11 -0500 Subject: [PATCH 16/18] docs: add note about using the registry --- fixtures/geotiff-test-data | 2 +- packages/geotiff/README.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/fixtures/geotiff-test-data b/fixtures/geotiff-test-data index cb348a76..ee231aa8 160000 --- a/fixtures/geotiff-test-data +++ b/fixtures/geotiff-test-data @@ -1 +1 @@ -Subproject commit cb348a763afad492ead5a4b26aada0f8923a72f6 +Subproject commit ee231aa8e99a433987c40da96806d446d59437c4 diff --git a/packages/geotiff/README.md b/packages/geotiff/README.md index 295ee24a..d84c8bc3 100644 --- a/packages/geotiff/README.md +++ b/packages/geotiff/README.md @@ -75,6 +75,19 @@ Until you try to load an image compressed with, say, [LERC], you don't pay for t [LERC]: https://github.com/Esri/lerc +You can also override the built-in decoders with your own by using `registry`. For example, to use a custom zstd decoder: + +```ts +import { Compression } from "@cogeotiff/core"; +import { registry } from "@developmentseed/geotiff"; + +registry.set(Compression.Zstd, () => + import("your-zstd-module").then((m) => m.decode), +); +``` + +A decoder is a function that takes an `ArrayBuffer` and a `DecoderMetadata` object and returns a `Promise`. See [decode.ts](./src/decode.ts) for the full type definitions. + ### Full user control over caching and chunking There are a lot of great utilities in [`chunkd`](https://github.com/blacha/chunkd) that work out of the box here. From b391a27f900f9a05d9a658e0ec061c5d91919909 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Fri, 27 Feb 2026 17:45:57 -0500 Subject: [PATCH 17/18] fix: remove old --- packages/geotiff/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts index 100dbca5..9ea92b42 100644 --- a/packages/geotiff/src/index.ts +++ b/packages/geotiff/src/index.ts @@ -1,5 +1,4 @@ export type { RasterArray } from "./array.js"; -export { setZstdDecoder } from "./codecs/zstd.js"; export { parseColormap } from "./colormap.js"; export type { ProjJson } from "./crs.js"; export type { DecodedPixels, Decoder, DecoderMetadata } from "./decode.js"; From 5c810ab28064e2e73198b57706a0e9de73363a71 Mon Sep 17 00:00:00 2001 From: Pete Gadomski Date: Sat, 28 Feb 2026 06:50:35 -0500 Subject: [PATCH 18/18] fix: remove band fetching (separate PR) --- packages/geotiff/src/fetch.ts | 137 +++++++--------------------------- 1 file changed, 28 insertions(+), 109 deletions(-) diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 0ad05420..bc2a786f 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -1,8 +1,7 @@ -import type { Predictor, SampleFormat, TiffImage } from "@cogeotiff/core"; -import { PlanarConfiguration, TiffTag } from "@cogeotiff/core"; -import type { Affine } from "@developmentseed/affine"; +import type { SampleFormat, TiffImage } from "@cogeotiff/core"; +import { TiffTag } from "@cogeotiff/core"; import { compose, translation } from "@developmentseed/affine"; -import type { RasterArray, RasterTypedArray } from "./array.js"; +import type { RasterArray } from "./array.js"; import type { ProjJson } from "./crs.js"; import { decode } from "./decode.js"; import type { CachedTags } from "./ifd.js"; @@ -42,12 +41,18 @@ export async function fetchTile( throw new Error("Mask fetching not implemented yet"); } + const tile = await self.image.getTile(x, y, options); + if (tile === null) { + throw new Error("Tile not found"); + } + const { bitsPerSample: bitsPerSamples, predictor, planarConfiguration, sampleFormat: sampleFormats, } = self.cachedTags; + const { bytes, compression } = tile; const { sampleFormat, bitsPerSample } = getUniqueSampleFormat( sampleFormats, bitsPerSamples, @@ -60,49 +65,26 @@ export async function fetchTile( const samplesPerPixel = self.image.value(TiffTag.SamplesPerPixel) ?? 1; - let array: RasterArray; - - if ( - planarConfiguration === PlanarConfiguration.Separate && - samplesPerPixel > 1 - ) { - array = await fetchBandSeparateTile(self, x, y, { - sampleFormat, - bitsPerSample, - samplesPerPixel, - predictor, - planarConfiguration, - tileTransform, - signal: options.signal, - }); - } else { - const tile = await self.image.getTile(x, y, options); - if (tile === null) { - throw new Error("Tile not found"); - } - - const { bytes, compression } = tile; - const decodedPixels = await decode(bytes, compression, { - sampleFormat, - bitsPerSample, - samplesPerPixel, - width: self.tileWidth, - height: self.tileHeight, - predictor, - planarConfiguration, - }); + const decodedPixels = await decode(bytes, compression, { + sampleFormat, + bitsPerSample, + samplesPerPixel, + width: self.tileWidth, + height: self.tileHeight, + predictor, + planarConfiguration, + }); - array = { - ...decodedPixels, - count: samplesPerPixel, - height: self.tileHeight, - width: self.tileWidth, - mask: null, - transform: tileTransform, - crs: self.crs, - nodata: self.nodata, - }; - } + const array: RasterArray = { + ...decodedPixels, + count: samplesPerPixel, + height: self.tileHeight, + width: self.tileWidth, + mask: null, + transform: tileTransform, + crs: self.crs, + nodata: self.nodata, + }; return { x, @@ -114,69 +96,6 @@ export async function fetchTile( }; } -async function fetchBandSeparateTile( - self: HasTiffReference, - x: number, - y: number, - opts: { - sampleFormat: SampleFormat; - bitsPerSample: number; - samplesPerPixel: number; - predictor: Predictor; - planarConfiguration: PlanarConfiguration; - tileTransform: Affine; - signal?: AbortSignal; - }, -): Promise { - const { samplesPerPixel, planarConfiguration } = opts; - const nxTiles = self.image.tileCount.x; - const nyTiles = self.image.tileCount.y; - const tilesPerBand = nxTiles * nyTiles; - const baseTileIndex = y * nxTiles + x; - - const bandPromises: Promise[] = []; - for (let b = 0; b < samplesPerPixel; b++) { - const tileIndex = b * tilesPerBand + baseTileIndex; - bandPromises.push( - self.image.getTileSize(tileIndex).then(async ({ offset, imageSize }) => { - const result = await self.image.getBytes(offset, imageSize, { - signal: opts.signal, - }); - if (result === null) { - throw new Error(`Band ${b} tile not found at index ${tileIndex}`); - } - const decoded = await decode(result.bytes, result.compression, { - sampleFormat: opts.sampleFormat, - bitsPerSample: opts.bitsPerSample, - samplesPerPixel: 1, - width: self.tileWidth, - height: self.tileHeight, - predictor: opts.predictor, - planarConfiguration, - }); - if (decoded.layout === "band-separate") { - return decoded.bands[0]!; - } - return decoded.data; - }), - ); - } - - const bands = await Promise.all(bandPromises); - - return { - layout: "band-separate", - bands, - count: samplesPerPixel, - height: self.tileHeight, - width: self.tileWidth, - mask: null, - transform: opts.tileTransform, - crs: self.crs, - nodata: self.nodata, - }; -} - /** * Clip a decoded tile array to the valid image bounds. *