Skip to content

Commit

Permalink
feat: add support for LZW compression
Browse files Browse the repository at this point in the history
  • Loading branch information
blacha committed Jan 28, 2025
1 parent 72f2a81 commit 8f40491
Show file tree
Hide file tree
Showing 19 changed files with 467 additions and 142 deletions.
14 changes: 14 additions & 0 deletions packages/__tests__/src/test.tiff.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ export const TestDataPath = new URL('../static/', import.meta.url);
const TiffGooglePath = new URL('rgba8.google.tiff', TestDataPath);
const TiffNztm2000Path = new URL('rgba8.nztm2000.tiff', TestDataPath);

const TiffLzw = new URL('red.lzw.tiff', TestDataPath);
const TiffZstd = new URL('red.zstd.tiff', TestDataPath);

export class TestTiff {
static get Nztm2000(): URL {
return TiffNztm2000Path;
Expand All @@ -11,4 +14,15 @@ export class TestTiff {
static get Google(): URL {
return TiffGooglePath;
}

static get CompressLzw(): URL {
return TiffLzw;
}

static get CompressZstd(): URL {
return TiffZstd;
}
static get CompressRaw(): URL {
return new URL('red.none.tiff', TestDataPath);
}
}
7 changes: 6 additions & 1 deletion packages/__tests__/static/generate-tiff.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ gdal_translate -a_srs epsg:2193 \



# Webmercator bounds are 20037508.3427892 x -20037508.3427892 square
# WebMercator bounds are 20037508.3427892 x -20037508.3427892 square
# So create a tiff approx half the size
gdal_translate -a_srs epsg:3857 \
-a_ullr -10018754.1713946 10018754.1713946 10018754.1713946 -10018754.1713946 \
-of GTiff -co COMPRESS=WEBP -co WEBP_LOSSLESS=TRUE -co TILED=YES \
-co TILED=YES -co BLOCKXSIZE=16 -co BLOCKYSIZE=16 \
rgba8_tiled.tiff rgba8.google.tiff


# output some COGS with different compression types reducing their size down to 1024x1024
gdal_translate -of COG -co COMPRESS=zstd -b 1 rgba8_tiled.tiff red.zstd.tiff
gdal_translate -of COG -co COMPRESS=lzw -b 1 rgba8_tiled.tiff red.lzw.tiff
Binary file added packages/__tests__/static/red.lzw.tiff
Binary file not shown.
Binary file added packages/__tests__/static/red.zstd.tiff
Binary file not shown.
Binary file modified packages/__tests__/static/rgba8.google.tiff
Binary file not shown.
Binary file modified packages/__tests__/static/rgba8.nztm2000.tiff
Binary file not shown.
12 changes: 9 additions & 3 deletions packages/tiler-sharp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { Metrics } from '@linzjs/metrics';
import Sharp from 'sharp';

import { Decompressors } from './pipeline/decompressor.lerc.js';
import { Decompressors } from './pipeline/decompressors.js';
import { cropResize } from './pipeline/pipeline.resize.js';
import { Pipelines } from './pipeline/pipelines.js';

Expand Down Expand Up @@ -162,7 +162,13 @@ export class TileMakerSharp implements TileMaker {
const tile = await comp.asset.images[comp.source.imageId].getTile(comp.source.x, comp.source.y);
if (tile == null) return null;
const tiffTile = { imageId: comp.source.imageId, x: comp.source.x, y: comp.source.y };
const bytes = await Decompressors[tile.compression]?.bytes(comp.asset, tile.bytes);
const bytes = await Decompressors[tile.compression]?.decompress({
tiff: comp.asset,
x: comp.source.x,
y: comp.source.y,
imageId: comp.source.imageId,
bytes: tile.bytes,
});
if (bytes == null) throw new Error(`Failed to decompress: ${comp.asset.source.url.href}`);

let result = bytes;
Expand Down Expand Up @@ -202,7 +208,7 @@ export class TileMakerSharp implements TileMaker {
if (result.depth !== 'uint8') throw new Error('Expected RGBA image output');

return {
input: Buffer.from(result.pixels),
input: Buffer.from(result.buffer),
top: comp.y,
left: comp.x,
raw: { width: result.width, height: result.height, channels: result.channels as 1 },
Expand Down
37 changes: 37 additions & 0 deletions packages/tiler-sharp/src/pipeline/__tests__/decompress.lzw.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { before, describe, it } from 'node:test';

import { fsa } from '@basemaps/shared';
import { TestTiff } from '@basemaps/test';
import { Tiff } from '@cogeotiff/core';
import assert from 'assert';
import { createHash } from 'crypto';

import { LzwDecompressor } from '../decompressor.lzw.js';

describe('decompressor.lzw', () => {
let tiff: Tiff;
before(async () => {
tiff = await Tiff.create(fsa.source(TestTiff.CompressLzw));
});

it('should decode a 64x64 lzw tile', async () => {
const tile = await tiff.images[0].getTile(0, 0);

const ret = await LzwDecompressor.decompress({
tiff,
imageId: 0,
x: 0,
y: 0,
bytes: tile!.bytes,
});

const dataHash = createHash('sha256').update(ret.buffer).digest('hex');
console.log(dataHash);

assert.equal(ret.width, 512);
assert.equal(ret.height, 512);
assert.equal(ret.buffer.length, 512 * 512);
assert.equal(ret.buffer[0], 0x02);
assert.equal(dataHash, '21cfddfdab9b130811a6d6f62f14bf67291698e6a0659538b75bc5ab4e5983db');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const FakeComp = { asset: FakeTiff, source: { x: 0, y: 0, imageId: 0 } } as Comp
describe('pipeline.color-ramp', () => {
it('should color-ramp a float32 DEM with default ramp', async () => {
const bytes: DecompressedInterleaved = {
pixels: new Float32Array([-9999, 0, 100]),
buffer: new Float32Array([-9999, 0, 100]),
depth: 'float32',
channels: 1,
width: 3,
Expand All @@ -24,13 +24,13 @@ describe('pipeline.color-ramp', () => {

assert.equal(output.channels, 4);

assert.equal(String(output.pixels.slice(0, 4)), '0,0,0,0');
assert.equal(String(output.pixels.slice(4, 8)), '167,205,228,255');
assert.equal(String(output.buffer.slice(0, 4)), '0,0,0,0');
assert.equal(String(output.buffer.slice(4, 8)), '167,205,228,255');
});

it('should color-ramp a uint8', async () => {
const bytes: DecompressedInterleaved = {
pixels: new Uint8Array([0, 128, 255]),
buffer: new Uint8Array([0, 128, 255]),
depth: 'uint8',
channels: 1,
width: 3,
Expand All @@ -41,14 +41,14 @@ describe('pipeline.color-ramp', () => {

assert.equal(output.channels, 4);

assert.equal(String(output.pixels.slice(0, 4)), '0,0,0,255');
assert.equal(String(output.pixels.slice(4, 8)), '128,128,128,255');
assert.equal(String(output.pixels.slice(8, 12)), '255,255,255,255');
assert.equal(String(output.buffer.slice(0, 4)), '0,0,0,255');
assert.equal(String(output.buffer.slice(4, 8)), '128,128,128,255');
assert.equal(String(output.buffer.slice(8, 12)), '255,255,255,255');
});

it('should color-ramp a uint32', async () => {
const bytes: DecompressedInterleaved = {
pixels: new Uint32Array([0, 2 ** 31, 2 ** 32 - 1]),
buffer: new Uint32Array([0, 2 ** 31, 2 ** 32 - 1]),
depth: 'uint32',
channels: 1,
width: 3,
Expand All @@ -59,8 +59,8 @@ describe('pipeline.color-ramp', () => {

assert.equal(output.channels, 4);

assert.equal(String(output.pixels.slice(0, 4)), '0,0,0,255');
assert.equal(String(output.pixels.slice(4, 8)), '128,128,128,255');
assert.equal(String(output.pixels.slice(8, 12)), '255,255,255,255');
assert.equal(String(output.buffer.slice(0, 4)), '0,0,0,255');
assert.equal(String(output.buffer.slice(4, 8)), '128,128,128,255');
assert.equal(String(output.buffer.slice(8, 12)), '255,255,255,255');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ describe('resize-bilinear', () => {
it('should round numbers when working with uint arrays', () => {
const ret = resizeBilinear(
{
pixels: new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
buffer: new Uint8Array([1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]),
depth: 'uint8',
width: 4,
height: 4,
Expand All @@ -21,6 +21,6 @@ describe('resize-bilinear', () => {
);

// All values should be rounded to 1 and not truncated down to 0
assert.ok(ret.pixels.every((f) => f === 1));
assert.ok(ret.buffer.every((f) => f === 1));
});
});
58 changes: 29 additions & 29 deletions packages/tiler-sharp/src/pipeline/__tests__/terrain.rgb.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ function decodeTerrainRgb(buf: ArrayLike<number>, offset = 0): number {
describe('TerrainRgb', () => {
it('should encode zero', async () => {
const output = await PipelineTerrainRgb.process(FakeComp, {
pixels: new Float32Array([0, 1, 2, 3]),
buffer: new Float32Array([0, 1, 2, 3]),
depth: 'float32',
width: 2,
height: 2,
Expand All @@ -34,16 +34,16 @@ describe('TerrainRgb', () => {
assert.equal(output.depth, 'uint8');

// valid value should not have alpha
assert.equal(output.pixels[3], 255);
assert.equal(decodeTerrainRgb(output.pixels, 0), 0);
assert.equal(decodeTerrainRgb(output.pixels, 4), 1);
assert.equal(decodeTerrainRgb(output.pixels, 8), 2);
assert.equal(decodeTerrainRgb(output.pixels, 12), 3);
assert.equal(output.buffer[3], 255);
assert.equal(decodeTerrainRgb(output.buffer, 0), 0);
assert.equal(decodeTerrainRgb(output.buffer, 4), 1);
assert.equal(decodeTerrainRgb(output.buffer, 8), 2);
assert.equal(decodeTerrainRgb(output.buffer, 12), 3);
});

it('should encode using the first channel', async () => {
const output = await PipelineTerrainRgb.process(FakeComp, {
pixels: new Float32Array([0, 1, 2, 3, 1, 1, 2, 3, 2, 1, 2, 3, 3, 1, 2, 3]),
buffer: new Float32Array([0, 1, 2, 3, 1, 1, 2, 3, 2, 1, 2, 3, 3, 1, 2, 3]),
depth: 'float32',
width: 2,
height: 2,
Expand All @@ -56,23 +56,23 @@ describe('TerrainRgb', () => {
assert.equal(output.depth, 'uint8');

// valid value should not have alpha
assert.equal(output.pixels[3], 255);
assert.equal(decodeTerrainRgb(output.pixels, 0), 0);
assert.equal(decodeTerrainRgb(output.pixels, 4), 1);
assert.equal(decodeTerrainRgb(output.pixels, 8), 2);
assert.equal(decodeTerrainRgb(output.pixels, 12), 3);
assert.equal(output.buffer[3], 255);
assert.equal(decodeTerrainRgb(output.buffer, 0), 0);
assert.equal(decodeTerrainRgb(output.buffer, 4), 1);
assert.equal(decodeTerrainRgb(output.buffer, 8), 2);
assert.equal(decodeTerrainRgb(output.buffer, 12), 3);
});

it('should set no data as zero', async () => {
const output = await PipelineTerrainRgb.process(FakeComp, {
pixels: new Float32Array([-32627]),
buffer: new Float32Array([-32627]),
depth: 'float32',
width: 1,
height: 1,
channels: 1,
});

assert.equal(output.pixels[3], 0);
assert.equal(output.buffer[3], 0);
});

it('should not encode values outside the range of terrainrgb', async () => {
Expand All @@ -84,32 +84,32 @@ describe('TerrainRgb', () => {
assert.equal(maxValue, PipelineTerrainRgb.MaxValue);

const output = await PipelineTerrainRgb.process(FakeComp, {
pixels: new Float32Array([minValue, -10_001, maxValue, maxValue + 1]),
buffer: new Float32Array([minValue, -10_001, maxValue, maxValue + 1]),
depth: 'float32',
width: 4,
height: 1,
channels: 1,
});

assert.equal(decodeTerrainRgb(output.pixels, 0), minValue);
assert.equal(decodeTerrainRgb(output.pixels, 4), minValue);
assert.equal(decodeTerrainRgb(output.buffer, 0), minValue);
assert.equal(decodeTerrainRgb(output.buffer, 4), minValue);

assert.equal(decodeTerrainRgb(output.pixels, 8), maxValue);
assert.equal(decodeTerrainRgb(output.pixels, 12), maxValue);
assert.equal(decodeTerrainRgb(output.buffer, 8), maxValue);
assert.equal(decodeTerrainRgb(output.buffer, 12), maxValue);
});

it('should encode every possible value', async () => {
const widthHeight = 4096;
const pixels = new Float32Array(widthHeight * widthHeight);
const buffer = new Float32Array(widthHeight * widthHeight);

for (let i = 0; i < widthHeight * widthHeight; i++) {
const target = -10_000 + i * 0.1;
pixels[i] = target;
buffer[i] = target;
}

console.time('encode');
const output = await PipelineTerrainRgb.process(FakeComp, {
pixels,
buffer: buffer,
depth: 'float32',
width: widthHeight,
height: widthHeight,
Expand All @@ -118,17 +118,17 @@ describe('TerrainRgb', () => {
console.timeEnd('encode');

let rgbExpected = 0;
for (let i = 0; i < output.pixels.length; i += 4) {
const rgbOutput = output.pixels[i] * 256 * 256 + output.pixels[i + 1] * 256 + output.pixels[i + 2];
for (let i = 0; i < output.buffer.length; i += 4) {
const rgbOutput = output.buffer[i] * 256 * 256 + output.buffer[i + 1] * 256 + output.buffer[i + 2];
const diff = Math.abs(rgbOutput - rgbExpected);

// allow a 1px interval shift due to floating point rounding
if (diff !== 0 && diff !== 1) {
console.log(`Failed alignment at offset: ${i} value: ${decodeTerrainRgb(output.pixels, i)} diff: ${diff}`);
console.log(`Failed alignment at offset: ${i} value: ${decodeTerrainRgb(output.buffer, i)} diff: ${diff}`);
assert.deepEqual(
diff,
0,
`Failed alignment at offset: ${i} value: ${decodeTerrainRgb(output.pixels, i)} diff: ${diff}`,
`Failed alignment at offset: ${i} value: ${decodeTerrainRgb(output.buffer, i)} diff: ${diff}`,
);
}

Expand All @@ -138,7 +138,7 @@ describe('TerrainRgb', () => {

it('should reduce the precision when resolution is low', async () => {
const input = {
pixels: new Float32Array([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]),
buffer: new Float32Array([0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]),
depth: 'float32',
width: 11,
height: 1,
Expand All @@ -156,8 +156,8 @@ describe('TerrainRgb', () => {
const output = await PipelineTerrainRgb.process(tiff, input);

const decoded: number[] = [];
for (let i = 0; i < input.pixels.length; i++) {
decoded.push(decodeTerrainRgb(output.pixels, i * 4));
for (let i = 0; i < input.buffer.length; i++) {
decoded.push(decodeTerrainRgb(output.buffer, i * 4));
}
return decoded.map((m) => Number(m.toFixed(1)));
}
Expand Down
Loading

0 comments on commit 8f40491

Please sign in to comment.