Skip to content
Merged
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/src/geotiff/render-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ function createUnormPipeline(
options: GetTileDataOptions,
) => {
const { device, x, y, signal } = options;
const tile = await image.fetchTile(x, y, { signal });
const tile = await image.fetchTile(x, y, { signal, boundless: false });
let { array } = tile;

let numSamples = samplesPerPixel;
Expand Down
82 changes: 79 additions & 3 deletions packages/geotiff/src/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { SampleFormat, TiffImage } from "@cogeotiff/core";
import { TiffTag } from "@cogeotiff/core";
import { compose, translation } from "@developmentseed/affine";
import type { RasterArray } from "./array.js";
import type { ProjJson } from "./crs.js";
import { decode } from "./decode.js";
import type { CachedTags } from "./ifd.js";
Expand Down Expand Up @@ -34,7 +35,7 @@ export async function fetchTile(
self: HasTiffReference,
x: number,
y: number,
options: { signal?: AbortSignal } = {},
options: { boundless?: boolean; signal?: AbortSignal } = {},
): Promise<Tile> {
if (self.maskImage != null) {
throw new Error("Mask fetching not implemented yet");
Expand Down Expand Up @@ -74,7 +75,7 @@ export async function fetchTile(
planarConfiguration,
});

const array = {
const array: RasterArray = {
...decodedPixels,
count: samplesPerPixel,
height: self.tileHeight,
Expand All @@ -88,7 +89,82 @@ export async function fetchTile(
return {
x,
y,
array,
array:
options.boundless === false
? clipToImageBounds(self, x, y, array)
: array,
};
}

/**
* Clip a decoded tile array to the valid image bounds.
*
* Edge tiles in a COG are always encoded at the full tile size, with the
* out-of-bounds region zero-padded. When `boundless=false` is requested, this
* function copies only the valid pixel sub-rectangle into a new typed array,
* returning a `RasterArray` whose `width`/`height` match the actual image
* content rather than the tile dimensions.
*
* Interior tiles (where the tile fits entirely within the image) are returned
* unchanged.
*/
function clipToImageBounds(
self: HasTiffReference,
x: number,
y: number,
array: RasterArray,
): RasterArray {
const { width: clippedWidth, height: clippedHeight } =
self.image.getTileBounds(x, y);

// Interior tile — nothing to clip.
if (clippedWidth === self.tileWidth && clippedHeight === self.tileHeight) {
return array;
}

if (array.layout === "pixel-interleaved") {
const { count, data } = array;
const Ctor = data.constructor as new (n: number) => typeof data;
const clipped = new Ctor(clippedWidth * clippedHeight * count);
for (let r = 0; r < clippedHeight; r++) {
const srcOffset = r * self.tileWidth * count;
const dstOffset = r * clippedWidth * count;
clipped.set(
data.subarray(srcOffset, srcOffset + clippedWidth * count),
dstOffset,
);
}
return {
...array,
width: clippedWidth,
height: clippedHeight,
data: clipped,
};
}

// band-separate
const { bands } = array;
const Ctor = bands[0]!.constructor as new (
n: number,
) => (typeof bands)[number];
const clippedBands = bands.map((band) => {
const clipped = new Ctor(clippedWidth * clippedHeight);
for (let r = 0; r < clippedHeight; r++) {
const srcOffset = r * self.tileWidth;
const dstOffset = r * clippedWidth;
clipped.set(
band.subarray(srcOffset, srcOffset + clippedWidth),
dstOffset,
);
}
return clipped;
});

return {
...array,
width: clippedWidth,
height: clippedHeight,
bands: clippedBands,
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/geotiff/src/geotiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export class GeoTIFF {
async fetchTile(
x: number,
y: number,
options: { signal?: AbortSignal } = {},
options: { boundless?: boolean; signal?: AbortSignal } = {},
): Promise<Tile> {
return await fetchTile(this, x, y, options);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/geotiff/src/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class Overview {
async fetchTile(
x: number,
y: number,
options: { signal?: AbortSignal } = {},
options: { boundless?: boolean; signal?: AbortSignal } = {},
): Promise<Tile> {
return await fetchTile(this, x, y, options);
}
Expand Down
97 changes: 97 additions & 0 deletions packages/geotiff/tests/fetch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Tests for fetchTile's `boundless` option.
*
* Uses the unaligned fixture (265×266, 128×128 tiles) which has partial edge
* tiles: right edge is 9px wide (265 % 128), bottom edge is 10px tall (266 % 128).
*/

import { describe, expect, it } from "vitest";
import { loadGeoTIFF } from "./helpers.js";

describe("fetchTile boundless option", () => {
describe("boundless=true (default)", () => {
it("returns the full tile dimensions for an interior tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(0, 0);
expect(tile.array.width).toBe(tiff.tileWidth);
expect(tile.array.height).toBe(tiff.tileHeight);
});

it("returns the full tile dimensions for an edge tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
// x=2 is the right edge column (265 / 128 = 2.07 → 3 columns, last is partial)
const tile = await tiff.fetchTile(2, 0);
expect(tile.array.width).toBe(tiff.tileWidth);
expect(tile.array.height).toBe(tiff.tileHeight);
});
});

describe("boundless=false", () => {
it("returns the full tile dimensions for an interior tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(0, 0, { boundless: false });
expect(tile.array.width).toBe(tiff.tileWidth);
expect(tile.array.height).toBe(tiff.tileHeight);
});

it("clips width for a right-edge tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(2, 0, { boundless: false });
const expectedWidth = tiff.width % tiff.tileWidth; // 265 % 128 = 9
expect(tile.array.width).toBe(expectedWidth);
expect(tile.array.height).toBe(tiff.tileHeight);
});

it("clips height for a bottom-edge tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(0, 2, { boundless: false });
const expectedHeight = tiff.height % tiff.tileHeight; // 266 % 128 = 10
expect(tile.array.width).toBe(tiff.tileWidth);
expect(tile.array.height).toBe(expectedHeight);
});

it("clips both dimensions for a corner tile", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(2, 2, { boundless: false });
const expectedWidth = tiff.width % tiff.tileWidth; // 9
const expectedHeight = tiff.height % tiff.tileHeight; // 10
expect(tile.array.width).toBe(expectedWidth);
expect(tile.array.height).toBe(expectedHeight);
});

it("data length matches clipped dimensions", async () => {
const tiff = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
const tile = await tiff.fetchTile(2, 2, { boundless: false });
const { array } = tile;
const expectedPixels = array.width * array.height * array.count;
if (array.layout === "pixel-interleaved") {
expect(array.data.length).toBe(expectedPixels);
} else {
for (const band of array.bands) {
expect(band.length).toBe(array.width * array.height);
}
}
});
});
});
46 changes: 46 additions & 0 deletions packages/geotiff/tests/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const FIXTURES = [
// float32_1band_lerc_deflate_block32: geotiff.js does not support LERC_DEFLATE
] as const;

// The unaligned fixture: 265×266, 128×128 tiles — right edge is 9px, bottom is 10px.
const UNALIGNED_EDGE_W = 265 % 128; // 9
const UNALIGNED_EDGE_H = 266 % 128; // 10

/** Open the same file with geotiff.js. */
async function loadGeoTiffJs(
name: string,
Expand Down Expand Up @@ -118,3 +122,45 @@ describe("integration vs geotiff.js", () => {
});
}
});

describe("boundless=false edge tile pixel values", () => {
let ours: GeoTIFF;
let ref: GeotiffJs;
let refImage: GeoTIFFImage;

beforeAll(async () => {
ours = await loadGeoTIFF(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
ref = await loadGeoTiffJs(
"uint8_1band_deflate_block128_unaligned",
"rasterio",
);
refImage = await ref.getImage();
});

afterAll(() => ref.close());

it("corner tile (2,2) pixel values match geotiff.js readRasters window", async () => {
const tile = await ours.fetchTile(2, 2, { boundless: false });
const { array } = tile;

expect(array.width).toBe(UNALIGNED_EDGE_W);
expect(array.height).toBe(UNALIGNED_EDGE_H);

const left = 2 * ours.tileWidth;
const top = 2 * ours.tileHeight;
const right = left + UNALIGNED_EDGE_W;
const bottom = top + UNALIGNED_EDGE_H;

const refData = await refImage.readRasters({
window: [left, top, right, bottom],
});
const oursBandSep = toBandSeparate(array);

for (let b = 0; b < ours.count; b++) {
expect(oursBandSep.bands[b]).toEqual(refData[b] as ArrayLike<number>);
}
});
});