Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: Add Cloud Optimized GeoTIFF (COG) sample #2250

Merged
merged 4 commits into from
Jan 23, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@
"Plugins": [
"DragNDrop",
"FeatureToolTip",
"TIFFParser"
"TIFFParser",
"COGSource",
"COGParser"
],

"Widgets": [
Expand Down
3 changes: 2 additions & 1 deletion examples/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
223 changes: 223 additions & 0 deletions examples/js/plugins/COGParser.js
Original file line number Diff line number Diff line change
@@ -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<THREE.DataTexture>} 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;
}
128 changes: 128 additions & 0 deletions examples/js/plugins/COGSource.js
Original file line number Diff line number Diff line change
@@ -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;
}
Loading