Skip to content

Commit b8bedc3

Browse files
tawera-manaenaWentao-Kuangblacha
authored
feat(cogify): add background color support for overriding transparent pixels BM-1146 (#3379)
### Motivation As a Basemaps user, when I view the [NZTopo Raster Maps][nztopo-raster-maps] from afar (low zoom level), I can see black edge artifacts around the perimeter of the imagery. | [Topo 50][basemaps-topo50] | [Topo250][basemaps-topo250] | | - | - | | ![][topo50] | ![][topo250] | | Appears between zoom levels `0-6` inclusive. Hard to notice when zoom level is `>= 6.5` | Appears between zoom levels `0-4` inclusive. Hard to notice when zoom level is `>= 4.5` | _You may need to open each image/link in a new tab to see the black lines more clearly._ #### Problem We suspect the issue resides in the way the **basemaps/tiler** composes a small tile. The resizing down of many pixels into one, causes artifacts to appear where opaque pixels meet transparent pixels. #### Solution There is already a ['workaround' fix][kernel-pr] that treats the symptoms of the black edge artifacts. This work, however, seeks to treat the source of the issue by providing a mechanism to replace all transparent pixels with opaque pixels. The effect being to erase such areas where opaque pixels meet transparent pixels. ### Modifications **basemaps/cogify** - We have added a `background` parameter to the `cover` command. - When the user provides the parameter, the `create` function will perform two additional `gdal` steps after `gdalwarp` and before `gdal_translate`, to ensure that any and all transparent pixels that would otherwise appear in the resulting tile, are replaced with the provided background colour. - `gdal_create` - To create a background image where all pixels are set to the provided background colour. - `gdalbuildvrt` - To layer the background image behind the source GeoTIFF files after reprojection. ### Verification | For a given tile | What cogify usually makes | What cogify can now make | | - | - | - | | ![][verification-tile] | ![][verification-before] | ![][verification-after] | | Example shown: [9-4-325][basemaps-9-4-325] - Web Mercator (EPSG:3857) | No transparent pixels are overwritten. | All transparent pixels are overwritten with the provided background colour. | <!-- links --> [nztopo-raster-maps]: https://dev.basemaps.linz.govt.nz/@-41.8899962,174.0492437,z5?style=topo-raster&i=topographic&config=TmVmbYRQjL9T2JWgyaSie193b4D1qZnBD8hjaSqPFYSsEvEYYhaGYKrcp1HoDE7nHXaP89x5RKia68nGwyRKku4ExE7QvB424FYmjokRMr2qXgj6oehUjHaB27QiY6d [basemaps-topo50]: https://basemaps.linz.govt.nz/@-40.4900187,173.5118508,z5?style=01JE7PGRG2AHNAN80CVCBK5JS1&i=01JE7PGRG2AHNAN80CVCBK5JS1&config=5LN3whfVkKeLNsSo5jGHMuw3a3bk5rR1ekotn4iApaGccpE1D8L2hLZdbsYkzbUrGCFpy2jXFkbngKguAkob2ZKZHKkKGTw6xx1f14Zxe2VaPmUV3PNRTJero5NDH1WgtA16AnKtaRVXQ7KaQevPzeTfwNmxdWZECGqDkps59ifDDuTAQJXXJK6rfMk3tF15s&debug=true [basemaps-topo250]: https://basemaps.linz.govt.nz/@-41.8971463,173.1394504,z4?style=01JE7NBEJHND65K0WWWB9PWXQZ&i=01JE7NBEJHND65K0WWWB9PWXQZ&config=L8TuzSUutDHnXXerPsYTDJdYLrUMAR6yseMyXuNiLyqvD5SPvX6E3CkoPoqhkogKnBCaQpbQ7LUHiSXGsM9ctAVTvjWU8BnSaV3SrRRGDULg5PSGz8thtzn5BGc28bijrqq677m9gSHJqVdwXPsjQ78pKez8TMncCYth2s4npjkmokz7q2r1GgMumQ3WTjT72y&debug=true [topo50]: https://github.com/user-attachments/assets/aaa16253-efed-45b9-a6bd-fb6054b33927 [topo250]: https://github.com/user-attachments/assets/64655fe0-b5ad-4036-aab6-2810fef3c578 [kernel-pr]: #3377 [basemaps-9-4-325]: https://basemaps.linz.govt.nz/@-43.8859446,-176.8266051,z8?debug=true&debug.tile=true [verification-tile]: https://github.com/user-attachments/assets/2159eb1d-3009-4aec-a747-d886cd51df32 [verification-before]: https://github.com/user-attachments/assets/3889e3a2-c784-4436-94d4-f264dc546866 [verification-after]: https://github.com/user-attachments/assets/89018eb0-4aad-437d-a4fd-f4f62c10eb54 --------- Co-authored-by: Wentao Kuang <wkuang@linz.govt.nz> Co-authored-by: Blayne Chard <bchard@linz.govt.nz>
1 parent 9c1d78f commit b8bedc3

File tree

10 files changed

+109
-13
lines changed

10 files changed

+109
-13
lines changed

packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,13 @@ describe('cli.cover', () => {
2323
target: new URL('memory://target/'),
2424
preset: 'webp',
2525
tileMatrix: 'WebMercatorQuad',
26-
2726
cutline: undefined,
2827
cutlineBlend: 20,
2928
baseZoomOffset: undefined,
3029
verbose: false,
3130
extraVerbose: false,
3231
requireStacCollection: false,
32+
background: undefined,
3333
};
3434

3535
it('should generate a covering', async () => {
@@ -50,7 +50,6 @@ describe('cli.cover', () => {
5050
paths: [new URL('memory://source/')],
5151
target: new URL('memory://target/'),
5252
preset: 'webp',
53-
5453
requireStacCollection: true,
5554
tileMatrix: 'WebMercatorQuad',
5655
}).catch((e) => String(e));

packages/cogify/src/cogify/cli/cli.cog.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { CutlineOptimizer } from '../../cutline.js';
1515
import { SourceDownloader } from '../../download.js';
1616
import { HashTransform } from '../../hash.stream.js';
1717
import { getLogger, logArguments } from '../../log.js';
18-
import { gdalBuildCog, gdalBuildVrt, gdalBuildVrtWarp } from '../gdal.command.js';
18+
import { gdalBuildCog, gdalBuildVrt, gdalBuildVrtWarp, gdalCreate } from '../gdal.command.js';
1919
import { GdalRunner } from '../gdal.runner.js';
2020
import { Url, UrlArrayJsonFile } from '../parsers.js';
2121
import { CogifyCreationOptions, CogifyStacItem, getCutline, getSources } from '../stac.js';
@@ -87,7 +87,6 @@ export const BasemapsCogifyCreateCommand = command({
8787
const logger = getLogger(this, args);
8888

8989
if (args.docker) process.env['GDAL_DOCKER'] = '1';
90-
9190
const paths = args.fromFile != null ? args.path.concat(args.fromFile) : args.path;
9291

9392
const toCreate = await Promise.all(paths.map(async (p) => loadItem(p, logger)));
@@ -332,9 +331,26 @@ async function createCog(ctx: CogCreationContext): Promise<URL> {
332331
);
333332
await new GdalRunner(vrtWarpCommand).run(logger);
334333

335-
logger?.debug({ tileId }, 'Cog:Create:Tiff');
336-
// Create the COG from the warped vrt
337-
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtWarpCommand.output, options);
334+
if (options.background == null) {
335+
// Create the COG from the warped vrt without a forced background
336+
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtWarpCommand.output, options);
337+
await new GdalRunner(cogCreateCommand).run(logger);
338+
return cogCreateCommand.output;
339+
}
340+
341+
// Create a colored background tiff to fill the empty space in the target cog
342+
const gdalCreateCommand = gdalCreate(new URL(`${tileId}-bg.tiff`, ctx.tempFolder), options.background, options);
343+
await new GdalRunner(gdalCreateCommand).run(logger);
344+
345+
// Create a vrt with the background tiff behind the source file vrt
346+
const vrtMergeCommand = gdalBuildVrt(new URL(`${tileId}-merged.vrt`, ctx.tempFolder), [
347+
gdalCreateCommand.output,
348+
vrtWarpCommand.output,
349+
]);
350+
await new GdalRunner(vrtMergeCommand).run(logger);
351+
352+
// Create the COG from the merged vrt with a forced background
353+
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtMergeCommand.output, options);
338354
await new GdalRunner(cogCreateCommand).run(logger);
339355
return cogCreateCommand.output;
340356
}

packages/cogify/src/cogify/cli/cli.cover.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { CutlineOptimizer } from '../../cutline.js';
1111
import { getLogger, logArguments } from '../../log.js';
1212
import { Presets } from '../../preset.js';
1313
import { createTileCover, TileCoverContext } from '../../tile.cover.js';
14-
import { Url, UrlFolder } from '../parsers.js';
14+
import { RgbaType, Url, UrlFolder } from '../parsers.js';
1515
import { createFileStats } from '../stac.js';
1616

1717
const SupportedTileMatrix = [GoogleTms, Nztm2000QuadTms];
@@ -62,6 +62,11 @@ export const BasemapsCogifyCoverCommand = command({
6262
defaultValue: () => false,
6363
defaultValueIsSerializable: true,
6464
}),
65+
background: option({
66+
type: optional(RgbaType),
67+
long: 'background',
68+
description: 'Replace all transparent COG pixels with this RGBA hexstring color',
69+
}),
6570
},
6671
async handler(args) {
6772
const metrics = new Metrics();
@@ -95,6 +100,7 @@ export const BasemapsCogifyCoverCommand = command({
95100
metrics,
96101
cutline,
97102
preset: args.preset,
103+
background: args.background,
98104
targetZoomOffset: args.baseZoomOffset,
99105
};
100106

packages/cogify/src/cogify/gdal.command.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import { Rgba } from '@basemaps/config';
12
import { Epsg, EpsgCode, TileMatrixSets } from '@basemaps/geo';
23
import { urlToString } from '@basemaps/shared';
34

45
import { Presets } from '../preset.js';
56
import { GdalCommand } from './gdal.runner.js';
67
import { CogifyCreationOptions } from './stac.js';
78

9+
const isPowerOfTwo = (x: number): boolean => (x & (x - 1)) === 0;
10+
811
export function gdalBuildVrt(targetVrt: URL, source: URL[]): GdalCommand {
912
if (source.length === 0) throw new Error('No source files given for :' + targetVrt.href);
1013
return {
@@ -97,3 +100,45 @@ export function gdalBuildCog(targetTiff: URL, sourceVrt: URL, opt: CogifyCreatio
97100
.map(String),
98101
};
99102
}
103+
104+
/**
105+
* Creates an empty tiff where all pixel values are set to the given color.
106+
* Used to force a background so that there are no empty pixels in the final COG.
107+
*
108+
* @param targetTiff the file path and name for the created tiff
109+
* @param color the color to set all pixel values
110+
* @param opt a CogifyCreationOptions object
111+
*
112+
* @returns a 'gdal_create' GdalCommand object
113+
*/
114+
export function gdalCreate(targetTiff: URL, color: Rgba, opt: CogifyCreationOptions): GdalCommand {
115+
const cfg = { ...Presets[opt.preset], ...opt };
116+
117+
const tileMatrix = TileMatrixSets.find(cfg.tileMatrix);
118+
if (tileMatrix == null) throw new Error('Unable to find tileMatrix: ' + cfg.tileMatrix);
119+
120+
const bounds = tileMatrix.tileToSourceBounds(cfg.tile);
121+
const pixelScale = tileMatrix.pixelScale(cfg.zoomLevel);
122+
const size = Math.round(bounds.width / pixelScale);
123+
124+
// if the value of 'size' is not a power of 2
125+
if (!isPowerOfTwo(size)) throw new Error('Size did not compute to a power of 2');
126+
127+
return {
128+
command: 'gdal_create',
129+
output: targetTiff,
130+
args: [
131+
['-of', 'GTiff'],
132+
['-outsize', size, size], // set the size to match that of the final COG
133+
['-bands', '4'],
134+
['-burn', `${color.r} ${color.g} ${color.b} ${color.alpha}`], // set all pixel values to the given color
135+
['-a_srs', tileMatrix.projection.toEpsgString()],
136+
['-a_ullr', bounds.x, bounds.bottom, bounds.right, bounds.y],
137+
['-co', 'COMPRESS=LZW'],
138+
urlToString(targetTiff),
139+
]
140+
.filter((f) => f != null)
141+
.flat()
142+
.map(String),
143+
};
144+
}

packages/cogify/src/cogify/gdal.runner.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ export interface GdalCommand {
88
/** Output file location */
99
output: URL;
1010
/** GDAL command to use */
11-
command: 'gdalwarp' | 'gdalbuildvrt' | 'gdal_translate';
11+
command: 'gdal_create' | 'gdalwarp' | 'gdalbuildvrt' | 'gdal_translate';
1212
/** GDAL arguments to use */
1313
args: string[];
1414
}

packages/cogify/src/cogify/parsers.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,22 @@
11
import { pathToFileURL } from 'node:url';
22

3+
import { parseRgba, Rgba } from '@basemaps/config';
34
import { fsa } from '@basemaps/shared';
45
import { Type } from 'cmd-ts';
56

67
/**
7-
* Parse a input parameter as a URL.
8+
* Parse an input RGBA hexstring as an RGBA object.
9+
*
10+
* Throws an error if the RGBA hexstring is invalid.
11+
**/
12+
export const RgbaType: Type<string, Rgba> = {
13+
from(str) {
14+
return Promise.resolve(parseRgba(str));
15+
},
16+
};
17+
18+
/**
19+
* Parse an input parameter as a URL.
820
*
921
* If it looks like a file path, it will be converted using `pathToFileURL`.
1022
**/
@@ -19,7 +31,7 @@ export const Url: Type<string, URL> = {
1931
};
2032

2133
/**
22-
* Parse a input parameter as a URL which represents a folder.
34+
* Parse an input parameter as a URL which represents a folder.
2335
*
2436
* If it looks like a file path, it will be converted using `pathToFileURL`.
2537
* Any search parameters or hash will be removed, and a trailing slash added

packages/cogify/src/cogify/stac.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from 'node:crypto';
22

3+
import { Rgba } from '@basemaps/config';
34
import { Tile } from '@basemaps/geo';
45
import { StacCollection, StacItem, StacLink } from 'stac-ts';
56

@@ -56,7 +57,11 @@ export interface CogifyCreationOptions {
5657
* @default 'lanczos'
5758
*/
5859
overviewResampling?: GdalResampling;
60+
61+
/** Color with which to replace all transparent COG pixels */
62+
background?: Rgba;
5963
}
64+
6065
export type GdalResampling = 'nearest' | 'bilinear' | 'cubic' | 'cubicspline' | 'lanczos' | 'average' | 'mode';
6166

6267
export type CogifyStacCollection = StacCollection;

packages/cogify/src/tile.cover.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Rgba } from '@basemaps/config';
12
import { ConfigImageryTiff } from '@basemaps/config-loader';
23
import { BoundingBox, Bounds, EpsgCode, Projection, ProjectionLoader, TileId, TileMatrixSet } from '@basemaps/geo';
34
import { fsa, LogType, urlToString } from '@basemaps/shared';
@@ -32,6 +33,8 @@ export interface TileCoverContext {
3233
logger?: LogType;
3334
/** GDAL configuration preset */
3435
preset: string;
36+
/** Optional color with which to replace all transparent COG pixels */
37+
background?: Rgba;
3538
/**
3639
* Override the base zoom to store the output COGS as
3740
*/
@@ -180,6 +183,9 @@ export async function createTileCover(ctx: TileCoverContext): Promise<TileCoverR
180183
assets: {},
181184
};
182185

186+
// Add the background color if it exists
187+
if (ctx.background) item.properties['linz_basemaps:options'].background = ctx.background;
188+
183189
// Add the source imagery as a STAC Link
184190
for (const src of source) {
185191
const srcLink: CogifyLinkSource = {

packages/config/src/color.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@ export function parseHex(str: string): number {
1111
return val;
1212
}
1313

14+
export interface Rgba {
15+
r: number;
16+
g: number;
17+
b: number;
18+
alpha: number;
19+
}
20+
1421
/**
1522
* Parse a hexstring into RGBA
1623
*
1724
* Defaults to 0 if missing values
1825
* @param str string to parse
1926
*/
20-
export function parseRgba(str: string): { r: number; g: number; b: number; alpha: number } {
27+
export function parseRgba(str: string): Rgba {
2128
if (str.startsWith('0x')) str = str.slice(2);
2229
else if (str.startsWith('#')) str = str.slice(1);
2330
if (str.length !== 6 && str.length !== 8) {

packages/config/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export {
77
} from './base.config.js';
88
export { base58, isBase58 } from './base58.js';
99
export { ensureBase58, sha256base58 } from './base58.node.js';
10-
export { parseHex, parseRgba } from './color.js';
10+
export { parseHex, parseRgba, Rgba } from './color.js';
1111
export { ConfigBase as BaseConfig } from './config/base.js';
1212
export { ConfigBundle } from './config/config.bundle.js';
1313
export { ConfigImagery, ConfigImageryOverview, ImageryBandType, ImageryDataType } from './config/imagery.js';

0 commit comments

Comments
 (0)