Skip to content

Commit

Permalink
feat(cogify): add background color support for overriding transparent…
Browse files Browse the repository at this point in the history
… 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 <[email protected]>
Co-authored-by: Blayne Chard <[email protected]>
  • Loading branch information
3 people authored Jan 6, 2025
1 parent 9c1d78f commit b8bedc3
Show file tree
Hide file tree
Showing 10 changed files with 109 additions and 13 deletions.
3 changes: 1 addition & 2 deletions packages/cogify/src/cogify/cli/__test__/cli.cover.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,13 @@ describe('cli.cover', () => {
target: new URL('memory://target/'),
preset: 'webp',
tileMatrix: 'WebMercatorQuad',

cutline: undefined,
cutlineBlend: 20,
baseZoomOffset: undefined,
verbose: false,
extraVerbose: false,
requireStacCollection: false,
background: undefined,
};

it('should generate a covering', async () => {
Expand All @@ -50,7 +50,6 @@ describe('cli.cover', () => {
paths: [new URL('memory://source/')],
target: new URL('memory://target/'),
preset: 'webp',

requireStacCollection: true,
tileMatrix: 'WebMercatorQuad',
}).catch((e) => String(e));
Expand Down
26 changes: 21 additions & 5 deletions packages/cogify/src/cogify/cli/cli.cog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { CutlineOptimizer } from '../../cutline.js';
import { SourceDownloader } from '../../download.js';
import { HashTransform } from '../../hash.stream.js';
import { getLogger, logArguments } from '../../log.js';
import { gdalBuildCog, gdalBuildVrt, gdalBuildVrtWarp } from '../gdal.command.js';
import { gdalBuildCog, gdalBuildVrt, gdalBuildVrtWarp, gdalCreate } from '../gdal.command.js';
import { GdalRunner } from '../gdal.runner.js';
import { Url, UrlArrayJsonFile } from '../parsers.js';
import { CogifyCreationOptions, CogifyStacItem, getCutline, getSources } from '../stac.js';
Expand Down Expand Up @@ -87,7 +87,6 @@ export const BasemapsCogifyCreateCommand = command({
const logger = getLogger(this, args);

if (args.docker) process.env['GDAL_DOCKER'] = '1';

const paths = args.fromFile != null ? args.path.concat(args.fromFile) : args.path;

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

logger?.debug({ tileId }, 'Cog:Create:Tiff');
// Create the COG from the warped vrt
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtWarpCommand.output, options);
if (options.background == null) {
// Create the COG from the warped vrt without a forced background
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtWarpCommand.output, options);
await new GdalRunner(cogCreateCommand).run(logger);
return cogCreateCommand.output;
}

// Create a colored background tiff to fill the empty space in the target cog
const gdalCreateCommand = gdalCreate(new URL(`${tileId}-bg.tiff`, ctx.tempFolder), options.background, options);
await new GdalRunner(gdalCreateCommand).run(logger);

// Create a vrt with the background tiff behind the source file vrt
const vrtMergeCommand = gdalBuildVrt(new URL(`${tileId}-merged.vrt`, ctx.tempFolder), [
gdalCreateCommand.output,
vrtWarpCommand.output,
]);
await new GdalRunner(vrtMergeCommand).run(logger);

// Create the COG from the merged vrt with a forced background
const cogCreateCommand = gdalBuildCog(new URL(`${tileId}.tiff`, ctx.tempFolder), vrtMergeCommand.output, options);
await new GdalRunner(cogCreateCommand).run(logger);
return cogCreateCommand.output;
}
Expand Down
8 changes: 7 additions & 1 deletion packages/cogify/src/cogify/cli/cli.cover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { CutlineOptimizer } from '../../cutline.js';
import { getLogger, logArguments } from '../../log.js';
import { Presets } from '../../preset.js';
import { createTileCover, TileCoverContext } from '../../tile.cover.js';
import { Url, UrlFolder } from '../parsers.js';
import { RgbaType, Url, UrlFolder } from '../parsers.js';
import { createFileStats } from '../stac.js';

const SupportedTileMatrix = [GoogleTms, Nztm2000QuadTms];
Expand Down Expand Up @@ -62,6 +62,11 @@ export const BasemapsCogifyCoverCommand = command({
defaultValue: () => false,
defaultValueIsSerializable: true,
}),
background: option({
type: optional(RgbaType),
long: 'background',
description: 'Replace all transparent COG pixels with this RGBA hexstring color',
}),
},
async handler(args) {
const metrics = new Metrics();
Expand Down Expand Up @@ -95,6 +100,7 @@ export const BasemapsCogifyCoverCommand = command({
metrics,
cutline,
preset: args.preset,
background: args.background,
targetZoomOffset: args.baseZoomOffset,
};

Expand Down
45 changes: 45 additions & 0 deletions packages/cogify/src/cogify/gdal.command.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Rgba } from '@basemaps/config';
import { Epsg, EpsgCode, TileMatrixSets } from '@basemaps/geo';
import { urlToString } from '@basemaps/shared';

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

const isPowerOfTwo = (x: number): boolean => (x & (x - 1)) === 0;

export function gdalBuildVrt(targetVrt: URL, source: URL[]): GdalCommand {
if (source.length === 0) throw new Error('No source files given for :' + targetVrt.href);
return {
Expand Down Expand Up @@ -97,3 +100,45 @@ export function gdalBuildCog(targetTiff: URL, sourceVrt: URL, opt: CogifyCreatio
.map(String),
};
}

/**
* Creates an empty tiff where all pixel values are set to the given color.
* Used to force a background so that there are no empty pixels in the final COG.
*
* @param targetTiff the file path and name for the created tiff
* @param color the color to set all pixel values
* @param opt a CogifyCreationOptions object
*
* @returns a 'gdal_create' GdalCommand object
*/
export function gdalCreate(targetTiff: URL, color: Rgba, opt: CogifyCreationOptions): GdalCommand {
const cfg = { ...Presets[opt.preset], ...opt };

const tileMatrix = TileMatrixSets.find(cfg.tileMatrix);
if (tileMatrix == null) throw new Error('Unable to find tileMatrix: ' + cfg.tileMatrix);

const bounds = tileMatrix.tileToSourceBounds(cfg.tile);
const pixelScale = tileMatrix.pixelScale(cfg.zoomLevel);
const size = Math.round(bounds.width / pixelScale);

// if the value of 'size' is not a power of 2
if (!isPowerOfTwo(size)) throw new Error('Size did not compute to a power of 2');

return {
command: 'gdal_create',
output: targetTiff,
args: [
['-of', 'GTiff'],
['-outsize', size, size], // set the size to match that of the final COG
['-bands', '4'],
['-burn', `${color.r} ${color.g} ${color.b} ${color.alpha}`], // set all pixel values to the given color
['-a_srs', tileMatrix.projection.toEpsgString()],
['-a_ullr', bounds.x, bounds.bottom, bounds.right, bounds.y],
['-co', 'COMPRESS=LZW'],
urlToString(targetTiff),
]
.filter((f) => f != null)
.flat()
.map(String),
};
}
2 changes: 1 addition & 1 deletion packages/cogify/src/cogify/gdal.runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export interface GdalCommand {
/** Output file location */
output: URL;
/** GDAL command to use */
command: 'gdalwarp' | 'gdalbuildvrt' | 'gdal_translate';
command: 'gdal_create' | 'gdalwarp' | 'gdalbuildvrt' | 'gdal_translate';
/** GDAL arguments to use */
args: string[];
}
Expand Down
16 changes: 14 additions & 2 deletions packages/cogify/src/cogify/parsers.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
import { pathToFileURL } from 'node:url';

import { parseRgba, Rgba } from '@basemaps/config';
import { fsa } from '@basemaps/shared';
import { Type } from 'cmd-ts';

/**
* Parse a input parameter as a URL.
* Parse an input RGBA hexstring as an RGBA object.
*
* Throws an error if the RGBA hexstring is invalid.
**/
export const RgbaType: Type<string, Rgba> = {
from(str) {
return Promise.resolve(parseRgba(str));
},
};

/**
* Parse an input parameter as a URL.
*
* If it looks like a file path, it will be converted using `pathToFileURL`.
**/
Expand All @@ -19,7 +31,7 @@ export const Url: Type<string, URL> = {
};

/**
* Parse a input parameter as a URL which represents a folder.
* Parse an input parameter as a URL which represents a folder.
*
* If it looks like a file path, it will be converted using `pathToFileURL`.
* Any search parameters or hash will be removed, and a trailing slash added
Expand Down
5 changes: 5 additions & 0 deletions packages/cogify/src/cogify/stac.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createHash } from 'node:crypto';

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

Expand Down Expand Up @@ -56,7 +57,11 @@ export interface CogifyCreationOptions {
* @default 'lanczos'
*/
overviewResampling?: GdalResampling;

/** Color with which to replace all transparent COG pixels */
background?: Rgba;
}

export type GdalResampling = 'nearest' | 'bilinear' | 'cubic' | 'cubicspline' | 'lanczos' | 'average' | 'mode';

export type CogifyStacCollection = StacCollection;
Expand Down
6 changes: 6 additions & 0 deletions packages/cogify/src/tile.cover.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Rgba } from '@basemaps/config';
import { ConfigImageryTiff } from '@basemaps/config-loader';
import { BoundingBox, Bounds, EpsgCode, Projection, ProjectionLoader, TileId, TileMatrixSet } from '@basemaps/geo';
import { fsa, LogType, urlToString } from '@basemaps/shared';
Expand Down Expand Up @@ -32,6 +33,8 @@ export interface TileCoverContext {
logger?: LogType;
/** GDAL configuration preset */
preset: string;
/** Optional color with which to replace all transparent COG pixels */
background?: Rgba;
/**
* Override the base zoom to store the output COGS as
*/
Expand Down Expand Up @@ -180,6 +183,9 @@ export async function createTileCover(ctx: TileCoverContext): Promise<TileCoverR
assets: {},
};

// Add the background color if it exists
if (ctx.background) item.properties['linz_basemaps:options'].background = ctx.background;

// Add the source imagery as a STAC Link
for (const src of source) {
const srcLink: CogifyLinkSource = {
Expand Down
9 changes: 8 additions & 1 deletion packages/config/src/color.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,20 @@ export function parseHex(str: string): number {
return val;
}

export interface Rgba {
r: number;
g: number;
b: number;
alpha: number;
}

/**
* Parse a hexstring into RGBA
*
* Defaults to 0 if missing values
* @param str string to parse
*/
export function parseRgba(str: string): { r: number; g: number; b: number; alpha: number } {
export function parseRgba(str: string): Rgba {
if (str.startsWith('0x')) str = str.slice(2);
else if (str.startsWith('#')) str = str.slice(1);
if (str.length !== 6 && str.length !== 8) {
Expand Down
2 changes: 1 addition & 1 deletion packages/config/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export {
} from './base.config.js';
export { base58, isBase58 } from './base58.js';
export { ensureBase58, sha256base58 } from './base58.node.js';
export { parseHex, parseRgba } from './color.js';
export { parseHex, parseRgba, Rgba } from './color.js';
export { ConfigBase as BaseConfig } from './config/base.js';
export { ConfigBundle } from './config/config.bundle.js';
export { ConfigImagery, ConfigImageryOverview, ImageryBandType, ImageryDataType } from './config/imagery.js';
Expand Down

0 comments on commit b8bedc3

Please sign in to comment.