Skip to content

Commit

Permalink
feature: Add Cloud Optimized GeoTIFF (COG) sample (#2250)
Browse files Browse the repository at this point in the history
* feature: Add Cloud Optimized GeoTIFF (COG) sample

* fix(cog): Fix geotiff link

* refactor(COG): Apply code review

* fix(COG): Use readRGB instead readRasters

---------

Co-authored-by: Kevin ETOURNEAU <kevin.etourneau@sogelink.com>
ketourneau and Kevin ETOURNEAU authored Jan 23, 2024

Verified

This commit was created on GitHub.com and signed with GitHub’s verified signature.
1 parent 9761d58 commit f707e26
Showing 5 changed files with 436 additions and 2 deletions.
4 changes: 3 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
@@ -127,7 +127,9 @@
"Plugins": [
"DragNDrop",
"FeatureToolTip",
"TIFFParser"
"TIFFParser",
"COGSource",
"COGParser"
],

"Widgets": [
3 changes: 2 additions & 1 deletion examples/config.json
Original file line number Diff line number Diff line change
@@ -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": {
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;
}
80 changes: 80 additions & 0 deletions examples/source_file_cog.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
<html>
<head>
<title>Cloud Optimized GeoTiff</title>
<meta charset="UTF-8">
<link rel="stylesheet" type="text/css" href="css/example.css">
<link rel="stylesheet" type="text/css" href="css/LoadingScreen.css">

<style type="text/css">
#description {
z-index: 2;
left: 10px;
}
</style>

<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js"></script>
</head>
<body>
<div id="description">
<div>Specify the URL of a COG to load:
<input type="text" id="cog_url" />
<button onclick="readCOGURL()">Load</button>
</div>
<button onclick="loadRGBSample()">Load RGB sample</button>
<button onclick="load1BandSample()">Load 1 band sample</button>
</div>
<div id="viewerDiv"></div>
<script src="js/GUI/GuiTools.js"></script>
<script src="../dist/itowns.js"></script>
<script src="../dist/debug.js"></script>
<script src="js/GUI/LoadingScreen.js"></script>
<script src="https://cdn.jsdelivr.net/npm/geotiff"></script>
<script src="js/plugins/COGParser.js"></script>
<script src="js/plugins/COGSource.js"></script>
<script type="text/javascript">
itowns.proj4.defs('EPSG:2154', '+proj=lcc +lat_1=49 +lat_2=44 +lat_0=46.5 +lon_0=3 +x_0=700000 +y_0=6600000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs');

var viewerDiv = document.getElementById('viewerDiv');

function readCOGURL() {
var url = document.getElementById('cog_url').value || new URLSearchParams(window.location.search).get('geotiff');

if (url) {
loadCOG(url);
document.getElementById('cog_url').value = url;
}
}

function loadRGBSample() {
document.getElementById('cog_url').value = 'https://cdn.jsdelivr.net/gh/bloc-in-bloc/iTowns2-sample-data@add-cog-sample/cog/orvault.tif';
readCOGURL();
}

function load1BandSample() {
document.getElementById('cog_url').value = 'https://oin-hotosm.s3.amazonaws.com/60fbca155a90f10006fd2fc3/0/60fbca155a90f10006fd2fc4.tif';
readCOGURL();
}

function loadCOG(url, crs) {
// create a source from a Cloud Optimized GeoTiff
var cogSource = new COGSource({
url: url,
crs: "EPSG:2154"
});

cogSource.whenReady.then(() => {
var view = new itowns.PlanarView(viewerDiv, cogSource.extent, { disableSkirt: true, placement: { tilt: 90 } });
setupLoadingScreen(viewerDiv, view);
new itowns.PlanarControls(view, {});
var cogLayer = new itowns.ColorLayer('cog', {
source: cogSource,
});
view.addLayer(cogLayer);
});
}

readCOGURL();
</script>
</body>
</html>

0 comments on commit f707e26

Please sign in to comment.