Skip to content

Commit f707e26

Browse files
authored
feature: Add Cloud Optimized GeoTIFF (COG) sample (#2250)
* 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 <[email protected]>
1 parent 9761d58 commit f707e26

File tree

5 files changed

+436
-2
lines changed

5 files changed

+436
-2
lines changed

docs/config.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,9 @@
127127
"Plugins": [
128128
"DragNDrop",
129129
"FeatureToolTip",
130-
"TIFFParser"
130+
"TIFFParser",
131+
"COGSource",
132+
"COGParser"
131133
],
132134

133135
"Widgets": [

examples/config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,8 @@
5656
"source_file_kml_raster": "KML to raster",
5757
"source_file_kml_raster_usgs": "USGS KML flux to raster",
5858
"source_file_gpx_raster": "GPX to raster",
59-
"source_file_gpx_3d": "GPX to 3D objects"
59+
"source_file_gpx_3d": "GPX to 3D objects",
60+
"source_file_cog": "Cloud Optimized GeoTIFF (COG)"
6061
},
6162

6263
"Customize FileSource": {

examples/js/plugins/COGParser.js

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/* global itowns, THREE */
2+
3+
/**
4+
* @typedef {Object} GeoTIFFLevel
5+
* @property {GeoTIFFImage} image
6+
* @property {number} width
7+
* @property {number} height
8+
* @property {number[]} resolution
9+
*/
10+
11+
/**
12+
* Select the best overview level (or the final image) to match the
13+
* requested extent and pixel width and height.
14+
*
15+
* @param {Object} source The COGSource
16+
* @param {Extent} source.extent Source extent
17+
* @param {GeoTIFFLevel[]} source.levels
18+
* @param {THREE.Vector2} source.dimensions
19+
* @param {Extent} requestExtent The node extent.
20+
* @param {number} requestWidth The pixel width of the window.
21+
* @param {number} requestHeight The pixel height of the window.
22+
* @returns {GeoTIFFLevel} The selected zoom level.
23+
*/
24+
function selectLevel(source, requestExtent, requestWidth, requestHeight) {
25+
// Number of images = original + overviews if any
26+
const cropped = requestExtent.clone().intersect(source.extent);
27+
// Dimensions of the requested extent
28+
const extentDimension = cropped.planarDimensions();
29+
30+
const targetResolution = Math.min(
31+
extentDimension.x / requestWidth,
32+
extentDimension.y / requestHeight,
33+
);
34+
35+
let level;
36+
37+
// Select the image with the best resolution for our needs
38+
for (let index = source.levels.length - 1; index >= 0; index--) {
39+
level = source.levels[index];
40+
const sourceResolution = Math.min(
41+
source.dimensions.x / level.width,
42+
source.dimensions.y / level.height,
43+
);
44+
45+
if (targetResolution >= sourceResolution) {
46+
break;
47+
}
48+
}
49+
50+
return level;
51+
}
52+
53+
/**
54+
* Returns a window in the image's coordinates that matches the requested extent.
55+
*
56+
* @param {Object} source The COGSource
57+
* @param {number[]} source.origin Root image origin as an XYZ-vector
58+
* @param {Extent} extent The window extent.
59+
* @param {number[]} resolution The spatial resolution of the window.
60+
* @returns {number[]} The window.
61+
*/
62+
function makeWindowFromExtent(source, extent, resolution) {
63+
const [oX, oY] = source.origin;
64+
const [imageResX, imageResY] = resolution;
65+
66+
const wnd = [
67+
Math.round((extent.west - oX) / imageResX),
68+
Math.round((extent.north - oY) / imageResY),
69+
Math.round((extent.east - oX) / imageResX),
70+
Math.round((extent.south - oY) / imageResY),
71+
];
72+
73+
const xMin = Math.min(wnd[0], wnd[2]);
74+
let xMax = Math.max(wnd[0], wnd[2]);
75+
const yMin = Math.min(wnd[1], wnd[3]);
76+
let yMax = Math.max(wnd[1], wnd[3]);
77+
78+
// prevent zero-sized requests
79+
if (Math.abs(xMax - xMin) === 0) {
80+
xMax += 1;
81+
}
82+
if (Math.abs(yMax - yMin) === 0) {
83+
yMax += 1;
84+
}
85+
86+
return [xMin, yMin, xMax, yMax];
87+
}
88+
89+
/**
90+
* Creates a texture from the pixel buffer(s).
91+
*
92+
* @param {Object} source The COGSource
93+
* @param {THREE.TypedArray | THREE.TypedArray[]} buffers The buffers (one buffer per band)
94+
* @param {number} buffers.width
95+
* @param {number} buffers.height
96+
* @param {number} buffers.byteLength
97+
* @returns {THREE.DataTexture} The generated texture.
98+
*/
99+
function createTexture(source, buffers) {
100+
const { width, height, byteLength } = buffers;
101+
const pixelCount = width * height;
102+
const targetDataType = source.dataType;
103+
const format = THREE.RGBAFormat;
104+
const channelCount = 4;
105+
let texture;
106+
let data;
107+
108+
// Check if it's a RGBA buffer
109+
if (pixelCount * channelCount === byteLength) {
110+
data = buffers;
111+
}
112+
113+
switch (targetDataType) {
114+
case THREE.UnsignedByteType: {
115+
if (!data) {
116+
// We convert RGB buffer to RGBA
117+
const newBuffers = new Uint8ClampedArray(pixelCount * channelCount);
118+
data = convertToRGBA(buffers, newBuffers, source.defaultAlpha);
119+
}
120+
texture = new THREE.DataTexture(data, width, height, format, THREE.UnsignedByteType);
121+
break;
122+
}
123+
case THREE.FloatType: {
124+
if (!data) {
125+
// We convert RGB buffer to RGBA
126+
const newBuffers = new Float32Array(pixelCount * channelCount);
127+
data = convertToRGBA(buffers, newBuffers, source.defaultAlpha / 255);
128+
}
129+
texture = new THREE.DataTexture(data, width, height, format, THREE.FloatType);
130+
break;
131+
}
132+
default:
133+
throw new Error('unsupported data type');
134+
}
135+
136+
return texture;
137+
}
138+
139+
function convertToRGBA(buffers, newBuffers, defaultAlpha) {
140+
const { width, height } = buffers;
141+
142+
for (let i = 0; i < width * height; i++) {
143+
const oldIndex = i * 3;
144+
const index = i * 4;
145+
// Copy RGB from original buffer
146+
newBuffers[index + 0] = buffers[oldIndex + 0]; // R
147+
newBuffers[index + 1] = buffers[oldIndex + 1]; // G
148+
newBuffers[index + 2] = buffers[oldIndex + 2]; // B
149+
// Add alpha to new buffer
150+
newBuffers[index + 3] = defaultAlpha; // A
151+
}
152+
153+
return newBuffers;
154+
}
155+
156+
/**
157+
* The COGParser module provides a [parse]{@link module:COGParser.parse}
158+
* method that takes a COG in and gives a `THREE.DataTexture` that can be
159+
* displayed in the view.
160+
*
161+
* It needs the [geotiff](https://github.com/geotiffjs/geotiff.js/) library to parse the
162+
* COG.
163+
*
164+
* @example
165+
* GeoTIFF.fromUrl('http://image.tif')
166+
* .then(COGParser.parse)
167+
* .then(function _(texture) {
168+
* var source = new itowns.FileSource({ features: texture });
169+
* var layer = new itowns.ColorLayer('cog', { source });
170+
* view.addLayer(layer);
171+
* });
172+
*
173+
* @module COGParser
174+
*/
175+
const COGParser = (function _() {
176+
if (typeof THREE == 'undefined' && itowns.THREE) {
177+
// eslint-disable-next-line no-global-assign
178+
THREE = itowns.THREE;
179+
}
180+
181+
return {
182+
/**
183+
* Parse a COG file and return a `THREE.DataTexture`.
184+
*
185+
* @param {Object} data Data passed with the Tile extent
186+
* @param {Extent} data.extent
187+
* @param {Object} options Options (contains source)
188+
* @param {Object} options.in
189+
* @param {COGSource} options.in.source
190+
* @param {number} options.in.tileWidth
191+
* @param {number} options.in.tileHeight
192+
* @return {Promise<THREE.DataTexture>} A promise resolving with a `THREE.DataTexture`.
193+
*
194+
* @memberof module:COGParser
195+
*/
196+
parse: async function _(data, options) {
197+
const source = options.in;
198+
const nodeExtent = data.extent.as(source.crs);
199+
const level = selectLevel(source, nodeExtent, source.tileWidth, source.tileHeight);
200+
const viewport = makeWindowFromExtent(source, nodeExtent, level.resolution);
201+
202+
const buffers = await level.image.readRGB({
203+
window: viewport,
204+
pool: source.pool,
205+
enableAlpha: true,
206+
interleave: true,
207+
});
208+
209+
const texture = createTexture(source, buffers);
210+
texture.flipY = true;
211+
texture.extent = data.extent;
212+
texture.needsUpdate = true;
213+
texture.magFilter = THREE.LinearFilter;
214+
texture.minFilter = THREE.LinearFilter;
215+
216+
return Promise.resolve(texture);
217+
},
218+
};
219+
}());
220+
221+
if (typeof module != 'undefined' && module.exports) {
222+
module.exports = COGParser;
223+
}

examples/js/plugins/COGSource.js

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* global itowns, GeoTIFF, COGParser, THREE */
2+
3+
/**
4+
* @classdesc
5+
* An object defining the source of resources to get from a [COG]{@link
6+
* https://www.cogeo.org/} file. It
7+
* inherits from {@link Source}.
8+
*
9+
* @extends Source
10+
*
11+
* @property {Object} zoom - Object containing the minimum and maximum values of
12+
* the level, to zoom in the source.
13+
* @property {number} zoom.min - The minimum level of the source. Default value is 0.
14+
* @property {number} zoom.max - The maximum level of the source. Default value is Infinity.
15+
* @property {string} url - The URL of the COG.
16+
* @property {GeoTIFF.Pool} pool - Pool use to decode GeoTiff.
17+
* @property {number} defaultAlpha - Alpha byte value used if no alpha is present in COG. Default value is 255.
18+
*
19+
* @example
20+
* // Create the source
21+
* const cogSource = new itowns.COGSource({
22+
* url: 'https://cdn.jsdelivr.net/gh/iTowns/iTowns2-sample-data/cog/orvault.tif',
23+
* });
24+
*
25+
* // Create the layer
26+
* const colorLayer = new itowns.ColorLayer('COG', {
27+
* source: cogSource,
28+
* });
29+
*
30+
* // Add the layer
31+
* view.addLayer(colorLayer);
32+
*/
33+
class COGSource extends itowns.Source {
34+
/**
35+
* @param {Object} source - An object that can contain all properties of a
36+
* COGSource and {@link Source}. Only `url` is mandatory.
37+
* @constructor
38+
*/
39+
constructor(source) {
40+
super(source);
41+
42+
if (source.zoom) {
43+
this.zoom = source.zoom;
44+
} else {
45+
this.zoom = { min: 0, max: Infinity };
46+
}
47+
48+
this.url = source.url;
49+
this.pool = source.pool || new GeoTIFF.Pool();
50+
// We don't use fetcher, we let geotiff.js manage it
51+
this.fetcher = () => Promise.resolve({});
52+
this.parser = COGParser.parse;
53+
54+
this.defaultAlpha = source.defaultAlpha || 255;
55+
56+
this.whenReady = GeoTIFF.fromUrl(this.url)
57+
.then(async (geotiff) => {
58+
this.geotiff = geotiff;
59+
this.firstImage = await geotiff.getImage();
60+
this.origin = this.firstImage.getOrigin();
61+
this.dataType = this.selectDataType(this.firstImage.getSampleFormat(), this.firstImage.getBitsPerSample());
62+
63+
this.tileWidth = this.firstImage.getTileWidth();
64+
this.tileHeight = this.firstImage.getTileHeight();
65+
66+
// Compute extent
67+
const [minX, minY, maxX, maxY] = this.firstImage.getBoundingBox();
68+
this.extent = new itowns.Extent(this.crs, minX, maxX, minY, maxY);
69+
this.dimensions = this.extent.planarDimensions();
70+
71+
this.levels = [];
72+
this.levels.push(this.makeLevel(this.firstImage, this.firstImage.getResolution()));
73+
74+
// Number of images (original + overviews)
75+
const imageCount = await this.geotiff.getImageCount();
76+
77+
const promises = [];
78+
for (let index = 1; index < imageCount; index++) {
79+
const promise = this.geotiff.getImage(index)
80+
.then(image => this.makeLevel(image, image.getResolution(this.firstImage)));
81+
promises.push(promise);
82+
}
83+
this.levels.push(await Promise.all(promises));
84+
});
85+
}
86+
87+
/**
88+
* @param {number} format - Format to interpret each data sample in a pixel
89+
* https://www.awaresystems.be/imaging/tiff/tifftags/sampleformat.html
90+
* @param {number} bitsPerSample - Number of bits per component.
91+
* https://www.awaresystems.be/imaging/tiff/tifftags/bitspersample.html
92+
* @return {THREE.AttributeGPUType}
93+
*/
94+
selectDataType(format, bitsPerSample) {
95+
switch (format) {
96+
case 1: // unsigned integer data
97+
if (bitsPerSample <= 8) {
98+
return THREE.UnsignedByteType;
99+
}
100+
break;
101+
default:
102+
break;
103+
}
104+
return THREE.FloatType;
105+
}
106+
107+
makeLevel(image, resolution) {
108+
return {
109+
image,
110+
width: image.getWidth(),
111+
height: image.getHeight(),
112+
resolution,
113+
};
114+
}
115+
116+
// We don't use UrlFromExtent, we let geotiff.js manage it
117+
urlFromExtent() {
118+
return '';
119+
}
120+
121+
extentInsideLimit(extent) {
122+
return this.extent.intersectsExtent(extent);
123+
}
124+
}
125+
126+
if (typeof module != 'undefined' && module.exports) {
127+
module.exports = COGSource;
128+
}

0 commit comments

Comments
 (0)