diff --git a/README.md b/README.md index 10d8f516..7d138172 100755 --- a/README.md +++ b/README.md @@ -3,6 +3,9 @@

+ + GitHub Actions Workflow Status + npm @@ -122,6 +125,8 @@ To generate the coverage report, use the following command: ```bash cargo tarpaulin +# faster +cargo tarpaulin --color always --skip-clean # bacon bacon coverage # or type `l` inside the tool ``` diff --git a/bacon.toml b/bacon.toml index d89bb579..3df8c173 100644 --- a/bacon.toml +++ b/bacon.toml @@ -42,7 +42,7 @@ need_stdout = true command = [ "sh", "-c", - "cargo tarpaulin --out xml > /dev/null 2>&1 && pycobertura show cobertura.xml", + "cargo tarpaulin --skip-clean --color always --out xml > /dev/null 2>&1 && pycobertura show cobertura.xml", ] need_stdout = true diff --git a/src/converters/toTiles/file.ts b/src/converters/toTiles/file.ts new file mode 100644 index 00000000..e69de29b diff --git a/src/converters/toTiles/index.ts b/src/converters/toTiles/index.ts index 35a29b11..d8f8cc22 100644 --- a/src/converters/toTiles/index.ts +++ b/src/converters/toTiles/index.ts @@ -1,11 +1,12 @@ import { MetadataBuilder } from 's2-tilejson'; import type { Reader } from '../../readers'; +import type { TileWriter } from '../../writers'; import type { VectorFeatures } from '../../geometry'; import type { Attribution, Encoding, LayerMetaData, Scheme } from 's2-tilejson'; -import type { TileWriter, Writer } from '../../writers'; import type { ClusterOptions } from '../../dataStructures/pointCluster'; +import type { TileStoreOptions } from '../../dataStructures/tile'; /** A layer defines the exact mechanics of what data to parse and how the data is stored */ export interface SourceLayer { @@ -15,16 +16,69 @@ export interface SourceLayer { metadata: LayerMetaData; } +/** + * Before tiling the data, you can mutate it here. It can also act as a filter if you return undefined + */ +export type OnFeature = (feature: VectorFeatures) => VectorFeatures | undefined; + /** Sources are the blueprints of what data to fetch and how to store it */ -export interface Source { +export interface BaseSource { + /** The name of the source */ + name: string; /** The reader to parse the data from */ data: Reader; +} + +/** Vector source */ +export interface VectorSource extends BaseSource { + /** The layers to construct and organize the data around for this source */ + layers: SourceLayer[]; /** If options are provided, the assumption is the point data is clustered */ cluster?: ClusterOptions; + /** tile buffer on each side so lines and polygons don't get clipped */ + buffer?: number; + /** whether to build the bounding box for each tile feature */ + buildBBox?: boolean; /** Before tiling the data, you can mutate it here. It can also act as a filter if you return undefined */ - onFeature?: (feature: VectorFeatures) => VectorFeatures | undefined; - /** The layers to construct and organize the data around for this source */ - layers: SourceLayer[]; + onFeature?: OnFeature; +} + +/** Raster source */ +export interface RasterSource extends BaseSource { + description?: string; +} + +/** A layerguide is a minified version of a layer used by workers to build tiles */ +export interface LayerGuide { + /** Components of how the layer is built and stored */ + metadata: LayerMetaData; + /** Stringified version of the onFeature used by the source so it can be shipped to a worker. */ + onFeature?: string; + /** Guide on how to splice the data into vector tiles */ + tileGuide: TileStoreOptions; +} + +/** A parsed LayerGuide where onFeature is parsed back into a function */ +export interface ParsedLayerGuide extends Omit { + onFeature?: OnFeature; +} + +/** A Source Guide is a minified version of a source used by workers to build tiles */ +export interface SourceGuide { + /** The name of the source */ + [sourceName: string]: { + /** Name of the associated layer */ + [layerName: string]: LayerGuide; + }; +} + +/** A parsed SourceGuide where onFeature is parsed back into a function */ +export interface ParsedSourceGuide { + /** The name of the source */ + [sourceName: string]: { + /** Name of the associated layer */ + [layerName: string]: ParsedLayerGuide; + }; } /** A user defined guide on building the vector tiles */ @@ -42,9 +96,11 @@ export interface BuildGuide { scheme?: Scheme; /** The encoding format. Can be either 'gz', 'br', 'zstd' or 'none' [Default: 'gz'] */ encoding?: Encoding; - /** The sources that the tile is built from and how the layers are to be stored */ - sources: Source[]; - /** The attribution of the data. Store as { key: presentation name }. [value: href link] */ + /** The vector sources that the tile is built from and how the layers are to be stored */ + vectorSources?: VectorSource[]; + /** The raster sources that will be conjoined into a single rgba pixel index for tile extraction */ + rasterSources?: RasterSource[]; + /** The attribution of the data. Store as { key: 'presentation name' }. [value: href link] */ attribution?: Attribution; /** * The vector format if applicable helps define how the vector data is stored. @@ -52,35 +108,40 @@ export interface BuildGuide { * and 3D geometries. * - The legacy vector format is the 'open-v1' which only supports 2D geometries and works on * older map engines like Mapbox-gl-js. + * [Default: 'open-v2'] */ - vectorFormat?: 'open-v2' | 'open-v1'; + vectorFormat?: 'open-v1' | 'open-v2'; /** * The data created will be stored in either a folder structure or a pmtiles file * Folder structure is either '{face}/{zoom}/{x}/{y}.pbf' or '{zoom}/{x}/{y}.pbf'. * PMTiles store all data in a single data file. */ tileWriter: TileWriter; - /** Explain to the module what kind of writer to use (A buffer or file writer) */ - writer: Writer; + /** Set the number of threads to use. [Default: 1] */ + threads?: number; } /** * Build vector tiles give a guide on what sources to parse data from and how to store it * @param buildGuide - the user defined guide on building the vector tiles */ -export async function toTiles(buildGuide: BuildGuide): Promise { +export async function toVectorTiles(buildGuide: BuildGuide): Promise { const { tileWriter } = buildGuide; - // first setup our metadata builder - const metaBuilder = new MetadataBuilder(); - updateBuilder(metaBuilder, buildGuide); + // TODO: Prepare init messages for workers // TODO: iterate features and have workers split/store them + // TODO: externalSort on features at this point. + // TODO: have workers build tiles - // FINISH: + // build metadata based on the guide + const metaBuilder = new MetadataBuilder(); + updateBuilder(metaBuilder, buildGuide); const metadata = metaBuilder.commit(); + + // FINISH await tileWriter.commit(metadata); } @@ -89,7 +150,7 @@ export async function toTiles(buildGuide: BuildGuide): Promise { * @param buildGuide - the user defined guide on building the vector tiles */ function updateBuilder(metaBuilder: MetadataBuilder, buildGuide: BuildGuide): void { - const { name, description, version, scheme, encoding, attribution, sources } = buildGuide; + const { name, description, version, scheme, encoding, attribution, vectorSources } = buildGuide; metaBuilder.setName(name); metaBuilder.setExtension('pbf'); @@ -103,14 +164,13 @@ function updateBuilder(metaBuilder: MetadataBuilder, buildGuide: BuildGuide): vo metaBuilder.addAttribution(displayName, href); } } - for (const { layers } of sources) { - for (const layer of layers) { - metaBuilder.addLayer(layer.name, layer.metadata); - } + for (const { layers } of vectorSources ?? []) { + for (const layer of layers) metaBuilder.addLayer(layer.name, layer.metadata); } } // TODO: +// VECTOR: // - step 1: ship individual features to workers // - - step 1a: splite the features into tiles, requesting all tiles from range min->max of the layer // - - step 1b: store all those features into a multimap where the key is the tile-id and the value is the features @@ -119,3 +179,18 @@ function updateBuilder(metaBuilder: MetadataBuilder, buildGuide: BuildGuide): vo // - - step 2b: build the tile from the features, gzip, etc. then ship the buffer and metadata to main thread // - - step 2c: store metadata into metaBuilder and the buffer into the store // finish +// +// CLUSTER: +// - step 1: for each point, just add. +// - step 2: cluster. +// - step 3: request tiles as needed. +// +// RASTER: +// - step 1: for every pixel, store to a point index where the m-value is the rgba value +// - step 2: build tiles from the point index +// - - step 2a: start at max zoom - for each pixel in the tile search the range and find average +// of all the points in that range (remember color is logarithmic so multiply each pixel r-g-b-a by itself first) +// If too far zoomed in, find the closest pixel within a resonable radius. +// - - step 2b: after finishing max zoom, use https://github.com/rgba-image/lanczos as I move towards minzoom. +// - - sidenote: I think the easiest way to build tiles is start at 0 zoom and dive down everytime we find at least one point, then move back up +// finish diff --git a/src/converters/toTiles/tileWorkers/buildTile.ts b/src/converters/toTiles/tileWorkers/buildTile.ts new file mode 100644 index 00000000..c3ae9278 --- /dev/null +++ b/src/converters/toTiles/tileWorkers/buildTile.ts @@ -0,0 +1,16 @@ +declare let self: Worker; + +// TWO WAYS TO BUILD TILES: +// * Vector Tiles +// * Raster Tiles + +/** + * A worker that sorts a chunk of a file and sends it to an output directory + * @param event - the sort chunk message + * @param _event + */ +self.onmessage = (_event: Bun.MessageEvent): void => { + // void sortChunk(event.data as SortChunk).then((outFile): void => { + // postMessage(outFile); + // }); +}; diff --git a/src/converters/toTiles/vectorWorker/file.ts b/src/converters/toTiles/vectorWorker/file.ts new file mode 100644 index 00000000..00328a4a --- /dev/null +++ b/src/converters/toTiles/vectorWorker/file.ts @@ -0,0 +1,14 @@ +declare let self: Worker; + +import { FileMultiMap } from '../../../dataStore/multimap/file'; +import VectorTileWorker from './vectorTileWorker'; + +import type { VectorFeature } from '../../../geometry'; + +/** Convert a vector feature to a collection of tiles and store each tile feature */ +class FileVectorTileWorker extends VectorTileWorker { + writer = new FileMultiMap(); +} + +const vecWorker = new FileVectorTileWorker(); +self.onmessage = vecWorker.onmessage.bind(vecWorker); diff --git a/src/converters/toTiles/vectorWorker/index.ts b/src/converters/toTiles/vectorWorker/index.ts new file mode 100644 index 00000000..da1bdbad --- /dev/null +++ b/src/converters/toTiles/vectorWorker/index.ts @@ -0,0 +1,6 @@ +declare let self: Worker; + +import VectorTileWorker from './vectorTileWorker'; + +const vecWorker = new VectorTileWorker(); +self.onmessage = vecWorker.onmessage.bind(vecWorker); diff --git a/src/converters/toTiles/vectorWorker/vectorTileWorker.ts b/src/converters/toTiles/vectorWorker/vectorTileWorker.ts new file mode 100644 index 00000000..10de5828 --- /dev/null +++ b/src/converters/toTiles/vectorWorker/vectorTileWorker.ts @@ -0,0 +1,71 @@ +import { MultiMap } from '../../../dataStore'; + +import type { MultiMapStore } from '../../../dataStore'; +import type { VectorFeature } from '../../../geometry'; +import type { OnFeature, ParsedSourceGuide, SourceGuide } from '../..'; + +/** Take in options that will be used to create a tiled data correctly */ +export interface InitMessage { + /** Message type */ + type: 'init'; + /** The sources that will be used to create the tile */ + sources: SourceGuide; +} + +/** Take in a feature that will be added to the tile */ +export interface FeatureMessage { + /** Message type */ + type: 'feature'; + /** The feature to add to the tile */ + feature: VectorFeature; +} + +/** Convert a vector feature to a collection of tiles and store each tile feature */ +export default class VectorTileWorker { + sources: ParsedSourceGuide = {}; + writer: MultiMapStore = new MultiMap(); + /** + * Tile-ize input vector features and store them + * @param event - the init message or a feature message + */ + onmessage(event: Bun.MessageEvent): void { + this.handleMessage(event.data); + self.postMessage({ type: 'ready' }); + } + + /** + * Tile-ize input vector features and store them + * @param message - the init message or a feature message + */ + handleMessage(message: InitMessage | FeatureMessage): void { + const { type } = message; + if (type === 'init') { + this.sources = parseSourceGuide(message.sources); + } else { + // TODO: + } + } +} + +/** + * Convert a source guide to a parsed source guide (where onFeature is parsed back into a function) + * @param sourceGuide - the source guide to parse + * @returns the parsed source guide + */ +function parseSourceGuide(sourceGuide: SourceGuide): ParsedSourceGuide { + const res: ParsedSourceGuide = {}; + + for (const [sourceName, source] of Object.entries(sourceGuide)) { + for (const [layerName, layer] of Object.entries(source)) { + res[sourceName][layerName] = { + ...layer, + onFeature: + layer.onFeature !== undefined + ? (new Function(layer.onFeature)() as OnFeature) + : undefined, + }; + } + } + + return res; +} diff --git a/src/dataStructures/tile.ts b/src/dataStructures/tile.ts index f4f73b25..6738e6bc 100644 --- a/src/dataStructures/tile.ts +++ b/src/dataStructures/tile.ts @@ -95,7 +95,7 @@ export class Tile { * @param ti - tile i * @param tj - tile j */ -function _transform(geometry: VectorGeometry, zoom: number, ti: number, tj: number) { +function _transform(geometry: VectorGeometry, zoom: number, ti: number, tj: number): void { const { type, coordinates } = geometry; zoom = 1 << zoom; @@ -141,7 +141,7 @@ export interface TileStoreOptions { minzoom?: number; /** max zoom level to cluster the points on */ maxzoom?: number; - /** tile buffer on each side in pixels */ + /** max zoom to index data on construction */ indexMaxzoom?: number; /** simplification tolerance (higher means simpler) */ tolerance?: number; diff --git a/src/file.ts b/src/file.ts index 3502333e..00e969f2 100644 --- a/src/file.ts +++ b/src/file.ts @@ -6,3 +6,4 @@ export * from './dataStore/vector/file'; export * from './readers/shapefile/file'; export * from './readers/file'; export * from './writers/file'; +export * from './writers/tile'; diff --git a/src/tools/lanczos/index.ts b/src/tools/lanczos/index.ts new file mode 100644 index 00000000..0eab19db --- /dev/null +++ b/src/tools/lanczos/index.ts @@ -0,0 +1,62 @@ +import { copy, createImage, resize } from './util'; + +export * from './util'; + +/** + * Lanczos filter function for downscaling + * @param source - the source image + * @param dest - the destination image + * @param sx - source starting x point [Default: 0] + * @param sy - source starting y point [Default: 0] + * @param sw - source width to use [Default: source width - sx] + * @param sh - source height to use [Default: source height - sy] + * @param dx - destination starting x point [Default: 0] + * @param dy - destination starting y point [Default: 0] + * @param dw - destination width to use [Default: destination width - dx] + * @param dh - destination height to use [Default: destination height - dy] + * @param use2 - use 2nd lanczos filter instead of 3rd [Default: false] + */ +export function lanczos( + source: ImageData, + dest: ImageData, + sx = 0, + sy = 0, + sw = source.width - sx, + sh = source.height - sy, + dx = 0, + dy = 0, + dw = dest.width - dx, + dh = dest.height - dy, + use2 = false, +): void { + sx |= 0; + sy |= 0; + sw |= 0; + sh |= 0; + dx |= 0; + dy |= 0; + dw |= 0; + dh |= 0; + + if (sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) return; + + if ( + sx === 0 && + sy === 0 && + sw === source.width && + sh === source.height && + dx === 0 && + dy === 0 && + dw === dest.width && + dh === dest.height + ) { + resize(source, dest, use2); + return; + } + + const croppedSource = createImage(sw, sh); + const croppedDest = createImage(dw, dh); + copy(source, croppedSource, sx, sy); + resize(croppedSource, croppedDest, use2); + copy(croppedDest, dest, 0, 0, croppedDest.width, croppedDest.height, dx, dy); +} diff --git a/src/tools/lanczos/util.ts b/src/tools/lanczos/util.ts new file mode 100644 index 00000000..bec80fca --- /dev/null +++ b/src/tools/lanczos/util.ts @@ -0,0 +1,335 @@ +const FIXED_FRAC_BITS = 14; + +/** + * Filter input value given a filter window. + * @param x - input + * @param a - filter window + * @returns - filtered value + */ +function filterValue(x: number, a: 2 | 3): number { + if (x <= -a || x >= a) return 0; + if (x === 0) return 0; + // appears to do nothing? + // if ( x > -1.19209290e-07 && x < 1.19209290e-07 ) return 1 + const xPi = x * Math.PI; + + return ((Math.sin(xPi) / xPi) * Math.sin(xPi / a)) / (xPi / a); +} + +/** + * Convert value to fixed point + * @param value - input + * @returns - fixed point + */ +function toFixedPoint(value: number): number { + return Math.round(value * ((1 << FIXED_FRAC_BITS) - 1)); +} + +/** + * Create a Lanczos filter + * @param srcSize - source image size + * @param destSize - destination image size + * @param scale - scale factor + * @param offset - offset to apply + * @param use2 - use 2nd lanczos filter instead of 3rd + * @returns - filter + */ +export function filters( + srcSize: number, + destSize: number, + scale: number, + offset: number, + use2: boolean, +): Int16Array { + const a = use2 ? 2 : 3; + const scaleInverted = 1 / scale; + const scaleClamped = Math.min(1, scale); // For upscale + + // Filter window (averaging interval), scaled to src image + const srcWindow = a / scaleClamped; + + const maxFilterElementSize = Math.floor((srcWindow + 1) * 2); + const packedFilter = new Int16Array((maxFilterElementSize + 2) * destSize); + + let packedFilterPtr = 0; + + // For each destination pixel calculate source range and built filter values + for (let destPixel = 0; destPixel < destSize; destPixel++) { + // Scaling should be done relative to central pixel point + const sourcePixel = (destPixel + 0.5) * scaleInverted + offset; + const sourceFirst = Math.max(0, Math.floor(sourcePixel - srcWindow)); + const sourceLast = Math.min(srcSize - 1, Math.ceil(sourcePixel + srcWindow)); + + const filterElementSize = sourceLast - sourceFirst + 1; + const floatFilter = new Float32Array(filterElementSize); + const fxpFilter = new Int16Array(filterElementSize); + + let total = 0; + + // Fill filter values for calculated range + let index = 0; + for (let pixel = sourceFirst; pixel <= sourceLast; pixel++) { + const floatValue = filterValue((pixel + 0.5 - sourcePixel) * scaleClamped, a); + + total += floatValue; + floatFilter[index] = floatValue; + + index++; + } + + // Normalize filter, convert to fixed point and accumulate conversion error + let filterTotal = 0; + + for (let index = 0; index < floatFilter.length; index++) { + const filterValue = floatFilter[index] / total; + + filterTotal += filterValue; + fxpFilter[index] = toFixedPoint(filterValue); + } + + // Compensate normalization error, to minimize brightness drift + fxpFilter[destSize >> 1] += toFixedPoint(1 - filterTotal); + + // + // Now pack filter to useable form + // + // 1. Trim heading and tailing zero values, and compensate shitf/length + // 2. Put all to single array in this format: + // + // [ pos shift, data length, value1, value2, value3, ... ] + // + let leftNotEmpty = 0; + while (leftNotEmpty < fxpFilter.length && fxpFilter[leftNotEmpty] === 0) { + leftNotEmpty++; + } + + let rightNotEmpty = fxpFilter.length - 1; + while (rightNotEmpty > 0 && fxpFilter[rightNotEmpty] === 0) { + rightNotEmpty--; + } + + const filterShift = sourceFirst + leftNotEmpty; + const filterSize = rightNotEmpty - leftNotEmpty + 1; + + packedFilter[packedFilterPtr++] = filterShift; // shift + packedFilter[packedFilterPtr++] = filterSize; // size + + packedFilter.set(fxpFilter.subarray(leftNotEmpty, rightNotEmpty + 1), packedFilterPtr); + packedFilterPtr += filterSize; + } + + return packedFilter; +} + +/** + * Convolve an image with a filter + * @param source - the source image + * @param dest - the destination image + * @param sw - source width + * @param sh - source height + * @param dw - destination width + * @param filters - image filter + */ +export function convolve( + source: Uint8ClampedArray, + dest: Uint8ClampedArray, + sw: number, + sh: number, + dw: number, + filters: Int16Array, +): void { + let srcOffset = 0; + let destOffset = 0; + + // For each row + for (let sourceY = 0; sourceY < sh; sourceY++) { + let filterPtr = 0; + + // Apply precomputed filters to each destination row point + for (let destX = 0; destX < dw; destX++) { + // Get the filter that determines the current output pixel. + const filterShift = filters[filterPtr++]; + + let srcPtr = (srcOffset + filterShift * 4) | 0; + + let r = 0; + let g = 0; + let b = 0; + let a = 0; + + // Apply the filter to the row to get the destination pixel r, g, b, a + for (let filterSize = filters[filterPtr++]; filterSize > 0; filterSize--) { + const filterValue = filters[filterPtr++]; + + r = (r + filterValue * source[srcPtr]) | 0; + g = (g + filterValue * source[srcPtr + 1]) | 0; + b = (b + filterValue * source[srcPtr + 2]) | 0; + a = (a + filterValue * source[srcPtr + 3]) | 0; + + srcPtr = (srcPtr + 4) | 0; + } + + // Bring this value back in range. All of the filter scaling factors + // are in fixed point with FIXED_FRAC_BITS bits of fractional part. + // + // (!) Add 1/2 of value before clamping to get proper rounding. In other + // case brightness loss will be noticeable if you resize image with white + // border and place it on white background. + // + dest[destOffset] = (r + (1 << 13)) >> FIXED_FRAC_BITS; + dest[destOffset + 1] = (g + (1 << 13)) >> FIXED_FRAC_BITS; + dest[destOffset + 2] = (b + (1 << 13)) >> FIXED_FRAC_BITS; + dest[destOffset + 3] = (a + (1 << 13)) >> FIXED_FRAC_BITS; + + destOffset = (destOffset + sh * 4) | 0; + } + + destOffset = ((sourceY + 1) * 4) | 0; + srcOffset = ((sourceY + 1) * sw * 4) | 0; + } +} + +/** + * Copy the contents of the source image to the destination image + * @param source - the source image + * @param dest - the destination image + * @param sx - source starting x point [Default: 0] + * @param sy - source starting y point [Default: 0] + * @param sw - source width to use [Default: source width - sx] + * @param sh - source height to use [Default: source height - sy] + * @param dx - destination starting x point [Default: 0] + * @param dy - destination starting y point [Default: 0] + */ +export function copy( + source: ImageData, + dest: ImageData, + sx = 0, + sy = 0, + sw = source.width - sx, + sh = source.height - sy, + dx = 0, + dy = 0, +): void { + sx = sx | 0; + sy = sy | 0; + sw = sw | 0; + sh = sh | 0; + dx = dx | 0; + dy = dy | 0; + + if (sw <= 0 || sh <= 0) return; + + const sourceData = new Uint32Array(source.data.buffer); + const destData = new Uint32Array(dest.data.buffer); + + for (let y = 0; y < sh; y++) { + const sourceY = sy + y; + if (sourceY < 0 || sourceY >= source.height) continue; + + const destY = dy + y; + if (destY < 0 || destY >= dest.height) continue; + + for (let x = 0; x < sw; x++) { + const sourceX = sx + x; + if (sourceX < 0 || sourceX >= source.width) continue; + + const destX = dx + x; + if (destX < 0 || destX >= dest.width) continue; + + const sourceIndex = sourceY * source.width + sourceX; + const destIndex = destY * dest.width + destX; + + destData[destIndex] = sourceData[sourceIndex]; + } + } +} + +/** + * Create an image given the size, fill color and number of channels + * @param width - the image width + * @param height - the image height + * @param data - the image data [Default: creates a new array] + * @param fill - the fill color [Default: [0, 0, 0, 0]] + * @param channels - the number of channels [Default: 4] + * @returns - the created image + */ +export function createImage( + width: number, + height: number, + data?: Uint8ClampedArray, + fill: number[] | Uint8ClampedArray = [0, 0, 0, 0], + channels = 4, +): ImageData { + width = Math.floor(width); + height = Math.floor(height); + + if (width < 1 || height < 1) { + throw TypeError('Index or size is negative or greater than the allowed amount'); + } + + const length = width * height * channels; + + if (data === undefined) { + data = new Uint8ClampedArray(length); + } + + if (data.length !== length) { + throw TypeError('Index or size is negative or greater than the allowed amount'); + } + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const index = (y * width + x) * channels; + for (let c = 0; c < channels; c++) { + data[index + c] = fill[c]; + } + } + } + + return { data, width, height }; +} + +/** + * Lanczos resize function + * @param source - the source image + * @param dest - the destination image + * @param use2 - use 2nd lanczos filter instead of 3rd + */ +export function resize(source: ImageData, dest: ImageData, use2 = false): void { + const xRatio = dest.width / source.width; + const yRatio = dest.height / source.height; + + const filtersX = filters(source.width, dest.width, xRatio, 0, use2); + const filtersY = filters(source.height, dest.height, yRatio, 0, use2); + + const tmp = new Uint8ClampedArray(dest.width * source.height * 4); + + convolve(source.data, tmp, source.width, source.height, dest.width, filtersX); + convolve(tmp, dest.data, source.height, dest.width, dest.height, filtersY); +} + +/* + Adapted to typescript from pica: https://github.com/nodeca/pica + + (The MIT License) + + Copyright (C) 2014-2017 by Vitaly Puzrin + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. +*/ diff --git a/src/writers/tile.ts b/src/writers/tile.ts index 1aa6b36a..c7b245b7 100644 --- a/src/writers/tile.ts +++ b/src/writers/tile.ts @@ -5,7 +5,7 @@ import type { Metadata } from 's2-tilejson'; import type { TileWriter } from '.'; /** This is a filesystem Tile writer that organizes data via folders. */ -export class LocalTileWriter implements TileWriter { +export class FileTileWriter implements TileWriter { /** * @param path - the location to write the data * @param fileType - the file ending to write diff --git a/tests/tools/lanczos/fixtures/pattern-border.png b/tests/tools/lanczos/fixtures/pattern-border.png new file mode 100644 index 00000000..88bdfcf3 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-border.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-double-region.png b/tests/tools/lanczos/fixtures/pattern-double-region.png new file mode 100644 index 00000000..104f8e32 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-double-region.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-double.png b/tests/tools/lanczos/fixtures/pattern-double.png new file mode 100644 index 00000000..e333eaeb Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-double.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-half-2.png b/tests/tools/lanczos/fixtures/pattern-half-2.png new file mode 100644 index 00000000..85fa0899 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-half-2.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-half-region-2.png b/tests/tools/lanczos/fixtures/pattern-half-region-2.png new file mode 100644 index 00000000..5497771f Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-half-region-2.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-half-region.png b/tests/tools/lanczos/fixtures/pattern-half-region.png new file mode 100644 index 00000000..e4d6f5f7 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-half-region.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-half.png b/tests/tools/lanczos/fixtures/pattern-half.png new file mode 100644 index 00000000..80e757a7 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-half.png differ diff --git a/tests/tools/lanczos/fixtures/pattern-out-of-bounds.png b/tests/tools/lanczos/fixtures/pattern-out-of-bounds.png new file mode 100644 index 00000000..68b1922f Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern-out-of-bounds.png differ diff --git a/tests/tools/lanczos/fixtures/pattern.png b/tests/tools/lanczos/fixtures/pattern.png new file mode 100644 index 00000000..f843e263 Binary files /dev/null and b/tests/tools/lanczos/fixtures/pattern.png differ diff --git a/tests/tools/lanczos/index.test.ts b/tests/tools/lanczos/index.test.ts new file mode 100644 index 00000000..2b7d48f6 --- /dev/null +++ b/tests/tools/lanczos/index.test.ts @@ -0,0 +1,184 @@ +import { createImage, lanczos } from '../../../src/tools/lanczos'; +import { expect, test } from 'bun:test'; + +import sharp from 'sharp'; + +const patternPng = await sharp(`${__dirname}/fixtures/pattern.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const patternBorderPng = await sharp(`${__dirname}/fixtures/pattern-border.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternHalfPng = await sharp(`${__dirname}/fixtures/pattern-half.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternHalf2Png = await sharp(`${__dirname}/fixtures/pattern-half-2.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternDoublePng = await sharp(`${__dirname}/fixtures/pattern-double.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternHalfRegionPng = await sharp(`${__dirname}/fixtures/pattern-half-region.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternHalfRegion2Png = await sharp(`${__dirname}/fixtures/pattern-half-region-2.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternDoubleRegionPng = await sharp(`${__dirname}/fixtures/pattern-double-region.png`) + .raw() + .toBuffer({ resolveWithObject: true }); +const expectPatternOutOfBoundsPng = await sharp(`${__dirname}/fixtures/pattern-out-of-bounds.png`) + .raw() + .toBuffer({ resolveWithObject: true }); + +const pattern = { + data: new Uint8ClampedArray(patternPng.data.buffer), + width: patternPng.info.width, + height: patternPng.info.height, +} as ImageData; +const patternBorder = { + data: new Uint8ClampedArray(patternBorderPng.data.buffer), + width: patternBorderPng.info.width, + height: patternBorderPng.info.height, +} as ImageData; +const expectPatternHalf = { + data: new Uint8ClampedArray(expectPatternHalfPng.data.buffer), + width: expectPatternHalfPng.info.width, + height: expectPatternHalfPng.info.height, +} as ImageData; +const expectPatternHalf2 = { + data: new Uint8ClampedArray(expectPatternHalf2Png.data.buffer), + width: expectPatternHalf2Png.info.width, + height: expectPatternHalf2Png.info.height, +} as ImageData; +const expectPatternDouble = { + data: new Uint8ClampedArray(expectPatternDoublePng.data.buffer), + width: expectPatternDoublePng.info.width, + height: expectPatternDoublePng.info.height, +} as ImageData; +const expectPatternHalfRegion = { + data: new Uint8ClampedArray(expectPatternHalfRegionPng.data.buffer), + width: expectPatternHalfRegionPng.info.width, + height: expectPatternHalfRegionPng.info.height, +} as ImageData; +const expectPatternHalfRegion2 = { + data: new Uint8ClampedArray(expectPatternHalfRegion2Png.data.buffer), + width: expectPatternHalfRegion2Png.info.width, + height: expectPatternHalfRegion2Png.info.height, +} as ImageData; +const expectPatternDoubleRegion = { + data: new Uint8ClampedArray(expectPatternDoubleRegionPng.data.buffer), + width: expectPatternDoubleRegionPng.info.width, + height: expectPatternDoubleRegionPng.info.height, +} as ImageData; +const expectPatternOutOfBounds = { + data: new Uint8ClampedArray(expectPatternOutOfBoundsPng.data.buffer), + width: expectPatternOutOfBoundsPng.info.width, + height: expectPatternOutOfBoundsPng.info.height, +} as ImageData; + +test('lanczos - resizes down', () => { + const patternHalf = createImage(4, 4); + + lanczos(pattern, patternHalf); + + expect(patternHalf).toEqual(expectPatternHalf); +}); + +test('resizes down with lanczos2', () => { + const patternHalf = createImage(4, 4); + + lanczos( + pattern, + patternHalf, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + true, + ); + + expect(patternHalf).toEqual(expectPatternHalf2); +}); + +test('resizes up', () => { + const patternDouble = createImage(16, 16); + + lanczos(pattern, patternDouble); + + expect(patternDouble).toEqual(expectPatternDouble); +}); + +test('resizes region down', () => { + const patternHalfRegion = createImage(6, 6); + + lanczos(patternBorder, patternHalfRegion, 2, 2, 8, 8, 1, 1, 4, 4); + + expect(patternHalfRegion).toEqual(expectPatternHalfRegion); +}); + +test('resizes region down with lanczos2', () => { + const patternHalfRegion = createImage(6, 6); + + lanczos(patternBorder, patternHalfRegion, 2, 2, 8, 8, 1, 1, 4, 4, true); + + expect(patternHalfRegion).toEqual(expectPatternHalfRegion2); +}); + +test('resizes region up', () => { + const patternDoubleRegion = createImage(18, 18); + + lanczos(patternBorder, patternDoubleRegion, 2, 2, 8, 8, 1, 1, 16, 16); + + expect(patternDoubleRegion).toEqual(expectPatternDoubleRegion); +}); + +test('early return when any dimension is 0', () => { + const empty = createImage(8, 8); + const destSw = createImage(8, 8); + const destSh = createImage(8, 8); + const destDw = createImage(8, 8); + const destDh = createImage(8, 8); + + lanczos(destSw, pattern, 0, 0, 0, 8); + lanczos(destSh, pattern, 0, 0, 8, 0); + lanczos(destDw, pattern, 0, 0, 8, 8, 0, 0, 0, 8); + lanczos(destDh, pattern, 0, 0, 8, 8, 0, 0, 8, 0); + + expect(destSw).toEqual(empty); + expect(destSh).toEqual(empty); + expect(destDw).toEqual(empty); + expect(destDh).toEqual(empty); + + lanczos(destSw, pattern, 0, 0, 0, 8, undefined, undefined, undefined, undefined, true); + lanczos(destSh, pattern, 0, 0, 8, 0, undefined, undefined, undefined, undefined, true); + lanczos(destDw, pattern, 0, 0, 8, 8, 0, 0, 0, 8, true); + lanczos(destDh, pattern, 0, 0, 8, 8, 0, 0, 8, 0, true); + + expect(destSw).toEqual(empty); + expect(destSh).toEqual(empty); + expect(destDw).toEqual(empty); + expect(destDh).toEqual(empty); +}); + +test('does not sample outside bounds', () => { + const patternOutOfBounds = createImage(8, 8); + + lanczos(pattern, patternOutOfBounds, 0, 0, 16, 16, 0, 0, 32, 32); + + expect(patternOutOfBounds).toEqual(expectPatternOutOfBounds); +}); + +test('createImage errors', () => { + expect(() => { + createImage(0, 0); + }).toThrow('Index or size is negative or greater than the allowed amount'); + + expect(() => { + createImage(10, 10, new Uint8ClampedArray(0)); + }).toThrow('Index or size is negative or greater than the allowed amount'); +}); diff --git a/tests/util/polyfills/dataview.test.ts b/tests/util/polyfills/dataview.test.ts index 317be269..ebefaf75 100644 --- a/tests/util/polyfills/dataview.test.ts +++ b/tests/util/polyfills/dataview.test.ts @@ -11,9 +11,9 @@ test('getFloat16', () => { dv.setFloat16(4, 0.1); dv.setFloat16(6, -0.01, true); dv.setFloat16(8, Infinity); - expect(dv.getFloat16(0)).toEqual(0x7c00); - expect(dv.getFloat16(2, true)).toEqual(0); - expect(dv.getFloat16(4)).toEqual(0.0999755859375); - expect(dv.getFloat16(6, true)).toEqual(-0.0099945068359375); - expect(dv.getFloat16(8)).toEqual(Infinity); + expect(dv.getFloat16(0)).toBeCloseTo(0x7c00); + expect(dv.getFloat16(2, true)).toBeCloseTo(0); + expect(dv.getFloat16(4)).toBeCloseTo(0.0999755859375); + expect(dv.getFloat16(6, true)).toBeCloseTo(-0.0099945068359375); + expect(dv.getFloat16(8)).toBeCloseTo(Infinity); });