layer/geotiff/MultiGeoTIFFReader.tstypescript
import {Extent} from "../../Extent";
import {EPSG3857} from "../../proj/EPSG3857";
import type {Proj} from "../../proj/Proj";
import type {Pool} from "geotiff";
import {GeoTIFFReader} from "./GeoTIFFReader";
import {parseNoDataValue} from "./ColorScale";
import {acquireWorkerPool, type IWorkerPoolHandle} from "./workerPool";
import type {
DecodedTileData,
IBandStats,
IGeoTIFFMetadata,
IGeoTIFFReader,
IGeoTIFFReaderParams,
IGeoTIFFRenderOptions,
IGeoTIFFSourceItem
} from "./types";
/**
* Maps a global band of the stacked layer onto a band of one source.
* The global band number is the position in `_bandMappings` plus one.
*/
interface IBandMapping {
sourceIndex: number;
sourceBand: number; // 1-indexed
}
/**
* MultiGeoTIFFReader manages multiple Cloud-Optimized GeoTIFF (COG) sources,
* enabling multi-band compositing (e.g. RGB or NDVI across separate band files)
* and spatial mosaics (merging adjacent COG tiles into a unified layer).
*/
export class MultiGeoTIFFReader implements IGeoTIFFReader {
public options: IGeoTIFFReaderParams;
public childReaders: GeoTIFFReader[] = [];
public sources: IGeoTIFFSourceItem[] = [];
public metadata: IGeoTIFFMetadata | null = null;
public extentWgs84: Extent = new Extent();
public isReady: boolean = false;
public crsCode: number = 4326;
/** Whether every source could be georeferenced, see {@link IGeoTIFFMetadata.crsResolved} */
public crsResolved: boolean = true;
public workerPool: Pool | null = null;
private _poolHandle: IWorkerPoolHandle | null = null;
private _bandMappings: IBandMapping[] = [];
private _activeReads: number = 0;
private _destroyed: boolean = false;
constructor(options: IGeoTIFFReaderParams = {}) {
this.options = options;
}
/**
* Initializes all GeoTIFF sources and builds unified extent and band mappings.
*/
public async init(sources?: IGeoTIFFSourceItem[]): Promise<IGeoTIFFMetadata> {
const srcList = sources || this.options.sources;
if (!srcList || srcList.length === 0) {
throw new Error("[MultiGeoTIFFReader] No sources provided.");
}
this.sources = srcList;
this._destroyed = false;
this._releasePool();
this._poolHandle = acquireWorkerPool(this.options);
this.workerPool = this._poolHandle.pool;
// Create child readers sharing the worker pool
this.childReaders = srcList.map((s) => {
const nodata =
s.nodata !== undefined
? parseNoDataValue(s.nodata)
: s.noData !== undefined
? parseNoDataValue(s.noData)
: this.options.nodata !== undefined
? parseNoDataValue(this.options.nodata)
: this.options.noData !== undefined
? parseNoDataValue(this.options.noData)
: undefined;
return new GeoTIFFReader({
...this.options,
crs: s.crs ?? this.options.crs,
nodata,
requestOptions: {
...this.options.requestOptions,
...s.requestOptions
},
geotiffWorkerPool: this.workerPool || undefined,
workerPoolSize: 0
});
});
// Initialize all child readers in parallel
const metas = await Promise.all(
this.childReaders.map((reader, i) => {
const s = srcList[i];
const src = s.src || s.url;
if (!src) {
throw new Error(`[MultiGeoTIFFReader] Source at index ${i} has no src or url.`);
}
return reader.init(src);
})
);
// Compute unified extentWgs84
let unionExtent: Extent | null = null;
for (let i = 0; i < this.childReaders.length; i++) {
const ext = this.childReaders[i].extentWgs84;
if (ext) {
unionExtent = unionExtent ? unionExtent.createUnion(ext) : ext.clone();
}
}
if (!unionExtent) {
unionExtent = new Extent();
}
this.extentWgs84 = unionExtent;
// `metadata.bbox` is expressed in `metadata.crsCode`, the same contract GeoTIFFReader follows.
// Sources that share a CRS keep their native bounds, a mixed CRS set falls back to WGS84.
const firstCrs = this.childReaders[0]?.crsCode ?? 4326;
const isSharedCrs = this.childReaders.every((reader) => reader.crsCode === firstCrs);
this.crsCode = isSharedCrs ? firstCrs : 4326;
this.crsResolved = metas.every((meta) => meta.crsResolved);
const bbox: [number, number, number, number] = isSharedCrs
? this._unionNativeBBox()
: [
unionExtent.southWest.lon,
unionExtent.southWest.lat,
unionExtent.northEast.lon,
unionExtent.northEast.lat
];
const isMosaic = Boolean(this.options.mosaic);
const bandsMeta: Record<number, IBandStats> = {};
if (isMosaic) {
// Mosaic mode: all sources contribute to the same bands spatially
const baseSamples = metas[0].samplesPerPixel || 1;
for (let b = 1; b <= baseSamples; b++) {
let bMin = Infinity;
let bMax = -Infinity;
for (let i = 0; i < metas.length; i++) {
const s = srcList[i];
const meta = metas[i];
const min = s.min !== undefined ? s.min : (meta.bands[b]?.min ?? 0);
const max = s.max !== undefined ? s.max : (meta.bands[b]?.max ?? 255);
if (min < bMin) bMin = min;
if (max > bMax) bMax = max;
}
bandsMeta[b] = {
min: Number.isFinite(bMin) ? bMin : 0,
max: Number.isFinite(bMax) ? bMax : 255
};
}
this.metadata = {
bbox,
crsCode: this.crsCode,
crsResolved: this.crsResolved,
width: Math.max(...metas.map((m) => m.width)),
height: Math.max(...metas.map((m) => m.height)),
samplesPerPixel: baseSamples,
noData: metas[0].noData,
isTiled: metas.every((m) => m.isTiled),
overviewCount: Math.max(...metas.map((m) => m.overviewCount)),
bands: bandsMeta
};
} else {
// Multi-band mode (default): stack selected bands sequentially across sources
this._bandMappings = [];
let globalBand = 1;
for (let i = 0; i < srcList.length; i++) {
const s = srcList[i];
const meta = metas[i];
const childSamples = meta.samplesPerPixel || 1;
const requestedBands =
s.bands && s.bands.length > 0 ? s.bands : Array.from({length: childSamples}, (_, idx) => idx + 1);
for (const b of requestedBands) {
const min = s.min !== undefined ? s.min : (meta.bands[b]?.min ?? 0);
const max = s.max !== undefined ? s.max : (meta.bands[b]?.max ?? 255);
this._bandMappings.push({
sourceIndex: i,
sourceBand: b
});
bandsMeta[globalBand] = {min, max};
globalBand++;
}
}
this.metadata = {
bbox,
crsCode: this.crsCode,
crsResolved: this.crsResolved,
width: Math.max(...metas.map((m) => m.width)),
height: Math.max(...metas.map((m) => m.height)),
samplesPerPixel: this._bandMappings.length,
noData: metas[0].noData,
isTiled: metas.every((m) => m.isTiled),
overviewCount: Math.max(...metas.map((m) => m.overviewCount)),
bands: bandsMeta
};
}
this.isReady = true;
return this.metadata;
}
/**
* Union of the native bounding boxes of all sources, valid only when they share a CRS.
*/
private _unionNativeBBox(): [number, number, number, number] {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const reader of this.childReaders) {
const [west, south, east, north] = reader.nativeBBox;
if (west < minX) minX = west;
if (south < minY) minY = south;
if (east > maxX) maxX = east;
if (north > maxY) maxY = north;
}
if (!Number.isFinite(minX)) {
return [0, 0, 0, 0];
}
return [minX, minY, maxX, maxY];
}
/**
* Applies rendering options to this reader and to every child reader.
*/
public setRenderOptions(renderOptions: IGeoTIFFRenderOptions): void {
this.options.renderOptions = renderOptions;
for (let i = 0; i < this.childReaders.length; i++) {
const source = this.sources[i];
const hasOwnNoData = source && (source.nodata !== undefined || source.noData !== undefined);
this.childReaders[i].setRenderOptions(
hasOwnNoData ? {...renderOptions, nodata: undefined, noData: undefined} : renderOptions
);
}
}
/**
* Reads rasters for a tile from the underlying GeoTIFF sources.
*/
public async readTileRasters(
tileExtentLonLat: Extent,
zoomLevel: number,
tileSize: number = 256,
readSamples?: number[],
segmentProj: Proj = EPSG3857
): Promise<DecodedTileData | null> {
if (this._destroyed || !this.isReady || this.childReaders.length === 0) {
return null;
}
if (!this.extentWgs84.overlaps(tileExtentLonLat)) {
return null;
}
this._activeReads++;
try {
return this.options.mosaic
? await this._readMosaicTileRasters(tileExtentLonLat, zoomLevel, tileSize, readSamples, segmentProj)
: await this._readMultiBandTileRasters(tileExtentLonLat, zoomLevel, tileSize, readSamples, segmentProj);
} finally {
this._activeReads--;
if (this._destroyed && this._activeReads === 0) {
this._releasePool();
}
}
}
private async _readMultiBandTileRasters(
tileExtentLonLat: Extent,
zoomLevel: number,
tileSize: number,
readSamples?: number[],
segmentProj: Proj = EPSG3857
): Promise<DecodedTileData | null> {
const samplesToRead =
readSamples && readSamples.length > 0 ? readSamples : this._bandMappings.map((_, idx) => idx);
// Group requested global bands by child reader
const sourceSamplesMap = new Map<number, number[]>();
for (const sampleIdx of samplesToRead) {
const mapping = this._bandMappings[sampleIdx];
if (mapping) {
const localSample = mapping.sourceBand - 1;
let list = sourceSamplesMap.get(mapping.sourceIndex);
if (!list) {
list = [];
sourceSamplesMap.set(mapping.sourceIndex, list);
}
if (!list.includes(localSample)) {
list.push(localSample);
}
}
}
// Query only sources that overlap the requested tile extent
const sourceResults = new Map<number, { localSamples: number[]; tileData: DecodedTileData | null }>();
const readPromises: Promise<void>[] = [];
for (const [sourceIndex, localSamples] of sourceSamplesMap.entries()) {
const reader = this.childReaders[sourceIndex];
if (!reader.extentWgs84.overlaps(tileExtentLonLat)) {
sourceResults.set(sourceIndex, {localSamples, tileData: null});
continue;
}
readPromises.push(
reader
.readTileRasters(tileExtentLonLat, zoomLevel, tileSize, localSamples, segmentProj)
.then((tileData) => {
sourceResults.set(sourceIndex, {localSamples, tileData});
})
.catch((err) => {
console.error(`[MultiGeoTIFFReader] Error reading source ${sourceIndex}:`, err);
sourceResults.set(sourceIndex, {localSamples, tileData: null});
})
);
}
await Promise.all(readPromises);
// Check if at least one source produced valid raster data
let anyValid = false;
for (const res of sourceResults.values()) {
if (res.tileData && res.tileData.rasters && res.tileData.rasters.length > 0) {
anyValid = true;
break;
}
}
if (!anyValid) {
return null;
}
// Stack output rasters in the exact requested order
const pixelCount = tileSize * tileSize;
const outRasters: Float32Array[] = [];
for (const sampleIdx of samplesToRead) {
const mapping = this._bandMappings[sampleIdx];
if (!mapping) {
outRasters.push(new Float32Array(pixelCount).fill(NaN));
continue;
}
const res = sourceResults.get(mapping.sourceIndex);
if (res && res.tileData && res.tileData.rasters) {
const localSample = mapping.sourceBand - 1;
const localOrderIdx = res.localSamples.indexOf(localSample);
const r = localOrderIdx !== -1 ? res.tileData.rasters[localOrderIdx] : null;
if (r) {
outRasters.push(r as Float32Array);
} else {
outRasters.push(new Float32Array(pixelCount).fill(NaN));
}
} else {
outRasters.push(new Float32Array(pixelCount).fill(NaN));
}
}
return {
rasters: outRasters,
width: tileSize,
height: tileSize,
dstX: 0,
dstY: 0,
tileSize,
window: [0, 0, tileSize, tileSize],
isRGB: false
};
}
private async _readMosaicTileRasters(
tileExtentLonLat: Extent,
zoomLevel: number,
tileSize: number,
readSamples?: number[],
segmentProj: Proj = EPSG3857
): Promise<DecodedTileData | null> {
const overlappingIndices: number[] = [];
for (let i = 0; i < this.childReaders.length; i++) {
if (this.childReaders[i].extentWgs84.overlaps(tileExtentLonLat)) {
overlappingIndices.push(i);
}
}
if (overlappingIndices.length === 0) {
return null;
}
const results = await Promise.all(
overlappingIndices.map((idx) =>
this.childReaders[idx]
.readTileRasters(tileExtentLonLat, zoomLevel, tileSize, readSamples, segmentProj)
.catch(() => null)
)
);
const validResults: DecodedTileData[] = results.filter(
(r): r is DecodedTileData => r !== null && Boolean(r.rasters && r.rasters.length > 0)
);
if (validResults.length === 0) {
return null;
}
// Most mosaic tiles fall inside a single source, and its rasters are already
// warped into the tile grid, so there is nothing to merge
if (validResults.length === 1) {
return {...validResults[0], window: [0, 0, tileSize, tileSize]};
}
const numBands = validResults[0].rasters.length;
const pixelCount = tileSize * tileSize;
const outRasters: Float32Array[] = [];
for (let b = 0; b < numBands; b++) {
// _warpRasters allocates a fresh buffer per read, so the first source is filled in place
const merged = validResults[0].rasters[b] as Float32Array;
for (let s = 1; s < validResults.length; s++) {
const r = validResults[s].rasters[b];
if (!r) continue;
for (let p = 0; p < pixelCount; p++) {
const val = r[p];
if (!Number.isNaN(val) && Number.isNaN(merged[p])) {
merged[p] = val;
}
}
}
outRasters.push(merged);
}
return {
rasters: outRasters,
width: tileSize,
height: tileSize,
dstX: 0,
dstY: 0,
tileSize,
window: [0, 0, tileSize, tileSize],
isRGB: false
};
}
private _releasePool(): void {
if (this._poolHandle) {
this._poolHandle.release();
this._poolHandle = null;
}
this.workerPool = null;
}
/**
* Cleans up child readers and shared worker pool.
*/
public destroy(): void {
this._destroyed = true;
for (const reader of this.childReaders) {
reader.destroy();
}
this.childReaders = [];
this.isReady = false;
this.metadata = null;
if (this._activeReads === 0) {
this._releasePool();
}
}
}