diff --git a/docs/config.json b/docs/config.json index 5ad8155d11..3ea235c738 100644 --- a/docs/config.json +++ b/docs/config.json @@ -127,7 +127,9 @@ "Plugins": [ "DragNDrop", "FeatureToolTip", - "TIFFParser" + "TIFFParser", + "COGSource", + "COGParser" ], "Widgets": [ diff --git a/examples/config.json b/examples/config.json index 506aef37b4..d1014d01dd 100644 --- a/examples/config.json +++ b/examples/config.json @@ -56,7 +56,8 @@ "source_file_kml_raster": "KML to raster", "source_file_kml_raster_usgs": "USGS KML flux to raster", "source_file_gpx_raster": "GPX to raster", - "source_file_gpx_3d": "GPX to 3D objects" + "source_file_gpx_3d": "GPX to 3D objects", + "source_file_cog": "Cloud Optimized GeoTIFF (COG)" }, "Customize FileSource": { diff --git a/examples/js/plugins/COGParser.js b/examples/js/plugins/COGParser.js new file mode 100644 index 0000000000..45f77a39ac --- /dev/null +++ b/examples/js/plugins/COGParser.js @@ -0,0 +1,223 @@ +/* global itowns, THREE */ + +/** + * @typedef {Object} GeoTIFFLevel + * @property {GeoTIFFImage} image + * @property {number} width + * @property {number} height + * @property {number[]} resolution + */ + +/** + * Select the best overview level (or the final image) to match the + * requested extent and pixel width and height. + * + * @param {Object} source The COGSource + * @param {Extent} source.extent Source extent + * @param {GeoTIFFLevel[]} source.levels + * @param {THREE.Vector2} source.dimensions + * @param {Extent} requestExtent The node extent. + * @param {number} requestWidth The pixel width of the window. + * @param {number} requestHeight The pixel height of the window. + * @returns {GeoTIFFLevel} The selected zoom level. + */ +function selectLevel(source, requestExtent, requestWidth, requestHeight) { + // Number of images = original + overviews if any + const cropped = requestExtent.clone().intersect(source.extent); + // Dimensions of the requested extent + const extentDimension = cropped.planarDimensions(); + + const targetResolution = Math.min( + extentDimension.x / requestWidth, + extentDimension.y / requestHeight, + ); + + let level; + + // Select the image with the best resolution for our needs + for (let index = source.levels.length - 1; index >= 0; index--) { + level = source.levels[index]; + const sourceResolution = Math.min( + source.dimensions.x / level.width, + source.dimensions.y / level.height, + ); + + if (targetResolution >= sourceResolution) { + break; + } + } + + return level; +} + +/** + * Returns a window in the image's coordinates that matches the requested extent. + * + * @param {Object} source The COGSource + * @param {number[]} source.origin Root image origin as an XYZ-vector + * @param {Extent} extent The window extent. + * @param {number[]} resolution The spatial resolution of the window. + * @returns {number[]} The window. + */ +function makeWindowFromExtent(source, extent, resolution) { + const [oX, oY] = source.origin; + const [imageResX, imageResY] = resolution; + + const wnd = [ + Math.round((extent.west - oX) / imageResX), + Math.round((extent.north - oY) / imageResY), + Math.round((extent.east - oX) / imageResX), + Math.round((extent.south - 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 {Object} source The COGSource + * @param {THREE.TypedArray | THREE.TypedArray[]} buffers The buffers (one buffer per band) + * @param {number} buffers.width + * @param {number} buffers.height + * @param {number} buffers.byteLength + * @returns {THREE.DataTexture} The generated texture. + */ +function createTexture(source, buffers) { + const { width, height, byteLength } = buffers; + const pixelCount = width * height; + const targetDataType = source.dataType; + const format = THREE.RGBAFormat; + const channelCount = 4; + let texture; + let data; + + // Check if it's a RGBA buffer + if (pixelCount * channelCount === byteLength) { + data = buffers; + } + + switch (targetDataType) { + case THREE.UnsignedByteType: { + if (!data) { + // We convert RGB buffer to RGBA + const newBuffers = new Uint8ClampedArray(pixelCount * channelCount); + data = convertToRGBA(buffers, newBuffers, source.defaultAlpha); + } + texture = new THREE.DataTexture(data, width, height, format, THREE.UnsignedByteType); + break; + } + case THREE.FloatType: { + if (!data) { + // We convert RGB buffer to RGBA + const newBuffers = new Float32Array(pixelCount * channelCount); + data = convertToRGBA(buffers, newBuffers, source.defaultAlpha / 255); + } + texture = new THREE.DataTexture(data, width, height, format, THREE.FloatType); + break; + } + default: + throw new Error('unsupported data type'); + } + + return texture; +} + +function convertToRGBA(buffers, newBuffers, defaultAlpha) { + const { width, height } = buffers; + + for (let i = 0; i < width * height; i++) { + const oldIndex = i * 3; + const index = i * 4; + // Copy RGB from original buffer + newBuffers[index + 0] = buffers[oldIndex + 0]; // R + newBuffers[index + 1] = buffers[oldIndex + 1]; // G + newBuffers[index + 2] = buffers[oldIndex + 2]; // B + // Add alpha to new buffer + newBuffers[index + 3] = defaultAlpha; // A + } + + return newBuffers; +} + +/** + * The COGParser module provides a [parse]{@link module:COGParser.parse} + * method that takes a COG in and gives a `THREE.DataTexture` that can be + * displayed in the view. + * + * It needs the [geotiff](https://github.com/geotiffjs/geotiff.js/) library to parse the + * COG. + * + * @example + * GeoTIFF.fromUrl('http://image.tif') + * .then(COGParser.parse) + * .then(function _(texture) { + * var source = new itowns.FileSource({ features: texture }); + * var layer = new itowns.ColorLayer('cog', { source }); + * view.addLayer(layer); + * }); + * + * @module COGParser + */ +const COGParser = (function _() { + if (typeof THREE == 'undefined' && itowns.THREE) { + // eslint-disable-next-line no-global-assign + THREE = itowns.THREE; + } + + return { + /** + * Parse a COG file and return a `THREE.DataTexture`. + * + * @param {Object} data Data passed with the Tile extent + * @param {Extent} data.extent + * @param {Object} options Options (contains source) + * @param {Object} options.in + * @param {COGSource} options.in.source + * @param {number} options.in.tileWidth + * @param {number} options.in.tileHeight + * @return {Promise} A promise resolving with a `THREE.DataTexture`. + * + * @memberof module:COGParser + */ + parse: async function _(data, options) { + const source = options.in; + const nodeExtent = data.extent.as(source.crs); + const level = selectLevel(source, nodeExtent, source.tileWidth, source.tileHeight); + const viewport = makeWindowFromExtent(source, nodeExtent, level.resolution); + + const buffers = await level.image.readRGB({ + window: viewport, + pool: source.pool, + enableAlpha: true, + interleave: true, + }); + + const texture = createTexture(source, buffers); + texture.flipY = true; + texture.extent = data.extent; + texture.needsUpdate = true; + texture.magFilter = THREE.LinearFilter; + texture.minFilter = THREE.LinearFilter; + + return Promise.resolve(texture); + }, + }; +}()); + +if (typeof module != 'undefined' && module.exports) { + module.exports = COGParser; +} diff --git a/examples/js/plugins/COGSource.js b/examples/js/plugins/COGSource.js new file mode 100644 index 0000000000..1cab8bdbc7 --- /dev/null +++ b/examples/js/plugins/COGSource.js @@ -0,0 +1,128 @@ +/* global itowns, GeoTIFF, COGParser, THREE */ + +/** + * @classdesc + * An object defining the source of resources to get from a [COG]{@link + * https://www.cogeo.org/} file. It + * inherits from {@link Source}. + * + * @extends Source + * + * @property {Object} zoom - Object containing the minimum and maximum values of + * the level, to zoom in the source. + * @property {number} zoom.min - The minimum level of the source. Default value is 0. + * @property {number} zoom.max - The maximum level of the source. Default value is Infinity. + * @property {string} url - The URL of the COG. + * @property {GeoTIFF.Pool} pool - Pool use to decode GeoTiff. + * @property {number} defaultAlpha - Alpha byte value used if no alpha is present in COG. Default value is 255. + * + * @example + * // Create the source + * const cogSource = new itowns.COGSource({ + * url: 'https://cdn.jsdelivr.net/gh/iTowns/iTowns2-sample-data/cog/orvault.tif', + * }); + * + * // Create the layer + * const colorLayer = new itowns.ColorLayer('COG', { + * source: cogSource, + * }); + * + * // Add the layer + * view.addLayer(colorLayer); + */ +class COGSource extends itowns.Source { + /** + * @param {Object} source - An object that can contain all properties of a + * COGSource and {@link Source}. Only `url` is mandatory. + * @constructor + */ + constructor(source) { + super(source); + + if (source.zoom) { + this.zoom = source.zoom; + } else { + this.zoom = { min: 0, max: Infinity }; + } + + this.url = source.url; + this.pool = source.pool || new GeoTIFF.Pool(); + // We don't use fetcher, we let geotiff.js manage it + this.fetcher = () => Promise.resolve({}); + this.parser = COGParser.parse; + + this.defaultAlpha = source.defaultAlpha || 255; + + this.whenReady = GeoTIFF.fromUrl(this.url) + .then(async (geotiff) => { + this.geotiff = geotiff; + this.firstImage = await geotiff.getImage(); + this.origin = this.firstImage.getOrigin(); + this.dataType = this.selectDataType(this.firstImage.getSampleFormat(), this.firstImage.getBitsPerSample()); + + this.tileWidth = this.firstImage.getTileWidth(); + this.tileHeight = this.firstImage.getTileHeight(); + + // Compute extent + const [minX, minY, maxX, maxY] = this.firstImage.getBoundingBox(); + this.extent = new itowns.Extent(this.crs, minX, maxX, minY, maxY); + this.dimensions = this.extent.planarDimensions(); + + this.levels = []; + this.levels.push(this.makeLevel(this.firstImage, this.firstImage.getResolution())); + + // Number of images (original + overviews) + const imageCount = await this.geotiff.getImageCount(); + + const promises = []; + for (let index = 1; index < imageCount; index++) { + const promise = this.geotiff.getImage(index) + .then(image => this.makeLevel(image, image.getResolution(this.firstImage))); + promises.push(promise); + } + this.levels.push(await Promise.all(promises)); + }); + } + + /** + * @param {number} format - Format to interpret each data sample in a pixel + * https://www.awaresystems.be/imaging/tiff/tifftags/sampleformat.html + * @param {number} bitsPerSample - Number of bits per component. + * https://www.awaresystems.be/imaging/tiff/tifftags/bitspersample.html + * @return {THREE.AttributeGPUType} + */ + selectDataType(format, bitsPerSample) { + switch (format) { + case 1: // unsigned integer data + if (bitsPerSample <= 8) { + return THREE.UnsignedByteType; + } + break; + default: + break; + } + return THREE.FloatType; + } + + makeLevel(image, resolution) { + return { + image, + width: image.getWidth(), + height: image.getHeight(), + resolution, + }; + } + + // We don't use UrlFromExtent, we let geotiff.js manage it + urlFromExtent() { + return ''; + } + + extentInsideLimit(extent) { + return this.extent.intersectsExtent(extent); + } +} + +if (typeof module != 'undefined' && module.exports) { + module.exports = COGSource; +} diff --git a/examples/source_file_cog.html b/examples/source_file_cog.html new file mode 100644 index 0000000000..cca03c3298 --- /dev/null +++ b/examples/source_file_cog.html @@ -0,0 +1,80 @@ + + + Cloud Optimized GeoTiff + + + + + + + + + + +
+
Specify the URL of a COG to load: + + +
+ + +
+
+ + + + + + + + + +