layer/geotiff/GeoTIFFReader.tstypescript
import { fromArrayBuffer, fromBlob, fromUrl, GeoTIFF, GeoTIFFImage, Pool } from "geotiff";
import type { ReadRastersOptions, TypedArray } from "geotiff";
import { Extent } from "../../Extent";
import { LonLat } from "../../LonLat";
import * as mercator from "../../mercator";
import { getFastNoDataChecker, getRasterMinMax, parseNoDataValue } from "./ColorScale";
import { getProjectionHelper, type IProjectionHelper } from "./utm";
import { acquireWorkerPool, type IWorkerPoolHandle } from "./workerPool";
import { Proj } from "../../proj/Proj";
import { EPSG3857 } from "../../proj/EPSG3857";
import { EPSG4326 } from "../../proj/EPSG4326";
import type {
DecodedTileData,
GeoTIFFSource,
IBandStats,
IGeoTIFFMetadata,
IGeoTIFFReader,
IGeoTIFFReaderParams,
IGeoTIFFRenderOptions,
ProjFunc
} from "./types";
/**
* Sampling grid of a tile, source pixel positions are interpolated between its nodes.
*/
interface TileGrid {
gridU: Float64Array;
gridV: Float64Array;
gridSize: number;
uMin: number;
uMax: number;
vMin: number;
vMax: number;
}
const GRID_MIN_SIZE = 4;
/**
* Finest grid, reached only by tiles that span a large part of the globe.
*/
const GRID_MAX_SIZE = 64;
/**
* Allowed interpolation error between the grid nodes, in tile pixels.
*/
const GRID_MAX_ERROR = 0.25;
/**
* Size of a cached byte range of a remote file.
*/
const BLOCK_SIZE = 65536 * 4;
/**
* Memory taken by cached byte ranges, the default cacheSize is derived from it.
*/
const BLOCK_CACHE_BYTES = 32 * Math.pow(1024, 2);
const OVERVIEW_RESOLUTION_TOLERANCE = 1.4;
/**
* Photometric interpretations that do not store RGB in the samples: CMYK, YCbCr and CIELab.
* JPEG compressed imagery is usually YCbCr.
*/
const CONVERTED_PHOTOMETRICS = [5, 6, 8];
export function parseCrsCode(crs?: number | string): number | undefined {
if (!crs) return;
if (typeof crs === "number") return crs;
if (typeof crs === "string") {
const match = crs.match(/\d+/);
return match ? parseInt(match[0], 10) : undefined;
}
return;
}
export class GeoTIFFReader implements IGeoTIFFReader {
public source: GeoTIFF | null = null;
public images: GeoTIFFImage[] = [];
/** Internal transparency mask of every entry of images, null when the file has none */
public masks: (GeoTIFFImage | null)[] = [];
public workerPool: Pool | null = null;
public metadata: IGeoTIFFMetadata | null = null;
public extentWgs84: Extent = new Extent();
public nativeBBox: [number, number, number, number] = [0, 0, 0, 0];
public crsCode: number = 4326;
/** Whether `crsCode` could be georeferenced, see {@link IGeoTIFFMetadata.crsResolved} */
public crsResolved: boolean = true;
public projFunc?: ProjFunc;
public reverseY: boolean = false;
public requestLevels: number[] = [];
public isReady: boolean = false;
private _poolHandle: IWorkerPoolHandle | null = null;
private _projHelper: IProjectionHelper | null = null;
private _convertPhotometric: boolean = false;
private _activeReads: number = 0;
private _destroyed: boolean = false;
constructor(public options: IGeoTIFFReaderParams = {}) {
this.projFunc = options.projFunc;
}
/**
* Initializes and parses the GeoTIFF source and its overview pyramid.
*/
public async init(src: GeoTIFFSource): Promise<IGeoTIFFMetadata> {
let source: GeoTIFF;
if (typeof src === "string") {
source = await fromUrl(src, {
blockSize: BLOCK_SIZE,
cacheSize: Math.max(1, Math.round(BLOCK_CACHE_BYTES / (this.options.requestOptions?.blockSize || BLOCK_SIZE))),
...this.options.requestOptions
});
} else if (
src instanceof ArrayBuffer ||
(typeof ArrayBuffer !== "undefined" && Object.prototype.toString.call(src) === "[object ArrayBuffer]")
) {
source = await fromArrayBuffer(src as ArrayBuffer);
} else if (ArrayBuffer.isView(src)) {
const view = src as ArrayBufferView;
const buf = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
source = await fromArrayBuffer(buf as ArrayBuffer);
} else if (
(typeof Blob !== "undefined" && src instanceof Blob) ||
(typeof File !== "undefined" && (src as any) instanceof File) ||
(src && typeof (src as any).slice === "function" && typeof (src as any).size === "number")
) {
source = await fromBlob(src as any);
} else {
throw new Error("Invalid GeoTIFF source provided.");
}
this.source = source;
this._destroyed = false;
// Initialize Web Worker Pool for decoding if supported, releasing a lease
// a previous init() may have taken
this._releasePool();
this._poolHandle = acquireWorkerPool(this.options);
this.workerPool = this._poolHandle.pool;
const imageCount = await source.getImageCount();
this.images = [];
const maskImages: GeoTIFFImage[] = [];
for (let i = 0; i < imageCount; i++) {
const img = await source.getImage(i);
// In NewSubfileType bit 0 is an overview and bit 2 is a transparency mask
const subfileType = Number(img.getFileDirectory().getValue("NewSubfileType") ?? 0);
const isMask = (subfileType & 4) === 4;
if (isMask) {
maskImages.push(img);
} else if (i === 0 || (subfileType & 1) === 1) {
this.images.push(img);
}
}
// A mask has the size of the level it belongs to
this.masks = this.images.map(
(img) =>
maskImages.find((m) => m.getWidth() === img.getWidth() && m.getHeight() === img.getHeight()) ?? null
);
const baseImage = this.images[0];
this.reverseY = this._checkIfReversed(baseImage);
// Bounding box in native coordinates [minX, minY, maxX, maxY]
this.nativeBBox = baseImage.getBoundingBox() as [number, number, number, number];
const [minX, minY, maxX, maxY] = this.nativeBBox;
// Determine CRS
const geoKeys = baseImage.getGeoKeys() || {};
let crsCode = +(geoKeys.ProjectedCSTypeGeoKey || geoKeys.GeographicTypeGeoKey || 0);
const forcedCrs = parseCrsCode(this.options.crs);
if (forcedCrs) {
crsCode = forcedCrs;
} else if (crsCode === 32767 || crsCode === 65535 || !crsCode) {
if (minX >= -180.5 && maxX <= 180.5 && minY >= -90.5 && maxY <= 90.5) {
crsCode = 4326;
} else {
crsCode = 3857;
}
}
this.crsCode = crsCode;
this._projHelper = getProjectionHelper(crsCode, this.projFunc);
this.crsResolved = true;
// Calculate WGS84 Extent
if (crsCode === 4326) {
this.extentWgs84 = new Extent(new LonLat(minX, minY), new LonLat(maxX, maxY));
} else if (crsCode === 3857 || crsCode === 900913) {
const swLon = mercator.inverse_lon(minX);
const swLat = mercator.inverse_lat(minY);
const neLon = mercator.inverse_lon(maxX);
const neLat = mercator.inverse_lat(maxY);
this.extentWgs84 = new Extent(new LonLat(swLon, swLat), new LonLat(neLon, neLat));
} else if (this._projHelper) {
const sw = this._projHelper.unproject([minX, minY]);
const se = this._projHelper.unproject([maxX, minY]);
const nw = this._projHelper.unproject([minX, maxY]);
const ne = this._projHelper.unproject([maxX, maxY]);
const lons = [sw[0], se[0], nw[0], ne[0]];
const lats = [sw[1], se[1], nw[1], ne[1]];
this.extentWgs84 = new Extent(
new LonLat(Math.min(...lons), Math.min(...lats)),
new LonLat(Math.max(...lons), Math.max(...lats))
);
} else {
console.warn(`[GeoTIFFReader] Unrecognized CRS: EPSG:${crsCode}. Assuming bounds are lon/lat.`);
this.extentWgs84 = new Extent(new LonLat(minX, minY), new LonLat(maxX, maxY));
this.crsResolved = false;
}
const samplesPerPixel = baseImage.getSamplesPerPixel();
// GeoTIFF is a color image in another color space, readRGB() returns it as RGB
const photometric = Number(baseImage.getFileDirectory().getValue("PhotometricInterpretation") ?? 2);
this._convertPhotometric = CONVERTED_PHOTOMETRICS.indexOf(photometric) !== -1;
const gdalNoData = parseNoDataValue(baseImage.getGDALNoData());
let userNoData: number | null | undefined = undefined;
if (this.options.renderOptions?.nodata !== undefined) {
userNoData = parseNoDataValue(this.options.renderOptions.nodata);
} else if (this.options.renderOptions?.noData !== undefined) {
userNoData = parseNoDataValue(this.options.renderOptions.noData);
} else if (this.options.nodata !== undefined) {
userNoData = parseNoDataValue(this.options.nodata);
} else if (this.options.noData !== undefined) {
userNoData = parseNoDataValue(this.options.noData);
}
const noData = userNoData !== undefined ? userNoData : (gdalNoData ?? null);
// Sample band min/max statistics from metadata or thumbnail overview
const bands: Record<number, IBandStats> = {};
const previewImage = this.images[this.images.length - 1];
if (samplesPerPixel >= 3) {
for (let i = 0; i < samplesPerPixel; i++) {
const bandNum = i + 1;
const meta = await baseImage.getGDALMetadata(i);
if (meta?.STATISTICS_MINIMUM && meta?.STATISTICS_MAXIMUM) {
bands[bandNum] = {
min: parseFloat(String(meta.STATISTICS_MINIMUM)),
max: parseFloat(String(meta.STATISTICS_MAXIMUM))
};
} else {
bands[bandNum] = { min: 0, max: 255 };
}
}
} else {
let needsSampling = false;
for (let i = 0; i < samplesPerPixel; i++) {
const bandNum = i + 1;
const meta = await baseImage.getGDALMetadata(i);
if (meta?.STATISTICS_MINIMUM && meta?.STATISTICS_MAXIMUM) {
bands[bandNum] = {
min: parseFloat(String(meta.STATISTICS_MINIMUM)),
max: parseFloat(String(meta.STATISTICS_MAXIMUM))
};
} else {
needsSampling = true;
}
}
if (needsSampling && previewImage) {
try {
const sampleRasters = await previewImage.readRasters({
interleave: false,
pool: this.workerPool || undefined
});
for (let i = 0; i < samplesPerPixel; i++) {
const bandNum = i + 1;
if (!bands[bandNum]) {
bands[bandNum] = getRasterMinMax(sampleRasters[i], noData);
}
}
} catch {
for (let i = 0; i < samplesPerPixel; i++) {
const bandNum = i + 1;
if (!bands[bandNum]) {
bands[bandNum] = { min: 0, max: 255 };
}
}
}
}
}
this.metadata = {
bbox: this.nativeBBox,
crsCode: this.crsCode,
crsResolved: this.crsResolved,
width: baseImage.getWidth(),
height: baseImage.getHeight(),
samplesPerPixel,
noData,
isTiled: baseImage.isTiled,
overviewCount: this.images.length,
bands
};
this.isReady = true;
return this.metadata;
}
/**
* Applies rendering options that affect decoding: nodata masking and the resampling method.
*/
public setRenderOptions(renderOptions: IGeoTIFFRenderOptions): void {
this.options.renderOptions = renderOptions;
}
/**
* Reads raster data for a tile and warps it into the tile grid.
*
* Source pixel coordinates are calculated in the nodes of a coarse grid and interpolated in between,
* so rotated grids (UTM and other projected systems) land on the tile without seams.
*
* @param tileExtentLonLat - Tile extent in degrees.
* @param _zoomLevel - Tile zoom level, kept for API compatibility.
* @param tileSize - Output tile size in pixels.
* @param readSamples - Band indexes to read.
* @param segmentProj - Projection of the segment the tile belongs to, EPSG:3857 by default.
*/
public async readTileRasters(
tileExtentLonLat: Extent,
zoomLevel: number,
tileSize: number = 256,
readSamples?: number[],
segmentProj: Proj = EPSG3857
): Promise<DecodedTileData | null> {
if (this._destroyed) {
return null;
}
this._activeReads++;
try {
return await this._readTileRasters(tileExtentLonLat, zoomLevel, tileSize, readSamples, segmentProj);
} finally {
this._activeReads--;
if (this._destroyed && this._activeReads === 0) {
this._releasePool();
}
}
}
private async _readTileRasters(
tileExtentLonLat: Extent,
_zoomLevel: number,
tileSize: number = 256,
readSamples?: number[],
segmentProj: Proj = EPSG3857
): Promise<DecodedTileData | null> {
if (!this.isReady || !this.source || this.images.length === 0) {
return null;
}
// 1. Check spatial intersection with WGS84 extent
if (!this.extentWgs84.overlaps(tileExtentLonLat)) {
return null;
}
const [nativeWest, nativeSouth, nativeEast, nativeNorth] = this.nativeBBox;
const nativeWidth = nativeEast - nativeWest;
const nativeHeight = nativeNorth - nativeSouth;
if (nativeWidth <= 0 || nativeHeight <= 0) return null;
// 2. Sample the tile with a coarse grid: every node gets its position inside the raster,
// normalized to 0..1, where u grows to the east and v grows down the raster rows.
const grid = this._createTileGrid(tileExtentLonLat, tileSize, segmentProj);
if (!grid) return null;
const { uMin, uMax, vMin, vMax } = grid;
if (uMax < 0 || uMin > 1 || vMax < 0 || vMin > 1) {
return null;
}
// 3. Select the overview whose resolution matches the tile resolution
const overviewIndex = this._selectOverviewIndex(grid, tileSize);
const image = this.images[overviewIndex];
if (!image) return null;
const mask = this.masks[overviewIndex];
const imgWidth = image.getWidth();
const imgHeight = image.getHeight();
// 4. Source window covers the whole projected tile boundary, with a one pixel margin.
// A tile may be larger than the raster itself, so the window is clamped to the image.
const winX0 = Math.max(0, Math.floor(Math.max(0, uMin) * imgWidth) - 1);
const winY0 = Math.max(0, Math.floor(Math.max(0, vMin) * imgHeight) - 1);
const winX1 = Math.min(imgWidth, Math.ceil(Math.min(1, uMax) * imgWidth) + 1);
const winY1 = Math.min(imgHeight, Math.ceil(Math.min(1, vMax) * imgHeight) + 1);
if (winX1 <= winX0 || winY1 <= winY0) {
return null;
}
const options: ReadRastersOptions & { interleave: false } = {
window: [winX0, winY0, winX1, winY1],
pool: this.workerPool || undefined,
fillValue: this._getFillValue(),
interleave: false
};
if (readSamples && readSamples.length > 0) {
options.samples = readSamples;
}
try {
// readRGB() converts the samples of a non RGB color space and returns R, G and B
const rastersPromise = this._convertPhotometric
? image.readRGB({
window: options.window,
pool: options.pool,
interleave: false
})
: image.readRasters(options);
// The mask covers the same window as the image and marks the valid pixels of the file
const maskPromise = mask
? mask.readRasters({
window: options.window,
pool: options.pool,
samples: [0],
interleave: false
})
: undefined;
const [rasters, maskRasters] = await Promise.all([rastersPromise, maskPromise]);
const maskRaster = maskRasters?.[0];
return {
rasters: this._warpRasters(
rasters,
grid,
imgWidth,
imgHeight,
[winX0, winY0, winX1, winY1],
tileSize,
maskRaster
),
width: tileSize,
height: tileSize,
dstX: 0,
dstY: 0,
tileSize,
window: [winX0, winY0, winX1, winY1],
isRGB: false
};
} catch (err) {
console.error("[GeoTIFFReader] Error reading rasters:", err);
return null;
}
}
/**
* Projects a lon/lat point into the raster coordinates, normalized to 0..1.
*/
protected _projectToRaster(lon: number, lat: number): [number, number] {
const [nativeWest, nativeSouth, nativeEast, nativeNorth] = this.nativeBBox;
let x: number, y: number;
if (this.crsCode === 4326) {
x = lon;
y = lat;
} else if (this.crsCode === 3857 || this.crsCode === 900913) {
x = mercator.forward_lon(lon);
y = mercator.forward_lat(lat);
} else if (this._projHelper) {
const p = this._projHelper.project([lon, lat]);
x = p[0];
y = p[1];
} else {
x = lon;
y = lat;
}
const u = (x - nativeWest) / (nativeEast - nativeWest);
const v = this.reverseY
? (y - nativeSouth) / (nativeNorth - nativeSouth)
: (nativeNorth - y) / (nativeNorth - nativeSouth);
return [u, v];
}
protected _mercatorLatFunc(north: number, south: number): (t: number) => number {
const top = mercator.forward_lat(north);
const bottom = mercator.forward_lat(south);
return (t: number) => mercator.inverse_lat(top + (bottom - top) * t);
}
protected _lonLatLatFunc(north: number, south: number): (t: number) => number {
return (t: number) => north + (south - north) * t;
}
protected _createLatFunc(segmentProj: Proj, north: number, south: number): (t: number) => number {
return segmentProj.equal(EPSG4326) ? this._lonLatLatFunc(north, south) : this._mercatorLatFunc(north, south);
}
private _buildGrid(west: number, east: number, latFunc: (t: number) => number, gridSize: number): TileGrid {
const side = gridSize + 1;
const gridU = new Float64Array(side * side);
const gridV = new Float64Array(side * side);
let uMin = Infinity,
uMax = -Infinity,
vMin = Infinity,
vMax = -Infinity;
for (let j = 0; j < side; j++) {
const lat = latFunc(j / gridSize);
for (let i = 0; i < side; i++) {
const lon = west + (east - west) * (i / gridSize);
const [u, v] = this._projectToRaster(lon, lat);
const n = j * side + i;
gridU[n] = u;
gridV[n] = v;
if (u < uMin) uMin = u;
if (u > uMax) uMax = u;
if (v < vMin) vMin = v;
if (v > vMax) vMax = v;
}
}
return { gridU, gridV, gridSize, uMin, uMax, vMin, vMax };
}
/**
* Interpolation error of the grid, measured in tile pixels.
* In every cell the interpolated center is compared with the exact projection of that center.
*/
private _gridError(
grid: TileGrid,
west: number,
east: number,
latFunc: (t: number) => number,
tileSize: number
): number {
const baseWidth = this.images[0].getWidth();
const baseHeight = this.images[0].getHeight();
const { gridU, gridV, gridSize } = grid;
const side = gridSize + 1;
const cellSize = tileSize / gridSize;
let maxError = 0;
for (let j = 0; j < gridSize; j++) {
const lat = latFunc((j + 0.5) / gridSize);
for (let i = 0; i < gridSize; i++) {
const lon = west + (east - west) * ((i + 0.5) / gridSize);
const [u, v] = this._projectToRaster(lon, lat);
const n00 = j * side + i;
const n10 = n00 + 1;
const n01 = n00 + side;
const n11 = n01 + 1;
const du = ((gridU[n00] + gridU[n10] + gridU[n01] + gridU[n11]) / 4 - u) * baseWidth;
const dv = ((gridV[n00] + gridV[n10] + gridV[n01] + gridV[n11]) / 4 - v) * baseHeight;
const dx = Math.hypot((gridU[n10] - gridU[n00]) * baseWidth, (gridV[n10] - gridV[n00]) * baseHeight);
const dy = Math.hypot((gridU[n01] - gridU[n00]) * baseWidth, (gridV[n01] - gridV[n00]) * baseHeight);
const density = Math.min(dx, dy) / cellSize;
if (density > 0) {
maxError = Math.max(maxError, Math.hypot(du, dv) / density);
}
}
}
return maxError;
}
/**
* Builds the sampling grid, refining it until the interpolation error becomes small enough.
* Almost affine cases, such as a UTM raster on a zoomed in tile, stay on the coarsest grid.
*/
private _createTileGrid(tileExtentLonLat: Extent, tileSize: number, segmentProj: Proj): TileGrid | null {
const west = tileExtentLonLat.southWest.lon;
const east = tileExtentLonLat.northEast.lon;
const south = tileExtentLonLat.southWest.lat;
const north = tileExtentLonLat.northEast.lat;
if (east <= west || north <= south) return null;
const latFunc = this._createLatFunc(segmentProj, north, south);
let grid = this._buildGrid(west, east, latFunc, GRID_MIN_SIZE);
while (grid.gridSize < GRID_MAX_SIZE && this._gridError(grid, west, east, latFunc, tileSize) > GRID_MAX_ERROR) {
grid = this._buildGrid(west, east, latFunc, grid.gridSize * 2);
}
if (!Number.isFinite(grid.uMin) || !Number.isFinite(grid.vMin)) {
return null;
}
return grid;
}
private _selectOverviewIndex(grid: TileGrid, tileSize: number): number {
if (this.images.length <= 1) {
return 0;
}
const { gridU, gridV, gridSize } = grid;
const baseWidth = this.images[0].getWidth();
const baseHeight = this.images[0].getHeight();
const side = gridSize + 1;
const cellSize = tileSize / gridSize;
// How many full resolution source pixels fall into one tile pixel
let density = 0;
for (let j = 0; j < side; j++) {
for (let i = 0; i < gridSize; i++) {
const n = j * side + i;
const du = Math.abs(gridU[n + 1] - gridU[n]) * baseWidth;
const dv = Math.abs(gridV[n + 1] - gridV[n]) * baseHeight;
density = Math.max(density, Math.hypot(du, dv) / cellSize);
}
}
for (let j = 0; j < gridSize; j++) {
for (let i = 0; i < side; i++) {
const n = j * side + i;
const du = Math.abs(gridU[n + side] - gridU[n]) * baseWidth;
const dv = Math.abs(gridV[n + side] - gridV[n]) * baseHeight;
density = Math.max(density, Math.hypot(du, dv) / cellSize);
}
}
if (!(density > 1)) {
return 0;
}
const maxFactor = density * OVERVIEW_RESOLUTION_TOLERANCE;
for (let i = this.images.length - 1; i > 0; i--) {
if (baseWidth / this.images[i].getWidth() <= maxFactor) {
return i;
}
}
return 0;
}
private _warpRasters(
rasters: TypedArray[],
grid: TileGrid,
imgWidth: number,
imgHeight: number,
window: [number, number, number, number],
tileSize: number,
maskRaster?: TypedArray
): Float32Array[] {
const [winX0, winY0, winX1, winY1] = window;
const winWidth = winX1 - winX0;
const winHeight = winY1 - winY0;
const { gridU, gridV, gridSize } = grid;
const side = gridSize + 1;
const cellSize = tileSize / gridSize;
const bilinear = this.options.renderOptions?.resampleMethod === "bilinear";
const nodata = this._getEffectiveNoData();
const checkNoData = nodata !== null ? getFastNoDataChecker(nodata) : null;
const out: Float32Array[] = [];
for (let b = 0; b < rasters.length; b++) {
out.push(new Float32Array(tileSize * tileSize).fill(NaN));
}
for (let y = 0; y < tileSize; y++) {
// Grid nodes sit on tile pixel borders, so pixel centers are used here
const gy = (y + 0.5) / cellSize;
const j0 = Math.min(gridSize - 1, gy | 0);
const fy = gy - j0;
for (let x = 0; x < tileSize; x++) {
const gx = (x + 0.5) / cellSize;
const i0 = Math.min(gridSize - 1, gx | 0);
const fx = gx - i0;
// Bilinear interpolation of the source position between the grid nodes
const n00 = j0 * side + i0;
const n10 = n00 + 1;
const n01 = n00 + side;
const n11 = n01 + 1;
const w00 = (1 - fx) * (1 - fy);
const w10 = fx * (1 - fy);
const w01 = (1 - fx) * fy;
const w11 = fx * fy;
const u = gridU[n00] * w00 + gridU[n10] * w10 + gridU[n01] * w01 + gridU[n11] * w11;
const v = gridV[n00] * w00 + gridV[n10] * w10 + gridV[n01] * w01 + gridV[n11] * w11;
if (u < 0 || u >= 1 || v < 0 || v >= 1) continue;
// Source position inside the window, in pixel centers
const sx = u * imgWidth - winX0 - 0.5;
const sy = v * imgHeight - winY0 - 0.5;
const outIdx = y * tileSize + x;
// Pixels the mask marks as invalid stay NaN, which is rendered as transparent
if (maskRaster) {
const mx = Math.max(0, Math.min(winWidth - 1, Math.round(sx)));
const my = Math.max(0, Math.min(winHeight - 1, Math.round(sy)));
if (!maskRaster[my * winWidth + mx]) continue;
}
if (bilinear) {
const x0 = Math.floor(sx);
const y0 = Math.floor(sy);
const tx = sx - x0;
const ty = sy - y0;
const x1 = Math.min(winWidth - 1, x0 + 1);
const y1 = Math.min(winHeight - 1, y0 + 1);
const cx0 = Math.max(0, Math.min(winWidth - 1, x0));
const cy0 = Math.max(0, Math.min(winHeight - 1, y0));
const i00 = cy0 * winWidth + cx0;
const i10 = cy0 * winWidth + x1;
const i01 = y1 * winWidth + cx0;
const i11 = y1 * winWidth + x1;
for (let b = 0; b < rasters.length; b++) {
const src = rasters[b];
const v00 = src[i00],
v10 = src[i10],
v01 = src[i01],
v11 = src[i11];
if (
checkNoData
? checkNoData(v00) || checkNoData(v10) || checkNoData(v01) || checkNoData(v11)
: Number.isNaN(v00) || Number.isNaN(v10) || Number.isNaN(v01) || Number.isNaN(v11)
) {
continue;
}
out[b][outIdx] = (v00 * (1 - tx) + v10 * tx) * (1 - ty) + (v01 * (1 - tx) + v11 * tx) * ty;
}
} else {
const px = Math.max(0, Math.min(winWidth - 1, Math.round(sx)));
const py = Math.max(0, Math.min(winHeight - 1, Math.round(sy)));
const srcIdx = py * winWidth + px;
for (let b = 0; b < rasters.length; b++) {
const val = rasters[b][srcIdx];
if (checkNoData ? checkNoData(val) : Number.isNaN(val)) continue;
out[b][outIdx] = val;
}
}
}
}
return out;
}
/**
* NoData value that masks pixels, or null when masking is turned off.
* Layer options take precedence over the file metadata.
*/
private _getEffectiveNoData(): number | null {
let userNoData: number | null | undefined = undefined;
if (this.options.renderOptions?.nodata !== undefined) {
userNoData = parseNoDataValue(this.options.renderOptions.nodata);
} else if (this.options.renderOptions?.noData !== undefined) {
userNoData = parseNoDataValue(this.options.renderOptions.noData);
} else if (this.options.nodata !== undefined) {
userNoData = parseNoDataValue(this.options.nodata);
} else if (this.options.noData !== undefined) {
userNoData = parseNoDataValue(this.options.noData);
}
if (userNoData !== undefined) {
return userNoData !== null && !Number.isNaN(userNoData) ? userNoData : null;
}
const metaNoData = this.metadata?.noData;
return metaNoData !== null && metaNoData !== undefined && !Number.isNaN(metaNoData) ? metaNoData : null;
}
private _getFillValue(): number | undefined {
return this._getEffectiveNoData() ?? undefined;
}
private _checkIfReversed(image: GeoTIFFImage): boolean {
const fileDirectory = image.getFileDirectory();
const pixelScale = fileDirectory.getValue("ModelPixelScale");
if (pixelScale && pixelScale[1] < 0) {
return true;
}
const transform = fileDirectory.getValue("ModelTransformation");
if (transform && transform[7] > transform[3]) {
return true;
}
return false;
}
private _releasePool(): void {
if (this._poolHandle) {
this._poolHandle.release();
this._poolHandle = null;
}
this.workerPool = null;
}
/**
* Cleans up worker pool and cached images.
*/
public destroy(): void {
this._destroyed = true;
this.images = [];
this.masks = [];
this.source = null;
this.isReady = false;
if (this._activeReads === 0) {
this._releasePool();
}
}
}