diff --git a/packages/geotiff/package.json b/packages/geotiff/package.json new file mode 100644 index 0000000..c1a8363 --- /dev/null +++ b/packages/geotiff/package.json @@ -0,0 +1,48 @@ +{ + "name": "@developmentseed/geotiff", + "version": "0.1.0", + "description": "High-level GeoTIFF reading library.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc --build tsconfig.build.json", + "test": "vitest run", + "test:watch": "vitest", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "geotiff", + "cog", + "cloud-optimized-geotiff", + "raster" + ], + "author": "Development Seed", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/developmentseed/deck.gl-raster.git" + }, + "devDependencies": { + "@types/node": "^25.1.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + }, + "volta": { + "extends": "../../package.json" + }, + "dependencies": { + "@cogeotiff/core": "^9.1.2" + } +} diff --git a/packages/geotiff/src/affine.ts b/packages/geotiff/src/affine.ts new file mode 100644 index 0000000..f59666c --- /dev/null +++ b/packages/geotiff/src/affine.ts @@ -0,0 +1,48 @@ +/** + * Affine geotransform: [a, b, c, d, e, f]. + * + * Maps pixel (col, row) to geographic (x, y): + * x = a * col + b * row + c + * y = d * col + e * row + f + */ +export type Affine = [ + a: number, + b: number, + c: number, + d: number, + e: number, + f: number, +]; + +/** + * Apply a geotransform to a coordinate. + * + * x_out = a * x + b * y + c + * y_out = d * x + e * y + f + */ +export function forward( + [a, b, c, d, e, f]: Affine, + x: number, + y: number, +): [number, number] { + return [a * x + b * y + c, d * x + e * y + f]; +} + +/** + * Compute the inverse of an Affine. + */ +export function invert([sa, sb, sc, sd, se, sf]: Affine): Affine { + const det = sa * se - sb * sd; + + if (det === 0) { + throw new Error("Cannot invert degenerate transform"); + } + + const idet = 1.0 / det; + const ra = se * idet; + const rb = -sb * idet; + const rd = -sd * idet; + const re = sa * idet; + + return [ra, rb, -sc * ra - sf * rb, rd, re, -sc * rd - sf * re]; +} diff --git a/packages/geotiff/src/array.ts b/packages/geotiff/src/array.ts new file mode 100644 index 0000000..f9dccec --- /dev/null +++ b/packages/geotiff/src/array.ts @@ -0,0 +1,40 @@ +import type { Affine } from "./affine.js"; + +/** + * Decoded raster data from a GeoTIFF region. + * + * Data is stored in pixel-interleaved order: for each pixel in row-major + * order, all band values are contiguous. The flat array length is + * `height * width * bands`. + */ +export type RasterArray = { + /** Pixel-interleaved raster data. Length = height * width * bands. */ + data: ArrayBuffer; + + /** + * Optional validity mask. Length = height * width. + * 1 = valid pixel, 0 = nodata. null when no mask IFD is present. + */ + mask: Uint8Array | null; + + /** Number of bands (samples per pixel). */ + count: number; + + /** Height in pixels. */ + height: number; + + /** Width in pixels. */ + width: number; + + /** + * Affine geotransform [a, b, c, d, e, f] mapping pixel (col, row) to + * geographic (x, y): + * x = a * col + b * row + c + * y = d * col + e * row + f + */ + transform: Affine; + + crs: string; + + nodata: number | null; +}; diff --git a/packages/geotiff/src/fetch.ts b/packages/geotiff/src/fetch.ts new file mode 100644 index 0000000..647a063 --- /dev/null +++ b/packages/geotiff/src/fetch.ts @@ -0,0 +1,81 @@ +import type { + Compression, + Tiff, + TiffImage, + TiffMimeType, +} from "@cogeotiff/core"; +import type { Tile } from "./tile"; +import type { HasTransform } from "./transform"; + +/** Protocol for objects that hold a TIFF reference and can request tiles. */ +interface HasTiffReference extends HasTransform { + /** The data Image File Directory (IFD) */ + ifd: TiffImage; + + /** The mask Image File Directory (IFD), if any. */ + maskIfd: TiffImage | null; + + /** The underlying TIFF object. */ + tiff: Tiff; + + /** The coordinate reference system. */ + crs: string; + + /** The height of tiles in pixels. */ + tileHeight: number; + + /** The width of tiles in pixels. */ + tileWidth: number; + + /** The nodata value for the image, if any. */ + nodata: number | null; +} + +export async function fetchTile( + self: HasTiffReference, + x: number, + y: number, +): Promise { + const tileFut = self.ifd.getTile(x, y); + let maskFut: Promise<{ + mimeType: TiffMimeType; + bytes: ArrayBuffer; + compression: Compression; + } | null> | null = null; + if (self.maskIfd != null) { + maskFut = self.maskIfd.getTile(x, y); + const [tile, mask] = await Promise.all([tileFut, maskFut]); + console.log(tile, mask); + } + + throw new Error("Not implemented"); +} + +// mask_data: AsyncTiffArray | None = None +// if self._mask_ifd is not None: +// mask_fut = self._mask_ifd.fetch_tile(x, y) +// tile, mask = await asyncio.gather(tile_fut, mask_fut) +// tile_data, mask_data = await asyncio.gather(tile.decode(), mask.decode()) +// else: +// tile = await tile_fut +// tile_data = await tile.decode() + +// tile_transform = self.transform * Affine.translation( +// x * self.tile_width, +// y * self.tile_height, +// ) + +// array = Array._create( # noqa: SLF001 +// data=tile_data, +// mask=mask_data, +// planar_configuration=self._ifd.planar_configuration, +// crs=self.crs, +// transform=tile_transform, +// nodata=self.nodata, +// ) +// return Tile( +// x=x, +// y=y, +// _ifd=self._ifd, +// array=array, +// ) diff --git a/packages/geotiff/src/geotiff.ts b/packages/geotiff/src/geotiff.ts new file mode 100644 index 0000000..cc0bee5 --- /dev/null +++ b/packages/geotiff/src/geotiff.ts @@ -0,0 +1,281 @@ +import type { Source, TiffImage } from "@cogeotiff/core"; +import { Photometric, SubFileType, Tiff, TiffTag } from "@cogeotiff/core"; +import type { Affine } from "./affine.js"; +import type { FetchOptions, TileBytes } from "./overview.js"; +import { Overview } from "./overview.js"; +import { index, xy } from "./transform.js"; + +/** + * A higher-level GeoTIFF abstraction built on @cogeotiff/core. + * + * Separates data IFDs from mask IFDs, pairs them by resolution level, + * and exposes sorted overviews. Mirrors the Python async-geotiff API. + * + * Construct via `GeoTIFF.open(source)` or `GeoTIFF.fromTiff(tiff)`. + */ +export class GeoTIFF { + /** + * Reduced-resolution overview levels, sorted finest-to-coarsest. + * + * Does not include the full-resolution image — use `fetchTile` / methods + * on the GeoTIFF instance itself for that. + */ + readonly overviews: Overview[]; + /** Affine geotransform of the full-resolution image. */ + readonly transform: Affine; + /** The primary (full-resolution) TiffImage. Useful for tag/geo key access. */ + readonly primaryImage: TiffImage; + /** The underlying Tiff instance. */ + readonly tiff: Tiff; + + /** Overview wrapper around the primary image for the convenience delegates. */ + private readonly _primary: Overview; + + private constructor( + tiff: Tiff, + primary: Overview, + overviews: Overview[], + transform: Affine, + primaryImage: TiffImage, + ) { + this.tiff = tiff; + this._primary = primary; + this.overviews = overviews; + this.transform = transform; + this.primaryImage = primaryImage; + } + + /** + * Open a GeoTIFF from a @cogeotiff/core Source. + * + * This creates and initialises the underlying Tiff, then classifies IFDs. + */ + static async open(source: Source): Promise { + const tiff = await Tiff.create(source); + return GeoTIFF.fromTiff(tiff); + } + + /** + * Create a GeoTIFF from an already-initialised Tiff instance. + * + * All IFDs are walked; mask IFDs are matched to data IFDs by matching + * (width, height). Overviews are sorted from finest to coarsest resolution. + */ + static fromTiff(tiff: Tiff): GeoTIFF { + const images = tiff.images; + if (images.length === 0) { + throw new Error("TIFF does not contain any IFDs"); + } + + const primaryImage = images[0]!; + const baseTransform = extractGeotransform(primaryImage); + const primaryWidth = primaryImage.size.width; + + // Classify IFDs (skipping index 0) into data and mask buckets + // keyed by "width,height". + const dataIFDs = new Map(); + const maskIFDs = new Map(); + + for (let i = 1; i < images.length; i++) { + const image = images[i]!; + const size = image.size; + const key = `${size.width},${size.height}`; + + if (isMaskIfd(image)) { + maskIFDs.set(key, image); + } else { + dataIFDs.set(key, image); + } + } + + // Build the primary Overview (full-resolution image + its mask, if any) + const primaryKey = `${primaryImage.size.width},${primaryImage.size.height}`; + const primary = new Overview( + primaryImage, + maskIFDs.get(primaryKey) ?? null, + baseTransform, + ); + + // Build reduced-resolution Overview instances, sorted by pixel count + // descending (finest first). + const dataEntries = Array.from(dataIFDs.entries()); + dataEntries.sort((a, b) => { + const sa = a[1].size; + const sb = b[1].size; + return sb.width * sb.height - sa.width * sa.height; + }); + + const overviews: Overview[] = dataEntries.map(([key, dataImage]) => { + const maskImage = maskIFDs.get(key) ?? null; + const overviewWidth = dataImage.size.width; + + // Scale the base transform for this overview level. + const scale = primaryWidth / overviewWidth; + const [a, b, c, d, e, f] = baseTransform; + const overviewTransform: Affine = [ + a * scale, + b * scale, + c, + d * scale, + e * scale, + f, + ]; + + return new Overview(dataImage, maskImage, overviewTransform); + }); + + return new GeoTIFF(tiff, primary, overviews, baseTransform, primaryImage); + } + + // ── Properties from the primary image ───────────────────────────────── + + /** Image width in pixels. */ + get width(): number { + return this._primary.width; + } + + /** Image height in pixels. */ + get height(): number { + return this._primary.height; + } + + /** Tile width in pixels. */ + get tileWidth(): number { + return this._primary.tileWidth; + } + + /** Tile height in pixels. */ + get tileHeight(): number { + return this._primary.tileHeight; + } + + /** The NoData value, or null if not set. */ + get nodata(): number | null { + return this.primaryImage.noData; + } + + /** Whether the primary image is tiled. */ + get isTiled(): boolean { + return this.primaryImage.isTiled(); + } + + /** Number of bands (samples per pixel). */ + get count(): number { + return (this.primaryImage.value(TiffTag.SamplesPerPixel) as number) ?? 1; + } + + /** EPSG code from GeoTIFF tags, or null if not set. */ + get epsg(): number | null { + return this.primaryImage.epsg; + } + + /** Bounding box [minX, minY, maxX, maxY] in the CRS. */ + get bbox(): [number, number, number, number] { + return this.primaryImage.bbox; + } + + // ── Convenience delegates to the full-resolution image ──────────────── + + /** Fetch a single tile from the full-resolution image. */ + async fetchTile( + x: number, + y: number, + options?: FetchOptions, + ): Promise { + return this._primary.fetchTile(x, y, options); + } + + /** Fetch data and mask tiles in parallel from the full-resolution image. */ + async fetchTileWithMask( + x: number, + y: number, + options?: FetchOptions, + ): Promise<{ + data: TileBytes; + mask: TileBytes | null; + } | null> { + return this._primary.fetchTileWithMask(x, y, options); + } + + // Transform mixin + + /** + * Get the (row, col) pixel index containing the geographic coordinate (x, y). + * + * @param x x coordinate in the CRS. + * @param y y coordinate in the CRS. + * @param op Rounding function applied to fractional pixel indices. + * Defaults to Math.floor. + * @returns [row, col] pixel indices. + */ + index( + x: number, + y: number, + op: (n: number) => number = Math.floor, + ): [number, number] { + return index(this, x, y, op); + } + + /** + * Get the geographic (x, y) coordinate of the pixel at (row, col). + * + * @param row Pixel row. + * @param col Pixel column. + * @param offset Which part of the pixel to return. Defaults to "center". + * @returns [x, y] in the CRS. + */ + xy( + row: number, + col: number, + offset: "center" | "ul" | "ur" | "ll" | "lr" = "center", + ): [number, number] { + return xy(this, row, col, offset); + } +} + +/** + * Extract affine geotransform from a TiffImage. + * + * Returns [a, b, c, d, e, f] where: + * x = a * col + b * row + c + * y = d * col + e * row + f + */ +export function extractGeotransform(image: TiffImage): Affine { + const origin = image.origin; + const resolution = image.resolution; + + // Check for rotation via ModelTransformation + const modelTransformation = image.value(TiffTag.ModelTransformation); + + let b = 0; // row rotation + let d = 0; // column rotation + + if (modelTransformation != null && modelTransformation.length >= 16) { + b = modelTransformation[1]!; + d = modelTransformation[4]!; + } + + return [ + resolution[0], // a: pixel width (x per col) + b, // b: row rotation + origin[0], // c: x origin + d, // d: column rotation + resolution[1], // e: pixel height (negative = north-up) + origin[1], // f: y origin + ]; +} + +/** + * Determine whether a TiffImage is a mask IFD. + * + * A mask IFD has SubFileType with the Mask bit set (value 4) AND + * PhotometricInterpretation === Mask (4). + */ +export function isMaskIfd(image: TiffImage): boolean { + const subFileType = image.value(TiffTag.SubFileType) ?? 0; + const photometric = image.value(TiffTag.Photometric); + + return ( + (subFileType & SubFileType.Mask) !== 0 && photometric === Photometric.Mask + ); +} diff --git a/packages/geotiff/src/index.ts b/packages/geotiff/src/index.ts new file mode 100644 index 0000000..b60eec4 --- /dev/null +++ b/packages/geotiff/src/index.ts @@ -0,0 +1,14 @@ +export * as affine from "./affine.js"; +export type { RasterArray } from "./array.js"; +export { + extractGeotransform, + GeoTIFF, + isMaskIfd, +} from "./geotiff.js"; +export type { FetchOptions, TileBytes } from "./overview.js"; +export { Overview } from "./overview.js"; +export type { Tile } from "./tile.js"; +export { + index, + xy, +} from "./transform.js"; diff --git a/packages/geotiff/src/overview.ts b/packages/geotiff/src/overview.ts new file mode 100644 index 0000000..996691a --- /dev/null +++ b/packages/geotiff/src/overview.ts @@ -0,0 +1,134 @@ +import type { Compression, TiffImage, TiffMimeType } from "@cogeotiff/core"; +import type { Affine } from "./affine.js"; + +/** Options for fetching tile/raster data. */ +export type FetchOptions = { + /** AbortSignal to cancel the fetch operation. */ + signal?: AbortSignal; +}; + +/** Raw tile bytes returned by fetchTile before any decoding. */ +export type TileBytes = { + /** Tile column index. */ + x: number; + /** Tile row index. */ + y: number; + /** Compressed tile bytes. */ + bytes: ArrayBuffer; + /** MIME type of the compressed data (e.g. "image/jpeg"). */ + mimeType: TiffMimeType; + /** Compression enum value. */ + compression: Compression; +}; + +/** + * A single resolution level of a GeoTIFF — either the full-resolution image + * or a reduced-resolution overview. Pairs the data IFD with its + * corresponding mask IFD (if any). + */ +export class Overview { + /** The data IFD for this resolution level. */ + readonly image: TiffImage; + /** The mask IFD, or null when no mask exists at this level. */ + readonly maskImage: TiffImage | null; + /** Affine geotransform for this overview's pixel grid. */ + readonly transform: Affine; + /** Image width in pixels. */ + readonly width: number; + /** Image height in pixels. */ + readonly height: number; + /** Tile width in pixels (equals image width when the image is not tiled). */ + readonly tileWidth: number; + /** Tile height in pixels (equals image height when the image is not tiled). */ + readonly tileHeight: number; + + constructor( + image: TiffImage, + maskImage: TiffImage | null, + transform: Affine, + ) { + this.image = image; + this.maskImage = maskImage; + this.transform = transform; + + const size = image.size; + this.width = size.width; + this.height = size.height; + + if (image.isTiled()) { + const ts = image.tileSize; + this.tileWidth = ts.width; + this.tileHeight = ts.height; + } else { + this.tileWidth = this.width; + this.tileHeight = this.height; + } + } + + /** + * Fetch a single tile's raw compressed bytes by its grid indices. + * + * Returns null if the tile has no data (sparse COG). + */ + async fetchTile( + x: number, + y: number, + _options?: FetchOptions, + ): Promise { + const result = await this.image.getTile(x, y); + if (result == null) return null; + + return { + x, + y, + bytes: result.bytes, + mimeType: result.mimeType, + compression: result.compression, + }; + } + + /** + * Fetch data and mask tiles in parallel for the given grid position. + * + * Returns null if the data tile has no data (sparse COG). + */ + async fetchTileWithMask( + x: number, + y: number, + _options?: FetchOptions, + ): Promise<{ + data: TileBytes; + mask: TileBytes | null; + } | null> { + const dataPromise = this.image.getTile(x, y); + const maskPromise = this.maskImage ? this.maskImage.getTile(x, y) : null; + + const [dataResult, maskResult] = await Promise.all([ + dataPromise, + maskPromise, + ]); + + if (dataResult == null) return null; + + const dataTile: TileBytes = { + x, + y, + bytes: dataResult.bytes, + mimeType: dataResult.mimeType, + compression: dataResult.compression, + }; + + let maskTile: TileBytes | null = null; + if (maskResult != null) { + maskTile = { + x, + y, + bytes: maskResult.bytes, + mimeType: maskResult.mimeType, + compression: maskResult.compression, + }; + } + + return { data: dataTile, mask: maskTile }; + } +} diff --git a/packages/geotiff/src/tile.ts b/packages/geotiff/src/tile.ts new file mode 100644 index 0000000..c2b637b --- /dev/null +++ b/packages/geotiff/src/tile.ts @@ -0,0 +1,11 @@ +import type { RasterArray } from "./array.js"; + +/** A single tile fetched from a GeoTIFF or Overview. */ +export type Tile = { + /** Tile column index in the image's tile grid. */ + x: number; + /** Tile row index in the image's tile grid. */ + y: number; + /** Decoded raster data for this tile. */ + array: RasterArray; +}; diff --git a/packages/geotiff/src/transform.ts b/packages/geotiff/src/transform.ts new file mode 100644 index 0000000..f7d8c5d --- /dev/null +++ b/packages/geotiff/src/transform.ts @@ -0,0 +1,73 @@ +import type { Affine } from "./affine.js"; +import { forward, invert } from "./affine.js"; + +/** + * Interface for objects that have an affine transform. + */ +export interface HasTransform { + /** The affine transform. */ + transform: Affine; +} + +/** + * Get the (row, col) pixel index containing the geographic coordinate (x, y). + * + * @param x x coordinate in the CRS. + * @param y y coordinate in the CRS. + * @param op Rounding function applied to fractional pixel indices. + * Defaults to Math.floor. + * @returns [row, col] pixel indices. + */ +export function index( + self: HasTransform, + x: number, + y: number, + op: (n: number) => number = Math.floor, +): [number, number] { + const inv = invert(self.transform); + const [col, row] = forward(inv, x, y); + return [op(row), op(col)]; +} + +/** + * Get the geographic (x, y) coordinate of the pixel at (row, col). + * + * @param row Pixel row. + * @param col Pixel column. + * @param offset Which part of the pixel to return. Defaults to "center". + * @returns [x, y] in the CRS. + */ +export function xy( + self: HasTransform, + row: number, + col: number, + offset: "center" | "ul" | "ur" | "ll" | "lr" = "center", +): [number, number] { + let c: number; + let r: number; + + switch (offset) { + case "center": + c = col + 0.5; + r = row + 0.5; + break; + case "ul": + c = col; + r = row; + break; + case "ur": + c = col + 1; + r = row; + break; + case "ll": + c = col; + r = row + 1; + break; + case "lr": + c = col + 1; + r = row + 1; + break; + } + + return forward(self.transform, c, r); +} diff --git a/packages/geotiff/tests/affine.test.ts b/packages/geotiff/tests/affine.test.ts new file mode 100644 index 0000000..cc3029d --- /dev/null +++ b/packages/geotiff/tests/affine.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import type { Affine } from "../src/affine.js"; +import { forward, invert } from "../src/affine.js"; + +describe("forward", () => { + it("applies an identity-like transform", () => { + const gt: Affine = [1, 0, 0, 0, 1, 0]; + expect(forward(gt, 3, 4)).toEqual([3, 4]); + }); + + it("applies translation", () => { + const gt: Affine = [1, 0, 10, 0, 1, 20]; + expect(forward(gt, 5, 5)).toEqual([15, 25]); + }); + + it("applies scale + translation", () => { + const gt: Affine = [0.5, 0, 100, 0, -0.5, 200]; + expect(forward(gt, 10, 20)).toEqual([105, 190]); + }); +}); + +describe("invert", () => { + it("inverts a simple scale+translate transform", () => { + const gt: Affine = [2, 0, 10, 0, -3, 50]; + const inv = invert(gt); + // Applying the inverse should undo the forward transform + const [x, y] = forward(gt, 5, 7); + const [col, row] = forward(inv, x, y); + expect(col).toBeCloseTo(5); + expect(row).toBeCloseTo(7); + }); + + it("throws for a degenerate transform", () => { + const gt: Affine = [0, 0, 0, 0, 0, 0]; + expect(() => invert(gt)).toThrow(/degenerate/); + }); +}); diff --git a/packages/geotiff/tests/geotiff.test.ts b/packages/geotiff/tests/geotiff.test.ts new file mode 100644 index 0000000..71547ea --- /dev/null +++ b/packages/geotiff/tests/geotiff.test.ts @@ -0,0 +1,266 @@ +import { Photometric, SubFileType } from "@cogeotiff/core"; +import { describe, expect, it } from "vitest"; +import { extractGeotransform, GeoTIFF, isMaskIfd } from "../src/geotiff.js"; +import { mockImage, mockTiff } from "./helpers.js"; + +describe("isMaskIfd", () => { + it("returns true for a mask IFD", () => { + const image = mockImage({ + width: 256, + height: 256, + subFileType: SubFileType.Mask, + photometric: Photometric.Mask, + }); + expect(isMaskIfd(image)).toBe(true); + }); + + it("returns false when SubFileType has no mask bit", () => { + const image = mockImage({ + width: 256, + height: 256, + subFileType: SubFileType.ReducedImage, + photometric: Photometric.Mask, + }); + expect(isMaskIfd(image)).toBe(false); + }); + + it("returns false when Photometric is not Mask", () => { + const image = mockImage({ + width: 256, + height: 256, + subFileType: SubFileType.Mask, + photometric: Photometric.MinIsBlack, + }); + expect(isMaskIfd(image)).toBe(false); + }); + + it("returns true when SubFileType combines ReducedImage + Mask bits", () => { + const image = mockImage({ + width: 256, + height: 256, + subFileType: SubFileType.ReducedImage | SubFileType.Mask, + photometric: Photometric.Mask, + }); + expect(isMaskIfd(image)).toBe(true); + }); + + it("returns false when SubFileType is absent (defaults to 0)", () => { + const image = mockImage({ + width: 256, + height: 256, + photometric: Photometric.Mask, + }); + expect(isMaskIfd(image)).toBe(false); + }); +}); + +describe("extractGeotransform", () => { + it("extracts a basic north-up geotransform", () => { + const image = mockImage({ + width: 1000, + height: 1000, + origin: [-180, 90, 0], + resolution: [0.1, -0.1, 0], + }); + + const gt = extractGeotransform(image); + expect(gt).toEqual([0.1, 0, -180, 0, -0.1, 90]); + }); + + it("extracts rotation from ModelTransformation", () => { + const mt = new Array(16).fill(0); + mt[1] = 0.01; // b (row rotation) + mt[4] = 0.02; // d (column rotation) + + const image = mockImage({ + width: 1000, + height: 1000, + origin: [100, 50, 0], + resolution: [1, -1, 0], + modelTransformation: mt, + }); + + const gt = extractGeotransform(image); + expect(gt[1]).toBe(0.01); + expect(gt[3]).toBe(0.02); + }); +}); + +describe("GeoTIFF", () => { + it("throws for empty TIFF", () => { + const tiff = mockTiff([]); + expect(() => GeoTIFF.fromTiff(tiff)).toThrow(/does not contain/); + }); + + it("creates a GeoTIFF from a single-image TIFF", () => { + const primary = mockImage({ + width: 1000, + height: 1000, + origin: [0, 0, 0], + resolution: [1, -1, 0], + samplesPerPixel: 3, + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + + expect(geo.width).toBe(1000); + expect(geo.height).toBe(1000); + expect(geo.count).toBe(3); + expect(geo.overviews).toHaveLength(0); + expect(geo.transform).toEqual([1, 0, 0, 0, -1, 0]); + }); + + it("classifies reduced-resolution IFDs as overviews", () => { + const primary = mockImage({ + width: 1000, + height: 1000, + origin: [0, 0, 0], + resolution: [1, -1, 0], + }); + const ov1 = mockImage({ width: 500, height: 500 }); + const ov2 = mockImage({ width: 250, height: 250 }); + + const tiff = mockTiff([primary, ov1, ov2]); + const geo = GeoTIFF.fromTiff(tiff); + + expect(geo.overviews).toHaveLength(2); + }); + + it("sorts overviews finest-to-coarsest", () => { + const primary = mockImage({ + width: 1000, + height: 1000, + origin: [0, 0, 0], + resolution: [1, -1, 0], + }); + // Insert in reverse order + const small = mockImage({ width: 125, height: 125 }); + const medium = mockImage({ width: 250, height: 250 }); + const large = mockImage({ width: 500, height: 500 }); + + const tiff = mockTiff([primary, small, medium, large]); + const geo = GeoTIFF.fromTiff(tiff); + + expect(geo.overviews).toHaveLength(3); + expect(geo.overviews[0]!.width).toBe(500); + expect(geo.overviews[1]!.width).toBe(250); + expect(geo.overviews[2]!.width).toBe(125); + }); + + it("separates mask IFDs from data IFDs", () => { + const primary = mockImage({ + width: 1000, + height: 1000, + origin: [0, 0, 0], + resolution: [1, -1, 0], + }); + const ov = mockImage({ width: 500, height: 500 }); + const primaryMask = mockImage({ + width: 1000, + height: 1000, + subFileType: SubFileType.Mask, + photometric: Photometric.Mask, + }); + const ovMask = mockImage({ + width: 500, + height: 500, + subFileType: SubFileType.ReducedImage | SubFileType.Mask, + photometric: Photometric.Mask, + }); + + const tiff = mockTiff([primary, ov, primaryMask, ovMask]); + const geo = GeoTIFF.fromTiff(tiff); + + // Only one data overview (the mask IFDs are paired, not listed as overviews) + expect(geo.overviews).toHaveLength(1); + expect(geo.overviews[0]!.maskImage).not.toBeNull(); + }); + + it("scales overview transforms correctly", () => { + const primary = mockImage({ + width: 1000, + height: 1000, + origin: [100, 200, 0], + resolution: [0.01, -0.01, 0], + }); + const ov = mockImage({ width: 500, height: 500 }); + + const tiff = mockTiff([primary, ov]); + const geo = GeoTIFF.fromTiff(tiff); + + const ovTransform = geo.overviews[0]!.transform; + // scale = 1000 / 500 = 2 + expect(ovTransform[0]).toBeCloseTo(0.02); // a * 2 + expect(ovTransform[4]).toBeCloseTo(-0.02); // e * 2 + // Origin unchanged + expect(ovTransform[2]).toBe(100); // c + expect(ovTransform[5]).toBe(200); // f + }); + + it("delegates fetchTile to the primary image", async () => { + const primary = mockImage({ + width: 256, + height: 256, + origin: [0, 0, 0], + resolution: [1, -1, 0], + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + + const tile = await geo.fetchTile(0, 0); + expect(tile).not.toBeNull(); + expect(tile!.x).toBe(0); + expect(tile!.y).toBe(0); + }); + + it("exposes nodata", () => { + const primary = mockImage({ + width: 100, + height: 100, + origin: [0, 0, 0], + resolution: [1, -1, 0], + noData: -9999, + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + expect(geo.nodata).toBe(-9999); + }); + + it("exposes epsg", () => { + const primary = mockImage({ + width: 100, + height: 100, + origin: [0, 0, 0], + resolution: [1, -1, 0], + epsg: 4326, + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + expect(geo.epsg).toBe(4326); + }); + + it("exposes bbox", () => { + const primary = mockImage({ + width: 100, + height: 100, + origin: [0, 0, 0], + resolution: [1, -1, 0], + bbox: [-180, -90, 180, 90], + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + expect(geo.bbox).toEqual([-180, -90, 180, 90]); + }); + + it("defaults count to 1 when SamplesPerPixel is absent", () => { + const primary = mockImage({ + width: 100, + height: 100, + origin: [0, 0, 0], + resolution: [1, -1, 0], + }); + const tiff = mockTiff([primary]); + const geo = GeoTIFF.fromTiff(tiff); + expect(geo.count).toBe(1); + }); +}); diff --git a/packages/geotiff/tests/helpers.ts b/packages/geotiff/tests/helpers.ts new file mode 100644 index 0000000..fb0cfc2 --- /dev/null +++ b/packages/geotiff/tests/helpers.ts @@ -0,0 +1,64 @@ +import type { Tiff, TiffImage } from "@cogeotiff/core"; +import { TiffTag } from "@cogeotiff/core"; + +/** Create a mock TiffImage with configurable properties. */ +export function mockImage(opts: { + width: number; + height: number; + tileWidth?: number; + tileHeight?: number; + tiled?: boolean; + origin?: [number, number, number]; + resolution?: [number, number, number]; + subFileType?: number; + photometric?: number; + samplesPerPixel?: number; + noData?: number | null; + epsg?: number | null; + bbox?: [number, number, number, number]; + modelTransformation?: number[] | null; +}): TiffImage { + const tiled = opts.tiled ?? true; + const tags = new Map(); + + if (opts.subFileType != null) { + tags.set(TiffTag.SubFileType, opts.subFileType); + } + if (opts.photometric != null) { + tags.set(TiffTag.Photometric, opts.photometric); + } + if (opts.samplesPerPixel != null) { + tags.set(TiffTag.SamplesPerPixel, opts.samplesPerPixel); + } + if (opts.modelTransformation != null) { + tags.set(TiffTag.ModelTransformation, opts.modelTransformation); + } + + return { + size: { width: opts.width, height: opts.height }, + tileSize: { + width: opts.tileWidth ?? 256, + height: opts.tileHeight ?? 256, + }, + isTiled: () => tiled, + origin: opts.origin ?? [0, 0, 0], + resolution: opts.resolution ?? [1, -1, 0], + noData: opts.noData ?? null, + epsg: opts.epsg ?? null, + bbox: opts.bbox ?? [0, 0, 100, 100], + value: (tag: number) => { + if (tags.has(tag)) return tags.get(tag); + return null; + }, + getTile: async (_x: number, _y: number) => ({ + bytes: new ArrayBuffer(8), + mimeType: "image/jpeg", + compression: 7, // JPEG + }), + } as unknown as TiffImage; +} + +/** Create a mock Tiff with the given images. */ +export function mockTiff(images: TiffImage[]): Tiff { + return { images } as unknown as Tiff; +} diff --git a/packages/geotiff/tests/overview.test.ts b/packages/geotiff/tests/overview.test.ts new file mode 100644 index 0000000..d270a8d --- /dev/null +++ b/packages/geotiff/tests/overview.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from "vitest"; +import { Overview } from "../src/overview.js"; +import { mockImage } from "./helpers.js"; + +describe("Overview", () => { + it("exposes image dimensions", () => { + const image = mockImage({ width: 512, height: 256 }); + const ov = new Overview(image, null, [1, 0, 0, 0, -1, 0]); + expect(ov.width).toBe(512); + expect(ov.height).toBe(256); + }); + + it("exposes tile size for tiled images", () => { + const image = mockImage({ + width: 1024, + height: 1024, + tileWidth: 256, + tileHeight: 256, + tiled: true, + }); + const ov = new Overview(image, null, [1, 0, 0, 0, -1, 0]); + expect(ov.tileWidth).toBe(256); + expect(ov.tileHeight).toBe(256); + }); + + it("uses image dimensions as tile size for non-tiled images", () => { + const image = mockImage({ + width: 512, + height: 256, + tiled: false, + }); + const ov = new Overview(image, null, [1, 0, 0, 0, -1, 0]); + expect(ov.tileWidth).toBe(512); + expect(ov.tileHeight).toBe(256); + }); + + it("fetchTile returns tile bytes", async () => { + const image = mockImage({ width: 256, height: 256 }); + const ov = new Overview(image, null, [1, 0, 0, 0, -1, 0]); + + const tile = await ov.fetchTile(0, 0); + expect(tile).not.toBeNull(); + expect(tile!.x).toBe(0); + expect(tile!.y).toBe(0); + expect(tile!.bytes).toBeInstanceOf(ArrayBuffer); + }); + + it("fetchTile returns null for sparse tiles", async () => { + const image = mockImage({ width: 256, height: 256 }); + (image as any).getTile = async () => null; + + const ov = new Overview(image, null, [1, 0, 0, 0, -1, 0]); + const tile = await ov.fetchTile(0, 0); + expect(tile).toBeNull(); + }); + + it("fetchTileWithMask returns data and mask", async () => { + const dataImage = mockImage({ width: 256, height: 256 }); + const maskImage = mockImage({ width: 256, height: 256 }); + + const ov = new Overview(dataImage, maskImage, [1, 0, 0, 0, -1, 0]); + const result = await ov.fetchTileWithMask(0, 0); + + expect(result).not.toBeNull(); + expect(result!.data.bytes).toBeInstanceOf(ArrayBuffer); + expect(result!.mask).not.toBeNull(); + expect(result!.mask!.bytes).toBeInstanceOf(ArrayBuffer); + }); + + it("fetchTileWithMask returns null mask when no mask image", async () => { + const dataImage = mockImage({ width: 256, height: 256 }); + const ov = new Overview(dataImage, null, [1, 0, 0, 0, -1, 0]); + const result = await ov.fetchTileWithMask(0, 0); + + expect(result).not.toBeNull(); + expect(result!.mask).toBeNull(); + }); +}); diff --git a/packages/geotiff/tests/transform.test.ts b/packages/geotiff/tests/transform.test.ts new file mode 100644 index 0000000..a43768b --- /dev/null +++ b/packages/geotiff/tests/transform.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import type { Affine } from "../src/affine.js"; +import { index, xy } from "../src/transform.js"; + +describe("index", () => { + // Simple north-up, 1m resolution at (100, 200) + const gt: Affine = [1, 0, 100, 0, -1, 200]; + + it("returns [row, col] for a coordinate", () => { + const [row, col] = index({ transform: gt }, 105, 195); + expect(col).toBe(5); + expect(row).toBe(5); + }); + + it("uses Math.floor by default", () => { + const [row, col] = index({ transform: gt }, 100.9, 199.1); + expect(col).toBe(0); + expect(row).toBe(0); + }); + + it("accepts a custom rounding op", () => { + const [row, col] = index({ transform: gt }, 100.9, 199.1, Math.round); + expect(col).toBe(1); + expect(row).toBe(1); + }); +}); + +describe("xy", () => { + const gt: Affine = [1, 0, 100, 0, -1, 200]; + + it("returns pixel center by default", () => { + const [x, y] = xy({ transform: gt }, 0, 0); + expect(x).toBeCloseTo(100.5); + expect(y).toBeCloseTo(199.5); + }); + + it("returns upper-left corner", () => { + const [x, y] = xy({ transform: gt }, 0, 0, "ul"); + expect(x).toBeCloseTo(100); + expect(y).toBeCloseTo(200); + }); + + it("returns lower-right corner", () => { + const [x, y] = xy({ transform: gt }, 0, 0, "lr"); + expect(x).toBeCloseTo(101); + expect(y).toBeCloseTo(199); + }); +}); + +describe("index/xy round-trip", () => { + const gt: Affine = [0.5, 0, -180, 0, -0.5, 90]; + + it("xy then index recovers the original pixel", () => { + const row = 10; + const col = 20; + const [x, y] = xy({ transform: gt }, row, col, "ul"); + const [rRow, rCol] = index({ transform: gt }, x, y); + expect(rRow).toBe(row); + expect(rCol).toBe(col); + }); +}); diff --git a/packages/geotiff/tsconfig.build.json b/packages/geotiff/tsconfig.build.json new file mode 100644 index 0000000..b3e1ed7 --- /dev/null +++ b/packages/geotiff/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/packages/geotiff/tsconfig.json b/packages/geotiff/tsconfig.json new file mode 100644 index 0000000..4de4cf5 --- /dev/null +++ b/packages/geotiff/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "types": ["node"] + }, + "include": ["src/**/*", "tests/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 506bc3a..d9c3024 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -346,6 +346,22 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.1.0)(happy-dom@20.0.11)(jsdom@27.4.0) + packages/geotiff: + dependencies: + '@cogeotiff/core': + specifier: ^9.1.2 + version: 9.1.2 + devDependencies: + '@types/node': + specifier: ^25.1.0 + version: 25.1.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.1.0)(happy-dom@20.0.11)(jsdom@27.4.0) + packages/raster-reproject: devDependencies: '@types/node': @@ -514,6 +530,10 @@ packages: cpu: [x64] os: [win32] + '@cogeotiff/core@9.1.2': + resolution: {integrity: sha512-m1F9glmz8zxxfDyqVnOQibF/uIZciequ1576zy6FG1rCcXVtLhE3qERT1GcS79K/dKvf+B+imgEmV2qfl6TdJQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + '@csstools/color-helpers@5.1.0': resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} engines: {node: '>=18'} @@ -2623,6 +2643,8 @@ snapshots: '@biomejs/cli-win32-x64@2.3.13': optional: true + '@cogeotiff/core@9.1.2': {} + '@csstools/color-helpers@5.1.0': {} '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': diff --git a/tsconfig.json b/tsconfig.json index ee7584f..b688fc7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,9 +26,10 @@ "exclude": ["node_modules"], "files": [], "references": [ - { "path": "packages/raster-reproject/tsconfig.build.json" }, - { "path": "packages/deck.gl-raster/tsconfig.build.json" }, { "path": "packages/deck.gl-geotiff/tsconfig.build.json" }, - { "path": "packages/deck.gl-zarr/tsconfig.build.json" } + { "path": "packages/deck.gl-raster/tsconfig.build.json" }, + { "path": "packages/deck.gl-zarr/tsconfig.build.json" }, + { "path": "packages/geotiff/tsconfig.build.json" }, + { "path": "packages/raster-reproject/tsconfig.build.json" } ] }