s2-tools
Version:
A collection of geospatial tools primarily designed for WGS84, Web Mercator, and S2.
478 lines • 19.8 kB
JavaScript
import { applyPredictor } from './predictor';
import { buildTransform } from './proj';
import { getDecoder } from './decoder';
import { buildSamples, convertColorSpace } from './color';
import { needsNormalization, normalizeArray, sampleSum, toArrayType } from './imageUtil';
/** A Container for a GeoTIFF image */
export class GeoTIFFImage {
#reader;
#imageDirectory;
#littleEndian;
#isTiled = false;
#planarConfiguration = 1;
#transformer;
/**
* @param reader - the reader containing the input data
* @param imageDirectory - the image directory
* @param littleEndian - true if little endian false if big endian
* @param gridStore - the grid readers to utilize if needed
* @param definitions - an array of projection definitions for the transformer if needed
* @param epsgCodes - a record of EPSG codes to use for the transformer if needed
*/
constructor(reader, imageDirectory, littleEndian, gridStore, definitions = [], epsgCodes = {}) {
this.#reader = reader;
this.#imageDirectory = imageDirectory;
this.#littleEndian = littleEndian;
if (imageDirectory.StripOffsets === undefined)
this.#isTiled = true;
if (imageDirectory.PlanarConfiguration !== undefined)
this.#planarConfiguration = imageDirectory.PlanarConfiguration;
this.#transformer = buildTransform(this.#imageDirectory.geoKeyDirectory, gridStore, definitions, epsgCodes);
}
/**
* Get the image width
* @returns - the image width
*/
get width() {
return this.#imageDirectory.ImageWidth ?? 0;
}
/**
* Get the image height
* @returns - the image height
*/
get height() {
return this.#imageDirectory.ImageLength ?? 0;
}
/**
* Get the tile width
* @returns - the tile width
*/
get tileWidth() {
return this.#isTiled ? (this.#imageDirectory.TileWidth ?? 0) : this.width;
}
/**
* Get the tile height
* @returns - the tile height
*/
get tileHeight() {
const { TileLength, RowsPerStrip } = this.#imageDirectory;
return this.#isTiled ? (TileLength ?? 0) : Math.min(this.height, RowsPerStrip ?? Infinity);
}
/**
* Get the block width
* @returns - the block width
*/
get blockWidth() {
return this.tileWidth;
}
/**
* Get the block height
* @param y - the y coordinate of the block
* @returns - the block height
*/
getBlockHeight(y) {
if (this.#isTiled || (y + 1) * this.tileHeight <= this.height) {
return this.tileHeight;
}
else {
return this.height - y * this.tileHeight;
}
}
/**
* Calculates the number of bytes for each pixel across all samples. Only full
* bytes are supported, an exception is thrown when this is not the case.
* @returns the bytes per pixel
*/
get bytesPerPixel() {
const bitsPerSample = this.#imageDirectory.BitsPerSample ?? [];
let bytes = 0;
for (let i = 0; i < bitsPerSample.length; ++i) {
bytes += Math.ceil(bitsPerSample[i] / 8);
}
return bytes;
}
/**
* Returns the number of samples per pixel.
* @returns the number of samples per pixel
*/
get samplesPerPixel() {
const { SamplesPerPixel } = this.#imageDirectory;
return SamplesPerPixel !== undefined ? SamplesPerPixel : 1;
}
/**
* Returns the sample format
* @param sampleIndex - the sample index to start at
* @returns the sample format code
*/
getSampleFormat(sampleIndex = 0) {
const { SampleFormat } = this.#imageDirectory;
return Array.isArray(SampleFormat) ? SampleFormat[sampleIndex] : 1;
}
/**
* Returns the number of bits per sample
* @param sampleIndex - the sample index to start at
* @returns the number of bits per sample at the sample index
*/
getBitsPerSample(sampleIndex = 0) {
const { BitsPerSample } = this.#imageDirectory;
return (BitsPerSample ?? [])[sampleIndex];
}
/**
* Convert the data format and bits per sample to the appropriate array type
* @param raster - the data
* @returns - the array
*/
rasterToArrayType(raster) {
const format = this.getSampleFormat();
const bitsPerSample = this.getBitsPerSample();
return toArrayType(raster, format, bitsPerSample);
}
/**
* Returns an array of tiepoints.
* @returns - An array of tiepoints
*/
get tiePoints() {
const tiepoint = this.#imageDirectory.tiepoint ?? [];
const tiePoints = [];
for (let i = 0; i < tiepoint.length; i += 6) {
tiePoints.push({
i: tiepoint[i],
j: tiepoint[i + 1],
k: tiepoint[i + 2],
x: tiepoint[i + 3],
y: tiepoint[i + 4],
z: tiepoint[i + 5],
});
}
return tiePoints;
}
/**
* Returns the image origin as a XYZ-vector. When the image has no affine
* transformation, then an exception is thrown.
* @returns The origin as a vector
*/
get origin() {
const { tiepoint, ModelTransformation: transform } = this.#imageDirectory;
if (Array.isArray(tiepoint) && tiepoint.length === 6) {
return { x: tiepoint[3], y: tiepoint[4], z: tiepoint[5] };
}
else if (transform !== undefined) {
return { x: transform[3], y: transform[7], z: transform[11] };
}
throw new Error('The image does not have an affine transformation.');
}
/**
* Returns the image origin as a XYZ-vector in lon-lat space. When the image has no affine
* transformation, then an exception is thrown.
* @returns The origin as a lon-lat vector
*/
get originLL() {
const { origin } = this;
return this.#transformer.forward(origin);
}
/**
* Returns the image resolution as a XYZ-vector. When the image has no affine
* transformation, then an exception is thrown. in cases when the current image does
* not have the required tags on its own.
* @returns The resolution as a vector
*/
get resolution() {
const { sqrt } = Math;
const { pixelScale, ModelTransformation: transform } = this.#imageDirectory;
if (Array.isArray(pixelScale)) {
return { x: pixelScale[0], y: -pixelScale[1], z: pixelScale[2] };
}
if (transform !== undefined) {
if (transform[1] === 0 && transform[4] === 0) {
return { x: transform[0], y: -transform[5], z: transform[10] };
}
return {
x: sqrt(transform[0] * transform[0] + transform[4] * transform[4]),
y: -sqrt(transform[1] * transform[1] + transform[5] * transform[5]),
z: transform[10],
};
}
throw new Error('The image does not have an affine transformation.');
}
/**
* Returns the image resolution as a XYZ-vector in lon-lat space. When the image has no affine
* transformation, then an exception is thrown. in cases when the current image does not
* have the required tags on its own.
* @returns The resolution as a lon-lat vector
*/
get resolutionLL() {
const { resolution } = this;
return this.#transformer.forward(resolution);
}
/**
* Returns whether or not the pixels of the image depict an area (or point).
* @returns Whether the pixels are a point
*/
get pixelIsArea() {
return this.#imageDirectory.geoKeyDirectory?.GTRasterTypeGeoKey === 1;
}
/**
* Returns the image bounding box as an array of 4 values: min-x, min-y,
* max-x and max-y. When the image has no affine transformation, then an
* exception is thrown.
* @param transform - apply affine transformation or proj4 transformation
* @returns The bounding box
*/
getBoundingBox(transform = true) {
const { height, width } = this;
const { ModelTransformation } = this.#imageDirectory;
if (ModelTransformation !== undefined && transform) {
const [a, b, _c, d, e, f, _g, h] = ModelTransformation;
const corners = [
[0, 0],
[0, height],
[width, 0],
[width, height],
];
const projected = corners.map(([I, J]) => [d + a * I + b * J, h + e * I + f * J]);
const xs = projected.map((pt) => pt[0]);
const ys = projected.map((pt) => pt[1]);
return [Math.min(...xs), Math.min(...ys), Math.max(...xs), Math.max(...ys)];
}
else {
const { x: x1, y: y1 } = this.origin;
const { x: r1, y: r2 } = this.resolution;
const x2 = x1 + r1 * width;
const y2 = y1 + r2 * height;
const minX = Math.min(x1, x2);
const minY = Math.min(y1, y2);
const maxX = Math.max(x1, x2);
const maxY = Math.max(y1, y2);
if (transform) {
const { x: tminX, y: tminY } = this.#transformer.forward({ x: minX, y: minY });
const { x: tmaxX, y: tmaxY } = this.#transformer.forward({ x: maxX, y: maxY });
return [tminX, tminY, tmaxX, tmaxY];
}
else {
return [minX, minY, maxX, maxY];
}
}
}
/**
* Returns the raster data of the image.
* @param samples - Samples to read from the image
* @returns - The raster data
*/
async rasterData(samples = []) {
const { tileWidth, tileHeight, width, height, samplesPerPixel } = this;
const bitsPerSample = this.#imageDirectory.BitsPerSample ?? [];
const decodeFn = getDecoder(this.#imageDirectory.Compression);
if (samples.length === 0)
samples = [...Array(samplesPerPixel).keys()];
let bytesPerPixel = this.bytesPerPixel;
const srcSampleOffsets = [];
const sampleReaders = [];
for (let i = 0; i < samples.length; ++i) {
if (this.#planarConfiguration === 1) {
srcSampleOffsets.push(sampleSum(bitsPerSample, 0, samples[i]) / 8);
}
else {
srcSampleOffsets.push(0);
}
sampleReaders.push(this.getReaderForSample(samples[i]));
}
const res = Array(width * height * samplesPerPixel);
const maxXTile = Math.ceil(width / tileWidth);
const maxYTile = Math.ceil(height / tileHeight);
for (let yTile = 0; yTile < maxYTile; ++yTile) {
for (let xTile = 0; xTile < maxXTile; ++xTile) {
let data;
if (this.#planarConfiguration === 1) {
data = await this.getTileOrStrip(xTile, yTile, 0, decodeFn);
}
for (let sampleIndex = 0; sampleIndex < samples.length; ++sampleIndex) {
const si = sampleIndex;
const sample = samples[sampleIndex];
if (this.#planarConfiguration === 2) {
bytesPerPixel = Math.ceil(bitsPerSample[sample] / 8);
data = await this.getTileOrStrip(xTile, yTile, sample, decodeFn);
}
if (data === undefined)
throw new Error('data failed to load');
const dataView = new DataView(data);
const blockHeight = this.getBlockHeight(yTile);
const firstLine = yTile * tileHeight;
const firstCol = xTile * tileWidth;
const lastLine = firstLine + blockHeight;
const lastCol = (xTile + 1) * tileWidth;
const reader = sampleReaders[si];
const ymax = Math.min(blockHeight, blockHeight - (lastLine - height), height - firstLine);
const xmax = Math.min(tileWidth, tileWidth - (lastCol - width), width - firstCol);
for (let y = 0; y < ymax; ++y) {
for (let x = 0; x < xmax; ++x) {
const pixelOffset = (y * tileWidth + x) * bytesPerPixel;
const value = reader.call(dataView, pixelOffset + srcSampleOffsets[si], this.#littleEndian);
const windowCoordinate = (y + firstLine) * width * samples.length + (x + firstCol) * samples.length + si;
res[windowCoordinate] = value;
}
}
}
}
}
return { data: this.rasterToArrayType(res), width, height, alpha: false };
}
/**
* Returns the RGBA raster data of the image.
* @returns - The RGBA raster data
*/
async getRGBA() {
const bitsPerSample = this.#imageDirectory.BitsPerSample ?? [0];
const extraSamples = (this.#imageDirectory.ExtraSamples ?? [0])[0];
const pi = this.#imageDirectory.PhotometricInterpretation;
const samples = buildSamples(pi, bitsPerSample, extraSamples);
const rasterData = await this.rasterData(samples);
const max = 2 ** this.getBitsPerSample();
convertColorSpace(pi, rasterData, max, this.#imageDirectory.ColorMap);
rasterData.alpha = extraSamples !== 0;
return rasterData;
}
/**
* Build a vector feature from the image
* @returns - The vector feature with rgba values incoded into the points
*/
async getMultiPointVector() {
const { width, height, alpha, data } = await this.getRGBA();
const [minX, minY, maxX, maxY] = this.getBoundingBox(false);
const coordinates = [];
const rgbaStride = alpha ? 4 : 3;
const boundX = minX === maxX ? 1 : maxX - minX;
const boundY = minY === maxY ? 1 : maxY - minY;
const areaXStride = this.pixelIsArea ? (0.5 / width) * boundX : 0;
const areaYStride = this.pixelIsArea ? (0.5 / height) * boundY : 0;
const pixelXStride = width === 1 ? 1 : width - 1;
const pixelYStride = height === 1 ? 1 : height - 1;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// Adjust xPos and yPos relative to the bounding box
const xPos = minX + (x / pixelXStride) * boundX + areaXStride;
const yPos = minY + (y / pixelYStride) * boundY + areaYStride;
// Extract RGBA values
const r = data[y * width * rgbaStride + x * rgbaStride];
const g = data[y * width * rgbaStride + x * rgbaStride + 1];
const b = data[y * width * rgbaStride + x * rgbaStride + 2];
const a = alpha ? data[y * width * rgbaStride + x * rgbaStride + 3] : 255;
// find the lon-lat coordinates of the point
const { x: lon, y: lat } = this.#transformer.forward({ x: xPos, y: yPos });
// Add point to coordinates array
coordinates.push({
x: lon,
y: lat,
m: { r, g, b, a },
});
}
}
return {
geometry: {
type: 'MultiPoint',
is3D: false,
coordinates,
},
width,
height,
alpha,
};
}
/**
* Returns the reader for a sample
* @param sampleIndex - the index of the sample
* @returns - a function to read each sample value
*/
getReaderForSample(sampleIndex) {
const bitsPerSample = (this.#imageDirectory.BitsPerSample ?? [])[sampleIndex];
const format = this.#imageDirectory.SampleFormat !== undefined
? this.#imageDirectory.SampleFormat[sampleIndex]
: 1;
switch (format) {
case 1: // unsigned integer data
if (bitsPerSample <= 8) {
return DataView.prototype.getUint8;
}
else if (bitsPerSample <= 16) {
return DataView.prototype.getUint16;
}
else if (bitsPerSample <= 32) {
return DataView.prototype.getUint32;
}
break;
case 2: // twos complement signed integer data
if (bitsPerSample <= 8) {
return DataView.prototype.getInt8;
}
else if (bitsPerSample <= 16) {
return DataView.prototype.getInt16;
}
else if (bitsPerSample <= 32) {
return DataView.prototype.getInt32;
}
break;
case 3:
switch (bitsPerSample) {
case 16:
return DataView.prototype.getFloat16;
case 32:
return DataView.prototype.getFloat32;
case 64:
return DataView.prototype.getFloat64;
default:
break;
}
break;
}
throw Error('Unsupported data format/bitsPerSample');
}
/**
* Get the data for a tile or strip
* @param x - the tile or strip x coordinate
* @param y - the tile or strip y coordinate
* @param sample - the sample
* @param decodeFn - the function to decode the data
* @returns - the data as a buffer
*/
async getTileOrStrip(x, y, sample, decodeFn) {
const { TileOffsets, TileByteCounts, StripOffsets, StripByteCounts } = this.#imageDirectory;
const numTilesPerRow = Math.ceil(this.width / this.tileWidth);
const numTilesPerCol = Math.ceil(this.height / this.tileHeight);
const index = this.#planarConfiguration === 1
? y * numTilesPerRow + x
: this.#planarConfiguration === 2
? sample * numTilesPerRow * numTilesPerCol + y * numTilesPerRow + x
: 0;
const offset = this.#isTiled ? (TileOffsets ?? [])[index] : (StripOffsets ?? [])[index];
const byteCount = this.#isTiled
? (TileByteCounts ?? [])[index]
: (StripByteCounts ?? [])[index];
const slice = this.#reader.slice(offset, offset + byteCount).buffer;
let data = await decodeFn(slice, this.#imageDirectory.JPEGTables);
data = this.maybeApplyPredictor(data);
const sampleFormat = this.getSampleFormat();
const bitsPerSample = this.getBitsPerSample();
if (needsNormalization(sampleFormat, bitsPerSample)) {
data = normalizeArray(data, sampleFormat, this.#planarConfiguration, this.samplesPerPixel, bitsPerSample, this.tileWidth, this.getBlockHeight(y));
}
return data;
}
/**
* Apply the predictor if necessary
* @param data - the raw data
* @returns - the data with the predictor applied
*/
maybeApplyPredictor(data) {
const predictor = this.#imageDirectory.Predictor ?? 1;
if (predictor === 1) {
return data;
}
else {
const tileWidth = this.#isTiled ? this.tileWidth : this.width;
const tileHeight = this.#isTiled
? this.tileHeight
: (this.#imageDirectory.RowsPerStrip ?? this.height);
return applyPredictor(data, predictor, tileWidth, tileHeight, this.#imageDirectory.BitsPerSample ?? [], this.#planarConfiguration);
}
}
}
//# sourceMappingURL=image.js.map