@giro3d/giro3d
Version:
A JS/WebGL framework for 3D geospatial data visualization
625 lines (595 loc) • 19.5 kB
JavaScript
import { FloatType, MathUtils, Texture, UnsignedByteType, Vector2 } from 'three';
import { BaseClient, BaseResponse, fromCustomClient, globals as geotiffGlobals, Pool } from 'geotiff';
import { GlobalCache } from '../core/Cache';
import Extent from '../core/geographic/Extent';
import Fetcher from '../utils/Fetcher';
import PromiseUtils from '../utils/PromiseUtils';
import TextureGenerator from '../utils/TextureGenerator';
import { nonNull } from '../utils/tsutils';
import ConcurrentDownloader from './ConcurrentDownloader';
import ImageSource, { ImageResult } from './ImageSource';
const tmpDim = new Vector2();
let sharedPool = undefined;
/**
* How the samples in the GeoTIFF files (also
* known as bands), are mapped to the color channels of an RGB(A) image.
*
* Must be an array of either 1, 3 or 4 elements. Each element is the index of a sample in the
* source file. For example, to map the samples 0, 3, and 2 to the R, G, B colors, you can use
* `[0, 3, 2]`.
*
* - 1 element means the resulting image will be a grayscale image
* - 3 elements means the resulting image will be a RGB image
* - 4 elements means the resulting image will be a RGB image with an alpha channel.
*
* Note: if the channels is `undefined`, then they will be selected automatically with the
* following rules: if the image has 3 or more samples, the first 3 samples will be used,
* (i.e `[0, 1, 2]`). Otherwise, only the first sample will be used (i.e `[0]`). In any case,
* no transparency channel will be selected automatically, as there is no way to determine
* if a specific sample represents transparency.
*
* ## Examples
*
* - I have a color image, but I only want to see the blue channel (sample = 1): `[1]`
* - I have a grayscale image, with only 1 sample: `[0]`
* - I have a grayscale image with a transparency channel at index 1: `[0, 0, 0, 1]`
* - I have a color image without a transparency channel: `[0, 1, 2]`
* - I have a color image with a transparency channel at index 3: `[0, 1, 2, 3]`
* - I have a color image with transparency at index 3, but I only want to see the blue channel:
* `[1, 1, 1, 3]`
* - I have a color image but in the B, G, R order: `[2, 1, 0]`
*/
function getPool() {
if (sharedPool == null && window.Worker != null) {
sharedPool = new Pool();
}
return sharedPool;
}
/**
* Determine if an image type is a mask.
* See https://www.awaresystems.be/imaging/tiff/tifftags/newsubfiletype.html
* Note: this function is taken from OpenLayers (GeoTIFF.js)
* @param image - The image.
* @returns `true` if the image is a mask.
*/
function isMask(image) {
const FILETYPE_MASK = 4;
const fileDirectory = image.fileDirectory;
const type = fileDirectory.NewSubfileType ?? 0;
return (type & FILETYPE_MASK) === FILETYPE_MASK;
}
/**
* Determines if we can safely use the `readRGB()` method from geotiff.js for this image.
*/
function canReadRGB(image) {
if (image.getSamplesPerPixel() !== 3) {
return false;
}
if (image.getBitsPerSample() > 8) {
return false;
}
const interpretation = image.fileDirectory.PhotometricInterpretation;
const interpretations = geotiffGlobals.photometricInterpretations;
return interpretation === interpretations.CMYK || interpretation === interpretations.YCbCr || interpretation === interpretations.CIELab || interpretation === interpretations.ICCLab;
}
export class FetcherResponse extends BaseResponse {
/**
* BaseResponse facade for fetch API Response
*
* @param response - The response.
*/
constructor(response) {
super();
this.response = response;
}
// @ts-expect-error (the base class does not type this getter)
get status() {
return this.response.status;
}
getHeader(name) {
return this.response.headers.get(name);
}
// @ts-expect-error (incorrectly typed base method, should be a Promise, but is an ArrayBuffer)
async getData() {
const data = await this.response.arrayBuffer();
return data;
}
}
/**
* A custom geotiff.js client that uses the Fetcher in order
* to centralize requests and benefit from the HTTP configuration module.
*/
class FetcherClient extends BaseClient {
_downloader = new ConcurrentDownloader({
fetch: Fetcher.fetch,
retry: 3,
timeout: 10000
});
constructor(url, options) {
super(url);
this._priority = options.priority;
}
// @ts-expect-error (untyped base method)
async request({
headers,
credentials,
signal
} = {}) {
const response = await this._downloader.fetch(this.url, {
headers,
credentials,
signal,
priority: this._priority
});
return new FetcherResponse(response);
}
}
/**
* A level in the GeoTIFF pyramid.
*/
function selectDataType(format, bitsPerSample) {
switch (format) {
case 1:
// unsigned integer data
if (bitsPerSample <= 8) {
return UnsignedByteType;
}
break;
default:
break;
}
return FloatType;
}
/**
* Provides data from a remote GeoTIFF file.
*
* Features:
* - supports tiled and untiled TIFF images
* - supports [Cloud Optimized GeoTIFF (COG)](https://www.cogeo.org/),
* - supports various compression (LZW, DEFLATE, JPEG...)
* - supports RGB and YCbCr color spaces
* - supports grayscale (e.g elevation data) and color images,
* - support high-dynamic range colors (8-bit, 16-bit and 32-bit floating point pixels),
* - dynamic channel mapping,
*
* Note: performance might be degraded if the GeoTIFF is not optimized for streaming. We recommend
* using [Cloud Optimized GeoTIFFs (COGs)](https://www.cogeo.org/) for best performance.
*/
class GeoTIFFSource extends ImageSource {
isGeoTIFFSource = true;
type = 'GeoTIFFSource';
_cacheId = MathUtils.generateUUID();
_cache = GlobalCache;
_channels = [0, 1, 2];
// Fields available after initialization
_initialized = false;
/**
* Creates a {@link GeoTIFFSource} source.
*
* @param options - options
*/
constructor(options) {
super({
...options,
flipY: options.flipY ?? true
});
this.url = options.url;
this.crs = options.crs;
this._enableWorkers = options.enableWorkers ?? true;
this._pool = this._enableWorkers ? getPool() : undefined;
this._imageCount = 0;
this._images = [];
this._masks = [];
this._channels = options.channels ?? this._channels;
this._cacheOptions = options.cacheOptions;
}
getInternalCache() {
if (!this._tiffImage) {
return undefined;
}
const source = this._tiffImage.source;
return source.blockCache;
}
getMemoryUsage(context) {
if (!this._tiffImage) {
return;
}
const cache = this.getInternalCache();
if (cache) {
let bytes = 0;
cache.forEach(block => {
bytes += block.data.byteLength;
});
context.objects.set(`${this.type}-${this._cacheId}`, {
cpuMemory: bytes,
gpuMemory: 0
});
}
}
getExtent() {
return nonNull(this._extent);
}
getCrs() {
return this.crs;
}
/**
* Attemps to compute the exact extent of the TIFF image.
*
* @param crs - The CRS.
* @param tiffImage - The TIFF image.
*/
static computeExtent(crs, tiffImage) {
const [minx, miny, maxx, maxy] = tiffImage.getBoundingBox();
const extent = new Extent(crs, minx, maxx, miny, maxy);
return extent;
}
/**
* @param requestExtent - The request extent.
* @param requestWidth - The width, in pixels, of the request extent.
* @param requestHeight - The height, in pixels, of the request extent.
* @param margin - The margin, in pixels.
* @returns The adjusted parameters.
*/
adjustExtentAndPixelSize(requestExtent, requestWidth, requestHeight, margin = 0) {
const {
image
} = this.selectLevel(requestExtent, requestWidth, requestHeight);
const dims = nonNull(this._dimensions);
const pixelWidth = dims.x / image.width;
const pixelHeight = dims.y / image.height;
const marginExtent = requestExtent.withMargin(pixelWidth * margin, pixelHeight * margin);
const adjustedWidth = Math.floor(marginExtent.dimensions(tmpDim).x / pixelWidth);
const adjustedHeight = Math.floor(marginExtent.dimensions(tmpDim).y / pixelHeight);
let width = requestWidth;
let height = requestHeight;
// Ensure that we are not returning texture sizes that are too big, which can
// happen when the source is much smaller than the map that hosts it.
const threshold = 100; // pixels
if (adjustedWidth < requestWidth + threshold && adjustedHeight < requestHeight + threshold) {
width = adjustedWidth;
height = adjustedHeight;
}
return {
extent: marginExtent,
width,
height
};
}
initialize() {
if (!this._initializePromise) {
this._initializePromise = this.initializeOnce();
}
return this._initializePromise;
}
async initializeOnce() {
if (this._initialized) {
return;
}
const opts = {
cacheSize: this._cacheOptions?.cacheSize,
blockSize: this._cacheOptions?.blockSize
};
const url = this.url;
const client = new FetcherClient(url, {
priority: this.priority
});
// We are using a custom client to ensure that outgoing requests are done through
// the Fetcher so we can benefit from automatic HTTP configuration and control over
// outgoing requests.
// @ts-expect-error (typing issue with geotiff.js)
this._tiffImage = await fromCustomClient(client, opts);
// Get original image header
const firstImage = await this._tiffImage.getImage();
this._extent = GeoTIFFSource.computeExtent(this.crs, firstImage);
this._dimensions = this._extent.dimensions();
this._origin = firstImage.getOrigin();
// Samples are equivalent to GDAL's bands
this._sampleCount = firstImage.getSamplesPerPixel();
// Automatic selection of channels, if the user did not specify a mapping.
if (this._sampleCount < this._channels.length) {
this._channels = [0];
}
this._nodata = firstImage.getGDALNoData();
const format = firstImage.getSampleFormat();
const bps = firstImage.getBitsPerSample();
this.datatype = selectDataType(format, bps);
function makeLevel(image, resolution) {
return {
image,
width: image.getWidth(),
height: image.getHeight(),
resolution
};
}
this._images.push(makeLevel(firstImage, firstImage.getResolution()));
const rawImageCount = await this._tiffImage.getImageCount();
let nonMaskImageCount = 1; // Includes the main image previously pushed
// We want to preserve the order of the overviews so we await them inside
// the loop not to have the smallest overviews coming before the biggest
for (let i = 1; i < rawImageCount; i++) {
const image = await this._tiffImage.getImage(i);
const level = makeLevel(image, image.getResolution(firstImage));
if (isMask(image)) {
this._masks.push(level);
} else {
nonMaskImageCount++;
this._images.push(level);
}
}
// Number of images (original + overviews)
this._imageCount = nonMaskImageCount;
this._initialized = true;
}
/**
* Returns a window in the image's coordinates that matches the requested extent.
*
* @param extent - The window extent.
* @param resolution - The spatial resolution of the window.
* @returns The window.
*/
makeWindowFromExtent(extent, resolution) {
const [oX, oY] = nonNull(this._origin);
const [imageResX, imageResY] = resolution;
const ext = extent.values;
const wnd = [Math.round((ext[0] - oX) / imageResX), Math.round((ext[2] - oY) / imageResY), Math.round((ext[1] - oX) / imageResX), Math.round((ext[3] - oY) / imageResY)];
const xmin = Math.min(wnd[0], wnd[2]);
let xmax = Math.max(wnd[0], wnd[2]);
const ymin = Math.min(wnd[1], wnd[3]);
let ymax = Math.max(wnd[1], wnd[3]);
// prevent zero-sized requests
if (Math.abs(xmax - xmin) === 0) {
xmax += 1;
}
if (Math.abs(ymax - ymin) === 0) {
ymax += 1;
}
return [xmin, ymin, xmax, ymax];
}
/**
* Creates a texture from the pixel buffer(s).
*
* @param buffers - The buffers (one buffer per band)
* @returns The generated texture.
*/
async createTexture(buffers) {
// Width and height in pixels of the returned data.
// The geotiff.js patches the arrays with the width and height properties.
const {
width,
height
} = buffers;
const dataType = this.datatype;
const {
texture,
min,
max
} = await TextureGenerator.createDataTextureAsync({
width,
height,
nodata: this._nodata ?? undefined,
enableWorkers: this._enableWorkers
}, dataType, ...buffers);
return {
texture,
min,
max
};
}
/**
* Select the best overview level (or the final image) to match the
* requested extent and pixel width and height.
*
* @param requestExtent - The window extent.
* @param requestWidth - The pixel width of the window.
* @param requestHeight - The pixel height of the window.
* @returns The selected zoom level.
*/
selectLevel(requestExtent, requestWidth, requestHeight) {
// Number of images = original + overviews if any
const imageCount = this._imageCount;
const cropped = requestExtent.clone().intersect(nonNull(this._extent));
// Dimensions of the requested extent
const extentDimension = cropped.dimensions(tmpDim);
const targetResolution = Math.min(extentDimension.x / requestWidth, extentDimension.y / requestHeight);
let image = this._images[imageCount - 1];
let mask = this._masks[imageCount - 1];
// Select the image with the best resolution for our needs
for (let i = imageCount - 1; i >= 0; i--) {
image = this._images[i];
mask = this._masks[i];
const dims = nonNull(this._dimensions);
const sourceResolution = Math.min(dims.x / image.width, dims.y / image.height);
if (targetResolution >= sourceResolution) {
break;
}
}
return {
image,
mask
};
}
/**
* Gets or sets the channel mapping.
*/
get channels() {
return this._channels;
}
set channels(value) {
if (value == null) {
throw new Error('expected non-null value');
}
const length = value.length;
if (!(length === 1 || length === 3 || length === 4)) {
throw new Error(`channels must be either a 1, 3 or 4 element array, got: ${length}`);
}
this._channels = value;
this.update();
}
async loadImage(opts) {
const {
extent,
width,
height,
id,
signal
} = opts;
const {
image,
mask
} = this.selectLevel(extent, width, height);
const adjusted = extent.fitToGrid(nonNull(this._extent), image.width, image.height, 8, 8);
const actualExtent = adjusted.extent;
const buffers = await this.getRegionBuffers(actualExtent, image, this._channels, signal, id);
signal?.throwIfAborted();
let texture;
let min = undefined;
let max = undefined;
if (buffers == null) {
texture = new Texture();
} else {
if (mask != null && buffers.length === 3) {
const alpha = await this.processTransparencyMask(mask, actualExtent, signal, id);
if (alpha) {
buffers.push(alpha);
}
}
const result = await this.createTexture(buffers);
texture = result.texture;
min = result.min;
max = result.max;
}
const result = {
extent: actualExtent,
texture,
id,
min,
max
};
return new ImageResult(result);
}
async processTransparencyMask(mask, extent, signal, id) {
const bufs = await this.getRegionBuffers(extent, mask, [0], signal, id);
if (!bufs) {
return null;
}
const alpha = bufs[0];
const is1bit = mask.image.getBitsPerSample() === 1;
// Peform 8-bit expansion
if (is1bit) {
for (let i = 0; i < alpha.length; i++) {
alpha[i] = alpha[i] * 255;
}
}
return alpha;
}
async readWindow(image, window, channels, signal) {
if (canReadRGB(image)) {
return await image.readRGB({
pool: this._pool,
window,
signal,
interleave: false
});
}
// TODO possible optimization: instead of letting geotiff.js crop and resample
// the tiles into the desired region, we could use image.getTileOrStrip() to
// read individual tiles (aka blocks) and make a texture per block. This way,
// there would not be multiple concurrent reads for the same block, and we would not
// waste time resampling the blocks since resampling is already done in the composer.
// We would create more textures, but it could be worth it.
const buf = await image.readRasters({
pool: this._pool,
fillValue: this._nodata ?? undefined,
samples: channels,
window,
signal
});
return buf;
}
/**
* @param image - The image to read.
* @param window - The image region to read.
* @param signal - The abort signal.
* @returns The buffers.
*/
async fetchBuffer(image, window, channels, signal) {
signal?.throwIfAborted();
try {
return await this.readWindow(image, window, channels, signal);
} catch (e) {
if (e instanceof Error) {
if (e.toString() === 'AggregateError: Request failed') {
// Problem with the source that is blocked by another fetch
// (request failed in readRasters). See the conversations in
// https://github.com/geotiffjs/geotiff.js/issues/218
// https://github.com/geotiffjs/geotiff.js/issues/221
// https://github.com/geotiffjs/geotiff.js/pull/224
// Retry until it is not blocked.
// TODO retry counter
await PromiseUtils.delay(100);
return this.fetchBuffer(image, window, channels, signal);
}
if (e.name !== 'AbortError') {
console.error(e);
}
} else {
console.error(e);
}
return null;
}
}
/**
* Extract a region from the specified image.
*
* @param extent - The request extent.
* @param imageInfo - The image to sample.
* @param signal - The abort signal.
* @param id - The request id.
* @returns The buffer(s).
*/
async getRegionBuffers(extent, imageInfo, channels, signal, id) {
const window = this.makeWindowFromExtent(extent, imageInfo.resolution);
const cacheKey = `${this._cacheId}-${id}-${channels.join(',')}`;
const cached = this._cache.get(cacheKey);
if (cached != null) {
return cached;
}
const buf = await this.fetchBuffer(imageInfo.image, window, channels, signal);
if (buf == null) {
return null;
}
let result;
let size = 0;
if (Array.isArray(buf)) {
size = buf.map(b => b.byteLength).reduce((a, b) => a + b);
result = buf;
} else {
size = buf.byteLength;
result = [buf];
}
this._cache.set(cacheKey, result, {
size
});
return result;
}
getImages(options) {
const {
signal,
id
} = options;
signal?.throwIfAborted();
const opts = {
...options,
id
};
const request = () => this.loadImage(opts);
return [{
id,
request
}];
}
dispose() {
this.getInternalCache()?.clear();
}
}
export default GeoTIFFSource;