Skip to content

Commit 198aa69

Browse files
authored
fix(cli-raster): zstd covering with smaller tiles than expected for single band sources (#3551)
### Motivation Covering a collection of tiffs with ZSTD was creating more files than other dataset types. for some datasets this was a difference in 5000 files ### Modifications Use the source band information to help determine what target zoom level a dataset should be tiled at. ### Verification Unit test + ran some RGB / RGBI tests locally
1 parent 72f72b6 commit 198aa69

File tree

2 files changed

+146
-15
lines changed

2 files changed

+146
-15
lines changed
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import assert from 'node:assert';
2+
import { describe, it } from 'node:test';
3+
4+
import { ImageryBandDataType, ImageryBandType } from '@basemaps/config';
5+
6+
import { findOptimialCoveringZoomOffset } from '../tile.cover.js';
7+
8+
const TileSize = 512;
9+
10+
function rawDataSize(imageryType: ImageryBandDataType): number {
11+
switch (imageryType) {
12+
case 'uint8':
13+
return 1;
14+
case 'uint16':
15+
return 2;
16+
case 'float32':
17+
return 4;
18+
default:
19+
throw new Error(`Unknown imagery type: ${imageryType}`);
20+
}
21+
}
22+
23+
function rawTileSize(sourceBands: ImageryBandType[]): number {
24+
let size = 0;
25+
for (const b of sourceBands) size += rawDataSize(b.type);
26+
return size * TileSize * TileSize;
27+
}
28+
29+
function tileCount(zoomOffset: number): number {
30+
let count = 0;
31+
for (let z = 0; z < zoomOffset; z++) {
32+
// z:0 - 1x1 - 1 tile
33+
// z:1 - 2x2 - 4 tiles
34+
// z:3 - 4x4 - 16 tiles
35+
// z:3 - 8x8 - 64 tiles
36+
count = count + 2 ** z * 2 ** z;
37+
}
38+
return count;
39+
}
40+
41+
function estimatedCogSize(zoomOffset: number, sourceBands: ImageryBandType[]): number {
42+
const tileCountValue = tileCount(zoomOffset);
43+
const tileSizeValue = rawTileSize(sourceBands);
44+
return tileCountValue * tileSizeValue;
45+
}
46+
47+
describe('coveringOffset', () => {
48+
const uint8 = { type: 'uint8' } as const;
49+
const float32 = { type: 'float32' } as const;
50+
const uint16 = { type: 'uint16' } as const;
51+
52+
// TODO use these estimated sizes to determine if the covering zoom level offset is ok
53+
it('should calculate raw size of a 6 level cog correctly', () => {
54+
assert.equal(tileCount(1), 1);
55+
assert.equal(tileCount(2), 1 + 4);
56+
assert.equal(tileCount(3), 1 + 4 + 16);
57+
assert.equal(tileCount(4), 1 + 4 + 16 + 64);
58+
assert.equal(tileCount(5), 1 + 4 + 16 + 64 + 256);
59+
60+
assert.equal(estimatedCogSize(1, [uint8]), 1 * 512 * 512 * 1);
61+
assert.equal(estimatedCogSize(1, [uint16]), 1 * 512 * 512 * 2);
62+
assert.equal(estimatedCogSize(1, [uint16, uint16]), 1 * 512 * 512 * 2 * 2);
63+
assert.equal(estimatedCogSize(1, [float32]), 1 * 512 * 512 * 4);
64+
65+
rawTileSize([uint16, uint16, uint16, uint16, uint16]);
66+
});
67+
68+
it('should cover singleband uint8 or uint16 correctly', () => {
69+
for (const preset of ['lerc_1mm', 'lerc_10mm', 'zstd_17'] as const) {
70+
assert.equal(7, findOptimialCoveringZoomOffset(preset, [uint8]));
71+
assert.equal(7, findOptimialCoveringZoomOffset(preset, [uint16]));
72+
}
73+
});
74+
75+
it('should cover a webp rgb(a) correctly', () => {
76+
assert.equal(7, findOptimialCoveringZoomOffset('webp', [uint8, uint8, uint8, uint8]));
77+
assert.equal(7, findOptimialCoveringZoomOffset('webp', [uint8, uint8, uint8]));
78+
});
79+
80+
it('should cover a float32 single band correctly', () => {
81+
assert.equal(7, findOptimialCoveringZoomOffset('lerc_1mm', [float32]));
82+
assert.equal(7, findOptimialCoveringZoomOffset('lerc_10mm', [float32]));
83+
});
84+
85+
it('should reduce the size with zstd compression', () => {
86+
assert.equal(6, findOptimialCoveringZoomOffset('zstd_17', [float32]));
87+
assert.equal(6, findOptimialCoveringZoomOffset('zstd_17', [uint8, uint8, uint8]));
88+
assert.equal(6, findOptimialCoveringZoomOffset('zstd_17', [uint8, uint8, uint8, uint8]));
89+
90+
// RGBI+Alpha
91+
assert.equal(6, findOptimialCoveringZoomOffset('zstd_17', [uint8, uint8, uint8, uint8, uint8]));
92+
assert.equal(6, findOptimialCoveringZoomOffset('zstd_17', [uint16, uint16, uint16, uint16, uint16]));
93+
});
94+
95+
it('should default to level 6 for lzw compression', () => {
96+
assert.equal(6, findOptimialCoveringZoomOffset('lzw', [float32]));
97+
assert.equal(6, findOptimialCoveringZoomOffset('lzw', [uint8, uint8, uint8]));
98+
assert.equal(6, findOptimialCoveringZoomOffset('lzw', [uint8, uint8, uint8, uint8]));
99+
100+
// RGBI+Alpha - these have not been tested and are assumed to be ok
101+
assert.equal(6, findOptimialCoveringZoomOffset('lzw', [uint8, uint8, uint8, uint8, uint8]));
102+
assert.equal(6, findOptimialCoveringZoomOffset('lzw', [uint16, uint16, uint16, uint16, uint16]));
103+
});
104+
});

packages/cli-raster/src/cogify/covering/tile.cover.ts

Lines changed: 42 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Rgba } from '@basemaps/config';
1+
import { ImageryBandType, Rgba } from '@basemaps/config';
22
import { ConfigImageryTiff } from '@basemaps/config-loader';
33
import { BoundingBox, Bounds, EpsgCode, Projection, ProjectionLoader, TileId, TileMatrixSet } from '@basemaps/geo';
44
import { fsa, LogType, urlToString } from '@basemaps/shared';
@@ -75,17 +75,44 @@ function getTargetBaseZoom(tileMatrix: TileMatrixSet, resolution: number, target
7575
return Projection.getTiffResZoom(tileMatrix, resolution) + targetZoomOffset;
7676
}
7777

