diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts index 974a0eb..433e2d7 100644 --- a/packages/geotiff/src/fetch.ts +++ b/packages/geotiff/src/fetch.ts @@ -1,7 +1,8 @@ -import type { SampleFormat, TiffImage } from "@cogeotiff/core"; -import { TiffTag } from "@cogeotiff/core"; +import type { Predictor, SampleFormat, TiffImage } from "@cogeotiff/core"; +import { PlanarConfiguration, TiffTag } from "@cogeotiff/core"; +import type { Affine } from "@developmentseed/affine"; 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"; @@ -87,22 +88,117 @@ export async function fetchTile( ? pool.decode(bytes, compression, decoderMetadata) : decode(bytes, compression, decoderMetadata)); - const array: RasterArray = { - ...decodedPixels, + 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, + }); + + array = { + ...decodedPixels, + count: samplesPerPixel, + height: self.tileHeight, + width: self.tileWidth, + mask: null, + transform: tileTransform, + crs: self.crs, + nodata: self.nodata, + }; + } + + return { + x, + y, + array: boundless === false ? clipToImageBounds(self, x, y, array) : array, + }; +} + +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: tileTransform, + transform: opts.tileTransform, crs: self.crs, nodata: self.nodata, }; - - return { - x, - y, - array: boundless === false ? clipToImageBounds(self, x, y, array) : array, - }; } /** diff --git a/packages/geotiff/tests/fetch.test.ts b/packages/geotiff/tests/fetch.test.ts index 5be3387..b1079cf 100644 --- a/packages/geotiff/tests/fetch.test.ts +++ b/packages/geotiff/tests/fetch.test.ts @@ -8,6 +8,39 @@ import { describe, expect, it } from "vitest"; import { loadGeoTIFF } from "./helpers.js"; +describe("fetchTile band-separate", () => { + it("returns band-separate layout for a multi-band planar TIFF", async () => { + const tiff = await loadGeoTIFF("int8_3band_zstd_block64", "rasterio"); + const tile = await tiff.fetchTile(0, 0); + expect(tile.array.layout).toBe("band-separate"); + expect(tile.array.count).toBe(3); + if (tile.array.layout === "band-separate") { + expect(tile.array.bands).toHaveLength(3); + for (const band of tile.array.bands) { + expect(band.length).toBe(tiff.tileWidth * tiff.tileHeight); + } + } + }); + + it("returns correct tile dimensions", async () => { + const tiff = await loadGeoTIFF("int8_3band_zstd_block64", "rasterio"); + const tile = await tiff.fetchTile(0, 0); + expect(tile.array.width).toBe(tiff.tileWidth); + expect(tile.array.height).toBe(tiff.tileHeight); + }); + + it("returns different data per band", async () => { + const tiff = await loadGeoTIFF("int8_3band_zstd_block64", "rasterio"); + const tile = await tiff.fetchTile(0, 0); + expect(tile.array.layout).toBe("band-separate"); + if (tile.array.layout === "band-separate") { + const [b0, b1, b2] = tile.array.bands; + expect(b0).not.toEqual(b1); + expect(b0).not.toEqual(b2); + } + }); +}); + describe("fetchTile boundless option", () => { describe("boundless=true (default)", () => { it("returns the full tile dimensions for an interior tile", async () => {