Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cogify): add background color support for overriding transparent pixels BM-1146 #3379

Merged
merged 17 commits into from
Jan 6, 2025
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 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,14 @@ 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,
name: undefined,
};

it('should generate a covering', async () => {
Expand All @@ -50,7 +51,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
2 changes: 1 addition & 1 deletion packages/cogify/src/cogify/cli/cli.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export const BasemapsCogifyConfigCommand = command({
const q = pLimit(args.concurrency);

metrics.start('imagery:load');
const im = await initImageryFromTiffUrl(args.path, q, undefined, logger);
const im = await initImageryFromTiffUrl(args.path, q, undefined, undefined, logger);
const ts = ConfigProviderMemory.imageryToTileSet(im) as ConfigTileSetRaster;
provider.put(im);
metrics.end('imagery:load');
Expand Down
15 changes: 13 additions & 2 deletions 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 { Rgba, Url, UrlFolder } from '../parsers.js';
import { createFileStats } from '../stac.js';

const SupportedTileMatrix = [GoogleTms, Nztm2000QuadTms];
Expand Down Expand Up @@ -62,14 +62,24 @@ export const BasemapsCogifyCoverCommand = command({
defaultValue: () => false,
defaultValueIsSerializable: true,
}),
name: option({
blacha marked this conversation as resolved.
Show resolved Hide resolved
type: optional(string),
long: 'name',
tawera-manaena marked this conversation as resolved.
Show resolved Hide resolved
description: 'Define the name of the output imagery',
}),
background: option({
type: optional(Rgba),
long: 'background',
description: 'Replace all transparent COG pixels with this RGBA hexstring color',
}),
},
async handler(args) {
const metrics = new Metrics();
const logger = getLogger(this, args);

const mem = new ConfigProviderMemory();
metrics.start('imagery:load');
const cfg = await initConfigFromUrls(mem, args.paths);
const cfg = await initConfigFromUrls(mem, args.paths, args.name);
const imageryLoadTime = metrics.end('imagery:load');
if (cfg.imagery.length === 0) throw new Error('No imagery found');
const im = cfg.imagery[0];
Expand All @@ -95,6 +105,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,3 +1,4 @@
import { RGBA } from '@basemaps/config';
import { Epsg, EpsgCode, TileMatrixSets } from '@basemaps/geo';
import { urlToString } from '@basemaps/shared';

Expand Down Expand Up @@ -97,3 +98,47 @@ 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 ((Math.log(size) / Math.log(size)) % 1 !== 0) {
tawera-manaena marked this conversation as resolved.
Show resolved Hide resolved
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 Rgba: 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
2 changes: 1 addition & 1 deletion packages/config-loader/src/json/json.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export class ConfigJson {
const id = ConfigId.prefix(ConfigPrefix.Imagery, imageId);
this.logger.trace({ url: url.href, imageId: id }, 'Imagery:Fetch');

const img = await initImageryFromTiffUrl(url, this.Q, this.imageryConfigCache, this.logger);
const img = await initImageryFromTiffUrl(url, this.Q, undefined, this.imageryConfigCache, this.logger);
img.id = id; // TODO could we use img.collection.id for this?

// TODO should we be overwriting the name and title when it is loaded from the STAC metadata?
Expand Down
6 changes: 4 additions & 2 deletions packages/config-loader/src/json/tiff.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ export async function loadTiffsFromPaths(sourceFiles: URL[], Q: LimitFunction):
export async function initImageryFromTiffUrl(
target: URL,
Q: LimitFunction,
name?: string,
configCache?: URL,
log?: LogType,
): Promise<ConfigImageryTiff> {
Expand All @@ -364,7 +365,7 @@ export async function initImageryFromTiffUrl(
if (stac == null) log?.warn({ target: target }, 'Tiff:StacNotFound');
const params = await computeTiffSummary(target, tiffs);

const imageryName = getImageryName(target);
const imageryName = name ? name : getImageryName(target);
const title = stac?.title ?? imageryName;
const tileMatrix =
params.projection === EpsgCode.Nztm2000 ? Nztm2000QuadTms : TileMatrixSets.tryGet(params.projection);
Expand Down Expand Up @@ -436,14 +437,15 @@ export async function initImageryFromTiffUrl(
export async function initConfigFromUrls(
provider: ConfigProviderMemory,
targets: URL[],
name?: string,
concurrency = 25,
configCache?: URL,
log?: LogType,
): Promise<{ tileSet: ConfigTileSetRaster; tileSets: ConfigTileSetRaster[]; imagery: ConfigImageryTiff[] }> {
const q = pLimit(concurrency);

const imageryConfig: Promise<ConfigImageryTiff>[] = [];
for (const target of targets) imageryConfig.push(initImageryFromTiffUrl(target, q, configCache, log));
for (const target of targets) imageryConfig.push(initImageryFromTiffUrl(target, q, name, configCache, log));

const aerialTileSet: ConfigTileSetRaster = {
id: 'ts_aerial',
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 {
blacha marked this conversation as resolved.
Show resolved Hide resolved
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
2 changes: 1 addition & 1 deletion packages/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export async function loadConfig(opts: ServerOptions, logger: LogType): Promise<
// Load the config directly from the source tiff files
if ('paths' in opts) {
const mem = new ConfigProviderMemory();
const ret = await initConfigFromUrls(mem, opts.paths, TiffLoadConcurrency, opts.configCache, logger);
const ret = await initConfigFromUrls(mem, opts.paths, undefined, TiffLoadConcurrency, opts.configCache, logger);
for (const ts of ret.tileSets) {
logger.info(
{ tileSet: ts.name, layers: ts.layers.length, outputs: ts.outputs?.map((f) => f.name) },
Expand Down
Loading