UNPKG

geotiff.js

Version:

GeoTIFF image decoding in JavaScript

604 lines (540 loc) 21.6 kB
import { fieldTypes, fieldTagNames, arrayFields, geoKeyNames } from './globals'; import GeoTIFFImage from './geotiffimage'; import DataView64 from './dataview64'; import DataSlice from './dataslice'; import { makeRemoteSource, makeBufferSource, makeFileSource, makeFileReaderSource } from './source'; import Pool from './pool'; import { writeGeotiff } from './geotiffwriter'; import * as globals from './globals'; export { globals }; import * as rgb from './rgb'; export { rgb }; function getFieldTypeLength(fieldType) { switch (fieldType) { case fieldTypes.BYTE: case fieldTypes.ASCII: case fieldTypes.SBYTE: case fieldTypes.UNDEFINED: return 1; case fieldTypes.SHORT: case fieldTypes.SSHORT: return 2; case fieldTypes.LONG: case fieldTypes.SLONG: case fieldTypes.FLOAT: return 4; case fieldTypes.RATIONAL: case fieldTypes.SRATIONAL: case fieldTypes.DOUBLE: case fieldTypes.LONG8: case fieldTypes.SLONG8: case fieldTypes.IFD8: return 8; default: throw new RangeError(`Invalid field type: ${fieldType}`); } } function parseGeoKeyDirectory(fileDirectory) { const rawGeoKeyDirectory = fileDirectory.GeoKeyDirectory; if (!rawGeoKeyDirectory) { return null; } const geoKeyDirectory = {}; for (let i = 4; i <= rawGeoKeyDirectory[3] * 4; i += 4) { const key = geoKeyNames[rawGeoKeyDirectory[i]]; const location = (rawGeoKeyDirectory[i + 1]) ? (fieldTagNames[rawGeoKeyDirectory[i + 1]]) : null; const count = rawGeoKeyDirectory[i + 2]; const offset = rawGeoKeyDirectory[i + 3]; let value = null; if (!location) { value = offset; } else { value = fileDirectory[location]; if (typeof value === 'undefined' || value === null) { throw new Error(`Could not get value of geoKey '${key}'.`); } else if (typeof value === 'string') { value = value.substring(offset, offset + count - 1); } else if (value.subarray) { value = value.subarray(offset, offset + count - 1); } } geoKeyDirectory[key] = value; } return geoKeyDirectory; } function getValues(dataSlice, fieldType, count, offset) { let values = null; let readMethod = null; const fieldTypeLength = getFieldTypeLength(fieldType); switch (fieldType) { case fieldTypes.BYTE: case fieldTypes.ASCII: case fieldTypes.UNDEFINED: values = new Uint8Array(count); readMethod = dataSlice.readUint8; break; case fieldTypes.SBYTE: values = new Int8Array(count); readMethod = dataSlice.readInt8; break; case fieldTypes.SHORT: values = new Uint16Array(count); readMethod = dataSlice.readUint16; break; case fieldTypes.SSHORT: values = new Int16Array(count); readMethod = dataSlice.readInt16; break; case fieldTypes.LONG: values = new Uint32Array(count); readMethod = dataSlice.readUint32; break; case fieldTypes.SLONG: values = new Int32Array(count); readMethod = dataSlice.readInt32; break; case fieldTypes.LONG8: case fieldTypes.IFD8: values = new Array(count); readMethod = dataSlice.readUint64; break; case fieldTypes.SLONG8: values = new Array(count); readMethod = dataSlice.readInt64; break; case fieldTypes.RATIONAL: values = new Uint32Array(count * 2); readMethod = dataSlice.readUint32; break; case fieldTypes.SRATIONAL: values = new Int32Array(count * 2); readMethod = dataSlice.readInt32; break; case fieldTypes.FLOAT: values = new Float32Array(count); readMethod = dataSlice.readFloat32; break; case fieldTypes.DOUBLE: values = new Float64Array(count); readMethod = dataSlice.readFloat64; break; default: throw new RangeError(`Invalid field type: ${fieldType}`); } // normal fields if (!(fieldType === fieldTypes.RATIONAL || fieldType === fieldTypes.SRATIONAL)) { for (let i = 0; i < count; ++i) { values[i] = readMethod.call( dataSlice, offset + (i * fieldTypeLength), ); } } else { // RATIONAL or SRATIONAL for (let i = 0; i < count; i += 2) { values[i] = readMethod.call( dataSlice, offset + (i * fieldTypeLength), ); values[i + 1] = readMethod.call( dataSlice, offset + ((i * fieldTypeLength) + 4), ); } } if (fieldType === fieldTypes.ASCII) { return String.fromCharCode.apply(null, values); } return values; } class GeoTIFFBase { /** * (experimental) Reads raster data from the best fitting image. This function uses * the image with the lowest resolution that is still a higher resolution than the * requested resolution. * When specified, the `bbox` option is translated to the `window` option and the * `resX` and `resY` to `width` and `height` respectively. * Then, the [readRasters]{@link GeoTIFFImage#readRasters} method of the selected * image is called and the result returned. * @see GeoTIFFImage.readRasters * @param {Object} [options={}] optional parameters * @param {Array} [options.window=whole image] the subset to read data from. * @param {Array} [options.bbox=whole image] the subset to read data from in * geographical coordinates. * @param {Array} [options.samples=all samples] the selection of samples to read from. * @param {Boolean} [options.interleave=false] whether the data shall be read * in one single array or separate * arrays. * @param {Number} [options.pool=null] The optional decoder pool to use. * @param {Number} [options.width] The desired width of the output. When the width is not the * same as the images, resampling will be performed. * @param {Number} [options.height] The desired height of the output. When the width is not the * same as the images, resampling will be performed. * @param {String} [options.resampleMethod='nearest'] The desired resampling method. * @param {Number|Number[]} [options.fillValue] The value to use for parts of the image * outside of the images extent. When multiple * samples are requested, an array of fill values * can be passed. * @returns {Promise.<(TypedArray|TypedArray[])>} the decoded arrays as a promise */ async readRasters(options = {}) { const { window: imageWindow, width, height } = options; let { resX, resY, bbox } = options; const firstImage = await this.getImage(); let usedImage = firstImage; const imageCount = await this.getImageCount(); const imgBBox = firstImage.getBoundingBox(); if (imageWindow && bbox) { throw new Error('Both "bbox" and "window" passed.'); } // if width/height is passed, transform it to resolution if (width || height) { // if we have an image window (pixel coordinates), transform it to a BBox // using the origin/resolution of the first image. if (imageWindow) { const [oX, oY] = firstImage.getOrigin(); const [rX, rY] = firstImage.getResolution(); bbox = [ oX + (imageWindow[0] * rX), oY + (imageWindow[1] * rY), oX + (imageWindow[2] * rX), oY + (imageWindow[3] * rY), ]; } // if we have a bbox (or calculated one) const usedBBox = bbox || imgBBox; if (width) { if (resX) { throw new Error('Both width and resX passed'); } resX = (usedBBox[2] - usedBBox[0]) / width; } if (height) { if (resY) { throw new Error('Both width and resY passed'); } resY = (usedBBox[3] - usedBBox[1]) / height; } } // if resolution is set or calculated, try to get the image with the worst acceptable resolution if (resX || resY) { const allImages = []; for (let i = 0; i < imageCount; ++i) { const image = await this.getImage(i); const { SubfileType: subfileType, NewSubfileType: newSubfileType } = image.fileDirectory; if (i === 0 || subfileType === 2 || newSubfileType & 1) { allImages.push(image); } } allImages.sort((a, b) => a.getWidth() - b.getWidth()); for (let i = 0; i < allImages.length; ++i) { const image = allImages[i]; const imgResX = (imgBBox[2] - imgBBox[0]) / image.getWidth(); const imgResY = (imgBBox[3] - imgBBox[1]) / image.getHeight(); usedImage = image; if ((resX && resX > imgResX) || (resY && resY > imgResY)) { break; } } } let wnd = imageWindow; if (bbox) { const [oX, oY] = firstImage.getOrigin(); const [imageResX, imageResY] = usedImage.getResolution(firstImage); wnd = [ Math.round((bbox[0] - oX) / imageResX), Math.round((bbox[1] - oY) / imageResY), Math.round((bbox[2] - oX) / imageResX), Math.round((bbox[3] - oY) / imageResY), ]; wnd = [ Math.min(wnd[0], wnd[2]), Math.min(wnd[1], wnd[3]), Math.max(wnd[0], wnd[2]), Math.max(wnd[1], wnd[3]), ]; } return usedImage.readRasters(Object.assign({}, options, { window: wnd, })); } } /** * The abstraction for a whole GeoTIFF file. * @augments GeoTIFFBase */ class GeoTIFF extends GeoTIFFBase { /** * @constructor * @param {Source} source The datasource to read from. * @param {Boolean} littleEndian Whether the image uses little endian. * @param {Boolean} bigTiff Whether the image uses bigTIFF conventions. * @param {Number} firstIFDOffset The numeric byte-offset from the start of the image * to the first IFD. * @param {Object} [options] further options. * @param {Boolean} [options.cache=false] whether or not decoded tiles shall be cached. */ constructor(source, littleEndian, bigTiff, firstIFDOffset, options = {}) { super(); this.source = source; this.littleEndian = littleEndian; this.bigTiff = bigTiff; this.firstIFDOffset = firstIFDOffset; this.cache = options.cache || false; this.fileDirectories = null; this.fileDirectoriesParsing = null; } async getSlice(offset, size) { const fallbackSize = this.bigTiff ? 4048 : 1024; return new DataSlice( await this.source.fetch( offset, typeof size !== 'undefined' ? size : fallbackSize, ), offset, this.littleEndian, this.bigTiff, ); } async parseFileDirectories() { let nextIFDByteOffset = this.firstIFDOffset; const offsetSize = this.bigTiff ? 8 : 2; const entrySize = this.bigTiff ? 20 : 12; const fileDirectories = []; while (nextIFDByteOffset !== 0x00000000) { let dataSlice = await this.getSlice(nextIFDByteOffset); const numDirEntries = this.bigTiff ? dataSlice.readUint64(nextIFDByteOffset) : dataSlice.readUint16(nextIFDByteOffset); // if the slice does not cover the whole IFD, request a bigger slice, where the // whole IFD fits: num of entries + n x tag length + offset to next IFD const byteSize = (numDirEntries * entrySize) + (this.bigTiff ? 16 : 6); if (!dataSlice.covers(nextIFDByteOffset, byteSize)) { dataSlice = await this.getSlice(nextIFDByteOffset, byteSize); } const fileDirectory = {}; // loop over the IFD and create a file directory object let i = nextIFDByteOffset + (this.bigTiff ? 8 : 2); for (let entryCount = 0; entryCount < numDirEntries; i += entrySize, ++entryCount) { const fieldTag = dataSlice.readUint16(i); const fieldType = dataSlice.readUint16(i + 2); const typeCount = this.bigTiff ? dataSlice.readUint64(i + 4) : dataSlice.readUint32(i + 4); let fieldValues; let value; const fieldTypeLength = getFieldTypeLength(fieldType); const valueOffset = i + (this.bigTiff ? 12 : 8); // check whether the value is directly encoded in the tag or refers to a // different external byte range if (fieldTypeLength * typeCount <= (this.bigTiff ? 8 : 4)) { fieldValues = getValues(dataSlice, fieldType, typeCount, valueOffset); } else { // resolve the reference to the actual byte range const actualOffset = dataSlice.readOffset(valueOffset); const length = getFieldTypeLength(fieldType) * typeCount; // check, whether we actually cover the referenced byte range; if not, // request a new slice of bytes to read from it if (dataSlice.covers(actualOffset, length)) { fieldValues = getValues(dataSlice, fieldType, typeCount, actualOffset); } else { const fieldDataSlice = await this.getSlice(actualOffset, length); fieldValues = getValues(fieldDataSlice, fieldType, typeCount, actualOffset); } } // unpack single values from the array if (typeCount === 1 && arrayFields.indexOf(fieldTag) === -1 && !(fieldType === fieldTypes.RATIONAL || fieldType === fieldTypes.SRATIONAL)) { value = fieldValues[0]; } else { value = fieldValues; } // write the tags value to the file directly fileDirectory[fieldTagNames[fieldTag]] = value; } fileDirectories.push([ fileDirectory, parseGeoKeyDirectory(fileDirectory), ]); // continue with the next IFD nextIFDByteOffset = dataSlice.readOffset( nextIFDByteOffset + offsetSize + (entrySize * numDirEntries), ); } return fileDirectories; } /** * Get the n-th internal subfile of an image. By default, the first is returned. * * @param {Number} [index=0] the index of the image to return. * @returns {GeoTIFFImage} the image at the given index */ async getImage(index = 0) { if (!this.fileDirectories) { if (!this.fileDirectoriesParsing) { this.fileDirectoriesParsing = this.parseFileDirectories(); } this.fileDirectories = await this.fileDirectoriesParsing; } const fileDirectoryAndGeoKey = this.fileDirectories[index]; if (!fileDirectoryAndGeoKey) { throw new RangeError('Invalid image index'); } return new GeoTIFFImage( fileDirectoryAndGeoKey[0], fileDirectoryAndGeoKey[1], this.dataView, this.littleEndian, this.cache, this.source, ); } /** * Returns the count of the internal subfiles. * * @returns {Number} the number of internal subfile images */ async getImageCount() { if (!this.fileDirectories) { if (!this.fileDirectoriesParsing) { this.fileDirectoriesParsing = this.parseFileDirectories(); } this.fileDirectories = await this.fileDirectoriesParsing; } return this.fileDirectories.length; } /** * Parse a (Geo)TIFF file from the given source. * * @param {source~Source} source The source of data to parse from. * @param {object} options Additional options. */ static async fromSource(source, options) { const headerData = await source.fetch(0, 1024); const dataView = new DataView64(headerData); const BOM = dataView.getUint16(0, 0); let littleEndian; if (BOM === 0x4949) { littleEndian = true; } else if (BOM === 0x4D4D) { littleEndian = false; } else { throw new TypeError('Invalid byte order value.'); } const magicNumber = dataView.getUint16(2, littleEndian); let bigTiff; if (magicNumber === 42) { bigTiff = false; } else if (magicNumber === 43) { bigTiff = true; const offsetByteSize = dataView.getUint16(4, littleEndian); if (offsetByteSize !== 8) { throw new Error('Unsupported offset byte-size.'); } } else { throw new TypeError('Invalid magic number.'); } const firstIFDOffset = bigTiff ? dataView.getUint64(8, littleEndian) : dataView.getUint32(4, littleEndian); return new GeoTIFF(source, littleEndian, bigTiff, firstIFDOffset, options); } } export { GeoTIFF }; export default GeoTIFF; /** * Wrapper for GeoTIFF files that have external overviews. * @augments GeoTIFFBase */ class MultiGeoTIFF extends GeoTIFFBase { /** * Construct a new MultiGeoTIFF from a main and several overview files. * @param {GeoTIFF} mainFile The main GeoTIFF file. * @param {GeoTIFF[]} overviewFiles An array of overview files. */ constructor(mainFile, overviewFiles) { super(); this.mainFile = mainFile; this.overviewFiles = overviewFiles; this.imageFiles = [mainFile].concat(overviewFiles); this.fileDirectoriesPerFile = null; this.fileDirectoriesPerFileParsing = null; this.imageCount = null; } async parseFileDirectoriesPerFile() { const requests = [this.mainFile.parseFileDirectories()] .concat(this.overviewFiles.map(file => file.parseFileDirectories())); this.fileDirectoriesPerFile = await Promise.all(requests); return this.fileDirectoriesPerFile; } /** * Get the n-th internal subfile of an image. By default, the first is returned. * * @param {Number} [index=0] the index of the image to return. * @returns {GeoTIFFImage} the image at the given index */ async getImage(index = 0) { if (!this.fileDirectoriesPerFile) { if (!this.fileDirectoriesPerFileParsing) { this.fileDirectoriesPerFileParsing = this.parseFileDirectoriesPerFile(); } this.fileDirectoriesPerFile = await this.fileDirectoriesPerFileParsing; } let relativeIndex = index; for (let i = 0; i < this.fileDirectoriesPerFile.length; ++i) { const fileDirectories = this.fileDirectoriesPerFile[i]; if (relativeIndex < fileDirectories.length) { const file = this.imageFiles[i]; return new GeoTIFFImage( fileDirectories[relativeIndex][0], fileDirectories[relativeIndex][1], file.dataView, file.littleEndian, file.cache, file.source, ); } relativeIndex -= fileDirectories.length; } throw new RangeError('Invalid image index'); } /** * Returns the count of the internal subfiles. * * @returns {Number} the number of internal subfile images */ async getImageCount() { if (!this.fileDirectoriesPerFile) { if (!this.fileDirectoriesPerFileParsing) { this.fileDirectoriesPerFileParsing = this.parseFileDirectoriesPerFile(); } this.fileDirectoriesPerFile = await this.fileDirectoriesPerFileParsing; } return this.fileDirectoriesPerFile.reduce((count, ifds) => count + ifds.length, 0); } } export { MultiGeoTIFF }; /** * Creates a new GeoTIFF from a remote URL. * @param {string} url The URL to access the image from * @param {object} [options] Additional options to pass to the source. * See {@link makeRemoteSource} for details. * @returns {Promise.<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromUrl(url, options = {}) { return GeoTIFF.fromSource(makeRemoteSource(url, options)); } /** * Construct a new GeoTIFF from an * [ArrayBuffer]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer}. * @param {ArrayBuffer} arrayBuffer The data to read the file from. * @returns {Promise.<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromArrayBuffer(arrayBuffer) { return GeoTIFF.fromSource(makeBufferSource(arrayBuffer)); } /** * Construct a GeoTIFF from a local file path. This uses the node * [filesystem API]{@link https://nodejs.org/api/fs.html} and is * not available on browsers. * @param {string} path The filepath to read from. * @returns {Promise.<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromFile(path) { return GeoTIFF.fromSource(makeFileSource(path)); } /** * Construct a GeoTIFF from an HTML * [Blob]{@link https://developer.mozilla.org/en-US/docs/Web/API/Blob} or * [File]{@link https://developer.mozilla.org/en-US/docs/Web/API/File} * object. * @param {Blob|File} blob The Blob or File object to read from. * @returns {Promise.<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromBlob(blob) { return GeoTIFF.fromSource(makeFileReaderSource(blob)); } /** * Construct a MultiGeoTIFF from the given URLs. * @param {string} mainUrl The URL for the main file. * @param {string[]} overviewUrls An array of URLs for the overview images. * @param {object} [options] Additional options to pass to the source. * See [makeRemoteSource]{@link module:source.makeRemoteSource} * for details. * @returns {Promise.<MultiGeoTIFF>} The resulting MultiGeoTIFF file. */ export async function fromUrls(mainUrl, overviewUrls = [], options = {}) { const mainFile = await GeoTIFF.fromSource(makeRemoteSource(mainUrl, options)); const overviewFiles = await Promise.all( overviewUrls.map(url => GeoTIFF.fromSource(makeRemoteSource(url, options))), ); return new MultiGeoTIFF(mainFile, overviewFiles); } /** * Main creating function for GeoTIFF files. * @param {(Array)} array of pixel values * @returns {metadata} metadata */ export async function writeArrayBuffer(values, metadata) { return writeGeotiff(values, metadata); } export { Pool };