78-
// The base zoom is 256x256 pixels at its resolution, we are trying to find a image that is <32k pixels wide/high
79-
// zooming out 7 levels converts a 256x256 image into 32k x 32k image
80-
// 256 * 2 ** 7 = 32,768 - 256x256 tile
81-
// 512 * 2 ** 6 = 32,768 - 512x512 tile
82-
// This math only works for highly compressed RGB imagery, for multispectrial imagery small tiles need to be made
8378
export const TargetZoomOffsetDefault = 7;
84-
// ZSTD files are generally larger than webp or LERC
85-
export const TargetZoomOffset: Partial<Record<PresetName, number>> = {
86-
zstd_17: 6,
87-
lzw: 6,
88-
};
79+
80+
/**
81+
* We are trying to find a zoom level that will create COGS that are optimally sized for the preset,
82+
* We are looking for cogs that are around 1GB in size on average and keeping under 4GB when maximally filled
83+
*
84+
* There are lots of different presets and source imagery bands which affect the final size of the COGs,
85+
* for examples:
86+
* - RGB(A) `uint8` imagery compressed with webp this preset is approximately 7 levels from the base zoom level.
87+
* - Elevation `float32` compressed with zstd is less effective the offset is reduced to 6 levels
88+
*
89+
* @param preset target compression preset
90+
* @param targetBaseZoom the base zoom level to use
91+
* @param sourceBands bands present in the source dataset
92+
*
93+
* @returns a zoom level offset to use for the covering
94+
*/
95+
export function findOptimialCoveringZoomOffset(preset: PresetName, sourceBands: ImageryBandType[]): number {
96+
// Webp is very efficient at compressing RGB(A) imagery so we can keep the default offset
97+
if (preset === 'webp') return TargetZoomOffsetDefault;
98+
// LZW is not very effective at compressing most imagery so we reduce the offset by one
99+
if (preset === 'lzw') return TargetZoomOffsetDefault - 1;
100+
101+
const sourceBandText = sourceBands.map((m) => m.type).join(',');
102+
// Single band float32 imagery tends to be elevation data which compresses well with lossy lerc and less so with lossless zstd
103+
if (sourceBandText === 'float32') {
104+
if (preset === 'lerc_1mm' || preset === 'lerc_10mm') return TargetZoomOffsetDefault;
105+
// all other compressors are lossless
106+
return TargetZoomOffsetDefault - 1;
107+
}
108+
109+
// Single band uint8 or uint16 compresses generally very effectively with most presets
110+
if (sourceBandText === 'uint8') return TargetZoomOffsetDefault;
111+
if (sourceBandText === 'uint16') return TargetZoomOffsetDefault;
112+
113+
// Unknown preset so assume one level offset is ok
114+
return TargetZoomOffsetDefault - 1;
115+
}
89116

90117
export async function createTileCover(ctx: TileCoverContext): Promise<TileCoverResult> {
91118
// Ensure we have the projection loaded for the source imagery
@@ -94,10 +121,10 @@ export async function createTileCover(ctx: TileCoverContext): Promise<TileCoverR
94121

95122
// Find the zoom level that is at least as good as the source imagery
96123
const targetBaseZoom = getTargetBaseZoom(ctx.tileMatrix, ctx.imagery.gsd, ctx.targetZoomOffset);
97-
98-
const targetZoomOffset = TargetZoomOffset[ctx.preset] ?? 7;
99-
100-
const optimalCoveringZoom = Math.max(1, targetBaseZoom - targetZoomOffset); // z12 from z19
124+
const optimalCoveringZoom = Math.max(
125+
1,
126+
targetBaseZoom - findOptimialCoveringZoomOffset(ctx.preset, ctx.imagery.bands),
127+
);
101128
ctx.logger?.debug({ targetBaseZoom, cogOverZoom: optimalCoveringZoom }, 'Imagery:ZoomLevel');
102129

103130
const sourceBounds = projectPolygon(

0 commit comments

Comments
 (0)