UNPKG

ja-geotiff

Version:

GeoTIFF image decoding in JavaScript

887 lines (813 loc) 27.9 kB
/** @module geotiff */ import GeoTIFFImage from "./geotiffimage.js" import DataView64 from "./dataview64.js" import DataSlice from "./dataslice.js" import Pool from "./pool.js" import { makeRemoteSource, makeCustomSource } from "./source/remote.js" import { makeBufferSource } from "./source/arraybuffer.js" import { makeFileReaderSource } from "./source/filereader.js" import { makeFileSource } from "./source/file.js" import { BaseClient, BaseResponse } from "./source/client/base.js" import { fieldTypes, fieldTagNames, arrayFields, geoKeyNames, } from "./globals.js" import { writeGeotiff } from "./geotiffwriter.js" import * as globals from "./globals.js" import * as rgb from "./rgb.js" import { getDecoder, addDecoder } from "./compression/index.js" import { setLogger } from "./logging.js" export { globals } export { rgb } export { default as BaseDecoder } from "./compression/basedecoder.js" export { getDecoder, addDecoder } export { setLogger } /** * @typedef {Uint8Array | Int8Array | Uint16Array | Int16Array | Uint32Array | Int32Array | Float32Array | Float64Array} * TypedArray */ /** * @typedef {{ height:number, width: number }} Dimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `TypedArray & { height: number; width: number}` * @typedef {TypedArray & Dimensions} TypedArrayWithDimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `TypedArray[] & { height: number; width: number}` * @typedef {TypedArray[] & Dimensions} TypedArrayArrayWithDimensions */ /** * The autogenerated docs are a little confusing here. The effective type is: * * `(TypedArray | TypedArray[]) & { height: number; width: number}` * @typedef {TypedArrayWithDimensions | TypedArrayArrayWithDimensions} ReadRasterResult */ 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: case fieldTypes.IFD: 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) if (count === 1) { value = value[0] } } } 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: case fieldTypes.IFD: 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 new TextDecoder("utf-8").decode(values) } return values } /** * Data class to store the parsed file directory (+ its raw form), geo key directory and * offset to the next IFD */ class ImageFileDirectory { /** * Create an ImageFileDirectory. * @param {object} fileDirectory the file directory, mapping tag names to values * @param {Map} rawFileDirectory the raw file directory, mapping tag IDs to values * @param {object} geoKeyDirectory the geo key directory, mapping geo key names to values * @param {number} nextIFDByteOffset the byte offset to the next IFD */ constructor( fileDirectory, rawFileDirectory, geoKeyDirectory, nextIFDByteOffset ) { this.fileDirectory = fileDirectory this.rawFileDirectory = rawFileDirectory this.geoKeyDirectory = geoKeyDirectory this.nextIFDByteOffset = nextIFDByteOffset } } /** * Error class for cases when an IFD index was requested, that does not exist * in the file. */ class GeoTIFFImageIndexError extends Error { constructor(index) { super(`No image at index ${index}`) this.index = index } } 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 {import('./geotiffimage').ReadRasterOptions} [options={}] optional parameters * @returns {Promise<ReadRasterResult>} the decoded array(s), with `height` and `width`, 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({ ...options, window: wnd }) } } /** * @typedef {Object} GeoTIFFOptions * @property {boolean} [cache=false] whether or not decoded tiles shall be cached. */ /** * The abstraction for a whole GeoTIFF file. * @augments GeoTIFFBase */ class GeoTIFF extends GeoTIFFBase { /** * @constructor * @param {*} 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 {GeoTIFFOptions} [options] further options. */ 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.ifdRequests = [] this.ghostValues = null } async getSlice(offset, size) { const fallbackSize = this.bigTiff ? 4048 : 1024 return new DataSlice( ( await this.source.fetch([ { offset, length: typeof size !== "undefined" ? size : fallbackSize, }, ]) )[0], offset, this.littleEndian, this.bigTiff ) } /** * Instructs to parse an image file directory at the given file offset. * As there is no way to ensure that a location is indeed the start of an IFD, * this function must be called with caution (e.g only using the IFD offsets from * the headers or other IFDs). * @param {number} offset the offset to parse the IFD at * @returns {Promise<ImageFileDirectory>} the parsed IFD */ async parseFileDirectoryAt(offset) { const entrySize = this.bigTiff ? 20 : 12 const offsetSize = this.bigTiff ? 8 : 2 let dataSlice = await this.getSlice(offset) const numDirEntries = this.bigTiff ? dataSlice.readUint64(offset) : dataSlice.readUint16(offset) // 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(offset, byteSize)) { dataSlice = await this.getSlice(offset, byteSize) } const fileDirectory = {} const rawFileDirectory = new Map() // loop over the IFD and create a file directory object let i = offset + (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 directory const tagName = fieldTagNames[fieldTag] if (tagName) { fileDirectory[tagName] = value } rawFileDirectory.set(fieldTag, value) } const geoKeyDirectory = parseGeoKeyDirectory(fileDirectory) const nextIFDByteOffset = dataSlice.readOffset( offset + offsetSize + entrySize * numDirEntries ) return new ImageFileDirectory( fileDirectory, rawFileDirectory, geoKeyDirectory, nextIFDByteOffset ) } async requestIFD(index) { // see if we already have that IFD index requested. if (this.ifdRequests[index]) { // attach to an already requested IFD return this.ifdRequests[index] } else if (index === 0) { // special case for index 0 this.ifdRequests[index] = this.parseFileDirectoryAt(this.firstIFDOffset) return this.ifdRequests[index] } else if (!this.ifdRequests[index - 1]) { // if the previous IFD was not yet loaded, load that one first // this is the recursive call. try { this.ifdRequests[index - 1] = this.requestIFD(index - 1) } catch (e) { // if the previous one already was an index error, rethrow // with the current index if (e instanceof GeoTIFFImageIndexError) { throw new GeoTIFFImageIndexError(index) } // rethrow anything else throw e } } // if the previous IFD was loaded, we can finally fetch the one we are interested in. // we need to wrap this in an IIFE, otherwise this.ifdRequests[index] would be delayed this.ifdRequests[index] = (async () => { const previousIfd = await this.ifdRequests[index - 1] if (previousIfd.nextIFDByteOffset === 0) { throw new GeoTIFFImageIndexError(index) } return this.parseFileDirectoryAt(previousIfd.nextIFDByteOffset) })() return this.ifdRequests[index] } /** * 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 {Promise<GeoTIFFImage>} the image at the given index */ async getImage(index = 0) { const ifd = await this.requestIFD(index) return new GeoTIFFImage( ifd.fileDirectory, ifd.geoKeyDirectory, this.dataView, this.littleEndian, this.cache, this.source ) } /** * Returns the count of the internal subfiles. * * @returns {Promise<number>} the number of internal subfile images */ async getImageCount() { let index = 0 // loop until we run out of IFDs let hasNext = true while (hasNext) { try { await this.requestIFD(index) ++index } catch (e) { if (e instanceof GeoTIFFImageIndexError) { hasNext = false } else { throw e } } } return index } /** * Get the values of the COG ghost area as a parsed map. * See https://gdal.org/drivers/raster/cog.html#header-ghost-area for reference * @returns {Promise<Object>} the parsed ghost area or null, if no such area was found */ async getGhostValues() { const offset = this.bigTiff ? 16 : 8 if (this.ghostValues) { return this.ghostValues } const detectionString = "GDAL_STRUCTURAL_METADATA_SIZE=" const heuristicAreaSize = detectionString.length + 100 let slice = await this.getSlice(offset, heuristicAreaSize) if ( detectionString === getValues(slice, fieldTypes.ASCII, detectionString.length, offset) ) { const valuesString = getValues( slice, fieldTypes.ASCII, heuristicAreaSize, offset ) const firstLine = valuesString.split("\n")[0] const metadataSize = Number(firstLine.split("=")[1].split(" ")[0]) + firstLine.length if (metadataSize > heuristicAreaSize) { slice = await this.getSlice(offset, metadataSize) } const fullString = getValues( slice, fieldTypes.ASCII, metadataSize, offset ) this.ghostValues = {} fullString .split("\n") .filter((line) => line.length > 0) .map((line) => line.split("=")) .forEach(([key, value]) => { this.ghostValues[key] = value }) } return this.ghostValues } /** * Parse a (Geo)TIFF file from the given source. * * @param {*} source The source of data to parse from. * @param {GeoTIFFOptions} [options] Additional options. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted */ static async fromSource(source, options, signal) { const headerData = ( await source.fetch([{ offset: 0, length: 1024 }], signal) )[0] 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) } /** * Closes the underlying file buffer * N.B. After the GeoTIFF has been completely processed it needs * to be closed but only if it has been constructed from a file. */ close() { if (typeof this.source.close === "function") { return this.source.close() } return false } } 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.parseFileDirectoryAt(this.mainFile.firstIFDOffset), ].concat( this.overviewFiles.map((file) => file.parseFileDirectoryAt(file.firstIFDOffset) ) ) 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 {Promise<GeoTIFFImage>} the image at the given index */ async getImage(index = 0) { await this.getImageCount() await this.parseFileDirectoriesPerFile() let visited = 0 let relativeIndex = 0 for (let i = 0; i < this.imageFiles.length; i++) { const imageFile = this.imageFiles[i] for (let ii = 0; ii < this.imageCounts[i]; ii++) { if (index === visited) { const ifd = await imageFile.requestIFD(relativeIndex) return new GeoTIFFImage( ifd.fileDirectory, ifd.geoKeyDirectory, imageFile.dataView, imageFile.littleEndian, imageFile.cache, imageFile.source ) } visited++ relativeIndex++ } relativeIndex = 0 } throw new RangeError("Invalid image index") } /** * Returns the count of the internal subfiles. * * @returns {Promise<number>} the number of internal subfile images */ async getImageCount() { if (this.imageCount !== null) { return this.imageCount } const requests = [this.mainFile.getImageCount()].concat( this.overviewFiles.map((file) => file.getImageCount()) ) this.imageCounts = await Promise.all(requests) this.imageCount = this.imageCounts.reduce((count, ifds) => count + ifds, 0) return this.imageCount } } export { MultiGeoTIFF } /** * Creates a new GeoTIFF from a remote URL. * @param {string | string[]} url The URL to access the image from * @param {object} [options] Additional options to pass to the source. * See {@link makeRemoteSource} for details. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromUrl(url, options = {}, signal) { return GeoTIFF.fromSource(makeRemoteSource(url, options), signal) } /** * Creates a new GeoTIFF from a custom {@link BaseClient}. * @param {BaseClient} client The client. * @param {object} [options] Additional options to pass to the source. * See {@link makeRemoteSource} for details. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromCustomClient(client, options = {}, signal) { return GeoTIFF.fromSource(makeCustomSource(client, options), signal) } /** * 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. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromArrayBuffer(arrayBuffer, signal) { return GeoTIFF.fromSource(makeBufferSource(arrayBuffer), signal) } /** * 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. * * N.B. After the GeoTIFF has been completely processed it needs * to be closed but only if it has been constructed from a file. * @param {string} path The file path to read from. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromFile(path, signal) { return GeoTIFF.fromSource(makeFileSource(path), signal) } /** * 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. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<GeoTIFF>} The resulting GeoTIFF file. */ export async function fromBlob(blob, signal) { return GeoTIFF.fromSource(makeFileReaderSource(blob), signal) } /** * 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. * @param {AbortSignal} [signal] An AbortSignal that may be signalled if the request is * to be aborted * @returns {Promise<MultiGeoTIFF>} The resulting MultiGeoTIFF file. */ export async function fromUrls( mainUrl, overviewUrls = [], options = {}, signal ) { const mainFile = await GeoTIFF.fromSource( makeRemoteSource(mainUrl, options), signal ) 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 function writeArrayBuffer(values, metadata) { return writeGeotiff(values, metadata) } export { Pool } export { GeoTIFFImage } export { BaseClient, BaseResponse }